Gradients¶
Six estimators behind one grad(), plus the shift-rule machinery they share.
qmlkit.gradients.batch¶
Gradients for a whole training batch in one pass. param_shift_grad_batch never
inspects a state, so it works on every backend including a sampling-only device, and
is the batched submission real hardware wants.
batch ¶
Gradients for a whole training batch, in one pass rather than one per sample.
A training step evaluates the same circuit structure at one parameter vector per
sample. The forward pass has been batched for a while; the backward pass had not, and
the backward pass is where the time goes — measured on a 4-qubit VQC at batch 128,
the forward pass was 13 ms of an 1170 ms step. 99% of training was still running one
sample at a time.
Two routes, and the difference between them is the point of this module.
:func:param_shift_grad_batch is backend-agnostic. A shift rule only ever needs
the circuit run at shifted angles, so a whole batch's gradient is one big set of
evaluations — batch x 2P of them — with no inspection of the state at all. It goes
through :meth:~qmlkit.core.backends.base.Backend.expectation_over_slots, which every
backend has, so this works on NumPy, Qiskit, Cirq, SpinQit, a sampling-only device, and
anything registered later. On hardware it is exactly the batched submission a provider
wants: one job instead of batch x 2P blocking calls.
:func:adjoint_grad_batch is faster and simulator-only, because it reads the state.
Both are exact, and both are asserted equal to the per-sample functions they replace.
The shifts happen in slot space, not logical-parameter space, which is why the batch primitives underneath are slot-based. A shift rule moves one occurrence of a parameter and a weight-tied parameter has several; a batched routine written against logical parameters cannot express that, and would silently compute a different derivative for any circuit with shared weights.
param_shift_grad_batch ¶
param_shift_grad_batch(
spec: CircuitSpec,
thetas: ArrayLike,
obs: Observable | None = None,
shots: int | None = None,
backend: Backend | str | None = None,
seed: int | None = None,
) -> NDArray[Any]
Parameter-shift gradients for a batch, as one set of evaluations.
Returns (batch, n_params).
Every shifted circuit the whole batch needs is assembled first and handed to the backend in a single call, so a backend that can evaluate many circuits at once — or submit them as one job — gets to. Nothing here inspects a state, so this is valid on hardware and under sampling.
Weight-tied parameters accumulate across occurrences and rescaled references get
their chain-rule factor, exactly as the per-sample
:func:~qmlkit.gradients.parameter_shift.param_shift_grad does.
Source code in src/qmlkit/gradients/batch.py
param_shift_batch_cost ¶
param_shift_batch_cost(
spec: CircuitSpec, batch: int
) -> int
Circuit evaluations a batched parameter-shift gradient submits, in total.
Source code in src/qmlkit/gradients/batch.py
adjoint_grad_batch ¶
adjoint_grad_batch(
spec: CircuitSpec,
thetas: ArrayLike,
obs: Observable | None = None,
backend: Backend | str | None = None,
) -> NDArray[Any]
Adjoint gradients for a batch in one forward and one backward sweep.
Returns (batch, n_params). Exact, and independent of P in cost, but it
needs the statevector — so it is simulator-only, and
:func:param_shift_grad_batch is what a device uses.
The sweep is the one in :mod:~qmlkit.gradients.adjoint with the batch carried as
a leading axis: every gate is undone for the whole stack at once, and each
parameter's contribution comes out as one inner product per row.
Source code in src/qmlkit/gradients/batch.py
grad_batch ¶
grad_batch(
spec: CircuitSpec,
thetas: ArrayLike,
obs: Observable | None = None,
method: str = "auto",
shots: int | None = None,
backend: Backend | str | None = None,
seed: int | None = None,
) -> NDArray[Any]
(batch, n_params) gradients, by whichever route fits the backend.
"auto" picks adjoint on an exact simulator and parameter-shift otherwise —
the same rule :func:~qmlkit.gradients.dispatch.choose_method uses for one
sample, since sampling puts the statevector out of reach by definition.
>>> import numpy as np, qmlkit as qk
>>> a = qk.hardware_efficient(3, 2)
>>> qk.grad_batch(a.build(), np.zeros((5, a.n_params)), qk.Z(0)).shape
(5, 12)
Source code in src/qmlkit/gradients/batch.py
qmlkit.gradients.dispatch¶
dispatch ¶
One gradient function, several methods, and a registry for adding more.
qk.grad(spec, theta, obs) # picks the right method for you
qk.grad(spec, theta, obs, method="parameter-shift")
method="auto" (the default) uses adjoint when every gate has a closed-form
derivative and the backend can hand back a statevector — exact, and independent of
the parameter count. It falls back to parameter-shift otherwise, which is exact
too, just 2P circuits instead of one pass.
A researcher with a different estimator registers it and it becomes a keyword
everywhere the library takes method=:
@register_gradient("my_estimator")
def my_estimator(spec, theta, obs, *, backend=None, shots=None, **kw):
...
register_gradient ¶
Register a gradient estimator under name. Usable as a decorator.
Source code in src/qmlkit/gradients/dispatch.py
choose_method ¶
choose_method(
spec: CircuitSpec,
backend: Backend | str | None = None,
shots: int | None = None,
) -> str
What method="auto" resolves to, and why.
Sampling means the statevector is off the table by definition, so shots force parameter-shift.
Source code in src/qmlkit/gradients/dispatch.py
grad ¶
grad(
spec: CircuitSpec,
theta: ArrayLike,
obs: Observable | None = None,
method: str = "auto",
backend: Backend | str | None = None,
shots: int | None = None,
**kwargs: object,
) -> NDArray[Any]
Gradient of <obs> with respect to theta.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
method
|
str
|
|
'auto'
|
shots
|
int | None
|
Sampling budget. Anything other than |
None
|
Source code in src/qmlkit/gradients/dispatch.py
hessian ¶
hessian(
spec: CircuitSpec,
theta: Sequence[float],
obs: Observable | None = None,
backend: Backend | str | None = None,
eps: float = 0.0001,
) -> NDArray[Any]
Second derivatives, by differencing the exact gradient.
The gradient itself is exact (adjoint), so only the outer derivative is approximated — far more accurate than differencing the expectation twice.
Source code in src/qmlkit/gradients/dispatch.py
gradient_cost ¶
gradient_cost(
spec: CircuitSpec, method: str = "parameter-shift"
) -> int | str
Circuit evaluations one gradient needs under a given method.
Source code in src/qmlkit/gradients/dispatch.py
qmlkit.gradients.rules¶
rules ¶
Shift rules, derived rather than remembered.
A circuit expectation as a function of one gate angle is a finite Fourier series whose frequencies are the unique positive gaps between the eigenvalues of that gate's generator:
.. math:: f(\theta) = a_0 + \sum_{k} a_k \cos(\omega_k \theta) + b_k \sin(\omega_k \theta)
We want coefficients :math:c_i and shifts :math:s_i with
:math:\sum_i c_i f(\theta + s_i) = f'(\theta) for every such :math:f.
Expanding and matching the :math:\cos(\omega\theta) and :math:\sin(\omega\theta)
terms gives, for each frequency :math:\omega:
.. math:: \sum_i c_i \cos(\omega s_i) = 0, \qquad \sum_i c_i \sin(\omega s_i) = \omega
Choosing antisymmetric shifts :math:\{+s_1, -s_1, \dots\} with antisymmetric
coefficients satisfies the cosine equation and :math:\sum_i c_i = 0 identically,
leaving an :math:R \times R linear system in the positive half. We solve it.
Deriving the rule instead of hardcoding constants means a new gate needs only its
frequencies declared — no new gradient code, and no chance of a transcribed
constant being subtly wrong. :func:four_term_rule reproduces the textbook
controlled-rotation constants, and the test suite asserts exactly that.
ShiftRule
dataclass
¶
f'(theta) = sum_i coeffs[i] * f(theta + shifts[i]) (+ an unshifted term).
general_shift_rule ¶
general_shift_rule(
frequencies: Sequence[float],
shifts: Sequence[float] | None = None,
) -> ShiftRule
Build the exact shift rule for a generator with these frequencies.
Source code in src/qmlkit/gradients/rules.py
rule_for_frequencies
cached
¶
rule_for_frequencies(
frequencies: tuple[float, ...],
) -> ShiftRule
Cached :func:general_shift_rule — rules are pure functions of the spectrum.
four_term_rule ¶
four_term_rule() -> ShiftRule
Controlled rotations: generator eigenvalues {0, 0, +-1/2} => frequencies {1/2, 1}.
rule_for_gate ¶
rule_for_gate(gate: str) -> ShiftRule
Look the rule up from the gate's declared generator frequencies.
This per-gate lookup is the whole point: a circuit mixing ry with crz
needs two different rules, and applying one uniform rule to both returns a
plausible, wrong gradient with no error raised.
Source code in src/qmlkit/gradients/rules.py
second_derivative_rule ¶
second_derivative_rule(
frequencies: tuple[float, ...] = (1.0,),
) -> ShiftRule
Diagonal Hessian rule. For one frequency: f'' = (f(theta+pi) - f(theta)) / 2.
Source code in src/qmlkit/gradients/rules.py
qmlkit.gradients.parameter_shift¶
parameter_shift ¶
The parameter-shift rule — exact gradients from measurements alone.
Two things here are easy to get wrong, and both fail silently — a plausible number, no exception:
Per-gate rules. The shift rule is a property of the gate's generator, not of
the call. A circuit mixing ry (one frequency, two-term rule) with crz (two
frequencies, four-term rule) needs both. We look the rule up per slot.
Per-occurrence shifting. When one logical parameter drives several gates —
weight tying, as in a QCNN's shared convolution block — the derivative is the sum
over occurrences, each shifted on its own. Shifting them together computes
something else. The slot abstraction in :mod:qmlkit.core.ir makes this fall out
naturally: several slots simply map back to the same parameter index.
param_shift_grad ¶
param_shift_grad(
f_slots: SlotFn,
spec: CircuitSpec,
theta: Sequence[float],
rules: dict[int, ShiftRule] | None = None,
f0: float | None = None,
) -> NDArray[Any]
Exact gradient of f with respect to the logical parameter vector.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f_slots
|
SlotFn
|
Evaluates the circuit given slot angles — one angle per parameterised gate site. Taking slot angles rather than the logical vector is what makes per-occurrence shifting expressible at all. |
required |
spec
|
CircuitSpec
|
Supplies the slot map and each slot's gate (hence its shift rule). |
required |
theta
|
Sequence[float]
|
The logical parameter vector. |
required |
rules
|
dict[int, ShiftRule] | None
|
Optional |
None
|
f0
|
float | None
|
The unshifted value, if you already have it. Unused by the standard
two-term rule; required by rules with |
None
|
Source code in src/qmlkit/gradients/parameter_shift.py
param_shift_grad_circuit ¶
param_shift_grad_circuit(
spec: CircuitSpec,
theta: Sequence[float],
obs: Observable | None = None,
shots: int | None = None,
backend: BackendLike = None,
seed: int | None = None,
) -> NDArray[Any]
Convenience wrapper: parameter-shift gradient of <obs> for a circuit.
Source code in src/qmlkit/gradients/parameter_shift.py
grad_circuit_cost ¶
grad_circuit_cost(spec: CircuitSpec) -> int
Circuit evaluations for one full parameter-shift gradient.
Sums each slot's real rule cost. This is deliberately not a flat 2P:
a controlled rotation costs four evaluations, not two, and a weight-tied
parameter costs its rule once per occurrence.
Source code in src/qmlkit/gradients/parameter_shift.py
finite_diff_grad ¶
finite_diff_grad(
f: Callable[[NDArray[Any]], float],
theta: Sequence[float],
eps: float = 1e-06,
mode: str = "central",
) -> NDArray[Any]
Finite differences. For debugging and tests only — never for training.
Carries an O(eps**2) bias and amplifies any sampling noise by 1/eps,
which is why there is no good choice of eps on a noisy device.
Source code in src/qmlkit/gradients/parameter_shift.py
qmlkit.gradients.adjoint¶
adjoint ¶
Adjoint differentiation — exact gradients in one backward pass.
Parameter-shift needs 2P circuit evaluations because a real device can only be
run, never inspected. On a simulator the state is right there, so the whole
gradient comes out of a single forward and a single backward sweep — independent
of P.
The sweep. With :math:|\psi_j\rangle = U_j \cdots U_1 |0\rangle and
:math:E = \langle\psi_n| O |\psi_n\rangle,
.. math:: \frac{\partial E}{\partial\theta_k} = 2\,\mathrm{Re}\,\langle\lambda_k|\,\partial_k U_k\,|\psi_{k-1}\rangle, \qquad \lambda_k = (U_n\cdots U_{k+1})^\dagger O |\psi_n\rangle
so walking backwards and undoing one gate at a time keeps both states current at
O(1) extra memory.
This is exact — the gate derivatives are closed-form, not finite differences — but
it is simulator-only, because no device will hand you an amplitude. Since
0.x is simulator-only anyway, it is the right default for training;
parameter-shift remains what the course teaches, what validates this, and what
keeps the library hardware-ready.
supports_adjoint ¶
supports_adjoint(
spec: CircuitSpec, backend: Backend | str | None = None
) -> bool
True if every parameterised gate in spec has a closed-form derivative.
Source code in src/qmlkit/gradients/adjoint.py
adjoint_grad ¶
adjoint_grad(
spec: CircuitSpec,
theta: NDArray[Any],
obs: Observable | None = None,
backend: Backend | str | None = None,
) -> NDArray[Any]
Exact gradient of <obs> with respect to the logical parameter vector.
One forward pass and one backward pass, whatever P is. Weight-tied
parameters accumulate across their occurrences, exactly as parameter-shift does.
Source code in src/qmlkit/gradients/adjoint.py
adjoint_grad_terms ¶
adjoint_grad_terms(
spec: CircuitSpec,
theta: NDArray[Any],
obs: Observable,
backend: Backend | str | None = None,
) -> dict[PauliString, NDArray[Any]]
Per-term gradients, for diagnosing which observable term drives a parameter.
Source code in src/qmlkit/gradients/adjoint.py
qmlkit.gradients.hadamard¶
hadamard ¶
Hadamard-test gradient — one circuit per parameter instead of two.
For :math:U_k = e^{-i\theta_k P_k/2} inserted at position :math:k, write
:math:|\varphi\rangle for the state you get by applying :math:P_k right after
:math:U_k. Then
.. math:: \partial_k E = -\,\mathrm{Im}\,\langle\varphi| O |\psi\rangle
and that imaginary part is exactly what a Hadamard test reads out. Put an ancilla in
:math:|+\rangle, run the circuit with a controlled :math:P_k inserted after
gate :math:k, and measure :math:\langle Y_a \otimes O\rangle:
.. code-block:: text
|+>_a ----------●---------- <Y_a (x) O>
|0>_n -- U_1..U_k -- P_k -- U_{k+1}..U_L --
Half the circuits of parameter-shift, and unlike adjoint it is a real measurement, so it is valid on hardware. The trade is an ancilla that must couple to every wire the generator touches, plus controlled gates. On real devices that routing cost usually outweighs the saved circuits, which is why parameter-shift stays the default there.
On a simulator the halving does show up in wall-clock: 5 qubits, P=120, two-term
observable gives 404 ms against parameter-shift's 823 ms. Getting that required
passing the lifted observable as a single sum rather than term by term — the ancilla
already doubles the statevector, so re-preparing it per term would have handed the
whole advantage straight back.
Only single-qubit Pauli rotations are supported: their generators are Paulis, so the
controlled form is a cx/cy/cz. A controlled rotation's generator is not a
Pauli, and this method refuses it rather than guessing.
supports_hadamard_grad ¶
supports_hadamard_grad(spec: CircuitSpec) -> bool
hadamard_grad ¶
hadamard_grad(
spec: CircuitSpec,
theta: NDArray[Any],
obs: Observable | None = None,
backend: BackendLike = None,
shots: int | None = None,
seed: int | None = None,
) -> NDArray[Any]
Exact gradient using one extra qubit and one circuit per parameter.
Source code in src/qmlkit/gradients/hadamard.py
hadamard_grad_cost ¶
hadamard_grad_cost(spec: CircuitSpec) -> int
qmlkit.gradients.spsa¶
spsa ¶
SPSA — a gradient estimate in two evaluations, whatever P is.
Parameter-shift costs 2P circuits. SPSA perturbs every parameter at once
along a random :math:\pm 1 direction and costs two, forever:
.. math:: \hat{g} = \frac{f(\theta + c\Delta) - f(\theta - c\Delta)}{2c}\,\Delta^{-1}
The estimate is noisy but unbiased in expectation, and stochastic optimisers
tolerate that well — which is why it is the standard answer once P gets large
enough that 2P circuits per step stops being affordable.
The decay schedules are Spall's. A, the stability constant, is the one people
leave out: without it the first steps are far too large, and the run diverges
before the schedule has a chance to settle it. Roughly 10 % of the planned
iteration count is the usual choice.
SPSASchedule ¶
SPSASchedule(
a: float = 0.2,
c: float = 0.1,
A: float | None = None,
alpha: float = 0.602,
gamma: float = 0.101,
n_iterations: int = 100,
)
Spall's decay schedules for the step size and the perturbation size.
Source code in src/qmlkit/gradients/spsa.py
spsa_grad ¶
spsa_grad(
f: LossFn,
theta: ArrayLike,
c: float = 0.1,
n_avg: int = 1,
seed: int | None = None,
rng: Generator | None = None,
) -> NDArray[Any]
A stochastic gradient estimate from 2 * n_avg evaluations.
n_avg averages several random directions, trading evaluations for variance
— still constant in P.
Source code in src/qmlkit/gradients/spsa.py
spsa_step ¶
spsa_step(
f: LossFn,
theta: ArrayLike,
k: int,
schedule: SPSASchedule | None = None,
rng: Generator | None = None,
) -> NDArray[Any]
One SPSA update at iteration k.
Source code in src/qmlkit/gradients/spsa.py
minimize_spsa ¶
minimize_spsa(
f: LossFn,
theta0: ArrayLike,
n_iterations: int = 100,
schedule: SPSASchedule | None = None,
seed: int | None = None,
callback: Callable[[int, NDArray[Any], float], None]
| None = None,
) -> tuple[NDArray[Any], list[float]]
Minimise f with SPSA. Returns the final parameters and the loss history.
Two evaluations per iteration regardless of how many parameters there are.