Jak obliczać niestandardowe ograniczenia
compute_forward_bounds i compute_backward_bounds to wygodne opakowania. Oba są zbudowane z
tych samych trzech elementów, które udostępnia publiczne API:
-
Funkcja normy, która ogranicza pojedynczy ewoluowany człon błędu Pauliego
-
Stożek światła, który ustala, w kierunku czego ewoluują człony błędu
-
compute_bounds, które iteruje po obwodzie i rozdziela pracę na poszczególne człony
Złożenie ich samodzielnie oznacza, że możesz ograniczyć coś, czego opakowania nie obejmują. Ten przewodnik przedstawia jeden przypadek, w którym warto to zrobić: obwód lustrzany, w którym zwykłe ograniczenie wsteczne jest niepotrzebnie luźne.
Co faktycznie robią opakowania
Dwa opakowania różnią się w trzech miejscach wymienionych powyżej:
compute_forward_bounds | compute_backward_bounds | |
|---|---|---|
| obwód | jak podano | remove_measure(circuit).inverse() |
| funkcja normy | time_evolved_norm_forward, względem obserwabla | time_evolved_norm_backward, względem stanu początkowego |
| stożek światła | LightCone.initialize_from_pauli, zasiany z obserwabla | LightCone.initialize_from_measurements, zasiany ze wszystkich aktywnych kubitów |
backwards | False | True |
Ograniczenie w przód pyta „Jak bardzo ten błąd może zmienić obserwabl, który zaraz zmierzę?” i odpowiada normą spektralną komutatora. Ograniczenie wsteczne pyta „Jak bardzo ten błąd może zmienić stan, od którego zacząłem?” i odpowiada normą jądrową, przyjmując, że tym stanem jest stan samych zer na każdym aktywnym kubicie. To różne pytania, a każde jest właściwe dla błędów w swojej połowie obwodu.
Przypadek obwodu lustrzanego
Obwód lustrzany stosuje pewną unitarną operację, a następnie ją cofa: . Pojawiają się one zawsze wtedy, gdy idealny wynik musi być znany dokładnie (zwykle w echach Loschmidta, losowym benchmarkingu i eksperymentach weryfikacyjnych).
Obwody lustrzane mają własność, której ogólne ograniczenie wsteczne nie potrafi wykorzystać. Ponieważ druga połowa jest odwrotnością pierwszej, ewoluowanie błędu wstecz do stanu początkowego przechodzi przez te same bramki co ewoluowanie go w przód do obserwabla. Oba pytania się pokrywają, więc ostrzejsze ograniczenie w stylu „w przód” jest poprawne także dla kierunku wstecznego.
# 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
Złóż niestandardowe ograniczenie
Przepis zachowuje to, co compute_backward_bounds robi z obwodem (usuwa pomiary,
odwraca go i iteruje po nim z backwards=True), i podmienia pozostałe dwa elementy: funkcję
normy w przód oraz stożek światła zasiany z obserwabla przez LightCone.initialize_from_pauli,
dokładnie tak, jak compute_forward_bounds zasiewa własny. Każda podmiana to jedna linia.
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,
)
Ile zyskuje
Porównanie ze standardowym ograniczeniem wstecznym na tym samym obwodzie, podsumowane tak samo jak w przewodniku konfiguracji: średnie ograniczenie i jego odchylenie standardowe, a następnie udział członów ograniczonych przez 0, z obliczonym ograniczeniem pomiędzy oraz pozostawionych przy trywialnym 2.0.
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%
Standardowe ograniczenie wsteczne znajduje się blisko trywialnej wartości 2.0, która niesie prawie zero informacji. W obwodzie lustrzanym każdy błąd jest „blisko stanu początkowego” w sensie, który mierzy to ograniczenie, więc nie potrafi ono rozróżniać członów. Niestandardowe ograniczenie jest kilkakrotnie ściślejsze i ogranicza wiele członów przez 0.
Aby sprawdzić implementację, można porównać oba ograniczenia blok po bloku. Dla każdego bloku niestandardowe ograniczenie wsteczne wychodzi identyczne z ograniczeniem w przód tego samego bloku, z dokładnością do błędu zaokrągleń. Oba ograniczają ten sam błąd w tym samym punkcie obwodu, a w obwodzie lustrzanym bramki po tym punkcie redukują się do odwrotności bramek przed nim, więc oba obliczenia wyznaczają tę samą wielkość z przeciwnych końców:
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
Uwaga: Ta równość jest własnością struktury lustrzanej, a nie samej techniki. W obwodzie, który nie jest samoodwrotny, ta sama podmiana nadal działa, ale nie oblicza już poprawnego ograniczenia dla kierunku wstecznego: odpowiada na pytanie „w przód” o połowę obwodu, do której to pytanie nie ma zastosowania. Używaj tego tylko wtedy, gdy druga połowa obwodu odwraca pierwszą.
Przekaż to do reszty przepływu pracy
Wynikiem jest zwykły słownik Bounds, więc wszystko, co następuje dalej, pozostaje bez zmian.
W obwodzie lustrzanym nie ma też powodu, by obliczać któreś z ograniczeń dla całego obwodu. Ograniczenia
w przód są potrzebne tylko dla bloków , licząc od końca, a ograniczenia wsteczne
tylko dla bloków , licząc od początku. max_num_boxes ogranicza więc każde z nich
do połowy zaszumionych bloków, a merge_bounds łączy obie połowy.
Tak właśnie używa się ograniczeń w praktyce, co oznacza, że porównanie blok po bloku powyżej nie jest już dostępne: wymaga ono obu ograniczeń dla każdego bloku. To sprawdzenie implementacji na potrzeby tego przewodnika, a nie krok przepływu pracy.
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
Przy standardowych ograniczeniach wstecznych narzut próbkowania jest znacznie większy. merge_bounds używa
ograniczeń wstecznych do jednego punktu przełączenia w obwodzie, a od niego ograniczeń w przód,
wybierając punkt minimalizujący całkowity błąd systematyczny. Gdy każde ograniczenie jest obliczane dla jednej połowy, ten punkt
wypada w środku obwodu, więc bloki są pokryte wyłącznie ograniczeniami wstecznymi. Standardowe
ograniczenie wsteczne jest tam bliskie trywialnego 2.0, co zwiększa narzut. Niestandardowe
jest tak ścisłe, jak byłoby ograniczenie w przód, więc narzut odpowiada narzutowi pełnych
ograniczeń w przód.
Nasuwa się oczywiste pytanie, po co się trudzić, skoro same pełne ograniczenia w przód dają ten sam wynik. Odpowiedzią jest koszt. Ograniczenie w przód dla bloku na początku obwodu ewoluuje jego błąd przez resztę , a potem przez całe , co jest najdłuższą i najszerszą ewolucją w obwodzie. Ewoluowany operator może rosnąć wykładniczo wraz z liczbą bramek nie-Cliffordowskich, przez które przechodzi, a obliczenie normy spektralnej komutatora staje się wykładniczo droższe, gdy stożek światła się poszerza. W podejściu połowa na połowę żaden błąd nie jest ewoluowany przez więcej niż połowę obwodu.
Oszczędność rośnie więc wraz z głębokością obwodu. Co ważniejsze, pełne ograniczenia w przód
przestają być możliwe do obliczenia na długo przed tym, zanim stanie się to z połowicznymi. Gdy ewolucje wczesnych
bloków przekroczą evolution_max_terms lub eigval_max_qubits, ich ograniczenia są obcinane lub wracają
do nierówności trójkąta, a w końcu ich obliczenie kosztuje zbyt wiele. Gdziekolwiek ten punkt
wypada, podejście połowa na połowę potrzebuje tylko, by wypadał gdzieś w pierwszej połowie obwodu.
Ograniczenia w przód muszą sięgać nie dalej niż do środka, a w obwodzie lustrzanym ograniczenia
wsteczne dla bloków ewoluują ich błędy wstecz do początku przez tyle samo obwodu, ile ograniczenia
w przód dla odpowiadających bloków do końca, więc wyczerpują się
na tej samej głębokości.
Uwaga: Dlaczego obliczać ograniczenia wsteczne dla bloków , skoro ograniczenia w przód dla bloków wyglądają, jakby miały mieć te same wartości? Ponieważ bloki nie są swoimi lustrzanymi odbiciami. Każdy blok wstrzykuje swój szum po jednej stronie swoich bramek, a odwrócone warstwy są osobnymi blokami z własnymi modelami szumu, więc blok lustrzany względem bloku widzi jego błędy w innym punkcie obwodu. Dlatego ich ograniczenia zgadzają się tylko częściowo, a ograniczenia dla bloków trzeba obliczyć dla tych bloków.
Pisanie innych niestandardowych ograniczeń
Te same trzy podmiany obejmują inne przypadki:
-
Inna norma.
time_evolved_norm_forwardprzyjmujecomm_norm_order; przekazanie czegokolwiek innego niż2pomija solver wartości własnych i oblicza tę normę bezpośrednio. -
Inny stożek światła.
LightCone.initialize_from_paulizasiewa stożek światła z dowolnego operatora Pauliego, a konstruktorLightConeprzyjmuje kubity do śledzenia i operacje go zasiewające, więc ograniczenie można obliczyć względem dowolnego operatora, który da się w ten sposób wyrazić. -
Zupełnie inna funkcja normy.
compute_boundswymaga tylko obiektu wywoływalnego: przyjmującegoPauliiRotationGatesoraz zwracającegoCommutatorBounds. Działa cokolwiek, co spełnia tę sygnaturę, włącznie z ograniczeniem w postaci zamkniętej, specyficznym dla twojej rodziny obwodów.
Cokolwiek podstawisz, musisz udowodnić, że wynik jest rzeczywistym ograniczeniem górnym. Zaniżenie wpływu członu błędu nie powoduje widocznej awarii. Daje wartość oczekiwaną, która jest po cichu błędna, ponieważ człon zostanie pominięty w złagodzonym modelu szumu.