Quantinuum H-Series - Launching circuits and retrieving results

In this tutorial, we will demonstrate the process of initiating circuits on the backend and obtaining the corresponding result handles using the Quantinuum H-Series. To illustrate this, we will conduct a Hamiltonian averaging experiment for a 6-qubit active-space of the Methane chemical system.

This tutorial will require that your InQuanto administrator has granted you access to the Quantinuum systems. You will also need credentials to run on a 6 qubit machine for a short time.

The steps below are such:

  • Configuring the Quantinuum backend

  • Define the system

  • Find optimal VQE parameters on a noiseless simulator

  • Define protocol and build InQuanto computables

  • Submit circuits to backend

  • Retrieve results from backend

  • Evaluate the expectation value of the system

The express module is used to load in the converged mean-field (Hartree-Fock) spin-orbitals, and Hamiltonian from a H\(_2\) computation using the STO-3G basis set.

[ ]:
import warnings
warnings.filterwarnings('ignore')

from inquanto.express import load_h5

h2 = load_h5("h2_sto3g.h5", as_tuple=True)
hamiltonian = h2.hamiltonian_operator

The Fermionic space (FermionSpace) is defined with 4 spin-orbitals (which matches the full H2 STO-3G space) and the D2h point group is employed. This point group is the most practical high symmetry group to approximate the D(infinity)h group. We also explicitly define the orbital symmetries.

The Fermionic state (FermionState) is then determined by the ground state occupations [1,1,0,0] and the Hamiltonian is encoded from the Hartree-fock integrals. The qubit_encode function carries out qubit encoding, utilizing the mapping class associated with the current integral operator. The default mapping approach is the Jordan-Wigner method.

[ ]:
from inquanto.spaces import FermionSpace
from inquanto.states import FermionState
from inquanto.symmetry import PointGroup

space = FermionSpace(
    4, point_group=PointGroup("D2h"), orb_irreps=["Ag", "Ag", "B1u", "B1u"]
)

state = FermionState([1, 1, 0, 0])
qubit_hamiltonian = hamiltonian.qubit_encode()

To construct our ansatz for the specified fermion space and fermion state, we have employed the Chemically Aware Unitary Coupled Cluster method with singles and doubles excitations (UCCSD). The circuit is synthesized using Jordan-Wigner encoding.

[ ]:
from inquanto.ansatzes import FermionSpaceAnsatzChemicallyAwareUCCSD

ansatz = FermionSpaceAnsatzChemicallyAwareUCCSD(space, state)

Here, we perform a simple experiment using a Variational Quantum Eigensolver (VQE) on a statevector backend to identify the optimal parameters that result in the ground state energy of our system. This enables us to carry out experiments on both quantum hardware and emulators using these pre-optimized parameters. For a more comprehensive guide on performing VQE calculations using InQuanto on quantum computers, we recommend referring to the VQE tutorial.

[ ]:
from inquanto.express import run_vqe
from pytket.extensions.qulacs import QulacsBackend

state_backend = QulacsBackend()

vqe = run_vqe(ansatz, hamiltonian, backend=state_backend, with_gradient=False)

parameters = vqe.final_parameters
# TIMER BLOCK-0 BEGINS AT 2024-02-19 09:59:11.452381
# TIMER BLOCK-0 ENDS - DURATION (s):  0.3955611 [0:00:00.395561]

To reduce errors and inaccuracies caused by quantum noise and imperfections in the Quantinuum device, we can employ noise mitigation techniques. In this case, we will define the Qubit Operator symmetries within the system, enabling us to utilize PMSV (Partition Measurement Symmetry Verification). PMSV is an efficient technique for symmetry-verified quantum calculations. It represents molecular symmetries using Pauli strings, including mirror planes (Z2) and electron-number conservation (U1). For systems with Abelian point group symmetry, qubit tapering methods can be applied. PMSV uses commutation between Pauli symmetries and Hamiltonian terms for symmetry verification. It groups them into sets of commuting Pauli strings. If each string in a set commutes with the symmetry operator, measurement circuits for that set can be verified for symmetry without additional quantum resources, discarding measurements violating system point group symmetries.

Parameters used:

stabilisers – List of state stabilzers as QubitOperators with only single pauli strings in them.

The InQuanto symmetry_operators_z2_in_sector function is employed to retrieve a list of symmetry operators applicable to our system. These symmetry operators are associated with the point group, spin parity, and particle number parity Z2 symmetries that uphold a specific symmetry sector. You can find additional details regarding this in the linked page.

[ ]:
from inquanto.protocols import PMSV
from inquanto.mappings import QubitMappingJordanWigner

stabilizers = QubitMappingJordanWigner().operator_map(
    space.symmetry_operators_z2_in_sector(state)
)

mitms_pmsv = PMSV(stabilizers)

To simulate the specific noise profiles of machines, we can load and apply them to our simulations using the QuantinuumBackend, which retrieves information from your Quantinuum account. The QuantinuumBackend offers a range of available emulators, such as H1-1E and H1-2E. These are device-specific emulators for the corresponding hardware devices. These emulators run only remotely on a server. Additional information about the pytket-quantinuum extension can be found in the link.

Parameters used:

device_name – Name of device, e.g. “H1-1E”

label – Job labels used if Circuits have no name, defaults to “job”

group – string identifier of a collection of jobs, can be used for usage tracking.

[ ]:
from pytket.extensions.quantinuum import QuantinuumBackend

backend = QuantinuumBackend(device_name="H1-1E", label="test", group ="Default - UK")

To compute the expectation value of a Hermitian operator through operator averaging on the system register, we employ the PauliAveraging protocol. This protocol effectively implements the procedure outlined in ‘Operator Averaging’.

[ ]:
from inquanto.protocols import PauliAveraging
from pytket.partition import PauliPartitionStrat


protocol = PauliAveraging(
    backend,
    shots_per_circuit=5000,
    pauli_partition_strategy=PauliPartitionStrat.CommutingSets,
)

A protocol has been constructed to process a computable dataset and calculate the expected value.

[ ]:
# requires Quantinuum credentials
protocol.build(parameters, ansatz, qubit_hamiltonian, noise_mitigation=mitms_pmsv)
<inquanto.protocols.averaging._pauli_averaging.PauliAveraging at 0x7faf11e91c00>

You can also display a Pandas DataFrame using dataframe_measurements containing columns ‘pauli_string,’ ‘mean,’ and ‘stderr.’ Each row corresponds to a distinct Pauli string and its respective mean and standard error. Moreover, the dataframe_circuit_shot function generates a Pandas DataFrame containing circuit, shot, and depth details.

[ ]:
print(protocol.dataframe_measurements())
print('')
print(protocol.dataframe_circuit_shot())
   pauli_string  mean stderr umean sample_size
0            Z0  None   None  None        None
1            Z3  None   None  None        None
2            Z2  None   None  None        None
3   Y0 Y1 X2 X3  None   None  None        None
4   X0 Y1 Y2 X3  None   None  None        None
5         Z1 Z3  None   None  None        None
6   X0 X1 Y2 Y3  None   None  None        None
7         Z2 Z3  None   None  None        None
8         Z0 Z2  None   None  None        None
9         Z1 Z2  None   None  None        None
10           Z1  None   None  None        None
11        Z0 Z1  None   None  None        None
12        Z0 Z3  None   None  None        None
13  Y0 X1 X2 Y3  None   None  None        None

    Qubits Depth Depth2q DepthCX  Shots
0        4    11       5       0   5000
1        4     8       3       0   5000
Sum      -     -       -       -  10000

The dumps function allows you to pickle protocols for later reloading using loads. Additionally, you have the option to clear internal protocol data using clear.

[ ]:
pickled_data = protocol.dumps()
new_protocol = PauliAveraging.loads(pickled_data, backend)

protocol.clear()
<inquanto.protocols.averaging._pauli_averaging.PauliAveraging at 0x7faf11e91c00>

Running an experiment involves launching the circuits to the backend using the launch function. This approach handles all the circuits related to the expectation value calculations and provides a list of ResultHandle objects, each representing a handle for the results. Alternatively, an experiment can be initiated by employing the run function, which automatically executes the launch and retrieve methods. Typically, the run method is more useful for statevector calculations where you will receive your results from the backend immediately. On the other hand, launch and retrieve are more suitable for situations in which you expect a delay in receiving the results.

You could attempt both methods and print out the computational details to verify that you obtain the same results.

[ ]:
handles = new_protocol.launch()

We can pickle these ResultHandle objects so we can retrieve the results once there are ready.

[ ]:
import pickle

with open("handles.pickle", "wb") as handle:
    pickle.dump(handles, handle, protocol=pickle.HIGHEST_PROTOCOL)

You can monitor the progress of your experiments on the Quantinuum page using the same credentials you used to run the experiments.

After our experiments have finished, we can obtain the results by utilizing the retrieve function, which retrieves distributions from the backend for the specified source. The expectation value of a kernel for a specified quantum state is calculated by using the evaluate_expectation_value function. In addition, we have employed the evaluate_expectation_uvalue function, which calculates the expectation value of the Hermitian kernel while considering linear error propagation theory.

[ ]:
with open("handles.pickle", "rb") as handle:
    new_handles = pickle.load(handle)
print(new_handles)
[ResultHandle('50106a91ceb048e3bba7bcab26de7320', 'null', 4, '[["c", 0], ["c", 1], ["c", 2], ["c", 3]]'), ResultHandle('483171224e0b43469e22fb7467939d0a', 'null', 4, '[["c", 0], ["c", 1], ["c", 2], ["c", 3]]')]

We can use the backend to simply query the job to see if it has completed. If all the circuits have run we can collect and process the results.

[ ]:
completion_check=backend.circuit_status(new_handles[-1])[0].value #n-1
print(completion_check)
Circuit is queued.

Below we evaluate the expectation value and its uncertainty due to noise and sampling.

[ ]:
if completion_check=='Circuit has completed. Results are ready.':
    new_protocol.retrieve(new_handles)

    energy_value = new_protocol.evaluate_expectation_value(ansatz, qubit_hamiltonian)
    print("Energy Value:\n{}".format(energy_value))

    error = new_protocol.evaluate_expectation_uvalue(ansatz, qubit_hamiltonian)
    print("Energy with error:\n{}".format(error))
else:
    print('Results not yet complete. ')
Energy Value:
-1.1364071606946333
Energy with error:
-1.1364+/-0.0018