Simulating Noise Tutorial 06 of 10
The running example: amplitude damping
Every section of this page uses the same channel: amplitude damping (AD). A qubit stores energy.
The standard description is a pair of Kraus operators:
We fix
By the end of the page a stabilizer simulator computes this number, and every step it takes to get there will be on the table.
Why the simulator cannot run it directly
A QEC experiment has hundreds of qubits and needs millions of shots. Storing the full quantum state,
- state vector: 2n amplitudes
- stabilizer tableau: ~n2 bits
| qubits n | state vector | tableau |
|---|---|---|
| 10 | ~103 | 420 |
| 40 | ~1012 | 6,480 |
| 100 | ~1030 | 40,200 |
The way out is the Gottesman-Knill theorem: a circuit made only of Clifford operations (H, S, CX, Pauli gates, measurement, reset) acting on stabilizer states can be simulated with an
A Pauli channel avoids the problem. X_ERROR(p) means: with probability
The fix: a menu of Clifford branches
The channel can still be written as a weighted sum of Clifford operations, if the weights are allowed to be negative:
Think of it as a menu. Each line (a branch) is a weight
| branch | gates | weight | at |
|---|---|---|---|
| no fault | none | ||
| phase flip | |||
| reset |
Why these numbers? The reset branch carries the population transfer: it moves weight from
- positive weight
- negative weight
qliff builds this menu for you, and you can read it directly:
from qliff.noise import make_channel
for weight, ops in make_channel("AMPLITUDE_DAMP", 0.1).branches((0,)):
label = ops[0][0] if len(ops) > 0 else "I"
print(f"{label} {weight:+.4f}")
# -> I +0.9243
# -> Z -0.0243
# -> R +0.1000The weights sum to
with
How the simulator picks a branch
Here is the sampling rule, in full. At every noise location, in every shot, the simulator does five things:
- Compute
. For AD at : . - Divide
into one slot per branch, of length : the slot is , the slot , the slot . - Draw a uniform random number
and scale it: . - Apply the gates of the branch whose slot contains
. One branch, and only that branch: the simulation never forks. - Multiply the shot's running weight by
. The weight starts at and one shot has one weight.
So each branch is picked with probability
Walk one draw by hand. Say the generator returns
. - Walk the slots:
, so not ; , so the slot contains it. - Apply
to the qubit. , so the weight multiplies by . The shot's weight is now negative.
The three possible outcomes of a single location, side by side:
| draw | slot | gates | weight factor | |
|---|---|---|---|---|
| 0.50 | 0.5243 | none | ||
| 0.90 | 0.9438 | |||
| 0.97 | 1.0172 | reset |
Note the factor has magnitude Channel.sample is this rule verbatim, and its long-run pick frequencies match the slot sizes:
import random
from qliff.noise import make_channel
ch = make_channel("AMPLITUDE_DAMP", 0.1)
rng = random.Random(2026)
counts = {"I": 0, "Z": 0, "R": 0}
factor_of = {}
for _ in range(100_000):
factor, ops = ch.sample((0,), rng)
label = ops[0][0] if len(ops) > 0 else "I"
counts[label] += 1
factor_of[label] = factor
for label in ("I", "Z", "R"):
frac = counts[label] / 100_000
print(f"{label} picked {frac:.4f} of shots, factor {factor_of[label]:+.4f}")
# -> I picked 0.8827 of shots, factor +1.0487
# -> Z picked 0.0232 of shots, factor -1.0487
# -> R picked 0.0941 of shots, factor +1.0487A circuit has many noise locations, and a trajectory is one pass through all of them: roll at the first location, apply its branch, roll at the second, and so on, multiplying the factors into a single per-shot weight. After
One shot through four damping locations
Legend:
?= location not yet rolledI= no-fault branch (factor) Z= phase-flip branch (factor) R= reset branch (factor) - vertical marker = draw
on slot bar - highlighted square = location whose roll is shown
From weighted shots to answers
A single trajectory is not the channel; only the average over shots is. Say you want the expectation of some outcome function
where
the term the channel definition asks for. The
Check it on the target from the top of the page:
Circuit.estimate runs this estimator end to end:
from qliff import Circuit
damped = Circuit(1)
damped.X(0) # prepare |1>
damped.AMPLITUDE_DAMP(0, 0.1) # one damping location
print("estimate", round(damped.estimate("Z", shots=50_000, seed=3), 3))
print("truth ", 2 * 0.1 - 1)
# -> estimate -0.8
# -> truth -0.8Two special cases are worth naming:
- Pauli channels collapse to counting. If every
then and every shot's weight is , so the weighted mean is a plain frequency count. That is why compile_sampleranddetector_samplerhand you raw bitstrings for Pauli-noise circuits: no weights needed. For non-Pauli noise a bitstring cannot carry a signed weight, so those samplers refuse andestimateis the interface. - Dropping the signs gives the wrong answer. If you sampled branches and averaged
without the weights, you would be estimating the wrong channel: the one with weights . The Std Python tab below shows the miss.
The price of the minus sign
The factor
For AD at
The flip side: Pauli channels have
From a trajectory to a syndrome
A decoder never sees which branches fired. It sees detection events, and they come from measurements. The recipe:
- Run the circuit once with no noise. Every detector (a parity of measurement records, Tutorial 02) gets a deterministic reference value.
- Run a noisy trajectory. The branches' gates ride along with the shot; for Pauli noise this is a Pauli frame, two bits per qubit saying "an
flip is pending here" and "a flip is pending here". An or flip on a qubit inverts its Z-basis measurement record; a flip does not. - XOR each measured parity against its reference:
A detector fires when noise changed its value. The resulting bit string is the syndrome, the decoder's only input.
- data qubit with X error
- clean data qubit
- Z-check lit (detection event = 1)
- Z-check quiet (0)
In code, with the error rate set to
from qliff import Circuit
rep = Circuit()
rep.X_ERROR([1], 0.2) # noise on the middle of three data qubits 0,1,2
rep.append("CX", [0, 3]) # ancilla 3 reads the parity of q0, q1
rep.append("CX", [1, 3])
rep.append("CX", [1, 4]) # ancilla 4 reads the parity of q1, q2
rep.append("CX", [2, 4])
rep.append("MR", [3])
rep.append("MR", [4])
rep.detector(-2)
rep.detector(-1)
dets, _obs = rep.detector_sampler().sample(20_000, seed=5)
both = (dets[:, 0] == 1) & (dets[:, 1] == 1)
one = dets.sum(axis=1) == 1
print("both checks fire ", round(float(both.mean()), 4))
print("only one fires ", round(float(one.mean()), 4))
# -> both checks fire 0.2009
# -> only one fires 0.0The error on q1 fires both of its neighbouring checks together (at its rate,
How does AD fit this picture? Branch by branch: the
Implementation
The tabs rebuild the page in code, all on the same AD running example.
Pseudocode. The per-shot loop: one tableau, one running weight, one branch drawn per noise location, detection events from the XOR with a noiseless reference run.
Std Python. The AD algebra in NumPy: the Kraus pair, the Pauli expansion showing no flip model fits, the signed menu with its pick probabilities, and a weighted Monte Carlo for
Qliff. The same objects from the library: branches for the menu, sample for seeded draws with their estimate for the weighted answer, and a Pauli channel collapsing to plain counting.
reference = run the circuit once with no noise # deterministic record bits
for shot in 1..N:
state = tableau with all qubits in |0>
weight = 1
for instruction in circuit:
if instruction is a noise location: # e.g. AMPLITUDE_DAMP(p)
menu = its branches [(q_k, Clifford ops)]
gamma = sum_k |q_k| # 1.0487 for AD(0.1)
draw one k with probability |q_k| / gamma
apply that branch's ops to state # nothing, Z, or reset
weight = weight * sign(q_k) * gamma
else:
apply the Clifford gate / measurement to state
for each detector d:
event[d] = parity of d's records XOR reference parity of d
emit (events, weight) # |weight| = gamma^L
estimate of E[f] = (1/N) * sum over shots of weight * f(shot)
# Pauli noise: every q_k >= 0, so gamma = 1 and every weight = 1,
# and the estimate is a plain frequency countimport numpy as np
rng = np.random.default_rng(7)
p = 0.1
# 1. Amplitude damping as Kraus operators. K0 shrinks the |1> amplitude,
# K1 moves it to |0> (a decay event).
K0 = np.array([[1.0, 0.0], [0.0, np.sqrt(1.0 - p)]])
K1 = np.array([[0.0, np.sqrt(p)], [0.0, 0.0]])
print("trace preserving:", np.allclose(K0.T @ K0 + K1.T @ K1, np.eye(2)))
# -> trace preserving: True
# 2. Neither operator is a number times one Pauli, so no set of flip
# probabilities reproduces the channel. Expand K = sum_P c_P P:
paulis = {
"I": np.eye(2, dtype=complex),
"X": np.array([[0, 1], [1, 0]], dtype=complex),
"Y": np.array([[0, -1j], [1j, 0]], dtype=complex),
"Z": np.array([[1, 0], [0, -1]], dtype=complex),
}
for name, K in [("K0", K0), ("K1", K1)]:
coeff = [np.trace(P @ K) / 2.0 for P in paulis.values()]
print(name, np.round(coeff, 3))
# -> K0 [0.974+0.j 0. +0.j 0. +0.j 0.026+0.j]
# -> K1 [0. +0.j 0.158+0.j 0. +0.158j 0. +0.j ]
# 3. The signed Clifford menu {I, Z, Reset} and its sampling table.
root = np.sqrt(1.0 - p)
q = np.array([(1.0 - p + root) / 2.0, (1.0 - p - root) / 2.0, p])
gamma = np.abs(q).sum()
print("weights", np.round(q, 4), " sum", round(q.sum(), 4), " gamma", round(gamma, 4))
# -> weights [ 0.9243 -0.0243 0.1 ] sum 1.0 gamma 1.0487
print("pick probabilities", np.round(np.abs(q) / gamma, 4))
# -> pick probabilities [0.8814 0.0232 0.0954]
# 4. Monte Carlo for <Z> on a damped |1>. Branch outcomes: I keeps |1>
# (Z reads -1), Z keeps |1> (-1), Reset gives |0> (+1). Truth: 2p - 1.
f = np.array([-1.0, -1.0, +1.0])
shots = 400_000
k = rng.choice(3, size=shots, p=np.abs(q) / gamma)
weighted = (np.sign(q)[k] * gamma * f[k]).mean()
naive = f[k].mean()
print("truth ", 2 * p - 1)
print("weighted", round(float(weighted), 4))
print("naive ", round(float(naive), 4), " (its limit:", round(float(np.abs(q) @ f / gamma), 4), ")")
# -> truth -0.8
# -> weighted -0.8017
# -> naive -0.811 (its limit: -0.8093 )
# 5. The sign-blind miss is small here because gamma is near 1. At p = 0.5
# the truth is 0 and dropping the signs misses by 0.17.
p2 = 0.5
root2 = np.sqrt(1.0 - p2)
q2 = np.array([(1.0 - p2 + root2) / 2.0, (1.0 - p2 - root2) / 2.0, p2])
print("truth", 2 * p2 - 1, " naive limit", round(float(np.abs(q2) @ f / np.abs(q2).sum()), 4))
# -> truth 0.0 naive limit -0.1716import random
from qliff import Circuit
from qliff.noise import make_channel
# 1. The AD menu, straight from the library: (weight, ops) branches.
ch = make_channel("AMPLITUDE_DAMP", 0.1)
for weight, ops in ch.branches((0,)):
label = ops[0][0] if len(ops) > 0 else "I"
print(label, f"{weight:+.4f}")
# -> I +0.9243
# -> Z -0.0243
# -> R +0.1000
# 2. Four seeded draws, one per noise location of a 4-location shot.
# sample() picks a branch with probability |q|/gamma and returns
# (sign(q) * gamma, ops) for the branch it picked.
rng = random.Random(106)
weight = 1.0
for _ in range(4):
factor, ops = ch.sample((0,), rng)
weight *= factor
print(round(factor, 4), ops)
print("shot weight", round(weight, 4))
# -> 1.0487 []
# -> -1.0487 [('Z', (0,))]
# -> 1.0487 []
# -> 1.0487 [('R', (0,))]
# -> shot weight -1.2094
# 3. The weighted estimator end to end: <Z> after damping |1>.
damped = Circuit(1)
damped.X(0)
damped.AMPLITUDE_DAMP(0, 0.1)
print(round(damped.estimate("Z", shots=50_000, seed=3), 3))
# -> -0.8
# 4. A Pauli channel has gamma = 1, so sampling is plain counting:
# the flip rate comes back as a frequency.
flip = Circuit(1)
flip.X_ERROR([0], 0.2)
flip.M(0)
recs = flip.compile_sampler().sample(50_000, seed=7)
print(round(float(recs.mean()), 4))
# -> 0.1987One channel, one path through the machine: AD's Kraus pair cannot run on a tableau, its signed three-branch menu can, the sampler draws one branch per location at