# SETUP (common to all tutorials)
import fermioniq
client = fermioniq.Client() # Initialize the client (assumes environment variables are set)

import rich # Module for pretty printing
import nest_asyncio # Necessary only for Jupyter, can be omitted in .py files
nest_asyncio.apply()

5) Noisy emulation#

To perform a noisy emulation, you need to specify a noise model. There are two ways to do this:

  1. Defining your own noise model. Explained in this tutorial

  2. Choose a pre-configured noise model supported by the emulator. Currently Unavailable


Defining a custom noise model

Broadly speaking, a noise model is built in three steps:

  1. Build a NoiseModel object

  2. Define NoiseChannels

  3. Specify when the NoiseChannels should be applied

  1. A NoiseModel is defined for a list of qubits (Qiskit or Cirq qubit objects).

    Important note: These qubits should match the qubits in the circuit. Hence it is best to first create a circuit in Qiskit/Cirq.

from qiskit import QuantumCircuit
from fermioniq import NoiseModel

# Create a 3-qubit circuit for which the noise model is defined (gates can be added later)
qiskit_circuit = QuantumCircuit(3)

noise_model = NoiseModel(qubits=qiskit_circuit.qubits, name="example-qiskit-noise-model", description="An example noise model for 3 qubits")
  1. NoiseChannels are imported from the client. Several common channels can be defined easily, and custom channels can be specified via their Kraus operators or Pauli channel descriptions.

# These are all available channels
from fermioniq import DepolarizingChannel, PhaseDampingChannel, AmplitudeDampingChannel, PhaseAmplitudeDampingChannel, BitFlipChannel, PauliChannel, KrausChannel
import numpy as np

# Examples of channel instances -- see class descriptions for details on the input parameters.
d = DepolarizingChannel(p=0.3, num_qubits=2)
pd = PhaseDampingChannel(gamma_pd=(gamma_pd:=0.3))
ad = AmplitudeDampingChannel(gamma_ad=0.3, excited_state_population=0.1)
pad = PhaseAmplitudeDampingChannel(gamma_pd=0.3, gamma_ad=0.3, excited_state_population=0.1)
b = BitFlipChannel(p0=0.2, p1=0.4)

# Example of a custom Krauss channel, in this case re-implementing the phase damping channel using Kraus operators
k_0 = np.array([[1, 0], [0, np.sqrt(1-gamma_pd)]])
k_1 = np.array([[0,0], [0, np.sqrt(gamma_pd)]])
kraus_pd = KrausChannel(kraus_ops=[k_0, k_1])

# Example of a single-qubit depolarizing channel using Pauli strings.
# Note that the PauliChannel constructor takes a dictionary of Pauli strings and their *probability* of being applied.
p = 0.2
pauli_d = PauliChannel(pauli_probs={"I": 1 - 3*p/4, "X": p/4, "Y": p/4, "Z": p/4})

Dict representations of the channel can be printed if needed.

rich.print(d.model_dump())
rich.print(pd.model_dump())
rich.print(ad.model_dump())
rich.print(pad.model_dump())
rich.print(b.model_dump())

rich.print(kraus_pd.model_dump())
rich.print(pauli_d.model_dump())
  1. Add NoiseChannels to the NoiseModel by specifying the conditions under which they are triggered. A condition is a combination of a gate and the qubits that that gate acts upon.

    For instance, a very simple noise model could be one with the following triggers and channels:

  • Upon application of any 2-qubit gate to any pair of qubits (trigger), apply 2-qubit depolarizing noise to those same qubits after the gate is applied (noise channel).

  • Upon application of any 1-qubit gate to any qubit (trigger), apply single-qubit depolarizing noise to that same qubit before the gate is applied (noise channel).

    Important note: noise should be specified for all gates (even if it is via a generic rule using ANY), and readout errors should always be specified for all qubits. This restriction can be dropped later by setting a config flag.

from fermioniq import ANY

# Add two-qubit gate noise
noise_model.add(gate_name=ANY, gate_qubits=2, channel=d, channel_qubits="same", when="post")

# Add one-qubit gate noise
d_one_qubit = DepolarizingChannel(p=0.3, num_qubits=1)
noise_model.add(gate_name=ANY, gate_qubits=1, channel=d_one_qubit, channel_qubits="same", when="pre")

# We add trivial readout error, both the probability of misreading 0 (first number), and that of misreading 1 (second number) are 0
noise_model.add_readout_error(qubit = ANY, p0 = 0, p1 = 0)

Noisy Emulation

To run noisy emulation simply add the NoiseModel to the emulator job.

Important note: To get settings in standard_config that are sensible for mixed state emulation, set noise = True.

from fermioniq.config.defaults import standard_config

# Add some gates to the circuit
qiskit_circuit.h(0)
qiskit_circuit.x(1)
qiskit_circuit.cx(0, 1)
qiskit_circuit.cx(1, 2)
qiskit_circuit.cx(0, 1)
print(qiskit_circuit)

# Create a standard configuration for a noisy emulation of the circuit
noisy_config = standard_config(qiskit_circuit, noise=True)
rich.print(noisy_config)

# Adding the noise model to the emulator job will automatically configure it for a noisy emulation
emulator_job = fermioniq.EmulatorJob(circuit=qiskit_circuit, config=noisy_config, noise_model=noise_model)

# Note that the output will be uninteresting, since we didn't add any gates to the circuit yet!
result = client.schedule_and_wait(emulator_job)
rich.print(result)

Testing your NoiseModel

  1. We can apply a NoiseModel to a circuit to get a full overview of the resulting noisy circuit by using on_circuit(circuit). This returns a dictionary with two keys:

  • noise_by_gate: A list of dictionaries, one for each gate in the circuit, giving the gate and the noise channels that are applied before and after the gate.

  • missing_noise: A list of dictionaries, one for each gate in the circuit for which no noise channels were found, giving the gate, the position in the circuit, and the trace of the lookup process (useful for debugging).

  1. We can inspect the channels triggered by a specific gate on specific qubits by using get_noise_channels(gate_name, gate_qubits, trace=False). By setting the trace flag to True, the function also returns the lookup trace: a dictionary describing how the noise model arrived at the channels that it did (i.e. where it did and did not find matches). This is useful for debugging.

# Define a circuit
qiskit_circuit.h(0)
qiskit_circuit.cx(0, 1)
qiskit_circuit.cx(1, 0)
qiskit_circuit.h(2)
qiskit_circuit.cx(1, 2)
qubits = qiskit_circuit.qubits


# 1: Full overview of the noisy circuit
noise_effects = noise_model.on_circuit(qiskit_circuit)
rich.print("Noise effects:")
rich.print(noise_effects)

# 2: Noise for specific gate and qubits
channels, trace = noise_model.get_noise_channels(gate_name="h", gate_qubits=[qubits[0]], trace=True)

rich.print("Lookup trace for H on q_0:")
rich.print(trace)
rich.print("(matches rule 1: H on q_0)")

channels, trace = noise_model.get_noise_channels(gate_name="cx", gate_qubits=[qubits[1], qubits[2]], trace=True)
rich.print("Lookup trace for CX on q_1 and q_2:")
rich.print(trace)
rich.print("(matches rule 5: CX on any 2 qubits)")

Full overview of options and behaviour

We will now explain in more detail:

  1. Which triggers can be defined

  2. How a NoiseModel handles multiple definitions of channels triggered by the same gate and qubits.

  3. Which errors might be thrown when building a NoiseModel.

Triggers

Noise models can be as complicated or as general as desired. The only restriction (currently) is that the channels act on either one or two qubits.

The add function takes the following arguments:

  • gate_name: The name of the gate that triggers the noise. Can be either a gate name for a Qiskit or Cirq circuit as a string, e.g. ‘cx’, ‘h’, ‘CNOT’, ‘H’, ‘Y’, or ANY (see below);

  • gate_qubits: The qubits acted upon by the gate that triggers the noise. Can be a particular set of qubits such as (‘q1’, ‘q2’), ‘q0’, or ‘q3’, or an integer (1 or 2) specifying the number of qubits the gate acts on;

  • channel: The channel that is applied. Can be any one of the fermioniq noise channels (see above);

  • channel_qubits: The qubits that the channel acts on. Can be a particular set of qubits, an integer (1 or 2) specifying the number of qubits, or ‘same’ to apply the channel to the same qubits as the gate (i.e. gate_qubits);

  • when: When the noise is applied. Can be either ‘pre’ or ‘post’, determining whether the channel should be applied before or after the gate.

(The ANY constant from fermioniq can be used to match to any gate or set of qubits)

Above, gate_name and gate_qubits together specify the trigger, and channel and channel_qubits specify the noise channel that should be applied before or after the gate in response to this trigger.

Multiple channels can be added for the same trigger, in this case the order of application is:

  1. Channels in “pre”, in the order in which they were added (when='pre').

  2. The gate itself.

  3. Channels in “post”, in the order in which they were added (when='post).

For defining triggers, all gate names in Cirq and Qiskit are available (depending on which type of circuit is being used).

For instance, suppose we want to construct a noise model with the following ‘rules’:

  1. Whenever a H gate acts on qubit q0, apply a phase damping channel to q0, then an amplitude damping channel to q1 (modelling some kind of cross-talk).

  2. Whenever a CX gate acts on qubits q0 and q1, apply a depolarizing channel to those qubits, as well as an amplitude damping channel to qubit q0 (the control qubit) before the gate.

  3. Whenever a CX gate acts on qubits q1 and q0, apply a depolarizing channel to those qubits, as well as an amplitude damping channel to qubit q1 (the control qubit) before the gate. Note that this is the same as the previous rule, but with the qubits swapped.

  4. Whenever a H gate acts on any qubit, apply a phase-amplitude damping channel to that qubit.

  5. Whenever any gate acts on any two qubits, apply a depolarizing channel to those same qubits.

  6. Whenever any gate acts on any one or two qubits, apply a phase damping channel to those same qubits.

  7. Qubit q0 has readout error where 0 is flipped to 1 with probability 0.3, and 1 is flipped to 0 with probability 0.6.

  8. All other qubits have readout errors where 0 is flipped to 1 with probability 0.1, and 1 is flipped to 0 with probability 0.2.

Note that rules 1, 2, and 3 are specific in terms of both the gate and the qubits that it acts on. Rule 4 is specific in terms of the gate, but generic in terms of the qubits. Rules 5 and 6 are generic in terms of both the gate and the qubits, with 5 specifiying a number of qubits, and 6 specifying ‘any’ qubits.

from fermioniq import NoiseModel, ANY
import qiskit

# Standard qiskit gate constructors are given here
from qiskit.circuit.library import standard_gates
# print(dir(standard_gates))

from qiskit.circuit.library import HGate, CXGate

# These strings correspond to those used in the qiskit 'circuit.add()' function, i.e. "h", "cx"
# Alternatively, they can be obtained from instructions in a qiskit QuantumCircuit
HGate_name = HGate().name
CXGate_name = CXGate().name
assert HGate_name == "h"
assert CXGate_name == "cx"

# Let's make an empty circuit for which the noise model will be defined
qiskit_circuit = qiskit.QuantumCircuit(3)
# Qubits on which the noise model acts
qubits = list(qiskit_circuit.qubits)

noise_model = NoiseModel(qubits=qubits, name="example-qiskit-noise-model", description="An example noise model for 3 qubits")

# Rule 1; first apply phase damping, then amplitude damping
noise_model.add(gate_name="h", gate_qubits=[qubits[0]], channel=pd, channel_qubits=[qubits[0]])
noise_model.add(gate_name="h", gate_qubits=[qubits[0]], channel=ad, channel_qubits=[qubits[1]])


# Rule 2.
noise_model.add(gate_name="cx", gate_qubits=qubits[0:2], channel=d, channel_qubits=qubits[0:2])
noise_model.add(gate_name="cx", gate_qubits=qubits[0:2], channel=ad, channel_qubits=[qubits[0]], when="pre")

# # Rule 3.
noise_model.add(gate_name="cx", gate_qubits=[qubits[1], qubits[0]], channel=d, channel_qubits="same")  # channel_qubits="same" is an equivalent way to specify channel_qubits=gate_qubits
noise_model.add(gate_name="cx", gate_qubits=[qubits[1], qubits[0]], channel=ad, channel_qubits=[qubits[1]], when="pre")

# # Rule 4.
noise_model.add(gate_name="h", gate_qubits=1, channel=pad, channel_qubits="same")

# # Rule 5.
noise_model.add(gate_name=ANY, gate_qubits=2, channel=d, channel_qubits="same")

# # Rule 6.
noise_model.add(gate_name=ANY, gate_qubits=1, channel=kraus_pd, channel_qubits="same")
noise_model.add(gate_name=ANY, gate_qubits=2, channel=kraus_pd, channel_qubits="same")

# # Rule 7.
noise_model.add_readout_error(qubits[0], 0.3, 0.6)

# # Rule 8.
noise_model.add_readout_error(ANY, 0.1, 0.2)

Custom Gates

Additionally, triggers can be defined with custom gates (a unitary in Qiskit, or MatrixGate in Cirq). If you wish to create custom gates with their own labels, then you should add those labels to the noise model’s list of supported gates, just like in Qiskit. To do this, you can use the add_supported_gate() method of the NoiseModel class. This method takes two arguments: the gate name, and the number of qubits that the gate acts on. For instance, if you have a custom gate called my_gate that acts on 2 qubits, you can add it to the noise model using noise_model.add_supported_gate('my_gate', 2).

from qiskit import QuantumCircuit

circuit = QuantumCircuit(2)
qubits = circuit.qubits

# Apply some custom unitaries (which for the purposes of the example are just the identity)
circuit.unitary(np.eye(2), [qubits[0]], label="U_0")
circuit.unitary(np.eye(2), [qubits[1]], label="U_1")
circuit.unitary(np.eye(4), [qubits[0], qubits[1]], label="U_01")

# Make a noise model
noise_model = NoiseModel(qubits=qubits, name="example-qiskit-noise-model", description="An example noise model for 2 qubits")

# Add the unitary names to the list of supported gates
noise_model.add_supported_gate(gate_name="U_0", n_qubits=1)  # Acts on 1 qubit
noise_model.add_supported_gate(gate_name="U_1", n_qubits=1)  # Acts on 1 qubit
noise_model.add_supported_gate(gate_name="U_01", n_qubits=2)  # Acts on 2 qubits

# Add some noise for these gates
d1 = DepolarizingChannel(num_qubits=1, p=0.3)
noise_model.add(gate_name="U_0", gate_qubits=[qubits[0]], channel=d1, channel_qubits="same")
noise_model.add(gate_name="U_1", gate_qubits=[qubits[0]], channel=d1, channel_qubits="same")
noise_model.add(gate_name="U_01", gate_qubits=qubits[0:2], channel=d1, channel_qubits="same")

Multiple matching triggers

In many cases, triggers overlap. If a gate in the circuit matches a specific trigger (i.e. cx on qubits (q0 , q1)), as well as a more general trigger (i.e. any gate on 2 qubits), only the noise from the more specific trigger is applied.

Note: This behavior is necessary for the noise model above to match rules (1) - (8)

Since it is not always obvious which trigger is ‘more general’, we specifically take the following view: gate triggers are more specific than qubit triggers – i.e. a trigger that matches a specific gate but generic qubits is considered more specific than a trigger than matches a generic gate but specific qubits. Precisely, the way that noise is applied to a circuit follows the lookup procedure in the flowchart below, which displays the decision process when a gate called <gate_name> is encountered in the circuit acting on <n> qubits with labels <qubits>.

noise lookup.svg

Errors

When defining a NoiseChannel, missing or inconsistent parameters return errors.

k_wrong = KrausChannel(kraus_ops=[np.array([[0, 0], [0, 0]])*np.sqrt(0.5), np.array([[0, 1], [1, 0]])*np.sqrt(0.5)])
pauli_wrong = PauliChannel(pauli_probs={"II": 0.5, "XI": 0.25, "YY": 0.2})

When adding noise to the NoiseModel, errors are raised whenever things don’t add up:

# H gates only act on single qubits, so this trigger is invaild.
noise_model.add(gate_name="h", gate_qubits=[qubits[0],qubits[1]], channel=pd, channel_qubits=[qubits[0]])
# d is a DepolarizingNoise instance on 2 qubits, but only one target is given
noise_model.add(gate_name="h", gate_qubits=[qubits[0]], channel=d, channel_qubits=[qubits[0]])
# Noise is triggered by a single qubit, and attempted to be applied to that same qubit, but the channel is a 2-qubit channel.
noise_model.add(gate_name=ANY, gate_qubits=1, channel=d, channel_qubits="same")
# Noise is triggered by any gate on any single qubit. To apply a channel to the same qubits, it should be a single-qubit channel, or act on the same number of qubits as the gate (1 again, in this example).
noise_model.add(gate_name=ANY, gate_qubits=1, channel=d, channel_qubits="same")

An error is also raised if you try to add a noise rule for a gate name that is not in the list of supported gates:

noise_model.add(gate_name="'unsupported'", gate_qubits=qubits[0:2], channel=d, channel_qubits="same")

Config and validation

We mentioned earlier that noise should be specified for all gates and readout errors should always be specified for all qubits, otherwise an error will be thrown upon validation (creation of an emulator job). This restriction can be dropped by setting a config option. Specifically, setting the validate_model flag of the noise config dictionary to False:

noisy_config["noise"]["validate_model"] = False