├── .gitattributes ├── .github └── workflows │ └── publish-to-pypi.yml ├── .gitignore ├── ChangeLog.md ├── LICENSE.md ├── README.md ├── pyproject.toml ├── requirements.txt ├── setup.py └── source └── PSID ├── IPSID.py ├── LSSM.py ├── MatHelper.py ├── PSID.py ├── PrepModel.py ├── __init__.py ├── evaluation.py ├── example ├── IPSID_example.py ├── IPSID_tutorial.ipynb ├── PSID_example.py ├── PSID_tutorial.ipynb ├── __init__.py ├── sample_model.mat ├── sample_model_IPSID.mat └── sample_model_IPSID_add_step.mat └── tests └── test_PrepModel.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Basic .gitattributes for a python repo. 2 | 3 | # Source files 4 | # ============ 5 | *.pxd text diff=python 6 | *.py text diff=python 7 | *.py3 text diff=python 8 | *.pyc text diff=python 9 | *.pyd text diff=python 10 | *.pyo text diff=python 11 | *.pyw text diff=python 12 | *.pyx text diff=python 13 | *.pyz text diff=python 14 | 15 | # Binary files 16 | # ============ 17 | *.db binary 18 | *.p binary 19 | *.pkl binary 20 | *.pickle binary 21 | *.pyc binary 22 | *.pyd binary 23 | *.pyo binary 24 | 25 | # Jupyter notebook 26 | *.ipynb text 27 | 28 | # Note: .db, .p, and .pkl files are associated 29 | # with the python modules ``pickle``, ``dbm.*``, 30 | # ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` 31 | # (among others). -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build distribution 📦 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.x" 16 | - name: Install pypa/build 17 | run: >- 18 | python3 -m 19 | pip install 20 | build 21 | --user 22 | - name: Build a binary wheel and a source tarball 23 | run: python3 -m build 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: python-package-distributions 28 | path: dist/ 29 | 30 | publish-to-pypi: 31 | name: >- 32 | Publish Python 🐍 distribution 📦 to PyPI 33 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 34 | needs: 35 | - build 36 | runs-on: ubuntu-latest 37 | environment: 38 | name: pypi 39 | url: https://pypi.org/p/PSID # Replace with your PyPI project name 40 | permissions: 41 | id-token: write # IMPORTANT: mandatory for trusted publishing 42 | 43 | steps: 44 | - name: Download all the dists 45 | uses: actions/download-artifact@v4 46 | with: 47 | name: python-package-distributions 48 | path: dist/ 49 | - name: Publish distribution 📦 to PyPI 50 | uses: pypa/gh-action-pypi-publish@release/v1 51 | 52 | github-release: 53 | name: >- 54 | Sign the Python 🐍 distribution 📦 with Sigstore 55 | and upload them to GitHub Release 56 | needs: 57 | - publish-to-pypi 58 | runs-on: ubuntu-latest 59 | 60 | permissions: 61 | contents: write # IMPORTANT: mandatory for making GitHub Releases 62 | id-token: write # IMPORTANT: mandatory for sigstore 63 | 64 | steps: 65 | - name: Download all the dists 66 | uses: actions/download-artifact@v4 67 | with: 68 | name: python-package-distributions 69 | path: dist/ 70 | - name: Sign the dists with Sigstore 71 | uses: sigstore/gh-action-sigstore-python@v3.0.0 72 | with: 73 | inputs: >- 74 | ./dist/*.tar.gz 75 | ./dist/*.whl 76 | - name: Create GitHub Release 77 | env: 78 | GITHUB_TOKEN: ${{ github.token }} 79 | run: >- 80 | gh release create 81 | '${{ github.ref_name }}' 82 | --repo '${{ github.repository }}' 83 | --notes "" 84 | - name: Upload artifact signatures to GitHub Release 85 | env: 86 | GITHUB_TOKEN: ${{ github.token }} 87 | # Upload to GitHub Release using the `gh` CLI. 88 | # `dist/` contains the built packages, and the 89 | # sigstore-produced signatures and certificates. 90 | run: >- 91 | gh release upload 92 | '${{ github.ref_name }}' dist/** 93 | --repo '${{ github.repository }}' 94 | 95 | publish-to-testpypi: 96 | name: Publish Python 🐍 distribution 📦 to TestPyPI 97 | needs: 98 | - build 99 | runs-on: ubuntu-latest 100 | if: false 101 | 102 | environment: 103 | name: testpypi 104 | url: https://test.pypi.org/p/PSID 105 | 106 | permissions: 107 | id-token: write # IMPORTANT: mandatory for trusted publishing 108 | 109 | steps: 110 | - name: Download all the dists 111 | uses: actions/download-artifact@v4 112 | with: 113 | name: python-package-distributions 114 | path: dist/ 115 | - name: Publish distribution 📦 to TestPyPI 116 | uses: pypa/gh-action-pypi-publish@release/v1 117 | with: 118 | repository-url: https://test.pypi.org/legacy/ 119 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # celery beat schedule file 97 | celerybeat-schedule 98 | 99 | # SageMath parsed files 100 | *.sage.py 101 | 102 | # Environments 103 | .env 104 | .venv 105 | env/ 106 | venv/ 107 | ENV/ 108 | env.bak/ 109 | venv.bak/ 110 | 111 | # Spyder project settings 112 | .spyderproject 113 | .spyproject 114 | 115 | # Rope project settings 116 | .ropeproject 117 | 118 | # mkdocs documentation 119 | /site 120 | 121 | # mypy 122 | .mypy_cache/ 123 | .dmypy.json 124 | dmypy.json 125 | 126 | # Pyre type checker 127 | .pyre/ -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | Versioning follows [semver](https://semver.org/). 3 | 4 | - v1.2.6: 5 | - Fixes minor error in variable init for trial-based ISID. 6 | - v1.2.5: 7 | - Fixes minor `numpy.eye` error that was thrown for unstable learned models. 8 | - v1.2.0: 9 | - Adds version with support for external input (i.e., IPSID). 10 | - v1.1.0: 11 | - Automatically does the necessary mean-removal preprocessing for input neural/behavior data. Automatically adds back the learned means to predicted signals. 12 | - v1.0.6: 13 | - Adds option to return the state prediction/filtering error covariances from the Kalman filter. 14 | - v1.0.5: 15 | - Fixes the n1=0 case for trial based usage. 16 | - Adds graceful handling of data segments that are too short. 17 | - v1.0.4: 18 | - Updates readme 19 | - v1.0.3: 20 | - Fixes readme links on https://pypi.org/project/PSID 21 | - v1.0.2: 22 | - Changes path to example script. Adds jupyter notebook version. 23 | - v1.0.1: 24 | - Updates [source/PSID/example/PSID_example.py](https://github.com/ShanechiLab/PyPSID/blob/main/source/PSID/example/PSID_example.py) with smaller file by generating data in code. 25 | - v1.0.0: 26 | - Adds PSID to pip. -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This software is Copyright © 2020 The University of Southern California. All Rights Reserved. 2 | 3 | Permission to use, copy, modify, and distribute this software and its documentation for educational, research 4 | and non-profit purposes, without fee, and without a written agreement is hereby granted, provided that the 5 | above copyright notice, this paragraph and the following three paragraphs appear in all copies. 6 | 7 | Permission to make commercial use of this software may be obtained by contacting: 8 | USC Stevens Center for Innovation 9 | University of Southern California 10 | 1150 S. Olive Street, Suite 2300 11 | Los Angeles, CA 90115, USA 12 | 13 | This software program and documentation are copyrighted by The University of Southern California. The software 14 | program and documentation are supplied "as is", without any accompanying services from USC. USC does not warrant 15 | that the operation of the program will be uninterrupted or error-free. The end-user understands that the program 16 | was developed for research purposes and is advised not to rely exclusively on the program for any reason. 17 | 18 | IN NO EVENT SHALL THE UNIVERSITY OF SOUTHERN CALIFORNIA BE LIABLE TO ANY PARTY FOR 19 | DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST 20 | PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF THE 21 | UNIVERSITY OF SOUTHERN CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH 22 | DAMAGE. THE UNIVERSITY OF SOUTHERN CALIFORNIA SPECIFICALLY DISCLAIMS ANY 23 | WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 24 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED 25 | HEREUNDER IS ON AN "AS IS" BASIS, AND THE UNIVERSITY OF SOUTHERN CALIFORNIA HAS NO 26 | OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR 27 | MODIFICATIONS. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | - [(I)PSID: (Input) Preferential subspace identification \[Python implementation\]](#ipsid-input-preferential-subspace-identification--python-implementation) 2 | - [Publications](#publications) 3 | - [PSID](#psid) 4 | - [IPSID](#ipsid) 5 | - [Usage guide](#usage-guide) 6 | - [Installation](#installation) 7 | - [Initialization](#initialization) 8 | - [Main learning function](#main-learning-function) 9 | - [Extracting latent states using learned model](#extracting-latent-states-using-learned-model) 10 | - [Required preprocessing](#required-preprocessing) 11 | - [Choosing the hyperparameters](#choosing-the-hyperparameters) 12 | - [How to pick the state dimensions nx and n1?](#how-to-pick-the-state-dimensions-nx-and-n1) 13 | - [How to pick the horizon `i`?](#how-to-pick-the-horizon-i) 14 | - [Usage examples](#usage-examples) 15 | - [PSID](#psid-1) 16 | - [IPSID](#ipsid-1) 17 | - [Change Log](#change-log) 18 | - [Licence](#licence) 19 | 20 | # (I)PSID: (Input) Preferential subspace identification
[Python implementation] 21 | 22 | For MATLAB implementation see http://github.com/ShanechiLab/PSID 23 | 24 | Given signals y_t (e.g. neural signals) and z_t (e.g behavior), PSID learns a dynamic model for y_t while prioritizing the dynamics that are relevant to z_t. 25 | 26 | IPSID is an extension of PSID that also supports taking a third signal u_t (e.g., task instructions) that is simultaneously measured with y_t. In the learned dynamical model, u_t plays the role of input to the latent states. 27 | 28 | # Publications 29 | ## PSID 30 | For the derivation of PSID and results in real neural data see the paper below. 31 | 32 | Omid G. Sani, Hamidreza Abbaspourazad, Yan T. Wong, Bijan Pesaran, Maryam M. Shanechi. *Modeling behaviorally relevant neural dynamics enabled by preferential subspace identification*. Nature Neuroscience, 24, 140–149 (2021). https://doi.org/10.1038/s41593-020-00733-0 33 | 34 | View-only full-text link: https://rdcu.be/b993t 35 | 36 | Original preprint: https://doi.org/10.1101/808154 37 | 38 | You can also find a summary of the paper in the following Twitter thread: 39 | https://twitter.com/MaryamShanechi/status/1325835609345122304 40 | 41 | ## IPSID 42 | For the derivation of IPSID and results in real neural data see the paper below. 43 | 44 | Parsa Vahidi*, Omid G. Sani*, Maryam M. Shanechi. *Modeling and dissociation of intrinsic and input-driven neural population dynamics underlying behavior*. PNAS (2024). https://doi.org/10.1073/pnas.2212887121 45 | 46 | 47 | # Usage guide 48 | ## Installation 49 | Download the source code from [the GitHub repository](https://github.com/ShanechiLab/PyPSID), or install PSID in your Python environment using pip, by running: 50 | ``` 51 | pip install PSID --upgrade 52 | ``` 53 | You can find the usage license in [LICENSE.md](https://github.com/ShanechiLab/PyPSID/blob/main/LICENSE.md). 54 | 55 | ## Initialization 56 | Import the PSID module. 57 | ``` 58 | import PSID 59 | ``` 60 | 61 | ## Main learning function 62 | The main functions for the Python implementation are the follwing: 63 | - For PSID: [source/PSID/PSID.py](https://github.com/ShanechiLab/PyPSID/blob/main/source/PSID/PSID.py) -> the function called PSID 64 | - For IPSID [source/PSID/IPSID.py](https://github.com/ShanechiLab/PyPSID/blob/main/source/PSID/IPSID.py) -> the function called IPSID 65 | 66 | A complete usage guide is available in as comments in each function. The following shows example use cases: 67 | ``` 68 | idSys = PSID.PSID(y, z, nx, n1, i) 69 | # Or, if modeling effect of input u is also of interest 70 | idSys = PSID.IPSID(y, z, u, nx, n1, i) 71 | ``` 72 | Inputs: 73 | - y and z are time x dimension matrices with neural (e.g. LFP signal powers or spike counts) and behavioral data (e.g. joint angles, hand position, etc), respectively. 74 | - IPSID also takes u as an input, which is a time x dimension matrix, containing the measured input data. 75 | - nx is the total number of latent states to be identified. 76 | - n1 is the number of states that are going to be dedicated to behaviorally relevant dynamics. 77 | - i is the subspace horizon used for modeling. 78 | 79 | Output: 80 | - idSys: an LSSM object containing all model parameters (A, Cy, Cz, etc). For a full list see the code. 81 | 82 | ## Extracting latent states using learned model 83 | Once a model is learned using (I)PSID, you can apply the model to new data (i.e. run the associated Kalman filter) as follows: 84 | ``` 85 | zPred, yPred, xPred = idSys.predict(y) 86 | # Or, for IPSID: 87 | zPred, yPred, xPred = idSys.predict(y, u) 88 | ``` 89 | Input: 90 | - y: neural activity time series (time x dimension) 91 | - [For IPSID] u: input time series (time x dimension) 92 | 93 | Outputs: 94 | - zPred: one-step ahead prediction of behavior (if any) 95 | - yPred: one-step ahead prediction of neural activity 96 | - xPred: Extracted latent state 97 | 98 | ## Required preprocessing 99 | - Repeated data dimensions (e.g., two identical neurons) can cause issues for the learning. Remove repeated data dimensions as a preprocessing and repeat predictions as needed to reproduce prediction of repeated data dimensions. 100 | - A required preprocessing when using (I)PSID is to remove the mean of neural/behavior/input signals and if needed, add them back to neural/behavior predictions after learning the model. Starting from version 1.1.0, Python (I)PSID and MATLAB PSID libraries automatically do this by default so that users won't need to worry about it. Please update to the latest version if you are using an older version. 101 | 102 | ## Choosing the hyperparameters 103 | ### How to pick the state dimensions nx and n1? 104 | nx determines the total dimension of the latent state and n1 determines how many of those dimensions will be prioritizing the inclusion of behaviorally relevant neural dynamics (i.e. will be extracted using stage 1 of (I)PSID). So the values that you would select for these hyperparameters depend on the goal of modeling and on the data. Some examples use cases are: 105 | 106 | If you want to perform dimension reduction, nx will be your desired target dimension. For example, to reduce dimension to 2 to plot low-dimensional visualizations of neural activity, you would use nx=2. Now if you want to reduce dimension while preserving as much behaviorally relevant neural dynamics as possible, you would use n1=nx. 107 | If you want to find the best fit to data overall, you can perform a grid search over values of nx and n1 and pick the value that achieves the best performance metric in the training data. For example, you could pick the nx and n1 pair that achieves the best cross-validated behavior decoding in an inner-cross-validation within the training data. 108 | 109 | ### How to pick the horizon `i`? 110 | The horizon `i` does not affect the model structure and only affects the intermediate linear algebra operations that (I)PSID performs during the learning of the model. Nevertheless, different values of `i` may have different model learning performance. `i` needs to be at least 2, but also also determines the maximum n1 and nx that can be used per: 111 | 112 | ``` 113 | n1 <= nz * i 114 | nx <= ny * i 115 | ``` 116 | 117 | So if you have a low dimensional y_k or z_k (small ny or nz), you typically would choose larger values for `i`, and vice versa. It is also possible to select the best performing `i` via an inner cross-validation approach similar to nx and n1 above. Overall, since `i` affects the learning performance, it is important for reproducibility that the `i` that was used is reported. 118 | 119 | For more information, see the notebook(s) referenced in the next section. 120 | 121 | # Usage examples 122 | ## PSID 123 | Example code for running PSID is provided in 124 | [source/example/PSID_example.py](https://github.com/ShanechiLab/PyPSID/blob/main/source/PSID/example/PSID_example.py) 125 | This script performs PSID model identification and visualizes the learned eigenvalues similar to in Supplementary Fig 1 in (Sani et al, 2021). 126 | 127 | The following notebook also contains some examples along with more descriptions: 128 | [source/example/PSID_tutorial.ipynb](https://github.com/ShanechiLab/PyPSID/blob/main/source/PSID/example/PSID_tutorial.ipynb) 129 | 130 | ## IPSID 131 | Example code for running IPSID is provided in 132 | [source/example/IPSID_example.py](https://github.com/ShanechiLab/PyPSID/blob/main/source/PSID/example/IPSID_example.py) 133 | This script performs IPSID model identification and visualizes the learned eigenvalues similar to in Fig. 2A in (Vahidi, Sani, et al, 2024). 134 | 135 | The following notebook also contains some examples along with more descriptions: 136 | [source/example/IPSID_tutorial.ipynb](https://github.com/ShanechiLab/PyPSID/blob/main/source/PSID/example/IPSID_tutorial.ipynb) 137 | 138 | # Change Log 139 | You can see the change log in [ChangeLog.md](https://github.com/ShanechiLab/PyPSID/blob/main/ChangeLog.md) 140 | 141 | # Licence 142 | Copyright (c) 2020 University of Southern California 143 | See full notice in [LICENSE.md](https://github.com/ShanechiLab/PyPSID/blob/main/LICENSE.md) 144 | Omid G. Sani, Parsa Vahidi and Maryam M. Shanechi 145 | Shanechi Lab, University of Southern California 146 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "PSID" 7 | version = "1.2.6" 8 | authors = [ 9 | {name = "Omid Sani", email = "omidsani@gmail.com"}, 10 | ] 11 | description = "Python implementation for preferential subspace identification (PSID)" 12 | requires-python = ">=3.10" 13 | classifiers = [ 14 | "Programming Language :: Python :: 3", 15 | "Operating System :: OS Independent", 16 | ] 17 | dependencies = [ 18 | "numpy", 19 | "scipy", 20 | "scikit-learn", 21 | "matplotlib", 22 | "h5py" 23 | ] 24 | dynamic = ["readme"] 25 | 26 | [tool.setuptools.dynamic] 27 | readme = {file = ["README.md"], content-type = "text/markdown"} 28 | 29 | [tool.setuptools.packages.find] 30 | where = ["source"] # list of folders that contain the packages (["."] by default) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | scipy 3 | scikit-learn # sklearn 4 | matplotlib 5 | h5py -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Release tutorial: 2 | # https://packaging.python.org/tutorials/packaging-projects/ 3 | # Old version: 4 | # pip install setuptools wheel twine 5 | # python setup.py sdist bdist_wheel 6 | # New version: 7 | # python -m build 8 | # Then run the following to upload to PyPI 9 | # python -m twine upload --repository testpypi dist/* 10 | # pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple PSID --upgrade 11 | # python -m twine upload --repository pypi dist/* 12 | 13 | 14 | import setuptools, os 15 | 16 | # Get the directory of the setup.py file 17 | dir_path = os.path.dirname(os.path.realpath(__file__)) 18 | base_dir = os.path.join(dir_path) 19 | 20 | # requirements_file_path = os.path.join(base_dir, 'requirements.txt') 21 | # with open(requirements_file_path, "r", encoding="utf-8") as fh: 22 | # requirements = fh.read().split('\n') 23 | 24 | readme_file_path = os.path.join(base_dir, "README.md") 25 | with open(readme_file_path, "r", encoding="utf-8") as fh: 26 | long_description = fh.read() 27 | 28 | setuptools.setup( 29 | name="PSID", 30 | version="1.2.5", 31 | author="Omid Sani", 32 | author_email="omidsani@gmail.com", 33 | description="Python implementation for preferential subspace identification (PSID)", 34 | long_description=long_description, 35 | long_description_content_type="text/markdown", 36 | url="https://github.com/ShanechiLab/PyPSID", 37 | packages=setuptools.find_packages(where="source"), 38 | package_dir={"": "source"}, 39 | package_data={"PSID": ["*.mat"]}, 40 | classifiers=[ 41 | "Programming Language :: Python :: 3", 42 | "Operating System :: OS Independent", 43 | ], 44 | python_requires=">=3.6", 45 | # install_requires=requirements, 46 | ) 47 | -------------------------------------------------------------------------------- /source/PSID/IPSID.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2020 University of Southern California 3 | See full notice in LICENSE.md 4 | Parsa Vahidi, Omid G. Sani and Maryam M. Shanechi 5 | Shanechi Lab, University of Southern California 6 | """ 7 | 8 | import warnings 9 | 10 | import numpy as np 11 | from scipy import linalg 12 | 13 | from . import LSSM 14 | from . import PrepModel 15 | from .PSID import blkhankskip, projOrth, getHSize 16 | 17 | 18 | def transposeIf(Y): 19 | """Transposes Y itself if Y is an array or each element of Y if it is a list/tuple of arrays. 20 | 21 | Args: 22 | Y (np.array or list or tuple): input data or list of input data arrays. 23 | 24 | Returns: 25 | np.array or list or tuple: transposed Y or list of transposed arrays. 26 | """ 27 | if Y is None: 28 | return None 29 | elif isinstance(Y, (list, tuple)): 30 | return [transposeIf(YThis) for YThis in Y] 31 | else: 32 | return Y.T 33 | 34 | 35 | def catIf(Y, axis=None): 36 | """If Y is a list of arrays, will concatenate them otherwise returns Y 37 | 38 | Args: 39 | Y (np.array or list or tuple): input data or list of input data arrays. 40 | 41 | Returns: 42 | np.array or list or tuple: transposed Y or list of transposed arrays. 43 | """ 44 | if Y is None: 45 | return None 46 | elif isinstance(Y, (list, tuple)): 47 | return np.concatenate(Y, axis=axis) 48 | else: 49 | return Y 50 | 51 | 52 | def removeProjOrth(A, B): 53 | """ 54 | Projects A onto B and then subtracts the result from A. 55 | A and B must be wide matrices with dim x samples. 56 | Returns: 57 | 1) A_AHat: A minus the projection of A onto B 58 | """ 59 | return A - projOrth(A, B)[0] 60 | 61 | 62 | def projOblique(A, B, C): 63 | """ 64 | Projects A onto B along C. 65 | A, B and C must be wide matrices with dim x samples. 66 | Returns: 67 | 1) AHat: projection of A onto B along C 68 | 2) W: The matrix that gives AHat when it is right multiplied by B 69 | """ 70 | if C is not None: 71 | A_C = removeProjOrth(A, C) 72 | B_C = removeProjOrth(B, C) 73 | W = projOrth(A_C, B_C)[1] 74 | AHat = W @ B 75 | else: 76 | AHat, W = projOrth(A, B) 77 | return AHat, W 78 | 79 | 80 | def computeObsFromAC(A, C, i): 81 | """ 82 | Computes the extended observability matrix for pair (A, C) 83 | Returns: 84 | 1) Oy: extended observability matrix for (A, C) 85 | 2) Oy_Minus: Oy, minus the last block row 86 | """ 87 | ny = C.shape[0] 88 | Oy = C 89 | for ii in range(i): 90 | Oy = np.concatenate((Oy, Oy[(ii - 1) * ny : ii * ny, :] @ A)) 91 | Oy_Minus = Oy[:(-ny), :] 92 | return Oy, Oy_Minus 93 | 94 | 95 | def recomputeObsAndStates(A, C, i, YHat, YHatMinus): 96 | """ 97 | Computes observabilioty matrices Oy and Oy_Minus using A and C 98 | and recompute Xk and Xk_Plus1 using the new Oy and Oy_Minus 99 | Returns: 100 | 1) Xk: recomputed states 101 | 2) Xk_Plus1: recomputed states at next time step 102 | """ 103 | Oy, Oy_Minus = computeObsFromAC(A, C, i) 104 | Xk = np.linalg.pinv(Oy) @ YHat 105 | Xk_Plus1 = np.linalg.pinv(Oy_Minus) @ YHatMinus 106 | return Xk, Xk_Plus1 107 | 108 | 109 | def computeBD(A, C, Yii, Xk_Plus1, Xk, i, nu, Uf): 110 | """ 111 | Computes matrices corresponding to the effect of external input 112 | Returns: 113 | 1)B and 2)D matrices in the following state space equations 114 | x(k) = A * x(k) + B * u(k) + w(k) 115 | y(k) = Cy * x(k) + Dy * u(k) + v(k) 116 | """ 117 | # Find B and D 118 | Oy, Oy_Minus = computeObsFromAC(A, C, i) 119 | 120 | # See ref. 40 pages 125-127 121 | PP = np.concatenate((Xk_Plus1 - A @ Xk, Yii - C @ Xk)) 122 | 123 | L1 = A @ np.linalg.pinv(Oy) 124 | L2 = C @ np.linalg.pinv(Oy) 125 | 126 | nx = A.shape[0] 127 | ny = C.shape[0] 128 | 129 | ZM = np.concatenate((np.zeros((nx, ny)), np.linalg.pinv(Oy_Minus)), axis=1) 130 | 131 | # LHS * DB = PP 132 | LHS = np.zeros((PP.size, (nx + ny) * nu)) 133 | RMul = linalg.block_diag(np.eye(ny), Oy_Minus) 134 | 135 | NNAll = [] # ref. 40 (4.54), (4.57),..,(4.59) 136 | # Plug in the terms into NN 137 | for ii in range(i): 138 | NN = np.zeros(((nx + ny), i * ny)) 139 | 140 | NN[:nx, : ((i - ii) * ny)] = ZM[:, (ii * ny) :] - L1[:, (ii * ny) :] 141 | NN[nx : (nx + ny), : ((i - ii) * ny)] = -L2[:, (ii * ny) :] 142 | if ii == 0: 143 | NN[nx : (nx + ny), :ny] = NN[nx : (nx + ny), :ny] + np.eye(ny) 144 | 145 | # Plug into LHS 146 | LHS = LHS + np.kron(Uf[(ii * nu) : (ii * nu + nu), :].T, NN @ RMul) 147 | NNAll.append(NN) 148 | 149 | DBVec = np.linalg.lstsq(LHS, PP.flatten(order="F"), rcond=None)[0] 150 | DB = np.reshape(DBVec, [nx + ny, nu], order="F") 151 | D = DB[:ny, :] 152 | B = DB[ny : (ny + nx), :] 153 | return B, D 154 | 155 | 156 | def fitCzDzViaKFRegression( 157 | s, Y, Z, U=None, time_first=True, Cz=None, fit_Cz_via_KF=False, missing_marker=None 158 | ): 159 | """ 160 | Fits the behavior projection parameter Cz (and behavior feedthrough parameter Dz) by first estimating 161 | the latent states with a Kalman filter and then using ordinary 162 | least squares regression 163 | """ 164 | if not isinstance(Y, (list, tuple)): 165 | if time_first: 166 | YTF = Y 167 | ZTF = Z 168 | UTF = U 169 | else: 170 | YTF = Y.T 171 | ZTF = Z.T 172 | if U is not None: 173 | UTF = U.T 174 | else: 175 | UTF = None 176 | xHat = s.kalman(YTF, UTF)[0] 177 | else: 178 | for yInd in range(len(Y)): 179 | if time_first: 180 | YTFThis = Y[yInd] 181 | ZTFThis = Z[yInd] 182 | if U is not None: 183 | UTFThis = U[yInd] 184 | else: 185 | UTFThis = None 186 | else: 187 | YTFThis = Y[yInd].T 188 | ZTFThis = Z[yInd].T 189 | if U is not None: 190 | UTFThis = U[yInd].T 191 | else: 192 | UTFThis = None 193 | xHatThis = s.kalman(YTFThis, UTFThis)[0] 194 | if yInd == 0: 195 | xHat = xHatThis 196 | ZTF = ZTFThis 197 | UTF = UTFThis 198 | else: 199 | xHat = np.concatenate((xHat, xHatThis), axis=0) 200 | ZTF = np.concatenate((ZTF, ZTFThis), axis=0) 201 | if UTFThis is not None: 202 | UTF = np.concatenate((UTF, UTFThis), axis=0) 203 | if missing_marker is not None: 204 | isNotMissing = np.logical_not(np.any(ZTF == missing_marker, axis=1)) 205 | else: 206 | isNotMissing = np.ones(ZTF.shape[0], dtype=bool) 207 | if fit_Cz_via_KF: 208 | if U is not None: 209 | CzDz = projOrth( 210 | ZTF[isNotMissing, :].T, 211 | np.concatenate((xHat[isNotMissing, :].T, UTF[isNotMissing, :].T)), 212 | )[1] 213 | nx = xHat.shape[1] 214 | Cz = CzDz[:, :nx] 215 | Dz = CzDz[:, nx:] 216 | else: 217 | Cz = projOrth(ZTF[isNotMissing, :].T, xHat[isNotMissing, :].T)[1] 218 | Dz = None 219 | else: 220 | if U is not None: 221 | Dz = projOrth( 222 | ZTF[isNotMissing, :].T - Cz @ xHat[isNotMissing, :].T, 223 | UTF[isNotMissing, :].T, 224 | )[1] 225 | else: 226 | Dz = None 227 | return Cz, Dz 228 | 229 | 230 | def combineIdSysWithEps(s, s3, missing_marker): 231 | """ 232 | Creates and returns a single model by combining parameters of: 233 | s: Main model, parameters associated with X1, X2 in IPSID stages 1, 2 234 | s3: Optional model, parameters associated with X3 in IPSID additional step 2 235 | """ 236 | s_new = s 237 | newA = linalg.block_diag(s.A, s3.A) 238 | newB = np.concatenate((s.B, s3.B), axis=0) 239 | newC = np.concatenate((s.C, np.zeros((s.C.shape[0], s3.A.shape[0]))), axis=1) 240 | if hasattr(s, "Cz") and s.Cz.size > 0 and hasattr(s3, "Cz") and s3.Cz.size > 0: 241 | newCz = np.concatenate((s.Cz, s3.Cz), axis=1) 242 | elif hasattr(s3.Cz) and s3.Cz.size > 0: 243 | newCz = s3.Cz 244 | 245 | if hasattr(s, "Dz") and s.Dz.size > 0 and hasattr(s3, "Dz") and s3.Dz.size > 0: 246 | newDz = s.Dz + s3.Dz 247 | elif hasattr(s3.Dz) and s3.Dz.size > 0: 248 | newDz = s3.Dz 249 | 250 | newQ = linalg.block_diag(s.Q, s3.Q) 251 | newS = np.concatenate((s.S, 0 * s3.S), axis=0) 252 | newSxz = np.concatenate((s.Sxz, np.zeros((s3.A.shape[0], s.Cz.shape[0]))), axis=0) 253 | 254 | new_params = { 255 | "A": newA, 256 | "B": newB, 257 | "C": newC, 258 | "D": s.D, 259 | "Cz": newCz, 260 | "Dz": newDz, 261 | "Q": newQ, 262 | "R": s.R, 263 | "S": newS, 264 | "Sxz": newSxz, 265 | "Syz": s.Syz, 266 | "Rz": s.Rz, 267 | } 268 | newSys = LSSM.LSSM(params=new_params) 269 | 270 | return newSys 271 | 272 | 273 | def IPSID( 274 | Y, 275 | Z=None, 276 | U=None, 277 | nx=None, 278 | n1=0, 279 | i=None, 280 | WS=dict(), 281 | return_WS=False, 282 | fit_Cz_via_KF=True, 283 | time_first=True, 284 | remove_mean_Y=True, 285 | remove_mean_Z=True, 286 | remove_mean_U=True, 287 | zscore_Y=False, 288 | zscore_Z=False, 289 | zscore_U=False, 290 | missing_marker=None, 291 | remove_nonYrelated_fromX1=False, 292 | n_pre=np.inf, 293 | n3=0, 294 | ) -> LSSM: 295 | """ 296 | IPSID: Input Preferential Subspace Identification Algorithm 297 | Publication: P. Vahidi, O. G. Sani, and M. M. Shanechi, "Modeling and dissociation of 298 | intrinsic and input-driven neural population dynamics underlying behavior", PNAS (2024). 299 | * Comments within the documentation that refer to Eq. (XX), Figures, and Notes are referencing the above paper. 300 | IPSID identifies a linear stochastic model for a signal y, while prioritizing 301 | the latent states that are predictive of another signal z, while a known external input 302 | u is applied to the system. The complete model is as follows: 303 | [x1(k+1); x2(k+1); x3(k+1)] = [A11 0 0; A21 A22 0;0 0 A33] * [x1(k); x2(k); x3(k)] + [B1; B2; B3] * u(k) + w(k) 304 | y(k) = [Cy1 Cy2 0] * [x1(k); x2(k); x3(k)] + Dy * u(k) + v(k) 305 | z(k) = [Cz1 0 Cz3] * [x1(k); x2(k); x3(k)] + Dz * u(k) + e(k) 306 | x(k) = [x1(k); x2(k); x3(k)] => Latent state time series 307 | x1(k) => Latent states related to y and z ( the pair (A11, Cz1) is observable ) 308 | x2(k) => Latent states related to y but unrelated to z 309 | x3(k) => Latent states related to z but unrelated to y 310 | u(k) => External input that was applied to the system 311 | Given training time series from y(k), z(k) and u(k), the dimension of x(k) 312 | (i.e. nx), the dimension of x1(k) (i.e. n1), and the dimension of x3(k) (i.e. n3) the algorithm finds 313 | all model parameters and noise statistics: 314 | - A : [A11 0 0; A21 A22 0;0 0 A33] 315 | - B : [B1 B2 B3] 316 | - Cy : [Cy1 Cy2 0] 317 | - Cz : [Cz1 0 Cz3] 318 | - Dy : [Dy] 319 | - Dz : [Dz] 320 | - Q : Cov( w(k), w(k) ) 321 | - R : Cov( v(k), v(k) ) 322 | - S : Cov( w(k), v(k) ) 323 | as well as the following model characteristics/parameters: 324 | - G : Cov( x(k+1), y(k) ) 325 | - YCov: Cov( y(k), y(k) ) 326 | - K: steady state stationary Kalman filter for estimating x from y 327 | - innovCov: covariance of innovation for the Kalman filter 328 | - P: covariance of Kalman predicted state error 329 | - xPCov: covariance of Kalman predicted state itself 330 | - xCov: covariance of the latent state 331 | 332 | Inputs: 333 | - (1) Y: Inputs signal 1 (e.g. neural signal). 334 | Must be a T x ny matrix (unless time_first=False). 335 | It can also be a list of matrices, one for each data segment (e.g. trials): 336 | [y(1); y(2); y(3); ...; y(T)] 337 | Segments do not need to have the same number of samples. 338 | - (2) Z: Inputs signal 2, to be studied using y (e.g. behavior). 339 | Format options are similar to Y. 340 | Must be a T x nz matrix (unless time_first=False). 341 | It can also be a list of matrices, one for each data segment (e.g. trials): 342 | [z(1); z(2); z(3); ...; z(T)] 343 | Segments do not need to have the same number of samples. 344 | - (3) U: External inputs (e.g. task instructions). 345 | Format options are similar to Y. 346 | Must be a T x nu matrix (unless time_first=False). 347 | It can also be a list of matrices, one for each data segment (e.g. trials): 348 | [u(1); u(2); u(3); ...; u(T)] 349 | Segments do not need to have the same number of samples. 350 | - (4) nx: the total number of latent states in the stochastic model 351 | - (5) n1: number of latent states to extract in the first stage. 352 | - (6) i: the number of block-rows (i.e. future and past horizon). 353 | Different values of i may have different identification performance. 354 | Must be at least 2. It also determines the maximum n1 and nx 355 | that can be used per: 356 | n1 <= nz * i 357 | nx <= ny * i 358 | So if you have a low dimensional y or z, you typically would choose larger 359 | values for i, and vice versa. 360 | i Can also be a list, tuple, or array indicating [iY,iZ,iU], in which case 361 | different horizons will be used for Y, Z and U (for now only iY == iU is supported) 362 | - (7) WS: the WS output from a previous call using the exact 363 | same data. If calling IPSID repeatedly with the same data 364 | and horizon, several computationally costly steps can be 365 | reused from before. Otherwise will be discarded. 366 | - (8) return_WS (default: False): if True, will return WS as the second output 367 | - (9) fit_Cz_via_KF (default: True): if True (preferred option), 368 | refits Cz more accurately using a KF after all other 369 | parameters are learned 370 | - (10) time_first (default: True): if True, will expect the time dimension 371 | of the data to be the first dimension (e.g. Z is T x nz). If False, 372 | will expect time to be the second dimension in all data 373 | (e.g. Z is nz x T). 374 | - (11) remove_mean_Y: if True will remove the mean of Y. 375 | Must be True if data is not zero mean. Defaults to True. 376 | - (12) remove_mean_Z: if True will remove the mean of Z. 377 | Must be True if data is not zero mean. Defaults to True. 378 | - (13) remove_mean_U: if True will remove the mean of U. 379 | Must be True if data is not zero mean. Defaults to True. 380 | - (14) zscore_Y: if True will z-score Y. It is ok to set this to False, 381 | but setting to True may help with stopping some dimensions of 382 | data from dominating others. Defaults to False. 383 | - (15) zscore_Z: if True will z-score Z. It is ok to set this to False, 384 | but setting to True may help with stopping some dimensions of 385 | data from dominating others. Defaults to False. 386 | - (16) zscore_U: if True will z-score U. It is ok to set this to False, 387 | but setting to True may help with stopping some dimensions of 388 | data from dominating others. Defaults to False. 389 | - (17) missing_marker (default: None): if not None, will discard samples of Z that 390 | equal to missing_marker when fitting Cz. Only effective if fit_Cz_via_KF is 391 | True. 392 | - (18) remove_nonYrelated_fromX1 (default: False): If remove_nonYrelated_fromX1=True, the direct effect 393 | of input u(k) on z(k) would be excluded from x1(k) in additional step 1 (preprocessing stage). 394 | If False, additional step 1 won't happen and x3 (and its corresponding model parameters 395 | [A33, B3, Cz3 and noise statistics related to x3]) won't be learned even if n3>0 provided. 396 | - (19) n_pre (default: np.inf): preprocessing dimension used in additional step 1. 397 | Additional step 1 only happens if remove_nonYrelated_fromX1=True. 398 | Large values of n_pre (assuming there is enough data to fit models with 399 | such large state dimensions) would ensure all dynamics of Y are preserved in 400 | the preprocessing step. 401 | If, n_pre=np.inf, n_pre will be automatically set to the largest possible value given the data 402 | (all available SVD dimensions). 403 | If n_pre=0, Additional steps 1 and 2 won't happen and x3 won't be learned 404 | (remove_nonYrelated_fromX1 will be set to False, n3 will be 0). 405 | - (20) n3: number of latent states x3(k) in the optional additional step 2. 406 | 407 | Outputs: 408 | - (1) idSys: an LSSM object with the system parameters for 409 | the identified system. Will have the following 410 | attributes (defined above), and some more attributes 411 | and methods: 412 | 'A', 'B', 'Cy', 'Cz', 'Dy', 'Dz', 'Q', 'R', 'S' 413 | 'G', 'YCov', 'K', 'innovCov', 'P', 'xPCov', 'xCov' 414 | - (2) WS (optional): dictionary to provide to later calls of PSID 415 | on the same data (see input (6) for more details) 416 | 417 | Notes: 418 | (1) Additional step 1 (preprocessing step) (refer to (Vahidi, Sani et al) Fig. S5 - top row, and Note S2) 419 | is optional and won't happen by default. To enable, provide remove_nonYrelated_fromX1=True, n_pre>0. 420 | When enabled, this step ensures all learned latent dynamics are encoded in Y. 421 | In this case, the n_pre determines the state dimension used in the preprocessing. 422 | (2) In case Additional step 1 enabled (see Note 1 above), parameter Dz won't be fitted 423 | (will be 0). 424 | (3) Learning x3 and fitting its corresponding parameters are optional and won't happen by default. 425 | To enable, provide n3>0, and enable Additional step 1 (see Note 1 above). 426 | (4) PSID (Preferential Subspace Identification) can be performed as a special case using the IPSID algorithm. 427 | To do so, simply set U=None. 428 | (5) INDM (or ISID, i.e., Subspace Identification with input U, unsupervised by Z) can be performed as 429 | a special case of IPSID. To do so, simply set Z=None and n1=0. 430 | (6) NDM (or SID, i.e., Standard Subspace Identification without input U, unsupervised by Z) can be performed as 431 | a special case of IPSID. To do so, simply set Z=None, U=None and n1=0. 432 | 433 | Usage example: 434 | idSys = IPSID(Y, Z, U, nx=nx, n1=n1, i=i); # With external input 435 | idSys = IPSID(Y, Z, U, nx=nx, n1=n1, remove_nonYrelated_inX1=True, n_pre=n_pre, i=i); # With external input and preprocessing x1(k) 436 | idSys = IPSID(Y, Z, U, nx=nx, n1=n1, remove_nonYrelated_inX1=True, n_pre=n_pre, n3=n3, i=i); # With external input, preprocessing x1(k) and optional states x3(k) 437 | idSysPSID = IPSID(Y, Z, nx=nx, n1=n1, i=i); # No external input: PSID 438 | [idSys, WS] = IPSID(Y, Z, nx=nx, n1=n1, i=i, WS=WS); 439 | idSysISID = IPSID(Y, Z=None, U, nx, 0, i); # Set n1=0 and Z=None for ISID 440 | idSysSID = IPSID(Y, Z=None, U=None, nx, 0, i); # Set n1=0, Z=None and U=None for SID 441 | """ 442 | if not isinstance(i, (list, tuple, np.ndarray)): 443 | i = [i] 444 | iAll = np.array(i) 445 | iY = int(iAll[0]) # Horizon for Y 446 | iZ = iY if iAll.size < 2 else int(iAll[1]) # Horizon for Z 447 | iU = iY if iAll.size < 3 else int(iAll[2]) # Horizon for U (must be the same as iY) 448 | iMax = np.max([iY, iZ, iU]) 449 | 450 | YPrepModel = PrepModel.PrepModel() 451 | YPrepModel.fit(Y, remove_mean=remove_mean_Y, zscore=zscore_Y, time_first=time_first) 452 | Y = YPrepModel.apply(Y, time_first=time_first) 453 | 454 | ZPrepModel = PrepModel.PrepModel() 455 | if Z is not None: 456 | ZPrepModel.fit( 457 | Z, remove_mean=remove_mean_Z, zscore=zscore_Z, time_first=time_first 458 | ) 459 | Z = ZPrepModel.apply(Z, time_first=time_first) 460 | 461 | UPrepModel = PrepModel.PrepModel() 462 | if U is not None: 463 | UPrepModel.fit( 464 | U, remove_mean=remove_mean_U, zscore=zscore_U, time_first=time_first 465 | ) 466 | U = UPrepModel.apply(U, time_first=time_first) 467 | 468 | ny, ySamples, N, y1, NTot = getHSize(Y, iMax, time_first=time_first) 469 | if Z is not None: 470 | nz, zSamples, _, z1, NTot = getHSize(Z, iMax, time_first=time_first) 471 | else: 472 | nz, zSamples = 0, 0 473 | if U is not None: 474 | nu, uSamples, _, u1, NTot = getHSize(U, iMax, time_first=time_first) 475 | else: 476 | nu = 0 477 | 478 | if isinstance(N, list) and np.any(np.array(N) < 1): 479 | warnings.warn( 480 | "{} of the {} data segments will be discarded because they are too short for using with a horizon of {}.".format( 481 | np.sum(np.array(N) < 1), len(N), i 482 | ), 483 | ) 484 | 485 | if ( 486 | "NTot" in WS 487 | and WS["NTot"] == NTot 488 | and "N" in WS 489 | and WS["N"] == N 490 | and "iY" in WS 491 | and WS["iY"] == iY 492 | and "iZ" in WS 493 | and WS["iZ"] == iZ 494 | and "iU" in WS 495 | and WS["iU"] == iU 496 | and "ySamples" in WS 497 | and WS["ySamples"] == ySamples 498 | and "zSamples" in WS 499 | and WS["zSamples"] == zSamples 500 | and "Y1" in WS 501 | and WS["Y1"] == y1 502 | and (nz == 0 or ("Z1" in WS and WS["Z1"] == z1)) 503 | ): 504 | # Have WS from previous call with the same data 505 | pass 506 | else: 507 | WS = { 508 | "NTot": NTot, 509 | "N": N, 510 | "i": i, 511 | "iY": iY, 512 | "iZ": iZ, 513 | "iU": iU, 514 | "ySamples": ySamples, 515 | "Y1": y1, 516 | } 517 | if nz > 0: 518 | WS["zSamples"] = zSamples 519 | WS["Z1"] = z1 520 | 521 | if "Yp" not in WS or WS["Yp"] is None: 522 | WS["Yp"] = blkhankskip(Y, iY, N, iMax - iY, time_first=time_first) 523 | WS["Yf"] = blkhankskip(Y, iY, N, iMax, time_first=time_first) 524 | WS["Yii"] = blkhankskip(Y, 1, N, iMax, time_first=time_first) 525 | if nu > 0: 526 | WS["Up"] = blkhankskip(U, iU, N, iMax - iU, time_first=time_first) 527 | WS["Uf"] = blkhankskip(U, iU, N, iMax, time_first=time_first) 528 | WS["Uii"] = blkhankskip(U, 1, N, iMax, time_first=time_first) 529 | else: 530 | WS["Up"] = np.empty((0, N)) 531 | WS["Uf"] = WS["Up"] 532 | WS["Uii"] = WS["Up"] 533 | if nz > 0: 534 | WS["Zii"] = blkhankskip(Z, 1, N, iMax, time_first=time_first) 535 | 536 | if n1 > nx: 537 | n1 = nx # Max possible n1 value 538 | 539 | if nz == 0: 540 | n3 = 0 541 | if ( 542 | nu == 0 or n1 == 0 543 | ): # Since the external input U and/or n1 is not provided, preprocessing step is disabled and X3 won't be learned. 544 | remove_nonYrelated_fromX1, n_pre, n3 = False, 0, 0 545 | if ( 546 | not remove_nonYrelated_fromX1 or n_pre == 0 547 | ): # Due to provided settings, preprocessing step is disabled and X3 won't be learned. 548 | remove_nonYrelated_fromX1, n_pre, n3 = False, 0, 0 549 | 550 | if n1 > 0 and nz > 0: 551 | if n1 > iZ * nz: 552 | raise ( 553 | Exception( 554 | "n1 (currently {}) must be at most iZ*nz={}*{}={}. Use a larger horizon iZ.".format( 555 | n1, iZ, nz, iZ * nz 556 | ) 557 | ) 558 | ) 559 | if "ZHatObUfRes_U" not in WS or WS["ZHatObUfRes_U"] is None: 560 | Zf = blkhankskip(Z, iZ, N, iMax, time_first=time_first) 561 | ######### Additional step1/Preprocessing ((Vahidi, Sani et al) Fig. S5, top row) ########## 562 | if remove_nonYrelated_fromX1: 563 | Yf_Minus, Uf_Minus = WS["Yf"][ny:, :], WS["Uf"][nu:, :] 564 | YHatOb_pr = projOblique( 565 | WS["Yf"], np.concatenate((WS["Up"], WS["Yp"])), WS["Uf"] 566 | )[0] 567 | YHatObRes_pr = removeProjOrth(YHatOb_pr, WS["Uf"]) 568 | U0, S0, YHat_V0 = linalg.svd( 569 | YHatObRes_pr, full_matrices=False, lapack_driver="gesvd" 570 | ) 571 | keepDims = n_pre if n_pre <= U0.shape[1] else U0.shape[1] 572 | S0 = np.diag(S0[:keepDims]) 573 | U0 = U0[:, :keepDims] 574 | Oy0 = U0 @ S0 ** (1 / 2) 575 | YHat_pre = projOrth( 576 | WS["Yf"], np.concatenate((WS["Up"], WS["Yp"], WS["Uf"])) 577 | )[0] 578 | Xk_pre = np.linalg.pinv(Oy0) @ YHat_pre 579 | Zf_pre, Qz = projOblique( 580 | Zf, Xk_pre, np.concatenate((WS["Up"], WS["Uf"])) 581 | ) # Eq.(39) 582 | 583 | Oy0_Minus = Oy0[:-ny, :] 584 | YHatMinus_pre = projOrth( 585 | Yf_Minus, 586 | np.concatenate( 587 | (WS["Up"], WS["Uii"], WS["Yp"], WS["Yii"], Uf_Minus) 588 | ), 589 | )[0] 590 | XkMinus_pre = np.linalg.pinv(Oy0_Minus) @ YHatMinus_pre 591 | Qz_Minus = Qz[:-nz, :] 592 | ZfMinus_pre = Qz_Minus @ XkMinus_pre 593 | 594 | Zf, Zf_Minus = Zf_pre, ZfMinus_pre 595 | ################################################### 596 | else: 597 | Zf_Minus = Zf[nz:, :] 598 | Uf_Minus = WS["Uf"][nu:, :] 599 | 600 | # IPSID Stage 1 601 | #################################################### 602 | # Oblique projection of Zf along Uf onto UpYp: Eq.(22) 603 | ZHatOb = projOblique(Zf, np.concatenate((WS["Up"], WS["Yp"])), WS["Uf"])[0] 604 | WS["ZHatObUfRes"] = removeProjOrth(ZHatOb, WS["Uf"]) 605 | 606 | # Orthogonal projection of Zf onto UpYpUf 607 | WS["ZHat"] = projOrth(Zf, np.concatenate((WS["Up"], WS["Yp"], WS["Uf"])))[0] 608 | 609 | # Orthogonal projection of Zf_Minus onto Up_plus, Yp_plus and Uf_Minus 610 | WS["ZHatMinus"] = projOrth( 611 | Zf_Minus, 612 | np.concatenate((WS["Up"], WS["Uii"], WS["Yp"], WS["Yii"], Uf_Minus)), 613 | )[0] 614 | 615 | # Take SVD of ZHatObUfRes 616 | WS["ZHatObUfRes_U"], WS["ZHatObUfRes_S"], ZHat_V = linalg.svd( 617 | WS["ZHatObUfRes"], full_matrices=False, lapack_driver="gesvd" 618 | ) # Eq.(23) 619 | 620 | Sz = np.diag(WS["ZHatObUfRes_S"][:n1]) 621 | Uz = WS["ZHatObUfRes_U"][:, :n1] 622 | 623 | Oz = Uz @ Sz ** (1 / 2) 624 | Oz_Minus = Oz[:-nz, :] 625 | 626 | Xk = np.linalg.pinv(Oz) @ WS["ZHat"] 627 | # Eq. (24) 628 | Xk_Plus1 = np.linalg.pinv(Oz_Minus) @ WS["ZHatMinus"] 629 | else: 630 | n1 = 0 631 | Xk = np.empty([0, NTot]) 632 | Xk_Plus1 = np.empty([0, NTot]) 633 | 634 | n2 = nx - n1 635 | if ( 636 | n3 > 0 637 | ): # In case asked to dedicate some model capacity (state dimension) to X3, then recompute dimension of X21 638 | n2 = max( 639 | 0, nx - n1 - n3 640 | ) # Anything remaining from nx after allocating n1 and n3 becomes n2 641 | n3 = ( 642 | nx - n1 - n2 643 | ) # The dimension of final model would be equal to final n1+n2+n3 based on their adjusted values (which is equal to the input nx). 644 | nx = ( 645 | n1 + n2 646 | ) # This is the nx used in 2-stage IPSID algorithm (without considering X3) i.e., dim([X1;X2]) 647 | 648 | # IPSID Stage 2 649 | # ---------------- 650 | if n2 > 0: 651 | if nx > iY * ny: 652 | raise ( 653 | Exception( 654 | "nx (currently {}) must be at most iY*ny={}*{}={}. Use a larger horizon iY.".format( 655 | nx, iY, ny, iY * ny 656 | ) 657 | ) 658 | ) 659 | if ( 660 | "YHatObUfRes_U" not in WS 661 | or WS["YHatObUfRes_U"] is None 662 | or "n1" not in WS 663 | or WS["n1"] != n1 664 | ): 665 | WS["n1"] = n1 666 | 667 | Yf = WS["Yf"] 668 | Yf_Minus = Yf[ny:, :] 669 | Uf_Minus = WS["Uf"][nu:, :] 670 | 671 | if ( 672 | n1 > 0 673 | ): # Have already extracted some states, so remove the already predicted part of Yf 674 | # Remove the already predicted part of future y 675 | # Oblique projection of Yf along Uf, onto UpYp 676 | YHatOb1, Oy1 = projOblique(Yf, Xk, np.concatenate((WS["Up"], WS["Uf"]))) 677 | Yf = Yf - YHatOb1 # Eq.(25) 678 | 679 | Oy1_Minus = Oy1[:-ny, :] 680 | Yf_Minus = Yf_Minus - Oy1_Minus @ Xk_Plus1 681 | 682 | # Oblique projection of Yf along Uf, onto UpYp: Eq.(26) 683 | YHatOb = projOblique(Yf, np.concatenate((WS["Up"], WS["Yp"])), WS["Uf"])[0] 684 | WS["YHatObUfRes"] = removeProjOrth(YHatOb, WS["Uf"]) 685 | 686 | # Orthogonal projection of Yf onto UfUpYp 687 | WS["YHat"] = projOrth(Yf, np.concatenate((WS["Up"], WS["Yp"], WS["Uf"])))[0] 688 | 689 | # Orthogonal projection of Yf_Minus onto Up_plus,Yp_plus,Uf_Minus 690 | WS["YHatMinus"] = projOrth( 691 | Yf_Minus, 692 | np.concatenate((WS["Up"], WS["Uii"], WS["Yp"], WS["Yii"], Uf_Minus)), 693 | )[0] 694 | 695 | # Take SVD of YHatObUfRes 696 | WS["YHatObUfRes_U"], WS["YHatObUfRes_S"], YHat_V = linalg.svd( 697 | WS["YHatObUfRes"], full_matrices=False, lapack_driver="gesvd" 698 | ) # Eq.(27) 699 | 700 | S2 = np.diag(WS["YHatObUfRes_S"][:n2]) 701 | U2 = WS["YHatObUfRes_U"][:, :n2] 702 | 703 | Oy = U2 @ S2 ** (1 / 2) 704 | Oy_Minus = Oy[:-ny, :] 705 | 706 | Xk2 = np.linalg.pinv(Oy) @ WS["YHat"] # Eq.(28) 707 | Xk2_Plus1 = np.linalg.pinv(Oy_Minus) @ WS["YHatMinus"] 708 | 709 | Xk = np.concatenate((Xk, Xk2)) 710 | Xk_Plus1 = np.concatenate((Xk_Plus1, Xk2_Plus1)) 711 | 712 | # Parameter identification 713 | # ------------------------ 714 | if n1 > 0: 715 | # A associated with the z-related states 716 | XkP1Hat, A1Tmp = projOrth( 717 | Xk_Plus1[:n1, :], np.concatenate((Xk[:n1, :], WS["Uf"])) 718 | ) # Eq.(29) 719 | A = A1Tmp[:n1, :n1] 720 | w = Xk_Plus1[:n1, :] - XkP1Hat[:n1, :] # Eq.(33) 721 | else: 722 | A = np.empty([0, 0]) 723 | w = np.empty([0, NTot]) 724 | 725 | if n2 > 0: 726 | # A associated with the other states (X2) 727 | XkP2Hat, A23Tmp = projOrth( 728 | Xk_Plus1[n1:, :], np.concatenate((Xk, WS["Uf"])) 729 | ) # Eq.(30) 730 | A23 = A23Tmp[:, :nx] 731 | if n1 > 0: 732 | A10 = np.concatenate((A, np.zeros([n1, n2])), axis=1) 733 | A = np.concatenate((A10, A23)) 734 | else: 735 | A = A23 736 | w = np.concatenate((w, Xk_Plus1[n1:, :] - XkP2Hat)) # Eq.(34) 737 | 738 | if nz > 0: 739 | ZiiHat, CzTmp = projOrth(WS["Zii"], np.concatenate((Xk, WS["Uf"]))) # Eq.(32) 740 | Cz = CzTmp[:, :nx] 741 | e = WS["Zii"] - ZiiHat 742 | else: 743 | Cz = np.empty([0, nx]) 744 | 745 | YiiHat, CyTmp = projOrth(WS["Yii"], np.concatenate((Xk, WS["Uf"]))) # Eq.(31) 746 | Cy = CyTmp[:, :nx] 747 | v = WS["Yii"] - YiiHat # Eq.(35) 748 | 749 | # Compute noise covariances 750 | NA = w.shape[1] 751 | Q = (w @ w.T) / NA # Eq.(36) 752 | S = (w @ v.T) / NA # Eq.(36) 753 | R = (v @ v.T) / NA # Eq.(36) 754 | 755 | Q = (Q + Q.T) / 2 # Make precisely symmetric 756 | R = (R + R.T) / 2 # Make precisely symmetric 757 | 758 | params = {"A": A, "C": Cy, "Q": Q, "R": R, "S": S} 759 | if nz > 0: 760 | params["Sxz"] = (w @ e.T) / NA 761 | params["Syz"] = (v @ e.T) / NA 762 | Rz = (e @ e.T) / NA 763 | params["Rz"] = (Rz + Rz.T) / 2 # Make precisely symmetric 764 | 765 | s = LSSM.LSSM(params=params) 766 | if np.any(np.isnan(s.Pp)): # Riccati did not have a solution. 767 | warnings.warn( 768 | "The learned model did not have a solution for the Riccati equation." 769 | ) 770 | 771 | if ( 772 | nu > 0 773 | ): # Following a procedure similar to ref. 40 in (Vahidi, Sani, et al), pages 125-127 to find the least squares solution for the model parameters B and Dy 774 | RR = np.triu( 775 | np.linalg.qr( 776 | np.concatenate((WS["Up"], WS["Uf"], WS["Yp"], WS["Yf"])).T / np.sqrt(NA) 777 | )[1] 778 | ).T 779 | if iU != iY: 780 | raise (Exception("Only iY=iU is supported!")) 781 | RR = RR[: ((2 * nu + 2 * ny) * iY), : ((2 * nu + 2 * ny) * iY)] 782 | 783 | RUf = RR[(nu * iY) : (2 * nu * iY), :] 784 | RYf = RR[((2 * nu + ny) * iY) : ((2 * nu + 2 * ny) * iY), :] 785 | RYf_Minus = RR[((2 * nu + ny) * iY + ny) : ((2 * nu + 2 * ny) * iY), :] 786 | RYii = RR[((2 * nu + ny) * iY) : ((2 * nu + ny) * iY + ny), :] 787 | 788 | YHat = np.concatenate( 789 | (RYf[:, : ((2 * nu + ny) * iY)], np.zeros((ny * iY, ny))), axis=1 790 | ) 791 | YHatMinus = RYf_Minus[:, : ((2 * nu + ny) * iY + ny)] 792 | Yii = RYii[:, : ((2 * nu + ny) * iY + ny)] 793 | Uf = RUf[:, : ((2 * nu + ny) * iY + ny)] 794 | 795 | # Recompute Oy and Oy_Minus using A and Cy and recompute Xk and Xk_Plus1 using the new Oy 796 | Xk, Xk_Plus1 = recomputeObsAndStates(A, Cy, iY, YHat, YHatMinus) 797 | B, Dy = computeBD(A, Cy, Yii, Xk_Plus1, Xk, iY, nu, Uf) 798 | s.changeParams({"B": B, "D": Dy}) 799 | 800 | s.Cz = Cz 801 | if nz > 0: 802 | if not remove_nonYrelated_fromX1: 803 | Cz, Dz = fitCzDzViaKFRegression( 804 | s, 805 | Y, 806 | Z, 807 | U, 808 | time_first, 809 | Cz, 810 | fit_Cz_via_KF=fit_Cz_via_KF, 811 | missing_marker=missing_marker, 812 | ) 813 | if fit_Cz_via_KF: 814 | s.Cz = Cz 815 | if nu > 0: 816 | s.Dz = Dz 817 | else: 818 | xHat = s.predict( 819 | Y if time_first else transposeIf(Y), 820 | U if time_first else transposeIf(U), 821 | steady_state=True, 822 | )[2] 823 | xHatCat = catIf(transposeIf(xHat), axis=1) 824 | ZCat = catIf(transposeIf(Z) if time_first else Z, axis=1) 825 | s.Cz = projOrth(ZCat, xHatCat)[ 826 | 1 827 | ] # Eq.(40) Fitting Z-readout from all states in case of using additional steps (preprocessing) 828 | s.Dz = np.zeros( 829 | (nz, nu) 830 | ) # Enforcing no feedthrough to z in case of using additional steps (preprocessing) 831 | 832 | # Additional step 2/Learning x3 and its model parameters (if desired): (Vahidi, Sani et al) Fig. S5 bottom row, Note S2 833 | #################################################### 834 | if ( 835 | n3 > 0 836 | ): # Learn n3 additional stated that optimize the prediction of residual of Z only using past U and past residual of Z 837 | UCat = catIf(transposeIf(U) if time_first else U, axis=1) 838 | ZRes = ZCat - (s.Cz @ xHatCat) - (s.Dz @ UCat) 839 | 840 | # Using Stage 2 of IPSID alone for identifying dynamics in residual Z (ZRes) driven by U 841 | s3 = IPSID(ZRes, Z=None, U=UCat, nx=n3, n1=0, i=[iZ, iY, iU], time_first=False) 842 | params3 = { 843 | "A": s3.A, 844 | "B": s3.B, 845 | "C": np.zeros((ny, n3)), 846 | "D": np.zeros((ny, nu)), 847 | "Cz": s3.C, 848 | "Dz": s3.D, 849 | "Q": np.zeros_like(s3.A), 850 | "R": np.zeros_like(s.R), 851 | "S": np.zeros((s3.A.shape[0], s.C.shape[0])), 852 | "Sxz": s3.S, 853 | "Syz": np.zeros((ny, nz)), 854 | "Rz": s3.R, 855 | } 856 | 857 | s3 = LSSM.LSSM(params=params3) 858 | s = combineIdSysWithEps( 859 | s, s3, missing_marker 860 | ) # Combining model parametrs learned for [X1,X2] and [X3] in a single model 861 | #################################################### 862 | 863 | s.YPrepModel = YPrepModel 864 | s.ZPrepModel = ZPrepModel 865 | s.UPrepModel = UPrepModel 866 | 867 | if not return_WS: 868 | return s 869 | else: 870 | return s, WS 871 | -------------------------------------------------------------------------------- /source/PSID/LSSM.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2020 University of Southern California 3 | See full notice in LICENSE.md 4 | Omid G. Sani and Maryam M. Shanechi 5 | Shanechi Lab, University of Southern California 6 | 7 | An LSSM object for keeping parameters, filtering, etc 8 | """ 9 | 10 | import warnings 11 | 12 | import numpy as np 13 | from scipy import linalg 14 | 15 | 16 | def dict_get_either(d, fieldNames, defaultVal=None): 17 | for f in fieldNames: 18 | if f in d: 19 | return d[f] 20 | return defaultVal 21 | 22 | 23 | def genRandomGaussianNoise(N, Q, m=None): 24 | Q2 = np.atleast_2d(Q) 25 | dim = Q2.shape[0] 26 | if m is None: 27 | m = np.zeros((dim, 1)) 28 | 29 | D, V = linalg.eig(Q2) 30 | if np.any(D < 0): 31 | raise ("Cov matrix is not PSD!") 32 | QShaping = np.real(np.matmul(V, np.sqrt(np.diag(D)))) 33 | w = np.matmul(np.random.randn(N, dim), QShaping.T) 34 | return w, QShaping 35 | 36 | 37 | class LSSM: 38 | def __init__(self, params, output_dim=None, state_dim=None, input_dim=None): 39 | self.output_dim = output_dim 40 | self.state_dim = state_dim 41 | self.input_dim = input_dim 42 | self.setParams(params) 43 | 44 | def setParams(self, params={}): 45 | A = dict_get_either(params, ["A", "a"]) 46 | A = np.atleast_2d(A) 47 | 48 | C = dict_get_either(params, ["C", "c"]) 49 | C = np.atleast_2d(C) 50 | 51 | self.A = A 52 | self.state_dim = self.A.shape[0] 53 | if C.shape[1] != self.state_dim and C.shape[0] == self.state_dim: 54 | C = C.T 55 | self.C = C 56 | self.output_dim = self.C.shape[0] 57 | 58 | B = dict_get_either(params, ["B", "b"], None) 59 | D = dict_get_either(params, ["D", "d", "Dy", "dy"], None) 60 | if isinstance(B, float) or (isinstance(B, np.ndarray) and B.size > 0): 61 | B = np.atleast_2d(B) 62 | if B.shape[0] != self.state_dim: 63 | B = B.T 64 | self.input_dim = B.shape[1] 65 | elif isinstance(D, float) or (isinstance(D, np.ndarray) and D.size > 0): 66 | D = np.atleast_2d(D) 67 | if D.shape[0] != self.output_dim: 68 | D = D.T 69 | self.input_dim = D.shape[1] 70 | else: 71 | self.input_dim = 0 72 | if B is None or B.size == 0: 73 | B = np.zeros((self.state_dim, self.input_dim)) 74 | B = np.atleast_2d(B) 75 | if ( 76 | B.size > 0 77 | and B.shape[0] != self.state_dim 78 | and B.shape[1] == self.output_dim 79 | ): 80 | B = B.T 81 | self.B = B 82 | if D is None or D.size == 0: 83 | D = np.zeros((self.output_dim, self.input_dim)) 84 | D = np.atleast_2d(D) 85 | if ( 86 | D.size > 0 87 | and D.shape[0] != self.output_dim 88 | and D.shape[1] == self.output_dim 89 | ): 90 | D = D.T 91 | self.D = D 92 | 93 | if "q" in params or "Q" in params: # Stochastic form with QRS provided 94 | Q = dict_get_either(params, ["Q", "q"], None) 95 | R = dict_get_either(params, ["R", "r"], None) 96 | S = dict_get_either(params, ["S", "s"], None) 97 | Q = np.atleast_2d(Q) 98 | R = np.atleast_2d(R) 99 | 100 | self.Q = Q 101 | self.R = R 102 | if S is None or S.size == 0: 103 | S = np.zeros((self.state_dim, self.output_dim)) 104 | S = np.atleast_2d(S) 105 | if S.shape[0] != self.state_dim: 106 | S = S.T 107 | self.S = S 108 | elif "k" in params or "K" in params: 109 | self.Q = None 110 | self.R = None 111 | self.S = None 112 | self.K = np.atleast_2d(dict_get_either(params, ["K", "k"], None)) 113 | self.innovCov = np.atleast_2d(dict_get_either(params, ["innovCov"], None)) 114 | 115 | self.update_secondary_params() 116 | 117 | for f, v in params.items(): # Add any remaining params (e.g. Cz) 118 | if f in set(["Cz", "Dz"]) or ( 119 | not hasattr(self, f) 120 | and not hasattr(self, f.upper()) 121 | and f not in set(["sig", "L0", "P"]) 122 | ): 123 | setattr(self, f, v) 124 | 125 | if hasattr(self, "Cz") and self.Cz is not None: 126 | Cz = np.atleast_2d(self.Cz) 127 | if Cz.shape[1] != self.state_dim and Cz.shape[0] == self.state_dim: 128 | Cz = Cz.T 129 | self.Cz = Cz 130 | 131 | def changeParams(self, params={}): 132 | curParams = self.getListOfParams() 133 | for f, v in curParams.items(): 134 | if f not in params: 135 | params[f] = v 136 | self.setParams(params) 137 | 138 | def getListOfParams(self): 139 | params = {} 140 | for field in dir(self): 141 | val = self.__getattribute__(field) 142 | if not field.startswith("__") and isinstance( 143 | val, (np.ndarray, list, tuple, type(self)) 144 | ): 145 | params[field] = val 146 | return params 147 | 148 | def update_secondary_params(self): 149 | if self.Q is not None and self.state_dim > 0: # Given QRS 150 | try: 151 | A_Eigs = linalg.eig(self.A)[0] 152 | except Exception as e: 153 | print("Error in eig ({})... Trying again!".format(e)) 154 | A_Eigs = linalg.eig(self.A)[0] # Try again! 155 | isStable = np.max(np.abs(A_Eigs)) < 1 156 | if isStable: 157 | self.XCov = linalg.solve_discrete_lyapunov(self.A, self.Q) 158 | self.G = self.A @ self.XCov @ self.C.T + self.S 159 | self.YCov = self.C @ self.XCov @ self.C.T + self.R 160 | self.YCov = (self.YCov + self.YCov.T) / 2 161 | else: 162 | self.XCov = np.eye(self.state_dim) 163 | self.XCov[:] = np.nan 164 | self.YCov = np.eye(self.output_dim) 165 | self.YCov[:] = np.nan 166 | 167 | try: 168 | self.Pp = linalg.solve_discrete_are( 169 | self.A.T, self.C.T, self.Q, self.R, s=self.S 170 | ) # Solves Katayama eq. 5.42a 171 | self.innovCov = self.C @ self.Pp @ self.C.T + self.R 172 | innovCovInv = np.linalg.pinv(self.innovCov) 173 | self.K = (self.A @ self.Pp @ self.C.T + self.S) @ innovCovInv 174 | self.Kf = self.Pp @ self.C.T @ innovCovInv 175 | self.Kv = self.S @ innovCovInv 176 | self.A_KC = self.A - self.K @ self.C 177 | except Exception as err: 178 | print("Could not solve DARE: {}".format(err)) 179 | self.Pp = np.empty(self.A.shape) 180 | self.Pp[:] = np.nan 181 | self.K = np.empty((self.A.shape[0], self.R.shape[0])) 182 | self.K[:] = np.nan 183 | self.Kf = np.array(self.K) 184 | self.Kv = np.array(self.K) 185 | self.innovCov = np.empty(self.R.shape) 186 | self.innovCov[:] = np.nan 187 | self.A_KC = np.empty(self.A.shape) 188 | self.A_KC[:] = np.nan 189 | 190 | self.P2 = ( 191 | self.XCov - self.Pp 192 | ) # (should give the solvric solution) Proof: Katayama Theorem 5.3 and A.3 in pvo book 193 | elif hasattr(self, "K") and self.K is not None: # Given K 194 | self.XCov = None 195 | if not hasattr(self, "G"): 196 | self.G = None 197 | if not hasattr(self, "YCov"): 198 | self.YCov = None 199 | 200 | self.Pp = None 201 | self.Kf = None 202 | self.Kv = None 203 | self.A_KC = self.A - self.K @ self.C 204 | if not hasattr(self, "P2"): 205 | self.P2 = None 206 | elif self.R is not None: 207 | self.YCov = self.R 208 | 209 | def isStable(self): 210 | return np.all(np.abs(self.eigenvalues) < 1) 211 | 212 | def generateObservationFromStates( 213 | self, X, u=None, param_names=["C", "D"], prep_model_param="YPrepModel" 214 | ): 215 | """Can generate Y or Z observation time series given the latent state time series X and optional external input u 216 | 217 | Args: 218 | X (numpy array): Dimensions are time x dimesions. 219 | param_names (list, optional): The name of the read-out parameter. Defaults to ['C']. 220 | prep_model_param (str, optional): The name of the preprocessing model parameter. 221 | Defaults to 'YPrepModel'. 222 | 223 | Returns: 224 | numpy array: The observation time series. 225 | If param_names=['C'] and prep_model_param='YPrepModel', will 226 | produce Y = C * X, and then applies the inverse of the 227 | Y preprocessing model. 228 | If param_names=['Cz'] and prep_model_param='ZPrepModel', will 229 | produce Y = Cz * X, and then applies the inverse of the 230 | Z preprocessing model. 231 | """ 232 | Y = None 233 | if hasattr(self, param_names[0]): 234 | C = getattr(self, param_names[0]) 235 | else: 236 | C = None 237 | if len(param_names) > 1 and hasattr(self, param_names[1]): 238 | D = getattr(self, param_names[1]) 239 | else: 240 | D = None 241 | 242 | if C is not None and C.size > 0 or D is not None and D.size > 0: 243 | ny = C.shape[0] if C is not None and self.C.size > 0 else D.shape[0] 244 | N = X.shape[0] 245 | Y = np.zeros((N, ny)) 246 | if C is not None and C.size > 0: 247 | Y += (C @ X.T).T 248 | if D is not None and D.size > 0 and u is not None: 249 | if hasattr(self, "UPrepModel") and self.UPrepModel is not None: 250 | u = self.UPrepModel.apply( 251 | u, time_first=True 252 | ) # Apply any mean removal/zscoring 253 | Y += (D @ u.T).T 254 | 255 | if prep_model_param is not None and hasattr(self, prep_model_param): 256 | prep_model_param_obj = getattr(self, prep_model_param) 257 | if prep_model_param_obj is not None: 258 | Y = prep_model_param_obj.apply_inverse( 259 | Y 260 | ) # Apply inverse of any mean-removal/zscoring 261 | 262 | return Y 263 | 264 | def generateRealization( 265 | self, N, x0=None, w0=None, u0=None, u=None, return_wv=False 266 | ): 267 | QRS = np.block([[self.Q, self.S], [self.S.T, self.R]]) 268 | wv, self.QRSShaping = genRandomGaussianNoise(N, QRS) 269 | w = wv[:, : self.state_dim] 270 | v = wv[:, self.state_dim :] 271 | if x0 is None: 272 | x0 = np.zeros((self.state_dim, 1)) 273 | if w0 is None: 274 | w0 = np.zeros((self.state_dim, 1)) 275 | if self.input_dim > 0 and u0 is None: 276 | u0 = np.zeros((self.input_dim, 1)) 277 | X = np.empty((N, self.state_dim)) 278 | Y = np.empty((N, self.output_dim)) 279 | for i in range(N): 280 | if i == 0: 281 | Xt_1 = x0 282 | Wt_1 = w0 283 | if self.input_dim > 0 and u is not None: 284 | Ut_1 = u0 285 | else: 286 | Xt_1 = X[i - 1, :].T 287 | Wt_1 = w[i - 1, :].T 288 | if self.input_dim > 0 and u is not None: 289 | Ut_1 = u[i - 1, :].T 290 | X[i, :] = (self.A @ Xt_1 + Wt_1).T 291 | # Y[i, :] = (self.C @ X[i, :].T + v[i, :].T).T # Will make Y later 292 | if u is not None: 293 | X[i, :] += np.squeeze((self.B @ Ut_1).T) 294 | # Y[i, :] += np.squeeze((self.D @ u[i, :]).T) # Will make Y later 295 | Y = v 296 | CxDu = self.generateObservationFromStates( 297 | X, u=u, param_names=["C", "D"], prep_model_param="YPrepModel" 298 | ) 299 | if CxDu is not None: 300 | Y += CxDu 301 | out = Y, X 302 | if return_wv: 303 | out += (wv,) 304 | return out 305 | 306 | def kalman( 307 | self, Y, U=None, x0=None, P0=None, steady_state=True, return_state_cov=False 308 | ): 309 | """Applies the Kalman filter associated with the LSSM to some observation time-series 310 | 311 | Args: 312 | Y (np.ndarray): observation time series (time first). 313 | U (np.ndarray, optional): input time series (time first). Defaults to None. 314 | x0 (np.ndarray, optional): Initial Kalman state. Defaults to None. 315 | P0 (np.ndarray, optional): Initial Kalman state estimation error covariance. Defaults to None. 316 | steady_state (bool, optional): If True, will use steady state Kalman gain, which is much faster. Defaults to True. 317 | return_state_cov (bool, optional): If true, will return state error covariances. Defaults to False. 318 | 319 | Returns: 320 | allXp (np.ndarray): one-step ahead predicted states (t|t-1) 321 | allYp (np.ndarray): one-step ahead predicted observations (t|t-1) 322 | allXf (np.ndarray): filtered states (t|t) 323 | allPp (np.ndarray): error cov for one-step ahead predicted states (t|t-1) 324 | allPf (np.ndarray): error cov for filtered states (t|t) 325 | """ 326 | if self.state_dim == 0: 327 | allXp = np.zeros((Y.shape[0], self.state_dim)) 328 | allXf = allXp 329 | allYp = np.zeros((Y.shape[0], self.output_dim)) 330 | return allXp, allYp, allXf 331 | if np.any(np.isnan(self.K)) and steady_state: 332 | steady_state = False 333 | warnings.warn( 334 | "Steady state Kalman gain not available. Will perform non-steady-state Kalman." 335 | ) 336 | N, ny = Y.shape[0], Y.shape[1] 337 | allXp = np.nan * np.ones((N, self.state_dim)) # X(i|i-1) 338 | allXf = np.nan * np.ones((N, self.state_dim)) # X(i|i) 339 | if return_state_cov: 340 | allPp = np.zeros((N, self.state_dim, self.state_dim)) # P(i|i-1) 341 | allPf = np.zeros((N, self.state_dim, self.state_dim)) # P(i|i) 342 | if x0 is None: 343 | x0 = np.zeros((self.state_dim, 1)) 344 | if P0 is None: 345 | P0 = np.eye(self.state_dim) 346 | Xp = x0 347 | Pp = P0 348 | for i in range(N): 349 | allXp[i, :] = np.transpose(Xp) # X(i|i-1) 350 | thisY = Y[i, :][np.newaxis, :] 351 | if hasattr(self, "YPrepModel") and self.YPrepModel is not None: 352 | thisY = self.YPrepModel.apply( 353 | thisY, time_first=True 354 | ) # Apply any mean removal/zscoring 355 | zi = thisY.T - self.C @ Xp # Innovation Z(i) 356 | if U is not None: 357 | ui = U[i, :][:, np.newaxis] 358 | if hasattr(self, "UPrepModel") and self.UPrepModel is not None: 359 | ui = self.UPrepModel.apply( 360 | ui, time_first=False 361 | ) # Apply any mean removal/zscoring 362 | if self.D.size > 0: 363 | zi -= self.D @ ui 364 | 365 | if steady_state: 366 | Kf = self.Kf 367 | K = self.K 368 | else: 369 | ziCov = self.C @ Pp @ self.C.T + self.R 370 | Kf = np.linalg.lstsq(ziCov.T, (Pp @ self.C.T).T, rcond=None)[ 371 | 0 372 | ].T # Kf(i) 373 | 374 | if self.S.size > 0: 375 | Kw = np.linalg.lstsq(ziCov.T, self.S.T, rcond=None)[0].T # Kw(i) 376 | K = self.A @ Kf + Kw # K(i) 377 | else: 378 | K = self.A @ Kf # K(i) 379 | 380 | P = Pp - Kf @ self.C @ Pp # P(i|i) 381 | 382 | if return_state_cov: 383 | allPp[i, :, :] = Pp # P(i|i-1) 384 | allPf[i, :, :] = P # P(i|i) 385 | 386 | if Kf is not None: # Otherwise cannot do filtering 387 | X = Xp + Kf @ zi # X(i|i) 388 | allXf[i, :] = np.transpose(X) 389 | 390 | newXp = self.A @ Xp 391 | newXp += K @ zi 392 | if U is not None and self.B.size > 0: 393 | newXp += self.B @ ui 394 | 395 | Xp = newXp 396 | if not steady_state: 397 | Pp = self.A @ Pp @ self.A.T + self.Q - K @ ziCov @ K.T 398 | 399 | allYp = self.generateObservationFromStates( 400 | allXp, u=U, param_names=["C", "D"], prep_model_param="YPrepModel" 401 | ) 402 | 403 | if not return_state_cov: 404 | return allXp, allYp, allXf 405 | else: 406 | return allXp, allYp, allXf, allPp, allPf 407 | 408 | def predict(self, Y, U=None, useXFilt=False, **kwargs): 409 | if isinstance(Y, (list, tuple)): # If segments of data are provided as a list 410 | for trialInd, trialY in enumerate(Y): 411 | trialOuts = self.predict( 412 | trialY, 413 | U=U if U is None else U[trialInd], 414 | useXFilt=useXFilt, 415 | **kwargs, 416 | ) 417 | if trialInd == 0: 418 | outs = [[o] for oi, o in enumerate(trialOuts)] 419 | else: 420 | outs = [outs[oi] + [o] for oi, o in enumerate(trialOuts)] 421 | return tuple(outs) 422 | # If only one data segment is provided 423 | allXp, allYp, allXf = self.kalman(Y, U=U, **kwargs)[0:3] 424 | if useXFilt: 425 | allXp = allXf 426 | if (hasattr(self, "Cz") and self.Cz is not None) or ( 427 | hasattr(self, "Dz") and self.Dz is not None 428 | ): 429 | allZp = self.generateObservationFromStates( 430 | allXp, u=U, param_names=["Cz", "Dz"], prep_model_param="ZPrepModel" 431 | ) 432 | else: 433 | allZp = None 434 | 435 | return allZp, allYp, allXp 436 | -------------------------------------------------------------------------------- /source/PSID/MatHelper.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2020 University of Southern California 3 | See full notice in LICENSE.md 4 | Omid G. Sani and Maryam M. Shanechi 5 | Shanechi Lab, University of Southern California 6 | 7 | Helps with interfacing with matlab 8 | """ 9 | 10 | import scipy.io as sio 11 | import numpy as np 12 | import h5py 13 | 14 | 15 | def loadmat(file_path, variable_names=None): 16 | "Loads a mat file as a dictionary" 17 | try: 18 | # mat_dict = sio.loadmat(file_path, matlab_compatible=True, variable_names=variable_names) 19 | mat_dict = sio.loadmat( 20 | file_path, 21 | struct_as_record=False, 22 | squeeze_me=True, 23 | chars_as_strings=True, 24 | variable_names=variable_names, 25 | ) 26 | except ( 27 | NotImplementedError 28 | ): # Runs for v7.3: 'Please use HDF reader for matlab v7.3 files' 29 | mat_dict = h5py.File(file_path) 30 | 31 | return _check_keys(mat_dict) 32 | 33 | 34 | # From https://stackoverflow.com/a/8832212/2275605 35 | def _check_keys(d): 36 | """ 37 | checks if entries in dictionary are mat-objects. If yes 38 | todict is called to change them to nested dictionaries 39 | """ 40 | for key in d: 41 | if isinstance(d[key], sio.matlab.mio5_params.mat_struct): 42 | d[key] = _todict(d[key]) 43 | elif ( 44 | isinstance(d[key], np.ndarray) 45 | and len(d[key]) > 0 46 | and isinstance(d[key].item(0), sio.matlab.mio5_params.mat_struct) 47 | ): 48 | for i in range(d[key].size): 49 | if isinstance(d[key].item(i), sio.matlab.mio5_params.mat_struct): 50 | d[key].itemset(i, _todict(d[key].item(i))) 51 | else: 52 | pass 53 | 54 | return d 55 | 56 | 57 | def _todict(matobj): 58 | """ 59 | A recursive function which constructs from matobjects nested dictionaries 60 | """ 61 | d = {} 62 | for key in matobj._fieldnames: 63 | elem = matobj.__dict__[key] 64 | if isinstance(elem, sio.matlab.mio5_params.mat_struct): 65 | d[key] = _todict(elem) 66 | elif ( 67 | isinstance(elem, np.ndarray) 68 | and elem.size > 0 69 | and isinstance(elem.item(0), sio.matlab.mio5_params.mat_struct) 70 | ): 71 | for i in range(elem.size): 72 | if isinstance(elem.item(i), sio.matlab.mio5_params.mat_struct): 73 | elem.itemset(i, _todict(elem.item(i))) 74 | d[key] = elem 75 | else: 76 | d[key] = elem 77 | return d 78 | -------------------------------------------------------------------------------- /source/PSID/PSID.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2020 University of Southern California 3 | See full notice in LICENSE.md 4 | Omid G. Sani and Maryam M. Shanechi 5 | Shanechi Lab, University of Southern California 6 | """ 7 | 8 | import warnings 9 | 10 | import numpy as np 11 | from scipy import linalg 12 | 13 | from . import LSSM 14 | from . import PrepModel 15 | 16 | 17 | def projOrth(A, B): 18 | """ 19 | Projects A onto B. A and B must be wide matrices with dim x samples. 20 | Returns: 21 | 1) AHat: projection of A onto B 22 | 2) W: The matrix that gives AHat when it is right multiplied by B 23 | """ 24 | if B is not None: 25 | BCov = ( 26 | B @ B.T / B.shape[0] 27 | ) # Division by num samples eventually cancels out but it makes the computations (specially pinv) numerically more stable 28 | ABCrossCov = A @ B.T / B.shape[0] 29 | isOk, attempts = False, 0 30 | while not isOk and attempts < 10: 31 | try: 32 | attempts += 1 33 | W = ABCrossCov @ np.linalg.pinv( 34 | BCov 35 | ) # or: A / B = A * B.' * pinv(B * B.') 36 | isOk = True 37 | except Exception as e: 38 | print('Error: "{}". Will retry...'.format(e)) 39 | if not isOk: 40 | raise (Exception(e)) 41 | AHat = W @ B # or: A * B.' * pinv(B * B.') * B 42 | else: 43 | W = np.zeros((A.shape[0], B.shape[1])) 44 | AHat = np.zeros(A.shape) 45 | return (AHat, W) 46 | 47 | 48 | def blkhankskip(Y, i, j=None, s=0, time_first=True): 49 | """ 50 | Constructs block Hankel matrices from the provided data Y 51 | """ 52 | if isinstance(Y, (list, tuple)): 53 | if j is None: 54 | j = [None for yi in range(len(Y))] 55 | H = None 56 | for yInd in range(len(Y)): 57 | if j[yInd] < 1: 58 | continue # This data segment is too short 59 | thisH = blkhankskip(Y[yInd], i, j[yInd], s, time_first=time_first) 60 | if H is None: 61 | H = thisH 62 | else: 63 | H = np.concatenate((H, thisH), axis=1) 64 | else: 65 | ny, N = getHSize(Y, i, time_first=time_first)[:2] 66 | if j is None: 67 | j = N - 2 * i + 1 68 | H = np.empty((ny * i, j)) 69 | for r in range(i): 70 | if time_first: 71 | thisBlock = Y[slice(s + r, s + r + j), :].T 72 | else: 73 | thisBlock = Y[:, slice(s + r, s + r + j)] 74 | H[slice(r * ny, r * ny + ny), :] = thisBlock 75 | return H 76 | 77 | 78 | def getHSize(Y, i, time_first=True): 79 | """ 80 | Extracts time and data dimension information and the expected size of 81 | the block Hankel matrices that will be constructed using blkhankskip 82 | """ 83 | ny = None 84 | y1 = None 85 | if not isinstance(Y, (list, tuple)): 86 | if time_first: 87 | ySamples, ny = Y.shape 88 | else: 89 | ny, ySamples = Y.shape 90 | N = ySamples - 2 * i + 1 91 | NTot = N 92 | if ySamples > 0: 93 | y1 = Y.flatten()[0] 94 | else: 95 | ySamples = [] 96 | N = [] 97 | for yi, thisY in enumerate(Y): 98 | nyThis, ySamplesThis, NThis, y1This = getHSize(thisY, i, time_first)[:4] 99 | if yi == 0: 100 | ny = nyThis 101 | y1 = y1This 102 | else: 103 | if nyThis != ny: 104 | raise ( 105 | Exception( 106 | "Size of dimension 1 must be the same in all elements of the data list." 107 | ) 108 | ) 109 | ySamples.append(ySamplesThis) 110 | N.append(NThis) 111 | NArr = np.array(N) 112 | NTot = np.sum(NArr[NArr > 0]) 113 | return ny, ySamples, N, y1, NTot 114 | 115 | 116 | def fitCzViaKFRegression(s, Y, Z, time_first): 117 | """ 118 | Fits the behavior projection parameter Cz by first estimating 119 | the latent states with a Kalman filter and then using ordinary 120 | least squares regression 121 | """ 122 | if not isinstance(Y, (list, tuple)): 123 | if time_first: 124 | YTF = Y 125 | ZTF = Z 126 | else: 127 | YTF = Y.T 128 | ZTF = Z.T 129 | xHat = s.kalman(YTF)[0] 130 | else: 131 | for yInd in range(len(Y)): 132 | if time_first: 133 | YTFThis = Y[yInd] 134 | ZTFThis = Z[yInd] 135 | else: 136 | YTFThis = Y[yInd].T 137 | ZTFThis = Z[yInd].T 138 | xHatThis = s.kalman(YTFThis)[0] 139 | if yInd == 0: 140 | xHat = xHatThis 141 | ZTF = ZTFThis 142 | else: 143 | xHat = np.concatenate((xHat, xHatThis), axis=0) 144 | ZTF = np.concatenate((ZTF, ZTFThis), axis=0) 145 | Cz = projOrth(ZTF.T, xHat.T)[1] 146 | return Cz 147 | 148 | 149 | def PSID( 150 | Y, 151 | Z=None, 152 | nx=None, 153 | n1=0, 154 | i=None, 155 | WS=dict(), 156 | return_WS=False, 157 | fit_Cz_via_KF=True, 158 | time_first=True, 159 | remove_mean_Y=True, 160 | remove_mean_Z=True, 161 | zscore_Y=False, 162 | zscore_Z=False, 163 | ) -> LSSM: 164 | """ 165 | PSID PSID: Preferential Subspace Identification Algorithm 166 | Identifies a linear stochastic model for a signal y, while prioritizing 167 | the latent states that are predictive of another signal z. The model is 168 | as follows: 169 | [x1(k+1); x2(k+1)] = [A11 0; A21 A22] * [x1(k); x2(k)] + w(k) 170 | y(k) = [Cy1 Cy2] * [x1(k); x2(k)] + v(k) 171 | z(k) = [Cz1 0] * [x1(k); x2(k)] + e(k) 172 | x(k) = [x1(k); x2(k)] => Latent state time series 173 | x1(k) => Latent states related to z ( the pair (A11, Cz1) is observable ) 174 | x2(k) => Latent states unrelated to z 175 | Given training time series from y(k) and z(k), the dimension of x(k) 176 | (i.e. nx), and the dimension of x1(k) (i.e. n1), the algorithm finds 177 | all model parameters and noise statistics: 178 | - A : [A11 0; A21 A22] 179 | - Cy : [Cy1 Cy2] 180 | - Cz : [Cz1 0] 181 | - Q : Cov( w(k), w(k) ) 182 | - R : Cov( v(k), v(k) ) 183 | - S : Cov( w(k), v(k) ) 184 | as well as the following model characteristics/parameters: 185 | - G : Cov( x(k+1), y(k) ) 186 | - YCov: Cov( y(k), y(k) ) 187 | - K: steady state stationary Kalman filter for estimating x from y 188 | - innovCov: covariance of innovation for the Kalman filter 189 | - P: covariance of Kalman predicted state error 190 | - xPCov: covariance of Kalman predicted state itself 191 | - xCov: covariance of the latent state 192 | 193 | Inputs: 194 | - (1) Y: Inputs signal 1 (e.g. neural signal). 195 | Must be a T x ny matrix (unless time_first=False). 196 | It can also be a list of matrices, one for each data segment (e.g. trials): 197 | [y(1); y(2); y(3); ...; y(T)] 198 | Segments do not need to have the same number of samples. 199 | - (2) Z: Inputs signal 2, to be studied using y (e.g. behavior). 200 | Format options are similar to Y. 201 | Must be a T x nz matrix (unless time_first=False). 202 | It can also be a list of matrices, one for each data segment (e.g. trials): 203 | [z(1); z(2); z(3); ...; z(T)] 204 | Segments do not need to have the same number of samples. 205 | - (3) nx: the total number of latent states in the stochastic model 206 | - (4) n1: number of latent states to extract in the first stage. 207 | - (5) i: the number of block-rows (i.e. future and past horizon). 208 | Different values of i may have different identification performance. 209 | Must be at least 2. It also determines the maximum n1 and nx 210 | that can be used per: 211 | n1 <= nz * i 212 | nx <= ny * i 213 | So if you have a low dimensional y or z, you typically would choose larger 214 | values for i, and vice versa. 215 | - (6) WS: the WS output from a previous call using the exact 216 | same data. If calling PSID repeatedly with the same data 217 | and horizon, several computationally costly steps can be 218 | reused from before. Otherwise will be discarded. 219 | - (7) return_WS (default: False): if true, will return WS as the second output 220 | - (8) fit_Cz_via_KF (default: True): if true (preferred option), 221 | refits Cz more accurately using a KF after all other 222 | paramters are learned 223 | - (9) time_first (default: True): if true, will expect the time dimension 224 | of the data to be the first dimension (e.g. Z is T x nz). If false, 225 | will expect time to be the second dimension in all data 226 | (e.g. Z is nz x T). 227 | - (10) remove_mean_Y: if True will remove the mean of Y. 228 | Must be True if data is not zero mean. Defaults to True. 229 | - (11) remove_mean_Z: if True will remove the mean of Z. 230 | Must be True if data is not zero mean. Defaults to True. 231 | - (12) zscore_Y: if True will z-score Y. It is ok to set this to False, 232 | but setting to true may help with stopping some dimensions of 233 | data from dominating others. Defaults to True. 234 | - (13) zscore_Z: if True will z-score Z. It is ok to set this to False, 235 | but setting to true may help with stopping some dimensions of 236 | data from dominating others. Defaults to True. 237 | Outputs: 238 | - (1) idSys: an LSSM object with the system parameters for 239 | the identified system. Will have the following 240 | attributes (defined above), and some more attributes 241 | and methods: 242 | 'A', 'Cy', 'Cz', 'Q', 'R', 'S' 243 | 'G', 'YCov', 'K', 'innovCov', 'P', 'xPCov', 'xCov' 244 | - (2) WS (optional): dictionary to provide to later calls of PSID 245 | on the same data (see input (6) for more details) 246 | Usage example: 247 | idSys = PSID(Y, Z, nx, n1, i) 248 | [idSys, WS] = PSID(Y, Z, nx, n1, i, WS, return_WS=True) 249 | idSysSID = PSID(Y, Z, nx, 0, i) # Set n1=0 for SID 250 | """ 251 | YPrepModel = PrepModel.PrepModel() 252 | YPrepModel.fit(Y, remove_mean=remove_mean_Y, zscore=zscore_Y, time_first=time_first) 253 | Y = YPrepModel.apply(Y, time_first=time_first) 254 | 255 | ZPrepModel = PrepModel.PrepModel() 256 | if Z is not None: 257 | ZPrepModel.fit( 258 | Z, remove_mean=remove_mean_Z, zscore=zscore_Z, time_first=time_first 259 | ) 260 | Z = ZPrepModel.apply(Z, time_first=time_first) 261 | 262 | ny, ySamples, N, y1, NTot = getHSize(Y, i, time_first=time_first) 263 | if Z is not None: 264 | nz, zSamples, _, z1, NTot = getHSize(Z, i, time_first=time_first) 265 | else: 266 | nz, zSamples = 0, 0 267 | 268 | if isinstance(N, list) and np.any(np.array(N) < 1): 269 | warnings.warn( 270 | "{} of the {} data segments will be discarded because they are too short for using with a horizon of {}.".format( 271 | np.sum(np.array(N) < 1), len(N), i 272 | ), 273 | ) 274 | 275 | if ( 276 | "NTot" in WS 277 | and WS["NTot"] == NTot 278 | and "N" in WS 279 | and WS["N"] == N 280 | and "i" in WS 281 | and WS["i"] == i 282 | and "ySamples" in WS 283 | and WS["ySamples"] == ySamples 284 | and "zSamples" in WS 285 | and WS["zSamples"] == zSamples 286 | and "Y1" in WS 287 | and WS["Y1"] == y1 288 | and (nz == 0 or ("Z1" in WS and WS["Z1"] == z1)) 289 | ): 290 | # Have WS from previous call with the same data 291 | pass 292 | else: 293 | WS = {"NTot": NTot, "N": N, "i": i, "ySamples": ySamples, "Y1": y1} 294 | if nz > 0: 295 | WS["zSamples"] = zSamples 296 | WS["Z1"] = z1 297 | 298 | if "Yp" not in WS or WS["Yp"] is None: 299 | WS["Yp"] = blkhankskip(Y, i, N, time_first=time_first) 300 | WS["Yii"] = blkhankskip(Y, 1, N, i, time_first=time_first) 301 | if nz > 0: 302 | WS["Zii"] = blkhankskip(Z, 1, N, i, time_first=time_first) 303 | 304 | if n1 > nx: 305 | n1 = nx # n1 can at most be nx 306 | 307 | # Stage 1 308 | if n1 > 0 and nz > 0: 309 | if n1 > i * nz: 310 | raise ( 311 | Exception( 312 | "n1 (currently {}) must be at most i*nz={}*{}={}. Use a larger horizon i.".format( 313 | n1, i, nz, i * nz 314 | ) 315 | ) 316 | ) 317 | if "ZHat_U" not in WS or WS["ZHat_U"] is None: 318 | Zf = blkhankskip(Z, i, N, i, time_first=time_first) 319 | WS["ZHat"] = projOrth(Zf, WS["Yp"])[ 320 | 0 321 | ] # Zf @ WS['Yp'].T @ np.linalg.pinv(WS['Yp'] @ WS['Yp'].T) @ WS['Yp'] # Eq. (10) 322 | Yp_Plus = np.concatenate((WS["Yp"], WS["Yii"])) 323 | Zf_Minus = Zf[nz:, :] 324 | WS["ZHatMinus"] = projOrth(Zf_Minus, Yp_Plus)[ 325 | 0 326 | ] # Zf_Minus @ Yp_Plus.T @ np.linalg.pinv(Yp_Plus @ Yp_Plus.T) @ Yp_Plus # Eq. (11) 327 | 328 | # Take SVD of ZHat 329 | WS["ZHat_U"], WS["ZHat_S"], ZHat_V = linalg.svd( 330 | WS["ZHat"], full_matrices=False, lapack_driver="gesvd" 331 | ) # Eq. (12) 332 | 333 | Sz = np.diag(WS["ZHat_S"][:n1]) # Eq. (12) 334 | Uz = WS["ZHat_U"][:, :n1] # Eq. (12) 335 | 336 | Oz = Uz @ Sz ** (1 / 2) # Eq. (13) 337 | Oz_Minus = Oz[:-nz, :] # Eq. (15) 338 | 339 | Xk = np.linalg.pinv(Oz) @ WS["ZHat"] 340 | # Eq. (14) 341 | Xk_Plus1 = np.linalg.pinv(Oz_Minus) @ WS["ZHatMinus"] 342 | # Eq. (16) 343 | else: 344 | n1 = 0 345 | Xk = np.empty([0, NTot]) 346 | Xk_Plus1 = np.empty([0, NTot]) 347 | 348 | # Stage 2 349 | n2 = nx - n1 350 | if n2 > 0: 351 | if nx > i * ny: 352 | raise ( 353 | Exception( 354 | "nx (currently {}) must be at most i*ny={}*{}={}. Use a larger horizon i.".format( 355 | nx, i, ny, i * ny 356 | ) 357 | ) 358 | ) 359 | if ( 360 | "YHat_U" not in WS 361 | or WS["YHat_U"] is None 362 | or "n1" not in WS 363 | or WS["n1"] != n1 364 | ): 365 | WS["n1"] = n1 366 | 367 | Yf = blkhankskip(Y, i, N, i, time_first=time_first) 368 | Yf_Minus = Yf[ny:, :] 369 | 370 | if ( 371 | n1 > 0 372 | ): # Have already extracted some states, so remove the already predicted part of Yf 373 | # Remove the already predicted part of future y 374 | Oy1 = projOrth(Yf, Xk)[ 375 | 1 376 | ] # Yf @ Xk.T @ np.linalg.pinv(Xk @ Xk.T) # Eq. (18) - Find the y observability matrix for Xk 377 | Yf = Yf - Oy1 @ Xk # Eq. (19) 378 | 379 | Oy1_Minus = Oy1[:-ny, :] # Eq. (20) 380 | Yf_Minus = Yf_Minus - Oy1_Minus @ Xk_Plus1 # Eq. (21) 381 | 382 | WS["YHat"] = projOrth(Yf, WS["Yp"])[ 383 | 0 384 | ] # Yf @ WS['Yp'].T @ np.linalg.pinv(WS['Yp'] @ WS['Yp'].T) @ WS['Yp'] 385 | Yp_Plus = np.concatenate((WS["Yp"], WS["Yii"])) 386 | WS["YHatMinus"] = projOrth(Yf_Minus, Yp_Plus)[ 387 | 0 388 | ] # Yf_Minus @ Yp_Plus.T @ np.linalg.pinv(Yp_Plus @ Yp_Plus.T) @ Yp_Plus # Eq. (23) 389 | 390 | # Take SVD of YHat 391 | WS["YHat_U"], WS["YHat_S"], YHat_V = linalg.svd( 392 | WS["YHat"], full_matrices=False, lapack_driver="gesvd" 393 | ) # Eq. (24) 394 | 395 | S2 = np.diag(WS["YHat_S"][:n2]) # Eq. (24) 396 | U2 = WS["YHat_U"][:, :n2] # Eq. (24) 397 | 398 | Oy = U2 @ S2 ** (1 / 2) # Eq. (25) 399 | Oy_Minus = Oy[:-ny, :] # Eq. (27) 400 | 401 | Xk2 = np.linalg.pinv(Oy) @ WS["YHat"] 402 | # Eq. (26) 403 | Xk2_Plus1 = np.linalg.pinv(Oy_Minus) @ WS["YHatMinus"] 404 | # Eq. (28) 405 | 406 | Xk = np.concatenate((Xk, Xk2)) # Eq. (29) 407 | Xk_Plus1 = np.concatenate((Xk_Plus1, Xk2_Plus1)) # Eq. (29) 408 | 409 | # Parameter identification 410 | if n1 > 0: 411 | # A associated with the z-related states 412 | A = projOrth(Xk_Plus1[:n1, :], Xk[:n1, :])[1] # Eq. (17) 413 | else: 414 | A = np.empty([0, 0]) 415 | 416 | if n2 > 0: 417 | A23 = projOrth(Xk_Plus1[n1:, :], Xk)[ 418 | 1 419 | ] # Xk_Plus1[n1:, :] @ Xk.T @ np.linalg.pinv(Xk @ Xk.T) # Eq. (30) 420 | if n1 > 0: 421 | A10 = np.concatenate((A, np.zeros([n1, n2])), axis=1) 422 | A = np.concatenate((A10, A23)) # Eq. (31) 423 | else: 424 | A = A23 425 | 426 | w = Xk_Plus1 - A @ Xk # Eq. (34) 427 | 428 | if nz > 0: 429 | Cz = projOrth(WS["Zii"], Xk)[ 430 | 1 431 | ] # WS['Zii'] @ Xk.T @ np.linalg.pinv(Xk @ Xk.T) # Eq. (33) 432 | else: 433 | Cz = np.empty([0, nx]) 434 | 435 | Cy = projOrth(WS["Yii"], Xk)[ 436 | 1 437 | ] # WS['Yii'] @ Xk.T @ np.linalg.pinv(Xk @ Xk.T) # Eq. (32) 438 | v = WS["Yii"] - Cy @ Xk # Eq. (34) 439 | 440 | # Compute noise covariances 441 | NA = w.shape[1] 442 | Q = (w @ w.T) / NA # Eq. (35) 443 | S = (w @ v.T) / NA # Eq. (35) 444 | R = (v @ v.T) / NA # Eq. (35) 445 | 446 | Q = (Q + Q.T) / 2 # Make precisely symmetric 447 | R = (R + R.T) / 2 # Make precisely symmetric 448 | 449 | s = LSSM.LSSM(params={"A": A, "C": Cy, "Q": Q, "R": R, "S": S}) 450 | if fit_Cz_via_KF and nz > 0: 451 | Cz = fitCzViaKFRegression(s, Y, Z, time_first) 452 | s.Cz = Cz 453 | 454 | s.YPrepModel = YPrepModel 455 | s.ZPrepModel = ZPrepModel 456 | 457 | if not return_WS: 458 | return s 459 | else: 460 | return s, WS 461 | -------------------------------------------------------------------------------- /source/PSID/PrepModel.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2020 University of Southern California 3 | See full notice in LICENSE.md 4 | Omid G. Sani and Maryam M. Shanechi 5 | Shanechi Lab, University of Southern California 6 | 7 | An object for keeping track of data preprocessing (mean removal and zscoring) 8 | """ 9 | 10 | import warnings 11 | 12 | import numpy as np 13 | 14 | 15 | class PrepModel: 16 | """Describes a preprocessing model to change mean/std of a time-series and undo that""" 17 | 18 | def __init__(self, mean=None, std=None, remove_mean=None, zscore=False): 19 | """See the fit method.""" 20 | self.mean = mean 21 | self.std = std 22 | self.remove_mean = remove_mean 23 | self.zscore = zscore 24 | 25 | def fit(self, Y, remove_mean=True, zscore=False, std_ddof=1, time_first=True): 26 | """Learns the preprocessing model from data 27 | Args: 28 | Y (numpy array or list of arrays): Input data. First dimension must be timeand the second 29 | dimension is the data. Can be an array of data in which case the stats will be 30 | learned from the concatenation of all segments along the first dimension. 31 | remove_mean (bool, optional): If True, will remove the mean of data. Defaults to True. 32 | zscore (bool, optional): If True, will zscore the data to have unit std in all dimensions. Defaults to False. 33 | std_ddof (int, optional): ddof argument for computing std. Defaults to 1. 34 | time_first (bool, optional): If true, will assume input data has time as the first dimension. 35 | Otherwise assumes time is the second dimension. In any case, model will by default 36 | treat new data as if time is the first dimension. Defaults to True. 37 | """ 38 | if zscore: 39 | remove_mean = True # Must also remove the mean for z-scoring 40 | 41 | if isinstance(Y, (list, dict)): 42 | if time_first: 43 | YCat = np.concatenate(Y, axis=0) 44 | else: 45 | YCat = np.concatenate(Y, axis=1) 46 | else: 47 | YCat = Y 48 | 49 | if not time_first: 50 | YCat = YCat.T 51 | 52 | yDim = YCat.shape[1] 53 | yMean = np.zeros(yDim) 54 | yStd = np.ones(yDim) 55 | if remove_mean: 56 | yMean = np.array(np.nanmean(YCat, axis=0)) 57 | if zscore: 58 | yStd = np.array(np.nanstd(YCat, axis=0, ddof=std_ddof)) 59 | if np.any(yStd == 0): 60 | warnings.warn( 61 | "{} dimension(s) of y (out of {}) are flat. Will skip scaling to unit variance for those dimensions.".format( 62 | np.sum(yStd == 0), yStd.size 63 | ) 64 | ) 65 | if np.all(yStd == 0): # No dimension can be z-scored 66 | zscore = False 67 | 68 | self.remove_mean = remove_mean 69 | self.zscore = zscore 70 | self.mean = yMean 71 | self.std = yStd 72 | self.stdDOF = std_ddof 73 | 74 | def get_mean(self, time_first=True): 75 | """Returns the mean, but transposes it if needed 76 | 77 | Args: 78 | time_first (bool, optional): If true, will return the mean a row vector, 79 | otherwise returns it as a row vector. Defaults to True. 80 | """ 81 | if time_first: 82 | return self.mean[np.newaxis, :] 83 | else: 84 | return self.mean[:, np.newaxis] 85 | 86 | def get_std(self, time_first=True): 87 | """Returns the std, but transposes it if needed 88 | 89 | Args: 90 | time_first (bool, optional): If true, will return the std a row vector, 91 | otherwise returns it as a row vector. Defaults to True. 92 | """ 93 | if time_first: 94 | return self.std[np.newaxis, :] 95 | else: 96 | return self.std[:, np.newaxis] 97 | 98 | def apply_segment(self, Y, time_first=True): 99 | """Applies the preprocessing on new data 100 | 101 | Args: 102 | Y (numpy array): Input data. First dimension must be time and the second 103 | dimension is the data. Can be an array of data. 104 | time_first (bool, optional): If False, will assume time is the second dimensions. 105 | Defaults to True. 106 | """ 107 | if self.remove_mean: 108 | Y = Y - self.get_mean(time_first) 109 | if self.zscore: 110 | okDims = self.std > 0 111 | if time_first: 112 | Y[:, okDims] = Y[:, okDims] / self.get_std(time_first)[:, okDims] 113 | else: 114 | Y[okDims, :] = Y[okDims, :] / self.get_std(time_first)[okDims, :] 115 | return Y 116 | 117 | def apply(self, Y, time_first=True): 118 | """Applies the preprocessing on new data 119 | 120 | Args: 121 | Y (numpy array or list of arrays): Input data. First dimension must be time and the second 122 | dimension is the data. Can be an array of data. 123 | time_first (bool, optional): If False, will assume time is the second dimensions. 124 | Defaults to True. 125 | """ 126 | if isinstance(Y, (list, tuple)): 127 | return [self.apply_segment(YThis, time_first) for YThis in Y] 128 | else: 129 | return self.apply_segment(Y, time_first) 130 | 131 | def apply_inverse_segment(self, Y, time_first=True): 132 | """Applies inverse of the preprocessing on new data (i.e. undoes the preprocessing) 133 | 134 | Args: 135 | Y (numpy array): Input data. First dimension must be time and the second 136 | dimension is the data. Can be an array of data. 137 | time_first (bool, optional): If False, will assume time is the second dimensions. 138 | Defaults to True. 139 | """ 140 | if self.zscore: 141 | okDims = self.std > 0 142 | if time_first: 143 | Y[:, okDims] = Y[:, okDims] * self.get_std(time_first)[:, okDims] 144 | else: 145 | Y[okDims, :] = Y[okDims, :] * self.get_std(time_first)[okDims, :] 146 | if self.remove_mean: 147 | mean = self.get_mean(time_first) 148 | Y = Y + mean 149 | return Y 150 | 151 | def apply_inverse(self, Y, time_first=True): 152 | """Applies inverse of the preprocessing on new data (i.e. undoes the preprocessing) 153 | 154 | Args: 155 | Y (numpy array or list of arrays): Input data. First dimension must be time and the second 156 | dimension is the data. Can be an array of data. 157 | time_first (bool, optional): If False, will assume time is the second dimensions. 158 | Defaults to True. 159 | """ 160 | if isinstance(Y, (list, tuple)): 161 | return [self.apply_inverse_segment(YThis, time_first) for YThis in Y] 162 | else: 163 | return self.apply_inverse_segment(Y, time_first) 164 | -------------------------------------------------------------------------------- /source/PSID/__init__.py: -------------------------------------------------------------------------------- 1 | from .PSID import PSID 2 | from .IPSID import IPSID 3 | from .LSSM import LSSM 4 | -------------------------------------------------------------------------------- /source/PSID/evaluation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2020 University of Southern California 3 | See full notice in LICENSE.md 4 | Omid G. Sani and Maryam M. Shanechi 5 | Shanechi Lab, University of Southern California 6 | 7 | Tools for evaluating system identification 8 | """ 9 | 10 | import warnings 11 | import numpy as np 12 | from sklearn.metrics import r2_score, mean_squared_error 13 | 14 | 15 | def evalPrediction(trueValue, prediction, measure): 16 | if prediction.shape[0] == 0: 17 | perf = np.empty(trueValue.shape[1]) 18 | perf[:] = np.nan 19 | return perf 20 | 21 | if measure == "CC": 22 | n = trueValue.shape[1] 23 | with warnings.catch_warnings(): 24 | warnings.simplefilter("ignore") 25 | R = np.corrcoef(trueValue, prediction, rowvar=False) 26 | perf = np.diag(R[n:, :n]) 27 | elif measure == "R2": 28 | perf = r2_score(trueValue, prediction, multioutput="raw_values") 29 | elif measure == "MSE": 30 | perf = mean_squared_error(trueValue, prediction, multioutput="raw_values") 31 | elif measure == "RMSE": 32 | MSE = evalPrediction(trueValue, prediction, "MSE") 33 | perf = np.sqrt(MSE) 34 | else: 35 | raise (Exception('Performance measure "{}" is not supported.'.format(measure))) 36 | return perf 37 | -------------------------------------------------------------------------------- /source/PSID/example/IPSID_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2020 University of Southern California 3 | See full notice in LICENSE.md 4 | Parsa Vahidi, Omid G. Sani and Maryam M. Shanechi 5 | Shanechi Lab, University of Southern California 6 | 7 | Example for using the IPSID algorithm 8 | """ 9 | 10 | import argparse, sys, os 11 | 12 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) 13 | 14 | import numpy as np 15 | import matplotlib.pyplot as plt 16 | from matplotlib import patches 17 | 18 | import PSID 19 | from PSID.evaluation import evalPrediction 20 | from PSID.MatHelper import loadmat 21 | from PSID.PrepModel import PrepModel 22 | 23 | 24 | def main(): 25 | 26 | # (1) IPSID examples 27 | ################################# 28 | ################################# 29 | sample_model_path = os.path.join( 30 | os.path.dirname(PSID.__file__), "example", "sample_model_IPSID.mat" 31 | ) 32 | 33 | parser = argparse.ArgumentParser( 34 | description="Run IPSID on an example simulated dataset" 35 | ) 36 | parser.add_argument( 37 | "--datafile", type=str, default=sample_model_path, help="Data file" 38 | ) 39 | 40 | args = parser.parse_args() 41 | 42 | # Load data 43 | print("Loading example model from {}".format(args.datafile)) 44 | data = loadmat(args.datafile) 45 | # This is an example model (shown in Fig. 2A) with 46 | # (a) 2 behaviorally relevant latent states x_k^(1) (corresponding to intrinsic behaviorally relevant dynamics), 47 | # (b) 4 other latent states x_k^(2) (corresponding to other intrinsic dynamics), 48 | # (c) 2 states that drive the external input (corresponding to input dynamics) 49 | 50 | # Generating some sample data from this model 51 | np.random.seed(42) # For exact reproducibility 52 | 53 | N = int(2e5) 54 | 55 | # Generating dynamical input u 56 | uSys = PSID.LSSM(params=data["uSys"]) 57 | u, _ = uSys.generateRealization(N) 58 | Au = uSys.A 59 | trueSys = PSID.LSSM(params=data["trueSys"]) 60 | trueSys.Dz = trueSys.dz 61 | y, x = trueSys.generateRealization(N, u=u) 62 | z = (trueSys.Cz @ x.T).T + (trueSys.Dz @ u.T).T 63 | 64 | # Add some z dynamics that are not encoded in y (i.e. epsilon) 65 | epsSys = PSID.LSSM(params=data["epsSys"]) 66 | _, x_eps = epsSys.generateRealization(N) 67 | z += (epsSys.Cz @ x_eps.T).T 68 | 69 | allYData, allZData, allUData = y, z, u 70 | 71 | # Given the above state-space model used by IPSID, it is important for the neural/behavior/input data to be zero-mean. 72 | # Starting version v1.1.0, IPSID by default internally removes the mean from the neural/behavior data and adds 73 | # it back to predictions, so the user does not need to handle this preprocessing. If the data is already zero-mean, 74 | # this mean-removal will simply subtract and add zeros to signals so everything will still work. 75 | # To cover this general case with data that is not zero-mean, let's artificially add some non-zero mean to the sample data: 76 | YMean = 10 * np.random.randn(allYData.shape[-1]) 77 | ZMean = 10 * np.random.randn(allZData.shape[-1]) 78 | UMean = 10 * np.random.randn(allUData.shape[-1]) 79 | allYData += YMean 80 | allZData += ZMean 81 | allUData += UMean 82 | # Also reflect this in the true model: 83 | trueSys.YPrepModel = PrepModel(mean=YMean, remove_mean=True) 84 | trueSys.ZPrepModel = PrepModel(mean=ZMean, remove_mean=True) 85 | trueSys.UPrepModel = PrepModel(mean=UMean, remove_mean=True) 86 | 87 | # Separate data into training and test data: 88 | trainInds = np.arange(np.round(0.5 * allYData.shape[0]), dtype=int) 89 | testInds = np.arange(1 + trainInds[-1], allYData.shape[0]) 90 | yTrain = allYData[trainInds, :] 91 | yTest = allYData[testInds, :] 92 | zTrain = allZData[trainInds, :] 93 | zTest = allZData[testInds, :] 94 | uTrain = allUData[trainInds, :] 95 | uTest = allUData[testInds, :] 96 | 97 | ## (Example 1) IPSID can be used to dissociate and extract only the 98 | # intrinsic behaviorally relevant latent states (with nx = n1 = 2) 99 | idSys1 = PSID.IPSID(yTrain, zTrain, uTrain, nx=2, n1=2, i=10) 100 | # You can also use the time_first=False argument if time is the second dimension: 101 | # idSys1 = PSID.IPSID(yTrain.T, zTrain.T, uTrain.T, nx=2, n1=2, i=10, time_first=False) 102 | 103 | # Predict behavior using the learned model 104 | zTestPred1, yTestPred1, xTestPred1 = idSys1.predict(yTest, uTest) 105 | 106 | # Compute R2 of decoding 107 | R2 = evalPrediction(zTest, zTestPred1, "R2") 108 | 109 | # Predict behavior using the true model for comparison 110 | zTestPredIdeal, yTestPredIdeal, xTestPredIdeal = trueSys.predict(yTest, uTest) 111 | R2Ideal = evalPrediction(zTest, zTestPredIdeal, "R2") 112 | 113 | print( 114 | "Behavior decoding R2:\n IPSID => {:.3g}, Ideal using true model => {:.3g}".format( 115 | np.mean(R2), np.mean(R2Ideal) 116 | ) 117 | ) 118 | 119 | ## (Example 2) Optionally, IPSID can additionally also learn the 120 | # behaviorally irrelevant latent states (with nx = 6, n1 = 2) 121 | idSys2 = PSID.IPSID(yTrain, zTrain, uTrain, nx=6, n1=2, i=10) 122 | 123 | # In addition to ideal behavior decoding, this model will also have ideal neural self-prediction 124 | zTestPred2, yTestPred2, xTestPred2 = idSys2.predict(yTest, uTest) 125 | yR2 = evalPrediction(yTest, yTestPred2, "R2") 126 | yR2Ideal = evalPrediction(yTest, yTestPredIdeal, "R2") 127 | print( 128 | "Neural self-prediction R2:\n IPSID => {:.3g}, Ideal using true model => {:.3g}".format( 129 | np.mean(yR2), np.mean(yR2Ideal) 130 | ) 131 | ) 132 | 133 | ## (Example 3) IPSID can be used if data is available in discontinuous segments (e.g. different trials) 134 | # In this case, y, z and u data segments must be provided as elements of a list 135 | # Trials do not need to have the same number of samples 136 | # Here, for example assume that trials start at every 1000 samples. 137 | # And each each trial has a random length of 900 to 990 samples 138 | trialStartInds = np.arange(0, allYData.shape[0] - 1000, 1000) 139 | trialDurRange = np.array([900, 990]) 140 | trialDur = np.random.randint( 141 | low=trialDurRange[0], high=1 + trialDurRange[1], size=trialStartInds.shape 142 | ) 143 | trialInds = [ 144 | trialStartInds[ti] + np.arange(trialDur[ti]) 145 | for ti in range(trialStartInds.size) 146 | ] 147 | yTrials = [allYData[trialIndsThis, :] for trialIndsThis in trialInds] 148 | zTrials = [allZData[trialIndsThis, :] for trialIndsThis in trialInds] 149 | uTrials = [allUData[trialIndsThis, :] for trialIndsThis in trialInds] 150 | 151 | # Separate data into training and test data: 152 | trainInds = np.arange(np.round(0.5 * len(yTrials)), dtype=int) 153 | testInds = np.arange(1 + trainInds[-1], len(yTrials)) 154 | yTrainTrials = [yTrials[ti] for ti in trainInds] 155 | yTestTrials = [yTrials[ti] for ti in testInds] 156 | zTrainTrials = [zTrials[ti] for ti in trainInds] 157 | zTestTrials = [zTrials[ti] for ti in testInds] 158 | uTrainTrials = [uTrials[ti] for ti in trainInds] 159 | uTestTrials = [uTrials[ti] for ti in testInds] 160 | 161 | idSys3 = PSID.IPSID(yTrainTrials, zTrainTrials, uTrainTrials, nx=2, n1=2, i=10) 162 | 163 | zPredTrials, yPredTrials, xPredTrials = idSys3.predict(yTestTrials, uTestTrials) 164 | zPredTrialsIdeal, yPredTrialsIdeal, xPredTrialsIdeal = trueSys.predict( 165 | yTestTrials, uTestTrials 166 | ) 167 | zTestA = np.concatenate(zTestTrials, axis=0) 168 | zPredA = np.concatenate(zPredTrials, axis=0) 169 | zPredIdealA = np.concatenate(zPredTrialsIdeal, axis=0) 170 | 171 | R2TrialBased = evalPrediction(zTestA, zPredA, "R2") 172 | R2TrialBasedIdeal = evalPrediction(zTestA, zPredIdealA, "R2") 173 | 174 | print( 175 | "Behavior decoding R2 (trial-based learning/decoding):\n IPSID => {:.3g}, Ideal using true model = {:.3g}".format( 176 | np.mean(R2TrialBased), np.mean(R2TrialBasedIdeal) 177 | ) 178 | ) 179 | 180 | # ######################################### 181 | # Plot the true and identified eigenvalues 182 | 183 | # (Example 1) Eigenvalues when only learning behaviorally relevant states 184 | idEigs1 = np.linalg.eig(idSys1.A)[0] 185 | 186 | # (Example 2) Additional eigenvalues when also learning behaviorally irrelevant states 187 | # The identified model is already in form of Eq. 1, with behaviorally irrelevant states 188 | # coming as the last 4 dimensions of the states in the identified model 189 | idEigs2 = np.linalg.eig(idSys2.A[2:, 2:])[0] 190 | 191 | relevantDims = ( 192 | trueSys.zDims - 1 193 | ) # Dimensions that drive both behavior and neural activity 194 | irrelevantDims = [ 195 | x for x in np.arange(trueSys.state_dim, dtype=int) if x not in relevantDims 196 | ] # Dimensions that only drive the neural activity 197 | trueEigsRelevant = np.linalg.eig(trueSys.A[np.ix_(relevantDims, relevantDims)])[0] 198 | trueEigsIrrelevant = np.linalg.eig( 199 | trueSys.A[np.ix_(irrelevantDims, irrelevantDims)] 200 | )[0] 201 | trueEigsInput = np.linalg.eig(Au)[0] 202 | 203 | fig = plt.figure(figsize=(8, 4)) 204 | axs = fig.subplots(1, 2) 205 | axs[1].remove() 206 | ax = axs[0] 207 | ax.axis("equal") 208 | ax.add_patch( 209 | patches.Circle((0, 0), radius=1, fill=False, color="black", alpha=0.2, ls="-") 210 | ) 211 | ax.plot([-1, 1, 0, 0, 0], [0, 0, 0, -1, 1], color="black", alpha=0.2, ls="-") 212 | ax.scatter( 213 | np.real(trueEigsInput), 214 | np.imag(trueEigsInput), 215 | marker="o", 216 | edgecolors="#800080", 217 | facecolors="none", 218 | label="Input eigenvalues", 219 | ) 220 | ax.scatter( 221 | np.real(trueEigsIrrelevant), 222 | np.imag(trueEigsIrrelevant), 223 | marker="o", 224 | edgecolors="#FF5733", 225 | facecolors="none", 226 | label="Other neural eigenvalues", 227 | ) 228 | ax.scatter( 229 | np.real(trueEigsRelevant), 230 | np.imag(trueEigsRelevant), 231 | marker="o", 232 | edgecolors="#50C878", 233 | facecolors="none", 234 | label="Behaviorally relevant neural eigenvalues", 235 | ) 236 | ax.scatter( 237 | np.real(idEigs1), 238 | np.imag(idEigs1), 239 | marker="x", 240 | facecolors="#138a33", 241 | label="IPSID Identified (stage 1)", 242 | ) 243 | ax.scatter( 244 | np.real(idEigs2), 245 | np.imag(idEigs2), 246 | marker="x", 247 | facecolors="#b04c1a", 248 | label="(optional) IPSID Identified (stage 2)", 249 | ) 250 | ax.set_title("True and identified eigevalues") 251 | ax.legend(bbox_to_anchor=(1.04, 0.5), loc="center left", borderaxespad=0) 252 | plt.show() 253 | 254 | # (2) IPSID example with the additional steps 255 | ################################################### 256 | ################################################### 257 | sample_model_path = os.path.join( 258 | os.path.dirname(PSID.__file__), "example", "sample_model_IPSID_add_step.mat" 259 | ) 260 | 261 | parser = argparse.ArgumentParser( 262 | description="Run IPSID with the additional steps on an example simulated dataset" 263 | ) 264 | parser.add_argument( 265 | "--datafile", type=str, default=sample_model_path, help="Data file" 266 | ) 267 | 268 | args = parser.parse_args() 269 | 270 | # Load data 271 | print("Loading example model from {}".format(args.datafile)) 272 | data = loadmat(args.datafile) 273 | # This is an example model (shown in Fig. 3) with 274 | # (a) 2 behaviorally relevant latent states, x_k^(1), encoded in neural activity y_k (corresponding to intrinsic behaviorally relevant neural dynamics), 275 | # (b) 2 other latent states, x_k^(2), encoded in neural activity y_k (corresponding to other intrinsic dynamics), 276 | # (c) 2 states that drive the external input (corresponding to input dynamics) 277 | # (d) 2 behaviorally relevant latent states, x_k^(3), driven by the input u_k but not encoded in neural activity y_k 278 | 279 | # Generating some sample data from this model 280 | np.random.seed(42) # For exact reproducibility 281 | 282 | N = int(2e5) 283 | 284 | # Generating dynamical input u 285 | uSys = PSID.LSSM(params=data["uSys"]) 286 | u, _ = uSys.generateRealization(N) 287 | Au = uSys.A 288 | trueSys = PSID.LSSM(params=data["trueSys"]) 289 | y, x = trueSys.generateRealization(N, u=u) 290 | z = (trueSys.Cz @ x.T).T 291 | 292 | allYData, allZData, allUData = y, z, u 293 | 294 | # Given the above state-space model used by IPSID, it is important for the neural/behavior/input data to be zero-mean. 295 | # Starting version v1.1.0, IPSID by default internally removes the mean from the neural/behavior data and adds 296 | # it back to predictions, so the user does not need to handle this preprocessing. If the data is already zero-mean, 297 | # this mean-removal will simply subtract and add zeros to signals so everything will still work. 298 | # To cover this general case with data that is not zero-mean, let's artificially add some non-zero mean to the sample data: 299 | YMean = 10 * np.random.randn(allYData.shape[-1]) 300 | ZMean = 10 * np.random.randn(allZData.shape[-1]) 301 | UMean = 10 * np.random.randn(allUData.shape[-1]) 302 | allYData += YMean 303 | allZData += ZMean 304 | allUData += UMean 305 | # Also reflect this in the true model: 306 | trueSys.YPrepModel = PrepModel(mean=YMean, remove_mean=True) 307 | trueSys.ZPrepModel = PrepModel(mean=ZMean, remove_mean=True) 308 | trueSys.UPrepModel = PrepModel(mean=UMean, remove_mean=True) 309 | 310 | # Separate data into training and test data: 311 | trainInds = np.arange(np.round(0.5 * allYData.shape[0]), dtype=int) 312 | testInds = np.arange(1 + trainInds[-1], allYData.shape[0]) 313 | yTrain = allYData[trainInds, :] 314 | yTest = allYData[testInds, :] 315 | zTrain = allZData[trainInds, :] 316 | zTest = allZData[testInds, :] 317 | uTrain = allUData[trainInds, :] 318 | uTest = allUData[testInds, :] 319 | 320 | ## (Example 3) IPSID with additional steps can be used to further 321 | # dissociate the intrinsic behaviorally relevant neural dynamics that 322 | # encoded in neural activity from those that are not. 323 | 324 | # all latent states [x1;x2,x3] (with nx = 6, n1 = 2, n3 = 2) 325 | idSys4 = PSID.IPSID( 326 | yTrain, 327 | zTrain, 328 | uTrain, 329 | nx=6, 330 | n1=2, 331 | i=10, 332 | remove_nonYrelated_fromX1=True, 333 | n_pre=4, 334 | n3=2, 335 | ) # n_pre should be equal to true n1+true n2 336 | 337 | # Predict behavior using the learned model 338 | zTestPred4, yTestPred4, xTestPred3 = idSys4.predict(yTest, uTest) 339 | 340 | # Compute R2 of decoding and neural self-prediction 341 | R2 = evalPrediction(zTest, zTestPred4, "R2") 342 | yR2 = evalPrediction(yTest, yTestPred4, "R2") 343 | 344 | # For comparison, let's also learn a model without the additional step 2 (only [x1;x2]) 345 | idSys4_low_dim = PSID.IPSID( 346 | yTrain, 347 | zTrain, 348 | uTrain, 349 | nx=4, 350 | n1=2, 351 | i=10, 352 | remove_nonYrelated_fromX1=True, 353 | n_pre=4, 354 | n3=0, 355 | ) # n_pre should be equal to true n1+true n2 356 | zTestPred4_low_dim, yTestPred4_low_dim, xTestPred4_low_dim = idSys4_low_dim.predict( 357 | yTest, uTest 358 | ) 359 | R2_low_dim = evalPrediction(zTest, zTestPred4_low_dim, "R2") 360 | yR2_low_dim = evalPrediction(yTest, yTestPred4_low_dim, "R2") 361 | 362 | # Predict using the true model for comparison 363 | zTestPredIdeal, yTestPredIdeal, xTestPredIdeal = trueSys.predict(yTest, uTest) 364 | R2Ideal = evalPrediction(zTest, zTestPredIdeal, "R2") 365 | yR2Ideal = evalPrediction(yTest, yTestPredIdeal, "R2") 366 | 367 | print( 368 | "Behavior decoding R2:\n IPSID => {:.3g}, IPSID (without additional step 2) => {:.3g}, Ideal using true model => {:.3g}".format( 369 | np.mean(R2), np.mean(R2_low_dim), np.mean(R2Ideal) 370 | ) 371 | ) 372 | print( 373 | "Neural self-prediction R2:\n IPSID => {:.3g}, IPSID (without additional step 2) => {:.3g}, Ideal using true model => {:.3g}".format( 374 | np.mean(yR2), np.mean(yR2_low_dim), np.mean(yR2Ideal) 375 | ) 376 | ) 377 | 378 | # ######################################### 379 | # Plot the true and identified eigenvalues for IPSID with additional steps 380 | 381 | # Intrinsic behaviorally relevant eigenvalues encoded in neural activity 382 | idEigs1 = np.linalg.eig(idSys4_low_dim.A[:2, :2])[0] 383 | 384 | # Other intrinsic eigenvalues encoded in neural activity 385 | idEigs2 = np.linalg.eig(idSys4_low_dim.A[2:, 2:])[0] 386 | 387 | # Behaviorally relevant eigenvalues not encoded in neural activity 388 | idEigs3 = np.linalg.eig(idSys4.A[4:, 4:])[0] 389 | 390 | relevantDims = ( 391 | trueSys.zDims - 1 392 | ) # Dimensions that drive both behavior and neural activity 393 | irrelevantDims = [2, 3] # Dimensions that only drive the neural activity 394 | trueEigsRelevant = np.linalg.eig(trueSys.A[np.ix_(relevantDims, relevantDims)])[0] 395 | trueEigsIrrelevant = np.linalg.eig( 396 | trueSys.A[np.ix_(irrelevantDims, irrelevantDims)] 397 | )[0] 398 | trueEigsInput = np.linalg.eig(Au)[0] 399 | trueEigsNonEncoded = np.linalg.eig(trueSys.A[4:, 4:])[0] 400 | 401 | fig = plt.figure(figsize=(8, 4)) 402 | axs = fig.subplots(1, 2) 403 | axs[1].remove() 404 | ax = axs[0] 405 | ax.axis("equal") 406 | ax.add_patch( 407 | patches.Circle((0, 0), radius=1, fill=False, color="black", alpha=0.2, ls="-") 408 | ) 409 | ax.plot([-1, 1, 0, 0, 0], [0, 0, 0, -1, 1], color="black", alpha=0.2, ls="-") 410 | ax.scatter( 411 | np.real(trueEigsInput), 412 | np.imag(trueEigsInput), 413 | marker="o", 414 | edgecolors="#800080", 415 | facecolors="none", 416 | label="Input eigenvalues", 417 | ) 418 | ax.scatter( 419 | np.real(trueEigsIrrelevant), 420 | np.imag(trueEigsIrrelevant), 421 | marker="o", 422 | edgecolors="#FF5733", 423 | facecolors="none", 424 | label="Other neural eigenvalues", 425 | ) 426 | ax.scatter( 427 | np.real(trueEigsRelevant), 428 | np.imag(trueEigsRelevant), 429 | marker="o", 430 | edgecolors="#50C878", 431 | facecolors="none", 432 | label="Behaviorally relevant neural eigenvalues", 433 | ) 434 | ax.scatter( 435 | np.real(trueEigsNonEncoded), 436 | np.imag(trueEigsNonEncoded), 437 | marker="o", 438 | edgecolors="#000000", 439 | facecolors="none", 440 | label="Behaviorally relevant not encoded in neural activity eigenvalues", 441 | ) 442 | ax.scatter( 443 | np.real(idEigs1), 444 | np.imag(idEigs1), 445 | marker="x", 446 | facecolors="#138a33", 447 | label="IPSID Identified (stage 1)", 448 | ) 449 | ax.scatter( 450 | np.real(idEigs2), 451 | np.imag(idEigs2), 452 | marker="x", 453 | facecolors="#b04c1a", 454 | label="(optional) IPSID Identified (stage 2)", 455 | ) 456 | ax.scatter( 457 | np.real(idEigs3), 458 | np.imag(idEigs3), 459 | marker="x", 460 | facecolors="#000000", 461 | label="(optional) IPSID Identified in optional additional step 2", 462 | ) 463 | 464 | ax.set_title("True and identified eigevalues") 465 | ax.legend(bbox_to_anchor=(1.04, 0.5), loc="center left", borderaxespad=0) 466 | plt.show() 467 | 468 | ## (Example 4) IPSID with additional steps can also be used if data is available 469 | # in discontinuous segments (e.g. different trials) 470 | # In this case, y, z and u data segments must be provided as elements of a list 471 | # Trials do not need to have the same number of samples 472 | # Here, for example assume that trials start at every 1000 samples. 473 | # And each each trial has a random length of 900 to 990 samples 474 | trialStartInds = np.arange(0, allYData.shape[0] - 1000, 1000) 475 | trialDurRange = np.array([900, 990]) 476 | trialDur = np.random.randint( 477 | low=trialDurRange[0], high=1 + trialDurRange[1], size=trialStartInds.shape 478 | ) 479 | trialInds = [ 480 | trialStartInds[ti] + np.arange(trialDur[ti]) 481 | for ti in range(trialStartInds.size) 482 | ] 483 | yTrials = [allYData[trialIndsThis, :] for trialIndsThis in trialInds] 484 | zTrials = [allZData[trialIndsThis, :] for trialIndsThis in trialInds] 485 | uTrials = [allUData[trialIndsThis, :] for trialIndsThis in trialInds] 486 | 487 | # Separate data into training and test data: 488 | trainInds = np.arange(np.round(0.5 * len(yTrials)), dtype=int) 489 | testInds = np.arange(1 + trainInds[-1], len(yTrials)) 490 | yTrainTrials = [yTrials[ti] for ti in trainInds] 491 | yTestTrials = [yTrials[ti] for ti in testInds] 492 | zTrainTrials = [zTrials[ti] for ti in trainInds] 493 | zTestTrials = [zTrials[ti] for ti in testInds] 494 | uTrainTrials = [uTrials[ti] for ti in trainInds] 495 | uTestTrials = [uTrials[ti] for ti in testInds] 496 | 497 | idSys4 = PSID.IPSID( 498 | yTrainTrials, 499 | zTrainTrials, 500 | uTrainTrials, 501 | nx=6, 502 | n1=2, 503 | i=10, 504 | remove_nonYrelated_fromX1=True, 505 | n_pre=4, 506 | n3=2, 507 | ) # n_pre should be equal to true n1+true n2 508 | 509 | zPredTrials, yPredTrials, xPredTrials = idSys4.predict(yTestTrials, uTestTrials) 510 | zPredA = np.concatenate(zPredTrials, axis=0) 511 | yPredA = np.concatenate(yPredTrials, axis=0) 512 | 513 | zPredTrialsIdeal, yPredTrialsIdeal, xPredTrialsIdeal = trueSys.predict( 514 | yTestTrials, uTestTrials 515 | ) 516 | zPredIdealA = np.concatenate(zPredTrialsIdeal, axis=0) 517 | yPredIdealA = np.concatenate(yPredTrialsIdeal, axis=0) 518 | 519 | zTestA = np.concatenate(zTestTrials, axis=0) 520 | yTestA = np.concatenate(yTestTrials, axis=0) 521 | R2TrialBased = evalPrediction(zTestA, zPredA, "R2") 522 | yR2TrialBased = evalPrediction(yTestA, yPredA, "R2") 523 | R2TrialBasedIdeal = evalPrediction(zTestA, zPredIdealA, "R2") 524 | yR2TrialBasedIdeal = evalPrediction(yTestA, yPredIdealA, "R2") 525 | 526 | # For comparison, let's also learn a model without the additional step 2 (only [x1;x2]) 527 | idSys4_low_dim = PSID.IPSID( 528 | yTrainTrials, 529 | zTrainTrials, 530 | uTrainTrials, 531 | nx=4, 532 | n1=2, 533 | i=10, 534 | remove_nonYrelated_fromX1=True, 535 | n_pre=4, 536 | n3=0, 537 | ) # n_pre should be equal to true n1+true n2 538 | zPredTrials_low_dim, yPredTrials_low_dim, xPredTrials_low_dim = ( 539 | idSys4_low_dim.predict(yTestTrials, uTestTrials) 540 | ) 541 | zPredA_low_dim = np.concatenate(zPredTrials_low_dim, axis=0) 542 | yPredA_low_dim = np.concatenate(yPredTrials_low_dim, axis=0) 543 | R2TrialBased_low_dim = evalPrediction(zTestA, zPredA_low_dim, "R2") 544 | yR2TrialBased_low_dim = evalPrediction(yTestA, yPredA_low_dim, "R2") 545 | 546 | print( 547 | "\nBehavior decoding R2 (trial-based learning/decoding):\n IPSID => {:.3g}, IPSID (without additional step 2) => {:.3g}, Ideal using true model => {:.3g}".format( 548 | np.mean(R2TrialBased), 549 | np.mean(R2TrialBased_low_dim), 550 | np.mean(R2TrialBasedIdeal), 551 | ) 552 | ) 553 | print( 554 | "Neural self-prediction R2 (trial-based learning/decoding):\n IPSID => {:.3g}, IPSID (without additional step 2) => {:.3g}, Ideal using true model => {:.3g}".format( 555 | np.mean(yR2TrialBased), 556 | np.mean(yR2TrialBased_low_dim), 557 | np.mean(yR2TrialBasedIdeal), 558 | ) 559 | ) 560 | 561 | pass 562 | 563 | 564 | if __name__ == "__main__": 565 | main() 566 | -------------------------------------------------------------------------------- /source/PSID/example/PSID_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2020 University of Southern California 3 | See full notice in LICENSE.md 4 | Omid G. Sani and Maryam M. Shanechi 5 | Shanechi Lab, University of Southern California 6 | 7 | Example for using the PSID algorithm 8 | """ 9 | 10 | import argparse, sys, os 11 | 12 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) 13 | 14 | import numpy as np 15 | import matplotlib.pyplot as plt 16 | from matplotlib import patches 17 | 18 | import PSID 19 | from PSID.evaluation import evalPrediction 20 | from PSID.MatHelper import loadmat 21 | from PSID.PrepModel import PrepModel 22 | 23 | 24 | def main(): 25 | sample_model_path = os.path.join( 26 | os.path.dirname(PSID.__file__), "example", "sample_model.mat" 27 | ) 28 | 29 | parser = argparse.ArgumentParser( 30 | description="Run PSID on an example simulated dataset" 31 | ) 32 | parser.add_argument( 33 | "--datafile", type=str, default=sample_model_path, help="Data file" 34 | ) 35 | 36 | args = parser.parse_args() 37 | 38 | # Load data 39 | print("Loading example model from {}".format(args.datafile)) 40 | data = loadmat(args.datafile) 41 | # This is an example model (shown in Supplementary Fig. 1) with 42 | # (a) 2 behaviorally relevant latent states, 43 | # (b) 2 behaviorally irrelevant latent states, and 44 | # (c) 2 states that drive behavior but are not represented in neural activity 45 | 46 | # Let's first generate some sample data from this model 47 | np.random.seed(42) # For exact reproducibility 48 | 49 | N = int(2e4) # Total number of samples, the more data you have, 50 | # the more accurate the identification will be 51 | 52 | trueSys = PSID.LSSM(params=data["trueSys"]) 53 | y, x = trueSys.generateRealization(N) 54 | z = (trueSys.Cz @ x.T).T 55 | 56 | # Add some z dynamics that are not encoded in y (i.e. epsilon) 57 | epsSys = PSID.LSSM(params=data["epsSys"]) 58 | eps, _ = epsSys.generateRealization(N) 59 | z += eps 60 | 61 | allYData, allZData = y, z 62 | 63 | # Given the stable state-space model used by PSID, it is important for the neural/behavior data to be zero-mean. 64 | # Starting version v1.1.0, PSID by default internally removes the mean from the neural/behavior data and adds 65 | # it back to predictions, so the user does not need to handle this preprocessing. If the data is already zero-mean, 66 | # this mean-removal will simply subtract and add zeros to signals so everything will still work. 67 | # To cover this general case with data that is not zero-mean, only for this simulation, let's artificially add 68 | # some non-zero mean to the sample data: 69 | YMean = 10 * np.random.randn(allYData.shape[-1]) 70 | ZMean = 10 * np.random.randn(allZData.shape[-1]) 71 | allYData += YMean 72 | allZData += ZMean 73 | # Also reflect this in the true model: 74 | trueSys.YPrepModel = PrepModel(mean=YMean, remove_mean=True) 75 | trueSys.ZPrepModel = PrepModel(mean=ZMean, remove_mean=True) 76 | 77 | # Separate data into training and test data: 78 | trainInds = np.arange(np.round(0.5 * allYData.shape[0]), dtype=int) 79 | testInds = np.arange(1 + trainInds[-1], allYData.shape[0]) 80 | yTrain = allYData[trainInds, :] 81 | yTest = allYData[testInds, :] 82 | zTrain = allZData[trainInds, :] 83 | zTest = allZData[testInds, :] 84 | 85 | ## (Example 1) PSID can be used to dissociate and extract only the 86 | # behaviorally relevant latent states (with nx = n1 = 2) 87 | idSys1 = PSID.PSID(yTrain, zTrain, nx=2, n1=2, i=10) 88 | # You can also use the time_first=False argument if time is the second dimension: 89 | # idSys1 = PSID.PSID(yTrain.T, zTrain.T, nx=2, n1=2, i=10, time_first=False) 90 | 91 | # Predict behavior using the learned model 92 | zTestPred1, yTestPred1, xTestPred1 = idSys1.predict(yTest) 93 | 94 | # Compute R2 of decoding 95 | R2 = evalPrediction(zTest, zTestPred1, "R2") 96 | 97 | # Predict behavior using the true model for comparison 98 | zTestPredIdeal, yTestPredIdeal, xTestPredIdeal = trueSys.predict(yTest) 99 | R2Ideal = evalPrediction(zTest, zTestPredIdeal, "R2") 100 | 101 | print( 102 | "Behavior decoding R2:\n PSID => {:.3g}, Ideal using true model => {:.3g}".format( 103 | np.mean(R2), np.mean(R2Ideal) 104 | ) 105 | ) 106 | 107 | ## (Example 2) Optionally, PSID can additionally also learn the 108 | # behaviorally irrelevant latent states (with nx = 4, n1 = 2) 109 | idSys2 = PSID.PSID(yTrain, zTrain, nx=4, n1=2, i=10) 110 | 111 | # In addition to ideal behavior decoding, this model will also have ideal neural self-prediction 112 | zTestPred2, yTestPred2, xTestPred2 = idSys2.predict(yTest) 113 | yR22 = evalPrediction(yTest, yTestPred2, "R2") 114 | yR2Ideal = evalPrediction(yTest, yTestPredIdeal, "R2") 115 | print( 116 | "Neural self-prediction R2:\n PSID => {:.3g}, Ideal using true model => {:.3g}".format( 117 | np.mean(yR22), np.mean(yR2Ideal) 118 | ) 119 | ) 120 | 121 | ## (Example 3) PSID can be used if data is available in discontinuous segments (e.g. different trials) 122 | # In this case, y and z data segments must be provided as elements of a list 123 | # Trials do not need to have the same number of samples 124 | # Here, for example assume that trials start at every 1000 samples. 125 | # And each each trial has a random length of 500 to 900 samples 126 | trialStartInds = np.arange(0, allYData.shape[0] - 1000, 1000) 127 | trialDurRange = np.array([900, 990]) 128 | trialDur = np.random.randint( 129 | low=trialDurRange[0], high=1 + trialDurRange[1], size=trialStartInds.shape 130 | ) 131 | trialInds = [ 132 | trialStartInds[ti] + np.arange(trialDur[ti]) 133 | for ti in range(trialStartInds.size) 134 | ] 135 | yTrials = [allYData[trialIndsThis, :] for trialIndsThis in trialInds] 136 | zTrials = [allZData[trialIndsThis, :] for trialIndsThis in trialInds] 137 | 138 | # Separate data into training and test data: 139 | trainInds = np.arange(np.round(0.5 * len(yTrials)), dtype=int) 140 | testInds = np.arange(1 + trainInds[-1], len(yTrials)) 141 | yTrain = [yTrials[ti] for ti in trainInds] 142 | yTest = [yTrials[ti] for ti in testInds] 143 | zTrain = [zTrials[ti] for ti in trainInds] 144 | zTest = [zTrials[ti] for ti in testInds] 145 | 146 | idSys3 = PSID.PSID(yTrain, zTrain, nx=2, n1=2, i=10) 147 | 148 | for ti in range(len(yTest)): 149 | zPredThis, yPredThis, xPredThis = idSys3.predict(yTest[ti]) 150 | zPredThisIdeal, yPredThisIdeal, xPredThisIdeal = trueSys.predict(yTest[ti]) 151 | if ti == 0: 152 | zTestA = zTest[ti] 153 | zPredA = zPredThis 154 | zPredIdealA = zPredThisIdeal 155 | else: 156 | zTestA = np.concatenate((zTestA, zTest[ti]), axis=0) 157 | zPredA = np.concatenate((zPredA, zPredThis), axis=0) 158 | zPredIdealA = np.concatenate((zPredIdealA, zPredThisIdeal), axis=0) 159 | 160 | R2TrialBased = evalPrediction(zTestA, zPredA, "R2") 161 | R2TrialBasedIdeal = evalPrediction(zTestA, zPredIdealA, "R2") 162 | 163 | print( 164 | "Behavior decoding R2 (trial-based learning/decoding):\n PSID => {:.3g}, Ideal using true model = {:.3g}".format( 165 | np.mean(R2TrialBased), np.mean(R2TrialBasedIdeal) 166 | ) 167 | ) 168 | 169 | # ######################################### 170 | # Plot the true and identified eigenvalues 171 | 172 | # (Example 1) Eigenvalues when only learning behaviorally relevant states 173 | idEigs1 = np.linalg.eig(idSys1.A)[0] 174 | 175 | # (Example 2) Additional eigenvalues when also learning behaviorally irrelevant states 176 | # The identified model is already in form of Eq. 4, with behaviorally irrelevant states 177 | # coming as the last 2 dimensions of the states in the identified model 178 | idEigs2 = np.linalg.eig(idSys2.A[2:, 2:])[0] 179 | 180 | relevantDims = ( 181 | trueSys.zDims - 1 182 | ) # Dimensions that drive both behavior and neural activity 183 | irrelevantDims = [ 184 | x for x in np.arange(trueSys.state_dim, dtype=int) if x not in relevantDims 185 | ] # Dimensions that only drive the neural activity 186 | trueEigsRelevant = np.linalg.eig(trueSys.A[np.ix_(relevantDims, relevantDims)])[0] 187 | trueEigsIrrelevant = np.linalg.eig( 188 | trueSys.A[np.ix_(irrelevantDims, irrelevantDims)] 189 | )[0] 190 | nonEncodedEigs = np.linalg.eig(data["epsSys"]["a"])[ 191 | 0 192 | ] # Eigenvalues for states that only drive behavior 193 | 194 | fig = plt.figure(figsize=(8, 4)) 195 | axs = fig.subplots(1, 2) 196 | axs[1].remove() 197 | ax = axs[0] 198 | ax.axis("equal") 199 | ax.add_patch( 200 | patches.Circle((0, 0), radius=1, fill=False, color="black", alpha=0.2, ls="-") 201 | ) 202 | ax.plot([-1, 1, 0, 0, 0], [0, 0, 0, -1, 1], color="black", alpha=0.2, ls="-") 203 | ax.scatter( 204 | np.real(nonEncodedEigs), 205 | np.imag(nonEncodedEigs), 206 | marker="o", 207 | edgecolors="#0000ff", 208 | facecolors="none", 209 | label="Not encoded in neural signals", 210 | ) 211 | ax.scatter( 212 | np.real(trueEigsIrrelevant), 213 | np.imag(trueEigsIrrelevant), 214 | marker="o", 215 | edgecolors="#ff0000", 216 | facecolors="none", 217 | label="Behaviorally irrelevant", 218 | ) 219 | ax.scatter( 220 | np.real(trueEigsRelevant), 221 | np.imag(trueEigsRelevant), 222 | marker="o", 223 | edgecolors="#00ff00", 224 | facecolors="none", 225 | label="Behaviorally relevant", 226 | ) 227 | ax.scatter( 228 | np.real(idEigs1), 229 | np.imag(idEigs1), 230 | marker="x", 231 | facecolors="#00aa00", 232 | label="PSID Identified (stage 1)", 233 | ) 234 | ax.scatter( 235 | np.real(idEigs2), 236 | np.imag(idEigs2), 237 | marker="x", 238 | facecolors="#aa0000", 239 | label="(optional) PSID Identified (stage 2)", 240 | ) 241 | ax.set_title("True and identified eigevalues") 242 | ax.legend(bbox_to_anchor=(1.04, 0.5), loc="center left", borderaxespad=0) 243 | plt.show() 244 | 245 | pass 246 | 247 | 248 | if __name__ == "__main__": 249 | main() 250 | -------------------------------------------------------------------------------- /source/PSID/example/PSID_tutorial.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "id": "isXmUBaxJanB" 7 | }, 8 | "source": [ 9 | "Written by: Omid G. Sani \n", 10 | "Last update: June 17, 2021" 11 | ] 12 | }, 13 | { 14 | "cell_type": "markdown", 15 | "metadata": { 16 | "id": "0pXbrXQPKQRF" 17 | }, 18 | "source": [ 19 | "# What is PSID?\n", 20 | "\n", 21 | "PSID stands for preferential subspace identification, a method for dynamic modeling of time-series data, while prioritizing the dynamics shared with another time-series. \n", 22 | "\n", 23 | "For example, given signals $y_k$ (e.g. neural signals) and $z_k$ (e.g behavior), PSID learns a dynamic model for $y_k$ while prioritizing the dynamics that are relevant to $z_k$.\n", 24 | "\n", 25 | "For the derivation and results in real neural data see the paper below.\n", 26 | "\n", 27 | "**Publication:**\n", 28 | "\n", 29 | "Omid G. Sani, Hamidreza Abbaspourazad, Yan T. Wong, Bijan Pesaran, Maryam M. Shanechi. *Modeling behaviorally relevant neural dynamics enabled by preferential subspace identification*. Nature Neuroscience 24, 140–149 (2021). https://doi.org/10.1038/s41593-020-00733-0\n", 30 | "\n", 31 | "View-only full-text link: https://rdcu.be/b993t\n", 32 | "\n", 33 | "Original preprint: https://doi.org/10.1101/808154\n", 34 | "\n", 35 | "You can also find a summary of the paper in the following Twitter thread: https://twitter.com/MaryamShanechi/status/1325835609345122304" 36 | ] 37 | }, 38 | { 39 | "cell_type": "markdown", 40 | "metadata": { 41 | "id": "BSlPMSM5Ai3P" 42 | }, 43 | "source": [ 44 | "# Installing PSID\n", 45 | "To use PSID, you can either get the source code from [the PSID Github repository](https://github.com/ShanechiLab/PSID), or install it in your Python environment using pip:\n", 46 | "\n", 47 | "\n", 48 | "```\n", 49 | "pip install PSID\n", 50 | "```\n", 51 | "\n", 52 | "You can find the usage license in [LICENSE.md](https://github.com/ShanechiLab/PyPSID/blob/main/LICENSE.md). For this notebook, we will also start by installing PSID from pip." 53 | ] 54 | }, 55 | { 56 | "cell_type": "code", 57 | "execution_count": 40, 58 | "metadata": { 59 | "colab": { 60 | "base_uri": "https://localhost:8080/" 61 | }, 62 | "id": "DZFHrfUEAYmC", 63 | "outputId": "2c807175-d8ba-4c0f-f75e-cc191c653876" 64 | }, 65 | "outputs": [ 66 | { 67 | "name": "stdout", 68 | "output_type": "stream", 69 | "text": [ 70 | "Requirement already satisfied: PSID in c:\\codes\\envs\\py37\\lib\\site-packages (1.1.0)\n" 71 | ] 72 | } 73 | ], 74 | "source": [ 75 | "!pip install PSID --upgrade" 76 | ] 77 | }, 78 | { 79 | "cell_type": "markdown", 80 | "metadata": { 81 | "id": "KsDpingpAhpK" 82 | }, 83 | "source": [ 84 | "# Using PSID\n", 85 | "## Modeling data\n", 86 | "To use PSID, you first need to import the library by running:\n", 87 | "```\n", 88 | "import PSID\n", 89 | "```\n", 90 | "You can then use its main data modeling function as:\n", 91 | "```\n", 92 | "idSys = PSID.PSID(y, z, nx, n1, i);\n", 93 | "```\n", 94 | "With the following arguments:\n", 95 | "- `y` and `z`: Neural (e.g. LFP signal powers or spike counts) and behavioral data (e.g. joint angles, hand position, etc), respectively. Dimensions are: time x data dimension (this can be changed with an optional argument documented in the code).\n", 96 | "- `nx`: the total dimension of the latent state in the model.\n", 97 | "- `n1`: the number of latent state dimensions that are going to be dedicated to behaviorally relevant neural dynamics.\n", 98 | "- `i`: the subspace horizon used for modeling. There is more on the choice of `i` later in this notebook, but numbers such as 5 or 10 are typically suitable values for `i`.\n", 99 | "\n", 100 | "And the following output:\n", 101 | "- `idSys`: an object containing all the learned model parameters ($A$, $C_y$, $C_z$, etc) and some prediction, etc methods. There is more on the model structure later in this notebook.\n", 102 | "\n", 103 | "## Using the model for dimension reduction, state estimation, and decoding\n", 104 | "For a learned PSID model `idSys`, you can use the `predict` method to extract the latent state and predict behavior and neural activity given any new neural data as:\n", 105 | "```\n", 106 | "zPred, yPred, xPred = idSys.predict(yTest)\n", 107 | "```\n", 108 | "With the argument:\n", 109 | "- `yTest`: Neural activity `y` in the test data. Dimensions are: time x data dimension.\n", 110 | "\n", 111 | "And outputs (all dimensions are time x data dimension):\n", 112 | "- `zPred`: Prediction of behavior using past neural activity at each data point.\n", 113 | "- `yPred`: Prediction of neural activity using past neural activity at each data point.\n", 114 | "- `xPred`: The latent state extarcted at each data point.\n", 115 | "\n", 116 | "We will next go through a complete example of using PSID in data.\n", 117 | "\n", 118 | "# A complete example\n", 119 | "In this example, we will use PSID to model some data. First, we import PSID and a few other useful tools from PSID and other libraries." 120 | ] 121 | }, 122 | { 123 | "cell_type": "code", 124 | "execution_count": 41, 125 | "metadata": { 126 | "id": "bcBhkYe_Bt22" 127 | }, 128 | "outputs": [], 129 | "source": [ 130 | "import argparse, sys, os\n", 131 | "sys.path.insert(0, os.path.join('..', '..'))\n", 132 | "\n", 133 | "import numpy as np\n", 134 | "import matplotlib.pyplot as plt\n", 135 | "from matplotlib import patches\n", 136 | "\n", 137 | "import PSID\n", 138 | "from PSID.evaluation import evalPrediction\n", 139 | "from PSID.MatHelper import loadmat" 140 | ] 141 | }, 142 | { 143 | "cell_type": "markdown", 144 | "metadata": {}, 145 | "source": [ 146 | "Let's start by loading an example model:" 147 | ] 148 | }, 149 | { 150 | "cell_type": "code", 151 | "execution_count": 42, 152 | "metadata": { 153 | "colab": { 154 | "base_uri": "https://localhost:8080/" 155 | }, 156 | "id": "2DSKNITZB4r-", 157 | "outputId": "a33507ed-9df3-4041-d227-06b0e6317125" 158 | }, 159 | "outputs": [ 160 | { 161 | "name": "stdout", 162 | "output_type": "stream", 163 | "text": [ 164 | "Loading example model from ..\\..\\PSID\\example\\sample_model.mat\n" 165 | ] 166 | } 167 | ], 168 | "source": [ 169 | "# Load data\n", 170 | "sample_model_path = os.path.join(os.path.dirname(PSID.__file__), 'example', 'sample_model.mat')\n", 171 | " \n", 172 | "print('Loading example model from {}'.format(sample_model_path))\n", 173 | "data = loadmat(sample_model_path)\n", 174 | "# This is an example model (shown in Supplementary Fig. 1) with \n", 175 | "# (a) 2 behaviorally relevant latent states, \n", 176 | "# (b) 2 behaviorally irrelevant latent states, and \n", 177 | "# (c) 2 states that drive behavior but are not represented in neural activity" 178 | ] 179 | }, 180 | { 181 | "cell_type": "markdown", 182 | "metadata": { 183 | "id": "TKjZYzGV4Oya" 184 | }, 185 | "source": [ 186 | "The PSID model looks like this:\n", 187 | "\n", 188 | "$$ x_{k+1} = A x_k + w_k $$ \n", 189 | "\n", 190 | "$$ y_k = C_y x_k + v_k $$ \n", 191 | "\n", 192 | "$$ z_k = C_z x_k + \\epsilon_k $$ \n", 193 | "\n", 194 | "where $y_k \\in \\!R^{n_y}$ is the neural activity, $z_k \\in \\!R^{n_z}$ is the behavior, and $x_k \\in \\!R^{n_x}$ is the latent state the describes the dynamics in both. Note that in general $y_k$ and $z_k$ could also be any other two signals (e.g. brain activity from two regions or even non-neural signals), but here we will refer these signals as neural activity and behavior, respectively. Importantly, PSID learns the model in the following format\n", 195 | "\n", 196 | "$$\n", 197 | "x_k = \\begin{bmatrix}\n", 198 | "x_k^{(1)} \\\\\n", 199 | "x_k^{(2)}\n", 200 | "\\end{bmatrix}\n", 201 | "$$\n", 202 | "\n", 203 | "where the behaviorally relevant dimensions of latent state ($x_k^{(1)} \\in \\!R^{n_1}$), which are those that drive $z_k$, are separated from the other dimensions ($x_k^{(2)} \\in \\!R^{n_2}$ with $n_2=n_x-n_1$). There are many equivalent ways of writing a latent state model such as this one, but PSID learns the one that uses minimal number of dimensions to explain behavior as parsimoniously as possible (you can find the precise definition in the paper). Critically, PSID can learn this minimal model (with only $x_k^{(1)}$) without having to also learn the rest of the model (the $x_k^{(2)}$ part). This is the concept of prioritization and allows PSID to learn the model more accurately, while requiring fewer training samples.\n", 204 | "\n", 205 | "\n", 206 | "Before going further, let's generate some sample data from this model." 207 | ] 208 | }, 209 | { 210 | "cell_type": "code", 211 | "execution_count": 43, 212 | "metadata": { 213 | "id": "-9bAIpLC4LaE" 214 | }, 215 | "outputs": [], 216 | "source": [ 217 | "# Generating some sample data from this model\n", 218 | "np.random.seed(42) # For exact reproducibility\n", 219 | "\n", 220 | "N = int(2e4)\n", 221 | "trueSys = PSID.LSSM(params=data['trueSys'])\n", 222 | "y, x = trueSys.generateRealization(N)\n", 223 | "z = (trueSys.Cz @ x.T).T\n", 224 | "\n", 225 | "# Add some z dynamics that are not encoded in y (i.e. epsilon)\n", 226 | "epsSys = PSID.LSSM(params=data['epsSys'])\n", 227 | "eps, _ = epsSys.generateRealization(N)\n", 228 | "z += eps\n", 229 | "\n", 230 | "allYData, allZData = y, z" 231 | ] 232 | }, 233 | { 234 | "cell_type": "markdown", 235 | "metadata": {}, 236 | "source": [ 237 | "Given the above state-space model used by PSID, it is important for the neural/behavior data to be zero-mean. \n", 238 | "Starting version v1.1.0, PSID by default internally removes the mean from the neural/behavior data and adds\n", 239 | "it back to predictions, so the user does not need to handle this preprocessing. If the data is already zero-mean,\n", 240 | "this mean-removal will simply subtract and add zeros to signals so everything will still work.\n", 241 | "To cover this general case with data that is not zero-mean, let's artificially add some non-zero mean to the sample data:" 242 | ] 243 | }, 244 | { 245 | "cell_type": "code", 246 | "execution_count": 44, 247 | "metadata": {}, 248 | "outputs": [], 249 | "source": [ 250 | "# Just for this simulation, let's artificially add some non-zero mean to the sample data to cover the general case with non-zero-mean data:\n", 251 | "YMean = 10*np.random.randn(allYData.shape[-1])\n", 252 | "ZMean = 10*np.random.randn(allZData.shape[-1])\n", 253 | "allYData += YMean\n", 254 | "allZData += ZMean\n", 255 | "# Also reflect this in the true model:\n", 256 | "from PSID.PrepModel import PrepModel\n", 257 | "trueSys.YPrepModel = PrepModel(mean=YMean, remove_mean=True)\n", 258 | "trueSys.ZPrepModel = PrepModel(mean=ZMean, remove_mean=True)" 259 | ] 260 | }, 261 | { 262 | "cell_type": "markdown", 263 | "metadata": { 264 | "id": "CQWYLQuwGWGY" 265 | }, 266 | "source": [ 267 | "Let's separate the data into training and test segments." 268 | ] 269 | }, 270 | { 271 | "cell_type": "code", 272 | "execution_count": 45, 273 | "metadata": { 274 | "id": "eiefXAMyExUa" 275 | }, 276 | "outputs": [], 277 | "source": [ 278 | "# Separate data into training and test data:\n", 279 | "trainInds = np.arange(np.round(0.5*allYData.shape[0]), dtype=int)\n", 280 | "testInds = np.arange(1+trainInds[-1], allYData.shape[0])\n", 281 | "yTrain = allYData[trainInds, :]\n", 282 | "yTest = allYData[testInds, :]\n", 283 | "zTrain = allZData[trainInds, :]\n", 284 | "zTest = allZData[testInds, :]" 285 | ] 286 | }, 287 | { 288 | "cell_type": "markdown", 289 | "metadata": { 290 | "id": "5_HZ9Xd_G7ew" 291 | }, 292 | "source": [ 293 | "We will next use PSID in two ways: \n", 294 | "1. Learn a model with a low-dimensional latent state that only focuses on learning the behaviorally relevant neural dynamics (i.e. uses stage 1 of PSID only). \n", 295 | "2. Learn a model that also learns other neural dynamics (i.e. uses both stages of PSID)\n", 296 | "\n", 297 | "We will then plot the learned models' eigenvalues (the eigenvalues of the $A$ matrix) to show that PSID learns the correct dynamics in each case.\n", 298 | "\n", 299 | "First, let's learn a model with a 2 dimensional latent state that only learns the behaviorally relevant neural dyanmics. For this, we pass the arguments nx=2 and n1=2 to the PSID function:" 300 | ] 301 | }, 302 | { 303 | "cell_type": "code", 304 | "execution_count": 46, 305 | "metadata": { 306 | "colab": { 307 | "base_uri": "https://localhost:8080/" 308 | }, 309 | "id": "KeAWDK3sGfmr", 310 | "outputId": "39967d6f-e864-4236-8c87-8af7dd489f1e" 311 | }, 312 | "outputs": [], 313 | "source": [ 314 | "## (Example 1) PSID can be used to dissociate and extract only the \n", 315 | "# behaviorally relevant latent states (with nx = n1 = 2)\n", 316 | "idSys1 = PSID.PSID(yTrain, zTrain, nx=2, n1=2, i=10)\n", 317 | "# You can also use the time_first=False argument if time is the second dimension:\n", 318 | "# idSys1 = PSID.PSID(yTrain.T, zTrain.T, nx=2, n1=2, i=10, time_first=False) " 319 | ] 320 | }, 321 | { 322 | "cell_type": "markdown", 323 | "metadata": {}, 324 | "source": [ 325 | "The PSID learning function returns an object (here idSys1) that contains the learned model parameters and can be used to extract the latent states and decode behavior in new data. To do this, we use the 'predict' method in the learned model:" 326 | ] 327 | }, 328 | { 329 | "cell_type": "code", 330 | "execution_count": 47, 331 | "metadata": {}, 332 | "outputs": [ 333 | { 334 | "name": "stdout", 335 | "output_type": "stream", 336 | "text": [ 337 | "Behavior decoding R2:\n", 338 | " PSID => 0.414, Ideal using true model => 0.414\n" 339 | ] 340 | } 341 | ], 342 | "source": [ 343 | "# Predict behavior using the learned model\n", 344 | "zTestPred1, yTestPred1, xTestPred1 = idSys1.predict(yTest)\n", 345 | "\n", 346 | "# Compute R2 of decoding\n", 347 | "R2 = evalPrediction(zTest, zTestPred1, 'R2')\n", 348 | "\n", 349 | "# Predict behavior using the true model for comparison\n", 350 | "zTestPredIdeal, yTestPredIdeal, xTestPredIdeal = trueSys.predict(yTest)\n", 351 | "R2Ideal = evalPrediction(zTest, zTestPredIdeal, 'R2')\n", 352 | "\n", 353 | "print('Behavior decoding R2:\\n PSID => {:.3g}, Ideal using true model => {:.3g}'.format(np.mean(R2), np.mean(R2Ideal)) )" 354 | ] 355 | }, 356 | { 357 | "cell_type": "markdown", 358 | "metadata": { 359 | "id": "LfhHQ3hQKkeq" 360 | }, 361 | "source": [ 362 | "We can see that the PSID model with a 2D latent state is as accurate in explaining behavior as the full model that has a 4D latent state. This is because the other 2 latent state dimensions in the true model explain dynamics that are exclusive to neural activity (i.e. are not behaviorally relevant).\n", 363 | "\n", 364 | "Optionally, PSID can also learn other latent states beyond the behaviorally relevant ones. For this, we pass the arguments nx=4 and n1=2 to the PSID function:" 365 | ] 366 | }, 367 | { 368 | "cell_type": "code", 369 | "execution_count": 48, 370 | "metadata": { 371 | "colab": { 372 | "base_uri": "https://localhost:8080/" 373 | }, 374 | "id": "a2chTfdSKW5d", 375 | "outputId": "2ad2af37-c80b-4a1b-dfba-9039a56ce8e0" 376 | }, 377 | "outputs": [ 378 | { 379 | "name": "stdout", 380 | "output_type": "stream", 381 | "text": [ 382 | "Neural self-prediction R2:\n", 383 | " PSID => 0.725, Ideal using true model => 0.725\n" 384 | ] 385 | } 386 | ], 387 | "source": [ 388 | "## (Example 2) Optionally, PSID can additionally also learn the \n", 389 | "# behaviorally irrelevant latent states (with nx = 4, n1 = 2)\n", 390 | "idSys2 = PSID.PSID(yTrain, zTrain, nx=4, n1=2, i=10)\n", 391 | "\n", 392 | "# In addition to ideal behavior decoding, this model will also have ideal neural self-prediction \n", 393 | "zTestPred2, yTestPred2, xTestPred2 = idSys2.predict(yTest)\n", 394 | "yR22 = evalPrediction(yTest, yTestPred2, 'R2')\n", 395 | "yR2Ideal = evalPrediction(yTest, yTestPredIdeal, 'R2')\n", 396 | "print('Neural self-prediction R2:\\n PSID => {:.3g}, Ideal using true model => {:.3g}'.format(np.mean(yR22), np.mean(yR2Ideal)))" 397 | ] 398 | }, 399 | { 400 | "cell_type": "markdown", 401 | "metadata": { 402 | "id": "LDvqwXjRLKgj" 403 | }, 404 | "source": [ 405 | "We can see that in this case, the model learned by PSID (which now has a 4D latent state) is also as good as the true model in terms of explaining neural activity. \n", 406 | "\n", 407 | "\n", 408 | "Finally, we can plot the eigenvalues of the $A$ matrix in each of the learned models and compare them with the eigenvalues of the $A$ matrix in the true model to see the accurate learning of behaviorally relevant (and optionally the other) dynamics by PSID. " 409 | ] 410 | }, 411 | { 412 | "cell_type": "code", 413 | "execution_count": 49, 414 | "metadata": { 415 | "colab": { 416 | "base_uri": "https://localhost:8080/", 417 | "height": 281 418 | }, 419 | "id": "rglPnehvLJ-2", 420 | "outputId": "57690508-4203-4361-bd15-25056eff6395" 421 | }, 422 | "outputs": [ 423 | { 424 | "data": { 425 | "image/png": "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ycmx3bFjh8vmzZsLjQvYHzp0qNVi6R3tKy0tTbBp06b87OzslLy8PNsTJ044AMCyZctKr127lpaVlZXS0NDA2b17918SGJfLxbBhwxrPnz/f4SLGBw4cEMlksgYvLy/dlClTqgMCAiLj4+ODPvnkE9eufu0uMjJSlZaW1u76vR2VT5s3b57/0qVLS65du5Z28ODB688//3ygcVt2drbd6dOnM//444+0tWvX+qrVavLBBx8U+Pv7q9PT01O3bt1aYHqcw4cPZxu3xcXFtVwb66h83D/+8Y/AdevW5bW3oD8ApKen24hEopa6sR999JHHggULStLT01OvXr2aFhQU1NQ2LqFQqD969Gh2ampq2unTpzNfffVVP71ejzNnzggPHz7skpycnHr06NHrV69ebfmjmTt37rDNmzfnpaSkpL3//vsF8+fP73DI+6OPPnK/6667Wnpe/9xOogAAF3dJREFUo0ePbjDdX1cMmdm5RsbeKPueHTOYGHuhXanu0deMiTRn+5+l0HqTQAGguLiY5+jo2HIx9OzZsy1lyvz9/bVjxoyp+89//iMUiUT6iIiIerlc3gQAM2fOrDx79qzDnDlzzJb+6sq+goODNQCgUChU169ftwGAY8eOOa5bt867sbGRU11dzZPL5Q0A/jKM5e7urs3Pzzd7vXL27NnD7ezs9H5+fuotW7bkAcCePXtyL1y4UHrs2DHHjRs3ep88edJp//79OZ2dn84mRHZUPu23335zysrKannDq6ur41ZVVXEA4N57760WCARUIBBoXV1dNQUFBT16z2+vfFxFRQW3traWe//999cBwN///veKU6dO/eUDSX5+Pt/V1bXl9R83blz92rVrfQoKCmwSEhKqzC3YoNfryeLFi/3Onz/vwOFwUFpaalNQUMD79ddfHaZNm1bdXLuVTpkypRowFCa4fPmyw4wZM4KN+2hqamr3D+rw4cOOX3/9tfu5c+dakj+PxwOfz6dVVVUcFxeXLg27DLkk6uLigtzcXHh7t3spg2EGnMrKSvj4+HTesB8Yh3BN70ucO9e/N4nU3t5e39TU1PLgjpJGe9eM2423g33Z2tq2bORyudBqtUSlUpEXX3xx2O+//54aEhKiWbp0qW9jY6PZJ6ZWqzlCodDsm+mOHTtu3HHHHX9ZPD42NrYhNja24bnnnqsMCQmJAJDT4RMAkJyc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", 426 | "text/plain": [ 427 | "
" 428 | ] 429 | }, 430 | "metadata": { 431 | "needs_background": "light" 432 | }, 433 | "output_type": "display_data" 434 | } 435 | ], 436 | "source": [ 437 | "# #########################################\n", 438 | "# Plot the true and identified eigenvalues \n", 439 | "\n", 440 | "# (Example 1) Eigenvalues when only learning behaviorally relevant states\n", 441 | "idEigs1 = np.linalg.eig(idSys1.A)[0]\n", 442 | "\n", 443 | "# (Example 2) Additional eigenvalues when also learning behaviorally irrelevant states\n", 444 | "# The identified model is already in form of Eq. 4, with behaviorally irrelevant states \n", 445 | "# coming as the last 2 dimensions of the states in the identified model\n", 446 | "idEigs2 = np.linalg.eig(idSys2.A[2:, 2:])[0]\n", 447 | "\n", 448 | "relevantDims = trueSys.zDims - 1 # Dimensions that drive both behavior and neural activity\n", 449 | "irrelevantDims = [x for x in np.arange(trueSys.state_dim, dtype=int) if x not in relevantDims] # Dimensions that only drive the neural activity\n", 450 | "trueEigsRelevant = np.linalg.eig(trueSys.A[np.ix_(relevantDims, relevantDims)])[0]\n", 451 | "trueEigsIrrelevant = np.linalg.eig(trueSys.A[np.ix_(irrelevantDims, irrelevantDims)])[0]\n", 452 | "nonEncodedEigs = np.linalg.eig(data['epsSys']['a'])[0] # Eigenvalues for states that only drive behavior\n", 453 | "\n", 454 | "fig = plt.figure(figsize=(8, 4))\n", 455 | "axs = fig.subplots(1, 2)\n", 456 | "axs[1].remove() \n", 457 | "ax = axs[0]\n", 458 | "ax.axis('equal')\n", 459 | "ax.add_patch( patches.Circle((0,0), radius=1, fill=False, color='black', alpha=0.2, ls='-') )\n", 460 | "ax.plot([-1,1,0,0,0], [0,0,0,-1,1], color='black', alpha=0.2, ls='-')\n", 461 | "ax.scatter(np.real(nonEncodedEigs), np.imag(nonEncodedEigs), marker='o', edgecolors='#0000ff', facecolors='none', label='Not encoded in neural signals')\n", 462 | "ax.scatter(np.real(trueEigsIrrelevant), np.imag(trueEigsIrrelevant), marker='o', edgecolors='#ff0000', facecolors='none', label='Behaviorally irrelevant')\n", 463 | "ax.scatter(np.real(trueEigsRelevant), np.imag(trueEigsRelevant), marker='o', edgecolors='#00ff00', facecolors='none', label='Behaviorally relevant')\n", 464 | "ax.scatter(np.real(idEigs1), np.imag(idEigs1), marker='x', facecolors='#00aa00', label='PSID Identified (stage 1)')\n", 465 | "ax.scatter(np.real(idEigs2), np.imag(idEigs2), marker='x', facecolors='#aa0000', label='(optional) PSID Identified (stage 2)')\n", 466 | "ax.set_title('True and identified eigevalues')\n", 467 | "ax.legend(bbox_to_anchor=(1.04,0.5), loc=\"center left\", borderaxespad=0)\n", 468 | "plt.show()" 469 | ] 470 | }, 471 | { 472 | "cell_type": "markdown", 473 | "metadata": { 474 | "id": "OIg0PhNeFcyj" 475 | }, 476 | "source": [ 477 | "# Using PSID with trial based data\n", 478 | "You can also use PSID if the data is available in separate chunks, for example across many trials. To do this, simply pass a python list with the data in each chunk/trial as the argument to PSID. The trials don't need to have the same number of samples either. \n", 479 | "\n", 480 | "Below is an example, where we break the same data as before in small chunks of random length, and then pass it to PSID." 481 | ] 482 | }, 483 | { 484 | "cell_type": "code", 485 | "execution_count": 50, 486 | "metadata": { 487 | "colab": { 488 | "base_uri": "https://localhost:8080/" 489 | }, 490 | "id": "-McGIeBhE90J", 491 | "outputId": "91fa16cf-aa58-4688-e695-5c675dd12886" 492 | }, 493 | "outputs": [ 494 | { 495 | "name": "stdout", 496 | "output_type": "stream", 497 | "text": [ 498 | "Behavior decoding R2 (trial-based learning/decoding):\n", 499 | " PSID => 0.412, Ideal using true model = 0.413\n" 500 | ] 501 | } 502 | ], 503 | "source": [ 504 | "## (Example 3) PSID can be used if data is available in discontinuous segments (e.g. different trials)\n", 505 | "# In this case, y and z data segments must be provided as elements of a list\n", 506 | "# Trials do not need to have the same number of samples\n", 507 | "# Here, for example assume that trials start at every 1000 samples.\n", 508 | "# And each each trial has a random length of 500 to 900 samples\n", 509 | "trialStartInds = np.arange(0, allYData.shape[0]-1000, 1000)\n", 510 | "trialDurRange = np.array([900, 990])\n", 511 | "trialDur = np.random.randint(low=trialDurRange[0], high=1+trialDurRange[1], size=trialStartInds.shape)\n", 512 | "trialInds = [trialStartInds[ti]+np.arange(trialDur[ti]) for ti in range(trialStartInds.size)] \n", 513 | "yTrials = [allYData[trialIndsThis, :] for trialIndsThis in trialInds] \n", 514 | "zTrials = [allZData[trialIndsThis, :] for trialIndsThis in trialInds] \n", 515 | "\n", 516 | "# Separate data into training and test data:\n", 517 | "trainInds = np.arange(np.round(0.5*len(yTrials)), dtype=int)\n", 518 | "testInds = np.arange(1+trainInds[-1], len(yTrials))\n", 519 | "yTrain = [yTrials[ti] for ti in trainInds]\n", 520 | "yTest = [yTrials[ti] for ti in testInds]\n", 521 | "zTrain = [zTrials[ti] for ti in trainInds]\n", 522 | "zTest = [zTrials[ti] for ti in testInds]\n", 523 | "\n", 524 | "idSys3 = PSID.PSID(yTrain, zTrain, nx=2, n1=2, i=10)\n", 525 | "\n", 526 | "for ti in range(len(yTest)):\n", 527 | " zPredThis, yPredThis, xPredThis = idSys3.predict(yTest[ti])\n", 528 | " zPredThisIdeal, yPredThisIdeal, xPredThisIdeal = trueSys.predict(yTest[ti])\n", 529 | " if ti == 0:\n", 530 | " zTestA = zTest[ti]\n", 531 | " zPredA = zPredThis\n", 532 | " zPredIdealA = zPredThisIdeal\n", 533 | " else:\n", 534 | " zTestA = np.concatenate( (zTestA, zTest[ti]), axis=0)\n", 535 | " zPredA = np.concatenate( (zPredA, zPredThis), axis=0)\n", 536 | " zPredIdealA = np.concatenate( (zPredIdealA, zPredThisIdeal), axis=0)\n", 537 | "\n", 538 | "R2TrialBased = evalPrediction(zTestA, zPredA, 'R2')\n", 539 | "R2TrialBasedIdeal = evalPrediction(zTestA, zPredIdealA, 'R2')\n", 540 | "\n", 541 | "print('Behavior decoding R2 (trial-based learning/decoding):\\n PSID => {:.3g}, Ideal using true model = {:.3g}'.format(np.mean(R2TrialBased), np.mean(R2TrialBasedIdeal)) )" 542 | ] 543 | }, 544 | { 545 | "cell_type": "markdown", 546 | "metadata": {}, 547 | "source": [ 548 | "# How to pick the state dimensions `nx` and `n1`?\n", 549 | "`nx` determines the total dimension of the latent state and `n1` determines how many of those dimensions will be prioritizing the inclusion of behaviorally relevant neural dynamics (i.e. will be extracted using stage 1 of PSID). So the values that you would select for these hyperparameters depend on the goal of modeling and on the data. Some examples use cases are:\n", 550 | "- If you want to perform dimension reduction, `nx` will be your desired target dimension. For example, to reduce dimension to 2 to plot low-dimensional visualizations of neural activity, you would use `nx=2`. Now if you want to reduce dimension while preserving as much behaviorally relevant neural dynamics as possible, you would use `n1=nx`. \n", 551 | "- If you want to find the best fit to data overall, you can perform a grid search over values of `nx` and `n1` and pick the value that achieves the best performance metric in the training data. For example, you could pick the `nx` and `n1` pair that achieves the best cross-validated behavior decoding in an inner-cross-validation within the training data.\n", 552 | "\n", 553 | "# How to pick the horizon `i`?\n", 554 | "The horizon `i` does not affect the model structure and only affects the intermediate linear algebra operations that PSID performs during the learning of the model. Nevertheless, different values of `i` may have different model learning performance. `i` needs to be at least 2, but also also determines the maximum `n1` and `nx` that can be used per: \n", 555 | "```\n", 556 | "n1 <= nz * i\n", 557 | "nx <= ny * i\n", 558 | "```\n", 559 | "So if you have a low dimensional y or z, you typically would choose larger values for `i`, and vice versa. It is also possible to select the best performing `i` via an inner cross-validation approach similar to `nx` and `n1` above." 560 | ] 561 | }, 562 | { 563 | "cell_type": "markdown", 564 | "metadata": {}, 565 | "source": [ 566 | "# Licence\n", 567 | "Copyright (c) 2020 University of Southern California \n", 568 | "See full notice in [LICENSE.md](https://github.com/ShanechiLab/PyPSID/blob/main/LICENSE.md) \n", 569 | "Omid G. Sani and Maryam M. Shanechi \n", 570 | "Shanechi Lab, University of Southern California" 571 | ] 572 | } 573 | ], 574 | "metadata": { 575 | "colab": { 576 | "collapsed_sections": [], 577 | "name": "PSID_example.ipynb", 578 | "provenance": [], 579 | "toc_visible": true 580 | }, 581 | "kernelspec": { 582 | "display_name": "Python 3.11.4 ('py311tf213')", 583 | "language": "python", 584 | "name": "python3" 585 | }, 586 | "language_info": { 587 | "codemirror_mode": { 588 | "name": "ipython", 589 | "version": 3 590 | }, 591 | "file_extension": ".py", 592 | "mimetype": "text/x-python", 593 | "name": "python", 594 | "nbconvert_exporter": "python", 595 | "pygments_lexer": "ipython3", 596 | "version": "3.11.4" 597 | }, 598 | "vscode": { 599 | "interpreter": { 600 | "hash": "f370938e660bea469f131c613689d5c02e8c75f5127e5a1eea02754ce8f2c2f6" 601 | } 602 | } 603 | }, 604 | "nbformat": 4, 605 | "nbformat_minor": 1 606 | } 607 | -------------------------------------------------------------------------------- /source/PSID/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShanechiLab/PyPSID/fb4387dbd59bde2b721e54ef821ce12d32a79a04/source/PSID/example/__init__.py -------------------------------------------------------------------------------- /source/PSID/example/sample_model.mat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShanechiLab/PyPSID/fb4387dbd59bde2b721e54ef821ce12d32a79a04/source/PSID/example/sample_model.mat -------------------------------------------------------------------------------- /source/PSID/example/sample_model_IPSID.mat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShanechiLab/PyPSID/fb4387dbd59bde2b721e54ef821ce12d32a79a04/source/PSID/example/sample_model_IPSID.mat -------------------------------------------------------------------------------- /source/PSID/example/sample_model_IPSID_add_step.mat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShanechiLab/PyPSID/fb4387dbd59bde2b721e54ef821ce12d32a79a04/source/PSID/example/sample_model_IPSID_add_step.mat -------------------------------------------------------------------------------- /source/PSID/tests/test_PrepModel.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2020 University of Southern California 3 | See full notice in LICENSE.md 4 | Omid G. Sani and Maryam M. Shanechi 5 | Shanechi Lab, University of Southern California 6 | 7 | Tests the PrepModel object 8 | """ 9 | 10 | import unittest 11 | import sys, os, copy 12 | 13 | sys.path.insert(0, os.path.dirname(__file__)) 14 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) 15 | 16 | import numpy as np 17 | 18 | 19 | class TestPrepModel(unittest.TestCase): 20 | def test_preprocessing(self): 21 | from PSID.PrepModel import PrepModel 22 | 23 | np.random.seed(42) 24 | 25 | arg_sets = [ 26 | {"remove_mean": True, "zscore": True}, 27 | {"remove_mean": False, "zscore": True}, 28 | {"remove_mean": True, "zscore": False}, 29 | {"remove_mean": False, "zscore": False}, 30 | {"remove_mean": True, "zscore": True, "std_ddof": 0}, 31 | ] 32 | 33 | for args in arg_sets: 34 | with self.subTest(ci=args): 35 | numTests = 100 36 | for ci in range(numTests): 37 | n_dim = np.random.randint(1, 10) 38 | 39 | trueMean = 10 * np.random.randn(n_dim) 40 | trueStd = 10 * np.random.randn(n_dim) 41 | 42 | for time_first in [True, False]: 43 | n_samples = np.random.randint(10, 100) 44 | data = np.random.randn(n_samples, n_dim) * trueStd + trueMean 45 | if not time_first: 46 | data = data.T 47 | dataCopy = copy.deepcopy(data) 48 | 49 | sm = PrepModel() 50 | sm.fit(data, time_first=time_first, **args) 51 | 52 | np.testing.assert_equal(data, dataCopy) 53 | 54 | ddof = args["std_ddof"] if "std_ddof" in args else 1 55 | newData = sm.apply(data, time_first=time_first) 56 | if time_first: 57 | newDataMean = np.mean(newData, axis=0) 58 | newDataStd = np.std(newData, axis=0, ddof=ddof) 59 | else: 60 | newDataMean = np.mean(newData, axis=1) 61 | newDataStd = np.std(newData, axis=1, ddof=ddof) 62 | 63 | if args["zscore"] or args["remove_mean"]: 64 | np.testing.assert_almost_equal( 65 | newDataMean, np.zeros_like(newDataMean) 66 | ) 67 | if args["zscore"]: 68 | np.testing.assert_almost_equal( 69 | newDataStd, np.ones_like(newDataStd) 70 | ) 71 | 72 | recoveredData = sm.apply_inverse(newData, time_first=time_first) 73 | 74 | np.testing.assert_almost_equal(recoveredData, dataCopy) 75 | 76 | def test_preprocessing_for_segmented_data(self): 77 | from PSID.PrepModel import PrepModel 78 | 79 | np.random.seed(42) 80 | 81 | arg_sets = [ 82 | {"remove_mean": True, "zscore": True}, 83 | {"remove_mean": False, "zscore": True}, 84 | {"remove_mean": True, "zscore": False}, 85 | {"remove_mean": False, "zscore": False}, 86 | {"remove_mean": True, "zscore": True, "std_ddof": 0}, 87 | ] 88 | 89 | for args in arg_sets: 90 | with self.subTest(ci=args): 91 | numTests = 100 92 | for ci in range(numTests): 93 | n_dim = np.random.randint(1, 10) 94 | 95 | trueMean = 10 * np.random.randn(n_dim) 96 | trueStd = 10 * np.random.randn(n_dim) 97 | 98 | n_segments = np.random.randint(1, 10) 99 | 100 | for time_first in [True, False]: 101 | data = [] 102 | for t in range(n_segments): 103 | n_samples = np.random.randint(10, 100) 104 | dataThis = ( 105 | np.random.randn(n_samples, n_dim) * trueStd + trueMean 106 | ) 107 | if not time_first: 108 | dataThis = dataThis.T 109 | data.append(dataThis) 110 | 111 | dataCopy = copy.deepcopy(data) 112 | 113 | sm = PrepModel() 114 | sm.fit(data, time_first=time_first, **args) 115 | 116 | np.testing.assert_equal(data, dataCopy) 117 | 118 | ddof = args["std_ddof"] if "std_ddof" in args else 1 119 | newData = sm.apply(data, time_first) 120 | if time_first: 121 | newDataCat = np.concatenate(newData, axis=0) 122 | newDataMean = np.mean(newDataCat, axis=0) 123 | newDataStd = np.std(newDataCat, axis=0, ddof=ddof) 124 | else: 125 | newDataCat = np.concatenate(newData, axis=1) 126 | newDataMean = np.mean(newDataCat, axis=1) 127 | newDataStd = np.std(newDataCat, axis=1, ddof=ddof) 128 | 129 | if args["zscore"] or args["remove_mean"]: 130 | np.testing.assert_almost_equal( 131 | newDataMean, np.zeros_like(newDataMean) 132 | ) 133 | if args["zscore"]: 134 | np.testing.assert_almost_equal( 135 | newDataStd, np.ones_like(newDataStd) 136 | ) 137 | 138 | recoveredData = sm.apply_inverse(newData, time_first) 139 | 140 | for t in range(len(dataCopy)): 141 | np.testing.assert_almost_equal( 142 | recoveredData[t], dataCopy[t] 143 | ) 144 | 145 | 146 | if __name__ == "__main__": 147 | unittest.main() 148 | --------------------------------------------------------------------------------