Noise and error correction

Add a noise model to a circuit, then estimate or sample under noise, from zero-cost analytical damping to exact density-matrix channels. For error correction with Stim and Sinter, see QEC sampling with Stim and Sinter.

import math

import maestro
from maestro.circuits import QuantumCircuit

bell = QuantumCircuit()
bell.h(0)
bell.cx(0, 1)

noise = maestro.NoiseModel()
noise.set_all_depolarizing(num_qubits=2, p=0.05)

result = maestro.noisy_estimate(bell, ["ZZ", "XX", "YY"], noise)
print("noisy:", result["expectation_values"])
print("ideal:", result["ideal_expectation_values"])
noisy: [0.871111111111111, 0.871111111111111, -0.871111111111111]
ideal: [0.9999999999999998, 0.9999999999999998, -0.9999999999999998]

noisy_estimate runs the circuit once without noise and damps each expectation value analytically, so it costs nothing extra. The other functions trade speed for more physical detail:

Function

Cost

Noise mechanism

Use for

noisy_estimate

One noiseless run

Analytical Pauli damping

Screening many circuits quickly

noisy_estimate_montecarlo

N noisy runs

Random Pauli errors after each gate

Noise that builds up with depth

noisy_execute

N noisy runs

Gate-by-gate Pauli errors, plus shot noise

Sampled counts under noise

coherent_estimate

N noisy runs

Systematic rotation errors

Calibration errors

coherent_execute

N noisy runs

Coherent errors, plus shot noise

Sampled counts under coherent errors

full_noise_estimate

N noisy runs

Every configured noise layer

Device-level expectation values

full_noise_execute

N noisy runs

Every configured noise layer, plus shot noise

Device-level sampling

noisy_fidelity

N inner products

Every noise layer

Fidelity to the ideal state

qc.noisy_prob

Path integral

Readout error correction

Single-outcome probabilities

N is noise_realizations. Analytical and Monte Carlo noise compares the analytical and Monte Carlo modes on one circuit.

Building a noise model

NoiseModel holds error rates per qubit. Pauli noise applies after every gate that touches the qubit. Here each of four flipped qubits gets a different channel, and ⟨Z⟩ shows which ones the channel can disturb:

nm = maestro.NoiseModel()
nm.set_depolarizing(qubit=0, p=0.01)  # X, Y and Z each with probability p/3
nm.set_dephasing(qubit=1, p=0.02)  # Z errors, which leave <Z> alone
nm.set_bit_flip(qubit=2, p=0.01)  # X errors
nm.set_qubit_noise(qubit=3, px=0.005, py=0.002, pz=0.01)  # any Pauli channel

flipped4 = QuantumCircuit()
for q in range(4):
    flipped4.x(q)
print(maestro.noisy_estimate(flipped4, ["ZIII", "IZII", "IIZI", "IIIZ"], nm)["expectation_values"])
[-0.9866666666666667, -1.0, -0.98, -0.986]

set_all_depolarizing(num_qubits, p) sets the same depolarizing rate on every qubit.

Noise at a point in the circuit

A Pauli channel can also sit at a fixed point in the circuit, like a Stim noise instruction. probabilities has 4**len(qubits) entries; base-4 digit k of an index selects I, X, Y or Z on qubits[k].

p = 0.01
channel = QuantumCircuit()
channel.h(0)
channel.cx(0, 1)
channel.pauli_channel([0], [1 - p, p / 3, p / 3, p / 3])  # DEPOLARIZE1(p)
channel.pauli_channel([0, 1], [1 - p] + [p / 15] * 15)  # DEPOLARIZE2(p)
channel.measure_all()

# Statevector and MPS draw one Pauli per noise realization.
counts = channel.full_noise_execute(maestro.NoiseModel(), shots=1000, noise_realizations=1000)["counts"]
print(counts)
{'10': 4, '01': 3, '11': 509, '00': 484}

To keep a basis change that belongs to a measurement or reset out of a NoiseModel’s gate noise, pass noiseless=True to h, s or sdg.

Gate-by-gate Monte Carlo

noisy_estimate_montecarlo injects random Pauli errors after each gate and averages over many noisy runs, so noise accumulates with circuit depth. noisy_execute does the same for sampled counts:

mc = maestro.noisy_estimate_montecarlo(
    bell, ["ZZ", "XX"], noise, noise_realizations=200, noise_seed=42
)
print("Monte Carlo:", mc["expectation_values"])

measured = QuantumCircuit()
measured.h(0)
measured.cx(0, 1)
measured.measure_all()
print(maestro.noisy_execute(measured, noise, shots=1024, noise_realizations=64, noise_seed=42)["counts"])
Monte Carlo: [0.8699999999999999, 0.7899999999999998]
{'10': 62, '01': 50, '00': 461, '11': 451}

In the estimate functions, noise_realizations is the number of independent noise samples averaged. In the execute functions, it is the number of batches the shots are split into, so at most shots batches run.

Coherent errors

Coherent noise applies deterministic rotations after gates instead of random Pauli flips, modelling systematic over-rotation and phase drift:

coherent = maestro.NoiseModel()
# Rotate by epsilon = 2*arcsin(sqrt(p)) about a body diagonal; its Pauli twirl is DEPOLARIZE1(p).
coherent.set_coherent_depolarizing(qubit=0, p=0.01)
coherent.set_coherent_dephasing(qubit=1, p=0.01)  # about Z
coherent.set_coherent_rotation(qubit=0, rx=0.01, ry=0.0, rz=0.05)  # explicit angles

print(maestro.coherent_estimate(bell, ["ZZ", "XX"], coherent, noise_realizations=100)["expectation_values"])
[0.9866579562352326, 0.9334392239085396]

Each setter replaces only the component it names, so qubit 0 above has both the diagonal rotation and the explicit angles.

Thermal relaxation (T1 and T2)

Thermal relaxation models energy decay (T1) and dephasing (T2) from gate durations:

thermal = maestro.NoiseModel()
thermal.set_thermal_relaxation(qubit=0, gate_time_s=30e-9, t1_s=100e-6, t2_s=80e-6)  # 1-qubit gates
thermal.set_thermal_relaxation_2q(qubit=0, gate_time_s=300e-9, t1_s=100e-6, t2_s=80e-6)  # 2-qubit gates
print(thermal.has_thermal_relaxation())
True

T2 must be at most 2·T1. Density-matrix and supported MPO backends apply the exact thermal channel. Sampled statevector and MPS runs approximate T1 < T2 ≤ 2·T1 with an effective T2 = T1 and emit one RuntimeWarning naming the affected qubits; use a density matrix for faithful evolution in that range.

Exact channels (density matrix and MPO)

Some channels have no Pauli-trajectory form and need an exact mixed-state backend, DensityMatrix or MatrixProductOperator:

exact = maestro.NoiseModel()
exact.set_phase_damping(qubit=0, gamma=0.05)
exact.set_generalized_amplitude_damping(qubit=1, gamma=0.02, excited_population=0.05)
exact.set_correlated_phase_flip(q1=0, q2=1, probability=0.01, correlation=0.8)

# Any channel given as Kraus operators with sum_k E_k^† E_k = I, here a bit flip.
p_flip = 0.01
exact.set_kraus_channel([0], [
    [[math.sqrt(1 - p_flip), 0], [0, math.sqrt(1 - p_flip)]],
    [[0, math.sqrt(p_flip)], [math.sqrt(p_flip), 0]],
])

flipped = QuantumCircuit()
flipped.x(0)
flipped.cx(0, 1)
for method in ["DensityMatrix", "MatrixProductOperator"]:
    config = maestro.SimulatorConfig(simulation_type=getattr(maestro.SimulationType, method))
    print(method, flipped.full_noise_estimate(["ZI", "IZ"], exact, config=config)["expectation_values"])
DensityMatrix [-0.9603999999999975, -0.9424000000000017]
MatrixProductOperator [-0.9604000000000024, -0.9424000000000017]

The statevector backend rejects these channels with a ValueError instead of approximating them. set_phase_damping_from_time(qubit, gate_time_s, t_phi_s) sets phase damping from a dephasing time.

Crosstalk and two-qubit errors

pairwise = maestro.NoiseModel()
pairwise.set_crosstalk(q1=0, q2=1, strength=0.005)  # ZZ crosstalk between neighbours
pairwise.set_2q_depolarizing(q1=0, q2=1, p=1.8e-3)  # after two-qubit gates such as CX or CZ
print(pairwise.has_crosstalk(), pairwise.has_any_2q_depolarizing())
True True

Readout error

Readout error flips the classical result when a measurement writes its bit. Rates follow the measured qubit, and later classical conditions see the noisy result:

readout = maestro.NoiseModel()
readout.set_readout_error(qubit=0, p_meas1_prep0=0.003, p_meas0_prep1=0.06)  # asymmetric
readout.set_readout_error(qubit=1, p_meas1_prep0=0.003, p_meas0_prep1=0.06)

ones = QuantumCircuit()
ones.x(0)
ones.x(1)
ones.measure_all()
print(maestro.noisy_execute(ones, readout, shots=2000, noise_seed=7)["counts"])

# Probability of reading "11" once readout error is included, from the path integral
print(bell.noisy_prob("11", readout)["probability"])  # 0.5 * (1 - 0.06)**2
{'00': 5, '10': 116, '01': 115, '11': 1764}
0.44179999999999986

noisy_prob expands to first order in the readout error rates, so outcomes two flips away from "11", such as "00", don’t contribute.

set_all_readout_error(num_qubits, p_error) sets a symmetric rate on every qubit.

The noise_seed argument (also accepted as seed) seeds noise injection and, unless SimulatorConfig.seed is set, measurement and readout randomness as well. An explicit config seed takes precedence for simulator randomness. Without a seed, each call uses fresh random streams. Each noise realization derives its own simulator seed, so seeded runs are reproducible without repeating readout draws across batches. coherent_execute follows the same rules.

Time-correlated dephasing

Real hardware has non-Markovian phase drift from background fluctuators. Maestro models it as Ornstein-Uhlenbeck (OU) processes, discretized as AR(1) noise after each gate. sigma is the noise strength and alpha the correlation time in units of the gate time:

drift = maestro.NoiseModel()
drift.set_correlated_ou(qubit=0, sigma=15.0, alpha=0.5, gate_time=100e-9, stationary_init=True)
drift.set_all_correlated_ou(num_qubits=5, sigma=15.0, alpha=0.5, gate_time=100e-9)
print(drift.has_correlated())
True

Several bands and 1/f noise

Superpose independent OU bands on a qubit to reproduce a 1/f spectrum over several decades of frequency:

spectrum = maestro.NoiseModel()
spectrum.set_correlated_ou_band(qubit=0, sigma=10.0, alpha=0.1, gate_time=100e-9)
spectrum.set_correlated_ou_band(qubit=0, sigma=5.0, alpha=1.0, gate_time=100e-9)
spectrum.set_multi_correlated_ou(qubit=1, bands=[(10.0, 0.1), (5.0, 1.0)], gate_time=100e-9)
spectrum.set_1_over_f_noise(
    qubit=2, total_power=1e-3, f_min=1e3, f_max=1e7, num_bands=6,
    gate_time=100e-9, stationary_init=True,
)
print(spectrum.has_correlated())
True

Idle noise during delays

set_idle_noise configures relaxation, dephasing and coherent detuning that act during qc.delay(qubit, duration). Active OU processes also evolve through the delay:

idle = maestro.NoiseModel()
idle.set_idle_noise(
    qubit=0,
    t1=100e-6,  # seconds
    t2=80e-6,  # at most 2 * t1
    excited_population=0.01,
    detuning_hz=5e3,
)

ramsey = QuantumCircuit()
ramsey.h(0)
ramsey.delay(0, 20e-6)
ramsey.h(0)
ramsey.measure_all()
print(ramsey.full_noise_execute(idle, shots=1024, seed=3)["counts"])
{'0': 812, '1': 212}

Every layer together

full_noise_execute, full_noise_estimate and noisy_fidelity apply every configured layer in physical order:

device = maestro.NoiseModel()
device.set_all_correlated_ou(num_qubits=2, sigma=15.0, alpha=0.5, gate_time=100e-9)
device.set_all_coherent_depolarizing(num_qubits=2, p=0.002)
device.set_all_readout_error(num_qubits=2, p_error=0.01)

print("counts:", measured.full_noise_execute(device, shots=1024, noise_realizations=64, seed=5)["counts"])
print("values:", bell.full_noise_estimate(["ZZ", "XX"], device, noise_realizations=100, seed=5)["expectation_values"])
fid = bell.noisy_fidelity(device, noise_realizations=100, seed=5)
print(f"fidelity: {fid['fidelity']:.4f} +/- {fid['std_error']:.4f}")
counts: {'01': 17, '10': 11, '11': 502, '00': 494}
values: [0.9950287395340883, 0.9900981329174434]
fidelity: 0.9937 +/- 0.0003

Error correction with Stim and Sinter

To sample Stim circuits through Maestro, including from sinter collect with a decoder, see QEC sampling with Stim and Sinter.