Qiskit Advanced Free 41/61 in series 18 min read By ·

Molecular Simulation with OpenFermion and Qiskit

Build a complete quantum chemistry pipeline: encode the H2 Hamiltonian with OpenFermion, transform it to qubits via Jordan-Wigner, then find the ground state energy using VQE on Qiskit's statevector simulator.

Prerequisites

  • Strong Python skills
  • Solid quantum computing foundations
  • Linear algebra and complex numbers
In this guide11 sections
  1. 01Why Quantum Chemistry is a Natural Quantum Computing Application
  2. 02Second Quantization: The Language of Quantum Chemistry
  3. 03The Jordan-Wigner Transformation
  4. 04Installing the Tools
  5. 05Building the H2 Hamiltonian
  6. 06Applying the Jordan-Wigner Transformation
  7. 07Converting to Qiskit’s SparsePauliOp
  8. 08Designing the Ansatz
  9. 09Running VQE with the Estimator Primitive
  10. 10Comparing with the Exact Solution
  11. 11What Comes Next

Why Quantum Chemistry is a Natural Quantum Computing Application

Simulating molecules on a classical computer is exponentially hard. The exact wavefunction of a molecule with N electrons lives in a Hilbert space of dimension 2^N; you need to store and manipulate exponentially large vectors. Methods like full configuration interaction (FCI) scale as O(N! / (k!(N-k)!)) in the number of basis functions, quickly becoming intractable beyond a dozen electrons.

Quantum computers offer a natural encoding: map each molecular orbital to a qubit. The quantum state of N qubits has 2^N amplitudes, exactly the right representation for N electrons in N orbitals. Quantum simulation of chemistry was, in fact, the original motivation Feynman had in mind when he proposed quantum computing.

Second Quantization: The Language of Quantum Chemistry

Classical quantum chemistry uses the wavefunction picture: track every electron explicitly. Second quantization reformulates this in terms of creation and annihilation operators acting on a vacuum state.

A creation operator a†_p adds an electron to orbital p. An annihilation operator a_p removes one. The molecular Hamiltonian in second quantization is:

H = sum_{pq} h_{pq} a†_p a_q + 0.5 * sum_{pqrs} h_{pqrs} a†_p a†_q a_r a_s

where h_{pq} are one-electron integrals (kinetic energy + nuclear attraction) and h_{pqrs} are two-electron repulsion integrals. These integrals come from classical quantum chemistry packages like PySCF or PSI4.

The advantage: the Hamiltonian is now expressed in a fixed-size algebraic form regardless of how many electrons are present. The complication: fermionic operators obey anticommutation relations, not the commutation relations natural to qubits.

The Jordan-Wigner Transformation

To run quantum chemistry on a qubit processor, you must map fermionic operators to qubit operators. The Jordan-Wigner (JW) transformation is the most common choice:

a_j = (tensor of Z's from qubit 0 to j-1) x (X_j + i Y_j) / 2

Each fermionic mode maps to one qubit. The occupation of orbital j is encoded in the |0> or |1> state of qubit j. The string of Z operators to the left of position j enforces the fermionic anticommutation statistics; this is the price of embedding fermionic algebra into spin algebra.

The result is a sum of Pauli strings: terms like X0 Z1 Y2 I3, each with a real coefficient. This Pauli sum form is exactly what VQE needs to compute expectation values.

Installing the Tools

pip install openfermion qiskit qiskit-aer qiskit-algorithms scipy

For computing molecular integrals from scratch you would also install pyscf and openfermionpyscf, but for this tutorial we hardcode the H2 Hamiltonian integrals at a fixed bond length to keep dependencies minimal.

Building the H2 Hamiltonian

Hydrogen molecule (H2) is the canonical benchmark. At a bond length of 0.74 angstroms, the STO-3G basis gives a 2-spatial-orbital (2-qubit after JW) Hamiltonian when using spatial-orbital integrals. The integrals below are for the two spatial orbitals (bonding and antibonding MOs); spin degrees of freedom are folded in via the two-body tensor.

import openfermion as of
import numpy as np

# H2 molecular integrals at R=0.74 angstrom, STO-3G basis
# (precomputed via PySCF / openfermion-pyscf)
n_orbitals = 2
n_electrons = 2

# One-body integrals (alpha spin)
one_body_coefficients = np.array([
    [-1.2563, 0.0],
    [ 0.0,   -0.4718]
])

# Two-body integrals (physicists' notation: <pq|rs>)
two_body_coefficients = np.zeros((2, 2, 2, 2))
two_body_coefficients[0, 0, 0, 0] = 0.6757
two_body_coefficients[1, 1, 1, 1] = 0.6986
two_body_coefficients[0, 0, 1, 1] = 0.6645
two_body_coefficients[1, 1, 0, 0] = 0.6645
two_body_coefficients[0, 1, 1, 0] = 0.1809
two_body_coefficients[1, 0, 0, 1] = 0.1809

# Build the FermionOperator Hamiltonian
molecular_hamiltonian = of.InteractionOperator(
    constant=0.7151,
    one_body_tensor=one_body_coefficients,
    two_body_tensor=0.5 * two_body_coefficients
)

fermionic_hamiltonian = of.get_fermion_operator(molecular_hamiltonian)
print("Fermionic Hamiltonian terms (first 5):")
for term, coeff in list(fermionic_hamiltonian.terms.items())[:5]:
    print(f"  {coeff:.4f}  {term}")

The FermionOperator represents the Hamiltonian as a sum of products of creation and annihilation operators. The constant 0.7151 Hartree is the nuclear repulsion energy for H2 at 0.74 angstroms.

Applying the Jordan-Wigner Transformation

# Requires: openfermion
# Transform to qubit operators
qubit_hamiltonian = of.jordan_wigner(fermionic_hamiltonian)

print(f"\nNumber of Pauli terms after JW: {len(qubit_hamiltonian.terms)}")
print("\nQubit Hamiltonian (first 8 terms):")
for term, coeff in list(qubit_hamiltonian.terms.items())[:8]:
    if abs(coeff) > 1e-6:
        pauli_string = " ".join(f"{op}_{idx}" for idx, op in term) if term else "I"
        print(f"  {coeff.real:+.4f}  {pauli_string}")

H2 in STO-3G with spatial-orbital integrals produces 4 Pauli terms after JW transformation acting on 2 qubits. The terms are diagonal (all Z or identity) because the spatial-orbital Hamiltonian has no off-diagonal hopping at this level of representation. A full spin-orbital treatment would require 4 qubits and ~15 Pauli terms including X and Y contributions from the exchange integrals.

Converting to Qiskit’s SparsePauliOp

Qiskit uses SparsePauliOp internally. We convert from OpenFermion’s format:

# Requires: openfermion
from qiskit.quantum_info import SparsePauliOp

def openfermion_to_qiskit(qubit_op, n_qubits):
    """Convert OpenFermion QubitOperator to Qiskit SparsePauliOp."""
    pauli_list = []
    for term, coeff in qubit_op.terms.items():
        if abs(coeff) < 1e-10:
            continue
        # Build the Pauli string (Qiskit uses reversed qubit ordering)
        pauli_chars = ["I"] * n_qubits
        for qubit_idx, pauli_char in term:
            pauli_chars[n_qubits - 1 - qubit_idx] = pauli_char
        pauli_str = "".join(pauli_chars)
        pauli_list.append((pauli_str, coeff.real))

    return SparsePauliOp.from_list(pauli_list)

n_qubits = of.count_qubits(qubit_hamiltonian)
qiskit_hamiltonian = openfermion_to_qiskit(qubit_hamiltonian, n_qubits)
print(f"\nConverted to SparsePauliOp with {len(qiskit_hamiltonian)} terms")
print(qiskit_hamiltonian)

Note the qubit ordering reversal: OpenFermion indexes qubits from left to right, while Qiskit uses the convention where qubit 0 is the rightmost character in a Pauli string.

Designing the Ansatz

For H2 with 2 spatial orbitals mapped to 2 qubits, a two-parameter hardware-efficient ansatz that spans the relevant subspace is:

from qiskit.circuit import QuantumCircuit, ParameterVector

def h2_ansatz():
    """
    Two-parameter ansatz for H2 ground state (2-qubit, spatial-orbital basis).
    Qubit 0 represents the bonding MO; qubit 1 the antibonding MO.
    """
    theta = ParameterVector("theta", 2)
    qc = QuantumCircuit(2)

    # Prepare reference state with one electron in the bonding orbital
    qc.x(0)

    # Parametric rotations to explore superposition
    qc.ry(theta[0], 0)
    qc.ry(theta[1], 1)

    # Entanglement layer
    qc.cx(0, 1)

    return qc

ansatz = h2_ansatz()
print(ansatz.draw("text"))
print(f"\nNumber of parameters: {ansatz.num_parameters}")

Running VQE with the Estimator Primitive

# Requires: openfermion
from qiskit_algorithms import VQE
from qiskit_algorithms.optimizers import COBYLA, SLSQP
from qiskit.primitives import StatevectorEstimator

# Statevector estimator gives exact expectation values (no shot noise)
estimator = StatevectorEstimator()

optimizer = COBYLA(maxiter=500, rhobeg=0.5)

vqe = VQE(
    estimator=estimator,
    ansatz=ansatz,
    optimizer=optimizer,
    initial_point=[0.0, 0.0]
)

result = vqe.compute_minimum_eigenvalue(qiskit_hamiltonian)

print(f"\nVQE ground state energy: {result.eigenvalue:.6f} Hartree")
print(f"Optimal parameters: {result.optimal_parameters}")
print(f"Function evaluations: {result.cost_function_evals}")

Comparing with the Exact Solution

Verify against the exact diagonalization (FCI) result:

# Requires: openfermion
from qiskit_algorithms import NumPyMinimumEigensolver

exact_solver = NumPyMinimumEigensolver()
exact_result = exact_solver.compute_minimum_eigenvalue(qiskit_hamiltonian)

fci_energy = exact_result.eigenvalue.real
vqe_energy = result.eigenvalue.real
chemical_accuracy = 0.0016  # Hartree (1 kcal/mol)

print(f"\nFCI (exact) energy:  {fci_energy:.6f} Hartree")
print(f"VQE energy:          {vqe_energy:.6f} Hartree")
print(f"Error:               {abs(vqe_energy - fci_energy):.6f} Hartree")
print(f"Chemical accuracy:   {chemical_accuracy:.4f} Hartree")
print(f"Within chemical accuracy: {abs(vqe_energy - fci_energy) < chemical_accuracy}")

For H2 with this 2-parameter ansatz, VQE should reach the FCI energy to within 10^-5 Hartree, well within the 1.6 millihartree chemical accuracy threshold.

What Comes Next

The H2 example is illustrative but trivially small. Practical quantum chemistry targets molecules where classical FCI is intractable: systems with 50-200 electrons in an active space of 20-50 orbitals. Reaching that regime requires:

  • Better ansatze: UCCSD or ADAPT-VQE to reduce parameter count while staying close to the exact ground state
  • Larger qubit counts: ~40 qubits for a 20-orbital active space
  • Error mitigation: zero-noise extrapolation or probabilistic error cancellation to suppress hardware noise
  • Symmetry reduction: exploiting particle number and spin symmetry to reduce the effective Hilbert space

OpenFermion integrates with PySCF via openfermion-pyscf to generate integrals from actual molecular geometries. Replace the hardcoded integrals above with a MolecularData object and a PySCF SCF calculation to simulate real molecules like LiH, BeH2, or small organic radicals.

Was this tutorial helpful?