Skip to content

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 I,Z, and R, with weights

qI=0.962340,qZ=−0.012340,qR=0.050000.

To take one shot, the location draws a branch with probability |qk|/γ and multiplies the shot's running weight by sign(qk)γ, where

γ=∑k|qk|=1−p+p=1.024679.

This γ is what the page is about. A Pauli channel has every qk≥0 and γ=1. A channel the simulator cannot represent needs a negative weight, and γ measures how far above 1 that pushes it. It is the price of running one location.

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.

python
from qliff.qec.codes import rotated_surface_code

circuit = rotated_surface_code(3, 3, 1, 0.05, channel="AMPLITUDE_DAMP")

Each of the nine locations multiplies the weight by its γ, so a shot that runs the whole circuit comes back with magnitude

Γ=∏i=19γi=γ9=1.245352.

Γ is the price of one shot, and the whole page is about paying less of it. The reason it is so expensive comes first, then the fix: sort the shots by how many locations actually faulted, and Γ divides out before any number is formed.

Every trajectory carries the same magnitude ​

Draw three thousand weighted trajectories and look at the weights themselves, not their average:

python
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: 2699 plus / 301 minus

Three thousand shots, one distinct magnitude. Every trajectory came back carrying Γ=1.245352, and the only thing that differed between them was the sign.

3000 weighted trajectories, one magnitude
  • weight = +Γ
  • weight = -Γ
  • +1.2453522699
  • -1.245352301
Bar length is the shot count, colour is the sign of the weight. There is no third bar and no spread: the sampler's weight histogram for this circuit is two spikes at ±Γ, seed 7.

That is not a coincidence, it is the sampling rule. At every noise location the shot's weight multiplies by sign(qk)γ, and |sign(qk)γ|=γ whether the location fired the identity branch or a fault branch. After A locations,

|w|=∏i=1Aγi=Γfor every shot, always.

So the logical error rate comes out of the flat logical_error_rate as the mean of N numbers, each of which is +Γ, −Γ or 0, and the answer is a probability of order 10−2 or smaller. The estimator is asking cancellation to do all the work. Its standard error inflates by Γ over the Pauli case, so the shot budget inflates by Γ2 (the same rule the LER page uses for coherent noise):

Nflat=Γ2Nbinom,Nbinom=1−LERLER⋅ε2  for a relative bar ε.

And Γ is a product over every noise location, so it grows exponentially with the size of the experiment. Keeping p=0.05 and running d rounds of a distance-d patch:

Gamma across surface-code sizes (amplitude damping, p = 0.05, d rounds)
  • noise locations A = d2 × d
  • flat shot-budget factor Γ2
distance dlocations AΓΓ2
3271.933.7
512521.064.4 x 102
734342821.8 x 107
Computed with qliff's own build_strata, not modelled: A is the number of noise instructions the circuit emits and Γ the product of their per-location gammas. Each step in d multiplies the shot budget by orders of magnitude while the physics stays the same.

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 Γ=2.83, 141 and 8.6×105 at d=3,5,7 for amplitude damping, and Γ=6.6×1017 at d=3 for coherent RZ noise. At 6.6×1017 there is no shot budget. That is the wall.

The Gamma wall

Legend:

  • purple curve = Γ=γA vs noise locations A (log y)
  • red dot = selected A
  • Pauli shots = binomial budget at a 10% bar
  • flat shots = Pauli shots times Γ2

Sort the shots by how many locations faulted ​

The move is to split the trajectories into strata by their fault count k: how many of the A locations took a branch other than identity. Every trajectory lands in one stratum, so the answer splits with them:

LER=∑k=0AmkFk,

where mk is the stratum's total signed quasiprobability mass and Fk the conditional error rate given k faults. Nothing has been approximated: this is the law of total probability, written for a signed measure.

The point is that mk does not have to be sampled. Summarise location i by two numbers: ai, the weight of its identity branch, and bi, the summed weight of all its fault branches. Trace preservation says every location's weights sum to one, so

ai+bi=1for every location.

Choosing a branch at each location is then choosing one term of the product ∏i(ai+bi), and collecting the terms with k fault factors is reading off a polynomial coefficient:

∑kmkxk=∏i=1A(ai+bix),mk=[xk]∏i(ai+bix).

That is stratum_masses in qliff/noise/sampler.py, one convolution per location. For the running example, a=qI=0.962340 and b=qZ+qR=0.037660 at every location, giving

stratum kmass m_ksampled P(k)mean sign
0+0.7078740.5684121.000000
1+0.2493180.3313920.604114
2+0.0390270.0858690.364953
3+0.0035640.0129790.220473

Two distributions, not one, and they differ. mk is the truth: the signed mass the channel puts on k faults, summing to 1. P(k) is what the sampler draws: the Poisson-binomial of the per-location fault probabilities φi=gfaulti/γi, where gfaulti=∑k≠I|qk| is the location's summed absolute fault weight. The split between the two columns is the crux: the mass is built from the signed fault weight bi, while the sampler draws from the absolute gfaulti. The last column, mk/(ΓP(k)), measures the gap that opens up between them, and the next section is what it costs.

The masses are signed in general

For amplitude damping both a and b come out positive, so every mk here is positive and the masses read like an ordinary probability distribution. That is a property of this channel, not of the method: nothing forces ai or bi to be non-negative, and where they are not, some mk are negative and the "conditional error rate" Fk is a ratio of signed masses that can fall outside [0,1]. Only the total ∑kmkFk has to be a probability.

Where the answer lives

Legend:

  • purple bar = mk, the signed mass of stratum k (red if negative)
  • grey bar = P(k), what the sampler draws from
  • mean sign = mk/(ΓP(k))

Self-normalising one stratum ​

Now sample inside a stratum. Fix k, draw k faulty locations from the Poisson-binomial (fault_set), and at each of them draw a fault branch with probability |w|/gfaulti (pick_branch). Write S for the chosen fault set. The trajectory's true weight and the probability of having drawn it are

wtrue=∏i∉Sai∏i∈Swji,Pr[traj]=1P(k)∏i∉Saiγi∏i∈S|wji|γi,

using 1−φi=ai/γi and φi=gfaulti/γi. Divide, and every ai and every |wji| cancels:

wtruePr[traj]=ΓP(k)∏i∈Ssign(wji)=ΓP(k)⋅σ,

with σ=±1 the product of the chosen branches' signs. Every trajectory in stratum k carries the same importance weight up to that sign. The natural first move is to estimate Fk as a ratio, dividing that common weight out:

F^kratio=∑shots in kσ⋅1[decoder wrong]∑shots in kσ.

The common factor ΓP(k) divides out of numerator and denominator, so Γ never touches the arithmetic. This is the self-normalisation, and it is why DetectorSampler.sample_strata hands back signs and stratum labels instead of weights. It takes the shots per stratum explicitly, and neyman_counts spreads a budget over the strata (proportional to P(k) when no spreads are given, at least two shots each):

python
from qliff.noise.sampler import neyman_counts
from qliff.qec.sampler import DetectorSampler

sampler = DetectorSampler(circuit)
_locs, _phis, pk, _mass = sampler.strata
counts = neyman_counts({k: p for k, p in enumerate(pk) if p > 0.0}, None, 3000)
_dets, _obs, signs, strata = sampler.sample_strata(counts, seed=7)
print(sorted(set(signs.tolist())))
# -> [-1.0, 1.0]

That denominator is a sampled sum of ±1 signs, and the next section shows it decays like rk. Once it is small, dividing by it is dividing by noise. So qliff does not take the ratio. Its expectation is already known: E[σ]=rk=mk/(ΓP(k)), the mean-sign column of the mass table. Rather than divide by a noisy estimate of rk, subtract the known rk as a control variate:

y=σ1[wrong]−β(σ−rk),LER^=∑kΓP(k)yk―.

Because E[σ]=rk holds analytically, E[y]=E[σ1[wrong]] for any β: the estimate is unbiased, and there is no denominator to blow up. The coefficient β is the variance-optimal Cov(σ1[wrong],σ)/Var(σ), computed leave-one-out (_loo_beta) so it never scores the shot it was fit on. At β=0 it reduces to the plain within-stratum mean ΓP(k)σ1[wrong]―, and the control variate only shrinks the variance from there. That is logical_error_rate(..., stratify=True) in qliff/qec/threshold.py.

Two consequences:

  • Γ never appears in the answer. The flat estimator averages numbers of size Γ. This one averages ±1 signs and multiplies by masses that sum to 1. At Γ=6.6×1017 that is the difference between an estimator that returns a number and one that does not.
  • The k=0 stratum is free. Every k=0 trajectory is the same identity-everywhere run, so F0 is evaluated rather than estimated. On the running example that one configuration settles 70.8% of the answer. The pilot pass still seats k=0 by P(0), but its zero spread sends the Neyman remainder to the strata that vary.
The two ledgers, side by side
  • flat importance sampling
  • stratified, control-variate
flatstratified
per-shot payloadsigned weight ±Γsign ±1 + stratum k
estimatormean(w · wrong)Σk ΓP(k) · mean(yk)
fault-count weightssampledcomputed
k = 0 stratum~57% of shotspilot share only, exact
residual noise sourceΓ across all A locationssign cancellation across k faults
Both estimators are unbiased for the same quantity and both run the same trajectories through the same decoder. They differ in what each shot is allowed to carry, and therefore in what sets the error bar.

What survives: the signs ​

Cancelling Γ does not remove the sign problem, it relocates it. The sign in question is the quasiprobability sign sign(qk) each branch carries. The estimator keeps every one of them, because the ratio is built from nothing else. What changes is where the cost of those signs lands. Take the expectation of the denominator. A faulty location picks fault branch j with probability |wj|/gfaulti, so its mean sign is

ri=∑j|wj|gfaultisign(wj)=bigfaulti,

the ratio of the location's signed fault weight to its absolute one. With identical locations the mean sign of a stratum-k trajectory is rk, and that is the third column of the mass table: mk/(ΓP(k))=rk. For amplitude damping,

r=qR+qZqR+|qZ|=0.0376600.062340=0.604114(p=0.05),

and it barely moves with p: 0.6008 at p=0.01, 0.6041 at p=0.05, 0.6180 at p=0.2. Coherent RZ is harsher, r≈0.22 at θ=0.05 and 0.27 at θ=0.2, because two of its three fault branches (Z and S†) carry negative weight.

The measured signs match:

python
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 1695  sum(sign)  +1695  mean 1.0000
# -> k=1  n  989  sum(sign)   +619  mean 0.6259
# -> k=2  n  257  sum(sign)    +81  mean 0.3152
# -> k=3  n   40  sum(sign)     +2  mean 0.0500

At k=1 the signs lean +0.63 against an expected 0.60 and the stratum is well resolved. By k=3 its 40 shots net +2, a mean of 0.05 against an expected 0.22, and past that rk sinks into the sampling noise. The control variate removes the ratio's divide-by-a-vanishing-number failure, but it cannot manufacture information the signs no longer carry: as rk→0 each y is almost pure sign and little signal, so Var(y) climbs and the stratum's contribution turns noisy. Nothing divides by zero, but a stratum whose signs have washed out still spends shots for almost nothing.

Notice the exponent. Γ is exponential in A, the number of locations. The surviving cancellation is exponential in k, the number of faults. Stratification swaps one exponent for the other. Whether that is a good trade depends on the channel, and the last section puts measured numbers on it.

Sign cancellation inside one stratum

Legend:

  • green + = trajectory sign +1
  • red - = trajectory sign −1
  • filled cell = decoder wrong
  • sum(sign) = net sign of the stratum (washes out as r^k)

The Pauli sanity check ​

Everything above collapses to something ordinary when the noise is Pauli. Every branch has qk≥0, so γ=1 and Γ=1, the signed fault weight equals the absolute one so r=1 and every trajectory sign is +1, and mk is the Poisson-binomial P(k) itself.

python
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)
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: True

With every sign +1, the control variate's (σ−rk) term vanishes and y=1[wrong], so each stratum contributes P(k) times its plain failure rate. The method is ordinary stratified sampling: partition by fault count, count failures in each part, recombine with known weights. The signs and the two-distribution split are all a non-Pauli channel adds on top.

Worked example: stratum k = 1 of the 3x3 damping patch

Nine locations, all identical, a=0.962340 and b=0.037660.

  1. Mass. One location faults, the other eight do not, and there are nine ways to choose which: m1=9a8b=9×0.9623408×0.037660=0.249318.
  2. Sampling odds. The sampler's per-location fault probability is φ=gfault/γ=0.062340/1.024679=0.060838, so P(1)=9(1−φ)8φ=0.331392. It draws stratum 1 more often than its mass deserves, because it is drawing from |q|, not q.
  3. Weight magnitude. Every stratum-1 trajectory carries ΓP(1)=1.245352×0.331392=0.412700, times its sign. The magnitude is identical for all 989 of them.
  4. Mean sign. The one faulty location picks Z with probability |qZ|/gfault=0.012340/0.062340=0.198 and R with probability 0.802, so the mean sign is 0.802−0.198=0.604114=r. Times ΓP(1) that is 0.249318=m1: the identity mk=ΓP(k)rk closes.
  5. The estimate. The sampler drew 989 trajectories here, 804 with sign +1 and 185 with sign −1, so ∑σ=619 against an expectation of 989×0.604114=597. F^1 is ∑σ1[wrong] over that 459, and stratum 1 contributes m1F^1 to the logical error rate.

Result: the 0.249318 of the answer that lives at k=1 was known before a single shot was taken. The 989 shots were spent only on F1, and the ΓP(1)=0.4127 they each carried never entered the arithmetic.

What it costs ​

The control variate does not need a crossover argument to be safe. At β=0 it is a stratified flat mean, and the optimal β only lowers the variance from there, so it is never worse than flat by more than sampling noise. What is left to ask is how much it wins, and that is capped by the sign decay rk: past the resolvable zone a stratum's shots carry almost no information no matter how many you take.

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:

Estimator spread over 8 seeds, distance-3 patch, p = 0.05
  • flat importance sampling
  • stratified, control-variate
roundsAΓflat spreadstratified spread
2181.550.00660.0030
4362.410.02080.0117
6543.730.01880.0224
1200 requested shots per estimate, seeds 0-7, decoder "coherent". Measured with an earlier shot allocator; the current pilot-then-Neyman split in logical_error_rate was not re-measured for this table, so read the numbers as the trend, not current values. At Γ around 1.5 to 2.4 the control variate roughly halves the spread. By Γ around 3.7 the sign decay has caught up and the two are level. The retired self-normalised ratio lost to flat at every size here, which is why it was replaced.

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 rk. Amplitude damping's r=0.604 is steep, so within a handful of faults a stratum's signs have washed out. A channel whose fault branches are mostly positive has r near 1 and barely pays.
  • Where the shots go. logical_error_rate(..., stratify=True) spends a quarter of the shots on a pilot proportional to P(k), measures each stratum's spread Vk on the first decoder, then spends the rest by Neyman, nk∝P(k)Vk (both through neyman_counts). Proportional allocation alone only ties flat sampling. The Vk factor is the win, moving shots off the strata that never vary and onto the ones that carry the failures.
  • How large Γ is. At Γ≈2 flat is barely inconvenienced, so the margin is modest. At Γ=8.6×105 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

neyman_counts seats two shots per stratum, heaviest first, only while the budget lasts. When the strata no shot reached hold more than 0.1% of the absolute quasiprobability mass, logical_error_rate(..., stratify=True) raises a RuntimeWarning naming the fraction: that is the part of the answer the run did not look at, a bias rather than noise. On the running example every stratum is seated. Raise shots when it fires.

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 34 branch assignments to get the true logical error rate, and checks ∑kmkFk against it to the last digit. It also confirms mk/(ΓP(k))=rk, the identity the whole page turns on.

Qliff. The same objects from the library: build_strata for the masses, WeightedDetectorSampler for the flat weights, DetectorSampler.sample_strata with neyman_counts for the signs and strata, and logical_error_rate(..., stratify=True) for the end-to-end estimate.

text
# --- 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 with P(k) > 0:
    n_k  = shots for stratum k (pilot ~ P(k), then Neyman: n_k ~ P(k)*sqrt(V_k))
    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 denominator
python
import 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]
python
import numpy as np

from qliff.noise.sampler import build_strata, neyman_counts
from qliff.qec.codes import rotated_surface_code
from qliff.qec.sampler import DetectorSampler, WeightedDetectorSampler
from qliff.qec.threshold import logical_error_rate

circuit = rotated_surface_code(3, 3, 1, 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: 2699 plus / 301 minus

# 2. The strata, computed rather than sampled.
locs, _phis, pk, mass = build_strata(circuit)
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.
counts = neyman_counts({k: p for k, p in enumerate(pk) if p > 0.0}, None, 3000)
_d, _o, signs, strata = DetectorSampler(circuit).sample_strata(counts, 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 2996
# -> k=0  n 1695  sum(sign)  +1695  mean 1.0000
# -> k=1  n  989  sum(sign)   +619  mean 0.6259
# -> k=2  n  257  sum(sign)    +81  mean 0.3152
# -> k=3  n   40  sum(sign)     +2  mean 0.0500

# 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(
    3, 3, 2, 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.00599
# -> stratify=True   LER = 0.02497 +/- 0.00560

One circuit, two ways to spend a shot. Flat importance sampling books sign(q)γ at every location and hands the estimator a number of size Γ. Stratified sampling computes the fault-count masses, spends its shots on the conditional rates, and subtracts the known mean sign so ΓP(k) never enters the arithmetic. What is left is a sign problem measured in faults rather than in locations, plus the arithmetic of which strata your budget reached. Both feed the same scoreboard: the logical error rate, its error bar, and the threshold.