├── .coveragerc
├── .gitattributes
├── .github
├── dependabot.yml
└── workflows
│ ├── build_and_deploy.yml
│ └── python-test.yml
├── .gitignore
├── .readthedocs.yml
├── LICENSE.txt
├── MANIFEST.in
├── README.md
├── RELEASE.txt
├── docs
├── .gitignore
├── Makefile
├── conf.py
├── images
│ ├── data-and-pd.png
│ └── pers-im-h1.png
├── index.rst
├── logo.png
├── make.bat
├── notebooks
│ ├── Classification with persistence images.ipynb
│ ├── Differentiation with Persistence Landscapes.ipynb
│ ├── Persistence Landscapes and Machine Learning.ipynb
│ ├── Persistence barcode measure.ipynb
│ ├── Persistence images.ipynb
│ ├── Persistence landscapes.ipynb
│ └── distances.ipynb
├── reference
│ ├── .gitignore
│ └── index.rst
└── requirements.txt
├── persim
├── __init__.py
├── _version.py
├── bottleneck.py
├── gromov_hausdorff.py
├── heat.py
├── images.py
├── images_kernels.py
├── images_weights.py
├── landscapes
│ ├── __init__.py
│ ├── approximate.py
│ ├── auxiliary.py
│ ├── base.py
│ ├── exact.py
│ ├── tools.py
│ ├── transformer.py
│ └── visuals.py
├── persistent_entropy.py
├── sliced_wasserstein.py
├── visuals.py
└── wasserstein.py
├── pyproject.toml
├── setup.py
└── test
├── __init__.py
├── test_distances.py
├── test_landscapes.py
├── test_persim.py
├── test_persistence_imager.py
├── test_persistent_entropy.py
└── test_visuals.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = True
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.ipynb linguist-language=Python
2 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # Set update schedule for GitHub Actions
2 |
3 | version: 2
4 | updates:
5 | - package-ecosystem: "github-actions"
6 | directory: "/"
7 | schedule:
8 | # Check for updates to GitHub Actions every week
9 | interval: "weekly"
10 |
--------------------------------------------------------------------------------
/.github/workflows/build_and_deploy.yml:
--------------------------------------------------------------------------------
1 | # This workflows will upload a Python Package using Twine when a release is created
2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
3 |
4 | name: Build and Upload Python Package
5 |
6 | on:
7 | workflow_dispatch:
8 | pull_request:
9 | push:
10 | branches:
11 | - main
12 | release:
13 | types:
14 | - published
15 |
16 | jobs:
17 | build_wheel_and_sdist:
18 | runs-on: ubuntu-latest
19 |
20 | steps:
21 | - uses: actions/checkout@v4
22 | with:
23 | fetch-depth: 0
24 |
25 | - name: Build SDist and wheel
26 | run: pipx run build
27 |
28 | - uses: actions/upload-artifact@v4
29 | with:
30 | name: Packages
31 | path: dist/*
32 |
33 | - name: Check metadata
34 | run: pipx run twine check dist/*
35 |
36 | upload_pypi:
37 | name: Upload release to PyPI
38 | needs: [build_wheel_and_sdist]
39 | runs-on: ubuntu-latest
40 | environment:
41 | name: pypi
42 | url: https://pypi.org/p/persim
43 | permissions:
44 | id-token: write
45 | if: github.event_name == 'release' && github.event.action == 'published'
46 | steps:
47 | - uses: actions/download-artifact@v4
48 | with:
49 | name: Packages
50 | path: dist
51 |
52 | - uses: pypa/gh-action-pypi-publish@release/v1
53 |
--------------------------------------------------------------------------------
/.github/workflows/python-test.yml:
--------------------------------------------------------------------------------
1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
3 |
4 | name: Python application
5 |
6 | on:
7 | push:
8 | branches: [master]
9 | pull_request:
10 | branches: [master]
11 |
12 | jobs:
13 | test:
14 | runs-on: ubuntu-latest
15 | strategy:
16 | matrix:
17 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
18 |
19 | steps:
20 | - uses: actions/checkout@v4
21 | - name: Set up Python ${{ matrix.python-version }}
22 | uses: actions/setup-python@v5
23 | with:
24 | python-version: ${{ matrix.python-version }}
25 | - name: Install dependencies
26 | run: |
27 | python -m pip install --upgrade pip
28 | pip install flake8
29 | pip install -e ".[testing]"
30 | - name: Lint with flake8
31 | run: |
32 | # stop the build if there are Python syntax errors or undefined names
33 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
34 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
35 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
36 | - name: Test with pytest
37 | run: |
38 | pytest --cov persim
39 | - name: Upload coverage results
40 | run: |
41 | bash <(curl -s https://codecov.io/bash)
42 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode/
2 | venv/
3 | *.egg-info/
4 | __pycache__
5 | .pytest_cache
6 | .DS_Store
7 | .coverage
8 | .ipynb_checkpoints
9 |
10 | build
11 | dist
12 |
--------------------------------------------------------------------------------
/.readthedocs.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | sphinx:
4 | configuration: docs/conf.py
5 |
6 | build:
7 | os: "ubuntu-22.04"
8 | tools:
9 | python: "3.11"
10 |
11 | python:
12 | install:
13 | - requirements: docs/requirements.txt
14 | - method: pip
15 | path: .
16 | extra_requirements:
17 | - docs
18 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Nathaniel Saul - nat@saulgill.com
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include *.txt
2 | include *.md
3 | include persim/landscapes/*
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://badge.fury.io/py/persim)
2 | 
3 | [](https://anaconda.org/conda-forge/persim)
4 | [](https://anaconda.org/conda-forge/persim)
5 | [](https://codecov.io/gh/scikit-tda/persim)
6 | [](https://opensource.org/licenses/MIT)
7 |
8 |
9 |
10 |
11 | Persim is a Python package for many tools used in analyzing Persistence Diagrams. It currently houses implementations of
12 |
13 | - Persistence Images
14 | - Persistence Landscapes
15 | - Bottleneck distance
16 | - Modified Gromov–Hausdorff distance
17 | - Sliced Wasserstein Kernel
18 | - Heat Kernel
19 | - Diagram plotting
20 |
21 |
22 | ## Setup
23 |
24 | The latest version of persim can be found on Pypi and installed with pip:
25 |
26 | ```
27 | pip install persim
28 | ```
29 |
30 | ## Documentation and Usage
31 |
32 | Documentation about the library, it's API, and examples of how to use it can be found at [persim.scikit-tda.org](http://persim.scikit-tda.org).
33 |
34 | ## Contributions
35 |
36 | We welcome contributions of all shapes and sizes. There are lots of opportunities for potential projects, so please get in touch if you would like to help out. Everything from an implementation of your favorite distance, notebooks, examples, and documentation are all equally valuable so please don't feel you can't contribute.
37 |
38 | To contribute please fork the project make your changes and submit a pull request. We will do our best to work through any issues with you and get your code merged into the main branch.
39 |
40 |
41 |
--------------------------------------------------------------------------------
/RELEASE.txt:
--------------------------------------------------------------------------------
1 | 0.3.7
2 | - Fix bug of Issue #81 (https://github.com/scikit-tda/persim/issues/81)
3 |
4 | 0.3.6
5 | - Update to pyproject.toml specification.
6 | - Update github workflows.
7 |
8 | 0.3.5
9 | - Fix broken notebook, Issue #77 (https://github.com/scikit-tda/persim/issues/77).
10 |
11 | 0.3.4
12 | - Fix bug of Issue #70 (https://github.com/scikit-tda/persim/issues/70).
13 |
14 | 0.3.3
15 | - Fix plotting methods of Persistence Landscapes, add doc strings.
16 | - Update to notebooks.
17 |
18 | 0.3.2
19 | - Update codebase to support python 3.7 - 3.12.
20 | - Change `PersistenceLandscaper` API for sklearn compatibility.
21 |
22 | 0.3.1
23 | - Fixed bug with repeated intervals in bottleneck
24 | - Tidied up API for indicating matchings for bottleneck and wasserstein, and updated notebook
25 |
26 | 0.3.0
27 | - Add implementations of Persistence Landscapes, including plotting methods, a transformer, and additional notebooks.
28 |
29 | 0.2.1
30 | - Allowed for more than 9 diagram labels in plot_persistence_diagrams.
31 |
32 | 0.2.0
33 | - New full featured implementation of Persistence Images.
34 | - Legacy PersImage now deprecated.
35 |
36 | 0.1.4
37 | - Migrate to a new CI/CD pipeline
38 |
39 | 0.1.3
40 | - Fixing documentation
41 | - Removed the float64 memory layout specification in the np.copy() function, that was causing an error when used with the 1.18+ versions of numpy
42 |
43 | 0.1.1
44 | - Fix bug in Wasserstein and bottleneck distance.
45 |
46 | 0.1.0
47 | - Include Wasserstein distance.
48 |
49 | 0.0.10
50 | - Add license and README to distributed packages
51 | 0.0.9
52 | - Include modified Gromov--Hausdorff distance
53 | 0.0.8
54 | - Include diagram plotting
55 | - revamped documentation
56 |
57 | 0.0.7
58 | - Implementation of sliced wasserstein (thanks Alice Patania)
59 | - Implementation of heat kernel and bottleneck distance (thanks Chris Tralie)
60 | - Expansion of documentation
61 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | _build/
2 | .cache/
3 | persim/
4 | persim.egg-info
5 | venv/
6 |
7 | .vscode/
8 | *.egg-info/
9 | __pycache__
10 | .pytest_cache
11 | .DS_Store
12 | .coverage
13 | .ipynb_checkpoints
14 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SPHINXPROJ = Persim
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)
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import os
3 | import sys
4 |
5 | sys.path.insert(0, os.path.abspath("."))
6 | from sktda_docs_config import *
7 |
8 | from persim import __version__
9 |
10 | project = "Persim"
11 | copyright = "2019, Nathaniel Saul"
12 | author = "Nathaniel Saul"
13 |
14 | version = __version__
15 | release = __version__
16 |
17 | language = "en"
18 |
19 | html_theme_options.update(
20 | {
21 | "collapse_naviation": False,
22 | # Google Analytics info
23 | "ga_ua": "UA-124965309-3",
24 | "ga_domain": "",
25 | "gh_url": "scikit-tda/persim",
26 | }
27 | )
28 |
29 | html_short_title = project
30 | htmlhelp_basename = "Persimdoc"
31 |
32 | autodoc_default_options = {"members": False, "maxdepth": 1}
33 |
34 | autodoc_member_order = "groupwise"
35 |
36 |
37 | # Set canonical URL from the Read the Docs Domain
38 | html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "")
39 |
40 | # Tell Jinja2 templates the build is running on Read the Docs
41 | if os.environ.get("READTHEDOCS", "") == "True":
42 | if "html_context" not in globals():
43 | html_context = {}
44 | html_context["READTHEDOCS"] = True
45 |
--------------------------------------------------------------------------------
/docs/images/data-and-pd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scikit-tda/persim/e994c6a164723fad790564003ac857f59b4bf574/docs/images/data-and-pd.png
--------------------------------------------------------------------------------
/docs/images/pers-im-h1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scikit-tda/persim/e994c6a164723fad790564003ac857f59b4bf574/docs/images/pers-im-h1.png
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | |PyPI version| |Downloads| |Codecov| |License: MIT|
2 |
3 | Persim is a Python package for many tools used in analyzing Persistence Diagrams. It currently includes implementations of most of the popular methods of working with persistence diagrams, including
4 |
5 | - Persistence Images
6 | - Persistence Landscapes
7 | - Bottleneck distance
8 | - Modified Gromov--Hausdorff distance
9 | - Sliced Wasserstein Kernel
10 | - Heat Kernel
11 | - Diagram plotting
12 |
13 | Setup
14 | --------
15 |
16 | The latest version of persim can be found on Pypi and installed with pip:
17 |
18 | .. code:: Bash
19 |
20 | pip install persim
21 |
22 |
23 | Contributions
24 | --------------
25 |
26 | We welcome contributions of all shapes and sizes. There are lots of opportunities for potential projects, so please get in touch if you would like to help out. Everything from an implementation of your favorite distance, notebooks, examples, and documentation are all equally valuable so please don't feel you can't contribute.
27 |
28 | To contribute please fork the project make your changes and submit a pull request. We will do our best to work through any issues with you and get your code merged into the main branch.
29 |
30 | Documentation
31 | --------------
32 |
33 | .. toctree::
34 | :maxdepth: 1
35 | :caption: User Guide
36 |
37 | notebooks/Persistence images
38 | notebooks/distances
39 | notebooks/Persistence landscapes
40 | reference/index
41 |
42 |
43 | .. toctree::
44 | :maxdepth: 1
45 | :caption: Tutorials
46 |
47 | notebooks/Classification with persistence images
48 | notebooks/Persistence barcode measure
49 | notebooks/Differentiation with Persistence Landscapes
50 | notebooks/Persistence Landscapes and Machine Learning
51 |
52 |
53 | .. |Downloads| image:: https://img.shields.io/pypi/dm/persim
54 | :target: https://pypi.python.org/pypi/persim/
55 | .. |PyPI version| image:: https://badge.fury.io/py/persim.svg
56 | :target: https://badge.fury.io/py/persim
57 | .. |Codecov| image:: https://codecov.io/gh/scikit-tda/persim/branch/master/graph/badge.svg
58 | :target: https://codecov.io/gh/scikit-tda/persim
59 | .. |License: MIT| image:: https://img.shields.io/badge/License-MIT-yellow.svg
60 | :target: https://opensource.org/licenses/MIT)
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/docs/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scikit-tda/persim/e994c6a164723fad790564003ac857f59b4bf574/docs/logo.png
--------------------------------------------------------------------------------
/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 | set SPHINXPROJ=Persim
13 |
14 | if "%1" == "" goto help
15 |
16 | %SPHINXBUILD% >NUL 2>NUL
17 | if errorlevel 9009 (
18 | echo.
19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
20 | echo.installed, then set the SPHINXBUILD environment variable to point
21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
22 | echo.may add the Sphinx directory to PATH.
23 | echo.
24 | echo.If you don't have Sphinx installed, grab it from
25 | echo.http://sphinx-doc.org/
26 | exit /b 1
27 | )
28 |
29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
30 | goto end
31 |
32 | :help
33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
34 |
35 | :end
36 | popd
37 |
--------------------------------------------------------------------------------
/docs/notebooks/Persistence barcode measure.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "cell_type": "markdown",
5 | "metadata": {},
6 | "source": [
7 | "# Finding significant difference with persistent entropy"
8 | ]
9 | },
10 | {
11 | "cell_type": "code",
12 | "execution_count": 1,
13 | "metadata": {},
14 | "outputs": [],
15 | "source": [
16 | "import numpy as np\n",
17 | "import ripser\n",
18 | "import matplotlib.pyplot as plt\n",
19 | "import random\n",
20 | "from persim.persistent_entropy import *\n",
21 | "from scipy import stats"
22 | ]
23 | },
24 | {
25 | "cell_type": "code",
26 | "execution_count": 2,
27 | "metadata": {},
28 | "outputs": [],
29 | "source": [
30 | "import cechmate"
31 | ]
32 | },
33 | {
34 | "cell_type": "markdown",
35 | "metadata": {},
36 | "source": [
37 | "This notebook shows how persistent entropy can be used to find significant difference in the geometrical distribution of the data. We will distinguish point clouds following a normal distribution from point clouds following a uniform distribution. Persistent entropy allow to use a one dimensional non-parametric statistical test instead of a multivariative test."
38 | ]
39 | },
40 | {
41 | "cell_type": "markdown",
42 | "metadata": {},
43 | "source": [
44 | "## Construct the data\n",
45 | "We will generate a sample of 20 point clouds, 10 following a normal distribution and 10 following the uniform one. Each point cloud is 2D and have 50 points."
46 | ]
47 | },
48 | {
49 | "cell_type": "code",
50 | "execution_count": 3,
51 | "metadata": {
52 | "scrolled": true
53 | },
54 | "outputs": [],
55 | "source": [
56 | "# Normal point clouds\n",
57 | "mu = 0.5\n",
58 | "sigma = 0.25\n",
59 | "l1 = []\n",
60 | "for i in range(10):\n",
61 | " d1 = np.random.normal(mu, sigma, (50,2))\n",
62 | " l1.append(d1)\n",
63 | "# Uniform point clouds\n",
64 | "l2 = []\n",
65 | "for i in range(10):\n",
66 | " d2 = np.random.random((50,2))\n",
67 | " l2.append(d2)"
68 | ]
69 | },
70 | {
71 | "cell_type": "code",
72 | "execution_count": 4,
73 | "metadata": {},
74 | "outputs": [
75 | {
76 | "data": {
77 | "image/png": "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\n",
78 | "text/plain": [
79 | ""
80 | ]
81 | },
82 | "metadata": {
83 | "needs_background": "light"
84 | },
85 | "output_type": "display_data"
86 | }
87 | ],
88 | "source": [
89 | "# Example of normal and uniform point clouds\n",
90 | "plt.scatter(d1[:,0], d1[:,1], label=\"Normal distribution\")\n",
91 | "plt.scatter(d2[:,0], d2[:,1], label=\"Uniform distribution\")\n",
92 | "plt.axis('equal')\n",
93 | "plt.legend()\n",
94 | "plt.show()\n"
95 | ]
96 | },
97 | {
98 | "cell_type": "markdown",
99 | "metadata": {},
100 | "source": [
101 | "## Calculate persistent entropy \n",
102 | "In order to calculate persistent entropy, is necessary to generate the persistent diagrams previously. Note that we do not consider the infinity bar in the computation of persistent entropy since it does not give information about the point cloud. "
103 | ]
104 | },
105 | {
106 | "cell_type": "code",
107 | "execution_count": 5,
108 | "metadata": {},
109 | "outputs": [],
110 | "source": [
111 | "# Generate the persistent diagrams using ripser\n",
112 | "p = 0\n",
113 | "dgm_d1 = []\n",
114 | "dgm_d2 = []\n",
115 | "for i in range(len(l1)):\n",
116 | " dgm_d1.append(ripser.ripser(l1[i])['dgms'][p])\n",
117 | " dgm_d2.append(ripser.ripser(l2[i])['dgms'][p])\n",
118 | "# Calculate their persistent entropy.\n",
119 | "e1 = persistent_entropy(dgm_d1)\n",
120 | "e2 = persistent_entropy(dgm_d2)"
121 | ]
122 | },
123 | {
124 | "cell_type": "markdown",
125 | "metadata": {},
126 | "source": [
127 | "## Statistical test\n",
128 | "Finally, perform the statistical test which suits better for your aim. In our case, we perform the Mann–Whitney U test. You can claim there are differences in the geometry of both point clouds if the pvalue is smaller than the significance level α (usually α is 0.05)."
129 | ]
130 | },
131 | {
132 | "cell_type": "code",
133 | "execution_count": 6,
134 | "metadata": {},
135 | "outputs": [
136 | {
137 | "data": {
138 | "text/plain": [
139 | "MannwhitneyuResult(statistic=18.0, pvalue=0.008628728041559883)"
140 | ]
141 | },
142 | "execution_count": 6,
143 | "metadata": {},
144 | "output_type": "execute_result"
145 | }
146 | ],
147 | "source": [
148 | "stats.mannwhitneyu(e1, e2)"
149 | ]
150 | }
151 | ],
152 | "metadata": {
153 | "kernelspec": {
154 | "display_name": "persimenv",
155 | "language": "python",
156 | "name": "persimenv"
157 | },
158 | "language_info": {
159 | "codemirror_mode": {
160 | "name": "ipython",
161 | "version": 3
162 | },
163 | "file_extension": ".py",
164 | "mimetype": "text/x-python",
165 | "name": "python",
166 | "nbconvert_exporter": "python",
167 | "pygments_lexer": "ipython3",
168 | "version": "3.7.9"
169 | }
170 | },
171 | "nbformat": 4,
172 | "nbformat_minor": 2
173 | }
174 |
--------------------------------------------------------------------------------
/docs/reference/.gitignore:
--------------------------------------------------------------------------------
1 | stubs
--------------------------------------------------------------------------------
/docs/reference/index.rst:
--------------------------------------------------------------------------------
1 | API Reference
2 | --------------
3 |
4 | Distances
5 | ==========
6 |
7 |
8 | .. autosummary::
9 | :toctree: stubs
10 | :nosignatures:
11 |
12 | persim.wasserstein
13 | persim.bottleneck
14 | persim.sliced_wasserstein
15 | persim.heat
16 | persim.gromov_hausdorff
17 |
18 |
19 | Persistence Images
20 | ====================
21 |
22 | .. autosummary::
23 | :toctree: stubs
24 | :recursive:
25 | :nosignatures:
26 |
27 | persim.PersistenceImager
28 | persim.PersImage
29 |
30 | Persistence Landscapes
31 | ========================
32 | .. autosummary::
33 | :toctree: stubs
34 | :recursive:
35 | :nosignatures:
36 |
37 | persim.PersLandscapeExact
38 | persim.PersLandscapeApprox
39 | persim.PersistenceLandscaper
40 |
41 | .. autosummary::
42 | :toctree: stubs
43 | :recursive:
44 | :nosignatures:
45 |
46 | persim.average_approx
47 | persim.snap_PL
48 | persim.plot_landscape
49 | persim.plot_landscape_simple
50 |
51 |
52 | Diagram Visualization
53 | ======================
54 |
55 | .. autosummary::
56 | :toctree: stubs
57 | :nosignatures:
58 |
59 | persim.plot_diagrams
60 | persim.bottleneck_matching
61 | persim.wasserstein_matching
62 | persim.plot_landscape
63 | persim.plot_landscape_simple
64 |
65 |
66 | Persistence barcode measure
67 | =============================
68 |
69 | .. autosummary::
70 | :toctree: stubs
71 | :nosignatures:
72 |
73 | persim.persistent_entropy
74 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | sphinx<8.2.0
2 | sphinx_rtd_theme
3 | numpydoc
4 | ipython
5 | nbsphinx
6 |
--------------------------------------------------------------------------------
/persim/__init__.py:
--------------------------------------------------------------------------------
1 | from ._version import __version__
2 | from .bottleneck import *
3 | from .gromov_hausdorff import *
4 | from .heat import *
5 | from .images import *
6 | from .landscapes.approximate import PersLandscapeApprox
7 | from .landscapes.exact import PersLandscapeExact
8 | from .landscapes.transformer import PersistenceLandscaper
9 | from .sliced_wasserstein import *
10 | from .visuals import *
11 | from .wasserstein import *
12 |
13 | __all__ = ["PersLandscapeApprox", "PersistenceLandscaper", "PersLandscapeExact"]
14 |
--------------------------------------------------------------------------------
/persim/_version.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.3.8"
2 |
--------------------------------------------------------------------------------
/persim/bottleneck.py:
--------------------------------------------------------------------------------
1 | """
2 |
3 | Implementation of the bottleneck distance using binary
4 | search and the Hopcroft-Karp algorithm
5 |
6 | Author: Chris Tralie
7 |
8 | """
9 |
10 | import numpy as np
11 |
12 | from bisect import bisect_left
13 | from hopcroftkarp import HopcroftKarp
14 | import warnings
15 |
16 | __all__ = ["bottleneck"]
17 |
18 |
19 | def bottleneck(dgm1, dgm2, matching=False):
20 | """
21 | Perform the Bottleneck distance matching between persistence diagrams.
22 | Assumes first two columns of S and T are the coordinates of the persistence
23 | points, but allows for other coordinate columns (which are ignored in
24 | diagonal matching).
25 |
26 | See the `distances` notebook for an example of how to use this.
27 |
28 | Parameters
29 | -----------
30 | dgm1: Mx(>=2)
31 | array of birth/death pairs for PD 1
32 | dgm2: Nx(>=2)
33 | array of birth/death paris for PD 2
34 | matching: bool, default False
35 | if True, return matching infromation and cross-similarity matrix
36 |
37 | Returns
38 | --------
39 |
40 | d: float
41 | bottleneck distance between dgm1 and dgm2
42 | matching: ndarray(Mx+Nx, 3), Only returns if `matching=True`
43 | A list of correspondences in an optimal matching, as well as their distance, where:
44 | * First column is index of point in first persistence diagram, or -1 if diagonal
45 | * Second column is index of point in second persistence diagram, or -1 if diagonal
46 | * Third column is the distance of each matching
47 | """
48 |
49 | return_matching = matching
50 | S = np.array(dgm1)
51 | M = min(S.shape[0], S.size)
52 | if S.size > 0:
53 | S = S[np.isfinite(S[:, 1]), :]
54 | if S.shape[0] < M:
55 | warnings.warn(
56 | "dgm1 has points with non-finite death times;" + "ignoring those points"
57 | )
58 | M = S.shape[0]
59 | T = np.array(dgm2)
60 | N = min(T.shape[0], T.size)
61 | if T.size > 0:
62 | T = T[np.isfinite(T[:, 1]), :]
63 | if T.shape[0] < N:
64 | warnings.warn(
65 | "dgm2 has points with non-finite death times;" + "ignoring those points"
66 | )
67 | N = T.shape[0]
68 |
69 | if M == 0:
70 | S = np.array([[0, 0]])
71 | M = 1
72 | if N == 0:
73 | T = np.array([[0, 0]])
74 | N = 1
75 |
76 | # Step 1: Compute CSM between S and T, including points on diagonal
77 | # L Infinity distance
78 | Sb, Sd = S[:, 0], S[:, 1]
79 | Tb, Td = T[:, 0], T[:, 1]
80 | D1 = np.abs(Sb[:, None] - Tb[None, :])
81 | D2 = np.abs(Sd[:, None] - Td[None, :])
82 | DUL = np.maximum(D1, D2)
83 |
84 | # Put diagonal elements into the matrix, being mindful that Linfinity
85 | # balls meet the diagonal line at a diamond vertex
86 | D = np.zeros((M + N, M + N))
87 | # Upper left is Linfinity cross-similarity between two diagrams
88 | D[0:M, 0:N] = DUL
89 | # Upper right is diagonal matching of points from S
90 | UR = np.inf * np.ones((M, M))
91 | np.fill_diagonal(UR, 0.5 * (S[:, 1] - S[:, 0]))
92 | D[0:M, N::] = UR
93 | # Lower left is diagonal matching of points from T
94 | UL = np.inf * np.ones((N, N))
95 | np.fill_diagonal(UL, 0.5 * (T[:, 1] - T[:, 0]))
96 | D[M::, 0:N] = UL
97 | # Lower right is all 0s by default (remaining diagonals match to diagonals)
98 |
99 | # Step 2: Perform a binary search + Hopcroft Karp to find the
100 | # bottleneck distance
101 | ds = np.sort(np.unique(D.flatten())) # [0:-1] # Everything but np.inf
102 | bdist = ds[-1]
103 | matching = {}
104 | while len(ds) >= 1:
105 | idx = 0
106 | if len(ds) > 1:
107 | idx = bisect_left(range(ds.size), int(ds.size / 2))
108 | d = ds[idx]
109 | graph = {}
110 | for i in range(D.shape[0]):
111 | graph["{}".format(i)] = {j for j in range(D.shape[1]) if D[i, j] <= d}
112 | res = HopcroftKarp(graph).maximum_matching()
113 | if len(res) == 2 * D.shape[0] and d <= bdist:
114 | bdist = d
115 | matching = res
116 | ds = ds[0:idx]
117 | else:
118 | ds = ds[idx + 1 : :]
119 |
120 | if return_matching:
121 | matchidx = []
122 | for i in range(M + N):
123 | j = matching["{}".format(i)]
124 | d = D[i, j]
125 | if i < M:
126 | if j >= N:
127 | j = -1 # Diagonal match from first persistence diagram
128 | else:
129 | if j >= N: # Diagonal to diagonal, so don't include this
130 | continue
131 | i = -1
132 | matchidx.append([i, j, d])
133 | return bdist, np.array(matchidx)
134 | else:
135 | return bdist
136 |
--------------------------------------------------------------------------------
/persim/heat.py:
--------------------------------------------------------------------------------
1 | """
2 | Implementation of the "multiscale heat kernel" (CVPR 2015),
3 |
4 | Author: Chris Tralie
5 |
6 | """
7 |
8 | import numpy as np
9 |
10 | __all__ = ["heat"]
11 |
12 | def evalHeatKernel(dgm1, dgm2, sigma):
13 | """
14 | Evaluate the continuous heat-based kernel between dgm1 and dgm2 (more correct than L2 on the discretized version above but may be slower because can't exploit fast matrix multiplication when evaluating many, many kernels)
15 | """
16 | kSigma = 0
17 | I1 = np.array(dgm1)
18 | I2 = np.array(dgm2)
19 | for i in range(I1.shape[0]):
20 | p = I1[i, 0:2]
21 | for j in range(I2.shape[0]):
22 | q = I2[j, 0:2]
23 | qc = I2[j, 1::-1]
24 | kSigma += np.exp(-(np.sum((p - q) ** 2)) / (8 * sigma)) - np.exp(
25 | -(np.sum((p - qc) ** 2)) / (8 * sigma)
26 | )
27 | return kSigma / (8 * np.pi * sigma)
28 |
29 |
30 | def heat(dgm1, dgm2, sigma=0.4):
31 | """
32 | Return the pseudo-metric between two diagrams based on the continuous
33 | heat kernel as described in "A Stable Multi-Scale Kernel for Topological Machine Learning" by Jan Reininghaus, Stefan Huber, Ulrich Bauer, and Roland Kwitt (CVPR 2015)
34 |
35 | Parameters
36 | -----------
37 |
38 | dgm1: np.array (m,2)
39 | A persistence diagram
40 | dgm2: np.array (n,2)
41 | A persistence diagram
42 | sigma: float
43 | Heat diffusion parameter (larger sigma makes blurrier)
44 | Returns
45 | --------
46 |
47 | dist: float
48 | heat kernel distance between dgm1 and dgm2
49 |
50 | """
51 | return np.sqrt(
52 | evalHeatKernel(dgm1, dgm1, sigma)
53 | + evalHeatKernel(dgm2, dgm2, sigma)
54 | - 2 * evalHeatKernel(dgm1, dgm2, sigma)
55 | )
56 |
--------------------------------------------------------------------------------
/persim/images_kernels.py:
--------------------------------------------------------------------------------
1 | """
2 | Kernel functions for PersistenceImager() transformer:
3 |
4 | A valid kernel is a Python function of the form
5 |
6 | kernel(x, y, mu=(birth, persistence), **kwargs)
7 |
8 | defining a cumulative distribution function(CDF) such that kernel(x, y) = P(X <= x, Y <=y), where x and y are numpy arrays of equal length.
9 |
10 | The required parameter mu defines the dependance of the kernel on the location of a persistence pair and is usually taken to be the mean of the probability distribution function associated to kernel CDF.
11 | """
12 | import numpy as np
13 | from scipy.special import erfc
14 |
15 | def uniform(x, y, mu=None, width=1, height=1):
16 | w1 = np.maximum(x - (mu[0] - width/2), 0)
17 | h1 = np.maximum(y - (mu[1] - height/2), 0)
18 |
19 | w = np.minimum(w1, width)
20 | h = np.minimum(h1, height)
21 |
22 | return w*h / (width*height)
23 |
24 |
25 | def gaussian(birth, pers, mu=None, sigma=None):
26 | """ Optimized bivariate normal cumulative distribution function for computing persistence images using a Gaussian kernel.
27 |
28 | Parameters
29 | ----------
30 | birth : (M,) numpy.ndarray
31 | Birth coordinate(s) of pixel corners.
32 | pers : (N,) numpy.ndarray
33 | Persistence coordinates of pixel corners.
34 | mu : (2,) numpy.ndarray
35 | Coordinates of the distribution mean (birth-persistence pairs).
36 | sigma : float or (2,2) numpy.ndarray
37 | Distribution's covariance matrix or the equal variances if the distribution is standard isotropic.
38 |
39 | Returns
40 | -------
41 | float
42 | Value of joint CDF at (birth, pers), i.e., P(X <= birth, Y <= pers).
43 | """
44 | if mu is None:
45 | mu = np.array([0.0, 0.0], dtype=np.float64)
46 | if sigma is None:
47 | sigma = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float64)
48 |
49 | if sigma[0][1] == 0.0:
50 | return sbvn_cdf(birth, pers,
51 | mu_x=mu[0], mu_y=mu[1], sigma_x=sigma[0][0], sigma_y=sigma[1][1])
52 | else:
53 | return bvn_cdf(birth, pers,
54 | mu_x=mu[0], mu_y=mu[1], sigma_xx=sigma[0][0], sigma_yy=sigma[1][1], sigma_xy=sigma[0][1])
55 |
56 |
57 | def norm_cdf(x):
58 | """ Univariate normal cumulative distribution function (CDF) with mean 0.0 and standard deviation 1.0.
59 |
60 | Parameters
61 | ----------
62 | x : float
63 | Value at which to evaluate the CDF (upper limit).
64 |
65 | Returns
66 | -------
67 | float
68 | Value of CDF at x, i.e., P(X <= x), for X ~ N[0,1].
69 | """
70 | return erfc(-x / np.sqrt(2.0)) / 2.0
71 |
72 |
73 | def sbvn_cdf(x, y, mu_x=0.0, mu_y=0.0, sigma_x=1.0, sigma_y=1.0):
74 | """ Standard bivariate normal cumulative distribution function with specified mean and variances.
75 |
76 | Parameters
77 | ----------
78 | x : float or numpy.ndarray of floats
79 | x-coordinate(s) at which to evaluate the CDF (upper limit).
80 | y : float or numpy.ndarray of floats
81 | y-coordinate(s) at which to evaluate the CDF (upper limit).
82 | mu_x : float
83 | x-coordinate of the mean.
84 | mu_y : float
85 | y-coordinate of the mean.
86 | sigma_x : float
87 | Variance in x.
88 | sigma_y : float
89 | Variance in y.
90 |
91 | Returns
92 | -------
93 | float
94 | Value of joint CDF at (x, y), i.e., P(X <= birth, Y <= pers).
95 | """
96 | x = (x - mu_x) / np.sqrt(sigma_x)
97 | y = (y - mu_y) / np.sqrt(sigma_y)
98 | return norm_cdf(x) * norm_cdf(y)
99 |
100 |
101 | def bvn_cdf(x, y, mu_x=0.0, mu_y=0.0, sigma_xx=1.0, sigma_yy=1.0, sigma_xy=0.0):
102 | """ Bivariate normal cumulative distribution function with specified mean and covariance matrix.
103 |
104 | Parameters
105 | ----------
106 | x : float or numpy.ndarray of floats
107 | x-coordinate(s) at which to evaluate the CDF (upper limit).
108 | y : float or numpy.ndarray of floats
109 | y-coordinate(s) at which to evaluate the CDF (upper limit).
110 | mu_x : float
111 | x-coordinate of the mean.
112 | mu_y : float
113 | y-coordinate of the mean.
114 | sigma_x : float
115 | Variance in x.
116 | sigma_y : float
117 | Variance in y.
118 | sigma_xy : float
119 | Covariance of x and y.
120 |
121 | Returns
122 | -------
123 | float
124 | Value of joint CDF at (x, y), i.e., P(X <= birth, Y <= pers).
125 |
126 | Notes
127 | -----
128 | Based on the Matlab implementations by Thomas H. Jørgensen (http://www.tjeconomics.com/code/) and Alan Genz (http://www.math.wsu.edu/math/faculty/genz/software/matlab/bvnl.m) using the approach described by Drezner and Wesolowsky (https://doi.org/10.1080/00949659008811236).
129 | """
130 | dh = -(x - mu_x) / np.sqrt(sigma_xx)
131 | dk = -(y - mu_y) / np.sqrt(sigma_yy)
132 |
133 | hk = np.multiply(dh, dk)
134 | r = sigma_xy / np.sqrt(sigma_xx * sigma_yy)
135 |
136 | lg, w, x = gauss_legendre_quad(r)
137 |
138 | dim1 = np.ones((len(dh),), dtype=np.float64)
139 | dim2 = np.ones((lg,), dtype=np.float64)
140 | bvn = np.zeros((len(dh),), dtype=np.float64)
141 |
142 | if abs(r) < 0.925:
143 | hs = (np.multiply(dh, dh) + np.multiply(dk, dk)) / 2.0
144 | asr = np.arcsin(r)
145 | sn1 = np.sin(asr * (1.0 - x) / 2.0)
146 | sn2 = np.sin(asr * (1.0 + x) / 2.0)
147 | dim1w = np.outer(dim1, w)
148 | hkdim2 = np.outer(hk, dim2)
149 | hsdim2 = np.outer(hs, dim2)
150 | dim1sn1 = np.outer(dim1, sn1)
151 | dim1sn2 = np.outer(dim1, sn2)
152 | sn12 = np.multiply(sn1, sn1)
153 | sn22 = np.multiply(sn2, sn2)
154 | bvn = asr * np.sum(np.multiply(dim1w, np.exp(np.divide(np.multiply(dim1sn1, hkdim2) - hsdim2,
155 | (1 - np.outer(dim1, sn12))))) +
156 | np.multiply(dim1w, np.exp(np.divide(np.multiply(dim1sn2, hkdim2) - hsdim2,
157 | (1 - np.outer(dim1, sn22))))), axis=1) / (4 * np.pi) \
158 | + np.multiply(norm_cdf(-dh), norm_cdf(-dk))
159 | else:
160 | if r < 0:
161 | dk = -dk
162 | hk = -hk
163 |
164 | if abs(r) < 1:
165 | opmr = (1.0 - r) * (1.0 + r)
166 | sopmr = np.sqrt(opmr)
167 | xmy2 = np.multiply(dh - dk, dh - dk)
168 | xmy = np.sqrt(xmy2)
169 | rhk8 = (4.0 - hk) / 8.0
170 | rhk16 = (12.0 - hk) / 16.0
171 | asr = -1.0 * (np.divide(xmy2, opmr) + hk) / 2.0
172 |
173 | ind = asr > 100
174 | bvn[ind] = sopmr * np.multiply(np.exp(asr[ind]),
175 | 1.0 - np.multiply(np.multiply(rhk8[ind], xmy2[ind] - opmr),
176 | (1.0 - np.multiply(rhk16[ind], xmy2[ind]) / 5.0) / 3.0)
177 | + np.multiply(rhk8[ind], rhk16[ind]) * opmr * opmr / 5.0)
178 |
179 | ind = hk > -100
180 | ncdfxmyt = np.sqrt(2.0 * np.pi) * norm_cdf(-xmy / sopmr)
181 | bvn[ind] = bvn[ind] - np.multiply(np.multiply(np.multiply(np.exp(-hk[ind] / 2.0), ncdfxmyt[ind]), xmy[ind]),
182 | 1.0 - np.multiply(np.multiply(rhk8[ind], xmy2[ind]),
183 | (1.0 - np.multiply(rhk16[ind], xmy2[ind]) / 5.0) / 3.0))
184 | sopmr = sopmr / 2
185 | for ix in [-1, 1]:
186 | xs = np.multiply(sopmr + sopmr * ix * x, sopmr + sopmr * ix * x)
187 | rs = np.sqrt(1 - xs)
188 | xmy2dim2 = np.outer(xmy2, dim2)
189 | dim1xs = np.outer(dim1, xs)
190 | dim1rs = np.outer(dim1, rs)
191 | dim1w = np.outer(dim1, w)
192 | rhk16dim2 = np.outer(rhk16, dim2)
193 | hkdim2 = np.outer(hk, dim2)
194 | asr1 = -1.0 * (np.divide(xmy2dim2, dim1xs) + hkdim2) / 2.0
195 |
196 | ind1 = asr1 > -100
197 | cdim2 = np.outer(rhk8, dim2)
198 | sp1 = 1.0 + np.multiply(np.multiply(cdim2, dim1xs), 1.0 + np.multiply(rhk16dim2, dim1xs))
199 | ep1 = np.divide(np.exp(np.divide(-np.multiply(hkdim2, (1.0 - dim1rs)),
200 | 2.0 * (1.0 + dim1rs))), dim1rs)
201 | bvn = bvn + np.sum(np.multiply(np.multiply(np.multiply(sopmr, dim1w), np.exp(np.multiply(asr1, ind1))),
202 | np.multiply(ep1, ind1) - np.multiply(sp1, ind1)), axis=1)
203 | bvn = -bvn / (2.0 * np.pi)
204 |
205 | if r > 0:
206 | bvn = bvn + norm_cdf(-np.maximum(dh, dk))
207 | elif r < 0:
208 | bvn = -bvn + np.maximum(0, norm_cdf(-dh) - norm_cdf(-dk))
209 |
210 | return bvn
211 |
212 |
213 | def gauss_legendre_quad(r):
214 | """ Return weights and abscissae for the Legendre-Gauss quadrature integral approximation.
215 |
216 | Parameters
217 | ----------
218 | r : float
219 | Correlation
220 |
221 | Returns
222 | -------
223 | tuple
224 | Number of points in the Gaussian quadrature rule, quadrature weights, and quadrature points.
225 | """
226 | if np.abs(r) < 0.3:
227 | lg = 3
228 | w = np.array([0.1713244923791705, 0.3607615730481384, 0.4679139345726904])
229 | x = np.array([0.9324695142031522, 0.6612093864662647, 0.2386191860831970])
230 | elif np.abs(r) < 0.75:
231 | lg = 6
232 | w = np.array([.04717533638651177, 0.1069393259953183, 0.1600783285433464,
233 | 0.2031674267230659, 0.2334925365383547, 0.2491470458134029])
234 | x = np.array([0.9815606342467191, 0.9041172563704750, 0.7699026741943050,
235 | 0.5873179542866171, 0.3678314989981802, 0.1252334085114692])
236 | else:
237 | lg = 10
238 | w = np.array([0.01761400713915212, 0.04060142980038694, 0.06267204833410906,
239 | 0.08327674157670475, 0.1019301198172404, 0.1181945319615184,
240 | 0.1316886384491766, 0.1420961093183821, 0.1491729864726037,
241 | 0.1527533871307259])
242 | x = np.array([0.9931285991850949, 0.9639719272779138, 0.9122344282513259,
243 | 0.8391169718222188, 0.7463319064601508, 0.6360536807265150,
244 | 0.5108670019508271, 0.3737060887154196, 0.2277858511416451,
245 | 0.07652652113349733])
246 |
247 | return lg, w, x
--------------------------------------------------------------------------------
/persim/images_weights.py:
--------------------------------------------------------------------------------
1 | """
2 | Weight Functions for PersistenceImager() transformer:
3 |
4 | A valid weight function is a Python function of the form
5 |
6 | weight(birth, persistence, **kwargs)
7 |
8 | defining a scalar-valued function over the birth-persistence plane, where birth and persistence are assumed to be numpy arrays of equal length. To ensure stability, functions should vanish continuously at the line persistence = 0.
9 | """
10 | import numpy as np
11 |
12 | def linear_ramp(birth, pers, low=0.0, high=1.0, start=0.0, end=1.0):
13 | """ Continuous peicewise linear ramp function which is constant below and above specified input values.
14 |
15 | Parameters
16 | ----------
17 | birth : (N,) numpy.ndarray
18 | Birth coordinates of a collection of persistence pairs.
19 | pers : (N,) numpy.ndarray
20 | Persistence coordinates of a collection of persistence pairs.
21 | low : float
22 | Minimum weight.
23 | high : float
24 | Maximum weight.
25 | start : float
26 | Start persistence value of linear transition from low to high weight.
27 | end : float
28 | End persistence value of linear transition from low to high weight.
29 |
30 | Returns
31 | -------
32 | (N,) numpy.ndarray
33 | Weights at the persistence pairs.
34 | """
35 | try:
36 | n = len(birth)
37 | except:
38 | n = 1
39 | birth = [birth]
40 | pers = [pers]
41 |
42 | w = np.zeros((n,))
43 | for i in range(n):
44 | if pers[i] < start:
45 | w[i] = low
46 | elif pers[i] > end:
47 | w[i] = high
48 | else:
49 | w[i] = (pers[i] - start) * (high - low) / (end - start) + low
50 |
51 | return w
52 |
53 | def persistence(birth, pers, n=1.0):
54 | """ Continuous monotonic function which weight a persistence pair (b,p) by p^n for some n > 0.
55 |
56 | Parameters
57 | ----------
58 | birth : (N,) numpy.ndarray
59 | Birth coordinates of a collection of persistence pairs.
60 | pers : (N,) numpy.ndarray
61 | Persistence coordinates of a collection of persistence pairs.
62 | n : positive float
63 | Exponent of persistence weighting function.
64 |
65 | Returns
66 | -------
67 | (N,) numpy.ndarray
68 | Weights at the persistence pairs.
69 | """
70 | return pers ** n
--------------------------------------------------------------------------------
/persim/landscapes/__init__.py:
--------------------------------------------------------------------------------
1 | from .exact import PersLandscapeExact
2 | from .approximate import PersLandscapeApprox
3 | from .transformer import PersistenceLandscaper
4 | from .visuals import plot_landscape, plot_landscape_simple
5 | from .tools import *
6 |
--------------------------------------------------------------------------------
/persim/landscapes/approximate.py:
--------------------------------------------------------------------------------
1 | """
2 | Persistence Landscape Approximate class
3 | """
4 |
5 | import numpy as np
6 | from operator import itemgetter
7 | from .base import PersLandscape
8 | from .auxiliary import union_vals, ndsnap_regular, _p_norm
9 |
10 | __all__ = ["PersLandscapeApprox"]
11 |
12 |
13 | class PersLandscapeApprox(PersLandscape):
14 | """
15 | Persistence Landscape Approximate class.
16 |
17 | This class implements an approximate version of Persistence Landscape,
18 | given by sampling the landscape functions on a grid. This version is only
19 | an approximation to the true landscape, but given a fine enough grid, this
20 | should suffice for most applications. If an exact calculation with no
21 | approximation is desired, consider `PersLandscapeExact`. Computations with this
22 | class are much faster compared to `PersLandscapeExact` in general.
23 |
24 | The optional parameters `start`, `stop`, `num_steps` determine the approximating
25 | grid that the values are sampled on. If both `dgms` and `values` are passed but `start`
26 | and `stop` are not, `start` and `stop` will be determined by `dgms`.
27 |
28 |
29 | Parameters
30 | ----------
31 | start : float, optional
32 | The start parameter of the approximating grid.
33 |
34 | stop : float, optional
35 | The stop parameter of the approximating grid.
36 |
37 | num_steps : int, optional
38 | The number of dimensions of the approximation, equivalently the
39 | number of steps in the grid.
40 |
41 | dgms : list of (-,2) numpy.ndarrays, optional
42 | Nested list of numpy arrays, e.g., [array( array([:]), array([:]) ),..., array([:])].
43 | Each entry in the list corresponds to a single homological degree.
44 | Each array represents the birth-death pairs in that homological degree. This is
45 | precisely the output format from ripser.py: ripser(data_user)['dgms'].
46 |
47 | hom_deg : int
48 | Represents the homology degree of the persistence diagram.
49 |
50 | values : numpy.ndarray, optional
51 | The approximated values of the landscapes, indexed by depth.
52 |
53 | compute : bool, optional
54 | Flag determining whether landscape functions are computed upon instantiation.
55 |
56 | Examples
57 | --------
58 | Define a persistence diagram and instantiate the landscape::
59 |
60 | >>> from persim import PersLandscapeApprox
61 | >>> import numpy as np
62 | >>> pd = [ np.array([[0,3],[1,4]]), np.array([[1,4]]) ]
63 | >>> pla = PersLandscapeApprox(dgms=pd, hom_deg=0); pla
64 |
65 | Approximate persistence landscape in homological degree 0 on grid from 0 to 4 with 500 steps
66 |
67 | The `start` and `stop` parameters will be determined to be as tight as possible from `dgms` if they are not passed. They can be passed directly if desired::
68 |
69 | >>> wide_pl = PersLandscapeApprox(dgms=pd, hom_deg=0, start=-1, stop=3.1415, num_steps=1000); wide_pl
70 |
71 | Approximate persistence landscape in homological degree 0 on grid from -1 to 3.1415 with 1000 steps
72 |
73 | The approximating values are stored in the `values` attribute::
74 |
75 | >>> wide_pl.values
76 |
77 | array([[0. , 0. , 0. , ..., 0.00829129, 0.00414565,
78 | 0. ],
79 | [0. , 0. , 0. , ..., 0. , 0. ,
80 | 0. ]])
81 |
82 | Arithmetic methods are implemented for approximate landscapes, so they can be summed, subtracted, and admit scalar multiplication.
83 |
84 | .. note:: Landscapes must have the same grid parameters (`start`, `stop`, and `num_steps`) before any arithmetic is involved. See the `snap_PL` function for a method that will snap a list of landscapes onto a common grid.
85 |
86 | >>> pla - wide_pl
87 |
88 | ValueError: Start values of grids do not coincide
89 |
90 | >>> from persim import snap_pl
91 | >>> [snapped_pla, snapped_wide_pl] = snap_pl([pla,wide_pl])
92 | >>> print(snapped_pla, snapped_wide_pl)
93 |
94 | Approximate persistence landscape in homological degree 0 on grid from -1 to 4 with 1000 steps Approximate persistence landscape in homological degree 0 on grid from -1 to 4 with 1000 steps
95 |
96 | >>> sum_pl = snapped_pla + snapped_wide_pl; sum_pl.values
97 | array([[0. , 0. , 0. , ..., 0.01001001, 0.00500501,
98 | 0. ],
99 | [0. , 0. , 0. , ..., 0. , 0. ,
100 | 0. ]])
101 |
102 | Approximate landscapes are sliced by depth and slicing returns the approximated values in those depths::
103 |
104 | >>> sum_pl[0]
105 |
106 | array([0.00000000e+00, 0.00000000e+00, 0.00000000e+00, ...,
107 | 1.50150150e-02, 1.00100100e-02, 5.00500501e-03, 0.00000000e+00])
108 |
109 | Norms are implemented for all `p` including the supremum norm::
110 |
111 | >>> sum_pl.p_norm(p=5)
112 |
113 | 12.44665414332285
114 |
115 | >>> sum_pl.sup_norm()
116 |
117 | 2.9958943943943943
118 |
119 | """
120 |
121 | def __init__(
122 | self,
123 | start: float = None,
124 | stop: float = None,
125 | num_steps: int = 500,
126 | dgms: list = [],
127 | hom_deg: int = 0,
128 | values=np.array([]),
129 | compute: bool = True,
130 | ) -> None:
131 | super().__init__(dgms=dgms, hom_deg=hom_deg)
132 | if not dgms and values.size == 0:
133 | raise ValueError("dgms and values cannot both be emtpy")
134 | if dgms: # diagrams are passed
135 | self.dgms = dgms[self.hom_deg]
136 | # remove infity values
137 | self.dgms = self.dgms[~np.any(self.dgms == np.inf, axis=1)]
138 | # calculate start and stop
139 | if start is None:
140 | start = min(self.dgms, key=itemgetter(0))[0]
141 | if stop is None:
142 | stop = max(self.dgms, key=itemgetter(1))[1]
143 | elif values.size > 0: # values passed, diagrams weren't
144 | self.dgms = dgms
145 | if start is None:
146 | raise ValueError("start parameter must be passed if values are passed")
147 | if stop is None:
148 | raise ValueError("stop parameter must be passed if values are passed")
149 | if start > stop:
150 | raise ValueError("start must be less than or equal to stop")
151 | self.start = start
152 | self.stop = stop
153 | self.values = values
154 | self.max_depth = len(self.values)
155 | self.num_steps = num_steps
156 | if compute:
157 | self.compute_landscape()
158 |
159 | def __repr__(self) -> str:
160 | return (
161 | "Approximate persistence landscape in homological "
162 | f"degree {self.hom_deg} on grid from {self.start} to {self.stop}"
163 | f" with {self.num_steps} steps"
164 | )
165 |
166 | def compute_landscape(self, verbose: bool = False) -> list:
167 | """Computes the approximate landscape values
168 |
169 | Parameters
170 | ----------
171 | verbose : bool, optional
172 | If true, progress messages are printed during computation.
173 |
174 | """
175 |
176 | verboseprint = print if verbose else lambda *a, **k: None
177 |
178 | if self.values.size:
179 | verboseprint("values was stored, exiting")
180 | return
181 | verboseprint("values was empty, computing values")
182 | # make grid
183 | grid_values, step = np.linspace(
184 | self.start, self.stop, self.num_steps, retstep=True
185 | )
186 | # grid_values = list(grid_values)
187 | # grid = np.array([[x,y] for x in grid_values for y in grid_values])
188 | bd_pairs = self.dgms
189 |
190 | # snap birth-death pairs to grid
191 | bd_pairs_grid = ndsnap_regular(bd_pairs, *(grid_values, grid_values))
192 |
193 | # make grid dictionary
194 | index = list(range(self.num_steps))
195 | dict_grid = dict(zip(grid_values, index))
196 |
197 | # initialze W to a list of 2m + 1 empty lists
198 | W = [[] for _ in range(self.num_steps)]
199 |
200 | # for each birth death pair
201 | for ind_in_bd_pairs, bd in enumerate(bd_pairs_grid):
202 | [b, d] = bd
203 | ind_in_Wb = dict_grid[b] # index in W
204 | ind_in_Wd = dict_grid[d] # index in W
205 | mid_pt = (
206 | ind_in_Wb + (ind_in_Wd - ind_in_Wb) // 2
207 | ) # index half way between, rounded down
208 |
209 | # step through by x value
210 | j = 0
211 | # j in (b, b+d/2]
212 | for _ in range(ind_in_Wb, mid_pt):
213 | j += 1
214 | # j*step: adding points from a line with slope 1
215 | W[ind_in_Wb + j].append(j * step)
216 | j = 0
217 | # j in (b+d/2, d)
218 | for _ in range(mid_pt + 1, ind_in_Wd):
219 | j += 1
220 | W[ind_in_Wd - j].append(j * step)
221 | # sort each list in W
222 | for i in range(len(W)):
223 | W[i] = sorted(W[i], reverse=True)
224 | # calculate k: max length of lists in W
225 | K = max([len(_) for _ in W])
226 |
227 | # initialize L to be a zeros matrix of size K x (2m+1)
228 | L = np.array([np.zeros(self.num_steps) for _ in range(K)])
229 |
230 | # input Values from W to L
231 | for i in range(self.num_steps):
232 | for k in range(len(W[i])):
233 | L[k][i] = W[i][k]
234 | # check if L is empty
235 | if not L.size:
236 | L = np.array(["empty"])
237 | print("Bad choice of grid, values is empty")
238 | self.values = L
239 | self.max_depth = len(L)
240 | return
241 |
242 | def values_to_pairs(self):
243 | """Converts function values to ordered pairs and returns them"""
244 | self.compute_landscape()
245 | grid_values = list(np.linspace(self.start, self.stop, self.num_steps))
246 | result = []
247 | for vals in self.values:
248 | pairs = list(zip(grid_values, vals))
249 | result.append(pairs)
250 | return np.array(result)
251 |
252 | def __add__(self, other):
253 | """Computes the sum of two approximate persistence landscapes
254 |
255 | Parameters
256 | ----------
257 | other : PersLandscapeApprox
258 | The other summand.
259 | """
260 | super().__add__(other)
261 | if self.start != other.start:
262 | raise ValueError("Start values of grids do not coincide")
263 | if self.stop != other.stop:
264 | raise ValueError("Stop values of grids do not coincide")
265 | if self.num_steps != other.num_steps:
266 | raise ValueError("Number of steps of grids do not coincide")
267 | self_pad, other_pad = union_vals(self.values, other.values)
268 | return PersLandscapeApprox(
269 | start=self.start,
270 | stop=self.stop,
271 | num_steps=self.num_steps,
272 | hom_deg=self.hom_deg,
273 | values=self_pad + other_pad,
274 | )
275 |
276 | def __neg__(self):
277 | """Negates an approximate persistence landscape"""
278 | return PersLandscapeApprox(
279 | start=self.start,
280 | stop=self.stop,
281 | num_steps=self.num_steps,
282 | hom_deg=self.hom_deg,
283 | values=np.array([-1 * depth_array for depth_array in self.values]),
284 | )
285 | pass
286 |
287 | def __sub__(self, other):
288 | """Computes the difference of two approximate persistence landscapes
289 |
290 | Parameters
291 | ----------
292 | other : PersLandscapeApprox
293 | The landscape to be subtracted.
294 | """
295 | return self + -other
296 |
297 | def __mul__(self, other: float):
298 | """Multiplies an approximate persistence landscape by a real scalar
299 |
300 | Parameters
301 | ----------
302 | other : float
303 | The real scalar to be multiplied.
304 | """
305 | super().__mul__(other)
306 | return PersLandscapeApprox(
307 | start=self.start,
308 | stop=self.stop,
309 | num_steps=self.num_steps,
310 | hom_deg=self.hom_deg,
311 | values=np.array([other * depth_array for depth_array in self.values]),
312 | )
313 |
314 | def __rmul__(self, other: float):
315 | """Multiplies an approximate persistence landscape by a real scalar
316 |
317 | Parameters
318 | ----------
319 | other : float
320 | The real scalar factor.
321 | """
322 | return self.__mul__(other)
323 |
324 | def __truediv__(self, other: float):
325 | """Divides an approximate persistence landscape by a non-zero real scalar
326 |
327 | Parameters
328 | ----------
329 | other : float
330 | The non-zero real scalar divisor.
331 | """
332 | super().__truediv__(other)
333 | return (1.0 / other) * self
334 |
335 | def __getitem__(self, key: slice) -> list:
336 | """
337 | Returns a list of values corresponding in range specified by
338 | depth
339 |
340 | Parameters
341 | ----------
342 | key : slice object
343 |
344 | Returns
345 | -------
346 | list
347 | The values of the landscape function corresponding
348 | to depths given by key
349 | """
350 | self.compute_landscape()
351 | return self.values[key]
352 |
353 | def p_norm(self, p: int = 2) -> float:
354 | """
355 | Returns the L_{`p`} norm of an approximate persistence landscape
356 |
357 | Parameters
358 | ----------
359 | p: float, default 2
360 | value p of the L_{`p`} norm
361 | """
362 | super().p_norm(p=p)
363 | return _p_norm(p=p, critical_pairs=self.values_to_pairs())
364 |
365 | def sup_norm(self) -> float:
366 | """
367 | Returns the supremum norm of an approximate persistence landscape
368 |
369 | """
370 | return np.max(np.abs(self.values))
371 |
--------------------------------------------------------------------------------
/persim/landscapes/auxiliary.py:
--------------------------------------------------------------------------------
1 | """
2 | Auxilary functions for working with persistence diagrams.
3 | """
4 |
5 | import itertools
6 | import numpy as np
7 |
8 |
9 | def union_vals(A, B):
10 | """Helper function for summing grid landscapes.
11 |
12 | Extends one list to the length of the other by padding with zero lists.
13 | """
14 | diff = A.shape[0] - B.shape[0]
15 | if diff < 0:
16 | # B has more entries, so pad A
17 | A = np.pad(A, pad_width=((0, np.abs(diff)), (0, 0)))
18 | return A, B
19 | elif diff > 0:
20 | # A has more entries, so pad B
21 | B = np.pad(B, pad_width=((0, diff), (0, 0)))
22 | return A, B
23 | else:
24 | return A, B
25 |
26 |
27 | def union_crit_pairs(A, B):
28 | """Helper function for summing landscapes.
29 |
30 | Computes the union of two sets of critical pairs.
31 | """
32 | result_pairs = []
33 | A.compute_landscape()
34 | B.compute_landscape()
35 | # zip functions in landscapes A and B and pad with None
36 | for a, b in list(itertools.zip_longest(A.critical_pairs, B.critical_pairs)):
37 | # B had more functions
38 | if a is None:
39 | result_pairs.append(b)
40 | # A had more functions
41 | elif b is None:
42 | result_pairs.append(a)
43 | # A, B > pos_to_slope_interp > sum_slopes > slope_to_pos_interp
44 | else:
45 | result_pairs.append(
46 | slope_to_pos_interp(
47 | sum_slopes(
48 | pos_to_slope_interp(a),
49 | pos_to_slope_interp(b),
50 | )
51 | )
52 | )
53 | return result_pairs
54 |
55 |
56 | def pos_to_slope_interp(l: list) -> list:
57 | """Convert positions of critical pairs to (x-value, slope) pairs.
58 |
59 | Intended for internal use. Inverse function of `slope_to_pos_interp`.
60 |
61 | Result
62 | ------
63 | list
64 | [(xi,mi)] for i in len(function in landscape)
65 | """
66 |
67 | output = []
68 | # for sequential pairs in landscape function
69 | for [[x0, y0], [x1, y1]] in zip(l, l[1:]):
70 | slope = (y1 - y0) / (x1 - x0)
71 | output.append([x0, slope])
72 | output.append([l[-1][0], 0])
73 | return output
74 |
75 |
76 | def slope_to_pos_interp(l: list) -> list:
77 | """Convert positions of (x-value, slope) pairs to critical pairs.
78 |
79 | Intended
80 | for internal use. Inverse function of `pos_to_slope_interp`.
81 |
82 | Result
83 | ------
84 | list
85 | [(xi, yi)]_i for i in len(function in landscape)
86 | """
87 | output = [[l[0][0], 0]]
88 | # for sequential pairs in [(xi,mi)]_i
89 | for [[x0, m], [x1, _]] in zip(l, l[1:]):
90 | # uncover y0 and y1 from slope formula
91 | y0 = output[-1][1]
92 | y1 = y0 + (x1 - x0) * m
93 | output.append([x1, y1])
94 | return output
95 |
96 |
97 | def sum_slopes(a: list, b: list) -> list:
98 | """
99 | Sum two piecewise linear functions, each represented as a list
100 | of pairs (xi,mi), where each xi is the x-value of critical pair and
101 | mi is the slope. The input should be of the form of the output of the
102 | `pos_to_slope_interp' function.
103 |
104 | Result
105 | ------
106 | list
107 |
108 | """
109 | result = []
110 | am, bm = 0, 0 # initialize slopes
111 | while len(a) > 0 or len(b) > 0:
112 | if len(a) == 0 or (len(a) > 0 and len(b) > 0 and a[0][0] > b[0][0]):
113 | # The next critical pair comes from list b.
114 | bx, bm = b[0]
115 | # pop b0
116 | b = b[1:]
117 | result.append([bx, am + bm])
118 | elif len(b) == 0 or (len(a) > 0 and len(b) > 0 and a[0][0] < b[0][0]):
119 | # The next critical pair comes from list a.
120 | ax, am = a[0]
121 | # pop a0
122 | a = a[1:]
123 | result.append([ax, am + bm])
124 | else:
125 | # The x-values of two critical pairs coincide.
126 | ax, am = a[0]
127 | bx, bm = b[0]
128 | # pop a0 and b0
129 | a, b = a[1:], b[1:]
130 | result.append([ax, am + bm])
131 | return result
132 |
133 |
134 | def ndsnap_regular(points, *grid_axes):
135 | """Snap points to the 2d grid determined by grid_axes"""
136 | # https://stackoverflow.com/q/8457645/717525
137 | snapped = []
138 | for i, ax in enumerate(grid_axes):
139 | diff = ax[:, np.newaxis] - points[:, i]
140 | best = np.argmin(np.abs(diff), axis=0)
141 | snapped.append(ax[best])
142 | return np.array(snapped).T
143 |
144 |
145 | def _p_norm(p: float, critical_pairs: list = []):
146 | """
147 | Compute `p` norm of interpolated piecewise linear function defined from list of
148 | critical pairs.
149 | """
150 | result = 0.0
151 | for l in critical_pairs:
152 | for [[x0, y0], [x1, y1]] in zip(l, l[1:]):
153 | if y0 == y1:
154 | # horizontal line segment
155 | result += (np.abs(y0) ** p) * (x1 - x0)
156 | continue
157 | # slope is well-defined
158 | slope = (y1 - y0) / (x1 - x0)
159 | b = y0 - slope * x0
160 | # segment crosses the x-axis
161 | if (y0 < 0 and y1 > 0) or (y0 > 0 and y1 < 0):
162 | z = -b / slope
163 | ev_x1 = (slope * x1 + b) ** (p + 1) / (slope * (p + 1))
164 | ev_x0 = (slope * x0 + b) ** (p + 1) / (slope * (p + 1))
165 | ev_z = (slope * z + +b) ** (p + 1) / (slope * (p + 1))
166 | result += np.abs(ev_x1 + ev_x0 - 2 * ev_z)
167 | # segment does not cross the x-axis
168 | else:
169 | ev_x1 = (slope * x1 + b) ** (p + 1) / (slope * (p + 1))
170 | ev_x0 = (slope * x0 + b) ** (p + 1) / (slope * (p + 1))
171 | result += np.abs(ev_x1 - ev_x0)
172 | return (result) ** (1.0 / p)
173 |
--------------------------------------------------------------------------------
/persim/landscapes/base.py:
--------------------------------------------------------------------------------
1 | """
2 | Base Persistence Landscape class.
3 |
4 | authors: Gabrielle Angeloro, Michael Catanzaro
5 | """
6 | from abc import ABC, abstractmethod
7 | import numpy as np
8 |
9 |
10 | class PersLandscape(ABC):
11 | """
12 | The base Persistence Landscape class.
13 |
14 | This is the base persistence landscape class. This class should not be
15 | called directly; the subclasses `PersLandscapeApprox` or
16 | `PersLandscapeExact` should instead be called.
17 |
18 | Parameters
19 | ----------
20 | dgms: list[list]
21 | A list of birth-death pairs.
22 |
23 | hom_deg: int
24 | The homological degree.
25 | """
26 |
27 | def __init__(self, dgms: list = [], hom_deg: int = 0) -> None:
28 | if not isinstance(hom_deg, int):
29 | raise TypeError("hom_deg must be an integer")
30 | if hom_deg < 0:
31 | raise ValueError("hom_deg must be positive")
32 | if not isinstance(dgms, (list, tuple, np.ndarray)):
33 | raise TypeError("dgms must be a list, tuple, or numpy array")
34 | self.hom_deg = hom_deg
35 |
36 | # We force landscapes to have arithmetic and norms,
37 | # this is the whole reason for using them.
38 |
39 | @abstractmethod
40 | def p_norm(self, p: int = 2) -> float:
41 | if p < -1 or -1 < p < 0:
42 | raise ValueError(f"p can't be negative, but {p} was passed")
43 | self.compute_landscape()
44 | if p == -1:
45 | return self.sup_norm()
46 |
47 | @abstractmethod
48 | def sup_norm(self) -> float:
49 | pass
50 |
51 | @abstractmethod
52 | def __add__(self, other):
53 | if self.hom_deg != other.hom_deg:
54 | raise ValueError(
55 | "Persistence landscapes must be of same homological degree"
56 | )
57 |
58 | @abstractmethod
59 | def __neg__(self):
60 | pass
61 |
62 | @abstractmethod
63 | def __sub__(self, other):
64 | pass
65 |
66 | @abstractmethod
67 | def __mul__(self, other):
68 | if not isinstance(other, (int, float)):
69 | raise TypeError(
70 | "Can only multiply persistence landscapes" "by real numbers"
71 | )
72 |
73 | @abstractmethod
74 | def __truediv__(self, other):
75 | if other == 0.0:
76 | raise ValueError("Cannot divide by zero")
77 |
--------------------------------------------------------------------------------
/persim/landscapes/exact.py:
--------------------------------------------------------------------------------
1 | """
2 | Persistence Landscape Exact class
3 | """
4 |
5 | import itertools
6 | from operator import itemgetter
7 |
8 | import numpy as np
9 |
10 | from .approximate import PersLandscapeApprox
11 | from .auxiliary import _p_norm, union_crit_pairs
12 | from .base import PersLandscape
13 |
14 | __all__ = ["PersLandscapeExact"]
15 |
16 |
17 | class PersLandscapeExact(PersLandscape):
18 | """Persistence Landscape Exact class.
19 |
20 | This class implements an exact version of Persistence Landscapes. The landscape
21 | functions are stored as a list of critical pairs, and the actual function is the
22 | linear interpolation of these critical pairs. All
23 | computations done with these classes are exact. For much faster but
24 | approximate methods that should suffice for most applications, consider
25 | `PersLandscapeApprox`.
26 |
27 | Parameters
28 | ----------
29 | dgms : list of (-,2) numpy.ndarrays, optional
30 | A nested list of numpy arrays, e.g., [array( array([:]), array([:]) ),..., array([:])]
31 | Each entry in the list corresponds to a single homological degree.
32 | Each array represents the birth-death pairs in that homological degree. This is
33 | the output format from ripser.py: ripser(data_user)['dgms']. Only
34 | one of diagrams or critical pairs should be specified.
35 |
36 | hom_deg : int
37 | Represents the homology degree of the persistence diagram.
38 |
39 | critical_pairs : list, optional
40 | List of lists of critical pairs (points, values) for specifying a persistence landscape.
41 | These do not necessarily have to arise from a persistence
42 | diagram. Only one of diagrams or critical pairs should be specified.
43 |
44 | compute : bool, optional
45 | Flag determining whether landscape functions are computed upon instantiation.
46 |
47 |
48 | Examples
49 | --------
50 | Define a persistence diagram and instantiate the landscape::
51 |
52 | >>> from persim import PersLandscapeExact
53 | >>> import numpy as np
54 | >>> pd = [ np.array([[0,3],[1,4]]), np.array([[1,4]]) ]
55 | >>> ple = PersLandscapeExact(dgms=pd, hom_deg=0)
56 | >>> ple
57 |
58 | `PersLandscapeExact` instances store the critical pairs of the landscape as a list of lists in the `critical_pairs` attribute. The `i`-th entry corresponds to the critical values of the depth `i` landscape::
59 |
60 | >>> ple.critical_pairs
61 |
62 | [[[0, 0], [1.5, 1.5], [2.0, 1.0], [2.5, 1.5], [4, 0]],
63 | [[1, 0], [2.0, 1.0], [3, 0]]]
64 |
65 | Addition, subtraction, and scalar multiplication between landscapes is implemented::
66 |
67 | >>> pd2 = [ np.array([[0.5,7],[3,5],[4.1,6.5]]), np.array([[1,4]])]
68 | >>> pl2 = PersLandscapeExact(dgms=pd2,hom_deg=0)
69 | >>> pl_sum = ple + pl2
70 | >>> pl_sum.critical_pairs
71 |
72 | [[[0, 0], [0.5, 0.5], [1.5, 2.5], [2.0, 2.5],
73 | [2.5, 3.5], [3.75, 3.5],[4, 3.0], [7.0, 0.0]],
74 | [[1, 0], [2.0, 1.0], [3, 0.0], [4.0, 1.0],
75 | [4.55, 0.45], [5.3, 1.2], [6.5, 0.0]],
76 | [[4.1, 0], [4.55, 0.45], [5.0, 0]]]
77 |
78 | >>> diff_pl = ple - pl2
79 | >>> diff_pl.critical_pairs
80 |
81 | [[[0, 0], [0.5, 0.5], [1.5, 0.5], [2.0, -0.5],
82 | [2.5, -0.5], [3.75, -3.0], [4, -3.0], [7.0, 0.0]],
83 | [[1, 0], [2.0, 1.0], [3, 0.0], [4.0, -1.0],
84 | [4.55, -0.45], [5.3, -1.2], [6.5, 0.0]],
85 | [[4.1, 0], [4.55, -0.45], [5.0, 0]]]
86 |
87 | >>> (5*ple).critical_pairs
88 |
89 | [[(0, 0), (1.5, 7.5), (2.0, 5.0), (2.5, 7.5), (4, 0)],
90 | [(1, 0), (2.0, 5.0), (3, 0)]]
91 |
92 | Landscapes are sliced by depth and slicing returns the critical pairs in the range specified::
93 |
94 | >>> ple[0]
95 |
96 | [[0, 0], [1.5, 1.5], [2.0, 1.0], [2.5, 1.5], [4, 0]]
97 |
98 | >>> pl2[1:]
99 |
100 | [[[3.0, 0], [4.0, 1.0], [4.55, 0.4500000000000002],
101 | [5.3, 1.2000000000000002], [6.5, 0]],
102 | [[4.1, 0], [4.55, 0.4500000000000002], [5.0, 0]]]
103 |
104 | `p` norms are implemented for all `p` as well as the supremum norm::
105 |
106 | >>> ple.p_norm(p=3)
107 |
108 | 1.7170713638299977
109 |
110 | >>> pl2.sup_norm()
111 |
112 | 3.25
113 | """
114 |
115 | def __init__(
116 | self,
117 | dgms: list = [],
118 | hom_deg: int = 0,
119 | critical_pairs: list = [],
120 | compute: bool = True,
121 | ) -> None:
122 | super().__init__(dgms=dgms, hom_deg=hom_deg)
123 | self.critical_pairs = critical_pairs
124 | if dgms:
125 | self.dgms = dgms[self.hom_deg]
126 | else: # critical pairs are passed. Is this the best check for this?
127 | self.dgms = dgms
128 | if not dgms and not critical_pairs:
129 | raise ValueError("dgms and critical_pairs cannot both be empty")
130 | self.max_depth = len(self.critical_pairs)
131 | if compute:
132 | self.compute_landscape()
133 |
134 | def __repr__(self):
135 | return f"Exact persistence landscape in homological degree {self.hom_deg}"
136 |
137 | def __neg__(self):
138 | """
139 | Computes the negation of a persistence landscape object
140 |
141 | """
142 | self.compute_landscape()
143 | return PersLandscapeExact(
144 | hom_deg=self.hom_deg,
145 | critical_pairs=[
146 | [[a, -b] for a, b in depth_list] for depth_list in self.critical_pairs
147 | ],
148 | )
149 |
150 | def __add__(self, other):
151 | """
152 | Computes the sum of two persistence landscape objects
153 |
154 | """
155 |
156 | if self.hom_deg != other.hom_deg:
157 | raise ValueError("homological degrees must match")
158 | return PersLandscapeExact(
159 | critical_pairs=union_crit_pairs(self, other), hom_deg=self.hom_deg
160 | )
161 |
162 | def __sub__(self, other):
163 | """
164 | Computes the difference of two persistence landscape objects
165 |
166 | """
167 | return self + -other
168 |
169 | def __mul__(self, other: float):
170 | """
171 | Computes the product of a persistence landscape object and a float
172 |
173 | Parameters
174 | -------
175 | other: float
176 | the real scalar the persistence landscape will be multiplied by
177 |
178 | """
179 | self.compute_landscape()
180 | return PersLandscapeExact(
181 | hom_deg=self.hom_deg,
182 | critical_pairs=[
183 | [(a, other * b) for a, b in depth_list]
184 | for depth_list in self.critical_pairs
185 | ],
186 | )
187 |
188 | def __rmul__(self, other: float):
189 | """
190 | Computes the product of a persistence landscape object and a float
191 |
192 | Parameters
193 | -------
194 | other: float
195 | the real scalar the persistence landscape will be multiplied by
196 |
197 | """
198 | return self.__mul__(other)
199 |
200 | def __truediv__(self, other: float):
201 | """
202 | Computes the quotient of a persistence landscape object and a float
203 |
204 | Parameters
205 | -------
206 | other: float
207 | the real divisor of the persistence landscape object
208 |
209 | """
210 |
211 | if other == 0.0:
212 | raise ValueError("Cannot divide by zero")
213 | return self * (1.0 / other)
214 |
215 | def __getitem__(self, key: slice) -> list:
216 | """
217 | Returns a list of critical pairs corresponding in range specified by
218 | depth
219 |
220 | Parameters
221 | ----------
222 | key : slice object
223 |
224 | Returns
225 | -------
226 | list
227 | The critical pairs of the landscape function corresponding
228 | to depths given by key
229 |
230 | Note
231 | ----
232 | If the slice is beyond `self.max_depth` an IndexError is raised.
233 | """
234 | self.compute_landscape()
235 | return self.critical_pairs[key]
236 |
237 | def compute_landscape(self, verbose: bool = False) -> list:
238 | """
239 | Stores the persistence landscape in `self.critical_pairs` as a list
240 |
241 | Parameters
242 | ----------
243 | verbose: bool, optional
244 | If true, progress messages are printed during computation
245 |
246 | """
247 |
248 | verboseprint = print if verbose else lambda *a, **k: None
249 |
250 | # check if landscapes were already computed
251 | if self.critical_pairs:
252 | verboseprint(
253 | "self.critical_pairs was not empty and stored value was returned"
254 | )
255 | return self.critical_pairs
256 |
257 | A = self.dgms
258 | # change A into a list
259 | A = list(A)
260 | # change inner nparrays into lists
261 | for i in range(len(A)):
262 | A[i] = list(A[i])
263 | if A[-1][1] == np.inf:
264 | A.pop(-1)
265 |
266 | landscape_idx = 0
267 | L = []
268 |
269 | # Sort A: read from right to left inside ()
270 | A = sorted(A, key=lambda x: [x[0], -x[1]])
271 |
272 | while A:
273 | verboseprint(f"computing landscape index {landscape_idx+1}...")
274 |
275 | # add a 0 element to begin count of lamda_k
276 | # size_landscapes = np.append(size_landscapes, [0])
277 |
278 | # pop first term
279 | b, d = A.pop(0)
280 | verboseprint(f"(b,d) is ({b},{d})")
281 |
282 | # outer brackets for start of L_k
283 | L.append([[-np.inf, 0], [b, 0], [(b + d) / 2, (d - b) / 2]])
284 |
285 | # check for duplicates of (b,d)
286 | duplicate = 0
287 |
288 | for j, itemj in enumerate(A):
289 | if itemj == [b, d]:
290 | duplicate += 1
291 | A.pop(j)
292 | else:
293 | break
294 |
295 | while L[landscape_idx][-1] != [np.inf, 0]:
296 | # if d is > = all remaining pairs, then end lambda
297 | # includes edge case with (b,d) pairs with the same death time
298 | if all(d >= _[1] for _ in A):
299 | # add to end of L_k
300 | L[landscape_idx].extend([[d, 0], [np.inf, 0]])
301 | # for duplicates, add another copy of the last computed lambda
302 | for _ in range(duplicate):
303 | L.append(L[-1])
304 | landscape_idx += 1
305 |
306 | else:
307 | # set (b', d') to be the first term so that d' > d
308 | for i, item in enumerate(A):
309 | if item[1] > d:
310 | b_prime, d_prime = A.pop(i)
311 | verboseprint(f"(bp,dp) is ({b_prime},{d_prime})")
312 | break
313 |
314 | # Case I
315 | if b_prime > d:
316 | L[landscape_idx].extend([[d, 0]])
317 |
318 | # Case II
319 | if b_prime >= d:
320 | L[landscape_idx].extend([[b_prime, 0]])
321 |
322 | # Case III
323 | else:
324 | L[landscape_idx].extend(
325 | [[(b_prime + d) / 2, (d - b_prime) / 2]]
326 | )
327 | # push (b', d) into A in order
328 | # find the first b_i in A so that b'<= b_i
329 |
330 | # push (b', d) to end of list if b' not <= any bi
331 | ind = len(A)
332 | for i in range(len(A)):
333 | if b_prime <= A[i][0]:
334 | ind = i # index to push (b', d) if b' != b_i
335 | break
336 | # if b' not < = any bi, put at the end of list
337 | if ind == len(A):
338 | pass
339 | # if b' = b_i
340 | elif b_prime == A[ind][0]:
341 | # pick out (bk,dk) such that b' = bk
342 | A_i = [item for item in A if item[0] == b_prime]
343 |
344 | # move index to the right one for every d_i such that d < d_i
345 | for j in range(len(A_i)):
346 | if d < A_i[j][1]:
347 | ind += 1
348 |
349 | # d > dk for all k
350 |
351 | A.insert(ind, [b_prime, d])
352 |
353 | L[landscape_idx].extend(
354 | [[(b_prime + d_prime) / 2, (d_prime - b_prime) / 2]]
355 | )
356 | # size_landscapes[landscape_idx] += 1
357 |
358 | b, d = b_prime, d_prime # Set (b',d')= (b, d)
359 |
360 | landscape_idx += 1
361 |
362 | verboseprint("self.critical_pairs was empty and algorthim was executed")
363 | self.max_depth = len(L)
364 | self.critical_pairs = [item[1:-1] for item in L]
365 |
366 | def compute_landscape_by_depth(self, depth: int) -> list:
367 | """
368 | Returns the function of depth from `self.critical_pairs` as a list
369 |
370 | Parameters
371 | ----------
372 | depth: int
373 | the depth of the desired landscape function
374 | """
375 |
376 | if self.critical_pairs:
377 | return self.critical_pairs[depth]
378 | else:
379 | return self.compute_landscape()[depth]
380 |
381 | def p_norm(self, p: int = 2) -> float:
382 | """
383 | Returns the L_{`p`} norm of an exact persistence landscape
384 |
385 | Parameters
386 | ----------
387 | p: float, default 2
388 | value p of the L_{`p`} norm
389 | """
390 | super().p_norm(p=p)
391 | return _p_norm(p=p, critical_pairs=self.critical_pairs)
392 |
393 | def sup_norm(self) -> float:
394 | """
395 | Returns the supremum norm of an exact persistence landscape
396 | """
397 |
398 | self.compute_landscape()
399 | cvals = list(itertools.chain.from_iterable(self.critical_pairs))
400 | return max(np.abs(cvals), key=itemgetter(1))[1]
401 |
--------------------------------------------------------------------------------
/persim/landscapes/tools.py:
--------------------------------------------------------------------------------
1 | """
2 | Tools for working with exact and approximate persistence landscapes.
3 | """
4 |
5 | import numpy as np
6 | from operator import itemgetter, attrgetter
7 |
8 | from .approximate import PersLandscapeApprox
9 | from .exact import PersLandscapeExact
10 |
11 | __all__ = [
12 | "death_vector",
13 | "vectorize",
14 | "snap_pl",
15 | "lc_approx",
16 | "average_approx",
17 | ]
18 |
19 |
20 | def death_vector(dgms: list, hom_deg: int = 0):
21 | """Returns the death vector in degree 0 for the persistence diagram
22 |
23 | For Vietoris-Rips or Cech complexes, or any similar filtration, all bars in
24 | homological degree 0 start at filtration value 0. Therefore, the discerning
25 | information is the death values. The death vector is the vector of death times,
26 | sorted from largest to smallest.
27 |
28 | Parameters
29 | ----------
30 | dgms : list of persistence diagrams
31 |
32 | hom_deg : int specifying the homological degree
33 |
34 | """
35 | if hom_deg != 0:
36 | raise NotImplementedError(
37 | "The death vector is not defined for "
38 | "homological degrees greater than zero."
39 | )
40 | return sorted(dgms[hom_deg][:, 1], reverse=True)
41 |
42 |
43 | def snap_pl(
44 | pls: list, start: float = None, stop: float = None, num_steps: int = None
45 | ) -> list:
46 | """Snap a list of PersLandscapeApprox tpes to a common grid
47 |
48 | Given a list `l` of PersLandscapeApprox types, convert them to a list
49 | where each entry has the same start, stop, and num_steps. This puts each
50 | entry of `l` on the same grid, so they can be added, averaged, etc.
51 | This assumes they're all of the same homological degree.
52 |
53 | If the user
54 | does not specify the grid parameters, they are computed as tightly as
55 | possible from the input list `l`.
56 | """
57 | if start is None:
58 | start = min(pls, key=attrgetter("start")).start
59 | if stop is None:
60 | stop = max(pls, key=attrgetter("stop")).stop
61 | if num_steps is None:
62 | num_steps = max(pls, key=attrgetter("num_steps")).num_steps
63 | grid = np.linspace(start, stop, num_steps)
64 | k = []
65 | for pl in pls:
66 | snapped_landscape = []
67 | for funct in pl:
68 | # snap each function and store
69 | snapped_landscape.append(
70 | np.array(
71 | np.interp(grid, np.linspace(pl.start, pl.stop, pl.num_steps), funct)
72 | )
73 | )
74 | # store snapped persistence landscape
75 | k.append(
76 | PersLandscapeApprox(
77 | start=start,
78 | stop=stop,
79 | num_steps=num_steps,
80 | values=np.array(snapped_landscape),
81 | hom_deg=pl.hom_deg,
82 | )
83 | )
84 | return k
85 |
86 |
87 | def lc_approx(
88 | landscapes: list,
89 | coeffs: list,
90 | start: float = None,
91 | stop: float = None,
92 | num_steps: int = None,
93 | ) -> PersLandscapeApprox:
94 | """Compute the linear combination of a list of PersLandscapeApprox objects.
95 |
96 | This uses vectorized arithmetic from numpy, so it should be faster and
97 | more memory efficient than a standard for-loop.
98 |
99 | Parameters
100 | -------
101 | landscapes: list
102 | a list of PersLandscapeApprox objects
103 |
104 | coeffs: list
105 | a list of the coefficients defining the linear combination
106 |
107 | start: float
108 | starting value for the common grid for PersLandscapeApprox objects
109 | in `landscapes`
110 |
111 | stop: float
112 | last value in the common grid for PersLandscapeApprox objects
113 | in `landscapes`
114 |
115 | num_steps: int
116 | number of steps on the common grid for PersLandscapeApprox objects
117 | in `landscapes`
118 |
119 | Returns
120 | -------
121 | PersLandscapeApprox:
122 | The specified linear combination of PersLandscapeApprox objects
123 | in `landscapes`
124 |
125 | """
126 | pl = snap_pl(landscapes, start=start, stop=stop, num_steps=num_steps)
127 | return np.sum(np.array(coeffs) * np.array(pl))
128 |
129 |
130 | def average_approx(
131 | landscapes: list, start: float = None, stop: float = None, num_steps: int = None
132 | ) -> PersLandscapeApprox:
133 | """Compute the average of a list of PersLandscapeApprox objects.
134 |
135 | Parameters
136 | -------
137 | landscapes: list
138 | a list of PersLandscapeApprox objects
139 |
140 | start: float, optional
141 | starting value for the common grid for PersLandscapeApprox objects
142 | in `landscapes`
143 |
144 | stop: float, optional
145 | last value in the common grid for PersLandscapeApprox objects
146 | in `landscapes`
147 |
148 | num_steps: int
149 | number of steps on the common grid for PersLandscapeApprox objects
150 | in `landscapes`
151 |
152 | Returns
153 | -------
154 | PersLandscapeApprox:
155 | The specified average of PersLandscapeApprox objects in `landscapes`
156 | """
157 | return lc_approx(
158 | landscapes=landscapes,
159 | coeffs=[1.0 / len(landscapes) for _ in landscapes],
160 | start=start,
161 | stop=stop,
162 | num_steps=num_steps,
163 | )
164 |
165 |
166 | def vectorize(
167 | l: PersLandscapeExact, start: float = None, stop: float = None, num_steps: int = 500
168 | ) -> PersLandscapeApprox:
169 | """Converts a `PersLandscapeExact` type to a `PersLandscapeApprox` type.
170 |
171 | Parameters
172 | ----------
173 | start: float, default None
174 | start value of grid
175 | if start is not inputed, start is assigned to minimum birth value
176 |
177 | stop: float, default None
178 | stop value of grid
179 | if stop is not inputed, stop is assigned to maximum death value
180 |
181 | num_dims: int, default 500
182 | number of points starting from `start` and ending at `stop`
183 |
184 | """
185 |
186 | l.compute_landscape()
187 | if start is None:
188 | start = min(l.critical_pairs[0], key=itemgetter(0))[0]
189 | if stop is None:
190 | stop = max(l.critical_pairs[0], key=itemgetter(0))[0]
191 | grid = np.linspace(start, stop, num_steps)
192 | result = []
193 | # creates sequential pairs of points for each lambda in critical_pairs
194 | for depth in l.critical_pairs:
195 | xs, ys = zip(*depth)
196 | result.append(np.interp(grid, xs, ys))
197 | return PersLandscapeApprox(
198 | start=start,
199 | stop=stop,
200 | num_steps=num_steps,
201 | hom_deg=l.hom_deg,
202 | values=np.array(result),
203 | )
204 |
--------------------------------------------------------------------------------
/persim/landscapes/transformer.py:
--------------------------------------------------------------------------------
1 | """
2 | Implementation of scikit-learn transformers for persistence
3 | landscapes.
4 | """
5 | from operator import itemgetter
6 |
7 | import numpy as np
8 | from sklearn.base import BaseEstimator, TransformerMixin
9 |
10 | from .approximate import PersLandscapeApprox
11 |
12 | __all__ = ["PersistenceLandscaper"]
13 |
14 |
15 | class PersistenceLandscaper(BaseEstimator, TransformerMixin):
16 | """A scikit-learn transformer for converting persistence diagrams into persistence landscapes.
17 |
18 | Parameters
19 | ----------
20 | hom_deg : int
21 | Homological degree of persistence landscape.
22 |
23 | start : float, optional
24 | Starting value of approximating grid.
25 |
26 | stop : float, optional
27 | Stopping value of approximating grid.
28 |
29 | num_steps : int, optional
30 | Number of steps of approximating grid.
31 |
32 | flatten : bool, optional
33 | Determines if the resulting values are flattened.
34 |
35 |
36 | Examples
37 | --------
38 | First instantiate the PersistenceLandscaper::
39 |
40 | >>> from persim import PersistenceLandscaper
41 | >>> pl = PersistenceLandscaper(hom_deg=0, num_steps=10, flatten=True)
42 | >>> print(pl)
43 |
44 | PersistenceLandscaper(hom_deg=1,num_steps=10)
45 |
46 | The `fit()` method is first called on a list of (-,2) numpy.ndarrays to determine the `start` and `stop` parameters of the approximating grid::
47 |
48 | >>> ex_dgms = [np.array([[0,3],[1,4]]),np.array([[1,4]])]
49 | >>> pl.fit(ex_dgms)
50 |
51 | PersistenceLandscaper(hom_deg=0, start=0, stop=4, num_steps=10)
52 |
53 | The `transform()` method will then compute the values of the landscape functions on the approximated grid. The `flatten` flag determines if the output should be a flattened numpy array::
54 |
55 | >>> ex_pl = pl.transform(ex_dgms)
56 | >>> ex_pl
57 |
58 | array([0. , 0.44444444, 0.88888889, 1.33333333, 1.33333333,
59 | 1.33333333, 1.33333333, 0.88888889, 0.44444444, 0. ,
60 | 0. , 0. , 0. , 0.44444444, 0.88888889,
61 | 0.88888889, 0.44444444, 0. , 0. , 0. ])
62 | """
63 |
64 | def __init__(
65 | self,
66 | hom_deg: int = 0,
67 | start: float = None,
68 | stop: float = None,
69 | num_steps: int = 500,
70 | flatten: bool = False,
71 | ):
72 | self.hom_deg = hom_deg
73 | self.start = start
74 | self.stop = stop
75 | self.num_steps = num_steps
76 | self.flatten = flatten
77 |
78 | def __repr__(self):
79 | if self.start is None or self.stop is None:
80 | return f"PersistenceLandscaper(hom_deg={self.hom_deg}, num_steps={self.num_steps})"
81 | else:
82 | return f"PersistenceLandscaper(hom_deg={self.hom_deg}, start={self.start}, stop={self.stop}, num_steps={self.num_steps})"
83 |
84 | def fit(self, X: np.ndarray, y=None):
85 | """Find optimal `start` and `stop` parameters for approximating grid.
86 |
87 | Parameters
88 | ----------
89 |
90 | X : list of (-,2) numpy.ndarrays
91 | List of persistence diagrams.
92 | y : Ignored
93 | Ignored; included for sklearn compatibility.
94 | """
95 | # TODO: remove infinities
96 | _dgm = X[self.hom_deg]
97 | if self.start is None:
98 | self.start = min(_dgm, key=itemgetter(0))[0]
99 | if self.stop is None:
100 | self.stop = max(_dgm, key=itemgetter(1))[1]
101 | return self
102 |
103 | def transform(self, X: np.ndarray, y=None):
104 | """Construct persistence landscape values.
105 |
106 | Parameters
107 | ----------
108 |
109 | X : list of (-,2) numpy.ndarrays
110 | List of persistence diagrams
111 | y : Ignored
112 | Ignored; included for sklearn compatibility.
113 |
114 | Returns
115 | -------
116 |
117 | numpy.ndarray
118 | Persistence Landscape values sampled on approximating grid.
119 | """
120 | result = PersLandscapeApprox(
121 | dgms=X,
122 | start=self.start,
123 | stop=self.stop,
124 | num_steps=self.num_steps,
125 | hom_deg=self.hom_deg,
126 | )
127 | if self.flatten:
128 | return (result.values).flatten()
129 | else:
130 | return result.values
131 |
--------------------------------------------------------------------------------
/persim/landscapes/visuals.py:
--------------------------------------------------------------------------------
1 | """
2 | Visualization methods for plotting persistence landscapes.
3 | """
4 |
5 | import itertools
6 | from operator import itemgetter
7 | import matplotlib as mpl
8 | import matplotlib.pyplot as plt
9 | import numpy as np
10 |
11 | from .base import PersLandscape
12 | from .exact import PersLandscapeExact
13 | from .approximate import PersLandscapeApprox
14 |
15 | __all__ = ["plot_landscape", "plot_landscape_simple"]
16 |
17 |
18 | def plot_landscape(
19 | landscape: PersLandscape,
20 | num_steps: int = 3000,
21 | color="default",
22 | alpha: float = 0.8,
23 | title=None,
24 | labels=None,
25 | padding: float = 0.0,
26 | ax=None,
27 | depth_range=None,
28 | ):
29 | """
30 | A 3-dimensional plot of a persistence landscape.
31 |
32 | If the user wishes to modify the plot beyond the provided parameters, they
33 | should create a matplotlib.pyplot figure axis first and then pass it as the
34 | optional 'ax' parameter. This allows for easy modification of the plots
35 | after creation.
36 |
37 | Warning: This function is quite slow, especially for large landscapes.
38 |
39 | Parameters
40 | ----------
41 | landscape: PersLandscape,
42 | The persistence landscape to be plotted.
43 |
44 | num_steps: int, default 3000
45 | The number of sampled points that are plotted.
46 |
47 | color, defualt cm.viridis
48 | The color scheme for shading of landscape functions.
49 |
50 | alpha: float, default 0.8
51 | The transparency of shading.
52 |
53 | title: string
54 | The title of the plot.
55 |
56 | labels: list[string],
57 | A list of strings specifying labels for the coordinate axes.
58 | Note that the second entry corresponds to the depth axis of the landscape.
59 |
60 | padding: float, default 0.0
61 | The amount of empty space or margin shown to left and right of the
62 | axis within the figure.
63 |
64 | ax: matplotlib axis, default = None
65 | An optional parameter allowing the user to pass a matplotlib axis for later modification.
66 |
67 | depth_range: slice, default = None
68 | Specifies a range of depths to be plotted. The default behavior is to plot all.
69 | """
70 | if isinstance(landscape, PersLandscapeApprox):
71 | return plot_landscape_approx(
72 | landscape=landscape,
73 | num_steps=num_steps,
74 | color=color,
75 | alpha=alpha,
76 | title=title,
77 | labels=labels,
78 | padding=padding,
79 | ax=ax,
80 | depth_range=depth_range,
81 | )
82 | if isinstance(landscape, PersLandscapeExact):
83 | return plot_landscape_exact(
84 | landscape=landscape,
85 | num_steps=num_steps,
86 | color=color,
87 | alpha=alpha,
88 | title=title,
89 | labels=labels,
90 | padding=padding,
91 | ax=ax,
92 | depth_range=depth_range,
93 | )
94 |
95 |
96 | def plot_landscape_simple(
97 | landscape: PersLandscape,
98 | alpha=1,
99 | padding=0.1,
100 | num_steps=1000,
101 | title=None,
102 | ax=None,
103 | labels=None,
104 | depth_range=None,
105 | ):
106 | """A 2-dimensional plot of the persistence landscape.
107 |
108 | This is a faster plotting
109 | utility than the standard plotting, but is recommended for smaller landscapes
110 | for ease of visualization.
111 |
112 | If the user wishes to modify the plot beyond the provided parameters, they
113 | should create a matplotlib.figure axis first and then pass it as the optional 'ax'
114 | parameter. This allows for easy modification of the plots after creation.
115 |
116 | Parameters
117 | ----------
118 | landscape: PersLandscape
119 | The landscape to be plotted.
120 |
121 | alpha: float, default 1
122 | The transparency of shading.
123 |
124 | padding: float, default 0.1
125 | The amount of empty space or margin shown to left and right of the
126 | landscape functions.
127 |
128 | num_steps: int, default 1000
129 | The number of sampled points that are plotted. Only used for plotting
130 | PersLandscapeApprox classes.
131 |
132 | title: string
133 | The title of the plot.
134 |
135 | ax: matplotlib axis, default = None
136 | The axis to plot on.
137 |
138 | labels: list[string],
139 | A list of strings specifying labels for the coordinate axes.
140 |
141 | depth_range: slice, default = None
142 | Specifies a range of depths to be plotted. The default behavior is to plot all.
143 |
144 | """
145 | if isinstance(landscape, PersLandscapeExact):
146 | return plot_landscape_exact_simple(
147 | landscape=landscape,
148 | alpha=alpha,
149 | padding=padding,
150 | title=title,
151 | ax=ax,
152 | labels=labels,
153 | depth_range=depth_range,
154 | )
155 | if isinstance(landscape, PersLandscapeApprox):
156 | return plot_landscape_approx_simple(
157 | landscape=landscape,
158 | alpha=alpha,
159 | padding=padding,
160 | num_steps=num_steps,
161 | title=title,
162 | ax=ax,
163 | labels=labels,
164 | depth_range=depth_range,
165 | )
166 |
167 |
168 | def plot_landscape_exact(
169 | landscape: PersLandscapeExact,
170 | num_steps: int = 3000,
171 | color="default",
172 | alpha=0.8,
173 | title=None,
174 | labels=None,
175 | padding: float = 0.0,
176 | ax=None,
177 | depth_range=None,
178 | ):
179 | """
180 | A 3-dimensional plot of the exact persistence landscape.
181 |
182 | Warning: This function is quite slow, especially for large landscapes.
183 |
184 | Parameters
185 | ----------
186 | landscape: PersLandscapeExact,
187 | The persistence landscape to be plotted.
188 |
189 | num_steps: int, default 3000
190 | The number of sampled points that are plotted.
191 |
192 | color, default cm.viridis
193 | The color scheme for shading of landscape functions.
194 |
195 | alpha: float, default 0.8
196 | The transparency of the shading.
197 |
198 | labels: list[string],
199 | A list of strings specifying labels for the coordinate axes.
200 | Note that the second entry corresponds to the depth axis of the landscape.
201 |
202 | padding: float, default 0.0
203 | The amount of empty grid shown to left and right of landscape functions.
204 |
205 | depth_range: slice, default = None
206 | Specifies a range of depths to be plotted. The default behavior is to plot all.
207 |
208 | """
209 | fig = plt.figure()
210 | plt.style.use(color)
211 | ax = fig.add_subplot(projection="3d")
212 | landscape.compute_landscape()
213 | # itemgetter index selects which entry to take max/min wrt.
214 | # the hanging [0] or [1] takes that entry.
215 | crit_pairs = list(itertools.chain.from_iterable(landscape.critical_pairs))
216 | min_crit_pt = min(crit_pairs, key=itemgetter(0))[0] # smallest birth time
217 | max_crit_pt = max(crit_pairs, key=itemgetter(0))[0] # largest death time
218 | max_crit_val = max(crit_pairs, key=itemgetter(1))[1] # largest peak of landscape
219 | min_crit_val = min(crit_pairs, key=itemgetter(1))[1] # smallest peak of landscape
220 | norm = mpl.colors.Normalize(vmin=min_crit_val, vmax=max_crit_val)
221 | scalarMap = mpl.cm.ScalarMappable(norm=norm)
222 | # x-axis for grid
223 | domain = np.linspace(min_crit_pt, max_crit_pt, num=num_steps)
224 | # for each landscape function
225 | if not depth_range:
226 | depth_range = range(landscape.max_depth + 1)
227 | for depth, l in enumerate(landscape):
228 | if depth not in depth_range:
229 | continue
230 | # sequential pairs in landscape
231 | xs, zs = zip(*l)
232 | image = np.interp(domain, xs, zs)
233 | for x, z in zip(domain, image):
234 | if z == 0.0:
235 | # plot a single point here?
236 | continue # moves to the next iterable in for loop
237 | if z > 0.0:
238 | ztuple = [0, z]
239 | elif z < 0.0:
240 | ztuple = [z, 0]
241 | # for coloring https://matplotlib.org/3.1.0/tutorials/colors/colormapnorms.html
242 | ax.plot(
243 | [x, x], # plotting a line to get shaded function
244 | [depth, depth],
245 | ztuple,
246 | linewidth=0.5,
247 | alpha=alpha,
248 | # c=colormap(norm(z)))
249 | c=scalarMap.to_rgba(z),
250 | )
251 | ax.plot([x], [depth], [z], "k.", markersize=0.1)
252 | ax.set_ylabel("depth")
253 | if labels:
254 | ax.set_xlabel(labels[0])
255 | ax.set_ylabel(labels[1])
256 | ax.set_zlabel(labels[2])
257 | if title:
258 | plt.title(title)
259 | ax.margins(padding)
260 | ax.view_init(10, 90)
261 | return fig
262 |
263 |
264 | def plot_landscape_exact_simple(
265 | landscape: PersLandscapeExact,
266 | alpha=1,
267 | padding=0.1,
268 | title=None,
269 | ax=None,
270 | labels=None,
271 | depth_range=None,
272 | ):
273 | """
274 | A 2-dimensional plot of the persistence landscape. This is a faster plotting
275 | utility than the standard plotting, but is recommended for smaller landscapes
276 | for ease of visualization.
277 |
278 | Parameters
279 | ----------
280 | landscape: PersLandscape
281 | The landscape to be plotted.
282 |
283 | alpha: float, default 1
284 | The transparency of shading.
285 |
286 | padding: float, default 0.1
287 | The amount of empty space or margin shown to left and right of the
288 | landscape functions.
289 |
290 | title: string
291 | The title of the plot.
292 |
293 | ax: matplotlib axis, default = None
294 | The axis to plot on.
295 |
296 | labels: list[string],
297 | A list of strings specifying labels for the coordinate axes.
298 |
299 | depth_range: slice, default = None
300 | Specifies a range of depths to be plotted. The default behavior is to plot all.
301 | """
302 | ax = ax or plt.gca()
303 | landscape.compute_landscape()
304 | if not depth_range:
305 | depth_range = range(landscape.max_depth + 1)
306 | for depth, l in enumerate(landscape):
307 | if depth not in depth_range:
308 | continue
309 | ls = np.array(l)
310 | ax.plot(ls[:, 0], ls[:, 1], label=f"$\lambda_{{{depth}}}$", alpha=alpha)
311 | ax.legend()
312 | ax.margins(padding)
313 | if title:
314 | ax.set_title(title)
315 | if labels:
316 | ax.set_xlabel(labels[0])
317 | ax.set_ylabel(labels[1])
318 | return ax
319 |
320 |
321 | def plot_landscape_approx(
322 | landscape: PersLandscapeApprox,
323 | num_steps: int = 3000,
324 | color="default",
325 | alpha=0.8,
326 | title=None,
327 | labels=None,
328 | padding: float = 0.0,
329 | ax=None,
330 | depth_range=None,
331 | ):
332 | """
333 | A plot of the approximate persistence landscape.
334 |
335 | Warning: This function is quite slow, especially for large landscapes.
336 |
337 | Parameters
338 | ----------
339 | num_steps: int, default 3000
340 | number of sampled points that are plotted
341 |
342 | color, defualt cm.viridis
343 | color scheme for shading of landscape functions
344 |
345 | labels: list[string],
346 | A list of strings specifying labels for the coordinate axes.
347 | Note that the second entry corresponds to the depth axis of the landscape.
348 |
349 | alpha, default 0.8
350 | transparency of shading
351 |
352 | padding: float, default 0.0
353 | amount of empty grid shown to left and right of landscape functions
354 |
355 | depth_range: slice, default = None
356 | Specifies a range of depths to be plotted. The default behavior is to plot all.
357 | """
358 | fig = plt.figure()
359 | plt.style.use(color)
360 | ax = fig.add_subplot(projection="3d")
361 | landscape.compute_landscape()
362 | # TODO: RE the following line: is this better than np.concatenate?
363 | # There is probably an even better way without creating an intermediary.
364 | _vals = list(itertools.chain.from_iterable(landscape.values))
365 | min_val = min(_vals)
366 | max_val = max(_vals)
367 | norm = mpl.colors.Normalize(vmin=min_val, vmax=max_val)
368 | scalarMap = mpl.cm.ScalarMappable(norm=norm)
369 | # x-axis for grid
370 | domain = np.linspace(landscape.start, landscape.stop, num=num_steps)
371 | # for each landscape function
372 | if not depth_range:
373 | depth_range = range(landscape.max_depth + 1)
374 | for depth, l in enumerate(landscape):
375 | if depth not in depth_range:
376 | continue
377 | # sequential pairs in landscape
378 | # xs, zs = zip(*l)
379 | image = np.interp(
380 | domain,
381 | np.linspace(
382 | start=landscape.start, stop=landscape.stop, num=landscape.num_steps
383 | ),
384 | l,
385 | )
386 | for x, z in zip(domain, image):
387 | if z == 0.0:
388 | # plot a single point here?
389 | continue # moves to the next iterable in for loop
390 | if z > 0.0:
391 | ztuple = [0, z]
392 | elif z < 0.0:
393 | ztuple = [z, 0]
394 | # for coloring https://matplotlib.org/3.1.0/tutorials/colors/colormapnorms.html
395 | ax.plot(
396 | [x, x], # plotting a line to get shaded function
397 | [depth, depth],
398 | ztuple,
399 | linewidth=0.5,
400 | alpha=alpha,
401 | # c=colormap(norm(z)))
402 | c=scalarMap.to_rgba(z),
403 | )
404 | ax.plot([x], [depth], [z], "k.", markersize=0.1)
405 | ax.set_ylabel("depth")
406 | if labels:
407 | ax.set_xlabel(labels[0])
408 | ax.set_ylabel(labels[1])
409 | ax.set_zlabel(labels[2])
410 | ax.margins(padding)
411 | if title:
412 | plt.title(title)
413 | ax.view_init(10, 90)
414 | return fig
415 |
416 |
417 | def plot_landscape_approx_simple(
418 | landscape: PersLandscapeApprox,
419 | alpha=1,
420 | padding=0.1,
421 | num_steps=1000,
422 | title=None,
423 | ax=None,
424 | labels=None,
425 | depth_range=None,
426 | ):
427 | """
428 | A 2-dimensional plot of the persistence landscape. This is a faster plotting
429 | utility than the standard plotting, but is recommended for smaller landscapes
430 | for ease of visualization.
431 |
432 | Parameters
433 | ----------
434 | landscape: PersLandscape
435 | The landscape to be plotted.
436 |
437 | alpha: float, default 1
438 | The transparency of shading.
439 |
440 | padding: float, default 0.1
441 | The amount of empty space or margin shown to left and right of the
442 | landscape functions.
443 |
444 | num_steps: int, default 1000
445 | The number of sampled points that are plotted. Only used for plotting
446 | PersLandscapeApprox classes.
447 |
448 | title: string
449 | The title of the plot.
450 |
451 | ax: matplotlib axis, default = None
452 | The axis to plot on.
453 |
454 | labels: list[string],
455 | A list of strings specifying labels for the coordinate axes.
456 |
457 | depth_range: slice, default = None
458 | Specifies a range of depths to be plotted. The default behavior is to plot all.
459 | """
460 |
461 | ax = ax or plt.gca()
462 | landscape.compute_landscape()
463 | if not depth_range:
464 | depth_range = range(landscape.max_depth + 1)
465 | for depth, l in enumerate(landscape):
466 | if depth not in depth_range:
467 | continue
468 | domain = np.linspace(landscape.start, landscape.stop, num=len(l))
469 | ax.plot(domain, l, label=f"$\lambda_{{{depth}}}$", alpha=alpha)
470 | ax.legend()
471 | ax.margins(padding)
472 | if title:
473 | ax.set_title(title)
474 | if labels:
475 | ax.set_xlabel(labels[0])
476 | ax.set_ylabel(labels[1])
477 | return ax
478 |
--------------------------------------------------------------------------------
/persim/persistent_entropy.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | # -*- coding: utf-8 -*-
3 |
4 | """
5 |
6 | The persistent entropy has been defined in [1]. A precursor of this definition was given in [2]
7 | to measure how different bars of the barcode are in length.
8 |
9 | [1] M. Rucco, F. Castiglione, E. Merelli, M. Pettini, Characterisation of the
10 | idiotypic immune network through persistent entropy, in: Proc. Complex, 2015.
11 | [2] H. Chintakunta, T. Gentimis, R. Gonzalez-Diaz, M.-J. Jimenez,
12 | H. Krim, An entropy-based persistence barcode, Pattern Recognition
13 | 48 (2) (2015) 391–401.
14 |
15 | Implementation of persistent entropy
16 |
17 | Author: Eduardo Paluzo Hidalgo (cimagroup, University of Seville)
18 | contact: epaluzo@us.es
19 |
20 | """
21 |
22 | from __future__ import division
23 | import numpy as np
24 |
25 |
26 | __all__ = ["persistent_entropy"]
27 |
28 |
29 | def persistent_entropy(
30 | dgms, keep_inf=False, val_inf=None, normalize=False
31 | ):
32 | """
33 | Perform the persistent entropy values of a family of persistence barcodes (or persistence diagrams).
34 | Assumes that the input diagrams are from a determined dimension. If the infinity bars have any meaning
35 | in your experiment and you want to keep them, remember to give the value you desire to val_Inf.
36 |
37 | Parameters
38 | -----------
39 | dgms: ndarray (n_pairs, 2) or list of diagrams
40 | array or list of arrays of birth/death pairs of a persistence barcode of a determined dimension.
41 | keep_inf: bool, default False
42 | if False, the infinity bars are removed.
43 | if True, the infinity bars remain.
44 | val_inf: float, default None
45 | substitution value to infinity.
46 | normalize: bool, default False
47 | if False, the persistent entropy values are not normalized.
48 | if True, the persistent entropy values are normalized.
49 |
50 | Returns
51 | --------
52 |
53 | ps: ndarray (n_pairs,)
54 | array of persistent entropy values corresponding to each persistence barcode.
55 |
56 | """
57 |
58 | if isinstance(dgms, list) == False:
59 | dgms = [dgms]
60 |
61 | # Step 1: Remove infinity bars if keep_inf = False. If keep_inf = True, infinity value is substituted by val_inf.
62 |
63 | if keep_inf == False:
64 | dgms = [(dgm[dgm[:, 1] != np.inf]) for dgm in dgms]
65 | if keep_inf == True:
66 | if val_inf != None:
67 | dgms = [
68 | np.where(dgm == np.inf, val_inf, dgm)
69 | for dgm in dgms
70 | ]
71 | else:
72 | raise Exception(
73 | "Remember: You need to provide a value to infinity bars if you want to keep them."
74 | )
75 |
76 | # Step 2: Persistent entropy computation.
77 | ps = []
78 | for dgm in dgms:
79 | l = dgm[:, 1] - dgm[:, 0]
80 | if all(l > 0):
81 | L = np.sum(l)
82 | p = l / L
83 | E = -np.sum(p * np.log(p))
84 | if normalize == True:
85 | E = E / np.log(len(l))
86 | ps.append(E)
87 | else:
88 | raise Exception("A bar is born after dying")
89 |
90 | return np.array(ps)
91 |
--------------------------------------------------------------------------------
/persim/sliced_wasserstein.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | from scipy.spatial.distance import cityblock
3 |
4 | __all__ = ["sliced_wasserstein"]
5 |
6 | def sliced_wasserstein(PD1, PD2, M=50):
7 | """ Implementation of Sliced Wasserstein distance as described in
8 | Sliced Wasserstein Kernel for Persistence Diagrams by Mathieu Carriere, Marco Cuturi, Steve Oudot (https://arxiv.org/abs/1706.03358)
9 |
10 |
11 | Parameters
12 | -----------
13 |
14 | PD1: np.array size (m,2)
15 | Persistence diagram
16 | PD2: np.array size (n,2)
17 | Persistence diagram
18 | M: int, default is 50
19 | Iterations to run approximation.
20 |
21 | Returns
22 | --------
23 | sw: float
24 | Sliced Wasserstein distance between PD1 and PD2
25 | """
26 |
27 | diag_theta = np.array(
28 | [np.cos(0.25 * np.pi), np.sin(0.25 * np.pi)], dtype=np.float32
29 | )
30 |
31 | l_theta1 = [np.dot(diag_theta, x) for x in PD1]
32 | l_theta2 = [np.dot(diag_theta, x) for x in PD2]
33 |
34 | if (len(l_theta1) != PD1.shape[0]) or (len(l_theta2) != PD2.shape[0]):
35 | raise ValueError("The projected points and origin do not match")
36 |
37 | PD_delta1 = [[np.sqrt(x ** 2 / 2.0)] * 2 for x in l_theta1]
38 | PD_delta2 = [[np.sqrt(x ** 2 / 2.0)] * 2 for x in l_theta2]
39 |
40 | # i have the input now to compute the sw
41 | sw = 0
42 | theta = 0.5
43 | step = 1.0 / M
44 | for i in range(M):
45 | l_theta = np.array(
46 | [np.cos(theta * np.pi), np.sin(theta * np.pi)], dtype=np.float32
47 | )
48 |
49 | V1 = [np.dot(l_theta, x) for x in PD1] + [np.dot(l_theta, x) for x in PD_delta2]
50 |
51 | V2 = [np.dot(l_theta, x) for x in PD2] + [np.dot(l_theta, x) for x in PD_delta1]
52 |
53 | sw += step * cityblock(sorted(V1), sorted(V2))
54 | theta += step
55 |
56 | return sw
57 |
--------------------------------------------------------------------------------
/persim/visuals.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import matplotlib.pyplot as plt
3 |
4 | __all__ = ["plot_diagrams", "bottleneck_matching", "wasserstein_matching"]
5 |
6 |
7 | def plot_diagrams(
8 | diagrams,
9 | plot_only=None,
10 | title=None,
11 | xy_range=None,
12 | labels=None,
13 | colormap="default",
14 | size=20,
15 | ax_color=np.array([0.0, 0.0, 0.0]),
16 | diagonal=True,
17 | lifetime=False,
18 | legend=True,
19 | show=False,
20 | ax=None
21 | ):
22 | """A helper function to plot persistence diagrams.
23 |
24 | Parameters
25 | ----------
26 | diagrams: ndarray (n_pairs, 2) or list of diagrams
27 | A diagram or list of diagrams. If diagram is a list of diagrams,
28 | then plot all on the same plot using different colors.
29 | plot_only: list of numeric
30 | If specified, an array of only the diagrams that should be plotted.
31 | title: string, default is None
32 | If title is defined, add it as title of the plot.
33 | xy_range: list of numeric [xmin, xmax, ymin, ymax]
34 | User provided range of axes. This is useful for comparing
35 | multiple persistence diagrams.
36 | labels: string or list of strings
37 | Legend labels for each diagram.
38 | If none are specified, we use H_0, H_1, H_2,... by default.
39 | colormap: string, default is 'default'
40 | Any of matplotlib color palettes.
41 | Some options are 'default', 'seaborn', 'sequential'.
42 | See all available styles with
43 |
44 | .. code:: python
45 |
46 | import matplotlib as mpl
47 | print(mpl.styles.available)
48 |
49 | size: numeric, default is 20
50 | Pixel size of each point plotted.
51 | ax_color: any valid matplotlib color type.
52 | See [https://matplotlib.org/api/colors_api.html](https://matplotlib.org/api/colors_api.html) for complete API.
53 | diagonal: bool, default is True
54 | Plot the diagonal x=y line.
55 | lifetime: bool, default is False. If True, diagonal is turned to False.
56 | Plot life time of each point instead of birth and death.
57 | Essentially, visualize (x, y-x).
58 | legend: bool, default is True
59 | If true, show the legend.
60 | show: bool, default is False
61 | Call plt.show() after plotting. If you are using self.plot() as part
62 | of a subplot, set show=False and call plt.show() only once at the end.
63 | """
64 |
65 | ax = ax or plt.gca()
66 | plt.style.use(colormap)
67 |
68 | xlabel, ylabel = "Birth", "Death"
69 |
70 | if not isinstance(diagrams, list):
71 | # Must have diagrams as a list for processing downstream
72 | diagrams = [diagrams]
73 |
74 | if labels is None:
75 | # Provide default labels for diagrams if using self.dgm_
76 | labels = ["$H_{{{}}}$".format(i) for i , _ in enumerate(diagrams)]
77 |
78 | if plot_only:
79 | diagrams = [diagrams[i] for i in plot_only]
80 | labels = [labels[i] for i in plot_only]
81 |
82 | if not isinstance(labels, list):
83 | labels = [labels] * len(diagrams)
84 |
85 | # Construct copy with proper type of each diagram
86 | # so we can freely edit them.
87 | diagrams = [dgm.astype(np.float32, copy=True) for dgm in diagrams]
88 |
89 | # find min and max of all visible diagrams
90 | concat_dgms = np.concatenate(diagrams).flatten()
91 | has_inf = np.any(np.isinf(concat_dgms))
92 | finite_dgms = concat_dgms[np.isfinite(concat_dgms)]
93 |
94 | # clever bounding boxes of the diagram
95 | if not xy_range:
96 | # define bounds of diagram
97 | ax_min, ax_max = np.min(finite_dgms), np.max(finite_dgms)
98 | x_r = ax_max - ax_min
99 |
100 | # Give plot a nice buffer on all sides.
101 | # ax_range=0 when only one point,
102 | buffer = 1 if xy_range == 0 else x_r / 5
103 |
104 | x_down = ax_min - buffer / 2
105 | x_up = ax_max + buffer
106 |
107 | y_down, y_up = x_down, x_up
108 | else:
109 | x_down, x_up, y_down, y_up = xy_range
110 |
111 | yr = y_up - y_down
112 |
113 | if lifetime:
114 |
115 | # Don't plot landscape and diagonal at the same time.
116 | diagonal = False
117 |
118 | # reset y axis so it doesn't go much below zero
119 | y_down = -yr * 0.05
120 | y_up = y_down + yr
121 |
122 | # set custom ylabel
123 | ylabel = "Lifetime"
124 |
125 | # set diagrams to be (x, y-x)
126 | for dgm in diagrams:
127 | dgm[:, 1] -= dgm[:, 0]
128 |
129 | # plot horizon line
130 | ax.plot([x_down, x_up], [0, 0], c=ax_color)
131 |
132 | # Plot diagonal
133 | if diagonal:
134 | ax.plot([x_down, x_up], [x_down, x_up], "--", c=ax_color)
135 |
136 | # Plot inf line
137 | if has_inf:
138 | # put inf line slightly below top
139 | b_inf = y_down + yr * 0.95
140 | ax.plot([x_down, x_up], [b_inf, b_inf], "--", c="k", label=r"$\infty$")
141 |
142 | # convert each inf in each diagram with b_inf
143 | for dgm in diagrams:
144 | dgm[np.isinf(dgm)] = b_inf
145 |
146 | # Plot each diagram
147 | for dgm, label in zip(diagrams, labels):
148 |
149 | # plot persistence pairs
150 | ax.scatter(dgm[:, 0], dgm[:, 1], size, label=label, edgecolor="none")
151 |
152 | ax.set_xlabel(xlabel)
153 | ax.set_ylabel(ylabel)
154 |
155 | ax.set_xlim([x_down, x_up])
156 | ax.set_ylim([y_down, y_up])
157 | ax.set_aspect('equal', 'box')
158 |
159 | if title is not None:
160 | ax.set_title(title)
161 |
162 | if legend is True:
163 | ax.legend(loc="lower right")
164 |
165 | if show is True:
166 | plt.show()
167 |
168 | def plot_a_bar(p, q, c='b', linestyle='-'):
169 | plt.plot([p[0], q[0]], [p[1], q[1]], c=c, linestyle=linestyle, linewidth=1)
170 |
171 | def bottleneck_matching(dgm1, dgm2, matching, labels=["dgm1", "dgm2"], ax=None):
172 | """ Visualize bottleneck matching between two diagrams
173 |
174 | Parameters
175 | ===========
176 |
177 | dgm1: Mx(>=2)
178 | array of birth/death pairs for PD 1
179 | dgm2: Nx(>=2)
180 | array of birth/death paris for PD 2
181 | matching: ndarray(Mx+Nx, 3)
182 | A list of correspondences in an optimal matching, as well as their distance, where:
183 | * First column is index of point in first persistence diagram, or -1 if diagonal
184 | * Second column is index of point in second persistence diagram, or -1 if diagonal
185 | * Third column is the distance of each matching
186 | labels: list of strings
187 | names of diagrams for legend. Default = ["dgm1", "dgm2"],
188 | ax: matplotlib Axis object
189 | For plotting on a particular axis.
190 |
191 |
192 | Examples
193 | ==========
194 |
195 | dist, matching = persim.bottleneck(A_h1, B_h1, matching=True)
196 | persim.bottleneck_matching(A_h1, B_h1, matching)
197 |
198 | """
199 | ax = ax or plt.gca()
200 |
201 | plot_diagrams([dgm1, dgm2], labels=labels, ax=ax)
202 | cp = np.cos(np.pi / 4)
203 | sp = np.sin(np.pi / 4)
204 | R = np.array([[cp, -sp], [sp, cp]])
205 | if dgm1.size == 0:
206 | dgm1 = np.array([[0, 0]])
207 | if dgm2.size == 0:
208 | dgm2 = np.array([[0, 0]])
209 | dgm1Rot = dgm1.dot(R)
210 | dgm2Rot = dgm2.dot(R)
211 | max_idx = np.argmax(matching[:, 2])
212 | for idx, [i, j, d] in enumerate(matching):
213 | i = int(i)
214 | j = int(j)
215 | linestyle = '--'
216 | linewidth = 1
217 | c = 'C2'
218 | if idx == max_idx:
219 | linestyle = '-'
220 | linewidth = 2
221 | c = 'C3'
222 | if i != -1 or j != -1: # At least one point is a non-diagonal point
223 | if i == -1:
224 | diagElem = np.array([dgm2Rot[j, 0], 0])
225 | diagElem = diagElem.dot(R.T)
226 | ax.plot([dgm2[j, 0], diagElem[0]], [dgm2[j, 1], diagElem[1]], c, linewidth=linewidth, linestyle=linestyle)
227 | elif j == -1:
228 | diagElem = np.array([dgm1Rot[i, 0], 0])
229 | diagElem = diagElem.dot(R.T)
230 | ax.plot([dgm1[i, 0], diagElem[0]], [dgm1[i, 1], diagElem[1]], c, linewidth=linewidth, linestyle=linestyle)
231 | else:
232 | ax.plot([dgm1[i, 0], dgm2[j, 0]], [dgm1[i, 1], dgm2[j, 1]], c, linewidth=linewidth, linestyle=linestyle)
233 |
234 |
235 | def wasserstein_matching(dgm1, dgm2, matching, labels=["dgm1", "dgm2"], ax=None):
236 | """ Visualize bottleneck matching between two diagrams
237 |
238 | Parameters
239 | ===========
240 |
241 | dgm1: array
242 | A diagram
243 | dgm2: array
244 | A diagram
245 | matching: ndarray(Mx+Nx, 3)
246 | A list of correspondences in an optimal matching, as well as their distance, where:
247 | * First column is index of point in first persistence diagram, or -1 if diagonal
248 | * Second column is index of point in second persistence diagram, or -1 if diagonal
249 | * Third column is the distance of each matching
250 | labels: list of strings
251 | names of diagrams for legend. Default = ["dgm1", "dgm2"],
252 | ax: matplotlib Axis object
253 | For plotting on a particular axis.
254 |
255 | Examples
256 | ==========
257 |
258 | bn_matching, (matchidx, D) = persim.wasserstien(A_h1, B_h1, matching=True)
259 | persim.wasserstein_matching(A_h1, B_h1, matchidx, D)
260 |
261 | """
262 | ax = ax or plt.gca()
263 |
264 | cp = np.cos(np.pi / 4)
265 | sp = np.sin(np.pi / 4)
266 | R = np.array([[cp, -sp], [sp, cp]])
267 | if dgm1.size == 0:
268 | dgm1 = np.array([[0, 0]])
269 | if dgm2.size == 0:
270 | dgm2 = np.array([[0, 0]])
271 | dgm1Rot = dgm1.dot(R)
272 | dgm2Rot = dgm2.dot(R)
273 | for [i, j, d] in matching:
274 | i = int(i)
275 | j = int(j)
276 | if i != -1 or j != -1: # At least one point is a non-diagonal point
277 | if i == -1:
278 | diagElem = np.array([dgm2Rot[j, 0], 0])
279 | diagElem = diagElem.dot(R.T)
280 | ax.plot([dgm2[j, 0], diagElem[0]], [dgm2[j, 1], diagElem[1]], "g")
281 | elif j == -1:
282 | diagElem = np.array([dgm1Rot[i, 0], 0])
283 | diagElem = diagElem.dot(R.T)
284 | ax.plot([dgm1[i, 0], diagElem[0]], [dgm1[i, 1], diagElem[1]], "g")
285 | else:
286 | ax.plot([dgm1[i, 0], dgm2[j, 0]], [dgm1[i, 1], dgm2[j, 1]], "g")
287 |
288 | plot_diagrams([dgm1, dgm2], labels=labels, ax=ax)
289 |
--------------------------------------------------------------------------------
/persim/wasserstein.py:
--------------------------------------------------------------------------------
1 | """
2 |
3 | Implementation of the Wasserstein distance using
4 | the Hungarian algorithm
5 |
6 | Author: Chris Tralie
7 |
8 | """
9 | import numpy as np
10 | from sklearn import metrics
11 | from scipy import optimize
12 | import warnings
13 |
14 | __all__ = ["wasserstein"]
15 |
16 |
17 | def wasserstein(dgm1, dgm2, matching=False):
18 | """
19 | Perform the Wasserstein distance matching between persistence diagrams.
20 | Assumes first two columns of dgm1 and dgm2 are the coordinates of the persistence
21 | points, but allows for other coordinate columns (which are ignored in
22 | diagonal matching).
23 |
24 | See the `distances` notebook for an example of how to use this.
25 |
26 | Parameters
27 | ------------
28 |
29 | dgm1: Mx(>=2)
30 | array of birth/death pairs for PD 1
31 | dgm2: Nx(>=2)
32 | array of birth/death paris for PD 2
33 | matching: bool, default False
34 | if True, return matching information and cross-similarity matrix
35 |
36 | Returns
37 | ---------
38 |
39 | d: float
40 | Wasserstein distance between dgm1 and dgm2
41 | (matching, D): Only returns if `matching=True`
42 | (tuples of matched indices, (N+M)x(N+M) cross-similarity matrix)
43 |
44 | """
45 |
46 | S = np.array(dgm1)
47 | M = min(S.shape[0], S.size)
48 | if S.size > 0:
49 | S = S[np.isfinite(S[:, 1]), :]
50 | if S.shape[0] < M:
51 | warnings.warn(
52 | "dgm1 has points with non-finite death times;"+
53 | "ignoring those points"
54 | )
55 | M = S.shape[0]
56 | T = np.array(dgm2)
57 | N = min(T.shape[0], T.size)
58 | if T.size > 0:
59 | T = T[np.isfinite(T[:, 1]), :]
60 | if T.shape[0] < N:
61 | warnings.warn(
62 | "dgm2 has points with non-finite death times;"+
63 | "ignoring those points"
64 | )
65 | N = T.shape[0]
66 |
67 | if M == 0:
68 | S = np.array([[0, 0]])
69 | M = 1
70 | if N == 0:
71 | T = np.array([[0, 0]])
72 | N = 1
73 | # Compute CSM between S and dgm2, including points on diagonal
74 | DUL = metrics.pairwise.pairwise_distances(S, T)
75 |
76 | # Put diagonal elements into the matrix
77 | # Rotate the diagrams to make it easy to find the straight line
78 | # distance to the diagonal
79 | cp = np.cos(np.pi/4)
80 | sp = np.sin(np.pi/4)
81 | R = np.array([[cp, -sp], [sp, cp]])
82 | S = S[:, 0:2].dot(R)
83 | T = T[:, 0:2].dot(R)
84 | D = np.zeros((M+N, M+N))
85 | np.fill_diagonal(D, 0)
86 | D[0:M, 0:N] = DUL
87 | UR = np.inf*np.ones((M, M))
88 | np.fill_diagonal(UR, S[:, 1])
89 | D[0:M, N:N+M] = UR
90 | UL = np.inf*np.ones((N, N))
91 | np.fill_diagonal(UL, T[:, 1])
92 | D[M:N+M, 0:N] = UL
93 |
94 | # Step 2: Run the hungarian algorithm
95 | matchi, matchj = optimize.linear_sum_assignment(D)
96 | matchdist = np.sum(D[matchi, matchj])
97 |
98 | if matching:
99 | matchidx = [(i, j) for i, j in zip(matchi, matchj)]
100 | ret = np.zeros((len(matchidx), 3))
101 | ret[:, 0:2] = np.array(matchidx)
102 | ret[:, 2] = D[matchi, matchj]
103 | # Indicate diagonally matched points
104 | ret[ret[:, 0] >= M, 0] = -1
105 | ret[ret[:, 1] >= N, 1] = -1
106 | # Exclude diagonal to diagonal
107 | ret = ret[ret[:, 0] + ret[:, 1] != -2, :]
108 | return matchdist, ret
109 |
110 | return matchdist
111 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools >= 61.0"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [project]
6 | name = "persim"
7 | dynamic = ["version"]
8 | description = "Distances and representations of persistence diagrams"
9 | readme = "README.md"
10 | authors = [
11 | { name = "Nathaniel Saul", email = "nat@riverasaul.com" },
12 | { name = "Chris Tralie", email = "chris.tralie@gmail.com" },
13 | { name = "Francis Motta", email = "francis.c.motta@gmail.com" },
14 | { name = "Michael Catanzaro", email = "catanzaromj@pm.me" },
15 | { name = "Gabrielle Angeloro", email = "gabrielleangeloro@gmail.com" },
16 | { name = "Calder Sheagren", email = "caldersheagren@gmail.com" },
17 | ]
18 | maintainers = [
19 | { name = "Nathaniel Saul", email = "nat@riverasaul.com" },
20 | { name = "Chris Tralie", email = "chris.tralie@gmail.com" },
21 | { name = "Michael Catanzaro", email = "catanzaromj@pm.me" },
22 | { name = "Abraham Smith", email = "abraham.smith@geomdata.com" },
23 | ]
24 |
25 | dependencies = [
26 | "deprecated",
27 | "hopcroftkarp",
28 | "joblib",
29 | "matplotlib",
30 | "numpy",
31 | "scikit-learn",
32 | ]
33 |
34 | classifiers = [
35 | "Development Status :: 3 - Alpha",
36 | "Intended Audience :: Science/Research",
37 | "Intended Audience :: Education",
38 | "Intended Audience :: Financial and Insurance Industry",
39 | "Intended Audience :: Healthcare Industry",
40 | "Topic :: Scientific/Engineering :: Information Analysis",
41 | "Topic :: Scientific/Engineering :: Mathematics",
42 | "License :: OSI Approved :: MIT License",
43 | "Programming Language :: Python",
44 | ]
45 |
46 |
47 | keywords = [
48 | "persistent homology",
49 | "persistence images",
50 | "persistence diagrams",
51 | "topological data analysis",
52 | "algebraic topology",
53 | "unsupervised learning",
54 | "supervised learning",
55 | "machine learning",
56 | "sliced wasserstein distance",
57 | "bottleneck distance",
58 | ]
59 | [project.optional-dependencies]
60 | testing = ["pytest", "pytest-cov"]
61 |
62 | docs = ["ripser", "sktda_docs_config"]
63 |
64 | [project.urls]
65 | Homepage = "https://persim.scikit-tda.org"
66 | Documentation = "https://persim.scikit-tda.org"
67 | Repository = "https://github.com/scikit-tda/persim"
68 | Issues = "https://github.com/scikit-tda/persim/issues"
69 | Changelog = "https://github.com/scikit-tda/persim/blob/master/RELEASE.txt"
70 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import setuptools
2 | import re
3 |
4 |
5 | def get_version():
6 | VERSIONFILE = "persim/_version.py"
7 | verstrline = open(VERSIONFILE, "rt").read()
8 | VSRE = r"^__version__ = ['\"]([^'\"]*)['\"]"
9 | mo = re.search(VSRE, verstrline, re.M)
10 | if mo:
11 | return mo.group(1)
12 | else:
13 | raise RuntimeError("Unable to find version string in %s." % (VERSIONFILE,))
14 |
15 |
16 | setuptools.setup(
17 | version=get_version(),
18 | )
19 |
--------------------------------------------------------------------------------
/test/__init__.py:
--------------------------------------------------------------------------------
1 | import matplotlib
2 | matplotlib.use('Agg')
--------------------------------------------------------------------------------
/test/test_distances.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pytest
3 | import scipy.sparse as sps
4 |
5 | from persim import bottleneck, gromov_hausdorff, heat, sliced_wasserstein, wasserstein
6 |
7 |
8 | class TestBottleneck:
9 | def test_single(self):
10 | d = bottleneck(np.array([[0.5, 1]]), np.array([[0.5, 1.1]]))
11 |
12 | # These are very loose bounds
13 | assert d == pytest.approx(0.1, 0.001)
14 |
15 | def test_some(self):
16 | d = bottleneck(
17 | np.array([[0.5, 1], [0.6, 1.1]]), np.array([[0.5, 1.1], [0.6, 1.3]])
18 | )
19 |
20 | # These are very loose bounds
21 | assert d == pytest.approx(0.2, 0.001)
22 |
23 | def test_diagonal(self):
24 | d = bottleneck(
25 | np.array([[10.5, 10.5], [10.6, 10.5], [10.3, 10.3]]),
26 | np.array([[0.5, 1.0], [0.6, 1.2], [0.3, 0.7]]),
27 | )
28 |
29 | # I expect this to be 0.6
30 | assert d == pytest.approx(0.3, 0.001)
31 |
32 | def test_different_size(self):
33 | d = bottleneck(np.array([[0.5, 1], [0.6, 1.1]]), np.array([[0.5, 1.1]]))
34 | assert d == 0.25
35 |
36 | def test_matching(self):
37 | dgm1 = np.array([[0.5, 1], [0.6, 1.1]])
38 | dgm2 = np.array(
39 | [
40 | [0.5, 1.1],
41 | [0.6, 1.1],
42 | [0.8, 1.1],
43 | [1.0, 1.1],
44 | ]
45 | )
46 |
47 | d, m = bottleneck(dgm1, dgm2, matching=True)
48 | u1 = np.unique(m[:, 0])
49 | u1 = u1[u1 >= 0]
50 | u2 = np.unique(m[:, 1])
51 | u2 = u2[u2 >= 0]
52 | assert u1.size == dgm1.shape[0] and u2.size == dgm2.shape[0]
53 |
54 | def test_matching_to_self(self):
55 | # Matching a diagram to itself should yield 0
56 | pd = np.array(
57 | [
58 | [0.0, 1.71858561],
59 | [0.0, 1.74160683],
60 | [0.0, 2.43430877],
61 | [0.0, 2.56949258],
62 | [0.0, np.inf],
63 | ]
64 | )
65 | dist = bottleneck(pd, pd)
66 | assert dist == 0
67 |
68 | def test_single_point_same(self):
69 | dgm = np.array([[0.11371516, 4.45734882]])
70 | dist = bottleneck(dgm, dgm)
71 | assert dist == 0
72 |
73 | def test_2x2_bisect_bug(self):
74 | dgm1 = np.array([[6, 9], [6, 8]])
75 | dgm2 = np.array([[4, 10], [9, 10]])
76 | dist = bottleneck(dgm1, dgm2)
77 | assert dist == 2
78 |
79 | def test_one_empty(self):
80 | dgm1 = np.array([[1, 2]])
81 | empty = np.array([[]])
82 | dist = bottleneck(dgm1, empty)
83 | assert dist == 0.5
84 |
85 | def test_inf_deathtime(self):
86 | dgm = np.array([[1, 2]])
87 | empty = np.array([[0, np.inf]])
88 | with pytest.warns(
89 | UserWarning, match="dgm1 has points with non-finite death"
90 | ) as w:
91 | dist1 = bottleneck(empty, dgm)
92 | with pytest.warns(
93 | UserWarning, match="dgm2 has points with non-finite death"
94 | ) as w:
95 | dist2 = bottleneck(dgm, empty)
96 | assert (dist1 == 0.5) and (dist2 == 0.5)
97 |
98 | def test_repeated(self):
99 | # Issue #44
100 | G = np.array([[0, 1], [0, 1]])
101 | H = np.array([[0, 1]])
102 | dist = bottleneck(G, H)
103 | assert dist == 0.5
104 |
105 | def test_one_diagonal(self):
106 | # Issue #70: https://github.com/scikit-tda/persim/issues/70
107 | dgm1 = np.array([[0, 10]])
108 | dgm2 = np.array([[5, 5]])
109 | dist, returned_matching = bottleneck(dgm1, dgm2, matching=True)
110 | assert dist == 5.0
111 | assert returned_matching.shape[1] == 3
112 |
113 |
114 | class TestWasserstein:
115 | def test_single(self):
116 | d = wasserstein(np.array([[0.5, 1]]), np.array([[0.5, 1.1]]))
117 |
118 | # These are very loose bounds
119 | assert d == pytest.approx(0.1, 0.001)
120 |
121 | def test_some(self):
122 | d = wasserstein(
123 | np.array([[0.6, 1.1], [0.5, 1]]), np.array([[0.5, 1.1], [0.6, 1.3]])
124 | )
125 |
126 | # These are very loose bounds
127 | assert d == pytest.approx(0.3, 0.001)
128 |
129 | def test_matching_to_self(self):
130 | # Matching a diagram to itself should yield 0
131 | pd = np.array(
132 | [[0.0, 1.71858561], [0.0, 1.74160683], [0.0, 2.43430877], [0.0, 2.56949258]]
133 | )
134 | dist = wasserstein(pd, pd)
135 | assert dist == 0
136 |
137 | def test_single_point_same(self):
138 | dgm = np.array([[0.11371516, 4.45734882]])
139 | dist = wasserstein(dgm, dgm)
140 | assert dist == 0
141 |
142 | def test_one_empty(self):
143 | dgm1 = np.array([[1, 2]])
144 | empty = np.array([])
145 | dist = wasserstein(dgm1, empty)
146 | assert np.allclose(dist, np.sqrt(2) / 2)
147 |
148 | def test_inf_deathtime(self):
149 | dgm = np.array([[1, 2]])
150 | empty = np.array([[0, np.inf]])
151 | with pytest.warns(
152 | UserWarning, match="dgm1 has points with non-finite death"
153 | ) as w:
154 | dist1 = wasserstein(empty, dgm)
155 | with pytest.warns(
156 | UserWarning, match="dgm2 has points with non-finite death"
157 | ) as w:
158 | dist2 = wasserstein(dgm, empty)
159 | assert (np.allclose(dist1, np.sqrt(2) / 2)) and (
160 | np.allclose(dist2, np.sqrt(2) / 2)
161 | )
162 |
163 | def test_repeated(self):
164 | dgm1 = np.array([[0, 10], [0, 10]])
165 | dgm2 = np.array([[0, 10]])
166 | dist = wasserstein(dgm1, dgm2)
167 | assert dist == pytest.approx(5 * np.sqrt(2))
168 |
169 | def test_matching(self):
170 | dgm1 = np.array([[0.5, 1], [0.6, 1.1]])
171 | dgm2 = np.array(
172 | [
173 | [0.5, 1.1],
174 | [0.6, 1.1],
175 | [0.8, 1.1],
176 | [1.0, 1.1],
177 | ]
178 | )
179 |
180 | d, m = wasserstein(dgm1, dgm2, matching=True)
181 | u1 = np.unique(m[:, 0])
182 | u1 = u1[u1 >= 0]
183 | u2 = np.unique(m[:, 1])
184 | u2 = u2[u2 >= 0]
185 | assert u1.size == dgm1.shape[0] and u2.size == dgm2.shape[0]
186 |
187 |
188 | class TestSliced:
189 | def test_single(self):
190 | d = sliced_wasserstein(np.array([[0.5, 1]]), np.array([[0.5, 1.1]]))
191 |
192 | # These are very loose bounds
193 | assert d == pytest.approx(0.1, 0.01)
194 |
195 | def test_some(self):
196 | d = sliced_wasserstein(
197 | np.array([[0.5, 1], [0.6, 1.1]]), np.array([[0.5, 1.1], [0.6, 1.2]])
198 | )
199 |
200 | # These are very loose bounds
201 | assert d == pytest.approx(0.19, 0.02)
202 |
203 | def test_different_size(self):
204 | d = sliced_wasserstein(np.array([[0.5, 1], [0.6, 1.1]]), np.array([[0.6, 1.2]]))
205 |
206 | # These are very loose bounds
207 | assert d == pytest.approx(0.314, 0.1)
208 |
209 | def test_single_point_same(self):
210 | dgm = np.array([[0.11371516, 4.45734882]])
211 | dist = sliced_wasserstein(dgm, dgm)
212 | assert dist == 0
213 |
214 |
215 | class TestHeat:
216 | def test_compare(self):
217 | """lets at least be sure that large distances are captured"""
218 | d1 = heat(np.array([[0.5, 1]]), np.array([[0.5, 1.1]]))
219 | d2 = heat(np.array([[0.5, 1]]), np.array([[0.5, 1.5]]))
220 |
221 | # These are very loose bounds
222 | assert d1 < d2
223 |
224 | def test_single_point_same(self):
225 | dgm = np.array([[0.11371516, 4.45734882]])
226 | dist = heat(dgm, dgm)
227 | assert dist == 0
228 |
229 |
230 | class TestModifiedGromovHausdorff:
231 | def test_single_point(self):
232 | A_G = sps.csr_matrix(([1] * 4, ([0, 0, 1, 2], [1, 3, 2, 3])), shape=(4, 4))
233 | A_H = sps.csr_matrix(([], ([], [])), shape=(1, 1))
234 | lb, ub = gromov_hausdorff(A_G, A_H)
235 |
236 | assert lb == 1
237 | assert ub == 1
238 |
239 | def test_isomorphic(self):
240 | A_G = sps.csr_matrix(
241 | ([1] * 6, ([0, 0, 0, 1, 1, 2], [1, 2, 3, 2, 3, 3])), shape=(4, 4)
242 | )
243 | A_H = sps.csr_matrix(
244 | ([1] * 6, ([0, 0, 0, 1, 1, 2], [1, 2, 3, 2, 3, 3])), shape=(4, 4)
245 | )
246 | lb, ub = gromov_hausdorff(A_G, A_H)
247 |
248 | assert lb == 0
249 | assert ub == 0
250 |
251 | def test_cliques(self):
252 | A_G = sps.csr_matrix(([1], ([0], [1])), shape=(2, 2))
253 | A_H = sps.csr_matrix(
254 | ([1] * 6, ([0, 0, 0, 1, 1, 2], [1, 2, 3, 2, 3, 3])), shape=(4, 4)
255 | )
256 | lb, ub = gromov_hausdorff(A_G, A_H)
257 |
258 | assert lb == 0.5
259 | assert ub == 0.5
260 |
261 | def test_same_size(self):
262 | A_G = sps.csr_matrix(
263 | ([1] * 6, ([0, 0, 0, 1, 1, 2], [1, 2, 3, 2, 3, 3])), shape=(4, 4)
264 | )
265 | A_H = sps.csr_matrix(([1] * 4, ([0, 0, 1, 2], [1, 3, 2, 3])), shape=(4, 4))
266 | lb, ub = gromov_hausdorff(A_G, A_H)
267 |
268 | assert lb == 0.5
269 | assert ub == 0.5
270 |
271 | def test_many_graphs(self):
272 | A_G = sps.csr_matrix(([1], ([0], [1])), shape=(2, 2))
273 | A_H = sps.csr_matrix(
274 | ([1] * 6, ([0, 0, 0, 1, 1, 2], [1, 2, 3, 2, 3, 3])), shape=(4, 4)
275 | )
276 | A_I = sps.csr_matrix(([1] * 4, ([0, 0, 1, 2], [1, 3, 2, 3])), shape=(4, 4))
277 | lbs, ubs = gromov_hausdorff([A_G, A_H, A_I])
278 |
279 | np.testing.assert_array_equal(
280 | lbs, np.array([[0, 0.5, 0.5], [0.5, 0, 0.5], [0.5, 0.5, 0]])
281 | )
282 | np.testing.assert_array_equal(
283 | ubs, np.array([[0, 0.5, 0.5], [0.5, 0, 0.5], [0.5, 0.5, 0]])
284 | )
285 |
--------------------------------------------------------------------------------
/test/test_landscapes.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pytest
3 |
4 | from persim.landscapes import (
5 | PersistenceLandscaper,
6 | PersLandscapeApprox,
7 | PersLandscapeExact,
8 | average_approx,
9 | death_vector,
10 | lc_approx,
11 | snap_pl,
12 | vectorize,
13 | )
14 |
15 |
16 | class TestPersLandscapeExact:
17 | def test_exact_empty(self):
18 | with pytest.raises(ValueError):
19 | PersLandscapeExact()
20 |
21 | def test_exact_hom_deg(self):
22 | P = PersLandscapeExact(
23 | dgms=[np.array([[1.0, 5.0]])],
24 | hom_deg=0,
25 | )
26 | assert P.hom_deg == 0
27 | with pytest.raises(ValueError):
28 | PersLandscapeExact(hom_deg=-1)
29 |
30 | def test_exact_critical_pairs(self):
31 | assert not PersLandscapeExact(
32 | dgms=[np.array([[0, 3]])], compute=False
33 | ).critical_pairs
34 | # example from Peter & Pavel's paper
35 | P = PersLandscapeExact(
36 | dgms=[
37 | np.array([[1.0, 5.0], [2.0, 8.0], [3.0, 4.0], [5.0, 9.0], [6.0, 7.0]])
38 | ],
39 | hom_deg=0,
40 | )
41 | P.compute_landscape()
42 |
43 | expected_P_pairs = [
44 | [
45 | [1.0, 0],
46 | [3.0, 2.0],
47 | [3.5, 1.5],
48 | [5.0, 3.0],
49 | [6.5, 1.5],
50 | [7.0, 2.0],
51 | [9.0, 0],
52 | ],
53 | [[2.0, 0], [3.5, 1.5], [5.0, 0], [6.5, 1.5], [8.0, 0]],
54 | [[3.0, 0], [3.5, 0.5], [4.0, 0], [6.0, 0], [6.5, 0.5], [7.0, 0]],
55 | ]
56 | assert len(P.critical_pairs) == len(expected_P_pairs)
57 | for idx, lambda_level in enumerate(P.critical_pairs):
58 | assert lambda_level == expected_P_pairs[idx]
59 |
60 | # duplicate bars
61 | Q = PersLandscapeExact(dgms=[np.array([[1, 5], [1, 5], [3, 6]])], hom_deg=0)
62 | Q.compute_landscape()
63 |
64 | expected_Q_pairs = [
65 | [[1, 0], [3.0, 2.0], [4.0, 1.0], [4.5, 1.5], [6, 0]],
66 | [[1, 0], [3.0, 2.0], [4.0, 1.0], [4.5, 1.5], [6, 0]],
67 | [[3, 0], [4.0, 1.0], [5, 0]],
68 | ]
69 | assert len(Q.critical_pairs) == len(expected_Q_pairs)
70 | for idx, lambda_level in enumerate(Q.critical_pairs):
71 | assert lambda_level == expected_Q_pairs[idx]
72 |
73 | def test_exact_add(self):
74 | with pytest.raises(ValueError):
75 | PersLandscapeExact(
76 | critical_pairs=[[[0, 0], [1, 1]]], hom_deg=0
77 | ) + PersLandscapeExact(critical_pairs=[[[0, 0], [1, 1]]], hom_deg=1)
78 | Q = PersLandscapeExact(
79 | critical_pairs=[[[0, 0], [1, 1], [2, 2]]]
80 | ) + PersLandscapeExact(critical_pairs=[[[0, 0], [0.5, 1], [1.5, 3]]])
81 | np.testing.assert_array_equal(
82 | Q.critical_pairs, [[[0, 0], [0.5, 1.5], [1, 3], [1.5, 4.5], [2, 5]]]
83 | )
84 |
85 | def test_exact_neg(self):
86 | P = PersLandscapeExact(
87 | critical_pairs=[[[0, 0], [1, 1], [2, 1], [3, 1], [4, 0]]]
88 | )
89 | assert (-P).critical_pairs == [[[0, 0], [1, -1], [2, -1], [3, -1], [4, 0]]]
90 | Q = PersLandscapeExact(critical_pairs=[[[0, 0], [5, 0]]])
91 | assert (-Q).critical_pairs == Q.critical_pairs
92 |
93 | def test_exact_mul(self):
94 | P = PersLandscapeExact(
95 | critical_pairs=[[[0, 0], [1, 2], [2, 0], [3, -1], [4, 0]]]
96 | )
97 | np.testing.assert_array_equal(
98 | (4 * P).critical_pairs, [[[0, 0], [1, 8], [2, 0], [3, -4], [4, 0]]]
99 | )
100 | np.testing.assert_array_equal(
101 | (P * (-1)).critical_pairs, [[[0, 0], [1, -2], [2, 0], [3, 1], [4, 0]]]
102 | )
103 |
104 | def test_exact_div(self):
105 | P = PersLandscapeExact(
106 | critical_pairs=[[[0, 0], [1, 2], [2, 0], [3, -1], [4, 0]]]
107 | )
108 | np.testing.assert_array_equal(
109 | (P / 2).critical_pairs, [[[0, 0], [1, 1], [2, 0], [3, -0.5], [4, 0]]]
110 | )
111 | with pytest.raises(ValueError):
112 | P / 0
113 |
114 | def test_exact_get_item(self):
115 | P = PersLandscapeExact(dgms=[np.array([[1, 5], [1, 5], [3, 6]])])
116 | np.testing.assert_array_equal(
117 | P[1], [[1, 0], [3, 2], [4, 1], [4.5, 1.5], [6, 0]]
118 | )
119 | with pytest.raises(IndexError):
120 | P[3]
121 |
122 | def test_exact_norm(self):
123 | P = PersLandscapeExact(
124 | critical_pairs=[[[0, 0], [1, 1], [2, 1], [3, 1], [4, 0]]], hom_deg=0
125 | )
126 | negP = PersLandscapeExact(
127 | critical_pairs=[[[0, 0], [1, -1], [2, -1], [3, -1], [4, 0]]], hom_deg=0
128 | )
129 | assert P.sup_norm() == 1
130 | assert P.p_norm(p=2) == pytest.approx(np.sqrt(2 + (2.0 / 3.0)))
131 | assert P.p_norm(p=5) == pytest.approx((2 + (1.0 / 3.0)) ** (1.0 / 5.0))
132 | assert P.p_norm(p=113) == pytest.approx((2 + (1.0 / 57.0)) ** (1.0 / 113.0))
133 | assert negP.sup_norm() == 1
134 | assert negP.p_norm(p=2) == pytest.approx(np.sqrt(2 + (2.0 / 3.0)))
135 | assert negP.p_norm(p=5) == pytest.approx((2 + (1.0 / 3.0)) ** (1.0 / 5.0))
136 | assert negP.p_norm(p=113) == pytest.approx((2 + (1.0 / 57.0)) ** (1.0 / 113.0))
137 |
138 |
139 | class TestPersLandscapeApprox:
140 | def test_approx_empty(self):
141 | with pytest.raises(ValueError):
142 | PersLandscapeApprox()
143 |
144 | def test_approx_compute_flag(self):
145 | assert (
146 | PersLandscapeApprox(dgms=[np.array([[0, 4]])], compute=False).values.size
147 | == 0
148 | )
149 |
150 | def test_approx_hom_deg(self):
151 | with pytest.raises(IndexError):
152 | PersLandscapeApprox(dgms=[np.array([[2, 6], [4, 10]])], hom_deg=2)
153 | with pytest.raises(ValueError):
154 | PersLandscapeApprox(hom_deg=-1)
155 | assert (
156 | PersLandscapeApprox(
157 | dgms=[np.array([[2, 6], [4, 10]]), np.array([[1, 5], [4, 6]])],
158 | hom_deg=1,
159 | ).hom_deg
160 | == 1
161 | )
162 |
163 | def test_approx_grid_params(self):
164 | with pytest.raises(ValueError):
165 | PersLandscapeApprox(values=np.array([[2, 6], [4, 10]]), start=1)
166 | with pytest.raises(ValueError):
167 | PersLandscapeApprox(values=np.array([[2, 6], [4, 10]]), stop=11)
168 | with pytest.raises(ValueError):
169 | PersLandscapeApprox(values=np.array([[2, 6], [4, 10]]), start=3, stop=2)
170 | P = PersLandscapeApprox(dgms=[np.array([[0, 3], [1, 4]])], num_steps=100)
171 | assert P.start == 0
172 | assert P.stop == 4
173 | assert P.num_steps == 100
174 | Q = PersLandscapeApprox(values=np.array([[2, 6], [4, 10]]), start=0, stop=5)
175 | assert Q.start == 0
176 | assert Q.stop == 5
177 |
178 | def test_max_depth(self):
179 | P1 = PersLandscapeApprox(dgms=[np.array([[2,6],[4,10]])])
180 | assert P1.max_depth == 2
181 | P2 = PersLandscapeApprox(dgms=[np.array([[1,3],[5,7]])])
182 | assert P2.max_depth == 1
183 |
184 |
185 | def test_approx_compute_landscape(self):
186 | diagrams1 = [np.array([[2, 6], [4, 10]])]
187 | P1 = PersLandscapeApprox(
188 | start=0, stop=10, num_steps=11, dgms=diagrams1, hom_deg=0
189 | )
190 | P2 = PersLandscapeApprox(
191 | start=0, stop=10, num_steps=6, dgms=diagrams1, hom_deg=0
192 | )
193 | P3 = PersLandscapeApprox(
194 | start=0, stop=10, num_steps=21, dgms=diagrams1, hom_deg=0
195 | )
196 |
197 | # duplicate bars
198 | diagrams2 = [np.array([[2, 6], [2, 6], [4, 10]])]
199 | Q2 = PersLandscapeApprox(
200 | start=0, stop=10, num_steps=11, dgms=diagrams2, hom_deg=0
201 | )
202 |
203 | # edge case: bars start same value
204 | diagrams3 = [np.array([[3, 5], [3, 7]])]
205 | Q3 = PersLandscapeApprox(
206 | start=0, stop=10, num_steps=11, dgms=diagrams3, hom_deg=0
207 | )
208 |
209 | # edge case: bars end same value
210 | diagrams4 = [np.array([[2, 6], [4, 6]])]
211 | Q4 = PersLandscapeApprox(
212 | start=0, stop=10, num_steps=11, dgms=diagrams4, hom_deg=0
213 | )
214 | np.testing.assert_array_equal(
215 | P1.values,
216 | np.array(
217 | [
218 | [0.0, 0.0, 0.0, 1.0, 2.0, 1.0, 2.0, 3.0, 2.0, 1.0, 0.0],
219 | [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
220 | ]
221 | ),
222 | )
223 |
224 | np.testing.assert_array_equal(
225 | P2.values, np.array([[0.0, 0.0, 2.0, 2.0, 2.0, 0.0]])
226 | )
227 |
228 | np.testing.assert_array_equal(
229 | P3.values,
230 | np.array(
231 | [
232 | [
233 | 0.0,
234 | 0.0,
235 | 0.0,
236 | 0.0,
237 | 0.0,
238 | 0.5,
239 | 1.0,
240 | 1.5,
241 | 2.0,
242 | 1.5,
243 | 1.0,
244 | 1.5,
245 | 2.0,
246 | 2.5,
247 | 3.0,
248 | 2.5,
249 | 2.0,
250 | 1.5,
251 | 1.0,
252 | 0.5,
253 | 0.0,
254 | ],
255 | [
256 | 0.0,
257 | 0.0,
258 | 0.0,
259 | 0.0,
260 | 0.0,
261 | 0.0,
262 | 0.0,
263 | 0.0,
264 | 0.0,
265 | 0.5,
266 | 1.0,
267 | 0.5,
268 | 0.0,
269 | 0.0,
270 | 0.0,
271 | 0.0,
272 | 0.0,
273 | 0.0,
274 | 0.0,
275 | 0.0,
276 | 0.0,
277 | ],
278 | ]
279 | ),
280 | )
281 |
282 | np.testing.assert_array_equal(
283 | Q2.values,
284 | np.array(
285 | [
286 | [0.0, 0.0, 0.0, 1.0, 2.0, 1.0, 2.0, 3.0, 2.0, 1.0, 0.0],
287 | [0.0, 0.0, 0.0, 1.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
288 | [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
289 | ]
290 | ),
291 | )
292 |
293 | np.testing.assert_array_equal(
294 | Q3.values,
295 | np.array(
296 | [
297 | [0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0],
298 | [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
299 | ]
300 | ),
301 | )
302 |
303 | np.testing.assert_array_equal(
304 | Q4.values,
305 | np.array(
306 | [
307 | [0.0, 0.0, 0.0, 1.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
308 | [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
309 | ]
310 | ),
311 | )
312 |
313 | def test_approx_values_to_pairs(self):
314 | diagrams1 = [np.array([[2, 6], [4, 10]])]
315 | P1 = PersLandscapeApprox(
316 | start=0, stop=10, num_steps=11, dgms=diagrams1, hom_deg=0
317 | )
318 | np.testing.assert_array_equal(
319 | P1.values_to_pairs(),
320 | np.array(
321 | [
322 | [
323 | [0, 0],
324 | [1, 0],
325 | [2, 0],
326 | [3, 1],
327 | [4, 2],
328 | [5, 1],
329 | [6, 2],
330 | [7, 3],
331 | [8, 2],
332 | [9, 1],
333 | [10, 0],
334 | ],
335 | [
336 | [0, 0],
337 | [1, 0],
338 | [2, 0],
339 | [3, 0],
340 | [4, 0],
341 | [5, 1],
342 | [6, 0],
343 | [7, 0],
344 | [8, 0],
345 | [9, 0],
346 | [10, 0],
347 | ],
348 | ]
349 | ),
350 | )
351 |
352 | def test_approx_add(self):
353 | with pytest.raises(ValueError):
354 | PersLandscapeApprox(
355 | dgms=[np.array([[0, 1]])], start=0, stop=2
356 | ) + PersLandscapeApprox(dgms=[np.array([[0, 1]])], start=1, stop=2)
357 | with pytest.raises(ValueError):
358 | PersLandscapeApprox(
359 | dgms=[np.array([[0, 1]])], start=0, stop=2
360 | ) + PersLandscapeApprox(dgms=[np.array([[0, 1]])], start=0, stop=3)
361 | with pytest.raises(ValueError):
362 | PersLandscapeApprox(
363 | dgms=[np.array([[0, 1]])], start=0, stop=2, num_steps=100
364 | ) + PersLandscapeApprox(
365 | dgms=[np.array([[0, 1]])], start=1, stop=2, num_steps=200
366 | )
367 | with pytest.raises(ValueError):
368 | PersLandscapeApprox(
369 | dgms=[np.array([[0, 1], [1, 2]])], hom_deg=0
370 | ) + PersLandscapeApprox(
371 | dgms=[np.array([[0, 1], [1, 2]]), np.array([[1, 3]])], hom_deg=1
372 | )
373 |
374 | Q = PersLandscapeApprox(
375 | start=0, stop=5, num_steps=6, dgms=[np.array([[1, 4]])]
376 | ) + PersLandscapeApprox(start=0, stop=5, num_steps=6, dgms=[np.array([[2, 5]])])
377 | np.testing.assert_array_equal(Q.values, np.array([[0, 0, 1, 2, 1, 0]]))
378 |
379 | def test_approx_neg(self):
380 | P = PersLandscapeApprox(
381 | start=0, stop=5, num_steps=6, dgms=[np.array([[0, 5], [1, 3]])]
382 | )
383 | np.testing.assert_array_equal(
384 | (-P).values, np.array([[0, -1, -2, -2, -1, 0], [0, 0, -1, 0, 0, 0]])
385 | )
386 |
387 | def test_approx_sub(self):
388 | P = PersLandscapeApprox(
389 | start=0,
390 | stop=5,
391 | num_steps=6,
392 | values=np.array([[0, 1, 2, 2, 1, 0], [0, 0, 1, 0, 0, 0]]),
393 | )
394 | Q = PersLandscapeApprox(
395 | start=0, stop=5, num_steps=6, values=np.array([[0, 1, 1, 1, 1, 0]])
396 | )
397 | np.testing.assert_array_equal(
398 | (P - Q).values, np.array([[0, 0, 1, 1, 0, 0], [0, 0, 1, 0, 0, 0]])
399 | )
400 |
401 | def test_approx_mul(self):
402 | P = PersLandscapeApprox(
403 | start=0,
404 | stop=5,
405 | num_steps=6,
406 | values=np.array([[0, 1, 2, 2, 1, 0], [0, 0, 1, 0, 0, 0]]),
407 | )
408 | np.testing.assert_array_equal(
409 | (3 * P).values, np.array([[0, 3, 6, 6, 3, 0], [0, 0, 3, 0, 0, 0]])
410 | )
411 |
412 | def test_approx_div(self):
413 | P = PersLandscapeApprox(
414 | start=0,
415 | stop=5,
416 | num_steps=6,
417 | values=np.array([[0, 1, 2, 2, 1, 0], [0, 0, 1, 0, 0, 0]]),
418 | )
419 | with pytest.raises(ValueError):
420 | P / 0
421 | np.testing.assert_array_equal(
422 | (P / 2).values, np.array([[0, 0.5, 1, 1, 0.5, 0], [0, 0, 0.5, 0, 0, 0]])
423 | )
424 |
425 | def test_approx_get_item(self):
426 | P = PersLandscapeApprox(
427 | start=0,
428 | stop=5,
429 | num_steps=6,
430 | values=np.array([[0, 1, 2, 2, 1, 0], [0, 0, 1, 0, 0, 0]]),
431 | )
432 | with pytest.raises(IndexError):
433 | P[3]
434 | np.testing.assert_array_equal(P[1], np.array([0, 0, 1, 0, 0, 0]))
435 |
436 | def test_approx_norm(self):
437 | P = PersLandscapeApprox(
438 | start=0,
439 | stop=5,
440 | num_steps=6,
441 | values=np.array([[0, 1, 1, 1, 1, 0]]),
442 | )
443 | assert P.p_norm(p=2) == pytest.approx(np.sqrt(3 + (2.0 / 3.0)))
444 | assert P.sup_norm() == 1.0
445 | Q = PersLandscapeApprox(
446 | start=0,
447 | stop=5,
448 | num_steps=6,
449 | values=np.array([[0, 1, 2, 2, 1, 0], [0, 0, 1, 1, 0, 0]]),
450 | )
451 | assert Q.p_norm(p=3) == pytest.approx((16 + 3.0 / 2.0) ** (1.0 / 3.0))
452 | assert Q.sup_norm() == 2.0
453 |
454 |
455 | class TestAuxiliary:
456 | def test_vectorize(self):
457 | P = PersLandscapeExact(dgms=[np.array([[0, 5], [1, 4]])])
458 | Q = vectorize(P, start=0, stop=5, num_steps=6)
459 | assert Q.hom_deg == P.hom_deg
460 | assert Q.start == 0
461 | assert Q.stop == 5
462 | np.testing.assert_array_equal(
463 | Q.values, np.array([[0, 1, 2, 2, 1, 0], [0, 0, 1, 1, 0, 0]])
464 | )
465 | R = vectorize(P)
466 | assert R.start == 0
467 | assert R.stop == 5
468 |
469 | def test_snap_PL(self):
470 | P = PersLandscapeApprox(
471 | start=0,
472 | stop=5,
473 | num_steps=6,
474 | values=np.array([[0, 1, 1, 1, 1, 0]]),
475 | )
476 | [P_snapped] = snap_pl([P], start=0, stop=10, num_steps=11)
477 | assert P_snapped.hom_deg == P.hom_deg
478 | assert P_snapped.start == 0
479 | assert P_snapped.stop == 10
480 | assert P_snapped.num_steps == 11
481 | np.testing.assert_array_equal(
482 | P_snapped.values, np.array([[0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0]])
483 | )
484 | Q = PersLandscapeApprox(
485 | start=1, stop=6, num_steps=6, values=np.array([[0, 1, 2, 2, 1, 0]])
486 | )
487 | [P_snap, Q_snap] = snap_pl([P, Q], start=0, stop=10, num_steps=11)
488 | assert P_snap.start == 0
489 | assert Q_snap.stop == 10
490 | assert P_snap.num_steps == Q_snap.num_steps
491 | np.testing.assert_array_equal(
492 | Q_snap.values, np.array([[0, 0, 1, 2, 2, 1, 0, 0, 0, 0, 0]])
493 | )
494 |
495 | def test_lc_approx(self):
496 | P = PersLandscapeApprox(
497 | start=0,
498 | stop=10,
499 | num_steps=11,
500 | values=np.array([[0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0]]),
501 | )
502 | Q = PersLandscapeApprox(
503 | start=0,
504 | stop=10,
505 | num_steps=11,
506 | values=np.array([[0, 1, 2, 2, 1, 0, 1, 2, 3, 2, 0]]),
507 | )
508 | lc = lc_approx([P, Q], [2, -1])
509 | np.testing.assert_array_equal(
510 | lc.values, np.array([[0, 1, 0, 0, 1, 0, -1, 0, -1, -2, 0]])
511 | )
512 |
513 | def test_average_approx(self):
514 | P = PersLandscapeApprox(
515 | start=0, stop=5, num_steps=6, values=np.array([[0, 1, 2, 3, 2, 1]])
516 | )
517 | Q = PersLandscapeApprox(
518 | start=0, stop=5, num_steps=6, values=np.array([[0, -1, -2, -1, 0, 1]])
519 | )
520 | np.testing.assert_array_equal(
521 | average_approx([P, Q]).values, np.array([[0, 0, 0, 1, 1, 1]])
522 | )
523 |
524 | def test_death_vector(self):
525 | dgms = [np.array([[0, 4], [0, 1], [0, 10]])]
526 | np.testing.assert_array_equal(death_vector(dgms), [10, 4, 1])
527 |
528 |
529 | class TestTransformer:
530 | def test_persistenceimager(self):
531 | pl = PersistenceLandscaper(hom_deg=0, num_steps=5, flatten=True)
532 | assert pl.hom_deg == 0
533 | assert not pl.start
534 | assert not pl.stop
535 | assert pl.num_steps == 5
536 | assert pl.flatten
537 | dgms = [np.array([[0, 3], [1, 4]]), np.array([[1, 4]])]
538 | pl.fit(dgms)
539 | assert pl.start == 0
540 | assert pl.stop == 4.0
541 | np.testing.assert_array_equal(
542 | pl.transform(dgms),
543 | np.array(
544 | [
545 | 0.0,
546 | 1.0,
547 | 1.0,
548 | 1.0,
549 | 0.0,
550 | 0.0,
551 | 0.0,
552 | 1.0,
553 | 0.0,
554 | 0.0,
555 | ]
556 | ),
557 | )
558 | pl2 = PersistenceLandscaper(hom_deg=1, num_steps=4)
559 | assert pl2.hom_deg == 1
560 | pl2.fit(dgms)
561 | assert pl2.start == 1.0
562 | assert pl2.stop == 4.0
563 | np.testing.assert_array_equal(pl2.transform(dgms), [[0.0, 1.0, 1.0, 0.0]])
564 | pl3 = PersistenceLandscaper(hom_deg=0, num_steps=5, flatten=True)
565 | np.testing.assert_array_equal(
566 | pl3.fit_transform(dgms),
567 | np.array(
568 | [
569 | 0.0,
570 | 1.0,
571 | 1.0,
572 | 1.0,
573 | 0.0,
574 | 0.0,
575 | 0.0,
576 | 1.0,
577 | 0.0,
578 | 0.0,
579 | ]
580 | ),
581 | )
582 |
--------------------------------------------------------------------------------
/test/test_persim.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pytest
3 |
4 | from persim import PersImage
5 |
6 |
7 | def test_landscape():
8 | bds = np.array([[1, 1], [1, 2]])
9 |
10 | ldsp = PersImage.to_landscape(bds)
11 |
12 | np.testing.assert_array_equal(ldsp, [[1, 0], [1, 1]])
13 |
14 |
15 | def test_integer_diagrams():
16 | """This test is inspired by gh issue #3 by gh user muszyna25.
17 |
18 | Integer diagrams return nan values.
19 |
20 | This does not work: dgm = [[0, 2], [0, 6], [0, 8]];
21 |
22 | This one works fine: dgm = [[0.0, 2.0], [0.0, 6.0], [0.0, 8.0]];
23 |
24 | """
25 |
26 | dgm = [[0, 2], [0, 6], [0, 8]]
27 | dgm2 = [[0.0, 2.0], [0.0, 6.0], [0.0, 8.0]]
28 | pim = PersImage()
29 | res = pim.transform(dgm2)
30 | res2 = pim.transform(dgm)
31 | np.testing.assert_array_equal(res, res2)
32 |
33 |
34 | class TestEmpty:
35 | def test_empty_diagram(self):
36 | dgm = np.zeros((0, 2))
37 | pim = PersImage(pixels=(10, 10))
38 | res = pim.transform(dgm)
39 | assert np.all(res == np.zeros((10, 10)))
40 |
41 | def test_empyt_diagram_list(self):
42 | dgm1 = [np.array([[2, 3]]), np.zeros((0, 2))]
43 | pim1 = PersImage(pixels=(10, 10))
44 | res1 = pim1.transform(dgm1)
45 | assert np.all(res1[1] == np.zeros((10, 10)))
46 |
47 | dgm2 = [np.zeros((0, 2)), np.array([[2, 3]])]
48 | pim2 = PersImage(pixels=(10, 10))
49 | res2 = pim2.transform(dgm2)
50 | assert np.all(res2[0] == np.zeros((10, 10)))
51 |
52 | dgm3 = [np.zeros((0, 2)), np.zeros((0, 2))]
53 | pim3 = PersImage(pixels=(10, 10))
54 | res3 = pim3.transform(dgm3)
55 | assert np.all(res3[0] == np.zeros((10, 10)))
56 | assert np.all(res3[1] == np.zeros((10, 10)))
57 |
58 |
59 | class TestWeighting:
60 | def test_zero_on_xaxis(self):
61 | pim = PersImage()
62 |
63 | wf = pim.weighting()
64 |
65 | assert wf([1, 0]) == 0
66 | assert wf([100, 0]) == 0
67 | assert wf([99, 1.4]) == 1.4
68 |
69 | def test_scales(self):
70 | pim = PersImage()
71 |
72 | wf = pim.weighting(np.array([[0, 1], [1, 2], [3, 4]]))
73 |
74 | assert wf([1, 0]) == 0
75 | assert wf([1, 4]) == 1
76 | assert wf([1, 2]) == 0.5
77 |
78 |
79 | class TestKernels:
80 | def test_kernel_mean(self):
81 | pim = PersImage()
82 | kf = pim.kernel(2)
83 |
84 | data = np.array([[0, 0]])
85 | assert kf(np.array([[0, 0]]), [0, 0]) >= kf(
86 | np.array([[1, 1]]), [0, 0]
87 | ), "decreasing away"
88 | assert kf(np.array([[0, 0]]), [1, 1]) == kf(
89 | np.array([[1, 1]]), [0, 0]
90 | ), "symmetric"
91 |
92 |
93 | class TestTransforms:
94 | def test_lists_of_lists(self):
95 | pim = PersImage(pixels=(3, 3))
96 | diagram = [[0, 1], [1, 1], [3, 5]]
97 | img = pim.transform(diagram)
98 |
99 | assert img.shape == (3, 3)
100 |
101 | def test_n_pixels(self):
102 | pim = PersImage(pixels=(3, 3))
103 | diagram = np.array([[0, 1], [1, 1], [3, 5]])
104 | img = pim.transform(diagram)
105 |
106 | assert img.shape == (3, 3)
107 |
108 | def test_multiple_diagrams(self):
109 | pim = PersImage(pixels=(3, 3))
110 |
111 | diagram1 = np.array([[0, 1], [1, 1], [3, 5]])
112 | diagram2 = np.array([[0, 1], [1, 1], [3, 6]])
113 | imgs = pim.transform([diagram1, diagram2])
114 |
115 | assert len(imgs) == 2
116 | assert imgs[0].shape == imgs[1].shape
117 |
--------------------------------------------------------------------------------
/test/test_persistence_imager.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pytest
3 |
4 | from persim import PersistenceImager, images_kernels, images_weights
5 |
6 | # ----------------------------------------
7 | # New PersistenceImager Tests
8 | # ----------------------------------------
9 |
10 |
11 | def test_empty_diagram():
12 | dgm = np.zeros((0, 2))
13 | persimgr = PersistenceImager(pixel_size=0.1)
14 | res = persimgr.transform(dgm)
15 | np.testing.assert_array_equal(res, np.zeros((10, 10)))
16 |
17 |
18 | def test_empty_diagram_list():
19 | dgms1 = [np.array([[2, 3]]), np.zeros((0, 2))]
20 | persimgr1 = PersistenceImager(pixel_size=0.1)
21 | res1 = persimgr1.transform(dgms1)
22 | np.testing.assert_array_equal(res1[1], np.zeros((10, 10)))
23 |
24 | dgms2 = [np.zeros((0, 2)), np.array([[2, 3]])]
25 | persimgr2 = PersistenceImager(pixel_size=0.1)
26 | res2 = persimgr2.transform(dgms2)
27 | np.testing.assert_array_equal(res2[0], np.zeros((10, 10)))
28 |
29 | dgms3 = [np.zeros((0, 2)), np.zeros((0, 2))]
30 | persimgr3 = PersistenceImager(pixel_size=0.1)
31 | res3 = persimgr3.transform(dgms3)
32 | np.testing.assert_array_equal(res3[0], np.zeros((10, 10)))
33 | np.testing.assert_array_equal(res3[1], np.zeros((10, 10)))
34 |
35 |
36 | def test_birth_range_setter():
37 | persimgr = PersistenceImager(birth_range=(0, 1), pers_range=(0, 2), pixel_size=1)
38 | persimgr.birth_range = (0.0, 4.5)
39 |
40 | np.testing.assert_equal(persimgr.pixel_size, 1)
41 | np.testing.assert_equal(persimgr._pixel_size, 1)
42 | np.testing.assert_equal(persimgr.pers_range, (0, 2))
43 | np.testing.assert_equal(persimgr._pers_range, (0, 2))
44 | np.testing.assert_equal(persimgr.birth_range, (-0.25, 4.75))
45 | np.testing.assert_equal(persimgr._birth_range, (-0.25, 4.75))
46 | np.testing.assert_equal(persimgr.width, 5)
47 | np.testing.assert_equal(persimgr._width, 5)
48 | np.testing.assert_equal(persimgr.height, 2)
49 | np.testing.assert_equal(persimgr._height, 2)
50 | np.testing.assert_equal(persimgr.resolution, (5, 2))
51 | np.testing.assert_equal(persimgr._resolution, (5, 2))
52 | np.testing.assert_array_equal(
53 | persimgr._bpnts, [-0.25, 0.75, 1.75, 2.75, 3.75, 4.75]
54 | )
55 | np.testing.assert_array_equal(persimgr._ppnts, [0.0, 1.0, 2.0])
56 |
57 |
58 | def test_pers_range_setter():
59 | persimgr = PersistenceImager(birth_range=(0, 1), pers_range=(0, 2), pixel_size=1)
60 | persimgr.pers_range = (-1.5, 4.5)
61 |
62 | np.testing.assert_equal(persimgr.pixel_size, 1)
63 | np.testing.assert_equal(persimgr._pixel_size, 1)
64 | np.testing.assert_equal(persimgr.pers_range, (-1.5, 4.5))
65 | np.testing.assert_equal(persimgr._pers_range, (-1.5, 4.5))
66 | np.testing.assert_equal(persimgr.birth_range, (0, 1))
67 | np.testing.assert_equal(persimgr._birth_range, (0, 1))
68 | np.testing.assert_equal(persimgr.width, 1)
69 | np.testing.assert_equal(persimgr._width, 1)
70 | np.testing.assert_equal(persimgr.height, 6)
71 | np.testing.assert_equal(persimgr._height, 6)
72 | np.testing.assert_equal(persimgr.resolution, (1, 6))
73 | np.testing.assert_equal(persimgr._resolution, (1, 6))
74 | np.testing.assert_array_equal(
75 | persimgr._ppnts, [-1.5, -0.5, 0.5, 1.5, 2.5, 3.5, 4.5]
76 | )
77 | np.testing.assert_array_equal(persimgr._bpnts, [0.0, 1.0])
78 |
79 |
80 | def test_pixel_size_setter():
81 | persimgr = PersistenceImager(birth_range=(0, 1), pers_range=(0, 2), pixel_size=1)
82 | persimgr.pixel_size = 0.75
83 |
84 | np.testing.assert_equal(persimgr.pixel_size, 0.75)
85 | np.testing.assert_equal(persimgr._pixel_size, 0.75)
86 | np.testing.assert_equal(persimgr.birth_range, (-0.25, 1.25))
87 | np.testing.assert_equal(persimgr._birth_range, (-0.25, 1.25))
88 | np.testing.assert_equal(persimgr.pers_range, (-0.125, 2.125))
89 | np.testing.assert_equal(persimgr._pers_range, (-0.125, 2.125))
90 | np.testing.assert_equal(persimgr.height, 2.25)
91 | np.testing.assert_equal(persimgr._height, 2.25)
92 | np.testing.assert_equal(persimgr.width, 1.5)
93 | np.testing.assert_equal(persimgr._width, 1.5)
94 | np.testing.assert_equal(persimgr.resolution, (2, 3))
95 | np.testing.assert_equal(persimgr._resolution, (2, 3))
96 | np.testing.assert_array_equal(persimgr._ppnts, [-0.125, 0.625, 1.375, 2.125])
97 | np.testing.assert_array_equal(persimgr._bpnts, [-0.25, 0.5, 1.25])
98 |
99 |
100 | def test_fit_diagram():
101 | persimgr = PersistenceImager(birth_range=(0, 1), pers_range=(0, 2), pixel_size=1)
102 | dgm = np.array([[1, 2], [4, 8], [-1, 5.25]])
103 | persimgr.fit(dgm)
104 |
105 | np.testing.assert_equal(persimgr.pixel_size, 1)
106 | np.testing.assert_equal(persimgr._pixel_size, 1)
107 | np.testing.assert_equal(persimgr.birth_range, (-1, 4))
108 | np.testing.assert_equal(persimgr._birth_range, (-1, 4))
109 | np.testing.assert_equal(persimgr.pers_range, (0.625, 6.625))
110 | np.testing.assert_equal(persimgr._pers_range, (0.625, 6.625))
111 | np.testing.assert_equal(persimgr.height, 6)
112 | np.testing.assert_equal(persimgr._height, 6)
113 | np.testing.assert_equal(persimgr.width, 5)
114 | np.testing.assert_equal(persimgr._width, 5)
115 | np.testing.assert_equal(persimgr.resolution, (5, 6))
116 | np.testing.assert_equal(persimgr._resolution, (5, 6))
117 | np.testing.assert_array_equal(
118 | persimgr._ppnts, [0.625, 1.625, 2.625, 3.625, 4.625, 5.625, 6.625]
119 | )
120 | np.testing.assert_array_equal(persimgr._bpnts, [-1.0, 0.0, 1.0, 2.0, 3.0, 4.0])
121 |
122 |
123 | def test_fit_diagram_list():
124 | persimgr = PersistenceImager(birth_range=(0, 1), pers_range=(0, 2), pixel_size=1)
125 | dgms = [np.array([[1, 2], [4, 8], [-1, 5.25]]), np.array([[1, 2], [2, 3], [3, 4]])]
126 | persimgr.fit(dgms)
127 |
128 | np.testing.assert_equal(persimgr.pixel_size, 1)
129 | np.testing.assert_equal(persimgr._pixel_size, 1)
130 | np.testing.assert_equal(persimgr.birth_range, (-1, 4))
131 | np.testing.assert_equal(persimgr._birth_range, (-1, 4))
132 | np.testing.assert_equal(persimgr.pers_range, (0.625, 6.625))
133 | np.testing.assert_equal(persimgr._pers_range, (0.625, 6.625))
134 | np.testing.assert_equal(persimgr.height, 6)
135 | np.testing.assert_equal(persimgr._height, 6)
136 | np.testing.assert_equal(persimgr.width, 5)
137 | np.testing.assert_equal(persimgr._width, 5)
138 | np.testing.assert_equal(persimgr.resolution, (5, 6))
139 | np.testing.assert_equal(persimgr._resolution, (5, 6))
140 | np.testing.assert_array_equal(
141 | persimgr._ppnts, [0.625, 1.625, 2.625, 3.625, 4.625, 5.625, 6.625]
142 | )
143 | np.testing.assert_array_equal(persimgr._bpnts, [-1.0, 0.0, 1.0, 2.0, 3.0, 4.0])
144 |
145 |
146 | def test_mixed_pairs():
147 | """This test is inspired by gh issue #3 by gh user muszyna25.
148 | Integer diagrams return nan values.
149 | This does not work: dgm = [[0, 2], [0, 6], [0, 8]];
150 | This one works fine: dgm = [[0.0, 2.0], [0.0, 6.0], [0.0, 8.0]];
151 | """
152 | persimgr = PersistenceImager()
153 |
154 | dgm = [[0, 2], [0, 6], [0, 8]]
155 | dgm2 = [[0.0, 2.0], [0.0, 6.0], [0.0, 8.0]]
156 | dgm3 = [[0.0, 2], [0.0, 6.0], [0, 8.0e0]]
157 |
158 | res = persimgr.transform(dgm)
159 | res2 = persimgr.transform(dgm2)
160 | res3 = persimgr.transform(dgm3)
161 |
162 | np.testing.assert_array_equal(res, res2)
163 | np.testing.assert_array_equal(res, res3)
164 |
165 |
166 | def test_parameter_exceptions():
167 | def construct_imager(param_dict):
168 | pimgr = PersistenceImager(**param_dict)
169 |
170 | np.testing.assert_raises(ValueError, construct_imager, {"birth_range": 0})
171 | np.testing.assert_raises(ValueError, construct_imager, {"birth_range": ("str", 0)})
172 | np.testing.assert_raises(ValueError, construct_imager, {"birth_range": (0, 0, 0)})
173 | np.testing.assert_raises(ValueError, construct_imager, {"pers_range": 0})
174 | np.testing.assert_raises(ValueError, construct_imager, {"pers_range": ("str", 0)})
175 | np.testing.assert_raises(ValueError, construct_imager, {"pers_range": (0, 0, 0)})
176 | np.testing.assert_raises(ValueError, construct_imager, {"pixel_size": "str"})
177 | np.testing.assert_raises(ValueError, construct_imager, {"weight": 0})
178 | np.testing.assert_raises(ValueError, construct_imager, {"weight": "invalid_weight"})
179 | np.testing.assert_raises(ValueError, construct_imager, {"kernel": 0})
180 | np.testing.assert_raises(ValueError, construct_imager, {"kernel": "invalid_kernel"})
181 | np.testing.assert_raises(ValueError, construct_imager, {"weight_params": 0})
182 | np.testing.assert_raises(ValueError, construct_imager, {"kernel_params": 0})
183 |
184 |
185 | class TestWeightFunctions:
186 | def test_zero_on_birthaxis(self):
187 | persimgr = PersistenceImager(
188 | weight=images_weights.linear_ramp,
189 | weight_params={"low": 0.0, "high": 1.0, "start": 0.0, "end": 1.0},
190 | )
191 | wf = persimgr.weight
192 | wf_params = persimgr.weight_params
193 | np.testing.assert_equal(wf(1, 0, **wf_params), 0)
194 |
195 | persimgr = PersistenceImager(
196 | weight=images_weights.persistence, weight_params={"n": 2}
197 | )
198 | wf = persimgr.weight
199 | wf_params = persimgr.weight_params
200 | np.testing.assert_equal(wf(1, 0, **wf_params), 0)
201 |
202 | def test_linear_ramp(self):
203 | persimgr = PersistenceImager(
204 | weight=images_weights.linear_ramp,
205 | weight_params={"low": 0.0, "high": 5.0, "start": 0.0, "end": 1.0},
206 | )
207 |
208 | wf = persimgr.weight
209 | wf_params = persimgr.weight_params
210 |
211 | np.testing.assert_equal(wf(1, 0, **wf_params), 0)
212 | np.testing.assert_equal(wf(1, 1 / 5, **wf_params), 1)
213 | np.testing.assert_equal(wf(1, 1, **wf_params), 5)
214 | np.testing.assert_equal(wf(1, 2, **wf_params), 5)
215 |
216 | persimgr.weight_params = {"low": 0.0, "high": 5.0, "start": 0.0, "end": 5.0}
217 | wf_params = persimgr.weight_params
218 |
219 | np.testing.assert_equal(wf(1, 0, **wf_params), 0)
220 | np.testing.assert_equal(wf(1, 1 / 5, **wf_params), 1 / 5)
221 | np.testing.assert_equal(wf(1, 1, **wf_params), 1)
222 | np.testing.assert_equal(wf(1, 5, **wf_params), 5)
223 |
224 | persimgr.weight_params = {"low": 0.0, "high": 5.0, "start": 1.0, "end": 5.0}
225 | wf_params = persimgr.weight_params
226 |
227 | np.testing.assert_equal(wf(1, 0, **wf_params), 0)
228 | np.testing.assert_equal(wf(1, 1, **wf_params), 0)
229 | np.testing.assert_equal(wf(1, 5, **wf_params), 5)
230 |
231 | persimgr.weight_params = {"low": 1.0, "high": 5.0, "start": 1.0, "end": 5.0}
232 | wf_params = persimgr.weight_params
233 | np.testing.assert_equal(wf(1, 0, **wf_params), 1)
234 | np.testing.assert_equal(wf(1, 1, **wf_params), 1)
235 | np.testing.assert_equal(wf(1, 2, **wf_params), 2)
236 |
237 | def test_persistence(self):
238 | persimgr = PersistenceImager(
239 | weight=images_weights.persistence, weight_params={"n": 1.0}
240 | )
241 |
242 | wf = persimgr.weight
243 | wf_params = persimgr.weight_params
244 |
245 | x = np.random.rand()
246 | np.testing.assert_equal(wf(1, x, **wf_params), x)
247 |
248 | persimgr.weight_params = {"n": 1.5}
249 | wf_params = persimgr.weight_params
250 |
251 | np.testing.assert_equal(wf(1, x, **wf_params), x**1.5)
252 |
253 |
254 | class TestKernelFunctions:
255 | def test_gaussian(self):
256 | kernel = images_kernels.gaussian
257 | kernel_params = {"mu": [1, 1], "sigma": np.array([[1, 0], [0, 1]])}
258 | np.testing.assert_almost_equal(
259 | kernel(np.array([1]), np.array([1]), **kernel_params), 1 / 4, 8
260 | )
261 |
262 | kernel = images_kernels.bvn_cdf
263 | kernel_params = {
264 | "mu_x": 1.0,
265 | "mu_y": 1.0,
266 | "sigma_xx": 1.0,
267 | "sigma_yy": 1.0,
268 | "sigma_xy": 0.0,
269 | }
270 | np.testing.assert_almost_equal(
271 | kernel(np.array([1]), np.array([1]), **kernel_params), 1 / 4, 8
272 | )
273 |
274 | kernel_params = {
275 | "mu_x": 1.0,
276 | "mu_y": 1.0,
277 | "sigma_xx": 1.0,
278 | "sigma_yy": 1.0,
279 | "sigma_xy": 0.5,
280 | }
281 | np.testing.assert_almost_equal(
282 | kernel(np.array([1]), np.array([1]), **kernel_params), 1 / 3, 8
283 | )
284 |
285 | kernel_params = {
286 | "mu_x": 1.0,
287 | "mu_y": 1.0,
288 | "sigma_xx": 1.0,
289 | "sigma_yy": 2.0,
290 | "sigma_xy": 0.0,
291 | }
292 | np.testing.assert_almost_equal(
293 | kernel(np.array([1]), np.array([0]), **kernel_params), 0.11987503, 8
294 | )
295 |
296 | kernel_params = {
297 | "mu_x": 1.0,
298 | "mu_y": 1.0,
299 | "sigma_xx": 1.0,
300 | "sigma_yy": 2.0,
301 | "sigma_xy": 1.0,
302 | }
303 | np.testing.assert_equal(
304 | kernel(np.array([1]), np.array([1]), **kernel_params), 0.375
305 | )
306 |
307 | def test_norm_cdf(self):
308 | np.testing.assert_equal(images_kernels.norm_cdf(0), 0.5)
309 | np.testing.assert_almost_equal(
310 | images_kernels.norm_cdf(1), 0.8413447460685429, 8
311 | )
312 |
313 | def test_uniform(self):
314 | kernel = images_kernels.uniform
315 | kernel_params = {"width": 3, "height": 1}
316 |
317 | np.testing.assert_equal(
318 | kernel(np.array([-1]), np.array([-1]), mu=(0, 0), **kernel_params), 0
319 | )
320 | np.testing.assert_equal(
321 | kernel(np.array([3]), np.array([1]), mu=(0, 0), **kernel_params), 1
322 | )
323 | np.testing.assert_equal(
324 | kernel(np.array([5]), np.array([5]), mu=(0, 0), **kernel_params), 1
325 | )
326 |
327 | def test_sigma(self):
328 | kernel = images_kernels.gaussian
329 | kernel_params1 = {"sigma": np.array([[1, 0], [0, 1]])}
330 | kernel_params2 = {"sigma": [[1, 0], [0, 1]]}
331 | np.testing.assert_equal(
332 | kernel(np.array([1]), np.array([1]), **kernel_params1),
333 | kernel(np.array([1]), np.array([1]), **kernel_params2),
334 | )
335 |
336 |
337 | class TestTransformOutput:
338 | def test_lists_of_lists(self):
339 | persimgr = PersistenceImager(
340 | birth_range=(0, 3), pers_range=(0, 3), pixel_size=1
341 | )
342 | dgm = [[0, 1], [1, 1], [3, 5]]
343 | img = persimgr.transform(dgm)
344 |
345 | np.testing.assert_equal(img.shape, (3, 3))
346 |
347 | def test_n_pixels(self):
348 | persimgr = PersistenceImager(
349 | birth_range=(0, 5), pers_range=(0, 3), pixel_size=1
350 | )
351 | dgm = np.array([[0, 1], [1, 1], [3, 5]])
352 | img = persimgr.transform(dgm)
353 |
354 | np.testing.assert_equal(img.shape, (5, 3))
355 |
356 | img = persimgr.fit_transform(dgm)
357 | np.testing.assert_equal(img.shape, (3, 2))
358 |
359 | def test_multiple_diagrams(self):
360 | persimgr = PersistenceImager(
361 | birth_range=(0, 5), pers_range=(0, 3), pixel_size=1
362 | )
363 |
364 | dgm1 = np.array([[0, 1], [1, 1], [3, 5]])
365 | dgm2 = np.array([[0, 1], [1, 1], [3, 6], [1, 1]])
366 | imgs = persimgr.transform([dgm1, dgm2])
367 |
368 | np.testing.assert_equal(len(imgs), 2)
369 | np.testing.assert_equal(imgs[0].shape, imgs[1].shape)
370 |
371 | imgs = persimgr.fit_transform([dgm1, dgm2])
372 | np.testing.assert_equal(len(imgs), 2)
373 | np.testing.assert_equal(imgs[0].shape, imgs[1].shape)
374 | np.testing.assert_equal(imgs[0].shape, (3, 3))
375 |
--------------------------------------------------------------------------------
/test/test_persistent_entropy.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 |
3 | import pytest
4 |
5 | from persim import persistent_entropy as pe
6 |
7 |
8 | class TestPersistentEntropy:
9 | def test_one_diagram(self):
10 | dgm = np.array([[0, 1], [0, 3], [2, 4]])
11 | p = pe.persistent_entropy(dgm)
12 |
13 | # An upper bound of persistent entropy is the logarithm of the number of bars.
14 | assert p < np.log(len(dgm))
15 |
16 | def test_diagrams(self):
17 | dgms = [
18 | np.array([[0, 1], [0, 3], [2, 4]]),
19 | np.array([[2, 5], [3, 8]]),
20 | np.array([[0, 10]]),
21 | ]
22 | p = pe.persistent_entropy(dgms)
23 | # An upper bound of persistent entropy is the logarithm of the number of bars.
24 | assert all(p < np.log(3))
25 |
26 | def test_diagrams_inf(self):
27 | dgms = [
28 | np.array([[0, 1], [0, 3], [2, 4]]),
29 | np.array([[2, 5], [3, 8]]),
30 | np.array([[0, np.inf]]),
31 | ]
32 | p = pe.persistent_entropy(dgms, keep_inf=True, val_inf=10)
33 | # An upper bound of persistent entropy is the logarithm of the number of bars.
34 | assert all(p < np.log(3))
35 |
36 | def test_diagram_one_bar(self):
37 | dgm = np.array([[-1, 2]])
38 | p = pe.persistent_entropy(dgm)
39 | assert all(p == 0)
40 |
--------------------------------------------------------------------------------
/test/test_visuals.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | import numpy as np
3 |
4 | import matplotlib.pyplot as plt
5 |
6 | import persim
7 | from persim import plot_diagrams
8 |
9 | from persim.landscapes import (
10 | plot_landscape,
11 | plot_landscape_simple,
12 | PersLandscapeExact,
13 | PersLandscapeApprox,
14 | )
15 |
16 | """
17 |
18 | Testing visualization is a little more difficult, but still necessary. An example of how to get started:
19 | > https://stackoverflow.com/questions/27948126/how-can-i-write-unit-tests-against-code-that-uses-matplotlib
20 |
21 | ```
22 | def test_plot_square2():
23 | f, ax = plt.subplots()
24 | x, y = [0, 1, 2], [0, 1, 2]
25 | plot_square(x, y)
26 | x_plot, y_plot = ax.lines[0].get_xydata().T
27 | np.testing.assert_array_equal(y_plot, np.square(y))
28 |
29 | ```
30 |
31 |
32 | Notes
33 | -----
34 |
35 | ax.get_children() gives all the pieces in the plot, very useful
36 | Scatter data is of type `PathCollection` and will be a child of ax.
37 |
38 | """
39 |
40 |
41 | class TestPlotting:
42 | def test_single(self):
43 | """Most just test this doesn't crash"""
44 | diagram = np.array([[0, 1], [1, 1], [2, 4], [3, 5]])
45 |
46 | f, ax = plt.subplots()
47 | plot_diagrams(diagram, show=False)
48 |
49 | x_plot, y_plot = ax.lines[0].get_xydata().T
50 |
51 | assert x_plot[0] <= np.min(diagram)
52 | assert x_plot[1] >= np.max(diagram)
53 |
54 | # get PathCollection
55 | pathcols = [
56 | child
57 | for child in ax.get_children()
58 | if child.__class__.__name__ == "PathCollection"
59 | ]
60 | assert len(pathcols) == 1
61 |
62 | def test_multiple(self):
63 | diagrams = [
64 | np.array([[0, 1], [1, 1], [2, 4], [3, 5]]),
65 | np.array([[0.5, 3], [2, 4], [4, 5], [10, 15]]),
66 | ]
67 |
68 | f, ax = plt.subplots()
69 | plot_diagrams(diagrams, show=False)
70 |
71 | pathcols = [
72 | child
73 | for child in ax.get_children()
74 | if child.__class__.__name__ == "PathCollection"
75 | ]
76 |
77 | assert len(pathcols) == 2
78 | np.testing.assert_array_equal(pathcols[0].get_offsets(), diagrams[0])
79 | np.testing.assert_array_equal(pathcols[1].get_offsets(), diagrams[1])
80 |
81 | def test_plot_only(self):
82 | diagrams = [
83 | np.array([[0, 1], [1, 1], [2, 4], [3, 5]]),
84 | np.array([[0.5, 3], [2, 4], [4, 5], [10, 15]]),
85 | ]
86 | f, ax = plt.subplots()
87 | plot_diagrams(diagrams, legend=False, show=False, plot_only=[1])
88 |
89 | def test_legend_true(self):
90 | diagrams = [
91 | np.array([[0, 1], [1, 1], [2, 4], [3, 5]]),
92 | np.array([[0.5, 3], [2, 4], [4, 5], [10, 15]]),
93 | ]
94 |
95 | f, ax = plt.subplots()
96 | plot_diagrams(diagrams, legend=True, show=False)
97 | legend = [
98 | child for child in ax.get_children() if child.__class__.__name__ == "Legend"
99 | ]
100 |
101 | assert len(legend) == 1
102 |
103 | def test_legend_false(self):
104 | diagrams = [
105 | np.array([[0, 1], [1, 1], [2, 4], [3, 5]]),
106 | np.array([[0.5, 3], [2, 4], [4, 5], [10, 15]]),
107 | ]
108 |
109 | f, ax = plt.subplots()
110 | plot_diagrams(diagrams, legend=False, show=False)
111 | legend = [
112 | child for child in ax.get_children() if child.__class__.__name__ == "Legend"
113 | ]
114 |
115 | assert len(legend) == 0
116 |
117 | def test_set_title(self):
118 | diagrams = [
119 | np.array([[0, 1], [1, 1], [2, 4], [3, 5]]),
120 | np.array([[0.5, 3], [2, 4], [4, 5], [10, 15]]),
121 | ]
122 |
123 | f, ax = plt.subplots()
124 | plot_diagrams(diagrams, title="my title", show=False)
125 | assert ax.get_title() == "my title"
126 |
127 | f, ax = plt.subplots()
128 | plot_diagrams(diagrams, show=False)
129 | assert ax.get_title() == ""
130 |
131 | def test_default_square(self):
132 | diagrams = [
133 | np.array([[0, 1], [1, 1], [2, 4], [3, 5]]),
134 | np.array([[0.5, 3], [2, 4], [4, 5], [10, 15]]),
135 | ]
136 |
137 | f, ax = plt.subplots()
138 | plot_diagrams(diagrams, show=False)
139 | diagonal = ax.lines[0].get_xydata()
140 |
141 | assert diagonal[0, 0] == diagonal[0, 1]
142 | assert diagonal[1, 0] == diagonal[1, 1]
143 |
144 | def test_default_label(self):
145 | diagrams = [
146 | np.array([[0, 1], [1, 1], [2, 4], [3, 5]]),
147 | np.array([[0.5, 3], [2, 4], [4, 5], [10, 15]]),
148 | ]
149 |
150 | f, ax = plt.subplots()
151 | plot_diagrams(diagrams, show=False)
152 |
153 | assert ax.get_ylabel() == "Death"
154 | assert ax.get_xlabel() == "Birth"
155 |
156 | def test_lifetime(self):
157 | diagrams = [
158 | np.array([[0, 1], [1, 1], [2, 4], [3, 5]]),
159 | np.array([[0.5, 3], [2, 4], [4, 5], [10, 15]]),
160 | ]
161 |
162 | f, ax = plt.subplots()
163 | plot_diagrams(diagrams, lifetime=True, show=False)
164 |
165 | assert ax.get_ylabel() == "Lifetime"
166 | assert ax.get_xlabel() == "Birth"
167 |
168 | line = ax.get_lines()[0]
169 | np.testing.assert_array_equal(line.get_ydata(), [0, 0])
170 |
171 | def test_lifetime_removes_birth(self):
172 | diagrams = [
173 | np.array([[0, 1], [1, 1], [2, 4], [3, 5]]),
174 | np.array([[0.5, 3], [2, 4], [4, 5], [10, 15]]),
175 | ]
176 |
177 | f, ax = plt.subplots()
178 | plot_diagrams(diagrams, lifetime=True, show=False)
179 |
180 | pathcols = [
181 | child
182 | for child in ax.get_children()
183 | if child.__class__.__name__ == "PathCollection"
184 | ]
185 |
186 | modded1 = diagrams[0]
187 | modded1[:, 1] = diagrams[0][:, 1] - diagrams[0][:, 0]
188 | modded2 = diagrams[1]
189 | modded2[:, 1] = diagrams[1][:, 1] - diagrams[1][:, 0]
190 | assert len(pathcols) == 2
191 | np.testing.assert_array_equal(pathcols[0].get_offsets(), modded1)
192 | np.testing.assert_array_equal(pathcols[1].get_offsets(), modded2)
193 |
194 | def test_infty(self):
195 | diagrams = [
196 | np.array([[0, np.inf], [1, 1], [2, 4], [3, 5]]),
197 | np.array([[0.5, 3], [2, 4], [4, 5], [10, 15]]),
198 | ]
199 |
200 | f, ax = plt.subplots()
201 | plot_diagrams(diagrams, legend=True, show=False)
202 |
203 | # Right now just make sure nothing breaks
204 |
205 |
206 | class TestMatching:
207 | def test_bottleneck_matching(self):
208 | dgm1 = np.array([[0.1, 0.2], [0.2, 0.4]])
209 | dgm2 = np.array([[0.1, 0.2], [0.3, 0.45]])
210 |
211 | d, matching = persim.bottleneck(dgm1, dgm2, matching=True)
212 | persim.bottleneck_matching(dgm1, dgm2, matching)
213 |
214 | def test_plot_labels(self):
215 | dgm1 = np.array([[0.1, 0.2], [0.2, 0.4]])
216 | dgm2 = np.array([[0.1, 0.2], [0.3, 0.45]])
217 |
218 | d, matching = persim.bottleneck(dgm1, dgm2, matching=True)
219 | persim.bottleneck_matching(dgm1, dgm2, matching, labels=["X", "Y"])
220 |
221 |
222 | class TestLandscapePlots:
223 | diagrams = [
224 | np.array([[0, 1], [1, 1], [2, 4], [3, 5]]),
225 | np.array([[0.5, 3], [2, 4], [4, 5], [10, 15]]),
226 | ]
227 |
228 | # Test to ensure plots are created
229 |
230 | def test_simple_plots(self):
231 | plot_landscape_simple(PersLandscapeApprox(dgms=self.diagrams))
232 | plot_landscape_simple(PersLandscapeExact(dgms=self.diagrams, hom_deg=1))
233 |
234 | def test_plots(self):
235 | plot_landscape(PersLandscapeExact(dgms=self.diagrams))
236 | plot_landscape(PersLandscapeApprox(dgms=self.diagrams, hom_deg=1))
237 |
--------------------------------------------------------------------------------