Solution to hands-on lab on error mitigation with Mitiq.#

This is a hands-on notebook created for the SQMS/GGI 2022 Summer School on Quantum Simulation of Field Theories.

It is a guided tutorial on error mitigation with Mitiq and is focused on the zero-noise extrapolation (ZNE) technique. As this is intended to be a hands-on exercise, the solutions to the examples are provided in this notebook.

Useful links :

../_images/zne_workflow2_steps.png

The diagram shows the workflow of the zero noise extrapolation (ZNE) technique in Mitiq.#

Checking Python packages are installed correctly#

This notebook was tested with Mitiq v0.23.0 and qiskit v0.41.0. It probably works with other versions too. Moreover, with minor changes, it can be adapted to quantum libraries that are different from Qiskit: Cirq, Braket, PyQuil, etc..

If you need to install Mitiq and/or Qiskit, you can uncomment and run the following cells.

# !pip install mitiq==0.23.0
# !pip install qiskit==0.41.0

You can check your locally installed version of Mitiq and of the associated frontend libraries by running the next cell.

from mitiq import about

about()
Mitiq: A Python toolkit for implementing error mitigation on quantum computers
==============================================================================
Authored by: Mitiq team, 2020 & later (https://github.com/unitaryfund/mitiq)

Mitiq Version:	0.37.0

Core Dependencies
-----------------
Cirq Version:	1.3.0
NumPy Version:	1.26.4
SciPy Version:	1.13.1

Optional Dependencies
---------------------
PyQuil Version:	3.5.4
Qiskit Version:	Not installed
Braket Version:	1.69.1

Python Version:	3.11.6
Platform Info:	Linux (x86_64)

Computing a quantum expectation value without error mitigation#

Define the circuit of interest#

For example, we define a circuit \(U\) that prepares the GHZ state for \(n\) qubits.

\[ U |00...0\rangle = \frac{|00...0\rangle + |11...1\rangle}{\sqrt{2}} \]

This can be done by manually defining a Qiskit circuit or by calling the Mitiq function mitiq.benchmarks.generate_ghz_circuit().

from mitiq.benchmarks import generate_ghz_circuit


n_qubits = 7

circuit = generate_ghz_circuit(n_qubits=n_qubits, return_type="qiskit")
print("GHZ circuit:")
print(circuit)
GHZ circuit:
     ┌───┐                              
q_0: ┤ H ├──■───────────────────────────
     └───┘┌─┴─┐                         
q_1: ─────┤ X ├──■──────────────────────
          └───┘┌─┴─┐                    
q_2: ──────────┤ X ├──■─────────────────
               └───┘┌─┴─┐               
q_3: ───────────────┤ X ├──■────────────
                    └───┘┌─┴─┐          
q_4: ────────────────────┤ X ├──■───────
                         └───┘┌─┴─┐     
q_5: ─────────────────────────┤ X ├──■──
                              └───┘┌─┴─┐
q_6: ──────────────────────────────┤ X ├
                                   └───┘

Let us define the Hermitian observable:

\[ A = |00...0\rangle\langle 00...0| + |11...1\rangle\langle 11...1|.\]

In the absence of noise, the expectation value of \(A\) is equal to 1:

\[{\rm tr}(\rho_{\rm} A)= \langle 00...0| U^\dagger A U |00...0\rangle= \frac{1}{2} + \frac{1}{2}=1.\]

In practice this means that, when measuring the state in the computational basis, we can only obtain either the bitstring \(00\dots 0\) or the biststring \(11\dots 1\).

In the presence of noise instead, the expectation value of the same observable \(A\) will be smaller. Let’s verify this fact, before applying any error mitigation.

Run the circuit with a noiseless backend and with a noisy backend#

Hint: As a noiseless backend you can use the AerSimulator class. As a noisy backend you can use a fake (simulated) device as shown here.

from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator
from qiskit.visualization import plot_histogram
from qiskit import transpile
from qiskit_ibm_runtime.fake_provider import FakeJakartaV2 as FakeJakarta  # Fake (simulated) QPUs

# Number of measurements
shots = 10 ** 5

We first execute the circuit on an ideal noiseless simulator.

ideal_backend = AerSimulator()

# Append measurement gates
circuit_to_run = circuit.copy()
circuit_to_run.measure_all()

ideal_result = ideal_backend.run(circuit_to_run, shots=shots).result()
ideal_counts = ideal_result.get_counts(circuit_to_run)
plot_histogram(ideal_counts, title='Counts for an ideal GHZ state')
../_images/f1413ada5479fef5b3039b794f80c0e152739b0d44a1f7780088bb688d02338f.png

We now execute the same circuit on a noisy backend (a classical emulator of a real QPU)

noisy_backend = FakeJakarta() # QPU emulator

# Compile the circuit into the native gates of the backend
compiled_circuit = transpile(circuit_to_run, noisy_backend)
# Run the simulation on the noisy backend
noisy_result = noisy_backend.run(compiled_circuit, shots=shots).result()
noisy_counts = noisy_result.get_counts(compiled_circuit)
plot_histogram(noisy_counts, title='Counts for a noisy GHZ state', figsize=(15, 5))
../_images/09b2738c1729313dc909a860117cbabe0955c78d29023f06f8c0b61b695e34bc.png
ideal_expectation_value = (ideal_counts[n_qubits * "0"] + ideal_counts[n_qubits * "1"]) / shots
print(f"The ideal expectation value is <A> = {ideal_expectation_value}")

noisy_expectation_value = (noisy_counts[n_qubits * "0"] + noisy_counts[n_qubits * "1"]) / shots
print(f"The noisy expectation value is <A> = {noisy_expectation_value}")
The ideal expectation value is <A> = 1.0
The noisy expectation value is <A> = 0.79561

Apply zero-noise extrapolation with Mitiq#

Before using Mitiq we need wrap the previous code into a function that takes as input a circuit and returns the noisy expectation value of the observable \(A\). This function will be used by Mitiq as a black box during the error mitigation process.

def execute(compiled_circuit):
    """Executes the input circuits and returns the expectation value of A=|00..0><00..0| + |11..1><11..1|."""
    print("Executing a circuit of depth:", compiled_circuit.depth())
    noisy_backend = FakeJakarta()
    noisy_result = noisy_backend.run(compiled_circuit, shots=shots).result()
    noisy_counts = noisy_result.get_counts(compiled_circuit)
    noisy_expectation_value = (noisy_counts[n_qubits * "0"] + noisy_counts[n_qubits * "1"]) / shots
    return noisy_expectation_value

Let us check if the function works as expeted.

print(f"The noisy expectation value is <A> = {execute(compiled_circuit)}")
Executing a circuit of depth: 16
The noisy expectation value is <A> = 0.79444

We can now apply zero-noise extrapolation with Mitiq. Without advanced options, this requires a single line of code.

from mitiq import zne

zne_value = zne.execute_with_zne(compiled_circuit, executor=execute)
print(f"The error mitigated expectation value is <A> = {zne_value}")
Executing a circuit of depth: 16
Executing a circuit of depth: 30
Executing a circuit of depth: 46
The error mitigated expectation value is <A> = 0.7897799999999996

Note: As you can see from the printed output, Mitiq calls the execute function multiple times (3 in this case) to evaluate circuits of different depths in order to extrapolate the ideal result.

Let us compare the absolute estimation error obtained with and without Mitiq.

print(f"Error without Mitiq: {abs(ideal_expectation_value - noisy_expectation_value)}")
print(f"Error with Mitiq: {abs(ideal_expectation_value - zne_value)}")
Error without Mitiq: 0.20438999999999996
Error with Mitiq: 0.2102200000000004

Explicitly selecting the noise-scaling method and the extrapolation method#

from mitiq import zne

# Select a noise scaling method
folding_function = zne.scaling.fold_global

# Select an inference method
factory = zne.inference.RichardsonFactory(scale_factors = [1.0, 2.0, 3.0])

zne_value = zne.execute_with_zne(
    compiled_circuit, 
    executor=execute,
    scale_noise = folding_function,
    factory = factory,
)
factory.plot_fit()
print(f"The error mitigated expectation value is <A> = {zne_value}")
Executing a circuit of depth: 16
Executing a circuit of depth: 32
Executing a circuit of depth: 46
The error mitigated expectation value is <A> = 0.911689999999999
../_images/471660fbe3ff6d4020915d97f17d1ac23aeb567053fd650328a774be4423c372.png

What happens behind the scenes? A low-level application of ZNE#

In Mitiq one can indirectly amplify noise by intentionally increasing the depth of the circuit in different ways.

For example, the function zne.scaling.fold_gates_at_random() applies transformation \(G \rightarrow G G^\dagger G\) to each gate of the circuit (or to a random subset of gates).

STEP 1: Noise-scaled expectation values are evaluated via gate-level “unitary folding” transformations#

locally_folded_circuit = zne.scaling.fold_gates_at_random(circuit, scale_factor=3)

print("Locally folded GHZ circuit:")
print(locally_folded_circuit)
Locally folded GHZ circuit:
     ┌───┐┌───┐┌───┐                                                       »
q_0: ┤ H ├┤ H ├┤ H ├──■────■────■──────────────────────────────────────────»
     └───┘└───┘└───┘┌─┴─┐┌─┴─┐┌─┴─┐                                        »
q_1: ───────────────┤ X ├┤ X ├┤ X ├──■────■────■───────────────────────────»
                    └───┘└───┘└───┘┌─┴─┐┌─┴─┐┌─┴─┐                         »
q_2: ──────────────────────────────┤ X ├┤ X ├┤ X ├──■────■────■────────────»
                                   └───┘└───┘└───┘┌─┴─┐┌─┴─┐┌─┴─┐          »
q_3: ─────────────────────────────────────────────┤ X ├┤ X ├┤ X ├──■────■──»
                                                  └───┘└───┘└───┘┌─┴─┐┌─┴─┐»
q_4: ────────────────────────────────────────────────────────────┤ X ├┤ X ├»
                                                                 └───┘└───┘»
q_5: ──────────────────────────────────────────────────────────────────────»
                                                                           »
q_6: ──────────────────────────────────────────────────────────────────────»
                                                                           »
«                                        
«q_0: ───────────────────────────────────
«                                        
«q_1: ───────────────────────────────────
«                                        
«q_2: ───────────────────────────────────
«                                        
«q_3: ──■────────────────────────────────
«     ┌─┴─┐                              
«q_4: ┤ X ├──■────■────■─────────────────
«     └───┘┌─┴─┐┌─┴─┐┌─┴─┐               
«q_5: ─────┤ X ├┤ X ├┤ X ├──■────■────■──
«          └───┘└───┘└───┘┌─┴─┐┌─┴─┐┌─┴─┐
«q_6: ────────────────────┤ X ├┤ X ├┤ X ├
«                         └───┘└───┘└───┘

Note: To get a simple visualization, we did’t apply the preliminary circuit transpilation that we used in the previous section.

Alternatively, the function zne.scaling.fold_global() applies the transformation \(U \rightarrow U U^\dagger U\) to the full circuit.

globally_folded_circuit = zne.scaling.fold_global(circuit, scale_factor=3)
print("Globally folded GHZ circuit:")
print(globally_folded_circuit)
Globally folded GHZ circuit:
     ┌───┐                                                            ┌───┐»
q_0: ┤ H ├──■──────────────────────────────────────────────────────■──┤ H ├»
     └───┘┌─┴─┐                                                  ┌─┴─┐└───┘»
q_1: ─────┤ X ├──■────────────────────────────────────────────■──┤ X ├─────»
          └───┘┌─┴─┐                                        ┌─┴─┐└───┘     »
q_2: ──────────┤ X ├──■──────────────────────────────────■──┤ X ├──────────»
               └───┘┌─┴─┐                              ┌─┴─┐└───┘          »
q_3: ───────────────┤ X ├──■────────────────────────■──┤ X ├───────────────»
                    └───┘┌─┴─┐                    ┌─┴─┐└───┘               »
q_4: ────────────────────┤ X ├──■──────────────■──┤ X ├────────────────────»
                         └───┘┌─┴─┐          ┌─┴─┐└───┘                    »
q_5: ─────────────────────────┤ X ├──■────■──┤ X ├─────────────────────────»
                              └───┘┌─┴─┐┌─┴─┐└───┘                         »
q_6: ──────────────────────────────┤ X ├┤ X ├──────────────────────────────»
                                   └───┘└───┘                              »
«     ┌───┐                              
«q_0: ┤ H ├──■───────────────────────────
«     └───┘┌─┴─┐                         
«q_1: ─────┤ X ├──■──────────────────────
«          └───┘┌─┴─┐                    
«q_2: ──────────┤ X ├──■─────────────────
«               └───┘┌─┴─┐               
«q_3: ───────────────┤ X ├──■────────────
«                    └───┘┌─┴─┐          
«q_4: ────────────────────┤ X ├──■───────
«                         └───┘┌─┴─┐     
«q_5: ─────────────────────────┤ X ├──■──
«                              └───┘┌─┴─┐
«q_6: ──────────────────────────────┤ X ├
«                                   └───┘

In both cases, the results are longer circuits which are more sensitive to noise. Those circuits can be used to evaluate noise scaled expectation values.

For example, let’s use global folding to evaluate a list of noise scaled expectation values.

scale_factors = [1.0, 2.0, 3.0]
# It is usually better apply unitary folding to the compiled circuit
noise_scaled_circuits = [zne.scaling.fold_global(compiled_circuit, s) for s in scale_factors]

# We run all the noise scaled circuits on the noisy backend
noise_scaled_vals = [execute(c) for c in noise_scaled_circuits]

print("Noise-scaled expectation values:", noise_scaled_vals)
Executing a circuit of depth: 16
Executing a circuit of depth: 32
Executing a circuit of depth: 46
Noise-scaled expectation values: [0.79687, 0.72828, 0.72025]

STEP 2: Inference of the ideal result via zero-noise extrapolation#

Given the list of noise scaled expectation values, one can extrapolate the zero-noise limit. This is the final classical post-processing step.

# Initialize a Richardson extrapolation object
richardson_factory = zne.RichardsonFactory(scale_factors)

# Load the previously measured data
for s, val in zip(scale_factors, noise_scaled_vals):
    richardson_factory.push({"scale_factor": s}, val)

print("The Richardson zero-noise extrapolation is:", richardson_factory.reduce())
_ = richardson_factory.plot_fit()
The Richardson zero-noise extrapolation is: 0.9260199999999981
../_images/bcdf1cb57f39feda4c06beb8074ac98defa3872634fd58c6dd83a0632626c417.png
# Initialize a linear extrapolation object
linear_factory = zne.LinearFactory(scale_factors)

# Load the previously measured data
for s, val in zip(scale_factors, noise_scaled_vals):
    linear_factory.push({"scale_factor": s}, val)

print("The linear zero-noise extrapolation is", linear_factory.reduce())
_ = linear_factory.plot_fit()
The linear zero-noise extrapolation is 0.8250866666666669
../_images/2bd2ad0f20b12ebada0478750a65689afa6a34c2096d2b73e7136f1f956ab988.png

Note: We evaluated two different extrapolations without measuring the system twice. This is possible since the final extrapolation step is simply a classical post-processing of the same measured data.

References#

  1. Mitiq: A software package for error mitigation on noisy quantum computers, R. LaRose at al., arXiv:2009.04417 (2020).

  2. Efficient variational quantum simulator incorporating active error minimisation, Y. Li, S. C. Benjamin, arXiv:1611.09301 (2016).

  3. Error mitigation for short-depth quantum circuits, K. Temme, S. Bravyi, J. M. Gambetta, arXiv:1612.02058 (2016).

  4. Digital zero noise extrapolation for quantum error mitigation, T. Giurgica-Tiron, Y. Hindy, R. LaRose, A. Mari, W. J. Zeng, arXiv:2005.10921 (2020).