Vai al contenuto principale
Questo progetto è in fase beta. Hai trovato un bug? Apri una issue su GitHub. Hai una domanda o un'idea? Chiedi su GitHub Discussions — apprezziamo il tuo feedback!
Com'è la qualità della traduzione su questa pagina?

Come calcolare limiti personalizzati

compute_forward_bounds e compute_backward_bounds sono wrapper di comodo. Entrambi sono costruiti a partire dagli stessi tre elementi, che l'API pubblica espone:

  • Una funzione di norma, che limita un singolo termine di errore di Pauli evoluto

  • Un cono di luce, che stabilisce verso cosa vengono fatti evolvere i termini di errore

  • compute_bounds, che itera il circuito e distribuisce il lavoro per ciascun termine

Assemblarli da soli significa poter limitare qualcosa che i wrapper non coprono. Questa guida illustra un caso in cui vale la pena farlo: un circuito speculare, in cui il solito limite all'indietro è inutilmente largo.

Cosa fanno realmente i wrapper​

I due wrapper differiscono nei tre punti elencati sopra:

compute_forward_boundscompute_backward_bounds
circuitocome fornitoremove_measure(circuit).inverse()
funzione di normatime_evolved_norm_forward, rispetto all'osservabiletime_evolved_norm_backward, rispetto allo stato iniziale
cono di luceLightCone.initialize_from_pauli, inizializzato dall'osservabileLightCone.initialize_from_measurements, inizializzato da ogni qubit attivo
backwardsFalseTrue

Il limite in avanti chiede "Quanto può questo errore cambiare l'osservabile che sto per misurare?" e risponde con la norma spettrale di un commutatore. Il limite all'indietro chiede "Quanto può questo errore cambiare lo stato da cui sono partito?" e risponde con una norma nucleare, assumendo che tale stato sia lo stato tutto-zero su ogni qubit attivo. Sono domande diverse, e ciascuna è quella giusta per gli errori nella propria metà del circuito.

Il caso del circuito speculare​

Un circuito speculare applica un'unitaria e poi la annulla: U†UU^\dagger U. Compaiono ogni volta che l'esito ideale deve essere noto esattamente (in genere echi di Loschmidt, randomized benchmarking ed esperimenti di verifica).

I circuiti speculari hanno una proprietà che il limite all'indietro generico non può sfruttare. Poiché la seconda metà è l'inversa della prima, far evolvere un errore all'indietro fino allo stato iniziale attraversa gli stessi gate di farlo evolvere in avanti fino all'osservabile. Le due domande coincidono, quindi il limite più netto in stile forward è valido anche per la direzione all'indietro.

# 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

Assemblare il limite personalizzato​

La ricetta mantiene ciò che compute_backward_bounds fa con il circuito (rimuovere le misure, invertirlo e iterarlo con backwards=True) e sostituisce gli altri due elementi: la funzione di norma in avanti e un cono di luce inizializzato dall'osservabile con LightCone.initialize_from_pauli, esattamente come compute_forward_bounds inizializza il proprio. Ogni sostituzione è una riga.

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,
)

Quanto si guadagna​

Confronto con il limite all'indietro standard sullo stesso circuito, riassunto nello stesso modo della guida alla configurazione: il limite medio e la sua deviazione standard, seguiti dalla quota di termini con limite 0, con un limite calcolato nel mezzo e lasciati al banale 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%

Il limite all'indietro standard è vicino al valore banale di 2.0, che porta quasi nessuna informazione. In un circuito speculare, ogni errore è "vicino allo stato iniziale" nel senso misurato da quel limite, quindi non può discriminare tra i termini. Il limite personalizzato è diverse volte più stretto e limita molti dei termini a 0.

Per verificare l'implementazione, i due possono essere confrontati box per box. Per ogni box, il limite all'indietro personalizzato risulta identico al limite in avanti dello stesso box, a meno dell'errore di arrotondamento. Entrambi limitano lo stesso errore nello stesso punto del circuito, e in un circuito speculare i gate dopo quel punto si annullano nell'inversa dei gate che lo precedono, quindi i due calcoli valutano la stessa quantità da estremi opposti:

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

Nota: Questa uguaglianza è una proprietà della struttura speculare, non della tecnica. In un circuito che non è autoinverso, la stessa sostituzione viene comunque eseguita ma non calcola più un limite valido per la direzione all'indietro: risponde alla domanda in avanti su una metà del circuito dove quella domanda non si applica. Usalo solo quando la seconda metà del tuo circuito inverte la prima.

Inserirlo nel resto del flusso di lavoro​

Il risultato è un normale dizionario Bounds, quindi tutto ciò che segue resta invariato.

In un circuito speculare non c'è nemmeno motivo di calcolare entrambi i limiti per l'intero circuito. I limiti in avanti servono solo per i box di U†U^\dagger, contati dalla fine, e i limiti all'indietro solo per i box di UU, contati dall'inizio. max_num_boxes quindi limita ciascuno a metà dei box rumorosi, e merge_bounds unisce le due metà.

Ecco come i limiti vengono usati in pratica, e significa che il confronto box per box qui sopra non è più disponibile: richiede entrambi i limiti per ogni box. È un controllo dell'implementazione per questa guida, non un passo del flusso di lavoro.

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

Con i limiti all'indietro standard, l'overhead di campionamento è molto maggiore. merge_bounds usa i limiti all'indietro fino a un punto di passaggio nel circuito e i limiti in avanti da lì in poi, scegliendo il punto che minimizza il bias totale. Con ciascun limite calcolato per una metà, quel punto cade a metà del circuito, quindi i box di UU sono coperti solo da limiti all'indietro. Il limite all'indietro standard è vicino al banale 2.0 lì, il che fa salire l'overhead. Quello personalizzato è stretto quanto sarebbe stato il limite in avanti, quindi l'overhead coincide con quello dei limiti in avanti completi.

Ciò solleva l'ovvia domanda sul perché prendersi il disturbo, quando i soli limiti in avanti completi danno lo stesso risultato. La risposta è il costo. Un limite in avanti per un box all'inizio del circuito fa evolvere il suo errore attraverso il resto di UU e poi tutto U†U^\dagger, che è l'evoluzione più lunga e più ampia nel circuito. L'operatore evoluto può crescere esponenzialmente nel numero di gate non-Clifford che attraversa, e calcolare la norma spettrale del commutatore diventa esponenzialmente più costoso man mano che il cono di luce si allarga. Nell'approccio a metà e metà, nessun errore viene fatto evolvere attraverso più di metà del circuito.

Il risparmio cresce quindi con la profondità del circuito. Ancora più importante, i limiti in avanti completi smettono di essere calcolabili molto prima di quelli a metà. Quando le evoluzioni dei primi box superano evolution_max_terms o eigval_max_qubits, i loro limiti vengono troncati o ripiegano sulla disuguaglianza triangolare, e alla fine costano troppo da calcolare. Ovunque si trovi quel punto, l'approccio a metà e metà richiede solo che cada da qualche parte nella prima metà del circuito. I limiti in avanti non devono arrivare oltre la metà, e in un circuito speculare i limiti all'indietro per i box di UU fanno evolvere i loro errori fino all'inizio attraverso tanto circuito quanto i limiti in avanti per i box corrispondenti di U†U^\dagger fino alla fine, quindi si esauriscono alla stessa profondità.

Nota: Perché calcolare i limiti all'indietro per i box di UU, quando i limiti in avanti dei box di U†U^\dagger sembrano dover contenere gli stessi numeri? Perché i box non sono immagini speculari l'uno dell'altro. Ogni box inietta il proprio rumore su un lato dei suoi gate, e i livelli inversi di U†U^\dagger sono box separati con modelli di rumore propri, quindi il box che rispecchia un box di UU vede i suoi errori in un punto diverso del circuito. Pertanto, i loro limiti concordano solo in parte, e i limiti per i box di UU devono essere calcolati per quei box.

Scrivere altri limiti personalizzati​

Le stesse tre sostituzioni coprono altri casi:

  • Una norma diversa. time_evolved_norm_forward accetta comm_norm_order; passando qualsiasi valore diverso da 2 si salta l'autosolver e si calcola direttamente quella norma.

  • Un cono di luce diverso. LightCone.initialize_from_pauli inizializza un cono di luce da qualsiasi Pauli, e il costruttore LightCone prende i qubit da tracciare e le operazioni che lo inizializzano, quindi un limite può essere calcolato rispetto a qualsiasi operatore tu possa esprimere in questo modo.

  • Una funzione di norma completamente diversa. compute_bounds richiede solo un oggetto richiamabile: che prenda un Pauli e RotationGates e restituisca un CommutatorBounds. Qualsiasi cosa soddisfi quella firma funziona, incluso un limite in forma chiusa specifico per la tua famiglia di circuiti.

Qualunque cosa tu sostituisca, devi dimostrare che il risultato è un autentico limite superiore. Sottostimare l'effetto di un termine di errore non produce un fallimento visibile. Produce un valore di aspettazione silenziosamente sbagliato, perché il termine sarà stato eliminato dal modello di rumore mitigato.