Retropropagazione dell'operatore (OBP) per la stima dei valori attesi
Stima della durata: 4 minuti su un processore Heron r3 (NOTA: si tratta solo di una stima). (La durata potrebbe variare.)
Risultati di apprendimento
Dopo aver seguito questo tutorial, gli utenti dovrebbero aver compreso:
- Come utilizzare
qiskit-addon-obpper ridurre la profondità del circuito quantistico a costo di un maggior numero di esecuzioni del circuito - Come utilizzare
qiskit-addon-utilsper costruire gli hamiltoniani XYZ e i relativi circuiti di evoluzione temporale
Prerequisiti
Consigliamo agli utenti di acquisire familiarità con i seguenti argomenti prima di seguire questo tutorial:
- Utilizzo della primitiva Estimator per calcolare i valori attesi di una grandezza osservabile
Sfondo
La retropropagazione degli operatori è una tecnica che consiste nell'assorbire le operazioni dalla fine di un circuito quantistico nell'osservabile misurato, riducendo in genere la profondità del circuito a scapito dell'introduzione di termini aggiuntivi nell'osservabile. L'obiettivo è quello di applicare il backpropagation a una porzione del circuito il più ampia possibile, senza che la grandezza dell'osservabile diventi eccessiva. Nell'add-on OBP Qiskit è disponibile un'implementazione basata su Qiskit. Per ulteriori informazioni, consultare la documentazione corrispondente.
Si consideri un circuito di esempio in cui si deve misurare un osservabile , dove sono i vettori di Pauli e sono i coefficienti. Indichiamo il circuito come un unico " " unitario, che può essere suddiviso logicamente in " ", come illustrato nella figura sottostante.
La retropropagazione dell'operatore incorpora l' e unitaria nell'osservabile, facendola evolvere come . In altre parole, parte del calcolo viene eseguita in modo classico 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à inferiore, il cui operatore unitario è .
L' e unitario è rappresentato come un insieme di sezioni . Esistono diversi modi per definire una sezione. Ad esempio, nel circuito sopra riportato, ogni strato di gate " " e ogni strato di gate " " può essere considerato come una singola sezione. La retropropagazione comporta, in linea di massima, il calcolo dell' . Ogni sezione può essere rappresentata come , dove è un operatore di Pauli a -qubit e è uno scalare. È facile verificare che
Nell'esempio sopra riportato, se , allora per calcolare il valore atteso è necessario eseguire due circuiti quantistici, anziché uno solo. Pertanto, la retropropagazione potrebbe aumentare il numero di termini nell'osservabile, determinando un maggior numero di esecuzioni del circuito. Un modo per consentire una retropropagazione più profonda nel circuito, evitando al contempo che l'operatore diventi troppo grande, consiste nel troncare i termini con coefficienti piccoli, anziché aggiungerli all'operatore. Ad esempio, nell'esempio precedente, si potrebbe scegliere di troncare il termine che coinvolge , a condizione che sia sufficientemente piccolo. Il troncamento dei termini può comportare una riduzione del numero di circuiti quantistici da eseguire, ma ciò comporta un errore nel calcolo del valore atteso finale, proporzionale all'ampiezza dei coefficienti dei termini troncati.
Requisiti
Prima di iniziare questa esercitazione, assicuratevi di aver installato quanto segue:
- Qiskit SDK v2.0 o versioni successive, con supporto alla visualizzazione
- Qiskit Runtime v0.22 o versioni successive (
pip install qiskit-ibm-runtime) - Componente aggiuntivo OBP Qiskit 0.3 o versioni successive (
pip install qiskit-addon-obp) - Qiskit addon utils 0.3 o versioni successive (
pip install qiskit-addon-utils)
Configura
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, EstimatorOptionsEsempio di simulatore su piccola scala
Questo tutorial implementa un modello Qiskit per la simulazione delle dinamiche quantistiche di una catena di spin di Heisenberg utilizzando l 'add-on OBP per Qiskit. Si noti che in un simulatore privo di rumore, il valore atteso ottenuto con e senza retropropagazione sarà lo stesso.
Fase 1: mappare gli input classici su un problema quantistico
Mappare l'evoluzione temporale di un modello quantistico di Heisenberg in un esperimento quantistico
Per prima cosa, useremo la generate_xyz_hamiltonian funzione di qiskit-addon-utils per generare un hamiltoniano di tipo Heisenberg su un dato grafo di connettività. Questo grafico può essere sia un grafico a barre ( rustworkx.PyGraph ) che un grafico a linee ( CouplingMap ). Di seguito utilizzeremo una catena CouplingMap 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")Output:
Successivamente, generiamo un operatore di Pauli che modella un hamiltoniano di Heisenberg XYZ:
dove è il grafico della mappa di accoppiamento. Per questo tutorial abbiamo impostato in modo che rimandi a e in modo che rimandi a .
# 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)Output:
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 partire dall'operatore del qubit, possiamo generare un circuito quantistico che ne modella l'evoluzione temporale. Abbiamo utilizzato generate_time_evolution_circuit la scomposizione 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)Output:
Fase 2: Ottimizzazione del problema per l'esecuzione su hardware quantistico
Creare sezioni di circuito per la retropropagazione
La backpropagate funzione esegue la retropropagazione di intere sezioni del circuito alla volta. Pertanto, la scelta della suddivisione in strati può influire sulle prestazioni della retropropagazione per un determinato problema. In questa sede raggrupperemo i gate dello stesso tipo in sezioni utilizzando la slice_by_depth funzione.
Per un approfondimento sul circuit slicing, dai un'occhiata a questa guida pratica del qiskit-addon-utils pacchetto.
slices = slice_by_depth(circuit, max_slice_depth=1)
print(f"Separated the circuit into {len(slices)} slices.")Output:
Separated the circuit into 18 slices.
Limitare la crescita dell'operatore durante la retropropagazione
Durante la retropropagazione, il numero di termini nell'operatore tenderà generalmente ad avvicinarsi rapidamente a , dove è il numero di sezioni. Quando due termini dell'operatore non commutano a livello di qubit, occorrono circuiti distinti per ottenere i valori attesi a essi corrispondenti. Ad esempio, se abbiamo un osservabile a due qubit , allora, dato che , è sufficiente effettuare una misurazione su una singola base per calcolare i valori attesi di questi due termini. Tuttavia, l'operatore è anticommutativo rispetto agli altri due termini, quindi occorre una misurazione di base separata per calcolare il valore atteso dell'operatore . In altre parole, occorrono due circuiti anziché uno solo per calcolare l'operatore . Man mano che il numero di termini nell'operatore aumenta, è possibile che aumenti anche il numero di esecuzioni del circuito richieste.
La dimensione dell'operatore può essere delimitata specificando il kwarg operator_budget della funzione backpropagate , che accetta un'istanza OperatorBudget istanza.
Per controllare la quantità di risorse aggiuntive (numero di iterazioni del circuito e, di conseguenza, il tempo richiesto dalla QPU) assegnate, limitiamo il numero massimo di gruppi di Pauli commutativi a livello di qubit che l'osservabile sottoposto a retropropagazione può avere. Qui specifichiamo che la retropropagazione deve interrompersi quando il numero di gruppi di Pauli commutativi a livello di qubit presenti nell'operatore supera gli otto.
op_budget = OperatorBudget(max_qwc_groups=8)Retropropagare le sezioni dal circuito
Per prima cosa specifichiamo che l'osservabile è , essendo il numero di qubit. Si retropropagheranno le fette del circuito di evoluzione temporale fino a quando i termini dell'osservabile non potranno più essere combinati in gruppi di Pauli commutativi di otto o meno qubit.
observable = SparsePauliOp.from_sparse_list(
[("Z", [i], 1 / num_qubits) for i in range(num_qubits)],
num_qubits=num_qubits,
)
observableOutput:
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])
Come potete vedere qui sotto, abbiamo eseguito la retropropagazione su sei sezioni e i termini sono stati raggruppati in sei gruppi anziché in otto. Ciò implica che la retropropagazione di un'ulteriore sezione farebbe sì che il numero dei gruppi di Pauli superasse gli otto. Possiamo verificare che sia così esaminando i metadati restituiti. Si noti inoltre che in questa parte la trasformazione del circuito è esatta. In altre parole, nessun termine della nuova grandezza osservabile è stato troncato. Il circuito con retropropagazione e l'operatore con retropropagazione forniscono 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 "
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)Output:
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 eseguito su un simulatore, non ricorreremo al troncamento. Questo perché, in assenza di rumore, il circuito con e senza retropropagazione porta allo stesso risultato, mentre il troncamento peggiora il risultato a causa dell'approssimazione aggiuntiva.
Trasformare i circuiti nel set di porte di base
Ora trasponiamo sia il circuito originale che quello ottenuto tramite backpropagation nel gate di base del backend. Non è necessario eseguire la transpilazione sul backend vero e proprio, dato che per l'istanza di piccole dimensioni utilizzeremo un simulatore.
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=133
)
print(backend)Output:
<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)Passaggio 3: eseguire utilizzando Qiskit primitives
Per prima cosa, creiamo due blocchi unificati primitivi (PUB) corrispondenti al circuito originale e al circuito ottenuto tramite retropropagazione. Quindi eseguiamo le simulazioni su un Estimator ideale per ottenere i valori attesi.
pubs = [(isa_circuit, observable), (isa_bp_circuit, bp_obs)]rng = np.random.default_rng()
estimator = StatevectorEstimator(seed=rng)
job = estimator.run(pubs)Fase 4: Elaborazione finale e restituzione del risultato nel formato classico desiderato
Ora calcoliamo i valori attesi dei circuiti originali e di quelli sottoposti a retropropagazione.
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)Output:
Text(0, 0.5, '$M_Z$')
Come previsto, i due valori attesi coincidono. Poiché stiamo utilizzando un simulatore di vettori di stato privo di rumore, la retropropagazione costituisce una trasformazione esatta della coppia circuito-osservabile; pertanto, il flusso di lavoro originale e quello ottenuto tramite retropropagazione devono produrre lo stesso valore dell' e. Il vantaggio della retropropagazione diventa evidente solo su hardware soggetto a rumore, dove il circuito retropropagato, essendo più breve, accumula meno errori, come illustrato nell'esempio di hardware su larga scala riportato di seguito.
Esempio di hardware su larga scala
Quando si progetta un esperimento, è utile partire da un circuito semplice per facilitare la creazione di rappresentazioni grafiche e simulazioni. Ora esaminiamo la retropropagazione 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 atteso ideale a questa scala non può essere calcolato con un metodo di forza bruta, pertanto utilizziamo una rete tensoriale e otteniamo che il valore atteso ideale è pari a .
Oltre alla retropropagazione, in questo esempio su larga scala introduciamo anche la retropropagazione con troncamento. L'ideale sarebbe eseguire la retropropagazione il più possibile per ridurre la profondità del circuito effettivo. Tuttavia, ciò comporta spesso la presenza di un gran numero di termini non commutativi nell'osservabile aggiornato, aumentando il sovraccarico quantistico. Pertanto, possiamo eliminare i termini osservabili con coefficienti piccoli ricorrendo a una tecnica chiamata troncamento. Sebbene il troncamento consenta una maggiore propagazione riducendo il numero di termini nell'osservabile aggiornato, introduce anche una certa approssimazione. È quindi necessario limitare il troncamento entro certi limiti, in modo che l'errore di approssimazione non annulli la riduzione del rumore ottenuta grazie a una retropropagazione più profonda.
Per limitare l'entità del troncamento, assegniamo un margine di errore a ciascuna sezione, oltre al margine di errore complessivo per l'intero circuito sottoposto a retropropagazione, utilizzando la setup_budget funzione. Ciò garantisce che il troncamento sia controllato sia per ogni singola sezione che per l'intero circuito. Consulta anche questa guida per scoprire altri modi di ripartire 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: "
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()Output:
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}")Output:
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)Output:
Text(0, 0.5, '$M_Z$')
Passi successivi
Se questo lavoro ti è sembrato interessante, potrebbero interessarti anche i seguenti contenuti:
- Compilazione quantistica approssimativa per circuiti di evoluzione temporale
- Formule multiprodotto per ridurre l'errore di Trotter
pauli-prop, un pacchetto ottimizzato con Rust per la propagazione di Pauli, con tutorial dedicati all'OBP, alla stima classica del valore atteso e alla simulazione con rumore