QM/MM simulation: VQE within a Polarizable Embedded Framework.¶
In this tutorial, we will discuss a hybrid quantum-classical algorithm called PE-VQE-SCF introduced in this paper. The PE-VQE-scf approch combines:
Variational Quantum Eigensolver (VQE) – a quantum algorithm for finding ground-state energies of molecules.
Self-Consistent Field (SCF) – a classical iterative method used in quantum chemistry.
Polarizable Embedding (PE) – a technique to model the effect of an environment (like a solvent or protein) on a quantum system.
The goal is to simulate chemical systems on quantum computers (or simulator) by embedding them in a polarizable environment. In this tutorial, we will use CUDA-Q to implemnet the QM/MM framework and the CUDA-Q GPU accelerated simulator for running the simulation.
Key concepts:¶
1- Variational Quantum Eigensolver (VQE) - A quantum algorithm that estimates the ground state energy of a molecule. - It uses a parameterized quantum circuit (ansatz) and a classical optimizer to minimize the energy expectation value. - In this tutorial, we employ VQE with UCCSD ansatz for the quantum part. However, user should be able to replace the VQE and the ansatz with other quantum algorithm.
2- Self-Consistent Field (SCF) - A method where the solution (e.g., molecular orbitals) is updated iteratively until convergence. - In this context, SCF is used to update the embedding potential based on the quantum system’s density.
3- Polarizable Embedding (PE) - Models the environment as a set of polarizable sites that respond to the quantum system’s electric field. - The environment affects the quantum system, and vice versa, requiring mutual polarization.
PE-VQE-SCF Algorithm Steps¶
Step 1: Initialize (Classical pre-processing)¶
Define the quantum region (e.g., a molecule) and the classical environment (e.g., solvent).
Set up the PE parameters (multipoles, polarizabilities).
Step 2: Build the Hamiltonian¶
Construct the PE-augmented Hamiltonian that includes the molecular Hamiltonian and the interaction with the polarizable environment.
Step 3: Run VQE¶
Use a quantum computer (or simulator) to estimate the ground state energy of the system using VQE.
The quantum circuit prepares a trial wavefunction, and the classical optimizer adjusts parameters to minimize energy.
Step 4: Update Environment¶
Compute the electronic density from the VQE result.
Update the induced dipoles in the environment based on this density.
Step 5: Self-Consistency Loop¶
Repeat Steps 2–4 until the energy and density converge (i.e., SCF convergence).
Requirments:¶
[ ]:
# You might need to install (apt-get update & apt-get install build-essential -y
# apt-get install python3-dev) before installing cppe.
%pip install cppe
%pip install pyscf
[1]:
from pyscf import gto, scf, solvent
import numpy as np
from functools import reduce
import cudaq
cudaq.set_target("nvidia", option="fp64")
Example 1: LiH with 2 water molecules.¶
Initialize and build the hamiltonian.¶
[6]:
from qchem.classical_pyscf import get_mol_pe_hamiltonian
geometry = 'Li 0.3925 0.0 0.0; H -1.1774 0.0 0.0'
water_pot= "qchem/4NP_in_water.pot"
molecular_data=get_mol_pe_hamiltonian(xyz=geometry, potfile=water_pot, spin=0, charge=0, basis='sto3g', ccsd=True, verbose=True)
obi_mol = molecular_data[0]
tbi_mol = molecular_data[1]
e_nn = molecular_data[2]
obi_pe = molecular_data[3]
nelectrons = molecular_data[4]
norbitals = molecular_data[5]
qubits_num = 2 * norbitals
overwrite output file: Li 0-pyscf.log
[Pyscf] Total number of electrons = 4
[Pyscf] Total number of orbitals = 6
[Pyscf] Total HF energy with solvent: -7.862708330327407
[Pyscf] Polarizable embedding energy from HF: -1.1127389358871828e-05
[Pyscf] Total CCSD energy with solvent: -7.882680504632604
[Pyscf] Polarizable embedding energy from CCSD: -1.0898134916648752e-05
Running solver for induced moments.
0 --- Norm: 0.000095431562
1 --- Norm: 0.000000927586
--- Turning on DIIS. ---
2 --- Norm: 0.000000010648
3 --- Norm: 0.000000000133
[3]:
from qchem.hamiltonian import jordan_wigner_fermion, jordan_wigner_pe
spin_mol_ham=jordan_wigner_fermion(obi_mol, tbi_mol, e_nn, tolerance = 1e-12)
spin_pe_ham=jordan_wigner_pe(obi_pe, tolerance = 1e-12)
[4]:
mol = gto.M(
atom = geometry,
spin = 0,
charge = 0,
basis = 'sto3g'
)
mf = scf.RHF(mol)
mf_pe = solvent.PE(mf, water_pot).run()
converged SCF energy = -7.86270833032741
VQE, update environment, and scf loop.¶
Note: for more accurate result, user needs to change conv_tol
to much smaller value, e.g., 1e-7. In addition, the tolerance for the classical optimizer vqe_tol
should be modified to smaller value, e.g., 1e-7.
[5]:
from qchem.uccsd import uccsd_parameter_size
from qchem.uccsd_vqe import uccsd_circuit_vqe
from qchem.particle_operator import one_particle_op
from qchem.PEoperator import pe_operator
from qchem.uccsd_init_param import get_parameters
e_last = 0.0
conv_tol = 1e-2
dE = 1.0
cycle = 1
vqe_tol = 1e-4
# Get the number of parameters
singles, doubles, total = uccsd_parameter_size(nelectrons, qubits_num, spin_mult = 0)
print(f"Number of parameters: {singles} singles, {doubles} doubles, {total} total")
# Get the initial parameters for the UCCSD circuit
theta = get_parameters(xyz=geometry, spin=0, charge=0, basis='sto3g', ccsd=True, without_solvent=False, potfile=water_pot)
#theta = np.zeros(total, dtype=np.float64)
while dE > conv_tol:
#for i in range (2):
spin_ham = spin_mol_ham + spin_pe_ham
vqe_result = uccsd_circuit_vqe(spin_mult = 0, only_singles = False, only_doubles = False, qubits_num = qubits_num,
electron_count = nelectrons, optimize = True, theta = theta,
hamiltonian = spin_ham, method = 'L-BFGS-B', vqe_tol = vqe_tol, verbose = False)
print(f"VQE Energy: {vqe_result[0]}")
#print(f"VQE parameters: {vqe_result[1]}")
print(f"VQE converged: {vqe_result[2]}")
theta = vqe_result[1]
if vqe_result[2]:
# Compute rdm in atomic orbital.
dm_a = np.zeros((qubits_num // 2, qubits_num // 2))
dm_b = np.zeros((qubits_num // 2, qubits_num // 2))
n_spatial_orbitals = qubits_num // 2
n_occupied = nelectrons // 2
n_virtual = n_spatial_orbitals - n_occupied
alpha_indices = [i * 2 for i in range(n_occupied)]
alpha_indices += [i * 2 + nelectrons for i in range(n_virtual)]
beta_indices = [i * 2 + 1 for i in range(n_occupied)]
beta_indices += [i * 2 + 1 + nelectrons for i in range(n_virtual)]
for i in range(len(alpha_indices)):
p = alpha_indices[i]
for j in range(len(alpha_indices)):
q = alpha_indices[j]
qubit_op_dm = one_particle_op(p, q)
result = uccsd_circuit_vqe(spin_mult = 0, only_singles = False, only_doubles = False, qubits_num = qubits_num,
electron_count = nelectrons, optimize = False, theta = theta,
hamiltonian = qubit_op_dm, verbose = False)
dm_a[i, j] = result[0]
for i in range(len(beta_indices)):
p = beta_indices[i]
for j in range(len(beta_indices)):
q = beta_indices[j]
qubit_op_dm = one_particle_op(p, q)
result = uccsd_circuit_vqe(spin_mult = 0, only_singles = False, only_doubles = False, qubits_num = qubits_num,
electron_count = nelectrons, optimize = False, theta = theta,
hamiltonian = qubit_op_dm, verbose = False)
dm_b[i, j] = result[0]
dm_ao = dm_a + dm_b
dm_ao = reduce(np.dot, (mf_pe.mo_coeff, dm_ao, mf_pe.mo_coeff.T))
spin_pe_ham, E_pe, V_pe = pe_operator(dm_ao, mol, mf_pe.mo_coeff, water_pot, tolerance = 1e-12)
result = uccsd_circuit_vqe(spin_mult = 0, only_singles = False, only_doubles = False, qubits_num = qubits_num,
electron_count = nelectrons, optimize = False, theta = theta,
hamiltonian = spin_pe_ham, verbose = False)
edup = result[0]
# Check energy are the same
ovlp = mol.intor_symmetric('int1e_ovlp')
dm = np.einsum('pi,pq,qj->ij', ovlp, dm_ao, ovlp)
dm_mo = reduce(np.dot, (mf_pe.mo_coeff.T, dm, mf_pe.mo_coeff))
edup_np = np.einsum('ij,ji->', V_pe, dm_mo)
print('Energy from observe call= {:15g} & Energy from np einsum= {:15g}' .format(edup, edup_np), flush=True)
e_current = vqe_result[0] - edup + E_pe
dE = np.abs(e_current - e_last)
e_last = e_current
print('Cycle {:d} E_tot = {:.15g} dE = {:g}' .format(cycle, e_last, dE), flush=True)
print('Polarizable embedding energy: ', E_pe, flush=True)
print('Expectation value of polarizable operator: ', edup, flush=True)
print('\n')
cycle+=1
else:
#raise ValueError("Unsuccessful optimization.")
print("Unsuccessful optimization. Restart the optimization.", flush=True)
pass
# Compute the RDM in molecular orbitals and natural orbital occupation number.
ovlp = mol.intor_symmetric('int1e_ovlp')
dm = np.einsum('pi,pq,qj->ij', ovlp, dm_ao, ovlp)
dm_mo = reduce(np.dot, (mf_pe.mo_coeff.T, dm, mf_pe.mo_coeff))
noon, U = np.linalg.eigh(dm_mo)
noon = np.flip(noon)
print('\n')
print('Final result: ')
print('Polarizable embedding energy: ', E_pe)
print('Expectation value of polarizable operator: ', edup, flush=True)
print('PE-VQE-UCCSD energy (L-BFGS-B)= ', e_last)
print('Natural orbitals occupation number: ', noon)
Number of parameters: 16 singles, 76 doubles, 92 total
overwrite output file: Li 0-pyscf.log
Optimizer exited successfully: True
VQE Energy: -7.867161909761761
VQE converged: True
Running solver for induced moments.
0 --- Norm: 0.000095457621
1 --- Norm: 0.000000860362
--- Turning on DIIS. ---
2 --- Norm: 0.000000004312
Energy from observe call= 0.000762169 & Energy from np einsum= 0.000762197
Cycle 1 E_tot = -7.86793486027634 dE = 7.86793
Polarizable embedding energy: -1.0781673490428836e-05
Expectation value of polarizable operator: 0.0007621688410912728
Optimizer exited successfully: True
VQE Energy: -7.8692404296251395
VQE converged: True
Running solver for induced moments.
0 --- Norm: 0.000095564807
1 --- Norm: 0.000000887381
--- Turning on DIIS. ---
2 --- Norm: 0.000000007916
Energy from observe call= 0.000762218 & Energy from np einsum= 0.000762229
Cycle 2 E_tot = -7.87001339604889 dE = 0.00207854
Polarizable embedding energy: -1.0748496723151968e-05
Expectation value of polarizable operator: 0.0007622179270272233
Final result:
Polarizable embedding energy: -1.0748496723151968e-05
Expectation value of polarizable operator: 0.0007622179270272233
PE-VQE-UCCSD energy (L-BFGS-B)= -7.87001339604889
Natural orbitals occupation number: [1.99899690e+00 1.91601348e+00 6.71542420e-02 1.60555115e-02
1.15390205e-03 6.30790416e-04]
Example 2: NH3 with 46 water molecule using active space.¶
[2]:
from qchem.classical_pyscf import get_mol_pe_hamiltonian
from qchem.hamiltonian import jordan_wigner_fermion, jordan_wigner_pe
geometry_NH3 = 'qchem/NH3.xyz'
water_pot = 'qchem/46_water.pot'
molecular_data=get_mol_pe_hamiltonian(xyz=geometry_NH3, potfile=water_pot, spin=0, charge=0, basis='631g', \
nele_cas=6, norb_cas=6, ccsd=True, verbose=True)
obi_mol = molecular_data[0]
tbi_mol = molecular_data[1]
e_core = molecular_data[2]
obi_pe =molecular_data[3]
nelectrons = molecular_data[4]
norbitals = molecular_data[5]
qubits_num = 2 * norbitals
spin_mol_ham = jordan_wigner_fermion(obi_mol, tbi_mol, e_core)
spin_pe_ham = jordan_wigner_pe(obi_pe)
overwrite output file: qchem/NH3-pyscf.log
[Pyscf] Total number of electrons = 10
[Pyscf] Total number of orbitals = 15
[Pyscf] Total HF energy with solvent: -56.460544146152934
[Pyscf] Polarizable embedding energy from HF: -0.2987297397877397
[pyscf] CCSD energy of the active space (using molecular orbitals) with solvent= -56.47023410365107
[Pyscf] Polarizable embedding energy from CCSD: -0.2986368707105786
Running solver for induced moments.
0 --- Norm: 0.572427861637
1 --- Norm: 0.194328219249
--- Turning on DIIS. ---
2 --- Norm: 0.063478479823
3 --- Norm: 0.007602903988
4 --- Norm: 0.002247104251
5 --- Norm: 0.000460072303
6 --- Norm: 0.000114086983
7 --- Norm: 0.000023144988
8 --- Norm: 0.000005162818
9 --- Norm: 0.000001149278
10 --- Norm: 0.000000248690
11 --- Norm: 0.000000069750
12 --- Norm: 0.000000018086
13 --- Norm: 0.000000001401
[3]:
from pyscf import gto, scf, mcscf, solvent
mol = gto.M(
atom = geometry_NH3,
spin = 0,
charge = 0,
basis = '631g'
)
mf_pe = scf.RHF(mol)
mf_pe = solvent.PE(mf_pe, water_pot).run()
mc = mcscf.CASCI(mf_pe, norbitals, nelectrons)
converged SCF energy = -56.460544146164
Note: for more accurate result, user needs to change conv_tol
to much smaller value, e.g., 1e-7. In addition, the tolerance for the classical optimizer vqe_tol
should be modified to smaller value, e.g., 1e-7.
[4]:
import numpy as np
from functools import reduce
from qchem.uccsd import uccsd_parameter_size
from qchem.uccsd_vqe import uccsd_circuit_vqe
from qchem.particle_operator import one_particle_op
from qchem.PEoperator import pe_operator_as
from qchem.uccsd_init_param import get_parameters
# Get the number of parameters
singles, doubles, total = uccsd_parameter_size(nelectrons, qubits_num, spin_mult = 0)
print(f"Number of parameters: {singles} singles, {doubles} doubles, {total} total")
#theta = get_parameters(xyz = geometry_NH3, spin = 0,charge = 0, basis = '631g', nele_cas = 6, norb_cas = 6, \
# ccsd = True, without_solvent = False, potfile = water_pot)
theta = np.zeros(total, dtype=np.float64)
e_last = 0.0
conv_tol = 1e-2
dE = 1.0
cycle = 1
vqe_tol = 1e-3
while dE > conv_tol:
#for i in range (1):
spin_ham = spin_mol_ham + spin_pe_ham
vqe_result = uccsd_circuit_vqe(spin_mult = 0, only_singles = False, only_doubles = False, qubits_num = qubits_num,
electron_count = nelectrons, optimize = True, theta = theta,
hamiltonian = spin_ham, method = 'L-BFGS-B', vqe_tol = vqe_tol, verbose = False)
print(f"VQE Energy: {vqe_result[0]}")
#print(f"VQE parameters: {vqe_result[1]}")
print(f"VQE converged: {vqe_result[2]}")
theta = vqe_result[1]
if vqe_result[2]:
dm_a = np.zeros((qubits_num // 2, qubits_num // 2))
dm_b = np.zeros((qubits_num // 2, qubits_num // 2))
dm = np.zeros((qubits_num, qubits_num))
n_spatial_orbitals = qubits_num // 2
n_occupied = nelectrons // 2
n_virtual = n_spatial_orbitals - n_occupied
alpha_indices = [i * 2 for i in range(n_occupied)]
alpha_indices += [i * 2 + nelectrons for i in range(n_virtual)]
beta_indices = [i * 2 + 1 for i in range(n_occupied)]
beta_indices += [i * 2 + 1 + nelectrons for i in range(n_virtual)]
for i in range(len(alpha_indices)):
p = alpha_indices[i]
for j in range(len(alpha_indices)):
q = alpha_indices[j]
qubit_op_dm = one_particle_op(p, q)
result = uccsd_circuit_vqe(spin_mult = 0, only_singles = False, only_doubles = False, qubits_num = qubits_num,
electron_count = nelectrons, optimize = False, theta = theta,
hamiltonian = qubit_op_dm, verbose = False)
dm_a[i, j] = result[0]
for i in range(len(beta_indices)):
p = beta_indices[i]
for j in range(len(beta_indices)):
q = beta_indices[j]
qubit_op_dm = one_particle_op(p, q)
result = uccsd_circuit_vqe(spin_mult = 0, only_singles = False, only_doubles = False, qubits_num = qubits_num,
electron_count = nelectrons, optimize = False, theta = theta,
hamiltonian = qubit_op_dm, verbose = False)
dm_b[i, j] = result[0]
mocore = mf_pe.mo_coeff[:, :mc.ncore]
dm_core = np.dot(mocore, mocore.conj().T) * 2
casdm = dm_a + dm_b
mocas = mf_pe.mo_coeff[:, mc.ncore:mc.ncore + mc.ncas]
# RDM in atomic orbitals
dm = dm_core + reduce(np.dot, (mocas, casdm, mocas.conj().T))
spin_pe_hamiltonian, E_pe, V_pe, V_pe_cas= pe_operator_as(dm,mol,mf_pe.mo_coeff,water_pot,mc)
result = uccsd_circuit_vqe(spin_mult = 0, only_singles = False, only_doubles = False, qubits_num = qubits_num,
electron_count = nelectrons, optimize = False, theta = theta,
hamiltonian = spin_pe_ham, verbose = False)
edup = result[0]
e_current = vqe_result[0] - edup + E_pe
dE = np.abs(e_current - e_last)
e_last = e_current
print('Cycle {:d} E_tot = {:.15g} dE = {:g}' .format(cycle, e_last, dE), flush=True)
print('Polarizable embedding energy: ', E_pe, flush=True)
print('Expectation value of polarizable operator: ', edup, flush=True)
print('\n')
cycle+=1
else:
print("Unsuccessful optimization. Restart the optimization.", flush=True)
pass
# convert dm from ao to mo (D_MO= C.T S D_AO S C ) and compute natural orbital occupation number.
ovlp = mol.intor_symmetric('int1e_ovlp')
dm = np.einsum('pi,pq,qj->ij', ovlp, dm, ovlp)
dm_mo = reduce(np.dot, (mf_pe.mo_coeff.T, dm, mf_pe.mo_coeff))
noon, U = np.linalg.eigh(dm_mo)
noon = np.flip(noon)
print('\n')
print('Final result: ')
print('Polarizable embedding energy: ', E_pe)
print('Expectation value of polarizable operator: ', edup, flush=True)
print('PE-VQE-UCCSD energy (L-BFGS-B)= ', e_last)
print('Natural orbitals occupation number: ', noon)
Number of parameters: 18 singles, 99 doubles, 117 total
Optimizer exited successfully: True
VQE Energy: -56.07492192212563
VQE converged: True
Running solver for induced moments.
0 --- Norm: 0.560904998895
1 --- Norm: 0.195464440561
--- Turning on DIIS. ---
2 --- Norm: 0.080909047471
3 --- Norm: 0.014193006098
4 --- Norm: 0.004526458635
5 --- Norm: 0.001256746907
6 --- Norm: 0.000476278998
7 --- Norm: 0.000120262763
8 --- Norm: 0.000033369845
9 --- Norm: 0.000007548513
10 --- Norm: 0.000001932468
11 --- Norm: 0.000000513677
12 --- Norm: 0.000000251862
13 --- Norm: 0.000000062997
14 --- Norm: 0.000000031247
15 --- Norm: 0.000000008129
Cycle 1 E_tot = -56.4704454234038 dE = 56.4704
Polarizable embedding energy: -0.29871334470788147
Expectation value of polarizable operator: 0.09681015657024516
Optimizer exited successfully: True
VQE Energy: -56.074941291297016
VQE converged: True
Running solver for induced moments.
0 --- Norm: 0.578438887906
1 --- Norm: 0.186313099889
--- Turning on DIIS. ---
2 --- Norm: 0.068418421402
3 --- Norm: 0.012730202853
4 --- Norm: 0.004094551612
5 --- Norm: 0.000999816754
6 --- Norm: 0.000319250864
7 --- Norm: 0.000077598802
8 --- Norm: 0.000023880904
9 --- Norm: 0.000008037391
10 --- Norm: 0.000001859332
11 --- Norm: 0.000000532803
12 --- Norm: 0.000000133206
13 --- Norm: 0.000000032446
14 --- Norm: 0.000000009283
Cycle 2 E_tot = -56.4704648912704 dE = 1.94679e-05
Polarizable embedding energy: -0.2987133198747535
Expectation value of polarizable operator: 0.09681028009861878
Final result:
Polarizable embedding energy: -0.2987133198747535
Expectation value of polarizable operator: 0.09681028009861878
PE-VQE-UCCSD energy (L-BFGS-B)= -56.47046489127038
Natural orbitals occupation number: [ 2.00000000e+00 2.00000000e+00 1.99935887e+00 1.99541659e+00
1.99508319e+00 4.74450015e-03 4.36742635e-03 1.08093504e-03
4.89965694e-15 1.85085120e-15 1.30851294e-15 5.07383839e-16
-1.05732651e-15 -1.59225347e-15 -6.12772938e-15]
[5]:
print(cudaq.__version__)
CUDA-Q Version cu12-latest (https://github.com/NVIDIA/cuda-quantum 615d91910310605c83ea59f6afe6e7ae6dfecd28)