Comment calculer des bornes personnalisées
compute_forward_bounds et compute_backward_bounds sont des enveloppes pratiques. Les deux sont construites à partir
des mêmes trois éléments, que l'API publique expose :
-
Une fonction de norme, qui borne un seul terme d'erreur de Pauli évolué
-
Un cône de lumière, qui amorce vers quoi les termes d'erreur sont évolués
-
compute_bounds, qui itère sur le circuit et répartit le travail par terme
Les assembler toi-même te permet de borner ce que les enveloppes ne couvrent pas. Ce guide présente un cas où cela vaut la peine : un circuit miroir, où la borne arrière habituelle est inutilement lâche.
Ce que font réellement les enveloppes
Les deux enveloppes diffèrent sur les trois points énumérés ci-dessus :
compute_forward_bounds | compute_backward_bounds | |
|---|---|---|
| circuit | tel que donné | remove_measure(circuit).inverse() |
| fonction de norme | time_evolved_norm_forward, par rapport à l'observable | time_evolved_norm_backward, par rapport à l'état initial |
| cône de lumière | LightCone.initialize_from_pauli, amorcé à partir de l'observable | LightCone.initialize_from_measurements, amorcé à partir de chaque qubit actif |
backwards | False | True |
La borne avant pose la question « Dans quelle mesure cette erreur peut-elle modifier l'observable que je m'apprête à mesurer ? » et y répond avec la norme spectrale d'un commutateur. La borne arrière pose la question « Dans quelle mesure cette erreur peut-elle modifier l'état dont je suis parti ? » et y répond avec une norme nucléaire, en prenant cet état comme l'état tout-zéro sur chaque qubit actif. Ce sont des questions différentes, et chacune est la bonne pour les erreurs de sa propre moitié du circuit.
Le cas du circuit miroir
Un circuit miroir applique une unitaire puis l'annule : . Ils apparaissent chaque fois que le résultat idéal doit être connu exactement (généralement les échos de Loschmidt, le benchmarking randomisé et les expériences de vérification).
Les circuits miroirs ont une propriété que la borne arrière générique ne peut pas exploiter. Comme la seconde moitié est l'inverse de la première, faire évoluer une erreur vers l'arrière jusqu'à l'état initial traverse les mêmes Gates que la faire évoluer vers l'avant jusqu'à l'observable. Les deux questions coïncident, donc la borne plus fine de type avant est valide aussi pour la direction arrière.
# 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
Assembler la borne personnalisée
La recette conserve ce que compute_backward_bounds fait du circuit (retirer les mesures,
l'inverser et l'itérer avec backwards=True) et substitue les deux autres éléments : la fonction de
norme avant, et un cône de lumière amorcé à partir de l'observable avec LightCone.initialize_from_pauli,
exactement comme compute_forward_bounds amorce le sien. Chaque substitution tient en une ligne.
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,
)
Ce qu'elle apporte
Comparaison avec la borne arrière standard sur le même circuit, résumée de la même façon que dans le guide de configuration : la borne moyenne et son écart type, puis la proportion de termes bornés par 0, avec une borne calculée entre les deux, et laissés à la borne triviale de 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%
La borne arrière standard se situe près de la valeur triviale de 2.0, ce qui ne porte presque aucune information. Sur un circuit miroir, chaque erreur est « proche de l'état initial » au sens que mesure cette borne, donc elle ne peut pas distinguer les termes. La borne personnalisée est plusieurs fois plus serrée et borne de nombreux termes par 0.
Pour vérifier l'implémentation, on peut comparer les deux boîte par boîte. Pour chaque boîte, la borne arrière personnalisée est identique à la borne avant de cette même boîte, aux erreurs d'arrondi près. Les deux bornent la même erreur au même point du circuit, et sur un circuit miroir, les Gates situées après ce point s'annulent en l'inverse des Gates qui le précèdent, donc les deux calculs évaluent la même quantité depuis des extrémités opposées :
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
Note : Cette égalité est une propriété de la structure miroir, pas de la technique. Sur un circuit qui n'est pas auto-inverse, la même substitution s'exécute encore mais ne calcule plus une borne valide pour la direction arrière : elle répond à la question avant pour une moitié de circuit où cette question ne s'applique pas. Ne l'utilise que lorsque la seconde moitié de ton circuit inverse la première.
L'intégrer au reste du flux de travail
Le résultat est un dictionnaire Bounds ordinaire, donc tout ce qui suit reste inchangé.
Sur un circuit miroir, il n'y a pas non plus de raison de calculer l'une ou l'autre borne pour tout le circuit. Les
bornes avant ne sont nécessaires que pour les boîtes de , comptées depuis la fin, et les bornes arrière
uniquement pour les boîtes de , comptées depuis le début. max_num_boxes limite donc chacune
à la moitié des boîtes bruitées, et merge_bounds joint les deux moitiés.
C'est ainsi que les bornes sont utilisées en pratique, et cela signifie que la comparaison boîte par boîte ci-dessus n'est plus disponible : elle nécessite les deux bornes pour chaque boîte. C'est une vérification de l'implémentation pour ce guide, pas une étape du flux de travail.
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
Avec les bornes arrière standard, le surcoût d'échantillonnage est bien plus grand. merge_bounds utilise les
bornes arrière jusqu'à un point de bascule dans le circuit et les bornes avant à partir de là,
en choisissant le point qui minimise le biais total. Avec chaque borne calculée pour une moitié, ce point
tombe au milieu du circuit, donc les boîtes de sont couvertes par les seules bornes arrière. La
borne arrière standard est proche de la borne triviale de 2.0 à cet endroit, ce qui fait grimper le surcoût. La borne
personnalisée est aussi serrée que l'aurait été la borne avant, donc le surcoût correspond à celui des bornes
avant complètes.
Cela soulève la question évidente de savoir pourquoi s'en donner la peine, alors que les bornes avant complètes donnent à elles seules le même résultat. La réponse est le coût. Une borne avant pour une boîte située tôt dans le circuit fait évoluer son erreur à travers le reste de puis tout , ce qui est l'évolution la plus longue et la plus large du circuit. L'opérateur évolué peut croître exponentiellement avec le nombre de Gates non Clifford qu'il traverse, et le calcul de la norme spectrale du commutateur devient exponentiellement plus coûteux à mesure que le cône de lumière s'élargit. Dans l'approche moitié-moitié, aucune erreur n'est évoluée à travers plus de la moitié du circuit.
Le gain croît donc avec la profondeur du circuit. Plus important encore, les bornes avant complètes
cessent d'être calculables bien avant les bornes de moitié. Une fois que les évolutions des premières
boîtes dépassent evolution_max_terms ou eigval_max_qubits, leurs bornes sont tronquées ou se replient
sur l'inégalité triangulaire, et finalement elles coûtent trop cher à calculer. Où que se situe ce point,
l'approche moitié-moitié a seulement besoin qu'il tombe quelque part dans la première moitié du circuit.
Les bornes avant n'ont pas à aller plus loin que le milieu, et sur un circuit miroir, les bornes arrière
pour les boîtes de font évoluer leurs erreurs jusqu'au début à travers autant de circuit que les bornes avant pour les boîtes correspondantes de jusqu'à la fin, donc elles atteignent leur limite
à la même profondeur.
Note : Pourquoi calculer des bornes arrière pour les boîtes de , alors que les bornes avant des boîtes de semblent devoir contenir les mêmes nombres ? Parce que les boîtes ne sont pas des images miroirs les unes des autres. Chaque boîte injecte son bruit d'un côté de ses Gates, et les couches inverses de sont des boîtes distinctes avec leurs propres modèles de bruit, donc la boîte qui reflète une boîte de voit ses erreurs à un point différent du circuit. Par conséquent, leurs bornes ne concordent que partiellement, et les bornes des boîtes de doivent être calculées pour ces boîtes.
Écrire d'autres bornes personnalisées
Les mêmes trois substitutions couvrent d'autres cas :
-
Une norme différente.
time_evolved_norm_forwardacceptecomm_norm_order; passer autre chose que2saute le solveur propre et calcule directement cette norme. -
Un cône de lumière différent.
LightCone.initialize_from_pauliamorce un cône de lumière à partir de n'importe quel Pauli, et le constructeurLightConeprend les qubits à suivre et les opérations qui l'amorcent, de sorte qu'une borne peut être calculée par rapport à tout opérateur que tu peux exprimer ainsi. -
Une fonction de norme entièrement différente.
compute_boundsexige seulement un objet appelable : prenant unPaulietRotationGateset renvoyant unCommutatorBounds. Tout ce qui satisfait cette signature fonctionne, y compris une borne en forme close propre à ta famille de circuits.
Quoi que tu substitues, tu dois prouver que le résultat est une véritable borne supérieure. Sous-estimer l'effet d'un terme d'erreur ne produit pas d'échec visible. Cela produit une valeur moyenne discrètement erronée, parce que le terme aura été retiré du modèle de bruit atténué.