Zum Hauptinhalt springen

Fehlererkennung mit geringem Overhead durch Raumzeit-Codes

Schätzung der Laufzeit: 10 Sekunden auf einem Heron-r3-Prozessor (HINWEIS: Dies ist nur eine Schätzung. Deine tatsächliche Laufzeit kann abweichen.)

Einführung

Low-overhead error detection with spacetime codes [1] von Simon Martiel und Ali Javadi-Abhari schlägt vor, gewichtsarme, konnektivitätsbewusste Raumzeit-Prüfungen für Clifford-dominierte Circuits zu synthetisieren und dann anhand dieser Prüfungen durch Post-Selektion Fehler abzufangen – mit weit weniger Overhead als bei der vollständigen Fehlerkorrektur und mit weniger Shots als bei der Standardfehlerminderung.

Diese Arbeit schlägt eine neuartige Methode zur Fehlererkennung in Quantencircuits (insbesondere Clifford-Circuits) vor, die eine Balance zwischen vollständiger Fehlerkorrektur und leichtgewichtigeren Minderungstechniken herstellt. Die Kernidee besteht darin, Raumzeit-Codes zu verwenden, um Prüfungen über den Circuit hinweg zu erzeugen, die in der Lage sind, Fehler zu erkennen, mit deutlich geringerem Qubit- und Gate-Overhead als bei der vollständigen fehlertoleranten Fehlerkorrektur. Die Autoren entwickeln effiziente Algorithmen, um Prüfungen auszuwählen, die gewichtsarm (wenige Qubits beteiligend), kompatibel mit der physikalischen Konnektivität des Geräts und in der Lage sind, große zeitliche und räumliche Bereiche des Circuits abzudecken. Sie demonstrieren den Ansatz an Circuits mit bis zu 50 logischen Qubits und ~2450 CZ-Gates und erzielen Treue-Verbesserungen von physikalisch zu logisch von bis zu 236×. Es ist auch zu beachten, dass mit zunehmenden Nicht-Clifford-Operationen die Anzahl gültiger Prüfungen exponentiell abnimmt, was zeigt, dass die Methode am besten für Clifford-dominierte Circuits funktioniert. Insgesamt bietet die Fehlererkennung über Raumzeit-Codes kurzfristig einen praktischen, weniger aufwändigen Weg zur Verbesserung der Zuverlässigkeit von Quantenhardware.

Diese Fehlererkennungstechnik stützt sich auf das Konzept kohärenter Pauli-Prüfungen und basiert auf der Arbeit Single-shot error mitigation by coherent Pauli checks [2] von van den Berg et al.

Jüngst berichtet die Arbeit Big cats: entanglement in 120 qubits and beyond [3] von Javadi-Abhari et al. von der Erzeugung eines 120-Qubit-Greenberger-Horne-Zeilinger-(GHZ-)Zustands, des bislang größten mehrpartigen verschränkten Zustands auf einer supraleitenden Qubit-Plattform. Mit einem hardwarebewussten Compiler, Fehlererkennung mit geringem Overhead und einer „temporären Unberechnung"-Technik zur Rauschreduzierung erzielten die Forscher eine Treue von 0,56 ± 0,03 bei einer Post-Selektionseffizienz von etwa 28 %. Die Arbeit demonstriert echte Verschränkung über alle 120 Qubits hinweg, validiert mehrere Treuezertifizierungsmethoden und setzt einen wichtigen Maßstab für skalierbare Quantenhardware.

Dieses Tutorial baut auf diesen Ideen auf und führt dich durch die Implementierung des Fehlererkennungsalgorithmus – zunächst auf einem kleinen zufälligen Clifford-Circuit und anschließend durch die Aufgabe der GHZ-Zustandsvorbereitung – um dir zu helfen, Fehlererkennung auf deinen eigenen Quantencircuits auszuprobieren.

Voraussetzungen

Stelle vor dem Start dieses Tutorials sicher, dass Folgendes installiert ist:

  • Qiskit SDK v2.0 oder höher, mit Visualisierungsunterstützung
  • Qiskit Runtime v0.40 oder höher (pip install qiskit-ibm-runtime)
  • Qiskit Aer v0.17.2 (pip install qiskit-aer)
  • Qiskit Device Benchmarking (pip install "qiskit-device-benchmarking @ git+https://github.com/qiskit-community/qiskit-device-benchmarking.git")
  • NumPy v2.3.2 (pip install numpy)
  • Matplotlib v3.10.7 (pip install matplotlib)

Setup

# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-aer qiskit-device-benchmarking qiskit-ibm-runtime
# Standard library imports
from collections import defaultdict, deque
from functools import partial

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

# Qiskit
from qiskit import ClassicalRegister, QuantumCircuit
from qiskit.circuit import Delay
from qiskit.circuit.library import RZGate, XGate
from qiskit.converters import circuit_to_dag, dag_to_circuit
from qiskit.quantum_info import Pauli, random_clifford
from qiskit.transpiler import AnalysisPass, PassManager
from qiskit.transpiler.passes import (
ALAPScheduleAnalysis,
CollectAndCollapse,
PadDelay,
PadDynamicalDecoupling,
RemoveBarriers,
)
from qiskit.transpiler.passes.optimization.collect_and_collapse import (
collect_using_filter_function,
collapse_to_operation,
)
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit.visualization import plot_histogram

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

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

# Qiskit Device Benchmarking
from qiskit_device_benchmarking.utilities.gate_map import plot_gate_map

Erstes Beispiel

Um diese Methode zu demonstrieren, beginnen wir mit der Konstruktion eines einfachen Clifford-Circuits. Unser Ziel ist es, erkennen zu können, wenn bestimmte Fehlertypen in diesem Circuit auftreten, sodass wir fehlerhafte Messergebnisse verwerfen können. In der Fehlererkennungsterminologie wird dies auch als unser Nutzlast-Circuit bezeichnet.

circ = random_clifford(num_qubits=2, seed=11).to_circuit()
circ.draw("mpl")

Ausgabe der vorherigen Code-Zelle

Unser Ziel ist es, eine kohärente Pauli-Prüfung in diesen Nutzlast-Circuit einzufügen. Bevor wir das tun, unterteilen wir diesen Circuit in Schichten. Das wird später beim Einfügen von Pauli-Gates zwischen den Schichten nützlich sein.

# Separate circuit into layers
dag = circuit_to_dag(circ)
circ_layers = []
for layer in dag.layers():
layer_as_circuit = dag_to_circuit(layer["graph"])
circ_layers.append(layer_as_circuit)

# Create subplots
fig, (ax1, ax2, ax3, ax4, ax5) = plt.subplots(1, 5, figsize=(10, 4))

# Draw circuits on respective axes
circ_layers[0].draw(output="mpl", ax=ax1)
circ_layers[1].draw(output="mpl", ax=ax2)
circ_layers[2].draw(output="mpl", ax=ax3)
circ_layers[3].draw(output="mpl", ax=ax4)
circ_layers[4].draw(output="mpl", ax=ax5)

# Adjust layout to prevent overlap
plt.tight_layout()
plt.show()

Ausgabe der vorherigen Code-Zelle

Jetzt sind wir bereit, kohärente Pauli-Prüfungen in den Nutzlast-Circuit einzufügen. Dazu müssen wir eine „gültige Prüfung" konstruieren und in den Circuit einfügen. Eine „Prüfung" ist in diesem Fall ein Operator, der signalisieren kann, ob ein Fehler im Circuit aufgetreten ist, indem er eine Messung auf einem Ancilla-Qubit vornimmt. Sie gilt als gültige Prüfung, wenn die zusätzlich in den Quantencircuit eingefügten Operatoren den ursprünglichen Circuit logisch nicht verändern.

Diese Prüfung ist in der Lage, Fehlertypen zu erkennen, die mit ihr antikommutieren. In diesem Fall löst die Prüfung durch Phasen-Kickback eine Messung des 1\ket{1}-Zustands im Ancilla-Qubit statt 0\ket{0} aus. Daher können wir Messungen, bei denen ein Fehler signalisiert wurde, verwerfen.

Im Allgemeinen sind kohärente Pauli-Prüfungen kontrollierte Pauli-Operatoren, die in „Leitungen" – Raumzeit-Positionen zwischen Gates – eingefügt werden. Das Ancilla-Qubit, das für die Fehlersignalisierung zuständig ist, ist das Steuer-Qubit.

Nachfolgend konstruieren wir eine gültige Prüfung für den zuvor erstellten Clifford-Circuit. Wir können zeigen, dass diese Prüfung die Circuit-Operation nicht verändert, indem wir demonstrieren, dass sich diese Pauli-Prüfungen beim Weiterpropagieren an den Anfang des Circuits gegenseitig aufheben. Dies lässt sich leicht zeigen, da ein Pauli-Operator durch ein Clifford-Gate zu einem anderen Pauli-Operator wird.

Im Allgemeinen kann man eine Dekodierheuristik wie in [1] beschrieben verwenden, um gültige Prüfungen zu identifizieren. Für unser anfängliches Beispiel können wir gültige Prüfungen auch durch analytische Pauli- und Clifford-Gate-Multiplikationsbedingungen konstruieren.

# Define a valid check
pauli_1 = Pauli("ZI")
pauli_2 = Pauli("XZ")
circ_1 = circ_layers[0].compose(circ_layers[1])
circ_1.draw("mpl")

Ausgabe der vorherigen Code-Zelle

pauli_1_ev = pauli_1.evolve(circ_1, frame="h")
pauli_1_ev
Pauli('-ZI')
circ_2 = circ.copy()
circ_2.draw("mpl")

Ausgabe der vorherigen Code-Zelle

pauli_2_ev = pauli_2.evolve(circ_2, frame="h")
pauli_2_ev
Pauli('-ZI')
pauli_1_ev.dot(pauli_2_ev)
Pauli('II')

Wie wir sehen, haben wir eine gültige Prüfung, da die eingefügten Pauli-Operatoren denselben Effekt wie ein Identitätsoperator auf den Circuit haben. Wir können diese Prüfungen jetzt mit einem Ancilla-Qubit in den Circuit einfügen. Dieses Ancilla-Qubit, auch Prüf-Qubit genannt, startet im +\ket{+}-Zustand. Es enthält die kontrollierten Versionen der oben beschriebenen Pauli-Operationen und wird schließlich in der XX-Basis gemessen. Dieses Prüf-Qubit kann nun Fehler im Nutzlast-Circuit erfassen, ohne ihn logisch zu verändern. Das liegt daran, dass bestimmte Rauscharten im Nutzlast-Circuit den Zustand des Prüf-Qubits verändern und es im Fehlerfall als „1" statt „0" gemessen wird.

# New circuit with 3 qubits (2 payload + 1 ancilla for check)
circ_meas = QuantumCircuit(3)
circ_meas.h(0)
circ_meas.compose(circ_layers[0], [1, 2], inplace=True)
circ_meas.compose(circ_layers[1], [1, 2], inplace=True)
circ_meas.cz(0, 2)
circ_meas.compose(circ_layers[2], [1, 2], inplace=True)
circ_meas.compose(circ_layers[3], [1, 2], inplace=True)
circ_meas.compose(circ_layers[4], [1, 2], inplace=True)
circ_meas.cz(0, 1)
circ_meas.cx(0, 2)
circ_meas.h(0)

# Add measurement to payload qubits
c0 = ClassicalRegister(2, name="c0")
circ_meas.add_register(c0)
circ_meas.measure(1, c0[0])
circ_meas.measure(2, c0[1])

# Add measurement to check qubit
c1 = ClassicalRegister(1, name="c1")
circ_meas.add_register(c1)
circ_meas.measure(0, c1[0])

# Visualize the final circuit with the inserted checks
circ_meas.draw("mpl")

Ausgabe der vorherigen Code-Zelle

Wird das Prüf-Qubit als „0" gemessen, behalten wir diese Messung. Wird es als „1" gemessen, bedeutet das, dass im Nutzlast-Circuit ein Fehler aufgetreten ist, und wir verwerfen diese Messung.

# Noiseless simulation using stabilizer method
sim_stab = AerSimulator(method="stabilizer")
res = sim_stab.run(circ_meas, shots=1000).result()
counts_noiseless = res.get_counts()
print(f"Stabilizer simulation result: {counts_noiseless}")
Stabilizer simulation result: {'0 11': 523, '0 01': 477}
# Plot the noiseless results
# Note that the first bit in the key corresponds to the check qubit
plot_histogram(counts_noiseless)

Ausgabe der vorherigen Code-Zelle

Mit einem idealen Simulator erkennt das Prüf-Qubit keine Fehler. Wir führen jetzt ein Rauschmodell in die Simulation ein und beobachten, wie das Prüf-Qubit Fehler erfasst.

# Qiskit Aer noise model
noise = NoiseModel()
p2 = 0.003 # two-qubit depolarizing per CZ
p1 = 0.001 # one-qubit depolarizing per 1q Clifford
pr = 0.01 # readout bit-flip probability

# 1q depolarizing on common 1q gates
e1 = depolarizing_error(p1, 1)
for g1 in ["id", "rz", "sx", "x", "h", "s"]:
noise.add_all_qubit_quantum_error(e1, g1)

# 2q depolarizing on CZ
e2 = depolarizing_error(p2, 2)
noise.add_all_qubit_quantum_error(e2, "cz")

# Readout error on measure
ro = ReadoutError([[1 - pr, pr], [pr, 1 - pr]])
noise.add_all_qubit_readout_error(ro)

# Qiskit Aer simulation with noise model
aer = AerSimulator(method="automatic", seed_simulator=43210)
job = aer.run(circ_meas, shots=1000, noise_model=noise)
result = job.result()
counts_noisy = result.get_counts()
print(f"Noise model simulation result: {counts_noisy}")
Noise model simulation result: {'1 01': 5, '0 11': 478, '1 11': 6, '1 00': 2, '1 10': 1, '0 01': 500, '0 00': 5, '0 10': 3}
plot_histogram(counts_noisy)

Ausgabe der vorherigen Code-Zelle

Wie wir sehen, haben einige Messungen den Fehler erkannt, indem das Prüf-Qubit als „1" markiert wurde – diese sind in den letzten vier Spalten sichtbar. Diese Shots werden verworfen. Hinweis: Das Ancilla-Qubit kann auch neue Fehler in den Circuit einbringen. Um diesen Effekt zu verringern, können wir verschachtelte Prüfungen mit zusätzlichen Ancilla-Qubits in den Quantencircuit einfügen.

Praxisbeispiel: GHZ-Zustand auf echter Hardware vorbereiten

Schritt 1: Klassische Eingaben auf ein Quantenproblem abbilden

Jetzt demonstrieren wir eine bedeutende Aufgabe für Quantencomputeralgorithmen: die Vorbereitung eines GHZ-Zustands. Wir zeigen, wie dies auf einem echten Backend unter Verwendung von Fehlererkennung durchgeführt wird.

# Set optional seed for reproducibility
SEED = 1

if SEED:
np.random.seed(SEED)

Der Fehlererkennungsalgorithmus für die GHZ-Zustandsvorbereitung berücksichtigt die Hardware-Topologie. Wir beginnen mit der Auswahl der gewünschten Hardware.

# This is used to run on real hardware
service = QiskitRuntimeService()

# Choose a backend to build GHZ on
backend_name = service.least_busy(
operational=True, simulator=False, min_num_qubits=133
)

backend = service.backend(backend_name)
coupling_map = backend.target.build_coupling_map()

Ein GHZ-Zustand auf nn Qubits ist definiert als GHZn  =  12(0n+1n).\lvert \mathrm{GHZ}_n\rangle \;=\; \frac{1}{\sqrt{2}}\Big(\lvert 0\rangle^{\otimes n} \,+\, \lvert 1\rangle^{\otimes n}\Big).

Ein sehr naiver Ansatz zur Vorbereitung des GHZ-Zustands wäre, ein Wurzel-Qubit mit einem anfänglichen Hadamard-Gate zu wählen, das das Qubit in einen gleichmäßigen Superpositionszustand versetzt, und dann dieses Qubit mit jedem anderen Qubit zu verschränken. Dies ist kein guter Ansatz, da er weitreichende und tiefe CNOT-Wechselwirkungen erfordert. In diesem Tutorial verwenden wir mehrere Techniken zusammen mit Fehlererkennung, um den GHZ-Zustand zuverlässig auf echter Hardware vorzubereiten.

Schritt 2: Problem für die Ausführung auf Quantenhardware optimieren

Den GHZ-Zustand auf Hardware abbilden

Zunächst suchen wir nach einer Wurzel, von der aus wir den GHZ-Circuit auf Hardware abbilden. Wir entfernen Kanten/Knoten, deren CZ-Fehler, Messfehler und T2-Werte schlechter als die nachfolgenden Schwellenwerte sind. Diese werden nicht in den GHZ-Circuit einbezogen.

def bad_cz(target, threshold=0.01):
"""Return list of edges whose CZ error is worse than threshold."""
undirected_edges = []
for edge in backend.target.build_coupling_map().get_edges():
if (edge[1], edge[0]) not in undirected_edges:
undirected_edges.append(edge)
edges = undirected_edges
cz_errors = {}
for edge in edges:
cz_errors[edge] = target["cz"][edge].error
worst_edges = sorted(cz_errors.items(), key=lambda x: x[1], reverse=True)
return [list(edge) for edge, error in worst_edges if error > threshold]

def bad_readout(target, threshold=0.01):
"""Return list of nodes whose measurement error is worse than threshold."""
meas_errors = {}
for node in range(backend.num_qubits):
meas_errors[node] = target["measure"][(node,)].error
worst_nodes = sorted(
meas_errors.items(), key=lambda x: x[1], reverse=True
)
return [node for node, error in worst_nodes if error > threshold]

def bad_coherence(target, threshold=60):
"""Return list of nodes whose T2 value is lower than threshold."""
t2s = {}
for node in range(backend.num_qubits):
t2 = target.qubit_properties[node].t2
t2s[node] = t2 * 1e6 if t2 else 0
worst_nodes = sorted(t2s.items(), key=lambda x: x[1])
return [node for node, val in worst_nodes if val < threshold]

THRESH_CZ = 0.025 # exclude from BFS those edges whose CZ error is worse than this threshold
THRESH_MEAS = 0.15 # exclude from BFS those nodes whose measurement error is worse than this threshold
THRESH_T2 = 10 # exclude from BFS those nodes whose T2 value is lower than this threshold

bad_edges = bad_cz(backend.target, threshold=THRESH_CZ)
bad_nodes_readout = bad_readout(backend.target, threshold=THRESH_MEAS)
dead_qubits = bad_readout(backend.target, threshold=0.4)
bad_nodes_coherence = bad_coherence(backend.target, threshold=THRESH_T2)
bad_nodes = list(set(bad_nodes_readout) | set(bad_nodes_coherence))
print(f"{len(bad_edges)} bad edges: \n{bad_edges}")
print(f"{len(bad_nodes)} bad nodes: \n{bad_nodes}")
17 bad edges: 
[[30, 31], [112, 113], [113, 114], [113, 119], [120, 121], [130, 131], [145, 146], [146, 147], [111, 112], [55, 59], [64, 65], [131, 138], [131, 132], [119, 133], [129, 130], [47, 57], [29, 38]]
5 bad nodes:
[1, 113, 131, 146, 120]

Mit der folgenden Funktion konstruieren wir den GHZ-Circuit auf der gewählten Hardware, ausgehend von der Wurzel und mithilfe einer Breitensuche (BFS).

def parallel_ghz(root, num_qubits, backend, bad_edges, skip):
"""
Build a GHZ state of size `num_qubits` on the given `backend`,
starting from `root`, expanding in BFS order.

At each BFS layer, every active qubit adds at most one new neighbor
(so that two-qubit operations can run in parallel with no qubit conflicts).

It grows the entanglement tree outward layer-by-layer.
"""

# -------------------------------------------------------------
# (1) Filter usable connections from the backend coupling map
# -------------------------------------------------------------
# The coupling map lists all directed hardware connections as (control, target).
# We remove edges that are:
# - listed in `bad_edges` (or their reversed form)
# - involve a qubit in the `skip` list
cmap = backend.configuration().coupling_map
edges = [
e
for e in cmap
if e not in bad_edges
and [e[1], e[0]] not in bad_edges
and e[0] not in skip
and e[1] not in skip
]

# -------------------------------------------------------------
# (2) Build an undirected adjacency list for traversal
# -------------------------------------------------------------
# Even though coupling_map edges are directed, BFS expansion just needs
# connectivity information (so we treat edges as undirected for search).
adj = defaultdict(list)
for u, v in edges:
adj[u].append(v)
adj[v].append(u)

# -------------------------------------------------------------
# (3) Initialize the quantum circuit and BFS state
# -------------------------------------------------------------
n = backend.configuration().num_qubits
qc = QuantumCircuit(
n
) # create a circuit with same number of qubits as hardware
visited = [
root
] # record the order qubits are added to the GHZ chain/tree
queue = deque([root]) # BFS queue (start from root)
explored = defaultdict(
set
) # to track which neighbors each node has already explored
layers = [] # list of per-layer (control, target) gate tuples
qc.h(root) # GHZ states start with a Hadamard on the root qubit

# -------------------------------------------------------------
# (4) BFS expansion: build the GHZ tree one layer at a time
# -------------------------------------------------------------
# Loop until we’ve added the desired number of qubits to the GHZ
while queue and len(visited) < num_qubits:
layer = [] # collect new (control, target) pairs for this layer
current = list(
queue
) # snapshot current frontier (so queue mutations don’t affect iteration)
busy = (
set()
) # track qubits already used in this layer (to avoid conflicts)

for node in current:
queue.popleft()

# find one unvisited neighbor of this node not already explored
unvisited_neighbors = [
nb
for nb in adj[node]
if nb not in visited and nb not in explored[node]
]

if unvisited_neighbors:
nb = unvisited_neighbors[
0
] # pick the first available neighbor
visited.append(nb) # mark it as part of the GHZ structure
queue.append(
node
) # re-enqueue current node (can keep growing)
queue.append(nb) # enqueue the newly added qubit
explored[node].add(nb) # mark that edge as explored
layer.append(
(node, nb)
) # schedule a CNOT between node and neighbor
busy.update([node, nb]) # reserve both qubits for this layer

# stop early if we've reached the desired number of qubits
if len(visited) == num_qubits:
break
# else: node has no unused unvisited neighbors left → skip

if layer:
# add all pairs (node, nb) scheduled this round to layers
layers.append(layer)
else:
# nothing new discovered this pass → done
break

# -------------------------------------------------------------
# (5) Emit all layers into the quantum circuit
# -------------------------------------------------------------
# For each layer:
# - apply a CX gate for every (control, target) pair
# - insert a barrier so transpiler keeps layer structure
for layer in layers:
for q1, q2 in layer:
qc.cx(q1, q2)
qc.barrier()

# -------------------------------------------------------------
# (6) Return outputs
# -------------------------------------------------------------
# qc: the built quantum circuit
# visited: order of qubits added
# layers: list of parallelizable two-qubit operations per step
return qc, visited, layers

Wir suchen nun wiederholt nach der besten Wurzel, von der aus der GHZ-Circuit seinen Ursprung nimmt.

ROOT = None  # root for BFS search
GHZ_SIZE = 100 # number of (data) qubits in the GHZ state
SKIP = [] # nodes to intentionally skip so that we have a better chance for finding checks

# Search for the best root (yielding the shallowest GHZ)
if ROOT is None:
best_root = -1
base_depth = 100
for root in range(backend.num_qubits):
qc, ghz_qubits, _ = parallel_ghz(
root, GHZ_SIZE, backend, bad_edges, SKIP
)
if len(ghz_qubits) != GHZ_SIZE:
continue
depth = qc.depth(lambda x: x.operation.num_qubits == 2)
if depth < base_depth:
best_root = root
base_depth = depth
ROOT = best_root

Wir konstruieren nun den GHZ-Circuit ausgehend von einem bestimmten Knoten – der besten Wurzel – und suchen mithilfe der Breitensuche nach der geringsten Tiefe.

# Build a GHZ starting at the best root
qc, ghz_qubits, _ = parallel_ghz(
ROOT, GHZ_SIZE, backend, bad_edges, SKIP + bad_nodes
)
base_depth = qc.depth(lambda x: x.operation.num_qubits == 2)
base_count = qc.size(lambda x: x.operation.num_qubits == 2)
print(f"base depth: {base_depth}, base count: {base_count}")
print(f"ROOT: {ROOT}")
if len(ghz_qubits) != GHZ_SIZE:
raise Exception("No GHZ found. Relax error thresholds.")
base depth: 17, base count: 99
ROOT: 50

Wir brauchen eine letzte Überlegung, bevor wir gültige Prüfungen einfügen. Diese betrifft das Konzept der „Abdeckung" (Coverage), ein Maß dafür, wie viele der Leitungen in einem Quantencircuit eine Prüfung abdecken kann. Mit einer höheren Abdeckung können wir Fehler in einem größeren Teil des Circuits erkennen. Mit diesem Maß können wir unter den gültigen Prüfungen mit der höchsten Circuit-Abdeckung auswählen. Mit anderen Worten: Wir verwenden die Funktion weighted_coverage, um verschiedene Prüfungen für den GHZ-Circuit zu bewerten.

def weighted_coverage(layers, parities, w_idle=0.2, w_gate=0.8):
"""
Compute weighted fraction (idle + gate) of wires that are
covered by at least one parity to all active wires.
"""
wires = active_wires(layers) # defined below
covered_by_any = {n_layer: set() for n_layer in range(len(layers))}
for parity in parities:
trace = z_trace_backward(layers, parity) # defined below
for n_layer, qs in trace.items():
covered_by_any[n_layer] |= qs
covered_weight = 0
total_weight = 0
for n_layer in range(len(layers)):
idle = wires[n_layer]["idle"]
gate = wires[n_layer]["gate"]
total_weight += w_idle * len(idle) + w_gate * len(gate)
covered_idle = covered_by_any[n_layer] & idle
covered_gate = covered_by_any[n_layer] & gate
covered_weight += w_idle * len(covered_idle) + w_gate * len(
covered_gate
)
return covered_weight / total_weight if total_weight > 0 else 0

def active_wires(layers):
"""
Returns per-layer dict with two sets:
- 'idle': activated wires that are idle in this layer
- 'gate': activated wires that are control/target of a CNOT at this layer
"""
first_activation = {}
for n_layer, layer in enumerate(layers):
for c, t in layer:
first_activation.setdefault(c, n_layer)
first_activation.setdefault(t, n_layer)
result = {}
for n_layer in range(len(layers)):
active = {
q
for q, n_layer0 in first_activation.items()
if n_layer >= n_layer0
}
gate = {q for c, t in layers[n_layer] for q in (c, t)}
idle = active - gate
result[n_layer] = {"idle": idle, "gate": gate}
return result

def z_trace_backward(layers, initial_Zs):
"""
Backward propagate Zs with parity cancellation.
Returns {layer: set of qubits with odd parity Z at that layer}.
"""
wires = active_wires(layers)
support = set(initial_Zs)
trace = {}
for n_layer in range(len(layers) - 1, -1, -1):
active = wires[n_layer]["idle"] | wires[n_layer]["gate"]
trace[n_layer] = support & active
# propagate backwards
new_support = set()
for q in support:
hit = False
for c, t in layers[n_layer]:
if q == t: # Z on target: copy to control
new_support ^= {t, c} # toggle both
hit = True
break
elif q == c: # Z on control: passes through
new_support ^= {c}
hit = True
break
if not hit: # unaffected
new_support ^= {q}
support = new_support
return trace

Wir können jetzt Prüfungen in den GHZ-Circuit einfügen. Gültige Prüfungen zu finden ist für den GHZ-Zustand sehr praktisch, da jeder Zwei-Qubit-Pauli-ZZ-Operator ZiZjZ_i Z_j, der auf zwei beliebige Qubits i,ji,j des GHZ-Circuits wirkt, ein Träger und damit eine gültige Prüfung ist.

Beachte auch, dass die Prüfungen in diesem Fall kontrollierte ZZ-Operatoren sind, die links und rechts von Hadamard-Gates auf dem Ancilla-Qubit angeordnet sind. Dies entspricht einem CNOT-Gate, das auf das Ancilla-Qubit angewendet wird. Der folgende Code fügt die Prüfungen in den Circuit ein.

# --- Tunables controlling the search space / scoring ---
MAX_SKIPS = 10 # at most how many qubits to skip (in addition to the bad ones and the ones forced to skip above)
SHUFFLES = 200 # how many times to try removing nodes for checks
MAX_DEPTH_INCREASE = 10 # how far from the base GHZ depth to go to include checks (increase this for more checks at expense of depth)

W_IDLE = 0.2 # weight of errors to consider during idle timesteps
W_GATE = 0.8 # weight of errors to consider during gates

# Remove random nodes from the GHZ and build from the root again to increase checks
degree_two_nodes = [
i
for i in ghz_qubits
if all(n in ghz_qubits for n in coupling_map.neighbors(i))
and len(coupling_map.neighbors(i)) >= 2
]

# --- Best-so-far tracking for the randomized search ---
num_checks = 0
best_covered_fraction = -1
best_qc = qc
best_checks = []
best_parities = []
best_layers = []

# Outer loop: vary how many GHZ nodes we try skipping (0..MAX_SKIPS-1)
for num_skips in range(MAX_SKIPS):
# Inner loop: try SHUFFLES random choices of 'num_skips' nodes to skip
for _ in range(SHUFFLES):
# Construct the skip set:
# - pre-existing forced SKIP
# - plus a random sample of 'degree_two_nodes' of size 'num_skips'
skip = SKIP + list(np.random.choice(degree_two_nodes, num_skips))

# Rebuild the GHZ using the current skip set and bad_nodes
qc, ghz_qubits, layers = parallel_ghz(
ROOT, GHZ_SIZE, backend, bad_edges, skip + bad_nodes
)

# Measure circuit cost as 2-qubit-gate depth only
depth = qc.depth(lambda x: x.operation.num_qubits == 2)

# If we failed to reach the target GHZ size, discard this attempt
if len(ghz_qubits) != GHZ_SIZE:
continue

# --- Build "checks" around the GHZ we just constructed ---
# A check qubit is a non-GHZ, non-dead qubit that has ≥2 neighbors inside the GHZ
# and all those incident edges are usable (i.e., not in bad_edges).
checks = []
parities = []
for i in range(backend.num_qubits):
neighbors = [
n for n in coupling_map.neighbors(i) if n in ghz_qubits
]

if (
i not in ghz_qubits
and i not in dead_qubits
and len(neighbors) >= 2
and not any(
[
[neighbor, i] in bad_edges
or [i, neighbor] in bad_edges
for neighbor in neighbors
]
)
):
# Record this qubit as a check qubit
checks.append(i)
parities.append((neighbors[0], neighbors[1]))
# Physically couple the check qubit 'i' to the two GHZ neighbors via CNOTs
# (This is the actual "check" attachment in the circuit.)
qc.cx(neighbors[0], i)
qc.cx(neighbors[1], i)

# Score this design using the weighted coverage metric over the GHZ build layers
covered_fraction = weighted_coverage(
layers=layers, parities=parities, w_idle=W_IDLE, w_gate=W_GATE
)

# Keep it only if:
# - coverage improves over the best so far, AND
# - the 2q depth budget isn't blown by more than MAX_DEPTH_INCREASE
if (
covered_fraction > best_covered_fraction
and depth <= base_depth + MAX_DEPTH_INCREASE
):
best_covered_fraction = covered_fraction
best_qc = qc
best_ghz_qubits = ghz_qubits
best_checks = checks
best_parities = parities
best_layers = layers

Wir können nun die im GHZ-Circuit verwendeten Qubits und die Prüf-Qubits ausgeben.

# --- After search, report the best design found ---
qc = best_qc
checks = best_checks
parities = best_parities
layers = best_layers
ghz_qubits = best_ghz_qubits
if len(ghz_qubits) != GHZ_SIZE:
raise Exception("No GHZ found. Relax error thresholds.")

print(f"GHZ qubits: {ghz_qubits} {len(ghz_qubits)}")
print(f"Check qubits: {checks} {len(checks)}")

covered_fraction = weighted_coverage(
layers=layers, parities=parities, w_idle=W_IDLE, w_gate=W_GATE
)
print(
"Covered fraction (no idle): ",
weighted_coverage(
layers=layers, parities=parities, w_idle=0.0, w_gate=1.0
),
)
GHZ qubits: [50, 49, 51, 38, 52, 48, 58, 53, 47, 71, 39, 46, 70, 54, 33, 45, 72, 69, 55, 32, 37, 73, 68, 34, 31, 44, 25, 74, 78, 67, 18, 24, 79, 75, 89, 57, 11, 23, 93, 59, 88, 66, 10, 22, 92, 90, 87, 65, 12, 9, 21, 94, 91, 86, 77, 13, 8, 20, 95, 98, 97, 14, 7, 36, 99, 111, 107, 15, 6, 41, 115, 110, 106, 19, 17, 5, 40, 114, 109, 108, 105, 27, 4, 42, 118, 104, 28, 3, 129, 117, 103, 29, 2, 128, 125, 96, 30, 127, 124, 102] 100
Check qubits: [16, 26, 35, 43, 85, 126] 6
Covered fraction (no idle): 0.4595959595959596

Wir können auch einige Fehlerstatistiken ausgeben.

def circuit_errors(target, circ, error_type="cz"):
"""
Pull per-resource error numbers from a Qiskit Target
for ONLY the qubits/edges actually used by `circ`.

Args:
target: qiskit.transpiler.Target (e.g., backend.target)
circ: qiskit.QuantumCircuit
error_type: one of {"cz", "meas", "t1", "t2"}:
- "cz" -> 2q CZ gate error on the circuit's used edges
- "meas" -> measurement error on the circuit's used qubits
- "t1" -> T1 (converted to microseconds) on used qubits
- "t2" -> T2 (converted to microseconds) on used qubits

Returns:
list[float] of the requested quantity for the active edges/qubits.
"""

# Get all 2-qubit edges that appear in the circuit (as undirected pairs).
active_edges = active_gates(circ) # e.g., {(0,1), (2,3), ...}

# Intersect those with the device coupling map (so we only query valid edges).
# Note: target.build_coupling_map().get_edges() yields directed pairs.
edges = [
edge
for edge in target.build_coupling_map().get_edges()
if tuple(sorted(edge)) in active_edges
]

# Deduplicate direction: keep only one orientation of each edge.
undirected_edges = []
for edge in edges:
if (edge[1], edge[0]) not in undirected_edges:
undirected_edges.append(edge)
edges = undirected_edges # (not used later—see note below)

# Accumulators for different error/physics quantities
cz_errors, meas_errors, t1_errors, t2_errors = [], [], [], []

# For every active (undirected) edge in the circuit, fetch its CZ error.
# NOTE: Uses active_gates(circ) again (undirected tuples). This assumes
# `target['cz']` accepts undirected indexing; many Targets store both directions.
for edge in active_gates(circ):
cz_errors.append(target["cz"][edge].error)

# For every active qubit, fetch measure error and T1/T2 (converted to µs).
for qubit in active_qubits(circ):
meas_errors.append(target["measure"][(qubit,)].error)
t1_errors.append(
target.qubit_properties[qubit].t1 * 1e6
) # seconds -> microseconds
t2_errors.append(
target.qubit_properties[qubit].t2 * 1e6
) # seconds -> microseconds

# Select which set to return.
if error_type == "cz":
return cz_errors
elif error_type == "meas":
return meas_errors
elif error_type == "t1":
return t1_errors
else:
return t2_errors

def active_qubits(circ):
"""
Return a list of qubit indices that participate in at least one
non-delay, non-barrier instruction in `circ`.
"""
active_qubits = set()
for inst in circ.data:
# Skip scheduling artifacts that don’t act on state
if (
inst.operation.name != "delay"
and inst.operation.name != "barrier"
):
for qubit in inst.qubits:
q = circ.find_bit(
qubit
).index # map Qubit object -> integer index
active_qubits.add(q)
return list(active_qubits)

def active_gates(circ):
"""
Return a set of undirected 2-qubit edges (i, j) that appear in `circ`.
"""
used_2q_gates = set()
for inst in circ:
if inst.operation.num_qubits == 2:
qs = inst.qubits
# map Qubit objects -> indices, then sort to make the edge undirected
qs = sorted([circ.find_bit(q).index for q in qs])
used_2q_gates.add(tuple(sorted(qs)))
return used_2q_gates

# ---- Print summary statistics ----
cz_errors = circuit_errors(backend.target, qc, error_type="cz")
meas_errors = circuit_errors(backend.target, qc, error_type="meas")
t1_errors = circuit_errors(backend.target, qc, error_type="t1")
t2_errors = circuit_errors(backend.target, qc, error_type="t2")

np.set_printoptions(linewidth=np.inf)
print(
f"cz errors: \n mean: {np.round(np.mean(cz_errors), 3)}, max: {np.round(np.max(cz_errors), 3)}"
)
print(
f"meas errors: \n mean: {np.round(np.mean(meas_errors), 3)}, max: {np.round(np.max(meas_errors), 3)}"
)
print(
f"t1 errors: \n mean: {np.round(np.mean(t1_errors), 1)}, min: {np.round(np.min(t1_errors), 1)}"
)
print(
f"t2 errors: \n mean: {np.round(np.mean(t2_errors), 1)}, min: {np.round(np.min(t2_errors), 1)}"
)
cz errors: 
mean: 0.002, max: 0.012
meas errors:
mean: 0.014, max: 0.121
t1 errors:
mean: 267.9, min: 23.6
t2 errors:
mean: 155.9, min: 13.9

Wie zuvor können wir den Circuit zunächst ohne Rauschen simulieren, um die Korrektheit des GHZ-Zustandsvorbereitungs-Circuits sicherzustellen.

# --- Simulate to ensure correctness ---

qc_meas = qc.copy()

# Add measurements to the GHZ qubits
c1 = ClassicalRegister(len(ghz_qubits), "c1")
qc_meas.add_register(c1)
for q, c in zip(ghz_qubits, c1):
qc_meas.measure(q, c)

# Add measurements to the check qubits
if len(checks) > 0:
c2 = ClassicalRegister(len(checks), "c2")
qc_meas.add_register(c2)
for q, c in zip(checks, c2):
qc_meas.measure(q, c)

# Simulate the circuit with stabilizer method
sim_stab = AerSimulator(method="stabilizer")
res = sim_stab.run(qc_meas, shots=1000).result()
counts = res.get_counts()
print("Stabilizer simulation result:")
print(counts)

# Rename keys to "0 0" and "0 1" for easier plotting
# First len(checks) bits are check bits, rest are GHZ bits
keys = list(counts.keys())
for key in keys:
check_bits = key[: len(checks)]
ghz_bits = key[(len(checks) + 1) :]
if set(check_bits) == {"0"} and set(ghz_bits) == {"0"}:
counts["0 0"] = counts.pop(key)
elif set(check_bits) == {"0"} and set(ghz_bits) == {"1"}:
counts["0 1"] = counts.pop(key)
else:
continue

plot_histogram(counts)
Stabilizer simulation result:
{'000000 1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111': 525, '000000 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000': 475}

Ausgabe der vorherigen Code-Zelle

Wie erwartet werden die Prüf-Qubits alle als null gemessen, und wir haben erfolgreich den GHZ-Zustand vorbereitet.

Schritt 3: Mit Qiskit-Primitives ausführen

Jetzt sind wir bereit, den Circuit auf echter Hardware auszuführen und zu demonstrieren, wie das Fehlererkennungsprotokoll Fehler bei der GHZ-Zustandsvorbereitung erfassen kann.

BAD_QUBITS = []  # specify any additional bad qubits to avoid (this is specific to the chosen backend)
SHOTS = 10000 # number of shots

Wir definieren eine Hilfsfunktion, um Messungen zum GHZ-Circuit hinzuzufügen.

def add_measurements(qc, ghz_qubits, checks):
# --- Measure each set of qubits into different classical registers to facilitate post-processing ---

# Add measurements to the GHZ qubits
c1 = ClassicalRegister(len(ghz_qubits), "c1")
qc.add_register(c1)
for q, c in zip(ghz_qubits, c1):
qc.measure(q, c)

# Add measurements to the check qubits
c2 = ClassicalRegister(len(checks), "c2")
qc.add_register(c2)
for q, c in zip(checks, c2):
qc.measure(q, c)

return qc

Bevor wir ausführen, zeichnen wir das Layout der GHZ-Qubits und der Prüf-Qubits auf der ausgewählten Hardware.

# Plot the layout of GHZ and check qubits on the device
plot_gate_map(
backend,
label_qubits=True,
line_width=20,
line_color=[
"black"
if edge[0] in ghz_qubits + checks and edge[1] in ghz_qubits + checks
else "lightgrey"
for edge in backend.coupling_map.graph.edge_list()
],
qubit_color=[
"blue"
if i in ghz_qubits
else "salmon"
if i in checks
else "lightgrey"
for i in range(0, backend.num_qubits)
],
)
plt.show()

Ausgabe der vorherigen Code-Zelle

qc.draw("mpl", idle_wires=False, fold=-1)

Ausgabe der vorherigen Code-Zelle

Wir fügen nun die Messungen hinzu.

qc = add_measurements(qc, ghz_qubits, checks)

Die Scheduling-Pipeline unten legt das Timing fest, entfernt Barrieren, vereinfacht Verzögerungen und fügt Dynamical Decoupling ein – und zwar so, dass die ursprünglichen Operationszeiten erhalten bleiben.

# The scheduling consists of first inserting delays while barriers are still there
# Then removing the barriers and consolidating the delays, so that the operations do not move in time
# Lastly we replace delays with dynamical decoupling
collect_function = partial(
collect_using_filter_function,
filter_function=(lambda node: node.op.name == "delay"),
split_blocks=True,
min_block_size=2,
split_layers=False,
collect_from_back=False,
max_block_width=None,
)

collapse_function = partial(
collapse_to_operation,
collapse_function=(
lambda circ: Delay(sum(inst.operation.duration for inst in circ))
),
)

class Unschedule(AnalysisPass):
"""Removes a property from the passmanager property set so that the circuit looks unscheduled, so we can schedule it again."""

def run(self, dag):
del self.property_set["node_start_time"]

def build_passmanager(backend, dd_qubits=None):
pm = generate_preset_pass_manager(
target=backend.target,
layout_method="trivial",
optimization_level=2,
routing_method="none",
)

pm.scheduling = PassManager(
[
ALAPScheduleAnalysis(target=backend.target),
PadDelay(target=backend.target),
RemoveBarriers(),
Unschedule(),
CollectAndCollapse(
collect_function=collect_function,
collapse_function=collapse_function,
),
ALAPScheduleAnalysis(target=backend.target),
PadDynamicalDecoupling(
dd_sequence=[XGate(), RZGate(-np.pi), XGate(), RZGate(np.pi)],
spacing=[1 / 4, 1 / 2, 0, 0, 1 / 4],
target=backend.target,
qubits=dd_qubits,
),
]
)

return pm

Wir können jetzt den benutzerdefinierten Pass Manager verwenden, um den Circuit für das ausgewählte Backend zu transpilieren.

# Transpile the circuits for the backend
pm = build_passmanager(backend, ghz_qubits)

# Instruction set architecture (ISA) level circuit after scheduling and DD insertion
isa_circuit = pm.run(qc)

# Draw after scheduling and DD insertion
# timeline_drawer(isa_circuit, show_idle=False, time_range=(0, 1000), target=backend.target)
isa_circuit.draw("mpl", fold=-1, idle_wires=False)

Ausgabe der vorherigen Code-Zelle

Anschließend senden wir den Job mithilfe des Qiskit Runtime Sampler Primitive.

# Select the sampler options
sampler = Sampler(mode=backend)
sampler.options.default_shots = SHOTS
sampler.options.dynamical_decoupling.enable = False
sampler.options.execution.rep_delay = 0.00025

# Submit the job
print("Submitting sampler job")
ghz_job = sampler.run([isa_circuit])

print(ghz_job.job_id())
d493f17nmdfs73abf9qg

Schritt 4: Nachverarbeitung und Rückgabe des Ergebnisses im gewünschten klassischen Format

Wir können nun die Ergebnisse des Sampler-Jobs abrufen und analysieren.

# Retrieve the job results
job_result = ghz_job.result()
# Get the counts from GHZ and check qubit measurements
ghz_counts = job_result[0].data.c1.get_counts()
checks_counts = job_result[0].data.c2.get_counts()
# Post-process to get unflagged GHZ counts (i.e., check bits are all '0')
joined_counts = job_result[0].join_data().get_counts()
unflagged_counts = {}
for key, count in joined_counts.items():
check_bits = key[: len(checks)]
ghz_bits = key[len(checks) :]
if set(check_bits) == {"0"}:
unflagged_counts[ghz_bits] = count
# Get top 20 outcomes by frequency from the unflagged counts
top_counts = dict(
sorted(unflagged_counts.items(), key=lambda x: x[1], reverse=True)[:20]
)

# Rename keys for better visualization
top_counts_renamed = {}
i = 0
for key, count in top_counts.items():
if set(key) == {"0"}:
top_counts_renamed["all 0s"] = count
elif set(key) == {"1"}:
top_counts_renamed["all 1s"] = count
else:
top_counts_renamed[f"other_{i}"] = count
i += 1

plot_histogram(top_counts_renamed, figsize=(12, 7))

Ausgabe der vorherigen Code-Zelle

Im obigen Histogramm haben wir 20 Bitstring-Messungen der GHZ-Qubits aufgetragen, die von den Prüf-Qubits nicht markiert wurden. Wie erwartet haben die Alles-0- und Alles-1-Bitstrings die höchsten Häufigkeiten. Es ist zu beachten, dass einige fehlerhafte Bitstrings mit geringem Fehlergewicht nicht durch die Fehlererkennung erfasst wurden. Die höchsten Häufigkeiten befinden sich jedoch nach wie vor bei den erwarteten Bitstrings.

Diskussion

In diesem Tutorial haben wir gezeigt, wie man eine Fehlererkennungstechnik mit geringem Overhead unter Verwendung von Raumzeit-Codes implementiert, und deren Anwendung in der Praxis zur Vorbereitung von GHZ-Zuständen auf Hardware demonstriert. Für weitere technische Details zur GHZ-Zustandsvorbereitung siehe [3]. Zusätzlich zur Fehlererkennung nutzen die Autoren Auslesefehlerminderung mit M3 und TREX sowie temporäre Unberechnungstechniken, um GHZ-Zustände mit hoher Treue vorzubereiten.

Referenzen

  • [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.
  • [3] Javadi-Abhari, A., Martiel, S., Seif, A., Takita, M., & Wei, K. X. (2025). Big cats: entanglement in 120 qubits and beyond. arXiv preprint arXiv:2510.09520.