Stratified Self-Normalised Sampling Tutorial 07 of 10
The running example: a 3x3 damping patch
Tutorial 06 built a sampler for non-Pauli noise. It runs a channel a stabilizer simulator cannot represent, like amplitude damping, by rewriting it as a signed sum of branches the simulator can run. For AMPLITUDE_DAMP(0.05) those branches are
To take one shot, the location draws a branch with probability
This
Now put nine of them in a circuit: a distance-3 rotated surface-code patch, one round, one AMPLITUDE_DAMP(0.05) on each of the nine data qubits.
from qliff.qec.codes import rotated_surface_code
circuit = rotated_surface_code(rows=3, cols=3, rounds=1, p=0.05, channel="AMPLITUDE_DAMP")Each of the nine locations multiplies the weight by its
Every trajectory carries the same magnitude
Draw three thousand weighted trajectories and look at the weights themselves, not their average:
import numpy as np
from qliff.qec.sampler import WeightedDetectorSampler
_dets, _obs, weights = WeightedDetectorSampler(circuit).sample(3000, seed=7)
print("distinct |w|:", np.unique(np.round(np.abs(weights), 9)))
print("signs:", int((weights > 0).sum()), "plus /", int((weights < 0).sum()), "minus")
# -> distinct |w|: [1.24535216]
# -> signs: 2710 plus / 290 minusThree thousand shots, one distinct magnitude. Every trajectory came back carrying
- weight = +Γ
- weight = -Γ
That is not a coincidence, it is the sampling rule. At every noise location the shot's weight multiplies by
So the logical error rate comes out of _weighted_error_rate as the mean of
And
- noise locations A = d2 × d
- flat shot-budget factor Γ2
| distance d | locations A | Γ | Γ2 |
|---|---|---|---|
| 3 | 27 | 1.93 | 3.7 |
| 5 | 125 | 21.06 | 4.4 x 102 |
| 7 | 343 | 4282 | 1.8 x 107 |
A full circuit-level model puts a noise location after every gate rather than once per data qubit per round, which pushes the same numbers much harder: the paper this estimator comes from (arXiv:2512.07304, Myers, Teo, Mishra, Chai and Hui Khoon Ng at CQT/NUS) reports
The Gamma wall
Legend:
- purple curve =
vs noise locations (log y) - red dot = selected
Pauli shots= binomial budget at a 10% barflat shots=Pauli shotstimes
Sort the shots by how many locations faulted
The move is to split the trajectories into strata by their fault count
where
The point is that
Choosing a branch at each location is then choosing one term of the product
That is stratum_masses in qliff/noise/sampler.py, one convolution per location. For the running example,
| stratum k | mass m_k | sampled P(k) | mean sign |
|---|---|---|---|
| 0 | +0.707874 | 0.568412 | 1.000000 |
| 1 | +0.249318 | 0.331392 | 0.604114 |
| 2 | +0.039027 | 0.085869 | 0.364953 |
| 3 | +0.003564 | 0.012979 | 0.220473 |
Two distributions, not one, and they differ.
The masses are signed in general
For amplitude damping both
Where the answer lives
Legend:
- purple bar =
, the signed mass of stratum (red if negative) - grey bar =
, what the sampler draws from mean sign=
Self-normalising one stratum
Now sample inside a stratum. Fix fault_set), and at each of them draw a fault branch with probability pick_branch). Write
using
with
The common factor StratifiedDetectorSampler.sample hands back signs and stratum labels instead of weights:
from qliff.qec.sampler import StratifiedDetectorSampler
_dets, _obs, signs, strata = StratifiedDetectorSampler(circuit).sample(3000, seed=7)
print(sorted(set(signs.tolist())))
# -> [-1.0, 1.0]That denominator is a sampled sum of
Because _loo_beta) so it never scores the shot it was fit on. At _stratified_error_rate in qliff/qec/threshold.py.
Two consequences:
never appears in the answer. The flat estimator averages numbers of size . This one averages signs and multiplies by masses that sum to 1. At that is the difference between an estimator that returns a number and one that does not. - The
stratum is free. It contains one trajectory (identity everywhere), so is not estimated but evaluated. On the running example that single shot settles 70.8% of the answer, and allocateseatswith that one shot rather than spending its full proportional share on it.
- flat importance sampling
- stratified, control-variate
| flat | stratified | |
|---|---|---|
| per-shot payload | signed weight ±Γ | sign ±1 + stratum k |
| estimator | mean(w · wrong) | Σk ΓP(k) · mean(yk) |
| fault-count weights | sampled | computed |
| k = 0 stratum | ~57% of shots | 1 shot, evaluated |
| residual noise source | Γ across all A locations | sign cancellation across k faults |
What survives: the signs
Cancelling
the ratio of the location's signed fault weight to its absolute one. With identical locations the mean sign of a stratum-
and it barely moves with
The measured signs match:
import numpy as np
for k in (0, 1, 2, 3):
sel = strata == k
print(f"k={k} n {int(sel.sum()):4d} sum(sign) {signs[sel].sum():+6.0f} mean {signs[sel].mean():.4f}")
# -> k=0 n 1 sum(sign) +1 mean 1.0000
# -> k=1 n 2292 sum(sign) +1370 mean 0.5977
# -> k=2 n 595 sum(sign) +211 mean 0.3546
# -> k=3 n 91 sum(sign) +11 mean 0.1209At
Notice the exponent.
Sign cancellation inside one stratum
Legend:
- green
+= trajectory sign - red
-= trajectory sign - filled cell = decoder wrong
sum(sign)= net sign of the stratum (washes out as r^k)
Beyond the resolvable zone: interpolation
The signed mean interpolate=True stops sampling them. It pins the shape of
- Lower end, measured. For small
, _interpolated_error_rateruns an exhaustive scan: it decodes every-fault configuration, so there is computed, not sampled. This matters because is not trivially zero at small . An unprotected colouring of the code can fail on a single amplitude-damping event, real physics that an "errors below the distance are harmless" assumption would miss. - Upper end, pinned. As faults accumulate the logical state fully mixes and
. Strata above the resolvable zone are pinned there, and the bias that introduces is bounded and reported as the mass-weighted deviation from the pin, never hidden.
from qliff.qec.codes import rotated_surface_code
from qliff.qec.threshold import logical_error_rate
memory = rotated_surface_code(rows=3, cols=3, rounds=2, p=0.05, channel="AMPLITUDE_DAMP", prep=True)
for seed in (0, 1, 2):
strat, _ = logical_error_rate(memory, "coherent", shots=1200, seed=seed, stratify=True)
interp, _ = logical_error_rate(memory, "coherent", shots=1200, seed=seed, interpolate=True)
print(f"seed={seed} stratify {strat:.5f} interpolate {interp:.5f}")
# -> seed=0 stratify 0.02295 interpolate 0.02165
# -> seed=1 stratify 0.01442 interpolate 0.02639
# -> seed=2 stratify 0.01984 interpolate 0.02105On this small patch the resolvable zone already covers most of the mass, so interpolation lands in the same range as the full stratified estimate rather than beating it. Its advantage shows when the resolvable zone is a thin slice of the strata that carry mass, the high-
The Pauli sanity check
Everything above collapses to something ordinary when the noise is Pauli. Every branch has
from qliff import Circuit
from qliff.noise.sampler import build_strata
pauli = Circuit(5)
for q in range(5):
pauli.append("X_ERROR", [q], 0.08)
_locs, _phis, pk, mass = build_strata(pauli.instructions)
print([round(m, 8) for m in mass[:4]])
print([round(x, 8) for x in pk[:4]])
print("identical:", all(abs(m - x) < 1e-12 for m, x in zip(mass, pk)))
# -> [0.65908152, 0.28655718, 0.04983603, 0.00433357]
# -> [0.65908152, 0.28655718, 0.04983603, 0.00433357]
# -> identical: TrueWith every sign
Worked example: stratum k = 1 of the 3x3 damping patch
Nine locations, all identical,
- Mass. One location faults, the other eight do not, and there are nine ways to choose which:
. - Sampling odds. The sampler's per-location fault probability is
, so . It draws stratum 1 more often than its mass deserves, because it is drawing from , not . - Weight magnitude. Every stratum-1 trajectory carries
, times its sign. The magnitude is identical for all 745 of them. - Mean sign. The one faulty location picks
with probability and with probability , so the mean sign is . Times that is : the identity closes. - The estimate. The sampler drew 745 trajectories here, 602 with sign
and 143 with sign , so against an expectation of . is over that 459, and stratum 1 contributes to the logical error rate.
Result: the 0.249318 of the answer that lives at
What it costs
The control variate does not need a crossover argument to be safe. At
Measured on the running patch, with a prep round so the damping has something to act on, decoded by the coherent decoder, spread of the estimate over eight seeds:
- flat importance sampling
- stratified, control-variate
| rounds | A | Γ | flat spread | stratified spread |
|---|---|---|---|---|
| 2 | 18 | 1.55 | 0.0066 | 0.0030 |
| 4 | 36 | 2.41 | 0.0208 | 0.0117 |
| 6 | 54 | 3.73 | 0.0188 | 0.0224 |
Read the table as the method's shape, not a verdict. Three things set how far ahead it gets:
- How badly the signs cancel. The per-fault discount is
. Amplitude damping's is steep, so within a handful of faults a stratum's signs have washed out. A channel whose fault branches are mostly positive has near 1 and barely pays. - Where the shots go.
allocateruns a pilot pass proportional to, measures each stratum's spread , then spends the rest by Neyman, . Proportional allocation alone only ties flat sampling. The factor is the win, moving shots off the strata that never vary and onto the ones that carry the failures. - How large
is. At flat is barely inconvenienced, so the margin is modest. At flat cannot produce a number at all, whatever its asymptotics say, and a noisy estimate beats none. Surviving the high- end is the whole reason the method exists.
A stratum the budget could not seat is missing, not conservative
StratifiedDetectorSampler banks the mass of any stratum it could not seat in dropped_mass. Check that field: it is the fraction of the answer the run did not look at. On the running example it is
Implementation
The tabs rebuild the page in code, all on the same damping patch.
Pseudocode. The two estimators side by side, so the structural difference is visible at a glance: what a shot carries, and how the strata recombine.
Std Python. Four locations in NumPy, small enough to brute-force. It builds the masses by convolution, enumerates all
Qliff. The same objects from the library: build_strata for the masses, WeightedDetectorSampler for the flat weights, StratifiedDetectorSampler for the signs and strata, and logical_error_rate(..., stratify=True) (or interpolate=True) for the end-to-end estimate.
# --- flat importance sampling (tutorial 06) --------------------------------
for shot in 1..N:
weight = 1
for location in circuit:
draw branch k with probability |q_k| / gamma
weight = weight * sign(q_k) * gamma # |weight| = Gamma, always
emit (detection events, weight)
ler = mean over shots of weight * decoder_is_wrong # a mean of +/- Gamma
# --- stratified, control-variate -------------------------------------------
a[i] = identity-branch weight of location i # a[i] + b[i] = 1
b[i] = summed fault weight of location i
mass = coefficients of prod_i (a[i] + b[i] * x) # computed, no sampling
phi[i] = gfault[i] / gamma[i] # sampling fault odds
P = poisson_binomial(phi)
Gamma = prod_i gamma[i]
ler = 0
for k where |mass[k]| is worth sampling:
n_k = shots for stratum k (Neyman: n_k ~ P(k)*sqrt(V_k), k = 0 gets one)
coef = Gamma * P(k) # stratum weight, computed
r_k = mass[k] / coef # known mean sign E[sign]
sv = []
wv = []
for shot in 1..n_k:
S = draw k faulty locations from P
sign = product over i in S of sign(chosen branch weight)
sv += [sign]
wv += [sign * decoder_is_wrong]
beta = leave_one_out Cov(wv, sv) / Var(sv) # variance-optimal, no bias
y = [w - beta * (s - r_k) for s, w in zip(sv, wv)]
ler = ler + coef * mean(y) # unbiased, no denominatorimport itertools
import numpy as np
p = 0.05
root = np.sqrt(1.0 - p)
q = np.array([(1.0 - p + root) / 2.0, (1.0 - p - root) / 2.0, p]) # I, Z, R
A = 4
# 1. Every location splits into an identity weight a and a summed fault weight b.
# Trace preservation forces a + b = 1; the sampler instead sees the ABSOLUTE
# sums, gfault and gamma, and those do not add up to 1.
a = q[0]
b = q[1:].sum()
gfault = np.abs(q[1:]).sum()
gamma = np.abs(q).sum()
print("a", round(a, 6), " b", round(b, 6), " a+b", round(a + b, 6))
print("gfault", round(gfault, 6), " gamma", round(gamma, 6))
print("phi", round(gfault / gamma, 6), " r = b/gfault", round(b / gfault, 6))
# -> a 0.96234 b 0.03766 a+b 1.0
# -> gfault 0.06234 gamma 1.024679
# -> phi 0.060838 r = b/gfault 0.604114
# 2. Stratum masses = the coefficients of x^k in prod_i (a + b x): analytic, no sampling.
mass = np.array([1.0])
for _ in range(A):
mass = np.convolve(mass, [a, b])
print("mass", np.round(mass, 6), " sum", round(mass.sum(), 12))
# -> mass [8.57657e-01 1.34254e-01 7.88100e-03 2.06000e-04 2.00000e-06] sum 1.0
# 3. Brute force over every branch assignment of the 4 locations. Each trajectory
# has a true signed weight (the product of the q's) and a fault count k. Say the
# shot is a logical error when two or more locations fired the reset branch.
true_ler = 0.0
per_k = np.zeros(A + 1)
for pick in itertools.product(range(3), repeat=A):
weight = float(np.prod(q[list(pick)]))
k = sum(1 for i in pick if i != 0)
wrong = 1.0 if sum(1 for i in pick if i == 2) >= 2 else 0.0
true_ler += weight * wrong
per_k[k] += weight * wrong
print("true LER", round(true_ler, 8))
# -> true LER 0.01401875
# 4. The identity the estimator rests on: LER = sum_k mass[k] * F_k. Note that the
# conditional rates are ratios of SIGNED masses, so they need not sit in [0, 1];
# only their mass-weighted sum has to be a probability.
f_k = per_k / mass
print("F_k ", np.round(f_k, 6))
print("sum_k mass[k]*F_k", round(float((mass * f_k).sum()), 8))
# -> F_k [0. 0. 1.762677 0.607564 1.175289]
# -> sum_k mass[k]*F_k 0.01401875
# 5. Flat sampling gives every trajectory the SAME magnitude, |w| = Gamma, so only
# the sign moves. Within stratum k the magnitude is Gamma*P(k) and the mean sign
# is r^k = mass/(Gamma*P(k)), the quantity the estimator turns into F_k.
phi = gfault / gamma
pk = np.array([1.0])
for _ in range(A):
pk = np.convolve(pk, [1 - phi, phi])
print("Gamma", round(gamma**A, 6))
print("mean sign per stratum", np.round(mass / (gamma**A * pk), 6))
print("r^k ", np.round((b / gfault) ** np.arange(A + 1), 6))
# -> Gamma 1.102433
# -> mean sign per stratum [1. 0.604114 0.364953 0.220473 0.133191]
# -> r^k [1. 0.604114 0.364953 0.220473 0.133191]import numpy as np
from qliff.noise.sampler import build_strata
from qliff.qec.codes import rotated_surface_code
from qliff.qec.sampler import StratifiedDetectorSampler, WeightedDetectorSampler
from qliff.qec.threshold import logical_error_rate
circuit = rotated_surface_code(rows=3, cols=3, rounds=1, p=0.05, channel="AMPLITUDE_DAMP")
# 1. Flat importance sampling: one magnitude, two signs.
_dets, _obs, weights = WeightedDetectorSampler(circuit).sample(3000, seed=7)
print("distinct |w|:", np.unique(np.round(np.abs(weights), 9)))
print("signs:", int((weights > 0).sum()), "plus /", int((weights < 0).sum()), "minus")
# -> distinct |w|: [1.24535216]
# -> signs: 2710 plus / 290 minus
# 2. The strata, computed rather than sampled.
locs, _phis, pk, mass = build_strata(circuit.instructions)
big = float(np.prod([g for _branches, g in locs]))
print("locations", len(locs), " Gamma", round(big, 6), " sum(mass)", round(sum(mass), 12))
for k in range(4):
print(f"k={k} mass {mass[k]:+.6f} P(k) {pk[k]:.6f} mean sign {mass[k] / (big * pk[k]):.6f}")
# -> locations 9 Gamma 1.245352 sum(mass) 1.0
# -> k=0 mass +0.707874 P(k) 0.568412 mean sign 1.000000
# -> k=1 mass +0.249318 P(k) 0.331392 mean sign 0.604114
# -> k=2 mass +0.039027 P(k) 0.085869 mean sign 0.364953
# -> k=3 mass +0.003564 P(k) 0.012979 mean sign 0.220473
# 3. The stratified sampler returns SIGNS and stratum labels, never a weight.
_d, _o, signs, strata = StratifiedDetectorSampler(circuit).sample(3000, seed=7)
print("trajectories", len(signs))
for k in np.unique(strata)[:4]:
sel = strata == k
print(f"k={k} n {int(sel.sum()):4d} sum(sign) {signs[sel].sum():+6.0f} mean {signs[sel].mean():.4f}")
# -> trajectories 2997
# -> k=0 n 1 sum(sign) +1 mean 1.0000
# -> k=1 n 2292 sum(sign) +1370 mean 0.5977
# -> k=2 n 595 sum(sign) +211 mean 0.3546
# -> k=3 n 91 sum(sign) +11 mean 0.1209
# 4. End to end. `prep` projects into the code space first, so the damping has an
# excited population to act on; stratify picks the estimator.
memory = rotated_surface_code(
rows=3, cols=3, rounds=2, p=0.05, channel="AMPLITUDE_DAMP", prep=True
)
for stratify in (False, True):
ler, stderr = logical_error_rate(memory, "coherent", shots=1200, seed=0, stratify=stratify)
print(f"stratify={str(stratify):5s} LER = {ler:.5f} +/- {stderr:.5f}")
# -> stratify=False LER = 0.00646 +/- 0.00592
# -> stratify=True LER = 0.02295 +/- 0.00356One circuit, two ways to spend a shot. Flat importance sampling books