Tags on this page: ddd zne cirq intermediate

Composing techniques: Digital Dynamical Decoupling and Zero Noise Extrapolation#

Noise in quantum computers can arise from a variety of sources, and sometimes applying multiple error mitigation techniques can be more beneficial than applying a single technique alone.

Here we apply a combination of Digital Dynamical Decoupling (DDD) and Zero Noise Extrapolation (ZNE) on a GHZ state. We choose this particular use case because it is a common subroutine in many circuits, and we know its expectation value beforehand, making it easy to compare fidelities with and without error mitigation.

In DDD, the input quantum circuit is modified by inserting gate sequences at regular intervals designed to reduce interaction between (i.e., decouple) the qubits from their environment.

In ZNE, the expectation value of the observable of interest is computed at different noise levels, and subsequently the ideal expectation value is inferred by extrapolating the measured results to the zero-noise limit.

Setup#

We begin by importing the relevant modules and libraries required for the rest of this tutorial.

import cirq
import numpy as np
from mitiq import MeasurementResult, Observable, PauliString

Task#

We will demonstrate quantum error mitigation on a GHZ state, entangling multiple qubits together. We can create a short function to do this for convenience. We will also define a utility function idle_qubits, which will give us a way to insert additional time steps in which to let our qubits idle.

We will explain this further in the noise model section, but suffice it to say that these are intended to amplify the effect of time correlated noise.

def idle_qubits(circuit, qubits, idle_steps):
    """Set qubits to idle for specfied number of steps 
    in by inserting Identity gates in each `moment`
    """
    for step in range(idle_steps):
        circuit.append(cirq.I(q) for q in qubits)

    return circuit
def ghz(num_qubits, idle_steps=0):
    # Create  qubit registers
    qubits = cirq.LineQubit.range(num_qubits)

    # Create a quantum circuit
    circuit = cirq.Circuit()
    
    # Add CNOT gates to entangle the first qubit with each of the other qubits
    for i in range(num_qubits):
        if i == 0: 
            # Add a Hadamard gate to the first qubit
            circuit.append(cirq.H(qubits[0]))
        else:
            circuit.append(cirq.CNOT(qubits[0], qubits[i]))
            # Set qubits to idle for specfied number of steps
            # in the form of Identity gates
            other_qubits = qubits[1:i] + qubits[i+1:]
            circuit = idle_qubits(circuit, other_qubits, idle_steps)
    
    return circuit

Defining the circuit#

For this example we will create a 6 qubit GHZ state with two idle steps after each moment:

num_qubits = 6
circuit = ghz(num_qubits, idle_steps=3)

print(circuit)
                  ┌──┐           ┌──┐           ┌──┐           ┌──┐
0: ───H───@────────@──────────────@──────────────@──────────────@─────────
          │        │              │              │              │
1: ───────X───I────┼I────I───I────┼I────I───I────┼I────I───I────┼I────I───
                   │              │              │              │
2: ───I───I───I────X─────I───I────┼I────I───I────┼I────I───I────┼I────────
                                  │              │              │
3: ───I───I───I────I─────I───I────X─────I───I────┼I────I───I────┼I────────
                                                 │              │
4: ───I───I───I────I─────I───I────I─────I───I────X─────I───I────┼I────────
                                                                │
5: ───I───I───I────I─────I───I────I─────I───I────I─────I───I────X─────────
                  └──┘           └──┘           └──┘           └──┘

In the diagram above, the horizontal brakets above and below specific gates represent the cirq notion of moments, which are time-slices intended to help with qubit scheduling (e.g. making sure qubits arrive at a gate at the same time).

In the our noise model, we will apply errors after each moment. Adding the additional identity gates within each “moment” should amplify the effect of these time-correlated errors.

Note: Due to cirq’s definition of moments, the first two gates of the circuit (cirq.H(qubits[0]) and cirq.CNOT(qubits[0], qubits[i])) are in the same moment. This means the idle steps cannot be added between these gates, and therefore the time correlated noise will have less of an effect for the start of the circuit.

Noise model and executor#

Importantly, since DDD is designed to mitigate time-correlated (non-Markovian) noise, we simulate systematic \(R_z\) rotations and depolarising noise applied to each qubit after each time step. This corresponds to noise which is strongly time-correlated and, therefore, likely to be mitigated by DDD.

We use an executor function to run the quantum circuit with the noise model applied. The default noise values have been chosen empirically to demonstrate an error model which strongly impacts our chosen circuit (creating a GHZ state), but which can still be mitigated by our chosen QEM techniques.

def execute(
    circuit: cirq.Circuit, 
    rz_noise: float = 0.02,
    depolar_noise: float = 0.005
    ) -> MeasurementResult:
    """
    Execute a circuit with R_z dephasing noise of strength ``rz_noise`` and depolarizing noise ``depolar_noise``
    """
    # Simulate systematic dephasing (coherent RZ) on each qubit for each moment.
    circuit = circuit.with_noise(cirq.rz(rz_noise))

    # Simulate systematic depolarizing on each qubit for each moment.
    circuit = circuit.with_noise(cirq.bit_flip(depolar_noise))

    # Measure out all qubits
    circuit += cirq.measure(*sorted(circuit.all_qubits()), key="m")

    # Use a noise simulator
    simulator = cirq.DensityMatrixSimulator()

    # Run the circuit 1000 times
    result = simulator.run(circuit, repetitions=1000)

    # Retrieve the measurement results in bitstring form
    bitstrings = result.measurements["m"]

    return MeasurementResult(bitstrings)

Let’s see what the execute function returns when we call it on our GHZ circuit, just leaving the default noise levels:

res = execute(circuit)
res.to_dict() # Dictionary for more convenient visualization
{'nqubits': 6,
 'qubit_indices': (0, 1, 2, 3, 4, 5),
 'shots': 1000,
 'counts': {'101111': 24,
  '000000': 343,
  '111111': 323,
  '000011': 1,
  '000001': 28,
  '110111': 21,
  '111110': 26,
  '010000': 29,
  '100001': 12,
  '001110': 2,
  '111101': 19,
  '001000': 20,
  '000100': 24,
  '000010': 20,
  '110110': 3,
  '100011': 5,
  '010110': 1,
  '010010': 1,
  '100111': 6,
  '001001': 3,
  '111011': 22,
  '100000': 2,
  '011111': 5,
  '111100': 1,
  '110011': 2,
  '011100': 12,
  '110101': 1,
  '011000': 9,
  '101110': 3,
  '001010': 4,
  '011110': 7,
  '100101': 1,
  '101101': 3,
  '111010': 1,
  '000110': 3,
  '111001': 2,
  '001100': 1,
  '011101': 1,
  '010100': 2,
  '010001': 1,
  '101011': 2,
  '111000': 1,
  '110100': 1,
  '011001': 1,
  '000101': 1}}

The 'counts' key represents the number of times a particular measurement bitstring occurred.

Since the default execute function includes noise in this case, we see that most of the results are all ones or all zeros, corresponding to perfect entanglement between all qubits.

A few others, like '110111' however are one or more bitflips away from either of these states. It is these types of errors we want to mitigate.

Observable#

In this example, we just want to check if we have achieved entanglement across all qubits. In this case, we will measure the observable \(⨂_{i=1}^n​X_i\) or measuring X on all qubits.

This corresponds to projecting the state onto either the \(∣+⟩^{⊗n}\) or \(∣−⟩^{⊗n}\) basis. For a perfect GHZ state, this observable will have an expectation value of 1, which corresponds to all qubits being in the same state.

obs = Observable(PauliString("X" * num_qubits))
print(obs)
X(q(0))*X(q(1))*X(q(2))*X(q(3))*X(q(4))*X(q(5))

For the circuit defined above, the ideal (noiseless) expectation value of the observable is 1:

from functools import partial

ideal_exec = partial(execute, rz_noise = 0.0, depolar_noise = 0.0)

ideal = obs.expectation(circuit, ideal_exec)
print("Ideal value:", "{:.5f}".format(ideal.real))
Ideal value: 1.00000

The unmitigated (noisy) result however, is impacted by the Rz and depolarizing errors. In fact, we are not even halfway to the correct expectation value:

noisy_exec = partial(execute, rz_noise = 0.02, depolar_noise = 0.005)
noisy = obs.expectation(circuit, noisy_exec) 
print("Unmitigated noisy value:", "{:.5f}".format(noisy.real))
Unmitigated noisy value: 0.50800

Applying Digital Dynamical Decoupling#

Next we choose our gate sequences to be used in the digital dynamical decoupling routine (DDD). More information on choosing appropriate sequences can be found in the DDD theory section of the user guide.

To do this, we will insert DDD sequences into our circuit itself and then compare the original circuit with the modified one.

from mitiq.ddd import insert_ddd_sequences, rules

print("Original circuit \n", circuit)

rule = rules.yy

ddd_circuit = insert_ddd_sequences(circuit, rule)
print("DDD modified circuit \n", ddd_circuit)
Original circuit 
                   ┌──┐           ┌──┐           ┌──┐           ┌──┐
0: ───H───@────────@──────────────@──────────────@──────────────@─────────
          │        │              │              │              │
1: ───────X───I────┼I────I───I────┼I────I───I────┼I────I───I────┼I────I───
                   │              │              │              │
2: ───I───I───I────X─────I───I────┼I────I───I────┼I────I───I────┼I────────
                                  │              │              │
3: ───I───I───I────I─────I───I────X─────I───I────┼I────I───I────┼I────────
                                                 │              │
4: ───I───I───I────I─────I───I────I─────I───I────X─────I───I────┼I────────
                                                                │
5: ───I───I───I────I─────I───I────I─────I───I────I─────I───I────X─────────
                  └──┘           └──┘           └──┘           └──┘
DDD modified circuit 
                   ┌──┐           ┌──┐           ┌──┐           ┌──┐
0: ───H───@────────@─────Y───Y────@─────Y───Y────@─────Y───Y────@─────────
          │        │              │              │              │
1: ───────X───I────┼I────I───I────┼Y────I───I────┼I────Y───I────┼I────I───
                   │              │              │              │
2: ───I───Y───Y────X─────I───I────┼I────Y───I────┼I────Y───I────┼I────I───
                                  │              │              │
3: ───I───I───Y────I─────Y───I────X─────I───I────┼Y────I───Y────┼I────I───
                                                 │              │
4: ───I───I───I────Y─────I───I────Y─────I───I────X─────I───Y────┼Y────I───
                                                                │
5: ───I───I───I────I─────Y───I────I─────I───Y────I─────I───I────X─────────
                  └──┘           └──┘           └──┘           └──┘

Now we execute our function on the ddd_circuit:

ddd_noisy = obs.expectation(ddd_circuit, noisy_exec)

print("Unmitigated expectation value:", "{:.5f}".format(noisy.real))

print("Expectation value with DDD:", "{:.5f}".format(ddd_noisy.real))
Unmitigated expectation value: 0.50800
Expectation value with DDD: 0.78800

Zero Noise Extrapolation alone#

For comparison, we then apply ZNE on our original circuit (without DDD sequences inserted).

from mitiq import zne

zne_executor = zne.mitigate_executor(noisy_exec, observable=obs, scale_noise=zne.scaling.folding.fold_global)
zne_result = zne_executor(circuit)
print("Mitigated value obtained with ZNE:", "{:.5f}".format(zne_result.real))
Mitigated value obtained with ZNE: 2.19600

In this case, we see that ZNE by itself actually makes things worse than the unmitigated expectation value!

Digital Dynamical Decoupling + Zero noise extrapolation#

Finally, we apply a combination of DDD and ZNE. DDD is applied first to apply the control pulses to each circuit which ZNE runs to do its extrapolation.

combined_result = zne_executor(ddd_circuit)
print("Mitigated value obtained with DDD + ZNE:", "{:.5f}".format(combined_result.real))
Mitigated value obtained with DDD + ZNE: 0.93400

From this example we can see that for this specific noise model, DDD improved things somewhat on its own, while ZNE was ineffectual on the original unmitigated circuit. It was only by using DDD and ZNE in conjunction that we were able to achieve significant gains in performance.

We encourage users to experiment with different circuits and noise models to see where they can find the best advantage to using these techniques in conjunction!