What happens when I use ZNE?#

In Mitiq, ZNE is clearly divided into two steps, noise scaling and extrapolation. They are shown in the Figure below. The corresponding sub-modules in the codebase are mitiq.zne.scaling.folding, mitiq.zne.scaling.identity_insertion and mitiq.zne.inference.


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

The first step involves generating and executing noise-scaled quantum circuits.

  • The user provides a QPROGRAM, i.e., a quantum circuit defined via any of the supported frontends.

  • Mitiq generates a set of noise-scaled circuits by applying a scaling method (unitary folding or identity insertion scaling) with different scale factors.

  • The noise-scaled circuits are executed on the noisy backend obtaining a set of noise-scaled expectation values.

The second step involves inferring the zero-noise value from the measured results.

  • A parametrized curve is fit to the noise-scaled expectation values.

  • The curve is extrapolated to the zero-noise limit, obtaining an error mitigated expectation value.

As demonstrated in How do I use ZNE ?, the function execute_with_zne() applies both steps behind the scenes. In the next sections instead, we show how one can apply ZNE at a lower level, i.e., by applying each step independently.

Moreover, we will also show how the user can customize noise scaling methods and factory objects.

First step: generating and executing noise-scaled circuits#

Problem setup#

We define a circuit and an executor, as shown in How do I use ZNE?.

from mitiq import benchmarks
from cirq import DensityMatrixSimulator, depolarize

circuit = benchmarks.generate_rb_circuits(
  n_qubits=1, num_cliffords=2, return_type="cirq",

def execute(circuit, noise_level=0.01):
    """Returns Tr[ρ |0⟩⟨0|] where ρ is the state prepared by the circuit
    executed with depolarizing noise.
    noisy_circuit = circuit.with_noise(depolarize(p=noise_level))
    rho = DensityMatrixSimulator().simulate(noisy_circuit).final_density_matrix
    return rho[0, 0].real

Evaluate noise-scaled expectation values#

We first apply local unitary folding to generate a sequence of noise-scaled circuits.

from mitiq import zne

# Choose a list of scale factors
scale_factors = [1.0, 3.0, 5.0]
# Generate a list of folded circuits
noise_scaled_circuits = [zne.scaling.fold_gates_at_random(circuit, s) for s in scale_factors]

For each noise-scaled circuit we evaluate the associated expectation value.

expectation_values = [execute(circ) for circ in noise_scaled_circuits]

Second step: extrapolating the zero-noise limit#

The simplest way to extrapolate the zero-noise limit of an expectation value is to use the static method Factory.extrapolate() of a Factory object. For example, an exponential extrapolation (assuming an infinite noise limit of \(0.5\)) can be obtained as follows.

zne.ExpFactory.extrapolate(scale_factors, expectation_values, asymptote=0.5)

Alternatively, one can also instantiate a Factory object, which can be useful for additional analysis and visualization of the measured data.

# Initialize a factory
fac = zne.inference.ExpFactory(scale_factors, asymptote=0.5)

# Load data:
for s, e in zip(scale_factors, expectation_values):
    fac.push({"scale_factor": s}, e)

# Evaluate the extrapolation
# Plot the extrapolation fit
_ = fac.plot_fit()

Both steps demonstrated above can be used for identity insertion scaling as well. The unitary folding function used above is now replaced with the identity insertion scaling function.

from mitiq import zne

# Choose a list of scale factors
scale_factors = [1.0, 3.0, 5.0]
# Generate a list of folded circuits
noise_scaled_circuits = [zne.scaling.insert_id_layers(circuit, s) for s in scale_factors]

Custom noise-scaling methods#

Custom folding methods can be defined and used with Mitiq, e.g., with execute_with_zne(). The signature of this function must be as follows:

import cirq
from mitiq.interface.conversions import atomic_converter

def my_custom_folding_function(circuit: cirq.Circuit, scale_factor: float) -> cirq.Circuit:
    # Insert custom folding method here
    folded_circuit = circuit  # Trivial example for testing
    return folded_circuit


The atomic_converter() decorator makes it so my_custom_folding_function can be used with any supported circuit type, not just Cirq circuits. The body of the my_custom_folding_function should assume the input circuit is a Cirq circuit, however.

This function can then be used with .zne.execute_with_zne as an option to scale the noise:

zne.execute_with_zne(circuit, execute, scale_noise=my_custom_folding_function)

Custom extrapolation methods#

If necessary, the user can modify existing extrapolation methods by subclassing one of the built-in factories.

Alternatively, a custom adaptive extrapolation method can be derived from the abstract class AdaptiveFactory. In this case its core methods must be implemented: AdaptiveFactory.__init__(), AdaptiveFactory.next(), AdaptiveFactory.is_converged(), AdaptiveFactory.reduce().

A new non-adaptive method can instead be derived from the abstract BatchedFactory class. In this case it is usually sufficient to override only the BatchedFactory.__init__() and the BatchedFactory.extrapolate() methods, which are responsible for the initialization and for the final zero-noise extrapolation, respectively. An example of a simple custom non-adaptive Factory is given in the next code cell.

from mitiq.zne.inference import BatchedFactory, LinearFactory
import numpy as np

class MyFactory(BatchedFactory):
    """Factory object implementing a linear extrapolation taking
    into account that the expectation value must be within a given
    interval. If the zero-noise limit falls outside the
    interval, its value is clipped.

    def __init__(self, scale_factors, min_expval, max_expval):
          scale_factors: The noise scale factors at which
                         expectation values should be measured.
          min_expval: The lower bound for the expectation value.
          min_expval: The upper bound for the expectation value.
       super(MyFactory, self).__init__(scale_factors)
       self._options = {"min_expval": min_expval, "max_expval": max_expval}

    def extrapolate(
       scale_factors, exp_values, min_expval, max_expval, full_output = False,
        """Fit a linear model and clip its zero-noise limit."""
        # Perform standard linear extrapolation
        result = LinearFactory.extrapolate(scale_factors, exp_values, full_output)
        # Return the clipped zero-noise extrapolation.
        if not full_output:
           return np.clip(result, min_expval, max_expval)
           # In this case "result" is a tuple of extrapolation data
           zne_limit = np.clip(result[0], min_expval, max_expval)
           return (zne_limit, *result[1:])

Using MyFactory as an option for execute_with_zne(), the customized extrapolation is applied.

# Test MyFactory clips the result as expected
fac = MyFactory([1, 2, 3], min_expval=0.0, max_expval=0.5)
zne_limit_clipped = zne.execute_with_zne(circuit, execute, factory=fac)
assert zne_limit_clipped == 0.5

After defining a custom Factory, we suggest to check that all the methods inherited from the parent class run without errors.

# Test parent methods run without errors
zne_limit_clipped = fac.get_zero_noise_limit()

Regression tools in zne.inference#

In the body of the previous MyFactory example, we imported and used the mitiq_polyfit() function. This is simply a wrap of numpy.polyfit(), slightly adapted to the notion and to the error types of Mitiq. This function can be used to fit a polynomial ansatz to the measured expectation values. This function performs a least squares minimization which is linear (with respect to the coefficients) and therefore admits an algebraic solution.

Similarly, from mitiq.zne.inference one can also import mitiq_curve_fit(), which is instead a wrap of scipy.optimize.curve_fit(). Differently from mitiq_polyfit(), mitiq_curve_fit() can be used with a generic (user-defined) ansatz. Since the fit is based on a numerical non-linear least squares minimization, this method may fail to converge or could be subject to numerical instabilities.

Low-level usage of a factory#

In this section we present a low-level usage of a Factory . In typical use-cases, the following information is not necessary but it can be useful for understanding how Factory objects work under the hood.

The run method.#

The Factory.run() method can be used to run all the data acquisition steps associated to a Factory, until enough data is collected for the extrapolation.

fac = zne.inference.AdaExpFactory(steps=5, asymptote=0.5)
# Run the factory to collect data
fac.run(circuit, execute)
# Extrapolate

The run_classical method.#

Instead of Factory.run(), the Factory.run_classical() method can be used if we have at disposal a function which directly maps a noise scale factor to the corresponding expectation value.

def noise_to_expval(scale_factor: float) -> float:
    """Function returning an expectation value for a given scale_factor."""
    scaled_circuit = zne.scaling.fold_gates_at_random(circuit, scale_factor)
    return execute(scaled_circuit)
# Remove internal data, if present.
# Run the factory to collect data
# Extrapolate

The Factory.run_classical() applies ZNE as a fully classical inference problem. Indeed, all the quantum aspects of the problem (circuit, observable, backend, etc.) are wrapped into the noise_to_expval() function.