Skip to main content
IBM Quantum Platform

SqDRIFT algoritmo per la stima dello stato fondamentale

Scarica il quaderno “ Python ”

Scarica il notebook “ Python ” per seguire tu stesso questo tutorial. Il notebook scarica il file di input FCIDump se non è già presente.

Stima del tempo di esecuzione: 180 secondi su un processore Heron r3 (NOTA: si tratta solo di una stima. (La durata potrebbe variare.)


Risultati di apprendimento

  • Scopri come realizzare circuiti con profondità inferiore rispetto alla tecnica di Trotter
  • Esplora un flusso di lavoro completo per la stima dello stato fondamentale utilizzando l' qDRIFT e e l'SQD
  • Scopri come utilizzarlo in qiskit-fermions combinazione con altri componenti aggiuntivi di Qiskit per implementare un flusso di lavoro di questo tipo

Prerequisiti


Sfondo

SqDRIFT è una variante dello SKQD che sostituisce la necessità di scegliere un ansatz da cui campionare le stringhe di bit con un insieme di circuiti di evoluzione temporale costruiti direttamente a partire dall'Hamiltoniano di destinazione. Ciò si ottiene mediante il sottocampionamento di operatori di evoluzione temporale più piccoli a partire dall’Hamiltoniano, sulla base dei suoi coefficienti; tale metodo è noto come metodo di Trotterizzazione “ qDRIFT ”.

Entrambe le implementazioni utilizzano Qiskit Fermions per rappresentare l'Hamiltoniano molecolare e costruire circuiti per l'algoritmo " qDRIFT ". L'implementazione in Python utilizza passaggi di transpiler fermionici, mentre l'implementazione in C++ campiona esplicitamente i gruppi e li mappa agli operatori sui qubit.

Sia l'Hamiltoniano della forma:

H=∑i=1NcihiH = \sum_{i=1}^{N} c_i h_i

dove, senza perdita di generalità, si pone ci>0c_i > 0 e si richiede che il massimo autovalore di hih_i sia uguale, in valore assoluto, a 11. Qualsiasi prefattore con segno o complesso viene assorbito in hih_i, per cui i coefficienti cic_i sono pesi strettamente positivi mentre i hih_i indicano la direzione di ciascun termine. Qui NN indica il numero di termini (o, dopo il raggruppamento, il numero di gruppi) nell’Hamiltoniano; si tratta di una proprietà dell’Hamiltoniano ed è distinta dal numero di operatori campionati in un singolo circuito, indicato di seguito c nn.

L'algoritmo " qDRIFT " realizza quindi, per il tempo target tt, un operatore VkV_k, dove kk va da 1⋯K1 \cdots K e indica il circuito kthk_{th} SqDRIFT, definito come:

Vk=∏j=1ne−ihkjλt/nV_k = \prod_{j=1}^{n} e^{-i h_{k_j} \lambda t / n }

Qui nn indica il numero di operatori campionati per circuito e KK indica il numero di circuiti nell'insieme. Il prodotto viene calcolato sulle estrazioni nn, non su tutti i termini hamiltoniani NN; inoltre, poiché le estrazioni avvengono con sostituzione, lo stesso hih_i può comparire più di una volta in un singolo VkV_k.

La quantità:

λ=∑i=1Nci\lambda = \sum_{i=1}^{N} c_i

è la norma L1L_1 dei coefficienti, quindi ciascuno dei passi nn si evolve per la stessa durata λt/n\lambda t / n indipendentemente dal termine estratto. L'uniformità dell'angolo di passo è la caratteristica distintiva dell' qDRIFT: : un coefficiente influenza il risultato in base alla frequenza con cui il suo termine viene estratto, non in base all'ampiezza della rotazione di tale termine. Gli indici sono estratti dalla distribuzione:

P[ki]=ciλP[k_i] = \frac{c_i}{\lambda}

quindi la serie (k1,…,kn)(k_1, \ldots, k_n) è una sequenza casuale di indici di termini estratti da questa distribuzione. Poiché i valori di cic_i sono positivi e la loro somma è pari a λ\lambda, si tratta di una distribuzione di probabilità normalizzata, e l’aspettativa del canale risultante rispetto alle estrazioni casuali approssima l’evoluzione in base a HH, con un errore che diminuisce all’aumentare di nn. Si noti che l'errore di approssimazione dipende da λ\lambda piuttosto che dal numero di termini NN.

(L'articolo pubblicato su * SqDRIFT * indica il numero di termini come N\mathcal{N} e la lunghezza della sequenza come NN; qui utilizziamo NN e nn per distinguere chiaramente le due cose.)

Questo tutorial illustra come generare un insieme di tali circuiti randomizzati. Dopo aver creato questi circuiti, analogamente a come si crea un sottospazio di Krylov per diversi operatori, campioniamo stringhe di bit provenienti da più operatori di questo tipo con parametri temporali diversi. Ciò garantisce una maggiore sovrapposizione tra i vettori dello stato fondamentale e le stringhe di bit campionate.


Requisiti

Prima di iniziare questo tutorial, assicurati di aver installato

  • Un ambiente virtuale " Python " (>= 3.10 )
  • pip>=25.1
  • qiskit ~= 2.5
  • qiskit-fermions== 0.1.0 (Si noti che il nome è al plurale)
  • numpy
  • pyscf
  • qiskit-aer
  • qiskit-ibm-runtime
  • qiskit-addon-sqd

È possibile installare tutti i pacchetti necessari con:

pip install "qiskit~=2.5" "qiskit-fermions==0.1.0" qiskit-aer qiskit-ibm-runtime qiskit-addon-sqd pyscf numpy

Configura

# Third-party scientific computing
import numpy as np

# PySCF
from pyscf import tools, ao2mo, fci

# Qiskit core
from qiskit import transpile
from qiskit.primitives import BitArray

# Qiskit Aer
from qiskit_aer import AerSimulator

# IBM Quantum Compute Service
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2 as Sampler

# Qiskit Fermions
from qiskit_fermions.operators.library import FCIDump
from qiskit_fermions.operators import FermionOperator
from qiskit_fermions.operators.terms.filtering import filter_diagonal_terms
from qiskit_fermions.operators.terms.grouping import (
    group_terms_by_electronic_structure,
)
from qiskit_fermions.operators.terms.ordering import canonical_order
from qiskit_fermions.circuit import FermionicCircuit
from qiskit_fermions.circuit.library import Evolution
from qiskit_fermions.transpiler import FermionicPassManager
from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
from qiskit_fermions.transpiler.passes import QDriftTrotterization
from qiskit_fermions.circuit.library import InitializeModes

# Qiskit addon SQD
from qiskit_addon_sqd.fermion import (
    diagonalize_fermionic_hamiltonian,
    SCIResult,
)

Implementazione

Fase 1: Mappare gli input classici su un problema quantistico

I passaggi riportati di seguito relativi a “ Python ” utilizzano il simulatore Aer. Di seguito è riportato l'esempio relativo all'hardware.

Lettura e preparazione dell'FCIDump

Per questo tutorial, caricheremo l'Hamiltoniano della struttura elettronica dell'azoto ( N2 ). Esistono anche altri modi per creare operatori fermionici. Consultare la documentazione all'indirizzo qiskit_fermions.operators.library.

Informazioni su questo FCIDump. Il file N2_sto_3g descrive una molecola di azoto ( N2N_2 ) nella base minima STO-3G con una distanza interatomica di 1.09 A˚\AA, ovvero la lunghezza di legame di equilibrio sperimentale. L'intestazione dichiara NORB=10, NELEC=14, e MS2=0: 10 orbitali spaziali (quindi 20 orbitali di spin e 20 qubit secondo la rappresentazione di Jordan-Wigner), 14 elettroni in uno stato di singoletto di spin, ovvero sette elettroni α\alpha e sette β\beta. A tutti gli orbitali viene assegnata l'etichetta di simmetria 1, ovvero non viene sfruttata alcuna simmetria di gruppo puntuale. Trattandosi di un dump “ STO-3G ” a spazio pieno, nessun orbitale è congelato e lo spazio di correlazione è sufficientemente piccolo da consentire il calcolo classico di un’energia di riferimento FCI esatta a scopo di confronto, come illustrato nel blocco di codice seguente.

È possibile rigenerare un file equivalente con il comando PySCF:

from pyscf import gto, scf, tools

mol = gto.M(atom="N 0 0 0; N 0 0 1.09", basis="sto-3g", symmetry=False)
mf = scf.RHF(mol).run()
tools.fcidump.from_scf(mf, "N2_sto_3g")

Poiché gli integrali dipendono dagli orbitali SCF convergenti, un file rigenerato potrebbe differire da quello fornito in termini di fase o ordine degli orbitali; le energie totali non ne risentono.

Come procurarsi il file. Trova FCIDump in questo repository GitHub. Puoi eseguire il codice riportato di seguito per scaricarlo nella posizione indicata nel resto del tutorial.

Per prima cosa utilizziamo la funzione fornita cisolver da pyscf per ottenere l'energia di riferimento. Questa è la vera energia dello stato fondamentale della molecola su cui stiamo lavorando. A tal fine, dichiareremo innanzitutto norb e nelec, che rappresentano rispettivamente il numero di orbitali e il numero di elettroni. Quindi definiamo e h1e h2e, che sono rispettivamente gli integrali a un elettrone e a due elettroni. Tutti questi elementi verranno utilizzati in seguito anche per l'SQD.

import os
from urllib.request import urlopen

# The FCIDump is stored with this tutorial in the Qiskit documentation repository.
FCIDUMP_URL = "https://raw.githubusercontent.com/Qiskit/documentation/main/docs/tutorials/assets/sqdrift/fcidump_files/N2_sto_3g"
FCIDUMP_PATH = "fcidump_files/N2_sto_3g"

if not os.path.exists(FCIDUMP_PATH):
    os.makedirs(os.path.dirname(FCIDUMP_PATH), exist_ok=True)
    with urlopen(FCIDUMP_URL) as response:
        contents = response.read()
    with open(FCIDUMP_PATH, "wb") as f:
        f.write(contents)
    print(f"Downloaded FCIDump to {FCIDUMP_PATH}")
else:
    print(f"Using existing FCIDump at {FCIDUMP_PATH}")
name = "fcidump_files/N2_sto_3g"

fcidump = tools.fcidump.read(name)

# Extract metadata from the FCIDump header
norb = fcidump["NORB"]  # number of spatial orbitals
nelec = fcidump["NELEC"]  # total number of electrons
e_nuc = fcidump["ECORE"]  # nuclear repulsion / core energy
ms2 = fcidump["MS2"]  # 2S (spin)

num_elec_a = (nelec + ms2) // 2  # alpha electrons
num_elec_b = (nelec - ms2) // 2  # beta  electrons

# Reconstruct full 4-index ERIs from the FCIDump (stored in 8-fold symmetry)
h1e = fcidump["H1"]  # shape (norb, norb)
h2e = ao2mo.restore(  # shape (norb, norb, norb, norb)
    1, fcidump["H2"], norb
)

cisolver = fci.direct_spin1.FCI()
cisolver.max_cycle = 200
cisolver.conv_tol = 1e-12

e_fci, _ = cisolver.kernel(
    h1e,
    h2e,
    norb,
    (num_elec_a, num_elec_b),
    ecore=e_nuc,  # adds nuclear repulsion to the final energy
)

reference_energy = e_fci

print(f"Reference FCI Energy  = {reference_energy:.10f} Ha")

nuclear_repulsion_energy = fcidump["ECORE"]
print(f"Nuclear Repulsion Energy = {nuclear_repulsion_energy:.10f} Ha")
Reference FCI Energy  = -107.6481842917 Ha
Nuclear Repulsion Energy = 23.7887003074 Ha

Caricamento dell'Hamiltoniano

Una volta preparati i dati necessari, leggiamo l’Hamiltoniano dal file FCI in un formato compatibile con qiskit-fermions

fcidump = FCIDump.from_file(name)
hamiltonian = FermionOperator.from_fcidump(fcidump)
num_modes = 2 * fcidump.norb

Flussi di lavoro fermionici con qiskit-fermions

Per prima cosa mapperemo l'Hamiltoniano su un modello di circuito fermionico utilizzando qiskit-fermions, che fornisce passaggi di transpiler e porte specifiche per i circuiti fermionici. Questi verranno successivamente utilizzati prima che il transpiler tradizionale di Qiskit esegua le fasi previste da questo flusso di lavoro.

Raggruppamento per termini

Per garantire la riproducibilità dei risultati, utilizziamo innanzitutto canonical_order per ordinare i termini basandoci esclusivamente sulla loro struttura. L'ordine degli operatori nell'elenco canon è quindi fisso. Ciò garantisce la riproducibilità degli operatori creati, poiché il pass QDriftTrotterization che utilizzeremo negli esempi successivi estrae indici casuali per generare gli operatori qDRIFT.

In questa fase, sfruttiamo le numerose simmetrie presenti nell'Hamiltoniano della struttura elettronica raggruppando i termini correlati con coefficienti identici. Sebbene ciò modifichi la distribuzione dei coefficienti degli operatori da cui il protocollo “ qDRIFT ” preleva i campioni, ciò non influisce sulle sue garanzie di convergenza. È fondamentale sottolineare che il raggruppamento dei termini correlati per simmetria comporta una cancellazione vantaggiosa dei termini di Pauli e una profondità complessiva del circuito inferiore quando si evolve nel tempo uno stato sotto la loro azione.

qiskit-fermions fornisce la funzione group_terms_by_electronic_structure che esegue questo raggruppamento al posto nostro.

Si noti che la formula group_terms_by_electronic_structure presuppone che i termini siano in ordine normale.

Filtraggio dei termini diagonali

Eliminiamo i termini diagonali dall’Hamiltoniano utilizzato per generare i circuiti, in modo che nn qDRIFT gli slot di campionamento siano dedicati ai termini che spostano la popolazione tra le configurazioni. È preferibile eliminare tali termini dall’Hamiltoniano in questa fase, prima che il gate Evolution venga costruito nella fase successiva.

I termini in questione sono quelli diagonali nella base "occupazione-numero", ovvero i prodotti degli operatori numerici ai†aia^\dagger_i a_i. Rientrano in questa descrizione tre tipi di termini:

  • l' offset energetico costante, un prodotto di operatori a numero zero, la cui evoluzione temporale contribuisce solo con una fase globale;
  • gli operatori a numero singolo nin_i, la cui evoluzione temporale si riduce a rotazioni di un singolo qubit ZZ;
  • i prodotti di ordine superiore, come ad esempio ninjn_i n_j.

Di per sé, nessuna di queste mosse fa passare la popolazione da una configurazione occupazionale all’altra; esse agiscono solo sulle fasi delle configurazioni già presenti. Tuttavia, non sono inerti: quelle fasi relative contribuiscono all’interferenza generata dai termini di eccitazione più avanti nel circuito, quindi filtrarle modifica l’evoluzione effettivamente generata e può alterare la distribuzione di campionamento. Si tratta di un'approssimazione intenzionale nella fase di generazione del circuito, volta a concentrare il campionamento sui termini di eccitazione, piuttosto che di una fase che lasci inalterata la distribuzione campionata. A differenza del raggruppamento di simmetria sopra descritto, che lascia intatte le garanzie di convergenza di tipo “ qDRIFT ”, questo filtro modifica l’operatore oggetto dell’evoluzione. I circuiti, quindi, non approssimano più l’evoluzione nell’ambito dell’Hamiltoniano completo, e i limiti di errore di tipo “ qDRIFT ” si applicano all’operatore filtrato anziché a quello originale. In questo caso ciò è accettabile perché i circuiti rappresentano solo un metodo euristico di campionamento utilizzato per proporre configurazioni: nessun termine viene perso nella stima dell'energia stessa, poiché il filtro si applica solo all'Hamiltoniano utilizzato per costruire i circuiti, mentre la diagonalizzazione classica successiva utilizza l'Hamiltoniano completo, termini diagonali inclusi. L'accuratezza dell'SQD dipende da quel passo classico, che rimane variazionale nel sottospazio campionato indipendentemente dal modo in cui sono state proposte le configurazioni.

La funzione filter_diagonal_terms() rimuove tali termini da un operatore direttamente sul posto. Li identifica in base alla loro struttura ordinata normalmente — il multinseme delle modalità di creazione che corrisponde al multinseme delle modalità di annichilimento — quindi è valida solo su un operatore che sia già ordinato normalmente. Questa ipotesi non viene verificata durante l'esecuzione.

# Apply automatic grouping
canon = canonical_order(hamiltonian.normal_ordered().simplify(atol=1e-16))
exit_code = group_terms_by_electronic_structure(
    canon, num_modes, two_body_physicist_order=False
)
filter_diagonal_terms(canon)

print(len(canon.groups))
5060

Ora che abbiamo raggruppato i termini nell'Hamiltoniano, stabiliremo i seguenti parametri per generare l'insieme dei circuiti:

  • Il numero di circuiti da generare: num_circuits
  • La lunghezza di ciascun circuito in termini di gruppi di eccitazione: num_exc
  • Il fattore che determina i diversi tempi di evoluzione: times

Creazione di circuiti fermionici

Ora creeremo circuiti fermionici per ciascuno degli intervalli di tempo. Ogni circuito sarà costituito da un unico gate di evoluzione, con il tempo di evoluzione che abbiamo indicato in precedenza. L'operatore di evoluzione è l'hamiltoniano. Successivamente eseguiamo i passaggi del transpiler su questi circuiti per creare circuiti di tip qDRIFT.

Preparazione dell'Ansatz

Prepariamo lo stato di Hartree-Fock utilizzando la classe InitializeModes . Per l'azoto, il processo consiste semplicemente nell'applicare X gate ai primi num_elec_a qubit e poi ai qubit num_elec_b , entrambi pari a sette nel caso dell'azoto. Questo stato rappresenta i sette elettroni α\alpha e i sette elettroni β\beta dell’azoto.

# SqDRIFT parameters
times = [1.0, 10.0]  # Total evolution times used for the subspace creation
num_exc = 10  # Number of excitation groups per circuit
num_circuits = 200  # Number of circuits to generate


init_circuits = []

hf_gate = InitializeModes.from_hartree_fock(norb, (num_elec_a, num_elec_b))

for time in times:
    evo_gate = Evolution(num_modes, canon, time)
    circ = FermionicCircuit(num_modes)
    circ.append(hf_gate, circ.modes)
    circ.append(evo_gate, circ.modes)
    init_circuits.append(circ)

Fase 2: Ottimizzare il problema per l'esecuzione su hardware quantistico

Ora che disponiamo dei nostri circuiti, utilizzeremo innanzitutto i passaggi disponibili in qiskit-fermions per eseguire ottimizzazioni a livello fermionico, per poi traspilare il nostro circuito per il backend di nostra scelta. Trattandosi di un esperimento al simulatore, lo faremo innanzitutto per l’ AerSimulator.

Calcolo del peso per ciascun gruppo

In questa fase, eseguiamo il campionamento “ qDRIFT ” dei termini in modo stocastico, con probabilità proporzionali ai loro coefficienti nell’Hamiltoniano. Il passaggio del transpiler “ qDRIFT ” lo fa per noi. Ora siamo in grado di creare circuiti meno profondi che possono essere eseguiti sull'hardware in modo più efficiente nonostante la connettività limitata dei qubit, anche quando l'hamiltoniano contiene accoppiamenti a lungo raggio e termini superiori al quadrato. Dopo il raggruppamento per termini, effettua un campionamento degli operatori in base ai loro pesi. Per ciascun operatore hih_i, il peso WhiW_{h_i} è definito come segue:

Whi=∣ci∣/λW_{h_i} = |c_i| / \lambda

Poiché i termini sono stati raggruppati nella Fase 1, ogni " hih_i " qui rappresenta un intero gruppo: " cic_i " è il coefficiente assoluto medio dei termini del gruppo " ii ", e ogni termine del gruppo viene elaborato con il proprio coefficiente ridotto al solo segno.

Ottimizzazioni fermioniche e native per l'hardware

La funzione restituisce generate_preset_jw_pass_manager() un oggetto MultiStagePassManager che accetta un parametro e FermionicCircuit produce un circuito finale ottimizzato che possiamo transpilare per eseguirlo sul nostro hardware. Sostituiamo la sua fase di ottimizzazione predefinita con una che contenga FermionicPassManager il nostro passaggio QDriftTrotterization :

  • Il pass QDriftTrotterization utilizza internamente il calcolo del peso e il campionamento per generare i circuiti che useremo per il campionamento
  • Il passaggio RelabelModes è un’ulteriore fase di ottimizzazione che può essere utilizzata per permutare le modalità fermioniche al fine di ottimizzare la connettività tra i qubit e ridurre la profondità dei gate; per ulteriori informazioni, consultare la documentazione di riferimento dell’API

Le fasi restanti vengono eseguite MultiStagePassManager automaticamente e gestiscono l'intera mappatura da fermioni a qubit:

  • F2QLayout : Il gestore di passaggi predefinito applica il passaggio TrivialF2QLayout , che mappa in modo banale i bit fermionici nn i ai qubit nn i.
  • F2QSynth : un passaggio di transpilazione per mappare le istruzioni dei circuiti basati sui fermioni a quelle basate sui qubit.
qdrift = QDriftTrotterization(num_exc, rng=19)

pm = generate_preset_jw_pass_manager()
pm.optimization = FermionicPassManager([qdrift])

sqdrift_circuits = []
for circ in init_circuits:
    sqdrift_circuits += (pm.run(circ) for _ in range(num_circuits))

for circ in sqdrift_circuits:
    circ.measure_all()

print(len(sqdrift_circuits))
400

Ora che abbiamo completato le ottimizzazioni a livello fermionico, possiamo traspilare i circuiti per eseguirli sul simulatore.

simulator = AerSimulator()
shots = 100

transpiled_circuits = transpile(sqdrift_circuits, simulator)

Fase 3: Eseguire il comando utilizzando Qiskit primitives

Ora che abbiamo i nostri circuiti, possiamo eseguirli utilizzando Qiskit primitives su AerSimulator. Sommaremo tutti i conteggi provenienti dai diversi circuiti. Li convertiamo in vettori booleani prima di procedere alla post-elaborazione finale con SQD.

print(
    f"Executing {len(transpiled_circuits)} circuits with {shots} shots each..."
)

job = simulator.run(transpiled_circuits, shots=shots)
result = job.result()

all_counts = [result.get_counts(i) for i in range(len(transpiled_circuits))]

print(len(all_counts), "length before post processing")
Executing 400 circuits with 100 shots each...
400 length before post processing

Fase 4: Elaborazione finale e restituzione del risultato nel formato classico desiderato

Utilizzo delle stringhe di bit per SQD

Ora possiamo applicare lo schema di diagonalizzazione alle stringhe di bit selezionate per individuare l'autovalore più basso, che corrisponderà all'energia dello stato fondamentale della molecola. Creiamo una funzione di callback, dichiariamo le occupazioni iniziali e impostiamo i parametri prima di eseguire infine lo schema di diagonalizzazione. La funzione di callback viene utilizzata per visualizzare l'iterazione corrente e la stima dell'autovalore corrente ad ogni iterazione.

Infine, per ottenere la stima dello stato fondamentale, aggiungiamo il nuclear_repulsion_energy all’energia risultante.

Nota : la dimensione del sottospazio non è fissa da un'iterazione all'altra, nemmeno sul simulatore privo di rumore: ogni sottocampione genera un insieme diverso di configurazioni e la fase di recupero rimodella il pool tra un'iterazione e l'altra, pertanto la dimensione riportata varia da un sottocampione all'altro. Il campionamento silenzioso non determina di per sé la dimensione del sottospazio selezionato. L'esecuzione su hardware, tuttavia, tende a produrre sottospazi sistematicamente più grandi, poiché gli scatti rumorosi rompono la simmetria del numero di particelle e il recupero della configurazione li trasforma in vettori di base aggiuntivi. Per questo motivo, nella sezione dedicata all'hardware introdurremo anche un'ulteriore fase per la potatura delle stringhe di bit.

combined_counts = {}
for counts in all_counts:
    for bitstring, count in counts.items():
        combined_counts[bitstring] = combined_counts.get(bitstring, 0) + count

bit_array = BitArray.from_counts(combined_counts)
print(bit_array.num_shots)

print(f"  Alpha electrons: {num_elec_a}")
print(f"  Beta electrons: {num_elec_b}")
print(f"  Number of orbitals: {norb}")
print(f"  Number of spin orbitals (qubits): {2*norb}")
print(f"Integral shapes: h1e={h1e.shape}, h2e={h2e.shape}")

# SQD parameters
samples_per_batch = 300
num_batches = 3
max_iterations = 5

initial_occupancies = (
    np.array([1] * num_elec_a + [0] * (norb - num_elec_a)),  # alpha
    np.array([1] * num_elec_b + [0] * (norb - num_elec_b)),  # beta
)

result_history = []


def callback(results: list[SCIResult]):
    result_history.append(results)
    iteration = len(result_history)
    print(f"Iteration {iteration}")
    for i, result in enumerate(results):
        print(f"\tSubsample {i}")
        print(f"\t\tEnergy: {result.energy + nuclear_repulsion_energy}")
        print(
            f"\t\tSubspace dimension: {np.prod(result.sci_state.amplitudes.shape)}"
        )


# Run SQD with configuration recovery
print("\nRunning SQD with configuration recovery...")
result = diagonalize_fermionic_hamiltonian(
    h1e,
    h2e,
    bit_array,
    samples_per_batch=samples_per_batch,
    norb=norb,
    nelec=(num_elec_a, num_elec_b),
    num_batches=num_batches,
    energy_tol=1e-3,
    occupancies_tol=1e-3,
    max_iterations=max_iterations,
    initial_occupancies=initial_occupancies,
    seed=42,
    callback=callback,
)

computed_energy = result.energy + nuclear_repulsion_energy

print("FINAL SQD RESULTS")
print(f"Orbital occupancies (alpha): {result.orbital_occupancies[0]}")
print(f"Orbital occupancies (beta): {result.orbital_occupancies[1]}")


energy_error = abs(computed_energy - reference_energy)
print(f"Reference Energy: {reference_energy:.10f} Ha")
print(f"Computed Energy:  {computed_energy:.10f} Ha")
print(f"Error:            {energy_error:.10e} Ha")
40000
  Alpha electrons: 7
  Beta electrons: 7
  Number of orbitals: 10
  Number of spin orbitals (qubits): 20
Integral shapes: h1e=(10, 10), h2e=(10, 10, 10, 10)

Running SQD with configuration recovery...
Iteration 1
	Subsample 0
		Energy: -107.64767025226178
		Subspace dimension: 5538
	Subsample 1
		Energy: -107.64772799119115
		Subspace dimension: 5670
	Subsample 2
		Energy: -107.64765512281548
		Subspace dimension: 5767
Iteration 2
	Subsample 0
		Energy: -107.64795948524682
		Subspace dimension: 6080
	Subsample 1
		Energy: -107.64806617355072
		Subspace dimension: 6300
	Subsample 2
		Energy: -107.64802260640258
		Subspace dimension: 6308
FINAL SQD RESULTS
Orbital occupancies (alpha): [0.99999464 0.99999643 0.99584631 0.99332984 0.96684652 0.96686712
 0.99301927 0.0373282  0.0373266  0.00944508]
Orbital occupancies (beta): [0.99999462 0.99999643 0.9958261  0.99332349 0.96684268 0.96686737
 0.99302145 0.03733536 0.03733399 0.0094585 ]
Reference Energy: -107.6481842917 Ha
Computed Energy:  -107.6480661736 Ha
Error:            1.1811817564e-04 Ha

Esempio di hardware

In questo esempio vengono utilizzati 20 qubit (10 orbitali spaziali). Questa scelta è dettata da ragioni di praticità per un tutorial che dovrebbe essere eseguito rapidamente, non rappresenta un limite inderogabile per il metodo.

Il costo della fase classica non dipende direttamente dal numero di qubit. SQD diagonalizza l’Hamiltoniano proiettato sul sottospazio generato dalle configurazioni campionate, quindi ciò che determina il costo classico è la dimensione di quel sottospazio selezionato — determinata in questo caso da samples_per_batch, num_batches, e dal numero di configurazioni distinte effettivamente prodotte dai circuiti — insieme all’algebra lineare sparsa necessaria per applicare l’Hamiltoniano proiettato. Lo spazio CI completo cresce in modo combinatorio con il numero di orbitali e di elettroni, ma il sottospazio selezionato ne costituisce una piccola porzione regolabile, di cui controlliamo direttamente le dimensioni. Di conseguenza, il numero di qubit e la difficoltà classica possono essere variati in modo relativamente indipendente: uno spazio orbitale più ampio, campionato in un sottospazio di dimensioni modeste, può risultare meno oneroso rispetto a un sistema più piccolo diagonalizzato su uno spazio molto ampio.

In pratica, quindi, la dimensione massima del sistema dipende dalla dimensione del sottospazio necessaria per ottenere la precisione desiderata e dalla memoria e dai core a disposizione del risolutore di autovalori. Spazi orbitali più ampi richiedono in genere un sottospazio più ampio per raggiungere l'accuratezza chimica, ed è proprio questo che alla fine giustifica l'uso di risorse distribuite — si veda qiskit-addon-sqd-hpc per la scalabilità di questa fase. Anziché ipotizzare un valore limite fisso, l'approccio pratico consiste nell'osservare la dimensione del sottospazio riportata e la convergenza dell'energia nel corso delle iterazioni, aumentando la dimensione del sottospazio fino a quando l'energia smette di migliorare o si esaurisce la memoria disponibile.

Nota: a causa dell'errore di campionamento dovuto al rumore presente nell'hardware, il sottospazio creato per la diagonalizzazione nell'esecuzione su hardware risulterà più ampio rispetto a quello ottenuto utilizzando il simulatore. Sebbene aumenti la dimensione del sottospazio che vogliamo diagonalizzare, il flusso di lavoro ci fornisce comunque una risposta accurata grazie alla robustezza dell'SQD nei confronti del rumore.

Eliminazione delle stringhe superflue

A questo punto possiamo scegliere di eseguire un passaggio aggiuntivo. Una volta ottenute tutte le stringhe di bit derivanti dall’esecuzione dei circuiti, possiamo scegliere se filtrare le stringhe di bit non valide prima di eseguire l’SQD oppure procedere senza effettuare alcuna eliminazione. In genere, è preferibile non effettuare la potatura nelle esecuzioni hardware, poiché in questo modo le soluzioni con simmetria violata rimangono disponibili per il recupero della configurazione, che può ripararle trasformandole in configurazioni valide e quindi ampliare il sottospazio, anziché scartarle del tutto.

Poiché l'azoto può avere solo sette α\alpha e sette β\beta elettroni, qualsiasi stringa di bit che contenga più o meno di sette 1s nella prima e nella seconda metà dell'output può essere scartata. Definiamo una funzione che verifica se le stringhe di bit sono valide e, in caso contrario, le scarta. Una volta eliminate le stringhe di bit spurie, quelle rimanenti vengono inviate allo schema di diagonalizzazione. Utilizza il flag PRUNE riportato di seguito per passare da un comportamento all'altro.

Tieni presente che la potatura è solo una delle diverse opzioni che determinano il sottospazio finale, insieme al numero di circuiti, all’insieme dei tempi di evoluzione e al filtraggio dei termini diagonali. Il confronto tra un'esecuzione potata e una non potata è significativo solo se tutti gli altri fattori rimangono costanti; la scheda dedicata al C++ approfondisce questo aspetto, poiché tale linguaggio effettua una post-selezione anziché un recupero e presenta differenze anche in relazione a quegli altri parametri.

name = "fcidump_files/N2_sto_3g"

fcidump = tools.fcidump.read(name)

# Extract metadata from the FCIDump header
norb = fcidump["NORB"]  # number of spatial orbitals
nelec = fcidump["NELEC"]  # total number of electrons
e_nuc = fcidump["ECORE"]  # nuclear repulsion / core energy
ms2 = fcidump["MS2"]  # 2S (spin)

num_elec_a = (nelec + ms2) // 2  # alpha electrons
num_elec_b = (nelec - ms2) // 2  # beta  electrons

# Reconstruct full 4-index ERIs from the FCIDump (stored in 8-fold symmetry)
h1e = fcidump["H1"]  # shape (norb, norb)
h2e = ao2mo.restore(  # shape (norb, norb, norb, norb)
    1, fcidump["H2"], norb
)

cisolver = fci.direct_spin1.FCI()
cisolver.max_cycle = 200
cisolver.conv_tol = 1e-12

e_fci, _ = cisolver.kernel(
    h1e,
    h2e,
    norb,
    (num_elec_a, num_elec_b),
    ecore=e_nuc,  # adds nuclear repulsion to the final energy
)

reference_energy = e_fci

print(f"Reference FCI Energy  = {reference_energy:.10f} Ha")

nuclear_repulsion_energy = fcidump["ECORE"]
print(f"Nuclear Repulsion Energy = {nuclear_repulsion_energy:.10f} Ha")

fcidump = FCIDump.from_file(name)
hamiltonian = FermionOperator.from_fcidump(fcidump)
num_modes = 2 * fcidump.norb

# Apply automatic grouping
canon = canonical_order(hamiltonian.normal_ordered().simplify(atol=1e-16))
exit_code = group_terms_by_electronic_structure(
    canon, num_modes, two_body_physicist_order=False
)
filter_diagonal_terms(canon)

print(len(canon.groups))

# SqDRIFT parameters
times = [1.0, 10.0]  # Total evolution times used for the subspace creation
num_exc = 10  # Number of excitation groups per circuit
num_circuits = 200  # Number of circuits to generate

init_circuits = []
hf_gate = InitializeModes.from_hartree_fock(norb, (num_elec_a, num_elec_b))

for time in times:
    evo_gate = Evolution(num_modes, canon, time)
    circ = FermionicCircuit(num_modes)
    circ.append(hf_gate, circ.modes)
    circ.append(evo_gate, circ.modes)
    init_circuits.append(circ)

# Calculate weights for sampling (one per group)
qdrift = QDriftTrotterization(num_exc, rng=19)

pm = generate_preset_jw_pass_manager()
pm.optimization = FermionicPassManager([qdrift])

sqdrift_circuits = []
for circ in init_circuits:
    sqdrift_circuits += (pm.run(circ) for _ in range(num_circuits))

for circ in sqdrift_circuits:
    circ.measure_all()

print(len(sqdrift_circuits))

# This example assumes you have saved your IBM Quantum Platform account locally.
service = QiskitRuntimeService(channel="ibm_quantum_platform")

# Select backend (choose based on qubit requirements)
backend = service.least_busy(
    operational=True,
    simulator=False,
    min_num_qubits=2 * norb,
)

print(f"Selected backend: {backend.name} ({backend.num_qubits} qubits)")

# Transpile for hardware
transpiled_circuits = transpile(
    sqdrift_circuits,
    backend=backend,
    optimization_level=3,
    seed_transpiler=42,
)

shots = 100

sampler = Sampler(mode=backend)

sampler.options.environment.job_tags = ["TUT_SQDRIFT"]

job = sampler.run(transpiled_circuits, shots=shots)
result = job.result()

# Extract counts from SamplerV2 results
all_counts = [pub_result.data.meas.get_counts() for pub_result in result]

# Set to True to filter out bitstrings that violate electron-number conservation
PRUNE = False


def is_valid_bitstring(
    bitstring: str, norb: int, nelec: tuple[int, int]
) -> bool:
    n_alpha, n_beta = nelec
    return (
        len(bitstring) == 2 * norb
        and bitstring[norb:].count("1") == n_alpha
        and bitstring[:norb].count("1") == n_beta
    )


if PRUNE:
    all_counts_filtered = []
    for counts in all_counts:
        filtered_count = {}
        for key in counts:
            if not is_valid_bitstring(key, norb, (num_elec_a, num_elec_b)):
                continue
            elif key not in filtered_count.keys():
                filtered_count[key] = counts[key]
            else:
                filtered_count[key] += counts[key]
        all_counts_filtered.append(filtered_count)
    all_counts = all_counts_filtered

combined_counts = {}
for counts in all_counts:
    for bitstring, count in counts.items():
        combined_counts[bitstring] = combined_counts.get(bitstring, 0) + count

bit_array = BitArray.from_counts(combined_counts)
print(bit_array.num_shots)

print("Electron configuration:")
print(f"  Total electrons: {nelec}")
print(f"  Alpha electrons: {num_elec_a}")
print(f"  Beta electrons: {num_elec_b}")
print(f"  Number of orbitals: {norb}")
print(f"  Number of spin orbitals (qubits): {2*norb}")
print(f"Integral shapes: h1e={h1e.shape}, h2e={h2e.shape}")

# SQD parameters
samples_per_batch = 300
num_batches = 3
max_iterations = 5

initial_occupancies = (
    np.array([1] * num_elec_a + [0] * (norb - num_elec_a)),  # alpha
    np.array([1] * num_elec_b + [0] * (norb - num_elec_b)),  # beta
)

result_history = []


def callback(results: list[SCIResult]):
    result_history.append(results)
    iteration = len(result_history)
    print(f"Iteration {iteration}")
    for i, result in enumerate(results):
        print(f"\tSubsample {i}")
        print(f"\t\tEnergy: {result.energy + nuclear_repulsion_energy}")
        print(
            f"\t\tSubspace dimension: {np.prod(result.sci_state.amplitudes.shape)}"
        )


# Run SQD with configuration recovery
print("\nRunning SQD with configuration recovery...")
result = diagonalize_fermionic_hamiltonian(
    h1e,
    h2e,
    bit_array,
    samples_per_batch=samples_per_batch,
    norb=norb,
    nelec=(num_elec_a, num_elec_b),
    num_batches=num_batches,
    energy_tol=1e-3,
    occupancies_tol=1e-3,
    max_iterations=max_iterations,
    initial_occupancies=initial_occupancies,
    seed=42,
    callback=callback,
)

computed_energy = result.energy + nuclear_repulsion_energy

print("FINAL SQD RESULTS")
print(f"Orbital occupancies (alpha): {result.orbital_occupancies[0]}")
print(f"Orbital occupancies (beta): {result.orbital_occupancies[1]}")


energy_error = abs(computed_energy - reference_energy)
print(f"Reference Energy: {reference_energy:.10f} Ha")
print(f"Computed Energy:  {computed_energy:.10f} Ha")
print(f"Error:            {energy_error:.10e} Ha")
Reference FCI Energy  = -107.6481842917 Ha
Nuclear Repulsion Energy = 23.7887003074 Ha
5060
400

Selected backend: ibm_aachen (156 qubits)
40000
Electron configuration:
  Total electrons: 14
  Alpha electrons: 7
  Beta electrons: 7
  Number of orbitals: 10
  Number of spin orbitals (qubits): 20
Integral shapes: h1e=(10, 10), h2e=(10, 10, 10, 10)

Running SQD with configuration recovery...
Iteration 1
	Subsample 0
		Energy: -107.64593072647523
		Subspace dimension: 7221
	Subsample 1
		Energy: -107.6458270048177
		Subspace dimension: 7209
	Subsample 2
		Energy: -107.64007673117075
		Subspace dimension: 7138
Iteration 2
	Subsample 0
		Energy: -107.64757372124944
		Subspace dimension: 9009
	Subsample 1
		Energy: -107.64674060104392
		Subspace dimension: 8245
	Subsample 2
		Energy: -107.64731360491942
		Subspace dimension: 8178
Iteration 3
	Subsample 0
		Energy: -107.64765518770588
		Subspace dimension: 8835
	Subsample 1
		Energy: -107.64767975712016
		Subspace dimension: 8649
	Subsample 2
		Energy: -107.64761634415606
		Subspace dimension: 8648
FINAL SQD RESULTS
Orbital occupancies (alpha): [0.99999504 0.9999964  0.99590318 0.9932359  0.96697158 0.96696295
 0.99298797 0.03728154 0.03728186 0.00938359]
Orbital occupancies (beta): [0.9999946  0.99999641 0.99590413 0.99323077 0.96697361 0.96696174
 0.99298424 0.03728121 0.03728169 0.00939159]
Reference Energy: -107.6481842917 Ha
Computed Energy:  -107.6476797571 Ha
Error:            5.0453460619e-04 Ha

Passi successivi

Suggerimenti
Questa pagina è stata utile?
Segnala un bug, un errore di battitura o richiedi contenuti su GitHub.