Vai al contenuto principale

Rilevamento di errori a basso overhead con codici spaziotemporali

Stima di utilizzo: 4 minuti su un processore Heron (ibm_kingston o equivalente) (NOTA: questa è solo una stima. Il tuo tempo di esecuzione potrebbe variare.)

Risultati di apprendimento

  • Come i controlli Pauli spaziotemporali rilevano errori logici nei circuiti di Clifford, e come la postselezione sulle loro sindromi aumenta la fedeltà di una distribuzione campionata.

  • Come usare il pacchetto qiskit-paulice per trovare e inserire automaticamente controlli efficienti a livello hardware con get_check_qubits, NoiseModel e add_pauli_checks.

  • Come stimare la fedeltà di uno stato stabilizzatore campionando i suoi stabilizzatori e applicando la postselezione sulle sindromi di controllo.

  • Come eseguire il flusso di lavoro completo di rilevamento degli errori su hardware IBM Quantum® e confrontare le fedeltà rumorose e postselezionate.

Prerequisiti

  • Fondamenti dell'hardware per il calcolo quantistico su scala di utilità.

  • Il formalismo di Clifford e degli stabilizzatori, incluso come un gruppo di stabilizzatori descrive uno stato stabilizzatore puro.

Contesto

Low-overhead error detection with spacetime codes [1] di Simon Martiel e Ali Javadi-Abhari introduce un metodo per rilevare errori logici in circuiti a dominanza Clifford che si colloca tra la correzione completa degli errori e la mitigazione degli errori più leggera. L'idea si basa sui controlli Pauli coerenti (CPC) descritti in Single-shot error mitigation by coherent Pauli checks [2] di van den Berg e altri. In entrambi gli approcci, un circuito "payload" di Clifford viene intrecciato con qubit ancilla per verificare certe invarianti. Misurando le ancille si produce una sindrome che riporta se durante l'esecuzione è stato rilevato un errore. Mantenere solo i campioni senza errori rilevati migliora la fedeltà della distribuzione campionata, al costo di un tasso di postselezione ridotto.

La differenza chiave tra i controlli Pauli coerenti e i controlli spaziotemporali è negli operatori che misurano. I controlli Pauli coerenti misurano operatori localizzati nel tempo e ad alto peso. Su topologie di qubit con connettività limitata, come l'heavy hex, questi controlli richiedono molte porte SWAP e spesso rendono il circuito troppo profondo da eseguire nella pratica. Implementare i controlli come codici spaziotemporali distribuisce invece ciascun controllo nel circuito payload sia nello spazio che nel tempo. Questo produce una codifica efficiente a livello hardware che rimane efficace nel rilevare errori logici mantenendo basso l'overhead di qubit e di profondità.

Cosa fa il pacchetto qiskit-paulice

Il pacchetto qiskit-paulice automatizza la costruzione di questi controlli in modo che tu non debba costruirli a mano. Il suo ruolo principale è trovare e inserire controlli Pauli spaziotemporali validi nelle posizioni di un circuito che massimizzano il rilevamento degli errori riducendo al minimo l'overhead di qubit. Un controllo è valido quando i suoi operatori lasciano invariata l'azione logica del circuito payload, è a basso peso quando usa poche porte di entanglement, ed è efficace quando rileva una grande porzione degli errori, rispetto al rumore introdotto dal controllo stesso. Il pacchetto valuta i controlli candidati rispetto a un modello di rumore e applica al circuito quelli migliori. Questo tutorial usa tre metodi API:

  • get_check_qubits ispeziona la mappa di accoppiamento di un backend e restituisce coppie di qubit target e ancilla. Un controllo su target_qubits[i] usa ancilla_qubits[i].

  • NoiseModel.from_backend costruisce un modello di rumore approssimativo dai dati di benchmark del backend. Il modello valuta i controlli candidati, quindi non è necessario un modello di rumore esatto e appreso. Per un modello di Pauli-Lindblad appreso, vedi NoiseModel.from_pauli_lindblad_maps.

  • add_pauli_checks trova e inserisce controlli in un circuito. Restituisce una sequenza di oggetti CheckedCircuit con un numero crescente di controlli, e ciascun oggetto fornisce un get_postselection_method che mappa una stringa di bit misurata su un vettore di sindrome. L'argomento cost seleziona la funzione che valuta un controllo (gamma, l'overhead di campionamento del canale di rumore inverso postselezionato, oppure LER, il tasso di errore logico). L'argomento method seleziona la strategia di ricerca (windowed, genetic o windowed_genetic). Questo tutorial usa cost="gamma" e method="windowed", che insieme garantiscono una selezione dei controlli deterministica e riproducibile.

Stimare la fedeltà dal campionamento degli stabilizzatori

Per misurare quanto bene funziona il rilevamento degli errori, puoi stimare la fedeltà dello stato stabilizzatore ψ=U0n|\psi\rangle = U|0\rangle^{\otimes n} che il circuito prepara idealmente rispetto allo stato rumoroso ρ\rho che l'hardware effettivamente produce in output. Il proiettore su uno stato stabilizzatore puro ψ|\psi\rangle è uguale alla media uniforme sui 2n2^n elementi del suo gruppo di stabilizzatori S\mathcal{S}:

ψψ=12nGSG.|\psi\rangle\langle\psi| = \frac{1}{2^n}\sum_{G \in \mathcal{S}} G.

Sostituendo questo nella fedeltà si ottiene la fedeltà di ρ\rho come valore medio di aspettazione di ogni stabilizzatore GSG \in \mathcal{S} rispetto a ρ\rho:

F=Tr(ρψψ)=12nGSTr(ρG)=12nGSGρ.F = \mathrm{Tr}(\rho|\psi\rangle\langle\psi|) = \frac{1}{2^n} \sum_{G \in \mathcal{S}} \mathrm{Tr}(\rho G) = \frac{1}{2^n} \sum_{G \in \mathcal{S}} \langle G \rangle_\rho.

Per problemi più grandi, enumerare tutti i 2n2^n stabilizzatori è impraticabile, quindi puoi stimare la fedeltà da un campione casuale. Estraendo MM stabilizzatori G1,,GMG_1, \ldots, G_M in modo uniforme e casuale da S\mathcal{S} si ottiene una stima non distorta:

F^M=1Mi=1MGiρ.\hat F_M = \frac{1}{M} \sum_{i=1}^M \langle G_i \rangle_\rho.

Poiché un circuito di Clifford prepara uno stato stabilizzatore, puoi stimare la sua fedeltà direttamente dai valori di aspettazione campionati dei suoi stabilizzatori. Questo tutorial mostra prima il flusso di lavoro su un simulatore con un piccolo circuito, poi esegue lo stesso flusso di lavoro su hardware con un circuito più grande e più profondo. Man mano che i circuiti includono più operazioni non di Clifford, il numero di controlli validi si riduce rapidamente, quindi il metodo funziona meglio per circuiti a dominanza Clifford.

Requisiti

Prima di iniziare questo tutorial, assicurati di avere installato quanto segue:

  • Qiskit SDK v2.0 o successivo, con supporto per la visualizzazione

  • Qiskit Runtime v0.40 o successivo (pip install qiskit-ibm-runtime)

  • Qiskit Aer v0.17 o successivo (pip install qiskit-aer)

  • Qiskit Paulice (pip install qiskit-paulice)

  • tqdm (pip install tqdm)

Configurazione

Importa le librerie richieste e definisci le funzioni helper che non sono disponibili come importazioni. La funzione random_clifford_circuit costruisce un payload di Clifford casuale a muratura (brickwork), find_check_layout cerca nella mappa di accoppiamento di un backend un percorso di qubit a basso errore con molte ancille disponibili, learned_noise_model trasforma l'output di NoiseLearner in un modello di rumore qiskit-paulice, append_basis_rotation ruota un circuito in modo che uno stabilizzatore venga misurato nella base computazionale, expectation calcola un valore di aspettazione dello stabilizzatore dai conteggi campionati, e cum_mean_sem tiene traccia della stima corrente della fedeltà.

# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-aer qiskit-ibm-runtime qiskit-paulice tqdm
# Standard library imports
import random
import time

# External libraries
import matplotlib.pyplot as plt
import numpy as np
from tqdm import tqdm

# Qiskit
from qiskit import QuantumCircuit
from qiskit.quantum_info import Clifford, Pauli, PauliLindbladMap, PauliList
from qiskit.result import sampled_expectation_value
from qiskit.transpiler import generate_preset_pass_manager
from qiskit.visualization import plot_coupling_map

# Qiskit Aer
from qiskit_aer import AerSimulator
from qiskit_aer.noise import NoiseModel as AerNoiseModel
from qiskit_aer.noise import ReadoutError, depolarizing_error

# Qiskit IBM Runtime
from qiskit_ibm_runtime import NoiseLearner, QiskitRuntimeService
from qiskit_ibm_runtime import SamplerV2 as Sampler

# Qiskit Paulice
from qiskit_paulice import add_pauli_checks
from qiskit_paulice.layout import get_check_qubits
from qiskit_paulice.noise_models import NoiseModel
def random_clifford_circuit(
num_qubits: int, depth: int, rng: np.random.Generator
) -> QuantumCircuit:
"""Brickwork random Clifford on `num_qubits`, with `depth` CZ layers."""
qc = QuantumCircuit(num_qubits)
qc.h(range(num_qubits))
for d in range(depth):
for i in range(d % 2, num_qubits - 1, 2):
qc.cz(i, i + 1)
for q in range(num_qubits):
if rng.integers(0, 2):
qc.sx(q)
if rng.integers(0, 2):
qc.s(q)
if rng.integers(0, 2):
qc.sx(q)
return qc

def find_check_layout(
backend,
num_qubits: int,
rng: np.random.Generator,
num_trials: int = 200,
max_gate_error: float = 0.03,
max_readout_error: float = 0.2,
) -> list[int]:
"""Find a low-error path of `num_qubits` qubits with many available ancillas.

Builds random self-avoiding walks on the coupling map, excluding the qubits
and two-qubit gates whose reported errors exceed the thresholds, and keeps
the path that offers the most target and ancilla pairs. Ties are broken by
the lower average two-qubit gate error along the path.
"""
target = backend.target
gate_2q = next(
name for name in ("cz", "ecr", "cx") if name in target.operation_names
)

# Collect per-edge gate errors and per-qubit readout errors
edge_error = {}
for qubits, props in target[gate_2q].items():
edge = tuple(sorted(qubits))
if props is not None and props.error is not None:
edge_error[edge] = min(edge_error.get(edge, 1.0), props.error)
readout_error = {
qubit: target["measure"][(qubit,)].error
for (qubit,) in target["measure"]
}

# Keep only the edges whose gate and readout errors are acceptable
adjacency = {}
for (q1, q2), error in edge_error.items():
if (
error <= max_gate_error
and readout_error.get(q1, 1.0) <= max_readout_error
and readout_error.get(q2, 1.0) <= max_readout_error
):
adjacency.setdefault(q1, set()).add(q2)
adjacency.setdefault(q2, set()).add(q1)

# Random self-avoiding walks; keep the path with the most check pairs
starts = sorted(adjacency)
best_path = None
best_score = (-1, float("inf"))
for _ in range(num_trials):
path = [starts[rng.integers(len(starts))]]
while len(path) < num_qubits:
options = sorted(adjacency[path[-1]] - set(path))
if not options:
break
path.append(options[rng.integers(len(options))])
if len(path) < num_qubits:
continue
num_pairs = len(get_check_qubits(backend.coupling_map, path)[0])
mean_error = float(
np.mean(
[edge_error[tuple(sorted(e))] for e in zip(path, path[1:])]
)
)
if num_pairs > best_score[0] or (
num_pairs == best_score[0] and mean_error < best_score[1]
):
best_path, best_score = path, (num_pairs, mean_error)

if best_path is None:
raise RuntimeError(
"No connected low-error path found. Relax the error thresholds."
)
return best_path

def learned_noise_model(layer_errors, layout: list[int]) -> NoiseModel:
"""Build a `NoiseModel` from `NoiseLearner` results.

`NoiseLearner` reports one `PauliLindbladError` per entangling layer, whose
generators are indexed against that layer's own physical qubits, while
`NoiseModel.from_pauli_lindblad_maps` expects `PauliLindbladMap`s indexed the
way `NoiseModel.from_backend` indexes them: by position in `layout`. This
translates between the two and drops generators that fall outside `layout`.
"""
phys_to_virt = {phys: virt for virt, phys in enumerate(layout)}
maps = []
for layer in layer_errors:
if layer.error is None:
continue
terms = []
for pauli, rate in zip(
layer.error.generators, layer.error.rates, strict=True
):
label, indices = [], []
for local, phys in enumerate(layer.qubits):
x, z = bool(pauli.x[local]), bool(pauli.z[local])
if not (x or z):
continue
if phys not in phys_to_virt:
break # generator reaches outside the layout, so skip it
label.append("Y" if x and z else "X" if x else "Z")
indices.append(phys_to_virt[phys])
else:
if label:
terms.append(
("".join(label), tuple(indices), float(rate))
)
# Each map needs a 2-qubit generator to define an entangling layer
if any(len(t[1]) == 2 for t in terms):
maps.append(
PauliLindbladMap.from_sparse_list(
terms, num_qubits=len(layout)
)
)
if not maps:
raise RuntimeError(
"No usable layer errors. Check that the learner ran on this layout."
)
return NoiseModel.from_pauli_lindblad_maps(maps)

def append_basis_rotation(
circuit: QuantumCircuit, pauli: Pauli
) -> QuantumCircuit:
"""Strip measurements, append basis rotations for `pauli`, and re-measure."""
out = circuit.remove_final_measurements(inplace=False)
for q in range(pauli.num_qubits):
if pauli.x[q]:
if pauli.z[q]:
out.sdg(q)
out.h(q)
out.measure_all()
return out

def expectation(counts: dict, pauli: Pauli) -> float:
"""Expectation value of `pauli` from counts measured in the Z basis.

Pads with identity on any qubits beyond the support of `pauli`, such as the
check ancillas that appear in the postselected counts.
"""
if not counts:
return float("nan")
n = pauli.num_qubits
sign = -1 if int(pauli.phase) % 4 == 2 else 1
total = len(next(iter(counts)))
label = "".join(
"Z" if q < n and (pauli.x[q] or pauli.z[q]) else "I"
for q in range(total - 1, -1, -1)
)
return sign * sampled_expectation_value(counts, label)

def cum_mean_sem(values: np.ndarray):
"""Cumulative mean and standard error of the mean, ignoring NaNs."""
valid = ~np.isnan(values)
total = np.cumsum(np.where(valid, values, 0.0))
total_sq = np.cumsum(np.where(valid, values**2, 0.0))
count = np.maximum(np.cumsum(valid).astype(float), 1)
mean = total / count
sem = np.sqrt(np.maximum(total_sq / count - mean**2, 0) / count)
return np.where(np.cumsum(valid) > 0, mean, np.nan), sem

Esempio su simulatore su piccola scala

Questa sezione illustra l'intero flusso di lavoro su un simulatore rumoroso. Usa i dati di benchmark del backend per scegliere un layout di qubit e un modello di rumore, trova i controlli automaticamente, e usa la postselezione sulla distribuzione campionata per mostrare il miglioramento della fedeltà.

Passo 1: Mappare gli input classici su un problema quantistico

Il circuito payload è un circuito di Clifford casuale a muratura (brickwork) unidimensionale poco profondo. Poiché il circuito è di Clifford, prepara uno stato stabilizzatore la cui fedeltà puoi stimare direttamente dai valori di aspettazione campionati degli stabilizzatori. Inizia con un circuito poco profondo in modo che i controlli siano facili da visualizzare nel passo successivo.

num_qubits = 12
depth = 4
seed = 1764
rng = np.random.default_rng(seed)
np.random.seed(seed)

circuit = random_clifford_circuit(num_qubits, depth, rng)
circuit.measure_all()
circuit.draw("mpl", fold=-1, scale=0.6)

Output of the previous code cell

Passo 2: Ottimizzare per l'esecuzione su hardware quantistico

Mappare il circuito sull'hardware imposta il layout fisico dei qubit, il modello di rumore che valuta i controlli candidati, e i controlli stessi.

Prima, seleziona un backend e cerca nella sua mappa di accoppiamento un layout di qubit unidimensionale con l'helper find_check_layout definito nella sezione Configurazione. L'helper costruisce cammini casuali auto-evitanti che evitano le porte e le letture con l'errore più alto, e mantiene il percorso che offre il maggior numero di coppie target e ancilla. Poiché la ricerca legge la connettività e i dati di errore direttamente dal backend, lo stesso codice funziona su qualsiasi QPU IBM Quantum. La funzione get_check_qubits restituisce quindi le coppie target e ancilla, dove un controllo su target_qubits[i] usa ancilla_qubits[i].

Nel grafo di accoppiamento che segue, i qubit verdi sono i qubit payload e i qubit arancioni sono le ancille che implementano i controlli. I qubit con un'ancilla adiacente sono usati come qubit target per i controlli.

service = QiskitRuntimeService()
backend = service.least_busy(operational=True, simulator=False)

print(f"Backend: {backend.name}")

# Search for a low-error path, then pair each target qubit with a neighboring ancilla
layout = find_check_layout(backend, num_qubits, rng)
target_qubits, ancilla_qubits = get_check_qubits(backend, layout)
num_checks = len(target_qubits)

print(f"Target qubits: {target_qubits}")
print(f"Ancilla qubits: {ancilla_qubits}")
plot_coupling_map(
num_qubits=backend.num_qubits,
qubit_coordinates=getattr(
backend.configuration(), "qubit_coordinates", None
),
coupling_map=backend.configuration().coupling_map,
figsize=(12, 12),
qubit_color=[
"#4CAF50"
if i in set(layout)
else "#FF9800"
if i in set(ancilla_qubits)
else "#DDDDDD"
for i in backend.coupling_map.graph.node_indices()
],
qubit_size=220,
line_width=2,
font_size=90,
)
Backend: ibm_boston
Target qubits: [105, 107, 108, 123, 125, 141, 143]
Ancilla qubits: [104, 97, 109, 122, 126, 140, 144]

Output of the previous code cell

Con il backend e il layout scelti, transpila il payload in un circuito con architettura del set di istruzioni (ISA). È necessario solo impostare il layout e tradurre le porte nel set di porte nativo del backend.

pm = generate_preset_pass_manager(
optimization_level=0, backend=backend, initial_layout=layout
)
circuit_isa = pm.run(circuit)
circuit_isa.draw("mpl", fold=-1, scale=0.6)

Output of the previous code cell

Successivamente, modella come il rumore delle porte e della lettura sul backend influisce sull'esecuzione. Il modello di rumore determina dove nel circuito un controllo cattura più errore. Un modello più accurato migliora il rilevamento, ma di solito non è necessario impararne uno campionando la QPU. Il modello che segue deduce un canale di depolarizzazione uniforme per il rumore delle porte e della lettura dai dati di benchmark di qiskit-ibm-runtime.

noise_model = NoiseModel.from_backend(
backend, layout, uniform_gate_noise=True
)
print(noise_model)
NoiseModel(gate_noise=0.001079865281450939, readout_noise=0.006001790364583333, idling_noise=None)

Ora aggiungi controlli al circuito. La funzione add_pauli_checks prende il payload di Clifford, l'elenco dei qubit target e il modello di rumore. L'argomento ancilla_qubits indica alla funzione quale ancilla fisica abbinare a ciascun target. I controlli vengono aggiunti nell'ordine in cui compaiono i qubit target, quindi il layout finale del circuito controllato è layout + ancilla_qubits. Per eseguire un circuito di output con meno controlli (i), il layout finale è layout + ancilla_qubits[:i].

L'output di add_pauli_checks è una sequenza di circuiti con un numero crescente di controlli, da nessun controllo fino a un controllo su ogni qubit target. La visualizzazione conferma che i controlli usano le coppie target e ancilla specificate. Per i dettagli su come trovare buoni controlli, vedi le sezioni da II a IV delle informazioni supplementari nel riferimento [1].

checked = add_pauli_checks(
circuit_isa,
target_qubits,
noise_model,
ancilla_qubits=ancilla_qubits,
cost="gamma",
method="windowed",
seed=seed,
)

print(f"Physical layout of payload and ancillas: {layout + ancilla_qubits}")
print("Checked circuit:")
checked[-1].circuit.draw("mpl", fold=-1, idle_wires=False)
Physical layout of payload and ancillas: [108, 107, 106, 105, 117, 125, 124, 123, 136, 143, 142, 141, 104, 97, 109, 122, 126, 140, 144]
Checked circuit:

Output of the previous code cell

Passo 3: Eseguire usando le primitive Qiskit

Per rendere visibile l'effetto del rumore delle porte, aumenta la profondità del payload e campiona un sottoinsieme dei suoi stabilizzatori. Ogni stabilizzatore generalmente non commuta qubit per qubit con gli altri, quindi un singolo insieme di controlli non è valido per due stabilizzatori diversi. Invece di raggruppare gli stabilizzatori in insiemi commutanti, trova un buon insieme di controlli per ogni stabilizzatore in modo indipendente. Campionare gli stabilizzatori in modo uniforme e casuale fornisce una stima di fedeltà non distorta.

Costruisci il circuito più profondo e estrai un campione casuale dei suoi stabilizzatori.

depth = 24
num_stabilizers = 20
num_shots = 1_000

circuit = random_clifford_circuit(num_qubits, depth, rng)

# Build the full stabilizer group, then sample from it uniformly at random
circ_no_meas = circuit.remove_final_measurements(inplace=False)
stabilizer_group = PauliList([Pauli("I" * num_qubits)])
for generator in (
Pauli(label) for label in Clifford(circ_no_meas).to_labels(mode="S")
):
stabilizer_group = stabilizer_group + stabilizer_group.compose(generator)

keep = np.where(
stabilizer_group.x.any(axis=1) | stabilizer_group.z.any(axis=1)
)[0]
chosen = np.random.default_rng(seed).choice(
keep, size=min(num_stabilizers, len(keep)), replace=False
)
stabilizers = [stabilizer_group[int(i)] for i in chosen]

two_qubit_depth = circuit.depth(lambda x: x.operation.num_qubits == 2)
print(
f"Sampled {len(stabilizers)} stabilizers of a {circuit.num_qubits}-qubit "
f"circuit with two-qubit depth {two_qubit_depth}: "
f"{{{stabilizers[0]}, {stabilizers[1]}, ...}}"
)
Sampled 20 stabilizers of a 12-qubit circuit with two-qubit depth 24: {ZXIIXZYYXIZZ, XXXYIIZYXIII, ...}

Per ogni stabilizzatore campionato, ruota il circuito in modo che lo stabilizzatore venga misurato nella base computazionale, transpilalo sul backend, e trova un buon insieme di controlli. Le coppie target e ancilla vengono mescolate insieme per ogni stabilizzatore in modo che ogni target mantenga la propria ancilla. Ricorda che i controlli vengono applicati in sequenza nell'ordine in cui vengono forniti i qubit target, e un controllo applicato non viene modificato man mano che vengono aggiunti altri controlli.

noisy_circuits = []
checked_circuits = []
depths_2q = []
t0 = time.time()
for i, pauli in enumerate(tqdm(stabilizers)):
noisy_circuits.append(pm.run(append_basis_rotation(circuit, pauli)))
# Shuffle target and ancilla pairs together so each target keeps its ancilla
targets, ancillas = zip(
*random.sample(
list(zip(target_qubits, ancilla_qubits, strict=True)),
k=len(target_qubits),
),
strict=True,
)
checked_circuits.append(
add_pauli_checks(
noisy_circuits[-1],
list(targets),
noise_model,
ancilla_qubits=list(ancillas),
cost="gamma",
method="windowed",
seed=seed + 1 + i,
)
)
depths_2q.append(
checked_circuits[-1][-1].circuit.depth(lambda x: len(x.qubits) == 2)
)

print(
f"Added {num_checks} checks to {len(stabilizers)} circuits "
f"in {(time.time() - t0):.0f}s."
)
print(
f"On average, two-qubit depth increased from "
f"{circuit.depth(lambda x: len(x.qubits) == 2)} to {int(np.mean(depths_2q))} "
f"when adding {num_checks} checks."
)
100%|██████████| 20/20 [00:15<00:00, 1.29it/s]
Added 7 checks to 20 circuits in 15s.
On average, two-qubit depth increased from 24 to 33 when adding 7 checks.

Campiona il payload nudo e i circuiti controllati con Qiskit Aer. Il simulatore usa lo stesso modello di depolarizzazione che ha valutato i controlli, quindi il rumore a cui puntano i controlli è il rumore che il simulatore applica.

aer_nm = AerNoiseModel()
aer_nm.add_all_qubit_quantum_error(
depolarizing_error(noise_model.gate_noise, 2), ["cz"]
)
p = noise_model.readout_noise
aer_nm.add_all_qubit_readout_error(ReadoutError([[1 - p, p], [p, 1 - p]]))
noisy_sim = AerSimulator(method="stabilizer", noise_model=aer_nm)

counts = []
for i, checked_circ_result in enumerate(tqdm(checked_circuits)):
noisy_counts = (
noisy_sim.run(
noisy_circuits[i], shots=num_shots, seed_simulator=seed * i + 1
)
.result()
.get_counts()
)
checked_counts_per_variant = []
for k, ck in enumerate(checked_circ_result):
variant_counts = (
noisy_sim.run(
ck.circuit, shots=num_shots, seed_simulator=seed * i + 2 + k
)
.result()
.get_counts()
)
checked_counts_per_variant.append(variant_counts)
counts.append((noisy_counts, checked_counts_per_variant))
100%|██████████| 20/20 [00:17<00:00, 1.13it/s]

Passo 4: Post-elaborare e restituire il risultato nel formato classico desiderato

Ogni controllo usa porte di entanglement tra un'ancilla e un target. L'ancilla parte da 0|0\rangle, quindi ZancZ_\text{anc} stabilizza il suo input. Propagando ZancZ_\text{anc} in avanti attraverso il circuito controllato si ottiene un operatore di Pauli sull'output i cui termini non identità definiscono il supporto del controllo. Un controllo passa quando i bit nel suo supporto hanno parità pari. Un campione viene mantenuto solo quando ogni controllo passa.

Il get_postselection_method di ogni CheckedCircuit restituisce una funzione che mappa una stringa di bit misurata su un vettore di sindrome. Mantieni i campioni la cui sindrome è zero per ogni controllo, e scarta gli altri. Il grafico che segue mostra che aggiungere più controlli abbassa il tasso di postselezione. Un tasso di postselezione più basso richiede più shot per raggiungere un'accuratezza target, quindi c'è un compromesso tra capacità di rilevamento e costo di campionamento. Il tasso sembra convergere, il che indica che i controlli aggiuntivi contribuiscono con una capacità di rilevamento minore.

rate_per_variant = []
kept_per_stab = []
for i, (_, checked_counts_per_variant) in enumerate(counts):
rates = []
kept_at_num_checks = None
for k, variant_counts in enumerate(checked_counts_per_variant):
ps_fn = checked_circuits[i][k].get_postselection_method()
kept = {
bs: n for bs, n in variant_counts.items() if not ps_fn(bs).any()
}
rates.append(sum(kept.values()) / num_shots)
if k == num_checks:
kept_at_num_checks = kept
rate_per_variant.append(rates)
kept_per_stab.append(kept_at_num_checks)

max_len = max(len(s) for s in rate_per_variant)
rates_arr = np.full((len(rate_per_variant), max_len), np.nan)
for i, s in enumerate(rate_per_variant):
rates_arr[i, : len(s)] = s
ks = np.arange(max_len)

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(ks, rates_arr.T, color="#ff8c00", alpha=0.15, linewidth=1)
ax.plot(
ks,
np.nanmedian(rates_arr, axis=0),
color="black",
linewidth=1,
linestyle="--",
label="median",
)
ax.set_xlabel("Checks committed")
ax.set_ylabel("Postselection rate")
ax.set_ylim((0, 1.05))
ax.set_title(
f"Per-stabilizer postselection rate ({len(rates_arr)} stabilizers)"
)
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

Output of the previous code cell

Ora confronta la fedeltà dello stato rumoroso nudo con quella dello stato postselezionato. Applicare la postselezione solo ai campioni senza errori rilevati aumenta il valore di aspettazione di ogni stabilizzatore, e quindi la fedeltà stimata. I valori postselezionati usano meno campioni rispetto ai valori grezzi, eppure i valori di aspettazione sono più accurati e la varianza campionata è più bassa. Nota anche che il tasso medio di postselezione è vicino alla fedeltà rumorosa. Questo è quanto ci si aspetta quando i controlli rilevano quasi tutti i campioni erronei: la frazione di campioni che passa ogni controllo si avvicina alla frazione di campioni privi di errori, che è la fedeltà dello stato rumoroso.

results = []
for i, ((noisy_counts, _), kept) in enumerate(
zip(counts, kept_per_stab, strict=True)
):
results.append(
(
expectation(noisy_counts, stabilizers[i]),
expectation(kept, stabilizers[i]),
sum(kept.values()) / num_shots,
)
)

fidelity_noisy = float(np.nanmean([r[0] for r in results]))
fidelity_postsel = float(np.nanmean([r[1] for r in results]))
psr = float(np.mean([r[2] for r in results]))
print(
f"ideal fidelity: 1.0\n"
f"noisy fidelity: {fidelity_noisy:.4f}\n"
f"postselected fidelity: {fidelity_postsel:.4f}\n"
f"mean postselection rate: {psr:.3f}"
)

evs_ideal = np.ones(len(results))
evs_noisy = np.array([r[0] for r in results])
evs_post = np.array([r[1] for r in results])
idx = np.arange(len(results))

def strip(ax, ys, color, label):
m, s = np.nanmean(ys), np.nanstd(ys)
ax.axhspan(
m - s, m + s, color=color, alpha=0.15, label=f"{label} mean and std"
)
ax.axhline(
m, color=color, linewidth=1, linestyle="--", label=f"{label} fidelity"
)

fig, ax = plt.subplots(figsize=(8, 4))
ax.axhline(np.nanmean(evs_ideal), color="black", linewidth=1.5, label="ideal")
strip(ax, evs_noisy, "red", "noisy")
strip(ax, evs_post, "green", "postselected")
ax.scatter(idx, evs_noisy, color="red", s=22, alpha=0.7, label="noisy EVs")
ax.scatter(
idx,
evs_post,
color="green",
s=22,
alpha=0.7,
label="postselected EVs",
)
ax.set_xlabel("stabilizer index")
ax.set_ylabel(r"$\langle G \rangle$")
ax.set_ylim((-0.1, 1.1))
ax.set_title("Per-stabilizer expectation values")
ax.legend(loc="lower left")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

M = np.arange(1, len(results) + 1)
fig, ax = plt.subplots(figsize=(8, 4))
for ys, color, label in [
(evs_ideal, "black", "ideal"),
(evs_noisy, "red", "noisy"),
(evs_post, "green", "postselected"),
]:
cm, sem = cum_mean_sem(ys)
ax.plot(M, cm, color=color, linewidth=1.5, label=label)
ax.fill_between(M, cm - sem, cm + sem, color=color, alpha=0.15)
ax.set_xlabel("number of stabilizers averaged")
ax.set_ylabel("running fidelity estimate")
ax.set_title("Fidelity convergence versus number of stabilizers")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
ideal fidelity: 1.0
noisy fidelity: 0.7899
postselected fidelity: 0.9679
mean postselection rate: 0.780

Output of the previous code cell

Output of the previous code cell

Il punteggio gamma riporta quanta parte del canale di rumore modellato rimane non rilevata dai controlli. Rappresentare graficamente il punteggio gamma rispetto al numero di controlli applicati mostra come la capacità di rilevamento migliora man mano che ogni controllo viene aggiunto. Un valore di 1.0 significa che i controlli catturano tutto il rumore modellato. Le curve scendono verso 1.0 man mano che vengono applicati più controlli, il che mostra che ogni controllo aggiuntivo cattura parte dell'errore non rilevato rimanente.

stab_scores = [
[variant.cost for variant in checked_circ_result]
for checked_circ_result in checked_circuits
]
max_len = max(len(s) for s in stab_scores)
scores = np.full((len(stab_scores), max_len), np.nan)
for i, s in enumerate(stab_scores):
scores[i, : len(s)] = s
ks = np.arange(max_len)

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(ks, scores.T, color="#4682b4", alpha=0.15, linewidth=1)
ax.plot(
ks,
np.nanmedian(scores, axis=0),
color="black",
linewidth=1,
linestyle="--",
label="median",
)
ax.set_xlabel("Checks committed")
ax.set_ylabel("Gamma")
ax.set_yscale("log")
ax.set_title(f"Per-stabilizer gamma curves ({len(scores)} stabilizers)")
ax.legend()
ax.grid(True, alpha=0.3, which="both")
plt.tight_layout()
plt.show()

Output of the previous code cell

Esempio su hardware su larga scala

Lo stesso flusso di lavoro viene eseguito su hardware con un payload più grande e più profondo. Questa sezione riutilizza il backend dell'esempio del simulatore ma costruisce un nuovo layout a 20 qubit con le proprie coppie target e ancilla e il proprio pass manager, quindi invia i circuiti alla QPU in un singolo job. A questa scala, la maggior parte degli shot attiva almeno un controllo, quindi il tasso di postselezione è basso, e ogni circuito ha bisogno di un ampio budget di shot per avere abbastanza campioni superstiti. L'esempio quindi concentra il proprio budget su pochi stabilizzatori campionati; si tratta comunque di una stima di fedeltà non distorta, ma più grezza rispetto alla media su molti stabilizzatori dell'esempio del simulatore.

Una cosa cambia rispetto all'esempio del simulatore: invece di dedurre un canale di depolarizzazione uniforme dai dati di calibrazione, questa sezione impara il modello di rumore con NoiseLearner e costruisce il modello qiskit-paulice dal risultato con NoiseModel.from_pauli_lindblad_maps. Un modello di Pauli-Lindblad appreso cattura la struttura spaziale del rumore su questo specifico layout, invece di assumere che ogni bordo sia ugualmente rumoroso, quindi il posizionamento dei controlli viene valutato rispetto a un rumore più simile a quello che influisce sulla QPU. Imparare il rumore richiede il campionamento della QPU e dovrebbe essere considerato in qualsiasi budget complessivo di campionamento della QPU.

I parametri che seguono impostano il numero di qubit, la profondità, il numero di stabilizzatori, e il numero di shot. Scala hw_num_shots con l'inverso del tasso di postselezione: a un tasso del 3%, 40.000 shot lasciano circa 1.200 campioni postselezionati per circuito. Aumenta hw_num_stabilizers per una stima di fedeltà più precisa al costo di più circuiti per job, ciascuno dei quali necessita dello stesso budget di shot.

Passi da 1 a 4 (compressi in un unico blocco di codice)

La cella seguente esegue gli stessi quattro passi dell'esempio del simulatore. Costruisce il payload più grande e campiona alcuni stabilizzatori (passo 1); sceglie un layout, apprende il modello di rumore su di esso, e trova il circuito completamente controllato per ogni stabilizzatore (passo 2); invia un unico job Sampler che contiene sia il circuito nudo che quelli controllati (passo 3); e applica la postselezione ai conteggi controllati per confrontare le stime di fedeltà rumorosa e postselezionata, per ogni stabilizzatore e in media (passo 4). A questa scala, enumerare l'intero gruppo di stabilizzatori come nell'esempio del simulatore è impraticabile, quindi la cella sottocampiona un sottoinsieme casuale di stabilizzatori per calcolare una stima della fedeltà.

Nota che il passo 2 qui fa di più rispetto all'esempio del simulatore: apprendere il modello di rumore invia un proprio job NoiseLearner prima del job Sampler, quindi la cella esegue due job in totale. Portano i tag TUT_ASPC_LEARN e TUT_ASPC in modo da poterli trovare in seguito. Vedi Organizzare e cercare tramite i tag dei job per saperne di più sull'etichettatura dei job.

# -------------------------Step 1: build a larger payload and sample stabilizers-------------------------
hw_num_qubits = 20
hw_depth = 36
hw_num_stabilizers = 10
hw_num_shots = 40_000

hw_circuit = random_clifford_circuit(hw_num_qubits, hw_depth, rng)
hw_no_meas = hw_circuit.remove_final_measurements(inplace=False)

# Enumerating all 2^n stabilizers is infeasible at this size, so draw each
# stabilizer by composing a random subset of the group generators
hw_generators = [
Pauli(label) for label in Clifford(hw_no_meas).to_labels(mode="S")
]
sample_rng = np.random.default_rng(seed)
hw_stabilizers = []
while len(hw_stabilizers) < hw_num_stabilizers:
mask = sample_rng.integers(0, 2, hw_num_qubits).astype(bool)
if not mask.any():
continue # skip the identity
stabilizer = Pauli("I" * hw_num_qubits)
for generator, chosen in zip(hw_generators, mask, strict=True):
if chosen:
stabilizer = stabilizer.compose(generator)
hw_stabilizers.append(stabilizer)

# -------------------------Step 2: find a 20-qubit layout, learn its noise, and add checks-------------------------
# A single bad coupler or bad-readout qubit on the path drags every
# stabilizer down, so search harder and with tighter error thresholds
hw_layout = find_check_layout(
backend,
hw_num_qubits,
rng,
num_trials=500,
max_gate_error=0.015,
max_readout_error=0.05,
)
hw_target_qubits, hw_ancilla_qubits = get_check_qubits(backend, hw_layout)
hw_pm = generate_preset_pass_manager(
optimization_level=0, backend=backend, initial_layout=hw_layout
)
print(f"Layout with {len(hw_target_qubits)} check pairs: {hw_layout}")

# ----- learn a Pauli-Lindblad noise model on this layout -----
# The simulator example scored checks against a uniform depolarizing channel
# inferred from calibration data. Here, learn the noise instead: NoiseLearner
# runs its own job on the QPU and returns a Pauli-Lindblad channel per unique
# entangling layer, so the checks are placed against the noise this layout
# actually has, including its spatial structure. All the sampled stabilizers
# share the same entangling layers and differ only in their final basis
# rotation, so learning on the bare payload covers all of them.
learner = NoiseLearner(
mode=backend,
options={
"max_layers_to_learn": 4,
"num_randomizations": 32,
"shots_per_randomization": 128,
"environment": {"job_tags": ["TUT_ASPC_LEARN"]},
},
)
learner_job = learner.run([hw_pm.run(hw_circuit)])
print(f"Submitted noise-learner job {learner_job.job_id()}")
hw_layer_errors = learner_job.result().data

# To see how much the learned model helps, swap the next line for the
# simulator example's uniform model - a one-line change:
# hw_noise_model = NoiseModel.from_backend(backend, hw_layout, uniform_gate_noise=True)
hw_noise_model = learned_noise_model(hw_layer_errors, hw_layout)
# NoiseLearner characterizes gate noise only, so keep the readout estimate
# from calibration data rather than leaving it unset
hw_noise_model.readout_noise = NoiseModel.from_backend(
backend, hw_layout, uniform_gate_noise=True
).readout_noise
print(
f"Learned {len(hw_layer_errors)} layers; "
f"readout noise {hw_noise_model.readout_noise:.5f}"
)

# ----- add the fully checked circuit per stabilizer -----
hw_noisy_circuits = []
hw_checked_circuits = []
for i, pauli in enumerate(tqdm(hw_stabilizers)):
bare = hw_pm.run(append_basis_rotation(hw_circuit, pauli))
hw_noisy_circuits.append(bare)
variants = add_pauli_checks(
bare,
hw_target_qubits,
hw_noise_model,
ancilla_qubits=hw_ancilla_qubits,
cost="gamma",
method="windowed",
seed=seed + 1 + i,
)
hw_checked_circuits.append(variants[-1]) # keep the fully checked circuit

# -------------------------Step 3: submit one Sampler job with the bare and checked circuits-------------------------
sampler = Sampler(mode=backend)
sampler.options.default_shots = hw_num_shots
sampler.options.environment.job_tags = ["TUT_ASPC"]

pubs = hw_noisy_circuits + [cc.circuit for cc in hw_checked_circuits]
job = sampler.run(pubs)
print(f"Submitted job {job.job_id()} with {len(pubs)} circuits")

# -------------------------Step 4: postselect and compare fidelity-------------------------
result = job.result()
n_stab = len(hw_stabilizers)

hw_results = []
for i in range(n_stab):
noisy_counts = result[i].join_data().get_counts()
checked_counts = result[n_stab + i].join_data().get_counts()
ps_fn = hw_checked_circuits[i].get_postselection_method()
kept = {bs: c for bs, c in checked_counts.items() if not ps_fn(bs).any()}
hw_results.append(
(
expectation(noisy_counts, hw_stabilizers[i]),
expectation(kept, hw_stabilizers[i]),
sum(kept.values()) / sum(checked_counts.values()),
)
)

hw_fidelity_noisy = float(np.nanmean([r[0] for r in hw_results]))
hw_fidelity_postsel = float(np.nanmean([r[1] for r in hw_results]))
hw_psr = float(np.mean([r[2] for r in hw_results]))
print(
f"noisy fidelity estimate: {hw_fidelity_noisy:.4f}\n"
f"postselected fidelity estimate: {hw_fidelity_postsel:.4f}\n"
f"mean postselection rate: {hw_psr:.4f} "
f"(~{int(round(hw_psr * hw_num_shots))} kept shots per circuit)"
)

# Per-stabilizer breakdown. The postselection rate varies from stabilizer to
# stabilizer, so a stabilizer whose postselected value barely moves is usually
# one whose checks rejected little; the kept-shot count says how much of the
# gap is statistics rather than signal.
print("\nper-stabilizer results:")
print(
f"{'idx':>3} {'noisy':>8} {'postsel':>8} {'psr':>7} {'kept shots':>10}"
)
for i, (noisy, post, psr_i) in enumerate(hw_results):
print(
f"{i:>3} {noisy:>8.4f} {post:>8.4f} {psr_i:>7.4f} "
f"{int(round(psr_i * hw_num_shots)):>10}"
)

hw_noisy = np.array([r[0] for r in hw_results])
hw_post = np.array([r[1] for r in hw_results])
idx = np.arange(n_stab)

fig, ax = plt.subplots(figsize=(9, 4))
ax.axhline(1.0, color="black", linewidth=1.5, label="ideal")
strip(ax, hw_noisy, "red", "noisy")
strip(ax, hw_post, "green", "postselected")
ax.scatter(idx, hw_noisy, color="red", s=22, alpha=0.7, label="noisy EVs")
ax.scatter(
idx,
hw_post,
color="green",
s=22,
alpha=0.7,
label="postselected EVs",
)
ax.set_xlabel("stabilizer index")
ax.set_ylabel(r"$\langle G \rangle$")
ax.set_ylim((-0.1, 1.1))
ax.set_xticks(idx)
ax.set_title("Per-stabilizer expectation values on hardware")
# Outside the axes so it cannot hide a data point
ax.legend(loc="center left", bbox_to_anchor=(1.02, 0.5), frameon=False)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Layout with 11 check pairs: [153, 152, 151, 138, 131, 130, 129, 118, 109, 110, 111, 98, 91, 90, 89, 78, 69, 70, 71, 58]
Submitted noise-learner job d9f4mncjeosc73fjfmkg
Learned 4 layers; readout noise 0.00470
100%|██████████| 10/10 [01:01<00:00, 6.18s/it]
Submitted job d9f4s04jeosc73fjftkg with 20 circuits
noisy fidelity estimate: 0.3685
postselected fidelity estimate: 0.6869
mean postselection rate: 0.2851 (~11404 kept shots per circuit)

per-stabilizer results:
idx noisy postsel psr kept shots
0 0.3769 0.6918 0.3247 12987
1 0.3745 0.6760 0.2999 11995
2 0.3659 0.6389 0.3549 14196
3 0.3821 0.7060 0.2660 10641
4 0.3653 0.7475 0.2531 10124
5 0.3752 0.7022 0.2698 10791
6 0.3508 0.7144 0.2711 10842
7 0.3485 0.7087 0.2381 9523
8 0.3825 0.6289 0.2928 11711
9 0.3630 0.6549 0.2808 11232

Output of the previous code cell

Per circuiti di questa dimensione, la maggior parte dei campioni contiene almeno un errore rilevato, quindi il tasso di postselezione è piccolo e la postselezione scarta la maggior parte degli shot. I campioni che superano ogni controllo forniscono un valore di aspettazione molto migliore rispetto al circuito nudo, e i valori per stabilizzatore si separano nettamente dalla linea di base rumorosa. Per affinare la stima di fedeltà, campiona più stabilizzatori con lo stesso budget di shot per circuito. Per aumentare il tasso di postselezione, riduci la profondità del circuito o applica meno controlli; per estendersi a payload più grandi, scala il budget di shot con l'inverso del tasso di postselezione.

Passi successivi

Consigli

Se hai trovato interessante questo lavoro, potrebbero interessarti i seguenti materiali:

Riferimenti

  • [1] Martiel, S., & Javadi-Abhari, A. (2025). Low-overhead error detection with spacetime codes. arXiv preprint arXiv:2504.15725.

  • [2] van den Berg, E., Bravyi, S., Gambetta, J. M., Jurcevic, P., Maslov, D., & Temme, K. (2023). Single-shot error mitigation by coherent Pauli checks. Physical Review Research, 5(3), 033193.