Comment ajuster les options de configuration
Le calcul des bornes est exact en principe mais approximatif en pratique : l'évolution d'un terme d'erreur de Pauli à travers un circuit produit un opérateur comportant un nombre exponentiel de termes, et trouver la norme spectrale du commutateur obtenu est lui-même coûteux. Les options ci-dessous contrôlent ce coût. Elles se répartissent en trois groupes :
-
Les options d'exactitude modifient la valeur numérique des bornes, en échangeant précision contre coût.
-
La portée détermine quelles bornes sont calculées.
-
Les options d'exécution modifient la manière dont le calcul s'exécute (et non ce qu'il calcule).
| Option | Valeur par défaut | Contrôle | Effet sur le résultat |
|---|---|---|---|
| Exactitude | |||
evolution_max_terms | 1_000_000 | Combien de termes d'opérateur survivent à l'évolution | Plus bas : moins coûteux, bornes plus lâches |
atol_simplify | 1e-8 | Quels termes du commutateur sont écartés comme négligeables | Plus haut : moins coûteux, bornes plus lâches |
eigval_max_qubits | 14 | Quelle largeur peut avoir un commutateur pour que sa norme spectrale soit calculée | Plus bas : moins coûteux, bornes plus lâches |
atol_eigenvalue | 1e-10 | Quand le solveur propre itératif est considéré comme convergé | Plus haut : moins coûteux, bornes moins exactes |
| Portée | |||
max_num_boxes | None | Pour combien de boîtes les bornes sont calculées | Les boîtes au-delà de la limite gardent la borne triviale de 2.0 |
| Exécution | |||
timeout | None | Un budget de temps réel pour l'ensemble du calcul | Tout travail restant garde la borne triviale de 2.0 |
num_processes | 1 | Combien de processus de travail calculent les bornes en parallèle | Aucun en soi |
Une borne n'est utile que si elle est valide : elle ne doit jamais sous-estimer l'effet d'un terme d'erreur sur
l'observable. Relâcher l'une de ces options, hormis atol_eigenvalue, ne peut qu'agrandir les bornes
(les rendre plus conservatrices), jamais les réduire. Ainsi, le coût d'un réglage lâche est un surcoût d'échantillonnage
plus important, et non une valeur moyenne erronée - atol_eigenvalue fait exception.
Définition du circuit
Les expériences suivantes utilisent le même circuit d'Ising trotterisé et la même observable que le guide de démarrage rapide. À cette taille, chaque option a un effet visible tandis que chaque calcul prend encore moins d'une seconde.
# 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
Une petite fonction utilitaire résume chaque ensemble de bornes en une ligne, de sorte que l'effet d'une option apparaisse sous la forme de quelques nombres plutôt que d'un mur de tableaux.
Chaque borne se situe entre 0 et la borne triviale de 2.0. Le résumé indique la borne moyenne et son écart type, puis la répartition des termes dans ces groupes :
- Termes bornés par exactement 0 parce qu'ils commutent avec l'observable évoluée
- Termes dont la borne calculée est comprise entre 0 et 2.0
- Termes laissés à la borne triviale de 2.0
Note que une moyenne plus basse est plus serrée, et plus serrée signifie un surcoût d'échantillonnage plus faible par la suite.
Un balayage sur une option marque la valeur par défaut de l'option avec * :
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
Exactitude
evolution_max_terms
Faire évoluer un terme d'erreur de Pauli à travers des Gates non Clifford le transforme en une somme de Paulis, et cette somme
croît avec la profondeur du circuit. evolution_max_terms plafonne le nombre de ces termes conservés. La 1-norme de
ce qui est écarté est ajoutée à la borne, de sorte que rien n'est perdu silencieusement.
Le balayage va d'une valeur plus serrée que la valeur par défaut jusqu'à une coupe extrême. Abaisser le plafond rend le calcul moins coûteux et les bornes plus lâches.
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
Relever le plafond au-delà de sa valeur par défaut ne change rien ici. Pour un circuit de cette taille, la valeur par défaut conserve déjà tous les termes, donc les bornes sont aussi serrées que cette option peut les rendre. C'est la situation typique. Resserrer une option d'exactitude ne vaut la peine que lorsque sa valeur par défaut est réellement limitante, et le moyen de le savoir est l'expérience ci-dessus. Relance-la avec une valeur plus serrée et regarde si les bornes bougent.
Avertissement : La routine de propagation sous-jacente traite evolution_max_terms comme une capacité à
pré-allouer, et non comme une limite supérieure à vérifier. Passer une valeur nominalement illimitée, comme
numpy.iinfo(numpy.uint).max, demande à l'allocateur des téraoctets de mémoire et interrompt le processus.
Garde un nombre réaliste. La valeur par défaut d'un million est déjà généreuse.
atol_simplify
Une fois le commutateur formé, les termes dont le coefficient est inférieur à atol_simplify sont supprimés. Comme pour
le plafond d'évolution, la 1-norme perdue de cette façon est ajoutée à la borne.
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
La borne la plus serrée disponible est la norme spectrale du commutateur, qui nécessite un calcul de valeurs propres.
Celui-ci devient coûteux à mesure que le commutateur s'élargit, donc eigval_max_qubits fixe la largeur
au-delà de laquelle la tentative est abandonnée au profit de l'inégalité triangulaire. Cette borne,
sur les coefficients du commutateur, est valide mais plus lâche.
La fixer à zéro force le repli partout, ce qui montre le bénéfice du solveur propre en un seul nombre.
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
Même avec le solveur propre désactivé, la moyenne reste bien en dessous de la borne triviale de 2.0. La proportion de termes bornés par 0 ne change pas : un terme qui commute exactement avec l'observable évoluée a un commutateur nul, ce qui est reconnu avant même d'envisager un calcul de valeurs propres. Parmi les autres, la borne par inégalité triangulaire atteint 2.0 pour certains et y est plafonnée, mais pour beaucoup elle est encore plus petite, si bien qu'ils gardent une borne calculée inférieure à 2.0.
atol_eigenvalue
Lorsque la norme spectrale est calculée, atol_eigenvalue est la tolérance de convergence du
solveur propre itératif. La plupart des commutateurs n'atteignent cependant jamais ce solveur :
-
Un commutateur agissant sur au plus quatre qubits est diagonalisé exactement, directement.
-
Un commutateur plus large est d'abord réduit au plus petit nombre de qubits dont ses termes de Pauli ont besoin, et si cette forme réduite agit sur au plus huit qubits, il est diagonalisé exactement.
Seul un commutateur qui nécessite encore plus de huit qubits après cette réduction est confié au solveur itératif, ce qui est le seul endroit où cette tolérance s'applique. Les circuits de la taille utilisée dans ce guide n'en arrivent jamais là, donc un balayage sur cette option montrerait des bornes identiques pour chaque valeur. Elle compte pour des cônes de lumière larges sur de grands appareils.
Contrairement aux autres options d'exactitude, relâcher atol_eigenvalue n'est pas sûr dans un sens. Un
solveur propre itératif approche la valeur propre extrême de l'intérieur du spectre, donc une estimation
qui n'a pas totalement convergé sous-estime la norme spectrale, et donc la borne. La valeur par défaut est
délibérément serrée pour cette raison. Si le solveur ne converge pas, le repli par inégalité triangulaire
prend le relais, ce qui est de nouveau valide.
Portée
max_num_boxes
Parfois, la limite utile n'est pas l'exactitude mais la profondeur. max_num_boxes cesse de lancer des calculs de
bornes au-delà d'un nombre donné de boîtes, et chaque couche au-delà reçoit à la place la borne triviale
de 2.0.
La limite compte les instructions BoxOp bruitées (celles qui portent une annotation InjectNoise) plutôt
que, par exemple, une profondeur en Gates. Cela la lie à la structure visible par l'utilisateur du circuit et
évite de dépendre des références d'annotation générées automatiquement, qui peuvent changer d'une exécution de
transpilation à l'autre.
C'est le choix naturel pour un circuit dont les deux moitiés sont traitées respectivement par des bornes avant et arrière : ne calcule chacune que jusqu'à la profondeur où elle sera utilisée.
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
Exécution
timeout
timeout est un budget de temps réel en secondes. Lorsqu'il expire, les bornes déjà calculées sont conservées
et tout ce qui est en attente garde la borne triviale de 2.0, de sorte que le résultat est toujours utilisable, simplement
plus lâche plus tu l'interromps tôt.
Ce n'est pas une échéance stricte : les tâches déjà en cours peuvent se terminer, donc l'appel peut dépasser le budget de la durée d'une tâche.
num_processes
Les bornes de différents termes d'erreur sont indépendantes, donc num_processes les répartit sur des processus
de travail. À lui seul, il n'affecte que la durée du calcul, jamais son résultat.
Note : Combiner timeout et num_processes rend le résultat dépendant du temps : quelles tâches
se terminent avant l'expiration du budget n'est pas déterministe. Les bornes restent valides, mais elles ne sont plus
reproductibles d'une exécution à l'autre.
Par où commencer
Les valeurs par défaut sont choisies pour donner des bornes serrées sur des circuits d'une taille où cela reste abordable. Lorsqu'un calcul est trop lent, essaie d'appliquer les options à peu près dans cet ordre :
-
num_processes: une accélération gratuite, sans coût en exactitude. -
max_num_boxes: si tu sais à l'avance jusqu'à quelle profondeur du circuit tu t'intéresses. -
eigval_max_qubits: saute l'étape la plus coûteuse pour les commutateurs les plus larges, qui sont généralement une petite minorité des termes. -
evolution_max_termsetatol_simplify: ils influent sur l'exactitude partout, donc ajuste-les en dernier, en vérifiant avec un balayage comme ceux ci-dessus de combien les bornes bougent. -
timeout: un arrêt brutal plutôt qu'une aide au réglage, pour le cas où un calcul doit se terminer avant une échéance, quelle que soit la qualité.
Plus les bornes sont lâches, plus le surcoût d'échantillonnage que compute_local_scales rapportera est grand pour
une tolérance de biais donnée. Ce surcoût est la quantité à surveiller pendant le réglage : il t'indique ce que
coûtent réellement les approximations.