Extending qmlkit¶
Almost every extension point is a registry. Register something and it becomes reachable by name everywhere the library takes one — no subclassing, no plugin manifest, no coordination with anything else, and no fork.
| I want to change | Use | And it becomes reachable from |
|---|---|---|
| The circuit shape | register_ansatz — or build an Ansatz inline |
get_ansatz, qk.search's ansatz= axis, compare_ansatze |
| A gate the library lacks | register_gate |
every circuit, every backend, every gradient method |
| The two-qubit block inside a QCNN | register_conv_filter |
conv_block, qcnn_ansatz, mps_ansatz, tree_tensor_network |
| How data becomes angles | register_feature_map |
qk.search's feature_map= axis |
| How gradients are estimated | register_gradient |
method= anywhere, including QuantumLayer |
| Where circuits run | register_backend |
backend=, QMLKIT_BACKEND, backend_report() |
| What the classical bar is | register_baseline |
qk.baseline's comparison table |
| Reading circuits in from elsewhere | register_importer |
get_importer, list_importers |
Two things are extended without a registry, and that is deliberate — see a new optimiser and a new algorithm.
A new ansatz¶
import qmlkit as qk
@qk.register_ansatz("my_ladder")
def my_ladder(n_qubits, n_layers=2):
block = qk.RotationLayer(("ry", "rz")) + qk.EntanglerLayer("cx", "chain")
return qk.Ansatz(n_qubits, qk.repeat(n_layers, block), "my_ladder")
ansatz = qk.get_ansatz("my_ladder", n_qubits=3, n_layers=2)
print(ansatz)
print(qk.draw(ansatz.build()))
It now has correct gradients, resource counting, drawing, an AnsatzReport, and a
QuantumLayer — none of which you wrote. The parameter count is inferred from a dry
build, so there is nothing to miscount.
A new gate¶
A gate needs a matrix. Declare its generator frequencies and parameter-shift works on it; add a derivative matrix and adjoint differentiation works too.
import numpy as np
import qmlkit as qk
def _sqrt_x(_=None):
return 0.5 * np.array([[1 + 1j, 1 - 1j], [1 - 1j, 1 + 1j]], dtype=complex)
qk.register_gate(qk.GateDef("sx", n_qubits=1, n_params=0, matrix=_sqrt_x))
qc = qk.QCircuit(1)
qc.apply("sx", 0)
print(np.round(qk.statevector(qc.to_spec()), 4))
For a parameterised gate, the two optional fields are what unlock differentiation:
import numpy as np
import qmlkit as qk
def _rzz(theta):
return np.diag([np.exp(-0.5j * theta), np.exp(0.5j * theta),
np.exp(0.5j * theta), np.exp(-0.5j * theta)])
def _d_rzz(theta):
return np.diag([-0.5j * np.exp(-0.5j * theta), 0.5j * np.exp(0.5j * theta),
0.5j * np.exp(0.5j * theta), -0.5j * np.exp(-0.5j * theta)])
qk.register_gate(qk.GateDef(
"rzz", n_qubits=2, n_params=1,
matrix=_rzz,
frequencies=(1.0,), # -> a correct 2-term shift rule, derived not transcribed
dmatrix=_d_rzz, # -> adjoint differentiation
))
qc = qk.QCircuit(2)
qc.h(0).apply("rzz", (0, 1), qk.ParamRef(0))
spec, theta = qc.to_spec(), np.array([0.7])
shift = qk.grad(spec, theta, qk.X(0), method="parameter-shift")
adj = qk.grad(spec, theta, qk.X(0), method="adjoint")
print(f"parameter-shift {shift[0]:+.10f}")
print(f"adjoint {adj[0]:+.10f}")
print(f"agree to {abs(shift[0] - adj[0]):.2e}")
Getting the same number from two independent routes is the check worth making on any gate you add — see The parameter-shift rule for why the frequencies matter.
Gate registration is global
The registry is process-wide, so a gate registered in a test is visible to every later test. If you register throwaway gates, snapshot the registry rather than reading it live — the parity suite learned this the hard way.
How far does your gate travel?¶
The obvious worry: Qiskit and Cirq have their own gate tables, and your gate is not in
either. So what happens when you ask for backend="qiskit"?
It works. Neither SDK has heard of your gate, so it is emitted as its matrix —
UnitaryGate on Qiskit, MatrixGate on Cirq — built by calling the matrix= you
registered. Nothing else about your circuit changes, and the result agrees with the
NumPy reference to machine precision.
# docs: skip
qk.register_gate(qk.GateDef("xy", n_qubits=2, n_params=1, matrix=xy, frequencies=(1.0,)))
qk.statevector(spec, backend="qiskit") # UnitaryGate, exact
qk.statevector(spec, backend="cirq") # MatrixGate, exact
qk.grad(spec, theta, qk.Z(1), method="parameter-shift", backend="qiskit")
The subtlety this hides is qubit order, and it is the kind that does not raise. qmlkit
is big-endian and Qiskit is little-endian, so to_qiskit already maps qubit i to
n-1-i — but a raw matrix carries its qubit order in its basis rather than in its
wire list, so the basis has to be reversed as well or a two-qubit gate on (a, b)
quietly acts as though it were on (b, a). Cirq needs no reversal at all, being
big-endian like qmlkit. Rather than reason about that, the cross-backend suite asserts
it against the NumPy reference over ascending, descending and non-adjacent wire
orders, and over a three-qubit custom gate.
| Backend | A registered gate |
|---|---|
numpy · qiskit · cirq · aer |
works, to machine precision |
cirq-density · qiskit-aer |
works — they inherit the same translation |
spinqit |
refuses. Its builder takes named gates, not an arbitrary matrix |
torch |
refuses for backprop. It differentiates through the gate, which needs a torch-native form; your matrix= is NumPy and no gradient flows through it. parameter-shift works, because a shift rule never inspects a state |
Both refusals say that, and what to do instead, rather than telling you to edit the library.
A new gradient estimator¶
import numpy as np
import qmlkit as qk
@qk.register_gradient("forward_diff")
def forward_diff(spec, theta, obs, *, backend=None, shots=None, eps=1e-6, **kwargs):
base = qk.expval(spec, obs, theta=theta, backend=backend, shots=shots)
out = np.zeros(spec.n_params)
for k in range(spec.n_params):
step = np.zeros_like(theta)
step[k] = eps
out[k] = (qk.expval(spec, obs, theta=theta + step, backend=backend, shots=shots) - base) / eps
return out
ansatz = qk.hardware_efficient(2, 1)
spec, theta = ansatz.build(), ansatz.init(seed=0)
mine = qk.grad(spec, theta, qk.Z(0), method="forward_diff")
exact = qk.grad(spec, theta, qk.Z(0), method="adjoint")
print(f"max deviation from adjoint: {np.abs(mine - exact).max():.2e}")
The signature is fixed: (spec, theta, obs, *, backend, shots, **kwargs), returning
an array of length spec.n_params. Anything else you need arrives through kwargs,
and callers pass it straight through qk.grad(..., your_kwarg=...).
A new backend¶
Subclass Backend and implement one method — statevector. The base class
supplies the measurement semantics: sampling, basis rotation, expectation values,
seeded counts. That is deliberate: if every backend re-implemented those, agreement
between them would be a coincidence rather than a property.
import numpy as np
import qmlkit as qk
from qmlkit.core.backends.base import Backend
class MirrorBackend(Backend):
"""The NumPy reference, but proving the extension point works."""
name = "mirror"
supports_statevector = True
supports_exact = True
def statevector(self, spec):
self._check_bound(spec)
return qk.get_backend("numpy").statevector(spec)
qk.register_backend("mirror", MirrorBackend)
qc = qk.QCircuit(2)
qc.h(0).cx(0, 1)
spec = qc.to_spec()
print(np.round(qk.statevector(spec, backend="mirror"), 4))
print("agrees with numpy:", np.allclose(
qk.statevector(spec, backend="mirror"), qk.statevector(spec, backend="numpy")))
If you add a real backend, the thing to run is tests/test_cross_backend.py — it
parametrises over every installed backend automatically, so yours is covered the
moment it is registered.
A device, which cannot hand you a state¶
A real QPU has no statevector and no shot-free expectation. It can run a circuit and report bitstrings, and that is also one method:
import numpy as np
import qmlkit as qk
from qmlkit.core.backends.base import Backend
class Device(Backend):
"""Everything a QPU is, and nothing it is not."""
name = "example_device"
supports_statevector = False # no amplitudes
supports_exact = False # no shot-free expectation
def counts(self, spec, shots, seed=None):
self._check_bound(spec)
# a real provider would submit the circuit here
probabilities = np.abs(qk.get_backend("numpy").statevector(spec)) ** 2
rng = np.random.default_rng(0 if seed is None else seed)
drawn = rng.multinomial(shots, probabilities / probabilities.sum())
return {format(i, f"0{spec.n_qubits}b"): int(n) for i, n in enumerate(drawn) if n}
device = Device()
ansatz = qk.hardware_efficient(3, 2)
spec, theta = ansatz.build(), ansatz.init(seed=0)
observable = qk.Z(0) + 0.5 * qk.ZZ(0, 2)
print(f"sampled expectation {qk.expectation(spec, observable, theta, shots=4096, backend=device):+.3f}")
What that one method gets you¶
Everything above it, derived once in the base class:
thetas = np.random.default_rng(0).uniform(-np.pi, np.pi, (5, ansatz.n_params))
values = qk.expectation_over(spec, thetas, observable, shots=4096, backend=device)
print(f"batched expectations {values.shape}")
gradients = qk.grad_batch(spec, thetas, observable,
method="parameter-shift", shots=4096, backend=device)
print(f"batched gradients {gradients.shape}")
kernel = qk.QuantumKernel(qk.AngleFeatureMap(3), shots=4096, backend=device, seed=0)
print(f"Gram matrix {kernel(np.random.default_rng(1).uniform(0, np.pi, (4, 3))).shape}")
Qubit-wise-commuting grouping comes with it, so a four-term observable diagonal in Z
costs one circuit rather than four — on a device, where circuit count is the binding
constraint, that is the difference between a feasible run and an infeasible one.
param_shift_grad_batch matters most here. A shift rule only ever needs the circuit
run at shifted angles, so a whole batch's gradient is one set of evaluations with no
state inspection anywhere — which on hardware is a single job submission instead of
batch x 2P blocking calls.
And what it refuses¶
for method in ("adjoint", "backprop"):
try:
qk.grad(spec, theta, observable, method=method, backend=device)
except ValueError as error:
print(f"{method}: {str(error)[:70]}...")
Both need the statevector, so both refuse and name parameter-shift instead. So does
exact mode:
try:
qk.expectation(spec, observable, theta, backend=device)
except ValueError as error:
print(error)
That is the half of "backend-agnostic" that matters. Anything can run everywhere; the
useful property is that what cannot work is refused by name rather than silently
computed on a simulator and handed back looking perfect. backprop was doing exactly
that until it was caught by writing this section.
What is still missing for real hardware¶
The 0.x line ships no device backend, and the protocol supporting one is a different
claim from having one. examples/toward_hardware.py runs a mock QPU end to end and
states the four gaps in the order they would bite: batched submission (now largely
in place — expectation_over_slots is the call a provider would turn into a job),
transpilation and routing, error mitigation, and asynchronous jobs. The
last is the one that would still change the Backend protocol.
A new optimiser¶
There is no register_optimizer, because there is nothing to look up — OPTIMIZERS is
an open dict and an optimiser is just a function:
# docs: skip
from qmlkit.algorithms.vqe import OPTIMIZERS
def my_optimiser(loss, theta0, *, grad, n_steps=200, lr=0.1):
theta = np.asarray(theta0, dtype=float).copy()
history = [float(loss(theta))]
for _ in range(n_steps):
theta = theta - lr * grad(theta) # qk.grad, exact, not a difference
history.append(float(loss(theta)))
return theta, history # the contract: (theta, history)
OPTIMIZERS["mine"] = my_optimiser
VQE(hamiltonian, n_qubits=3, optimizer="mine").run(n_steps=50, lr=0.05)
The contract is fn(loss, theta0, **kw) -> (theta, history). VQE, QAOA and
AdaptVQE all take optimizer= as either a name from that dict or the function
itself, so you do not have to register anything to use one.
If your optimiser needs the gradient, the caller has to inject it
Each solver decides which optimisers get grad= passed in. Naming only some of
them there is how optimizer="adam" came to raise TypeError: _adam() missing 1
required keyword-only argument: 'grad' from all three algorithms that advertised
it — a documented option that had never run. tests/test_optimizer_wiring.py now
parametrises over OPTIMIZERS itself, so a new entry cannot be added without
being covered.
Each optimiser also spells its own iteration count (n_sweeps, n_iterations,
n_steps), because run(**optimizer_kwargs) passes them straight through.
A new algorithm¶
There is no register_algorithm either, and this one is worth explaining because it
looks like an omission.
A registry exists so the library can look something up on your behalf — a gate name
inside a circuit, a method= string, a backend= string. An algorithm is the outermost
layer. Nothing inside qmlkit needs to find your VQE by name; you call it. So an
algorithm is not a plugin, it is a loop:
Each one is a loop over machinery that already exists — ansatz, gradients, optimisers, observables — so each is thin, and every structural choice it makes is an argument rather than something baked in.
—
qmlkit/algorithms/__init__.py
VQE is 200 lines and the shape is worth copying:
# docs: skip
@dataclass(frozen=True)
class MyResult:
value: float
theta: np.ndarray
history: list[float]
exact: float | None = None # what it was checked against
class MyAlgorithm:
def __init__(self, hamiltonian, n_qubits, *, ansatz=None,
optimizer="rotosolve", backend=None, shots=None):
self.ansatz = ansatz or qk.hardware_efficient(n_qubits, 2) # default, not hardcode
def cost(self, theta): ... # one qk.expectation call
def gradient_of_cost(self, theta): ... # one qk.grad call
def run(self, seed=None, compare_exact=None, **optimizer_kwargs) -> MyResult: ...
Four things make it belong here rather than merely work:
Take the structure as an argument, and actually use it. tests/test_injection.py
injects two ansätze of different parameter counts and asserts the model's count
follows — because a constructor that accepts ansatz= and quietly ignores it looks
identical from the outside.
Check yourself against something exact while you still can. VQE, QAOA and
AdaptVQE diagonalise densely when it is affordable (12 qubits or fewer by default)
and report error_vs_exact. A variational algorithm converging confidently on the
wrong answer is the ordinary failure here, not the exotic one.
Refuse, or warn, when a component's assumptions do not hold. QAOA consults
supports_rotosolve(spec) and warns when the optimiser is invalid for the circuit it
just built: Rotosolve assumes each angle drives a single sinusoid, and QAOA's cost angle
drives one rz per edge — five frequencies on a five-edge problem, measured. It
converges immediately, on the wrong point, and reports it as a result.
Use the exact gradient. qk.grad already gives you one on any circuit and
observable. Finite differences are for debugging and tests, and say so in their own
docstring.
Where to put it¶
If it is your own research, it does not need to be in the library at all — import qmlkit, write your loop, keep it in your project. That is what the three layers are for, and nothing is gained by vendoring it in.
If it is genuinely general, it goes in src/qmlkit/algorithms/, exported from that
package's __init__, with a reference page entry and a changelog entry. And register
the pieces it introduces — a gate, an ansatz shape, a filter, an estimator — because
those are the parts other people's code will want to reach by name.