diff --git a/conftest.py b/conftest.py index 257cc0c7..0811cdd9 100644 --- a/conftest.py +++ b/conftest.py @@ -1,3 +1,4 @@ +"""Pyttb pytest configuration.""" # 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. @@ -11,12 +12,12 @@ @pytest.fixture(autouse=True) -def add_packages(doctest_namespace): +def add_packages(doctest_namespace): # noqa: D103 doctest_namespace["np"] = numpy doctest_namespace["ttb"] = pyttb -def pytest_addoption(parser): +def pytest_addoption(parser): # noqa: D103 parser.addoption( "--packaging", action="store_true", @@ -26,6 +27,6 @@ def pytest_addoption(parser): ) -def pytest_configure(config): +def pytest_configure(config): # noqa: D103 if not config.option.packaging: config.option.markexpr = "not packaging" diff --git a/docs/source/matlab/common.rst b/docs/source/matlab/common.rst index 0caad94d..1b48f091 100644 --- a/docs/source/matlab/common.rst +++ b/docs/source/matlab/common.rst @@ -56,7 +56,7 @@ Methods +-----------------+----------------------+------------------------------------------------------------------------+ | ``subsref`` | ``__getitem__`` | ``X[index]`` | +-----------------+----------------------+------------------------------------------------------------------------+ -| ``tenfun`` | ``tt_tenfun`` | e.g., ``pyttb.tt_tenfun(lambda x: x + 1, A)`` | +| ``tenfun`` | ``tenfun`` | ``X.tenfun(lambda x: x + 1)`` | +-----------------+----------------------+------------------------------------------------------------------------+ | ``times`` | ``__mul__`` | ``X * Y`` | +-----------------+----------------------+------------------------------------------------------------------------+ diff --git a/docs/source/tutorial/algorithm_gcp_opt.ipynb b/docs/source/tutorial/algorithm_gcp_opt.ipynb index a372fbd2..ee5b5d04 100644 --- a/docs/source/tutorial/algorithm_gcp_opt.ipynb +++ b/docs/source/tutorial/algorithm_gcp_opt.ipynb @@ -158,7 +158,6 @@ "import sys\n", "import pyttb as ttb\n", "import numpy as np\n", - "from pyttb.pyttb_utils import tt_tenfun\n", "\n", "from pyttb.gcp.fg_setup import function_type, setup\n", "from pyttb.gcp.handles import Objectives\n", diff --git a/docs/source/tutorial/class_tensor.ipynb b/docs/source/tutorial/class_tensor.ipynb index 99c003b2..bc1b1fb6 100644 --- a/docs/source/tutorial/class_tensor.ipynb +++ b/docs/source/tutorial/class_tensor.ipynb @@ -27,8 +27,7 @@ "source": [ "import pyttb as ttb\n", "import numpy as np\n", - "import sys\n", - "from pyttb.pyttb_utils import tt_tenfun" + "import sys" ] }, { @@ -847,8 +846,8 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Using `tt_tenfun` for elementwise operations on one or more `tensor`s\n", - "The function `tt_tenfun` applies a specified function to a number of `tensor`s. This can be used for any function that is not predefined for `tensor`s." + "## Using `tenfun` for elementwise operations on one or more `tensor`s\n", + "The method `tenfun` applies a specified function to a number of `tensor`s. This can be used for any function that is not predefined for `tensor`s." ] }, { @@ -859,7 +858,7 @@ "source": [ "np.random.seed(0)\n", "A = ttb.tensor(np.floor(3 * np.random.rand(2, 2, 3))) # Generate some data.\n", - "tt_tenfun(lambda x: x + 1, A) # Increment every element of A by one." + "A.tenfun(lambda x: x + 1) # Increment every element of A by one." ] }, { @@ -873,7 +872,7 @@ " return np.maximum(a, b)\n", "\n", "\n", - "tt_tenfun(max_elements, A, B) # Max of A and B, elementwise." + "A.tenfun(max_elements, B) # Max of A and B, elementwise." ] }, { @@ -891,7 +890,7 @@ " return np.floor(np.mean(X, axis=0))\n", "\n", "\n", - "tt_tenfun(elementwise_mean, A, B, C) # Elementwise means for A, B, and C." + "A.tenfun(elementwise_mean, B, C) # Elementwise means for A, B, and C." ] }, { diff --git a/pyproject.toml b/pyproject.toml index 590c0a9a..bf5eb0b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ requires = ["setuptools>=61.0", "numpy", "numpy_groupies", "scipy", "wheel"] build-backend = "setuptools.build_meta" [tool.ruff.lint] -select = ["E", "F", "PL", "W", "I", "N", "NPY", "RUF", "B"] +select = ["E", "F", "PL", "W", "I", "N", "NPY", "RUF", "B", "D"] ignore = [ # Ignored in conversion to ruff since not previously enforced "PLR2004", @@ -84,15 +84,18 @@ ignore = [ # There is ongoing discussion about logging/warning etc "B028", ] +[tool.ruff.lint.pydocstyle] +convention = "numpy" + [tool.ruff.lint.per-file-ignores] # See see https://github.com/astral-sh/ruff/issues/3172 for details on this becoming simpler # Everything but I, F (to catch import mess and potential logic errors) -"tests/**.py" = ["E", "PL", "W", "N", "NPY", "RUF", "B"] +"tests/**.py" = ["E", "PL", "W", "N", "NPY", "RUF", "B", "D"] # Ignore everything for now -"docs/**.py" = ["E", "F", "PL", "W", "I", "N", "NPY", "RUF", "B"] -"docs/**.ipynb" = ["E", "F", "PL", "W", "I", "N", "NPY", "RUF", "B"] -"profiling/**.ipynb" = ["E", "F", "PL", "W", "I", "N", "NPY", "RUF", "B"] +"docs/**.py" = ["E", "F", "PL", "W", "I", "N", "NPY", "RUF", "B", "D"] +"docs/**.ipynb" = ["E", "F", "PL", "W", "I", "N", "NPY", "RUF", "B", "D"] +"profiling/**.ipynb" = ["E", "F", "PL", "W", "I", "N", "NPY", "RUF", "B", "D"] [tool.ruff.format] docstring-code-format = true diff --git a/pyttb/__init__.py b/pyttb/__init__.py index 2a726d6e..6df27a82 100644 --- a/pyttb/__init__.py +++ b/pyttb/__init__.py @@ -1,4 +1,4 @@ -"""pyttb: Python Tensor Toolbox""" +"""pyttb: Python Tensor Toolbox.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -32,7 +32,7 @@ def ignore_warnings(ignore=True): - """Helper to disable warnings""" + """Disable warnings.""" if ignore: warnings.simplefilter("ignore") else: diff --git a/pyttb/cp_als.py b/pyttb/cp_als.py index 98bc8016..68842228 100644 --- a/pyttb/cp_als.py +++ b/pyttb/cp_als.py @@ -1,4 +1,4 @@ -"""CP Decomposition via Alternating Least Squares""" +"""CP Decomposition via Alternating Least Squares.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -25,8 +25,7 @@ def cp_als( # noqa: PLR0912,PLR0913,PLR0915 printitn: int = 1, fixsigns: bool = True, ) -> Tuple[ttb.ktensor, ttb.ktensor, Dict]: - """ - Compute CP decomposition with alternating least squares + """Compute CP decomposition with alternating least squares. Parameters ---------- @@ -129,7 +128,6 @@ def cp_als( # noqa: PLR0912,PLR0913,PLR0915 Iter 1: f = ... f-delta = ... Final f = ... """ - # Extract number of dimensions and norm of tensor N = input_tensor.ndims normX = input_tensor.norm() diff --git a/pyttb/cp_apr.py b/pyttb/cp_apr.py index c5b1e1df..6ae00b62 100644 --- a/pyttb/cp_apr.py +++ b/pyttb/cp_apr.py @@ -1,4 +1,4 @@ -"""Non-negative CP decomposition with alternating Poisson regression""" +"""Non-negative CP decomposition with alternating Poisson regression.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -230,9 +230,6 @@ def tt_cp_apr_mu( # noqa: PLR0912,PLR0913,PLR0915 kappatol: MU ALGORITHM PARAMETER: Tolerance on complementary slackness - Returns - ------- - Notes ----- REFERENCE: E. C. Chi and T. G. Kolda. On Tensors, Sparsity, and @@ -405,9 +402,9 @@ def tt_cp_apr_pdnr( # noqa: PLR0912,PLR0913,PLR0915 precompinds: bool, inexact: bool, ) -> Tuple[ttb.ktensor, Dict]: - """ - Compute nonnegative CP with alternating Poisson regression - computes an estimate of the best rank-R + """Compute nonnegative CP with alternating Poisson regression. + + Computes an estimate of the best rank-R CP model of a tensor X using an alternating Poisson regression. The algorithm solves "row subproblems" in each alternating subproblem, using a Hessian of size R^2. @@ -1272,9 +1269,7 @@ def get_search_dir_pdnr( # noqa: PLR0913 mu: float, epsActSet: float, ) -> Tuple[np.ndarray, np.ndarray]: - """ - Compute the search direction for PDNR using a two-metric projection with - damped Hessian + """Compute the search direction using a two-metric projection with damped Hessian. Parameters ---------- @@ -1372,8 +1367,7 @@ def tt_linesearch_prowsubprob( # noqa: PLR0913 phi_row: np.ndarray, display_warning: bool, ) -> Tuple[np.ndarray, float, float, float, int]: - """ - Perform a line search on a row subproblem + """Perform a line search on a row subproblem. Parameters ---------- @@ -1488,9 +1482,9 @@ def tt_linesearch_prowsubprob( # noqa: PLR0913 def get_hessian( upsilon: np.ndarray, Pi: np.ndarray, free_indices: np.ndarray ) -> np.ndarray: - """ - Return the Hessian for one PDNR row subproblem of Model[n], for just the rows and - columns corresponding to the free variables + """Return the Hessian for one PDNR row subproblem of Model[n]. + + Only for just the rows and columns corresponding to the free variables. Parameters ---------- @@ -1505,7 +1499,6 @@ def get_hessian( Sub-block of full Hessian identified by free-indices """ - num_free = len(free_indices) H = np.zeros((num_free, num_free)) for i in range(num_free): @@ -1523,8 +1516,7 @@ def tt_loglikelihood_row( model_row: np.ndarray, Pi: np.ndarray, ) -> float: - """ - Compute log-likelihood of one row subproblem + """Compute log-likelihood of one row subproblem. Parameters ---------- @@ -1618,7 +1610,6 @@ def get_search_dir_pqnr( # noqa: PLR0913 URL: http://arxiv.org/abs/1304.4964. Submitted for publication. """ - lbfgsSize = delta_model.shape[1] # Determine active and free variables. @@ -1679,8 +1670,7 @@ def calc_grad( data_row: np.ndarray, model_row: np.ndarray, ) -> Tuple[np.ndarray, np.ndarray]: - """ - Compute the gradient for a PQNR row subproblem + """Compute the gradient for a PQNR row subproblem. Parameters ---------- @@ -1710,6 +1700,7 @@ def calc_grad( return grad_row, phi_row +# TODO verify what pi is # Mu helper functions def calculate_pi( Data: Union[ttb.sptensor, ttb.tensor], @@ -1718,9 +1709,7 @@ def calculate_pi( factorIndex: int, ndims: int, ) -> np.ndarray: - """ - Helper function to calculate Pi matrix - # TODO verify what pi is + """Calculate Pi matrix. Parameters ---------- @@ -1758,7 +1747,7 @@ def calculate_phi( # noqa: PLR0913 Pi: np.ndarray, epsilon: float, ) -> np.ndarray: - """ + """Calcualte Phi. Parameters ---------- @@ -1769,9 +1758,6 @@ def calculate_phi( # noqa: PLR0913 Pi: epsilon: - Returns - ------- - """ if isinstance(Data, ttb.sptensor): Phi = -np.ones((Data.shape[factorIndex], rank)) @@ -1846,8 +1832,7 @@ def tt_loglikelihood( def vectorize_for_mu(matrix: np.ndarray) -> np.ndarray: - """ - Helper Function to unravel matrix into vector + """Unravel matrix into vector. Parameters ---------- diff --git a/pyttb/export_data.py b/pyttb/export_data.py index 7f8f56ef..86a5d8c2 100644 --- a/pyttb/export_data.py +++ b/pyttb/export_data.py @@ -1,4 +1,4 @@ -"""Utilities for saving tensor data""" +"""Utilities for saving tensor data.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -20,9 +20,7 @@ def export_data( fmt_data: Optional[str] = None, fmt_weights: Optional[str] = None, ): - """ - Export tensor-related data to a file. - """ + """Export tensor-related data to a file.""" if not isinstance(data, (ttb.tensor, ttb.sptensor, ttb.ktensor, np.ndarray)): assert False, f"Invalid data type for export: {type(data)}" @@ -58,7 +56,7 @@ def export_data( def export_size(fp: TextIO, shape: Shape): - """Export the size of something to a file""" + """Export the size of something to a file.""" shape = parse_shape(shape) print(f"{len(shape)}", file=fp) # # of dimensions on one line shape_str = " ".join([str(d) for d in shape]) @@ -66,12 +64,12 @@ def export_size(fp: TextIO, shape: Shape): def export_rank(fp: TextIO, data: ttb.ktensor): - """Export the rank of a ktensor to a file""" + """Export the rank of a ktensor to a file.""" print(f"{len(data.weights)}", file=fp) # ktensor rank on one line def export_weights(fp: TextIO, data: ttb.ktensor, fmt_weights: Optional[str]): - """Export KTensor weights""" + """Export KTensor weights.""" if not fmt_weights: fmt_weights = "%.16e" data.weights.tofile(fp, sep=" ", format=fmt_weights) @@ -79,7 +77,7 @@ def export_weights(fp: TextIO, data: ttb.ktensor, fmt_weights: Optional[str]): def export_array(fp: TextIO, data: np.ndarray, fmt_data: Optional[str]): - """Export dense data""" + """Export dense data.""" if not fmt_data: fmt_data = "%.16e" data.tofile(fp, sep="\n", format=fmt_data) @@ -87,7 +85,7 @@ def export_array(fp: TextIO, data: np.ndarray, fmt_data: Optional[str]): def export_factor(fp: TextIO, data: np.ndarray, fmt_data: Optional[str]): - """Export KTensor factor""" + """Export KTensor factor.""" if not fmt_data: fmt_data = "%.16e" for i in range(data.shape[0]): @@ -97,7 +95,7 @@ def export_factor(fp: TextIO, data: np.ndarray, fmt_data: Optional[str]): def export_sparse_size(fp: TextIO, A: ttb.sptensor): - """Export the size of something to a file""" + """Export the size of something to a file.""" print(f"{len(A.shape)}", file=fp) # # of dimensions on one line shape_str = " ".join([str(d) for d in A.shape]) print(f"{shape_str}", file=fp) # size of each dimensions on the next line @@ -105,7 +103,7 @@ def export_sparse_size(fp: TextIO, A: ttb.sptensor): def export_sparse_array(fp: TextIO, A: ttb.sptensor, fmt_data: Optional[str]): - """Export sparse array data in coordinate format""" + """Export sparse array data in coordinate format.""" if not fmt_data: fmt_data = "%.16e" # TODO: looping through all values may take a long time, can this be more efficient? diff --git a/pyttb/gcp/__init__.py b/pyttb/gcp/__init__.py index e69de29b..00d2f2c7 100644 --- a/pyttb/gcp/__init__.py +++ b/pyttb/gcp/__init__.py @@ -0,0 +1 @@ +"""Generalized CP Decomposition Support Code.""" diff --git a/pyttb/gcp/fg.py b/pyttb/gcp/fg.py index a303b3aa..f525837e 100644 --- a/pyttb/gcp/fg.py +++ b/pyttb/gcp/fg.py @@ -1,4 +1,4 @@ -"""Evaluate Function And Gradient Handles""" +"""Evaluate Function And Gradient Handles.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -51,7 +51,7 @@ def evaluate( function_handle: Optional[function_type] = None, gradient_handle: Optional[function_type] = None, ) -> Union[float, List[np.ndarray], Tuple[float, List[np.ndarray]]]: - """Evaluate an objective function and/or gradient function + """Evaluate an objective function and/or gradient function. Parameters ---------- diff --git a/pyttb/gcp/fg_est.py b/pyttb/gcp/fg_est.py index 7dc7a514..6addc48b 100644 --- a/pyttb/gcp/fg_est.py +++ b/pyttb/gcp/fg_est.py @@ -1,4 +1,4 @@ -"""Evaluate Functions And Gradients based on Subsamples""" +"""Evaluate Functions And Gradients based on Subsamples.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -65,7 +65,7 @@ def estimate( # noqa: PLR0913 lambda_check: bool = True, crng: Optional[np.ndarray] = None, ) -> Union[float, List[np.ndarray], Tuple[float, List[np.ndarray]]]: - """Estimate the GCP function and gradient with a subsample + """Estimate the GCP function and gradient with a subsample. Parameters ---------- @@ -142,7 +142,7 @@ def estimate( # noqa: PLR0913 def estimate_helper( factors: List[np.ndarray], subs: np.ndarray ) -> Tuple[np.ndarray, List[np.ndarray]]: - """Extract model values at sample locations and exploded Zk's + """Extract model values at sample locations and exploded Zk's. Parameters ---------- diff --git a/pyttb/gcp/fg_setup.py b/pyttb/gcp/fg_setup.py index 62a33247..333fad8e 100644 --- a/pyttb/gcp/fg_setup.py +++ b/pyttb/gcp/fg_setup.py @@ -1,4 +1,4 @@ -"""Prepare Function and Gradient Handles for GCP OPT""" +"""Prepare Function and Gradient Handles for GCP OPT.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -24,7 +24,7 @@ def setup( # noqa: PLR0912,PLR0915 data: Optional[Union[ttb.tensor, ttb.sptensor]] = None, additional_parameter: Optional[float] = None, ) -> fg_return: - """Collects the function and gradient handles for GCP + """Collect the function and gradient handles for GCP. Parameters ---------- @@ -116,21 +116,21 @@ def setup( # noqa: PLR0912,PLR0915 def valid_nonneg(data: Union[ttb.tensor, ttb.sptensor]) -> bool: - """Check if provided data is valid non-negative tensor""" + """Check if provided data is valid non-negative tensor.""" if isinstance(data, ttb.sptensor): return bool(np.all(data.vals > 0)) return bool(np.all(data.data > 0)) def valid_binary(data: Union[ttb.tensor, ttb.sptensor]) -> bool: - """Check if provided data is valid binary tensor""" + """Check if provided data is valid binary tensor.""" if isinstance(data, ttb.sptensor): return bool(np.all(data.vals == 1)) return bool(np.all(np.isin(np.unique(data.data), [0, 1]))) def valid_natural(data: Union[ttb.tensor, ttb.sptensor]) -> bool: - """Check if provided data is valid natural number tensor""" + """Check if provided data is valid natural number tensor.""" if isinstance(data, ttb.sptensor): vals = data.vals else: diff --git a/pyttb/gcp/handles.py b/pyttb/gcp/handles.py index 33270552..dc55cc9a 100644 --- a/pyttb/gcp/handles.py +++ b/pyttb/gcp/handles.py @@ -1,4 +1,4 @@ -"""Implementation of the different function and gradient handles for GCP OPT""" +"""Implementation of the different function and gradient handles for GCP OPT.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -17,7 +17,7 @@ class Objectives(Enum): - """Valid objective functions for GCP""" + """Valid objective functions for GCP.""" GAUSSIAN = 0 BERNOULLI_ODDS = 1 @@ -32,77 +32,77 @@ class Objectives(Enum): def gaussian(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return objective function for gaussian distributions""" + """Return objective function for gaussian distributions.""" return (model - data) ** 2 def gaussian_grad(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return gradient function for gaussian distributions""" + """Return gradient function for gaussian distributions.""" return 2 * (model - data) def bernoulli_odds(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return objective function for bernoulli distributions""" + """Return objective function for bernoulli distributions.""" return np.log(model + 1) - data * np.log(model + EPS) def bernoulli_odds_grad(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return gradient function for bernoulli distributions""" + """Return gradient function for bernoulli distributions.""" return 1.0 / (model + 1) - data / (model + EPS) def bernoulli_logit(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return objective function for bernoulli logit distributions""" + """Return objective function for bernoulli logit distributions.""" return np.log(np.exp(model) + 1) - data * model def bernoulli_logit_grad(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return gradient function for bernoulli logit distributions""" + """Return gradient function for bernoulli logit distributions.""" return np.exp(model) / (np.exp(model) + 1) - data def poisson(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return objective function for poisson distributions""" + """Return objective function for poisson distributions.""" return model - data * np.log(model + EPS) def poisson_grad(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return gradient function for poisson distributions""" + """Return gradient function for poisson distributions.""" return 1 - data / (model + EPS) def poisson_log(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return objective function for log poisson distributions""" + """Return objective function for log poisson distributions.""" return np.exp(model) - data * model def poisson_log_grad(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return gradient function for log poisson distributions""" + """Return gradient function for log poisson distributions.""" return np.exp(model) - data def rayleigh(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return objective function for rayleigh distributions""" + """Return objective function for rayleigh distributions.""" return 2 * np.log(model + EPS) + (np.pi / 4) * (data / (model + EPS)) ** 2 def rayleigh_grad(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return gradient function for rayleigh distributions""" + """Return gradient function for rayleigh distributions.""" return 2 / (model + EPS) - (np.pi / 2) * data**2 / (model + EPS) ** 3 def gamma(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return objective function for gamma distributions""" + """Return objective function for gamma distributions.""" return data / (model + EPS) + np.log(model + EPS) def gamma_grad(data: np.ndarray, model: np.ndarray) -> np.ndarray: - """Return gradient function for gamma distributions""" + """Return gradient function for gamma distributions.""" return -data / (model + EPS) ** 2 + 1 / (model + EPS) def huber(data: ttb.tensor, model: ttb.tensor, threshold: float) -> np.ndarray: - """Return objective function for huber loss""" + """Return objective function for huber loss.""" abs_diff = np.abs(data - model) below_threshold = abs_diff < threshold return abs_diff**2 * below_threshold + ( @@ -111,7 +111,7 @@ def huber(data: ttb.tensor, model: ttb.tensor, threshold: float) -> np.ndarray: def huber_grad(data: ttb.tensor, model: ttb.tensor, threshold: float) -> np.ndarray: - """Return gradient function for huber loss""" + """Return gradient function for huber loss.""" abs_diff = np.abs(data - model) below_threshold = abs_diff < threshold return -2 * (data - model) * below_threshold - ( @@ -124,24 +124,24 @@ def huber_grad(data: ttb.tensor, model: ttb.tensor, threshold: float) -> np.ndar def negative_binomial( data: np.ndarray, model: np.ndarray, num_trials: float ) -> np.ndarray: - """Return objective function for negative binomial distributions""" + """Return objective function for negative binomial distributions.""" return (num_trials + data) * np.log(model + 1) - data * np.log(model + EPS) def negative_binomial_grad( data: np.ndarray, model: np.ndarray, num_trials: float ) -> np.ndarray: - """Return gradient function for negative binomial distributions""" + """Return gradient function for negative binomial distributions.""" return (num_trials + 1) / (1 + model) - data / (model + EPS) def beta(data: np.ndarray, model: np.ndarray, b: float) -> np.ndarray: - """Return objective function for beta distributions""" + """Return objective function for beta distributions.""" return (1 / b) * (model + EPS) ** b - (1 / (b - 1)) * data * (model + EPS) ** ( b - 1 ) def beta_grad(data: np.ndarray, model: np.ndarray, b: float) -> np.ndarray: - """Return gradient function for beta distributions""" + """Return gradient function for beta distributions.""" return (model + EPS) ** (b - 1) - data * (model + EPS) ** (b - 2) diff --git a/pyttb/gcp/optimizers.py b/pyttb/gcp/optimizers.py index ce5acac2..300a7fcf 100644 --- a/pyttb/gcp/optimizers.py +++ b/pyttb/gcp/optimizers.py @@ -1,4 +1,4 @@ -"""Optimizer Implementations for GCP""" +"""Optimizer Implementations for GCP.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -23,7 +23,7 @@ class StochasticSolver(ABC): - """Interface for Stochastic GCP Solvers""" + """Interface for Stochastic GCP Solvers.""" def __init__( # noqa: PLR0913 self, @@ -35,7 +35,7 @@ def __init__( # noqa: PLR0913 max_iters: int = 1000, printitn: int = 1, ): - """General Setup for Stochastic Solvers + """General Setup for Stochastic Solvers. Parameters ---------- @@ -70,7 +70,7 @@ def update_step( gradient: List[np.ndarray], lower_bound: float, ) -> Tuple[List[np.ndarray], float]: - """Calculates the update step for the solver + """Calculate the update step for the solver. Parameters ---------- @@ -89,7 +89,7 @@ def update_step( @abstractmethod def set_failed_epoch(self): - """Set internal state on failed epoch""" + """Set internal state on failed epoch.""" def solve( # noqa: PLR0913 self, @@ -100,7 +100,7 @@ def solve( # noqa: PLR0913 lower_bound: float = -np.inf, sampler: Optional[GCPSampler] = None, ) -> Tuple[ttb.ktensor, Dict]: - """Run solver until completion + """Run solver until completion. Parameters ---------- @@ -242,9 +242,9 @@ def solve( # noqa: PLR0913 class SGD(StochasticSolver): - """General Stochastic Gradient Descent""" + """General Stochastic Gradient Descent.""" - def update_step( + def update_step( # noqa: D102 self, model: ttb.ktensor, gradient: List[np.ndarray], lower_bound: float ) -> Tuple[List[np.ndarray], float]: step = self._decay**self._nfails * self._rate @@ -254,13 +254,13 @@ def update_step( ] return factor_matrices, step - def set_failed_epoch(self): + def set_failed_epoch(self): # noqa: D102 # No additional internal state for SGD pass class Adam(StochasticSolver): - """Adam Optimizer""" + """Adam Optimizer.""" def __init__( # noqa: PLR0913 self, @@ -275,7 +275,7 @@ def __init__( # noqa: PLR0913 beta_2: float = 0.999, epsilon: float = 1e-8, ): - """General Setup for Adam Solver + """General Setup for Adam Solver. Parameters ---------- @@ -318,14 +318,14 @@ def __init__( # noqa: PLR0913 self._v: List[np.ndarray] = [] self._v_prev: List[np.ndarray] = [] - def set_failed_epoch( + def set_failed_epoch( # noqa: D102 self, ): self._total_iterations -= self._epoch_iters self._m = self._m_prev.copy() self._v = self._v_prev.copy() - def update_step( + def update_step( # noqa: D102 self, model: ttb.ktensor, gradient: List[np.ndarray], lower_bound: float ) -> Tuple[List[np.ndarray], float]: if self._total_iterations == 0: @@ -364,7 +364,7 @@ def update_step( class Adagrad(StochasticSolver): - """Adagrad Optimizer""" + """Adagrad Optimizer.""" def __init__( # noqa: PLR0913 self, @@ -387,12 +387,12 @@ def __init__( # noqa: PLR0913 ) self._gnormsum = 0.0 - def set_failed_epoch( + def set_failed_epoch( # noqa: D102 self, ): self._gnormsum = 0.0 - def update_step( + def update_step( # noqa: D102 self, model: ttb.ktensor, gradient: List[np.ndarray], lower_bound: float ) -> Tuple[List[np.ndarray], float]: self._gnormsum += np.sum([np.sum(gk**2) for gk in gradient]) @@ -406,7 +406,7 @@ def update_step( # If we use more scipy optimizers in the future we should generalize this class LBFGSB: - """Simple wrapper around scipy lbfgsb + """Simple wrapper around scipy lbfgsb. NOTE: If used for publications please see scipy documentation for adding citation for the implementation. @@ -425,7 +425,7 @@ def __init__( # noqa: PLR0913 callback: Optional[Callable[[np.ndarray], None]] = None, maxls: Optional[int] = None, ): - """Setup all hyper-parameters for solver. + """Prepare all hyper-parameters for solver. See scipy for details and standard defaults. A variety of defaults are set specifically for gcp opt. @@ -475,7 +475,7 @@ def solve( # noqa: PLR0913 lower_bound: float = -np.inf, mask: Optional[np.ndarray] = None, ) -> Tuple[ttb.ktensor, Dict]: - """Solves the defined optimization problem""" + """Solves the defined optimization problem.""" model = initial_model.copy() def lbfgsb_func_grad(vector: np.ndarray): @@ -519,6 +519,8 @@ def lbfgsb_func_grad(vector: np.ndarray): return model, lbfgsb_info class Monitor(dict): + """Monitor LBFGSB Timings.""" + def __init__( self, maxiter: int, @@ -530,6 +532,7 @@ def __init__( self._callback = callback def __call__(self, xk: np.ndarray) -> None: + """Update monitor.""" if self._callback is not None: self._callback(xk) self.time_trace[self.iter] = time.perf_counter() - self.startTime @@ -537,10 +540,12 @@ def __call__(self, xk: np.ndarray) -> None: @property def callback(self): + """Return stored callback.""" return self._callback @property def __dict__(self): + """Monitor Entries.""" if not self._callback: return {"time_trace": self.time_trace} else: diff --git a/pyttb/gcp/samplers.py b/pyttb/gcp/samplers.py index bd9e29a9..607ed4e0 100644 --- a/pyttb/gcp/samplers.py +++ b/pyttb/gcp/samplers.py @@ -1,4 +1,4 @@ -"""Implementation of various sampling approaches for GCP OPT""" +"""Implementation of various sampling approaches for GCP OPT.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -26,14 +26,14 @@ @dataclass class StratifiedCount: - """Contains stratified sampling counts""" + """Contains stratified sampling counts.""" num_zeros: int num_nonzeros: int class Samplers(Enum): - """Implemented Samplers""" + """Implemented Samplers.""" UNIFORM = 0 SEMISTRATIFIED = 1 @@ -41,7 +41,7 @@ class Samplers(Enum): class GCPSampler: - """Contains Gradient and Function Sampling Details""" + """Contains Gradient and Function Sampling Details.""" def __init__( # noqa: PLR0913 self, @@ -237,16 +237,16 @@ def _prepare_gradient_sampler( # noqa: PLR0912,PLR0913 raise ValueError("Invalid choice for function_sampler") def function_sample(self, data: Union[ttb.tensor, ttb.sptensor]) -> sample_type: - """Draw a sample from the objective function""" + """Draw a sample from the objective function.""" return self._fsampler(data) def gradient_sample(self, data: Union[ttb.tensor, ttb.sptensor]) -> sample_type: - """Draw a sample from the gradient function""" + """Draw a sample from the gradient function.""" return self._gsampler(data) @property def crng(self) -> np.ndarray: - """Correction Range for possibly miss-sampled zeros""" + """Correction Range for possibly miss-sampled zeros.""" return self._crng @@ -289,7 +289,7 @@ def zeros( over_sample_rate: float = 1.1, with_replacement=True, ) -> np.ndarray: - """Samples zeros from a sparse tensor + """Sample zeros from a sparse tensor. Parameters ---------- @@ -372,7 +372,7 @@ def zeros( def uniform(data: ttb.tensor, samples: int) -> sample_type: - """Uniformly samples indices from a tensor + """Uniformly samples indices from a tensor. Parameters ---------- @@ -397,7 +397,7 @@ def uniform(data: ttb.tensor, samples: int) -> sample_type: def semistrat(data: ttb.sptensor, num_nonzeros: int, num_zeros: int) -> sample_type: - """Sample nonzero and zero entries from a sparse tensor + """Sample nonzero and zero entries from a sparse tensor. Parameters ---------- @@ -435,7 +435,7 @@ def stratified( num_zeros: int, over_sample_rate: float = 1.1, ) -> sample_type: - """Sample nonzero and zero entries from a sparse tensor + """Sample nonzero and zero entries from a sparse tensor. Parameters ---------- diff --git a/pyttb/gcp_opt.py b/pyttb/gcp_opt.py index e9a9bcfd..f362ab3e 100644 --- a/pyttb/gcp_opt.py +++ b/pyttb/gcp_opt.py @@ -1,4 +1,4 @@ -"""Generalized CP Decomposition""" +"""Generalized CP Decomposition.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -137,7 +137,7 @@ def _get_initial_guess( rank: int, init: Union[Literal["random"], ttb.ktensor, Sequence[np.ndarray]], ) -> ttb.ktensor: - """Get initial guess for gcp_opt + """Get initial guess for gcp_opt. Returns ------- diff --git a/pyttb/hosvd.py b/pyttb/hosvd.py index 7936e331..e999c95a 100644 --- a/pyttb/hosvd.py +++ b/pyttb/hosvd.py @@ -1,4 +1,4 @@ -"""Higher Order SVD Implementation""" +"""Higher Order SVD Implementation.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the diff --git a/pyttb/import_data.py b/pyttb/import_data.py index d19c2e57..6008c2f0 100644 --- a/pyttb/import_data.py +++ b/pyttb/import_data.py @@ -1,4 +1,4 @@ -"""Utilities for importing tensor data""" +"""Utilities for importing tensor data.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -17,8 +17,7 @@ def import_data( filename: str, index_base: int = 1 ) -> Union[ttb.sptensor, ttb.ktensor, ttb.tensor, np.ndarray]: - """ - Import tensor data + """Import tensor data. Parameters ---------- @@ -73,12 +72,12 @@ def import_data( def import_type(fp: TextIO) -> str: - """Extract IO data type""" + """Extract IO data type.""" return fp.readline().strip().split(" ")[0] def import_shape(fp: TextIO) -> Tuple[int, ...]: - """Extract the shape of something from a file""" + """Extract the shape of something from a file.""" n = int(fp.readline().strip().split(" ")[0]) shape = [int(d) for d in fp.readline().strip().split(" ")] if len(shape) != n: @@ -87,19 +86,19 @@ def import_shape(fp: TextIO) -> Tuple[int, ...]: def import_nnz(fp: TextIO) -> int: - """Extract the number of non-zeros of something from a file""" + """Extract the number of non-zeros of something from a file.""" return int(fp.readline().strip().split(" ")[0]) def import_rank(fp: TextIO) -> int: - """Extract the rank of something from a file""" + """Extract the rank of something from a file.""" return int(fp.readline().strip().split(" ")[0]) def import_sparse_array( fp: TextIO, n: int, nz: int, index_base: int = 1 ) -> Tuple[np.ndarray, np.ndarray]: - """Extract sparse data subs and vals from coordinate format data""" + """Extract sparse data subs and vals from coordinate format data.""" subs = np.zeros((nz, n), dtype="int64") vals = np.zeros((nz, 1)) for k in range(nz): @@ -110,5 +109,5 @@ def import_sparse_array( def import_array(fp: TextIO, n: Union[int, np.integer]) -> np.ndarray: - """Extract numpy array from file""" + """Extract numpy array from file.""" return np.fromfile(fp, count=n, sep=" ") diff --git a/pyttb/khatrirao.py b/pyttb/khatrirao.py index e5f718e0..2d55e56c 100644 --- a/pyttb/khatrirao.py +++ b/pyttb/khatrirao.py @@ -1,4 +1,4 @@ -"""Khatri-Rao Product Implementation""" +"""Khatri-Rao Product Implementation.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the diff --git a/pyttb/ktensor.py b/pyttb/ktensor.py index e32dfd67..5c5f5571 100644 --- a/pyttb/ktensor.py +++ b/pyttb/ktensor.py @@ -80,8 +80,9 @@ def __init__( weights: Optional[np.ndarray] = None, copy: bool = True, ): - """ - Create a :class:`pyttb.ktensor` in one of the following ways: + """Create a :class:`pyttb.ktensor`. + + Created in one of the following ways: - With no inputs (or `weights` and `factor_matrices` both None), return an empty :class:`pyttb.ktensor`. - Otherwise, return a :class:`pyttb.ktensor` with `weights` and @@ -140,7 +141,6 @@ def __init__( [[5. 6.] [7. 8.]] """ - # Cannot specify weights and not factor_matrices if factor_matrices is None and weights is not None: assert False, "factor_matrices cannot be None if weights are provided." @@ -206,8 +206,9 @@ def from_function( shape: Shape, num_components: int, ): - """ - Construct a :class:`pyttb.ktensor` whose factor matrix entries are + """Construct a :class:`pyttb.ktensor`. + + Factor matrix entries are set using a function. The weights of the returned :class:`pyttb.ktensor` will all be equal to 1. @@ -303,8 +304,9 @@ def from_function( @classmethod def from_vector(cls, data: np.ndarray, shape: Shape, contains_weights: bool): - """ - Construct a :class:`pyttb.ktensor` from a vector and shape. The rank of the + """Construct a :class:`pyttb.ktensor` from a vector and shape. + + The rank of the :class:`pyttb.ktensor` is inferred from the shape and length of the vector. Parameters @@ -422,8 +424,8 @@ def arrange( weight_factor: Optional[int] = None, permutation: Optional[Union[Tuple, List, np.ndarray]] = None, ): - """ - Arrange the rank-1 components of a :class:`pyttb.ktensor` in place. + """Arrange the rank-1 components of a :class:`pyttb.ktensor` in place. + If `permutation` is passed, the columns of `self.factor_matrices` are arranged using the provided permutation, so you must make a copy before calling this method if you want to store the original @@ -612,6 +614,7 @@ def copy(self) -> ktensor: return ttb.ktensor(self.factor_matrices, self.weights, copy=True) def __deepcopy__(self, memo): + """Return deep copy of ktensor.""" return self.copy() def double(self) -> np.ndarray: @@ -640,9 +643,7 @@ def double(self) -> np.ndarray: def extract( self, idx: Optional[Union[int, tuple, list, np.ndarray]] = None ) -> ktensor: - """ - Creates a new :class:`pyttb.ktensor` with only the specified - components. + """Create a new :class:`pyttb.ktensor` with only the specified components. Parameters ---------- @@ -722,8 +723,9 @@ def extract( assert False, "Input parameter must be an int, tuple, list or numpy.ndarray" def fixsigns(self, other: Optional[ktensor] = None) -> ktensor: # noqa: PLR0912 - """ - Change the elements of a :class:`pyttb.ktensor` in place so that the + """Change the elements of a :class:`pyttb.ktensor` in place. + + Update so that the largest magnitude entries for each column vector in each factor matrix are positive, provided that the sign on pairs of vectors in a rank-1 component can be flipped. @@ -868,8 +870,9 @@ def fixsigns(self, other: Optional[ktensor] = None) -> ktensor: # noqa: PLR0912 return self def to_tensor(self) -> ttb.tensor: - """Convenience method to convert to tensor. - Same as :meth:`pyttb.ktensor.full` + """Convert to tensor. + + Same as :meth:`pyttb.ktensor.full`. """ return self.full() @@ -905,10 +908,11 @@ def full(self) -> ttb.tensor: """ def min_split_dims(dims: Tuple[int, ...]): - """ - solve + """Return Minimum split dimensions. + + Solve min_{i in range(1,d)} product(dims[:i]) + product(dims[i:]) - to minimize the memory footprint of the intermediate matrix + to minimize the memory footprint of the intermediate matrix. """ sum_of_prods = [ prod(dims[:i]) + prod(dims[i:]) for i in range(1, len(dims)) @@ -931,9 +935,7 @@ def to_tenmat( ] = None, copy: bool = True, ) -> ttb.tenmat: - """ - Construct a :class:`pyttb.tenmat` from a :class:`pyttb.ktensor` and - unwrapping details. + """Construct a :class:`pyttb.tenmat` from a :class:`pyttb.ktensor`. Parameters ---------- @@ -1085,9 +1087,7 @@ def issymmetric( def issymmetric( self, return_diffs: bool = False ) -> Union[bool, Tuple[bool, np.ndarray]]: - """ - Returns True if the :class:`pyttb.ktensor` is exactly symmetric for - every permutation. + """Return True if :class:`pyttb.ktensor` is symmetric for every permutation. Parameters ---------- @@ -1139,8 +1139,9 @@ def issymmetric( return issym def mask(self, W: Union[ttb.tensor, ttb.sptensor]) -> np.ndarray: - """ - Extract :class:`pyttb.ktensor` values as specified by `W`, a + """Extract :class:`pyttb.ktensor` values as specified by `W`. + + `W` is a :class:`pyttb.tensor` or :class:`pyttb.sptensor` containing only values of zeros (0) and ones (1). The values in the :class:`pyttb.ktensor` corresponding to the indices for the @@ -1240,9 +1241,7 @@ def mttkrp( @property def ncomponents(self) -> int: - """ - Number of components in the :class:`pyttb.ktensor` (i.e., number of - columns in each factor matrix) of the :class:`pyttb.ktensor`. + """Number of columns in each factor matrix for the :class:`pyttb.ktensor`. Examples -------- @@ -1254,9 +1253,7 @@ def ncomponents(self) -> int: @property def ndims(self) -> int: - """ - Number of dimensions (i.e., number of factor matrices) of the - :class:`pyttb.ktensor`. + """Number of dimensions of the :class:`pyttb.ktensor`. Examples -------- @@ -1267,9 +1264,10 @@ def ndims(self) -> int: return len(self.factor_matrices) def norm(self) -> float: - """ - Compute the norm (i.e., square root of the sum of squares of entries) - of a :class:`pyttb.ktensor`. + """Compute the norm of a :class:`pyttb.ktensor`. + + Frobenius norm, or square root of the sum of + squares of entries. Examples -------- @@ -1290,10 +1288,9 @@ def normalize( normtype: float = 2, mode: Optional[int] = None, ) -> ktensor: - """ - Normalize the columns of the factor matrices of a - :class:`pyttb.ktensor` in place, then optionally - absorb the weights into desired normalized factors. + """Normalize the columns of the factor matrices in place. + + Optionally absorb the weights into desired normalized factors. Parameters ---------- @@ -1512,8 +1509,8 @@ def permute(self, order: OneDArray) -> ktensor: return ttb.ktensor([self.factor_matrices[i] for i in order], self.weights) def redistribute(self, mode: int) -> ktensor: - """ - Distribute weights of a :class:`pyttb.ktensor` to the specified mode. + """Distribute weights of a :class:`pyttb.ktensor` to the specified mode. + The redistribution is performed in place. Parameters @@ -1579,10 +1576,7 @@ def score( threshold: Optional[float] = None, greedy: bool = True, ) -> Tuple[float, ktensor, bool, np.ndarray]: - """ - Checks if two :class:`pyttb.ktensor` instances with the same shapes - but potentially different number of components match except for - permutation. + """Check if two :class:`pyttb.ktensor` with the same shape match. Matching is defined as follows. If `self` and `other` are single- component :class:`pyttb.ktensor` instances that have been normalized @@ -1655,7 +1649,6 @@ def score( >>> print(perm) [0 1 2] """ - assert ( greedy ), "Not yet implemented. Only greedy method is implemented currently." @@ -1811,9 +1804,10 @@ def symmetrize(self) -> ktensor: return ttb.ktensor([V.copy() for i in range(K.ndims)], weights) def tolist(self, mode: Optional[int] = None) -> List[np.ndarray]: - """ - Convert :class:`pyttb.ktensor` to a list of factor matrices, evenly - distributing the weights across factors. Optionally absorb the + """Convert :class:`pyttb.ktensor` to a list of factor matrices. + + Eevenly + distributes the weights across factors. Optionally absorb the weights into a single mode. Parameters @@ -1885,8 +1879,9 @@ def tolist(self, mode: Optional[int] = None) -> List[np.ndarray]: return factor_matrices def tovec(self, include_weights: bool = True) -> np.ndarray: - """ - Convert :class:`pyttb.ktensor` to column vector. Optionally include + """Convert :class:`pyttb.ktensor` to column vector. + + Optionally include or exclude the weights. The output of this method can be consumed by :meth:`from_vector`. @@ -2014,7 +2009,7 @@ def ttv( input. If k == n, a scalar is returned. Examples - ------- + -------- Compute the product of a :class:`pyttb.ktensor` and a single vector (results in a :class:`pyttb.ktensor`): @@ -2099,8 +2094,9 @@ def ttv( return ttb.ktensor(factor_matrices, new_weights, copy=False) def update(self, modes: OneDArray, data: np.ndarray) -> ktensor: - """ - Updates a :class:`pyttb.ktensor` in the specific dimensions with the + """Update a :class:`pyttb.ktensor` in the specific dimensions. + + Updates with the values in `data` (in vector or matrix form). The value of `modes` must be a value in [-1,...,self.ndims]. If the Further, the number of elements in `data` must equal self.shape[modes] * self.ncomponents. The update is @@ -2495,9 +2491,7 @@ def __sub__(self, other): return ttb.ktensor(factor_matrices, weights) def __mul__(self, other): - """ - Elementwise (including scalar) multiplication for - :class:`pyttb.ktensor` instances. + """Elementwise (including scalar) multiplication for :class:`pyttb.ktensor`. Parameters ---------- @@ -2518,9 +2512,7 @@ def __mul__(self, other): ), "Multiplication by ktensors only allowed for scalars, tensors, or sptensors" def __rmul__(self, other): - """ - Elementwise (including scalar) multiplication for - :class:`pyttb.ktensor` instances. + """Elementwise (including scalar) multiplication for :class:`pyttb.ktensor`. Parameters ---------- @@ -2533,8 +2525,7 @@ def __rmul__(self, other): return self.__mul__(other) def __repr__(self): - """ - String representation of a :class:`pyttb.ktensor`. + """Return string representation of a :class:`pyttb.ktensor`. Returns ------- diff --git a/pyttb/pyttb_utils.py b/pyttb/pyttb_utils.py index 870e589f..2718dd93 100644 --- a/pyttb/pyttb_utils.py +++ b/pyttb/pyttb_utils.py @@ -1,4 +1,4 @@ -"""PYTTB shared utilities across tensor types""" +"""PYTTB shared utilities across tensor types.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -7,15 +7,16 @@ from __future__ import annotations from enum import Enum -from inspect import signature from math import prod from typing import ( + Any, Iterable, Literal, Optional, Sequence, Tuple, Union, + cast, get_args, overload, ) @@ -29,8 +30,7 @@ def tt_union_rows(MatrixA: np.ndarray, MatrixB: np.ndarray) -> np.ndarray: - """ - Helper function to reproduce functionality of MATLABS intersect(a,b,'rows') + """Reproduce functionality of MATLABS intersect(a,b,'rows'). Parameters ---------- @@ -53,7 +53,7 @@ def tt_union_rows(MatrixA: np.ndarray, MatrixB: np.ndarray) -> np.ndarray: [1, 2], [3, 4]]) """ - # TODO ismember and uniqe are very similar in function + # TODO ismember and unique are very similar in function if MatrixA.size > 0: MatrixAUnique, idxA = np.unique(MatrixA, axis=0, return_index=True) else: @@ -97,15 +97,41 @@ def tt_dimscheck( # noqa: PLR0912 dims: Optional[OneDArray] = None, exclude_dims: Optional[OneDArray] = None, ) -> Tuple[np.ndarray, Optional[np.ndarray]]: - """ - Used to preprocess dimensions for tensor dimensions + """Preprocess dimensions for tensor operations. Parameters ---------- + N: Tensor order + M: Num of multiplicands + dims: Dimensions to check + exclude_dims: Check all dimensions but these. (Mutually exclusive with dims) Returns ------- + sdims: New dimensions + vidx: Index into the multiplicands (if M defined). + + Examples + -------- + # Default captures all dims and no index + >>> rdims, _ = tt_dimscheck(6) + >>> np.array_equal(rdims, np.arange(6)) + True + + # Exclude single dim and still no index + + >>> rdims, _ = tt_dimscheck(6, exclude_dims=np.array([5])) + >>> np.array_equal(rdims, np.arange(5)) + True + + # Exclude single dim and number of multiplicands equals resulting size + + >>> rdims, ridx = tt_dimscheck(6, 5, exclude_dims=np.array([0])) + >>> np.array_equal(rdims, np.array([1, 2, 3, 4, 5])) + True + >>> np.array_equal(ridx, np.arange(0, 5)) + True """ if dims is not None and exclude_dims is not None: raise ValueError("Either specify dims to include or exclude, but not both") @@ -174,108 +200,8 @@ def tt_dimscheck( # noqa: PLR0912 return sdims, vidx -def tt_tenfun(function_handle, *inputs): # noqa: PLR0912 - """ - Apply a function to each element in a tensor - - Parameters - ---------- - function_handle: - callable - inputs: - tensor type, or np.array - - Returns - ------- - :class:`pyttb.tensor` - """ - # Allow inputs to be mutable in case of type conversion - inputs = list(inputs) - - if len(inputs) == 0: - assert False, "Must provide element(s) to perform operation on" - - assert callable(function_handle), "function_handle must be callable" - - # Convert inputs to tensors if they aren't already - for i, an_input in enumerate(inputs): - if isinstance(an_input, (ttb.tensor, float, int)): - continue - if isinstance(an_input, np.ndarray): - inputs[i] = ttb.tensor(an_input) - elif isinstance( - an_input, - ( - ttb.ktensor, - ttb.ttensor, - ttb.sptensor, - ttb.sumtensor, - ttb.symtensor, - ttb.symktensor, - ), - ): - inputs[i] = an_input.to_tensor() - else: - assert False, "Invalid input to ten fun" - - # It's ok if there are two input and one is a scalar; otherwise all inputs have to - # be the same size - if ( - (len(inputs) == 2) - and isinstance(inputs[0], (float, int)) - and isinstance(inputs[1], ttb.tensor) - ): - sz = inputs[1].shape - elif ( - (len(inputs) == 2) - and isinstance(inputs[1], (float, int)) - and isinstance(inputs[0], ttb.tensor) - ): - sz = inputs[0].shape - else: - for i, an_input in enumerate(inputs): - if isinstance(an_input, (float, int)): - assert False, f"Argument {i} is a scalar but expected a tensor" - elif i == 0: - sz = an_input.shape - elif sz != an_input.shape: - assert ( - False - ), f"Tensor {i} is not the same size as the first tensor input" - - # Number of inputs for function handle - nfunin = len(signature(function_handle).parameters) - - # Case I: Binary function - if len(inputs) == 2 and nfunin == 2: - X = inputs[0] - Y = inputs[1] - if not isinstance(X, (float, int)): - X = X.data - if not isinstance(Y, (float, int)): - Y = Y.data - - data = function_handle(X, Y) - Z = ttb.tensor(data, copy=False) - return Z - - # Case II: Expects input to be matrix and applies operation on each columns - if len(inputs) == 1: - X = inputs[0].data - X = np.reshape(X, (1, -1)) - else: - X = np.zeros((len(inputs), prod(sz))) - for i, an_input in enumerate(inputs): - X[i, :] = np.reshape(an_input.data, (prod(sz))) - data = function_handle(X) - data = np.reshape(data, sz) - Z = ttb.tensor(data, copy=False) - return Z - - def tt_setdiff_rows(MatrixA: np.ndarray, MatrixB: np.ndarray) -> np.ndarray: - """ - Helper function to reproduce functionality of MATLABS setdiff(a,b,'rows') + """Reproduce functionality of MATLABS setdiff(a,b,'rows'). Parameters ---------- @@ -304,8 +230,7 @@ def tt_setdiff_rows(MatrixA: np.ndarray, MatrixB: np.ndarray) -> np.ndarray: def tt_intersect_rows(MatrixA: np.ndarray, MatrixB: np.ndarray) -> np.ndarray: - """ - Helper function to reproduce functionality of MATLABS intersect(a,b,'rows') + """Reproduce functionality of MATLABS intersect(a,b,'rows'). Parameters ---------- @@ -343,9 +268,10 @@ def tt_intersect_rows(MatrixA: np.ndarray, MatrixB: np.ndarray) -> np.ndarray: return location[valid] -def tt_irenumber(t: ttb.sptensor, shape: Tuple[int, ...], number_range) -> np.ndarray: - """ - RENUMBER indices for sptensor subsasgn +def tt_irenumber( + t: ttb.sptensor, shape: Tuple[int, ...], number_range: Sequence[IndexType] +) -> np.ndarray: + """Renumber indices for sptensor __setitem__. Parameters ---------- @@ -375,17 +301,16 @@ def tt_irenumber(t: ttb.sptensor, shape: Tuple[int, ...], number_range) -> np.nd # This appears to be inserting new keys as rows to our subs here newsubs = np.insert(newsubs, obj=i, values=r, axis=1) else: - if isinstance(r, list): + if not isinstance(r, np.ndarray): r = np.array(r) # noqa: PLW2901 newsubs[:, i] = r[newsubs[:, i]] return newsubs def tt_renumber( - subs: np.ndarray, shape: Tuple[int, ...], number_range + subs: np.ndarray, shape: Tuple[int, ...], number_range: Sequence[IndexType] ) -> Tuple[np.ndarray, Tuple[int, ...]]: - """ - RENUMBER indices for sptensor subsref + """Renumber indices for sptensor __getitem__. [NEWSUBS,NEWSZ] = RENUMBER(SUBS,SZ,RANGE) takes a set of original subscripts SUBS with entries from a tensor of size @@ -396,9 +321,11 @@ def tt_renumber( Parameters ---------- subs: + Original subscripts for source tensor. shape: Shape of source tensor. - range: + number_range: + Key from __getitem__ for tensor. Returns ------- @@ -413,13 +340,21 @@ def tt_renumber( if not number_range[i] == slice(None, None, None): if subs.size == 0: if not isinstance(number_range[i], slice): - if isinstance(number_range[i], (int, float, np.integer)): - newshape[i] = number_range[i] + # This should be statically determinable but mypy unhappy + # without intermediate + number_range_i = number_range[i] + if isinstance(number_range_i, (int, float, np.integer)): + newshape[i] = number_range_i else: - newshape[i] = len(number_range[i]) + assert not isinstance(number_range_i, (int, slice, np.integer)) + newshape[i] = len(number_range_i) else: # TODO get this length without generating the range - newshape[i] = len(range(0, shape[i])[number_range[i]]) + # This should be statically determinable but mypy unhappy + # without assert + number_range_i = number_range[i] + assert isinstance(number_range_i, slice) + newshape[i] = len(range(0, shape[i])[number_range_i]) else: newsubs[:, i], newshape[i] = tt_renumberdim( subs[:, i], shape[i], number_range[i] @@ -428,9 +363,12 @@ def tt_renumber( return newsubs, tuple(newshape) -def tt_renumberdim(idx: np.ndarray, shape: int, number_range) -> Tuple[int, int]: - """ - RENUMBERDIM helper function for RENUMBER +def tt_renumberdim( + idx: np.ndarray, shape: int, number_range: IndexType +) -> Tuple[int, int]: + """Renumber a single dimension. + + Helper function for RENUMBER. Parameters ---------- @@ -445,12 +383,15 @@ def tt_renumberdim(idx: np.ndarray, shape: int, number_range) -> Tuple[int, int] """ # Determine the size of the new range if isinstance(number_range, (int, np.integer)): + number_range = [int(number_range)] newshape = 0 elif isinstance(number_range, slice): - number_range = range(0, shape)[number_range] + number_range = list(range(0, shape))[number_range] newshape = len(number_range) - else: + elif isinstance(number_range, (Sequence, np.ndarray)): newshape = len(number_range) + else: + raise ValueError(f"Bad number range: {number_range}") # Create map from old range to the new range idx_map = np.zeros(shape=shape) @@ -468,8 +409,7 @@ def tt_renumberdim(idx: np.ndarray, shape: int, number_range) -> Tuple[int, int] def tt_ismember_rows( search: np.ndarray, source: np.ndarray ) -> Tuple[np.ndarray, np.ndarray]: - """ - Find location of search rows in source array + """Find location of search rows in source array. Parameters ---------- @@ -520,12 +460,20 @@ def tt_ind2sub( Parameters ---------- - shape: - idx: + shape: Shape of tensor indexing into. + idx: Array of linear indices into the tensor. Returns ------- - :class:`numpy.ndarray` + Multi-dimensional indices for the tensor. + + Example + ------- + >>> shape = (2, 2, 2) + >>> linear_indices = np.array([0, 1]) + >>> tt_ind2sub(shape, linear_indices) + array([[0, 0, 0], + [1, 0, 0]]) """ if idx.size == 0: return np.empty(shape=(0, len(shape)), dtype=int) @@ -533,9 +481,8 @@ def tt_ind2sub( return np.array(np.unravel_index(idx, shape, order=order)).transpose() -def tt_subsubsref(obj, s): - """ - Helper function for tensor toolbox subsref. +def tt_subsubsref(obj: np.ndarray, s: Any) -> Union[float, np.ndarray]: + """Helper function for tensor toolbox subsref. Parameters ---------- @@ -547,7 +494,7 @@ def tt_subsubsref(obj, s): Returns ------- Still uncertain to this functionality - """ + """ # noqa: D401 # TODO figure out when subsref yields key of length>1 for now ignore this logic and # just return # if len(s) == 1: @@ -555,34 +502,17 @@ def tt_subsubsref(obj, s): # else: # return obj[s[1:]] if isinstance(obj, np.ndarray) and obj.size == 1: - return obj.item() + # TODO: Globally figure out why typing thinks item is a string + return cast(float, obj.item()) return obj -def tt_intvec2str(v: np.ndarray) -> str: - """ - Print integer vector to a string with brackets. Numpy should already handle this so - it is a placeholder stub - - Parameters - ---------- - v: - Integer vector - - Returns - ------- - Formatted string to print - """ - return np.array2string(v) - - def tt_sub2ind( shape: Tuple[int, ...], subs: np.ndarray, order: Union[Literal["F"], Literal["C"]] = "F", ) -> np.ndarray: - """ - Converts multidimensional subscripts to linear indices. + """Convert multidimensional subscripts to linear indices. Parameters ---------- @@ -593,13 +523,15 @@ def tt_sub2ind( order: Memory layout - Returns - ------- - :class:`numpy.ndarray` + Examples + -------- + >>> shape = (2, 2, 2) + >>> full_indices = np.array([[0, 0, 0], [1, 0, 0]], dtype=int) + >>> tt_sub2ind(shape, full_indices) + array([0, 1]) See Also -------- - :func:`tt_ind2sub`: """ if subs.size == 0: @@ -627,9 +559,15 @@ def tt_sizecheck(shape: Tuple[int, ...], nargout: bool = True) -> bool: ------- bool - See Also + Examples -------- + >>> tt_sizecheck((0, -1, 2)) + False + >>> tt_sizecheck((1, 1, 1)) + True + See Also + -------- :func:`tt_subscheck`: """ siz = np.array(shape) @@ -669,9 +607,15 @@ def tt_subscheck(subs: np.ndarray, nargout: bool = True) -> bool: ------- bool - See Also + Examples -------- + >>> tt_subscheck(np.array([[2, 2], [3, 3]])) + True + >>> tt_subscheck(np.array([[2, 2], [3, -1]])) + False + See Also + -------- :func:`tt_sizecheck`: :func:`tt_valscheck`: """ @@ -709,6 +653,18 @@ def tt_valscheck(vals: np.ndarray, nargout: bool = True) -> bool: Returns ------- bool + + Examples + -------- + >>> tt_valscheck(np.array([[1], [2]])) + True + >>> tt_valscheck(np.array([[1, 2, 3], [2, 2, 2]])) + False + + See Also + -------- + :func:`tt_sizecheck`: + :func:`tt_subscheck`: """ if vals.size == 0: ok = True @@ -732,9 +688,12 @@ def isrow(v: np.ndarray) -> bool: v: Vector input - Returns - ------- - bool + Examples + -------- + >>> isrow(np.array([[1, 2]])) + True + >>> isrow(np.array([[1, 2], [3, 4]])) + False """ return v.ndim == 2 and v.shape[0] == 1 and v.shape[1] >= 1 @@ -780,7 +739,7 @@ def islogical(a: np.ndarray) -> bool: class IndexVariant(Enum): - """Methods for indexing entries of tensors""" + """Methods for indexing entries of tensors.""" UNKNOWN = 0 LINEAR = 1 @@ -789,12 +748,16 @@ class IndexVariant(Enum): # We probably want to create a specific file for utility types -LinearIndexType = Union[int, float, np.generic, slice] -IndexType = Union[LinearIndexType, list, np.ndarray] +LinearIndexType = Union[int, np.integer, slice] +IndexType = Union[LinearIndexType, Sequence[int], np.ndarray] def get_index_variant(indices: IndexType) -> IndexVariant: - """Decide on intended indexing variant. No correctness checks.""" + """Decide on intended indexing variant. No correctness checks. + + See getitem or setitem in :class:`pyttb.tensor` for elaboration of the + various indexing options. + """ variant = IndexVariant.UNKNOWN if isinstance(indices, get_args(LinearIndexType)): variant = IndexVariant.LINEAR @@ -807,7 +770,7 @@ def get_index_variant(indices: IndexType) -> IndexVariant: variant = IndexVariant.SUBSCRIPTS elif isinstance(indices, tuple): variant = IndexVariant.SUBTENSOR - elif isinstance(indices, list): + elif isinstance(indices, Sequence) and isinstance(indices[0], int): # TODO this is slightly redundant/inefficient key = np.array(indices) if len(key.shape) == 1 or key.shape[1] == 1: @@ -818,7 +781,7 @@ def get_index_variant(indices: IndexType) -> IndexVariant: def get_mttkrp_factors( U: Union[ttb.ktensor, Sequence[np.ndarray]], n: Union[int, np.integer], ndims: int ) -> Sequence[np.ndarray]: - """Apply standard checks and type conversions for mttkrp factors""" + """Apply standard checks and type conversions for mttkrp factors.""" if isinstance(U, ttb.ktensor): U = U.copy() # Absorb lambda into one of the factors but not the one that is skipped @@ -845,6 +808,36 @@ def gather_wrap_dims( cdims: Optional[np.ndarray] = None, cdims_cyclic: Optional[Union[Literal["fc"], Literal["bc"], Literal["t"]]] = None, ) -> Tuple[np.ndarray, np.ndarray]: + """Extract tensor modes mapped to rows and columns for matricized tensors. + + Parameters + ---------- + ndims: + Number of dimensions. + rdims: + Mapping of row indices. + cdims: + Mapping of column indices. + cdims_cyclic: + When only rdims is specified maps a single rdim to the rows and + the remaining dimensons span the columns. _fc_ (forward cyclic[1]_) + in the order range(rdims,self.ndims()) followed by range(0, rdims). + _bc_ (backward cyclic[2]_) range(rdims-1, -1, -1) then + range(self.ndims(), rdims, -1). + + Notes + ----- + Forward cyclic is defined by Kiers [1]_ and backward cyclic is defined by + De Lathauwer, De Moor, and Vandewalle [2]_. + + References + ---------- + .. [1] KIERS, H. A. L. 2000. Towards a standardized notation and terminology + in multiway analysis. J. Chemometrics 14, 105-122. + .. [2] DE LATHAUWER, L., DE MOOR, B., AND VANDEWALLE, J. 2000b. On the best + rank-1 and rank-(R1, R2, ... , RN ) approximation of higher-order + tensors. SIAM J. Matrix Anal. Appl. 21, 4, 1324-1342. + """ alldims = np.array([range(ndims)]) if rdims is not None and cdims is None: @@ -901,8 +894,7 @@ def np_to_python( def parse_shape(shape: Shape) -> Tuple[int, ...]: - """Provides more flexible shape support - + """Parse flexible type into shape tuple. Examples -------- @@ -942,7 +934,7 @@ def parse_shape(shape: Shape) -> Tuple[int, ...]: def parse_one_d(maybe_vector: OneDArray) -> np.ndarray: - """Provides more flexible vector support + """Parse flexible type into numpy array. Examples -------- diff --git a/pyttb/sptenmat.py b/pyttb/sptenmat.py index c97ad504..7b374c62 100644 --- a/pyttb/sptenmat.py +++ b/pyttb/sptenmat.py @@ -17,10 +17,7 @@ class sptenmat: - """ - SPTENMAT Store sparse tensor as a sparse matrix. - - """ + """Store sparse tensor as a sparse matrix.""" __slots__ = ("tshape", "rdims", "cdims", "subs", "vals") @@ -33,8 +30,9 @@ def __init__( # noqa: PLR0913 tshape: Tuple[int, ...] = (), copy: bool = True, ): - """ - Construct a :class:`pyttb.sptenmat` from a set of 2D subscripts (subs) + """Construct a :class:`pyttb.sptenmat`. + + Constructed from a set of 2D subscripts (subs) and values (vals) along with the mappings of the row (rdims) and column indices (cdims) and the shape of the original tensor (tshape). @@ -170,8 +168,9 @@ def from_array( cdims: Optional[np.ndarray] = None, tshape: Tuple[int, ...] = (), ): - """ - Construct a :class:`pyttb.sptenmat` from a coo_matrix + """Construct a :class:`pyttb.sptenmat`. + + Constructed from a coo_matrix along with the mappings of the row (rdims) and column indices (cdims) and the shape of the original tensor (tshape). @@ -250,11 +249,11 @@ def copy(self) -> sptenmat: ) def __deepcopy__(self, memo): + """Return deepcopy of this sptenmat.""" return self.copy() def to_sptensor(self) -> ttb.sptensor: - """ - Contruct a :class:`pyttb.sptensor` from `:class:pyttb.sptenmat` + """Contruct a :class:`pyttb.sptensor` from `:class:pyttb.sptenmat`. Examples -------- @@ -372,9 +371,10 @@ def nnz(self) -> int: return len(self.vals) def norm(self) -> float: - """ - Compute the norm (i.e., Frobenius norm, or square root of the sum of - squares of entries) of the :class:`pyttb.sptenmat`. + """Compute the norm of the :class:`pyttb.sptenmat`. + + Frobenius norm, or square root of the sum of + squares of entries. Examples -------- @@ -542,8 +542,7 @@ def __setitem__(self, key, value): # noqa: PLR0912 self.vals = self.vals[sort_idx] def __repr__(self): - """ - String representation of a :class:`pyttb.sptenmat`. + """Return string representation of a :class:`pyttb.sptenmat`. Examples -------- diff --git a/pyttb/sptensor.py b/pyttb/sptensor.py index c1114750..509f2795 100644 --- a/pyttb/sptensor.py +++ b/pyttb/sptensor.py @@ -42,7 +42,6 @@ tt_dimscheck, tt_ind2sub, tt_intersect_rows, - tt_intvec2str, tt_irenumber, tt_ismember_rows, tt_renumber, @@ -92,8 +91,9 @@ def __init__( shape: Optional[Shape] = None, copy: bool = True, ): - """ - Construct a :class:`pyttb.sptensor` from a set of `subs` (subscripts), + """Construct a :class:`pyttb.sptensor`. + + Constructed from a set of `subs` (subscripts), `vals` (values), and `shape`. No validation is performed. For initializer with error checking see :meth:`from_aggregator`. @@ -180,8 +180,9 @@ def from_function( shape: Shape, nonzeros: float, ) -> sptensor: - """ - Construct a :class:`pyttb.sptensor` whose nonzeros are set using a + """Construct a :class:`pyttb.sptensor`. + + Constructed with nonzeros set using a function. The subscripts of the nonzero elements of the sparse tensor are generated randomly using `numpy`, so calling `numpy.random.seed()` before using this method will provide reproducible results. @@ -264,8 +265,9 @@ def from_aggregator( shape: Optional[Shape] = None, function_handle: Union[str, Callable[[Any], Union[float, np.ndarray]]] = "sum", ) -> sptensor: - """ - Construct a :class:`pyttb.sptensor` from a set of `subs` (subscripts), + """Construct a :class:`pyttb.sptensor`. + + Constructed from a set of `subs` (subscripts), `vals` (values), and `shape` after an aggregation function is applied to the values. @@ -385,6 +387,7 @@ def copy(self) -> sptensor: return ttb.sptensor(self.subs, self.vals, self.shape, copy=True) def __deepcopy__(self, memo): + """Return deep copy of this sptensor.""" return self.copy() def allsubs(self) -> np.ndarray: @@ -496,8 +499,9 @@ def collapse( return ttb.sptensor(np.array([]), np.array([]), tuple(newsize), copy=False) def contract(self, i_0: int, i_1: int) -> Union[np.ndarray, sptensor, ttb.tensor]: - """ - Contract the :class:`pyttb.sptensor` along two dimensions. If the + """Contract the :class:`pyttb.sptensor` along two dimensions. + + If the result is sufficiently dense, it is returned as a :class:`pyttb.tensor`. @@ -593,9 +597,9 @@ def double(self) -> np.ndarray: return a def elemfun(self, function_handle: Callable[[np.ndarray], np.ndarray]) -> sptensor: - """ - Apply a function to the nonzero elements of the - :class:`pyttb.sptensor`. Returns a copy of the sparse tensor, with the + """Apply a function to the nonzero elements of the :class:`pyttb.sptensor`. + + Returns a copy of the sparse tensor, with the updated values. Parameters @@ -648,7 +652,7 @@ def extract(self, searchsubs: np.ndarray) -> np.ndarray: error_msg = "The following subscripts are invalid: \n" badsubs = searchsubs[badloc, :] for i in np.arange(0, badloc[0].size): - error_msg += f"\tsubscript = {tt_intvec2str(badsubs[i, :])} \n" + error_msg += f"\tsubscript = {np.array2string(badsubs[i, :])} \n" assert False, f"{error_msg}" "Invalid subscripts" # Set the default answer to zero @@ -676,7 +680,8 @@ def find(self) -> Tuple[np.ndarray, np.ndarray]: return self.subs, self.vals def to_tensor(self) -> ttb.tensor: - """ + """Convert to dense tensor. + Same as :meth:`pyttb.sptensor.full`. """ return self.full() @@ -723,9 +728,7 @@ def to_sptenmat( Union[Literal["fc"], Literal["bc"], Literal["t"]] ] = None, ) -> ttb.sptenmat: - """ - Construct a :class:`pyttb.sptenmat` from a :class:`pyttb.sptensor` and - unwrapping details. + """Construct a :class:`pyttb.sptenmat` from a :class:`pyttb.sptensor`. Parameters ---------- @@ -837,9 +840,7 @@ def to_sptenmat( def innerprod( self, other: Union[sptensor, ttb.tensor, ttb.ktensor, ttb.ttensor] ) -> float: - """ - Compute inner product of the :class:`pyttb.sptensor` with another - tensor. + """Compute inner product of the :class:`pyttb.sptensor` with another tensor. Parameters ---------- @@ -906,9 +907,9 @@ def innerprod( assert False, f"Inner product between sptensor and {type(other)} not supported" def isequal(self, other: Union[sptensor, ttb.tensor]) -> bool: - """ - Determine if the :class:`pyttb.sptensor` is equal to another tensor, - where all elements are exactly the same in both tensors. + """Determine if the :class:`pyttb.sptensor` is equal to another tensor. + + Equal when all elements are exactly the same in both tensors. Parameters ---------- @@ -1186,8 +1187,8 @@ def logical_xor( assert False, "The argument must be an sptensor, tensor or scalar" def mask(self, W: sptensor) -> np.ndarray: - """ - Extract values of the :class:`pyttb.sptensor` as specified by `W`. + """Extract values of the :class:`pyttb.sptensor` as specified by `W`. + The values in the sparse tensor corresponding to ones (1) in `W` will be returned as a column vector. @@ -1250,9 +1251,9 @@ def mask(self, W: sptensor) -> np.ndarray: def mttkrp( self, U: Union[ttb.ktensor, Sequence[np.ndarray]], n: Union[int, np.integer] ) -> np.ndarray: - """ - Matricized tensor times Khatri-Rao product using the - :class:`pyttb.sptensor`. This is an efficient form of the matrix + """Matricized tensor times Khatri-Rao product using :class:`pyttb.sptensor`. + + This is an efficient form of the matrix product that avoids explicitly computing the matricized sparse tensor and the large intermediate Khatri-Rao product arrays. @@ -1364,9 +1365,10 @@ def nnz(self) -> int: return self.subs.shape[0] def norm(self) -> float: - """ - Compute the norm (i.e., Frobenius norm, or square root of the sum of - squares of entries) of the :class:`pyttb.sptensor`. + """Compute the norm of the :class:`pyttb.sptensor`. + + Frobenius norm, or square root of the sum of + squares of entries. Examples -------- @@ -1491,8 +1493,9 @@ def ones(self) -> sptensor: return ttb.sptensor(self.subs, oneVals, self.shape) def permute(self, order: OneDArray) -> sptensor: - """ - Permute the :class:`pyttb.sptensor` dimensions. The result is a new + """Permute the :class:`pyttb.sptensor` dimensions. + + The result is a new sparse tensor that has the same values, but the order of the subscripts needed to access any particular element are rearranged as specified by `order`. @@ -1544,9 +1547,9 @@ def reshape( new_shape: Shape, old_modes: Optional[Union[np.ndarray, int]] = None, ) -> sptensor: - """ - Reshape the :class:`pyttb.sptensor` to the have shape specified in - `new_shape`. If `old_modes` is specified, reshape only those modes of + """Reshape the :class:`pyttb.sptensor` to the `new_shape`. + + If `old_modes` is specified, reshape only those modes of the sparse tensor, moving newly reshaped modes to the end of the subscripts; otherwise use all modes. The product of the new shape must equal the product of the old shape. @@ -1716,9 +1719,7 @@ def scale( assert False, "Invalid scaling factor" def spmatrix(self) -> sparse.coo_matrix: - """ - Converts a 2-way :class:`pyttb.sptensor` to a - :class:`scipy.sparse.coo_matrix`. + """Convert 2-way :class:`pyttb.sptensor` to :class:`scipy.sparse.coo_matrix`. Examples -------- @@ -1749,8 +1750,7 @@ def spmatrix(self) -> sparse.coo_matrix: ) def squeeze(self) -> Union[sptensor, float]: - """ - Removes singleton dimensions from the :class:`pyttb.sptensor`. + """Remove singleton dimensions from the :class:`pyttb.sptensor`. Examples -------- @@ -2997,7 +2997,7 @@ def __rmul__(self, other): assert False, "This object cannot be multiplied by sptensor" def _compare(self, other, operator, opposite_operator, include_zero=False): # noqa: PLR0912 - """Generalized Comparison operation + """Generalized Comparison operation. Parameters ---------- @@ -3226,8 +3226,8 @@ def __gt__(self, other): return self._compare(other, gt, lt) def __truediv__(self, other): # noqa: PLR0912, PLR0915 - """ - Element-wise left division operator (/). + """Element-wise left division operator (/). + Comparisons with empty tensors raise an exception. Parameters @@ -3256,7 +3256,6 @@ def __truediv__(self, other): # noqa: PLR0912, PLR0915 sparse tensor of shape (2, 2) with 1 nonzeros [1, 1] = 0.66666... """ - # Divide by a scalar -> result is sparse if isinstance(other, (float, int)): # Inline mrdivide @@ -3380,8 +3379,7 @@ def __rtruediv__(self, other): assert False, "Dividing that object by an sptensor is not supported" def __repr__(self): # pragma: no cover - """ - String representation of a :class:`pyttb.sptensor`. + """Return string representation of a :class:`pyttb.sptensor`. Examples -------- @@ -3607,8 +3605,9 @@ def sptenrand( density: Optional[float] = None, nonzeros: Optional[float] = None, ) -> sptensor: - """ - Create a :class:`pyttb.sptensor` with entries drawn from a uniform + """Create a :class:`pyttb.sptensor` with random entries and indices. + + Entries drawn from a uniform distribution on the unit interval and indices selected using a uniform distribution. You can specify the density or number of nonzeros in the resulting sparse tensor but not both. @@ -3661,8 +3660,8 @@ def unit_uniform(pass_through_shape: Tuple[int, ...]) -> np.ndarray: def sptendiag(elements: OneDArray, shape: Optional[Shape] = None) -> sptensor: - """ - Creates a :class:`pyttb.sptensor` with elements along the super diagonal. + """Create a :class:`pyttb.sptensor` with elements along the super diagonal. + If provided shape is too small the sparse tensor will be enlarged to accommodate. diff --git a/pyttb/sptensor3.py b/pyttb/sptensor3.py index 64a83cd2..97420f70 100644 --- a/pyttb/sptensor3.py +++ b/pyttb/sptensor3.py @@ -1,4 +1,4 @@ -"""Sparse Tensor 3 Class Placeholder""" +"""Sparse Tensor 3 Class Placeholder.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -6,10 +6,7 @@ class sptensor3: - """ - SPTENSOR3 a sparse tensor variant. - - """ + """A sparse tensor variant.""" def __init__(self): assert False, "SPTENSOR3 class not yet implemented" diff --git a/pyttb/sumtensor.py b/pyttb/sumtensor.py index 771b028a..f6be9bf9 100644 --- a/pyttb/sumtensor.py +++ b/pyttb/sumtensor.py @@ -18,10 +18,7 @@ class sumtensor: - """ - SUMTENSOR Class for implicit sum of other tensors. - - """ + """Class for implicit sum of other tensors.""" def __init__( self, @@ -30,8 +27,8 @@ def __init__( ] = None, copy: bool = True, ): - """ - Creates a :class:`pyttb.sumtensor` from a collection of tensors. + """Create a :class:`pyttb.sumtensor` from a collection of tensors. + Each provided tensor is explicitly retained. All provided tensors must have the same shape but can be combinations of types. @@ -43,7 +40,7 @@ def __init__( Whether to make a copy of provided data or just reference it. Examples - ------- + -------- Create an empty :class:`pyttb.tensor`: >>> T1 = ttb.tenones((3, 4, 5)) @@ -85,17 +82,18 @@ def copy(self) -> sumtensor: return ttb.sumtensor(self.parts, copy=True) def __deepcopy__(self, memo): + """Return deepcopy of this sumtensor.""" return self.copy() @property def shape(self) -> Tuple[int, ...]: + """Shape of a :class:`pyttb.sumtensor`.""" if len(self.parts) == 0: return () return self.parts[0].shape def __repr__(self): - """ - String representation of the sumtensor. + """Return string representation of the sumtensor. Returns ------- @@ -247,6 +245,13 @@ def __radd__(self, other): """ return self.__add__(other) + def to_tensor(self) -> ttb.tensor: + """Return sumtensor converted to dense tensor. + + Same as :meth:`pyttb.sumtensor.full`. + """ + return self.full() + def full(self) -> ttb.tensor: """ Convert a :class:`pyttb.sumtensor` to a :class:`pyttb.tensor`. @@ -292,9 +297,7 @@ def double(self) -> np.ndarray: def innerprod( self, other: Union[ttb.tensor, ttb.sptensor, ttb.ktensor, ttb.ttensor] ) -> float: - """ - Efficient inner product between a sumtensor and other `pyttb` tensors - (`tensor`, `sptensor`, `ktensor`, or `ttensor`). + """Efficient inner product between a sumtensor and other `pyttb` tensors. Parameters ---------- @@ -321,8 +324,9 @@ def innerprod( def mttkrp( self, U: Union[ttb.ktensor, List[np.ndarray]], n: Union[int, np.integer] ) -> np.ndarray: - """ - Matricized tensor times Khatri-Rao product. The matrices used in the + """Matricized tensor times Khatri-Rao product. + + The matrices used in the Khatri-Rao product are passed as a :class:`pyttb.ktensor` (where the factor matrices are used) or as a list of :class:`numpy.ndarray` objects. @@ -430,7 +434,7 @@ def ttv( return ttb.sumtensor(new_parts, copy=False) def norm(self) -> float: - """Compatibility Interface. Just returns 0""" + """Compatibility Interface. Just returns 0.""" warnings.warn( "Sumtensor doesn't actually support norm. " "Returning 0 for compatibility." ) diff --git a/pyttb/symktensor.py b/pyttb/symktensor.py index 398e77a5..a9a11591 100644 --- a/pyttb/symktensor.py +++ b/pyttb/symktensor.py @@ -1,4 +1,4 @@ -"""Symmetric Kruskal Tensor Class Placeholder""" +"""Symmetric Kruskal Tensor Class Placeholder.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -6,10 +6,7 @@ class symktensor: - """ - SYMKTENSOR Class for symmetric Kruskal tensors (decomposed). - - """ + """Class for symmetric Kruskal tensors (decomposed).""" def __init__(self): assert False, "SYMKTENSOR class not yet implemented" diff --git a/pyttb/symtensor.py b/pyttb/symtensor.py index d1eb548a..a95d8007 100644 --- a/pyttb/symtensor.py +++ b/pyttb/symtensor.py @@ -1,4 +1,4 @@ -"""Symmetric Tensor Class Placeholder""" +"""Symmetric Tensor Class Placeholder.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -6,10 +6,7 @@ class symtensor: - """ - SYMTENSOR Class for storing only unique entries of symmetric tensor. - - """ + """Class for storing only unique entries of symmetric tensor.""" def __init__(self): assert False, "SYMTENSOR class not yet implemented" diff --git a/pyttb/tenmat.py b/pyttb/tenmat.py index 531be953..482cbca5 100644 --- a/pyttb/tenmat.py +++ b/pyttb/tenmat.py @@ -16,10 +16,7 @@ class tenmat: - """ - TENMAT Store tensor as a matrix. - - """ + """Store tensor as a matrix.""" __slots__ = ("tshape", "rindices", "cindices", "data") @@ -31,8 +28,8 @@ def __init__( # noqa: PLR0912 tshape: Optional[Shape] = None, copy: bool = True, ): - """ - Construct a :class:`pyttb.tenmat` from explicit components. + """Construct a :class:`pyttb.tenmat` from explicit components. + If you already have a tensor see :meth:`pyttb.tensor.to_tenmat`. Parameters @@ -87,7 +84,6 @@ def __init__( # noqa: PLR0912 [[4., 5.], [6., 7.]]]) """ - # Case 0a: Empty Constructor # data is empty, return empty tenmat unless rdims, cdims, or tshape are # not empty @@ -202,6 +198,7 @@ def copy(self) -> tenmat: ) def __deepcopy__(self, memo): + """Return deep copy of this tenmat.""" return self.copy() def to_tensor(self, copy: bool = True) -> ttb.tensor: @@ -338,8 +335,7 @@ def ndims(self) -> int: return len(self.shape) def norm(self) -> float: - """ - Frobenius norm of a :class:`pyttb.tenmat`. + """Frobenius norm of a :class:`pyttb.tenmat`. Examples -------- @@ -556,7 +552,6 @@ def __add__(self, other): ------- :class:`pyttb.tenmat` """ - # One argument is a scalar if np.isscalar(other): Z = self.copy() @@ -627,7 +622,6 @@ def __sub__(self, other): ------- :class:`pyttb.tenmat` """ - # One argument is a scalar if np.isscalar(other): Z = self.copy() @@ -666,7 +660,6 @@ def __rsub__(self, other): ------- :class:`pyttb.tenmat` """ - # One argument is a scalar if np.isscalar(other): Z = self.copy() @@ -702,7 +695,6 @@ def __pos__(self): :class:`pyttb.tenmat` copy of tenmat """ - T = self.copy() return T @@ -727,15 +719,13 @@ def __neg__(self): :class:`pyttb.tenmat` Copy of original tenmat with negated data. """ - T = self.copy() T.data = -1 * T.data return T def __repr__(self): - """ - String representation of a :class:`pyttb.tenmat`. + """Return string representation of a :class:`pyttb.tenmat`. Examples -------- diff --git a/pyttb/tensor.py b/pyttb/tensor.py index b527f949..465522d9 100644 --- a/pyttb/tensor.py +++ b/pyttb/tensor.py @@ -8,6 +8,7 @@ import logging from collections.abc import Iterable +from inspect import signature from itertools import combinations_with_replacement, permutations from math import factorial, prod from typing import ( @@ -43,7 +44,6 @@ tt_ind2sub, tt_sub2ind, tt_subsubsref, - tt_tenfun, ) @@ -77,8 +77,7 @@ def __init__( shape: Optional[Shape] = None, copy: bool = True, ): - """ - Creates a :class:`pyttb.tensor` from a :class:`numpy.ndarray` + """Create a :class:`pyttb.tensor` from a :class:`numpy.ndarray`. Note that 1D tensors (i.e., when len(shape)==1) contains a data array that follow the Numpy convention of being a row vector. @@ -93,7 +92,7 @@ def __init__( Whether to make a copy of provided data or just reference it. Examples - ------- + -------- Create an empty :class:`pyttb.tensor`: >>> T = ttb.tensor() @@ -146,7 +145,7 @@ def __init__( if copy: self.data = data.copy(self.order) else: - if not self.matches_order(data): + if not self._matches_order(data): logging.warning( f"Selected no copy, but input data isn't {self.order} ordered " "so must copy." @@ -160,7 +159,8 @@ def order(self) -> Literal["F"]: """Return the data layout of the underlying storage.""" return "F" - def matches_order(self, array: np.ndarray) -> bool: + 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": @@ -173,9 +173,7 @@ def from_function( function_handle: Callable[[Tuple[int, ...]], np.ndarray], shape: Shape, ) -> tensor: - """ - Construct a :class:`pyttb.tensor` whose data entries are set using - a function. + """Construct a :class:`pyttb.tensor` with data from a function. Parameters ---------- @@ -240,11 +238,12 @@ def copy(self) -> tensor: return ttb.tensor(self.data, self.shape, copy=True) def __deepcopy__(self, memo): + """Return deep copy of this tensor.""" return self.copy() def collapse( self, - dims: Optional[np.ndarray] = None, + dims: Optional[OneDArray] = None, fun: Callable[[np.ndarray], Union[float, np.ndarray]] = np.sum, ) -> Union[float, np.ndarray, tensor]: """ @@ -281,10 +280,11 @@ def collapse( if dims is None: dims = np.arange(0, self.ndims) + dims, _ = tt_dimscheck(self.ndims, dims=dims) + if dims.size == 0: return self.copy() - dims, _ = tt_dimscheck(self.ndims, dims=dims) remdims = np.setdiff1d(np.arange(0, self.ndims), dims) # Check for the case where we accumulate over *all* dimensions @@ -456,8 +456,7 @@ def find(self) -> Tuple[np.ndarray, np.ndarray]: return subs, vals def to_sptensor(self) -> ttb.sptensor: - """ - Contruct a :class:`pyttb.sptensor` from `:class:pyttb.tensor` + """Contruct a :class:`pyttb.sptensor` from `:class:pyttb.tensor`. Returns ------- @@ -500,9 +499,7 @@ def to_tenmat( ] = None, copy: bool = True, ) -> ttb.tenmat: - """ - Construct a :class:`pyttb.tenmat` from a :class:`pyttb.tensor` and - unwrapping details. + """Construct a :class:`pyttb.tenmat` from a :class:`pyttb.tensor`. Parameters ---------- @@ -617,9 +614,7 @@ def to_tenmat( def innerprod( self, other: Union[tensor, ttb.sptensor, ttb.ktensor, ttb.ttensor] ) -> float: - """ - Efficient inner product between a tensor and other `pyttb` tensors - (`tensor`, `sptensor`, `ktensor`, or `ttensor`). + """Efficient inner product between a tensor and other `pyttb` tensors. Parameters ---------- @@ -815,7 +810,7 @@ def logical_and(self, other: Union[float, tensor]) -> tensor: def logical_and(x, y): return np.logical_and(x, y).astype(dtype=x.dtype) - return tt_tenfun(logical_and, self, other) + return self.tenfun(logical_and, other) def logical_not(self) -> tensor: """ @@ -849,7 +844,7 @@ def logical_or(self, other: Union[float, tensor]) -> tensor: def tensor_or(x, y): return np.logical_or(x, y).astype(x.dtype) - return tt_tenfun(tensor_or, self, other) + return self.tenfun(tensor_or, other) def logical_xor(self, other: Union[float, tensor]) -> tensor: """ @@ -870,7 +865,7 @@ def logical_xor(self, other: Union[float, tensor]) -> tensor: def tensor_xor(x, y): return np.logical_xor(x, y).astype(dtype=x.dtype) - return tt_tenfun(tensor_xor, self, other) + return self.tenfun(tensor_xor, other) def mask(self, W: tensor) -> np.ndarray: """ @@ -905,8 +900,9 @@ def mask(self, W: tensor) -> np.ndarray: def mttkrp( self, U: Union[ttb.ktensor, Sequence[np.ndarray]], n: Union[int, np.integer] ) -> np.ndarray: - """ - Matricized tensor times Khatri-Rao product. The matrices used in the + """Matricized tensor times Khatri-Rao product. + + The matrices used in the Khatri-Rao product are passed as a :class:`pyttb.ktensor` (where the factor matrices are used) or as a list of :class:`numpy.ndarray` objects. @@ -929,7 +925,6 @@ def mttkrp( array([[4., 4.], [4., 4.]]) """ - # check that we have a tensor that can perform mttkrp if self.ndims < 2: assert False, "MTTKRP is invalid for tensors with fewer than 2 dimensions" @@ -1047,8 +1042,9 @@ def nnz(self) -> int: return np.count_nonzero(self.data) def norm(self) -> float: - """ - Frobenius norm of the tensor, defined as the square root of the sum of the + """Frobenius norm of the tensor. + + Defined as the square root of the sum of the squares of the elements of the tensor. Examples @@ -1121,8 +1117,9 @@ def nvecs(self, n: int, r: int, flipsign: bool = True) -> np.ndarray: return v def permute(self, order: OneDArray) -> tensor: - """ - Permute tensor dimensions. The result is a tensor that has the + """Permute tensor dimensions. + + The result is a tensor that has the same values, but the order of the subscripts needed to access any particular element are rearranged as specified by `order`. @@ -1251,8 +1248,7 @@ def scale( return ttb.tenmat(result, dims, remdims, self.shape, copy=False).to_tensor() def squeeze(self) -> Union[tensor, float]: - """ - Removes singleton dimensions from the tensor. + """Remove singleton dimensions from the tensor. Returns ------- @@ -1782,6 +1778,168 @@ def ttsv( return y assert False, "Invalid value for version; should be None, 1, or 2" + def tenfun( + self, + function_handle: Union[ + Callable[[np.ndarray, np.ndarray], np.ndarray], + Callable[[np.ndarray], np.ndarray], + ], + *inputs: Union[ + float, + int, + np.ndarray, + ttb.tensor, + ttb.ktensor, + ttb.ttensor, + ttb.sptensor, + ttb.sumtensor, + ], + ) -> ttb.tensor: + """Apply a function to each element in a tensor or tensors. + + See :meth:`pyttb.tensor.tenfun_binary` and + :meth:`pyttb.tensor.tenfun_binary_unary` for supported + options. + """ + assert callable(function_handle), "function_handle must be callable" + + # Number of inputs for function handle + nfunin = len(signature(function_handle).parameters) + + # Case I: Binary function + if len(inputs) == 1 and nfunin == 2: + # We manually inspected the function handle for the parameters + # maybe there is a more clever way to convince mypy + binary_function_handle = cast( + Callable[[np.ndarray, np.ndarray], np.ndarray], function_handle + ) + Y = inputs[0] + if not isinstance(Y, (int, float)): + Y = self._tt_to_tensor(Y) + return self.tenfun_binary(binary_function_handle, Y) + + # Convert inputs to tensors if they aren't already + # Allow inputs to be mutable in case of type conversion + input_tensors: list[Union[ttb.tensor]] = [] + for an_input in inputs: + if not isinstance( + an_input, + ( + np.ndarray, + ttb.tensor, + ttb.ktensor, + ttb.ttensor, + ttb.sptensor, + ttb.sumtensor, + ), + ): + assert ( + False + ), f"Invalid input to ten fun: {an_input} of type {type(an_input)}" + input_tensors.append(self._tt_to_tensor(an_input)) + + # Case II: Expects input to be matrix and applies operation on each columns + if nfunin != 1: + raise ValueError( + "Tenfun only supports binary and unary function handles but provided " + "function handle takes {nfunin} arguments." + ) + unary_function_handle = cast( + Callable[[np.ndarray], np.ndarray], function_handle + ) + return self.tenfun_unary(unary_function_handle, *input_tensors) + + def tenfun_binary( + self, + function_handle: Callable[[np.ndarray, np.ndarray], np.ndarray], + other: Union[ttb.tensor, int, float], + first: bool = True, + ) -> ttb.tensor: + """Apply a binary operation to two tensors or a tensor and a scalar. + + Parameters + ---------- + function_handle: Function to apply. + other: Other input to the binary function. + first: Whether the tensor comes first in the method call (if ordering matters). + + Example + ------- + >>> add = lambda x, y: x + y + >>> t0 = ttb.tenones((2, 2)) + >>> t1 = t0.tenfun_binary(add, t0) + >>> t1.isequal(t0 * 2) + True + >>> t2 = t0.tenfun_binary(add, 1) + >>> t2.isequal(t1) + True + """ + X = self.data + if not isinstance(other, (float, int)): + Y = other.data + else: + Y = np.array(other) + + if not first: + Y, X = X, Y + data = function_handle(X, Y) + Z = ttb.tensor(data, copy=False) + return Z + + def tenfun_unary( + self, function_handle: Callable[[np.ndarray], np.ndarray], *inputs: ttb.tensor + ) -> ttb.tensor: + """Apply a unary operation to multiple tensors columnwise. + + Example + ------- + >>> tensor_max = lambda x: np.max(x, axis=0) + >>> data = np.array([[1, 2, 3], [4, 5, 6]]) + >>> t0 = ttb.tensor(data) + >>> t1 = ttb.tensor(data) + >>> t2 = t0.tenfun_unary(tensor_max, t1) + >>> t2.isequal(t1) + True + """ + sz = self.shape + for i, an_input in enumerate(inputs): + if isinstance(an_input, (float, int)): + assert False, f"Argument {i} is a scalar but expected a tensor" + elif sz != an_input.shape: + assert ( + False + ), f"Tensor {i} is not the same size as the first tensor input" + if len(inputs) == 0: + X = self.data + X = np.reshape(X, (1, -1)) + else: + X = np.zeros((len(inputs) + 1, np.prod(sz))) + X[0, :] = np.reshape(self.data, (np.prod(sz))) + for i, an_input in enumerate(inputs): + X[i + 1, :] = np.reshape(an_input.data, (np.prod(sz))) + data = function_handle(X) + data = np.reshape(data, sz) + Z = ttb.tensor(data, copy=False) + return Z + + def _tt_to_tensor( + self, + some_tensor: Union[ + np.ndarray, + ttb.tensor, + ttb.ktensor, + ttb.ttensor, + ttb.sptensor, + ttb.sumtensor, + ], + ) -> ttb.tensor: + """Convert a variety of data structures to a dense tensor.""" + if isinstance(some_tensor, np.ndarray): + return ttb.tensor(some_tensor) + elif isinstance(some_tensor, ttb.tensor): + return some_tensor + return some_tensor.to_tensor() + def __setitem__(self, key, value): """ Subscripted assignment for a tensor. @@ -2071,7 +2229,7 @@ def __eq__(self, other): def tensor_equality(x, y): return x == y - return tt_tenfun(tensor_equality, self, other) + return self.tenfun(tensor_equality, other) def __ne__(self, other): """ @@ -2103,7 +2261,7 @@ def __ne__(self, other): def tensor_not_equal(x, y): return x != y - return tt_tenfun(tensor_not_equal, self, other) + return self.tenfun(tensor_not_equal, other) def __ge__(self, other): """ @@ -2135,7 +2293,7 @@ def __ge__(self, other): def greater_or_equal(x, y): return x >= y - return tt_tenfun(greater_or_equal, self, other) + return self.tenfun(greater_or_equal, other) def __le__(self, other): """ @@ -2167,7 +2325,7 @@ def __le__(self, other): def less_or_equal(x, y): return x <= y - return tt_tenfun(less_or_equal, self, other) + return self.tenfun(less_or_equal, other) def __gt__(self, other): """ @@ -2199,7 +2357,7 @@ def __gt__(self, other): def greater(x, y): return x > y - return tt_tenfun(greater, self, other) + return self.tenfun(greater, other) def __lt__(self, other): """ @@ -2231,7 +2389,7 @@ def __lt__(self, other): def less(x, y): return x < y - return tt_tenfun(less, self, other) + return self.tenfun(less, other) def __sub__(self, other): """ @@ -2263,7 +2421,7 @@ def __sub__(self, other): def minus(x, y): return x - y - return tt_tenfun(minus, self, other) + return self.tenfun(minus, other) def __add__(self, other): """ @@ -2298,11 +2456,10 @@ def __add__(self, other): def tensor_add(x, y): return x + y - return tt_tenfun(tensor_add, self, other) + return self.tenfun(tensor_add, other) def __radd__(self, other): - """ - Right binary addition (+) for tensors + """Right binary addition (+) for tensors. Parameters ---------- @@ -2348,11 +2505,10 @@ def __pow__(self, power): def tensor_pow(x, y): return x**y - return tt_tenfun(tensor_pow, self, power) + return self.tenfun(tensor_pow, power) def __mul__(self, other): - """ - Element-wise multiplication (*) for tensors, self*other + """Element-wise multiplication (*) for tensors, self*other. Parameters ---------- @@ -2383,11 +2539,10 @@ def mul(x, y): if isinstance(other, (ttb.ktensor, ttb.sptensor, ttb.ttensor)): other = other.full() - return tt_tenfun(mul, self, other) + return self.tenfun(mul, other) def __rmul__(self, other): - """ - Element wise right multiplication (*) for tensors, other*self + """Element wise right multiplication (*) for tensors, other*self. Parameters ---------- @@ -2409,8 +2564,7 @@ def __rmul__(self, other): return self.__mul__(other) def __truediv__(self, other): - """ - Element-wise left division (/) for tensors, self/other + """Element-wise left division (/) for tensors, self/other. Parameters ---------- @@ -2441,11 +2595,10 @@ def div(x, y): with np.errstate(divide="ignore", invalid="ignore"): return x / y - return tt_tenfun(div, self, other) + return self.tenfun(div, other) def __rtruediv__(self, other): - """ - Element wise right division (/) for tensors, other/self + """Element wise right division (/) for tensors, other/self. Parameters ---------- @@ -2472,7 +2625,7 @@ def div(x, y): with np.errstate(divide="ignore", invalid="ignore"): return x / y - return tt_tenfun(div, other, self) + return self.tenfun_binary(div, other, first=False) def __pos__(self): """ @@ -2510,12 +2663,10 @@ def __neg__(self): [[-1 -2] [-3 -4]] """ - return ttb.tensor(-1 * self.data) def __repr__(self): - """ - String representation of the tensor. + """Return string representation of the tensor. Returns ------- @@ -2574,8 +2725,7 @@ def __repr__(self): def tenones(shape: Shape, order: Union[Literal["F"], Literal["C"]] = "F") -> tensor: - """ - Creates a tensor of all ones. + """Create a tensor of all ones. Parameters ---------- @@ -2611,8 +2761,7 @@ def ones(shape: Tuple[int, ...]) -> np.ndarray: def tenzeros(shape: Shape, order: Union[Literal["F"], Literal["C"]] = "F") -> tensor: - """ - Creates a tensor of all zeros. + """Create a tensor of all zeros. Parameters ---------- @@ -2648,9 +2797,7 @@ def zeros(shape: Tuple[int, ...]) -> np.ndarray: def tenrand(shape: Shape, order: Union[Literal["F"], Literal["C"]] = "F") -> tensor: - """ - Creates a tensor with entries drawn from a uniform - distribution on the unit interval. + """Create a tensor with entries drawn from a uniform distribution on [0, 1]. Parameters ---------- @@ -2689,9 +2836,9 @@ def tendiag( shape: Optional[Shape] = None, order: Union[Literal["F"], Literal["C"]] = "F", ) -> tensor: - """ - Creates a tensor with elements along super diagonal. If provided shape is too - small the tensor will be enlarged to accomodate. + """Create a tensor with elements along super diagonal. + + If provided shape is too small the tensor will be enlarged to accomodate. Parameters ---------- @@ -2781,8 +2928,8 @@ def teneye( def mttv_left(W_in: np.ndarray, U1: np.ndarray) -> np.ndarray: - """ - Contract leading mode in partial MTTKRP W_in using factor matrix U1. + """Contract leading mode in partial MTTKRP W_in using factor matrix U1. + The leading mode is the mode for which consecutive increases in index address elements at consecutive increases in the memory offset. diff --git a/pyttb/ttensor.py b/pyttb/ttensor.py index cab3665e..deb0eeac 100644 --- a/pyttb/ttensor.py +++ b/pyttb/ttensor.py @@ -1,4 +1,4 @@ -"""Tucker Tensor Implementation""" +"""Tucker Tensor Implementation.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -23,9 +23,7 @@ class ttensor: - """ - TTENSOR Class for Tucker tensors (decomposed). - """ + """Class for Tucker tensors (decomposed).""" __slots__ = ("core", "factor_matrices") @@ -124,10 +122,11 @@ def copy(self) -> ttensor: return ttb.ttensor(self.core, self.factor_matrices, copy=True) def __deepcopy__(self, memo): + """Return deepcopy of class.""" return self.copy() def _validate_ttensor(self): - """Verifies the validity of constructed ttensor""" + """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)): @@ -161,8 +160,7 @@ def shape(self) -> Tuple[int, ...]: return tuple(factor.shape[0] for factor in self.factor_matrices) def __repr__(self): # pragma: no cover - """ - String representation of a tucker tensor. + """Return string representation of a tucker tensor. Returns ------- @@ -170,7 +168,8 @@ def __repr__(self): # pragma: no cover 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") + 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" @@ -181,7 +180,8 @@ def __repr__(self): # pragma: no cover __str__ = __repr__ def to_tensor(self) -> ttb.tensor: - """Convenience method to convert to tensor. + """Convert to tensor. + Same as :meth:`pyttb.ttensor.full` """ return self.full() @@ -196,8 +196,7 @@ def full(self) -> ttb.tensor: return recomposed_tensor def double(self) -> np.ndarray: - """ - Convert ttensor to an array of doubles + """Convert ttensor to an array of doubles. Returns ------- @@ -217,8 +216,7 @@ def ndims(self) -> int: return len(self.factor_matrices) def isequal(self, other: ttensor) -> bool: - """ - Component equality for ttensors + """Component equality for ttensors. Parameters ---------- @@ -248,12 +246,10 @@ def __pos__(self): ------- :class:`pyttb.ttensor`, copy of tensor """ - return self.copy() def __neg__(self): - """ - Unary minus (-) for ttensors + """Unary minus (-) for ttensors. Returns ------- @@ -264,8 +260,7 @@ def __neg__(self): def innerprod( self, other: Union[ttb.tensor, ttb.sptensor, ttb.ktensor, ttb.ttensor] ) -> float: - """ - Efficient inner product with a ttensor + """Efficient inner product with a ttensor. Parameters ---------- @@ -313,8 +308,7 @@ def innerprod( ) def __mul__(self, other): - """ - Element wise multiplication (*) for ttensors (only scalars supported) + """Element wise multiplication (*) for ttensors (only scalars supported). Parameters ---------- @@ -332,8 +326,7 @@ def __mul__(self, other): ) def __rmul__(self, other): - """ - Element wise right multiplication (*) for ttensors (only scalars supported) + """Element wise right multiplication (*) for ttensors (only scalars supported). Parameters ---------- @@ -353,8 +346,7 @@ def ttv( dims: Optional[OneDArray] = None, exclude_dims: Optional[OneDArray] = None, ) -> Union[float, ttensor]: - """ - TTensor times vector + """TTensor times vector. Parameters ---------- @@ -436,6 +428,7 @@ def mttkrp( def norm(self) -> float: """ Compute the norm of a ttensor. + Returns ------- Frobenius norm of Tensor. @@ -481,8 +474,7 @@ def ttm( exclude_dims: Optional[Union[int, np.ndarray]] = None, transpose: bool = False, ) -> ttensor: - """ - Tensor times matrix for ttensor + """Tensor times matrix for ttensor. Parameters ---------- diff --git a/pyttb/tucker_als.py b/pyttb/tucker_als.py index 430e404f..aff16785 100644 --- a/pyttb/tucker_als.py +++ b/pyttb/tucker_als.py @@ -1,4 +1,4 @@ -"""Tucker decomposition via Alternating Least Squares""" +"""Tucker decomposition via Alternating Least Squares.""" # Copyright 2024 National Technology & Engineering Solutions of Sandia, # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the @@ -25,8 +25,7 @@ def tucker_als( # noqa: PLR0912, PLR0913, PLR0915 init: Union[Literal["random"], Literal["nvecs"], ttb.ktensor] = "random", printitn: int = 1, ) -> Tuple[ttensor, ttensor, Dict]: - """ - Compute Tucker decomposition with alternating least squares + """Compute Tucker decomposition with alternating least squares. Parameters ---------- diff --git a/tests/test_pyttb_utils.py b/tests/test_pyttb_utils.py index f58a4284..f9e66f4a 100644 --- a/tests/test_pyttb_utils.py +++ b/tests/test_pyttb_utils.py @@ -76,59 +76,6 @@ def test_tt_dimscheck(): assert "Negative dims" in str(excinfo), f"{str(excinfo)}" -def test_tt_tenfun(): - data = np.array([[1, 2, 3], [4, 5, 6]]) - t1 = ttb.tensor(data) - t2 = ttb.tensor(data) - - # Binary case - def add(x, y): - return x + y - - assert np.array_equal(ttb_utils.tt_tenfun(add, t1, t2).data, 2 * data) - - # Single argument case - def add1(x): - return x + 1 - - assert np.array_equal(ttb_utils.tt_tenfun(add1, t1).data, (data + 1)) - - # Multi argument case - def tensor_max(x): - return np.max(x, axis=0) - - assert np.array_equal(ttb_utils.tt_tenfun(tensor_max, t1, t1, t1).data, data) - # TODO: sptensor arguments, depends on fixing the indexing ordering - - # No np array case - assert np.array_equal(ttb_utils.tt_tenfun(tensor_max, data, data, data).data, data) - - # No argument case - with pytest.raises(AssertionError) as excinfo: - ttb_utils.tt_tenfun(tensor_max) - assert "Must provide element(s) to perform operation on" in str(excinfo) - - # No list case - with pytest.raises(AssertionError) as excinfo: - ttb_utils.tt_tenfun(tensor_max, [1, 2, 3]) - assert "Invalid input to ten fun" in str(excinfo) - - # Scalar argument not in first two positions - with pytest.raises(AssertionError) as excinfo: - ttb_utils.tt_tenfun(tensor_max, t1, t1, 1) - assert "Argument 2 is a scalar but expected a tensor" in str(excinfo) - - # Tensors of different sizes - with pytest.raises(AssertionError) as excinfo: - ttb_utils.tt_tenfun( - tensor_max, - t1, - t1, - ttb.tensor(np.concatenate((data, np.array([[7, 8, 9]])))), - ) - assert "Tensor 2 is not the same size as the first tensor input" in str(excinfo) - - def test_tt_setdiff_rows(): a = np.array([[4, 6], [1, 9], [2, 6], [2, 6], [99, 0]]) b = np.array( @@ -339,12 +286,6 @@ def test_tt_subsubsref_valid(): assert True -def test_tt_intvec2str_valid(): - """This function is slotted to be removed because it is probably unnecessary in python""" - v = np.array([1, 2, 3]) - assert ttb_utils.tt_intvec2str(v) == "[1 2 3]" - - def test_tt_sizecheck_empty(): assert ttb_utils.tt_sizecheck(()) @@ -470,7 +411,7 @@ def test_islogical_invalid(): def test_get_index_variant_linear(): assert ttb_utils.get_index_variant(1) == ttb_utils.IndexVariant.LINEAR - assert ttb_utils.get_index_variant(1.0) == ttb_utils.IndexVariant.LINEAR + assert ttb_utils.get_index_variant(1.0) == ttb_utils.IndexVariant.UNKNOWN assert ttb_utils.get_index_variant(slice(1, 5)) == ttb_utils.IndexVariant.LINEAR assert ttb_utils.get_index_variant(np.int32(2)) == ttb_utils.IndexVariant.LINEAR assert ( diff --git a/tests/test_tensor.py b/tests/test_tensor.py index 762a67c1..04e22b51 100644 --- a/tests/test_tensor.py +++ b/tests/test_tensor.py @@ -1777,3 +1777,62 @@ def test_mttkrps(): np.allclose(a_direct, an_optimized) for a_direct, an_optimized in zip(direct, optimized) ) + + +def test_tenfun(): + data = np.array([[1, 2, 3], [4, 5, 6]]) + t1 = ttb.tensor(data) + t2 = ttb.tensor(data) + + # Binary case + def add(x, y): + return x + y + + assert np.array_equal(t1.tenfun(add, t2).data, 2 * data) + + # Single argument case + def add1(x): + return x + 1 + + assert np.array_equal(t1.tenfun(add1).data, (data + 1)) + + # Multi argument case + def tensor_max(x): + return np.max(x, axis=0) + + assert np.array_equal(t1.tenfun(tensor_max, t1, t1).data, data) + # TODO: sptensor arguments, depends on fixing the indexing ordering + + # No np array case + assert np.array_equal(t1.tenfun(tensor_max, data, data).data, data) + + # No list case + with pytest.raises(AssertionError) as excinfo: + t1.tenfun(tensor_max, [1, 2, 3]) + assert "Invalid input to ten fun" in str(excinfo) + + # Scalar argument not in first two positions + with pytest.raises(AssertionError) as excinfo: + t1.tenfun(tensor_max, t1, 1) + assert "Invalid input to ten fun" in str(excinfo) + + # Tensors of different sizes + with pytest.raises(AssertionError) as excinfo: + t1.tenfun( + tensor_max, + t1, + ttb.tensor(np.concatenate((data, np.array([[7, 8, 9]])))), + ) + assert "Tensor 1 is not the same size as the first tensor input" in str(excinfo) + + with pytest.raises(ValueError) as excinfo: + + def three_arg_function(x, y, z): + pass + + t1.tenfun(three_arg_function) + assert "only supports binary and unary function handles" in str(excinfo) + + with pytest.raises(AssertionError) as excinfo: + _ = t1.tenfun_unary(add1, 1) + assert "scalar but expected a tensor" in str(excinfo)