Fehlererkennung mit geringem Overhead durch Spacetime-Codes
In diesem Tutorial implementieren wir einen Workflow, um aus einem Clifford-Circuit zu samplen und mit dem Paket qiskit-paulice die Fidelity der gemessenen Verteilung zu erhöhen, indem Spacetime-Pauli-Checks im gesamten Circuit angewendet werden. Spacetime-Pauli-Checks ähneln kohärenten Pauli-Checks (CPC) insofern, als ein Clifford-"Payload"-Circuit mit einigen Hilfs-Qubits verschränkt wird, um bestimmte Invarianten im Circuit zu "prüfen". Die Messung der Ancillas liefert ein Syndrom, mit dem bestimmt wird, ob während der Ausführung des Circuits ein logischer Fehler erkannt wurde. Wenn man nur Samples nachselektiert, bei denen kein Fehler erkannt wurde, kann man die Fidelity der gesampelten Verteilungen verbessern, allerdings auf Kosten sinkender Postselektionsraten. Der entscheidende Unterschied zwischen CPC und Spacetime-Checks besteht darin, dass die Messung von CPC-Syndromen die Messung zeitlich lokalisierter Operatoren mit hohem Gewicht erfordert. Bei Qubit-Topologien mit begrenzter Konnektivität, wie Heavy-Hex, erfordert die Implementierung dieser Checks viele SWAP-Gates, wodurch der Circuit oft zu tief wird, um ihn praktisch auszuführen. Die Implementierung von Pauli-Checks als Spacetime-Codes erlaubt es, sie über den gesamten Payload-Circuit zu verteilen, was eine hardwareeffiziente Qubit-Kodierung ermöglicht, die weiterhin logische Fehler während der Ausführung des Circuits wirksam erkennt. Die Hauptaufgabe des Pakets qiskit-paulice besteht darin, gültige Spacetime-Pauli-Checks zu finden und an Stellen im Circuit einzufügen, die die Fehlererkennung maximieren und den Qubit-Overhead minimieren.
Dieses Tutorial implementiert einen Workflow zur Erhöhung der Fidelity der aus einem Quantum Circuit gesampelten Verteilung mithilfe von Spacetime-Checks. Da wir einen Clifford-Circuit verwenden, können wir die Fidelity unkompliziert aus den gemittelten Erwartungswerten der Stabilisatoren des Circuits schätzen. Wir gehen dabei in diesen Schritten vor:
-
Einen Circuit erstellen: Flacher, zufälliger 1D-Brickwork-Clifford-Circuit zur Visualisierung der Checks.
-
Qubits als Ziele für Checks identifizieren: Den Circuit auf physische Qubits des Heron QPU
ibm_bostonabbilden und Qubits mit benachbarten Ancillas identifizieren. -
Den Circuit für das Backend transpilieren: Ein Qubit-Layout auswählen und die Circuit-Gates in den nativen Gate-Satz von
ibm_bostonübersetzen. -
Ein Rauschmodell aus Backend-Daten erstellen: Ein grobes Rauschmodell auf Basis der Backend-Benchmark-Daten erzeugen. Diese Rauschdaten werden verwendet, um die Wirksamkeit jedes zusätzlichen Checks zu bewerten.
-
Pauli-Checks visualisieren: Dem flachen Circuit einige Pauli-Checks hinzufügen, damit wir sehen können, wie die Checks eingefügt werden.
-
Zu messende Stabilisatoren bestimmen: Die Tiefe des Circuits erhöhen, um den Einfluss des Gate-Rauschens zu verstärken. Zufällig eine Teilmenge von Stabilisatoren des tieferen Circuits samplen.
-
Gute Checks für den gesamten Zustand finden: Eine Menge von Checks für die Stabilisatoren des Zustands finden, den der Circuit präpariert. Solche Checks sind unabhängig davon gültig, was anschließend gemessen wird, sodass derselbe geprüfte Circuit für jeden gesampelten Stabilisator dient.
-
Aus dem verrauschten Circuit samplen: Den ungeprüften und den geprüften Circuit in der Basis jedes gesampelten Stabilisators mit samplomatic messen und mit dem
Executor-Primitive auf einem verrauschten Simulator samplen, der dasselbe depolarisierende Rauschen verwendet, das zum Finden der Checks genutzt wurde. -
Geprüfte Circuit-Samples ohne erkannte Fehler nachselektieren: Samples entfernen, die mindestens einen Check nicht bestehen. Erwartungswerte für den ungeprüften verrauschten Circuit und den Circuit mit Checks berechnen.
-
Fidelities der ursprünglichen und der nachselektierten verrauschten Zustände vergleichen: Zeigen, dass die Nachselektion nur von Samples ohne erkannte Fehler die geschätzte Fidelity des verrauschten Zustands verbessert.
1. Den Circuit erstellen
Zuerst erstellen wir einen flachen Clifford-Circuit, dessen verschränkende Gates in einer Brickwork-Konnektivität angeordnet sind. Mit diesem kleinen Circuit visualisieren wir, wie Checks zum Circuit hinzugefügt werden.
# Added by doQumentation — installs the packages this notebook needs if they are missing
import importlib.util
_needed = {"matplotlib": "matplotlib", "numpy": "numpy", "qiskit": "qiskit", "qiskit_aer": "qiskit-aer", "qiskit_ibm_runtime": "qiskit-ibm-runtime", "qiskit_paulice": "qiskit-paulice", "samplomatic": "samplomatic"}
_missing = [pip for module, pip in _needed.items()
if importlib.util.find_spec(module) is None]
# One at a time, so a package that fails to install does not block the others
for _pip in _missing:
%pip install -q {_pip}
if not _missing:
print("\u2713 All required packages are installed")
import numpy as np
from qiskit import QuantumCircuit
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
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.draw("mpl", fold=-1, scale=0.6)

2. Qubits als Ziele für Checks identifizieren
Als Nächstes wählen wir ein 1D-Qubit-Layout auf dem Heron r3 QPU ibm_boston. Wir wählen ein Layout, das diagonal im Zickzack durch den Kopplungsgraphen verläuft und die Anzahl verfügbarer Ancilla-Qubits maximiert. Um verfügbare Ancillas zu finden, verwenden wir die Funktion get_check_qubits, die Listen von Target-/Ancilla-Qubit-Paaren zurückgibt. Ein Pauli-Check auf target_qubits[i] wird mit ancilla_qubits[i] implementiert.
Im Kopplungsgraphen unten sind die grünen Qubits die Payload-Qubits und die orangefarbenen Qubits die Ancillas, die wir zur Implementierung der Checks verwenden. Die Qubits 68, 69, 89, 91, 111, 113 und 133 haben benachbarte Ancillas, daher verwenden wir diese Qubits (entsprechend den virtuellen Qubits 0, 1, 3, 5, 7, 9 und 11) als Target-Qubits für die Implementierung von Checks. Beachte, dass Qubit 134 im Payload an Qubit 133 angrenzt, aber nicht als Ancilla hervorgehoben ist. Das liegt daran, dass 133 bereits (willkürlich) gewählt wurde, um einen Check mit Ancilla 132 zu implementieren.
from qiskit.visualization import plot_coupling_map
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_paulice.layout import get_check_qubits
service = QiskitRuntimeService()
backend = service.backend("ibm_boston")
# Choose a layout and pair each available target qubit with a neighboring ancilla
layout = [68, 69, 78, 89, 90, 91, 98, 111, 112, 113, 119, 133]
target_qubits, ancilla_qubits = get_check_qubits(backend, layout)
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,
)
Target qubits: [68, 69, 89, 91, 111, 113, 133]
Ancilla qubits: [67, 70, 88, 92, 110, 114, 132]

3. Den Circuit für das Backend transpilieren
Da das Backend bereits gewählt wurde, können wir unseren virtuellen Circuit in einen ISA-Circuit transpilieren. Hier legen wir nur das Qubit-Layout fest und übersetzen die Gates in den nativen Gate-Satz von ibm_boston.
from qiskit.transpiler import generate_preset_pass_manager
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)

4. Ein Rauschmodell aus Backend-Daten erstellen
Nachdem wir nun wissen, wie der Circuit auf dem QPU angeordnet wird, können wir modellieren, wie sich das Gate- und Auslese-Rauschen des Backends auf die Ausführung des Circuits auswirkt. Das Rauschmodell wird verwendet, um die optimalen Stellen im Circuit für Pauli-Checks zu bestimmen. Wir wollen Checks an Positionen platzieren, an denen sie die größte Fehlermenge erfassen. Ein genaueres Rauschmodell kann zwar die Erkennungsfähigkeit der Checks verbessern, es ist aber meist nicht nötig, ein Rauschmodell durch Sampling des QPU zu lernen. Hier schließen wir ein einheitliches depolarisierendes Gate-Rauschmodell allein aus den Backend-Benchmark-Daten von qiskit-ibm-runtime und lassen den ebenfalls gemeldeten Auslesefehler weg: Die Checks, die wir in Schritt 7 finden, lesen nur ihre Ancilla aus, sodass Auslesefehler des Payloads für sie unsichtbar sind und nur die nachselektierte Fidelity in Schritt 10 senken würden. Weitere Informationen zur Angabe von Gate- und Ausleserauschen findest du in der Dokumentation zu qiskit_paulice.NoiseModel. Wenn du ein gelerntes Pauli-Lindblad-Rauschmodell verwenden möchtest, siehe NoiseModel.from_pauli_lindblad_maps.
from dataclasses import replace
from qiskit_paulice.noise_models import NoiseModel
# Model the gate noise as a uniform depolarizing channel based on backend benchmark data
noise_model = replace(
NoiseModel.from_backend(backend, layout, uniform_gate_noise=True),
readout_noise=None,
)
print(noise_model)
NoiseModel(gate_noise=0.0015808382794757693, readout_noise=None, idling_noise=None)
5. Pauli-Checks visualisieren
Fügen wir dem Circuit nun einige Checks hinzu und schauen, wie sie aussehen. Mit der Funktion qiskit_paulice.add_pauli_checks können wir eine gute Menge gültiger Checks finden. Wir übergeben den Clifford-Payload-Circuit als Eingabe, zusammen mit einer Liste von Payload-Qubits, auf denen Checks hinzugefügt werden sollen (Target-Qubits), und dem Rauschmodell, das wir im vorherigen Schritt erstellt haben. In diesem Beispiel fügen wir Checks zu allen 7 Target-Qubits mit verfügbaren Ancillas hinzu. Checks werden den Qubits in der Reihenfolge hinzugefügt, in der sie in target_qubits erscheinen, was es leicht macht, das endgültige Layout des geprüften Circuits nachzuvollziehen: final_layout = layout + ancilla_qubits. Wenn jemand einen der Ausgabe-Circuits lieber mit weniger (i) Checks ausführen möchte, wäre das endgültige Layout final_layout = layout + ancilla_qubits[:i+1].
Die Circuit-Visualisierung zeigt, dass die Checks mit den angegebenen Target-/Ancilla-Paaren korrekt implementiert wurden.
Details zu qiskit_paulice.add_pauli_checks:
- Die erste Eingabe sollte ein Clifford-Circuit sein. Hier enthält er keine Messungen und wir übergeben
stabilizers="all", sodass die Checks für die Stabilisatoren des Zustands gefunden werden, den der Circuit präpariert: Ein Check ist gültig, wenn sein Spacetime-Pauli-Produkt einen solchen Stabilisator misst, sein Syndrom allein aus seinem Ancilla-Bit besteht und er unabhängig davon gültig bleibt, in welcher Basis der Payload anschließend gemessen wird. Das ist für eine Fidelity-Schätzung erforderlich, da jeder gesampelte Stabilisator in einer anderen Basis gemessen wird. (Wirdstabilizersweggelassen, werden stattdessen Checks für die abschließenden Messungen eines Circuits gefunden.) - Das zweite Positionsargument ist eine Liste von Target-Qubit-Indizes. Jedes Target-Qubit teilt sich verschränkende Gates mit einem Ancilla-Qubit, um einen Check zu bilden. Checks werden in der Reihenfolge gefunden und zum Circuit hinzugefügt, in der sie in diesem Argument angegeben sind.
- Das letzte Positionsargument ist ein Rauschmodell, das das Rauschen darstellt, das den Circuit bei der Ausführung voraussichtlich beeinflusst. Dieses Modell wird verwendet, um die Wirksamkeit eines bestimmten Checks zu bewerten.
ancilla_qubits: Wenn der Eingabe-Circuit ein Layout hat, teilt dieses Keyword-Argumentadd_pauli_checksmit, wie physische Qubits neu hinzugefügten Ancillas zugewiesen werden.ancilla_qubitssollte dieselbe Länge wietarget_qubits(2. Positionsargument) haben, undancilla_qubits[i]sollte zur Implementierung von Checks auftarget_qubits[i]verwendet werden.cost: Die Kostenfunktion zur Bewertung von Checks. KanngammaoderLER(logische Fehlerrate) sein, was alles Möglichkeiten sind zu beschreiben, wie viel des Rauschkanals von den Checks unentdeckt bleibt. Mitstabilizers="all"zählt ein Fehler als logisch, wenn er den präparierten Zustand verändert, was genau das ist, was die Fidelity misst.method: Die Methode zum Finden gültiger Checks. Wir verwendenwindowed, das zufällig viele räumliche Fenster-Teilmengen des Supports (der Wires) eines Target-Qubits samplet. Weitere gültige Werte sindgeneticundwindowed_genetic.- Die Ausgabe von
add_pauli_checksenthält Circuits mit zunehmender Anzahl von Checks, von einem Circuit ohne Checks bis zu einem Circuit mit Checks auf jedem Target-Qubit (sofern gültige Checks gefunden werden).
Siehe Abschnitte II-IV der ergänzenden Informationen für detaillierte Informationen zum Finden guter Spacetime-Pauli-Checks
import matplotlib.pyplot as plt
from qiskit_paulice import add_pauli_checks
# Find checks on circuit
checked = add_pauli_checks(
circuit_isa,
target_qubits,
noise_model,
ancilla_qubits=ancilla_qubits,
cost="gamma",
method="windowed",
seed=seed,
stabilizers="all",
)
print(f"Physical layout of payload+ancillas: {layout + ancilla_qubits}")
print("Checked circuit:")
checked[-1].circuit.draw("mpl", fold=-1, idle_wires=False)
Physical layout of payload+ancillas: [68, 69, 78, 89, 90, 91, 98, 111, 112, 113, 119, 133, 67, 70, 88, 92, 110, 114, 132]
Checked circuit:

6. Zu messende Stabilisatoren bestimmen
Um die Leistung der Fehlererkennung zu testen, schätzen wir die Fidelity des Stabilisatorzustands , den der Circuit idealerweise präpariert, gegenüber dem verrauschten Zustand , den die Hardware tatsächlich ausgibt. Der Projektor auf den reinen Stabilisatorzustand ist definiert als der gleichverteilte Mittelwert über die Elemente seiner Stabilisatorgruppe :
Setzen wir das in die Fidelity-Gleichung ein, sehen wir, dass die Fidelity des verrauschten Zustands dem durchschnittlichen Erwartungswert aller Stabilisatoren bezüglich entspricht:
Bei größeren Problemen ist es nicht praktikabel, alle Stabilisatoren zu messen (oder auch nur aufzuzählen), weshalb die Fidelity oft durch Messung einer Zufallsstichprobe von Stabilisatoren geschätzt wird. Das gleichverteilte zufällige Ziehen von Stabilisatoren aus liefert einen erwartungstreuen Schätzer der Fidelity, ,
Für dieses Experiment machen wir den Circuit deutlich tiefer, um den Effekt des Gate-Rauschens zu verstärken, und fügen Checks zu jedem Qubit mit einem benachbarten Ancilla-Qubit hinzu. Es gibt zwar ausgefeiltere Methoden, Stabilisatoren zur Fidelity-Schätzung zu samplen, in diesem Beispiel samplen wir jedoch Stabilisatoren mit gleichverteilter Wahrscheinlichkeit.
from qiskit.quantum_info import Clifford, Pauli, PauliList
num_stabilizers = 100
num_shots = 1_000
num_checks = len(target_qubits)
depth = 72
circuit = random_clifford_circuit(num_qubits, depth, rng)
# Create all 2^N stabilizers
stabilizers = PauliList([Pauli("I" * num_qubits)])
for generator in (
Pauli(label) for label in Clifford(circuit).to_labels(mode="S")
):
stabilizers = stabilizers + stabilizers.compose(generator)
# Choose some random stabilizers from the set
keep = np.where(stabilizers.x.any(axis=1) | stabilizers.z.any(axis=1))[0]
chosen = np.random.default_rng(seed).choice(
keep, size=min(num_stabilizers, len(keep)), replace=False
)
stabilizers = stabilizers[chosen]
depth = circuit.depth(lambda x: x.operation.num_qubits == 2)
print(
f"Sampled {len(stabilizers)} stabilizers of {circuit.num_qubits}-qubit circuit with two-qubit depth of {depth}: {{{stabilizers[0]}, {stabilizers[1]}, ...}}"
)
Sampled 100 stabilizers of 12-qubit circuit with two-qubit depth of 72: {-YIIYZZZIZIYY, -XIXYIIZXYIZX, ...}
7. Gute Checks für den gesamten Zustand finden
Wir finden eine Menge von Checks für den tiefen Circuit genau wie in Schritt 5. Da die Checks nur ihre Ancillas auslesen, dient derselbe geprüfte Circuit für jeden gesampelten Stabilisator. Die zurückgegebenen Circuits messen nur die Check-Ancillas, daher fügen wir die Payload-Messungen im nächsten Schritt hinzu.
import time
# Transpile the deep payload without measurements and find one set of checks for the whole state
payload_isa = pm.run(circuit)
t0 = time.time()
checked = add_pauli_checks(
payload_isa,
target_qubits,
noise_model,
ancilla_qubits=ancilla_qubits,
cost="gamma",
method="windowed",
seed=seed,
stabilizers="all",
)
print(
f"Added {len(checked[-1].check_qubits)} checks in {time.time() - t0:.0f}s."
)
print(
f"2-qubit depth increased from {payload_isa.depth(lambda x: len(x.qubits) == 2)} to "
f"{checked[-1].circuit.depth(lambda x: len(x.qubits) == 2)} when adding {num_checks} checks."
)
Added 7 checks in 3s.
2-qubit depth increased from 72 to 85 when adding 7 checks.
8. Aus dem verrauschten Circuit samplen
Wir samplen jede Variante, vom ungeprüften Payload bis zum Circuit mit allen 7 Checks, für jeden gesampelten Stabilisator. Die Variante mit null Checks ist der ungeprüfte Payload mit denselben Messungen und dient daher als verrauschte Referenz. Anstatt die Basisrotation jedes Stabilisators an eine Kopie jeder Variante anzuhängen, lassen wir samplomatic die Messbasis zur Sampling-Zeit wählen. Für jede Variante gehen wir wie folgt vor:
-
Den Payload messen und die abschließenden Messungen mit dem Boxing-Pass-Manager von samplomatic in einer Box gruppieren. Die Box trägt eine
ChangeBasis-Annotation, sodassbuildeinen Template-Circuit zurückgibt, dessen basiswechselnde Gates parametrisiert sind, zusammen mit einem Samplex, der die Parameter für eine angeforderte Basis berechnet. -
Das Template und den Samplex zu einem
QuantumProgramhinzufügen. Der Samplex erwartet pro Qubit in der Box eine Messbasis, in aufsteigender physischer Reihenfolge, kodiert als , , , . Die Übergabe eines Arrays von Basen mit einer führenden Achse der Länge100lässt ein einzelnes Item alle100Stabilisatoren messen: den Payload in der Basis des jeweiligen Stabilisators und die Ancillas in . Ein Qubit außerhalb des Supports eines Stabilisators wird ebenfalls in gemessen, und sein Bit wird bei der Berechnung des Erwartungswerts des Stabilisators ignoriert.
Das Executor-Primitive aus qiskit-ibm-runtime führt das gesamte Programm in einem Job aus. Wenn man ihm anstelle eines QPU einen AerSimulator übergibt, läuft es lokal auf diesem Simulator. Wir übergeben ihm daher einen mit demselben depolarisierenden Gate-Rauschen, das wir zum Finden der Checks verwendet haben, und nutzen die Stabilisator-Methode, da alle Circuits Clifford-Circuits sind. Qiskit Aer wird nur benötigt, um diesen Simulator zu bauen: Der lokale Modus übernimmt den Seed aus den Executor-Optionen, ignoriert aber deren noise_model, sodass das Rauschen auf dem Simulator selbst gesetzt werden muss. Würde man stattdessen ein QPU-Backend übergeben, würde dasselbe Programm auf Hardware laufen. Sobald die Simulationen abgeschlossen sind, berechnen wir die Postselektionsrate und die Erwartungswerte, um ein Gefühl dafür zu bekommen, wie gut die Fehlererkennung funktioniert hat, bevor wir die Daten visualisieren.
from qiskit import ClassicalRegister
from qiskit_aer import AerSimulator
from qiskit_aer.noise import NoiseModel as AerNoiseModel
from qiskit_aer.noise import depolarizing_error
from qiskit_ibm_runtime import Executor
from qiskit_ibm_runtime.quantum_program import QuantumProgram
from samplomatic.builders import build
from samplomatic.transpiler import generate_boxing_pass_manager
# Group the terminal measurements into a box whose measurement basis is chosen at sampling time
boxing_pm = generate_boxing_pass_manager(
enable_gates=False, measure_annotations="change_basis"
)
# bases[i, q]: basis in which physical qubit q is measured for stabilizer i (I=0, Z=1, X=2, Y=3)
bases = np.ones((len(stabilizers), backend.num_qubits), dtype=np.uint8)
bases[:, layout] = stabilizers.z + 2 * stabilizers.x
program = QuantumProgram(shots=num_shots)
for variant in checked:
circ = variant.circuit.copy()
circ.add_register(meas := ClassicalRegister(num_qubits, "meas"))
circ.measure(layout, meas)
template, samplex = build(boxing_pm.run(circ))
(basis_input,) = samplex.inputs().get_specs()
measured_qubits = sorted(layout + list(variant.check_qubits))
program.append_samplex_item(
template,
samplex=samplex,
samplex_arguments={basis_input.name: bases[:, measured_qubits]},
shape=(len(stabilizers),),
)
# Simulate the program with the same gate noise we used to find the checks
aer_nm = AerNoiseModel()
aer_nm.add_all_qubit_quantum_error(
depolarizing_error(noise_model.gate_noise, 2), ["cz"]
)
noisy_sim = AerSimulator(method="stabilizer", noise_model=aer_nm)
# outs[k][register]: bits of the variant with k checks, shape (stabilizers, shots, bits)
t0 = time.time()
outs = (
Executor(noisy_sim, options={"simulator": {"seed_simulator": seed}})
.run(program)
.result()
)
print(
f"Sampled {len(checked)} circuits for {len(stabilizers)} stabilizers in {time.time() - t0:.0f}s."
)
Sampled 8 circuits for 100 stabilizers in 77s.
9. Geprüfte Circuit-Samples nachselektieren
In diesem Paket wird ein Check mit verschränkenden Gates zwischen einem Ancilla-Qubit und einem Target-Qubit implementiert. Jede Ancilla startet in , sodass ihren Eingangszustand stabilisiert. Das Vorwärtspropagieren von vom Beginn der Ancilla durch den gesamten geprüften Circuit ergibt mal einen Stabilisator des präparierten Zustands, sodass der Check bestanden ist, wenn das Ancilla-Bit ist. Ein Sample wird behalten, wenn jeder Check bestanden wird.
Die geprüften Circuits messen die Ancillas in das Register checks_c, sodass ein Sample behalten wird, wenn jedes Bit dieses Registers 0 ist. Allgemein bildet die Methode get_postselection_method der von add_pauli_checks ausgegebenen CheckedCircuit-Instanzen den Bitstring eines Shots auf das Syndrom jedes Checks gleichzeitig ab; bei Checks, die für die Stabilisatoren des präparierten Zustands gefunden wurden, ist jedes Syndrom nur sein Ancilla-Bit, daher lesen wir das Register direkt aus.
Im Diagramm sehen wir, dass die Postselektionsrate sinkt, wenn mehr Checks hinzugefügt werden. Das Ziel der Fehlererkennung ist es zwar, so viele Fehler wie möglich zu erfassen, doch bei sinkender Postselektionsrate können mehr Shots nötig sein, um eine bestimmte Zielgenauigkeit zu erreichen. Sinkt die Postselektionsrate zu stark, kann die Anzahl der Shots, die zum Sammeln eines logischen Samples nötig sind, untragbar werden. In diesem Beispiel scheint die Postselektionsrate zu konvergieren, was bedeutet, dass zusätzliche Checks weniger zusätzliche Erkennungsfähigkeit beitragen. Obwohl das Hinzufügen weiterer Pauli-Checks im Allgemeinen eine größere Fehlererkennungsfähigkeit bietet, geht es mit erhöhter Circuit-Tiefe und höheren Sampling-Kosten einher.
def keep_mask(out):
"""Per-shot True/False: every ancilla read 0, so every Pauli check passed."""
if "checks_c" not in out: # the bare payload has no checks
return np.ones(out["meas"].shape[:-1], dtype=bool)
return ~out["checks_c"].any(axis=-1)
# masks[k][i]: kept shots of stabilizer i measured on the variant with k checks
masks = [keep_mask(out) for out in outs]
psrs_arr = np.array([mask.mean(axis=-1) for mask in masks]).T
ks = np.arange(psrs_arr.shape[1])
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(ks, psrs_arr.T, color="darkorange", alpha=0.15, linewidth=1)
ax.plot(
ks,
np.median(psrs_arr, axis=0),
color="black",
linewidth=1,
linestyle="--",
label="median",
)
ax.set(
xlabel="Checks committed",
ylabel="Post-selection rate",
ylim=(0, 1.05),
title=f"Per-stabilizer post-selection rate ({len(psrs_arr)} stabilizers)",
)
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

10. Fidelities der ursprünglichen und der nachselektierten verrauschten Zustände vergleichen
Wir sehen, dass die Nachselektion nur von Samples ohne erkannte Fehler den Erwartungswert für jeden Stabilisator stark verbessert und damit die geschätzte Fidelity des Stabilisatorzustands verbessert. Obwohl die nachselektierten Werte nur aus den Samples berechnet wurden, die jeden Check bestanden haben, sind die Erwartungswerte deutlich genauer und die Sampling-Varianz ist geringer. Wir können jetzt auch sehen, dass die Postselektionsrate oben tatsächlich gegen einen Wert konvergierte, der der Fidelity des verrauschten Circuits ähnelt. Die Postselektionsrate konvergierte, weil die Checks die überwiegende Mehrheit der tatsächlich während der Ausführung des Circuits auftretenden Fehler erfassten.
def expectation_values(out, mask):
"""EV of each stabilizer from the shots of ``out`` kept by ``mask``.
Bit q of ``meas`` is virtual qubit q, so a stabilizer's eigenvalue in a shot is the parity of
the bits in its support, times its sign.
"""
support = stabilizers.x | stabilizers.z
parity = (out["meas"] & support[:, None, :]).sum(axis=-1) % 2
signs = np.where(stabilizers.phase == 2, -1.0, 1.0)
eigenvalues = np.where(parity, -1.0, 1.0) * signs[:, None]
kept = mask.sum(axis=-1)
return np.where(
kept > 0,
(eigenvalues * mask).sum(axis=-1) / np.maximum(kept, 1),
np.nan,
)
evs_noisy = expectation_values(outs[0], masks[0])
evs_post = expectation_values(outs[-1], masks[-1])
print(
f"ideal fidelity: 1.0\n"
f"noisy fidelity: {np.nanmean(evs_noisy):.4f}\n"
f"postselected fidelity: {np.nanmean(evs_post):.4f}\n"
f"mean post-sel rate: {masks[-1].mean():.3f}\n"
)
evs_ideal = np.ones(len(stabilizers))
idx = np.arange(len(stabilizers))
def cum_mean_sem(ys):
valid = ~np.isnan(ys)
s = np.cumsum(np.where(valid, ys, 0.0))
s2 = np.cumsum(np.where(valid, ys**2, 0.0))
n = np.maximum(np.cumsum(valid).astype(float), 1)
mean = s / n
sem = np.sqrt(np.maximum(s2 / n - mean**2, 0) / n)
return np.where(np.cumsum(valid) > 0, mean, np.nan), sem
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 ± std"
)
ax.axhline(m, color=color, linewidth=1, linestyle="--")
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", "post-sel")
ax.scatter(
idx, evs_noisy, color="red", s=22, alpha=0.7, zorder=3, label="noisy <G>"
)
ax.scatter(
idx,
evs_post,
color="green",
s=22,
alpha=0.7,
zorder=3,
label="post-sel <G>",
)
ax.set(
xlabel="stabilizer index",
ylabel=r"$\langle G \rangle$",
ylim=(-0.1, 1.1),
title="Per-stabilizer EVs",
)
ax.legend(loc="lower left", ncol=2)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
M = np.arange(1, len(stabilizers) + 1)
fig, ax = plt.subplots(figsize=(8, 4))
for ys, color, label in [
(evs_ideal, "black", "ideal"),
(evs_noisy, "red", "noisy"),
(evs_post, "green", "post-sel"),
]:
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",
ylabel="running fidelity estimate",
title="Fidelity convergence vs. number of stabilizers",
)
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
ideal fidelity: 1.0
noisy fidelity: 0.5582
postselected fidelity: 0.9747
mean post-sel rate: 0.535

Da eine Menge von Checks für jeden Stabilisator dient, gibt es nur eine einzige Gamma-Kurve zu betrachten: den Wert der Kostenfunktion nach dem Festlegen jedes Checks. Bei anderen Problemen auf einer anderen Menge physischer Qubits und mit einem unvollkommenen Rauschmodell (denk daran, dass unser Rauschmodell in diesem Tutorial das Rauschen, das die Samples beeinflusst, exakt abbildete) kann die Reihenfolge, in der Checks zu den Target-Qubits hinzugefügt werden, einen stärkeren Einfluss darauf haben, wie die Kurve konvergiert.
Dass diese Gamma-Kurve gegen 1.0 konvergiert, bedeutet, dass zusätzliche Checks nicht in der Lage sein werden, viel zusätzlichen Fehler zu erkennen, da nur sehr wenig modellierter Fehler von den Checks unerfasst bleibt. Das bestärkt unsere frühere Beobachtung, dass die Konvergenz der Postselektionsrate auf einen Mangel an unerfasstem Fehler im Circuit zurückzuführen ist.
costs = [variant.cost for variant in checked]
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(range(len(costs)), costs, color="steelblue", marker="o")
ax.set(
xlabel="Checks committed",
ylabel="Gamma",
yscale="log",
title="Gamma of the noise left uncovered by the checks",
)
ax.grid(True, alpha=0.3, which="both")
plt.tight_layout()
plt.show()