Chapter 5: Error Mitigation as Software, and Resource Estimation

Readout Correction, Zero-Noise Extrapolation by Gate Folding, Probabilistic Error Cancellation, the Exponential Wall They All Run Into, and the Pipeline That Prices the Alternative

๐Ÿ“– Reading Time: 45-50 minutes ๐Ÿ“Š Difficulty: Advanced ๐Ÿ’ป Code Examples: 8 ๐Ÿ“ Exercises: 5

๐ŸŒ EN | ๐Ÿ‡ฏ๐Ÿ‡ต JP | Last sync: 2026-08-13

Fundamental Mathematics Dojo > Introduction to the Quantum Software Stack > Chapter 5

The four chapters before this one built a stack that assumed the hardware works. An optimizer that removes gates, a router that adds SWAPs, a calibration loop that pushes a single-qubit gate error to $5 \times 10^{-6}$ โ€” all of it is an attempt to make a circuit short and accurate, and all of it stops helping once the circuit is longer than the error rate allows. At $10^{-3}$ per two-qubit gate, a thousand-gate circuit has already lost. This chapter is about the layer that sits on top of that fact and does something about it anyway.

There are exactly two things software can do. It can mitigate: leave the noise alone, run modified circuits, and post-process the results so that the expectation value comes out closer to the noiseless one. It can also estimate: work out what the noise-free alternative would cost, in physical qubits and wall-clock time, so that the mitigation question can be answered with numbers rather than adjectives. Sections 5.1 to 5.3 implement three mitigation methods and measure both what each one buys and what each one costs. Section 5.4 states the boundary as plainly as it can be stated: mitigation costs grow exponentially in the size of the circuit and error correction costs grow polynomially, so there is a circuit size above which no amount of post-processing helps, and Section 5.5 computes where it is. Both halves of that sentence get numbers, because error mitigation is genuinely load-bearing on the hardware that exists and exponentially expensive, and dropping either half of that produces a wrong picture of the field.

Learning Objectives

After completing this chapter, you will be able to:

What Carries Over

Everything here runs on the circuit IR of Chapter 1 and the state-vector simulator underneath it, both re-listed in Example 1 together with the equivalence checker โ€” because a noise-amplification pass is a rewriting pass, and every rewriting pass in this course has to prove it preserved the meaning. Two functions from the introductory course's simulator that Chapter 1 did not need appear here as well, PAULI and expval, re-listed verbatim from the same source.

Three numerical anchors come from the sister courses and this chapter stays consistent with all of them. Introduction to Quantum Computing, Chapter 5 gives the surface-code scaling $p_L \approx A(p/p_{\text{th}})^{(d+1)/2}$ with $A = 0.1$ and $p_{\text{th}} = 10^{-2}$, and a table of code distances and qubit overheads that Example 8 reproduces line for line. It also gives the Richardson weight norms $\lVert w \rVert_2 = 2.24, 4.36, 8.31$ for two, three and four noise scales, which Example 5 recomputes. Intermediate Quantum Algorithms, Chapter 4 gives the qubitized phase-estimation Toffoli counts for a FeMoco-scale calculation and the conversion to days and physical qubits; Example 8 reproduces those too. One convention differs between the two sisters by exactly one qubit โ€” the rotated surface code is quoted as $2d^2 - 1$ physical qubits per logical qubit in the first and $2d^2$ in the second โ€” and the code here uses $2d^2 - 1$ and says so.

One difference from the sister course's treatment of zero-noise extrapolation is deliberate. There, the noise strength was scaled by turning up the error probability directly, which is what a simulator can do and hardware cannot. Here it is scaled by folding gates, which is what hardware actually does, and the difference turns out to matter: folding reaches only odd integer scales unless you work for fractional ones, and it is blind to one whole class of error.


5.1 Readout-Error Mitigation

The one error that is classical

Of all the error channels in a quantum computer, exactly one is a classical, stationary, measurable stochastic process: the assignment error of the final measurement. A qubit in $\lvert 0 \rangle$ is reported as 1 with some probability $\epsilon_{01}$, a qubit in $\lvert 1 \rangle$ is reported as 0 with probability $\epsilon_{10}$, and on current superconducting hardware those numbers are between one and five percent โ€” one to two orders of magnitude larger than the gate errors of Chapter 4. Because it is classical, it can be inverted exactly, and because it is the largest single error in many experiments, inverting it is the highest-value thing in this chapter.

Write the measured distribution over the $2^n$ bitstrings as a vector $\mathbf{p}_{\text{meas}}$ and the distribution the circuit actually produced as $\mathbf{p}_{\text{true}}$. Assignment error is a stochastic matrix acting between them:

$$ \mathbf{p}_{\text{meas}} = M\,\mathbf{p}_{\text{true}}, \qquad M_{mt} = P(\text{read } m \mid \text{prepared } t) $$

$M$ is the confusion matrix, its columns sum to 1, and it is measurable: prepare each of the $2^n$ basis states, measure, and each experiment gives you one column. Then $\mathbf{p}_{\text{true}} = M^{-1}\mathbf{p}_{\text{meas}}$, and the whole method is one line of linear algebra. Two things go wrong with that line, and both matter.

Code Example 1: The Simulator, the IR and the Checker, Re-listed

"""Minimal state-vector simulator (big-endian: qubit 0 = leftmost = most significant).

Save this file as qcsim.py; every later example does `from qcsim import *`.
"""
import numpy as np

# ---- single-qubit gates -------------------------------------------------
I2 = 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)
H = np.array([[1, 1], [1, -1]], dtype=complex) / np.sqrt(2)
S = np.array([[1, 0], [0, 1j]], dtype=complex)
T = np.array([[1, 0], [0, np.exp(1j * np.pi / 4)]], dtype=complex)


def rx(theta):
    c, s = np.cos(theta / 2), np.sin(theta / 2)
    return np.array([[c, -1j * s], [-1j * s, c]], dtype=complex)


def ry(theta):
    c, s = np.cos(theta / 2), np.sin(theta / 2)
    return np.array([[c, -s], [s, c]], dtype=complex)


def rz(theta):
    e = np.exp(-1j * theta / 2)
    return np.array([[e, 0], [0, np.conj(e)]], dtype=complex)


# ---- states -------------------------------------------------------------
def ket(bits: str) -> np.ndarray:
    """'01' -> the 4-dimensional basis state |01> (big-endian)."""
    n = len(bits)
    psi = np.zeros(2 ** n, dtype=complex)
    psi[int(bits, 2)] = 1.0
    return psi


def apply_gate(state, U, targets, n):
    """Apply the 2^k x 2^k unitary U to the listed target qubits of an n-qubit state."""
    k = len(targets)
    psi = state.reshape([2] * n)          # 1. view as an n-index tensor
    psi = np.moveaxis(psi, targets, range(k))   # 2. bring targets to the front
    rest = psi.shape[k:]
    psi = psi.reshape(2 ** k, -1)         # 3. flatten and multiply
    psi = U @ psi
    psi = psi.reshape(list((2,) * k) + list(rest))
    psi = np.moveaxis(psi, range(k), targets)   # 4. put the axes back
    return psi.reshape(-1)


CNOT4 = np.array([[1, 0, 0, 0],
                  [0, 1, 0, 0],
                  [0, 0, 0, 1],
                  [0, 0, 1, 0]], dtype=complex)


def cnot(state, control, target, n):
    """CNOT with the given control and target; any pair of qubits, any order."""
    return apply_gate(state, CNOT4, [control, target], n)


def probs(state):
    """Born-rule probabilities of all 2^n outcomes."""
    return np.abs(state) ** 2


PAULI = {'I': I2, 'X': X, 'Y': Y, 'Z': Z}


def expval(state, pauli, coeff_map=None):
    """Expectation value of a Pauli string such as 'ZZ', 'XI' (one character per qubit).

    If coeff_map is given, the result is multiplied by coeff_map[pauli], so that a
    whole Hamiltonian is one line:  sum(expval(psi, p, terms) for p in terms).
    """
    n = len(pauli)
    phi = state.copy()
    for q, ch in enumerate(pauli):
        if ch != 'I':
            phi = apply_gate(phi, PAULI[ch], [q], n)
    val = np.vdot(state, phi).real
    if coeff_map is not None:
        val *= coeff_map.get(pauli, 1.0)
    return val


# ---- the circuit IR of Chapter 1, re-listed (qir.py) --------------------
CZ4 = np.diag([1.0, 1.0, 1.0, -1.0]).astype(complex)

FIXED_1Q = {"h": H, "x": X, "z": Z, "s": S, "t": T}
ROT_1Q = {"rx": rx, "ry": ry, "rz": rz}
TWO_Q = ("cx", "cz")


def gate_qubits(g):
    """The qubits one gate tuple touches, in the order they are written."""
    if g[0] in ROT_1Q:
        return (g[2],)
    if g[0] in TWO_Q:
        return (g[1], g[2])
    if g[0] in FIXED_1Q:
        return (g[1],)
    raise ValueError(f"unknown gate name {g[0]!r}")


def apply_ir_gate(state, g, n):
    """Apply one gate tuple to an n-qubit state vector."""
    name = g[0]
    if name in FIXED_1Q:
        return apply_gate(state, FIXED_1Q[name], [g[1]], n)
    if name in ROT_1Q:
        return apply_gate(state, ROT_1Q[name](g[1]), [g[2]], n)
    if name == "cx":
        return cnot(state, g[1], g[2], n)
    if name == "cz":
        return apply_gate(state, CZ4, [g[1], g[2]], n)
    raise ValueError(f"unknown gate name {name!r}")


def run_circuit(circ, n, psi0=None):
    """Execute a gate-tuple list on the state-vector simulator; return the final state.

    psi0 defaults to |00...0>. Gates are applied left to right, so the matrix
    of the circuit is the product of the gate matrices in reverse order.
    """
    state = ket("0" * n) if psi0 is None else np.asarray(psi0, dtype=complex)
    for g in circ:
        state = apply_ir_gate(state, g, n)
    return state


def circuit_depth(circ, n):
    """Greedy layering by qubit disjointness: how many layers the circuit needs.

    Every gate is assumed to take one unit of time, which is false on hardware
    and is corrected in Chapter 4.
    """
    ready = [0] * n              # first free layer of each qubit
    for g in circ:
        qs = gate_qubits(g)
        layer = max(ready[q] for q in qs)
        for q in qs:
            ready[q] = layer + 1
    return max(ready) if n else 0


def gate_counts(circ):
    """Gate name -> count, plus the key "2q" holding the total of two-qubit gates."""
    counts = {}
    for g in circ:
        counts[g[0]] = counts.get(g[0], 0) + 1
    counts["2q"] = sum(counts.get(name, 0) for name in TWO_Q)
    return counts


# ---- the equivalence checker of Chapter 1, re-listed --------------------
def unitary_of(circ, n):
    """The 2^n x 2^n matrix of a circuit: run it once on each basis state."""
    dim = 2 ** n
    U = np.empty((dim, dim), dtype=complex)
    for j in range(dim):
        e = np.zeros(dim, dtype=complex)
        e[j] = 1.0
        U[:, j] = run_circuit(circ, n, psi0=e)
    return U


def best_global_phase(U, V):
    """The phase that makes e^{i phi} V as close to U as a phase can make it.

    It comes from the Hilbert-Schmidt overlap tr(V^dagger U). By Cauchy-Schwarz
    the modulus of that overlap reaches 2^n exactly when U = e^{i phi} V, so an
    overlap near zero is already a proof that the two are inequivalent.
    """
    tr = np.trace(V.conj().T @ U)
    return 1.0 + 0.0j if abs(tr) < 1e-12 else tr / abs(tr)


def phase_free_error(U, V):
    """max |U - e^{i phi} V| after removing the best global phase."""
    return float(np.max(np.abs(U - best_global_phase(U, V) * V)))


def assert_equivalent(a, b, n, label="", atol=1e-10):
    """The test that guards every rewriting pass in this course."""
    err = phase_free_error(unitary_of(a, n), unitary_of(b, n))
    if err > atol:
        raise AssertionError(f"{label}: circuits differ, max error {err:.3e}")
    return err


print("The simulator, the IR and the checker, re-listed and validated")
print("=" * 74)
bell = [("h", 0), ("cx", 0, 1)]
ghz = [("h", 0)] + [("cx", i, i + 1) for i in range(3)]
print(f"  Bell amplitudes      : {np.round(run_circuit(bell, 2).real, 6)}")
print(f"  GHZ(4) probabilities : "
      f"{np.round(probs(run_circuit(ghz, 4))[[0, 15]], 6)} at |0000>, |1111>")
print(f"  GHZ(4) <ZZZZ>        : {expval(run_circuit(ghz, 4), 'ZZZZ'):.6f}")
print(f"  GHZ(4) gate counts   : {gate_counts(ghz)}")
print(f"  GHZ(4) depth         : {circuit_depth(ghz, 4)}")
print(f"  checker on itself    : {assert_equivalent(bell, bell, 2):.2e}")
print(f"  checker on a wrong circuit: "
      f"{phase_free_error(unitary_of(bell, 2), unitary_of([('h', 0), ('h', 1)], 2)):.3f}")
The simulator, the IR and the checker, re-listed and validated
==========================================================================
  Bell amplitudes      : [0.707107 0.       0.       0.707107]
  GHZ(4) probabilities : [0.5 0.5] at |0000>, |1111>
  GHZ(4) <ZZZZ>        : 1.000000
  GHZ(4) gate counts   : {'h': 1, 'cx': 3, '2q': 3}
  GHZ(4) depth         : 4
  checker on itself    : 0.00e+00
  checker on a wrong circuit: 1.207

What to notice. Nothing here is new: the simulator functions are the introductory course's, verbatim; run_circuit, circuit_depth and gate_counts are Chapter 1's IR, verbatim; unitary_of through assert_equivalent are Chapter 1's equivalence checker, verbatim. assert_equivalent returns the phase-free error rather than merely raising, which is what lets it be printed in a table โ€” and it is the function that makes the noise amplification of Section 5.2 auditable rather than hopeful.

Code Example 2: The Confusion Matrix, and Why Its Inverse Is Not Enough

"""Chapter 5, Example 2: the confusion matrix, its inverse, and why the inverse
is not enough. Continues from Example 1 (same session)."""
from functools import reduce

# per-qubit (eps01, eps10) = (P(read 1 | 0), P(read 0 | 1)); the correction
# routines below never read this tuple, they only see calibration counts
RATES = [(0.030, 0.060), (0.020, 0.050), (0.050, 0.090)]


def confusion_matrix(rates):
    """M[m, t] = P(read m | prepared t) for independent per-qubit readout error.

    Big-endian, so qubit 0 is the leftmost factor of the Kronecker product.
    """
    return reduce(np.kron, [np.array([[1 - e01, e10], [e01, 1 - e10]])
                            for e01, e10 in rates])


def calibrate(rates, shots, rng):
    """The 2^n calibration experiments: prepare each basis state, count outcomes.

    This is the whole cost of readout mitigation and the whole reason it does
    not scale: one circuit per basis state.
    """
    M_true = confusion_matrix(rates)
    return np.column_stack([rng.multinomial(shots, M_true[:, t]) / shots
                            for t in range(M_true.shape[1])])


def project_simplex(v):
    """Euclidean projection of v onto the probability simplex."""
    u = np.sort(v)[::-1]
    css = np.cumsum(u)
    k = np.nonzero(u * np.arange(1, v.size + 1) > css - 1.0)[0][-1]
    return np.maximum(v - (css[k] - 1.0) / (k + 1.0), 0.0)


def ls_simplex(M, p, iters=4000):
    """argmin ||M x - p||^2 over the probability simplex, by projected gradient."""
    x = np.full(p.size, 1.0 / p.size)
    step = 1.0 / np.linalg.norm(M, 2) ** 2
    for _ in range(iters):
        x = project_simplex(x - step * (M.T @ (M @ x - p)))
    return x


def tvd(p, q):
    """Total variation distance."""
    return 0.5 * float(np.sum(np.abs(p - q)))


def z_expval(p, mask, n):
    """Expectation of a product of Z operators, read off a measured distribution.

    mask selects the qubits; big-endian, so qubit q is bit n - 1 - q.
    """
    return float(sum(p[m] * (-1) ** bin(m & mask).count("1")
                     for m in range(2 ** n)))


N = 3
rng = np.random.default_rng(20260813)
circ = [("h", 0), ("cx", 0, 1), ("cx", 1, 2)]
p_true = probs(run_circuit(circ, N))
M_true = confusion_matrix(RATES)
M_cal = calibrate(RATES, 20000, rng)

print("A. The confusion matrix")
print("=" * 74)
print(f"  qubits = {N}, so the matrix is {2**N} x {2**N} and calibration costs"
      f" {2**N} circuits")
print(f"  per-qubit rates (hidden): {RATES}")
print(f"  M[0,0] = {M_true[0, 0]:.6f}   (all three qubits read correctly"
      " from |000>)")
print(f"  M[7,7] = {M_true[7, 7]:.6f}   (all three read correctly from |111>)")
print(f"  column sums = {np.round(M_true.sum(axis=0), 12)}")
print(f"  condition number of M     : {np.linalg.cond(M_true):.4f}")
print(f"  calibrated at 20000 shots : max |M_cal - M_true| ="
      f" {np.max(np.abs(M_cal - M_true)):.5f}")

print("\nB. Correcting a GHZ measurement")
print("=" * 74)
SHOTS = 8000
p_meas = rng.multinomial(SHOTS, M_true @ p_true) / SHOTS
p_inv = np.linalg.solve(M_cal, p_meas)
p_ls = ls_simplex(M_cal, p_meas)
print(f"  {'state':>7} {'true':>10} {'measured':>10} {'M^-1':>11} {'LS':>10}")
for m in range(2 ** N):
    print(f"  {format(m, '03b'):>7} {p_true[m]:10.4f} {p_meas[m]:10.4f}"
          f" {p_inv[m]:+11.4f} {p_ls[m]:10.4f}")
MASK01 = 0b110                        # Z on qubits 0 and 1; true value +1
print(f"\n  {'':>18} {'TVD to truth':>13} {'<Z0 Z1>':>9} {'error':>10}")
for name, p in (("measured", p_meas), ("M^-1 corrected", p_inv),
                ("LS corrected", p_ls)):
    print(f"  {name:>18} {tvd(p, p_true):13.5f}"
          f" {z_expval(p, MASK01, N):9.4f}"
          f" {z_expval(p, MASK01, N) - z_expval(p_true, MASK01, N):+10.5f}")
print(f"\n  negative entries after M^-1 : "
      f"{int(np.sum(p_inv < 0))} of {2**N}, most negative"
      f" {p_inv.min():+.5f}")
print(f"  sum of |negative part|      : {float(-p_inv[p_inv < 0].sum()):.5f}")
A. The confusion matrix
==========================================================================
  qubits = 3, so the matrix is 8 x 8 and calibration costs 8 circuits
  per-qubit rates (hidden): [(0.03, 0.06), (0.02, 0.05), (0.05, 0.09)]
  M[0,0] = 0.903070   (all three qubits read correctly from |000>)
  M[7,7] = 0.812630   (all three read correctly from |111>)
  column sums = [1. 1. 1. 1. 1. 1. 1. 1.]
  condition number of M     : 1.3982
  calibrated at 20000 shots : max |M_cal - M_true| = 0.00538

B. Correcting a GHZ measurement
==========================================================================
    state       true   measured        M^-1         LS
      000     0.5000     0.4461     +0.4906     0.4895
      001     0.0000     0.0262     +0.0045     0.0027
      010     0.0000     0.0130     +0.0016     0.0000
      011     0.0000     0.0250     -0.0039     0.0000
      100     0.0000     0.0146     -0.0004     0.0000
      101     0.0000     0.0206     -0.0030     0.0000
      110     0.0000     0.0421     +0.0037     0.0026
      111     0.5000     0.4123     +0.5071     0.5051

                      TVD to truth   <Z0 Z1>      error
            measured       0.14162    0.8535   -0.14650
      M^-1 corrected       0.01686    1.0117   +0.01171
        LS corrected       0.01046    1.0000   +0.00000

  negative entries after M^-1 : 3 of 8, most negative -0.00393
  sum of |negative part|      : 0.00741

What to notice. The measurement is badly wrong before correction. A GHZ state should give 0.5 and 0.5 on $\lvert 000 \rangle$ and $\lvert 111 \rangle$ and nothing anywhere else; the measured distribution puts 14% of its weight on the six strings that should be empty, and the parity observable $\langle Z_0 Z_1 \rangle$ comes back as 0.854 instead of 1. That $-0.147$ error is a hundred times a good gate error. Readout mitigation is not a refinement.

Inverting the calibrated matrix removes almost all of it โ€” total variation distance from 0.142 to 0.017, the observable error from $-0.147$ to $+0.012$ โ€” and produces three negative probabilities on the way, the largest $-0.0039$. That is not a bug and better calibration will not fix it: $M^{-1}$ is not a stochastic matrix, it maps the simplex outside itself, and shot noise gets pushed out through the corners. The negative entries are the honest signal that the estimate is unphysical. The standard repair is to minimise $\lVert M x - \mathbf{p}_{\text{meas}} \rVert^2$ over the simplex instead of solving the equation โ€” a ten-line projected-gradient loop, which here beats the raw inverse on both measures (TVD 0.010 against 0.017) and never leaves the simplex. The exact agreement of $\langle Z_0 Z_1 \rangle$ with 1.0000 is luck on this instance; staying physical is not.

The cost, and the assumption everyone makes

The honest method needs one calibration circuit per basis state. That is fine at three qubits and impossible at fifty. The universal workaround is to assume the readout errors are independent, which makes the confusion matrix a tensor product of $2 \times 2$ blocks. Then $2n$ calibration experiments determine it, the inverse is the tensor product of the inverses, and applying it costs $O(n 2^n)$ operations on the distribution without ever forming a $2^n \times 2^n$ matrix. This is what production software does. It is also an assumption about the hardware, and Example 3 breaks it on purpose.

Code Example 3: What Mitigation Costs, and the Assumption That Makes It Affordable

"""Chapter 5, Example 3: what readout mitigation costs, and the assumption that
makes it affordable. Continues from Example 2 (same session)."""
print("A. The cost of the honest version")
print("=" * 74)
print(f"  {'qubits':>7} {'calib. circuits':>16} {'matrix entries':>15}"
      f" {'cond(M)':>12} {'noise blow-up':>14}")
MQ = confusion_matrix([(0.030, 0.060)])
for n in (1, 3, 10, 20, 50):
    print(f"  {n:7d} {2 ** n:16,d} {4 ** n:15.3e}"
          f" {np.linalg.cond(MQ) ** n:12.3e}"
          f" {np.linalg.norm(np.linalg.inv(MQ), 2) ** n:14.3e}")

print("\nB. The tensored shortcut, and its check")
print("=" * 74)


def tensored_inverse_apply(rates, p):
    """Apply the inverse of a tensored confusion matrix without forming it.

    M = M_0 (x) M_1 (x) ... so M^-1 = M_0^-1 (x) M_1^-1 (x) ..., and each factor
    acts on one tensor axis: O(n 2^n) work and O(2^n) memory instead of O(4^n).
    """
    n = len(rates)
    v = p.reshape([2] * n)
    for q, (e01, e10) in enumerate(rates):
        Minv = np.linalg.inv(np.array([[1 - e01, e10], [e01, 1 - e10]]))
        v = np.moveaxis(np.tensordot(Minv, np.moveaxis(v, q, 0), axes=1), 0, q)
    return v.reshape(-1)


p_tens = tensored_inverse_apply(RATES, p_meas)
print(f"  max |tensored inverse - full inverse| = "
      f"{np.max(np.abs(p_tens - np.linalg.solve(M_true, p_meas))):.2e}")
print(f"  calibration circuits: {2 * N} instead of {2 ** N}"
      f"   (2n instead of 2^n)")

print("\nC. What the tensored assumption misses")
print("=" * 74)
C_CORR = 0.040               # probability that qubits 0 and 1 flip together
PERM = np.zeros((2 ** N, 2 ** N))
for t in range(2 ** N):
    PERM[t ^ 0b110, t] = 1.0
M_real = (1 - C_CORR) * M_true + C_CORR * (M_true @ PERM)
marg = []
for q in range(N):
    bit = 2 ** (N - 1 - q)
    e01 = float(sum(M_real[m, 0] for m in range(2 ** N) if m & bit))
    e10 = float(sum(M_real[m, 2 ** N - 1] for m in range(2 ** N)
                    if not m & bit))
    marg.append((e01, e10))
print(f"  correlated flip probability : {C_CORR}")
print("  per-qubit marginals inferred:",
      [(round(a, 4), round(b, 4)) for a, b in marg])
p_meas_r = M_real @ p_true
print(f"\n  {'':>22} {'TVD to truth':>13} {'<Z0 Z1>':>9} {'error':>10}")
for name, p in (("measured", p_meas_r),
                ("tensored correction", tensored_inverse_apply(marg, p_meas_r)),
                ("full-matrix correction", np.linalg.solve(M_real, p_meas_r))):
    print(f"  {name:>22} {tvd(p, p_true):13.5f}"
          f" {z_expval(p, MASK01, N):9.4f}"
          f" {z_expval(p, MASK01, N) - z_expval(p_true, MASK01, N):+10.5f}")
A. The cost of the honest version
==========================================================================
   qubits  calib. circuits  matrix entries      cond(M)  noise blow-up
        1                2       4.000e+00    1.105e+00      1.102e+00
        3                8       6.400e+01    1.347e+00      1.337e+00
       10            1,024       1.049e+06    2.702e+00      2.634e+00
       20        1,048,576       1.100e+12    7.302e+00      6.939e+00
       50 1,125,899,906,842,624       1.268e+30    1.441e+02      1.268e+02

B. The tensored shortcut, and its check
==========================================================================
  max |tensored inverse - full inverse| = 1.11e-16
  calibration circuits: 6 instead of 8   (2n instead of 2^n)

C. What the tensored assumption misses
==========================================================================
  correlated flip probability : 0.04
  per-qubit marginals inferred: [(0.0664, 0.0964), (0.0572, 0.0872), (0.05, 0.09)]

                          TVD to truth   <Z0 Z1>      error
                measured       0.17365    0.8472   -0.15280
     tensored correction       0.09074    1.1815   +0.18147
  full-matrix correction       0.00000    1.0000   +0.00000

What to notice. Part A prices the exact method: 1024 calibration circuits at ten qubits, a million at twenty, $1.1 \times 10^{15}$ at fifty. The condition number grows too, as $\mathrm{cond}(M_q)^n$ โ€” 2.7 at ten qubits and 144 at fifty โ€” so even if you could run the circuits the correction would amplify shot noise by two orders of magnitude. Part B checks the tensored shortcut against the full inverse and finds agreement at $10^{-16}$, as it must when the assumption holds exactly.

Part C is the part worth remembering. Add a 4% probability that qubits 0 and 1 flip together โ€” a completely realistic crosstalk channel โ€” and calibrate the tensored model from the single-qubit marginals, which is all $2n$ experiments can see. The raw measurement has $\langle Z_0 Z_1 \rangle$ low by $0.153$. The tensored correction returns $1.18$: it is now high by $0.181$, a larger error than doing nothing, and it has the same sign of confidence attached to it. The full calibrated matrix removes the error exactly. A correction built on a wrong noise model does not fail gracefully; it fails in the opposite direction, and nothing in the output says so. The practical consequence is that correlated readout error has to be measured โ€” by preparing correlated calibration states and looking for the residual โ€” and not assumed away.


5.2 Zero-Noise Extrapolation, Implemented by Folding

Amplifying noise without changing the circuit

The idea of zero-noise extrapolation is to measure an observable at several known multiples of the device's noise level and extrapolate the sequence back to zero. The sister course scaled the noise by turning up the error probability, which no user of real hardware can do. What a user can do is make the circuit longer in a way that does not change what it computes:

$$ G \;\longrightarrow\; G\,(G^{-1}G)^{k}, \qquad \lambda = 2k+1 $$

Every gate is replaced by $2k+1$ copies of itself, alternating with its inverse. The product is $G$ โ€” exactly, since the inverses cancel in pairs โ€” so the circuit's unitary is untouched, while the number of places where noise can enter is multiplied by $\lambda$. Under a noise model that is independent of the gate, the effective error rate is therefore $\lambda$ times the device's. This is gate folding, and it comes in two flavours: local, folding each gate in place, and global, folding the entire circuit as one block, $C \to C(C^{-1}C)^k$.

Two consequences of the definition are worth stating before the code. First, integer local folding reaches only $\lambda = 1, 3, 5, \ldots$; fractional scales require folding a subset of the gates, which Exercise 2 does. Second, the inverse of every gate must itself be expressible in the IR's gate set, or the folded circuit cannot be emitted. For the rotations that is trivial โ€” negate the angle โ€” and for $S$ and $T$ there are exact identities, $S^{-1} = ZS$ and $T^{-1} = ZST$, with no leftover phase.

Code Example 4: Noise Amplification by Folding, and the Proof It Changes Nothing

"""Chapter 5, Example 4: noise amplification by gate folding, and the proof that
it changes nothing. Continues from Example 3 (same session)."""


def invert_gate(g):
    """Inverse of one gate, as gate tuples inside the IR's own gate set.

    s^-1 = z s and t^-1 = z s t are exact matrix identities with no leftover
    phase, so a folded circuit stays inside the gate set the compiler emits.
    """
    if g[0] in ("h", "x", "z", "cx", "cz"):
        return [g]
    if g[0] == "s":
        return [("z", g[1]), ("s", g[1])]
    if g[0] == "t":
        return [("z", g[1]), ("s", g[1]), ("t", g[1])]
    if g[0] in ROT_1Q:
        return [(g[0], -g[1], g[2])]
    raise ValueError(f"no inverse rule for {g[0]}")


def fold_local(circ, k):
    """Replace every gate G by G (G^-1 G)^k. Noise scale lambda = 2k + 1."""
    out = []
    for g in circ:
        out.append(g)
        for _ in range(k):
            out.extend(invert_gate(g))
            out.append(g)
    return out


def fold_global(circ, k):
    """Replace the whole circuit C by C (C^-1 C)^k. Same lambda, different noise."""
    inv = [h for g in reversed(circ) for h in invert_gate(g)]
    out = list(circ)
    for _ in range(k):
        out.extend(inv)
        out.extend(circ)
    return out


def ansatz(theta, n, layers):
    """Hardware-efficient ansatz: an ry-rz pair per qubit, then a CX ladder."""
    circ, k = [], 0
    for _ in range(layers):
        for q in range(n):
            circ.append(("ry", theta[k], q))
            circ.append(("rz", theta[k + 1], q))
            k += 2
        for q in range(n - 1):
            circ.append(("cx", q, q + 1))
    return circ


NQ, LAYERS = 4, 2
THETA = 0.25 + 0.31 * np.arange(2 * NQ * LAYERS)
CIRC = ansatz(THETA, NQ, LAYERS)
# 4-site transverse-field Ising chain, h = 1
TFIM = {"ZZII": 1.0, "IZZI": 1.0, "IIZZ": 1.0,
        "XIII": 1.0, "IXII": 1.0, "IIXI": 1.0, "IIIX": 1.0}

print("Gate folding: more noise, same unitary")
print("=" * 74)
print(f"  test circuit: {NQ} qubits, {LAYERS} layers,"
      f" {len(CIRC)} gates, depth {circuit_depth(CIRC, NQ)}")
print(f"  gate counts : {gate_counts(CIRC)}")
psi0 = run_circuit(CIRC, NQ)
E0 = sum(expval(psi0, p, TFIM) for p in TFIM)
print(f"  noiseless energy <H> = {E0:.6f}")

print(f"\n  {'lambda':>7} {'gates':>7} {'depth':>7} {'2q gates':>9}"
      f" {'local == global':>16} {'phase-free error':>18}"
      f" {'<H> noiseless':>14}")
for k in (0, 1, 2, 3):
    fl, fg = fold_local(CIRC, k), fold_global(CIRC, k)
    psi = run_circuit(fl, NQ)
    print(f"  {2 * k + 1:7d} {len(fl):7d} {circuit_depth(fl, NQ):7d}"
          f" {gate_counts(fl)['2q']:9d} {str(fl == fg):>16}"
          f" {max(assert_equivalent(CIRC, fl, NQ), assert_equivalent(CIRC, fg, NQ)):18.2e}"
          f" {sum(expval(psi, p, TFIM) for p in TFIM):14.6f}")
Gate folding: more noise, same unitary
==========================================================================
  test circuit: 4 qubits, 2 layers, 22 gates, depth 9
  gate counts : {'ry': 8, 'rz': 8, 'cx': 6, '2q': 6}
  noiseless energy <H> = -1.507650

   lambda   gates   depth  2q gates  local == global   phase-free error  <H> noiseless
        1      22       9         6             True           0.00e+00      -1.507650
        3      66      27        18            False           5.66e-16      -1.507650
        5     110      45        30            False           6.18e-16      -1.507650
        7     154      63        42            False           1.18e-15      -1.507650

What to notice. The gate count and the depth both grow exactly as $\lambda$: 22 gates at $\lambda = 1$, 154 at $\lambda = 7$, with the two-qubit count going 6 to 42. The phase-free error against the original circuit stays at $10^{-16}$ for every scale and both folding strategies, and the noiseless energy is unchanged to six decimals. That is the whole point: a noiseless simulator cannot detect that folding happened. Only a noisy device can, which is what makes it a noise knob and not a compiler bug.

Local and global folding give the same gate count and the same depth here, because this circuit's inverse has the same length as the circuit; the local == global column shows that the two gate lists nevertheless differ from $\lambda = 3$ on, and Example 5 measures whether that matters.

Code Example 5: Extrapolating to Zero Noise

"""Chapter 5, Example 5: zero-noise extrapolation on the folded circuits.
Continues from Example 4 (same session)."""


def gate_matrix(g):
    """(unitary, target list) of one gate tuple."""
    if g[0] in FIXED_1Q:
        return FIXED_1Q[g[0]], [g[1]]
    if g[0] in ROT_1Q:
        return ROT_1Q[g[0]](g[1]), [g[2]]
    if g[0] == "cx":
        return CNOT4, [g[1], g[2]]
    if g[0] == "cz":
        return CZ4, [g[1], g[2]]
    raise ValueError(g[0])


def rho_apply(rho, U, targets, n):
    """U rho U^dagger, built out of apply_gate acting on the columns of rho."""
    A = np.column_stack([apply_gate(rho[:, k], U, targets, n)
                         for k in range(rho.shape[1])])
    Ad = A.conj().T
    return np.column_stack([apply_gate(Ad[:, k], U, targets, n)
                            for k in range(Ad.shape[1])])


def depolarize(rho, q, p, n):
    """Single-qubit depolarizing channel: (1-p) rho + (p/3) sum_P P rho P."""
    out = (1.0 - p) * rho
    for P in (X, Y, Z):
        out = out + (p / 3.0) * rho_apply(rho, P, [q], n)
    return out


def noisy_rho(circ, n, p):
    """Density matrix after a circuit with a depolarizing kick on every qubit
    every gate touches. Exact -- no trajectory sampling."""
    rho = np.zeros((2 ** n, 2 ** n), dtype=complex)
    rho[0, 0] = 1.0
    for g in circ:
        U, tg = gate_matrix(g)
        rho = rho_apply(rho, U, tg, n)
        for q in gate_qubits(g):
            rho = depolarize(rho, q, p, n)
    return rho


def rho_expval(rho, pauli):
    """tr(P rho) for a Pauli string."""
    n, A = len(pauli), rho
    for q, ch in enumerate(pauli):
        if ch != "I":
            A = np.column_stack([apply_gate(A[:, k], PAULI[ch], [q], n)
                                 for k in range(A.shape[1])])
    return float(np.trace(A).real)


def energy(rho):
    return sum(TFIM[p] * rho_expval(rho, p) for p in TFIM)


def richardson_weights(lams):
    """Exact polynomial interpolation of the noise scales, evaluated at zero."""
    lams = np.asarray(lams, dtype=float)
    return np.array([np.prod([-lj / (li - lj) for lj in lams if lj != li])
                     for li in lams])


def ls_line_weights(lams):
    """Intercept weights of a least-squares straight line through the scales."""
    A = np.column_stack([np.ones_like(lams, dtype=float), np.asarray(lams,
                                                                    float)])
    return np.linalg.pinv(A)[0]


LAMS = np.array([1, 3, 5, 7])
print("A. The extrapolation weights, and what they cost in variance")
print("=" * 74)
print(f"  {'scales':>16} {'estimator':>12} {'weights':>34} {'||w||^2':>9}")
for lams in ([1, 2], [1, 2, 3], [1, 2, 3, 4], [1, 3, 5, 7]):
    for name, wf in (("Richardson", richardson_weights),
                     ("LS line", ls_line_weights)):
        if name == "LS line" and len(lams) == 2:
            continue
        w = wf(lams)
        print(f"  {str(lams):>16} {name:>12}"
              f" {np.array2string(np.round(w, 4), separator=', '):>34}"
              f" {float(w @ w):9.3f}")

print("\nB. Noisy energies at the four folded noise scales")
print("=" * 74)
print(f"  noiseless <H> = {E0:.6f}  (a fixed circuit, not a variational"
      " minimum)")
print(f"\n  {'p':>8} {'folding':>8}" + "".join(f" {'lam=' + str(l):>10}"
                                              for l in LAMS))
VALS = {}
for p in (0.002, 0.005, 0.010):
    for name, fold in (("local", fold_local), ("global", fold_global)):
        VALS[(p, name)] = np.array(
            [energy(noisy_rho(fold(CIRC, (l - 1) // 2), NQ, p)) for l in LAMS])
        print(f"  {p:8.3f} {name:>8}"
              + "".join(f" {v:10.5f}" for v in VALS[(p, name)]))

print("\nC. Extrapolating to zero noise (local folding)")
print("=" * 74)
print(f"  {'p':>8} {'raw bias':>10} {'Richardson':>11} {'bias':>10}"
      f" {'reduction':>10} {'LS line':>10} {'bias':>10} {'reduction':>10}")
for p in (0.002, 0.005, 0.010):
    v = VALS[(p, "local")]
    rich = float(richardson_weights(LAMS) @ v)
    line = float(ls_line_weights(LAMS) @ v)
    raw = v[0] - E0
    print(f"  {p:8.3f} {raw:+10.4f} {rich:11.5f} {rich - E0:+10.5f}"
          f" {abs(raw / (rich - E0)):9.0f}x {line:10.5f} {line - E0:+10.5f}"
          f" {abs(raw / (line - E0)):9.1f}x")
print("\n  local vs global folding, largest disagreement over the whole grid:"
      f" {max(abs(VALS[(p, 'local')] - VALS[(p, 'global')]).max() for p in (0.002, 0.005, 0.010)):.2e}")
A. The extrapolation weights, and what they cost in variance
==========================================================================
            scales    estimator                            weights   ||w||^2
            [1, 2]   Richardson                         [ 2., -1.]     5.000
         [1, 2, 3]   Richardson                    [ 3., -3.,  1.]    19.000
         [1, 2, 3]      LS line        [ 1.3333,  0.3333, -0.6667]     2.333
      [1, 2, 3, 4]   Richardson               [ 4., -6.,  4., -1.]    69.000
      [1, 2, 3, 4]      LS line           [ 1. ,  0.5, -0. , -0.5]     1.500
      [1, 3, 5, 7]   Richardson [ 2.1875, -2.1875,  1.3125, -0.3125]    11.391
      [1, 3, 5, 7]      LS line       [ 0.85,  0.45,  0.05, -0.35]     1.050

B. Noisy energies at the four folded noise scales
==========================================================================
  noiseless <H> = -1.507650  (a fixed circuit, not a variational minimum)

         p  folding      lam=1      lam=3      lam=5      lam=7
     0.002    local   -1.46442   -1.38162   -1.30419   -1.23175
     0.002   global   -1.46442   -1.38162   -1.30419   -1.23175
     0.005    local   -1.40193   -1.21398   -1.05464   -0.91910
     0.005   global   -1.40193   -1.21398   -1.05466   -0.91915
     0.010    local   -1.30380   -0.98264   -0.74986   -0.57884
     0.010   global   -1.30380   -0.98267   -0.75001   -0.57917

C. Extrapolating to zero noise (local folding)
==========================================================================
         p   raw bias  Richardson       bias  reduction    LS line       bias  reduction
     0.002    +0.0432    -1.50795   -0.00030       143x   -1.50058   +0.00707       6.1x
     0.005    +0.1057    -1.50815   -0.00050       212x   -1.46898   +0.03867       2.7x
     0.010    +0.2039    -1.50585   +0.00180       113x   -1.38531   +0.12233       1.7x

  local vs global folding, largest disagreement over the whole grid: 3.24e-04

What to notice. Part A is the arithmetic that decides everything else. Richardson extrapolation is exact polynomial interpolation through the noise scales, evaluated at zero; for $\lambda = 1,2$ it gives $(2,-1)$, for $1,2,3$ it gives $(3,-3,1)$, for $1,2,3,4$ it gives $(4,-6,4,-1)$, with $\lVert w \rVert^2 = 5, 19, 69$ and therefore $\lVert w \rVert_2 = 2.24, 4.36, 8.31$ โ€” the sister course's numbers, recovered from the definition. The folding scales $1,3,5,7$ give gentler weights, $\lVert w \rVert^2 = 11.4$ rather than 69, simply because the points are further apart. And the least-squares line through the same four points has $\lVert w \rVert^2 = 1.05$, which is less than one per point: it is barely more expensive than not extrapolating at all.

Part C is the payoff and the trade. At $p = 0.002$ the raw noisy energy is biased by $+0.043$ and the cubic Richardson estimate brings it to $-0.0003$, a 143-fold reduction; at $p = 0.005$ it is 212-fold and at $p = 0.01$ it is 113-fold. The numbers are that large because the noise is nearly linear in $\lambda$ here, so a cubic through four points models it very well. The least-squares line on the same data manages only 6.1, 2.7 and 1.7 fold. The two estimators differ by a factor of twenty in bias and eleven in variance, in opposite directions โ€” the choice a practitioner has to make, and cannot make without the shot budget. Local and global folding disagree by at most $3.2 \times 10^{-4}$ anywhere on this grid, because depolarizing noise commutes with everything and only the count of insertions matters; for coherent noise they are not equivalent at all, which is Exercise 3.

Code Example 6: The Variance Mitigation Buys the Bias With

"""Chapter 5, Example 6: the variance mitigation buys the bias with.
Continues from Example 5 (same session)."""
P_NOISE = 0.005
TERM_EV = [np.array([rho_expval(noisy_rho(fold_local(CIRC, (l - 1) // 2),
                                          NQ, P_NOISE), p) for p in TFIM])
           for l in LAMS]
COEF = np.array([TFIM[p] for p in TFIM])


def sampled_energies(shots, rng):
    """One shot-noise realization of <H> at each of the four noise scales.

    Each Pauli term is measured in its own circuit with `shots` shots; the
    estimator of <P> is 2 k / shots - 1 from a binomial count.
    """
    return np.array([float(COEF @ (2.0 * rng.binomial(shots, 0.5 * (1.0 + ev))
                                   / shots - 1.0)) for ev in TERM_EV])


W_RICH = richardson_weights(LAMS)
W_LINE = ls_line_weights(LAMS)
print("The variance cost of extrapolation, measured")
print("=" * 74)
print(f"  p = {P_NOISE}, four noise scales {LAMS.tolist()},"
      f" {len(TFIM)} Pauli terms")
print(f"  ||w||^2 : Richardson {float(W_RICH @ W_RICH):.3f},"
      f" LS line {float(W_LINE @ W_LINE):.3f}, raw 1.000")
print(f"\n  {'shots/term':>11} {'total shots':>12} {'estimator':>11}"
      f" {'mean bias':>10} {'std':>9} {'predicted std':>14} {'RMSE':>9}")
for shots in (1000, 10000, 100000):
    rng = np.random.default_rng(7)
    draws = np.array([sampled_energies(shots, rng) for _ in range(600)])
    raw = draws[:, 0]
    rich = draws @ W_RICH
    line = draws @ W_LINE
    s_raw = float(np.std(raw))
    for name, est, w2 in (("raw", raw, 1.0),
                          ("LS line", line, float(W_LINE @ W_LINE)),
                          ("Richardson", rich, float(W_RICH @ W_RICH))):
        bias = float(np.mean(est)) - E0
        sd = float(np.std(est))
        print(f"  {shots:11d} {shots * len(TFIM) * len(LAMS):12,d} {name:>11}"
              f" {bias:+10.5f} {sd:9.5f} {s_raw * np.sqrt(w2):14.5f}"
              f" {np.sqrt(bias ** 2 + sd ** 2):9.5f}")
print("\n  Shot count needed for the extrapolated value to match the raw")
print("  estimator's statistical error, at fixed total budget:")
for name, w2 in (("LS line", float(W_LINE @ W_LINE)),
                 ("Richardson", float(W_RICH @ W_RICH))):
    print(f"    {name:>11}: {len(LAMS)} scales x ||w||^2 = {w2:6.3f}"
          f"  ->  {len(LAMS) * w2:6.2f}x the shots")
The variance cost of extrapolation, measured
==========================================================================
  p = 0.005, four noise scales [1, 3, 5, 7], 7 Pauli terms
  ||w||^2 : Richardson 11.391, LS line 1.050, raw 1.000

   shots/term  total shots   estimator  mean bias       std  predicted std      RMSE
         1000       28,000         raw   +0.10671   0.07563        0.07563   0.13079
         1000       28,000     LS line   +0.03844   0.07950        0.07749   0.08831
         1000       28,000  Richardson   -0.00194   0.25368        0.25524   0.25369
        10000      280,000         raw   +0.10612   0.02412        0.02412   0.10883
        10000      280,000     LS line   +0.03942   0.02479        0.02472   0.04657
        10000      280,000  Richardson   -0.00122   0.08473        0.08142   0.08474
       100000    2,800,000         raw   +0.10592   0.00804        0.00804   0.10622
       100000    2,800,000     LS line   +0.03899   0.00837        0.00824   0.03988
       100000    2,800,000  Richardson   -0.00086   0.02753        0.02713   0.02755

  Shot count needed for the extrapolated value to match the raw
  estimator's statistical error, at fixed total budget:
        LS line: 4 scales x ||w||^2 =  1.050  ->    4.20x the shots
     Richardson: 4 scales x ||w||^2 = 11.391  ->   45.56x the shots

What to notice. The measured standard deviations track $\sigma_{\text{raw}}\lVert w \rVert_2$ to within a few percent at every budget โ€” 0.2537 against a predicted 0.2552 for Richardson at a thousand shots per term, 0.0080 against 0.0080 for the raw estimator at a hundred thousand. The variance penalty of extrapolation is not a heuristic; it is the norm of the weight vector, and it is computable before any data is taken.

The RMSE column is the honest summary and it has a crossover in it. At a thousand shots per term the ranking is LS line (0.088), raw (0.131), Richardson (0.254) โ€” the high-order extrapolation is the worst of the three, because its variance swamps a bias it removed almost perfectly. At a hundred thousand shots per term the ranking is reversed: Richardson (0.0276), LS line (0.0399), raw (0.106). Which extrapolation is best is a property of the shot budget, not of the method. Any paper reporting a ZNE result without its shot count has withheld the information needed to interpret it.

The total budgets are worth reading too: 2.8 million shots in the last block, on a four-qubit circuit with twenty-two gates, to get one expectation value to $\pm 0.03$. Mitigation converts a bias problem into a sampling problem, and the sampling problem was already the binding one.


5.3 Probabilistic Error Cancellation

Inverting a channel you cannot invert

Zero-noise extrapolation makes an assumption it cannot check: that the observable is a smooth, low-order function of the noise strength. Probabilistic error cancellation makes no such assumption, and in exchange it demands a full characterisation of the noise. The idea is to apply the inverse of the noise channel. The obstacle is that the inverse of a noisy channel is not a channel โ€” it is not completely positive, and no hardware can run it. The resolution is that it can always be written as a quasiprobability combination of operations that can be run:

$$ \mathcal{N}^{-1} = \sum_i c_i\,\mathcal{O}_i, \qquad \sum_i c_i = 1, \qquad \text{some } c_i < 0 $$

Sample $\mathcal{O}_i$ with probability $\lvert c_i \rvert / \gamma$ where $\gamma = \sum_i \lvert c_i \rvert$, insert it into the circuit, and multiply the measured value by $\gamma\,\mathrm{sign}(c_i)$. The average of that estimator is the noiseless expectation value โ€” it is unbiased, which extrapolation is not. The price is in the variance: the estimator's spread grows by a factor $\gamma$ per noise location, so the shot count grows as $\gamma^{2N}$ for $N$ locations. That $\gamma^{2N}$ is the exponential wall of this chapter, and Example 7 puts numbers on it.

For a single-qubit depolarizing channel the algebra is short enough to do by hand. Its Pauli transfer matrix is $\mathrm{diag}(1, f, f, f)$ with $f = 1 - 4p/3$; writing the inverse as $\alpha\,\mathrm{id} + \beta(X \cdot X + Y \cdot Y + Z \cdot Z)$ gives $\alpha + 3\beta = 1$ and $\alpha - \beta = 1/f$, hence

$$ \beta = \frac{1}{4}\left(1 - \frac{1}{f}\right) < 0, \qquad \alpha = 1 - 3\beta, \qquad \gamma = \lvert \alpha \rvert + 3\lvert \beta \rvert = 1 + \frac{3}{2}\left(\frac{1}{f} - 1\right) $$

and to leading order $\gamma \approx 1 + 2p$.

Code Example 7: Probabilistic Error Cancellation, and Its Sampling Bill

"""Chapter 5, Example 7: probabilistic error cancellation, and its sampling bill.
Continues from Example 6 (same session)."""
PAULI_LIST = (I2, X, Y, Z)


def pec_coeffs(p):
    """Quasiprobability decomposition of the inverse depolarizing channel.

    The channel's Pauli transfer matrix is diag(1, f, f, f) with f = 1 - 4p/3.
    Writing the inverse as alpha * identity + beta * (X . X + Y . Y + Z . Z)
    gives diag(alpha + 3 beta, alpha - beta, ...), so alpha + 3 beta = 1 and
    alpha - beta = 1/f. The one-norm gamma = |alpha| + 3|beta| is what the
    sampling cost is exponential in.
    """
    f = 1.0 - 4.0 * p / 3.0
    beta = (1.0 - 1.0 / f) / 4.0
    alpha = 1.0 - 3.0 * beta
    return alpha, beta, abs(alpha) + 3.0 * abs(beta)


def rho1_channel(rho, p):
    """One-qubit depolarizing channel, on a 2 x 2 density matrix."""
    return (1 - p) * rho + (p / 3.0) * sum(P @ rho @ P for P in (X, Y, Z))


def rho1_inverse(rho, p):
    """The quasiprobability inverse, applied exactly (not sampled)."""
    alpha, beta, _ = pec_coeffs(p)
    return alpha * rho + beta * sum(P @ rho @ P for P in (X, Y, Z))


print("A. The inverse of a depolarizing channel is not a channel")
print("=" * 74)
rng = np.random.default_rng(5150)
v = rng.normal(size=(2, 2)) + 1j * rng.normal(size=(2, 2))
rho_test = v @ v.conj().T
rho_test = rho_test / np.trace(rho_test)
print(f"  {'p':>8} {'f':>10} {'alpha':>10} {'beta':>11} {'gamma':>10}"
      f" {'1 + 2p':>9} {'inversion error':>16}")
for p in (0.001, 0.002, 0.005, 0.010, 0.050):
    a, b, g = pec_coeffs(p)
    err = np.max(np.abs(rho1_inverse(rho1_channel(rho_test, p), p) - rho_test))
    print(f"  {p:8.3f} {1 - 4 * p / 3:10.6f} {a:10.6f} {b:+11.6f} {g:10.6f}"
          f" {1 + 2 * p:9.6f} {err:16.2e}")
print("  beta is negative, so the inverse is a quasiprobability and not a")
print("  channel -- it cannot be run on hardware, only sampled from.")

print("\nB. Sampling overhead, which is exponential in the circuit size")
print("=" * 74)
LOCS = sum(len(gate_qubits(g)) for g in CIRC)
print(f"  the Example 4 circuit has {len(CIRC)} gates and {LOCS} noise"
      " locations")
print("  Entries are log10 of the factor by which the shot budget must grow to")
print("  hold the statistical error fixed: overhead = gamma^(2N).")
print(f"\n  {'p':>8} {'gamma':>10}" + "".join(
    f" {'N=' + f'{n:.0e}':>10}" for n in (LOCS, 1e2, 1e3, 1e4, 1e6)))
for p in (0.001, 0.002, 0.005, 0.010):
    _, _, g = pec_coeffs(p)
    print(f"  {p:8.3f} {g:10.6f}"
          + "".join(f" {2 * n * np.log10(g):10.1f}"
                    for n in (LOCS, 1e2, 1e3, 1e4, 1e6)))

print("\nC. PEC implemented, on a circuit small enough to sample")
print("=" * 74)
SMALL = [("h", 0), ("cx", 0, 1), ("ry", 0.7, 1)]
NS, P_PEC = 2, 0.02
psi_ideal = run_circuit(SMALL, NS)
ideal = expval(psi_ideal, "ZZ")
noisy = rho_expval(noisy_rho(SMALL, NS, P_PEC), "ZZ")
alpha, beta, gamma = pec_coeffs(P_PEC)
weights = np.array([abs(alpha), abs(beta), abs(beta), abs(beta)]) / gamma
signs = np.sign([alpha, beta, beta, beta])
n_loc = sum(len(gate_qubits(g)) for g in SMALL)


def pec_sample(rng):
    """One PEC sample: insert Paulis drawn from the quasiprobability, keep the sign."""
    rho = np.zeros((2 ** NS, 2 ** NS), dtype=complex)
    rho[0, 0] = 1.0
    sign = 1.0
    for g in SMALL:
        U, tg = gate_matrix(g)
        rho = rho_apply(rho, U, tg, NS)
        for q in gate_qubits(g):
            rho = depolarize(rho, q, P_PEC, NS)
            j = rng.choice(4, p=weights)
            sign *= signs[j]
            rho = rho_apply(rho, PAULI_LIST[j], [q], NS)
    return sign * gamma ** n_loc * rho_expval(rho, "ZZ"), sign


rng = np.random.default_rng(24680)
draws, sgn = map(np.array, zip(*[pec_sample(rng) for _ in range(4000)]))
print(f"  circuit: {len(SMALL)} gates, {n_loc} noise locations, p = {P_PEC}")
print(f"  gamma = {gamma:.6f}, gamma^{n_loc} = {gamma ** n_loc:.4f},"
      f" overhead gamma^{2 * n_loc} = {gamma ** (2 * n_loc):.4f}")
print(f"\n  {'estimator':>14} {'value':>10} {'bias':>10} {'std of one sample':>18}")
print(f"  {'noiseless':>14} {ideal:10.5f} {0.0:+10.5f} {'--':>18}")
print(f"  {'noisy':>14} {noisy:10.5f} {noisy - ideal:+10.5f} {'--':>18}")
print(f"  {'PEC, 4000':>14} {draws.mean():10.5f} {draws.mean() - ideal:+10.5f}"
      f" {draws.std():18.5f}")
print(f"  standard error of the PEC mean: {draws.std() / np.sqrt(len(draws)):.5f}")
print(f"  fraction of samples drawn with a negative sign:"
      f" {float(np.mean(sgn < 0)):.4f}")
A. The inverse of a depolarizing channel is not a channel
==========================================================================
         p          f      alpha        beta      gamma    1 + 2p  inversion error
     0.001   0.998667   1.001001   -0.000334   1.002003  1.002000         1.11e-16
     0.002   0.997333   1.002005   -0.000668   1.004011  1.004000         0.00e+00
     0.005   0.993333   1.005034   -0.001678   1.010067  1.010000         1.11e-16
     0.010   0.986667   1.010135   -0.003378   1.020270  1.020000         1.11e-16
     0.050   0.933333   1.053571   -0.017857   1.107143  1.100000         5.55e-17
  beta is negative, so the inverse is a quasiprobability and not a
  channel -- it cannot be run on hardware, only sampled from.

B. Sampling overhead, which is exponential in the circuit size
==========================================================================
  the Example 4 circuit has 22 gates and 28 noise locations
  Entries are log10 of the factor by which the shot budget must grow to
  hold the statistical error fixed: overhead = gamma^(2N).

         p      gamma    N=3e+01    N=1e+02    N=1e+03    N=1e+04    N=1e+06
     0.001   1.002003        0.0        0.2        1.7       17.4     1737.8
     0.002   1.004011        0.1        0.3        3.5       34.8     3476.7
     0.005   1.010067        0.2        0.9        8.7       87.0     8700.5
     0.010   1.020270        0.5        1.7       17.4      174.3    17430.5

C. PEC implemented, on a circuit small enough to sample
==========================================================================
  circuit: 3 gates, 4 noise locations, p = 0.02
  gamma = 1.041096, gamma^4 = 1.1748, overhead gamma^8 = 1.3801

       estimator      value       bias  std of one sample
       noiseless    0.76484   +0.00000                 --
           noisy    0.70527   -0.05957                 --
       PEC, 4000    0.75812   -0.00672            0.33428
  standard error of the PEC mean: 0.00529
  fraction of samples drawn with a negative sign: 0.0757

What to notice. Part A confirms the algebra: $\beta$ is negative at every noise level, the quasiprobability inverse composed with the channel returns the input density matrix to $10^{-16}$, and $\gamma$ agrees with the leading-order $1 + 2p$ to four digits at $p = 10^{-3}$ and to two at $p = 0.05$.

Part B is the wall. Read the row at $p = 0.005$. On the twenty-two-gate circuit of Example 4, with twenty-eight noise locations, the sampling overhead is $10^{0.2}$, a factor of 1.6 โ€” completely affordable, and this is why PEC is used in practice on small circuits. At a thousand noise locations it is $10^{8.7}$. At ten thousand it is $10^{87}$, and at a million it is $10^{8700}$. There is no hardware improvement, no algorithmic cleverness and no funding level that touches a number like that. Mitigation is not a path to large circuits; it is a way to get more out of small ones.

Part C is PEC implemented, on a circuit small enough that four thousand samples resolve the answer. The noisy expectation is biased by $-0.060$; PEC returns $0.758$ against a noiseless $0.765$, a residual of $-0.007$ against a standard error of $0.005$ โ€” unbiased within one and a half standard errors, as advertised. The standard deviation of a single sample is 0.334 for an observable bounded by 1, and 7.6% of samples carry a negative sign. Those two numbers are the exponential cost made visible: as the circuit grows the sign approaches a coin flip, positive and negative contributions cancel ever more completely, and the samples needed to see the difference grow as $\gamma^{2N}$.


5.4 Where Software Cleverness Ends

Put the three methods side by side, honestly.

What it fixes Extra qubits Assumption Sampling cost
Readout mitigation Assignment error only none Noise model of the readout, and $2^n$ or $2n$ calibrations $\sim 1$, plus calibration
Zero-noise extrapolation Bias from any gate noise that scales with $\lambda$ none The observable is low-order in $\lambda$; folding scales the noise $\lVert w \rVert^2$ per scale, so 4 to 46$\times$ here
Probabilistic error cancellation Bias, with no smoothness assumption none Full tomographic knowledge of the noise $\gamma^{2N}$, exponential in circuit size
Error correction Everything, including leakage if the code is designed for it $2d^2 - 1$ per logical qubit Physical error below threshold $\sim 1$

The pattern in the last column is the whole story of this chapter. The three mitigation methods buy accuracy with samples, and the cost in samples grows with the size of the circuit โ€” polynomially for extrapolation if the extrapolation model holds, and exponentially for PEC always. Error correction buys accuracy with qubits, and the cost in qubits grows with the logarithm of the required logical error rate, because the code distance enters the exponent. A quantity that grows exponentially and a quantity that grows logarithmically cross, and they cross early.

Three further asymmetries are worth naming, because they are the parts that a table of overheads hides.


5.5 The Resource-Estimation Pipeline

The other way to answer "should I mitigate?" is to price the alternative. A fault-tolerant resource estimate is a chain of four functions, each of which is arithmetic, and the discipline lies entirely in stating the inputs.

$$ \text{algorithm} \xrightarrow{\ \lambda,\ \varepsilon,\ C_{\text{walk}}\ } N_{\text{Toffoli}} \xrightarrow{\ \text{failure budget}\ } p_L \xrightarrow{\ p_{\text{phys}},\ p_{\text{th}}\ } d \xrightarrow{\ \text{code}\ } n_{\text{phys}},\ t_{\text{wall}} $$

The first link is the algorithm-level statement of Intermediate Quantum Algorithms, Chapter 4: qubitized phase estimation needs about $(\pi/2)(\lambda/\varepsilon)$ walk steps, each costing $C_{\text{walk}}$ Toffolis. The second is a budget choice โ€” hold the whole run's failure probability at 0.1, so $p_L = 0.1/N_{\text{Toffoli}}$. The third is the threshold formula of Introduction to Quantum Computing, Chapter 5, $p_L \approx A(p/p_{\text{th}})^{(d+1)/2}$, solved for the smallest odd $d$. The fourth is the code's footprint, $2d^2 - 1$ physical qubits per logical qubit for the rotated surface code, and a placeholder cycle time of 10 $\mu$s per logical Toffoli.

One implementation detail is not optional. The third step compares a ratio raised to a large power against a power of ten, and in binary floating point those are not the numbers they look like: 0.1 * 0.1 ** 5 evaluates to 1.0000000000000004e-06, which is not $\le 10^{-6}$. A bare comparison therefore rejects the distance that meets the target exactly and returns the next one up โ€” two whole steps of code distance, since $d$ is odd, and hundreds of physical qubits per logical qubit. Two of the sister courses hit this trap; the guard is a relative tolerance, and Example 8 demonstrates the failure before applying it.

Code Example 8: Algorithm Inputs to Physical Qubits and Days

"""Chapter 5, Example 8: the resource-estimation pipeline, logical to physical.
Continues from Example 7 (same session); arithmetic only, no simulator needed."""
CHEM_ACC = 1.6e-3        # Hartree, the conventional chemical-accuracy target
P_THRESHOLD = 1e-2       # representative surface-code threshold, order of magnitude
A_PREFACTOR = 0.1        # dimensionless prefactor, order of magnitude
T_TOFFOLI = 1e-5         # seconds per logical Toffoli, a standard placeholder


def qubitized_toffolis(lam, eps, toffolis_per_walk):
    """Toffoli count of qubitized phase estimation: (pi/2)(lam/eps) walk steps."""
    steps = np.pi * lam / (2 * eps)
    return steps, steps * toffolis_per_walk


def logical_error_target(n_toffoli, total_failure=0.1):
    """Per-gate logical error rate that keeps the whole run's failure at 0.1."""
    return total_failure / n_toffoli


def logical_error(p_phys, d, p_th=P_THRESHOLD, A=A_PREFACTOR):
    """Surface-code scaling p_L ~ A (p/p_th)^((d+1)/2)."""
    return A * (p_phys / p_th) ** ((d + 1) / 2)


def required_distance(p_phys, target, p_th=P_THRESHOLD, A=A_PREFACTOR):
    """Smallest odd code distance reaching a target logical error rate.

    The comparison carries a RELATIVE tolerance because p_L is a ratio raised to
    a large power: a distance that meets the target exactly lands a few ulps
    above it in binary floating point, and a bare `<= target` would reject it
    and return the next distance up. Two sister courses hit this trap.
    """
    if p_phys >= p_th:
        return None                  # at or above threshold, more qubits do not help
    for d in range(3, 201, 2):
        if logical_error(p_phys, d, p_th, A) <= target * (1 + 1e-9):
            return d
    return None


def physical_per_logical(d):
    """Rotated surface code: 2 d^2 - 1 physical qubits per logical qubit."""
    return 2 * d * d - 1


def estimate(lam, eps, toffolis_per_walk, p_phys, n_logical):
    """The whole pipeline as one function: algorithm inputs -> hardware."""
    steps, tof = qubitized_toffolis(lam, eps, toffolis_per_walk)
    pL = logical_error_target(tof)
    d = required_distance(p_phys, pL)
    per = physical_per_logical(d)
    return {"walk_steps": steps, "toffolis": tof, "p_L": pL, "distance": d,
            "per_logical": per, "physical": per * n_logical,
            "seconds": tof * T_TOFFOLI, "days": tof * T_TOFFOLI / 86400}


print("A. The floating-point trap in the threshold comparison")
print("=" * 74)
print(f"  0.1 * 0.1 ** 5  = {0.1 * 0.1 ** 5!r}")
print(f"  is it <= 1e-06? {0.1 * 0.1 ** 5 <= 1e-6}")
print(f"  with a relative tolerance? {0.1 * 0.1 ** 5 <= 1e-6 * (1 + 1e-9)}")
for target in (1e-6, 1e-10, 1e-12):
    d_ok = required_distance(1e-3, target)
    d_bad = next((d for d in range(3, 201, 2)
                  if logical_error(1e-3, d) <= target), None)
    print(f"  p = 1e-3, target {target:.0e}: guarded d = {d_ok:3d},"
          f" unguarded d = {d_bad:3d}"
          + ("   <- off by one distance step" if d_ok != d_bad else ""))

print("\nB. Reproducing the sister courses' tables")
print("=" * 74)
print(f"  {'p_phys':>9} {'target p_L':>12} {'distance d':>11}"
      f" {'phys/logical':>13} {'100 logical qubits':>19}")
for p_phys in (1e-3, 3e-4, 1e-4):
    for target in (1e-6, 1e-10, 1e-15):
        d = required_distance(p_phys, target)
        per = physical_per_logical(d)
        print(f"  {p_phys:9.0e} {target:12.0e} {d:11d} {per:13,d} {100 * per:19,d}")

print("\nC. Algorithm to hardware, in one call")
print("=" * 74)
print(f"  eps = {CHEM_ACC:.1e} Ha, p_phys = 1e-3, {2 * 76 + 1000} logical qubits")
print(f"\n  {'system':>26} {'lambda':>8} {'C_walk':>8} {'Toffolis':>10}"
      f" {'p_L':>9} {'d':>4} {'physical':>10} {'days':>8}")
for name, lam, cw in (("N2, moderate space", 40.0, 1e4),
                      ("FeMoco, low estimate", 300.0, 1e4),
                      ("FeMoco, mid estimate", 1000.0, 3e4),
                      ("FeMoco, high estimate", 4000.0, 1e5)):
    r = estimate(lam, CHEM_ACC, cw, 1e-3, 2 * 76 + 1000)
    print(f"  {name:>26} {lam:8.0f} {cw:8.0e} {r['toffolis']:10.2e}"
          f" {r['p_L']:9.1e} {r['distance']:4d} {r['physical']:10.2e}"
          f" {r['days']:8.2f}")

print("\n  cross-check against Intermediate Quantum Algorithms Chapter 4:")
for tof in (1e9, 1e10, 1e11):
    pL = logical_error_target(tof)
    d = required_distance(1e-3, pL)
    per = physical_per_logical(d)
    print(f"    {tof:.0e} Toffolis -> p_L {pL:.1e}, d = {d}, {per} per logical,"
          f" {per * 1152:.2e} physical, {tof * T_TOFFOLI / 86400:.2f} days")

print("\nD. Where mitigation stops and correction starts")
print("=" * 74)
print("  mitigation: PEC sampling overhead gamma^(2N) at p = 1e-3, N noise")
print("  locations. correction: physical qubits per logical qubit at p = 1e-3")
print("  for a logical error rate of 0.1/N.")
_, _, g3 = pec_coeffs(1e-3)
print(f"\n  {'N (gates)':>11} {'log10 PEC overhead':>19} {'p_L needed':>11}"
      f" {'d':>4} {'phys/logical':>13}")
for n in (1e2, 1e3, 1e4, 1e6, 1e9, 1e11):
    d = required_distance(1e-3, logical_error_target(n))
    print(f"  {n:11.0e} {2 * n * np.log10(g3):19.1f}"
          f" {logical_error_target(n):11.1e} {d:4d}"
          f" {physical_per_logical(d):13,d}")
A. The floating-point trap in the threshold comparison
==========================================================================
  0.1 * 0.1 ** 5  = 1.0000000000000004e-06
  is it <= 1e-06? False
  with a relative tolerance? True
  p = 1e-3, target 1e-06: guarded d =   9, unguarded d =  11   <- off by one distance step
  p = 1e-3, target 1e-10: guarded d =  17, unguarded d =  19   <- off by one distance step
  p = 1e-3, target 1e-12: guarded d =  21, unguarded d =  23   <- off by one distance step

B. Reproducing the sister courses' tables
==========================================================================
     p_phys   target p_L  distance d  phys/logical  100 logical qubits
      1e-03        1e-06           9           161              16,100
      1e-03        1e-10          17           577              57,700
      1e-03        1e-15          27         1,457             145,700
      3e-04        1e-06           7            97               9,700
      3e-04        1e-10          11           241              24,100
      3e-04        1e-15          19           721              72,100
      1e-04        1e-06           5            49               4,900
      1e-04        1e-10           9           161              16,100
      1e-04        1e-15          13           337              33,700

C. Algorithm to hardware, in one call
==========================================================================
  eps = 1.6e-03 Ha, p_phys = 1e-3, 1152 logical qubits

                      system   lambda   C_walk   Toffolis       p_L    d   physical     days
          N2, moderate space       40    1e+04   3.93e+08   2.5e-10   17   6.65e+05     0.05
        FeMoco, low estimate      300    1e+04   2.95e+09   3.4e-11   19   8.31e+05     0.34
        FeMoco, mid estimate     1000    3e+04   2.95e+10   3.4e-12   21   1.01e+06     3.41
       FeMoco, high estimate     4000    1e+05   3.93e+11   2.5e-13   23   1.22e+06    45.45

  cross-check against Intermediate Quantum Algorithms Chapter 4:
    1e+09 Toffolis -> p_L 1.0e-10, d = 17, 577 per logical, 6.65e+05 physical, 0.12 days
    1e+10 Toffolis -> p_L 1.0e-11, d = 19, 721 per logical, 8.31e+05 physical, 1.16 days
    1e+11 Toffolis -> p_L 1.0e-12, d = 21, 881 per logical, 1.01e+06 physical, 11.57 days

D. Where mitigation stops and correction starts
==========================================================================
  mitigation: PEC sampling overhead gamma^(2N) at p = 1e-3, N noise
  locations. correction: physical qubits per logical qubit at p = 1e-3
  for a logical error rate of 0.1/N.

    N (gates)  log10 PEC overhead  p_L needed    d  phys/logical
        1e+02                 0.2     1.0e-03    3            17
        1e+03                 1.7     1.0e-04    5            49
        1e+04                17.4     1.0e-05    7            97
        1e+06              1737.8     1.0e-07   11           241
        1e+09           1737757.8     1.0e-10   17           577
        1e+11         173775776.0     1.0e-12   21           881

What to notice. Part A is the trap, exhibited. At $p = 10^{-3}$ and a target of $10^{-6}$ the guarded search returns $d = 9$ and the unguarded one returns $d = 11$; at $10^{-12}$ it is 21 against 23. In physical qubits that is 161 against 241, and 881 against 1057 โ€” a 20% error in the headline number of a resource estimate, from a floating-point comparison. The fix costs one multiplication.

Part B reproduces the table of Introduction to Quantum Computing, Chapter 5 exactly, which it can because that course uses the same $2d^2 - 1$ convention: at $p = 10^{-3}$, a logical error rate of $10^{-10}$ needs distance 17 and 577 physical qubits per logical qubit, so a hundred logical qubits cost 57,700 physical ones; improving the physical error rate to $3 \times 10^{-4}$ brings the same target down to distance 11 and 24,100. Every row matches.

Part C runs the whole pipeline in one call and lands where the intermediate algorithms course landed. A FeMoco-scale calculation at $\lambda = 1000$ Hartree and $C_{\text{walk}} = 3 \times 10^4$ needs $2.95 \times 10^{10}$ Toffolis, a logical error rate of $3.4 \times 10^{-12}$, code distance 21, about $10^6$ physical qubits and 3.4 days โ€” and at the high end of the plausible input range, $3.9 \times 10^{11}$ Toffolis, $1.2 \times 10^6$ physical qubits and 45 days. The cross-check block reproduces the three headline rows of Intermediate Quantum Algorithms, Chapter 4 โ€” the code distance and the wall-clock day count at $10^9$, $10^{10}$ and $10^{11}$ Toffolis โ€” digit for digit. The physical-qubit column is the one place the two disagree, and only by the single qubit per logical qubit that separates the conventions: that course counts $2d^2$, this one counts $2d^2 - 1$ as ยง5.5 disclosed, following Introduction to Quantum Computing, Chapter 5.

Part D is the boundary of Section 5.4, computed. At a hundred noise locations the PEC overhead is a factor of 1.6 and error correction would need distance 3, seventeen physical qubits per logical qubit โ€” mitigation wins easily, and it needs no extra qubits at all. At ten thousand locations mitigation costs $10^{17}$ in samples and correction costs 97 physical qubits per logical qubit. At $10^{11}$ โ€” the size of the FeMoco circuit โ€” mitigation costs $10^{1.7 \times 10^{8}}$ and correction costs 881. The crossover is somewhere around a few thousand gates, and it is not close on either side of that. Everything above the crossover is a fault-tolerance problem, and everything below it is where all of today's quantum computing happens.


5.6 The Map of the Series

This is the sixth and last of the quantum courses in this collection, and the six were written to be read against each other. The table is the map.

Course The question it answers What it hands to the others
Introduction to Quantum Computing What is a qubit, a gate, an algorithm; how do you simulate one The mini-simulator every other course runs on, and the noise, mitigation and correction budgets of its Chapter 5
Introduction to Quantum Hardware What are $T_1$, $T_2$ and a gate error made of, physically The transmon spectrum and the DRAG physics that Chapter 4 of this course turns into a control stack
Intermediate Quantum Algorithms Which speedups are theorems, and what do they assume Block encodings, qubitization and the Toffoli-count language of Section 5.5
Introduction to the Quantum Software Stack What happens between an algorithm and a pulse A circuit IR, an optimizer, a router, a pulse simulator and a mitigation layer, all built rather than imported
Quantum Machine Learning Does a quantum model learn better than a classical one The evaluation discipline โ€” matched budgets, paired intervals, honest baselines
Quantum Sensing Where is quantum technology already delivering an advantage The one affirmative answer in the collection, and the metrology that calibration in Chapter 4 rests on

Two cross-cutting themes run through all six and are worth stating once, at the end.

The layers leak, and knowing which way is the skill. A compiler that pushes $Z$ rotations to the end is exploiting a pulse-layer fact. A resource estimate that quotes physical qubits is exploiting a threshold formula whose prefactor is a decoder property. A mitigation method that fails silently fails because its noise model came from a different layer than the noise did. Every serious decision here requires reaching one layer down, which is why a course teaching one layer's API would not be enough.

And the numbers are the argument. Every claim in these six courses that could be measured was measured, in code that runs, with the failures left in: the ansatz that lost to a fifteen-line local search, the readout correction that was worse than no correction, the calibration loop that stalled on a systematic, the extrapolation that was worst of three at a small budget. The habit worth taking away is the one that catches over-general claims: ask what the budget was, what the baseline was, and which layer the assumption came from.


Exercises

Exercise 1: Readout Mitigation on a Local Observable

Most observables of interest are $k$-local โ€” a parity on two or three qubits โ€” and the full $2^n$ machinery is unnecessary for them.

  1. For a single qubit $q$ with rates $(\epsilon_{01}, \epsilon_{10})$, show that the measured marginal satisfies $P_{\text{meas}}(1) = (1 - \epsilon_{01} - \epsilon_{10})P_{\text{true}}(1) + \epsilon_{01}$, and invert it.
  2. Verify the inversion numerically for all three qubits of the GHZ state of Example 2.
  3. What does the result imply about the calibration cost of correcting a $k$-local observable on an $n$-qubit device?
  4. Under the correlated readout model of Example 3, does the single-qubit inversion still work for a single-qubit observable?
Solution

1. Marginalising the tensored confusion matrix over every other qubit leaves the \(2 \times 2\) block for qubit \(q\), so \(P_{\text{meas}}(1) = (1-\epsilon_{10})P_{\text{true}}(1) + \epsilon_{01}(1 - P_{\text{true}}(1))\), which rearranges to the stated form. Inverting gives \(P_{\text{true}}(1) = (P_{\text{meas}}(1) - \epsilon_{01})/(1 - \epsilon_{01} - \epsilon_{10})\). The correction is one affine map per qubit, and the denominator is the readout contrast.

2. All three qubits return 0.500000 against a true 0.5, from measured marginals of 0.4850, 0.4850 and 0.4800.

3. Only the \(k\) qubits in the observable's support matter, so \(2^k\) calibration circuits suffice โ€” 4 for a two-qubit parity, 8 for a three-qubit one, independent of \(n\). This is why readout mitigation is used routinely on hundred-qubit devices even though the exact method needs \(2^{100}\) circuits: nobody corrects the full distribution, they correct the marginals the observables need. The cost of the exact method is a cost of correcting the distribution, which is a much stronger thing to ask for.

4. Yes, for a genuinely single-qubit observable. The correlated flip of Example 3 changes each qubit's marginal error rates โ€” which is exactly what the marginal calibration measures โ€” so a single-qubit correction calibrated from marginals is consistent. It is the joint observable \(\langle Z_0 Z_1 \rangle\) that the tensored model gets wrong, because a correlated flip leaves the marginals intact in form while changing the correlations. Correlated noise breaks correlated observables first.

for q in range(N):
    e01, e10 = RATES[q]
    bit = 2 ** (N - 1 - q)
    m_true = float(sum(p_true[m] for m in range(2 ** N) if m & bit))
    m_meas = float(sum(M_true[m, t] * p_true[t] for m in range(2 ** N)
                       for t in range(2 ** N) if m & bit))
    corr = (m_meas - e01) / (1.0 - e01 - e10)
    print(f"  qubit {q}: P(1) true {m_true:.4f}  measured {m_meas:.4f}"
          f"  corrected {corr:.6f}")
#   qubit 0: P(1) true 0.5000  measured 0.4850  corrected 0.500000
#   qubit 1: P(1) true 0.5000  measured 0.4850  corrected 0.500000
#   qubit 2: P(1) true 0.5000  measured 0.4800  corrected 0.500000

Exercise 2: Fractional Noise Scales

Integer local folding reaches only $\lambda = 1, 3, 5, \ldots$. Fractional scales are obtained by folding only the first $m$ gates.

  1. Implement partial folding and confirm that the achieved $\lambda = (\lvert C \rvert + 2m)/\lvert C \rvert$ and that the unitary is preserved.
  2. Run the Example 5 extrapolation on $\lambda = \lbrace 1, 1.5, 2, 3 \rbrace$ instead of $\lbrace 1,3,5,7 \rbrace$. What happens to the Richardson weights?
  3. Which set would you use, and on what grounds?
Solution

1. Requesting \(\lambda = 1.5\) on a 22-gate circuit needs \(m = 5.5\), which rounds to 6 and delivers \(\lambda = 34/22 = 1.545\) โ€” the achievable scales are quantised in steps of \(2/\lvert C \rvert\). The equivalence error is \(10^{-16}\) at every scale, so the transformation is still exact.

2. They blow up: \(\lVert w \rVert^2 = 419\) for \(\lbrace 1, 1.5, 2, 3\rbrace\) against 11.4 for \(\lbrace 1,3,5,7\rbrace\), a factor of 37 in variance and 6 in standard deviation. Exact polynomial interpolation through closely spaced points is an ill-conditioned operation, and clustering the noise scales is exactly how to make it worse. The bias also degrades, to \(+0.072\) against \(-0.0005\), because the lever arm is short: the fit has to extrapolate from \(\lambda \in [1,3]\) rather than \([1,7]\).

3. The wide set, for a high-order extrapolation. The narrow set is defensible only with a low-order estimator โ€” the least-squares line on \(\lbrace 1,1.5,2,3\rbrace\) has \(\lVert w \rVert^2 = 1.86\) and a bias of \(+0.018\), which is a reasonable operating point. The general rule is that the extrapolation order and the spread of the scales must be chosen together; fractional folding is useful for keeping the largest \(\lambda\) small when the deepest circuit would otherwise depolarize completely.

def fold_partial(circ, k, m):
    """Fold every gate k times and the first m gates once more: lambda = 2k+1+2m/|C|."""
    out = []
    for i, g in enumerate(circ):
        reps = k + (1 if i < m else 0)
        out.append(g)
        for _ in range(reps):
            out.extend(invert_gate(g))
            out.append(g)
    return out


for lam_t in (1.5, 2.0, 2.5):
    m = int(round((lam_t - 1.0) * len(CIRC) / 2.0))
    fc = fold_partial(CIRC, 0, m)
    print(f"  target lambda {lam_t:4.2f}: m = {m:2d}, gates {len(fc):3d},"
          f" achieved lambda {len(fc) / len(CIRC):5.3f},"
          f" equivalence error {assert_equivalent(CIRC, fc, NQ):.1e}")
LAMS_F = np.array([1.0, 1.5, 2.0, 3.0])
vals_f = np.array([energy(noisy_rho(fold_partial(CIRC, 0,
                   int(round((l - 1) * len(CIRC) / 2))), NQ, 0.005))
                   for l in LAMS_F])
wr, wl = richardson_weights(LAMS_F), ls_line_weights(LAMS_F)
print(f"  Richardson bias {float(wr @ vals_f) - E0:+.5f}, ||w||^2 {float(wr @ wr):.3f}")
print(f"  LS line    bias {float(wl @ vals_f) - E0:+.5f}, ||w||^2 {float(wl @ wl):.3f}")
#   target lambda 1.50: m =  6, gates  34, achieved lambda 1.545, equivalence error 3.4e-16
#   target lambda 2.00: m = 11, gates  44, achieved lambda 2.000, equivalence error 7.0e-16
#   target lambda 2.50: m = 16, gates  54, achieved lambda 2.455, equivalence error 5.7e-16
#   Richardson bias +0.07236, ||w||^2 419.000
#   LS line    bias +0.01843, ||w||^2 1.857

Exercise 3: A Coherent Error That Folding Cannot See

Replace the depolarizing noise by a purely unitary, systematic error: every $R_z(\theta)$ in the circuit is implemented as $R_z(\theta + \delta)$ for some small $\delta$. Consider two versions: $\delta$ carrying the sign of $\theta$ (a scale-factor error), and $\delta$ with a fixed sign (a zero-offset error).

  1. What does local folding do to each of the two errors? Answer analytically first.
  2. Run the Example 5 extrapolation on both and report the raw and extrapolated bias.
  3. What does this imply about reporting a ZNE result?
Solution

1. Local folding of \(R_z(\theta)\) emits \(R_z(\theta)R_z(-\theta)R_z(\theta)\). With a scale-factor error the three become \(R_z(\theta+\delta)\), \(R_z(-\theta-\delta)\), \(R_z(\theta+\delta)\), whose product is \(R_z(\theta+\delta)\) โ€” identical to the unfolded gate. Folding cancels the error exactly and amplifies nothing. With a zero-offset error the three become \(R_z(\theta+\delta)\), \(R_z(-\theta+\delta)\), \(R_z(\theta+\delta)\), whose product is \(R_z(\theta+3\delta)\): the error is amplified by exactly \(\lambda\), which is what ZNE wants.

2. Both models give the same raw bias, \(-0.00153\), because every angle in this circuit is positive so the two coincide at \(\lambda = 1\). Under folding they diverge completely. The scale-factor error gives the same energy at all four scales to five decimals, so both extrapolators return \(-0.00153\): ZNE reports the biased value and there is nothing in the output that says so. The offset error does vary with \(\lambda\), and Richardson brings the bias to \(+0.00052\) while the least-squares line overshoots to \(-0.034\), 22 times worse than doing nothing โ€” because the \(\lambda\) dependence of a coherent error is trigonometric rather than polynomial, and a straight line is the wrong model for it.

3. That the flatness of the \(\lambda\) sweep must be reported, and that a flat sweep is not evidence of a small error. It is equally consistent with a large error that folding is blind to. The honest protocol is to report the noisy values at every scale, not just the extrapolated one, and to cross-check with an independent method โ€” PEC with a characterised noise model, or a Clifford circuit whose exact answer is known.

DELTA = 0.05


def coherent_rho(circ, n, delta, proportional):
    """Every rz gets an extra delta: a purely unitary, systematic error."""
    rho = np.zeros((2 ** n, 2 ** n), dtype=complex)
    rho[0, 0] = 1.0
    for g in circ:
        U, tg = gate_matrix(g)
        if g[0] == "rz":
            U = rz(g[1] + (np.sign(g[1]) if proportional else 1.0) * delta)
        rho = rho_apply(rho, U, tg, n)
    return rho


for prop in (True, False):
    vals_c = np.array([energy(coherent_rho(fold_local(CIRC, (l - 1) // 2), NQ,
                                           DELTA, prop)) for l in LAMS])
    rich = float(richardson_weights(LAMS) @ vals_c)
    line = float(ls_line_weights(LAMS) @ vals_c)
    print(f"  proportional = {prop}: energies {np.round(vals_c, 5).tolist()}")
    print(f"    raw bias {vals_c[0] - E0:+.5f}   Richardson bias {rich - E0:+.5f}"
          f"   LS line bias {line - E0:+.5f}")
#   proportional = True: energies [-1.50918, -1.50918, -1.50918, -1.50918]
#     raw bias -0.00153   Richardson bias -0.00153   LS line bias -0.00153
#   proportional = False: energies [-1.50918, -1.4911, -1.44773, -1.38424]
#     raw bias -0.00153   Richardson bias +0.00052   LS line bias -0.03405

Exercise 4: The PEC Wall-Clock Budget

Assume a base budget of $10^6$ shots for the unmitigated estimate and a throughput of $10^4$ shots per second.

  1. At $p = 10^{-3}$ and $p = 3\times10^{-4}$, compute the wall-clock time for PEC on circuits with $10^2$, $10^3$ and $10^4$ noise locations.
  2. What is the largest circuit that fits in one day at each error rate?
  3. A tenfold improvement in the physical error rate buys what factor in circuit size?
Solution

1. At \(p = 10^{-3}\) the times are \(10^{2.17}\) s (2.5 minutes), \(10^{3.74}\) s (15 hours) and \(10^{19.4}\) s โ€” \(10^{11.9}\) years. At \(p = 3\times10^{-4}\) the same three are \(10^{2.05}\) s, \(10^{2.52}\) s (6 minutes) and \(10^{7.2}\) s, about six months. The middle column is the interesting one: a thousand-gate circuit is a day's work at one error rate and a coffee break at the other.

2. Solving \(\gamma^{2N} = 86400 \times 10^4 / 10^6\) gives \(N = 1690\) noise locations at \(p = 10^{-3}\) and \(N = 5634\) at \(p = 3\times10^{-4}\).

3. A factor of about 3.3 in circuit size, not a factor of 10. Since \(\log \gamma \approx 2p\), the affordable \(N\) scales as \(1/p\) at fixed budget โ€” so the 3.3-fold improvement in \(p\) here (from \(10^{-3}\) to \(3\times10^{-4}\)) buys the same 3.3-fold in \(N\), and a full decade in \(p\) would buy a decade in \(N\). That is linear in the error rate against a target that needs many orders of magnitude, which is the quantitative form of the statement that mitigation does not scale. Compare with error correction, where the same decade in \(p\) moves the required distance by a couple of steps and the circuit size by an unbounded factor.

RATE, base = 1e4, 1e6
for p in (1e-3, 3e-4):
    _, _, g = pec_coeffs(p)
    print(f"  p = {p:.0e}, gamma = {g:.6f}")
    for nloc in (1e2, 1e3, 1e4):
        log_secs = np.log10(base / RATE) + 2 * nloc * np.log10(g)
        print(f"    N = {nloc:6.0e}: log10 seconds {log_secs:8.2f},"
              f" log10 years {log_secs - np.log10(3.156e7):8.2f}")
    print(f"    largest N in one day:"
          f" {np.log10(86400 * RATE / base) / (2 * np.log10(g)):.0f}")
#   p = 1e-03, gamma = 1.002003
#     N =  1e+02: log10 seconds     2.17, log10 years    -5.33
#     N =  1e+03: log10 seconds     3.74, log10 years    -3.76
#     N =  1e+04: log10 seconds    19.38, log10 years    11.88
#     largest N in one day: 1690
#   p = 3e-04, gamma = 1.000600
#     N =  1e+02: log10 seconds     2.05, log10 years    -5.45
#     N =  1e+03: log10 seconds     2.52, log10 years    -4.98
#     N =  1e+04: log10 seconds     7.21, log10 years    -0.29
#     largest N in one day: 5634

Exercise 5: Which Input Does a Resource Estimate Actually Depend On

Using the pipeline of Example 8 at $\lambda = 1000$ Hartree, $C_{\text{walk}} = 3\times10^4$, $p_{\text{phys}} = 10^{-3}$ and 1152 logical qubits:

  1. Relax the target precision from chemical accuracy to ten times coarser. What changes?
  2. Vary $\lambda$ by a factor of two either way. What changes?
  3. Improve $p_{\text{phys}}$ from $10^{-3}$ to $10^{-4}$. What changes?
  4. Which of the two surface-code conventions in the sister courses would change a headline number, and by how much?
Solution

1. Everything scales down by ten: \(2.95\times10^{9}\) Toffolis instead of \(2.95\times10^{10}\), 0.34 days instead of 3.41, and the code distance drops from 21 to 19 because the logical error budget per gate relaxes by a decade. Relaxing \(\varepsilon\) is the single cheapest saving available in a resource estimate, and it is physically legitimate whenever the quantity of interest is an energy difference whose systematic errors cancel.

2. Runtime scales linearly โ€” 1.70, 3.41, 6.82 days for \(\lambda = 500, 1000, 2000\) โ€” while the code distance stays at 21 throughout, because \(d\) depends on the logarithm of the Toffoli count and a factor of four is less than one distance step. So \(\lambda\) sets the wall clock and does almost nothing to the qubit count.

3. The distance falls from 21 to 11 and the physical qubit count from \(1.01\times10^{6}\) to \(2.78\times10^{5}\), a factor of 3.6, while the runtime does not change at all. Physical error rate is a qubit-count lever, not a runtime lever; precision and 1-norm are runtime levers. Confusing the two is how resource-estimate discussions go wrong.

4. At \(d = 21\) the two conventions give 882 and 881 physical qubits per logical qubit, and on 1152 logical qubits \(1.016\times10^{6}\) against \(1.015\times10^{6}\) โ€” a difference of 0.1%, invisible at the two significant figures any such estimate deserves. It is worth checking precisely because it is invisible: a discrepancy of this size between two published estimates is a convention difference and not a disagreement, and mistaking it for one wastes time.

for eps, lab in ((1.6e-3, "chemical accuracy"), (1.6e-2, "10x coarser")):
    r = estimate(1000.0, eps, 3e4, 1e-3, 1152)
    print(f"  {lab:>18}: Toffolis {r['toffolis']:.2e}, d = {r['distance']},"
          f" physical {r['physical']:.2e}, days {r['days']:.2f}")
for lam in (500.0, 1000.0, 2000.0):
    r = estimate(lam, 1.6e-3, 3e4, 1e-3, 1152)
    print(f"  lambda = {lam:6.0f}: Toffolis {r['toffolis']:.2e},"
          f" d = {r['distance']}, days {r['days']:.2f}")
for p in (1e-3, 1e-4):
    r = estimate(1000.0, 1.6e-3, 3e4, p, 1152)
    print(f"  p_phys = {p:.0e}: d = {r['distance']},"
          f" per logical {r['per_logical']}, physical {r['physical']:.2e}")
print(f"  2d^2 vs 2d^2-1 at d = 21: {2*21*21} vs {2*21*21-1},"
      f" on 1152 logical qubits {2*21*21*1152:.3e} vs {(2*21*21-1)*1152:.3e}")
#    chemical accuracy: Toffolis 2.95e+10, d = 21, physical 1.01e+06, days 3.41
#          10x coarser: Toffolis 2.95e+09, d = 19, physical 8.31e+05, days 0.34
#   lambda =    500: Toffolis 1.47e+10, d = 21, days 1.70
#   lambda =   1000: Toffolis 2.95e+10, d = 21, days 3.41
#   lambda =   2000: Toffolis 5.89e+10, d = 21, days 6.82
#   p_phys = 1e-03: d = 21, per logical 881, physical 1.01e+06
#   p_phys = 1e-04: d = 11, per logical 241, physical 2.78e+05
#   2d^2 vs 2d^2-1 at d = 21: 882 vs 881, on 1152 logical qubits 1.016e+06 vs 1.015e+06

Summary

Key Takeaways

1. Readout error is classical, large, and correctable โ€” up to a point

2. Gate folding amplifies noise and provably changes nothing else

3. Extrapolation removes bias by a large factor and pays in variance by a computable one

4. PEC is unbiased and exponentially expensive, and both are exactly quantifiable

5. Mitigation is exponential, correction is not, and the crossover is early

6. A resource estimate is four functions and a floating-point guard

Practical implications

Where This Leads

This is the end of the course, and the end of the descent it set out to make: an algorithm becomes a circuit, a circuit becomes a shorter circuit, a shorter circuit becomes one that fits the hardware graph, that circuit becomes a schedule of shaped pulses, and the measurements that come back become an estimate that has had its noise argued with. Nothing in any of those five layers was an SDK feature. Every one of them was a few dozen lines of NumPy with a test attached, and that is the claim the course was built to support: the layers are simple enough to build, and building one is the way to understand the documentation of the ones you will actually use.

Where to go next is now a question about your own problem rather than about the stack. With a Hamiltonian, the route runs through Intermediate Quantum Algorithms to a Toffoli count and then through Section 5.5 to a number of physical qubits. With a device, it runs through Introduction to Quantum Hardware to the materials question underneath its error rates. With a dataset, Quantum Machine Learning has the evaluation discipline needed to avoid fooling yourself. And if what you want is a quantum technology that works today, Quantum Sensing is the one course here whose answer to "is there an advantage" is yes.

โ† Chapter 4: Pulses and Calibration Back to Series Index โ†’

Disclaimer