Saltar al contenido principal
Este proyecto está en fase beta. ¿Encontraste un error? Abre un issue en GitHub. ¿Tienes una pregunta o una idea? Pregunta en Discussions — ¡nos encantaría tu opinión!
¿Cómo es la calidad de la traducción en esta página?

Cómo calcular cotas personalizadas

compute_forward_bounds y compute_backward_bounds son envoltorios de conveniencia. Ambos se construyen a partir de las mismas tres piezas, que la API pública expone:

  • Una función de norma, que acota un único término de error de Pauli evolucionado

  • Un cono de luz, que siembra hacia dónde se evolucionan los términos de error

  • compute_bounds, que itera el circuito y distribuye el trabajo por término

Ensamblarlas tú mismo significa que puedes acotar algo que los envoltorios no cubren. Esta guía recorre un caso en el que merece la pena hacerlo: un circuito espejo, donde la cota hacia atrás habitual es innecesariamente holgada.

Qué hacen realmente los envoltorios​

Los dos envoltorios difieren en los tres puntos indicados arriba:

compute_forward_boundscompute_backward_bounds
circuitotal como se daremove_measure(circuit).inverse()
función de normatime_evolved_norm_forward, respecto al observabletime_evolved_norm_backward, respecto al estado inicial
cono de luzLightCone.initialize_from_pauli, sembrado desde el observableLightCone.initialize_from_measurements, sembrado desde cada qubit activo
backwardsFalseTrue

La cota hacia adelante pregunta «¿Cuánto puede este error cambiar el observable que voy a medir?» y la responde con la norma espectral de un conmutador. La cota hacia atrás pregunta «¿Cuánto puede este error cambiar el estado del que partí?» y responde con una norma nuclear, tomando ese estado como el estado de todo ceros en cada qubit activo. Son preguntas distintas, y cada una es la adecuada para los errores de su propia mitad del circuito.

El caso del circuito espejo​

Un circuito espejo aplica una unitaria y luego la deshace: U†UU^\dagger U. Aparecen siempre que el resultado ideal debe conocerse exactamente (en general, ecos de Loschmidt, benchmarking aleatorizado y experimentos de verificación).

Los circuitos espejo tienen una propiedad que la cota hacia atrás genérica no puede aprovechar. Como la segunda mitad es la inversa de la primera, evolucionar un error hacia atrás hasta el estado inicial recorre las mismas compuertas que evolucionarlo hacia adelante hasta el observable. Las dos preguntas coinciden, así que la cota más afinada de estilo hacia adelante también es válida para la dirección hacia atrás.

# 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

Ensambla la cota personalizada​

La receta conserva lo que compute_backward_bounds hace con el circuito (quitar las mediciones, invertirlo e iterarlo con backwards=True) y sustituye las otras dos piezas: la función de norma hacia adelante, y un cono de luz sembrado desde el observable con LightCone.initialize_from_pauli, exactamente como compute_forward_bounds siembra el suyo. Cada sustitución es una línea.

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

Cuánto se gana​

Comparando con la cota hacia atrás estándar sobre el mismo circuito, resumida del mismo modo que en la guía de configuración: la cota media y su desviación estándar, seguidas de la proporción de términos acotados por 0, con una cota calculada intermedia, y que se quedan en el 2.0 trivial.

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 cota hacia atrás estándar se sitúa cerca del valor trivial de 2.0, que casi no aporta información. En un circuito espejo, todo error está «cerca del estado inicial» en el sentido que mide esa cota, así que no puede discriminar entre términos. La cota personalizada es varias veces más ajustada y acota por 0 muchos de los términos.

Para comprobar la implementación, se pueden comparar las dos box por box. Para cada box, la cota hacia atrás personalizada resulta idéntica a la cota hacia adelante de esa misma box, salvo error de redondeo. Ambas acotan el mismo error en el mismo punto del circuito, y en un circuito espejo las compuertas posteriores a ese punto se cancelan hasta ser la inversa de las anteriores, de modo que los dos cálculos evalúan la misma magnitud desde extremos opuestos:

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: Esta igualdad es una propiedad de la estructura espejo, no de la técnica. En un circuito que no es autoinverso, la misma sustitución sigue ejecutándose pero ya no calcula una cota válida para la dirección hacia atrás: responde la pregunta hacia adelante sobre una mitad del circuito donde esa pregunta no se aplica. Úsala solo cuando la segunda mitad de tu circuito invierta la primera.

Aliméntala al resto del flujo de trabajo​

El resultado es un diccionario Bounds normal, así que todo lo que sigue permanece sin cambios.

En un circuito espejo tampoco hay motivo para calcular ninguna de las cotas para todo el circuito. Las cotas hacia adelante solo hacen falta para los boxes de U†U^\dagger, contando desde el final, y las cotas hacia atrás solo para los boxes de UU, contando desde el inicio. Por tanto, max_num_boxes limita cada una a la mitad de los boxes ruidosos, y merge_bounds une las dos mitades.

Así es como se usan las cotas en la práctica, y significa que la comparación box por box anterior ya no está disponible: necesita ambas cotas para cada box. Es una comprobación de la implementación para esta guía, no un paso del flujo de trabajo.

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 las cotas hacia atrás estándar, el sobrecoste de muestreo es mucho mayor. merge_bounds usa las cotas hacia atrás hasta un punto de cambio en el circuito y las cotas hacia adelante a partir de ahí, eligiendo el punto que minimiza el sesgo total. Con cada cota calculada para una mitad, ese punto cae en medio del circuito, así que los boxes de UU quedan cubiertos solo por cotas hacia atrás. La cota hacia atrás estándar está cerca del 2.0 trivial ahí, lo que dispara el sobrecoste. La personalizada es tan ajustada como lo habría sido la cota hacia adelante, así que el sobrecoste coincide con el de las cotas hacia adelante completas.

Eso plantea la pregunta obvia de para qué molestarse, cuando las cotas hacia adelante completas por sí solas dan el mismo resultado. La respuesta es el coste. Una cota hacia adelante para un box temprano del circuito evoluciona su error a través del resto de UU y luego de todo U†U^\dagger, que es la evolución más larga y más ancha del circuito. El operador evolucionado puede crecer exponencialmente con el número de compuertas no Clifford por las que pasa, y calcular la norma espectral del conmutador se vuelve exponencialmente más costoso a medida que el cono de luz se ensancha. En el enfoque mitad y mitad, ningún error se evoluciona a través de más de la mitad del circuito.

Por tanto, el ahorro crece con la profundidad del circuito. Más importante aún, las cotas hacia adelante completas dejan de ser calculables mucho antes que las de la mitad. Una vez que las evoluciones de los primeros boxes superan evolution_max_terms o eigval_max_qubits, sus cotas se truncan o recurren a la desigualdad triangular, y finalmente cuesta demasiado calcularlas. Dondequiera que esté ese punto, el enfoque mitad y mitad solo necesita que caiga en algún lugar de la primera mitad del circuito. Las cotas hacia adelante no tienen que llegar más allá del medio, y en un circuito espejo, las cotas hacia atrás para los boxes de UU evolucionan sus errores hasta el inicio a través de tanto circuito como las cotas hacia adelante para los boxes correspondientes de U†U^\dagger hasta el final, de modo que se agotan a la misma profundidad.

Nota: ¿Por qué calcular cotas hacia atrás para los boxes de UU, cuando las cotas hacia adelante de los boxes de U†U^\dagger parece que deberían tener los mismos números? Porque los boxes no son imágenes especulares unos de otros. Cada box inyecta su ruido en un lado de sus compuertas, y las capas inversas de U†U^\dagger son boxes distintos con sus propios modelos de ruido, así que el box que refleja un box de UU ve sus errores en otro punto del circuito. Por tanto, sus cotas solo coinciden en parte, y las cotas de los boxes de UU tienen que calcularse para esos boxes.

Escribir otras cotas personalizadas​

Las mismas tres sustituciones cubren otros casos:

  • Una norma distinta. time_evolved_norm_forward acepta comm_norm_order; pasar cualquier valor distinto de 2 omite el solucionador de autovalores y calcula esa norma directamente.

  • Un cono de luz distinto. LightCone.initialize_from_pauli siembra un cono de luz desde cualquier Pauli, y el constructor de LightCone recibe los qubits que seguir y las operaciones que lo siembran, de modo que una cota puede calcularse respecto a cualquier operador que puedas expresar de esa manera.

  • Una función de norma totalmente distinta. compute_bounds solo requiere un invocable: que reciba un Pauli y RotationGates y devuelva un CommutatorBounds. Cualquier cosa que cumpla esa firma funciona, incluida una cota de forma cerrada específica de tu familia de circuitos.

Sea lo que sea lo que sustituyas, debes demostrar que el resultado es una cota superior genuina. Subestimar el efecto de un término de error no produce un fallo visible. Produce un valor esperado silenciosamente erróneo, porque el término se habrá eliminado del modelo de ruido mitigado.