Sari la conținutul principal

Detecție de erori cu overhead redus folosind coduri spațio-temporale

Estimare de utilizare: 10 secunde pe un procesor Heron r3 (NOTĂ: Aceasta este doar o estimare. Timpul tău de execuție poate varia.)

Introducere

Detecție de erori cu overhead redus folosind coduri spațio-temporale [1] de Simon Martiel și Ali Javadi-Abhari propune sintetizarea unor verificări spațio-temporale de greutate mică și compatibile cu conectivitatea, pentru circuite dominate de Clifford, urmată de post-selecție pe aceste verificări pentru a detecta defecte cu mult mai puțin overhead decât corecția completă a erorilor și cu mai puține shot-uri decât mitigarea standard a erorilor.

Această lucrare propune o metodă nouă pentru detecția erorilor în circuitele cuantice (în special în circuitele Clifford), care găsește un echilibru între corecția completă a erorilor și tehnicile de mitigare mai ușoare. Ideea cheie este utilizarea codurilor spațio-temporale pentru a genera „verificări" de-a lungul circuitului, capabile să detecteze erori, cu un overhead semnificativ mai mic de qubiți și porți față de corecția completă a erorilor tolerantă la defecte. Autorii proiectează algoritmi eficienți pentru a selecta verificări de greutate mică (implicând puțini qubiți), compatibile cu conectivitatea fizică a dispozitivului și acoperind regiuni temporale și spațiale extinse ale circuitului. Aceștia demonstrează abordarea pe circuite cu până la 50 de qubiți logici și ~2450 de porți CZ, obținând câștiguri de fidelitate fizică-la-logică de până la 236x. De asemenea, este de remarcat că, pe măsură ce circuitele includ mai multe operații non-Clifford, numărul de verificări valide scade exponențial, ceea ce indică faptul că metoda funcționează cel mai bine pentru circuitele dominate de Clifford. În general, pe termen scurt, detecția erorilor prin coduri spațio-temporale poate oferi o cale practică și cu overhead mai mic pentru îmbunătățirea fiabilității hardware-ului cuantic.

Această tehnică de detecție a erorilor se bazează pe noțiunea de verificări Pauli coerente și pornește de la lucrarea Mitigare a erorilor într-un singur pas prin verificări Pauli coerente [2] de van den Berg et al.

Mai recent, lucrarea Pisici mari: entanglement în 120 de qubiți și dincolo [3] de Javadi-Abhari et al. raportează crearea unei stări Greenberger-Horne-Zeilinger (GHZ) cu 120 de qubiți, cea mai mare stare entanglată multipartit obținută până în prezent pe o platformă cu qubiți superconductori. Folosind un compilator adaptat hardware-ului, detecție de erori cu overhead redus și o tehnică de „decomputație temporară" pentru reducerea zgomotului, cercetătorii au obținut o fidelitate de 0,56 ± 0,03 cu o eficiență de post-selecție de aproximativ 28%. Lucrarea demonstrează entanglement veritabil pe toți cei 120 de qubiți, validând mai multe metode de certificare a fidelității, și marchează un reper important pentru hardware-ul cuantic scalabil.

Acest tutorial se bazează pe aceste idei, ghidându-te prin implementarea algoritmului de detecție a erorilor mai întâi pe un circuit Clifford aleatoriu la scară mică și apoi prin sarcina de pregătire a stării GHZ, pentru a te ajuta să experimentezi cu detecția erorilor pe propriile tale circuite cuantice.

Cerințe

Înainte de a începe acest tutorial, asigură-te că ai instalat următoarele:

  • Qiskit SDK v2.0 sau mai recent, cu suport pentru vizualizare
  • Qiskit Runtime v0.40 sau mai recent (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)

Configurare

# 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

Primul exemplu

Pentru a demonstra această metodă, începem prin construirea unui circuit Clifford simplu. Scopul nostru este să putem detecta când apar anumite tipuri de erori în acest circuit, astfel încât să putem elimina rezultatele de măsurare eronate. În terminologia detecției erorilor, acesta este cunoscut și sub denumirea de circuit de sarcină utilă (payload circuit).

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

Output of the previous code cell

Scopul nostru este să inserăm o verificare Pauli coerentă în acest circuit de sarcină utilă. Dar înainte de a face asta, separăm circuitul în straturi. Aceasta va fi utilă mai târziu când inserăm porți Pauli între ele.

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

Output of the previous code cell

Acum suntem pregătiți să adăugăm verificări Pauli coerente în circuitul de sarcină utilă. Pentru a face asta, trebuie să construim o „verificare validă" și să o inserăm în circuit. O „verificare" în acest caz este un operator capabil să semnalizeze dacă a apărut o eroare în circuit, prin efectuarea unei măsurători pe un qubit ansilă. Este considerată o verificare validă atunci când operatorii suplimentari inserați în circuitul cuantic nu modifică logic circuitul original.

Această verificare este capabilă să detecteze tipuri de erori care anticomutează cu ea, iar verificarea va declanșa o măsurătoare a stării 1\ket{1} pe qubitul ansilă în loc de 0\ket{0} prin phase kickback. Prin urmare, vom putea elimina măsurătorile în care a fost semnalizată o eroare.

În general, verificările Pauli coerente sunt operatori Pauli controlați inserați în „fire" - locații spațio-temporale între porți. Qubitul ansilă responsabil cu semnalizarea erorii este qubitul de control.

Mai jos construim o verificare validă pentru circuitul Clifford creat mai sus. Putem demonstra că această verificare nu modifică operația circuitului arătând că, atunci când aceste verificări Pauli sunt propagate la începutul circuitului, ele se anulează reciproc. Aceasta este ușor de demonstrat deoarece un operator Pauli printr-o poartă Clifford este un alt operator Pauli.

În general, se poate folosi o euristică de decodare, așa cum este descrisă în [1], pentru a identifica verificări valide. În scopul exemplului nostru inițial, putem construi de asemenea verificări valide folosind condițiile analitice de înmulțire a porților Pauli și Clifford.

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

Output of the previous code cell

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

Output of the previous code cell

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

După cum putem vedea, avem o verificare validă, deoarece operatorii Pauli inserați au același efect ca un operator identitate asupra circuitului. Putem acum să inserăm aceste verificări în circuit cu un qubit ansilă. Acest qubit ansilă, sau qubitul de verificare, începe în starea +\ket{+}. Include versiunile controlate ale operațiilor Pauli descrise mai sus și este în final măsurat în baza XX. Acest qubit de verificare este acum capabil să capteze erorile din circuitul de sarcină utilă fără a-l modifica logic. Aceasta se datorează faptului că anumite tipuri de zgomot din circuitul de sarcină utilă vor modifica starea qubitului de verificare, care va fi măsurat „1" în loc de „0" în cazul în care apare o astfel de eroare.

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

Output of the previous code cell

Dacă qubitul de verificare este măsurat „0", păstrăm acea măsurătoare. Dacă este măsurat „1", atunci aceasta înseamnă că a apărut o eroare în circuitul de sarcină utilă și eliminăm acea măsurătoare.

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

Output of the previous code cell

Observă că, cu un simulator ideal, qubitul de verificare nu detectează nicio eroare. Acum introducem un model de zgomot în simulare și vedem cum qubitul de verificare captează erorile.

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

Output of the previous code cell

După cum putem vedea, unele măsurători au capturat eroarea prin marcarea qubitului de verificare ca „1", vizibile în ultimele patru coloane. Aceste shot-uri sunt eliminate. Notă: Qubitul ansilă poate introduce și el erori noi în circuit. Pentru a reduce efectul acestora, putem insera verificări imbricate cu qubiți ansilă suplimentari în circuitul cuantic.

Exemplu din lumea reală: Pregătește o stare GHZ pe hardware real

Pasul 1: Mapează intrările clasice la o problemă cuantică

Acum demonstrăm o sarcină importantă pentru algoritmii de calcul cuantic: pregătirea unei stări GHZ. Vom arăta cum se face acest lucru pe un Backend real, folosind detecția erorilor.

# Set optional seed for reproducibility
SEED = 1

if SEED:
np.random.seed(SEED)

Algoritmul de detecție a erorilor pentru pregătirea stării GHZ respectă topologia hardware-ului. Începem prin selectarea hardware-ului dorit.

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

O stare GHZ pe nn Qubiți este definită ca 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).

O abordare naivă pentru pregătirea stării GHZ ar fi să alegi un Qubit rădăcină cu o poartă Hadamard inițială, care pune Qubitul într-o stare de superpoziție egală, și apoi să împletești acest Qubit cu fiecare alt Qubit. Aceasta nu este o abordare bună, deoarece necesită interacțiuni CNOT de lungă distanță și adânci. În acest tutorial, vom folosi mai multe tehnici alături de detecția erorilor pentru a pregăti în mod fiabil starea GHZ pe hardware real.

Pasul 2: Optimizează problema pentru execuția pe hardware cuantic

Mapează starea GHZ pe hardware

Mai întâi, căutăm o rădăcină de la care să mapăm Circuit-ul GHZ pe hardware. Eliminăm muchiile/nodurile ale căror erori CZ, erori de măsurare și valori T2 sunt mai proaste decât pragurile de mai jos. Acestea nu vor fi incluse în Circuit-ul GHZ.

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]

Folosind funcția de mai jos, construim Circuit-ul GHZ pe hardware-ul ales, pornind de la rădăcină și folosind căutarea în lățime (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

Acum căutăm în mod repetat cea mai bună rădăcină, de unde va porni Circuit-ul GHZ.

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

Construim acum Circuit-ul GHZ pornind de la un nod specific — adică de la cea mai bună rădăcină — căutând adâncimea minimă cu ajutorul căutării în lățime.

# 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

Mai avem o considerație finală înainte de a insera verificările valide. Aceasta se referă la conceptul de „acoperire" (coverage), care măsoară câte fire dintr-un Circuit cuantic poate acoperi o verificare. Cu o acoperire mai mare, putem detecta erori pe o zonă mai largă a circuitului. Cu ajutorul acestei măsuri, putem selecta, dintre verificările valide, pe cele cu cea mai mare acoperire a circuitului. Cu alte cuvinte, vom folosi funcția weighted_coverage pentru a puncta diferitele verificări ale Circuit-ului GHZ.

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

Acum putem insera verificările în Circuit-ul GHZ. Găsirea verificărilor valide este foarte convenabilă pentru starea GHZ, deoarece orice operator Pauli ZZ pe doi Qubiți ZiZjZ_i Z_j, acționând pe oricare doi Qubiți i,ji,j ai Circuit-ului GHZ, constituie un suport și, prin urmare, o verificare validă.

De asemenea, observă că verificările în acest caz sunt operatori ZZ controlați, vecini cu porți Hadamard din stânga și din dreapta pe Qubitul ancilă. Acest lucru este echivalent cu o Gate CNOT aplicată Qubitului ancilă. Codul de mai jos inserează verificările în circuit.

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

Acum putem afișa Qubiții folosiți în Circuit-ul GHZ și Qubiții de verificare.

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

Putem de asemenea afișa câteva statistici despre erori.

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

Ca și înainte, putem simula mai întâi circuit-ul în absența zgomotului pentru a verifica corectitudinea circuit-ului de pregătire a stării GHZ.

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

Output of the previous code cell

Așa cum era de așteptat, Qubiții de verificare sunt măsurați ca toți zero, iar noi am pregătit cu succes starea GHZ.

Pasul 3: Execuție folosind primitivele Qiskit

Acum suntem gata să rulăm circuitul pe hardware real și să demonstrăm cum protocolul de detectare a erorilor poate surprinde erorile din pregătirea stării GHZ.

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

Definim o funcție ajutătoare pentru a adăuga măsurători la circuitul GHZ.

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

Înainte de execuție, desenăm dispunerea Qubit-urilor GHZ și a Qubit-urilor de verificare pe hardware-ul selectat.

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

Output of the previous code cell

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

Output of the previous code cell

Adăugăm acum măsurătorile.

qc = add_measurements(qc, ghz_qubits, checks)

Pipeline-ul de planificare de mai jos fixează timpii, elimină barierele, simplifică întârzierile și injectează decuplarea dinamică, păstrând în același timp timpii originali ai operațiilor.

# 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

Putem folosi acum managerul de pași personalizat pentru a transpila circuitul pentru Backend-ul selectat.

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

Output of the previous code cell

Trimitem apoi job-ul folosind primitiva Sampler din Qiskit Runtime.

# 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

Pasul 4: Post-procesare și returnarea rezultatului în formatul clasic dorit

Putem acum să recuperăm și să analizăm rezultatele din job-ul Sampler.

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

Output of the previous code cell

În histograma de mai sus, am reprezentat 20 de măsurători de șiruri de biți de la Qubit-urile GHZ care nu au fost semnalate de Qubit-urile de verificare. Conform așteptărilor, șirurile de biți formate numai din 0 și numai din 1 au cele mai mari numărători. Rețineți că unele șiruri de biți eronate cu ponderi mici ale erorilor nu au fost detectate de sistemul de detectare a erorilor. Cele mai mari numărători se regăsesc în continuare în șirurile de biți așteptate.

Discuție

În acest tutorial, am arătat cum să implementezi o tehnică de detectare a erorilor cu overhead redus folosind coduri spațio-temporale și am demonstrat aplicarea ei practică la pregătirea stărilor GHZ pe hardware. Consultă [3] pentru a explora în detaliu aspectele tehnice ale pregătirii stărilor GHZ. Pe lângă detectarea erorilor, autorii utilizează atenuarea erorilor de citire cu M3 și TREX și aplică tehnici de decomputație temporară pentru a pregăti stări GHZ de înaltă fidelitate.

Referințe

  • [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.