VQE Implementation in PyQuil
Implement the Variational Quantum Eigensolver (VQE) in PyQuil to find the ground state energy of molecular hydrogen. Build the Hamiltonian, parameterized ansatz, and classical optimization loop.
Circuit diagrams
The Variational Quantum Eigensolver (VQE) is one of the most important near-term quantum algorithms. It uses a quantum computer to prepare trial wavefunctions and measure energies, while a classical optimizer tunes the circuit parameters to minimize the energy. This hybrid approach is well-suited to NISQ hardware because it uses relatively shallow circuits and can tolerate moderate amounts of noise.
In this tutorial, you will implement VQE from scratch in PyQuil to find the ground state energy of molecular hydrogen (H2).
Prerequisites
You should be comfortable with PyQuil program construction, the QVM, and basic quantum mechanics (Hamiltonians, expectation values, the variational principle). You will also need scipy for the classical optimizer.
pip install pyquil numpy scipy matplotlib
The Variational Principle
The variational principle states that for any trial wavefunction |psi(theta)>, the expectation value of the Hamiltonian is always greater than or equal to the true ground state energy:
E(theta) = <psi(theta)| H |psi(theta)> >= E_ground
VQE exploits this by parameterizing a quantum circuit with angles theta, measuring the expectation value of H, and using a classical optimizer to find the theta that minimizes E(theta).
Step 1: Define the H2 Hamiltonian
The electronic Hamiltonian of H2 at equilibrium bond length (0.735 Angstroms) can be mapped to a qubit operator using techniques like the Jordan-Wigner transformation. For a minimal basis (STO-3G), the result is a sum of Pauli operators acting on two qubits.
from pyquil.paulis import PauliSum, sI, sX, sY, sZ
# H2 Hamiltonian at equilibrium bond length (0.735 Angstroms)
# in the STO-3G basis with Jordan-Wigner mapping.
# These coefficients come from quantum chemistry calculations.
H2_hamiltonian = (
-0.4804 * sI(0)
+ 0.3435 * sZ(0)
- 0.4347 * sZ(1)
+ 0.5716 * sZ(0) * sZ(1)
+ 0.0910 * sX(0) * sX(1)
+ 0.0910 * sY(0) * sY(1)
)
print("H2 Hamiltonian:")
print(H2_hamiltonian)
print(f"\nNumber of terms: {len(H2_hamiltonian)}")
Expected Output
H2 Hamiltonian:
(-0.4804+0j)*I + (0.3435+0j)*Z0 + (-0.4347+0j)*Z1 + (0.5716+0j)*Z0*Z1 + (0.091+0j)*X0*X1 + (0.091+0j)*Y0*Y1
Number of terms: 6
Each term in the Hamiltonian is a tensor product of Pauli operators. The identity term is a constant energy offset. The Z terms represent single-qubit energies, and the ZZ, XX, YY terms represent interactions between the two qubits (corresponding to electron-electron interactions in the molecule).
Step 2: Build the Parameterized Ansatz
The ansatz is a parameterized quantum circuit that prepares the trial wavefunction. For H2 with two qubits, a common choice is the UCCSD-inspired ansatz with a single parameter.
import numpy as np
from pyquil import Program
from pyquil.gates import RX, RY, RZ, CNOT, H as H_gate, MEASURE
from pyquil.quilbase import Declare
def build_ansatz(theta):
"""
Build a hardware-efficient ansatz for H2 with one variational parameter.
This prepares a state in the form:
cos(theta/2)|01> + sin(theta/2)|10>
which spans the relevant subspace for the H2 ground state.
"""
p = Program()
# Prepare |01> as the reference (Hartree-Fock) state
p += RX(np.pi, 0) # Flip qubit 0 to |1>
# Parameterized entangling block
p += RY(theta, 0)
p += CNOT(0, 1)
return p
# Test with theta = 0
test_prog = build_ansatz(0.5)
print("Ansatz circuit:")
print(test_prog)
Step 3: Measure Expectation Values
To compute <psi|H|psi>, we measure the expectation value of each Pauli term separately. Each Pauli string requires a different measurement basis rotation.
from pyquil import get_qc
from pyquil.gates import H as H_gate, RX, MEASURE
def measure_pauli_term(qc, ansatz, pauli_term, n_shots=10000):
"""
Measure the expectation value of a single Pauli term.
Applies basis rotations before measurement.
"""
coefficient = pauli_term.coefficient.real
# Identity term: expectation value is just the coefficient
if not pauli_term.operations_as_set():
return coefficient
p = ansatz.copy()
ro = p.declare('ro', 'BIT', 2)
# Track which qubits to measure
qubits_to_measure = []
# Apply basis rotation for each qubit in the Pauli term
for qubit, gate_name in pauli_term._ops.items():
if gate_name == 'X':
# Rotate from X basis to Z basis: apply H
p += H_gate(qubit)
qubits_to_measure.append(qubit)
elif gate_name == 'Y':
# Rotate from Y basis to Z basis: apply RX(-pi/2)
p += RX(-np.pi / 2, qubit)
qubits_to_measure.append(qubit)
elif gate_name == 'Z':
# Z basis: no rotation needed
qubits_to_measure.append(qubit)
# Measure all relevant qubits
for qubit in qubits_to_measure:
p += MEASURE(qubit, ro[qubit])
p.wrap_in_numshots_loop(n_shots)
# Run on QVM
result = qc.run(qc.compile(p))
bitstrings = result.readout_data['ro']
# Compute expectation value
# Each measurement outcome maps to eigenvalue: 0 -> +1, 1 -> -1
eigenvalues = 1 - 2 * bitstrings[:, qubits_to_measure]
# Product of eigenvalues for multi-qubit Pauli terms
parity = np.prod(eigenvalues, axis=1)
expectation = np.mean(parity)
return coefficient * expectation
def measure_hamiltonian(qc, ansatz, hamiltonian, n_shots=10000):
"""
Measure the full Hamiltonian expectation value
by summing over all Pauli terms.
"""
energy = 0.0
for term in hamiltonian:
energy += measure_pauli_term(qc, ansatz, term, n_shots)
return energy
Step 4: The Classical Optimization Loop
Now we tie everything together. The classical optimizer calls the quantum circuit repeatedly, adjusting theta to minimize the energy.
from scipy.optimize import minimize
# Get a 2-qubit QVM
qc = get_qc('2q-qvm')
# Track optimization history
energy_history = []
def objective(params):
"""Objective function for the classical optimizer."""
theta = params[0]
ansatz = build_ansatz(theta)
energy = measure_hamiltonian(qc, ansatz, H2_hamiltonian, n_shots=10000)
energy_history.append(energy)
print(f" theta = {theta:.4f}, energy = {energy:.6f} Ha")
return energy
# Initial parameter guess
theta_init = np.array([0.0])
# Run the optimizer
print("Starting VQE optimization...\n")
result = minimize(
objective,
theta_init,
method='COBYLA',
options={'maxiter': 50, 'rhobeg': 0.5},
)
print(f"\nOptimization complete!")
print(f"Optimal theta: {result.x[0]:.4f}")
print(f"Minimum energy: {result.fun:.6f} Ha")
print(f"Exact ground state energy: -1.1373 Ha")
print(f"Error: {abs(result.fun - (-1.1373)):.6f} Ha")
Expected Output
Starting VQE optimization...
theta = 0.0000, energy = -0.4926 Ha
theta = 0.5000, energy = -0.8753 Ha
theta = 1.0000, energy = -1.0614 Ha
...
theta = 0.5934, energy = -1.1351 Ha
Optimization complete!
Optimal theta: 0.5934
Minimum energy: -1.1351 Ha
Exact ground state energy: -1.1373 Ha
Error: 0.0022 Ha
The VQE converges to within chemical accuracy (about 1.6 milliHartree) of the exact ground state energy. The small remaining error comes from shot noise in the expectation value estimation.
Step 5: Visualize the Energy Landscape
Since we have only one parameter, we can plot the full energy landscape.
import matplotlib.pyplot as plt
# Sweep theta and compute energy
thetas = np.linspace(-np.pi, np.pi, 50)
energies = []
for theta in thetas:
ansatz = build_ansatz(theta)
energy = measure_hamiltonian(qc, ansatz, H2_hamiltonian, n_shots=5000)
energies.append(energy)
plt.figure(figsize=(10, 6))
plt.plot(thetas, energies, 'b-o', markersize=3, label='VQE Energy')
plt.axhline(y=-1.1373, color='r', linestyle='--', label='Exact Ground State')
plt.xlabel('theta (radians)')
plt.ylabel('Energy (Hartree)')
plt.title('VQE Energy Landscape for H2')
plt.legend()
plt.grid(True)
plt.savefig('vqe_landscape.png')
plt.show()
The Parameter Shift Rule for Gradients
The COBYLA optimizer used above is gradient-free. For gradient-based optimizers, you can compute exact gradients of quantum expectation values using the parameter shift rule. For a gate of the form exp(-i * theta/2 * G) where G is a Pauli generator:
dE/dtheta = (E(theta + pi/2) - E(theta - pi/2)) / 2
This requires two circuit evaluations per parameter per gradient component.
def parameter_shift_gradient(qc, hamiltonian, theta, n_shots=10000):
"""
Compute the gradient of the energy with respect to theta
using the parameter shift rule.
"""
shift = np.pi / 2
# Evaluate at theta + shift
ansatz_plus = build_ansatz(theta + shift)
energy_plus = measure_hamiltonian(qc, ansatz_plus, hamiltonian, n_shots)
# Evaluate at theta - shift
ansatz_minus = build_ansatz(theta - shift)
energy_minus = measure_hamiltonian(qc, ansatz_minus, hamiltonian, n_shots)
gradient = (energy_plus - energy_minus) / 2.0
return gradient
# Example: compute gradient at the initial point
grad = parameter_shift_gradient(qc, H2_hamiltonian, 0.0)
print(f"Gradient at theta=0: {grad:.4f}")
The parameter shift rule is especially valuable because it gives unbiased gradient estimates that work on real hardware, unlike finite-difference methods that suffer from shot noise amplification.
Using Memory References for Parameterized Circuits
For production VQE implementations, you should use PyQuil’s memory references instead of rebuilding the circuit at each iteration. This allows the compiled circuit to be reused, saving compilation time.
from pyquil.quilbase import Declare
def build_parameterized_ansatz():
"""
Build an ansatz using DECLARE'd memory for parameters.
The circuit is compiled once and reused with different parameter values.
"""
p = Program()
theta = p.declare('theta', 'REAL', 1)
p += RX(np.pi, 0)
p += RY(theta[0], 0)
p += CNOT(0, 1)
return p, theta
# Build and compile once
param_ansatz, theta_ref = build_parameterized_ansatz()
ro = param_ansatz.declare('ro', 'BIT', 2)
param_ansatz += MEASURE(0, ro[0])
param_ansatz += MEASURE(1, ro[1])
param_ansatz.wrap_in_numshots_loop(10000)
compiled = qc.compile(param_ansatz)
# Run with different parameter values without recompiling
for theta_val in [0.0, 0.5, 1.0]:
result = qc.run(compiled, memory_map={'theta': [theta_val]})
bitstrings = result.readout_data['ro']
print(f"theta={theta_val:.1f}: {dict(Counter(map(tuple, bitstrings.tolist())))}")
This pattern is critical for performance when running VQE with many optimization iterations, especially when targeting real QPU hardware where compilation is expensive.
Summary
You have implemented a complete VQE workflow in PyQuil: defining a molecular Hamiltonian as a PauliSum, building a parameterized ansatz circuit, measuring expectation values term-by-term with basis rotations, and running a classical optimization loop to find the ground state energy of H2. You also learned about the parameter shift rule for computing quantum gradients and how to use memory references for efficient parameterized execution. These building blocks extend directly to larger molecules and more complex ansatze, forming the foundation of variational quantum chemistry on near-term hardware.
Was this tutorial helpful?