시공간 코드를 이용한 저오버헤드 오류 검출
사용 시간 추정: Heron r3 프로세서에서 약 10초 (참고: 이는 추정치이며, 실제 실행 시간은 다를 수 있습니다.)
소개
Simon Martiel과 Ali Javadi-Abhari가 저술한 시공간 코드를 이용한 저오버헤드 오류 검출 [1]은 Clifford 위주 회로에 대해 저가중(low-weight)이고 연결성을 고려한 시공간 검사(spacetime check)를 합성하고, 이 검사에 기반한 사후 선택(post-selection)을 통해 완전한 오류 수정보다 훨씬 낮은 오버헤드와 표준 오류 완화보다 적은 측정 횟수로 결함을 잡아내는 방법을 제안합니다.
이 논문은 완전한 오류 수정과 경량 완화 기법 사이의 균형을 맞추는 양자 회로(특히 Clifford 회로)의 오류 검출을 위한 새로운 방법을 제안합니다. 핵심 아이디어는 시공간 코드를 사용해 회로 전반에 걸쳐 오류를 잡아낼 수 있는 "검사(checks)"를 생성하되, 완전한 내결함성 오류 수정보다 훨씬 적은 Qubit 및 Gate 오버헤드를 사용하는 것입니다. 저자들은 가중치가 낮고(소수의 Qubit만 포함), 장치의 물리적 연결성과 호환되며, 회로의 넓은 시간적·공간적 영역을 커버하는 검사를 선택하는 효율적인 알고리즘을 설계합니다. 이 방법을 최대 50개의 논리 Qubit와 약 2450개의 CZ Gate를 포함하는 회로에 적용하여 물리-논리 충실도(fidelity) 향상을 최대 236배까지 달성했음을 입증합니다. 또한 비Clifford 연산이 회로에 많아질수록 유효한 검사의 수가 지수적으로 감소하므로, 이 방법은 Clifford 위주 회로에서 가장 효과적입니다. 전반적으로, 단기적으로는 시공간 코드를 통한 오류 검출이 양자 하드웨어의 신뢰성을 개선하는 실용적이고 낮은 오버헤드의 접근법을 제공할 수 있습니다.
이 오류 검출 기법은 코히어런트 파울리 검사(coherent Pauli check) 개념에 의존하며, van den Berg 등이 저술한 단일 샷 오류 완화를 위한 코히어런트 파울리 검사 [2]의 연구를 기반으로 합니다.
최근에는 Javadi-Abhari 등이 저술한 Big cats: 120 Qubit 이상에서의 얽힘 [3] 논문에서, 초전도 Qubit 플랫폼에서 현재까지 달성된 가장 큰 다자간 얽힘 상태인 120 Qubit Greenberger-Horne-Zeilinger (GHZ) 상태 생성을 보고합니다. 하드웨어 인식 컴파일러, 저오버헤드 오류 검출, 그리고 노이즈를 줄이기 위한 "임시 비계산(temporary uncomputation)" 기법을 사용하여 약 28%의 사후 선택 효율로 0.56 ± 0.03의 충실도를 달성했습니다. 이 연구는 120개 Qubit 전체에 걸친 진정한 얽힘을 입증하고, 여러 충실도 인증 방법을 검증하며, 확장 가능한 양자 하드웨어를 위한 주요 벤치마크를 제시합니다.
이 튜토리얼은 이러한 아이디어를 바탕으로, 먼저 소규모 무작위 Clifford 회로에서 오류 검출 알고리즘을 구현하고, 이어서 GHZ 상태 준비 작업을 단계별로 안내하여 여러분 자신의 양자 회로에서 오류 검출을 실험할 수 있도록 돕습니다.
요구 사항
이 튜토리얼을 시작하기 전에 다음이 설치되어 있는지 확인하세요:
- Qiskit SDK v2.0 이상, 시각화 지원 포함
- Qiskit Runtime v0.40 이상 (
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)
설정
# 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
첫 번째 예시
이 방법을 시연하기 위해, 먼저 간단한 Clifford Circuit을 구성합니다. 우리의 목표는 이 Circuit에서 특정 유형의 오류가 발생할 때 이를 감지하여 오류가 있는 측정 결과를 버릴 수 있게 하는 것입니다. 오류 검출 용어로, 이를 페이로드 Circuit(payload circuit)이라고도 합니다.
circ = random_clifford(num_qubits=2, seed=11).to_circuit()
circ.draw("mpl")
우리의 목표는 이 페이로드 Circuit에 코히어런트 파울리 검사를 삽입하는 것입니다. 그러기 전에, 이 Circuit을 레이어로 분리합니다. 이는 나중에 레이어 사이에 Pauli Gate를 삽입할 때 유용합니다.
# 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()
이제 페이로드 Circuit에 코히어런트 파울리 검사를 추가할 준비가 되었습니다. 이를 위해 "유효한 검사(valid check)"를 구성하고 Circuit에 삽입해야 합니다. 여기서 "검사"란 앙실라(ancilla) Qubit에 대한 측정을 통해 Circuit에서 오류가 발생했는지를 신호로 알릴 수 있는 연산자입니다. 양자 Circuit에 삽입된 추가 연산자가 원래 Circuit의 논리적 동작을 변경하지 않을 때 유효한 검사로 간주됩니다.
이 검사는 자신과 반교환(anticommute)하는 유형의 오류를 감지할 수 있으며, 위상 반동(phase kickback)을 통해 앙실라 Qubit에서 대신 상태의 측정을 유발합니다. 따라서 오류가 신호된 측정 결과를 버릴 수 있습 니다.
일반적으로 코히어런트 파울리 검사는 Gate 사이의 "와이어"(시공간 위치)에 삽입되는 제어-파울리(controlled-Pauli) 연산자입니다. 오류를 신호하는 앙실라 Qubit이 제어 Qubit 역할을 합니다.
아래에서 우리가 만든 Clifford Circuit에 대한 유효한 검사를 구성합니다. 이 파울리 검사들을 Circuit 앞쪽으로 전파했을 때 서로 상쇄됨을 보여, 검사가 Circuit 동작을 변경하지 않음을 확인할 수 있습니다. 파울리 연산자가 Clifford Gate를 통과하면 다른 파울리 연산자가 된다는 사실로 이를 쉽게 보일 수 있습니다.
일반적으로는 [1]에 설명된 디코딩 휴리스틱을 사용하여 유효한 검사를 찾을 수 있습니다. 초기 예시에서는 파울리 및 Clifford Gate 곱셈 조건을 분석적으로 활용하여 유효한 검사를 구성할 수도 있습니다.
# 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")
pauli_1_ev = pauli_1.evolve(circ_1, frame="h")
pauli_1_ev
Pauli('-ZI')
circ_2 = circ.copy()
circ_2.draw("mpl")
pauli_2_ev = pauli_2.evolve(circ_2, frame="h")
pauli_2_ev
Pauli('-ZI')
pauli_1_ev.dot(pauli_2_ev)
Pauli('II')
보면 알 수 있듯이, 삽입된 파울리 연산자들이 Circuit에서 항등 연산자와 동일한 효과를 가지므로, 유효한 검사가 확인되었습니다. 이제 앙실라 Qubit과 함께 이 검사들을 Circuit에 삽입할 수 있습니다. 이 앙실라 Qubit, 즉 검사 Qubit은 상태에서 시작하고, 위에서 설명한 파울리 연산들의 제어 버전을 포함하며, 마지막으로 기저에서 측정됩니다. 이 검사 Qubit은 페이로드 Circuit의 논리적 동작을 변경하지 않으면서 오류를 잡아낼 수 있습니다. 페이로드 Circuit의 특정 유형의 노이즈가 검사 Qubit의 상태를 변경하여, 그러한 오류가 발생하면 "0" 대신 "1"이 측정되기 때문입니다.
# 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")
검사 Qubit이 "0"으로 측정되면 해당 측정 결과를 유지합니다. "1"로 측정되면 페이로드 Circuit에서 오류가 발생한 것이므로 해당 측정 결과를 버립니다.
# 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)
이상적인 시뮬레이터에서는 검사 Qubit이 어떤 오류도 감지하지 않습니다. 이제 시뮬레이션에 노이즈 모델을 도입하고 검사 Qubit이 오류를 어떻게 잡아내는지 살펴보겠습니다.
# 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)
보면 알 수 있듯이, 일부 측정에서 검사 Qubit이 "1"로 표시되어 오류를 잡아냈으며, 이는 마지막 네 열에 표시됩니다. 이 샷들은 버려집니다. 참고: 앙실라 Qubit도 Circuit에 새로운 오류를 도입할 수 있습니다. 이를 줄이기 위해 추가 앙실라 Qubit을 사용하는 중첩 검사를 양자 Circuit에 삽입할 수 있습니다.
실제 사례: 실제 하드웨어에서 GHZ 상태 준비하기
1단계: 고전적 입력을 양자 문제로 매핑하기
이제 양자 컴퓨팅 알고리즘의 중요한 작업인 GHZ 상태 준비를 시연합니다. 오류 검출을 사용하여 실제 Backend에서 이를 수행하는 방법을 보여주겠습니다.
# Set optional seed for reproducibility
SEED = 1
if SEED:
np.random.seed(SEED)
GHZ 상태 준비를 위한 오류 검출 알고리즘은 하드웨어 토폴로지를 고려합니다. 먼저 원하는 하드웨어를 선택합니다.
# 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()
개의 Qubit에 대한 GHZ 상태는 다음과 같이 정의됩니다.
GHZ 상태를 준비하는 가장 단순한 방법은 루트 Qubit에 Hadamard Gate를 적용하여 Qubit을 균등 중첩 상태로 만든 다음, 이 Qubit을 다른 모든 Qubit과 얽히게 하는 것입니다. 그러나 이 방법은 원거리 및 깊은 CNOT 상호작용이 필요하므로 좋은 접근법이 아닙니다. 이 튜토리얼에서는 오류 검출과 함께 여러 기법을 사용하여 실제 하드웨어에서 안정적으로 GHZ 상태를 준비합니다.
Step 2: Optimize problem for quantum hardware execution
Map the GHZ state to hardware
먼저, GHZ 회로를 하드웨어에 매핑할 루트를 탐색합니다. CZ 오류, 측정 오류, T2 값이 아래 임계값보다 나쁜 엣지/노드를 제거합니다. 이들은 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]
아래 함수를 사용하여 루트에서 시작해 너비 우선 탐색(BFS)으로 선택된 하드웨어 위에 GHZ 회로를 구성합니다.
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
이제 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
이제 특정 노드(즉, 최적의 루트)에서 시작하여 너비 우선 탐색으로 최단 깊이를 탐색하며 GHZ 회로를 구성합니다.
# 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
유효한 검사를 삽입하기 전에 한 가지 추가 고려사항이 있습니다. 이는 "커버리지(coverage)"라는 개념과 관련되며, 하나의 검사가 양자 회로의 와이어 중 얼마나 많은 부분을 커버할 수 있는지를 나타내는 척도입니다. 커버리지가 높을수록 회로의 더 넓은 부분에서 오류를 감지할 수 있습니다. 이 척도를 사용하여 회로 커버리지가 가장 높은 유효 검사들 중에서 선택할 수 있습니다. 즉, weighted_coverage 함수를 사용하여 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
이제 GHZ 회로에 검사(check)를 삽입할 수 있습니다. GHZ 상태에서 유효한 검사를 찾는 것은 매우 편리한데, GHZ 회로의 임의의 두 큐비트 에 작용하는 2-큐비트 파울리(Pauli) 연산자 는 지지(support)이며 따라서 유효한 검사가 되기 때문입니다.
또한, 이 경우 검사는 앤실라(ancilla) 큐비트의 좌우에 위치한 아다마르(Hadamard) 게이트와 인접한 제어- 연산자입니다. 이는 앤실라 큐비트에 적용되는 CNOT 게이트와 동일합니다. 아래 코드는 검사를 회로에 삽입합니다.
# --- 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
이제 GHZ 회로에서 사용된 큐비트와 검사 큐비트를 출력할 수 있습니다.
# --- 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
오류 통계도 출력할 수 있습니다.
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
앞서와 마찬가지로, 노이즈가 없는 상태에서 회로를 먼저 시뮬레이션하여 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}
예상대로 검사 큐비트는 모두 0으로 측정되었으며, GHZ 상태를 성공적으로 준비하였습니다.
Step 3: Qiskit 프리미티브를 사용하여 실행
이제 실제 하드웨어에서 Circuit을 실행하여 GHZ 상태 준비 과정에서 오류 감지 프로토콜이 오류를 어떻게 포착하는지 보여드릴 준비가 되었습니다.
BAD_QUBITS = [] # specify any additional bad qubits to avoid (this is specific to the chosen backend)
SHOTS = 10000 # number of shots
GHZ Circuit에 측정을 추가하는 도우미 함수를 정의합니다.
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
실행 전에, 선택한 하드웨어에서 GHZ Qubit과 체크 Qubit의 레이아웃을 그립니다.
# 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()

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

이제 측정을 추가합니다.
qc = add_measurements(qc, ghz_qubits, checks)
아래의 스케줄링 파이프라인은 타이밍을 고정하고, 장벽을 제거하며, 지연을 단순화하고, 동적 디커플링을 삽입하면서 원래의 연산 시간을 유지합니다.
# 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
이제 커스텀 패스 매니저를 사용하여 선택한 Backend에 맞게 Circuit을 트랜스파일할 수 있습니다.
# 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)

그런 다음 Qiskit Runtime Sampler 프리미티브를 사용하여 작업을 제출합니다.
# 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
Step 4: 후처리 및 원하는 고전적 형식으로 결과 반환
이제 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))

위의 히스토그램에서는 체크 Qubit에 의해 플래그 처리되지 않은 GHZ Qubit의 비트스트링 측정 결과 20개를 표시했습니다. 예상대로 모두 0인 비트스트링과 모두 1인 비트스트링이 가장 높은 횟수를 보입니다. 낮은 오류 가중치를 가진 일부 잘못된 비트스트링은 오류 감지에서 포착되지 않았음을 참고하세요. 그러나 가장 높은 횟수는 여전히 예상되는 비트스트링에서 나타납니다.
논의
이 튜토리얼에서는 시공간 코드를 사용하는 낮은 오버헤드 오류 감지 기법을 구현하는 방법을 보여드렸으며, 하드웨어에서 GHZ 상태를 준비하는 실제 적용 사례를 시연했습니다. GHZ 상태 준비의 기술적 세부 사항을 더 탐구하려면 [3]을 참조하세요. 오류 감지 외에도, 저자들은 M3 및 TREX를 통한 판독 오류 완화와 임시 비계산 기법을 활용하여 고충실도 GHZ 상태를 준비합니다.
참고 문헌
- [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.