# This code is part of Qiskit.
#
# (C) Copyright IBM 2017, 2019.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.
"""
Abstract base class for Quantum Channels.
"""
from __future__ import annotations
import copy
import sys
from abc import abstractmethod
from numbers import Number, Integral
import numpy as np
from qiskit.circuit.instruction import Instruction
from qiskit.exceptions import QiskitError
from qiskit.quantum_info.operators.linear_op import LinearOp
from qiskit.quantum_info.operators.op_shape import OpShape
from qiskit.quantum_info.operators.operator import Operator
from qiskit.quantum_info.operators.predicates import is_positive_semidefinite_matrix
from qiskit.quantum_info.operators.channel.transformations import _transform_rep
from qiskit.quantum_info.operators.channel.transformations import _to_choi
from qiskit.quantum_info.operators.channel.transformations import _to_kraus
from qiskit.quantum_info.operators.channel.transformations import _to_operator
from qiskit.quantum_info.operators.scalar_op import ScalarOp
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
class QuantumChannel(LinearOp):
"""Quantum channel representation base class."""
def __init__(
self,
data: list | np.ndarray,
num_qubits: int | None = None,
op_shape: OpShape | None = None,
):
"""Initialize a quantum channel Superoperator operator.
Args:
data (array or list): quantum channel data array.
op_shape (OpShape): the operator shape of the channel.
num_qubits (int): the number of qubits if the channel is N-qubit.
Raises:
QiskitError: if arguments are invalid.
"""
self._data = data
super().__init__(num_qubits=num_qubits, op_shape=op_shape)
def __repr__(self):
prefix = f"{self._channel_rep}("
pad = len(prefix) * " "
return "{}{},\n{}input_dims={}, output_dims={})".format(
prefix,
np.array2string(np.asarray(self.data), separator=", ", prefix=prefix),
pad,
self.input_dims(),
self.output_dims(),
)
def __eq__(self, other: Self):
"""Test if two QuantumChannels are equal."""
if not super().__eq__(other):
return False
return np.allclose(self.data, other.data, rtol=self.rtol, atol=self.atol)
@property
def data(self):
"""Return data."""
return self._data
@property
def _channel_rep(self):
"""Return channel representation string"""
return type(self).__name__
@property
def settings(self):
"""Return settings."""
return {
"data": self.data,
"input_dims": self.input_dims(),
"output_dims": self.output_dims(),
}
# ---------------------------------------------------------------------
# LinearOp methods
# ---------------------------------------------------------------------
@abstractmethod
def conjugate(self):
r"""Return the conjugate quantum channel.
.. note::
This is equivalent to the matrix complex conjugate in the
:class:`~qiskit.quantum_info.SuperOp` representation
ie. for a channel :math:`\mathcal{E}`, the SuperOp of
the conjugate channel :math:`\overline{{\mathcal{{E}}}}` is
:math:`S_{\overline{\mathcal{E}^\dagger}} = \overline{S_{\mathcal{E}}}`.
"""
@abstractmethod
def transpose(self) -> Self:
r"""Return the transpose quantum channel.
.. note::
This is equivalent to the matrix transpose in the
:class:`~qiskit.quantum_info.SuperOp` representation,
ie. for a channel :math:`\mathcal{E}`, the SuperOp of
the transpose channel :math:`\mathcal{{E}}^T` is
:math:`S_{mathcal{E}^T} = S_{\mathcal{E}}^T`.
"""
def adjoint(self) -> Self:
r"""Return the adjoint quantum channel.
.. note::
This is equivalent to the matrix Hermitian conjugate in the
:class:`~qiskit.quantum_info.SuperOp` representation
ie. for a channel :math:`\mathcal{E}`, the SuperOp of
the adjoint channel :math:`\mathcal{{E}}^\dagger` is
:math:`S_{\mathcal{E}^\dagger} = S_{\mathcal{E}}^\dagger`.
"""
return self.conjugate().transpose()
def power(self, n: float) -> Self:
r"""Return the power of the quantum channel.
Args:
n (float): the power exponent.
Returns:
CLASS: the channel :math:`\mathcal{{E}} ^n`.
Raises:
QiskitError: if the input and output dimensions of the
CLASS are not equal.
.. note::
For non-positive or non-integer exponents the power is
defined as the matrix power of the
:class:`~qiskit.quantum_info.SuperOp` representation
ie. for a channel :math:`\mathcal{{E}}`, the SuperOp of
the powered channel :math:`\mathcal{{E}}^\n` is
:math:`S_{{\mathcal{{E}}^n}} = S_{{\mathcal{{E}}}}^n`.
"""
if n > 0 and isinstance(n, Integral):
return super().power(n)
# Conversion to superoperator
if self._input_dim != self._output_dim:
raise QiskitError("Can only take power with input_dim = output_dim.")
rep = self._channel_rep
input_dim, output_dim = self.dim
superop = _transform_rep(rep, "SuperOp", self._data, input_dim, output_dim)
superop = np.linalg.matrix_power(superop, n)
# Convert back to original representation
ret = copy.copy(self)
ret._data = _transform_rep("SuperOp", rep, superop, input_dim, output_dim)
return ret
def __sub__(self, other) -> Self:
qargs = getattr(other, "qargs", None)
if not isinstance(other, type(self)):
other = type(self)(other)
return self._add(-other, qargs=qargs)
def _add(self, other, qargs=None):
# NOTE: this method must be overridden for subclasses
# that don't have a linear matrix representation
# ie Kraus and Stinespring
if not isinstance(other, type(self)):
other = type(self)(other)
self._op_shape._validate_add(other._op_shape, qargs)
other = ScalarOp._pad_with_identity(self, other, qargs)
ret = copy.copy(self)
ret._data = self._data + other._data
return ret
def _multiply(self, other):
# NOTE: this method must be overridden for subclasses
# that don't have a linear matrix representation
# ie Kraus and Stinespring
if not isinstance(other, Number):
raise QiskitError("other is not a number")
ret = copy.copy(self)
ret._data = other * self._data
return ret
# ---------------------------------------------------------------------
# Additional methods
# ---------------------------------------------------------------------
def is_cptp(self, atol: float | None = None, rtol: float | None = None) -> bool:
"""Return True if completely-positive trace-preserving (CPTP)."""
choi = _to_choi(self._channel_rep, self._data, *self.dim)
return self._is_cp_helper(choi, atol, rtol) and self._is_tp_helper(choi, atol, rtol)
def is_tp(self, atol: float | None = None, rtol: float | None = None) -> bool:
"""Test if a channel is trace-preserving (TP)"""
choi = _to_choi(self._channel_rep, self._data, *self.dim)
return self._is_tp_helper(choi, atol, rtol)
def is_cp(self, atol: float | None = None, rtol: float | None = None) -> bool:
"""Test if Choi-matrix is completely-positive (CP)"""
choi = _to_choi(self._channel_rep, self._data, *self.dim)
return self._is_cp_helper(choi, atol, rtol)
def is_unitary(self, atol: float | None = None, rtol: float | None = None) -> bool:
"""Return True if QuantumChannel is a unitary channel."""
try:
op = self.to_operator()
return op.is_unitary(atol=atol, rtol=rtol)
except QiskitError:
return False
def to_operator(self) -> Operator:
"""Try to convert channel to a unitary representation Operator."""
mat = _to_operator(self._channel_rep, self._data, *self.dim)
return Operator(mat, self.input_dims(), self.output_dims())
def to_instruction(self) -> Instruction:
"""Convert to a Kraus or UnitaryGate circuit instruction.
If the channel is unitary it will be added as a unitary gate,
otherwise it will be added as a kraus simulator instruction.
Returns:
qiskit.circuit.Instruction: A kraus instruction for the channel.
Raises:
QiskitError: if input data is not an N-qubit CPTP quantum channel.
"""
# Check if input is an N-qubit CPTP channel.
num_qubits = int(np.log2(self._input_dim))
if self._input_dim != self._output_dim or 2**num_qubits != self._input_dim:
raise QiskitError(
"Cannot convert QuantumChannel to Instruction: channel is not an N-qubit channel."
)
if not self.is_cptp():
raise QiskitError("Cannot convert QuantumChannel to Instruction: channel is not CPTP.")
# Next we convert to the Kraus representation. Since channel is CPTP we know
# that there is only a single set of Kraus operators
kraus, _ = _to_kraus(self._channel_rep, self._data, *self.dim)
# If we only have a single Kraus operator then the channel is
# a unitary channel so can be converted to a UnitaryGate. We do this by
# converting to an Operator and using its to_instruction method
if len(kraus) == 1:
return Operator(kraus[0]).to_instruction()
return Instruction("kraus", num_qubits, 0, kraus)
def _is_cp_helper(self, choi, atol, rtol):
"""Test if a channel is completely-positive (CP)"""
if atol is None:
atol = self.atol
if rtol is None:
rtol = self.rtol
return is_positive_semidefinite_matrix(choi, rtol=rtol, atol=atol)
def _is_tp_helper(self, choi, atol, rtol):
"""Test if Choi-matrix is trace-preserving (TP)"""
if atol is None:
atol = self.atol
if rtol is None:
rtol = self.rtol
# Check if the partial trace is the identity matrix
d_in, d_out = self.dim
mat = np.trace(np.reshape(choi, (d_in, d_out, d_in, d_out)), axis1=1, axis2=3)
tp_cond = np.linalg.eigvalsh(mat - np.eye(len(mat)))
zero = np.isclose(tp_cond, 0, atol=atol, rtol=rtol)
return np.all(zero)
def _format_state(self, state, density_matrix=False):
"""Format input state so it is statevector or density matrix"""
state = np.array(state)
shape = state.shape
ndim = state.ndim
if ndim > 2:
raise QiskitError("Input state is not a vector or matrix.")
# Flatten column-vector to vector
if ndim == 2:
if shape[1] != 1 and shape[1] != shape[0]:
raise QiskitError("Input state is not a vector or matrix.")
if shape[1] == 1:
# flatten column-vector to vector
state = np.reshape(state, shape[0])
# Convert statevector to density matrix if required
if density_matrix and ndim == 1:
state = np.outer(state, np.transpose(np.conj(state)))
return state
@abstractmethod
def _evolve(self, state, qargs=None):
"""Evolve a quantum state by the quantum channel.
Args:
state (DensityMatrix or Statevector): The input state.
qargs (list): a list of quantum state subsystem positions to apply
the quantum channel on.
Returns:
DensityMatrix: the output quantum state as a density matrix.
Raises:
QiskitError: if the quantum channel dimension does not match the
specified quantum state subsystem dimensions.
"""
pass
@classmethod
def _init_transformer(cls, data):
"""Convert input into a QuantumChannel subclass object or Operator object"""
# This handles common conversion for all QuantumChannel subclasses.
# If the input is already a QuantumChannel subclass it will return
# the original object
if isinstance(data, QuantumChannel):
return data
if hasattr(data, "to_quantumchannel"):
# If the data object is not a QuantumChannel it will give
# preference to a 'to_quantumchannel' attribute that allows
# an arbitrary object to define its own conversion to any
# quantum channel subclass.
return data.to_quantumchannel()
if hasattr(data, "to_channel"):
# TODO: this 'to_channel' method is the same case as the above
# but is used by current version of Aer. It should be removed
# once Aer is nupdated to use `to_quantumchannel`
# instead of `to_channel`,
return data.to_channel()
# Finally if the input is not a QuantumChannel and doesn't have a
# 'to_quantumchannel' conversion method we try and initialize it as a
# regular matrix Operator which can be converted into a QuantumChannel.
return Operator(data)