Sari la conținutul principal
Acest proiect este în versiune beta. Ai găsit o eroare? Deschide un issue pe GitHub. Ai o întrebare sau o idee? Întreabă în Discussions — ne-ar plăcea să aflăm părerea ta!
Cum este calitatea traducerii pe această pagină?

Î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σhpr a^pσ†a^rσ+∑prqsστ(pr∣qs)2 a^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 fermionici de creare/anihilare asociați celui de-al pp-lea element al bazei și spinului σ\sigma. hprh_{pr} și (pr∣qs)(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

Se știe că 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 \} al cărui număr nu crește exponențial cu dimensiunea problemei. În acest scenariu, diagonalizarea Hamiltonianului proiectat în subspațiul definit de S\mathcal{S}:

HS=PSHPS with PS=∑x∈S∣x⟩⟨x∣;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 doar 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 — installs the packages this notebook needs if they are missing
import importlib.util

_needed = {"ffsim": "ffsim", "matplotlib": "matplotlib", "numpy": "numpy", "pyscf": "pyscf", "qiskit": "qiskit", "qiskit_addon_sqd": "qiskit-addon-sqd", "qiskit_ibm_runtime": "qiskit-ibm-runtime"}
_missing = [pip for module, pip in _needed.items()
if importlib.util.find_spec(module) is None]
# One at a time, so a package that fails to install does not block the others
for _pip in _missing:
%pip install -q {_pip}
if not _missing:
print("\u2713 All required packages are installed")
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 a 4-a 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 eșantioane 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 eșantioane 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