Aller au contenu principal
Ce projet est en phase bêta. Tu as trouvé un bug ? Ouvrir un ticket GitHub. Une question ou une idée ? Pose ta question dans Discussions — tes retours sont les bienvenus !
Quelle est la qualité de la traduction sur cette page ?

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_boundscompute_backward_bounds
circuittel que donnéremove_measure(circuit).inverse()
fonction de normetime_evolved_norm_forward, par rapport à l'observabletime_evolved_norm_backward, par rapport à l'état initial
cône de lumièreLightCone.initialize_from_pauli, amorcé à partir de l'observableLightCone.initialize_from_measurements, amorcé à partir de chaque qubit actif
backwardsFalseTrue

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 : U†UU^\dagger U. 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 U†U^\dagger, comptées depuis la fin, et les bornes arrière uniquement pour les boîtes de UU, 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 UU 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 UU puis tout U†U^\dagger, 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 UU font évoluer leurs erreurs jusqu'au début à travers autant de circuit que les bornes avant pour les boîtes correspondantes de U†U^\dagger jusqu'à la fin, donc elles atteignent leur limite à la même profondeur.

Note : Pourquoi calculer des bornes arrière pour les boîtes de UU, alors que les bornes avant des boîtes de U†U^\dagger 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 U†U^\dagger sont des boîtes distinctes avec leurs propres modèles de bruit, donc la boîte qui reflète une boîte de UU 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 UU 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_forward accepte comm_norm_order ; passer autre chose que 2 saute le solveur propre et calcule directement cette norme.

  • Un cône de lumière différent. LightCone.initialize_from_pauli amorce un cône de lumière à partir de n'importe quel Pauli, et le constructeur LightCone prend 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_bounds exige seulement un objet appelable : prenant un Pauli et RotationGates et renvoyant un CommutatorBounds. 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é.