Retropropagación de operadores (OBP) para la estimación de valores esperados
Estimación de uso: 4 minutos en un procesador Heron r3 (NOTA: Esta es solo una estimación. Tu tiempo de ejecución podría variar.)
Resultados de aprendizaje
Después de completar este tutorial, los usuarios deberán comprender:
- Cómo usar
qiskit-addon-obppara reducir la profundidad del circuito cuántico a costa de un mayor número de ejecuciones de circuitos - Cómo usar
qiskit-addon-utilspara construir hamiltonianos XYZ y sus circuitos de evolución temporal
Prerrequisitos
Sugerimos que los usuarios estén familiarizados con los siguientes temas antes de realizar este tutorial:
- Usar la primitiva Estimator para calcular valores esperados de un observable
Antecedentes
La retropropagación de operadores es una técnica que consiste en absorber operaciones desde el final de un circuito cuántico en el observable medido, reduciendo generalmente la profundidad del circuito a costa de términos adicionales en el observable. El objetivo es retropropagar la mayor parte posible del circuito sin permitir que el observable crezca demasiado. Una implementación basada en Qiskit está disponible en el complemento OBP de Qiskit. Consulta la documentación correspondiente para más información.
Considera un circuito de ejemplo para el cual se debe medir un observable , donde son operadores de Pauli y son coeficientes. Denotemos el circuito como un unitario único que puede particionarse lógicamente en como se muestra en la figura a continuación.

La retropropagación de operadores absorbe el unitario en el observable al evolucionarlo como . En otras palabras, parte del cálculo se realiza clásicamente mediante la evolución del observable de a . El problema original ahora puede reformularse como la medición del observable para el nuevo circuito de menor profundidad cuyo unitario es .
El unitario se representa como un número de segmentos . Existen múltiples formas de definir un segmento. Por ejemplo, en el circuito de ejemplo anterior, cada capa de puertas y cada capa de puertas puede considerarse un segmento individual. La retropropagación implica el cálculo clásico de . Cada segmento puede representarse como , donde es un operador de Pauli de -qubits y es un escalar. Es fácil verificar que
En el ejemplo anterior, si , entonces necesitamos ejecutar dos circuitos cuánticos, en lugar de uno, para calcular el valor esperado. Por lo tanto, la retropropagación puede aumentar el número de términos en el observable, lo que conduce a un mayor número de ejecuciones de circuitos. Una forma de permitir una retropropagación más profunda en el circuito, al tiempo que se evita que el operador crezca demasiado, es truncar los términos con coeficientes pequeños, en lugar de agregarlos al operador. Truncar términos puede resultar en menos circuitos cuánticos por ejecutar, pero hacerlo introduce cierto error en el cálculo final del valor esperado proporcional a la magnitud de los coeficientes de los términos truncados.
Requisitos
Antes de comenzar este tutorial, asegúrate de tener instalado lo siguiente:
- Qiskit SDK v2.0 o posterior, con soporte de visualización
- Qiskit Runtime v0.22 o posterior (
pip install qiskit-ibm-runtime) - Complemento OBP de Qiskit 0.3 o posterior (
pip install qiskit-addon-obp) - Utilidades del complemento de Qiskit 0.3 o posterior (
pip install qiskit-addon-utils)
Configuración
# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-addon-obp qiskit-addon-utils qiskit-ibm-runtime rustworkx
import numpy as np
import matplotlib.pyplot as plt
from qiskit.primitives import StatevectorEstimator
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit.quantum_info import SparsePauliOp
from qiskit.transpiler import CouplingMap
from qiskit.synthesis import LieTrotter
from qiskit_addon_utils.problem_generators import generate_xyz_hamiltonian
from qiskit_addon_utils.problem_generators import (
generate_time_evolution_circuit,
)
from qiskit_addon_utils.slicing import slice_by_depth, combine_slices
from qiskit_addon_obp.utils.simplify import OperatorBudget
from qiskit_addon_obp import backpropagate
from qiskit_addon_obp.utils.truncating import setup_budget
from rustworkx.visualization import graphviz_draw
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime import EstimatorV2, EstimatorOptions
Ejemplo a pequeña escala con simulador
Este tutorial implementa un patrón de Qiskit para simular la dinámica cuántica de una cadena de espines de Heisenberg utilizando el complemento OBP de Qiskit. Ten en cuenta que en un simulador sin ruido, el valor esperado obtenido con y sin retropropagación será el mismo.
Paso 1: Mapear las entradas clásicas a un problema cuántico
Mapear la evolución temporal de un modelo cuántico de Heisenberg a un experimento cuántico
Primero, usaremos la función generate_xyz_hamiltonian de qiskit-addon-utils para generar un hamiltoniano similar al de Heisenberg en un grafo de conectividad dado. Este grafo puede ser un rustworkx.PyGraph o un CouplingMap. A continuación, usaremos un CouplingMap de cadena lineal de 10 qubits.
num_qubits = 10
layout = [(i - 1, i) for i in range(1, num_qubits)]
# Instantiate a CouplingMap object
coupling_map = CouplingMap(layout)
graphviz_draw(coupling_map.graph, method="circo")
A continuación, generamos un operador de Pauli que modela un hamiltoniano XYZ de Heisenberg:
donde es el grafo del mapa de acoplamiento. En este tutorial, hemos usado iguales a , respectivamente, y iguales a , respectivamente.
# Get a qubit operator describing the Heisenberg XYZ model
hamiltonian = generate_xyz_hamiltonian(
coupling_map,
coupling_constants=(np.pi / 8, np.pi / 4, np.pi / 2),
ext_magnetic_field=(np.pi / 3, np.pi / 6, np.pi / 9),
)
print(hamiltonian)
SparsePauliOp(['IIIIIIIXXI', 'IIIIIIIYYI', 'IIIIIIIZZI', 'IIIIIXXIII', 'IIIIIYYIII', 'IIIIIZZIII', 'IIIXXIIIII', 'IIIYYIIIII', 'IIIZZIIIII', 'IXXIIIIIII', 'IYYIIIIIII', 'IZZIIIIIII', 'IIIIIIIIXX', 'IIIIIIIIYY', 'IIIIIIIIZZ', 'IIIIIIXXII', 'IIIIIIYYII', 'IIIIIIZZII', 'IIIIXXIIII', 'IIIIYYIIII', 'IIIIZZIIII', 'IIXXIIIIII', 'IIYYIIIIII', 'IIZZIIIIII', 'XXIIIIIIII', 'YYIIIIIIII', 'ZZIIIIIIII', 'IIIIIIIIIX', 'IIIIIIIIIY', 'IIIIIIIIIZ', 'IIIIIIIIXI', 'IIIIIIIIYI', 'IIIIIIIIZI', 'IIIIIIIXII', 'IIIIIIIYII', 'IIIIIIIZII', 'IIIIIIXIII', 'IIIIIIYIII', 'IIIIIIZIII', 'IIIIIXIIII', 'IIIIIYIIII', 'IIIIIZIIII', 'IIIIXIIIII', 'IIIIYIIIII', 'IIIIZIIIII', 'IIIXIIIIII', 'IIIYIIIIII', 'IIIZIIIIII', 'IIXIIIIIII', 'IIYIIIIIII', 'IIZIIIIIII', 'IXIIIIIIII', 'IYIIIIIIII', 'IZIIIIIIII', 'XIIIIIIIII', 'YIIIIIIIII', 'ZIIIIIIIII'],
coeffs=[0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j,
0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j,
1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j,
0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j,
0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j,
1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j,
0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j, 1.04719755+0.j,
0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j,
0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j,
1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j,
0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j,
0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j,
1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j,
0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j,
0.34906585+0.j])
A partir del operador de qubits, podemos generar un circuito cuántico que modele su evolución temporal. Hemos usado generate_time_evolution_circuit con descomposición de Lie Trotter para construir el circuito de evolución temporal.
circuit = generate_time_evolution_circuit(
hamiltonian,
time=0.2,
synthesis=LieTrotter(reps=2),
)
circuit.draw("mpl", style="iqp", fold=-1)

Paso 2: Optimizar el problema para la ejecución en hardware cuántico
Crear segmentos del circuito para retropropagar
La función backpropagate retropropaga segmentos completos del circuito a la vez. Por tanto, la elección de la segmentación puede tener un impacto en el rendimiento de la retropropagación para un problema dado. Aquí, agruparemos las puertas del mismo tipo en segmentos utilizando la función slice_by_depth.
Para una discusión más detallada sobre la segmentación de circuitos, consulta esta guía práctica del paquete qiskit-addon-utils.
slices = slice_by_depth(circuit, max_slice_depth=1)
print(f"Separated the circuit into {len(slices)} slices.")
Separated the circuit into 18 slices.
Restringir cuánto puede crecer el operador durante la retropropagación
Durante la retropropagación, el número de términos en el operador generalmente se acercará rápidamente a , donde es el número de qubits. Cuando dos términos en el operador no conmutan qubit a qubit, necesitamos circuitos separados para obtener los valores esperados correspondientes a ellos. Por ejemplo, si tenemos un observable de 2 qubits , entonces dado que , una medición en una sola base es suficiente para calcular los valores esperados de estos dos términos. Sin embargo, anticonmuta con los otros dos términos. Por lo tanto, necesitamos una medición de base separada para calcular el valor esperado de . En otras palabras, necesitamos dos circuitos, en lugar de uno, para calcular . A medida que aumenta el número de términos en el operador, existe la posibilidad de que el número requerido de ejecuciones de circuitos también aumente.
El tamaño del operador puede limitarse especificando el argumento operator_budget de la función backpropagate, que acepta una instancia de OperatorBudget.
Para controlar la cantidad de recursos adicionales (tiempo) asignados, restringimos el número máximo de grupos de Pauli conmutativos qubit a qubit que el observable retropropagado puede tener. Aquí especificamos que la retropropagación debe detenerse cuando el número de grupos de Pauli conmutativos qubit a qubit en el operador supere 8.
op_budget = OperatorBudget(max_qwc_groups=8)
Retropropagar segmentos del circuito
Primero especificamos el observable como , donde es el número de qubits. Retropropagaremos segmentos del circuito de evolución temporal hasta que los términos en el observable ya no puedan combinarse en ocho o menos grupos de Pauli conmutativos qubit a qubit.
observable = SparsePauliOp.from_sparse_list(
[("Z", [i], 1 / num_qubits) for i in range(num_qubits)],
num_qubits=num_qubits,
)
observable
SparsePauliOp(['IIIIIIIIIZ', 'IIIIIIIIZI', 'IIIIIIIZII', 'IIIIIIZIII', 'IIIIIZIIII', 'IIIIZIIIII', 'IIIZIIIIII', 'IIZIIIIIII', 'IZIIIIIIII', 'ZIIIIIIIII'],
coeffs=[0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j,
0.1+0.j, 0.1+0.j])
A continuación verás que retropropagamos seis segmentos, y los términos se combinaron en seis grupos, no en ocho. Esto implica que retropropagar un segmento más haría que el número de grupos de Pauli superara ocho. Podemos verificar que este es el caso inspeccionando los metadatos devueltos. Observa también que en esta parte la transformación del circuito es exacta. Es decir, no se truncaron términos del nuevo observable . El circuito retropropagado y el operador retropropagado producen el mismo resultado que el circuito y el operador originales.
# Backpropagate slices onto the observable
bp_obs, remaining_slices, metadata = backpropagate(
observable, slices, operator_budget=op_budget
)
# Recombine the slices remaining after backpropagation
bp_circuit = combine_slices(remaining_slices)
print(f"Backpropagated {metadata.num_backpropagated_slices} slices.")
print(
f"New observable has {len(bp_obs.paulis)} terms, which can be combined into {len(bp_obs.group_commuting(qubit_wise=True))} groups."
)
print(
f"Note that backpropagating one more slice would result in {metadata.backpropagation_history[-1].num_paulis[0]} terms "
f"across {metadata.backpropagation_history[-1].num_qwc_groups} groups."
)
print("The remaining circuit after backpropagation looks as follows:")
bp_circuit.draw("mpl", fold=-1, scale=0.6)
Backpropagated 6 slices.
New observable has 60 terms, which can be combined into 6 groups.
Note that backpropagating one more slice would result in 114 terms across 12 groups.
The remaining circuit after backpropagation looks as follows:
Para el ejemplo a pequeña escala con simulador, no usaremos truncación. Esto se debe a que, en ausencia de ruido, el circuito con y sin retropropagación produce el mismo resultado, y la truncación empeora el resultado por la aproximación adicional que introduce.
Transpilar los circuitos al conjunto de puertas base
Ahora transpilamos tanto el circuito original como el retropropagado al conjunto de puertas base del backend. No necesitamos transpilar en el backend real, ya que vamos a ejecutar en un simulador para la instancia pequeña.
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=133
)
print(backend)
<IBMBackend('ibm_kingston')>
pm_basis = generate_preset_pass_manager(
optimization_level=3, basis_gates=backend.configuration().basis_gates
)
isa_circuit = pm_basis.run(circuit)
isa_bp_circuit = pm_basis.run(bp_circuit)
Paso 3: Ejecutar utilizando primitivas de Qiskit
Primero, creamos dos Primitive Unified Blocs (PUBs) correspondientes al circuito original y al circuito retropropagado. Luego ejecutamos los PUBs en un Estimator ideal para obtener los valores esperados.
pubs = [(isa_circuit, observable), (isa_bp_circuit, bp_obs)]
rng = np.random.default_rng()
estimator = StatevectorEstimator(seed=rng)
job = estimator.run(pubs)
Paso 4: Posprocesar y devolver el resultado al formato clásico deseado
Ahora obtenemos los valores esperados de los circuitos original y retropropagado.
primitive_result = job.result()
circuit_expval = primitive_result[0].data.evs.item()
bp_circuit_expval = primitive_result[1].data.evs.item()
methods = [
"No backpropagation",
"Backpropagation",
]
values = [circuit_expval, bp_circuit_expval]
ax = plt.gca()
plt.bar(methods, values, color="#a56eff", width=0.4, edgecolor="#8a3ffc")
ax.set_ylim([0.6, 0.92])
ax.set_ylabel(r"$M_Z$", fontsize=12)
Text(0, 0.5, '$M_Z$')
Como era de esperar, los dos valores esperados coinciden. Dado que ejecutamos en un simulador de vector de estado sin ruido, la retropropagación es una transformación exacta del par circuito-observable, por lo que los flujos de trabajo original y retropropagado deben producir el mismo valor de . El beneficio de la retropropagación solo se vuelve aparente en hardware ruidoso, donde el circuito retropropagado más corto acumula menos error, como se ilustra en el ejemplo de hardware a gran escala a continuación.
Ejemplo de hardware a gran escala
Al desarrollar un experimento, es útil comenzar con un circuito pequeño para facilitar las visualizaciones y simulaciones. Ahora exploramos la retropropagación de operadores para un hamiltoniano de Heisenberg de 50 qubits con el mismo conjunto de valores para los parámetros y y el mismo observable , pero con cuatro pasos de Trotter. El valor esperado ideal a esta escala no puede calcularse por fuerza bruta, por lo que usamos una red tensorial y obtenemos el valor esperado ideal .
Junto con la retropropagación, en este ejemplo a gran escala también introducimos la retropropagación con truncación. Idealmente queremos retropropagar tanto como sea posible para reducir la profundidad del circuito efectivo. Sin embargo, esto a menudo lleva a un gran número de términos no conmutativos en el observable actualizado, lo que aumenta la sobrecarga cuántica. Por tanto, podemos eliminar términos del observable con coeficientes pequeños mediante una práctica llamada truncación. Si bien la truncación permite mayor propagación al reducir el número de términos en el observable actualizado, también introduce cierta aproximación. Por ello, es necesario restringir la truncación dentro de ciertos límites para que el error de aproximación no supere la reducción de ruido obtenida por una retropropagación más profunda.
Para restringir la cantidad de truncación, asignamos un presupuesto de error por segmento y también un presupuesto de error total sobre todo el circuito retropropagado mediante la función setup_budget. Esto garantiza que la truncación esté controlada tanto para cada segmento como para todo el circuito. Consulta también esta guía para otras formas de asignar el presupuesto.
num_qubits = 50
layout = [(i - 1, i) for i in range(1, num_qubits)]
# Instantiate a CouplingMap object
coupling_map = CouplingMap(layout)
hamiltonian = generate_xyz_hamiltonian(
coupling_map,
coupling_constants=(np.pi / 8, np.pi / 4, np.pi / 2),
ext_magnetic_field=(np.pi / 3, np.pi / 6, np.pi / 9),
)
# Generate a time evolution circuit for the Hamiltonian
circuit = generate_time_evolution_circuit(
hamiltonian,
time=0.2,
synthesis=LieTrotter(reps=4),
)
# Define the observable to measure
observable = SparsePauliOp.from_sparse_list(
[("Z", [i], 1 / num_qubits) for i in range(num_qubits)],
num_qubits,
)
slices = slice_by_depth(circuit, max_slice_depth=1)
# Define the maximum number of qwc groups allowed in the backpropagated observable, and the truncation error budget
op_budget = OperatorBudget(max_qwc_groups=15)
truncation_error_budget = setup_budget(
max_error_total=0.03, max_error_per_slice=0.005
)
# First backpropagation without truncation
bp_obs, remaining_slices, metadata = backpropagate(
observable, slices, operator_budget=op_budget
)
bp_circuit = combine_slices(remaining_slices)
# Now backpropagate with truncation, using the same operator budget and the defined truncation error budget
bp_obs_trunc, remaining_slices_trunc, metadata = backpropagate(
observable,
slices,
operator_budget=op_budget,
truncation_error_budget=truncation_error_budget,
)
bp_circuit_trunc = combine_slices(
remaining_slices_trunc, include_barriers=False
)
# Now we transpile the original circuit and the two backpropagated circuits, and apply the layout to the corresponding observables
pm = generate_preset_pass_manager(optimization_level=3, backend=backend)
isa_circuit = pm.run(circuit)
isa_bp_circuit = pm.run(bp_circuit)
isa_bp_circuit_trunc = pm.run(bp_circuit_trunc)
isa_observable = observable.apply_layout(isa_circuit.layout)
isa_bp_observable = bp_obs.apply_layout(isa_bp_circuit.layout)
isa_bp_observable_trunc = bp_obs_trunc.apply_layout(
isa_bp_circuit_trunc.layout
)
# Compare the 2-qubit depth of each transpiled circuit to see how much depth backpropagation saved
print(
f"2-qubit depth without backpropagation: {isa_circuit.depth(lambda x: x.operation.num_qubits == 2)}"
)
print(
f"2-qubit depth with backpropagation: {isa_bp_circuit.depth(lambda x: x.operation.num_qubits == 2)}"
)
print(
f"2-qubit depth with backpropagation and truncation: {isa_bp_circuit_trunc.depth(lambda x: x.operation.num_qubits == 2)}"
)
pubs = [
(isa_circuit, isa_observable),
(isa_bp_circuit, isa_bp_observable),
(isa_bp_circuit_trunc, isa_bp_observable_trunc),
]
# Now we instantiate the Estimator primitive for the hardware with ZNE and measurement error mitigation
# and compute the three circuits and observables
options = EstimatorOptions()
options.default_precision = 0.01
options.resilience_level = 2
options.resilience.zne.noise_factors = [1, 1.2, 1.4]
options.resilience.zne.extrapolator = ["linear"]
estimator = EstimatorV2(mode=backend, options=options)
estimator.options.environment.job_tags = ["TUT_OBP"]
job = estimator.run(pubs)
# Retrieve the results and the standard deviations
result_no_bp = job.result()[0].data.evs.item()
result_bp = job.result()[1].data.evs.item()
result_bp_trunc = job.result()[2].data.evs.item()
std_no_bp = job.result()[0].data.stds.item()
std_bp = job.result()[1].data.stds.item()
std_bp_trunc = job.result()[2].data.stds.item()
2-qubit depth without backpropagation: 24
2-qubit depth with backpropagation: 20
2-qubit depth with backpropagation and truncation: 18
print(f"Expectation value without backpropagation: {result_no_bp}")
print(f"Backpropagated expectation value: {result_bp}")
print(f"Backpropagated expectation value with truncation: {result_bp_trunc}")
Expectation value without backpropagation: 0.9543907942381811
Backpropagated expectation value: 0.9445337385406468
Backpropagated expectation value with truncation: 0.934050286970965
# Plot the results
methods = [
"No backpropagation",
"Backpropagation",
"Backpropagation w/ truncation",
]
values = [result_no_bp, result_bp, result_bp_trunc]
error_bars = [std_no_bp, std_bp, std_bp_trunc]
ax = plt.gca()
plt.bar(methods, values, color="#a56eff", width=0.4, edgecolor="#8a3ffc")
plt.errorbar(methods, values, yerr=error_bars, fmt="o", color="r", capsize=5)
plt.axhline(0.89)
ax.set_ylim([0.8, 0.98])
plt.text(0.25, 0.895, "Exact result")
ax.set_ylabel(r"$M_Z$", fontsize=12)
Text(0, 0.5, '$M_Z$')
Próximos pasos
Si encontraste este trabajo interesante, puede que te interese el siguiente material:
- Compilación cuántica aproximada para circuitos de evolución temporal
- Fórmulas multiproducto para reducir el error de Trotter
pauli-prop, un paquete acelerado con Rust para la propagación de Pauli, con tutoriales que cubren OBP, estimación clásica de valores esperados y simulación ruidosa