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 implement 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

e4089a9f6f274a16beb3cbf6c60b95a3

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:

[1]:
# You might need to install (apt-get update & apt-get install build-essential -y
#                         apt-get install python3-dev) before installing cppe.
%pip install --upgrade setuptools
%pip install cppe
%pip install pyscf
[2]:
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.

[3]:
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.112738935883172e-05
[Pyscf] Total CCSD energy with solvent:  -7.882680504632596
[Pyscf] Polarizable embedding energy from CCSD:  -1.089813491052816e-05
        Running solver for induced moments.
0        --- Norm: 0.000095615129
1        --- Norm: 0.000000930774
        --- Turning on DIIS. ---
2        --- Norm: 0.000000007008
[4]:
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)

[5]:
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.8627083303274

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.

[6]:
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.881456264307182
VQE converged: True
        Running solver for induced moments.
0        --- Norm: 0.000095403904
1        --- Norm: 0.000000748309
        --- Turning on DIIS. ---
2        --- Norm: 0.000000007479
Energy from observe call=     0.000762039 & Energy from np einsum=      0.00076198
Cycle 1  E_tot = -7.88222931176252  dE = 7.88223
Polarizable embedding energy:  -1.1008237304300546e-05
Expectation value of polarizable operator:  0.0007620392180328967


Optimizer exited successfully:  True
VQE Energy: -7.881685030545087
VQE converged: True
        Running solver for induced moments.
0        --- Norm: 0.000095616250
1        --- Norm: 0.000000948911
        --- Turning on DIIS. ---
2        --- Norm: 0.000000007398
Energy from observe call=     0.000762037 & Energy from np einsum=     0.000761991
Cycle 2  E_tot = -7.88245806438924  dE = 0.000228753
Polarizable embedding energy:  -1.0997000538082559e-05
Expectation value of polarizable operator:  0.0007620368436104166




Final result:
Polarizable embedding energy:  -1.0997000538082559e-05
Expectation value of polarizable operator:  0.0007620368436104166
PE-VQE-UCCSD energy (L-BFGS-B)=  -7.882458064389236
Natural orbitals occupation number:  [1.99994756e+00 1.95804140e+00 3.84732000e-02 1.45458205e-03
 1.45448694e-03 6.28773018e-04]

Example 2: NH3 with 46 water molecule using active space.

[7]:
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.46054414611039
[Pyscf] Polarizable embedding energy from HF:  -0.298729739745301
[pyscf] CCSD energy of the active space (using molecular orbitals) with solvent=  -56.47023410386734
[Pyscf] Polarizable embedding energy from CCSD:  -0.2986368709268163
        Running solver for induced moments.
0        --- Norm: 0.586472819364
1        --- Norm: 0.193783817190
        --- Turning on DIIS. ---
2        --- Norm: 0.052366566202
3        --- Norm: 0.005770613979
4        --- Norm: 0.001096671421
5        --- Norm: 0.000240954869
6        --- Norm: 0.000046695934
7        --- Norm: 0.000011133375
8        --- Norm: 0.000003080297
9        --- Norm: 0.000000540627
10        --- Norm: 0.000000130514
11        --- Norm: 0.000000038555
12        --- Norm: 0.000000010061
13        --- Norm: 0.000000001252
[8]:
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.4605441461449

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.

[9]:
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.074426476013564
VQE converged: True
        Running solver for induced moments.
0        --- Norm: 0.572318773112
1        --- Norm: 0.195666782676
        --- Turning on DIIS. ---
2        --- Norm: 0.058807815273
3        --- Norm: 0.004497500472
4        --- Norm: 0.001129354225
5        --- Norm: 0.000151145045
6        --- Norm: 0.000019428764
7        --- Norm: 0.000005662408
8        --- Norm: 0.000001192387
9        --- Norm: 0.000000287622
10        --- Norm: 0.000000096234
11        --- Norm: 0.000000017752
12        --- Norm: 0.000000000869
Cycle 1  E_tot = -56.4699499241113  dE = 56.4699
Polarizable embedding energy:  -0.29871471196218335
Expectation value of polarizable operator:  0.09680873613553222


Optimizer exited successfully:  True
VQE Energy: -56.0747318290178
VQE converged: True
        Running solver for induced moments.
0        --- Norm: 0.583085290933
1        --- Norm: 0.192439703471
        --- Turning on DIIS. ---
2        --- Norm: 0.058661144548
3        --- Norm: 0.009395427087
4        --- Norm: 0.002471454205
5        --- Norm: 0.000527553904
6        --- Norm: 0.000241516685
7        --- Norm: 0.000076836446
8        --- Norm: 0.000018242378
9        --- Norm: 0.000004062313
10        --- Norm: 0.000000548254
11        --- Norm: 0.000000113972
12        --- Norm: 0.000000020099
13        --- Norm: 0.000000003720
Cycle 2  E_tot = -56.4703163016152  dE = 0.000366378
Polarizable embedding energy:  -0.29873969323969307
Expectation value of polarizable operator:  0.09684477935774831




Final result:
Polarizable embedding energy:  -0.29873969323969307
Expectation value of polarizable operator:  0.09684477935774831
PE-VQE-UCCSD energy (L-BFGS-B)=  -56.47031630161524
Natural orbitals occupation number:  [ 2.00000000e+00  2.00000000e+00  1.99882491e+00  1.99430107e+00
  1.99358353e+00  6.17761982e-03  5.45028586e-03  1.66259058e-03
  5.47510085e-15  4.43704250e-15  2.80529359e-15 -7.25382351e-17
 -1.43637860e-15 -3.87660852e-15 -9.64429802e-15]
[10]:
print(cudaq.__version__)
CUDA-Q Version proto-0.8.0-developer (https://github.com/NVIDIA/cuda-quantum 6c1896cbdaa429589f24c9af2dc74d170ba0b006)