Skip to main content
IBM Quantum Platform

Rilevamento degli errori a basso overhead con codici spazio-temporali

Stima del tempo di esecuzione: 4 minuti su un processore Heron (ibm_kingston o equivalente) (NOTA: si tratta solo di una stima). (La durata potrebbe variare.)


Risultati di apprendimento

Al termine di questo tutorial, avrai acquisito le seguenti conoscenze:

  • In che modo i controlli di Pauli sullo spazio-tempo individuano gli errori logici nei circuiti di Clifford e in che modo la post-selezione sulle loro sindromi aumenta la fedeltà di una distribuzione campionata.
  • Come utilizzare il pacchetto qiskit-paulice per individuare e inserire automaticamente controlli ottimizzati a livello hardware con get_check_qubits, NoiseModel, e add_pauli_checks.
  • Come stimare la fedeltà di uno stato stabilizzatore mediante campionamento dei suoi stabilizzatori e post-selezione sui sindromi di controllo.
  • Come eseguire il flusso di lavoro completo di rilevamento degli errori su un hardware " IBM Quantum® " e confrontare le fedeltà "noisy" e "postselected".

Prerequisiti

Si consiglia di approfondire i seguenti argomenti:

  • Nozioni fondamentali sull'hardware per il calcolo quantistico su scala industriale.
  • Il formalismo di Clifford e dello stabilizzatore, compreso il modo in cui un gruppo stabilizzatore descrive uno stato stabilizzatore puro.

Sfondo

L'articolo "Rilevamento degli errori a basso overhead con codici spazio-temporali " [1] di Simon Martiel e Ali Javadi-Abhari introduce un metodo per il rilevamento degli errori logici nei circuiti dominati da Clifford che si colloca a metà strada tra la correzione completa degli errori e la mitigazione degli errori, che comporta un carico inferiore. L'idea si basa sui controlli di Pauli coerenti (CPC) descritti nell'articolo " Mitigazione degli errori in modalità single-shot tramite controlli di Pauli coerenti " [2] di van den Berg e altri. In entrambi gli approcci, un circuito Clifford “payload” viene intrecciato con i qubit ancillari per verificare determinati invarianti. La misurazione delle ancille genera una sindrome che segnala se è stato rilevato un errore durante l'esecuzione. Mantenere solo i campioni in cui non è stato rilevato alcun errore migliora la fedeltà della distribuzione campionaria, a scapito di un tasso di post-selezione ridotto.

La differenza fondamentale tra i controlli coerenti di Pauli e i controlli spazio-temporali risiede negli operatori che misurano. I controlli di Pauli coerenti misurano operatori localizzati nel tempo e ad alto peso. Nelle topologie di qubit con connettività limitata, come l’“heavy hex”, tali verifiche richiedono un numero elevato di porte SWAP e spesso rendono il circuito troppo complesso per poter essere eseguito nella pratica. Implementando invece i controlli come codici spazio-temporali, ciascun controllo viene distribuito lungo il circuito di carico utile nello spazio e nel tempo. Ciò consente una codifica efficiente dal punto di vista hardware, che rimane efficace nel rilevare gli errori logici mantenendo al contempo basso il sovraccarico in termini di qubit e profondità.

Cosa fa il pacchetto qiskit-paulice

Il pacchetto qiskit-paulice automatizza la creazione di questi controlli, in modo da non doverli creare manualmente. Il suo ruolo principale è quello di individuare e inserire controlli di Pauli spazio-temporali validi nei punti del circuito che massimizzano il rilevamento degli errori riducendo al minimo il sovraccarico dei qubit. Un controllo è valido quando i suoi operatori lasciano inalterata l’azione logica del circuito di carico utile, è a basso peso quando utilizza poche porte di entanglement ed è efficace quando rileva una porzione consistente degli errori, rispetto al rumore introdotto dal controllo stesso. Il pacchetto valuta i controlli dei candidati rispetto a un modello di rumore e inserisce quelli migliori nel circuito. Questo tutorial utilizza tre metodi API:

  • get_check_qubits esamina una mappa di accoppiamento del backend e restituisce coppie di qubit bersaglio e ancilla. Un controllo su utilizza target_qubits[i] ancilla_qubits[i].
  • NoiseModel.from_backend crea un modello approssimativo del rumore sulla base dei dati dei benchmark del backend. Il modello assegna un punteggio ai controlli dei candidati, pertanto non è necessario un modello di rumore esatto e appreso. Per un modello di Pauli-Lindblad appreso, si veda NoiseModel.from_pauli_lindblad_maps.
  • add_pauli_checks individua e inserisce i controlli in un circuito. Restituisce una sequenza di CheckedCircuit oggetti con un numero crescente di controlli, e ciascun oggetto fornisce una funzione get_postselection_method che mappa una stringa di bit misurata su un vettore di sindrome. L'argomento cost seleziona la funzione che assegna un punteggio a un controllo (gamma, il sovraccarico di campionamento del canale di rumore inverso post-selezionato, oppure LER, il tasso di errore logico). L'argomento method seleziona la strategia di ricerca (windowed, genetic, o windowed_genetic). Questo tutorial utilizza e cost="gamma" method="windowed", che insieme garantiscono una selezione dei test deterministica e riproducibile.

Stima della fedeltà a partire dal campionamento dello stabilizzatore

Per valutare l'efficacia del sistema di rilevamento degli errori, è possibile stimare la fedeltà tra lo stato stabilizzato ψ=U0n|\psi\rangle = U|0\rangle^{\otimes n} che il circuito prepara idealmente e lo stato soggetto a rumore ρ\rho che l'hardware emette effettivamente. La proiezione su uno stato puro dello stabilizzatore ψ|\psi\rangle è pari alla media uniforme sugli elementi 2n2^n del suo gruppo stabilizzatore S\mathcal{S} :

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

Sostituendo questo nell'espressione della fedeltà, si ottiene che la fedeltà di ρ\rho è pari al valore atteso medio 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 di dimensioni maggiori, è impossibile enumerare tutti gli stabilizzatori 2n2^n, pertanto è possibile stimare la fedeltà a partire da un campione casuale. Estraendo in modo casuale e uniforme gli stabilizzatori MM G1,,GMG_1, \ldots, G_M 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 stabilizzato, è possibile stimarne la fedeltà direttamente dai valori attesi campionati dei suoi stabilizzatori. Questo tutorial illustra innanzitutto il flusso di lavoro su un simulatore con un circuito di piccole dimensioni, per poi eseguire lo stesso flusso di lavoro su hardware con un circuito più grande e complesso. Man mano che i circuiti includono un numero crescente di operazioni non Clifford, il numero di verifiche valide diminuisce rapidamente; pertanto, il metodo funziona al meglio per i circuiti dominati da operazioni Clifford.


Requisiti

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

  • Qiskit SDK v2.0 o versioni successive, con supporto alla visualizzazione
  • Qiskit Runtime v0.40 o versioni successive (pip install qiskit-ibm-runtime)
  • Qiskit Aer v0.17 o versione successiva (pip install qiskit-aer)
  • Qiskit Paulice (pip install qiskit-paulice)
  • tqdm (pip install tqdm)

Configura

Importa le librerie necessarie e definisci le funzioni di supporto che non sono disponibili come importazioni. La funzione random_clifford_circuit genera un payload Clifford casuale di tipo "brickwork", find_check_layout cerca in una mappa di accoppiamento del backend un percorso di qubit a basso errore con molti ancilla disponibili, learned_noise_model converte NoiseLearner l'output 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 il valore atteso dello stabilizzatore a partire dai conteggi campionati e cum_mean_sem tiene traccia della stima della fedeltà in tempo reale.

# 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 di simulatore su piccola scala

Questa sezione illustra il flusso di lavoro completo su un simulatore con rumore. Utilizza i dati dei benchmark del backend per scegliere una configurazione dei qubit e un modello di rumore, individua automaticamente i controlli e ricorre alla post-selezione sulla distribuzione campionata per dimostrare il miglioramento della fedeltà.

Fase 1: Mappare gli input classici su un problema quantistico

Il circuito di carico utile è un circuito di Clifford casuale unidimensionale a struttura a mattoni poco profonda. Poiché il circuito è di tipo Clifford, genera uno stato stabilizzatore la cui fedeltà può essere stimata direttamente dai valori attesi campionati dello stabilizzatore. Inizia con un circuito poco complesso, in modo che i controlli siano facili da visualizzare nella fase successiva.

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

Fase 2: Ottimizzazione per l'esecuzione su hardware quantistico

La mappatura del circuito sull'hardware definisce la disposizione fisica dei qubit, il modello di rumore utilizzato per valutare i controlli candidati e i controlli stessi.

Per prima cosa, seleziona un backend e cerca nella sua mappa di accoppiamento una disposizione unidimensionale dei qubit con l’helper find_check_layout definito nella sezione “Setup”. L'helper genera percorsi casuali che evitano i gate e i readout con il maggior errore, e mantiene il percorso che offre il maggior numero di coppie di target e ancilla. Poiché la ricerca legge i dati relativi alla connettività e agli errori direttamente dal backend, lo stesso codice funziona su qualsiasi QPU IBM Quantum. La funzione get_check_qubits restituisce quindi le coppie di target e ancilla, dove la verifica su utilizza target_qubits[i] ancilla_qubits[i].

Nel grafico di accoppiamento riportato di seguito, i qubit verdi sono i qubit di carico utile, mentre quelli arancioni sono gli ancilla che eseguono i controlli. I qubit con un ancilla adiacente vengono utilizzati come qubit di riferimento 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,
)

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

Una volta scelti il backend e il layout, traspilare il payload in un circuito con architettura a set di istruzioni (ISA). È sufficiente impostare il layout e convertire i gate nel set di gate 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:

Output of the previous code cell

Successivamente, illustra in che modo il rumore di gate e di lettura nel backend influisce sull'esecuzione. Il modello di rumore determina in quale punto del circuito un controllo rileva il maggior numero di errori. Un modello più accurato migliora il rilevamento, ma di solito non è necessario addestrarne uno tramite campionamento della QPU. Il modello che segue deduce, sulla base dei dati di riferimento qiskit-ibm-runtime , un canale di depolarizzazione uniforme per il rumore di gate e di lettura.

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)

Ora aggiungi i controlli al circuito. La funzione add_pauli_checks accetta come parametri il payload di Clifford, l'elenco dei qubit di destinazione e il modello di rumore. L'argomento ancilla_qubits indica alla funzione quale ancilla fisica deve essere accoppiata a ciascun bersaglio. I controlli vengono aggiunti nell'ordine in cui compaiono i qubit di destinazione, quindi la disposizione finale del circuito controllato è layout + ancilla_qubits. Per realizzare un circuito di uscita con un numero inferiore di (i) controlli, il layout finale è il seguente layout + ancilla_qubits[:i].

L'output di è add_pauli_checks una sequenza di circuiti con un numero crescente di controlli, che va da nessun controllo fino a un controllo per ogni qubit di destinazione. La visualizzazione conferma che i controlli utilizzano le coppie di target e ancilla specificate. Per ulteriori dettagli su come individuare controlli efficaci, si vedano le sezioni da II a IV delle informazioni supplementari contenute 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)

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

Passaggio 3: Eseguire il comando utilizzando Qiskit primitives

Per rendere visibile l'effetto del rumore di gate, aumentare la profondità del payload e campionare un sottoinsieme dei suoi stabilizzatori. In genere, ogni stabilizzatore non commuta con gli altri a livello di qubit, pertanto una singola serie di verifiche non è valida per due stabilizzatori diversi. Anziché raggruppare gli stabilizzatori in insiemi di pendolari, individua una serie adeguata di controlli per ciascuno stabilizzatore in modo indipendente. Il campionamento casuale e uniforme degli stabilizzatori fornisce una stima non distorta della fedeltà.

Costruisci il circuito più complesso 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]}, ...}}"
)

Output:

Sampled 20 stabilizers of a 12-qubit circuit with two-qubit depth 24: {ZXIIXZYYXIZZ, XXXYIIZYXIII, ...}

Per ogni stabilizzatore campionato, ruotare il circuito in modo che lo stabilizzatore venga misurato nella base computazionale, trasporlo sul backend e individuare una serie adeguata di controlli. Per ogni stabilizzatore, le coppie di target e ancilla vengono mescolate in modo che ogni target mantenga la propria ancilla. Ricorda che i controlli vengono confermati in ordine sequenziale, secondo l'ordine in cui vengono specificati i qubit di destinazione, e che un controllo già confermato non viene modificato anche se 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."
)

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.

Prova il payload non protetto e i circuiti verificati con Qiskit Aer. Il simulatore utilizza lo stesso modello di depolarizzazione utilizzato per la valutazione dei test, pertanto il rumore su cui si concentrano i test è lo stesso 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))

Output:

100%|██████████| 20/20 [00:17<00:00,  1.13it/s]

Fase 4: Elaborazione successiva e restituzione del risultato nel formato classico desiderato

Ogni verifica utilizza porte di entanglement tra un ancilla e un bersaglio. L'ancilla parte da 0|0\rangle, quindi ZancZ_\text{anc} ne stabilizza l'input. La propagazione di un’ ZancZ_\text{anc} e in avanti attraverso il circuito di controllo produce un operatore di Pauli in uscita, i cui termini non identitari definiscono il supporto del controllo. Un controllo è valido quando i bit che lo compongono hanno parità pari. Un campione viene conservato solo se supera tutti i controlli.

La funzione di get_postselection_method ciascun restituisce CheckedCircuit una funzione che mappa una stringa di bit misurata su un vettore di sindrome. Conserva i campioni il cui indice di sindrome è pari a zero in ogni controllo e scarta gli altri. Il grafico che segue mostra che l'aggiunta di ulteriori controlli riduce il tasso di post-selezione. Un tasso di post-selezione più basso richiede un numero maggiore di scatti per raggiungere una determinata precisione, pertanto esiste un compromesso tra capacità di rilevamento e costo di campionamento. Il tasso sembra convergere, il che indica che ulteriori controlli contribuiscono in misura minore alla capacità di rilevamento.

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

Ora confrontiamo la fedeltà dello stato "nudo" e rumoroso con quella dello stato post-selezionato. La selezione a posteriori dei soli campioni in cui non è stato rilevato alcun errore aumenta il valore atteso di ogni stabilizzatore e, di conseguenza, la fedeltà stimata. I valori post-selezionati utilizzano un numero inferiore di campioni rispetto ai valori grezzi, ma i valori attesi sono più precisi e la varianza campionaria è inferiore. Si noti inoltre che il tasso medio di post-selezione è prossimo alla fedeltà soggetta a rumore. Questo è ciò che ci si aspetta quando i controlli rilevano quasi tutti i campioni errati: la frazione di campioni che superano ogni controllo si avvicina alla frazione di campioni privi di errori, che corrisponde alla 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()

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

Il punteggio gamma indica quale percentuale del canale di rumore modellato non viene rilevata dai controlli. Il grafico che mette in relazione il punteggio gamma con il numero di controlli effettuati mostra come la capacità di rilevamento migliori man mano che viene aggiunto ogni controllo. Un valore pari a indica 1.0 che i controlli rilevano tutto il rumore modellato. Le curve tendono verso 1.0 man mano che vengono eseguiti ulteriori controlli, il che dimostra che ogni controllo aggiuntivo individua una parte dell’errore residuo non ancora rilevato.

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

Esempio di hardware su larga scala

Lo stesso flusso di lavoro viene eseguito su hardware dotato di una capacità di carico maggiore e più profonda. Questa sezione riutilizza il backend dell'esempio del simulatore, ma crea un nuovo layout da 20 qubit con le proprie coppie di target e ancilla e il proprio gestore di passaggi, per poi inviare i circuiti alla QPU in un unico lavoro. A queste dimensioni, la maggior parte degli scatti attiva almeno un controllo, quindi il tasso di post-selezione è basso e ogni circuito richiede un budget di scatti elevato per ottenere un numero sufficiente di campioni. L'esempio concentra quindi il proprio budget su un numero limitato di stabilizzatori campionati; si tratta comunque di una stima della fedeltà non distorta, ma meno dettagliata rispetto alla media calcolata dall'esempio del simulatore su un ampio numero di stabilizzatori.

Rispetto all’esempio del simulatore, c’è una differenza: invece di dedurre un canale depolarizzante uniforme dai dati di calibrazione, in questa sezione si apprende il modello di rumore con NoiseLearner e si costruisce il modello qiskit-paulice partendo dal risultato ottenuto con NoiseModel.from_pauli_lindblad_maps. Un modello Pauli-Lindblad addestrato riproduce la struttura spaziale del rumore su questa specifica configurazione, anziché ipotizzare che ogni bordo presenti lo stesso livello di rumore; pertanto, il posizionamento dei simboli viene valutato in base a un rumore più simile a quello che interessa la QPU. L'apprendimento del rumore richiede il campionamento della QPU e deve essere preso in considerazione nel bilancio complessivo di campionamento della QPU.

I parametri riportati di seguito definiscono il numero di qubit, la profondità, il numero di stabilizzatori e il numero di shot. La scala hw_num_shots dipende dall'inverso del tasso di post-selezione: con un tasso del 3%, 40.000 misurazioni producono circa 1.200 campioni post-selezionati per circuito. Aumentare hw_num_stabilizers per ottenere una stima più precisa della fedeltà, a costo di un maggior numero di circuiti per ogni lavoro, ciascuno dei quali richiede lo stesso budget di shot.

Passaggi da 1 a 4 (raggruppati in un unico blocco di codice)

La cella seguente esegue gli stessi quattro passaggi dell'esempio del simulatore. Crea il payload più grande e campiona alcuni stabilizzatori (fase 1); sceglie un layout, apprende il modello di rumore su di esso e individua il circuito completamente verificato per ciascuno stabilizzatore (fase 2); invia un’attività Sampler contenente sia i circuiti grezzi che quelli verificati (fase 3); ed effettua una post-selezione dei conteggi verificati per confrontare le stime di fedeltà con rumore e quelle post-selezionate, per singolo stabilizzatore e in media (fase 4). A queste dimensioni, enumerare l’intero gruppo degli stabilizzatori come nell’esempio del simulatore è impossibile, quindi la cella campiona in modo casuale un sottoinsieme degli stabilizzatori per calcolare una stima della fedeltà.

Si noti che il passaggio 2 in questo caso svolge un ruolo più ampio rispetto all'esempio del simulatore: il processo di apprendimento del modello di rumore invia il proprio lavoro NoiseLearner prima di quello del campionatore, quindi la cella esegue in totale due lavori. Sono provvisti delle etichette e TUT_ASPC_LEARN TUT_ASPC in modo da poterli ritrovare in seguito. Per ulteriori informazioni sull'assegnazione di tag alle offerte di lavoro, consulta la sezione " Organizza e cerca in base ai tag delle offerte di lavoro".

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

Per circuiti di queste dimensioni, la maggior parte dei campioni contiene almeno un errore rilevato, quindi il tasso di post-selezione è basso e la post-selezione scarta la maggior parte degli scatti. I campioni che superano tutti i controlli forniscono un valore atteso di gran lunga migliore rispetto al circuito nudo, e i valori relativi a ciascuno stabilizzatore si distinguono nettamente dalla linea di base rumorosa. Per ottenere una stima più accurata della fedeltà, campionare un numero maggiore di stabilizzatori utilizzando lo stesso budget di misurazioni per circuito. Per aumentare il tasso di post-selezione, ridurre la profondità del circuito o eseguire un numero minore di controlli; per ottenere carichi utili maggiori, scalare il budget di shot in base all’inverso del tasso di post-selezione.


Passi successivi

Suggerimenti

Se questo lavoro ti è sembrato interessante, potrebbero interessarti anche i seguenti contenuti:


Riferimenti

  • [1] Martiel, S., & Javadi-Abhari, A. (2025). Rilevamento degli errori a basso overhead con codici spazio-temporali. arXiv preprint arXiv:2504.15725.
  • [2] van den Berg, E., Bravyi, S., Gambetta, J. M., Jurcevic, P., Maslov, D., & Temme, K. (2023). Mitigazione degli errori su singoli shot tramite controlli di Pauli coerenti. Physical Review Research, 5(3), 033193.
Questa pagina è stata utile?
Segnala un bug, un errore di battitura o richiedi contenuti su GitHub.