So stimmst du die Konfigurationsoptionen ab
Die Berechnung der Schranken ist im Prinzip exakt, in der Praxis aber näherungsweise: Die Zeitentwicklung eines Pauli-Fehlerterms durch einen Circuit erzeugt einen Operator mit exponentiell vielen Termen, und die Bestimmung der Spektralnorm des resultierenden Kommutators ist selbst aufwendig. Die folgenden Optionen steuern diesen Aufwand. Sie fallen in drei Gruppen:
-
Genauigkeits-Optionen verändern den numerischen Wert der Schranken und tauschen Schärfe gegen Aufwand.
-
Umfang bestimmt, welche Schranken berechnet werden.
-
Ausführungs-Optionen verändern, wie die Berechnung abläuft (nicht, was sie berechnet).
| Option | Standard | Steuert | Auswirkung auf das Ergebnis |
|---|---|---|---|
| Genauigkeit | |||
evolution_max_terms | 1_000_000 | Wie viele Operatorterme die Zeitentwicklung überleben | Niedriger: günstiger, lockerere Schranken |
atol_simplify | 1e-8 | Welche Kommutatorterme als vernachlässigbar verworfen werden | Höher: günstiger, lockerere Schranken |
eigval_max_qubits | 14 | Wie breit ein Kommutator sein darf, damit seine Spektralnorm berechnet wird | Niedriger: günstiger, lockerere Schranken |
atol_eigenvalue | 1e-10 | Wann der iterative Eigenwertlöser als konvergiert gilt | Höher: günstiger, ungenauere Schranken |
| Umfang | |||
max_num_boxes | None | Für wie viele Boxen Schranken berechnet werden | Boxen jenseits des Limits behalten die triviale Schranke 2.0 |
| Ausführung | |||
timeout | None | Ein Zeitbudget (Echtzeit) für die gesamte Berechnung | Jede verbleibende Arbeit behält die triviale Schranke 2.0 |
num_processes | 1 | Wie viele Worker-Prozesse Schranken parallel berechnen | Für sich allein keine |
Eine Schranke ist nur nützlich, wenn sie gültig ist: Sie darf die Wirkung eines Fehlerterms auf die
Observable niemals unterschätzen. Das Lockern jeder dieser Optionen außer atol_eigenvalue kann die Schranken nur
größer (konservativer) machen, niemals kleiner. Die Kosten einer lockeren Einstellung sind also ein größerer Sampling-
Overhead, nicht ein falscher Erwartungswert - atol_eigenvalue ist die Ausnahme.
Circuit-Definition
Die folgenden Experimente verwenden denselben Trotter-Ising-Circuit und dieselbe Observable wie der Schnellstart-Leitfaden. Bei dieser Größe hat jede Option einen sichtbaren Effekt, während jede Berechnung noch weniger als eine Sekunde dauert.
# 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
circuit = trotter_ising_circuit(
num_qubits, num_steps=4, rx_angle=np.pi / 16, rzz_angle=-np.pi / 3
)
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"noisy layers: {len(noise_model_paulis)}")
print(
f"Pauli error terms: {sum(len(p) for p in noise_model_paulis.values())}"
)
noisy layers: 2
Pauli error terms: 198
Eine kleine Hilfsfunktion fasst jeden Satz von Schranken in einer Zeile zusammen, sodass sich die Wirkung einer Option in wenigen Zahlen statt in einer Wand aus Arrays zeigt.
Jede Schranke liegt zwischen 0 und der trivialen Schranke 2.0. Die Zusammenfassung gibt die mittlere Schranke und ihre Standardabweichung an, gefolgt davon, wie sich die Terme auf diese Gruppen verteilen:
- Terme mit der Schranke genau 0, weil sie mit der zeitentwickelten Observable kommutieren
- Terme mit einer berechneten Schranke zwischen 0 und 2.0
- Terme, die bei der trivialen Schranke 2.0 bleiben
Beachte: Ein niedrigerer Mittelwert ist schärfer, und schärfer bedeutet später einen kleineren Sampling-Overhead.
Ein Sweep über eine Option markiert den Standardwert der Option mit *:
import inspect
import time
from qiskit_addon_slc.bounds import compute_forward_bounds
def summarize(rates, label, elapsed):
"""Prints a one-line summary of a set of bounds."""
# 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:28} 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%} │ "
f"{elapsed:4.2f}s"
)
def forward(label, **kwargs):
"""Computes the forward bounds, summarizes them and returns all their rates."""
start = time.perf_counter()
bounds = compute_forward_bounds(
boxed_circuit, noise_model_paulis, observable, **kwargs
)
elapsed = time.perf_counter() - start
rates = np.concatenate([bound.rates for bound in bounds.values()])
summarize(rates, label, elapsed)
return rates
def sweep(option, values):
"""Computes the forward bounds for each value of one option, marking the default with `*`."""
default = (
inspect.signature(compute_forward_bounds).parameters[option].default
)
for value in values:
marker = "*" if value == default else " "
forward(f"{marker} {option}={value:g}", **{option: value})
baseline = forward(" defaults")
defaults mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.29s
Genauigkeit
evolution_max_terms
Die Zeitentwicklung eines Pauli-Fehlerterms durch Nicht-Clifford-Gates macht daraus eine Summe von Paulis, und diese Summe
wächst mit der Circuit-Tiefe. evolution_max_terms begrenzt, wie viele dieser Terme behalten werden. Die 1-Norm des
Verworfenen wird zur Schranke addiert, sodass nichts stillschweigend verloren geht.
Der Sweep reicht von schärfer als der Standard bis hin zu einem extremen Abschneiden. Eine Absenkung der Obergrenze macht die Berechnung günstiger und die Schranken lockerer.
sweep("evolution_max_terms", (10_000_000, 1_000_000, 1000, 100, 10, 2))
evolution_max_terms=1e+07 mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.38s
* evolution_max_terms=1e+06 mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.31s
evolution_max_terms=1000 mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.30s
evolution_max_terms=100 mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.29s
evolution_max_terms=10 mean 0.3927 ± 0.711 │ at 0: 59% computed: 31% at 2.0: 9% │ 0.28s
evolution_max_terms=2 mean 0.5245 ± 0.792 │ at 0: 59% computed: 23% at 2.0: 18% │ 0.21s
Eine Anhebung der Obergrenze über den Standardwert hinaus ändert hier nichts. Für einen Circuit dieser Größe behält der Standard bereits jeden Term, sodass die Schranken so scharf sind, wie diese Option sie machen kann. Das ist der typische Fall. Das Verschärfen einer Genauigkeitsoption lohnt sich erst, wenn ihr Standardwert tatsächlich einschränkt, und das findest du mit dem obigen Experiment heraus. Führe es mit einem schärferen Wert erneut aus und prüfe, ob sich die Schranken verschieben.
Warnung: Die zugrunde liegende Propagationsroutine behandelt evolution_max_terms als vorab zu reservierende Kapazität,
nicht als Obergrenze, gegen die geprüft wird. Wenn du etwas nominell Unbegrenztes übergibst, etwa
numpy.iinfo(numpy.uint).max, fordert der Allokator Terabytes an Speicher an und der Prozess bricht ab.
Halte dich an eine realistische Zahl. Der Standardwert von einer Million ist bereits großzügig.
atol_simplify
Nachdem der Kommutator gebildet wurde, werden Terme mit einem Koeffizienten unterhalb von atol_simplify verworfen. Wie bei
der Entwicklungsobergrenze wird die dabei verlorene 1-Norm zur Schranke addiert.
sweep("atol_simplify", (0.0, 1e-8, 1e-4, 1e-2, 1e-1))
atol_simplify=0 mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.32s
* atol_simplify=1e-08 mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.31s
atol_simplify=0.0001 mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.33s
atol_simplify=0.01 mean 0.3656 ± 0.685 │ at 0: 59% computed: 35% at 2.0: 6% │ 0.31s
atol_simplify=0.1 mean 0.3837 ± 0.697 │ at 0: 59% computed: 31% at 2.0: 10% │ 0.31s
eigval_max_qubits
Die schärfste verfügbare Schranke ist die Spektralnorm des Kommutators, für die eine Eigenwertberechnung nötig ist. Diese
wird teuer, wenn der Kommutator breiter wird, daher legt eigval_max_qubits die Breite fest, ab der der Versuch zugunsten der Dreiecksungleichung
abgebrochen wird. Diese Schranke,
über die Koeffizienten des Kommutators, ist gültig, aber lockerer.
Setzt du den Wert auf null, wird überall der Rückfall erzwungen, was den Nutzen des Eigenwertlösers in einer einzigen Zahl zeigt.
sweep("eigval_max_qubits", (20, 14, 8, 4, 2, 0))
eigval_max_qubits=20 mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.31s
* eigval_max_qubits=14 mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.30s
eigval_max_qubits=8 mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.32s
eigval_max_qubits=4 mean 0.3742 ± 0.694 │ at 0: 59% computed: 35% at 2.0: 6% │ 0.30s
eigval_max_qubits=2 mean 0.4983 ± 0.809 │ at 0: 59% computed: 23% at 2.0: 18% │ 0.27s
eigval_max_qubits=0 mean 0.5081 ± 0.810 │ at 0: 59% computed: 21% at 2.0: 19% │ 0.27s
Selbst bei abgeschaltetem Eigenwertlöser bleibt der Mittelwert weit unter der trivialen 2.0. Der Anteil der Terme mit Schranke 0 ändert sich nicht: Ein Term, der exakt mit der zeitentwickelten Observable kommutiert, hat einen Null-Kommutator, was erkannt wird, bevor überhaupt eine Eigenwertberechnung in Betracht kommt. Bei den übrigen erreicht die Schranke der Dreiecksungleichung für einige 2.0 und wird dort gekappt, für viele ist sie aber noch kleiner, sodass sie eine berechnete Schranke unter 2.0 behalten.
atol_eigenvalue
Wenn die Spektralnorm berechnet wird, ist atol_eigenvalue die Konvergenztoleranz des
iterativen Eigenwertlösers. Die meisten Kommutatoren erreichen diesen Löser allerdings nie:
-
Ein Kommutator, der auf höchstens vier Qubits wirkt, wird direkt exakt diagonalisiert.
-
Ein breiterer wird zunächst auf die kleinste Anzahl von Qubits reduziert, die seine Pauli-Terme benötigen, und wenn diese reduzierte Form auf höchstens acht Qubits wirkt, wird er exakt diagonalisiert.
Nur ein Kommutator, der nach dieser Reduktion immer noch mehr als acht Qubits benötigt, geht an den iterativen Löser, und nur dort gilt diese Toleranz. Circuits in der Größe dieses Leitfadens kommen nie so weit, ein Sweep über diese Option würde also für jeden Wert identische Schranken zeigen. Relevant wird sie für breite Lichtkegel auf großen Geräten.
Anders als die anderen Genauigkeitsoptionen ist das Lockern von atol_eigenvalue in einer Richtung nicht sicher. Ein
iterativer Eigenwertlöser nähert sich dem extremalen Eigenwert aus dem Inneren des Spektrums, sodass eine nicht
vollständig konvergierte Schätzung die Spektralnorm und damit die Schranke unterschätzt. Der Standardwert ist aus
diesem Grund bewusst streng. Konvergiert der Löser nicht, übernimmt der Rückfall auf die Dreiecksungleichung,
der wieder gültig ist.
Umfang
max_num_boxes
Manchmal ist die nützliche Grenze nicht die Genauigkeit, sondern die Tiefe. max_num_boxes hört ab einer bestimmten Anzahl von Boxen auf, Schrankenberechnungen
zu starten, und jeder darüber hinausgehenden Schicht wird stattdessen die triviale Schranke
2.0 zugewiesen.
Das Limit zählt die verrauschten BoxOp-Anweisungen (solche mit einer InjectNoise-Annotation) und nicht
zum Beispiel eine Tiefe in Gates. So bleibt es an die für den Nutzer sichtbare Struktur des Circuits gebunden
und vermeidet die Kopplung an die automatisch erzeugten Annotationsreferenzen, die sich zwischen
Transpilationsläufen ändern können.
Das ist die naheliegende Wahl für einen Circuit, dessen zwei Hälften jeweils durch Vorwärts- und Rückwärtsschranken behandelt werden: Berechne jede nur so tief, wie sie gebraucht wird.
from qiskit.circuit import BoxOp
from samplomatic import InjectNoise
# The limit counts the noisy boxes encountered while iterating the circuit, not the unique ones:
# a repeated layer is one noise model but several boxes. Boxes without an `InjectNoise`
# annotation carry no error terms and do not count.
num_boxes = sum(
1
for inst in boxed_circuit.data
if isinstance(inst.operation, BoxOp)
and any(
isinstance(annotation, InjectNoise)
for annotation in inst.operation.annotations
)
)
print(
f"the circuit has {num_boxes} noisy boxes drawn from "
f"{len(noise_model_paulis)} distinct noise models\n"
)
for limit in (None, 6, 4, 2, 1):
marker = "*" if limit is None else " "
forward(f"{marker} max_num_boxes={limit}", max_num_boxes=limit)
the circuit has 9 noisy boxes drawn from 2 distinct noise models
* max_num_boxes=None mean 0.3639 ± 0.684 │ at 0: 59% computed: 36% at 2.0: 5% │ 0.33s
max_num_boxes=6 mean 0.7486 ± 0.935 │ at 0: 53% computed: 18% at 2.0: 30% │ 0.18s
max_num_boxes=4 mean 1.1506 ± 0.980 │ at 0: 40% computed: 5% at 2.0: 55% │ 0.11s
max_num_boxes=2 mean 1.5707 ± 0.821 │ at 0: 21% computed: 0% at 2.0: 79% │ 0.05s
max_num_boxes=1 mean 1.8157 ± 0.579 │ at 0: 9% computed: 0% at 2.0: 91% │ 0.03s
Ausführung
timeout
timeout ist ein Zeitbudget in Sekunden (Echtzeit). Läuft es ab, bleiben die bereits berechneten Schranken erhalten,
und alles Ausstehende behält die triviale Schranke 2.0, sodass das Ergebnis immer nutzbar ist, nur
umso lockerer, je früher du abbrichst.
Es ist keine harte Frist: Bereits laufende Aufgaben dürfen abgeschlossen werden, sodass der Aufruf das Budget um die Dauer einer Aufgabe überschreiten kann.
num_processes
Schranken für verschiedene Fehlerterme sind unabhängig voneinander, daher verteilt num_processes sie auf Worker-
Prozesse. Für sich genommen beeinflusst das nur, wie lange die Berechnung dauert, niemals ihr Ergebnis.
Hinweis: Ein timeout in Kombination mit num_processes macht das Ergebnis vom Timing abhängig: Welche Aufgaben
vor Ablauf des Budgets fertig werden, ist nicht deterministisch. Die Schranken bleiben gültig, sind aber nicht mehr
von Lauf zu Lauf reproduzierbar.
Wo du anfangen solltest
Die Standardwerte sind so gewählt, dass sie bei Circuits einer Größe, bei der das bezahlbar ist, scharfe Schranken liefern. Wenn eine Berechnung zu langsam ist, wende die Optionen ungefähr in dieser Reihenfolge an:
-
num_processes: Eine kostenlose Beschleunigung ohne Genauigkeitsverlust. -
max_num_boxes: Wenn du im Voraus weißt, wie tief in den Circuit du interessiert bist. -
eigval_max_qubits: Überspringt den teuersten Schritt für die breitesten Kommutatoren, die meist eine kleine Minderheit der Terme sind. -
evolution_max_termsundatol_simplify: Diese beeinflussen die Genauigkeit durchgängig, stimme sie daher zuletzt ab und prüfe mit einem Sweep wie den obigen, wie weit sich die Schranken verschieben. -
timeout: Ein harter Stopp statt einer Abstimmungshilfe, für den Fall, dass eine Berechnung bis zu einer Frist fertig sein muss, welche Qualität auch immer.
Je lockerer die Schranken, desto größer der Sampling-Overhead, den compute_local_scales für
eine gegebene Bias-Toleranz meldet. Dieser Overhead ist die Größe, die beim Abstimmen im Blick zu behalten lohnt: Er zeigt dir,
was die Näherungen tatsächlich kosten.