├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── example.py ├── examples ├── many_frequencies.py └── many_frequencies.svg ├── matrixfuncs ├── __init__.py ├── _farray.py ├── _mspace.py └── utils │ ├── __init__.py │ ├── _fcoeffs.py │ └── _misc.py ├── pyproject.toml ├── sinFromMFunc.svg ├── tests ├── __init__.py └── test_fcoeff.py ├── tox.ini └── typst ├── .gitignore ├── eq.typ ├── github-logo.svg ├── matrix-functions-docu.typ └── template.typ /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: "[Bug] Title" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Version** 11 | Package Version: 12 | 13 | **Describe the bug** 14 | A clear and concise description of what the bug is. 15 | 16 | **To Reproduce** 17 | Code to reproduce the behavior: 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: "[Suggestion] Title" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Suggestion** 11 | Detailed explanation of what feature you would like to have added. 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the workflow will run 4 | on: 5 | push: 6 | tags: [ "v*" ] 7 | #branches: [ "main" ] 8 | #pull_request: 9 | # branches: [ "main" ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | test-matrix: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | # python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14-dev' ] 21 | python-version: [ '3.10', '3.11', '3.12', '3.13' ] 22 | name: Test on ${{ matrix.python-version }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Setup python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | cache: 'pip' 30 | cache-dependency-path: 'pyproject.toml' 31 | - name: Test python package 32 | run: | 33 | pip install --upgrade tox pytest 34 | TESTVERSION=${{ matrix.python-version }} 35 | TESTVERSION=$(python -c "s='$TESTVERSION'; print('py3'+s[2:].split('-')[0])") 36 | tox -e $TESTVERSION 37 | 38 | # act --pull=false -P ubuntu-latest=catthehacker/ubuntu:act-latest --job="build" 39 | build: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - name: Setup python 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: '3.10' 48 | cache: 'pip' 49 | cache-dependency-path: 'pyproject.toml' 50 | 51 | - name: Build python package 52 | run: | 53 | pip install --upgrade build 54 | python -m build 55 | PKGVERSION=$(python -c "$(grep version\ =\ matrixfuncs/_version.py); print(version)") 56 | echo "PKGVERSION=v$PKGVERSION" >> $GITHUB_ENV 57 | - name: "Report built version" 58 | run: echo "Built version ${{ env.PKGVERSION }}" 59 | - name: Upload package 60 | uses: actions/upload-artifact@v4 61 | with: 62 | name: python-package 63 | path: dist/* 64 | 65 | build-docs: 66 | runs-on: ubuntu-latest 67 | steps: 68 | - uses: actions/checkout@v4 69 | - name: Compile Typst 70 | uses: typst-community/setup-typst@v4 71 | - run: | 72 | cd typst 73 | typst compile --root .. matrix-functions-docu.typ 74 | cd 75 | - name: Upload documentation 76 | uses: actions/upload-artifact@v4 77 | with: 78 | name: python-package-documentation 79 | path: typst/matrix-functions-docu.pdf 80 | 81 | release: 82 | runs-on: ubuntu-latest 83 | if: github.triggering_actor == 'nextdorf' 84 | permissions: 85 | contents: write 86 | needs: [build, build-docs] 87 | steps: 88 | - name: Download 89 | uses: actions/download-artifact@v4 90 | with: 91 | path: out 92 | merge-multiple: true 93 | - name: Release 94 | uses: softprops/action-gh-release@v2 95 | with: 96 | files: out/* 97 | draft: false 98 | prerelease: false 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # custom 132 | local/ 133 | matrixfuncs/_version.py -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Matrix Functions 2 | 3 | A Python package for **numerically computing matrix functions**, with a particular focus on **difference equations** and **analytic continuation of matrices**. 4 | 5 | ## Features 6 | 7 | - **General Matrix Functions** – Computes **any function** of a matrix. 8 | - **Numerical Computation** – Focuses on **floating-point approximations** rather than symbolic computation. 9 | - **Difference Equations** – Provides tools for solving **recurrence relations** using matrix function techniques. 10 | - **Analytic Continuation** – Enables non-integer shifts in difference equations using analytic continuation. 11 | - **Mathematical Documentation** – Each release includes a **PDF document** explaining the mathematical foundations. 12 | - **Pure Python** – No compilation required, making installation simple and cross-platform. 13 | 14 | ## Installation 15 | 16 | The package is available on PyPI and can be installed with: 17 | 18 | ```bash 19 | pip install matrixfuncs 20 | ``` 21 | 22 | ### 23 | 24 | ## Usage 25 | 26 | The Cayley-Hamilton theorem states that every square matrix is a root of its own characteristic polynomial. From this it follows that $A^n$ is a linear combination of $A^0,\ A,\ A^2,\ \dots,\ A^{n-1}$ and therefore every polynomial in A is such a linear combination: 27 | 28 | $$ 29 | A^m = \sum_{k=0}^{n-1} \alpha_{mk} A^k 30 | $$ 31 | 32 | It turns out that $\alpha_{mk}$ only depends on the eigenvalues $\lambda_1,\ \dots,\ \lambda_n$. Hence, every matrix function can be expressed in in such a way if the function is analytic in the eigenvalues: 33 | 34 | $$ 35 | f(A) = \varphi_{ij}^{(k)} A^i f^{(k)}(\lambda_j) 36 | $$ 37 | 38 | ### Computing the Sine Function Using Matrix Recurrence Relations 39 | 40 | The sine function satisfies the recurrence relation: 41 | 42 | $$ 43 | sin(x + a) = 2\cos(a) sin(x) - sin(x - a) 44 | $$ 45 | 46 | This allows us to express the sine of a shifted angle using matrix multiplication: 47 | 48 | $$ 49 | \begin{bmatrix} sin(x + a) \\ sin(x) \end{bmatrix} = 50 | \begin{bmatrix} 2\cos(a) & -1 \\ 1 & 0 \end{bmatrix} 51 | \begin{bmatrix} sin(x) \\ sin(x - a) \end{bmatrix} 52 | $$ 53 | 54 | 55 | 56 | Using **matrix exponentiation**, we can compute \(sin(x + na)\) efficiently. 57 | 58 | #### Python Implementation 59 | 60 | ```python 61 | import matrixfuncs as mf 62 | import numpy as np 63 | import matplotlib.pyplot as plt 64 | 65 | # Define a transformation matrix based on a unit parameter 66 | unit = 0.2 67 | M = np.array([[2 * np.cos(unit), -1], [1, 0]]) 68 | 69 | # Generate time steps for evaluation 70 | ts = np.linspace(0, 2 * np.pi, 1000) 71 | 72 | # Define the function to be applied to the matrix 73 | f = lambda x: x ** (ts / unit) 74 | 75 | # Convert the matrix into a functional array representation 76 | arr = mf.FArray.from_matrix(M) 77 | 78 | # Define input vectors for left-hand side and right-hand side multiplications 79 | v0_lhs = np.array([1, 0]) # Left-hand side vector 80 | v0_rhs = np.sin([0, -unit]) # Right-hand side vector 81 | 82 | # Compute the function applied to the matrix and evaluate it 83 | vals = v0_lhs @ arr @ v0_rhs # Compute matrix function application 84 | f_M = vals(f) # Evaluate the function over the time steps 85 | 86 | 87 | # Plot the computed function values 88 | fig = plt.figure(figsize=(8, 5)) 89 | plt.plot(ts, f_M, 'b-', label='Continuation of sampled function') 90 | plt.plot(unit*np.arange(2*np.pi/unit), np.sin(unit*np.arange(2*np.pi/unit)), 'ro', label=f'Sampled at step size {unit}') 91 | plt.xlabel('Time ($t$)') 92 | plt.ylabel('$\\sin(t)$') 93 | plt.title(f'Smooth Continuaton of the Sine function with Step Size {unit}') 94 | plt.legend() 95 | plt.grid(True) 96 | plt.show(fig) 97 | fig.savefig('sinFromMFunc.png', dpi=200) 98 | ``` 99 | ![Output of plt.show(fig)](https://raw.githubusercontent.com/nextdorf/matrix-functions/main/sinFromMFunc.svg?raw=true) 100 | 101 | 102 | ## Documentation 103 | 104 | - A **PDF document** explaining the mathematical background is included with each release. 105 | - The package includes **in-code documentation** but no separate programming guide at this time. 106 | 107 | ## Benchmarks & Performance 108 | 109 | Currently, the package is focused on **numerical accuracy** rather than high-performance computing. Future updates will prioritize: 110 | 111 | - **Stability improvements** for larger matrices. 112 | - **Optimized numerical methods** to reduce floating-point errors. 113 | - **Support for large-scale computations**. 114 | 115 | ## Future Plans 116 | 117 | This package is **not yet feature-complete**. Future improvements will focus on: 118 | 119 | - Enhancing **numerical stability**. 120 | - Expanding support for **larger matrices**. 121 | - General optimizations and performance improvements. 122 | 123 | ## Contributing 124 | 125 | Contributions are welcome! If you'd like to help improve this package: 126 | 127 | 1. Fork the repository. 128 | 2. Create a new branch for your changes. 129 | 3. Submit a pull request with a clear description of your updates. 130 | 131 | Areas where contributions could be particularly helpful: 132 | 133 | - **Performance optimization**. 134 | - **Expanding function support**. 135 | - **Adding better documentation**. 136 | 137 | For feature suggestions or bug reports, please open an issue on GitHub. 138 | 139 | ## License 140 | 141 | This project is licensed under the **LGPL-3 License**. See the [LICENSE](LICENSE) file for details. -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import matrixfuncs as mf 2 | import numpy as np 3 | import matplotlib.pyplot as plt 4 | 5 | # Define a transformation matrix based on a unit parameter 6 | unit = 0.2 7 | M = np.array([[2 * np.cos(unit), -1], [1, 0]]) 8 | 9 | # Generate time steps for evaluation 10 | ts = np.linspace(0, 2 * np.pi, 1000) 11 | 12 | # Define the function to be applied to the matrix 13 | f = lambda x: x ** (ts / unit) 14 | 15 | # Convert the matrix into a functional array representation 16 | arr = mf.FArray.from_matrix(M) 17 | 18 | # Define input vectors for left-hand side and right-hand side multiplications 19 | v0_lhs = np.array([1, 0]) # Left-hand side vector 20 | v0_rhs = np.sin([0, -unit]) # Right-hand side vector 21 | 22 | # Compute the function applied to the matrix and evaluate it 23 | vals = v0_lhs @ arr @ v0_rhs # Compute matrix function application 24 | f_M = vals(f) # Evaluate the function over the time steps 25 | 26 | 27 | # Plot the computed function values 28 | fig = plt.figure(figsize=(8, 5)) 29 | plt.plot(ts, f_M, 'b-', label='Continuation of sampled function') 30 | plt.plot(unit*np.arange(2*np.pi/unit), np.sin(unit*np.arange(2*np.pi/unit)), 'ro', label=f'Sampled at step size {unit}') 31 | plt.xlabel('Time ($t$)') 32 | plt.ylabel('$\\sin(t)$') 33 | plt.title(f'Smooth Continuaton of the Sine function with Step Size {unit}') 34 | plt.legend() 35 | plt.grid(True) 36 | plt.show(fig) 37 | fig.savefig('sinFromMFunc.svg', dpi=200) 38 | -------------------------------------------------------------------------------- /examples/many_frequencies.py: -------------------------------------------------------------------------------- 1 | import matrixfuncs as mf 2 | import numpy as np 3 | import matplotlib.pyplot as plt 4 | 5 | 6 | def est_M(sampled_vec): 7 | '''Estimate transition matrix M from sampled function values. 8 | 9 | Parameters 10 | ---------- 11 | sampled_vec : ndarray of shape (dim, N) 12 | A matrix where each column represents function values over a time window. 13 | 14 | Returns 15 | ------- 16 | M : ndarray of shape (dim, dim) 17 | The estimated transition matrix that best maps sampled_vec[:, :-1] to sampled_vec[:, 1:]. 18 | ''' 19 | dim = sampled_vec.shape[0] 20 | r = range(sampled_vec.shape[1] - dim) 21 | Ms = [np.linalg.solve(sampled_vec.T[k:k+dim], sampled_vec.T[k+1:k+1+dim]).T for k in r] 22 | M = np.mean(Ms, 0) 23 | return M 24 | 25 | 26 | # Number of frequency components 27 | num = 4 28 | 29 | # Generate random function coefficients and frequencies 30 | f_coeffs = np.random.randn(num) 31 | f_freqs = 2*np.pi/(.1 + .9*np.random.rand(num)) 32 | 33 | # Define the reference function as a weighted sum of sines and cosines 34 | f_ref = lambda t: f_coeffs @ np.concat([np.sin(f_freqs[:num//2]*t), np.cos(f_freqs[num//2:]*t)]) 35 | 36 | # Sample function values over a finite time range 37 | t_sampled = np.linspace(0, 1, 5*num+1) 38 | f_sampled = np.array(list(map(f_ref, t_sampled))) 39 | 40 | # Construct sampled windows for transition matrix estimation 41 | f_sampled_window = np.array([f_sampled[np.arange(i, i+2*num)] for i in range(len(t_sampled) - 2*num)]).T 42 | M = est_M(f_sampled_window) 43 | assert np.allclose(M @ f_sampled_window[:, :-1], f_sampled_window[:, 1:]) 44 | 45 | 46 | # Define evaluation times for function continuation 47 | ts = np.linspace(-1, 2, 4000) 48 | dt_sample = t_sampled[1] - t_sampled[0] 49 | assert np.allclose(t_sampled[1:], t_sampled[:-1] + dt_sample) 50 | 51 | # Define the function to be applied to the matrix 52 | f = lambda x: x ** (ts / dt_sample) 53 | 54 | # Convert the transition matrix into a functional array representation 55 | arr = mf.FArray.from_matrix(M) 56 | 57 | # Define initial left-hand side and right-hand side vectors 58 | v0_lhs = (np.arange(f_sampled_window.shape[0])==0)*1. 59 | v0_rhs = f_sampled_window[:, 0] 60 | 61 | # Compute the function applied to the matrix and evaluate it 62 | vals = v0_lhs @ arr @ v0_rhs # Compute matrix function application 63 | f_M = vals(f) # Evaluate the function over the time steps 64 | 65 | 66 | # Plot the computed function values 67 | fig, (ax, bx, cx) = plt.subplots(3, figsize=(8, 9), gridspec_kw={'height_ratios': [2, 1, 1]}) 68 | fig.suptitle(f'Smooth Continuation of Random Signal with DOF={2*num}') 69 | fig.tight_layout(pad=1.) 70 | 71 | # Plot the reconstructed function 72 | ax.plot(ts, f_M, 'b-', label='Continuation of sampled function') 73 | ax.plot(t_sampled, f_sampled, 'ro', label=f'Sampled at step size {dt_sample}') 74 | ax.fill_betweenx((-1e8, 1e8), 0, 1, color='r', alpha=.2, label=f'Sampled region') 75 | ax.set_xlim(-1, 2) 76 | ax.set_ylim(np.array([np.min(f_M), np.max(f_M)]) * 1.05) 77 | ax.set_ylabel('Signal') 78 | ax.legend() 79 | ax.grid(True) 80 | 81 | # Compute error between estimated function and reference function 82 | err1 = f_M - np.array([f_ref(t) for t in ts]) 83 | 84 | # Plot the error 85 | bx.plot(ts, err1, 'b-', label='Error') 86 | bx.plot(ts, np.zeros_like(ts), 'g-') 87 | bx.fill_betweenx((-1e8, 1e8), 0, 1, color='r', alpha=.2) 88 | bx.sharex(ax) 89 | bx.set_ylim(np.array([-1.05, 1.05]) * np.max(abs(err1))) 90 | bx.set_ylabel('Signal Error') 91 | bx.legend() 92 | bx.grid(True) 93 | 94 | # Repeat on bigger scale 95 | ts = np.linspace(-50, 50, 4000) 96 | f = lambda x: x ** (ts / dt_sample) 97 | f_M = vals(f) 98 | err2 = f_M - np.array([f_ref(t) for t in ts]) 99 | 100 | cx.plot(ts, err2, 'b-', label='Error') 101 | cx.plot(ts, np.zeros_like(ts), 'g-') 102 | cx.fill_betweenx((-1e8, 1e8), 0, 1, color='r', alpha=.2) 103 | cx.set_xlim((min(ts), max(ts))) 104 | cx.set_ylim(np.array([-1.05, 1.05]) * np.max(abs(err2))) 105 | cx.set_xlabel('Time ($t$)') 106 | cx.set_ylabel('Signal Error') 107 | cx.legend() 108 | cx.grid(True) 109 | 110 | 111 | # Display and save the plot 112 | plt.show(fig) 113 | fig.savefig('many_frequencies.svg', dpi=200, bbox_inches='tight') 114 | -------------------------------------------------------------------------------- /matrixfuncs/__init__.py: -------------------------------------------------------------------------------- 1 | from . import utils 2 | from .utils import apply_fn, Multiplicity 3 | from ._mspace import MSpace 4 | from ._farray import FArray 5 | 6 | -------------------------------------------------------------------------------- /matrixfuncs/_farray.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import scipy 3 | from . import MSpace, apply_fn 4 | 5 | class FArray: 6 | '''Represents a functional array associated with a matrix space. 7 | 8 | This class enables efficient function application to matrices using precomputed function coefficients. 9 | 10 | Attributes 11 | ---------- 12 | space : MSpace 13 | The underlying matrix space associated with the functional array. 14 | mult_space : str 15 | Defines the multiplication space ('min' by default). 16 | bare : np.ndarray 17 | The function coefficients tensor. If not provided, it is copied from `space.f_coeffs`. 18 | ''' 19 | __array_priority__ = 1 20 | def __init__(self, space: MSpace, mult_space='min', bare=None, stay_lifted=True): 21 | ''' 22 | Initialize an FArray instance. 23 | 24 | Parameters 25 | ---------- 26 | space : MSpace 27 | The matrix space associated with this functional array. 28 | mult_space : str, optional 29 | The multiplication space, default is 'min'. 30 | bare : np.ndarray, optional 31 | Precomputed function coefficients. Defaults to `space.f_coeffs` if not provided. 32 | stay_lifted : bool, optional 33 | Whether the object remains lifted when applying functions. 34 | ''' 35 | self.space = space 36 | self.mult_space=mult_space 37 | self.bare = np.copy(space.f_coeffs) if bare is None else bare 38 | self.__stay_lifted = stay_lifted 39 | 40 | @staticmethod 41 | def from_matrix(M: np.ndarray, **kwargs): 42 | ''' 43 | Create an FArray instance from a matrix. 44 | 45 | Parameters 46 | ---------- 47 | M : np.ndarray 48 | The input matrix. 49 | **kwargs : dict 50 | Optional keyword arguments, including: 51 | - eigvals: Precomputed eigenvalues. 52 | - mult_space: Multiplication space setting. 53 | - bare: Precomputed function coefficients. 54 | - stay_lifted: Whether the instance should stay lifted. 55 | 56 | Returns 57 | ------- 58 | FArray 59 | An instance of the functional array. 60 | ''' 61 | _kwargs = {k: kwargs[k] for k in 'eigvals'.split() if k in kwargs} 62 | space = MSpace(M, **_kwargs) 63 | _kwargs = {k: kwargs[k] for k in 'mult_space bare stay_lifted'.split() if k in kwargs} 64 | return FArray(space, **_kwargs) 65 | 66 | def __call__(self, f, *dfs, gen_df=None, stay_lifted=None, real_if_close='auto', **kwargs): 67 | if isinstance(real_if_close, str): 68 | if real_if_close == 'auto': 69 | real_if_close = not np.iscomplexobj(self.space.M) 70 | else: 71 | raise ValueError(f'Unsupported value for real_if_close: {real_if_close}') 72 | 73 | ret = apply_fn(M=None, f=f, dfs=dfs, gen_df=gen_df, eigvals=self.space.multiplicity, coeffs=self.bare, mult_space=self.mult_space, real_if_close=real_if_close, **kwargs) 74 | 75 | ret_shape = np.shape(ret) 76 | is_squre_matrix = len(ret_shape)==2 and ret_shape[0] == ret_shape[1] 77 | if stay_lifted is None: 78 | stay_lifted = self.__stay_lifted and is_squre_matrix 79 | elif stay_lifted == True: 80 | stay_lifted = is_squre_matrix 81 | else: 82 | stay_lifted = False 83 | 84 | if stay_lifted: 85 | f_space = MSpace(ret) 86 | arr = FArray(f_space, mult_space=self.mult_space) 87 | return arr 88 | else: 89 | return ret 90 | 91 | @property 92 | def cs_shape(self): 93 | return np.shape(self.bare)[1:] 94 | @property 95 | def f_shape(self): 96 | return np.shape(self.bare)[0] 97 | @property 98 | def shape(self): 99 | return np.shape(self.bare) 100 | 101 | 102 | def __matmul__coeffs(self, other): 103 | if self.appliedFunc: 104 | coeffs = np.tensordot(self.coeffs, other, ([-1], [0])) 105 | if isinstance(coeffs, type(NotImplemented)): 106 | return coeffs 107 | else: 108 | coeffs = np.tensordot(self.coeffs, other, ([-2], [0])) 109 | if isinstance(coeffs, type(NotImplemented)): 110 | return coeffs 111 | oldRank = len(self.coeffs.shape) 112 | newRank = len(coeffs.shape) 113 | if oldRank-1 != newRank: 114 | coeffs = np.moveaxis(coeffs, oldRank-2, -1) 115 | return coeffs 116 | def __matmul__(self, other): 117 | if not bool(self.cs_shape): 118 | return NotImplemented 119 | cs = np.tensordot(self.bare, other, 1) 120 | if cs is NotImplemented: 121 | return NotImplemented 122 | ret = FArray(self.space, mult_space=self.mult_space, bare=cs, stay_lifted=False) 123 | return ret 124 | def __rmatmul__(self, other): 125 | if not bool(self.cs_shape): 126 | return NotImplemented 127 | cs = np.tensordot(other, self.bare, ((-1,), (1,))) 128 | if cs is NotImplemented: 129 | return NotImplemented 130 | cs = np.moveaxis(cs, -len(self.cs_shape), 0) 131 | ret = FArray(self.space, mult_space=self.mult_space, bare=cs, stay_lifted=False) 132 | return ret 133 | def __imatmul__(self, other): 134 | if not bool(self.cs_shape): 135 | return NotImplemented 136 | cs = np.tensordot(self.bare, other, 1) 137 | if cs is NotImplemented: 138 | return NotImplemented 139 | self.bare = cs 140 | self.__stay_lifted = False 141 | return self 142 | 143 | def __repr__(self): 144 | ret = repr(self.bare) 145 | main_idx = ret.find('(') 146 | return f'FArray{ret[main_idx:]}' 147 | -------------------------------------------------------------------------------- /matrixfuncs/_mspace.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from collections import namedtuple 3 | import scipy 4 | from .utils import matrix_power_series, Multiplicity, function_coeffs, apply_fn, err 5 | from warnings import warn 6 | 7 | def bare_coeffs(tensor: np.ndarray, basis: np.ndarray, assume_normed = False, normalize_to: float|None=1.): 8 | '''Compute expansion coefficients of `tensor` in terms of `basis` vectors. 9 | 10 | Parameters 11 | ---------- 12 | tensor : np.ndarray 13 | The input tensor to decompose. 14 | basis : np.ndarray 15 | The basis vectors (assumed to be square matrices). 16 | assume_normed : bool, optional 17 | If True, assumes that `basis` is already orthonormal, avoiding unnecessary computations. 18 | normalize_to : float or None, optional 19 | If not None, rescales `basis` to have a norm of `normalize_to`. 20 | 21 | Returns 22 | ------- 23 | np.ndarray 24 | The expansion coefficients of `tensor` in the `basis`. 25 | ''' 26 | 27 | #TODO This function is unreasonably more stable for normed basis. I suspect a bug somewhere 28 | if normalize_to is not None and not assume_normed: 29 | # prod_norms = [] 30 | # for evs in abs(np.linalg.eigvals(basis)): 31 | # non_zero = evs[evs > 1e-15] 32 | # dim = len(non_zero) 33 | # norm = np.prod(non_zero**(1/dim)) if dim > 0 else 1 34 | # prod_norms.append(norm) 35 | # # prod_norms = np.reshape(prod_norms, (-1, ) + (1,)*(len(np.shape(basis))-1)) 36 | # prod_norms = np.array(prod_norms) 37 | # scale = np.reshape(prod_norms / normalize_to, (-1, 1, 1)) 38 | norms = MSpace.norm(basis) 39 | scale = np.reshape(norms / normalize_to, (-1, 1, 1)) 40 | scaled_cs = bare_coeffs(tensor, basis / scale, assume_normed=assume_normed, normalize_to=None) 41 | cs = scaled_cs / np.reshape(scale, (1, -1)) 42 | else: 43 | cs = MSpace.sdot(basis, tensor) 44 | if not assume_normed: 45 | basis_change = MSpace.sdot(basis, basis) 46 | # cs = scipy.linalg.solve(basis_change, cs, assume_a='hermitian') 47 | 48 | # TODO: Check if this improves the accuracy problem for near-singular matrices 49 | cond_number = np.linalg.cond(basis_change) # A test to check whether basis_change is almost singular 50 | if cond_number > 1e12: 51 | cs = np.linalg.pinv(basis_change) @ cs 52 | else: 53 | cs = scipy.linalg.solve(basis_change, cs, assume_a='hermitian') 54 | 55 | cs = np.moveaxis(cs, 0, -1) 56 | return cs 57 | 58 | 59 | 60 | class MSpace: 61 | '''Represents a matrix space generated by a given square matrix. 62 | 63 | This class provides methods to compute various objects related to the vector space generated by a matrix, including 64 | an orthonormal basis, eigenvalues, function coefficient tensors, and transformations between different basis representations. 65 | 66 | Attributes 67 | ---------- 68 | M : np.ndarray 69 | The input square matrix that generates the space. 70 | eigvals : np.ndarray 71 | The eigenvalues of `M`. 72 | normed_basis : np.ndarray 73 | The orthonormal basis. 74 | multiplicity : Multiplicity 75 | The (algebraic and geometric) multiplicity of the eigenvalues. 76 | basis : np.ndarray 77 | The basis of the space generated by `M` repeatedly multiplying `M` with itself. 78 | normed_to_basis : np.ndarray 79 | Transformation matrix from the orthonormal basis to the generated basis. 80 | basis_to_normed : np.ndarray 81 | Inverse transformation matrix from the generated basis to the orthonormal basis. 82 | f_coeffs : np.ndarray 83 | Function coefficients for computing functions of `M`, i.e. `tensordot(multiplicity.map(f), f_coeffs, 1) = f(M)`. 84 | phi_coeffs_normed : np.ndarray 85 | Phi coefficients expressed in the orthonormal basis, i.e. `tensordot(phi_coeffs_normed, normed_basis, 1) = f_coeffs`. 86 | phi_coeffs : np.ndarray 87 | Phi coefficients expressed in the generated basis, i.e. `tensordot(phi_coeffs, basis, 1) = f_coeffs`. 88 | ''' 89 | 90 | def __init__(self, M: np.ndarray, eigvals: np.ndarray|Multiplicity|None = None): 91 | ''' 92 | Parameters 93 | ---------- 94 | M : np.ndarray 95 | A square matrix generating the space. 96 | eigvals : np.ndarray, Multiplicity, or None, optional 97 | If provided, these are the eigenvalues of `M`. If `None`, they are computed automatically. 98 | ''' 99 | def test(name:str, val, ref): 100 | if not np.allclose(val, ref): 101 | err_val = err(val, ref) 102 | warn(f'Error of {name} is high, relative error is {err_val}') 103 | 104 | self.M = np.array(M) 105 | self.eigvals = ( 106 | np.linalg.eigvals(self.M) if eigvals is None 107 | else eigvals.full_eigvals if isinstance(eigvals, Multiplicity) 108 | else eigvals 109 | ) 110 | self.__normed_basis = MSpace.Normed_Basis(self.M) 111 | self.multiplicity = Multiplicity.from_matrix(self.M, self.eigvals) if not isinstance(eigvals, Multiplicity) else eigvals 112 | dim = self.dim 113 | _dim = np.sum(self.multiplicity.algebraic - self.multiplicity.geometric + 1) 114 | assert dim == _dim, 'Inconsistent dimension count between basis dimension and eigenvalue multiplicity' 115 | self.basis = matrix_power_series(self.M, dim) 116 | 117 | self.normed_to_basis = MSpace.sdot(self.normed_basis, self.basis) 118 | self.basis_to_normed = scipy.linalg.inv(self.normed_to_basis) 119 | 120 | self.f_coeffs, _ = function_coeffs(self.M, self.multiplicity) 121 | self.phi_coeffs_normed = bare_coeffs(self.f_coeffs, self.normed_basis, assume_normed=True) 122 | self.phi_coeffs = np.tensordot(self.phi_coeffs_normed, self.basis_to_normed, ((1, ), (1,))) 123 | 124 | _basis = np.tensordot(self.normed_to_basis, self.normed_basis, ((0,), (0,))) 125 | test('normed_to_basis', self.basis, _basis) 126 | _n_basis = np.tensordot(self.basis_to_normed, self.basis, ((0,), (0,))) 127 | test('basis_to_normed', self.normed_basis, _n_basis) 128 | _fcoeffs_n = np.tensordot(self.phi_coeffs_normed, self.normed_basis, 1) 129 | test('phi_coeffs_normed', self.f_coeffs, _fcoeffs_n) 130 | _fcoeffs = np.tensordot(self.phi_coeffs, self.basis, 1) 131 | test('phi_coeffs', self.f_coeffs, _fcoeffs) 132 | 133 | @property 134 | def normed_basis(self): 135 | 'Orthonormal Basis' 136 | return self.__normed_basis 137 | 138 | @property 139 | def dim(self): 140 | 'Dimension of the space (rank of the matrix)' 141 | return len(self.normed_basis) 142 | 143 | @staticmethod 144 | def sdot(x: np.ndarray, y: np.ndarray): 145 | '''Compute the scaled inner product (dot product) of two square matrices. 146 | 147 | Parameters 148 | ---------- 149 | x : np.ndarray 150 | A square matrix. 151 | y : np.ndarray 152 | A square matrix of the same size as `x`. 153 | 154 | Returns 155 | ------- 156 | np.ndarray 157 | The scaled inner product of `x` and `y`, defined as `tr(x^t y)/n` 158 | 159 | Raises 160 | ------ 161 | AssertionError 162 | If `x` and `y` are not square matrices of the same size. 163 | ''' 164 | shapes = np.shape(x)[-2:] + np.shape(y)[-2:] 165 | n = shapes[0] 166 | assert shapes == (n, )*4, 'x and y have to be square matrices of the same size' 167 | ret = np.tensordot(np.conj(x), y, ((-2, -1), (-2, -1)))/n 168 | return ret 169 | 170 | @staticmethod 171 | def norm(x: np.ndarray): 172 | 'Normalized Frobenius norm of the matrix `x`' 173 | return np.linalg.norm(x, axis=(-2, -1))/np.sqrt(np.shape(x)[-1]) 174 | 175 | @staticmethod 176 | def Normed_Basis(M: np.ndarray, repeat_count=1): 177 | '''Compute an orthonormal basis of matrices using the Gram-Schmidt process. 178 | 179 | This function applies the Gram-Schmidt (GS) orthogonalization method to the input matrix `M`, 180 | ensuring that the resulting vectors form an orthonormal basis. The method starts with the the 181 | identity matrix, and generates the next basis element by multiplying the last basis element 182 | with `M` and subtracting the span of the previous basis elements. Optionally, the process can 183 | be repeated multiple times for increased numerical stability. 184 | 185 | Parameters 186 | ---------- 187 | M : np.ndarray 188 | The input matrix whose column vectors are to be orthonormalized. 189 | repeat_count : int, optional (default=1) 190 | The number of times the Gram-Schmidt process is applied. If `repeat_count` is greater than 1, 191 | the method is reapplied to refine the basis. However, applying GS multiple times generally does 192 | not significantly improve accuracy. 193 | 194 | Returns 195 | ------- 196 | np.ndarray 197 | A 2D array where each row represents an orthonormal basis vector. 198 | 199 | Notes 200 | ----- 201 | - If the input matrix contains linearly dependent vectors, the basis may have fewer vectors than `M.shape[-1]`. 202 | - The method relies on the custom `MSpace.sdot` and `MSpace.norm` functions for inner products and vector norms. 203 | ''' 204 | 205 | ret: list[np.ndarray] = [np.eye(M.shape[-1])] 206 | 207 | # First pass of Gram-Schmidt orthogonalization 208 | for _ in range(1, M.shape[-1]): 209 | new_val0 = ret[-1] @ M 210 | parallels = MSpace.sdot(ret, new_val0) 211 | paralell_vec = np.tensordot(ret, parallels, ((0,), (0,))) 212 | perpendicular_vec = new_val0 - paralell_vec 213 | norm = MSpace.norm(perpendicular_vec) 214 | if np.allclose(norm, 0): 215 | break 216 | ret.append(perpendicular_vec / norm) 217 | 218 | # Optional: Repeat Gram-Schmidt process for refinement 219 | for _ in range(repeat_count - 1): 220 | if len(ret) > 2: 221 | break 222 | ret0 = ret 223 | ret = ret0[:2] 224 | for next_val in ret0[2:]: 225 | parallels = MSpace.sdot(ret, next_val) 226 | paralell_vec = np.tensordot(ret, parallels, ((0,), (0,))) 227 | perpendicular_vec = next_val - paralell_vec 228 | norm = MSpace.norm(perpendicular_vec) 229 | if np.allclose(norm, 0): 230 | break 231 | 232 | ret.append(perpendicular_vec / norm) 233 | 234 | return np.array(ret) 235 | 236 | def __call__(self, f, *dfs, gen_df=None, **kwargs): 237 | 'See `apply_fn`' 238 | return apply_fn(M=None, f=f, dfs=dfs, gen_df=gen_df, eigvals=self.multiplicity, coeffs=self.f_coeffs, mult_space='min', **kwargs) 239 | 240 | def __repr__(self) -> str: 241 | ident = ' '*2 242 | inner = ident + f',\n{ident}'.join(( 243 | f'M: {self.M}', 244 | f'multiplicity: {self.multiplicity}', 245 | f'dim: {self.dim}', 246 | f'basis: {self.basis}', 247 | f'coeffs: {self.phi_coeffs}' 248 | )) 249 | return f'MSpace(\n{inner} )' 250 | -------------------------------------------------------------------------------- /matrixfuncs/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from ._fcoeffs import ( 2 | apply_fn, 3 | b_matrix, 4 | eigval_multiplicity, 5 | function_coeffs, 6 | matrix_power_series, 7 | Multiplicity 8 | ) 9 | 10 | from ._misc import err 11 | 12 | -------------------------------------------------------------------------------- /matrixfuncs/utils/_fcoeffs.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from collections import namedtuple 3 | import numdifftools as nd 4 | import scipy 5 | 6 | class Multiplicity(namedtuple('Multiplicity', 'eigvals algebraic geometric'.split())): 7 | '''A named tuple representing the multiplicities of eigenvalues in a matrix. 8 | 9 | This class stores distinct eigenvalues along with their algebraic and geometric multiplicities. 10 | It provides methods for applying functions to eigenvalues and computing properties like trace, 11 | determinant, rank, and a normalization factor. 12 | 13 | Attributes 14 | ---------- 15 | eigvals : np.ndarray 16 | An array containing distinct eigenvalues of the matrix. 17 | algebraic : np.ndarray 18 | The algebraic multiplicity of each corresponding eigenvalue. 19 | geometric : np.ndarray 20 | The geometric multiplicity of each corresponding eigenvalue. 21 | ''' 22 | def __new__(cls, eigvals: np.ndarray, algebraic: np.ndarray, geometric: np.ndarray): 23 | '''Constructor for the Multiplicity class. 24 | 25 | Parameters 26 | ---------- 27 | eigvals : np.ndarray 28 | Distinct eigenvalues of the matrix. 29 | algebraic : np.ndarray 30 | Algebraic multiplicities of each eigenvalue. 31 | geometric : np.ndarray 32 | Geometric multiplicities of each eigenvalue. 33 | 34 | Returns 35 | ------- 36 | Multiplicity 37 | A named tuple containing eigenvalues and their multiplicities. 38 | ''' 39 | return super(Multiplicity, cls).__new__(cls, np.asarray(eigvals), np.asarray(algebraic), np.asarray(geometric)) 40 | 41 | def map(self, f, *dfs, gen_df=None, mult_space='min', **kwargs): 42 | '''Maps a function (and derivatives) to the eigenvalues. 43 | 44 | This method applies `f` and its derivatives to each eigenvalue according to its 45 | algebraic multiplicity. 46 | 47 | Parameters 48 | ---------- 49 | f : function 50 | The function to apply to the eigenvalues. 51 | dfs : tuple of functions, optional 52 | Precomputed derivatives of `f`, if available. 53 | mult_space : 'min' | 'full' 54 | See `Multiplicity.ev_iter` for details. 55 | gen_df : function, optional 56 | A function that generates higher-order derivatives when needed. gen_df(k) should generate 57 | the kth derivative. Using dfs takes precedance over using gen_df. If gen_df is `None` then 58 | `numdifftools.Derivative` is used. 59 | 60 | Returns 61 | ------- 62 | ndarray 63 | (f(ev0), f'(ev0), f''(ev0), ..., f(ev1), f'(ev1), f''(ev1), ...) 64 | 65 | Notes 66 | ----- 67 | See also `ev_iter` for further details on the order. 68 | ''' 69 | diff_count = np.max(self.algebraic) 70 | fs = [f] + list(dfs[:diff_count - 1]) 71 | 72 | if len(fs) < diff_count: 73 | if gen_df is None: 74 | k0 = len(fs) - 1 75 | gen_df = lambda i, **_: nd.Derivative(fs[k0], n=i - k0, order=2 * 1) 76 | for i in range(len(fs), diff_count): 77 | fs.append(gen_df(i)) 78 | 79 | #TODO: vectorize 80 | ev_iter = self.ev_iter(mult_space) 81 | ret = [fs[k](ev) for _, ev, _, k in ev_iter] 82 | return np.array(ret) 83 | 84 | def ev_iter(self, mult_space='min'): 85 | '''Iterate over eigenvalues and their multiplicities. 86 | 87 | Parameters 88 | ---------- 89 | mult_space : 'min' | 'full' 90 | - if 'full' then iterate through algebraic multiplicity 91 | - if 'min' then iterate algebraic multiplicity of irreducable representation 92 | 93 | 94 | Returns 95 | ------- 96 | generator 97 | Yields (eigenvalue index, eigenvalue, multiplicity, counter of the multiplicity) 98 | 99 | The inner iteration is through the multiplicity and the outer iterator is through the 100 | eigenvalues. 101 | ''' 102 | return ((i, ev, mult, k) for i, (ev, mult) in enumerate(zip(self.eigvals, self._multiplicity(mult_space))) for k in range(mult)) 103 | 104 | def _multiplicity(self, mult_space='min'): 105 | '''Effective multiplicity 106 | 107 | Parameters 108 | ---------- 109 | mult_space : 'min' | 'full' 110 | - if 'full' then return algebraic multiplicity 111 | - if 'min' then return algebraic multiplicity of irreducable representation 112 | 113 | Returns 114 | ------- 115 | ndarray 116 | ''' 117 | if mult_space == 'min': 118 | return self.algebraic - self.geometric + 1 119 | elif mult_space == 'full': 120 | return self.algebraic 121 | else: 122 | raise ValueError(f'Unsupported mult_space "{mult_space}"') 123 | 124 | 125 | @property 126 | def tr(self): 127 | 'The trace' 128 | return self.eigvals @ self.algebraic 129 | 130 | @property 131 | def full_eigvals(self): 132 | 'All eigenvalues' 133 | return np.array([ev for _,ev,_,_ in self.ev_iter('full')]) 134 | 135 | @property 136 | def det(self): 137 | 'The determinant' 138 | return np.prod(self.eigvals ** self.algebraic) 139 | 140 | @property 141 | def dim(self): 142 | 'The dimension' 143 | return np.sum(self.algebraic) 144 | 145 | @property 146 | def rank(self): 147 | 'The rank' 148 | return np.sum(self.algebraic - self.geometric + 1) 149 | 150 | def product_norm(self, mult_space='min'): 151 | '''Compute a norm-like quantity used for matrix normalization. 152 | 153 | - If all eigenvalues are nonzero, returns `|det|^(1/dim)`. 154 | - If some but not all eigenvalues are nonzero, returns `product_norm` of the nonzero part. 155 | - If the only eigenvalue is 0, returns 1. 156 | 157 | Parameters 158 | ---------- 159 | mult_space : 'min' | 'full' 160 | See `Multiplicity.ev_iter` for details. 161 | 162 | Returns 163 | ------- 164 | float 165 | Computed normalization value. 166 | ''' 167 | if np.all(self.eigvals == 0): 168 | return np.ones_like(self.eigvals[0]) 169 | elif np.any(self.eigvals == 0): 170 | inds = self.eigvals != 0 171 | return Multiplicity(*(q[inds] for q in self)).product_norm(mult_space) 172 | else: 173 | mult = self._multiplicity(mult_space) 174 | e = mult / np.sum(mult) 175 | return np.prod(np.abs(self.eigvals) ** e) 176 | 177 | @staticmethod 178 | def from_matrix(M: np.ndarray, eigvals: np.ndarray | None = None, **kwargs): 179 | '''Create a `Multiplicity` instance from a given matrix. 180 | 181 | Parameters 182 | ---------- 183 | M : np.ndarray 184 | The input matrix. 185 | eigvals : np.ndarray, optional 186 | Precomputed eigenvalues. If `None`, they are computed automatically. 187 | 188 | Returns 189 | ------- 190 | Multiplicity 191 | A `Multiplicity` instance containing eigenvalue data. 192 | 193 | See also 194 | -------- 195 | `eigval_multiplicity` 196 | ''' 197 | return eigval_multiplicity(M=M, eigvals=eigvals, **kwargs) 198 | 199 | 200 | def matrix_power_series(M: np.ndarray, stop: int): 201 | '''Compute a sequence of matrix powers up to a given order. 202 | 203 | This function efficiently computes powers of a square matrix up to `stop - 1` using recursive 204 | squaring to reduce computational complexity. 205 | 206 | Parameters 207 | ---------- 208 | M : np.ndarray 209 | A square matrix whose powers need to be computed. 210 | stop : int 211 | The exclusive upper bound to compute in the series. 212 | 213 | Returns 214 | ------- 215 | np.ndarray 216 | An array containing matrices from the identity matrix (I) to M^(stop-1). 217 | ''' 218 | shape = np.shape(M) 219 | assert len(shape)==2 and shape[0]==shape[1], 'M is not a square matrix' 220 | dim = shape[0] 221 | ret = [np.eye(dim, dtype=M.dtype), M] 222 | for i in range(2, stop): 223 | k1 = i // 2 224 | k2 = i - k1 225 | ret.append(ret[k1] @ ret[k2]) 226 | return np.array(ret)[:max(stop, 0)] 227 | 228 | def eigval_multiplicity(M: np.ndarray, eigvals: np.ndarray | None =None, zero_thrsh = 1e-15, rel_eq_thrsh = 1e-8): 229 | '''Compute the algebraic and geometric multiplicities of eigenvalues. 230 | 231 | This function determines the distinct eigenvalues of a given matrix, their algebraic 232 | multiplicities, and geometric multiplicities (dimension of the eigenspaces). 233 | 234 | Parameters 235 | ---------- 236 | M : np.ndarray 237 | A square matrix whose eigenvalues are analyzed. 238 | eigvals : np.ndarray, optional 239 | Precomputed eigenvalues of the matrix. If None, they are computed internally. 240 | zero_thrsh : float = 1e-15 241 | Threshold below which eigenvalues are considered zero. Set to 0 if only 0 itself should be 242 | considered a vanishing eigenvalue. 243 | rel_eq_thrsh : float = 1e-8 244 | Relative threshold for treating eigenvalues as identical. 245 | 246 | Returns 247 | ------- 248 | mult: Multiplicity 249 | ''' 250 | # TODO: This seems to be extremely instable for eigenvalues which are close but not equal, however fcoeffs.dim seems to do a good job in finding the dimension 251 | if eigvals is None: 252 | eigvals = np.linalg.eigvals(M) 253 | else: 254 | eigvals = np.array(eigvals) 255 | nEigvals = eigvals.shape[0] 256 | non_zero_eigvals = eigvals[abs(eigvals) > zero_thrsh] 257 | unique_eigvals = [0*eigvals[0]] if non_zero_eigvals.shape != eigvals.shape else [] 258 | for ev in non_zero_eigvals: 259 | differs = abs(np.array(unique_eigvals)/ev - 1) > rel_eq_thrsh 260 | if differs.all(): 261 | unique_eigvals.append(ev) 262 | unique_eigvals = np.array(unique_eigvals) 263 | 264 | alg_mult = [0]*len(unique_eigvals) 265 | for ev in eigvals: 266 | alg_mult[abs(unique_eigvals - ev).argmin()] += 1 267 | alg_mult = np.array(alg_mult) 268 | 269 | geom_mult = [] 270 | for i, ev in enumerate(unique_eigvals): 271 | if len(unique_eigvals) == 1: 272 | ev_thrsh = np.inf 273 | else: 274 | other_eigvals = unique_eigvals[np.arange(len(unique_eigvals)) != i] 275 | ev_thrsh = abs(other_eigvals - ev).min() / 2 276 | eigvals_ker, eigvecs_ker = np.linalg.eig(M - ev*np.eye(nEigvals)) 277 | inds_ker = abs(eigvals_ker) < ev_thrsh 278 | vecs_ker = eigvecs_ker[:, inds_ker] 279 | # vecs_ker_min = normed_basis(vecs_ker.T).T 280 | vecs_ker_min = scipy.linalg.orth(vecs_ker) 281 | geom_mult.append(np.shape(vecs_ker_min)[-1]) 282 | geom_mult = np.array(geom_mult) 283 | 284 | # Ret = namedtuple('Multiplicity', 'eigvals algebraic geometric'.split()) 285 | # ret = Ret(unique_eigvals, alg_mult, geom_mult) 286 | ret = Multiplicity(unique_eigvals, alg_mult, geom_mult) 287 | return ret 288 | 289 | def b_matrix(multiplicity: Multiplicity, mult_space='min'): 290 | '''Construct the basis matrix `phi` for function approximation using eigenvalue decomposition. 291 | 292 | This function generates a matrix used to solve for function coefficients when applying matrix 293 | functions. 294 | 295 | Parameters 296 | ---------- 297 | multiplicity : Multiplicity 298 | Eigenvalue multiplicity structure computed from a matrix. 299 | mult_space : 'min' | 'full' 300 | See `Multiplicity.ev_iter` for details. 301 | 302 | Returns 303 | ------- 304 | np.ndarray 305 | A transformation matrix for function coefficient computation. Intended to be used like 306 | `cs = scipy.linalg.solve(b, matrix_power_series(M, len(b)))`. See also the example. 307 | 308 | Example 309 | ------- 310 | >>> # Construct a random 5x5 matrix M and explicitly construct the exp(M) 311 | >>> M = np.random.randn(5,5) 312 | >>> mult = eigval_multiplicity(M) 313 | >>> b = b_matrix(mult)[:, ::-1].T 314 | >>> cs = scipy.linalg.solve(b, matrix_power_series(M, len(b))) 315 | >>> 316 | >>> # M can be set to None since `coeffs` and `eigvals` are provided 317 | >>> expM = apply_fn(None, 'exp', coeffs=cs, eigvals=mult) 318 | >>> 319 | >>> # Compare solution with reference solution from `scipy.linalg.expm` 320 | >>> expM_ref = scipy.linalg.expm(M) 321 | >>> np.linalg.norm(expM - expM_ref) 322 | >>> 323 | >>> #The expected rounding error is of order 1.11e-16 * order of biggest values * sqrt(number of calculation steps in that biggest order) 324 | np.float64(2.0755569795398968e-15) 325 | ''' 326 | ret = [] 327 | ev_iter = list(multiplicity.ev_iter(mult_space)) 328 | dim = len(ev_iter) 329 | for (_, ev, mult, k) in ev_iter: 330 | val0 = np.concat([ev**np.arange(dim-1-k, 0, -1), [1]]) 331 | val_fac = (np.arange(dim-k, 0, -1).reshape((-1, 1)) + np.arange(k).reshape((1, -1))).prod(axis=-1) 332 | val = np.concat([val0 * val_fac, [0]*k]) 333 | ret.append(val) 334 | ret = np.array(ret) 335 | return ret 336 | 337 | def function_coeffs(M: np.ndarray, eigvals:np.ndarray|None|Multiplicity=None, mult_space='min', normalize_to:float|None=1.): 338 | '''Compute coefficients for matrix function computation. 339 | 340 | This function determines the necessary coefficients to compute functions applied to matrices, 341 | leveraging eigenvalue decomposition. 342 | 343 | Parameters 344 | ---------- 345 | M : np.ndarray 346 | The input square matrix. 347 | eigvals : np.ndarray or Multiplicity, optional 348 | Precomputed eigenvalues or their multiplicities. If it is not of type Multiplicity then it is 349 | calculated using `eigval_multiplicity` 350 | mult_space : 'min' | 'full' 351 | See `Multiplicity.ev_iter` for details. 352 | normalize_to : float, optional, default=1. 353 | Scaling factor to normalize eigenvalues, improving numerical stability. 354 | 355 | To achieve better accuracy the `M` is rescaled such that the eigenvalues are approximately of 356 | size `normalize_to`. In particular, for a non-singular Matrix its determinate will be rescaled 357 | to `normalize_to`. Setting `normalize_to` to `None` skips the normalization step. 358 | 359 | Returns 360 | ------- 361 | cs,mult : ndarray, Multiplicity 362 | The quantities necessary for applying a function to a matrix. `cs` is the `phi`-tensor. `mult` 363 | is the the multiplicity used. If the type of `eigvals` is `Multiplicity` then `mult` is equal 364 | to `eigvals`. 365 | 366 | See also 367 | -------- 368 | See also `b_matrix` or `apply_fn` for an example 369 | ''' 370 | 371 | # Idea behind the normalization: f(A) = f(sx) with x=A/s 372 | if isinstance(eigvals, Multiplicity): 373 | ev_mult = eigvals 374 | else: 375 | ev_mult = eigval_multiplicity(M, eigvals) 376 | if normalize_to is not None: 377 | ev_iter = list(ev_mult.ev_iter(mult_space)) 378 | norm_scale = ev_mult.product_norm(mult_space)/normalize_to**(1/len(ev_iter)) 379 | _ev_mult = Multiplicity(ev_mult.eigvals/norm_scale, *ev_mult[1:]) 380 | _M = M/norm_scale 381 | cs_rescale = np.array([np.ones_like(norm_scale) if k==0 else norm_scale**k for _,_,_,k in ev_iter]) 382 | b = b_matrix(_ev_mult, mult_space=mult_space)[:, ::-1].T 383 | _cs = scipy.linalg.solve(b, matrix_power_series(_M, len(b))) 384 | cs = cs_rescale.reshape((-1, 1, 1)) * _cs 385 | else: 386 | b = b_matrix(ev_mult, mult_space=mult_space)[:, ::-1].T 387 | cs = scipy.linalg.solve(b, matrix_power_series(M, len(b))) 388 | return cs, ev_mult 389 | 390 | def apply_fn(M: np.ndarray, f, *dfs, gen_df=None, eigvals:np.ndarray|None|Multiplicity=None, coeffs:np.ndarray|None=None, real_if_close=True, normalize_to:float|None=1., **kwargs): 391 | '''Apply a scalar function to a matrix using spectral decomposition. 392 | 393 | If `eigvals` is of type Multiplicity and `coeffs` is provided then `M` is ignored and the 394 | function is applied directly. 395 | 396 | Parameters 397 | ---------- 398 | M : np.ndarray 399 | The square matrix to transform. 400 | f : function 401 | A function which is applied on the eigenvalues. Can be a predefined function like 'exp', 'log', 402 | etc. If a supported function is provided `dfs` and `gen_df` are ignored. 403 | 404 | Full list of supported predefined functions: 405 | - `exp` 406 | - `log` or `ln` 407 | - `inv` 408 | - `sin`, and `cos` 409 | - `sqrt` 410 | dfs : function 411 | Precomputed derivatives of `f`, if available. 412 | gen_df : function, optional 413 | A function that generates higher-order derivatives when needed. gen_df(k) should generate 414 | the kth derivative. Using dfs takes precedance over using gen_df. If gen_df is `None` then 415 | `numdifftools.Derivative` is used. 416 | eigvals : Optional[ndarray|Multiplicity] = None 417 | Precomputed eigenvalues or their multiplicities. If it is not of type Multiplicity then it is 418 | calculated using `eigval_multiplicity` 419 | cs : Optional[ndarray] 420 | The `phi`-tensor. See also `function_coeffs` 421 | real_if_close : bool = True 422 | Whether to `np.real_if_close` at the end. 423 | normalize_to : Optional[float] = 1. 424 | Scaling factor to normalize eigenvalues, improving numerical stability. 425 | 426 | To achieve better accuracy the `M` is rescaled such that the eigenvalues are approximately of 427 | size `normalize_to`. In particular, for a non-singular Matrix its determinate will be rescaled 428 | to `normalize_to`. Setting `normalize_to` to `None` skips the normalization step. 429 | 430 | Returns 431 | ------- 432 | f_M: ndarray 433 | The matrix after applying the function `f`. 434 | 435 | Example 436 | ------- 437 | >>> # Construct a random 5x5 matrix M and explicitly construct the exp(M) 438 | >>> # Compare solution with reference solution from `scipy.linalg.expm` 439 | >>> M = np.random.randn(5,5) 440 | >>> expM = apply_fn(M, 'exp') 441 | >>> expM_ref = scipy.linalg.expm(M) 442 | >>> np.linalg.norm(expM - expM_ref) 443 | >>> 444 | >>> #The expected rounding error is of order 1.11e-16 * order of biggest values * sqrt(number of calculation steps in that biggest order) 445 | np.float64(2.0755569795398968e-15) 446 | 447 | See also 448 | -------- 449 | See also `b_matrix` for more fine-grained usage, which might be preferred if many functions 450 | have to be calculated for the same matrix. 451 | ''' 452 | if isinstance(f, str): 453 | f = f.lower().strip() 454 | if f == 'exp': 455 | f = np.exp 456 | gen_df = lambda _: np.exp 457 | elif f in 'log ln'.split(): 458 | _0 = np.array(0j) 459 | f = lambda x: np.log(x + _0) 460 | gen_df = lambda k: (lambda x: np.prod(-np.arange(1, k))/x**k) 461 | elif f == 'inv': 462 | f = lambda x: x**-1 463 | gen_df = lambda k: (lambda x: np.prod(-np.arange(1, k+1))/x**(k+1)) 464 | elif f == 'sin': 465 | f = np.sin 466 | _dfs = [np.sin, np.cos, lambda x: -np.sin(x), lambda x: -np.cos(x)] 467 | gen_df = lambda k: _dfs[k%4] 468 | elif f == 'cos': 469 | f = np.cos 470 | _dfs = [np.cos, lambda x: -np.sin(x), lambda x: -np.cos(x), np.sin] 471 | gen_df = lambda k: _dfs[k%4] 472 | elif f == 'sqrt': 473 | _0 = np.array(0j) 474 | f = lambda x: np.sqrt(x + _0) 475 | gen_df = lambda k: (lambda x: np.prod(np.arange(.5, 1-k, -1))/x**(k-.5)) 476 | else: 477 | raise ValueError(f'Unknown function f={f}') 478 | if coeffs is not None and isinstance(eigvals, Multiplicity): 479 | cs, ev_mult = coeffs, eigvals 480 | else: 481 | _kwargs = {k: kwargs[k] for k in 'mult_space'.split() if k in kwargs} 482 | cs, ev_mult = function_coeffs(M, eigvals, normalize_to=normalize_to, **_kwargs) 483 | f_ev_mult = ev_mult.map(f, *dfs, gen_df=gen_df, **kwargs) 484 | ret = np.tensordot(f_ev_mult, cs, ((0, ), (0, ))) 485 | 486 | if real_if_close: 487 | ret = np.real_if_close(ret) 488 | 489 | return ret 490 | -------------------------------------------------------------------------------- /matrixfuncs/utils/_misc.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | def err(x,y): 4 | '''Compute the relative error between two matrices or vectors. 5 | 6 | Parameters 7 | ---------- 8 | x : np.ndarray 9 | First input array (matrix or vector). 10 | y : np.ndarray 11 | Second input array (matrix or vector) to compare against `x`. 12 | 13 | Returns 14 | ------- 15 | float 16 | The relative error metric, or the absolute norm difference if both `x` and `y` have zero norm. 17 | ''' 18 | nxy = np.linalg.norm(x - y) 19 | nxy_s = np.sqrt(np.linalg.norm(x) * np.linalg.norm(y)) 20 | return nxy if nxy_s==0 else nxy/nxy_s 21 | 22 | 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "setuptools-scm>=2.0.0" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | 9 | [tool.setuptools] 10 | packages = ["matrixfuncs"] 11 | 12 | [project] 13 | name = "matrixfuncs" 14 | dynamic = ["version"] 15 | description = "A package for computing matrix functions using eigenvalue decomposition." 16 | readme = "README.md" 17 | requires-python = ">=3.10" 18 | keywords = ["matrix", "linear algebra", "eigenvalues", "numerical methods"] 19 | license = {text = "LGPL-3.0-only"} 20 | classifiers = [ 21 | "Development Status :: 4 - Beta", 22 | "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", 23 | "Intended Audience :: Science/Research", 24 | "Programming Language :: Python :: 3", 25 | "Topic :: Scientific/Engineering :: Mathematics", 26 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 27 | ] 28 | dependencies = [ 29 | "numpy", 30 | "scipy", 31 | "numdifftools" 32 | ] 33 | 34 | 35 | [project.urls] 36 | "Github" = "https://github.com/nextdorf/matrix-functions" 37 | 38 | [tool.setuptools.dynamic] 39 | version = {attr = "matrixfuncs._version.version"} 40 | 41 | [tool.setuptools_scm] 42 | write_to = "matrixfuncs/_version.py" 43 | 44 | [tool.pytest.ini_options] 45 | testpaths = ["tests"] 46 | -------------------------------------------------------------------------------- /sinFromMFunc.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 2025-02-20T11:31:55.278489 10 | image/svg+xml 11 | 12 | 13 | Matplotlib v3.10.0, https://matplotlib.org/ 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 46 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 366 | 379 | 409 | 434 | 435 | 448 | 473 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 504 | 505 | 506 | 507 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 526 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 688 | 689 | 690 | 691 | 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 706 | 709 | 710 | 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | 760 | 779 | 780 | 781 | 782 | 783 | 784 | 785 | 786 | 787 | 788 | 789 | 790 | 888 | 889 | 890 | 891 | 902 | 903 | 904 | 905 | 906 | 907 | 908 | 909 | 910 | 911 | 912 | 913 | 914 | 915 | 916 | 917 | 918 | 919 | 920 | 921 | 922 | 923 | 924 | 925 | 926 | 927 | 928 | 929 | 930 | 931 | 932 | 933 | 934 | 935 | 936 | 937 | 938 | 939 | 942 | 943 | 944 | 947 | 948 | 949 | 952 | 953 | 954 | 957 | 958 | 959 | 960 | 961 | 962 | 993 | 1014 | 1035 | 1054 | 1075 | 1097 | 1130 | 1151 | 1172 | 1188 | 1214 | 1227 | 1228 | 1229 | 1230 | 1231 | 1232 | 1233 | 1234 | 1235 | 1236 | 1237 | 1238 | 1239 | 1240 | 1241 | 1242 | 1243 | 1244 | 1245 | 1246 | 1247 | 1248 | 1249 | 1250 | 1251 | 1252 | 1253 | 1254 | 1255 | 1256 | 1257 | 1258 | 1259 | 1260 | 1261 | 1262 | 1263 | 1264 | 1265 | 1266 | 1267 | 1268 | 1269 | 1270 | 1271 | 1272 | 1273 | 1274 | 1275 | 1276 | 1277 | 1278 | 1279 | 1280 | 1281 | 1282 | 1283 | 1284 | 1285 | 1286 | 1287 | 1288 | 1289 | 1290 | 1301 | 1302 | 1303 | 1307 | 1308 | 1309 | 1310 | 1311 | 1312 | 1319 | 1345 | 1346 | 1347 | 1348 | 1349 | 1350 | 1351 | 1352 | 1353 | 1354 | 1355 | 1356 | 1357 | 1358 | 1359 | 1360 | 1361 | 1362 | 1363 | 1364 | 1365 | 1366 | 1367 | 1368 | 1369 | 1370 | 1371 | 1372 | 1373 | 1374 | 1375 | 1376 | 1377 | 1378 | 1379 | 1380 | 1381 | 1382 | 1383 | 1384 | 1385 | 1386 | 1387 | 1388 | 1389 | 1390 | 1391 | 1392 | 1393 | 1394 | 1395 | 1396 | 1397 | 1398 | 1399 | 1400 | 1401 | 1402 | 1403 | 1404 | 1405 | 1406 | 1407 | 1408 | 1409 | 1410 | 1411 | 1412 | 1413 | 1414 | 1415 | 1416 | 1417 | 1418 | 1419 | 1420 | 1421 | 1422 | 1423 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from matrixfuncs import * 2 | from matrixfuncs.utils import * 3 | import numpy as np 4 | import pytest 5 | 6 | def direct_prod(xs0, *xss): 7 | if not hasattr(xs0, '__iter__'): 8 | xs0 = (xs0, ) 9 | if len(xss) == 0: 10 | for x in xs0: 11 | yield (x,) 12 | else: 13 | inner = list(direct_prod(*xss)) 14 | for x in xs0: 15 | for y in inner: 16 | yield (x,) + y 17 | 18 | def matrix_from_mult(mult: Multiplicity): 19 | t=np.arange(mult.dim) 20 | M=np.zeros(t.shape*2) 21 | M[t,t] = [ev for _,ev,_,_ in mult.ev_iter('full')] 22 | off_diag0 = [1.*(np.arange(alg) < alg-geom) for alg, geom in zip(mult.algebraic, mult.geometric)] 23 | off_diag = np.concat(off_diag0)[:-1] 24 | M[t[:-1],t[1:]] = off_diag 25 | return M 26 | 27 | def rnd_matrix_from_mult(mult: Multiplicity): 28 | M0 = matrix_from_mult(mult) 29 | Q, Q_inv = None, None 30 | while Q_inv is None: 31 | try: 32 | if np.iscomplexobj(M0): 33 | Q = np.random.randn(*M0.shape, 2) @ [1, 1j] 34 | else: 35 | Q = np.random.randn(*M0.shape) 36 | Q /= np.linalg.det(Q) 37 | Q_inv = np.linalg.inv(Q) 38 | except: 39 | continue 40 | inv_err = err(Q_inv@Q, np.eye(M0.shape[0])) 41 | if inv_err > 1e-9: 42 | pytest.warns(f'Too big error in rnd_matrix_from_mult ({inv_err}), try again...') 43 | Q_inv = None 44 | M = Q @ M0 @ Q_inv 45 | return M 46 | 47 | 48 | -------------------------------------------------------------------------------- /tests/test_fcoeff.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | import scipy 3 | 4 | 5 | frac_f_ref = lambda t: lambda A: scipy.linalg.fractional_matrix_power(A, t) 6 | 7 | @pytest.mark.parametrize('args, real_vals, dim, count', direct_prod( 8 | [ 9 | dict(f='exp', f_ref=scipy.linalg.expm), 10 | dict(f='log', f_ref=scipy.linalg.logm), 11 | dict(f='sin', f_ref=scipy.linalg.sinm), 12 | dict(f='sqrt', f_ref=scipy.linalg.sqrtm), 13 | dict(f='rnd_fractional', f_ref=frac_f_ref), 14 | ], 15 | (True, False), 16 | [2, 3, 4, 5, 7, 10, 15, 20, 30], 17 | 5, 18 | )) 19 | def test_apply_fn(args, real_vals: bool, dim: int, count: int): 20 | f, f_ref = map(args.get, 'f f_ref'.split()) 21 | is_rnd_fractional = f == 'rnd_fractional' 22 | for _ in range(count): 23 | if is_rnd_fractional: 24 | t = np.random.randn()*5 25 | f = lambda x: (x + 0j)**t 26 | f_ref = frac_f_ref(t) 27 | if real_vals: 28 | M = np.random.randn(dim, dim) 29 | else: 30 | M = np.random.randn(dim, dim, 2) @ [1, 1j] / 2**.5 31 | f_M = apply_fn(M, f) 32 | f_ref_M = f_ref(M) 33 | same_nan = np.isnan(f_M) == np.isnan(f_ref_M) 34 | assert np.all(same_nan), repr(np.array([M, f_M, f_ref_M])) 35 | f_M = np.nan_to_num(f_M, nan=0) 36 | f_ref_M = np.nan_to_num(f_M, nan=0) 37 | assert err(f_M, f_ref_M) < 1e-9 38 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = py{313, 312, 311, 310} 4 | 5 | [testenv] 6 | deps = pytest 7 | commands = pytest {posargs} 8 | 9 | -------------------------------------------------------------------------------- /typst/.gitignore: -------------------------------------------------------------------------------- 1 | *.pdf 2 | -------------------------------------------------------------------------------- /typst/eq.typ: -------------------------------------------------------------------------------- 1 | // Thankfully provided by github.com/jorson 2 | // https://github.com/typst/typst/issues/380#issuecomment-1523884719 3 | // https://typst.app/project/roACTA6jaO8lqADEG5PFc- 4 | 5 | 6 | #let foldl1(a, f) = a.slice(1).fold(a.first(), f) 7 | #let concat(a) = foldl1(a, (acc, x) => acc + x) 8 | #let nonumber(e) = math.equation(block: true, numbering: none, e) 9 | 10 | #let eq(es, numberlast: false ) = if es.has("children") { 11 | let esf = es.children.filter(x => not ([ ], [#parbreak()]).contains(x)) 12 | let bodyOrChildren(e) = if e.body.has("children") { concat(e.body.children) } else { e.body } 13 | let hideEquation(e) = if e.has("numbering") and e.numbering == none { 14 | nonumber(hide(e)) 15 | } else [ 16 | $ #hide(bodyOrChildren(e)) $ #{if e.has("label") { e.label }} 17 | ] 18 | let hidden = box(concat( 19 | if numberlast == true { 20 | esf.slice(0, esf.len()-1).map(e => nonumber(hide(e))) + (hideEquation(esf.last()),) 21 | } else if numberlast == false { 22 | esf.map(e => hideEquation(e)) 23 | } else if numberlast == none { 24 | esf.map(e => nonumber(hide(e))) 25 | })) 26 | let folder(acc, e) = acc + if acc != [] { linebreak() } + e 27 | let aligned = math.equation(block: true, numbering: none, esf.fold([], folder)) 28 | 29 | hidden 30 | // style(s => v(-measure(hidden, s).height, weak: true)) 31 | context { 32 | v(-measure(hidden).height, weak: true) 33 | } 34 | aligned 35 | } 36 | -------------------------------------------------------------------------------- /typst/github-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /typst/matrix-functions-docu.typ: -------------------------------------------------------------------------------- 1 | #import "template.typ": minimal-document, appendices 2 | #import "eq.typ": eq, nonumber 3 | #import "@preview/ouset:0.2.0": ouset, overset 4 | #import "@preview/wrap-it:0.1.1": wrap-content 5 | #import "@preview/codelst:2.0.2": sourcecode, sourcefile 6 | 7 | #show: minimal-document.with( 8 | title: "Documentation for Matrixfuncs", 9 | authors: ( 10 | ( 11 | name: "Nextdorf", 12 | // subtitle: link("https://github.com/nextdorf/matrix-functions")[matrixfuncs\@github.com], 13 | subtitle: link("https://github.com/nextdorf/matrix-functions")[ 14 | #grid(columns: (auto, auto, auto), rows: 0pt, align: alignment.bottom, [matrixfuncs\@ #h(.05em)], [], [#figure(image("github-logo.svg", height: .9em))]) 15 | ], 16 | ), 17 | ), 18 | // Insert your abstract after the colon, wrapped in brackets. 19 | // Example: `abstract: [This is my abstract...]` 20 | // abstract: lorem(55), 21 | keywords: ("First keyword", "Second keyword", "etc."), 22 | date: "January 28, 2025", 23 | ) 24 | #set cite(style: "chicago-author-date") 25 | 26 | 27 | 28 | = Mathematical background 29 | == 2 dimensional case 30 | The Cayley-Hamilton theorem states that any square matrix $A in CC^(n times n)$ satisfies its own characteristic equation. If $p_A (lambda)=det(lambda bb(1) - A)$ is the characteristic polynomial of $A$, then substituting $A$ into it results in $p_A (A)=0$. 31 | 32 | For $n=2$: 33 | 34 | #eq[ 35 | $p_A (lambda)=det(lambda bb(1)-A)=(lambda-lambda_1)(lambda-lambda_2)=lambda^2-lambda tr A+det A$ 36 | 37 | $=> A^2=A tr A-bb(1)det A$ 38 | ] 39 | So $A^2$ is a linear combination of $A$ and $bb(1)$. By multiplication with $A$ we find, that $A^3$ is a linear combination of $A^2$ and $A$ which reduces according to the above relation to a linear combination of $A$ and $bb(1)$. By repeatedly multiplying with $A$, we find equivalent results for $A^4$, $A^5$, ..., as well. By complete induction it follows that $A^n$ is also such a linear combination of $A$ and $bb(1)$ for any $n in NN_0$ (the base case of $n=0, 1$ is trivial): 40 | 41 | $ 42 | A^n =: a_n A+b_n bb(1), quad A^(n+1)=(a_n tr A+b_n)A-(a_n det A)bb(1) 43 | $ 44 | $ 45 | a_(n+1) = a_n tr A+b_n, quad b_(n+1)=-a_n det A,\ quad a_0=0, quad a_1=1, quad b_0=1, quad b_1=0 46 | $ 47 | 48 | In order to solve the recurrence equation we use a shift operator $Delta$ for $a_n$, s.t. $a_(n+1) = Delta a_n$ 49 | 50 | $ 51 | 0=(Delta^2-Delta tr A+det A)a_n=p_A (Delta)a_n=(Delta-lambda_1)(Delta-lambda_2)a_n 52 | $ 53 | 54 | As an Ansatz we choose $a_n$ as a linear combination of the solutions of $0=(Delta-lambda_1)a_n$ and $0=(Delta-lambda_2)a_n$. We are motivated to pick this Ansatz because clearly any solution to $0=(Delta-lambda_(1 #[ or ] 2))a_n$ solves the above equation and the $Delta^2$ hints at a 2-dimensional solution-space. In addition the problem is linear. This approach assumes distinct eigenvalues $lambda_1, lambda_2$. When the eigenvalues coincide, additional techniques are required, which will be discussed at the end of this section and in @ch-math-general-case: 55 | 56 | $ 57 | a_n = c_1 lambda_1^n +c_2 lambda_2^n 58 | $ 59 | As the base case for induction, we consider the trivial cases $n=0$ and $n=1$: 60 | 61 | #eq(numberlast: true)[ 62 | $ a_0 =& c_1+c_2=0, quad a_1 = c_1 lambda_1 + c_2 lambda c_2 = 1$ 63 | $=> c_2 =& -c_1, quad c_1(lambda_1-lambda_2) = 1$ 64 | $=> c_1 =& 1/(lambda_1-lambda_2), quad c_2 = (-1)/(lambda_1-lambda_2)$ 65 | ] 66 | 67 | Thus, the explicit closed-form solutions for $a_n$ and $b_n$ are: 68 | 69 | $ 70 | a_n = (lambda_1^n-lambda_2^n)/(lambda_1-lambda_2), quad b_n ouset(=,#ref()) -(lambda_2lambda_1^n - lambda_1lambda_2^n)/(lambda_1 - lambda_2) 71 | $ 72 | 73 | Thus, we obtain the explicit linear combination representation of $A^n$: 74 | 75 | $ 76 | A^n = (lambda_1^n-lambda_2^n)/(lambda_1-lambda_2)A -(lambda_2lambda_1^n - lambda_1lambda_2^n)/(lambda_1 - lambda_2)bb(1) 77 | $ 78 | 79 | Since $A^n$ is linear in $lambda_i^n$ we know how to evaluate arbitrary polynomials of $A$. Let $q(x)$ be a polynomial: 80 | 81 | $ 82 | q(A) = 1/(lambda_1-lambda_2)[(q(lambda_1) - q(lambda_2))A - (lambda_2 q(lambda_1) - lambda_1 q(lambda_2))bb(1)] 83 | $ 84 | 85 | Since the coefficients only depend on $q(lambda_i)$ the formula can be generalized to all functions which are analytic in $lambda_1$ and $lambda_2$. Let $f(x)$ be an analytic function in the eigenvalues of $A$: 86 | 87 | $ 88 | f(A) = 1/(lambda_1-lambda_2)[(f(lambda_1) - f(lambda_2))A - (lambda_2 f(lambda_1) - lambda_1 f(lambda_2))bb(1)] 89 | $ 90 | The generalization of @func-2dim is the core of this package. The important properties are that it allows efficient computation of $f(A)$ with arbitrary precision and that the application of $f$ commutes with any linear function applied on the vector space. With other words this means there exists a rank-3 tensor $F$, which only depends on $A$, s.t. $f(A)_(i j) = F_(i j k) f(lambda_k)$. 91 | 92 | Since $f(A)$ is continuous in the eigenvalues and the set of square matrices with distinct eigenvalues is dense, we evaluate $f(A)$ for equal eigenvalues using a limiting process: 93 | 94 | #eq(numberlast: true)[ 95 | $f(A | lambda_1& = lambda + epsilon, lambda_2 = lambda) = 1/epsilon [epsilon f'(lambda)A - epsilon(lambda f'(lambda) - f(lambda))bb(1)] + o(epsilon)$ 96 | $ouset(-->, epsilon -> 0, &) f'(lambda)(A - lambda bb(1)) + f(lambda)bb(1)$ 97 | $=> f(A) =& f'(lambda)(A - lambda bb(1)) + f(lambda)bb(1) wide #[if $lambda$ is the only eigenvalue of $A$]$ 98 | ] 99 | 100 | So when $A$ is not diagonal $f(A)$ depends on $f'(lambda)$. In general we will find that $f(A)$ depends on $f(lambda_i), f'(lambda_i), ..., f^((m))(lambda_i)$ where $m$ is the difference of the algebraic and geometric multiplicity of $lambda_i$. 101 | 102 | 103 | == Applications and Examples in 2 dimensions 104 | === Comparison to Jordan form approach 105 | ... 106 | === Relation to Krylov subspaces 107 | ... 108 | === Generating $sin x$ from a difference equation 109 | 110 | Matrix functions have many applications, but the primary focus of this package is solving difference equations. Assuming you want to consider a multi-dimensional first-order linear recurrence, i.e. a sequence of vectors $(v_i)_(i=0)^infinity subset FF^n$ satisfying the recurrence relation $v_(i+1) = M v_i$ for some matrix $M in FF^(n times n)$. An obvious conclusion is $v_k = M^k v_0$. Using equations @func-2dim or @func-2dim-1eigval, we can express $M^k$ without any matrix multiplications. Instead we just need to compute $f(lambda) = lambda^k$ for all eigenvalues. As discussed in @ch-krylov, we can precompute $f(M)v_0$ or any other tensor contraction to $f(M)$ before we specify $k$, allowing for efficient evaluation for different or delayed values of $k$. 111 | 112 | Another neat application is the analytic continuation of $v_k$ in $k$ by setting $f(lambda) = e^(k ln(lambda))$, allowing for non-integer shifts. We apply this method in the example here by solving a difference equation for a sampled $sin$ function and then evaluating the numerically solved $sin$ between the sampled points. 113 | 114 | For the setup of the difference equation, consider the sum identity of $sin$: 115 | 116 | $ 117 | sin(x+a) = sin(x)cos(a) + cos(x)sin(a) 118 | $ 119 | 120 | For fixed $a$ $sin(x+a)$ can be expressed as a linear combination of the basis $sin x$ and $cos x$. As shown in the following derivation, $sin x$ can be written as a linear combination of $sin(x+a)$ and $sin(x+2a)$ for almost all $a$: 121 | 122 | #eq(numberlast: none)[ 123 | $sin(x) =:& alpha sin(x+a) + beta sin(x+2a)$ 124 | $=& alpha(sin(x)cos(a) + sin(a)cos(x)) + beta(sin(x)cos(2a) + sin(2a)cos(x))$ 125 | $=& sin x(alpha cos a + beta cos(2a)) + cos x(alpha sin(a) + beta sin(2a))$ 126 | $=& vec(sin x, cos x) mat(cos a, cos(2a); sin a, sin(2a)) vec(alpha, beta)$ 127 | $=> vec(alpha, beta) =& mat(cos a, cos(2a); sin a, sin(2a))^(-1) vec(1, 0)$ 128 | $ouset(=, #ref(), &) vec(2cos(a), -1)$ 129 | ] 130 | 131 | From this we can construct a difference equation which shifts $sin x$ by $a$. 132 | 133 | #eq(numberlast: true)[ 134 | $vec(sin x, sin(x-a)) =& underbrace(mat(2cos a, -1; 1, 0), =: M(a)) vec(sin(x-a), sin(x-2a))$ 135 | $=> vec(sin(x+n a), sin(x+(n-1)a)) =& M(a)^n vec(sin x, sin(x-a))$ 136 | $=> sin(x+n a) =& hat(e)_1 dot M(a)^n vec(sin x, sin(x-a))$ 137 | $=> sin(x+y) =& hat(e)_1 dot exp(y/a ln(M(a))) vec(sin x, sin(x-a))$ 138 | ] 139 | 140 | Applying the 2D recurrence formula from @func-2dim, we compute $sin(x+y)$ as follows: 141 | 142 | 143 | $ 144 | sin(x+y) ouset(=, #ref()) hat(e)_1 dot 1/(lambda_1-lambda_2)&[(f(lambda_1) - f(lambda_2))M(a) - (lambda_2 f(lambda_1) - lambda_1 f(lambda_2))bb(1)] vec(sin x, sin(x-a)) 145 | $ 146 | 147 | #eq(numberlast: none)[ 148 | $#[with ] f(lambda) =& exp(y/a ln(lambda))$ 149 | $#[and ] lambda_(1,2) =& 1/2 tr M(a) plus.minus sqrt((1/2 tr M(a))^2 - det M(a))$ 150 | $=& cos a plus.minus sqrt(cos^2 a - 1)$ 151 | $=& e^(plus.minus i a)$ 152 | $#[s.t. ] f(lambda_(1,2)) =&e^(plus.minus i y)$ 153 | ] 154 | Simplifying @sin-ab-numerical yields @sin-ab. 155 | 156 | 157 | #wrap-content( 158 | figure(image("../sinFromMFunc.svg")), 159 | align: right, 160 | )[ 161 | #box(height: 1em) 162 | 163 | The example code at #link("https://github.com/nextdorf/matrix-functions/blob/main/example.py")["/example.py"] illustrates how to do the above computation using the library. It generates the figure on the right: 164 | 165 | For my examples check out the snippets at #link("https://github.com/nextdorf/matrix-functions/tree/main/examples")["/examples/"] or at @ch-code-examples. 166 | ] 167 | 168 | 169 | == General case 170 | 171 | Similarly to the 2d-case we use the Cayley-Hamilton-theorem: $A in CC^(n times n), p_A (A) = 0$ 172 | 173 | #eq[ 174 | #nonumber($p_A (lambda) =& det(lambda bb(1) - A) = product_(k=1)^n (lambda - lambda_k) =: lambda^n - sum_(k=0)^(n-1) Lambda_k lambda^k$) 175 | $=> Lambda_k =& sum_(j_1...j_(n-k)=1,\ j_1<... 177 | $A^m =:& sum_(k=0)^(n-1) alpha_(m k) A^k$ 178 | ] 179 | 180 | As before we multiply equation @matrix-power-lin-ansatz with $A$ which will generate an $A^n$ on the rhs. That generated term is substituted using @matrix-power-lin-n, resulting in a recurrence relation for $alpha_(m k)$: 181 | 182 | #eq[ 183 | #nonumber($A^(m+1) =& sum_(k=1)^(n-1)(alpha_(m,k-1) + alpha_(m, n-1) Lambda_k)A^k + alpha_(m, n-1) Lambda_0 A^0$) 184 | $=> alpha_(m+1, 0) =& alpha_(m,n-1) Lambda_0, quad alpha_(m+1, k) = alpha_(m, k-1) + alpha_(m, n-1) Lambda_k$ 185 | ] 186 | 187 | In order to solve the recurrence equation @recurrent-ndim, note that $alpha_(m k)$ can be expressed in terms of $alpha_(m-1, k-1)$ and $alpha_(m-1, n-1)$. $alpha_(m-1, k-1)$ can be further expressed in terms of $alpha_(m-2, k-2)$ and $alpha_(m-2, n-1)$, and so forth, until eventually $alpha_(m-k, 0)$ can be expressed in terms of $alpha_(m-k-1, n-1)$. Hence, the recurrence equation @recurrent-ndim can be solved by noticing that all $alpha_(m, n-1)$ can be reduced to a function of $alpha_(m-1, n-1), alpha_(m-2, n-1), ...$. Thus, we can simplify the recurrence relation to a recurrence relation in the first argument only and fixing the second argument to $n-1$: 188 | 189 | #eq(numberlast: true)[ 190 | $alpha_(m,n-1) =& sum_(k=1)^(n-1)alpha_(m-k,n-1)Lambda_(n-k) + alpha_(m+1-n, 0)$ 191 | $=& sum_(k=1)^n alpha_(m-k,n-1)Lambda_(n-k)$ 192 | $=& sum_(k=0)^(n-1) alpha_(m-n+k,n-1)Lambda_k$ 193 | $=> 0=& alpha_(m+n, n-1) - sum_(k=0)^(n-1) alpha_(m+k,n-1)Lambda_k$ 194 | $=& (Delta^n - sum_(k=0)^(n-1) Delta^k Lambda_k) alpha_(m, n-1) wide #grid(rows: (1.25em, 0em), align: left, $#[with ] Delta alpha_(m k) = alpha_(m+1, k)$, [i.e. $Delta$ acts on the first index])$ 195 | //#[with $Delta alpha_(m k) = alpha_(m+1, k)$ i.e. $Delta$ acts on the first index]$ 196 | $=& p_A (Delta) alpha_(m, n-1)$ 197 | $=& product_(k=1)^r (Delta - lambda_k)^(mu_k) alpha_(m, n-1)\ &#[with $mu_k$ being the algebraic multiplicity of $lambda_k$]$ 198 | ] 199 | 200 | The general solution is $alpha_(m, n-1) = sum_(k=1)^r lambda_k^m p_k (m)$ with $p_k$ being an arbitrary polynomial of degree $mu_k-1$. 201 | 202 | Proof: 203 | 204 | #eq(numberlast: none)[ 205 | $#[Induction Start: ] 0 =& (Delta - lambda)c_n => c_n =lambda c_(n-1) = lambda^n c_0$ 206 | $#[Assume ] 0 =& (Delta - lambda)^m lambda^n sum_(k=0)^(m-1) c_k n^k quad forall c_0, ..., c_(m-1)$ 207 | $=> (Delta - lambda)^(m+1) lambda^n sum_(k=0)^m c_k n^k =& (Delta - lambda)^m (Delta - lambda) lambda^n c_m n^m + (Delta - lambda)(Delta - lambda)^m lambda^n sum_(k=0)^(m-1) c_k n^k$ 208 | $=& (Delta - lambda)^m ((n+1)^m - n^m) lambda^(n+1) c_m$ 209 | $=& (Delta - lambda)^m lambda^n sum_(k=0)^(m-1) binom(m, k) lambda c_m n^k$ 210 | $=& 0$ 211 | $=> 0 =& (Delta - lambda)^m lambda^n sum_(k=0)^m' c_k n^k quad forall c_0, ..., c_(m'), m > m'$ 212 | ] 213 | 214 | What is left to show is that the above solution is general. Considering $(Delta - lambda)^n$ as a linear operator, the solution space we are interested in is effectively the operator's kernel. Thus, the above $n$-dimensional solution is general if the dimension of the operator's kernel is $n$ as well. 215 | 216 | Now consider $overline(c)_n = (Delta - lambda) c_n => c_(n+1) = overline(c)_n + lambda c_n = sum_(k=0)^n lambda^k overline(c)_(n-k) + lambda^(n+1) c_0$ 217 | 218 | Since the solution of $c_(n+1)$ is linear in $overline(c)_n$ we can consider the dimension of the solution space if $overline(c)_n$ is itself a solution of a similar equation. The solution space of $0 = (Delta - lambda_1) c_n^((1))$ is 1-dimensional. Therefore the solution space of $c_n^((m)) = (Delta - lambda_1) c_n^((m+1))$ is either of the same dimension as the solution space of $c_n^((m))$ or the dimension increases by 1. So the dimension of the solution space of $p_A (Delta) alpha_(m, n-1)$ is at most $n$. Since $sum_(k=1)^r lambda_k^m p_k(m)$ is a $sum_(k=1)^r dim(p_k) = n$ dimensional solution it is the general solution. 219 | 220 | #place(right, $qed$) 221 | \ 222 | #eq[ 223 | $alpha_(m, n-1) =& sum_(k=1)^r sum_(l=0)^(min(mu_k-1, m)) overline(beta)_(k l) lambda_k^(m-l) m!/((m-l)!) = sum_(k=1)^r sum_(l=0)^(min(mu_k-1, m)) overline(beta)_(k l) partial_lambda^l lambda_k^m bar_(lambda=lambda_k) =: sum_(k=1)^n beta_k lambda_k^((m))$ 224 | #nonumber($beta_k =& (overline(beta)_10, ..., overline(beta)_(1,mu_1-1), overline(beta)_20, ..., overline(beta)_(2,mu_2-1), ..., overline(beta)_(r 0), ..., overline(beta)_(r,mu_r-1))_k$) 225 | #nonumber($lambda_k^((m)) =& (lambda_1^m, ..., partial_(lambda_1)^(mu_1-1) lambda_1^m, lambda_2^m, ..., partial_(lambda_2)^(mu_2-1) lambda_2^m, ..., lambda_r^m, ..., partial_(lambda_r)^(mu_r-1) lambda_r^m)_k$) 226 | ] 227 | 228 | For $m delta_(m,n-1) = beta_k lambda_k^((m))$ 229 | 230 | $ 231 | => arrow(beta) perp& sum_(k=1)^n hat(e)_k lambda_k^m =: arrow(lambda)^((m)) #[ for ] m = 0 ... n-2, quad #[and ] arrow(beta) dot arrow(lambda)^(n-1) = 1 232 | $ 233 | 234 | // There are several ways to construct $beta$. Here we will list two ways. The first way relies on some properties of the determinant. It is the approach used in an older version of this package and is often more useful for analytical calculations. It introduces however precision-errors for numerical calculations and becomes unstable for large dimensions and/or eigenvalues. 235 | 236 | // This is why in a more recent version, the change was made to directly finding the orthogonal complement of the space spanned by $arrow(lambda)^((0)), ..., arrow(lambda)^((n-2))$. We expect the orthogonal complement to be of dimension 1, s.t. $beta$'s relation to $arrow(lambda)^((n-1))$ uniquely defines $beta$. In both cases we rely on the fact that $arrow(lambda)^((0)), ..., arrow(lambda)^((n-1))$ are linear independent. 237 | 238 | // #list([ 239 | // *Construction via the determinant* 240 | 241 | If the rows (or columns) of a matrix are linearly dependent then the matrix is not invertable and its determinant will vanish. On the flip side if the entries are linear independent then the matrix's determinant will be non-zero. Thus, for a set of independent vectors ${w_(k+1), ..., w_n}$ an orthogonal tensor $T$ with 242 | 243 | #nonumber($T_(m_1...m_k) = det(hat(e)_(m_1) | ... | hat(e)_(m_k) | w_(k+1) | ... | w_n)$) 244 | 245 | can be constructed. If contracted with arbitrary vectors $(w_1, ..., w_k)$ then the orthogonal tensor will yield $det(w_1 | ... | w_n)$ which will be 0 iff the vectors $w_1, ..., w_n$ are linear dependent. This means that $T$ is non-zero, and means in particular for $k=1$ that $T$ becomes the tensor perpendicular to the vectors $w_2, ..., w_n$ 246 | 247 | Since $arrow(lambda)^((0)), ..., arrow(lambda)^((n-1))$ are linear independent, we find: 248 | 249 | #math.equation(block: true, /*numbering: n => "("+[#n]+"a)"*/)[ 250 | $beta_k =& det(hat(e)_k | arrow(lambda)^((n-2)) | arrow(lambda)^((n-3)) | ... | arrow(lambda)^((0))) / det(arrow(lambda)^((n-1)) | ... | arrow(lambda)^((0)))$ 251 | ] 252 | // ], [ 253 | // *Subtracting the span* 254 | 255 | // As elegant the above approach might be, for numerical problems it turns out to be more stable to just spanning the embedding $n$-dimensional vector space by some vectors and then subtracting the spanned space from the embedding space. The subtracted vectors will then only span the orthogonal complement: 256 | 257 | // Consider some arbitrary vectors $(v_1, v_2, ...)$ with $"span"(v_1, v_2, ...) = FF^n$, and define: 258 | 259 | // #nonumber($v'_i =& v_i - sum_(m=0)^(n-2) arrow(lambda)^((m)) (arrow(lambda)^((m)) dot v_i)/(arrow(lambda)^((m)) dot arrow(lambda)^((m)))$) 260 | 261 | // #math.equation(block: true, numbering: n => "("+[#(n - 1)]+"b)")[ 262 | // $=> "span"(arrow(beta)) =& "span"(v'_1, v'_2, ...)$ 263 | // ] 264 | 265 | // The condition $arrow(beta) dot arrow(lambda)^((n-1)) ouset(=,!) 1$ fixes the scale of $beta$. 266 | // ]) 267 | // #counter(math.equation).update(n => n - 1) 268 | 269 | @recurrent-ndim yields for $alpha_(m k)$: 270 | 271 | $ 272 | alpha_(m k) = sum_(j=1)^(k+1) alpha_(m-j, n-1) Lambda_(k+1-j) = sum_(j=1)^(k+1) arrow(beta) dot arrow(lambda)^((m-j)) Lambda_(k+1-j) 273 | $ 274 | 275 | As in the two dimensional case this defines $f(A)$ if $f$ is analytic in the eigenvalues of $A$: 276 | 277 | #eq[ 278 | $A^m =& sum_(k=0) A^k sum_(j=1)^(k+1) Lambda_(k+1-j) sum_(l=1)^r sum_(p=0)^(min(mu_l-1, m-j)) overline(beta)_(l p) lambda_l^(m-j-p) (m-j)!/((m-j-p)!)$ 279 | #nonumber($f(A) =& sum_(k=0) A^k sum_(j=1)^(k+1) Lambda_(k+1-j) sum_(l=1)^r sum_(p=0)^(mu_l-1) overline(beta)_(l p) partial_(lambda_l)^p lambda_l^(-j) f(lambda_l)$) 280 | $=& sum_(k=0) A^k sum_(j=1)^(k+1) Lambda_(k+1-j) sum_(l=1)^r sum_(p=0)^(mu_l-1) overline(beta)_(l p) sum_(q=0)^p binom(p, q) (-1)^(p-q) (j-1+p-q)!/((j-1)!) lambda_l^(-j-p+q) f^((q))(lambda_l)$ 281 | ] 282 | 283 | Since $f(A)$ is linear in $A^k$ and $f^((q))(lambda_l)$ we can define the tensors $phi$ and $phi.alt$, s.t. 284 | 285 | $ 286 | f(A) = sum_(i=0)^(n-1) sum_(j=1)^r sum_(k=0)^(mu_j-1) phi_(i j)^((k)) A^i f^((k))(lambda_j), quad phi.alt_(i j k)^((l)) = sum_(m=0)^(n-1) phi_(m k)^((l))(A^m)_(i j) 287 | $ 288 | #eq(numberlast: true)[ 289 | $phi_(k l)^((q)) =& partial_(x_q) partial_(y_l) sum_(j=1)^(k+1) Lambda_(k+1-j) sum_(l'=1)^r sum_(p=0)^(mu_(l')-1) overline(beta)_(l' p) sum_(q'=0)^p binom(p, q') (-1)^(p-q') (j-1+p-q')!/((j-1)!) lambda_(l')^(-j-p+q') x_(q') y_(l')$ 290 | $=& sum_(j=1)^(k+1) Lambda_(k+1-j) sum_(p=q)^(mu_l-1) overline(beta)_(l p) binom(p, q) (-1)^(p-q) (j-1+p-q)!/((j-1)!) lambda_l^(-j-p+q)$ 291 | ] 292 | 293 | === Sanity check for $n=2$ 294 | 295 | #list( 296 | [ 297 | $lambda_1 != lambda_2 => mu_1 = mu_2 = 1, r = 2:$ 298 | 299 | #math.equation(block: true, numbering: _ => "", [ 300 | // $lambda_k^((m)) =& lambda_k^m, quad beta_(dot 0) = binom(0, 1), quad Lambda_0 = -lambda^2, quad Lambda_1 = 2lambda$\ 301 | $lambda_k^((m)) =& lambda_k^m, quad beta_(dot 0) = 1/(lambda_1 - lambda_2)binom(1, -1), quad Lambda_0 = -lambda_1 lambda_2_, quad Lambda_1 = lambda_1 + lambda_2$\ 302 | $phi^((0)) =& mat(Lambda_0 overline(beta)_10 slash lambda_1, Lambda_0 overline(beta)_20 slash lambda_2; Lambda_0 overline(beta)_10 slash lambda_1^2 + Lambda_1 overline(beta)_10 slash lambda_1, Lambda_0 overline(beta)_20 slash lambda_2^2 + Lambda_1 overline(beta)_20 slash lambda_2) = 1/(lambda_1 - lambda_2) mat(-lambda_2, lambda_1; 1, -1)$\ 303 | $=> f(A) =& 1/(lambda_1 - lambda_2)[(f(lambda_1) - f(lambda_2))A - (lambda_2 f(lambda_1) - lambda_1 f(lambda_2))bb(1)] wide checkmark$ 304 | ]) 305 | ], [ 306 | $lambda_1 = lambda_2 = lambda => mu_1 = 2, r = 1:$ 307 | 308 | #math.equation(block: true, numbering: _ => "", [ 309 | $lambda_k^((m)) =& (lambda^m, m lambda^(m-1))_k, quad beta_(dot 1) = binom(0, 1), quad Lambda_0 = -lambda^2, quad Lambda_1 = 2lambda$\ 310 | $phi^((0)) =& mat(Lambda_0(overline(beta)_10 slash lambda - overline(beta)_11 slash lambda^2); Lambda_1(overline(beta)_10 slash lambda - overline(beta)_11 slash lambda^2) + Lambda_0(overline(beta)_10 slash lambda^2 - 2overline(beta)_11 slash lambda^3)) = mat(1; 0)$\ 311 | $phi^((1)) =& mat(Lambda_0 overline(beta)_11 slash lambda; Lambda_1 overline(beta)_11 slash lambda + Lambda_0 overline(beta)_11 slash lambda^2) = mat(-lambda; 1)$\ 312 | $=> f(A) =& f(lambda)bb(1) + f'(lambda)(A - lambda bb(1)) wide checkmark$ 313 | ]) 314 | ]) 315 | 316 | // == Vector space properties 317 | // #let sdot(dual, vec) = $angle.l dual, vec angle.r$ 318 | // Scalar product: 319 | // $ 320 | // sdot(A, B) = 1/n tr(A^dagger B), quad norm(A) = sqrt(sdot(A, A)) 321 | // $ 322 | // ONS: 323 | // $ 324 | // N_0 = bb(1), quad N_(k+1) = (A N_k - sum_(j=0)^k N_j sdot(N_j, A N_k))/norm(A N_k - sum_(j=0)^k N_j sdot(N_j, A N_k)) 325 | // $ 326 | 327 | = Code Examples 328 | == Example from @ch-sin-generation 329 | 330 | #sourcefile(read("../example.py"), file: "example.py",lang: "python") 331 | #figure(image("../sinFromMFunc.svg")), 332 | 333 | == More advanced Fcuntion Continution from random Samples 334 | 335 | #sourcefile(read("../examples/many_frequencies.py"), file: "example.py",lang: "python") 336 | #figure(image("../examples/many_frequencies.svg")) 337 | 338 | // #show: appendices 339 | 340 | -------------------------------------------------------------------------------- /typst/template.typ: -------------------------------------------------------------------------------- 1 | // Based on preview/arkheion:0.1.0, https://github.com/mgoulao/arkheion/tree/main, https://typst.app/universe/package/arkheion/, MIT liensed 2 | 3 | #let minimal-document( 4 | title: "", 5 | abstract: [], 6 | keywords: (), 7 | authors: (), 8 | date: none, 9 | body, 10 | ) = { 11 | // Set the document's basic properties. 12 | set document(author: authors.map(a => a.name), title: title) 13 | set page( 14 | margin: (left: 12.5mm, right: 12.5mm, top: 12.5mm, bottom: 12.5mm), 15 | numbering: "1", 16 | number-align: center, 17 | ) 18 | set text(font: "New Computer Modern", lang: "en") 19 | show math.equation: set text(weight: 400) 20 | show math.equation: set block(spacing: 0.65em) 21 | set math.equation( 22 | numbering: "(1)", 23 | supplement: none, 24 | ) 25 | set heading(numbering: "1.1") 26 | 27 | let refcol = color.hsl(245deg, 80%, 45%) 28 | show link: set text(refcol) 29 | show ref: it => { 30 | set text(refcol) 31 | if it.element != none and it.element.func() == math.equation { 32 | link(it.target)[(#it)] 33 | } else { 34 | it 35 | } 36 | } 37 | 38 | // Set run-in subheadings, starting at level 4. 39 | show heading: it => { 40 | // H1 and H2 41 | if it.level == 1 { 42 | pad( 43 | top: .5em, 44 | // bottom: 1em, 45 | it 46 | ) 47 | } 48 | else if it.level == 2 { 49 | pad( 50 | top: .5em, 51 | bottom: .25em, 52 | it 53 | ) 54 | } 55 | else if it.level > 3 { 56 | text(11pt, weight: "bold", it.body + " ") 57 | } else { 58 | it 59 | } 60 | } 61 | 62 | line(length: 100%, stroke: 2pt) 63 | // Title row. 64 | pad( 65 | bottom: 4pt, 66 | top: 4pt, 67 | align(center)[ 68 | #block(text(weight: 500, 1.75em, title)) 69 | #v(1em, weak: true) 70 | ] 71 | ) 72 | line(length: 100%, stroke: 2pt) 73 | 74 | // Author information. 75 | pad( 76 | top: 0.5em, 77 | x: 2em, 78 | grid( 79 | columns: (1fr,) * calc.min(3, authors.len()), 80 | gutter: 1em, 81 | ..authors.map(author => align(center)[ 82 | *#author.name* \ 83 | #if author.keys().contains("subtitle") { 84 | author.subtitle 85 | } 86 | ]), 87 | ), 88 | ) 89 | 90 | // align(center)[#date] 91 | 92 | // Table of Contents. 93 | show outline.entry: it => { 94 | if it.level <= 2 { 95 | it = strong(it) 96 | } 97 | set text(refcol) 98 | it 99 | } 100 | outline(indent: auto) 101 | 102 | // Abstract. 103 | if abstract != [] { 104 | pad( 105 | x: 3em, 106 | top: 1em, 107 | bottom: 0.4em, 108 | align(center)[ 109 | #heading( 110 | outlined: false, 111 | numbering: none, 112 | text(0.85em, smallcaps[Abstract]), 113 | ) 114 | #set par(justify: true) 115 | #set text(hyphenate: false) 116 | 117 | #abstract 118 | ], 119 | ) 120 | } 121 | 122 | // // Keywords 123 | // if keywords.len() > 0 { 124 | // [*_Keywords_* #h(0.3cm)] + keywords.map(str).join(" · ") 125 | // } 126 | // Main body. 127 | set par(justify: true) 128 | set text(hyphenate: false) 129 | 130 | body 131 | } 132 | 133 | #let appendices(body) = { 134 | counter(heading).update(0) 135 | counter("appendices").update(1) 136 | 137 | set heading( 138 | numbering: (..nums) => { 139 | let vals = nums.pos() 140 | let value = "ABCDEFGHIJ".at(vals.at(0) - 1) 141 | if vals.len() == 1 { 142 | return "APPENDIX " + value 143 | } 144 | else { 145 | return value + "." + nums.pos().slice(1).map(str).join(".") 146 | } 147 | } 148 | ); 149 | [#pagebreak() #body] 150 | } 151 | 152 | --------------------------------------------------------------------------------