Simulate the variational quantum eigensolver (VQE)

In this guide, we show how to use ffsim to simulate the variational quantum eigensolver (VQE). Here we use the local unitary cluster Jastrow (LUCJ) ansatz to produce the variationally optimized quantum state, but the workflow is similar for other variational ansatzes in ffsim, and you can also define your own ansatz. We’ll use VQE to calculate an approximation to the ground state energy of a nitrogen molecule in an active space of 6 electrons in 6 orbitals derived from the STO-6G basis set.

First, let’s build the molecule.

[1]:
import pyscf
import pyscf.mcscf

import ffsim

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

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

# Get molecular data and molecular Hamiltonian (one- and two-body tensors)
scf = pyscf.scf.RHF(mol).run()
mol_data = ffsim.MolecularData.from_scf(scf, active_space=active_space)
norb = mol_data.norb
nelec = mol_data.nelec
mol_hamiltonian = mol_data.hamiltonian

# Compute FCI energy
cas = pyscf.mcscf.CASCI(scf, ncas=norb, nelecas=nelec).run()

print(f"FCI energy = {cas.e_tot}")
print(f"norb = {norb}")
print(f"nelec = {nelec}")

WARN: Unable to to identify input symmetry using original axes.
Different symmetry axes will be used.

converged SCF energy = -108.464957764796
CASCI E = -108.566842251942  E(CI) = -11.9110176528507  S^2 = 0.0000000
FCI energy = -108.5668422519418
norb = 6
nelec = (3, 3)

General UCJ ansatz

Since our molecule has a closed-shell Hartree-Fock state, we’ll use the spin-balanced variant of the UCJ ansatz, UCJOpSpinBalanced. We’ll initialize the ansatz from t2 amplitudes obtained from a CCSD calculations. We’ll first demonstrate the general UCJ ansatz, without adding the locality constraints of the LUCJ ansatz just yet.

The following code cell initializes the ansatz operator, applies it to the Hartree-Fock state, and computes the energy of the resulting state.

[2]:
import numpy as np
from pyscf import cc

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

# Construct UCJ operator
n_reps = 2
operator = ffsim.UCJOpSpinBalanced.from_t_amplitudes(ccsd.t2, t1=ccsd.t1, n_reps=n_reps)

# Construct the Hartree-Fock state to use as the reference state
reference_state = ffsim.hartree_fock_state(norb, nelec)

# Apply the operator to the reference state
ansatz_state = ffsim.apply_unitary(reference_state, operator, norb=norb, nelec=nelec)

# Compute the energy ⟨ψ|H|ψ⟩ of the ansatz state
hamiltonian = ffsim.linear_operator(mol_hamiltonian, norb=norb, nelec=nelec)
energy = np.real(np.vdot(ansatz_state, hamiltonian @ ansatz_state))
print(f"Energy at initialization: {energy}")
E(CCSD) = -108.5658290955831  E_corr = -0.1008713307875628
Energy at initialization: -108.55281964833281

To variationally optimize the ansatz, we’ll take advantage of methods for conversion to and from real-valued parameter vectors. In the following code cell, we define an objective function that takes a parameter vector as input and outputs the energy of the associated ansatz state. We then optimize this objective function using scipy.optimize.minimize, with an initial guess obtained from the operator we initialized previously from t2 amplitudes.

[3]:
import scipy.optimize


def fun(x):
    # Initialize the ansatz operator from the parameter vector
    operator = ffsim.UCJOpSpinBalanced.from_parameters(
        x, norb=norb, n_reps=n_reps, with_final_orbital_rotation=True
    )
    # Apply the ansatz operator to the reference state
    final_state = ffsim.apply_unitary(reference_state, operator, norb=norb, nelec=nelec)
    # Return the energy ⟨ψ|H|ψ⟩ of the ansatz state
    return np.real(np.vdot(final_state, hamiltonian @ final_state))


result = scipy.optimize.minimize(
    fun, x0=operator.to_parameters(), method="L-BFGS-B", options=dict(maxiter=10)
)

print(f"Number of parameters: {len(result.x)}")
print(result)
Number of parameters: 192
  message: STOP: TOTAL NO. OF ITERATIONS REACHED LIMIT
  success: False
   status: 1
      fun: -108.56163480065874
        x: [-4.312e-02 -3.748e-01 ...  8.562e-04 -1.327e-02]
      nit: 10
      jac: [-1.378e-04 -1.137e-05 ... -4.405e-05 -8.185e-04]
     nfev: 2509
     njev: 13
 hess_inv: <192x192 LbfgsInvHessProduct with dtype=float64>

LUCJ ansatz

Now, let’s add locality constraints to simulate the LUCJ ansatz. We’ll restrict same-spin interactions to a line topology, and opposite-spin interactions to those within the same spatial orbital. As explained in The local unitary cluster Jastrow (LUCJ) ansatz, these constraints allow the ansatz to be simulated directly on a square lattice.

In the following code cell, we demonstrate the optimization of the ansatz with these constraints imposed. Notice that with the same number of ansatz repetitions, the number of parameters of the ansatz has decreased from 88 to 62.

[4]:
pairs_aa = [(p, p + 1) for p in range(norb - 1)]
pairs_ab = [(p, p) for p in range(norb)]
interaction_pairs = (pairs_aa, pairs_ab)


def fun(x):
    operator = ffsim.UCJOpSpinBalanced.from_parameters(
        x,
        norb=norb,
        n_reps=n_reps,
        interaction_pairs=interaction_pairs,
        with_final_orbital_rotation=True,
    )
    final_state = ffsim.apply_unitary(reference_state, operator, norb=norb, nelec=nelec)
    return np.real(np.vdot(final_state, hamiltonian @ final_state))


result = scipy.optimize.minimize(
    fun,
    x0=operator.to_parameters(interaction_pairs=interaction_pairs),
    method="L-BFGS-B",
    options=dict(maxiter=10),
)
print(f"Number of parameters: {len(result.x)}")
print(result)
Number of parameters: 130
  message: STOP: TOTAL NO. OF ITERATIONS REACHED LIMIT
  success: False
   status: 1
      fun: -108.5396033658456
        x: [-4.171e-02 -3.753e-01 ...  2.966e-04 -1.317e-02]
      nit: 10
      jac: [ 1.421e-05  1.108e-04 ... -1.165e-04 -2.174e-04]
     nfev: 1703
     njev: 13
 hess_inv: <130x130 LbfgsInvHessProduct with dtype=float64>

Optimize with the linear method

ffsim includes an implementation of the “linear method” for optimization of a variational wavefunction. The linear method often converges faster than a standard optimization algorithm like L-BFGS-B. The interface is similar to that of scipy.optimize.minimize, the main difference being that instead of passing a callable that directly returns the function value to be optimized, you pass two objects: a callable that returns the wavefunction, and the Hamiltonian representing the energy to be optimized as a LinearOperator. The code cell below shows how to use the linear method to optimize the LUCJ ansatz from the previous example. It also shows how you can use an optional callback function to save intermediate results of the optimization.

[5]:
from collections import defaultdict

from ffsim.optimize import minimize_linear_method


# Define function that converts a list of parameters to the corresponding state vector
def params_to_vec(x: np.ndarray) -> np.ndarray:
    operator = ffsim.UCJOpSpinBalanced.from_parameters(
        x,
        norb=norb,
        n_reps=n_reps,
        interaction_pairs=interaction_pairs,
        with_final_orbital_rotation=True,
    )
    return ffsim.apply_unitary(reference_state, operator, norb=norb, nelec=nelec)


# Define a callback function used to save optimization information (this is optional)
info = defaultdict(list)


def callback(intermediate_result: scipy.optimize.OptimizeResult):
    # The callback function is called after each iteration. It accepts
    # an OptimizeResult object storing the parameters and function value at
    # the current iteration, and possibly other information
    info["x"].append(intermediate_result.x)
    info["fun"].append(intermediate_result.fun)
    if hasattr(intermediate_result, "jac"):
        info["jac"].append(intermediate_result.jac)
    if hasattr(intermediate_result, "regularization"):
        info["regularization"].append(intermediate_result.regularization)
    if hasattr(intermediate_result, "variation"):
        info["variation"].append(intermediate_result.variation)


# Optimize with the linear method
result = minimize_linear_method(
    params_to_vec,
    hamiltonian,
    x0=operator.to_parameters(interaction_pairs=interaction_pairs),
    maxiter=10,
    callback=callback,
)

# Print some information
print(f"Number of parameters: {len(result.x)}")
print(result)
print()
for i, (fun, jac, regularization, variation) in enumerate(
    zip(info["fun"], info["jac"], info["regularization"], info["variation"])
):
    print(f"Iteration {i + 1}")
    print(f"    Energy: {fun}")
    print(f"    Norm of gradient: {np.linalg.norm(jac)}")
    print(f"    Regularization hyperparameter: {np.linalg.norm(regularization)}")
    print(f"    Variation hyperparameter: {np.linalg.norm(variation)}")
Number of parameters: 130
 message: Stop: Total number of iterations reached limit.
 success: False
     fun: -108.56020361751523
       x: [ 3.968e-01 -3.701e-01 ...  3.826e-02 -1.851e-01]
     nit: 10
     jac: [-2.832e-04 -1.013e-03 ... -6.729e-04  5.070e-04]
    nfev: 2965
    njev: 10
  nlinop: 1665

Iteration 1
    Energy: -108.53955213408909
    Norm of gradient: 0.012814843184578357
    Regularization hyperparameter: 0.006634444343676385
    Variation hyperparameter: 0.9918889655780383
Iteration 2
    Energy: -108.53997291431367
    Norm of gradient: 0.01508979742043413
    Regularization hyperparameter: 0.002357909476091514
    Variation hyperparameter: 0.9918906480798517
Iteration 3
    Energy: -108.54225429170882
    Norm of gradient: 0.04662270537186717
    Regularization hyperparameter: 0.003142367001812675
    Variation hyperparameter: 0.9918899876862565
Iteration 4
    Energy: -108.54636322907481
    Norm of gradient: 0.025468036687531486
    Regularization hyperparameter: 0.01339993092110243
    Variation hyperparameter: 0.9921877149578111
Iteration 5
    Energy: -108.54874819291209
    Norm of gradient: 0.07803709646795061
    Regularization hyperparameter: 0.005778786583648525
    Variation hyperparameter: 0.9921906592794734
Iteration 6
    Energy: -108.55321498037314
    Norm of gradient: 0.08101732007321319
    Regularization hyperparameter: 0.0045198959064543165
    Variation hyperparameter: 0.9921921479569982
Iteration 7
    Energy: -108.55636731194315
    Norm of gradient: 0.059471422091195485
    Regularization hyperparameter: 0.0031427289219741776
    Variation hyperparameter: 0.992193292428939
Iteration 8
    Energy: -108.55813629270328
    Norm of gradient: 0.032610134385834134
    Regularization hyperparameter: 0.002134273498708646
    Variation hyperparameter: 0.9921910109837693
Iteration 9
    Energy: -108.55926896794846
    Norm of gradient: 0.04176649445871963
    Regularization hyperparameter: 0.0017993137332465827
    Variation hyperparameter: 0.9921905929224202