Noisy simulations on MIMIQ#
This page provides detailed information on simulating noise in quantum circuits using MIMIQ.
Contents#
Summary of noise functionality#
Custom noise channels:
Specialized noise channels:
Note that the Reset
type operations can also be thought of as noisy operations.
Coherent noise can be added by using any of the supported gates (e.g., Gate
).
Noise channels come with the following methods:
unitarymatrices()
andunitarygates()
(only for mixed-unitary)probabilities()
(only for mixed-unitary)
To add noise channels to a circuit, you can use:
push()
(like gates)add_noise()
(to add noise to every instance of a gate)
To generate one sample of a circuit with mixed unitaries, use:
See below for further information. You can also run help(Circuit().sample_mixedunitaries).
Mathematical background#
Kraus operators#
Noise in a quantum circuit refers to any kind of unwanted interaction of the qubits with the environment (or with itself). Mathematically, this puts us in the framework of open systems and the state of the qubits now needs to be described in terms of a density matrix \(\rho\), which fulfills \(\rho = \rho^\dagger\) and \(\mathrm{Tr} (\rho) = 1\).
A quantum operation such as noise can then be described using the Kraus operator representation as:
We consider only completely positive and trace-preserving (CPTP) operations. In this case, the Kraus operators \(E_k\) can be any matrix as long as they fulfill the completeness relation:
Note that unitary gates \(U\) just correspond to a single Kraus operator, \(E_1 = U\).
When all Kraus operators are proportional to a unitary matrix, \(E_k = \alpha_k U_k\), this is called a mixed-unitary quantum operation and can be written as:
where \(p_k = |\alpha_k|^2\).
Such operations are easier to implement as we’ll see below.
Remarks:
Unitary gates \(U\) correspond to a single Kraus operator, \(E_1 = U\).
The number of Kraus operators depends on the noise considered.
For a given quantum operation \(\mathcal{E}\), the Kraus operator representation is not unique. One can change the basis of Kraus operators using a unitary matrix \(U\) as \(\tilde{E}_i = \sum_j U_{ij} E_j\).
We define a noise channel (or Kraus channel) as a quantum operation \(\mathcal{E}\) described by a set of Kraus operators as given above. A common way of modeling noisy quantum computers is by considering each operation \(O\) in the circuit as a noisy quantum operation \(\mathcal{E}_O\).
Evolution with noise#
There are two common ways to evolve the state of the system when acting with Kraus channels:
Density matrix: If we use a density matrix to describe the qubits, then a Kraus channel can simply be applied by directly performing the matrix multiplications as:
\[\mathcal{E}(\rho) = \sum_k E_k \rho E_k^\dagger.\]The advantage of this approach is that the density matrix contains the full information of the system and we only need to run the circuit once. The disadvantage is that \(\rho\) requires more memory to be stored (\(2^{2N}\) as opposed to \(2^N\) for a state vector), so we can simulate fewer qubits.
Quantum trajectories: This method consists in simulating the evolution of the state vector \(|\psi_\alpha \rangle\) for a set of iterations \(\alpha = 1, \ldots, n\). In each iteration, a noise channel is applied by randomly selecting one of the Kraus operators according to some probabilities (see below) and applying that Kraus operator to the state vector. The advantage of this approach is that we need less memory since we work with a state vector. The disadvantage is that we need to run the circuit many times to collect samples (one sample per run).
Currently, MIMIQ only implements the quantum trajectories method.
The basis for quantum trajectories is that a Kraus channel can be rewritten as:
where \(p_k = \mathrm{Tr}(E_k \rho E_k^\dagger)\) and \(\tilde{E}_k = E_k / \sqrt{p_k}\).
The parameters \(p_k\) can be interpreted as probabilities since they fulfill \(0 \leq p_k \leq 1\) and \(\sum_k p_k = 1\). In this way, the Kraus channel can be viewed as a linear combination of operations with different Kraus operators weighted by the probabilities \(p_k\).
Note that the probabilities \(p_k\) generally depend on the state, so they need to be computed at runtime. The exception is mixed-unitary channels, for which the probabilities are fixed (state-independent).
Building noise channels#
You can create noise channels using one of the many functions available. Most noise channels take one or more parameters, and custom channels require passing the Kraus matrices and/or probabilities. Here are some examples of how to build noise channels:
>>> p = 0.1 # probability
>>> PauliX(p)
PauliX(0.1)
>>> p, gamma = 0.1, 0.2 # parameters
>>> GeneralizedAmplitudeDamping(p, gamma)
GeneralizedAmplitudeDamping((0.1, 0.2))
>>> ps = [0.8, 0.1, 0.1] # probabilities
>>> paulis = ["II", "XX", "YY"] # Pauli strings
>>> PauliNoise(ps, paulis)
PauliNoise((0.8, pauli"II"), (0.1, pauli"XX"), (0.1, pauli"YY"))
>>> from symengine import *
>>> ps = [0.9, 0.1] # probabilities
>>> unitaries = [Matrix([[1, 0], [0, 1]]), Matrix([[1, 0], [0, -1]])] # unitary matrices
>>> MixedUnitary(ps, unitaries)
MixedUnitary((0.9, "Custom([1 0; 0 1])"), (0.1, "Custom([1 0; 0 -1])"))
>>> kmatrices = [Matrix([[1, 0], [0, (0.9)**0.5]]), Matrix([[0, (0.1)**0.5], [0, 0]])] # Kraus matrices
>>> Kraus(kmatrices)
Kraus(Operator([[1, 0], [0, 0.948683298050514]]), Operator([[0, 0.316227766016838], [0, 0]]))
Check the documentation for each noise channel to understand the conditions that each parameter needs to fulfill for the noise channel to be valid.
In MIMIQ, the most important distinction between noise channels is whether they are mixed-unitary or general Kraus channels. You can use ismixedunitary()
to check if a channel is mixed unitary:
>>> PauliX(0.1).ismixedunitary()
True
>>> AmplitudeDamping(0.1).ismixedunitary()
False
You can retrieve the Kraus matrices/operators used to define a given channel using krausmatrices()
or krausoperators()
. For example:
>>> ProjectiveNoise("Z").krausmatrices()
[[1.0, 0]
[0, 0]
, [0, 0]
[0, 1.0]
]
For mixed unitary channels, you can obtain the list of probabilities and the list of unitary gates/matrices separately using probabilities()
, unitarymatrices()
, or unitarygates()
, respectively:
>>> PauliZ(0.1).unitarymatrices()
[[1.0, 0]
[0, 1.0]
, [1.0, 0]
[0, -1.0]
]
>>> Depolarizing1(0.1).unitarygates()
[I, X, Y, Z]
>>> PauliNoise([0.1, 0.9], ["II", "ZZ"]).probabilities()
[0.1, 0.9]
In MIMIQ, noise channels can be added at any point in a circuit to make any operation noisy. For noisy gates, one would normally add a noise channel after an ideal gate. To model measurement, preparation, and reset errors, noise channels can be added before and/or after the corresponding operation. More information can be found in the next section.
How to add noise#
Adding noise one by one#
The simplest and most flexible way to add noise to a circuit is by using the push()
method, similar to how gates are added. Here’s an example of how to create a noisy 5-qubit GHZ circuit:
>>> c = Circuit()
>>> c.push(PauliX(0.1), [1, 2, 3, 4, 5]) # preparation/reset error since all qubits start in 0
6-qubit circuit with 5 instructions:
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
└── PauliX(0.1) @ q[5]
>>> c.push(GateH(), 1)
6-qubit circuit with 6 instructions:
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
├── PauliX(0.1) @ q[5]
└── H @ q[1]
>>> c.push(AmplitudeDamping(0.1), 1) # 1-qubit noise for GateH
6-qubit circuit with 7 instructions:
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
├── PauliX(0.1) @ q[5]
├── H @ q[1]
└── AmplitudeDamping(0.1) @ q[1]
>>> c.push(GateCX(), 1, [2, 3, 4, 5])
6-qubit circuit with 11 instructions:
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
├── PauliX(0.1) @ q[5]
├── H @ q[1]
├── AmplitudeDamping(0.1) @ q[1]
├── CX @ q[1], q[2]
├── CX @ q[1], q[3]
├── CX @ q[1], q[4]
└── CX @ q[1], q[5]
>>> c.push(Depolarizing2(0.1), 1, [2, 3, 4, 5]) # 2-qubit noise for GateCX
6-qubit circuit with 15 instructions:
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
├── PauliX(0.1) @ q[5]
├── H @ q[1]
├── AmplitudeDamping(0.1) @ q[1]
├── CX @ q[1], q[2]
├── CX @ q[1], q[3]
├── CX @ q[1], q[4]
├── CX @ q[1], q[5]
├── Depolarizing(0.1) @ q[1,2]
├── Depolarizing(0.1) @ q[1,3]
├── Depolarizing(0.1) @ q[1,4]
└── Depolarizing(0.1) @ q[1,5]
>>> c.push(PauliX(0.1), [1, 2, 3, 4, 5]) # measurement error. Note it's added before the measurement
6-qubit circuit with 20 instructions:
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
├── PauliX(0.1) @ q[5]
├── H @ q[1]
├── AmplitudeDamping(0.1) @ q[1]
├── CX @ q[1], q[2]
├── CX @ q[1], q[3]
├── CX @ q[1], q[4]
├── CX @ q[1], q[5]
├── Depolarizing(0.1) @ q[1,2]
├── Depolarizing(0.1) @ q[1,3]
├── Depolarizing(0.1) @ q[1,4]
├── Depolarizing(0.1) @ q[1,5]
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
└── PauliX(0.1) @ q[5]
>>> c=c.push(Measure(), [1, 2, 3, 4, 5], [1, 2, 3, 4, 5])
>>> print(c)
6-qubit circuit with 25 instructions:
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
├── PauliX(0.1) @ q[5]
├── H @ q[1]
├── AmplitudeDamping(0.1) @ q[1]
├── CX @ q[1], q[2]
├── CX @ q[1], q[3]
├── CX @ q[1], q[4]
├── CX @ q[1], q[5]
├── Depolarizing(0.1) @ q[1,2]
├── Depolarizing(0.1) @ q[1,3]
├── Depolarizing(0.1) @ q[1,4]
├── Depolarizing(0.1) @ q[1,5]
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
├── PauliX(0.1) @ q[5]
├── M @ q[1], c[1]
├── M @ q[2], c[2]
├── M @ q[3], c[3]
├── M @ q[4], c[4]
└── M @ q[5], c[5]
Note how bit-flip error (PauliX
) is added at the beginning for state preparation/reset errors and right before measuring for measurement errors.
Adding noise to all gates of the same type#
Usually, when adding noise to a circuit, the same type of noise is added to each instance of a given gate. Instead of adding noise channels one by one, you can use add_noise()
. It takes several parameters:
Circuit().add_noise(gate, kraus, before=False, parallel=False)
This function adds the noise channel specified by kraus to every instance of the gate g in the circuit circuit. The optional parameter before determines whether to add the noise before or after the operation, and parallel determines whether to add the noise in parallel after/before a block of transversal gates.
Here’s the same noisy GHZ circuit, using add_noise()
:
>>> cnoise = c.add_noise(Reset(), PauliX(0.1), parallel=True)
>>> cnoise.add_noise(GateH(), AmplitudeDamping(0.1))
6-qubit circuit with 21 instructions:
├── Reset @ q[1]
├── Reset @ q[2]
├── Reset @ q[3]
├── Reset @ q[4]
├── Reset @ q[5]
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
├── PauliX(0.1) @ q[5]
├── H @ q[1]
├── AmplitudeDamping(0.1) @ q[1]
├── CX @ q[1], q[2]
├── CX @ q[1], q[3]
├── CX @ q[1], q[4]
├── CX @ q[1], q[5]
├── M @ q[1], c[1]
├── M @ q[2], c[2]
├── M @ q[3], c[3]
⋮ ⋮
└── M @ q[5], c[5]
>>> cnoise.add_noise(GateCX(), Depolarizing2(0.1), parallel=True)
6-qubit circuit with 25 instructions:
├── Reset @ q[1]
├── Reset @ q[2]
├── Reset @ q[3]
├── Reset @ q[4]
├── Reset @ q[5]
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
├── PauliX(0.1) @ q[5]
├── H @ q[1]
├── AmplitudeDamping(0.1) @ q[1]
├── CX @ q[1], q[2]
├── Depolarizing(0.1) @ q[1,2]
├── CX @ q[1], q[3]
├── Depolarizing(0.1) @ q[1,3]
├── CX @ q[1], q[4]
├── Depolarizing(0.1) @ q[1,4]
├── CX @ q[1], q[5]
⋮ ⋮
└── M @ q[5], c[5]
>>> cnoise.add_noise(Measure(), PauliX(0.1), before=True, parallel=True)
6-qubit circuit with 30 instructions:
├── Reset @ q[1]
├── Reset @ q[2]
├── Reset @ q[3]
├── Reset @ q[4]
├── Reset @ q[5]
├── PauliX(0.1) @ q[1]
├── PauliX(0.1) @ q[2]
├── PauliX(0.1) @ q[3]
├── PauliX(0.1) @ q[4]
├── PauliX(0.1) @ q[5]
├── H @ q[1]
├── AmplitudeDamping(0.1) @ q[1]
├── CX @ q[1], q[2]
├── Depolarizing(0.1) @ q[1,2]
├── CX @ q[1], q[3]
├── Depolarizing(0.1) @ q[1,3]
├── CX @ q[1], q[4]
├── Depolarizing(0.1) @ q[1,4]
├── CX @ q[1], q[5]
⋮ ⋮
└── M @ q[5], c[5]
Running a noisy circuit#
Circuits with noise can be run using the execute()
function,
just as with circuits without noise. Currently, noisy simulations are run using the quantum trajectories
method. In this case, when running a circuit with noise for n samples, the circuit will internally be
run once for each sample, with a different set of random Kraus operators selected based on the
corresponding probabilities.
When the noise channel is a mixed-unitary channel, the unitary operators to be applied can be selected
before applying the operations on the state. Use the sample_mixedunitaries()
function to generate samples of a circuit with mixed-unitary noise:
>>> from mimiqcircuits import *
>>> import random
>>> rng = random.Random(42)
>>> c = Circuit()
>>> c.push(Depolarizing1(0.5), [1, 2, 3, 4, 5])
6-qubit circuit with 5 instructions:
├── Depolarizing(0.5) @ q[1]
├── Depolarizing(0.5) @ q[2]
├── Depolarizing(0.5) @ q[3]
├── Depolarizing(0.5) @ q[4]
└── Depolarizing(0.5) @ q[5]
# Produce a circuit with either I, X, Y, or Z in place of each depolarizing channel
>>> c.sample_mixedunitaries(rng=rng, ids=True)
6-qubit circuit with 5 instructions:
├── X @ q[1]
├── I @ q[2]
├── I @ q[3]
├── I @ q[4]
└── Y @ q[5]
This function is called internally when executing a circuit, but it can also be used separately.