Backpropagazione degli operatori (OBP) per la stima dei valori di aspettazione
Stima di utilizzo: 4 minuti su un processore Heron r3 (NOTA: questa è solo una stima. Il tempo di esecuzione effettivo potrebbe variare.)
Obiettivi di apprendimento​
Dopo aver completato questo tutorial, gli utenti dovrebbero capire:
- Come usare
qiskit-addon-obpper ridurre la profondità del circuito quantistico al costo di un numero maggiore di esecuzioni del circuito - Come usare
qiskit-addon-utilsper costruire Hamiltoniani XYZ e i loro circuiti di evoluzione temporale
Prerequisiti​
Suggeriamo agli utenti di avere familiarità con i seguenti argomenti prima di procedere con questo tutorial:
- Utilizzo della primitiva Estimator per calcolare i valori di aspettazione di un osservabile
Background​
La backpropagazione degli operatori è una tecnica che consiste nell'assorbire le operazioni dalla fine di un circuito quantistico nell'osservabile misurato, riducendo generalmente la profondità del circuito al costo di termini aggiuntivi nell'osservabile. L'obiettivo è retropropagare quanto più possibile del circuito senza permettere che l'osservabile cresca eccessivamente. Un'implementazione basata su Qiskit è disponibile nell'addon OBP di Qiskit. Leggi la documentazione corrispondente per ulteriori informazioni.
Considera un circuito di esempio per cui si deve misurare un osservabile , dove sono Pauli e sono coefficienti. Denotiamo il circuito come un singolo unitario , che può essere logicamente suddiviso in come mostrato nella figura seguente.

La backpropagazione degli operatori assorbe l'unitario nell'osservabile evolvendolo come . In altre parole, parte del calcolo viene eseguita classicamente attraverso l'evoluzione dell'osservabile da a . Il problema originale può ora essere riformulato come la misurazione dell'osservabile per il nuovo circuito a profondità ridotta il cui unitario è .
L'unitario è rappresentato come un numero di sezioni . Esistono diversi modi per definire una sezione. Ad esempio, nel circuito di esempio sopra, ogni strato di e ogni strato di gate possono essere considerati come singole sezioni. La backpropagazione implica il calcolo di classicamente. Ogni sezione può essere rappresentata come , dove è un Pauli a qubit e è uno scalare. È facile verificare che
Nell'esempio sopra, se , allora dobbiamo eseguire due circuiti quantistici invece di uno per calcolare il valore di aspettazione. Pertanto, la backpropagazione potrebbe aumentare il numero di termini nell'osservabile, portando a un numero più elevato di esecuzioni del circuito. Un modo per consentire una backpropagazione più profonda nel circuito, evitando che l'operatore cresca eccessivamente, è troncare i termini con coefficienti piccoli, piuttosto che aggiungerli all'operatore. Ad esempio, nell'esempio sopra, si potrebbe scegliere di troncare il termine che coinvolge purché sia sufficientemente piccolo. Troncare i termini può ridurre il numero di circuiti quantistici da eseguire, ma ciò introduce un certo errore nel calcolo finale del valore di aspettazione proporzionale alla magnitudine dei coefficienti dei termini troncati.
Requisiti​
Prima di iniziare questo tutorial, assicurati di avere installato quanto segue:
- Qiskit SDK v2.0 o successivo, con supporto per la visualizzazione
- Qiskit Runtime v0.22 o successivo (
pip install qiskit-ibm-runtime) - OBP Qiskit addon 0.3 o successivo (
pip install qiskit-addon-obp) - Qiskit addon utils 0.3 o successivo (
pip install qiskit-addon-utils)
Setup​
# 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
Esempio su simulatore su piccola scala​
Questo tutorial implementa un pattern Qiskit per simulare la dinamica quantistica di una catena di spin di Heisenberg usando l'addon OBP di Qiskit. Nota che in un simulatore privo di rumore, il valore di aspettazione ottenuto con e senza backpropagazione sarà lo stesso.
Passo 1: Mappare gli input classici in un problema quantistico​
Mappare l'evoluzione temporale di un modello quantistico di Heisenberg a un esperimento quantistico​
Per prima cosa, useremo la funzione generate_xyz_hamiltonian di qiskit-addon-utils per generare un Hamiltoniano simile a quello di Heisenberg su un dato grafo di connettività . Questo grafo può essere sia un rustworkx.PyGraph sia una CouplingMap. Di seguito useremo una CouplingMap a catena lineare di 10 qubit.
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")
Successivamente, generiamo un operatore Pauli che modella un Hamiltoniano XYZ di Heisenberg:
dove è il grafo della mappa di accoppiamento. Per questo tutorial, abbiamo usato pari a , rispettivamente, e pari a , rispettivamente.
# 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])
Dall'operatore qubit, possiamo generare un circuito quantistico che modella la sua evoluzione temporale. Abbiamo usato generate_time_evolution_circuit con la decomposizione di Lie-Trotter per costruire il circuito di evoluzione temporale.
circuit = generate_time_evolution_circuit(
hamiltonian,
time=0.2,
synthesis=LieTrotter(reps=2),
)
circuit.draw("mpl", style="iqp", fold=-1)

Passo 2: Ottimizzare il problema per l'esecuzione su hardware quantistico​
Creare sezioni del circuito da retropropagare​
La funzione backpropagate retropropaga intere sezioni del circuito alla volta. Pertanto, la scelta della suddivisione in sezioni può avere un impatto su quanto bene la backpropagazione funziona per un dato problema. Qui raggrupperemo i gate dello stesso tipo in sezioni usando la funzione slice_by_depth.
Per una discussione più dettagliata sulla suddivisione in sezioni del circuito, consulta questa guida pratica del pacchetto 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.
Vincolare la crescita dell'operatore durante la backpropagazione​
Durante la backpropagazione, il numero di termini nell'operatore tenderà generalmente ad avvicinarsi rapidamente a , dove è il numero di sezioni. Quando due termini nell'operatore non commutano qubit per qubit, abbiamo bisogno di circuiti separati per ottenere i valori di aspettazione corrispondenti. Ad esempio, se abbiamo un osservabile a due qubit , allora poiché , la misurazione in una singola base è sufficiente per calcolare i valori di aspettazione per questi due termini. Tuttavia, anti-commuta con gli altri due termini, quindi abbiamo bisogno di una misurazione in una base separata per calcolare il valore di aspettazione di . In altre parole, abbiamo bisogno di due circuiti invece di uno per calcolare . All'aumentare del numero di termini nell'operatore, aumenta anche la possibilità che aumenti il numero richiesto di esecuzioni del circuito.
La dimensione dell'operatore può essere limitata specificando il kwarg operator_budget della funzione backpropagate, che accetta un'istanza di OperatorBudget.
Per controllare la quantità di risorse aggiuntive (numero di esecuzioni del circuito, e quindi il tempo QPU richiesto) allocate, limitiamo il numero massimo di gruppi di Pauli che commutano qubit per qubit che l'osservabile retropropagato può avere. Qui specifichiamo che la backpropagazione deve fermarsi quando il numero di gruppi di Pauli che commutano qubit per qubit nell'operatore supera otto.
op_budget = OperatorBudget(max_qwc_groups=8)
Retropropagare le sezioni dal circuito​
Prima specifichiamo l'osservabile come , dove è il numero di qubit. Retropropagheremo le sezioni dal circuito di evoluzione temporale finché i termini nell'osservabile non possono più essere combinati in otto o meno gruppi di Pauli che commutano qubit per 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])
Di seguito vedrai che abbiamo retropropagato sei sezioni, e i termini sono stati combinati in sei e non otto gruppi. Ciò implica che retropropagare un'ulteriore sezione causerebbe il superamento di otto da parte del numero di gruppi di Pauli. Possiamo verificare che questo sia il caso ispezionando i metadati restituiti. Nota anche che in questa parte la trasformazione del circuito è esatta. Ovvero, nessun termine del nuovo osservabile è stato troncato. Il circuito retropropagato e l'operatore retropropagato producono lo stesso risultato esatto del circuito e dell'operatore originali.
# 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:
Per l'esempio su piccola scala su un simulatore, non useremo la troncatura. Questo perché in assenza di rumore, il circuito con e senza backpropagazione porta allo stesso risultato, e la troncatura peggiora il risultato a causa dell'approssimazione aggiunta.
Trasporre i circuiti nel set di gate di base​
Ora trasponiamo sia il circuito originale sia il circuito retropropagato nel gate di base del backend. Non è necessario trasporre sul backend effettivo poiché eseguiremo su un simulatore per l'istanza piccola.
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)
Passo 3: Eseguire usando le primitive Qiskit​
Prima creiamo due Primitive Unified Blocs (PUB) corrispondenti al circuito originale e al circuito retropropagato. Poi eseguiamo i PUB su un Estimator ideale per ottenere i valori di aspettazione.
pubs = [(isa_circuit, observable), (isa_bp_circuit, bp_obs)]
rng = np.random.default_rng()
estimator = StatevectorEstimator(seed=rng)
job = estimator.run(pubs)
Passo 4: Post-elaborare e restituire il risultato nel formato classico desiderato​
Ora otteniamo i valori di aspettazione dei circuiti originale e retropropagato.
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$')
Come previsto, i due valori di aspettazione concordano. Poiché stiamo eseguendo su un simulatore statevector privo di rumore, la backpropagazione è una trasformazione esatta della coppia circuito-osservabile, quindi i flussi di lavoro originale e retropropagato devono produrre lo stesso valore di . Il vantaggio della backpropagazione diventa evidente solo su hardware rumoroso, dove il circuito retropropagato più corto accumula meno errore, come illustrato nell'esempio su hardware su larga scala di seguito.
Esempio su hardware su larga scala​
Quando si sviluppa un esperimento, è utile iniziare con un circuito piccolo per rendere più semplici le visualizzazioni e le simulazioni. Ora esaminiamo la backpropagazione degli operatori per un Hamiltoniano di Heisenberg a 50 qubit con lo stesso insieme di valori per i parametri e e lo stesso osservabile , ma per quattro passi di Trotter. Il valore di aspettazione ideale a questa scala non può essere calcolato con un metodo diretto, quindi usiamo una rete tensoriale e otteniamo il valore di aspettazione ideale pari a .
Insieme alla backpropagazione, in questo esempio su larga scala, introduciamo anche la backpropagazione con troncatura. Idealmente vogliamo retropropagare il più possibile per ridurre la profondità del circuito effettivo. Tuttavia, ciò spesso porta a un gran numero di termini non commutanti nell'osservabile aggiornato, aumentando l'overhead quantistico. Pertanto, possiamo eliminare i termini dell'osservabile con coefficienti piccoli usando una pratica chiamata troncatura. Mentre la troncatura consente una maggiore propagazione riducendo il numero di termini nell'osservabile aggiornato, introduce anche una certa approssimazione. Pertanto, è necessario limitare la troncatura entro certi limiti affinché l'errore di approssimazione non superi la riduzione del rumore ottenuta da una backpropagazione più profonda.
Per limitare la quantità di troncatura, allochiamo un budget di errore per ogni sezione e il budget di errore totale sull'intero circuito retropropagato usando la funzione setup_budget. Ciò garantisce che la troncatura sia controllata sia per ogni sezione sia per l'intero circuito. Consulta anche questa guida per altri modi di allocare il budget.
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$')
Prossimi passi​
Se hai trovato questo lavoro interessante, potresti essere interessato al seguente materiale:
- Compilazione quantistica approssimativa per circuiti di evoluzione temporale
- Formule multi-prodotto per ridurre l'errore di Trotter
pauli-prop, un pacchetto accelerato da Rust per la propagazione di Pauli, con tutorial che coprono OBP, stima classica dei valori di aspettazione e simulazione rumorosa