Cómo ajustar las opciones de configuración
El cálculo de las cotas es exacto en principio pero aproximado en la práctica: evolucionar un término de error de Pauli a través de un circuito produce un operador con un número exponencial de términos, y hallar la norma espectral del conmutador resultante es en sí mismo costoso. Las opciones siguientes controlan ese coste. Se dividen en tres grupos:
-
Las opciones de precisión cambian el valor numérico de las cotas, intercambiando ajuste por coste.
-
El alcance determina qué cotas se calculan.
-
Las opciones de ejecución cambian cómo se ejecuta el cálculo (no lo que calcula).
| Opción | Valor predeterminado | Controla | Efecto en el resultado |
|---|---|---|---|
| Precisión | |||
evolution_max_terms | 1_000_000 | Cuántos términos del operador sobreviven a la evolución | Menor: más barato, cotas más holgadas |
atol_simplify | 1e-8 | Qué términos del conmutador se descartan por ser despreciables | Mayor: más barato, cotas más holgadas |
eigval_max_qubits | 14 | Qué anchura puede tener un conmutador para calcular su norma espectral | Menor: más barato, cotas más holgadas |
atol_eigenvalue | 1e-10 | Cuándo se considera convergido el solucionador iterativo de autovalores | Mayor: más barato, cotas menos precisas |
| Alcance | |||
max_num_boxes | None | A cuántos boxes se les calculan cotas | Los boxes más allá del límite conservan la cota trivial de 2.0 |
| Ejecución | |||
timeout | None | Un presupuesto de tiempo real para todo el cálculo | Cualquier trabajo restante conserva la cota trivial de 2.0 |
num_processes | 1 | Cuántos procesos de trabajo calculan cotas en paralelo | Ninguno por sí solo |
Una cota solo es útil si es válida: nunca debe subestimar el efecto de un término de error sobre el
observable. Relajar cualquiera de estas opciones, salvo atol_eigenvalue, solo puede hacer que las cotas
sean mayores (más conservadoras), nunca menores. Así, el coste de un ajuste holgado es un mayor
sobrecoste de muestreo, no un valor esperado erróneo; atol_eigenvalue es la excepción.
Definición del circuito
Los siguientes experimentos usan el mismo circuito de Ising con Trotter y el mismo observable que la guía de inicio rápido. Con este tamaño, cada opción tiene un efecto visible y cada cálculo sigue tardando menos de un segundo.
# 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
Una pequeña función auxiliar resume cada conjunto de cotas en una línea, de modo que el efecto de una opción se vea en unos pocos números en lugar de en un muro de arreglos.
Toda cota se sitúa entre 0 y la cota trivial de 2.0. El resumen indica la cota media y su desviación estándar, seguidas de cómo se reparten los términos en estos grupos:
- Términos acotados exactamente por 0 porque conmutan con el observable evolucionado
- Términos con una cota calculada entre 0 y 2.0
- Términos que se quedan en el 2.0 trivial
Ten en cuenta que una media menor es más ajustada, y más ajustada significa un menor sobrecoste de muestreo más adelante.
Un barrido sobre una opción marca el valor predeterminado de la opción con *:
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
Precisión
evolution_max_terms
Evolucionar un término de error de Pauli a través de compuertas no Clifford lo convierte en una suma de Paulis, y esa suma
crece con la profundidad del circuito. evolution_max_terms limita cuántos de esos términos se conservan. La 1-norma de
lo que se descarta se suma a la cota, de modo que nada se pierde en silencio.
El barrido va desde un valor más ajustado que el predeterminado hasta un recorte extremo. Reducir el límite hace que el cálculo sea más barato y las cotas más holgadas.
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
Subir el límite por encima de su valor predeterminado no cambia nada aquí. Para un circuito de este tamaño, el valor predeterminado ya conserva todos los términos, así que las cotas son tan ajustadas como esta opción puede conseguir. Esa es la situación típica. Ajustar más una opción de precisión solo compensa cuando su valor predeterminado es realmente limitante, y la forma de averiguarlo es el experimento anterior. Vuelve a ejecutarlo con un valor más ajustado y comprueba si las cotas se mueven.
Advertencia: La rutina de propagación subyacente trata evolution_max_terms como una capacidad que hay que
preasignar, no como un límite superior que se comprueba. Pasar un valor nominalmente ilimitado, como
numpy.iinfo(numpy.uint).max, hace que se soliciten terabytes de memoria al asignador y aborta el proceso.
Mantenlo en un número realista. El valor predeterminado de un millón ya es generoso.
atol_simplify
Una vez formado el conmutador, se descartan los términos con un coeficiente inferior a atol_simplify. Igual que con
el límite de evolución, la 1-norma perdida de este modo se suma a la cota.
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 cota disponible más ajustada es la norma espectral del conmutador, que requiere un cálculo de autovalores.
Eso se vuelve costoso a medida que el conmutador se ensancha, así que eigval_max_qubits fija la anchura
a partir de la cual se abandona el intento en favor de la desigualdad triangular. Esa cota,
sobre los coeficientes del conmutador, es válida pero más holgada.
Ponerlo a cero fuerza la alternativa en todas partes, lo que muestra el beneficio del solucionador de autovalores en un solo número.
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
Incluso con el solucionador de autovalores desactivado, la media se mantiene muy por debajo del 2.0 trivial. La proporción de términos acotados por 0 no cambia: un término que conmuta exactamente con el observable evolucionado tiene un conmutador nulo, lo cual se reconoce antes de considerar siquiera un cálculo de autovalores. De los otros, la cota de la desigualdad triangular llega a 2.0 en algunos y queda limitada ahí, pero en muchos sigue siendo menor, de modo que conservan una cota calculada inferior a 2.0.
atol_eigenvalue
Cuando sí se calcula la norma espectral, atol_eigenvalue es la tolerancia de convergencia del
solucionador iterativo de autovalores. Sin embargo, la mayoría de los conmutadores nunca llegan a ese solucionador:
-
Un conmutador que actúa sobre como máximo cuatro qubits se diagonaliza de forma exacta y directa.
-
Uno más ancho se reduce primero al menor número de qubits que necesitan sus términos de Pauli, y si esa forma reducida actúa sobre como máximo ocho qubits, se diagonaliza de forma exacta.
Solo un conmutador que todavía necesita más de ocho qubits tras esa reducción pasa al solucionador iterativo, que es el único lugar donde se aplica esta tolerancia. Los circuitos del tamaño usado en esta guía nunca llegan tan lejos, por lo que un barrido sobre esta opción mostraría cotas idénticas para cada valor. Importa para conos de luz anchos en dispositivos grandes.
A diferencia de las otras opciones de precisión, relajar atol_eigenvalue no es seguro en una dirección. Un
solucionador iterativo de autovalores se aproxima al autovalor extremo desde dentro del espectro, de modo que una estimación
que no ha convergido del todo subestima la norma espectral y, con ella, la cota. El valor predeterminado es
deliberadamente estricto por esa razón. Si el solucionador no converge, entra en juego la alternativa de la
desigualdad triangular, que vuelve a ser válida.
Alcance
max_num_boxes
A veces el límite útil no es la precisión sino la profundidad. max_num_boxes deja de lanzar cálculos de cotas
tras un número dado de boxes, y a cada capa posterior se le asigna en su lugar la cota trivial
de 2.0.
El límite cuenta las instrucciones BoxOp ruidosas (las que llevan una anotación InjectNoise) en lugar
de, por ejemplo, una profundidad en compuertas. Eso lo mantiene ligado a la estructura del circuito visible para el usuario y
evita depender de las referencias de anotación generadas automáticamente, que pueden cambiar entre
ejecuciones de transpilación.
Esta es la elección natural para un circuito cuyas dos mitades se tratan con cotas hacia adelante y hacia atrás respectivamente: calcula cada una solo hasta la profundidad en que se usará.
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
Ejecución
timeout
timeout es un presupuesto de tiempo real en segundos. Cuando expira, se conservan las cotas que ya se han calculado
y todo lo pendiente conserva la cota trivial de 2.0, de modo que el resultado siempre es utilizable, solo que
cuanto antes lo cortes, más holgado.
No es un plazo estricto: se permite que terminen las tareas que ya están en ejecución, así que la llamada puede exceder el presupuesto en la duración de una tarea.
num_processes
Las cotas de distintos términos de error son independientes, así que num_processes las distribuye entre procesos de
trabajo. Por sí solo, esto afecta únicamente al tiempo que tarda el cálculo, nunca a su resultado.
Nota: Combinar timeout con num_processes hace que el resultado dependa del tiempo: qué tareas
terminan antes de que expire el presupuesto no es determinista. Las cotas siguen siendo válidas, pero ya no son
reproducibles de una ejecución a otra.
Por dónde empezar
Los valores predeterminados se eligen para dar cotas ajustadas en circuitos de un tamaño en el que eso es asequible. Cuando un cálculo es demasiado lento, prueba a aplicar las opciones aproximadamente en este orden:
-
num_processes: Una aceleración gratuita, sin coste de precisión. -
max_num_boxes: Si sabes de antemano hasta qué profundidad del circuito te interesa. -
eigval_max_qubits: Omite el paso más costoso para los conmutadores más anchos, que suelen ser una pequeña minoría de los términos. -
evolution_max_termsyatol_simplify: Estas afectan a la precisión en todo el cálculo, así que ajústalas las últimas, comprobando con un barrido como los anteriores cuánto se mueven las cotas. -
timeout: Una parada forzosa más que una ayuda de ajuste, para cuando un cálculo debe terminar antes de un plazo, sea cual sea la calidad.
Cuanto más holgadas sean las cotas, mayor será el sobrecoste de muestreo que compute_local_scales informará para
una tolerancia de sesgo dada. Ese sobrecoste es la magnitud que merece la pena vigilar al ajustar: te dice lo que
cuestan realmente las aproximaciones.