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.
- qmlkit/__init__.py +495 -0
- qmlkit/_aliases.py +135 -0
- qmlkit/algorithms/__init__.py +82 -0
- qmlkit/algorithms/adapt.py +297 -0
- qmlkit/algorithms/autoencoder.py +206 -0
- qmlkit/algorithms/chemistry.py +222 -0
- qmlkit/algorithms/clustering.py +149 -0
- qmlkit/algorithms/hamiltonians.py +143 -0
- qmlkit/algorithms/molecule.py +442 -0
- qmlkit/algorithms/qaoa.py +208 -0
- qmlkit/algorithms/rl.py +198 -0
- qmlkit/algorithms/vqe.py +198 -0
- qmlkit/ansatz/__init__.py +68 -0
- qmlkit/ansatz/blocks.py +348 -0
- qmlkit/ansatz/library.py +570 -0
- qmlkit/ansatz/reupload.py +168 -0
- qmlkit/baselines.py +604 -0
- qmlkit/budget.py +234 -0
- qmlkit/core/__init__.py +1 -0
- qmlkit/core/backends/__init__.py +22 -0
- qmlkit/core/backends/_sampling.py +43 -0
- qmlkit/core/backends/base.py +256 -0
- qmlkit/core/backends/cirq_backend.py +110 -0
- qmlkit/core/backends/cirq_density_backend.py +71 -0
- qmlkit/core/backends/noisy.py +86 -0
- qmlkit/core/backends/numpy_backend.py +276 -0
- qmlkit/core/backends/qiskit_aer_backend.py +79 -0
- qmlkit/core/backends/qiskit_backend.py +104 -0
- qmlkit/core/backends/registry.py +210 -0
- qmlkit/core/backends/spinqit_backend.py +233 -0
- qmlkit/core/backends/torch_backend.py +185 -0
- qmlkit/core/builder.py +189 -0
- qmlkit/core/execute.py +193 -0
- qmlkit/core/gates.py +243 -0
- qmlkit/core/ir.py +320 -0
- qmlkit/core/observables.py +269 -0
- qmlkit/datasets.py +178 -0
- qmlkit/diagnostics.py +719 -0
- qmlkit/draw.py +177 -0
- qmlkit/encoding/__init__.py +63 -0
- qmlkit/encoding/amplitude.py +178 -0
- qmlkit/encoding/angle.py +61 -0
- qmlkit/encoding/feature_maps.py +353 -0
- qmlkit/encoding/hamiltonian.py +206 -0
- qmlkit/encoding/pipeline.py +198 -0
- qmlkit/encoding/scaling.py +139 -0
- qmlkit/evaluate.py +686 -0
- qmlkit/fourier.py +124 -0
- qmlkit/generative.py +406 -0
- qmlkit/gradients/__init__.py +61 -0
- qmlkit/gradients/adjoint.py +138 -0
- qmlkit/gradients/batch.py +275 -0
- qmlkit/gradients/dispatch.py +247 -0
- qmlkit/gradients/hadamard.py +108 -0
- qmlkit/gradients/parameter_shift.py +142 -0
- qmlkit/gradients/rules.py +151 -0
- qmlkit/gradients/spsa.py +134 -0
- qmlkit/imbalance.py +335 -0
- qmlkit/info.py +153 -0
- qmlkit/interop.py +778 -0
- qmlkit/kernels/__init__.py +69 -0
- qmlkit/kernels/estimators.py +206 -0
- qmlkit/kernels/matrix.py +439 -0
- qmlkit/kernels/models.py +315 -0
- qmlkit/metrics.py +394 -0
- qmlkit/nn/__init__.py +18 -0
- qmlkit/nn/advanced.py +254 -0
- qmlkit/nn/layer.py +343 -0
- qmlkit/nn/losses.py +124 -0
- qmlkit/nn/models.py +245 -0
- qmlkit/optim.py +306 -0
- qmlkit/provenance.py +271 -0
- qmlkit/py.typed +0 -0
- qmlkit/search.py +561 -0
- qmlkit/shadows.py +117 -0
- qmlkit/utils/__init__.py +19 -0
- qmlkit/utils/errors.py +130 -0
- qmlkit/utils/shots.py +55 -0
- qmlkit-0.1.0.dist-info/METADATA +745 -0
- qmlkit-0.1.0.dist-info/RECORD +83 -0
- qmlkit-0.1.0.dist-info/WHEEL +4 -0
- qmlkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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")
|