Noise Scaling Methods#

In this tutorial we will compare two noise scaling methods available for use in Zero-Noise Extrapolation (ZNE): identity insertion and unitary folding. ZNE works by running multiple versions of the desired circuit, each intended to scale the noise up from the base-level achieved by the hardware. Experimentally these experiments are often performed by pulse stretching, but as a quantum programmer, we typically do not have access to such low-level control. For this reason, we use “digital” methods that allow us to scale the noise using gate-based methods. To this end, we will study circuit folding and identity insertion as methods to increase the amount of noise present in our computation.

These techniques are summarized by the following equations, and can be performed in Mitiq with the associated functions.

Folding

Identity Insertion

Equation

\(G \to GG^\dagger G\)

\(G \to I G\)

Mitiq Function

fold_global

insert_id_layers

Comparison#

To get started, we can demo what these two functions do to a small GHZ circuit. Each function (fold_global and insert_id_layers) will take a circuit, and a specified scale factor as inputs. The argument scale_factor controls how much to increase the depth of the input circuit so that the achieved scale factor is exactly equal, or very close, to the specified scale factor.

from mitiq.benchmarks import generate_ghz_circuit
from mitiq.zne.scaling import insert_id_layers, fold_global

demo = generate_ghz_circuit(3)
scale_factor = 3

print("-----ORIGINAL-----")
print(demo)
print("\n-----------------FOLDING------------------")
print(fold_global(demo, scale_factor))
print("\n-----------------SCALING------------------")
print(insert_id_layers(demo, scale_factor))
-----ORIGINAL-----
0: ───H───@───────
          │
1: ───────X───@───
              │
2: ───────────X───

-----------------FOLDING------------------
0: ───H───@───────────@───H───H───@───────
          │           │           │
1: ───────X───@───@───X───────────X───@───
              │   │                   │
2: ───────────X───X───────────────────X───

-----------------SCALING------------------
0: ───H───I───I───@───I───I───────I───I───
                  │
1: ───────I───I───X───I───I───@───I───I───
                              │
2: ───────I───I───────I───I───X───I───I───

Theoretically, these circuits should give the same result when measured, but due to noise, this is almost never the case. Both methods work by extending the duration of the circuit, but do so in different ways that might be beneficial for different scenarios. When using folding, noise is amplified by applying additional gates, and in particular inverse gates. Scaling amplifies noise by letting the qubits idle for longer between computation.

Warning

Unitary folding scales noise by applying an additional layer \(G^\dagger G\) to the circuit. For non-hermitian gates \(G\) and \(G^\dagger\) may not have the same noise model, and hence noise is potentially scaled in an non-linear way.

Similarly, the noise that predominantly scaled in identity insertion is that of idle qubit noise/decoherence.

Let’s now look at how much the depth of these circuit increase with different scale factors.

print(
    "{: >12}  {: ^14} {: ^14} {: ^15}".format(
        "scale factor", "original depth", "folded depth", "id insertion depth"
    )
)
for scale_factor in range(1, 10):
    folded_depth = len(fold_global(demo, scale_factor))
    id_insert_depth = len(insert_id_layers(demo, scale_factor))
    print(
        "{: >12}  {: ^14} {: ^14} {: ^15}".format(
            scale_factor, len(demo), folded_depth, id_insert_depth
        )
    )
scale factor  original depth  folded depth  id insertion depth
           1        3              3               3       
           2        3              7               6       
           3        3              9               9       
           4        3              13             12       
           5        3              15             15       
           6        3              19             18       
           7        3              21             21       
           8        3              25             24       
           9        3              27             27       

As expected, we have \(\mathtt{depth} * \mathtt{scale\_factor} = \mathtt{scaled\_depth}\) when the scale factor is an integer. The scale factor can also take on non-integer values, where this equation will hold approximately.

Using noise scaling methods#

Here, we demo how you can use these noise-scaling technique in ZNE. First, we define an executor which is needed to tell Mitiq how to run the circuit. We choose depolarizing noise via a simple density matrix simulation to act between every circuit layer.

from mitiq.zne import execute_with_zne
import cirq

def execute(circuit, noise_level=0.05):
    noisy_circuit = circuit.with_noise(cirq.depolarize(p=noise_level))
    return (
        cirq.DensityMatrixSimulator()
        .simulate(noisy_circuit)
        .final_density_matrix[0, 0]
        .real
    )

We can then pass the desired noise-scaling method into execute_with_zne using the scale_noise keyword argument.

execute_with_zne(generate_ghz_circuit(6), execute, scale_noise=insert_id_layers)
0.32638816162943857

To give a slightly more systematic understanding of the differences between these two methods, we will perform a small experiment on the following variational circuit.

def variational_circuit(gamma):
    q0, q1 = cirq.LineQubit.range(2)

    return cirq.Circuit(
        [
            cirq.rx(gamma)(q0),
            cirq.CNOT(q0, q1),
            cirq.rx(gamma)(q1),
            cirq.CNOT(q0, q1),
            cirq.rx(gamma)(q0),
        ]
    )

We can run this circuit many times, varying \(\gamma\) each time, and compute ideal and noisy expectation values to compare to the mitigated versions. We do this comparison by using an improvement factor (IF) which is defined as

\[\left|\frac{\text{ideal} - \text{noisy}}{\text{ideal} - \text{mitigated}}\right|.\]
from random import uniform
import numpy as np

results = {"fold": [], "id": []}
for _ in range(100):
    gamma = uniform(0, 2 * np.pi)
    circuit = variational_circuit(gamma)

    ideal_expval = execute(circuit, noise_level=0.0)
    noisy_expval = execute(circuit)
    folded_expval = execute_with_zne(circuit, execute, scale_noise=fold_global)
    id_expval = execute_with_zne(circuit, execute, scale_noise=insert_id_layers)

    noisy_error = abs(ideal_expval - noisy_expval)
    folded_IF = noisy_error / abs(ideal_expval - folded_expval)
    scaled_IF = noisy_error / abs(ideal_expval - id_expval)

    results["fold"].append(folded_IF)
    results["id"].append(scaled_IF)

print("Avg improvement factor (`fold_global`):      ", round(np.average(results["fold"]), 4))
print("Avg improvement factor (`insert_id_layers`): ", round(np.average(results["id"]), 4))
Avg improvement factor (`fold_global`):       1.892
Avg improvement factor (`insert_id_layers`):  7.6175

As we can see, both techniques offer an improvement, but for this problem identity insertion outperforms folding dramatically. This is due to the fact that ZNE assumes one can scale the noise throughout a circuit by doubling, tripling, etc, the noise’s strength. On the simple noise model used in our example, identity insertion does exactly this, and hence has great performance. Noise models with increased complexity and gate-dependent errors would likely see more equal, or perhaps better performance by folding-based techniques.

Conclusion#

In this tutorial, we’ve shown how to use both folding and identity insertion as noise scaling methods for Zero-Noise Extrapolation. If you’re interested in finding out more about these techniques, check out our Noise Scaling Functions section of our users guide!