Protocols

Protocols represent low-level strategies through which useful quantities such as expectation values, phases, overlaps, fidelities, and derivatives can be calculated and measured using quantum circuits. Protocols can also provide evaluator functions to computables and algorithms which work based on real measurements, statevector or shot-based simulations. Moreover, they can work together with noise mitigation methods, provide resource estimation, and apply measurement optimization. While some protocols are multifunctional, others are highly specialized in computing a single quantity.

Crucially, protocols are the entry point for defining the backend device on which to run experiments. InQuanto makes use of the pytket backend class, and supports the use of third-party backends through pytket extensions. Alternatively for those who use InQuanto with Quantinuum Nexus, quantum devices or simulators may be targeted through qnexus along with a reference to the chosen Nexus project.

In order to use a protocol to generate results, we go through five stages: instancing, building, compiling, running and evaluating. These are detailed in the usage section below.

Generally, protocols fall into three major categories:

1. Statevector Protocols: These protocols, such as SparseStatevectorProtocol or SymbolicProtocol, calculate quantities that can be expressed in the form of \(\langle \text{bra} | \text{operator} | \text{ket} \rangle\), where the \(\langle \text{bra}|\) and \(| \text{ket} \rangle\) states internally will be represented as statevectors.

2. Averaging Protocols: These protocols build measurement circuits for Pauli strings, typically averaging over the distributions retrieved from a quantum device or simulator. Examples include PauliAveraging and HadamardTest. These protocols are specialized for one or only a few quantities. For instance, PauliAveraging calculates expectation values.

3. Quantum Phase Estimation Protocols: These come in various flavors, including those for the construction of canonical and iterative phase estimation variants, and the interpretation of relevant measurement results.

While some protocols share a common API, there is generally no strictly enforced universal API across all protocols, given their potentially diverse functionalities and the different quantities they calculate. In the subsections, protocols are discussed in more details.

Usage

Protocols usage follows a workflow involving instancing, building, compiling, running, and evaluating. The simple workflow below can be modified by adding extensions, for example noise mitigation strategies. Each of these steps modifies the protocol in-place, adding increasing specificity to the generated quantum circuits. Below, we cover the five basic steps.

Instantiate

Much like any Python object, an InQuanto protocol must first be instantiated. At this stage, we specify the overarching details of the experiment which are crucial to logical circuit design. Minimally, we must specify the backend as to which the experiment is to be performed upon, and number of shots to be performed. Depending on the protocol, certain optimization parameters may also be set at this stage.

from pytket.extensions.qiskit import AerBackend
from inquanto.protocols import PauliAveraging

backend = AerBackend()

protocol = PauliAveraging(
    backend,
    shots_per_circuit=1000,

)

Build

Next, a protocol must be built. This means the protocol will generate the logical, architecture-independent quantum circuits required to compute the quantity of interest. To do this, we specify the system parameters. For example, in PauliAveraging, when the build() method is called we provide the state (i.e. QubitState) and the qubit operators (e.g. QubitOperator) that need to be evaluated, and the protocol will then contain

the list of logical measurement circuits needed to evaluate all the measurement terms. These circuits will generally be constructed in terms of pytket circuit boxes. Maintaining the circuit at this abstract level allows for greater flexibility in the following stage (compilation), leading to more efficient use of quantum resources.

# Preparing an small example system

from inquanto.ansatzes import TrotterAnsatz
from inquanto.express import load_h5
from inquanto.operators import QubitOperatorList
from inquanto.states import QubitState

h2 = load_h5("h2_sto3g.h5", as_tuple=True)
qubit_hamiltonian = h2.hamiltonian_operator.qubit_encode()
exponents = QubitOperatorList.from_string("theta [(1j, Y0 X1 X2 X3)]")
reference = QubitState([1, 1, 0, 0])
ansatz = TrotterAnsatz(exponents, reference)
parameters = dict(theta=-0.111)


# Build measurement circuits to evaluate qubit_hamiltonian on ansatz
protocol.build(parameters, ansatz, qubit_hamiltonian)

# We can inspect logical measurement circuits:
from pytket.circuit.display import render_circuit_jupyter
render_circuit_jupyter(protocol.get_circuits()[0])

As the circuits are stored in this abstract box structure at this stage, they are not directly comparable to each other, and may be substantially changed when rebasing and compiling, as in the following stage.

Compile

Following the generation of logical circuits, the protocol must be compiled with the compile_circuits() method. This involves converting the logical, architecture-independent circuits into the architecture-dependent circuits that will be submitted to the target backend. The circuits will be rebased into the gateset that the target backend requires.

At this stage, TKET compilation passes may also be specified to reduce the quantum resources required for the experiment. These are passed in as optional arguments to the compile_circuits() method. Three controllable forms of pass specification are allowed – optimization_level, compiler_passes and preoptimize_passes. optimization_level corresponds to default tket optimization levels provided by the backend; for example, the default tket optimization levels provided by the pytket-quantinuum backend are shown here. preoptimize_passes will be performed prior to the default passes provided by the optimization_level. preoptimize_passes are typically provided by algorithm components (for instance, each ansatz object will provide its own preoptimize_passes). compiler_passes will either replace or be performed after the optimization_level passes, depending on whether the optimization_level argument has been explicitly set. For a detailed explanation of this behaviour, see the API documentation of the compile_circuits() method, for example compile_circuits().

protocol.compile_circuits(optimization_level=1)

# We can get a summary of the resources necessary for the compiled circuits:
print("Circuit resources:")
print(protocol.dataframe_circuit_shot())
# And can examine the actual circuits themselves with the .get_circuits() method:
print("First compiled circuit:")

render_circuit_jupyter(protocol.get_circuits()[0])
Circuit resources:
    Qubits Depth Count Depth2q Count2q DepthCX CountCX  Shots
0        4    10    18       6       6       6       6   1000
1        4    10    18       6       6       6       6   1000
2        4     9    17       6       6       6       6   1000
3        4    10    18       6       6       6       6   1000
4        4    10    17       6       6       6       6   1000
Sum      -     -     -       -       -       -       -   5000
First compiled circuit:

Note how the circuit has been rebased into a specific gateset, and the circuit boxes have been decomposed.

Run

Compiled measurement circuits can be submitted to hardware and emulators with the run() and launch() methods. The former of these waits (i.e. locking the Python kernel) for results to be returned, whereas the second runs asynchronously, with results later retrieved from the backend using retrieve().

If using remote devices, the launch and retrieve approach is recommended, and protocols can be saved to disk to ensure consistency. These methods will send all required measurement circuits to the pytket backend for evaluation, returning the shots table.

protocol.run()
print(protocol.dataframe_measurements())
   Pauli string   Mean  Standard error  Sample size
0            Z0 -0.970        0.007691         1000
1            Z3  0.970        0.007691         1000
2   X0 Y1 Y2 X3 -0.254        0.030601         1000
3   Y0 Y1 X2 X3  0.212        0.030919         1000
4            Z2  0.970        0.007691         1000
5         Z1 Z3 -1.000        0.000000         1000
6   X0 X1 Y2 Y3  0.136        0.031345         1000
7         Z2 Z3  1.000        0.000000         1000
8         Z0 Z2 -1.000        0.000000         1000
9         Z1 Z2 -1.000        0.000000         1000
10           Z1 -0.970        0.007691         1000
11        Z0 Z1  1.000        0.000000         1000
12        Z0 Z3 -1.000        0.000000         1000
13  Y0 X1 X2 Y3 -0.254        0.030601         1000

Evaluate

Finally, when the shots table have been returned to the protocol, it can be used to evaluate (e.g evaluate_expectation_value()) its target quantity. These can be used in complex Computable structures. Note that the evaluation step must again be informed of the logical details of the experiment, as below. This allows for evaluation of compatible operators without repeatedly performing the experiment.

result = protocol.evaluate_expectation_value(ansatz, 2.0 * qubit_hamiltonian)
print(result)
-2.262418290546007