# (C) Copyright IBM 2024.
#
# 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.
"""ProductFrame."""
from __future__ import annotations
import math
import sys
import warnings
from collections.abc import Sequence
from typing import Generic, TypeVar
if sys.version_info < (3, 11):
from typing_extensions import Self
else:
from typing import Self # pragma: no cover
if sys.version_info < (3, 12):
from typing_extensions import override
else:
from typing import override # pragma: no cover
import numpy as np
from qiskit.quantum_info import Operator, SparsePauliOp
from .base import BaseFrame
from .multi_qubit_frame import MultiQubitFrame
T = TypeVar("T", bound=MultiQubitFrame)
[docs]
class ProductFrame(BaseFrame[tuple[int, ...]], Generic[T]):
r"""Class to represent a set of product frame operators.
A product frame :math:`M` is made of local frames :math:`M1, M2, ...` acting on respective
subsystems. Each global operator can be written as the tensor product of local operators,
:math:`M_{k_1, k_2, ...} = M1_{k_1} \otimes M2_{k_2} \otimes \cdots`.
.. note::
This is a base class which collects functionality common to various subclasses. As an
end-user you would not use this class directly. Check out :mod:`povm_toolbox.quantum_info`
for more general information.
"""
def __init__(self, frames: dict[tuple[int, ...], T]) -> None:
"""Initialize from a mapping of local frames.
Args:
frames: a dictionary mapping from a tuple of subsystem indices to a local frame objects.
Raises:
ValueError: if any key in ``frames`` is not a tuple consisting of unique integers. In
other words, every local frame must act on a distinct set of subsystem indices which
do not overlap with each other.
ValueError: if any key in ``frames`` re-uses a previously used subsystem index. In other
words, all local frames must act on mutually exclusive subsystem indices.
ValueError: if any key in ``frames`` does not specify the number of subsystem indices,
which matches the number of systems acted upon by that local frame
(:meth:`MultiQubitFrame.num_subsystems`).
"""
subsystem_indices = set()
self._dimension = 1
self._num_operators = 1
shape: list[int] = []
for idx, frame in frames.items():
idx_set = set(idx)
if len(idx) != len(idx_set):
raise ValueError(
"The subsystem indices acted upon by any local frame must be mutually "
f"exclusive. The index '{idx}' does not fulfill this criterion."
)
if any(i in subsystem_indices for i in idx):
raise ValueError(
"The subsystem indices acted upon by all the local frames must be mutually "
f"exclusive. However, one of the indices in '{idx}' was already encountered "
"before."
)
if len(idx_set) != frame.num_subsystems:
raise ValueError(
"The number of subsystem indices for a local frame must match the number of "
"subsystems which it acts upon. This is not satisfied for the local frame "
f"specified to act on subsystems '{idx}' but having support on "
f"'{frame.num_subsystems}' subsystems."
)
subsystem_indices.update(idx_set)
self._dimension *= frame.dimension
self._num_operators *= frame.num_operators
shape.append(frame.num_operators)
self._informationally_complete: bool = all(
[frame.informationally_complete for frame in frames.values()]
)
self._frames = frames
self._shape: tuple[int, ...] = tuple(shape)
self._check_validity()
def __repr__(self) -> str:
"""Return the string representation of a :class:`.ProductFrame` instance."""
f_repr = "\n " + "\n ".join(f"{name}: {value}" for name, value in self._frames.items())
return (
f"{self.__class__.__name__}(num_subsystems={self.num_subsystems})"
f"<{','.join(map(str, self.shape))}>:{f_repr}"
)
[docs]
@classmethod
def from_list(cls, frames: Sequence[T]) -> Self:
"""Construct a :class:`.ProductFrame` from a list of :class:`.MultiQubitFrame` objects.
This is a convenience method to simplify the construction of a :class:`.ProductFrame` for
the cases in which the local frame objects act on a sequential order of subsystems. In other
words, this method converts the sequence of frames to a dictionary of frames in accordance
with the input to :meth:`.ProductFrame.__init__` by using the positions along the sequence
as subsystem indices.
Below are some examples:
>>> from qiskit.quantum_info import Operator
>>> from povm_toolbox.quantum_info import SingleQubitPOVM, MultiQubitPOVM, ProductPOVM
>>> sqp = SingleQubitPOVM([Operator.from_label("0"), Operator.from_label("1")])
>>> product = ProductPOVM.from_list([sqp, sqp])
>>> # is equivalent to
>>> product = ProductPOVM({(0,): sqp, (1,): sqp})
>>> mqp = MultiQubitPOVM(
... [
... Operator.from_label("00"),
... Operator.from_label("01"),
... Operator.from_label("10"),
... Operator.from_label("11"),
... ]
... )
>>> product = ProductPOVM.from_list([mqp, mqp])
>>> # is equivalent to
>>> product = ProductPOVM({(0, 1): mqp, (2, 3): mqp})
>>> product = ProductPOVM.from_list([sqp, sqp, mqp])
>>> # is equivalent to
>>> product = ProductPOVM({(0,): sqp, (1,): sqp, (2, 3): mqp})
>>> product = ProductPOVM.from_list([sqp, mqp, sqp])
>>> # is equivalent to
>>> product = ProductPOVM({(0,): sqp, (1, 2): mqp, (3,): sqp})
Args:
frames: a sequence of :class:`.MultiQubitFrame` objects.
Returns:
A new :class:`.ProductFrame` instance.
"""
frame_dict = {}
idx = 0
for frame in frames:
prev_idx = idx
idx += frame.num_subsystems
frame_dict[tuple(range(prev_idx, idx))] = frame
return cls(frame_dict)
@property
def informationally_complete(self) -> bool:
"""If the frame spans the entire Hilbert space."""
return self._informationally_complete
@property
def dimension(self) -> int:
"""The dimension of the Hilbert space on which the effects act."""
return self._dimension
@property
def num_operators(self) -> int:
"""The number of effects of the frame."""
return self._num_operators
@property
def shape(self) -> tuple[int, ...]:
"""Give the number of operators per sub-system."""
return self._shape
@property
def sub_systems(self) -> list[tuple[int, ...]]:
"""Give the number of operators per sub-system."""
return list(self._frames.keys())
def _check_validity(self) -> None:
"""Check if frame axioms are fulfilled for all local frames.
.. note::
This raises whatever errors the local frames' methods may raise.
"""
for povm in self._frames.values():
povm._check_validity()
def __getitem__(self, sub_system: tuple[int, ...]) -> T:
r"""Return the :class:`.MultiQubitFrame` acting on the specified sub-system.
Args:
sub_system: indicate the sub-system on which the queried frame acts on.
Returns:
The :class:`.MultiQubitFrame` acting on the specified sub-system.
"""
return self._frames[sub_system]
def __len__(self) -> int:
"""Return the number of outcomes of the product frame."""
return self.num_operators
def _trace_of_prod(self, operator: SparsePauliOp, frame_op_idx: tuple[int, ...]) -> float:
"""Return the trace of the product of a Hermitian operator with a specific frame operator.
Args:
operator: the input operator to multiply with a frame operator.
frame_op_idx: the label specifying the frame operator to use. The frame operator is
labeled by a tuple of integers (one index per local frame).
Returns:
The trace of the product of the input operator with the specified frame operator.
Raises:
IndexError: when the provided outcome label (tuple of integers) has a number of integers
which does not correspond to the number of local frames making up the product frame.
IndexError: when a local index exceeds the number of operators of the corresponding
local frame.
ValueError: when the output is not a real number.
"""
p_idx = 0.0 + 0.0j
# Second, we iterate over our input operator, ``operator``.
for label, op_coeff in operator.label_iter():
summand = op_coeff
# Third, we iterate over the POVMs stored inside the ProductPOVM.
# - ``j`` is the index of the POVM inside the ``ProductPOVM``. This encodes the axis
# of the high-dimensional array ``p_init`` along which this local POVM is encoded.
# - ``idx`` are the qubit indices on which this local POVM acts.
# - ``povm`` is the actual local POVM object.
for j, (idx, povm) in enumerate(self._frames.items()):
# Extract the local Pauli term on the qubit indices of this local POVM.
sublabel = "".join(label[-(i + 1)] for i in idx)
# Try to obtain the coefficient of the local POVM for this local Pauli term.
try:
local_idx = frame_op_idx[j]
coeff = povm.pauli_operators[local_idx][sublabel]
except KeyError:
# If it does not exist, the current summand becomes 0 because it would be
# multiplied by 0.
summand = 0.0
# In this case we can break the iteration over the remaining local POVMs.
break
except IndexError as exc:
if len(frame_op_idx) <= j:
raise IndexError(
f"The outcome label {frame_op_idx} does not match the expected shape. "
f"It is supposed to contain {len(self._frames)} integers, but has "
f"{len(frame_op_idx)}."
) from exc
if povm.num_operators <= frame_op_idx[j]:
raise IndexError(
f"Outcome index '{frame_op_idx[j]}' is out of range for the local POVM"
f" acting on subsystems {idx}. This POVM has {povm.num_operators}"
" outcomes."
) from exc
raise exc
else:
# If the label does exist, we multiply the coefficient into our summand.
# The factor 2^N_qubit comes from Tr[(P_1...P_N)^2] = 2^N.
summand *= coeff * 2**povm.num_subsystems
# Once we have finished computing our summand, we add it into ``p_init``.
p_idx += summand
if abs(p_idx.imag) > operator.atol:
warnings.warn(f"Expected a real number, instead got {p_idx}.", stacklevel=2)
return float(p_idx.real)
[docs]
@override
def analysis(
self,
hermitian_op: SparsePauliOp | Operator,
frame_op_idx: tuple[int, ...] | set[tuple[int, ...]] | None = None,
) -> float | dict[tuple[int, ...], float] | np.ndarray:
if not isinstance(hermitian_op, SparsePauliOp):
# Convert the provided operator to a Pauli operator.
hermitian_op = SparsePauliOp.from_operator(hermitian_op)
# Assert matching operator and POVM sizes.
if hermitian_op.num_qubits != self.num_subsystems:
raise ValueError(
f"Size of the operator ({hermitian_op.num_qubits}) does not match the size of the "
f"povm ({math.log2(self.dimension)})."
)
# If frame_op_idx is ``None``, it means all outcomes are queried
if frame_op_idx is None:
# Extract the number of outcomes for each local POVM.
# Create the output probability array as a high-dimensional matrix. This matrix will
# have its number of dimensions equal to the number of POVMs stored inside the
# ProductPOVM. The length of each dimension is given by the number of outcomes of the
# POVM encoded along it.
p_init: np.ndarray = np.zeros(self.shape, dtype=float)
# First, we iterate over all the positions of ``p_init``. This corresponds to the
# different probabilities for the different outcomes whose probability we want to
# compute.
# - ``m`` is the multi-dimensional index into the high-dimensional ``p_init`` array.
for m, _ in np.ndenumerate(p_init):
p_init[m] = self._trace_of_prod(hermitian_op, m)
return p_init
if isinstance(frame_op_idx, set):
return {idx: self._trace_of_prod(hermitian_op, idx) for idx in frame_op_idx}
if isinstance(frame_op_idx, tuple):
return self._trace_of_prod(hermitian_op, frame_op_idx)
raise TypeError("Wrong type for ``frame_op_idx``.")