Détection d'erreurs à faible surcoût grâce aux codes spatio-temporels
Estimation du temps d'exécution : 4 minutes sur un processeur Heron (ibm_kingston ou équivalent) (REMARQUE : il s'agit uniquement d'une estimation. (La durée d'exécution peut varier.)
Acquis d'apprentissage
À l'issue de ce tutoriel, vous devriez être en mesure de comprendre les éléments suivants :
- Comment les tests de Pauli sur l'espace-temps permettent de détecter les erreurs logiques dans les circuits de Clifford, et comment la post-sélection sur leurs syndromes améliore la fidélité d'une distribution échantillonnée.
- Comment utiliser le package
qiskit-paulicepour rechercher et insérer automatiquement des vérifications optimisées pour le matériel à l'aide deget_check_qubits,NoiseModel, etadd_pauli_checks. - Comment estimer la fidélité d'un état stabilisateur en échantillonnant ses stabilisateurs et en effectuant une post-sélection sur des syndromes de contrôle.
- Comment exécuter l'ensemble du processus de détection d'erreurs sur le matériel d' IBM Quantum®, et comparer les fidélités « noisy » et « postselected ».
Prérequis
Nous vous recommandons de vous familiariser avec les sujets suivants :
- Principes fondamentaux du matériel pour l'informatique quantique à grande échelle.
- Le formalisme de Clifford et des stabilisateurs, y compris la manière dont un groupe stabilisateur décrit un état stabilisateur pur.
Arrière-plan
L'article « Détection d'erreurs à faible surcoût à l'aide de codes spatio-temporels [1] », rédigé par Simon Martiel et Ali Javadi-Abhari, présente une méthode de détection des erreurs logiques dans les circuits dominés par Clifford, qui se situe à mi-chemin entre la correction complète des erreurs et l'atténuation plus légère des erreurs. Cette idée s'appuie sur les contrôles de Pauli cohérents (CPC) présentés dans l'article « Single-shot error mitigation by coherent Pauli checks » [2] de van den Berg et al. Dans ces deux approches, un circuit « de charge utile » de Clifford est intriqué avec des qubits auxiliaires afin de vérifier certaines invariantes. La mesure des ancillas génère un syndrome qui indique si une erreur a été détectée pendant l'exécution. Le fait de ne conserver que les échantillons pour lesquels aucune erreur n'a été détectée améliore la fidélité de la distribution échantillonnée, au prix d'une baisse du taux de post-sélection.
La principale différence entre les contrôles de Pauli cohérents et les contrôles spatio-temporels réside dans les opérateurs qu'ils mesurent. Les contrôles de Pauli cohérents mesurent des opérateurs localisés dans le temps et de poids élevé. Dans le cas des topologies de qubits à connectivité limitée, telles que la topologie « heavy hex », ces vérifications nécessitent un grand nombre de portes SWAP et rendent souvent le circuit trop profond pour pouvoir être exécuté dans la pratique. En mettant en œuvre ces contrôles sous forme de codes spatio-temporels, on répartit chaque contrôle sur l'ensemble du circuit de charge utile, tant dans l'espace que dans le temps. On obtient ainsi un codage efficace sur le plan matériel, qui reste performant pour détecter les erreurs logiques tout en limitant la surcharge liée aux qubits et à la profondeur.
Fonctionnalités du module qiskit-paulice
Ce module qiskit-paulice automatise la création de ces vérifications, ce qui vous évite de devoir les créer manuellement. Son rôle principal consiste à identifier et à insérer des contrôles de Pauli spatio-temporels valides aux emplacements d'un circuit qui optimisent la détection des erreurs tout en minimisant la surcharge des qubits. Un contrôle est valide lorsque ses opérateurs ne modifient pas l'action logique du circuit de charge utile; il est « léger » lorsqu'il utilise peu de portes d'intrication; et il est « efficace » lorsqu'il détecte une grande partie des erreurs, par rapport au bruit qu'il introduit lui-même. Le module évalue les vérifications candidates par rapport à un modèle de bruit et intègre les meilleures d'entre elles dans le circuit. Ce tutoriel utilise trois méthodes de l'API :
get_check_qubitsexamine une carte de couplage du backend et renvoie des paires de qubits cibles et auxiliaires. Une vérification des utilisationstarget_qubits[i]deancilla_qubits[i].NoiseModel.from_backendélabore un modèle approximatif de bruit à partir des données de benchmark du backend. Le modèle évalue les vérifications proposées; il n'est donc pas nécessaire de disposer d'un modèle de bruit précis et appris. Pour un modèle de Pauli-Lindblad appris, voirNoiseModel.from_pauli_lindblad_maps.add_pauli_checksrecherche et insère des contrôles dans un circuit. Elle renvoie une séquence d'objetsCheckedCircuitdont le nombre de vérifications augmente progressivement, et chaque objet fournit une fonctionget_postselection_methodqui associe une chaîne binaire mesurée à un vecteur de syndrome. L'argumentcostpermet de sélectionner la fonction qui évalue un contrôle (gamma, la surcharge d'échantillonnage du canal de bruit inverse post-sélectionné, ouLER, le taux d'erreur logique). Cet argumentmethodpermet de sélectionner la stratégie de recherche (windowed,genetic, ouwindowed_genetic). Ce tutoriel utilise etcost="gamma"method="windowed", qui, combinés, permettent une sélection de vérifications déterministe et reproductible.
Estimation de la fidélité à partir d'un échantillonnage stabilisateur
Pour évaluer l'efficacité de la détection d'erreurs, vous pouvez estimer la fidélité entre l'état stabilisé que le circuit devrait idéalement préparer et l'état bruité que le matériel produit réellement. La projection d'un état pur du stabilisateur est égale à la moyenne uniforme sur les éléments de son groupe stabilisateur :
En substituant cela dans la formule de fidélité, on obtient la fidélité de comme valeur moyenne attendue de chaque stabilisateur par rapport à :
Pour les problèmes de grande envergure, il est impossible d'énumérer tous les stabilisateurs d' , on peut donc estimer la fidélité à partir d'un échantillon aléatoire. En tirant au hasard et de manière uniforme des stabilisateurs à partir de , on obtient une estimation non biaisée :
Étant donné qu'un circuit de Clifford prépare un état stabilisateur, il est possible d'estimer sa fidélité directement à partir des valeurs attendues échantillonnées de ses stabilisateurs. Ce tutoriel présente tout d'abord le déroulement du processus sur un simulateur avec un petit circuit, puis applique ce même processus sur du matériel avec un circuit plus grand et plus complexe. À mesure que les circuits intègrent davantage d'opérations non-Clifford, le nombre de vérifications valides diminue rapidement; cette méthode est donc particulièrement efficace pour les circuits à prédominance Clifford.
Exigences
Avant de commencer ce tutoriel, assurez-vous d'avoir installé les éléments suivants :
- Qiskit SDK v2.0 ou version ultérieure, avec prise en charge de la visualisation
- Qiskit Runtime v0.40 ou version ultérieure (
pip install qiskit-ibm-runtime) - Qiskit Aer v0.17 ou version ultérieure (
pip install qiskit-aer) - Qiskit Paulice (
pip install qiskit-paulice) - tqdm (
pip install tqdm)
Configuration
Importez les bibliothèques nécessaires et définissez les fonctions d'aide qui ne sont pas disponibles sous forme d'importations. La fonction random_clifford_circuit génère une charge utile Clifford aléatoire de type « brickwork », find_check_layout recherche dans une carte de couplage du backend un chemin de qubits à faible erreur comportant de nombreux ancilles disponibles, learned_noise_model convertit la sortie NoiseLearner en un modèle de bruit qiskit-paulice , append_basis_rotation fait pivoter un circuit de manière à ce qu’un stabilisateur soit mesuré dans la base de calcul, expectation calcule la valeur attendue d’un stabilisateur à partir des comptages échantillonnés et cum_mean_sem suit l’estimation de la fidélité en temps réel.
# 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 NoiseModeldef 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), semExemple de simulateur à petite échelle
Cette section présente le déroulement complet du processus sur un simulateur présentant du bruit. Il utilise des données de référence du backend pour choisir une configuration de qubits et un modèle de bruit, identifie automatiquement les tests de vérification et recourt à la post-sélection sur la distribution échantillonnée pour mettre en évidence l'amélioration de la fidélité.
Étape 1 : Mettre en correspondance les entrées classiques avec un problème quantique
Le circuit de charge utile est un circuit de Clifford aléatoire unidimensionnel de type « brickwork » peu profond. Comme il s'agit d'un circuit de Clifford, il génère un état stabilisateur dont vous pouvez estimer la fidélité directement à partir des valeurs attendues échantillonnées du stabilisateur. Commencez par un circuit peu profond afin que les vérifications soient faciles à visualiser à l'étape suivante.
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:
Étape 2 : Optimisation pour l'exécution sur du matériel quantique
La mise en correspondance du circuit avec le matériel permet de définir la disposition physique des qubits, le modèle de bruit servant à évaluer les vérifications candidates, ainsi que les vérifications elles-mêmes.
Commencez par sélectionner un backend, puis recherchez dans sa carte de couplage une configuration de qubits unidimensionnelle avec l'« helper find_check_layout » défini dans la section « Configuration ». Cet assistant génère des parcours aléatoires « auto-évitants » qui contournent les portes et les lectures présentant les erreurs les plus élevées, et il conserve le parcours qui offre le plus grand nombre de paires cible-ancilla. Comme la recherche lit elle-même les données de connectivité et d'erreurs à partir du backend, le même code s'exécute sur n'importe quel QPU IBM Quantum. La fonction get_check_qubits renvoie ensuite les paires « cible » et « ancilla », où une vérification sur utilise target_qubits[i] ancilla_qubits[i].
Dans le graphe de couplage ci-dessous, les qubits verts correspondent aux qubits de charge utile et les qubits orange aux qubits auxiliaires qui assurent les vérifications. Les qubits associés à un ancilla adjacent servent de qubits cibles pour les vérifications.
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,
)Output:
Backend: ibm_boston
Target qubits: [105, 107, 108, 123, 125, 141, 143]
Ancilla qubits: [104, 97, 109, 122, 126, 140, 144]
Une fois le backend et la disposition choisis, transcompilez la charge utile en un circuit d'architecture de jeu d'instructions (ISA). Il suffit de définir la disposition et de convertir les portes en jeu de portes natif du 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:
Modélisez ensuite l'impact du bruit de grille et du bruit de lecture au niveau du backend sur l'exécution. Le modèle de bruit permet de déterminer à quel endroit du circuit un contrôle permet de détecter le plus grand nombre d'erreurs. Un modèle plus précis améliore la détection, mais il n'est généralement pas nécessaire d'en entraîner un en effectuant des essais sur le QPU. Le modèle présenté ci-après permet de déduire, à partir de données de référence qiskit-ibm-runtime , un canal de dépolarisation uniforme pour le bruit de grille et le bruit de lecture.
noise_model = NoiseModel.from_backend(
backend, layout, uniform_gate_noise=True
)
print(noise_model)Output:
NoiseModel(gate_noise=0.001079865281450939, readout_noise=0.006001790364583333, idling_noise=None)
Ajoutez maintenant des fusibles au circuit. La fonction add_pauli_checks prend en paramètre la charge utile de Clifford, la liste des qubits cibles et le modèle de bruit. Cet argument ancilla_qubits indique à la fonction quelle ancille physique doit être associée à chaque cible. Les vérifications sont ajoutées dans l'ordre d'apparition des qubits cibles; la configuration finale du circuit vérifié est donc layout + ancilla_qubits. Pour faire fonctionner un circuit de sortie avec moins de (i) vérifications, la configuration finale est la suivante : layout + ancilla_qubits[:i].
La sortie de est add_pauli_checks une suite de circuits comportant un nombre croissant de vérifications, allant de l'absence totale de vérifications à une vérification par qubit cible. La visualisation confirme que les vérifications utilisent les paires cible-ancilla spécifiées. Pour plus de détails sur la recherche de bons tests de contrôle, voir les sections II à IV des informations complémentaires figurant dans la référence [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)Output:
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:
Étape 3 : Exécutez la commande à l'aide d' Qiskit primitives
Pour rendre visible l'effet du bruit de grille, augmentez la profondeur de la charge utile et échantillonnez un sous-ensemble de ses stabilisateurs. En règle générale, chaque stabilisateur ne commute pas, au niveau des qubits, avec les autres; par conséquent, un même ensemble de vérifications ne peut pas s'appliquer à deux stabilisateurs différents. Plutôt que de regrouper les stabilisateurs en ensembles commutants, trouvez un ensemble de vérifications adapté pour chaque stabilisateur pris séparément. Un échantillonnage aléatoire et uniforme des stabilisateurs permet d'obtenir une estimation non biaisée de la fidélité.
Construisez le circuit plus complexe et prélevez un échantillon aléatoire de ses stabilisateurs.
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]}, ...}}"
)Output:
Sampled 20 stabilizers of a 12-qubit circuit with two-qubit depth 24: {ZXIIXZYYXIZZ, XXXYIIZYXIII, ...}
Pour chaque stabilisateur échantillonné, faites pivoter le circuit de manière à ce que le stabilisateur soit mesuré dans la base de calcul, transposez-le sur le backend, puis déterminez un ensemble pertinent de vérifications. Pour chaque stabilisateur, les paires « cible-ancilla » sont mélangées de manière à ce que chaque cible conserve son ancilla. N'oubliez pas que les vérifications sont validées de manière séquentielle, dans l'ordre dans lequel les qubits cibles sont indiqués, et qu'une vérification validée ne change pas même si d'autres vérifications sont ajoutées.
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."
)Output:
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.
Testez la charge utile de base et les circuits vérifiés avec Qiskit Aer. Le simulateur utilise le même modèle de dépolarisation que celui utilisé pour l'évaluation des tests; ainsi, le bruit ciblé par ces tests est celui que le simulateur applique.
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))Output:
100%|██████████| 20/20 [00:17<00:00, 1.13it/s]
Étape 4 : Traitement ultérieur et restitution du résultat dans le format classique souhaité
Chaque vérification utilise des portes d'intrication entre une ancille et une cible. L'ancilla démarre en mode « », ce qui fait que « » stabilise son entrée. La propagation d' e vers l'avant à travers le circuit de vérification donne lieu à un opérateur de Pauli en sortie, dont les termes non identitaires définissent le support de la vérification. Un contrôle est validé lorsque les bits qui le composent ont une parité paire. Un échantillon n'est conservé que s'il satisfait à tous les contrôles.
La fonction get_postselection_method de chaque CheckedCircuit renvoie une fonction qui associe une chaîne de bits mesurée à un vecteur de syndrome. Conservez les échantillons dont le syndrome est égal à zéro à chaque contrôle, et éliminez les autres. Le graphique ci-dessous montre que l'augmentation du nombre de contrôles réduit le taux de post-sélection. Un taux de post-sélection plus faible nécessite davantage de tirs pour atteindre une précision cible; il existe donc un compromis entre la capacité de détection et le coût d'échantillonnage. Le taux semble converger, ce qui indique que les contrôles supplémentaires n'améliorent que très peu la capacité de détection.
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:
Comparons maintenant la fidélité de l'état brut bruité à celle de l'état post-sélectionné. Le fait de ne retenir a posteriori que les échantillons pour lesquels aucune erreur n'a été détectée augmente la valeur attendue de chaque stabilisateur, et donc la fidélité estimée. Les valeurs post-sélectionnées utilisent moins d'échantillons que les valeurs brutes, mais les valeurs attendues sont plus précises et la variance échantillonnée est plus faible. On remarque également que le taux moyen de post-sélection est proche de la fidélité bruyante. C'est ce à quoi on s'attend lorsque les contrôles détectent la quasi-totalité des échantillons erronés : la proportion d'échantillons qui satisfont à tous les contrôles tend vers la proportion d'échantillons exempts d'erreurs, ce qui correspond à la fidélité de l'état bruité.
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()Output:
ideal fidelity: 1.0
noisy fidelity: 0.7899
postselected fidelity: 0.9679
mean postselection rate: 0.780
Le score gamma indique la proportion du canal de bruit modélisé qui n'est pas détectée par les contrôles. Le graphique représentant l'évolution du score gamma en fonction du nombre de vérifications validées montre comment la capacité de détection s'améliore à mesure que chaque vérification est ajoutée. Une valeur de signifie 1.0 que les contrôles prennent en compte l'intégralité du bruit modélisé. Les courbes s'abaissent à mesure 1.0 que le nombre de vérifications validées augmente, ce qui montre que chaque vérification supplémentaire permet de détecter une partie des erreurs restantes non détectées.
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:
Exemple de matériel à grande échelle
Ce même flux de travail s'exécute sur du matériel doté d'une capacité de charge utile plus importante et plus profonde. Cette section réutilise le backend de l'exemple du simulateur, mais crée une nouvelle configuration de 20 qubits avec ses propres paires de cibles et d'ancilla, ainsi qu'un gestionnaire de passes, puis soumet les circuits au QPU dans le cadre d'une seule tâche. À cette échelle, la plupart des tirs déclenchent au moins une vérification; le taux de post-sélection est donc faible, et chaque circuit nécessite un budget de tirs important pour disposer d'un nombre suffisant d'échantillons afin de survivre. L'exemple concentre donc son budget sur quelques stabilisateurs choisis au hasard; il s'agit toujours d'une estimation non biaisée de la fidélité, mais elle est moins précise que la moyenne calculée par le simulateur sur un grand nombre de stabilisateurs.
Il y a une différence par rapport à l'exemple du simulateur : au lieu de déduire un canal de dépolarisation uniforme à partir des données d'étalonnage, cette section apprend le modèle de bruit avec NoiseLearner et construit le modèle qiskit-paulice à partir du résultat obtenu avec NoiseModel.from_pauli_lindblad_maps. Un modèle Pauli-Lindblad appris permet de rendre compte de la structure spatiale du bruit sur cette configuration spécifique, plutôt que de supposer que chaque arête présente un niveau de bruit identique; ainsi, le placement des cases est évalué par rapport à un bruit plus proche de celui affectant le QPU. L'apprentissage du bruit nécessite un échantillonnage du QPU et doit être pris en compte dans le budget global d'échantillonnage du QPU.
Les paramètres suivants permettent de définir le nombre de qubits, la profondeur, le nombre de stabilisateurs et le nombre d'itérations. Échelle proportionnelle à hw_num_shots l'inverse du taux de post-sélection : à un taux de 3 %, 40 000 prises de vue donnent environ 1 200 échantillons post-sélectionnés par circuit. Augmenter hw_num_stabilizers pour obtenir une estimation plus précise de la fidélité, au prix d'un nombre plus élevé de circuits par tâche, chacun nécessitant le même budget de tirs.
Étapes 1 à 4 (regroupées dans un seul bloc de code)
La cellule suivante exécute les quatre étapes identiques à celles de l'exemple du simulateur. Il construit la charge utile globale et échantillonne quelques stabilisateurs (étape 1); choisit une disposition, apprend le modèle de bruit sur celle-ci et détermine le circuit entièrement vérifié pour chaque stabilisateur (étape 2); soumet une tâche Sampler contenant à la fois les circuits bruts et les circuits vérifiés (étape 3); et effectue une post-sélection des comptes vérifiés afin de comparer les estimations de fidélité avec bruit et post-sélectionnées, par stabilisateur et en moyenne (étape 4). À cette échelle, il est impossible d'énumérer l'ensemble du groupe des stabilisateurs, comme dans l'exemple du simulateur; la cellule procède donc à un échantillonnage aléatoire d'un sous-ensemble de stabilisateurs afin de calculer une estimation de la fidélité.
Notez que l'étape 2 va plus loin ici que dans l'exemple du simulateur : l'apprentissage du modèle de bruit soumet son propre NoiseLearner travail avant celui de l'échantillonneur, de sorte que la cellule exécute deux travaux au total. Ils sont munis des étiquettes et TUT_ASPC_LEARN TUT_ASPC pour que vous puissiez les retrouver plus tard. Pour en savoir plus sur l'ajout de balises aux offres d'emploi, consultez la section « Organiser et rechercher par balises d'emploi ».
# -------------------------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()Output:
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
Pour des circuits de cette taille, la plupart des échantillons contiennent au moins une erreur détectée; le taux de post-sélection est donc faible et la post-sélection élimine la plupart des prises. Les échantillons qui satisfont à tous les contrôles présentent une valeur attendue bien supérieure à celle du circuit nu, et les valeurs propres à chaque stabilisateur se distinguent nettement de la courbe de référence bruyante. Pour affiner l'estimation de la fidélité, prélevez davantage d'échantillons de stabilisateurs tout en conservant le même budget de prises par circuit. Pour augmenter le taux de post-sélection, réduisez la profondeur du circuit ou effectuez moins de vérifications; pour atteindre des charges utiles plus importantes, adaptez le budget de tirs à l'inverse du taux de post-sélection.
Etapes suivantes
Si ce travail vous a paru intéressant, les documents suivants pourraient vous intéresser :
- Le tutoriel sur les codes de répétition : une introduction à la correction d'erreurs quantiques.
- La
qiskit-paulicedocumentation pour l'API complète de recherche de chèques, et la du paquet GitHub référentiel pour le code source. - L'article « Détection d'erreurs à faible surcoût grâce aux codes spatio-temporels » présente les fondements théoriques de ces contrôles.
Références
- [1] Martiel, S., & Javadi-Abhari, A. (2025). Détection d'erreurs à faible surcoût grâce aux codes spatio-temporels. arXiv prépublication arXiv:2504.15725.
- [2] van den Berg, E., Bravyi, S., Gambetta, J. M., Jurcevic, P., Maslov, D., & Temme, K. (2023). Atténuation des erreurs sur un seul tir grâce à des contrôles de Pauli cohérents. Physical Review Research, 5(3), 033193.