Перейти до основного вмісту

Виявлення похибок з низькими накладними витратами за допомогою просторово-часових кодів

Оцінка часу використання: 4 хвилини на процесорі Heron (ibm_kingston або еквівалент) (ПРИМІТКА: це лише оцінка. Твій фактичний час виконання може відрізнятися.)

Результати навчання

  • Як просторово-часові перевірки Паулі виявляють логічні похибки в схемах Кліффорда, і як постселекція за їхніми синдромами підвищує точність вибіркового розподілу.

  • Як використовувати пакет qiskit-paulice для автоматичного пошуку та вставлення апаратно-ефективних перевірок за допомогою get_check_qubits, NoiseModel та add_pauli_checks.

  • Як оцінити точність стабілізаторного стану шляхом вибірки його стабілізаторів та постселекції за синдромами перевірок.

  • Як запустити повний робочий процес виявлення похибок на апаратному забезпеченні IBM Quantum® та порівняти зашумлену і постселектовану точність.

Передумови

  • Основи апаратного забезпечення для квантових обчислень корисного масштабу.

  • Формалізм Кліффорда та стабілізаторів, включно з тим, як стабілізаторна група описує чистий стабілізаторний стан.

Передумови

Виявлення похибок з низькими накладними витратами за допомогою просторово-часових кодів [1] авторства Simon Martiel та Ali Javadi-Abhari представляє метод виявлення логічних похибок у схемах, домінованих Кліффордом, який знаходиться між повним виправленням похибок та легшим пом'якшенням похибок. Ідея базується на когерентних перевірках Паулі (CPC) з Однократного пом'якшення похибок за допомогою когерентних перевірок Паулі [2] авторства van den Berg та інших. В обох підходах схема "навантаження" Кліффорда заплутується з допоміжними кубітами для перевірки певних інваріантів. Вимірювання допоміжних кубітів створює синдром, який повідомляє, чи була виявлена похибка під час виконання. Збереження лише зразків без виявленої похибки покращує точність вибіркового розподілу, ціною зниженої частоти постселекції.

Ключова відмінність між когерентними перевірками Паулі та просторово-часовими перевірками полягає в операторах, які вони вимірюють. Когерентні перевірки Паулі вимірюють локалізовані в часі оператори з високою вагою. На кубітних топологіях з обмеженою зв'язністю, наприклад heavy hex, такі перевірки потребують багато вентилів SWAP і часто роблять схему занадто глибокою для практичного виконання. Реалізація перевірок у вигляді просторово-часових кодів натомість розподіляє кожну перевірку по схемі навантаження в просторі та часі. Це дає апаратно-ефективне кодування, яке залишається ефективним у виявленні логічних похибок, зберігаючи низькі накладні витрати на кубіти та глибину.

Що робить пакет qiskit-paulice

Пакет qiskit-paulice автоматизує побудову цих перевірок, щоб тобі не довелося будувати їх вручну. Його основна роль — знаходити та вставляти дійсні просторово-часові перевірки Паулі в тих місцях схеми, які максимізують виявлення похибок, мінімізуючи накладні витрати на кубіти. Перевірка є дійсною, коли її оператори залишають логічну дію схеми навантаження незмінною, низьковаговою, коли вона використовує мало заплутуючих вентилів, і ефективною, коли вона виявляє велику частину похибок відносно шуму, який сама перевірка вносить. Пакет оцінює кандидатські перевірки відносно моделі шуму та фіксує найкращі з них у схемі. Цей туторіал використовує три методи API:

  • get_check_qubits перевіряє карту зв'язності бекенду і повертає пари цільового та допоміжного кубітів. Перевірка на target_qubits[i] використовує ancilla_qubits[i].

  • NoiseModel.from_backend будує приблизну модель шуму з даних тестування бекенду. Модель оцінює кандидатські перевірки, тому точна, вивчена модель шуму не потрібна. Для вивченої моделі Паулі-Ліндблада див. NoiseModel.from_pauli_lindblad_maps.

  • add_pauli_checks знаходить і вставляє перевірки в схему. Він повертає послідовність об'єктів CheckedCircuit зі зростаючою кількістю перевірок, і кожен об'єкт надає get_postselection_method, що відображає виміряний рядок бітів на вектор синдрому. Аргумент cost вибирає функцію, яка оцінює перевірку (gamma, накладні витрати на вибірку постселектованого оберненого шумового каналу, або LER, логічна частота похибок). Аргумент method вибирає стратегію пошуку (windowed, genetic або windowed_genetic). Цей туторіал використовує cost="gamma" та method="windowed", які разом забезпечують детермінований, відтворюваний вибір перевірок.

Оцінка точності з вибірки стабілізаторів

Щоб виміряти, наскільки добре працює виявлення похибок, можна оцінити точність стабілізаторного стану ψ=U0n|\psi\rangle = U|0\rangle^{\otimes n}, який схема ідеально готує, відносно зашумленого стану ρ\rho, який фактично видає апаратне забезпечення. Проектор на чистий стабілізаторний стан ψ|\psi\rangle дорівнює рівномірному середньому по 2n2^n елементах його стабілізаторної групи S\mathcal{S}:

ψψ=12nGSG.|\psi\rangle\langle\psi| = \frac{1}{2^n}\sum_{G \in \mathcal{S}} G.

Підставивши це в точність, отримуємо точність ρ\rho як середнє очікуване значення кожного стабілізатора GSG \in \mathcal{S} відносно ρ\rho:

F=Tr(ρψψ)=12nGSTr(ρG)=12nGSGρ.F = \mathrm{Tr}(\rho|\psi\rangle\langle\psi|) = \frac{1}{2^n} \sum_{G \in \mathcal{S}} \mathrm{Tr}(\rho G) = \frac{1}{2^n} \sum_{G \in \mathcal{S}} \langle G \rangle_\rho.

Для більших задач перерахування всіх 2n2^n стабілізаторів нездійсненне, тому можна оцінити точність за випадковою вибіркою. Вибираючи MM стабілізаторів G1,,GMG_1, \ldots, G_M рівномірно випадковим чином з S\mathcal{S}, отримуємо незміщену оцінку:

F^M=1Mi=1MGiρ.\hat F_M = \frac{1}{M} \sum_{i=1}^M \langle G_i \rangle_\rho.

Оскільки схема Кліффорда готує стабілізаторний стан, можна оцінити її точність безпосередньо з вибіркових очікуваних значень її стабілізаторів. Цей туторіал спершу проходить робочий процес на симуляторі з маленькою схемою, а потім запускає той самий робочий процес на апаратному забезпеченні з більшою, глибшою схемою. У міру того, як схеми включають більше операцій, що не є Кліффордом, кількість дійсних перевірок швидко зменшується, тому метод найкраще працює для схем, домінованих Кліффордом.

Вимоги

Перш ніж почати цей туторіал, переконайся, що в тебе встановлено таке:

  • Qiskit SDK v2.0 або новіший, з підтримкою візуалізації

  • Qiskit Runtime v0.40 або новіший (pip install qiskit-ibm-runtime)

  • Qiskit Aer v0.17 або новіший (pip install qiskit-aer)

  • Qiskit Paulice (pip install qiskit-paulice)

  • tqdm (pip install tqdm)

Налаштування

Імпортуй необхідні бібліотеки та визнач допоміжні функції, які не доступні як імпорти. Функція random_clifford_circuit будує випадкове цегляне навантаження Кліффорда, find_check_layout шукає в карті зв'язності бекенду шлях кубітів з низькою похибкою і багатьма доступними допоміжними кубітами, learned_noise_model перетворює вивід NoiseLearner у модель шуму qiskit-paulice, append_basis_rotation обертає схему так, щоб стабілізатор вимірювався в обчислювальному базисі, expectation обчислює очікуване значення стабілізатора з вибіркових підрахунків, а cum_mean_sem відстежує поточну оцінку точності.

# 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

Приклад для симулятора малого масштабу

У цьому розділі розглядається повний робочий процес на зашумленому симуляторі. Він використовує дані тестування бекенду для вибору розташування кубітів та моделі шуму, автоматично знаходить перевірки та використовує постселекцію на вибірковому розподілі, щоб показати покращення точності.

Крок 1: відображення класичних вхідних даних на квантову задачу

Схема навантаження — це неглибока одновимірна цеглана випадкова схема Кліффорда. Оскільки схема Кліффорда, вона готує стабілізаторний стан, точність якого можна оцінити безпосередньо з вибіркових очікуваних значень стабілізаторів. Почни з неглибокої схеми, щоб перевірки було легко візуалізувати на наступному кроці.

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 of the previous code cell

Крок 2: оптимізація для виконання на квантовому апаратному забезпеченні

Відображення схеми на апаратне забезпечення встановлює фізичне розташування кубітів, модель шуму, яка оцінює кандидатські перевірки, та самі перевірки.

Спочатку вибери бекенд і виконай пошук у його карті зв'язності одновимірного розташування кубітів за допомогою допоміжної функції find_check_layout, визначеної в розділі налаштування. Ця допоміжна функція будує випадкові самоуникаючі блукання, які уникають вентилів та зчитувань з найвищою похибкою, і залишає шлях, що пропонує найбільше пар цільових та допоміжних кубітів. Оскільки пошук зчитує зв'язність та дані про похибки з самого бекенду, той самий код працює на будь-якому QPU IBM Quantum. Функція get_check_qubits потім повертає пари цільового та допоміжного кубітів, де перевірка на target_qubits[i] використовує ancilla_qubits[i].

На наступному графі зв'язності зелені кубіти — це кубіти навантаження, а оранжеві кубіти — це допоміжні кубіти, які реалізують перевірки. Кубіти із сусіднім допоміжним кубітом використовуються як цільові кубіти для перевірок.

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]

Output of the previous code cell

Обравши бекенд і розташування, транспілюй навантаження в схему архітектури набору інструкцій (ISA). Потрібно лише встановити розташування та перекласти вентилі в нативний набір вентилів бекенду.

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 of the previous code cell

Далі, змоделюй, як шум вентилів та зчитування на бекенді впливає на виконання. Модель шуму визначає, де в схемі перевірка вловлює найбільше похибки. Точніша модель покращує виявлення, але зазвичай немає потреби вивчати її шляхом вибірки на QPU. Наведена нижче модель виводить однорідний деполяризаційний канал для шуму вентилів та зчитування з даних тестування 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)

Тепер додай перевірки до схеми. Функція add_pauli_checks приймає навантаження Кліффорда, список цільових кубітів та модель шуму. Аргумент ancilla_qubits повідомляє функції, який фізичний допоміжний кубіт поєднати з кожним цільовим. Перевірки додаються в порядку, в якому наведені цільові кубіти, тому кінцеве розташування перевіреної схеми — це layout + ancilla_qubits. Щоб запустити вихідну схему з меншою кількістю (i) перевірок, кінцеве розташування — layout + ancilla_qubits[:i].

Виведенням add_pauli_checks є послідовність схем із зростаючою кількістю перевірок, від відсутності перевірок до однієї перевірки на кожному цільовому кубіті. Візуалізація підтверджує, що перевірки використовують задані пари цільових та допоміжних кубітів. Для деталей про пошук хороших перевірок див. розділи II-IV додаткової інформації в довідці [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:

Output of the previous code cell

Крок 3: виконання за допомогою примітивів Qiskit

Щоб зробити вплив шуму вентилів видимим, збільш глибину навантаження та вибери підмножину його стабілізаторів. Кожен стабілізатор загалом не комутує покубітно з іншими, тому один набір перевірок не є дійсним для двох різних стабілізаторів. Замість групування стабілізаторів у комутуючі набори, знайди хороший набір перевірок для кожного стабілізатора незалежно. Вибірка стабілізаторів рівномірно випадковим чином дає незміщену оцінку точності.

Побудуй глибшу схему та витягни випадкову вибірку її стабілізаторів.

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, ...}

Для кожного вибраного стабілізатора обертай схему так, щоб стабілізатор вимірювався в обчислювальному базисі, транспілюй її на бекенд і знайди хороший набір перевірок. Пари цільових та допоміжних кубітів перемішуються разом для кожного стабілізатора, щоб кожен цільовий зберігав свій допоміжний кубіт. Пам'ятай, що перевірки фіксуються послідовно в порядку, в якому наведені цільові кубіти, і зафіксована перевірка не змінюється в міру додавання більшої кількості перевірок.

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.

Виконай вибірку голого навантаження та перевірених схем за допомогою Qiskit Aer. Симулятор використовує ту саму деполяризаційну модель, яка оцінювала перевірки, тому шум, на який націлені перевірки, — це той шум, який застосовує симулятор.

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]

Крок 4: постобробка та повернення результату в потрібному класичному форматі

Кожна перевірка використовує заплутуючі вентилі між одним допоміжним і одним цільовим кубітом. Допоміжний кубіт починає в 0|0\rangle, тому ZancZ_\text{anc} стабілізує його вхід. Поширення ZancZ_\text{anc} вперед через перевірену схему дає оператор Паулі на виході, чиї нетотожні члени визначають носій перевірки. Перевірка проходить, коли біти в її носії мають парну парність. Зразок зберігається лише тоді, коли проходять усі перевірки.

get_postselection_method кожного CheckedCircuit повертає функцію, яка відображає виміряний рядок бітів на вектор синдрому. Зберігай зразки, синдром яких дорівнює нулю для кожної перевірки, і відкидай решту. Наведений далі графік показує, що додавання більшої кількості перевірок знижує частоту постселекції. Нижча частота постселекції потребує більше шотів для досягнення цільової точності, тому існує компроміс між здатністю виявлення та вартістю вибірки. Здається, що частота сходиться, що вказує на те, що додаткові перевірки дають менший внесок у здатність виявлення.

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 of the previous code cell

Тепер порівняй точність голого зашумленого стану з постселектованим станом. Постселекція лише зразків без виявленої похибки підвищує очікуване значення кожного стабілізатора, а отже, й оцінену точність. Постселектовані значення використовують менше зразків, ніж необроблені значення, проте очікувані значення точніші, а вибіркова дисперсія нижча. Зверни також увагу, що середня частота постселекції близька до зашумленої точності. Це те, чого варто очікувати, коли перевірки виявляють майже всі помилкові зразки: частка зразків, які проходять кожну перевірку, наближається до частки безпомилкових зразків, що і є точністю зашумленого стану.

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

Output of the previous code cell

Output of the previous code cell

Оцінка gamma повідомляє, наскільки з модельованого шумового каналу залишається невиявленим перевірками. Побудова графіка оцінки gamma відносно кількості зафіксованих перевірок показує, як здатність виявлення покращується з додаванням кожної перевірки. Значення 1.0 означає, що перевірки охоплюють весь модельований шум. Криві падають до 1.0 у міру фіксації більшої кількості перевірок, що показує, що кожна додаткова перевірка вловлює частину похибки, що залишалася невиявленою.

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 of the previous code cell

Приклад великомасштабного апаратного забезпечення

Той самий робочий процес виконується на апаратному забезпеченні з більшим, глибшим навантаженням. У цьому розділі повторно використовується бекенд із прикладу симулятора, але будується новий 20-кубітний макет із власним таргетом, парами анцил і pass manager, після чого схеми надсилаються на QPU в одному завданні. За такого розміру більшість шотів запускають принаймні одну перевірку, тому частка постселекції низька, а кожній схемі потрібен великий бюджет шотів для отримання достатньої кількості зразків, що вижили. Тому цей приклад зосереджує свій бюджет на кількох вибраних стабілізаторах; це все ще незміщена оцінка фіделіті, але грубіша, ніж усереднення за багатьма стабілізаторами в прикладі симулятора.

Одна річ змінюється порівняно з прикладом симулятора: замість того щоб виводити однорідний деполяризаційний канал із калібрувальних даних, у цьому розділі ми навчаємо модель шуму за допомогою NoiseLearner і будуємо модель qiskit-paulice з результату за допомогою NoiseModel.from_pauli_lindblad_maps. Навчена модель Паулі-Ліндблада відображає просторову структуру шуму саме на цьому макеті, замість припущення, що кожне ребро однаково зашумлене, тому розміщення перевірок оцінюється щодо шуму, більш подібного до того, що впливає на QPU. Навчання шуму потребує вибірки з QPU, і це слід враховувати в загальному бюджеті вибірки QPU.

Наведені далі параметри задають кількість кубітів, глибину, кількість стабілізаторів і кількість шотів. Масштабуй hw_num_shots обернено пропорційно до частки постселекції: за частки 3% 40 000 шотів залишають приблизно 1200 постселектованих зразків на схему. Збільшуй hw_num_stabilizers для точнішої оцінки фіделіті ціною більшої кількості схем на завдання, кожна з яких потребує того самого бюджету шотів.

Кроки 1-4 (стиснуті в один блок коду)

Наступна комірка виконує ті самі чотири кроки, що й приклад симулятора. Вона будує більше навантаження та вибирає кілька стабілізаторів (крок 1); обирає макет, навчає на ньому модель шуму та знаходить повністю перевірену схему для кожного стабілізатора (крок 2); надсилає одне завдання Sampler, що містить як голі, так і перевірені схеми (крок 3); і постселектує перевірені підрахунки для порівняння шумної та постселектованої оцінок фіделіті, за стабілізатором і в середньому (крок 4). За такого розміру повне перерахування групи стабілізаторів, як у прикладі симулятора, нездійсненне, тому комірка вибирає випадкову підмножину стабілізаторів для обчислення оцінки фіделіті.

Зверни увагу, що крок 2 тут робить більше, ніж у прикладі симулятора: навчання моделі шуму надсилає власне завдання NoiseLearner перед завданням Sampler, тому комірка виконує два завдання загалом. Вони мають теги TUT_ASPC_LEARN і TUT_ASPC, щоб ти міг знайти їх пізніше. Докладніше про теги завдань див. Організація та пошук за тегами завдань.

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

Output of the previous code cell

Для схем такого розміру більшість зразків містять принаймні одну виявлену помилку, тому частка постселекції мала, і постселекція відкидає більшість шотів. Зразки, що пройшли кожну перевірку, дають набагато краще очікуване значення, ніж гола схема, а значення по стабілізаторах чітко відокремлюються від шумного базового рівня. Щоб звузити оцінку фіделіті, вибирай більше стабілізаторів із тим самим бюджетом шотів на схему. Щоб підвищити частку постселекції, зменш глибину схеми або застосуй менше перевірок; щоб перейти до більших навантажень, масштабуй бюджет шотів обернено пропорційно до частки постселекції.

Наступні кроки

Рекомендації

Якщо ця робота видалася тобі цікавою, тебе можуть зацікавити такі матеріали:

Посилання

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