VQE workflow using Quantinuum Nexus¶
This is a full example of using features (properties and context management) in qnexus
to run and restart a ‘Variational Quantum Eigensolver’ workflow adapted from this example.
N.B. This is a simple VQE example demonstrating qnexus features in action, if you are interested in performing calculations of chemical systems see InQuanto.
from datetime import datetime
from pytket import Circuit
from pytket.circuit.display import render_circuit_jupyter
from pytket.utils.operators import QubitPauliOperator
from pytket.partition import measurement_reduction, MeasurementBitMap, MeasurementSetup, PauliPartitionStrat
from pytket.backends.backendresult import BackendResult
from pytket.pauli import Pauli, QubitPauliString
from pytket.circuit import Qubit
from scipy.optimize import minimize
from numpy import ndarray
from numpy.random import random_sample
from sympy import Symbol
import qnexus as qnx
Set up the VQE components¶
# 1. Synthesise Symbolic State-Preparation Circuit (hardware efficient ansatz)
symbols = [Symbol(f"p{i}") for i in range(4)]
symbolic_circuit = Circuit(2)
symbolic_circuit.X(0)
symbolic_circuit.Ry(symbols[0], 0).Ry(symbols[1], 1)
symbolic_circuit.CX(0, 1)
symbolic_circuit.Ry(symbols[2], 0).Ry(symbols[3], 0)
render_circuit_jupyter(symbolic_circuit)
# 2. Define Hamiltonian
# coefficients in the Hamiltonian are obtained from PhysRevX.6.031007
coeffs = [-0.4804, 0.3435, -0.4347, 0.5716, 0.0910, 0.0910]
term0 = {
QubitPauliString(
{
Qubit(0): Pauli.I,
Qubit(1): Pauli.I,
}
): coeffs[0]
}
term1 = {QubitPauliString({Qubit(0): Pauli.Z, Qubit(1): Pauli.I}): coeffs[1]}
term2 = {QubitPauliString({Qubit(0): Pauli.I, Qubit(1): Pauli.Z}): coeffs[2]}
term3 = {QubitPauliString({Qubit(0): Pauli.Z, Qubit(1): Pauli.Z}): coeffs[3]}
term4 = {QubitPauliString({Qubit(0): Pauli.X, Qubit(1): Pauli.X}): coeffs[4]}
term5 = {QubitPauliString({Qubit(0): Pauli.Y, Qubit(1): Pauli.Y}): coeffs[5]}
term_sum = {}
term_sum.update(term0)
term_sum.update(term1)
term_sum.update(term2)
term_sum.update(term3)
term_sum.update(term4)
term_sum.update(term5)
hamiltonian = QubitPauliOperator(term_sum)
# 3 Computing Expectation Values
# Computing Expectation Values for Pauli-Strings
def compute_expectation_paulistring(
distribution: dict[tuple[int, ...], float], bitmap: MeasurementBitMap
) -> float:
value = 0
for bitstring, probability in distribution.items():
value += probability * (sum(bitstring[i] for i in bitmap.bits) % 2)
return ((-1) ** bitmap.invert) * (-2 * value + 1)
# 3.2 Computing Expectation Values for sums of Pauli-strings multiplied by coefficients
def compute_expectation_value(
results: list[BackendResult],
measurement_setup: MeasurementSetup,
operator: QubitPauliOperator,
) -> float:
energy = 0
for pauli_string, bitmaps in measurement_setup.results.items():
string_coeff = operator.get(pauli_string, 0.0)
if string_coeff > 0:
for bm in bitmaps:
index = bm.circ_index
distribution = results[index].get_distribution()
value = compute_expectation_paulistring(distribution, bm)
energy += complex(value * string_coeff).real
return energy
# 4. Building our Objective function
class Objective:
def __init__(
self,
symbolic_circuit: qnx.circuits.CircuitRef,
problem_hamiltonian: QubitPauliOperator,
n_shots_per_circuit: int,
target: qnx.BackendConfig,
iteration_number: int = 0,
n_iterations: int = 10,
) -> None:
"""Returns the objective function needed for a variational
procedure.
"""
terms = [term for term in problem_hamiltonian._dict.keys()]
self._symbolic_circuit: Circuit = symbolic_circuit.download_circuit()
self._hamiltonian: QubitPauliOperator = problem_hamiltonian
self._nshots: int = n_shots_per_circuit
self._measurement_setup: MeasurementSetup = measurement_reduction(
terms, strat=PauliPartitionStrat.CommutingSets
)
self._iteration_number: int = iteration_number
self._niters: int = n_iterations
self._target = target
def __call__(self, parameter: ndarray) -> float:
value = self._objective_function(parameter)
self._iteration_number += 1
if self._iteration_number >= self._niters:
self._iteration_number = 0
return value
def _objective_function(
self,
parameters: ndarray,
) -> float:
# Prepare the parameterised state preparation circuit
assert len(parameters) == len(self._symbolic_circuit.free_symbols())
symbol_dict = {s: p for s, p in zip(self._symbolic_circuit.free_symbols(), parameters)}
state_prep_circuit = self._symbolic_circuit.copy()
state_prep_circuit.symbol_substitution(symbol_dict)
# Label each job with the properties associated with the circuit.
properties = {str(sym): val for sym, val in symbol_dict.items()} | {"iteration": self._iteration_number}
with qnx.context.using_properties(**properties):
circuit_list = self._build_circuits(state_prep_circuit)
# Execute circuits with Nexus
results = qnx.execute(
name=f"execute_job_VQE_{datetime.now()}_{self._iteration_number}",
circuits=circuit_list,
n_shots=[self._nshots]*len(circuit_list),
backend_config=self._target,
timeout=None,
)
expval = compute_expectation_value(
results, self._measurement_setup, self._hamiltonian
)
return expval
def _build_circuits(self, state_prep_circuit: Circuit) -> list[qnx.circuits.CircuitRef]:
# Requires properties to be set in the context
# Upload the numerical state-prep circuit to Nexus
qnx.circuits.upload(
circuit=state_prep_circuit,
name=f"state prep circuit {self._iteration_number}",
)
circuit_list = []
for mc in self._measurement_setup.measurement_circs:
c = state_prep_circuit.copy()
c.append(mc)
# Upload each measurement circuit to Nexus with correct params
measurement_circuit_ref = qnx.circuits.upload(
circuit=c,
name=f"state prep circuit {self._iteration_number}",
)
circuit_list.append(measurement_circuit_ref)
# Compile circuits with Nexus
compiled_circuit_refs = qnx.compile(
name=f"compile_job_VQE_{datetime.now()}_{self._iteration_number}",
circuits=circuit_list,
optimisation_level=2,
backend_config=self._target,
timeout=None,
)
return compiled_circuit_refs
Set up the Nexus Project and run the VQE¶
# set up the project
project_ref = qnx.projects.create(
name=f"VQE_example_{str(datetime.now())}",
description="A VQE done with qnexus",
)
# set this in the context
qnx.context.set_active_project(project_ref)
Using Properties for Parameters¶
Properties are a way to annotate resources in Nexus with custom attributes.
As we will be computing properties in a loop, the iteration number is a natural fit for the property.
qnx.projects.add_property(
name="iteration",
property_type="int",
description="The iteration number in my dihydrogen VQE experiment",
)
# Set up the properties for the symbolic circuit parameters
for sym in symbolic_circuit.free_symbols():
qnx.projects.add_property(
name=str(sym),
property_type="float",
description=f"Our VQE {str(sym)} parameter",
)
# Upload our ansatz circuit
ansatz_ref = qnx.circuits.upload(
circuit=symbolic_circuit,
name="ansatz_circuit",
description="The ansatz state-prep circuit for my dihydrogen VQE",
)
Construct our objective function¶
objective = Objective(
symbolic_circuit = ansatz_ref,
problem_hamiltonian = hamiltonian,
n_shots_per_circuit = 500,
n_iterations= 4,
target = qnx.QuantinuumConfig(device_name="H1-1LE")
)
Run the VQE loop¶
initial_parameters = random_sample(len(symbolic_circuit.free_symbols()))
result = minimize(
objective,
initial_parameters,
method="COBYLA",
options={"disp": True, "maxiter": objective._niters},
tol=1e-2,
)
print(result.fun)
print(result.x)
Use Nexus to Rescue a VQE workflow¶
For instance, lets say that some failure happened on the 2nd iteration (e.g. laptop ran out of battery) and we want to resume ASAP.
In the above we ran for 4 iterations, lets pretend that we actually wanted to run for 7 and it failed on the 4th one.
N.B. The SciPy minimizer will have internal state which is not accounted for in this example.
# Get the project, fetching the latest one with the name prefix from above
project_matches = qnx.projects.get_all(name_like="VQE_example_", sort_filters=['-created'])
project_ref = project_matches.list()[0]
# set this in the context
qnx.context.set_active_project(project_ref)
project_ref.df()
# Get the symbolic circuit
symbolic_circuit_ref = qnx.circuits.get(name_like="ansatz_circuit")
most_recent_circuits = qnx.circuits.get_all(name_like="final", project=project_ref)
most_recent_circuits.summarize()
most_recent_circuits_refs = most_recent_circuits.list()
most_recent_circuits_refs.df()
# Get the latest circuit to get the new 'initial_parameters'
latest_circuit: qnx.circuits.CircuitRef = most_recent_circuits_refs[-1]
latest_circuit_properties = latest_circuit.annotations.properties
latest_circuit.df()
# Get what iteration we were on (from the latest circuit)
last_iteration_count = latest_circuit_properties.pop("iteration")
print(last_iteration_count)
# Retreive the params and check them
new_starting_params = list(latest_circuit_properties.values())
print(new_starting_params)
# Build the Objective and run 'minimize' to continue the experiment
objective = Objective(
symbolic_circuit_ref,
hamiltonian,
n_shots_per_circuit = 500,
iteration_number=last_iteration_count, # resume from 3rd iteration of 7
n_iterations = 7,
target = qnx.QuantinuumConfig(device_name="H1-1LE")
)
result = minimize(
objective,
new_starting_params,
method="COBYLA",
options={"disp": True, "maxiter": objective._niters},
tol=1e-2,
)
print(result.fun)
print(result.x)