Given a circuit generating a quantum state $\lvert \psi \rangle$, it is very common to have an operator $H$ and ask for the expectation value $\langle \psi \vert H \vert \psi \rangle$. A notable example is in quantum computational chemistry, where $\lvert \psi \rangle$ encodes the wavefunction for the electronic state of a small molecule, and the energy of the molecule can be derived from the expectation value with respect to the molecule's Hamiltonian operator $H$.
from pytket import Circuit, Qubit, Bit
from sympy import symbols
Generate ansatz and Hamiltonian:
ansatz = Circuit()
qubits = ansatz.add_q_register("q", 4)
args = symbols("a0 a1 a2 a3 a4 a5 a6 a7")
for i in range(4):
ansatz.Ry(args[i], qubits[i])
for i in range(3):
ansatz.CX(qubits[i], qubits[i + 1])
for i in range(4):
ansatz.Ry(args[4 + i], qubits[i])
ansatz.measure_all()
for command in ansatz:
print(command)
In reality, you would use an expectation value calculation as the objective function for a classical optimisation routine to determine the parameter values for the ground state. For the purposes of this notebook, we will use some predetermined values for the ansatz, already optimised for $\mathrm{H}_2$.
arg_values = [
7.17996183e-02,
2.95442468e-08,
1.00000015e00,
1.00000086e00,
9.99999826e-01,
1.00000002e00,
9.99999954e-01,
1.13489747e-06,
]
ansatz.symbol_substitution(dict(zip(args, arg_values)))
We can use for example the openfermion library to express an Hamiltonian as a sum of tensors of paulis.
import openfermion as of
hamiltonian = (
-0.0970662681676282 * of.QubitOperator("")
+ -0.045302615503799284 * of.QubitOperator("X0 X1 Y2 Y3")
+ 0.045302615503799284 * of.QubitOperator("X0 Y1 Y2 X3")
+ 0.045302615503799284 * of.QubitOperator("Y0 X1 X2 Y3")
+ -0.045302615503799284 * of.QubitOperator("Y0 Y1 X2 X3")
+ 0.17141282644776884 * of.QubitOperator("Z0")
+ 0.16868898170361213 * of.QubitOperator("Z0 Z1")
+ 0.12062523483390425 * of.QubitOperator("Z0 Z2")
+ 0.16592785033770352 * of.QubitOperator("Z0 Z3")
+ 0.17141282644776884 * of.QubitOperator("Z1")
+ 0.16592785033770352 * of.QubitOperator("Z1 Z2")
+ 0.12062523483390425 * of.QubitOperator("Z1 Z3")
+ -0.22343153690813597 * of.QubitOperator("Z2")
+ 0.17441287612261608 * of.QubitOperator("Z2 Z3")
+ -0.22343153690813597 * of.QubitOperator("Z3")
)
This can be converted into pytket's QubitPauliOperator type.
from pytket.pauli import Pauli, QubitPauliString
from pytket.utils.operators import QubitPauliOperator
pauli_sym = {"I": Pauli.I, "X": Pauli.X, "Y": Pauli.Y, "Z": Pauli.Z}
def qps_from_openfermion(paulis):
"""Convert OpenFermion tensor of Paulis to pytket QubitPauliString."""
qlist = []
plist = []
for q, p in paulis:
qlist.append(Qubit(q))
plist.append(pauli_sym[p])
return QubitPauliString(qlist, plist)
def qpo_from_openfermion(openf_op):
"""Convert OpenFermion QubitOperator to pytket QubitPauliOperator."""
tk_op = dict()
for term, coeff in openf_op.terms.items():
string = qps_from_openfermion(term)
tk_op[string] = coeff
return QubitPauliOperator(tk_op)
hamiltonian_op = qpo_from_openfermion(hamiltonian)
We can simulate this exactly using a statevector simulator like ProjectQ. This has a built-in method for fast calculations of expectation values that works well for small examples like this.
from pytket.extensions.projectq import ProjectQBackend
backend = ProjectQBackend()
ideal_energy = backend.get_operator_expectation_value(ansatz, hamiltonian_op)
print(ideal_energy)
Ideally the state generated by this ansatz will only span the computational basis states with exactly two of the four qubits in state $\lvert 1 \rangle$. This is because these basis states correspond to two electrons being present in the molecule.
syn = Qubit("synq", 0)
syn_res = Bit("synres", 0)
ansatz.add_qubit(syn)
ansatz.add_bit(syn_res)
for qb in qubits:
ansatz.CX(qb, syn)
ansatz.Measure(syn, syn_res)
Using this, we can define a filter function which removes the shots which the syndrome qubit detected as erroneous. BackendResult
objects allow retrieval of shots in any bit order, so we can retrieve the synres
results separately and use them to filter the shots from the remaining bits. The Backends example notebook describes this in more detail.
def filter_shots(backend_result, syn_res_bit):
bits = sorted(backend_result.get_bitlist())
bits.remove(syn_res_bit)
syn_shots = backend_result.get_shots([syn_res])[:, 0]
main_shots = backend_result.get_shots(bits)
return main_shots[syn_shots == 0]
filtered_rows = shot_table[shot_table[:, syn_res_index] == 0]
return np.delete(filtered_rows, syn_res_index, axis=1)
Depending on which backend we will be using, we will need to compile each circuit we run to conform to the gate set and connectivity constraints. We can define a compilation pass for each backend that optimises the circuit and maps it onto the backend's gate set and connectivity constraints. We don't expect this to change our circuit too much as it is already near-optimal.
from pytket.passes import OptimisePhaseGadgets, SequencePass
def compiler_pass(backend):
return SequencePass([OptimisePhaseGadgets(), backend.default_compilation_pass()])
Given the full statevector, the expectation value can be calculated simply by matrix multiplication. However, with a real quantum system, we cannot observe the full statevector directly. Fortunately, the Pauli decomposition of the operator gives us a sequence of measurements we should apply to obtain the relevant information to reconstruct the expectation value.
from pytket.predicates import CompilationUnit
from pytket.utils import append_pauli_measurement
def gen_pauli_measurement_circuits(state_circuit, compiler_pass, operator):
# compile main circuit once
state_cu = CompilationUnit(state_circuit)
compiler_pass.apply(state_cu)
compiled_state = state_cu.circuit
final_map = state_cu.final_map
# make a measurement circuit for each pauli
pauli_circuits = []
coeffs = []
energy = 0
for p, c in operator.terms.items():
if p == ():
# constant term
energy += c
else:
# make measurement circuits and compile them
pauli_circ = Circuit(state_circuit.n_qubits - 1) # ignore syndrome qubit
append_pauli_measurement(qps_from_openfermion(p), pauli_circ)
pauli_cu = CompilationUnit(pauli_circ)
compiler_pass.apply(pauli_cu)
pauli_circ = pauli_cu.circuit
init_map = pauli_cu.initial_map
# map measurements onto the placed qubits from the state
rename_map = {
i: final_map[o] for o, i in init_map.items() if o in final_map
}
pauli_circ.rename_units(rename_map)
state_and_measure = compiled_state.copy()
state_and_measure.append(pauli_circ)
pauli_circuits.append(state_and_measure)
coeffs.append(c)
return pauli_circuits, coeffs, energy
We can now start composing these together to get our generalisable expectation value function. Passing all of our circuits to process_circuits
allows them to be submitted to IBM Quantum devices at the same time, giving substantial savings in overall queueing time. Since the backend will cache any results from Backend.process_circuits
, we will remove the results when we are done with them to prevent memory bloating when this method is called many times.
from pytket.utils import expectation_from_shots
def expectation_value(state_circuit, operator, backend, n_shots):
if backend.supports_expectation:
circuit = state_circuit.copy()
compiled_circuit = backend.get_compiled_circuit(circuit)
return backend.get_operator_expectation_value(
compiled_circuit, qpo_from_openfermion(operator)
)
elif backend.supports_shots:
syn_res_index = state_circuit.bit_readout[syn_res]
pauli_circuits, coeffs, energy = gen_pauli_measurement_circuits(
state_circuit, compiler_pass(backend), operator
)
handles = backend.process_circuits(pauli_circuits, n_shots=n_shots)
for handle, coeff in zip(handles, coeffs):
res = backend.get_result(handle)
filtered = filter_shots(res, syn_res)
energy += coeff * expectation_from_shots(filtered)
backend.pop_result(handle)
return energy
else:
raise NotImplementedError("Implementation for state and counts to be written")
...and then run it for our ansatz. AerBackend
supports faster expectation value from snapshopts (using the AerBackend.get_operator_expectation_value
method), but this only works when all the qubits in the circuit are default register qubits that go up from 0. So we will need to rename synq
.
from pytket.extensions.qiskit import IBMQEmulatorBackend, AerBackend
ansatz.rename_units({Qubit("synq", 0): Qubit("q", 4)})
print(expectation_value(ansatz, hamiltonian, AerBackend(), 8000))
# Try replacing IBMQEmulatorBackend with IBMQBackend to submit the circuits to a real IBM Quantum device.
print(
expectation_value(ansatz, hamiltonian, IBMQEmulatorBackend("ibmq_manila"), 8000)
)
For basic practice with using pytket backends and their results, try editing the code here to:
expectation_value
to work with statevector backends (e.g. AerStateBackend
)filter_shots
and see the effect on the expectation value on a noisy simulation/devicefilter_shots
to be able to filter a counts dictionary and adapt expectation_value
to calulate the result using the counts summary from the backend (pytket.utils.expectation_from_counts
will be useful here)