Running under noise¶
qmlkit is simulator-only, but a simulator does not have to be a perfect one. Two backends evolve a density matrix and take a noise model, so you can ask what a circuit does on a device that makes mistakes.
| Backend | Simulator | Noise model | Install |
|---|---|---|---|
cirq-density |
cirq.DensityMatrixSimulator |
any cirq channel or cirq.NoiseModel |
pip install 'qmlkit[cirq]' |
qiskit-aer |
AerSimulator(method="density_matrix") |
qiskit_aer.noise.NoiseModel, including NoiseModel.from_backend(...) |
pip install 'qmlkit[aer]' |
qiskit-aer is a separate distribution from qiskit: installing the Qiskit backend
does not give you this one. SpinQit has no noisy simulator — its noisy path is the
real NMR hardware, which 0.x does not target.
You always name the backend¶
Noise never selects a simulator for you.
# docs: requires cirq
import cirq
import qmlkit as qk
backend = qk.get_backend("cirq-density", noise=cirq.depolarize(0.01))
Asking for noise without naming a mixed-state backend is an error, not a guess:
# docs: requires cirq
try:
qk.get_backend(noise=cirq.depolarize(0.01))
except ValueError as exc:
print(str(exc).splitlines()[0])
# noise was given, but the default backend evolves a pure state and cannot carry it.
This is deliberate. A mixed-state run costs more, refuses two of the gradient methods, and answers a different question — so which simulator produced a number should be visible in the code that produced it, not inferred from a global default.
Noise and shot noise are separate, and stay separate¶
shots=None on a noisy backend is not a contradiction. The density matrix is
evolved exactly; what is exact is the answer given the noise model.
# docs: requires cirq
import numpy as np
spec = qk.angle_encode([0.7])
noisy = qk.get_backend("cirq-density", noise=cirq.depolarize(0.05))
exact_pure = qk.expectation(spec, qk.Z(0), backend="numpy")
exact_noisy = qk.expectation(spec, qk.Z(0), backend=noisy)
sampled = qk.expectation(spec, qk.Z(0), backend=noisy, shots=10_000, seed=0)
print(f"no noise, no shots {exact_pure:+.4f}") # cos(0.7)
print(f"noise, no shots {exact_noisy:+.4f}") # cos(0.7) * (1 - 4p/3)
print(f"noise and shots {sampled:+.4f}")
Being able to turn one off is the point. Decoherence and sampling error both pull a
number around, and studying either one with the other layered on top means never
knowing which you are looking at. Ask for shots=N when you want both — that is
what a device would give you.
With no noise model at all, these backends reproduce the pure-state ones to machine
precision. That is not a trivial case; it is what makes the noisy numbers
trustworthy, and tests/test_noisy_backends.py asserts it across circuits,
observables and both SDKs.
What is refused¶
There is no statevector, so the two gradient methods that differentiate one decline:
# docs: requires cirq
ansatz = qk.hardware_efficient(2, 2)
theta = np.linspace(0.1, 1.2, ansatz.n_params)
try:
qk.grad(ansatz.build(), theta, qk.Z(0), method="adjoint", backend=noisy)
except ValueError as exc:
print(str(exc)[:60])
Refusing is the whole point. adjoint and backprop would have quietly
differentiated a noiseless circuit and handed back a machine-precision gradient to
someone who asked about a noisy one — a number that is wrong in a way no assertion
would catch.
parameter-shift works, because a shift rule never inspects a state — it evaluates
the same circuit at shifted angles, which is exactly what a device does:
# docs: requires cirq
g = qk.grad(ansatz.build(), theta, qk.Z(0), method="parameter-shift", backend=noisy)
print(g.shape)
qk.grad_batch routes through the same path, so a batched training step works under
noise too.
What noise does to trainability¶
This is the number worth knowing before you spend a week on an experiment. A 3-qubit, 3-layer hardware-efficient ansatz, gradient norm averaged over 8 random parameter vectors, against depolarizing strength:
p |
mean ‖∇‖ | vs noiseless | purity |
|---|---|---|---|
| 0 | 0.9145 | 1.000 | 1.000 |
| 0.005 | 0.8257 | 0.903 | 0.753 |
| 0.01 | 0.7425 | 0.812 | 0.585 |
| 0.02 | 0.5052 | 0.552 | 0.334 |
| 0.05 | 0.3243 | 0.355 | 0.151 |
| 0.1 | 0.1270 | 0.139 | 0.126 |
The gradient direction survives — it correlates above 0.99 with the noiseless one at
p = 0.05 — but the magnitude collapses. That is the failure mode: the cost
landscape flattens toward the maximally mixed state, and it flattens faster with
depth. Past some point the gradient is smaller than the standard error of the shot
budget you can afford, and training stops working for a reason no amount of tuning
fixes.
qk.plan() will tell you the standard error your shot budget buys. Comparing it to
the gradient norm above is the arithmetic that decides whether an experiment is
feasible at all.
# docs: requires cirq
print(f"purity under 5% depolarizing: {noisy.purity(ansatz.build(theta)):.4f}")
purity() is the cheapest single number that says how much the noise model actually
did. 1.0 is a pure state; 1/2**n is maximally mixed.
Traps¶
A fidelity kernel loses its unit diagonal. k(x, x) = 1 is a fact about a
noiseless compute-uncompute circuit. Under noise the circuit does not return to
|0⟩, so the diagonal drops below one — and anything that fills the diagonal in by
assumption is writing down a number it did not measure.
# docs: requires cirq
x = np.array([0.4, 0.5])
kernel = qk.QuantumKernel(qk.ZZFeatureMap(2), backend=noisy)
print(f"k(x, x) under noise: {float(np.atleast_2d(kernel(x, x))[0, 0]):.4f}") # < 1
The diagnostic thresholds were calibrated on exact gradients. qk.diagnose()
reports FLAT_GRADIENTS against a cutoff chosen for shot-free, noiseless runs. Under
noise, gradients are genuinely smaller, so that finding fires more readily than on an
exact backend — the deep ansatz below is clean at p = 0 and flagged at p = 0.1.
The finding says which of the two it is measuring, but it cannot separate them for
you: read it as "the gradient is small here", which is true, rather than "the ansatz
is badly designed", which may not be. Rerun on numpy to tell them apart.
Two of diagnose()'s probes need a statevector — whether a parameter is dead, and
whether the circuit entangles — and a mixed-state backend has none. Both are questions
about the ansatz rather than the device, so they run on the exact reference, and the
report's subject line says so rather than substituting quietly:
# docs: requires cirq
report = qk.diagnose(qk.hardware_efficient(3, 2), backend="cirq-density", n_samples=6)
print(report.subject)
# hardware_efficient on 3 qubits [structure checked on the numpy reference: ...]
That notice is not a finding, so if qk.diagnose(model): keeps meaning "something is
wrong".
Some measures do not exist on a mixed state, and say so. Expressibility, Meyer-Wallach entangling capability and the Fubini-Study metric tensor are defined between state vectors. Asking for one on a density-matrix backend is refused by name rather than approximated:
# docs: requires cirq
try:
qk.metrics.entangling_capability(qk.hardware_efficient(2, 1), n_samples=4,
backend=noisy)
except ValueError as exc:
print(str(exc)[:96])
# entangling_capability is defined on a pure state, and the 'cirq-density' backend ...
Note the difference from diagnose(), which substitutes the reference instead. That
is not an inconsistency: diagnose() was asked to check a model, and whether a
parameter is dead is a property of the ansatz. Here you named a backend and asked for
a measure on it, so the honest answer is that the measure is not defined there. To ask
what the noise did to a particular circuit, use purity().
Cirq applies a bare channel after every moment. cirq.depolarize(p) passed as a
noise model becomes a ConstantQubitNoiseModel — every qubit, every moment,
including idle ones. That is a reasonable first model and a poor imitation of a real
device. Pass a cirq.NoiseModel, or lift one off hardware with Qiskit's
NoiseModel.from_backend(...), when the answer needs to mean something.
What qmlkit does not do¶
Error mitigation. No ZNE, PEC or CDR here. Mitiq is backend-agnostic and already good at this, and a qmlkit backend is an executor function away from it. Reimplementing it would be a worse copy of a solved problem.
Error correction. Simulating a code with a QML circuit inside it is not a library gap — an encoded VQC is non-Clifford, so the stabilizer simulators that make QEC tractable at scale do not apply, and dense simulation of the encoded register is out of reach. Stim is the tool for codes.
What is missing and worth having is the honest comparison: whether mitigation
actually improved an estimate, or only traded bias for variance, measured on
identical seeds with the shot cost stated. That is the shape of qk.baseline, and
it is where this will go next.