Evaluating Computables with Protocols
Protocols are designed to manage the lower-level details of calculations. In particular, in workflows that require quantum measurements, a protocol builds and compiles measurement circuits, post-processes measurement results, and interprets distributions. While protocols and computables are independent data structures, several protocols are provided that can help evaluate some of the atomic computables.
One of the most versatile protocols is the SparseStatevectorProtocol
, which internally performs statevector
calculations for various quantum expressions with the help of a statevector pytket backend.
More details on InQuanto protocols can be found here and in the API reference.
An instance of SparseStatevectorProtocol
can provide an evaluator function via the
get_evaluator()
method.
Computables may be symbolic objects, that is, they may depend on Sympy symbols
originating from a symbolic ansatz. As a result, the get_evaluator()
method requires a symbol-value map to substitute the numerical values in place of the symbols
before the statevector computation takes place. The resulting evaluator function (sv_evaluator
below) may then be passed
to the computable’s evaluate()
method, to obtain the final results.
We continue with the same example as the previous section to compute the Hamiltonian variance:
from pytket.extensions.qiskit import AerStateBackend
from inquanto.express import get_system
from inquanto.core import SymbolDict
from inquanto.operators import QubitOperatorList
from inquanto.ansatzes import TrotterAnsatz
from inquanto.protocols import SparseStatevectorProtocol
from inquanto.computables import ExpectationValue
from inquanto.computables.primitive import ComputableFunction
ham, _, _ = get_system("h2_sto3g.h5")
qubit_hamiltonian = ham.qubit_encode().hermitian_part()
ansatz = TrotterAnsatz(
exponents=QubitOperatorList.from_string("theta [(1j, Y0 X1 X2 X3)]"),
reference=[1, 1, 0, 0],
)
parameters = SymbolDict(theta=-0.41)
c_variance = ComputableFunction(lambda x, y: x - y,
ExpectationValue(ansatz, qubit_hamiltonian ** 2),
ComputableFunction(lambda x: x ** 2, ExpectationValue(ansatz, qubit_hamiltonian))
)
sv = SparseStatevectorProtocol(AerStateBackend())
sv_evaluator = sv.get_evaluator(parameters)
print(c_variance.evaluate(evaluator=sv_evaluator))
0.23089952010568726
The SparseStatevectorProtocol
does not generate measurement circuits, but uses the backend to obtain the statevector
of the ansatz, therefore the computational cost exponentially increases with the number of qubits.
In contrast, a more specialized protocol PauliAveraging
is able to use a quantum device to calculate expectation values.
This protocol builds measurement circuits, which may be submitted to a quantum device or shot-based simulator:
from pytket.extensions.qiskit import AerBackend
from inquanto.protocols import PauliAveraging
from pytket.partition import PauliPartitionStrat
pa = PauliAveraging(
AerBackend(),
shots_per_circuit=1000,
pauli_partition_strategy=PauliPartitionStrat.CommutingSets,
)
pa.build_from(parameters, c_variance)
pa.run(seed=0)
pa_evaluator = pa.get_evaluator()
print(c_variance.evaluate(evaluator=pa_evaluator))
0.20430236328482176
It is important to note that since this protocol needs to build and run the measurement circuits,
it requires the build_from()
method, which builds the non-symbolic measurement circuits that are
necessary to eventually evaluate c_variance
, and requires the
run()
method which submits the circuits to the backend and retrieves results.
The build phase is when measurement reduction takes place using the commuting set strategy.
Since the protocol is built from a computable, this measurement reduction is applied to the entire computable
expression tree, which is more advantageous than measuring the expectation values in the variance expression separately,
in this particular example.
After the run phase, the protocol is ready to provide an evaluator with get_evaluator()
.
In this case there is no need to pass the symbol-value map to the evaluator; the build phase performs symbol subsitution so that
measurement circuits are already fully numerical.
Note that the run()
method submits circuits to the backend, and waits for retrieval. As a result,
it is ill-suited to submitted circuits to real quantum hardware, where there may be a long wait time. In this case, it is recommended to
use the launch()
and retrieve()
methods:
handles = pa.launch(seed=0)
pa.retrieve(handles)
<inquanto.protocols.averaging._pauli_averaging.PauliAveraging at 0x7f781ce033d0>
Together, these methods are equivalent to run()
, but
launch()
does not block the runtime.
The quantum computational details are stored in the protocol in this case, therefore all optimizations, redundancies,
uncertainties and resources that are necessary to evaluate c_variance
can be requested from the protocol for analysis.
Sometimes, details of the workflow are not important, therefore some protocols provide the
get_runner()
method which
returns a function that takes in a symbol-value map and returns the value of the computable it was made for:
sv_runner_variance = sv.get_runner(c_variance)
print(sv_runner_variance(parameters))
pa_runner_variance = pa.get_runner(c_variance)
print(pa_runner_variance(parameters))
0.23089952010568726
0.23401851138155538
And it is often useful to get the result quickly for a computable, during prototyping for example. For this purpose, one can use:
print(c_variance.default_evaluate(parameters))
0.23089952010568726
which internally instantiates a SparseStatevectorProtocol
protocol and calculates the result with it.
This statevector protocol will attempt to use an
AerStateBackend instance from
pytket-qiskit then a
QulacsBackend from
pytket-qulacs
to evaluate the protocol if the former is unavailable.
Note that there may be several protocols which are capable of calculating the same quantity, but by quite different methods. Also, some protocols can calculate various quantities, while others are capable of calculating only one specific quantity. It is recommended to refer to the protocols manual page, and the API documentation to familiarize yourself with the capabilities and scopes of specific computables and protocols.
Evaluating Composite Computables with ProtocolList
This section contains some more advanced topics in the use of computables and protocols. It is recommended that the reader first familiarizes themselves with the use of statevector and averaging protocols and with primitive computable objects before reading this section.
Composite computable objects may require more than one shot-based protocol to build and run all of the circuits required for evaluation. One such example of this is a simple pair of expectation values with respect to two different states:
from inquanto.computables import ComputableTuple
ansatz2 = ansatz.copy().symbol_substitution("{}_2")
parameters = SymbolDict(theta=-0.41, theta_2=1.0)
computable_tuple = ComputableTuple(
ExpectationValue(ansatz, qubit_hamiltonian),
ExpectationValue(ansatz2, qubit_hamiltonian),
)
The PauliAveraging
protocol is capable of evaluating expectation values,
but a single instance of any averaging protocol supports only a single ansatz state (or pair of states
in the case of overlap and overlap squared protocols).
Evaluating an object like this in InQuanto is made easier and more efficient using the ProtocolList
class:
protocols = PauliAveraging.build_protocols_from(
parameters=parameters,
computable=computable_tuple,
backend=AerBackend(),
shots_per_circuit=10000,
pauli_partition_strategy=PauliPartitionStrat.CommutingSets,
)
print(f"Number of protocols: {len(protocols)}\n")
print(protocols.dataframe_protocol_circuit())
Number of protocols: 2
Protocol ID Protocol Type Qubits Depth2q Shots
0 140153857411280 PauliAveraging 4 6 10000
1 140153857411280 PauliAveraging 4 8 10000
2 140152874061008 PauliAveraging 4 6 10000
3 140152874061008 PauliAveraging 4 8 10000
The build_protocols_from()
method parses the input computable and builds protocols for
computable nodes that the chosen protocol is capable of evaluating, storing them in a ProtocolList
.
In this case, two PauliAveraging
protocols are required, totaling four circuits.
We may then run all circuits through the ProtocolList
interface, and generate an evaluator function
for this composite computable:
protocols.run(seed=0)
computable_tuple.evaluate(protocols.get_evaluator())
(-0.9869527172517868, 0.20709664898096042)
If our composite computable contains two expectation values with respect to the same state,
build_protocols_from()
will collect all measurements required for both nodes into a
single protocol. This allows measurement reduction (the collection of Pauli words into simultaneously measurable sets) over
all Pauli words in both expectation values, potentially reducing the total number of circuits required. For example:
computable_tuple2 = ComputableTuple(
ExpectationValue(ansatz, qubit_hamiltonian),
ExpectationValue(ansatz, qubit_hamiltonian**2),
)
protocols = PauliAveraging.build_protocols_from(
parameters=parameters,
computable=computable_tuple2,
backend=AerBackend(),
shots_per_circuit=10000,
pauli_partition_strategy=PauliPartitionStrat.CommutingSets,
)
print(f"Number of protocols: {len(protocols)}\n")
print(protocols.dataframe_protocol_circuit())
Number of protocols: 1
Protocol ID Protocol Type Qubits Depth2q Shots
0 140153858915024 PauliAveraging 4 6 10000
1 140153858915024 PauliAveraging 4 8 10000
2 140153858915024 PauliAveraging 4 8 10000
Other averaging protocols will perform similar measurement reduction when parsing composite computables with
build_protocols_from()
.
Composite computables may also consist of a mixture of different physical quantities. Again, we consider a simple example: a tuple containing two expectation values, and an overlap squared:
from inquanto.computables import OverlapSquared
computable_tuple_mix = ComputableTuple(
ExpectationValue(ansatz, qubit_hamiltonian),
ExpectationValue(ansatz2, qubit_hamiltonian),
OverlapSquared(ansatz, ansatz2),
)
For this computable we will use two averaging protocols, SwapTest
and
PauliAveraging
. We use build_protocols_from()
again to parse the computable, and then compose a single ProtocolList
for running and evaluating:
from inquanto.protocols import SwapTest
ovlp_protocols = SwapTest.build_protocols_from(
parameters=parameters,
computable=computable_tuple_mix,
backend=AerBackend(),
n_shots=10000,
)
eval_protocols = PauliAveraging.build_protocols_from(
parameters=parameters,
computable=computable_tuple_mix,
backend=AerBackend(),
shots_per_circuit=10000,
pauli_partition_strategy=PauliPartitionStrat.CommutingSets,
)
# Join together all protocols into a single ProtocolList
protocols = ovlp_protocols + eval_protocols
print(protocols.dataframe_protocol_circuit())
protocols.run(seed=0)
computable_tuple_mix.evaluate(protocols.get_evaluator())
Protocol ID Protocol Type Qubits Depth2q Shots
0 140152873771152 SwapTest 9 27 10000
1 140153857301200 PauliAveraging 4 6 10000
2 140153857301200 PauliAveraging 4 8 10000
3 140152872937808 PauliAveraging 4 6 10000
4 140152872937808 PauliAveraging 4 8 10000
(-0.9869527172517868, 0.20709664898096042, 0.02899999999999997)
If we wish to evaluate part of the computable with a shot-based protocol, and another part with statevector simulation, we may use partial evaluation. For example, below we consider the same tuple of overlap squared and expectation values, but evaluate the overlap squared part using statevector simulation:
# First, build and run protocols for the expectation value nodes
protocol_list = PauliAveraging.build_protocols_from(
parameters=parameters,
computable=computable_tuple_mix,
backend=AerBackend(),
shots_per_circuit=10000,
pauli_partition_strategy=PauliPartitionStrat.CommutingSets,
)
protocol_list.run(seed=1)
# Note the use of allow_partial=True for building a partial evaluator
shot_evaluator = protocol_list.get_evaluator(allow_partial=True)
partial_result = computable_tuple_mix.evaluate(shot_evaluator)
print(f"Partially evaluated computable:\n{partial_result}\n")
# Define statevector protocol to evaluate remaining node
sv_evaluator = SparseStatevectorProtocol(AerStateBackend()).get_evaluator(parameters)
final_result = partial_result.evaluate(sv_evaluator)
print(f"Fully evaluated computable:\n{final_result}")
Partially evaluated computable:
(-0.9869804954120792, 0.22476991621493542, OverlapSquared(bra_state=<inquanto.ansatzes._trotter_ansatz.TrotterAnsatz, qubits=4, gates=33, symbols=1>, ket_state=<inquanto.ansatzes._trotter_ansatz.TrotterAnsatz, qubits=4, gates=33, symbols=1>, kernel={(): 1.0}))
Fully evaluated computable:
(-0.9869804954120792, 0.22476991621493542, 0.025633390578446377)
Beyond these simple cases, ProtocolList
is useful for evaluating composite protocols in general.
A natural example of this is the overlap matrix, which
consists of expectation values along the diagonal, and complex overlaps on the off-diagonal elements.