Skip to main content
IBM Quantum Platform

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-paulice pour rechercher et insérer automatiquement des vérifications optimisées pour le matériel à l'aide de get_check_qubits, NoiseModel, et add_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_qubits examine une carte de couplage du backend et renvoie des paires de qubits cibles et auxiliaires. Une vérification des utilisations target_qubits[i] de ancilla_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, voir NoiseModel.from_pauli_lindblad_maps.
  • add_pauli_checks recherche et insère des contrôles dans un circuit. Elle renvoie une séquence d'objets CheckedCircuit dont le nombre de vérifications augmente progressivement, et chaque objet fournit une fonction get_postselection_method qui associe une chaîne binaire mesurée à un vecteur de syndrome. L'argument cost permet de sélectionner la fonction qui évalue un contrôle (gamma, la surcharge d'échantillonnage du canal de bruit inverse post-sélectionné, ou LER, le taux d'erreur logique). Cet argument method permet de sélectionner la stratégie de recherche (windowed, genetic, ou windowed_genetic). Ce tutoriel utilise et cost="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é ψ=U0n|\psi\rangle = U|0\rangle^{\otimes n} que le circuit devrait idéalement préparer et l'état bruité ρ\rho que le matériel produit réellement. La projection d'un état pur du stabilisateur ψ|\psi\rangle est égale à la moyenne uniforme sur les éléments 2n2^n de son groupe stabilisateur S\mathcal{S} :

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

En substituant cela dans la formule de fidélité, on obtient la fidélité de ρ\rho comme valeur moyenne attendue de chaque stabilisateur GSG \in \mathcal{S} par rapport à ρ\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.

Pour les problèmes de grande envergure, il est impossible d'énumérer tous les stabilisateurs d' 2n2^n, on peut donc estimer la fidélité à partir d'un échantillon aléatoire. En tirant au hasard et de manière uniforme des stabilisateurs MM G1,,GMG_1, \ldots, G_M à partir de S\mathcal{S}, on obtient une estimation non biaisée :

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

É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 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

Exemple 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:

Output of the previous code cell

É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]
Output of the previous code cell

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:

Output of the previous code cell

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:
Output of the previous code cell

É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 « 0|0\rangle », ce qui fait que « ZancZ_\text{anc} » stabilise son entrée. La propagation d' ZancZ_\text{anc} 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:

Output of the previous code cell

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
Output of the previous code cell Output of the previous code cell

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:

Output of the previous code cell

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
Output of the previous code cell

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

Recommandations

Si ce travail vous a paru intéressant, les documents suivants pourraient vous intéresser :


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.
Cette page a-t-elle été utile ?
Signaler un bogue, une coquille ou proposer du contenu sur GitHub.