Noise
qliff.noise simulates noisy circuits inside the stabilizer formalism. Each channel is a quasiprobability mixture of stabilizer (Clifford) channels:
A sampler runs many pure-Clifford trajectories and reweights each by
You rarely touch a channel directly. Add noise to a Circuit, then call estimate or sample -- the classes below are what those methods build.
Channel
Channel is an abstract base; subclasses expose a stabilizer-channel decomposition. A branch is a pair (weight, ops), where ops is a list of (gate, targets). The identity (no-fault) branch comes first.
| Property/Method | Description |
|---|---|
is_pauli | True if all weights are probabilities (records are sampleable) |
is_unitary | True for a coherent unitary channel, whose one Kraus operator carries interfering amplitudes |
arity | number of qubits the channel acts on |
gamma | the sampling overhead 1.0 for a Pauli channel |
branches(targets) | the (weight, ops) decomposition for the given qubits |
sample(targets, rng) | draw one branch as (sign(w) * gamma, ops) |
kraus_branches(targets) | Kraus operators, each a sum of (amplitude, ops) Pauli terms |
pauli_twirl(targets) | (px, py, pz), the lossy Pauli approximation; single-qubit only |
branches is the sampling decomposition. The last two rows are the other views a decoder may need: Kraus operators (exact for coherent rotation, whose single operator kraus_branches(targets)[0] holds the interfering amplitude terms, and exact for damping via the doubled process-matrix (chi) decoder), and the twirl (lossy, and only where a Pauli-only decoder is asked for).
Channel catalog
Every noise instruction resolves to a Channel through make_channel(name, arg), which reads the CHANNEL_META registry; build the object directly when you need one outside a circuit. The first seven rows are PauliChannels (weights are probabilities); the last three are general (is_pauli = False) and carry signed quasiprobabilities. Rotation decomposes over the Cliffords diagonal in its own axis,
| Channel | Circuit method | Arguments | Description |
|---|---|---|---|
make_channel("DEPOLARIZE1", p) | DEPOLARIZE1(q, p) | ||
make_channel("DEPOLARIZE2", p) | DEPOLARIZE2(pair, p) | the 15 two-qubit Paulis, each | |
PauliChannel({"X": p}) | X_ERROR(q, p) | ||
PauliChannel({"Y": p}) | Y_ERROR(q, p) | ||
PauliChannel({"Z": p}) | Z_ERROR(q, p) | ||
PauliChannel({"X": .., "Z": ..}) | PAULI_CHANNEL_1(q, w) | the same channel as DEPOLARIZE1 | |
PauliChannel({"ZZ": .., "XI": ..}) | PAULI_CHANNEL_2(pair, w) | the same channel as DEPOLARIZE2 | |
Rotation(axis, theta) | RZ(q, theta) / RX(q, theta) / RY(q, theta) | axis, | coherent rotation |
AmplitudeDamping(p) | AMPLITUDE_DAMP(q, p) | energy decay GeneralizedDamping | |
GeneralizedDamping(lam, mu, tau) | c.noise(ch, q) | combined amplitude + phase damping; build from from_times |
Anisotropy
Isotropic is the default. A bare scalar spreads evenly:
When you want anisotropy, pass a {label: rate} dict instead of the scalar. Anything unlisted is zero:
c.DEPOLARIZE1(q, {"Z": 0.02, "X": 0.001}) # dephasing-dominated
c.DEPOLARIZE2([a, b], {"ZZ": 0.02}) # pure ZZ crosstalk
PauliChannel({"ZZ": 0.02}) # the channel object directlyDEPOLARIZE1 and PAULI_CHANNEL_1 are the same channel under two names, as are DEPOLARIZE2 and PAULI_CHANNEL_2; the depolarizing name reads better where the rate is isotropic, the Pauli name where it is not. Both take a scalar, a dict, or the dense vector in canonical order
(the dense form exists for interop; prefer the dict, since nobody can read fifteen positional floats and a mis-ordered one is silent). A mistyped, wrong-case or wrong-arity label raises naming the problem rather than landing as a zero rate.
PauliChannel(rates) reads the arity off the key width: {"X": ..} is one qubit, {"ZZ": ..} is two. Mixed widths are refused rather than guessed at.
X_ERROR, Y_ERROR and Z_ERROR carry a single Pauli each, so they take a scalar only and refuse a vector. A 3-tuple handed to X_ERROR used to be re-read as the whole channel, silently turning a bit flip into a general Pauli channel.
For the common single-axis case the code builders take bias, the ratio bias_axis, instead of a dict. The split itself is qliff.noise.channel.bias_split(p, eta, axis="Z"), returning (px, py, pz) for axis in "X", "Y", "Z":
so bias_axis and memory together gives a bit-identical detector error model, which a bias / bias_axis are accepted by every code builder -- see the noise bias.
bias shapes the 1-qubit Pauli channels only. There is no canonical two-qubit lift of a one-qubit DEPOLARIZE2 at
Amplitude damping
AmplitudeDamping(p) decomposes exactly over
The overhead expect (estimate reweights automatically).
GeneralizedDamping(lam, mu, tau) is the GeneralizedDamping.from_times(t1, t2, t). It supplies its own Kraus decomposition, so it works with the doubled process-matrix (chi) decoder and with pauli_twirl(); the twirl is AmplitudeDamping(p) is this channel at
Rotation
Rotation(axis, theta) folds the nearest Clifford power
Rates are validated
Channel rates are checked on construction: negative rates, rates above 1, and a rate set summing above 1 raise with the offending value named, as do non-CP
This applies to rates, not to the signed decompositions: Rotation and GeneralizedDamping in its signed regime legitimately carry negative quasiprobabilities and are unaffected.
Leakage
Leakage is the one hardware mechanism here that is not a channel. A leaked qubit has no qubit density matrix, so there is nothing to decompose: no CHANNEL_META entry, no stabilizer-branch decomposition, no LEAKAGE instruction and no detector error model. It is configured with a LeakageModel object handed to a LeakySimulator, which a WeightedDetectorSampler runs once per shot, never with a circuit.append(...) noise instruction.
What makes it tractable anyway is that coherence between the computational levels and the leaked level decays on the anharmonicity timescale, far faster than a syndrome round. Which qubits are leaked is therefore a classical fact you can sample, and conditioned on it everything else is an ordinary qubit process again. One bit per qubit suffices even though the transition involves a pair, because the post-transition state is a product state.
The mechanism
The leak rides the same leak times the pair's
which on a stabilizer state is one of
On the event, one qubit leaves the subspace and the other drops to its ground state. The qubit that does not leak therefore takes a real error despite never leaving the subspace. site selects which member leaks: "target" for "control" for
While a qubit is leaked
| Operation | What happens |
|---|---|
| single-qubit gate | skipped, the leaked qubit is decoupled from the tableau |
| two-qubit gate | a rotation is applied to the healthy partner instead |
| measurement | a biased random bit, governed by readout |
| reset, or measure-and-reset | the flag clears |
readout is P(measure 1 given leaked). 0.5 models a discriminator that cannot separate the leaked level from the code states, 1.0 one that reports every leaked qubit as excited. The record still gets exactly one bit per measured qubit either way.
The reset row is the one that matters in practice. Ancillas are reset every round, so ancilla leakage self-clears. Data qubits are not, so data leakage persists.
Seeping back happens with probability seep per two-qubit gate the leaked qubit takes part in, and returns the qubit in AMPLITUDE_DAMP, not here.
The partner kick is coherent, not stochastic
phi is the per-gate angle the healthy partner is rotated by. Under kick="COHERENT" (the default) it is applied as the Rotation it is, decomposed through the same quasiprobability path as every other non-Pauli channel, so no twirl enters anywhere and each trajectory carries an importance weight. That weight is exactly 1.0 on any shot where nothing leaked, so negativity is paid only where leakage actually happened. kick="TWIRL" replaces the rotation with its Pauli approximation and gives unit weights throughout. It is kept for comparison with the literature, not because it is right.
At distance 3 the erasure of the leaked qubit accounts for the effect, and the rotation's contribution was not resolvable at that shot budget. No difference in logical error rate is claimed between the two arms here.
| Argument | Default | Meaning |
|---|---|---|
leak | 0.0 | P(leak) per two-qubit gate, times the pair's |
seep | 0.0 | P(return to |
phi | 0.0 | per-gate rotation angle imprinted on the healthy partner |
readout | 0.5 | P(measure 1 given leaked) |
kick | "COHERENT" | "COHERENT" or "TWIRL" |
site | "target" | which member of the pair leaks: "target" or "control" |
Rates outside kick / site names are refused at construction. LeakySimulator is the per-shot object underneath, a Simulator carrying one leaked flag per qubit, and sim.leaked lists the qubits currently outside the computational subspace.
Sampling
WeightedDetectorSampler(circuit, simulator) takes any simulator(num_qubits, seed) factory and runs one per shot. Its sample(shots, seed) returns three values where DetectorSampler.sample returns two: detection events, observable flips, and the per-trajectory importance weights.
from functools import partial
from qliff.noise import LeakageModel, LeakySimulator
from qliff.qec import WeightedDetectorSampler, rotated_surface_code
from qliff.qec.decoder import make
circ = rotated_surface_code(3, 3, 0.001)
model = LeakageModel(leak=0.02, seep=0.05, phi=0.3)
leaky = WeightedDetectorSampler(circ, partial(LeakySimulator, model=model))
dets, obs, weights = leaky.sample(300, seed=0)
# the circuit's own leakage-free error model: what hardware forces on a decoder
decoder = make("bposd", circ)
wrong = (decoder.decode_batch(dets) != obs).any(axis=1)
ler = float((weights * wrong).sum() / weights.sum())The weighted mean is unbiased under the true leakage process. Under kick="TWIRL" every weight is 1.0 and it degenerates to the plain failing fraction. At leak=0 it reproduces DetectorSampler(circuit, Simulator), the plain per-shot Python sampler, bit for bit.
Why no detector error model can represent it
A DetectorErrorModel can only produce syndromes in the column space of its check matrix
| noise | fraction of syndromes outside |
|---|---|
DEPOLARIZE1 | 0 |
DEPOLARIZE1 | 0 |
DEPOLARIZE1 | 0 |
leakage, leak=0.005 | 3.3% |
leakage, leak=0.02 | 12.6% |
leakage, leak=0.05 | 28.0% |
leakage, leak=0.10 | 45.8% |
The leakage rows used seep=0.05, phi=0.3 and the coherent kick.
This is not a statement about miscalibrated priors. The syndrome is not in the image of the model at any rates, so no reweighting and no fitted detector error model reaches it. Minimum-weight matching does not misdecode these syndromes, it fails to return a matching at all, and pymatching raises. Use bposd for leakage work, which returns a best-effort answer instead of raising. The twirled and coherent arms give the same escape fraction to within sampling error, which locates the effect in the classical flag rather than in the rotation.
Limitations
- No coherence between the computational and the leaked levels. This is the load-bearing assumption.
- The branch in which the gate does not leak carries a back-action
, which is not a stabilizer operation. It is neglected, an effect per gate. - Leakage is injected only at two-qubit gates. Real devices also leak on single-qubit gates, while idling, and at readout.
- Seepage is evaluated per two-qubit gate rather than per unit time.
- Only the first leaked level is modelled.
- A
SWAPinvolving a leaked qubit does not transport the flag. - This is the per-shot Python path with no batched core behind it, so it is the slowest sampler in the package.
Sampler
Sampler wraps a circuit and runs trajectories. It has two methods.
| Method | Handles | Notes |
|---|---|---|
expect(obs, shots, seed=None, stratify=False) | any channel | importance estimate, unbiased for any noise |
sample(shots, seed=None) | Pauli only | uint8 array (shots, measurements); raises on a general channel |
expect draws one branch per noise location and reweights each trajectory by stratify=True it rewrites the estimate as
from qliff import Circuit
from qliff.noise import Sampler
c = Circuit(1)
c.H(0).RZ(0, 0.3)
Sampler(c).expect("X", 20000)
Sampler(c).expect("X", 20000, stratify=True)sample is Pauli-only: a measured bitstring cannot be reweighted by a negative quasiprobability, so a non-Pauli channel raises.
TIP
Prefer c.estimate(observable, shots). It uses flat importance sampling for Pauli circuits and stratifies otherwise, so you rarely build a Sampler by hand.
Rare-event splitting
Direct Monte-Carlo needs on the order of SplittingEstimator spends a fixed decode budget across a ladder of physical error rates instead: it factors the tail probability into a product of conditional level-crossing ratios, each estimated by a Metropolis walk over the decoder's failing set. The method is Bravyi-Vargo multilevel splitting (arXiv:1308.6270); the estimate is unbiased.
from qliff.noise import SplittingEstimator
from qliff.qec import rotated_surface_code
est = SplittingEstimator(lambda p: rotated_surface_code(5, 5, p), 0.002)
res = est.estimate(p_top=0.04, p_target=0.002, levels=9) # (ler, rel_err, decodes)
res.ler # logical error rate at the deepest ladder rate
res.rel_err # relative standard error, in quadrature over the levels
res.decodes # decoder calls spent| Argument | Meaning |
|---|---|
circuit_fn(p) | builder returning the memory Circuit at physical rate p |
decoder_ref | rate at which the fixed decoder is built |
decoder_cls | any batch decoder; MwpmDecoder by default |
ladder | explicit strictly-descending rate list, in place of p_top/p_target/levels |
top_shots | shots for the direct-MC measurement at the top of the ladder |
burn, samples | length of each per-level Metropolis chain |
The decoder is built once, at decoder_ref, and held fixed down the ladder. The failing set it induces then depends on the code and the decoder alone and not on est.priors_at(p) gives the per-mechanism prior vector at any rate, in that model's mechanism order. est.direct_mc(p, shots) runs plain Monte-Carlo with the same fixed decoder and returns (failures, last failing fault vector or None), for cross-checking wherever MC is affordable.
Measured on rotated-surface patches under the amplitude damping channel's Pauli twirl, the log-log slope of LER against the damping rate reproduces
| 2 | 3 | 4 | 6 | 8 | 12 | |
|---|---|---|---|---|---|---|
| 1 | 2 | 2 | 3 | 4 | 6 | |
| measured slope | 1.00 | 1.99 | 1.91 | 2.92 | 3.97 | 6.01 |
The last column is a
The generator is a local sweep script, not tracked in the repository, which builds each circuit as PAULI_CHANNEL_1 carrying AmplitudeDamping(gamma).pauli_twirl((0,)). It runs the twirl rather than the channel because the estimator is Pauli-only, for the reason below. The twirl is validated rather than assumed: against well-resolved direct Monte Carlo on the real amplitude damping channel it agrees to within 3-8%.
WARNING
SplittingEstimator is Pauli-only, and the twirl it falls back on is a modelling approximation. It works from a DetectorErrorModel, which has nowhere to put a signed or complex branch weight, so circuit_fn must return a Pauli circuit. Under a non-Pauli channel what is being estimated is the Pauli twirl of the circuit, not the circuit. The twirl gets the decoding decisions right but underestimates the logical error rate: true / twirled is ~1.35x under amplitude damping at
Custom channels
Subclass Channel, set is_pauli (and arity = 2 for a two-qubit channel), and return the branches: an identity branch first, then (weight, ops) faults (weights may be negative quasiprobabilities). No Rust or recompilation needed. Drop it into a circuit with c.noise(channel, q).
from qliff import Circuit
from qliff.noise import Channel
class Dephase(Channel):
is_pauli = True
def __init__(self, p):
self.p = p
def branches(self, targets):
q = targets[0]
return [(1.0 - self.p, []), (self.p, [("Z", (q,))])]
c = Circuit(1)
c.H(0).noise(Dephase(0.1), 0)A custom channel is classified by what it actually emits, not by its name. If every live branch commutes with a memory basis, qliff.noise.channel.blind_axis(channel, arg) reports that basis and the QEC builders warn -- see the memory basis.