diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 00000000..ec703542 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,39 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/regression-tests.yml new file mode 100644 index 00000000..d5361eb2 --- /dev/null +++ b/.github/workflows/regression-tests.yml @@ -0,0 +1,47 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Regression tests + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install coverage coveralls sphinx_rtd_theme + pip install ".[dev]" + - name: Check auto-formatters + run: | + isort --check . + black --check . +# - name: Lint with flake8 +# run: | +# # stop the build if there are Python syntax errors or undefined names +# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics +# # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide +# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Run tests + run: | + coverage run --source pyttb -m pytest tests/ + coverage report + - name: Upload coverage to Coveralls + uses: coverallsapp/github-action@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..be20376a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: + - repo: https://github.com/pycqa/isort + rev: 5.11.5 + hooks: + - id: isort + name: isort (python) + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + language_version: python diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..48384bd3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,109 @@ +# v1.6.1 (2023-04-27) +- New: + - Tensor generator helpers: + - `tenones`, `tenzeros`, `tendiag`, `sptendiag` (PR https://github.com/sandialabs/pyttb/pull/93) + - `tenrand`, `sptenrand` (PR https://github.com/sandialabs/pyttb/pull/100) + - Moved to using `logging` instead of `warnings` (PR https://github.com/sandialabs/pyttb/pull/99) +- Documentation: + - Completed: `ktensor` (PR https://github.com/sandialabs/pyttb/pull/101) + - Fixed linking for new classes (PR https://github.com/sandialabs/pyttb/pull/98) +# v1.6.0 (2023-04-16) +- API Change (PR https://github.com/sandialabs/pyttb/pull/91) + - *Not backwards compatible* + - `pyttb_utils.tt_dimscheck` + - Addresses ambiguity of -0 by using `exclude_dims` (`numpy.ndarray`) parameter + - `ktensor.ttv`, `sptensor.ttv`, `tensor.ttv`, `ttensor.ttv` + - Use `exlude_dims` parameter instead of `-dims` + - Explicit nameing of dimensions to exclude + - `tensor.ttsv` + - Use `skip_dim` (`int`) parameter instead of `-dims` + - Exclude all dimensions up to and including `skip_dim` +- Fixes/Completed: + - Code cleaning: minor changes associated with replacing `-dims` with `exclude_dims`/`skip_dim` + - Authorship: PyPI only allows one author, changing to current POC + +# v1.5.1 (2023-04-14) +- New: + - Dev Support: + - Linting: support for `pyttb_utils` and `sptensor` (PR https://github.com/sandialabs/pyttb/pull/77) + - Pre-commit: support @ntjohnson1 in (PR https://github.com/sandialabs/pyttb/pull/83) +- Fixed/Completed: + - `hosvd`: Negative signs can be permuted for equivalent decomposition (PR https://github.com/sandialabs/pyttb/pull/82) + - Versioning: using dynamic version in pyproject.toml (PR https://github.com/sandialabs/pyttb/pull/86) + - Package Testing: fixed problem with subprocesses (PR https://github.com/sandialabs/pyttb/pull/87) + +# v1.5.0 (2023-03-19) +- New: + - Added `hosvd` Tuecker decomposition (Issue #56, PR #67) + - Added `tucker_als` Tuecker decomposition (PR #66) + - Autoformatting using `black` and `isort` (Issue #59, PR #60) +- Updated/Ongoing: + - Included more testing for improved coverage (Issue #78, PR #79) + +# v1.4.0 (2023-02-21) +- New: + - Added `ttensor` class and associated tests (Issue #10, PR #51) +- Fixed/Completed: + - Tensor slicing now passes through to `numpy` array slicing (Issue #41, PR #50) +- Updated/Ongoing: + - Included more testing for improved coverage (Issue #14, PR #52) + +# v1.3.9 (2023-02-20) +- Remove deprecated `numpy` code associated with aliases to built-in types and ragged arrays (Issue #48, PR #49) + +# v1.3.8 (2022-10-12) +- Fixed `pyttb_utils.tt_ind2sub` (Issue #45, PR #47) +- Implemented `ktensor.score` (Issue #46, PR #47) + +# v1.3.7 (2022-07-17) +- Fixed `tenmat` to accept empty arrays for `rdims` or `cdims` (Issue #42, PR #43) +- Implemented `tensor.ttt` (Issue #28, PR #44) +- Adding GitHub action to publish releases to PyPi + +# v1.3.6 (2022-07-15) +- Implemented `tensor.ttm` (Issue #27, PR #40) + +# v1.3.5 (2022-07-12) +- Fixing `np.reshape` in `tensor.ttv` (Issue #37, PR #38) +- Fixing `np.reshape` in remainder of `tensor` (Issue #30, PR #39) + +# v1.3.4 (2022-07-12) +- Fixing issues with PyPi uploads + +# v1.3.3 (2022-07-11) +- Fixed indexing bug in `tensor.mttkrp` (Issue #35, PR #36) +- Updated LICENSE to compliant format (Issue #33 , PR #34) +- Now using [coveralls.io](https://coveralls.io/github/sandialabs/pyttb) for coverage reporting +- Now using [readthedocs.io](https://pyttb.readthedocs.io/en/latest/) for documentation + +# v1.3.2 (2022-07-06) +- Update `tensor.nvecs` to use `tenmat` (Issue #25, PR #31) +- Full implementation of `tensor.collapse` (Issue #2, PR #32) +- Added `CHANGELOG.md` + +# v1.3.1 (2022-07-01) +- Using `pyttb.__version__` for specifying package version in code and docs +- Implemented `tenmat.__setitem__` and tests (#23) +- Fix warnings in `cp_apr` associated with divide by zero (#13) +- Several documentation fixes. + +# v1.3.0 (2022-07-01) +- Changed package name to `pyttb` (#24) + +# v1.2.0 (2022-07-01) +- Added `tenmat` class and associated tests (#8) +- Added `tensor.__rmul__` for preadding scalars (#18) +- Fixed error in `sptensor.__lt__` that led to creation of large boolean tensors when comparing with 0 (#15) +- Matched output of `cp_als` to Matlab (#17) + +# v1.1.1 (2022-06-29) +- Fixed `tensor/mttkrp` use of `np.reshape` (#16) +- Now updating version numbers in `setup.py` + +# v1.1.0 (2022-06-27) +- Fixed `import_data` method +- New `export_data` method +- More testing + +# v1.0.0 (2022-06-27) +- Initial release of Python Tensor Toolbox diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..f0c14f69 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Python Tensor Toolbox Contributor Guide + +## Issues +If you are looking to get started or want to propose a change please start by checking +current or filing a new [issue](https://github.com/sandialabs/pyttb/issues). + +## Working on PYTTB locally +1. clone your fork and enter the directory + ``` + $ git clone git@github.com:/pyttb.git + $ cd pyttb + ``` + 1. setup your desired python environment as appropriate + +1. install dependencies + ``` + $ pip install -e ".[dev]" + $ make install_dev # shorthand for above + ``` + +1. Checkout a branch and make your changes + ``` + git checkout -b my-new-feature-branch + ``` +1. Formatters and linting + 1. Run autoformatters from root of project (they will change your code) + ```commandline + $ isort . + $ black . + ``` + 1. [We](./.pre-commit-config.yaml) optionally support [pre-commit hooks](https://pre-commit.com/) for this + 1. Pylint and mypy coverage is work in progress (these only raise errors) + ```commandline + mypy pyttb/ + pylint pyttb/file_name.py //Today only tensor is compliant + ``` + +1. Run tests (at desired fidelity) + 1. Just doctests (enabled by default) + ```commandline + pytest + ``` + 1. Functional tests + ```commandline + pytest . + ``` + 1. All tests (linting and formatting checks) + ```commandline + pytest . --packaging + ``` + 1. With coverage + ```commandline + pytest . --cov=pyttb --cov-report=term-missing + ``` \ No newline at end of file diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index fbafa5d1..b463e591 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -8,9 +8,17 @@ U.S. Government retains certain rights in this software. ## Contributors -**Primary POC:** Danny Dunlavy, dmdunla@sandia.gov +**Primary POC:** [Danny Dunlavy](@dmdunla) -**Contributors:** -* Danny Dunlavy - original author, `sptensor`, `ktensor`, `tensor`, `tenmat`, `cp_als`, `cp_apr` -* Nick Johnson - original author, `sptensor`, `ktensor`, `tensor`, `cp_als`, `cp_apr` -* Derek Tucker - `tensor` +**Main Developers:** + +- [Danny Dunlavy](@dmdunla) - original author, `sptensor`, `ktensor`, `tensor`, `tenmat`, `cp_als`, `cp_apr`, +[PRs](https://github.com/sandialabs/pyttb/commits?author=dmdunla) +- [Nick Johnson](@ntjohnson1) - original author, `sptensor`, `ktensor`, `tensor`, `ttensor`, `cp_als`, `cp_apr`, +[PRs](https://github.com/sandialabs/pyttb/commits?author=ntjohnson1) + +**Other Contributors:** +- [Brian Kelley](@brian_kelley) - `numpy` compatability, +[PRs](https://github.com/sandialabs/pyttb/commits?author=brian-kelley) +- [Derek Tucker](@jdtuck)[#1] - `tensor`, +[PRs](https://github.com/sandialabs/pyttb/commits?author=jdtuck) diff --git a/LICENSE b/LICENSE index 6f043885..e711b3a3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,8 +1,7 @@ BSD 2-Clause License -Copyright 2022 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. +Copyright (c) 2022, 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. +All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/Makefile b/Makefile index ff657774..6fe744d1 100644 --- a/Makefile +++ b/Makefile @@ -14,9 +14,24 @@ BUILDDIR = ./docs/build # Put it first so that "make" without argument is like "make help". help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @echo "install: Install release build" + @echo "install_dev: Install dev build" + @echo "install_docs: Install docs build" + @echo "docs_help: Show additional docs commands" + +.PHONY: help install install_dev install_docs Makefile + +install: + python -m pip install -e . -.PHONY: help Makefile +install_dev: + python -m pip install -e ".[dev]" + +install_docs: + python -m pip install -e ".[doc]" + +docs_help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). diff --git a/README.md b/README.md index e01da647..5a9c7fc5 100644 --- a/README.md +++ b/README.md @@ -6,30 +6,26 @@ U.S. Government retains certain rights in this software. # pyttb: Python Tensor Toolbox -## Contributors -* Danny Dunlavy, Nick Johnson, Derek Tucker +Welcome to `pyttb`, a set of Python classes and methods functions for +manipulating dense, sparse, and structured tensors, along with algorithms +for computing low-rank tensor models. -## Quick start +**Tensor Classes:** +* `tensor`: dense tensors +* `sptensor`: sparse tensors +* `ktensor`: Kruskal tensors +* `tenmat`: matricized tensors +* `ttensor`: Tucker tensors -### Install -* User: ```python setup.py install``` -* Developer: ```python setup.py develop``` +**Tensor Algorithms:** +* `cp_als`, `cp_apr`: Canonical Polyadic (CP) decompositions +* `tucker_als`: Tucker decompostions -### Testing -``` -python -m pytest -``` +# Getting Started +Check out the [Documentation](https://pyttb.readthedocs.io) to get started. -### Coverage Testing -``` -pytest --cov=pyttb tests/ --cov-report=html -# output can be accessed via htmlcov/index.html -``` - -### Documentation -``` -# requires `sphinx` -sphinx-build ./docs/source ./docs/build/html -# output can be accessed via docs/build/html/index.html -``` +# Contributing +Check out our [contributing guide](CONTRIBUTING.md). +--- +[![Regression tests](https://github.com/sandialabs/pyttb/actions/workflows/regression-tests.yml/badge.svg)](https://github.com/sandialabs/pyttb/actions/workflows/regression-tests.yml) [![Coverage Status](https://coveralls.io/repos/github/sandialabs/pyttb/badge.svg?branch=main)](https://coveralls.io/github/sandialabs/pyttb?branch=main) diff --git a/conftest.py b/conftest.py index 2ce8f734..4e39cc18 100644 --- a/conftest.py +++ b/conftest.py @@ -2,11 +2,30 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. +import numpy + # content of conftest.py import pytest -import numpy + import pyttb + + @pytest.fixture(autouse=True) def add_packages(doctest_namespace): - doctest_namespace['np'] = numpy - doctest_namespace['ttb'] = pyttb + doctest_namespace["np"] = numpy + doctest_namespace["ttb"] = pyttb + + +def pytest_addoption(parser): + parser.addoption( + "--packaging", + action="store_true", + dest="packaging", + default=False, + help="enable slow packaging tests", + ) + + +def pytest_configure(config): + if not config.option.packaging: + setattr(config.option, "markexpr", "not packaging") diff --git a/docs/source/algorithms.rst b/docs/source/algorithms.rst index dfd10da3..6c61222a 100644 --- a/docs/source/algorithms.rst +++ b/docs/source/algorithms.rst @@ -5,3 +5,5 @@ Algorithms cpals.rst cpapr.rst + hosvd.rst + tuckerals.rst diff --git a/docs/source/conf.py b/docs/source/conf.py index 9807dcc3..9f6af9df 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -18,22 +18,23 @@ # import os import sys -sys.path.insert(0, os.path.abspath('../../')) -sys.path.insert(0, os.path.abspath('../')) -sys.path.insert(0, os.path.abspath('.')) + +sys.path.insert(0, os.path.abspath("../../")) +sys.path.insert(0, os.path.abspath("../")) +sys.path.insert(0, os.path.abspath(".")) from pyttb import __version__ # -- Project information ----------------------------------------------------- -project = 'pyttb' -copyright = '' -author = 'Danny Dunlavy, Nick Johnson' +project = "pyttb" +copyright = "" +author = "Danny Dunlavy, Nick Johnson" # The short X.Y version version = __version__ # The full version, including alpha/beta/rc tags -release = '' +release = "" # -- General configuration --------------------------------------------------- @@ -46,11 +47,11 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', + "sphinx.ext.autodoc", "sphinx.ext.intersphinx", - 'sphinx.ext.mathjax', - 'sphinx.ext.viewcode', - 'sphinx.ext.napoleon' + "sphinx.ext.mathjax", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", ] napoleon_use_param = False @@ -59,26 +60,26 @@ intersphinx_mapping = { "numpy": ("http://docs.scipy.org/doc/numpy/", "numpy.inv"), "python": ("http://docs.python.org/3.6/", "python.inv"), - } +} # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -86,7 +87,7 @@ exclude_patterns = [] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # -- Options for HTML output ------------------------------------------------- @@ -94,7 +95,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -121,7 +122,7 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = 'pyttbdoc' +htmlhelp_basename = "pyttbdoc" # -- Options for LaTeX output ------------------------------------------------ @@ -130,26 +131,28 @@ # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # - 'pointsize': '10pt', - + "pointsize": "10pt", # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # - 'figure_align': 'htbp', + "figure_align": "htbp", } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'pyttb.tex', 'pyttb Documentation', - 'Danny Dunlavy, Nick Johnson', 'manual'), + ( + master_doc, + "pyttb.tex", + "pyttb Documentation", + "Danny Dunlavy, Nick Johnson", + "manual", + ), ] @@ -157,10 +160,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'pyttb', 'pyttb Documentation', - [author], 1) -] +man_pages = [(master_doc, "pyttb", "pyttb Documentation", [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -169,13 +169,19 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'pyttb', 'pyttb Documentation', - author, 'Danny Dunlavy, Nick Johnson', 'Python Tensor Toolbox', - 'Miscellaneous'), + ( + master_doc, + "pyttb", + "pyttb Documentation", + author, + "Danny Dunlavy, Nick Johnson", + "Python Tensor Toolbox", + "Miscellaneous", + ), ] # -- Extension configuration ------------------------------------------------- # Autodoc settings autoclass_content = "class" -autodoc_member_order = 'bysource' +autodoc_member_order = "bysource" diff --git a/docs/source/hosvd.rst b/docs/source/hosvd.rst index 691d64a6..5c26ed1b 100644 --- a/docs/source/hosvd.rst +++ b/docs/source/hosvd.rst @@ -1,2 +1,7 @@ pyttb.hosvd -=================== \ No newline at end of file +=================== + +.. automodule:: pyttb.hosvd + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/requirements.txt b/docs/source/requirements.txt new file mode 100644 index 00000000..8a4e97f0 --- /dev/null +++ b/docs/source/requirements.txt @@ -0,0 +1,9 @@ +ipython +nbsphinx +numpy +numpy_groupies +scipy +sphinx >= 3.5 +sphinx-argparse +sphinx-gallery +sphinx_rtd_theme diff --git a/docs/source/sptensor.rst b/docs/source/sptensor.rst index 82e47f00..c32f16cc 100644 --- a/docs/source/sptensor.rst +++ b/docs/source/sptensor.rst @@ -1,9 +1,9 @@ pyttb.sptensor ---------------------- -.. autoclass:: pyttb.sptensor +.. automodule:: pyttb.sptensor :members: :special-members: :exclude-members: __dict__,__weakref__ :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/source/tensor_classes.rst b/docs/source/tensor_classes.rst index 75b57d4e..70d93643 100644 --- a/docs/source/tensor_classes.rst +++ b/docs/source/tensor_classes.rst @@ -7,5 +7,6 @@ Tensor Classes ktensor.rst sptensor.rst tensor.rst + ttensor.rst tenmat.rst diff --git a/docs/source/ttensor.rst b/docs/source/ttensor.rst index dba06f77..77290058 100644 --- a/docs/source/ttensor.rst +++ b/docs/source/ttensor.rst @@ -1,2 +1,9 @@ pyttb.ttensor -===================== \ No newline at end of file +-------------------- + +.. automodule:: pyttb.ttensor + :members: + :special-members: + :exclude-members: __dict__,__weakref__ + :undoc-members: + :show-inheritance: diff --git a/docs/source/tuckerals.rst b/docs/source/tuckerals.rst index e4104d0b..528ee6f8 100644 --- a/docs/source/tuckerals.rst +++ b/docs/source/tuckerals.rst @@ -1,2 +1,7 @@ pyttb.tucker_als -======================== \ No newline at end of file +======================== + +.. automodule:: pyttb.tucker_als + :members: + :undoc-members: + :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..6a6e6db6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,81 @@ +[project] +name = "pyttb" +dynamic = ["version"] +description = "Python Tensor Toolbox" +authors = [ + {name="Daniel M. Dunlavy", email="dmdunla@sandia.gov"}, +] +license = { text="BSD 2-Clause License" } +readme = "README.md" +requires-python = ">=3.8" + +dependencies = [ + "numpy", + "numpy_groupies", + "scipy", +] + +classifiers = [ + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", +] + +[project.urls] +homepage = "https://github.com/sandialabs/pyttb" +coverage = "https://coveralls.io/github/sandialabs/pyttb" +documentation = "https://pyttb.readthedocs.io" + +[project.optional-dependencies] +dev = [ + "black", + "isort", + "pylint", + "mypy", + "pytest", + "pytest-cov", +] +doc = [ + "sphinx_rtd_theme", +] + +[tool.setuptools] +packages = ["pyttb"] + +[tool.setuptools.dynamic] +version = {attr = "pyttb.__version__"} + +[build-system] +requires = ["setuptools>=61.0", "numpy", "numpy_groupies", "scipy", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.isort] +profile = "black" + +[tool.pylint] +# Play nice with black +max-line-length = 88 +disable="fixme,too-many-lines" + +[tool.pylint.basic] +# To match MATLAB Tensortoolbox styles for clarity +argument-naming-style = "any" +class-naming-style = "any" +variable-naming-style = "any" + +[tool.pylint.design] +# MATLAB Tensortoolbox interface +max-public-methods = 40 + +[tool.mypy] +warn_unused_configs = true +plugins = "numpy.typing.mypy_plugin" + +[[tool.mypy.overrides]] +module = [ + "scipy", + "scipy.sparse", + "scipy.sparse.linalg", + "numpy_groupies" +] +ignore_missing_imports = true diff --git a/pytest.ini b/pytest.ini index 77581c49..f5348f26 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,9 @@ [pytest] markers = indevelopment: marks tests as in development, should not be run + packaging: slow tests that check formatting over function filterwarnings = ignore:.*deprecated.*: + +addopts = --doctest-modules pyttb diff --git a/pyttb/__init__.py b/pyttb/__init__.py index 33b96625..d7ac614f 100644 --- a/pyttb/__init__.py +++ b/pyttb/__init__.py @@ -2,32 +2,35 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -__version__ = '1.3.1' +__version__ = "1.6.1" +import warnings + +from pyttb.cp_als import cp_als +from pyttb.cp_apr import * +from pyttb.export_data import export_data +from pyttb.hosvd import hosvd +from pyttb.import_data import import_data +from pyttb.khatrirao import khatrirao from pyttb.ktensor import ktensor -from pyttb.sptensor import sptensor -from pyttb.tensor import tensor +from pyttb.pyttb_utils import * from pyttb.sptenmat import sptenmat +from pyttb.sptensor import sptendiag, sptenrand, sptensor from pyttb.sptensor3 import sptensor3 from pyttb.sumtensor import sumtensor from pyttb.symktensor import symktensor from pyttb.symtensor import symtensor from pyttb.tenmat import tenmat +from pyttb.tensor import tendiag, tenones, tenrand, tensor, tenzeros from pyttb.ttensor import ttensor +from pyttb.tucker_als import tucker_als -from pyttb.pyttb_utils import * -from pyttb.khatrirao import khatrirao -from pyttb.cp_apr import * -from pyttb.cp_als import cp_als - -from pyttb.import_data import import_data -from pyttb.export_data import export_data -import warnings def ignore_warnings(ignore=True): if ignore: - warnings.simplefilter('ignore') + warnings.simplefilter("ignore") else: - warnings.simplefilter('default') + warnings.simplefilter("default") + ignore_warnings(True) diff --git a/pyttb/cp_als.py b/pyttb/cp_als.py index dcc03924..3286ddf0 100644 --- a/pyttb/cp_als.py +++ b/pyttb/cp_als.py @@ -2,19 +2,29 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb -from .pyttb_utils import * import numpy as np -def cp_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None, - init='random', printitn=1, fixsigns=True): +import pyttb as ttb +from pyttb.pyttb_utils import * + + +def cp_als( + input_tensor, + rank, + stoptol=1e-4, + maxiters=1000, + dimorder=None, + init="random", + printitn=1, + fixsigns=True, +): """ Compute CP decomposition with alternating least squares Parameters ---------- - tensor: :class:`pyttb.tensor` or :class:`pyttb.sptensor` or :class:`pyttb.ktensor` - rank: int + input_tensor: :class:`pyttb.tensor` or :class:`pyttb.sptensor` or :class:`pyttb.ktensor` + rank: int Rank of the decomposition stoptol: float Tolerance used for termination - when the change in the fitness function in successive iterations drops @@ -26,7 +36,7 @@ def cp_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None, init: str or :class:`pyttb.ktensor` Initial guess (default: "random") - * "random": initialize using a :class:`pyttb.ktensor` with values chosen from a Normal distribution with mean 1 and standard deviation 0 + * "random": initialize using a :class:`pyttb.ktensor` with values chosen from a Normal distribution with mean 0 and standard deviation 1 * "nvecs": initialize factor matrices of a :class:`pyttb.ktensor` using the eigenvectors of the outer product of the matricized input tensor * :class:`pyttb.ktensor`: initialize using a specific :class:`pyttb.ktensor` as input - must be the same shape as the input tensor and have the same rank as the input rank @@ -51,6 +61,8 @@ def cp_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None, Example ------- + Random initialization causes slight pertubation in intermediate results. + `...` is our place holder for these numeric values. Example using default values ("random" initialization): >>> weights = np.array([1., 2.]) @@ -58,52 +70,52 @@ def cp_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None, >>> fm1 = np.array([[5., 6.], [7., 8.]]) >>> K = ttb.ktensor.from_data(weights, [fm0, fm1]) >>> np.random.seed(1) - >>> M, Minit, output = ttb.cp_als(K.full(), 2) + >>> M, Minit, output = ttb.cp_als(K.full(), 2) # doctest: +ELLIPSIS CP_ALS: - Iter 0: f = 0.9999999836180988 f-delta = 0.9999999836180988 - Iter 1: f = 0.9999999836180988 f-delta = 0.0 - Final f = 0.9999999836180988 - >>> print(M) + Iter 0: f = ... f-delta = ... + Iter 1: f = ... f-delta = ... + Final f = ... + >>> print(M) # doctest: +ELLIPSIS ktensor of shape 2 x 2 - weights=[108.47158396 8.61141076] + weights=[108.4715... 8.6114...] factor_matrices[0] = - [[0.41877462 0.39899343] - [0.9080902 0.91695378]] + [[0.4187... 0.3989...] + [0.9080... 0.9169...]] factor_matrices[1] = - [[0.61888633 0.25815611] - [0.78548056 0.96610322]] - >>> print(Minit) + [[0.6188... 0.2581...] + [0.7854... 0.9661...]] + >>> print(Minit) # doctest: +ELLIPSIS ktensor of shape 2 x 2 weights=[1. 1.] factor_matrices[0] = - [[4.17022005e-01 7.20324493e-01] - [1.14374817e-04 3.02332573e-01]] + [[4.1702...e-01 7.2032...e-01] + [1.1437...e-04 3.0233...e-01]] factor_matrices[1] = - [[0.14675589 0.09233859] - [0.18626021 0.34556073]] + [[0.1467... 0.0923...] + [0.1862... 0.3455...]] >>> print(output) - {'params': (0.0001, 1000, 1, [0, 1]), 'iters': 1, 'normresidual': 1.9073486328125e-06, 'fit': 0.9999999836180988} + {'params': (0.0001, 1000, 1, [0, 1]), 'iters': 1, 'normresidual': ..., 'fit': ...} Example using "nvecs" initialization: - >>> M, Minit, output = ttb.cp_als(K.full(), 2, init="nvecs") + >>> M, Minit, output = ttb.cp_als(K.full(), 2, init="nvecs") # doctest: +ELLIPSIS CP_ALS: - Iter 0: f = 1.0 f-delta = 1.0 - Iter 1: f = 1.0 f-delta = 0.0 - Final f = 1.0 + Iter 0: f = ... f-delta = ... + Iter 1: f = ... f-delta = ... + Final f = ... Example using :class:`pyttb.ktensor` initialization: - >>> M, Minit, output = ttb.cp_als(K.full(), 2, init=K) + >>> M, Minit, output = ttb.cp_als(K.full(), 2, init=K) # doctest: +ELLIPSIS CP_ALS: - Iter 0: f = 0.9999999836180988 f-delta = 0.9999999836180988 - Iter 1: f = 0.9999999836180988 f-delta = 0.0 - Final f = 0.9999999836180988 + Iter 0: f = ... f-delta = ... + Iter 1: f = ... f-delta = ... + Final f = ... """ # Extract number of dimensions and norm of tensor - N = tensor.ndims - normX = tensor.norm() + N = input_tensor.ndims + normX = input_tensor.norm() # Set up dimorder if not specified if not dimorder: @@ -112,7 +124,9 @@ def cp_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None, if not isinstance(dimorder, list): assert False, "Dimorder must be a list" elif tuple(range(N)) != tuple(sorted(dimorder)): - assert False, "Dimorder must be a list or permutation of range(tensor.ndims)" + assert ( + False + ), "Dimorder must be a list or permutation of range(tensor.ndims)" # Error checking assert rank > 0, "Number of components requested must be positive" @@ -121,19 +135,23 @@ def cp_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None, if isinstance(init, ttb.ktensor): # User provided an initial ktensor; validate it assert init.ndims == N, "Initial guess does not have {} modes".format(N) - assert init.ncomponents == rank, "Initial guess does not have {} components".format(rank) + assert ( + init.ncomponents == rank + ), "Initial guess does not have {} components".format(rank) for n in dimorder: - if init.factor_matrices[n].shape != (tensor.shape[n], rank): + if init.factor_matrices[n].shape != (input_tensor.shape[n], rank): assert False, "Mode {} of the initial guess is the wrong size".format(n) - elif init.lower() == 'random': + elif isinstance(init, str) and init.lower() == "random": factor_matrices = [] for n in range(N): - factor_matrices.append(np.random.uniform(0, 1, (tensor.shape[n], rank))) + factor_matrices.append( + np.random.uniform(0, 1, (input_tensor.shape[n], rank)) + ) init = ttb.ktensor.from_factor_matrices(factor_matrices) - elif init.lower() == 'nvecs': + elif isinstance(init, str) and init.lower() == "nvecs": factor_matrices = [] for n in range(N): - factor_matrices.append(tensor.nvecs(n, rank)) + factor_matrices.append(input_tensor.nvecs(n, rank)) init = ttb.ktensor.from_factor_matrices(factor_matrices) else: assert False, "The selected initialization method is not supported" @@ -143,40 +161,38 @@ def cp_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None, fit = 0 # Store the last MTTKRP result to accelerate fitness computation - U_mttkrp = np.zeros((tensor.shape[dimorder[-1]], rank)) + U_mttkrp = np.zeros((input_tensor.shape[dimorder[-1]], rank)) if printitn > 0: - print('CP_ALS:') + print("CP_ALS:") # Main Loop: Iterate until convergence - UtU = np.zeros((rank,rank,N)) + UtU = np.zeros((rank, rank, N)) for n in range(N): - UtU[:,:,n] = U[n].T @ U[n] + UtU[:, :, n] = U[n].T @ U[n] for iter in range(maxiters): - fitold = fit # Iterate over all N modes of the tensor for n in dimorder: - # Calculate Unew = X_(n) * khatrirao(all U except n, 'r'). - Unew = tensor.mttkrp(U, n) + Unew = input_tensor.mttkrp(U, n) # Save the last MTTKRP result for fitness check. if n == dimorder[-1]: U_mttkrp = Unew # Compute the matrix of coefficients for linear system - Y = np.prod(UtU,axis=2,where=[i!=n for i in range(N)]) + Y = np.prod(UtU, axis=2, where=[i != n for i in range(N)]) # don't try to solve linear system with Y = 0 if (Y == np.zeros(Y.shape)).all(): Unew = np.zeros(Unew.shape) else: Unew = np.linalg.solve(Y.T, Unew.T).T # TODO: should we have issparse implemented? I am not sure when the following will occur - #if issparse(Unew): + # if issparse(Unew): # Unew = full(Unew) # for the case R=1 # Normalize each vector to prevent singularities in coefmatrix @@ -190,18 +206,20 @@ def cp_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None, Unew = Unew / weights U[n] = Unew - UtU[:,:,n] = U[n].T @ U[n] + UtU[:, :, n] = U[n].T @ U[n] M = ttb.ktensor.from_data(weights, U) # This is equivalent to innerprod(X,P). - iprod = np.sum(np.sum(M.factor_matrices[dimorder[-1]] * U_mttkrp, 0) * weights, 0) + iprod = np.sum( + np.sum(M.factor_matrices[dimorder[-1]] * U_mttkrp, 0) * weights, 0 + ) if normX == 0: - normresidual = M.norm()**2 - 2 * iprod + normresidual = M.norm() ** 2 - 2 * iprod fit = normresidual else: # the following input to np.sqrt can be negative due to rounding and truncation errors, so np.abs is used - normresidual = np.sqrt(np.abs(normX**2 + M.norm()**2 - 2 * iprod)) + normresidual = np.sqrt(np.abs(normX**2 + M.norm() ** 2 - 2 * iprod)) fit = 1 - (normresidual / normX) # fraction explained by model fitchange = np.abs(fitold - fit) @@ -213,7 +231,7 @@ def cp_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None, flag = 1 if (divmod(iter, printitn)[1] == 0) or (printitn > 0 and flag == 0): - print(f' Iter {iter}: f = {fit:e} f-delta = {fitchange:7.1e}') + print(f" Iter {iter}: f = {fit:e} f-delta = {fitchange:7.1e}") # Check for convergence if flag == 0: @@ -229,22 +247,25 @@ def cp_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None, if printitn > 0: if normX == 0: - normresidual = M.norm()**2 - 2 * tensor.innerprod(M) + normresidual = M.norm() ** 2 - 2 * input_tensor.innerprod(M) fit = normresidual else: - normresidual = np.sqrt(np.abs(normX**2 + M.norm()**2 - 2 * tensor.innerprod(M))) - fit = 1 - (normresidual / normX) # fraction explained by model - print(f' Final f = {fit:e}') + normresidual = np.sqrt( + np.abs(normX**2 + M.norm() ** 2 - 2 * input_tensor.innerprod(M)) + ) + fit = 1 - (normresidual / normX) # fraction explained by model + print(f" Final f = {fit:e}") output = {} - output['params'] = (stoptol, maxiters, printitn, dimorder) - output['iters'] = iter - output['normresidual'] = normresidual - output['fit'] = fit + output["params"] = (stoptol, maxiters, printitn, dimorder) + output["iters"] = iter + output["normresidual"] = normresidual + output["fit"] = fit return M, init, output + if __name__ == "__main__": - import doctest # pragma: no cover - import pyttb as ttb # pragma: no cover - doctest.testmod() # pragma: no cover + import doctest # pragma: no cover + + doctest.testmod() # pragma: no cover diff --git a/pyttb/cp_apr.py b/pyttb/cp_apr.py index fbe1c044..89020a0c 100644 --- a/pyttb/cp_apr.py +++ b/pyttb/cp_apr.py @@ -2,27 +2,46 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb -from .pyttb_utils import * -import numpy as np import time -from numpy_groupies import aggregate as accumarray import warnings +import numpy as np +from numpy_groupies import aggregate as accumarray + +import pyttb as ttb + +from .pyttb_utils import * + -def cp_apr(tensor, rank, algorithm='mu', stoptol=1e-4, stoptime=1e6, maxiters=1000, - init='random', maxinneriters=10, epsDivZero=1e-10, printitn=1, printinneritn=0, - kappa=0.01, kappatol=1e-10, epsActive=1e-8, mu0=1e-5, precompinds=True, inexact=True, - lbfgsMem=3): +def cp_apr( + input_tensor, + rank, + algorithm="mu", + stoptol=1e-4, + stoptime=1e6, + maxiters=1000, + init="random", + maxinneriters=10, + epsDivZero=1e-10, + printitn=1, + printinneritn=0, + kappa=0.01, + kappatol=1e-10, + epsActive=1e-8, + mu0=1e-5, + precompinds=True, + inexact=True, + lbfgsMem=3, +): """ - Compute nonnegative CP with alternating Poisson regression. - + Compute non-negative CP with alternating Poisson regression. + Parameters ---------- - tensor: :class:`pyttb.tensor` or :class:`pyttb.sptensor` - rank: int + input_tensor: :class:`pyttb.tensor` or :class:`pyttb.sptensor` + rank: int Rank of the decomposition - algorithm: str + algorithm: str in {'mu', 'pdnr, 'pqnr'} stoptol: float Tolerance on overall KKT violation @@ -45,7 +64,7 @@ def cp_apr(tensor, rank, algorithm='mu', stoptol=1e-4, stoptime=1e6, maxiters=10 kappatol: MU ALGORITHM PARAMETER: Tolerance on complementary slackness epsActive: float - PDNR & PQNR ALGORITHM PARAMETER: Bertsekas tolerance for active set + PDNR & PQNR ALGORITHM PARAMETER: Bertsekas tolerance for active set mu0: float PDNR ALGORITHM PARAMETER: Initial Damping Parameter precompinds: bool @@ -66,60 +85,117 @@ def cp_apr(tensor, rank, algorithm='mu', stoptol=1e-4, stoptime=1e6, maxiters=10 """ # Extract the number of modes in tensor X - N = tensor.ndims + N = input_tensor.ndims assert rank > 0, "Number of components requested must be positive" # Check that the data is non-negative. - tmp = (tensor < 0.0) - assert tmp.nnz == 0, "Data tensor must be nonnegative for Poisson-based factorization" + tmp = input_tensor < 0.0 + assert ( + tmp.nnz == 0 + ), "Data tensor must be nonnegative for Poisson-based factorization" # Set up an initial guess for the factor matrices. if isinstance(init, ttb.ktensor): # User provided an initial ktensor; validate it assert init.ndims == N, "Initial guess does not have the right number of modes" - assert init.ncomponents == rank, "Initial guess does not have the right number of componenets" + assert ( + init.ncomponents == rank + ), "Initial guess does not have the right number of componenets" for n in range(N): - if init.shape[n] != tensor.shape[n]: + if init.shape[n] != input_tensor.shape[n]: assert False, "Mode {} of the initial guess is the wrong size".format(n) if np.min(init.factor_matrices[n]) < 0.0: assert False, "Initial guess has negative element in mode {}".format(n) if np.min(init.weights) < 0: - assert False, 'Initial guess has a negative ktensor weight' + assert False, "Initial guess has a negative ktensor weight" - elif init.lower() == 'random': + elif init.lower() == "random": factor_matrices = [] for n in range(N): - factor_matrices.append(np.random.uniform(0, 1, (tensor.shape[n], rank))) + factor_matrices.append( + np.random.uniform(0, 1, (input_tensor.shape[n], rank)) + ) init = ttb.ktensor.from_factor_matrices(factor_matrices) # Call solver based on the couce of algorithm parameter, passing all the other input parameters - if algorithm.lower() == 'mu': - M, output = tt_cp_apr_mu(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriters, epsDivZero, printitn, - printinneritn, kappa, kappatol) - output['algorithm'] = 'mu' - elif algorithm.lower() == 'pdnr': - M, output = tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriters, epsDivZero, printitn, - printinneritn, epsActive, mu0, precompinds, inexact) - output['algorithm'] = 'pdnr' - elif algorithm.lower() == 'pqnr': - M, output = tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriters, epsDivZero, printitn, - printinneritn, epsActive, lbfgsMem, precompinds) - output['algorithm'] = 'pqnr' + if algorithm.lower() == "mu": + M, output = tt_cp_apr_mu( + input_tensor, + rank, + init, + stoptol, + stoptime, + maxiters, + maxinneriters, + epsDivZero, + printitn, + printinneritn, + kappa, + kappatol, + ) + output["algorithm"] = "mu" + elif algorithm.lower() == "pdnr": + M, output = tt_cp_apr_pdnr( + input_tensor, + rank, + init, + stoptol, + stoptime, + maxiters, + maxinneriters, + epsDivZero, + printitn, + printinneritn, + epsActive, + mu0, + precompinds, + inexact, + ) + output["algorithm"] = "pdnr" + elif algorithm.lower() == "pqnr": + M, output = tt_cp_apr_pqnr( + input_tensor, + rank, + init, + stoptol, + stoptime, + maxiters, + maxinneriters, + epsDivZero, + printitn, + printinneritn, + epsActive, + lbfgsMem, + precompinds, + ) + output["algorithm"] = "pqnr" else: assert False, "{} is not a supported cp_als algorithm".format(algorithm) return M, init, output -def tt_cp_apr_mu(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriters, epsDivZero, printitn, - printinneritn, kappa, kappatol): +def tt_cp_apr_mu( + input_tensor, + rank, + init, + stoptol, + stoptime, + maxiters, + maxinneriters, + epsDivZero, + printitn, + printinneritn, + kappa, + kappatol, +): """ Compute nonnegative CP with alternating Poisson regression. Parameters ---------- - tensor: :class:`pyttb.tensor` or :class:`pyttb.sptensor` + input_tensor: :class:`pyttb.tensor` or :class:`pyttb.sptensor` rank: int Rank of the decomposition init: :class:`pyttb.ktensor` @@ -153,16 +229,16 @@ def tt_cp_apr_mu(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriters, URL: http://arxiv.org/abs/1112.2414. Submitted for publication. """ - N = tensor.ndims + N = input_tensor.ndims # TODO I vote no duplicate error checking, copy error checking from cp_apr for initial guess here if disagree # Initialize output arrays - #fnEvals = np.zeros((maxiters,)) + # fnEvals = np.zeros((maxiters,)) kktViolations = -np.ones((maxiters,)) # TODO we initialize nInnerIters of size max outer iters? nInnerIters = np.zeros((maxiters,)) - #nzeros = np.zeros((maxiters,)) + # nzeros = np.zeros((maxiters,)) nViolations = np.zeros((maxiters,)) nTimes = np.zeros((maxiters,)) @@ -170,14 +246,14 @@ def tt_cp_apr_mu(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriters, # TODO replace with copy M = ttb.ktensor.from_tensor_type(init) M.normalize(normtype=1) - Phi = [] #np.zeros((N,))#cell(N,1) + Phi = [] # np.zeros((N,))#cell(N,1) for n in range(N): # TODO prepopulation Phi instead of appen should be faster Phi.append(np.zeros(M[n].shape)) kktModeViolations = np.zeros((N,)) if printitn > 0: - print('\nCP_APR:\n') + print("\nCP_APR:\n") # Start the wall clock timer. start = time.time() @@ -195,26 +271,27 @@ def tt_cp_apr_mu(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriters, V = (Phi[n] > 0) & (M[n] < kappatol) if np.any(V): nViolations[iter] += 1 - M.factor_matrices[n][V>0] += kappa + M.factor_matrices[n][V > 0] += kappa # Shift the weight from lambda to mode n M.redistribute(mode=n) # Calculate product of all matrices but the n-th # Sparse case only calculates entries corresponding to nonzeros in X - Pi = calculatePi(tensor, M, rank, n, N) + Pi = calculatePi(input_tensor, M, rank, n, N) # Do the multiplicative updates for i in range(maxinneriters): - # Count the inner iterations nInnerIters[iter] += 1 # Calculate matrix for multiplicative update - Phi[n] = calculatePhi(tensor, M, rank, n, Pi, epsDivZero) + Phi[n] = calculatePhi(input_tensor, M, rank, n, Pi, epsDivZero) # Check for convergence - kktModeViolations[n] = np.max(np.abs(vectorizeForMu(np.minimum(M.factor_matrices[n], 1 - Phi[n])))) + kktModeViolations[n] = np.max( + np.abs(vectorizeForMu(np.minimum(M.factor_matrices[n], 1 - Phi[n]))) + ) if kktModeViolations[n] < stoptol: break else: @@ -226,26 +303,33 @@ def tt_cp_apr_mu(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriters, # Print status if printinneritn != 0 and divmod(i, printinneritn)[1] == 0: - print('\t\tMode = {}, Inner Iter = {}, KKT violation = {}\n'.format(n, i, kktModeViolations[n])) + print( + "\t\tMode = {}, Inner Iter = {}, KKT violation = {}\n".format( + n, i, kktModeViolations[n] + ) + ) # Shift weight from mode n back to lambda M.normalize(normtype=1, mode=n) kktViolations[iter] = np.max(kktModeViolations) if divmod(iter, printitn)[1] == 0: - print('\tIter {}: Inner Its = {} KKT violation = {}, nViolations = {}'.format(iter, nInnerIters[iter], - kktViolations[iter], nViolations[iter])) + print( + "\tIter {}: Inner Its = {} KKT violation = {}, nViolations = {}".format( + iter, nInnerIters[iter], kktViolations[iter], nViolations[iter] + ) + ) nTimes[iter] = time.time() - start # Check for convergence if isConverged: if printitn > 0: - print('Exiting because all subproblems reached KKT tol.\n') + print("Exiting because all subproblems reached KKT tol.\n") break if nTimes[iter] > stoptime: if printitn > 0: - print('Exiting because time limit exceeded.\n') + print("Exiting because time limit exceeded.\n") break t_stop = time.time() - start @@ -253,33 +337,60 @@ def tt_cp_apr_mu(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriters, # Clean up final result M.normalize(sort=True, normtype=1) - obj = tt_loglikelihood(tensor, M) + obj = tt_loglikelihood(input_tensor, M) if printitn > 0: - normTensor = tensor.norm() - normresidual = np.sqrt(normTensor**2+M.norm()**2 - 2*tensor.innerprod(M)) - fit = 1 - (normresidual/ normTensor) #fraction explained by model - print('===========================================\n') - print(' Final log-likelihood = {} \n'.format(obj)) - print(' Final least squares fit = {} \n'.format(fit)) - print(' Final KKT violation = {}\n'.format(kktViolations[iter])) - print(' Total inner iterations = {}\n'.format(sum(nInnerIters))) - print(' Total execution time = {} secs\n'.format(t_stop)) + normTensor = input_tensor.norm() + normresidual = np.sqrt( + normTensor**2 + M.norm() ** 2 - 2 * input_tensor.innerprod(M) + ) + fit = 1 - (normresidual / normTensor) # fraction explained by model + print("===========================================\n") + print(" Final log-likelihood = {} \n".format(obj)) + print(" Final least squares fit = {} \n".format(fit)) + print(" Final KKT violation = {}\n".format(kktViolations[iter])) + print(" Total inner iterations = {}\n".format(sum(nInnerIters))) + print(" Total execution time = {} secs\n".format(t_stop)) output = {} - output['params'] = (stoptol, stoptime, maxiters, maxinneriters, epsDivZero, printitn, printinneritn, kappa, kappatol) - output['kktViolations'] = kktViolations[:iter+1] - output['nInnerIters'] = nInnerIters[:iter+1] - output['nViolations'] = nViolations[:iter+1] - output['nTotalIters'] = np.sum(nInnerIters) - output['times'] = nTimes[:iter+1] - output['totalTime'] = t_stop - output['obj'] = obj + output["params"] = ( + stoptol, + stoptime, + maxiters, + maxinneriters, + epsDivZero, + printitn, + printinneritn, + kappa, + kappatol, + ) + output["kktViolations"] = kktViolations[: iter + 1] + output["nInnerIters"] = nInnerIters[: iter + 1] + output["nViolations"] = nViolations[: iter + 1] + output["nTotalIters"] = np.sum(nInnerIters) + output["times"] = nTimes[: iter + 1] + output["totalTime"] = t_stop + output["obj"] = obj return M, output -def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriters, epsDivZero, printitn, - printinneritn, epsActive, mu0, precompinds, inexact): + +def tt_cp_apr_pdnr( + input_tensor, + rank, + init, + stoptol, + stoptime, + maxiters, + maxinneriters, + epsDivZero, + printitn, + printinneritn, + epsActive, + mu0, + precompinds, + inexact, +): """ Compute nonnegative CP with alternating Poisson regression computes an estimate of the best rank-R @@ -290,7 +401,7 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter Parameters ---------- # TODO it looks like this method of define union helps the typ hinting better than or - tensor: Union[:class:`pyttb.tensor`,:class:`pyttb.sptensor`] + input_tensor: Union[:class:`pyttb.tensor`,:class:`pyttb.sptensor`] rank: int Rank of the decomposition init: str or :class:`pyttb.ktensor` @@ -331,13 +442,13 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter """ # Extract the number of modes in tensor X - N = tensor.ndims + N = input_tensor.ndims # If the initial guess has any rows of all zero elements, then modify so the row subproblem is not taking log(0). # Values will be restored to zero later if the unfolded X for the row has no zeros. for n in range(N): rowsum = np.sum(init[n], axis=1) - tmpIdx = np.where(rowsum==0)[0] + tmpIdx = np.where(rowsum == 0)[0] if tmpIdx.size != 0: init[n][tmpIdx, 0] = 1e-8 @@ -347,7 +458,7 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter M.normalize(normtype=1) # Sparse tensor flag affects how Pi and Phi are computed. - if isinstance(tensor, ttb.sptensor): + if isinstance(input_tensor, ttb.sptensor): isSparse = True else: isSparse = False @@ -363,7 +474,7 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter if printitn > 0: print("\nCP_PDNR (alternating Poisson regression using damped Newton)\n") - dispLineWarn = (printinneritn > 0) + dispLineWarn = printinneritn > 0 # Start the wall clock timer. start = time.time() @@ -372,16 +483,17 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Precompute sparse index sets for all the row subproblems. # Takes more memory but can cut exectuion time significantly in some cases. if printitn > 0: - print('\tPrecomuting sparse index sets...') + print("\tPrecomuting sparse index sets...") sparseIx = [] for n in range(N): num_rows = M[n].shape[0] - sparseIx.append(np.zeros((num_rows, 1))) + row_indices = [] for jj in range(num_rows): - sparseIx[n][jj] = np.where(tensor.subs[:, n] == jj)[0] + row_indices.append(np.where(input_tensor.subs[:, n] == jj)[0]) + sparseIx.append(row_indices) if printitn > 0: - print('done\n') + print("done\n") e_vec = np.ones((1, rank)) @@ -390,20 +502,19 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Main loop: iterate until convergence or a max threshold is reached for iter in range(maxiters): isConverged = True - kktModeViolations = np.zeros((N, )) - countInnerIters = np.zeros((N, )) + kktModeViolations = np.zeros((N,)) + countInnerIters = np.zeros((N,)) # Alternate thru each factor matrix, A_1, A_2, ..., A_N. for n in range(N): - # Shift the weight from lambda to mode n. M.redistribute(mode=n) # calculate khatri-rao product of all matrices but the n-th if isSparse == False: # Data is not a sparse tensor. - Pi = ttb.tt_calcpi_prowsubprob(tensor, M, rank, n, N, isSparse) - X_mat = ttb.tt_to_dense_matrix(tensor, n) + Pi = ttb.tt_calcpi_prowsubprob(input_tensor, M, rank, n, N, isSparse) + X_mat = ttb.tt_to_dense_matrix(input_tensor, n) num_rows = M[n].shape[0] isRowNOTconverged = np.zeros((num_rows,)) @@ -417,7 +528,7 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter if isSparse: # Data is a sparse tensor if not precompinds: - sparse_indices = np.where(tensor.subs[:, n] == jj)[0] + sparse_indices = np.where(input_tensor.subs[:, n] == jj)[0] else: sparse_indices = sparseIx[n][jj] @@ -426,10 +537,12 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter M.factor_matrices[n][jj, :] = 0 continue - x_row = tensor.vals(sparse_indices) + x_row = input_tensor.vals[sparse_indices] # Calculate just the columns of Pi needed for this row. - Pi = ttb.tt_calcpi_prowsubprob(tensor, M, rank, n, N, isSparse, sparse_indices) + Pi = ttb.tt_calcpi_prowsubprob( + input_tensor, M, rank, n, N, isSparse, sparse_indices + ) else: x_row = X_mat[jj, :] @@ -445,7 +558,9 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter for i in range(innerIterMaximum): # Calculate the gradient. - [phi_row, ups_row] = calc_partials(isSparse, Pi, epsDivZero, x_row, m_row) + [phi_row, ups_row] = calc_partials( + isSparse, Pi, epsDivZero, x_row, m_row + ) gradM = (e_vec - phi_row).transpose() # Compute the row subproblem kkt_violation. @@ -454,19 +569,25 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # kkt_violation = np.norm(np.abs(np.minimum(m_row, gradM.transpose()))) # We now use \| KKT \|_{inf}: - kkt_violation = np.max(np.abs(np.minimum(m_row, gradM.transpose()[0]))) + kkt_violation = np.max( + np.abs(np.minimum(m_row, gradM.transpose()[0])) + ) # Report largest row subproblem initial violation if i == 0 and kkt_violation > kktModeViolations[n]: kktModeViolations[n] = kkt_violation if printinneritn > 0 and np.mod(i, printinneritn) == 0: - print('\tMode = {}, Row = {}, InnerIt = {}'.format(n, jj, i)) + print("\tMode = {}, Row = {}, InnerIt = {}".format(n, jj, i)) if i == 0: - print(', RowKKT = {}\n'.format(kkt_violation)) + print(", RowKKT = {}\n".format(kkt_violation)) else: - print(', RowKKT = {}, RowObj = {}\n'.format(kkt_violation, -f_new)) + print( + ", RowKKT = {}, RowObj = {}\n".format( + kkt_violation, -f_new + ) + ) # Check for row subproblem convergence. if kkt_violation < stoptol: @@ -477,14 +598,33 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Calculate the search direction # TODO clean up reshaping gradM to row - search_dir, predicted_red = getSearchDirPdnr(Pi, ups_row, rank, gradM.transpose()[0], m_row, mu, epsActive) + search_dir, predicted_red = getSearchDirPdnr( + Pi, ups_row, rank, gradM.transpose()[0], m_row, mu, epsActive + ) # Perform a projected linesearch and update variables. # Start from a unit step length, decrease by 1/2, # stop with sufficicent decrease of 1.0e-4 or at most 10 steps. - m_rowNew, f_old, f_unit, f_new, num_evals = \ - ttb.tt_linesearch_prowsubprob(search_dir.transpose()[0], gradM.transpose(), m_row, 1, 1/2, 10, - 1.0e-4, isSparse, x_row, Pi, phi_row, dispLineWarn) + ( + m_rowNew, + f_old, + f_unit, + f_new, + num_evals, + ) = ttb.tt_linesearch_prowsubprob( + search_dir.transpose()[0], + gradM.transpose(), + m_row, + 1, + 1 / 2, + 10, + 1.0e-4, + isSparse, + x_row, + Pi, + phi_row, + dispLineWarn, + ) fnEvals[iter] += num_evals m_row = m_rowNew @@ -493,10 +633,10 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter rho = actual_red / -predicted_red if predicted_red == 0: mu *= 10 - elif rho < 1/4: - mu *= 7/2 - elif rho > 3/4: - mu *= 2/7 + elif rho < 1 / 4: + mu *= 7 / 2 + elif rho > 3 / 4: + mu *= 2 / 7 M.factor_matrices[n][jj, :] = m_row countInnerIters[n] += i @@ -515,7 +655,7 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Save output items for the outer iteration. num_zero = 0 for n in range(N): - num_zero += np.count_nonzero(M[n] == 0)#[0].size + num_zero += np.count_nonzero(M[n] == 0) # [0].size nzeros[iter] = num_zero kktViolations[iter] = np.max(kktModeViolations) @@ -525,11 +665,18 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Print outer iteration status. if printitn > 0 and np.mod(iter, printitn) == 0: - fnVals[iter] = -tt_loglikelihood(tensor, M) - print("{}. Ttl Inner Its: {}, KKT viol = {}, obj = {}, nz: {}\n". - format(iter, nInnerIters[iter], kktViolations[iter], fnVals[iter], num_zero)) + fnVals[iter] = -tt_loglikelihood(input_tensor, M) + print( + "{}. Ttl Inner Its: {}, KKT viol = {}, obj = {}, nz: {}\n".format( + iter, + nInnerIters[iter], + kktViolations[iter], + fnVals[iter], + num_zero, + ) + ) - times[iter] = time.time()-start + times[iter] = time.time() - start # Check for convergence if isConverged and inexact == False: @@ -540,42 +687,67 @@ def tt_cp_apr_pdnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter print("EXiting because time limit exceeded\n") break - t_stop = time.time()-start + t_stop = time.time() - start # Clean up final result M.normalize(sort=True, normtype=1) - obj = tt_loglikelihood(tensor, M) + obj = tt_loglikelihood(input_tensor, M) if printitn > 0: - normTensor = tensor.norm() - normresidual = np.sqrt(normTensor ** 2 + M.norm() ** 2 - 2 * tensor.innerprod(M)) + normTensor = input_tensor.norm() + normresidual = np.sqrt( + normTensor**2 + M.norm() ** 2 - 2 * input_tensor.innerprod(M) + ) fit = 1 - (normresidual / normTensor) # fraction explained by model - print('===========================================\n') - print(' Final log-likelihood = {} \n'.format(obj)) - print(' Final least squares fit = {} \n'.format(fit)) - print(' Final KKT violation = {}\n'.format(kktViolations[iter])) - print(' Total inner iterations = {}\n'.format(sum(nInnerIters))) - print(' Total execution time = {} secs\n'.format(t_stop)) + print("===========================================\n") + print(" Final log-likelihood = {} \n".format(obj)) + print(" Final least squares fit = {} \n".format(fit)) + print(" Final KKT violation = {}\n".format(kktViolations[iter])) + print(" Total inner iterations = {}\n".format(sum(nInnerIters))) + print(" Total execution time = {} secs\n".format(t_stop)) output = {} - output['params'] = ( - stoptol, stoptime, maxiters, maxinneriters, epsDivZero, printitn, - printinneritn, epsActive, mu0, precompinds, inexact) - output['kktViolations'] = kktViolations[:iter + 1] - output['obj'] = obj - output['fnEvals'] = fnEvals[:iter + 1] - output['fnVals'] = fnVals[:iter + 1] - output['nInnerIters'] = nInnerIters[:iter + 1] - output["nZeros"] = nzeros[:iter + 1] - output['times'] = times[:iter + 1] - output['totalTime'] = t_stop - + output["params"] = ( + stoptol, + stoptime, + maxiters, + maxinneriters, + epsDivZero, + printitn, + printinneritn, + epsActive, + mu0, + precompinds, + inexact, + ) + output["kktViolations"] = kktViolations[: iter + 1] + output["obj"] = obj + output["fnEvals"] = fnEvals[: iter + 1] + output["fnVals"] = fnVals[: iter + 1] + output["nInnerIters"] = nInnerIters[: iter + 1] + output["nZeros"] = nzeros[: iter + 1] + output["times"] = times[: iter + 1] + output["totalTime"] = t_stop return M, output -def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriters, epsDivZero, printitn, - printinneritn, epsActive, lbfgsMem, precompinds): + +def tt_cp_apr_pqnr( + input_tensor, + rank, + init, + stoptol, + stoptime, + maxiters, + maxinneriters, + epsDivZero, + printitn, + printinneritn, + epsActive, + lbfgsMem, + precompinds, +): """ Compute nonnegative CP with alternating Poisson regression. @@ -599,7 +771,7 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter Parameters ---------- - tensor: Union[:class:`pyttb.tensor`,:class:`pyttb.sptensor`] + input_tensor: Union[:class:`pyttb.tensor`,:class:`pyttb.sptensor`] rank: int Rank of the decomposition init: str or :class:`pyttb.ktensor` @@ -638,7 +810,7 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter """ # TODO first ~100 lines are identical to PDNR, consider abstracting just the algorithm portion # Extract the number of modes in data tensor - N = tensor.ndims + N = input_tensor.ndims # If the initial guess has any rows of all zero elements, then modify so the row subproblem is not taking log(0). # Values will be restored to zero later if the unfolded X for the row has no zeros. @@ -654,7 +826,7 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter M.normalize(normtype=1) # Sparse tensor flag affects how Pi and Phi are computed. - if isinstance(tensor, ttb.sptensor): + if isinstance(input_tensor, ttb.sptensor): isSparse = True else: isSparse = False @@ -670,7 +842,7 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter if printitn > 0: print("\nCP_PQNR (alternating Poisson regression using quasi-Newton)\n") - dispLineWarn = (printinneritn > 0) + dispLineWarn = printinneritn > 0 # Start the wall clock timer. start = time.time() @@ -679,16 +851,17 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Precompute sparse index sets for all the row subproblems. # Takes more memory but can cut exectuion time significantly in some cases. if printitn > 0: - print('\tPrecomuting sparse index sets...') + print("\tPrecomuting sparse index sets...") sparseIx = [] for n in range(N): num_rows = M[n].shape[0] - sparseIx.append(np.zeros((num_rows, 1))) + row_indices = [] for jj in range(num_rows): - sparseIx[n][jj] = np.where(tensor.subs[:, n] == jj)[0] + row_indices.append(np.where(input_tensor.subs[:, n] == jj)[0]) + sparseIx.append(row_indices) if printitn > 0: - print('done\n') + print("done\n") # Main loop: iterate until convergence or a max threshold is reached for iter in range(maxiters): @@ -698,15 +871,14 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Alternate thru each factor matrix, A_1, A_2, ..., A_N. for n in range(N): - # Shift the weight from lambda to mode n. M.redistribute(mode=n) # calculate khatri-rao product of all matrices but the n-th if isSparse == False: # Data is not a sparse tensor. - Pi = ttb.tt_calcpi_prowsubprob(tensor, M, rank, n, N, isSparse) - X_mat = ttb.tt_to_dense_matrix(tensor, n) + Pi = ttb.tt_calcpi_prowsubprob(input_tensor, M, rank, n, N, isSparse) + X_mat = ttb.tt_to_dense_matrix(input_tensor, n) num_rows = M[n].shape[0] isRowNOTconverged = np.zeros((num_rows,)) @@ -717,7 +889,7 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter if isSparse: # Data is a sparse tensor if not precompinds: - sparse_indices = np.where(tensor.subs[:, n] == jj)[0] + sparse_indices = np.where(input_tensor.subs[:, n] == jj)[0] else: sparse_indices = sparseIx[n][jj] @@ -726,10 +898,12 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter M.factor_matrices[n][jj, :] = 0 continue - x_row = tensor.vals(sparse_indices) + x_row = input_tensor.vals[sparse_indices] # Calculate just the columns of Pi needed for this row. - Pi = ttb.tt_calcpi_prowsubprob(tensor, M, rank, n, N, isSparse, sparse_indices) + Pi = ttb.tt_calcpi_prowsubprob( + input_tensor, M, rank, n, N, isSparse, sparse_indices + ) else: x_row = X_mat[jj, :] @@ -740,7 +914,7 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Initialize L-BFGS storage for the row subproblem. delm = np.zeros((rank, lbfgsMem)) delg = np.zeros((rank, lbfgsMem)) - rho = np.zeros((lbfgsMem, )) + rho = np.zeros((lbfgsMem,)) lbfgsPos = 0 m_rowOLD = [] gradOLD = [] @@ -758,11 +932,24 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # TODO: fix in a future release. m_rowOLD = m_row gradOLD = gradM - m_row, f, f_unit, f_new, num_evals = \ - tt_linesearch_prowsubprob(-gradM.transpose(), gradM.transpose(), m_rowOLD, 1, 1/2, 10, 1e-4, - isSparse, x_row, Pi, phi_row, dispLineWarn) + m_row, f, f_unit, f_new, num_evals = tt_linesearch_prowsubprob( + -gradM.transpose(), + gradM.transpose(), + m_rowOLD, + 1, + 1 / 2, + 10, + 1e-4, + isSparse, + x_row, + Pi, + phi_row, + dispLineWarn, + ) fnEvals[iter] += num_evals - gradM, phi_row = calc_grad(isSparse, Pi, epsDivZero, x_row, m_row) + gradM, phi_row = calc_grad( + isSparse, Pi, epsDivZero, x_row, m_row + ) # Compute the row subproblem kkt_violation. @@ -771,19 +958,23 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # We now use \| KKT \|_{inf}: kkt_violation = np.max(np.abs(np.minimum(m_row, gradM))) - #print("Intermediate Printing m_row: {}\n and gradM{}".format(m_row, gradM)) + # print("Intermediate Printing m_row: {}\n and gradM{}".format(m_row, gradM)) # Report largest row subproblem initial violation if i == 0 and kkt_violation > kktModeViolations[n]: kktModeViolations[n] = kkt_violation if printinneritn > 0 and np.mod(i, printinneritn) == 0: - print('\tMode = {}, Row = {}, InnerIt = {}'.format(n, jj, i)) + print("\tMode = {}, Row = {}, InnerIt = {}".format(n, jj, i)) if i == 0: - print(', RowKKT = {}\n'.format(kkt_violation)) + print(", RowKKT = {}\n".format(kkt_violation)) else: - print(', RowKKT = {}, RowObj = {}\n'.format(kkt_violation, -f_new)) + print( + ", RowKKT = {}, RowObj = {}\n".format( + kkt_violation, -f_new + ) + ) # Check for row subproblem convergence. if kkt_violation < stoptol: @@ -805,21 +996,34 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Rho is required to be postive; if not, then skip the L-BFGS update pair. The recommended # safeguard for full BFGS is Powell damping, but not clear how to damp in 2-loop L-BFGS if dispLineWarn: - warnings.warn('WARNING: skipping L-BFGS update, rho whould be 1 / {}'. - format(tmp_delm*tmp_delg)) + warnings.warn( + "WARNING: skipping L-BFGS update, rho whould be 1 / {}".format( + tmp_delm * tmp_delg + ) + ) # Roll back lbfgsPos since it will increment later. if lbfgsPos == 0: - if rho[lbfgsMem-1] > 0: - lbfgsPos = lbfgsMem-1 + if rho[lbfgsMem - 1] > 0: + lbfgsPos = lbfgsMem - 1 else: # Fatal error, should not happen. - assert False, 'ERROR: L-BFGS first iterate is bad' + assert False, "ERROR: L-BFGS first iterate is bad" else: lbfgsPos -= 1 # Calculate search direction - search_dir = getSearchDirPqnr(m_row, gradM, epsActive, delm, delg, rho, lbfgsPos, i, dispLineWarn) + search_dir = getSearchDirPqnr( + m_row, + gradM, + epsActive, + delm, + delg, + rho, + lbfgsPos, + i, + dispLineWarn, + ) lbfgsPos = np.mod(lbfgsPos, lbfgsMem) @@ -829,9 +1033,20 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Perform a projected linesearch and update variables. # Start from a unit step length, decrease by 1/2, # stop with sufficicent decrease of 1.0e-4 or at most 10 steps. - m_row, f, f_unit, f_new, num_evals = \ - ttb.tt_linesearch_prowsubprob(search_dir.transpose()[0], gradOLD.transpose(), m_rowOLD, 1, - 1 / 2, 10, 1.0e-4, isSparse, x_row, Pi, phi_row, dispLineWarn) + m_row, f, f_unit, f_new, num_evals = ttb.tt_linesearch_prowsubprob( + search_dir.transpose()[0], + gradOLD.transpose(), + m_rowOLD, + 1, + 1 / 2, + 10, + 1.0e-4, + isSparse, + x_row, + Pi, + phi_row, + dispLineWarn, + ) fnEvals[iter] += num_evals M.factor_matrices[n][jj, :] = m_row @@ -858,9 +1073,12 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Print outer iteration status. if printitn > 0 and np.mod(iter, printitn) == 0: - fnVals[iter] = -tt_loglikelihood(tensor, M) - print("{}. Ttl Inner Its: {}, KKT viol = {}, obj = {}, nz: {}\n". - format(iter, nInnerIters[iter], kktViolations[iter], fnVals[iter], num_zero)) + fnVals[iter] = -tt_loglikelihood(input_tensor, M) + print( + "{}. Ttl Inner Its: {}, KKT viol = {}, obj = {}, nz: {}\n".format( + iter, nInnerIters[iter], kktViolations[iter], fnVals[iter], num_zero + ) + ) times[iter] = time.time() - start @@ -876,36 +1094,50 @@ def tt_cp_apr_pqnr(tensor, rank, init, stoptol, stoptime, maxiters, maxinneriter # Clean up final result M.normalize(sort=True, normtype=1) - obj = tt_loglikelihood(tensor, M) + obj = tt_loglikelihood(input_tensor, M) if printitn > 0: - normTensor = tensor.norm() - normresidual = np.sqrt(normTensor ** 2 + M.norm() ** 2 - 2 * tensor.innerprod(M)) + normTensor = input_tensor.norm() + normresidual = np.sqrt( + normTensor**2 + M.norm() ** 2 - 2 * input_tensor.innerprod(M) + ) fit = 1 - (normresidual / normTensor) # fraction explained by model - print('===========================================\n') - print(' Final log-likelihood = {} \n'.format(obj)) - print(' Final least squares fit = {} \n'.format(fit)) - print(' Final KKT violation = {}\n'.format(kktViolations[iter])) - print(' Total inner iterations = {}\n'.format(sum(nInnerIters))) - print(' Total execution time = {} secs\n'.format(t_stop)) + print("===========================================\n") + print(" Final log-likelihood = {} \n".format(obj)) + print(" Final least squares fit = {} \n".format(fit)) + print(" Final KKT violation = {}\n".format(kktViolations[iter])) + print(" Total inner iterations = {}\n".format(sum(nInnerIters))) + print(" Total execution time = {} secs\n".format(t_stop)) output = {} - output['params'] = ( - stoptol, stoptime, maxiters, maxinneriters, epsDivZero, printitn, - printinneritn, epsActive, lbfgsMem, precompinds) - output['kktViolations'] = kktViolations[:iter + 1] - output['obj'] = obj - output['fnEvals'] = fnEvals[:iter + 1] - output['fnVals'] = fnVals[:iter + 1] - output['nInnerIters'] = nInnerIters[:iter + 1] - output["nZeros"] = nzeros[:iter + 1] - output['times'] = times[:iter + 1] - output['totalTime'] = t_stop + output["params"] = ( + stoptol, + stoptime, + maxiters, + maxinneriters, + epsDivZero, + printitn, + printinneritn, + epsActive, + lbfgsMem, + precompinds, + ) + output["kktViolations"] = kktViolations[: iter + 1] + output["obj"] = obj + output["fnEvals"] = fnEvals[: iter + 1] + output["fnVals"] = fnVals[: iter + 1] + output["nInnerIters"] = nInnerIters[: iter + 1] + output["nZeros"] = nzeros[: iter + 1] + output["times"] = times[: iter + 1] + output["totalTime"] = t_stop return M, output + # PDNR helper functions -def tt_calcpi_prowsubprob(Data, Model, rank, factorIndex, ndims, isSparse=False, sparse_indices=None): +def tt_calcpi_prowsubprob( + Data, Model, rank, factorIndex, ndims, isSparse=False, sparse_indices=None +): """ Compute Pi for a row subproblem. @@ -938,10 +1170,15 @@ def tt_calcpi_prowsubprob(Data, Model, rank, factorIndex, ndims, isSparse=False, for i in np.setdiff1d(np.arange(ndims), factorIndex).astype(int): Pi *= Model[i][Data.subs[sparse_indices, i], :] else: - Pi = ttb.khatrirao(Model.factor_matrices[:factorIndex] + Model.factor_matrices[factorIndex + 1:ndims+1], reverse=True) + Pi = ttb.khatrirao( + Model.factor_matrices[:factorIndex] + + Model.factor_matrices[factorIndex + 1 : ndims + 1], + reverse=True, + ) return Pi + def calc_partials(isSparse, Pi, epsilon, data_row, model_row): """ Compute derivative quantities for a PDNR row subproblem. @@ -974,6 +1211,7 @@ def calc_partials(isSparse, Pi, epsilon, data_row, model_row): ups_row = data_row.transpose() / np.maximum(u, epsilon) return phi_row, ups_row + def getSearchDirPdnr(Pi, ups_row, rank, gradModel, model_row, mu, epsActSet): """ Compute the search direction for PDNR using a two-metric projection with damped Hessian @@ -1002,23 +1240,25 @@ def getSearchDirPdnr(Pi, ups_row, rank, gradModel, model_row, mu, epsActSet): predicted reduction in quadratic model """ search_dir = np.zeros((rank, 1)) - projGradStep = (model_row - gradModel.transpose())*(model_row - (gradModel.transpose()>0).astype(float)) - wk = np.linalg.norm(model_row-projGradStep) + projGradStep = (model_row - gradModel.transpose()) * ( + model_row - (gradModel.transpose() > 0).astype(float) + ) + wk = np.linalg.norm(model_row - projGradStep) # Determine active and free variables num_free = 0 - free_indices_tmp = np.zeros((rank, )).astype(int) + free_indices_tmp = np.zeros((rank,)).astype(int) for r in range(rank): if (model_row[r] <= np.minimum(epsActSet, wk)) and (gradModel[r] > 0): # Variable is not free (belongs to set A or G) - if (model_row[r] != 0): + if model_row[r] != 0: # Variable moves according to the gradient (set G). search_dir[r] = -gradModel[r] else: # Variable is free (set F). num_free += 1 - free_indices_tmp[num_free-1] = r + free_indices_tmp[num_free - 1] = r free_indices = free_indices_tmp[0:num_free] @@ -1030,7 +1270,9 @@ def getSearchDirPdnr(Pi, ups_row, rank, gradModel, model_row, mu, epsActSet): # TODO verify this is appropriate representation of matlab's method, s.b. because hessian is square, and addition # should ensure full rank, try.catch handles singular matrix try: - search_dir[free_indices] = np.linalg.solve(Hessian_free + (mu * np.eye(num_free)), grad_free)[:, None] + search_dir[free_indices] = np.linalg.solve( + Hessian_free + (mu * np.eye(num_free)), grad_free + )[:, None] except np.linalg.LinAlgError: warnings.warn("CP_APR: Damped Hessian is nearly singular\n") # TODO: note this may be a typo in matlab see line 1107 @@ -1038,16 +1280,36 @@ def getSearchDirPdnr(Pi, ups_row, rank, gradModel, model_row, mu, epsActSet): # Calculate expected reduction in the quadratic model of the objective. # TODO: double check if left or right multiplication has an speed effect, memory layout - q = search_dir[free_indices].transpose().dot(Hessian_free + (mu*np.eye(num_free))).dot(search_dir[free_indices]) - pred_red = (search_dir[free_indices].transpose().dot(gradModel[free_indices])) + (0.5*q) + q = ( + search_dir[free_indices] + .transpose() + .dot(Hessian_free + (mu * np.eye(num_free))) + .dot(search_dir[free_indices]) + ) + pred_red = (search_dir[free_indices].transpose().dot(gradModel[free_indices])) + ( + 0.5 * q + ) if pred_red > 0: warnings.warn("CP_APR: Expected decrease in objective is positive\n") search_dir = -gradModel return search_dir, pred_red -def tt_linesearch_prowsubprob(direction, grad, model_old, step_len, step_red, max_steps, suff_decr, isSparse, - data_row, Pi, phi_row, display_warning): + +def tt_linesearch_prowsubprob( + direction, + grad, + model_old, + step_len, + step_red, + max_steps, + suff_decr, + isSparse, + data_row, + Pi, + phi_row, + display_warning, +): """ Perform a line search on a row subproblem @@ -1103,8 +1365,8 @@ def tt_linesearch_prowsubprob(direction, grad, model_old, step_len, step_red, ma while count <= max_steps: # Compute a new step and project it onto the positive orthant. - model_new = model_old + stepSize*direction - model_new *= (model_new > 0) + model_new = model_old + stepSize * direction + model_new *= model_new > 0 # Check that it is a descent direction. gDotd = np.sum(grad * (model_new - model_old)) @@ -1125,7 +1387,7 @@ def tt_linesearch_prowsubprob(direction, grad, model_old, step_len, step_red, ma f_1 = f_new # Check for sufficient decrease. - if f_new <= (f_old + suff_decr*gDotd): + if f_new <= (f_old + suff_decr * gDotd): break else: stepSize *= step_red @@ -1147,7 +1409,7 @@ def tt_linesearch_prowsubprob(direction, grad, model_old, step_len, step_red, ma model_new = model_old * phi_row # multiplicative update # Project to the constraints and reevaluate the subproblem objective - model_new *= (model_new > 0) + model_new *= model_new > 0 f_new = -ttb.tt_loglikelihood_row(isSparse, data_row, model_new, Pi) num_evals += 1 @@ -1155,7 +1417,9 @@ def tt_linesearch_prowsubprob(direction, grad, model_old, step_len, step_red, ma f_1 = f_old if display_warning: - warnings.warn("CP_APR: Line search failed, using multiplicative update step") + warnings.warn( + "CP_APR: Line search failed, using multiplicative update step" + ) return model_new, f_old, f_1, f_new, num_evals @@ -1184,10 +1448,11 @@ def getHessian(upsilon, Pi, free_indices): for j in range(num_free): c = free_indices[i] d = free_indices[j] - val = np.sum(upsilon.transpose()*Pi[:, c]*Pi[:, d]) + val = np.sum(upsilon.transpose() * Pi[:, c] * Pi[:, d]) H[(i, j), (j, i)] = val return H + def tt_loglikelihood_row(isSparse, data_row, model_row, Pi): """ Compute log-likelihood of one row subproblem @@ -1227,7 +1492,7 @@ def tt_loglikelihood_row(isSparse, data_row, model_row, Pi): """ term1 = -np.sum(model_row) if isSparse: - term2 = np.sum(data_row.transpose() * np.log(model_row.dot(Pi))) + term2 = np.sum(data_row.transpose() * np.log(model_row.dot(Pi.transpose()))) else: b_pi = model_row.dot(Pi.transpose()) term2 = 0 @@ -1238,8 +1503,19 @@ def tt_loglikelihood_row(isSparse, data_row, model_row, Pi): loglikelihood = term1 + term2 return loglikelihood + # PQNR helper functions -def getSearchDirPqnr(model_row, gradModel, epsActSet, delta_model, delta_grad, rho, lbfgs_pos, iters, disp_warn): +def getSearchDirPqnr( + model_row, + gradModel, + epsActSet, + delta_model, + delta_grad, + rho, + lbfgs_pos, + iters, + disp_warn, +): """ Compute the search direction by projecting with L-BFGS. @@ -1285,7 +1561,9 @@ def getSearchDirPqnr(model_row, gradModel, epsActSet, delta_model, delta_grad, r # fixedVars = find((m_row == 0) & (grad' > 0)); # For the general case this works but is less clear and assumes m_row > 0: # fixedVars = find((grad' > 0) & (m_row <= min(epsActSet,grad'))); - projGradStep = (model_row - gradModel.transpose()) * (model_row - (gradModel.transpose() > 0).astype(float)) + projGradStep = (model_row - gradModel.transpose()) * ( + model_row - (gradModel.transpose() > 0).astype(float) + ) wk = np.linalg.norm(model_row - projGradStep) fixedVars = np.logical_and(gradModel > 0, (model_row <= np.minimum(epsActSet, wk))) @@ -1298,28 +1576,35 @@ def getSearchDirPqnr(model_row, gradModel, epsActSet, delta_model, delta_grad, r warnings.warn("WARNING: L-BFGS update is orthogonal, using gradient") return direction - alpha = np.ones((lbfgsSize, )) + alpha = np.ones((lbfgsSize,)) k = lbfgs_pos # Perform an L-BFGS two-loop recursion to compute the search direction. for i in range(np.minimum(iters, lbfgsSize)): - alpha[k] = rho[k]*(delta_model[:, k].transpose().dot(direction)) - direction -= alpha[k]*(delta_grad[:, k]) + alpha[k] = rho[k] * (delta_model[:, k].transpose().dot(direction)) + direction -= alpha[k] * (delta_grad[:, k]) # TODO check mod - k = lbfgsSize - np.mod(1-k, lbfgsSize) - 1 # -1 accounts for numpy indexing starting at 0 not 1 - - coef = 1 / rho[lbfgs_pos] / delta_grad[:, lbfgs_pos].transpose().dot(delta_grad[:, lbfgs_pos]) + k = ( + lbfgsSize - np.mod(1 - k, lbfgsSize) - 1 + ) # -1 accounts for numpy indexing starting at 0 not 1 + + coef = ( + 1 + / rho[lbfgs_pos] + / delta_grad[:, lbfgs_pos].transpose().dot(delta_grad[:, lbfgs_pos]) + ) direction *= coef for i in range(np.minimum(iters, lbfgsSize)): - k = np.mod(k, lbfgsSize) #+ 1 - b = rho[k]*(delta_grad[:, k].transpose().dot(direction)) - direction += (alpha[k]-b)*(delta_model[:, k]) + k = np.mod(k, lbfgsSize) # + 1 + b = rho[k] * (delta_grad[:, k].transpose().dot(direction)) + direction += (alpha[k] - b) * (delta_model[:, k]) direction[fixedVars] = 0 return direction + def calc_grad(isSparse, Pi, eps_div_zero, data_row, model_row): """ Compute the gradient for a PQNR row subproblem @@ -1344,12 +1629,13 @@ def calc_grad(isSparse, Pi, eps_div_zero, data_row, model_row): v = model_row.dot(Pi.transpose()) w = data_row / np.maximum(v, eps_div_zero) phi_row = w.dot(Pi) - #print("V: {}, W :{}".format(v,w)) - #u = v**2 - #ups_row = data_row.transpose() / np.maximum(u, epsilon) + # print("V: {}, W :{}".format(v,w)) + # u = v**2 + # ups_row = data_row.transpose() / np.maximum(u, epsilon) grad_row = (np.ones(phi_row.shape) - phi_row).transpose() return grad_row, phi_row + # Mu helper functions def calculatePi(Data, Model, rank, factorIndex, ndims): """ @@ -1373,10 +1659,15 @@ def calculatePi(Data, Model, rank, factorIndex, ndims): for i in np.setdiff1d(np.arange(ndims), factorIndex).astype(int): Pi *= Model[i][Data.subs[:, i], :] else: - Pi = ttb.khatrirao(Model.factor_matrices[:factorIndex]+Model.factor_matrices[factorIndex+1:], reverse=True) + Pi = ttb.khatrirao( + Model.factor_matrices[:factorIndex] + + Model.factor_matrices[factorIndex + 1 :], + reverse=True, + ) return Pi + def calculatePhi(Data, Model, rank, factorIndex, Pi, epsilon): """ @@ -1396,20 +1687,25 @@ def calculatePhi(Data, Model, rank, factorIndex, Pi, epsilon): if isinstance(Data, ttb.sptensor): Phi = -np.ones((Data.shape[factorIndex], rank)) xsubs = Data.subs[:, factorIndex] - v = np.sum(Model.factor_matrices[factorIndex][xsubs, :]*Pi, axis=1) + v = np.sum(Model.factor_matrices[factorIndex][xsubs, :] * Pi, axis=1) wvals = Data.vals / np.maximum(v, epsilon)[:, None] for r in range(rank): - Yr = accumarray(xsubs, np.squeeze(wvals*Pi[:, r][:, None]), size=Data.shape[factorIndex]) + Yr = accumarray( + xsubs, + np.squeeze(wvals * Pi[:, r][:, None]), + size=Data.shape[factorIndex], + ) Phi[:, r] = Yr else: Xn = ttb.tt_to_dense_matrix(Data, factorIndex) V = Model.factor_matrices[factorIndex].dot(Pi.transpose()) - W = Xn/np.maximum(V, epsilon) + W = Xn / np.maximum(V, epsilon) Y = W.dot(Pi) Phi = Y return Phi + def tt_loglikelihood(Data, Model): """ Compute log-likelihood of data with model. @@ -1439,7 +1735,9 @@ def tt_loglikelihood(Data, Model): A = Model.factor_matrices[0][xsubs[:, 0], :] for n in range(1, N): A *= Model.factor_matrices[n][xsubs[:, n], :] - return np.sum(Data.vals*np.log(np.sum(A, axis=1))[:, None]) - np.sum(Model.factor_matrices[0]) + return np.sum(Data.vals * np.log(np.sum(A, axis=1))[:, None]) - np.sum( + Model.factor_matrices[0] + ) else: dX = ttb.tt_to_dense_matrix(Data, 1) dM = ttb.tt_to_dense_matrix(Model, 1) @@ -1454,6 +1752,7 @@ def tt_loglikelihood(Data, Model): f -= np.sum(Model.factor_matrices[0]) return f + def vectorizeForMu(matrix): """ Helper Function to unravel matrix into vector diff --git a/pyttb/export_data.py b/pyttb/export_data.py index 0e11c378..c8a6ccbb 100644 --- a/pyttb/export_data.py +++ b/pyttb/export_data.py @@ -2,35 +2,39 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. +import os + +import numpy as np + import pyttb as ttb + from .pyttb_utils import * -import numpy as np -import os + def export_data(data, filename, fmt_data=None, fmt_weights=None): """ Export tensor-related data to a file. """ # open file - fp = open(filename, 'w') + fp = open(filename, "w") if isinstance(data, ttb.tensor): - print('tensor', file=fp) + print("tensor", file=fp) export_size(fp, data.shape) export_array(fp, data.data, fmt_data) elif isinstance(data, ttb.sptensor): - print('sptensor', file=fp) + print("sptensor", file=fp) export_sparse_size(fp, data) export_sparse_array(fp, data, fmt_data) elif isinstance(data, ttb.ktensor): - print('ktensor', file=fp) + print("ktensor", file=fp) export_size(fp, data.shape) export_rank(fp, data) export_weights(fp, data, fmt_weights) for n in range(data.ndims): - print('matrix', file=fp) + print("matrix", file=fp) export_size(fp, data.factor_matrices[n].shape) export_factor(fp, data.factor_matrices[n], fmt_data) """ @@ -46,59 +50,70 @@ def export_data(data, filename, fmt_data=None, fmt_weights=None): """ elif isinstance(data, np.ndarray): - print('matrix', file=fp) + print("matrix", file=fp) export_size(fp, data.shape) export_array(fp, data, fmt_data) else: - assert False, 'Invalid data type for export' + assert False, "Invalid data type for export" + def export_size(fp, shape): # Export the size of something to a file - print(f'{len(shape)}', file=fp) # # of dimensions on one line - shape_str = ' '.join([str(d) for d in shape]) - print(f'{shape_str}', file=fp) # size of each dimensions on the next line + print(f"{len(shape)}", file=fp) # # of dimensions on one line + shape_str = " ".join([str(d) for d in shape]) + print(f"{shape_str}", file=fp) # size of each dimensions on the next line + def export_rank(fp, data): # Export the rank of a ktensor to a file - print(f'{len(data.weights)}', file=fp) # ktensor rank on one line + print(f"{len(data.weights)}", file=fp) # ktensor rank on one line + def export_weights(fp, data, fmt_weights): # Export dense data that supports numel and linear indexing - if not fmt_weights: fmt_weights = '%.16e' - data.weights.tofile(fp, sep=' ', format=fmt_weights) + if not fmt_weights: + fmt_weights = "%.16e" + data.weights.tofile(fp, sep=" ", format=fmt_weights) print(file=fp) + def export_array(fp, data, fmt_data): # Export dense data that supports numel and linear indexing - if not fmt_data: fmt_data = '%.16e' - data.tofile(fp, sep='\n', format=fmt_data) + if not fmt_data: + fmt_data = "%.16e" + data.tofile(fp, sep="\n", format=fmt_data) print(file=fp) + def export_factor(fp, data, fmt_data): # Export dense data that supports numel and linear indexing - if not fmt_data: fmt_data = '%.16e' + if not fmt_data: + fmt_data = "%.16e" for i in range(data.shape[0]): - row = data[i,:] - row.tofile(fp, sep=' ', format=fmt_data) + row = data[i, :] + row.tofile(fp, sep=" ", format=fmt_data) print(file=fp) + def export_sparse_size(fp, A): # 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 - print(f'{A.nnz}', file=fp) # number of nonzeros + 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 + print(f"{A.nnz}", file=fp) # number of nonzeros + def export_sparse_array(fp, A, fmt_data): # Export sparse array data in coordinate format - if not fmt_data: fmt_data = '%.16e' + if not fmt_data: + fmt_data = "%.16e" # TODO: looping through all values may take a long time, can this be more efficient? for i in range(A.nnz): # 0-based indexing in package, 1-based indexing in file - subs = A.subs[i,:] + 1 - subs.tofile(fp, sep=' ', format="%d") - print(end=' ', file=fp) + subs = A.subs[i, :] + 1 + subs.tofile(fp, sep=" ", format="%d") + print(end=" ", file=fp) val = A.vals[i][0] - val.tofile(fp, sep=' ', format=fmt_data) + val.tofile(fp, sep=" ", format=fmt_data) print(file=fp) diff --git a/pyttb/hosvd.py b/pyttb/hosvd.py new file mode 100644 index 00000000..51ccddfa --- /dev/null +++ b/pyttb/hosvd.py @@ -0,0 +1,138 @@ +"""Higher Order SVD Implementation""" +import warnings +from typing import List, Optional + +import numpy as np +import scipy + +import pyttb as ttb + + +def hosvd( + input_tensor, + tol: float, + verbosity: float = 1, + dimorder: Optional[List[int]] = None, + sequential: bool = True, + ranks: Optional[List[int]] = None, +): + """Compute sequentially-truncated higher-order SVD (Tucker). + + Computes a Tucker decomposition with relative error + specified by tol, i.e., it computes a ttensor T such that + ||X-T||/||X|| <= tol. + + Parameters + ---------- + input_tensor: Tensor to factor + tol: Relative error to stop at + verbosity: Print level + dimorder: Order to loop through dimensions + sequential: Use sequentially-truncated version + ranks: Specify ranks to consider rather than computing + + Example + ------- + >>> data = np.array([[29, 39.], [63., 85.]]) + >>> tol = 1e-4 + >>> disable_printing = -1 + >>> tensorInstance = ttb.tensor().from_data(data) + >>> result = hosvd(tensorInstance, tol, verbosity=disable_printing) + >>> ((result.full() - tensorInstance).norm() / tensorInstance.norm()) < tol + True + """ + # In tucker als this is N + d = input_tensor.ndims + + if ranks is not None: + if len(ranks) != d: + raise ValueError( + f"Ranks must be a list of length tensor ndims. Ndims: {d} but got " + f"ranks: {ranks}." + ) + else: + ranks = [0] * d + + # Set up dimorder if not specified (this is copy past from tucker_als + if not dimorder: + dimorder = list(range(d)) + else: + if not isinstance(dimorder, list): + raise ValueError("Dimorder must be a list") + elif tuple(range(d)) != tuple(sorted(dimorder)): + raise ValueError( + "Dimorder must be a list or permutation of range(tensor.ndims)" + ) + + # TODO should unify printing throughout. Probably easier to use python logging levels + if verbosity > 0: + print("Computing HOSVD...\n") + + normxsqr = (input_tensor**2).collapse() + eigsumthresh = ((tol**2) * normxsqr) / d + + if verbosity > 2: + print( + f"||X||^2 = {normxsqr: g}\n" + f"tol = {tol: g}\n" + f"eigenvalue sum threshold = tol^2 ||X||^2 / d = {eigsumthresh: g}" + ) + + # Main Loop + factor_matrices = [np.empty(1)] * d + # Copy input tensor, shrinks every step for sequential + Y = ttb.tensor.from_tensor_type(input_tensor) + + for k in dimorder: + # Compute Gram matrix + Yk = ttb.tenmat.from_tensor_type(Y, np.array([k])).double() + Z = np.dot(Yk, Yk.transpose()) + + # Compute eigenvalue decomposition + D, V = scipy.linalg.eigh(Z) + pi = np.argsort(-D, kind="quicksort") + eigvec = D[pi] + + # If rank not provided compute it. + if ranks[k] == 0: + eigsum = np.cumsum(eigvec[::-1]) + eigsum = eigsum[::-1] + ranks[k] = np.where(eigsum > eigsumthresh)[0][-1] + + if verbosity > 5: + print(f"Reverse cummulative sum of evals of Gram matrix:") + for i in range(len(eigsum)): + print_msg = f"{i: d}: {eigsum[i]: 6.4f}" + if i == ranks[k]: + print_msg += " <-- Cutoff" + print(print_msg) + + # Extract factor matrix b picking leading eigenvectors of V + # NOTE: Plus 1 in pi slice for inclusive range to match MATLAB + factor_matrices[k] = V[:, pi[0 : ranks[k] + 1]] + + # Shrink! + if sequential: + Y = Y.ttm(factor_matrices[k].transpose(), k) + # Extract final core + if sequential: + G = Y + else: + G = Y.ttm(factor_matrices, transpose=True) + + result = ttb.ttensor.from_data(G, factor_matrices) + + if verbosity > 0: + diffnormsqr = ((input_tensor - result.full()) ** 2).collapse() + relnorm = np.sqrt(diffnormsqr / normxsqr) + print(f" Size of core: {G.shape}") + if relnorm <= tol: + print(f"||X-T||/||X|| = {relnorm: g} <=" f"{tol: f} (tol)") + else: + print( + "Tolerance not satisfied!! " + f"||X-T||/||X|| = {relnorm: g} >=" + f"{tol: f} (tol)" + ) + warnings.warn("Specified tolerance was not achieved") + return result diff --git a/pyttb/import_data.py b/pyttb/import_data.py index 668f79e7..bdb2aa7d 100644 --- a/pyttb/import_data.py +++ b/pyttb/import_data.py @@ -2,94 +2,99 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. +import os + +import numpy as np + import pyttb as ttb + from .pyttb_utils import * -import numpy as np -import os -def import_data(filename): +def import_data(filename): # Check if file exists if not os.path.isfile(filename): assert False, f"File path {filename} does not exist." # import - fp = open(filename, 'r') + fp = open(filename, "r") # tensor type should be on the first line # valid: tensor, sptensor, matrix, ktensor data_type = import_type(fp) - if data_type not in ['tensor','sptensor','matrix','ktensor']: + if data_type not in ["tensor", "sptensor", "matrix", "ktensor"]: assert False, f"Invalid data type found: {data_type}" - - if data_type == 'tensor': - + + if data_type == "tensor": shape = import_shape(fp) data = import_array(fp, np.prod(shape)) return ttb.tensor().from_data(data, shape) - - elif data_type == 'sptensor': - + + elif data_type == "sptensor": shape = import_shape(fp) nz = import_nnz(fp) subs, vals = import_sparse_array(fp, len(shape), nz) return ttb.sptensor().from_data(subs, vals, shape) - - elif data_type == 'matrix': + elif data_type == "matrix": shape = import_shape(fp) mat = import_array(fp, np.prod(shape)) mat = np.reshape(mat, np.array(shape)) return mat - - elif data_type == 'ktensor': + elif data_type == "ktensor": shape = import_shape(fp) r = import_rank(fp) weights = import_array(fp, r) factor_matrices = [] for n in range(len(shape)): - fac_type = fp.readline().strip() - fac_shape = import_shape(fp) - fac = import_array(fp, np.prod(fac_shape)) - fac = np.reshape(fac, np.array(fac_shape)) - factor_matrices.append(fac) + fac_type = fp.readline().strip() + fac_shape = import_shape(fp) + fac = import_array(fp, np.prod(fac_shape)) + fac = np.reshape(fac, np.array(fac_shape)) + factor_matrices.append(fac) return ttb.ktensor().from_data(weights, factor_matrices) - + # Close file fp.close() + def import_type(fp): # Import IO data type - return fp.readline().strip().split(' ')[0] + return fp.readline().strip().split(" ")[0] + def import_shape(fp): # Import the shape of something from a file - n = int(fp.readline().strip().split(' ')[0]) - shape = [int(d) for d in fp.readline().strip().split(' ')] + n = int(fp.readline().strip().split(" ")[0]) + shape = [int(d) for d in fp.readline().strip().split(" ")] if len(shape) != n: assert False, "Imported dimensions are not of expected size" return tuple(shape) + def import_nnz(fp): # Import the size of something from a file - return int(fp.readline().strip().split(' ')[0]) + return int(fp.readline().strip().split(" ")[0]) + def import_rank(fp): # Import the rank of something from a file - return int(fp.readline().strip().split(' ')[0]) + return int(fp.readline().strip().split(" ")[0]) + def import_sparse_array(fp, n, nz): # Import sparse data subs and vals from coordinate format data - subs = np.zeros((nz, n), dtype='int64') + subs = np.zeros((nz, n), dtype="int64") vals = np.zeros((nz, 1)) for k in range(nz): - line = fp.readline().strip().split(' ') + line = fp.readline().strip().split(" ") # 1-based indexing in file, 0-based indexing in package - subs[k,:] = [np.int64(i)-1 for i in line[:-1]] - vals[k,0] = line[-1] + subs[k, :] = [np.int64(i) - 1 for i in line[:-1]] + vals[k, 0] = line[-1] return subs, vals + def import_array(fp, n): - return np.fromfile(fp, count=n, sep=' ') + return np.fromfile(fp, count=n, sep=" ") diff --git a/pyttb/khatrirao.py b/pyttb/khatrirao.py index a364cfe7..aded4497 100644 --- a/pyttb/khatrirao.py +++ b/pyttb/khatrirao.py @@ -4,6 +4,7 @@ import numpy as np + def khatrirao(*listOfMatrices, reverse=False): """ KHATRIRAO Khatri-Rao product of matrices. @@ -24,53 +25,60 @@ def khatrirao(*listOfMatrices, reverse=False): Examples -------- - >>>A = np.random.norm(size=(5,2)) - >>>khatrirao(A,B) #<-- Khatri-Rao of A and B - >>>>khatrirao(B,A,reverse=True) #<-- same thing as above - >>>>khatrirao([A,A,B]) #<-- passing a list - >>>>khatrirao([B,A,A},reverse = True) #<-- same as above + >>> A = np.random.normal(size=(5,2)) + >>> B = np.random.normal(size=(5,2)) + >>> _ = khatrirao(A,B) #<-- Khatri-Rao of A and B + >>> _ = khatrirao(B,A,reverse=True) #<-- same thing as above + >>> _ = khatrirao([A,A,B]) #<-- passing a list + >>> _ = khatrirao([B,A,A],reverse = True) #<-- same as above """ - #Determine if list of matrices of multiple matrix arguments + # Determine if list of matrices of multiple matrix arguments if isinstance(listOfMatrices[0], list): if len(listOfMatrices) == 1: listOfMatrices = listOfMatrices[0] else: - assert False, "Khatri Rao Acts on multiple Array arguments or a list of Arrays" + assert ( + False + ), "Khatri Rao Acts on multiple Array arguments or a list of Arrays" # Error checking on input and set matrix order if reverse == True: listOfMatrices = list(reversed(listOfMatrices)) ndimsA = [len(matrix.shape) == 2 for matrix in listOfMatrices] if not np.all(ndimsA): - assert False, 'Each argument must be a matrix' + assert False, "Each argument must be a matrix" ncolFirst = listOfMatrices[0].shape[1] ncols = [matrix.shape[1] == ncolFirst for matrix in listOfMatrices] if not np.all(ncols): - assert False, 'All matrices must have the same number of columns.' + assert False, "All matrices must have the same number of columns." # Computation - #print(f'A =\n {listOfMatrices}') + # print(f'A =\n {listOfMatrices}') P = listOfMatrices[0] - #print(f'size_P = \n{P.shape}') - #print(f'P = \n{P}') + # print(f'size_P = \n{P.shape}') + # print(f'P = \n{P}') if ncolFirst == 1: for i in listOfMatrices[1:]: - #print(f'size_Ai = \n{i.shape}') - #print(f'size_reshape_Ai = \n{np.reshape(i, newshape=(-1, ncolFirst)).shape}') - #print(f'size_P = \n{P.shape}') - #print(f'size_reshape_P = \n{np.reshape(P, newshape=(ncolFirst, -1)).shape}') - P = np.reshape(i, newshape=(-1, ncolFirst))*np.reshape(P, newshape=(ncolFirst, -1),order='F') - #print(f'size_P = \n{P.shape}') - #print(f'P = \n{P}') + # print(f'size_Ai = \n{i.shape}') + # print(f'size_reshape_Ai = \n{np.reshape(i, newshape=(-1, ncolFirst)).shape}') + # print(f'size_P = \n{P.shape}') + # print(f'size_reshape_P = \n{np.reshape(P, newshape=(ncolFirst, -1)).shape}') + P = np.reshape(i, newshape=(-1, ncolFirst)) * np.reshape( + P, newshape=(ncolFirst, -1), order="F" + ) + # print(f'size_P = \n{P.shape}') + # print(f'P = \n{P}') else: - for i in listOfMatrices[1:]: - #print(f'size_Ai = \n{i.shape}') - #print(f'size_reshape_Ai = \n{np.reshape(i, newshape=(-1, 1, ncolFirst)).shape}') - #print(f'size_P = \n{P.shape}') - #print(f'size_reshape_P = \n{np.reshape(P, newshape=(1, -1, ncolFirst)).shape}') - P = np.reshape(i, newshape=(-1, 1, ncolFirst))*np.reshape(P, newshape=(1, -1, ncolFirst), order='F') - #print(f'size_P = \n{P.shape}') - #print(f'P = \n{P}') + for i in listOfMatrices[1:]: + # print(f'size_Ai = \n{i.shape}') + # print(f'size_reshape_Ai = \n{np.reshape(i, newshape=(-1, 1, ncolFirst)).shape}') + # print(f'size_P = \n{P.shape}') + # print(f'size_reshape_P = \n{np.reshape(P, newshape=(1, -1, ncolFirst)).shape}') + P = np.reshape(i, newshape=(-1, 1, ncolFirst)) * np.reshape( + P, newshape=(1, -1, ncolFirst), order="F" + ) + # print(f'size_P = \n{P.shape}') + # print(f'P = \n{P}') - return np.reshape(P, newshape=(-1, ncolFirst), order='F') + return np.reshape(P, newshape=(-1, ncolFirst), order="F") diff --git a/pyttb/ktensor.py b/pyttb/ktensor.py index 5bb50d9b..58b068d3 100644 --- a/pyttb/ktensor.py +++ b/pyttb/ktensor.py @@ -3,75 +3,94 @@ # U.S. Government retains certain rights in this software. """Classes and functions for working with Kruskal tensors.""" +from __future__ import annotations -import pyttb as ttb -from .pyttb_utils import * -import numpy as np +import logging import warnings + +import numpy as np import scipy.sparse as sparse import scipy.sparse.linalg +import pyttb as ttb +from pyttb.pyttb_utils import * + + class ktensor(object): """ - Class for Kruskal tensors (decomposed). + KTENSOR Class for Kruskal tensors (decomposed). Contains the following data members: - * ``weights``: :class:`numpy.ndarray` vector containing the weights of the rank-1 tensors defined by the outer \ - products of the column vectors of the factor_matrices. + ``weights``: :class:`numpy.ndarray` vector containing the weights of the + rank-1 tensors defined by the outer products of the column vectors of the + factor_matrices. - * ``factor_matrices``: :class:`list` of :class:`numpy.ndarray`. The length of the list is equal to the number \ - of dimensions of the tensor. The shape of the ith element of the list is (n_i, r), where n_i is the length \ - dimension i and r is the rank of the tensor (as well as the length of the weights vector). + ``factor_matrices``: :class:`list` of :class:`numpy.ndarray`. The length + of the list is equal to the number of dimensions of the tensor. The shape + of the ith element of the list is (n_i, r), where n_i is the length + dimension i and r is the rank of the tensor (as well as the length of the + weights vector). - Although the constructor, `__init__()`, can be used to create an empty :class:`pyttb.ktensor`, there - are several class methods that can be used to create an instance of this class: + Although the constructor `__init__()` can be used to create an empty + :class:`pyttb.ktensor`, there are several class methods that can be used + to create an instance of this class: * :meth:`from_data` * :meth:`from_tensor_type` * :meth:`from_factor_matrices` * :meth:`from_function` * :meth:`from_vector` + + Examples + -------- + For all examples listed below, the following module imports are assumed: + + >>> import pyttb as ttb + >>> import numpy as np """ - __slots__ = ('weights', 'factor_matrices') + __slots__ = ("weights", "factor_matrices") def __init__(self): """ - Constructor for :class:`pyttb.ktensor` + Construct an empty :class:`pyttb.ktensor` - The constructor takes no arguments and returns an empty :class:`pyttb.ktensor`. + The constructor takes no arguments and returns an empty + :class:`pyttb.ktensor`. """ # Empty constructor - self.weights = np.array([]) # renamed from lambda to weights - self.factor_matrices = [] # changed from cell array to list; changed name from 'u' to 'factor_matrices' + self.weights = np.array([]) + self.factor_matrices = [] @classmethod def from_data(cls, weights, *factor_matrices): """ Construct a :class:`pyttb.ktensor` from weights and factor matrices. - The length of the list or the number of arguments specified by `factor_matrices` must equal - the length of `weights` + The length of the list or the number of arguments specified by + `factor_matrices` must equal the length of `weights`. See + :class:`pyttb.ktensor` for parameter descriptions. Parameters ---------- - weights: :class:`numpy.ndarray` - factor_matrices: :class:`list` of :class:`numpy.ndarray` or variable number of :class:`numpy.ndarray` + weights: :class:`numpy.ndarray`, required + factor_matrices: :class:`list` of :class:`numpy.ndarray` or variable number of :class:`numpy.ndarray`, required Returns ------- :class:`pyttb.ktensor` - Example - ------- - Create a `ktensor` from weights and a list of factor matrices: + Examples + -------- + Create a :class:`pyttb.ktensor` from weights and a list of factor + matrices: >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) >>> fm1 = np.array([[5., 6.], [7., 8.]]) - >>> K_from_list = ttb.ktensor.from_data(weights, [fm0, fm1]) - >>> print(K_from_list) + >>> K = ttb.ktensor.from_data(weights, [fm0, fm1]) + >>> print(K) ktensor of shape 2 x 2 weights=[1. 2.] factor_matrices[0] = @@ -81,10 +100,11 @@ def from_data(cls, weights, *factor_matrices): [[5. 6.] [7. 8.]] - Create a `ktensor` from weights and a factor matrices passed as arguments: + Create a :class:`pyttb.ktensor` from weights and factor matrices passed as + arguments: - >>> K_from_args = ttb.ktensor.from_data(weights, fm0, fm1) - >>> print(K_from_args) + >>> K = ttb.ktensor.from_data(weights, fm0, fm1) + >>> print(K) ktensor of shape 2 x 2 weights=[1. 2.] factor_matrices[0] = @@ -95,8 +115,9 @@ def from_data(cls, weights, *factor_matrices): [7. 8.]] """ # Check individual input parameters - assert (isinstance(weights, np.ndarray) and isvector(weights)),\ - "Input parameter 'weights' must be a numpy.array type vector." + assert isinstance(weights, np.ndarray) and isvector( + weights + ), "Input parameter 'weights' must be a numpy.array type vector." # Input can be a list or sequences of factor_matrices if isinstance(factor_matrices[0], list): @@ -104,46 +125,55 @@ def from_data(cls, weights, *factor_matrices): else: _factor_matrices = [f.copy() for f in factor_matrices[0:]] for fm in _factor_matrices: - assert isinstance(fm, np.ndarray), \ - "Input parameter 'factor_matrices' must be a list of numpy.array's." + assert isinstance( + fm, np.ndarray + ), "Input parameter 'factor_matrices' must be a list of numpy.array's." # Check dimensions of weights and factor_matrices num_weights = len(weights) for i, fm in enumerate(_factor_matrices): - assert (num_weights == fm.shape[1]), \ - "Size of factor_matrix {} does not match number of weights ({}).".format(i, num_weights) + assert ( + num_weights == fm.shape[1] + ), "Size of factor_matrix {} does not match number of weights ({}).".format( + i, num_weights + ) # Create ktensor and populate data members k = cls() k.weights = weights.copy() - if k.weights.dtype != np.float: - print("converting weights from {} to np.float".format(k.weights.dtype)) - k.weights = k.weights.astype(np.float) + if k.weights.dtype != float: + print("converting weights from {} to float".format(k.weights.dtype)) + k.weights = k.weights.astype(float) k.factor_matrices = _factor_matrices for i in range(len(k.factor_matrices)): - if k.factor_matrices[i].dtype != np.float: - print("converting factor_matrices[{}] from {} to np.float".format(i, k.factor_matrices[i].dtype)) - k.factor_matrices[i] = k.factor_matrices[i].astype(np.float) + if k.factor_matrices[i].dtype != float: + print( + "converting factor_matrices[{}] from {} to float".format( + i, k.factor_matrices[i].dtype + ) + ) + k.factor_matrices[i] = k.factor_matrices[i].astype(float) return k @classmethod - def from_tensor_type(cls, source): + def from_tensor_type(cls, source) -> ktensor: """ - Construct a ktensor from another ktensor. A deep copy of the data from the input ktensor - is used for the new ktensor. + Construct a :class:`pyttb.ktensor` from another + :class:`pyttb.ktensor`. A deep copy of the data from the input + :class:`pyttb.ktensor` is used for the new :class:`pyttb.ktensor`. Parameters ---------- - source: :class:`pyttb.ktensor` + source: :class:`pyttb.ktensor`, required Returns ------- :class:`pyttb.ktensor` - Example - ------- - Create an instance of a `ktensor`: + Examples + -------- + Create an instance of a :class:`pyttb.ktensor`: >>> fm0 = np.array([[1., 2.], [3., 4.]]) >>> fm1 = np.array([[5., 6.], [7., 8.]]) @@ -159,10 +189,11 @@ def from_tensor_type(cls, source): [[5. 6.] [7. 8.]] - Create another instance of a `ktensor` from the original one above: + Create another instance of a :class:`pyttb.ktensor` from the original + one above: - >>> K_copy = ttb.ktensor.from_tensor_type(K_source) - >>> print(K_copy) + >>> K = ttb.ktensor.from_tensor_type(K_source) + >>> print(K) ktensor of shape 2 x 2 weights=[1. 1.] factor_matrices[0] = @@ -172,10 +203,12 @@ def from_tensor_type(cls, source): [[5. 6.] [7. 8.]] - See also :func:`~pyttb.ktensor.copy` + See also :func:`pyttb.ktensor.copy` """ if isinstance(source, ktensor): - return cls().from_data(source.weights.copy(), [f.copy() for f in source.factor_matrices]) + return cls().from_data( + source.weights.copy(), [f.copy() for f in source.factor_matrices] + ) # TODO impement conversion when symktensor has been implemented # if isinstance(source, ttb.symktensor): @@ -189,26 +222,29 @@ def from_tensor_type(cls, source): @classmethod def from_factor_matrices(cls, *factor_matrices): """ - Construct a ktensor from factor matrices. The weights of the returned ktensor will all be equal to 1. + Construct a :class:`pyttb.ktensor` from factor matrices. The weights + of the returned :class:`pyttb.ktensor` will all be equal to 1. Parameters ---------- - factor_matrices: :class:`list` of :class:`numpy.ndarray` or variable number of :class:`numpy.ndarray` - The number of columns of each of the factor matrices must be the same. + factor_matrices: :class:`list` of :class:`numpy.ndarray` or variable number of :class:`numpy.ndarray`, required + The number of columns of each of the factor matrices must be the + same. Returns ------- :class:`pyttb.ktensor` - Example - ------- - Create a `ktensor` from a list of factor matrices: + Examples + -------- + Create a :class:`pyttb.ktensor` from a :class:`list` of factor + matrices: >>> fm0 = np.array([[1., 2.], [3., 4.]]) >>> fm1 = np.array([[5., 6.], [7., 8.]]) >>> factor_matrices = [fm0, fm1] - >>> K_from_list = ttb.ktensor.from_factor_matrices(factor_matrices) - >>> print(K_from_list) + >>> K = ttb.ktensor.from_factor_matrices(factor_matrices) + >>> print(K) ktensor of shape 2 x 2 weights=[1. 1.] factor_matrices[0] = @@ -218,10 +254,11 @@ def from_factor_matrices(cls, *factor_matrices): [[5. 6.] [7. 8.]] - Create a `ktensor` from a sequence of factor matrices passed as arguments: + Create a :class:`pyttb.ktensor` from factor matrices passed as + arguments: - >>> K_from_args = ttb.ktensor.from_factor_matrices(fm0, fm1) - >>> print(K_from_args) + >>> K = ttb.ktensor.from_factor_matrices(fm0, fm1) + >>> print(K) ktensor of shape 2 x 2 weights=[1. 1.] factor_matrices[0] = @@ -238,54 +275,60 @@ def from_factor_matrices(cls, *factor_matrices): else: _factor_matrices = [f for f in factor_matrices[0:]] for fm in _factor_matrices: - assert isinstance(fm, np.ndarray), \ - "Input parameter 'factor_matrices' must be a list of numpy.array's." + assert isinstance( + fm, np.ndarray + ), "Input parameter 'factor_matrices' must be a list of numpy.array's." nc = _factor_matrices[0].shape[1] return cls().from_data(np.ones(nc), _factor_matrices) @classmethod def from_function(cls, fun, shape, num_components): """ - Construct a ktensor whose factor matrix entries are set using a function. The weights of the returned ktensor will all be equal to 1. + Construct a :class:`pyttb.ktensor` whose factor matrix entries are + set using a function. The weights of the returned + :class:`pyttb.ktensor` will all be equal to 1. Parameters ---------- - fun: function - A function that can accept a shape (i.e., :class:`tuple` of dimension sizes) and return a :class:`numpy.ndarray` - of that shape. Example functions include `numpy.random.random_sample`, `numpy,zeros`, `numpy.ones`. - shape: :class:`tuple` - num_components: int + fun: function, required + A function that can accept a shape (i.e., :class:`tuple` of + dimension sizes) and return a :class:`numpy.ndarray` of that shape. + Example functions include `numpy.random.random_sample`, + `numpy,zeros`, `numpy.ones`. + shape: :class:`tuple`, required + num_components: int, required Returns ------- :class:`pyttb.ktensor` - Example - ------- - Create a `ktensor` with entries of the factor matrices taken from a uniform random distribution: + Examples + -------- + Create a :class:`pyttb.ktensor` with entries of the factor matrices + taken from a uniform random distribution: >>> np.random.seed(1) - >>> K_random = ttb.ktensor.from_function(np.random.random_sample, (2, 3, 4), 2) - >>> print(K_random) + >>> K = ttb.ktensor.from_function(np.random.random_sample, (2, 3, 4), 2) + >>> print(K) # doctest: +ELLIPSIS ktensor of shape 2 x 3 x 4 weights=[1. 1.] factor_matrices[0] = - [[4.17022005e-01 7.20324493e-01] - [1.14374817e-04 3.02332573e-01]] + [[4.1702...e-01 7.2032...e-01] + [1.1437...e-04 3.0233...e-01]] factor_matrices[1] = - [[0.14675589 0.09233859] - [0.18626021 0.34556073] - [0.39676747 0.53881673]] + [[0.1467... 0.0923...] + [0.1862... 0.3455...] + [0.3967... 0.5388...]] factor_matrices[2] = - [[0.41919451 0.6852195 ] - [0.20445225 0.87811744] - [0.02738759 0.67046751] - [0.4173048 0.55868983]] + [[0.4191... 0.6852...] + [0.2044... 0.8781...] + [0.0273... 0.6704...] + [0.4173... 0.5586...]] - Create a `ktensor` with entries equal to 1: + Create a :class:`pyttb.ktensor` with entries equal to 1: - >>> K_ones = ttb.ktensor.from_function(np.ones, (2, 3, 4), 2) - >>> print(K_ones) + >>> K = ttb.ktensor.from_function(np.ones, (2, 3, 4), 2) + >>> print(K) ktensor of shape 2 x 3 x 4 weights=[1. 1.] factor_matrices[0] = @@ -301,10 +344,10 @@ def from_function(cls, fun, shape, num_components): [1. 1.] [1. 1.]] - Create a `ktensor` with entries equal to 0: + Create a :class:`pyttb.ktensor` with entries equal to 0: - >>> K_zeros = ttb.ktensor.from_function(np.zeros, (2, 3, 4), 2) - >>> print(K_zeros) + >>> K = ttb.ktensor.from_function(np.zeros, (2, 3, 4), 2) + >>> print(K) ktensor of shape 2 x 3 x 4 weights=[1. 1.] factor_matrices[0] = @@ -323,7 +366,9 @@ def from_function(cls, fun, shape, num_components): # CONSTRUCTOR FROM FUNCTION HANDLE assert callable(fun), "Input parameter 'fun' must be a function." assert isinstance(shape, tuple), "Input parameter 'shape' must be a tuple." - assert isinstance(num_components, int), "Input parameter 'num_components' must be an int." + assert isinstance( + num_components, int + ), "Input parameter 'num_components' must be an int." nd = len(shape) weights = np.ones(num_components) factor_matrices = [] @@ -334,33 +379,40 @@ def from_function(cls, fun, shape, num_components): @classmethod def from_vector(cls, data, shape, contains_weights): """ - Construct a ktensor from a vector. The rank of the `ktensor` is inferred from the shape and size of the vector. + Construct a :class:`pyttb.ktensor` from a vector (given as a + :class:`numpy.ndarray`) and shape (given as a + :class:`numpy.ndarray`). The rank of the :class:`pyttb.ktensor` + is inferred from the shape and length of the vector. Parameters ---------- - data: :class:`numpy.ndarray` - Vector containing either elements of the factor matrices (when contains_weights==False) or - elements of the weights and factor matrices (when contains_weights==True). When both the - elements of the weights and the factor_matrices are present, the weights come first and - the columns of the factor matrices come next. - shape: :class:`numpy.ndarray` - Vector containing the shape of the tensor (i.e., sizes of the dimensions). - contains_weights: bool - Flag to specify whether or not ``data`` contains weights. If False, all weights are set to 1. + data: :class:`numpy.ndarray`, required + Vector containing either elements of the factor matrices (when + `contains_weights`==False) or elements of the weights and factor + matrices (when `contains_weights`==True). When both the elements of + the weights and the factor_matrices are present, the weights come + first and the columns of the factor matrices come next. + shape: :class:`numpy.ndarray`, required + Vector containing the shape of the tensor (i.e., lengths of the + dimensions). + contains_weights: bool, required + Flag to specify whether or not `data` contains weights. If False, + all weights are set to 1. Returns ------- :class:`pyttb.ktensor` - Example - ------- - Create a `ktensor` from a vector containing only elements of the factor matrices: + Examples + -------- + Create a :class:`pyttb.ktensor` from a vector containing only + elements of the factor matrices: >>> rank = 2 >>> shape = np.array([2, 3, 4]) - >>> data = np.arange(1, rank*sum(shape)+1).astype(np.float) - >>> K_without_weights = ttb.ktensor.from_vector(data[:], shape, False) - >>> print(K_without_weights) + >>> data = np.arange(1, rank*sum(shape)+1).astype(float) + >>> K = ttb.ktensor.from_vector(data[:], shape, False) + >>> print(K) ktensor of shape 2 x 3 x 4 weights=[1. 1.] factor_matrices[0] = @@ -376,12 +428,13 @@ def from_vector(cls, data, shape, contains_weights): [13. 17.] [14. 18.]] - Create a `ktensor` from a vector containing elements of both the weights and the factor matrices: + Create a :class:`pyttb.ktensor` from a vector containing elements + of both the weights and the factor matrices: - >>> weights = 2 * np.ones(rank).astype(np.float) + >>> weights = 2 * np.ones(rank).astype(float) >>> weights_and_data = np.concatenate((weights, data), axis=0) - >>> K_with_weights = ttb.ktensor.from_vector(weights_and_data[:], shape, True) - >>> print(K_with_weights) + >>> K = ttb.ktensor.from_vector(weights_and_data[:], shape, True) + >>> print(K) ktensor of shape 2 x 3 x 4 weights=[2. 2.] factor_matrices[0] = @@ -398,8 +451,12 @@ def from_vector(cls, data, shape, contains_weights): [14. 18.]] """ assert isvector(data), "Input parameter 'data' must be a numpy.array vector." - assert isinstance(shape, np.ndarray), "Input parameter 'shape' must be a numpy.array vector." - assert isinstance(contains_weights, bool), "Input parameter 'contains_weights' must be a bool." + assert isinstance( + shape, np.ndarray + ), "Input parameter 'shape' must be a numpy.array vector." + assert isinstance( + contains_weights, bool + ), "Input parameter 'contains_weights' must be a bool." if isrow(data): data = data.T @@ -427,30 +484,42 @@ def from_vector(cls, data, shape, contains_weights): factor_matrices = [] for n in range(len(shape)): mstart = num_components * sum(shape[0:n]) + shift - mend = num_components * sum(shape[0:n+1]) + shift + mend = num_components * sum(shape[0 : n + 1]) + shift # the following will match MATLAB output - factor_matrix = np.reshape(data[mstart:mend].copy(), (shape[n], num_components), order='F') + factor_matrix = np.reshape( + data[mstart:mend].copy(), (shape[n], num_components), order="F" + ) factor_matrices.append(factor_matrix) return cls().from_data(weights, factor_matrices) def arrange(self, weight_factor=None, permutation=None): """ - Arrange the rank-1 components of a `ktensor`. The columns are permuted in place, so you must make a copy - before calling this method if you want to store the original. + 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 + :class:`pyttb.ktensor`. If `weight_factor` is passed, then the values + in `self.weights` are absorbed into + `self.factor_matrices[weight_factor]`. If no parameters are passed, + then the columns of `self.factor_matrices` are normalized and then + permuted such that the resulting `self.weights` are sorted by + magnitude, greatest to least. Passing both parameters leads to an + error. Parameters ---------- - weight_factor: - permutation: - - Returns - ------- - :class:`pyttb.ktensor` + weight_factor: int, optional + The index of the factor matrix that the weights will be absorbed into. + permutation: :class:`tuple`, :class:`list`, or :class:`numpy.ndarray`, optional + The new order of the components of the :class:`pyttb.ktensor` + into which to permute. The permutation must be of length equal to + the number of components of the :class:`pyttb.ktensor`, `self.ncomponents` + and must be a permutation of [0,...,`self.ncomponents`-1]. - Example - ------- - Create the initial `ktensor`: + Examples + -------- + Create the initial :class:`pyttb.ktensor`: >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) @@ -479,22 +548,55 @@ def arrange(self, weight_factor=None, permutation=None): factor_matrices[1] = [[6. 5.] [8. 7.]] - """ - if permutation is not None and weight_factor is not None: - assert False, "Weighting and permuting the ktensor at the same time is not allowed." + Normalize and permute columns such that `weights` are sorted in + decreasing order: - if permutation is not None and isinstance(permutation, (tuple, list, np.ndarray)): + >>> K.arrange() + >>> print(K) # doctest: +ELLIPSIS + ktensor of shape 2 x 2 + weights=[89.4427... 27.2029...] + factor_matrices[0] = + [[0.4472... 0.3162...] + [0.8944... 0.9486...]] + factor_matrices[1] = + [[0.6... 0.5812...] + [0.8... 0.8137...]] + + Absorb the weights into the second factor: + + >>> K.arrange(weight_factor=1) + >>> print(K) # doctest: +ELLIPSIS + ktensor of shape 2 x 2 + weights=[1. 1.] + factor_matrices[0] = + [[0.4472... 0.3162...] + [0.8944... 0.9486...]] + factor_matrices[1] = + [[53.6656... 15.8113...] + [71.5541... 22.1359...]] + """ + if permutation is not None and weight_factor is not None: + assert ( + False + ), "Weighting and permuting the ktensor at the same time is not allowed." + + # arrange columns of factor matrices using the permutation provided + if permutation is not None and isinstance( + permutation, (tuple, list, np.ndarray) + ): if len(permutation) == self.ncomponents: self.weights = self.weights[permutation] for i in range(self.ndims): self.factor_matrices[i] = self.factor_matrices[i][:, permutation] return else: - assert False, "Number of elements in permutation does not match number of components in ktensor." + assert ( + False + ), "Number of elements in permutation does not match number of components in ktensor." - # TODO there is a relationship here between normalize and arrange that repeats tasks. Can this be made to be more efficient? - # ensure that factor matrices are normalized + # TODO there is a relationship here between normalize and arrange that repeats tasks. + # Can this be made to be more efficient? ensure that factor matrices are normalized self.normalize() # sort @@ -503,79 +605,94 @@ def arrange(self, weight_factor=None, permutation=None): for i in range(self.ndims): self.factor_matrices[i] = self.factor_matrices[i][:, p] - # TODO is this necessary? Matlab only absorbs into the last factor, not factor N as is documented - # absorb weight into one factor if requested + # absorb the weights into one factor, optional if weight_factor is not None: - pass - # if exist('foo','var') - # r = length(X.lambda); - # X.u{end} = full(X.u{end} * spdiags(X.lambda,0,r,r)); - # X.lambda = ones(size(X.lambda)); - # end - return self + r = len(self.weights) + self.factor_matrices[weight_factor] *= self.weights + self.weights = np.ones_like(self.weights) + + return def copy(self): """ - Make a deep copy of a `ktensor`. + Make a deep copy of a :class:`pyttb.ktensor`. Returns ------- :class:`pyttb.ktensor` - Example - ------- - Create a random `ktensor` with weights of 1: + Examples + -------- + Create a random :class:`pyttb.ktensor` with weights of 1: >>> np.random.seed(1) >>> K = ttb.ktensor.from_function(np.random.random_sample, (2, 3, 4), 2) - >>> print(K) + >>> print(K) # doctest: +ELLIPSIS ktensor of shape 2 x 3 x 4 weights=[1. 1.] factor_matrices[0] = - [[4.17022005e-01 7.20324493e-01] - [1.14374817e-04 3.02332573e-01]] + [[4.1702...e-01 7.2032...e-01] + [1.1437...e-04 3.0233...e-01]] factor_matrices[1] = - [[0.14675589 0.09233859] - [0.18626021 0.34556073] - [0.39676747 0.53881673]] + [[0.1467... 0.0923...] + [0.1862... 0.3455...] + [0.3967... 0.5388...]] factor_matrices[2] = - [[0.41919451 0.6852195 ] - [0.20445225 0.87811744] - [0.02738759 0.67046751] - [0.4173048 0.55868983]] + [[0.4191... 0.6852...] + [0.2044... 0.8781...] + [0.0273... 0.6704...] + [0.4173... 0.5586...]] - Create a copy of the `ktensor` and change the weights: + Create a copy of the :class:`pyttb.ktensor` and change the weights: - >>> K1 = K.copy() - >>> K1.weights = np.array([2., 3.]) - >>> print(K1) + >>> K2 = K.copy() + >>> K2.weights = np.array([2., 3.]) + >>> print(K2) # doctest: +ELLIPSIS ktensor of shape 2 x 3 x 4 weights=[2. 3.] factor_matrices[0] = - [[4.17022005e-01 7.20324493e-01] - [1.14374817e-04 3.02332573e-01]] + [[4.1702...e-01 7.2032...e-01] + [1.1437...e-04 3.023...e-01]] + factor_matrices[1] = + [[0.1467... 0.0923...] + [0.1862... 0.3455...] + [0.3967... 0.5388...]] + factor_matrices[2] = + [[0.4191... 0.6852...] + [0.2044... 0.8781...] + [0.0273... 0.6704...] + [0.4173... 0.5586...]] + + Show that the original :class:`pyttb.ktensor` is unchanged: + + >>> print(K) # doctest: +ELLIPSIS + ktensor of shape 2 x 3 x 4 + weights=[1. 1.] + factor_matrices[0] = + [[4.1702...e-01 7.2032...e-01] + [1.1437...e-04 3.0233...e-01]] factor_matrices[1] = - [[0.14675589 0.09233859] - [0.18626021 0.34556073] - [0.39676747 0.53881673]] + [[0.1467... 0.0923...] + [0.1862... 0.3455...] + [0.3967... 0.5388...]] factor_matrices[2] = - [[0.41919451 0.6852195 ] - [0.20445225 0.87811744] - [0.02738759 0.67046751] - [0.4173048 0.55868983]] + [[0.4191... 0.6852...] + [0.2044... 0.8781...] + [0.0273... 0.6704...] + [0.4173... 0.5586...]] """ return ttb.ktensor.from_tensor_type(self) def double(self): """ - Convert `ktensor` to numpy array. + Convert :class:`pyttb.ktensor` to :class:`numpy.ndarray`. Returns ------- :class:`numpy.ndarray` - Example - ------- + Examples + -------- >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) >>> fm1 = np.array([[5., 6.], [7., 8.]]) @@ -584,26 +701,26 @@ def double(self): >>> K.double() array([[29., 39.], [63., 85.]]) + >>> type(K.double()) + """ return self.full().double() def end(self, k=None): """ - Last index of indexing expression for `ktensor`. + Last index of indexing expression for :class:`pyttb.ktensor`. Parameters ---------- - k: int - dimension for subscripted indexing - n: int - dimensions to index + k: int, optional + dimension for subscripted indexing Returns ------- int: index - Example - ------- + Examples + -------- >>> K = ttb.ktensor.from_function(np.ones, (2, 3, 4), 2) >>> print(K.end(2)) 3 @@ -616,21 +733,24 @@ def end(self, k=None): def extract(self, idx=None): """ - Creates a new `ktensor` with only the specified components. + Creates a new :class:`pyttb.ktensor` with only the specified + components. Parameters ---------- - idx: int, tuple, list, :class:`numpy.ndarray` - Index set of components to extract. It should be the case that `idx` is a subset of - [0,...,self.ncomponents]. If this parameter is None or is empty, a copy of the ktensor is returned. + idx: int, :class:`tuple`, :class:`list`, :class:`numpy.ndarray`, optional + Index set of components to extract. It should be the case that + `idx` is a subset of [0,...,`self.ncomponents`]. If this + parameter is None or is empty, a copy of the + :class:`pyttb.ktensor` is returned. Returns ------- - :class:`ktensor` + :class:`pyttb.ktensor` - Example - ------- - Create a `ktensor`: + Examples + -------- + Create a :class:`pyttb.ktensor`: >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) @@ -646,7 +766,8 @@ def extract(self, idx=None): [[5. 6.] [7. 8.]] - Create a new `ktensor`, extracting only the second component from each factor of the original `ktensor`: + Create a new :class:`pyttb.ktensor`, extracting only the second + component from each factor of the original :class:`pyttb.ktensor`: >>> K.extract([1]) ktensor of shape 2 x 2 @@ -668,7 +789,11 @@ def extract(self, idx=None): else: components = idx if len(components) == 0 or len(components) > self.ncomponents: - assert False, "Number of components requested is not valid: {} (should be in [1,...,{}]).".format(len(components), self.ncomponents) + assert ( + False + ), "Number of components requested is not valid: {} (should be in [1,...,{}]).".format( + len(components), self.ncomponents + ) else: # check that all requested component indices are valid invalid_entries = [] @@ -676,7 +801,11 @@ def extract(self, idx=None): if components[i] not in range(self.ncomponents): invalid_entries.append(components[i]) if len(invalid_entries) > 0: - assert False, "Invalid component indices to be extracted: {} not in range({})".format(str(invalid_entries), self.ncomponents) + assert ( + False + ), "Invalid component indices to be extracted: {} not in range({})".format( + str(invalid_entries), self.ncomponents + ) new_weights = self.weights[components] new_factor_matrices = [] for i in range(self.ndims): @@ -687,25 +816,29 @@ def extract(self, idx=None): def fixsigns(self, other=None): """ - Change the elements of a `ktensor` in place 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. + Change the elements of a :class:`pyttb.ktensor` in place 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. Parameters ---------- - other: :class:`pyttb.ktensor` - If not None, returns a version of the `ktensor` where some of the signs of - the columns of the factor matrices have been flipped to better align - with `other`. + other: :class:`pyttb.ktensor`, optional + If not None, returns a version of the :class:`pyttb.ktensor` + where some of the signs of the columns of the factor matrices have + been flipped to better align with `other`. In not None, both + :class:`pyttb.ktensor` objects are first normalized (using + :func:`~pyttb.ktensor.normalize`). Returns ------- :class:`pyttb.ktensor` - The changes are made in place and a reference to the updated tensor is returned + The changes are made in place and a reference to the updated + tensor is returned - Example - ------- - Create a `ktensor` with negative large magnitude entries: + Examples + -------- + Create a :class:`pyttb.ktensor` with negative large magnitude entries: >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) @@ -735,22 +868,22 @@ def fixsigns(self, other=None): [[ 5. -6.] [ 7. 8.]] - Fix the signs using another `ktensor`: + Fix the signs using another :class:`pyttb.ktensor`: >>> K = ttb.ktensor.from_data(weights, [fm0, fm1]) >>> K2 = K.copy() - >>> K2.factor_matrices[0][1, 1] = - K2.factor_matrices[0][1, 1] - >>> K2.factor_matrices[1][1, 1] = - K2.factor_matrices[1][1, 1] + >>> K2.factor_matrices[0][1, 1] = -K2.factor_matrices[0][1, 1] + >>> K2.factor_matrices[1][1, 1] = -K2.factor_matrices[1][1, 1] >>> K = K.fixsigns(K2) - >>> print(K) + >>> print(K) # doctest: +ELLIPSIS ktensor of shape 2 x 2 - weights=[27.20294102 89.4427191 ] + weights=[27.2029... 89.4427...] factor_matrices[0] = - [[ 0.31622777 -0.4472136 ] - [ 0.9486833 -0.89442719]] + [[ 0.3162... -0.4472...] + [ 0.9486... -0.8944...]] factor_matrices[1] = - [[ 0.58123819 -0.6 ] - [ 0.81373347 -0.8 ]] + [[ 0.5812... -0.6...] + [ 0.8137... -0.8...]] """ if other == None: for r in range(self.ncomponents): @@ -781,14 +914,15 @@ def fixsigns(self, other=None): # Try to fix the signs for each component best_sign = np.zeros((N, RA)) for r in range(RB): - # Compute the inner products. They should mostly be O(1) if there is a # good match because the factors have prevsiouly been normalized. If # the signs are correct, then the score should be +1. Otherwise we need # to flip the sign and the score should be -1. sgn_score = np.zeros(N) for n in range(N): - sgn_score[n] = self.factor_matrices[n][:, r].T @ other.factor_matrices[n][:, r] + sgn_score[n] = ( + self.factor_matrices[n][:, r].T @ other.factor_matrices[n][:, r] + ) # Sort the sign scores. sort_idx = np.argsort(sgn_score) @@ -809,65 +943,83 @@ def fixsigns(self, other=None): if np.mod(breakpt + 1, 2) == 0: endpt = breakpt + 1 else: - warnings.warn('Trouble fixing signs for mode {}'.format(r)) - if (breakpt < RB) and (-sort_sgn_score[breakpt] > sort_sgn_score[breakpt+1]): + warnings.warn("Trouble fixing signs for mode {}".format(r)) + if (breakpt < RB) and ( + -sort_sgn_score[breakpt] > sort_sgn_score[breakpt + 1] + ): endpt = breakpt + 1 else: endpt = breakpt - 1 # Flip the signs for i in range(endpt): - self.factor_matrices[sort_idx[i]][:, r] = -1 * self.factor_matrices[sort_idx[i]][:, r] + self.factor_matrices[sort_idx[i]][:, r] = ( + -1 * self.factor_matrices[sort_idx[i]][:, r] + ) best_sign[sort_idx[i], r] = -1 return self def full(self): """ - Convert a `ktensor` to a (dense) `tensor`. + Convert a :class:`pyttb.ktensor` to a :class:`pyttb.tensor`. Returns ------- :class:`pyttb.tensor` - Example - ------- + Examples + -------- >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) >>> fm1 = np.array([[5., 6.], [7., 8.]]) >>> K = ttb.ktensor.from_data(weights, [fm0, fm1]) - >>> print(K.full()) + >>> print(K) + ktensor of shape 2 x 2 + weights=[1. 2.] + factor_matrices[0] = + [[1. 2.] + [3. 4.]] + factor_matrices[1] = + [[5. 6.] + [7. 8.]] + >>> print(K.full()) # doctest: +NORMALIZE_WHITESPACE tensor of shape 2 x 2 - data[:, :] = + data[:, :] = [[29. 39.] [63. 85.]] + """ data = self.weights @ ttb.khatrirao(self.factor_matrices, reverse=True).T return ttb.tensor.from_data(data, self.shape) def innerprod(self, other): """ - Efficient inner product with a `ktensor`. + Efficient inner product with a :class:`pyttb.ktensor`. - Efficiently computes the inner product between two tensors, self and other. If other is a ktensor, - the inner product is computed using inner products of the factor matrices. Otherwise, the inner product - is computed using ttv with all of the columns of self's factor matrices. + Efficiently computes the inner product between two tensors, `self` + and `other`. If other is a :class:`pyttb.ktensor`, the inner + product is computed using inner products of the factor matrices. + Otherwise, the inner product is computed using the `ttv` (tensor + times vector) of `other` with all of the columns of + `self.factor_matrices`. Parameters ---------- - other: compute inner product of ktensor with other + other: :class:`pyttb.ktensor`, :class:`pyttb.sptensor`, :class:`pyttb.tensor`, or :class:`pyttb.ttensor`, required + Tensor with which to compute the inner product. Returns ------- :float - Example - ------- + Examples + -------- >>> K = ttb.ktensor.from_function(np.ones, (2,3,4), 2) >>> print(K.innerprod(K)) 96.0 """ - if not(self.shape == other.shape): + if not (self.shape == other.shape): assert False, "Innerprod can only be computed for tensors of the same size" if isinstance(other, ktensor): @@ -876,29 +1028,30 @@ def innerprod(self, other): M = M * (self.factor_matrices[i].T @ other.factor_matrices[i]) return np.sum(np.sum(M)) - if isinstance(other, (ttb.tensor, ttb.sptensor, ttb.ttensor)): + if isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ttensor)): res = 0.0 for r in range(self.ncomponents): vecs = [] for n in range(self.ndims): vecs.append(self.factor_matrices[n][:, r]) - res = res + self.weights[r] * other.ttv(np.array(vecs)) + res = res + self.weights[r] * other.ttv(vecs) return res def isequal(self, other): """ - Equal comparator for `ktensors`. + Equal comparator for :class:`pyttb.ktensor` objects. Parameters ---------- - other: compare equality of ktensor to other + other: :class:`pyttb.ktensor`, required + :class:`pyttb.ktensor` with which to compare. Returns ------- :bool - Example - ------- + Examples + -------- >>> K1 = ttb.ktensor.from_function(np.ones, (2,3,4), 2) >>> weights = np.ones((2, 1)) >>> factor_matrices = [np.ones((2, 2)), np.ones((3, 2)), np.ones((4, 2))] @@ -919,47 +1072,53 @@ def isequal(self, other): def issymmetric(self, return_diffs=False): """ - Returns true if the `ktensor` is exactly symmetric for every permutation. + Returns True if the :class:`pyttb.ktensor` is exactly symmetric for + every permutation. Parameters ---------- - return_diffs: bool - If True, returns the matrix of the norm of the differences between the factor matrices + return_diffs: bool, optional + If True, returns the matrix of the norm of the differences between + the factor matrices. Returns ------- :bool - :class:`numpy.ndarray` - Matrix of the norm of the differences between the factor matrices (optional) + :class:`numpy.ndarray`, optional + Matrix of the norm of the differences between the factor matrices - Example - ------- - Create a 'ktensor` that is symmetric and test if it is symmetric: + Examples + -------- + Create a :class:`pyttb.ktensor` that is symmetric and test if it is + symmetric: >>> K = ttb.ktensor.from_function(np.ones, (3, 3, 3), 2) >>> print(K.issymmetric()) True - Create a `ktensor` that is not symmetric and return the differences: + Create a :class:`pyttb.ktensor` that is not symmetric and return the + differences: >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) >>> fm1 = np.array([[5., 6.], [7., 8.]]) - >>> K1 = ttb.ktensor.from_data(weights, [fm0, fm1]) - >>> issym, diffs = K1.issymmetric(return_diffs=True) + >>> K2 = ttb.ktensor.from_data(weights, [fm0, fm1]) + >>> issym, diffs = K2.issymmetric(return_diffs=True) >>> print(diffs) [[0. 8.] [0. 0.]] """ diffs = np.zeros((self.ndims, self.ndims)) for i in range(self.ndims): - for j in range(i+1, self.ndims): - if not(self.factor_matrices[i].shape == self.factor_matrices[j].shape): + for j in range(i + 1, self.ndims): + if not (self.factor_matrices[i].shape == self.factor_matrices[j].shape): diffs[i, j] = np.inf elif (self.factor_matrices[i] == self.factor_matrices[j]).all(): diffs[i, j] = 0 else: - diffs[i, j] = np.linalg.norm(self.factor_matrices[i] - self.factor_matrices[j]) + diffs[i, j] = np.linalg.norm( + self.factor_matrices[i] - self.factor_matrices[j] + ) issym = (diffs == 0).all() if return_diffs: @@ -969,34 +1128,41 @@ def issymmetric(self, return_diffs=False): def mask(self, W): """ - Extract `ktensor` values as specified by a mask `tensor` + Extract :class:`pyttb.ktensor` values as specified by `W`, 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 + ones (1) in `W` will be returned as a column vector. Parameters ---------- - W: :class:`pyttb.sptensor` + W: :class:`pyttb.tensor` or :class:`pyttb.sptensor`, required Returns ------- - :class:`Numpy.ndarray` + :class:`numpy.ndarray` - Example - ------- - Create a `ktensor`: + Examples + -------- + Create a :class:`pyttb.ktensor`: >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) >>> fm1 = np.array([[5., 6.], [7., 8.]]) >>> K = ttb.ktensor.from_data(weights, [fm0, fm1]) - Create a mask `tensor` and extract the elements of the `ktensor` using the mask: + Create a mask :class:`pyttb.tensor` and extract the elements of the + :class:`pyttb.ktensor` using the mask: >>> W = ttb.tensor.from_data(np.array([[0, 1], [1, 0]])) >>> print(K.mask(W)) - [[39.] - [63.]] + [[63.] + [39.]] """ # Error check - if len(W.shape) != len(self.shape) or np.any(np.array(W.shape) > np.array(self.shape)): + if len(W.shape) != len(self.shape) or np.any( + np.array(W.shape) > np.array(self.shape) + ): assert False, "Mask cannot be bigger than the data tensor" # Extract locations of nonzeros in W @@ -1015,15 +1181,17 @@ def mask(self, W): def mttkrp(self, U, n): """ - Matricized `tensor` times Khatri-Rao product for `ktensor`. + Matricized tensor times Khatri-Rao product for :class:`pyttb.ktensor`. - Efficiently calculates the matrix product of the n-mode matricization of the `ktensor` with the - Khatri-Rao product of all entries in U, a list of matrices, except the nth. + Efficiently calculates the matrix product of the n-mode matricization + of the `ktensor` with the Khatri-Rao product of all entries in U, + a :class:`list` of factor matrices, except the nth. Parameters ---------- - U: list of factor matrices - n: multiplies by all modes except n + U: :class:`list` of factor matrices, required + n: int, required + Multiply by all modes except n. Returns ------- @@ -1041,7 +1209,7 @@ def mttkrp(self, U, n): assert False, "Second argument must be list of numpy.ndarray's" if len(U) != self.ndims: - assert False, 'List of factor matrices is the wrong length' + assert False, "List of factor matrices is the wrong length" # Number of columns in input matrices if n == 0: @@ -1060,78 +1228,98 @@ def mttkrp(self, U, n): @property def ncomponents(self): - """Number of components (i.e., number of columns in each factor matrix) of the `ktensor`. + """ + Number of components in the :class:`pyttb.ktensor` (i.e., number of + columns in each factor matrix) of the :class:`pyttb.ktensor`. Returns ------- :int + + Examples + -------- + >>> K = ttb.ktensor.from_function(np.ones, (2, 3, 4), 2) + >>> print(K.ncomponents) + 2 """ return len(self.weights) @property def ndims(self): - """Number of dimensions (i.e., number of factor_matrices) of the `ktensor`. + """ + Number of dimensions (i.e., number of factor matrices) of the + :class:`pyttb.ktensor`. Returns ------- :int + + Examples + -------- + >>> K = ttb.ktensor.from_function(np.ones, (2, 3, 4), 2) + >>> print(K.ndims) + 3 """ return len(self.factor_matrices) def norm(self): """ - Compute the norm (i.e., square root of the sum of squares of entries) of a `ktensor`. + Compute the norm (i.e., square root of the sum of squares of entries) + of a :class:`pyttb.ktensor`. Returns -------- :int - Example - ------- + Examples + -------- >>> K = ttb.ktensor.from_function(np.ones, (2, 3, 4), 2) >>> K.norm() 9.797958971132712 """ # Compute the matrix of correlation coefficients - coefMatrix = self.weights[:,None] @ self.weights[None,:] + coefMatrix = self.weights[:, None] @ self.weights[None, :] for f in self.factor_matrices: coefMatrix = coefMatrix * (f.T @ f) return np.sqrt(np.abs(np.sum(coefMatrix))) def normalize(self, weight_factor=None, sort=False, normtype=2, mode=None): """ - Normalize the columns of the factor matrices. + Normalize the columns of the factor matrices of a + :class:`pyttb.ktensor` in place. Parameters ---------- weight_factor: {"all", int}, optional - Absorb the weights into one or more factors: - * "all": absorb weight equally across all factors - * int: absorb weight into a single dimension (int must be in range(self.ndims) - sort: bool - Sort the columns in descending order of the weights - normtype: {non-zero int, inf, -inf, 'fro', 'nuc'}, optional - Order of the norm (see :func:`numpy.linalg.norm` for possible values) - mode: {int, None} - Index of factor matrix to normalize. A value of `None` means normalize all factor matrices. + Absorb the weights into one or more factors. If "all", absorb + weight equally across all factors. If `int`, absorb weight into a + single dimension (value must be in range(self.ndims)). + sort: bool, optional + Sort the columns in descending order of the weights. + normtype: {non-negative int, -1, -2, np.inf, -np.inf}, optional + Order of the norm (see :func:`numpy.linalg.norm` for possible + values). + mode: int, optional + Index of factor matrix to normalize. A value of `None` means + normalize all factor matrices. Returns - -------- + ------- :class:`pyttb.ktensor` - Example - ------- + Examples + -------- >>> K = ttb.ktensor.from_function(np.ones, (2, 3, 4), 2) - >>> print(K.normalize()) + >>> print(K.normalize()) # doctest: +ELLIPSIS ktensor of shape 2 x 3 x 4 - weights=[4.89897949 4.89897949] + weights=[4.898... 4.898...] factor_matrices[0] = - [[0.70710678 0.70710678] - [0.70710678 0.70710678]] + [[0.7071... 0.7071...] + [0.7071... 0.7071...]] factor_matrices[1] = - [[0.57735027 0.57735027] - [0.57735027 0.57735027] - [0.57735027 0.57735027]] + [[0.5773... 0.5773...] + [0.5773... 0.5773...] + [0.5773... 0.5773...]] factor_matrices[2] = [[0.5 0.5] [0.5 0.5] @@ -1144,18 +1332,24 @@ def normalize(self, weight_factor=None, sort=False, normtype=2, mode=None): for r in range(self.ncomponents): tmp = np.linalg.norm(self.factor_matrices[mode][:, r], ord=normtype) if tmp > 0: - self.factor_matrices[mode][:, r] = 1.0/tmp * self.factor_matrices[mode][:, r] + self.factor_matrices[mode][:, r] = ( + 1.0 / tmp * self.factor_matrices[mode][:, r] + ) self.weights[r] = self.weights[r] * tmp - return + return self else: - assert False, "Parameter single_factor is invalid; index must be an int in range of number of dimensions" + assert ( + False + ), "Parameter single_factor is invalid; index must be an int in range of number of dimensions" # ensure that all factor_matrices are normalized for mode in range(self.ndims): for r in range(self.ncomponents): tmp = np.linalg.norm(self.factor_matrices[mode][:, r], ord=normtype) if tmp > 0: - self.factor_matrices[mode][:, r] = 1.0/tmp * self.factor_matrices[mode][:, r] + self.factor_matrices[mode][:, r] = ( + 1.0 / tmp * self.factor_matrices[mode][:, r] + ) self.weights[r] = self.weights[r] * tmp # check that all weights are positive, flip sign of columns in first factor matrix if negative weight found @@ -1166,64 +1360,66 @@ def normalize(self, weight_factor=None, sort=False, normtype=2, mode=None): # absorb weight into factors if weight_factor == "all": # all factors - D = np.diag(np.power(self.weights, 1.0/self.ndims)) + D = np.diag(np.power(self.weights, 1.0 / self.ndims)) for i in range(self.ndims): self.factor_matrices[i] = self.factor_matrices[i] @ D - self.weights[:] = 1. + self.weights[:] = 1.0 elif weight_factor in range(self.ndims): # single factor - self.factor_matrices[weight_factor] = self.factor_matrices[weight_factor] @ np.diag(self.weights) + self.factor_matrices[weight_factor] = self.factor_matrices[ + weight_factor + ] @ np.diag(self.weights) self.weights = np.ones((self.weights.shape)) if sort: if self.ncomponents > 1: # indices of srting in descending order p = np.argsort(self.weights)[::-1] - self = self.arrange(permutation=p) + self.arrange(permutation=p) return self def nvecs(self, n, r, flipsign=True): """ - Compute the leading mode-n vectors for a `ktensor`. + Compute the leading mode-n vectors for a :class:`pyttb.ktensor`. - Computes the `r` leading eigenvectors of Xn*Xn' - (where Xn is the mode-N matricization/unfolding of self), which provides - information about the mode-N fibers. In two-dimensions, the `r` - leading mode-1 vectors are the same as the 'r' left singular vectors - and the `r` leading mode-2 vectors are the same as the `r` right - singular vectors. By default, this method computes the top `r` - eigenvectors of the matrix Xn*Xn'. + Computes the `r` leading eigenvectors of Xn*Xn.T (where Xn is the + mode-`n` matricization/unfolding of self), which provides information + about the mode-N fibers. In two-dimensions, the `r` leading mode-1 + vectors are the same as the `r` left singular vectors and the `r` + leading mode-2 vectors are the same as the `r` right singular + vectors. By default, this method computes the top `r` eigenvectors + of Xn*Xn.T. Parameters ---------- - n: int - Mode for tensor matricization - r: int - Number of eigenvectors to compute and use - flipsign: bool - Make each column's largest element positive if `True` + n: int, required + Mode for tensor matricization. + r: int, required + Number of eigenvectors to compute and use. + flipsign: bool, optional + If True, make each column's largest element positive. Returns ------- - :class:`Numpy.ndarray` + :class:`numpy.ndarray` - Example - ------- + Examples + -------- Compute single eigenvector for dimension 0: >>> K = ttb.ktensor.from_function(np.ones, (2, 3, 4), 2) >>> nvecs1 = K.nvecs(0, 1) - >>> print(nvecs1) - [[0.70710678] - [0.70710678]] + >>> print(nvecs1) # doctest: +ELLIPSIS + [[0.70710678...] + [0.70710678...]] Compute first 2 leading eigenvectors for dimension 0: >>> nvecs2 = K.nvecs(0, 2) - >>> print(nvecs2) - [[ 0.70710678 0.70710678] - [ 0.70710678 -0.70710678]] + >>> print(nvecs2) # doctest: +ELLIPSIS + [[ 0.70710678... 0.70710678...] + [ 0.70710678... -0.70710678...]] """ M = self.weights[:, None] @ self.weights[:, None].T for i in range(self.ndims): @@ -1237,7 +1433,9 @@ def nvecs(self, n, r, flipsign=True): v = v[:, (-np.abs(w)).argsort()] v = v[:, :r] else: - warnings.warn('Greater than or equal to ktensor.shape[n] - 1 eigenvectors requires cast to dense to solve') + logging.debug( + "Greater than or equal to ktensor.shape[n] - 1 eigenvectors requires cast to dense to solve" + ) w, v = scipy.linalg.eigh(y) v = v[:, (-np.abs(w)).argsort()] v = v[:, :r] @@ -1251,25 +1449,24 @@ def nvecs(self, n, r, flipsign=True): def permute(self, order): """ - Permute `ktensor` dimensions. + Permute :class:`pyttb.ktensor` dimensions. - Rearranges the dimensions of A so that they are in the order specified by the - vector `order`. The output is a `ktensor` with components rearranged as specified - by `order`. The corresponding tensor has the same components as `self` but the order - of the subscripts needed to access any particular element is rearranged as - specified by `order`. + Rearranges the dimensions of a :class:`pyttb.ktensor` so that they are + in the order specified by `order`. The corresponding tensor has the + same components as `self` but the order of the subscripts needed to + access any particular element is rearranged as specified by `order`. Parameters ---------- - order: :class:`Numpy.ndarray` + order: :class:`numpy.ndarray` + Permutation of [0,...,self.ndimensions]. Returns ------- :class:`pyttb.ktensor` - shapeNew == shapePrevious[order] - Example - ------- + Examples + -------- >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) >>> fm1 = np.array([[5., 6.], [7., 8.]]) @@ -1306,15 +1503,17 @@ def permute(self, order): def redistribute(self, mode): """ - Distribute weights of a `ktensor` to a specified mode + Distribute weights of a :class:`pyttb.ktensor` to the specified mode. + The redistribution is performed in place. Parameters ---------- mode: int + Must be value in [0,...self.ndims]. Example ------- - Create a `ktensor`: + Create a :class:`pyttb.ktensor`: >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) @@ -1331,7 +1530,7 @@ def redistribute(self, mode): [[5. 6.] [7. 8.]] - Distribute weights of that `ktensor` to mode 0: + Distribute weights of that :class:`pyttb.ktensor` to mode 0: >>> K.redistribute(0) >>> print(K) @@ -1345,14 +1544,16 @@ def redistribute(self, mode): [7. 8.]] """ for r in range(self.ncomponents): - self.factor_matrices[mode][:, [r]] = self.factor_matrices[mode][:, [r]] * self.weights[r] + self.factor_matrices[mode][:, [r]] = ( + self.factor_matrices[mode][:, [r]] * self.weights[r] + ) self.weights[r] = 1 @property def shape(self): - """Shape of a `ktensor`. + """Shape of a :class:`pyttb.ktensor`. - Returns the sizes of all dimensions. + Returns the lengths of all dimensions of the :class:`pyttb.ktensor`. Returns ------- @@ -1360,24 +1561,166 @@ def shape(self): """ return tuple([f.shape[0] for f in self.factor_matrices]) - # TODO implement - def score(self, other, **kwargs): - assert False, "Not yet implemented" # pragma: no cover + def score(self, other, weight_penalty=True, threshold=0.99, greedy=True): + """ + Checks if two :class:`pyttb.ktensor` instances with the same shapes + but potentially different number of components match except for + permutation. + + Matching is defined as follows. If `self` and `other` are single- + component :class:`pyttb.ktensor` instances that have been normalized + so that their weights are `self.weights` and `other.weights`, and their + factor matrices are single column vectors containing [a1,a2,...,an] and + [b1,b2,...bn], rescpetively, then the score is defined as + + score = penalty * (a1.T*b1) * (a2.T*b2) * ... * (an.T*bn), + + where the penalty is defined by the weights such that + + penalty = 1 - abs(self.weights - other.weights) / max(self.weights, other.weights). + + The score of multi-component :class:`pyttb.ktensor` instances is a + normalized sum of the scores across the best permutation of the + components of `self`. `self` can have more components than `other`; + any extra components are ignored in terms of the matching score. + + Parameters + ---------- + other: :class:`pyttb.ktensor`, required + :class:`pyttb.ktensor` with which to match. + weight_penalty: bool, optional + Flag indicating whether or not to consider the weights in the + calculations. + threshold: float, optional + Threshold specified in the formula above for determining a match. + greedy: bool, optional + Flag indicating whether or not to consider all possible matchings + (exponentially expensive) or just do a greedy matching. + + Returns + ------- + int + Score (between 0 and 1). + :class:`pyttb.ktensor` + Copy of `self`, which has been normalized and permuted to best match + `other`. + bool + Flag indicating a match according to a user-specified threshold. + :class:`numpy.ndarray` + Permutation (i.e. array of indices of the modes of self) of the + components of `self` that was used to best match `other`. + + Examples + -------- + Create two :class:`pyttb.ktensor` instances and compute the score + between them: + + >>> K = ttb.ktensor.from_data(np.array([2., 1., 3.]), np.ones((3,3)), np.ones((4,3)), np.ones((5,3))) + >>> K2 = ttb.ktensor.from_data(np.array([2., 4.]), np.ones((3,2)), np.ones((4,2)), np.ones((5,2))) + >>> score,Kperm,flag,perm = K.score(K2) + >>> print(score) + 0.875 + >>> print(perm) + [0 2 1] + + Compute score without using weights: + + >>> score,Kperm,flag,perm = K.score(K2,weight_penalty=False) + >>> print(score) + 1.0 + >>> print(perm) + [0 1 2] + """ + + if not greedy: + assert ( + False + ), "Not yet implemented. Only greedy method is implemented currently." + + if not isinstance(other, ktensor): + assert False, "The first input should be a ktensor" + + if not (self.shape == other.shape): + assert False, "Size mismatch" + + # Set-up + N = self.ndims + RA = self.ncomponents + RB = other.ncomponents + + # We're matching components in A to B + if RA < RB: + assert False, "Tensor A must have at least as many components as tensor B" + + # Make sure columns of factor matrices are normalized + A = ttb.ktensor.from_tensor_type(self).normalize() + B = ttb.ktensor.from_tensor_type(other).normalize() + + # Compute all possible vector-vector congruences. + + # Compute every pair for each mode + Cbig = ttb.tensor.from_function(np.zeros, (RA, RB, N)) + for n in range(N): + Cbig[:, :, n] = np.abs(A.factor_matrices[n].T @ B.factor_matrices[n]) + + # Collapse across all modes using the product + C = Cbig.collapse(np.array([2]), np.prod).double() + + # Calculate penalty based on differences in the weights + # Note that we are assuming the the weights are positive because the + # ktensor's were previously normalized. + if weight_penalty: + P = np.zeros((RA, RB)) + for ra in range(RA): + la = A.weights[ra] + for rb in range(RB): + lb = B.weights[rb] + if (la == 0) and (lb == 0): + # if both lambda values are zero (0), they match + P[ra, rb] = 1 + else: + P[ra, rb] = 1 - ( + np.abs(la - lb) / np.max([np.abs(la), np.abs(lb)]) + ) + C = P * C + + # Option to do greedy matching + if greedy: + best_perm = -1 * np.ones((RA), dtype=int) + best_score = 0 + for r in range(RB): + idx = np.argmax(C.reshape(np.prod(C.shape), order="F")) + ij = tt_ind2sub((RA, RB), idx) + best_score = best_score + C[ij[0], ij[1]] + C[ij[0], :] = -10 + C[:, ij[1]] = -10 + best_perm[ij[1]] = ij[0] + best_score = best_score / RB + flag = 1 + + # Rearrange the components of A according to the best matching + foo = np.arange(RA) + tf = np.in1d(foo, best_perm) + best_perm[RB : RA + 1] = foo[~tf] + A.arrange(permutation=best_perm) + return best_score, A, flag, best_perm def symmetrize(self): """ - Symmetrize a `ktensor` in all modes. + Symmetrize a :class:`pyttb.ktensor` in all modes. - Symmetrize a `ktensor` with respect to all modes so that the resulting `ktensor` is symmetric with respect - to any permutation of indices. + Symmetrize a :class:`pyttb.ktensor` with respect to all modes so that + the resulting :class:`pyttb.ktensor` is symmetric with respect to any + permutation of indices. Returns ------- :class:`pyttb.ktensor` - A new `ktensor` whose factor matrices are symmetric - Example - ------- + Examples + -------- + Create a :class:`pyttb.ktensor`: + >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) >>> fm1 = np.array([[5., 6.], [7., 8.]]) @@ -1393,21 +1736,24 @@ def symmetrize(self): [[5. 6.] [7. 8.]] - Make the factor matrices of the `ktensor` symmetric: + Make the factor matrices of the :class:`pyttb.ktensor` symmetric with + respect to any permutation of the factor matrices: >>> K1 = K.symmetrize() - >>> print(K1) + >>> print(K1) # doctest: +ELLIPSIS ktensor of shape 2 x 2 weights=[1. 1.] factor_matrices[0] = - [[2.34043142 4.95196735] - [4.59606911 8.01245149]] + [[2.3404... 4.9519...] + [4.5960... 8.0124...]] factor_matrices[1] = - [[2.34043142 4.95196735] - [4.59606911 8.01245149]] + [[2.3404... 4.9519...] + [4.5960... 8.0124...]] """ # Check tensor dimensions for compatibility with symmetrization - assert (self.shape == self.shape[0]*np.ones(self.ndims)).all(), "Tensor is not cubic -- cannot be symmetrized" + assert ( + self.shape == self.shape[0] * np.ones(self.ndims) + ).all(), "Tensor is not cubic -- cannot be symmetrized" # Distribute lambda evenly into factors K = self.from_tensor_type(self) @@ -1438,21 +1784,22 @@ def symmetrize(self): def tolist(self, mode=None): """ - Converts `ktensor` to a list of factor matrices, evenly distributing the weights across factors. + Convert :class:`pyttb.ktensor` to a list of factor matrices, evenly + distributing the weights across factors. Optionally absorb the + weights into a single mode. Parameters ---------- - mode: int - Index of factor matrix to absorb all of the weight. + mode: int, optional + Index of factor matrix to absorb all of the weights. Returns ------- - :list of :class:`numpy.ndarray` - List of factor matrices + :class:`list` of :class:`numpy.ndarray` - Example - ------- - Create a `ktensor` of all ones: + Examples + -------- + Create a :class:`pyttb.ktensor` of all ones: >>> weights = np.array([1., 2.]) >>> fm0 = np.array([[1., 2.], [3., 4.]]) @@ -1469,23 +1816,25 @@ def tolist(self, mode=None): [[5. 6.] [7. 8.]] - Spread weights equally to all factors and return list of factor matrices: + Spread weights equally to all factors and return list of factor + matrices: >>> fm_list = K.tolist() - >>> for fm in fm_list: print(fm) - [[1. 2.82842712] - [3. 5.65685425]] - [[ 5. 8.48528137] - [ 7. 11.3137085 ]] + >>> for fm in fm_list: print(fm) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE + [[1. 2.8284...] + [3. 5.6568...]] + [[ 5. 8.4852...] + [ 7. 11.313...]] - Shift weight to single factor matrix and return list of factor matrices: + Shift weight to single factor matrix and return list of factor + matrices: >>> fm_list = K.tolist(0) - >>> for fm in fm_list: print(fm) - [[ 8.60232527 40. ] - [25.8069758 80. ]] - [[0.58123819 0.6 ] - [0.81373347 0.8 ]] + >>> for fm in fm_list: print(fm) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE + [[ 8.6023... 40. ] + [25.8069... 80. ]] + [[0.5812... 0.6...] + [0.8137... 0.8...]] """ if mode is not None: if isinstance(mode, int) and mode in range(self.ndims): @@ -1508,30 +1857,33 @@ def tolist(self, mode=None): def tovec(self, include_weights=True): """ - Convert `ktensor` to column vector. + Convert :class:`pyttb.ktensor` to column vector. Optionally include + or exclude the weights. Parameters ---------- - include_weights: bool - Flag to specify whether or not to include weights in output (default is True). + include_weights: bool, optional + Flag to specify whether or not to include weights in output. Returns ------- :class:`numpy.ndarray` - The length of the column vector is (sum(K.shape)+1)*K.ncomponents. The vector contains the ktensor - weights (if requested) stacked on top of each of the columns of the factor_matrices in order. + The length of the column vector is + (sum(self.shape)+1)*self.ncomponents. The vector contains the + weights (if requested) stacked on top of each of the columns of + the factor_matrices in order. - Example - ------- - Create a `ktensor` from a vector: + Examples + -------- + Create a :class:`pyttb.ktensor` from a vector: >>> rank = 2 >>> shape = np.array([2, 3, 4]) >>> data = np.arange(1, rank*sum(shape)+1) >>> weights = 2 * np.ones(rank) >>> weights_and_data = np.concatenate((weights, data), axis=0) - >>> K_from_vector = ttb.ktensor.from_vector(weights_and_data[:], shape, True) - >>> print(K_from_vector) + >>> K = ttb.ktensor.from_vector(weights_and_data[:], shape, True) + >>> print(K) ktensor of shape 2 x 3 x 4 weights=[2. 2.] factor_matrices[0] = @@ -1547,10 +1899,11 @@ def tovec(self, include_weights=True): [13. 17.] [14. 18.]] - Create a ktensor from a vector of data extracted from another `ktensor`: + Create a :class:`pyttb.ktensor` from a vector of data extracted from + another :class:`pyttb.ktensor`: - >>> K_from_tovec = ttb.ktensor.from_vector(K_from_vector.tovec(), shape, True) - >>> print(K_from_tovec) + >>> K2 = ttb.ktensor.from_vector(K.tovec(), shape, True) + >>> print(K2) ktensor of shape 2 x 3 x 4 weights=[2. 2.] factor_matrices[0] = @@ -1567,8 +1920,8 @@ def tovec(self, include_weights=True): [14. 18.]] """ if include_weights: - x = np.zeros(self.ncomponents * int(sum(self.shape)+1)) - x[0:self.ncomponents] = self.weights + x = np.zeros(self.ncomponents * int(sum(self.shape) + 1)) + x[0 : self.ncomponents] = self.weights offset = self.ncomponents else: x = np.zeros(self.ncomponents * int(sum(self.shape))) @@ -1576,57 +1929,55 @@ def tovec(self, include_weights=True): for f in self.factor_matrices: for r in range(self.ncomponents): - x[offset:offset+f.shape[0]] = f[:, r].reshape(f.shape[0]) + x[offset : offset + f.shape[0]] = f[:, r].reshape(f.shape[0]) offset += f.shape[0] - # if include_weights: - # x = np.zeros((self.ncomponents * int(sum(self.shape)+1), 1)) - # x[0:self.ncomponents] = self.weights.reshape((len(self.weights), 1)) - # offset = self.ncomponents - # else: - # x = np.zeros((self.ncomponents * int(sum(self.shape)), 1)) - # offset = 0 - # - # for f in self.factor_matrices: - # for r in range(self.ncomponents): - # x[offset:offset+f.shape[0]] = f[:, r].reshape((f.shape[0], 1)) - # offset += f.shape[0] return x - def ttv(self, vector, dims=None): - """ - `Tensor` times vector for `ktensors`. - - Computes the product of a `ktensor` with a vector. If `dims` is an integer, it specifies - the dimension in the `ktensor` along which the vector is multiplied. If the shape of the vector - is = (I,1), then the size of dimension `dims` of the `ktensor` must have size I. Note that - number of dimensions of the returned `tensor` is 1 less than the dimension of the `ktensor` used - in the multiplication because dimension `dims` is removed. - - ttv(np.array([v1,v2,...,vN])) computes the product of the `ktensor` with a sequence of vectors in the - array. The products are computed sequentially along all dimensions (or modes) of the `ktensor`. The array - contains N=self.ndims vectors. - - ttv(np.array([v1,v2,...,vk]), dims=np.array([I1,I2,...,Ik) computes the sequence of `ktensor`-by-vector - products along the dimensions specified by `dims`. In this case, the number of products, k, can be less - than N=self.ndims and the order of the sequence does not need to match the order of the dimensions in the - `ktensor`. Note that the number of vectors must match the number of dimensions provides, and the length of - each vector must match the size of each dimension of the `ktensor` specified in `dims`. + def ttv(self, vector, dims=None, exclude_dims=None): + """ + Tensor times vector for a :class:`pyttb.ktensor`. + + Computes the product of a :class:`pyttb.ktensor` with a vector (i.e., + np.array). If `dims` is an integer, it specifies the dimension in the + :class:`pyttb.ktensor` along which the vector is multiplied. + If the shape of the vector is = (I,1), then the length of dimension + `dims` of the :class:`pyttb.ktensor` must be I. Note that the number + of dimensions of the returned :class:`pyttb.ktensor` is 1 less than + the dimension of the :class:`pyttb.ktensor` used in the + multiplication because dimension `dims` is removed. + + If `vector` is a :class:`list` of np.array instances, the + :class:`pyttb.ktensor` is multiplied with each vector in the list. The + products are computed sequentially along all dimensions (or modes) of + the :class:`pyttb.ktensor`, and thus the list must contain `self.ndims` + vectors. + + When `dims` is not None, compute the products along the dimensions + specified by `dims`. In this case, the number of products can be less + than `self.ndims` and the order of the sequence does not need to match + the order of the dimensions in the :class:`pyttb.ktensor`. Note that + the number of vectors must match the number of dimensions provided, + and the length of each vector must match the size of each dimension + of the :class:`pyttb.ktensor` specified in `dims`. Parameters ---------- - vector: :class:`Numpy.ndarray`, list[:class:`Numpy.ndarray`] - dims: int, :class:`Numpy.ndarray` + vector: :class:`numpy.ndarray` or list[:class:`numpy.ndarray`], required + dims: int, :class:`numpy.ndarray`, optional + exclude_dims: Returns ------- float or :class:`pyttb.ktensor` - The number of dimensions of the returned `ktensor` is N-k, where N=self.ndims and k = number of - vectors provided as input. If k == N, a scalar is returned + The number of dimensions of the returned :class:`pyttb.ktensor` is + n-k, where n = self.ndims and k = number of vectors provided as + input. If k == n, a scalar is returned. - Example + Examples ------- - Compute the product of a `ktensor` and a single vector (results in a `ktensor`): + Compute the product of a :class:`pyttb.ktensor` and a single vector + (results in a :class:`pyttb.ktensor`): >>> rank = 2 >>> shape = np.array([2, 3, 4]) @@ -1634,7 +1985,7 @@ def ttv(self, vector, dims=None): >>> weights = 2 * np.ones(rank) >>> weights_and_data = np.concatenate((weights, data), axis=0) >>> K = ttb.ktensor.from_vector(weights_and_data[:], shape, True) - >>> K0 = K.ttv(np.array([1, 1, 1]), dims=1) # compute along a single dimension + >>> K0 = K.ttv(np.array([1, 1, 1]),dims=1) # compute along a single dimension >>> print(K0) ktensor of shape 2 x 4 weights=[36. 54.] @@ -1647,18 +1998,20 @@ def ttv(self, vector, dims=None): [13. 17.] [14. 18.]] - Compute the product of a `ktensor` and a vector for each dimension (results in a `float`): + Compute the product of a :class:`pyttb.ktensor` and a vector for each + dimension (results in a `float`): >>> vec2 = np.array([1, 1]) >>> vec3 = np.array([1, 1, 1]) >>> vec4 = np.array([1, 1, 1, 1]) - >>> K1 = K.ttv(np.array([vec2, vec3, vec4])) + >>> K1 = K.ttv([vec2, vec3, vec4]) >>> print(K1) 30348.0 - Compute the product of a `ktensor` and multiple vectors out of order (results in a `ktensor`): + Compute the product of a :class:`pyttb.ktensor` and multiple vectors + out of order (results in a :class:`pyttb.ktensor`): - >>> K2 = K.ttv(np.array([vec4, vec3]), np.array([2, 1])) + >>> K2 = K.ttv([vec4, vec3],np.array([2, 1])) >>> print(K2) ktensor of shape 2 weights=[1800. 3564.] @@ -1667,21 +2020,24 @@ def ttv(self, vector, dims=None): [2. 4.]] """ - if dims is None: + if dims is None and exclude_dims is None: dims = np.array([]) elif isinstance(dims, (float, int)): dims = np.array([dims]) + if isinstance(exclude_dims, (float, int)): + exclude_dims = np.array([exclude_dims]) + # Check that vector is a list of vectors, if not place single vector as element in list - if len(vector.shape) == 1 and isinstance(vector[0], (int, float, np.int_, np.float_)): - return self.ttv(np.array([vector]), dims) + if len(vector) > 0 and isinstance(vector[0], (int, float, np.int_, np.float_)): + return self.ttv([vector], dims) # Get sorted dims and index for multiplicands - dims, vidx = ttb.tt_dimscheck(dims, self.ndims, vector.shape[0]) + dims, vidx = ttb.tt_dimscheck(self.ndims, len(vector), dims, exclude_dims) # Check that each multiplicand is the right size. for i in range(dims.size): - if vector[vidx[i]].shape != (self.shape[dims[i]], ): + if vector[vidx[i]].shape != (self.shape[dims[i]],): assert False, "Multiplicand is wrong size" # Figure out which dimensions will be left when we're done @@ -1690,7 +2046,9 @@ def ttv(self, vector, dims=None): # Collapse dimensions that are being multiplied out new_weights = self.weights.copy() for i in range(len(dims)): - new_weights = new_weights * (self.factor_matrices[dims[i]].T @ vector[vidx[i]]) + new_weights = new_weights * ( + self.factor_matrices[dims[i]].T @ vector[vidx[i]] + ) # Create final result if len(remdims) == 0: @@ -1703,31 +2061,34 @@ def ttv(self, vector, dims=None): def update(self, modes, data): """ - Updates a `ktensor` in the specific dimensions, `modes`, with the values in `data` - (in vector or matrix form). The value of `mode` must be an integer between 1 - and self.ndims. Further, the number of elements in `data` must equal self.shape[mode] * self.ncomponents. + Updates a :class:`pyttb.ktensor` in the specific dimensions with the + values in `data` (in vector or matrix form). The value of `modes` must + be a value in [-1,...,self.ndoms]. If the Further, the number of elements in + `data` must equal self.shape[modes] * self.ncomponents. The update is + performed in place. Parameters ---------- - modes: list - List of dimensions to update; values must be in ascending order. Can include one or more of the following values: - * -1: update the weights - * `int`: value must be sorted and in `{range(self.ndims)}` - data: :class:numpy.ndarray - Vector of data values to use in the update + modes: int or :class:`list` of int, required + List of dimensions to update; values must be in ascending order. If + the first element of the list is -1, then update the weights. All + other integer values values must be sorted and in + [0,...,self.ndims]. + data: :class:`numpy.ndarray`, required + Data values to use in the update. Results ------- :class:`pyttb.ktensor` - The update is performed in place and a reference to self is returned - Example - ------- - Create a `ktensor` of all ones: + Examples + -------- + Create a :class:`pyttb.ktensor` of all ones: >>> K = ttb.ktensor.from_function(np.ones, (2, 3, 4), 2) - Create vectors for updating various factor matrices of the `ktensor`: + Create vectors for updating various factor matrices of the + :class:`pyttb.ktensor`: >>> vec0 = 2 * np.ones(K.shape[0] * K.ncomponents) >>> vec1 = 3 * np.ones(K.shape[1] * K.ncomponents) @@ -1774,7 +2135,7 @@ def update(self, modes, data): [4. 4.] [4. 4.]] - Update some but not all factor matrices + Update some but not all factor matrices: >>> K3 = K.copy() >>> vec_some = np.concatenate((vec0, vec2)) @@ -1815,24 +2176,27 @@ def update(self, modes, data): endloc = loc + self.shape[k] * self.ncomponents if len(data) < endloc: assert False, "Data is too short" - self.factor_matrices[k] = np.reshape(data[loc:endloc].copy(), (self.shape[k], self.ncomponents)) + self.factor_matrices[k] = np.reshape( + data[loc:endloc].copy(), (self.shape[k], self.ncomponents) + ) loc = endloc else: - assert False, 'Invalid mode: {}'.format(k) + assert False, "Invalid mode: {}".format(k) ## Check that we used all the data if not (loc == len(data)): - warnings.warn('Failed to consume all of the input data') + warnings.warn("Failed to consume all of the input data") return self def __add__(self, other): """ - Binary addition for `ktensors`. + Binary addition for :class:`pyttb.ktensor`. Parameters ---------- - other: :class:`pyttb.ktensor` + other: :class:`pyttb.ktensor`, required + :class:`pyttb.ktensor` to add to `self`. Returns ------- @@ -1840,33 +2204,38 @@ def __add__(self, other): """ # TODO include test of other as sumtensor and call sumtensor.__add__ if not isinstance(other, ktensor): - assert False, 'Cannot add instance of this type to a ktensor' + assert False, "Cannot add instance of this type to a ktensor" if self.shape != other.shape: - assert False, 'Must be two ktensors of the same shape' + assert False, "Must be two ktensors of the same shape" weights = np.concatenate((self.weights, other.weights)) factor_matrices = [] for k in range(self.ndims): - factor_matrices.append(np.concatenate((self.factor_matrices[k], other.factor_matrices[k]), axis=1)) + factor_matrices.append( + np.concatenate( + (self.factor_matrices[k], other.factor_matrices[k]), axis=1 + ) + ) return ktensor.from_data(weights, factor_matrices) def __getitem__(self, item): """ - Subscripted reference for a `ktensor`. + Subscripted reference for a :class:`pyttb.ktensor`. - Subscripted reference is used to query the components of a `ktensor`. + Subscripted reference is used to query the components of a + :class:`pyttb.ktensor`. Parameters ---------- - item: {tuple(int), int} + item: tuple(int) or int, required - Example - ------- + Examples + -------- >>> K = ttb.ktensor.from_function(np.ones, (2, 3, 4), 2) - >>> K.weights #<--returns the weights array (np.array([1., 1.])) + >>> K.weights array([1., 1.]) - >>> K.factor_matrices #<--returns a list of 3 np.ndarrays + >>> K.factor_matrices [array([[1., 1.], [1., 1.]]), array([[1., 1.], [1., 1.], @@ -1874,15 +2243,15 @@ def __getitem__(self, item): [1., 1.], [1., 1.], [1., 1.]])] - >>> K.factor_matrices[0] #<--returns the factor matrix corresponding to the first mode + >>> K.factor_matrices[0] array([[1., 1.], [1., 1.]]) - >>> K[0] #<-returns the factor matrix corresponding to the first mode + >>> K[0] array([[1., 1.], [1., 1.]]) - >>> K[1, 2, 0] #<--calculates and returns a specific single element of K + >>> K[1, 2, 0] 2.0 - >>> K[0][:, [0]] # returns the first column of the factor matrix corresponding to the first mode + >>> K[0][:, [0]] array([[1.], [1.]]) """ @@ -1894,23 +2263,25 @@ def __getitem__(self, item): b = self.weights[k] for i in range(self.ndims): b = b * self.factor_matrices[i][item[i], k] - a = a + b; + a = a + b return a else: - assert False, "ktensor.__getitem__ requires tuples with {} elements".format(self.ndims) + assert ( + False + ), "ktensor.__getitem__ requires tuples with {} elements".format( + self.ndims + ) elif isinstance(item, (int, np.int_)) and item in range(self.ndims): # Extract factor matrix return self.factor_matrices[item].copy() else: - assert False, 'ktensor.__getitem__() can only extract single elements (tuple of indices) or factor matrices (single index)' + assert ( + False + ), "ktensor.__getitem__() can only extract single elements (tuple of indices) or factor matrices (single index)" def __neg__(self): """ - Unary minus (negative) for ktensors. - - Parameters - ---------- - other: :class:`pyttb.ktensor` + Unary minus (negative) for :class:`pyttb.ktensor` instances. Returns ------- @@ -1920,11 +2291,7 @@ def __neg__(self): def __pos__(self): """ - Unary minus (positive) for `ktensors`. - - Parameters - ---------- - other: :class:`pyttb.ktensor` + Unary plus (positive) for :class:`pyttb.ktensor` instances. Returns ------- @@ -1934,25 +2301,44 @@ def __pos__(self): def __setitem__(self, key, value): """ - Subscripted assignment for `ktensors`. + Subscripted assignment for :class:`pyttb.ktensor`. - Subscripted assignment cannot be used to update individual elements of a `ktensor`. However, you - can update the weights vector or the factor matrices of a `ktensor`. The entire factor matrix or weight + Subscripted assignment cannot be used to update individual elements of + a :class:`pyttb.ktensor`. You can update the weights vector or the + factor matrices of a :class:`pyttb.ktensor`. Example ------- - >>> K = ktensor.from_data(np.ones((4,1)), [np.random.random((2,4)), np.random.random((3,4)), np.random.random((4,4))]) + >>> K = ttb.ktensor.from_data(np.ones((4,1)), [np.random.random((2,4)), np.random.random((3,4)), np.random.random((4,4))]) >>> K.weights = 2 * np.ones((4,1)) >>> K.factor_matrices[0] = np.zeros((2, 4)) >>> K.factor_matrices = [np.zeros((2, 4)), np.zeros((3, 4)), np.zeros((4, 4))] + >>> print(K) + ktensor of shape 2 x 3 x 4 + weights=[[2.] + [2.] + [2.] + [2.]] + factor_matrices[0] = + [[0. 0. 0. 0.] + [0. 0. 0. 0.]] + factor_matrices[1] = + [[0. 0. 0. 0.] + [0. 0. 0. 0.] + [0. 0. 0. 0.]] + factor_matrices[2] = + [[0. 0. 0. 0.] + [0. 0. 0. 0.] + [0. 0. 0. 0.] + [0. 0. 0. 0.]] """ - assert False, 'Subscripted assignment cannot be used to update individual elements of a ktensor. However, \ - you can update the weights vector or the factor matrices of a ktensor. The entire factor matrix or weight \ - vector must be provided.' + assert ( + False + ), "Subscripted assignment cannot be used to update individual elements of a ktensor. However, you can update the weights vector or the factor matrices of a ktensor. The entire factor matrix or weight vector must be provided." def __sub__(self, other): """ - Binary subtraction for ktensors. + Binary subtraction for :class:`pyttb.ktensor`. Parameters ---------- @@ -1963,20 +2349,25 @@ def __sub__(self, other): :class:`pyttb.ktensor` """ if not isinstance(other, ktensor): - assert False, 'Cannot subtract instance of this type from a ktensor' + assert False, "Cannot subtract instance of this type from a ktensor" if self.shape != other.shape: - assert False, 'Must be two ktensors of the same shape' + assert False, "Must be two ktensors of the same shape" weights = np.concatenate((self.weights, -other.weights)) factor_matrices = [] for k in range(self.ndims): - factor_matrices.append(np.concatenate((self.factor_matrices[k], other.factor_matrices[k]), axis=1)) + factor_matrices.append( + np.concatenate( + (self.factor_matrices[k], other.factor_matrices[k]), axis=1 + ) + ) return ktensor.from_data(weights, factor_matrices) def __mul__(self, other): """ - Elementwise (including scalar) multiplication for ktensors. + Elementwise (including scalar) multiplication for + :class:`pyttb.ktensor` instances. Parameters ---------- @@ -1992,11 +2383,14 @@ def __mul__(self, other): if isinstance(other, (float, int)): return ktensor.from_data(other * self.weights, self.factor_matrices) - assert False, "Multiplication by ktensors only allowed for scalars, tensors, or sptensors" + assert ( + False + ), "Multiplication by ktensors only allowed for scalars, tensors, or sptensors" def __rmul__(self, other): """ - Elementwise (including scalar) multiplication for ktensors. + Elementwise (including scalar) multiplication for + :class:`pyttb.ktensor` instances. Parameters ---------- @@ -2010,27 +2404,27 @@ def __rmul__(self, other): def __repr__(self): """ - String representation of a ktensor. - + String representation of a :class:`pyttb.ktensor`. + Returns ------- str: - Contains the shape, weights and factor_matrices as strings on different lines. """ - s = '' - s += 'ktensor of shape ' - s += (' x ').join([str(int(d)) for d in self.shape]) - s += '\n' - s += 'weights=' + s = "" + s += "ktensor of shape " + s += (" x ").join([str(int(d)) for d in self.shape]) + s += "\n" + s += "weights=" s += str(self.weights) for i in range(len(self.factor_matrices)): - s += '\nfactor_matrices[{}] =\n'.format(i) + s += "\nfactor_matrices[{}] =\n".format(i) s += str(self.factor_matrices[i]) return s __str__ = __repr__ + if __name__ == "__main__": - import doctest # pragma: no cover - import pyttb as ttb # pragma: no cover - doctest.testmod() # pragma: no cover + import doctest # pragma: no cover + + doctest.testmod() # pragma: no cover diff --git a/pyttb/pyttb_utils.py b/pyttb/pyttb_utils.py index 4f3d06f5..accfdb8d 100644 --- a/pyttb/pyttb_utils.py +++ b/pyttb/pyttb_utils.py @@ -1,15 +1,19 @@ # Copyright 2022 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. +"""PYTTB shared utilities across tensor types""" +from inspect import signature +from typing import Optional, Tuple, overload -import pyttb as ttb import numpy as np -from inspect import signature -import scipy.sparse as sparse -def tt_to_dense_matrix(tensorInstance, mode, transpose= False): +import pyttb as ttb + + +def tt_to_dense_matrix(tensorInstance, mode, transpose=False): """ - Helper function to unwrap tensor into dense matrix, should replace the core need for tenmat + Helper function to unwrap tensor into dense matrix, should replace the core need + for tenmat Parameters ---------- @@ -31,15 +35,19 @@ def tt_to_dense_matrix(tensorInstance, mode, transpose= False): # TODO check if full can be done after permutation and reshape for efficiency if isinstance(tensorInstance, ttb.ktensor): tensorInstance = tensorInstance.full() - tensorInstance = tensorInstance.permute(permutation).reshape((siz[mode], np.prod(siz[old]))) + tensorInstance = tensorInstance.permute(permutation).reshape( + (siz[mode], np.prod(siz[old])) + ) matrix = tensorInstance.data if transpose: matrix = np.transpose(matrix) return matrix + def tt_from_dense_matrix(matrix, shape, mode, idx): """ - Helper function to wrap dense matrix into tensor. Inverse of :class:`pyttb.tt_to_dense_matrix` + Helper function to wrap dense matrix into tensor. + Inverse of :class:`pyttb.tt_to_dense_matrix` Parameters ---------- @@ -57,59 +65,11 @@ def tt_from_dense_matrix(matrix, shape, mode, idx): if idx == 0: tensorInstance = tensorInstance.permute(np.array([1, 0])) tensorInstance = tensorInstance.reshape(shape) - tensorInstance = tensorInstance.permute(np.concatenate((np.arange(1, mode + 1), [0], np.arange(mode + 1, len(shape))))) + tensorInstance = tensorInstance.permute( + np.concatenate((np.arange(1, mode + 1), [0], np.arange(mode + 1, len(shape)))) + ) return tensorInstance -def tt_to_sparse_matrix(sptensorInstance, mode, transpose= False): - """ - Helper function to unwrap sptensor into sparse matrix, should replace the core need for sptenmat - - Parameters - ---------- - sptensorInstance: :class:`pyttb.sptensor` - mode: int - Mode around which to unwrap tensor - transpose: bool - Whether or not to tranpose unwrapped tensor - - Returns - ------- - spmatrix: :class:`Scipy.sparse.coo_matrix` - """ - old = np.setdiff1d(np.arange(sptensorInstance.ndims), mode).astype(int) - spmatrix = sptensorInstance.reshape((np.prod(np.array(sptensorInstance.shape)[old]), ), old).spmatrix() - if transpose: - return spmatrix.transpose() - else: - return spmatrix - -def tt_from_sparse_matrix(spmatrix, shape, mode, idx): - """ - Helper function to wrap sparse matrix into sptensor. Inverse of :class:`pyttb.tt_to_sparse_matrix` - - Parameters - ---------- - spmatrix: :class:`Scipy.sparse.coo_matrix` - mode: int - Mode around which tensor was unwrapped - idx: int - in {0,1}, idx of mode in spmatrix, s.b. 0 for tranpose=True - - Returns - ------- - sptensorInstance: :class:`pyttb.sptensor` - """ - siz = np.array(shape) - old = np.setdiff1d(np.arange(len(shape)), mode).astype(int) - sptensorInstance = ttb.sptensor.from_tensor_type(sparse.coo_matrix(spmatrix)) - - # This expands the compressed dimension back to full size - sptensorInstance = sptensorInstance.reshape(siz[old], idx) - # This puts the modes in the right order, reshape places modified modes after the unchanged ones - sptensorInstance = sptensorInstance.reshape(shape, np.concatenate((np.arange(1, mode + 1), [0], np.arange(mode + 1, len(shape))))) - - return sptensorInstance - def tt_union_rows(MatrixA, MatrixB): """ @@ -126,12 +86,14 @@ def tt_union_rows(MatrixA, MatrixB): Examples -------- - >>>a = np.array([[1,2],[3,4]]) - >>>b = np.array([[0,0],[1,2],[3,4],[0,0]]) - >>>ttb.tt_union_rows(a,b) - [[1,2],[3,4],[0,0]] - """ - #TODO ismember and uniqe are very similar in function + >>> a = np.array([[1,2],[3,4]]) + >>> b = np.array([[0,0],[1,2],[3,4],[0,0]]) + >>> ttb.tt_union_rows(a,b) + array([[0, 0], + [1, 2], + [3, 4]]) + """ + # TODO ismember and uniqe are very similar in function if MatrixA.size > 0: MatrixAUnique, idxA = np.unique(MatrixA, axis=0, return_index=True) else: @@ -142,11 +104,41 @@ def tt_union_rows(MatrixA, MatrixB): else: MatrixB = MatrixBUnique = np.empty(shape=MatrixA.shape) idxB = np.array([], dtype=int) - location = tt_ismember_rows(MatrixBUnique[np.argsort(idxB)], MatrixAUnique[np.argsort(idxA)]) - union = np.vstack((MatrixB[np.sort(idxB[np.where(location < 0)])], MatrixA[np.sort(idxA)])) + location = tt_ismember_rows( + MatrixBUnique[np.argsort(idxB)], MatrixAUnique[np.argsort(idxA)] + ) + union = np.vstack( + (MatrixB[np.sort(idxB[np.where(location < 0)])], MatrixA[np.sort(idxA)]) + ) return union -def tt_dimscheck(dims, N, M=None): + +@overload +def tt_dimscheck( + N: int, + M: None = None, + dims: Optional[np.ndarray] = None, + exclude_dims: Optional[np.ndarray] = None, +) -> Tuple[np.ndarray, None]: + ... # pragma: no cover see coveragepy/issues/970 + + +@overload +def tt_dimscheck( + N: int, + M: int, + dims: Optional[np.ndarray] = None, + exclude_dims: Optional[np.ndarray] = None, +) -> Tuple[np.ndarray, np.ndarray]: + ... # pragma: no cover see coveragepy/issues/970 + + +def tt_dimscheck( + N: int, + M: Optional[int] = None, + dims: Optional[np.ndarray] = None, + exclude_dims: Optional[np.ndarray] = None, +) -> Tuple[np.ndarray, Optional[np.ndarray]]: """ Used to preprocess dimensions for tensor dimensions @@ -157,24 +149,43 @@ def tt_dimscheck(dims, N, M=None): ------- """ + if dims is not None and exclude_dims is not None: + raise ValueError("Either specify dims to include or exclude, but not both") + + dim_array: np.ndarray = np.empty((1,)) + + # Explicit exclude to resolve ambiguous -0 + if exclude_dims is not None: + # Check that all members in range + valid_indices = np.isin(exclude_dims, np.arange(0, N)) + if not np.all(valid_indices): + invalid_indices = np.logical_not(valid_indices) + raise ValueError( + f"Exclude dims provided: {exclude_dims} " + f"but, {exclude_dims[invalid_indices]} were out of valid range" + f"[0,{N}]" + ) + dim_array = np.setdiff1d(np.arange(0, N), exclude_dims) + # Fix empty case - if dims.size == 0: - dims = np.arange(0, N) + if (dims is None or dims.size == 0) and exclude_dims is None: + dim_array = np.arange(0, N) + elif isinstance(dims, np.ndarray): + dim_array = dims - # Fix "minus" case - if (np.max(dims) < 0): - # Check that all memebers in range - if not np.all(np.isin(-dims, np.arange(0, N+1))): - assert False, "Invalid magnitude for negative dims selection" - dims = np.setdiff1d(np.arange(1, N+1), -dims) - 1 + # Catch minus case to avoid silent errors + if np.any(dim_array < 0): + raise ValueError( + "Negative dims aren't allowed in pyttb, see exclude_dims argument instead" + ) # Save dimensions of dims - P = len(dims) + P = len(dim_array) - # Reorder dims from smallest to largest - # (this matters in particular for the vector multiplicand case, where the order affects the result) - sidx = np.argsort(dims) - sdims = dims[sidx] + # Reorder dims from smallest to largest (this matters in particular for the vector + # multiplicand case, where the order affects the result) + sidx = np.argsort(dim_array) + sdims = dim_array[sidx] vidx = None if M is not None: @@ -182,23 +193,25 @@ def tt_dimscheck(dims, N, M=None): if M > N: assert False, "Cannot have more multiplicands than dimensions" - # Check that the number of multiplicands must either be full dimensional or equal to the specified dimensions - # (M==N) or M(==P) respectively - if M != N and M != P: + # Check that the number of multiplicands must either be full dimensional or + # equal to the specified dimensions (M==N) or M(==P) respectively + if M not in (N, P): assert False, "Invalid number of multiplicands" # Check sizes to determine how to index multiplicands if P == M: - # Case 1: Number of items in dims and number of multiplicands are equal; therfore, index in order of sdims + # Case 1: Number of items in dims and number of multiplicands are equal; + # therfore, index in order of sdims vidx = sidx else: - # Case 2: Number of multiplicands is equal to the number of dimensions of tensor; - # therefore, index multiplicands by dimensions in dims argument. + # Case 2: Number of multiplicands is equal to the number of dimensions of + # tensor; therefore, index multiplicands by dimensions in dims argument. vidx = sdims return sdims, vidx -def tt_tenfun(function_handle, *inputs): + +def tt_tenfun(function_handle, *inputs): # pylint:disable=too-many-branches """ Apply a function to each element in a tensor @@ -220,29 +233,50 @@ def tt_tenfun(function_handle, *inputs): assert callable(function_handle), "function_handle must be callable" # Convert inputs to tensors if they aren't already - for i in range(0, len(inputs)): - if isinstance(inputs[i], ttb.tensor) or isinstance(inputs[i], (float, int)): + for i, an_input in enumerate(inputs): + if isinstance(an_input, (ttb.tensor, float, int)): continue - elif isinstance(inputs[i], np.ndarray): - inputs[i] = ttb.tensor.from_data(inputs[i]) - elif isinstance(inputs[i], (ttb.ktensor, ttb.ttensor, ttb.sptensor, ttb.sumtensor, ttb.symtensor, ttb.symktensor)): - inputs[i] = ttb.tensor.from_tensor_type(inputs[i]) + if isinstance(an_input, np.ndarray): + inputs[i] = ttb.tensor.from_data(an_input) + elif isinstance( + an_input, + ( + ttb.ktensor, + ttb.ttensor, + ttb.sptensor, + ttb.sumtensor, + ttb.symtensor, + ttb.symktensor, + ), + ): + inputs[i] = ttb.tensor.from_tensor_type(an_input) 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): + # 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): + elif ( + (len(inputs) == 2) + and isinstance(inputs[1], (float, int)) + and isinstance(inputs[0], ttb.tensor) + ): sz = inputs[0].shape else: - for i in range(0, len(inputs)): - if isinstance(inputs[i], (float, int)): - assert False, "Argument {} is a scalar but expected a tensor".format(i) + 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 = inputs[i].shape - elif sz != inputs[i].shape: - assert False, "Tensor {} is not the same size as the first tensor input".format(i) + 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) @@ -266,13 +300,14 @@ def tt_tenfun(function_handle, *inputs): X = np.reshape(X, (1, -1)) else: X = np.zeros((len(inputs), np.prod(sz))) - for i in range(0, len(inputs)): - X[i, :] = np.reshape(inputs[i].data, (np.prod(sz))) + for i, an_input in enumerate(inputs): + X[i, :] = np.reshape(an_input.data, (np.prod(sz))) data = function_handle(X) data = np.reshape(data, sz) Z = ttb.tensor.from_data(data) return Z + def tt_setdiff_rows(MatrixA, MatrixB): """ Helper function to reproduce functionality of MATLABS setdiff(a,b,'rows') @@ -286,7 +321,7 @@ def tt_setdiff_rows(MatrixA, MatrixB): ------- location: :class:`numpy.ndarray` list of set difference indices """ - #TODO intersect and setdiff are very similar in function + # TODO intersect and setdiff are very similar in function if MatrixA.size > 0: MatrixAUnique, idxA = np.unique(MatrixA, axis=0, return_index=True) else: @@ -295,7 +330,9 @@ def tt_setdiff_rows(MatrixA, MatrixB): MatrixBUnique, idxB = np.unique(MatrixB, axis=0, return_index=True) else: MatrixBUnique = idxB = np.array([], dtype=int) - location = tt_ismember_rows(MatrixBUnique[np.argsort(idxB)], MatrixAUnique[np.argsort(idxA)]) + location = tt_ismember_rows( + MatrixBUnique[np.argsort(idxB)], MatrixAUnique[np.argsort(idxA)] + ) return np.setdiff1d(idxA, location[np.where(location >= 0)]) @@ -314,14 +351,14 @@ def tt_intersect_rows(MatrixA, MatrixB): Examples -------- - >>>a = np.array([[1,2],[3,4]]) - >>>b = np.array([[0,0],[1,2],[3,4],[0,0]]) - >>>ttb.tt_intersect_rows(a,b) - [0,1] - >>>ttb.tt_intersect_rows(b,a) - [1,2] - """ - #TODO ismember and uniqe are very similar in function + >>> a = np.array([[1,2],[3,4]]) + >>> b = np.array([[0,0],[1,2],[3,4],[0,0]]) + >>> ttb.tt_intersect_rows(a,b) + array([0, 1]) + >>> ttb.tt_intersect_rows(b,a) + array([1, 2]) + """ + # TODO ismember and uniqe are very similar in function if MatrixA.size > 0: MatrixAUnique, idxA = np.unique(MatrixA, axis=0, return_index=True) else: @@ -330,11 +367,13 @@ def tt_intersect_rows(MatrixA, MatrixB): MatrixBUnique, idxB = np.unique(MatrixB, axis=0, return_index=True) else: MatrixBUnique = idxB = np.array([], dtype=int) - location = tt_ismember_rows(MatrixBUnique[np.argsort(idxB)], MatrixAUnique[np.argsort(idxA)]) + location = tt_ismember_rows( + MatrixBUnique[np.argsort(idxB)], MatrixAUnique[np.argsort(idxA)] + ) return location[np.where(location >= 0)] -def tt_irenumber(t, shape, number_range): +def tt_irenumber(t, shape, number_range): # pylint: disable=unused-argument """ RENUMBER indices for sptensor subsasgn @@ -348,25 +387,26 @@ def tt_irenumber(t, shape, number_range): ------- newsubs: :class:`numpy.ndarray` """ - # TODO shape is unused. Should it be used? I don't particularly understand what this is meant to be doing + # TODO shape is unused. Should it be used? I don't particularly understand what + # this is meant to be doing nz = t.nnz if nz == 0: newsubs = np.array([]) return newsubs - else: - newsubs = t.subs.astype(int) - for i in range(0, len(number_range)): - r = number_range[i] - if isinstance(r, slice): - newsubs[:, i] = (newsubs[:, i])[r] - elif isinstance(r, int): - # 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): - r = np.array(r) - newsubs[:, i] = r[newsubs[:, i]] - return newsubs + + newsubs = t.subs.astype(int) + for i, r in enumerate(number_range): + if isinstance(r, slice): + newsubs[:, i] = (newsubs[:, i])[r] + elif isinstance(r, int): + # 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): + r = np.array(r) + newsubs[:, i] = r[newsubs[:, i]] + return newsubs + def tt_assignment_type(x, subs, rhs): """ @@ -382,13 +422,13 @@ def tt_assignment_type(x, subs, rhs): ------- objectType """ - if type(x) == type(rhs): - return 'subtensor' + if type(x) is type(rhs): + return "subtensor" # If subscripts is a tuple that contains an nparray - elif (isinstance(subs, tuple) and len(subs) >= 2): - return 'subtensor' - else: - return 'subscripts' + if isinstance(subs, tuple) and len(subs) >= 2: + return "subtensor" + return "subscripts" + def tt_renumber(subs, shape, number_range): """ @@ -413,11 +453,11 @@ def tt_renumber(subs, shape, number_range): """ newshape = np.array(shape) newsubs = subs - for i in range(0, len(shape)): - if not (number_range[i] == slice(None, None, None)): + for i in range(0, len(shape)): # pylint: disable=consider-using-enumerate + 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)): + if isinstance(number_range[i], (int, float)): newshape[i] = number_range[i] else: newshape[i] = len(number_range[i]) @@ -425,7 +465,9 @@ def tt_renumber(subs, shape, number_range): # TODO get this length without generating the range newshape[i] = len(range(0, shape[i])[number_range[i]]) else: - newsubs[:, i], newshape[i] = tt_renumberdim(subs[:, i], shape[i], number_range[i]) + newsubs[:, i], newshape[i] = tt_renumberdim( + subs[:, i], shape[i], number_range[i] + ) return newsubs, tuple(newshape) @@ -464,12 +506,14 @@ def tt_renumberdim(idx, shape, number_range): return newidx, newshape +# TODO make more efficient, decide if we want to support the multiple response +# matlab does +# pylint: disable=line-too-long +# https://stackoverflow.com/questions/22699756/python-version-of-ismember-with-rows-and-index +# For thoughts on how to speed this up def tt_ismember_rows(search, source): """ Find location of search rows in source array - https://stackoverflow.com/questions/22699756/python-version-of-ismember-with-rows-and-index - For thoughts on how to speed this up - #TODO make more efficient, decide if we want to support the multiple response matlab does Parameters ---------- @@ -486,12 +530,13 @@ def tt_ismember_rows(search, source): Examples -------- >>> a = np.array([[4, 6], [1, 9], [2, 6]]) - >>> b = np.array([[1, 7],[1, 8],[2, 6],[2, 1],[2, 4],[4, 6],[4, 7],[5, 9],[5, 2],[5, 1]]) + >>> b = np.array([[2, 6],[2, 1],[2, 4],[4, 6],[4, 7],[5, 9],[5, 2],[5, 1]]) >>> results = tt_ismember_rows(a,b) - array([5 , -1, 2]) + >>> print(results) + [ 3 -1 0] """ - results = np.ones(shape=search.shape[0])*-1 + results = np.ones(shape=search.shape[0]) * -1 if search.size == 0: return results.astype(int) if source.size == 0: @@ -501,7 +546,7 @@ def tt_ismember_rows(search, source): return results.astype(int) -def tt_ind2sub(shape, idx): +def tt_ind2sub(shape: Tuple[int, ...], idx: np.ndarray) -> np.ndarray: """ Multiple subscripts from linear indices. @@ -514,12 +559,12 @@ def tt_ind2sub(shape, idx): :class:`numpy.ndarray` """ if idx.size == 0: - return np.array([]) + return np.empty(shape=(0, len(shape)), dtype=int) - return np.array(np.unravel_index(idx, shape)).transpose() + return np.array(np.unravel_index(idx, shape, order="F")).transpose() -def tt_subsubsref(obj, s): +def tt_subsubsref(obj, s): # pylint: disable=unused-argument """ Helper function for tensor toolbox subsref. @@ -532,17 +577,19 @@ def tt_subsubsref(obj, s): ------- Still uncertain to this functionality """ - # TODO figure out when subsref yields key of length>1 for now ignore this logic and just return - #if len(s) == 1: + # TODO figure out when subsref yields key of length>1 for now ignore this logic and + # just return + # if len(s) == 1: # return obj - #else: + # else: # return obj[s[1:]] return obj def tt_intvec2str(v): """ - Print integer vector to a string with brackets. Numpy should already handle this so it is a placeholder stub + Print integer vector to a string with brackets. Numpy should already handle this so + it is a placeholder stub Parameters ---------- @@ -553,6 +600,7 @@ def tt_intvec2str(v): """ return np.array2string(v) + def tt_sub2ind(shape, subs): """ Converts multidimensional subscripts to linear indices. @@ -575,7 +623,7 @@ def tt_sub2ind(shape, subs): """ if subs.size == 0: return np.array([]) - idx = np.ravel_multi_index(tuple(subs.transpose()), shape) + idx = np.ravel_multi_index(tuple(subs.transpose()), shape, order="F") return idx @@ -604,7 +652,12 @@ def tt_sizecheck(shape, nargout=True): :func:`tt_subscheck`: """ siz = np.array(shape) - if len(siz.shape) == 1 and all(np.isfinite(siz)) and issubclass(siz.dtype.type, np.integer) and all(siz > 0): + if ( + len(siz.shape) == 1 + and all(np.isfinite(siz)) + and issubclass(siz.dtype.type, np.integer) + and all(siz > 0) + ): ok = True elif siz.size == 0: ok = True @@ -612,7 +665,7 @@ def tt_sizecheck(shape, nargout=True): ok = False if not ok and not nargout: - assert False, 'Size must be a row vector of real positive integers' + assert False, "Size must be a row vector of real positive integers" return ok @@ -643,13 +696,18 @@ def tt_subscheck(subs, nargout=True): """ if subs.size == 0: ok = True - elif len(subs.shape) == 2 and (np.isfinite(subs)).all() and issubclass(subs.dtype.type, np.integer) and (subs > 0).all(): + elif ( + len(subs.shape) == 2 + and (np.isfinite(subs)).all() + and issubclass(subs.dtype.type, np.integer) + and (subs >= 0).all() + ): ok = True else: ok = False if not ok and not nargout: - assert False, 'Subscripts must be a matrix of real positive integers' + assert False, "Subscripts must be a matrix of real positive integers" return ok @@ -678,7 +736,7 @@ def tt_valscheck(vals, nargout=True): else: ok = False if not ok and not nargout: - assert False, 'Values must be in array' + assert False, "Values must be in array" return ok @@ -697,10 +755,7 @@ def isrow(v): ------- bool """ - if v.ndim == 2 and v.shape[0] == 1 and v.shape[1] >= 1: - return True - else: - return False + return v.ndim == 2 and v.shape[0] == 1 and v.shape[1] >= 1 def isvector(a): @@ -717,12 +772,11 @@ def isvector(a): ------- bool """ - if a.ndim == 1 or (a.ndim ==2 and (a.shape[0] == 1 or a.shape[1] == 1)): - return True - else: - return False + return a.ndim == 1 or (a.ndim == 2 and (a.shape[0] == 1 or a.shape[1] == 1)) + -# TODO: this is a challenge, since it may need to apply to either Python built in types or numpy types +# TODO: this is a challenge, since it may need to apply to either Python built in types +# or numpy types def islogical(a): """ ISLOGICAL Checks if vector is a logical vector. @@ -737,4 +791,4 @@ def islogical(a): ------- bool """ - return type(a) == bool + return isinstance(a, bool) diff --git a/pyttb/sptenmat.py b/pyttb/sptenmat.py index e7b6ef93..a901937e 100644 --- a/pyttb/sptenmat.py +++ b/pyttb/sptenmat.py @@ -2,9 +2,12 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. +import numpy as np + import pyttb as ttb + from .pyttb_utils import * -import numpy as np + class sptenmat(object): """ diff --git a/pyttb/sptensor.py b/pyttb/sptensor.py index 32de933b..9aeb83a5 100644 --- a/pyttb/sptensor.py +++ b/pyttb/sptensor.py @@ -1,20 +1,99 @@ # Copyright 2022 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. +"""Sparse Tensor Implementation""" +from __future__ import annotations -import pyttb as ttb -from .pyttb_utils import * -import numpy as np -from numpy_groupies import aggregate as accumarray +import logging import warnings -import scipy.sparse as sparse +from collections.abc import Iterable, Sequence +from typing import Any, Callable, List, Optional, Tuple, Union, cast, overload + +import numpy as np import scipy.sparse.linalg +from numpy_groupies import aggregate as accumarray +from scipy import sparse + +import pyttb as ttb +from pyttb.pyttb_utils import ( + tt_assignment_type, + tt_dimscheck, + tt_ind2sub, + tt_intvec2str, + tt_sizecheck, + tt_sub2ind, + tt_subscheck, + tt_subsubsref, + tt_valscheck, +) + + +def tt_to_sparse_matrix( + sptensorInstance: sptensor, mode: int, transpose: bool = False +) -> sparse.coo_matrix: + """ + Helper function to unwrap sptensor into sparse matrix, should replace the core need + for sptenmat + + Parameters + ---------- + sptensorInstance: sparse tensor to unwrap + mode: Mode around which to unwrap tensor + transpose: Whether or not to tranpose unwrapped tensor + + Returns + ------- + spmatrix: unwrapped tensor + """ + old = np.setdiff1d(np.arange(sptensorInstance.ndims), mode).astype(int) + spmatrix = sptensorInstance.reshape( + (np.prod(np.array(sptensorInstance.shape)[old]),), old + ).spmatrix() + if transpose: + return spmatrix.transpose() + return spmatrix + + +def tt_from_sparse_matrix( + spmatrix: sparse.coo_matrix, shape: Any, mode: int, idx: int +) -> sptensor: + """ + Helper function to wrap sparse matrix into sptensor. + Inverse of :class:`pyttb.tt_to_sparse_matrix` + + Parameters + ---------- + spmatrix: :class:`Scipy.sparse.coo_matrix` + mode: int + Mode around which tensor was unwrapped + idx: int + in {0,1}, idx of mode in spmatrix, s.b. 0 for tranpose=True + + Returns + ------- + sptensorInstance: :class:`pyttb.sptensor` + """ + siz = np.array(shape) + old = np.setdiff1d(np.arange(len(shape)), mode).astype(int) + sptensorInstance = ttb.sptensor.from_tensor_type(sparse.coo_matrix(spmatrix)) + + # This expands the compressed dimension back to full size + sptensorInstance = sptensorInstance.reshape(siz[old], idx) + # This puts the modes in the right order, reshape places modified modes after the + # unchanged ones + sptensorInstance = sptensorInstance.reshape( + shape, + np.concatenate([np.arange(1, mode + 1), [0], np.arange(mode + 1, len(shape))]), + ) + + return sptensorInstance -class sptensor(object): +class sptensor: """ SPTENSOR Class for sparse tensors. """ + def __init__(self): """ Create an empty sparse tensor @@ -37,19 +116,17 @@ def __init__(self): # return @classmethod - def from_data(cls, subs, vals, shape): + def from_data( + cls, subs: np.ndarray, vals: np.ndarray, shape: Tuple[int, ...] + ) -> sptensor: """ Construct an sptensor from fully defined SUB, VAL and SIZE matrices. Parameters ---------- - subs: :class:`numpy.ndarray` - vals: :class:`numpy.ndarray` - shape: tuple - - Returns - ------- - :class:`pyttb.sptensor` + subs: location of non-zero entries + vals: values for non-zero entries + shape: shape of sparse tensor Examples -------- @@ -73,19 +150,19 @@ def from_data(cls, subs, vals, shape): return sptensorInstance @classmethod - def from_tensor_type(cls, source): + def from_tensor_type( + cls, source: Union[sptensor, ttb.tensor, sparse.coo_matrix] + ) -> sptensor: """ - Contruct an :class:`pyttb.sptensor` from :class:`pyttb.sptensor`, - :class:`pyttb.tensor`, :class:`pyttb.sptenmat`, :class:`pyttb.sptensor3` + Contruct an :class:`pyttb.sptensor` from compatible tensor types Parameters ---------- - source: :class:`pyttb.sptensor`, :class:`pyttb.tensor`, :class:`pyttb.sptenmat`,\ - or :class:`pyttb.sptensor3` + source: Source tensor to create sptensor from Returns ------- - :class:`pyttb.sptensor` + Generated Sparse Tensor """ # Copy Constructor if isinstance(source, sptensor): @@ -117,35 +194,48 @@ def from_tensor_type(cls, source): assert False, "Invalid Tensor Type To initialize Sptensor" @classmethod - def from_function(cls, function_handle, shape, nonzeros): + def from_function( + cls, + function_handle: Callable[[Tuple[int, ...]], np.ndarray], + shape: Tuple[int, ...], + nonzeros: float, + ) -> sptensor: """ - Creates a sparse tensor of the specified shape with NZ nonzeros created from the specified function handle + Creates a sparse tensor of the specified shape with NZ nonzeros created from + the specified function handle Parameters ---------- - function_handle: function that accepts 2 arguments and generates :class:`numpy.ndarray` of length nonzeros + function_handle: function that accepts 2 arguments and generates + :class:`numpy.ndarray` of length nonzeros shape: tuple nonzeros: int or float Returns ------- - :class:`pyttb.sptensor` + Generated Sparse Tensor """ # Random Tensor assert callable(function_handle), "function_handle must be callable" if (nonzeros < 0) or (nonzeros >= np.prod(shape)): - assert False, "Requested number of non-zeros must be positive and less than the total size" + assert False, ( + "Requested number of non-zeros must be positive " + "and less than the total size" + ) elif nonzeros < 1: - nonzeros = np.int(np.ceil(np.prod(shape) * nonzeros)) + nonzeros = int(np.ceil(np.prod(shape) * nonzeros)) else: - nonzeros = np.int(np.floor(nonzeros)) + nonzeros = int(np.floor(nonzeros)) + nonzeros = int(nonzeros) # Keep iterating until we find enough unique non-zeros or we give up subs = np.array([]) cnt = 0 while (len(subs) < nonzeros) and (cnt < 10): - subs = (np.random.uniform(size=[nonzeros, len(shape)]).dot(np.diag(shape))).astype(int) + subs = ( + np.random.uniform(size=[nonzeros, len(shape)]).dot(np.diag(shape)) + ).astype(int) subs = np.unique(subs, axis=0) cnt += 1 @@ -157,26 +247,34 @@ def from_function(cls, function_handle, shape, nonzeros): return cls().from_data(subs, vals, shape) @classmethod - def from_aggregator(cls, subs, vals, shape=None, function_handle='sum'): + def from_aggregator( + cls, + subs: np.ndarray, + vals: np.ndarray, + shape: Optional[Tuple[int, ...]] = None, + function_handle: Union[str, Callable[[Any], Union[float, np.ndarray]]] = "sum", + ) -> sptensor: """ - Construct an sptensor from fully defined SUB, VAL and shape matrices, after an aggregation is applied + Construct an sptensor from fully defined SUB, VAL and shape matrices, + after an aggregation is applied Parameters ---------- - subs: :class:`numpy.ndarray` - vals: :class:`numpy.ndarray` - shape: tuple - function_handle: callable + subs: location of non-zero entries + vals: values for non-zero entries + shape: shape of sparse tensor + function_handle: Aggregation function, or name of supported + aggregation function from numpy_groupies Returns ------- - :class:`pyttb.sptensor` + Generated Sparse Tensor Examples -------- >>> subs = np.array([[1, 2], [1, 3]]) >>> vals = np.array([[6], [7]]) - >>> shape = np.array([4, 4, 4]) + >>> shape = np.array([4, 4]) >>> K0 = ttb.sptensor.from_aggregator(subs,vals) >>> K1 = ttb.sptensor.from_aggregator(subs,vals,shape) >>> function_handle = sum @@ -199,8 +297,8 @@ def from_aggregator(cls, subs, vals, shape=None, function_handle='sum'): assert False, "More subscripts than specified by shape" # Check for subscripts out of range - for j in range(len(shape)): - if subs.size > 0 and np.max(subs[:, j]) > shape[j]: + for j, dim in enumerate(shape): + if subs.size > 0 and np.max(subs[:, j]) >= dim: assert False, "Subscript exceeds sptensor shape" if subs.size == 0: @@ -211,7 +309,9 @@ def from_aggregator(cls, subs, vals, shape=None, function_handle='sum'): newsubs, loc = np.unique(subs, axis=0, return_inverse=True) # Sum the corresponding values # Squeeze to convert from column vector to row vector - newvals = accumarray(loc, np.squeeze(vals), size=newsubs.shape[0], func=function_handle) + newvals = accumarray( + loc, np.squeeze(vals), size=newsubs.shape[0], func=function_handle + ) # Find the nonzero indices of the new values nzidx = np.nonzero(newvals) @@ -225,12 +325,13 @@ def from_aggregator(cls, subs, vals, shape=None, function_handle='sum'): return cls().from_data(newsubs, newvals, shape) # TODO decide if property - def allsubs(self): + def allsubs(self) -> np.ndarray: """ Generate all possible subscripts for sparse tensor + Returns ------- - s: :class:`numpy.ndarray` all possible subscripts for sptensor + s: All possible subscripts for sptensor """ # Generate all possible indices @@ -251,31 +352,43 @@ def allsubs(self): return s.astype(int) - def collapse(self, dims=None, fun="sum"): + def collapse( + self, + dims: Optional[np.ndarray] = None, + fun: Callable[[np.ndarray], Union[float, np.ndarray]] = np.sum, + ) -> Union[float, np.ndarray, sptensor]: """ Collapse sparse tensor along specified dimensions. Parameters ---------- - dims: - fun: callable + dims: Dimensions to collapse + fun: Method used to collapse dimensions Returns ------- + Collapsed value + Example + ------- + >>> subs = np.array([[1, 2], [1, 3]]) + >>> vals = np.array([[1], [1]]) + >>> shape = np.array([4, 4]) + >>> X = ttb.sptensor.from_data(subs, vals, shape) + >>> X.collapse() + 2 + >>> X.collapse(np.arange(X.ndims), sum) + 2 """ if dims is None: dims = np.arange(0, self.ndims) - dims, _ = tt_dimscheck(dims, self.ndims) + 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 if remdims.size == 0: - if fun == "sum": - return sum(self.vals.transpose()[0]) - else: - return fun(self.vals.transpose()[0]) + return fun(self.vals.transpose()[0]) # Calculate the size of the result newsize = np.array(self.shape)[remdims] @@ -283,29 +396,40 @@ def collapse(self, dims=None, fun="sum"): # Check for the case where the result is just a dense vector if remdims.size == 1: if self.subs.size > 0: - return accumarray(self.subs[:, remdims].transpose()[0], self.vals.transpose()[0], size=newsize[0], func=fun) - else: - return np.zeros((newsize[0], 1)) + return accumarray( + self.subs[:, remdims].transpose()[0], + self.vals.transpose()[0], + size=newsize[0], + func=fun, + ) + return np.zeros((newsize[0], 1)) # Create Result if self.subs.size > 0: - return ttb.sptensor.from_aggregator(self.subs[:, remdims], self.vals, tuple(newsize), fun) - else: - return ttb.sptensor.from_data(np.array([]), np.array([]), tuple(newsize)) + return ttb.sptensor.from_aggregator( + self.subs[:, remdims], self.vals, tuple(newsize), fun + ) + return ttb.sptensor.from_data(np.array([]), np.array([]), tuple(newsize)) - def contract(self, i, j): + def contract(self, i: int, j: int) -> Union[np.ndarray, sptensor, ttb.tensor]: """ Contract tensor along two dimensions (array trace). Parameters ---------- - i: int - j: int + i: First dimension + j: Second dimension Returns ------- + Contracted sptensor, converted to tensor if sufficiently dense - + Example + ------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> Y = sptensor.from_tensor_type(X) + >>> Y.contract(0, 1) + 2.0 """ if self.shape[i] != self.shape[j]: assert False, "Must contract along equally sized dimensions" @@ -329,79 +453,75 @@ def contract(self, i, j): # Let constructor sum entries if remdims.size == 1: - y = ttb.sptensor.from_aggregator(self.subs[indx, remdims][:, None], self.vals[indx], newsize) + y = ttb.sptensor.from_aggregator( + self.subs[indx, remdims][:, None], self.vals[indx], newsize + ) else: - y = ttb.sptensor.from_aggregator(self.subs[indx, :][:, remdims], self.vals[indx], newsize) + y = ttb.sptensor.from_aggregator( + self.subs[indx, :][:, remdims], self.vals[indx], newsize + ) # Check if result should be dense - if y.nnz > 0.5*np.prod(y.shape): + if y.nnz > 0.5 * np.prod(y.shape): # Final result is a dense tensor return ttb.tensor.from_tensor_type(y) - else: - return y + return y - def double(self): + def double(self) -> np.ndarray: """ Convert sptensor to dense multidimensional array - - Returns - ------- - :class:`numpy.ndarray` """ a = np.zeros(self.shape) if self.nnz > 0: a[tuple(self.subs.transpose())] = self.vals.transpose()[0] return a - def elemfun(self, function): + def elemfun(self, function_handle: Callable[[np.ndarray], np.ndarray]) -> sptensor: """ Manipulate the non-zero elements of a sparse tensor Parameters ---------- - function: callable + function_handle: Function that updates all values. Returns ------- - :class:`Tensortoolbox.sptensor` + Updated sptensor + + Example + ------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> Y = sptensor.from_tensor_type(X) + >>> Z = Y.elemfun(lambda values: values*2) + >>> Z.isequal(Y*2) + True """ - vals = function(self.vals) + vals = function_handle(self.vals) idx = np.where(vals > 0)[0] if idx.size == 0: return ttb.sptensor.from_data(np.array([]), np.array([]), self.shape) - else: - return ttb.sptensor.from_data(self.subs[idx, :], vals[idx], self.shape) + return ttb.sptensor.from_data(self.subs[idx, :], vals[idx], self.shape) - def end(self, k = None): + def end(self, k: Optional[int] = None) -> int: """ Last index of indexing expression for sparse tensor Parameters ---------- k: int Dimension for subscript indexing - n: int 1 for linear indexing, ndims for subscript - - Returns - ------- - int: """ if k is not None: return self.shape[k] - 1 - else: - return np.prod(self.shape) - 1 + return np.prod(self.shape) - 1 - def extract(self, searchsubs): + def extract(self, searchsubs: np.ndarray) -> np.ndarray: """ Extract value for a sptensor. Parameters ---------- - searchsubs: :class:`numpy.ndarray` subscripts to find in sptensor - - Returns - ------- - :class:`numpy.ndarray` + searchsubs: subscripts to find in sptensor See Also -------- @@ -419,14 +539,14 @@ def extract(self, searchsubs): invalid = (searchsubs < 0) | (searchsubs >= np.array(self.shape)) badloc = np.where(np.sum(invalid, axis=1) > 0) if badloc[0].size > 0: - print('The following subscripts are invalid: \n') + error_msg = "The following subscripts are invalid: \n" badsubs = searchsubs[badloc, :] for i in np.arange(0, badloc[0].size): - print('\tsubscript = {}) \n'.format(tt_intvec2str(badsubs[i, :]))) - assert False, 'Invalid subscripts' + error_msg += f"\tsubscript = {tt_intvec2str(badsubs[i, :])} \n" + assert False, f"{error_msg}" "Invalid subscripts" # Set the default answer to zero - a = np.zeros(shape=(p, 1)) + a = np.zeros(shape=(p, 1), dtype=self.vals.dtype) # Find which indices already exist and their locations loc = ttb.tt_ismember_rows(searchsubs, self.subs) @@ -435,22 +555,20 @@ def extract(self, searchsubs): a[nzsubs] = self.vals[loc[nzsubs]] return a - def find(self): + def find(self) -> Tuple[np.ndarray, np.ndarray]: """ FIND Find subscripts of nonzero elements in a sparse tensor. Returns ------- - subs: :class:`numpy.ndarray` - vals: :class:`numpy.ndarray` + subs: Subscripts of nonzero elements + vals: Values at corresponding subscripts """ return self.subs, self.vals - def full(self): + def full(self) -> ttb.tensor: """ FULL Convert a sparse tensor to a (dense) tensor. - - :return: tensor """ # Handle the completely empty (no shape) case if len(self.shape) == 0: @@ -469,29 +587,25 @@ def full(self): B[idx.astype(int)] = self.vals.transpose()[0] return B - def innerprod(self, other): + def innerprod( + self, other: Union[sptensor, ttb.tensor, ttb.ktensor, ttb.ttensor] + ) -> float: """ Efficient inner product with a sparse tensor Parameters ---------- - other: :class:`pyttb.tensor`, :class:`pyttb.sptensor`, :class:`pyttb.ktensor`, - :class:`pyttb.ttensor` - - Returns - ------- - float + other: Other tensor to take innerproduct with """ # If all entries are zero innerproduct must be 0 if self.nnz == 0: return 0 if isinstance(other, ttb.sptensor): - if self.shape != other.shape: assert False, "Sptensors must be same shape for innerproduct" - if other.nnz == 0: #other sptensor is all zeros + if other.nnz == 0: # other sptensor is all zeros return 0 if self.nnz < other.nnz: @@ -502,83 +616,91 @@ def innerprod(self, other): valsSelf = self.extract(subsOther) return valsOther.transpose().dot(valsSelf) - elif isinstance(other, ttb.tensor): + if isinstance(other, ttb.tensor): if self.shape != other.shape: assert False, "Sptensor and tensor must be same shape for innerproduct" [subsSelf, valsSelf] = self.find() - valsOther = other[subsSelf, 'extract'] + valsOther = other[subsSelf.transpose(), "extract"] return valsOther.transpose().dot(valsSelf) - elif isinstance(other, (ttb.ktensor, ttb.ttensor)): # pragma: no cover + if isinstance(other, (ttb.ktensor, ttb.ttensor)): # pragma: no cover # Reverse arguments to call ktensor/ttensor implementation return other.innerprod(self) - else: - assert False, "Inner product between sptensor and that class not supported" + assert False, f"Inner product between sptensor and {type(other)} not supported" - def isequal(self, other): + def isequal(self, other: Union[sptensor, ttb.tensor]) -> bool: """ Exact equality for sptensors Parameters ---------- - other: :class:`pyttb.tensor`, :class:`pyttb.sptensor` - - Returns - ------- - bool: True if sptensors are identical, false otherwise + other: Other tensor to compare against """ if self.shape != other.shape: return False - elif isinstance(other, ttb.sptensor): - return (self-other).nnz == 0 - elif isinstance(other, ttb.tensor): + if isinstance(other, ttb.sptensor): + return (self - other).nnz == 0 + if isinstance(other, ttb.tensor): return other.isequal(self) - else: - return False + return False - def logical_and(self, B): + def logical_and(self, B: Union[float, sptensor, ttb.tensor]) -> sptensor: """ Logical and with self and another object - :param B: Scalar, tensor, or sptensor - :return: Indicator tensor + Parameters + ---------- + B: Other value to compare with + + Returns + ---------- + Indicator tensor """ # Case 1: One argument is a scalar if isinstance(B, (int, float)): if B == 0: C = sptensor.from_data(np.array([]), np.array([]), self.shape) else: - newvals = (self.vals == B) + newvals = self.vals == B C = sptensor.from_data(self.subs, newvals, self.shape) return C # Case 2: Argument is a tensor of some sort if isinstance(B, sptensor): # Check that the shapes match - if not (self.shape == B.shape): - assert False, 'Must be tensors of the same shape' + if not self.shape == B.shape: + assert False, "Must be tensors of the same shape" - def isLength2(x): + def is_length_2(x): return len(x) == 2 - C = sptensor.from_aggregator(np.vstack((self.subs, B.subs)), np.vstack((self.vals, B.vals)), self.shape, isLength2) + + C = sptensor.from_aggregator( + np.vstack((self.subs, B.subs)), + np.vstack((self.vals, B.vals)), + self.shape, + is_length_2, + ) return C if isinstance(B, ttb.tensor): - BB = sptensor.from_data(self.subs, B[self.subs, 'extract'][:, None], self.shape) + BB = sptensor.from_data( + self.subs, B[self.subs.transpose(), "extract"][:, None], self.shape + ) C = self.logical_and(BB) return C # Otherwise - assert False, 'The arguments must be two sptensors or an sptensor and a scalar.' + assert False, "The arguments must be two sptensors or an sptensor and a scalar." - def logical_not(self): + def logical_not(self) -> sptensor: """ Logical NOT for sptensors Returns ------- - :class:`pyttb.sptensor` Sparse tensor with all zero-values marked from original sparse tensor + Sparse tensor with all zero-values marked from original + sparse tensor """ allsubs = self.allsubs() subsIdx = ttb.tt_setdiff_rows(allsubs, self.subs) @@ -586,17 +708,26 @@ def logical_not(self): trueVector = np.ones(shape=(subs.shape[0], 1), dtype=bool) return sptensor.from_data(subs, trueVector, self.shape) - def logical_or(self, B): + @overload + def logical_or(self, B: Union[float, ttb.tensor]) -> ttb.tensor: + ... # pragma: no cover see coveragepy/issues/970 + + @overload + def logical_or(self, B: sptensor) -> sptensor: + ... # pragma: no cover see coveragepy/issues/970 + + def logical_or( + self, B: Union[float, ttb.tensor, sptensor] + ) -> Union[ttb.tensor, sptensor]: """ - Logical OR for sptensors + Logical OR for sptensor and another value Returns ------- - :class:'pyttb.sptensor` or :class:'pyttb.tensor` sptensor.logical_or() yields - tensor, sptensor.logical_or(sptensor) yields sptensor. + Indicator tensor """ # Case 1: Argument is a scalar or tensor - if isinstance(B, (float, int)) or isinstance(B, ttb.tensor): + if isinstance(B, (float, int, ttb.tensor)): return self.full().logical_or(B) # Case 2: Argument is an sptensor @@ -604,56 +735,77 @@ def logical_or(self, B): assert False, "Logical Or requires tensors of the same size" if isinstance(B, ttb.sptensor): - def isLengthGE1(x): + + def is_length_ge_1(x): return len(x) >= 1 - return sptensor.from_aggregator(np.vstack((self.subs, B.subs)), - np.ones((self.subs.shape[0] + B.subs.shape[0], 1)), self.shape, isLengthGE1) + + return sptensor.from_aggregator( + np.vstack((self.subs, B.subs)), + np.ones((self.subs.shape[0] + B.subs.shape[0], 1)), + self.shape, + is_length_ge_1, + ) assert False, "Sptensor Logical Or argument must be scalar or sptensor" - def logical_xor(self, other): + @overload + def logical_xor(self, other: Union[float, ttb.tensor]) -> ttb.tensor: + ... # pragma: no cover see coveragepy/issues/970 + + @overload + def logical_xor(self, other: sptensor) -> sptensor: + ... # pragma: no cover see coveragepy/issues/970 + + def logical_xor( + self, other: Union[float, ttb.tensor, sptensor] + ) -> Union[ttb.tensor, sptensor]: """ Logical XOR for sptensors Parameters ---------- + other: Other value to xor against Returns ------- - + Indicator tensor """ # Case 1: Argument is a scalar or dense tensor - if isinstance(other, (float, int)) or isinstance(other, ttb.tensor): + if isinstance(other, (float, int, ttb.tensor)): return self.full().logical_xor(other) # Case 2: Argument is an sptensor if isinstance(other, ttb.sptensor): - # Check shape consistency if self.shape != other.shape: assert False, "Logical XOR requires tensors of the same size" def length1(x): return len(x) == 1 + subs = np.vstack((self.subs, other.subs)) - return ttb.sptensor.from_aggregator(subs, np.ones((len(subs), 1)), self.shape, length1) + return ttb.sptensor.from_aggregator( + subs, np.ones((len(subs), 1)), self.shape, length1 + ) assert False, "The argument must be an sptensor, tensor or scalar" - def mask(self, W): + def mask(self, W: sptensor) -> np.ndarray: """ Extract values as specified by a mask tensor Parameters ---------- - W: :class:`pyttb.sptensor` + W: Mask tensor Returns ------- - :class:`Numpy.ndarray` + Extracted values """ # Error check - if len(W.shape) != len(self.shape) or np.any(np.array(W.shape) > np.array(self.shape)): + if len(W.shape) != len(self.shape) or np.any( + np.array(W.shape) > np.array(self.shape) + ): assert False, "Mask cannot be bigger than the data tensor" # Extract locations of nonzeros in W @@ -668,18 +820,18 @@ def mask(self, W): vals[idx] = self.vals[idx] return vals - def mttkrp(self, U, n): + def mttkrp(self, U: Union[ttb.ktensor, List[np.ndarray]], n: int) -> np.ndarray: """ Matricized tensor times Khatri-Rao product for sparse tensor. Parameters ---------- - U: array of matrices or ktensor - n: multiplies by all modes except n + U: Matrices to create the Khatri-Rao product + n: Mode to matricize sptensor in Returns ------- - :class:`numpy.ndarray` + Matrix product Examples -------- @@ -687,12 +839,12 @@ def mttkrp(self, U, n): >>> subs = np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2], [3, 3, 3]]) >>> vals = np.array([[0.5], [1.5], [2.5], [3.5]]) >>> shape = (4, 4, 4) - >>> sptensorInstance.from_data(subs, vals, shape) + >>> sptensorInstance = sptensor.from_data(subs, vals, shape) >>> sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 0) - [[0, 0, 0, 0], - [2, 2, 2, 2], - [2.5, 2.5, 2.5, 2.5], - [3.5, 3.5, 3.5, 3.5]] + array([[0. , 0. , 0. , 0. ], + [2. , 2. , 2. , 2. ], + [2.5, 2.5, 2.5, 2.5], + [3.5, 3.5, 3.5, 3.5]]) """ # In the sparse case, it is most efficient to do a series of TTV operations @@ -714,7 +866,7 @@ def mttkrp(self, U, n): assert False, "Second argument must be ktensor or array" if len(U) != N: - assert False, 'List is the wrong length' + assert False, "List is the wrong length" if n == 0: R = U[1].shape[1] @@ -731,66 +883,62 @@ def mttkrp(self, U, n): else: Z.append(np.array([])) # Perform ttv multiplication - V[:, r] = self.ttv(np.array(Z), -(n+1)).double() + V[:, r] = self.ttv(Z, exclude_dims=n).double() return V @property - def ndims(self): + def ndims(self) -> int: """ NDIMS Number of dimensions of a sparse tensor. - - Returns - ------- - int - Number of dimensions of Sptensor """ return len(self.shape) @property - def nnz(self): + def nnz(self) -> int: """ Number of nonzeros in sparse tensor - - Returns - ------- - nnz: int """ if self.subs.size == 0: return 0 - else: - return self.subs.shape[0] + return self.subs.shape[0] - def norm(self): + def norm(self) -> np.floating: """ - Compute the norm of a sparse tensor. - Returns - ------- - norm: float, Frobenius norm of Tensor + Compute the Frobenius norm of a sparse tensor. """ return np.linalg.norm(self.vals) - def nvecs(self, n, r, flipsign = True): + def nvecs(self, n: int, r: int, flipsign: bool = True) -> np.ndarray: """ Compute the leading mode-n vectors for a sparse tensor. Parameters ---------- - n: mode for tensor matricization - r: number of eigenvalues - flipsign: Make each column's largest element positive if true - - Returns - ------- - + n: Mode to unfold + r: Number of eigenvectors to compute + flipsign: Make each eigenvector's largest element positive """ old = np.setdiff1d(np.arange(self.ndims), n).astype(int) - tnt = self.reshape((np.prod(np.array(self.shape)[old]), 1), old).squeeze().spmatrix().transpose() + # tnt calculation is a workaround for missing sptenmat + mutatable_sptensor = ( + sptensor.from_tensor_type(self) + .reshape((np.prod(np.array(self.shape)[old]), 1), old) + .squeeze() + ) + if isinstance(mutatable_sptensor, (int, float, np.generic)): + raise ValueError( + "Cannot call nvecs on sptensor with only singleton dimensions" + ) + tnt = mutatable_sptensor.spmatrix().transpose() y = tnt.transpose().dot(tnt) if r < y.shape[0] - 1: _, v = scipy.sparse.linalg.eigs(y, r) else: - warnings.warn('Greater than or equal to sptensor.shape[n] - 1 eigenvectors requires cast to dense to solve') + logging.debug( + "Greater than or equal to sptensor.shape[n] - 1 eigenvectors requires" + " cast to dense to solve" + ) w, v = scipy.linalg.eig(y.toarray()) v = v[(-np.abs(w)).argsort()] v = v[:, :r] @@ -802,7 +950,7 @@ def nvecs(self, n, r, flipsign = True): v[:, i] *= -1 return v - def ones(self): + def ones(self) -> sptensor: """ Replace nonzero elements of sparse tensor with ones """ @@ -810,28 +958,34 @@ def ones(self): oneVals.fill(1) return ttb.sptensor.from_data(self.subs, oneVals, self.shape) - def permute(self, order): + def permute(self, order: np.ndarray) -> sptensor: """ Rearrange the dimensions of a sparse tensor Parameters ---------- - order: :class:`Numpy.ndarray` - - Returns - ------- - :class:`pyttb.sptensor` + order: Updated order of dimensions """ - #Error check - if self.ndims != order.size or np.any(np.sort(order) != np.arange(0, self.ndims)): + # Error check + if self.ndims != order.size or np.any( + np.sort(order) != np.arange(0, self.ndims) + ): assert False, "Invalid permutation order" # Do the permutation if not self.subs.size == 0: - return ttb.sptensor.from_data(self.subs[:, order], self.vals, tuple(np.array(self.shape)[order])) - return ttb.sptensor.from_data(self.subs, self.vals, tuple(np.array(self.shape)[order])) - - def reshape(self, new_shape, old_modes=None): + return ttb.sptensor.from_data( + self.subs[:, order], self.vals, tuple(np.array(self.shape)[order]) + ) + return ttb.sptensor.from_data( + self.subs, self.vals, tuple(np.array(self.shape)[order]) + ) + + def reshape( + self, + new_shape: Tuple[int, ...], + old_modes: Optional[Union[np.ndarray, int]] = None, + ) -> sptensor: """ Reshape specified modes of sparse tensor @@ -859,17 +1013,24 @@ def reshape(self, new_shape, old_modes=None): assert False, "Reshape must maintain tensor size" if self.subs.size == 0: - return ttb.sptensor.from_data(np.array([]), np.array([]), tuple(np.concatenate((keep_shape, new_shape)))) + return ttb.sptensor.from_data( + np.array([]), + np.array([]), + tuple(np.concatenate((keep_shape, new_shape))), + ) + if np.isscalar(old_shape): + old_shape = (old_shape,) + inds = ttb.tt_sub2ind(old_shape, self.subs[:, old_modes][:, None]) else: - if np.isscalar(old_shape): - old_shape = (old_shape,) - inds = ttb.tt_sub2ind(old_shape, self.subs[:, old_modes][:, None]) - else: - inds = ttb.tt_sub2ind(old_shape, self.subs[:, old_modes]) - new_subs = ttb.tt_ind2sub(new_shape, inds) - return ttb.sptensor.from_data(np.concatenate((self.subs[:, keep_modes], new_subs), axis=1), self.vals, tuple(np.concatenate((keep_shape, new_shape)))) + inds = ttb.tt_sub2ind(old_shape, self.subs[:, old_modes]) + new_subs = ttb.tt_ind2sub(new_shape, inds) + return ttb.sptensor.from_data( + np.concatenate((self.subs[:, keep_modes], new_subs), axis=1), + self.vals, + tuple(np.concatenate((keep_shape, new_shape))), + ) - def scale(self, factor, dims): + def scale(self, factor: np.ndarray, dims: Union[float, np.ndarray]) -> sptensor: """ Scale along specified dimensions for sparse tensors @@ -884,43 +1045,50 @@ def scale(self, factor, dims): """ if isinstance(dims, (float, int)): dims = np.array([dims]) - dims = ttb.tt_dimscheck(dims, self.ndims) + dims, _ = ttb.tt_dimscheck(self.ndims, dims=dims) if isinstance(factor, ttb.tensor): shapeArray = np.array(self.shape) - if np.any(factor.shape != shapeArray[dims]): + if not np.array_equal(factor.shape, shapeArray[dims]): assert False, "Size mismatch in scale" - return ttb.sptensor.from_data(self.subs, self.vals*factor[self.subs[:, dims[0]], 'extract'][:, None], self.shape) - elif isinstance(factor, ttb.sptensor): + return ttb.sptensor.from_data( + self.subs, + self.vals * factor[self.subs[:, dims].transpose(), "extract"][:, None], + self.shape, + ) + if isinstance(factor, ttb.sptensor): shapeArray = np.array(self.shape) - if np.any(factor.shape != shapeArray[dims]): + if not np.array_equal(factor.shape, shapeArray[dims]): assert False, "Size mismatch in scale" - return ttb.sptensor.from_data(self.subs, self.vals * factor.extract(self.subs[:, dims[0]]), self.shape) - elif isinstance(factor, np.ndarray): + return ttb.sptensor.from_data( + self.subs, self.vals * factor.extract(self.subs[:, dims]), self.shape + ) + if isinstance(factor, np.ndarray): shapeArray = np.array(self.shape) if factor.shape[0] != shapeArray[dims]: assert False, "Size mismatch in scale" - return ttb.sptensor.from_data(self.subs, self.vals * factor[self.subs[:, dims[0]].transpose()[0]], self.shape) - else: - assert False, "Invalid scaling factor" + return ttb.sptensor.from_data( + self.subs, + self.vals * factor[self.subs[:, dims].transpose()[0]], + self.shape, + ) + assert False, "Invalid scaling factor" - def spmatrix(self): + def spmatrix(self) -> sparse.coo_matrix: """ - Converts a two-way sparse tensor to a sparse matrix in scipy.sparse.coo_matrix format - - Returns - ------- - :class:`scipy.sparse.coo_matrix` + Converts a two-way sparse tensor to a sparse matrix in + scipy.sparse.coo_matrix format """ if self.ndims != 2: - assert False, 'Sparse tensor must be two dimensional' + assert False, "Sparse tensor must be two dimensional" if self.subs.size == 0: return sparse.coo_matrix(self.shape) - else: - return sparse.coo_matrix((self.vals.transpose()[0], self.subs.transpose()), self.shape) + return sparse.coo_matrix( + (self.vals.transpose()[0], self.subs.transpose()), self.shape + ) - def squeeze(self): + def squeeze(self) -> Union[sptensor, float]: """ Remove singleton dimensions from a sparse tensor @@ -933,18 +1101,15 @@ def squeeze(self): # No singleton dimensions if np.all(shapeArray > 1): return ttb.sptensor.from_tensor_type(self) - else: - idx = np.where(shapeArray > 1)[0] - if idx.size == 0: - return self.vals[0].copy() - else: - siz = tuple(shapeArray[idx]) - if self.vals.size == 0: - return ttb.sptensor.from_data(np.array([]), np.array([]), siz) - else: - return ttb.sptensor.from_data(self.subs[:, idx], self.vals, siz) + idx = np.where(shapeArray > 1)[0] + if idx.size == 0: + return self.vals[0].copy() + siz = tuple(shapeArray[idx]) + if self.vals.size == 0: + return ttb.sptensor.from_data(np.array([]), np.array([]), siz) + return ttb.sptensor.from_data(self.subs[:, idx], self.vals, siz) - def subdims(self, region): + def subdims(self, region: Sequence[Union[int, np.ndarray, slice]]) -> np.ndarray: """ SUBDIMS Compute the locations of subscripts within a subdimension. @@ -962,17 +1127,16 @@ def subdims(self, region): -------- >>> subs = np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2], [3, 3, 3]]) >>> vals = np.array([[0.5], [1.5], [2.5], [3.5]]) - >>> shape = np.array([4, 4, 4]) + >>> shape = (4, 4, 4) >>> sp = sptensor.from_data(subs,vals,shape) - >>> region = np.ndarray([1, [1], [1,3]]) + >>> region = [np.array([1]), np.array([1]), np.array([1,3])] >>> loc = sp.subdims(region) >>> print(loc) - loc = [0,1] + [0 1] >>> region = (1, 1, slice(None, None, None)) >>> loc = sp.subdims(region) >>> print(loc) - loc = [0,1] - + [0 1] """ if len(region) != self.ndims: assert False, "Number of subdimensions must equal number of dimensions" @@ -980,7 +1144,8 @@ def subdims(self, region): # Error check that range is valid # TODO I think only accepting numeric arrays fixes this - # TODO we use this empty check a lot, do we want a boolean we store in the class for this? + # TODO we use this empty check a lot, do we want a boolean we store in the + # class for this? if self.subs.size == 0: loc = np.array([]) return loc @@ -992,46 +1157,58 @@ def subdims(self, region): loc = np.arange(0, len(self.subs)) for i in range(0, self.ndims): - if not isinstance(region[i], slice): - # Find subscripts that match in dimension i - tf = np.isin(self.subs[loc, i], region[i]) - - # Pare down the list of indices - loc = loc[tf] - else: + # TODO: Consider cleaner typing coercion + # Find subscripts that match in dimension i + if isinstance(region[i], (int, np.generic)): + tf = np.isin(self.subs[loc, i], cast(int, region[i])) + elif isinstance(region[i], (np.ndarray, list)): + tf = np.isin(self.subs[loc, i], cast(np.ndarray, region[i])) + elif isinstance(region[i], slice): sliceRegion = range(0, self.shape[i])[region[i]] - tf = np.isin(self.subs[loc, i], sliceRegion) + else: + raise ValueError( + f"Unexpected type in region sequence. " + f"At index: {i} got {region[i]} with type {type(region[i])}" + ) + + # Pare down the list of indices + loc = loc[tf] - # Pare down the list of indices - loc = loc[tf] return loc - def ttv(self, vector, dims=None): + # pylint: disable=too-many-branches, too-many-locals + def ttv( + self, + vector: Union[np.ndarray, List[np.ndarray]], + dims: Optional[Union[int, np.ndarray]] = None, + exclude_dims: Optional[Union[int, np.ndarray]] = None, + ) -> Union[sptensor, ttb.tensor]: """ Sparse tensor times vector Parameters ---------- - vector - dims - - Returns - ------- - + vector: Vector(s) to multiply against + dims: Dimensions to multiply with vector(s) + exclude_dims: Use all dimensions but these """ - if dims is None: + if dims is None and exclude_dims is None: dims = np.array([]) elif isinstance(dims, (float, int)): dims = np.array([dims]) - # Check that vector is a list of vectors, if not place single vector as element in list - if len(vector.shape) == 1 and isinstance(vector[0], (int, float, np.int_, np.float_)): - return self.ttv(np.array([vector]), dims) + if isinstance(exclude_dims, (float, int)): + exclude_dims = np.array([exclude_dims]) + + # Check that vector is a list of vectors, + # if not place single vector as element in list + if len(vector) > 0 and isinstance(vector[0], (int, float, np.int_, np.float_)): + return self.ttv(np.array([vector]), dims, exclude_dims) # Get sorted dims and index for multiplicands - dims, vidx = ttb.tt_dimscheck(dims, self.ndims, vector.shape[0]) + dims, vidx = ttb.tt_dimscheck(self.ndims, len(vector), dims, exclude_dims) remdims = np.setdiff1d(np.arange(0, self.ndims), dims).astype(int) # Check that each multiplicand is the right size. @@ -1045,9 +1222,9 @@ def ttv(self, vector, dims=None): if subs.size == 0: # No non-zeros in tensor newsubs = np.array([], dtype=int) else: - for n in range(len(dims)): - idx = subs[:, dims[n]] # extract indices for dimension n - w = vector[vidx[n]] # extract the nth vector + for n, dims_n in enumerate(dims): + idx = subs[:, dims_n] # extract indices for dimension n + w = vector[vidx[n]] # extract the nth vector bigw = w[idx][:, None] # stretch out the vector newvals = newvals * bigw newsubs = subs[:, remdims].astype(int) @@ -1063,21 +1240,25 @@ def ttv(self, vector, dims=None): if remdims.size == 1: if newvals.size == 0: return ttb.sptensor.from_data(np.array([]), np.array([]), tuple(newsiz)) - c = accumarray(newsubs.transpose()[0], newvals.transpose()[0], size=newsiz[0]) + c = accumarray( + newsubs.transpose()[0], newvals.transpose()[0], size=newsiz[0] + ) if np.count_nonzero(c) <= 0.5 * newsiz: - return ttb.sptensor.from_aggregator(np.arange(0, newsiz)[:, None], c, tuple(newsiz)) - else: - return ttb.tensor.from_data(c, tuple(newsiz)) + return ttb.sptensor.from_aggregator( + np.arange(0, newsiz)[:, None], c, tuple(newsiz) + ) + return ttb.tensor.from_data(c, tuple(newsiz)) # Case 2: Result is a multiway array c = ttb.sptensor.from_aggregator(newsubs, newvals, tuple(newsiz)) # Convert to a dense tensor if more than 50% of the result is nonzero. - if c.nnz > 0.5*np.prod(newsiz): + if c.nnz > 0.5 * np.prod(newsiz): c = ttb.tensor.from_tensor_type(c) return c + # pylint: disable=too-many-branches def __getitem__(self, item): """ Subscripted reference for a sparse tensor. @@ -1112,18 +1293,21 @@ def __getitem__(self, item): Examples -------- - >>> X = sptensor(np.array([[4,4,4],[2,2,1],[2,3,2]]),np.array([[3],[5],[1]]),(4,4,4)) - >>> X[1,2,1] #<-- returns zero - >>> X[4,4,4] #<-- returns 3 - >>> X[3:4,:,:] #<-- returns 1 x 4 x 4 sptensor - X = sptensor([6;16;26],[1;1;1],30); - X([1:6]') <-- extracts a subtensor - X([1:6]','extract') %<-- extracts a vector of 6 elements - """ - #TODO IndexError for value outside of indices + >>> subs = np.array([[3,3,3],[1,1,0],[1,2,1]]) + >>> vals = np.array([3,5,1]) + >>> shape = (4,4,4) + >>> X = sptensor.from_data(subs,vals,shape) + >>> _ = X[0,1,0] #<-- returns zero + >>> _ = X[3,3,3] #<-- returns 3 + >>> _ = X[2:3,:,:] #<-- returns 1 x 4 x 4 sptensor + """ + # This does not work like MATLAB TTB; you must call sptensor.extract to get + # this functionality: X([1:6]','extract') %<-- extracts a vector of 6 elements + + # TODO IndexError for value outside of indices # TODO Key error if item not in container # *** CASE 1: Rectangular Subtensor *** - if isinstance(item, tuple) and len(item) == self.ndims and item[len(item)-1] != 'extract': + if isinstance(item, tuple) and len(item) == self.ndims: # Extract the subdimensions to be extracted from self region = item @@ -1138,17 +1322,17 @@ def __getitem__(self, item): [subs, shape] = ttb.tt_renumber(subs, self.shape, region) # Determine the subscripts - newsiz = [] # (future) new size - kpdims = [] # dimensions to keep - rmdims = [] # dimensions to remove + newsiz = [] # (future) new size + kpdims = [] # dimensions to keep + rmdims = [] # dimensions to remove # Determine the new size and what dimensions to keep - for i in range(0, len(region)): - if isinstance(region[i], slice): + for i, a_region in enumerate(region): + if isinstance(a_region, slice): newsiz.append(self.shape[i]) kpdims.append(i) - elif not isinstance(region[i], (int, float)): - newsiz.append(np.prod(region[i])) + elif not isinstance(a_region, (int, float)): + newsiz.append(np.prod(a_region)) kpdims.append(i) else: rmdims.append(i) @@ -1160,7 +1344,7 @@ def __getitem__(self, item): # Return a single double value for a zero-order sub-tensor if newsiz.size == 0: if vals.size == 0: - a = 0 + a = np.array([[0]]) else: a = vals return a @@ -1168,30 +1352,39 @@ def __getitem__(self, item): # Assemble the resulting sparse tensor # TODO clean up tuple array cast below if subs.size == 0: - a = sptensor.from_data(np.array([]), np.array([]), tuple(np.array(shape)[kpdims])) + a = sptensor.from_data( + np.array([]), np.array([]), tuple(np.array(shape)[kpdims]) + ) else: - a = sptensor.from_data(subs[:, kpdims], vals, tuple(np.array(shape)[kpdims])) + a = sptensor.from_data( + subs[:, kpdims], vals, tuple(np.array(shape)[kpdims]) + ) return a # TODO understand how/ why this is used, logic doesn't translate immediately # Case 2: EXTRACT # *** CASE 2a: Subscript indexing *** - if len(item) > 1 and isinstance(item[-1], str) and item[-1] == 'extract': - # extract array of subscripts - srchsubs = np.array(item[0]) - item = item[0] - - # *** CASE 2b: Linear indexing *** + if ( + isinstance(item, np.ndarray) + and len(item.shape) == 2 + and item.shape[0] == self.ndims + ): + srchsubs = np.array(item.transpose()) + + # *** CASE 2b: Linear indexing *** else: # Error checking - if not isinstance(item, list) and not isinstance(item, np.ndarray): - assert False, 'Invalid indexing' + if isinstance(item, list): + idx = np.array(item) + elif isinstance(item, np.ndarray): + idx = item + else: + assert False, "Invalid indexing" - idx = item if len(idx.shape) != 1: - assert False, 'Expecting a row index' - #idx=np.expand_dims(idx, axis=1) + assert False, "Expecting a row index" + # extract linear indices and convert to subscripts srchsubs = tt_ind2sub(self.shape, idx) @@ -1245,264 +1438,314 @@ def __setitem__(self, key, value): # TODO IndexError for value outside of indices # TODO Key error if item not in container # If empty sptensor and assignment is empty list or empty nparray - if self.vals.size == 0 and ((isinstance(value, np.ndarray) and value.size == 0) or (isinstance(value, list) and value == [])): - return + if self.vals.size == 0 and ( + (isinstance(value, np.ndarray) and value.size == 0) + or (isinstance(value, list) and value == []) + ): + return None # Determine if we are doing a substenor or list of subscripts objectType = tt_assignment_type(self, key, value) # Case 1: Replace a sub-tensor if objectType == "subtensor": - # Case I(a): RHS is another sparse tensor - if isinstance(value, ttb.sptensor): - # First, Resize the tensor and check the size match with the tensor that's being inserted. - m = 0 - newsz = [] - for n in range(0, len(key)): - if isinstance(key[n], slice): - if self.ndims <= n: - if key[n].stop is None: - newsz.append(value.shape[m]) - else: - newsz.append(key[n].stop) - else: - if key[n].stop is None: - newsz.append(max([self.shape[n], value.shape[m]])) - else: - newsz.append(max([self.shape[n], key[n].stop])) - m = m + 1 - elif isinstance(key[n], (float, int)): - if self.ndims <= n: - newsz.append(key[n]+1) + return self._set_subtensor(key, value) + # Case 2: Subscripts + if objectType == "subscripts": + return self._set_subscripts(key, value) + raise ValueError("Unknown assignment type") # pragma: no cover + + def _set_subscripts(self, key, value): + # Case II: Replacing values at specific indices + newsubs = key + if len(newsubs.shape) == 1: + newsubs = np.expand_dims(newsubs, axis=0) + tt_subscheck(newsubs, nargout=False) + + # Error check on subscripts + if newsubs.shape[0] < self.ndims: + assert False, "Invalid subscripts" + + # Check for expanding the order + if newsubs.shape[0] > self.ndims: + newshape = list(self.shape) + # TODO no need for loop, just add correct size + for _ in range(self.ndims, newsubs.shape[0]): + newshape.append(1) + if self.subs.size > 0: + self.subs = np.concatenate( + ( + self.subs, + np.ones( + (self.shape[0], newsubs.shape[0] - self.ndims), + dtype=int, + ), + ), + axis=1, + ) + self.shape = tuple(newshape) + + # Copy rhs to newvals + newvals = value + + if isinstance(newvals, (float, int)): + newvals = np.expand_dims([newvals], axis=1) + + # Error check the rhs is a column vector. We don't bother to handle any + # other type with sparse tensors + tt_valscheck(newvals, nargout=False) + + # Determine number of nonzeros being inserted. + # (This is determined by number of subscripts) + newnnz = newsubs.shape[1] + + # Error check on size of newvals + if newvals.size == 1: + # Special case where newvals is a single element to be assigned + # to multiple LHS. Fix to correct size + newvals = newvals * np.ones((newnnz, 1)) + + elif newvals.shape[0] != newnnz: + # Sizes don't match + assert False, "Number of subscripts and number of values do not match!" + + # Remove duplicates and print warning if any duplicates were removed + newsubs, idx = np.unique(newsubs.transpose(), axis=0, return_index=True) + if newsubs.shape[0] != newnnz: + warnings.warn("Duplicate assignments discarded") + + newvals = newvals[idx] + + # Find which subscripts already exist and their locations + tf = ttb.tt_ismember_rows(newsubs, self.subs) + loc = np.where(tf >= 0)[0].astype(int) + + # Split into three groups for processing: + # + # Group A: Elements that already exist and need to be changed + # Group B: Elements that already exist and need to be removed + # Group C: Elements that do not exist and need to be added + # + # Note that we are ignoring any new zero elements, because + # those obviously do not need to be added. Also, it's + # important to process Group A before Group B because the + # processing of Group B may change the locations of the + # remaining elements. + + # TF+1 for logical consideration because 0 is valid index + # and -1 is our null flag + idxa = np.logical_and(tf + 1, newvals)[0] + idxb = np.logical_and(tf + 1, np.logical_not(newvals))[0] + idxc = np.logical_and(np.logical_not(tf + 1), newvals)[0] + + # Process Group A: Changing values + if np.sum(idxa) > 0: + self.vals[tf[idxa]] = newvals[idxa] + # Proces Group B: Removing Values + if np.sum(idxb) > 0: + removesubs = loc[idxb] + keepsubs = np.setdiff1d(range(0, self.nnz), removesubs) + self.subs = self.subs[keepsubs, :] + self.vals = self.vals[keepsubs] + # Process Group C: Adding new, nonzero values + if np.sum(idxc) > 0: + if self.subs.size > 0: + self.subs = np.vstack((self.subs, newsubs[idxc, :])) + self.vals = np.vstack((self.vals, newvals[idxc])) + else: + self.subs = newsubs[idxc, :] + self.vals = newvals[idxc] + + # Resize the tensor + newshape = [] + for n, dim in enumerate(self.shape): + smax = max(newsubs[:, n] + 1) + newshape.append(max(dim, smax)) + self.shape = tuple(newshape) + + # pylint:disable=too-many-statements + def _set_subtensor(self, key, value): + # Case I(a): RHS is another sparse tensor + if isinstance(value, ttb.sptensor): + # First, Resize the tensor and check the size match with the tensor + # that's being inserted. + m = 0 + newsz = [] + for n, key_n in enumerate(key): + if isinstance(key_n, slice): + if self.ndims <= n: + if key_n.stop is None: + newsz.append(value.shape[m]) else: - newsz.append(max([self.shape[n], key[n]+1])) + newsz.append(key_n.stop) else: - if len(key[n]) != value.shape[m]: - assert False, 'RHS does not match range size' - if self.ndims <= n: - newsz.append(max(key[n])+1) + if key_n.stop is None: + newsz.append(max([self.shape[n], value.shape[m]])) else: - newsz.append(max([self.shape[n], max(key[n])+1])) - self.shape = tuple(newsz) - - # Expand subs array if there are new modes, i.e., if the order - # has increased. - if self.subs.size > 0 and (len(self.shape) > self.subs.shape[1]): - self.subs = np.append(self.subs, np.zeros(shape=(self.subs.shape[0], len(self.shape)-self.subs.shape[1])), axis=1) - # Delete what currently occupies the specified range - rmloc = self.subdims(key) - kploc = np.setdiff1d(range(0, self.nnz), rmloc) - # TODO: evaluate solution for assigning value to empty sptensor - if len(self.subs.shape) > 1: - newsubs = self.subs[kploc.astype(int), :] - else: - newsubs = self.subs[kploc.astype(int)] - newvals = self.vals[kploc.astype(int)] - - # Renumber the subscripts - addsubs = ttb.tt_irenumber(value, self.shape, key) - if newsubs.size > 0 and addsubs.size > 0: - self.subs = np.vstack((newsubs, addsubs)) - self.vals = np.vstack((newvals, value.vals)) - elif newsubs.size > 0: - self.subs = newsubs - self.vals = newvals - elif addsubs.size > 0: - self.subs = addsubs - self.vals = value.vals - else: - self.subs = np.array([], ndmin=2, dtype=int) - self.vals = np.array([], ndmin=2) - - return - # Case I(b): Value is zero or scalar - - # First, resize the tensor, determine new size of existing modes - newsz = [] - for n in range(0, self.ndims): - if isinstance(key[n], slice): - if key[n].stop is None: - newsz.append(self.shape[n]) + newsz.append(max([self.shape[n], key_n.stop])) + m = m + 1 + elif isinstance(key_n, (float, int)): + if self.ndims <= n: + newsz.append(key_n + 1) else: - newsz.append(max([self.shape[n], key[n].stop])) + newsz.append(max([self.shape[n], key_n + 1])) else: - newsz.append(max([self.shape[n], key[n] + 1])) - - # Determine size of new modes, if any - for n in range(self.ndims, len(key)): - if isinstance(key[n], slice): - if key[n].stop is None: - assert False, "Must have well defined slice when expanding sptensor shape with setitem" + if len(key_n) != value.shape[m]: + assert False, "RHS does not match range size" + if self.ndims <= n: + newsz.append(max(key_n) + 1) else: - newsz.append(key[n].stop) - elif isinstance(key[n], np.ndarray): - newsz.append(max(key[n]) + 1) - else: - newsz.append(key[n] + 1) + newsz.append(max([self.shape[n], max(key_n) + 1])) self.shape = tuple(newsz) - # Expand subs array if there are new modes, i.e.m if the order has increasesd - if self.subs.size > 0 and len(self.shape) > self.subs.shape[1]: - self.subs = np.append(self.subs, - np.zeros(shape=(self.subs.shape[0], len(self.shape) - self.subs.shape[1])), axis=1) - - # Case I(b)i: Zero right-hand side - if isinstance(value, (int, float)) and value == 0: - # Delete what currently occupies the specified range - rmloc = self.subdims(key) - kploc = np.setdiff1d(range(0, self.nnz), rmloc).astype(int) - self.subs = self.subs[kploc, :] - self.vals = self.vals[kploc] - return - - # Case I(b)ii: Scalar Right Hand Side - if isinstance(value, (int, float)): - - # Determine number of dimensions (may be larger than current number) - N = len(key) - keyCopy = np.array(key) - # Figure out how many indices are in each dimension - nssubs = np.zeros((N, 1)) - for n in range(0, N): - if isinstance(key[n], slice): - # Generate slice explicitly to determine its length - keyCopy[n] = np.arange(0, self.shape[n])[key[n]] - indicesInN = len(keyCopy[n]) - else: - indicesInN = 1 - nssubs[n] = indicesInN - - # Preallocate (discover any memory issues here!) - addsubs = np.zeros((np.prod(nssubs).astype(int), N)) - - # Generate appropriately sized ones vectors - o = [] - for n in range(N): - o.append(np.ones((int(nssubs[n]), 1))) + # Expand subs array if there are new modes, i.e., if the order + # has increased. + if self.subs.size > 0 and (len(self.shape) > self.subs.shape[1]): + self.subs = np.append( + self.subs, + np.zeros( + shape=( + self.subs.shape[0], + len(self.shape) - self.subs.shape[1], + ) + ), + axis=1, + ) + # Delete what currently occupies the specified range + rmloc = self.subdims(key) + kploc = np.setdiff1d(range(0, self.nnz), rmloc) + # TODO: evaluate solution for assigning value to empty sptensor + if len(self.subs.shape) > 1: + newsubs = self.subs[kploc.astype(int), :] + else: + newsubs = self.subs[kploc.astype(int)] + newvals = self.vals[kploc.astype(int)] + + # Renumber the subscripts + addsubs = ttb.tt_irenumber(value, self.shape, key) + if newsubs.size > 0 and addsubs.size > 0: + self.subs = np.vstack((newsubs, addsubs)) + self.vals = np.vstack((newvals, value.vals)) + elif newsubs.size > 0: + self.subs = newsubs + self.vals = newvals + elif addsubs.size > 0: + self.subs = addsubs + self.vals = value.vals + else: + self.subs = np.array([], ndmin=2, dtype=int) + self.vals = np.array([], ndmin=2) - # Generate each column of the subscripts in turn - for n in range(N): - i = o.copy() - if not np.isscalar(keyCopy[n]): - i[n] = np.array(keyCopy[n])[:, None] - else: - i[n] = np.array(keyCopy[n], ndmin=2) - addsubs[:, n] = ttb.khatrirao(i).transpose()[:] + return + # Case I(b): Value is zero or scalar - if self.subs.size > 0: - # Replace existing values - loc = ttb.tt_intersect_rows(self.subs, addsubs) - self.vals[loc] = value - # pare down list of subscripts to add - addsubs = addsubs[ttb.tt_setdiff_rows(addsubs, self.subs)] - - # If there are things to insert then insert them - if addsubs.size > 0: - if self.subs.size > 0: - self.subs = np.vstack((self.subs, addsubs.astype(int))) - self.vals = np.vstack((self.vals, value*np.ones((addsubs.shape[0], 1)))) - else: - self.subs = addsubs.astype(int) - self.vals = value*np.ones(addsubs.shape[0]) - return + # First, resize the tensor, determine new size of existing modes + newsz = [] + for n in range(0, self.ndims): + if isinstance(key[n], slice): + if key[n].stop is None: + newsz.append(self.shape[n]) + else: + newsz.append(max([self.shape[n], key[n].stop])) + elif isinstance(key[n], Iterable): + newsz.append(max([self.shape[n], max(key[n]) + 1])) + else: + newsz.append(max([self.shape[n], key[n] + 1])) + + # Determine size of new modes, if any + for n in range(self.ndims, len(key)): + if isinstance(key[n], slice): + if key[n].stop is None: + assert False, ( + "Must have well defined slice when expanding sptensor " + "shape with setitem" + ) + else: + newsz.append(key[n].stop) + elif isinstance(key[n], (np.ndarray, Iterable)): + newsz.append(max(key[n]) + 1) + else: + newsz.append(key[n] + 1) + self.shape = tuple(newsz) + + # Expand subs array if there are new modes, i.e. if the order has increased + if self.subs.size > 0 and len(self.shape) > self.subs.shape[1]: + self.subs = np.append( + self.subs, + np.zeros( + shape=(self.subs.shape[0], len(self.shape) - self.subs.shape[1]), + dtype=int, + ), + axis=1, + ) + + # Case I(b)i: Zero right-hand side + if isinstance(value, (int, float)) and value == 0: + # Delete what currently occupies the specified range + rmloc = self.subdims(key) + kploc = np.setdiff1d(range(0, self.nnz), rmloc).astype(int) + self.subs = self.subs[kploc, :] + self.vals = self.vals[kploc] + return - assert False, "Invalid assignment value" + # Case I(b)ii: Scalar Right Hand Side + if isinstance(value, (int, float)): + # Determine number of dimensions (may be larger than current number) + N = len(key) + keyCopy = [None] * N + # Figure out how many indices are in each dimension + nssubs = np.zeros((N, 1)) + for n in range(0, N): + if isinstance(key[n], slice): + # Generate slice explicitly to determine its length + keyCopy[n] = np.arange(0, self.shape[n])[key[n]] + indicesInN = len(keyCopy[n]) + elif isinstance(key[n], Iterable): + keyCopy[n] = key[n] + indicesInN = len(key[n]) + else: + keyCopy[n] = key[n] + indicesInN = 1 + nssubs[n] = indicesInN + + # Preallocate (discover any memory issues here!) + addsubs = np.zeros((np.prod(nssubs).astype(int), N)) + + # Generate appropriately sized ones vectors + o = [] + for n in range(N): + o.append(np.ones((int(nssubs[n]), 1))) + + # Generate each column of the subscripts in turn + for n in range(N): + i = o.copy() + if not np.isscalar(keyCopy[n]): + i[n] = np.array(keyCopy[n])[:, None] + else: + i[n] = np.array(keyCopy[n], ndmin=2) + addsubs[:, n] = ttb.khatrirao(i).transpose()[:] - # Case 2: Subscripts - elif objectType == "subscripts": - # Case II: Replacing values at specific indices - - newsubs = key - if len(newsubs.shape) == 1: - newsubs = np.expand_dims(newsubs, axis=0) - tt_subscheck(newsubs, nargout=False) - - # Error check on subscripts - if newsubs.shape[1] < self.ndims: - assert False, "Invalid subscripts" - - # Check for expanding the order - if newsubs.shape[1] > self.ndims: - newshape = list(self.shape) - for i in range(self.ndims, newsubs.shape[1]): - newshape.append(1) + if self.subs.size > 0: + # Replace existing values + loc = ttb.tt_intersect_rows(self.subs, addsubs) + self.vals[loc] = value + # pare down list of subscripts to add + addsubs = addsubs[ttb.tt_setdiff_rows(addsubs, self.subs)] + + # If there are things to insert then insert them + if addsubs.size > 0: if self.subs.size > 0: - self.subs = np.concatenate((self.subs, np.ones((self.shape[0], newsubs.shape[1]-self.ndims), dtype=int)), axis=1) - self.shape = tuple(newshape) - - # Copy rhs to newvals - newvals = value - - if isinstance(newvals, (float, int)): - newvals = np.expand_dims([newvals], axis=1) - - # Error check the rhs is a column vector. We don't bother to handle any other type with sparse tensors - tt_valscheck(newvals, nargout=False) - - # Determine number of nonzeros being inserted. (This is determined by number of subscripts) - newnnz = newsubs.shape[0] - - # Error check on size of newvals - if newvals.size == 1: - # Special case where newvals is a single element to be assigned to multiple LHS. Fix to correct size - newvals = newvals * np.ones((newnnz, 1)) - - elif newvals.shape[0] != newnnz: - - # Sizes don't match - assert False, "Number of subscripts and number of values do not match!" - - # Remove duplicates and print warning if any duplicates were removed - newsubs, idx = np.unique(newsubs, axis=0, return_index=True) - if newsubs.shape[0] != newnnz: - warnings.warn('Duplicate assignments discarded') - - newvals = newvals[idx] - - # Find which subscripts already exist and their locations - tf = ttb.tt_ismember_rows(newsubs, self.subs) - loc = np.where(tf >= 0)[0].astype(int) - - # Split into three groups for processing: - # - # Group A: Elements that already exist and need to be changed - # Group B: Elements that already exist and need to be removed - # Group C: Elements that do not exist and need to be added - # - # Note that we are ignoring any new zero elements, because - # those obviously do not need to be added. Also, it's - # important to process Group A before Group B because the - # processing of Group B may change the locations of the - # remaining elements. - - # TF+1 for logical consideration because 0 is valid index and -1 is our null flag - idxa = np.logical_and(tf+1, newvals)[0] - idxb = np.logical_and(tf+1, np.logical_not(newvals))[0] - idxc = np.logical_and(np.logical_not(tf+1), newvals)[0] - - # Process Group A: Changing values - if np.sum(idxa) > 0: - self.vals[tf[idxa]] = newvals[idxa] - # Proces Group B: Removing Values - if np.sum(idxb) > 0: - removesubs = loc[idxb] - keepsubs = np.setdiff1d(range(0, self.nnz), removesubs) - self.subs = self.subs[keepsubs, :] - self.vals = self.vals[keepsubs] - # Process Group C: Adding new, nonzero values - if np.sum(idxc) > 0: - self.subs = np.vstack((self.subs, newsubs[idxc, :])) - self.vals = np.vstack((self.vals, newvals[idxc])) - - # Resize the tensor - newshape = [] - for n in range(0, len(self.shape)): - smax = max(newsubs[:, n] + 1) - newshape.append(max(self.shape[n], smax)) - self.shape = tuple(newshape) - + self.subs = np.vstack((self.subs, addsubs.astype(int))) + self.vals = np.vstack( + (self.vals, value * np.ones((addsubs.shape[0], 1))) + ) + else: + self.subs = addsubs.astype(int) + self.vals = value * np.ones((addsubs.shape[0], 1)) return + assert False, "Invalid assignment value" + def __eq__(self, other): """ Equal comparator for sptensors @@ -1519,48 +1762,65 @@ def __eq__(self, other): if isinstance(other, (float, int)): if other == 0: return self.logical_not() - else: - idx = (self.vals == other) - return sptensor.from_data(self.subs[idx.transpose()[0]], True*np.ones((self.subs.shape[0], 1)).astype(bool), self.shape) + idx = self.vals == other + return sptensor.from_data( + self.subs[idx.transpose()[0]], + True * np.ones((self.subs.shape[0], 1)).astype(bool), + self.shape, + ) # Case 2: other is a tensor type # Check sizes if self.shape != other.shape: - assert False, 'Size mismatch in sptensor equality' + assert False, "Size mismatch in sptensor equality" # Case 2a: other is a sparse tensor if isinstance(other, ttb.sptensor): - # Find where their zeros intersect xzerosubs = ttb.tt_setdiff_rows(self.allsubs(), self.subs) otherzerosubs = ttb.tt_setdiff_rows(other.allsubs(), other.subs) - #zzerosubs = np.isin(xzerosubs, otherzerosubs) - zzerosubsIdx = ttb.tt_intersect_rows(self.allsubs()[xzerosubs], other.allsubs()[otherzerosubs]) + # zzerosubs = np.isin(xzerosubs, otherzerosubs) + zzerosubsIdx = ttb.tt_intersect_rows( + self.allsubs()[xzerosubs], other.allsubs()[otherzerosubs] + ) zzerosubs = self.allsubs()[xzerosubs][zzerosubsIdx] # Find where their nonzeros intersect - # TODO consider if intersect rows should return 3 args so we don't have to call it twice + # TODO consider if intersect rows should return 3 args so we don't have to + # call it twice nzsubsIdx = ttb.tt_intersect_rows(self.subs, other.subs) nzsubs = self.subs[nzsubsIdx] iother = ttb.tt_intersect_rows(other.subs, self.subs) - znzsubs = nzsubs[(self.vals[nzsubsIdx] == other.vals[iother]).transpose()[0], :] - - return sptensor.from_data(np.vstack((zzerosubs, znzsubs)), - True*np.ones((zzerosubs.shape[0] + znzsubs.shape[0])).astype(bool)[:, None], self.shape) + znzsubs = nzsubs[ + (self.vals[nzsubsIdx] == other.vals[iother]).transpose()[0], : + ] + + return sptensor.from_data( + np.vstack((zzerosubs, znzsubs)), + True + * np.ones((zzerosubs.shape[0] + znzsubs.shape[0])).astype(bool)[ + :, None + ], + self.shape, + ) # Case 2b: other is a dense tensor if isinstance(other, ttb.tensor): - # Find where their zeros interact - otherzerosubs, otherzerosubsflag = (other == 0).find() - zzerosubs = otherzerosubs[(self.extract(otherzerosubs) == 0).transpose()[0], :] + otherzerosubs, _ = (other == 0).find() + zzerosubs = otherzerosubs[ + (self.extract(otherzerosubs) == 0).transpose()[0], : + ] # Find where their nonzeros intersect - othervals = other[self.subs, 'extract'] + othervals = other[self.subs.transpose(), "extract"] znzsubs = self.subs[(othervals[:, None] == self.vals).transpose()[0], :] - return sptensor.from_data(np.vstack((zzerosubs, znzsubs)), - True * np.ones((zzerosubs.shape[0] + znzsubs.shape[0])).astype(bool), self.shape) + return sptensor.from_data( + np.vstack((zzerosubs, znzsubs)), + True * np.ones((zzerosubs.shape[0] + znzsubs.shape[0])).astype(bool), + self.shape, + ) assert False, "Sptensor == argument must be scalar or sptensor" @@ -1579,12 +1839,17 @@ def __ne__(self, other): # Case 1: One argument is a scalar if isinstance(other, (float, int)): if other == 0: - return ttb.sptensor.from_data(self.subs, True*np.ones((self.subs.shape[0], 1)), self.shape) - else: - subs1 = self.subs[self.vals.transpose()[0] != other, :] - subs2Idx = ttb.tt_setdiff_rows(self.allsubs(), self.subs) - subs2 = self.allsubs()[subs2Idx, :] - return ttb.sptensor.from_data(np.vstack((subs1, subs2)), True*np.ones((self.subs.shape[0], 1)).astype(bool), self.shape) + return ttb.sptensor.from_data( + self.subs, True * np.ones((self.subs.shape[0], 1)), self.shape + ) + subs1 = self.subs[self.vals.transpose()[0] != other, :] + subs2Idx = ttb.tt_setdiff_rows(self.allsubs(), self.subs) + subs2 = self.allsubs()[subs2Idx, :] + return ttb.sptensor.from_data( + np.vstack((subs1, subs2)), + True * np.ones((self.subs.shape[0], 1)).astype(bool), + self.shape, + ) # Case 2: Both x and y are tensors or some sort # Check that the sizes match @@ -1592,7 +1857,7 @@ def __ne__(self, other): assert False, "Size mismatch" # Case 2a: Two sparse tensors if isinstance(other, ttb.sptensor): - #find entries where either x *or* y is nonzero, but not both + # find entries where either x *or* y is nonzero, but not both # TODO this is a quick alternative to setxor nonUniqueSelf = ttb.tt_intersect_rows(self.subs, other.subs) selfIdx = True * np.ones(self.subs.shape[0], dtype=bool) @@ -1601,35 +1866,47 @@ def __ne__(self, other): otherIdx = True * np.ones(other.subs.shape[0], dtype=bool) otherIdx[nonUniqueOther] = False subs1 = np.concatenate((self.subs[selfIdx], other.subs[otherIdx])) - #subs1 = setxor(self.subs, other.subs,'rows') - #find entries where both are nonzero, but inequal + # subs1 = setxor(self.subs, other.subs,'rows') + # find entries where both are nonzero, but inequal subs2 = ttb.tt_intersect_rows(self.subs, other.subs) subs_pad = np.zeros((self.shape[0],)).astype(bool) - subs_pad[subs2] = (self.extract(self.subs[subs2]) != other.extract(self.subs[subs2])).transpose()[0] + subs_pad[subs2] = ( + self.extract(self.subs[subs2]) != other.extract(self.subs[subs2]) + ).transpose()[0] subs2 = self.subs[subs_pad, :] # put it all together - return ttb.sptensor.from_data(np.vstack((subs1, subs2)), - True*np.ones((subs1.shape[0] + subs2.shape[0], 1)).astype(bool), self.shape) + return ttb.sptensor.from_data( + np.vstack((subs1, subs2)), + True * np.ones((subs1.shape[0] + subs2.shape[0], 1)).astype(bool), + self.shape, + ) # Case 2b: y is a dense tensor if isinstance(other, ttb.tensor): # find entries where x is zero but y is nonzero - unionSubs = ttb.tt_union_rows(self.subs, np.array(np.where(other.data == 0)).transpose()) + unionSubs = ttb.tt_union_rows( + self.subs, np.array(np.where(other.data == 0)).transpose() + ) if unionSubs.shape[0] != np.prod(self.shape): subs1Idx = ttb.tt_setdiff_rows(self.allsubs(), unionSubs) subs1 = self.allsubs()[subs1Idx] else: subs1 = np.empty((0, self.subs.shape[1])) # find entries where x is nonzero but not equal to y - subs2 = self.subs[self.vals.transpose()[0] != other[self.subs, 'extract'], :] + subs2 = self.subs[ + self.vals.transpose()[0] != other[self.subs.transpose(), "extract"], : + ] if subs2.size == 0: subs2 = np.empty((0, self.subs.shape[1])) # put it all together - return ttb.sptensor.from_data(np.vstack((subs1, subs2)), - True * np.ones((subs1.shape[0] + subs2.shape[0], 1)).astype(bool), self.shape) + return ttb.sptensor.from_data( + np.vstack((subs1, subs2)), + True * np.ones((subs1.shape[0] + subs2.shape[0], 1)).astype(bool), + self.shape, + ) # Otherwise - assert False, 'The arguments must be two sptensors or an sptensor and a scalar.' + assert False, "The arguments must be two sptensors or an sptensor and a scalar." def __sub__(self, other): """ @@ -1648,14 +1925,18 @@ def __sub__(self, other): # a dense result, even if the scalar is zero. # Case 1: Second argument is a scalar or a dense tensor - if isinstance(other, (float, int)) or isinstance(other, ttb.tensor): + if isinstance(other, (float, int, ttb.tensor)): return self.full() - other # Case 2: Both are sparse tensors if not isinstance(other, ttb.sptensor) or self.shape != other.shape: - assert False, 'Must be two sparse tensors of the same shape' + assert False, "Must be two sparse tensors of the same shape" - return ttb.sptensor.from_aggregator(np.vstack((self.subs, other.subs)), np.vstack((self.vals, -1 *other.vals)), self.shape) + return ttb.sptensor.from_aggregator( + np.vstack((self.subs, other.subs)), + np.vstack((self.vals, -1 * other.vals)), + self.shape, + ) def __add__(self, other): """ @@ -1695,7 +1976,7 @@ def __neg__(self): :class:`pyttb.sptensor`, copy of tensor """ - return ttb.sptensor.from_data(self.subs, -1*self.vals, self.shape) + return ttb.sptensor.from_data(self.subs, -1 * self.vals, self.shape) def __mul__(self, other): """ @@ -1703,27 +1984,34 @@ def __mul__(self, other): Parameters ---------- - :class:`pyttb.sptensor`, :class:`pyttb.tensor`, float, int + other: :class:`pyttb.sptensor`, :class:`pyttb.tensor`, float, int Returns ------- :class:`pyttb.sptensor` """ - if isinstance(other, (float,int)): - return ttb.sptensor.from_data(self.subs, self.vals*other, self.shape) + if isinstance(other, (float, int, np.number)): + return ttb.sptensor.from_data(self.subs, self.vals * other, self.shape) - if isinstance(other, (ttb.sptensor,ttb.tensor,ttb.ktensor)) and self.shape != other.shape: + if ( + isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor)) + and self.shape != other.shape + ): assert False, "Sptensor Multiply requires two tensors of the same shape." if isinstance(other, ttb.sptensor): idxSelf = ttb.tt_intersect_rows(self.subs, other.subs) idxOther = ttb.tt_intersect_rows(other.subs, self.subs) - return ttb.sptensor.from_data(self.subs[idxSelf], self.vals[idxSelf]*other.vals[idxOther], self.shape) - elif isinstance(other, ttb.tensor): + return ttb.sptensor.from_data( + self.subs[idxSelf], + self.vals[idxSelf] * other.vals[idxOther], + self.shape, + ) + if isinstance(other, ttb.tensor): csubs = self.subs - cvals = self.vals * other[csubs, 'extract'][:, None] + cvals = self.vals * other[csubs.transpose(), "extract"][:, None] return ttb.sptensor.from_data(csubs, cvals, self.shape) - elif isinstance(other, ttb.ktensor): + if isinstance(other, ttb.ktensor): csubs = self.subs cvals = np.zeros(self.vals.shape) R = other.weights.size @@ -1731,13 +2019,13 @@ def __mul__(self, other): for r in range(R): tvals = other.weights[r] * self.vals for n in range(N): - # Note other[n][:, r] extracts 1-D instead of column vector, which necessitates [:, None] + # Note other[n][:, r] extracts 1-D instead of column vector, + # which necessitates [:, None] v = other[n][:, r][:, None] tvals = tvals * v[csubs[:, n]] cvals += tvals return ttb.sptensor.from_data(csubs, cvals, self.shape) - else: - assert False, "Sptensor cannot be multiplied by that type of object" + assert False, "Sptensor cannot be multiplied by that type of object" def __rmul__(self, other): """ @@ -1745,17 +2033,17 @@ def __rmul__(self, other): Parameters ---------- - float, int + other: float, int Returns ------- :class:`pyttb.sptensor` """ - if isinstance(other, (float,int)): + if isinstance(other, (float, int, np.number)): return self.__mul__(other) - else: - assert False, "This object cannot be multiplied by sptensor" + assert False, "This object cannot be multiplied by sptensor" + # pylint:disable=too-many-branches def __le__(self, other): """ Less than or equal (<=) for sptensor @@ -1768,26 +2056,32 @@ def __le__(self, other): ------- :class:`pyttb.sptensor` """ - # TODO le,lt,ge,gt have a lot of code duplication, look at generalizing them for future maintainabilty + # TODO le,lt,ge,gt have a lot of code duplication, look at generalizing them + # for future maintainabilty # Case 1: One argument is a scalar if isinstance(other, (float, int)): subs1 = self.subs[(self.vals <= other).transpose()[0], :] if other >= 0: - subs2 = self.allsubs()[ttb.tt_setdiff_rows(self.allsubs(),self.subs), :] + subs2 = self.allsubs()[ + ttb.tt_setdiff_rows(self.allsubs(), self.subs), : + ] subs = np.vstack((subs1, subs2)) else: subs = subs1 - return ttb.sptensor.from_data(subs, True*np.ones((len(subs), 1)), self. shape) - + return ttb.sptensor.from_data( + subs, True * np.ones((len(subs), 1)), self.shape + ) # Case 2: Both x and y are tensors of some sort # Check that the sizes match - if isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor)) and self.shape != other.shape: - assert False, 'Size mismatch' + if ( + isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor)) + and self.shape != other.shape + ): + assert False, "Size mismatch" # Case 2a: Two sparse tensors if isinstance(other, ttb.sptensor): - # self not zero, other zero if self.subs.size > 0: subs1 = self.subs[ttb.tt_setdiff_rows(self.subs, other.subs), :] @@ -1808,18 +2102,26 @@ def __le__(self, other): if self.subs.size > 0: subs3 = self.subs[ttb.tt_intersect_rows(self.subs, other.subs), :] if subs3.size > 0: - subs3 = subs3[(self.extract(subs3) <= other.extract(subs3)).transpose()[0], :] + subs3 = subs3[ + (self.extract(subs3) <= other.extract(subs3)).transpose()[0], : + ] else: subs3 = np.empty(shape=(0, other.subs.shape[1])) # self and other zero - xzerosubs = self.allsubs()[ttb.tt_setdiff_rows(self.allsubs(), self.subs), :] - yzerosubs = other.allsubs()[ttb.tt_setdiff_rows(other.allsubs(), other.subs), :] + xzerosubs = self.allsubs()[ + ttb.tt_setdiff_rows(self.allsubs(), self.subs), : + ] + yzerosubs = other.allsubs()[ + ttb.tt_setdiff_rows(other.allsubs(), other.subs), : + ] subs4 = xzerosubs[ttb.tt_intersect_rows(xzerosubs, yzerosubs), :] # assemble subs = np.vstack((subs1, subs2, subs3, subs4)) - return ttb.sptensor.from_data(subs, True*np.ones((len(subs), 1)), self.shape) + return ttb.sptensor.from_data( + subs, True * np.ones((len(subs), 1)), self.shape + ) # Case 2b: One dense tensor if isinstance(other, ttb.tensor): @@ -1828,15 +2130,20 @@ def __le__(self, other): subs1 = subs1[ttb.tt_setdiff_rows(subs1, self.subs), :] # self nonzero - subs2 = self.subs[self.vals.transpose()[0] <= other[self.subs, 'extract'], :] + subs2 = self.subs[ + self.vals.transpose()[0] <= other[self.subs.transpose(), "extract"], : + ] # assemble subs = np.vstack((subs1, subs2)) - return ttb.sptensor.from_data(subs, True*np.ones((len(subs), 1)), self.shape) + return ttb.sptensor.from_data( + subs, True * np.ones((len(subs), 1)), self.shape + ) # Otherwise assert False, "Cannot compare sptensor with that type" + # pylint:disable=too-many-branches def __lt__(self, other): """ Less than (<) for sptensor @@ -1853,20 +2160,26 @@ def __lt__(self, other): if isinstance(other, (float, int)): subs1 = self.subs[(self.vals < other).transpose()[0], :] if other > 0: - subs2 = self.allsubs()[ttb.tt_setdiff_rows(self.allsubs(), self.subs), :] + subs2 = self.allsubs()[ + ttb.tt_setdiff_rows(self.allsubs(), self.subs), : + ] subs = np.vstack((subs1, subs2)) else: subs = subs1 - return ttb.sptensor.from_data(subs, True * np.ones((len(subs), 1)), self.shape) + return ttb.sptensor.from_data( + subs, True * np.ones((len(subs), 1)), self.shape + ) # Case 2: Both x and y are tensors of some sort # Check that the sizes match - if isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor)) and self.shape != other.shape: - assert False, 'Size mismatch' + if ( + isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor)) + and self.shape != other.shape + ): + assert False, "Size mismatch" # Case 2a: Two sparse tensors if isinstance(other, ttb.sptensor): - # self not zero, other zero if self.subs.size > 0: subs1 = self.subs[ttb.tt_setdiff_rows(self.subs, other.subs), :] @@ -1887,13 +2200,17 @@ def __lt__(self, other): if self.subs.size > 0: subs3 = self.subs[ttb.tt_intersect_rows(self.subs, other.subs), :] if subs3.size > 0: - subs3 = subs3[(self.extract(subs3) < other.extract(subs3)).transpose()[0], :] + subs3 = subs3[ + (self.extract(subs3) < other.extract(subs3)).transpose()[0], : + ] else: subs3 = np.empty(shape=(0, other.subs.shape[1])) # assemble subs = np.vstack((subs1, subs2, subs3)) - return ttb.sptensor.from_data(subs, True * np.ones((len(subs), 1)), self.shape) + return ttb.sptensor.from_data( + subs, True * np.ones((len(subs), 1)), self.shape + ) # Case 2b: One dense tensor if isinstance(other, ttb.tensor): @@ -1902,11 +2219,15 @@ def __lt__(self, other): subs1 = subs1[ttb.tt_setdiff_rows(subs1, self.subs), :] # self nonzero - subs2 = self.subs[self.vals.transpose()[0] < other[self.subs, 'extract'], :] + subs2 = self.subs[ + self.vals.transpose()[0] < other[self.subs.transpose(), "extract"], : + ] # assemble subs = np.vstack((subs1, subs2)) - return ttb.sptensor.from_data(subs, True * np.ones((len(subs), 1)), self.shape) + return ttb.sptensor.from_data( + subs, True * np.ones((len(subs), 1)), self.shape + ) # Otherwise assert False, "Cannot compare sptensor with that type" @@ -1931,12 +2252,17 @@ def __ge__(self, other): subs = np.vstack((subs1, self.allsubs()[subs2])) else: subs = subs1 - return ttb.sptensor.from_data(subs, True*np.ones((len(subs), 1)), self.shape) + return ttb.sptensor.from_data( + subs, True * np.ones((len(subs), 1)), self.shape + ) # Case 2: Both x and y are tensors of some sort # Check that the sizes match - if isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor)) and self.shape != other.shape: - assert False, 'Size mismatch' + if ( + isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor)) + and self.shape != other.shape + ): + assert False, "Size mismatch" # Case 2a: Two sparse tensors if isinstance(other, ttb.sptensor): @@ -1944,17 +2270,24 @@ def __ge__(self, other): # Case 2b: One dense tensor if isinstance(other, ttb.tensor): - # self zero subs1, _ = (other <= 0).find() subs1 = subs1[ttb.tt_setdiff_rows(subs1, self.subs), :] # self nonzero - subs2 = self.subs[(self.vals >= other[self.subs, 'extract'][:, None]).transpose()[0], :] + subs2 = self.subs[ + ( + self.vals >= other[self.subs.transpose(), "extract"][:, None] + ).transpose()[0], + :, + ] # assemble - return ttb.sptensor.from_data(np.vstack((subs1, subs2)), - True*np.ones((len(subs1)+len(subs2), 1)), self.shape) + return ttb.sptensor.from_data( + np.vstack((subs1, subs2)), + True * np.ones((len(subs1) + len(subs2), 1)), + self.shape, + ) # Otherwise assert False, "Cannot compare sptensor with that type" @@ -1979,12 +2312,17 @@ def __gt__(self, other): subs = np.vstack((subs1, self.allsubs()[subs2])) else: subs = subs1 - return ttb.sptensor.from_data(subs, True * np.ones((len(subs), 1)), self.shape) + return ttb.sptensor.from_data( + subs, True * np.ones((len(subs), 1)), self.shape + ) # Case 2: Both x and y are tensors of some sort # Check that the sizes match - if isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor)) and self.shape != other.shape: - assert False, 'Size mismatch' + if ( + isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor)) + and self.shape != other.shape + ): + assert False, "Size mismatch" # Case 2a: Two sparse tensors if isinstance(other, ttb.sptensor): @@ -1998,15 +2336,24 @@ def __gt__(self, other): subs1 = subs1[ttb.tt_setdiff_rows(subs1, self.subs), :] # self and other nonzero - subs2 = self.subs[(self.vals > other[self.subs, 'extract'][:, None]).transpose()[0], :] + subs2 = self.subs[ + ( + self.vals > other[self.subs.transpose(), "extract"][:, None] + ).transpose()[0], + :, + ] # assemble - return ttb.sptensor.from_data(np.vstack((subs1, subs2)), - True * np.ones((len(subs1) + len(subs2), 1)), self.shape) + return ttb.sptensor.from_data( + np.vstack((subs1, subs2)), + True * np.ones((len(subs1) + len(subs2), 1)), + self.shape, + ) # Otherwise assert False, "Cannot compare sptensor with that type" + # pylint:disable=too-many-statements, too-many-branches, too-many-locals def __truediv__(self, other): """ Division for sparse tensors (sptensor/other). @@ -2024,23 +2371,26 @@ def __truediv__(self, other): if isinstance(other, (float, int)): # Inline mrdivide newsubs = self.subs - # We ignore the divide by zero errors because np.inf/np.nan is an appropriate representation - with np.errstate(divide='ignore', invalid='ignore'): + # We ignore the divide by zero errors because np.inf/np.nan is an + # appropriate representation + with np.errstate(divide="ignore", invalid="ignore"): newvals = self.vals / other if other == 0: nansubsidx = ttb.tt_setdiff_rows(self.allsubs(), newsubs) nansubs = self.allsubs()[nansubsidx] newsubs = np.vstack((newsubs, nansubs)) - newvals = np.vstack((newvals, np.nan*np.ones((nansubs.shape[0], 1)))) + newvals = np.vstack((newvals, np.nan * np.ones((nansubs.shape[0], 1)))) return ttb.sptensor.from_data(newsubs, newvals, self.shape) # Tensor divided by a tensor - if isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor)) and self.shape!=other.shape: + if ( + isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor)) + and self.shape != other.shape + ): assert False, "Sptensor division requires tensors of the same shape" # Two sparse tensors if isinstance(other, ttb.sptensor): - # Find where their zeros are if self.subs.size == 0: SelfZeroSubs = self.allsubs() @@ -2091,11 +2441,11 @@ def __truediv__(self, other): return ttb.sptensor.from_data(newsubs, newvals, self.shape) - elif isinstance(other, ttb.tensor): + if isinstance(other, ttb.tensor): csubs = self.subs - cvals = self.vals / other[csubs, 'extract'][:, None] + cvals = self.vals / other[csubs.transpose(), "extract"][:, None] return ttb.sptensor.from_data(csubs, cvals, self.shape) - elif isinstance(other, ttb.ktensor): + if isinstance(other, ttb.ktensor): # TODO consider removing epsilon and generating nans consistent with above epsilon = np.finfo(float).eps subs = self.subs @@ -2108,9 +2458,10 @@ def __truediv__(self, other): v = other[n][:, r][:, None] tvals = tvals * v[subs[:, n]] vals += tvals - return ttb.sptensor.from_data(self.subs, self.vals/np.maximum(epsilon, vals), self.shape) - else: - assert False, "Invalid arguments for sptensor division" + return ttb.sptensor.from_data( + self.subs, self.vals / np.maximum(epsilon, vals), self.shape + ) + assert False, "Invalid arguments for sptensor division" def __rtruediv__(self, other): """ @@ -2126,9 +2477,8 @@ def __rtruediv__(self, other): """ # Scalar divided by a tensor -> result is dense if isinstance(other, (float, int)): - return other/self.full() - else: - assert False, "Dividing that object by an sptensor is not supported" + return other / self.full() + assert False, "Dividing that object by an sptensor is not supported" def __repr__(self): # pragma: no cover """ @@ -2145,52 +2495,66 @@ def __repr__(self): # pragma: no cover if self.ndims == 0: s += str(self.shape) return s - s += (' x ').join([str(int(d)) for d in self.shape]) + s += (" x ").join([str(int(d)) for d in self.shape]) return s - else: - s = "Sparse tensor of shape " - s += (' x ').join([str(int(d)) for d in self.shape]) - s += " with {} nonzeros \n".format(nz) + + s = "Sparse tensor of shape " + s += (" x ").join([str(int(d)) for d in self.shape]) + s += f" with {nz} nonzeros \n" # Stop insane printouts if nz > 10000: r = input("Are you sure you want to print all nonzeros? (Y/N)") if r.upper() != "Y": return s - for i, j in enumerate(range(0, self.subs.shape[0])): - s += '\t' - s += '[' + for i in range(0, self.subs.shape[0]): + s += "\t" + s += "[" idx = self.subs[i, :] s += str(idx.tolist())[1:] - s += ' = ' + s += " = " s += str(self.vals[i][0]) - if i < self.subs.shape[0]-1: - s += '\n' + if i < self.subs.shape[0] - 1: + s += "\n" return s __str__ = __repr__ - def ttm(self, matrices, mode, dims=None, transpose=False): + def ttm( + self, + matrices: Union[np.ndarray, List[np.ndarray]], + dims: Optional[Union[float, np.ndarray]] = None, + exclude_dims: Optional[Union[float, np.ndarray]] = None, + transpose: bool = False, + ): """ Sparse tensor times matrix. Parameters ---------- matrices: A matrix or list of matrices - mode: - dims: + dims: Dimensions to multiply against + exclude_dims: Use all dimensions but these transpose: Transpose matrices to be multiplied Returns ------- """ - if dims is None: + if dims is None and exclude_dims is None: dims = np.arange(self.ndims) + elif isinstance(dims, list): + dims = np.array(dims) + elif isinstance(dims, (float, int, np.generic)): + dims = np.array([dims]) + + if isinstance(exclude_dims, (float, int)): + exclude_dims = np.array([exclude_dims]) + # Handle list of matrices if isinstance(matrices, list): # Check dimensions are valid - [dims, vidx] = tt_dimscheck(mode, self.ndims, len(matrices)) + [dims, vidx] = tt_dimscheck(self.ndims, len(matrices), dims, exclude_dims) # Calculate individual products Y = self.ttm(matrices[vidx[0]], dims[0], transpose=transpose) for i in range(1, dims.size): @@ -2205,35 +2569,125 @@ def ttm(self, matrices, mode, dims=None, transpose=False): if transpose: matrices = matrices.transpose() - # Check mode - if not np.isscalar(mode) or mode < 0 or mode > self.ndims-1: - assert False, "Mode must be in [0, ndims)" + # FIXME: This made typing happy but shouldn't be possible + if not isinstance(dims, np.ndarray): # pragma: no cover + raise ValueError("Dims should be an array here") + + # Ensure this is the terminal single dimension case + if not (dims.size == 1 and np.isin(dims, np.arange(self.ndims))): + assert False, "dims must contain values in [0,self.dims)" + final_dim: int = dims[0] # Compute the product # Check that sizes match - if self.shape[mode] != matrices.shape[1]: + if self.shape[final_dim] != matrices.shape[1]: assert False, "Matrix shape doesn't match tensor shape" # Compute the new size siz = np.array(self.shape) - siz[mode] = matrices.shape[0] + siz[final_dim] = matrices.shape[0] # Compute self[mode]' - Xnt = ttb.tt_to_sparse_matrix(self, mode, True) + Xnt = tt_to_sparse_matrix(self, final_dim, True) - # Reshape puts the reshaped things after the unchanged modes, transpose then puts it in front + # Reshape puts the reshaped things after the unchanged modes, transpose then + # puts it in front idx = 0 # Convert to sparse matrix and do multiplication; generally result is sparse Z = Xnt.dot(matrices.transpose()) - # Rearrange back into sparse tensor of original shape - Ynt = ttb.tt_from_sparse_matrix(Z, self.shape, mode, idx) + # Rearrange back into sparse tensor of correct shape + Ynt = tt_from_sparse_matrix(Z, siz, final_dim, idx) - if Z.nnz <= 0.5 * np.prod(siz): + if not isinstance(Z, np.ndarray) and Z.nnz <= 0.5 * np.prod(siz): return Ynt - else: - # TODO evaluate performance loss by casting into sptensor then tensor. I assume minimal since we are already - # using spare matrix representation - return ttb.tensor.from_tensor_type(Ynt) + # TODO evaluate performance loss by casting into sptensor then tensor. + # I assume minimal since we are already using spare matrix representation + return ttb.tensor.from_tensor_type(Ynt) + + +def sptenrand( + shape: Tuple[int, ...], + density: Optional[float] = None, + nonzeros: Optional[float] = None, +) -> sptensor: + """ + Create sptensor with entries drawn from a uniform distribution on the unit interval + + Parameters + ---------- + shape: Shape of resulting tensor + density: Density of resulting sparse tensor + nonzeros: Number of nonzero entries in resulting sparse tensor + + Returns + ------- + Constructed tensor + + Example + ------- + >>> X = ttb.sptenrand((2,2), nonzeros=1) + >>> Y = ttb.sptenrand((2,2), density=0.25) + """ + if density is None and nonzeros is None: + raise ValueError("Must set either density or nonzeros") + + if density is not None and nonzeros is not None: + raise ValueError("Must set either density or nonzeros but not both") + + if density is not None and not 0 < density <= 1: + raise ValueError(f"Density must be a fraction (0, 1] but received {density}") + + if isinstance(density, float): + valid_nonzeros = float(np.prod(shape) * density) + elif isinstance(nonzeros, (int, float)): + valid_nonzeros = nonzeros + else: # pragma: no cover + raise ValueError( + f"Incorrect types for density:{density} and nonzeros:{nonzeros}" + ) + + # Typing doesn't play nice with partial + # mypy issue: 1484 + def unit_uniform(pass_through_shape: Tuple[int, ...]) -> np.ndarray: + return np.random.uniform(low=0, high=1, size=pass_through_shape) + + return ttb.sptensor.from_function(unit_uniform, shape, valid_nonzeros) + + +def sptendiag( + elements: np.ndarray, shape: Optional[Tuple[int, ...]] = None +) -> sptensor: + """ + Creates a sparse tensor with elements along super diagonal + If provided shape is too small the tensor will be enlarged to accomodate + + Parameters + ---------- + elements: Elements to set along the diagonal + shape: Shape of resulting tensor + + Returns + ------- + Constructed tensor + + Example + ------- + >>> shape = (2,) + >>> values = np.ones(shape) + >>> X = ttb.sptendiag(values) + >>> Y = ttb.sptendiag(values, (2, 2)) + >>> X.isequal(Y) + True + """ + # Flatten provided elements + elements = np.ravel(elements) + N = len(elements) + if shape is None: + constructed_shape = (N,) * N + else: + constructed_shape = tuple(max(N, dim) for dim in shape) + subs = np.tile(np.arange(0, N).transpose(), (len(constructed_shape), 1)).transpose() + return sptensor.from_aggregator(subs, elements.reshape((N, 1)), constructed_shape) diff --git a/pyttb/sptensor3.py b/pyttb/sptensor3.py index 4722dddc..d08e4b9e 100644 --- a/pyttb/sptensor3.py +++ b/pyttb/sptensor3.py @@ -2,9 +2,12 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. +import numpy as np + import pyttb as ttb + from .pyttb_utils import * -import numpy as np + class sptensor3(object): """ diff --git a/pyttb/sumtensor.py b/pyttb/sumtensor.py index 8633d0a2..591e0d95 100644 --- a/pyttb/sumtensor.py +++ b/pyttb/sumtensor.py @@ -2,9 +2,12 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. +import numpy as np + import pyttb as ttb + from .pyttb_utils import * -import numpy as np + class sumtensor(object): """ diff --git a/pyttb/symktensor.py b/pyttb/symktensor.py index 5532bed7..e1da5fc0 100644 --- a/pyttb/symktensor.py +++ b/pyttb/symktensor.py @@ -2,9 +2,11 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. +import numpy as np + import pyttb as ttb + from .pyttb_utils import * -import numpy as np class symktensor(object): diff --git a/pyttb/symtensor.py b/pyttb/symtensor.py index 497b7afd..a41f2b9c 100644 --- a/pyttb/symtensor.py +++ b/pyttb/symtensor.py @@ -2,9 +2,12 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. +import numpy as np + import pyttb as ttb + from .pyttb_utils import * -import numpy as np + class symtensor(object): """ diff --git a/pyttb/tenmat.py b/pyttb/tenmat.py index 7daf160d..d8e58a98 100644 --- a/pyttb/tenmat.py +++ b/pyttb/tenmat.py @@ -2,9 +2,11 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. +import numpy as np + import pyttb as ttb + from .pyttb_utils import * -import numpy as np class tenmat(object): @@ -13,7 +15,7 @@ class tenmat(object): """ - def __init__(self, *args): + def __init__(self): """ TENSOR Create empty tensor. """ @@ -25,47 +27,62 @@ def __init__(self, *args): self.data = np.array([]) @classmethod - def from_data(cls, data, rdims, cdims, tshape=None): + def from_data(cls, data, rdims, cdims=None, tshape=None): # CONVERT A MULTIDIMENSIONAL ARRAY - + # Verify that data is a numeric numpy.ndarray - if not isinstance(data, np.ndarray) or not issubclass(data.dtype.type, np.number): - assert False, 'First argument must be a numeric numpy.ndarray.' + if not isinstance(data, np.ndarray) or not issubclass( + data.dtype.type, np.number + ): + assert False, "First argument must be a numeric numpy.ndarray." # data is empty, return empty tenmat unless rdims, cdims, or tshape are not empty if data.size == 0: if not rdims.size == 0 or not cdims.size == 0 or not tshape == (): - assert False, 'When data is empty, rdims, cdims, and tshape must also be empty.' + assert ( + False + ), "When data is empty, rdims, cdims, and tshape must also be empty." else: return cls() # data is 1d array, must convert to 2d array for tenmat if len(data.shape) == 1: if tshape is None: - assert False, 'tshape must be specified when data is 1d array.' + assert False, "tshape must be specified when data is 1d array." else: # make data a 2d array with shape (1, data.shape[0]), i.e., a row vector - data = np.reshape(data.copy(), (1, data.shape[0]), order='F') + data = np.reshape(data.copy(), (1, data.shape[0]), order="F") + + # data is ndarray and only rdims is specified + if cdims is None: + return ttb.tenmat.from_tensor_type(ttb.tensor.from_data(data), rdims) # use data.shape for tshape if not provided if tshape is None: tshape = data.shape elif not isinstance(tshape, tuple): - assert False, 'tshape must be a tuple.' + assert False, "tshape must be a tuple." # check that data.shape and tshape agree if not np.prod(data.shape) == np.prod(tshape): - assert False, 'Incorrect dimensions specified: products of data.shape and tuple do not match' - - # check that data.shape and product of dimensions agree - if not np.prod(np.array(tshape)[rdims]) * np.prod(np.array(tshape)[cdims]) == np.prod(data.shape): - assert False, 'data.shape does not match shape specified by rdims, cdims, and tshape.' - - return ttb.tenmat.from_tensor_type(ttb.tensor.from_data(data, tshape), rdims, cdims) + assert ( + False + ), "Incorrect dimensions specified: products of data.shape and tuple do not match" + + # check that data.shape and product of dimensions agree + if not np.prod(np.array(tshape)[rdims]) * np.prod( + np.array(tshape)[cdims] + ) == np.prod(data.shape): + assert ( + False + ), "data.shape does not match shape specified by rdims, cdims, and tshape." + + return ttb.tenmat.from_tensor_type( + ttb.tensor.from_data(data, tshape), rdims, cdims + ) @classmethod def from_tensor_type(cls, source, rdims=None, cdims=None, cdims_cyclic=None): - # Case 0b: Copy Contructor if isinstance(source, tenmat): # Create tenmat @@ -84,37 +101,54 @@ def from_tensor_type(cls, source, rdims=None, cdims=None, cdims_cyclic=None): # Verify inputs if rdims is None and cdims is None: - assert False, 'Either rdims or cdims or both must be specified.' + assert False, "Either rdims or cdims or both must be specified." if rdims is not None and not sum(np.in1d(rdims, alldims)) == len(rdims): - assert False, 'Values in rdims must be in [0, source.ndims].' + assert False, "Values in rdims must be in [0, source.ndims]." if cdims is not None and not sum(np.in1d(cdims, alldims)) == len(cdims): - assert False, 'Values in cdims must be in [0, source.ndims].' + assert False, "Values in cdims must be in [0, source.ndims]." if rdims is not None and cdims is None: # Single row mapping if len(rdims) == 1 and cdims_cyclic is not None: - if cdims_cyclic == 'fc': + if cdims_cyclic == "fc": # cdims = [rdims+1:n, 1:rdims-1]; - cdims = np.array([i for i in range(rdims[0]+1,n)] + [i for i in range(rdims[0])]) - elif cdims_cyclic == 'bc': + cdims = np.array( + [i for i in range(rdims[0] + 1, n)] + + [i for i in range(rdims[0])] + ) + elif cdims_cyclic == "bc": # cdims = [rdims-1:-1:1, n:-1:rdims+1]; - cdims = np.array([i for i in range(rdims[0]-1,-1, -1)] + [i for i in range(n-1,rdims[0], -1)]) + cdims = np.array( + [i for i in range(rdims[0] - 1, -1, -1)] + + [i for i in range(n - 1, rdims[0], -1)] + ) else: - assert False, 'Unrecognized value for cdims_cyclic pattern, must be "fc" or "bc".' - + assert ( + False + ), 'Unrecognized value for cdims_cyclic pattern, must be "fc" or "bc".' + else: # Multiple row mapping cdims = np.setdiff1d(alldims, rdims) - + elif rdims is None and cdims is not None: rdims = np.setdiff1d(alldims, cdims) - - dims = np.hstack([rdims, cdims]) + # if rdims or cdims is empty, hstack will output an array of float not int + if rdims.size == 0: + dims = cdims.copy() + elif cdims.size == 0: + dims = rdims.copy() + else: + dims = np.hstack([rdims, cdims]) if not len(dims) == n or not (alldims == np.sort(dims)).all(): - assert False, 'Incorrect specification of dimensions, the sorted concatenation of rdims and cdims must be range(source.ndims).' + assert ( + False + ), "Incorrect specification of dimensions, the sorted concatenation of rdims and cdims must be range(source.ndims)." - data = np.reshape(source.permute(dims).data, (np.prod(np.array(tshape)[rdims]), np.prod(np.array(tshape)[cdims])), order='F') + rprod = 1 if rdims.size == 0 else np.prod(np.array(tshape)[rdims]) + cprod = 1 if cdims.size == 0 else np.prod(np.array(tshape)[cdims]) + data = np.reshape(source.permute(dims).data, (rprod, cprod), order="F") # Create tenmat tenmatInstance = cls() @@ -127,7 +161,7 @@ def from_tensor_type(cls, source, rdims=None, cdims=None, cdims_cyclic=None): def ctranspose(self): """ Complex conjugate transpose for tenmat. - + Parameters ---------- @@ -162,7 +196,7 @@ def end(self, k): ---------- k: int dimension for subscripted indexing - + Returns ------- int: index @@ -179,10 +213,7 @@ def ndims(self): ------- int """ - if self.shape == (0,): - return 0 - else: - return len(self.shape) + return len(self.shape) def norm(self): """ @@ -190,7 +221,7 @@ def norm(self): Parameters ---------- - + Returns ------- Returns @@ -226,10 +257,10 @@ def __setitem__(self, key, value): def __getitem__(self, item): """ SUBSREF Subscripted reference for tenmat. - + Parameters ---------- - item: + item: Returns ------- @@ -253,25 +284,38 @@ def __mul__(self, other): if np.isscalar(other): Z = ttb.tenmat.from_tensor_type(self) Z.data = Z.data * other - return Z + return Z elif isinstance(other, tenmat): # Check that data shapes are compatible if not self.shape[1] == other.shape[0]: - assert False, 'tenmat shape mismatch: number or columns of left operand must match number of rows of right operand.' - - tshape = tuple(np.hstack((np.array(self.tshape)[self.rindices], np.array(other.tshape)[other.cindices]))) + assert ( + False + ), "tenmat shape mismatch: number or columns of left operand must match number of rows of right operand." + + tshape = tuple( + np.hstack( + ( + np.array(self.tshape)[self.rindices], + np.array(other.tshape)[other.cindices], + ) + ) + ) if tshape == (): - return (self.data @ other.data)[0,0] + return (self.data @ other.data)[0, 0] else: tenmatInstance = tenmat() tenmatInstance.tshape = tshape tenmatInstance.rindices = np.arange(len(self.rindices)) - tenmatInstance.cindices = np.arange(len(other.cindices)) + len(self.rindices) - tenmatInstance.data = self.data @ other.data + tenmatInstance.cindices = np.arange(len(other.cindices)) + len( + self.rindices + ) + tenmatInstance.data = self.data @ other.data return tenmatInstance else: - assert False, 'tenmat multiplication only valid with scalar or tenmat objects.' + assert ( + False + ), "tenmat multiplication only valid with scalar or tenmat objects." def __rmul__(self, other): """ @@ -305,17 +349,17 @@ def __add__(self, other): if np.isscalar(other): Z = ttb.tenmat.from_tensor_type(self) Z.data = Z.data + other - return Z + return Z elif isinstance(other, tenmat): # Check that data shapes agree if not self.shape == other.shape: - assert False, 'tenmat shape mismatch.' + assert False, "tenmat shape mismatch." Z = ttb.tenmat.from_tensor_type(self) Z.data = Z.data + other.data return Z else: - assert False, 'tenmat addition only valid with scalar or tenmat objects.' + assert False, "tenmat addition only valid with scalar or tenmat objects." def __radd__(self, other): """ @@ -349,17 +393,17 @@ def __sub__(self, other): if np.isscalar(other): Z = ttb.tenmat.from_tensor_type(self) Z.data = Z.data - other - return Z + return Z elif isinstance(other, tenmat): # Check that data shapes agree if not self.shape == other.shape: - assert False, 'tenmat shape mismatch.' + assert False, "tenmat shape mismatch." Z = ttb.tenmat.from_tensor_type(self) Z.data = Z.data - other.data return Z else: - assert False, 'tenmat subtraction only valid with scalar or tenmat objects.' + assert False, "tenmat subtraction only valid with scalar or tenmat objects." def __rsub__(self, other): """ @@ -382,14 +426,13 @@ def __rsub__(self, other): elif isinstance(other, tenmat): # Check that data shapes agree if not self.shape == other.shape: - assert False, 'tenmat shape mismatch.' + assert False, "tenmat shape mismatch." Z = ttb.tenmat.from_tensor_type(self) Z.data = other.data - Z.data return Z else: - assert False, 'tenmat subtraction only valid with scalar or tenmat objects.' - + assert False, "tenmat subtraction only valid with scalar or tenmat objects." def __pos__(self): """ @@ -429,28 +472,28 @@ def __repr__(self): str Contains the shape, row indices (rindices), column indices (cindices) and data as strings on different lines. """ - s = '' - s += 'matrix corresponding to a tensor of shape ' + s = "" + s += "matrix corresponding to a tensor of shape " if self.data.size == 0: s += str(self.shape) else: - s += (' x ').join([str(int(d)) for d in self.tshape]) - s += '\n' + s += (" x ").join([str(int(d)) for d in self.tshape]) + s += "\n" - s += 'rindices = ' - s += '[ ' + (', ').join([str(int(d)) for d in self.rindices]) + ' ] ' - s += '(modes of tensor corresponding to rows)\n' + s += "rindices = " + s += "[ " + (", ").join([str(int(d)) for d in self.rindices]) + " ] " + s += "(modes of tensor corresponding to rows)\n" - s += 'cindices = ' - s += '[ ' + (', ').join([str(int(d)) for d in self.cindices]) + ' ] ' - s += '(modes of tensor corresponding to columns)\n' + s += "cindices = " + s += "[ " + (", ").join([str(int(d)) for d in self.cindices]) + " ] " + s += "(modes of tensor corresponding to columns)\n" if self.data.size == 0: - s += 'data = []\n' + s += "data = []\n" else: - s += 'data[:, :] = \n' + s += "data[:, :] = \n" s += str(self.data) - s += '\n' + s += "\n" return s diff --git a/pyttb/tensor.py b/pyttb/tensor.py index 31470592..b415824d 100644 --- a/pyttb/tensor.py +++ b/pyttb/tensor.py @@ -1,21 +1,32 @@ # Copyright 2022 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. +"""Dense Tensor Implementation""" +from __future__ import annotations -import pyttb as ttb -from .pyttb_utils import * -import numpy as np +import logging +from collections.abc import Iterable from itertools import permutations -from numpy_groupies import aggregate as accumarray +from math import factorial +from typing import Any, Callable, List, Optional, Tuple, Union + +import numpy as np import scipy.sparse.linalg -import warnings +from numpy_groupies import aggregate as accumarray + +import pyttb as ttb +from pyttb.pyttb_utils import tt_dimscheck, tt_ind2sub + -class tensor(object): +class tensor: """ TENSOR Class for dense tensors. """ - def __init__(self, *args): + data: np.ndarray + shape: Tuple + + def __init__(self): """ TENSOR Create empty tensor. """ @@ -25,45 +36,55 @@ def __init__(self, *args): self.shape = () @classmethod - def from_data(cls, data, shape=None): + def from_data( + cls, data: np.ndarray, shape: Optional[Tuple[int, ...]] = None + ) -> tensor: """ - Creates a tensor from explicit description. Note that 1D tensors (i.e., when len(shape)==1) - contains a data array that follow the Numpy convention of being a row vector, which is - different than in the Matlab Tensor Toolbox. + Creates a tensor from explicit description. Note that 1D tensors (i.e., + when len(shape)==1) contains a data array that follow the Numpy convention + of being a row vector, which is different than in the Matlab Tensor Toolbox. Parameters ---------- - data: :class:`numpy.ndarray` - shape: tuple + data: Tensor source data + shape: Shape of resulting tensor if not the same as data shape Returns ------- - :class:`pyttb.tensor` + Constructed tensor + + Example + ------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> Y = ttb.tensor.from_data(np.ones((2,2)), shape=(4,1)) """ # CONVERT A MULTIDIMENSIONAL ARRAY - if not issubclass(data.dtype.type, np.number) and not issubclass(data.dtype.type, np.bool_): - assert False, 'First argument must be a multidimensional array.' + if not issubclass(data.dtype.type, np.number) and not issubclass( + data.dtype.type, np.bool_ + ): + assert False, "First argument must be a multidimensional array." # Create or check second argument if shape is None: shape = data.shape else: if not isinstance(shape, tuple): - assert False, 'Second argument must be a tuple.' - + assert False, "Second argument must be a tuple." # Make sure the number of elements matches what's been specified if len(shape) == 0: if data.size > 0: - assert False, 'Empty tensor cannot contain any elements' + assert False, "Empty tensor cannot contain any elements" elif np.prod(shape) != data.size: - assert False, 'TTB:WrongSize, Size of data does not match specified size of tensor' + assert ( + False + ), "TTB:WrongSize, Size of data does not match specified size of tensor" # Make sure the data is indeed the right shape if data.size > 0 and len(shape) > 0: # reshaping using Fortran ordering to match Matlab conventions - data = np.reshape(data, np.array(shape), order='F') + data = np.reshape(data, np.array(shape), order="F") # Create the tensor tensorInstance = cls() @@ -72,54 +93,83 @@ def from_data(cls, data, shape=None): return tensorInstance @classmethod - def from_tensor_type(cls, source): + def from_tensor_type( + cls, source: Union[ttb.sptensor, tensor, ttb.ktensor, ttb.tenmat] + ) -> tensor: """ Converts other tensor types into a dense tensor Parameters ---------- - source: :class:`pyttb.sptensor`, :class:`pyttb.tensor`, :class:`pyttb.ktensor`, \ - :class:`pyttb.ttensor`, :class:`pyttb.sumtensor`, :class:`pyttb.symtensor`, \ - or :class:`pyttb.symktensor` + source: Tensor type to create dense tensor from Returns ------- - :class:`pyttb.tensor` + Constructed tensor + + Example + ------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> Y = ttb.tensor.from_tensor_type(X) """ # CONVERSION/COPY CONSTRUCTORS if isinstance(source, tensor): # COPY CONSTRUCTOR return cls.from_data(source.data.copy(), source.shape) - elif isinstance(source, (ttb.ktensor, ttb.ttensor, ttb.sptensor, ttb.sumtensor, ttb.symtensor, ttb.symktensor)): + if isinstance( + source, + ( + ttb.ktensor, + ttb.ttensor, + ttb.sptensor, + ttb.sumtensor, + ttb.symtensor, + ttb.symktensor, + ), + ): # CONVERSION t = source.full() return cls.from_data(t.data.copy(), t.shape) - elif isinstance(source, ttb.tenmat): # pragma: no cover + if isinstance(source, ttb.tenmat): # RESHAPE TENSOR-AS-MATRIX # Here we just reverse what was done in the tenmat constructor. # First we reshape the data to be an MDA, then we un-permute # it using ipermute. - raise NotImplementedError + shape = source.tshape + order = np.hstack([source.rindices, source.cindices]) + data = np.reshape(source.data.copy(), np.array(shape)[order], order="F") + if order.size > 1: + data = np.transpose(data, np.argsort(order)) + return cls.from_data(data, shape) + raise ValueError(f"Unsupported type for tensor source, received {type(source)}") @classmethod - def from_function(cls, function_handle, shape): + def from_function( + cls, + function_handle: Callable[[Tuple[int, ...]], np.ndarray], + shape: Tuple[int, ...], + ) -> tensor: """ Creates a tensor from a function handle and size Parameters ---------- - function_handle: FunctionType(tuple) - shape: tuple + function_handle: Function to generate data to construct tensor + shape: Shape of resulting tensor Returns ------- - :class:`pyttb.tensor` + Constructed tensor + + Example + ------- + >>> X = ttb.tensor.from_function(lambda a_shape: np.ones(a_shape), (2,2)) """ # FUNCTION HANDLE AND SIZE # Check size if not isinstance(shape, tuple): - assert False, 'TTB:BadInput, Shape must be a tuple' + assert False, "TTB:BadInput, Shape must be a tuple" # Generate data data = function_handle(shape) @@ -127,18 +177,30 @@ def from_function(cls, function_handle, shape): # Create the tensor return cls.from_data(data, shape) - def collapse(self, dims=None, fun="sum"): # pragma: no cover + def collapse( + self, + dims: Optional[np.ndarray] = None, + fun: Callable[[np.ndarray], Union[float, np.ndarray]] = np.sum, + ) -> Union[float, np.ndarray, tensor]: """ Collapse tensor along specified dimensions. Parameters ---------- - dims: :class:`numpy.ndarray` - fun: callable + dims: Dimensions to collapse + fun: Method used to collapse dimensions Returns ------- - float, :class:`pyttb.tensor` + Collapsed value + + Example + ------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> X.collapse() + 4.0 + >>> X.collapse(np.arange(X.ndims), sum) + 4.0 """ if self.data.size == 0: return np.array([]) @@ -149,47 +211,45 @@ def collapse(self, dims=None, fun="sum"): # pragma: no cover if dims.size == 0: return ttb.tensor.from_tensor_type(self) - dims, _ = tt_dimscheck(dims, self.ndims) + 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 if remdims.size == 0: - if fun == "sum": - return sum(self.data.flatten('F')) - else: - return fun(self.data.flatten('F')) - - assert False, "collapse not implemented for arbitrary subset of dimensions; requires TENMAT class, which is not yet implemented" + return fun(self.data.flatten("F")) - ## Calculate the size of the result - ##newsize = self.shape[remdims] - #newsize = (self.shape[d] for d in remdims) - #print(newsize) + ## Calculate the shape of the result + newshape = tuple(np.array(self.shape)[remdims]) ## Convert to a matrix where each row is going to be collapsed - #A = ttb.tenmat(self, remdims, dims).double() # TODO depends on tenmat + A = ttb.tenmat.from_data(self.data, remdims, dims).double() ## Apply the collapse function - #B = np.zeros((A.shape[0], 1)) - #for i in range(0, A.shape[0]): - # B[i] = fun(A[i, :]) + B = np.zeros((A.shape[0], 1)) + for i in range(0, A.shape[0]): + B[i] = fun(A[i, :]) ## Form and return the final result - #return ttb.tensor.from_tensor_type(ttb.tenmat(B, np.arange(0, np.prod(remdims)), np.array([]), newsize)) # TODO depends on tenmat + return ttb.tensor.from_data(B, newshape) - def contract(self, i, j): + def contract(self, i: int, j: int) -> Union[np.ndarray, tensor]: """ Contract tensor along two dimensions (array trace). Parameters ---------- - i: int - j: int + i: First dimension + j: Second dimension Returns ------- + Contracted tensor - + Example + ------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> X.contract(0, 1) + 2.0 """ if self.shape[i] != self.shape[j]: assert False, "Must contract along equally sized dimensions" @@ -217,164 +277,196 @@ def contract(self, i, j): x = self.permute(np.concatenate((remdims, np.array([i, j])))) # Reshape data to be 3D - data = np.reshape(x.data, (m, n, n), order='F') + data = np.reshape(x.data, (m, n, n), order="F") # Add diagonal entries for each slice newdata = np.zeros((m, 1)) - for i in range(0, n): - newdata += data[:, i, i][:, None] + for idx in range(0, n): + newdata += data[:, idx, idx][:, None] # Reshape result if np.prod(newsize) > 1: - newdata = np.reshape(newdata, newsize, order='F') + newdata = np.reshape(newdata, newsize, order="F") return ttb.tensor.from_data(newdata, newsize) - def double(self): + def double(self) -> np.ndarray: """ Convert tensor to an array of doubles Returns ------- - :class:`numpy.ndarray` - copy of tensor data + Copy of tensor data + + Example + ------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> X.double() + array([[1., 1.], + [1., 1.]]) """ return self.data.astype(np.float_).copy() - def exp(self): + def exp(self) -> tensor: """ Exponential of the elements of tensor Returns ------- - :class:`pyttb.tensor` + Copy of tensor data element-wise raised to exponential Examples -------- >>> tensor1 = ttb.tensor.from_data(np.array([[1, 2], [3, 4]])) - >>> tensor1.exp().data - array([ [2.71828183, 7.3890561] , [20.08553692, 54.59815003]]) + >>> tensor1.exp().data # doctest: +ELLIPSIS + array([[ 2.7182..., 7.3890... ], + [20.0855..., 54.5981...]]) """ return ttb.tensor.from_data(np.exp(self.data)) - def end(self, k=None): + def end(self, k: Optional[int] = None) -> int: """ Last index of indexing expression for tensor Parameters ---------- - k: int - dimension for subscripted indexing + k: dimension for subscripted indexing - Returns - ------- - int: index + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> X.end() # linear indexing + 3 + >>> X.end(0) + 1 """ if k is not None: # Subscripted indexing return self.shape[k] - 1 - else: # For linear indexing - return np.prod(self.shape) - 1 + # For linear indexing + return np.prod(self.shape) - 1 - def find(self): + def find(self) -> Tuple[np.ndarray, np.ndarray]: """ FIND Find subscripts of nonzero elements in a tensor. - S, V = FIND(X) returns the subscripts of the nonzero values in X and a column vector of the values. + S, V = FIND(X) returns the subscripts of the nonzero values in X and a column + vector of the values. Examples -------- - >>> X = tensor(rand(3,4,2)) - >>> subs, vals = find(X > 0.5) #<-- find subscripts of values greater than 0.5 + >>> X = ttb.tensor.from_data(np.zeros((3,4,2))) + >>> larger_entries = X > 0.5 + >>> subs, vals = larger_entries.find() See Also -------- TENSOR/SUBSREF, TENSOR/SUBSASGN - :return: + Returns + ------- + Subscripts and values for non-zero entries """ - idx = np.where(self.data > 0) - subs = np.array(idx).transpose() - vals = self.data[idx] - return subs, vals[:, None] + idx = np.nonzero(np.ravel(self.data, order="F"))[0] + subs = ttb.tt_ind2sub(self.shape, idx) + vals = self.data[tuple(subs.T)][:, None] + return subs, vals - def full(self): + def full(self) -> tensor: """ - Convert dense tensor to dense tensor, returns deep copy + Convert dense tensor to dense tensor. Returns ------- - :class:`pyttb.tensor` + Deep copy """ return ttb.tensor.from_data(self.data) - def innerprod(self, other): + def innerprod(self, other: Union[tensor, ttb.sptensor, ttb.ktensor]) -> float: """ Efficient inner product with a tensor Parameters ---------- - other: :class:`pyttb.tensor`, :class:`pyttb.sptensor`, :class:`pyttb.ktensor`,\ - :class:`pyttb.ttensor` - - Returns - ------- - float + other: Tensor type to take an innerproduct with Examples -------- >>> tensor1 = ttb.tensor.from_data(np.array([[1, 2], [3, 4]])) >>> tensor1.innerprod(tensor1) - 30 + 30 """ if isinstance(other, ttb.tensor): if self.shape != other.shape: - assert False, 'Inner product must be between tensors of the same size' - x = np.reshape(self.data, (self.data.size,)) - y = np.reshape(other.data, (other.data.size,)) - #x = np.reshape(self.data, (1, self.data.size)) - #y = np.reshape(other.data, (other.data.size, 1)) + assert False, "Inner product must be between tensors of the same size" + x = np.reshape(self.data, (self.data.size,), order="F") + y = np.reshape(other.data, (other.data.size,), order="F") return x.dot(y) - elif isinstance(other, (ttb.ktensor, ttb.sptensor, ttb.ttensor)): # pragma: no cover + if isinstance(other, (ttb.ktensor, ttb.sptensor, ttb.ttensor)): # Reverse arguments and call specializer code return other.innerprod(self) - else: - assert False, "Inner product between tensor and that class is not supported" + assert False, "Inner product between tensor and that class is not supported" - def isequal(self, other): + def isequal(self, other: Union[tensor, ttb.sptensor]) -> bool: """ Exact equality for tensors Parameters ---------- - other: :class:`pyttb.tensor`, :class:`pyttb.sptensor` + other: Tensor to compare against - Returns - ------- - bool: - True if tensors are identical, false otherwise + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> Y = ttb.tensor.from_data(np.zeros((2,2))) + >>> X.isequal(Y) + False """ - - if not isinstance(other, (ttb.tensor, ttb.sptensor)) or self.shape != other.shape: - return False - elif isinstance(other, ttb.tensor): - return np.all(self.data == other.data) - elif isinstance(other, ttb.sptensor): - return np.all(self.data == other.full().data) - - def issymmetric(self, grps=None, version=None, return_details = False): + if isinstance(other, ttb.tensor): + return bool(np.all(self.data == other.data)) + if isinstance(other, ttb.sptensor): + return bool(np.all(self.data == other.full().data)) + return False + + # TODO: We should probably always return details and let caller drop them + # pylint: disable=too-many-branches, too-many-locals + def issymmetric( + self, + grps: Optional[np.ndarray] = None, + version: Optional[Any] = None, + return_details: bool = False, + ) -> Union[bool, Tuple[bool, np.ndarray, np.ndarray]]: """ Determine if a dense tensor is symmetric in specified modes. Parameters ---------- - grps + grps: Modes to check for symmetry version: Flag Any non-None value will call the non-default old version + return_details: Flag to return symmetry details in addition to bool Returns ------- + If symmetric in modes, optionally all differences and permutations + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> X.issymmetric() + True + >>> X.issymmetric(grps=np.arange(X.ndims)) + True + >>> is_sym, diffs, perms = \ + X.issymmetric(grps=np.arange(X.ndims), version=1, return_details=True) + >>> print(f"Tensor is symmetric: {is_sym}") + Tensor is symmetric: True + >>> print(f"Differences in modes: {diffs}") + Differences in modes: [[0.] + [0.]] + >>> print(f"Permutations: {perms}") + Permutations: [[0. 1.] + [1. 0.]] """ n = self.ndims sz = np.array(self.shape) @@ -386,19 +478,17 @@ def issymmetric(self, grps=None, version=None, return_details = False): grps = np.array([grps]) # Substantially different routines are called depending on whether the user - # requests the permutation information. If permutation is required (or requested) - # the algorithm is much slower - if version is None: # Use new algorithm - for i in range(0, len(grps)): - - # Extract current group - thisgrp = grps[i] - + # requests the permutation information. If permutation is required + # (or requested) the algorithm is much slower + if version is None: # pylint:disable=no-else-return + # Use new algorithm + for thisgrp in grps: # Check tensor dimensions first if not np.all(sz[thisgrp[0]] == sz[thisgrp]): return False - # Construct matrix ind where each row is the multi-index for one element of X + # Construct matrix ind where each row is the multi-index for one + # element of X idx = tt_ind2sub(self.shape, np.arange(0, self.data.size)) # Find reference index for every element in the tensor - this @@ -418,46 +508,46 @@ def issymmetric(self, grps=None, version=None, return_details = False): # Use the older algorithm else: # Check tensor dimensions for compatibility with symmetrization - for i in range(0, len(grps)): - dims = grps[i] + for dims in grps: for j in dims[1:]: if sz[j] != sz[dims[0]]: return False # Check actual symmetry - cnt = sum([np.math.factorial(len(x)) for x in grps]) + cnt = sum(factorial(len(x)) for x in grps) all_diffs = np.zeros((cnt, 1)) all_perms = np.zeros((cnt, n)) - for i in range(0, len(grps)): - + for a_group in grps: # Compute the permutations for this group of symmetries - for idx, perm in enumerate(permutations(grps[i])): + for p_idx, perm in enumerate(permutations(a_group)): + all_perms[p_idx, :] = perm - all_perms[idx, :] = perm - - # Do the permutation and see if it is a match, if not record the difference. + # Do the permutation and record the difference. Y = self.permute(np.array(perm)) if np.array_equal(self.data, Y.data): - all_diffs[idx] = 0 + all_diffs[p_idx] = 0 else: - all_diffs[idx] = np.max(np.abs(self.data.ravel() - Y.data.ravel())) + all_diffs[p_idx] = np.max( + np.abs(self.data.ravel() - Y.data.ravel()) + ) - if return_details == False: + if return_details is False: return bool((all_diffs == 0).all()) - else: - return bool((all_diffs == 0).all()), all_diffs, all_perms + return bool((all_diffs == 0).all()), all_diffs, all_perms - def logical_and(self, B): + def logical_and(self, B: Union[float, tensor]) -> tensor: """ Logical and for tensors Parameters ---------- - B: int, float, :class:`pyttb.tensor` + B: Value to and against self - Returns - ------- - :class:`pyttb.tensor` + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2), dtype=bool)) + >>> X.logical_and(X).collapse() # All true + 4 """ def logical_and(x, y): @@ -465,68 +555,80 @@ def logical_and(x, y): return ttb.tt_tenfun(logical_and, self, B) - def logical_not(self): + def logical_not(self) -> tensor: """ Logical Not For Tensors Returns ------- - :class:`pyttb.tensor` + Negated tensor + + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2), dtype=bool)) + >>> X.logical_not().collapse() # All false + 0 """ return ttb.tensor.from_data(np.logical_not(self.data)) - def logical_or(self, other): + def logical_or(self, other: Union[float, tensor]) -> tensor: """ Logical or for tensors Parameters ---------- - other: :class:`pyttb.tensor`, float, int + other: Value to perform or against - Returns - ------- - :class:`pyttb.tensor` + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2), dtype=bool)) + >>> X.logical_or(X.logical_not()).collapse() # All true + 4 """ + def tensor_or(x, y): return np.logical_or(x, y) return ttb.tt_tenfun(tensor_or, self, other) - def logical_xor(self, other): + def logical_xor(self, other: Union[float, tensor]) -> tensor: """ Logical xor for tensors Parameters ---------- - other: :class:`pyttb.tensor`, float, int + other: Value to perform xor against - Returns - ------- - :class:`pyttb.tensor` + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2), dtype=bool)) + >>> X.logical_xor(X.logical_not()).collapse() # All true + 4 """ + def tensor_xor(x, y): return np.logical_xor(x, y) return ttb.tt_tenfun(tensor_xor, self, other) - def mask(self, W): + def mask(self, W: tensor) -> np.ndarray: """ Extract non-zero values at locations specified by mask tensor Parameters ---------- - W: :class:`pyttb.tensor` + W: Mask tensor Returns ------- - :class:`Numpy.ndarray` + Extracted values Examples -------- - >>> W = np.ones((2,2)) + >>> W = ttb.tensor.from_data(np.ones((2,2))) >>> tensor1 = ttb.tensor.from_data(np.array([[1, 2], [3, 4]])) >>> tensor1.mask(W) - array([[1, 2], [3, 4]]) + array([1, 3, 2, 4]) """ # Error checking if np.any(np.array(W.shape) > np.array(self.shape)): @@ -538,18 +640,32 @@ def mask(self, W): # Extract those non-zero values return self.data[tuple(wsubs.transpose())] - # TODO document and add example - def mttkrp(self, U, n): + # pylint: disable=too-many-branches + def mttkrp(self, U: Union[ttb.ktensor, List[np.ndarray]], n: int) -> np.ndarray: """ + Matricized tensor times Khatri-Rao product - :param U: - :param n: - :return: + Parameters + ---------- + U: Matrices to create the Khatri-Rao product + n: Mode to matricize tensor in + + Returns + ------- + Matrix product + + Example + ------- + >>> tensor1 = ttb.tensor.from_data(np.ones((2,2,2))) + >>> matrices = [np.ones((2,2))] * 3 + >>> tensor1.mttkrp(matrices, 2) + 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' + assert False, "MTTKRP is invalid for tensors with fewer than 2 dimensions" # extract the list of factor matrices if given a ktensor if isinstance(U, ttb.ktensor): @@ -563,11 +679,11 @@ def mttkrp(self, U, n): # check that we have a list (or list extracted from a ktensor) if not isinstance(U, list): - assert False, 'Second argument should be a list of arrays or a ktensor' + assert False, "Second argument should be a list of arrays or a ktensor" # check that list is the correct length if len(U) != self.ndims: - assert False, 'Second argument contains the wrong number of arrays' + assert False, "Second argument contains the wrong number of arrays" if n == 0: R = U[1].shape[1] @@ -579,97 +695,95 @@ def mttkrp(self, U, n): if i == n: continue if U[i].shape[0] != self.shape[i]: - assert False, 'Entry {} of list of arrays is wrong size'.format(i) + assert False, f"Entry {i} of list of arrays is wrong size" szl = int(np.prod(self.shape[0:n])) - szr = int(np.prod(self.shape[n+1:])) + szr = int(np.prod(self.shape[n + 1 :])) szn = self.shape[n] if n == 0: - Ur = ttb.khatrirao(U[1:self.ndims], reverse=True) - Y = np.reshape(self.data, (szn, szr), order='F') + Ur = ttb.khatrirao(U[1 : self.ndims], reverse=True) + Y = np.reshape(self.data, (szn, szr), order="F") return Y @ Ur - elif n == self.ndims - 1: - Ul = ttb.khatrirao(U[0:self.ndims - 1], reverse=True) - Y = np.reshape(self.data, (szl, szn), order='F') + if n == self.ndims - 1: # pylint: disable=no-else-return + Ul = ttb.khatrirao(U[0 : self.ndims - 1], reverse=True) + Y = np.reshape(self.data, (szl, szn), order="F") return Y.T @ Ul else: - Ul = ttb.khatrirao(U[n+1:], reverse=True) - Ur = np.reshape(ttb.khatrirao(U[0:self.ndims - 2], reverse=True), (szl, 1, R), order='F') - Y = np.reshape(self.data, (-1, szr), order='F') + Ul = ttb.khatrirao(U[n + 1 :], reverse=True) + Ur = np.reshape(ttb.khatrirao(U[0:n], reverse=True), (szl, 1, R), order="F") + Y = np.reshape(self.data, (-1, szr), order="F") Y = Y @ Ul - Y = np.reshape(Y, (szl, szn, R), order='F') + Y = np.reshape(Y, (szl, szn, R), order="F") V = np.zeros((szn, R)) for r in range(R): V[:, [r]] = Y[:, :, r].T @ Ur[:, :, r] return V @property - def ndims(self): + def ndims(self) -> int: """ Return the number of dimensions of a tensor - Returns - ------- - int + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> X.ndims + 2 """ if self.shape == (0,): return 0 - else: - return len(self.shape) + return len(self.shape) @property - def nnz(self): + def nnz(self) -> int: """ Number of non-zero elements in tensor - Returns - ------- - int: count + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> X.nnz + 4 """ return np.count_nonzero(self.data) - def norm(self): + def norm(self) -> np.floating: """ Frobenius Norm of Tensor - Returns - ------- - float + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> X.norm() + 2.0 """ - # default of np.linalg.norm is to vectorize the data and compute the vector norm, which is equivalent to - # the Frobenius norm for multidimensional arrays. However, the argument 'fro' only workks for 1-D and 2-D - # arrays currently. + # default of np.linalg.norm is to vectorize the data and compute the vector + # norm, which is equivalent to the Frobenius norm for multidimensional arrays. + # However, the argument 'fro' only works for 1-D and 2-D arrays currently. return np.linalg.norm(self.data) - def nvecs(self, n, r, flipsign=True): + def nvecs(self, n: int, r: int, flipsign: bool = True) -> np.ndarray: """ Compute the leading mode-n eigenvectors for a tensor - + Parameters ---------- - n: int - Mode to unfold - r: int - Number of eigenvectors to compute - flipsign: bool - Make each eigenvector's largest element positive - - Returns - ------- - :class:`Numpy.ndarray` + n: Mode to unfold + r: Number of eigenvectors to compute + flipsign: Make each eigenvector's largest element positive Examples -------- >>> tensor1 = ttb.tensor.from_data(np.array([[1, 2], [3, 4]])) - >>> tensor1.nvecs(0,1) - array([[0.40455358], - [0.9145143 ]]) - >>> tensor1.nvecs(0,2) - array([[ 0.40455358, 0.9145143 ], - [ 0.9145143 , -0.40455358]]) - """ - Xn =tt_to_dense_matrix(self, n) + >>> tensor1.nvecs(0,1) # doctest: +ELLIPSIS + array([[0.4045...], + [0.9145...]]) + >>> tensor1.nvecs(0,2) # doctest: +ELLIPSIS + array([[ 0.4045..., 0.9145...], + [ 0.9145..., -0.4045...]]) + """ + Xn = ttb.tenmat.from_tensor_type(self, rdims=np.array([n])).double() y = Xn @ Xn.T if r < y.shape[0] - 1: @@ -677,7 +791,10 @@ def nvecs(self, n, r, flipsign=True): v = v[:, (-np.abs(w)).argsort()] v = v[:, :r] else: - warnings.warn('Greater than or equal to tensor.shape[n] - 1 eigenvectors requires cast to dense to solve') + logging.debug( + "Greater than or equal to tensor.shape[n] - 1 eigenvectors" + " requires cast to dense to solve" + ) w, v = scipy.linalg.eigh(y) v = v[:, (-np.abs(w)).argsort()] v = v[:, :r] @@ -689,19 +806,24 @@ def nvecs(self, n, r, flipsign=True): v[:, i] *= -1 return v - def permute(self, order): + def permute(self, order: np.ndarray) -> tensor: """ Permute tensor dimensions. Parameters ---------- - order: :class:`Numpy.ndarray` + order: New order of tensor dimensions Returns ------- - :class:`pyttb.tensor` - shapeNew == shapePrevious[order] + Updated tensor with shapeNew == shapePrevious[order] + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> Y = X.permute(np.array((1,0))) + >>> X.isequal(Y) + True """ if self.ndims != order.size: assert False, "Invalid permutation order" @@ -717,69 +839,72 @@ def permute(self, order): # Np transpose does error checking on order, acts as permutation return ttb.tensor.from_data(np.transpose(self.data, order)) - # TODO should this be a property? - def reshape(self, *shape): + def reshape(self, shape: Tuple[int, ...]) -> tensor: """ Reshapes a tensor Parameters ---------- - *shape: tuple - """ + shape: New shape - if isinstance(shape[0], tuple): - shape = shape[0] + Examples + -------- + >>> X = ttb.tensor.from_data(np.ones((2,2))) + >>> Y = X.reshape((4,1)) + >>> Y.shape + (4, 1) + """ if np.prod(self.shape) != np.prod(shape): assert False, "Reshaping a tensor cannot change number of elements" - return ttb.tensor.from_data(np.reshape(self.data, shape, order='F'), shape) + return ttb.tensor.from_data(np.reshape(self.data, shape, order="F"), shape) - def squeeze(self): + def squeeze(self) -> Union[tensor, np.ndarray, float]: """ Removes singleton dimensions from a tensor Returns ------- - :class:`pyttb.tensor`, float + Tensor or scalar if all dims squeezed Examples -------- - >>> tensor1 = ttb.tensor.from_data(np.array([[[4]]]) + >>> tensor1 = ttb.tensor.from_data(np.array([[[4]]])) >>> tensor1.squeeze() - 4 + 4 >>> tensor2 = ttb.tensor.from_data(np.array([[1, 2, 3]])) >>> tensor2.squeeze().data - array([1, 2, 3]) + array([1, 2, 3]) """ shapeArray = np.array(self.shape) - if np.all(shapeArray > 1): + if np.all(shapeArray > 1): # pylint: disable=no-else-return return ttb.tensor.from_tensor_type(self) else: idx = np.where(shapeArray > 1) if idx[0].size == 0: - return self.data.copy() - else: - return ttb.tensor.from_data(np.squeeze(self.data)) + return np.squeeze(self.data)[()] + return ttb.tensor.from_data(np.squeeze(self.data)) - def symmetrize(self, grps=None, version=None): + def symmetrize( + self, grps: Optional[np.ndarray] = None, version: Optional[Any] = None + ) -> ( + tensor + ): # pylint: disable=too-many-branches, too-many-statements, too-many-locals """ Symmetrize a tensor in the specified modes - Notes ----- - It is *the same or less* work to just call X = symmetrize(X) then to first check if X is symmetric and then - symmetrize it, even if X is already symmetric. + It is *the same or less* work to just call X = symmetrize(X) then to first + check if X is symmetric and then symmetrize it, even if X is already symmetric. Parameters ---------- - grps - version - + grps: Modes to check for symmetry + version: Any non-None value will call the non-default old version Returns ------- - """ n = self.ndims sz = np.array(self.shape) @@ -792,10 +917,10 @@ def symmetrize(self, grps=None, version=None): data = self.data.copy() - if version is None: # Use default newer faster version + # Use default newer faster version + if version is None: # pylint: disable=no-else-return ngrps = len(grps) for i in range(0, ngrps): - # Extract current group thisgrp = grps[i] @@ -804,11 +929,12 @@ def symmetrize(self, grps=None, version=None): assert False, "Dimension mismatch for symmetrization" # Check for no overlap in the sets - if i < ngrps-1: - if not np.intersect1d(thisgrp, grps[i+1:, :]).size == 0: + if i < ngrps - 1: + if not np.intersect1d(thisgrp, grps[i + 1 :, :]).size == 0: assert False, "Cannot have overlapping symmetries" - # Construct matrix ind where each row is the multi-index for one element of tensor + # Construct matrix ind where each row is the multi-index for one + # element of tensor idx = ttb.tt_ind2sub(self.shape, np.arange(0, data.size)) # Find reference index for every element in the tensor - this @@ -827,10 +953,10 @@ def symmetrize(self, grps=None, version=None): # Take average over all elements in the same class classSum = accumarray(linclassidx, data.ravel()) classNum = accumarray(linclassidx, 1) - # We ignore this division error state because if we don't have an entry in linclassidx we won't - # reference the inf or nan in the slice below - with np.errstate(divide='ignore', invalid='ignore'): - avg = classSum/classNum + # We ignore this division error state because if we don't have an entry + # in linclassidx we won't reference the inf or nan in the slice below + with np.errstate(divide="ignore", invalid="ignore"): + avg = classSum / classNum newdata = avg[linclassidx] data = np.reshape(newdata, self.shape) @@ -838,7 +964,6 @@ def symmetrize(self, grps=None, version=None): return ttb.tensor.from_data(data) else: # Original version - # Check tensor dimensions for compatibility with symmetrization ngrps = len(grps) for i in range(0, ngrps): @@ -849,7 +974,7 @@ def symmetrize(self, grps=None, version=None): # Check for no overlap in sets for i in range(0, ngrps): - for j in range(i+1, ngrps): + for j in range(i + 1, ngrps): if not np.intersect1d(grps[i, :], grps[j, :]).size == 0: assert False, "Cannot have overlapping symmetries" @@ -857,7 +982,7 @@ def symmetrize(self, grps=None, version=None): combos = [] for i in range(0, ngrps): combos.append(np.array(list(permutations(grps[i, :])))) - combos = np.array(combos) + combos = np.stack(combos) # Create all the permuations to be averaged combo_lengths = [len(perm) for perm in combos] @@ -865,15 +990,17 @@ def symmetrize(self, grps=None, version=None): sym_perms = np.tile(np.arange(0, n), [total_perms, 1]) for i in range(0, ngrps): ntimes = np.prod(combo_lengths[0:i], dtype=int) - ncopies = np.prod(combo_lengths[i+1:], dtype=int) + ncopies = np.prod(combo_lengths[i + 1 :], dtype=int) nelems = len(combos[i]) - idx = 0 + perm_idx = 0 for j in range(0, ntimes): for k in range(0, nelems): - for l in range(0, ncopies): - sym_perms[idx, grps[i]] = combos[i][k, :] - idx += 1 + for _ in range(0, ncopies): + # TODO: Does this do anything? Matches MATLAB + # at very least should be able to flatten + sym_perms[perm_idx, grps[i]] = combos[i][k, :] + perm_idx += 1 # Create an average tensor Y = ttb.tensor.from_data(np.zeros(self.shape)) @@ -892,40 +1019,160 @@ def symmetrize(self, grps=None, version=None): return Y - def ttv(self, vector, dims=None): + def ttm( + self, + matrix: Union[np.ndarray, List[np.ndarray]], + dims: Optional[Union[float, np.ndarray]] = None, + exclude_dims: Optional[Union[int, np.ndarray]] = None, + transpose: bool = False, + ) -> tensor: + """ + Tensor times matrix + + Parameters + ---------- + matrix: Matrix or matrices to multiple by + dims: Dimensions to multiply against + exclude_dims: Use all dimensions but these + transpose: Transpose matrices during multiplication + """ + if dims is None and exclude_dims is None: + dims = np.arange(self.ndims) + elif isinstance(dims, list): + dims = np.array(dims) + elif isinstance(dims, (float, int, np.generic)): + dims = np.array([dims]) + + if isinstance(exclude_dims, (float, int)): + exclude_dims = np.array([exclude_dims]) + + if isinstance(matrix, list): + # Check that the dimensions are valid + dims, vidx = ttb.tt_dimscheck(self.ndims, len(matrix), dims, exclude_dims) + + # Calculate individual products + Y = self.ttm(matrix[vidx[0]], dims[0], transpose=transpose) + for k in range(1, dims.size): + Y = Y.ttm(matrix[vidx[k]], dims[k], transpose=transpose) + return Y + + if not isinstance(matrix, np.ndarray): + assert False, f"matrix must be of type numpy.ndarray but got:\n{matrix}" + + dims, _ = ttb.tt_dimscheck(self.ndims, dims=dims, exclude_dims=exclude_dims) + + if not (dims.size == 1 and np.isin(dims, np.arange(self.ndims))): + assert False, "dims must contain values in [0,self.dims)" + + # old version (ver=0) + shape = np.array(self.shape, dtype=int) + n = dims[0] + order = np.array([n] + list(range(0, n)) + list(range(n + 1, self.ndims))) + newdata = self.permute(order).data + ids = np.array(list(range(0, n)) + list(range(n + 1, self.ndims))) + second_dim = 1 + if len(ids) > 0: + second_dim = np.prod(shape[ids]) + newdata = np.reshape(newdata, (shape[n], second_dim), order="F") + if transpose: + newdata = matrix.T @ newdata + p = matrix.shape[1] + else: + newdata = matrix @ newdata + p = matrix.shape[0] + + newshape = np.array( + [p] + list(shape[range(0, n)]) + list(shape[range(n + 1, self.ndims)]) + ) + Y_data = np.reshape(newdata, newshape, order="F") + Y_data = np.transpose(Y_data, np.argsort(order)) + return ttb.tensor.from_data(Y_data) + + def ttt( + self, + other: tensor, + selfdims: Optional[Union[int, np.ndarray]] = None, + otherdims: Optional[Union[int, np.ndarray]] = None, + ) -> tensor: + """ + Tensor multiplication (tensor times tensor) + + Parameters + ---------- + other: Tensor to multiply by + selfdims: Dimensions to contract this tensor by for multiplication + otherdims: Dimensions to contract other tensor by for multiplication + """ + + if not isinstance(other, tensor): + assert False, "other must be of type tensor" + + if selfdims is None: + selfdims = np.array([], dtype=int) + elif isinstance(selfdims, int): + selfdims = np.array([selfdims]) + selfshape = tuple(np.array(self.shape)[selfdims]) + + if otherdims is None: + otherdims = selfdims.copy() + elif isinstance(otherdims, int): + otherdims = np.array([otherdims]) + othershape = tuple(np.array(other.shape)[otherdims]) + + if np.any(selfshape != othershape): + assert ( + False + ), f"Specified dimensions do not match got {selfshape} and {othershape}" + + # Compute the product + + # Avoid transpose by reshaping self and computing result = self * other + amatrix = ttb.tenmat.from_tensor_type(self, cdims=selfdims) + bmatrix = ttb.tenmat.from_tensor_type(other, rdims=otherdims) + cmatrix = amatrix * bmatrix + + # Check whether or not the result is a scalar + if isinstance(cmatrix, ttb.tenmat): + return ttb.tensor.from_tensor_type(cmatrix) + return cmatrix + + def ttv( + self, + vector: Union[np.ndarray, List[np.ndarray]], + dims: Optional[Union[int, np.ndarray]] = None, + exclude_dims: Optional[Union[int, np.ndarray]] = None, + ) -> tensor: """ Tensor times vector Parameters ---------- - vector: :class:`Numpy.ndarray`, list[:class:`Numpy.ndarray`] - dims: :class:`Numpy.ndarray`, int + vector: Vector(s) to multiply against + dims: Dimensions to multiply with vector(s) + exclude_dims: Use all dimensions but these """ - if dims is None: + if dims is None and exclude_dims is None: dims = np.array([]) elif isinstance(dims, (float, int)): dims = np.array([dims]) - # Check that vector is a list of vectors, if not place single vector as element in list - if len(vector.shape) == 1 and isinstance(vector[0], (int, float, np.int_, np.float_)): - return self.ttv(np.array([vector]), dims) + if isinstance(exclude_dims, (float, int)): + exclude_dims = np.array([exclude_dims]) + + # Check that vector is a list of vectors, if not place single vector as element + # in list + if len(vector) > 0 and isinstance(vector[0], (int, float, np.int_, np.float_)): + return self.ttv(np.array([vector]), dims, exclude_dims) # Get sorted dims and index for multiplicands - dims, vidx = ttb.tt_dimscheck(dims, self.ndims, vector.shape[0]) + dims, vidx = ttb.tt_dimscheck(self.ndims, len(vector), dims, exclude_dims) # Check that each multiplicand is the right size. for i in range(dims.size): - if vector[vidx[i]].shape != (self.shape[dims[i]], ): + if vector[vidx[i]].shape != (self.shape[dims[i]],): assert False, "Multiplicand is wrong size" - # TODO: not sure what this special case handles - #if exist('tensor/ttv_single', 'file') == 3: - # c = a - # for i = numel(dims): -1: 1 - # c = ttv_single(c, v{vidx(i)}, dims(i)) - # return c - # Extract the data c = self.data.copy() @@ -934,67 +1181,72 @@ def ttv(self, vector, dims=None): if self.ndims > 1: c = np.transpose(c, np.concatenate((remdims, dims))) - ## Do each multiply in sequence, doing the highest index first, which is important for vector multiplies. + # Do each multiply in sequence, doing the highest index first, which is + # important for vector multiplies. n = self.ndims sz = np.array(self.shape)[np.concatenate((remdims, dims))] - for i in range(dims.size-1, -1, -1): - c = np.reshape(c, tuple([np.prod(sz[0:n-1]), sz[n-1]])) + for i in range(dims.size - 1, -1, -1): + c = np.reshape(c, tuple([np.prod(sz[0 : n - 1]), sz[n - 1]]), order="F") c = c.dot(vector[vidx[i]]) n -= 1 # If needed, convert the final result back to tensor if n > 0: return ttb.tensor.from_data(c, tuple(sz[0:n])) - else: - return c[0] + return c[0] - def ttsv(self, vector, dims=None, version = None): + def ttsv( + self, + vector: Union[np.ndarray, List[np.ndarray]], + skip_dim: Optional[int] = None, + version: Optional[int] = None, + ) -> Union[np.ndarray, tensor]: """ Tensor times same vector in multiple modes Parameters ---------- - vector: :class:`Numpy.ndarray`, list[:class:`Numpy.ndarray`] - dims: :class:`Numpy.ndarray`, int + vector: Vector(s) to multiply against + skip_dim: Multiply tensor by vector in all dims except [0, skip_dim] """ # Only two simple cases are supported - if dims is None: - dims = 0 - elif dims > 0: - assert False, "Invalid modes in ttsv" + if skip_dim is None: + exclude_dims = None + skip_dim = -1 # For easier math later + elif skip_dim < 0: + raise ValueError("Invalid modes in ttsv") + else: + exclude_dims = np.arange(0, skip_dim + 1) - if version is not None: # Calculate the old way + if version == 1: # Calculate the old way P = self.ndims X = np.array([vector for i in range(P)]) - if dims == 0: - return self.ttv(X) - elif (dims == -1) or (dims == -2): # Return scalar or matrix - return (self.ttv(X, -np.arange(1, -dims+1))).double() - else: - return self.ttv(X, -np.arange(1, -dims+1)) + if skip_dim in (0, 1): # Return scalar or matrix + return self.ttv(X, exclude_dims=exclude_dims).double() + return self.ttv(X, exclude_dims=exclude_dims) - else: # Calculate the new way - if dims != 0: - assert False, "New version only support vector times all modes" + if version == 2 or version is None: # Calculate the new way d = self.ndims sz = self.shape[0] # Sizes of all modes must be the same - dnew = -dims # Number of modes in result + # pylint: disable=invalid-unary-operand-type + dnew = skip_dim + 1 # Number of modes in result drem = d - dnew # Number of modes multiplied out y = self.data for i in range(drem, 0, -1): - yy = np.reshape(y, (sz**(dnew + i -1), sz)) + yy = np.reshape(y, (sz ** (dnew + i - 1), sz), order="F") y = yy.dot(vector) # Convert to matrix if 2-way or convert back to tensor if result is >= 3-way - # TODO: currently this only support scalar return so these are ignored in coverage - if dnew == 2: # pragma: no cover - return np.reshape(y, [sz, sz]) - elif dnew > 2: # pragma: no cover - return ttb.tensor.from_data(np.reshape(y, sz*np.ones(dnew))) - else: - return y + if dnew == 2: + return np.reshape(y, [sz, sz], order="F") + if dnew > 2: + return ttb.tensor.from_data( + np.reshape(y, newshape=sz * np.ones(dnew, dtype=int), order="F") + ) + return y + assert False, "Invalid value for version; should be None, 1, or 2" def __setitem__(self, key, value): """ @@ -1022,114 +1274,151 @@ def __setitem__(self, key, value): X(1,1,2:3) = 1 <-- grows tensor X(1,1,4) = 1 %<- grows the size of the tensor """ - # Figure out if we are doing a subtensor, a list of subscripts or a list of linear indices - type = 'error' - if self.ndims <= 1: - if isinstance(key, np.ndarray): - type = 'subscripts' - else: - type = 'subtensor' + # Figure out if we are doing a subtensor, a list of subscripts or a list of + # linear indices + access_type = "error" + # TODO pull out this big decision tree into a function + if isinstance(key, (float, int, np.generic, slice)): + access_type = "linear indices" + elif self.ndims <= 1: + if isinstance(key, tuple): + access_type = "subtensor" + elif isinstance(key, np.ndarray): + access_type = "subscripts" else: if isinstance(key, np.ndarray): - if (len(key.shape) > 1 and key.shape[1] >= self.ndims): - type = 'subscripts' + if len(key.shape) > 1 and key.shape[1] >= self.ndims: + access_type = "subscripts" elif len(key.shape) == 1 or key.shape[1] == 1: - type = 'linear indices' + access_type = "linear indices" elif isinstance(key, tuple): - validSubtensor = [isinstance(keyElement, (int, slice)) for keyElement in key] + validSubtensor = [ + isinstance(keyElement, (int, slice, Iterable)) for keyElement in key + ] if np.all(validSubtensor): - type = 'subtensor' - + access_type = "subtensor" + elif isinstance(key, Iterable): + key = np.array(key) + if len(key.shape) == 1 or key.shape[1] == 1: + access_type = "linear indices" # Case 1: Rectangular Subtensor - if type == 'subtensor': - # Extract array of subscripts - subs = key - - # Will the size change? If so we first need to resize x - n = self.ndims - sliceCheck = [] - for element in subs: - if isinstance(element, slice): - if element.stop == None: - sliceCheck.append(1) - else: - sliceCheck.append(element.stop) - else: - sliceCheck.append(element) - bsiz = np.array(sliceCheck) - if n == 0: - newsiz = (bsiz[n:] + 1).astype(int) - else: - newsiz = np.concatenate((np.max((self.shape, bsiz[0:n] + 1), axis=0), bsiz[n :] + 1)).astype(int) - if (newsiz != self.shape).any(): - # We need to enlarge x.data. - newData = np.zeros(shape=tuple(newsiz)) - idx = [slice(None, currentShape) for currentShape in self.shape] - if self.data.size > 0: - newData[tuple(idx)] = self.data - self.data = newData + if access_type == "subtensor": + return self._set_subtensor(key, value) - self.shape = tuple(newsiz) - if isinstance(value, ttb.tensor): - self.data[key] = value.data - else: - self.data[key] = value + # Case 2a: Subscript indexing + if access_type == "subscripts": + return self._set_subscripts(key, value) - return + # Case 2b: Linear Indexing + if access_type == "linear indices": + return self._set_linear(key, value) + + assert False, "Invalid use of tensor setitem" + + def _set_linear(self, key, value): + idx = key + if not isinstance(idx, slice) and (idx > np.prod(self.shape)).any(): + assert ( + False + ), "TTB:BadIndex In assignment X[I] = Y, a tensor X cannot be resized" + if isinstance(key, (int, float, np.generic)): + idx = np.array([key]) + elif isinstance(key, slice): + idx = np.array(range(np.prod(self.shape))[key]) + idx = tt_ind2sub(self.shape, idx) + if idx.shape[0] == 1: + self.data[tuple(idx[0, :])] = value + else: + actualIdx = tuple(idx.transpose()) + self.data[actualIdx] = value - # Case 2a: Subscript indexing - if type == 'subscripts': - # Extract array of subscripts - subs = key - - # Will the size change? If so we first need to resize x - n = self.ndims - if len(subs.shape) == 1 and len(self.shape) == 1 and self.shape[0] < subs.shape[0]: - bsiz = subs - elif len(subs.shape) == 1: - bsiz = np.array([np.max(subs, axis=0)]) - key = key.tolist() - else: - bsiz = np.array(np.max(subs, axis=0)) - if n == 0: - newsiz = (bsiz[n:] + 1).astype(int) + def _set_subtensor(self, key, value): + # Extract array of subscripts + subs = key + # Will the size change? If so we first need to resize x + n = self.ndims + sliceCheck = [] + for element in subs: + if isinstance(element, slice): + if element.stop is None: + sliceCheck.append(1) + else: + sliceCheck.append(element.stop) + elif isinstance(element, Iterable): + if any( + not isinstance(entry, (float, int, np.generic)) for entry in element + ): + raise ValueError( + f"Entries for setitem must be numeric but recieved, {element}" + ) + sliceCheck.append(max(element)) else: - newsiz = np.concatenate((np.max((self.shape, bsiz[0:n] + 1), axis=0), bsiz[n:] + 1)).astype(int) - - if (newsiz != self.shape).any(): - # We need to enlarge x.data. - newData = np.zeros(shape=tuple(newsiz)) + sliceCheck.append(element) + bsiz = np.array(sliceCheck) + if n == 0: + newsiz = (bsiz[n:] + 1).astype(int) + else: + newsiz = np.concatenate( + (np.max((self.shape, bsiz[0:n] + 1), axis=0), bsiz[n:] + 1) + ).astype(int) + if not np.array_equal(newsiz, self.shape): + # We need to enlarge x.data. + newData = np.zeros(shape=tuple(newsiz)) + if self.data.size > 0: idx = [slice(None, currentShape) for currentShape in self.shape] - if self.data.size > 0: - newData[idx] = self.data - self.data = newData + idx.extend([0] * (len(newsiz) - self.ndims)) + newData[tuple(idx)] = self.data + self.data = newData - self.shape = tuple(newsiz) + self.shape = tuple(newsiz) + if isinstance(value, ttb.tensor): + self.data[key] = value.data + else: + self.data[key] = value - # Finally we can copy in new data - if isinstance(key, list): - self.data[key] = value - elif key.shape[0] == 1: # and len(key.shape) == 1: - self.data[tuple(key[0, :])] = value - else: - self.data[tuple(key)] = value - return + def _set_subscripts(self, key, value): + # Extract array of subscripts + subs = key - # Case 2b: Linear Indexing - if type == 'linear indices': - idx = key - if (idx > np.prod(self.shape)).any(): - assert False, 'TTB:BadIndex In assignment X[I] = Y, a tensor X cannot be resized' - idx = tt_ind2sub(self.shape, idx) - if idx.shape[0] == 1: - self.data[tuple(idx[0, :])] = value - else: - actualIdx = tuple(idx.transpose()) - self.data[actualIdx] = value - return + # Will the size change? If so we first need to resize x + n = self.ndims + if ( + len(subs.shape) == 1 + and len(self.shape) == 1 + and self.shape[0] < subs.shape[0] + ): + bsiz = subs + elif len(subs.shape) == 1: + bsiz = np.array([np.max(subs, axis=0)]) + key = key.tolist() + else: + bsiz = np.array(np.max(subs, axis=0)) + if n == 0: + newsiz = (bsiz[n:] + 1).astype(int) + else: + newsiz = np.concatenate( + (np.max((self.shape, bsiz[0:n] + 1), axis=0), bsiz[n:] + 1) + ).astype(int) + + if not np.array_equal(newsiz, self.shape): + # We need to enlarge x.data. + newData = np.zeros(shape=tuple(newsiz)) + if self.data.size > 0: + idx = [slice(None, currentShape) for currentShape in self.shape] + idx.extend([0] * (len(newsiz) - self.ndims)) + newData[tuple(idx)] = self.data + self.data = newData - assert False, 'Invalid use of tensor setitem' + self.shape = tuple(newsiz) + + # Finally we can copy in new data + if isinstance(key, list): + self.data[key] = value + elif key.shape[0] == 1: # and len(key.shape) == 1: + self.data[tuple(key[0, :])] = value + else: + self.data[tuple(key)] = value def __getitem__(self, item): """ @@ -1174,8 +1463,23 @@ def __getitem__(self, item): ------- :class:`pyttb.tensor` or :class:`numpy.ndarray` """ + # Case 0: Single Index Linear + if isinstance(item, (int, float, np.generic, slice)): + if isinstance(item, (int, float, np.generic)): + idx = np.array(item) + elif isinstance(item, slice): + idx = np.array(range(np.prod(self.shape))[item]) + a = np.squeeze( + self.data[tuple(ttb.tt_ind2sub(self.shape, idx).transpose())] + ) + # Todo if row make column? + return ttb.tt_subsubsref(a, idx) # Case 1: Rectangular Subtensor - if isinstance(item, tuple) and len(item) == self.ndims and item[len(item) - 1] != 'extract': + if ( + isinstance(item, tuple) + and len(item) == self.ndims + and item[len(item) - 1] != "extract" + ): # Copy the subscripts region = item @@ -1184,17 +1488,16 @@ def __getitem__(self, item): # Determine the subscripts newsiz = [] # future new size - kpdims = [] # dimensions to keep - rmdims = [] # dimensions to remove + kpdims = [] # dimensions to keep + rmdims = [] # dimensions to remove # Determine the new size and what dimensions to keep - # Determine the new size and what dimensions to keep - for i in range(0, len(region)): - if isinstance(region[i], slice): + for i, a_region in enumerate(region): + if isinstance(a_region, slice): newsiz.append(self.shape[i]) kpdims.append(i) - elif not isinstance(region[i], int) and len(region[i]) > 1: - newsiz.append(np.prod(region[i])) + elif not isinstance(a_region, int) and len(a_region) > 1: + newsiz.append(np.prod(a_region)) kpdims.append(i) else: rmdims.append(i) @@ -1205,32 +1508,35 @@ def __getitem__(self, item): # If the size is zero, then the result is returned as a scalar # otherwise, we convert the result to a tensor - if newsiz.size == 0: a = newdata else: - if rmdims.size == 0: - a = ttb.tensor.from_data(newdata) - else: - # If extracted data is a vector then no need to tranpose it - if len(newdata.shape) == 1: - a = ttb.tensor.from_data(newdata) - else: - a = ttb.tensor.from_data(np.transpose(newdata, np.concatenate((kpdims, rmdims)))) - return ttb.tt_subsubsref(a, item) + a = ttb.tensor.from_data(newdata) + return a # *** CASE 2a: Subscript indexing *** - if len(item) > 1 and isinstance(item[-1], str) and item[-1] == 'extract': + if isinstance(item, np.ndarray) and len(item) > 1: # Extract array of subscripts + subs = np.array(item) + a = np.squeeze(self.data[tuple(subs)]) + # TODO if is row make column? + return ttb.tt_subsubsref(a, subs) + if ( + len(item) > 1 + and isinstance(item[0], np.ndarray) + and isinstance(item[-1], str) + and item[-1] == "extract" + ): + # TODO dry this up subs = np.array(item[0]) - a = np.squeeze(self.data[tuple(subs.transpose())]) + a = np.squeeze(self.data[tuple(subs)]) # TODO if is row make column? return ttb.tt_subsubsref(a, subs) # Case 2b: Linear Indexing - if len(item) >= 2 and not isinstance(item[-1], str): - assert False, 'Linear indexing requires single input array' - idx = item[0] + if isinstance(item, tuple) and len(item) >= 2 and not isinstance(item[-1], str): + assert False, "Linear indexing requires single input array" + idx = np.array(item) a = np.squeeze(self.data[tuple(ttb.tt_ind2sub(self.shape, idx).transpose())]) # Todo if row make column? return ttb.tt_subsubsref(a, idx) @@ -1247,7 +1553,8 @@ def __eq__(self, other): ------- :class:`pyttb.tensor` """ - def tensor_equality(x,y): + + def tensor_equality(x, y): return x == y return ttb.tt_tenfun(tensor_equality, self, other) @@ -1264,10 +1571,11 @@ def __ne__(self, other): ------- :class:`pyttb.tensor` """ - def tensor_notEqual(x, y): + + def tensor_not_equal(x, y): return x != y - return ttb.tt_tenfun(tensor_notEqual, self, other) + return ttb.tt_tenfun(tensor_not_equal, self, other) def __ge__(self, other): """ @@ -1281,10 +1589,11 @@ def __ge__(self, other): ------- :class:`pyttb.tensor` """ - def ge(x, y): + + def greater_or_equal(x, y): return x >= y - return ttb.tt_tenfun(ge, self, other) + return ttb.tt_tenfun(greater_or_equal, self, other) def __le__(self, other): """ @@ -1298,10 +1607,11 @@ def __le__(self, other): ------- :class:`pyttb.tensor` """ - def le(x, y): + + def less_or_equal(x, y): return x <= y - return ttb.tt_tenfun(le, self, other) + return ttb.tt_tenfun(less_or_equal, self, other) def __gt__(self, other): """ @@ -1316,10 +1626,10 @@ def __gt__(self, other): :class:`pyttb.tensor` """ - def gt(x, y): + def greater(x, y): return x > y - return ttb.tt_tenfun(gt, self, other) + return ttb.tt_tenfun(greater, self, other) def __lt__(self, other): """ @@ -1334,10 +1644,10 @@ def __lt__(self, other): :class:`pyttb.tensor` """ - def lt(x, y): + def less(x, y): return x < y - return ttb.tt_tenfun(lt, self, other) + return ttb.tt_tenfun(less, self, other) def __sub__(self, other): """ @@ -1351,8 +1661,9 @@ def __sub__(self, other): ------- :class:`pyttb.tensor` """ + def minus(x, y): - return x-y + return x - y return ttb.tt_tenfun(minus, self, other) @@ -1380,7 +1691,7 @@ def tensor_add(x, y): def __radd__(self, other): """ - Reverse binary addition (+) for tensors + Right binary addition (+) for tensors Parameters ---------- @@ -1423,8 +1734,9 @@ def __mul__(self, other): ------- :class:`pyttb.tensor` """ + def mul(x, y): - return x*y + return x * y if isinstance(other, (ttb.ktensor, ttb.sptensor, ttb.ttensor)): other = other.full() @@ -1457,10 +1769,12 @@ def __truediv__(self, other): ------- :class:`pyttb.tensor` """ + def div(x, y): - # We ignore the divide by zero errors because np.inf/np.nan is an appropriate representation - with np.errstate(divide='ignore', invalid='ignore'): - return x/y + # We ignore the divide by zero errors because np.inf/np.nan is an + # appropriate representation + with np.errstate(divide="ignore", invalid="ignore"): + return x / y return ttb.tt_tenfun(div, self, other) @@ -1476,10 +1790,12 @@ def __rtruediv__(self, other): ------- :class:`pyttb.tensor` """ + def div(x, y): - # We ignore the divide by zero errors because np.inf/np.nan is an appropriate representation - with np.errstate(divide='ignore', invalid='ignore'): - return x/y + # We ignore the divide by zero errors because np.inf/np.nan is an + # appropriate representation + with np.errstate(divide="ignore", invalid="ignore"): + return x / y return ttb.tt_tenfun(div, other, self) @@ -1505,7 +1821,7 @@ def __neg__(self): copy of tensor """ - return ttb.tensor.from_data(-1*self.data) + return ttb.tensor.from_data(-1 * self.data) def __repr__(self): """ @@ -1517,41 +1833,154 @@ def __repr__(self): Contains the shape and data as strings on different lines. """ if self.ndims == 0: - s = '' - s += 'empty tensor of shape ' + s = "" + s += "empty tensor of shape " s += str(self.shape) - s += '\n' - s += 'data = []' + s += "\n" + s += "data = []" return s - s = '' - s += 'tensor of shape ' - s += (' x ').join([str(int(d)) for d in self.shape]) - s += '\n' + s = "" + s += "tensor of shape " + s += (" x ").join([str(int(d)) for d in self.shape]) + s += "\n" if self.ndims == 1: - s += 'data' + s += "data" if self.ndims == 1: - s += '[:]' - s += ' = \n' + s += "[:]" + s += " = \n" s += str(self.data) - s += '\n' + s += "\n" return s - for i, j in enumerate(range(0, np.prod(self.shape), self.shape[-1]*self.shape[-2])): - s += 'data' + for i in np.arange(np.prod(self.shape[:-2])): + s += "data" if self.ndims == 2: - s += '[:, :]' - s += ' = \n' + s += "[:, :]" + s += " = \n" s += str(self.data) - s += '\n' + s += "\n" elif self.ndims > 2: idx = ttb.tt_ind2sub(self.shape[:-2], np.array([i])) s += str(idx[0].tolist())[0:-1] - s += ', :, :]' - s += ' = \n' - s += str(self.data[tuple(np.concatenate((idx[0], np.array([slice(None), slice(None)]))))]) - s += '\n' - #s += '\n' + s += ", :, :]" + s += " = \n" + s += str( + self.data[ + tuple( + np.concatenate( + (idx[0], np.array([slice(None), slice(None)])) + ) + ) + ] + ) + s += "\n" + # s += '\n' return s __str__ = __repr__ + + +def tenones(shape: Tuple[int, ...]) -> tensor: + """ + Creates a tensor of all ones + + Parameters + ---------- + shape: Shape of resulting tensor + + Returns + ------- + Constructed tensor + + Example + ------- + >>> X = ttb.tenones((2,2)) + """ + return tensor.from_function(np.ones, shape) + + +def tenzeros(shape: Tuple[int, ...]) -> tensor: + """ + Creates a tensor of all zeros + + Parameters + ---------- + shape: Shape of resulting tensor + + Returns + ------- + Constructed tensor + + Example + ------- + >>> X = ttb.tenzeros((2,2)) + """ + return tensor.from_function(np.zeros, shape) + + +def tenrand(shape: Tuple[int, ...]) -> tensor: + """ + Creates a tensor with entries drawn from a uniform distribution on the unit interval + + Parameters + ---------- + shape: Shape of resulting tensor + + Returns + ------- + Constructed tensor + + Example + ------- + >>> X = ttb.tenrand((2,2)) + """ + + # Typing doesn't play nice with partial + # mypy issue: 1484 + def unit_uniform(pass_through_shape: Tuple[int, ...]) -> np.ndarray: + return np.random.uniform(low=0, high=1, size=pass_through_shape) + + return tensor.from_function(unit_uniform, shape) + + +def tendiag(elements: np.ndarray, shape: Optional[Tuple[int, ...]] = None) -> tensor: + """ + Creates a tensor with elements along super diagonal + If provided shape is too small the tensor will be enlarged to accomodate + + Parameters + ---------- + elements: Elements to set along the diagonal + shape: Shape of resulting tensor + + Returns + ------- + Constructed tensor + + Example + ------- + >>> shape = (2,) + >>> values = np.ones(shape) + >>> X = ttb.tendiag(values) + >>> Y = ttb.tendiag(values, (2, 2)) + >>> X.isequal(Y) + True + """ + # Flatten provided elements + elements = np.ravel(elements) + N = len(elements) + if shape is None: + constructed_shape = (N,) * N + else: + constructed_shape = tuple(max(N, dim) for dim in shape) + X = tenzeros(constructed_shape) + subs = np.tile(np.arange(0, N).transpose(), (len(constructed_shape), 1)) + X[subs] = elements + return X + + +if __name__ == "__main__": + import doctest # pragma: no cover + + doctest.testmod() # pragma: no cover diff --git a/pyttb/ttensor.py b/pyttb/ttensor.py index 2aa461bb..50b080de 100644 --- a/pyttb/ttensor.py +++ b/pyttb/ttensor.py @@ -2,9 +2,18 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb -from .pyttb_utils import * +import logging +import textwrap + import numpy as np +import scipy + +from pyttb import ktensor +from pyttb import pyttb_utils as ttb_utils +from pyttb import sptenmat, sptensor, tenmat, tensor + +ALT_CORE_ERROR = "TTensor doesn't support non-tensor cores yet" + class ttensor(object): """ @@ -12,5 +21,569 @@ class ttensor(object): """ - def __init__(self, *args): # pragma:no cover - assert False, "TTENSOR class not yet implemented" + def __init__(self): + """ + Create an empty decomposed tucker tensor + + Returns + ------- + :class:`pyttb.ttensor` + """ + # Empty constructor + self.core = tensor() + self.u = [] + + @classmethod + def from_data(cls, core, factors): + """ + Construct an ttensor from fully defined core tensor and factor matrices. + + Parameters + ---------- + core: :class: `ttb.tensor` + factors: :class:`list(numpy.ndarray)` + + Returns + ------- + :class:`pyttb.ttensor` + + Examples + -------- + Import required modules: + + >>> import pyttb as ttb + >>> import numpy as np + + Set up input data + # Create ttensor with explicit data description + + >>> core_values = np.ones((2,2,2)) + >>> core = ttb.tensor.from_data(core_values) + >>> factors = [np.ones((1,2))] * len(core_values.shape) + >>> K0 = ttb.ttensor.from_data(core, factors) + """ + ttensorInstance = ttensor() + if isinstance(core, tensor): + ttensorInstance.core = tensor.from_data(core.data, core.shape) + ttensorInstance.u = factors.copy() + else: + # TODO support any tensor type with supported ops + raise ValueError("TTENSOR doesn't yet support generic cores, only tensor") + ttensorInstance._validate_ttensor() + return ttensorInstance + + @classmethod + def from_tensor_type(cls, source): + """ + Converts other tensor types into a ttensor + + Parameters + ---------- + source: :class:`pyttb.ttensor` + + Returns + ------- + :class:`pyttb.ttensor` + """ + # Copy Constructor + if isinstance(source, ttensor): + return cls.from_data(source.core, source.u) + + def _validate_ttensor(self): + """ + Verifies the validity of constructed ttensor + + Returns + ------- + """ + # Confirm all factors are matrices + for factor_idx, factor in enumerate(self.u): + if not isinstance(factor, np.ndarray): + raise ValueError( + f"Factor matrices must be numpy arrays but factor {factor_idx} was {type(factor)}" + ) + if len(factor.shape) != 2: + raise ValueError( + f"Factor matrix {factor_idx} has shape {factor.shape} and is not a matrix!" + ) + + # Verify size consistency + core_order = len(self.core.shape) + num_matrices = len(self.u) + if core_order != num_matrices: + raise ValueError( + f"CORE has order {core_order} but there are {num_matrices} factors" + ) + for factor_idx, factor in enumerate(self.u): + if factor.shape[-1] != self.core.shape[factor_idx]: + raise ValueError( + f"Factor matrix {factor_idx} does not have {self.core.shape[factor_idx]} columns" + ) + + @property + def shape(self): + """ + Shape of the tensor this deconstruction represents. + + Returns + ------- + tuple(int) + """ + return tuple(factor.shape[0] for factor in self.u) + + def __repr__(self): # pragma: no cover + """ + String representation of a tucker tensor. + + Returns + ------- + str + Contains the core, and factor matrices as strings on different lines. + """ + display_string = f"Tensor of shape: {self.shape}\n" f"\tCore is a " + display_string += textwrap.indent(str(self.core), "\t") + + for factor_idx, factor in enumerate(self.u): + display_string += f"\tU[{factor_idx}] = \n" + display_string += textwrap.indent(str(factor), "\t\t") + display_string += "\n" + return display_string + + __str__ = __repr__ + + def full(self): + """ + Convert a ttensor to a (dense) tensor. + + Returns + ------- + :class:`pyttb.tensor` + """ + recomposed_tensor = self.core.ttm(self.u) + + # There is a small chance tensor could be sparse so ensure we cast that to dense. + if not isinstance(recomposed_tensor, tensor): + raise ValueError(ALT_CORE_ERROR) + return recomposed_tensor + + def double(self): + """ + Convert ttensor to an array of doubles + + Returns + ------- + :class:`numpy.ndarray` + copy of tensor data + """ + return self.full().double() + + @property + def ndims(self): + """ + Number of dimensions of a ttensor. + + Returns + ------- + int + Number of dimensions of ttensor + """ + return len(self.u) + + def isequal(self, other): + """ + Component equality for ttensors + + Parameters + ---------- + other: :class:`pyttb.ttensor` + + Returns + ------- + bool: True if ttensors decompositions are identical, false otherwise + """ + if not isinstance(other, ttensor): + return False + if self.ndims != other.ndims: + return False + return self.core.isequal(other.core) and all( + np.array_equal(this_factor, other_factor) + for this_factor, other_factor in zip(self.u, other.u) + ) + + def __pos__(self): + """ + Unary plus (+) for ttensors. Does nothing. + + Returns + ------- + :class:`pyttb.ttensor`, copy of tensor + """ + + return ttensor.from_tensor_type(self) + + def __neg__(self): + """ + Unary minus (-) for ttensors + + Returns + ------- + :class:`pyttb.ttensor`, copy of tensor + """ + + return ttensor.from_data(-self.core, self.u) + + def innerprod(self, other): + """ + Efficient inner product with a ttensor + + Parameters + ---------- + other: :class:`pyttb.tensor`, :class:`pyttb.sptensor`, :class:`pyttb.ktensor`, + :class:`pyttb.ttensor` + + Returns + ------- + float + """ + if isinstance(other, ttensor): + if self.shape != other.shape: + raise ValueError( + "ttensors must have same shape to perform an innerproduct, but this ttensor " + f"has shape {self.shape} and the other has {other.shape}" + ) + if np.prod(self.core.shape) > np.prod(other.core.shape): + # Reverse arguments so the ttensor with the smaller core comes first. + return other.innerprod(self) + W = [] + for this_factor, other_factor in zip(self.u, other.u): + W.append(this_factor.transpose().dot(other_factor)) + J = other.core.ttm(W) + return self.core.innerprod(J) + elif isinstance(other, (tensor, sptensor)): + if self.shape != other.shape: + raise ValueError( + "ttensors must have same shape to perform an innerproduct, but this ttensor " + f"has shape {self.shape} and the other has {other.shape}" + ) + if np.prod(self.shape) < np.prod(self.core.shape): + Z = self.full() + return Z.innerprod(other) + Z = other.ttm(self.u, transpose=True) + return Z.innerprod(self.core) + elif isinstance(other, ktensor): + # Call ktensor implementation + # TODO needs ttensor ttv + return other.innerprod(self) + else: + raise ValueError( + f"Inner product between ttensor and {type(other)} is not supported" + ) + + def __mul__(self, other): + """ + Element wise multiplication (*) for ttensors (only scalars supported) + + Parameters + ---------- + other: float, int + + Returns + ------- + :class:`pyttb.ttensor` + """ + if isinstance(other, (float, int, np.number)): + return ttensor.from_data(self.core * other, self.u) + raise ValueError( + "This object cannot be multiplied by ttensor. Convert to full if trying to " + "multiply ttensor by another tensor." + ) + + def __rmul__(self, other): + """ + Element wise right multiplication (*) for ttensors (only scalars supported) + + Parameters + ---------- + other: float, int + + Returns + ------- + :class:`pyttb.ttensor` + """ + if isinstance(other, (float, int, np.number)): + return self.__mul__(other) + raise ValueError("This object cannot be multiplied by ttensor") + + def ttv(self, vector, dims=None, exclude_dims=None): + """ + TTensor times vector + + Parameters + ---------- + vector: :class:`Numpy.ndarray`, list[:class:`Numpy.ndarray`] + dims: :class:`Numpy.ndarray`, int + """ + if dims is None and exclude_dims is None: + dims = np.array([]) + # TODO make helper function to check scalar since re-used many places + elif isinstance(dims, (float, int)): + dims = np.array([dims]) + + if isinstance(exclude_dims, (float, int)): + exclude_dims = np.array([exclude_dims]) + + # Check that vector is a list of vectors, if not place single vector as element in list + if len(vector) > 0 and isinstance(vector[0], (int, float, np.int_, np.float_)): + return self.ttv(np.array([vector]), dims, exclude_dims) + + # Get sorted dims and index for multiplicands + dims, vidx = ttb_utils.tt_dimscheck(self.ndims, len(vector), dims, exclude_dims) + + # Check that each multiplicand is the right size. + for i in range(dims.size): + if vector[vidx[i]].shape != (self.shape[dims[i]],): + raise ValueError("Multiplicand is wrong size") + + # Get remaining dimensions when we're done + remdims = np.setdiff1d(np.arange(0, self.ndims), dims) + + # Create W to multiply with core, only populated remaining dims + W = [None] * self.ndims + for i in range(dims.size): + dim_idx = dims[i] + W[dim_idx] = self.u[dim_idx].transpose().dot(vector[vidx[i]]) + + # Create new core + newcore = self.core.ttv(W, dims) + + # Create final result + if remdims.size == 0: + return newcore + else: + return ttensor.from_data(newcore, [self.u[dim] for dim in remdims]) + + def mttkrp(self, U, n): + """ + Matricized tensor times Khatri-Rao product for ttensors. + + Parameters + ---------- + U: array of matrices or ktensor + n: multiplies by all modes except n + + Returns + ------- + :class:`numpy.ndarray` + """ + # NOTE: MATLAB version calculates an unused R here + + W = [None] * self.ndims + for i in range(0, self.ndims): + if i == n: + continue + W[i] = self.u[i].transpose().dot(U[i]) + + Y = self.core.mttkrp(W, n) + + # Find each column of answer by multiplying by weights + return self.u[n].dot(Y) + + def norm(self): + """ + Compute the norm of a ttensor. + Returns + ------- + norm: float, Frobenius norm of Tensor + """ + if np.prod(self.shape) > np.prod(self.core.shape): + V = [] + for factor in self.u: + V.append(factor.transpose().dot(factor)) + Y = self.core.ttm(V) + tmp = Y.innerprod(self.core) + return np.sqrt(tmp) + else: + return self.full().norm() + + def permute(self, order): + """ + Permute dimensions for a ttensor + + Parameters + ---------- + order: :class:`Numpy.ndarray` + + Returns + ------- + :class:`pyttb.ttensor` + """ + if not np.array_equal(np.arange(0, self.ndims), np.sort(order)): + raise ValueError("Invalid permutation") + new_core = self.core.permute(order) + new_u = [self.u[idx] for idx in order] + return ttensor.from_data(new_core, new_u) + + def ttm(self, matrix, dims=None, exclude_dims=None, transpose=False): + """ + Tensor times matrix for ttensor + + Parameters + ---------- + matrix: :class:`Numpy.ndarray`, list[:class:`Numpy.ndarray`] + dims: :class:`Numpy.ndarray`, int + transpose: bool + """ + if dims is None and exclude_dims is None: + dims = np.arange(self.ndims) + elif isinstance(dims, list): + dims = np.array(dims) + elif np.isscalar(dims): + if dims < 0: + raise ValueError("Negative dims is currently unsupported, see #62") + dims = np.array([dims]) + + if isinstance(exclude_dims, (float, int)): + exclude_dims = np.array([exclude_dims]) + + if not isinstance(matrix, list): + return self.ttm([matrix], dims, exclude_dims, transpose) + + # Check that the dimensions are valid + dims, vidx = ttb_utils.tt_dimscheck(self.ndims, len(matrix), dims, exclude_dims) + + # Determine correct size index + size_idx = int(not transpose) + + # Check that each multiplicand is the right size. + for i in range(len(dims)): + if matrix[vidx[i]].shape[size_idx] != self.shape[dims[i]]: + raise ValueError(f"Multiplicand {i} is wrong size") + + # Do the actual multiplications in the specified modes. + new_u = self.u.copy() + for i in range(len(dims)): + if transpose: + new_u[dims[i]] = matrix[vidx[i]].transpose().dot(new_u[dims[i]]) + else: + new_u[dims[i]] = matrix[vidx[i]].dot(new_u[dims[i]]) + + return ttensor.from_data(self.core, new_u) + + def reconstruct(self, samples=None, modes=None): + """ + Reconstruct or partially reconstruct tensor from ttensor. + + Parameters + ---------- + samples: :class:`Numpy.ndarray`, list[:class:`Numpy.ndarray`] + modes: :class:`Numpy.ndarray`, list[:class:`Numpy.ndarray`] + + Returns + ------- + :class:`pyttb.ttensor` + """ + # Default to sampling full tensor + full_tensor_sampling = samples is None and modes is None + if full_tensor_sampling: + return self.full() + + if modes is None: + modes = np.arange(self.ndims) + elif isinstance(modes, list): + modes = np.array(modes) + elif np.isscalar(modes): + modes = np.array([modes]) + + if np.isscalar(samples): + samples = [np.array([samples])] + elif not isinstance(samples, list): + samples = [samples] + + unequal_lengths = len(samples) != len(modes) + if unequal_lengths: + raise ValueError( + "If samples and modes provides lengths must be equal, but " + f"samples had length {len(samples)} and modes {len(modes)}" + ) + + full_samples = [np.array([])] * self.ndims + for sample, mode in zip(samples, modes): + if np.isscalar(sample): + full_samples[mode] = np.array([sample]) + else: + full_samples[mode] = sample + + shape = self.shape + new_u = [] + for k in range(self.ndims): + if len(full_samples[k]) == 0: + # Skip empty samples + new_u.append(self.u[k]) + continue + elif ( + len(full_samples[k].shape) == 2 + and full_samples[k].shape[-1] == shape[k] + ): + new_u.append(full_samples[k].dot(self.u[k])) + else: + new_u.append(self.u[k][full_samples[k], :]) + + return ttensor.from_data(self.core, new_u).full() + + def nvecs(self, n, r, flipsign=True): + """ + Compute the leading mode-n vectors for a ttensor. + + Parameters + ---------- + n: mode for tensor matricization + r: number of eigenvalues + flipsign: Make each column's largest element positive if true + + Returns + ------- + :class:`numpy.ndarray` + """ + # Compute inner product of all n-1 factors + V = [] + for factor_idx, factor in enumerate(self.u): + if factor_idx == n: + V.append(factor) + else: + V.append(factor.transpose().dot(factor)) + H = self.core.ttm(V) + + if isinstance(H, sptensor): + raise NotImplementedError(ALT_CORE_ERROR) + else: + HnT = tenmat.from_tensor_type(H.full(), cdims=np.array([n])).double() + + G = self.core + + if isinstance(G, sptensor): + raise NotImplementedError(ALT_CORE_ERROR) + else: + GnT = tenmat.from_tensor_type(G.full(), cdims=np.array([n])).double() + + # Compute Xn * Xn' + Y = HnT.transpose().dot(GnT.dot(self.u[n].transpose())) + + # TODO: Lifted from tensor, consider common location + if r < Y.shape[0] - 1: + w, v = scipy.sparse.linalg.eigsh(Y, r) + v = v[:, (-np.abs(w)).argsort()] + v = v[:, :r] + else: + logging.debug( + "Greater than or equal to tensor.shape[n] - 1 eigenvectors requires cast to dense to solve" + ) + w, v = scipy.linalg.eigh(Y) + v = v[:, (-np.abs(w)).argsort()] + v = v[:, :r] + + if flipsign: + idx = np.argmax(np.abs(v), axis=0) + for i in range(v.shape[1]): + if v[idx[i], i] < 0: + v[:, i] *= -1 + return v diff --git a/pyttb/tucker_als.py b/pyttb/tucker_als.py new file mode 100644 index 00000000..02636875 --- /dev/null +++ b/pyttb/tucker_als.py @@ -0,0 +1,161 @@ +from numbers import Real + +import numpy as np + +from pyttb.ttensor import ttensor + + +def tucker_als( + input_tensor, + rank, + stoptol=1e-4, + maxiters=1000, + dimorder=None, + init="random", + printitn=1, +): + """ + Compute Tucker decomposition with alternating least squares + + Parameters + ---------- + input_tensor: :class:`pyttb.tensor` + rank: int, list[int] + Rank of the decomposition(s) + stoptol: float + Tolerance used for termination - when the change in the fitness function in successive iterations drops + below this value, the iterations terminate (default: 1e-4) + dimorder: list + Order to loop through dimensions (default: [range(tensor.ndims)]) + maxiters: int + Maximum number of iterations (default: 1000) + init: str or list[np.ndarray] + Initial guess (default: "random") + + * "random": initialize using a :class:`pyttb.ttensor` with values chosen from a Normal distribution with mean 1 and standard deviation 0 + * "nvecs": initialize factor matrices of a :class:`pyttb.ttensor` using the eigenvectors of the outer product of the matricized input tensor + * :class:`pyttb.ttensor`: initialize using a specific :class:`pyttb.ttensor` as input - must be the same shape as the input tensor and have the same rank as the input rank + + printitn: int + Number of iterations to perform before printing iteration status - 0 for no status printing (default: 1) + + Returns + ------- + M: :class:`pyttb.ttensor` + Resulting ttensor from Tucker-ALS factorization + Minit: :class:`pyttb.ttensor` + Initial guess + output: dict + Information about the computation. Dictionary keys: + + * `params` : tuple of (stoptol, maxiters, printitn, dimorder) + * `iters`: number of iterations performed + * `normresidual`: norm of the difference between the input tensor and ktensor factorization + * `fit`: value of the fitness function (fraction of tensor data explained by the model) + + """ + N = input_tensor.ndims + normX = input_tensor.norm() + + # TODO: These argument checks look common with CP-ALS factor out + if not isinstance(stoptol, Real): + raise ValueError( + f"stoptol must be a real valued scalar but received: {stoptol}" + ) + if not isinstance(maxiters, Real) or maxiters < 0: + raise ValueError( + f"maxiters must be a non-negative real valued scalar but received: {maxiters}" + ) + if not isinstance(printitn, Real): + raise ValueError( + f"printitn must be a real valued scalar but received: {printitn}" + ) + + if isinstance(rank, Real) or len(rank) == 1: + rank = rank * np.ones(N, dtype=int) + + # Set up dimorder if not specified + if not dimorder: + dimorder = list(range(N)) + else: + if not isinstance(dimorder, list): + raise ValueError("Dimorder must be a list") + elif tuple(range(N)) != tuple(sorted(dimorder)): + raise ValueError( + "Dimorder must be a list or permutation of range(tensor.ndims)" + ) + + if isinstance(init, list): + Uinit = init + if len(init) != N: + raise ValueError( + f"Init needs to be of length tensor.ndim (which was {N}) but only got length {len(init)}." + ) + for n in dimorder[1::]: + correct_shape = (input_tensor.shape[n], rank[n]) + if Uinit[n].shape != correct_shape: + raise ValueError( + f"Init factor {n} had incorrect shape. Expected {correct_shape} but got {Uinit[n].shape}" + ) + elif isinstance(init, str) and init.lower() == "random": + Uinit = [None] * N + # Observe that we don't need to calculate an initial guess for the + # first index in dimorder because that will be solved for in the first + # inner iteration. + for n in range(1, N): + Uinit[n] = np.random.uniform(0, 1, (input_tensor.shape[n], rank[n])) + elif isinstance(init, str) and init.lower() in ("nvecs", "eigs"): + # Compute an orthonormal basis for the dominant + # Rn-dimensional left singular subspace of + # X_(n) (0 <= n < N). + Uinit = [None] * N + for n in dimorder[1::]: + print(f" Computing {rank[n]} leading e-vector for factor {n}.\n") + Uinit[n] = input_tensor.nvecs(n, rank[n]) + else: + raise ValueError( + f"The selected initialization method is not supported. Provided: {init}" + ) + + # Set up for iterations - initializing U and the fit. + U = Uinit.copy() + fit = 0 + + if printitn > 0: + print("\nTucker Alternating Least-Squares:\n") + + # Main loop: Iterate until convergence + for iter in range(maxiters): + fitold = fit + + # Iterate over all N modes of the tensor + for n in dimorder: + Utilde = input_tensor.ttm(U, exclude_dims=n, transpose=True) + # Maximize norm(Utilde x_n W') wrt W and + # maintain orthonormality of W + U[n] = Utilde.nvecs(n, rank[n]) + + # Assemble the current approximation + core = Utilde.ttm(U, n, transpose=True) + + # Compute fit + normresidual = np.sqrt(abs(normX**2 - core.norm() ** 2)) + fit = 1 - (normresidual / normX) # fraction explained by model + fitchange = abs(fitold - fit) + + if iter % printitn == 0: + print(f" Iter {iter}: fit = {fit:e} fitdelta = {fitchange:7.1e}\n") + + # Check for convergence + if fitchange < stoptol: + break + + solution = ttensor.from_data(core, U) + + output = {} + output["params"] = (stoptol, maxiters, printitn, dimorder) + output["iters"] = iter + output["normresidual"] = normresidual + output["fit"] = fit + + return solution, Uinit, output diff --git a/setup.py b/setup.py deleted file mode 100644 index 37411290..00000000 --- a/setup.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright 2022 National Technology & Engineering Solutions of Sandia, -# LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the -# U.S. Government retains certain rights in this software. - -from setuptools import setup -from pyttb import __version__ - -setup( - name='pyttb', - version=__version__, - packages=['pyttb'], - package_dir={'': '.'}, - url='', - license='', - author='Danny Dunlavy, Nick Johnson', - author_email='', - description='Python Tensor Toolbox (pyttb)', - install_requires=[ - "numpy", - "pytest", - "sphinx_rtd_theme", - "numpy_groupies" - ] -) diff --git a/tests/test_cp_als.py b/tests/test_cp_als.py index d33c8bf9..a7b141c2 100644 --- a/tests/test_cp_als.py +++ b/tests/test_cp_als.py @@ -2,43 +2,46 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb import numpy as np import pytest +import pyttb as ttb + + @pytest.fixture() def sample_tensor(): - data = np.array([[29, 39.], [63., 85.]]) + data = np.array([[29, 39.0], [63.0, 85.0]]) shape = (2, 2) - params = {'data': data, 'shape': shape} + params = {"data": data, "shape": shape} tensorInstance = ttb.tensor().from_data(data, shape) return params, tensorInstance + @pytest.fixture() def sample_sptensor(): subs = np.array([[0, 0], [1, 0], [1, 1]]) vals = np.array([[0.5], [0.5], [0.5]]) shape = (2, 2) - data = {'subs':subs, 'vals':vals, 'shape': shape} + data = {"subs": subs, "vals": vals, "shape": shape} sptensorInstance = ttb.sptensor.from_data(subs, vals, shape) return data, sptensorInstance + @pytest.mark.indevelopment def test_cp_als_tensor_default_init(capsys, sample_tensor): (data, T) = sample_tensor (M, Minit, output) = ttb.cp_als(T, 2) capsys.readouterr() - assert pytest.approx(output['fit'], 1) == 0 + assert pytest.approx(output["fit"], 1) == 0 + @pytest.mark.indevelopment def test_cp_als_tensor_nvecs_init(capsys, sample_tensor): (data, T) = sample_tensor - with pytest.warns(Warning) as record: - (M, Minit, output) = ttb.cp_als(T, 1, init='nvecs') - assert 'Greater than or equal to tensor.shape[n] - 1 eigenvectors requires cast to dense to solve' \ - in str(record[0].message) + (M, Minit, output) = ttb.cp_als(T, 1, init="nvecs") capsys.readouterr() - assert pytest.approx(output['fit'], 1) == 0 + assert pytest.approx(output["fit"], 1) == 0 + @pytest.mark.indevelopment def test_cp_als_tensor_ktensor_init(capsys, sample_tensor): @@ -46,7 +49,8 @@ def test_cp_als_tensor_ktensor_init(capsys, sample_tensor): KInit = ttb.ktensor.from_function(np.random.random_sample, T.shape, 2) (M, Minit, output) = ttb.cp_als(T, 2, init=KInit) capsys.readouterr() - assert pytest.approx(output['fit'], 1) == 0 + assert pytest.approx(output["fit"], 1) == 0 + @pytest.mark.indevelopment def test_cp_als_incorrect_init(capsys, sample_tensor): @@ -54,7 +58,7 @@ def test_cp_als_incorrect_init(capsys, sample_tensor): # unsupported init type with pytest.raises(AssertionError) as excinfo: - (M, Minit, output) = ttb.cp_als(T, 2, init='init') + (M, Minit, output) = ttb.cp_als(T, 2, init="init") assert "The selected initialization method is not supported" in str(excinfo) # incorrect size of intial ktensor @@ -66,22 +70,22 @@ def test_cp_als_incorrect_init(capsys, sample_tensor): (M, Minit, output) = ttb.cp_als(T, 2, init=KInit) assert "Mode 0 of the initial guess is the wrong size" in str(excinfo) + @pytest.mark.indevelopment def test_cp_als_sptensor_default_init(capsys, sample_sptensor): (data, T) = sample_sptensor (M, Minit, output) = ttb.cp_als(T, 2) capsys.readouterr() - assert pytest.approx(output['fit'], 1) == 0 + assert pytest.approx(output["fit"], 1) == 0 + @pytest.mark.indevelopment def test_cp_als_sptensor_nvecs_init(capsys, sample_sptensor): (data, T) = sample_sptensor - with pytest.warns(Warning) as record: - (M, Minit, output) = ttb.cp_als(T, 1, init='nvecs') - assert 'Greater than or equal to sptensor.shape[n] - 1 eigenvectors requires cast to dense to solve' \ - in str(record[0].message) + (M, Minit, output) = ttb.cp_als(T, 1, init="nvecs") capsys.readouterr() - assert pytest.approx(output['fit'], 1) == 0 + assert pytest.approx(output["fit"], 1) == 0 + @pytest.mark.indevelopment def test_cp_als_sptensor_ktensor_init(capsys, sample_sptensor): @@ -89,7 +93,8 @@ def test_cp_als_sptensor_ktensor_init(capsys, sample_sptensor): KInit = ttb.ktensor.from_function(np.random.random_sample, T.shape, 2) (M, Minit, output) = ttb.cp_als(T, 2, init=KInit) capsys.readouterr() - assert pytest.approx(output['fit'], 1) == 0 + assert pytest.approx(output["fit"], 1) == 0 + @pytest.mark.indevelopment def test_cp_als_tensor_dimorder(capsys, sample_tensor): @@ -101,7 +106,7 @@ def test_cp_als_tensor_dimorder(capsys, sample_tensor): print(dimorder.__class__) (M, Minit, output) = ttb.cp_als(T, 2, dimorder=dimorder) capsys.readouterr() - assert pytest.approx(output['fit'], 1) == 0 + assert pytest.approx(output["fit"], 1) == 0 # reverse should work dimorder = [T.ndims - i - 1 for i in range(T.ndims)] @@ -109,7 +114,7 @@ def test_cp_als_tensor_dimorder(capsys, sample_tensor): print(dimorder.__class__) (M, Minit, output) = ttb.cp_als(T, 2, dimorder=dimorder) capsys.readouterr() - assert pytest.approx(output['fit'], 1) == 0 + assert pytest.approx(output["fit"], 1) == 0 # dimorder not a list with pytest.raises(AssertionError) as excinfo: @@ -121,7 +126,10 @@ def test_cp_als_tensor_dimorder(capsys, sample_tensor): dimorder[-1] = dimorder[-1] + 1 with pytest.raises(AssertionError) as excinfo: (M, Minit, output) = ttb.cp_als(T, 2, dimorder=dimorder) - assert "Dimorder must be a list or permutation of range(tensor.ndims)" in str(excinfo) + assert "Dimorder must be a list or permutation of range(tensor.ndims)" in str( + excinfo + ) + @pytest.mark.indevelopment def test_cp_als_tensor_zeros(capsys, sample_tensor): @@ -129,31 +137,32 @@ def test_cp_als_tensor_zeros(capsys, sample_tensor): T2 = ttb.tensor.from_function(np.zeros, (2, 2)) (M2, Minit2, output2) = ttb.cp_als(T2, 2) capsys.readouterr() - assert pytest.approx(output2['fit'], 1) == 0 - assert output2['normresidual'] == 0 + assert pytest.approx(output2["fit"], 1) == 0 + assert output2["normresidual"] == 0 # 3-way tensor T3 = ttb.tensor.from_function(np.zeros, (3, 4, 5)) (M3, Minit3, output3) = ttb.cp_als(T3, 2) capsys.readouterr() - assert pytest.approx(output3['fit'], 1) == 0 - assert output3['normresidual'] == 0 + assert pytest.approx(output3["fit"], 1) == 0 + assert output3["normresidual"] == 0 + @pytest.mark.indevelopment def test_cp_als_sptensor_zeros(capsys): # 2-way tensor shape2 = (2, 2) - T2 = ttb.sptensor.from_function(np.zeros, shape2, np.ceil(np.prod(shape2)/2.0)) + T2 = ttb.sptensor.from_function(np.zeros, shape2, np.ceil(np.prod(shape2) / 2.0)) print(T2) (M2, Minit2, output2) = ttb.cp_als(T2, 2) capsys.readouterr() - assert pytest.approx(output2['fit'], 1) == 0 - assert output2['normresidual'] == 0 + assert pytest.approx(output2["fit"], 1) == 0 + assert output2["normresidual"] == 0 # 3-way tensor shape3 = (2, 2) - T3 = ttb.sptensor.from_function(np.zeros, shape3, np.ceil(np.prod(shape3)/2.0)) + T3 = ttb.sptensor.from_function(np.zeros, shape3, np.ceil(np.prod(shape3) / 2.0)) (M3, Minit3, output3) = ttb.cp_als(T3, 2) capsys.readouterr() - assert pytest.approx(output3['fit'], 1) == 0 - assert output3['normresidual'] == 0 + assert pytest.approx(output3["fit"], 1) == 0 + assert output3["normresidual"] == 0 diff --git a/tests/test_cp_apr.py b/tests/test_cp_apr.py index ed1d28ce..aa442183 100644 --- a/tests/test_cp_apr.py +++ b/tests/test_cp_apr.py @@ -2,23 +2,25 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb import numpy as np import pytest +import pyttb as ttb + + @pytest.mark.indevelopment def test_vectorizeForMu(): matrix = np.array([[1, 2], [3, 4]]) vector = np.array([1, 2, 3, 4]) assert np.array_equal(ttb.vectorizeForMu(matrix), vector) + @pytest.mark.indevelopment def test_loglikelihood(): - # Test case when both model and data are zero, we define 0*log(0) = 0 - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() @@ -26,11 +28,15 @@ def test_loglikelihood(): # Generate explicit answer vector = tensorInstance.data.ravel() - vector2 = [element*np.log(element) for element in vector if element > 0] + vector2 = [element * np.log(element) for element in vector if element > 0] vector = [element for element in vector if element > 0] explicitAnswer = -np.sum(np.array(vector) - np.array(vector2)) - assert np.isclose(explicitAnswer, ttb.tt_loglikelihood(sptensorInstance, ktensorInstance)) - assert np.isclose(explicitAnswer, ttb.tt_loglikelihood(tensorInstance, ktensorInstance)) + assert np.isclose( + explicitAnswer, ttb.tt_loglikelihood(sptensorInstance, ktensorInstance) + ) + assert np.isclose( + explicitAnswer, ttb.tt_loglikelihood(tensorInstance, ktensorInstance) + ) # Test case for randomly selected model and data np.random.seed(123) @@ -40,7 +46,9 @@ def test_loglikelihood(): for i in range(n): factor_matrices.append(np.abs(np.random.normal(size=(5, n)))) ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) - tensorInstance = ttb.tensor.from_data(np.abs(np.random.normal(size=ktensorInstance.shape))) + tensorInstance = ttb.tensor.from_data( + np.abs(np.random.normal(size=ktensorInstance.shape)) + ) sptensorInstance = ttb.sptensor.from_tensor_type(tensorInstance) vector = ktensorInstance.full().data.ravel() @@ -50,25 +58,43 @@ def test_loglikelihood(): if element == 0: vector2.append(0) else: - vector2.append(data[idx]*np.log(element)) + vector2.append(data[idx] * np.log(element)) explicitAnswer = -np.sum(np.array(vector) - np.array(vector2)) - assert np.isclose(explicitAnswer, ttb.tt_loglikelihood(sptensorInstance, ktensorInstance)) - assert np.isclose(explicitAnswer, ttb.tt_loglikelihood(tensorInstance, ktensorInstance)) + assert np.isclose( + explicitAnswer, ttb.tt_loglikelihood(sptensorInstance, ktensorInstance) + ) + assert np.isclose( + explicitAnswer, ttb.tt_loglikelihood(tensorInstance, ktensorInstance) + ) + @pytest.mark.indevelopment def test_calculatePi(): - # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() sptensorInstance = ttb.sptensor.from_tensor_type(tensorInstance) answer = np.array([[0, 6], [7, 8]]) - assert np.all(np.isclose(ttb.calculatePi(tensorInstance, ktensorInstance, 2, 0, tensorInstance.ndims), answer)) - assert np.all(np.isclose(ttb.calculatePi(sptensorInstance, ktensorInstance, 2, 0, sptensorInstance.ndims), answer)) + assert np.all( + np.isclose( + ttb.calculatePi( + tensorInstance, ktensorInstance, 2, 0, tensorInstance.ndims + ), + answer, + ) + ) + assert np.all( + np.isclose( + ttb.calculatePi( + sptensorInstance, ktensorInstance, 2, 0, sptensorInstance.ndims + ), + answer, + ) + ) """ # Test case for randomly selected model and data @@ -91,27 +117,33 @@ def test_calculatePi(): assert True """ + @pytest.mark.indevelopment def test_calculatePhi(): # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() sptensorInstance = ttb.sptensor.from_tensor_type(tensorInstance) answer = np.array([[0, 0], [11.226415094339623, 24.830188679245282]]) Pi = ttb.calculatePi(sptensorInstance, ktensorInstance, 2, 0, tensorInstance.ndims) - assert np.isclose(ttb.calculatePhi(sptensorInstance, ktensorInstance, 2, 0, Pi, 1e-12), answer).all() - assert np.isclose(ttb.calculatePhi(tensorInstance, ktensorInstance, 2, 0, Pi, 1e-12), answer).all() + assert np.isclose( + ttb.calculatePhi(sptensorInstance, ktensorInstance, 2, 0, Pi, 1e-12), answer + ).all() + assert np.isclose( + ttb.calculatePhi(tensorInstance, ktensorInstance, 2, 0, Pi, 1e-12), answer + ).all() + @pytest.mark.indevelopment def test_cpapr_mu(capsys): # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() @@ -123,14 +155,22 @@ def test_cpapr_mu(capsys): # Assert given an inital guess of the final answer yields immediate convergence M, _, output = ttb.cp_apr(tensorInstance, 2, init=ktensorInstance) capsys.readouterr() - assert output['nTotalIters'] == 2 + assert output["nTotalIters"] == 2 + + # Edge cases + # Confirm timeout works + non_correct_answer = ktensorInstance * 2 + _ = ttb.cp_apr(tensorInstance, 2, init=non_correct_answer, stoptime=-1) + out, _ = capsys.readouterr() + assert "time limit exceeded" in out + @pytest.mark.indevelopment def test_cpapr_pdnr(capsys): # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() @@ -139,24 +179,44 @@ def test_cpapr_pdnr(capsys): capsys.readouterr() assert np.isclose(M.full().data, ktensorInstance.full().data, rtol=1e-04).all() + # Try solve with sptensor + sptensorInstance = ttb.sptensor.from_tensor_type(tensorInstance) + np.random.seed(123) + M, _, _ = ttb.cp_apr(sptensorInstance, 2, algorithm="pdnr") + capsys.readouterr() + assert np.isclose(M.full().data, ktensorInstance.full().data, rtol=1e-04).all() + M, _, _ = ttb.cp_apr(sptensorInstance, 2, algorithm="pdnr", precompinds=False) + capsys.readouterr() + assert np.isclose(M.full().data, ktensorInstance.full().data, rtol=1e-04).all() + + # Edge cases + # Confirm timeout works + non_correct_answer = ktensorInstance * 2 + _ = ttb.cp_apr( + tensorInstance, 2, init=non_correct_answer, algorithm="pdnr", stoptime=-1 + ) + out, _ = capsys.readouterr() + assert "time limit exceeded" in out + + @pytest.mark.indevelopment def test_cpapr_pqnr(capsys): # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() np.random.seed(123) with pytest.raises(AssertionError) as excinfo: M, _, _ = ttb.cp_apr(tensorInstance, 2, algorithm="pqnr") - assert 'ERROR: L-BFGS first iterate is bad' in str(excinfo) + assert "ERROR: L-BFGS first iterate is bad" in str(excinfo) capsys.readouterr() - weights = np.array([1., 2.]) - fm0 = np.array([[1., 1.], [3., 4.]]) - fm1 = np.array([[1., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[1.0, 1.0], [3.0, 4.0]]) + fm1 = np.array([[1.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() @@ -165,46 +225,88 @@ def test_cpapr_pqnr(capsys): capsys.readouterr() assert np.isclose(M.full().data, ktensorInstance.full().data, rtol=1e-01).all() + # Try solve with sptensor + sptensorInstance = ttb.sptensor.from_tensor_type(tensorInstance) + np.random.seed(123) + M, _, _ = ttb.cp_apr(sptensorInstance, 2, algorithm="pqnr") + capsys.readouterr() + assert np.isclose(M.full().data, ktensorInstance.full().data, rtol=1e-01).all() + M, _, _ = ttb.cp_apr(sptensorInstance, 2, algorithm="pqnr", precompinds=False) + capsys.readouterr() + assert np.isclose(M.full().data, ktensorInstance.full().data, rtol=1e-01).all() + + # Edge cases + # Confirm timeout works + _ = ttb.cp_apr(tensorInstance, 2, algorithm="pqnr", stoptime=-1) + out, _ = capsys.readouterr() + assert "time limit exceeded" in out + # PDNR tests below @pytest.mark.indevelopment def test_calculatepi_prowsubprob(): - # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() sptensorInstance = ttb.sptensor.from_tensor_type(tensorInstance) answer = np.array([[0, 6], [7, 8]]) # Reproduce calculate pi with the appropriate inputs - assert np.all(np.isclose(ttb.tt_calcpi_prowsubprob(tensorInstance, ktensorInstance, 2, 0, tensorInstance.ndims), answer)) - assert np.all(np.isclose(ttb.tt_calcpi_prowsubprob(sptensorInstance, ktensorInstance, 2, 0, sptensorInstance.ndims, True, np.arange(sptensorInstance.subs.shape[0])), answer)) + assert np.all( + np.isclose( + ttb.tt_calcpi_prowsubprob( + tensorInstance, ktensorInstance, 2, 0, tensorInstance.ndims + ), + answer, + ) + ) + assert np.all( + np.isclose( + ttb.tt_calcpi_prowsubprob( + sptensorInstance, + ktensorInstance, + 2, + 0, + sptensorInstance.ndims, + True, + np.arange(sptensorInstance.subs.shape[0]), + ), + answer, + ) + ) + def test_calc_partials(): # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() - #print(tensorInstance[:, 0]) + # print(tensorInstance[:, 0]) sptensorInstance = ttb.sptensor.from_tensor_type(tensorInstance) answer = np.array([[0, 6], [7, 8]]) Pi = ttb.calculatePi(sptensorInstance, ktensorInstance, 2, 0, tensorInstance.ndims) # TODO: These are just verifying same functionality as matlab - phi, ups = ttb.calc_partials(False, Pi, 1e-12, tensorInstance[0, :].data, ktensorInstance[0][0, :]) - assert np.isclose(phi, np.array([0,0])).all() + phi, ups = ttb.calc_partials( + False, Pi, 1e-12, tensorInstance[0, :].data, ktensorInstance[0][0, :] + ) + assert np.isclose(phi, np.array([0, 0])).all() assert np.isclose(ups, np.array([0, 0])).all() - phi, ups = ttb.calc_partials(False, Pi, 1e-12, tensorInstance[1, :].data, ktensorInstance[0][0, :]) - assert np.isclose(phi, 1e14*np.array([5.95, 9.68])).all() - assert np.isclose(ups, 1e13*np.array([4.8, 8.5])).all() + phi, ups = ttb.calc_partials( + False, Pi, 1e-12, tensorInstance[1, :].data, ktensorInstance[0][0, :] + ) + assert np.isclose(phi, 1e14 * np.array([5.95, 9.68])).all() + assert np.isclose(ups, 1e13 * np.array([4.8, 8.5])).all() - phi, ups = ttb.calc_partials(True, Pi, 1e-12, sptensorInstance.vals, ktensorInstance[0][0, :]) + phi, ups = ttb.calc_partials( + True, Pi, 1e-12, sptensorInstance.vals, ktensorInstance[0][0, :] + ) assert np.isclose(phi, 1e14 * np.array([5.95, 9.68])).all() assert np.isclose(ups, 1e13 * np.array([4.8, 8.5])).all() @@ -213,11 +315,11 @@ def test_calc_partials(): # This is meant to be an inefficient yet explicit implementation rank = 5 length = 7 - m_row = np.random.normal(size=(rank, )) - x_row = np.random.normal(size=(length, )) + m_row = np.random.normal(size=(rank,)) + x_row = np.random.normal(size=(length,)) Pi = np.random.normal(size=(length, rank)) - hessM = np.zeros(shape=(length, )) - gradM = np.zeros(shape=(rank, )) + hessM = np.zeros(shape=(length,)) + gradM = np.zeros(shape=(rank,)) eps_div_zero = 1e-12 # Test \nabla_{r}f_{row}(b) @@ -227,9 +329,9 @@ def test_calc_partials(): denominator = 0 for i in range(rank): # Note Pi indices are flipped, not sure if paper definition is transposed of ours - denominator += m_row[i]*Pi[j, i] - grad_sum += (x_row[j]*Pi[j, r])/np.maximum(denominator, eps_div_zero) - hessian_sum = (x_row[j])/np.maximum(denominator**2, eps_div_zero) + denominator += m_row[i] * Pi[j, i] + grad_sum += (x_row[j] * Pi[j, r]) / np.maximum(denominator, eps_div_zero) + hessian_sum = (x_row[j]) / np.maximum(denominator**2, eps_div_zero) # Test \nabla^2_{rs}f_{row}(b) hessM[j] = hessian_sum @@ -242,9 +344,9 @@ def test_calc_partials(): def test_getHessian(): # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() @@ -252,8 +354,12 @@ def test_getHessian(): free_indices = [0, 1] rank = 2 sptensorInstance = ttb.sptensor.from_tensor_type(tensorInstance) - Pi = ttb.calculatePi(sptensorInstance, ktensorInstance, rank, 0, tensorInstance.ndims) - phi, ups = ttb.calc_partials(False, Pi, 1e-12, tensorInstance[1, :].data, ktensorInstance[0][0, :]) + Pi = ttb.calculatePi( + sptensorInstance, ktensorInstance, rank, 0, tensorInstance.ndims + ) + phi, ups = ttb.calc_partials( + False, Pi, 1e-12, tensorInstance[1, :].data, ktensorInstance[0][0, :] + ) Hessian = ttb.getHessian(ups, Pi, free_indices) assert np.allclose(Hessian, Hessian.transpose()) @@ -281,11 +387,12 @@ def test_getHessian(): assert np.allclose(H, Hessian) + def test_getSearchDirPdnr(): # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() @@ -297,10 +404,12 @@ def test_getSearchDirPdnr(): phi, ups = ttb.calc_partials(False, Pi, 1e-12, data_row, model_row) search, pred = ttb.getSearchDirPdnr(Pi, ups, 2, phi, model_row, 0.1, 1e-6) # TODO validate this projection formulation - projGradStep = (model_row - ups.transpose()) * (model_row - (ups.transpose() > 0).astype(float)) + projGradStep = (model_row - ups.transpose()) * ( + model_row - (ups.transpose() > 0).astype(float) + ) wk = np.linalg.norm(model_row - projGradStep) epsilon_k = np.minimum(1e-6, wk) - free_indices =[] + free_indices = [] # Validates formulation of (3.5) for r in range(len(search)): if model_row[r] == 0 and ups[r] > 0: # in set A @@ -310,49 +419,74 @@ def test_getSearchDirPdnr(): else: free_indices.append(r) Hessian_free = ttb.getHessian(ups, Pi, free_indices) - direction = np.linalg.solve(Hessian_free + (0.1 * np.eye(len(free_indices))), -ups[free_indices])[:, None] + direction = np.linalg.solve( + Hessian_free + (0.1 * np.eye(len(free_indices))), -ups[free_indices] + )[:, None] for i in free_indices: assert search[i] == direction[i] + + @pytest.mark.indevelopment def test_tt_loglikelihood_row(): # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() # print(tensorInstance[:, 0]) sptensorInstance = ttb.sptensor.from_tensor_type(tensorInstance) Pi = ttb.calculatePi(sptensorInstance, ktensorInstance, 2, 0, tensorInstance.ndims) - loglikelihood = ttb.tt_loglikelihood_row(False, tensorInstance[1, :].data, tensorInstance[1, :].data, Pi) - #print(loglikelihood) + loglikelihood = ttb.tt_loglikelihood_row( + False, tensorInstance[1, :].data, tensorInstance[1, :].data, Pi + ) + # print(loglikelihood) + @pytest.mark.indevelopment def test_tt_linesearch_prowsubprob(): # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() # print(tensorInstance[:, 0]) sptensorInstance = ttb.sptensor.from_tensor_type(tensorInstance) Pi = ttb.calculatePi(sptensorInstance, ktensorInstance, 2, 0, tensorInstance.ndims) - phi, ups = ttb.calc_partials(False, Pi, 1e-12, tensorInstance[1, :].data, ktensorInstance[0][0, :]) - search, pred = ttb.getSearchDirPdnr(Pi, ups, 2, phi, tensorInstance[1, :].data, 0.1, 1e-6) + phi, ups = ttb.calc_partials( + False, Pi, 1e-12, tensorInstance[1, :].data, ktensorInstance[0][0, :] + ) + search, pred = ttb.getSearchDirPdnr( + Pi, ups, 2, phi, tensorInstance[1, :].data, 0.1, 1e-6 + ) with pytest.warns(Warning) as record: - ttb.tt_linesearch_prowsubprob(search.transpose()[0], phi.transpose(), tensorInstance[1, :].data, 1, 1 / 2, 10, - 1.0e-4, False, tensorInstance[1, :].data, Pi, phi, True) - assert 'CP_APR: Line search failed, using multiplicative update step' in str(record[0].message) + ttb.tt_linesearch_prowsubprob( + search.transpose()[0], + phi.transpose(), + tensorInstance[1, :].data, + 1, + 1 / 2, + 10, + 1.0e-4, + False, + tensorInstance[1, :].data, + Pi, + phi, + True, + ) + assert "CP_APR: Line search failed, using multiplicative update step" in str( + record[0].message + ) def test_getSearchDirPqnr(): # Test simple case - weights = np.array([1., 2.]) - fm0 = np.array([[0., 0.], [3., 4.]]) - fm1 = np.array([[0., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[0.0, 0.0], [3.0, 4.0]]) + fm1 = np.array([[0.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) tensorInstance = ktensorInstance.full() @@ -364,6 +498,32 @@ def test_getSearchDirPqnr(): phi, ups = ttb.calc_partials(False, Pi, 1e-12, data_row, model_row) delta_model = np.random.normal(size=(2, model_row.shape[0])) delta_grad = np.random.normal(size=(2, phi.shape[0])) - search, pred = ttb.getSearchDirPqnr(model_row, phi, 1e-6, delta_model, delta_grad, phi, 1, 5, False) + search, pred = ttb.getSearchDirPqnr( + model_row, phi, 1e-6, delta_model, delta_grad, phi, 1, 5, False + ) # This only verifies that for the right shaped input nothing crashes. Doesn't verify correctness assert True + + +def test_cp_apr_negative_tests(): + dense_tensor = ttb.tensor.from_data(np.ones((2, 2, 2))) + bad_weights = np.array([8.0]) + bad_factors = [np.array([[1.0]])] * 3 + bad_initial_guess_shape = ttb.ktensor.from_data(bad_weights, bad_factors) + with pytest.raises(AssertionError): + ttb.cp_apr(dense_tensor, init=bad_initial_guess_shape, rank=1) + good_weights = np.array([8.0] * 3) + good_factor = np.array([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]) + bad_initial_guess_factors = ttb.ktensor.from_data( + good_weights, [-1.0 * good_factor] * 3 + ) + with pytest.raises(AssertionError): + ttb.cp_apr(dense_tensor, init=bad_initial_guess_factors, rank=3) + bad_initial_guess_weight = ttb.ktensor.from_data( + -1.0 * good_weights, [good_factor] * 3 + ) + with pytest.raises(AssertionError): + ttb.cp_apr(dense_tensor, init=bad_initial_guess_weight, rank=3) + + with pytest.raises(AssertionError): + ttb.cp_apr(dense_tensor, rank=1, algorithm="UNSUPPORTED_ALG") diff --git a/tests/test_hosvd.py b/tests/test_hosvd.py new file mode 100644 index 00000000..47f51533 --- /dev/null +++ b/tests/test_hosvd.py @@ -0,0 +1,123 @@ +import numpy as np +import pytest + +import pyttb as ttb + + +@pytest.fixture() +def sample_tensor(): + data = np.array([[29, 39.0], [63.0, 85.0]]) + shape = (2, 2) + params = {"data": data, "shape": shape} + tensorInstance = ttb.tensor().from_data(data, shape) + return params, tensorInstance + + +@pytest.fixture() +def sample_tensor_3way(): + shape = (3, 3, 3) + data = np.array(range(1, 28)).reshape(shape, order="F") + params = {"data": data, "shape": shape} + tensorInstance = ttb.tensor().from_data(data, shape) + return params, tensorInstance + + +@pytest.mark.indevelopment +def test_hosvd_simple_convergence(capsys, sample_tensor): + (data, T) = sample_tensor + tol = 1e-4 + result = ttb.hosvd(T, tol) + assert (result.full() - T).norm() / T.norm() < tol, f"Failed to converge" + + tol = 1e-4 + result = ttb.hosvd(T, tol, sequential=False) + assert ( + result.full() - T + ).norm() / T.norm() < tol, f"Failed to converge for non-sequential option" + + impossible_tol = 1e-20 + with pytest.warns(UserWarning): + result = ttb.hosvd(T, impossible_tol) + assert ( + result.full() - T + ).norm() / T.norm() > impossible_tol, f"Converged beyond provided precision" + + +@pytest.mark.indevelopment +def test_hosvd_default_init(capsys, sample_tensor): + (data, T) = sample_tensor + _ = ttb.hosvd(T, 1) + + +@pytest.mark.indevelopment +def test_hosvd_smoke_test_verbosity(capsys, sample_tensor): + """For now just make sure verbosity calcs don't crash""" + (data, T) = sample_tensor + ttb.hosvd(T, 1, verbosity=10) + + +@pytest.mark.indevelopment +def test_hosvd_incorrect_ranks(capsys, sample_tensor): + (data, T) = sample_tensor + ranks = list(range(T.ndims - 1)) + with pytest.raises(ValueError): + _ = ttb.hosvd(T, 1, ranks=ranks) + + +@pytest.mark.indevelopment +def test_hosvd_incorrect_dimorder(capsys, sample_tensor): + (data, T) = sample_tensor + dimorder = list(range(T.ndims - 1)) + with pytest.raises(ValueError): + _ = ttb.hosvd(T, 1, dimorder=dimorder) + + dimorder = 1 + with pytest.raises(ValueError): + _ = ttb.hosvd(T, 1, dimorder=dimorder) + + +@pytest.mark.indevelopment +def test_hosvd_3way(capsys, sample_tensor_3way): + (data, T) = sample_tensor_3way + M = ttb.hosvd(T, 1e-4, verbosity=0) + capsys.readouterr() + print(f"M=\n{M}") + core = np.array( + [ + [ + [-8.301598119750199e01, -5.005881796972034e-03], + [-1.268039597172832e-02, 5.842630378620833e00], + ], + [ + [3.709974006281391e-02, -1.915213813096568e00], + [-5.157111619887230e-01, 5.243776123493664e-01], + ], + ] + ) + fm0 = np.array( + [ + [-5.452132631706279e-01, -7.321719955012304e-01], + [-5.767748638548937e-01, -2.576993904719336e-02], + [-6.083364645391598e-01, 6.806321174064961e-01], + ] + ) + fm1 = np.array( + [ + [-4.756392343758577e-01, 7.791666394653051e-01], + [-5.719678320081717e-01, 7.865197061237804e-02], + [-6.682964296404851e-01, -6.218626982406427e-01], + ] + ) + fm2 = np.array( + [ + [-1.922305666539489e-01, 8.924016710972924e-01], + [-5.140779746206554e-01, 2.627873081852611e-01], + [-8.359253825873615e-01, -3.668270547267537e-01], + ] + ) + expected = ttb.ttensor.from_data(ttb.tensor.from_data(core), [fm0, fm1, fm2]) + assert np.allclose(M.double(), expected.double()) + assert np.allclose(np.abs(M.core.data), np.abs(core)) + assert np.allclose(np.abs(M.u[0]), np.abs(fm0)) + assert np.allclose(np.abs(M.u[1]), np.abs(fm1)) + assert np.allclose(np.abs(M.u[2]), np.abs(fm2)) diff --git a/tests/test_import_export_data.py b/tests/test_import_export_data.py index 9f29c99d..c75d4d13 100644 --- a/tests/test_import_export_data.py +++ b/tests/test_import_export_data.py @@ -2,163 +2,196 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb +import os + import numpy as np import pytest -import os + +import pyttb as ttb + @pytest.fixture() def sample_tensor(): # truth data - T = ttb.tensor.from_data(np.ones((3,3,3)), (3,3,3)) + T = ttb.tensor.from_data(np.ones((3, 3, 3)), (3, 3, 3)) return T + @pytest.fixture() def sample_sptensor(): # truth data - subs = np.array([[0, 0, 0],[0, 2, 2],[1, 1, 1],[1, 2, 0],[1, 2, 1],[1, 2, 2], - [1, 3, 1],[2, 0, 0],[2, 0, 1],[2, 2, 0],[2, 2, 1],[2, 3, 0], - [2, 3, 2],[3, 0, 0],[3, 0, 1],[3, 2, 0],[4, 0, 2],[4, 3, 2]]) - vals = np.reshape(np.array(range(1,19)),(18,1)) + subs = np.array( + [ + [0, 0, 0], + [0, 2, 2], + [1, 1, 1], + [1, 2, 0], + [1, 2, 1], + [1, 2, 2], + [1, 3, 1], + [2, 0, 0], + [2, 0, 1], + [2, 2, 0], + [2, 2, 1], + [2, 3, 0], + [2, 3, 2], + [3, 0, 0], + [3, 0, 1], + [3, 2, 0], + [4, 0, 2], + [4, 3, 2], + ] + ) + vals = np.reshape(np.array(range(1, 19)), (18, 1)) shape = (5, 4, 3) S = ttb.sptensor().from_data(subs, vals, shape) return S + @pytest.fixture() def sample_ktensor(): # truth data weights = np.array([3, 2]) - fm0 = np.array([[1., 5.], [2., 6.], [3., 7.], [4., 8.]]) - fm1 = np.array([[ 2., 7.], [ 3., 8.], [ 4., 9.], [ 5., 10.], [ 6., 11.]]) - fm2 = np.array([[3., 6.], [4., 7.], [5., 8.]]) + fm0 = np.array([[1.0, 5.0], [2.0, 6.0], [3.0, 7.0], [4.0, 8.0]]) + fm1 = np.array([[2.0, 7.0], [3.0, 8.0], [4.0, 9.0], [5.0, 10.0], [6.0, 11.0]]) + fm2 = np.array([[3.0, 6.0], [4.0, 7.0], [5.0, 8.0]]) factor_matrices = [fm0, fm1, fm2] K = ttb.ktensor.from_data(weights, factor_matrices) return K + @pytest.fixture() def sample_array(): # truth data - M = np.array([[1., 5.], [2., 6.], [3., 7.], [4., 8.]]) + M = np.array([[1.0, 5.0], [2.0, 6.0], [3.0, 7.0], [4.0, 8.0]]) return M + @pytest.mark.indevelopment def test_import_data_tensor(sample_tensor): # truth data T = sample_tensor # imported data - data_filename = os.path.join(os.path.dirname(__file__),'data','tensor.tns') + data_filename = os.path.join(os.path.dirname(__file__), "data", "tensor.tns") X = ttb.import_data(data_filename) assert T.isequal(X) - + + @pytest.mark.indevelopment def test_import_data_sptensor(sample_sptensor): # truth data S = sample_sptensor # imported data - data_filename = os.path.join(os.path.dirname(__file__),'data','sptensor.tns') + data_filename = os.path.join(os.path.dirname(__file__), "data", "sptensor.tns") X = ttb.import_data(data_filename) - + assert S.isequal(X) + @pytest.mark.indevelopment def test_import_data_ktensor(sample_ktensor): # truth data K = sample_ktensor # imported data - data_filename = os.path.join(os.path.dirname(__file__),'data','ktensor.tns') + data_filename = os.path.join(os.path.dirname(__file__), "data", "ktensor.tns") X = ttb.import_data(data_filename) - + assert K.isequal(X) + @pytest.mark.indevelopment def test_import_data_array(sample_array): # truth data M = sample_array - + # imported data - data_filename = os.path.join(os.path.dirname(__file__),'data','matrix.tns') + data_filename = os.path.join(os.path.dirname(__file__), "data", "matrix.tns") X = ttb.import_data(data_filename) - + assert (M == X).all() + @pytest.mark.indevelopment def test_export_data_tensor(sample_tensor): # truth data T = sample_tensor - data_filename = os.path.join(os.path.dirname(__file__),'data','tensor.out') + data_filename = os.path.join(os.path.dirname(__file__), "data", "tensor.out") ttb.export_data(T, data_filename) X = ttb.import_data(data_filename) assert T.isequal(X) os.unlink(data_filename) - data_filename = os.path.join(os.path.dirname(__file__),'data','tensor_int.out') - ttb.export_data(T, data_filename, fmt_data='%d') + data_filename = os.path.join(os.path.dirname(__file__), "data", "tensor_int.out") + ttb.export_data(T, data_filename, fmt_data="%d") X = ttb.import_data(data_filename) assert T.isequal(X) os.unlink(data_filename) + @pytest.mark.indevelopment def test_export_data_sptensor(sample_sptensor): # truth data S = sample_sptensor # imported data - data_filename = os.path.join(os.path.dirname(__file__),'data','sptensor.out') + data_filename = os.path.join(os.path.dirname(__file__), "data", "sptensor.out") ttb.export_data(S, data_filename) - X = ttb.import_data(data_filename) + X = ttb.import_data(data_filename) assert S.isequal(X) os.unlink(data_filename) - data_filename = os.path.join(os.path.dirname(__file__),'data','sptensor_int.out') - ttb.export_data(S, data_filename, fmt_data='%d') + data_filename = os.path.join(os.path.dirname(__file__), "data", "sptensor_int.out") + ttb.export_data(S, data_filename, fmt_data="%d") - X = ttb.import_data(data_filename) + X = ttb.import_data(data_filename) assert S.isequal(X) os.unlink(data_filename) + @pytest.mark.indevelopment def test_export_data_ktensor(sample_ktensor): # truth data K = sample_ktensor - + # imported data - data_filename = os.path.join(os.path.dirname(__file__),'data','ktensor.out') + data_filename = os.path.join(os.path.dirname(__file__), "data", "ktensor.out") ttb.export_data(K, data_filename) X = ttb.import_data(data_filename) assert K.isequal(X) os.unlink(data_filename) - data_filename = os.path.join(os.path.dirname(__file__),'data','ktensor_int.out') - ttb.export_data(K, data_filename, fmt_data='%d', fmt_weights='%d') + data_filename = os.path.join(os.path.dirname(__file__), "data", "ktensor_int.out") + ttb.export_data(K, data_filename, fmt_data="%d", fmt_weights="%d") X = ttb.import_data(data_filename) assert K.isequal(X) os.unlink(data_filename) + @pytest.mark.indevelopment def test_export_data_array(sample_array): # truth data M = sample_array # imported data - data_filename = os.path.join(os.path.dirname(__file__),'data','matrix.out') + data_filename = os.path.join(os.path.dirname(__file__), "data", "matrix.out") ttb.export_data(M, data_filename) X = ttb.import_data(data_filename) assert (M == X).all() os.unlink(data_filename) - data_filename = os.path.join(os.path.dirname(__file__),'data','matrix_int.out') - ttb.export_data(M, data_filename, fmt_data='%d') + data_filename = os.path.join(os.path.dirname(__file__), "data", "matrix_int.out") + ttb.export_data(M, data_filename, fmt_data="%d") X = ttb.import_data(data_filename) assert (M == X).all() diff --git a/tests/test_khatrirao.py b/tests/test_khatrirao.py index 07c04fda..9dd00ceb 100644 --- a/tests/test_khatrirao.py +++ b/tests/test_khatrirao.py @@ -2,15 +2,28 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb import numpy as np import pytest +import pyttb as ttb + + @pytest.mark.indevelopment def test_khatrirao(): A = np.array([[1, 2, 3], [4, 5, 6]]) # This result was from MATLAB tensortoolbox, didn't verify by hand - answer = np.array([[1, 8, 27], [4, 20, 54], [4, 20, 54], [16, 50, 108], [4, 20, 54], [16, 50, 108], [16, 50, 108], [64, 125, 216]]) + answer = np.array( + [ + [1, 8, 27], + [4, 20, 54], + [4, 20, 54], + [16, 50, 108], + [4, 20, 54], + [16, 50, 108], + [16, 50, 108], + [64, 125, 216], + ] + ) assert (ttb.khatrirao([A, A, A]) == answer).all() assert (ttb.khatrirao([A, A, A], reverse=True) == answer).all() assert (ttb.khatrirao(A, A, A) == answer).all() @@ -19,19 +32,27 @@ def test_khatrirao(): a_1 = np.array([[1], [1], [1], [1]]) a_2 = np.array([[0], [1], [2], [3]]) a_3 = np.array([[0, 0], [1, 0], [2, 0], [3, 0]]) - result = np.vstack((a_2[0, 0]*np.ones((16,1)), a_2[1, 0]*np.ones((16, 1)), a_2[2, 0]*np.ones((16, 1)), - a_2[3, 0]*np.ones((16, 1)))) + result = np.vstack( + ( + a_2[0, 0] * np.ones((16, 1)), + a_2[1, 0] * np.ones((16, 1)), + a_2[2, 0] * np.ones((16, 1)), + a_2[3, 0] * np.ones((16, 1)), + ) + ) assert (ttb.khatrirao([a_2, a_1, a_1]) == result).all() assert (ttb.khatrirao(a_2, a_1, a_1) == result).all() with pytest.raises(AssertionError) as excinfo: ttb.khatrirao([a_2, a_1, a_1], a_2) - assert "Khatri Rao Acts on multiple Array arguments or a list of Arrays" in str(excinfo) + assert "Khatri Rao Acts on multiple Array arguments or a list of Arrays" in str( + excinfo + ) with pytest.raises(AssertionError) as excinfo: ttb.khatrirao(a_2, a_1, np.ones((2, 2, 2))) - assert 'Each argument must be a matrix' in str(excinfo) + assert "Each argument must be a matrix" in str(excinfo) with pytest.raises(AssertionError) as excinfo: ttb.khatrirao(a_2, a_1, a_3) - assert 'All matrices must have the same number of columns.' in str(excinfo) + assert "All matrices must have the same number of columns." in str(excinfo) diff --git a/tests/test_ktensor.py b/tests/test_ktensor.py index 03e3d6f1..861a16fc 100644 --- a/tests/test_ktensor.py +++ b/tests/test_ktensor.py @@ -2,51 +2,64 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb import numpy as np import pytest + +import pyttb as ttb + np.set_printoptions(precision=16) + @pytest.fixture() def sample_ktensor_2way(): - weights = np.array([1., 2.]) - fm0 = np.array([[1., 2.], [3., 4.]]) - fm1 = np.array([[5., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[1.0, 2.0], [3.0, 4.0]]) + fm1 = np.array([[5.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] - data = {'weights': weights, 'factor_matrices': factor_matrices} + data = {"weights": weights, "factor_matrices": factor_matrices} ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) return data, ktensorInstance + @pytest.fixture() def sample_ktensor_3way(): rank = 2 shape = np.array([2, 3, 4]) - vector = np.arange(1, rank*sum(shape)+1).astype(np.float) - weights = 2 * np.ones(rank).astype(np.float) + vector = np.arange(1, rank * sum(shape) + 1).astype(float) + weights = 2 * np.ones(rank).astype(float) vector_with_weights = np.concatenate((weights, vector), axis=0) - #vector_with_weights = vector_with_weights.reshape((len(vector_with_weights), 1)) + # vector_with_weights = vector_with_weights.reshape((len(vector_with_weights), 1)) # ground truth - fm0 = np.array([[1., 3.], [2., 4.]]) - fm1 = np.array([[5., 8.], [6., 9.], [7., 10.]]) - fm2 = np.array([[11., 15.], [12., 16.], [13., 17.], [14., 18.]]) + fm0 = np.array([[1.0, 3.0], [2.0, 4.0]]) + fm1 = np.array([[5.0, 8.0], [6.0, 9.0], [7.0, 10.0]]) + fm2 = np.array([[11.0, 15.0], [12.0, 16.0], [13.0, 17.0], [14.0, 18.0]]) factor_matrices = [fm0, fm1, fm2] - data = {'weights': weights, 'factor_matrices': factor_matrices, "vector": vector, - "vector_with_weights": vector_with_weights, "shape": shape} + data = { + "weights": weights, + "factor_matrices": factor_matrices, + "vector": vector, + "vector_with_weights": vector_with_weights, + "shape": shape, + } ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) return data, ktensorInstance + @pytest.fixture() def sample_ktensor_symmetric(): - weights = np.array([1., 1.]) - fm0 = np.array([[2.340431417384394, 4.951967353890655], - [4.596069112758807, 8.012451489774961]]) - fm1 = np.array([[2.340431417384394, 4.951967353890655], - [4.596069112758807, 8.012451489774961]]) + weights = np.array([1.0, 1.0]) + fm0 = np.array( + [[2.340431417384394, 4.951967353890655], [4.596069112758807, 8.012451489774961]] + ) + fm1 = np.array( + [[2.340431417384394, 4.951967353890655], [4.596069112758807, 8.012451489774961]] + ) factor_matrices = [fm0, fm1] - data = {'weights': weights, 'factor_matrices': factor_matrices} + data = {"weights": weights, "factor_matrices": factor_matrices} ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) return data, ktensorInstance + @pytest.mark.indevelopment def test_ktensor_init(): empty = np.array([]) @@ -54,7 +67,8 @@ def test_ktensor_init(): # No args K0 = ttb.ktensor() assert (K0.weights == empty).all() - assert (K0.factor_matrices == []) + assert K0.factor_matrices == [] + @pytest.mark.indevelopment def test_ktensor_from_tensor_type(sample_ktensor_2way): @@ -65,9 +79,10 @@ def test_ktensor_from_tensor_type(sample_ktensor_2way): assert (K0.factor_matrices[1] == K1.factor_matrices[1]).all() # won't work with instances other than ktensors with pytest.raises(AssertionError) as excinfo: - K2 = ttb.ktensor.from_tensor_type(np.ones((2,2))) + K2 = ttb.ktensor.from_tensor_type(np.ones((2, 2))) assert "Cannot convert from to ktensor" in str(excinfo) + @pytest.mark.indevelopment def test_ktensor_from_factor_matrices(sample_ktensor_2way): (data, K0) = sample_ktensor_2way @@ -77,11 +92,14 @@ def test_ktensor_from_factor_matrices(sample_ktensor_2way): assert (K0.factor_matrices[1] == data["factor_matrices"][1]).all() # Create ktensor with weights and multiple factor matrices as arguments - K1 = ttb.ktensor.from_factor_matrices(data["factor_matrices"][0], data["factor_matrices"][1]) + K1 = ttb.ktensor.from_factor_matrices( + data["factor_matrices"][0], data["factor_matrices"][1] + ) assert (K1.weights == np.ones(2)).all() assert (K1.factor_matrices[0] == data["factor_matrices"][0]).all() assert (K1.factor_matrices[1] == data["factor_matrices"][1]).all() + @pytest.mark.indevelopment def test_ktensor_from_data(sample_ktensor_2way, capsys): (data, K0) = sample_ktensor_2way @@ -91,7 +109,9 @@ def test_ktensor_from_data(sample_ktensor_2way, capsys): assert (K0.factor_matrices[1] == data["factor_matrices"][1]).all() # Create ktensor with weights and multiple factor matrices as arguments - K1 = ttb.ktensor.from_data(data["weights"], data["factor_matrices"][0], data["factor_matrices"][1]) + K1 = ttb.ktensor.from_data( + data["weights"], data["factor_matrices"][0], data["factor_matrices"][1] + ) assert (K1.weights == data["weights"]).all() assert (K1.factor_matrices[0] == data["factor_matrices"][0]).all() assert (K1.factor_matrices[1] == data["factor_matrices"][1]).all() @@ -100,8 +120,10 @@ def test_ktensor_from_data(sample_ktensor_2way, capsys): weights_int = np.array([1, 2]) K2 = ttb.ktensor.from_data(weights_int, data["factor_matrices"]) out, err = capsys.readouterr() - assert "converting weights from int64 to np.float" in out or \ - "converting weights from int32 to np.float" in out + assert ( + "converting weights from int64 to float" in out + or "converting weights from int32 to float" in out + ) # Weights that are int should be converted fm0 = np.array([[1, 2], [3, 4]]) @@ -109,43 +131,47 @@ def test_ktensor_from_data(sample_ktensor_2way, capsys): factor_matrices = [fm0, fm1] K3 = ttb.ktensor.from_data(data["weights"], factor_matrices) out, err = capsys.readouterr() - assert "converting factor_matrices[0] from int64 to np.float" in out or \ - "converting factor_matrices[0] from int32 to np.float" in out + assert ( + "converting factor_matrices[0] from int64 to float" in out + or "converting factor_matrices[0] from int32 to float" in out + ) + @pytest.mark.indevelopment def test_ktensor_from_function(): K0 = ttb.ktensor.from_function(np.ones, (2, 3, 4), 2) - assert (K0.weights == np.array([1., 1.])).all() + assert (K0.weights == np.array([1.0, 1.0])).all() assert (K0.factor_matrices[0] == np.ones((2, 2))).all() assert (K0.factor_matrices[1] == np.ones((3, 2))).all() assert (K0.factor_matrices[2] == np.ones((4, 2))).all() np.random.seed(1) K1 = ttb.ktensor.from_function(np.random.random_sample, (2, 3, 4), 2) - assert (K1.weights == np.array([1., 1.])).all() - fm0 = np.array( - [[4.17022005e-01, 7.20324493e-01], - [1.14374817e-04, 3.02332573e-01]]) + assert (K1.weights == np.array([1.0, 1.0])).all() + fm0 = np.array([[4.17022005e-01, 7.20324493e-01], [1.14374817e-04, 3.02332573e-01]]) fm1 = np.array( - [[0.14675589, 0.09233859], - [0.18626021, 0.34556073], - [0.39676747, 0.53881673]]) + [[0.14675589, 0.09233859], [0.18626021, 0.34556073], [0.39676747, 0.53881673]] + ) fm2 = np.array( - [[0.41919451, 0.6852195], - [0.20445225, 0.87811744], - [0.02738759, 0.67046751], - [0.4173048, 0.55868983]]) + [ + [0.41919451, 0.6852195], + [0.20445225, 0.87811744], + [0.02738759, 0.67046751], + [0.4173048, 0.55868983], + ] + ) assert np.linalg.norm(K1.factor_matrices[0] - fm0) < 1e-8 assert np.linalg.norm(K1.factor_matrices[1] - fm1) < 1e-8 assert np.linalg.norm(K1.factor_matrices[2] - fm2) < 1e-8 + @pytest.mark.indevelopment def test_ktensor_from_vector(sample_ktensor_3way): (data, K0) = sample_ktensor_3way # without explicit weights in x K0 = ttb.ktensor.from_vector(data["vector"], data["shape"], False) - assert (K0.weights == np.ones((3,1))).all() + assert (K0.weights == np.ones((3, 1))).all() assert (K0.factor_matrices[0] == data["factor_matrices"][0]).all() assert (K0.factor_matrices[1] == data["factor_matrices"][1]).all() assert (K0.factor_matrices[2] == data["factor_matrices"][2]).all() @@ -160,7 +186,7 @@ def test_ktensor_from_vector(sample_ktensor_3way): # data as a row vector will work, but will be transposed transposed_data = data["vector"].copy().reshape((1, len(data["vector"]))) K2 = ttb.ktensor.from_vector(transposed_data, data["shape"], False) - assert (K2.weights == np.ones((3,1))).all() + assert (K2.weights == np.ones((3, 1))).all() assert (K2.factor_matrices[0] == data["factor_matrices"][0]).all() assert (K2.factor_matrices[1] == data["factor_matrices"][1]).all() assert (K2.factor_matrices[2] == data["factor_matrices"][2]).all() @@ -170,13 +196,14 @@ def test_ktensor_from_vector(sample_ktensor_3way): K3 = ttb.ktensor.from_vector(data["vector"].T, data["shape"] + 7, False) assert "Input parameter 'data' is not the right length." in str(excinfo) + @pytest.mark.indevelopment def test_ktensor_arrange(sample_ktensor_2way): (data, K) = sample_ktensor_2way # permutation only K0 = ttb.ktensor.from_tensor_type(K) - p = [1,0] + p = [1, 0] K0.arrange(permutation=p) assert (K0.weights == data["weights"][p]).all() assert (K0.factor_matrices[0] == data["factor_matrices"][0][:, p]).all() @@ -187,7 +214,7 @@ def test_ktensor_arrange(sample_ktensor_2way): K1.arrange() weights = np.array([89.4427191, 27.20294102]) fm0 = np.array([[0.4472136, 0.31622777], [0.89442719, 0.9486833]]) - fm1 = np.array([[0.6, 0.58123819], [0.8, 0.81373347]]) + fm1 = np.array([[0.6, 0.58123819], [0.8, 0.81373347]]) assert np.linalg.norm(K1.weights - weights) < 1e-8 assert np.linalg.norm(K1.factor_matrices[0] - fm0) < 1e-8 assert np.linalg.norm(K1.factor_matrices[1] - fm1) < 1e-8 @@ -195,12 +222,19 @@ def test_ktensor_arrange(sample_ktensor_2way): # error, cannot shoft weight and permute simultaneously with pytest.raises(AssertionError) as excinfo: K1.arrange(weight_factor=0, permutation=p) - assert "Weighting and permuting the ktensor at the same time is not allowed." in str(excinfo) + assert ( + "Weighting and permuting the ktensor at the same time is not allowed." + in str(excinfo) + ) # error, length of permutation must equal number of components in ktensor with pytest.raises(AssertionError) as excinfo: - K1.arrange(permutation=[0,1,2]) - assert "Number of elements in permutation does not match number of components in ktensor." in str(excinfo) + K1.arrange(permutation=[0, 1, 2]) + assert ( + "Number of elements in permutation does not match number of components in ktensor." + in str(excinfo) + ) + @pytest.mark.indevelopment def test_ktensor_copy(sample_ktensor_2way): @@ -214,15 +248,43 @@ def test_ktensor_copy(sample_ktensor_2way): K1.weights[0] = 0 assert not (K0.weights[0] == K1.weights[0]) + @pytest.mark.indevelopment def test_ktensor_double(sample_ktensor_2way, sample_ktensor_3way): (data2, K2) = sample_ktensor_2way - assert (K2.double() == np.array([[29., 39.], [63., 85.]])).all() + assert (K2.double() == np.array([[29.0, 39.0], [63.0, 85.0]])).all() (data3, K3) = sample_ktensor_3way - A = np.array([ 830., 888., 946., 1004., 942., 1008., 1074., 1140., 1054., 1128., 1202., 1276., - 1180., 1264., 1348., 1432., 1344., 1440., 1536., 1632., 1508., 1616., 1724., 1832.]).reshape((2, 3, 4)) + A = np.array( + [ + 830.0, + 888.0, + 946.0, + 1004.0, + 942.0, + 1008.0, + 1074.0, + 1140.0, + 1054.0, + 1128.0, + 1202.0, + 1276.0, + 1180.0, + 1264.0, + 1348.0, + 1432.0, + 1344.0, + 1440.0, + 1536.0, + 1632.0, + 1508.0, + 1616.0, + 1724.0, + 1832.0, + ] + ).reshape((2, 3, 4)) assert (K3.double() == A).all() + @pytest.mark.indevelopment def test_ktensor_end(sample_ktensor_3way): (data, K) = sample_ktensor_3way @@ -231,6 +293,7 @@ def test_ktensor_end(sample_ktensor_3way): assert K.end(k=1) == 2 assert K.end(k=2) == 3 + @pytest.mark.indevelopment def test_ktensor_extract(sample_ktensor_3way): (data, K) = sample_ktensor_3way @@ -261,17 +324,25 @@ def test_ktensor_extract(sample_ktensor_3way): # wrong component index type with pytest.raises(AssertionError) as excinfo: K.extract(1.0) - assert "Input parameter must be an int, tuple, list or numpy.ndarray" in str(excinfo) + assert "Input parameter must be an int, tuple, list or numpy.ndarray" in str( + excinfo + ) # too many components with pytest.raises(AssertionError) as excinfo: - K.extract([0,1,2,3]) - assert "Number of components requested is not valid: 4 (should be in [1,...,2])." in str(excinfo) + K.extract([0, 1, 2, 3]) + assert ( + "Number of components requested is not valid: 4 (should be in [1,...,2])." + in str(excinfo) + ) # component index out of range with pytest.raises(AssertionError) as excinfo: K.extract((5)) - assert "Invalid component indices to be extracted: [5] not in range(2)" in str(excinfo) + assert "Invalid component indices to be extracted: [5] not in range(2)" in str( + excinfo + ) + @pytest.mark.indevelopment def test_ktensor_fixsigns(sample_ktensor_2way): @@ -288,25 +359,32 @@ def test_ktensor_fixsigns(sample_ktensor_2way): # use different ktensor for fixing the signs K3 = K.copy() - K3.factor_matrices[0][1, 1] = - K3.factor_matrices[0][1, 1] - K3.factor_matrices[1][1, 1] = - K3.factor_matrices[1][1, 1] + K3.factor_matrices[0][1, 1] = -K3.factor_matrices[0][1, 1] + K3.factor_matrices[1][1, 1] = -K3.factor_matrices[1][1, 1] K = K.fixsigns(K3) weights1 = np.array([27.202941017470888, 89.44271909999159]) - factor_matrix10 = np.array([[ 0.3162277660168379, -0.4472135954999579], - [ 0.9486832980505138, -0.8944271909999159]]) - factor_matrix11 = np.array([[0.5812381937190965, -0.6 ], - [0.813733471206735, -0.8 ]]) + factor_matrix10 = np.array( + [ + [0.3162277660168379, -0.4472135954999579], + [0.9486832980505138, -0.8944271909999159], + ] + ) + factor_matrix11 = np.array([[0.5812381937190965, -0.6], [0.813733471206735, -0.8]]) assert np.linalg.norm(K.weights - weights1) < 1e-8 assert np.linalg.norm(K.factor_matrices[0] - factor_matrix10) < 1e-8 assert np.linalg.norm(K.factor_matrices[1] - factor_matrix11) < 1e-8 + @pytest.mark.indevelopment def test_ktensor_full(sample_ktensor_2way, sample_ktensor_3way): (data, K2) = sample_ktensor_2way - assert K2.full().isequal(ttb.tensor.from_data(np.array([[29., 39.], [63., 85.]]), (2, 2))) + assert K2.full().isequal( + ttb.tensor.from_data(np.array([[29.0, 39.0], [63.0, 85.0]]), (2, 2)) + ) (data, K3) = sample_ktensor_3way print(K3.full()) + @pytest.mark.indevelopment def test_ktensor_innerprod(sample_ktensor_2way): (data, K) = sample_ktensor_2way @@ -320,7 +398,7 @@ def test_ktensor_innerprod(sample_ktensor_2way): # test with sptensor Ssubs = np.array([[0, 0], [0, 1], [1, 1]]) - Svals = np.array([[0.5], [1.], [1.5]]) + Svals = np.array([[0.5], [1.0], [1.5]]) Sshape = (2, 2) S = ttb.sptensor().from_data(Ssubs, Svals, Sshape) assert K.innerprod(S) == 181 @@ -331,6 +409,7 @@ def test_ktensor_innerprod(sample_ktensor_2way): K.innerprod(K1) assert "Innerprod can only be computed for tensors of the same size" in str(excinfo) + @pytest.mark.indevelopment def test_ktensor_isequal(sample_ktensor_2way): (data, K0) = sample_ktensor_2way @@ -351,77 +430,100 @@ def test_ktensor_isequal(sample_ktensor_2way): K4.factor_matrices[0] = np.zeros((2, 2)) assert ~(K0.isequal(K4)) + @pytest.mark.indevelopment def test_ktensor_issymetric(sample_ktensor_2way, sample_ktensor_symmetric): # should not be symmetric (data, K) = sample_ktensor_2way assert ~(K.issymmetric()) issym, diffs = K.issymmetric(return_diffs=True) - assert (diffs == np.array([[0., 8.], [0., 0]])).all() + assert (diffs == np.array([[0.0, 8.0], [0.0, 0]])).all() # should be symmetric (datas, K1) = sample_ktensor_symmetric assert K1.issymmetric() issym1, diffs1 = K1.issymmetric(return_diffs=True) - assert (diffs1 == np.array([[0., 0.], [0., 0]])).all() + assert (diffs1 == np.array([[0.0, 0.0], [0.0, 0]])).all() # Wrong shape K2 = ttb.ktensor.from_function(np.ones, (2, 3), 2) assert ~(K2.issymmetric()) issym2, diffs2 = K2.issymmetric(return_diffs=True) - assert (diffs2 == np.array([[0., np.inf], [0., 0]])).all() + assert (diffs2 == np.array([[0.0, np.inf], [0.0, 0]])).all() + @pytest.mark.indevelopment def test_ktensor_mask(sample_ktensor_2way): (data, K) = sample_ktensor_2way W = ttb.tensor.from_data(np.array([[0, 1], [1, 0]])) - assert (K.mask(W) == np.array([[39], [63]])).all() + assert (K.mask(W) == np.array([[63], [39]])).all() # Mask too large with pytest.raises(AssertionError) as excinfo: K.mask(ttb.tensor.from_function(np.ones, (2, 3, 4))) assert "Mask cannot be bigger than the data tensor" in str(excinfo) + @pytest.mark.indevelopment def test_ktensor_mttkrp(sample_ktensor_3way): (data, K) = sample_ktensor_3way K1 = ttb.ktensor.from_function(np.ones, (2, 3, 4), 4) - output0 = np.array([[12492., 12492., 12492., 12492.], - [17856., 17856., 17856., 17856.]]) + output0 = np.array( + [[12492.0, 12492.0, 12492.0, 12492.0], [17856.0, 17856.0, 17856.0, 17856.0]] + ) assert (K.mttkrp(K1.factor_matrices, 0) == output0).all() - output1 = np.array([[ 8892., 8892., 8892., 8892], - [10116., 10116., 10116., 10116], - [11340., 11340., 11340., 11340]]) + output1 = np.array( + [ + [8892.0, 8892.0, 8892.0, 8892], + [10116.0, 10116.0, 10116.0, 10116], + [11340.0, 11340.0, 11340.0, 11340], + ] + ) assert (K.mttkrp(K1.factor_matrices, 1) == output1).all() - output2 = np.array([[6858., 6858., 6858., 6858], - [7344., 7344., 7344., 7344], - [7830., 7830., 7830., 7830], - [8316., 8316., 8316., 8316]]) + output2 = np.array( + [ + [6858.0, 6858.0, 6858.0, 6858], + [7344.0, 7344.0, 7344.0, 7344], + [7830.0, 7830.0, 7830.0, 7830], + [8316.0, 8316.0, 8316.0, 8316], + ] + ) assert (K.mttkrp(K1.factor_matrices, 2) == output2).all() # Wrong number of factor matrices - fm_wrong_size = [K1.factor_matrices[0], K1.factor_matrices[0], K1.factor_matrices[0], np.ones((5, 4))] + fm_wrong_size = [ + K1.factor_matrices[0], + K1.factor_matrices[0], + K1.factor_matrices[0], + np.ones((5, 4)), + ] with pytest.raises(AssertionError) as excinfo: K.mttkrp(fm_wrong_size, 0) - assert 'List of factor matrices is the wrong length' in str(excinfo)\ - + assert "List of factor matrices is the wrong length" in str(excinfo) # Wrong input type - fm_wrong_type = (K1.factor_matrices[0], K1.factor_matrices[0], K1.factor_matrices[0]) + fm_wrong_type = ( + K1.factor_matrices[0], + K1.factor_matrices[0], + K1.factor_matrices[0], + ) with pytest.raises(AssertionError) as excinfo: K.mttkrp(fm_wrong_type, 0) - assert "Second argument must be list of numpy.ndarray's" in str(excinfo)\ + assert "Second argument must be list of numpy.ndarray's" in str(excinfo) + @pytest.mark.indevelopment def test_ktensor_ncomponents(sample_ktensor_2way): (data, K0) = sample_ktensor_2way - assert (K0.ncomponents == 2) + assert K0.ncomponents == 2 + @pytest.mark.indevelopment def test_ktensor_ndims(sample_ktensor_2way, sample_ktensor_3way): (data, K0) = sample_ktensor_2way - assert (K0.ndims == 2) + assert K0.ndims == 2 data, K1 = sample_ktensor_3way - assert (K1.ndims == 3) + assert K1.ndims == 3 + @pytest.mark.indevelopment def test_ktensor_norm(): @@ -433,11 +535,12 @@ def test_ktensor_norm(): rank = 2 shape = np.array([2, 3, 4]) - data = np.arange(1, rank*sum(shape)+1) + data = np.arange(1, rank * sum(shape) + 1) weights = 2 * np.ones(rank) weights_and_data = np.concatenate((weights, data), axis=0) K2 = ttb.ktensor.from_vector(weights_and_data[:], shape, True) - assert pytest.approx(K2.norm(), 1e-8) == 6.337788257744180e+03 + assert pytest.approx(K2.norm(), 1e-8) == 6.337788257744180e03 + @pytest.mark.indevelopment def test_ktensor_normalize(sample_ktensor_2way, sample_ktensor_3way): @@ -450,30 +553,41 @@ def test_ktensor_normalize(sample_ktensor_2way, sample_ktensor_3way): # normalize a single mode mode = 1 - #print("\nK0\n",K0) + # print("\nK0\n",K0) K0.normalize(mode=mode) - #print("\nK0\n",K0) + # print("\nK0\n",K0) weights0 = np.array([20.97617696340303, 31.304951684997057]) - factor_matrix0 = np.array([[0.4767312946227962, 0.5111012519999519], - [0.5720775535473555, 0.5749889084999459], - [0.6674238124719146, 0.6388765649999399]]) + factor_matrix0 = np.array( + [ + [0.4767312946227962, 0.5111012519999519], + [0.5720775535473555, 0.5749889084999459], + [0.6674238124719146, 0.6388765649999399], + ] + ) assert np.linalg.norm(K0.weights - weights0) < 1e-8 assert np.linalg.norm(K0.factor_matrices[mode] - factor_matrix0) < 1e-8 # normalize using the defaults - #print("\nK1\n",K1) + # print("\nK1\n",K1) K1.normalize() - #print("\nK1\n",K1) + # print("\nK1\n",K1) weights1 = np.array([1177.285012220915, 5177.161384388167]) - factor_matrix10 = np.array([[0.4472135954999579, 0.6 ], - [0.8944271909999159, 0.8 ]]) - factor_matrix11 = np.array([[0.4767312946227962, 0.5111012519999519], - [0.5720775535473555, 0.5749889084999459], - [0.6674238124719146, 0.6388765649999399]]) - factor_matrix12 = np.array([[0.4382504900892777, 0.4535055413676754], - [0.4780914437337575, 0.4837392441255204], - [0.5179323973782373, 0.5139729468833655], - [0.5577733510227171, 0.5442066496412105]]) + factor_matrix10 = np.array([[0.4472135954999579, 0.6], [0.8944271909999159, 0.8]]) + factor_matrix11 = np.array( + [ + [0.4767312946227962, 0.5111012519999519], + [0.5720775535473555, 0.5749889084999459], + [0.6674238124719146, 0.6388765649999399], + ] + ) + factor_matrix12 = np.array( + [ + [0.4382504900892777, 0.4535055413676754], + [0.4780914437337575, 0.4837392441255204], + [0.5179323973782373, 0.5139729468833655], + [0.5577733510227171, 0.5442066496412105], + ] + ) assert np.linalg.norm(K1.weights - weights1) < 1e-8 assert np.linalg.norm(K1.factor_matrices[0] - factor_matrix10) < 1e-8 assert np.linalg.norm(K1.factor_matrices[1] - factor_matrix11) < 1e-8 @@ -481,19 +595,31 @@ def test_ktensor_normalize(sample_ktensor_2way, sample_ktensor_3way): # normalize using vector 1-norm normtype = 1 - #print("\nK2\n",K2) + # print("\nK2\n",K2) K2.normalize(normtype=normtype) - #print("\nK2\n",K2) - weights2 = np.array([5400., 24948.]) - factor_matrix20 = np.array([[0.3333333333333333, 0.4285714285714285], - [0.6666666666666666, 0.5714285714285714]]) - factor_matrix21 = np.array([[0.2777777777777778, 0.2962962962962963], - [0.3333333333333333, 0.3333333333333333], - [0.3888888888888888, 0.3703703703703703]]) - factor_matrix22 = np.array([[0.22, 0.2272727272727273], - [0.24, 0.2424242424242424], - [0.26, 0.2575757575757576], - [0.28, 0.2727272727272727]]) + # print("\nK2\n",K2) + weights2 = np.array([5400.0, 24948.0]) + factor_matrix20 = np.array( + [ + [0.3333333333333333, 0.4285714285714285], + [0.6666666666666666, 0.5714285714285714], + ] + ) + factor_matrix21 = np.array( + [ + [0.2777777777777778, 0.2962962962962963], + [0.3333333333333333, 0.3333333333333333], + [0.3888888888888888, 0.3703703703703703], + ] + ) + factor_matrix22 = np.array( + [ + [0.22, 0.2272727272727273], + [0.24, 0.2424242424242424], + [0.26, 0.2575757575757576], + [0.28, 0.2727272727272727], + ] + ) assert np.linalg.norm(K2.weights - weights2) < 1e-8 assert np.linalg.norm(K2.factor_matrices[0] - factor_matrix20) < 1e-8 assert np.linalg.norm(K2.factor_matrices[1] - factor_matrix21) < 1e-8 @@ -501,19 +627,28 @@ def test_ktensor_normalize(sample_ktensor_2way, sample_ktensor_3way): # normalize and shift all weight to factor 1 weight_factor = 1 - #print("\nK3\n",K3) + # print("\nK3\n",K3) K3.normalize(weight_factor=weight_factor) - #print("\nK3\n",K3) - weights3 = np.array([1., 1.]) - factor_matrix30 = np.array([[0.4472135954999579, 0.6000000000000001], - [0.8944271909999159, 0.8 ]]) - factor_matrix31 = np.array([[ 561.2486080160912, 2646.0536653665963], - [ 673.4983296193095, 2976.8103735374207], - [ 785.7480512225277, 3307.567081708246 ]]) - factor_matrix32 = np.array([[0.4382504900892776, 0.4535055413676753], - [0.4780914437337574, 0.4837392441255204], - [0.5179323973782373, 0.5139729468833654], - [0.557773351022717, 0.5442066496412105]]) + # print("\nK3\n",K3) + weights3 = np.array([1.0, 1.0]) + factor_matrix30 = np.array( + [[0.4472135954999579, 0.6000000000000001], [0.8944271909999159, 0.8]] + ) + factor_matrix31 = np.array( + [ + [561.2486080160912, 2646.0536653665963], + [673.4983296193095, 2976.8103735374207], + [785.7480512225277, 3307.567081708246], + ] + ) + factor_matrix32 = np.array( + [ + [0.4382504900892776, 0.4535055413676753], + [0.4780914437337574, 0.4837392441255204], + [0.5179323973782373, 0.5139729468833654], + [0.557773351022717, 0.5442066496412105], + ] + ) assert np.linalg.norm(K3.weights - weights3) < 1e-8 assert np.linalg.norm(K3.factor_matrices[0] - factor_matrix30) < 1e-8 assert np.linalg.norm(K3.factor_matrices[1] - factor_matrix31) < 1e-8 @@ -522,20 +657,32 @@ def test_ktensor_normalize(sample_ktensor_2way, sample_ktensor_3way): # error if the mode is not in the range of number of dimensions with pytest.raises(AssertionError) as excinfo: K0.normalize(mode=4) - assert "Parameter single_factor is invalid; index must be an int in range of number of dimensions" in str(excinfo) + assert ( + "Parameter single_factor is invalid; index must be an int in range of number of dimensions" + in str(excinfo) + ) # normalize and sort K4.normalize(sort=True) weights = np.array([5177.161384388167, 1177.285012220915]) - fm0 = np.array([[0.6000000000000001, 0.4472135954999579], - [0.8, 0.8944271909999159]]) - fm1 = np.array([[0.5111012519999519, 0.4767312946227962], - [0.5749889084999459, 0.5720775535473555], - [0.6388765649999399, 0.6674238124719146]]) - fm2 = np.array([[0.4535055413676753, 0.4382504900892776], - [0.4837392441255204, 0.4780914437337574], - [0.5139729468833654, 0.5179323973782373], - [0.5442066496412105, 0.557773351022717]]) + fm0 = np.array( + [[0.6000000000000001, 0.4472135954999579], [0.8, 0.8944271909999159]] + ) + fm1 = np.array( + [ + [0.5111012519999519, 0.4767312946227962], + [0.5749889084999459, 0.5720775535473555], + [0.6388765649999399, 0.6674238124719146], + ] + ) + fm2 = np.array( + [ + [0.4535055413676753, 0.4382504900892776], + [0.4837392441255204, 0.4780914437337574], + [0.5139729468833654, 0.5179323973782373], + [0.5442066496412105, 0.557773351022717], + ] + ) assert np.linalg.norm(K4.weights - weights) < 1e-8 assert np.linalg.norm(K4.factor_matrices[0] - fm0) < 1e-8 assert np.linalg.norm(K4.factor_matrices[1] - fm1) < 1e-8 @@ -543,61 +690,87 @@ def test_ktensor_normalize(sample_ktensor_2way, sample_ktensor_3way): # distribute weight across all factors (data5, K5) = sample_ktensor_2way - K5.normalize(weight_factor='all') - weights = np.array([1., 1.]) - fm0 = np.array([[1.6493314105258194, 4.229485053762256 ], - [4.947994231577458, 8.458970107524513 ]]) - fm1 = np.array([[3.031531424242968, 5.674449654019056], - [4.244143993940155, 7.565932872025407]]) + K5.normalize(weight_factor="all") + weights = np.array([1.0, 1.0]) + fm0 = np.array( + [ + [1.6493314105258194, 4.229485053762256], + [4.947994231577458, 8.458970107524513], + ] + ) + fm1 = np.array( + [[3.031531424242968, 5.674449654019056], [4.244143993940155, 7.565932872025407]] + ) assert np.allclose(K5.weights, weights) assert np.allclose(K5.factor_matrices[0], fm0) assert np.allclose(K5.factor_matrices[1], fm1) + @pytest.mark.indevelopment def test_ktensor_nvecs(sample_ktensor_3way): (data, K) = sample_ktensor_3way - with pytest.warns(Warning) as record: - assert np.allclose(K.nvecs(0, 1), np.array([[0.5731077440321353], - [0.8194800264377384]])) - assert 'Greater than or equal to ktensor.shape[n] - 1 eigenvectors requires cast to dense to solve' in str( - record[0].message) - with pytest.warns(Warning) as record: - assert np.allclose(K.nvecs(0, 2), np.array([[0.5731077440321353, 0.8194800264377384], - [0.8194800264377384, -0.5731077440321353]])) - assert 'Greater than or equal to ktensor.shape[n] - 1 eigenvectors requires cast to dense to solve' in str( - record[0].message) - - assert np.allclose(K.nvecs(1, 1), np.array([[0.5048631426517823], - [0.5745404391632514], - [0.6442177356747206]])) - with pytest.warns(Warning) as record: - assert np.allclose(K.nvecs(1, 2), np.array([[ 0.5048631426517821, 0.7605567306550753], - [ 0.5745404391632517, 0.0568912743440822], - [ 0.6442177356747206, -0.6467741818894517]])) - assert 'Greater than or equal to ktensor.shape[n] - 1 eigenvectors requires cast to dense to solve' in str( - record[0].message) - - assert np.allclose(K.nvecs(2, 1), np.array([[0.4507198734531968], - [0.4827189140450413], - [0.5147179546368857], - [0.5467169952287301]])) - assert np.allclose(K.nvecs(2, 2), np.array([[ 0.4507198734531969, 0.7048770074600103], - [ 0.4827189140450412, 0.2588096791802433], - [ 0.5147179546368857, -0.1872576491687805], - [ 0.5467169952287302, -0.6333249775151949]])) + assert np.allclose( + K.nvecs(0, 1), np.array([[0.5731077440321353], [0.8194800264377384]]) + ) + assert np.allclose( + K.nvecs(0, 2), + np.array( + [ + [0.5731077440321353, 0.8194800264377384], + [0.8194800264377384, -0.5731077440321353], + ] + ), + ) + + assert np.allclose( + K.nvecs(1, 1), + np.array([[0.5048631426517823], [0.5745404391632514], [0.6442177356747206]]), + ) + assert np.allclose( + K.nvecs(1, 2), + np.array( + [ + [0.5048631426517821, 0.7605567306550753], + [0.5745404391632517, 0.0568912743440822], + [0.6442177356747206, -0.6467741818894517], + ] + ), + ) + + assert np.allclose( + K.nvecs(2, 1), + np.array( + [ + [0.4507198734531968], + [0.4827189140450413], + [0.5147179546368857], + [0.5467169952287301], + ] + ), + ) + assert np.allclose( + K.nvecs(2, 2), + np.array( + [ + [0.4507198734531969, 0.7048770074600103], + [0.4827189140450412, 0.2588096791802433], + [0.5147179546368857, -0.1872576491687805], + [0.5467169952287302, -0.6333249775151949], + ] + ), + ) # Test for r >= N-1, requires cast to dense - with pytest.warns(Warning) as record: - K.nvecs(1, 3) - assert 'Greater than or equal to ktensor.shape[n] - 1 eigenvectors requires cast to dense to solve' in str(record[0].message) + K.nvecs(1, 3) + @pytest.mark.indevelopment def test_ktensor_permute(sample_ktensor_3way): (data, K) = sample_ktensor_3way order = np.array([2, 0, 1]) fm = [data["factor_matrices"][i] for i in order] - K0 = ttb.ktensor.from_data(data['weights'], fm) + K0 = ttb.ktensor.from_data(data["weights"], fm) assert K0.isequal(K.permute(order)) # invalid permutation @@ -606,6 +779,7 @@ def test_ktensor_permute(sample_ktensor_3way): K.permute(order_invalid) assert "Invalid permutation" in str(excinfo) + @pytest.mark.indevelopment def test_ktensor_redistribute(sample_ktensor_2way): (data, K) = sample_ktensor_2way @@ -614,7 +788,57 @@ def test_ktensor_redistribute(sample_ktensor_2way): assert (np.array([[5, 6], [7, 8]]) == K[1]).all() assert (np.array([1, 1]) == K.weights).all() -@pytest.mark.indevelopment + +pytest.mark.indevelopment + + +def test_ktensor_score(): + A = ttb.ktensor.from_data( + np.array([2, 1, 3]), np.ones((3, 3)), np.ones((4, 3)), np.ones((5, 3)) + ) + B = ttb.ktensor.from_data( + np.array([2, 4]), np.ones((3, 2)), np.ones((4, 2)), np.ones((5, 2)) + ) + + # defaults + score, Aperm, flag, best_perm = A.score(B) + assert score == 0.875 + assert np.allclose(Aperm.weights, np.array([15.49193338, 23.23790008, 7.74596669])) + assert flag == 1 + assert (best_perm == np.array([0, 2, 1])).all() + + # compare just factor matrices (i.e., do not use weights) + score, Aperm, flag, best_perm = A.score(B, weight_penalty=False) + assert score == 1.0 + assert np.allclose(Aperm.weights, np.array([15.49193338, 7.74596669, 23.23790008])) + assert flag == 1 + assert (best_perm == np.array([0, 1, 2])).all() + + # compute score using exhaustive search + with pytest.raises(AssertionError) as excinfo: + score, Aperm, flag, best_perm = A.score(B, greedy=False) + assert "Not yet implemented. Only greedy method is implemented currently." in str( + excinfo + ) + + # try to compute score with tensor type other than ktensor + with pytest.raises(AssertionError) as excinfo: + score, Aperm, flag, best_perm = A.score(ttb.tensor.from_tensor_type(B)) + assert "The first input should be a ktensor" in str(excinfo) + + # try to compute score when ktensor dimensions do not match + with pytest.raises(AssertionError) as excinfo: + # A is 3x4x5; B is 3x4x4 + B = ttb.ktensor.from_data( + np.array([2, 4]), np.ones((3, 2)), np.ones((4, 2)), np.ones((4, 2)) + ) + score, Aperm, flag, best_perm = A.score(B) + assert "Size mismatch" in str(excinfo) + + +pytest.mark.indevelopment + + def test_ktensor_shape(sample_ktensor_2way, sample_ktensor_3way): (data, K0) = sample_ktensor_2way assert K0.shape == (2, 2) @@ -631,50 +855,69 @@ def test_ktensor_shape(sample_ktensor_2way, sample_ktensor_3way): K1.shape[3] assert "tuple index out of range" in str(excinfo) + @pytest.mark.indevelopment def test_ktensor_symmetrize(sample_ktensor_2way): (data, K) = sample_ktensor_2way K1 = K.symmetrize() - weights = np.array([1., 1.]) - fm = np.array([[2.340431417384394,4.951967353890656], - [4.596069112758807, 8.01245148977496 ]]) + weights = np.array([1.0, 1.0]) + fm = np.array( + [[2.340431417384394, 4.951967353890656], [4.596069112758807, 8.01245148977496]] + ) assert np.allclose(K1.weights, weights) assert np.allclose(K1.factor_matrices[0], fm) assert np.allclose(K1.factor_matrices[1], fm) assert K1.issymmetric() # odd-ordered ktensor with negative weight - weights = np.array([1., 2., -3.]) - fm0 = np.ones((4,3)) - fm1 = np.ones((4,3)) - fm2 = -np.ones((4,3)) + weights = np.array([1.0, 2.0, -3.0]) + fm0 = np.ones((4, 3)) + fm1 = np.ones((4, 3)) + fm2 = -np.ones((4, 3)) K2 = ttb.ktensor.from_data(weights, [fm0, fm1, fm2]) K3 = K2.symmetrize() - out_fm = np.array([[-1., -1.2599210498948732, 1.442249570307408], - [-1., -1.2599210498948732, 1.442249570307408], - [-1., -1.2599210498948732, 1.442249570307408], - [-1., -1.2599210498948732, 1.442249570307408]]) - assert np.allclose(K1.weights, np.ones((3,1))) + out_fm = np.array( + [ + [-1.0, -1.2599210498948732, 1.442249570307408], + [-1.0, -1.2599210498948732, 1.442249570307408], + [-1.0, -1.2599210498948732, 1.442249570307408], + [-1.0, -1.2599210498948732, 1.442249570307408], + ] + ) + assert np.allclose(K1.weights, np.ones((3, 1))) assert np.allclose(K3.factor_matrices[0], out_fm) assert np.allclose(K3.factor_matrices[1], out_fm) assert np.allclose(K3.factor_matrices[2], out_fm) assert K3.issymmetric() + @pytest.mark.indevelopment def test_ktensor_tolist(sample_ktensor_3way): (data, K) = sample_ktensor_3way # weights spread equally to all factor matrices fm0 = K.tolist() - m0 = np.array([[1.2599210498948732, 3.7797631496846193], - [2.5198420997897464, 5.039684199579493]]) - m1 = np.array([[ 6.299605249474366, 10.079368399158986], - [ 7.559526299369239, 11.339289449053858], - [ 8.819447349264113, 12.599210498948732]]) - m2 = np.array([[13.859131548843605, 18.898815748423097], - [15.119052598738477, 20.15873679831797 ], - [16.37897364863335, 21.418657848212845], - [17.638894698528226, 22.678578898107716]]) + m0 = np.array( + [ + [1.2599210498948732, 3.7797631496846193], + [2.5198420997897464, 5.039684199579493], + ] + ) + m1 = np.array( + [ + [6.299605249474366, 10.079368399158986], + [7.559526299369239, 11.339289449053858], + [8.819447349264113, 12.599210498948732], + ] + ) + m2 = np.array( + [ + [13.859131548843605, 18.898815748423097], + [15.119052598738477, 20.15873679831797], + [16.37897364863335, 21.418657848212845], + [17.638894698528226, 22.678578898107716], + ] + ) assert np.allclose(fm0[0], m0) assert np.allclose(fm0[1], m1) assert np.allclose(fm0[2], m2) @@ -687,15 +930,27 @@ def test_ktensor_tolist(sample_ktensor_3way): # weight spread to a single factor matrix fm1 = K.tolist(0) - m0 = np.array([[ 526.4978632435273, 3106.2968306329008], - [1052.9957264870545, 4141.729107510534 ]]) - m1 = np.array([[0.4767312946227962, 0.5111012519999519], - [0.5720775535473555, 0.5749889084999459], - [0.6674238124719146, 0.6388765649999399]]) - m2 = np.array([[0.4382504900892776, 0.4535055413676753], - [0.4780914437337574, 0.4837392441255204], - [0.5179323973782373, 0.5139729468833654], - [0.557773351022717, 0.5442066496412105]]) + m0 = np.array( + [ + [526.4978632435273, 3106.2968306329008], + [1052.9957264870545, 4141.729107510534], + ] + ) + m1 = np.array( + [ + [0.4767312946227962, 0.5111012519999519], + [0.5720775535473555, 0.5749889084999459], + [0.6674238124719146, 0.6388765649999399], + ] + ) + m2 = np.array( + [ + [0.4382504900892776, 0.4535055413676753], + [0.4780914437337574, 0.4837392441255204], + [0.5179323973782373, 0.5139729468833654], + [0.557773351022717, 0.5442066496412105], + ] + ) assert np.allclose(fm1[0], m0) assert np.allclose(fm1[1], m1) assert np.allclose(fm1[2], m2) @@ -705,19 +960,21 @@ def test_ktensor_tolist(sample_ktensor_3way): K.tolist(4) assert "Input parameter'mode' must be in the range of self.ndims" in str(excinfo) + @pytest.mark.indevelopment def test_ktensor_tovec(sample_ktensor_3way): (data, K0) = sample_ktensor_3way - assert(data["vector_with_weights"] == K0.tovec()).all() - assert(data["vector"] == K0.tovec(include_weights=False)).all() + assert (data["vector_with_weights"] == K0.tovec()).all() + assert (data["vector"] == K0.tovec(include_weights=False)).all() + @pytest.mark.indevelopment def test_ktensor_ttv(sample_ktensor_3way): (data, K) = sample_ktensor_3way K0 = K.ttv(np.array([1, 1, 1]), dims=1) - weights = np.array([36., 54.]) - fm0 = np.array([[1., 3.], [2., 4.]]) - fm1 = np.array([[11., 15.], [12., 16.], [13., 17.], [14., 18.]]) + weights = np.array([36.0, 54.0]) + fm0 = np.array([[1.0, 3.0], [2.0, 4.0]]) + fm1 = np.array([[11.0, 15.0], [12.0, 16.0], [13.0, 17.0], [14.0, 18.0]]) factor_matrices = [fm0, fm1] K1 = ttb.ktensor.from_data(weights, factor_matrices) assert K0.isequal(K1) @@ -726,18 +983,24 @@ def test_ktensor_ttv(sample_ktensor_3way): vec2 = np.array([1, 1]) vec3 = np.array([1, 1, 1]) vec4 = np.array([1, 1, 1, 1]) - assert K.ttv(np.array([vec2, vec3, vec4])) == 30348 + assert K.ttv([vec2, vec3, vec4]) == 30348 + + # Exclude dims should mirror dims + assert K.ttv([vec2, vec3], dims=np.array([0, 1])).isequal( + K.ttv([vec2, vec3], exclude_dims=2) + ) # Wrong shape with pytest.raises(AssertionError) as excinfo: - K.ttv(np.array([vec2, vec3, np.array([1,2])])) + K.ttv([vec2, vec3, np.array([1, 2])]) assert "Multiplicand is wrong size" in str(excinfo) # Multiple dimensions, but fewer than all dimensions, not in same order as ktensor dimensions - K2 = K.ttv(np.array([vec4, vec3]), dims=np.array([2, 1])) - weights = np.array([1800., 3564.]) - fm0 = np.array([[1., 3.], [2., 4.]]) - assert (K2.isequal(ttb.ktensor.from_data(weights, fm0))) + K2 = K.ttv([vec4, vec3], dims=np.array([2, 1])) + weights = np.array([1800.0, 3564.0]) + fm0 = np.array([[1.0, 3.0], [2.0, 4.0]]) + assert K2.isequal(ttb.ktensor.from_data(weights, fm0)) + @pytest.mark.indevelopment def test_ktensor_update(sample_ktensor_3way): @@ -749,26 +1012,26 @@ def test_ktensor_update(sample_ktensor_3way): vec1 = np.random.randn(K.shape[1] * K.ncomponents) vec2 = np.random.randn(K.shape[2] * K.ncomponents) K1.update(0, vec0) - assert (K1.factor_matrices[0] == vec0.reshape((K1.shape[0], K1.ncomponents))).all() + assert (K1.factor_matrices[0] == vec0.reshape((K1.shape[0], K1.ncomponents))).all() K1.update(1, vec1) - assert (K1.factor_matrices[1] == vec1.reshape((K1.shape[1], K1.ncomponents))).all() + assert (K1.factor_matrices[1] == vec1.reshape((K1.shape[1], K1.ncomponents))).all() K1.update(2, vec2) - assert (K1.factor_matrices[2] == vec2.reshape((K1.shape[2], K1.ncomponents))).all() + assert (K1.factor_matrices[2] == vec2.reshape((K1.shape[2], K1.ncomponents))).all() # all factor matrix updates K2 = ttb.ktensor.from_tensor_type(K) vec_all = np.concatenate((vec0, vec1, vec2)) K2.update([0, 1, 2], vec_all) - assert (K2.factor_matrices[0] == vec0.reshape((K2.shape[0], K2.ncomponents))).all() - assert (K2.factor_matrices[1] == vec1.reshape((K2.shape[1], K2.ncomponents))).all() - assert (K2.factor_matrices[2] == vec2.reshape((K2.shape[2], K2.ncomponents))).all() + assert (K2.factor_matrices[0] == vec0.reshape((K2.shape[0], K2.ncomponents))).all() + assert (K2.factor_matrices[1] == vec1.reshape((K2.shape[1], K2.ncomponents))).all() + assert (K2.factor_matrices[2] == vec2.reshape((K2.shape[2], K2.ncomponents))).all() # multiple but not all factor matrix updates K3 = ttb.ktensor.from_tensor_type(K) vec_some = np.concatenate((vec0, vec2)) K3.update([0, 2], vec_some) - assert (K3.factor_matrices[0] == vec0.reshape((K3.shape[0], K3.ncomponents))).all() - assert (K3.factor_matrices[2] == vec2.reshape((K3.shape[2], K3.ncomponents))).all() + assert (K3.factor_matrices[0] == vec0.reshape((K3.shape[0], K3.ncomponents))).all() + assert (K3.factor_matrices[2] == vec2.reshape((K3.shape[2], K3.ncomponents))).all() # weights update weights = np.array([100, 200]) @@ -799,8 +1062,8 @@ def test_ktensor_update(sample_ktensor_3way): vec_all_plus = np.concatenate((vec_all, np.array([0.5]))) with pytest.warns(Warning) as record: K2.update([0, 1, 2], vec_all_plus) - assert 'Failed to consume all of the input data' \ - in str(record[0].message) + assert "Failed to consume all of the input data" in str(record[0].message) + @pytest.mark.indevelopment def test_ktensor__add__(sample_ktensor_2way, sample_ktensor_3way): @@ -810,7 +1073,12 @@ def test_ktensor__add__(sample_ktensor_2way, sample_ktensor_3way): K2 = K0 + K0 assert (np.concatenate((data0["weights"], data0["weights"])) == K2.weights).all() for k in range(K2.ndims): - assert (np.concatenate((data0["factor_matrices"][k], data0["factor_matrices"][k]), axis=1) == K2.factor_matrices[k]).all() + assert ( + np.concatenate( + (data0["factor_matrices"][k], data0["factor_matrices"][k]), axis=1 + ) + == K2.factor_matrices[k] + ).all() # shapes do not match, should raise error with pytest.raises(AssertionError) as excinfo: @@ -819,14 +1087,15 @@ def test_ktensor__add__(sample_ktensor_2way, sample_ktensor_3way): # types do not match, should raise error with pytest.raises(AssertionError) as excinfo: - K0 + np.ones((2,2)) - assert 'Cannot add instance of this type to a ktensor' in str(excinfo) + K0 + np.ones((2, 2)) + assert "Cannot add instance of this type to a ktensor" in str(excinfo) with pytest.raises(AssertionError) as excinfo: K0 + ttb.tensor() - assert 'Cannot add instance of this type to a ktensor' in str(excinfo) + assert "Cannot add instance of this type to a ktensor" in str(excinfo) with pytest.raises(AssertionError) as excinfo: K0 + ttb.sptensor() - assert 'Cannot add instance of this type to a ktensor' in str(excinfo) + assert "Cannot add instance of this type to a ktensor" in str(excinfo) + @pytest.mark.indevelopment def test_ktensor__getitem__(sample_ktensor_2way): @@ -836,16 +1105,20 @@ def test_ktensor__getitem__(sample_ktensor_2way): assert K[0, 1] == 39 assert K[1, 0] == 63 assert K[1, 1] == 85 - assert (data['factor_matrices'][0] == K[0]).all() - assert (data['factor_matrices'][1] == K[1]).all() + assert (data["factor_matrices"][0] == K[0]).all() + assert (data["factor_matrices"][1] == K[1]).all() # to return a 2D ndarray, the columns must be defined by a slice - assert (data['factor_matrices'][0][:, [0]] == K[0][:, [0]]).all() + assert (data["factor_matrices"][0][:, [0]] == K[0][:, [0]]).all() with pytest.raises(AssertionError) as excinfo: K[0, 0, 0] assert "ktensor.__getitem__ requires tuples with 2 elements" in str(excinfo) with pytest.raises(AssertionError) as excinfo: K[5] - assert 'ktensor.__getitem__() can only extract single elements (tuple of indices) or factor matrices (single index)' in str(excinfo) + assert ( + "ktensor.__getitem__() can only extract single elements (tuple of indices) or factor matrices (single index)" + in str(excinfo) + ) + @pytest.mark.indevelopment def test_ktensor__neg__(sample_ktensor_2way): @@ -858,6 +1131,7 @@ def test_ktensor__neg__(sample_ktensor_2way): assert (data0["factor_matrices"][k] == K1.factor_matrices[k]).all() assert K2.isequal(K0) + @pytest.mark.indevelopment def test_ktensor__pos__(sample_ktensor_2way): (data0, K0) = sample_ktensor_2way @@ -868,13 +1142,18 @@ def test_ktensor__pos__(sample_ktensor_2way): assert (data0["factor_matrices"][k] == K1.factor_matrices[k]).all() assert K1.isequal(K0) + @pytest.mark.indevelopment def test_ktensor__setitem__(sample_ktensor_2way): (data, K) = sample_ktensor_2way # adding ktensor to itself with pytest.raises(AssertionError) as excinfo: K[0, 0] = 1 - assert "Subscripted assignment cannot be used to update individual elements of a ktensor." in str(excinfo) + assert ( + "Subscripted assignment cannot be used to update individual elements of a ktensor." + in str(excinfo) + ) + @pytest.mark.indevelopment def test_ktensor__sub__(sample_ktensor_2way, sample_ktensor_3way): @@ -884,7 +1163,12 @@ def test_ktensor__sub__(sample_ktensor_2way, sample_ktensor_3way): K2 = K0 - K0 assert (np.concatenate((data0["weights"], -data0["weights"])) == K2.weights).all() for k in range(K2.ndims): - assert (np.concatenate((data0["factor_matrices"][k], data0["factor_matrices"][k]), axis=1) == K2.factor_matrices[k]).all() + assert ( + np.concatenate( + (data0["factor_matrices"][k], data0["factor_matrices"][k]), axis=1 + ) + == K2.factor_matrices[k] + ).all() # shapes do not match, should raise error with pytest.raises(AssertionError) as excinfo: K3 = K0 - K1 @@ -892,14 +1176,15 @@ def test_ktensor__sub__(sample_ktensor_2way, sample_ktensor_3way): # types do not match, should raise error with pytest.raises(AssertionError) as excinfo: - K0 - np.ones((2,2)) - assert 'Cannot subtract instance of this type from a ktensor' in str(excinfo) + K0 - np.ones((2, 2)) + assert "Cannot subtract instance of this type from a ktensor" in str(excinfo) with pytest.raises(AssertionError) as excinfo: K0 - ttb.tensor() - assert 'Cannot subtract instance of this type from a ktensor' in str(excinfo) + assert "Cannot subtract instance of this type from a ktensor" in str(excinfo) with pytest.raises(AssertionError) as excinfo: K0 - ttb.sptensor() - assert 'Cannot subtract instance of this type from a ktensor' in str(excinfo) + assert "Cannot subtract instance of this type from a ktensor" in str(excinfo) + @pytest.mark.indevelopment def test_ktensor__mul__(sample_ktensor_2way, sample_ktensor_3way): @@ -914,25 +1199,29 @@ def test_ktensor__mul__(sample_ktensor_2way, sample_ktensor_3way): assert (data0["factor_matrices"][k] == K2.factor_matrices[k]).all() with pytest.raises(AssertionError) as excinfo: K3 = K0 * K0 - assert "Multiplication by ktensors only allowed for scalars, tensors, or sptensors" in str(excinfo) + assert ( + "Multiplication by ktensors only allowed for scalars, tensors, or sptensors" + in str(excinfo) + ) # test with tensor Tdata = np.array([[1, 2], [3, 4]]) Tshape = (2, 2) T = ttb.tensor().from_data(Tdata, Tshape) K0T = K0 * T - assert (K0T.double() == np.array([[29., 78.], [189., 340.]])).all() + assert (K0T.double() == np.array([[29.0, 78.0], [189.0, 340.0]])).all() # test with sptensor Ssubs = np.array([[0, 0], [0, 1], [1, 1]]) - Svals = np.array([[0.5], [1.], [1.5]]) + Svals = np.array([[0.5], [1.0], [1.5]]) Sshape = (2, 2) S = ttb.sptensor().from_data(Ssubs, Svals, Sshape) K0S = S * K0 - assert (K0S.double() == np.array([[14.5, 39.], [0., 127.5]])).all() + assert (K0S.double() == np.array([[14.5, 39.0], [0.0, 127.5]])).all() + @pytest.mark.indevelopment def test_ktensor__str__(sample_ktensor_2way): (data0, K0) = sample_ktensor_2way s = """ktensor of shape 2 x 2\nweights=[1. 2.]\nfactor_matrices[0] =\n[[1. 2.]\n [3. 4.]]\nfactor_matrices[1] =\n[[5. 6.]\n [7. 8.]]""" - assert (K0.__str__() == s) + assert K0.__str__() == s diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 00000000..fe87ea42 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,47 @@ +"""Testing of general package properties such as linting and formatting""" +import os +import subprocess + +import pytest + +import pyttb as ttb + + +@pytest.mark.packaging +def test_formatting(): + """Confirm formatting of the project is consistent""" + + source_dir = os.path.dirname(ttb.__file__) + root_dir = os.path.dirname(source_dir) + subprocess.run( + f"isort {root_dir} --check --settings-path {root_dir}", check=True, shell=True + ) + subprocess.run(f"black --check {root_dir}", check=True, shell=True) + + +@pytest.mark.packaging +def test_linting(): + """Confirm linting of the project is enforce""" + + enforced_files = [ + os.path.join(os.path.dirname(ttb.__file__), f"{ttb.tensor.__name__}.py"), + os.path.join(os.path.dirname(ttb.__file__), f"{ttb.sptensor.__name__}.py"), + ttb.pyttb_utils.__file__, + ] + # TODO pylint fails to import pyttb in tests + # add mypy check + root_dir = os.path.dirname(os.path.dirname(__file__)) + toml_file = os.path.join(root_dir, "pyproject.toml") + subprocess.run( + f"pylint {' '.join(enforced_files)} --rcfile {toml_file} -j0", + check=True, + shell=True, + ) + + +@pytest.mark.packaging +def test_typing(): + """Run type checker on package""" + root_dir = os.path.dirname(os.path.dirname(__file__)) + toml_file = os.path.join(root_dir, "pyproject.toml") + subprocess.run(f"mypy -p pyttb --config-file {toml_file}", check=True, shell=True) diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 00000000..33c90a19 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,11 @@ +import pytest + +import pyttb as ttb + + +def test_package_smoke(): + """A few sanity checks to make sure things don't explode""" + assert len(ttb.__version__) > 0 + # Make sure warnings filter doesn't crash + ttb.ignore_warnings(False) + ttb.ignore_warnings(True) diff --git a/tests/test_pyttb_utils.py b/tests/test_pyttb_utils.py index 3ad0dd96..24999c03 100644 --- a/tests/test_pyttb_utils.py +++ b/tests/test_pyttb_utils.py @@ -2,19 +2,29 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb +import logging + import numpy as np import pytest import scipy.sparse as sparse +import pyttb as ttb + + @pytest.mark.indevelopment def test_sptensor_to_dense_matrix(): subs = np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2], [3, 3, 3]]) vals = np.array([[0.5], [1.5], [2.5], [3.5]]) shape = (4, 4, 4) - mode0 = sparse.coo_matrix(([0.5, 1.5, 2.5, 3.5], ([5, 13, 10, 15], [1, 1, 2, 3]))).toarray() - mode1 = sparse.coo_matrix(([0.5, 1.5, 2.5, 3.5], ([5, 13, 10, 15], [1, 1, 2, 3]))).toarray() - mode2 = sparse.coo_matrix(([0.5, 1.5, 2.5, 3.5], ([5, 5, 10, 15], [1, 3, 2, 3]))).toarray() + mode0 = sparse.coo_matrix( + ([0.5, 1.5, 2.5, 3.5], ([5, 13, 10, 15], [1, 1, 2, 3])) + ).toarray() + mode1 = sparse.coo_matrix( + ([0.5, 1.5, 2.5, 3.5], ([5, 13, 10, 15], [1, 1, 2, 3])) + ).toarray() + mode2 = sparse.coo_matrix( + ([0.5, 1.5, 2.5, 3.5], ([5, 5, 10, 15], [1, 3, 2, 3])) + ).toarray() Ynt = [mode0, mode1, mode2] sptensorInstance = ttb.sptensor().from_data(subs, vals, shape) @@ -24,6 +34,7 @@ def test_sptensor_to_dense_matrix(): Xnt = ttb.tt_to_dense_matrix(tensorInstance, mode, True) assert np.array_equal(Xnt, Ynt[mode]) + @pytest.mark.indevelopment def test_sptensor_from_dense_matrix(): tensorInstance = ttb.tensor.from_data(np.random.normal(size=(4, 4, 4))) @@ -39,91 +50,71 @@ def test_sptensor_from_dense_matrix(): Ynt = ttb.tt_from_dense_matrix(Xnt, tensorCopy.shape, mode, 1) assert tensorCopy.isequal(Ynt) -@pytest.mark.indevelopment -def test_sptensor_to_sparse_matrix(): - subs = np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2], [3, 3, 3]]) - vals = np.array([[0.5], [1.5], [2.5], [3.5]]) - shape = (4, 4, 4) - mode0 = sparse.coo_matrix(([0.5, 1.5, 2.5, 3.5], ([5, 7, 10, 15], [1, 1, 2, 3]))) - mode1 = sparse.coo_matrix(([0.5, 1.5, 2.5, 3.5], ([5, 7, 10, 15], [1, 1, 2, 3]))) - mode2 = sparse.coo_matrix(([0.5, 1.5, 2.5, 3.5], ([5, 5, 10, 15], [1, 3, 2, 3]))) - Ynt = [mode0, mode1, mode2] - sptensorInstance = ttb.sptensor().from_data(subs, vals, shape) - - for mode in range(sptensorInstance.ndims): - Xnt = ttb.tt_to_sparse_matrix(sptensorInstance, mode, True) - assert (Xnt != Ynt[mode]).nnz == 0 - assert Xnt.shape == Ynt[mode].shape - -@pytest.mark.indevelopment -def test_sptensor_from_sparse_matrix(): - subs = np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2], [3, 3, 3]]) - vals = np.array([[0.5], [1.5], [2.5], [3.5]]) - shape = (4, 4, 4) - sptensorInstance = ttb.sptensor().from_data(subs, vals, shape) - for mode in range(sptensorInstance.ndims): - sptensorCopy = ttb.sptensor.from_tensor_type(sptensorInstance) - Xnt = ttb.tt_to_sparse_matrix(sptensorCopy, mode, True) - Ynt = ttb.tt_from_sparse_matrix(Xnt, sptensorCopy.shape, mode, 0) - assert sptensorCopy.isequal(Ynt) - - for mode in range(sptensorInstance.ndims): - sptensorCopy = ttb.sptensor.from_tensor_type(sptensorInstance) - Xnt = ttb.tt_to_sparse_matrix(sptensorCopy, mode, False) - Ynt = ttb.tt_from_sparse_matrix(Xnt, sptensorCopy.shape, mode, 1) - assert sptensorCopy.isequal(Ynt) @pytest.mark.indevelopment def test_tt_union_rows(): a = np.array([[4, 6], [1, 9], [2, 6], [2, 6], [99, 0]]) b = np.array([[1, 7], [1, 8], [2, 6]]) - assert (ttb.tt_union_rows(a, b) == np.array([[1, 7], [1, 8], [4, 6],[1, 9], [2, 6], [99, 0]])).all() + assert ( + ttb.tt_union_rows(a, b) + == np.array([[1, 7], [1, 8], [4, 6], [1, 9], [2, 6], [99, 0]]) + ).all() _, idx = np.unique(a, axis=0, return_index=True) assert (ttb.tt_union_rows(a, np.array([])) == a[np.sort(idx)]).all() assert (ttb.tt_union_rows(np.array([]), a) == a[np.sort(idx)]).all() + @pytest.mark.indevelopment def test_tt_dimscheck(): # Empty - rdims, ridx = ttb.tt_dimscheck(np.array([]), 6) + rdims, ridx = ttb.tt_dimscheck(6, dims=np.array([])) assert (rdims == np.array([0, 1, 2, 3, 4, 5])).all() assert ridx is None - # Minus - rdims, ridx = ttb.tt_dimscheck(np.array([-1]), 6) - assert (rdims == np.array([1, 2, 3, 4, 5])).all() + # Exclude Dims + rdims, ridx = ttb.tt_dimscheck(6, exclude_dims=np.array([1])) + assert (rdims == np.array([0, 2, 3, 4, 5])).all() assert ridx is None # Invalid minus - with pytest.raises(AssertionError) as excinfo: - ttb.tt_dimscheck(np.array([-7]), 6, 6) - assert "Invalid magnitude for negative dims selection" in str(excinfo) + with pytest.raises(ValueError) as excinfo: + ttb.tt_dimscheck(6, 6, exclude_dims=np.array([7])) + assert "Exclude dims" in str(excinfo) # Positive - rdims, ridx = ttb.tt_dimscheck(np.array([5]), 6) + rdims, ridx = ttb.tt_dimscheck(6, dims=np.array([5])) assert (rdims == np.array([5])).all() assert ridx is None # M==P - rdims, ridx = ttb.tt_dimscheck(np.array([-1]), 6, 5) + rdims, ridx = ttb.tt_dimscheck(6, 5, exclude_dims=np.array([0])) assert (rdims == np.array([1, 2, 3, 4, 5])).all() assert (ridx == np.arange(0, 5)).all() # M==N - rdims, ridx = ttb.tt_dimscheck(np.array([-1]), 6, 6) + rdims, ridx = ttb.tt_dimscheck(6, 6, exclude_dims=np.array([0])) assert (rdims == np.array([1, 2, 3, 4, 5])).all() assert (ridx == rdims).all() # M>N with pytest.raises(AssertionError) as excinfo: - ttb.tt_dimscheck(np.array([-1]), 6, 7) + ttb.tt_dimscheck(6, 7, exclude_dims=np.array([0])) assert "Cannot have more multiplicands than dimensions" in str(excinfo) # M!=P and M!=N with pytest.raises(AssertionError) as excinfo: - ttb.tt_dimscheck(np.array([-1]), 6, 4) + ttb.tt_dimscheck(6, 4, exclude_dims=np.array([0])) assert "Invalid number of multiplicands" in str(excinfo) + # Both dims and exclude dims + with pytest.raises(ValueError) as excinfo: + ttb.tt_dimscheck(6, dims=[], exclude_dims=[]) + assert "not both" in str(excinfo) + + # We no longer support negative dims. Make sure that is explicit + with pytest.raises(ValueError) as excinfo: + ttb.tt_dimscheck(6, dims=np.array([-1])) + assert "Negative dims" in str(excinfo), f"{str(excinfo)}" @pytest.mark.indevelopment @@ -134,17 +125,20 @@ def test_tt_tenfun(): # Binary case def add(x, y): - return x+y - assert (ttb.tt_tenfun(add, t1, t2).data == 2*data).all() + return x + y + + assert (ttb.tt_tenfun(add, t1, t2).data == 2 * data).all() # Single argument case def add1(x): - return x+1 + return x + 1 + assert (ttb.tt_tenfun(add1, t1).data == (data + 1)).all() # Multi argument case def tensor_max(x): return np.max(x, axis=0) + assert (ttb.tt_tenfun(tensor_max, t1, t1, t1).data == data).all() # TODO: sptensor arguments, depends on fixing the indexing ordering @@ -168,62 +162,97 @@ def tensor_max(x): # Tensors of different sizes with pytest.raises(AssertionError) as excinfo: - ttb.tt_tenfun(tensor_max, t1, t1, ttb.tensor.from_data(np.concatenate((data,np.array([[7,8,9]]))))) + ttb.tt_tenfun( + tensor_max, + t1, + t1, + ttb.tensor.from_data(np.concatenate((data, np.array([[7, 8, 9]])))), + ) assert "Tensor 2 is not the same size as the first tensor input" in str(excinfo) - - @pytest.mark.indevelopment def test_tt_setdiff_rows(): a = np.array([[4, 6], [1, 9], [2, 6], [2, 6], [99, 0]]) - b = np.array([[1, 7], [1, 8], [2, 6], [2, 1], [2, 4], [4, 6], [4, 7], [5, 9], [5, 2], [5, 1]]) + b = np.array( + [[1, 7], [1, 8], [2, 6], [2, 1], [2, 4], [4, 6], [4, 7], [5, 9], [5, 2], [5, 1]] + ) assert (ttb.tt_setdiff_rows(a, b) == np.array([1, 4])).all() a = np.array([[4, 6], [1, 9]]) b = np.array([[1, 7], [1, 8]]) assert (ttb.tt_setdiff_rows(a, b) == np.array([0, 1])).all() + a = np.array([[4, 6], [1, 9]]) + b = np.array([]) + assert (ttb.tt_setdiff_rows(a, b) == np.arange(a.shape[0])).all() + assert (ttb.tt_setdiff_rows(b, a) == b).all() + @pytest.mark.indevelopment def test_tt_intersect_rows(): a = np.array([[4, 6], [1, 9], [2, 6], [2, 6]]) - b = np.array([[1, 7], [1, 8], [2, 6], [2, 1], [2, 4], [4, 6], [4, 7], [5, 9], [5, 2], [5, 1]]) + b = np.array( + [[1, 7], [1, 8], [2, 6], [2, 1], [2, 4], [4, 6], [4, 7], [5, 9], [5, 2], [5, 1]] + ) assert (ttb.tt_intersect_rows(a, b) == np.array([2, 0])).all() + a = np.array([[4, 6], [1, 9]]) + b = np.array([]) + assert (ttb.tt_intersect_rows(a, b) == b).all() + assert (ttb.tt_intersect_rows(a, b) == ttb.tt_intersect_rows(b, a)).all() + @pytest.mark.indevelopment def test_tt_ismember_rows(): a = np.array([[4, 6], [1, 9], [2, 6]]) - b = np.array([[1, 7], [1, 8], [2, 6], [2, 1], [2, 4], [4, 6], [4, 7], [5, 9], [5, 2], [5, 1]]) + b = np.array( + [[1, 7], [1, 8], [2, 6], [2, 1], [2, 4], [4, 6], [4, 7], [5, 9], [5, 2], [5, 1]] + ) assert (ttb.tt_ismember_rows(a, b) == np.array([5, -1, 2])).all() - assert (ttb.tt_ismember_rows(b, a) == np.array([-1, -1, 2, -1, -1, 0, -1, -1, -1, -1])).all() + assert ( + ttb.tt_ismember_rows(b, a) == np.array([-1, -1, 2, -1, -1, 0, -1, -1, -1, -1]) + ).all() @pytest.mark.indevelopment def test_tt_irenumber(): - #TODO: Note this is more of a regression test by exploring the behaviour in MATLAB still not totally clear on WHY it behaves this way + # TODO: Note this is more of a regression test by exploring the behaviour in MATLAB still not totally clear on WHY it behaves this way # Constant shouldn't effect performance const = 1 subs = np.array([[const, const, 0], [const, const, 1]]) vals = np.array([[0.5], [1.5]]) shape = (4, 4, 4) - data = {'subs': subs, 'vals': vals, 'shape': shape} + data = {"subs": subs, "vals": vals, "shape": shape} sptensorInstance = ttb.sptensor().from_data(subs, vals, shape) - slice_tuple = (slice(None, None, None), slice(None, None, None), slice(None, None, None)) - extended_result = np.array([[const, const, const, const, const, 0], [const, const, const, const, const, 1]]) + slice_tuple = ( + slice(None, None, None), + slice(None, None, None), + slice(None, None, None), + ) + extended_result = np.array( + [[const, const, const, const, const, 0], [const, const, const, const, const, 1]] + ) # Pad equal to number of modes - assert (ttb.tt_irenumber(sptensorInstance, shape, (const, const, const)) == extended_result).all() + assert ( + ttb.tt_irenumber(sptensorInstance, shape, (const, const, const)) + == extended_result + ).all() # Full slice should equal original assert (ttb.tt_irenumber(sptensorInstance, shape, slice_tuple) == subs).all() # Verify that list and equivalent slice act the same - assert(ttb.tt_irenumber(sptensorInstance, shape, (const, const, slice(0, 1, 1))) == np.array([[const, const, const, const, 0], [const, const, const, const, 1]])).all() - assert (ttb.tt_irenumber(sptensorInstance, shape, (const, const, [0, 1])) == np.array( - [[const, const, const, const, 0], [const, const, const, const, 1]])).all() + assert ( + ttb.tt_irenumber(sptensorInstance, shape, (const, const, slice(0, 1, 1))) + == np.array([[const, const, const, const, 0], [const, const, const, const, 1]]) + ).all() + assert ( + ttb.tt_irenumber(sptensorInstance, shape, (const, const, [0, 1])) + == np.array([[const, const, const, const, 0], [const, const, const, const, 1]]) + ).all() @pytest.mark.indevelopment @@ -232,19 +261,19 @@ def test_tt_assignment_type(): x = 5 rhs = 5 subs = 5 - assert ttb.tt_assignment_type(x, subs, rhs) == 'subtensor' + assert ttb.tt_assignment_type(x, subs, rhs) == "subtensor" # type(x)!=type(rhs), subs dimensionality >=2 rhs = "cat" subs = (1, 1, 1) - assert ttb.tt_assignment_type(x, subs, rhs) == 'subtensor' + assert ttb.tt_assignment_type(x, subs, rhs) == "subtensor" subs = (np.array([1, 2, 3]),) - assert ttb.tt_assignment_type(x, subs, rhs) == 'subscripts' + assert ttb.tt_assignment_type(x, subs, rhs) == "subscripts" # type(x)!=type(rhs), subs dimensionality <2 subs = np.array([1]) - assert ttb.tt_assignment_type(x, subs, rhs) == 'subscripts' + assert ttb.tt_assignment_type(x, subs, rhs) == "subscripts" @pytest.mark.indevelopment @@ -303,7 +332,7 @@ def test_tt_renumber(): number_range = (slice(1, 3, None), slice(1, 3, None), slice(1, 3, None)) subs = np.array([]) newsubs, newshape = ttb.tt_renumber(subs, shape, number_range) - assert (newsubs.size == 0) + assert newsubs.size == 0 assert newshape == (2, 2, 2) # Not slice in each dimension, empty subs @@ -311,7 +340,7 @@ def test_tt_renumber(): number_range = ([1, 3, 4], [1, 3, 4], [1, 3, 4]) subs = np.array([]) newsubs, newshape = ttb.tt_renumber(subs, shape, number_range) - assert (newsubs.size == 0) + assert newsubs.size == 0 assert newshape == (3, 3, 3) @@ -325,20 +354,26 @@ def test_tt_sub2ind_valid(): empty = np.array([]) assert (ttb.tt_sub2ind(siz, empty) == empty).all() + @pytest.mark.indevelopment def test_tt_ind2sub_valid(): subs = np.array([[0, 0, 0], [1, 1, 1], [3, 3, 3]]) idx = np.array([0, 21, 63]) shape = (4, 4, 4) + logging.debug(f"\nttb.tt_ind2sub(shape, idx): {ttb.tt_ind2sub(shape, idx)}") assert (ttb.tt_ind2sub(shape, idx) == subs).all() - subs = np.array([[0, 1], [1, 0]]) + subs = np.array([[1, 0], [0, 1]]) idx = np.array([1, 2]) shape = (2, 2) + logging.debug(f"\nttb.tt_ind2sub(shape, idx): {ttb.tt_ind2sub(shape, idx)}") assert (ttb.tt_ind2sub(shape, idx) == subs).all() empty = np.array([]) - assert (ttb.tt_ind2sub(shape, empty) == empty).all() + assert ( + ttb.tt_ind2sub(shape, empty) == np.empty(shape=(0, len(shape)), dtype=int) + ).all() + @pytest.mark.indevelopment def test_tt_subsubsref_valid(): @@ -350,20 +385,24 @@ def test_tt_subsubsref_valid(): # TODO need to understand behavior better assert True + @pytest.mark.indevelopment 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.tt_intvec2str(v) == '[1 2 3]' + assert ttb.tt_intvec2str(v) == "[1 2 3]" + @pytest.mark.indevelopment def test_tt_sizecheck_empty(): assert ttb.tt_sizecheck(()) + @pytest.mark.indevelopment def test_tt_sizecheck_valid(): assert ttb.tt_sizecheck((2, 2, 2)) + @pytest.mark.indevelopment def test_tt_sizecheck_invalid(): # Float @@ -377,6 +416,7 @@ def test_tt_sizecheck_invalid(): # Zero assert not ttb.tt_sizecheck((0, 2, 2)) + @pytest.mark.indevelopment def test_tt_sizecheck_errorMessage(): # Raise when nargout == 0 @@ -384,14 +424,17 @@ def test_tt_sizecheck_errorMessage(): ttb.tt_sizecheck((1.0, 2, 2), nargout=False) assert "Size must be a row vector of real positive integers" in str(excinfo) + @pytest.mark.indevelopment def test_tt_subscheck_empty(): assert ttb.tt_subscheck(np.array([])) + @pytest.mark.indevelopment def test_tt_subscheck_valid(): assert ttb.tt_subscheck(np.array([[2, 2], [2, 2]])) + @pytest.mark.indevelopment def test_tt_subscheck_invalid(): # Too Few Dims @@ -407,6 +450,7 @@ def test_tt_subscheck_invalid(): # Non-int assert not ttb.tt_subscheck(np.array([[1.0, 2], [2, 2]])) + @pytest.mark.indevelopment def test_tt_subscheck_errorMessage(): # Raise when nargout == 0 @@ -414,14 +458,17 @@ def test_tt_subscheck_errorMessage(): ttb.tt_subscheck(np.array([1.0, 2, 2]), nargout=False) assert "Subscripts must be a matrix of real positive integers" in str(excinfo) + @pytest.mark.indevelopment def test_tt_valscheck_empty(): assert ttb.tt_valscheck(np.array([])) + @pytest.mark.indevelopment def test_tt_valscheck_valid(): assert ttb.tt_valscheck(np.array([[0.5], [1.5], [2.5]])) + @pytest.mark.indevelopment def test_tt_valscheck_invalid(): # Row array @@ -429,6 +476,7 @@ def test_tt_valscheck_invalid(): # Matrix, too many dimensions assert not ttb.tt_valscheck(np.array([[2, 2], [2, 2]])) + @pytest.mark.indevelopment def test_tt_valscheck_errorMessage(): # Raise when nargout == 0 @@ -436,46 +484,54 @@ def test_tt_valscheck_errorMessage(): ttb.tt_valscheck(np.array([2, 2]), nargout=False) assert "Values must be in array" in str(excinfo) + @pytest.mark.indevelopment def test_isrow_empty(): assert not ttb.isrow(np.array([[]])) + @pytest.mark.indevelopment def test_isrow_valid(): assert ttb.isrow(np.array([[2, 2, 2]])) + @pytest.mark.indevelopment def test_isrow_invalid(): # 2 x 2 Matrix - assert not ttb.isrow(np.array([[2, 2],[2, 2]])) + assert not ttb.isrow(np.array([[2, 2], [2, 2]])) # Column vector assert not ttb.isrow(np.array([[2, 2, 2]]).T) + @pytest.mark.indevelopment def test_isvector_empty(): assert ttb.isvector(np.array([[]])) + @pytest.mark.indevelopment def test_isvector_valid(): assert ttb.isvector(np.array([[2, 2, 2]])) assert ttb.isvector(np.array([[2, 2, 2]]).T) + @pytest.mark.indevelopment def test_isvector_invalid(): # 2 x 2 Matrix - assert not ttb.isvector(np.array([[2, 2],[2, 2]])) + assert not ttb.isvector(np.array([[2, 2], [2, 2]])) + @pytest.mark.indevelopment def test_islogical_empty(): assert not ttb.islogical(np.array([[]])) + @pytest.mark.indevelopment def test_islogical_valid(): assert ttb.islogical(True) + @pytest.mark.indevelopment def test_islogical_invalid(): assert not ttb.islogical(np.array([[2, 2, 2]])) assert not ttb.islogical(1.1) assert not ttb.islogical(0) - diff --git a/tests/test_sptensor.py b/tests/test_sptensor.py index 042c1ce6..f9662d10 100644 --- a/tests/test_sptensor.py +++ b/tests/test_sptensor.py @@ -2,20 +2,26 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb +import logging + import numpy as np import pytest import scipy.sparse as sparse +import pyttb as ttb +from pyttb.sptensor import tt_from_sparse_matrix, tt_to_sparse_matrix + + @pytest.fixture() def sample_sptensor(): subs = np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2], [3, 3, 3]]) vals = np.array([[0.5], [1.5], [2.5], [3.5]]) shape = (4, 4, 4) - data = {'subs':subs, 'vals':vals, 'shape': shape} + data = {"subs": subs, "vals": vals, "shape": shape} sptensorInstance = ttb.sptensor().from_data(subs, vals, shape) return data, sptensorInstance + @pytest.mark.indevelopment def test_sptensor_initialization_empty(): empty = np.array([], ndmin=2, dtype=int) @@ -26,68 +32,90 @@ def test_sptensor_initialization_empty(): assert (sptensorInstance.vals == empty).all() assert (sptensorInstance.shape == empty).all() + @pytest.mark.indevelopment def test_sptensor_initialization_from_data(sample_sptensor): (data, sptensorInstance) = sample_sptensor - assert (sptensorInstance.subs == data['subs']).all() - assert (sptensorInstance.vals == data['vals']).all() - assert (sptensorInstance.shape == data['shape']) + assert (sptensorInstance.subs == data["subs"]).all() + assert (sptensorInstance.vals == data["vals"]).all() + assert sptensorInstance.shape == data["shape"] + @pytest.mark.indevelopment def test_sptensor_initialization_from_tensor_type(sample_sptensor): - # Copy constructor (data, sptensorInstance) = sample_sptensor sptensorCopy = ttb.sptensor.from_tensor_type(sptensorInstance) - assert (sptensorCopy.subs == data['subs']).all() - assert (sptensorCopy.vals == data['vals']).all() - assert (sptensorCopy.shape == data['shape']) + assert (sptensorCopy.subs == data["subs"]).all() + assert (sptensorCopy.vals == data["vals"]).all() + assert sptensorCopy.shape == data["shape"] # Convert Tensor inputData = np.array([[1, 2, 3], [4, 5, 6]]) tensorInstance = ttb.tensor.from_data(inputData) sptensorFromTensor = ttb.sptensor.from_tensor_type(tensorInstance) - assert (sptensorFromTensor.subs == ttb.tt_ind2sub(inputData.shape, np.arange(0, inputData.size))).all() - assert (sptensorFromTensor.vals == inputData.reshape(inputData.size, 1)).all() - assert (sptensorFromTensor.shape == inputData.shape) + logging.debug(f"inputData = {inputData}") + logging.debug(f"tensorInstance = {tensorInstance}") + logging.debug(f"sptensorFromTensor = {sptensorFromTensor}") + assert ( + sptensorFromTensor.subs + == ttb.tt_ind2sub(inputData.shape, np.arange(0, inputData.size)) + ).all() + assert ( + sptensorFromTensor.vals == inputData.reshape((inputData.size, 1), order="F") + ).all() + assert sptensorFromTensor.shape == inputData.shape # From coo sparse matrix inputData = sparse.random(11, 4, 0.2) sptensorFromCOOMatrix = ttb.sptensor.from_tensor_type(sparse.coo_matrix(inputData)) assert (sptensorFromCOOMatrix.spmatrix() != sparse.coo_matrix(inputData)).nnz == 0 + # Negative Tests + with pytest.raises(AssertionError): + invalid_tensor_type = [] + ttb.sptensor.from_tensor_type(invalid_tensor_type) + + @pytest.mark.indevelopment def test_sptensor_initialization_from_function(): - # Random Tensor Success def function_handle(*args): return np.array([[0.5], [1.5], [2.5], [3.5], [4.5], [5.5]]) + np.random.seed(123) shape = (4, 4, 4) nz = 6 sptensorInstance = ttb.sptensor.from_function(function_handle, shape, nz) assert (sptensorInstance.vals == function_handle()).all() - assert (sptensorInstance.shape == shape) + assert sptensorInstance.shape == shape assert len(sptensorInstance.subs) == nz # NZ as a propotion in [0,1) nz = 0.09375 sptensorInstance = ttb.sptensor.from_function(function_handle, shape, nz) assert (sptensorInstance.vals == function_handle()).all() - assert (sptensorInstance.shape == shape) + assert sptensorInstance.shape == shape assert len(sptensorInstance.subs) == int(nz * np.prod(shape)) # Random Tensor exception for negative non-zeros nz = -1 with pytest.raises(AssertionError) as excinfo: a = ttb.sptensor.from_function(function_handle, shape, nz) - assert "Requested number of non-zeros must be positive and less than the total size" in str(excinfo) + assert ( + "Requested number of non-zeros must be positive and less than the total size" + in str(excinfo) + ) # Random Tensor exception for negative non-zeros nz = np.prod(shape) + 1 with pytest.raises(AssertionError) as excinfo: a = ttb.sptensor.from_function(function_handle, shape, nz) - assert "Requested number of non-zeros must be positive and less than the total size" in str(excinfo) + assert ( + "Requested number of non-zeros must be positive and less than the total size" + in str(excinfo) + ) + @pytest.mark.indevelopment def test_sptensor_initialization_from_aggregator(sample_sptensor): @@ -97,12 +125,12 @@ def test_sptensor_initialization_from_aggregator(sample_sptensor): a = ttb.sptensor.from_aggregator(subs, vals, shape) assert (a.subs == np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2], [3, 3, 3]])).all() assert (a.vals == np.array([[10.5], [1.5], [2.5], [3.5]])).all() - assert (a.shape == shape) + assert a.shape == shape a = ttb.sptensor.from_aggregator(subs, vals) assert (a.subs == np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2], [3, 3, 3]])).all() assert (a.vals == np.array([[10.5], [1.5], [2.5], [3.5]])).all() - assert (a.shape == shape) + assert a.shape == shape a = ttb.sptensor.from_aggregator(np.array([]), vals, shape) assert a.isequal(ttb.sptensor.from_data(np.array([]), np.array([]), shape)) @@ -112,7 +140,9 @@ def test_sptensor_initialization_from_aggregator(sample_sptensor): assert "Number of subscripts and values must be equal" in str(excinfo) with pytest.raises(AssertionError) as excinfo: - ttb.sptensor.from_aggregator(np.concatenate((subs, np.ones((6, 1))), axis=1), vals, shape) + ttb.sptensor.from_aggregator( + np.concatenate((subs, np.ones((6, 1))), axis=1), vals, shape + ) assert "More subscripts than specified by shape" in str(excinfo) badSubs = subs.copy() @@ -121,6 +151,7 @@ def test_sptensor_initialization_from_aggregator(sample_sptensor): ttb.sptensor.from_aggregator(badSubs, vals, shape) assert "Subscript exceeds sptensor shape" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor_and_scalar(sample_sptensor): (data, sptensorInstance) = sample_sptensor @@ -128,47 +159,54 @@ def test_sptensor_and_scalar(sample_sptensor): b = sptensorInstance.logical_and(0) assert (b.subs == np.array([])).all() assert (b.vals == np.array([])).all() - assert (b.shape == data['shape']) + assert b.shape == data["shape"] b = sptensorInstance.logical_and(0.5) - assert (b.subs == data['subs']).all() + assert (b.subs == data["subs"]).all() assert (b.vals == np.array([[True], [False], [False], [False]])).all() - assert (b.shape == data['shape']) + assert b.shape == data["shape"] + @pytest.mark.indevelopment def test_sptensor_and_sptensor(sample_sptensor): (data, sptensorInstance) = sample_sptensor b = sptensorInstance.logical_and(sptensorInstance) - assert (b.subs == data['subs']).all() + assert (b.subs == data["subs"]).all() assert (b.vals == np.array([[True], [True], [True], [True]])).all() - assert (b.shape == data['shape']) + assert b.shape == data["shape"] with pytest.raises(AssertionError) as excinfo: - sptensorInstance.logical_and(ttb.sptensor.from_data(data['subs'], data['vals'], (5, 5, 5))) - assert 'Must be tensors of the same shape' in str(excinfo) + sptensorInstance.logical_and( + ttb.sptensor.from_data(data["subs"], data["vals"], (5, 5, 5)) + ) + assert "Must be tensors of the same shape" in str(excinfo) with pytest.raises(AssertionError) as excinfo: - sptensorInstance.logical_and(np.ones(data['shape'])) - assert 'The arguments must be two sptensors or an sptensor and a scalar.' in str(excinfo) + sptensorInstance.logical_and(np.ones(data["shape"])) + assert "The arguments must be two sptensors or an sptensor and a scalar." in str( + excinfo + ) + @pytest.mark.indevelopment def test_sptensor_and_tensor(sample_sptensor): (data, sptensorInstance) = sample_sptensor b = sptensorInstance.logical_and(ttb.tensor.from_tensor_type(sptensorInstance)) - assert (b.subs == data['subs']).all() - assert (b.vals == np.ones(data['vals'].shape)).all() + assert (b.subs == data["subs"]).all() + assert (b.vals == np.ones(data["vals"].shape)).all() + @pytest.mark.indevelopment def test_sptensor_full(sample_sptensor): (data, sptensorInstance) = sample_sptensor densetensor = sptensorInstance.full() denseData = np.zeros(sptensorInstance.shape) - actualIdx = tuple(data['subs'].transpose()) - denseData[actualIdx] = data['vals'].transpose()[0] + actualIdx = tuple(data["subs"].transpose()) + denseData[actualIdx] = data["vals"].transpose()[0] assert (densetensor.data == denseData).all() - assert (densetensor.shape == data['shape']) + assert densetensor.shape == data["shape"] # Empty, no shape tensor conversion emptySptensor = ttb.sptensor() @@ -176,26 +214,34 @@ def test_sptensor_full(sample_sptensor): assert emptyTensor.isequal(emptySptensor.full()) # Empty, no non-zeros tensor conversion - emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), data['shape']) - assert (emptySptensor.full().data == np.zeros(data['shape'])).all() + emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), data["shape"]) + assert (emptySptensor.full().data == np.zeros(data["shape"])).all() + @pytest.mark.indevelopment def test_sptensor_subdims(sample_sptensor): (data, sptensorInstance) = sample_sptensor - assert (sptensorInstance.subdims(np.array([[1], [1], [1, 3]])) == np.array([0, 1])).all() - assert (sptensorInstance.subdims((1, 1, slice(None, None, None))) == np.array([0, 1])).all() + assert (sptensorInstance.subdims([[1], [1], [1, 3]]) == np.array([0, 1])).all() + assert ( + sptensorInstance.subdims((1, 1, slice(None, None, None))) == np.array([0, 1]) + ).all() with pytest.raises(AssertionError) as excinfo: - sptensorInstance.subdims(np.array([[1], [1, 3]])) + sptensorInstance.subdims([[1], [1, 3]]) assert "Number of subdimensions must equal number of dimensions" in str(excinfo) + with pytest.raises(ValueError): + sptensorInstance.subdims(("bad", "region", "types")) + + @pytest.mark.indevelopment def test_sptensor_ndims(sample_sptensor): (data, sptensorInstance) = sample_sptensor assert sptensorInstance.ndims == 3 + @pytest.mark.indevelopment def test_sptensor_extract(sample_sptensor, capsys): (data, sptensorInstance) = sample_sptensor @@ -213,14 +259,17 @@ def test_sptensor_extract(sample_sptensor, capsys): capsys.readouterr() # List of subs case - assert (sptensorInstance.extract(np.array([[1, 1, 1], [1, 1, 3]])) == [[0.5], [1.5]]).all() + assert ( + sptensorInstance.extract(np.array([[1, 1, 1], [1, 1, 3]])) == [[0.5], [1.5]] + ).all() # Single sub case # TODO if you pass a single sub should you get a list of vals with one entry or just a single val assert (sptensorInstance.extract(np.array([1, 1, 1])) == [[0.5]]).all() + @pytest.mark.indevelopment -def test_sptensor_getitem(sample_sptensor): +def test_sptensor__getitem__(sample_sptensor): (data, sptensorInstance) = sample_sptensor ## Case 1 # Empty value slice @@ -230,28 +279,38 @@ def test_sptensor_getitem(sample_sptensor): # Empty subtensor emptyResult = sptensorInstance[0:1, 0:1, 0:1] assert isinstance(emptyResult, ttb.sptensor) - assert emptyResult.isequal(ttb.sptensor.from_data(np.array([]), np.array([]), (1, 1, 1))) + assert emptyResult.isequal( + ttb.sptensor.from_data(np.array([]), np.array([]), (1, 1, 1)) + ) # Full subtensor assert isinstance(sptensorInstance[:, :, :], ttb.sptensor) - assert isinstance(sptensorInstance[[0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3]], ttb.sptensor) - assert sptensorInstance[:, :, :].isequal(sptensorInstance[[0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3]]) + assert isinstance( + sptensorInstance[[0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3]], ttb.sptensor + ) + assert sptensorInstance[:, :, :].isequal( + sptensorInstance[[0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3]] + ) # TODO need to understand what this intends to do ## Case 2 subscript indexing - assert sptensorInstance[np.array([1, 2, 1]), 'extract'] == np.array([[0]]) - assert (sptensorInstance[np.array([[1, 2, 1], [1, 3, 1]]), 'extract'] == np.array([[0], [0]])).all() + assert sptensorInstance[np.array([[1], [2], [1]])] == np.array([[0]]) + assert ( + sptensorInstance[np.array([[1, 1], [2, 3], [1, 1]])] == np.array([[0], [0]]) + ).all() ## Case 2 Linear Indexing - ind = ttb.tt_sub2ind(data['shape'], np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2]])) + ind = ttb.tt_sub2ind(data["shape"], np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2]])) assert (sptensorInstance[ind] == np.array([[0.5], [1.5], [2.5]])).all() - ind2 = ttb.tt_sub2ind(data['shape'], np.array([[1, 1, 1], [1, 1, 3]])) + list_ind = list(ind) + assert (sptensorInstance[list_ind] == np.array([[0.5], [1.5], [2.5]])).all() + ind2 = ttb.tt_sub2ind(data["shape"], np.array([[1, 1, 1], [1, 1, 3]])) assert (sptensorInstance[ind2] == np.array([[0.5], [1.5]])).all() with pytest.raises(AssertionError) as excinfo: sptensorInstance[ind2[:, None]] - assert 'Expecting a row index' in str(excinfo) + assert "Expecting a row index" in str(excinfo) with pytest.raises(AssertionError) as excinfo: - sptensorInstance['string'] - assert 'Invalid indexing' in str(excinfo) + sptensorInstance["string"] + assert "Invalid indexing" in str(excinfo) @pytest.mark.indevelopment @@ -261,23 +320,23 @@ def test_sptensor_setitem_Case1(sample_sptensor): # Empty sptensor assigned with nothing emptyTensor = ttb.sptensor() emptyTensor[:, :, :] = [] - assert (emptyTensor.vals.size == 0) + assert emptyTensor.vals.size == 0 emptyTensor[:, :, :] = np.array([]) - assert (emptyTensor.vals.size == 0) + assert emptyTensor.vals.size == 0 # Case I(a): Set empty tensor with sptensor emptyTensor = ttb.sptensor() emptyTensor[:, :, :] = sptensorInstance - assert (emptyTensor.subs == data['subs']).all() - assert (emptyTensor.vals == data['vals']).all() - assert (emptyTensor.shape == data['shape']) + assert (emptyTensor.subs == data["subs"]).all() + assert (emptyTensor.vals == data["vals"]).all() + assert emptyTensor.shape == data["shape"] # Case I(a): Set empty tensor with sptensor, none none end slice emptyTensor = ttb.sptensor() emptyTensor[0:4, 0:4, 0:4] = sptensorInstance - assert (emptyTensor.subs == data['subs']).all() - assert (emptyTensor.vals == data['vals']).all() - assert (emptyTensor.shape == data['shape']) + assert (emptyTensor.subs == data["subs"]).all() + assert (emptyTensor.vals == data["vals"]).all() + assert emptyTensor.shape == data["shape"] # Case I(a): Set sptensor with empty tensor emptyTensor = ttb.sptensor() @@ -286,7 +345,7 @@ def test_sptensor_setitem_Case1(sample_sptensor): sptensorCopy[:, :, :] = emptyTensor assert (sptensorCopy.subs == emptyTensor.subs).all() assert (sptensorCopy.vals == emptyTensor.vals).all() - assert (sptensorCopy.shape == data['shape']) + assert sptensorCopy.shape == data["shape"] # Case I(a): Set sptensor with smaller tensor emptyTensor = ttb.sptensor() @@ -298,7 +357,7 @@ def test_sptensor_setitem_Case1(sample_sptensor): sptensorCopy[:4, :4, :4] = sptensorInstanceCopy assert (sptensorCopy.subs[1:, :] == sptensorInstanceCopy.subs).all() assert (sptensorCopy.vals[1:] == sptensorInstanceCopy.vals).all() - assert (sptensorCopy.shape == (5, 5, 5)) + assert sptensorCopy.shape == (5, 5, 5) # Case I(a): Set sptensor with smaller tensor emptyTensor = ttb.sptensor() @@ -306,9 +365,9 @@ def test_sptensor_setitem_Case1(sample_sptensor): sptensorCopy = ttb.sptensor.from_tensor_type(sptensorInstance) sptensorCopy[4, 4, 4] = 1 sptensorCopy[:4, :4, :4] = emptyTensor - assert (sptensorCopy.subs[1:, :].size == 0) - assert (sptensorCopy.vals[1:].size == 0) - assert (sptensorCopy.shape == (5, 5, 5)) + assert sptensorCopy.subs[1:, :].size == 0 + assert sptensorCopy.vals[1:].size == 0 + assert sptensorCopy.shape == (5, 5, 5) # Case I(a): Set sptensor with larger empty tensor emptyTensor = ttb.sptensor.from_data(np.array([]), np.array([]), (4, 4, 4, 4)) @@ -316,7 +375,7 @@ def test_sptensor_setitem_Case1(sample_sptensor): sptensorCopy[:4, :4, :4, :4] = emptyTensor assert (sptensorCopy.subs == emptyTensor.subs).all() assert (sptensorCopy.vals == emptyTensor.vals).all() - assert (sptensorCopy.shape == emptyTensor.shape) + assert sptensorCopy.shape == emptyTensor.shape # Case I(a): Set sptensor with sptensor subs = np.array([[2, 1, 1], [2, 1, 3]]) @@ -338,8 +397,8 @@ def test_sptensor_setitem_Case1(sample_sptensor): emptyTensor[0, 0, 0] = sptensorCopy # TODO: This ne should be eq once irenumber is resolved assert sptensorCopy.subs.shape != emptyTensor.subs.shape - assert (sptensorCopy.vals == emptyTensor.vals) - assert (sptensorCopy.shape == emptyTensor.shape) + assert sptensorCopy.vals == emptyTensor.vals + assert sptensorCopy.shape == emptyTensor.shape # Case I(a): Set empty with same size sptensor emptyTensor = ttb.sptensor.from_data(np.array([]), np.array([]), (1, 1, 1)) @@ -348,9 +407,9 @@ def test_sptensor_setitem_Case1(sample_sptensor): emptyTensor[0, 0, 0, 0] = sptensorCopy # TODO: This ne should be eq once irenumber is resolved assert sptensorCopy.subs.shape != emptyTensor.subs.shape - assert (sptensorCopy.vals == emptyTensor.vals) + assert sptensorCopy.vals == emptyTensor.vals # Since we do a single index set item the size is only set large enough for that element - assert (sptensorCopy.shape == emptyTensor.shape) + assert sptensorCopy.shape == emptyTensor.shape # Case I(a): Set empty with same size sptensor emptyTensor = ttb.sptensor.from_data(np.array([]), np.array([]), (2, 2, 2)) @@ -361,58 +420,66 @@ def test_sptensor_setitem_Case1(sample_sptensor): # TODO: This ne should be eq once irenumber is resolved assert (sptensorCopy.subs == emptyTensor.subs).all() assert (sptensorCopy.vals == emptyTensor.vals).all() - assert (sptensorCopy.shape == emptyTensor.shape) + assert sptensorCopy.shape == emptyTensor.shape sptensorCopy = ttb.sptensor.from_data(np.array([]), np.array([]), (1, 1, 1, 1)) with pytest.raises(AssertionError) as excinfo: emptyTensor[[0, 1], [0, 1], [0, 1], [0, 1]] = sptensorCopy - assert 'RHS does not match range size' in str(excinfo) + assert "RHS does not match range size" in str(excinfo) with pytest.raises(AssertionError) as excinfo: - emptyTensor[np.array([0, 1]), np.array([0, 1]), np.array([0, 1]), np.array([0, 1])] = sptensorCopy - assert 'RHS does not match range size' in str(excinfo) + emptyTensor[ + np.array([0, 1]), np.array([0, 1]), np.array([0, 1]), np.array([0, 1]) + ] = sptensorCopy + assert "RHS does not match range size" in str(excinfo) # Case I(b)i: Set with zero, sub already exists - old_value = data['vals'][1, 0] + old_value = data["vals"][1, 0] sptensorInstance[1, 1, 3] = 0 subSelection = [0, 2, 3] - assert (sptensorInstance.subs == data['subs'][subSelection]).all() - assert (sptensorInstance.vals == data['vals'][subSelection]).all() - assert (sptensorInstance.shape == data['shape']) + assert (sptensorInstance.subs == data["subs"][subSelection]).all() + assert (sptensorInstance.vals == data["vals"][subSelection]).all() + assert sptensorInstance.shape == data["shape"] + + # Case I(b)i: Set with non-zero, no subs exist + empty_tensor = ttb.sptensor() + empty_tensor[0, 0] = 1 + # Validate entry worked correctly + empty_tensor.__repr__() # Case I(b)i: Set with zero, sub doesn't exist sptensorInstance[1, 1, 3] = old_value reorder = [0, 2, 3, 1] - assert (sptensorInstance.subs == data['subs'][reorder]).all() - assert (sptensorInstance.vals == data['vals'][reorder]).all() - assert (sptensorInstance.shape == data['shape']) + assert (sptensorInstance.subs == data["subs"][reorder]).all() + assert (sptensorInstance.vals == data["vals"][reorder]).all() + assert sptensorInstance.shape == data["shape"] # Reset tensor data - data['subs'] = data['subs'][reorder] - data['vals'] = data['vals'][reorder] + data["subs"] = data["subs"][reorder] + data["vals"] = data["vals"][reorder] # Case I(b)i: Set slice with zero, sub already exists - old_value = data['vals'][3, 0] + old_value = data["vals"][3, 0] sptensorInstance[1:2, 1:2, 3:4] = 0 subSelection = [0, 1, 2] - assert (sptensorInstance.subs == data['subs'][subSelection]).all() - assert (sptensorInstance.vals == data['vals'][subSelection]).all() - assert (sptensorInstance.shape == data['shape']) + assert (sptensorInstance.subs == data["subs"][subSelection]).all() + assert (sptensorInstance.vals == data["vals"][subSelection]).all() + assert sptensorInstance.shape == data["shape"] # Reset tensor data sptensorInstance[1, 1, 3] = old_value # Case I(b)i: Set slice with zero, sub already exists - old_value = data['vals'][2, 0] + old_value = data["vals"][2, 0] sptensorInstance[3:, 3:, 3:] = 0 subSelection = [0, 1, 3] - assert (sptensorInstance.subs == data['subs'][subSelection]).all() - assert (sptensorInstance.vals == data['vals'][subSelection]).all() - assert (sptensorInstance.shape == data['shape']) + assert (sptensorInstance.subs == data["subs"][subSelection]).all() + assert (sptensorInstance.vals == data["vals"][subSelection]).all() + assert sptensorInstance.shape == data["shape"] # Reset tensor data sptensorInstance[3, 3, 3] = old_value reorder = [0, 1, 3, 2] # Reset tensor data - data['subs'] = data['subs'][reorder] - data['vals'] = data['vals'][reorder] + data["subs"] = data["subs"][reorder] + data["vals"] = data["vals"][reorder] # Case I(b)i: Expand Shape of sptensor with set item sptensorInstanceLarger = ttb.sptensor.from_tensor_type(sptensorInstance) @@ -433,33 +500,50 @@ def test_sptensor_setitem_Case1(sample_sptensor): sptensorInstanceLarger = ttb.sptensor.from_tensor_type(sptensorInstance) with pytest.raises(AssertionError) as excinfo: sptensorInstanceLarger[1, 1, 1, 1:] = 0 - assert "Must have well defined slice when expanding sptensor shape with setitem" in str(excinfo) + assert ( + "Must have well defined slice when expanding sptensor shape with setitem" + in str(excinfo) + ) # Case I(b)ii: Set with scalar, sub already exists - old_value = data['vals'][2, 0] + old_value = data["vals"][2, 0] sptensorInstance[1, 1, 3] = 7 - modifiedVals = data['vals'].copy() + modifiedVals = data["vals"].copy() modifiedVals[2] = 7 - assert (sptensorInstance.subs == data['subs']).all() + assert (sptensorInstance.subs == data["subs"]).all() assert (sptensorInstance.vals == modifiedVals).all() - assert (sptensorInstance.shape == data['shape']) + assert sptensorInstance.shape == data["shape"] sptensorInstance[1, 1, 3] = old_value # Reset tensor # Case I(b)ii: Set with scalar, sub already exists - old_value = data['vals'][2, 0] + old_value = data["vals"][2, 0] sptensorInstance[1:2, 1:2, 3:4] = 7 - modifiedVals = data['vals'].copy() + modifiedVals = data["vals"].copy() modifiedVals[2] = 7 - assert (sptensorInstance.subs == data['subs']).all() + assert (sptensorInstance.subs == data["subs"]).all() assert (sptensorInstance.vals == modifiedVals).all() - assert (sptensorInstance.shape == data['shape']) + assert sptensorInstance.shape == data["shape"] sptensorInstance[1, 1, 3] = old_value # Reset tensor # Case I(b)ii: Set with scalar, sub doesn't exist yet sptensorInstance[1, 1, 2] = 7 - assert (sptensorInstance.subs == np.vstack((data['subs'], np.array([[1, 1, 2]])))).all() - assert (sptensorInstance.vals == np.vstack((data['vals'], np.array([[7]])))).all() - assert (sptensorInstance.shape == data['shape']) + assert ( + sptensorInstance.subs == np.vstack((data["subs"], np.array([[1, 1, 2]]))) + ).all() + assert (sptensorInstance.vals == np.vstack((data["vals"], np.array([[7]])))).all() + assert sptensorInstance.shape == data["shape"] + + # Case I(b)ii: Set with scalar, iterable index, empty sptensor + someTensor = ttb.sptensor() + someTensor[[0, 1], 0] = 1 + assert someTensor[0, 0] == 1 + assert someTensor[1, 0] == 1 + assert np.all(someTensor[[0, 1], 0].vals == 1) + # Case I(b)ii: Set with scalar, iterable index, non-empty sptensor + someTensor[[0, 1], 1] = 2 + assert someTensor[0, 1] == 2 + assert someTensor[1, 1] == 2 + assert np.all(someTensor[[0, 1], 1].vals == 2) # Case I: Assign with non-scalar or sptensor sptensorInstanceLarger = ttb.sptensor.from_tensor_type(sptensorInstance) @@ -467,6 +551,7 @@ def test_sptensor_setitem_Case1(sample_sptensor): sptensorInstanceLarger[1, 1, 1] = "String" assert "Invalid assignment value" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor_setitem_Case2(sample_sptensor): (data, sptensorInstance) = sample_sptensor @@ -478,95 +563,116 @@ def test_sptensor_setitem_Case2(sample_sptensor): # Case II: Too few keys in setitem for number of assignement values with pytest.raises(AssertionError) as excinfo: - sptensorInstance[np.array([1, 1, 1]).astype(int)] = np.array([[999.0], [888.0]]) + sptensorInstance[np.array([[1], [1], [1]]).astype(int)] = np.array( + [[999.0], [888.0]] + ) assert "Number of subscripts and number of values do not match!" in str(excinfo) # Case II: Warning For duplicates with pytest.warns(Warning) as record: - sptensorInstance[np.array([[1, 1, 1], [1, 1, 1]]).astype(int)] = np.array([[999.0], [999.0]]) - assert 'Duplicate assignments discarded' in str(record[0].message) + sptensorInstance[np.array([[1, 1], [1, 1], [1, 1]]).astype(int)] = np.array( + [[999.0], [999.0]] + ) + assert "Duplicate assignments discarded" in str(record[0].message) + + # Case II: Single entry, no subs exist + empty_tensor = ttb.sptensor() + empty_tensor[np.array([[0, 1], [2, 2]])] = 4 + assert np.all(empty_tensor[np.array([[0, 1], [2, 2]])] == 4) # Case II: Single entry, for single sub that exists - sptensorInstance[np.array([1, 1, 1]).astype(int)] = 999.0 - assert (sptensorInstance[np.array([1, 1, 1]), 'extract'] == np.array([999])).all() - assert (sptensorInstance.subs == data['subs']).all() + sptensorInstance[np.array([[1], [1], [1]]).astype(int)] = 999.0 + assert (sptensorInstance[np.array([[1], [1], [1]])] == np.array([[999]])).all() + assert (sptensorInstance.subs == data["subs"]).all() # Case II: Single entry, for multiple subs that exist (data, sptensorInstance) = sample_sptensor - sptensorInstance[np.array([[1, 1, 1], [1, 1, 3]]).astype(int)] = 999.0 - assert (sptensorInstance[np.array([[1, 1, 1], [1, 1, 3]]), 'extract'] == np.array([[999], [999]])).all() - assert (sptensorInstance.subs == data['subs']).all() + sptensorInstance[np.array([[1, 1], [1, 1], [1, 3]]).astype(int)] = 999.0 + assert ( + sptensorInstance[np.array([[1, 1], [1, 1], [1, 3]])] == np.array([[999], [999]]) + ).all() + assert (sptensorInstance.subs == data["subs"]).all() # Case II: Multiple entries, for multiple subs that exist (data, sptensorInstance) = sample_sptensor - sptensorInstance[np.array([[1, 1, 1], [1, 1, 3]]).astype(int)] = np.array([[888], [999]]) - assert (sptensorInstance[np.array([[1, 1, 1], [1, 1, 3]]), 'extract'] == np.array([[888], [999]])).all() - assert (sptensorInstance.subs == data['subs']).all() + sptensorInstance[np.array([[1, 1], [1, 1], [1, 3]]).astype(int)] = np.array( + [[888], [999]] + ) + assert ( + sptensorInstance[np.array([[1, 1], [1, 1], [3, 1]])] == np.array([[999], [888]]) + ).all() + assert (sptensorInstance.subs == data["subs"]).all() # Case II: Single entry, for single sub that doesn't exist (data, sptensorInstance) = sample_sptensor copy = ttb.sptensor.from_tensor_type(sptensorInstance) - copy[np.array([1, 1, 2]).astype(int)] = 999.0 - assert (copy[np.array([1, 1, 2]), 'extract'] == np.array([999])).all() - assert (copy.subs == np.concatenate((data['subs'], np.array([[1, 1, 2]])))).all() + copy[np.array([[1], [1], [2]]).astype(int)] = 999.0 + assert (copy[np.array([[1], [1], [2]])] == np.array([999])).all() + assert (copy.subs == np.concatenate((data["subs"], np.array([[1, 1, 2]])))).all() # Case II: Single entry, for single sub that doesn't exist, expand dimensions (data, sptensorInstance) = sample_sptensor copy = ttb.sptensor.from_tensor_type(sptensorInstance) - copy[np.array([1, 1, 2, 1]).astype(int)] = 999.0 - assert (copy[np.array([1, 1, 2, 1]), 'extract'] == np.array([999])).all() - #assert (copy.subs == np.concatenate((data['subs'], np.array([[1, 1, 2]])))).all() + copy[np.array([[1], [1], [2], [1]]).astype(int)] = 999.0 + assert (copy[np.array([[1], [1], [2], [1]])] == np.array([999])).all() + # assert (copy.subs == np.concatenate((data['subs'], np.array([[1, 1, 2]])))).all() # Case II: Single entry, for multiple subs one that exists and the other doesn't (data, sptensorInstance) = sample_sptensor copy = ttb.sptensor.from_tensor_type(sptensorInstance) - copy[np.array([[1, 1, 1], [2, 1, 3]]).astype(int)] = 999.0 - assert (copy[np.array([2, 1, 3]), 'extract'] == np.array([999])).all() - assert (copy.subs == np.concatenate((data['subs'], np.array([[2, 1, 3]])))).all() + copy[np.array([[1, 2], [1, 1], [1, 3]]).astype(int)] = 999.0 + assert (copy[np.array([[2], [1], [3]])] == np.array([999])).all() + assert (copy.subs == np.concatenate((data["subs"], np.array([[2, 1, 3]])))).all() # Case II: Multiple entries, for multiple subs that don't exist (data, sptensorInstance) = sample_sptensor copy = ttb.sptensor.from_tensor_type(sptensorInstance) - copy[np.array([[1, 1, 2], [2, 1, 3]]).astype(int)] = np.array([[888], [999]]) - assert (copy[np.array([[1, 1, 2], [2, 1, 3]]), 'extract'] == np.array([[888], [999]])).all() - assert (copy.subs == np.concatenate((data['subs'], np.array([[1, 1, 2], [2, 1, 3]])))).all() + copy[np.array([[1, 2], [1, 1], [2, 3]]).astype(int)] = np.array([[888], [999]]) + assert (copy[np.array([[1, 2], [1, 1], [2, 3]])] == np.array([[888], [999]])).all() + assert ( + copy.subs == np.concatenate((data["subs"], np.array([[1, 1, 2], [2, 1, 3]]))) + ).all() # Case II: Multiple entries, for multiple subs that exist and need to be removed (data, sptensorInstance) = sample_sptensor copy = ttb.sptensor.from_tensor_type(sptensorInstance) - copy[np.array([[1, 1, 1], [1, 1, 3]]).astype(int)] = np.array([[0], [0]]) - assert (copy[np.array([[1, 1, 2], [2, 1, 3]]), 'extract'] == np.array([[0], [0]])).all() + copy[np.array([[1, 1], [1, 1], [1, 3]]).astype(int)] = np.array([[0], [0]]) + assert (copy[np.array([[1, 2], [1, 1], [1, 3]])] == np.array([[0], [0]])).all() assert (copy.subs == np.array([[2, 2, 2], [3, 3, 3]])).all() + @pytest.mark.indevelopment def test_sptensor_norm(sample_sptensor): (data, sptensorInstance) = sample_sptensor - assert sptensorInstance.norm() == np.linalg.norm(data['vals']) + assert sptensorInstance.norm() == np.linalg.norm(data["vals"]) + @pytest.mark.indevelopment def test_sptensor_allsubs(sample_sptensor): (data, sptensorInstance) = sample_sptensor result = [] - for i in range(0, data['shape'][0]): - for j in range(0, data['shape'][1]): - for k in range(0, data['shape'][2]): + for i in range(0, data["shape"][0]): + for j in range(0, data["shape"][1]): + for k in range(0, data["shape"][2]): result.append([i, j, k]) assert (sptensorInstance.allsubs() == np.array(result)).all() + @pytest.mark.indevelopment def test_sptensor_logical_not(sample_sptensor): (data, sptensorInstance) = sample_sptensor result = [] - data_subs = data['subs'].tolist() - for i in range(0, data['shape'][0]): - for j in range(0, data['shape'][1]): - for k in range(0, data['shape'][2]): + data_subs = data["subs"].tolist() + for i in range(0, data["shape"][0]): + for j in range(0, data["shape"][1]): + for k in range(0, data["shape"][2]): if [i, j, k] not in data_subs: result.append([i, j, k]) notSptensorInstance = sptensorInstance.logical_not() assert (notSptensorInstance.vals == 1).all() assert (notSptensorInstance.subs == np.array(result)).all() - assert (notSptensorInstance.shape == data['shape']) + assert notSptensorInstance.shape == data["shape"] + @pytest.mark.indevelopment def test_sptensor_logical_or(sample_sptensor): @@ -574,14 +680,16 @@ def test_sptensor_logical_or(sample_sptensor): # Sptensor logical or with another sptensor sptensorOr = sptensorInstance.logical_or(sptensorInstance) - assert sptensorOr.shape == data['shape'] - assert (sptensorOr.subs == data['subs']).all() - assert (sptensorOr.vals == np.ones((data['vals'].shape[0], 1))).all() + assert sptensorOr.shape == data["shape"] + assert (sptensorOr.subs == data["subs"]).all() + assert (sptensorOr.vals == np.ones((data["vals"].shape[0], 1))).all() # Sptensor logical or with tensor - sptensorOr = sptensorInstance.logical_or(ttb.tensor.from_tensor_type(sptensorInstance)) - nonZeroMatrix = np.zeros(data['shape']) - nonZeroMatrix[tuple(data['subs'].transpose())] = 1 + sptensorOr = sptensorInstance.logical_or( + ttb.tensor.from_tensor_type(sptensorInstance) + ) + nonZeroMatrix = np.zeros(data["shape"]) + nonZeroMatrix[tuple(data["subs"].transpose())] = 1 assert (sptensorOr.data == nonZeroMatrix).all() # Sptensor logical or with scalar, 0 @@ -590,16 +698,18 @@ def test_sptensor_logical_or(sample_sptensor): # Sptensor logical or with scalar, not 0 sptensorOr = sptensorInstance.logical_or(1) - assert (sptensorOr.data == np.ones(data['shape'])).all() + assert (sptensorOr.data == np.ones(data["shape"])).all() # Sptensor logical or with wrong shape sptensor with pytest.raises(AssertionError) as excinfo: - sptensorInstance.logical_or(ttb.sptensor.from_data(data['subs'], data['vals'], (5, 5, 5))) - assert "Logical Or requires tensors of the same size" in str(excinfo) + sptensorInstance.logical_or( + ttb.sptensor.from_data(data["subs"], data["vals"], (5, 5, 5)) + ) + assert "Logical Or requires tensors of the same size" in str(excinfo) # Sptensor logical or with not scalar or tensor with pytest.raises(AssertionError) as excinfo: - sptensorInstance.logical_or(np.ones(data['shape'])) + sptensorInstance.logical_or(np.ones(data["shape"])) assert "Sptensor Logical Or argument must be scalar or sptensor" in str(excinfo) @@ -612,24 +722,39 @@ def test_sptensor__eq__(sample_sptensor): assert (eqSptensor.subs == sptensorInstance.logical_not().subs).all() eqSptensor = sptensorInstance == 0.5 - assert (eqSptensor.subs == data['subs'][0]).all() + assert (eqSptensor.subs == data["subs"][0]).all() eqSptensor = sptensorInstance == sptensorInstance - assert (eqSptensor.subs == np.vstack((sptensorInstance.logical_not().subs, data['subs']))).all() + assert ( + eqSptensor.subs + == np.vstack((sptensorInstance.logical_not().subs, data["subs"])) + ).all() denseTensor = ttb.tensor.from_tensor_type(sptensorInstance) eqSptensor = sptensorInstance == denseTensor - assert (eqSptensor.subs == np.vstack((sptensorInstance.logical_not().subs, data['subs']))).all() + logging.debug(f"\ndenseTensor = {denseTensor}") + logging.debug(f"\nsptensorInstance = {sptensorInstance}") + logging.debug(f"\ntype(eqSptensor.subs) = \n{type(eqSptensor.subs)}") + for i in range(eqSptensor.subs.shape[0]): + logging.debug(f"{i}\t{eqSptensor.subs[i,:]}") + logging.debug(f"\neqSptensor.subs = \n{eqSptensor.subs}") + logging.debug(f"\neqSptensor.subs.shape[0] = {eqSptensor.subs.shape[0]}") + logging.debug(f"\nsptensorInstance.shape = {sptensorInstance.shape}") + logging.debug( + f"\nnp.prod(sptensorInstance.shape) = {np.prod(sptensorInstance.shape)}" + ) + assert eqSptensor.subs.shape[0] == np.prod(sptensorInstance.shape) denseTensor = ttb.tensor.from_data(np.ones((5, 5, 5))) with pytest.raises(AssertionError) as excinfo: sptensorInstance == denseTensor - assert 'Size mismatch in sptensor equality' in str(excinfo) + assert "Size mismatch in sptensor equality" in str(excinfo) with pytest.raises(AssertionError) as excinfo: sptensorInstance == np.ones((4, 4, 4)) assert "Sptensor == argument must be scalar or sptensor" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor__ne__(sample_sptensor): (data, sptensorInstance) = sample_sptensor @@ -637,20 +762,28 @@ def test_sptensor__ne__(sample_sptensor): X = ttb.sptensor.from_data(np.array([[0, 0], [1, 1]]), np.array([[2], [2]]), (2, 2)) Y = ttb.sptensor.from_data(np.array([[0, 0], [0, 1]]), np.array([[3], [3]]), (2, 2)) assert (X != Y).isequal( - ttb.sptensor.from_data(np.array([[1, 1], [0, 1], [0, 0]]), np.array([True, True, True])[:, None], (2, 2))) + ttb.sptensor.from_data( + np.array([[1, 1], [0, 1], [0, 0]]), + np.array([True, True, True])[:, None], + (2, 2), + ) + ) eqSptensor = sptensorInstance != 0.0 - assert (eqSptensor.vals == 0*sptensorInstance.vals + 1).all() + assert (eqSptensor.vals == 0 * sptensorInstance.vals + 1).all() eqSptensor = sptensorInstance != 0.5 - assert (eqSptensor.subs == np.vstack((data['subs'][1:], sptensorInstance.logical_not().subs))).all() + assert ( + eqSptensor.subs + == np.vstack((data["subs"][1:], sptensorInstance.logical_not().subs)) + ).all() eqSptensor = sptensorInstance != sptensorInstance - assert (eqSptensor.vals.size == 0) + assert eqSptensor.vals.size == 0 denseTensor = ttb.tensor.from_tensor_type(sptensorInstance) eqSptensor = sptensorInstance != denseTensor - assert (eqSptensor.vals.size == 0) + assert eqSptensor.vals.size == 0 denseTensor = ttb.tensor.from_tensor_type(sptensorInstance) denseTensor[1, 1, 2] = 1 @@ -664,51 +797,58 @@ def test_sptensor__ne__(sample_sptensor): with pytest.raises(AssertionError) as excinfo: sptensorInstance != np.ones((4, 4, 4)) - assert 'The arguments must be two sptensors or an sptensor and a scalar.' in str(excinfo) + assert "The arguments must be two sptensors or an sptensor and a scalar." in str( + excinfo + ) def test_sptensor__end(sample_sptensor): (data, sptensorInstance) = sample_sptensor - assert sptensorInstance.end() == np.prod(data['shape']) - 1 - assert sptensorInstance.end(k=0) == data['shape'][0] - 1 + assert sptensorInstance.end() == np.prod(data["shape"]) - 1 + assert sptensorInstance.end(k=0) == data["shape"][0] - 1 + def test_sptensor__find(sample_sptensor): (data, sptensorInstance) = sample_sptensor subs, vals = sptensorInstance.find() - assert (subs == data['subs']).all() - assert (vals == data['vals']).all() + assert (subs == data["subs"]).all() + assert (vals == data["vals"]).all() + def test_sptensor__sub__(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Sptensor - sptensor subSptensor = sptensorInstance - sptensorInstance - assert (subSptensor.vals.size == 0) + assert subSptensor.vals.size == 0 # Sptensor - sptensor of wrong size with pytest.raises(AssertionError) as excinfo: sptensorInstance - ttb.sptensor.from_data(np.array([]), np.array([]), (6, 6, 6)) - assert 'Must be two sparse tensors of the same shape' in str(excinfo) + assert "Must be two sparse tensors of the same shape" in str(excinfo) # Sptensor - tensor subSptensor = sptensorInstance - ttb.tensor.from_tensor_type(sptensorInstance) - assert (subSptensor.data == np.zeros(data['shape'])).all() + assert (subSptensor.data == np.zeros(data["shape"])).all() # Sptensor - scalar subSptensor = sptensorInstance - 0 - assert (subSptensor.data == ttb.tensor.from_tensor_type(sptensorInstance).data).all() + assert ( + subSptensor.data == ttb.tensor.from_tensor_type(sptensorInstance).data + ).all() + def test_sptensor__add__(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Sptensor + sptensor subSptensor = sptensorInstance + sptensorInstance - assert (subSptensor.vals == 2*data['vals']).all() + assert (subSptensor.vals == 2 * data["vals"]).all() # Sptensor + sptensor of wrong size with pytest.raises(AssertionError) as excinfo: sptensorInstance + ttb.sptensor.from_data(np.array([]), np.array([]), (6, 6, 6)) - assert 'Must be two sparse tensors of the same shape' in str(excinfo) + assert "Must be two sparse tensors of the same shape" in str(excinfo) # Sptensor + tensor subSptensor = sptensorInstance + ttb.tensor.from_tensor_type(sptensorInstance) @@ -717,13 +857,18 @@ def test_sptensor__add__(sample_sptensor): # Sptensor + scalar subSptensor = sptensorInstance + 0 - assert (subSptensor.data == ttb.tensor.from_tensor_type(sptensorInstance).data).all() + assert ( + subSptensor.data == ttb.tensor.from_tensor_type(sptensorInstance).data + ).all() + def test_sptensor_isequal(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Wrong shape sptensor - assert not sptensorInstance.isequal(ttb.sptensor.from_data(np.array([]), np.array([]), (6, 6, 6))) + assert not sptensorInstance.isequal( + ttb.sptensor.from_data(np.array([]), np.array([]), (6, 6, 6)) + ) # Sptensor is equal to itself assert sptensorInstance.isequal(sptensorInstance) @@ -732,7 +877,8 @@ def test_sptensor_isequal(sample_sptensor): assert sptensorInstance.isequal(ttb.tensor.from_tensor_type(sptensorInstance)) # Sptensor equality with not sptensor or tensor - assert not sptensorInstance.isequal(np.ones(data['shape'])) + assert not sptensorInstance.isequal(np.ones(data["shape"])) + def test_sptensor__pos__(sample_sptensor): (data, sptensorInstance) = sample_sptensor @@ -740,6 +886,7 @@ def test_sptensor__pos__(sample_sptensor): assert sptensorInstance.isequal(sptensorInstance2) + def test_sptensor__neg__(sample_sptensor): (data, sptensorInstance) = sample_sptensor sptensorInstance2 = -sptensorInstance @@ -748,29 +895,34 @@ def test_sptensor__neg__(sample_sptensor): assert not sptensorInstance.isequal(sptensorInstance2) assert sptensorInstance.isequal(sptensorInstance3) + def test_sptensor__mul__(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Test mul with int - assert ((sptensorInstance * 2).vals == 2*data['vals']).all() + assert ((sptensorInstance * 2).vals == 2 * data["vals"]).all() # Test mul with float - assert ((sptensorInstance * 2.0).vals == 2*data['vals']).all() + assert ((sptensorInstance * 2.0).vals == 2 * data["vals"]).all() # Test mul with sptensor - assert ((sptensorInstance * sptensorInstance).vals == data['vals']*data['vals']).all() + assert ( + (sptensorInstance * sptensorInstance).vals == data["vals"] * data["vals"] + ).all() # Test mul with tensor - assert ((sptensorInstance * ttb.tensor.from_tensor_type(sptensorInstance)).vals == data['vals'] * data['vals']).all() + assert ( + (sptensorInstance * ttb.tensor.from_tensor_type(sptensorInstance)).vals + == data["vals"] * data["vals"] + ).all() # Test mul with ktensor - weights = np.array([1., 2.]) - fm0 = np.array([[1., 2.], [3., 4.]]) - fm1 = np.array([[5., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[1.0, 2.0], [3.0, 4.0]]) + fm1 = np.array([[5.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] K = ttb.ktensor.from_data(weights, factor_matrices) subs = np.array([[0, 0], [0, 1], [1, 1]]) - vals = np.array([[0.5], [1.], [1.5]]) + vals = np.array([[0.5], [1.0], [1.5]]) shape = (2, 2) S = ttb.sptensor().from_data(subs, vals, shape) - assert((S*K).full().isequal(K.full() * S)) - + assert (S * K).full().isequal(K.full() * S) # Test mul with wrong shape with pytest.raises(AssertionError) as excinfo: @@ -779,114 +931,144 @@ def test_sptensor__mul__(sample_sptensor): # Test mul with wrong type with pytest.raises(AssertionError) as excinfo: - sptensorInstance * 'string' + sptensorInstance * "string" assert "Sptensor cannot be multiplied by that type of object" in str(excinfo) + def test_sptensor__rmul__(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Test mul with int - assert ((2 * sptensorInstance ).vals == 2*data['vals']).all() + assert ((2 * sptensorInstance).vals == 2 * data["vals"]).all() # Test mul with float - assert ((2.0 * sptensorInstance).vals == 2*data['vals']).all() + assert ((2.0 * sptensorInstance).vals == 2 * data["vals"]).all() # Test mul with ktensor - weights = np.array([1., 2.]) - fm0 = np.array([[1., 2.], [3., 4.]]) - fm1 = np.array([[5., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[1.0, 2.0], [3.0, 4.0]]) + fm1 = np.array([[5.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] K = ttb.ktensor.from_data(weights, factor_matrices) subs = np.array([[0, 0], [0, 1], [1, 1]]) - vals = np.array([[0.5], [1.], [1.5]]) + vals = np.array([[0.5], [1.0], [1.5]]) shape = (2, 2) S = ttb.sptensor().from_data(subs, vals, shape) - assert ((S * K).full().isequal(S * K.full())) + assert (S * K).full().isequal(S * K.full()) # Test mul with wrong type with pytest.raises(AssertionError) as excinfo: - 'string' * sptensorInstance + "string" * sptensorInstance assert "This object cannot be multiplied by sptensor" in str(excinfo) + def test_sptensor_ones(sample_sptensor): (data, sptensorInstance) = sample_sptensor - assert (sptensorInstance.ones().vals == (0.0*data['vals'] + 1)).all() + assert (sptensorInstance.ones().vals == (0.0 * data["vals"] + 1)).all() + @pytest.mark.indevelopment def test_sptensor_double(sample_sptensor): (data, sptensorInstance) = sample_sptensor denseData = np.zeros(sptensorInstance.shape) - actualIdx = tuple(data['subs'].transpose()) - denseData[actualIdx] = data['vals'].transpose()[0] + actualIdx = tuple(data["subs"].transpose()) + denseData[actualIdx] = data["vals"].transpose()[0] assert (sptensorInstance.double() == denseData).all() - assert (sptensorInstance.double().shape == data['shape']) + assert sptensorInstance.double().shape == data["shape"] + @pytest.mark.indevelopment def test_sptensor__le__(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Test comparison to negative scalar - assert ((-sptensorInstance <= -0.1).vals == 0*data['vals'] + 1).all() + assert ((-sptensorInstance <= -0.1).vals == 0 * data["vals"] + 1).all() # Test comparison to positive scalar assert ((sptensorInstance <= 0.1).vals == sptensorInstance.logical_not().vals).all() # Test comparison to tensor - assert ((sptensorInstance <= sptensorInstance.full()).vals == np.ones((np.prod(data['shape']), 1))).all() + assert ( + (sptensorInstance <= sptensorInstance.full()).vals + == np.ones((np.prod(data["shape"]), 1)) + ).all() # Test comparison to sptensor - assert ((sptensorInstance <= sptensorInstance).vals == np.ones((np.prod(data['shape']), 1))).all() + assert ( + (sptensorInstance <= sptensorInstance).vals + == np.ones((np.prod(data["shape"]), 1)) + ).all() # Test comparison of empty tensor with sptensor, both ways - emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), data['shape']) - assert ((emptySptensor <= sptensorInstance).vals == np.ones((np.prod(data['shape']), 1))).all() - assert ((sptensorInstance <= emptySptensor).vals == sptensorInstance.logical_not().vals).all() + emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), data["shape"]) + assert ( + (emptySptensor <= sptensorInstance).vals == np.ones((np.prod(data["shape"]), 1)) + ).all() + assert ( + (sptensorInstance <= emptySptensor).vals == sptensorInstance.logical_not().vals + ).all() # Test comparison with different size with pytest.raises(AssertionError) as excinfo: - sptensorInstance <= ttb.sptensor.from_data(np.array([]), np.array([]), (5, 5, 5)) - assert 'Size mismatch' in str(excinfo) + sptensorInstance <= ttb.sptensor.from_data( + np.array([]), np.array([]), (5, 5, 5) + ) + assert "Size mismatch" in str(excinfo) # Test comparison with incorrect type with pytest.raises(AssertionError) as excinfo: - sptensorInstance <= 'string' + sptensorInstance <= "string" assert "Cannot compare sptensor with that type" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor__ge__(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Test comparison to positive scalar - assert ((sptensorInstance >= 0.1).vals == 0*data['vals'] + 1).all() + assert ((sptensorInstance >= 0.1).vals == 0 * data["vals"] + 1).all() # Test comparison to negative scalar - assert ((sptensorInstance >= -0.1).vals == np.ones((np.prod(data['shape']), 1))).all() + assert ( + (sptensorInstance >= -0.1).vals == np.ones((np.prod(data["shape"]), 1)) + ).all() # Test comparison to tensor - assert ((sptensorInstance >= sptensorInstance.full()).vals == np.ones((np.prod(data['shape']), 1))).all() + assert ( + (sptensorInstance >= sptensorInstance.full()).vals + == np.ones((np.prod(data["shape"]), 1)) + ).all() # Test comparison to sptensor - assert ((sptensorInstance >= sptensorInstance).vals == np.ones((np.prod(data['shape']), 1))).all() + assert ( + (sptensorInstance >= sptensorInstance).vals + == np.ones((np.prod(data["shape"]), 1)) + ).all() # Test comparison with different size with pytest.raises(AssertionError) as excinfo: - sptensorInstance >= ttb.sptensor.from_data(np.array([]), np.array([]), (5, 5, 5)) - assert 'Size mismatch' in str(excinfo) + sptensorInstance >= ttb.sptensor.from_data( + np.array([]), np.array([]), (5, 5, 5) + ) + assert "Size mismatch" in str(excinfo) # Test comparison with incorrect type with pytest.raises(AssertionError) as excinfo: - sptensorInstance >= 'string' + sptensorInstance >= "string" assert "Cannot compare sptensor with that type" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor__gt__(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Test comparison to positive scalar - assert ((sptensorInstance > 0.1).vals == 0*data['vals'] + 1).all() + assert ((sptensorInstance > 0.1).vals == 0 * data["vals"] + 1).all() # Test comparison to negative scalar - assert ((sptensorInstance > -0.1).vals == np.ones((np.prod(data['shape']), 1))).all() + assert ( + (sptensorInstance > -0.1).vals == np.ones((np.prod(data["shape"]), 1)) + ).all() # Test comparison to tensor - assert ((sptensorInstance > sptensorInstance.full()).vals.size == 0) + assert (sptensorInstance > sptensorInstance.full()).vals.size == 0 # Test comparison to tensor of different sparsity patter denseTensor = sptensorInstance.full() @@ -894,64 +1076,70 @@ def test_sptensor__gt__(sample_sptensor): assert ((sptensorInstance > denseTensor).subs == np.array([1, 1, 2])).all() # Test comparison to sptensor - assert ((sptensorInstance > sptensorInstance).vals.size == 0) + assert (sptensorInstance > sptensorInstance).vals.size == 0 # Test comparison with different size with pytest.raises(AssertionError) as excinfo: sptensorInstance > ttb.sptensor.from_data(np.array([]), np.array([]), (5, 5, 5)) - assert 'Size mismatch' in str(excinfo) + assert "Size mismatch" in str(excinfo) # Test comparison with incorrect type with pytest.raises(AssertionError) as excinfo: - sptensorInstance > 'string' + sptensorInstance > "string" assert "Cannot compare sptensor with that type" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor__lt__(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Test comparison to negative scalar - assert ((-sptensorInstance < -0.1).vals == 0*data['vals'] + 1).all() + assert ((-sptensorInstance < -0.1).vals == 0 * data["vals"] + 1).all() # Test comparison to positive scalar assert ((sptensorInstance < 0.1).vals == sptensorInstance.logical_not().vals).all() # Test comparison to tensor - assert ((sptensorInstance < sptensorInstance.full()).vals.size == 0) + assert (sptensorInstance < sptensorInstance.full()).vals.size == 0 # Test comparison to sptensor - assert ((sptensorInstance < sptensorInstance).vals.size == 0) + assert (sptensorInstance < sptensorInstance).vals.size == 0 # Test comparison of empty tensor with sptensor, both ways - emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), data['shape']) - assert ((emptySptensor < sptensorInstance).subs == data['subs']).all() - assert ((sptensorInstance < emptySptensor).vals.size == 0) + emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), data["shape"]) + assert ((emptySptensor < sptensorInstance).subs == data["subs"]).all() + assert (sptensorInstance < emptySptensor).vals.size == 0 # Test comparison with different size with pytest.raises(AssertionError) as excinfo: sptensorInstance < ttb.sptensor.from_data(np.array([]), np.array([]), (5, 5, 5)) - assert 'Size mismatch' in str(excinfo) + assert "Size mismatch" in str(excinfo) # Test comparison with incorrect type with pytest.raises(AssertionError) as excinfo: - sptensorInstance < 'string' + sptensorInstance < "string" assert "Cannot compare sptensor with that type" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor_innerprod(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Empty sptensor innerproduct - emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), data['shape']) + emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), data["shape"]) assert sptensorInstance.innerprod(emptySptensor) == 0 assert emptySptensor.innerprod(sptensorInstance) == 0 # Sptensor innerproduct - assert sptensorInstance.innerprod(sptensorInstance) == data['vals'].transpose().dot(data['vals']) + assert sptensorInstance.innerprod(sptensorInstance) == data["vals"].transpose().dot( + data["vals"] + ) # Sptensor innerproduct, other has more elements sptensorCopy = ttb.sptensor.from_tensor_type(sptensorInstance) sptensorCopy[0, 0, 0] = 1 - assert sptensorInstance.innerprod(sptensorCopy) == data['vals'].transpose().dot(data['vals']) + assert sptensorInstance.innerprod(sptensorCopy) == data["vals"].transpose().dot( + data["vals"] + ) # Wrong shape sptensor emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), (1, 1)) @@ -960,8 +1148,9 @@ def test_sptensor_innerprod(sample_sptensor): assert "Sptensors must be same shape for innerproduct" in str(excinfo) # Tensor innerproduct - assert sptensorInstance.innerprod(ttb.tensor.from_tensor_type(sptensorInstance)) == \ - data['vals'].transpose().dot(data['vals']) + assert sptensorInstance.innerprod( + ttb.tensor.from_tensor_type(sptensorInstance) + ) == data["vals"].transpose().dot(data["vals"]) # Wrong shape tensor with pytest.raises(AssertionError) as excinfo: @@ -971,13 +1160,14 @@ def test_sptensor_innerprod(sample_sptensor): # Wrong type for innerprod with pytest.raises(AssertionError) as excinfo: sptensorInstance.innerprod(5) - assert "Inner product between sptensor and that class not supported" in str(excinfo) + assert f"Inner product between sptensor and {type(5)} not supported" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor_logical_xor(sample_sptensor): (data, sptensorInstance) = sample_sptensor - nonZeroMatrix = np.zeros(data['shape']) - nonZeroMatrix[tuple(data['subs'].transpose())] = 1 + nonZeroMatrix = np.zeros(data["shape"]) + nonZeroMatrix[tuple(data["subs"].transpose())] = 1 # Sptensor logical xor with scalar, 0 sptensorXor = sptensorInstance.logical_xor(0) @@ -989,65 +1179,101 @@ def test_sptensor_logical_xor(sample_sptensor): # Sptensor logical xor with another sptensor sptensorXor = sptensorInstance.logical_xor(sptensorInstance) - assert sptensorXor.shape == data['shape'] - assert (sptensorXor.vals.size == 0) + assert sptensorXor.shape == data["shape"] + assert sptensorXor.vals.size == 0 # Sptensor logical xor with tensor - sptensorXor = sptensorInstance.logical_xor(ttb.tensor.from_tensor_type(sptensorInstance)) - assert (sptensorXor.data == np.zeros(data['shape'], dtype=bool)).all() + sptensorXor = sptensorInstance.logical_xor( + ttb.tensor.from_tensor_type(sptensorInstance) + ) + assert (sptensorXor.data == np.zeros(data["shape"], dtype=bool)).all() # Sptensor logical xor with wrong shape sptensor with pytest.raises(AssertionError) as excinfo: - sptensorInstance.logical_xor(ttb.sptensor.from_data(data['subs'], data['vals'], (5, 5, 5))) + sptensorInstance.logical_xor( + ttb.sptensor.from_data(data["subs"], data["vals"], (5, 5, 5)) + ) assert "Logical XOR requires tensors of the same size" in str(excinfo) # Sptensor logical xor with not scalar or tensor with pytest.raises(AssertionError) as excinfo: - sptensorInstance.logical_xor(np.ones(data['shape'])) + sptensorInstance.logical_xor(np.ones(data["shape"])) assert "The argument must be an sptensor, tensor or scalar" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor_squeeze(sample_sptensor): (data, sptensorInstance) = sample_sptensor # No singleton dimensions - assert (sptensorInstance.squeeze().vals == data['vals']).all() - assert (sptensorInstance.squeeze().subs == data['subs']).all() - + assert (sptensorInstance.squeeze().vals == data["vals"]).all() + assert (sptensorInstance.squeeze().subs == data["subs"]).all() # All singleton dimensions - assert (ttb.sptensor.from_data(np.array([[0, 0, 0]]), np.array([4]), (1, 1, 1)).squeeze() == 4) + assert ( + ttb.sptensor.from_data( + np.array([[0, 0, 0]]), np.array([4]), (1, 1, 1) + ).squeeze() + == 4 + ) # A singleton dimension - assert np.array_equal(ttb.sptensor.from_data(np.array([[0, 0, 0]]), np.array([4]), (2, 2, 1)).squeeze().subs, np.array([[0, 0]])) - assert (ttb.sptensor.from_data(np.array([[0, 0, 0]]), np.array([4]), (2, 2, 1)).squeeze().vals == np.array( - [4])).all() + assert np.array_equal( + ttb.sptensor.from_data(np.array([[0, 0, 0]]), np.array([4]), (2, 2, 1)) + .squeeze() + .subs, + np.array([[0, 0]]), + ) + assert ( + ttb.sptensor.from_data(np.array([[0, 0, 0]]), np.array([4]), (2, 2, 1)) + .squeeze() + .vals + == np.array([4]) + ).all() # Singleton dimension with empty sptensor - assert (ttb.sptensor.from_data(np.array([]), np.array([]), (2, 2, 1)).squeeze().shape == (2, 2)) + assert ttb.sptensor.from_data( + np.array([]), np.array([]), (2, 2, 1) + ).squeeze().shape == (2, 2) + @pytest.mark.indevelopment def test_sptensor_scale(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Scale with np array - assert (sptensorInstance.scale(np.array([4, 4, 4, 4]), 1).vals == 4*data['vals']).all() + assert ( + sptensorInstance.scale(np.array([4, 4, 4, 4]), 1).vals == 4 * data["vals"] + ).all() # Scale with sptensor - assert (sptensorInstance.scale(sptensorInstance, np.arange(0, 3)).vals == data['vals']**2).all() + assert ( + sptensorInstance.scale(sptensorInstance, np.arange(0, 3)).vals + == data["vals"] ** 2 + ).all() # Scale with tensor - assert (sptensorInstance.scale(ttb.tensor.from_tensor_type(sptensorInstance), np.arange(0, 3)).vals == data['vals'] ** 2).all() + assert ( + sptensorInstance.scale( + ttb.tensor.from_tensor_type(sptensorInstance), np.arange(0, 3) + ).vals + == data["vals"] ** 2 + ).all() # Incorrect shape np array, sptensor and tensor with pytest.raises(AssertionError) as excinfo: sptensorInstance.scale(np.array([4, 4, 4, 4, 4]), 1) assert "Size mismatch in scale" in str(excinfo) with pytest.raises(AssertionError) as excinfo: - sptensorInstance.scale(ttb.sptensor.from_data(np.array([]), np.array([]), (1, 1, 1, 1, 1)), np.arange(0, 3)) + sptensorInstance.scale( + ttb.sptensor.from_data(np.array([]), np.array([]), (1, 1, 1, 1, 1)), + np.arange(0, 3), + ) assert "Size mismatch in scale" in str(excinfo) with pytest.raises(AssertionError) as excinfo: - sptensorInstance.scale(ttb.tensor.from_data(np.ones((1, 1, 1, 1, 1))), np.arange(0, 3)) + sptensorInstance.scale( + ttb.tensor.from_data(np.ones((1, 1, 1, 1, 1))), np.arange(0, 3) + ) assert "Size mismatch in scale" in str(excinfo) # Scale with non nparray, sptensor or tensor @@ -1055,6 +1281,7 @@ def test_sptensor_scale(sample_sptensor): sptensorInstance.scale(1, 1) assert "Invalid scaling factor" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor_reshape(sample_sptensor): (data, sptensorInstance) = sample_sptensor @@ -1066,25 +1293,31 @@ def test_sptensor_reshape(sample_sptensor): assert sptensorInstance.reshape((16, 1), np.array([0, 2])).shape == (4, 16, 1) # Reshape empty sptensor - assert ttb.sptensor.from_data(np.array([]), np.array([]), (4, 4, 4)).reshape((16, 4, 1)).shape == (16, 4, 1) + assert ttb.sptensor.from_data(np.array([]), np.array([]), (4, 4, 4)).reshape( + (16, 4, 1) + ).shape == (16, 4, 1) # Improper reshape with pytest.raises(AssertionError) as excinfo: assert sptensorInstance.reshape((16, 1), np.array([0])).shape == (4, 16, 1) assert "Reshape must maintain tensor size" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor_mask(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Mask captures all non-zero entries - assert (sptensorInstance.mask(sptensorInstance) == data['vals']).all() + assert (sptensorInstance.mask(sptensorInstance) == data["vals"]).all() # Mask too large with pytest.raises(AssertionError) as excinfo: - sptensorInstance.mask(ttb.sptensor.from_data(np.array([]), np.array([]), (3, 3, 5))) + sptensorInstance.mask( + ttb.sptensor.from_data(np.array([]), np.array([]), (3, 3, 5)) + ) assert "Mask cannot be bigger than the data tensor" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor_permute(sample_sptensor): (data, sptensorInstance) = sample_sptensor @@ -1104,25 +1337,30 @@ def test_sptensor_permute(sample_sptensor): sptensorInstance.permute(np.array([0, 0, 0])) assert "Invalid permutation order" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor__rtruediv__(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Scalar / Spensor yields tensor, only resolves when left object doesn't have appropriate __truediv__ - # We ignore the divide by zero errors because np.inf/np.nan is an appropriate representation - with np.errstate(divide='ignore', invalid='ignore'): + # We ignore the divide by zero errors because np.inf/np.nan is an appropriate representation + with np.errstate(divide="ignore", invalid="ignore"): assert ((2 / sptensorInstance).data == (2 / sptensorInstance.full().data)).all() # Tensor / Spensor yields tensor should be calling tensor.__truediv__ - # We ignore the divide by zero errors because np.inf/np.nan is an appropriate representation - with np.errstate(divide='ignore', invalid='ignore'): - np.testing.assert_array_equal((sptensorInstance.full() / sptensorInstance).data,(sptensorInstance.full().data/sptensorInstance.full().data)) + # We ignore the divide by zero errors because np.inf/np.nan is an appropriate representation + with np.errstate(divide="ignore", invalid="ignore"): + np.testing.assert_array_equal( + (sptensorInstance.full() / sptensorInstance).data, + (sptensorInstance.full().data / sptensorInstance.full().data), + ) # Non-Scalar / Spensor yields tensor, only resolves when left object doesn't have appropriate __truediv__ with pytest.raises(AssertionError) as excinfo: - (('string' / sptensorInstance).data == (2 / sptensorInstance.full().data)) + (("string" / sptensorInstance).data == (2 / sptensorInstance.full().data)) assert "Dividing that object by an sptensor is not supported" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor__truediv__(sample_sptensor): (data, sptensorInstance) = sample_sptensor @@ -1130,71 +1368,103 @@ def test_sptensor__truediv__(sample_sptensor): emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), (4, 4, 4)) # Sptensor/ non-zero scalar - assert ((sptensorInstance / 5).vals == data['vals']/5).all() + assert ((sptensorInstance / 5).vals == data["vals"] / 5).all() # Sptensor/zero scalar - np.testing.assert_array_equal((sptensorInstance / 0).vals, np.vstack((np.inf*np.ones((data['subs'].shape[0], 1)), np.nan*np.ones((np.prod(data['shape']) - data['subs'].shape[0], 1))))) + np.testing.assert_array_equal( + (sptensorInstance / 0).vals, + np.vstack( + ( + np.inf * np.ones((data["subs"].shape[0], 1)), + np.nan * np.ones((np.prod(data["shape"]) - data["subs"].shape[0], 1)), + ) + ), + ) # Sptensor/sptensor - np.testing.assert_array_equal((sptensorInstance/sptensorInstance).vals, - np.vstack((data['vals']/data['vals'], np.nan*np.ones((np.prod(data['shape']) - data['subs'].shape[0], 1))))) + np.testing.assert_array_equal( + (sptensorInstance / sptensorInstance).vals, + np.vstack( + ( + data["vals"] / data["vals"], + np.nan * np.ones((np.prod(data["shape"]) - data["subs"].shape[0], 1)), + ) + ), + ) # Sptensor/ empty tensor - np.testing.assert_array_equal((sptensorInstance / emptySptensor).vals, np.nan * np.ones((np.prod(data['shape']), 1))) + np.testing.assert_array_equal( + (sptensorInstance / emptySptensor).vals, + np.nan * np.ones((np.prod(data["shape"]), 1)), + ) # empty tensor/Sptensor - np.testing.assert_array_equal((emptySptensor / sptensorInstance).vals, - np.vstack((np.zeros((data['subs'].shape[0], 1)), - np.nan * np.ones((np.prod(data['shape']) - data['subs'].shape[0], 1))))) + np.testing.assert_array_equal( + (emptySptensor / sptensorInstance).vals, + np.vstack( + ( + np.zeros((data["subs"].shape[0], 1)), + np.nan * np.ones((np.prod(data["shape"]) - data["subs"].shape[0], 1)), + ) + ), + ) # Sptensor/tensor - assert ((sptensorInstance/sptensorInstance.full()).vals == data['vals']/data['vals']).all() + assert ( + (sptensorInstance / sptensorInstance.full()).vals == data["vals"] / data["vals"] + ).all() # Sptensor/ktensor - weights = np.array([1., 2.]) - fm0 = np.array([[1., 2.], [3., 4.]]) - fm1 = np.array([[5., 6.], [7., 8.]]) + weights = np.array([1.0, 2.0]) + fm0 = np.array([[1.0, 2.0], [3.0, 4.0]]) + fm1 = np.array([[5.0, 6.0], [7.0, 8.0]]) factor_matrices = [fm0, fm1] K = ttb.ktensor.from_data(weights, factor_matrices) subs = np.array([[0, 0], [0, 1], [1, 1]]) - vals = np.array([[0.5], [1.], [1.5]]) + vals = np.array([[0.5], [1.0], [1.5]]) shape = (2, 2) S = ttb.sptensor().from_data(subs, vals, shape) - assert (S/K).full().isequal(S.full()/K.full()) + assert (S / K).full().isequal(S.full() / K.full()) # Sptensor/ invalid with pytest.raises(AssertionError) as excinfo: - (sptensorInstance/ 'string') + (sptensorInstance / "string") assert "Invalid arguments for sptensor division" in str(excinfo) with pytest.raises(AssertionError) as excinfo: emptySptensor.shape = (5, 5, 5) - (sptensorInstance/ emptySptensor) + (sptensorInstance / emptySptensor) assert "Sptensor division requires tensors of the same shape" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor_collapse(sample_sptensor): (data, sptensorInstance) = sample_sptensor emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), (4, 4, 4)) # Test with no arguments - assert sptensorInstance.collapse() == np.sum(data['vals']) + assert sptensorInstance.collapse() == np.sum(data["vals"]) # Test with custom function - assert sptensorInstance.collapse(fun=sum) == np.sum(data['vals']) + assert sptensorInstance.collapse(fun=sum) == np.sum(data["vals"]) # Test partial collapse, output vector - assert (sptensorInstance.collapse(dims=np.array([0, 1])) == np.array([0, 0.5, 2.5, 5])).all() - assert (emptySptensor.collapse(dims=np.array([0, 1])) == np.array([0, 0, 0, 0])).all() + assert ( + sptensorInstance.collapse(dims=np.array([0, 1])) == np.array([0, 0.5, 2.5, 5]) + ).all() + assert ( + emptySptensor.collapse(dims=np.array([0, 1])) == np.array([0, 0, 0, 0]) + ).all() # Test partial collapse, output sptensor collapseSptensor = sptensorInstance.collapse(dims=np.array([0])) - assert (collapseSptensor.vals == data['vals']).all() - assert (collapseSptensor.shape == (4, 4)) - assert (collapseSptensor.subs == data['subs'][:, 1:3]).all() + assert (collapseSptensor.vals == data["vals"]).all() + assert collapseSptensor.shape == (4, 4) + assert (collapseSptensor.subs == data["subs"][:, 1:3]).all() emptySptensorSmaller = ttb.sptensor.from_tensor_type(emptySptensor) emptySptensorSmaller.shape = (4, 4) - assert (emptySptensor.collapse(dims=np.array([0])).isequal(emptySptensorSmaller)) + assert emptySptensor.collapse(dims=np.array([0])).isequal(emptySptensorSmaller) + @pytest.mark.indevelopment def test_sptensor_contract(sample_sptensor): @@ -1214,23 +1484,28 @@ def test_sptensor_contract(sample_sptensor): assert contractableSptensor.contract(0, 1) == 6.5 contractableSptensor = ttb.sptensor.from_tensor_type(sptensorInstance) - assert (contractableSptensor.contract(0, 1).data == np.array([0, 0.5, 2.5, 5])).all() + assert ( + contractableSptensor.contract(0, 1).data == np.array([0, 0.5, 2.5, 5]) + ).all() contractableSptensor = ttb.sptensor.from_tensor_type(sptensorInstance) contractableSptensor[3, 3, 3, 3] = 1 - assert (contractableSptensor.contract(0, 1).shape == (4, 4)) + assert contractableSptensor.contract(0, 1).shape == (4, 4) + @pytest.mark.indevelopment def test_sptensor_elemfun(sample_sptensor): (data, sptensorInstance) = sample_sptensor def plus1(y): - return y+1 - assert (sptensorInstance.elemfun(plus1).vals == 1+data['vals']).all() - assert (sptensorInstance.elemfun(plus1).subs == data['subs']).all() + return y + 1 + + assert (sptensorInstance.elemfun(plus1).vals == 1 + data["vals"]).all() + assert (sptensorInstance.elemfun(plus1).subs == data["subs"]).all() emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), (4, 4, 4)) - assert (emptySptensor.elemfun(plus1).vals.size == 0) + assert emptySptensor.elemfun(plus1).vals.size == 0 + @pytest.mark.indevelopment def test_sptensor_spmatrix(sample_sptensor): @@ -1238,7 +1513,7 @@ def test_sptensor_spmatrix(sample_sptensor): with pytest.raises(AssertionError) as excinfo: sptensorInstance.spmatrix() - assert 'Sparse tensor must be two dimensional' in str(excinfo) + assert "Sparse tensor must be two dimensional" in str(excinfo) # Test empty sptensor to empty sparse matrix emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), (4, 4)) @@ -1247,26 +1522,33 @@ def test_sptensor_spmatrix(sample_sptensor): assert a.data.size == 0 assert a.shape == emptySptensor.shape - NonEmptySptensor = ttb.sptensor.from_data(np.array([[0, 0]]), np.array([[1]]), (4, 4)) + NonEmptySptensor = ttb.sptensor.from_data( + np.array([[0, 0]]), np.array([[1]]), (4, 4) + ) fullData = np.zeros(NonEmptySptensor.shape) - fullData[0,0] = 1 + fullData[0, 0] = 1 b = NonEmptySptensor.spmatrix() assert (b.toarray() == fullData).all() - NonEmptySptensor = ttb.sptensor.from_data(np.array([[0, 1], [1, 0]]), np.array([[1], [2]]), (4, 4)) + NonEmptySptensor = ttb.sptensor.from_data( + np.array([[0, 1], [1, 0]]), np.array([[1], [2]]), (4, 4) + ) fullData = np.zeros(NonEmptySptensor.shape) fullData[0, 1] = 1 fullData[1, 0] = 2 b = NonEmptySptensor.spmatrix() assert (b.toarray() == fullData).all() - NonEmptySptensor = ttb.sptensor.from_data(np.array([[0, 1], [2, 3]]), np.array([[1], [2]]), (4, 4)) + NonEmptySptensor = ttb.sptensor.from_data( + np.array([[0, 1], [2, 3]]), np.array([[1], [2]]), (4, 4) + ) fullData = np.zeros(NonEmptySptensor.shape) fullData[0, 1] = 1 fullData[2, 3] = 2 b = NonEmptySptensor.spmatrix() assert (b.toarray() == fullData).all() + @pytest.mark.indevelopment def test_sptensor_ttv(sample_sptensor): (data, sptensorInstance) = sample_sptensor @@ -1278,23 +1560,34 @@ def test_sptensor_ttv(sample_sptensor): # Wrong shape vector with pytest.raises(AssertionError) as excinfo: - onesSptensor.ttv(np.array([vector, np.array([1,2])])) + onesSptensor.ttv([vector, np.array([1, 2])]) assert "Multiplicand is wrong size" in str(excinfo) # Returns vector shaped object emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), (4, 4)) onesSptensor = ttb.sptensor.from_tensor_type(ttb.tensor.from_data(np.ones((4, 4)))) - assert emptySptensor.ttv(vector, 0).isequal(ttb.sptensor.from_data(np.array([]), np.array([]), (4,))) - assert onesSptensor.ttv(vector, 0).isequal(ttb.tensor.from_data(np.array([4, 4, 4, 4]))) + assert emptySptensor.ttv(vector, 0).isequal( + ttb.sptensor.from_data(np.array([]), np.array([]), (4,)) + ) + assert onesSptensor.ttv(vector, 0).isequal( + ttb.tensor.from_data(np.array([4, 4, 4, 4])) + ) emptySptensor[0, 0] = 1 assert (emptySptensor.ttv(vector, 0).full().data == np.array([1, 0, 0, 0])).all() # Returns tensor shaped object emptySptensor = ttb.sptensor.from_data(np.array([]), np.array([]), (4, 4, 4)) - onesSptensor = ttb.sptensor.from_tensor_type(ttb.tensor.from_data(np.ones((4, 4, 4)))) - assert emptySptensor.ttv(vector, 0).isequal(ttb.sptensor.from_data(np.array([]), np.array([]), (4, 4))) - assert onesSptensor.ttv(vector, 0).isequal(ttb.tensor.from_data(4 * np.ones((4, 4)))) + onesSptensor = ttb.sptensor.from_tensor_type( + ttb.tensor.from_data(np.ones((4, 4, 4))) + ) + assert emptySptensor.ttv(vector, 0).isequal( + ttb.sptensor.from_data(np.array([]), np.array([]), (4, 4)) + ) + assert onesSptensor.ttv(vector, 0).isequal( + ttb.tensor.from_data(4 * np.ones((4, 4))) + ) + @pytest.mark.indevelopment def test_sptensor_mttkrp(sample_sptensor): @@ -1303,45 +1596,73 @@ def test_sptensor_mttkrp(sample_sptensor): # MTTKRP with array of matrices # Note this is more of a regression test against the output of MATLAB TTB matrix = np.ones((4, 4)) - assert(sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 0) == - np.array([[0, 0, 0, 0], [2, 2, 2, 2], [2.5, 2.5, 2.5, 2.5], [3.5, 3.5, 3.5, 3.5]])).all() - assert(sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 1) == - sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 0)).all() - assert(sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 2) == - np.array([[0, 0, 0, 0], [0.5, 0.5, 0.5, 0.5], [2.5, 2.5, 2.5, 2.5], [5, 5, 5, 5]])).all() + assert ( + sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 0) + == np.array( + [[0, 0, 0, 0], [2, 2, 2, 2], [2.5, 2.5, 2.5, 2.5], [3.5, 3.5, 3.5, 3.5]] + ) + ).all() + assert ( + sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 1) + == sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 0) + ).all() + assert ( + sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 2) + == np.array( + [[0, 0, 0, 0], [0.5, 0.5, 0.5, 0.5], [2.5, 2.5, 2.5, 2.5], [5, 5, 5, 5]] + ) + ).all() # MTTKRP with factor matrices from ktensor K = ttb.ktensor.from_factor_matrices([matrix, matrix, matrix]) - assert (sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 0) == sptensorInstance.mttkrp(K, 0)).all() - assert (sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 1) == sptensorInstance.mttkrp(K, 1)).all() - assert (sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 2) == sptensorInstance.mttkrp(K, 2)).all() + assert ( + sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 0) + == sptensorInstance.mttkrp(K, 0) + ).all() + assert ( + sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 1) + == sptensorInstance.mttkrp(K, 1) + ).all() + assert ( + sptensorInstance.mttkrp(np.array([matrix, matrix, matrix]), 2) + == sptensorInstance.mttkrp(K, 2) + ).all() # Wrong length input with pytest.raises(AssertionError) as excinfo: sptensorInstance.mttkrp(np.array([matrix, matrix, matrix, matrix]), 0) - assert 'List is the wrong length' in str(excinfo) + assert "List is the wrong length" in str(excinfo) with pytest.raises(AssertionError) as excinfo: - sptensorInstance.mttkrp('string', 0) + sptensorInstance.mttkrp("string", 0) assert "Second argument must be ktensor or array" in str(excinfo) + @pytest.mark.indevelopment def test_sptensor_nvecs(sample_sptensor): (data, sptensorInstance) = sample_sptensor # Test for one eigenvector assert np.allclose((sptensorInstance.nvecs(1, 1)), np.array([0, 0, 0, 1])[:, None]) - assert np.allclose((sptensorInstance.nvecs(1, 2)), np.array([[0, 0, 0, 1], [0, 0, 1, 0]]).transpose()) + assert np.allclose( + (sptensorInstance.nvecs(1, 2)), + np.array([[0, 0, 0, 1], [0, 0, 1, 0]]).transpose(), + ) # Test for r >= N-1, requires cast to dense - with pytest.warns(Warning) as record: - ans = np.zeros((4, 3)) - ans[3, 0] = 1 - ans[2, 1] = 1 - ans[1, 2] = 1 - assert np.allclose((sptensorInstance.nvecs(1, 3)), ans) - assert 'Greater than or equal to sptensor.shape[n] - 1 eigenvectors requires cast to dense to solve' \ - in str(record[0].message) + ans = np.zeros((4, 3)) + ans[3, 0] = 1 + ans[2, 1] = 1 + ans[1, 2] = 1 + assert np.allclose((sptensorInstance.nvecs(1, 3)), ans) + + # Negative test, check for only singleton dims + with pytest.raises(ValueError): + single_val_sptensor = ttb.sptensor.from_data( + np.array([[0, 0]]), np.array([1]), shape=(1, 1) + ) + single_val_sptensor.nvecs(0, 0) + @pytest.mark.indevelopment def test_sptensor_ttm(sample_sptensor): @@ -1353,25 +1674,34 @@ def test_sptensor_ttm(sample_sptensor): result[:, 3, 3] = 3.5 result = ttb.tensor.from_data(result) result = ttb.sptensor.from_tensor_type(result) - assert sptensorInstance.ttm(sparse.coo_matrix(np.ones((4, 4))), mode=0).isequal(result) - assert sptensorInstance.ttm(sparse.coo_matrix(np.ones((4, 4))), mode=0, transpose=True).isequal(result) + assert sptensorInstance.ttm(sparse.coo_matrix(np.ones((4, 4))), dims=0).isequal( + result + ) + assert sptensorInstance.ttm( + sparse.coo_matrix(np.ones((4, 4))), dims=0, transpose=True + ).isequal(result) # This is a multiway multiplication yielding a sparse tensor, yielding a dense tensor relies on tensor.ttm matrix = sparse.coo_matrix(np.eye(4)) list_of_matrices = [matrix, matrix, matrix] - assert sptensorInstance.ttm(list_of_matrices, mode=np.array([0, 1, 2])).isequal(sptensorInstance) + assert sptensorInstance.ttm(list_of_matrices, dims=[0, 1, 2]).isequal( + sptensorInstance + ) + assert sptensorInstance.ttm(list_of_matrices, exclude_dims=2).isequal( + sptensorInstance.ttm(list_of_matrices[0:-1], dims=[0, 1]) + ) with pytest.raises(AssertionError) as excinfo: - sptensorInstance.ttm(sparse.coo_matrix(np.ones((5, 5))), mode=0) + sptensorInstance.ttm(sparse.coo_matrix(np.ones((5, 5))), dims=0) assert "Matrix shape doesn't match tensor shape" in str(excinfo) with pytest.raises(AssertionError) as excinfo: - sptensorInstance.ttm(np.array([1, 2, 3, 4]), mode=0) + sptensorInstance.ttm(np.array([1, 2, 3, 4]), dims=0) assert "Sptensor.ttm: second argument must be a matrix" in str(excinfo) with pytest.raises(AssertionError) as excinfo: - sptensorInstance.ttm(sparse.coo_matrix(np.ones((4, 4))), mode=4) - assert "Mode must be in [0, ndims)" in str(excinfo) + sptensorInstance.ttm(sparse.coo_matrix(np.ones((4, 4))), dims=4) + assert "dims must contain values in [0,self.dims)" in str(excinfo) sptensorInstance[0, :, :] = 1 sptensorInstance[3, :, :] = 1 @@ -1385,17 +1715,137 @@ def test_sptensor_ttm(sample_sptensor): # TODO: Ensure mode mappings are consistent between matlab and numpy # MATLAB is opposite orientation so the mapping from matlab to numpy is # {3:0, 2:2, 1:1} - assert (sptensorInstance.ttm(sparse.coo_matrix(np.ones((4, 4))), mode=1).isequal(ttb.tensor.from_data(result))) + assert sptensorInstance.ttm(sparse.coo_matrix(np.ones((4, 4))), dims=1).isequal( + ttb.tensor.from_data(result) + ) - result = 2*np.ones((4, 4, 4)) + result = 2 * np.ones((4, 4, 4)) result[:, 1, 1] = 2.5 result[:, 1, 3] = 3.5 result[:, 2, 2] = 4.5 - assert (sptensorInstance.ttm(sparse.coo_matrix(np.ones((4, 4))), mode=0).isequal(ttb.tensor.from_data(result))) + assert sptensorInstance.ttm(sparse.coo_matrix(np.ones((4, 4))), dims=0).isequal( + ttb.tensor.from_data(result) + ) result = np.zeros((4, 4, 4)) result[0, :, :] = 4.0 result[3, :, :] = 4.0 result[1, 1, :] = 2 result[2, 2, :] = 2.5 - assert (sptensorInstance.ttm(sparse.coo_matrix(np.ones((4, 4))), mode=2).isequal(ttb.tensor.from_data(result))) + assert sptensorInstance.ttm(sparse.coo_matrix(np.ones((4, 4))), dims=2).isequal( + ttb.tensor.from_data(result) + ) + + # Confirm reshape for non-square matrix + assert sptensorInstance.ttm(sparse.coo_matrix(np.ones((1, 4))), dims=2).shape == ( + 4, + 4, + 1, + ) + + +@pytest.mark.indevelopment +def test_sptensor_to_sparse_matrix(): + subs = np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2], [3, 3, 3]]) + vals = np.array([[0.5], [1.5], [2.5], [3.5]]) + shape = (4, 4, 4) + mode0 = sparse.coo_matrix(([0.5, 1.5, 2.5, 3.5], ([5, 13, 10, 15], [1, 1, 2, 3]))) + mode1 = sparse.coo_matrix(([0.5, 1.5, 2.5, 3.5], ([5, 13, 10, 15], [1, 1, 2, 3]))) + mode2 = sparse.coo_matrix(([0.5, 1.5, 2.5, 3.5], ([5, 5, 10, 15], [1, 3, 2, 3]))) + Ynt = [mode0, mode1, mode2] + sptensorInstance = ttb.sptensor().from_data(subs, vals, shape) + + for mode in range(sptensorInstance.ndims): + Xnt = tt_to_sparse_matrix(sptensorInstance, mode, True) + assert (Xnt != Ynt[mode]).nnz == 0 + assert Xnt.shape == Ynt[mode].shape + + +@pytest.mark.indevelopment +def test_sptensor_from_sparse_matrix(): + subs = np.array([[1, 1, 1], [1, 1, 3], [2, 2, 2], [3, 3, 3]]) + vals = np.array([[0.5], [1.5], [2.5], [3.5]]) + shape = (4, 4, 4) + sptensorInstance = ttb.sptensor().from_data(subs, vals, shape) + for mode in range(sptensorInstance.ndims): + sptensorCopy = ttb.sptensor.from_tensor_type(sptensorInstance) + Xnt = tt_to_sparse_matrix(sptensorCopy, mode, True) + Ynt = tt_from_sparse_matrix(Xnt, sptensorCopy.shape, mode, 0) + assert sptensorCopy.isequal(Ynt) + + for mode in range(sptensorInstance.ndims): + sptensorCopy = ttb.sptensor.from_tensor_type(sptensorInstance) + Xnt = tt_to_sparse_matrix(sptensorCopy, mode, False) + Ynt = tt_from_sparse_matrix(Xnt, sptensorCopy.shape, mode, 1) + assert sptensorCopy.isequal(Ynt) + + +def test_sptendiag(): + N = 4 + elements = np.arange(0, N) + exact_shape = [N] * N + + # Inferred shape + X = ttb.sptendiag(elements) + for i in range(N): + diag_index = (i,) * N + assert ( + X[diag_index] == i + ), f"Idx: {diag_index} expected: {i} got: {X[diag_index]}" + + # Exact shape + X = ttb.sptendiag(elements, tuple(exact_shape)) + for i in range(N): + diag_index = (i,) * N + assert X[diag_index] == i + + # Larger shape + larger_shape = exact_shape.copy() + larger_shape[0] += 1 + X = ttb.sptendiag(elements, tuple(larger_shape)) + for i in range(N): + diag_index = (i,) * N + assert X[diag_index] == i + + # Smaller Shape + smaller_shape = exact_shape.copy() + smaller_shape[0] -= 1 + X = ttb.sptendiag(elements, tuple(smaller_shape)) + for i in range(N): + diag_index = (i,) * N + assert X[diag_index] == i + + +def test_sptenrand(): + arbitrary_shape = (3, 3, 3) + rand_tensor = ttb.sptenrand(arbitrary_shape, nonzeros=1) + in_unit_interval = np.all(0 <= rand_tensor.vals <= 1) + assert ( + in_unit_interval + and rand_tensor.shape == arbitrary_shape + and rand_tensor.nnz == 1 + ) + + rand_tensor = ttb.sptenrand(arbitrary_shape, density=1 / np.prod(arbitrary_shape)) + in_unit_interval = np.all(0 <= rand_tensor.vals <= 1) + assert ( + in_unit_interval + and rand_tensor.shape == arbitrary_shape + and rand_tensor.nnz == 1 + ) + + # Negative tests + # Bad density + with pytest.raises(ValueError): + ttb.sptenrand(arbitrary_shape, density=-1) + ttb.sptenrand(arbitrary_shape, density=2) + + # Missing args + # Bad density + with pytest.raises(ValueError): + ttb.sptenrand(arbitrary_shape) + + # Redundant/contradicting args + # Bad density + with pytest.raises(ValueError): + ttb.sptenrand(arbitrary_shape, density=0.5, nonzeros=2) diff --git a/tests/test_tenmat.py b/tests/test_tenmat.py index 6e5f1e2b..f821536e 100644 --- a/tests/test_tenmat.py +++ b/tests/test_tenmat.py @@ -2,37 +2,51 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb import numpy as np import pytest +import pyttb as ttb + DEBUG_tests = False + @pytest.fixture() def sample_ndarray_1way(): shape = (16,) - ndarrayInstance = np.reshape(np.arange(1, 17), shape, order='F') - params = {'data':ndarrayInstance, 'shape':shape} + ndarrayInstance = np.reshape(np.arange(1, 17), shape, order="F") + params = {"data": ndarrayInstance, "shape": shape} return params, ndarrayInstance + @pytest.fixture() def sample_ndarray_2way(): shape = (4, 4) - ndarrayInstance = np.reshape(np.arange(1, 17), shape, order='F') - params = {'data':ndarrayInstance, 'shape':shape} + ndarrayInstance = np.reshape(np.arange(1, 17), shape, order="F") + params = {"data": ndarrayInstance, "shape": shape} return params, ndarrayInstance + +@pytest.fixture() +def sample_tensor_3way(): + data = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0]) + shape = (2, 3, 2) + params = {"data": np.reshape(data, np.array(shape), order="F"), "shape": shape} + tensorInstance = ttb.tensor().from_data(data, shape) + return params, tensorInstance + + @pytest.fixture() def sample_ndarray_4way(): shape = (2, 2, 2, 2) - ndarrayInstance = np.reshape(np.arange(1, 17), shape, order='F') - params = {'data':ndarrayInstance, 'shape':shape} + ndarrayInstance = np.reshape(np.arange(1, 17), shape, order="F") + params = {"data": ndarrayInstance, "shape": shape} return params, ndarrayInstance + @pytest.fixture() def sample_tenmat_4way(): shape = (4, 4) - data = np.reshape(np.arange(1, 17), shape, order='F') + data = np.reshape(np.arange(1, 17), shape, order="F") tshape = (2, 2, 2, 2) rdims = np.array([0, 1]) cdims = np.array([2, 3]) @@ -41,17 +55,25 @@ def sample_tenmat_4way(): tenmatInstance.rindices = rdims.copy() tenmatInstance.cindices = cdims.copy() tenmatInstance.data = data.copy() - params = {'data':data, 'rdims':rdims, 'cdims':cdims, 'tshape':tshape, 'shape':shape} + params = { + "data": data, + "rdims": rdims, + "cdims": cdims, + "tshape": tshape, + "shape": shape, + } return params, tenmatInstance + @pytest.fixture() def sample_tensor_4way(): data = np.arange(1, 17) shape = (2, 2, 2, 2) - params = {'data':np.reshape(data, np.array(shape), order='F'), 'shape': shape} + params = {"data": np.reshape(data, np.array(shape), order="F"), "shape": shape} tensorInstance = ttb.tensor.from_data(data, shape) return params, tensorInstance + @pytest.mark.indevelopment def test_tenmat_initialization_empty(): empty = np.array([]) @@ -64,13 +86,16 @@ def test_tenmat_initialization_empty(): assert (tenmatInstance.cindices == empty).all() assert (tenmatInstance.data == empty).all() + @pytest.mark.indevelopment -def test_tenmat_initialization_from_data(sample_ndarray_1way, sample_ndarray_2way, sample_ndarray_4way, sample_tenmat_4way): +def test_tenmat_initialization_from_data( + sample_ndarray_1way, sample_ndarray_2way, sample_ndarray_4way, sample_tenmat_4way +): (params, tenmatInstance) = sample_tenmat_4way - tshape = params['tshape'] - rdims = params['rdims'] - cdims = params['cdims'] - data = params['data'] + tshape = params["tshape"] + rdims = params["rdims"] + cdims = params["cdims"] + data = params["data"] (_, ndarrayInstance1) = sample_ndarray_1way (_, ndarrayInstance2) = sample_ndarray_2way (_, ndarrayInstance4) = sample_ndarray_4way @@ -92,7 +117,12 @@ def test_tenmat_initialization_from_data(sample_ndarray_1way, sample_ndarray_2wa assert tenmatNdarray1.tshape == tenmatInstance.tshape # Constructor from 1d array converted to 2d column vector - tenmatNdarray1c = ttb.tenmat.from_data(np.reshape(ndarrayInstance1,(ndarrayInstance1.shape[0],1), order='F'), rdims, cdims, tshape) + tenmatNdarray1c = ttb.tenmat.from_data( + np.reshape(ndarrayInstance1, (ndarrayInstance1.shape[0], 1), order="F"), + rdims, + cdims, + tshape, + ) assert (tenmatNdarray1c.data == tenmatInstance.data).all() assert (tenmatNdarray1c.rindices == tenmatInstance.rindices).all() assert (tenmatNdarray1c.cindices == tenmatInstance.cindices).all() @@ -115,22 +145,29 @@ def test_tenmat_initialization_from_data(sample_ndarray_1way, sample_ndarray_2wa assert tenmatNdarray4.shape == tenmatInstance.shape assert tenmatNdarray4.tshape == tenmatInstance.tshape + ## Constructor from 4d array just specifying rdims + tenmatNdarray4 = ttb.tenmat.from_data(ndarrayInstance4, np.array([0])) + assert ( + tenmatNdarray4.data + == np.reshape(ndarrayInstance4, tenmatNdarray4.shape, order="F") + ).all() + # Exceptions ## data is not numpy.ndarray - exc = 'First argument must be a numeric numpy.ndarray.' + exc = "First argument must be a numeric numpy.ndarray." with pytest.raises(AssertionError) as excinfo: a = ttb.tenmat.from_data([7], rdims, cdims, tshape) assert exc in str(excinfo) ## data is numpy.ndarray but not numeric - exc = 'First argument must be a numeric numpy.ndarray.' + exc = "First argument must be a numeric numpy.ndarray." with pytest.raises(AssertionError) as excinfo: - a = ttb.tenmat.from_data(ndarrayInstance2>0, rdims, cdims, tshape) + a = ttb.tenmat.from_data(ndarrayInstance2 > 0, rdims, cdims, tshape) assert exc in str(excinfo) - + # data is empty numpy.ndarray, but other params are not - exc = 'When data is empty, rdims, cdims, and tshape must also be empty.' + exc = "When data is empty, rdims, cdims, and tshape must also be empty." with pytest.raises(AssertionError) as excinfo: a = ttb.tenmat.from_data(np.array([]), rdims, np.array([]), ()) assert exc in str(excinfo) @@ -142,7 +179,7 @@ def test_tenmat_initialization_from_data(sample_ndarray_1way, sample_ndarray_2wa assert exc in str(excinfo) ## data is 1D numpy.ndarray - exc = 'tshape must be specified when data is 1d array.' + exc = "tshape must be specified when data is 1d array." with pytest.raises(AssertionError) as excinfo: a = ttb.tenmat.from_data(ndarrayInstance1, rdims, cdims) assert exc in str(excinfo) @@ -151,42 +188,50 @@ def test_tenmat_initialization_from_data(sample_ndarray_1way, sample_ndarray_2wa assert exc in str(excinfo) # tshape is not a tuple - exc = 'tshape must be a tuple.' + exc = "tshape must be a tuple." with pytest.raises(AssertionError) as excinfo: a = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, list(tshape)) assert exc in str(excinfo) # products of tshape and data.shape do not match - exc = 'Incorrect dimensions specified: products of data.shape and tuple do not match' + exc = ( + "Incorrect dimensions specified: products of data.shape and tuple do not match" + ) with pytest.raises(AssertionError) as excinfo: - a = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, tuple(np.array(tshape) + 1)) + a = ttb.tenmat.from_data( + ndarrayInstance2, rdims, cdims, tuple(np.array(tshape) + 1) + ) assert exc in str(excinfo) # products of tshape and data.shape do not match - exc = 'data.shape does not match shape specified by rdims, cdims, and tshape.' + exc = "data.shape does not match shape specified by rdims, cdims, and tshape." D = [] # do not span all dimensions D.append([np.array([0]), np.array([1])]) ## dimension not in range - #D.append([np.array([0]), np.array([1,2,4])]) + # D.append([np.array([0]), np.array([1,2,4])]) ## too many dimensions specified - #D.append([np.array([0]), np.array([1,2,3,4])]) + # D.append([np.array([0]), np.array([1,2,3,4])]) ## duplicate dimensions - #D.append([np.array([0,1,1]), np.array([2,3])]) - #D.append([np.array([0,1,1]), np.array([3])]) + # D.append([np.array([0,1,1]), np.array([2,3])]) + # D.append([np.array([0,1,1]), np.array([3])]) for d in D: with pytest.raises(AssertionError) as excinfo: a = ttb.tenmat.from_data(ndarrayInstance2, d[0], d[1], tshape) assert exc in str(excinfo) + @pytest.mark.indevelopment -def test_tenmat_initialization_from_tensor_type(sample_tenmat_4way, sample_tensor_4way): +def test_tenmat_initialization_from_tensor_type( + sample_tenmat_4way, sample_tensor_3way, sample_tensor_4way +): (_, tensorInstance) = sample_tensor_4way + (_, tensorInstance3) = sample_tensor_3way (params, tenmatInstance) = sample_tenmat_4way - tshape = params['tshape'] - rdims = params['rdims'] - cdims = params['cdims'] - data = params['data'] + tshape = params["tshape"] + rdims = params["rdims"] + cdims = params["cdims"] + data = params["data"] # Copy Constructor tenmatCopy = ttb.tenmat.from_tensor_type(tenmatInstance) @@ -204,6 +249,11 @@ def test_tenmat_initialization_from_tensor_type(sample_tenmat_4way, sample_tenso assert tenmatInstance.shape == tenmatTensorRdims.shape assert tenmatInstance.tshape == tenmatTensorRdims.tshape + # Constructor from tensor using empty rdims + tenmatTensorRdims = ttb.tenmat.from_tensor_type(tensorInstance3, rdims=np.array([])) + data = np.reshape(np.arange(1, 13), (1, 12)) + assert (tenmatTensorRdims.data == data).all() + # Constructor from tensor using cdims only tenmatTensorCdims = ttb.tenmat.from_tensor_type(tensorInstance, cdims=cdims) assert (tenmatInstance.data == tenmatTensorCdims.data).all() @@ -212,8 +262,15 @@ def test_tenmat_initialization_from_tensor_type(sample_tenmat_4way, sample_tenso assert tenmatInstance.shape == tenmatTensorCdims.shape assert tenmatInstance.tshape == tenmatTensorCdims.tshape + # Constructor from tensor using empty cdims + tenmatTensorCdims = ttb.tenmat.from_tensor_type(tensorInstance3, cdims=np.array([])) + data = np.reshape(np.arange(1, 13), (12, 1)) + assert (tenmatTensorCdims.data == data).all() + # Constructor from tensor using rdims and cdims - tenmatTensorRdimsCdims = ttb.tenmat.from_tensor_type(tensorInstance, rdims=rdims, cdims=cdims) + tenmatTensorRdimsCdims = ttb.tenmat.from_tensor_type( + tensorInstance, rdims=rdims, cdims=cdims + ) assert (tenmatInstance.data == tenmatTensorRdimsCdims.data).all() assert (tenmatInstance.rindices == tenmatTensorRdimsCdims.rindices).all() assert (tenmatInstance.cindices == tenmatTensorRdimsCdims.cindices).all() @@ -225,9 +282,10 @@ def test_tenmat_initialization_from_tensor_type(sample_tenmat_4way, sample_tenso cdimsFC = np.array([2, 3, 0]) tshapeFC = (2, 2, 2, 2) shapeFC = (2, 8) - dataFC = np.array([[ 1, 5, 9, 13, 2, 6, 10, 14], - [ 3, 7, 11, 15, 4, 8, 12, 16]]) - tenmatTensorFC = ttb.tenmat.from_tensor_type(tensorInstance, rdims=rdimsFC, cdims_cyclic='fc') + dataFC = np.array([[1, 5, 9, 13, 2, 6, 10, 14], [3, 7, 11, 15, 4, 8, 12, 16]]) + tenmatTensorFC = ttb.tenmat.from_tensor_type( + tensorInstance, rdims=rdimsFC, cdims_cyclic="fc" + ) assert (tenmatTensorFC.rindices == rdimsFC).all() assert (tenmatTensorFC.cindices == cdimsFC).all() assert (tenmatTensorFC.data == dataFC).all() @@ -239,9 +297,10 @@ def test_tenmat_initialization_from_tensor_type(sample_tenmat_4way, sample_tenso cdimsBC = np.array([0, 3, 2]) tshapeBC = (2, 2, 2, 2) shapeBC = (2, 8) - dataBC = np.array([[ 1, 2, 9, 10, 5, 6, 13, 14], - [ 3, 4, 11, 12, 7, 8, 15, 16]]) - tenmatTensorBC = ttb.tenmat.from_tensor_type(tensorInstance, rdims=rdimsBC, cdims_cyclic='bc') + dataBC = np.array([[1, 2, 9, 10, 5, 6, 13, 14], [3, 4, 11, 12, 7, 8, 15, 16]]) + tenmatTensorBC = ttb.tenmat.from_tensor_type( + tensorInstance, rdims=rdimsBC, cdims_cyclic="bc" + ) assert (tenmatTensorBC.rindices == rdimsBC).all() assert (tenmatTensorBC.cindices == cdimsBC).all() assert (tenmatTensorBC.data == dataBC).all() @@ -253,67 +312,75 @@ def test_tenmat_initialization_from_tensor_type(sample_tenmat_4way, sample_tenso # cdims_cyclic has incorrect value exc = 'Unrecognized value for cdims_cyclic pattern, must be "fc" or "bc".' with pytest.raises(AssertionError) as excinfo: - a = ttb.tenmat.from_tensor_type(tensorInstance, rdims=rdimsBC, cdims_cyclic='c') + a = ttb.tenmat.from_tensor_type(tensorInstance, rdims=rdimsBC, cdims_cyclic="c") assert exc in str(excinfo) # rdims and cdims cannot both be None - exc = 'Either rdims or cdims or both must be specified.' + exc = "Either rdims or cdims or both must be specified." with pytest.raises(AssertionError) as excinfo: a = ttb.tenmat.from_tensor_type(tensorInstance, rdims=None, cdims=None) assert exc in str(excinfo) # rdims must be valid dimensions - exc = 'Values in rdims must be in [0, source.ndims].' + exc = "Values in rdims must be in [0, source.ndims]." with pytest.raises(AssertionError) as excinfo: - a = ttb.tenmat.from_tensor_type(tensorInstance, rdims=np.array([0,1,4]), cdims=cdims) + a = ttb.tenmat.from_tensor_type( + tensorInstance, rdims=np.array([0, 1, 4]), cdims=cdims + ) assert exc in str(excinfo) # cdims must be valid dimensions - exc = 'Values in cdims must be in [0, source.ndims].' + exc = "Values in cdims must be in [0, source.ndims]." with pytest.raises(AssertionError) as excinfo: - a = ttb.tenmat.from_tensor_type(tensorInstance, rdims=rdims, cdims=np.array([2,3,4])) + a = ttb.tenmat.from_tensor_type( + tensorInstance, rdims=rdims, cdims=np.array([2, 3, 4]) + ) assert exc in str(excinfo) # incorrect dimensions - exc = 'Incorrect specification of dimensions, the sorted concatenation of rdims and cdims must be range(source.ndims).' + exc = "Incorrect specification of dimensions, the sorted concatenation of rdims and cdims must be range(source.ndims)." D = [] # do not span all dimensions D.append([np.array([0]), np.array([1])]) # duplicate dimensions - D.append([np.array([0,1,1]), np.array([2,3])]) - D.append([np.array([0,1,1]), np.array([3])]) + D.append([np.array([0, 1, 1]), np.array([2, 3])]) + D.append([np.array([0, 1, 1]), np.array([3])]) for d in D: with pytest.raises(AssertionError) as excinfo: a = ttb.tenmat.from_tensor_type(tensorInstance, d[0], d[1], tshape) assert exc in str(excinfo) + @pytest.mark.indevelopment def test_tenmat_ctranspose(sample_tenmat_4way): (params, tenmatInstance) = sample_tenmat_4way - print('\ntenmatInstance') + print("\ntenmatInstance") print(tenmatInstance) - print('\ntenmatInstance.data.conj().T:') + print("\ntenmatInstance.data.conj().T:") print(tenmatInstance.data.conj().T) tenmatInstanceCtranspose = tenmatInstance.ctranspose() - print('\ntenmatInstanceCtanspose') + print("\ntenmatInstanceCtanspose") print(tenmatInstanceCtranspose) assert (tenmatInstanceCtranspose.data == tenmatInstance.data.conj().T).all() + @pytest.mark.indevelopment def test_tenmat_double(sample_tenmat_4way): (params, tenmatInstance) = sample_tenmat_4way assert (tenmatInstance.double() == tenmatInstance.data.astype(np.float_)).all() + @pytest.mark.indevelopment def test_tenmat_end(sample_tenmat_4way): (params, tenmatInstance) = sample_tenmat_4way - shape = params['shape'] + shape = params["shape"] for k in range(len(shape)): assert tenmatInstance.end(k) == shape[k] - 1 + @pytest.mark.indevelopment def test_tenmat_ndims(sample_tenmat_4way): (params, tenmatInstance) = sample_tenmat_4way @@ -324,91 +391,99 @@ def test_tenmat_ndims(sample_tenmat_4way): # empty tenmat -> 0 dims assert ttb.tenmat().ndims == 0 + @pytest.mark.indevelopment def test_tenmat_norm(sample_ndarray_1way, sample_tenmat_4way): (params, tenmatInstance) = sample_tenmat_4way - tshape = params['tshape'] - rdims = params['rdims'] - cdims = params['cdims'] - data = params['data'] + tshape = params["tshape"] + rdims = params["rdims"] + cdims = params["cdims"] + data = params["data"] (_, ndarrayInstance1) = sample_ndarray_1way # tenmat of 4-way tensor - assert tenmatInstance.norm() == np.linalg.norm(params['data'].ravel()) + assert tenmatInstance.norm() == np.linalg.norm(params["data"].ravel()) # 1D tenmat - tensor1 = ttb.tensor.from_data(ndarrayInstance1,shape=(16,)) + tensor1 = ttb.tensor.from_data(ndarrayInstance1, shape=(16,)) tenmat1 = ttb.tenmat.from_tensor_type(tensor1, cdims=np.array([0])) assert tenmat1.norm() == np.linalg.norm(ndarrayInstance1.ravel()) # empty tenmat assert ttb.tenmat().norm() == 0 + @pytest.mark.indevelopment def test_tenmat__setitem__(): - ndarrayInstance = np.reshape(np.arange(1, 17), (2,2,2,2), order='F') - tensorInstance = ttb.tensor.from_data(ndarrayInstance, shape=(2,2,2,2)) - tenmatInstance = ttb.tenmat.from_tensor_type(tensorInstance, rdims=np.array([0,1])) + ndarrayInstance = np.reshape(np.arange(1, 17), (2, 2, 2, 2), order="F") + tensorInstance = ttb.tensor.from_data(ndarrayInstance, shape=(2, 2, 2, 2)) + tenmatInstance = ttb.tenmat.from_tensor_type(tensorInstance, rdims=np.array([0, 1])) # single element -> scalar tenmatInstance2 = ttb.tenmat.from_tensor_type(tenmatInstance) for i in range(4): for j in range(4): - tenmatInstance2[i,j] = i * 4 + j + 10 + tenmatInstance2[i, j] = i * 4 + j + 10 for i in range(4): for j in range(4): - assert tenmatInstance2[i,j] == i * 4 + j + 10 + assert tenmatInstance2[i, j] == i * 4 + j + 10 # Exceptions - + # checking that index out of bounds throws exception - exc = 'index 5 is out of bounds for axis 1 with size 4' + exc = "index 5 is out of bounds for axis 1 with size 4" with pytest.raises(IndexError) as excinfo: - tenmatInstance2[0,5] = 100 + tenmatInstance2[0, 5] = 100 assert exc in str(excinfo) + @pytest.mark.indevelopment def test_tenmat__getitem__(): - ndarrayInstance = np.reshape(np.arange(1, 17), (4,4), order='F') - tensorInstance = ttb.tensor.from_data(ndarrayInstance,shape=(4,4)) + ndarrayInstance = np.reshape(np.arange(1, 17), (4, 4), order="F") + tensorInstance = ttb.tensor.from_data(ndarrayInstance, shape=(4, 4)) tenmatInstance = ttb.tenmat.from_tensor_type(tensorInstance, rdims=np.array([0])) # single element -> scalar for i in range(4): for j in range(4): - assert ndarrayInstance[i,j] == tenmatInstance[i,j] + assert ndarrayInstance[i, j] == tenmatInstance[i, j] # slicing -> numpy.ndarray - assert (ndarrayInstance[0,:] == tenmatInstance[0,:]).all() - assert (ndarrayInstance[:,1] == tenmatInstance[:,1]).all() + assert (ndarrayInstance[0, :] == tenmatInstance[0, :]).all() + assert (ndarrayInstance[:, 1] == tenmatInstance[:, 1]).all() # submatrix -> numpy.ndarray - assert (ndarrayInstance[[0,2],[1,3]] == tenmatInstance[[0,2],[1,3]]).all() + assert (ndarrayInstance[[0, 2], [1, 3]] == tenmatInstance[[0, 2], [1, 3]]).all() # end -> scalar - assert (ndarrayInstance[-1,-1] == tenmatInstance[-1,-1]).all() + assert (ndarrayInstance[-1, -1] == tenmatInstance[-1, -1]).all() + @pytest.mark.indevelopment def test_tenmat__mul__(sample_ndarray_1way, sample_ndarray_4way, sample_tenmat_4way): (params, tenmatInstance) = sample_tenmat_4way - tshape = params['tshape'] - rdims = params['rdims'] - cdims = params['cdims'] - data = params['data'] + tshape = params["tshape"] + rdims = params["rdims"] + cdims = params["cdims"] + data = params["data"] (_, ndarrayInstance1) = sample_ndarray_1way (_, ndarrayInstance4) = sample_ndarray_4way # scalar * Tenmat -> Tenmat - assert ((tenmatInstance * 5).data == (params['data'] * 5)).all() - assert ((5 * tenmatInstance).data == (5 * params['data'])).all() - assert ((tenmatInstance * 2.1).data == (params['data'] * 2.1)).all() - assert ((2.2 * tenmatInstance).data == (2.2 * params['data'])).all() - assert ((tenmatInstance * np.int64(3)).data == (params['data'] * np.int64(3))).all() - assert ((np.int64(3) * tenmatInstance).data == (np.int64(3) * params['data'])).all() + assert ((tenmatInstance * 5).data == (params["data"] * 5)).all() + assert ((5 * tenmatInstance).data == (5 * params["data"])).all() + assert ((tenmatInstance * 2.1).data == (params["data"] * 2.1)).all() + assert ((2.2 * tenmatInstance).data == (2.2 * params["data"])).all() + assert ((tenmatInstance * np.int64(3)).data == (params["data"] * np.int64(3))).all() + assert ((np.int64(3) * tenmatInstance).data == (np.int64(3) * params["data"])).all() # Tenmat * Tenmat -> 2x2 result - tenmat1 = ttb.tenmat.from_data(ndarrayInstance4, rdims=np.array([0]), cdims=np.array([1,2,3])) - tenmat2 = ttb.tenmat.from_data(ndarrayInstance4, rdims=np.array([0,1,2]), cdims=np.array([3])) + tenmat1 = ttb.tenmat.from_data( + ndarrayInstance4, rdims=np.array([0]), cdims=np.array([1, 2, 3]) + ) + tenmat2 = ttb.tenmat.from_data( + ndarrayInstance4, rdims=np.array([0, 1, 2]), cdims=np.array([3]) + ) tenmatProd = tenmat1 * tenmat2 data = np.array([[372, 884], [408, 984]]) assert (tenmatProd.data == data).all() @@ -418,7 +493,7 @@ def test_tenmat__mul__(sample_ndarray_1way, sample_ndarray_4way, sample_tenmat_4 assert tenmatProd.shape == (2, 2) # 1D column Tenmat * 1D row Tenmat -> scalar result - tensor1 = ttb.tensor.from_data(ndarrayInstance1,shape=(16,)) + tensor1 = ttb.tensor.from_data(ndarrayInstance1, shape=(16,)) tenmat1 = ttb.tenmat.from_tensor_type(tensor1, cdims=np.array([0])) tenmat2 = ttb.tenmat.from_tensor_type(tensor1, rdims=np.array([0])) tenmatProd = tenmat1 * tenmat2 @@ -426,47 +501,54 @@ def test_tenmat__mul__(sample_ndarray_1way, sample_ndarray_4way, sample_tenmat_4 assert tenmatProd == 1496 # Exceptions - + # shape mismatch - exc = 'tenmat shape mismatch: number or columns of left operand must match number of rows of right operand.' - tenmat1 = ttb.tenmat.from_data(ndarrayInstance4, rdims=np.array([0,1]), cdims=np.array([2,3])) - tenmat2 = ttb.tenmat.from_data(ndarrayInstance4, rdims=np.array([0,1,2]), cdims=np.array([3])) + exc = "tenmat shape mismatch: number or columns of left operand must match number of rows of right operand." + tenmat1 = ttb.tenmat.from_data( + ndarrayInstance4, rdims=np.array([0, 1]), cdims=np.array([2, 3]) + ) + tenmat2 = ttb.tenmat.from_data( + ndarrayInstance4, rdims=np.array([0, 1, 2]), cdims=np.array([3]) + ) with pytest.raises(AssertionError) as excinfo: a = tenmat1 * tenmat2 assert exc in str(excinfo) # type mismatch - exc = 'tenmat multiplication only valid with scalar or tenmat objects.' + exc = "tenmat multiplication only valid with scalar or tenmat objects." tenmatNdarray4 = ttb.tenmat.from_data(ndarrayInstance4, rdims, cdims, tshape) with pytest.raises(AssertionError) as excinfo: a = tenmatInstance * tenmatNdarray4.data assert exc in str(excinfo) + @pytest.mark.indevelopment def test_tenmat__add__(sample_ndarray_2way, sample_tenmat_4way): (params, tenmatInstance) = sample_tenmat_4way - tshape = params['tshape'] - rdims = params['rdims'] - cdims = params['cdims'] - data = params['data'] + tshape = params["tshape"] + rdims = params["rdims"] + cdims = params["cdims"] + data = params["data"] (_, ndarrayInstance2) = sample_ndarray_2way # Tenmat + scalar - assert ((tenmatInstance + 1).data == (params['data'] + 1)).all() - assert ((1 + tenmatInstance).data == (1 + params['data'])).all() - assert ((tenmatInstance + 2.1).data == (params['data'] + 2.1)).all() - assert ((2.2 + tenmatInstance).data == (2.2 + params['data'])).all() - assert ((tenmatInstance + np.int64(3)).data == (params['data'] + np.int64(3))).all() - assert ((np.int64(3) + tenmatInstance).data == (np.int64(3) + params['data'])).all() + assert ((tenmatInstance + 1).data == (params["data"] + 1)).all() + assert ((1 + tenmatInstance).data == (1 + params["data"])).all() + assert ((tenmatInstance + 2.1).data == (params["data"] + 2.1)).all() + assert ((2.2 + tenmatInstance).data == (2.2 + params["data"])).all() + assert ((tenmatInstance + np.int64(3)).data == (params["data"] + np.int64(3))).all() + assert ((np.int64(3) + tenmatInstance).data == (np.int64(3) + params["data"])).all() # Tenmat + Tenmat - assert ((tenmatInstance + tenmatInstance).data == (params['data'] + params['data'])).all() + assert ( + (tenmatInstance + tenmatInstance).data == (params["data"] + params["data"]) + ).all() # Exceptions # shape mismatch - exc = 'tenmat shape mismatch.' - tenmatNdarray2 = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, (1,1,1,16)) + exc = "tenmat shape mismatch." + tenmatNdarray2 = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, (1, 1, 1, 16)) with pytest.raises(AssertionError) as excinfo: a = tenmatInstance + tenmatNdarray2 assert exc in str(excinfo) @@ -475,164 +557,176 @@ def test_tenmat__add__(sample_ndarray_2way, sample_tenmat_4way): assert exc in str(excinfo) # type mismatch - exc = 'tenmat addition only valid with scalar or tenmat objects.' + exc = "tenmat addition only valid with scalar or tenmat objects." tenmatNdarray2 = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, tshape) with pytest.raises(AssertionError) as excinfo: a = tenmatInstance + tenmatNdarray2.data assert exc in str(excinfo) + @pytest.mark.indevelopment def test_tenmat__sub__(sample_ndarray_2way, sample_tenmat_4way): (params, tenmatInstance) = sample_tenmat_4way - tshape = params['tshape'] - rdims = params['rdims'] - cdims = params['cdims'] - data = params['data'] + tshape = params["tshape"] + rdims = params["rdims"] + cdims = params["cdims"] + data = params["data"] (_, ndarrayInstance2) = sample_ndarray_2way # Tenmat + scalar - assert ((tenmatInstance - 1).data == (params['data'] - 1)).all() - assert ((1 - tenmatInstance).data == (1 - params['data'])).all() - assert ((tenmatInstance - 2.1).data == (params['data'] - 2.1)).all() - assert ((2.2 - tenmatInstance).data == (2.2 - params['data'])).all() - assert ((tenmatInstance - np.int64(3)).data == (params['data'] - np.int64(3))).all() - assert ((np.int64(3) - tenmatInstance).data == (np.int64(3) - params['data'])).all() + assert ((tenmatInstance - 1).data == (params["data"] - 1)).all() + assert ((1 - tenmatInstance).data == (1 - params["data"])).all() + assert ((tenmatInstance - 2.1).data == (params["data"] - 2.1)).all() + assert ((2.2 - tenmatInstance).data == (2.2 - params["data"])).all() + assert ((tenmatInstance - np.int64(3)).data == (params["data"] - np.int64(3))).all() + assert ((np.int64(3) - tenmatInstance).data == (np.int64(3) - params["data"])).all() # Tenmat + Tenmat - assert ((tenmatInstance - tenmatInstance).data == (params['data'] - params['data'])).all() + assert ( + (tenmatInstance - tenmatInstance).data == (params["data"] - params["data"]) + ).all() # Exceptions # shape mismatch - exc = 'tenmat shape mismatch.' - tenmatNdarray2 = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, (1,1,1,16)) + exc = "tenmat shape mismatch." + tenmatNdarray2 = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, (1, 1, 1, 16)) with pytest.raises(AssertionError) as excinfo: a = tenmatInstance - tenmatNdarray2 assert exc in str(excinfo) # type mismatch - exc = 'tenmat subtraction only valid with scalar or tenmat objects.' + exc = "tenmat subtraction only valid with scalar or tenmat objects." tenmatNdarray2 = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, tshape) with pytest.raises(AssertionError) as excinfo: a = tenmatInstance - tenmatNdarray2.data assert exc in str(excinfo) + @pytest.mark.indevelopment def test_tenmat__rsub__(sample_ndarray_2way, sample_tenmat_4way): (params, tenmatInstance) = sample_tenmat_4way - tshape = params['tshape'] - rdims = params['rdims'] - cdims = params['cdims'] - data = params['data'] + tshape = params["tshape"] + rdims = params["rdims"] + cdims = params["cdims"] + data = params["data"] (_, ndarrayInstance2) = sample_ndarray_2way # Tenmat + scalar - assert ((1 - tenmatInstance).data == (1 - params['data'])).all() - assert ((2.2 - tenmatInstance).data == (2.2 - params['data'])).all() - assert ((np.int64(3) - tenmatInstance).data == (np.int64(3) - params['data'])).all() + assert ((1 - tenmatInstance).data == (1 - params["data"])).all() + assert ((2.2 - tenmatInstance).data == (2.2 - params["data"])).all() + assert ((np.int64(3) - tenmatInstance).data == (np.int64(3) - params["data"])).all() # Tenmat + Tenmat - assert ((tenmatInstance.__rsub__(tenmatInstance)).data == (params['data'] - params['data'])).all() + assert ( + (tenmatInstance.__rsub__(tenmatInstance)).data + == (params["data"] - params["data"]) + ).all() # Exceptions # shape mismatch - exc = 'tenmat shape mismatch.' - tenmatNdarray2 = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, (1,1,1,16)) + exc = "tenmat shape mismatch." + tenmatNdarray2 = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, (1, 1, 1, 16)) with pytest.raises(AssertionError) as excinfo: a = tenmatInstance.__rsub__(tenmatNdarray2) assert exc in str(excinfo) # type mismatch - exc = 'tenmat subtraction only valid with scalar or tenmat objects.' + exc = "tenmat subtraction only valid with scalar or tenmat objects." tenmatNdarray2 = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, tshape) with pytest.raises(AssertionError) as excinfo: a = tenmatInstance.__rsub__(tenmatNdarray2.data) assert exc in str(excinfo) + @pytest.mark.indevelopment def test_tenmat__pos__(sample_tenmat_4way): (params, tenmatInstance) = sample_tenmat_4way - data = params['data'] + data = params["data"] # +Tenmat yields no change - assert ((+tenmatInstance).data == params['data']).all() + assert ((+tenmatInstance).data == params["data"]).all() + @pytest.mark.indevelopment def test_tenmat__neg__(sample_tenmat_4way): (params, tenmatInstance) = sample_tenmat_4way - data = params['data'] + data = params["data"] # +Tenmat yields no change - assert ((-tenmatInstance).data == -params['data']).all() + assert ((-tenmatInstance).data == -params["data"]).all() + @pytest.mark.indevelopment -def test_tenmat__str__(sample_ndarray_1way, sample_ndarray_2way, sample_ndarray_4way, sample_tenmat_4way): +def test_tenmat__str__( + sample_ndarray_1way, sample_ndarray_2way, sample_ndarray_4way, sample_tenmat_4way +): (params, tenmatInstance) = sample_tenmat_4way - tshape = params['tshape'] - rdims = params['rdims'] - cdims = params['cdims'] - data = params['data'] + tshape = params["tshape"] + rdims = params["rdims"] + cdims = params["cdims"] + data = params["data"] (_, ndarrayInstance1) = sample_ndarray_1way (_, ndarrayInstance2) = sample_ndarray_2way (_, ndarrayInstance4) = sample_ndarray_4way # Empty tenmatInstance = ttb.tenmat() - s = '' - s += 'matrix corresponding to a tensor of shape ()\n' - s += 'rindices = [ ] (modes of tensor corresponding to rows)\n' - s += 'cindices = [ ] (modes of tensor corresponding to columns)\n' - s += 'data = []\n' + s = "" + s += "matrix corresponding to a tensor of shape ()\n" + s += "rindices = [ ] (modes of tensor corresponding to rows)\n" + s += "cindices = [ ] (modes of tensor corresponding to columns)\n" + s += "data = []\n" assert s == tenmatInstance.__str__() # Test 1D tenmatInstance = ttb.tenmat.from_data(ndarrayInstance1, rdims, cdims, tshape) - s = '' - s += 'matrix corresponding to a tensor of shape ' - s += (' x ').join([str(int(d)) for d in tenmatInstance.tshape]) - s += '\n' - s += 'rindices = ' - s += '[ ' + (', ').join([str(int(d)) for d in tenmatInstance.rindices]) + ' ] ' - s += '(modes of tensor corresponding to rows)\n' - s += 'cindices = ' - s += '[ ' + (', ').join([str(int(d)) for d in tenmatInstance.cindices]) + ' ] ' - s += '(modes of tensor corresponding to columns)\n' - s += 'data[:, :] = \n' + s = "" + s += "matrix corresponding to a tensor of shape " + s += (" x ").join([str(int(d)) for d in tenmatInstance.tshape]) + s += "\n" + s += "rindices = " + s += "[ " + (", ").join([str(int(d)) for d in tenmatInstance.rindices]) + " ] " + s += "(modes of tensor corresponding to rows)\n" + s += "cindices = " + s += "[ " + (", ").join([str(int(d)) for d in tenmatInstance.cindices]) + " ] " + s += "(modes of tensor corresponding to columns)\n" + s += "data[:, :] = \n" s += str(tenmatInstance.data) - s += '\n' + s += "\n" assert s == tenmatInstance.__str__() ## Test 2D tenmatInstance = ttb.tenmat.from_data(ndarrayInstance2, rdims, cdims, tshape) - s = '' - s += 'matrix corresponding to a tensor of shape ' - s += (' x ').join([str(int(d)) for d in tenmatInstance.tshape]) - s += '\n' - s += 'rindices = ' - s += '[ ' + (', ').join([str(int(d)) for d in tenmatInstance.rindices]) + ' ] ' - s += '(modes of tensor corresponding to rows)\n' - s += 'cindices = ' - s += '[ ' + (', ').join([str(int(d)) for d in tenmatInstance.cindices]) + ' ] ' - s += '(modes of tensor corresponding to columns)\n' - s += 'data[:, :] = \n' + s = "" + s += "matrix corresponding to a tensor of shape " + s += (" x ").join([str(int(d)) for d in tenmatInstance.tshape]) + s += "\n" + s += "rindices = " + s += "[ " + (", ").join([str(int(d)) for d in tenmatInstance.rindices]) + " ] " + s += "(modes of tensor corresponding to rows)\n" + s += "cindices = " + s += "[ " + (", ").join([str(int(d)) for d in tenmatInstance.cindices]) + " ] " + s += "(modes of tensor corresponding to columns)\n" + s += "data[:, :] = \n" s += str(tenmatInstance.data) - s += '\n' + s += "\n" assert s == tenmatInstance.__str__() # Test 4D tenmatInstance = ttb.tenmat.from_data(ndarrayInstance4, rdims, cdims, tshape) - s = '' - s += 'matrix corresponding to a tensor of shape ' - s += (' x ').join([str(int(d)) for d in tenmatInstance.tshape]) - s += '\n' - s += 'rindices = ' - s += '[ ' + (', ').join([str(int(d)) for d in tenmatInstance.rindices]) + ' ] ' - s += '(modes of tensor corresponding to rows)\n' - s += 'cindices = ' - s += '[ ' + (', ').join([str(int(d)) for d in tenmatInstance.cindices]) + ' ] ' - s += '(modes of tensor corresponding to columns)\n' - s += 'data[:, :] = \n' + s = "" + s += "matrix corresponding to a tensor of shape " + s += (" x ").join([str(int(d)) for d in tenmatInstance.tshape]) + s += "\n" + s += "rindices = " + s += "[ " + (", ").join([str(int(d)) for d in tenmatInstance.rindices]) + " ] " + s += "(modes of tensor corresponding to rows)\n" + s += "cindices = " + s += "[ " + (", ").join([str(int(d)) for d in tenmatInstance.cindices]) + " ] " + s += "(modes of tensor corresponding to columns)\n" + s += "data[:, :] = \n" s += str(tenmatInstance.data) - s += '\n' + s += "\n" assert s == tenmatInstance.__str__() diff --git a/tests/test_tensor.py b/tests/test_tensor.py index f3890160..d27463c1 100644 --- a/tests/test_tensor.py +++ b/tests/test_tensor.py @@ -2,36 +2,41 @@ # LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the # U.S. Government retains certain rights in this software. -import pyttb as ttb import numpy as np import pytest -DEBUG_tests = False +import pyttb as ttb + +DEBUG_tests = True + @pytest.fixture() def sample_tensor_2way(): - data = np.array([[1., 2., 3.], [4., 5., 6.]]) + data = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) shape = (2, 3) - params = {'data':data, 'shape': shape} + params = {"data": data, "shape": shape} tensorInstance = ttb.tensor().from_data(data, shape) return params, tensorInstance + @pytest.fixture() def sample_tensor_3way(): - data = np.array([1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12.]) + data = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0]) shape = (2, 3, 2) - params = {'data':np.reshape(data, np.array(shape), order='F'), 'shape': shape} + params = {"data": np.reshape(data, np.array(shape), order="F"), "shape": shape} tensorInstance = ttb.tensor().from_data(data, shape) return params, tensorInstance + @pytest.fixture() def sample_tensor_4way(): data = np.arange(1, 82) shape = (3, 3, 3, 3) - params = {'data':np.reshape(data, np.array(shape), order='F'), 'shape': shape} + params = {"data": np.reshape(data, np.array(shape), order="F"), "shape": shape} tensorInstance = ttb.tensor().from_data(data, shape) return params, tensorInstance + @pytest.mark.indevelopment def test_tensor_initialization_empty(): empty = np.array([]) @@ -39,28 +44,31 @@ def test_tensor_initialization_empty(): # No args tensorInstance = ttb.tensor() assert (tensorInstance.data == empty).all() - assert (tensorInstance.shape == ()) + assert tensorInstance.shape == () + @pytest.mark.indevelopment def test_tensor_initialization_from_data(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way - assert (tensorInstance.data == params['data']).all() - assert (tensorInstance.shape == params['shape']) + assert (tensorInstance.data == params["data"]).all() + assert tensorInstance.shape == params["shape"] with pytest.raises(AssertionError) as excinfo: - a = ttb.tensor.from_data(params['data'], ()) + a = ttb.tensor.from_data(params["data"], ()) assert "Empty tensor cannot contain any elements" in str(excinfo) with pytest.raises(AssertionError) as excinfo: - a = ttb.tensor.from_data(params['data'], (2, 4)) - assert "TTB:WrongSize, Size of data does not match specified size of tensor" in str(excinfo) + a = ttb.tensor.from_data(params["data"], (2, 4)) + assert "TTB:WrongSize, Size of data does not match specified size of tensor" in str( + excinfo + ) with pytest.raises(AssertionError) as excinfo: - a = ttb.tensor.from_data(params['data'], np.array([2, 3])) + a = ttb.tensor.from_data(params["data"], np.array([2, 3])) assert "Second argument must be a tuple." in str(excinfo) # TODO how else to break this logical statement? - data = np.array([['a', 2, 3], [4, 5, 6]]) + data = np.array([["a", 2, 3], [4, 5, 6]]) with pytest.raises(AssertionError) as excinfo: a = ttb.tensor.from_data(data, (2, 3)) assert "First argument must be a multidimensional array." in str(excinfo) @@ -69,41 +77,40 @@ def test_tensor_initialization_from_data(sample_tensor_2way): # no shape spaecified tensorInstance1 = ttb.tensor.from_data(np.array([1, 2, 3])) if DEBUG_tests: - print('\ntensorInstance1:') + print("\ntensorInstance1:") print(tensorInstance1) data = np.array([1, 2, 3]) assert tensorInstance1.data.shape == data.shape assert (tensorInstance1.data == data).all() # shape is 1 x 3 - tensorInstance1 = ttb.tensor.from_data(np.array([1, 2, 3]), (1,3)) + tensorInstance1 = ttb.tensor.from_data(np.array([1, 2, 3]), (1, 3)) if DEBUG_tests: - print('\ntensorInstance1:') + print("\ntensorInstance1:") print(tensorInstance1) data = np.array([[1, 2, 3]]) assert tensorInstance1.data.shape == data.shape assert (tensorInstance1.data == data).all() # shape is 3 x 1 - tensorInstance1 = ttb.tensor.from_data(np.array([1, 2, 3]), (3,1)) + tensorInstance1 = ttb.tensor.from_data(np.array([1, 2, 3]), (3, 1)) if DEBUG_tests: - print('\ntensorInstance1:') + print("\ntensorInstance1:") print(tensorInstance1) - data = np.array([[1], - [2], - [3]]) + data = np.array([[1], [2], [3]]) assert tensorInstance1.data.shape == data.shape assert (tensorInstance1.data == data).all() @pytest.mark.indevelopment -def test_tensor_initialization_from_tensor_type(sample_tensor_2way): +def test_tensor_initialization_from_tensor_type(sample_tensor_2way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_2way + (_, tensorInstance4) = sample_tensor_4way # Copy Constructor tensorCopy = ttb.tensor.from_tensor_type(tensorInstance) - assert (tensorCopy.data == params['data']).all() - assert (tensorCopy.shape == params['shape']) + assert (tensorCopy.data == params["data"]).all() + assert tensorCopy.shape == params["shape"] subs = np.array([[0, 0], [0, 1], [0, 2], [1, 0]]) vals = np.array([[1], [2], [3], [4]]) @@ -114,79 +121,109 @@ def test_tensor_initialization_from_tensor_type(sample_tensor_2way): # Sptensor b = ttb.tensor.from_tensor_type(a) assert (b.data == data).all() - assert (b.shape == shape) + assert b.shape == shape + + # tenmat + tenmatInstance = ttb.tenmat.from_tensor_type(tensorInstance, np.array([0])) + tensorTenmatInstance = ttb.tensor.from_tensor_type(tenmatInstance) + assert tensorInstance.isequal(tensorTenmatInstance) + + # 1D 1-element tenmat + tensorInstance1 = ttb.tensor.from_data(np.array([3])) + tenmatInstance1 = ttb.tenmat.from_tensor_type(tensorInstance1, np.array([0])) + tensorTenmatInstance1 = ttb.tensor.from_tensor_type(tenmatInstance1) + assert tensorInstance1.isequal(tensorTenmatInstance1) + + # 4D tenmat + tenmatInstance4 = ttb.tenmat.from_tensor_type(tensorInstance4, np.array([3, 0])) + tensorTenmatInstance4 = ttb.tensor.from_tensor_type(tenmatInstance4) + assert tensorInstance4.isequal(tensorTenmatInstance4) + + # Non-tensor type + with pytest.raises(ValueError): + ttb.tensor.from_tensor_type(1) + @pytest.mark.indevelopment def test_tensor_initialization_from_function(): def function_handle(x): return np.array([[1, 2, 3], [4, 5, 6]]) + shape = (2, 3) data = np.array([[1, 2, 3], [4, 5, 6]]) a = ttb.tensor.from_function(function_handle, shape) assert (a.data == data).all() - assert (a.shape == shape) + assert a.shape == shape with pytest.raises(AssertionError) as excinfo: ttb.tensor.from_function(function_handle, [2, 3]) - assert 'TTB:BadInput, Shape must be a tuple' in str(excinfo) + assert "TTB:BadInput, Shape must be a tuple" in str(excinfo) + @pytest.mark.indevelopment def test_tensor_find(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_2way subs, vals = tensorInstance.find() - if DEBUG_tests: - print('\nsubs:') + if DEBUG_tests: + print("\nsubs:") print(subs) - print('\nvals:') + print("\nvals:") print(vals) - a = ttb.tensor.from_tensor_type(ttb.sptensor.from_data(subs, vals, tensorInstance.shape)) + a = ttb.tensor.from_tensor_type( + ttb.sptensor.from_data(subs, vals, tensorInstance.shape) + ) assert (a.data == tensorInstance.data).all() - assert (a.shape == tensorInstance.shape) + assert a.shape == tensorInstance.shape (params, tensorInstance) = sample_tensor_3way subs, vals = tensorInstance.find() - if DEBUG_tests: - print('\nsubs:') + if DEBUG_tests: + print("\nsubs:") print(subs) - print('\nvals:') + print("\nvals:") print(vals) - a = ttb.tensor.from_tensor_type(ttb.sptensor.from_data(subs, vals, tensorInstance.shape)) + a = ttb.tensor.from_tensor_type( + ttb.sptensor.from_data(subs, vals, tensorInstance.shape) + ) assert (a.data == tensorInstance.data).all() - assert (a.shape == tensorInstance.shape) + assert a.shape == tensorInstance.shape (params, tensorInstance) = sample_tensor_4way subs, vals = tensorInstance.find() - if DEBUG_tests: - print('\nsubs:') + if DEBUG_tests: + print("\nsubs:") print(subs) - print('\nvals:') + print("\nvals:") print(vals) - a = ttb.tensor.from_tensor_type(ttb.sptensor.from_data(subs, vals, tensorInstance.shape)) + a = ttb.tensor.from_tensor_type( + ttb.sptensor.from_data(subs, vals, tensorInstance.shape) + ) assert (a.data == tensorInstance.data).all() - assert (a.shape == tensorInstance.shape) + assert a.shape == tensorInstance.shape @pytest.mark.indevelopment def test_tensor_ndims(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_2way - assert tensorInstance.ndims == len(params['shape']) + assert tensorInstance.ndims == len(params["shape"]) (params, tensorInstance) = sample_tensor_3way - assert tensorInstance.ndims == len(params['shape']) + assert tensorInstance.ndims == len(params["shape"]) (params, tensorInstance) = sample_tensor_4way - assert tensorInstance.ndims == len(params['shape']) + assert tensorInstance.ndims == len(params["shape"]) # Empty tensor has zero dimensions assert ttb.tensor.from_data(np.array([])) == 0 + @pytest.mark.indevelopment -def test_tensor_setitem(sample_tensor_2way): +def test_tensor__setitem__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Subtensor assign with constant - dataCopy = params['data'].copy() + dataCopy = params["data"].copy() dataCopy[1, 1] = 0.0 tensorInstance[1, 1] = 0.0 assert (tensorInstance.data == dataCopy).all() @@ -198,7 +235,7 @@ def test_tensor_setitem(sample_tensor_2way): dataGrowth[0:2, 0:2] = 99.0 assert (tensorInstance.data == dataGrowth).all() - #Subtensor assign with np array + # Subtensor assign with np array tensorInstance[0:2, 0:3] = dataCopy dataGrowth[0:2, 0:3] = dataCopy assert (tensorInstance.data == dataGrowth).all() @@ -207,6 +244,19 @@ def test_tensor_setitem(sample_tensor_2way): tensorInstance[:, :] = tensorInstance assert (tensorInstance.data == dataGrowth).all() + # Subtensor add element to empty tensor + empty_tensor = ttb.tensor() + empty_tensor[0, 0] = 1 + + # Subtensor add dimension + empty_tensor[0, 0, 0] = 2 + + # Subtensor with lists + some_tensor = ttb.tenones((3, 3)) + some_tensor[[0, 1], [0, 1]] = 11 + assert some_tensor[0, 0] == 11 + assert some_tensor[1, 1] == 11 + assert np.all(some_tensor[[0, 1], [0, 1]].data == 11) # Subscripts with constant tensorInstance[np.array([[1, 1]])] = 13.0 @@ -218,24 +268,45 @@ def test_tensor_setitem(sample_tensor_2way): tensorVector[np.array([0, 1, 2])] = np.array([3, 4, 5]) assert (tensorVector.data == np.array([3, 4, 5, 0])).all() - # Subscripts with constant tensorInstance[np.array([[1, 1], [1, 2]])] = 13.0 dataGrowth[([1, 1], [1, 2])] = 13.0 assert (tensorInstance.data == dataGrowth).all() + # Subscripts add element to empty tensor + empty_tensor = ttb.tensor() + first_arbitrary_index = np.array([[0, 1], [2, 2]]) + second_arbitrary_index = np.array([[1, 2], [3, 3]]) + value = 4 + empty_tensor[first_arbitrary_index] = value + # Subscripts grow existing tensor + empty_tensor[second_arbitrary_index] = value + # Linear Index with constant tensorInstance[np.array([0])] = 13.0 - dataGrowth[0, 0] = 13.0 + dataGrowth[np.unravel_index([0], dataGrowth.shape, "F")] = 13.0 + assert (tensorInstance.data == dataGrowth).all() + + tensorInstance[0] = 14.0 + dataGrowth[np.unravel_index([0], dataGrowth.shape, "F")] = 14.0 + assert (tensorInstance.data == dataGrowth).all() + + tensorInstance[0:1] = 14.0 + dataGrowth[np.unravel_index([0], dataGrowth.shape, "F")] = 14.0 assert (tensorInstance.data == dataGrowth).all() # Linear Index with constant tensorInstance[np.array([0, 3, 4])] = 13.0 - dataGrowth[0, 0] = 13.0 - dataGrowth[0, 3] = 13.0 - dataGrowth[0, 4] = 13.0 + dataGrowth[np.unravel_index([0, 3, 4], dataGrowth.shape, "F")] = 13 assert (tensorInstance.data == dataGrowth).all() + # Linear index with multiple indicies + some_tensor = ttb.tenones((3, 3)) + some_tensor[[0, 1]] = 2 + assert some_tensor[0] == 2 + assert some_tensor[1] == 2 + assert np.array_equal(some_tensor[[0, 1]], [2, 2]) + # Test Empty Tensor Set Item, subtensor emptyTensor = ttb.tensor.from_data(np.array([])) emptyTensor[0, 0, 0] = 0 @@ -251,48 +322,81 @@ def test_tensor_setitem(sample_tensor_2way): # Linear Index with constant, index out of bounds with pytest.raises(AssertionError) as excinfo: tensorInstance[np.array([0, 3, 99])] = 13.0 - assert 'TTB:BadIndex In assignment X[I] = Y, a tensor X cannot be resized' in str(excinfo) + assert "TTB:BadIndex In assignment X[I] = Y, a tensor X cannot be resized" in str( + excinfo + ) # Attempting to set some other way + with pytest.raises(ValueError) as excinfo: + tensorInstance[0, "a", 5] = 13.0 + assert "must be numeric" in str(excinfo) + with pytest.raises(AssertionError) as excinfo: - tensorInstance[0, 'a', 5] = 13.0 - assert 'Invalid use of tensor setitem' in str(excinfo) + + class BadKey: + pass + + tensorInstance[BadKey] = 13.0 + assert "Invalid use of tensor setitem" in str(excinfo) + @pytest.mark.indevelopment -def test_tensor_getitem(sample_tensor_2way): +def test_tensor__getitem__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Case 1 single element - assert tensorInstance[0, 0] == params['data'][0, 0] + assert tensorInstance[0, 0] == params["data"][0, 0] # Case 1 Subtensor assert (tensorInstance[:, :] == tensorInstance).data.all() + three_way_data = np.random.random((2, 3, 4)) + two_slices = (slice(None, None, None), 0, slice(None, None, None)) + assert ( + ttb.tensor.from_data(three_way_data)[two_slices].double() + == three_way_data[two_slices] + ).all() # Case 1 Subtensor - assert (tensorInstance[np.array([0, 1]), :].data == tensorInstance.data[[0, 1], :]).all() + assert ( + tensorInstance[np.array([0, 1]), :].data == tensorInstance.data[[0, 1], :] + ).all() # Case 1 Subtensor assert (tensorInstance[0, :].data == tensorInstance.data[0, :]).all() assert (tensorInstance[:, 0].data == tensorInstance.data[:, 0]).all() # Case 2a: - assert tensorInstance[np.array([0, 0]), 'extract'] == params['data'][0, 0] - assert (tensorInstance[np.array([[0, 0], [1, 1]]), 'extract'] == params['data'][([0, 1], [0, 1])]).all() + assert tensorInstance[np.array([0, 0]), "extract"] == params["data"][0, 0] + assert ( + tensorInstance[np.array([[0, 0], [1, 1]]), "extract"] + == params["data"][([0, 0], [1, 1])] + ).all() + # Case 2a: Extract doesn't seem to be needed + assert tensorInstance[np.array([0, 0])] == params["data"][0, 0] + assert ( + tensorInstance[np.array([[0, 0], [1, 1]])] == params["data"][([0, 0], [1, 1])] + ).all() # Case 2b: Linear Indexing - assert tensorInstance[np.array([0])] == params['data'][0, 0] + assert tensorInstance[np.array([0])] == params["data"][0, 0] + assert tensorInstance[0] == params["data"][0, 0] + assert np.array_equal(tensorInstance[0:1], params["data"][0, 0]) with pytest.raises(AssertionError) as excinfo: tensorInstance[np.array([0]), np.array([0]), np.array([0])] assert "Linear indexing requires single input array" in str(excinfo) + @pytest.mark.indevelopment def test_tensor_logical_and(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Tensor And - assert (tensorInstance.logical_and(tensorInstance).data == np.ones((params['shape']))).all() + assert ( + tensorInstance.logical_and(tensorInstance).data == np.ones((params["shape"])) + ).all() # Non-zero And - assert (tensorInstance.logical_and(1).data == np.ones((params['shape']))).all() + assert (tensorInstance.logical_and(1).data == np.ones((params["shape"]))).all() # Zero And - assert (tensorInstance.logical_and(0).data == np.zeros((params['shape']))).all() + assert (tensorInstance.logical_and(0).data == np.zeros((params["shape"]))).all() + @pytest.mark.indevelopment def test_tensor__eq__(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): @@ -301,11 +405,11 @@ def test_tensor__eq__(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way # Tensor tensor equality assert ((tensorInstance == tensorInstance).data).all() - #Tensor scalar equality, not equal + # Tensor scalar equality, not equal assert not ((tensorInstance == 7).data).any() - #Tensor scalar equality, is equal - data = np.zeros(params['data'].shape) + # Tensor scalar equality, is equal + data = np.zeros(params["data"].shape) data[0, 0] = 1 assert ((tensorInstance == 1).data == data).all() @@ -320,7 +424,6 @@ def test_tensor__eq__(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way assert ((tensorInstance4 == tensorInstance4).data).all() - @pytest.mark.indevelopment def test_tensor__ne__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way @@ -328,52 +431,54 @@ def test_tensor__ne__(sample_tensor_2way): # Tensor tensor equality assert not ((tensorInstance != tensorInstance).data).any() - #Tensor scalar equality, not equal + # Tensor scalar equality, not equal assert ((tensorInstance != 7).data).all() - #Tensor scalar equality, is equal - data = np.zeros(params['data'].shape) + # Tensor scalar equality, is equal + data = np.zeros(params["data"].shape) data[0, 0] = 1 assert not ((tensorInstance != 1).data == data).any() + @pytest.mark.indevelopment def test_tensor_full(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params2, tensorInstance2) = sample_tensor_2way - if DEBUG_tests: + if DEBUG_tests: print("\nparam2['data']:") - print(params2['data']) - print('\ntensorInstace2.data:') + print(params2["data"]) + print("\ntensorInstace2.data:") print(tensorInstance2.data) - print('\ntensorInstace2.full():') + print("\ntensorInstace2.full():") print(tensorInstance2.full()) - assert (tensorInstance2.full().data == params2['data']).all() + assert (tensorInstance2.full().data == params2["data"]).all() (params3, tensorInstance3) = sample_tensor_3way - if DEBUG_tests: + if DEBUG_tests: print("\nparam3['data']:") - print(params3['data']) - print('\ntensorInstace3.data:') + print(params3["data"]) + print("\ntensorInstace3.data:") print(tensorInstance3.data) - print('\ntensorInstace3.full():') + print("\ntensorInstace3.full():") print(tensorInstance3.full()) - assert (tensorInstance3.full().data == params3['data']).all() + assert (tensorInstance3.full().data == params3["data"]).all() (params4, tensorInstance4) = sample_tensor_4way - if DEBUG_tests: + if DEBUG_tests: print("\nparam4['data']:") - print(params4['data']) - print('\ntensorInstace4.data:') + print(params4["data"]) + print("\ntensorInstace4.data:") print(tensorInstance4.data) - print('\ntensorInstace4.full():') + print("\ntensorInstace4.full():") print(tensorInstance4.full()) - assert (tensorInstance4.full().data == params4['data']).all() + assert (tensorInstance4.full().data == params4["data"]).all() + @pytest.mark.indevelopment -def test_tensor_ge(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): +def test_tensor__ge__(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_2way - tensorLarger = ttb.tensor.from_data(params['data']+1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert ((tensorInstance >= tensorInstance).data).all() assert ((tensorInstance >= tensorSmaller).data).all() @@ -381,8 +486,8 @@ def test_tensor_ge(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_3way - tensorLarger = ttb.tensor.from_data(params['data']+1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert ((tensorInstance >= tensorInstance).data).all() assert ((tensorInstance >= tensorSmaller).data).all() @@ -390,19 +495,20 @@ def test_tensor_ge(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_4way - tensorLarger = ttb.tensor.from_data(params['data']+1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert ((tensorInstance >= tensorInstance).data).all() assert ((tensorInstance >= tensorSmaller).data).all() assert not ((tensorInstance >= tensorLarger).data).any() + @pytest.mark.indevelopment -def test_tensor_gt(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): +def test_tensor__gt__(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_2way - tensorLarger = ttb.tensor.from_data(params['data']+1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert not ((tensorInstance > tensorInstance).data).any() assert ((tensorInstance > tensorSmaller).data).all() @@ -410,8 +516,8 @@ def test_tensor_gt(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_3way - tensorLarger = ttb.tensor.from_data(params['data']+1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert not ((tensorInstance > tensorInstance).data).any() assert ((tensorInstance > tensorSmaller).data).all() @@ -419,19 +525,20 @@ def test_tensor_gt(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_4way - tensorLarger = ttb.tensor.from_data(params['data']+1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert not ((tensorInstance > tensorInstance).data).any() assert ((tensorInstance > tensorSmaller).data).all() assert not ((tensorInstance > tensorLarger).data).any() + @pytest.mark.indevelopment -def test_tensor_le(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): +def test_tensor__le__(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_2way - tensorLarger = ttb.tensor.from_data(params['data'] + 1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert ((tensorInstance <= tensorInstance).data).all() assert not ((tensorInstance <= tensorSmaller).data).any() @@ -439,8 +546,8 @@ def test_tensor_le(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_3way - tensorLarger = ttb.tensor.from_data(params['data'] + 1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert ((tensorInstance <= tensorInstance).data).all() assert not ((tensorInstance <= tensorSmaller).data).any() @@ -448,19 +555,20 @@ def test_tensor_le(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_4way - tensorLarger = ttb.tensor.from_data(params['data'] + 1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert ((tensorInstance <= tensorInstance).data).all() assert not ((tensorInstance <= tensorSmaller).data).any() assert ((tensorInstance <= tensorLarger).data).all() + @pytest.mark.indevelopment -def test_tensor_lt(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): +def test_tensor__lt__(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_2way - tensorLarger = ttb.tensor.from_data(params['data'] + 1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert not ((tensorInstance < tensorInstance).data).any() assert not ((tensorInstance < tensorSmaller).data).any() @@ -468,8 +576,8 @@ def test_tensor_lt(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_3way - tensorLarger = ttb.tensor.from_data(params['data'] + 1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert not ((tensorInstance < tensorInstance).data).any() assert not ((tensorInstance < tensorSmaller).data).any() @@ -477,146 +585,173 @@ def test_tensor_lt(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_4way - tensorLarger = ttb.tensor.from_data(params['data'] + 1) - tensorSmaller = ttb.tensor.from_data(params['data'] - 1) + tensorLarger = ttb.tensor.from_data(params["data"] + 1) + tensorSmaller = ttb.tensor.from_data(params["data"] - 1) assert not ((tensorInstance < tensorInstance).data).any() assert not ((tensorInstance < tensorSmaller).data).any() assert ((tensorInstance < tensorLarger).data).all() + @pytest.mark.indevelopment def test_tensor_norm(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): # 2-way tensor (params2, tensorInstance2) = sample_tensor_2way - if DEBUG_tests: - print('\ntensorInstace2.norm():') + if DEBUG_tests: + print("\ntensorInstace2.norm():") print(tensorInstance2.norm()) - assert tensorInstance2.norm() == np.linalg.norm(params2['data'].ravel()) + assert tensorInstance2.norm() == np.linalg.norm(params2["data"].ravel()) # 3-way tensor (params3, tensorInstance3) = sample_tensor_3way - if DEBUG_tests: - print('\ntensorInstace3.norm():') + if DEBUG_tests: + print("\ntensorInstace3.norm():") print(tensorInstance3.norm()) - assert tensorInstance3.norm() == np.linalg.norm(params3['data'].ravel()) + assert tensorInstance3.norm() == np.linalg.norm(params3["data"].ravel()) # 4-way tensor (params4, tensorInstance4) = sample_tensor_4way - if DEBUG_tests: - print('\ntensorInstace4.norm():') + if DEBUG_tests: + print("\ntensorInstace4.norm():") print(tensorInstance4.norm()) - assert tensorInstance4.norm() == np.linalg.norm(params4['data'].ravel()) + assert tensorInstance4.norm() == np.linalg.norm(params4["data"].ravel()) + @pytest.mark.indevelopment def test_tensor_logical_not(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way - assert (tensorInstance.logical_not().data == np.logical_not(params['data'])).all() + assert (tensorInstance.logical_not().data == np.logical_not(params["data"])).all() + @pytest.mark.indevelopment def test_tensor_logical_or(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Tensor Or - assert (tensorInstance.logical_or(tensorInstance).data == np.ones((params['shape']))).all() + assert ( + tensorInstance.logical_or(tensorInstance).data == np.ones((params["shape"])) + ).all() # Non-zero Or - assert (tensorInstance.logical_or(1).data == np.ones((params['shape']))).all() + assert (tensorInstance.logical_or(1).data == np.ones((params["shape"]))).all() # Zero Or - assert (tensorInstance.logical_or(0).data == np.ones((params['shape']))).all() + assert (tensorInstance.logical_or(0).data == np.ones((params["shape"]))).all() + @pytest.mark.indevelopment def test_tensor_logical_xor(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Tensor Or - assert (tensorInstance.logical_xor(tensorInstance).data == np.zeros((params['shape']))).all() + assert ( + tensorInstance.logical_xor(tensorInstance).data == np.zeros((params["shape"])) + ).all() # Non-zero Or - assert (tensorInstance.logical_xor(1).data == np.zeros((params['shape']))).all() + assert (tensorInstance.logical_xor(1).data == np.zeros((params["shape"]))).all() # Zero Or - assert (tensorInstance.logical_xor(0).data == np.ones((params['shape']))).all() + assert (tensorInstance.logical_xor(0).data == np.ones((params["shape"]))).all() + @pytest.mark.indevelopment def test_tensor__add__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Tensor + Tensor - assert ((tensorInstance + tensorInstance).data == 2*(params['data'])).all() + assert ((tensorInstance + tensorInstance).data == 2 * (params["data"])).all() # Tensor + scalar - assert ((tensorInstance + 1).data == 1 + (params['data'])).all() + assert ((tensorInstance + 1).data == 1 + (params["data"])).all() + + # scalar + Tensor + assert ((1 + tensorInstance).data == 1 + (params["data"])).all() + @pytest.mark.indevelopment def test_tensor__sub__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Tensor - Tensor - assert ((tensorInstance - tensorInstance).data == 0*(params['data'])).all() + assert ((tensorInstance - tensorInstance).data == 0 * (params["data"])).all() # Tensor - scalar - assert ((tensorInstance - 1).data == (params['data'] - 1)).all() + assert ((tensorInstance - 1).data == (params["data"] - 1)).all() + @pytest.mark.indevelopment def test_tensor__pow__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Tensor** Tensor - assert ((tensorInstance**tensorInstance).data == (params['data']**params['data'])).all() + assert ( + (tensorInstance**tensorInstance).data == (params["data"] ** params["data"]) + ).all() # Tensor**Scalar - assert ((tensorInstance**2).data == (params['data']**2)).all() + assert ((tensorInstance**2).data == (params["data"] ** 2)).all() + @pytest.mark.indevelopment def test_tensor__mul__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Tensor* Tensor - assert ((tensorInstance * tensorInstance).data == (params['data'] * params['data'])).all() + assert ( + (tensorInstance * tensorInstance).data == (params["data"] * params["data"]) + ).all() # Tensor*Scalar - assert ((tensorInstance * 2).data == (params['data'] * 2)).all() + assert ((tensorInstance * 2).data == (params["data"] * 2)).all() # Tensor * Sptensor - assert ((tensorInstance * ttb.sptensor.from_tensor_type(tensorInstance)).data == - (params['data'] * params['data'])).all() + assert ( + (tensorInstance * ttb.sptensor.from_tensor_type(tensorInstance)).data + == (params["data"] * params["data"]) + ).all() # TODO tensor * ktensor + @pytest.mark.indevelopment def test_tensor__rmul__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Scalar * Tensor, only resolves when left object doesn't have appropriate __mul__ - assert ((2 * tensorInstance).data == (params['data'] * 2)).all() + assert ((2 * tensorInstance).data == (params["data"] * 2)).all() + @pytest.mark.indevelopment def test_tensor__pos__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # +Tensor yields no change - assert ((+tensorInstance).data == params['data']).all() + assert ((+tensorInstance).data == params["data"]).all() + @pytest.mark.indevelopment def test_tensor__neg__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # -Tensor yields negated copy of tensor - assert ((-tensorInstance).data == -1*params['data']).all() + assert ((-tensorInstance).data == -1 * params["data"]).all() # Original tensor should remain unchanged - assert ((tensorInstance).data == params['data']).all() + assert ((tensorInstance).data == params["data"]).all() + @pytest.mark.indevelopment def test_tensor_double(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way - assert (tensorInstance.double() == params['data']).all() + assert (tensorInstance.double() == params["data"]).all() + @pytest.mark.indevelopment def test_tensor_end(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way - assert tensorInstance.end() == np.prod(params['shape']) - 1 - assert tensorInstance.end(k=0) == params['shape'][0] - 1 + assert tensorInstance.end() == np.prod(params["shape"]) - 1 + assert tensorInstance.end(k=0) == params["shape"][0] - 1 + @pytest.mark.indevelopment def test_tensor_isequal(sample_tensor_2way): @@ -626,8 +761,10 @@ def test_tensor_isequal(sample_tensor_2way): for j in range(3): for i in range(2): subs.append([i, j]) - vals.append([params['data'][i, j]]) - sptensorInstance = ttb.sptensor.from_data(np.array(subs), np.array(vals), params['shape']) + vals.append([params["data"][i, j]]) + sptensorInstance = ttb.sptensor.from_data( + np.array(subs), np.array(vals), params["shape"] + ) assert tensorInstance.isequal(tensorInstance) assert tensorInstance.isequal(sptensorInstance) @@ -635,25 +772,33 @@ def test_tensor_isequal(sample_tensor_2way): # Tensor is not equal to scalar assert not tensorInstance.isequal(1) + @pytest.mark.indevelopment def test_tensor__truediv__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Tensor / Tensor - assert ((tensorInstance / tensorInstance).data == (params['data'] / params['data'])).all() + assert ( + (tensorInstance / tensorInstance).data == (params["data"] / params["data"]) + ).all() # Tensor / Sptensor - assert ((tensorInstance / ttb.sptensor.from_tensor_type(tensorInstance)).data == (params['data'] / params['data'])).all() + assert ( + (tensorInstance / ttb.sptensor.from_tensor_type(tensorInstance)).data + == (params["data"] / params["data"]) + ).all() # Tensor / Scalar - assert ((tensorInstance / 2).data == (params['data'] / 2)).all() + assert ((tensorInstance / 2).data == (params["data"] / 2)).all() + @pytest.mark.indevelopment def test_tensor__rtruediv__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Scalar / Tensor, only resolves when left object doesn't have appropriate __mul__ - assert ((2 / tensorInstance).data == (2 / params['data'])).all() + assert ((2 / tensorInstance).data == (2 / params["data"])).all() + @pytest.mark.indevelopment def test_tensor_nnz(sample_tensor_2way): @@ -666,6 +811,7 @@ def test_tensor_nnz(sample_tensor_2way): tensorInstance[0, 0] = 0 assert tensorInstance.nnz == 5 + @pytest.mark.indevelopment def test_tensor_reshape(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params2, tensorInstance2) = sample_tensor_2way @@ -673,54 +819,58 @@ def test_tensor_reshape(sample_tensor_2way, sample_tensor_3way, sample_tensor_4w # Reshape with tuple tensorInstance2 = tensorInstance2.reshape((3, 2)) if DEBUG_tests: - print('\ntensorInstance2.reshape(3, 2):') - print(tensorInstance2.reshape(3, 2)) + print("\ntensorInstance2.reshape((3, 2)):") + print(tensorInstance2.reshape((3, 2))) assert tensorInstance2.shape == (3, 2) - data = np.array([[1., 5.], - [4., 3.], - [2., 6.]]) + data = np.array([[1.0, 5.0], [4.0, 3.0], [2.0, 6.0]]) assert (tensorInstance2.data == data).all() - # Reshape with multiple arguments - tensorInstance2a = tensorInstance2.reshape(2, 3) - if DEBUG_tests: - print('\ntensorInstance.reshape(2, 3):') - print(tensorInstance2.reshape(2, 3)) - assert tensorInstance2a.shape == (2, 3) - data2 = np.array([[1., 2., 3.], - [4., 5., 6.]]) - assert (tensorInstance2a.data == data2).all() - with pytest.raises(AssertionError) as excinfo: - tensorInstance2.reshape(3, 3) + tensorInstance2.reshape((3, 3)) assert "Reshaping a tensor cannot change number of elements" in str(excinfo) (params3, tensorInstance3) = sample_tensor_3way tensorInstance3 = tensorInstance3.reshape((3, 2, 2)) if DEBUG_tests: - print('\ntensorInstance3.reshape(3, 2, 2):') + print("\ntensorInstance3.reshape((3, 2, 2)):") print(tensorInstance3) assert tensorInstance3.shape == (3, 2, 2) - data3 = np.array([[[1., 7.], [4., 10.]], - [[2., 8.], [5., 11.]], - [[3., 9.], [6., 12.]]]) + data3 = np.array( + [ + [[1.0, 7.0], [4.0, 10.0]], + [[2.0, 8.0], [5.0, 11.0]], + [[3.0, 9.0], [6.0, 12.0]], + ] + ) assert (tensorInstance3.data == data3).all() (params4, tensorInstance4) = sample_tensor_4way tensorInstance4 = tensorInstance4.reshape((1, 3, 3, 9)) if DEBUG_tests: - print('\ntensorInstance4.reshape(1, 3, 3, 9):') + print("\ntensorInstance4.reshape((1, 3, 3, 9)):") print(tensorInstance4) assert tensorInstance4.shape == (1, 3, 3, 9) - data4 = np.array([[[[ 1, 10, 19, 28, 37, 46, 55, 64, 73], - [ 4, 13, 22, 31, 40, 49, 58, 67, 76], - [ 7, 16, 25, 34, 43, 52, 61, 70, 79]], - [[ 2, 11, 20, 29, 38, 47, 56, 65, 74], - [ 5, 14, 23, 32, 41, 50, 59, 68, 77], - [ 8, 17, 26, 35, 44, 53, 62, 71, 80]], - [[ 3, 12, 21, 30, 39, 48, 57, 66, 75], - [ 6, 15, 24, 33, 42, 51, 60, 69, 78], - [ 9, 18, 27, 36, 45, 54, 63, 72, 81]]]]) + data4 = np.array( + [ + [ + [ + [1, 10, 19, 28, 37, 46, 55, 64, 73], + [4, 13, 22, 31, 40, 49, 58, 67, 76], + [7, 16, 25, 34, 43, 52, 61, 70, 79], + ], + [ + [2, 11, 20, 29, 38, 47, 56, 65, 74], + [5, 14, 23, 32, 41, 50, 59, 68, 77], + [8, 17, 26, 35, 44, 53, 62, 71, 80], + ], + [ + [3, 12, 21, 30, 39, 48, 57, 66, 75], + [6, 15, 24, 33, 42, 51, 60, 69, 78], + [9, 18, 27, 36, 45, 54, 63, 72, 81], + ], + ] + ] + ) assert (tensorInstance4.data == data4).all() @@ -729,7 +879,9 @@ def test_tensor_permute(sample_tensor_2way, sample_tensor_3way, sample_tensor_4w (params, tensorInstance) = sample_tensor_2way # Permute rows and columns - assert (tensorInstance.permute(np.array([1, 0])).data == np.transpose(params['data'])).all() + assert ( + tensorInstance.permute(np.array([1, 0])).data == np.transpose(params["data"]) + ).all() # len(order) != ndims with pytest.raises(AssertionError) as excinfo: @@ -737,74 +889,67 @@ def test_tensor_permute(sample_tensor_2way, sample_tensor_3way, sample_tensor_4w assert "Invalid permutation order" in str(excinfo) # Try to permute order-1 tensor - assert (ttb.tensor.from_data(np.array([1, 2, 3, 4])).permute(np.array([1])).data == np.array([1, 2, 3, 4])).all() + assert ( + ttb.tensor.from_data(np.array([1, 2, 3, 4])).permute(np.array([1])).data + == np.array([1, 2, 3, 4]) + ).all() # Empty order - assert (ttb.tensor.from_data(np.array([])).permute(np.array([])).data == np.array([])).all() + assert ( + ttb.tensor.from_data(np.array([])).permute(np.array([])).data == np.array([]) + ).all() # 2-way (params2, tensorInstance2) = sample_tensor_2way tensorInstance2 = tensorInstance2.permute(np.array([1, 0])) if DEBUG_tests: - print('\ntensorInstance2.permute(np.array([1, 0])):') + print("\ntensorInstance2.permute(np.array([1, 0])):") print(tensorInstance2) assert tensorInstance2.shape == (3, 2) - data2 = np.array([[1., 4.], - [2., 5.], - [3., 6.]]) + data2 = np.array([[1.0, 4.0], [2.0, 5.0], [3.0, 6.0]]) assert (tensorInstance2.data == data2).all() - + # 3-way (params3, tensorInstance3) = sample_tensor_3way tensorInstance3 = tensorInstance3.permute(np.array([2, 1, 0])) if DEBUG_tests: - print('\ntensorInstance3.permute(np.array([2, 1, 0])):') + print("\ntensorInstance3.permute(np.array([2, 1, 0])):") print(tensorInstance3) assert tensorInstance3.shape == (2, 3, 2) - data3 = np.array([[[ 1., 2.], - [ 3., 4.], - [ 5., 6.]], - [[ 7., 8.], - [ 9., 10.], - [11., 12.]]]) + data3 = np.array( + [[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], [[7.0, 8.0], [9.0, 10.0], [11.0, 12.0]]] + ) assert (tensorInstance3.data == data3).all() - + # 4-way (params4, tensorInstance4) = sample_tensor_4way tensorInstance4 = tensorInstance4.permute(np.array([3, 1, 2, 0])) if DEBUG_tests: - print('\ntensorInstance4.permute(np.array([3, 1, 2, 0])):') + print("\ntensorInstance4.permute(np.array([3, 1, 2, 0])):") print(tensorInstance4) assert tensorInstance4.shape == (3, 3, 3, 3) - data4 = np.array([[[[ 1, 2, 3], - [10, 11, 12], - [19, 20, 21]], - [[ 4, 5, 6], - [13, 14, 15], - [22, 23, 24]], - [[ 7, 8, 9], - [16, 17, 18], - [25, 26, 27]]], - [[[28, 29, 30], - [37, 38, 39], - [46, 47, 48]], - [[31, 32, 33], - [40, 41, 42], - [49, 50, 51]], - [[34, 35, 36], - [43, 44, 45], - [52, 53, 54]]], - [[[55, 56, 57], - [64, 65, 66], - [73, 74, 75]], - [[58, 59, 60], - [67, 68, 69], - [76, 77, 78]], - [[61, 62, 63], - [70, 71, 72], - [79, 80, 81]]]]) + data4 = np.array( + [ + [ + [[1, 2, 3], [10, 11, 12], [19, 20, 21]], + [[4, 5, 6], [13, 14, 15], [22, 23, 24]], + [[7, 8, 9], [16, 17, 18], [25, 26, 27]], + ], + [ + [[28, 29, 30], [37, 38, 39], [46, 47, 48]], + [[31, 32, 33], [40, 41, 42], [49, 50, 51]], + [[34, 35, 36], [43, 44, 45], [52, 53, 54]], + ], + [ + [[55, 56, 57], [64, 65, 66], [73, 74, 75]], + [[58, 59, 60], [67, 68, 69], [76, 77, 78]], + [[61, 62, 63], [70, 71, 72], [79, 80, 81]], + ], + ] + ) assert (tensorInstance4.data == data4).all() + @pytest.mark.indevelopment def test_tensor_collapse(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params2, tensorInstance2) = sample_tensor_2way @@ -822,9 +967,34 @@ def test_tensor_collapse(sample_tensor_2way, sample_tensor_3way, sample_tensor_4 assert tensorInstance4.collapse() == 3321 assert tensorInstance4.collapse(fun=np.max) == 81 - with pytest.raises(AssertionError) as excinfo: - tensorInstance2.collapse(np.array([0])) - assert "collapse not implemented for arbitrary subset of dimensions; requires TENMAT class, which is not yet implemented" in str(excinfo) + # single dimension collapse + data = np.array([5, 7, 9]) + tensorCollapse = tensorInstance2.collapse(np.array([0])) + assert (tensorCollapse.data == data).all() + + # single dimension collapse using max function + datamax = np.array([4, 5, 6]) + tensorCollapseMax = tensorInstance2.collapse(np.array([0]), fun=np.max) + assert (tensorCollapseMax.data == datamax).all() + + # multiple dimensions collapse + data4 = np.array([[99, 342, 585], [126, 369, 612], [153, 396, 639]]) + tensorCollapse4 = tensorInstance4.collapse(np.array([0, 2])) + assert (tensorCollapse4.data == data4).all() + + # multiple dimensions collapse + data4max = np.array([[21, 48, 75], [24, 51, 78], [27, 54, 81]]) + tensorCollapse4Max = tensorInstance4.collapse(np.array([0, 2]), fun=np.max) + assert (tensorCollapse4Max.data == data4max).all() + + # Empty tensor collapse + empty_data = np.array([]) + empty_tensor = ttb.tensor.from_data(empty_data) + assert np.all(empty_tensor.collapse() == empty_data) + + # Empty dims + assert tensorInstance2.collapse(empty_data).isequal(tensorInstance2) + @pytest.mark.indevelopment def test_tensor_contract(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): @@ -838,30 +1008,29 @@ def test_tensor_contract(sample_tensor_2way, sample_tensor_3way, sample_tensor_4 tensorInstance2.contract(0, 0) assert "Must contract along two different dimensions" in str(excinfo) - contractableTensor = ttb.tensor.from_data(np.array([1, 2, 3, 4, 5, 6, 7, 8, 9]),(3,3)) + contractableTensor = ttb.tensor.from_data( + np.array([1, 2, 3, 4, 5, 6, 7, 8, 9]), (3, 3) + ) assert contractableTensor.contract(0, 1) == 15 (params3, tensorInstance3) = sample_tensor_3way print("\ntensorInstance3.contract(0,2) = ") - print(tensorInstance3.contract(0,2)) + print(tensorInstance3.contract(0, 2)) data3 = np.array([9, 13, 17]) - assert (tensorInstance3.contract(0,2).data == data3).all() + assert (tensorInstance3.contract(0, 2).data == data3).all() (params4, tensorInstance4) = sample_tensor_4way print("\ntensorInstance4.contract(0,1) = ") - print(tensorInstance4.contract(0,1)) - data4 = np.array([[15, 96, 177], - [42, 123, 204], - [69, 150, 231]]) + print(tensorInstance4.contract(0, 1)) + data4 = np.array([[15, 96, 177], [42, 123, 204], [69, 150, 231]]) assert (tensorInstance4.contract(0, 1).data == data4).all() print("\ntensorInstance4.contract(1,3) = ") - print(tensorInstance4.contract(1,3)) - data4a = np.array([[93, 120, 147], - [96, 123, 150], - [99, 126, 153]]) + print(tensorInstance4.contract(1, 3)) + data4a = np.array([[93, 120, 147], [96, 123, 150], [99, 126, 153]]) assert (tensorInstance4.contract(1, 3).data == data4a).all() + @pytest.mark.indevelopment def test_tensor__repr__(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way @@ -874,114 +1043,344 @@ def test_tensor__repr__(sample_tensor_2way): str(ttb.tensor()) + @pytest.mark.indevelopment def test_tensor_exp(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_2way - assert (tensorInstance.exp().data == np.exp(params['data'])).all() + assert (tensorInstance.exp().data == np.exp(params["data"])).all() + @pytest.mark.indevelopment def test_tensor_innerprod(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): (params, tensorInstance) = sample_tensor_2way # Tensor innerproduct - assert tensorInstance.innerprod(tensorInstance) == np.arange(1, 7).dot(np.arange(1, 7)) + assert tensorInstance.innerprod(tensorInstance) == np.arange(1, 7).dot( + np.arange(1, 7) + ) # Sptensor innerproduct - assert tensorInstance.innerprod(ttb.sptensor.from_tensor_type(tensorInstance)) == \ - np.arange(1, 7).dot(np.arange(1, 7)) + assert tensorInstance.innerprod( + ttb.sptensor.from_tensor_type(tensorInstance) + ) == np.arange(1, 7).dot(np.arange(1, 7)) # Wrong size innerproduct with pytest.raises(AssertionError) as excinfo: tensorInstance.innerprod(ttb.tensor.from_data(np.ones((4, 4)))) - assert 'Inner product must be between tensors of the same size' in str(excinfo) + assert "Inner product must be between tensors of the same size" in str(excinfo) # Wrong class innerproduct with pytest.raises(AssertionError) as excinfo: tensorInstance.innerprod(5) - assert "Inner product between tensor and that class is not supported" in str(excinfo) + assert "Inner product between tensor and that class is not supported" in str( + excinfo + ) # 2-way (params2, tensorInstance2) = sample_tensor_2way - if DEBUG_tests: - print(f'\ntensorInstance2.innerprod(tensorInstance2): {tensorInstance2.innerprod(tensorInstance2)}') + if DEBUG_tests: + print( + f"\ntensorInstance2.innerprod(tensorInstance2): {tensorInstance2.innerprod(tensorInstance2)}" + ) assert tensorInstance2.innerprod(tensorInstance2) == 91 # 3-way (params3, tensorInstance3) = sample_tensor_3way - if DEBUG_tests: - print(f'\ntensorInstance3.innerprod(tensorInstance3): {tensorInstance3.innerprod(tensorInstance3)}') + if DEBUG_tests: + print( + f"\ntensorInstance3.innerprod(tensorInstance3): {tensorInstance3.innerprod(tensorInstance3)}" + ) assert tensorInstance3.innerprod(tensorInstance3) == 650 # 4-way (params4, tensorInstance4) = sample_tensor_4way - if DEBUG_tests: - print(f'\ntensorInstance4.innerprod(tensorInstance4): {tensorInstance4.innerprod(tensorInstance4)}') + if DEBUG_tests: + print( + f"\ntensorInstance4.innerprod(tensorInstance4): {tensorInstance4.innerprod(tensorInstance4)}" + ) assert tensorInstance4.innerprod(tensorInstance4) == 180441 + @pytest.mark.indevelopment def test_tensor_mask(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way - assert (tensorInstance.mask(ttb.tensor.from_data(np.ones(params['shape']))) == params['data'].reshape((6,))).all() + W = ttb.tensor.from_data(np.array([[0, 1, 0], [1, 0, 0]])) + assert (tensorInstance.mask(W) == np.array([4, 2])).all() # Wrong shape mask with pytest.raises(AssertionError) as excinfo: tensorInstance.mask(ttb.tensor.from_data(np.ones((11, 3)))) assert "Mask cannot be bigger than the data tensor" in str(excinfo) + @pytest.mark.indevelopment def test_tensor_squeeze(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # No singleton dimensions - assert (tensorInstance.squeeze().data == params['data']).all() + assert (tensorInstance.squeeze().data == params["data"]).all() # All singleton dimensions - assert (ttb.tensor.from_data(np.array([[[4]]])).squeeze() == 4) + squeeze_result = ttb.tensor.from_data(np.array([[[4]]])).squeeze() + assert squeeze_result == 4 + assert np.isscalar(squeeze_result) # A singleton dimension - assert (ttb.tensor.from_data(np.array([[1, 2, 3]])).squeeze().data == np.array([1, 2, 3])).all() + assert ( + ttb.tensor.from_data(np.array([[1, 2, 3]])).squeeze().data + == np.array([1, 2, 3]) + ).all() + @pytest.mark.indevelopment -def test_tensor_ttv(sample_tensor_2way): - (params, tensorInstance) = sample_tensor_2way +def test_tensor_ttm(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): + (params2, tensorInstance2) = sample_tensor_2way + (params3, tensorInstance3) = sample_tensor_3way + (params4, tensorInstance4) = sample_tensor_4way + + M2 = np.reshape(np.arange(1, 2 * 2 + 1), [2, 2], order="F") + M3 = np.reshape(np.arange(1, 3 * 3 + 1), [3, 3], order="F") + + # 3-way single matrix + T3 = tensorInstance3.ttm(M2, 0) + assert isinstance(T3, ttb.tensor) + assert T3.shape == (2, 3, 2) + data3 = np.array([[[7, 31], [15, 39], [23, 47]], [[10, 46], [22, 58], [34, 70]]]) + assert (T3.data == data3).all() + + # 3-way single matrix, transposed + T3 = tensorInstance3.ttm(M2, 0, transpose=True) + assert isinstance(T3, ttb.tensor) + assert T3.shape == (2, 3, 2) + data3 = np.array([[[5, 23], [11, 29], [17, 35]], [[11, 53], [25, 67], [39, 81]]]) + assert (T3.data == data3).all() + + # 3-way, two matrices, negative dimension + T3 = tensorInstance3.ttm([M2, M2], exclude_dims=1) + assert isinstance(T3, ttb.tensor) + assert T3.shape == (2, 3, 2) + data3 = np.array( + [[[100, 138], [132, 186], [164, 234]], [[148, 204], [196, 276], [244, 348]]] + ) + assert (T3.data == data3).all() + + # 3-way, two matrices, explicit dimensions + T3 = tensorInstance3.ttm([M2, M3], [2, 1]) + assert isinstance(T3, ttb.tensor) + assert T3.shape == (2, 3, 2) + data3 = np.array( + [[[408, 576], [498, 702], [588, 828]], [[456, 648], [558, 792], [660, 936]]] + ) + assert (T3.data == data3).all() + + # 3-way, 3 matrices, no dimensions specified + T3 = tensorInstance3.ttm([M2, M3, M2]) + assert isinstance(T3, ttb.tensor) + assert T3.shape == (2, 3, 2) + data3 = np.array( + [ + [[1776, 2520], [2172, 3078], [2568, 3636]], + [[2640, 3744], [3228, 4572], [3816, 5400]], + ] + ) + assert (T3.data == data3).all() + + # 3-way, matrix must be np.ndarray + Tmat = ttb.tenmat.from_data(M2, rdims=np.array([0])) + with pytest.raises(AssertionError) as excinfo: + tensorInstance3.ttm(Tmat, 0) + assert "matrix must be of type numpy.ndarray" in str(excinfo) + + # 3-way, dims must be in range [0,self.ndims] + with pytest.raises(AssertionError) as excinfo: + tensorInstance3.ttm(M2, tensorInstance3.ndims + 1) + assert "dims must contain values in [0,self.dims)" in str(excinfo) + + +@pytest.mark.indevelopment +def test_tensor_ttt(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): + M31 = ttb.tensor.from_data( + np.reshape(np.arange(1, 2 * 3 * 4 + 1), [4, 3, 2], order="F") + ) + M32 = ttb.tensor.from_data( + np.reshape(np.arange(1, 2 * 3 * 4 + 1), [3, 4, 2], order="F") + ) + + # outer product of M31 and M32 + TTT1 = M31.ttt(M32) + assert TTT1.shape == (4, 3, 2, 3, 4, 2) + # choose two random 2-way slices + data11 = np.array([1, 2, 3, 4]) + data12 = np.array([289, 306, 323, 340]) + data13 = np.array([504, 528, 552, 576]) + assert (TTT1[:, 0, 0, 0, 0, 0].data == data11).all() + assert (TTT1[:, 1, 1, 1, 1, 1].data == data12).all() + assert (TTT1[:, 2, 1, 2, 3, 1].data == data13).all() + + TTT1_with_dims = M31.ttt( + M31, selfdims=np.array([0, 1, 2]), otherdims=np.array([0, 1, 2]) + ) + assert np.allclose(TTT1_with_dims, M31.innerprod(M31)) + + # Negative tests + with pytest.raises(AssertionError): + invalid_tensor_type = [] + M31.ttt(invalid_tensor_type) + + with pytest.raises(AssertionError): + M31.ttt(M31, selfdims=np.array([0, 1, 2]), otherdims=np.array([0, 2, 1])) + + M2 = ttb.tensor.from_data(np.reshape(np.arange(0, 2), [1, 2], order="F")) + result = M2.ttt(M2, 0, 0) + row_vector = M2.data + column_vector = M2.data.transpose() + assert np.allclose(result.data, row_vector * column_vector) + + +@pytest.mark.indevelopment +def test_tensor_ttv(sample_tensor_2way, sample_tensor_3way, sample_tensor_4way): + (params2, tensorInstance2) = sample_tensor_2way + (params3, tensorInstance3) = sample_tensor_3way + (params4, tensorInstance4) = sample_tensor_4way # Wrong shape vector with pytest.raises(AssertionError) as excinfo: - tensorInstance.ttv(np.array([np.array([1, 2]), np.array([1, 2])])) + tensorInstance2.ttv(np.array([np.array([1, 2]), np.array([1, 2])])) assert "Multiplicand is wrong size" in str(excinfo) - # Multiply by single vector - assert (tensorInstance.ttv(np.array([2, 2]), 0).data == np.array([2, 2]).dot(params['data'])).all() + # 2-way Multiply by single vector + T2 = tensorInstance2.ttv(np.array([2, 2]), 0) + assert isinstance(T2, ttb.tensor) + assert T2.shape == (3,) + assert (T2.data == np.array([10, 14, 18])).all() + + # 2-way Multiply by single vector (exclude dims) + T2 = tensorInstance2.ttv(np.array([2, 2]), exclude_dims=1) + assert isinstance(T2, ttb.tensor) + assert T2.shape == (3,) + assert (T2.data == np.array([10, 14, 18])).all() # Multiply by multiple vectors, infer dimensions - assert (tensorInstance.ttv(np.array([np.array([2, 2]), np.array([1, 1, 1])])) == - np.array([1, 1, 1]).dot(np.array([2, 2]).dot(params['data']))) + assert tensorInstance2.ttv([np.array([2, 2]), np.array([1, 1, 1])]) == 42 + + # Multiply by multiple vectors as list of numpy.ndarrays + assert tensorInstance2.ttv([np.array([2, 2]), np.array([1, 1, 1])]) == 42 + + # 3-way Multiply by single vector + T3 = tensorInstance3.ttv(2 * np.ones((tensorInstance3.shape[0],)), 0) + assert isinstance(T3, ttb.tensor) + assert T3.shape == (tensorInstance3.shape[1], tensorInstance3.shape[2]) + assert (T3.data == np.array([[6, 30], [14, 38], [22, 46]])).all() + + # Multiply by multiple vectors, infer dimensions + assert ( + tensorInstance3.ttv([np.array([2, 2]), np.array([1, 1, 1]), np.array([2, 2])]) + == 312 + ) + + # 4-way Multiply by single vector + T4 = tensorInstance4.ttv(2 * np.ones((tensorInstance4.shape[0],)), 0) + assert isinstance(T4, ttb.tensor) + assert T4.shape == ( + tensorInstance4.shape[1], + tensorInstance4.shape[2], + tensorInstance4.shape[3], + ) + + # 4-way Multiply by single vector (exclude dims) + T4 = tensorInstance4.ttv( + 2 * np.ones((tensorInstance4.shape[0],)), exclude_dims=np.array([1, 2, 3]) + ) + assert isinstance(T4, ttb.tensor) + assert T4.shape == ( + tensorInstance4.shape[1], + tensorInstance4.shape[2], + tensorInstance4.shape[3], + ) + + data4 = np.array( + [ + [[12, 174, 336], [66, 228, 390], [120, 282, 444]], + [[30, 192, 354], [84, 246, 408], [138, 300, 462]], + [[48, 210, 372], [102, 264, 426], [156, 318, 480]], + ] + ) + assert (T4.data == data4).all() + + # Multiply by multiple vectors, infer dimensions + assert ( + tensorInstance4.ttv( + np.array( + [ + np.array([1, 1, 1]), + np.array([1, 1, 1]), + np.array([1, 1, 1]), + np.array([1, 1, 1]), + ] + ) + ) + == 3321 + ) + @pytest.mark.indevelopment -def test_tensor_ttsv(sample_tensor_2way): - (params, tensorInstance) = sample_tensor_2way - tensorInstance = ttb.tensor.from_data(np.ones((4, 4, 4))) - vector = np.array([1, 1, 1, 1]) +def test_tensor_ttsv(sample_tensor_4way): + # 3-way + tensorInstance3 = ttb.tensor.from_data(np.ones((4, 4, 4))) + vector3 = np.array([4, 3, 2, 1]) + assert tensorInstance3.ttsv(vector3, version=1) == 1000 + assert ( + tensorInstance3.ttsv(vector3, skip_dim=0, version=1) == 100 * np.ones((4,)) + ).all() + assert ( + tensorInstance3.ttsv(vector3, skip_dim=1, version=1) == 10 * np.ones((4, 4)) + ).all() # Invalid dims - with pytest.raises(AssertionError) as excinfo: - tensorInstance.ttsv(vector, dims = 1) + with pytest.raises(ValueError) as excinfo: + tensorInstance3.ttsv(vector3, skip_dim=-1) assert "Invalid modes in ttsv" in str(excinfo) + # 4-way tensor + (params4, tensorInstance4) = sample_tensor_4way + T4ttsv = tensorInstance4.ttsv(np.array([1, 2, 3]), 2, version=1) + data4_3 = np.array( + [ + [[222, 276, 330], [240, 294, 348], [258, 312, 366]], + [[228, 282, 336], [246, 300, 354], [264, 318, 372]], + [[234, 288, 342], [252, 306, 360], [270, 324, 378]], + ] + ) + assert (T4ttsv.data == data4_3).all() + + # 5-way dense tensor + shape = (3, 3, 3, 3, 3) + T5 = ttb.tensor.from_data(np.arange(1, np.prod(shape) + 1), shape) + T5ttsv = T5.ttsv(np.array([1, 2, 3]), 2, version=1) + data5_3 = np.array( + [ + [[5220, 5544, 5868], [5328, 5652, 5976], [5436, 5760, 6084]], + [[5256, 5580, 5904], [5364, 5688, 6012], [5472, 5796, 6120]], + [[5292, 5616, 5940], [5400, 5724, 6048], [5508, 5832, 6156]], + ] + ) + assert (T5ttsv.data == data5_3).all() + + # Test new algorithm, version=2 - assert tensorInstance.ttsv(vector, version=1) == 64 - assert (tensorInstance.ttsv(vector, dims=-1, version=1) == np.array([16, 16, 16, 16])).all() - - tensorInstance = ttb.tensor.from_data(np.ones((4, 4, 4, 4))) - assert tensorInstance.ttsv(vector, dims=-3, version=1).isequal(tensorInstance.ttv(vector, 0)) + # 3-way + assert tensorInstance3.ttsv(vector3) == 1000 + assert (tensorInstance3.ttsv(vector3, 0) == 100 * np.ones((4,))).all() + assert (tensorInstance3.ttsv(vector3, 1) == 10 * np.ones((4, 4))).all() - # Test new algorithm + # 4-way tensor + T4ttsv2 = tensorInstance4.ttsv(np.array([1, 2, 3]), 2) + assert (T4ttsv2.data == data4_3).all() - # Only works for all modes of equal length + # Incorrect version requested with pytest.raises(AssertionError) as excinfo: - tensorInstance.ttsv(vector, dims=-1) - assert "New version only support vector times all modes" in str(excinfo) - tensorInstance = ttb.tensor.from_data(np.ones((4, 4, 4))) - assert tensorInstance.ttsv(vector) == 64 + tensorInstance4.ttsv(np.array([1, 2, 3]), 2, version=3) + assert "Invalid value for version; should be None, 1, or 2" in str(excinfo) + @pytest.mark.indevelopment def test_tensor_issymmetric(sample_tensor_2way): @@ -990,10 +1389,14 @@ def test_tensor_issymmetric(sample_tensor_2way): assert tensorInstance.issymmetric() is False assert tensorInstance.issymmetric(version=1) is False - symmetricData = np.array([[[0.5, 0, 0.5, 0], [0, 0, 0, 0],[0.5, 0, 0, 0],[0, 0, 0, 0]], - [[0, 0, 0, 0], [0, 2.5, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], - [[0.5, 0, 0, 0], [0, 0, 0, 0], [0, 0, 3.5, 0], [0, 0, 0, 0]], - [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]]) + symmetricData = np.array( + [ + [[0.5, 0, 0.5, 0], [0, 0, 0, 0], [0.5, 0, 0, 0], [0, 0, 0, 0]], + [[0, 0, 0, 0], [0, 2.5, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], + [[0.5, 0, 0, 0], [0, 0, 0, 0], [0, 0, 3.5, 0], [0, 0, 0, 0]], + [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], + ] + ) symmetricTensor = ttb.tensor.from_data(symmetricData) assert symmetricTensor.issymmetric() is True assert symmetricTensor.issymmetric(version=1) is True @@ -1006,25 +1409,52 @@ def test_tensor_issymmetric(sample_tensor_2way): assert symmetricTensor.issymmetric() is False assert symmetricTensor.issymmetric(version=1) is False + @pytest.mark.indevelopment def test_tensor_symmetrize(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way # Test new default version - symmetricData = np.array([[[0.5, 0, 0.5, 0], [0, 0, 0, 0], [0.5, 0, 0, 0], [0, 0, 0, 0]], - [[0, 0, 0, 0], [0, 2.5, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], - [[0.5, 0, 0, 0], [0, 0, 0, 0], [0, 0, 3.5, 0], [0, 0, 0, 0]], - [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]]) + # 2-way + symmetricData = np.array( + [ + [[0.5, 0, 0.5, 0], [0, 0, 0, 0], [0.5, 0, 0, 0], [0, 0, 0, 0]], + [[0, 0, 0, 0], [0, 2.5, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], + [[0.5, 0, 0, 0], [0, 0, 0, 0], [0, 0, 3.5, 0], [0, 0, 0, 0]], + [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], + ] + ) symmetricTensor = ttb.tensor.from_data(symmetricData) assert symmetricTensor.symmetrize().isequal(symmetricTensor) + # 3-way symmetricData = np.zeros((4, 4, 4)) symmetricData[1, 2, 1] = 1 symmetricTensor = ttb.tensor.from_data(symmetricData) + print(f"\nsymmetricTensor:\n{symmetricTensor}") assert symmetricTensor.issymmetric() is False + print(f"\nsymmetricTensor.symmetrize():\n{symmetricTensor.symmetrize()}") assert (symmetricTensor.symmetrize()).issymmetric() + # 3-way + shape = (2, 2, 2) + T3 = ttb.tensor.from_data(np.arange(1, np.prod(shape) + 1), shape) + T3sym = T3.symmetrize() + print(f"\nT3sym:") + print(T3sym) + data3 = np.array( + [ + [[1, 3 + 1 / 3], [3 + 1 / 3, 5 + 2 / 3]], + [[3 + 1 / 3, 5 + 2 / 3], [5 + 2 / 3, 8]], + ] + ) + assert (T3sym.data == data3).all() + + # T3syms_2_1_3 = T3.symmetrize(grps=[[1], [0,2]]) + # print(f'\nT3syms_2_1_3:') + # print(T3syms_2_1_3) + with pytest.raises(AssertionError) as excinfo: symmetricTensor.symmetrize(grps=np.array([[0, 1], [1, 2]])) assert "Cannot have overlapping symmetries" in str(excinfo) @@ -1037,10 +1467,14 @@ def test_tensor_symmetrize(sample_tensor_2way): assert "Dimension mismatch for symmetrization" in str(excinfo) # Test older keyword version - symmetricData = np.array([[[0.5, 0, 0.5, 0], [0, 0, 0, 0], [0.5, 0, 0, 0], [0, 0, 0, 0]], - [[0, 0, 0, 0], [0, 2.5, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], - [[0.5, 0, 0, 0], [0, 0, 0, 0], [0, 0, 3.5, 0], [0, 0, 0, 0]], - [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]]) + symmetricData = np.array( + [ + [[0.5, 0, 0.5, 0], [0, 0, 0, 0], [0.5, 0, 0, 0], [0, 0, 0, 0]], + [[0, 0, 0, 0], [0, 2.5, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], + [[0.5, 0, 0, 0], [0, 0, 0, 0], [0, 0, 3.5, 0], [0, 0, 0, 0]], + [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], + ] + ) symmetricTensor = ttb.tensor.from_data(symmetricData) assert symmetricTensor.symmetrize(version=1).isequal(symmetricTensor) @@ -1062,74 +1496,91 @@ def test_tensor_symmetrize(sample_tensor_2way): asymmetricTensor.symmetrize(version=1) assert "Dimension mismatch for symmetrization" in str(excinfo) + @pytest.mark.indevelopment def test_tensor__str__(sample_tensor_2way): # Test 1D data = np.random.normal(size=(4,)) tensorInstance = ttb.tensor.from_data(data) - s = '' - s += 'tensor of shape ' - s += (' x ').join([str(int(d)) for d in tensorInstance.shape]) - s += '\n' - s += 'data' - s += '[:] = \n' + s = "" + s += "tensor of shape " + s += (" x ").join([str(int(d)) for d in tensorInstance.shape]) + s += "\n" + s += "data" + s += "[:] = \n" s += data.__str__() - s += '\n' + s += "\n" assert s == tensorInstance.__str__() # Test 2D data = np.random.normal(size=(4, 3)) tensorInstance = ttb.tensor.from_data(data) - s = '' - s += 'tensor of shape ' - s += (' x ').join([str(int(d)) for d in tensorInstance.shape]) - s += '\n' - s += 'data' - s += '[:, :] = \n' + s = "" + s += "tensor of shape " + s += (" x ").join([str(int(d)) for d in tensorInstance.shape]) + s += "\n" + s += "data" + s += "[:, :] = \n" s += data.__str__() - s += '\n' + s += "\n" assert s == tensorInstance.__str__() # Test 3D,shape in decreasing and increasing order data = np.random.normal(size=(4, 3, 2)) tensorInstance = ttb.tensor.from_data(data) - s = '' - s += 'tensor of shape ' - s += (' x ').join([str(int(d)) for d in tensorInstance.shape]) - s += '\n' + s = "" + s += "tensor of shape " + s += (" x ").join([str(int(d)) for d in tensorInstance.shape]) + s += "\n" for i in range(data.shape[0]): - s += 'data' - s += '[{}, :, :] = \n'.format(i) + s += "data" + s += "[{}, :, :] = \n".format(i) s += data[i, :, :].__str__() - s += '\n' + s += "\n" assert s == tensorInstance.__str__() data = np.random.normal(size=(2, 3, 4)) tensorInstance = ttb.tensor.from_data(data) - s = '' - s += 'tensor of shape ' - s += (' x ').join([str(int(d)) for d in tensorInstance.shape]) - s += '\n' + s = "" + s += "tensor of shape " + s += (" x ").join([str(int(d)) for d in tensorInstance.shape]) + s += "\n" for i in range(data.shape[0]): - s += 'data' - s += '[{}, :, :] = \n'.format(i) + s += "data" + s += "[{}, :, :] = \n".format(i) s += data[i, :, :].__str__() - s += '\n' + s += "\n" assert s == tensorInstance.__str__() - # Test > 3D + # Test 4D data = np.random.normal(size=(4, 4, 3, 2)) tensorInstance = ttb.tensor.from_data(data) - s = '' - s += 'tensor of shape ' - s += (' x ').join([str(int(d)) for d in tensorInstance.shape]) - s += '\n' + s = "" + s += "tensor of shape " + s += (" x ").join([str(int(d)) for d in tensorInstance.shape]) + s += "\n" + for i in range(data.shape[0]): + for j in range(data.shape[1]): + s += "data" + s += "[{}, {}, :, :] = \n".format(j, i) + s += data[j, i, :, :].__str__() + s += "\n" + assert s == tensorInstance.__str__() + + # Test 5D + data = np.random.normal(size=(2, 2, 2, 2, 2)) + tensorInstance = ttb.tensor.from_data(data) + s = "" + s += "tensor of shape " + s += (" x ").join([str(int(d)) for d in tensorInstance.shape]) + s += "\n" for i in range(data.shape[0]): - for j in range(data.shape[0]): - s += 'data' - s += '[{}, {}, :, :] = \n'.format(i,j) - s += data[i, j, :, :].__str__() - s += '\n' + for j in range(data.shape[1]): + for k in range(data.shape[2]): + s += "data" + s += "[{}, {}, {}, :, :] = \n".format(k, j, i) + s += data[k, j, i, :, :].__str__() + s += "\n" assert s == tensorInstance.__str__() @@ -1138,42 +1589,70 @@ def test_tensor_mttkrp(sample_tensor_2way): (params, tensorInstance) = sample_tensor_2way tensorInstance = ttb.tensor.from_function(np.ones, (2, 3, 4)) - weights = np.array([2., 2.]) - fm0 = np.array([[1., 3.], [2., 4.]]) - fm1 = np.array([[5., 8.], [6., 9.], [7., 10.]]) - fm2 = np.array([[11., 15.], [12., 16.], [13., 17.], [14., 18.]]) + # 2-way sparse tensor + weights = np.array([2.0, 2.0]) + fm0 = np.array([[1.0, 3.0], [2.0, 4.0]]) + fm1 = np.array([[5.0, 8.0], [6.0, 9.0], [7.0, 10.0]]) + fm2 = np.array([[11.0, 15.0], [12.0, 16.0], [13.0, 17.0], [14.0, 18.0]]) factor_matrices = [fm0, fm1, fm2] ktensorInstance = ttb.ktensor.from_data(weights, factor_matrices) - m0 = np.array([[1800., 3564.], - [1800., 3564.]]) - m1 = np.array([[300., 924.], - [300., 924.], - [300., 924.]]) - m2 = np.array([[108., 378.], - [108., 378.], - [108., 378.], - [108., 378.]]) + m0 = np.array([[1800.0, 3564.0], [1800.0, 3564.0]]) + m1 = np.array([[300.0, 924.0], [300.0, 924.0], [300.0, 924.0]]) + m2 = np.array([[108.0, 378.0], [108.0, 378.0], [108.0, 378.0], [108.0, 378.0]]) assert np.allclose(tensorInstance.mttkrp(ktensorInstance, 0), m0) assert np.allclose(tensorInstance.mttkrp(ktensorInstance, 1), m1) assert np.allclose(tensorInstance.mttkrp(ktensorInstance, 2), m2) + # 5-way dense tensor + shape = (2, 3, 4, 5, 6) + T = ttb.tensor.from_data(np.arange(1, np.prod(shape) + 1), shape) + U = [] + for s in shape: + U.append(np.ones((s, 2))) + + data0 = np.array([[129600, 129600], [129960, 129960]]) + assert (T.mttkrp(U, 0) == data0).all() + + data1 = np.array([[86040, 86040], [86520, 86520], [87000, 87000]]) + assert (T.mttkrp(U, 1) == data1).all() + + data2 = np.array([[63270, 63270], [64350, 64350], [65430, 65430], [66510, 66510]]) + assert (T.mttkrp(U, 2) == data2).all() + + data3 = np.array( + [[45000, 45000], [48456, 48456], [51912, 51912], [55368, 55368], [58824, 58824]] + ) + assert (T.mttkrp(U, 3) == data3).all() + + data4 = np.array( + [ + [7260, 7260], + [21660, 21660], + [36060, 36060], + [50460, 50460], + [64860, 64860], + [79260, 79260], + ] + ) + assert (T.mttkrp(U, 4) == data4).all() + # tensor too small with pytest.raises(AssertionError) as excinfo: tensorInstance2 = ttb.tensor.from_data(np.array([1])) tensorInstance2.mttkrp([], 0) - assert 'MTTKRP is invalid for tensors with fewer than 2 dimensions' in str(excinfo) + assert "MTTKRP is invalid for tensors with fewer than 2 dimensions" in str(excinfo) # second argument not a ktensor or list with pytest.raises(AssertionError) as excinfo: tensorInstance.mttkrp(5, 0) - assert 'Second argument should be a list of arrays or a ktensor' in str(excinfo) + assert "Second argument should be a list of arrays or a ktensor" in str(excinfo) # second argument list is not the correct length with pytest.raises(AssertionError) as excinfo: - m0 = np.ones((2,2)) + m0 = np.ones((2, 2)) tensorInstance.mttkrp([m0, m0, m0, m0], 0) - assert 'Second argument contains the wrong number of arrays' in str(excinfo) + assert "Second argument contains the wrong number of arrays" in str(excinfo) # arrays not the correct shape with pytest.raises(AssertionError) as excinfo: @@ -1181,21 +1660,80 @@ def test_tensor_mttkrp(sample_tensor_2way): m1 = np.ones((3, 2)) m2 = np.ones((5, 2)) tensorInstance.mttkrp([m0, m1, m2], 0) - assert 'Entry 2 of list of arrays is wrong size' in str(excinfo) + assert "Entry 2 of list of arrays is wrong size" in str(excinfo) + @pytest.mark.indevelopment def test_tensor_nvecs(sample_tensor_2way): (data, tensorInstance) = sample_tensor_2way - nv1 = np.array([[ 0.4286671335486261, 0.5663069188480352, 0.7039467041474443]]).T - nv2 = np.array([[ 0.4286671335486261, 0.5663069188480352, 0.7039467041474443], - [ 0.8059639085892916, 0.1123824140966059, -0.5811990803961161]]).T + nv1 = np.array([[0.4286671335486261, 0.5663069188480352, 0.7039467041474443]]).T + nv2 = np.array( + [ + [0.4286671335486261, 0.5663069188480352, 0.7039467041474443], + [0.8059639085892916, 0.1123824140966059, -0.5811990803961161], + ] + ).T # Test for one eigenvector assert np.allclose((tensorInstance.nvecs(1, 1)), nv1) # Test for r >= N-1, requires cast to dense - with pytest.warns(Warning) as record: - assert np.allclose((tensorInstance.nvecs(1, 2)), nv2) - assert 'Greater than or equal to tensor.shape[n] - 1 eigenvectors requires cast to dense to solve' \ - in str(record[0].message) + assert np.allclose((tensorInstance.nvecs(1, 2)), nv2) + + +def test_tenones(): + arbitrary_shape = (3, 3, 3) + ones_tensor = ttb.tenones(arbitrary_shape) + data_tensor = ttb.tensor.from_data(np.ones(arbitrary_shape)) + assert np.equal(ones_tensor, data_tensor), "Tenones should match all ones tensor" + + +def test_tenzeros(): + arbitrary_shape = (3, 3, 3) + zeros_tensor = ttb.tenzeros(arbitrary_shape) + data_tensor = ttb.tensor.from_data(np.zeros(arbitrary_shape)) + assert np.equal(zeros_tensor, data_tensor), "Tenzeros should match all zeros tensor" + + +def test_tenrand(): + arbitrary_shape = (3, 3, 3) + rand_tensor = ttb.tenrand(arbitrary_shape) + in_unit_interval = np.all((rand_tensor >= 0).data) and np.all( + (rand_tensor <= 1).data + ) + assert in_unit_interval and rand_tensor.shape == arbitrary_shape + + +def test_tendiag(): + N = 4 + elements = np.arange(0, N) + exact_shape = [N] * N + + # Inferred shape + X = ttb.tendiag(elements) + for i in range(N): + diag_index = (i,) * N + assert X[diag_index] == i + + # Exact shape + X = ttb.tendiag(elements, tuple(exact_shape)) + for i in range(N): + diag_index = (i,) * N + assert X[diag_index] == i + + # Larger shape + larger_shape = exact_shape.copy() + larger_shape[0] += 1 + X = ttb.tendiag(elements, tuple(larger_shape)) + for i in range(N): + diag_index = (i,) * N + assert X[diag_index] == i + + # Smaller Shape + smaller_shape = exact_shape.copy() + smaller_shape[0] -= 1 + X = ttb.tendiag(elements, tuple(smaller_shape)) + for i in range(N): + diag_index = (i,) * N + assert X[diag_index] == i diff --git a/tests/test_ttensor.py b/tests/test_ttensor.py new file mode 100644 index 00000000..e1e7dd9e --- /dev/null +++ b/tests/test_ttensor.py @@ -0,0 +1,398 @@ +# Copyright 2022 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. + +import numpy as np +import pytest +import scipy.sparse as sparse + +import pyttb as ttb + + +@pytest.fixture() +def sample_ttensor(): + """Simple TTENSOR to verify by hand""" + core = ttb.tensor.from_data(np.ones((2, 2, 2))) + factors = [np.ones((1, 2))] * len(core.shape) + ttensorInstance = ttb.ttensor().from_data(core, factors) + return ttensorInstance + + +@pytest.fixture() +def random_ttensor(): + """Arbitrary TTENSOR to verify consistency between alternative operations""" + core = ttb.tensor.from_data(np.random.random((2, 3, 4))) + factors = [ + np.random.random((5, 2)), + np.random.random((2, 3)), + np.random.random((4, 4)), + ] + ttensorInstance = ttb.ttensor().from_data(core, factors) + return ttensorInstance + + +@pytest.mark.indevelopment +def test_ttensor_initialization_empty(): + empty_tensor = ttb.tensor() + + # No args + ttensorInstance = ttb.ttensor() + assert ttensorInstance.core == empty_tensor + assert ttensorInstance.u == [] + + +@pytest.mark.indevelopment +def test_ttensor_initialization_from_data(sample_ttensor): + ttensorInstance = sample_ttensor + assert isinstance(ttensorInstance.core, ttb.tensor) + assert all([isinstance(a_factor, np.ndarray) for a_factor in ttensorInstance.u]) + + # Negative Tests + non_array_factor = ttensorInstance.u + [1] + with pytest.raises(ValueError): + ttb.ttensor.from_data(ttensorInstance.core, non_array_factor[1:]) + + non_matrix_factor = ttensorInstance.u + [np.array([1])] + with pytest.raises(ValueError): + ttb.ttensor.from_data(ttensorInstance.core, non_matrix_factor[1:]) + + too_few_factors = ttensorInstance.u.copy() + too_few_factors.pop() + with pytest.raises(ValueError): + ttb.ttensor.from_data(ttensorInstance.core, too_few_factors) + + wrong_shape_factor = ttensorInstance.u.copy() + row, col = wrong_shape_factor[0].shape + wrong_shape_factor[0] = np.random.random((row + 1, col + 1)) + with pytest.raises(ValueError): + ttb.ttensor.from_data(ttensorInstance.core, wrong_shape_factor) + + # Enforce error until sptensor core/other cores supported + with pytest.raises(ValueError): + ttb.ttensor.from_data( + ttb.sptensor.from_tensor_type(ttensorInstance.core), ttensorInstance.u + ) + + +@pytest.mark.indevelopment +def test_ttensor_initialization_from_tensor_type(sample_ttensor): + # Copy constructor + ttensorInstance = sample_ttensor + ttensorCopy = ttb.ttensor.from_tensor_type(ttensorInstance) + assert ttensorCopy.core == ttensorInstance.core + assert ttensorCopy.u == ttensorInstance.u + assert ttensorCopy.shape == ttensorInstance.shape + + +@pytest.mark.indevelopment +def test_ttensor_full(sample_ttensor): + ttensorInstance = sample_ttensor + tensor = ttensorInstance.full() + # This sanity check only works for all 1's + assert tensor.double() == np.prod(ttensorInstance.core.shape) + + # Negative tests + sparse_core = ttb.sptensor() + sparse_core.shape = ttensorInstance.core.shape + sparse_u = [ + sparse.coo_matrix(np.zeros(factor.shape)) for factor in ttensorInstance.u + ] + # We could probably make these properties to avoid this edge case but expect to eventually cover these alternate + # cores + ttensorInstance.core = sparse_core + ttensorInstance.u = sparse_u + with pytest.raises(ValueError): + ttensorInstance.full() + + +@pytest.mark.indevelopment +def test_ttensor_double(sample_ttensor): + ttensorInstance = sample_ttensor + # This sanity check only works for all 1's + assert ttensorInstance.double() == np.prod(ttensorInstance.core.shape) + + +@pytest.mark.indevelopment +def test_ttensor_ndims(sample_ttensor): + ttensorInstance = sample_ttensor + + assert ttensorInstance.ndims == 3 + + +@pytest.mark.indevelopment +def test_ttensor__pos__(sample_ttensor): + ttensorInstance = sample_ttensor + ttensorInstance2 = +ttensorInstance + + assert ttensorInstance.isequal(ttensorInstance2) + + +@pytest.mark.indevelopment +def test_sptensor__neg__(sample_ttensor): + ttensorInstance = sample_ttensor + ttensorInstance2 = -ttensorInstance + ttensorInstance3 = -ttensorInstance2 + + assert not ttensorInstance.isequal(ttensorInstance2) + assert ttensorInstance.isequal(ttensorInstance3) + + +@pytest.mark.indevelopment +def test_ttensor_innerproduct(sample_ttensor, random_ttensor): + ttensorInstance = sample_ttensor + + # TODO these are an overly simplistic edge case for ttensors that are a single float + + # ttensor innerprod ttensor + assert ttensorInstance.innerprod(ttensorInstance) == ttensorInstance.double() ** 2 + core_dim = ttensorInstance.core.shape[0] + 1 + ndim = ttensorInstance.ndims + large_core_ttensor = ttb.ttensor.from_data( + ttb.tensor.from_data(np.ones((core_dim,) * ndim)), + [np.ones((1, core_dim))] * ndim, + ) + assert large_core_ttensor.innerprod( + ttensorInstance + ) == ttensorInstance.full().innerprod(large_core_ttensor.full()) + + # ttensor innerprod tensor + assert ( + ttensorInstance.innerprod(ttensorInstance.full()) + == ttensorInstance.double() ** 2 + ) + + # ttensr innerprod ktensor + ktensorInstance = ttb.ktensor.from_data(np.array([8.0]), [np.array([[1.0]])] * 3) + assert ttensorInstance.innerprod(ktensorInstance) == ttensorInstance.double() ** 2 + + # ttensor innerprod tensor (shape larger than core) + random_ttensor.innerprod(random_ttensor.full()) + + # Negative Tests + ttensor_extra_factors = ttb.ttensor.from_tensor_type(ttensorInstance) + ttensor_extra_factors.u.extend(ttensorInstance.u) + with pytest.raises(ValueError): + ttensorInstance.innerprod(ttensor_extra_factors) + + tensor_extra_dim = ttb.tensor.from_data(np.ones(ttensorInstance.shape + (1,))) + with pytest.raises(ValueError): + ttensorInstance.innerprod(tensor_extra_dim) + + invalid_option = [] + with pytest.raises(ValueError): + ttensorInstance.innerprod(invalid_option) + + +@pytest.mark.indevelopment +def test_ttensor__mul__(sample_ttensor): + ttensorInstance = sample_ttensor + mul_factor = 2 + + # This sanity check only works for all 1's + assert (ttensorInstance * mul_factor).double() == np.prod( + ttensorInstance.core.shape + ) * mul_factor + assert (ttensorInstance * float(2)).double() == np.prod( + ttensorInstance.core.shape + ) * float(mul_factor) + + # Negative tests + with pytest.raises(ValueError): + _ = ttensorInstance * "some_string" + + +@pytest.mark.indevelopment +def test_ttensor__rmul__(sample_ttensor): + ttensorInstance = sample_ttensor + mul_factor = 2 + + # This sanity check only works for all 1's + assert (mul_factor * ttensorInstance).double() == np.prod( + ttensorInstance.core.shape + ) * mul_factor + assert (float(2) * ttensorInstance).double() == np.prod( + ttensorInstance.core.shape + ) * float(mul_factor) + + # Negative tests + with pytest.raises(ValueError): + _ = "some_string" * ttensorInstance + + +@pytest.mark.indevelopment +def test_ttensor_ttv(sample_ttensor): + ttensorInstance = sample_ttensor + mul_factor = 1 + trivial_vectors = [np.array([mul_factor])] * len(ttensorInstance.shape) + final_value = sample_ttensor.ttv(trivial_vectors) + assert final_value == np.prod(ttensorInstance.core.shape) + + assert np.allclose( + ttensorInstance.ttv(trivial_vectors[0], 0).double(), + ttensorInstance.full().ttv(trivial_vectors[0], 0).double(), + ) + + assert np.allclose( + ttensorInstance.ttv(trivial_vectors[0:2], exclude_dims=0).double(), + ttensorInstance.full().ttv(trivial_vectors[0:2], exclude_dims=0).double(), + ) + + # Negative tests + wrong_shape_vector = trivial_vectors.copy() + wrong_shape_vector[0] = np.array([mul_factor, mul_factor]) + with pytest.raises(ValueError): + sample_ttensor.ttv(wrong_shape_vector) + + +@pytest.mark.indevelopment +def test_ttensor_mttkrp(random_ttensor): + ttensorInstance = random_ttensor + column_length = 6 + vectors = [np.random.random((u.shape[0], column_length)) for u in ttensorInstance.u] + final_value = ttensorInstance.mttkrp(vectors, 2) + full_value = ttensorInstance.full().mttkrp(vectors, 2) + assert np.allclose(final_value, full_value), ( + f"TTensor value is: \n{final_value}\n\n" f"Full value is: \n{full_value}" + ) + + +@pytest.mark.indevelopment +def test_ttensor_norm(sample_ttensor, random_ttensor): + ttensorInstance = random_ttensor + assert np.isclose(ttensorInstance.norm(), ttensorInstance.full().norm()) + + # Core larger than full tensor + ttensorInstance = sample_ttensor + assert np.isclose(ttensorInstance.norm(), ttensorInstance.full().norm()) + + +@pytest.mark.indevelopment +def test_ttensor_permute(random_ttensor): + ttensorInstance = random_ttensor + original_order = np.arange(0, len(ttensorInstance.core.shape)) + permuted_tensor = ttensorInstance.permute(original_order) + assert ttensorInstance.isequal(permuted_tensor) + + # Negative Tests + with pytest.raises(ValueError): + bad_permutation_order = np.arange(0, len(ttensorInstance.core.shape) + 1) + ttensorInstance.permute(bad_permutation_order) + + +@pytest.mark.indevelopment +def test_ttensor_ttm(random_ttensor): + ttensorInstance = random_ttensor + row_length = 9 + matrices = [np.random.random((row_length, u.shape[0])) for u in ttensorInstance.u] + final_value = ttensorInstance.ttm(matrices, np.arange(len(matrices))) + reverse_value = ttensorInstance.ttm( + list(reversed(matrices)), np.arange(len(matrices) - 1, -1, -1) + ) + assert final_value.isequal(reverse_value), ( + f"TTensor value is: \n{final_value}\n\n" f"Full value is: \n{reverse_value}" + ) + final_value = ttensorInstance.ttm(matrices) # No dims + assert final_value.isequal(reverse_value) + final_value = ttensorInstance.ttm( + matrices, list(range(len(matrices))) + ) # Dims as list + assert final_value.isequal(reverse_value) + + # Exclude Dims + assert ttensorInstance.ttm(matrices[1:], exclude_dims=0).isequal( + ttensorInstance.ttm(matrices[1:], dims=np.array([1, 2])) + ) + + single_tensor_result = ttensorInstance.ttm(matrices[0], 0) + single_tensor_full_result = ttensorInstance.full().ttm(matrices[0], 0) + assert np.allclose( + single_tensor_result.double(), single_tensor_full_result.double() + ), ( + f"TTensor value is: \n{single_tensor_result.full()}\n\n" + f"Full value is: \n{single_tensor_full_result}" + ) + + transposed_matrices = [matrix.transpose() for matrix in matrices] + transpose_value = ttensorInstance.ttm( + transposed_matrices, np.arange(len(matrices)), transpose=True + ) + assert final_value.isequal(transpose_value) + + # Negative Tests + big_wrong_size = 123 + bad_matrices = matrices.copy() + bad_matrices[0] = np.random.random((big_wrong_size, big_wrong_size)) + with pytest.raises(ValueError): + _ = ttensorInstance.ttm(bad_matrices, np.arange(len(bad_matrices))) + + with pytest.raises(ValueError): + # Negative dims currently broken, ensure we catch early and + # remove once resolved + ttensorInstance.ttm(matrices, -1) + + +@pytest.mark.indevelopment +def test_ttensor_reconstruct(random_ttensor): + ttensorInstance = random_ttensor + # TODO: This slice drops the singleton dimension, should it? If so should ttensor squeeze during reconstruct? + full_slice = ttensorInstance.full()[:, 1, :] + ttensor_slice = ttensorInstance.reconstruct(1, 1) + assert np.allclose(full_slice.double(), ttensor_slice.squeeze().double()) + assert ttensorInstance.reconstruct().isequal(ttensorInstance.full()) + sample_all_modes = [np.array([0])] * len(ttensorInstance.shape) + sample_all_modes[-1] = 0 # Make raw scalar + reconstruct_scalar = ttensorInstance.reconstruct(sample_all_modes).full().double() + full_scalar = ttensorInstance.full()[tuple(sample_all_modes)] + assert np.isclose(reconstruct_scalar, full_scalar) + + scale = np.random.random(ttensorInstance.u[1].shape).transpose() + _ = ttensorInstance.reconstruct(scale, 1) + # FIXME from the MATLAB docs wasn't totally clear how to validate this + + # Negative Tests + with pytest.raises(ValueError): + _ = ttensorInstance.reconstruct(1, [0, 1]) + + +@pytest.mark.indevelopment +def test_ttensor_nvecs(random_ttensor): + ttensorInstance = random_ttensor + n = 0 + r = 2 + ttensor_eigvals = ttensorInstance.nvecs(n, r) + full_eigvals = ttensorInstance.full().nvecs(n, r) + assert np.allclose(ttensor_eigvals, full_eigvals) + + # Test for eig vals larger than shape-1 + n = 1 + r = 2 + full_eigvals = ttensorInstance.full().nvecs(n, r) + ttensor_eigvals = ttensorInstance.nvecs(n, r) + assert np.allclose(ttensor_eigvals, full_eigvals) + + # Negative Tests + sparse_core = ttb.sptensor() + sparse_core.shape = ttensorInstance.core.shape + ttensorInstance.core = sparse_core + + # Sparse core + with pytest.raises(NotImplementedError): + ttensorInstance.nvecs(0, 1) + + # Sparse factors + sparse_u = [ + sparse.coo_matrix(np.zeros(factor.shape)) for factor in ttensorInstance.u + ] + ttensorInstance.u = sparse_u + with pytest.raises(NotImplementedError): + ttensorInstance.nvecs(0, 1) + + +@pytest.mark.indevelopment +def test_sptensor_isequal(sample_ttensor): + ttensorInstance = sample_ttensor + # Negative Tests + assert not ttensorInstance.isequal(ttensorInstance.full()) + ttensor_extra_factors = ttb.ttensor.from_tensor_type(ttensorInstance) + ttensor_extra_factors.u.extend(ttensorInstance.u) + assert not ttensorInstance.isequal(ttensor_extra_factors) diff --git a/tests/test_tucker_als.py b/tests/test_tucker_als.py new file mode 100644 index 00000000..b72d1251 --- /dev/null +++ b/tests/test_tucker_als.py @@ -0,0 +1,97 @@ +import numpy as np +import pytest + +import pyttb as ttb + + +@pytest.fixture() +def sample_tensor(): + data = np.array([[29, 39.0], [63.0, 85.0]]) + shape = (2, 2) + params = {"data": data, "shape": shape} + tensorInstance = ttb.tensor().from_data(data, shape) + return params, tensorInstance + + +@pytest.mark.indevelopment +def test_tucker_als_tensor_default_init(capsys, sample_tensor): + (data, T) = sample_tensor + (Solution, Uinit, output) = ttb.tucker_als(T, 2) + capsys.readouterr() + assert pytest.approx(output["fit"], 1) == 0 + assert np.all(np.isclose(Solution.double(), T.double())) + + (Solution, Uinit, output) = ttb.tucker_als(T, 2, init=Uinit) + capsys.readouterr() + assert pytest.approx(output["fit"], 1) == 0 + + (Solution, Uinit, output) = ttb.tucker_als(T, 2, init="nvecs") + capsys.readouterr() + assert pytest.approx(output["fit"], 1) == 0 + + +@pytest.mark.indevelopment +def test_tucker_als_tensor_incorrect_init(capsys, sample_tensor): + (data, T) = sample_tensor + + non_list = np.array([1]) # TODO: Consider generalizing to iterable + with pytest.raises(ValueError): + _ = ttb.tucker_als(T, 2, init=non_list) + + bad_string = "foo_bar" + with pytest.raises(ValueError): + _ = ttb.tucker_als(T, 2, init=bad_string) + + wrong_length = [np.ones(T.shape)] * T.ndims + wrong_length.pop() + with pytest.raises(ValueError): + _ = ttb.tucker_als(T, 2, init=wrong_length) + + wrong_shape = [np.ones(5)] * T.ndims + with pytest.raises(ValueError): + _ = ttb.tucker_als(T, 2, init=wrong_shape) + + +@pytest.mark.indevelopment +def test_tucker_als_tensor_incorrect_steptol(capsys, sample_tensor): + (data, T) = sample_tensor + + non_scalar = np.array([1]) + with pytest.raises(ValueError): + _ = ttb.tucker_als(T, 2, stoptol=non_scalar) + + +@pytest.mark.indevelopment +def test_tucker_als_tensor_incorrect_maxiters(capsys, sample_tensor): + (data, T) = sample_tensor + + negative_value = -1 + with pytest.raises(ValueError): + _ = ttb.tucker_als(T, 2, maxiters=negative_value) + + non_scalar = np.array([1]) + with pytest.raises(ValueError): + _ = ttb.tucker_als(T, 2, maxiters=non_scalar) + + +@pytest.mark.indevelopment +def test_tucker_als_tensor_incorrect_printitn(capsys, sample_tensor): + (data, T) = sample_tensor + + non_scalar = np.array([1]) + with pytest.raises(ValueError): + _ = ttb.tucker_als(T, 2, printitn=non_scalar) + + +@pytest.mark.indevelopment +def test_tucker_als_tensor_incorrect_dimorder(capsys, sample_tensor): + (data, T) = sample_tensor + + non_list = np.array([1]) # TODO: Consider generalizing to iterable + with pytest.raises(ValueError): + _ = ttb.tucker_als(T, 2, dimorder=non_list) + + too_few_dims = list(range(len(T.shape))) + too_few_dims.pop() + with pytest.raises(ValueError): + _ = ttb.tucker_als(T, 2, dimorder=too_few_dims)