Detección de errores de bajo overhead con códigos espaciotemporales
Estimación de uso: 4 minutos en un procesador Heron (ibm_kingston o equivalente) (NOTA: Esto es solo una estimación. Tu tiempo de ejecución puede variar.)
Resultados de aprendizaje
-
Cómo las comprobaciones de Pauli espaciotemporales detectan errores lógicos en circuitos de Clifford, y cómo la postselección basada en sus síndromes mejora la fidelidad de una distribución muestreada.
-
Cómo usar el paquete
qiskit-paulicepara encontrar e insertar comprobaciones eficientes en hardware automáticamente conget_check_qubits,NoiseModelyadd_pauli_checks. -
Cómo estimar la fidelidad de un estado estabilizador muestreando sus estabilizadores y realizando postselección basada en síndromes de comprobación.
-
Cómo ejecutar el flujo de trabajo completo de detección de errores en hardware de IBM Quantum® y comparar las fidelidades ruidosas y postseleccionadas.
Requisitos previos
-
Fundamentos de hardware para computación cuántica a escala de utilidad.
-
El formalismo de Clifford y estabilizadores, incluyendo cómo un grupo estabilizador describe un estado estabilizador puro.
Contexto
Low-overhead error detection with spacetime codes [1] de Simon Martiel y Ali Javadi-Abhari presenta un método para detectar errores lógicos en circuitos dominados por Clifford que se sitúa entre la corrección de errores completa y la mitigación de errores más ligera. La idea se basa en las comprobaciones de Pauli coherentes (CPC) de Single-shot error mitigation by coherent Pauli checks [2] de van den Berg y otros. En ambos enfoques, un circuito "payload" de Clifford se entrelaza con qubits ancilla para comprobar ciertos invariantes. Medir las ancillas produce un síndrome que informa si se detectó un error durante la ejecución. Mantener solo las muestras sin error detectado mejora la fidelidad de la distribución muestreada, a costa de una tasa de postselección reducida.
La diferencia clave entre las comprobaciones de Pauli coherentes y las comprobaciones espaciotemporales son los operadores que miden. Las comprobaciones de Pauli coherentes miden operadores de alto peso localizados en el tiempo. En topologías de qubits con conectividad limitada, como heavy hex, esas comprobaciones necesitan muchas puertas SWAP y a menudo hacen que el circuito sea demasiado profundo para ejecutarse en la práctica. Implementar las comprobaciones como códigos espaciotemporales en su lugar distribuye cada comprobación a través del circuito payload en espacio y tiempo. Esto produce una codificación eficiente en hardware que sigue siendo efectiva para detectar errores lógicos manteniendo bajo el overhead de qubits y profundidad.
Qué hace el paquete qiskit-paulice
El paquete qiskit-paulice automatiza la construcción de estas comprobaciones para que no tengas que construirlas a mano. Su función principal es encontrar e insertar comprobaciones de Pauli espaciotemporales válidas en las ubicaciones de un circuito que maximizan la detección de errores mientras minimizan el overhead de qubits. Una comprobación es válida cuando sus operadores dejan sin cambios la acción lógica del circuito payload, de bajo peso cuando usa pocas puertas de entrelazamiento, y efectiva cuando detecta una gran parte de los errores, en relación con el ruido que la propia comprobación introduce. El paquete puntúa las comprobaciones candidatas frente a un modelo de ruido y confirma las mejores en el circuito. Este tutorial usa tres métodos de la API:
-
get_check_qubitsinspecciona un mapa de acoplamiento de un backend y devuelve pares de qubits objetivo y ancilla. Una comprobación entarget_qubits[i]usaancilla_qubits[i]. -
NoiseModel.from_backendconstruye un modelo de ruido aproximado a partir de datos de referencia del backend. El modelo puntúa las comprobaciones candidatas, por lo que no se requiere un modelo de ruido exacto y aprendido. Para un modelo de Pauli-Lindblad aprendido, consultaNoiseModel.from_pauli_lindblad_maps. -
add_pauli_checksencuentra e inserta comprobaciones en un circuito. Devuelve una secuencia de objetosCheckedCircuitcon un número creciente de comprobaciones, y cada objeto proporciona unget_postselection_methodque mapea una cadena de bits medida a un vector de síndrome. El argumentocostselecciona la función que puntúa una comprobación (gamma, el overhead de muestreo del canal de ruido inverso postseleccionado, oLER, la tasa de error lógico). El argumentomethodselecciona la estrategia de búsqueda (windowed,genetic, owindowed_genetic). Este tutorial usacost="gamma"ymethod="windowed", que juntos dan una selección de comprobaciones determinista y reproducible.
Estimar la fidelidad a partir del muestreo de estabilizadores
Para medir qué tan bien funciona la detección de errores, puedes estimar la fidelidad del estado estabilizador que el circuito idealmente prepara frente al estado ruidoso que el hardware realmente produce. El proyector sobre un estado estabilizador puro es igual al promedio uniforme sobre los elementos de su grupo estabilizador :
Sustituyendo esto en la fidelidad se obtiene la fidelidad de como el valor esperado promedio de cada estabilizador con respecto a :
Para problemas más grandes, enumerar todos los estabilizadores es inviable, por lo que puedes estimar la fidelidad a partir de una muestra aleatoria. Extraer estabilizadores de manera uniforme y aleatoria de da una estimación insesgada:
Debido a que un circuito de Clifford prepara un estado estabilizador, puedes estimar su fidelidad directamente a partir de valores esperados muestreados de sus estabilizadores. Este tutorial primero recorre el flujo de trabajo en un simulador con un circuito pequeño, y luego ejecuta el mismo flujo de trabajo en hardware con un circuito más grande y profundo. A medida que los circuitos incluyen más operaciones no Clifford, el número de comprobaciones válidas se reduce rápidamente, por lo que el método funciona mejor para circuitos dominados por Clifford.
Requisitos
Antes de comenzar este tutorial, asegúrate de tener instalado lo siguiente:
-
Qiskit SDK v2.0 o posterior, con soporte de visualización
-
Qiskit Runtime v0.40 o posterior (
pip install qiskit-ibm-runtime) -
Qiskit Aer v0.17 o posterior (
pip install qiskit-aer) -
Qiskit Paulice (
pip install qiskit-paulice) -
tqdm (
pip install tqdm)
Configuración
Importa las bibliotecas necesarias y define las funciones auxiliares que no están disponibles como importaciones. La función random_clifford_circuit construye un payload de Clifford aleatorio tipo brickwork, find_check_layout busca en el mapa de acoplamiento de un backend una ruta de qubits de bajo error con muchas ancillas disponibles, learned_noise_model convierte la salida de NoiseLearner en un modelo de ruido de qiskit-paulice, append_basis_rotation rota un circuito para que un estabilizador se mida en la base computacional, expectation calcula un valor esperado de estabilizador a partir de conteos muestreados, y cum_mean_sem rastrea la estimación de fidelidad acumulada.
# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-aer qiskit-ibm-runtime qiskit-paulice tqdm
# Standard library imports
import random
import time
# External libraries
import matplotlib.pyplot as plt
import numpy as np
from tqdm import tqdm
# Qiskit
from qiskit import QuantumCircuit
from qiskit.quantum_info import Clifford, Pauli, PauliLindbladMap, PauliList
from qiskit.result import sampled_expectation_value
from qiskit.transpiler import generate_preset_pass_manager
from qiskit.visualization import plot_coupling_map
# Qiskit Aer
from qiskit_aer import AerSimulator
from qiskit_aer.noise import NoiseModel as AerNoiseModel
from qiskit_aer.noise import ReadoutError, depolarizing_error
# Qiskit IBM Runtime
from qiskit_ibm_runtime import NoiseLearner, QiskitRuntimeService
from qiskit_ibm_runtime import SamplerV2 as Sampler
# Qiskit Paulice
from qiskit_paulice import add_pauli_checks
from qiskit_paulice.layout import get_check_qubits
from qiskit_paulice.noise_models import NoiseModel
def random_clifford_circuit(
num_qubits: int, depth: int, rng: np.random.Generator
) -> QuantumCircuit:
"""Brickwork random Clifford on `num_qubits`, with `depth` CZ layers."""
qc = QuantumCircuit(num_qubits)
qc.h(range(num_qubits))
for d in range(depth):
for i in range(d % 2, num_qubits - 1, 2):
qc.cz(i, i + 1)
for q in range(num_qubits):
if rng.integers(0, 2):
qc.sx(q)
if rng.integers(0, 2):
qc.s(q)
if rng.integers(0, 2):
qc.sx(q)
return qc
def find_check_layout(
backend,
num_qubits: int,
rng: np.random.Generator,
num_trials: int = 200,
max_gate_error: float = 0.03,
max_readout_error: float = 0.2,
) -> list[int]:
"""Find a low-error path of `num_qubits` qubits with many available ancillas.
Builds random self-avoiding walks on the coupling map, excluding the qubits
and two-qubit gates whose reported errors exceed the thresholds, and keeps
the path that offers the most target and ancilla pairs. Ties are broken by
the lower average two-qubit gate error along the path.
"""
target = backend.target
gate_2q = next(
name for name in ("cz", "ecr", "cx") if name in target.operation_names
)
# Collect per-edge gate errors and per-qubit readout errors
edge_error = {}
for qubits, props in target[gate_2q].items():
edge = tuple(sorted(qubits))
if props is not None and props.error is not None:
edge_error[edge] = min(edge_error.get(edge, 1.0), props.error)
readout_error = {
qubit: target["measure"][(qubit,)].error
for (qubit,) in target["measure"]
}
# Keep only the edges whose gate and readout errors are acceptable
adjacency = {}
for (q1, q2), error in edge_error.items():
if (
error <= max_gate_error
and readout_error.get(q1, 1.0) <= max_readout_error
and readout_error.get(q2, 1.0) <= max_readout_error
):
adjacency.setdefault(q1, set()).add(q2)
adjacency.setdefault(q2, set()).add(q1)
# Random self-avoiding walks; keep the path with the most check pairs
starts = sorted(adjacency)
best_path = None
best_score = (-1, float("inf"))
for _ in range(num_trials):
path = [starts[rng.integers(len(starts))]]
while len(path) < num_qubits:
options = sorted(adjacency[path[-1]] - set(path))
if not options:
break
path.append(options[rng.integers(len(options))])
if len(path) < num_qubits:
continue
num_pairs = len(get_check_qubits(backend.coupling_map, path)[0])
mean_error = float(
np.mean(
[edge_error[tuple(sorted(e))] for e in zip(path, path[1:])]
)
)
if num_pairs > best_score[0] or (
num_pairs == best_score[0] and mean_error < best_score[1]
):
best_path, best_score = path, (num_pairs, mean_error)
if best_path is None:
raise RuntimeError(
"No connected low-error path found. Relax the error thresholds."
)
return best_path
def learned_noise_model(layer_errors, layout: list[int]) -> NoiseModel:
"""Build a `NoiseModel` from `NoiseLearner` results.
`NoiseLearner` reports one `PauliLindbladError` per entangling layer, whose
generators are indexed against that layer's own physical qubits, while
`NoiseModel.from_pauli_lindblad_maps` expects `PauliLindbladMap`s indexed the
way `NoiseModel.from_backend` indexes them: by position in `layout`. This
translates between the two and drops generators that fall outside `layout`.
"""
phys_to_virt = {phys: virt for virt, phys in enumerate(layout)}
maps = []
for layer in layer_errors:
if layer.error is None:
continue
terms = []
for pauli, rate in zip(
layer.error.generators, layer.error.rates, strict=True
):
label, indices = [], []
for local, phys in enumerate(layer.qubits):
x, z = bool(pauli.x[local]), bool(pauli.z[local])
if not (x or z):
continue
if phys not in phys_to_virt:
break # generator reaches outside the layout, so skip it
label.append("Y" if x and z else "X" if x else "Z")
indices.append(phys_to_virt[phys])
else:
if label:
terms.append(
("".join(label), tuple(indices), float(rate))
)
# Each map needs a 2-qubit generator to define an entangling layer
if any(len(t[1]) == 2 for t in terms):
maps.append(
PauliLindbladMap.from_sparse_list(
terms, num_qubits=len(layout)
)
)
if not maps:
raise RuntimeError(
"No usable layer errors. Check that the learner ran on this layout."
)
return NoiseModel.from_pauli_lindblad_maps(maps)
def append_basis_rotation(
circuit: QuantumCircuit, pauli: Pauli
) -> QuantumCircuit:
"""Strip measurements, append basis rotations for `pauli`, and re-measure."""
out = circuit.remove_final_measurements(inplace=False)
for q in range(pauli.num_qubits):
if pauli.x[q]:
if pauli.z[q]:
out.sdg(q)
out.h(q)
out.measure_all()
return out
def expectation(counts: dict, pauli: Pauli) -> float:
"""Expectation value of `pauli` from counts measured in the Z basis.
Pads with identity on any qubits beyond the support of `pauli`, such as the
check ancillas that appear in the postselected counts.
"""
if not counts:
return float("nan")
n = pauli.num_qubits
sign = -1 if int(pauli.phase) % 4 == 2 else 1
total = len(next(iter(counts)))
label = "".join(
"Z" if q < n and (pauli.x[q] or pauli.z[q]) else "I"
for q in range(total - 1, -1, -1)
)
return sign * sampled_expectation_value(counts, label)
def cum_mean_sem(values: np.ndarray):
"""Cumulative mean and standard error of the mean, ignoring NaNs."""
valid = ~np.isnan(values)
total = np.cumsum(np.where(valid, values, 0.0))
total_sq = np.cumsum(np.where(valid, values**2, 0.0))
count = np.maximum(np.cumsum(valid).astype(float), 1)
mean = total / count
sem = np.sqrt(np.maximum(total_sq / count - mean**2, 0) / count)
return np.where(np.cumsum(valid) > 0, mean, np.nan), sem
Ejemplo de simulador a pequeña escala
Esta sección recorre el flujo de trabajo completo en un simulador ruidoso. Usa datos de referencia del backend para elegir un layout de qubits y un modelo de ruido, encuentra comprobaciones automáticamente, y usa la postselección en la distribución muestreada para mostrar la mejora de fidelidad.
Paso 1: Mapear entradas clásicas a un problema cuántico
El circuito payload es un circuito de Clifford aleatorio tipo brickwork unidimensional poco profundo. Debido a que el circuito es Clifford, prepara un estado estabilizador cuya fidelidad puedes estimar directamente a partir de valores esperados de estabilizadores muestreados. Comienza con un circuito poco profundo para que las comprobaciones sean fáciles de visualizar en el siguiente paso.
num_qubits = 12
depth = 4
seed = 1764
rng = np.random.default_rng(seed)
np.random.seed(seed)
circuit = random_clifford_circuit(num_qubits, depth, rng)
circuit.measure_all()
circuit.draw("mpl", fold=-1, scale=0.6)
Paso 2: Optimizar para la ejecución en hardware cuántico
Mapear el circuito al hardware establece el layout físico de qubits, el modelo de ruido que puntúa las comprobaciones candidatas, y las propias comprobaciones.
Primero, selecciona un backend y busca en su mapa de acoplamiento un layout de qubits unidimensional con la función auxiliar find_check_layout definida en la sección de Configuración. El ayudante construye caminatas aleatorias autoevitantes que evitan las puertas y lecturas de mayor error, y conserva la ruta que ofrece la mayor cantidad de pares objetivo y ancilla. Debido a que la búsqueda lee la conectividad y los datos de error directamente del backend, el mismo código se ejecuta en cualquier QPU de IBM Quantum. La función get_check_qubits luego devuelve los pares objetivo y ancilla, donde una comprobación en target_qubits[i] usa ancilla_qubits[i].
En el grafo de acoplamiento que sigue, los qubits verdes son qubits de payload y los qubits naranjas son las ancillas que implementan las comprobaciones. Los qubits con una ancilla adyacente se usan como qubits objetivo para las comprobaciones.
service = QiskitRuntimeService()
backend = service.least_busy(operational=True, simulator=False)
print(f"Backend: {backend.name}")
# Search for a low-error path, then pair each target qubit with a neighboring ancilla
layout = find_check_layout(backend, num_qubits, rng)
target_qubits, ancilla_qubits = get_check_qubits(backend, layout)
num_checks = len(target_qubits)
print(f"Target qubits: {target_qubits}")
print(f"Ancilla qubits: {ancilla_qubits}")
plot_coupling_map(
num_qubits=backend.num_qubits,
qubit_coordinates=getattr(
backend.configuration(), "qubit_coordinates", None
),
coupling_map=backend.configuration().coupling_map,
figsize=(12, 12),
qubit_color=[
"#4CAF50"
if i in set(layout)
else "#FF9800"
if i in set(ancilla_qubits)
else "#DDDDDD"
for i in backend.coupling_map.graph.node_indices()
],
qubit_size=220,
line_width=2,
font_size=90,
)
Backend: ibm_boston
Target qubits: [105, 107, 108, 123, 125, 141, 143]
Ancilla qubits: [104, 97, 109, 122, 126, 140, 144]

Con el backend y el layout elegidos, transpila el payload en un circuito de arquitectura de conjunto de instrucciones (ISA). Solo es necesario establecer el layout y traducir las puertas al conjunto de puertas nativas del backend.
pm = generate_preset_pass_manager(
optimization_level=0, backend=backend, initial_layout=layout
)
circuit_isa = pm.run(circuit)
circuit_isa.draw("mpl", fold=-1, scale=0.6)

A continuación, modela cómo el ruido de puertas y lectura del backend afecta la ejecución. El modelo de ruido determina en qué parte del circuito una comprobación captura la mayor cantidad de error. Un modelo más preciso mejora la detección, pero generalmente no es necesario aprenderlo muestreando la QPU. El modelo que sigue infiere un canal de despolarización uniforme para el ruido de puertas y lectura a partir de datos de referencia de qiskit-ibm-runtime.
noise_model = NoiseModel.from_backend(
backend, layout, uniform_gate_noise=True
)
print(noise_model)
NoiseModel(gate_noise=0.001079865281450939, readout_noise=0.006001790364583333, idling_noise=None)
Ahora agrega comprobaciones al circuito. La función add_pauli_checks toma el payload de Clifford, la lista de qubits objetivo y el modelo de ruido. El argumento ancilla_qubits indica a la función qué ancilla física emparejar con cada objetivo. Las comprobaciones se agregan en el orden en que aparecen los qubits objetivo, por lo que el layout final del circuito comprobado es layout + ancilla_qubits. Para ejecutar un circuito de salida con menos (i) comprobaciones, el layout final es layout + ancilla_qubits[:i].
La salida de add_pauli_checks es una secuencia de circuitos con un número creciente de comprobaciones, desde ninguna comprobación hasta una comprobación en cada qubit objetivo. La visualización confirma que las comprobaciones usan los pares objetivo y ancilla especificados. Para más detalles sobre cómo encontrar buenas comprobaciones, consulta las secciones II a IV de la información complementaria en la referencia [1].
checked = add_pauli_checks(
circuit_isa,
target_qubits,
noise_model,
ancilla_qubits=ancilla_qubits,
cost="gamma",
method="windowed",
seed=seed,
)
print(f"Physical layout of payload and ancillas: {layout + ancilla_qubits}")
print("Checked circuit:")
checked[-1].circuit.draw("mpl", fold=-1, idle_wires=False)
Physical layout of payload and ancillas: [108, 107, 106, 105, 117, 125, 124, 123, 136, 143, 142, 141, 104, 97, 109, 122, 126, 140, 144]
Checked circuit:

Paso 3: Ejecutar usando primitivas de Qiskit
Para hacer visible el efecto del ruido de puertas, aumenta la profundidad del payload y muestrea un subconjunto de sus estabilizadores. Cada estabilizador generalmente no conmuta cúbit a cúbit con los demás, por lo que un único conjunto de comprobaciones no es válido para dos estabilizadores diferentes. En lugar de agrupar los estabilizadores en conjuntos que conmutan, encuentra un buen conjunto de comprobaciones para cada estabilizador de forma independiente. Muestrear estabilizadores de manera uniforme y aleatoria da una estimación de fidelidad insesgada.
Construye el circuito más profundo y extrae una muestra aleatoria de sus estabilizadores.
depth = 24
num_stabilizers = 20
num_shots = 1_000
circuit = random_clifford_circuit(num_qubits, depth, rng)
# Build the full stabilizer group, then sample from it uniformly at random
circ_no_meas = circuit.remove_final_measurements(inplace=False)
stabilizer_group = PauliList([Pauli("I" * num_qubits)])
for generator in (
Pauli(label) for label in Clifford(circ_no_meas).to_labels(mode="S")
):
stabilizer_group = stabilizer_group + stabilizer_group.compose(generator)
keep = np.where(
stabilizer_group.x.any(axis=1) | stabilizer_group.z.any(axis=1)
)[0]
chosen = np.random.default_rng(seed).choice(
keep, size=min(num_stabilizers, len(keep)), replace=False
)
stabilizers = [stabilizer_group[int(i)] for i in chosen]
two_qubit_depth = circuit.depth(lambda x: x.operation.num_qubits == 2)
print(
f"Sampled {len(stabilizers)} stabilizers of a {circuit.num_qubits}-qubit "
f"circuit with two-qubit depth {two_qubit_depth}: "
f"{{{stabilizers[0]}, {stabilizers[1]}, ...}}"
)
Sampled 20 stabilizers of a 12-qubit circuit with two-qubit depth 24: {ZXIIXZYYXIZZ, XXXYIIZYXIII, ...}
Para cada estabilizador muestreado, rota el circuito para que el estabilizador se mida en la base computacional, transpílalo al backend, y encuentra un buen conjunto de comprobaciones. Los pares objetivo y ancilla se mezclan juntos para cada estabilizador de modo que cada objetivo conserve su ancilla. Recuerda que las comprobaciones se confirman secuencialmente en el orden en que se dan los qubits objetivo, y una comprobación confirmada no cambia a medida que se agregan más comprobaciones.
noisy_circuits = []
checked_circuits = []
depths_2q = []
t0 = time.time()
for i, pauli in enumerate(tqdm(stabilizers)):
noisy_circuits.append(pm.run(append_basis_rotation(circuit, pauli)))
# Shuffle target and ancilla pairs together so each target keeps its ancilla
targets, ancillas = zip(
*random.sample(
list(zip(target_qubits, ancilla_qubits, strict=True)),
k=len(target_qubits),
),
strict=True,
)
checked_circuits.append(
add_pauli_checks(
noisy_circuits[-1],
list(targets),
noise_model,
ancilla_qubits=list(ancillas),
cost="gamma",
method="windowed",
seed=seed + 1 + i,
)
)
depths_2q.append(
checked_circuits[-1][-1].circuit.depth(lambda x: len(x.qubits) == 2)
)
print(
f"Added {num_checks} checks to {len(stabilizers)} circuits "
f"in {(time.time() - t0):.0f}s."
)
print(
f"On average, two-qubit depth increased from "
f"{circuit.depth(lambda x: len(x.qubits) == 2)} to {int(np.mean(depths_2q))} "
f"when adding {num_checks} checks."
)
100%|██████████| 20/20 [00:15<00:00, 1.29it/s]
Added 7 checks to 20 circuits in 15s.
On average, two-qubit depth increased from 24 to 33 when adding 7 checks.
Muestrea el payload sin procesar y los circuitos comprobados con Qiskit Aer. El simulador usa el mismo modelo de despolarización que puntuó las comprobaciones, por lo que el ruido que las comprobaciones tienen como objetivo es el ruido que aplica el simulador.
aer_nm = AerNoiseModel()
aer_nm.add_all_qubit_quantum_error(
depolarizing_error(noise_model.gate_noise, 2), ["cz"]
)
p = noise_model.readout_noise
aer_nm.add_all_qubit_readout_error(ReadoutError([[1 - p, p], [p, 1 - p]]))
noisy_sim = AerSimulator(method="stabilizer", noise_model=aer_nm)
counts = []
for i, checked_circ_result in enumerate(tqdm(checked_circuits)):
noisy_counts = (
noisy_sim.run(
noisy_circuits[i], shots=num_shots, seed_simulator=seed * i + 1
)
.result()
.get_counts()
)
checked_counts_per_variant = []
for k, ck in enumerate(checked_circ_result):
variant_counts = (
noisy_sim.run(
ck.circuit, shots=num_shots, seed_simulator=seed * i + 2 + k
)
.result()
.get_counts()
)
checked_counts_per_variant.append(variant_counts)
counts.append((noisy_counts, checked_counts_per_variant))
100%|██████████| 20/20 [00:17<00:00, 1.13it/s]
Paso 4: Post-procesar y devolver el resultado en el formato clásico deseado
Cada comprobación usa puertas de entrelazamiento entre una ancilla y un objetivo. La ancilla comienza en , por lo que estabiliza su entrada. Propagar hacia adelante a través del circuito comprobado produce un operador de Pauli en la salida cuyos términos no identidad definen el soporte de la comprobación. Una comprobación pasa cuando los bits de su soporte tienen paridad par. Una muestra se conserva solo cuando cada comprobación pasa.
El get_postselection_method de cada CheckedCircuit devuelve una función que mapea una cadena de bits medida a un vector de síndrome. Conserva las muestras cuyo síndrome es cero para cada comprobación, y descarta el resto. El gráfico que sigue muestra que agregar más comprobaciones reduce la tasa de postselección. Una tasa de postselección más baja necesita más shots para alcanzar una precisión objetivo, por lo que hay un compromiso entre la capacidad de detección y el costo de muestreo. La tasa parece converger, lo que indica que las comprobaciones adicionales contribuyen con menos capacidad de detección.
rate_per_variant = []
kept_per_stab = []
for i, (_, checked_counts_per_variant) in enumerate(counts):
rates = []
kept_at_num_checks = None
for k, variant_counts in enumerate(checked_counts_per_variant):
ps_fn = checked_circuits[i][k].get_postselection_method()
kept = {
bs: n for bs, n in variant_counts.items() if not ps_fn(bs).any()
}
rates.append(sum(kept.values()) / num_shots)
if k == num_checks:
kept_at_num_checks = kept
rate_per_variant.append(rates)
kept_per_stab.append(kept_at_num_checks)
max_len = max(len(s) for s in rate_per_variant)
rates_arr = np.full((len(rate_per_variant), max_len), np.nan)
for i, s in enumerate(rate_per_variant):
rates_arr[i, : len(s)] = s
ks = np.arange(max_len)
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(ks, rates_arr.T, color="#ff8c00", alpha=0.15, linewidth=1)
ax.plot(
ks,
np.nanmedian(rates_arr, axis=0),
color="black",
linewidth=1,
linestyle="--",
label="median",
)
ax.set_xlabel("Checks committed")
ax.set_ylabel("Postselection rate")
ax.set_ylim((0, 1.05))
ax.set_title(
f"Per-stabilizer postselection rate ({len(rates_arr)} stabilizers)"
)
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Ahora compara la fidelidad del estado ruidoso sin procesar con el estado postseleccionado. Postseleccionar solo las muestras sin error detectado eleva el valor esperado de cada estabilizador, y por lo tanto la fidelidad estimada. Los valores postseleccionados usan menos muestras que los valores en bruto, sin embargo los valores esperados son más precisos y la varianza muestreada es menor. Nota también que la tasa media de postselección está cerca de la fidelidad ruidosa. Esto es lo que se espera cuando las comprobaciones detectan casi todas las muestras erróneas: la fracción de muestras que pasan cada comprobación se aproxima a la fracción de muestras libres de error, que es la fidelidad del estado ruidoso.
results = []
for i, ((noisy_counts, _), kept) in enumerate(
zip(counts, kept_per_stab, strict=True)
):
results.append(
(
expectation(noisy_counts, stabilizers[i]),
expectation(kept, stabilizers[i]),
sum(kept.values()) / num_shots,
)
)
fidelity_noisy = float(np.nanmean([r[0] for r in results]))
fidelity_postsel = float(np.nanmean([r[1] for r in results]))
psr = float(np.mean([r[2] for r in results]))
print(
f"ideal fidelity: 1.0\n"
f"noisy fidelity: {fidelity_noisy:.4f}\n"
f"postselected fidelity: {fidelity_postsel:.4f}\n"
f"mean postselection rate: {psr:.3f}"
)
evs_ideal = np.ones(len(results))
evs_noisy = np.array([r[0] for r in results])
evs_post = np.array([r[1] for r in results])
idx = np.arange(len(results))
def strip(ax, ys, color, label):
m, s = np.nanmean(ys), np.nanstd(ys)
ax.axhspan(
m - s, m + s, color=color, alpha=0.15, label=f"{label} mean and std"
)
ax.axhline(
m, color=color, linewidth=1, linestyle="--", label=f"{label} fidelity"
)
fig, ax = plt.subplots(figsize=(8, 4))
ax.axhline(np.nanmean(evs_ideal), color="black", linewidth=1.5, label="ideal")
strip(ax, evs_noisy, "red", "noisy")
strip(ax, evs_post, "green", "postselected")
ax.scatter(idx, evs_noisy, color="red", s=22, alpha=0.7, label="noisy EVs")
ax.scatter(
idx,
evs_post,
color="green",
s=22,
alpha=0.7,
label="postselected EVs",
)
ax.set_xlabel("stabilizer index")
ax.set_ylabel(r"$\langle G \rangle$")
ax.set_ylim((-0.1, 1.1))
ax.set_title("Per-stabilizer expectation values")
ax.legend(loc="lower left")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
M = np.arange(1, len(results) + 1)
fig, ax = plt.subplots(figsize=(8, 4))
for ys, color, label in [
(evs_ideal, "black", "ideal"),
(evs_noisy, "red", "noisy"),
(evs_post, "green", "postselected"),
]:
cm, sem = cum_mean_sem(ys)
ax.plot(M, cm, color=color, linewidth=1.5, label=label)
ax.fill_between(M, cm - sem, cm + sem, color=color, alpha=0.15)
ax.set_xlabel("number of stabilizers averaged")
ax.set_ylabel("running fidelity estimate")
ax.set_title("Fidelity convergence versus number of stabilizers")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
ideal fidelity: 1.0
noisy fidelity: 0.7899
postselected fidelity: 0.9679
mean postselection rate: 0.780

La puntuación gamma indica cuánto del canal de ruido modelado permanece sin detectar por las comprobaciones. Graficar la puntuación gamma frente al número de comprobaciones confirmadas muestra cómo mejora la capacidad de detección a medida que se agrega cada comprobación. Un valor de 1.0 significa que las comprobaciones capturan todo el ruido modelado. Las curvas caen hacia 1.0 a medida que se confirman más comprobaciones, lo que muestra que cada comprobación adicional captura parte del error restante no detectado.
stab_scores = [
[variant.cost for variant in checked_circ_result]
for checked_circ_result in checked_circuits
]
max_len = max(len(s) for s in stab_scores)
scores = np.full((len(stab_scores), max_len), np.nan)
for i, s in enumerate(stab_scores):
scores[i, : len(s)] = s
ks = np.arange(max_len)
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(ks, scores.T, color="#4682b4", alpha=0.15, linewidth=1)
ax.plot(
ks,
np.nanmedian(scores, axis=0),
color="black",
linewidth=1,
linestyle="--",
label="median",
)
ax.set_xlabel("Checks committed")
ax.set_ylabel("Gamma")
ax.set_yscale("log")
ax.set_title(f"Per-stabilizer gamma curves ({len(scores)} stabilizers)")
ax.legend()
ax.grid(True, alpha=0.3, which="both")
plt.tight_layout()
plt.show()
Ejemplo de hardware a gran escala
El mismo flujo de trabajo se ejecuta en hardware con un payload más grande y profundo. Esta sección reutiliza el backend del ejemplo del simulador pero construye un nuevo layout de 20 qubits con sus propios pares objetivo y ancilla y pass manager, y luego envía los circuitos a la QPU en un único job. A esta escala, la mayoría de los shots activan al menos una comprobación, por lo que la tasa de postselección es baja, y cada circuito necesita un gran presupuesto de shots para tener suficientes muestras que sobrevivan. El ejemplo por lo tanto concentra su presupuesto en unos pocos estabilizadores muestreados; esto sigue siendo una estimación de fidelidad insesgada, pero es más gruesa que el promedio del ejemplo del simulador sobre muchos estabilizadores.
Una cosa cambia respecto al ejemplo del simulador: en lugar de inferir un canal de despolarización uniforme a partir de datos de calibración, esta sección aprende el modelo de ruido con NoiseLearner y construye el modelo de qiskit-paulice a partir del resultado con NoiseModel.from_pauli_lindblad_maps. Un modelo de Pauli-Lindblad aprendido captura la estructura espacial del ruido en este layout específico, en lugar de asumir que cada arista es igualmente ruidosa, por lo que la ubicación de las comprobaciones se puntúa frente a un ruido más parecido al que afecta a la QPU. Aprender el ruido requiere muestrear la QPU y debe tenerse en cuenta en cualquier presupuesto general de muestreo de la QPU.
Los parámetros que siguen establecen el número de qubits, la profundidad, el número de estabilizadores y el número de shots. Escala hw_num_shots con el inverso de la tasa de postselección: a una tasa del 3%, 40,000 shots dejan aproximadamente 1,200 muestras postseleccionadas por circuito. Aumenta hw_num_stabilizers para obtener una estimación de fidelidad más precisa a costa de más circuitos por job, cada uno de los cuales necesita el mismo presupuesto de shots.
Pasos 1-4 (comprimidos en un único bloque de código)
La siguiente celda ejecuta los mismos cuatro pasos que el ejemplo del simulador. Construye el payload más grande y muestrea unos pocos estabilizadores (paso 1); elige un layout, aprende el modelo de ruido en él, y encuentra el circuito completamente comprobado para cada estabilizador (paso 2); envía un job de Sampler que contiene tanto los circuitos sin procesar como los comprobados (paso 3); y postselecciona los conteos comprobados para comparar las estimaciones de fidelidad ruidosa y postseleccionada, por estabilizador y en promedio (paso 4). A esta escala, enumerar el grupo estabilizador completo como en el ejemplo del simulador es inviable, por lo que la celda submuestrea un subconjunto aleatorio de estabilizadores para calcular una estimación de la fidelidad.
Ten en cuenta que el paso 2 hace más aquí que en el ejemplo del simulador: aprender el modelo de ruido envía su propio job de NoiseLearner antes del job de Sampler, por lo que la celda ejecuta dos jobs en total. Llevan las etiquetas TUT_ASPC_LEARN y TUT_ASPC para que puedas encontrarlos más tarde. Consulta Organizar y buscar por etiquetas de job para más información sobre el etiquetado de jobs.
# -------------------------Step 1: build a larger payload and sample stabilizers-------------------------
hw_num_qubits = 20
hw_depth = 36
hw_num_stabilizers = 10
hw_num_shots = 40_000
hw_circuit = random_clifford_circuit(hw_num_qubits, hw_depth, rng)
hw_no_meas = hw_circuit.remove_final_measurements(inplace=False)
# Enumerating all 2^n stabilizers is infeasible at this size, so draw each
# stabilizer by composing a random subset of the group generators
hw_generators = [
Pauli(label) for label in Clifford(hw_no_meas).to_labels(mode="S")
]
sample_rng = np.random.default_rng(seed)
hw_stabilizers = []
while len(hw_stabilizers) < hw_num_stabilizers:
mask = sample_rng.integers(0, 2, hw_num_qubits).astype(bool)
if not mask.any():
continue # skip the identity
stabilizer = Pauli("I" * hw_num_qubits)
for generator, chosen in zip(hw_generators, mask, strict=True):
if chosen:
stabilizer = stabilizer.compose(generator)
hw_stabilizers.append(stabilizer)
# -------------------------Step 2: find a 20-qubit layout, learn its noise, and add checks-------------------------
# A single bad coupler or bad-readout qubit on the path drags every
# stabilizer down, so search harder and with tighter error thresholds
hw_layout = find_check_layout(
backend,
hw_num_qubits,
rng,
num_trials=500,
max_gate_error=0.015,
max_readout_error=0.05,
)
hw_target_qubits, hw_ancilla_qubits = get_check_qubits(backend, hw_layout)
hw_pm = generate_preset_pass_manager(
optimization_level=0, backend=backend, initial_layout=hw_layout
)
print(f"Layout with {len(hw_target_qubits)} check pairs: {hw_layout}")
# ----- learn a Pauli-Lindblad noise model on this layout -----
# The simulator example scored checks against a uniform depolarizing channel
# inferred from calibration data. Here, learn the noise instead: NoiseLearner
# runs its own job on the QPU and returns a Pauli-Lindblad channel per unique
# entangling layer, so the checks are placed against the noise this layout
# actually has, including its spatial structure. All the sampled stabilizers
# share the same entangling layers and differ only in their final basis
# rotation, so learning on the bare payload covers all of them.
learner = NoiseLearner(
mode=backend,
options={
"max_layers_to_learn": 4,
"num_randomizations": 32,
"shots_per_randomization": 128,
"environment": {"job_tags": ["TUT_ASPC_LEARN"]},
},
)
learner_job = learner.run([hw_pm.run(hw_circuit)])
print(f"Submitted noise-learner job {learner_job.job_id()}")
hw_layer_errors = learner_job.result().data
# To see how much the learned model helps, swap the next line for the
# simulator example's uniform model - a one-line change:
# hw_noise_model = NoiseModel.from_backend(backend, hw_layout, uniform_gate_noise=True)
hw_noise_model = learned_noise_model(hw_layer_errors, hw_layout)
# NoiseLearner characterizes gate noise only, so keep the readout estimate
# from calibration data rather than leaving it unset
hw_noise_model.readout_noise = NoiseModel.from_backend(
backend, hw_layout, uniform_gate_noise=True
).readout_noise
print(
f"Learned {len(hw_layer_errors)} layers; "
f"readout noise {hw_noise_model.readout_noise:.5f}"
)
# ----- add the fully checked circuit per stabilizer -----
hw_noisy_circuits = []
hw_checked_circuits = []
for i, pauli in enumerate(tqdm(hw_stabilizers)):
bare = hw_pm.run(append_basis_rotation(hw_circuit, pauli))
hw_noisy_circuits.append(bare)
variants = add_pauli_checks(
bare,
hw_target_qubits,
hw_noise_model,
ancilla_qubits=hw_ancilla_qubits,
cost="gamma",
method="windowed",
seed=seed + 1 + i,
)
hw_checked_circuits.append(variants[-1]) # keep the fully checked circuit
# -------------------------Step 3: submit one Sampler job with the bare and checked circuits-------------------------
sampler = Sampler(mode=backend)
sampler.options.default_shots = hw_num_shots
sampler.options.environment.job_tags = ["TUT_ASPC"]
pubs = hw_noisy_circuits + [cc.circuit for cc in hw_checked_circuits]
job = sampler.run(pubs)
print(f"Submitted job {job.job_id()} with {len(pubs)} circuits")
# -------------------------Step 4: postselect and compare fidelity-------------------------
result = job.result()
n_stab = len(hw_stabilizers)
hw_results = []
for i in range(n_stab):
noisy_counts = result[i].join_data().get_counts()
checked_counts = result[n_stab + i].join_data().get_counts()
ps_fn = hw_checked_circuits[i].get_postselection_method()
kept = {bs: c for bs, c in checked_counts.items() if not ps_fn(bs).any()}
hw_results.append(
(
expectation(noisy_counts, hw_stabilizers[i]),
expectation(kept, hw_stabilizers[i]),
sum(kept.values()) / sum(checked_counts.values()),
)
)
hw_fidelity_noisy = float(np.nanmean([r[0] for r in hw_results]))
hw_fidelity_postsel = float(np.nanmean([r[1] for r in hw_results]))
hw_psr = float(np.mean([r[2] for r in hw_results]))
print(
f"noisy fidelity estimate: {hw_fidelity_noisy:.4f}\n"
f"postselected fidelity estimate: {hw_fidelity_postsel:.4f}\n"
f"mean postselection rate: {hw_psr:.4f} "
f"(~{int(round(hw_psr * hw_num_shots))} kept shots per circuit)"
)
# Per-stabilizer breakdown. The postselection rate varies from stabilizer to
# stabilizer, so a stabilizer whose postselected value barely moves is usually
# one whose checks rejected little; the kept-shot count says how much of the
# gap is statistics rather than signal.
print("\nper-stabilizer results:")
print(
f"{'idx':>3} {'noisy':>8} {'postsel':>8} {'psr':>7} {'kept shots':>10}"
)
for i, (noisy, post, psr_i) in enumerate(hw_results):
print(
f"{i:>3} {noisy:>8.4f} {post:>8.4f} {psr_i:>7.4f} "
f"{int(round(psr_i * hw_num_shots)):>10}"
)
hw_noisy = np.array([r[0] for r in hw_results])
hw_post = np.array([r[1] for r in hw_results])
idx = np.arange(n_stab)
fig, ax = plt.subplots(figsize=(9, 4))
ax.axhline(1.0, color="black", linewidth=1.5, label="ideal")
strip(ax, hw_noisy, "red", "noisy")
strip(ax, hw_post, "green", "postselected")
ax.scatter(idx, hw_noisy, color="red", s=22, alpha=0.7, label="noisy EVs")
ax.scatter(
idx,
hw_post,
color="green",
s=22,
alpha=0.7,
label="postselected EVs",
)
ax.set_xlabel("stabilizer index")
ax.set_ylabel(r"$\langle G \rangle$")
ax.set_ylim((-0.1, 1.1))
ax.set_xticks(idx)
ax.set_title("Per-stabilizer expectation values on hardware")
# Outside the axes so it cannot hide a data point
ax.legend(loc="center left", bbox_to_anchor=(1.02, 0.5), frameon=False)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Layout with 11 check pairs: [153, 152, 151, 138, 131, 130, 129, 118, 109, 110, 111, 98, 91, 90, 89, 78, 69, 70, 71, 58]
Submitted noise-learner job d9f4mncjeosc73fjfmkg
Learned 4 layers; readout noise 0.00470
100%|██████████| 10/10 [01:01<00:00, 6.18s/it]
Submitted job d9f4s04jeosc73fjftkg with 20 circuits
noisy fidelity estimate: 0.3685
postselected fidelity estimate: 0.6869
mean postselection rate: 0.2851 (~11404 kept shots per circuit)
per-stabilizer results:
idx noisy postsel psr kept shots
0 0.3769 0.6918 0.3247 12987
1 0.3745 0.6760 0.2999 11995
2 0.3659 0.6389 0.3549 14196
3 0.3821 0.7060 0.2660 10641
4 0.3653 0.7475 0.2531 10124
5 0.3752 0.7022 0.2698 10791
6 0.3508 0.7144 0.2711 10842
7 0.3485 0.7087 0.2381 9523
8 0.3825 0.6289 0.2928 11711
9 0.3630 0.6549 0.2808 11232
Para circuitos de este tamaño, la mayoría de las muestras contienen al menos un error detectado, por lo que la tasa de postselección es pequeña y la postselección descarta la mayoría de los shots. Las muestras que pasan cada comprobación dan un valor esperado mucho mejor que el circuito sin procesar, y los valores por estabilizador se separan claramente de la línea base ruidosa. Para ajustar la estimación de fidelidad, muestrea más estabilizadores con el mismo presupuesto de shots por circuito. Para aumentar la tasa de postselección, reduce la profundidad del circuito o confirma menos comprobaciones; para avanzar hacia payloads más grandes, escala el presupuesto de shots con el inverso de la tasa de postselección.
Próximos pasos
Si este trabajo te resultó interesante, podría interesarte el siguiente material:
-
El tutorial sobre códigos de repetición para una introducción a la corrección de errores cuánticos.
-
La documentación de
qiskit-paulicepara la API completa de búsqueda de comprobaciones, y el repositorio de GitHub del paquete para el código fuente. -
El artículo Low-overhead error detection with spacetime codes para la teoría detrás de las comprobaciones.
Referencias
-
[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.