# Zero Noise Extrapolation¶

Zero noise extrapolation has two main components: noise scaling and then extrapolation.

## Digital noise scaling: Unitary Folding¶

Zero noise extrapolation has two main components: noise scaling and then extrapolation. Unitary folding is a method for noise scaling that operates directly at the gate level. This makes it easy to use across platforms. It is especially appropriate when your underlying noise should scale with the depth and/or the number of gates in your quantum program. More details can be found in [25] where the unitary folding framework was introduced.

At the gate level, noise is amplified by mapping gates (or groups of gates) G to

This makes the circuit longer (adding more noise) while keeping its effect unchanged (because
\(G^\dagger = G^{-1}\) for unitary gates). We refer to this process as
*unitary folding*. If G is a subset of the gates in a circuit, we call it local folding.
If G is the entire circuit, we call it global folding.

In `mitiq`

, folding functions input a circuit and a *scale factor* (or simply *scale*), i.e., a floating point value
which corresponds to (approximately) how much the length of the circuit is scaled.
The minimum scale factor is one (which corresponds to folding no gates). A scale factor of three corresponds to folding
all gates locally. Scale factors beyond three begin to fold gates more than once.

### Local folding methods¶

For local folding, there is a degree of freedom for which gates to fold first. The order in which gates are folded can
have an important effect on how the noise is caled. As such, `mititq`

defines several local folding methods.

We introduce three folding functions:

`mitiq.zne.scaling.fold_gates_from_left`

`mitiq.zne.scaling.fold_gates_from_right`

`mitiq.zne.scaling.fold_gates_at_random`

The `mitiq`

function `fold_gates_from_left`

will fold gates from the left (or start) of the circuit
until the desired scale factor is reached.

```
>>> import cirq
>>> from mitiq.zne.scaling import fold_gates_from_left
# Get a circuit to fold
>>> qreg = cirq.LineQubit.range(2)
>>> circ = cirq.Circuit(cirq.ops.H.on(qreg[0]), cirq.ops.CNOT.on(qreg[0], qreg[1]))
>>> print("Original circuit:", circ, sep="\n")
Original circuit:
0: ───H───@───
│
1: ───────X───
# Fold the circuit
>>> folded = fold_gates_from_left(circ, scale_factor=2.)
>>> print("Folded circuit:", folded, sep="\n")
Folded circuit:
0: ───H───H───H───@───
│
1: ───────────────X───
```

In this example, we see that the folded circuit has the first (Hadamard) gate folded.

Note

`mitiq`

folding functions do not modify the input circuit.

Because input circuits are not modified, we can reuse this circuit for the next example. In the following code,
we use the `fold_gates_from_right`

function on the same input circuit.

```
>>> from mitiq.zne.scaling import fold_gates_from_right
# Fold the circuit
>>> folded = fold_gates_from_right(circ, scale_factor=2.)
>>> print("Folded circuit:", folded, sep="\n")
Folded circuit:
0: ───H───@───@───@───
│ │ │
1: ───────X───X───X───
```

We see the second (CNOT) gate in the circuit is folded, as expected when we start folding from the right (or end) of the circuit instead of the left (or start).

Finally, we mention `fold_gates_at_random`

which folds gates according to the following rules.

Gates are selected at random and folded until the input scale factor is reached.

No gate is folded more than once for any

`scale_factor <= 3`

.“Virtual gates” (i.e., gates appearing from folding) are never folded.

All of these local folding methods can be called with any `scale_factor >= 1`

.

### Any supported circuits can be folded¶

Any program types supported by `mitiq`

can be folded, and the interface for all folding functions is the same. In the
following example, we fold a Qiskit circuit.

Note

This example assumes you have Qiskit installed. `mitiq`

can interface with Qiskit, but Qiskit is not
a core `mitiq`

requirement and is not installed by default.

```
>>> import qiskit
>>> from mitiq.zne.scaling import fold_gates_from_left
# Get a circuit to fold
>>> qreg = qiskit.QuantumRegister(2)
>>> circ = qiskit.QuantumCircuit(qreg)
>>> _ = circ.h(qreg[0])
>>> _ = circ.cnot(qreg[0], qreg[1])
>>> print("Original circuit:", circ, sep="\n")
Original circuit:
┌───┐
q31_0: ┤ H ├──■──
└───┘┌─┴─┐
q31_1: ─────┤ X ├
└───┘
```

This code (when the print statement is uncommented) should display something like:

We can now fold this circuit as follows.

```
>>> folded = fold_gates_from_left(circ, scale_factor=2.)
>>> print("Folded circuit:", folded, sep="\n")
Folded circuit:
┌───┐┌───┐┌───┐
q_0: ┤ H ├┤ H ├┤ H ├──■──
└───┘└───┘└───┘┌─┴─┐
q_1: ───────────────┤ X ├
└───┘
```

By default, the folded circuit has the same type as the input circuit. To return an internal `mitiq`

representation
of the folded circuit (a Cirq circuit), one can use the keyword argument `return_mitiq=True`

.

#### Folding gates by fidelity¶

In local folding methods, gates can be folded according to custom fidelities by passing the keyword argument
`fidelities`

into a local folding method. This argument should be a dictionary where each key is a string which
specifies the gate and the value of the key is the fidelity of that gate. An example is shown below where we set the
fidelity of all single qubit gates to be 1.0, meaning that these gates introduce no errors in the computation.

```
from cirq import Circuit, LineQubit, ops
from mitiq.zne.scaling import fold_gates_at_random
qreg = LineQubit.range(3)
circ = Circuit(
ops.H.on_each(*qreg),
ops.CNOT.on(qreg[0], qreg[1]),
ops.T.on(qreg[2]),
ops.TOFFOLI.on(*qreg)
)
print(circ)
# 0: ───H───@───@───
# │ │
# 1: ───H───X───@───
# │
# 2: ───H───T───X───
folded = fold_gates_at_random(
circ, scale_factor=3., fidelities={"single": 1.0,
"CNOT": 0.99,
"TOFFOLI": 0.95}
)
print(folded)
# 0: ───H───@───@───@───@───@───@───
# │ │ │ │ │ │
# 1: ───H───X───X───X───@───@───@───
# │ │ │
# 2: ───H───T───────────X───X───X───
```

We can see that only the two-qubit gates and three-qubit gates have been folded in the folded circuit.

Specific gate keys override the global “single”, “double”, or “triple” options. For example, the dictionary
`fidelities = {"single": 1.0, "H": 0.99}`

sets all single qubit gates to fidelity one except the Hadamard gate.

A full list of string keys for gates can be found with `help(fold_method)`

where `fold_method`

is a valid local
folding method. Fidelity values must be between zero and one.

### Global folding¶

As mentioned, global folding methods fold the entire circuit instead of individual gates. An example using the same Cirq circuit above is shown below.

```
>>> import cirq
>>> from mitiq.zne.scaling import fold_global
# Get a circuit to fold
>>> qreg = cirq.LineQubit.range(2)
>>> circ = cirq.Circuit(cirq.ops.H.on(qreg[0]), cirq.ops.CNOT.on(qreg[0], qreg[1]))
>>> print("Original circuit:", circ, sep="\n")
Original circuit:
0: ───H───@───
│
1: ───────X───
# Fold the circuit
>>> folded = fold_global(circ, scale_factor=3.)
>>> print("Folded circuit:", folded, sep="\n")
Folded circuit:
0: ───H───@───@───H───H───@───
│ │ │
1: ───────X───X───────────X───
```

Notice that this circuit is still logically equivalent to the input circuit, but the global folding strategy folds
the entire circuit until the input scale factor is reached. As with local folding methods, global folding can be called
with any `scale_factor >= 3`

.

### Custom folding methods¶

Custom folding methods can be defined and used with `mitiq`

(e.g., with `mitiq.execute_with_zne`

. The signature
of this function must be as follows.

```
import cirq
from mitiq.zne.scaling import converter
@converter
def my_custom_folding_function(circuit: cirq.Circuit, scale_factor: float) -> cirq.Circuit:
# Insert custom folding method here
return folded_circuit
```

Note

The `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 `mitiq.execute_with_zne`

as an option to scale the noise:

```
# Variables circ and scale are a circuit to fold and a scale factor, respectively
zne = mitiq.execute_with_zne(circuit, executor, scale_noise=my_custom_folding_function)
```

## Classical fitting and extrapolation: Factory Objects¶

A `Factory`

object is a self-contained representation of an error mitigation method.

This representation is not just hardware-agnostic, it is even *quantum-agnostic*,
in the sense that it mainly deals with classical data: the classical input and the classical output of a
noisy computation. Nonetheless, a factory can easily interact with a quantum system via its `self.run`

method
which is the only interface between the “classical world” of a factory and the “quantum world” of a circuit.

The typical tasks of a factory are:

Record the result of the computation executed at the chosen noise level;

Determine the noise scale factor at which the next computation should be run;

Given the history of noise scale factors and results, evaluate the associated zero-noise extrapolation.

The structure of the `Factory`

class is adaptive by construction, since the choice of the next noise
level can depend on the history of these values. Obviously, non-adaptive
methods are supported too and they actually represent the most common choice.

Specific classes derived from the abstract class `Factory`

, like `LinearFactory`

,
`RichardsonFactory`

, etc., represent different zero-noise extrapolation methods.
All the built-in factories can be found in the module `mitiq.zne.inference`

and
are summarized in the following table.

Factory object implementing zero-noise extrapolation based on a linear fit.

Factory object implementing Richardson’s extrapolation.

Factory object implementing a zero-noise extrapolation algorithm based on a polynomial fit.

Factory object implementing a zero-noise extrapolation algorithm assuming an exponential ansatz y(x) = a + b * exp(-c * x), with c > 0.

Factory object implementing a zero-noise extrapolation algorithm assuming an (almost) exponential ansatz with a non linear exponent, i.e.:

Factory object implementing an adaptive zero-noise extrapolation algorithm assuming an exponential ansatz y(x) = a + b * exp(-c * x), with c > 0.

Once instantiated, a factory can be passed as an argument to the high-level functions contained in the module `mitiq.zne.zne`

.
Alternatively, a factory can be directly used to implement a zero-noise extrapolation procedure in a fully self-contained way.

To clarify this aspect, we now perform the same zero-noise extrapolation with both methods.

### Using a factory object with the `mitiq.zne`

module¶

Let us consider an `executor`

function which is similar to the one used in
the getting started section.

```
import numpy as np
from cirq import Circuit, depolarize, DensityMatrixSimulator
# initialize a backend
SIMULATOR = DensityMatrixSimulator()
# 5% depolarizing noise
NOISE = 0.05
def executor(circ: Circuit) -> float:
"""Executes a circuit with depolarizing noise and
returns the expectation value of the projector |0><0|."""
circuit = circ.with_noise(depolarize(p=NOISE))
rho = SIMULATOR.simulate(circuit).final_density_matrix
obs = np.diag([1, 0])
expectation = np.real(np.trace(rho @ obs))
return expectation
```

Note

In this example we used *Cirq* but other quantum software platforms can be used,
as shown in the getting started section.

We also define a simple quantum circuit whose ideal expectation value is by construction equal to
`1.0`

.

```
from cirq import LineQubit, X, H
qubit = LineQubit(0)
circuit = Circuit(X(qubit), H(qubit), H(qubit), X(qubit))
expval = executor(circuit)
exact = 1.0
print(f"The ideal result should be {exact}")
print(f"The real result is {expval:.4f}")
print(f"The abslute error is {abs(exact - expval):.4f}")
```

```
The ideal result should be 1.0
The real result is 0.8794
The abslute error is 0.1206
```

Now we are going to initialize three factory objects, each one encapsulating a different zero-noise extrapolation method.

```
from mitiq.zne.inference import LinearFactory, RichardsonFactory, PolyFactory
# method: scale noise by 1 and 2, then extrapolate linearly to the zero noise limit.
linear_fac = LinearFactory(scale_factors=[1.0, 2.0])
# method: scale noise by 1, 2 and 3, then evaluate the Richardson extrapolation.
richardson_fac = RichardsonFactory(scale_factors=[1.0, 2.0, 3.0])
# method: scale noise by 1, 2, 3, and 4, then extrapolate quadratically to the zero noise limit.
poly_fac = PolyFactory(scale_factors=[1.0, 2.0, 3.0, 4.0], order=2)
```

The previous factory objects can be passed as arguments to the high-level functions
in `mitiq.zne`

. For example:

```
from mitiq.zne.zne import execute_with_zne
zne_expval = execute_with_zne(circuit, executor, factory=linear_fac)
print(f"Error with linear_fac: {abs(exact - zne_expval):.4f}")
zne_expval = execute_with_zne(circuit, executor, factory=richardson_fac)
print(f"Error with richardson_fac: {abs(exact - zne_expval):.4f}")
zne_expval = execute_with_zne(circuit, executor, factory=poly_fac)
print(f"Error with poly_fac: {abs(exact - zne_expval):.4f}")
```

```
Error with linear_fac: 0.0291
Error with richardson_fac: 0.0070
Error with poly_fac: 0.0110
```

### Directly using a factory for error mitigation¶

Zero-noise extrapolation can also be implemented by directly using the methods `self.run`

and `self.reduce`

of a `Factory`

object.

The method `self.run`

evaluates different expectation values at different noise levels
until a sufficient amount of data is collected.

The method `self.reduce`

instead returns the final zero-noise extrapolation which, in practice,
corresponds to a statistical inference based on the measured data.

```
# we import one of the built-in noise scaling function
from mitiq.zne.scaling import fold_gates_at_random
linear_fac.run(circuit, executor, scale_noise=fold_gates_at_random)
zne_expval = linear_fac.reduce()
print(f"Error with linear_fac: {abs(exact - zne_expval):.4f}")
richardson_fac.run(circuit, executor, scale_noise=fold_gates_at_random)
zne_expval = richardson_fac.reduce()
print(f"Error with richardson_fac: {abs(exact - zne_expval):.4f}")
poly_fac.run(circuit, executor, scale_noise=fold_gates_at_random)
zne_expval = poly_fac.reduce()
print(f"Error with poly_fac: {abs(exact - zne_expval):.4f}")
```

```
Error with linear_fac: 0.0291
Error with richardson_fac: 0.0070
Error with poly_fac: 0.0110
```

### Advanced usage of a factory¶

Note

This section can be safely skipped by all the readers who are interested
in a standard usage of `mitiq`

.
On the other hand, more experienced users and `mitiq`

contributors
may find this content useful to understand how a factory object actually
works at a deeper level.

In this advanced section we present a *low-level usage* and a *very-low-level usage* of a factory.
Again, for simplicity, we solve the same zero-noise extrapolation problem that we have just considered
in the previous sections.

Eventually we will also discuss how the user can easily define a custom factory class.

#### Low-level usage: the `iterate`

method.¶

The `self.run`

method takes as arguments a circuit and other “quantum” objects.
On the other hand, the core computation performed by any factory corresponds to
a some classical computation applied to the measurement results.

At a lower level, it is possible to clearly separate the quantum and the classical steps of a zero-noise extrapolation procedure. This can be done by defining a function which 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."""
# apply noise scaling
scaled_circuit = fold_gates_at_random(circuit, scale_factor)
# return the corresponding expectation value
return executor(scaled_circuit)
```

Note

The body of the previous function contains the execution of a quantum circuit. However, if we see it as a “black-box”, it is just a classical function mapping real numbers to real numbers.

The function `noise_to_expval`

encapsulate the “quantum part” of the problem. The “classical
part” of the problem can be solved by passing `noise_to_expval`

to the `self.iterate`

method of a factory object.
This method will repeatedly call `noise_to_expval`

for different
noise levels until a sufficient amount of data is collected.
So, one can view `self.iterate`

as the classical counterpart of the quantum method `self.run`

.

```
linear_fac.iterate(noise_to_expval)
zne_expval = linear_fac.reduce()
print(f"Error with linear_fac: {abs(exact - zne_expval):.4f}")
richardson_fac.iterate(noise_to_expval)
zne_expval = richardson_fac.reduce()
print(f"Error with richardson_fac: {abs(exact - zne_expval):.4f}")
poly_fac.iterate(noise_to_expval)
zne_expval = poly_fac.reduce()
print(f"Error with poly_fac: {abs(exact - zne_expval):.4f}")
```

```
Error with linear_fac: 0.0291
Error with richardson_fac: 0.0070
Error with poly_fac: 0.0110
```

Note

With respect to `self.run`

the `self.iterate`

method is much more flexible and
can be applied whenever the user is able to autonomously scale the noise level associated
to an expectation value. Indeed, the function `noise_to_expval`

can represent any experiment
or any simulation in which noise can be artificially increased. The scenario
is therefore not restricted to quantum circuits but can be easily extended to
annealing devices or to gates which are controllable at a pulse level. In principle,
one could even use the `self.iterate`

method to mitigate experiments which are
unrelated to quantum computing.

#### Very low-level usage of a factory¶

It is also possible to emulate the action of the `self.iterate`

method
by manually measuring individual expectation values and saving them, one by one, into the factory.

Note

In a typical situation, such a deep level of control is likely unnecessary.
It is anyway instructive to understand the internal structure of the
`Factory`

class, especially if one is interested in defining a custom factory.

```
zne_list = []
# loop over different factories
for fac in [linear_fac, richardson_fac, poly_fac]:
# loop until enough expectation values are measured
while not fac.is_converged():
# Get the next noise scale factor from the factory
next_scale_factor = fac.next()
# Evaluate the expectation value
expval = noise_to_expval(next_scale_factor)
# Save the noise scale factor and the result into the factory
fac.push(next_scale_factor, expval)
# evaluate the zero-noise limit and append it to zne_list
zne_list.append(fac.reduce())
print(f"Error with linear_fac: {abs(exact - zne_list[0]):.4f}")
print(f"Error with richardson_fac: {abs(exact - zne_list[1]):.4f}")
print(f"Error with poly_fac: {abs(exact - zne_list[2]):.4f}")
```

```
Error with linear_fac: 0.0291
Error with richardson_fac: 0.0070
Error with poly_fac: 0.0110
```

In the previous code block we used the some core methods of a `Factory`

object:

`self.next`

to get the next noise scale factor;

`self.push`

to save the measured data into the factory;

`self.is_converged`

to know if enough data has been collected.

#### Defining a custom factory¶

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

Alternatively, a new adaptive extrapolation method can be derived from the abstract class `Factory`

.
In this case its core methods must be implemented:
`self.next`

, `self.push`

, `self.is_converged`

, `self.reduce`

, etc.
Typically, the `self.__init__`

method must be overridden.

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 `self.__init__`

and
the `self.reduce`

methods, which are responsible for the initialization and for the
final zero-noise extrapolation, respectively.

### Example: a simple custom factory¶

Assume that, from physical considerations, we know that the ideal expectation value
(measured by some quantum circuit) must always be within two limits: `min_expval`

and `max_expval`

.
For example, this is a typical situation whenever the measured observable has a bounded
spectrum.

We can define a linear non-adaptive factory which takes into account this information and clips the result if it falls outside its physical domain.

```
from typing import Iterable
from mitiq.zne.inference import BatchedFactory, mitiq_polyfit
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: Iterable[float],
min_expval: float,
max_expval: float,
) -> None:
"""
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.min_expval = min_expval
self.max_expval = max_expval
def reduce(self) -> float:
"""
Fit a linear model and clip its zero-noise limit.
Returns:
The clipped extrapolation to the zero-noise limit.
"""
# Fit a line and get the intercept
_, intercept = mitiq_polyfit(
self.get_scale_factors(), self.get_expectation_values(), deg=1
)
# Return the clipped zero-noise extrapolation.
return np.clip(intercept, self.min_expval, self.max_expval)
```

This custom factory can be used in exactly the same way as we have
shown in the previous section. By simply replacing `LinearFactory`

with `MyFactory`

in all the previous code snippets, the new extrapolation
method will be applied.

### Regression tools in `mitiq.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.