Concepts Intermediate Free 37/53 in series 25 min

Introduction to Quantum Error Models

A practical introduction to the main quantum error models: bit flip, phase flip, depolarizing, and amplitude damping. Understand how these map onto real hardware noise and how error correction addresses each.

What you'll learn

  • quantum errors
  • noise models
  • depolarizing channel
  • bit flip
  • phase flip
  • quantum error correction

Prerequisites

  • Python proficiency
  • Beginner quantum computing concepts (superposition, entanglement)
  • Linear algebra basics

Quantum computers are physical devices, and physical devices are noisy. Every gate has a finite error rate, qubits leak energy to their surroundings, and measurement is imperfect. Before you can understand quantum error correction, you need a working knowledge of the error models that describe what goes wrong. This tutorial covers the main single-qubit and multi-qubit error channels, their matrix representations, their geometric meaning on the Bloch sphere, how they relate to real hardware parameters, and how to simulate them in both raw NumPy and Qiskit Aer.

Why Density Matrices?

Pure quantum states can be described by state vectors |psi>. But noise introduces classical uncertainty on top of quantum uncertainty. Suppose a qubit preparation device produces |0> with probability 0.8 and |1> with probability 0.2, and you do not know which one it produced. This is a classical mixture, and no single state vector can describe it.

The density matrix handles both pure states and classical mixtures in one framework. For a pure state |psi>, the density matrix is the outer product:

import numpy as np

# Pure state |0>
psi = np.array([1, 0], dtype=complex)
rho_pure = np.outer(psi, psi.conj())
print("Pure |0>:")
print(rho_pure)
# [[1. 0.]
#  [0. 0.]]

For a classical mixture of states {|psi_k>} with probabilities {p_k}, the density matrix is:

rho = sum_k  p_k * |psi_k><psi_k|
# Classical mixture: 80% |0>, 20% |1>
psi_0 = np.array([1, 0], dtype=complex)
psi_1 = np.array([0, 1], dtype=complex)
rho_mixed = 0.8 * np.outer(psi_0, psi_0.conj()) + 0.2 * np.outer(psi_1, psi_1.conj())
print("Mixed state (80% |0>, 20% |1>):")
print(rho_mixed)
# [[0.8 0. ]
#  [0.  0.2]]

Purity: Distinguishing Pure from Mixed States

The purity of a density matrix is Tr(rho^2). It satisfies 1/d <= Tr(rho^2) <= 1, where d is the dimension of the Hilbert space. A pure state has purity exactly 1. The maximally mixed state I/d has purity 1/d.

def purity(rho):
    """Compute the purity Tr(rho^2) of a density matrix."""
    return np.real(np.trace(rho @ rho))

print(f"Purity of pure |0>:    {purity(rho_pure):.4f}")   # 1.0
print(f"Purity of 80/20 mix:   {purity(rho_mixed):.4f}")  # 0.68
print(f"Purity of I/2:         {purity(np.eye(2)/2):.4f}")  # 0.5

Purity is a useful diagnostic: when you send a pure state through a noisy channel, the purity of the output tells you how much information the channel destroyed. A purity of 0.5 for a single qubit means the state has become maximally mixed and carries no recoverable quantum information.

Density matrices also arise naturally from entanglement. If you have a two-qubit entangled state and you trace out one qubit (the partial trace), the remaining qubit is described by a mixed-state density matrix even though the global state is pure. This is why density matrices are essential for describing open quantum systems, where the qubit interacts with an environment you cannot access.

What Is a Quantum Channel?

A quantum channel is a completely positive, trace-preserving (CPTP) linear map that takes a density matrix to a density matrix. The most general form is the Kraus (operator-sum) representation:

rho_out = sum_i  K_i @ rho @ K_i^dagger

The Kraus operators {K_i} must satisfy the completeness relation sum_i K_i^dagger @ K_i = I, which ensures the output is a valid density matrix with trace 1.

The Kraus representation is not unique: different sets of Kraus operators can describe the same physical channel. However, every physically realizable quantum operation (including unitary gates, measurements, and noise) can be written in this form.

The Bit Flip Channel

A bit flip error flips |0> to |1> and |1> to |0> with probability p. This is the quantum analog of a classical bit error.

Kraus operators: K0 = sqrt(1-p) * I, K1 = sqrt(p) * X

def bit_flip_channel(rho, p):
    """Apply bit flip channel with probability p."""
    I = np.eye(2, dtype=complex)
    X = np.array([[0, 1], [1, 0]], dtype=complex)
    K0 = np.sqrt(1 - p) * I
    K1 = np.sqrt(p) * X
    return K0 @ rho @ K0.conj().T + K1 @ rho @ K1.conj().T

# Start in |+> = (|0> + |1>) / sqrt(2)
plus = np.array([1, 1], dtype=complex) / np.sqrt(2)
rho = np.outer(plus, plus.conj())

print("Before:", np.round(rho, 3))
print("After p=0.1:", np.round(bit_flip_channel(rho, 0.1), 3))
print("After p=0.5:", np.round(bit_flip_channel(rho, 0.5), 3))

At p=0.5, the off-diagonal elements (coherences) survive because bit flip acts in the Z basis and |+> is an eigenstate of X. The state is degraded but not fully dephased.

Bloch sphere picture

The bit flip channel transforms the Bloch vector (x, y, z) as:

(x, y, z) -> (x, (1-2p)*y, (1-2p)*z)

The x-component is preserved because X commutes with itself. The y and z components shrink by a factor of (1-2p). At p=0.5, only the x-component survives: any input state collapses to a mixture on the x-axis.

The Phase Flip Channel

A phase flip applies a Z gate with probability p. This flips the sign of the |1> amplitude: |+> becomes |->. Phase flip is the most damaging error for superposition states.

Kraus operators: K0 = sqrt(1-p) * I, K1 = sqrt(p) * Z

def phase_flip_channel(rho, p):
    """Apply phase flip channel with probability p."""
    I = np.eye(2, dtype=complex)
    Z = np.array([[1, 0], [0, -1]], dtype=complex)
    K0 = np.sqrt(1 - p) * I
    K1 = np.sqrt(p) * Z
    return K0 @ rho @ K0.conj().T + K1 @ rho @ K1.conj().T

rho_plus = np.outer(plus, plus.conj())
print("Phase flip p=0.1:", np.round(phase_flip_channel(rho_plus, 0.1), 3))
# Off-diagonal elements are reduced: coherence decays

Phase errors destroy the coherence that makes quantum computation useful. In superconducting qubits, phase noise (dephasing) from charge noise and flux noise is typically a dominant error source.

Bloch sphere picture

The phase flip channel transforms the Bloch vector as:

(x, y, z) -> ((1-2p)*x, (1-2p)*y, z)

The z-component is preserved (populations are unchanged), while the x and y components shrink. This is the opposite pattern from the bit flip channel: it preserves the computational basis information but destroys superposition coherence.

The Dephasing Channel and T2 Time

The phase flip channel is actually the special case of a more general dephasing channel. In real hardware, dephasing is a continuous process driven by the T2 relaxation time. The off-diagonal elements of the density matrix decay exponentially:

rho_out = [[rho_00, rho_01 * exp(-t/T2)],
           [rho_10 * exp(-t/T2), rho_11]]

The dephasing parameter is lambda = exp(-t/T2), so the channel leaves populations unchanged and multiplies coherences by lambda. The phase flip channel with probability p is equivalent to dephasing with lambda = 1 - 2p.

def dephasing_channel(rho, t, T2):
    """Apply dephasing channel for time t given coherence time T2."""
    lam = np.exp(-t / T2)
    rho_out = rho.copy()
    rho_out[0, 1] *= lam
    rho_out[1, 0] *= lam
    return rho_out

# Simulate coherence decay of |+> state over time
T2 = 100e-6  # 100 microseconds
plus = np.array([1, 1], dtype=complex) / np.sqrt(2)
rho_plus = np.outer(plus, plus.conj())

print("Coherence decay over time (T2 = 100 us):")
for t_us in [0, 10, 25, 50, 100, 200]:
    t = t_us * 1e-6
    rho_out = dephasing_channel(rho_plus, t, T2)
    coherence = np.abs(rho_out[0, 1])
    print(f"  t = {t_us:3d} us: |rho_01| = {coherence:.4f}")

This exponential decay is directly measurable in a Ramsey experiment: prepare |+>, wait time t, then measure in the X basis. The visibility of the oscillation decays as exp(-t/T2).

The Depolarizing Channel

The depolarizing channel applies I, X, Y, or Z each with a certain probability, with I being the “no error” outcome. With error parameter p, each Pauli is applied with probability p/4 and the identity with probability 1 - 3p/4.

def depolarizing_channel(rho, p):
    """Apply single-qubit depolarizing channel with error parameter p."""
    I = np.eye(2, dtype=complex)
    X = np.array([[0, 1], [1, 0]], dtype=complex)
    Y = np.array([[0, -1j], [1j, 0]], dtype=complex)
    Z = np.array([[1, 0], [0, -1]], dtype=complex)

    ops = [
        np.sqrt(1 - 3 * p / 4) * I,
        np.sqrt(p / 4) * X,
        np.sqrt(p / 4) * Y,
        np.sqrt(p / 4) * Z,
    ]
    return sum(K @ rho @ K.conj().T for K in ops)

# The depolarizing channel shrinks the Bloch vector toward the origin
psi_0 = np.array([1, 0], dtype=complex)
rho_0 = np.outer(psi_0, psi_0.conj())

for p in [0.0, 0.1, 0.5, 1.0]:
    rho_out = depolarizing_channel(rho_0, p)
    bloch_z = rho_out[0, 0].real - rho_out[1, 1].real
    print(f"p={p:.1f}  Bloch Z={bloch_z:.3f}")

At p=1.0, the output is the maximally mixed state I/2, with Bloch vector length 0. Depolarizing noise is the standard model for gate errors in most quantum computing simulators because it is symmetric and mathematically convenient.

Bloch sphere picture

The depolarizing channel uniformly shrinks the entire Bloch vector:

(x, y, z) -> (1 - 4p/3) * (x, y, z)

This is the only single-qubit channel that preserves the direction of the Bloch vector while uniformly reducing its length. All other channels distort the Bloch sphere into an ellipsoid.

Amplitude Damping

Amplitude damping models energy relaxation: a qubit in |1> spontaneously emits a photon and decays to |0>. This is the physical process behind the T1 relaxation time.

Kraus operators: K0 = [[1, 0], [0, sqrt(1-gamma)]], K1 = [[0, sqrt(gamma)], [0, 0]]

Note that K1 maps |1> to |0> (it is the lowering operator scaled by sqrt(gamma)). This directionality is important: amplitude damping always drives the qubit toward |0>, not the other way around.

def amplitude_damping_channel(rho, gamma):
    """Apply amplitude damping channel with decay parameter gamma."""
    K0 = np.array([[1, 0], [0, np.sqrt(1 - gamma)]], dtype=complex)
    K1 = np.array([[0, np.sqrt(gamma)], [0, 0]], dtype=complex)
    return K0 @ rho @ K0.conj().T + K1 @ rho @ K1.conj().T

# Start in |1>
psi_1 = np.array([0, 1], dtype=complex)
rho_1 = np.outer(psi_1, psi_1.conj())

for gamma in [0.0, 0.1, 0.5, 1.0]:
    rho_out = amplitude_damping_channel(rho_1, gamma)
    p_excited = rho_out[1, 1].real
    print(f"gamma={gamma:.1f}  P(|1>)={p_excited:.3f}")
# At gamma=1.0, the qubit has completely decayed to |0>

The relationship between gamma and T1 is: gamma = 1 - exp(-t / T1). For a gate duration of t = 50 ns on a device with T1 = 100 us, gamma is approximately 5e-4, small but not negligible over a long circuit.

Bloch sphere picture

The amplitude damping channel is the only channel we have discussed that is asymmetric: it shifts the Bloch sphere rather than just shrinking it. The transformation is:

x -> sqrt(1 - gamma) * x
y -> sqrt(1 - gamma) * y
z -> (1 - gamma) * z + gamma

The z-component is both contracted and shifted upward (toward |0>). The Bloch sphere maps to a teardrop shape, squeezed and pushed toward the north pole.

Computing the Bloch Vector from a Density Matrix

Each channel above transforms the Bloch vector in a specific way. The following utility function extracts the Bloch vector from any single-qubit density matrix:

def bloch_vector(rho):
    """Extract the Bloch vector (x, y, z) from a single-qubit density matrix.

    For rho = (I + r.sigma) / 2, the Bloch vector r = (x, y, z) is:
      x = 2 * Re(rho[0,1])
      y = 2 * Im(rho[1,0])
      z = rho[0,0] - rho[1,1]
    """
    x = 2 * np.real(rho[0, 1])
    y = 2 * np.imag(rho[1, 0])
    z = np.real(rho[0, 0] - rho[1, 1])
    return np.array([x, y, z])

def bloch_length(rho):
    """Length of the Bloch vector. Equals 1 for pure states, <1 for mixed."""
    r = bloch_vector(rho)
    return np.linalg.norm(r)

# Verify Bloch sphere transformations for each channel
# Start with a state pointing in the +x direction: |+>
rho_plus = np.outer(plus, plus.conj())
r0 = bloch_vector(rho_plus)
print(f"Initial Bloch vector: ({r0[0]:.3f}, {r0[1]:.3f}, {r0[2]:.3f})")

p = 0.1
channels = {
    "Bit flip":      bit_flip_channel(rho_plus, p),
    "Phase flip":    phase_flip_channel(rho_plus, p),
    "Depolarizing":  depolarizing_channel(rho_plus, p),
    "Amp. damping":  amplitude_damping_channel(rho_plus, p),
}

print(f"\nAfter each channel (p or gamma = {p}):")
for name, rho_out in channels.items():
    r = bloch_vector(rho_out)
    pur = purity(rho_out)
    print(f"  {name:16s}: r = ({r[0]:+.4f}, {r[1]:+.4f}, {r[2]:+.4f}), "
          f"|r| = {np.linalg.norm(r):.4f}, purity = {pur:.4f}")

Thermal Relaxation: Generalized Amplitude Damping

Standard amplitude damping assumes the environment is at zero temperature, so the qubit always decays to |0>. Real qubits are at a finite temperature (though typically very cold), meaning there is a small thermal excitation probability.

The thermal equilibrium population of the excited state is:

p_eq = 1 / (1 + exp(hbar * omega / (k_B * T)))

For superconducting transmon qubits at 15 mK with a 5 GHz transition frequency, p_eq is approximately 0.002, very small but nonzero. Some trapped-ion systems at room temperature on optical transitions have p_eq essentially zero.

The generalized amplitude damping channel has four Kraus operators that account for both T1 decay (|1> to |0>) and thermal excitation (|0> to |1>):

def generalized_amplitude_damping(rho, gamma, p_eq):
    """Generalized amplitude damping channel.

    Parameters:
        rho: single-qubit density matrix
        gamma: decay parameter, gamma = 1 - exp(-t/T1)
        p_eq: thermal equilibrium excited-state population
    """
    # Decay operators (weighted by ground-state equilibrium population)
    K0 = np.sqrt(1 - p_eq) * np.array([[1, 0],
                                         [0, np.sqrt(1 - gamma)]], dtype=complex)
    K1 = np.sqrt(1 - p_eq) * np.array([[0, np.sqrt(gamma)],
                                         [0, 0]], dtype=complex)
    # Excitation operators (weighted by excited-state equilibrium population)
    K2 = np.sqrt(p_eq) * np.array([[np.sqrt(1 - gamma), 0],
                                     [0, 1]], dtype=complex)
    K3 = np.sqrt(p_eq) * np.array([[0, 0],
                                     [np.sqrt(gamma), 0]], dtype=complex)

    # Verify completeness: sum K_i^dag K_i = I
    completeness = sum(K.conj().T @ K for K in [K0, K1, K2, K3])
    assert np.allclose(completeness, np.eye(2)), "Completeness violated!"

    return sum(K @ rho @ K.conj().T for K in [K0, K1, K2, K3])

# Simulate T1 decay with thermal population
p_eq = 0.002  # typical transmon at 15 mK
psi_1 = np.array([0, 1], dtype=complex)
rho_1 = np.outer(psi_1, psi_1.conj())

print("Generalized amplitude damping (p_eq = 0.002):")
for gamma in [0.0, 0.1, 0.5, 0.9, 1.0]:
    rho_out = generalized_amplitude_damping(rho_1, gamma, p_eq)
    p_excited = rho_out[1, 1].real
    print(f"  gamma={gamma:.1f}: P(|1>) = {p_excited:.5f}")
# At gamma=1.0, the qubit reaches thermal equilibrium: P(|1>) = p_eq

At gamma = 1.0 (infinite time), the qubit reaches thermal equilibrium with P(|1>) = p_eq. This is the steady state regardless of the initial state.

Two-Qubit Depolarizing Channel

Single-qubit noise models are not sufficient for characterizing multi-qubit gates. The two-qubit depolarizing channel generalizes the single-qubit version to the 16 two-qubit Pauli operators {I, X, Y, Z} x {I, X, Y, Z}.

With error parameter p, the identity (no error) occurs with probability 1 - 15p/16, and each of the 15 non-identity two-qubit Paulis occurs with probability p/16:

def two_qubit_depolarizing(rho, p):
    """Apply 2-qubit depolarizing channel with error parameter p.

    The 16 two-qubit Paulis {I,X,Y,Z}^2 are applied with probability
    p/16 each, except II which gets probability 1 - 15p/16.
    """
    I = np.eye(2, dtype=complex)
    X = np.array([[0, 1], [1, 0]], dtype=complex)
    Y = np.array([[0, -1j], [1j, 0]], dtype=complex)
    Z = np.array([[1, 0], [0, -1]], dtype=complex)

    paulis_1q = [I, X, Y, Z]

    rho_out = np.zeros_like(rho)
    for i, P1 in enumerate(paulis_1q):
        for j, P2 in enumerate(paulis_1q):
            P = np.kron(P1, P2)
            if i == 0 and j == 0:
                # Identity term
                weight = 1 - 15 * p / 16
            else:
                weight = p / 16
            rho_out += weight * (P @ rho @ P.conj().T)

    return rho_out

# Create a Bell state |Phi+> = (|00> + |11>) / sqrt(2)
bell = np.array([1, 0, 0, 1], dtype=complex) / np.sqrt(2)
rho_bell = np.outer(bell, bell.conj())

print("2-qubit depolarizing on Bell state |Phi+>:")
for p in [0.0, 0.01, 0.1, 0.5, 1.0]:
    rho_out = two_qubit_depolarizing(rho_bell, p)
    # Fidelity with original Bell state
    fidelity = np.real(bell.conj() @ rho_out @ bell)
    print(f"  p={p:.2f}: fidelity = {fidelity:.4f}")
# At p=1.0, the state is I/4 (maximally mixed), fidelity = 0.25

At p = 1.0, the output is the maximally mixed state I/4 on two qubits, and the fidelity with any pure state is 1/4.

Process Matrix (Chi Matrix) Representation

The Kraus representation is not the only way to describe a quantum channel. The process matrix (or chi matrix) representation expands the channel in a fixed operator basis, typically the Pauli basis {I, X, Y, Z}.

For any single-qubit channel with Kraus operators {K_k}, each Kraus operator can be decomposed in the Pauli basis:

K_k = sum_m  e_{km} * sigma_m

where sigma_0 = I, sigma_1 = X, sigma_2 = Y, sigma_3 = Z. The chi matrix is then:

chi_{mn} = sum_k  e_{km} * conj(e_{kn})

The channel action becomes E(rho) = sum_{m,n} chi_{mn} * sigma_m @ rho @ sigma_n.

def compute_chi_matrix(kraus_ops):
    """Compute the chi (process) matrix for a single-qubit channel.

    The chi matrix is expressed in the Pauli basis {I, X, Y, Z}.
    Each Kraus operator K_k is decomposed as K_k = sum_m e_{km} sigma_m,
    and chi_{mn} = sum_k e_{km} conj(e_{kn}).
    """
    I = np.eye(2, dtype=complex)
    X = np.array([[0, 1], [1, 0]], dtype=complex)
    Y = np.array([[0, -1j], [1j, 0]], dtype=complex)
    Z = np.array([[1, 0], [0, -1]], dtype=complex)
    paulis = [I, X, Y, Z]

    chi = np.zeros((4, 4), dtype=complex)

    for K in kraus_ops:
        # Decompose K in the Pauli basis: K = sum_m e_m sigma_m
        # Using Tr(sigma_m @ K) / 2 since Tr(sigma_m @ sigma_n) = 2 delta_mn
        e = np.array([np.trace(P @ K) / 2 for P in paulis])
        chi += np.outer(e, e.conj())

    return chi

# Compute chi for the depolarizing channel
p = 0.1
I = np.eye(2, dtype=complex)
X = np.array([[0, 1], [1, 0]], dtype=complex)
Y = np.array([[0, -1j], [1j, 0]], dtype=complex)
Z = np.array([[1, 0], [0, -1]], dtype=complex)

kraus_depol = [
    np.sqrt(1 - 3 * p / 4) * I,
    np.sqrt(p / 4) * X,
    np.sqrt(p / 4) * Y,
    np.sqrt(p / 4) * Z,
]

chi = compute_chi_matrix(kraus_depol)
print("Chi matrix for depolarizing channel (p=0.1):")
print(np.round(np.real(chi), 6))
print(f"\nDiagonal entries: chi_II={(1-3*p/4):.6f}, "
      f"chi_XX=chi_YY=chi_ZZ={(p/4):.6f}")

The depolarizing channel has a diagonal chi matrix: chi_II = 1 - 3p/4 and chi_XX = chi_YY = chi_ZZ = p/4. This diagonal structure means the Pauli errors are uncorrelated. Any off-diagonal entries in chi indicate coherent (correlated) error contributions.

Coherent vs. Incoherent Errors

Not all errors are stochastic. Incoherent errors are the random Pauli errors we have discussed so far: each gate application randomly picks a Pauli with some probability. Coherent errors are systematic, deterministic rotations that occur the same way every time, for example a gate that consistently over-rotates by a small angle epsilon.

This distinction matters enormously for error accumulation:

  • Incoherent errors accumulate statistically: after n gates each with error probability p, the total error is approximately n * p (linear in n, but the state remains close to a Pauli mixture).
  • Coherent errors accumulate quadratically in fidelity: after n gates each with over-rotation epsilon, the infidelity grows as n^2 * epsilon^2 because the rotations add coherently.
def coherent_overrotation(rho, epsilon):
    """Apply a coherent over-rotation: Rz(pi + epsilon) instead of Rz(pi).

    This is a systematic calibration error, not random noise.
    """
    # Rz(theta) = [[exp(-i*theta/2), 0], [0, exp(i*theta/2)]]
    theta = np.pi + epsilon
    Rz = np.array([[np.exp(-1j * theta / 2), 0],
                    [0, np.exp(1j * theta / 2)]], dtype=complex)
    return Rz @ rho @ Rz.conj().T

# Compare coherent vs incoherent error accumulation
# Start in |+> state
rho_init = np.outer(plus, plus.conj())
epsilon = 0.01  # 0.01 radian over-rotation
p_incoh = epsilon**2 / 2  # roughly equivalent depolarizing probability

print("Coherent vs incoherent error accumulation:")
print(f"{'n gates':>8s}  {'Coherent infidelity':>20s}  {'Incoherent infidelity':>22s}")

for n in [1, 10, 50, 100, 200]:
    # Coherent: apply over-rotation n times
    rho_coh = rho_init.copy()
    for _ in range(n):
        rho_coh = coherent_overrotation(rho_coh, epsilon)

    # Incoherent: apply depolarizing n times
    rho_incoh = rho_init.copy()
    for _ in range(n):
        rho_incoh = depolarizing_channel(rho_incoh, p_incoh)

    # Compute fidelity with initial state
    fid_coh = np.real(plus.conj() @ rho_coh @ plus)
    fid_incoh = np.real(plus.conj() @ rho_incoh @ plus)

    print(f"{n:8d}  {1 - fid_coh:20.6f}  {1 - fid_incoh:22.6f}")

The chi matrix reveals the difference: a purely incoherent (Pauli) channel has a real, diagonal chi matrix. A coherent error has significant off-diagonal elements. Quantum process tomography or gate set tomography can distinguish the two in practice.

This distinction has practical consequences for error correction. QEC codes are designed to correct stochastic Pauli errors efficiently. Coherent errors can be converted into incoherent errors through techniques like randomized compiling (also called Pauli frame randomization), which inserts random Pauli gates that average away the coherent component.

Error Rate and Circuit Fidelity

A crucial practical question is: given a per-gate error rate, how many gates can you run before the output becomes useless?

If each gate independently has fidelity 1 - epsilon, and your circuit has n gates, the overall circuit fidelity is approximately:

F = (1 - epsilon)^n ≈ exp(-n * epsilon)  for small epsilon

The circuit “breaks even” (fidelity drops to 1/e ≈ 0.368) when n = 1/epsilon.

print("Circuit fidelity F = (1 - epsilon)^n")
print(f"{'epsilon':>10s}  {'n=10':>10s}  {'n=100':>10s}  {'n=1000':>10s}  "
      f"{'n=10000':>10s}  {'1/e gate count':>15s}")
print("-" * 75)

for epsilon in [1e-2, 1e-3, 1e-4, 1e-5]:
    fidelities = [(1 - epsilon)**n for n in [10, 100, 1000, 10000]]
    breakeven = int(1 / epsilon)
    print(f"{epsilon:10.0e}  " +
          "  ".join(f"{f:10.4f}" for f in fidelities) +
          f"  {breakeven:15,d}")
Per-gate error10 gates100 gates1,000 gates10,000 gatesBreak-even (1/e)
1e-20.9040.3660.0000.000100
1e-30.9900.9050.3680.0001,000
1e-40.9990.9900.9050.36810,000
1e-51.0000.9990.9900.905100,000

Current hardware gate error rates of approximately 0.1% (1e-3) give a break-even of about 1,000 gates. This is why circuits deeper than a few hundred gates on current devices tend to produce noise-dominated outputs, and why quantum error correction is necessary for useful large-scale computation.

The Pauli Channel Decomposition Theorem

A foundational result in quantum noise theory states that any single-qubit CPTP map can be decomposed as a Pauli channel after a suitable unitary rotation. Specifically, for any single-qubit channel E, there exist unitaries U and V and probabilities p0, p1, p2, p3 (summing to 1) such that:

E(rho) = U * (p0 * rho' + p1 * X rho' X + p2 * Y rho' Y + p3 * Z rho' Z) * U^dag

where rho' = V rho V^dag.

This is why Pauli noise models are so widely used: they are universal for single-qubit errors, up to a unitary frame change. When we characterize a gate using the chi matrix, we can always extract the Pauli channel parameters from the diagonal of chi (after diagonalizing, if necessary).

def pauli_channel(rho, p0, p1, p2, p3):
    """General Pauli channel: E(rho) = p0*rho + p1*X*rho*X + p2*Y*rho*Y + p3*Z*rho*Z.

    Parameters must satisfy p0 + p1 + p2 + p3 = 1 and all pi >= 0.
    """
    assert abs(p0 + p1 + p2 + p3 - 1.0) < 1e-10, "Probabilities must sum to 1"
    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)

    return (p0 * rho +
            p1 * X @ rho @ X +
            p2 * Y @ rho @ Y +
            p3 * Z @ rho @ Z)

# The bit flip channel is a Pauli channel with p0=1-p, p1=p, p2=p3=0
# The depolarizing channel is a Pauli channel with p0=1-3p/4, p1=p2=p3=p/4
p = 0.1
rho_test = np.array([[0.7, 0.3+0.1j], [0.3-0.1j, 0.3]], dtype=complex)

rho_depol = depolarizing_channel(rho_test, p)
rho_pauli = pauli_channel(rho_test, 1 - 3*p/4, p/4, p/4, p/4)

print("Depolarizing matches Pauli channel:",
      np.allclose(rho_depol, rho_pauli))

How These Channels Map to Real Hardware

Error modelPhysical sourceTypical error parameter
Bit flipControl errors, crosstalkGate error ~0.1-1% per 2Q gate
Phase flipCharge noise, flux noise, dephasing (T2)T2 ~10-200 us
DepolarizingGate imperfections (model approximation)~0.1-0.5% per single-qubit gate
Amplitude dampingSpontaneous emission, T1 relaxationT1 ~50-500 us
Generalized amp. dampingT1 decay + thermal excitationT1 ~50-500 us, p_eq ~0.001-0.01
DephasingPure dephasing (T_phi) componentT2 <= 2*T1 always

In practice, noise on real hardware is not well described by any single pure channel. Calibration data is fit to a combination of depolarizing plus amplitude damping, or characterized directly via process tomography.

Simulating with Qiskit Aer Noise Models

The Qiskit Aer simulator provides a NoiseModel class that lets you attach error channels to specific gates. Here is a complete example that builds a realistic noise model and compares noisy vs. noiseless Bell state preparation:

from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator
from qiskit_aer.noise import (
    NoiseModel,
    depolarizing_error,
    amplitude_damping_error,
    pauli_error,
)

# Build a noise model
noise_model = NoiseModel()

# Bit flip on single-qubit gates (0.1% chance)
bit_flip = pauli_error([("X", 0.001), ("I", 0.999)])
noise_model.add_all_qubit_quantum_error(bit_flip, ["x", "h", "rz", "sx"])

# Depolarizing error on two-qubit gates (1% total error)
depol_2q = depolarizing_error(0.01, 2)
noise_model.add_all_qubit_quantum_error(depol_2q, ["cx"])

# Amplitude damping on all qubits during idle (gamma ~ 5e-4)
amp_damp = amplitude_damping_error(5e-4)
noise_model.add_all_qubit_quantum_error(amp_damp, ["id", "delay"])

print("Noise model:")
print(noise_model)

# Build a Bell state circuit
qc = QuantumCircuit(2, 2)
qc.h(0)        # Create superposition on qubit 0
qc.cx(0, 1)    # Entangle qubits 0 and 1
qc.measure([0, 1], [0, 1])

shots = 10_000

# Noiseless simulation
sim_ideal = AerSimulator()
result_ideal = sim_ideal.run(qc, shots=shots).result()
counts_ideal = result_ideal.get_counts()

# Noisy simulation
sim_noisy = AerSimulator(noise_model=noise_model)
result_noisy = sim_noisy.run(qc, shots=shots).result()
counts_noisy = result_noisy.get_counts()

print(f"\nBell state results ({shots} shots):")
print(f"  Ideal:  {counts_ideal}")
print(f"  Noisy:  {counts_noisy}")

# In the ideal case, you see only '00' and '11' (50/50).
# With noise, '01' and '10' appear at a rate proportional to
# the error probabilities.
for outcome in ["00", "01", "10", "11"]:
    ideal_frac = counts_ideal.get(outcome, 0) / shots
    noisy_frac = counts_noisy.get(outcome, 0) / shots
    print(f"  P({outcome}): ideal={ideal_frac:.3f}, noisy={noisy_frac:.3f}")

The noisy simulation shows a small but measurable leakage from the expected {00, 11} outcomes into {01, 10}, which is exactly what the bit flip and depolarizing errors predict.

Practical Noise Characterization Workflow

In a real lab or on cloud hardware, you characterize noise through a sequence of increasingly detailed protocols:

1. Randomized Benchmarking (RB) gives you the average gate fidelity. It works by applying random sequences of Clifford gates of increasing length, then measuring how quickly the survival probability decays. The decay rate gives a single number: the error per Clifford (EPC). RB is insensitive to state preparation and measurement (SPAM) errors, making it the gold standard for gate fidelity.

2. Gate Set Tomography (GST) reconstructs the full process matrix for each gate. It is much more expensive than RB (requiring many more circuits) but gives complete information about coherent and incoherent errors, including over-rotations, axis tilts, and correlated errors.

3. Cross-Entropy Benchmarking (XEB) characterizes system-level performance by running random circuits and comparing the output distribution to the ideal. This is the protocol Google used to demonstrate quantum computational advantage with Sycamore. XEB gives a single fidelity number for the entire circuit, not individual gates.

You can access real device noise data in Qiskit using fake backends:

from qiskit_ibm_runtime.fake_provider import FakeManilaV2

# Load a fake backend with calibration data from a real device
backend = FakeManilaV2()

# The backend contains real T1, T2, gate error, and readout error data
properties = backend.properties()
if properties is not None:
    for qubit in range(backend.num_qubits):
        t1 = properties.t1(qubit) * 1e6  # convert to microseconds
        t2 = properties.t2(qubit) * 1e6
        print(f"Qubit {qubit}: T1 = {t1:.1f} us, T2 = {t2:.1f} us")

# You can also extract a noise model directly from the backend
noise_model_real = NoiseModel.from_backend(backend)
print(f"\nNoise model from FakeManila: {noise_model_real}")

Common Mistakes

These five pitfalls catch both beginners and experienced practitioners:

1. Confusing T1 and T2. T1 is the energy relaxation time (amplitude damping). T2 is the total dephasing time, which includes contributions from both T1 decay and pure dephasing (T_phi). The fundamental constraint is T2 <= 2 * T1, always. When T2 = 2 * T1, there is no pure dephasing; all decoherence comes from energy relaxation. When T2 < 2 * T1, the difference is accounted for by the pure dephasing rate: 1/T2 = 1/(2*T1) + 1/T_phi. If your noise model has T2 > 2*T1, it is unphysical.

2. Applying amplitude damping in the wrong direction. The K1 Kraus operator for amplitude damping is [[0, sqrt(gamma)], [0, 0]]. This maps |1> to |0> (the lowering operator). A common mistake is writing K1 as [[0, 0], [sqrt(gamma), 0]], which would map |0> to |1> and represents excitation, not decay. Always check: does your channel drive the Bloch vector toward +z (|0>) or -z (|1>)?

3. Improper normalization of Kraus operators. The completeness relation sum_k K_k^dag K_k = I must hold exactly. For the depolarizing channel, this means the coefficients are sqrt(1 - 3p/4) for the identity and sqrt(p/4) for each Pauli, not 1 - 3p/4 and p/4. Forgetting the square root is a frequent source of bugs that produces non-physical density matrices with trace not equal to 1.

4. Confusing process fidelity and state fidelity. The average gate fidelity F_avg (what randomized benchmarking measures) and the process fidelity F_pro (the overlap of the process matrix with the ideal) are related but different: F_avg = (d * F_pro + 1) / (d + 1) where d is the Hilbert space dimension. For a single qubit (d=2), if F_pro = 0.99, then F_avg = (2 * 0.99 + 1) / 3 = 0.9933. Always specify which fidelity you are reporting.

5. Forgetting that noise is time-dependent. The amplitude damping parameter gamma = 1 - exp(-t/T1) depends on the gate duration t. A slow gate (e.g., 200 ns for a CX) accumulates more amplitude damping than a fast gate (e.g., 30 ns for a single-qubit gate). Similarly, idle qubits accumulate dephasing. Noise models that assign a fixed error rate per gate without accounting for gate duration are approximations that can significantly underestimate errors in circuits with many idle periods.

What Error Correction Targets

Quantum error correction (QEC) works by encoding one logical qubit into many physical qubits and detecting which error occurred without measuring the qubit’s actual value.

The three-qubit bit flip code detects and corrects single bit flip errors. The three-qubit phase flip code does the same for phase errors. The nine-qubit Shor code combines both and corrects any single-qubit error from the full set {I, X, Y, Z}.

The reason this is possible is that the four Pauli operators span the space of all single-qubit errors. If a quantum code can detect and correct X and Z independently, it corrects all single-qubit errors, including amplitude damping, because any physical error can be decomposed into a linear combination of Pauli operators.

Understanding the error models is the prerequisite for understanding why QEC codes are structured the way they are: each code is engineered to detect the specific syndrome signatures that distinguish I, X, Z, and Y on each physical qubit.

Was this tutorial helpful?