"""Tucker Tensor Implementation."""
# 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
import textwrap
from typing import List, Literal, Optional, Sequence, Tuple, Union
import numpy as np
import scipy
from scipy import sparse
import pyttb as ttb
from pyttb import pyttb_utils as ttb_utils
from pyttb.pyttb_utils import OneDArray, parse_one_d, to_memory_order
ALT_CORE_ERROR = "TTensor doesn't support non-tensor cores yet. Only tensor/sptensor."
[docs]
class ttensor:
"""Class for Tucker tensors (decomposed)."""
__slots__ = ("core", "factor_matrices")
def __init__(
self,
core: Optional[Union[ttb.tensor, ttb.sptensor]] = None,
factors: Optional[Sequence[np.ndarray]] = None,
copy: bool = True,
) -> None:
"""
Construct an ttensor from fully defined core tensor and factor matrices.
Parameters
----------
core:
Core of tucker tensor.
factors:
Factor matrices.
copy:
Whether to make a copy of provided data or just reference it.
Returns
-------
Constructed tucker tensor.
Examples
--------
Import required modules:
>>> import pyttb as ttb
>>> import numpy as np
Set up input data
# Create ttensor with explicit data description
>>> core_values = np.ones((2, 2, 2))
>>> core = ttb.tensor(core_values)
>>> factors = [np.ones((1, 2))] * len(core_values.shape)
>>> K0 = ttb.ttensor(core, factors)
"""
if core is None and factors is None:
# Empty constructor
# TODO explore replacing with typing protocol
self.core: Union[ttb.tensor, ttb.sptensor] = ttb.tensor()
self.factor_matrices: List[np.ndarray] = []
return
if core is None or factors is None:
raise ValueError(
"For non-empty ttensor both core and factors must be provided"
)
if isinstance(core, (ttb.tensor, ttb.sptensor)):
if not all(
isinstance(fm, (np.ndarray, sparse.coo_matrix)) for fm in factors
):
raise ValueError(
"Factor matrices must be numpy arrays or scipy sparse coo_matrices"
f"but received {[type(fm) for fm in factors]}."
)
if copy:
# TODO when generalizing tensor order add order argument to copy
self.core = core.copy()
self.factor_matrices = [
to_memory_order(fm, self.order, copy=True) for fm in factors
]
else:
if self.order != core.order:
# This isn't possible right now
raise ValueError("Core tensor doesn't match Tucker Tensor Order")
if not all(self._matches_order(factor) for factor in factors):
logging.warning(
"Selected no copy, but input factor matrices aren't "
f"{self.order} ordered so must copy."
)
factors = [
to_memory_order(fm, self.order, copy=True) for fm in factors
]
self.core = core
if isinstance(factors, list):
self.factor_matrices = factors
else:
logging.warning(
"Must provide factor matrices as list to avoid copy"
)
self.factor_matrices = list(factors)
else:
# TODO support any tensor type with supported ops
raise ValueError(ALT_CORE_ERROR)
self._validate_ttensor()
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) -> ttensor:
"""Make a deep copy of a :class:`pyttb.ttensor`.
Returns
-------
Copy of original ttensor.
Examples
--------
>>> core_values = np.ones((2, 2, 2))
>>> core = ttb.tensor(core_values)
>>> factors = [np.ones((1, 2))] * len(core_values.shape)
>>> first = ttb.ttensor(core, factors)
>>> second = first
>>> third = second.copy()
>>> first.factor_matrices[0][0, 0] = 2
# Item to convert numpy boolean to python boolena for nicer printing
>>> (first.factor_matrices[0][0, 0] == second.factor_matrices[0][0, 0]).item()
True
>>> (first.factor_matrices[0][0, 0] == third.factor_matrices[0][0, 0]).item()
False
"""
return ttb.ttensor(self.core, self.factor_matrices, copy=True)
[docs]
def __deepcopy__(self, memo):
"""Return deepcopy of class."""
return self.copy()
def _validate_ttensor(self):
"""Verify constructed ttensor."""
# Confirm all factors are matrices
for factor_idx, factor in enumerate(self.factor_matrices):
if not isinstance(factor, (np.ndarray, sparse.coo_matrix)):
raise ValueError(
f"Factor matrices must be numpy arrays but factor {factor_idx} "
f" was {type(factor)}"
)
if len(factor.shape) != 2:
raise ValueError(
f"Factor matrix {factor_idx} has shape {factor.shape} and is not "
f"a matrix!"
)
# Verify size consistency
core_order = len(self.core.shape)
num_matrices = len(self.factor_matrices)
if core_order != num_matrices:
raise ValueError(
f"CORE has order {core_order} but there are {num_matrices} factors"
)
for factor_idx, factor in enumerate(self.factor_matrices):
if factor.shape[-1] != self.core.shape[factor_idx]:
raise ValueError(
f"Factor matrix {factor_idx} does not have "
f"{self.core.shape[factor_idx]} columns"
)
@property
def shape(self) -> Tuple[int, ...]:
"""Shape of the tensor this deconstruction represents."""
return tuple(factor.shape[0] for factor in self.factor_matrices)
[docs]
def __repr__(self): # pragma: no cover
"""Return string representation of a tucker tensor.
Returns
-------
str
Contains the core, and factor matrices as strings on different lines.
"""
display_string = f"Tensor of shape: {self.shape}\n" f"\tCore is a\n"
display_string += textwrap.indent(str(self.core), "\t\t")
display_string += "\n"
for factor_idx, factor in enumerate(self.factor_matrices):
display_string += f"\tU[{factor_idx}] = \n"
display_string += textwrap.indent(str(factor), "\t\t")
display_string += "\n"
return display_string
__str__ = __repr__
[docs]
def to_tensor(self) -> ttb.tensor:
"""Convert to tensor.
Same as :meth:`pyttb.ttensor.full`
"""
return self.full()
[docs]
def full(self) -> ttb.tensor:
"""Convert a ttensor to a (dense) tensor."""
recomposed_tensor = self.core.ttm(self.factor_matrices)
# There is a small chance tensor could be sparse so cast that to dense.
if not isinstance(recomposed_tensor, ttb.tensor):
recomposed_tensor = recomposed_tensor.to_tensor()
return recomposed_tensor
[docs]
def double(self) -> np.ndarray:
"""Convert ttensor to an array of doubles.
Returns
-------
Copy of tensor data.
"""
return self.full().double()
@property
def ndims(self) -> int:
"""
Number of dimensions of a ttensor.
Returns
-------
Number of dimensions of ttensor
"""
return len(self.factor_matrices)
[docs]
def isequal(self, other: ttensor) -> bool:
"""Component equality for ttensors.
Parameters
----------
other:
TTensor to compare against.
Returns
-------
True if ttensors decompositions are identical, false otherwise
"""
if not isinstance(other, ttensor):
return False
if self.ndims != other.ndims:
return False
return self.core.isequal(other.core) and all(
np.array_equal(this_factor, other_factor)
for this_factor, other_factor in zip(
self.factor_matrices, other.factor_matrices
)
)
[docs]
def __pos__(self):
"""
Unary plus (+) for ttensors. Does nothing.
Returns
-------
:class:`pyttb.ttensor`, copy of tensor
"""
return self.copy()
[docs]
def __neg__(self):
"""Unary minus (-) for ttensors.
Returns
-------
:class:`pyttb.ttensor`, copy of tensor
"""
return ttensor(-self.core, self.factor_matrices)
[docs]
def innerprod(
self, other: Union[ttb.tensor, ttb.sptensor, ttb.ktensor, ttb.ttensor]
) -> float:
"""Efficient inner product with a ttensor.
Parameters
----------
other:
Tensor to take an innerproduct with.
Returns
-------
Result of the innerproduct.
"""
if isinstance(other, ttensor):
if self.shape != other.shape:
raise ValueError(
"ttensors must have same shape to perform an innerproduct, "
f" but this ttensor has shape {self.shape} and the other has "
f"{other.shape}"
)
if np.prod(self.core.shape) > np.prod(other.core.shape):
# Reverse arguments so the ttensor with the smaller core comes first.
return other.innerprod(self)
W = []
for this_factor, other_factor in zip(
self.factor_matrices, other.factor_matrices
):
W.append(this_factor.transpose().dot(other_factor))
J = other.core.ttm(W)
return self.core.innerprod(J)
if isinstance(other, (ttb.tensor, ttb.sptensor)):
if self.shape != other.shape:
raise ValueError(
"ttensors must have same shape to perform an innerproduct, but "
f" this ttensor has shape {self.shape} and the other has "
f"{other.shape}"
)
if np.prod(self.shape) < np.prod(self.core.shape):
Z: Union[ttb.tensor, ttb.sptensor] = self.full()
return Z.innerprod(other)
Z = other.ttm(self.factor_matrices, transpose=True)
return Z.innerprod(self.core)
if isinstance(other, ttb.ktensor):
# Call ktensor implementation
return other.innerprod(self)
raise ValueError(
f"Inner product between ttensor and {type(other)} is not supported"
)
[docs]
def __mul__(self, other):
"""Element wise multiplication (*) for ttensors (only scalars supported).
Parameters
----------
other: float, int
Returns
-------
:class:`pyttb.ttensor`
"""
if isinstance(other, (float, int, np.number)):
return ttensor(self.core * other, self.factor_matrices)
raise ValueError(
"This object cannot be multiplied by ttensor. Convert to full if trying to "
"multiply ttensor by another tensor."
)
[docs]
def __rmul__(self, other):
"""Element wise right multiplication (*) for ttensors (only scalars supported).
Parameters
----------
other: float, int
Returns
-------
:class:`pyttb.ttensor`
"""
if isinstance(other, (float, int, np.number)):
return self.__mul__(other)
raise ValueError("This object cannot be multiplied by ttensor")
[docs]
def ttv(
self,
vector: Union[Sequence[np.ndarray], np.ndarray],
dims: Optional[OneDArray] = None,
exclude_dims: Optional[OneDArray] = None,
) -> Union[float, ttensor]:
"""TTensor times vector.
Parameters
----------
vector:
Vector to multiply by.
dims:
Dimensions to multiply in.
exclude_dims:
Alternative multiply by all dimensions but these.
"""
# Check that vector is a list of vectors,
# if not place single vector as element in list
if (
len(vector) > 0
and isinstance(vector, np.ndarray)
and isinstance(vector[0], (int, float, np.int_, np.float64))
):
return self.ttv([vector], dims, exclude_dims)
# Get sorted dims and index for multiplicands
dims, vidx = ttb_utils.tt_dimscheck(self.ndims, len(vector), dims, exclude_dims)
# Check that each multiplicand is the right size.
for i in range(dims.size):
if vector[vidx[i]].shape != (self.shape[dims[i]],):
raise ValueError("Multiplicand is wrong size")
# Get remaining dimensions when we're done
remdims = np.setdiff1d(np.arange(0, self.ndims), dims)
# Create W to multiply with core, only populated remaining dims
W = [np.empty(())] * self.ndims
for i in range(dims.size):
dim_idx = dims[i]
W[dim_idx] = self.factor_matrices[dim_idx].transpose().dot(vector[vidx[i]])
# Create new core
newcore = self.core.ttv(W, dims)
# Create final result
if remdims.size == 0:
assert not isinstance(newcore, (ttb.tensor, ttb.sptensor))
return float(newcore)
assert not isinstance(newcore, float)
return ttensor(newcore, [self.factor_matrices[dim] for dim in remdims])
[docs]
def mttkrp(
self, U: Union[ttb.ktensor, Sequence[np.ndarray]], n: Union[int, np.integer]
) -> np.ndarray:
"""
Matricized tensor times Khatri-Rao product for ttensors.
Parameters
----------
U:
Array of matrices or ktensor
n:
Multiplies by all modes except n
Returns
-------
:class:`numpy.ndarray`
"""
# NOTE: MATLAB version calculates an unused R here
W = [np.empty((), order=self.order)] * self.ndims
if isinstance(U, ttb.ktensor):
U = U.factor_matrices
for i in range(0, self.ndims):
if i == n:
continue
W[i] = self.factor_matrices[i].transpose().dot(U[i])
Y = self.core.mttkrp(W, n)
# Find each column of answer by multiplying by weights
return to_memory_order(self.factor_matrices[n].dot(Y), self.order)
[docs]
def norm(self) -> float:
"""
Compute the norm of a ttensor.
Returns
-------
Frobenius norm of Tensor.
"""
if np.prod(self.shape) > np.prod(self.core.shape):
V = []
for factor in self.factor_matrices:
V.append(factor.transpose().dot(factor))
Y = self.core.ttm(V)
tmp = Y.innerprod(self.core)
return np.sqrt(tmp)
return self.full().norm()
[docs]
def permute(self, order: OneDArray) -> ttensor:
"""
Permute :class:`pyttb.ttensor` dimensions.
Rearranges the dimensions of a :class:`pyttb.ttensor` so that they are
in the order specified by `order`. The corresponding ttensor has the
same components as `self` but the order of the subscripts needed to
access any particular element is rearranged as specified by `order`.
Parameters
----------
order:
Permutation of [0,...,self.ndims].
Returns
-------
Permuted :class:`pyttb.ttensor`.
"""
order = parse_one_d(order)
if not np.array_equal(np.arange(0, self.ndims), np.sort(order)):
raise ValueError("Invalid permutation")
new_core = self.core.permute(order)
new_u = [self.factor_matrices[idx] for idx in order]
return ttensor(new_core, new_u)
[docs]
def ttm(
self,
matrix: Union[np.ndarray, Sequence[np.ndarray]],
dims: Optional[Union[float, np.ndarray]] = None,
exclude_dims: Optional[Union[int, np.ndarray]] = None,
transpose: bool = False,
) -> ttensor:
"""Tensor times matrix for ttensor.
Parameters
----------
matrix:
Matrix or matrices to multiple by
dims:
Dimensions to multiply against
exclude_dims:
Use all dimensions but these
transpose:
Transpose matrices during multiplication
"""
if dims is None and exclude_dims is None:
dims = np.arange(self.ndims)
elif isinstance(dims, list):
dims = np.array(dims, order=self.order)
elif isinstance(dims, (float, int, np.generic)):
dims = np.array([dims], order=self.order)
if isinstance(exclude_dims, (float, int)):
exclude_dims = np.array([exclude_dims], order=self.order)
if not isinstance(matrix, Sequence):
return self.ttm([matrix], dims, exclude_dims, transpose)
# Check that the dimensions are valid
dims, vidx = ttb_utils.tt_dimscheck(self.ndims, len(matrix), dims, exclude_dims)
# Determine correct size index
size_idx = int(not transpose)
# Check that each multiplicand is the right size.
for i, dim in enumerate(dims):
if matrix[vidx[i]].shape[size_idx] != self.shape[dim]:
raise ValueError(f"Multiplicand {i} is wrong size")
# Do the actual multiplications in the specified modes.
new_u = self.factor_matrices.copy()
for i, dim in enumerate(dims):
if transpose:
new_u[dim] = matrix[vidx[i]].transpose().dot(new_u[dim])
else:
new_u[dim] = matrix[vidx[i]].dot(new_u[dim])
return ttensor(self.core, new_u)
[docs]
def reconstruct( # noqa: PLR0912
self,
samples: Optional[Union[np.ndarray, Sequence[np.ndarray]]] = None,
modes: Optional[Union[np.ndarray, Sequence[np.ndarray]]] = None,
) -> ttb.tensor:
"""
Reconstruct or partially reconstruct tensor from ttensor.
Parameters
----------
samples:
modes:
Returns
-------
:class:`pyttb.ttensor`
"""
# Default to sampling full tensor
full_tensor_sampling = samples is None and modes is None
if full_tensor_sampling:
return self.full()
if modes is not None and samples is None:
raise ValueError(
"Samples can be provided without modes, but samples must be provided "
"with modes."
)
assert samples is not None
if modes is None:
modes = np.arange(self.ndims)
elif isinstance(modes, Sequence):
modes = np.array(modes, order=self.order)
elif np.isscalar(modes):
modes = np.array([modes], order=self.order)
if np.isscalar(samples):
samples = [np.array([samples], order=self.order)]
elif not isinstance(samples, Sequence):
samples = [samples]
unequal_lengths = len(samples) > 0 and len(samples) != len(modes)
if unequal_lengths:
raise ValueError(
"If samples and modes provided lengths must be equal, but "
f"samples had length {len(samples)} and modes {len(modes)}"
)
full_samples = [np.array([], order=self.order)] * self.ndims
for sample, mode in zip(samples, modes):
if np.isscalar(sample):
full_samples[mode] = np.array([sample], order=self.order)
else:
full_samples[mode] = sample
shape = self.shape
new_u = []
for k in range(self.ndims):
if len(full_samples[k]) == 0:
# Skip empty samples
new_u.append(self.factor_matrices[k])
continue
if (
len(full_samples[k].shape) == 2
and full_samples[k].shape[-1] == shape[k]
):
new_u.append(full_samples[k].dot(self.factor_matrices[k]))
else:
new_u.append(self.factor_matrices[k][full_samples[k], :])
return ttensor(self.core, new_u).full()
[docs]
def nvecs( # noqa: PLR0912
self, n: int, r: int, flipsign: bool = True
) -> np.ndarray:
"""
Compute the leading mode-n vectors for a ttensor.
Parameters
----------
n:
Mode for tensor matricization
r:
Number of eigenvalues
flipsign:
Make each column's largest element positive if true
Returns
-------
Computed eigenvectors.
"""
# Compute inner product of all n-1 factors
V = []
for factor_idx, factor in enumerate(self.factor_matrices):
if factor_idx == n:
V.append(factor)
else:
V.append(factor.transpose().dot(factor))
H = self.core.ttm(V)
if isinstance(H, ttb.sptensor):
HnT = H.to_sptenmat(
np.array([n], order=self.order), cdims_cyclic="t"
).double()
else:
HnT = H.full().to_tenmat(cdims=np.array([n], order=self.order)).double()
G = self.core
if isinstance(G, ttb.sptensor):
GnT = G.to_sptenmat(
np.array([n], order=self.order), cdims_cyclic="t"
).double()
else:
GnT = G.full().to_tenmat(cdims=np.array([n], order=self.order)).double()
# Compute Xn * Xn'
# Big hack because if RHS is sparse wrong dot product is used
if sparse.issparse(self.factor_matrices[n]):
XnT = sparse.coo_matrix.dot(GnT, self.factor_matrices[n].transpose())
else:
XnT = GnT.dot(self.factor_matrices[n].transpose())
if sparse.issparse(XnT):
Y = sparse.coo_matrix.dot(HnT.transpose(), XnT)
else:
Y = HnT.transpose().dot(XnT)
# TODO: Lifted from tensor, consider common location
if r < Y.shape[0] - 1:
w, v = scipy.sparse.linalg.eigsh(Y, r)
v = v[:, (-np.abs(w)).argsort()]
v = v[:, :r]
else:
logging.debug(
"Greater than or equal to tensor.shape[n] - 1 eigenvectors requires "
"cast to dense to solve"
)
if sparse.issparse(Y):
Y = Y.toarray()
w, v = scipy.linalg.eigh(Y)
v = v[:, (-np.abs(w)).argsort()]
v = v[:, :r]
if flipsign:
idx = np.argmax(np.abs(v), axis=0)
for i in range(v.shape[1]):
if v[idx[i], i] < 0:
v[:, i] *= -1
return v
if __name__ == "__main__":
import doctest # pragma: no cover
doctest.testmod() # pragma: no cover