Sari la conținutul principal

Îmbunătățirea estimării energiei unui Hamiltonian de chimie cu SQD

În acest tutorial implementăm un pattern Qiskit care arată cum să post-procesezi eșantioane cuantice zgomotoase pentru a găsi o aproximare a stării fundamentale a unui Hamiltonian de chimie: molecula N2N_2 la echilibru în baza 6-31G. Vom urma o abordare de diagonalizare cuantică bazată pe eșantionare pentru a procesa eșantioane preluate dintr-un ansatz de Circuit cuantic cu 36 de Qubiți (în acest caz, un circuit LUCJ). Pentru a ține cont de efectul zgomotului cuantic, se folosește tehnica de recuperare a configurației.

Patternul poate fi descris în patru pași:

  1. Pasul 1: Maparea la problema cuantică
    • Generează un ansatz pentru estimarea stării fundamentale
  2. Pasul 2: Optimizarea problemei
    • Transpilează ansatz-ul pentru Backend
  3. Pasul 3: Executarea experimentelor
    • Extrage eșantioane din ansatz folosind primitiva Sampler
  4. Pasul 4: Post-procesarea rezultatelor
    • Bucla de recuperare auto-consistentă a configurației
      • Post-procesează întregul set de eșantioane de tip bitstring, folosind cunoașterea prealabilă a numărului de particule și a ocupanței orbitale medii calculată la cea mai recentă iterație.
      • Creează probabilistic loturi de subeșantioane din bitstring-uri recuperate.
      • Proiectează și diagonalizează Hamiltonianul molecular peste fiecare subspațiu eșantionat.
      • Salvează energia minimă a stării fundamentale găsită în toate loturile și actualizează ocupanța orbitală medie.

Pentru acest exemplu, Hamiltonianul de electroni în interacțiune are forma generică:

H^=prσhpra^pσa^rσ+prqsστ(prqs)2a^pσa^qτa^sτa^rσ\hat{H} = \sum_{ \substack{pr\\\sigma} } h_{pr} \, \hat{a}^\dagger_{p\sigma} \hat{a}_{r\sigma} + \sum_{ \substack{prqs\\\sigma\tau} } \frac{(pr|qs)}{2} \, \hat{a}^\dagger_{p\sigma} \hat{a}^\dagger_{q\tau} \hat{a}_{s\tau} \hat{a}_{r\sigma}

a^pσ\hat{a}^\dagger_{p\sigma}/a^pσ\hat{a}_{p\sigma} sunt operatorii fermonici de creare/anihilare asociați celui de-al pp-lea element al bazei și spinului σ\sigma. hprh_{pr} și (prqs)(pr|qs) sunt integralele electronice de un corp și, respectiv, de două corpuri.

Fluxul de lucru SQD cu recuperare auto-consistentă a configurației este ilustrat în diagrama de mai jos.

SQD diagram

SQD funcționează bine atunci când starea proprie țintă este rară: funcția de undă este susținută într-un set de stări de bază S={x}\mathcal{S} = \{|x\rangle \} ale cărei dimensiuni nu cresc exponențial cu dimensiunea problemei. În acest scenariu, diagonalizarea Hamiltonianului proiectat în subspațiul definit de S\mathcal{S}:

HS=PSHPS with PS=xSxx;H_\mathcal{S} = P_\mathcal{S} H P_\mathcal{S} \textrm{ with } P_\mathcal{S} = \sum_{x \in \mathcal{S}} |x \rangle \langle x |;

produce o bună aproximare a stării proprii țintă. Rolul dispozitivului cuantic este de a produce eșantioane ale membrilor din S\mathcal{S}. Mai întâi, un Circuit cuantic pregătește starea Ψ|\Psi\rangle pe dispozitivul cuantic. Se folosește codificarea Jordan-Wigner. În consecință, membrii bazei de calcul reprezintă stări Fock (configurații/determinanți electronici). Circuitul este eșantionat în baza de calcul, obținând setul de configurații zgomotoase X~\tilde{\mathcal{X}}. Configurațiile sunt reprezentate prin bitstring-uri. Setul X~\tilde{\mathcal{X}} este apoi transmis blocului de post-procesare clasic, unde se folosește tehnica de recuperare auto-consistentă a configurației. În cadrul SQD, rolul dispozitivului cuantic este de a produce o distribuție de probabilitate.

Pasul 1: Maparea problemei la un Circuit cuantic

În acest tutorial, vom aproxima energia stării fundamentale a unei molecule de N2N_2. Mai întâi, vom specifica molecula și proprietățile sale. Apoi, vom crea un ansatz local unitary cluster Jastrow (LUCJ) (Circuit cuantic) pentru a genera eșantioane de pe un calculator cuantic în scopul estimării energiei stării fundamentale.

Mai întâi, vom specifica molecula și proprietățile sale.

# Added by doQumentation — required packages for this notebook
!pip install -q ffsim matplotlib numpy pyscf qiskit qiskit-addon-sqd qiskit-ibm-runtime
import warnings

warnings.filterwarnings("ignore")

import pyscf
import pyscf.cc
import pyscf.mcscf

# Specify molecule properties
open_shell = False
spin_sq = 0

# Build N2 molecule
mol = pyscf.gto.Mole()
mol.build(
atom=[["N", (0, 0, 0)], ["N", (1.0, 0, 0)]],
basis="6-31g",
symmetry="Dooh",
)

# Define active space
n_frozen = 2
active_space = range(n_frozen, mol.nao_nr())

# Get molecular integrals
scf = pyscf.scf.RHF(mol).run()
num_orbitals = len(active_space)
n_electrons = int(sum(scf.mo_occ[active_space]))
num_elec_a = (n_electrons + mol.spin) // 2
num_elec_b = (n_electrons - mol.spin) // 2
cas = pyscf.mcscf.CASCI(scf, num_orbitals, (num_elec_a, num_elec_b))
mo = cas.sort_mo(active_space, base=0)
hcore, nuclear_repulsion_energy = cas.get_h1cas(mo)
eri = pyscf.ao2mo.restore(1, cas.get_h2cas(mo), num_orbitals)

# Compute exact energy
exact_energy = cas.run().e_tot
converged SCF energy = -108.835236570775
CASCI E = -109.046671778080 E(CI) = -32.8155692383188 S^2 = 0.0000000

Apoi, vom crea ansatz-ul. Ansatz-ul LUCJ este un Circuit cuantic parametrizat și îl vom inițializa cu amplitudinile t2 și t1 obținute dintr-un calcul CCSD.

# Get CCSD t2 amplitudes for initializing the ansatz
ccsd = pyscf.cc.CCSD(scf, frozen=[i for i in range(mol.nao_nr()) if i not in active_space]).run()
t1 = ccsd.t1
t2 = ccsd.t2
E(CCSD) = -109.0398256929734  E_corr = -0.2045891221988311

Vom folosi pachetul ffsim pentru a crea și inițializa ansatz-ul cu amplitudinile t2 și t1 calculate mai sus. Deoarece molecula noastră are o stare Hartree-Fock cu înveliș închis, vom folosi varianta echilibrată de spin a ansatz-ului UCJ, UCJOpSpinBalanced.

Deoarece hardware-ul IBM țintă are o topologie heavy-hex, vom adopta modelul zig-zag pentru interacțiunile dintre Qubiți. În acest model, orbitalele (reprezentate de Qubiți) cu același spin sunt conectate printr-o topologie liniară (cercuri roșii și albastre) unde fiecare linie ia o formă zig-zag din cauza conectivității heavy-hex a hardware-ului țintă. Tot din cauza topologiei heavy-hex, orbitalele pentru spinuri diferite au conexiuni la fiecare al 4-lea orbital (0, 4, 8 etc.) (cercuri violet).

lucj_ansatz

import ffsim
from qiskit import QuantumCircuit, QuantumRegister

n_reps = 1
alpha_alpha_indices = [(p, p + 1) for p in range(num_orbitals - 1)]
alpha_beta_indices = [(p, p) for p in range(0, num_orbitals, 4)]

ucj_op = ffsim.UCJOpSpinBalanced.from_t_amplitudes(
t2=t2,
t1=t1,
n_reps=n_reps,
interaction_pairs=(alpha_alpha_indices, alpha_beta_indices),
)

nelec = (num_elec_a, num_elec_b)

# create an empty quantum circuit
qubits = QuantumRegister(2 * num_orbitals, name="q")
circuit = QuantumCircuit(qubits)

# prepare Hartree-Fock state as the reference state and append it to the quantum circuit
circuit.append(ffsim.qiskit.PrepareHartreeFockJW(num_orbitals, nelec), qubits)

# apply the UCJ operator to the reference state
circuit.append(ffsim.qiskit.UCJOpSpinBalancedJW(ucj_op), qubits)
circuit.measure_all()

Pasul 2: Optimizarea problemei

Acum vom optimiza Circuitul nostru pentru hardware-ul țintă. Trebuie să alegem dispozitivul hardware de utilizat înainte de a ne optimiza Circuitul. Vom folosi un Backend fals de 127 de Qubiți din qiskit_ibm_runtime pentru a emula un dispozitiv real. Pentru a rula pe un QPU real, înlocuiește pur și simplu Backend-ul fals cu unul real. Consultă documentația Qiskit IBM Runtime pentru mai multe informații.

from qiskit_ibm_runtime.fake_provider import FakeSherbrooke

backend = FakeSherbrooke()

Apoi, recomandăm următorii pași pentru a optimiza ansatz-ul și a-l face compatibil cu hardware-ul.

  • Selectează Qubiți fizici (initial_layout) de pe hardware-ul țintă care respectă modelul zig-zag descris mai sus. Dispunerea Qubiților în acest model duce la un Circuit eficient, compatibil cu hardware-ul și cu mai puține Gate-uri.
  • Generează un pass manager pe etape folosind funcția generate_preset_pass_manager din Qiskit cu alegerea ta de backend și initial_layout.
  • Setează etapa pre_init a pass manager-ului tău pe etape la ffsim.qiskit.PRE_INIT. ffsim.qiskit.PRE_INIT include pase de Transpiler Qiskit care descompun Gate-urile în rotații orbitale și apoi le îmbină, rezultând mai puține Gate-uri în Circuitul final.
  • Rulează pass manager-ul pe Circuitul tău.
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager

spin_a_layout = [0, 14, 18, 19, 20, 33, 39, 40, 41, 53, 60, 61, 62, 72, 81, 82]
spin_b_layout = [2, 3, 4, 15, 22, 23, 24, 34, 43, 44, 45, 54, 64, 65, 66, 73]
initial_layout = spin_a_layout + spin_b_layout

pass_manager = generate_preset_pass_manager(
optimization_level=3, backend=backend, initial_layout=initial_layout
)

# without PRE_INIT passes
isa_circuit = pass_manager.run(circuit)
print(f"Gate counts (w/o pre-init passes): {isa_circuit.count_ops()}")

# with PRE_INIT passes
# We will use the circuit generated by this pass manager for hardware execution
pass_manager.pre_init = ffsim.qiskit.PRE_INIT
isa_circuit = pass_manager.run(circuit)
print(f"Gate counts (w/ pre-init passes): {isa_circuit.count_ops()}")
Gate counts (w/o pre-init passes): OrderedDict({'rz': 4420, 'sx': 3432, 'ecr': 1366, 'x': 239, 'measure': 32, 'barrier': 1})
Gate counts (w/ pre-init passes): OrderedDict({'rz': 2460, 'sx': 2156, 'ecr': 730, 'x': 71, 'measure': 32, 'barrier': 1})

Pasul 3: Executarea experimentelor

După optimizarea circuitului pentru execuția pe hardware, suntem pregătiți să îl rulăm pe hardware-ul țintă și să colectăm mostre pentru estimarea energiei stării fundamentale. Deoarece avem un singur circuit, vom folosi modul de execuție Job din Qiskit Runtime și vom executa circuitul nostru.

Notă: Am comentat codul pentru rularea circuitului pe un QPU și l-am lăsat ca referință pentru utilizator. În loc să rulăm pe hardware real în acest ghid, vom genera mostre aleatoare extrase din distribuția uniformă.

import numpy as np
from qiskit_addon_sqd.counts import generate_bit_array_uniform

# from qiskit_ibm_runtime import SamplerV2 as Sampler

# sampler = Sampler(mode=backend)
# job = sampler.run([isa_circuit], shots=10_000)
# primitive_result = job.result()
# pub_result = primitive_result[0]
# bit_array = pub_result.data.meas

rng = np.random.default_rng(24)
bit_array = generate_bit_array_uniform(10_000, num_orbitals * 2, rand_seed=rng)

Pasul 4: Post-procesarea rezultatelor

Acum rulăm algoritmul SQD folosind funcția diagonalize_fermionic_hamiltonian. Consultă documentația API pentru explicații privind argumentele acestei funcții.

Solverul inclus în addon-ul SQD folosește implementarea CI selectiv din PySCF, mai precis pyscf.fci.selected_ci.kernel_fixed_space. Exemplul de mai jos arată, de asemenea, cum să transmiți argumente cu cuvinte cheie acelei funcții prin intermediul solverului inclus. Aici transmitem argumentul max_cycle.

from functools import partial

from qiskit_addon_sqd.fermion import SCIResult, diagonalize_fermionic_hamiltonian, solve_sci_batch

# SQD options
energy_tol = 1e-3
occupancies_tol = 1e-3
max_iterations = 5

# Eigenstate solver options
num_batches = 1
samples_per_batch = 300
symmetrize_spin = True
carryover_threshold = 1e-4
max_cycle = 200

# Pass options to the built-in eigensolver. If you just want to use the defaults,
# you can omit this step, in which case you would not specify the sci_solver argument
# in the call to diagonalize_fermionic_hamiltonian below.
sci_solver = partial(solve_sci_batch, spin_sq=0.0, max_cycle=max_cycle)

# List to capture intermediate results
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)}")

result = diagonalize_fermionic_hamiltonian(
hcore,
eri,
bit_array,
samples_per_batch=samples_per_batch,
norb=num_orbitals,
nelec=nelec,
num_batches=num_batches,
energy_tol=energy_tol,
occupancies_tol=occupancies_tol,
max_iterations=max_iterations,
sci_solver=sci_solver,
symmetrize_spin=symmetrize_spin,
carryover_threshold=carryover_threshold,
callback=callback,
seed=rng,
)
Iteration 1
Subsample 0
Energy: -105.45358671756313
Subspace dimension: 5476
Iteration 2
Subsample 0
Energy: -107.95172900082163
Subspace dimension: 249001
Iteration 3
Subsample 0
Energy: -108.97460330369815
Subspace dimension: 339889
Iteration 4
Subsample 0
Energy: -109.02739376648793
Subspace dimension: 440896
Iteration 5
Subsample 0
Energy: -109.030972328451
Subspace dimension: 597529

Acum reprezentăm grafic rezultatele.

Primul grafic arată că, după câteva iterații, estimăm energia stării fundamentale cu o eroare de aproximativ ~16 mH (acuratețea chimică este în general acceptată ca fiind 1 kcal/mol \approx 1.6 mH). Ține minte că mostrele cuantice din această demonstrație au fost zgomot pur. Semnalul provine din cunoștințele a priori despre structura electronică și Hamiltonianul molecular.

Al doilea grafic arată ocupanța medie a fiecărei orbitale spațiale după iterația finală. Putem observa că atât electronii cu spin-up, cât și cei cu spin-down ocupă primele cinci orbitale cu probabilitate ridicată în soluțiile noastre.

import matplotlib.pyplot as plt

# Data for energies plot
x1 = range(len(result_history))
min_e = [
min(result, key=lambda res: res.energy).energy + nuclear_repulsion_energy
for result in result_history
]
e_diff = [abs(e - exact_energy) for e in min_e]
yt1 = [1.0, 1e-1, 1e-2, 1e-3, 1e-4]

# Chemical accuracy (+/- 1 milli-Hartree)
chem_accuracy = 0.001

# Data for avg spatial orbital occupancy
y2 = np.sum(result.orbital_occupancies, axis=0)
x2 = range(len(y2))

fig, axs = plt.subplots(1, 2, figsize=(12, 6))

# Plot energies
axs[0].plot(x1, e_diff, label="energy error", marker="o")
axs[0].set_xticks(x1)
axs[0].set_xticklabels(x1)
axs[0].set_yticks(yt1)
axs[0].set_yticklabels(yt1)
axs[0].set_yscale("log")
axs[0].set_ylim(1e-4)
axs[0].axhline(y=chem_accuracy, color="#BF5700", linestyle="--", label="chemical accuracy")
axs[0].set_title("Approximated Ground State Energy Error vs SQD Iterations")
axs[0].set_xlabel("Iteration Index", fontdict={"fontsize": 12})
axs[0].set_ylabel("Energy Error (Ha)", fontdict={"fontsize": 12})
axs[0].legend()

# Plot orbital occupancy
axs[1].bar(x2, y2, width=0.8)
axs[1].set_xticks(x2)
axs[1].set_xticklabels(x2)
axs[1].set_title("Avg Occupancy per Spatial Orbital")
axs[1].set_xlabel("Orbital Index", fontdict={"fontsize": 12})
axs[1].set_ylabel("Avg Occupancy", fontdict={"fontsize": 12})

print(f"Exact energy: {exact_energy:.5f} Ha")
print(f"SQD energy: {min_e[-1]:.5f} Ha")
print(f"Absolute error: {e_diff[-1]:.5f} Ha")
plt.tight_layout()
plt.show()
Exact energy: -109.04667 Ha
SQD energy: -109.03097 Ha
Absolute error: 0.01570 Ha

Plot output