Custom Maestro noise model¶
MaestroBackend extracts uniform gate and readout rates from a circuit so you
don’t have to construct a maestro.NoiseModel for standard benchmarks. When you
want to model per-qubit calibration tables—such as a degraded “hot” qubit, a
noisy two-qubit coupler, asymmetric readout errors, directed coherent rotations,
or ZZ crosstalk—you can build a custom maestro.NoiseModel and pass it
directly to maestro.sinter.MaestroCompiledSampler or
maestro.sinter.MaestroSinterSampler.
At a glance
Guides: Noise · Sampling and Sinter
Key APIs:
maestro.NoiseModel,MaestroCompiledSampler,MaestroSinterSampler,noise_params_from_stim(),strip_gate_noise(),build_noise_model()Simulators used: Maestro Stabilizer (heterogeneous Pauli +
T1) and Maestro MatrixProductState (χ=32, coherent rotation + ZZ crosstalk)Run locally:
python examples/example_custom_noise_model.py(~6 s)
Setup: stripping uniform gate and readout noise¶
To avoid applying gate or readout noise twice, we first extract the baseline
SI1000Noise(p=0.005) rates with noise_params_from_stim() and strip the
after-gate depolarizing and M(p) measurement flips with
strip_gate_noise(noisy, readout=True). SI1000’s idle and reset noise remain in
stripped, while the custom maestro.NoiseModel takes over all gate and
readout channels.
def build_surface_code_base(p: float = BASE_P) -> tuple[stim.Circuit, stim.Circuit, dict]:
"""Build a d=3 rotated surface code and split its gate/readout noise out.
Returns (original_noisy_circuit, stripped_circuit, extracted_rates).
"""
code = RotatedPlanarCode(width=3, height=3)
circuit = css_code_memory_circuit(code, num_rounds=ROUNDS, logical_basis=PauliBasis.Z)
noisy = to_stim(
QPU(circuit.qubits, noise_model=SI1000Noise(p=p)).compile_and_add_noise_to_circuit(circuit)
)
rates = noise_params_from_stim(noisy)
# Strip gate depolarizing and measurement flips so the custom NoiseModel
# supplies them without double-counting; idle and reset noise stay in `stripped`.
stripped = strip_gate_noise(noisy, readout=True)
return noisy, stripped, rates
Step 1: Customizing per-qubit and per-coupler channels¶
Starting from the uniform baseline model returned by
build_noise_model(stripped, "pauli", **rates), you can call any per-qubit or
per-coupler setter on maestro.NoiseModel:
set_1q_gate_depolarizing(qubit, p)andset_2q_depolarizing(q1, q2, p)for inhomogeneous gate error rates.set_t1(qubit, gamma)for per-qubit amplitude damping / reset relaxation.set_readout_error(qubit, p_meas1_prep0, p_meas0_prep1)for asymmetric readout assignment errors.set_coherent_rotation(qubit, rx, ry, rz)andset_crosstalk(q1, q2, strength)for non-Clifford unitary errors simulated on MPS.
def build_hot_qubit_model(
stripped: stim.Circuit,
rates: dict[str, float],
hot_scale: float = 10.0,
include_coherent_and_crosstalk: bool = False,
) -> maestro.NoiseModel:
"""Start from uniform SI1000 rates and override a degraded qubit and coupler."""
nm = build_noise_model(stripped, "pauli", **rates)
# Per-qubit and per-coupler Pauli + T1 + asymmetric readout overrides
nm.set_1q_gate_depolarizing(HOT_DATA_QUBIT, rates["p_1q"] * hot_scale)
nm.set_2q_depolarizing(*HOT_COUPLER, rates["p_2q"] * hot_scale)
nm.set_t1(HOT_DATA_QUBIT, rates["p_2q"] * 0.5 * hot_scale)
nm.set_readout_error(
HOT_ANCILLA_QUBIT,
p_meas1_prep0=rates["readout_error"] * 0.8,
p_meas0_prep1=min(0.45, rates["readout_error"] * (1.0 + 0.15 * hot_scale)),
)
if include_coherent_and_crosstalk:
# Non-Clifford hardware channels (require MatrixProductState simulation)
nm.set_coherent_rotation(HOT_DATA_QUBIT, rx=0.08, ry=0.0, rz=0.16)
nm.set_crosstalk(*HOT_COUPLER, strength=0.06)
return nm
Output:
Base SI1000 rates: {'p_1q': 0.0005, 'p_2q': 0.005, 'readout_error': 0.025}
Hot qubit / coupler: q0, q2, coupler (0, 2)
Hot Pauli + T1 model: has_1q=True, has_2q=True, has_t1=True, has_readout=True
+ Coherent & crosstalk: has_coherent=True, has_crosstalk=True
Step 2: Direct sampling with MaestroCompiledSampler¶
MaestroCompiledSampler accepts a sinter.Task (carrying the stripped circuit
and the uniform baseline circuit’s Detector Error Model), your custom
noise_model, and a maestro.SimulatorConfig (built conveniently via
MaestroBackend.config_for()).
Here we compare three configurations decoded against the same baseline DEM:
Uniform SI1000 baseline (
Stabilizer).A
10×hot qubit (q0), hot coupler(0, 2),T1, and asymmetric readout on ancillaq2(Stabilizer).Adding a systematic coherent rotation on
q0and ZZ crosstalk on(0, 2)(MatrixProductState,χ=32).
dem = noisy.detector_error_model(decompose_errors=True)
task = sinter.Task(circuit=stripped, detector_error_model=dem)
backend = MaestroBackend(max_bond_dimension=32)
runs = [
(
"Uniform SI1000 baseline",
build_noise_model(stripped, "pauli", **rates),
backend.config_for("pauli"),
PAULI_SHOTS,
),
(
"Hot qubit + T1 + asym readout",
build_hot_qubit_model(stripped, rates, hot_scale=10.0),
backend.config_for("pauli", t1=rates["p_2q"]),
PAULI_SHOTS,
),
(
"+ Coherent rot & ZZ crosstalk",
build_hot_qubit_model(
stripped, rates, hot_scale=10.0, include_coherent_and_crosstalk=True
),
backend.config_for("coherent"),
MPS_SHOTS,
),
]
print(f" {'model configuration':<31} │ {'simulator':<18} │ {'det. rate':>9} │ {'LEP':>15}")
print(f" {'─' * 74}")
for label, noise_model, config, shots in runs:
sampler = MaestroCompiledSampler(
task,
noise_model=noise_model,
config=config,
decoder="pymatching",
seed=SEED,
)
dets, obs = sampler.sample_detection_events(shots)
lep, err = decode_logical_error_rate(dem, dets, obs)
sim_name = config.simulation_type.name
print(f" {label:<31} │ {sim_name:<18} │ {dets.mean():>9.4f} │ {lep:.4f} ± {err:.4f}")
Output:
model configuration │ simulator │ det. rate │ LEP
──────────────────────────────────────────────────────────────────────────
Uniform SI1000 baseline │ Stabilizer │ 0.1223 │ 0.0740 ± 0.0041
Hot qubit + T1 + asym readout │ Stabilizer │ 0.1373 │ 0.0820 ± 0.0043
+ Coherent rot & ZZ crosstalk │ MatrixProductState │ 0.1507 │ 0.0900 ± 0.0165
Both the mean detection-event rate and the decoded logical error probability increase as per-qubit degradation and non-Clifford coherent/crosstalk errors are added.
Step 3: Multi-worker sweeps with MaestroSinterSampler¶
Because maestro.NoiseModel is picklable across worker processes, you can wrap
each custom noise model in MaestroSinterSampler(noise_model=..., config=..., decoder="pymatching")
and pass the mapping to sinter.collect(custom_decoders=...) to sweep
calibration parameters across multiple CPU workers:
tasks = []
custom_decoders = {}
for scale in HOT_SCALES:
decoder_key = f"maestro_hot_{scale:g}x"
tasks.append(
sinter.Task(
circuit=stripped,
detector_error_model=dem,
decoder=decoder_key,
json_metadata={"hot_scale": scale},
)
)
custom_decoders[decoder_key] = MaestroSinterSampler(
noise_model=build_hot_qubit_model(stripped, rates, hot_scale=scale),
config=config,
decoder="pymatching",
)
stats = sinter.collect(
num_workers=2,
tasks=tasks,
custom_decoders=custom_decoders,
max_shots=SWEEP_SHOTS,
max_batch_size=500,
)
Output:
hot_scale │ shots │ errors │ LER
─────────────────────────────────────────────
1.0x │ 2000 │ 144 │ 0.0720 ± 0.0058
8.0x │ 2000 │ 188 │ 0.0940 ± 0.0065
20.0x │ 2000 │ 216 │ 0.1080 ± 0.0069
Key takeaways¶
Seed a custom
maestro.NoiseModelfrombuild_noise_model(stripped, "pauli", **rates)after callingstrip_gate_noise(noisy, readout=True)so uniform qubits keep their baseline rates while degraded qubits/couplers are overridden cleanly.Use
MaestroCompiledSamplerfor seeded direct sampling andMaestroSinterSamplerfor multi-workersinter.collectsweeps.Next: A surface code sweep scales up to a 4-worker surface code benchmark and coherent vs. Pauli twirl comparison.
Full script and complete output¶
View full script (examples/example_custom_noise_model.py)
"""
Custom Maestro NoiseModel — Per-Qubit Calibration, Crosstalk & Sinter
=====================================================================
`MaestroBackend` extracts uniform gate and readout error rates from a Stim or
Deltakit circuit so you do not have to construct a `maestro.NoiseModel` for
standard Pauli, T1, or uniform coherent-noise studies.
When modelling device-specific calibration data—such as a degraded "hot" qubit,
a noisy two-qubit coupler, asymmetric readout errors, directed coherent
over-rotations, or ZZ crosstalk—you can build a custom `maestro.NoiseModel`
and pass it directly to `maestro.sinter.MaestroCompiledSampler` or
`maestro.sinter.MaestroSinterSampler`.
Demonstrates:
1. Seeding a `maestro.NoiseModel` from a Deltakit SI1000 surface-code
circuit with `noise_params_from_stim()`, `strip_gate_noise()`, and
`build_noise_model()`, then customizing individual qubits and couplers.
2. Direct sampling and PyMatching decoding with `MaestroCompiledSampler`
comparing uniform SI1000 against a hot qubit/coupler (Stabilizer) and
adding directed coherent rotations + ZZ crosstalk (MPS).
3. Multi-worker `sinter.collect` sweeps over custom `maestro.NoiseModel`
configurations using `MaestroSinterSampler`.
Usage:
pip install -e ".[examples]" # from project root (deltakit-maestro/)
python examples/example_custom_noise_model.py
"""
# Deltakit must be imported before pymatching: with pymatching 2.4 loaded
# first, Deltakit's circuit builders fail with "Caught an unknown exception!".
from deltakit.circuit.gates import PauliBasis
from deltakit.explorer.codes import RotatedPlanarCode, css_code_memory_circuit
from deltakit.explorer.qpu import QPU, SI1000Noise
# isort: split
import maestro
import numpy as np
import pymatching
import sinter
import stim
from maestro.sinter import MaestroCompiledSampler, MaestroSinterSampler
from deltakit_maestro import (
MaestroBackend,
build_noise_model,
noise_params_from_stim,
strip_gate_noise,
to_stim,
)
# =============================================================================
# EXPERIMENT CONFIGURATION
# =============================================================================
BASE_P = 0.005 # Baseline SI1000 noise parameter
ROUNDS = 3
HOT_DATA_QUBIT = 0 # Degraded data qubit index
HOT_ANCILLA_QUBIT = 2 # Measure qubit coupled to HOT_DATA_QUBIT
HOT_COUPLER = (0, 2) # Degraded two-qubit gate pair
PAULI_SHOTS = 4000
MPS_SHOTS = 300
SWEEP_SHOTS = 2000
HOT_SCALES = [1.0, 8.0, 20.0]
SEED = 7
# =============================================================================
# CIRCUIT & CUSTOM NOISE MODEL BUILDERS
# =============================================================================
def build_surface_code_base(p: float = BASE_P) -> tuple[stim.Circuit, stim.Circuit, dict]:
"""Build a d=3 rotated surface code and split its gate/readout noise out.
Returns (original_noisy_circuit, stripped_circuit, extracted_rates).
"""
code = RotatedPlanarCode(width=3, height=3)
circuit = css_code_memory_circuit(code, num_rounds=ROUNDS, logical_basis=PauliBasis.Z)
noisy = to_stim(
QPU(circuit.qubits, noise_model=SI1000Noise(p=p)).compile_and_add_noise_to_circuit(circuit)
)
rates = noise_params_from_stim(noisy)
# Strip gate depolarizing and measurement flips so the custom NoiseModel
# supplies them without double-counting; idle and reset noise stay in `stripped`.
stripped = strip_gate_noise(noisy, readout=True)
return noisy, stripped, rates
def build_hot_qubit_model(
stripped: stim.Circuit,
rates: dict[str, float],
hot_scale: float = 10.0,
include_coherent_and_crosstalk: bool = False,
) -> maestro.NoiseModel:
"""Start from uniform SI1000 rates and override a degraded qubit and coupler."""
nm = build_noise_model(stripped, "pauli", **rates)
# Per-qubit and per-coupler Pauli + T1 + asymmetric readout overrides
nm.set_1q_gate_depolarizing(HOT_DATA_QUBIT, rates["p_1q"] * hot_scale)
nm.set_2q_depolarizing(*HOT_COUPLER, rates["p_2q"] * hot_scale)
nm.set_t1(HOT_DATA_QUBIT, rates["p_2q"] * 0.5 * hot_scale)
nm.set_readout_error(
HOT_ANCILLA_QUBIT,
p_meas1_prep0=rates["readout_error"] * 0.8,
p_meas0_prep1=min(0.45, rates["readout_error"] * (1.0 + 0.15 * hot_scale)),
)
if include_coherent_and_crosstalk:
# Non-Clifford hardware channels (require MatrixProductState simulation)
nm.set_coherent_rotation(HOT_DATA_QUBIT, rx=0.08, ry=0.0, rz=0.16)
nm.set_crosstalk(*HOT_COUPLER, strength=0.06)
return nm
def decode_logical_error_rate(
dem: stim.DetectorErrorModel,
dets: np.ndarray,
obs: np.ndarray,
) -> tuple[float, float]:
"""Decode detection events with PyMatching and return (lep, std_error)."""
matcher = pymatching.Matching.from_detector_error_model(dem)
predicted = matcher.decode_batch(dets)
fails = np.any(predicted != obs, axis=1)
rate = float(fails.mean())
err = float(np.sqrt(rate * (1 - rate) / len(fails)))
return rate, err
# =============================================================================
# STEP DEMONSTRATIONS
# =============================================================================
def step_1_inspect_custom_model(stripped: stim.Circuit, rates: dict[str, float]):
"""Build and inspect custom maestro.NoiseModel instances."""
print(f"\n{'─' * 76}")
print(" 1. Building a custom maestro.NoiseModel on top of SI1000 base rates")
print(f"{'─' * 76}")
print(" strip_gate_noise(noisy, readout=True) removes gate depolarizing and M(p)")
print(" flips so a custom maestro.NoiseModel can assign per-qubit and per-coupler")
print(" channels while keeping SI1000's idle and reset noise inside the circuit.\n")
pauli_hot = build_hot_qubit_model(stripped, rates, hot_scale=10.0)
coherent_hot = build_hot_qubit_model(
stripped, rates, hot_scale=10.0, include_coherent_and_crosstalk=True
)
print(f" Base SI1000 rates: {rates}")
print(
f" Hot qubit / coupler: q{HOT_DATA_QUBIT}, q{HOT_ANCILLA_QUBIT}, coupler {HOT_COUPLER}"
)
print(
f" Hot Pauli + T1 model: has_1q={pauli_hot.has_1q_gate_noise()}, "
f"has_2q={pauli_hot.has_any_2q_depolarizing()}, "
f"has_t1={pauli_hot.has_t1()}, "
f"has_readout={pauli_hot.has_readout_error()}"
)
print(
f" + Coherent & crosstalk: has_coherent={coherent_hot.has_coherent()}, "
f"has_crosstalk={coherent_hot.has_crosstalk()}"
)
def step_2_direct_sampling(
noisy: stim.Circuit,
stripped: stim.Circuit,
rates: dict[str, float],
):
"""Sample and decode custom NoiseModel runs via MaestroCompiledSampler."""
print(f"\n{'─' * 76}")
print(" 2. Direct sampling and decoding with MaestroCompiledSampler")
print(f"{'─' * 76}")
print(" A standard decoder uses the baseline Detector Error Model (DEM) from the")
print(" uniform circuit, while MaestroCompiledSampler executes the custom hardware")
print(" NoiseModel on Stabilizer (Clifford + T1) or MatrixProductState (coherent).\n")
dem = noisy.detector_error_model(decompose_errors=True)
task = sinter.Task(circuit=stripped, detector_error_model=dem)
backend = MaestroBackend(max_bond_dimension=32)
runs = [
(
"Uniform SI1000 baseline",
build_noise_model(stripped, "pauli", **rates),
backend.config_for("pauli"),
PAULI_SHOTS,
),
(
"Hot qubit + T1 + asym readout",
build_hot_qubit_model(stripped, rates, hot_scale=10.0),
backend.config_for("pauli", t1=rates["p_2q"]),
PAULI_SHOTS,
),
(
"+ Coherent rot & ZZ crosstalk",
build_hot_qubit_model(
stripped, rates, hot_scale=10.0, include_coherent_and_crosstalk=True
),
backend.config_for("coherent"),
MPS_SHOTS,
),
]
print(f" {'model configuration':<31} │ {'simulator':<18} │ {'det. rate':>9} │ {'LEP':>15}")
print(f" {'─' * 74}")
for label, noise_model, config, shots in runs:
sampler = MaestroCompiledSampler(
task,
noise_model=noise_model,
config=config,
decoder="pymatching",
seed=SEED,
)
dets, obs = sampler.sample_detection_events(shots)
lep, err = decode_logical_error_rate(dem, dets, obs)
sim_name = config.simulation_type.name
print(f" {label:<31} │ {sim_name:<18} │ {dets.mean():>9.4f} │ {lep:.4f} ± {err:.4f}")
def step_3_sinter_sweep(
noisy: stim.Circuit,
stripped: stim.Circuit,
rates: dict[str, float],
):
"""Run a multi-worker Sinter sweep across custom NoiseModel instances."""
print(f"\n{'─' * 76}")
print(" 3. Multi-worker sinter.collect with MaestroSinterSampler")
print(f"{'─' * 76}")
print(" MaestroSinterSampler wraps a custom NoiseModel as a Sinter Sampler so")
print(" sinter.collect can distribute shots across worker processes.\n")
dem = noisy.detector_error_model(decompose_errors=True)
config = MaestroBackend().config_for("pauli", t1=rates["p_2q"])
tasks = []
custom_decoders = {}
for scale in HOT_SCALES:
decoder_key = f"maestro_hot_{scale:g}x"
tasks.append(
sinter.Task(
circuit=stripped,
detector_error_model=dem,
decoder=decoder_key,
json_metadata={"hot_scale": scale},
)
)
custom_decoders[decoder_key] = MaestroSinterSampler(
noise_model=build_hot_qubit_model(stripped, rates, hot_scale=scale),
config=config,
decoder="pymatching",
)
stats = sinter.collect(
num_workers=2,
tasks=tasks,
custom_decoders=custom_decoders,
max_shots=SWEEP_SHOTS,
max_batch_size=500,
)
print(f" {'hot_scale':>9} │ {'shots':>6} │ {'errors':>6} │ {'LER':>15}")
print(f" {'─' * 45}")
for stat in sorted(stats, key=lambda s: s.json_metadata["hot_scale"]):
scale = stat.json_metadata["hot_scale"]
ler = stat.errors / stat.shots
err = float(np.sqrt(ler * (1 - ler) / stat.shots))
print(f" {scale:>8.1f}x │ {stat.shots:>6} │ {stat.errors:>6} │ {ler:.4f} ± {err:.4f}")
# =============================================================================
# MAIN ENTRYPOINT
# =============================================================================
def main():
noisy, stripped, rates = build_surface_code_base()
print("=" * 76)
print(" Custom maestro.NoiseModel — per-qubit calibration, crosstalk & Sinter")
print("=" * 76)
print(f" Circuit: d=3 rotated surface code ({stripped.num_qubits} qubits, {ROUNDS} rounds)")
# 1. Build and inspect custom NoiseModel layers
step_1_inspect_custom_model(stripped, rates)
# 2. Direct sampling and decoding on Stabilizer and MPS
step_2_direct_sampling(noisy, stripped, rates)
# 3. Multi-worker Sinter sweep
step_3_sinter_sweep(noisy, stripped, rates)
print("=" * 76)
if __name__ == "__main__":
main()
View complete output
============================================================================
Custom maestro.NoiseModel — per-qubit calibration, crosstalk & Sinter
============================================================================
Circuit: d=3 rotated surface code (17 qubits, 3 rounds)
────────────────────────────────────────────────────────────────────────────
1. Building a custom maestro.NoiseModel on top of SI1000 base rates
────────────────────────────────────────────────────────────────────────────
strip_gate_noise(noisy, readout=True) removes gate depolarizing and M(p)
flips so a custom maestro.NoiseModel can assign per-qubit and per-coupler
channels while keeping SI1000's idle and reset noise inside the circuit.
Base SI1000 rates: {'p_1q': 0.0005, 'p_2q': 0.005, 'readout_error': 0.025}
Hot qubit / coupler: q0, q2, coupler (0, 2)
Hot Pauli + T1 model: has_1q=True, has_2q=True, has_t1=True, has_readout=True
+ Coherent & crosstalk: has_coherent=True, has_crosstalk=True
────────────────────────────────────────────────────────────────────────────
2. Direct sampling and decoding with MaestroCompiledSampler
────────────────────────────────────────────────────────────────────────────
A standard decoder uses the baseline Detector Error Model (DEM) from the
uniform circuit, while MaestroCompiledSampler executes the custom hardware
NoiseModel on Stabilizer (Clifford + T1) or MatrixProductState (coherent).
model configuration │ simulator │ det. rate │ LEP
──────────────────────────────────────────────────────────────────────────
Uniform SI1000 baseline │ Stabilizer │ 0.1223 │ 0.0740 ± 0.0041
Hot qubit + T1 + asym readout │ Stabilizer │ 0.1373 │ 0.0820 ± 0.0043
+ Coherent rot & ZZ crosstalk │ MatrixProductState │ 0.1507 │ 0.0900 ± 0.0165
────────────────────────────────────────────────────────────────────────────
3. Multi-worker sinter.collect with MaestroSinterSampler
────────────────────────────────────────────────────────────────────────────
MaestroSinterSampler wraps a custom NoiseModel as a Sinter Sampler so
sinter.collect can distribute shots across worker processes.
hot_scale │ shots │ errors │ LER
─────────────────────────────────────────────
1.0x │ 2000 │ 144 │ 0.0720 ± 0.0058
8.0x │ 2000 │ 188 │ 0.0940 ± 0.0065
20.0x │ 2000 │ 216 │ 0.1080 ± 0.0069
============================================================================