Retropropagação de operador (OBP) para estimativa de valores esperados
Estimativa de uso: 4 minutos em um processador Heron r3 (NOTA: Esta é apenas uma estimativa. Seu tempo de execução pode variar.)
Resultados de aprendizagem
Ao concluir este tutorial, os usuários deverão compreender:
- Como usar o
qiskit-addon-obppara reduzir a profundidade do circuito quântico ao custo de um maior número de execuções de circuito - Como usar o
qiskit-addon-utilspara construir Hamiltonianos XYZ e seus circuitos de evolução temporal
Pré-requisitos
Sugerimos que os usuários estejam familiarizados com os seguintes tópicos antes de seguir este tutorial:
- Usar a primitiva Estimator para calcular valores esperados de um observável
Contexto
A retropropagação de operador é uma técnica que envolve absorver operações do final de um circuito quântico no observável medido, geralmente reduzindo a profundidade do circuito ao custo de termos adicionais no observável. O objetivo é retropropagar o máximo possível do circuito sem permitir que o observável cresça demais. Uma implementação baseada em Qiskit está disponível no addon OBP do Qiskit. Leia a documentação correspondente para mais informações.
Considere um circuito de exemplo para o qual um observável deve ser medido, onde são Paulis e são coeficientes. Vamos denotar o circuito como um único unitário , que pode ser logicamente particionado em como mostrado na figura abaixo.

A retropropagação de operador absorve o unitário no observável evoluindo-o como . Em outras palavras, parte da computação é realizada classicamente através da evolução do observável de para . O problema original agora pode ser reformulado como medir o observável para o novo circuito de menor profundidade cujo unitário é .
O unitário é representado como um número de fatias . Existem múltiplas maneiras de definir uma fatia. Por exemplo, no circuito de exemplo acima, cada camada de e cada camada de portas pode ser considerada como uma fatia individual. A retropropagação envolve o cálculo de classicamente. Cada fatia pode ser representada como , onde é um Pauli de -qubits e é um escalar. É fácil verificar que
No exemplo acima, se , então precisamos executar dois circuitos quânticos, em vez de um, para calcular o valor esperado. Portanto, a retropropagação pode aumentar o número de termos no observável, levando a um maior número de execuções de circuito. Uma maneira de permitir uma retropropagação mais profunda no circuito, enquanto evita que o operador cresça demais, é truncar termos com coeficientes pequenos, em vez de adicioná-los ao operador. Por exemplo, no exemplo acima, pode-se escolher truncar o termo envolvendo desde que seja suficientemente pequeno. Truncar termos pode resultar em menos circuitos quânticos para executar, mas fazer isso resulta em algum erro no cálculo final do valor esperado proporcional à magnitude dos coeficientes dos termos truncados.
Requisitos
Antes de iniciar este tutorial, certifique-se de ter o seguinte instalado:
- Qiskit SDK v2.0 ou posterior, com suporte a visualização
- Qiskit Runtime v0.22 ou posterior (
pip install qiskit-ibm-runtime) - OBP Qiskit addon 0.3 ou posterior (
pip install qiskit-addon-obp) - Qiskit addon utils 0.3 ou posterior (
pip install qiskit-addon-utils)
Configuração
# 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
Exemplo em simulador de pequena escala
Este tutorial implementa um padrão Qiskit para simular a dinâmica quântica de uma cadeia de spins de Heisenberg usando o addon OBP do Qiskit. Observe que em um simulador sem ruído, o valor esperado obtido com e sem retropropagação será o mesmo.
Etapa 1: Mapear entradas clássicas para um problema quântico
Mapear a evolução temporal de um modelo quântico de Heisenberg para um experimento quântico
Primeiro, usaremos a função generate_xyz_hamiltonian do qiskit-addon-utils para gerar um Hamiltoniano tipo Heisenberg em um determinado grafo de conectividade. Este grafo pode ser um rustworkx.PyGraph ou um CouplingMap. A seguir, usaremos um CouplingMap de cadeia linear 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")
Em seguida, geramos um operador de Pauli modelando um Hamiltoniano XYZ de Heisenberg:
onde é o grafo do mapa de acoplamento. Para este tutorial, utilizamos como , respectivamente, e como , 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 do operador de qubit, podemos gerar um circuito quântico que modela sua evolução temporal. Utilizamos generate_time_evolution_circuit com decomposição de Lie Trotter para construir o circuito de evolução temporal.
circuit = generate_time_evolution_circuit(
hamiltonian,
time=0.2,
synthesis=LieTrotter(reps=2),
)
circuit.draw("mpl", style="iqp", fold=-1)

Etapa 2: Otimizar problema para execução em hardware quântico
Criar fatias de circuito para retropropagar
A função backpropagate retropropaga fatias inteiras de circuito por vez. Portanto, a escolha do fatiamento pode ter um impacto em quão bem a retropropagação funciona para um determinado problema. Aqui, agruparemos portas do mesmo tipo em fatias usando a função slice_by_depth.
Para uma discussão mais detalhada sobre fatiamento de circuito, confira este guia de instruções do pacote 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 o quanto o operador pode crescer durante a retropropagação
Durante a retropropagação, o número de termos no operador geralmente se aproximará rapidamente de , onde é o número de fatias. Quando dois termos no operador não comutam qubit a qubit, precisamos de circuitos separados para obter os valores esperados correspondentes a eles. Por exemplo, se temos um observável de dois qubits , então como , a medição em uma única base é suficiente para calcular os valores esperados para esses dois termos. No entanto, anticomuta com os outros dois termos, então precisamos de uma medição de base separada para calcular o valor esperado de . Em outras palavras, precisamos de dois circuitos em vez de um para calcular . À medida que o número de termos no operador aumenta, há a possibilidade de que o número necessário de execuções de circuito também aumente.
O tamanho do operador pode ser limitado especificando o argumento operator_budget da função backpropagate, que aceita uma instância OperatorBudget.
Para controlar a quantidade de recursos extras (número de execuções de circuito e, portanto, o tempo de QPU necessário) alocados, restringimos o número máximo de grupos de Pauli comutantes qubit a qubit que o observável retropropagado pode ter. Aqui especificamos que a retropropagação deve parar quando o número de grupos de Pauli comutantes qubit a qubit no operador crescer além de oito.
op_budget = OperatorBudget(max_qwc_groups=8)
Retropropagar fatias do circuito
Primeiro especificamos o observável como , sendo o número de qubits. Retropropagaremos fatias do circuito de evolução temporal até que os termos no observável não possam mais ser combinados em oito ou menos grupos de Pauli comutantes 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])
Abaixo você verá que retropropagamos seis fatias, e os termos foram combinados em seis e não oito grupos. Isso implica que retropropagar mais uma fatia faria com que o número de grupos de Pauli excedesse oito. Podemos verificar que este é o caso inspecionando os metadados retornados. Observe também que nesta porção a transformação do circuito é exata. Isto é, nenhum termo do novo observável foi truncado. O circuito retropropagado e o operador retropropagado fornecem exatamente o mesmo resultado que o circuito e operador originais.
# 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 "
f"{len(bp_obs.group_commuting(qubit_wise=True))} groups."
)
print(
f"Note that backpropagating one more slice would result in "
f"{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 o exemplo de pequena escala em um simulador, não usaremos truncamento. Isso ocorre porque, na ausência de ruído, o circuito com e sem retropropagação leva ao mesmo resultado, e o truncamento piora o resultado devido à aproximação adicionada.
Transpilar os circuitos para o conjunto de portas base
Agora transpilamos tanto o circuito original quanto o circuito retropropagado para a porta base do backend. Não precisamos transpilar no backend real, pois vamos executar em um simulador para a instância pequena.
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)
Etapa 3: Executar usando primitivas Qiskit
Primeiro, criamos dois Primitive Unified Blocs (PUBs) correspondentes ao circuito original e ao circuito retropropagado. Em seguida, executamos os PUBs em um Estimator ideal para obter os valores esperados.
pubs = [(isa_circuit, observable), (isa_bp_circuit, bp_obs)]
rng = np.random.default_rng()
estimator = StatevectorEstimator(seed=rng)
job = estimator.run(pubs)
Etapa 4: Pós-processar e retornar resultado no formato clássico desejado
Agora obtemos os valores esperados dos circuitos original e 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 esperado, os dois valores esperados concordam. Como estamos executando em um simulador de vetor de estado sem ruído, a retropropagação é uma transformação exata do par circuito-observável, portanto os fluxos de trabalho original e retropropagado devem produzir o mesmo valor de . O benefício da retropropagação só se torna aparente em hardware com ruído, onde o circuito retropropagado mais curto acumula menos erro, conforme ilustrado no exemplo de hardware em grande escala abaixo.
Exemplo de hardware em grande escala
Ao desenvolver um experimento, é útil começar com um circuito pequeno para facilitar visualizações e simulações. Agora analisamos a retropropagação de operador para um Hamiltoniano de Heisenberg de 50 qubits com o mesmo conjunto de valores para os parâmetros e e o mesmo observável , mas para quatro passos de Trotter. O valor esperado ideal nessa escala não pode ser calculado por força bruta, então usamos uma rede de tensores e obtemos o valor esperado ideal como .
Juntamente com a retropropagação, neste exemplo em grande escala, também introduzimos a retropropagação com truncamento. Idealmente, queremos retropropagar o máximo possível para reduzir a profundidade do circuito efetivo. No entanto, isso frequentemente leva a um grande número de termos não comutativos no observável atualizado, aumentando o overhead quântico. Portanto, podemos eliminar termos do observável com coeficientes pequenos usando uma prática chamada truncamento. Embora o truncamento permita mais propagação ao reduzir o número de termos no observável atualizado, também introduz alguma aproximação. Portanto, é necessário restringir o truncamento dentro de certos limites para que o erro de aproximação não supere a redução de ruído obtida com uma retropropagação mais profunda.
Para restringir a quantidade de truncamento, alocamos um orçamento de erro para cada fatia, bem como o orçamento de erro total ao longo de todo o circuito retropropagado usando a função setup_budget. Isso garante que o truncamento seja controlado para cada fatia e para o circuito inteiro. Consulte também este guia para outras formas de alocar o orçamento.
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: "
f"{isa_circuit.depth(lambda x: x.operation.num_qubits == 2)}"
)
print(
f"2-qubit depth with backpropagation: "
f"{isa_bp_circuit.depth(lambda x: x.operation.num_qubits == 2)}"
)
print(
f"2-qubit depth with backpropagation and truncation: "
f"{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 passos
Se você achou este trabalho interessante, pode se interessar pelo seguinte material:
- Approximate quantum compilation for time evolution circuits
- Multi-product formulas to reduce Trotter error
pauli-prop, a Rust-accelerated package for Pauli propagation, with tutorials covering OBP, classical expectation-value estimation, and noisy simulation