├── 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 |
--------------------------------------------------------------------------------