Skip to content

Variational Quantum Eigensolvers with SNES

Training a Variational Quantum Eigensolver with evotorch and pennylane

This example demonstrates how you can train variational quantum eigensolvers (VQEs) using EvoTorch and PennyLane. To execute this example, you will need to install PennyLane with:

    pip install pennylane

This example is based on PennyLane's VQE example and we encourage the reader to read that tutorial for a deeper understanding of the problem setting.

Basics of variational quantum eigensolvers

VQE is a recently introduced algorithm which has the objective to train a quantum computer to prepare the ground state of a given molecule [1]. Finding this ground state is a central problem in computational physics and quantum chemistry, as knowledge of this state then enables parameterisation of further simulations.

In brief, a VQE consists of 3 components:

  1. A parameterisable quantum circuit Q with p parameters, which prepares the ground state of the molecule.
  2. A cost function C that computes the energy of a given ground state, which we want to minimise.
  3. A classical optimisation algorithm, which searches for the optimal parameter vector which minimises the energy.

Here we will be using variation quantum circuits for Q, which means that the circuits are parameterised by a vector of angles.

Limitations of backpropagation

The most natural solution to this problem would be to use backpropagation to train the parameters of the quantum circuit to minimise the cost. However, this is only possible in simulation, as backpropagation through a physical quantum circuit would require observing and reusing the state values of the circuit, which is impossible. Because of this, alternative approaches to differentiation of quantum circuits have been proposed. A particularly prominent approach is the 'parameter-shift rule' [2] which, effectively allows us to compute analytic gradients on the parameters by evaluating precise shifts of the parameters, one-by-one. This, of course, means that for one update of a circuit with p parameters, we must perform 2p circuit evaluations.

A recent paper [3] introduced the idea that evolutionary algorithms such as SNES and XNES may serve as an alternative approach to quantum circuit optimisation, where the number of circuit evaluation in each iteration is simply the population size k. In practice, this can yield similar performance to gradient-descent based methods in less circuit evaluations. In this example, we'll be doing exactly that: training the parameters of a VQE using SNES.

Setting up the Cost Function

First, we will need to define the molecular structure. We will study the H\(_2\) molecule, which means that we have:

import torch

symbols = ["H", "H"]  #H2 molecule
coordinates = torch.tensor([0.0, 0.0, -0.6614, 0.0, 0.0, 0.6614])  # nuclear coordinates in atomic units

Next, we need to calculate the electronic Hamiltonian:

import pennylane as qml

H, qubits = qml.qchem.molecular_hamiltonian(symbols, coordinates)
print("Number of qubits = ", qubits)
print("The Hamiltonian is ", H)

We'll be using the default PennyLane quantum simulator

dev = qml.device("default.qubit", wires=qubits)

Now we're ready to set up the circuit Q and cost function C. Note that due to the simplicity of the molecule, we have only a single parameter, p=1 to optimise!

electrons = 2  # H2 has 2 electrons
hf = qml.qchem.hf_state(electrons, qubits)  # giving the Hartree-Fock state 

def circuit(param, wires):
    # Setting up the circuit to optimise, which simply consists of preparing the basis state as the Hartree-Fock state 
    # And then applying a double excitation parameterised by the parameter
    qml.BasisState(hf, wires=wires)
    qml.DoubleExcitation(param, wires=[0, 1, 2, 3])

@qml.qnode(dev, diff_method=None)  # Disabling gradients -- we don't need them
def cost_fn(param):
    # Defining the cost function: simply apply the parameterised circuit and take the exponent of the Hamiltonian
    circuit(param, wires=range(qubits))
    return qml.expval(H)

Creating a EvoTorch Problem instance

With the cost function well-defined, we're ready to create a Problem instance. Note that we will repeat the steps above, so that the model has its own internal definition of the cost function. This will allow us to exploit parallelization with Ray.

from evotorch import Problem, Solution
from evotorch.algorithms import SNES
from evotorch.logging import StdOutLogger, PandasLogger

from typing import Optional
class VGEMin(Problem):

    def __init__(self, num_actors: Optional[int] = None):

        super().__init__(
            objective_sense='min',  # Minimise the objective
            solution_length = 1,  # There is only a single parameter to optimise
            initial_bounds = (-1e-6, 1e-6),  # Start the search very close to zero
            num_actors = num_actors,  # Number of ray actors
        )

        symbols = ["H", "H"]
        coordinates = torch.tensor([0.0, 0.0, -0.6614, 0.0, 0.0, 0.6614])
        self._H, self._qubits = qml.qchem.molecular_hamiltonian(symbols, coordinates)

        electrons = 2  # H2 has 2 electrons
        self._hf = qml.qchem.hf_state(electrons, qubits)

    def _prepare(self):
        # Prepare function called by actors once instantaited
        dev = qml.device("default.qubit", wires=self._qubits)

        # Inline definition of cost function allows us to easily decorate it as a quantum node
        @qml.qnode(dev, diff_method = None, interface = 'torch')
        def actor_cost_fn(param):
            with torch.no_grad():
                wires = range(self._qubits)
                qml.BasisState(self._hf, wires=wires)
                qml.DoubleExcitation(param[0], wires=[0, 1, 2, 3])
                return qml.expval(self._H)

        self._cost_fn = actor_cost_fn

    def _evaluate(self, individual: Solution):
        x = individual.access_values()  # Get the decision values -- in this case a vector of length 1
        cost = self._cost_fn(x)  # Evaluate the decision values
        individual.set_evals(cost)  # Update the fitness

problem = VGEMin(num_actors = 4)  # Instantiate the problem class
population = problem.generate_batch(5)  # Generate a population to test things out
problem.evaluate(population)  # If we've set everything up correctly we should get no error
print(f'Initial fitness values {population.access_evals()}')

Training the VQE with EvoTorch

Now we're ready to train. Simply create the searcher and logger:

searcher = SNES(problem, stdev_init=0.1)  # stdev_init=0.1 used in [3]
logger = PandasLogger(searcher)

And train for 100 generations

searcher.run(100)

progress = logger.to_dataframe()
progress.mean_eval.plot()

Taking a look at the mean of the searcher we have:

print(f'Final mean is {searcher.status["center"]} Final stdev is {searcher.status["stdev"]}')

Where the learned mean is close to the known approximate global optima of 0.208.

print(f'Cost of learned mean is {cost_fn(searcher.status["center"][0].numpy())} vs. approx global optima {cost_fn(0.208)}')

A more challenging example

With the basics down, we can now think about a more challenging problem. Instead of the simple H\(_2\) molecule, we will instead consider the water molecule H2O. A closely related experiment was performed in [3], meaning that this is a demonstration of a relatively 'state of the art' result for QVE training using EvoTorch.

Now we have the molecule configuration:

symbols = ["H", "O", "H"]  #H2O molecule
coordinates = torch.tensor([-0.0399, -0.0038, 0.0, 1.5780, 0.8540, 0.0, 2.7909, -0.5159, 0.0])  # nuclear coordinates in atomic units

And as before we need the electronic Hamiltonian

H, qubits = qml.qchem.molecular_hamiltonian(
    symbols, 
    coordinates,
    charge=0,
    mult=1,
    basis="sto-3g",
    active_electrons=4,
    active_orbitals=4,
) 

print("Number of qubits = ", qubits)
print("The Hamiltonian is ", H)

Making a new cost function, this time using UCCSD ansatz

from functools import partial

electrons = 10
orbitals = 7
core, active = qml.qchem.active_space(electrons, orbitals, active_electrons=4, active_orbitals=4)

singles, doubles = qml.qchem.excitations(len(active), qubits)
hf = qml.qchem.hf_state(
    len(active), 
    qubits,
)  # giving the Hartree-Fock state 

# Map excitations to the wires the UCCSD circuit will act on
s_wires, d_wires = qml.qchem.excitations_to_wires(singles, doubles)

# Define the device
dev = qml.device('default.qubit', wires=qubits)

def circuit2(param, wires):
    # Setting up the circuit to optimise, which simply consists of preparing the basis state as the Hartree-Fock state 
    # And then applying a UCCSD ansatz
    qml.UCCSD(param, wires=wires, s_wires = s_wires, d_wires = d_wires, init_state = hf)

@qml.qnode(dev, diff_method=None)  # Disabling gradients -- we don't need them
def cost_fn(param):
    # Defining the cost function: simply apply the parameterised circuit and take the exponent of the Hamiltonian
    circuit2(param, wires=range(qubits))
    return qml.expval(H)

Putting this together in a problem definition:

# Defining a new problem:
class VGEH2O(Problem):

    def __init__(self, num_actors: Optional[int]):

        super().__init__(
            objective_sense='min',  # Minimise the objective
            solution_length = 26,  # There are 26 parameters to optimise
            initial_bounds = (-1e-6, 1e-6),  # Start the search very close to zero
            num_actors = num_actors,
        )

    def _prepare(self):

        symbols = ["H", "O", "H"]  #H2O molecule
        coordinates = torch.tensor([-0.0399, -0.0038, 0.0, 1.5780, 0.8540, 0.0, 2.7909, -0.5159, 0.0])
        self._H, self._qubits = qml.qchem.molecular_hamiltonian(
            symbols, 
            coordinates,
            charge=0,
            mult=1,
            basis="sto-3g",
            active_electrons=4,
            active_orbitals=4,
        ) 

        electrons = 10
        orbitals = 7
        core, active = qml.qchem.active_space(electrons, orbitals, active_electrons=4, active_orbitals=4)

        singles, doubles = qml.qchem.excitations(len(active), qubits)
        self._hf = qml.qchem.hf_state(
            len(active), 
            qubits,
        ) 

        self._s_wires, self._d_wires = qml.qchem.excitations_to_wires(singles, doubles)

        # Prepare function called by actors once instantaited
        dev = qml.device("default.qubit", wires=self._qubits)

        # Inline definition of cost function allows us to easily decorate it as a quantum node
        @qml.qnode(dev, diff_method = None, interface = 'torch')
        def actor_cost_fn(param):
            with torch.no_grad():
                wires = range(self._qubits)
                qml.UCCSD(param, wires=wires, s_wires = self._s_wires, d_wires = self._d_wires, init_state = self._hf)
                return qml.expval(self._H)

        self._cost_fn = actor_cost_fn

    def _evaluate(self, individual: Solution):
        x = individual.access_values()  # Get the decision values
        cost = self._cost_fn(x)  # Evaluate the parameter vector
        individual.set_evals(cost)  # Update the fitness

problem = VGEH2O(num_actors = 4)
population = problem.generate_batch(10)
problem.evaluate(population)

And from there, all we need to do is train!

searcher = SNES(problem, stdev_init=0.1)  # stdev_init=0.1 used in [3]
pandas_logger = PandasLogger(searcher)
stdout_logger = StdOutLogger(searcher)

# Run for 200 generations
searcher.run(200)

And visualize the progress:

progress = pandas_logger.to_dataframe()
progress.mean_eval.plot()

References

[1] Peruzzo, Alberto, et al. "A variational eigenvalue solver on a photonic quantum processor." Nature communications 5.1 (2014): 1-7.

[2] Schuld, Maria, et al. "Evaluating analytic gradients on quantum hardware." Physical Review A 99.3 (2019): 032331.

[3] Anand, Abhinav, Matthias Degroote, and Alán Aspuru-Guzik. "Natural evolutionary strategies for variational quantum computation.". Machine Learning: Science and Technology 2.4 (2021): 045012


See this notebook on GitHub