qmlkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. qmlkit/__init__.py +495 -0
  2. qmlkit/_aliases.py +135 -0
  3. qmlkit/algorithms/__init__.py +82 -0
  4. qmlkit/algorithms/adapt.py +297 -0
  5. qmlkit/algorithms/autoencoder.py +206 -0
  6. qmlkit/algorithms/chemistry.py +222 -0
  7. qmlkit/algorithms/clustering.py +149 -0
  8. qmlkit/algorithms/hamiltonians.py +143 -0
  9. qmlkit/algorithms/molecule.py +442 -0
  10. qmlkit/algorithms/qaoa.py +208 -0
  11. qmlkit/algorithms/rl.py +198 -0
  12. qmlkit/algorithms/vqe.py +198 -0
  13. qmlkit/ansatz/__init__.py +68 -0
  14. qmlkit/ansatz/blocks.py +348 -0
  15. qmlkit/ansatz/library.py +570 -0
  16. qmlkit/ansatz/reupload.py +168 -0
  17. qmlkit/baselines.py +604 -0
  18. qmlkit/budget.py +234 -0
  19. qmlkit/core/__init__.py +1 -0
  20. qmlkit/core/backends/__init__.py +22 -0
  21. qmlkit/core/backends/_sampling.py +43 -0
  22. qmlkit/core/backends/base.py +256 -0
  23. qmlkit/core/backends/cirq_backend.py +110 -0
  24. qmlkit/core/backends/cirq_density_backend.py +71 -0
  25. qmlkit/core/backends/noisy.py +86 -0
  26. qmlkit/core/backends/numpy_backend.py +276 -0
  27. qmlkit/core/backends/qiskit_aer_backend.py +79 -0
  28. qmlkit/core/backends/qiskit_backend.py +104 -0
  29. qmlkit/core/backends/registry.py +210 -0
  30. qmlkit/core/backends/spinqit_backend.py +233 -0
  31. qmlkit/core/backends/torch_backend.py +185 -0
  32. qmlkit/core/builder.py +189 -0
  33. qmlkit/core/execute.py +193 -0
  34. qmlkit/core/gates.py +243 -0
  35. qmlkit/core/ir.py +320 -0
  36. qmlkit/core/observables.py +269 -0
  37. qmlkit/datasets.py +178 -0
  38. qmlkit/diagnostics.py +719 -0
  39. qmlkit/draw.py +177 -0
  40. qmlkit/encoding/__init__.py +63 -0
  41. qmlkit/encoding/amplitude.py +178 -0
  42. qmlkit/encoding/angle.py +61 -0
  43. qmlkit/encoding/feature_maps.py +353 -0
  44. qmlkit/encoding/hamiltonian.py +206 -0
  45. qmlkit/encoding/pipeline.py +198 -0
  46. qmlkit/encoding/scaling.py +139 -0
  47. qmlkit/evaluate.py +686 -0
  48. qmlkit/fourier.py +124 -0
  49. qmlkit/generative.py +406 -0
  50. qmlkit/gradients/__init__.py +61 -0
  51. qmlkit/gradients/adjoint.py +138 -0
  52. qmlkit/gradients/batch.py +275 -0
  53. qmlkit/gradients/dispatch.py +247 -0
  54. qmlkit/gradients/hadamard.py +108 -0
  55. qmlkit/gradients/parameter_shift.py +142 -0
  56. qmlkit/gradients/rules.py +151 -0
  57. qmlkit/gradients/spsa.py +134 -0
  58. qmlkit/imbalance.py +335 -0
  59. qmlkit/info.py +153 -0
  60. qmlkit/interop.py +778 -0
  61. qmlkit/kernels/__init__.py +69 -0
  62. qmlkit/kernels/estimators.py +206 -0
  63. qmlkit/kernels/matrix.py +439 -0
  64. qmlkit/kernels/models.py +315 -0
  65. qmlkit/metrics.py +394 -0
  66. qmlkit/nn/__init__.py +18 -0
  67. qmlkit/nn/advanced.py +254 -0
  68. qmlkit/nn/layer.py +343 -0
  69. qmlkit/nn/losses.py +124 -0
  70. qmlkit/nn/models.py +245 -0
  71. qmlkit/optim.py +306 -0
  72. qmlkit/provenance.py +271 -0
  73. qmlkit/py.typed +0 -0
  74. qmlkit/search.py +561 -0
  75. qmlkit/shadows.py +117 -0
  76. qmlkit/utils/__init__.py +19 -0
  77. qmlkit/utils/errors.py +130 -0
  78. qmlkit/utils/shots.py +55 -0
  79. qmlkit-0.1.0.dist-info/METADATA +745 -0
  80. qmlkit-0.1.0.dist-info/RECORD +83 -0
  81. qmlkit-0.1.0.dist-info/WHEEL +4 -0
  82. qmlkit-0.1.0.dist-info/licenses/LICENSE +202 -0
  83. qmlkit-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,138 @@
1
+ r"""Adjoint differentiation — exact gradients in one backward pass.
2
+
3
+ Parameter-shift needs ``2P`` circuit evaluations because a real device can only be
4
+ *run*, never inspected. On a simulator the state is right there, so the whole
5
+ gradient comes out of a single forward and a single backward sweep — independent
6
+ of ``P``.
7
+
8
+ The sweep. With :math:`|\psi_j\rangle = U_j \cdots U_1 |0\rangle` and
9
+ :math:`E = \langle\psi_n| O |\psi_n\rangle`,
10
+
11
+ .. math:: \frac{\partial E}{\partial\theta_k}
12
+ = 2\,\mathrm{Re}\,\langle\lambda_k|\,\partial_k U_k\,|\psi_{k-1}\rangle,
13
+ \qquad \lambda_k = (U_n\cdots U_{k+1})^\dagger O |\psi_n\rangle
14
+
15
+ so walking backwards and undoing one gate at a time keeps both states current at
16
+ ``O(1)`` extra memory.
17
+
18
+ This is exact — the gate derivatives are closed-form, not finite differences — but
19
+ it is **simulator-only**, because no device will hand you an amplitude. Since
20
+ ``0.x`` is simulator-only anyway, it is the right default for training;
21
+ parameter-shift remains what the course teaches, what validates this, and what
22
+ keeps the library hardware-ready.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+ import numpy as np
30
+ import numpy.typing as npt
31
+
32
+ from qmlkit.core.backends.base import Backend
33
+ from qmlkit.core.backends.numpy_backend import _apply
34
+ from qmlkit.core.backends.registry import get_backend
35
+ from qmlkit.core.gates import gate_derivative, gate_matrix
36
+ from qmlkit.core.ir import CircuitSpec, ParamRef
37
+ from qmlkit.core.observables import Observable, PauliString, Z, as_sum
38
+
39
+ __all__ = ["adjoint_grad", "supports_adjoint"]
40
+
41
+ _PAULI = {
42
+ "I": np.eye(2, dtype=complex),
43
+ "X": np.array([[0, 1], [1, 0]], dtype=complex),
44
+ "Y": np.array([[0, -1j], [1j, 0]], dtype=complex),
45
+ "Z": np.array([[1, 0], [0, -1]], dtype=complex),
46
+ }
47
+
48
+
49
+ def supports_adjoint(spec: CircuitSpec, backend: Backend | str | None = None) -> bool:
50
+ """True if every parameterised gate in ``spec`` has a closed-form derivative."""
51
+ from qmlkit.core.gates import get_gate
52
+
53
+ be = get_backend(backend)
54
+ if not be.supports_statevector:
55
+ return False
56
+ return all(get_gate(s.gate).has_derivative for s in spec.slots())
57
+
58
+
59
+ def _apply_observable(state: npt.NDArray[Any], obs: Observable, n_qubits: int) -> npt.NDArray[Any]:
60
+ """``O|psi>`` for a Pauli sum, in tensor shape."""
61
+ total = np.zeros_like(state)
62
+ for term in as_sum(obs).terms:
63
+ out = state
64
+ for q, p in term.paulis:
65
+ if p == "I":
66
+ continue
67
+ out = np.tensordot(_PAULI[p], out, axes=([1], [q]))
68
+ out = np.moveaxis(out, 0, q)
69
+ total = total + term.coeff * out
70
+ return total
71
+
72
+
73
+ def adjoint_grad(
74
+ spec: CircuitSpec,
75
+ theta: npt.NDArray[Any],
76
+ obs: Observable | None = None,
77
+ backend: Backend | str | None = None,
78
+ ) -> npt.NDArray[Any]:
79
+ """Exact gradient of ``<obs>`` with respect to the logical parameter vector.
80
+
81
+ One forward pass and one backward pass, whatever ``P`` is. Weight-tied
82
+ parameters accumulate across their occurrences, exactly as parameter-shift does.
83
+ """
84
+ obs = Z(0) if obs is None else obs
85
+ be = get_backend(backend)
86
+ if not be.supports_statevector:
87
+ raise ValueError(
88
+ f"the {be.name!r} backend has no statevector, so it cannot differentiate by "
89
+ 'the adjoint method; use grad_method="parameter-shift"'
90
+ )
91
+
92
+ theta = np.asarray(theta, dtype=float).ravel()
93
+ slots = spec.slots()
94
+ slot_angles = spec.bind_slots(theta)
95
+ n = spec.n_qubits
96
+ shape = (2,) * n
97
+
98
+ # forward: run the bound circuit once
99
+ psi = be.statevector(spec.with_slot_angles(slot_angles)).reshape(shape)
100
+ lam = _apply_observable(psi, obs, n)
101
+
102
+ grad = np.zeros(spec.n_params, dtype=float)
103
+ slot_of_op = {s.op_index: i for i, s in enumerate(slots)}
104
+
105
+ # backward: undo one gate at a time, reading off each parameter's contribution
106
+ cursor = len(slot_angles)
107
+ for op_index in range(len(spec.ops) - 1, -1, -1):
108
+ op = spec.ops[op_index]
109
+ slot_i = slot_of_op.get(op_index)
110
+ if slot_i is not None:
111
+ cursor -= 1
112
+ angles = [float(slot_angles[cursor])]
113
+ else:
114
+ angles = [float(p) for p in op.params if not isinstance(p, ParamRef)]
115
+
116
+ u = gate_matrix(op.gate, tuple(angles))
117
+ psi = _apply(psi, u.conj().T, op.qubits) # psi is now the state before this gate
118
+
119
+ if slot_i is not None:
120
+ du = gate_derivative(op.gate, tuple(angles))
121
+ mu = _apply(psi, du, op.qubits)
122
+ contribution = 2.0 * float(np.real(np.vdot(lam, mu)))
123
+ ref = slots[slot_i].ref
124
+ grad[ref.index] += contribution * ref.scale # += ties occurrences together
125
+
126
+ lam = _apply(lam, u.conj().T, op.qubits)
127
+
128
+ return grad
129
+
130
+
131
+ def adjoint_grad_terms(
132
+ spec: CircuitSpec,
133
+ theta: npt.NDArray[Any],
134
+ obs: Observable,
135
+ backend: Backend | str | None = None,
136
+ ) -> dict[PauliString, npt.NDArray[Any]]: # pragma: no cover - convenience for diagnostics
137
+ """Per-term gradients, for diagnosing which observable term drives a parameter."""
138
+ return {t: adjoint_grad(spec, theta, t, backend) for t in as_sum(obs).terms}
@@ -0,0 +1,275 @@
1
+ r"""Gradients for a whole training batch, in one pass rather than one per sample.
2
+
3
+ A training step evaluates the same circuit *structure* at one parameter vector per
4
+ sample. The forward pass has been batched for a while; the backward pass had not, and
5
+ the backward pass is where the time goes — measured on a 4-qubit ``VQC`` at batch 128,
6
+ the forward pass was 13 ms of an 1170 ms step. **99% of training was still running one
7
+ sample at a time.**
8
+
9
+ Two routes, and the difference between them is the point of this module.
10
+
11
+ :func:`param_shift_grad_batch` is **backend-agnostic**. A shift rule only ever needs
12
+ the circuit *run* at shifted angles, so a whole batch's gradient is one big set of
13
+ evaluations — ``batch x 2P`` of them — with no inspection of the state at all. It goes
14
+ through :meth:`~qmlkit.core.backends.base.Backend.expectation_over_slots`, which every
15
+ backend has, so this works on NumPy, Qiskit, Cirq, SpinQit, a sampling-only device, and
16
+ anything registered later. On hardware it is exactly the batched submission a provider
17
+ wants: one job instead of ``batch x 2P`` blocking calls.
18
+
19
+ :func:`adjoint_grad_batch` is faster and simulator-only, because it reads the state.
20
+
21
+ Both are exact, and both are asserted equal to the per-sample functions they replace.
22
+
23
+ The shifts happen in **slot** space, not logical-parameter space, which is why the
24
+ batch primitives underneath are slot-based. A shift rule moves one *occurrence* of a
25
+ parameter and a weight-tied parameter has several; a batched routine written against
26
+ logical parameters cannot express that, and would silently compute a different
27
+ derivative for any circuit with shared weights.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Any
33
+
34
+ import numpy as np
35
+ import numpy.typing as npt
36
+ from numpy.typing import ArrayLike
37
+
38
+ from qmlkit.core.backends.base import Backend
39
+ from qmlkit.core.backends.numpy_backend import (
40
+ _VECTORISED_D,
41
+ _apply_batch,
42
+ _batched_matrices,
43
+ )
44
+ from qmlkit.core.backends.registry import get_backend
45
+ from qmlkit.core.gates import gate_derivative
46
+ from qmlkit.core.ir import CircuitSpec, Op
47
+ from qmlkit.core.observables import Observable, Z, as_sum
48
+ from qmlkit.gradients.rules import rule_for_gate
49
+
50
+ __all__ = ["grad_batch", "param_shift_grad_batch", "adjoint_grad_batch"]
51
+
52
+ _PAULI = {
53
+ "I": np.eye(2, dtype=complex),
54
+ "X": np.array([[0, 1], [1, 0]], dtype=complex),
55
+ "Y": np.array([[0, -1j], [1j, 0]], dtype=complex),
56
+ "Z": np.array([[1, 0], [0, -1]], dtype=complex),
57
+ }
58
+
59
+
60
+ # --------------------------------------------------------------------------- #
61
+ # the backend-agnostic route
62
+ # --------------------------------------------------------------------------- #
63
+ def param_shift_grad_batch(
64
+ spec: CircuitSpec,
65
+ thetas: ArrayLike,
66
+ obs: Observable | None = None,
67
+ shots: int | None = None,
68
+ backend: Backend | str | None = None,
69
+ seed: int | None = None,
70
+ ) -> npt.NDArray[Any]:
71
+ """Parameter-shift gradients for a batch, as **one** set of evaluations.
72
+
73
+ Returns ``(batch, n_params)``.
74
+
75
+ Every shifted circuit the whole batch needs is assembled first and handed to the
76
+ backend in a single call, so a backend that can evaluate many circuits at once —
77
+ or submit them as one job — gets to. Nothing here inspects a state, so this is
78
+ valid on hardware and under sampling.
79
+
80
+ Weight-tied parameters accumulate across occurrences and rescaled references get
81
+ their chain-rule factor, exactly as the per-sample
82
+ :func:`~qmlkit.gradients.parameter_shift.param_shift_grad` does.
83
+ """
84
+ obs = Z(0) if obs is None else obs
85
+ be = get_backend(backend)
86
+ angles = spec.bind_slots_batch(np.atleast_2d(np.asarray(thetas, dtype=float)))
87
+ batch = angles.shape[0]
88
+ slots = spec.slots()
89
+ if not slots:
90
+ return np.zeros((batch, spec.n_params), dtype=float)
91
+
92
+ # ---- assemble every shifted row the batch needs, once ------------------ #
93
+ blocks: list[npt.NDArray[Any]] = []
94
+ plan: list[tuple[int, float]] = [] # (slot index, coefficient)
95
+ needs_unshifted = False
96
+ for i, slot in enumerate(slots):
97
+ rule = rule_for_gate(slot.gate)
98
+ for shift, coeff in zip(rule.shifts, rule.coeffs, strict=False):
99
+ shifted = angles.copy()
100
+ shifted[:, i] += shift
101
+ blocks.append(shifted)
102
+ plan.append((i, coeff))
103
+ needs_unshifted = needs_unshifted or rule.needs_unshifted
104
+ if needs_unshifted:
105
+ blocks.append(angles)
106
+
107
+ values = be.expectation_over_slots(
108
+ spec, np.concatenate(blocks, axis=0), obs, shots, seed
109
+ ).reshape(len(blocks), batch)
110
+
111
+ # ---- fold the evaluations back into per-parameter gradients ------------ #
112
+ grad = np.zeros((batch, spec.n_params), dtype=float)
113
+ for k, (i, coeff) in enumerate(plan):
114
+ ref = slots[i].ref
115
+ grad[:, ref.index] += coeff * values[k] * ref.scale
116
+ if needs_unshifted:
117
+ base = values[-1]
118
+ for slot in slots:
119
+ rule = rule_for_gate(slot.gate)
120
+ if rule.needs_unshifted:
121
+ grad[:, slot.ref.index] += rule.unshifted_coeff * base * slot.ref.scale
122
+ return grad
123
+
124
+
125
+ def param_shift_batch_cost(spec: CircuitSpec, batch: int) -> int:
126
+ """Circuit evaluations a batched parameter-shift gradient submits, in total."""
127
+ per_sample = sum(rule_for_gate(s.gate).n_evaluations for s in spec.slots())
128
+ return per_sample * batch
129
+
130
+
131
+ # --------------------------------------------------------------------------- #
132
+ # the simulator route
133
+ # --------------------------------------------------------------------------- #
134
+ def _apply_observable_batch(
135
+ state: npt.NDArray[Any], obs: Observable, n_qubits: int
136
+ ) -> npt.NDArray[Any]:
137
+ """``O|psi>`` for a stack of states, Pauli term by Pauli term."""
138
+ total = np.zeros_like(state)
139
+ for term in as_sum(obs).terms:
140
+ out = state
141
+ for q, p in term.paulis:
142
+ if p == "I":
143
+ continue
144
+ out = np.tensordot(_PAULI[p], out, axes=([1], [q + 1]))
145
+ out = np.moveaxis(out, 0, q + 1)
146
+ total = total + term.coeff * out
147
+ return total
148
+
149
+
150
+ def _dagger(matrices: npt.NDArray[Any]) -> npt.NDArray[Any]:
151
+ """Conjugate transpose, whether the array is one matrix or a stack of them."""
152
+ if matrices.ndim == 2:
153
+ return matrices.conj().T
154
+ return matrices.conj().transpose(0, 2, 1)
155
+
156
+
157
+ def _batched_derivatives(op: Op, columns: npt.NDArray[Any]) -> npt.NDArray[Any]:
158
+ """``dU/dtheta`` per row, in closed form where one is registered.
159
+
160
+ Profiling the batched adjoint put four times as much time in building these one
161
+ row at a time as in the contraction they feed, which is why the vectorised table
162
+ exists. A gate outside it still works, one build per row.
163
+ """
164
+ builder = _VECTORISED_D.get(op.gate)
165
+ if builder is not None and columns.shape[1] == 1:
166
+ return builder(columns[:, 0])
167
+ return np.stack([gate_derivative(op.gate, tuple(row)) for row in columns])
168
+
169
+
170
+ def adjoint_grad_batch(
171
+ spec: CircuitSpec,
172
+ thetas: ArrayLike,
173
+ obs: Observable | None = None,
174
+ backend: Backend | str | None = None,
175
+ ) -> npt.NDArray[Any]:
176
+ """Adjoint gradients for a batch in one forward and one backward sweep.
177
+
178
+ Returns ``(batch, n_params)``. Exact, and independent of ``P`` in cost, but it
179
+ needs the statevector — so it is simulator-only, and
180
+ :func:`param_shift_grad_batch` is what a device uses.
181
+
182
+ The sweep is the one in :mod:`~qmlkit.gradients.adjoint` with the batch carried as
183
+ a leading axis: every gate is undone for the whole stack at once, and each
184
+ parameter's contribution comes out as one inner product per row.
185
+ """
186
+ obs = Z(0) if obs is None else obs
187
+ be = get_backend(backend)
188
+ if not be.supports_statevector:
189
+ raise ValueError(
190
+ f"the {be.name!r} backend has no statevector, so it cannot differentiate by "
191
+ 'the adjoint method; use method="parameter-shift", which batches too'
192
+ )
193
+
194
+ values = np.atleast_2d(np.asarray(thetas, dtype=float))
195
+ angles = spec.bind_slots_batch(values)
196
+ batch, n = angles.shape[0], spec.n_qubits
197
+ slots = spec.slots()
198
+
199
+ psi = be.statevector_batch_slots(spec, angles).reshape((batch,) + (2,) * n)
200
+ lam = _apply_observable_batch(psi, obs, n)
201
+ grad = np.zeros((batch, spec.n_params), dtype=float)
202
+
203
+ # walk the slots backwards in step with the ops, the way the scalar version does
204
+ cursor = len(slots)
205
+ for op_index in range(len(spec.ops) - 1, -1, -1):
206
+ op = spec.ops[op_index]
207
+ first = cursor
208
+ while first > 0 and slots[first - 1].op_index == op_index:
209
+ first -= 1
210
+ columns = angles[:, first:cursor] if first < cursor else None
211
+ cursor = first
212
+
213
+ u = _batched_matrices(op, columns)
214
+ psi = _apply_batch(psi, _dagger(u), op.qubits) # the state before this gate
215
+
216
+ if columns is not None and columns.shape[1] == 1:
217
+ du = _batched_derivatives(op, columns)
218
+ mu = _apply_batch(psi, du, op.qubits)
219
+ overlap = np.einsum("bi,bi->b", lam.reshape(batch, -1).conj(), mu.reshape(batch, -1))
220
+ ref = slots[first].ref
221
+ grad[:, ref.index] += 2.0 * np.real(overlap) * ref.scale
222
+ elif columns is not None and columns.shape[1] > 1: # pragma: no cover
223
+ raise NotImplementedError(
224
+ f"gate {op.gate!r} has {columns.shape[1]} parameters; batched adjoint "
225
+ 'handles one per gate. Use method="parameter-shift".'
226
+ )
227
+
228
+ lam = _apply_batch(lam, _dagger(u), op.qubits)
229
+ return grad
230
+
231
+
232
+ # --------------------------------------------------------------------------- #
233
+ # dispatch
234
+ # --------------------------------------------------------------------------- #
235
+ def grad_batch(
236
+ spec: CircuitSpec,
237
+ thetas: ArrayLike,
238
+ obs: Observable | None = None,
239
+ method: str = "auto",
240
+ shots: int | None = None,
241
+ backend: Backend | str | None = None,
242
+ seed: int | None = None,
243
+ ) -> npt.NDArray[Any]:
244
+ """``(batch, n_params)`` gradients, by whichever route fits the backend.
245
+
246
+ ``"auto"`` picks adjoint on an exact simulator and parameter-shift otherwise —
247
+ the same rule :func:`~qmlkit.gradients.dispatch.choose_method` uses for one
248
+ sample, since sampling puts the statevector out of reach by definition.
249
+
250
+ >>> import numpy as np, qmlkit as qk
251
+ >>> a = qk.hardware_efficient(3, 2)
252
+ >>> qk.grad_batch(a.build(), np.zeros((5, a.n_params)), qk.Z(0)).shape
253
+ (5, 12)
254
+ """
255
+ if method == "auto":
256
+ from qmlkit.gradients.adjoint import supports_adjoint
257
+
258
+ method = (
259
+ "adjoint" if shots is None and supports_adjoint(spec, backend) else "parameter-shift"
260
+ )
261
+ if method == "adjoint":
262
+ if shots is not None:
263
+ raise ValueError("adjoint differentiation is exact; it cannot take shots")
264
+ return adjoint_grad_batch(spec, thetas, obs, backend)
265
+ if method == "parameter-shift":
266
+ return param_shift_grad_batch(spec, thetas, obs, shots, backend, seed)
267
+
268
+ from qmlkit.utils.errors import unknown
269
+
270
+ raise unknown(
271
+ "batched gradient method",
272
+ method,
273
+ ("auto", "adjoint", "parameter-shift"),
274
+ hint=" Other methods have no batched form yet; loop over qk.grad for those.",
275
+ )
@@ -0,0 +1,247 @@
1
+ """One gradient function, several methods, and a registry for adding more.
2
+
3
+ qk.grad(spec, theta, obs) # picks the right method for you
4
+ qk.grad(spec, theta, obs, method="parameter-shift")
5
+
6
+ ``method="auto"`` (the default) uses adjoint when every gate has a closed-form
7
+ derivative and the backend can hand back a statevector — exact, and independent of
8
+ the parameter count. It falls back to parameter-shift otherwise, which is exact
9
+ too, just ``2P`` circuits instead of one pass.
10
+
11
+ A researcher with a different estimator registers it and it becomes a keyword
12
+ everywhere the library takes ``method=``:
13
+
14
+ @register_gradient("my_estimator")
15
+ def my_estimator(spec, theta, obs, *, backend=None, shots=None, **kw):
16
+ ...
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable, Sequence
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+ import numpy.typing as npt
26
+ from numpy.typing import ArrayLike
27
+
28
+ from qmlkit.core.backends.base import Backend
29
+ from qmlkit.core.ir import CircuitSpec
30
+ from qmlkit.core.observables import Observable, Z
31
+ from qmlkit.utils.errors import unknown
32
+
33
+ __all__ = [
34
+ "grad",
35
+ "register_gradient",
36
+ "list_gradient_methods",
37
+ "choose_method",
38
+ "hessian",
39
+ "gradient_cost",
40
+ ]
41
+
42
+ GradFn = Callable[..., npt.NDArray[Any]]
43
+ _METHODS: dict[str, GradFn] = {}
44
+
45
+
46
+ def register_gradient(name: str, fn: GradFn | None = None) -> Callable[[GradFn], GradFn] | GradFn:
47
+ """Register a gradient estimator under ``name``. Usable as a decorator."""
48
+
49
+ def _register(f: GradFn) -> GradFn:
50
+ if name in _METHODS:
51
+ raise ValueError(f"gradient method {name!r} is already registered")
52
+ _METHODS[name] = f
53
+ return f
54
+
55
+ return _register if fn is None else _register(fn)
56
+
57
+
58
+ def list_gradient_methods() -> tuple[str, ...]:
59
+ return tuple(sorted(_METHODS))
60
+
61
+
62
+ def choose_method(
63
+ spec: CircuitSpec, backend: Backend | str | None = None, shots: int | None = None
64
+ ) -> str:
65
+ """What ``method="auto"`` resolves to, and why.
66
+
67
+ Sampling means the statevector is off the table by definition, so shots force
68
+ parameter-shift.
69
+ """
70
+ if shots is not None:
71
+ return "parameter-shift"
72
+ from qmlkit.gradients.adjoint import supports_adjoint
73
+
74
+ return "adjoint" if supports_adjoint(spec, backend) else "parameter-shift"
75
+
76
+
77
+ def grad(
78
+ spec: CircuitSpec,
79
+ theta: ArrayLike,
80
+ obs: Observable | None = None,
81
+ method: str = "auto",
82
+ backend: Backend | str | None = None,
83
+ shots: int | None = None,
84
+ **kwargs: object,
85
+ ) -> npt.NDArray[Any]:
86
+ """Gradient of ``<obs>`` with respect to ``theta``.
87
+
88
+ Parameters
89
+ ----------
90
+ method
91
+ ``auto`` (default), ``adjoint``, ``parameter-shift``, ``spsa``, or
92
+ ``finite-diff``. Registered methods are accepted by name too.
93
+ shots
94
+ Sampling budget. Anything other than ``None`` rules out adjoint.
95
+ """
96
+ obs = Z(0) if obs is None else obs
97
+ resolved = choose_method(spec, backend, shots) if method == "auto" else method
98
+ try:
99
+ fn = _METHODS[resolved]
100
+ except KeyError:
101
+ raise unknown(
102
+ "gradient method",
103
+ resolved,
104
+ list_gradient_methods(),
105
+ hint='"auto" chooses for you; add your own with register_gradient(name, fn).',
106
+ error=KeyError,
107
+ ) from None
108
+ return fn(spec, np.asarray(theta, dtype=float), obs, backend=backend, shots=shots, **kwargs)
109
+
110
+
111
+ # --------------------------------------------------------------------------- #
112
+ # the built-in methods
113
+ # --------------------------------------------------------------------------- #
114
+ @register_gradient("adjoint")
115
+ def _adjoint(spec, theta, obs, *, backend=None, shots=None, **kw): # type: ignore[no-untyped-def]
116
+ from qmlkit.gradients.adjoint import adjoint_grad
117
+
118
+ if shots is not None:
119
+ raise ValueError(
120
+ "adjoint differentiation reads the statevector, so it cannot honour a shot "
121
+ 'budget; use method="parameter-shift" with shots=N'
122
+ )
123
+ return adjoint_grad(spec, theta, obs, backend)
124
+
125
+
126
+ @register_gradient("parameter-shift")
127
+ def _param_shift(spec, theta, obs, *, backend=None, shots=None, seed=None, **kw): # type: ignore[no-untyped-def]
128
+ from qmlkit.gradients.parameter_shift import param_shift_grad_circuit
129
+
130
+ return param_shift_grad_circuit(spec, theta, obs, shots=shots, backend=backend, seed=seed)
131
+
132
+
133
+ @register_gradient("finite-diff")
134
+ def _finite_diff(spec, theta, obs, *, backend=None, shots=None, eps=1e-6, **kw): # type: ignore[no-untyped-def]
135
+ from qmlkit.core.execute import expval
136
+ from qmlkit.gradients.parameter_shift import finite_diff_grad
137
+
138
+ def f(t: npt.NDArray[Any]) -> float:
139
+ return expval(spec, obs, theta=t, shots=shots, backend=backend)
140
+
141
+ return finite_diff_grad(f, theta, eps=eps)
142
+
143
+
144
+ @register_gradient("spsa")
145
+ def _spsa(spec, theta, obs, *, backend=None, shots=None, c=0.1, n_avg=1, seed=None, **kw): # type: ignore[no-untyped-def]
146
+ from qmlkit.core.execute import expval
147
+ from qmlkit.gradients.spsa import spsa_grad
148
+
149
+ def f(t: npt.NDArray[Any]) -> float:
150
+ return expval(spec, obs, theta=t, shots=shots, backend=backend, seed=seed)
151
+
152
+ return spsa_grad(f, theta, c=c, n_avg=n_avg, seed=seed)
153
+
154
+
155
+ @register_gradient("hadamard")
156
+ def _hadamard(spec, theta, obs, *, backend=None, shots=None, seed=None, **kw): # type: ignore[no-untyped-def]
157
+ from qmlkit.gradients.hadamard import hadamard_grad
158
+
159
+ return hadamard_grad(spec, theta, obs, backend=backend, shots=shots, seed=seed)
160
+
161
+
162
+ @register_gradient("backprop")
163
+ def _backprop(spec, theta, obs, *, backend=None, shots=None, **kw): # type: ignore[no-untyped-def]
164
+ """Differentiate the torch statevector simulator directly.
165
+
166
+ This method needs an autograd-traced statevector, so it always evaluates on the
167
+ torch simulator whichever statevector backend is selected — the number is the same
168
+ because they agree, but the *device* is not honoured. On a backend that has no
169
+ statevector at all that difference stops being cosmetic: returning a machine-precision
170
+ gradient computed on a simulator, to a caller who asked for one from hardware, is
171
+ exactly the plausible-wrong-number failure this library exists to refuse. So it is
172
+ refused, the same way ``adjoint`` is.
173
+ """
174
+ from qmlkit.core.backends.base import BackendNotAvailable
175
+ from qmlkit.core.backends.registry import get_backend
176
+
177
+ device = get_backend(backend)
178
+ if not device.supports_statevector:
179
+ raise ValueError(
180
+ f"the {device.name!r} backend has no statevector, so it cannot differentiate "
181
+ 'by backprop; use method="parameter-shift", which is measurement-only and '
182
+ "batches into one submission"
183
+ )
184
+
185
+ try:
186
+ import torch
187
+ except ImportError as exc: # pragma: no cover - depends on the environment
188
+ # `backprop` is registered unconditionally, so it shows up in
189
+ # list_gradient_methods() on a bare install too. Anyone iterating those
190
+ # methods deserves the install command rather than a raw ModuleNotFoundError.
191
+ raise BackendNotAvailable(
192
+ "the backprop gradient needs PyTorch:\n pip install 'qmlkit[torch]'"
193
+ ) from exc
194
+
195
+ from qmlkit.core.backends.torch_backend import torch_expectation
196
+
197
+ if shots is not None:
198
+ raise ValueError(
199
+ "backprop differentiates the statevector, so it cannot honour a shot "
200
+ 'budget; use method="parameter-shift" with shots=N'
201
+ )
202
+ t = torch.tensor(np.asarray(theta, dtype=float), dtype=torch.float64, requires_grad=True)
203
+ torch_expectation(spec, t, obs).backward()
204
+ assert t.grad is not None
205
+ return t.grad.detach().cpu().numpy()
206
+
207
+
208
+ def hessian(
209
+ spec: CircuitSpec,
210
+ theta: Sequence[float],
211
+ obs: Observable | None = None,
212
+ backend: Backend | str | None = None,
213
+ eps: float = 1e-4,
214
+ ) -> npt.NDArray[Any]:
215
+ """Second derivatives, by differencing the exact gradient.
216
+
217
+ The gradient itself is exact (adjoint), so only the outer derivative is
218
+ approximated — far more accurate than differencing the expectation twice.
219
+ """
220
+ obs = Z(0) if obs is None else obs
221
+ arr = np.asarray(theta, dtype=float).ravel()
222
+ p = arr.size
223
+ out = np.zeros((p, p))
224
+ for k in range(p):
225
+ plus, minus = arr.copy(), arr.copy()
226
+ plus[k] += eps
227
+ minus[k] -= eps
228
+ out[k] = (
229
+ grad(spec, plus, obs, backend=backend) - grad(spec, minus, obs, backend=backend)
230
+ ) / (2 * eps)
231
+ return 0.5 * (out + out.T) # symmetrise away the differencing asymmetry
232
+
233
+
234
+ def gradient_cost(spec: CircuitSpec, method: str = "parameter-shift") -> int | str:
235
+ """Circuit evaluations one gradient needs under a given method."""
236
+ from qmlkit.gradients.hadamard import hadamard_grad_cost
237
+ from qmlkit.gradients.parameter_shift import grad_circuit_cost
238
+
239
+ costs = {
240
+ "adjoint": 1,
241
+ "backprop": 1,
242
+ "hadamard": hadamard_grad_cost(spec),
243
+ "parameter-shift": grad_circuit_cost(spec),
244
+ "finite-diff": 2 * spec.n_params,
245
+ "spsa": 2,
246
+ }
247
+ return costs.get(method, "unknown")