#
# Copyright © 2022-2023 University of Strasbourg. All Rights Reserved.
# Copyright © 2032-2024 QPerfect. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import mimiqcircuits as mc
from typing import List, Union
import numpy as np
import symengine as se
import sympy as sp
from mimiqcircuits.operations.krauschannel import krauschannel
from mimiqcircuits.operations.gates.generalized.paulistring import PauliString
[docs]
class PauliNoise(krauschannel):
r"""N-qubit Pauli noise channel specified by a list of probabilities and Pauli
gates.
A Pauli channel is defined by:
.. math::
\mathcal{E}(\rho) = \sum_k p_k P_k \rho P_k,
where :math:`0 \leq p_k \leq 1` and :math:`P_k` are Pauli string operators,
defined as tensor products of one-qubit Pauli operators. The probabilities must fulfill
:math:`\sum_k p_k = 1`.
This channel is a mixed unitary channel (see :func:`ismixedunitary`).
See Also:
:class:`Depolarizing`, :class:`PauliX`, :class:`PauliY`, :class:`PauliZ`,
which are special cases of PauliNoise.
Parameters:
p (list): List of probabilities that must add up to 1.
paulistrings (list): List of strings, each of length :math:`N`, with each character
being either `"I"`, `"X"`, `"Y"`, or `"Z"`. The number of qubits is equal to :math:`N`.
The lengths of `p` and `paulistrings` must be the same.
Examples:
PauliNoise channels can be defined for any number of qubits,
and for any number of Pauli strings:
>>> from mimiqcircuits import *
>>> c = Circuit()
>>> c.push(PauliNoise([0.8, 0.1, 0.1], ["I", "X", "Y"]), 1)
2-qubit circuit with 1 instructions:
└── PauliNoise((0.8, pauli"I"), (0.1, pauli"X"), (0.1, pauli"Y")) @ q[1]
<BLANKLINE>
>>> c.push(PauliNoise([0.9, 0.1], ["XY", "II"]), 1, 2)
3-qubit circuit with 2 instructions:
├── PauliNoise((0.8, pauli"I"), (0.1, pauli"X"), (0.1, pauli"Y")) @ q[1]
└── PauliNoise((0.9, pauli"XY"), (0.1, pauli"II")) @ q[1]
<BLANKLINE>
>>> c.push(PauliNoise([0.5, 0.2, 0.2, 0.1], ["IXIX", "XYXY", "ZZZZ", "IXYZ"]), 1, 2, 3, 4)
5-qubit circuit with 3 instructions:
├── PauliNoise((0.8, pauli"I"), (0.1, pauli"X"), (0.1, pauli"Y")) @ q[1]
├── PauliNoise((0.9, pauli"XY"), (0.1, pauli"II")) @ q[1]
└── PauliNoise((0.5, pauli"IXIX"), (0.2, pauli"XYXY"), (0.2, pauli"ZZZZ"), (0.1, pauli"IXYZ")) @ q[1]
<BLANKLINE>
"""
_name = "PauliNoise"
_num_qubits = None
_qregsizes = [1]
_parnames = ()
def __init__(self, p: List[Union[float, int]], paulistr: List[str]):
super().__init__()
if len(paulistr) == 0:
raise ValueError("List of Pauli strings must contain at least one element.")
if len(p) != len(paulistr):
raise ValueError(
"Lists of probabilities and Pauli strings must have the same length."
)
# Convert strings to PauliString instances, which will raise errors if invalid
self.paulistr = [PauliString(s) for s in paulistr]
self._num_qubits = self.paulistr[0].num_qubits
if self._num_qubits < 1:
raise ValueError("Cannot define a 0-qubit Pauli noise channel.")
for pauli_string in self.paulistr:
if pauli_string.num_qubits != self._num_qubits:
raise ValueError("All Pauli strings must be of the same length.")
# Check if any probability is symbolic; skip range and sum checks if so
if not any(isinstance(prob,(se.Basic, sp.Basic)) for prob in p):
if not np.isclose(sum(p), 1, rtol=1e-8):
raise ValueError("List of probabilities should add up to 1.")
if not all(0 <= prob <= 1 for prob in p):
raise ValueError("All probabilities must be between 0 and 1.")
self.p = p
self._parnames = ("p", "paulistr")
@property
def num_qubits(self):
return self._num_qubits
@property
def parnames(self):
return self._parnames
[docs]
def evaluate(self, d={}):
# Substitute and evaluate each probability expression if possible
evaluated_p = [
float(prob.subs(d).evalf()) if hasattr(prob, 'subs') and prob.subs(d).is_number
else prob.subs(d) if hasattr(prob, 'subs')
else prob
for prob in self.p
]
# Range check: Only perform the check if `prob` is numeric
for prob in evaluated_p:
if isinstance(prob, (int, float)) and (prob < 0 or prob > 1):
raise ValueError("All numeric probabilities must be between 0 and 1 after evaluation.")
# Sum check: Only perform if all probabilities are numeric
numeric_probs = [prob for prob in evaluated_p if isinstance(prob, (int, float))]
if len(numeric_probs) == len(evaluated_p) and not np.isclose(sum(numeric_probs), 1, rtol=1e-8):
raise ValueError("Numeric probabilities should add up to 1 after evaluation.")
# Return a new PauliNoise instance with the evaluated probabilities and the same Pauli strings
return PauliNoise(evaluated_p, [str(s) for s in self.paulistr])
[docs]
def krausmatrices(self):
probabilities = np.sqrt(self.probabilities())
unitary_matrices = self.unitarymatrices()
# Element-wise multiplication
return [
se.Matrix((prob * unitary).tolist())
for prob, unitary in zip(probabilities, unitary_matrices)
]
[docs]
def krausoperators(self):
probabilities = np.sqrt(self.probabilities())
pauli_strings = self.unitarygates()
return [mc.RescaledGate(pauli, prob) for pauli, prob in zip(pauli_strings, probabilities)]
[docs]
def probabilities(self):
return self.p
[docs]
def unitarymatrices(self):
return [
se.Matrix(pauli_str.unwrapped_matrix().tolist())
for pauli_str in self.paulistr
]
[docs]
def unitarygates(self):
return [mc.PauliString(str) for str in self.paulistr]
def _pauli_to_matrix(self, pauli: str):
"""Convert a Pauli string to its corresponding matrix representation."""
matrices = {
"I": mc.GateID().matrix(),
"X": mc.GateX().matrix(),
"Y": mc.GateY().matrix(),
"Z": mc.GateZ().matrix(),
}
result = matrices[pauli[0]]
for char in pauli[1:]:
result = np.kron(result, matrices[char])
return result
[docs]
@classmethod
def ismixedunitary(self):
return True
[docs]
def iswrapper(self):
return False
def __str__(self):
op_name = "PauliNoise"
ops_str = ", ".join(
f'({p}, pauli"{U}")' for p, U in zip(self.probabilities(), self.paulistr)
)
return f"{op_name}({ops_str})"
def __repr__(self):
return self.__str__()
[docs]
def unwrappedkrausmatrices(self):
return self.krausmatrices()
[docs]
class PauliX(krauschannel):
r"""One-qubit Pauli X noise channel (bit flip error).
This channel is defined by the Kraus operators:
.. math::
E_1 = \sqrt{1-p}\,I, \quad E_2 = \sqrt{p}\,X,
where :math:`0 \leq p \leq 1`.
This channel is a mixed unitary channel (see :func:`ismixedunitary`),
and is a special case of :class:`PauliNoise`.
`PauliX(p)` is equivalent to `PauliNoise([1-p, p], ["I", "X"])`.
Parameters:
p (float): Probability of a bit flip error, must be in the range [0, 1].
Examples:
>>> from mimiqcircuits import *
>>> c = Circuit()
>>> c.push(PauliX(0.1), 1)
2-qubit circuit with 1 instructions:
└── PauliX(0.1) @ q[1]
<BLANKLINE>
"""
_name = "PauliX"
_num_qubits = 1
_parnames = ()
def __init__(self, p: Union[float, int]):
if not isinstance(p, (se.Basic, sp.Basic)) and (p < 0 or p > 1):
raise ValueError("Probability should be between 0 and 1.")
self.p = p
super().__init__()
self._parnames = ("p",)
[docs]
def krausmatrices(self):
probabilities = np.sqrt(self.probabilities())
unitary_matrices = self.unitarymatrices()
# Element-wise multiplication
return [
se.Matrix((prob * unitary).tolist())
for prob, unitary in zip(probabilities, unitary_matrices)
]
[docs]
def evaluate(self, d={}):
# Substitute and evaluate `self.p` using the provided dictionary `d`
evaluated_p = (
float(self.p.subs(d).evalf()) if hasattr(self.p, 'subs') and self.p.subs(d).is_number
else self.p.subs(d) if hasattr(self.p, 'subs')
else self.p
)
# Range check: Only perform if `evaluated_p` is numeric
if isinstance(evaluated_p, (int, float)) and (evaluated_p < 0 or evaluated_p > 1):
raise ValueError("Probability p must be between 0 and 1 after evaluation.")
# Return a new instance of the same class with the evaluated probability
return type(self)(evaluated_p)
[docs]
def krausoperators(self):
return [mc.Operator(Ek) for (Ek) in self.krausmatrices()]
[docs]
def probabilities(self):
return [1 - self.p, self.p]
[docs]
def unitarymatrices(self):
return [Uk.matrix() for Uk in self.unitarygates()]
[docs]
def unitarygates(self):
return [mc.GateID(), mc.GateX()]
[docs]
@staticmethod
def ismixedunitary():
return True
def __str__(self):
return f"{self._name}({self.p})"
[docs]
class PauliY(krauschannel):
r"""One-qubit Pauli Y noise channel (bit-phase flip error).
This channel is determined by the Kraus operators:
.. math::
E_1 = \sqrt{1-p}\,I, \quad E_2 = \sqrt{p}\,Y,
where :math:`0 \leq p \leq 1`.
This channel is a mixed unitary channel (see :func:`ismixedunitary`),
and is a special case of :class:`PauliNoise`.
`PauliY(p)` is equivalent to `PauliNoise([1-p, p], ["I", "Y"])`.
Parameters:
p (float): Probability of a bit-phase flip error, must be in the range [0, 1].
Examples:
>>> from mimiqcircuits import *
>>> c = Circuit()
>>> c.push(PauliY(0.1), 1)
2-qubit circuit with 1 instructions:
└── PauliY(0.1) @ q[1]
<BLANKLINE>
"""
_name = "PauliY"
_num_qubits = 1
_parnames = ()
def __init__(self, p: Union[float, int]):
if not isinstance(p, (se.Basic, sp.Basic)) and (p < 0 or p > 1):
raise ValueError("Probability should be between 0 and 1.")
self.p = p
super().__init__()
self._parnames = "p"
[docs]
def evaluate(self, d={}):
evaluated_p = (
float(self.p.subs(d).evalf()) if hasattr(self.p, 'subs') and self.p.subs(d).is_number
else self.p.subs(d) if hasattr(self.p, 'subs')
else self.p
)
if isinstance(evaluated_p, (int, float)) and (evaluated_p < 0 or evaluated_p > 1):
raise ValueError("Probability p must be between 0 and 1 after evaluation.")
return type(self)(evaluated_p)
[docs]
def krausmatrices(self):
probabilities = np.sqrt(self.probabilities())
unitary_matrices = self.unitarymatrices()
# Element-wise multiplication
return [
se.Matrix((prob * unitary).tolist())
for prob, unitary in zip(probabilities, unitary_matrices)
]
[docs]
def krausoperators(self):
return [mc.Operator(Ek) for (Ek) in self.krausmatrices()]
[docs]
def probabilities(self):
return [1 - self.p, self.p]
[docs]
def unitarymatrices(self):
return [Uk.matrix() for Uk in self.unitarygates()]
[docs]
def unitarygates(self):
return [mc.GateID(), mc.GateY()]
[docs]
@staticmethod
def ismixedunitary():
return True
def __str__(self):
return f"{self._name}({self.p})"
[docs]
class PauliZ(krauschannel):
r"""One-qubit Pauli Z noise channel (phase flip error).
This channel is determined by the Kraus operators:
.. math::
E_1 = \sqrt{1-p}\,I, \quad E_2 = \sqrt{p}\,Z,
where :math:`0 \leq p \leq 1`.
This channel is a mixed unitary channel (see :func:`ismixedunitary`),
and is a special case of :class:`PauliNoise`.
`PauliZ(p)` is equivalent to `PauliNoise([1-p, p], ["I", "Z"])`.
Parameters:
p (float): Probability of a phase flip error, must be in the range [0, 1].
Examples:
>>> from mimiqcircuits import *
>>> c = Circuit()
>>> c.push(PauliZ(0.1), 1)
2-qubit circuit with 1 instructions:
└── PauliZ(0.1) @ q[1]
<BLANKLINE>
"""
_name = "PauliZ"
_num_qubits = 1
_parnames = ()
def __init__(self, p: Union[float, int]):
if not isinstance(p, (se.Basic, sp.Basic)) and (p < 0 or p > 1):
raise ValueError("Probability should be between 0 and 1.")
self.p = p
super().__init__()
self._parnames = "p"
[docs]
def evaluate(self, d={}):
evaluated_p = (
float(self.p.subs(d).evalf()) if hasattr(self.p, 'subs') and self.p.subs(d).is_number
else self.p.subs(d) if hasattr(self.p, 'subs')
else self.p
)
if isinstance(evaluated_p, (int, float)) and (evaluated_p < 0 or evaluated_p > 1):
raise ValueError("Probability p must be between 0 and 1 after evaluation.")
return type(self)(evaluated_p)
[docs]
def krausmatrices(self):
probabilities = np.sqrt(self.probabilities())
unitary_matrices = self.unitarymatrices()
# Element-wise multiplication
return [
se.Matrix((prob * unitary).tolist())
for prob, unitary in zip(probabilities, unitary_matrices)
]
[docs]
def krausoperators(self):
return [mc.Operator(Ek) for (Ek) in self.krausmatrices()]
[docs]
def probabilities(self):
return [1 - self.p, self.p]
[docs]
def unitarymatrices(self):
return [Uk.matrix() for Uk in self.unitarygates()]
[docs]
def unitarygates(self):
return [mc.GateID(), mc.GateZ()]
[docs]
@staticmethod
def ismixedunitary():
return True
def __str__(self):
return f"{self._name}({self.p})"
__all__ = ["PauliNoise", "PauliX", "PauliY", "PauliZ"]