Noise mitigation

Near-term quantum devices are inherently noisy: qubits can decohere and manipulation of qubits is imperfect. To combat the effects of noise a wide range of approaches are being developed, which include scalable error correction methods and various quantum error mitigation techniques. Many of these are general schemes and they are not specific to chemistry simulations, however there are also methods that are specifically designed for quantum chemistry algorithms (such as taking advantage of molecular symmetries) to calculate a more accurate final result.

General error mitigation methods can be applied to chemistry calculations in InQuanto by importing from the Qermit package [35]. This is a flexible open-source quantum error mitigation package developed by Quantinuum which can modify circuits and perform post-processing to improve results. Many schemes are available in Qermit, and they can be applied to InQuanto calculations through use of run_mitres() or run_mitex().

Alongside Qermit support, InQuanto offers additional error mitigation schemes. In particular we highlight Partition Measurement Symmetry Verification (PMSV), which uses symmetries of the Hamiltonian to validate measurements. Another approach involves mitigating state preparation and measurement (SPAM) noise, which enhances the precision of the energy derived from quantum hardware in comparison to the precise value obtained through state-vector simulations on a classical computer. These InQuanto error mitgation methods are contained in the protocols module, and are applied at the point of build(). These classes can seamlessly integrate with Qermit workflows.

Below, we showcase the application of both Qermit and InQuanto error mitigation techniques to InQuanto’s PauliAveraging protocol. To achieve this, we construct a simple 2-qubit ansatz and operator, and import the necessary dependencies:

from pytket import Circuit
from sympy import Symbol, pi

from inquanto.ansatzes import CircuitAnsatz
from inquanto.operators import QubitOperator

circ = Circuit(2)
circ.Ry(-2 * Symbol("a") / pi, 0)
circ.CX(0, 1)
circ.Rz(-2 * Symbol("b") / pi, 1)
circ.Rx(-2 * Symbol("c") / pi, 1)
circ.CX(1, 0)
circ.Ry(-2 * Symbol("d") / pi, 0)

ansatz = CircuitAnsatz(circ)
kernel = QubitOperator("X0 X1", 2) + QubitOperator("Y0 Y1", 2) + QubitOperator("Z0 Z1", 2)
parameters = ansatz.state_symbols.construct_from_array([0.1, 0.2, 0.3, 0.4])

We will calculate the expectation value of the operator kernel with the ansatz using PauliAveraging, and qiskit’s AerBackend with a basic noise model. The InQuanto express module offers a get_noisy_backend() utility function to quickly set up a simple noisy backend for demonstration purposes:

from inquanto.express import get_noisy_backend
from inquanto.protocols import PauliAveraging

noisy_backend = get_noisy_backend(n_qubits=2)
protocol = PauliAveraging(noisy_backend, shots_per_circuit=10000)

We are now ready to build the circuits and run them, applying the mitigation schemes.

Using Qermit

Qermit provides detailed API documentation and offers two types of error mitigation workflows: MitRes (mitigation of results) and MitEx (mitigation of expectation values). The PauliAveraging protocol supports both of these workflows. To begin, we will build the protocol without any InQuanto noise mitigation.

protocol.build(parameters, ansatz, kernel)
protocol.compile_circuits();

Typically, we can call the run() method of the protocol at this point. However, if we wish to use a MitRes or MitEx object, we need to call run_mitres() or run_mitex() respectively. For example, to use Qermit’s State preparation and measurement scheme (SPAM), follow these steps:

from qermit.spam import gen_UnCorrelated_SPAM_MitRes

uc_spam_mitres = gen_UnCorrelated_SPAM_MitRes(
    backend=noisy_backend, calibration_shots=50
)
protocol.run_mitres(uc_spam_mitres, {})

energy_value = protocol.evaluate_expectation_value(ansatz, kernel)
print("MitRes (SPAM): ", energy_value)
MitRes (SPAM):  1.5266797685149565

Alternatively, we could use Qermit’s zero-noise extrapolation (ZNE) method as such:

from qermit.zero_noise_extrapolation import gen_ZNE_MitEx

zne_mitex = gen_ZNE_MitEx(
    backend=noisy_backend, noise_scaling_list=[3]
)
protocol.run_mitex(zne_mitex, {})

energy_value = protocol.evaluate_expectation_value(ansatz, kernel)
print("MitEx (ZNE3): ", energy_value)
print("Exact: ", 1.5196420749021882)
MitEx (ZNE3):  1.52480000000000
Exact:  1.5196420749021882

In both cases, after running, we can call evaluate_expectation_value() to evaluate the final result.

Note that running a Qermit graph will often perform a significant amount of circuit preparation, evaluation, and post-processing, all of which must be completed synchronously.

Using InQuanto’s PMSV and SPAM

An alternative to Qermit is using InQuanto’s noise mitigation classes. These can be particularly useful for those who want to perform asynchronous jobs (i.e. launch/retrieve logic), enabling the separation of pre-measurement from post-measurement workflows.

A simple SPAM error mitigation can be implemented as follows:

from inquanto.protocols import SPAM

protocol = PauliAveraging(noisy_backend, shots_per_circuit=10000)

spam = SPAM(backend=noisy_backend).calibrate(
    calibration_shots=50, seed=0
)

protocol.build(parameters, ansatz, kernel).compile_circuits().run(seed=0)
energy_value = protocol.evaluate_expectation_value(ansatz, kernel)
print("Raw: ", energy_value)

protocol.clear()
protocol.build(
    parameters, ansatz, kernel, noise_mitigation=spam
).compile_circuits().run(seed=0)

energy_value = protocol.evaluate_expectation_value(ansatz, kernel)
print("NoiseMitigation (SPAM): ", energy_value)
Raw:  1.4407999999999999
NoiseMitigation (SPAM):  1.5000999800039994

In the next example, we use PMSV (inquanto.protocols.PMSV) for a chemical system (H2). We begin by preparing the system and evaluating the state vector energy for comparison. This state vector and the inquanto.protocols.PauliAveraging protocols that follow are built with optimized parameters (p). The quick-start guide shows an example of how this can be done.

from inquanto.express import get_system
from inquanto.ansatzes import FermionSpaceStateExpChemicallyAware
from inquanto.computables import ExpectationValue
import numpy as np

hamiltonian, space, state = get_system("h2_sto3g_symmetry.h5")
kernel = hamiltonian.qubit_encode()
exponents = space.construct_single_ucc_operators(state)
# Note that due to molecular symmetry, this space will not contain single excitations
exponents += space.construct_double_ucc_operators(state)

ansatz = FermionSpaceStateExpChemicallyAware(exponents, state)
# Optimized parameters
p = {"d0": np.float64(-0.10723347230091483)}

sv = ExpectationValue(ansatz, kernel=kernel).default_evaluate(p)
print("State vector:", sv)
State vector: -1.1368465754720525

To illustrate how the number of shots changes when PMSV is applied, next we run the protocol without PMSV.

# We need a 4 qubit noisy backend
noisy_backend = get_noisy_backend(4)

# The protocol must be instantiated, built, and compiled before it is run
protocol = PauliAveraging(noisy_backend, shots_per_circuit=10000)
protocol.build(p, ansatz, kernel)
protocol.compile_circuits()
protocol.run(seed=0)

no_pmsv_energy = protocol.evaluate_expectation_value(ansatz, kernel)
print(protocol.dataframe_measurements())
print("\nWithout PMSV:", no_pmsv_energy)
   Pauli string    Mean  Standard error  Sample size
0            Z0 -0.9382        0.003461        10000
1            Z3  0.9338        0.003578        10000
2   X0 Y1 Y2 X3 -0.1834        0.009831        10000
3   Y0 Y1 X2 X3  0.1884        0.009821        10000
4            Z2  0.9364        0.003510        10000
5         Z1 Z3 -0.9240        0.003824        10000
6   X0 X1 Y2 Y3  0.1946        0.009809        10000
7         Z2 Z3  0.9514        0.003080        10000
8         Z0 Z2 -0.9290        0.003701        10000
9         Z1 Z2 -0.9274        0.003741        10000
10           Z1 -0.9362        0.003515        10000
11        Z0 Z1  0.9552        0.002960        10000
12        Z0 Z3 -0.9264        0.003766        10000
13  Y0 X1 X2 Y3 -0.1808        0.009836        10000

Without PMSV: -1.0720246772983035

Finally, we build and run the protocol with PMSV. PMSV prepares a set of expected Pauli string results and adds measurement of those Pauli strings to the circuit. If a shot does not have the expected symmetry for the set of stabilizers, it is discarded. Further details are given in the appendix of the paper by Yamamoto et. al. [36]. It is up to the user to determine suitable symmetries (stabilizers) for their system. To this end, the inquanto.spaces.FermionSpace.symmetry_operators_z2_in_sector() and inquanto.spaces.QubitSpace.symmetry_operators_z2_in_sector() methods can be applied to fermionic and qubit space objects respectively.

from inquanto.protocols import PMSV

stabilizers = [
    -1 * QubitOperator("Z0 Z2"),
    -1 * QubitOperator("Z1 Z3"),
    +1 * QubitOperator("Z2 Z3"),
]

protocol = PauliAveraging(noisy_backend, shots_per_circuit=10000)
pmsv = PMSV(stabilizers)
# The protocol is built with PMSV
protocol.build(p, ansatz, kernel, noise_mitigation=pmsv)
protocol.compile_circuits()
protocol.run(seed=0)

pmsv_energy = protocol.evaluate_expectation_value(ansatz, kernel)
print(protocol.dataframe_measurements())
print("\nWith PMSV:", pmsv_energy)
   Pauli string      Mean  Standard error  Sample size
0            Z0 -0.972352        0.002408         9404
1            Z3  0.972352        0.002408         9404
2   X0 Y1 Y2 X3 -0.183075        0.010099         9477
3   Y0 Y1 X2 X3  0.192182        0.010088         9465
4            Z2  0.972352        0.002408         9404
5         Z1 Z3 -1.000000        0.000000        47271
6   X0 X1 Y2 Y3  0.182902        0.010101         9475
7         Z2 Z3  1.000000        0.000000        47271
8         Z0 Z2 -1.000000        0.000000        47271
9         Z1 Z2 -1.000000        0.000000         9404
10           Z1 -0.972352        0.002408         9404
11        Z0 Z1  1.000000        0.000000         9404
12        Z0 Z3 -1.000000        0.000000         9404
13  Y0 X1 X2 Y3 -0.195767        0.010088         9450

With PMSV: -1.1285645055559776

We can see that each Pauli string does not have the specified 10000 shots. Instead, about five percent of the shots for each string have been discarded because they fail the symmetry check. This discard rate will depend on the noise in the circuit, and the symmetries used. Also, the Pauli strings corresponding to the stabilizers have almost 50000 shots, five times the number of the other strings. This arises as the strings that comprise the stabilizer set are measured in every circuit and in this case the Pauli partition strategy , applied when the protocol was built, splits the state circuit into five measurement circuits.

PMSV can be effective at treating device noise at the expense of discarding shots, often pushing the resulting value closer to the noiseless state vector, as it does in this example.