├── examples ├── README.rst ├── plot_simulated_nonnegative.py ├── plot_examining_different_number_of_components.py ├── plot_semiconductor_etch_analysis.py ├── plot_bikesharing.py └── plot_custom_penalty.py ├── docs ├── references.rst ├── autodoc │ ├── datasets.rst │ ├── coupled_matrices.rst │ ├── decomposition.rst │ ├── random.rst │ └── penalties.rst ├── _templates │ └── layout.html ├── notebooks │ └── index.rst ├── api.rst ├── Makefile ├── index.rst ├── make.bat ├── installation.rst ├── about_matcouply.rst ├── conf.py ├── contributing.rst ├── coupled_matrix_factorization.rst ├── optimization.rst └── references.bib ├── MANIFEST.in ├── figures └── readme_components.png ├── src └── matcouply │ ├── datasets │ └── bike.zip │ ├── conftest.py │ ├── __init__.py │ ├── testing │ ├── __init__.py │ ├── utils.py │ ├── fixtures.py │ └── admm_penalty.py │ ├── _utils.py │ ├── random.py │ ├── _doc_utils.py │ ├── _unimodal_regression.py │ └── data.py ├── setup.py ├── tests ├── __init__.py ├── utils.py ├── conftest.py ├── test_random.py ├── test_utils.py ├── test_unimodal_regression.py ├── test_data.py └── test_coupled_matrices.py ├── .bumpversion.cfg ├── requirements.txt ├── pyproject.toml ├── .readthedocs.yml ├── LICENSE ├── CITATION.cff ├── .github └── workflows │ ├── Tests.yml │ └── Build.yml ├── .gitignore ├── setup.cfg └── README.rst /examples/README.rst: -------------------------------------------------------------------------------- 1 | .. _examples: 2 | 3 | Gallery of examples 4 | =================== -------------------------------------------------------------------------------- /docs/references.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | References 3 | ========== 4 | 5 | .. bibliography:: 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | 4 | graft src/matcouply 5 | global-exclude *.pyc -------------------------------------------------------------------------------- /figures/readme_components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarieRoald/matcouply/HEAD/figures/readme_components.png -------------------------------------------------------------------------------- /src/matcouply/datasets/bike.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarieRoald/matcouply/HEAD/src/matcouply/datasets/bike.zip -------------------------------------------------------------------------------- /docs/autodoc/datasets.rst: -------------------------------------------------------------------------------- 1 | Example datasets 2 | ---------------- 3 | 4 | .. automodule:: matcouply.data 5 | :members: -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """The setup script.""" 4 | 5 | from setuptools import setup 6 | 7 | setup() 8 | -------------------------------------------------------------------------------- /docs/autodoc/coupled_matrices.rst: -------------------------------------------------------------------------------- 1 | Decomposition class 2 | ------------------- 3 | 4 | .. automodule:: matcouply.coupled_matrices 5 | :members: -------------------------------------------------------------------------------- /docs/autodoc/decomposition.rst: -------------------------------------------------------------------------------- 1 | Factorizing a dataset 2 | --------------------- 3 | 4 | .. automodule:: matcouply.decomposition 5 | :members: -------------------------------------------------------------------------------- /docs/autodoc/random.rst: -------------------------------------------------------------------------------- 1 | Generating random coupled matrix factorizations 2 | ----------------------------------------------- 3 | 4 | .. automodule:: matcouply.random 5 | :members: -------------------------------------------------------------------------------- /src/matcouply/conftest.py: -------------------------------------------------------------------------------- 1 | import tensorly as tl 2 | 3 | 4 | def pytest_ignore_collect(): 5 | if tl.get_backend() != "numpy": 6 | return True 7 | return False 8 | -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | 3 | {% block scripts %} 4 | 5 | {{ super() }} 6 | {% endblock %} -------------------------------------------------------------------------------- /docs/autodoc/penalties.rst: -------------------------------------------------------------------------------- 1 | .. _regularization: 2 | 3 | Regularization penalties and constraints 4 | ---------------------------------------- 5 | 6 | .. automodule:: matcouply.penalties 7 | :inherited-members: -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # MIT License: Copyright (c) 2022, Marie Roald. 3 | # See the LICENSE file in the root directory for full license text. 4 | 5 | """Unit test package for matcouply.""" 6 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.6 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.cfg] 7 | 8 | [bumpversion:file:src/matcouply/__init__.py] 9 | 10 | [bumpversion:file:docs/conf.py] 11 | -------------------------------------------------------------------------------- /docs/notebooks/index.rst: -------------------------------------------------------------------------------- 1 | Example notebooks 2 | ================= 3 | 4 | Here we list example notebooks that take longer to run compared to the :ref:`examples`. 5 | 6 | .. toctree:: 7 | :maxdepth: 1 8 | :glob: 9 | 10 | notebook_example_* -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | autodoc/coupled_matrices 9 | autodoc/decomposition 10 | autodoc/penalties 11 | autodoc/random 12 | autodoc/datasets 13 | 14 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | import tensorly as tl 5 | 6 | if tl.get_backend() == "numpy": 7 | RTOL_SCALE = 1 8 | else: 9 | RTOL_SCALE = 500 # Single precision backends need less strict tests 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | pytest 3 | pytest-cov 4 | pytest-randomly 5 | sphinx 6 | sphinx-rtd-theme 7 | sphinx-gallery 8 | sphinxcontrib-bibtex 9 | flake8 10 | black 11 | isort 12 | bump2version 13 | wheel 14 | numpy 15 | scipy 16 | tensorly 17 | autodocsumm 18 | matplotlib 19 | plotly 20 | pandas 21 | wordcloud 22 | tqdm 23 | tlviz 24 | condat_tv 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 120 7 | 8 | [tool.isort] 9 | known_third_party = [] 10 | profile = "black" 11 | 12 | [tool.pytest.ini_options] 13 | addopts = "--doctest-modules" 14 | testpaths = [ 15 | "src", 16 | "tests", 17 | ] 18 | -------------------------------------------------------------------------------- /src/matcouply/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # MIT License: Copyright (c) 2022, Marie Roald. 3 | # See the LICENSE file in the root directory for full license text. 4 | 5 | __author__ = """Marie Roald""" 6 | __email__ = "roald.marie@gmail.com" 7 | __version__ = "0.1.6" 8 | 9 | 10 | from . import coupled_matrices, data, decomposition, penalties, random 11 | -------------------------------------------------------------------------------- /src/matcouply/testing/__init__.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | import numpy as np 5 | import tensorly as tl 6 | 7 | from .admm_penalty import * 8 | 9 | 10 | def assert_allclose(actual, desired, *args, **kwargs): 11 | np.testing.assert_allclose(tl.to_numpy(actual), tl.to_numpy(desired), *args, **kwargs) 12 | -------------------------------------------------------------------------------- /src/matcouply/testing/utils.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | 5 | def random_length(rng, min=2, mean=5): 6 | """Generate a random dimension length. 7 | 8 | Use Poisson distribution since it is discrete and centered around the mean. 9 | """ 10 | if min >= mean: 11 | raise ValueError("Min must be less than mean.") 12 | return min + round(rng.poisson(mean - min)) 13 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | # Set the version of Python and other tools you might need 4 | build: 5 | os: ubuntu-20.04 6 | tools: 7 | python: "3.9" 8 | 9 | # Build documentation in the docs/ directory with Sphinx 10 | sphinx: 11 | configuration: docs/conf.py 12 | 13 | python: 14 | install: 15 | - path: . 16 | method: pip 17 | extra_requirements: [devel, examples, gpl, numba] 18 | - requirements: requirements.txt 19 | 20 | 21 | # Default 22 | formats: [] -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | import os 5 | 6 | # We set environment variables before we load the matcouply fixtures to avoid import-time side effects 7 | 8 | # Disable JIT for unit tests 9 | os.environ["NUMBA_DISABLE_JIT"] = "1" 10 | 11 | # Anaconda on Windows can have problems with multiple linked OpenMP dlls. This (unsafe) workaround makes it possible to run code with multiple linked OpenMP dlls. 12 | os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" 13 | 14 | pytest_plugins = ["matcouply.testing.fixtures"] 15 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. MatCoupLy documentation master file, created by 2 | sphinx-quickstart on Mon Nov 8 07:01:22 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to MatCoupLy's documentation! 7 | ===================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | about_matcouply 14 | installation 15 | coupled_matrix_factorization 16 | optimization 17 | auto_examples/index 18 | notebooks/index 19 | api 20 | contributing 21 | references 22 | 23 | 24 | 25 | Indices and tables 26 | ================== 27 | 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | * :ref:`search` 31 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022, Marie Roald 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | authors: 2 | - family-names: Roald 3 | given-names: Marie 4 | orcid: https://orcid.org/0000-0002-9571-8829 5 | cff-version: 1.2.0 6 | message: "If you use this software, please cite both the article that introduces the algorithm and the software itself." 7 | references: 8 | - authors: 9 | - family-names: Roald 10 | given-names: Marie 11 | - family-names: Schenker 12 | given-names: Carla 13 | - family-names: Calhoun 14 | given-names: Vince D. 15 | - family-names: Adali 16 | given-names: Tülay 17 | - family-names: Bro 18 | given-names: Rasmus 19 | - family-names: Cohen 20 | given-names: Jeremy E. 21 | - family-names: Acar 22 | given-names: Evrim 23 | title: "An AO-ADMM approach to constraining PARAFAC2 on all modes" 24 | type: article 25 | scope: "The article that introduces the PARFAC2 AO-ADMM algorithm" 26 | journal: "SIAM Journal on Mathematics of Data Science (SIMODS)" 27 | date-released: 2022-08-30 28 | doi: 10.1137/21M1450033 29 | pages: 1191-1222 30 | issn: 2577-0187 31 | volume: 4 32 | issue: 3 33 | title: "MatCoupLy: Learning coupled matrix factorizations with Python" 34 | version: 0.1.3 35 | doi: 10.5281/zenodo.6993910 36 | type: software 37 | date-released: 2022-08-14 -------------------------------------------------------------------------------- /.github/workflows/Tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | jobs: 5 | tests: 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest] 10 | backend: ['numpy', 'pytorch'] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: '3.10' 16 | - name: install dependencies 17 | run: | 18 | python -m pip install --upgrade pip wheel 19 | python -m pip install cython==0.29.36 20 | python -m pip install torch==1.13.1 21 | python -m pip install numba==0.58.1 22 | python -m pip install numpy==1.24.2 23 | python -m pip install condat_tv 24 | python -m pip install -e .[gpl,devel,data] 25 | - name: run all tests 26 | if: ${{ matrix.backend == 'numpy' }} 27 | run: | 28 | python3 -m pytest --cov=matcouply --cov-report=xml 29 | env: 30 | TENSORLY_BACKEND: ${{ matrix.backend }} 31 | - name: run tests (no doctests) 32 | if: ${{ matrix.backend != 'numpy' }} 33 | run: | 34 | RAISE_NO_TV=1 python3 -m pytest tests 35 | env: 36 | TENSORLY_BACKEND: ${{ matrix.backend }} 37 | - name: upload coverage to Codecov 38 | if: ${{ matrix.backend == 'numpy' && matrix.os == 'ubuntu-latest'}} 39 | uses: codecov/codecov-action@v2 40 | with: 41 | token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos 42 | verbose: true # optional (default = false) 43 | -------------------------------------------------------------------------------- /src/matcouply/_utils.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | import tensorly as tl 4 | 5 | 6 | def is_iterable(x): 7 | try: 8 | iter(x) 9 | except TypeError: 10 | return False 11 | else: 12 | return True 13 | 14 | 15 | def get_svd(svd): 16 | if not isinstance(tl.SVD_FUNS, dict): 17 | if hasattr(tl.tenalg.svd, svd): 18 | return getattr(tl.tenalg.svd, svd) 19 | elif svd in tl.SVD_FUNS: 20 | return tl.SVD_FUNS[svd] 21 | 22 | message = ( 23 | f"Got svd={svd}. However, for the current backend ({tl.get_backend()})," 24 | + f" the possible choices are {list(tl.SVD_FUNS)}" 25 | ) 26 | raise ValueError(message) 27 | 28 | 29 | def get_shapes(matrices): 30 | return [tl.shape(matrix) for matrix in matrices] 31 | 32 | 33 | def get_padded_tensor_shape(matrices): 34 | I = len(matrices) 35 | K = tl.shape(matrices[0])[1] 36 | 37 | # Compute max J and check that all matrices have the same number of columns 38 | J = -float("inf") 39 | for matrix in matrices: 40 | J_i, K_i = tl.shape(matrix) 41 | if K_i != K: 42 | raise ValueError("All matrices must have the same number of columns") 43 | 44 | J = max(J_i, J) 45 | 46 | return I, J, K 47 | 48 | 49 | def create_padded_tensor(matrices): 50 | tensor = tl.zeros(get_padded_tensor_shape(matrices), **tl.context(matrices[0])) 51 | for i, matrix in enumerate(matrices): 52 | length = tl.shape(matrix)[0] 53 | tensor = tl.index_update(tensor, tl.index[i, :length], matrix) 54 | return tensor 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | ignore/ 3 | auto_examples/ 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # dotenv 88 | .env 89 | 90 | # virtualenv 91 | .venv 92 | venv/ 93 | ENV/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = matcouply 3 | keywords=matcouply 4 | version = 0.1.6 5 | license = MIT 6 | description = Regularized coupled matrix factorisation with AO-ADMM 7 | long_description = file: README.rst 8 | author = Marie Roald 9 | author_email = roald.marie@gmail.com 10 | url=https://github.com/marieroald/matcouply 11 | classifiers= 12 | Development Status :: 2 - Pre-Alpha 13 | Intended Audience :: Developers 14 | License :: OSI Approved :: MIT License 15 | Natural Language :: English 16 | Programming Language :: Python :: 3.6 17 | Programming Language :: Python :: 3.7 18 | Programming Language :: Python :: 3.8 19 | Programming Language :: Python :: 3.9 20 | 21 | [options] 22 | packages = find: 23 | package_dir = 24 | =src 25 | include_package_data = True 26 | install_requires = 27 | numpy 28 | scipy 29 | tensorly 30 | python_requires = >=3.6 31 | 32 | [options.extras_require] 33 | numba= 34 | numpy >= 1.21, <1.25 35 | numba == 0.56.4 36 | gpl= 37 | condat-tv 38 | testing= 39 | pytest 40 | data= 41 | pandas 42 | tqdm 43 | requests 44 | devel= 45 | coverage 46 | pytest 47 | pytest-cov 48 | pytest-randomly 49 | flake8 50 | darglint 51 | black 52 | isort 53 | sphinx 54 | sphinx-rtd-theme 55 | sphinx-gallery 56 | sphinxcontrib-bibtex 57 | autodocsumm 58 | nbsphinx 59 | ipython 60 | bump2version 61 | wheel 62 | scikit-learn 63 | ipykernel 64 | 65 | examples= 66 | matplotlib 67 | plotly 68 | wordcloud 69 | tlviz 70 | 71 | [options.packages.find] 72 | where=src 73 | 74 | [flake8] 75 | ignore = E741, E203, W503 76 | exclude = docs 77 | max-line-length = 120 78 | docstring_style = numpy 79 | 80 | [coverage:run] 81 | omit = 82 | src/matcouply/_doc_utils.py 83 | src/matcouply/conftest.py 84 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installing MatCoupLy 2 | ==================== 3 | 4 | You can install MatCoupLy and its dependencies by running 5 | 6 | .. code:: bash 7 | 8 | pip install matcouply 9 | 10 | 11 | Optional functionality 12 | ---------------------- 13 | For loading data, we also need to install the Pandas, tqdm and Requests libraries. 14 | To install MatCoupLy with these additional dependencies, run 15 | 16 | .. code:: bash 17 | 18 | pip install matcouply[data] 19 | 20 | The ``testing`` module, which contains functionality for automatic unit test generation requires pytest, which you can get by running 21 | 22 | .. code:: bash 23 | 24 | pip install matcouply[testing] 25 | 26 | The unimodality constraint can use Numba to increase its efficiency with just in time compilation. 27 | However, this requires that compatible versions of NumPy and Numba are installed. To ensure this, 28 | you can install matcouply with 29 | 30 | .. code:: bash 31 | 32 | pip install matcouply[numba] 33 | 34 | which will install ``numpy >= 1.22.1`` and ``numba == 0.53.1`` (which are compatible). 35 | 36 | If you also want to use the GPL-lisenced functionality (currently only the TV penalty), then you also need to install 37 | ``condat_tv``, which is under a GPL lisence. To do this, run 38 | 39 | .. code:: bash 40 | 41 | pip install matcouply[gpl] 42 | 43 | The examples depends on some additional libraries (e.g. ``wordcloud`` and ``plotly``), and to install these 44 | dependencies as well, you can run 45 | 46 | .. code:: bash 47 | 48 | pip install matcouply[examples] 49 | 50 | 51 | To install multiple optional dependencies, list them all in the brackets, separated with a comma. For example 52 | 53 | .. code:: bash 54 | 55 | pip install matcouply[gpl,examples] 56 | 57 | will install both the GPL-lisenced functionality and the requirements for the examples. 58 | 59 | Finally, to install the latest development branch of MatCoupLy, run 60 | 61 | .. code:: bash 62 | 63 | git clone https://github.com/marieroald/matcouply.git 64 | cd matcouply 65 | pip install -e . 66 | 67 | Alternatively, to install all requirements (including the development requirements), ``pip install -e .[gpl,devel,examples,data]``. 68 | -------------------------------------------------------------------------------- /src/matcouply/testing/fixtures.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | import pytest 5 | from .utils import random_length 6 | 7 | 8 | @pytest.fixture 9 | def seed(pytestconfig): 10 | try: 11 | return pytestconfig.getoption("randomly_seed") 12 | except ValueError: 13 | return 1 14 | 15 | 16 | @pytest.fixture 17 | def rng(seed): 18 | import tensorly as tl 19 | 20 | return tl.check_random_state(seed) 21 | 22 | 23 | @pytest.fixture 24 | def random_ragged_shapes(rng): 25 | I = random_length(rng) 26 | K = random_length(rng) 27 | 28 | shapes = tuple((random_length(rng), K) for i in range(I)) 29 | return shapes 30 | 31 | 32 | @pytest.fixture 33 | def random_regular_shapes(rng): 34 | I = random_length(rng) 35 | J = random_length(rng) 36 | K = random_length(rng) 37 | shapes = tuple((J, K) for i in range(I)) 38 | return shapes 39 | 40 | 41 | @pytest.fixture 42 | def random_ragged_cmf( 43 | rng, random_ragged_shapes, 44 | ): 45 | from matcouply.random import random_coupled_matrices 46 | 47 | smallest_J = min(shape[0] for shape in random_ragged_shapes) 48 | rank = rng.randint(1, smallest_J + 1) 49 | cmf = random_coupled_matrices(random_ragged_shapes, rank, random_state=rng) 50 | return cmf, random_ragged_shapes, rank 51 | 52 | 53 | @pytest.fixture 54 | def random_rank5_ragged_cmf(rng): 55 | from matcouply.random import random_coupled_matrices 56 | 57 | I = random_length(rng) 58 | K = random_length(rng) 59 | random_ragged_shapes = tuple((random_length(rng, min=5, mean=7), K) for i in range(I)) 60 | rank = 5 61 | cmf = random_coupled_matrices(random_ragged_shapes, rank, random_state=rng) 62 | return cmf, random_ragged_shapes, rank 63 | 64 | 65 | @pytest.fixture 66 | def random_regular_cmf( 67 | rng, random_regular_shapes, 68 | ): 69 | from matcouply.random import random_coupled_matrices 70 | 71 | rank = rng.randint(1, random_regular_shapes[0][0] + 1) 72 | cmf = random_coupled_matrices(random_regular_shapes, rank, random_state=rng) 73 | return cmf, random_regular_shapes, rank 74 | 75 | 76 | @pytest.fixture 77 | def random_matrix(rng): 78 | import tensorly as tl 79 | 80 | return tl.tensor(rng.standard_normal((10, 3))) 81 | 82 | 83 | @pytest.fixture 84 | def random_matrices(rng): 85 | import tensorly as tl 86 | 87 | return [tl.tensor(rng.standard_normal((10, 3))) for i in range(5)] 88 | -------------------------------------------------------------------------------- /docs/about_matcouply.rst: -------------------------------------------------------------------------------- 1 | About MatCoupLy 2 | =============== 3 | 4 | :doc:`Coupled matrix factorization` methods are useful for uncovering shared and varying patterns from related measurements. 5 | For these patterns to be unique and interpretable, it is often necessary to impose additional constraints. 6 | A prominent example of such a constrained coupled matrix factorization is PARAFAC2 :cite:p:`harshman1972parafac2,kiers1999parafac2`, which uses a constant cross-product 7 | constraint to achieve uniqueness under mild conditions :cite:p:`harshman1996uniqueness`. 8 | Lately, interest in such methods have increased :cite:p:`ruckebusch2013multivariate,madsen2017quantifying,ren2020robust,roald2020tracing`, 9 | but there is a lack of software support, especially free open-source software. 10 | The availability of free accessible software 11 | (like `scikit-learn `_ and `PyTorch `_) has been important for the rapid progress in machine learning research. 12 | Recently, `TensorLy `_ :cite:p:`kossaifi2019tensorly` has provided open source software support to tensor decomposition models as well. 13 | However, there is not yet such a software for constrained coupled matrix factorization models. 14 | Therefore, given the growing interest in these methods for data mining, there is a pressing need for well-developed, 15 | easy to use and documented open-source implementations. 16 | 17 | MatCoupLy aims to meet that need by building on top of the popular TensorLy framework and implementing 18 | coupled matrix factorization with alternating optimization with the alternating direction method of multipliers 19 | (AO-ADMM) which supports flexible constraints :cite:p:`huang2016flexible,roald2021admm`. MatCoupLy implements a 20 | :doc:`selection of useful constraints` and provides an easy-to-use foundation to make it straightforward and 21 | painless for researchers to implement, 22 | test and use custom constraints. 23 | The MIT licence makes MatCoupLy suitable for academic and commercial purposes. 24 | 25 | **Why Python?** 26 | 27 | Python is a free open source programming language that’s easy to use for beginners and professionals alike. Lately, Python has emerged as a natural choice for machine learning and data analysis, 28 | and is used both for research and industrial applications. 29 | The increasing popularity of TensorLy for tensor learning in Python further establishes 30 | Python as a natural language for a coupled matrix factorization library. -------------------------------------------------------------------------------- /tests/test_random.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | import pytest 5 | import tensorly as tl 6 | from numpy.linalg import matrix_rank 7 | 8 | from matcouply.random import random_coupled_matrices 9 | from matcouply.testing import assert_allclose 10 | 11 | 12 | def test_random_coupled_matrices(): 13 | """test for random.random_coupled_matrices""" 14 | shapes = [(10, 11), (12, 11), (9, 11), (10, 11), (15, 11)] 15 | rank = 4 16 | 17 | # Check that assembled matrices have correct shapes and ranks 18 | coupled_matrices = random_coupled_matrices(shapes, rank, full=True) 19 | 20 | assert len(coupled_matrices) == len(shapes) 21 | for matrix, shape in zip(coupled_matrices, shapes): 22 | assert tl.shape(matrix) == shape 23 | array = tl.to_numpy(matrix) 24 | assert matrix_rank(array) == rank 25 | 26 | # Check that factor matrices have correct shape 27 | weights, (A, B_is, C) = random_coupled_matrices(shapes, rank, full=False) 28 | assert tl.shape(A) == (len(shapes), rank) 29 | assert all(tl.shape(B_i) == (J_i, rank) for B_i, (J_i, K) in zip(B_is, shapes)) 30 | assert tl.shape(C) == (shapes[0][1], rank) 31 | 32 | # Check that normalising B_is gives B_is with unit normed columns 33 | weights, (A, B_is, C) = random_coupled_matrices(shapes, rank, full=False, normalise_B=True, normalise_factors=False) 34 | for B_i in B_is: 35 | assert_allclose(tl.norm(B_i, axis=0), tl.ones(rank), rtol=1e-6) # rtol=1e-6 due to PyTorch single precision 36 | assert_allclose(weights, tl.ones(rank), rtol=1e-6) # rtol=1e-6 due to PyTorch single precision 37 | 38 | # Check that normalising all factors gives factor matrices with unit normed columns 39 | weights, (A, B_is, C) = random_coupled_matrices(shapes, rank, full=False, normalise_factors=True) 40 | assert_allclose(tl.norm(A, axis=0), tl.ones(rank), rtol=1e-6) # rtol=1e-6 due to PyTorch single precision 41 | for B_i in B_is: 42 | assert_allclose(tl.norm(B_i, axis=0), tl.ones(rank), rtol=1e-6) # rtol=1e-6 due to PyTorch single precision 43 | assert_allclose(tl.norm(C, axis=0), tl.ones(rank), rtol=1e-6) # rtol=1e-6 due to PyTorch single precision 44 | 45 | # Should fail when shapes have different value for the number of columns 46 | shapes = [(10, 10), (12, 11), (9, 11), (10, 11), (15, 11)] 47 | with pytest.raises(ValueError): 48 | random_coupled_matrices(shapes, rank) 49 | -------------------------------------------------------------------------------- /src/matcouply/random.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | import tensorly as tl 5 | 6 | from .coupled_matrices import CoupledMatrixFactorization 7 | 8 | 9 | def random_coupled_matrices( 10 | shapes, rank, full=False, random_state=None, normalise_factors=True, normalise_B=False, **context 11 | ): 12 | """Generate a random coupled matrix decomposition (with non-negative entries) 13 | 14 | Parameters 15 | ---------- 16 | shapes : tuple 17 | A tuple where each element represents the shape of a matrix 18 | represented by the coupled matrix decomposition model. The 19 | second element in each shape-tuple must be constant. 20 | rank : int or int list 21 | rank of the coupled matrix decomposition 22 | full : bool, optional, default is False 23 | if True, a list of dense matrices is returned otherwise, 24 | the decomposition is returned 25 | random_state : `np.random.RandomState` 26 | 27 | Examples 28 | -------- 29 | Here is an example of how to generate a random coupled matrix factorization 30 | 31 | >>> from matcouply.random import random_coupled_matrices 32 | >>> shapes = ((5, 10), (6, 10), (7, 10)) 33 | >>> cmf = random_coupled_matrices(shapes, rank=4) 34 | >>> print(cmf) 35 | (weights, factors) : rank-4 CoupledMatrixFactorization of shape ((5, 10), (6, 10), (7, 10)) 36 | """ 37 | rns = tl.check_random_state(random_state) 38 | if not all(shape[1] == shapes[0][1] for shape in shapes): 39 | raise ValueError("All matrices must have equal number of columns.") 40 | 41 | A = tl.tensor(rns.random_sample((len(shapes), rank), **context)) 42 | B_is = [tl.tensor(rns.random_sample((j_i, rank), **context)) for j_i, k in shapes] 43 | K = shapes[0][1] 44 | C = tl.tensor(rns.random_sample((K, rank), **context)) 45 | 46 | weights = tl.ones(rank, **context) 47 | 48 | if normalise_factors or normalise_B: 49 | B_i_norms = [tl.norm(B_i, axis=0) for B_i in B_is] 50 | B_is = [B_i / B_i_norm for B_i, B_i_norm in zip(B_is, B_i_norms)] 51 | B_i_norms = tl.stack(B_i_norms) 52 | 53 | A = A * B_i_norms 54 | if normalise_factors: 55 | A_norm = tl.norm(A, axis=0) 56 | A = A / A_norm 57 | 58 | C_norm = tl.norm(C, axis=0) 59 | C = C / C_norm 60 | weights = A_norm * C_norm 61 | 62 | cmf = CoupledMatrixFactorization((weights, (A, B_is, C))) 63 | if full: 64 | return cmf.to_matrices() 65 | else: 66 | return cmf 67 | -------------------------------------------------------------------------------- /.github/workflows/Build.yml: -------------------------------------------------------------------------------- 1 | # Action modified from Siddhant Goel's streaming-form-data project (MIT lisenced). Lisence text at bottom. 2 | 3 | name: build 4 | 5 | on: 6 | workflow_dispatch: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | build_source: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.8 20 | 21 | - name: Setup pip 22 | run: python -m pip install --upgrade pip 23 | 24 | - name: Build source distribution 25 | run: python setup.py sdist 26 | 27 | - uses: actions/upload-artifact@v2 28 | with: 29 | path: ./dist/*.tar.gz 30 | 31 | build_wheels: 32 | runs-on: ${{ matrix.os }} 33 | strategy: 34 | matrix: 35 | os: [ubuntu-latest] 36 | steps: 37 | - uses: actions/checkout@v2 38 | 39 | - uses: actions/setup-python@v2 40 | with: 41 | python-version: 3.8 42 | 43 | - name: Setup pip 44 | run: | 45 | python -m pip install --upgrade pip 46 | 47 | - name: Build wheel 48 | run: pip wheel . -w dist --no-deps 49 | 50 | - uses: actions/upload-artifact@v2 51 | with: 52 | path: ./dist/*.whl 53 | 54 | publish: 55 | runs-on: ubuntu-latest 56 | needs: [build_source, build_wheels] 57 | steps: 58 | - uses: actions/download-artifact@v2 59 | with: 60 | name: artifact 61 | path: dist 62 | 63 | - name: Publish to PyPI 64 | uses: pypa/gh-action-pypi-publish@master 65 | with: 66 | user: __token__ 67 | password: ${{ secrets.PYPI_TOKEN }} 68 | verbose: true 69 | 70 | 71 | # MIT License 72 | # 73 | # Copyright (c) 2017 - 2021 Siddhant Goel 74 | # 75 | # Permission is hereby granted, free of charge, to any person obtaining a copy 76 | # of this software and associated documentation files (the "Software"), to deal 77 | # in the Software without restriction, including without limitation the rights 78 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 79 | # copies of the Software, and to permit persons to whom the Software is 80 | # furnished to do so, subject to the following conditions: 81 | # 82 | # The above copyright notice and this permission notice shall be included in all 83 | # copies or substantial portions of the Software. 84 | # 85 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 86 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 87 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 88 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 89 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 90 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 91 | # SOFTWARE. 92 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | import plotly.io as pio 17 | 18 | pio.renderers.default = "sphinx_gallery" 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = "MatCoupLy" 23 | copyright = "2022, Marie Roald" 24 | author = "Marie Roald" 25 | 26 | # The full version, including alpha/beta/rc tags 27 | release = "0.1.6" 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "sphinx.ext.autodoc", 37 | "sphinx.ext.mathjax", 38 | "sphinx.ext.viewcode", 39 | "sphinx.ext.napoleon", 40 | "sphinxcontrib.bibtex", 41 | "autodocsumm", 42 | "nbsphinx", 43 | 'IPython.sphinxext.ipython_console_highlighting', 44 | "sphinx_gallery.gen_gallery", 45 | ] 46 | 47 | sphinx_gallery_conf = { 48 | "examples_dirs": "../examples", # path to your example scripts 49 | "gallery_dirs": "auto_examples", # path to where to save gallery generated output 50 | "remove_config_comments": True, 51 | } 52 | 53 | bibtex_bibfiles = ["references.bib"] 54 | autodoc_default_options = {"autosummary": True} 55 | 56 | # Add any paths that contain templates here, relative to this directory. 57 | templates_path = ["_templates"] 58 | 59 | # List of patterns, relative to source directory, that match files and 60 | # directories to ignore when looking for source files. 61 | # This pattern also affects html_static_path and html_extra_path. 62 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 63 | 64 | 65 | # -- Options for HTML output ------------------------------------------------- 66 | 67 | # The theme to use for HTML and HTML Help pages. See the documentation for 68 | # a list of builtin themes. 69 | # 70 | html_theme = "sphinx_rtd_theme" 71 | 72 | # Add any paths that contain custom static files (such as style sheets) here, 73 | # relative to this directory. They are copied after the builtin static files, 74 | # so a file named "default.css" will overwrite the builtin "default.css". 75 | html_static_path = ["_static"] 76 | 77 | 78 | # -- Options for the combination of nbsphinx and sphinx-gallery ------------------------ 79 | exclude_patterns = [ 80 | # exclude .py and .ipynb files in auto_examples generated by sphinx-gallery 81 | # this is to prevent sphinx from complaining about duplicate source files 82 | "auto_examples/*.ipynb", 83 | "auto_examples/*.py", 84 | ] 85 | nbsphinx_execute = "never" -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | Contribution guidelines 3 | ======================= 4 | 5 | All contributions to MatCoupLy are welcome! If you find a bug or an error in the documentation, we very much appreciate 6 | an issue or a pull request! 7 | 8 | ----------------- 9 | How to contribute 10 | ----------------- 11 | 12 | If you find a bug, or have an idea for a new feature, and you want to know if the contribution is relevant and not 13 | being worked on, you can open a `new issue `_. For major 14 | bugs, it can also be useful to include a `minimal, reproducible example `_, 15 | to make it as easy as possible to fix it. 16 | 17 | You can submit implementation of new features or bug/documentation fixes as a `pull-request `_. 18 | 19 | .. note:: 20 | 21 | MatCoupLy is compatible with the TensorLy API and so if you want to implement new functionality, you should read the `contribution guidelines for TensorLy `_ as well to ensure compatibility. 22 | 23 | 24 | ----------------------- 25 | Development environment 26 | ----------------------- 27 | 28 | We recommend using a virtual environment to develop MatCoupLy locally on your machine. For example, with anaconda 29 | 30 | .. code:: bash 31 | 32 | conda create -n matcouply python=3.8 anaconda 33 | 34 | Then, you can download the MatCoupLy source code and install it together with all the development dependencies 35 | 36 | .. code:: bash 37 | 38 | git clone https://github.com/marieroald/matcouply.git 39 | cd matcouply 40 | pip install -e .[gpl,devel,examples] 41 | 42 | This will install MatCoupLy in editable mode, so any change to the source code will be applied to the installed 43 | version too. 44 | 45 | ----------- 46 | Style guide 47 | ----------- 48 | 49 | MatCoupLy follows the `Black `_ style (with a maximum line length of 120 characters) and 50 | follows most of the `flake8 `_ guidelines (except E741, E203, W503). Most style errors 51 | will be fixed automatically in VSCode if you include the following lines in your `settings.json` file 52 | 53 | .. code:: json 54 | 55 | { 56 | "python.linting.flake8Enabled": true, 57 | "python.formatting.provider": "black", 58 | "editor.formatOnSave": false, 59 | "python.linting.flake8Args": [ 60 | "--max-line-length=120" 61 | ], 62 | "python.sortImports.args": [ 63 | "--profile", 64 | "black" 65 | ], 66 | "[python]": { 67 | "editor.codeActionsOnSave": { 68 | "source.organizeImports": false 69 | } 70 | } 71 | } 72 | 73 | ---------- 74 | Unit tests 75 | ---------- 76 | 77 | MatCoupLy aims to have a high test coverage, so any new code should also include tests. You can run the 78 | tests by running 79 | 80 | .. code:: bash 81 | 82 | pytest 83 | 84 | To also check the test coverage, run 85 | 86 | .. code:: bash 87 | 88 | pytest --cov=matcouply 89 | coverage html 90 | 91 | This will generate a HTML report where you can see all lines not covered by any tests. 92 | 93 | ------------- 94 | Documentation 95 | ------------- 96 | 97 | The documentation is generated using sphinx with automatic API documentation from the docstrings and 98 | MathJax for equations. We use `sphinx-gallery `_ 99 | to generate the example gallery. To expand this, simply add a new example script with a name matching 100 | the pattern `plot_*.py` in the `examples`-directory (make sure to follow the `sphinx-gallery style `_ 101 | for your scripts). 102 | 103 | To ensure that the documentation is up to date, we use `doctest `_, 104 | which will evaluate all examples and compare with the expected output. Examples should therefore be seeded. 105 | -------------------------------------------------------------------------------- /src/matcouply/_doc_utils.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | from abc import ABCMeta 5 | from functools import partial 6 | 7 | 8 | # mro function by Steven D'Aprano (2011). Released under an MIT lisence. https://code.activestate.com/recipes/577748-calculate-the-mro-of-a-class/ 9 | def mro(*bases): 10 | """Calculate the Method Resolution Order of bases using the C3 algorithm. 11 | 12 | Suppose you intended creating a class K with the given base classes. This 13 | function returns the MRO which K would have, *excluding* K itself (since 14 | it doesn't yet exist), as if you had actually created the class. 15 | 16 | Another way of looking at this, if you pass a single class K, this will 17 | return the linearization of K (the MRO of K, *including* itself). 18 | """ 19 | seqs = [list(C.__mro__) for C in bases] + [list(bases)] 20 | res = [] 21 | while True: 22 | non_empty = list(filter(None, seqs)) 23 | if not non_empty: 24 | # Nothing left to process, we're done. 25 | return tuple(res) 26 | for seq in non_empty: # Find merge candidates among seq heads. 27 | candidate = seq[0] 28 | not_head = [s for s in non_empty if candidate in s[1:]] 29 | if not_head: 30 | # Reject the candidate. 31 | candidate = None 32 | else: 33 | break 34 | if not candidate: 35 | raise TypeError("inconsistent hierarchy, no C3 MRO is possible") 36 | res.append(candidate) 37 | for seq in non_empty: 38 | # Remove candidate. 39 | if seq[0] == candidate: 40 | del seq[0] 41 | 42 | 43 | # Code below by nikratio (2013). Released under an MIT lisence. https://code.activestate.com/recipes/578587-inherit-method-docstrings-without-breaking-decorat/ 44 | # The code is modified slightly to allow for metaclasses 45 | # This definition is only used to assist static code analyzers 46 | def copy_ancestor_docstring(fn): 47 | """Copy docstring for method from superclass 48 | 49 | For this decorator to work, the class has to use the `InheritableDocstrings` 50 | metaclass. 51 | """ 52 | raise RuntimeError("Decorator can only be used in classes " "using the `InheritableDocstrings` metaclass") 53 | 54 | 55 | def _copy_ancestor_docstring(mro, fn): 56 | """Decorator to set docstring for *fn* from *mro*""" 57 | 58 | if fn.__doc__ is not None: 59 | raise RuntimeError("Function already has docstring") 60 | 61 | # Search for docstring in superclass 62 | for cls in mro: 63 | super_fn = getattr(cls, fn.__name__, None) 64 | if super_fn is None: 65 | continue 66 | fn.__doc__ = super_fn.__doc__ 67 | break 68 | else: 69 | raise RuntimeError("Can't inherit docstring for %s: method does not " "exist in superclass" % fn.__name__) 70 | 71 | return fn 72 | 73 | 74 | class InheritableDocstrings(ABCMeta): 75 | @classmethod 76 | def __prepare__(cls, name, bases, **kwds): 77 | classdict = super().__prepare__(name, bases, *kwds) 78 | 79 | # Inject decorators into class namespace 80 | classdict["copy_ancestor_docstring"] = partial(_copy_ancestor_docstring, mro(*bases)) 81 | 82 | return classdict 83 | 84 | def __new__(cls, name, bases, classdict): 85 | 86 | # Decorator may not exist in class dict if the class (metaclass 87 | # instance) was constructed with an explicit call to `type`. 88 | # (cf http://bugs.python.org/issue18334) 89 | if "copy_ancestor_docstring" in classdict: 90 | 91 | # Make sure that class definition hasn't messed with decorators 92 | copy_impl = getattr(classdict["copy_ancestor_docstring"], "func", None) 93 | if copy_impl is not _copy_ancestor_docstring: 94 | raise RuntimeError( 95 | "No copy_ancestor_docstring attribute may be created " 96 | "in classes using the InheritableDocstrings metaclass" 97 | ) 98 | 99 | # Delete decorators from class namespace 100 | del classdict["copy_ancestor_docstring"] 101 | 102 | return super().__new__(cls, name, bases, classdict) 103 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | from unittest.mock import patch 4 | 5 | import numpy as np 6 | import pytest 7 | import scipy 8 | import tensorly as tl 9 | from numpy.core.numeric import allclose 10 | from tensorly.testing import assert_array_equal 11 | 12 | from matcouply import _utils as utils 13 | 14 | 15 | def test_is_iterable(): 16 | # Test with some objects that are iterable 17 | class TestIterable: 18 | def __iter__(self): 19 | return self 20 | 21 | def __next__(self): 22 | return 1 23 | 24 | test_iterables = [ 25 | [1, 2, 3, 4], 26 | (1, 2, 3, 4), 27 | "iterablestring", 28 | {1: "value1", 2: "value2"}, 29 | range(5), 30 | TestIterable(), 31 | ] 32 | for test_iterable in test_iterables: 33 | assert utils.is_iterable(test_iterable) 34 | 35 | # Test with some objects that arent't iterable 36 | def test_function(x): 37 | return x 38 | 39 | class TestNotIterable: 40 | pass 41 | 42 | test_not_iterables = [1, 3.14, test_function, TestNotIterable()] 43 | for test_not_iterable in test_not_iterables: 44 | assert not utils.is_iterable(test_not_iterable) 45 | 46 | 47 | @pytest.mark.parametrize("svd", ["truncated_svd"]) 48 | def test_get_svd(rng, svd): 49 | X = rng.standard_normal(size=(10, 20)) 50 | U1, s1, Vh1 = scipy.linalg.svd(X) 51 | svd_fun = utils.get_svd(svd) 52 | U2, s2, Vh2 = svd_fun(tl.tensor(X)) 53 | U2, s2, Vh2 = tl.to_numpy(U2), tl.to_numpy(s2), tl.to_numpy(Vh2) 54 | 55 | # Check singular values are the same 56 | assert allclose(s1, s2) 57 | 58 | # Check that first 10 (rank) singular vectors are equal or flipped 59 | U1TU2 = U1.T @ U2 60 | Vh1Vh2T = Vh1 @ Vh2.T 61 | Vh1Vh2T = Vh1Vh2T[:10, :10] 62 | assert allclose(U1TU2, Vh1Vh2T, atol=1e-6) # low tolerance due to roundoff errors 63 | assert allclose(U1TU2 * Vh1Vh2T, np.eye(U1TU2.shape[0])) 64 | 65 | 66 | def test_get_svd_works_with_old_tensorly(): 67 | svds = {"TEST": "SVD"} 68 | with patch("matcouply._utils.tl.SVD_FUNS", svds) as mock: 69 | svd = utils.get_svd("TEST") 70 | 71 | assert svd == svds["TEST"] 72 | 73 | 74 | def test_get_svd_fails_with_invalid_svd_name(): 75 | with pytest.raises(ValueError): 76 | utils.get_svd("THIS_IS_NOT_A_VALID_SVD") 77 | 78 | 79 | def test_get_shapes(rng, random_ragged_cmf): 80 | # Small manual test 81 | matrices = [tl.zeros((1, 2)), tl.zeros((3, 4)), tl.zeros((5, 6))] 82 | matrix_shapes = utils.get_shapes(matrices) 83 | assert matrix_shapes[0] == (1, 2) 84 | assert matrix_shapes[1] == (3, 4) 85 | assert matrix_shapes[2] == (5, 6) 86 | assert len(matrix_shapes) == 3 87 | 88 | # Test on random ragged cmf 89 | cmf, shapes, rank = random_ragged_cmf 90 | matrix_shapes = utils.get_shapes(cmf.to_matrices()) 91 | for matrix_shape, shape in zip(matrix_shapes, shapes): 92 | assert matrix_shape == shape 93 | 94 | 95 | def test_get_padded_tensor_shape(rng, random_ragged_cmf): 96 | cmf, shapes, rank = random_ragged_cmf 97 | 98 | I = len(shapes) 99 | J = max([shape[0] for shape in shapes]) 100 | K = shapes[0][1] 101 | 102 | assert (I, J, K) == utils.get_padded_tensor_shape(cmf.to_matrices()) 103 | 104 | matrices_different_columns = [ 105 | tl.tensor(rng.standard_normal(size=(3, 4))), 106 | tl.tensor(rng.standard_normal(size=(5, 6))), 107 | tl.tensor(rng.standard_normal(size=(5, 6))), 108 | ] 109 | with pytest.raises(ValueError): 110 | utils.get_padded_tensor_shape(matrices_different_columns) 111 | 112 | 113 | def test_create_padded_tensor(rng, random_ragged_cmf): 114 | cmf, shapes, rank = random_ragged_cmf 115 | matrices = cmf.to_matrices() 116 | padded_tensor = utils.create_padded_tensor(matrices) 117 | 118 | I = len(shapes) 119 | J = max([shape[0] for shape in shapes]) 120 | K = shapes[0][1] 121 | 122 | assert (I, J, K) == tl.shape(padded_tensor) 123 | 124 | for i, (matrix, shape) in enumerate(zip(matrices, shapes)): 125 | assert_array_equal(padded_tensor[i, : shape[0], :], matrix) 126 | assert_array_equal(padded_tensor[i, shape[0] :, :], 0) 127 | -------------------------------------------------------------------------------- /tests/test_unimodal_regression.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | from math import ceil 5 | from unittest.mock import patch 6 | 7 | import numpy as np 8 | import pytest 9 | from pytest import approx 10 | from sklearn.isotonic import IsotonicRegression 11 | from tensorly.testing import assert_array_equal 12 | 13 | from matcouply._unimodal_regression import ( 14 | _compute_isotonic_from_index, 15 | _unimodal_regression, 16 | prefix_isotonic_regression, 17 | unimodal_regression, 18 | ) 19 | 20 | 21 | @pytest.mark.parametrize("shape", [5, 10, 50, 100, 500]) 22 | @pytest.mark.parametrize("std", [1, 2, 3]) 23 | @pytest.mark.parametrize("non_negativity", [False, True]) 24 | def test_isotonic_regression(shape, std, non_negativity): 25 | np.random.seed(0) 26 | x = np.arange(shape).astype(float) 27 | y = x + np.random.standard_normal(shape) * std 28 | prefix_regressor, errors = prefix_isotonic_regression(y, non_negativity=non_negativity) 29 | for i in range(1, shape + 1): 30 | prefix_yhat = _compute_isotonic_from_index(i, *prefix_regressor) 31 | sklearn_yhat = IsotonicRegression(y_min=[None, 0][non_negativity]).fit_transform(x[:i], y[:i]) 32 | 33 | np.testing.assert_allclose(prefix_yhat, sklearn_yhat) 34 | 35 | error = np.sum((prefix_yhat - y[:i]) ** 2) 36 | assert error == approx(errors[i]) 37 | 38 | if std == 0: 39 | np.testing.assert_allclose(prefix_yhat, y[:i]) 40 | if non_negativity: 41 | assert all(prefix_yhat >= 0) 42 | 43 | 44 | @pytest.mark.parametrize("shape", [5, 10, 50, 100, 500]) 45 | @pytest.mark.parametrize("std", [1, 2, 3]) 46 | @pytest.mark.parametrize("non_negativity", [False, True]) 47 | def test_unimodal_regression_error(shape, std, non_negativity): 48 | np.random.seed(0) 49 | 50 | # Test increasing x 51 | x = np.arange(shape).astype(float) 52 | y = x + np.random.standard_normal(shape) * std 53 | yhat, error = _unimodal_regression(y, non_negativity=non_negativity) 54 | 55 | if std == 0: 56 | np.testing.assert_allclose(yhat, y) 57 | 58 | assert np.sum((yhat - y) ** 2) == approx(error) 59 | if non_negativity: 60 | assert all(yhat >= 0) 61 | 62 | # Test decreasing x 63 | x = np.arange(shape)[::-1].astype(float) 64 | y = x + np.random.standard_normal(shape) * std 65 | yhat, error = _unimodal_regression(y, non_negativity=non_negativity) 66 | 67 | if std == 0: 68 | np.testing.assert_allclose(yhat, y) 69 | 70 | assert np.sum((yhat - y) ** 2) == approx(error) 71 | if non_negativity: 72 | assert all(yhat >= 0) 73 | 74 | # Test unimodal x 75 | x = np.arange(shape).astype(float) 76 | y = np.zeros_like(x) 77 | y[: shape // 2] = x[: shape // 2] + np.random.standard_normal(shape // 2) * std 78 | y[shape // 2 :] = x[: ceil(shape / 2)][::-1] + np.random.standard_normal(ceil(shape / 2)) * std 79 | yhat, error = _unimodal_regression(y, non_negativity=non_negativity) 80 | 81 | if std == 0: 82 | np.testing.assert_allclose(yhat, y) 83 | if non_negativity: 84 | assert all(yhat >= 0) 85 | 86 | assert np.sum((yhat - y) ** 2) == approx(error) 87 | 88 | 89 | @pytest.mark.parametrize("non_negativity", [True, False]) 90 | def test_unimodal_regression_with_ndim_arrays(non_negativity): 91 | y = np.arange(10) 92 | with patch("matcouply._unimodal_regression._unimodal_regression") as mock: 93 | unimodal_regression(y, non_negativity=non_negativity) 94 | mock.assert_called_once() 95 | mock.assert_called_with(y, non_negativity=non_negativity) 96 | 97 | Y = np.arange(15).reshape(5, 3) 98 | with patch("matcouply._unimodal_regression._unimodal_regression", return_value=(np.zeros(5),)) as mock: 99 | out = unimodal_regression(Y, non_negativity=non_negativity) 100 | assert out.shape == Y.shape 101 | mock.assert_called() 102 | for call, y in zip(mock.call_args_list, Y.T): 103 | args, kwargs = call 104 | assert_array_equal(args[0], y) 105 | assert kwargs["non_negativity"] == non_negativity 106 | 107 | T = np.arange(30).reshape(5, 3, 2) 108 | with patch("matcouply._unimodal_regression._unimodal_regression", return_value=(np.zeros(5),)) as mock: 109 | out = unimodal_regression(T, non_negativity=non_negativity) 110 | assert out.shape == T.shape 111 | mock.assert_called() 112 | for call, y in zip(mock.call_args_list, T.reshape(5, -1).T): 113 | args, kwargs = call 114 | assert_array_equal(args[0], y) 115 | assert kwargs["non_negativity"] == non_negativity 116 | -------------------------------------------------------------------------------- /src/matcouply/_unimodal_regression.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | import numpy as np 5 | 6 | try: 7 | from numba import jit 8 | except ImportError: # pragma: no cover 9 | 10 | def jit(*args, **kwargs): 11 | return lambda x: x 12 | 13 | 14 | @jit(nopython=True, cache=True) 15 | def _merge_intervals_inplace(merge_target, merger, sum_weighted_y, sum_weighted_y_sq, sum_weights, level_set): 16 | sum_weighted_y[merge_target] += sum_weighted_y[merger] 17 | sum_weighted_y_sq[merge_target] += sum_weighted_y_sq[merger] 18 | sum_weights[merge_target] += sum_weights[merger] 19 | 20 | # Update the level set 21 | level_set[merge_target] = sum_weighted_y[merge_target] / sum_weights[merge_target] 22 | 23 | 24 | @jit( 25 | nopython=True, cache=True, 26 | ) 27 | def prefix_isotonic_regression(y, weights=None, non_negativity=False): 28 | if weights is None: 29 | weights = np.ones_like(y) 30 | 31 | sumwy = weights * y 32 | sumwy2 = weights * y * y 33 | sumw = weights.copy() 34 | 35 | level_set = np.zeros_like(y) 36 | index_range = np.zeros_like(y, dtype=np.int32) 37 | error = np.zeros(y.shape[0] + 1) # +1 since error[0] is error of empty set 38 | 39 | level_set[0] = y[0] 40 | index_range[0] = 0 41 | num_samples = y.shape[0] 42 | 43 | if non_negativity: 44 | cumsumwy2 = np.cumsum(sumwy2) 45 | threshold = np.zeros(level_set.shape) 46 | if level_set[0] < 0: 47 | threshold[0] = True 48 | error[1] = cumsumwy2[0] 49 | 50 | for i in range(1, num_samples): 51 | level_set[i] = y[i] 52 | index_range[i] = i 53 | while level_set[i] <= level_set[index_range[i] - 1] and index_range[i] != 0: 54 | _merge_intervals_inplace(i, index_range[i] - 1, sumwy, sumwy2, sumw, level_set) 55 | index_range[i] = index_range[index_range[i] - 1] 56 | 57 | levelerror = sumwy2[i] - (sumwy[i] ** 2 / sumw[i]) 58 | if non_negativity and level_set[i] < 0: 59 | threshold[i] = True 60 | error[i + 1] = cumsumwy2[i] 61 | else: 62 | error[i + 1] = levelerror + error[index_range[i]] 63 | 64 | if non_negativity: 65 | for i in range(len(level_set)): 66 | if threshold[i]: 67 | level_set[i] = 0 68 | 69 | return (level_set, index_range), error 70 | 71 | 72 | @jit(nopython=True, cache=True) 73 | def _compute_isotonic_from_index(end_index, level_set, index_range): 74 | idx = end_index - 1 75 | y_iso = np.empty_like(level_set[: idx + 1]) * np.nan 76 | 77 | while idx >= 0: 78 | y_iso[index_range[idx] : idx + 1] = level_set[idx] 79 | idx = index_range[idx] - 1 80 | 81 | return y_iso 82 | 83 | 84 | def _get_best_unimodality_index(error_left, error_right): 85 | best_error = error_right[-1] 86 | best_idx = 0 87 | for i in range(error_left.shape[0]): 88 | error = error_left[i] + error_right[len(error_left) - i - 1] 89 | if error < best_error: 90 | best_error = error 91 | best_idx = i 92 | return best_idx, best_error 93 | 94 | 95 | def _unimodal_regression(y, non_negativity): 96 | iso_left, error_left = prefix_isotonic_regression(y, non_negativity=non_negativity) 97 | iso_right, error_right = prefix_isotonic_regression(y[::-1], non_negativity=non_negativity) 98 | 99 | num_samples = y.shape[0] 100 | best_idx, error = _get_best_unimodality_index(error_left, error_right) 101 | y_iso_left = _compute_isotonic_from_index(best_idx, iso_left[0], iso_left[1]) 102 | y_iso_right = _compute_isotonic_from_index(num_samples - best_idx, iso_right[0], iso_right[1]) 103 | 104 | return np.concatenate([y_iso_left, y_iso_right[::-1]]), error 105 | 106 | 107 | def unimodal_regression(y, non_negativity=False): 108 | r"""Compute the unimodal vector, :math:`\mathbf{u}` that minimizes :math:`\|\mathbf{y} - \mathbf{u}\|`. 109 | 110 | The unimodal regression problem is a problem on the form 111 | 112 | .. math:: \min_{\mathbf{u}} \|\mathbf{y} - \mathbf{u}\|\\ 113 | \text{s.t.} u_1 \leq u_2 \leq ... \leq u_{t-1} \leq u_t \geq u_{t+1} \geq ... \geq u_{n-1} \geq u_n, 114 | 115 | for some index :math:`1 \leq t \leq n`. That is, it projects the input vector :math:`\mathbf{y}` onto the 116 | set of unimodal vectors. The *unimodal regression via prefix isotonic regression* algorithm :cite:p:`stout2008unimodal` 117 | is used to efficiently solve the unimodal regression problem. 118 | 119 | Parameters 120 | ---------- 121 | y : ndarray 122 | Vector to project. If it is an N-dimensional array, then it projects the first-mode fibers 123 | (e.g. columns in the case of matrices). 124 | non_negativity : bool 125 | If True, then non-negativity is imposed 126 | 127 | Returns 128 | ------- 129 | ndarray 130 | The unimodal vector or array of unimodal vectors 131 | """ 132 | y = np.asarray(y) 133 | if y.ndim == 1: 134 | return _unimodal_regression(y, non_negativity=non_negativity)[0] 135 | else: 136 | y2 = np.ascontiguousarray(y) 137 | y2 = y2.reshape(y2.shape[0], -1) 138 | unfolded_output = np.stack( 139 | [_unimodal_regression(y2[:, r], non_negativity=non_negativity)[0] for r in range(y2.shape[1])], axis=1, 140 | ) 141 | return unfolded_output.reshape(y.shape) 142 | -------------------------------------------------------------------------------- /examples/plot_simulated_nonnegative.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple example with simulated non-negative components. 3 | ------------------------------------------------------ 4 | """ 5 | 6 | import matplotlib.pyplot as plt 7 | import numpy as np 8 | import tensorly as tl 9 | from tlviz.factor_tools import factor_match_score 10 | 11 | import matcouply.decomposition as decomposition 12 | from matcouply.coupled_matrices import CoupledMatrixFactorization 13 | 14 | ############################################################################### 15 | # Setup 16 | # ^^^^^ 17 | 18 | I, J, K = 10, 15, 20 19 | rank = 3 20 | noise_level = 0.2 21 | rng = np.random.default_rng(0) 22 | 23 | 24 | def truncated_normal(size): 25 | x = rng.standard_normal(size=size) 26 | x[x < 0] = 0 27 | return tl.tensor(x) 28 | 29 | 30 | def normalize(x): 31 | return x / tl.sqrt(tl.sum(x**2, axis=0, keepdims=True)) 32 | 33 | 34 | ############################################################################### 35 | # Generate simulated data that follows the PARAFAC2 constraint 36 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 37 | A = rng.uniform(size=(I, rank)) + 0.1 # Add 0.1 to ensure that there is signal for all components for all slices 38 | A = tl.tensor(A) 39 | 40 | B_blueprint = truncated_normal(size=(J, rank)) 41 | B_is = [np.roll(B_blueprint, i, axis=0) for i in range(I)] 42 | B_is = [tl.tensor(B_i) for B_i in B_is] 43 | 44 | C = truncated_normal(size=(K, rank)) 45 | C = tl.tensor(C) 46 | 47 | ############################################################################### 48 | # Plot the simulated components 49 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 50 | 51 | fig, axes = plt.subplots(2, 3, tight_layout=True) 52 | 53 | axes[0, 0].plot(normalize(A)) 54 | axes[0, 0].set_title("$\\mathbf{A}$") 55 | 56 | axes[0, 1].plot(normalize(C)) 57 | axes[0, 1].set_title("$\\mathbf{C}$") 58 | 59 | axes[0, 2].axis("off") 60 | 61 | axes[1, 0].plot(normalize(B_is[0])) 62 | axes[1, 0].set_title("$\\mathbf{B}_0$") 63 | 64 | axes[1, 1].plot(normalize(B_is[I // 2])) 65 | axes[1, 1].set_title(f"$\\mathbf{{B}}_{{{I//2}}}$") 66 | 67 | axes[1, 2].plot(normalize(B_is[-1])) 68 | axes[1, 2].set_title(f"$\\mathbf{{B}}_{{{I-1}}}$") 69 | 70 | plt.show() 71 | 72 | 73 | ############################################################################### 74 | # Create the coupled matrix factorization, simulated data matrices and add noise 75 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 76 | 77 | cmf = CoupledMatrixFactorization((None, (A, B_is, C))) 78 | matrices = cmf.to_matrices() 79 | noise = [tl.tensor(rng.uniform(size=M.shape)) for M in matrices] 80 | noisy_matrices = [M + N * noise_level * tl.norm(M) / tl.norm(N) for M, N in zip(matrices, noise)] 81 | 82 | ############################################################################### 83 | # Fit a non-negative PARAFAC2 model to the noisy data 84 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 85 | 86 | lowest_error = float("inf") 87 | for init in range(5): 88 | print("Init:", init) 89 | out = decomposition.parafac2_aoadmm( 90 | noisy_matrices, rank, n_iter_max=1000, non_negative=True, return_errors=True, random_state=init 91 | ) 92 | if out[1].regularized_loss[-1] < lowest_error and out[1].satisfied_stopping_condition: 93 | out_cmf, diagnostics = out 94 | lowest_error = diagnostics.rec_errors[-1] 95 | 96 | print("=" * 50) 97 | print(f"Final reconstruction error: {lowest_error:.3f}") 98 | print(f"Feasibility gap for A: {diagnostics.feasibility_gaps[-1][0]}") 99 | print(f"Feasibility gap for B_is: {diagnostics.feasibility_gaps[-1][1]}") 100 | print(f"Feasibility gap for C: {diagnostics.feasibility_gaps[-1][2]}") 101 | 102 | ############################################################################### 103 | # Compute factor match score to measure the accuracy of the recovered components 104 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 105 | 106 | 107 | def get_stacked_CP_tensor(cmf): 108 | weights, factors = cmf 109 | A, B_is, C = factors 110 | 111 | stacked_cp_tensor = (weights, (A, np.concatenate(B_is, axis=0), C)) 112 | return stacked_cp_tensor 113 | 114 | 115 | fms, permutation = factor_match_score( 116 | get_stacked_CP_tensor(cmf), get_stacked_CP_tensor(out_cmf), consider_weights=False, return_permutation=True 117 | ) 118 | print(f"Factor match score: {fms}") 119 | 120 | ############################################################################### 121 | # Plot the loss logg 122 | # ^^^^^^^^^^^^^^^^^^ 123 | 124 | fig, ax = plt.subplots(tight_layout=True) 125 | ax.semilogy(diagnostics.rec_errors) 126 | plt.xlabel("Iteration") 127 | plt.ylabel("Relative normed error (2-norm)") 128 | plt.show() 129 | 130 | ############################################################################### 131 | # Plot the recovered components 132 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 133 | 134 | out_weights, (out_A, out_B_is, out_C) = out_cmf 135 | out_A = out_A[:, permutation] 136 | out_B_is = [out_B_i[:, permutation] for out_B_i in out_B_is] 137 | out_C = out_C[:, permutation] 138 | 139 | fig, axes = plt.subplots(2, 3, tight_layout=True) 140 | 141 | axes[0, 0].plot(normalize(out_A)) 142 | axes[0, 0].set_title("$\\mathbf{A}$") 143 | 144 | axes[0, 1].plot(normalize(out_C)) 145 | axes[0, 1].set_title("$\\mathbf{C}$") 146 | 147 | axes[0, 2].axis("off") 148 | 149 | axes[1, 0].plot(normalize(out_B_is[0])) 150 | axes[1, 0].set_title("$\\mathbf{B}_0$") 151 | 152 | axes[1, 1].plot(normalize(out_B_is[I // 2])) 153 | axes[1, 1].set_title(f"$\\mathbf{{B}}_{{{I//2}}}$") 154 | 155 | axes[1, 2].plot(normalize(out_B_is[-1])) 156 | axes[1, 2].set_title(f"$\\mathbf{{B}}_{{{I-1}}}$") 157 | 158 | plt.show() 159 | -------------------------------------------------------------------------------- /examples/plot_examining_different_number_of_components.py: -------------------------------------------------------------------------------- 1 | """ 2 | Examining effect of adding more components 3 | ------------------------------------------ 4 | """ 5 | 6 | import matplotlib.pyplot as plt 7 | import numpy as np 8 | import tensorly as tl 9 | 10 | import matcouply.decomposition as decomposition 11 | from matcouply.coupled_matrices import CoupledMatrixFactorization 12 | 13 | ############################################################################### 14 | # Setup 15 | # ^^^^^ 16 | I, J, K = 5, 10, 15 17 | rank = 4 18 | noise_level = 0.1 19 | rng = np.random.default_rng(0) 20 | 21 | 22 | def truncated_normal(size): 23 | x = rng.standard_normal(size=size) 24 | x[x < 0] = 0 25 | return tl.tensor(x) 26 | 27 | 28 | def normalize(x): 29 | return x / tl.sqrt(tl.sum(x**2, axis=0, keepdims=True)) 30 | 31 | 32 | ############################################################################### 33 | # Generate simulated PARFAC2 factor matrices where the true number of components (``rank``) is known 34 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 35 | A = rng.uniform(size=(I, rank)) + 0.1 # Add 0.1 to ensure that there is signal for all components for all slices 36 | A = tl.tensor(A) 37 | 38 | B_blueprint = truncated_normal(size=(J, rank)) 39 | B_is = [np.roll(B_blueprint, i, axis=0) for i in range(I)] 40 | B_is = [tl.tensor(B_i) for B_i in B_is] 41 | 42 | C = rng.standard_normal(size=(K, rank)) 43 | C = tl.tensor(C) 44 | 45 | cmf = CoupledMatrixFactorization((None, (A, B_is, C))) 46 | 47 | ############################################################################### 48 | # Create data marices from the decomposition and add noise 49 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 50 | 51 | matrices = cmf.to_matrices() 52 | noise = [tl.tensor(rng.uniform(size=M.shape)) for M in matrices] 53 | noisy_matrices = [M + N * noise_level * tl.norm(M) / tl.norm(N) for M, N in zip(matrices, noise)] 54 | 55 | 56 | ############################################################################### 57 | # Fit PARAFAC2 models with different number of components to the noisy data 58 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 59 | 60 | fit_scores = [] 61 | B_gaps = [] 62 | A_gaps = [] 63 | for num_components in range(2, 7): 64 | print(num_components, "components") 65 | lowest_error = float("inf") 66 | for init in range(3): # Here we just do three initialisations, for complex data, you may want to do more 67 | cmf, diagnostics = decomposition.parafac2_aoadmm( 68 | noisy_matrices, 69 | num_components, 70 | n_iter_max=1000, 71 | non_negative=[True, False, False], 72 | return_errors=True, 73 | random_state=init, 74 | ) 75 | if diagnostics.regularized_loss[-1] < lowest_error: 76 | selected_cmf = cmf 77 | selected_diagnostics = diagnostics 78 | lowest_error = diagnostics.regularized_loss[-1] 79 | 80 | fit_score = 1 - lowest_error 81 | fit_scores.append(fit_score) 82 | B_gaps.append(selected_diagnostics.feasibility_gaps[-1][1][0]) 83 | A_gaps.append(selected_diagnostics.feasibility_gaps[-1][0][0]) 84 | 85 | 86 | ############################################################################### 87 | # Create scree plots of fit score and feasability gaps for different number of components 88 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 89 | 90 | fig, axes = plt.subplots(3, 1, tight_layout=True, sharex=True) 91 | axes[0].set_title("Fit score") 92 | axes[0].plot(range(2, 7), fit_scores) 93 | axes[1].set_title("Feasibility gap for A (NN constraint)") 94 | axes[1].plot(range(2, 7), A_gaps) 95 | axes[2].set_title("Feasibility gap for B_is (PF2 constraint)") 96 | axes[2].plot(range(2, 7), B_gaps) 97 | axes[2].set_xlabel("No. components") 98 | axes[2].set_xticks(range(2, 7)) 99 | plt.show() 100 | 101 | ############################################################################### 102 | # The top plot above shows that adding more components improves the fit in the beginning, 103 | # but then the improvement lessens as we reach the "true" number of components. 104 | # We know that the correct number of components is four for this simulated data, 105 | # but if you work with a real dataset, you don't always know the "true" number. 106 | # So then, examining such a plot can help you choose an appropriate number of components. 107 | # The slope of the line plot decreases gradually, so it can be challenging to precisely 108 | # determine the correct number of components, but you can make out that 4 and 5 are 109 | # good candidates. For real data, the line plot might be even more challenging to read, 110 | # and you may find several candidates that you should then examine further. 111 | # Note that the fit score is just one metric and will not give you the entire picture, 112 | # so you should also examine other metrics and, most importantly, look at what makes 113 | # sense for your data when choosing a suitable model. 114 | # 115 | # Another important metric to consider when evaluating your models is the feasibility gap. 116 | # If the feasibility gap is too large, then the model doesn't satisfy the constraints. Here, 117 | # we see that the A-matrix was completely non-negative for all models, while there was a 118 | # slight feasibility gap for the B_i-matrices. This means that the B_i-matrices only 119 | # approximately satisfied the PARAFAC2 constraint (and this will often be the case). The 120 | # four-component model had the lowest feasibility gap, so it was the model that best followed 121 | # the PARAFAC2 constraint. This could be a clue that four is an appropriate number of components. 122 | # Still, we see that the feasibility gap was on the order of :math:`10^{-5}` for all of the 123 | # models, which means that the approximation is very good for all of them. 124 | -------------------------------------------------------------------------------- /tests/test_data.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | from unittest.mock import MagicMock, patch 5 | 6 | import numpy as np 7 | import pytest 8 | import tensorly as tl 9 | from tensorly.testing import assert_array_equal 10 | 11 | from matcouply import data 12 | from matcouply.coupled_matrices import CoupledMatrixFactorization 13 | from matcouply.decomposition import _cmf_reconstruction_error 14 | 15 | from .utils import RTOL_SCALE 16 | 17 | 18 | def test_get_simple_simulated_data(): 19 | simulated_data, cmf = data.get_simple_simulated_data() 20 | assert isinstance(simulated_data, list) 21 | assert isinstance(cmf, CoupledMatrixFactorization) 22 | 23 | # Check that same random state gets same data 24 | simulated_data1, cmf1 = data.get_simple_simulated_data(random_state=1) 25 | simulated_data2, cmf2 = data.get_simple_simulated_data(random_state=1) 26 | 27 | for X1, X2 in zip(simulated_data1, simulated_data2): 28 | assert_array_equal(X1, X2) 29 | 30 | assert_array_equal(cmf1[1][0], cmf2[1][0]) # check A 31 | assert_array_equal(cmf1[1][2], cmf2[1][2]) # check C 32 | 33 | for B_i1, B_i2 in zip(cmf1[1][1], cmf2[1][1]): 34 | assert_array_equal(B_i1, B_i2) 35 | 36 | # Check that different random state gets different data 37 | simulated_data3, cmf3 = data.get_simple_simulated_data(random_state=2) 38 | 39 | assert not np.all(tl.to_numpy(cmf1[1][0]) == tl.to_numpy(cmf3[1][0])) 40 | assert not np.all(tl.to_numpy(cmf1[1][2]) == tl.to_numpy(cmf3[1][2])) 41 | 42 | # Check that noise level is correct 43 | simulated_data_noise_02, cmf_noise_02 = data.get_simple_simulated_data(noise_level=0.2, random_state=2) 44 | data_strength = tl.norm(cmf_noise_02.to_tensor()) 45 | error = _cmf_reconstruction_error(simulated_data_noise_02, cmf_noise_02) / data_strength 46 | assert error == pytest.approx(0.2, rel=1e-6*RTOL_SCALE) 47 | 48 | 49 | def test_get_bike_data(): 50 | bike_data = data.get_bike_data() 51 | 52 | # Check that data has correct keys 53 | cities = ["oslo", "trondheim", "bergen"] 54 | 55 | # Check that data has correct columns 56 | stations = set(bike_data["station_metadata"].index) 57 | for city in cities: 58 | assert city in bike_data 59 | assert bike_data[city].shape[1] == bike_data["oslo"].shape[1] 60 | assert all(bike_data[city].columns == bike_data["oslo"].columns) 61 | 62 | for station in bike_data[city].index: 63 | assert station in stations 64 | 65 | 66 | def test_get_etch_data_prints_citation(capfd): 67 | data.get_semiconductor_etch_raw_data() 68 | assert "Wise et al. (1999) - J. Chemom. 13(3‐4)" in capfd.readouterr()[0] 69 | 70 | data.get_semiconductor_etch_machine_data() 71 | assert "Wise et al. (1999) - J. Chemom. 13(3‐4)" in capfd.readouterr()[0] 72 | 73 | 74 | @pytest.fixture 75 | def patch_dataset_parent(tmp_path): 76 | old_path = data.DATASET_PARENT 77 | old_download_parent = data.DOWNLOADED_PARENT 78 | data.DATASET_PARENT = tmp_path 79 | data.DOWNLOADED_PARENT = tmp_path / "downloads" 80 | yield old_path 81 | data.DATASET_PARENT = old_path 82 | data.DOWNLOADED_PARENT = old_download_parent 83 | 84 | 85 | class MockSuccessfullRequest(MagicMock): 86 | status_code = 200 87 | content = b"" 88 | 89 | 90 | class MockUnsuccessfullRequest(MagicMock): 91 | status_code = 404 92 | content = b"" 93 | 94 | 95 | @patch("matcouply.data.requests.get", return_value=MockSuccessfullRequest()) 96 | @patch("matcouply.data.loadmat") 97 | @patch("matcouply.data.BytesIO") 98 | def test_get_etch_raw_data_downloads_correctly(bytesio_mock, loadmat_mock, get_mock, patch_dataset_parent): 99 | with pytest.raises(RuntimeError): 100 | data.get_semiconductor_etch_raw_data(download_data=False) 101 | 102 | # Check that it works once when we download but don't save 103 | data.get_semiconductor_etch_raw_data(save_data=False) 104 | 105 | # Check that it wasn't saved when save_data=False 106 | with pytest.raises(RuntimeError): 107 | data.get_semiconductor_etch_raw_data(download_data=False) 108 | 109 | # Check that it raises error with unsuccessful download 110 | get_mock.return_value = MockUnsuccessfullRequest() 111 | with pytest.raises(RuntimeError): 112 | data.get_semiconductor_etch_raw_data() 113 | 114 | # Check that it works once the data is downloaded 115 | get_mock.return_value = MockSuccessfullRequest() 116 | data.get_semiconductor_etch_raw_data() 117 | data.get_semiconductor_etch_raw_data(download_data=False) 118 | 119 | 120 | def test_get_semiconductor_etch_raw_data(): 121 | raw_data = data.get_semiconductor_etch_raw_data() 122 | for file in ["MACHINE_Data.mat", "OES_DATA.mat", "RFM_DATA.mat"]: 123 | assert file in raw_data 124 | 125 | 126 | def test_get_semiconductor_machine_data(): 127 | train_data, train_metadata, test_data, test_metadata = data.get_semiconductor_etch_machine_data() 128 | 129 | train_sample_names = set(train_data) 130 | test_sample_names = set(test_data) 131 | train_metadata_names = set(train_metadata) 132 | test_metadata_names = set(test_metadata) 133 | 134 | assert len(train_sample_names.intersection(test_sample_names)) == 0 135 | assert len(train_sample_names.intersection(train_metadata_names)) == len(train_sample_names) 136 | assert len(test_sample_names.intersection(test_metadata_names)) == len(test_sample_names) 137 | 138 | # Check that data has correct columns 139 | one_train_name = next(iter(train_sample_names)) 140 | for name in train_data: 141 | assert train_data[name].shape[1] == train_data[one_train_name].shape[1] 142 | assert train_metadata[name].shape[0] == train_data[name].shape[0] 143 | assert all(train_data[name].columns == train_data[one_train_name].columns) 144 | 145 | one_test_name = next(iter(test_sample_names)) 146 | for name in test_data: 147 | assert test_data[name].shape[1] == test_data[one_test_name].shape[1] 148 | assert test_metadata[name].shape[0] == test_data[name].shape[0] 149 | assert all(test_data[name].columns == test_data[one_test_name].columns) 150 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | MatCoupLy 3 | ========= 4 | *Learning coupled matrix factorizations with Python* 5 | 6 | .. image:: https://github.com/MarieRoald/matcouply/actions/workflows/Tests.yml/badge.svg 7 | :target: https://github.com/MarieRoald/matcouply/actions/workflows/Tests.yml 8 | :alt: Tests 9 | 10 | .. image:: https://codecov.io/gh/MarieRoald/matcouply/branch/main/graph/badge.svg?token=GDCXEF2MGE 11 | :target: https://codecov.io/gh/MarieRoald/matcouply 12 | :alt: Coverage 13 | 14 | .. image:: https://readthedocs.org/projects/matcouply/badge/?version=latest 15 | :target: https://matcouply.readthedocs.io/en/latest/?badge=latest 16 | :alt: Documentation Status 17 | 18 | .. image:: https://zenodo.org/badge/402865945.svg 19 | :target: https://zenodo.org/badge/latestdoi/402865945 20 | 21 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 22 | :target: https://github.com/psf/black 23 | 24 | 25 | MatCoupLy is a Python library for learning coupled matrix factorizations with flexible constraints and regularization. 26 | For a quick introduction to coupled matrix factorization and PARAFAC2 see the `online documentation `_. 27 | 28 | Installation 29 | ------------ 30 | 31 | To install MatCoupLy and all MIT-compatible dependencies from PyPI, you can run 32 | 33 | .. code:: 34 | 35 | pip install matcouply 36 | 37 | If you also want to enable total variation regularization, you need to install all components, which comes with a GPL-v3 lisence 38 | 39 | .. code:: 40 | 41 | pip install matcouply[gpl] 42 | 43 | About 44 | ----- 45 | 46 | .. image:: docs/figures/CMF_multiblock.svg 47 | :alt: Illustration of a coupled matrix factorization 48 | 49 | MatCoupLy is a Python library that adds support for coupled matrix factorization in 50 | `TensorLy `_. For optimization, MatCoupLy uses 51 | alternating updates with the alternating direction method of multipliers (AO-ADMM), 52 | which allows you to fit coupled matrix factorization (and PARAFAC2) models with flexible 53 | constraints in any mode of your data [1, 2]. Currently, MatCoupLy supports the NumPy and 54 | PyTorch backends of TensorLy. 55 | 56 | 57 | Example 58 | ------- 59 | 60 | Below is a simulated example, where a set of 15 non-negative coupled matrices are generated and 61 | decomposed using a non-negative PARAFAC2 factorization with an L1 penalty on **C**, constraining 62 | the maximum norm of the **A** and **Bᵢ** matrices and unimodality constraints on the component 63 | vectors in the **Bᵢ** matrices. For more examples, see the `Gallery of examples `_ 64 | in the `online documentation `_. 65 | 66 | 67 | .. code:: python 68 | 69 | import matplotlib.pyplot as plt 70 | import numpy as np 71 | 72 | from matcouply.data import get_simple_simulated_data 73 | from matcouply.decomposition import cmf_aoadmm 74 | 75 | noisy_matrices, cmf = get_simple_simulated_data(noise_level=0.2, random_state=1) 76 | rank = cmf.rank 77 | weights, (A, B_is, C) = cmf 78 | 79 | # Decompose the dataset 80 | estimated_cmf = cmf_aoadmm( 81 | noisy_matrices, 82 | rank=rank, 83 | non_negative=True, # Constrain all components to be non-negative 84 | l1_penalty={2: 0.1}, # Sparsity on C 85 | l2_norm_bound=[1, 1, 0], # Norm of A and B_i-component vectors less than 1 86 | parafac2=True, # Enforce PARAFAC2 constraint 87 | unimodal={1: True}, # Unimodality (one peak) on the B_i component vectors 88 | constant_feasibility_penalty=True, # Must be set to apply l2_norm_penalty (row-penalty) on A. See documentation for more details 89 | verbose=-1, # Negative verbosity level for minimal (nonzero) printouts 90 | random_state=0, # A seed can be given similar to how it's done in TensorLy 91 | ) 92 | 93 | est_weights, (est_A, est_B_is, est_C) = estimated_cmf 94 | 95 | # Code to display the results 96 | def normalize(M): 97 | return M / np.linalg.norm(M, axis=0) 98 | 99 | fig, axes = plt.subplots(2, 3, figsize=(5, 2)) 100 | axes[0, 0].plot(normalize(A)) 101 | axes[0, 1].plot(normalize(B_is[0])) 102 | axes[0, 2].plot(normalize(C)) 103 | 104 | axes[1, 0].plot(normalize(est_A)) 105 | axes[1, 1].plot(normalize(est_B_is[0])) 106 | axes[1, 2].plot(normalize(est_C)) 107 | 108 | axes[0, 0].set_title(r"$\mathbf{A}$") 109 | axes[0, 1].set_title(r"$\mathbf{B}_0$") 110 | axes[0, 2].set_title(r"$\mathbf{C}$") 111 | 112 | axes[0, 0].set_ylabel("True") 113 | axes[1, 0].set_ylabel("Estimated") 114 | 115 | for ax in axes.ravel(): 116 | ax.set_yticks([]) # Components can be aribtrarily scaled 117 | for ax in axes[0]: 118 | ax.set_xticks([]) # Remove xticks from upper row 119 | 120 | plt.savefig("figures/readme_components.png", dpi=300) 121 | 122 | 123 | 124 | 125 | .. code:: raw 126 | 127 | All regularization penalties (including regs list): 128 | * Mode 0: 129 | - <'matcouply.penalties.L2Ball' with aux_init='random_uniform', dual_init='random_uniform', norm_bound=1, non_negativity=True)> 130 | * Mode 1: 131 | - <'matcouply.penalties.Parafac2' with svd='truncated_svd', aux_init='random_uniform', dual_init='random_uniform', update_basis_matrices=True, update_coordinate_matrix=True, n_iter=1)> 132 | - <'matcouply.penalties.Unimodality' with aux_init='random_uniform', dual_init='random_uniform', non_negativity=True)> 133 | - <'matcouply.penalties.L2Ball' with aux_init='random_uniform', dual_init='random_uniform', norm_bound=1, non_negativity=True)> 134 | * Mode 2: 135 | - <'matcouply.penalties.L1Penalty' with aux_init='random_uniform', dual_init='random_uniform', reg_strength=0.1, non_negativity=True)> 136 | converged in 218 iterations: FEASIBILITY GAP CRITERION AND RELATIVE LOSS CRITERION SATISFIED 137 | 138 | .. image:: figures/readme_components.png 139 | :alt: Plot of simulated and estimated components 140 | 141 | References 142 | ---------- 143 | 144 | * [1]: Roald M, Schenker C, Cohen JE, Acar E PARAFAC2 AO-ADMM: Constraints in all modes. EUSIPCO (2021). 145 | * [2]: Roald M, Schenker C, Calhoun VD, Adali T, Bro R, Cohen JE, Acar E An AO-ADMM approach to constraining PARAFAC2 on all modes (2022). Accepted for publication in SIAM Journal on Mathematics of Data Science, arXiv preprint arXiv:2110.01278. 146 | -------------------------------------------------------------------------------- /docs/coupled_matrix_factorization.rst: -------------------------------------------------------------------------------- 1 | What are coupled matrix factorizations? 2 | ======================================= 3 | 4 | MatCoupLy computes coupled matrix factorizations, which are useful for finding patterns in 5 | collections of matrices and third order tensors. A coupled matrix factorization is used to jointly 6 | factorize a collection of matrices, :math:`\{\mathbf{X}^{(i)}\}_{i=1}^I`, with the same number of columns 7 | but possibly different number of rows, on the form 8 | 9 | .. math:: 10 | 11 | \mathbf{X}^{(i)} \approx \mathbf{B}^{(i)} \mathbf{D}^{(i)} \mathbf{C}^\mathsf{T}, 12 | 13 | where :math:`\mathbf{C}` is a factor matrix shared for all :math:`\mathbf{X}^{(i)}`-s, and 14 | :math:`\{\mathbf{B}^{(i)}\}_{i=1}^I` is a collection factor matrices, one for each :math:`\mathbf{X}^{(i)}`. 15 | The diagonal :math:`\{\mathbf{D}^{(i)}\}_{i=1}^I`-matrices describes the signal strength of each 16 | component for each :math:`\mathbf{X}^{(i)}`, and their diagonal entries are often collected into 17 | a single factor matrix, :math:`\mathbf{A}`. Below is an illustration of this model: 18 | 19 | .. figure:: figures/CMF_multiblock.svg 20 | :alt: Illustration of a coupled matrix factorization 21 | :width: 90 % 22 | 23 | Illustration of a coupled matrix factorization where colours represent different components. 24 | 25 | Factor weights and the scale indeterminacy of CMF models 26 | -------------------------------------------------------- 27 | There are two important scaling indeterminacies with coupled matrix factorization. First, any column in one of the factor 28 | matrices (e.g. :math:`\mathbf{A}`) may be scaled arbitrarilly by a constant factor :math:`s` as long as the corresponding column 29 | of :math:`\mathbf{C}` or *all* :math:`\mathbf{B}^{(i)}` matrices are scaled by :math:`1/s`, without affecting the data represented 30 | by the model. It is therefore, sometimes, customary to normalize the factor matrices and store their norms in a separate 31 | *weight*-vector, :math:`\mathbf{w}`. Then, the data matrices are represented by 32 | 33 | .. math:: 34 | \mathbf{X}^{(i)} \approx \mathbf{B}^{(i)} \mathbf{D}^{(i)} \text{diag}(\mathbf{w}) \mathbf{C}^\mathsf{T}, 35 | 36 | where :math:`\text{diag}(\mathbf{w})` is the diagonal matrix with diagonal entries given by the weights. :func:`matcouply.decomposition.cmf_aoadmm` 37 | and :func:`matcouply.decomposition.parafac2` will not scale the factor matrices this way, since that may affect the penalty 38 | from norm-dependent regularization (e.g. the :class:`matcouply.penalties.GeneralizedL2Penalty`). However, :class:`matcouply.coupled_matrices.CoupledMatrixFactorization` 39 | supports the use of a ``weights`` vector to be consistent with TensorLy. 40 | 41 | There are, however, another scale indeterminacy which can affect the extracted components in a more severe way. Any column 42 | in any :math:`\mathbf{B}^{(i)}`-matrix may also be scaled arbitrarilly by a constant :math:`s` if the corresponding entry 43 | in :math:`\mathbf{D}^{(i)}` is scaled by :math:`1/s`. To resolve this indeterminacy, we need to either impose constraints or 44 | fix the values in the :math:`\mathbf{D}^{(i)}`-matrices (e.g. keeping them equal to 1) by passing ``update_A=False`` to 45 | :func:`matcouply.decomposition.cmf_aoadmm`. PARAFAC2, for example, takes care of the scaling indeterminacy, however, the sign of 46 | any column, :math:`\mathbf{b}^{(i)}_r` of :math:`\mathbf{B}^{(i)}` and any entry of :math:`\mathbf{D}^{(i)}` can be flipped if the same flip is imposed on all 47 | columns in :math:`\mathbf{B}^{(i)}` (and entries of :math:`\mathbf{D}^{(i)}`) that are not orthogonal to :math:`\mathbf{b}^{(i)}_r`. 48 | To avoid this sign indeterminacy, you can apply additional constraints to the PARAFAC2 model, e.g. enforcing non-negative :math:`\mathbf{D}^{(i)}` matrices. 49 | :cite:p:`harshman1972parafac2` 50 | 51 | Constraints and uniqueness 52 | -------------------------- 53 | 54 | Coupled matrix factorization models without any additional constraints are not unique. This means 55 | that their components cannot be directly interpreted. To see this, consider the stacked matrix 56 | 57 | .. math:: 58 | 59 | \mathbf{X} = \begin{bmatrix} 60 | \mathbf{X}^{(0)} \\ 61 | \mathbf{X}^{(1)} \\ 62 | \vdots \\ 63 | \mathbf{X}^{(I)} \\ 64 | \end{bmatrix} 65 | 66 | A coupled matrix factorization of :math:`\{\mathbf{X}^{(i)}\}_{i=1}^I` can be interpreted as a 67 | matrix factorization of :math:`\mathbf{X}`, which is known to have several solutions. Therefore, 68 | we need to impose additional constraints to obtain interpretable components. 69 | 70 | PARAFAC2 71 | ^^^^^^^^ 72 | 73 | One popular constraint used to obtain uniqueness is the *constant cross product constraint* of the 74 | PARAFAC2 model :cite:p:`harshman1972parafac2,kiers1999parafac2,harshman1996uniqueness` (therefore also called the *PARAFAC2 constraint*). 75 | 76 | .. math:: 77 | 78 | {\mathbf{B}^{(i_1)}}^\mathsf{T}{\mathbf{B}^{(i_1)}} = {\mathbf{B}^{(i_2)}}^\mathsf{T}{\mathbf{B}^{(i_2)}}. 79 | 80 | Coupled matrix factorization models with this constraint are named PARAFAC2 models, and they are 81 | commonly used in data mining :cite:p:`chew2007cross,gujral2020spade`, chemometrics :cite:p:`amigo2008solving`, 82 | and analysis of electronic health records :cite:p:`afshar2018copa`. 83 | 84 | Non-negativity 85 | ^^^^^^^^^^^^^^ 86 | 87 | Another popular constraint is non-negativity constraints, which are commonly imposed on all parameters of 88 | the model. Non-negativity constraints are commonly used for non-negative data, where we want non-negative 89 | components. While this constraint doesn't necessarily result in a unique model, it does improve the uniqueness 90 | properties of coupled matrix factorization models. Lately, it has also been a focus on adding non-negativity 91 | constraints to PARAFAC2, which often provides a unique model :cite:p:`cohen2018nonnegative,van2020getting,roald2021admm`. 92 | The added non-negativity constraints improves PARAFAC2 model's numerical properties and it can also make 93 | the components more interpretable :cite:p:`roald2021admm`. 94 | 95 | Other constraints and regularization penalties 96 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 97 | 98 | MatCoupLy supports a wide array of possible constraints and regularization penalties. For a full list 99 | of the implemented constraints and penalties, see :doc:`autodoc/penalties`. 100 | 101 | .. note:: 102 | 103 | If you use penalty based regularization that scales with the norm of one of the parameters, then 104 | norm-based regularization should be imposed on all modes. This can, for example, be L2 regularization, 105 | max- and min-bound constraints, L1 penalties or hard L2 norm constraints. See :cite:p:`roald2021admm` 106 | for more details. 107 | -------------------------------------------------------------------------------- /docs/optimization.rst: -------------------------------------------------------------------------------- 1 | .. _optimization: 2 | 3 | Fitting coupled matrix factorization models 4 | =========================================== 5 | 6 | Objective function 7 | ^^^^^^^^^^^^^^^^^^ 8 | 9 | To fit a coupled matrix factorization, we solve the following optimization problem 10 | 11 | .. math:: 12 | \min_{\mathbf{A}, \{\mathbf{B}^{(i)}\}_{i=1}^I, \mathbf{C}} 13 | \frac{1}{2} \sum_{i=1}^I \frac{\| \mathbf{B}^{(i)} \mathbf{D}^{(i)} \mathbf{C}^\mathsf{T} - \mathbf{X}^{(i)}\|^2}{\|\mathbf{X}^{(i)}\|^2}, 14 | 15 | where :math:`\mathbf{A}` is the matrix obtained by stacking the diagonal entries of all 16 | :math:`\mathbf{D}^{(i)}`-matrices. However, as discussed in :doc:`coupled_matrix_factorization`, this problem does not 17 | have a unique solution, and each time we fit a coupled matrix factorization, we may obtain 18 | different factor matrices. As a consequence, we cannot interpret the factor matrices. 19 | To circumvent this problem, it is common to add regularization, forming the following 20 | optimisation problem 21 | 22 | .. math:: 23 | \min_{\mathbf{A}, \{\mathbf{B}^{(i)}\}_{i=1}^I, \mathbf{C}} 24 | \frac{1}{2} \sum_{i=1}^I \frac{\| \mathbf{B}^{(i)} \mathbf{D}^{(i)} \mathbf{C}^\mathsf{T} - \mathbf{X}^{(i)}\|^2}{\|\mathbf{X}^{(i)}\|^2} 25 | + \sum_{n=1}^{N_\mathbf{A}} g^{(A)}_n(\mathbf{A}) 26 | + \sum_{n=1}^{N_\mathbf{B}} g^{(B)}_n(\{ \mathbf{B}^{(i)} \}_{i=1}^I) 27 | + \sum_{n=1}^{N_\mathbf{C}} g^{(C)}_n(\mathbf{C}), 28 | 29 | where the :math:`g`-functions are regularization penalties, and :math:`N_\mathbf{A}, N_\mathbf{B}` 30 | and :math:`N_\mathbf{C}` are the number of regularization penalties for 31 | :math:`\mathbf{A}, \{\mathbf{B}^{(i)}\}_{i=1}^I` and :math:`\mathbf{C}`, respectively. 32 | 33 | The formulation above also encompasses hard constraints, such as :math:`a_{ir} \geq 0` for 34 | any index :math:`(i, r)`. To obtain such a constraint, we set 35 | 36 | .. math:: 37 | g^{(\mathbf{A})} = \begin{cases} 38 | 0 & \text{if } a_{ir} \geq 0 \text{ for all } a_{ir} \\ 39 | \infty & \text{otherwise}. 40 | \end{cases} 41 | 42 | .. note:: 43 | 44 | The data fidelity term (sum of squared error) differs by a factor :math:`1/2` 45 | compared to that in :cite:p:`roald2021parafac2,roald2021admm` 46 | 47 | Optimization 48 | ^^^^^^^^^^^^ 49 | 50 | To solve the regularized least squares problem, we use alternating optimisation (AO) with 51 | the alternating direction method of multipliers (ADMM). AO-ADMM is a block 52 | coordinate descent scheme, where the factor matrices for each mode is updated in an 53 | alternating fashion. This means that the regularized loss function above is split into 54 | the following three optimization subproblems: 55 | 56 | .. math:: 57 | \min_{\mathbf{A}} 58 | \frac{1}{2} \sum_{i=1}^I \| \mathbf{B}^{(i)} \mathbf{D}^{(i)} \mathbf{C}^\mathsf{T} - \mathbf{X}^{(i)}\|^2 59 | + \sum_{n=1}^{N_\mathbf{A}} g^{(A)}_n(\mathbf{A}), 60 | 61 | .. math:: 62 | \min_{\{\mathbf{B}^{(i)}\}_{i=1}^I} 63 | \frac{1}{2} \sum_{i=1}^I \| \mathbf{B}^{(i)} \mathbf{D}^{(i)} \mathbf{C}^\mathsf{T} - \mathbf{X}^{(i)}\|^2 64 | + \sum_{n=1}^{N_\mathbf{B}} g^{(B)}_n(\{ \mathbf{B}^{(i)} \}_{i=1}^I), 65 | 66 | .. math:: 67 | \min_{\mathbf{C}} 68 | \frac{1}{2} \sum_{i=1}^I \| \mathbf{B}^{(i)} \mathbf{D}^{(i)} \mathbf{C}^\mathsf{T} - \mathbf{X}^{(i)}\|^2 69 | + \sum_{n=1}^{N_\mathbf{C}} g^{(C)}_n(\mathbf{C}), 70 | 71 | which we solve approximately, one at a time, using a few iterations of ADMM. We repeat this 72 | process, updating :math:`\mathbf{A}, \{\mathbf{B}^{(i)}\}_{i=1}^I` and :math:`\mathbf{C}` untill 73 | some convergence criteria are satisfied. 74 | 75 | The benefit of AO-ADMM is its flexibility in terms of regularization and constraints. We 76 | can impose any regularization penalty or hard constraint so long as we have a way to 77 | evaluate the scaled proximal operator of the penalty function or projection onto the 78 | feasible set of the hard constraint :cite:p:`huang2016flexible`. That is, any regularization 79 | function, :math:`g(\mathbf{v})`, where we can solve the problem 80 | 81 | .. math:: 82 | \min_{\mathbf{w}} g(\mathbf{w}) + \frac{\rho}{2}\|\mathbf{w} - \mathbf{v}\|^2, 83 | 84 | where :math:`\mathbf{v}` and :math:`\mathbf{w}` are the vectorized input to the regularized 85 | least squares subproblem we solve with ADMM. :math:`\rho` is a parameter that penalises infeasible 86 | solutions (more about that later), we use the name *feasibility penalty parameter* for :math:`\rho`. 87 | 88 | The AO-ADMM algorithm is described in detail (in the context of PARAFAC2, a special case of 89 | coupled matrix factorization) in :cite:p:`roald2021admm`. 90 | 91 | .. note:: 92 | The role of :math:`\mathbf{A}` and :math:`\mathbf{C}` are switched between this software and 93 | :cite:p:`roald2021admm`, as this change makes for more straightforward usage. 94 | 95 | ADMM and the feasibility gap 96 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 97 | 98 | There is one property of ADMM that is important to be aware of when fitting models with AO-ADMM: 99 | The *feasibility gap*. If the feasibility gap is high (what a high value depends on the application, 100 | but any value above 0.0001 is suspicious and any value above 0.01 is likely high), then the 101 | constraints and regularization we impose may not be satisfied. To explain why, we briefly describe 102 | ADMM (for a thorough introduction, see :cite:p:`boyd2011distributed`, and for an introduction 103 | specifically for coupled matrix factorization, see :cite:p:`roald2021admm`). 104 | 105 | ADMM can solve problems on the form 106 | 107 | .. math:: 108 | \min_{\mathbf{x}, \mathbf{z}} f(\mathbf{x}) + g(\mathbf{z}) \\ 109 | \text{s.t. } \mathbf{Mx} - \mathbf{Ny} = \mathbf{c}, 110 | 111 | which is useful for solving regularized least squares problems. If we have only one regularization 112 | penalty, then we can rewrite a regularized least squares problem to the standard form of ADMM: 113 | 114 | .. math:: 115 | \min_{\mathbf{x}, \mathbf{z}} \frac{1}{2}\|\mathbf{Tx} - \mathbf{b}\|^2 + g(\mathbf{z}) \\ 116 | \text{s.t. } \mathbf{x} = \mathbf{z}. 117 | 118 | ADMM then works by forming the augmented Lagrange dual problem: 119 | 120 | .. math:: 121 | \max_{\boldsymbol{\lambda}} \min_{\mathbf{x}, \mathbf{z}} \frac{1}{2}\|\mathbf{Tx} - \mathbf{b}\|^2 122 | + g(\mathbf{z}) 123 | + \frac{\rho}{2}(\mathbf{x} - \mathbf{z})^2 124 | + \boldsymbol{\lambda}^\mathsf{T} (\mathbf{x} - \mathbf{z}), 125 | 126 | where :math:`\rho` is a penalty parameter for infeasible solutions (i.e. solutions where 127 | :math:`\mathbf{x} \neq \mathbf{z}`). 128 | 129 | An important property for assessing validity of models fitted with AO-ADMM is therefore the 130 | feasibility gap, given by 131 | 132 | .. math:: 133 | \frac{\|\mathbf{x} - \mathbf{z}\|}{\|\mathbf{x}\|} 134 | 135 | If this is high, then the solution is infeasible, and the model is likely not valid. 136 | 137 | .. note:: 138 | 139 | A sufficiently small feasibility gap is part of the stopping criteria, so if the AO-ADMM 140 | procedure stopped before the maximum number of iterations were reached, then the feasibility 141 | gaps are sufficiently small. 142 | 143 | Penalty-functions 144 | ^^^^^^^^^^^^^^^^^ 145 | 146 | We separate the penalty functions into three categories: row-wise penalties, matrix-wise penalties 147 | and multi-matrix penalties: 148 | 149 | * *Multi-matrix* penalties are penalties that penalise behaviour across 150 | multiple :math:`\mathbf{B}^{(i)}`-matrices at once (e.g. the PARAFAC2 constraint: :meth:`matcouply.penalties.Parafac2`). 151 | * *Matrix-wise* penalties are penalties full matrices (or columns of full matrices) at once 152 | (e.g. total variation regularization: :meth:`matcouply.penalties.TotalVariationPenalty`) and can be 153 | applied either to the :math:`\mathbf{B}^{(i)}`-matrices, or the :math:`\mathbf{C}`-matrix with no. 154 | * Finally, *row-wise* penalties are penalties that single rows (or elements) of a matrix at a time 155 | (e.g. non-negativity: :meth:`matcouply.penalties.NonNegativity`. These penalties can be applied to 156 | any factor matrix. 157 | 158 | .. note:: 159 | 160 | We can also apply matrix-wise penalties on :math:`\mathbf{A}` and special multi-matrix 161 | penalties that require a constant feasibility penalty for all :math:`\mathbf{B}^{(i)}`-matrices 162 | by using the `constant_feasibility_penalty=True` argument. There are currently no 163 | multi-matrix penalties that require a constant feasibility penalty in MatCoupLy. An example 164 | of such a penalty could be a similarity-based penalty across the different 165 | :math:`\mathbf{B}^{(i)}`-matrices. 166 | 167 | Stopping conditions 168 | ^^^^^^^^^^^^^^^^^^^ 169 | 170 | The AO-ADMM procedure has two kinds of stopping conditions. The ADMM stopping conditions (inner loop), used to 171 | determine if the regularized least squares subproblems have converged and the AO-ADMM stopping conditions 172 | (outer loop) used to determine whether the the full fitting procedure should end. 173 | 174 | **Inner loop (ADMM):** 175 | 176 | The ADMM stopping conditions is by default disabled, and all inner iterations are ran without checking for 177 | convergence. The reason is that for a large portion of the iterations, the ADMM iterations will not converge, 178 | and checking the stopping conditions may be a bottleneck. If they are set, then the following conditions must 179 | be satisfied 180 | 181 | .. math:: 182 | \frac{\|\mathbf{x}^{(t, q)} - \mathbf{z}^{(t, q)}\|}{\|\mathbf{x}^{(t, q)}\|} < \epsilon_{\text{inner}}, 183 | 184 | .. math:: 185 | \frac{\|\mathbf{x}^{(t, q)} - \mathbf{z}^{(t, q-1)}\|}{\|\mathbf{z}^{(t, q)}\|} < \epsilon_{\text{inner}}, 186 | 187 | where :math:`\mathbf{x}^{(t, q)}` is the variable whose linear system we solve (i.e. :math:`\mathbf{A}, \{\mathbf{B}^{(i)}\}_{i=1}^I` 188 | or :math:`\mathbf{C}`) and :math:`t` and :math:`q` represent the outer and inner iteration number, respectively. 189 | 190 | **Outer loop (AO-ADMM):** 191 | 192 | For the outer, AO-ADMM, loop, the stopping conditions are enabled by default and consist of two parts that must 193 | be satisfied. The loss condition and the feasibility conditions. 194 | 195 | The loss condition states that either an absolute loss value condition or a relative loss decrease 196 | condition should be satisfied. These conditions are given by: 197 | 198 | .. math:: 199 | f(\mathbf{M}^{(t)}) + g(\mathbf{M}^{(t)}) < \epsilon_{\text{abs}}, 200 | 201 | and 202 | 203 | .. math:: 204 | \frac{|f(\mathbf{M}^{(t-1)}) - f(\mathbf{M}^{(t)}) + g(\mathbf{M}^{(t-1)}) - g(\mathbf{M}^{(1)})|} 205 | {f(\mathbf{M}^{(t-1)}) + g(\mathbf{M}^{(t-1)})} 206 | < \epsilon_{\text{rel}}, 207 | 208 | where :math:`f` is the relative sum of squared error and :math:`g` is the sum of all regularization functions. 209 | :math:`\mathbf{M}^{(t)}` represents the full decomposition after :math:`t` outer iterations. 210 | 211 | The feasibility conditions must also be satisfied for stopping the AO-ADMM algorithm, and they are on the form 212 | 213 | .. math:: 214 | \frac{\|\mathbf{x}^{(t)} - \mathbf{z}^{(t)}\|}{\|\mathbf{x}^{(t)}\|} \leq \epsilon_{\text{feasibility}}, 215 | 216 | where :math:`\mathbf{x}^{(t)}` represents :math:`\mathbf{A}, \{\mathbf{B}^{(i)}\}_{i=1}^I` or :math:`\mathbf{C}` after :math:`t` 217 | outer iterations and :math:`\mathbf{z}^{(t)}` represents a corresponding auxiliary variable after after :math:`t` 218 | outer iterations. The feasibility conditions must be satisfied for all auxiliary variables for all modes for stopping 219 | the outer loop. 220 | -------------------------------------------------------------------------------- /examples/plot_semiconductor_etch_analysis.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | PARAFAC2 for semiconductor etch analysis 4 | ======================================== 5 | 6 | Component models have been used to detect errors in semiconductor etch processes :cite:p:`wise1999comparison`, where the 7 | datasets have three natural modes: sample, measurement and time. However, the time required for measuring 8 | different samples may vary, which leads to a stack of matrices, one for each sample. This makes PARAFAC2 a 9 | natural choice :cite:p:`wise2001application`, as it naturally handles time profiles of different lengths. 10 | 11 | In this example, we repeat some of the analysis from :cite:p:`wise2001application` and show how total variation (TV) regularization 12 | can reduce noise in the components. TV regularization is well suited for reducing noise without overly smoothing 13 | sharp transitions :cite:p:`rudin1992nonlinear`. 14 | """ 15 | 16 | ############################################################################### 17 | # Setup 18 | # ^^^^^ 19 | 20 | # sphinx_gallery_thumbnail_number = -1 21 | 22 | import matplotlib.pyplot as plt 23 | import numpy as np 24 | from tensorly.decomposition import parafac2 25 | 26 | from matcouply.data import get_semiconductor_etch_machine_data 27 | from matcouply.decomposition import parafac2_aoadmm 28 | 29 | ############################################################################### 30 | # Data loading and preprocessing 31 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 32 | 33 | 34 | train_data, train_metadata, test_data, test_metadata = get_semiconductor_etch_machine_data() 35 | 36 | 37 | ############################################################################### 38 | # The dataset contains three experiments (experiments 29, 31 and 33). In :cite:p:`wise2001application`, the authors 39 | # highlight the components obtained for experiment 29, so let's look at the same experiment. 40 | 41 | 42 | dataset_29 = [key for key in train_data if key.isnumeric() and key.startswith("29")] 43 | matrices = [train_data[key].values for key in dataset_29] 44 | 45 | ############################################################################### 46 | # Before analysing, we apply the same preprocessing steps as in :cite:p:`wise2001application` — centering and scaling each 47 | # matrix based on the global mean and standard deviation. 48 | 49 | 50 | stacked = np.concatenate(matrices, axis=0) 51 | mean = stacked.mean(0, keepdims=True) 52 | std = stacked.std(0, keepdims=True) 53 | standardised = [(m - mean) / std for m in matrices] 54 | 55 | ############################################################################### 56 | # Fit a PARAFAC2 model 57 | # ^^^^^^^^^^^^^^^^^^^^ 58 | # 59 | # Let's begin by fitting an unregularized PARAFAC2 model using the alternating least squares algorithm 60 | # :cite:p:`kiers1999parafac2` with the implementation in `TensorLy `_ :cite:p:`kossaifi2019tensorly`. 61 | # This algorithm is comparable with the one used in :cite:p:`wise2001application`. 62 | # 63 | # We also impose non-negativity on the :math:`\mathbf{A}`-matrix to handle the special sign indeterminacy of 64 | # PARAFAC2 :cite:p:`harshman1972parafac2`. The :math:`\mathbf{A}`-matrix elements in :cite:p:`wise2001application` are 65 | # also non-negative, so this shouldn't change the components. 66 | # 67 | # Similarly as :cite:`wise2001application`, we extract two components. 68 | 69 | 70 | pf2, rec_err = parafac2( 71 | standardised, 2, n_iter_max=10_000, return_errors=True, nn_modes=[0], random_state=0, tol=1e-9, verbose=True 72 | ) 73 | 74 | 75 | ############################################################################### 76 | # We examine the results by plotting the relative SSE and its relative change as a function of iteration number 77 | 78 | it_num = np.arange(len(rec_err)) + 1 79 | rel_sse = np.array(rec_err) ** 2 80 | 81 | fig, axes = plt.subplots(1, 2, figsize=(10, 3), tight_layout=True) 82 | axes[0].plot(it_num, rel_sse) 83 | axes[0].set_ylim(0.67, 0.68) 84 | axes[0].set_xlabel("Iteration number") 85 | axes[0].set_ylabel("Relative SSE") 86 | 87 | axes[1].semilogy(it_num[1:], (rel_sse[:-1] - rel_sse[1:]) / rel_sse[:-1]) 88 | axes[1].set_xlabel("Iteration number") 89 | axes[1].set_ylabel("Relative change in SSE") 90 | axes[1].set_ylim(1e-9, 1e-6) 91 | 92 | plt.show() 93 | 94 | ############################################################################### 95 | # Next, we look at the components 96 | 97 | weights, (A, B, C), P_is = pf2 98 | B_is = [P_i @ B for P_i in P_is] 99 | 100 | # We normalise the components to make them easier to compare 101 | A_norm = np.linalg.norm(A, axis=0, keepdims=True) 102 | C_norm = np.linalg.norm(C, axis=0, keepdims=True) 103 | A = A / A_norm 104 | B_is = [B_i * A_norm * C_norm for B_i in B_is] 105 | C = C / C_norm 106 | 107 | # We find the permutation so the first component explains most of the variation in the data 108 | B_norm = np.linalg.norm(B, axis=0, keepdims=True) 109 | permutation = np.argsort(weights * A_norm * B_norm * C_norm).squeeze() 110 | 111 | fig, axes = plt.subplots(1, 2, figsize=(16, 5)) 112 | 113 | for i, B_i in enumerate(B_is): 114 | axes[0].plot(B_i[:, permutation[0]], color="k", alpha=0.3) 115 | axes[1].plot(B_i[:, permutation[1]], color="k", alpha=0.3) 116 | 117 | ############################################################################### 118 | # We see that the components are similar to those in :cite:p:`wise2001application`. We can see an overall shape, but 119 | # they are fairly noisy. 120 | # 121 | # .. note:: 122 | # 123 | # In this simple example, we only use one random initialisation. For a more thorough analysis, you should fit 124 | # several models with different random initialisations and select the model with the lowest SSE 125 | # :cite:p:`yu2021parafac2`. 126 | # 127 | 128 | ############################################################################### 129 | # Next we use PARAFAC2 ADMM to apply a TV penalty 130 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 131 | # 132 | # Since the TV-penalty scales with the norm of the factors, we also need to penalise the norm of :math:`\mathbf{A}` 133 | # and :math:`\mathbf{C}` :cite:p:`roald2021admm`. In this case, we use unit ball constraints, constraining the columns of 134 | # :math:`\mathbf{A}` and :math:`\mathbf{C}` to have unit norm. 135 | # 136 | # Similar as before, we add non-negativity on :math:`\mathbf{A}` to resolve the sign indeterminacy. 137 | # 138 | # .. note:: 139 | # 140 | # The proximal operator for the total variation penalty is computed using the C-implementation for the improved 141 | # version of the direct TV algorithm presented in :cite:p:`condat2013direct`. The C-implementation is CeCILL 142 | # lisenced and is available `here `__, and the Python-wrapper, 143 | # `condat-tv`, is GPL-3 lisenced and is available `here `__. 144 | # 145 | 146 | cmf, diagnostics = parafac2_aoadmm( 147 | standardised, 148 | 2, 149 | n_iter_max=10_000, 150 | non_negative={0: True}, 151 | l2_norm_bound=[1, None, 1], 152 | tv_penalty={1: 0.1}, 153 | verbose=100, 154 | return_errors=True, 155 | init_params={"nn_modes": [0]}, 156 | constant_feasibility_penalty=True, 157 | tol=1e-9, 158 | random_state=0, 159 | ) 160 | 161 | ############################################################################### 162 | # We examine the diagnostic plots 163 | # 164 | # For ALS, the relative SSE and its change was the only interesting metrics. However, with regularized PARAFAC2 and AO-ADMM 165 | # we should also to look at the feasibility gaps and the regularization penalty. 166 | # 167 | # All feasibility gaps and the change in relative SSE should be low. 168 | 169 | rel_sse = np.array(diagnostics.rec_errors) ** 2 170 | loss = np.array(diagnostics.regularized_loss) 171 | feasibility_penalty_A = np.array([gapA for gapA, gapB, gapC in diagnostics.feasibility_gaps]) 172 | feasibility_penalty_B = np.array([gapB for gapA, gapB, gapC in diagnostics.feasibility_gaps]) 173 | feasibility_penalty_C = np.array([gapC for gapA, gapB, gapC in diagnostics.feasibility_gaps]) 174 | 175 | it_num = np.arange(len(rel_sse)) 176 | 177 | fig, axes = plt.subplots(2, 3, figsize=(15, 6), tight_layout=True) 178 | axes[0, 0].plot(it_num, rel_sse) 179 | axes[0, 0].set_ylim(0.69, 0.71) 180 | axes[0, 0].set_xlabel("Iteration number") 181 | axes[0, 0].set_ylabel("Relative SSE") 182 | 183 | axes[0, 1].plot(it_num, loss) 184 | axes[0, 1].set_xlabel("Iteration number") 185 | axes[0, 1].set_ylabel("Regularized loss") 186 | 187 | axes[0, 2].semilogy(it_num[1:], np.abs(loss[:-1] - loss[1:]) / loss[:-1]) 188 | axes[0, 2].set_xlabel("Iteration number") 189 | axes[0, 2].set_ylabel("Relative change in regularized loss") 190 | 191 | axes[1, 0].semilogy(it_num, feasibility_penalty_A) 192 | axes[1, 0].set_xlabel("Iteration number") 193 | axes[1, 0].set_ylabel("Feasibility gap A") 194 | 195 | axes[1, 1].semilogy(it_num, feasibility_penalty_B) 196 | axes[1, 1].set_xlabel("Iteration number") 197 | axes[1, 1].set_ylabel("Feasibility gap B_is") 198 | axes[1, 1].legend(["PARAFAC2", "TV"]) 199 | 200 | axes[1, 2].semilogy(it_num, feasibility_penalty_C) 201 | axes[1, 2].set_xlabel("Iteration number") 202 | axes[1, 2].set_ylabel("Feasibility gap C") 203 | 204 | ############################################################################### 205 | # Next, we look at the regularized components 206 | 207 | weights, (A, B_is, C) = cmf 208 | # We find the permutation so the first component explains most of the variation in the data 209 | B_norm = np.linalg.norm(B_is[0], axis=0, keepdims=True) # All B_is have same norm due to PARAFAC2 constraint 210 | permutation = np.argsort(B_norm).squeeze() 211 | 212 | fig, axes = plt.subplots(1, 2, figsize=(16, 5)) 213 | 214 | for i, B_i in enumerate(B_is): 215 | axes[0].plot(B_i[:, permutation[0]], color="k", alpha=0.3) 216 | axes[1].plot(B_i[:, permutation[1]], color="k", alpha=0.3) 217 | 218 | ############################################################################### 219 | # We see that the TV regularization removed much of the noise. We now have piecewise constant components 220 | # with transitions that are easy to identify. 221 | 222 | ############################################################################### 223 | # Comparing with unregularized PARAFAC2 224 | print("Relative SSE with unregularized PARAFAC2: ", rec_err[-1] ** 2) 225 | print("Relative SSE with TV regularized PARAFAC2:", diagnostics.rec_errors[-1] ** 2) 226 | 227 | ############################################################################### 228 | # We see that there is only a small change in the relative SSE, but the components are much smoother and 229 | # the transitions are clearer. 230 | 231 | ############################################################################### 232 | # License 233 | # ^^^^^^^ 234 | # 235 | # Since this example uses the `condat_tv`-library, it is lisenced under a GPL-3 license 236 | # 237 | # .. code:: text 238 | # 239 | # Version 3, 29 June 2007 240 | # 241 | # Example demonstrating TV regularized PARAFAC2 242 | # Copyright (C) 2021 Marie Roald 243 | # 244 | # This program is free software: you can redistribute it and/or modify 245 | # it under the terms of the GNU General Public License as published by 246 | # the Free Software Foundation, either version 3 of the License, or 247 | # (at your option) any later version. 248 | # 249 | # This program is distributed in the hope that it will be useful, 250 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 251 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 252 | # GNU General Public License for more details. 253 | # 254 | # You should have received a copy of the GNU General Public License 255 | # along with this program. If not, see . 256 | -------------------------------------------------------------------------------- /src/matcouply/data.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | import json 5 | from datetime import datetime, timedelta 6 | from io import BytesIO 7 | from pathlib import Path 8 | from zipfile import ZipFile 9 | 10 | import numpy as np 11 | import scipy.stats as stats 12 | import tensorly as tl 13 | from scipy.io import loadmat 14 | 15 | try: 16 | import pandas as pd 17 | import requests 18 | from tqdm import tqdm 19 | except ModuleNotFoundError: 20 | pass 21 | 22 | from matcouply.coupled_matrices import CoupledMatrixFactorization 23 | 24 | DATASET_PARENT = Path(__file__).parent / "datasets" 25 | DOWNLOADED_PARENT = DATASET_PARENT / "downloads" 26 | 27 | 28 | def get_simple_simulated_data(noise_level=0.2, random_state=1): 29 | r"""Generate a simple simulated dataset with shifting unimodal :math:`\mathbf{B}^{(i)}` matrices. 30 | 31 | The entries in :math:`\mathbf{A}` (or :math:`\mathbf{D}^{(i)}`-matrices) are uniformly distributed 32 | between 0.1 and 1.1. This is done to ensure that there is signal from all components in all matrices. 33 | 34 | The component vectors in the :math:`\mathbf{B}^{(i)}` matrices are Gaussian probability density 35 | functions that shift one entry for each matrix. This means that they are non-negative, unimodal 36 | and satisfy the PARAFAC2 constraint. 37 | 38 | The entries in :math:`\mathbf{C}` are truncated normal distributed, and are therefore sparse. 39 | 40 | The dataset is generated by constructing the matrices represented by the decomposition and adding 41 | noise according to 42 | 43 | .. math:: 44 | 45 | \mathbf{M}_\text{noisy}^{(i)} = 46 | \mathbf{M}^{(i)} + \eta \frac{\|\mathbf{\mathcal{M}}\|}{\|\mathbf{\mathcal{N}}\|} \mathbf{N}^{(i)}, 47 | 48 | 49 | where :math:`\eta` is the noise level, :math:`\mathbf{M}^{(i)}` is the :math:`i`-th matrix represented 50 | by the simulated factorization, :math:`\mathbf{\mathcal{M}}` is the tensor obtained by stacking all the 51 | :math:`\mathbf{M}^{(i)}`-matrices, :math:`n^{(i)}_{jk} \sim \mathcal{N}(0, 1)` and :math:`\mathbf{N}^{(i)}` 52 | and :math:`\mathbf{\mathcal{N}}` and is the matrix and tensor with elements given by :math:`n^{(i)}_{jk}`, respectively. 53 | 54 | Parameters 55 | ---------- 56 | noise_level : float 57 | Strength of noise added to the matrices 58 | random_state : None, int or valid tensorly random state 59 | 60 | Returns 61 | ------- 62 | list of matrices 63 | The noisy matrices 64 | CoupledMatrixFactorization 65 | The factorization that underlies the simulated data matrices 66 | """ 67 | rank = 3 68 | I, J, K = 15, 50, 20 69 | 70 | rng = tl.check_random_state(random_state) 71 | 72 | # Uniformly distributed A 73 | A = rng.uniform(size=(I, rank)) + 0.1 # Add 0.1 to ensure that there is signal for all components for all slices 74 | A = tl.tensor(tl.tensor(A)) 75 | 76 | # Generate B-matrix as shifting normal distributions 77 | t = np.linspace(-10, 10, J) 78 | B_blueprint = np.stack([stats.norm.pdf(t, loc=-5), stats.norm.pdf(t, loc=0), stats.norm.pdf(t, loc=2)], axis=-1) 79 | B_is = [np.roll(B_blueprint, i, axis=0) for i in range(I)] # Cyclically permute to get changing B_i matrices 80 | B_is = [tl.tensor(B_i) for B_i in B_is] 81 | 82 | # Truncated normal distributed C 83 | C = tl.tensor(rng.standard_normal(size=(K, rank))) 84 | C[C < 0] = 0 85 | C = tl.tensor(C) 86 | 87 | # Construct decomposition and data matrix 88 | cmf = CoupledMatrixFactorization((None, (A, B_is, C))) 89 | matrices = cmf.to_matrices() 90 | 91 | # Add noise 92 | noise = [tl.tensor(rng.standard_normal(size=M.shape)) for M in matrices] 93 | scale_factor = tl.norm(tl.stack(matrices)) / tl.norm(tl.stack(noise)) 94 | matrices = [M + noise_level * scale_factor * N for M, N in zip(matrices, noise)] 95 | return matrices, cmf 96 | 97 | 98 | def get_bike_data(): 99 | r"""Get bike sharing data from three major Norwegian cities 100 | 101 | This dataset contains three matrices with bike sharing data from Oslo, Bergen and Trondheim, 102 | :math:`\mathbf{X}^{(\text{Oslo})}, \mathbf{X}^{(\text{Bergen})}` and :math:`\mathbf{X}^{(\text{Trondheim})}`. 103 | Each row of these data matrices represent a station, and each column of the data matrices 104 | represent an hour in 2021. The matrix element :math:`x^{(\text{Oslo})}_{jk}` is the number of trips 105 | that ended in station :math:`j` in Oslo during hour :math:`k`. 106 | 107 | The data was obtained using the open API of 108 | 109 | * Oslo Bysykkel: https://oslobysykkel.no/en/open-data 110 | * Bergen Bysykkel: https://bergenbysykkel.no/en/open-data 111 | * Trondheim Bysykkel: https://trondheimbysykkel.no/en/open-data 112 | 113 | on the 23rd of November 2021. 114 | 115 | The dataset is cleaned so it only contains for the dates in 2021 when bike sharing was open in all three 116 | cities (2021-04-07 - 2021-11-23). 117 | 118 | Returns 119 | ------- 120 | dict 121 | Dictionary mapping the city name with a data frame that contain bike sharing data from that city. 122 | There is also an additional ``"station_metadata"``-key, which maps to a data frame with additional 123 | station metadata. This metadata is useful for interpreting the extracted components. 124 | 125 | .. note:: 126 | 127 | The original bike sharing data is released under a NLOD lisence (https://data.norge.no/nlod/en/2.0/). 128 | """ 129 | with ZipFile(DATASET_PARENT / "bike.zip") as data_zip: 130 | with data_zip.open("bike.json", "r") as f: 131 | bike_data = json.load(f) 132 | 133 | datasets = {key: pd.DataFrame(value) for key, value in bike_data["dataset"].items()} 134 | time = [datetime(2021, 1, 1) + timedelta(hours=int(h)) for h in datasets["oslo"].columns] 135 | time = pd.to_datetime(time).tz_localize("UTC").tz_convert("CET") 136 | 137 | out = {} 138 | for city in ["oslo", "trondheim", "bergen"]: 139 | df = datasets[city] 140 | df.columns = time 141 | df.columns.name = "Time of arrival" 142 | df.index.name = "Arrival station ID" 143 | df.index = df.index.astype(int) 144 | out[city] = df 145 | 146 | out["station_metadata"] = datasets["station_metadata"] 147 | out["station_metadata"].index.name = "Arrival station ID" 148 | out["station_metadata"].index = out["station_metadata"].index.astype(int) 149 | 150 | return out 151 | 152 | 153 | def get_semiconductor_etch_raw_data(download_data=True, save_data=True): 154 | """Load semiconductor etch data from :cite:p:`wise1999comparison`. 155 | 156 | If the dataset is already downloaded on your computer, then the local files will be 157 | loaded. Otherwise, they will be downloaded. By default, the files are downloaded from 158 | https://eigenvector.com/data/Etch. 159 | 160 | Parameters 161 | ---------- 162 | download_data : bool 163 | If ``False``, then an error will be raised if the data is not 164 | already downloaded. 165 | save_data : bool 166 | if ``True``, then the data will be stored locally to avoid having to download 167 | multiple times. 168 | 169 | Returns 170 | ------- 171 | dict 172 | Dictionary where the keys are the dataset names and the values are the contents 173 | of the MATLAB files. 174 | """ 175 | data_urls = { 176 | "MACHINE_Data.mat": "http://eigenvector.com/data/Etch/MACHINE_Data.mat", 177 | "OES_DATA.mat": "http://eigenvector.com/data/Etch/OES_DATA.mat", 178 | "RFM_DATA.mat": "http://eigenvector.com/data/Etch/RFM_DATA.mat", 179 | } 180 | data_raw_mat = {} 181 | 182 | print("Loading semiconductor etch data from Wise et al. (1999) - J. Chemom. 13(3‐4), pp.379-396.") 183 | print("The data is available at: http://eigenvector.com/data/Etch/") 184 | for file, url in tqdm(data_urls.items()): 185 | file_path = DOWNLOADED_PARENT / file 186 | if file_path.exists(): 187 | data_raw_mat[file] = loadmat(file_path) 188 | elif download_data: 189 | request = requests.get(url) 190 | if request.status_code != 200: 191 | raise RuntimeError(f"Cannot download file {url} - Response: {request.status_code} {request.reason}") 192 | 193 | if save_data: 194 | DOWNLOADED_PARENT.mkdir(exist_ok=True, parents=True) 195 | with open(file_path, "wb") as f: 196 | f.write(request.content) 197 | 198 | data_raw_mat[file] = loadmat(BytesIO(request.content)) 199 | else: 200 | raise RuntimeError("The semiconductor etch data is not yet downloaded, and ``download_data=False``.") 201 | return data_raw_mat 202 | 203 | 204 | def get_semiconductor_etch_machine_data(download_data=True, save_data=True): 205 | """Load machine measurements from the semiconductor etch dataset from :cite:p:`wise1999comparison`. 206 | 207 | This function will load the semiconductor etch machine data and prepare it for analysis. 208 | 209 | If the dataset is already downloaded on your computer, then the local files will be 210 | loaded. Otherwise, they will be downloaded. By default, the files are downloaded from 211 | https://eigenvector.com/data/Etch. 212 | 213 | Parameters 214 | ---------- 215 | download_data : bool 216 | If ``False``, then an error will be raised if the data is not 217 | already downloaded. 218 | save_data : bool 219 | if ``True``, then the data will be stored locally to avoid having to download 220 | multiple times. 221 | 222 | Returns 223 | ------- 224 | dict 225 | Dictionary where the keys are the dataset names and the values are the contents 226 | of the MATLAB files. 227 | """ 228 | # Get raw MATLAB data and parse into Python dict 229 | data = get_semiconductor_etch_raw_data(download_data=download_data, save_data=save_data)["MACHINE_Data.mat"][ 230 | "LAMDATA" 231 | ] 232 | data = {key: data[key].squeeze().item().squeeze() for key in data.dtype.fields} 233 | 234 | # Format data as xarray dataset 235 | varnames = data["variables"][2:] 236 | 237 | # Get the training data 238 | train_names = [name.split(".")[0][1:] for name in data["calib_names"]] 239 | train_data = { 240 | name: pd.DataFrame(Xi[:-1, 2:], columns=varnames) # Slice away last row since it "belongs" to the next sample 241 | for name, Xi in zip(train_names, data["calibration"]) 242 | } 243 | train_metadata = {} 244 | for i, name in enumerate(list(train_data)): 245 | train_data[name].index.name = "Time point" 246 | train_data[name].columns.name = "Measurement" 247 | 248 | metadata = pd.DataFrame(data["calibration"][i][:-1, [0, 1]], columns=["Time", "Step number"]) 249 | metadata["Experiment"] = int(name[:2]) 250 | metadata["Sample"] = int(name[2:]) 251 | metadata.index.name = "Time point" 252 | metadata.columns.name = "Metadata" 253 | train_metadata[name] = metadata 254 | 255 | # Get the testing data 256 | test_names = [name.split(".")[0][1:] for name in data["test_names"]] 257 | test_data = { 258 | name: pd.DataFrame(Xi[:-1, 2:], columns=varnames) # Slice away last row since it "belongs" to the next sample 259 | for name, Xi in zip(test_names, data["test"]) 260 | } 261 | test_metadata = {} 262 | for i, name in enumerate(list(test_data)): 263 | test_data[name].index.name = "Time point" 264 | test_data[name].columns.name = "Measurement" 265 | 266 | metadata = pd.DataFrame(data["test"][i][:-1, [0, 1]], columns=["Time", "Step number"]) 267 | metadata["Experiment"] = int(name[:2]) 268 | metadata["Sample"] = int(name[2:]) 269 | metadata["Fault name"] = data["fault_names"][i] 270 | metadata.index.name = "Time point" 271 | metadata.columns.name = "Metadata" 272 | test_metadata[name] = metadata 273 | 274 | return train_data, train_metadata, test_data, test_metadata 275 | -------------------------------------------------------------------------------- /docs/references.bib: -------------------------------------------------------------------------------- 1 | @article{condat2013direct, 2 | title={A direct algorithm for 1-D total variation denoising}, 3 | author={Condat, Laurent}, 4 | journal={IEEE Signal Processing Letters}, 5 | volume={20}, 6 | number={11}, 7 | pages={1054--1057}, 8 | year={2013}, 9 | publisher={IEEE} 10 | } 11 | 12 | @article{friedman2007pathwise, 13 | title={Pathwise coordinate optimization}, 14 | author={Friedman, Jerome and Hastie, Trevor and H{\"o}fling, Holger and Tibshirani, Robert}, 15 | journal={The annals of applied statistics}, 16 | volume={1}, 17 | number={2}, 18 | pages={302--332}, 19 | year={2007}, 20 | publisher={Institute of Mathematical Statistics} 21 | } 22 | 23 | @article{kiers1999parafac2, 24 | title={{PARAFAC2}—{P}art {I}. A direct fitting algorithm for the {PARAFAC2} model}, 25 | author={Kiers, Henk AL and Ten Berge, Jos MF and Bro, Rasmus}, 26 | journal={Journal of Chemometrics: A Journal of the Chemometrics Society}, 27 | volume={13}, 28 | number={3-4}, 29 | pages={275--294}, 30 | year={1999}, 31 | publisher={Wiley Online Library} 32 | } 33 | 34 | @article{harshman1996uniqueness, 35 | title={Uniqueness proof for a family of models sharing features of Tucker's three-mode factor analysis and PARAFAC/CANDECOMP}, 36 | author={Harshman, Richard A and Lundy, Margaret E}, 37 | journal={Psychometrika}, 38 | volume={61}, 39 | number={1}, 40 | pages={133--154}, 41 | year={1996}, 42 | publisher={Springer} 43 | } 44 | 45 | @article{roald2021admm, 46 | title={An AO-ADMM approach to constraining {PARAFAC2} on all modes}, 47 | author={Roald, Marie and Schenker, Carla and Calhoun, Vince D and Adali, T\"ulai and Bro, Rasmus and Cohen, Jeremy E and Acar, Evrim}, 48 | journal={Accepted for publication in {SIAM} Journal on Mathematics of Data Science}, 49 | url={https://arxiv.org/abs/2110.01278}, 50 | year={2022} 51 | } 52 | 53 | @inproceedings{roald2021parafac2, 54 | title={{PARAFAC2} {AO-ADMM}: Constraints in all modes}, 55 | author={Roald, Marie and Schenker, Carla and Cohen, Jeremy E and Acar, Evrim}, 56 | booktitle={Proceedings of the 29th European Signal Processing Conference}, 57 | year={2021}, 58 | organization={EURASIP} 59 | } 60 | 61 | @article{wise1999comparison, 62 | title={A comparison of principal component analysis, multiway principal component analysis, trilinear decomposition and parallel factor analysis for fault detection in a semiconductor etch process}, 63 | author={Wise, Barry M and Gallagher, Neal B and Butler, Stephanie Watts and White Jr, Daniel D and Barna, Gabriel G}, 64 | journal={Journal of Chemometrics: A Journal of the Chemometrics Society}, 65 | volume={13}, 66 | number={3-4}, 67 | pages={379--396}, 68 | year={1999}, 69 | publisher={Wiley Online Library} 70 | } 71 | 72 | @article{wise2001application, 73 | title={Application of {PARAFAC2} to fault detection and diagnosis in semiconductor etch}, 74 | author={Wise, Barry M and Gallagher, Neal B and Martin, Elaine B}, 75 | journal={Journal of Chemometrics: A Journal of the Chemometrics Society}, 76 | volume={15}, 77 | number={4}, 78 | pages={285--298}, 79 | year={2001}, 80 | publisher={Wiley Online Library} 81 | } 82 | 83 | @article{kossaifi2019tensorly, 84 | title={TensorLy: Tensor Learning in Python}, 85 | author={Kossaifi, Jean and Panagakis, Yannis and Anandkumar, Anima and Pantic, Maja}, 86 | journal={Journal of Machine Learning Research}, 87 | volume={20}, 88 | number={26}, 89 | pages={1--6}, 90 | year={2019} 91 | } 92 | 93 | @article{harshman1972parafac2, 94 | author={Harshman, Richard A}, 95 | year={1972}, 96 | title={{PARAFAC2}: Mathematical and technical notes}, 97 | journal={{UCLA} Working Papers in Phonetics}, 98 | volume={22}, 99 | pages={30--44} 100 | } 101 | 102 | @article{yu2021parafac2, 103 | title={{PARAFAC2} and local minima}, 104 | author={Yu, Huiwen and Bro, Rasmus}, 105 | journal={Chemometrics and Intelligent Laboratory Systems}, 106 | pages={104446}, 107 | year={2021}, 108 | publisher={Elsevier} 109 | } 110 | 111 | @article{rudin1992nonlinear, 112 | title={Nonlinear total variation based noise removal algorithms}, 113 | author={Rudin, Leonid I and Osher, Stanley and Fatemi, Emad}, 114 | journal={Physica D: nonlinear phenomena}, 115 | volume={60}, 116 | number={1-4}, 117 | pages={259--268}, 118 | year={1992}, 119 | publisher={Elsevier} 120 | } 121 | 122 | @book{boyd2011distributed, 123 | title={Distributed optimization and statistical learning via the alternating direction method of multipliers}, 124 | author={Boyd, Stephen and Parikh, Neal and Chu, Eric}, 125 | year={2011}, 126 | publisher={Now Publishers Inc} 127 | } 128 | 129 | @article{huang2016flexible, 130 | title={A flexible and efficient algorithmic framework for constrained matrix and tensor factorization}, 131 | author={Huang, Kejun and Sidiropoulos, Nicholas D and Liavas, Athanasios P}, 132 | journal={IEEE Transactions on Signal Processing}, 133 | volume={64}, 134 | number={19}, 135 | pages={5052--5065}, 136 | year={2016}, 137 | publisher={IEEE} 138 | } 139 | 140 | @inproceedings{chew2007cross, 141 | title={Cross-language information retrieval using {PARAFAC2}}, 142 | author={Chew, Peter A and Bader, Brett W and Kolda, Tamara G and Abdelali, Ahmed}, 143 | booktitle={Proceedings of the 13th ACM SIGKDD international conference on Knowledge discovery and data mining}, 144 | pages={143--152}, 145 | year={2007} 146 | } 147 | 148 | @inproceedings{gujral2020spade, 149 | title={{SPADE:} Streaming {PARAFAC2} {DEcomposition} for Large Datasets}, 150 | author={Gujral, Ekta and Theocharous, Georgios and Papalexakis, Evangelos E}, 151 | booktitle={Proceedings of the 2020 SIAM International Conference on Data Mining}, 152 | pages={577--585}, 153 | year={2020}, 154 | organization={SIAM} 155 | } 156 | 157 | @article{amigo2008solving, 158 | title={Solving {GC-MS} problems with {PARAFAC2}}, 159 | author={Amigo, Jos{\'e} Manuel and Skov, Thomas and Bro, Rasmus and Coello, Jordi and Maspoch, Santiago}, 160 | journal={TrAC Trends in Analytical Chemistry}, 161 | volume={27}, 162 | number={8}, 163 | pages={714--725}, 164 | year={2008}, 165 | publisher={Elsevier} 166 | } 167 | 168 | @inproceedings{afshar2018copa, 169 | title={{COPA:} Constrained {PARAFAC2} for sparse \& large datasets}, 170 | author={Afshar, Ardavan and Perros, Ioakeim and Papalexakis, Evangelos E and Searles, Elizabeth and Ho, Joyce and Sun, Jimeng}, 171 | booktitle={Proceedings of the 27th ACM International Conference on Information and Knowledge Management}, 172 | pages={793--802}, 173 | year={2018} 174 | } 175 | 176 | @inproceedings{cohen2018nonnegative, 177 | title={Nonnegative {PARAFAC2:} A flexible coupling approach}, 178 | author={Cohen, Jeremy E and Bro, Rasmus}, 179 | booktitle={International Conference on Latent Variable Analysis and Signal Separation}, 180 | pages={89--98}, 181 | year={2018}, 182 | organization={Springer} 183 | } 184 | 185 | @article{van2020getting, 186 | title={Getting to the core of {PARAFAC2}, a nonnegative approach}, 187 | author={Van Benthem, Mark H and Keller, Timothy J and Gillispie, Gregory D and DeJong, Stephanie A}, 188 | journal={Chemometrics and Intelligent Laboratory Systems}, 189 | volume={206}, 190 | pages={104127}, 191 | year={2020}, 192 | publisher={Elsevier} 193 | } 194 | 195 | @article{stout2008unimodal, 196 | title={Unimodal regression via prefix isotonic regression}, 197 | author={Stout, Quentin F}, 198 | journal={Computational Statistics \& Data Analysis}, 199 | volume={53}, 200 | number={2}, 201 | pages={289--297}, 202 | year={2008}, 203 | publisher={Elsevier} 204 | } 205 | 206 | @inproceedings{ren2020robust, 207 | title={Robust Irregular Tensor Factorization and Completion for Temporal Health Data Analysis}, 208 | author={Ren, Yifei and Lou, Jian and Xiong, Li and Ho, Joyce C}, 209 | booktitle={Proceedings of the 29th ACM International Conference on Information \& Knowledge Management}, 210 | pages={1295--1304}, 211 | year={2020} 212 | } 213 | 214 | @inproceedings{roald2020tracing, 215 | title={Tracing network evolution using the {PARAFAC2} model}, 216 | author={Roald, Marie and Bhinge, Suchita and Jia, Chunying and Calhoun, Vince and Adal{\i}, T{\"u}lay and Acar, Evrim}, 217 | booktitle={ICASSP 2020-2020 IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP)}, 218 | pages={1100--1104}, 219 | year={2020}, 220 | organization={IEEE} 221 | } 222 | 223 | @article{madsen2017quantifying, 224 | title={Quantifying functional connectivity in multi-subject {fMRI} data using component models}, 225 | author={Madsen, Kristoffer H and Churchill, Nathan W and M{\o}rup, Morten}, 226 | journal={Human brain mapping}, 227 | volume={38}, 228 | number={2}, 229 | pages={882--899}, 230 | year={2017}, 231 | publisher={Wiley Online Library} 232 | } 233 | 234 | @article{ruckebusch2013multivariate, 235 | title={Multivariate curve resolution: a review of advanced and tailored applications and challenges}, 236 | author={Ruckebusch, C and Blanchet, L}, 237 | journal={Analytica chimica acta}, 238 | volume={765}, 239 | pages={28--36}, 240 | year={2013}, 241 | publisher={Elsevier} 242 | } 243 | 244 | @article{gong2016coupled, 245 | author={Gong, Maoguo and Zhang, Puzhao and Su, Linzhi and Liu, Jia}, 246 | journal={IEEE Transactions on Geoscience and Remote Sensing}, 247 | title={Coupled Dictionary Learning for Change Detection From Multisource Data}, 248 | year={2016}, 249 | volume={54}, 250 | number={12}, 251 | pages={7077-7091}, 252 | doi={10.1109/TGRS.2016.2594952}} 253 | 254 | @article{scikit-learn, 255 | title={Scikit-learn: Machine Learning in {P}ython}, 256 | author={Pedregosa, F. and Varoquaux, G. and Gramfort, A. and Michel, V. 257 | and Thirion, B. and Grisel, O. and Blondel, M. and Prettenhofer, P. 258 | and Weiss, R. and Dubourg, V. and Vanderplas, J. and Passos, A. and 259 | Cournapeau, D. and Brucher, M. and Perrot, M. and Duchesnay, E.}, 260 | journal={Journal of Machine Learning Research}, 261 | volume={12}, 262 | pages={2825--2830}, 263 | year={2011} 264 | } 265 | 266 | @incollection{NEURIPS2019_9015, 267 | title = {{PyTorch}: An Imperative Style, High-Performance Deep Learning Library}, 268 | author = {Paszke, Adam and Gross, Sam and Massa, Francisco and Lerer, Adam and Bradbury, James and Chanan, Gregory and Killeen, Trevor and Lin, Zeming and Gimelshein, Natalia and Antiga, Luca and Desmaison, Alban and Kopf, Andreas and Yang, Edward and DeVito, Zachary and Raison, Martin and Tejani, Alykhan and Chilamkurthy, Sasank and Steiner, Benoit and Fang, Lu and Bai, Junjie and Chintala, Soumith}, 269 | booktitle = {Advances in Neural Information Processing Systems 32}, 270 | editor = {H. Wallach and H. Larochelle and A. Beygelzimer and F. d\textquotesingle Alch\'{e}-Buc and E. Fox and R. Garnett}, 271 | pages = {8024--8035}, 272 | year = {2019}, 273 | publisher = {Curran Associates, Inc.}, 274 | url = {http://papers.neurips.cc/paper/9015-pytorch-an-imperative-style-high-performance-deep-learning-library.pdf} 275 | } 276 | 277 | @article{camp2019pymcr, 278 | title={{PyMCR}: A python library for multivariatecurve resolution analysis with alternating regression (MCR-AR)}, 279 | author={Camp Jr, Charles H}, 280 | journal={Journal of Research of the National Institute of Standards and Technology}, 281 | volume={124}, 282 | pages={1--10}, 283 | year={2019}, 284 | publisher={Superintendent of Documents} 285 | } 286 | 287 | @article{kiers1994hierarchical, 288 | title={Hierarchical relations between methods for simultaneous component analysis and a technique for rotation to a simple simultaneous structure}, 289 | author={Kiers, Henk AL and ten Berge, Jos MF}, 290 | journal={British Journal of mathematical and statistical psychology}, 291 | volume={47}, 292 | number={1}, 293 | pages={109--126}, 294 | year={1994}, 295 | publisher={Wiley Online Library} 296 | } 297 | 298 | @INPROCEEDINGS{chatzis2023timeaware, 299 | author={Chatzis, Christos and Pfeffer, Max and Lind, Pedro and Acar, Evrim}, 300 | booktitle={2023 IEEE 33rd International Workshop on Machine Learning for Signal Processing (MLSP)}, 301 | title={A Time-Aware Tensor Decomposition for Tracking Evolving Patterns}, 302 | year={2023}, 303 | volume={}, 304 | number={}, 305 | pages={1-6}, 306 | doi={10.1109/MLSP55844.2023.10285943}} -------------------------------------------------------------------------------- /examples/plot_bikesharing.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. _bike_example: 3 | 4 | Detecting behavioural patterns in bike sharing data 5 | --------------------------------------------------- 6 | """ 7 | 8 | import matplotlib.pyplot as plt 9 | import numpy as np 10 | import pandas as pd 11 | import plotly.express as px 12 | from tlviz.factor_tools import factor_match_score 13 | from wordcloud import WordCloud 14 | 15 | import matcouply.decomposition as decomposition 16 | from matcouply.data import get_bike_data 17 | 18 | ############################################################################### 19 | # Load the data 20 | # ^^^^^^^^^^^^^ 21 | # This dataset contains three matrices with bike sharing data from three cities in Norway: 22 | # Oslo, Bergen and Trondheim. Each row of these data matrices represent a station, and each column 23 | # represent an hour in 2021. The matrix element :math:`x^{(\text{Oslo})}_{jk}` is the number of trips 24 | # that ended in station :math:`j` in Oslo during hour :math:`k`. More information about this dataset 25 | # is available in the documentation for the ``get_bike_data``-function. 26 | 27 | bike_data = get_bike_data() 28 | matrices = [bike_data["oslo"].values, bike_data["bergen"].values, bike_data["trondheim"].values] 29 | 30 | ############################################################################### 31 | # Fit non-negative PARAFAC2 models 32 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 33 | # Let us fit a non-negative PARAFAC2 model to these matrices to extract underlying patterns. 34 | # We fit five models using different random initializations to avoid bad local minima and 35 | # to ensure that the model is unique. 36 | 37 | all_models = [] 38 | all_errors = [] 39 | lowest_error = float("inf") 40 | for init in range(5): 41 | print("-" * 50) 42 | print("Init:", init) 43 | print("-" * 50) 44 | cmf, diagnostics = decomposition.parafac2_aoadmm( 45 | matrices, 46 | rank=4, 47 | non_negative=True, 48 | n_iter_max=1000, 49 | tol=1e-8, 50 | verbose=100, 51 | return_errors=True, 52 | random_state=init, 53 | ) 54 | 55 | all_models.append(cmf) 56 | all_errors.append(diagnostics) 57 | 58 | if diagnostics.regularized_loss[-1] < lowest_error: 59 | selected_init = init 60 | lowest_error = diagnostics.regularized_loss[-1] 61 | 62 | ############################################################################### 63 | # Check uniqueness of the NN-PARAFAC2 models 64 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 65 | # To check that the model is unique, we check that the initialization runs that 66 | # reach the same loss also find the same components. 67 | 68 | 69 | def get_stacked_CP_tensor(cmf): 70 | weights, factors = cmf 71 | A, B_is, C = factors 72 | 73 | stacked_cp_tensor = (weights, (A, np.concatenate(B_is, axis=0), C)) 74 | return stacked_cp_tensor 75 | 76 | 77 | print("Similarity with selected init") 78 | for init, model in enumerate(all_models): 79 | if init == selected_init: 80 | print(f"Init {init} is the selected init") 81 | continue 82 | 83 | fms = factor_match_score( 84 | get_stacked_CP_tensor(model), get_stacked_CP_tensor(all_models[selected_init]), consider_weights=False 85 | ) 86 | print(f"Similarity with selected init: {fms:}") 87 | 88 | 89 | weights, (A, B_is, C) = all_models[selected_init] 90 | 91 | ############################################################################### 92 | # Convert factor matrices to DataFrame 93 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 94 | # To make visualization easier, we convert the factor matrices to dataframes with interpretable indices. 95 | # We also sort the components by weight and clip the factors at 0 since the AO-ADMM algorithm may allow 96 | # negative values that are very close to 0. 97 | 98 | if weights is None: 99 | weights = 1 100 | 101 | norms = np.linalg.norm(A, axis=0) * np.linalg.norm(B_is[0], axis=0) * np.linalg.norm(C, axis=0) 102 | order = np.argsort(-weights * norms) 103 | 104 | A = pd.DataFrame(np.maximum(0, A[:, order]), index=["Oslo", "Bergen", "Trondheim"]) 105 | B_is = [ 106 | pd.DataFrame(np.maximum(0, B_is[0][:, order]), index=bike_data["oslo"].index), 107 | pd.DataFrame(np.maximum(0, B_is[1][:, order]), index=bike_data["bergen"].index), 108 | pd.DataFrame(np.maximum(0, B_is[2][:, order]), index=bike_data["trondheim"].index), 109 | ] 110 | C = pd.DataFrame(np.maximum(0, C[:, order]), index=bike_data["oslo"].columns) 111 | 112 | ############################################################################### 113 | # Plot the time-components 114 | # ^^^^^^^^^^^^^^^^^^^^^^^^ 115 | 116 | C_melted = C.melt(value_name="Value", var_name="Component", ignore_index=False).reset_index() 117 | fig = px.line( 118 | C_melted, 119 | x="Time of arrival", 120 | y="Value", 121 | facet_row="Component", 122 | hover_data={"Time of arrival": "|%a, %b %e, %H:%M"}, 123 | color="Component", 124 | ) 125 | fig 126 | 127 | ############################################################################### 128 | # By briefly looking at the time-mode components, we immediately see that the fourth 129 | # component displays behaviour during summer, when people in Norway typically have 130 | # their vacation. If we zoom in a bit, we can see interesting behaviour for the first 131 | # three components too. The first three components are the most active during week-days. 132 | # The first component likely represents travel home from work, as it is active in the 133 | # afternoon and the second component likely represents travel too work, as it is active 134 | # in the morning. The third component however, is active the whole day, but mostly 135 | # during the afternoon and the morning. Interestingly, the 'vacation' component 136 | # is most active during weekends instead of week days. 137 | 138 | 139 | ############################################################################### 140 | # Plot the strength of the components in each city 141 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 142 | 143 | A_melted = A.melt(value_name="Value", var_name="Component", ignore_index=False).reset_index() 144 | A_melted["Component"] = A_melted["Component"].astype(str) # Force discrete colormap 145 | fig = px.bar(A_melted, x="index", y="Value", facet_row="Component", color="Component") 146 | fig 147 | 148 | ############################################################################### 149 | # We see that most of the components are most prominant in Oslo (which is the 150 | # largest city too), except for the third component, which is mainly prominent 151 | # in Bergen instead. 152 | 153 | ############################################################################### 154 | # Plot the Oslo-station components as a density-map 155 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 156 | # 157 | # We can visualize the station components as a density-map by first joining the station mode 158 | # factor matrices for each city with a dataframe constainting the station coordinates, and 159 | # then using the ``density_mapbox``-plot from PlotLy Express. 160 | 161 | B_0_melted = ( 162 | B_is[0] 163 | .join(bike_data["station_metadata"]) 164 | .melt(value_name="Value", var_name="Component", ignore_index=False, id_vars=bike_data["station_metadata"].columns) 165 | .reset_index() 166 | ) 167 | 168 | fig = px.density_mapbox( 169 | B_0_melted, 170 | lat="Arrival station latitude", 171 | lon="Arrival station longitude", 172 | z="Value", 173 | zoom=11, 174 | opacity=0.5, 175 | animation_frame="Component", 176 | animation_group="Arrival station ID", 177 | hover_data=["Arrival station name"], 178 | title="Oslo", 179 | ) 180 | fig.update_layout( 181 | mapbox_style="carto-positron", 182 | ) 183 | fig 184 | 185 | ############################################################################### 186 | # By exploring the map, you can see that the first component (active at the end of workdays) is active in residential 187 | # areas in parts of the city that are fairly close to the centre. This pattern is expected as people living in these 188 | # areas are the most likely to have a bike-sharing station close and a short enough commute to bike home from work. 189 | # Furthermore, the second component (active at the beginning of workdays) is active in more central, high-density 190 | # areas where offices and universities are located. The third components activation (active during the whole day), 191 | # is spread throughout the city. Finally, the fourth component (active during weekends in the summer) has activation for 192 | # stations close to popular swimming areas and areas with a lot of restaurants with outdoor seating. 193 | 194 | ############################################################################### 195 | # Plot the Bergen-station components as a density-map 196 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 197 | 198 | B_1_melted = ( 199 | B_is[1] 200 | .join(bike_data["station_metadata"]) 201 | .melt(value_name="Value", var_name="Component", ignore_index=False, id_vars=bike_data["station_metadata"].columns) 202 | .reset_index() 203 | ) 204 | 205 | fig = px.density_mapbox( 206 | B_1_melted, 207 | lat="Arrival station latitude", 208 | lon="Arrival station longitude", 209 | z="Value", 210 | zoom=11, 211 | opacity=0.5, 212 | animation_frame="Component", 213 | animation_group="Arrival station ID", 214 | hover_data=["Arrival station name"], 215 | title="Bergen", 216 | ) 217 | fig.update_layout( 218 | mapbox_style="carto-positron", 219 | ) 220 | fig 221 | 222 | ############################################################################### 223 | # Again, we see that the first component (active at the end of workdays) is active in residential areas 224 | # near the city centre. The second component (active at the beginning of workdays) is also clearly 225 | # active near offices and the universities. The third components activation (active during the whole day), 226 | # is spread throughout the city and residental areas. Finally, the fourth component (active during 227 | # weekends in the summer) has activation for stations close to popular swimming areas, parks and restaurants 228 | # with outdoor seating. 229 | 230 | ############################################################################### 231 | # Plot the Trondheim-station components as a density-map 232 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 233 | 234 | B_2_melted = ( 235 | B_is[2] 236 | .join(bike_data["station_metadata"]) 237 | .melt(value_name="Value", var_name="Component", ignore_index=False, id_vars=bike_data["station_metadata"].columns) 238 | .reset_index() 239 | ) 240 | 241 | fig = px.density_mapbox( 242 | B_2_melted, 243 | lat="Arrival station latitude", 244 | lon="Arrival station longitude", 245 | z="Value", 246 | zoom=11, 247 | opacity=0.5, 248 | animation_frame="Component", 249 | animation_group="Arrival station ID", 250 | hover_data=["Arrival station name"], 251 | title="Trondheim", 252 | ) 253 | fig.update_layout( 254 | mapbox_style="carto-positron", 255 | ) 256 | fig 257 | 258 | ############################################################################### 259 | # Here, we see the same story as with Oslo and Bergen. Component one is active near 260 | # residental areas, component two near offices and universities, component three is 261 | # active throughout the city and component four is active in areas that are popular 262 | # during the summer. 263 | 264 | ############################################################################### 265 | # Plot the station components as word-clouds 266 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 267 | 268 | n_components = B_is[0].shape[1] 269 | B_is_with_meta = [B_i.join(bike_data["station_metadata"]) for B_i in B_is] 270 | 271 | fig, axes = plt.subplots(n_components, 3, figsize=(10, 2 * n_components), dpi=200, squeeze=False) 272 | 273 | for r in range(n_components): 274 | for city_id in range(3): 275 | wc = WordCloud(background_color="black", max_words=1000, colormap="Pastel1") 276 | frequencies = B_is_with_meta[city_id].set_index("Arrival station name")[r].to_dict() 277 | wc.generate_from_frequencies(frequencies) 278 | axes[r, city_id].imshow(wc, interpolation="bilinear") 279 | axes[r, city_id].set_xticks([]) 280 | axes[r, city_id].set_yticks([]) 281 | 282 | axes[0, 0].set_title("Oslo") 283 | axes[0, 1].set_title("Bergen") 284 | axes[0, 2].set_title("Trondheim") 285 | for i in range(4): 286 | axes[i, 0].set_ylabel(f"Component {i}") 287 | plt.show() 288 | 289 | ############################################################################### 290 | # These wordcloud plots confirm the patterns you see on the maps. 291 | # Stations such as "Bankplassen", "Nygårdsporten" and "Vollabekken" are close to high density areas with a lot of 292 | # workplaces for Oslo, Bergen and Trondheim, respectivly. While stations like "Rådhusbrygge 4", "Festplassen" 293 | # and "Lade idrettsannlegg vest" are close to popular summer activities. 294 | -------------------------------------------------------------------------------- /examples/plot_custom_penalty.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example with custom penalty class for unimodality for all but one component 3 | --------------------------------------------------------------------------- 4 | 5 | In this example, we first demonstrate how to specify exactly how the penalties are imposed in the AO-ADMM fitting 6 | procedure. Then, we create a custom penalty that imposes non-negativity on all component vectors and unimodality on all 7 | but one of the component vectors. 8 | """ 9 | 10 | ############################################################################### 11 | # Imports 12 | # ^^^^^^^ 13 | 14 | import matplotlib.pyplot as plt 15 | import numpy as np 16 | import scipy.stats as stats 17 | import tensorly as tl 18 | from tlviz.factor_tools import factor_match_score 19 | 20 | import matcouply.decomposition as decomposition 21 | from matcouply.coupled_matrices import CoupledMatrixFactorization 22 | 23 | ############################################################################### 24 | # Setup 25 | # ^^^^^ 26 | 27 | I, J, K = 10, 40, 15 28 | rank = 3 29 | noise_level = 0.2 30 | rng = np.random.default_rng(0) 31 | 32 | 33 | def normalize(x): 34 | return x / tl.sqrt(tl.sum(x**2, axis=0, keepdims=True)) 35 | 36 | 37 | ############################################################################### 38 | # Generate simulated data that follows the PARAFAC2 constraint 39 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 40 | # We start by generating some components, for the :math:`\mathbf{A}` and :math:`\mathbf{C}` components, we use uniformly 41 | # distributed component vector elements. For the :math:`\mathbf{B}^{(i)}`-components, we create two unimodal vectors and one 42 | # component vector with uniformly distributed elements, and shift these vectors for each :math:`i`. 43 | 44 | # Random uniform components 45 | A = rng.uniform(size=(I, rank)) + 0.1 # Add 0.1 to ensure that there is signal for all components for all slices 46 | A = tl.tensor(A) 47 | 48 | B_0 = tl.zeros((J, rank)) 49 | # Simulating unimodal components 50 | t = np.linspace(-10, 10, J) 51 | for r in range(rank - 1): 52 | sigma = rng.uniform(0.5, 1.5) 53 | mu = rng.uniform(-10, 0) 54 | B_0[:, r] = stats.norm.pdf(t, loc=mu, scale=sigma) 55 | # The final component is random uniform, not unimodal 56 | B_0[:, rank - 1] = rng.uniform(size=(J,)) 57 | 58 | # Shift the components for each slice 59 | B_is = [np.roll(B_0, i, axis=0) for i in range(I)] 60 | B_is = [tl.tensor(B_i) for B_i in B_is] 61 | 62 | 63 | # Random uniform components 64 | C = rng.uniform(size=(K, rank)) 65 | C = tl.tensor(C) 66 | 67 | ############################################################################### 68 | # Plot the simulated components 69 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 70 | 71 | fig, axes = plt.subplots(2, 3, tight_layout=True) 72 | 73 | axes[0, 0].plot(normalize(A)) 74 | axes[0, 0].set_title("$\\mathbf{A}$") 75 | 76 | axes[0, 1].plot(normalize(C)) 77 | axes[0, 1].set_title("$\\mathbf{C}$") 78 | 79 | axes[0, 2].axis("off") 80 | 81 | axes[1, 0].plot(normalize(B_is[0])) 82 | axes[1, 0].set_title("$\\mathbf{B}_0$") 83 | 84 | axes[1, 1].plot(normalize(B_is[I // 2])) 85 | axes[1, 1].set_title(f"$\\mathbf{{B}}_{{{I//2}}}$") 86 | 87 | axes[1, 2].plot(normalize(B_is[-1])) 88 | axes[1, 2].set_title(f"$\\mathbf{{B}}_{{{I-1}}}$") 89 | fig.legend(["Component 0", "Component 1", "Component 2"], bbox_to_anchor=(0.95, 0.75), loc="center right") 90 | fig.suptitle("Simulated components") 91 | 92 | plt.show() 93 | 94 | ############################################################################### 95 | # For the :math:`\mathbf{B}^{(i)}`-s, we see that component 0 and 1 are unimodal, while component 2 is not. 96 | 97 | ############################################################################### 98 | # Create the coupled matrix factorization, simulated data matrices and add noise 99 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 100 | 101 | cmf = CoupledMatrixFactorization((None, (A, B_is, C))) 102 | matrices = cmf.to_matrices() 103 | noise = [tl.tensor(rng.uniform(size=M.shape)) for M in matrices] 104 | noisy_matrices = [M + N * noise_level * tl.norm(M) / tl.norm(N) for M, N in zip(matrices, noise)] 105 | 106 | 107 | ############################################################################### 108 | # Use the ``regs`` parameter to input regularization classes 109 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 110 | # Matcouply automatically parses the constraints from the ``parafac2_aoadmm`` and ``cmf_aoadmm`` funciton 111 | # arguments. However, sometimes, you may want full control over how a penalty is implemented. In that case, 112 | # the ``regs``-argument is useful. This argument makes it possible to specify exactly which penalty instances 113 | # to use. 114 | # 115 | # Since the components are non-negative, it makes sense to fit a non-negative PARAFAC2 model, however, 116 | # we also know that two of the :math:`\mathbf{B}^{(i)}`-component vectors are unimodal, so we first try with 117 | # a fully unimodal decomposition. 118 | 119 | from matcouply.penalties import NonNegativity, Unimodality 120 | 121 | lowest_error = float("inf") 122 | for init in range(4): 123 | print("Init:", init) 124 | out = decomposition.parafac2_aoadmm( 125 | noisy_matrices, 126 | rank, 127 | n_iter_max=1000, 128 | regs=[[NonNegativity()], [Unimodality(non_negativity=True)], [NonNegativity()]], 129 | return_errors=True, 130 | random_state=init, 131 | verbose=50, # Only print every 50 iteration 132 | ) 133 | if out[1].regularized_loss[-1] < lowest_error and out[1].satisfied_stopping_condition: 134 | out_cmf, diagnostics = out 135 | lowest_error = diagnostics.rec_errors[-1] 136 | 137 | print("=" * 50) 138 | print(f"Final reconstruction error: {lowest_error:.3f}") 139 | print(f"Feasibility gap for A: {diagnostics.feasibility_gaps[-1][0]}") 140 | print(f"Feasibility gap for B_is: {diagnostics.feasibility_gaps[-1][1]}") 141 | print(f"Feasibility gap for C: {diagnostics.feasibility_gaps[-1][2]}") 142 | 143 | ############################################################################### 144 | # Compute factor match score to measure the accuracy of the recovered components 145 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 146 | 147 | 148 | def get_stacked_CP_tensor(cmf): 149 | weights, factors = cmf 150 | A, B_is, C = factors 151 | 152 | stacked_cp_tensor = (weights, (A, np.concatenate(B_is, axis=0), C)) 153 | return stacked_cp_tensor 154 | 155 | 156 | fms, permutation = factor_match_score( 157 | get_stacked_CP_tensor(cmf), get_stacked_CP_tensor(out_cmf), consider_weights=False, return_permutation=True 158 | ) 159 | print(f"Factor match score: {fms}") 160 | 161 | ############################################################################### 162 | # Plot the recovered components 163 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 164 | 165 | out_weights, (out_A, out_B_is, out_C) = out_cmf 166 | out_A = out_A[:, permutation] 167 | out_B_is = [out_B_i[:, permutation] for out_B_i in out_B_is] 168 | out_C = out_C[:, permutation] 169 | 170 | fig, axes = plt.subplots(2, 3, tight_layout=True) 171 | 172 | axes[0, 0].plot(normalize(out_A)) 173 | axes[0, 0].set_title("$\\mathbf{A}$") 174 | 175 | axes[0, 1].plot(normalize(out_C)) 176 | axes[0, 1].set_title("$\\mathbf{C}$") 177 | 178 | axes[0, 2].axis("off") 179 | 180 | axes[1, 0].plot(normalize(out_B_is[0])) 181 | axes[1, 0].set_title("$\\mathbf{B}_0$") 182 | 183 | axes[1, 1].plot(normalize(out_B_is[I // 2])) 184 | axes[1, 1].set_title(f"$\\mathbf{{B}}_{{{I//2}}}$") 185 | 186 | axes[1, 2].plot(normalize(out_B_is[-1])) 187 | axes[1, 2].set_title(f"$\\mathbf{{B}}_{{{I-1}}}$") 188 | fig.legend(["Component 0", "Component 1", "Component 2"], bbox_to_anchor=(0.95, 0.75), loc="center right") 189 | 190 | fig.suptitle(r"Unimodality on the $\mathbf{B}^{(i)}$-components") 191 | plt.show() 192 | 193 | ############################################################################### 194 | # We see that the :math:`\mathbf{C}`-component vectors all follow the same pattern and that the the 195 | # :math:`\mathbf{A}`-component vectors all follow a similar pattern. This is not the case with the real, 196 | # uncorrelated random, components. The :math:`\mathbf{B}^{(i)}`-component vectors also follow a strange pattern 197 | # with peaks jumping forwards and backwards, which we know are not the case with the real components either. 198 | # 199 | # However, this strange behaviour is not too surprising, considering that there are only two uniomdal component 200 | # vectors in the data. So this model that assumes all unimodal components might be too restrictive. 201 | 202 | 203 | ############################################################################### 204 | # Create a custom penalty class for unimodality in all but one class 205 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 206 | # Now, we want to make a custom penalty that imposes unimodality on the first :math:`R-1` component 207 | # vectors. Since unimodality are imposed column-wise, we know that this constraint is a matrix penalty 208 | # (as opposed to a row-vector penalty or a multi-matrix penalty), so we import the ``MatrixPenalty``-superclass 209 | # from ``matcouply.penalties``. We also know that unimodality is a hard constraint, so we import 210 | # the ``HardConstraintMixin``-class, which provides a ``penalty``-method that always returns 0 and has an informative 211 | # docstring. 212 | 213 | from matcouply._doc_utils import ( 214 | copy_ancestor_docstring, # Helper decorator that makes it possible for ADMMPenalties to inherit a docstring 215 | ) 216 | from matcouply._unimodal_regression import unimodal_regression # The unimodal regression implementation 217 | from matcouply.penalties import HardConstraintMixin, MatrixPenalty 218 | 219 | 220 | class UnimodalAllExceptLast(HardConstraintMixin, MatrixPenalty): 221 | def __init__(self, non_negativity=False, aux_init="random_uniform", dual_init="random_uniform"): 222 | super().__init__(aux_init, dual_init) 223 | self.non_negativity = non_negativity 224 | 225 | @copy_ancestor_docstring 226 | def factor_matrix_update(self, factor_matrix, feasibility_penalty, aux): 227 | new_factor_matrix = tl.copy(factor_matrix) 228 | new_factor_matrix[:, :-1] = unimodal_regression(factor_matrix[:, :-1], non_negativity=self.non_negativity) 229 | if self.non_negativity: 230 | new_factor_matrix = tl.clip(new_factor_matrix, 0) 231 | return new_factor_matrix 232 | 233 | 234 | ############################################################################### 235 | # Fit a non-negative PARAFAC2 model using the custom penalty class on the B mode 236 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 237 | # Now, we can fit a new model with the custom unimodality class 238 | 239 | lowest_error = float("inf") 240 | for init in range(4): 241 | print("Init:", init) 242 | out = decomposition.parafac2_aoadmm( 243 | noisy_matrices, 244 | rank, 245 | n_iter_max=1000, 246 | regs=[[NonNegativity()], [UnimodalAllExceptLast(non_negativity=True)], [NonNegativity()]], 247 | return_errors=True, 248 | random_state=init, 249 | verbose=50, # Only print every 50 iteration 250 | ) 251 | if out[1].regularized_loss[-1] < lowest_error and out[1].satisfied_stopping_condition: 252 | out_cmf, diagnostics = out 253 | lowest_error = diagnostics.rec_errors[-1] 254 | 255 | print("=" * 50) 256 | print(f"Final reconstruction error: {lowest_error:.3f}") 257 | print(f"Feasibility gap for A: {diagnostics.feasibility_gaps[-1][0]}") 258 | print(f"Feasibility gap for B_is: {diagnostics.feasibility_gaps[-1][1]}") 259 | print(f"Feasibility gap for C: {diagnostics.feasibility_gaps[-1][2]}") 260 | 261 | ############################################################################### 262 | # Compute factor match score to measure the accuracy of the recovered components 263 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 264 | 265 | fms, permutation = factor_match_score( 266 | get_stacked_CP_tensor(cmf), get_stacked_CP_tensor(out_cmf), consider_weights=False, return_permutation=True 267 | ) 268 | print(f"Factor match score: {fms}") 269 | 270 | ############################################################################### 271 | # We see that the factor match score is much better now compared to before! 272 | 273 | ############################################################################### 274 | # Plot the recovered components 275 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 276 | 277 | out_weights, (out_A, out_B_is, out_C) = out_cmf 278 | out_A = out_A[:, permutation] 279 | out_B_is = [out_B_i[:, permutation] for out_B_i in out_B_is] 280 | out_C = out_C[:, permutation] 281 | 282 | fig, axes = plt.subplots(2, 3, tight_layout=True) 283 | 284 | axes[0, 0].plot(normalize(out_A)) 285 | axes[0, 0].set_title("$\\mathbf{A}$") 286 | 287 | axes[0, 1].plot(normalize(out_C)) 288 | axes[0, 1].set_title("$\\mathbf{C}$") 289 | 290 | axes[0, 2].axis("off") 291 | 292 | axes[1, 0].plot(normalize(out_B_is[0])) 293 | axes[1, 0].set_title("$\\mathbf{B}_0$") 294 | 295 | axes[1, 1].plot(normalize(out_B_is[I // 2])) 296 | axes[1, 1].set_title(f"$\\mathbf{{B}}_{{{I//2}}}$") 297 | 298 | axes[1, 2].plot(normalize(out_B_is[-1])) 299 | axes[1, 2].set_title(f"$\\mathbf{{B}}_{{{I-1}}}$") 300 | fig.legend(["Component 0", "Component 1", "Component 2"], bbox_to_anchor=(0.95, 0.75), loc="center right") 301 | fig.suptitle(r"Custom uniomdality on the $\mathbf{B}^{(i)}$-components") 302 | plt.show() 303 | 304 | ############################################################################### 305 | # We see that the model finds much more sensible component vectors. The :math:`\mathbf{A}`- and 306 | # :math:`\mathbf{C}`-component vectors no longer seem correlated, and the peaks of the :math:`\mathbf{B}^{(i)}`-component 307 | # vectors no longer jump around. 308 | 309 | 310 | ############################################################################### 311 | # Automated unit test creation with MatCoupLy 312 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 313 | # 314 | # Matcouply can autogenerate unit tests for new penalty types. Simply create a test class that inherits from 315 | # ``matcouply.testing.BaseTestFactorMatrixPenalty`` (or ``matcouply.testing.BaseTestFactorMatricesPenalty`` 316 | # or ``matcouply.testing.BaseTestRowPenalty`` if your penalty type is either a row-wise penalty or a multi matrix 317 | # penalty), overload the ``get_invariant_matrix`` and ``get_non_invariant_matrix`` methods (without changing the 318 | # signature) and MatCoupLy will generate a set of useful unit tests. The ``get_invariant_matrix`` method should 319 | # generate a matrix that is not modified by the proximal operator and the ``get_non_invariant_matrix`` should generate 320 | # a matrix that will be modified by the proximal operator. 321 | # 322 | # Note that to use this functionality in your own project, you need to add the MatCoupLy test fixtures to your test 323 | # suite. You can do this by adding the line ``pytest_plugins = ["matcouply.testing.fixtures"]`` to your 324 | # `conftest.py `_ 325 | # file. 326 | 327 | from matcouply.testing import MixinTestHardConstraint, BaseTestFactorMatrixPenalty 328 | 329 | class TestUnimodalAllExceptLast( 330 | MixinTestHardConstraint, BaseTestFactorMatrixPenalty 331 | ): 332 | PenaltyType = UnimodalAllExceptLast 333 | penalty_default_kwargs = {} 334 | min_rows = 3 335 | min_columns = 2 336 | 337 | def get_invariant_matrix(self, rng, shape): 338 | matrix = tl.zeros(shape) 339 | I, J = shape 340 | t = np.linspace(-10, 10, I) 341 | for j in range(J-1): 342 | sigma = rng.uniform(0.5, 1) 343 | mu = rng.uniform(-5, 5) 344 | matrix[:, j] = stats.norm.pdf(t, loc=mu, scale=sigma) 345 | matrix[:, J-1] = rng.uniform(size=I) 346 | return matrix 347 | 348 | def get_non_invariant_matrix(self, rng, shape): 349 | # There are at least 3 rows 350 | M = rng.uniform(size=shape) 351 | M[1, :-1] = -1 # M is positive, so setting the second element to -1 makes it impossible for it to be unimodal 352 | return M 353 | 354 | print(""" 355 | Auto generated unit tests: 356 | ==========================\ 357 | """) 358 | for name in dir(TestUnimodalAllExceptLast): 359 | if "test" in name: 360 | print(f" * {name}") 361 | -------------------------------------------------------------------------------- /tests/test_coupled_matrices.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | from copy import copy 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | import tensorly as tl 9 | from tensorly.testing import assert_array_equal 10 | 11 | from matcouply import coupled_matrices, random 12 | from matcouply.coupled_matrices import ( 13 | CoupledMatrixFactorization, 14 | cmf_to_matrices, 15 | cmf_to_matrix, 16 | ) 17 | from matcouply.testing import assert_allclose 18 | 19 | 20 | def test_from_cp_tensor(rng): 21 | # Test that cp_tensor converted to coupled matrix factorization constructs same dense tensor 22 | cp_tensor = tl.random.random_cp((10, 15, 20), 3) 23 | cmf = CoupledMatrixFactorization.from_CPTensor(cp_tensor) 24 | 25 | dense_tensor_cp = cp_tensor.to_tensor() 26 | dense_tensor_cmf = cmf.to_tensor() 27 | assert_allclose(dense_tensor_cmf, dense_tensor_cp, rtol=1e-6) # 1e-6 due to PyTorch single precision 28 | 29 | # Test that it fails when given CP tensor of order other than 3 30 | cp_tensor = tl.random.random_cp((10, 15, 20, 25), 3) 31 | with pytest.raises(ValueError): 32 | cmf = CoupledMatrixFactorization.from_CPTensor(cp_tensor) 33 | 34 | cp_tensor = tl.random.random_cp((10, 15), 3) 35 | with pytest.raises(ValueError): 36 | cmf = CoupledMatrixFactorization.from_CPTensor(cp_tensor) 37 | 38 | # Test that the B_is created from the cp tensor are copies, not the same view 39 | weights, (A, B_is, C) = cmf 40 | B_0 = tl.copy(B_is[0]) 41 | B_is[1][0] += 5 42 | assert_allclose(B_0, B_is[0]) 43 | 44 | 45 | def test_from_parafac2_tensor(rng, random_ragged_shapes): 46 | # Test that parafac2 tensor converted to coupled matrix factorization constructs same dense tensor 47 | rank = 3 48 | random_ragged_shapes = [(max(rank, J_i), K) for J_i, K in random_ragged_shapes] 49 | parafac2_tensor = tl.random.random_parafac2(random_ragged_shapes, rank) 50 | cmf = CoupledMatrixFactorization.from_Parafac2Tensor(parafac2_tensor) 51 | 52 | dense_tensor_pf2 = parafac2_tensor.to_tensor() 53 | dense_tensor_cmf = cmf.to_tensor() 54 | assert_allclose(dense_tensor_cmf, dense_tensor_pf2) 55 | 56 | 57 | def test_coupled_matrix_factorization(rng, random_regular_shapes): 58 | rank = 4 59 | cmf = random.random_coupled_matrices(random_regular_shapes, rank, random_state=rng) 60 | 61 | # Check that the length is equal to 2 (weights and factor matrices) 62 | assert len(cmf) == 2 63 | with pytest.raises(IndexError): 64 | cmf[2] 65 | 66 | # Check that the first element is the weight array 67 | assert_array_equal(cmf[0], cmf.weights) 68 | 69 | # Check that the second element is the factor matrices 70 | A1, B_is1, C1 = cmf.factors 71 | A2, B_is2, C2 = cmf[1] 72 | 73 | assert_array_equal(A1, A2) 74 | assert_array_equal(C1, C2) 75 | 76 | for B_i1, B_i2 in zip(B_is1, B_is2): 77 | assert_array_equal(B_i1, B_i2) 78 | 79 | 80 | def test_validate_cmf(rng, random_ragged_cmf): 81 | cmf, shapes, rank = random_ragged_cmf 82 | val_shapes, val_rank = coupled_matrices._validate_cmf(cmf) 83 | assert val_rank == rank 84 | assert shapes == val_shapes 85 | 86 | weights, (A, B_is, C) = cmf 87 | 88 | ##### 89 | # Check that non-tensor inputs result in TypeErrors 90 | # The weights is a scalar 91 | with pytest.raises(TypeError): 92 | coupled_matrices._validate_cmf((3, (A, B_is, C))) 93 | with pytest.raises(TypeError): 94 | coupled_matrices._validate_cmf((weights, (1, B_is, C))) 95 | with pytest.raises(TypeError): 96 | coupled_matrices._validate_cmf((weights, (None, B_is, C))) 97 | with pytest.raises(TypeError): 98 | coupled_matrices._validate_cmf((weights, (A, 1, C))) 99 | with pytest.raises(TypeError): 100 | coupled_matrices._validate_cmf((weights, (A, None, C))) 101 | with pytest.raises(TypeError): 102 | B_is_copy = copy(B_is) 103 | B_is_copy[0] = 1 104 | coupled_matrices._validate_cmf((weights, (A, B_is_copy, C))) 105 | with pytest.raises(TypeError): 106 | B_is_copy = copy(B_is) 107 | B_is_copy[0] = None 108 | coupled_matrices._validate_cmf((weights, (A, B_is_copy, C))) 109 | with pytest.raises(TypeError): 110 | coupled_matrices._validate_cmf((weights, (A, B_is, 1))) 111 | with pytest.raises(TypeError): 112 | coupled_matrices._validate_cmf((weights, (A, B_is, None))) 113 | 114 | ##### 115 | # Check that None-valued weights do not raise any errors 116 | coupled_matrices._validate_cmf((None, (A, B_is, C))) 117 | 118 | ##### 119 | # Check that wrongly shaped inputs result in ValueErrors 120 | 121 | # ## Weights 122 | # The weights is a matrix 123 | with pytest.raises(ValueError): 124 | coupled_matrices._validate_cmf((tl.ones((rank, rank)), (A, B_is, C))) 125 | # Wrong number of weights 126 | with pytest.raises(ValueError): 127 | coupled_matrices._validate_cmf((tl.ones((rank + 1,)), (A, B_is, C))) 128 | 129 | # ## Factor matrices 130 | # One of the matrices is a third order tensor 131 | with pytest.raises(ValueError): 132 | coupled_matrices._validate_cmf((weights, (tl.tensor(rng.random_sample(size=(4, rank, rank))), B_is, C))) 133 | with pytest.raises(ValueError): 134 | coupled_matrices._validate_cmf((weights, (A, B_is, tl.tensor(rng.random_sample(size=(4, rank, rank)))))) 135 | with pytest.raises(ValueError): 136 | B_is_copy = copy(B_is) 137 | B_is_copy[0] = tl.tensor(rng.random_sample(size=(4, rank, rank))) 138 | coupled_matrices._validate_cmf((weights, (A, B_is_copy, C))) 139 | 140 | # One of the matrices is a vector 141 | with pytest.raises(ValueError): 142 | coupled_matrices._validate_cmf((weights, (tl.tensor(rng.random_sample(size=(rank,))), B_is, C))) 143 | with pytest.raises(ValueError): 144 | coupled_matrices._validate_cmf((weights, (A, B_is, tl.tensor(rng.random_sample(size=(rank,)))))) 145 | with pytest.raises(ValueError): 146 | B_is_copy = copy(B_is) 147 | B_is_copy[0] = tl.tensor(rng.random_sample(size=(rank,))) 148 | coupled_matrices._validate_cmf((weights, (A, B_is_copy, C))) 149 | 150 | # ## Check wrong rank 151 | # Check with incorrect rank for one of the factors 152 | invalid_A = tl.tensor(rng.random_sample((len(shapes), rank + 1))) 153 | invalid_C = tl.tensor(rng.random_sample((shapes[0][1], rank + 1))) 154 | invalid_B_is_2 = [tl.tensor(rng.random_sample((j_i, rank))) for j_i, k in shapes] 155 | invalid_B_is_2[0] = tl.tensor(rng.random_sample((shapes[0][0], rank + 1))) 156 | 157 | # Both A and C have the wrong rank: 158 | with pytest.raises(ValueError): 159 | coupled_matrices._validate_cmf((weights, (invalid_A, B_is, invalid_C))) 160 | 161 | # One of the matrices (A, C or any of B_is) have wrong rank 162 | with pytest.raises(ValueError): 163 | coupled_matrices._validate_cmf((weights, (invalid_A, B_is, C))) 164 | with pytest.raises(ValueError): 165 | coupled_matrices._validate_cmf((weights, (A, B_is, invalid_C))) 166 | with pytest.raises(ValueError): 167 | coupled_matrices._validate_cmf((weights, (A, invalid_B_is_2, C))) 168 | 169 | # Number of rows in A does not match number of B_is 170 | with pytest.raises(ValueError): 171 | coupled_matrices._validate_cmf((weights, (A[:-1, :], B_is, C))) 172 | with pytest.raises(ValueError): 173 | coupled_matrices._validate_cmf((weights, (A, B_is[:-1], C))) 174 | 175 | 176 | def test_cmf_to_matrix(rng, random_ragged_cmf): 177 | cmf, shapes, rank = random_ragged_cmf 178 | weights, (A, B_is, C) = cmf 179 | 180 | # Compare matcouply implementation with our own implementation 181 | for i, B_i in enumerate(B_is): 182 | matrix = cmf.to_matrix(i) 183 | manually_assembled_matrix = (weights * A[i] * B_i) @ C.T 184 | assert_allclose(matrix, manually_assembled_matrix) 185 | 186 | # Test that it always fails when a single B_i is invalid and validate=True 187 | invalid_B_is = copy(B_is) 188 | invalid_B_is[0] = tl.tensor(rng.random_sample((tl.shape(B_is[0])[0], tl.shape(B_is[0])[1] + 1))) 189 | invalid_cmf = (weights, (A, invalid_B_is, C)) 190 | 191 | for i, _ in enumerate(invalid_B_is): 192 | with pytest.raises(ValueError): 193 | cmf_to_matrix(invalid_cmf, i, validate=True) 194 | 195 | # Test that it doesn't fail when a single B_i is invalid and validate=False. 196 | for i, _ in enumerate(invalid_B_is): 197 | if i == 0: # invalid B_i for i = 0 198 | continue 199 | cmf_to_matrix(invalid_cmf, i, validate=False) 200 | 201 | # Check that validate is called only when validate=True 202 | with patch("matcouply.coupled_matrices._validate_cmf", return_value=(shapes, rank)) as mock: 203 | coupled_matrices.cmf_to_matrix(cmf, 0, validate=False) 204 | mock.assert_not_called() 205 | coupled_matrices.cmf_to_matrix(cmf, 0, validate=True) 206 | mock.assert_called() 207 | 208 | 209 | def test_cmf_to_matrices(rng, random_ragged_cmf): 210 | cmf, shapes, rank = random_ragged_cmf 211 | weights, (A, B_is, C) = cmf 212 | 213 | # Compare matcouply implementation with our own implementation 214 | matrices = cmf.to_matrices() 215 | for i, matrix in enumerate(matrices): 216 | manually_assembled_matrix = (weights * A[i] * B_is[i]) @ C.T 217 | assert_allclose(matrix, manually_assembled_matrix) 218 | 219 | invalid_B_is = copy(B_is) 220 | invalid_B_is[0] = tl.tensor(rng.random_sample((tl.shape(B_is[0])[0], tl.shape(B_is[0])[1] + 1))) 221 | invalid_cmf = (weights, (A, invalid_B_is, C)) 222 | 223 | # Test that it always fails when a single B_i is invalid and validate=True 224 | with pytest.raises(ValueError): 225 | cmf_to_matrices(invalid_cmf, validate=True) 226 | 227 | # Check that validate is called only when validate=True 228 | with patch("matcouply.coupled_matrices._validate_cmf", return_value=(shapes, rank)) as mock: 229 | coupled_matrices.cmf_to_matrices(cmf, validate=False) 230 | mock.assert_not_called() 231 | coupled_matrices.cmf_to_matrices(cmf, validate=True) 232 | mock.assert_called() 233 | 234 | 235 | def test_cmf_to_slice(rng, random_ragged_cmf): 236 | cmf, shapes, rank = random_ragged_cmf 237 | weights, (A, B_is, C) = cmf 238 | 239 | # Compare matcouply implementation with our own implementation 240 | for i, B_i in enumerate(B_is): 241 | matrix = cmf.to_matrix(i) 242 | slice_ = coupled_matrices.cmf_to_slice(cmf, i) 243 | assert_allclose(matrix, slice_) 244 | 245 | # Check that to_slice is an alias for to_matrix 246 | with patch("matcouply.coupled_matrices.cmf_to_matrix") as mock: 247 | coupled_matrices.cmf_to_slice(cmf, 0) 248 | mock.assert_called() 249 | 250 | 251 | def test_cmf_to_slices(rng, random_ragged_cmf): 252 | cmf, shapes, rank = random_ragged_cmf 253 | 254 | # Compare matcouply implementation with our own implementation 255 | matrices = cmf.to_matrices() 256 | slices = coupled_matrices.cmf_to_slices(cmf) 257 | for slice_, matrix in zip(slices, matrices): 258 | assert_allclose(matrix, slice_) 259 | 260 | # Check that to_slices is an alias for to_matrices 261 | with patch("matcouply.coupled_matrices.cmf_to_matrices", return_value=matrices) as mock: 262 | coupled_matrices.cmf_to_slices(cmf) 263 | mock.assert_called() 264 | 265 | 266 | def test_cmf_to_tensor(rng, random_regular_cmf): 267 | cmf, shapes, rank = random_regular_cmf 268 | weights, (A, B_is, C) = cmf 269 | 270 | # Check that the tensor slices are equal to the manually assembled matrices 271 | tensor = cmf.to_tensor() 272 | for i, matrix in enumerate(tensor): 273 | manually_assembled_matrix = (weights * A[i] * B_is[i]) @ C.T 274 | assert_allclose(matrix, manually_assembled_matrix) 275 | 276 | # Check that the tensor slices when the matrices have different number 277 | # of rows are equal to the manually assembled matrices padded by zeros 278 | ragged_shapes = ((15, 10), (10, 10), (15, 10), (10, 10)) 279 | max_length = max(length for length, _ in ragged_shapes) 280 | ragged_cmf = random.random_coupled_matrices(ragged_shapes, rank, random_state=rng) 281 | weights, (A, B_is, C) = ragged_cmf 282 | 283 | ragged_tensor = ragged_cmf.to_tensor() 284 | for i, matrix in enumerate(ragged_tensor): 285 | manually_assembled_matrix = (weights * A[i] * B_is[i]) @ C.T 286 | 287 | shape = ragged_shapes[i] 288 | assert_allclose(matrix[: shape[0]], manually_assembled_matrix) 289 | assert_allclose(matrix[shape[0] :], 0) 290 | assert matrix.shape[0] == max_length 291 | 292 | with patch("matcouply.coupled_matrices._validate_cmf", return_value=(ragged_shapes, rank)) as mock: 293 | coupled_matrices.cmf_to_tensor(ragged_cmf, validate=False) 294 | mock.assert_not_called() 295 | coupled_matrices.cmf_to_tensor(ragged_cmf, validate=True) 296 | mock.assert_called() 297 | 298 | 299 | def test_cmf_to_unfolded(rng, random_ragged_cmf): 300 | cmf, shapes, rank = random_ragged_cmf 301 | 302 | # with padding 303 | tensor = cmf.to_tensor() 304 | for mode in range(3): 305 | unfolded_tensor = cmf.to_unfolded(mode) 306 | assert_allclose(unfolded_tensor, tl.unfold(tensor, mode)) 307 | 308 | with patch("matcouply.coupled_matrices._validate_cmf", return_value=(shapes, rank)) as mock: 309 | coupled_matrices.cmf_to_unfolded(cmf, mode, validate=False) 310 | mock.assert_not_called() 311 | coupled_matrices.cmf_to_unfolded(cmf, mode, validate=True) 312 | mock.assert_called() 313 | 314 | # without padding 315 | with pytest.raises(ValueError): 316 | cmf.to_unfolded(pad=False, mode=0) 317 | with pytest.raises(ValueError): 318 | cmf.to_unfolded(pad=False, mode=1) 319 | 320 | matrices = cmf.to_matrices() 321 | 322 | unfolded_matrices = tl.transpose(tl.concatenate(matrices, axis=0)) 323 | assert_allclose(cmf.to_unfolded(pad=False, mode=2), unfolded_matrices) 324 | with patch("matcouply.coupled_matrices._validate_cmf", return_value=(shapes, rank)) as mock: 325 | coupled_matrices.cmf_to_unfolded(cmf, 2, pad=False, validate=False) 326 | mock.assert_not_called() 327 | coupled_matrices.cmf_to_unfolded(cmf, 2, pad=False, validate=True) 328 | mock.assert_called() 329 | 330 | 331 | def test_cmf_to_vec(rng, random_ragged_cmf): 332 | cmf, shapes, rank = random_ragged_cmf 333 | 334 | # With zero padding 335 | tensor = cmf.to_tensor() 336 | vector = tl.reshape(tensor, (-1,)) 337 | assert_allclose(cmf.to_vec(), vector) 338 | 339 | # Without zero padding TODO:CHECK 340 | matrices = cmf.to_matrices() 341 | assert_allclose(cmf.to_vec(pad=False), tl.concatenate([tl.reshape(matrix, (-1,)) for matrix in matrices])) 342 | 343 | # Test that validate is called when it should be 344 | with patch("matcouply.coupled_matrices._validate_cmf", return_value=(shapes, rank)) as mock: 345 | coupled_matrices.cmf_to_vec(cmf, validate=False) 346 | mock.assert_not_called() 347 | coupled_matrices.cmf_to_vec(cmf, validate=True) 348 | mock.assert_called() 349 | 350 | 351 | def test_from_CPTensor_with_shapes(rng): 352 | A = tl.tensor(rng.standard_normal(size=(5, 4))) 353 | B = tl.tensor(rng.standard_normal(size=(10, 4))) 354 | C = tl.tensor(rng.standard_normal(size=(15, 4))) 355 | 356 | # Check that we get decomposition with the correct shape 357 | cp_tensor = tl.cp_tensor.CPTensor((None, (A, B, C))) 358 | full_shapes = ((10, 15), (10, 15), (10, 15), (10, 15), (10, 15)) 359 | cmf_full = coupled_matrices.CoupledMatrixFactorization.from_CPTensor(cp_tensor, shapes=full_shapes) 360 | assert full_shapes == cmf_full.shape 361 | 362 | ragged_shapes = ((9, 15), (10, 15), (8, 15), (10, 15), (10, 15)) 363 | cmf_ragged = coupled_matrices.CoupledMatrixFactorization.from_CPTensor(cp_tensor, shapes=ragged_shapes) 364 | assert ragged_shapes == cmf_ragged.shape 365 | 366 | # Check that invalid shapes yields valueerror 367 | shapes_invalid_I = ((10, 15), (10, 15), (10, 15), (10, 15), (10, 15), (10, 15)) 368 | with pytest.raises(ValueError): 369 | coupled_matrices.CoupledMatrixFactorization.from_CPTensor(cp_tensor, shapes=shapes_invalid_I) 370 | shapes_invalid_J = ((10, 15), (10, 15), (10, 15), (11, 15), (10, 15)) 371 | with pytest.raises(ValueError): 372 | coupled_matrices.CoupledMatrixFactorization.from_CPTensor(cp_tensor, shapes=shapes_invalid_J) 373 | shapes_invalid_K = ((10, 16), (10, 16), (10, 16), (10, 16), (10, 16)) 374 | with pytest.raises(ValueError): 375 | coupled_matrices.CoupledMatrixFactorization.from_CPTensor(cp_tensor, shapes=shapes_invalid_K) 376 | -------------------------------------------------------------------------------- /src/matcouply/testing/admm_penalty.py: -------------------------------------------------------------------------------- 1 | # MIT License: Copyright (c) 2022, Marie Roald. 2 | # See the LICENSE file in the root directory for full license text. 3 | 4 | import numpy as np 5 | import pytest 6 | import tensorly as tl 7 | from tensorly.testing import assert_array_equal 8 | 9 | from matcouply import penalties 10 | 11 | 12 | def assert_allclose(actual, desired, *args, **kwargs): 13 | np.testing.assert_allclose(tl.to_numpy(actual), tl.to_numpy(desired), *args, **kwargs) 14 | 15 | 16 | # # Interfaces only, not code to be run or inherited from: 17 | class BaseTestADMMPenalty: 18 | PenaltyType = penalties.ADMMPenalty 19 | penalty_default_kwargs = {} 20 | min_rows = 1 21 | max_rows = 10 22 | min_columns = 1 23 | max_columns = 10 24 | min_matrices = 1 25 | max_matrices = 10 26 | 27 | # TODO: Fixtures for random_matrix random_ragged_cmf, etc as part of this which uses the min_rows, etc... 28 | atol = 1e-10 29 | 30 | @property 31 | def rtol(self): 32 | if tl.get_backend() == "numpy": 33 | return 1e-6 34 | return 500 * 1e-6 # Single precision backends need less strict tests 35 | 36 | @pytest.fixture 37 | def random_row(self, rng): 38 | n_columns = rng.randint(self.min_columns, self.max_columns + 1) 39 | return tl.tensor(rng.standard_normal(n_columns)) 40 | 41 | @pytest.fixture 42 | def random_matrix(self, rng): 43 | n_rows = rng.randint(self.min_rows, self.max_rows + 1) 44 | n_columns = rng.randint(self.min_columns, self.max_columns + 1) 45 | return tl.tensor(rng.standard_normal((n_rows, n_columns))) 46 | 47 | @pytest.fixture 48 | def random_matrices(self, rng): 49 | n_rows = rng.randint(self.min_rows, self.max_rows + 1) 50 | n_columns = rng.randint(self.min_columns, self.max_columns + 1) 51 | n_matrices = rng.randint(self.min_matrices, self.max_matrices + 1) 52 | return [tl.tensor(rng.standard_normal((n_rows, n_columns))) for i in range(n_matrices)] 53 | 54 | @pytest.mark.parametrize("dual_init", ["random_uniform", "random_standard_normal", "zeros"]) 55 | def test_uniform_init_aux(self, rng, random_ragged_cmf, dual_init): 56 | cmf, shapes, rank = random_ragged_cmf 57 | matrices = cmf.to_matrices() 58 | penalty = self.PenaltyType(aux_init="random_uniform", dual_init=dual_init, **self.penalty_default_kwargs) 59 | 60 | init_matrix = penalty.init_aux(matrices, rank, mode=0, random_state=rng) 61 | assert init_matrix.shape[0] == len(shapes) 62 | assert init_matrix.shape[1] == rank 63 | assert tl.all(init_matrix >= 0) 64 | assert tl.all(init_matrix < 1) 65 | 66 | init_matrices = penalty.init_aux(matrices, rank, mode=1, random_state=rng) 67 | for init_matrix, shape in zip(init_matrices, shapes): 68 | assert init_matrix.shape[0] == shape[0] 69 | assert init_matrix.shape[1] == rank 70 | assert tl.all(init_matrix >= 0) 71 | assert tl.all(init_matrix < 1) 72 | 73 | init_matrix = penalty.init_aux(matrices, rank, mode=2, random_state=rng) 74 | assert init_matrix.shape[0] == shapes[0][1] 75 | assert init_matrix.shape[1] == rank 76 | assert tl.all(init_matrix >= 0) 77 | assert tl.all(init_matrix < 1) 78 | 79 | @pytest.mark.parametrize("dual_init", ["random_uniform", "random_standard_normal", "zeros"]) 80 | def test_standard_normal_init_aux(self, rng, random_ragged_cmf, dual_init): 81 | cmf, shapes, rank = random_ragged_cmf 82 | matrices = cmf.to_matrices() 83 | penalty = self.PenaltyType( 84 | aux_init="random_standard_normal", dual_init=dual_init, **self.penalty_default_kwargs 85 | ) 86 | 87 | init_matrix = penalty.init_aux(matrices, rank, mode=0, random_state=rng) 88 | assert init_matrix.shape[0] == len(shapes) 89 | assert init_matrix.shape[1] == rank 90 | 91 | init_matrices = penalty.init_aux(matrices, rank, mode=1, random_state=rng) 92 | for init_matrix, shape in zip(init_matrices, shapes): 93 | assert init_matrix.shape[0] == shape[0] 94 | assert init_matrix.shape[1] == rank 95 | 96 | init_matrix = penalty.init_aux(matrices, rank, mode=2, random_state=rng) 97 | assert init_matrix.shape[0] == shapes[0][1] 98 | assert init_matrix.shape[1] == rank 99 | 100 | @pytest.mark.parametrize("dual_init", ["random_uniform", "random_standard_normal", "zeros"]) 101 | def test_zeros_init_aux(self, rng, random_ragged_cmf, dual_init): 102 | cmf, shapes, rank = random_ragged_cmf 103 | matrices = cmf.to_matrices() 104 | penalty = self.PenaltyType(aux_init="zeros", dual_init=dual_init, **self.penalty_default_kwargs) 105 | 106 | init_matrix = penalty.init_aux(matrices, rank, mode=0, random_state=rng) 107 | assert init_matrix.shape[0] == len(shapes) 108 | assert init_matrix.shape[1] == rank 109 | assert_array_equal(init_matrix, 0) 110 | 111 | init_matrices = penalty.init_aux(matrices, rank, mode=1, random_state=rng) 112 | for init_matrix, shape in zip(init_matrices, shapes): 113 | assert init_matrix.shape[0] == shape[0] 114 | assert init_matrix.shape[1] == rank 115 | assert_array_equal(init_matrix, 0) 116 | 117 | init_matrix = penalty.init_aux(matrices, rank, mode=2, random_state=rng) 118 | assert init_matrix.shape[0] == shapes[0][1] 119 | assert init_matrix.shape[1] == rank 120 | assert_array_equal(init_matrix, 0) 121 | 122 | @pytest.mark.parametrize("dual_init", ["random_uniform", "random_standard_normal", "zeros"]) 123 | def test_given_init_aux(self, rng, random_ragged_cmf, dual_init): 124 | cmf, shapes, rank = random_ragged_cmf 125 | matrices = cmf.to_matrices() 126 | 127 | # Check that aux_init can be tensor (for mode 0 and 2) or list for mode 1 128 | weights, (A, B_is, C) = cmf 129 | penalty = self.PenaltyType(aux_init=A, dual_init=dual_init, **self.penalty_default_kwargs) 130 | assert_array_equal(A, penalty.init_aux(matrices, rank, 0, random_state=rng)) 131 | 132 | penalty = self.PenaltyType(aux_init=C, dual_init=dual_init, **self.penalty_default_kwargs) 133 | assert_array_equal(C, penalty.init_aux(matrices, rank, 2, random_state=rng)) 134 | 135 | penalty = self.PenaltyType(aux_init=B_is, dual_init=dual_init, **self.penalty_default_kwargs) 136 | dual_B_is = penalty.init_aux(matrices, rank, 1, random_state=rng) 137 | for B_i, dual_B_i in zip(B_is, dual_B_is): 138 | assert_array_equal(B_i, dual_B_i) 139 | 140 | @pytest.mark.parametrize("dual_init", ["random_uniform", "random_standard_normal", "zeros"]) 141 | @pytest.mark.parametrize("aux_init", ["random_uniform", "random_standard_normal", "zeros"]) 142 | def test_rank_and_mode_validation_for_init_aux(self, rng, random_ragged_cmf, dual_init, aux_init): 143 | cmf, shapes, rank = random_ragged_cmf 144 | matrices = cmf.to_matrices() 145 | penalty = self.PenaltyType(aux_init="zeros", dual_init=dual_init, **self.penalty_default_kwargs) 146 | # Test that mode and rank needs int input 147 | with pytest.raises(TypeError): 148 | penalty.init_aux(matrices, rank, mode=None) 149 | with pytest.raises(TypeError): 150 | penalty.init_aux(matrices, rank=None, mode=0) 151 | 152 | # Test that mode needs to be between 0 and 2 153 | with pytest.raises(ValueError): 154 | penalty.init_aux(matrices, rank, mode=-1) 155 | with pytest.raises(ValueError): 156 | penalty.init_aux(matrices, rank, mode=3) 157 | 158 | @pytest.mark.parametrize("dual_init", ["random_uniform", "random_standard_normal", "zeros"]) 159 | def test_validating_given_init_aux(self, rng, random_ragged_cmf, dual_init): 160 | cmf, shapes, rank = random_ragged_cmf 161 | matrices = cmf.to_matrices() 162 | 163 | # Check that we get value error if aux_init is tensor of wrong size (mode 0 or 2) 164 | # and if any of the tensors have wrong size (mode 1) or the list has the wrong length (mode 1) 165 | weights, (A, B_is, C) = cmf 166 | I = tl.shape(A)[0] 167 | J_is = [tl.shape(B_i)[0] for B_i in B_is] 168 | K = tl.shape(C)[0] 169 | 170 | invalid_A = tl.tensor(rng.random_sample((I + 1, rank))) 171 | invalid_C = tl.tensor(rng.random_sample((K + 1, rank))) 172 | invalid_B_is = [tl.tensor(rng.random_sample((J_i, rank))) for J_i in J_is] 173 | invalid_B_is[0] = tl.tensor(rng.random_sample((J_is[0] + 1, rank))) 174 | 175 | penalty = self.PenaltyType(aux_init=invalid_A, dual_init=dual_init, **self.penalty_default_kwargs) 176 | with pytest.raises(ValueError): 177 | penalty.init_aux(matrices, rank, 0, random_state=rng) 178 | penalty = self.PenaltyType(aux_init=invalid_C, dual_init=dual_init, **self.penalty_default_kwargs) 179 | with pytest.raises(ValueError): 180 | penalty.init_aux(matrices, rank, 2, random_state=rng) 181 | penalty = self.PenaltyType(aux_init=invalid_B_is, dual_init=dual_init, **self.penalty_default_kwargs) 182 | with pytest.raises(ValueError): 183 | penalty.init_aux(matrices, rank, 1, random_state=rng) 184 | penalty = self.PenaltyType(aux_init=B_is + B_is, dual_init=dual_init, **self.penalty_default_kwargs) 185 | with pytest.raises(ValueError): 186 | penalty.init_aux(matrices, rank, 1, random_state=rng) 187 | 188 | # Check that mode 0 and 2 cannot accept list of matrices 189 | penalty = self.PenaltyType(aux_init=B_is, dual_init=dual_init, **self.penalty_default_kwargs) 190 | with pytest.raises(TypeError): 191 | penalty.init_aux(matrices, rank, 0, random_state=rng) 192 | with pytest.raises(TypeError): 193 | penalty.init_aux(matrices, rank, 2, random_state=rng) 194 | 195 | # Check that mode 1 cannot accept single matrix 196 | penalty = self.PenaltyType(aux_init=A, dual_init=dual_init, **self.penalty_default_kwargs) 197 | with pytest.raises(TypeError): 198 | penalty.init_aux(matrices, rank, 1, random_state=rng) 199 | 200 | @pytest.mark.parametrize("dual_init", ["random_uniform", "random_standard_normal", "zeros"]) 201 | def test_input_validation_for_init_aux(self, rng, random_ragged_cmf, dual_init): 202 | cmf, shapes, rank = random_ragged_cmf 203 | matrices = cmf.to_matrices() 204 | # Test that the init method must be a valid type 205 | invalid_inits = [None, 1, 1.1] 206 | for invalid_init in invalid_inits: 207 | penalty = self.PenaltyType(aux_init=invalid_init, dual_init=dual_init, **self.penalty_default_kwargs) 208 | for mode in range(2): 209 | with pytest.raises(TypeError): 210 | penalty.init_aux(matrices, rank, mode=mode, random_state=rng) 211 | 212 | # Check that we get value error if aux init is str but not "random_uniform" or "random_standard_normal" 213 | penalty = self.PenaltyType(aux_init="invalid init name", dual_init=dual_init, **self.penalty_default_kwargs) 214 | for mode in range(2): 215 | with pytest.raises(ValueError): 216 | penalty.init_aux(matrices, rank, mode=mode, random_state=None) 217 | 218 | @pytest.mark.parametrize("aux_init", ["random_uniform", "random_standard_normal", "zeros"]) 219 | def test_uniform_init_dual(self, rng, random_ragged_cmf, aux_init): 220 | cmf, shapes, rank = random_ragged_cmf 221 | matrices = cmf.to_matrices() 222 | 223 | # Test that init works with random uniform init 224 | penalty = self.PenaltyType(aux_init=aux_init, dual_init="random_uniform", **self.penalty_default_kwargs) 225 | 226 | init_matrix = penalty.init_dual(matrices, rank, mode=0, random_state=rng) 227 | assert init_matrix.shape[0] == len(shapes) 228 | assert init_matrix.shape[1] == rank 229 | assert tl.all(init_matrix >= 0) 230 | assert tl.all(init_matrix < 1) 231 | 232 | init_matrices = penalty.init_dual(matrices, rank, mode=1, random_state=rng) 233 | for init_matrix, shape in zip(init_matrices, shapes): 234 | assert init_matrix.shape[0] == shape[0] 235 | assert init_matrix.shape[1] == rank 236 | assert tl.all(init_matrix >= 0) 237 | assert tl.all(init_matrix < 1) 238 | 239 | init_matrix = penalty.init_dual(matrices, rank, mode=2, random_state=rng) 240 | assert init_matrix.shape[0] == shapes[0][1] 241 | assert init_matrix.shape[1] == rank 242 | assert tl.all(init_matrix >= 0) 243 | assert tl.all(init_matrix < 1) 244 | 245 | @pytest.mark.parametrize("aux_init", ["random_uniform", "random_standard_normal", "zeros"]) 246 | def test_standard_normal_init_dual(self, rng, random_ragged_cmf, aux_init): 247 | cmf, shapes, rank = random_ragged_cmf 248 | matrices = cmf.to_matrices() 249 | # Test that init works with random standard normal init 250 | penalty = self.PenaltyType(aux_init=aux_init, dual_init="random_standard_normal", **self.penalty_default_kwargs) 251 | 252 | init_matrix = penalty.init_dual(matrices, rank, mode=0, random_state=rng) 253 | assert init_matrix.shape[0] == len(shapes) 254 | assert init_matrix.shape[1] == rank 255 | 256 | init_matrices = penalty.init_dual(matrices, rank, mode=1, random_state=rng) 257 | for init_matrix, shape in zip(init_matrices, shapes): 258 | assert init_matrix.shape[0] == shape[0] 259 | assert init_matrix.shape[1] == rank 260 | 261 | init_matrix = penalty.init_dual(matrices, rank, mode=2, random_state=rng) 262 | assert init_matrix.shape[0] == shapes[0][1] 263 | assert init_matrix.shape[1] == rank 264 | 265 | @pytest.mark.parametrize("aux_init", ["random_uniform", "random_standard_normal", "zeros"]) 266 | def test_zeros_init_dual(self, rng, random_ragged_cmf, aux_init): 267 | cmf, shapes, rank = random_ragged_cmf 268 | matrices = cmf.to_matrices() 269 | # Test that init works with zeros init 270 | penalty = self.PenaltyType(aux_init=aux_init, dual_init="zeros", **self.penalty_default_kwargs) 271 | 272 | init_matrix = penalty.init_dual(matrices, rank, mode=0, random_state=rng) 273 | assert init_matrix.shape[0] == len(shapes) 274 | assert init_matrix.shape[1] == rank 275 | assert_array_equal(init_matrix, 0) 276 | 277 | init_matrices = penalty.init_dual(matrices, rank, mode=1, random_state=rng) 278 | for init_matrix, shape in zip(init_matrices, shapes): 279 | assert init_matrix.shape[0] == shape[0] 280 | assert init_matrix.shape[1] == rank 281 | assert_array_equal(init_matrix, 0) 282 | 283 | init_matrix = penalty.init_dual(matrices, rank, mode=2, random_state=rng) 284 | assert init_matrix.shape[0] == shapes[0][1] 285 | assert init_matrix.shape[1] == rank 286 | assert_array_equal(init_matrix, 0) 287 | 288 | @pytest.mark.parametrize("aux_init", ["random_uniform", "random_standard_normal", "zeros"]) 289 | def test_given_init_dual(self, rng, random_ragged_cmf, aux_init): 290 | cmf, shapes, rank = random_ragged_cmf 291 | matrices = cmf.to_matrices() 292 | # Check that aux_init can be tensor (for mode 0 and 2) or list for mode 1 293 | weights, (A, B_is, C) = cmf 294 | penalty = self.PenaltyType(aux_init=aux_init, dual_init=A, **self.penalty_default_kwargs) 295 | assert_array_equal(A, penalty.init_dual(matrices, rank, 0, random_state=rng)) 296 | 297 | penalty = self.PenaltyType(aux_init=aux_init, dual_init=C, **self.penalty_default_kwargs) 298 | assert_array_equal(C, penalty.init_dual(matrices, rank, 2, random_state=rng)) 299 | 300 | penalty = self.PenaltyType(aux_init=aux_init, dual_init=B_is, **self.penalty_default_kwargs) 301 | dual_B_is = penalty.init_dual(matrices, rank, 1, random_state=rng) 302 | for B_i, dual_B_i in zip(B_is, dual_B_is): 303 | assert_array_equal(B_i, dual_B_i) 304 | 305 | @pytest.mark.parametrize("aux_init", ["random_uniform", "random_standard_normal", "zeros"]) 306 | def test_validating_given_init_dual(self, rng, random_ragged_cmf, aux_init): 307 | cmf, shapes, rank = random_ragged_cmf 308 | matrices = cmf.to_matrices() 309 | weights, (A, B_is, C) = cmf 310 | 311 | # Check that we get value error if aux_init is tensor of wrong size (mode 0 or 2) 312 | # and if any of the tensors have wrong size (mode 1) or the list has the wrong length (mode 1) 313 | I = tl.shape(A)[0] 314 | J_is = [tl.shape(B_i)[0] for B_i in B_is] 315 | K = tl.shape(C)[0] 316 | 317 | invalid_A = tl.tensor(rng.random_sample((I + 1, rank))) 318 | invalid_C = tl.tensor(rng.random_sample((K + 1, rank))) 319 | invalid_B_is = [tl.tensor(rng.random_sample((J_i, rank))) for J_i in J_is] 320 | invalid_B_is[0] = tl.tensor(rng.random_sample((J_is[0] + 1, rank))) 321 | 322 | penalty = self.PenaltyType(aux_init=aux_init, dual_init=invalid_A, **self.penalty_default_kwargs) 323 | with pytest.raises(ValueError): 324 | penalty.init_dual(matrices, rank, 0, random_state=rng) 325 | penalty = self.PenaltyType(aux_init=aux_init, dual_init=invalid_C, **self.penalty_default_kwargs) 326 | with pytest.raises(ValueError): 327 | penalty.init_dual(matrices, rank, 2, random_state=rng) 328 | penalty = self.PenaltyType(aux_init=aux_init, dual_init=invalid_B_is, **self.penalty_default_kwargs) 329 | with pytest.raises(ValueError): 330 | penalty.init_dual(matrices, rank, 1, random_state=rng) 331 | penalty = self.PenaltyType(aux_init=aux_init, dual_init=B_is + B_is, **self.penalty_default_kwargs) 332 | with pytest.raises(ValueError): 333 | penalty.init_dual(matrices, rank, 1, random_state=rng) 334 | 335 | # Check that mode 0 and 2 cannot accept list of matrices 336 | penalty = self.PenaltyType(aux_init=aux_init, dual_init=B_is, **self.penalty_default_kwargs) 337 | with pytest.raises(TypeError): 338 | penalty.init_dual(matrices, rank, 0, random_state=rng) 339 | with pytest.raises(TypeError): 340 | penalty.init_dual(matrices, rank, 2, random_state=rng) 341 | 342 | # Check that mode 1 cannot accept single matrix 343 | penalty = self.PenaltyType(aux_init=aux_init, dual_init=A, **self.penalty_default_kwargs) 344 | with pytest.raises(TypeError): 345 | penalty.init_dual(matrices, rank, 1, random_state=rng) 346 | 347 | @pytest.mark.parametrize( 348 | "dual_init,", ["random_uniform", "random_standard_normal", "zeros"], 349 | ) 350 | @pytest.mark.parametrize("aux_init", ["random_uniform", "random_standard_normal", "zeros"]) 351 | def test_rank_and_mode_validation_for_init_dual(self, rng, random_ragged_cmf, dual_init, aux_init): 352 | cmf, shapes, rank = random_ragged_cmf 353 | matrices = cmf.to_matrices() 354 | # Test that init works with zeros init 355 | penalty = self.PenaltyType(aux_init=aux_init, dual_init=dual_init, **self.penalty_default_kwargs) 356 | 357 | # Test that mode and rank needs int input 358 | with pytest.raises(TypeError): 359 | penalty.init_dual(matrices, rank, mode=None) 360 | with pytest.raises(TypeError): 361 | penalty.init_dual(matrices, rank=None, mode=0) 362 | 363 | # Test that mode needs to be between 0 and 2 364 | with pytest.raises(ValueError): 365 | penalty.init_dual(matrices, rank, mode=-1) 366 | with pytest.raises(ValueError): 367 | penalty.init_dual(matrices, rank, mode=3) 368 | 369 | @pytest.mark.parametrize("aux_init", ["random_uniform", "random_standard_normal", "zeros"]) 370 | def test_input_validation_init_dual(self, rng, random_ragged_cmf, aux_init): 371 | cmf, shapes, rank = random_ragged_cmf 372 | matrices = cmf.to_matrices() 373 | # Test that the init method must be a valid type 374 | invalid_inits = [None, 1, 1.1] 375 | for invalid_init in invalid_inits: 376 | penalty = self.PenaltyType(aux_init=aux_init, dual_init=invalid_init, **self.penalty_default_kwargs) 377 | for mode in range(2): 378 | with pytest.raises(TypeError): 379 | penalty.init_dual(matrices, rank, mode=mode, random_state=rng) 380 | 381 | # Check that we get value error if aux init is str but not "random_uniform" or "random_standard_normal" 382 | penalty = self.PenaltyType(aux_init=aux_init, dual_init="invalid init name", **self.penalty_default_kwargs) 383 | for mode in range(2): 384 | with pytest.raises(ValueError): 385 | penalty.init_dual(matrices, rank, mode=mode, random_state=rng) 386 | 387 | def test_penalty(self, rng): 388 | raise NotImplementedError 389 | 390 | def test_subtract_from_aux(self, random_matrices): 391 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 392 | for matrix in random_matrices: 393 | assert_array_equal(penalty.subtract_from_aux(matrix, matrix), 0) 394 | 395 | def test_subtract_from_auxes(self, random_matrices): 396 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 397 | zero_matrices = penalty.subtract_from_auxes(random_matrices, random_matrices) 398 | for zeros in zero_matrices: 399 | assert_array_equal(zeros, 0) 400 | 401 | def test_aux_as_matrix(self, random_matrix): 402 | # Check that this is an identity operator. 403 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 404 | random_matrix2 = penalty.aux_as_matrix(random_matrix) 405 | assert_array_equal(random_matrix, random_matrix2) 406 | 407 | def test_auxes_as_matrices(self, random_matrices): 408 | # Check that this is an identity operator. 409 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 410 | random_matrices2 = penalty.auxes_as_matrices(random_matrices) 411 | assert len(random_matrices) == len(random_matrices2) 412 | for random_matrix, random_matrix2 in zip(random_matrices, random_matrices2): 413 | assert_array_equal(random_matrix, random_matrix2) 414 | 415 | 416 | class BaseTestFactorMatricesPenalty(BaseTestADMMPenalty): # e.g. PARAFAC2 417 | def get_invariant_matrices(self, rng, shapes): 418 | return NotImplementedError 419 | 420 | def get_non_invariant_matrices(self, rng, shapes): 421 | return NotImplementedError 422 | 423 | @pytest.fixture 424 | def invariant_matrices(self, rng): 425 | n_columns = rng.randint(self.min_columns, self.max_columns + 1) 426 | n_matrices = rng.randint(self.min_matrices, self.max_matrices + 1) 427 | shapes = tuple((rng.randint(self.min_rows, self.max_rows + 1), n_columns) for k in range(n_matrices)) 428 | return self.get_invariant_matrices(rng, shapes) 429 | 430 | @pytest.fixture 431 | def non_invariant_matrices(self, rng): 432 | n_columns = rng.randint(self.min_columns, self.max_columns + 1) 433 | n_matrices = rng.randint(self.min_matrices, self.max_matrices + 1) 434 | shapes = tuple((rng.randint(self.min_rows, self.max_rows + 1), n_columns) for k in range(n_matrices)) 435 | return self.get_non_invariant_matrices(rng, shapes) 436 | 437 | def test_factor_matrices_update_invariant_point(self, invariant_matrices): 438 | feasibility_penalties = [10] * len(invariant_matrices) 439 | auxes = [None] * len(invariant_matrices) 440 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 441 | 442 | out = penalty.factor_matrices_update(invariant_matrices, feasibility_penalties, auxes) 443 | for invariant_matrix, out_matrix in zip(invariant_matrices, out): 444 | assert_allclose(invariant_matrix, out_matrix, rtol=self.rtol, atol=self.atol) 445 | 446 | def test_factor_matrices_update_changes_input(self, non_invariant_matrices): 447 | feasibility_penalties = [10] * len(non_invariant_matrices) 448 | auxes = [None] * len(non_invariant_matrices) 449 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 450 | 451 | out = penalty.factor_matrices_update(non_invariant_matrices, feasibility_penalties, auxes) 452 | for non_invariant_matrix, out_matrix in zip(non_invariant_matrices, out): 453 | assert not np.allclose(out_matrix, non_invariant_matrix, rtol=self.rtol, atol=self.atol) 454 | 455 | def test_factor_matrices_update_reduces_penalty(self, random_matrices): 456 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 457 | feasibility_penalties = [10] * len(random_matrices) 458 | auxes = [None] * len(random_matrices) 459 | initial_penalty = penalty.penalty(random_matrices) 460 | out = penalty.factor_matrices_update(random_matrices, feasibility_penalties, auxes) 461 | assert penalty.penalty(out) <= initial_penalty 462 | 463 | 464 | class BaseTestFactorMatrixPenalty(BaseTestFactorMatricesPenalty): 465 | def get_invariant_matrix(self, rng, shape): 466 | raise NotImplementedError 467 | 468 | def get_invariant_matrices(self, rng, shapes): 469 | return [self.get_invariant_matrix(rng, shape) for shape in shapes] 470 | 471 | def get_non_invariant_matrix(self, rng, shape): 472 | raise NotImplementedError 473 | 474 | def get_non_invariant_matrices(self, rng, shapes): 475 | return [self.get_non_invariant_matrix(rng, shape) for shape in shapes] 476 | 477 | @pytest.fixture 478 | def invariant_matrix(self, rng): 479 | n_columns = rng.randint(self.min_columns, self.max_columns + 1) 480 | n_rows = rng.randint(self.min_rows, self.max_rows + 1) 481 | shape = (n_rows, n_columns) 482 | return self.get_invariant_matrix(rng, shape) 483 | 484 | @pytest.fixture 485 | def non_invariant_matrix(self, rng): 486 | n_columns = rng.randint(self.min_columns, self.max_columns + 1) 487 | n_rows = rng.randint(self.min_rows, self.max_rows + 1) 488 | shape = (n_rows, n_columns) 489 | return self.get_non_invariant_matrix(rng, shape) 490 | 491 | def test_factor_matrix_update_invariant_point(self, invariant_matrix): 492 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 493 | 494 | out = penalty.factor_matrix_update(invariant_matrix, 10, None) 495 | assert_allclose(invariant_matrix, out, rtol=self.rtol, atol=self.atol) 496 | 497 | def test_factor_matrix_update_changes_input(self, non_invariant_matrix): 498 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 499 | 500 | out = penalty.factor_matrix_update(non_invariant_matrix, 10, None) 501 | assert not np.allclose(out, non_invariant_matrix, rtol=self.rtol, atol=self.atol) 502 | 503 | def test_factor_matrix_update_reduces_penalty(self, random_matrix): 504 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 505 | 506 | initial_penalty = penalty.penalty(random_matrix) 507 | out = penalty.factor_matrix_update(random_matrix, 10, None) 508 | assert penalty.penalty(out) <= initial_penalty 509 | 510 | 511 | class BaseTestRowVectorPenalty(BaseTestFactorMatrixPenalty): # e.g. non-negativity 512 | def get_invariant_row(self, rng, n_columns): 513 | raise NotImplementedError 514 | 515 | def get_invariant_matrix(self, rng, shape): 516 | return tl.stack([self.get_invariant_row(rng, shape[1]) for _ in range(shape[0])], axis=0) 517 | 518 | def get_non_invariant_row(self, rng, n_columns): 519 | raise NotImplementedError 520 | 521 | def get_non_invariant_matrix(self, rng, shape): 522 | return tl.stack([self.get_non_invariant_row(rng, shape[1]) for _ in range(shape[0])], axis=0) 523 | 524 | @pytest.fixture 525 | def invariant_row(self, rng): 526 | n_columns = rng.randint(self.min_columns, self.max_columns + 1) 527 | return self.get_invariant_row(rng, n_columns) 528 | 529 | @pytest.fixture 530 | def non_invariant_row(self, rng): 531 | n_columns = rng.randint(self.min_columns, self.max_columns + 1) 532 | return self.get_non_invariant_row(rng, n_columns) 533 | 534 | def test_row_update_invariant_point(self, invariant_row): 535 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 536 | 537 | out = penalty.factor_matrix_row_update(invariant_row, 10, None) 538 | assert_allclose(invariant_row, out, rtol=self.rtol, atol=self.atol) 539 | 540 | def test_row_update_changes_input(self, non_invariant_row): 541 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 542 | 543 | out = penalty.factor_matrix_row_update(non_invariant_row, 10, None) 544 | assert not np.allclose(out, non_invariant_row, rtol=self.rtol, atol=self.atol) 545 | 546 | def test_row_update_reduces_penalty(self, random_row): 547 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 548 | 549 | initial_penalty = penalty.penalty(random_row) 550 | out = penalty.factor_matrix_row_update(random_row, 10, None) 551 | assert penalty.penalty(out) <= initial_penalty 552 | 553 | 554 | class MixinTestHardConstraint: 555 | def test_penalty(self, random_ragged_cmf): 556 | cmf, shapes, rank = random_ragged_cmf 557 | weights, (A, B_is, C) = cmf 558 | penalty = self.PenaltyType(**self.penalty_default_kwargs) 559 | assert penalty.penalty(A) == 0 560 | assert penalty.penalty(B_is) == 0 561 | assert penalty.penalty(C) == 0 562 | --------------------------------------------------------------------------------