Przejdź do głównej treści

Wykrywanie błędów o niskim narzucie za pomocą kodów przestrzenno-czasowych

Szacowany czas wykorzystania: 4 minuty na procesorze Heron (ibm_kingston lub równoważny) (UWAGA: To tylko szacunek. Twój rzeczywisty czas wykonania może się różnić.)

Efekty nauki

  • Jak przestrzenno-czasowe kontrole Pauliego wykrywają błędy logiczne w obwodach Clifforda i jak postselekcja na podstawie ich syndromów zwiększa wierność próbkowanego rozkładu.

  • Jak używać pakietu qiskit-paulice do automatycznego znajdowania i wstawiania kontroli efektywnych sprzętowo za pomocą get_check_qubits, NoiseModel i add_pauli_checks.

  • Jak oszacować wierność stanu stabilizatorowego poprzez próbkowanie jego stabilizatorów i postselekcję na podstawie syndromów kontroli.

  • Jak uruchomić pełny przepływ pracy wykrywania błędów na sprzęcie IBM Quantum® i porównać wierność zaszumioną z tą po postselekcji.

Wymagania wstępne

  • Podstawy sprzętowe dla obliczeń kwantowych na skalę użytkową.

  • Formalizm Clifforda i stabilizatorów, w tym sposób, w jaki grupa stabilizatorów opisuje czysty stan stabilizatorowy.

Kontekst

Wykrywanie błędów o niskim narzucie za pomocą kodów przestrzenno-czasowych [1] autorstwa Simona Martiela i Alego Javadi-Abhariego przedstawia metodę wykrywania błędów logicznych w obwodach zdominowanych przez bramki Clifforda, sytuującą się pomiędzy pełną korekcją błędów a lżejszą mitygacją błędów. Idea ta bazuje na spójnych kontrolach Pauliego (CPC) z pracy Single-shot error mitigation by coherent Pauli checks [2] autorstwa van den Berga i innych. W obu podejściach obwód „użytkowy” Clifforda jest splątywany z kubitami pomocniczymi w celu sprawdzenia pewnych niezmienników. Pomiar kubitów pomocniczych generuje syndrom, który wskazuje, czy podczas wykonania wykryto błąd. Zachowanie tylko próbek bez wykrytego błędu poprawia wierność próbkowanego rozkładu, kosztem obniżonego wskaźnika postselekcji.

Kluczową różnicą między spójnymi kontrolami Pauliego a kontrolami przestrzenno-czasowymi są operatory, które mierzą. Spójne kontrole Pauliego mierzą zlokalizowane w czasie operatory o wysokiej wadze. W topologiach kubitów o ograniczonej łączności, takich jak ciężki heksagon, takie kontrole wymagają wielu bramek SWAP i często sprawiają, że obwód jest zbyt głęboki, by uruchomić go w praktyce. Zaimplementowanie kontroli jako kodów przestrzenno-czasowych rozprasza natomiast każdą kontrolę w obwodzie użytkowym w przestrzeni i czasie. Daje to kodowanie efektywne sprzętowo, które pozostaje skuteczne w wykrywaniu błędów logicznych, przy jednoczesnym zachowaniu niskiego narzutu kubitów i głębokości.

Co robi pakiet qiskit-paulice

Pakiet qiskit-paulice automatyzuje konstruowanie tych kontroli, dzięki czemu nie musisz budować ich ręcznie. Jego główną rolą jest znajdowanie i wstawianie prawidłowych przestrzenno-czasowych kontroli Pauliego w miejscach obwodu, które maksymalizują wykrywanie błędów, minimalizując jednocześnie narzut kubitów. Kontrola jest prawidłowa, gdy jej operatory pozostawiają działanie logiczne obwodu użytkowego niezmienione, o niskiej wadze, gdy wykorzystuje niewiele bramek splątujących, oraz skuteczna, gdy wykrywa dużą część błędów w stosunku do szumu wprowadzanego przez samą kontrolę. Pakiet ocenia kandydujące kontrole względem modelu szumu i wprowadza do obwodu te najlepsze. Ten tutorial wykorzystuje trzy metody API:

  • get_check_qubits sprawdza mapę połączeń backendu i zwraca pary kubitów docelowych i pomocniczych. Kontrola na target_qubits[i] wykorzystuje ancilla_qubits[i].

  • NoiseModel.from_backend buduje przybliżony model szumu na podstawie danych benchmarkowych backendu. Model ocenia kandydujące kontrole, więc nie jest wymagany dokładny, wyuczony model szumu. Aby zapoznać się z wyuczonym modelem Pauliego-Lindblada, zobacz NoiseModel.from_pauli_lindblad_maps.

  • add_pauli_checks znajduje i wstawia kontrole do obwodu. Zwraca sekwencję obiektów CheckedCircuit o rosnącej liczbie kontroli, a każdy obiekt udostępnia metodę get_postselection_method, która odwzorowuje zmierzony bitciąg na wektor syndromu. Argument cost wybiera funkcję oceniającą kontrolę (gamma, narzut próbkowania odwrotnego kanału szumu po postselekcji, lub LER, logiczny wskaźnik błędów). Argument method wybiera strategię przeszukiwania (windowed, genetic lub windowed_genetic). Ten tutorial używa cost="gamma" i method="windowed", co razem daje deterministyczny, powtarzalny wybór kontroli.

Szacowanie wierności na podstawie próbkowania stabilizatorów

Aby zmierzyć skuteczność wykrywania błędów, możesz oszacować wierność stanu stabilizatorowego ψ=U0n|\psi\rangle = U|0\rangle^{\otimes n}, który obwód idealnie przygotowuje, względem zaszumionego stanu ρ\rho, który faktycznie zwraca sprzęt. Projektor na czysty stan stabilizatorowy ψ|\psi\rangle jest równy jednorodnej średniej po 2n2^n elementach jego grupy stabilizatorów S\mathcal{S}:

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

Podstawiając to do wierności, otrzymujemy wierność ρ\rho jako średnią wartość oczekiwaną każdego stabilizatora GSG \in \mathcal{S} względem ρ\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.

Dla większych problemów wyliczenie wszystkich 2n2^n stabilizatorów jest niewykonalne, więc możesz oszacować wierność na podstawie losowej próby. Losowanie MM stabilizatorów G1,,GMG_1, \ldots, G_M jednostajnie losowo z S\mathcal{S} daje nieobciążony estymator:

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

Ponieważ obwód Clifforda przygotowuje stan stabilizatorowy, możesz oszacować jego wierność bezpośrednio na podstawie próbkowanych wartości oczekiwanych jego stabilizatorów. Ten tutorial najpierw przeprowadza Cię przez przepływ pracy na symulatorze z małym obwodem, a następnie uruchamia ten sam przepływ pracy na sprzęcie z większym, głębszym obwodem. Gdy obwody zawierają więcej operacji niebędących operacjami Clifforda, liczba prawidłowych kontroli szybko się kurczy, więc metoda działa najlepiej dla obwodów zdominowanych przez bramki Clifforda.

Wymagania

Przed rozpoczęciem tego tutoriala upewnij się, że masz zainstalowane następujące elementy:

  • Qiskit SDK w wersji 2.0 lub nowszej, ze wsparciem dla wizualizacji

  • Qiskit Runtime w wersji 0.40 lub nowszej (pip install qiskit-ibm-runtime)

  • Qiskit Aer w wersji 0.17 lub nowszej (pip install qiskit-aer)

  • Qiskit Paulice (pip install qiskit-paulice)

  • tqdm (pip install tqdm)

Konfiguracja

Zaimportuj wymagane biblioteki i zdefiniuj funkcje pomocnicze, które nie są dostępne jako importy. Funkcja random_clifford_circuit buduje losowy obwód użytkowy Clifforda w stylu murarskim, find_check_layout przeszukuje mapę połączeń backendu w poszukiwaniu ścieżki kubitów o niskim błędzie z wieloma dostępnymi kubitami pomocniczymi, learned_noise_model przekształca wynik NoiseLearner w model szumu qiskit-paulice, append_basis_rotation obraca obwód tak, aby stabilizator był mierzony w bazie obliczeniowej, expectation oblicza wartość oczekiwaną stabilizatora na podstawie próbkowanych zliczeń, a cum_mean_sem śledzi bieżący estymator wierności.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Przykład symulatora na małą skalę

Ta sekcja przeprowadza przez pełny przepływ pracy na zaszumionym symulatorze. Wykorzystuje dane benchmarkowe backendu do wyboru układu kubitów i modelu szumu, automatycznie znajduje kontrole i wykorzystuje postselekcję na próbkowanym rozkładzie, aby pokazać poprawę wierności.

Krok 1: Odwzorowanie klasycznych danych wejściowych na problem kwantowy

Obwód użytkowy to płytki jednowymiarowy losowy obwód Clifforda w stylu murarskim. Ponieważ obwód jest obwodem Clifforda, przygotowuje stan stabilizatorowy, którego wierność możesz oszacować bezpośrednio na podstawie próbkowanych wartości oczekiwanych stabilizatorów. Zacznij od płytkiego obwodu, aby kontrole były łatwe do zwizualizowania w następnym kroku.

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

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

Output of the previous code cell

Krok 2: Optymalizacja pod kątem wykonania na sprzęcie kwantowym

Odwzorowanie obwodu na sprzęt ustala fizyczny układ kubitów, model szumu oceniający kandydujące kontrole oraz same kontrole.

Najpierw wybierz backend i przeszukaj jego mapę połączeń w poszukiwaniu jednowymiarowego układu kubitów za pomocą funkcji pomocniczej find_check_layout zdefiniowanej w sekcji Konfiguracja. Funkcja pomocnicza buduje losowe samounikające się ścieżki, które unikają bramek i odczytów o najwyższym błędzie, i zachowuje ścieżkę oferującą najwięcej par docelowych i pomocniczych. Ponieważ wyszukiwanie odczytuje łączność i dane o błędach bezpośrednio z backendu, ten sam kod działa na dowolnym QPU IBM Quantum. Funkcja get_check_qubits następnie zwraca pary docelowe i pomocnicze, gdzie kontrola na target_qubits[i] wykorzystuje ancilla_qubits[i].

Na poniższym grafie połączeń zielone kubity to kubity użytkowe, a pomarańczowe kubity to kubity pomocnicze implementujące kontrole. Kubity z sąsiadującym kubitem pomocniczym są wykorzystywane jako kubity docelowe dla kontroli.

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

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

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

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

Output of the previous code cell

Po wybraniu backendu i układu, transpiluj obwód użytkowy do obwodu w architekturze zestawu instrukcji (ISA). Konieczne jest jedynie ustalenie układu i przetłumaczenie bramek na natywny zestaw bramek backendu.

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

Output of the previous code cell

Następnie zamodeluj, jak szum bramek i odczytu na backendzie wpływa na wykonanie. Model szumu określa, w którym miejscu obwodu kontrola przechwytuje najwięcej błędów. Dokładniejszy model poprawia wykrywanie, ale zazwyczaj nie jest konieczne uczenie go poprzez próbkowanie QPU. Poniższy model wnioskuje jednorodny kanał depolaryzujący dla szumu bramek i odczytu na podstawie danych benchmarkowych qiskit-ibm-runtime.

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

Teraz dodaj kontrole do obwodu. Funkcja add_pauli_checks przyjmuje obwód użytkowy Clifforda, listę kubitów docelowych oraz model szumu. Argument ancilla_qubits informuje funkcję, który fizyczny kubit pomocniczy sparować z każdym celem. Kontrole są dodawane w kolejności, w jakiej pojawiają się kubity docelowe, więc ostateczny układ sprawdzonego obwodu to layout + ancilla_qubits. Aby uruchomić obwód wyjściowy z mniejszą liczbą (i) kontroli, ostateczny układ to layout + ancilla_qubits[:i].

Wynikiem add_pauli_checks jest sekwencja obwodów o rosnącej liczbie kontroli, od braku kontroli aż do jednej kontroli na każdym kubicie docelowym. Wizualizacja potwierdza, że kontrole wykorzystują określone pary docelowe i pomocnicze. Aby uzyskać szczegóły dotyczące znajdowania dobrych kontroli, zobacz sekcje od II do IV materiałów uzupełniających w odnośniku [1].

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

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

Output of the previous code cell

Krok 3: Wykonanie za pomocą prymitywów Qiskit

Aby uczynić efekt szumu bramek widocznym, zwiększ głębokość obwodu użytkowego i pobierz próbkę podzbioru jego stabilizatorów. Każdy stabilizator na ogół nie komutuje kubitowo z pozostałymi, więc pojedynczy zestaw kontroli nie jest prawidłowy dla dwóch różnych stabilizatorów. Zamiast grupować stabilizatory w komutujące zestawy, znajdź dobry zestaw kontroli dla każdego stabilizatora niezależnie. Próbkowanie stabilizatorów jednostajnie losowo daje nieobciążony estymator wierności.

Zbuduj głębszy obwód i wylosuj próbę jego stabilizatorów.

depth = 24
num_stabilizers = 20
num_shots = 1_000

circuit = random_clifford_circuit(num_qubits, depth, rng)

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

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

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

Dla każdego wylosowanego stabilizatora obróć obwód tak, aby stabilizator był mierzony w bazie obliczeniowej, transpiluj go na backend i znajdź dobry zestaw kontroli. Pary docelowe i pomocnicze są tasowane razem dla każdego stabilizatora, tak aby każdy cel zachował swój kubit pomocniczy. Pamiętaj, że kontrole są wprowadzane sekwencyjnie w kolejności podania kubitów docelowych, a wprowadzona kontrola nie zmienia się w miarę dodawania kolejnych kontroli.

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

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

Pobierz próbki gołego obwodu użytkowego i sprawdzonych obwodów za pomocą Qiskit Aer. Symulator używa tego samego modelu depolaryzującego, który oceniał kontrole, więc szum, na który celują kontrole, jest tym samym szumem, który stosuje symulator.

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

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

Krok 4: Postprzetwarzanie i zwrócenie wyniku w pożądanym formacie klasycznym

Każda kontrola wykorzystuje bramki splątujące między jednym kubitem pomocniczym a jednym docelowym. Kubit pomocniczy zaczyna w stanie 0|0\rangle, więc ZancZ_\text{anc} stabilizuje jego wejście. Propagowanie ZancZ_\text{anc} do przodu przez sprawdzony obwód daje operator Pauliego na wyjściu, którego człony różne od tożsamości definiują nośnik kontroli. Kontrola przechodzi, gdy bity w jej nośniku mają parzystą parzystość. Próbka jest zachowywana tylko wtedy, gdy każda kontrola przejdzie.

get_postselection_method każdego CheckedCircuit zwraca funkcję, która odwzorowuje zmierzony bitciąg na wektor syndromu. Zachowaj próbki, których syndrom wynosi zero dla każdej kontroli, a resztę odrzuć. Poniższy wykres pokazuje, że dodawanie kolejnych kontroli obniża wskaźnik postselekcji. Niższy wskaźnik postselekcji wymaga większej liczby strzałów, aby osiągnąć docelową dokładność, więc istnieje kompromis między zdolnością wykrywania a kosztem próbkowania. Wskaźnik wydaje się zbiegać, co wskazuje, że dodatkowe kontrole wnoszą coraz mniejszą zdolność wykrywania.

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

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

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

Output of the previous code cell

Teraz porównaj wierność gołego zaszumionego stanu z wiernością stanu po postselekcji. Postselekcja wyłącznie próbek bez wykrytego błędu podnosi wartość oczekiwaną każdego stabilizatora, a tym samym szacowaną wierność. Wartości po postselekcji wykorzystują mniej próbek niż wartości surowe, mimo to wartości oczekiwane są dokładniejsze, a próbkowana wariancja jest niższa. Zauważ też, że średni wskaźnik postselekcji jest bliski zaszumionej wierności. Jest to spodziewane, gdy kontrole wykrywają niemal wszystkie błędne próbki: odsetek próbek, które przechodzą każdą kontrolę, zbliża się do odsetka próbek wolnych od błędów, czyli wierności zaszumionego stanu.

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

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

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

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

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

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

Output of the previous code cell

Output of the previous code cell

Wynik gamma pokazuje, jaka część modelowanego kanału szumu pozostaje niewykryta przez kontrole. Wykreślenie wyniku gamma w funkcji liczby wprowadzonych kontroli pokazuje, jak zdolność wykrywania poprawia się wraz z dodawaniem każdej kontroli. Wartość 1.0 oznacza, że kontrole przechwytują cały modelowany szum. Krzywe opadają w kierunku 1.0 w miarę wprowadzania kolejnych kontroli, co pokazuje, że każda dodatkowa kontrola przechwytuje część pozostałego niewykrytego błędu.

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

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

Output of the previous code cell

Przykład sprzętowy na dużą skalę

Ten sam przepływ pracy działa na sprzęcie z większym, głębszym obwodem użytkowym. Ta sekcja ponownie wykorzystuje backend z przykładu symulatora, ale buduje nowy 20-kubitowy układ z własnymi parami docelowymi i pomocniczymi oraz menedżerem przebiegów, a następnie przesyła obwody do QPU w ramach jednego zadania. Przy tej wielkości większość strzałów wyzwala co najmniej jedną kontrolę, więc wskaźnik postselekcji jest niski, a każdy obwód wymaga dużego budżetu strzałów, aby uzyskać wystarczającą liczbę próbek. Przykład ten koncentruje więc swój budżet na kilku wylosowanych stabilizatorach; nadal jest to nieobciążony estymator wierności, ale jest on mniej precyzyjny niż uśrednianie po wielu stabilizatorach z przykładu symulatora.

Jedna rzecz zmienia się w stosunku do przykładu symulatora: zamiast wnioskować jednorodny kanał depolaryzujący z danych kalibracyjnych, ta sekcja uczy się modelu szumu za pomocą NoiseLearner i buduje model qiskit-paulice z wyniku za pomocą NoiseModel.from_pauli_lindblad_maps. Wyuczony model Pauliego-Lindblada uchwyca strukturę przestrzenną szumu na tym konkretnym układzie, zamiast zakładać, że każda krawędź jest jednakowo zaszumiona, więc rozmieszczenie kontroli jest oceniane względem szumu bardziej zbliżonego do tego, który wpływa na QPU. Uczenie się szumu wymaga próbkowania QPU i powinno być uwzględnione w ogólnym budżecie próbkowania QPU.

Poniższe parametry ustalają liczbę kubitów, głębokość, liczbę stabilizatorów oraz liczbę strzałów. Skaluj hw_num_shots odwrotnie proporcjonalnie do wskaźnika postselekcji: przy wskaźniku 3%, 40 000 strzałów pozostawia około 1200 próbek po postselekcji na obwód. Zwiększ hw_num_stabilizers, aby uzyskać bardziej precyzyjny estymator wierności kosztem większej liczby obwodów na zadanie, z których każdy wymaga tego samego budżetu strzałów.

Kroki 1-4 (skompresowane w pojedynczy blok kodu)

Poniższa komórka uruchamia te same cztery kroki co przykład symulatora. Buduje większy obwód użytkowy i pobiera próbkę kilku stabilizatorów (krok 1); wybiera układ, uczy się na nim modelu szumu i znajduje w pełni sprawdzony obwód dla każdego stabilizatora (krok 2); przesyła jedno zadanie Sampler zawierające zarówno gołe, jak i sprawdzone obwody (krok 3); oraz stosuje postselekcję na zaszumionych zliczeniach, aby porównać estymatory wierności zaszumionej i po postselekcji, dla poszczególnych stabilizatorów i średnio (krok 4). Przy tej wielkości wyliczenie pełnej grupy stabilizatorów, jak w przykładzie symulatora, jest niewykonalne, więc komórka pobiera losowy podzbiór stabilizatorów, aby obliczyć estymator wierności.

Zauważ, że krok 2 robi tutaj więcej niż w przykładzie symulatora: uczenie się modelu szumu przesyła własne zadanie NoiseLearner przed zadaniem Sampler, więc komórka uruchamia łącznie dwa zadania. Noszą one etykiety TUT_ASPC_LEARN i TUT_ASPC, dzięki czemu możesz je później odnaleźć. Zobacz Organizowanie i wyszukiwanie według etykiet zadań, aby dowiedzieć się więcej o etykietowaniu zadań.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Output of the previous code cell

Dla obwodów tej wielkości większość próbek zawiera co najmniej jeden wykryty błąd, więc wskaźnik postselekcji jest niewielki, a postselekcja odrzuca większość strzałów. Próbki, które przechodzą każdą kontrolę, dają znacznie lepszą wartość oczekiwaną niż goły obwód, a wartości dla poszczególnych stabilizatorów wyraźnie odróżniają się od zaszumionej linii bazowej. Aby zawęzić estymator wierności, próbkuj więcej stabilizatorów przy tym samym budżecie strzałów na obwód. Aby podnieść wskaźnik postselekcji, zmniejsz głębokość obwodu lub wprowadź mniej kontroli; aby przejść do większych obwodów użytkowych, skaluj budżet strzałów odwrotnie proporcjonalnie do wskaźnika postselekcji.

Kolejne kroki

Rekomendacje

Jeśli ta praca Cię zainteresowała, mogą Cię zainteresować następujące materiały:

Odnośniki

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

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