Zum Hauptinhalt springen
Dieses Projekt befindet sich in der Beta-Phase. Einen Fehler gefunden? GitHub Issue erstellen. Frage oder Idee? In Discussions fragen — wir freuen uns über dein Feedback!
Wie ist die Übersetzungsqualität auf dieser Seite?

So berechnest du benutzerdefinierte Schranken

compute_forward_bounds und compute_backward_bounds sind Komfort-Wrapper. Beide sind aus den gleichen drei Bausteinen aufgebaut, die die öffentliche API bereitstellt:

  • Eine Normfunktion, die einen einzelnen zeitentwickelten Pauli-Fehlerterm beschränkt

  • Ein Lichtkegel, der festlegt, wohin die Fehlerterme entwickelt werden

  • compute_bounds, das den Circuit durchläuft und die Arbeit pro Term verteilt

Wenn du sie selbst zusammensetzt, kannst du etwas beschränken, das die Wrapper nicht abdecken. Dieser Leitfaden zeigt an einem Fall, bei dem sich das lohnt: einem Spiegel-Circuit, bei dem die übliche Rückwärtsschranke unnötig locker ist.

Was die Wrapper tatsächlich tun​

Die beiden Wrapper unterscheiden sich an den drei oben aufgeführten Stellen:

compute_forward_boundscompute_backward_bounds
Circuitwie übergebenremove_measure(circuit).inverse()
Normfunktiontime_evolved_norm_forward, gegen die Observabletime_evolved_norm_backward, gegen den Anfangszustand
LichtkegelLightCone.initialize_from_pauli, ausgehend von der ObservableLightCone.initialize_from_measurements, ausgehend von jedem aktiven Qubit
backwardsFalseTrue

Die Vorwärtsschranke fragt "Wie stark kann dieser Fehler die Observable verändern, die ich gleich messe?" und beantwortet das mit der Spektralnorm eines Kommutators. Die Rückwärtsschranke fragt "Wie stark kann dieser Fehler den Zustand verändern, von dem ich ausgegangen bin?" und antwortet mit einer Kernnorm, wobei dieser Zustand der All-Null-Zustand auf jedem aktiven Qubit ist. Das sind verschiedene Fragen, und jede ist die richtige für Fehler in ihrer eigenen Hälfte des Circuits.

Der Fall des Spiegel-Circuits​

Ein Spiegel-Circuit wendet eine Unitäre an und macht sie dann rückgängig: U†UU^\dagger U. Diese treten immer dann auf, wenn das ideale Ergebnis exakt bekannt sein muss (allgemein bei Loschmidt-Echos, Randomized Benchmarking und Verifikationsexperimenten).

Spiegel-Circuits haben eine Eigenschaft, die die generische Rückwärtsschranke nicht nutzen kann. Weil die zweite Hälfte die Inverse der ersten ist, durchläuft die Entwicklung eines Fehlers rückwärts zum Anfangszustand dieselben Gates wie die Entwicklung vorwärts zur Observable. Die beiden Fragen fallen zusammen, sodass die schärfere Schranke im Vorwärtsstil auch für die Rückwärtsrichtung gültig ist.

# Added by doQumentation — installs the packages this notebook needs if they are missing
import importlib.util

_needed = {"numpy": "numpy", "qiskit": "qiskit", "qiskit_addon_slc": "qiskit-addon-slc", "samplomatic": "samplomatic"}
_missing = [pip for module, pip in _needed.items()
if importlib.util.find_spec(module) is None]
# One at a time, so a package that fails to install does not block the others
for _pip in _missing:
%pip install -q {_pip}
if not _missing:
print("\u2713 All required packages are installed")
import numpy as np
from qiskit import QuantumCircuit
from qiskit.quantum_info import Pauli
from qiskit_addon_slc.utils import generate_noise_model_paulis
from samplomatic.transpiler import generate_boxing_pass_manager
from samplomatic.utils import find_unique_box_instructions

def trotter_ising_circuit(num_qubits, num_steps, rx_angle, rzz_angle):
"""Trotterized transverse-field Ising evolution on a 1D chain."""
circuit = QuantumCircuit(num_qubits)
for _ in range(num_steps):
circuit.rx(rx_angle, range(num_qubits))
circuit.barrier()
for start in (0, 1): # even then odd bonds
for i in range(start, num_qubits - 1, 2):
circuit.rzz(rzz_angle, i, i + 1)
circuit.barrier()
return circuit

num_qubits = 10
# The circuit of the quickstart guide...
forward_half = trotter_ising_circuit(
num_qubits, num_steps=4, rx_angle=np.pi / 16, rzz_angle=-np.pi / 3
)
# ...followed by its inverse. The ideal result is the initial state.
circuit = forward_half.compose(forward_half.inverse())

observable = Pauli("I" * num_qubits).compose("Z", [num_qubits // 2])

boxing_pass = generate_boxing_pass_manager(
inject_noise_targets="all",
inject_noise_strategy="individual_modification",
inject_noise_site="after",
twirling_strategy="active",
remove_barriers="never",
)
boxed_circuit = boxing_pass.run(circuit)
noise_model_paulis = generate_noise_model_paulis(
find_unique_box_instructions(boxed_circuit)
)

print(f"mirror circuit: {num_qubits} qubits, depth {circuit.depth()}")
print(f"noisy layers: {len(noise_model_paulis)}")
mirror circuit: 10 qubits, depth 24
noisy layers: 4

Die benutzerdefinierte Schranke zusammensetzen​

Das Rezept behält bei, was compute_backward_bounds mit dem Circuit macht (die Messungen entfernen, ihn invertieren und mit backwards=True durchlaufen), und ersetzt die anderen beiden Bausteine: die Vorwärts- Normfunktion und einen Lichtkegel, der mit LightCone.initialize_from_pauli von der Observable ausgeht, genau wie compute_forward_bounds seinen eigenen aufsetzt. Jede Ersetzung ist eine Zeile.

from functools import partial

from qiskit_addon_slc.bounds import (
LightCone,
compute_bounds,
time_evolved_norm_forward,
)
from qiskit_addon_slc.utils import remove_measure

def compute_mirror_backward_bounds(
circuit,
noise_model_paulis,
/,
observable,
*,
evolution_max_terms=1_000_000,
**kwargs,
):
"""Bounds the backward direction of a mirror circuit with a forward-style bound.

Valid only for circuits whose second half inverts the first: the backward evolution then
traverses the same gates as the forward one, and reproduces the forward bounds.
"""
# As `compute_backward_bounds` does: strip measurements and invert, so that walking the circuit
# backwards from its end becomes walking the inverted circuit forwards.
circuit = remove_measure(circuit).inverse()

# The substitution: the forward norm function, bounding against the observable rather than
# against the initial state.
norm_fn = partial(
time_evolved_norm_forward,
observable=observable,
evolution_max_terms=evolution_max_terms,
)

# Seed the light cone from the observable, not from every active qubit.
light_cone = LightCone.initialize_from_pauli(circuit, observable)

return compute_bounds(
circuit,
noise_model_paulis,
light_cone,
norm_fn,
backwards=True,
**kwargs,
)

Wie viel es bringt​

Wir vergleichen mit der Standard-Rückwärtsschranke auf demselben Circuit, auf dieselbe Weise zusammengefasst wie im Konfigurationsleitfaden: die mittlere Schranke und ihre Standardabweichung, gefolgt vom Anteil der Terme mit Schranke 0, mit dazwischen berechneter Schranke und bei der trivialen 2.0 belassen.

from qiskit_addon_slc.bounds import (
compute_backward_bounds,
compute_forward_bounds,
)

forward_bounds = compute_forward_bounds(
boxed_circuit, noise_model_paulis, observable
)
stock_backward = compute_backward_bounds(boxed_circuit, noise_model_paulis)
custom_backward = compute_mirror_backward_bounds(
boxed_circuit, noise_model_paulis, observable
)

def summarize(label, bounds):
"""Prints a one-line summary of a set of bounds."""
rates = np.concatenate([bound.rates for bound in bounds.values()])
# The bounds are floating-point results, so a term counts as bounded by 0, or as left at the
# trivial 2.0, when it is within rounding error of that value.
exact = np.isclose(rates, 0.0, rtol=0.0, atol=1e-12)
trivial = np.isclose(rates, 2.0, rtol=0.0, atol=1e-12)
computed = ~exact & ~trivial
print(
f"{label:18} mean {rates.mean():.4f} ± {rates.std():.3f} │ "
f"at 0: {exact.mean():4.0%} computed: {computed.mean():4.0%} "
f"at 2.0: {trivial.mean():4.0%}"
)

summarize("forward", forward_bounds)
summarize("backward (stock)", stock_backward)
summarize("backward (custom)", custom_backward)
forward mean 0.4323 ± 0.679 │ at 0: 38% computed: 61% at 2.0: 1%
backward (stock) mean 1.7218 ± 0.592 │ at 0: 0% computed: 95% at 2.0: 5%
backward (custom) mean 0.4323 ± 0.679 │ at 0: 38% computed: 61% at 2.0: 1%

Die Standard-Rückwärtsschranke liegt nahe am trivialen Wert 2.0 und enthält damit kaum Information. Bei einem Spiegel-Circuit ist jeder Fehler im Sinne dieser Schranke "nah am Anfangszustand", sodass sie nicht zwischen Termen unterscheiden kann. Die benutzerdefinierte Schranke ist um ein Vielfaches schärfer und beschränkt viele der Terme durch 0.

Um die Implementierung zu prüfen, lassen sich die beiden Box für Box vergleichen. Für jede Box ist die benutzerdefinierte Rückwärtsschranke bis auf Rundungsfehler identisch mit der Vorwärtsschranke derselben Box. Beide beschränken denselben Fehler am selben Punkt im Circuit, und bei einem Spiegel-Circuit heben sich die Gates nach diesem Punkt zur Inversen der Gates davor auf, sodass die beiden Berechnungen dieselbe Größe von entgegengesetzten Enden aus auswerten:

matches = sum(
np.allclose(forward_bounds[box_id].rates, custom_backward[box_id].rates)
for box_id in forward_bounds
)
print(f"{matches} of {len(forward_bounds)} boxes match the forward bounds")

worst = max(
np.abs(forward_bounds[box_id].rates - custom_backward[box_id].rates).max()
for box_id in forward_bounds
)
print(f"largest disagreement across all terms: {worst:.2e}")
16 of 16 boxes match the forward bounds
largest disagreement across all terms: 6.00e-15

Hinweis: Diese Gleichheit ist eine Eigenschaft der Spiegelstruktur, nicht der Technik. Bei einem Circuit, der nicht selbstinvers ist, läuft dieselbe Ersetzung zwar weiter, berechnet aber keine gültige Schranke mehr für die Rückwärtsrichtung: Sie beantwortet die Vorwärtsfrage für eine Circuit-Hälfte, für die diese Frage nicht gilt. Verwende dies nur, wenn die zweite Hälfte deines Circuits die erste invertiert.

In den Rest des Workflows einspeisen​

Das Ergebnis ist ein gewöhnliches Bounds-Dictionary, sodass sich für alles Weitere nichts ändert.

Bei einem Spiegel-Circuit gibt es außerdem keinen Grund, eine der beiden Schranken für den ganzen Circuit zu berechnen. Die Vorwärtsschranken werden nur für die Boxen von U†U^\dagger benötigt, vom Ende aus gezählt, und die Rückwärts- schranken nur für die Boxen von UU, vom Anfang aus gezählt. max_num_boxes begrenzt daher jede von ihnen auf die Hälfte der verrauschten Boxen, und merge_bounds fügt die beiden Hälften zusammen.

So werden die Schranken in der Praxis verwendet, und es bedeutet, dass der Box-für-Box-Vergleich von oben nicht mehr möglich ist: Er benötigt beide Schranken für jede Box. Er ist eine Prüfung der Implementierung für diesen Leitfaden, kein Workflow-Schritt.

from qiskit.quantum_info import PauliLindbladMap, QubitSparsePauliList
from qiskit_addon_slc.bounds import compute_local_scales, merge_bounds

# Stand-in for rates that a noise-learning experiment would measure on hardware.
rng = np.random.default_rng(42)
noise_rates = {
layer: PauliLindbladMap.from_components(
rng.random(len(terms)) * 5e-3,
QubitSparsePauliList.from_sparse_list(
terms.to_sparse_list(), terms.num_qubits
),
)
for layer, terms in noise_model_paulis.items()
}

# Compute each bound for its own half of the mirror circuit only. `forward_bounds` has an entry
# for every noisy box, which are the boxes `max_num_boxes` counts.
half = len(forward_bounds) // 2
half_forward = compute_forward_bounds(
boxed_circuit, noise_model_paulis, observable, max_num_boxes=half
)
half_stock = compute_backward_bounds(
boxed_circuit, noise_model_paulis, max_num_boxes=half
)
half_custom = compute_mirror_backward_bounds(
boxed_circuit, noise_model_paulis, observable, max_num_boxes=half
)

print(
f"each bound computed for {half} of {len(forward_bounds)} noisy boxes\n"
)
print(f"{'bounds':34} {'overhead':>12} {'residual bias':>14}")
for label, bounds in (
(
"forward + stock backward",
merge_bounds(boxed_circuit, half_forward, half_stock, noise_rates),
),
(
"forward + custom backward",
merge_bounds(boxed_circuit, half_forward, half_custom, noise_rates),
),
("forward only, whole circuit", forward_bounds),
):
_, cost, bias = compute_local_scales(
boxed_circuit, bounds, noise_rates, bias_tolerance=0.05
)
print(f"{label:34} {cost:12.4f} {bias:14.4f}")
Optimal spacetime partitioning not implemented! Just partitioning list of noisy boxes.
Optimal spacetime partitioning not implemented! Just partitioning list of noisy boxes.
each bound computed for 8 of 16 noisy boxes

bounds overhead residual bias
forward + stock backward 29848.0583 0.0498
forward + custom backward 465.4028 0.0498
forward only, whole circuit 465.4028 0.0498

Mit den Standard-Rückwärtsschranken ist der Sampling-Overhead weit größer. merge_bounds verwendet die Rückwärtsschranken bis zu einem Umschaltpunkt im Circuit und ab dort die Vorwärtsschranken und wählt den Punkt, der den gesamten Bias minimiert. Wenn jede Schranke für eine Hälfte berechnet wird, liegt dieser Punkt in der Mitte des Circuits, sodass die Boxen von UU allein durch Rückwärtsschranken abgedeckt werden. Die Standard-Rückwärtsschranke liegt dort nahe an der trivialen 2.0, was den Overhead nach oben treibt. Die benutzerdefinierte ist so scharf, wie es die Vorwärtsschranke gewesen wäre, sodass der Overhead dem der vollständigen Vorwärtsschranken entspricht.

Das wirft die naheliegende Frage auf, warum man sich die Mühe macht, wenn die vollständigen Vorwärtsschranken allein dasselbe Ergebnis liefern. Die Antwort sind die Kosten. Eine Vorwärtsschranke für eine Box früh im Circuit entwickelt ihren Fehler durch den Rest von UU und dann durch ganz U†U^\dagger, was die längste und breiteste Entwicklung im Circuit ist. Der entwickelte Operator kann exponentiell in der Anzahl der Nicht-Clifford-Gates wachsen, die er durchläuft, und die Berechnung der Spektralnorm des Kommutators wird exponentiell teurer, je breiter der Lichtkegel wird. Beim Halb-und-halb-Ansatz wird kein Fehler durch mehr als die Hälfte des Circuits entwickelt.

Die Ersparnis wächst daher mit der Circuit-Tiefe. Wichtiger noch: Die vollständigen Vorwärts- schranken sind lange nicht mehr berechenbar, bevor es die Halbschranken sind. Sobald die Entwicklungen der frühen Boxen evolution_max_terms oder eigval_max_qubits überschreiten, werden ihre Schranken abgeschnitten oder fallen auf die Dreiecksungleichung zurück, und schließlich sind sie zu teuer zu berechnen. Wo auch immer dieser Punkt liegt, der Halb-und-halb-Ansatz braucht nur, dass er irgendwo in der ersten Hälfte des Circuits liegt. Die Vorwärtsschranken müssen nicht weiter als bis zur Mitte reichen, und bei einem Spiegel-Circuit entwickeln die Rückwärts- schranken für die Boxen von UU ihre Fehler bis zum Anfang durch ebenso viel vom Circuit, wie es die Vorwärtsschranken für die entsprechenden Boxen von U†U^\dagger bis zum Ende tun, sodass sie bei derselben Tiefe an ihre Grenze stoßen.

Hinweis: Warum Rückwärtsschranken für die Boxen von UU berechnen, wenn die Vorwärtsschranken der Boxen von U†U^\dagger dieselben Zahlen enthalten sollten? Weil die Boxen keine Spiegel- bilder voneinander sind. Jede Box injiziert ihr Rauschen auf einer Seite ihrer Gates, und die inversen Schichten von U†U^\dagger sind eigene Boxen mit eigenen Rauschmodellen, sodass die Box, die eine Box von UU spiegelt, ihre Fehler an einem anderen Punkt im Circuit sieht. Daher stimmen ihre Schranken nur teilweise überein, und die Schranken für die Boxen von UU müssen für genau diese Boxen berechnet werden.

Weitere benutzerdefinierte Schranken schreiben​

Dieselben drei Ersetzungen decken weitere Fälle ab:

  • Eine andere Norm. time_evolved_norm_forward akzeptiert comm_norm_order; wird etwas anderes als 2 übergeben, wird der Eigenwertlöser übersprungen und diese Norm direkt berechnet.

  • Ein anderer Lichtkegel. LightCone.initialize_from_pauli setzt einen Lichtkegel aus einem beliebigen Pauli auf, und der LightCone-Konstruktor nimmt die zu verfolgenden Qubits und die ihn aufsetzenden Operationen entgegen, sodass eine Schranke gegen jeden Operator berechnet werden kann, den du auf diese Weise ausdrücken kannst.

  • Eine ganz andere Normfunktion. compute_bounds verlangt nur ein aufrufbares Objekt, das Pauli und RotationGates entgegennimmt und ein CommutatorBounds zurückgibt. Alles, was diese Signatur erfüllt, funktioniert, einschließlich einer geschlossenen Schranke speziell für deine Circuit-Familie.

Was immer du einsetzt, du musst beweisen, dass das Ergebnis eine echte obere Schranke ist. Das Unterschätzen der Wirkung eines Fehlerterms führt nicht zu einem sichtbaren Fehler. Es führt zu einem Erwartungswert, der unbemerkt falsch ist, weil der Term aus dem Rauschmodell der Fehlerminderung herausgefallen sein wird.