Lesson 4: Putting everything together¶
In this example we’re going to apply the previously learned concept to run a Hamiltonian simulation:
Defining an ansatz using a
pytketsymbolic circuitProvided a workflow to calculate an expected value of a circuit given a Pauli String:
Construct a workflow using input and output definitions, as well as builtin functionalities as shown in Lesson 1
(new) Using higher order constructs like
mapto parallelize execution(new) Introduce the
foldpattern
The ansatz¶
We start by defining a symbolic circuit as an ansatz which will be the input to our workflow. Were going to use a very simple circuit, which only uses 4 qubits and 3 symbols to match what we build so far. Note that it doesn’t have any measurements yet. We will add them in the next step when calculating the expected value.
from pytket.circuit import Circuit, fresh_symbol
def build_ansatz() -> Circuit:
a = fresh_symbol("a")
b = fresh_symbol("b")
c = fresh_symbol("c")
circ = Circuit(4)
circ.CX(0, 1)
circ.CX(1, 2)
circ.CX(2, 3)
circ.Rz(a, 3)
circ.CX(2, 3)
circ.CX(1, 2)
circ.CX(0, 1)
circ.Rz(b, 0)
circ.CX(0, 1)
circ.CX(1, 2)
circ.CX(2, 3)
circ.Rz(c, 3)
circ.CX(2, 3)
circ.CX(1, 2)
circ.CX(0, 1)
return circ
Calculating expected values¶
We will evaluate this circuit with an observable based on a Pauli string. We want to measure the circuit according the Pauli string after which we can compile and submit it. Using the respective workers we can construct a subgraph. In Tierkreis graphs are first class citizens, which we can reuse in multiple locations. In this example, we later want to map over this procedure with different parameters. Defining the graph which taktes a circuit, a Pauli string and the number of shots.
from typing import NamedTuple, Literal
from tierkreis.builder import Graph
from tierkreis.controller.data.models import TKR
from pytket_worker import (
append_pauli_measurement_impl,
optimise_phase_gadgets,
expectation,
)
from aer_worker import submit_single
class SubmitInputs(NamedTuple):
circuit: TKR[Literal["pytket._tket.circuit.Circuit"]]
pauli_string: TKR[Literal["pytket._tket.pauli.QubitPauliString"]]
n_shots: TKR[int]
def exp_val():
g = Graph(SubmitInputs, TKR[float])
circuit = g.inputs.circuit
pauli_string = g.inputs.pauli_string
n_shots = g.inputs.n_shots
measurement_circuit = g.task(append_pauli_measurement_impl(circuit, pauli_string))
compiled_circuit = g.task(optimise_phase_gadgets(measurement_circuit))
backend_result = g.task(submit_single(compiled_circuit, n_shots))
av = g.task(expectation(backend_result))
return g.finish_with_outputs(av)
Building the workflow¶
We are going to simulate a Hamiltonian given as a list of Pauli strings and their weights. As additional inputs we take the parameters to our ansazt. We can already build the first step of the graph, using the substitution worker we build before (see Lesson 2).
from my_example_worker import substitute
class SymbolicExecutionInputs(NamedTuple):
a: TKR[float]
b: TKR[float]
c: TKR[float]
ham: TKR[list[tuple[Literal["pytket._tket.pauli.QubitPauliString"], float]]]
ansatz: TKR[Literal["pytket._tket.circuit.Circuit"]]
simulation_graph = Graph(SymbolicExecutionInputs, TKR[float])
substituted_circuit = simulation_graph.task(
substitute(
simulation_graph.inputs.ansatz,
simulation_graph.inputs.a,
simulation_graph.inputs.b,
simulation_graph.inputs.c,
)
)
Using map¶
Since the exp_val() workflow can run independently for each Pauli string we can implement this using a map node.
Map nodes execute a subworkflow in parallel for an arbitrary number of different inputs.
The node itself expects Graph instance for the workflow and a list of input parameters to map over.
In our case, the exp_val() workflow expects SubmitInputs where the field circuit and n_shots are fixed.
So it is sufficient to map over the pauli strings
We need to prepare the inputs accordingly, in this scenario we build a list of SubmitInputs each with Pauli string using a lambda
from tierkreis.builtins import unzip
from tierkreis.controller.data.models import TKR
pauli_strings_list, parameters_list = simulation_graph.task(
unzip(simulation_graph.inputs.ham)
)
input_circuits = simulation_graph.map(
lambda x: SubmitInputs(substituted_circuit, x, simulation_graph.const(100)),
pauli_strings_list,
)
which we than can then supply to the map node:
exp_values = simulation_graph.map(exp_val(), input_circuits)
Using the fold pattern¶
To estimate the energy, we can take a weighted sum of the expectation values.
For this we want to compute a reduction of \(f:\mathbb{R}^2\times\mathbb{R} \rightarrow \mathbb{R} \quad ((x,y), z) \rightarrow x*y+z\) which we implement as a fold function.
This is a very common pattern; In Tierkreis the function \(f\) will be implemented as a workflow using the functionality in tierkreis.graphs.fold.
Defining \(f\) as compute_terms() using builtins:
from tierkreis.builtins import add, times, untuple
from tierkreis.graphs.fold import FoldFunctionInput
ComputeTermsInputs = FoldFunctionInput[
tuple[float, float], float
] # (value: (x,y) , accum: z) -> new_accum
def compute_terms():
g = Graph(ComputeTermsInputs, TKR[float])
res_0, res_1 = g.task(untuple(g.inputs.value))
prod = g.task(times(res_0, res_1))
sum = g.task(add(g.inputs.accum, prod))
return g.finish_with_outputs(sum)
Preparing the inputs for the fold graph we are going to use tuples (x=exp_val, y=weight) and defining the start value \( z=0 \):
from tierkreis.builtins import tkr_zip
from tierkreis.graphs.fold import FoldGraphInputs, fold_graph
tuple_values = simulation_graph.task(tkr_zip(exp_values, parameters_list))
fold_inputs = FoldGraphInputs(simulation_graph.const(0.0), tuple_values)
applying the fold operation yields the final output
computed = simulation_graph.eval(fold_graph(compute_terms()), fold_inputs)
workflow = simulation_graph.finish_with_outputs(computed)
Running the workflow¶
As before we now have to set up tierkreis storage and executors.
from uuid import UUID
from tierkreis.storage import FileStorage
from tierkreis.executor import ShellExecutor
storage = FileStorage(UUID(int=102), name="hamiltonian")
example_executor = ShellExecutor(None, workflow_dir=storage.workflow_dir)
and provide the inputs
from pytket._tket.unit_id import Qubit
from pytket.pauli import Pauli, QubitPauliString
qubits = [Qubit(0), Qubit(1), Qubit(2), Qubit(3)]
hamiltonian = [
(QubitPauliString(qubits, [Pauli.X, Pauli.Y, Pauli.X, Pauli.I]).to_list(), 0.1),
(QubitPauliString(qubits, [Pauli.Y, Pauli.Z, Pauli.X, Pauli.Z]).to_list(), 0.5),
(QubitPauliString(qubits, [Pauli.X, Pauli.Y, Pauli.Z, Pauli.I]).to_list(), 0.3),
(QubitPauliString(qubits, [Pauli.Z, Pauli.Y, Pauli.X, Pauli.Y]).to_list(), 0.6),
]
inputs = {
"ansatz": build_ansatz().to_dict(),
"a": 0.2,
"b": 0.55,
"c": 0.75,
"ham": hamiltonian,
}
before we can run the simulation:
from tierkreis.controller import run_graph
from tierkreis.storage import read_outputs
storage.clean_graph_files()
run_graph(
storage,
example_executor,
workflow,
inputs,
polling_interval_seconds=0.1,
)
output = read_outputs(workflow, storage)
print(output)
-0.07200000000000006