Detecção de erros com baixa sobrecarga usando códigos espaço-temporais
Estimativa de tempo de execução: 4 minutos em um processador Heron (ibm_kingston ou equivalente) (NOTA: Trata-se apenas de uma estimativa.) (O tempo de execução pode variar.)
Resultados do aprendizado
- Como as verificações de Pauli no espaço-tempo detectam erros lógicos em circuitos de Clifford e como a pós-seleção com base em suas síndromes aumenta a fidelidade de uma distribuição amostrada.
- Como usar o pacote
qiskit-paulicepara localizar e inserir verificações eficientes em termos de hardware automaticamente comget_check_qubits,NoiseModel, eadd_pauli_checks. - Como estimar a fidelidade de um estado estabilizador por meio da amostragem de seus estabilizadores e da pós-seleção com base em síndromes de verificação.
- Como executar o fluxo de trabalho completo de detecção de erros em um hardware d IBM Quantum® e e comparar as fidelidades com ruído e pós-selecionadas.
Pré-requisitos
- Fundamentos de hardware para computação quântica em escala de serviços públicos.
- O formalismo de Clifford e do estabilizador, incluindo como um grupo estabilizador descreve um estado puro do estabilizador.
Segundo plano
O artigo “Detecção de erros com baixa sobrecarga usando códigos espaço-temporais” [1], de Simon Martiel e Ali Javadi-Abhari, apresenta um método para detectar erros lógicos em circuitos dominados por Clifford que se situa entre a correção completa de erros e a mitigação de erros, que é uma abordagem mais leve. A ideia se baseia nas verificações coerentes de Pauli (CPC) descritas no artigo “Mitigação de erros em um único disparo por meio de verificações coerentes de Pauli” [2], de van den Berg e outros. Em ambas as abordagens, um circuito de “carga útil” de Clifford é entrelaçado com qubits auxiliares para verificar certas invariantes. A medição das ancillas gera uma síndrome que indica se foi detectado algum erro durante a execução. Manter apenas as amostras nas quais não foi detectado nenhum erro melhora a fidelidade da distribuição amostrada, em troca de uma taxa de pós-seleção reduzida.
A principal diferença entre as verificações coerentes de Pauli e as verificações no espaço-tempo é os operadores que elas medem. As verificações de Pauli coerentes medem operadores localizados no tempo e de alto peso. Em topologias de qubits com conectividade limitada, como a “heavy hex”, essas verificações exigem muitas portas SWAP e, muitas vezes, tornam o circuito muito complexo para ser executado na prática. A implementação das verificações como códigos espaço-temporais, por outro lado, distribui cada verificação pelo circuito de carga útil no espaço e no tempo. Isso resulta em uma codificação eficiente em termos de hardware que mantém a eficácia na detecção de erros lógicos, ao mesmo tempo em que mantém baixas as sobrecargas relacionadas aos qubits e à profundidade.
O que o pacote qiskit-paulice faz
O pacote qiskit-paulice automatiza a criação dessas verificações, para que você não precise criá-las manualmente. Sua principal função é identificar e inserir verificações de Pauli válidas no espaço-tempo nos pontos de um circuito que maximizem a detecção de erros e, ao mesmo tempo, minimizem a sobrecarga dos qubits. Uma verificação é válida quando seus operadores não alteram a ação lógica do circuito de carga útil, é de baixo peso quando utiliza poucas portas de entrelaçamento e é eficaz quando detecta uma grande parcela dos erros, em relação ao ruído que a própria verificação introduz. O pacote avalia as verificações candidatas em relação a um modelo de ruído e incorpora as melhores ao circuito. Este tutorial utiliza três métodos da API:
get_check_qubitsinspeciona um mapa de acoplamento do backend e retorna pares de qubits-alvo e qubits auxiliares. Uma verificação sobre utilizatarget_qubits[i]ancilla_qubits[i].NoiseModel.from_backendcria um modelo aproximado de ruído a partir de dados de benchmark do backend. O modelo avalia as verificações dos candidatos; portanto, não é necessário um modelo de ruído exato e treinado. Para um modelo de Pauli-Lindblad treinado, consulteNoiseModel.from_pauli_lindblad_maps.add_pauli_checkslocaliza e insere verificações em um circuito. Ela retorna uma sequência deCheckedCircuitobjetos com um número crescente de verificações, e cada objeto fornece uma funçãoget_postselection_methodque mapeia uma cadeia de bits medida para um vetor de síndrome. O argumentocostseleciona a função que avalia um teste (gamma, a sobrecarga de amostragem do canal de ruído inverso pós-selecionado, ouLER, a taxa de erro lógico). O argumentomethodseleciona a estratégia de pesquisa (windowed,genetic, ouwindowed_genetic). Este tutorial utiliza ecost="gamma"method="windowed", que, juntos, proporcionam uma seleção de verificações determinística e reproduzível.
Estimativa da fidelidade a partir da amostragem do estabilizador
Para avaliar a eficácia da detecção de erros, é possível estimar a fidelidade entre o estado do estabilizador — que o circuito, idealmente, prepara — e o estado com ruído — que o hardware realmente gera. A projeção de um estado em um estabilizador puro é igual à média uniforme sobre os elementos de seu grupo estabilizador :
Substituindo isso na fórmula da fidelidade, obtém-se a fidelidade de como o valor esperado médio de cada estabilizador em relação a :
Para problemas maiores, enumerar todos os estabilizadores é inviável; portanto, é possível estimar a fidelidade a partir de uma amostra aleatória. A seleção de estabilizadores e de forma aleatória e uniforme a partir de fornece uma estimativa não sesgada:
Como um circuito de Clifford prepara um estado estabilizador, é possível estimar sua fidelidade diretamente a partir dos valores esperados amostrados de seus estabilizadores. Este tutorial primeiro apresenta o fluxo de trabalho em um simulador com um circuito pequeno e, em seguida, executa o mesmo fluxo de trabalho em hardware com um circuito maior e mais complexo. À medida que os circuitos incluem mais operações não-Clifford, o número de verificações válidas diminui rapidamente; portanto, o método funciona melhor para circuitos dominados por operações Clifford.
Requisitos
Antes de iniciar este tutorial, certifique-se de ter os seguintes itens instalados:
- Qiskit SDK v2.0 ou versão posterior, com suporte à visualização
- Qiskit Runtime v0.40 ou posterior (
pip install qiskit-ibm-runtime) - Qiskit Aer v0.17 ou versão posterior (
pip install qiskit-aer) - Qiskit Paulice (
pip install qiskit-paulice) - tqdm (
pip install tqdm)
Instalação
Importe as bibliotecas necessárias e defina as funções auxiliares que não estão disponíveis como importações. A função random_clifford_circuit constrói uma carga útil Clifford aleatória do tipo “brickwork”, find_check_layout pesquisa um mapa de acoplamento do backend em busca de um caminho de qubits com baixo erro e muitas ancillas disponíveis, learned_noise_model transforma a saída NoiseLearner em um modelo de ruído qiskit-paulice , append_basis_rotation gira um circuito de modo que um estabilizador seja medido na base computacional, expectation calcula o valor esperado do estabilizador a partir de contagens amostradas e cum_mean_sem acompanha a estimativa de fidelidade em tempo real.
# 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 NoiseModeldef 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), semExemplo de simulador em pequena escala
Esta seção apresenta passo a passo todo o fluxo de trabalho em um simulador com ruído. Ele utiliza dados de benchmark do backend para escolher um layout de qubits e um modelo de ruído, identifica verificações automaticamente e utiliza pós-seleção na distribuição amostrada para demonstrar a melhoria na fidelidade.
Etapa 1: Mapeie entradas clássicas para um problema quântico
O circuito de carga útil é um circuito de Clifford aleatório unidimensional de estrutura em tijolo e pouca profundidade. Como o circuito é do tipo Clifford, ele prepara um estado estabilizador cuja fidelidade pode ser estimada diretamente a partir dos valores esperados amostrados do estabilizador. Comece com um circuito simples, para que as verificações sejam fáceis de visualizar na próxima etapa.
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)Output:
Etapa 2: Otimizar para execução em hardware quântico
O mapeamento do circuito para o hardware define o layout físico dos qubits, o modelo de ruído que avalia as verificações candidatas e as próprias verificações.
Primeiro, selecione um backend e procure em seu mapa de acoplamento um layout de qubits unidimensional com o find_check_layout auxiliar definido na seção “Configuração”. O auxiliar gera percursos aleatórios que evitam os portões e as leituras com maior erro, e mantém o caminho que oferece o maior número de pares de alvo e ancilla. Como a pesquisa lê os dados de conectividade e de erros diretamente do próprio backend, o mesmo código é executado em qualquer QPU do IBM Quantum. A função get_check_qubits retorna, então, os pares de alvo e ancilla, em que uma verificação sobre utiliza target_qubits[i] ancilla_qubits[i].
No gráfico de acoplamento a seguir, os qubits verdes são os qubits de carga útil e os qubits laranja são os qubits auxiliares que realizam as verificações. Os qubits com um ancilla adjacente são utilizados como qubits-alvo para as verificações.
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,
)Output:
Backend: ibm_boston
Target qubits: [105, 107, 108, 123, 125, 141, 143]
Ancilla qubits: [104, 97, 109, 122, 126, 140, 144]
Com o backend e o layout escolhidos, compile a carga útil em um circuito de arquitetura de conjunto de instruções (ISA). Basta definir o layout e converter os portões para o conjunto de portões nativo do 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)Output:
Em seguida, modele como o ruído de porta e de leitura no backend afetam a execução. O modelo de ruído determina em que ponto do circuito uma verificação detecta o maior número de erros. Um modelo mais preciso melhora a detecção, mas geralmente não é necessário treiná-lo por meio de amostragem na QPU. O modelo a seguir infere um canal despolarizador uniforme para o ruído de porta e de leitura a partir de dados de referência qiskit-ibm-runtime .
noise_model = NoiseModel.from_backend(
backend, layout, uniform_gate_noise=True
)
print(noise_model)Output:
NoiseModel(gate_noise=0.001079865281450939, readout_noise=0.006001790364583333, idling_noise=None)
Agora, adicione os componentes de verificação ao circuito. A função add_pauli_checks recebe a carga útil de Clifford, a lista de qubits-alvo e o modelo de ruído. O argumento ancilla_qubits indica à função qual ancila física deve ser emparelhada com cada alvo. As verificações são adicionadas na ordem em que os qubits-alvo aparecem; portanto, o layout final do circuito verificado é layout + ancilla_qubits. Para operar um circuito de saída com menos (i) verificações, o layout final é layout + ancilla_qubits[:i].
A saída de é add_pauli_checks uma sequência de circuitos com um número crescente de verificações, desde nenhuma verificação até uma verificação em cada qubit-alvo. A visualização confirma que as verificações utilizam os pares de alvo e ancilla especificados. Para obter detalhes sobre como identificar bons testes, consulte as Seções II a IV das informações complementares na referência [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)Output:
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:
Etapa 3: Executar usando Qiskit primitives
Para tornar visível o efeito do ruído da porta, aumente a profundidade da carga útil e amostre um subconjunto de seus estabilizadores. Geralmente, cada estabilizador não comuta com os outros em nível de qubit; portanto, um único conjunto de verificações não é válido para dois estabilizadores diferentes. Em vez de agrupar os estabilizadores em conjuntos de comutação, encontre um bom conjunto de verificações para cada estabilizador individualmente. A amostragem aleatória e uniforme dos estabilizadores fornece uma estimativa não sesgada da fidelidade.
Construa o circuito mais complexo e selecione uma amostra aleatória de seus 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]}, ...}}"
)Output:
Sampled 20 stabilizers of a 12-qubit circuit with two-qubit depth 24: {ZXIIXZYYXIZZ, XXXYIIZYXIII, ...}
Para cada estabilizador amostrado, gire o circuito de modo que o estabilizador seja medido na base computacional, transpilá-lo para o backend e encontre um bom conjunto de verificações. Os pares de alvo e ancilla são embaralhados juntos para cada estabilizador, de modo que cada alvo mantenha sua ancilla. Lembre-se de que as verificações são confirmadas sequencialmente, na ordem em que os qubits-alvo são especificados, e uma verificação confirmada não é alterada à medida que mais verificações são adicionadas.
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."
)Output:
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.
Teste a carga útil básica e os circuitos verificados com o Qiskit Aer. O simulador utiliza o mesmo modelo de despolarização usado para avaliar os testes; portanto, o ruído que os testes visam detectar é o mesmo ruído que o simulador aplica.
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))Output:
100%|██████████| 20/20 [00:17<00:00, 1.13it/s]
Etapa 4: Realizar o pós-processamento e apresentar o resultado no formato clássico desejado
Cada verificação utiliza portas de entrelaçamento entre uma ancilla e um alvo. A ancilla é iniciada em , portanto, estabiliza sua entrada. A propagação de um no sentido direto através do circuito verificado resulta em um operador de Pauli na saída, cujos termos diferentes da identidade definem o suporte da verificação. Um cheque é aprovado quando os bits que o compõem têm paridade par. Uma amostra só é mantida quando todas as verificações são aprovadas.
O de get_postselection_method cada retorna CheckedCircuit uma função que mapeia uma sequência de bits medida para um vetor de síndrome. Mantenha as amostras cujo índice seja zero em cada verificação e descarte as demais. O gráfico a seguir mostra que aumentar o número de verificações reduz a taxa de pós-seleção. Uma taxa de pós-seleção mais baixa exige mais tentativas para atingir a precisão desejada; portanto, há um compromisso entre a capacidade de detecção e o custo de amostragem. A taxa parece convergir, o que indica que verificações adicionais contribuem menos para a capacidade de detecção.
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()Output:
Agora, compare a fidelidade do estado puro com ruído com a do estado pós-selecionado. A pós-seleção apenas das amostras nas quais não foi detectado nenhum erro aumenta o valor esperado de cada estabilizador e, consequentemente, a fidelidade estimada. Os valores pós-selecionados utilizam menos amostras do que os valores brutos; no entanto, os valores esperados são mais precisos e a variância amostral é menor. Observe também que a taxa média pós-seleção está próxima da fidelidade com ruído. É isso que se espera quando as verificações detectam quase todas as amostras com erros: a fração de amostras que são aprovadas em todas as verificações se aproxima da fração de amostras sem erros, que é a fidelidade do 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()Output:
ideal fidelity: 1.0
noisy fidelity: 0.7899
postselected fidelity: 0.9679
mean postselection rate: 0.780
O índice gama indica qual a proporção do canal de ruído modelado que permanece não detectada pelas verificações. Ao traçar o gráfico da pontuação gama em função do número de verificações confirmadas, fica evidente como a capacidade de detecção melhora à medida que cada verificação é adicionada. Um valor de significa 1.0 que as verificações capturam todo o ruído modelado. As curvas se inclinam em direção a 1.0 à medida que mais verificações são executadas, o que demonstra que cada verificação adicional detecta parte do erro remanescente não identificado.
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()Output:
Exemplo de hardware em grande escala
O mesmo fluxo de trabalho é executado em hardware com uma capacidade útil maior e mais profunda. Esta seção reutiliza o backend do exemplo do simulador, mas cria um novo layout de 20 qubits com seus próprios pares de qubits-alvo e auxiliares, além de um gerenciador de passagens, e, em seguida, envia os circuitos para a QPU em um único trabalho. Nesse tamanho, a maioria dos disparos aciona pelo menos uma verificação; portanto, a taxa de pós-seleção é baixa, e cada circuito precisa de um grande orçamento de disparos para obter amostras suficientes para sobreviver. Portanto, o exemplo concentra seu orçamento em alguns estabilizadores selecionados; essa ainda é uma estimativa de fidelidade não sesgada, mas é mais aproximada do que a média calculada pelo simulador com base em muitos estabilizadores.
Há uma diferença em relação ao exemplo do simulador: em vez de inferir um canal despolarizador uniforme a partir dos dados de calibração, esta seção aprende o modelo de ruído com NoiseLearner e constrói o modelo qiskit-paulice a partir do resultado com NoiseModel.from_pauli_lindblad_maps. Um modelo Pauli-Lindblad treinado captura a estrutura espacial do ruído nesse layout específico, em vez de supor que todas as arestas apresentem o mesmo nível de ruído; assim, a colocação do xadrez é avaliada em relação a um ruído mais semelhante ao que afeta a QPU. A aprendizagem do ruído requer amostragem da QPU e deve ser considerada em qualquer orçamento geral de amostragem da QPU.
Os parâmetros a seguir definem o número de qubits, a profundidade, o número de estabilizadores e o número de disparos. A escala varia de acordo com hw_num_shots o inverso da taxa de pós-seleção: a uma taxa de 3%, 40.000 disparos resultam em aproximadamente 1.200 amostras pós-selecionadas por circuito. Aumente hw_num_stabilizers para obter uma estimativa de fidelidade mais precisa, ao custo de mais circuitos por tarefa, cada um dos quais requer o mesmo orçamento de disparos.
Etapas 1 a 4 (reunidas em um único bloco de código)
A célula a seguir executa as mesmas quatro etapas do exemplo do simulador. Ele constrói a carga útil maior e seleciona alguns estabilizadores (etapa 1); escolhe um layout, aprende o modelo de ruído nele e encontra o circuito totalmente verificado para cada estabilizador (etapa 2); envia um trabalho do Sampler que contém tanto os circuitos básicos quanto os verificados (etapa 3); e realiza a pós-seleção das contagens verificadas para comparar as estimativas de fidelidade com ruído e pós-selecionadas, por estabilizador e em média (etapa 4). Nessa escala, enumerar todo o grupo de estabilizadores, como no exemplo do simulador, é inviável; por isso, a célula realiza uma subamostragem aleatória de um subconjunto de estabilizadores para calcular uma estimativa da fidelidade.
Observe que a etapa 2 tem um papel mais importante aqui do que no exemplo do simulador: o aprendizado do modelo de ruído envia seu próprio NoiseLearner trabalho antes do trabalho do Sampler, de modo que a célula executa dois trabalhos no total. Eles vêm com as etiquetas e TUT_ASPC_LEARN TUT_ASPC para que você possa encontrá-los mais tarde. Consulte “Organizar e pesquisar por tags de vagas” para saber mais sobre como marcar vagas com tags.
# -------------------------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()Output:
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 desse tamanho, a maioria das amostras contém pelo menos um erro detectado; portanto, a taxa de pós-seleção é baixa e a pós-seleção descarta a maioria das tomadas. As amostras que passam em todas as verificações apresentam um valor esperado muito melhor do que o circuito simples, e os valores por estabilizador se destacam claramente da linha de base ruidosa. Para refinar a estimativa de fidelidade, amostre mais estabilizadores com o mesmo orçamento de disparos por circuito. Para aumentar a taxa de pós-seleção, reduza a profundidade do circuito ou execute menos verificações; para alcançar cargas úteis maiores, dimensione o orçamento de disparos com o inverso da taxa de pós-seleção.
Próximas etapas
Se você achou este trabalho interessante, talvez se interesse pelo material a seguir:
- O tutorial sobre códigos de repetição serve como uma introdução à correção de erros quânticos.
- A documentação
qiskit-pauliceda API completa do check-finding e o repositório GitHub do pacote, que contém o código-fonte. - O artigo “Detecção de erros com baixa sobrecarga usando códigos espaço-temporais” apresenta a teoria por trás das verificações.
Referências
- [1] Martiel, S., e Javadi-Abhari, A. (2025). Detecção de erros com baixa sobrecarga utilizando códigos espaço-temporais. arXiv pré-impressão arXiv:2504.15725.
- [2] van den Berg, E., Bravyi, S., Gambetta, J. M., Jurcevic, P., Maslov, D., e Temme, K. (2023). Mitigação de erros em um único disparo por meio de verificações de Pauli coerentes. Physical Review Research, 5(3), 033193.