Møller–Plesset partitioning#

Using Rayleigh–Schrödinger perturbation theory (RSPT), the Hamiltonian can be partitioned according to the Møller–Plesset (MP) scheme [HJO14, SB09, SO12]

\[\begin{equation*} \hat{H} = \hat{F} + \hat{\Phi} \end{equation*}\]

where the zeroth-order Hamiltonian, \(\hat{F}\), is the Fock operator and the perturbation, \(\hat{\Phi}\), is known as the fluctuation potential.

The exact solutions to the zeroth-order Hamiltonian are found from the time-independent Schrödinger equation

\[\begin{align*} \hat{F} | \Phi_n \rangle &= {\cal E}_n | \Phi_n \rangle \end{align*}\]

The exact state solutions to this equation are given by the Hartree–Fock state and its excited determinants

\[\begin{align*} | \Phi_0 \rangle & = | \Psi_\mathrm{HF} \rangle \\ | \Phi_n \rangle & = | \Psi_{ij\ldots}^{ab\ldots} \rangle; \quad n > 0 \end{align*}\]

and, in terms of orbital energies, the associated exact energies are equal to

\[\begin{align*} {\cal E}_0 & = \sum_{i}^\mathrm{occ} \varepsilon_{i} \\ {\cal E}^{ab\cdots}_{ij\cdots} &= \varepsilon_{a} + \varepsilon_{b} + \ldots - \varepsilon_{i} - \varepsilon_{j} - \ldots \\ \end{align*}\]

Perturbation expansion in the fluctuation potential#

The exact ground state is a solution to

\[ \hat{H} | \Psi \rangle = E | \Psi \rangle \]

where \(| \Psi \rangle\) and \(E\) are expanded in orders of the perturbation

\[ | \Psi \rangle = \sum_{k=0}^\infty | \Psi^{(k)} \rangle; \qquad E = \sum_{k=0}^\infty E^{(k)} \]

From RSPT, we get

\[\begin{align*} E^{(0)} & = {\cal E}_0 \\ E^{(1)} & = \langle \Psi_\mathrm{HF} | \hat{\Phi} | \Psi_\mathrm{HF} \rangle = -\frac{1}{2} \sum_{i,j}^\mathrm{occ} \langle ij \| ij \rangle \end{align*}\]

so that

\[ E_{\mathrm{HF}} = E^{(0)} + E^{(1)} \]

The first-order correction to the wave function is obtained from the RSPT master equation

\[ | \Psi^{(1)} \rangle = -\frac{1}{4} \sum_{i,j}^\mathrm{occ} \sum_{a,b}^\mathrm{virt} t_{ij}^{ab} | \Psi_{ij}^{ab} \rangle ; \qquad t_{ij}^{ab} = \frac{\langle ab \| ij \rangle}{{\cal E}_{ij}^{ab}} \]

and, in terms of the introduced \(t\)-amplitudes, we thereby get a second-order energy correction equal to

\[ E^{(2)} = - \frac{1}{4} \sum_{i,j}^\mathrm{occ} \sum_{a,b}^\mathrm{virt} t_{ij}^{ab} \langle ij || ab \rangle \]

such that

\[ E_{\mathrm{MP2}} = E_{\mathrm{HF}} + E^{(2)} \]

Closed-shell reference state#

For a closed-shell Hartree–Fock state, the MP2 energy correction can be written in terms of sums referring to molecular orbitals (MOs)

\[ E^{(2)} = E_{\mathrm{os}}^{(2)} + E_{\mathrm{ss}}^{(2)} \]


\[\begin{align*} E_{\mathrm{os}}^{(2)} & = - \sum_{i,j}^\mathrm{occ} \sum_{a,b}^\mathrm{virt} \frac{(ia|jb)(ia|jb)}{{\cal E}_{ij}^{ab}} \\ E_{\mathrm{ss}}^{(2)} & = - \sum_{i,j}^\mathrm{occ} \sum_{a,b}^\mathrm{virt} \frac{(ia|jb)}{{\cal E}_{ij}^{ab}} \big[ (ia|jb) - (ib|ja) \big] \\ \end{align*}\]

The chemist’s notation has here been used for the integrals.


Let us illustrate the theory with a calculation of the MP2 energy of water.

import veloxchem as vlx
h2o_xyz = """3
O    0.000000000000        0.000000000000        0.000000000000                         
H    0.000000000000        0.740848095288        0.582094932012                         
H    0.000000000000       -0.740848095288        0.582094932012

molecule = vlx.Molecule.read_xyz_string(h2o_xyz)
basis = vlx.MolecularBasis.read(molecule, "cc-pvdz", ostream=None)

nocc = molecule.number_of_alpha_electrons()
nvirt = basis.get_dimension_of_basis(molecule) - nocc

print("Number of occupied orbitals:", nocc)
print("Number of unoccupied orbitals:", nvirt)
Number of occupied orbitals: 5
Number of unoccupied orbitals: 19

You appear to be running in JupyterLab (or JavaScript failed to load for some other reason). You need to install the 3dmol extension:
jupyter labextension install jupyterlab_3dmol

Hartree–Fock reference state#

scf_drv = vlx.ScfRestrictedDriver()
scf_results = scf_drv.compute(molecule, basis)

We obtain the orbital energies.

E = scf_results["E"]

Integrals in the MO basis#

We create an instance of the MOIntegralsDriver class to get the electron repulsion integrals in the MO basis and in the chemist’s notation.

erimo_drv = vlx.MOIntegralsDriver()

ovov = erimo_drv.compute_in_memory(molecule, basis, scf_drv.mol_orbs, "chem_ovov")
print("(ov|ov):", ovov.shape)
(ov|ov): (5, 19, 5, 19)

The MP2 energy correction#

We now have all the ingredients to compute the opposite-spin and same-spin components of the MP2 energy correction.

e_mp2_ss = 0.0
e_mp2_os = 0.0

# extract the occupied subset of the orbital energies
e_o = E[:nocc]
# extract the virtual subset of the orbital energies
e_v = E[nocc:]

for i in range(nocc):
    for j in range(nocc):
        for a in range(nvirt):
            for b in range(nvirt):
                # enegy denominators
                e_ijab = e_v[a] + e_v[b] - e_o[i] - e_o[j]

                # update opposite-spin component of the energy
                e_mp2_os -= (ovov[i, a, j, b] * ovov[i, a, j, b]) / e_ijab

                # update same-spin component of the energy
                e_mp2_ss -= (
                    ovov[i, a, j, b] * (ovov[i, a, j, b] - ovov[i, b, j, a]) / e_ijab
print(f"Opposite-spin MP2 energy: {e_mp2_os:12.8f}")
print(f"Same-spin MP2 energy    : {e_mp2_ss:12.8f}")
print(f"MP2 energy              : {e_mp2_os + e_mp2_ss:12.8f}")
Opposite-spin MP2 energy:  -0.15163083
Same-spin MP2 energy    :  -0.05138187
MP2 energy              :  -0.20301270

Let us compare this result with the MP2 energy obtained with VeloxChem.

mp2_drv = vlx.Mp2Driver()

mp2_results = mp2_drv.compute_conventional(molecule, basis, scf_drv.mol_orbs)
print(f"Energy difference: {mp2_results['mp2_energy']:12.8f}")
Energy difference:  -0.20301270

We note that the two results are in perfect agreement.

Size consistency#

We see that to second order, Møller-Plesset perturbation theory only involves up to double excitations from the HF reference. It would thus be natural to consider it an approximation to CISD, and expect it to suffer from the same issue, namely a lack of size consistency. Yet this is not the case as discussed in the RSPT section on this topic.

dimer_xyz = """6
2 water 100 Å apart                                                                                                            
O    0.000000000000        0.000000000000        0.000000000000                         
H    0.000000000000        0.740848095288        0.582094932012                         
H    0.000000000000       -0.740848095288        0.582094932012
O  100.000000000000        0.000000000000        0.000000000000                         
H  100.000000000000        0.740848095288        0.582094932012                         
H  100.000000000000       -0.740848095288        0.582094932012

molecule = vlx.Molecule.read_xyz_string(dimer_xyz)
basis = vlx.MolecularBasis.read(molecule, "cc-pvdz", ostream=None)

You appear to be running in JupyterLab (or JavaScript failed to load for some other reason). You need to install the 3dmol extension:
jupyter labextension install jupyterlab_3dmol

scf_results = scf_drv.compute(molecule, basis)

mp2_dimer_results = mp2_drv.compute_conventional(molecule, basis, scf_drv.mol_orbs)
print("=" * 30)
print("MP2 energies")
print("-" * 30)
print(f"Dimer       : {mp2_dimer_results['mp2_energy']:16.10f}")
print(f"Two monomers: {2 * mp2_results['mp2_energy']:16.10f}")
print("=" * 30)
MP2 energies
Dimer       :    -0.4060254140
Two monomers:    -0.4060254051

We can see that the two energies match. MP2 is size consistent! So why is it behaving better than CISD in this aspect?

The key is that in MP2, the coefficients of the excited determinants are independent of the system size. Thus a molecule would have the same MP2 energy correction regardless of the presence or not of another, non-interacting, molecule. By contrast, in CISD, the coefficients depend on the system size through normalization, lowering the weight of these determinants as the size of the system increases.

Taylor expansion of the energy#

Using RSPT, it is a common practice to use an order parameter \(\lambda\) for the perturbation

\[ \hat{H}(\lambda) = \hat{H_0} + \lambda \hat{V} \]

Each order of the perturbation then corresponds to a power of \(\lambda\)

\[ E(\lambda) = E^{(0)} + \lambda E^{(1)} + \lambda^2 E^{(2)} + \ldots \]

The RSPT equations are found by collecting terms in powers of \(\lambda\) and, at the end, set \(\lambda\) equal to 1 to recover the correct Hamiltonian. Taking this approach, it is seen that RSPT relates to Taylor expansions. We will here demonstrate that

\[ E_\mathrm{FCI}(\lambda) = \sum_{i=0}^\infty \lambda^i E_{\mathrm{MP}}^{(i)} \]

or, in other words, the \(n\)th-order MP energy correction is given by

\[ E_{\mathrm{MP}}^{(n)} = \frac{1}{n!} \left. \frac{d^n E_\mathrm{FCI}(\lambda)}{d \lambda^n} \right|_{\lambda=0} \]

The total MP energy to order \(n\) is a sum of the corrections

\[ E_{\mathrm{MPn}} = \sum_{i=0}^n E_{\mathrm{MP}}^{(i)} \]

We will explicitly create \(\hat{H}(\lambda)\) and determine the FCI energy. This allows us to determine the MP2 energy by means of numerical differentiation to second order with respect to \(\lambda\).

First, we determine the Hartree–Fock state of water in the 6-31G basis set.

molecule = vlx.Molecule.read_xyz_string(h2o_xyz)
basis = vlx.MolecularBasis.read(molecule, "6-31G", ostream=None)

scf_drv = vlx.ScfRestrictedDriver()
scf_results = scf_drv.compute(molecule, basis)
# MO coefficients
C = scf_results["C"]

# orbital energies
E = scf_results["E"]

Second, we create \(\hat{H_0} = \hat{F}\) and \(\hat{V} = (\hat{H} - \hat{F})_{\lambda=1}\) in the MO basis. The perturbation has a one-electron as well as a two-electron part that we will keep separate

\[ \hat{V} = \hat{V}_1 + \hat{V}_2 \]
import numpy as np

F = np.diag(E)

# Get the core Hamiltonian in MO basis
kin_drv = vlx.KineticEnergyIntegralsDriver()
T_ao = kin_drv.compute(molecule, basis).to_numpy()

npot_drv = vlx.NuclearPotentialIntegralsDriver()
V_nucpot_ao = -npot_drv.compute(molecule, basis).to_numpy()

h = np.einsum("ai, ab, bj -> ij", C, T_ao + V_nucpot_ao, C)

# Compute the 2-electron integrals
eri_drv = vlx.ElectronRepulsionIntegralsDriver()
g_ao = eri_drv.compute_in_memory(molecule, basis)

g_1 = np.einsum("ds, abcd -> abcs", C, g_ao)
g_2 = np.einsum("cr, abcs -> abrs", C, g_1)
g_3 = np.einsum("bq, abrs -> aqrs", C, g_2)

g = np.einsum("ap, aqrs -> pqrs", C, g_3)
H0 = F

V_1 = h - F
V_2 = g

Third, we use MultiPsi to determine the FCI energies.

import multipsi as mtp

ci_drv = mtp.CIDriver()

space = mtp.OrbSpace(molecule, scf_drv.mol_orbs)

Determine \(E_\mathrm{FCI}(\lambda)\) and calculate the second derivative with use of a 3-point stencil.

E_FCI = []

for lambda_val in [-0.01, 0, 0.01]:

        molecule.nuclear_repulsion_energy(), H0 + lambda_val * V_1, lambda_val * V_2

    ci_results = ci_drv.compute(molecule, basis, space, 1)

E_MP2 = 0.5 * (E_FCI[0] + E_FCI[2] - 2 * E_FCI[1]) / 0.01**2
print(f"MP2 energy correction:{E_MP2:12.8f}")
MP2 energy correction: -0.12747116

Perform a reference calculation using the MP2 module in VeloxChem.

mp2_results = mp2_drv.compute(molecule, basis, scf_drv.mol_orbs)
print(f"MP2 energy correction:{mp2_results['mp2_energy']:12.8f}")
MP2 energy correction: -0.12747066

We note the the two results are in perfect agreement. The small discrepancy is due to the use of numerical differentiation in the former case.