# Copyright (C) Unitary Fund
#
# This source code is licensed under the GPL license (v3) found in the
# LICENSE file in the root directory of this source tree.
"""Types used in probabilistic error cancellation."""
import warnings
from copy import deepcopy
from typing import Any, List, Optional, Tuple, Union
import cirq
import numpy as np
import numpy.typing as npt
from cirq.value.linear_dict import _format_coefficient
from mitiq import QPROGRAM
from mitiq.interface import (
CircuitConversionError,
UnsupportedCircuitError,
convert_to_mitiq,
)
[docs]
class NoisyOperation:
"""An operation (or sequence of operations) which a noisy quantum computer
can actually implement.
Args:
circuit: A short circuit which, when executed on a given noisy
quantum computer, generates a noisy channel. It typically
contains a single-gate or a short sequence of gates.
channel_matrix: Superoperator representation of the noisy channel
which is generated when executing the input ``circuit`` on the
noisy quantum computer.
Raises:
TypeError: If ``ideal`` is not a ``QPROGRAM``.
"""
def __init__(
self,
circuit: QPROGRAM,
channel_matrix: Optional[npt.NDArray[np.complex64]] = None,
) -> None:
self._native_circuit = deepcopy(circuit)
try:
cirq_circuit, native_type = convert_to_mitiq(circuit)
except (CircuitConversionError, UnsupportedCircuitError):
raise TypeError(
"Failed to convert to an internal Mitiq representation"
f"the input circuit:\n{type(circuit)}\n"
)
self._circuit = cirq_circuit
self._native_type = native_type
dimension = 2**self.num_qubits
if channel_matrix is None:
self._channel_matrix = None
elif channel_matrix.shape != (
dimension**2,
dimension**2,
):
raise ValueError(
f"Arg `channel_matrix` has shape {channel_matrix.shape}"
" but the expected shape is"
f" {dimension ** 2, dimension ** 2}."
)
self._channel_matrix = deepcopy(channel_matrix)
@property
def circuit(self) -> cirq.Circuit:
"""Returns the circuit of the NoisyOperation as a Cirq circuit."""
return self._circuit
@property
def native_circuit(self) -> QPROGRAM:
"""Returns the circuit used to initialize the NoisyOperation."""
return self._native_circuit
@property
def qubits(self) -> Tuple[cirq.Qid, ...]:
return tuple(self._circuit.all_qubits())
@property
def num_qubits(self) -> int:
return len(self.qubits)
@property
def channel_matrix(self) -> npt.NDArray[np.complex64]:
if self._channel_matrix is None:
raise ValueError("The channel matrix is unknown.")
return deepcopy(self._channel_matrix)
def __add__(self, other: Any) -> "NoisyOperation":
if not isinstance(other, NoisyOperation):
raise ValueError(
f"Arg `other` must be a NoisyOperation but was {type(other)}."
)
if self.qubits != other.qubits:
raise NotImplementedError
if self._channel_matrix is None or other._channel_matrix is None:
matrix = None
else:
matrix = other._channel_matrix @ self._channel_matrix
return NoisyOperation(self._circuit + other._circuit, matrix)
def __str__(self) -> str:
return self._native_circuit.__str__()
[docs]
class NoisyBasis:
"""A set of noisy operations which a quantum computer can actually
implement, assumed to form a basis of n-qubit unitary matrices.
This class has been removed since Mitiq v0.24.0.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
raise NotImplementedError(
"The NoisyBasis class has been removed since Mitiq v0.24.0."
" Please replace it with a list of NoisyOperation objects."
)
[docs]
class OperationRepresentation:
"""A decomposition (basis expansion) of an operation or sequence of
operations in a basis of noisy, implementable operations.
Args:
ideal: The ideal operation desired to be implemented.
basis_expansion: Representation of the ideal operation in a basis
of ``NoisyOperation`` objects.
is_qubit_dependent: If True, the representation
corresponds to the operation on the specific qubits defined in
``ideal``. If False, the representation is valid for the same
gate even if acting on different qubits from those specified in
``ideal``.
Raises:
TypeError: If all keys of ``basis_expansion`` are not instances of
``NoisyOperation`` objects.
"""
def __init__(
self,
ideal: QPROGRAM,
noisy_operations: List[NoisyOperation],
coeffs: List[float],
is_qubit_dependent: bool = True,
) -> None:
if not all(isinstance(o, NoisyOperation) for o in noisy_operations):
raise TypeError(
"All elements of `noisy_operations` must be "
"of type `NoisyOperation`."
)
if not all(isinstance(c, float) for c in coeffs):
raise TypeError("All elements of `coeffs` must be floats.")
self._native_ideal = deepcopy(ideal)
self._ideal, self._native_type = convert_to_mitiq(ideal)
self._noisy_operations = noisy_operations
self._coeffs = coeffs
self._norm = sum(abs(c) for c in coeffs)
self._distribution = [abs(c) / self._norm for c in coeffs]
self.is_qubit_dependent = is_qubit_dependent
self._validate()
def _validate(self) -> None:
"""Validates initialization arguments."""
if len(self._noisy_operations) != len(self._coeffs):
raise ValueError(
"`noisy_operations` and `coeffs` must have equal length"
f" but {len(self._noisy_operations)}!={len(self._coeffs)}."
)
if not np.isclose(sum(self._coeffs), 1.0, atol=10**-4):
warnings.warn("The sum of the coefficients is different from 1.")
for op in self._noisy_operations:
if self._ideal.all_qubits() != op.circuit.all_qubits():
raise ValueError(
"The operation to represent acts on"
f" {self._ideal.all_qubits()}. Noisy operations"
f" must act on the same qubits but {op} acts on:"
f" {op.circuit.all_qubits()}"
)
@property
def ideal(self) -> cirq.Circuit:
return self._ideal
@property
def basis_expansion(self) -> List[Tuple[float, NoisyOperation]]:
return [(c, o) for c, o in zip(self._coeffs, self._noisy_operations)]
@property
def noisy_operations(self) -> List[NoisyOperation]:
return self._noisy_operations
@property
def coeffs(self) -> List[float]:
"""Returns the coefficients of the quasi-probability distribution."""
return self._coeffs
@property
def norm(self) -> float:
"""Returns the 1-norm of the quasi-probability distribution."""
return self._norm
@property
def distribution(self) -> List[float]:
"""Returns the probability distribution obtained from taking
the absolute value and normalizing the quasi-probability distribution.
"""
return self._distribution
[docs]
def sample(
self, random_state: Optional[Union[int, np.random.RandomState]] = None
) -> Tuple[NoisyOperation, int, float]:
"""Returns a randomly sampled NoisyOperation from the basis expansion.
Args:
random_state: Defines the seed for sampling if provided.
"""
if not random_state:
rng = np.random
elif isinstance(random_state, int):
rng = np.random.RandomState(random_state) # type: ignore
elif isinstance(random_state, np.random.RandomState):
rng = random_state # type: ignore
else:
raise TypeError(
"Arg `random_state` should be of type `np.random.RandomState` "
f"or `int`, but was {type(random_state)}."
)
idx = rng.choice(len(self.coeffs), p=self.distribution)
coeff, noisy_op = self.basis_expansion[idx]
return noisy_op, int(np.sign(coeff)), coeff
def __str__(self) -> str:
lhs = str(self._ideal) + " = "
rhs = ""
for c, circ in zip(self.coeffs, self.noisy_operations):
c_str = _format_coefficient(".3f", c)
if c_str:
if c_str[0] not in ["+", "-"]:
c_str = "+" + c_str
if len(self._ideal.all_qubits()) == 1:
# Print single-qubit circuits horizontally
rhs += f"{c_str}*({circ!s})"
else:
# Print multi-qubit circuits vertically
rhs += "\n\n" + f"{c_str}\n{circ!s}"
# Handle special cases as in cirq.value.linear_dict._format_terms()
if not rhs:
rhs = f"{0:.3f}"
# Remove "+" in the first term of a single-qubit representation
if rhs[0] == "+":
rhs = rhs[1:]
# Remove "+" in the first term of a multi-qubit representation
if rhs[0:3] == "\n\n+":
rhs = "\n\n" + rhs[3:]
return lhs + rhs
def __eq__(self, other: Any) -> bool:
"""Checks if two representations are equivalent. This function returns
True if the representations have the same ideal operation, the same
coefficients and equivalent NoisyOperation(s) (same gates but not
necessarily same channel_matrix since channel_matrix is optional).
"""
if self.is_qubit_dependent != other.is_qubit_dependent:
return False
if self._native_type != other._native_type:
return False
if not self._ideal == other._ideal:
return False
if len(self.basis_expansion) != len(other.basis_expansion):
return False
for c_a, op_a in self.basis_expansion:
found = False
for c_b, op_b in other.basis_expansion:
if op_a._circuit == op_b._circuit:
found = True
break
if not found:
return False
if not np.isclose(c_a, c_b):
return False
return True