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 and mitiq.zne.inference.

../_images/zne_workflow2_steps.png

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 unitary folding 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",
)[0]


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)
1.0000001849508315

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
fac.reduce()
1.0000001849508315
# Plot the extrapolation fit
_ = fac.plot_fit()
../_images/zne-4-low-level_13_0.png

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

@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

Note

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)
0.9551591277122505

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):
       """
       Args:
          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}

    @staticmethod
    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)
        else:
           # 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
fac.get_expectation_values()
fac.get_extrapolation_curve()
fac.get_optimal_parameters()
fac.get_parameters_covariance()
fac.get_scale_factors()
fac.get_zero_noise_limit_error()
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
fac.reduce()
1.0057045799371627

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.
fac.reset()
# Run the factory to collect data
fac.run_classical(noise_to_expval)
# Extrapolate
fac.reduce()
1.0057044799987562

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.