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.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 = {"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.23089952010568748

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.compile_circuits()
pa.run(seed=0)

pa_evaluator = pa.get_evaluator()
print(c_variance.evaluate(evaluator=pa_evaluator))
0.20430236328482154

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 0x7fca91d78150>

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.23089952010568748
0.24859657566678517

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.23089952010568748

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 = {
    "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())
protocols.compile_circuits()
Number of protocols: 2

       Protocol ID   Protocol type  Qubits  Depth2q  Shots
0  140508138850512  PauliAveraging       4        0  10000
1  140508138850512  PauliAveraging       4        0  10000
2  140508007126736  PauliAveraging       4        0  10000
3  140508007126736  PauliAveraging       4        0  10000
[<inquanto.protocols.averaging._pauli_averaging.PauliAveraging object at 0x7fca99b448d0>, <inquanto.protocols.averaging._pauli_averaging.PauliAveraging object at 0x7fca91da56d0>]

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.9869527172517875, 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())
protocols.compile_circuits()
Number of protocols: 1

       Protocol ID   Protocol type  Qubits  Depth2q  Shots
0  140508235867600  PauliAveraging       4        0  10000
1  140508235867600  PauliAveraging       4        0  10000
2  140508235867600  PauliAveraging       4        0  10000
[<inquanto.protocols.averaging._pauli_averaging.PauliAveraging object at 0x7fca9f7ca5d0>]

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.compile_circuits()

protocols.run(seed=0)
computable_tuple_mix.evaluate(protocols.get_evaluator())
       Protocol ID   Protocol type  Qubits  Depth2q  Shots
0  140508122743376        SwapTest       9        0  10000
1  140507952437584  PauliAveraging       4        0  10000
2  140507952437584  PauliAveraging       4        0  10000
3  140507997952272  PauliAveraging       4        0  10000
4  140507997952272  PauliAveraging       4        0  10000
(-0.9869527172517875, 0.20709664898096042, np.float64(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.compile_circuits()
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.9869804954120797, 0.22476991621493553, OverlapSquared(bra_state=<inquanto.ansatzes._trotter_ansatz.TrotterAnsatz, qubits=4, gates=3, symbols=1>, ket_state=<inquanto.ansatzes._trotter_ansatz.TrotterAnsatz, qubits=4, gates=3, symbols=1>, kernel={(): 1.0}))

Fully evaluated computable:
(-0.9869804954120797, 0.22476991621493553, 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.