"""Classes and functions for working with matricized dense tensors."""
# Copyright 2024 National Technology & Engineering Solutions of Sandia,
# LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the
# U.S. Government retains certain rights in this software.
from __future__ import annotations
import logging
from math import prod
from typing import Literal, Optional, Tuple, Union
import numpy as np
import pyttb as ttb
from pyttb.pyttb_utils import (
Shape,
gather_wrap_dims,
np_to_python,
parse_shape,
to_memory_order,
)
[docs]
class tenmat:
"""Store tensor as a matrix."""
__slots__ = ("tshape", "rindices", "cindices", "data")
def __init__( # noqa: PLR0912
self,
data: Optional[np.ndarray] = None,
rdims: Optional[np.ndarray] = None,
cdims: Optional[np.ndarray] = None,
tshape: Optional[Shape] = None,
copy: bool = True,
):
"""Construct a :class:`pyttb.tenmat` from explicit components.
If you already have a tensor see :meth:`pyttb.tensor.to_tenmat`.
Parameters
----------
data:
Flattened tensor data.
rdims:
Which dimensions of original tensor map to rows.
cdims:
Which dimensions of original tensor map to columns.
tshape:
Original tensor shape.
copy:
Whether to make a copy of provided data or just reference it.
Examples
--------
Create an empty :class:`pyttb.tenmat`.
>>> ttb.tenmat() # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape () with order F
rindices = [ ] (modes of tensor corresponding to rows)
cindices = [ ] (modes of tensor corresponding to columns)
data = []
Create tensor shaped data.
>>> tshape = (2, 2, 2)
>>> data = np.reshape(np.arange(prod(tshape), dtype=np.double), tshape)
>>> data # doctest: +NORMALIZE_WHITESPACE
array([[[0., 1.],
[2., 3.]],
[[4., 5.],
[6., 7.]]])
Manually matrize the tensor.
>>> flat_data = np.reshape(data, (2, 4), order="F")
>>> flat_data # doctest: +NORMALIZE_WHITESPACE
array([[0., 2., 1., 3.],
[4., 6., 5., 7.]])
Encode matrication into :class:`pyttb.tenmat`.
>>> tm = ttb.tenmat(flat_data, rdims=np.array([0]), tshape=tshape)
Extract original tensor shaped data.
>>> tm.to_tensor().double() # doctest: +NORMALIZE_WHITESPACE
array([[[0., 1.],
[2., 3.]],
[[4., 5.],
[6., 7.]]])
"""
# Case 0a: Empty Constructor
# data is empty, return empty tenmat unless rdims, cdims, or tshape are
# not empty
if data is None or (isinstance(data, np.ndarray) and data.size == 0):
cdims_empty = cdims is None or cdims.size == 0
rdims_empty = rdims is None or rdims.size == 0
tshape_empty = tshape is None or tshape == ()
assert (
rdims_empty and cdims_empty and tshape_empty
), "When data is empty, rdims, cdims, and tshape must also be empty."
self.tshape: Union[Tuple[()], Tuple[int, ...]] = ()
self.rindices = np.array([])
self.cindices = np.array([])
self.data = np.array([], ndmin=2, order=self.order)
return
# Verify that data is a numeric numpy.ndarray
assert isinstance(data, np.ndarray) and issubclass(
data.dtype.type, np.number
), "First argument must be a numeric numpy.ndarray."
# data is 1d array, must convert to 2d array for tenmat
if len(data.shape) == 1:
if tshape is None:
assert False, "tshape must be specified when data is 1d array."
else:
# make data a 2d array with shape (1, data.shape[0]), i.e., a row vector
data = np.reshape(data.copy(), (1, data.shape[0]), order=self.order)
if len(data.shape) != 2:
raise ValueError(
f"Data must be a matrix or vector but had {len(data.shape)} dimensions"
)
# use data.shape for tshape if not provided
if tshape is None:
tshape = data.shape
tshape = parse_shape(tshape)
# check that data.shape and tshape agree
if prod(data.shape) != prod(tshape):
assert False, (
"Incorrect dimensions specified: products of data.shape and tuple do "
"not match"
)
n = len(tshape)
alldims = np.array([range(n)])
rdims, cdims = gather_wrap_dims(n, rdims, cdims)
# check that data.shape and product of dimensions agree
if not np.prod(np.array(tshape)[rdims]) * np.prod(
np.array(tshape)[cdims]
) == prod(data.shape):
assert (
False
), "data.shape does not match shape specified by rdims, cdims, and tshape."
# if rdims or cdims is empty, hstack will output an array of float not int
if rdims.size == 0:
dims = cdims.copy()
elif cdims.size == 0:
dims = rdims.copy()
else:
dims = np.hstack([rdims, cdims])
if not len(dims) == n or not (alldims == np.sort(dims)).all():
assert False, (
"Incorrect specification of dimensions, the sorted concatenation "
"of rdims and cdims must be range(source.ndims)."
)
self.tshape = tshape
self.rindices = rdims.copy()
self.cindices = cdims.copy()
if not copy and not self._matches_order(data):
logging.warning(
f"Selected no copy, but input data isn't {self.order} ordered "
"so must copy."
)
copy = True
self.data = to_memory_order(data, self.order, copy=copy)
return
@property
def order(self) -> Literal["F"]:
"""Return the data layout of the underlying storage."""
return "F"
def _matches_order(self, array: np.ndarray) -> bool:
"""Check if provided array matches tensor memory layout."""
if array.flags["C_CONTIGUOUS"] and self.order == "C":
return True
if array.flags["F_CONTIGUOUS"] and self.order == "F":
return True
return False
[docs]
def copy(self) -> tenmat:
"""
Return a deep copy of the :class:`pyttb.tenmat`.
Examples
--------
Create a :class:`pyttb.tenmat` (TM1) and make a deep copy. Verify
the deep copy (TM3) is not just a reference (like TM2) to the original.
>>> T1 = ttb.tensor(np.ones((3, 2)))
>>> TM1 = T1.to_tenmat(np.array([0]))
>>> TM2 = TM1
>>> TM3 = TM1.copy()
>>> TM1[0, 0] = 3
# Item to convert numpy boolean to python boolena for nicer printing
>>> (TM1[0, 0] == TM2[0, 0]).item()
True
>>> (TM1[0, 0] == TM3[0, 0]).item()
False
"""
# Create tenmat
return ttb.tenmat(
self.data, self.rindices, self.cindices, self.tshape, copy=True
)
[docs]
def __deepcopy__(self, memo):
"""Return deep copy of this tenmat."""
return self.copy()
[docs]
def to_tensor(self, copy: bool = True) -> ttb.tensor:
"""
Return :class:`pyttb.tenmat` data as a :class:`pyttb.tensor`.
Parameters
----------
copy:
Whether to make a copy of provided data or just reference it.
Examples
--------
Create tensor shaped data.
>>> tshape = (2, 2, 2)
>>> data = np.reshape(np.arange(np.prod(tshape), dtype=np.double), tshape)
>>> data # doctest: +NORMALIZE_WHITESPACE
array([[[0., 1.],
[2., 3.]],
[[4., 5.],
[6., 7.]]])
Manually matrize the tensor.
>>> flat_data = np.reshape(data, (2, 4), order="F")
>>> flat_data # doctest: +NORMALIZE_WHITESPACE
array([[0., 2., 1., 3.],
[4., 6., 5., 7.]])
Encode matrication into :class:`pyttb.tenmat`.
>>> tm = ttb.tenmat(flat_data, rdims=np.array([0]), tshape=tshape)
Extract original tensor shaped data.
>>> tm.to_tensor() # doctest: +NORMALIZE_WHITESPACE
tensor of shape (2, 2, 2) with order F
data[:, :, 0] =
[[0. 2.]
[4. 6.]]
data[:, :, 1] =
[[1. 3.]
[5. 7.]]
"""
# RESHAPE TENSOR-AS-MATRIX
# Here we just reverse what was done in the tenmat constructor.
# First we reshape the data to be an MDA, then we un-permute
# it using ipermute.
shape = self.tshape
order = np.hstack([self.rindices, self.cindices])
data = self.data
if copy:
data = self.data.copy()
data = np.reshape(data, np.array(shape)[order], order=self.order)
if order.size > 1:
if not copy:
logging.warning(
"This tenmat cannot be trivially unwrapped into tensor "
"so must copy."
)
data = to_memory_order(np.transpose(data, np.argsort(order)), self.order)
return ttb.tensor(data, shape, copy=False)
[docs]
def ctranspose(self) -> tenmat:
"""
Complex conjugate transpose for :class:`pyttb.tenmat`.
Examples
--------
Create :class:`pyttb.tensor` then convert to :class:`pyttb.tenmat`.
>>> T = ttb.tenones((2, 2, 2))
>>> TM = T.to_tenmat(rdims=np.array([0]))
>>> TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1, 2 ] (modes of tensor corresponding to columns)
data[:, :] =
[[1. 1. 1. 1.]
[1. 1. 1. 1.]]
>>> TM.ctranspose() # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2, 2) with order F
rindices = [ 1, 2 ] (modes of tensor corresponding to rows)
cindices = [ 0 ] (modes of tensor corresponding to columns)
data[:, :] =
[[1. 1.]
[1. 1.]
[1. 1.]
[1. 1.]]
"""
return tenmat(
self.data.conj().T,
self.cindices,
self.rindices,
self.tshape,
copy=True,
)
[docs]
def double(self) -> np.ndarray:
"""
Convert a :class:`pyttb.tenmat` to an array of doubles.
Examples
--------
>>> T = ttb.tenones((2, 2, 2))
>>> TM = T.to_tenmat(rdims=np.array([0]))
>>> TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1, 2 ] (modes of tensor corresponding to columns)
data[:, :] =
[[1. 1. 1. 1.]
[1. 1. 1. 1.]]
>>> TM.double() # doctest: +NORMALIZE_WHITESPACE
array([[1., 1., 1., 1.],
[1., 1., 1., 1.]])
Returns
-------
Copy of tenmat data.
"""
return to_memory_order(self.data, self.order, copy=True).astype(np.float64)
@property
def ndims(self) -> int:
"""Return the number of dimensions of a :class:`pyttb.tenmat`.
Examples
--------
>>> TM = ttb.tenmat() # empty tenmat
>>> TM.ndims
0
>>> TM = ttb.tenones((2, 2, 2)).to_tenmat(np.array([0]))
>>> TM.ndims
2
"""
return len(self.shape)
[docs]
def norm(self) -> float:
"""Frobenius norm of a :class:`pyttb.tenmat`.
Examples
--------
>>> T = ttb.tenones((2, 2, 2))
>>> TM = T.to_tenmat(rdims=np.array([0]))
>>> TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1, 2 ] (modes of tensor corresponding to columns)
data[:, :] =
[[1. 1. 1. 1.]
[1. 1. 1. 1.]]
>>> TM.norm() # doctest: +ELLIPSIS
2.82...
"""
# default of np.linalg.norm is to vectorize the data and compute the vector
# norm, which is equivalent to the Frobenius norm for multidimensional arrays.
# However, the argument 'fro' only works for 1-D and 2-D
# arrays currently.
return float(np.linalg.norm(self.data))
@property
def shape(self) -> Tuple[int, ...]:
"""Return the shape of a :class:`pyttb.tenmat`.
Examples
--------
>>> TM = ttb.tenmat() # empty tenmat
>>> TM.shape
()
>>> TM = ttb.tenones((2, 2, 2)).to_tenmat(np.array([0]))
>>> TM.shape
(2, 4)
"""
if self.data.size == 0:
return ()
return self.data.shape
[docs]
def isequal(self, other: tenmat) -> bool:
"""
Exact equality for :class:`pyttb.tenmat`.
Examples
--------
>>> TM1 = ttb.tenmat() # empty tenmat
>>> TM2 = ttb.tenones((2, 2, 2)).to_tenmat(np.array([0]))
>>> TM1.isequal(TM2)
False
>>> TM1.isequal(TM1)
True
"""
if not isinstance(other, ttb.tenmat):
raise ValueError(
f"Can only compares against other tenmat but received: {type(other)}"
)
return (
np.array_equal(self.data, other.data)
and self.tshape == other.tshape
and np.array_equal(self.rindices, other.rindices)
and np.array_equal(self.cindices, other.cindices)
)
[docs]
def __setitem__(self, key, value):
"""
Subscripted assignment for a :class:`pyttb.tenmat`.
Examples
--------
>>> TM = ttb.tenones((2, 2, 2)).to_tenmat(np.array([0]))
>>> TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1, 2 ] (modes of tensor corresponding to columns)
data[:, :] =
[[1. 1. 1. 1.]
[1. 1. 1. 1.]]
>>> TM[0, 0] = 2.0
>>> TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1, 2 ] (modes of tensor corresponding to columns)
data[:, :] =
[[2. 1. 1. 1.]
[1. 1. 1. 1.]]
"""
self.data[key] = value
[docs]
def __getitem__(self, item):
"""
Subscripted reference for :class:`pyttb.tenmat`.
Examples
--------
>>> TM = ttb.tenones((2, 2, 2)).to_tenmat(np.array([0]))
>>> print(TM[0, 0])
1.0
Returns
-------
:class:`numpy.ndarray`, float, int
"""
return self.data[item]
[docs]
def __mul__(self, other):
"""
Multiplies two :class:`pyttb.tenmat` objects.
Parameters
----------
other: :class:`pyttb.tenmat`
Examples
--------
>>> TM = ttb.tenones((2, 2)).to_tenmat(np.array([0]))
>>> TM * TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1 ] (modes of tensor corresponding to columns)
data[:, :] =
[[2. 2.]
[2. 2.]]
Returns
-------
:class:`pyttb.tenmat`
"""
# One argument is a scalar
if np.isscalar(other):
Z = self.copy()
Z.data = Z.data * other
return Z
if isinstance(other, tenmat):
# Check that data shapes are compatible
if not self.shape[1] == other.shape[0]:
assert False, (
"tenmat shape mismatch: number or columns of left operand must "
"match number of rows of right operand."
)
tshape = tuple(
np.hstack(
(
np.array(self.tshape)[self.rindices],
np.array(other.tshape)[other.cindices],
)
)
)
if not tshape:
return (self.data @ other.data)[0, 0]
tenmatInstance = tenmat(
self.data @ other.data,
np.arange(len(self.rindices)),
np.arange(len(other.cindices)) + len(self.rindices),
tshape,
copy=False,
)
return tenmatInstance
assert False, "tenmat multiplication only valid with scalar or tenmat objects."
[docs]
def __rmul__(self, other):
"""
Multiplies two :class:`pyttb.tenmat` objects.
Parameters
----------
other: :class:`pyttb.tenmat`
Examples
--------
>>> TM = ttb.tenones((2, 2)).to_tenmat(np.array([0]))
>>> TM * TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1 ] (modes of tensor corresponding to columns)
data[:, :] =
[[2. 2.]
[2. 2.]]
Returns
-------
:class:`pyttb.tenmat`
"""
return self.__mul__(other)
[docs]
def __add__(self, other):
"""
Binary addition (+) for :class:`pyttb.tenmat`.
Parameters
----------
other: :class:`pyttb.tenmat`, float, int
Examples
--------
>>> TM = ttb.tenones((2, 2)).to_tenmat(np.array([0]))
>>> TM + TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1 ] (modes of tensor corresponding to columns)
data[:, :] =
[[2. 2.]
[2. 2.]]
>>> TM + 1.0 # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1 ] (modes of tensor corresponding to columns)
data[:, :] =
[[2. 2.]
[2. 2.]]
Returns
-------
:class:`pyttb.tenmat`
"""
# One argument is a scalar
if np.isscalar(other):
Z = self.copy()
Z.data = Z.data + other
return Z
if isinstance(other, tenmat):
# Check that data shapes agree
if not self.shape == other.shape:
assert False, "tenmat shape mismatch."
Z = self.copy()
Z.data = Z.data + other.data
return Z
assert False, "tenmat addition only valid with scalar or tenmat objects."
[docs]
def __radd__(self, other):
"""
Right binary addition (+) for :class:`pyttb.tenmat`.
Parameters
----------
other: :class:`pyttb.tenmat`, float, int
Examples
--------
>>> TM = ttb.tenones((2, 2)).to_tenmat(np.array([0]))
>>> 1.0 + TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1 ] (modes of tensor corresponding to columns)
data[:, :] =
[[2. 2.]
[2. 2.]]
Returns
-------
:class:`pyttb.tenmat`
"""
return self.__add__(other)
[docs]
def __sub__(self, other):
"""
Binary subtraction (-) for :class:`pyttb.tenmat`.
Parameters
----------
other: :class:`pyttb.tenmat`, float, int
Examples
--------
>>> TM = ttb.tenones((2, 2)).to_tenmat(np.array([0]))
>>> TM - TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1 ] (modes of tensor corresponding to columns)
data[:, :] =
[[0. 0.]
[0. 0.]]
>>> TM - 1.0 # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1 ] (modes of tensor corresponding to columns)
data[:, :] =
[[0. 0.]
[0. 0.]]
Returns
-------
:class:`pyttb.tenmat`
"""
# One argument is a scalar
if np.isscalar(other):
Z = self.copy()
Z.data = Z.data - other
return Z
if isinstance(other, tenmat):
# Check that data shapes agree
if not self.shape == other.shape:
assert False, "tenmat shape mismatch."
Z = self.copy()
Z.data = Z.data - other.data
return Z
assert False, "tenmat subtraction only valid with scalar or tenmat objects."
[docs]
def __rsub__(self, other):
"""
Right binary subtraction (-) for :class:`pyttb.tenmat`.
Parameters
----------
other: :class:`pyttb.tenmat`, float, int
Examples
--------
>>> TM = ttb.tenones((2, 2)).to_tenmat(np.array([0]))
>>> 1.0 - TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1 ] (modes of tensor corresponding to columns)
data[:, :] =
[[0. 0.]
[0. 0.]]
Returns
-------
:class:`pyttb.tenmat`
"""
# One argument is a scalar
if np.isscalar(other):
Z = self.copy()
Z.data = other - Z.data
return Z
if isinstance(other, tenmat):
# Check that data shapes agree
if not self.shape == other.shape:
assert False, "tenmat shape mismatch."
Z = self.copy()
Z.data = other.data - Z.data
return Z
assert False, "tenmat subtraction only valid with scalar or tenmat objects."
[docs]
def __pos__(self):
"""
Unary plus (+) for :class:`pyttb.tenmat`.
Examples
--------
>>> TM = ttb.tenones((2, 2)).to_tenmat(np.array([0]))
>>> +TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1 ] (modes of tensor corresponding to columns)
data[:, :] =
[[1. 1.]
[1. 1.]]
Returns
-------
:class:`pyttb.tenmat`
copy of tenmat
"""
T = self.copy()
return T
[docs]
def __neg__(self):
"""
Unary minus (-) for :class:`pyttb.tenmat`.
Examples
--------
>>> TM = ttb.tenones((2, 2)).to_tenmat(np.array([0]))
>>> -TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1 ] (modes of tensor corresponding to columns)
data[:, :] =
[[-1. -1.]
[-1. -1.]]
Returns
-------
:class:`pyttb.tenmat`
Copy of original tenmat with negated data.
"""
T = self.copy()
T.data = -1 * T.data
return T
[docs]
def __repr__(self):
"""Return string representation of a :class:`pyttb.tenmat`.
Examples
--------
Print an empty :class:`pyttb.tenmat`.
>>> ttb.tenmat() # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape () with order F
rindices = [ ] (modes of tensor corresponding to rows)
cindices = [ ] (modes of tensor corresponding to columns)
data = []
Print a non-empty :class:`pyttb.tenmat`.
>>> TM = ttb.tenones((2, 2)).to_tenmat(np.array([0]))
>>> TM # doctest: +NORMALIZE_WHITESPACE
matrix corresponding to a tensor of shape (2, 2) with order F
rindices = [ 0 ] (modes of tensor corresponding to rows)
cindices = [ 1 ] (modes of tensor corresponding to columns)
data[:, :] =
[[1. 1.]
[1. 1.]]
Returns
-------
str
Contains the shape, row indices (rindices), column indices (cindices) and
data as strings on different lines.
"""
s = ""
s += "matrix corresponding to a tensor of shape "
s += str(np_to_python(self.tshape))
s += f" with order {self.order}"
s += "\n"
s += "rindices = "
s += "[ " + (", ").join([str(int(d)) for d in self.rindices]) + " ] "
s += "(modes of tensor corresponding to rows)\n"
s += "cindices = "
s += "[ " + (", ").join([str(int(d)) for d in self.cindices]) + " ] "
s += "(modes of tensor corresponding to columns)\n"
if self.data.size == 0:
s += "data = []\n"
else:
s += "data[:, :] = \n"
s += str(self.data)
s += "\n"
return s
__str__ = __repr__
if __name__ == "__main__":
import doctest # pragma: no cover
doctest.testmod() # pragma: no cover