├── .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 | [![PyPI version](https://badge.fury.io/py/persim.svg)](https://badge.fury.io/py/persim) 2 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/persim) 3 | [![Conda Version](https://img.shields.io/conda/vn/conda-forge/persim.svg)](https://anaconda.org/conda-forge/persim) 4 | [![Conda Downloads](https://img.shields.io/conda/dn/conda-forge/persim.svg)](https://anaconda.org/conda-forge/persim) 5 | [![codecov](https://codecov.io/gh/scikit-tda/persim/branch/master/graph/badge.svg)](https://codecov.io/gh/scikit-tda/persim) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 | --------------------------------------------------------------------------------