├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── release.yaml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── Makefile ├── README.md ├── docs ├── Makefile ├── _static │ ├── favicon.png │ ├── logo.gif │ ├── simdec_presentation.png │ └── version_switcher.json ├── api.rst ├── conf.py ├── getting-started.rst ├── index.rst ├── notebooks │ └── structural_reliability.ipynb └── tutorials.rst ├── environment.yml ├── joss ├── paper.bib ├── paper.md └── simdec_presentation.png ├── panel ├── Dockerfile ├── cloudbuild.yaml ├── index.html ├── login.html ├── logout.html ├── sampling.py └── simdec_app.py ├── pyproject.toml ├── src └── simdec │ ├── __init__.py │ ├── auth.py │ ├── decomposition.py │ ├── sensitivity_indices.py │ └── visualization.py └── tests ├── data ├── CO2.csv ├── crying.csv └── stress.csv ├── test_decomposition.py └── test_sensitivity_indices.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | linter: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.12" 16 | - uses: pre-commit/action@v3.0.0 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | environment: SimDec 12 | permissions: 13 | # IMPORTANT: this permission is mandatory for trusted publishing 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Setup Python 3.10 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.10' 22 | architecture: 'x64' 23 | 24 | - name: Install hatch 25 | run: pip install hatch 26 | 27 | - name: Build 28 | run: hatch build 29 | 30 | - name: Publish to PyPI 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | pytest: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.10', '3.12'] 19 | defaults: 20 | run: 21 | shell: bash -l {0} 22 | steps: 23 | - uses: actions/checkout@v3 24 | 25 | - name: Set up conda 26 | uses: conda-incubator/setup-miniconda@v2 27 | with: 28 | activate-environment: simdec 29 | python-version: ${{ matrix.python-version }} 30 | channels: conda-forge 31 | miniforge-variant: Mambaforge 32 | miniforge-version: latest 33 | use-mamba: true 34 | channel-priority: true 35 | 36 | - name: Get Date 37 | id: get-date 38 | run: echo "::set-output name=month::$(/bin/date -u '+%Y%m')" 39 | shell: bash 40 | 41 | - name: Cache conda 42 | uses: actions/cache@v3 43 | env: 44 | # Increase this value to reset cache if environment.yml has not changed 45 | CACHE_NUMBER: 0 46 | with: 47 | path: ${{ env.CONDA }}/envs/simdec 48 | key: 49 | ${{ runner.os }}--${{ steps.get-date.outputs.month }}-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles('environment.yml') }} 50 | id: envcache 51 | 52 | - name: Update Conda Environment 53 | run: mamba env update -n simdec -f environment.yml 54 | if: steps.envcache.outputs.cache-hit != 'true' 55 | 56 | - name: Install package 57 | run: | 58 | conda activate simdec 59 | pip install .[test] 60 | 61 | - name: Test 62 | if: matrix.python-version != '3.12' 63 | run: | 64 | conda activate simdec 65 | pytest 66 | 67 | - name: Test with coverage 68 | if: matrix.python-version == '3.12' 69 | run: | 70 | conda activate simdec 71 | pytest --cov simdec --cov-report term-missing 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary and binary files 2 | *~ 3 | *.py[cod] 4 | *.so 5 | __pycache__/* 6 | __pycache__ 7 | .cache/* 8 | **/.ipynb_checkpoints 9 | 10 | # Env variables with direnv 11 | .envrc 12 | 13 | # OS generated files 14 | .DS_Store* 15 | 16 | # Project files 17 | .project 18 | .pydevproject 19 | .settings 20 | .idea 21 | .vscode 22 | .spyproject 23 | 24 | # Test, linter, coverage 25 | htmlcov/* 26 | .coverage 27 | .mypy_cache 28 | .ruff_cache 29 | .pytest_cache 30 | 31 | # Build and docs folder/files 32 | build/* 33 | dist/* 34 | sdist/* 35 | docs/html 36 | docs/jupyter_execute 37 | app.html 38 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: check-added-large-files 7 | - id: check-ast 8 | - id: fix-byte-order-marker 9 | - id: check-case-conflict 10 | - id: check-docstring-first 11 | - id: check-merge-conflict 12 | - id: detect-private-key 13 | - id: end-of-file-fixer 14 | 15 | - repo: https://github.com/psf/black 16 | rev: 23.10.0 17 | hooks: 18 | - id: black 19 | 20 | - repo: https://github.com/astral-sh/ruff-pre-commit 21 | # Ruff version. 22 | rev: 'v0.1.0' 23 | hooks: 24 | - id: ruff 25 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Build documentation in the docs/ directory with Sphinx 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # Build documentation with MkDocs 12 | #mkdocs: 13 | # configuration: mkdocs.yml 14 | 15 | # Optionally build your docs in additional formats such as PDF 16 | #formats: 17 | # - pdf 18 | 19 | build: 20 | os: "ubuntu-22.04" 21 | tools: 22 | python: "mambaforge-4.10" 23 | 24 | python: 25 | install: 26 | - method: pip 27 | path: . 28 | 29 | conda: 30 | environment: environment.yml 31 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Until we have our own code of conduct, we are following NumFOCUS' Code of 4 | Conduct: 5 | 6 | https://numfocus.org/code-of-conduct 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Welcome to our community! Thank you for taking the time to read the following. 4 | 5 | ## TL;DR 6 | 7 | * All code should have tests. 8 | * All code should be documented. 9 | * No changes are ever committed without review and approval. 10 | 11 | ## Project management 12 | 13 | * *github* is used for the code base. 14 | * For a PR to be integrated, it must be approved at least by one core team member. 15 | * Development discussions happen on Discord but any request **must** be formalized in *github*. This ensures a common history. 16 | * Continuous Integration is provided by *Github actions* and configuration is located at ``.github/workflows``. 17 | 18 | ## Code 19 | 20 | ### Local development 21 | 22 | After cloning the repository, install with: 23 | 24 | ```bash 25 | $ pip install -e ".[dev]" 26 | ``` 27 | 28 | ### Building a local copy of the documentation 29 | 30 | Assuming the current location is the project root (the `SALib` directory): 31 | 32 | ```bash 33 | $ pip install -e ".[doc]" 34 | $ sphinx-build -b html docs docs/html 35 | ``` 36 | 37 | A copy of the documentation will be in the `docs/html` directory. 38 | Open `index.html` to view it. 39 | 40 | ### Testing 41 | 42 | Testing your code is paramount. Without continuous integration, we **cannot** 43 | guaranty the quality of the code. Some minor modification on a function can 44 | have unexpected implications. With a single commit, everything can go south! 45 | The ``main`` branch is always on a passing state: CI is green, working code, 46 | and an installable Python package. 47 | 48 | The library [pytest](https://docs.pytest.org/en/latest/) is used with 49 | [coverage](https://coverage.readthedocs.io/) to ensure the added 50 | functionalities are covered by tests. 51 | 52 | All tests can be launched using: 53 | 54 | ```bash 55 | pytest --cov simdec --cov-report term-missing 56 | ``` 57 | 58 | The output consists in tests results and coverage report. 59 | 60 | > Tests will be automatically launched when you will push your branch to 61 | > GitHub. Be mindful of this resource! 62 | 63 | ### Style 64 | 65 | For all python code, developers **must** follow guidelines from the Python Software Foundation. As a quick reference: 66 | 67 | * For code: [PEP 8](https://www.python.org/dev/peps/pep-0008/) 68 | * For documentation: [PEP 257](https://www.python.org/dev/peps/pep-0257/) 69 | * Use reStructuredText formatting: [PEP 287](https://www.python.org/dev/peps/pep-0287/) 70 | 71 | And for a more Pythonic code: [PEP 20](https://www.python.org/dev/peps/pep-0020/) 72 | Last but not least, avoid common pitfalls: [Anti-patterns](https://docs.quantifiedcode.com/python-anti-patterns/) 73 | 74 | ### Linter 75 | 76 | Apart from normal unit and integration tests, you can perform a static 77 | analysis of the code using [black](https://black.readthedocs.io/en/stable/): 78 | 79 | ```bash 80 | black ml_package 81 | ``` 82 | 83 | This allows to spot naming errors for example as well as other style errors. 84 | 85 | ## GIT 86 | 87 | ### Workflow 88 | 89 | The development model is based on the Cactus Model also called 90 | [Trunk Based Development](https://trunkbaseddevelopment.com) model. 91 | More specificaly, we use the Scaled Trunk-Based Development model. 92 | 93 | > Some additional ressources: 94 | > [gitflow](https://nvie.com/posts/a-successful-git-branching-model/), 95 | > [gitflow critique](https://barro.github.io/2016/02/a-succesful-git-branching-model-considered-harmful/), 96 | > [github PR](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-request-merges). 97 | 98 | It means that **each** new feature has to go through a new branch. Why? 99 | For peer review. Pushing directly on the develop without review should be 100 | exceptional (hotfix)! 101 | 102 | This project is using pre-commit hooks. So you have to set it up like this: 103 | 104 | ```bash 105 | pre-commit install 106 | pre-commit run --all-files 107 | ``` 108 | When you try to commit your changes, it will launch the pre-commit hooks 109 | (``.pre-commit-config.yaml``) 110 | and modify the files if there are any changes to be made for the commit to be 111 | accepted. If you don't use this feature and your changes are not compliant 112 | (linter), CI will fail. 113 | 114 | ### Recipe for new feature 115 | 116 | If you want to add a modification, create a new branch branching off ``main``. 117 | Then you can create a merge request on *github*. From here, the fun begins. 118 | 119 | > For every commit you push, the linter and tests are launched. 120 | 121 | Your request will only be considered for integration if in a **finished** state: 122 | 123 | 1. Respect python coding rules, 124 | 2. Have tests regarding the changes, 125 | 3. The branch passes all tests (current and new ones), 126 | 4. Maintain test coverage, 127 | 5. Have the respective documentation. 128 | 129 | #### Writing the commit message 130 | 131 | Commit messages should be clear and follow a few basic rules. Example: 132 | 133 | ```bash 134 | Add functionality X. 135 | 136 | Lines shouldn't be longer than 72 137 | characters. If the commit is related to a ticket, you can indicate that 138 | with "See #3456", "See ticket 3456", "Closes #3456", or similar. 139 | ``` 140 | 141 | Describing the motivation for a change, the nature of a bug for bug fixes or 142 | some details on what an enhancement does are also good to include in a commit 143 | message. Messages should be understandable without looking at the code 144 | changes. A commit message like ``fixed another one`` is an example of 145 | what not to do; the reader has to go look for context elsewhere. 146 | 147 | ### Squash, rebase and merge 148 | 149 | Squash-merge is systematically used to maintain a linear history. It's 150 | important to check the message on the squash commit. 151 | 152 | ## Making a release 153 | 154 | Following is the process that the development team follows in order to make 155 | a release: 156 | 157 | 1. Update the version in the main `pyproject.toml`. 158 | 2. Build locally using `hatch build`, and verify the content of the artifacts 159 | 3. Submit PR, wait for tests to pass, and merge release into `main` 160 | 4. Tag release with version number and push to the repo 161 | 5. Check that release has been deployed to PyPI 162 | 6. Check documentation is built and deployed to readthedocs 163 | 7. Check that auto-generated PR is auto-merged on the conda-forge feedstock repo 164 | 165 | ## Dashboard 166 | A live dashboard is available at: 167 | 168 | https://simdec.io 169 | 170 | The DNS records are available on CPanel and the rest is hosted on Google Cloud 171 | Platform. 172 | 173 | Developing locally requires the installation of GCP CLI and Docker engine. 174 | 175 | A few helper commands are provided in the Makefile. 176 | 177 | ### Local use 178 | 179 | The dashboard can be run locally using: 180 | 181 | make serve-dev 182 | 183 | If you want to test OAuth, you need to export the following env variables: 184 | 185 | export PANEL_OAUTH_REDIRECT_URI=http://localhost:5006/app 186 | export PANEL_OAUTH_KEY=[VALUE IN GCP Secret Manager] 187 | export PANEL_OAUTH_SECRET=[VALUE IN GCP Secret Manager] 188 | export PANEL_OAUTH_ENCRYPTION=[VALUE IN GCP Secret Manager] 189 | 190 | Use the CLI tool `direnv` for convenience. Then you can serve with 191 | OAuth support: 192 | 193 | make serve-oauth 194 | 195 | ### Deployment 196 | 197 | New version can either be deployed from the CI with `cloudbuild` or locally: 198 | 199 | make production 200 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023, Simulation-Decomposition 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help prepare doc test serve build publish-production deploy-production promote-production production cloudbuild-production 2 | .DEFAULT_GOAL := help 3 | SHELL:=/bin/bash 4 | 5 | ifndef version 6 | override version = development 7 | endif 8 | 9 | ifndef region 10 | override region = europe-north1 11 | endif 12 | 13 | ifndef project 14 | override project = delta-entity-401706 15 | endif 16 | 17 | 18 | # Add help text after each target name starting with '\#\#' 19 | help: ## show this help 20 | @echo -e "Help for this makefile\n" 21 | @echo "Possible commands are:" 22 | @grep -h "##" $(MAKEFILE_LIST) | grep -v grep | sed -e 's/\(.*\):.*##\(.*\)/ \1: \2/' 23 | 24 | prepare: ## Install dependencies and pre-commit hook 25 | pip install -e ".[dev]" 26 | pre-commit install 27 | gcloud init 28 | gcloud auth configure-docker europe-north1-docker.pkg.dev 29 | 30 | # Doc and tests 31 | 32 | clean-doc: 33 | rm -rf docs/html docs/jupyter_execute 34 | 35 | doc: clean-doc ## Build Sphinx documentation 36 | sphinx-build -b html docs docs/html;open docs/html/index.html 37 | 38 | test: ## Run tests with coverage 39 | pytest --cov simdec --cov-report term-missing 40 | 41 | # Dashboard commands 42 | 43 | serve-dev: ## Serve Panel dashboard - Dev mode 44 | panel serve panel/simdec_app.py panel/sampling.py \ 45 | --index panel/index.html \ 46 | --show --autoreload \ 47 | --static-dirs _static=docs/_static \ 48 | --reuse-sessions --warm 49 | 50 | serve-oauth: ## Serve Panel dashboard - Prod mode with OAuth2. Needs: PANEL_OAUTH_REDIRECT_URI, PANEL_OAUTH_KEY, PANEL_OAUTH_SECRET, PANEL_OAUTH_ENCRYPTION 51 | PANEL_OAUTH_SCOPE=email panel serve panel/simdec_app.py panel/sampling.py \ 52 | --index panel/index.html \ 53 | --show \ 54 | --cookie-secret panel_cookie_secret_oauth \ 55 | --basic-login-template panel/login.html \ 56 | --logout-template panel/logout.html \ 57 | --oauth-provider custom_google \ 58 | --static-dirs _static=docs/_static \ 59 | --reuse-sessions --warm 60 | 61 | # Deployment commands 62 | # --progress=plain 63 | 64 | build-local: ## Build for local architecture 65 | docker build -f ./panel/Dockerfile \ 66 | --tag simdec-panel-local:$(version) \ 67 | --pull \ 68 | ./. 69 | 70 | run-local: build-local 71 | docker run --rm -it \ 72 | --name=simdec-panel-local \ 73 | --memory=1g \ 74 | --cpuset-cpus=0 \ 75 | -e ENV=development \ 76 | -e PANEL_OAUTH_SCOPE=email \ 77 | -e PANEL_OAUTH_REDIRECT_URI=$(PANEL_OAUTH_REDIRECT_URI) \ 78 | -e PANEL_OAUTH_SECRET=$(PANEL_OAUTH_SECRET) \ 79 | -e PANEL_OAUTH_KEY=$(PANEL_OAUTH_KEY) \ 80 | -e PANEL_OAUTH_ENCRYPTION=$(PANEL_OAUTH_ENCRYPTION) \ 81 | -p "5006:8080" \ 82 | simdec-panel-local:$(version) 83 | 84 | # Need to specifically build on linux/amd64 to avoid issues on macOS M platform 85 | build: ## Build for linux/amd64 (production) 86 | docker build -f ./panel/Dockerfile \ 87 | --platform linux/amd64 \ 88 | --tag simdec-panel:$(version) \ 89 | --pull \ 90 | ./. 91 | 92 | run: build 93 | docker run --rm -it \ 94 | --name=simdec-panel \ 95 | --memory=1g \ 96 | --cpuset-cpus=0 \ 97 | -e ENV=development \ 98 | -e PANEL_OAUTH_SCOPE=email \ 99 | -e PANEL_OAUTH_REDIRECT_URI=$(PANEL_OAUTH_REDIRECT_URI) \ 100 | -e PANEL_OAUTH_SECRET=$(PANEL_OAUTH_SECRET) \ 101 | -e PANEL_OAUTH_KEY=$(PANEL_OAUTH_KEY) \ 102 | -e PANEL_OAUTH_ENCRYPTION=$(PANEL_OAUTH_ENCRYPTION) \ 103 | -p "5006:8080" \ 104 | simdec-panel:$(version) 105 | 106 | # Ship 107 | 108 | publish-production: build ## Tag and push to GCP 109 | docker tag simdec-panel:$(version) $(region)-docker.pkg.dev/$(project)/simdec-panel/simdec-panel:$(version) 110 | docker push $(region)-docker.pkg.dev/$(project)/simdec-panel/simdec-panel:$(version) 111 | 112 | 113 | deploy-production: publish-production ## Deploy new revision to GCP 114 | @echo "Deploying '$(version)'." 115 | gcloud run deploy simdec-panel \ 116 | --cpu=2 \ 117 | --concurrency=5 \ 118 | --min-instances=0 \ 119 | --max-instances=2 \ 120 | --region=$(region) \ 121 | --port=8080 \ 122 | --set-env-vars ENV=production \ 123 | --set-env-vars PANEL_OAUTH_SCOPE=email \ 124 | --set-secrets=PANEL_OAUTH_REDIRECT_URI=PANEL_OAUTH_REDIRECT_URI:latest \ 125 | --set-secrets=PANEL_OAUTH_KEY=PANEL_OAUTH_KEY:latest \ 126 | --set-secrets=PANEL_OAUTH_SECRET=PANEL_OAUTH_SECRET:latest \ 127 | --set-secrets=PANEL_OAUTH_ENCRYPTION=PANEL_OAUTH_ENCRYPTION:latest \ 128 | --allow-unauthenticated \ 129 | --session-affinity \ 130 | --timeout=60m \ 131 | --service-account simdec-panel@delta-entity-401706.iam.gserviceaccount.com \ 132 | --image=$(region)-docker.pkg.dev/$(project)/simdec-panel/simdec-panel:$(version) \ 133 | --memory 2Gi \ 134 | --no-traffic 135 | 136 | promote-production: ## Serve new revision to GCP 137 | gcloud run services update-traffic simdec-panel --to-latest 138 | 139 | production: promote-production ## Build, Deploy and Serve new revision to GCP 140 | 141 | cloudbuild-production: ## Build, Deploy and Serve new revision to GCP using cloudbuild 142 | gcloud builds submit --config panel/cloudbuild.yaml --substitutions COMMIT_SHA=$(git rev-list --max-count=1 HEAD) . 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **Warning** 2 | > This library is under active development and things can change at anytime! Suggestions and help are greatly appreciated. 3 | 4 | 5 | 6 | ![image](https://user-images.githubusercontent.com/37065157/233836694-5312496e-4ada-47cb-bc09-3bf8c00be135.png) 7 | 8 | 12 | 13 | **Simulation decomposition** or **SimDec** is an uncertainty and sensitivity 14 | analysis method, which is based on Monte Carlo simulation. SimDec consists of 15 | three major parts: 16 | 17 | 1. computing sensitivity indices, 18 | 2. creating multi-variable scenarios and mapping the output values to them, and 19 | 3. visualizing the scenarios on the output distribution by color-coding its segments. 20 | 21 | SimDec reveals the nature of causalities and interaction effects in the model. 22 | See our [publications](https://www.simdec.fi/publications) and join our 23 | [discord community](https://discord.gg/54SFcNsZS4). 24 | 25 | ## Python API 26 | The library is distributed on PyPi and can be installed with: 27 | 28 | pip install simdec 29 | 30 | ## Dashboard 31 | A live dashboard is available at: 32 | 33 | https://simdec.io 34 | 35 | ## Citations 36 | 37 | The algorithms and visualizations used in this package came primarily out of 38 | research at LUT University, Lappeenranta, Finland, and Stanford University, 39 | California, U.S., supported with grants from Business Finland, Wihuri 40 | Foundation, and Finnish Foundation for Economic Education. 41 | 42 | If you use SimDec in your research we would appreciate a citation to the 43 | following publications: 44 | 45 | - Kozlova, M., Moss, R. J., Yeomans, J. S., & Caers, J. (2024). Uncovering Heterogeneous Effects in Computational Models for Sustainable Decision-making. _Environmental Modelling & Software_, 171, 105898. [https://doi.org/10.1016/j.envsoft.2023.105898](https://doi.org/10.1016/j.envsoft.2023.105898) 46 | - Kozlova, M., Moss, R. J., Roy, P., Alam, A., & Yeomans, J. S. (2024). SimDec algorithm and guidelines for its usage and interpretation. In M. Kozlova & J. S. Yeomans (Eds.), _Sensitivity Analysis for Business, Technology, and Policymaking. Made Easy with Simulation Decomposition_. Routledge. [Available here](https://doi.org/10.4324/9781003453789-3). 47 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Simulation-Decomposition/simdec-python/c6ade75ebbe4e4f8dda3fec1aaa554dc47e72ee9/docs/_static/favicon.png -------------------------------------------------------------------------------- /docs/_static/logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Simulation-Decomposition/simdec-python/c6ade75ebbe4e4f8dda3fec1aaa554dc47e72ee9/docs/_static/logo.gif -------------------------------------------------------------------------------- /docs/_static/simdec_presentation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Simulation-Decomposition/simdec-python/c6ade75ebbe4e4f8dda3fec1aaa554dc47e72ee9/docs/_static/simdec_presentation.png -------------------------------------------------------------------------------- /docs/_static/version_switcher.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "dev", 4 | "version": "latest", 5 | "url": "https://simdec.readthedocs.io/en/latest/" 6 | }, 7 | { 8 | "name": "1.1.0 (stable)", 9 | "version":"v1.1.0", 10 | "url": "https://simdec.readthedocs.io/en/v1.1.0/" 11 | }, 12 | { 13 | "name": "1.0.0", 14 | "version":"v1.0.0", 15 | "url": "https://simdec.readthedocs.io/en/v1.0.0/" 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. automodule:: simdec 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | import os 3 | 4 | project = "SimDec" 5 | copyright = "2023, SimDec Developers" 6 | author = "Pamphile Roy" 7 | 8 | # -- General configuration --------------------------------------------------- 9 | 10 | extensions = [ 11 | "sphinx.ext.autodoc", 12 | "sphinx.ext.autosummary", 13 | "sphinx.ext.viewcode", 14 | "numpydoc", 15 | "myst_nb", 16 | ] 17 | 18 | templates_path = ["_templates"] 19 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 20 | 21 | # -- autosummary ------------------------------------------------------------- 22 | 23 | autosummary_generate = True 24 | 25 | # -- Notebook tutorials with MyST-NB ------------------------------------------ 26 | 27 | nb_execution_mode = "auto" 28 | 29 | # -- Version matching -------------------------------------------------------- 30 | 31 | json_url = ( 32 | "https://simdec.readthedocs.io/en/latest/_static/version_switcher.json" # noqa 33 | ) 34 | # Define the version we use for matching in the version switcher. 35 | version_match = os.environ.get("READTHEDOCS_VERSION") 36 | # If READTHEDOCS_VERSION doesn't exist, we're not on RTD 37 | # If it is an integer, we're in a PR build and the version isn't correct. 38 | if not version_match or version_match.isdigit(): 39 | # For local development, infer the version to match from the package. 40 | release = importlib.metadata.version("simdec") 41 | if "dev" in release or "rc" in release: 42 | version_match = "latest" 43 | json_url = "_static/version_switcher.json" 44 | else: 45 | version_match = release 46 | 47 | # -- Options for HTML output ------------------------------------------------- 48 | 49 | html_theme = "pydata_sphinx_theme" 50 | html_static_path = ["_static"] 51 | 52 | html_logo = "_static/logo.gif" 53 | html_favicon = "_static/favicon.png" 54 | 55 | html_theme_options = { 56 | "pygment_light_style": "github-light-colorblind", 57 | "pygment_dark_style": "pitaya-smoothie", 58 | "external_links": [ 59 | {"name": "Official website", "url": "https://simdec.fi"}, 60 | ], 61 | "icon_links": [ 62 | { 63 | "name": "Discord", 64 | "url": "https://discord.gg/54SFcNsZS4", 65 | "icon": "fa-brands fa-discord", 66 | }, 67 | { 68 | "name": "GitHub", 69 | "url": "https://github.com/Simulation-Decomposition/simdec-python", 70 | "icon": "fa-brands fa-github", 71 | }, 72 | { 73 | "name": "PyPI", 74 | "url": "https://pypi.org/project/simdec", 75 | "icon": "fa-brands fa-python", 76 | }, 77 | ], 78 | "navbar_center": ["version-switcher", "navbar-nav"], 79 | "switcher": { 80 | "json_url": json_url, 81 | "version_match": version_match, 82 | }, 83 | } 84 | -------------------------------------------------------------------------------- /docs/getting-started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | 5 | Installing SimDec 6 | ----------------- 7 | 8 | To install the latest stable version of SimDec 9 | via pip from `PyPI `__. 10 | together with all the dependencies, run the following command: 11 | 12 | :: 13 | 14 | pip install simdec 15 | 16 | To install the latest development version of SimDec, run the following 17 | commands. Note that the development version may be unstable and include bugs. 18 | We encourage users use the latest stable version. 19 | 20 | :: 21 | 22 | git clone git@github.com:Simulation-Decomposition/simdec-python.git 23 | cd simdec-python 24 | pip install . 25 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to SimDec's documentation! 2 | ================================== 3 | 4 | **Simulation decomposition** or **SimDec** is an uncertainty and sensitivity 5 | analysis method, which is based on Monte Carlo simulation. SimDec consists of 6 | three major parts: 7 | 8 | 1. computing sensitivity indices, 9 | 2. creating multi-variable scenarios and mapping the output values to them, and 10 | 3. visualizing the scenarios on the output distribution by color-coding its segments. 11 | 12 | SimDec reveals the nature of causalities and interaction effects in the model. 13 | See our `publications `_ and join our 14 | `discord community `_. 15 | 16 | .. toctree:: 17 | :maxdepth: 2 18 | 19 | getting-started 20 | API 21 | Tutorials 22 | -------------------------------------------------------------------------------- /docs/notebooks/structural_reliability.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "0874b43d-35a5-430d-b1fa-aac07629c3b6", 6 | "metadata": {}, 7 | "source": [ 8 | "# Structural Reliability\n", 9 | "\n", 10 | "A simulation dataset of a structural reliability model with one key output variable and four input variables is used for this case. " 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "id": "cedd5ec9-31f7-4e7f-91be-f73b1d1d00f1", 16 | "metadata": { 17 | "scrolled": true, 18 | "ExecuteTime": { 19 | "end_time": "2024-05-30T09:06:58.579066Z", 20 | "start_time": "2024-05-30T09:06:58.552735Z" 21 | } 22 | }, 23 | "source": [ 24 | "import pathlib\n", 25 | "\n", 26 | "import matplotlib.pyplot as plt\n", 27 | "import pandas as pd\n", 28 | "import simdec as sd" 29 | ], 30 | "outputs": [], 31 | "execution_count": 1 32 | }, 33 | { 34 | "metadata": {}, 35 | "cell_type": "markdown", 36 | "source": "Let's first load the dataset. It's a CSV file, each row represent a simulation or sample. The first column is the output or quantity of interest and other columns are parameters' values.", 37 | "id": "8700ed278bb1c06d" 38 | }, 39 | { 40 | "cell_type": "code", 41 | "id": "0b21846d-edff-4e39-a423-b247f81c4520", 42 | "metadata": { 43 | "ExecuteTime": { 44 | "end_time": "2024-05-30T09:06:58.595823Z", 45 | "start_time": "2024-05-30T09:06:58.579870Z" 46 | } 47 | }, 48 | "source": [ 49 | "fname = pathlib.Path(\"../../tests/data/stress.csv\")\n", 50 | "\n", 51 | "data = pd.read_csv(fname)\n", 52 | "output_name, *inputs_names = list(data.columns)\n", 53 | "inputs, output = data[inputs_names], data[output_name]\n", 54 | "inputs.head()" 55 | ], 56 | "outputs": [ 57 | { 58 | "data": { 59 | "text/plain": [ 60 | " Kf sigma_res Rp0.2 R\n", 61 | "0 2.454866 -84.530638 297.406169 -0.834480\n", 62 | "1 2.774116 347.586947 379.499452 -0.131827\n", 63 | "2 2.504617 946.567040 940.477667 -0.039126\n", 64 | "3 2.466723 74.222224 406.622486 0.440311\n", 65 | "4 2.615602 -32.937734 979.498038 0.419690" 66 | ], 67 | "text/html": [ 68 | "
\n", 69 | "\n", 82 | "\n", 83 | " \n", 84 | " \n", 85 | " \n", 86 | " \n", 87 | " \n", 88 | " \n", 89 | " \n", 90 | " \n", 91 | " \n", 92 | " \n", 93 | " \n", 94 | " \n", 95 | " \n", 96 | " \n", 97 | " \n", 98 | " \n", 99 | " \n", 100 | " \n", 101 | " \n", 102 | " \n", 103 | " \n", 104 | " \n", 105 | " \n", 106 | " \n", 107 | " \n", 108 | " \n", 109 | " \n", 110 | " \n", 111 | " \n", 112 | " \n", 113 | " \n", 114 | " \n", 115 | " \n", 116 | " \n", 117 | " \n", 118 | " \n", 119 | " \n", 120 | " \n", 121 | " \n", 122 | " \n", 123 | " \n", 124 | " \n", 125 | " \n", 126 | " \n", 127 | " \n", 128 | " \n", 129 | "
Kfsigma_resRp0.2R
02.454866-84.530638297.406169-0.834480
12.774116347.586947379.499452-0.131827
22.504617946.567040940.477667-0.039126
32.46672374.222224406.6224860.440311
42.615602-32.937734979.4980380.419690
\n", 130 | "
" 131 | ] 132 | }, 133 | "execution_count": 2, 134 | "metadata": {}, 135 | "output_type": "execute_result" 136 | } 137 | ], 138 | "execution_count": 2 139 | }, 140 | { 141 | "cell_type": "code", 142 | "id": "4206d712-b525-4e9b-9724-262a1fb3fb02", 143 | "metadata": { 144 | "ExecuteTime": { 145 | "end_time": "2024-05-30T09:06:58.600368Z", 146 | "start_time": "2024-05-30T09:06:58.596622Z" 147 | } 148 | }, 149 | "source": "output.head()", 150 | "outputs": [ 151 | { 152 | "data": { 153 | "text/plain": [ 154 | "0 311.898918\n", 155 | "1 500.044381\n", 156 | "2 715.171820\n", 157 | "3 482.477633\n", 158 | "4 663.983635\n", 159 | "Name: delta_sig, dtype: float64" 160 | ] 161 | }, 162 | "execution_count": 3, 163 | "metadata": {}, 164 | "output_type": "execute_result" 165 | } 166 | ], 167 | "execution_count": 3 168 | }, 169 | { 170 | "metadata": {}, 171 | "cell_type": "markdown", 172 | "source": "We can then compute sensitivity indices. In this case we will return Sobol' indices by using a simple binning approach.", 173 | "id": "f3ddc48a39ba7d27" 174 | }, 175 | { 176 | "cell_type": "code", 177 | "id": "1367362a-9b3d-4624-88d5-4931d4a56c8d", 178 | "metadata": { 179 | "ExecuteTime": { 180 | "end_time": "2024-05-30T09:06:58.622967Z", 181 | "start_time": "2024-05-30T09:06:58.601123Z" 182 | } 183 | }, 184 | "source": [ 185 | "indices = sd.sensitivity_indices(inputs=inputs, output=output)\n", 186 | "indices" 187 | ], 188 | "outputs": [ 189 | { 190 | "data": { 191 | "text/plain": [ 192 | "SensitivityAnalysisResult(si=array([0.03735157, 0.55436865, 0.13488759, 0.35593683]), first_order=array([0.03666241, 0.50270644, 0.10694638, 0.27702256]), second_order=array([[0. , 0. , 0.00137831, 0. ],\n", 193 | " [0. , 0. , 0. , 0.10332443],\n", 194 | " [0.00137831, 0. , 0. , 0.0545041 ],\n", 195 | " [0. , 0.10332443, 0.0545041 , 0. ]]))" 196 | ] 197 | }, 198 | "execution_count": 4, 199 | "metadata": {}, 200 | "output_type": "execute_result" 201 | } 202 | ], 203 | "execution_count": 4 204 | }, 205 | { 206 | "metadata": {}, 207 | "cell_type": "markdown", 208 | "source": "With this information, we can decompose the problem with SimDec.", 209 | "id": "f573990c0b589a" 210 | }, 211 | { 212 | "cell_type": "code", 213 | "id": "ad38a9b2-199a-458e-953b-33dbee5b9bd1", 214 | "metadata": { 215 | "ExecuteTime": { 216 | "end_time": "2024-05-30T09:06:58.646953Z", 217 | "start_time": "2024-05-30T09:06:58.624749Z" 218 | } 219 | }, 220 | "source": [ 221 | "si = indices.si\n", 222 | "res = sd.decomposition(inputs=inputs, output=output, sensitivity_indices=si)\n", 223 | "res" 224 | ], 225 | "outputs": [ 226 | { 227 | "data": { 228 | "text/plain": [ 229 | "DecompositionResult(var_names=['sigma_res', 'R'], statistic=array([[282.07754592, 407.79220985, 541.32231297],\n", 230 | " [434.89823001, 485.71976552, 534.1939336 ],\n", 231 | " [703.90456847, 725.15075719, 755.65161563]]), bins= 0 1 2 3 4 5 \\\n", 232 | "0 311.898918 565.755452 663.983635 409.659023 500.044381 482.477633 \n", 233 | "1 210.727366 468.836865 496.688973 448.747937 461.183337 535.841646 \n", 234 | "2 61.163271 474.595725 551.641014 387.720239 428.518186 534.050989 \n", 235 | "3 126.402829 404.649624 419.194207 420.077617 472.827542 456.816425 \n", 236 | "4 86.214556 245.112746 498.566253 362.089205 449.201292 414.209521 \n", 237 | "... ... ... ... ... ... ... \n", 238 | "2551 NaN NaN 544.400366 NaN NaN NaN \n", 239 | "2552 NaN NaN 415.030100 NaN NaN NaN \n", 240 | "2553 NaN NaN 476.182362 NaN NaN NaN \n", 241 | "2554 NaN NaN 545.191674 NaN NaN NaN \n", 242 | "2555 NaN NaN 609.400127 NaN NaN NaN \n", 243 | "\n", 244 | " 6 7 8 \n", 245 | "0 746.123228 715.171820 782.547166 \n", 246 | "1 679.791011 731.671886 781.319970 \n", 247 | "2 687.661203 695.831495 748.471898 \n", 248 | "3 716.001934 720.095096 736.710396 \n", 249 | "4 713.997502 729.323426 772.169673 \n", 250 | "... ... ... ... \n", 251 | "2551 NaN NaN NaN \n", 252 | "2552 NaN NaN NaN \n", 253 | "2553 NaN NaN NaN \n", 254 | "2554 NaN NaN NaN \n", 255 | "2555 NaN NaN NaN \n", 256 | "\n", 257 | "[2556 rows x 9 columns], states=[3, 3], bin_edges=[array([-399.89835911, 50.03854271, 499.97544454, 949.91234636]), array([-1.19998572, -0.56667382, 0.06663808, 0.69994998])])" 258 | ] 259 | }, 260 | "execution_count": 5, 261 | "metadata": {}, 262 | "output_type": "execute_result" 263 | } 264 | ], 265 | "execution_count": 5 266 | }, 267 | { 268 | "metadata": {}, 269 | "cell_type": "markdown", 270 | "source": [ 271 | "These are the raw result, we can use some helper functions to make some visualization.\n", 272 | "We need a nice color palettes, using the states we can get a palette for all variables." 273 | ], 274 | "id": "f79c7e53a7a3442c" 275 | }, 276 | { 277 | "cell_type": "code", 278 | "id": "baf6963d-700c-4eca-83c8-35b6a487e105", 279 | "metadata": { 280 | "ExecuteTime": { 281 | "end_time": "2024-05-30T09:06:58.652660Z", 282 | "start_time": "2024-05-30T09:06:58.647710Z" 283 | } 284 | }, 285 | "source": [ 286 | "palette = sd.palette(states=res.states)[::-1]\n", 287 | "palette" 288 | ], 289 | "outputs": [ 290 | { 291 | "data": { 292 | "text/plain": [ 293 | "[[0.0702614379084967, 0.43562091503267975, 0.41353874883286645, 1.0],\n", 294 | " [0.1227668845315904, 0.7203703703703704, 0.6842514783691254, 1.0],\n", 295 | " [0.5846405228758169, 0.9330065359477124, 0.9119514472455649, 1.0],\n", 296 | " [0.4956417501498417, 0.5004538059765391, 0.050526586180323685, 1.0],\n", 297 | " [0.7717498644290322, 0.778582641207866, 0.1397180123869053, 1.0],\n", 298 | " [0.9651425635756488, 0.9681736450038532, 0.6847675314667352, 1.0],\n", 299 | " [0.43562091503267975, 0.0702614379084967, 0.24892623716153087, 1.0],\n", 300 | " [0.7203703703703704, 0.1227668845315904, 0.41500155617802664, 1.0],\n", 301 | " [0.9330065359477124, 0.5846405228758169, 0.7549953314659192, 1.0]]" 302 | ] 303 | }, 304 | "execution_count": 6, 305 | "metadata": {}, 306 | "output_type": "execute_result" 307 | } 308 | ], 309 | "execution_count": 6 310 | }, 311 | { 312 | "metadata": {}, 313 | "cell_type": "markdown", 314 | "source": "Here we are! Look at the decomposition itself with the bins.", 315 | "id": "7655c82c3352a2ae" 316 | }, 317 | { 318 | "cell_type": "code", 319 | "id": "fe70e06e-5377-431e-8e3e-82aa4d21a792", 320 | "metadata": { 321 | "ExecuteTime": { 322 | "end_time": "2024-05-30T09:06:59.140251Z", 323 | "start_time": "2024-05-30T09:06:58.653609Z" 324 | } 325 | }, 326 | "source": [ 327 | "fig, ax = plt.subplots()\n", 328 | "ax = sd.visualization(bins=res.bins, palette=palette, ax=ax)" 329 | ], 330 | "outputs": [ 331 | { 332 | "data": { 333 | "text/plain": [ 334 | "
" 335 | ], 336 | "image/png": "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" 337 | }, 338 | "metadata": {}, 339 | "output_type": "display_data" 340 | } 341 | ], 342 | "execution_count": 7 343 | }, 344 | { 345 | "metadata": {}, 346 | "cell_type": "markdown", 347 | "source": "We can also use a boxplot.", 348 | "id": "a77020a4fd2737b3" 349 | }, 350 | { 351 | "cell_type": "code", 352 | "id": "2e340cd7-bab2-4b36-9b2c-0a300b2fd57b", 353 | "metadata": { 354 | "ExecuteTime": { 355 | "end_time": "2024-05-30T09:06:59.299570Z", 356 | "start_time": "2024-05-30T09:06:59.141519Z" 357 | } 358 | }, 359 | "source": [ 360 | "fig, ax = plt.subplots()\n", 361 | "ax = sd.visualization(bins=res.bins, palette=palette, kind=\"boxplot\", ax=ax)" 362 | ], 363 | "outputs": [ 364 | { 365 | "data": { 366 | "text/plain": [ 367 | "
" 368 | ], 369 | "image/png": "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T5KSkhJddNFFodQrSR988IG6d+9OoACAdnLJJZeosrJSR48elWmaqqysVGVlpV577TUZhiFJqq6ulmEYSkpKUlVVlaqrq1VcXKxAIKBx48Zp8+bNCgQCqq2t1a233qo1a9ZoxowZ2rx5szwejyorK+V2u/Xuu+/qyJEj2rFjh2bOnKni4mLNnTu3U4VPtFxYRyoGDx4sp9Ope+65RzNnzpTX69XChQt1yy23RKo+AMC3jB07Vn369NGqVatC0+rq6pqct6amRlLj0yEvvviiUlJSZJqmqqqqVFJSopKSktDzpmnq5ptv1qZNmzR58uTQ9F69eunRRx/lPhVoVliholu3blq+fLn+67/+S9ddd526du2qG264gVABAO3szjvv1B133BHRO2rOmTOHO2oiLGFfU9GnTx+tWLEiErUAAMLgcDg0derUNrVxqmGhdrudYaMIC18oBgAALEGoAAAAliBUAJ1cUlKSbDabunbtGu1ScBr0FWIdoQLo5DIyMvTaa6+pR48e0S4Fp0FfIdYRKgDI7XZHuwS0EH2FWEaoAAAAliBUAAAASxAqAACAJQgVAADAEmHfURMArPL558E2LdfW5QFYi1ABoN253W65XE4VPXvs9DOfQluWd7mcjKQALEaoANDuPB6PNmzYKL/fH7Ua3G63PB5P1NYPdESECgBR4fF42KkDHQwXagIAAEsQKgAAgCUIFQAAwBKECgAAYAku1ARwSj6fr11GaTAaA4h/hAoAzfL5fLr66vE6frw24utKTHTppZc2ECyAOEaoANAsv9+v48drde99P9f55/c87fwff1ym++97psXzf3s5v99PqADiGKECwGmdf35P9euXEbH5AXQMXKgJAAAsQagAAACWIFQAAABLECoAAIAlCBVADIvmt3h2VmxzoPUIFUCM8nq9ys3NldfrjXYpnQbbHGgbQgUQo2pqamQYhmpqaqJdSqfBNgfahlABAAAsEXaoqKur0/33368LL7xQl1xyiR555BGZphmJ2gAAQBwJ+46aDzzwgN5++20tX75cR48e1R133CGPx6MbbrghEvUBAIA4EdaRiurqaq1du1YFBQUaOHCgRowYoZtuukm7du2KVH0AACBOhHWkYtu2berWrZuGDx8emjZ9+nTLiwIAAPEnrCMVXq9X6enpeuGFFzR27Fh9//vf15IlS2QYRqTqAwAA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LFy4ULt379bTTz+te++9V48//rg2bdoU7bI6HdM0NWvWLB0/flx/+MMftGjRIv3jH//Q4sWLZZqmZs6cqdTUVK1du1YTJ07UbbfdJp/PJ0ny+XyaOXOmfvSjH+n5559XcnKybr311hbdTx5t89JLL2nz5s2hx8eOHdP06dM1bNgwrVu3ToMHD9aMGTN07NgxSdK7776r3/zmN7rtttu0Zs0a1dTU6K677opW+Z3GAw88oDfeeEPLly/Xww8/rOeee05r1qyhv2LQ7bffrjPPPFPr1q3T3XffrcWLF+uVV16hr8w4cPToUTM7O9t86623QtOWLFli/uxnP4tiVZ3T/v37zb59+5rl5eWhaevXrzcvvfRS84033jBzcnLMo0ePhp77xS9+YT722GOmaZrm4sWLG/XZsWPHzMGDBzfqV1jP7/ebo0aNMq+77jpz3rx5pmma5h//+EfzsssuMw3DME3TNA3DMK+44gpz7dq1pmmaZn5+fmhe0zRNn89n9uvXz/zkk0/a/wV0En6/38zKyjLffvvt0LRly5aZv/71r+mvGFNdXW327dvX3Lt3b2jabbfdZt5///2dvq/i4kjFv/71L9XX12vw4MGhaUOHDtWuXbtkGEYUK+t80tLS9Lvf/U6pqamNph85ckS7du1SVlaWzjzzzND0oUOHaufOnZKkXbt2adiwYaHnEhMTNWDAgNDziIwHH3xQEydOVO/evUPTdu3apaFDh4a+hychIUFDhgxptq/OPfdceTwe7dq1q11r70y2bdumbt26afjw4aFp06dP14IFC+ivGONyuZSYmKh169bpq6++UmlpqbZv367+/ft3+r6Ki1BRXl4ut9vd6NvRUlNTFQgEVF1dHb3COqGkpCSNHDky9NgwDP3+97/XxRdfrPLycvXo0aPR/CkpKfrss88k6bTPw3pvvvmmtm7dqltvvbXR9NP1xeeff05ftTOv16v09HS98MILGjt2rL7//e9ryZIlMgyD/ooxTqdT8+fP15o1azRo0CCNGzdOo0aN0qRJkzp9X3WJdgEtcfz48ZO+brXhcV1dXTRKwv+vsLBQ77//vp5//nmtXLmyyX5q6KPm+pE+jIxAIKB7771X8+fPl8vlavTc6fqitraWvmpnx44d08GDB1VUVKQFCxaovLxc8+fPV2JiIv0Vgz766CONGTNG06ZN0759+1RQUKARI0Z0+r6Ki1DhdDpP2uANj7/9YYn2U1hYqKefflqLFi1S37595XQ6TzpyVFdXF+qj5voxKSmpvUruVB5//HFdcMEFjY4sNWiuL07XV4mJiZEruJPr0qWLjhw5oocffljp6emSvr64+dlnn9V5551Hf8WQN998U88//7w2b94sl8ul7OxslZWV6cknn1RGRkan7qu4OP3Rs2dP+f1+1dfXh6aVl5fL5XKxQ4qSgoICrVixQoWFhbrqqqskfd1PFRUVjearqKgIHepr7vm0tLT2KbqTeemll/S3v/1NgwcP1uDBg7V+/XqtX79egwcPpq9iUFpampxOZyhQSNJ3v/tdHT58mP6KMbt379Z5553X6I/arKws+Xy+Tt9XcREq+vfvry5dujS6oG/btm3Kzs6WzRYXL6FDefzxx1VUVKRHHnlEV199dWj6oEGDtGfPHtXW1oambdu2TYMGDQo9v23bttBzx48f1/vvvx96HtZ65plntH79er3wwgt64YUXdNlll+myyy7TCy+8oEGDBmnHjh2h4bymaWr79u3N9tXhw4d1+PBh+iqCBg0apEAgoAMHDoSmlZaWKj09nf6KMT169NDBgwcbHXEoLS1Vr1696KtoDj0Jx3/8x3+YV199tblr1y7zlVdeMYcMGWL+9a9/jXZZnc7+/fvN/v37m4sWLTI///zzRv/q6+vN8ePHm7fffrv54YcfmsuWLTNzcnLMQ4cOmaZpml6v18zOzjaXLVtmfvjhh+bs2bPNCRMmhIZeIbLmzZsXGsr25ZdfmhdffLFZUFBg7tu3zywoKDBzc3NDw4G3b99uDhgwwHzuuefMDz74wPzZz35mzpgxI5rldwrTp083r7/+evODDz4wX3vtNfPiiy82n376aforxtTU1Ji5ublmfn6+WVpaav797383hw8fbj777LOdvq/iJlQcO3bMnDt3rpmTk2Neeuml5ooVK6JdUqe0bNkys2/fvk3+M03T/Pjjj82f/vSn5gUXXGBeffXV5pYtWxotX1xcbF555ZXmwIEDzV/84hcdZmx2PDgxVJimae7atcv84Q9/aGZnZ5s//vGPzT179jSaf+3atebo0aPNnJwcc+bMmWZVVVV7l9zp1NTUmPn5+WZOTo45YsQI83/+539CoZv+ii379u0zp06dag4ZMsS8/PLLzRUrVtBXpmkmmCa3MwQAAG3HBQkAAMAShAoAAGAJQgUAALAEoQIAAFiCUAEAACxBqAAAAJYgVAAAAEsQKgAAgCUIFQAAwBKECgAAYAlCBQAAsAShAgAAWOL/AbuWMJQBqcLWAAAAAElFTkSuQmCC" 370 | }, 371 | "metadata": {}, 372 | "output_type": "display_data" 373 | } 374 | ], 375 | "execution_count": 8 376 | }, 377 | { 378 | "metadata": {}, 379 | "cell_type": "markdown", 380 | "source": [ 381 | "Inputs 3 and 1 have the highest sensitivity indices and thus are automatically chosen for decomposition. The most influential input 3 divides the distribution of the output into three main states with distinct colors. Input 1 further subdivides them into shades. From the graph, it becomes obvious that input 1 influences the output when input 3 is low, but has a negligible effect when input 3 is medium or high.\n", 382 | "\n", 383 | "Finally, we can get some statistics on all states and scenarios with a table." 384 | ], 385 | "id": "455de7abf6c44fe7" 386 | }, 387 | { 388 | "cell_type": "code", 389 | "id": "1d3dfce7-bf9c-42e1-97b4-a0b9d6a27a83", 390 | "metadata": { 391 | "ExecuteTime": { 392 | "end_time": "2024-05-30T09:06:59.321196Z", 393 | "start_time": "2024-05-30T09:06:59.303862Z" 394 | } 395 | }, 396 | "source": [ 397 | "table, styler = sd.tableau(\n", 398 | " statistic=res.statistic,\n", 399 | " var_names=res.var_names,\n", 400 | " states=res.states,\n", 401 | " bins=res.bins,\n", 402 | " palette=palette,\n", 403 | ")\n", 404 | "styler" 405 | ], 406 | "outputs": [ 407 | { 408 | "data": { 409 | "text/plain": [ 410 | "" 411 | ], 412 | "text/html": [ 413 | "\n", 454 | "\n", 455 | " \n", 456 | " \n", 457 | " \n", 458 | " \n", 459 | " \n", 460 | " \n", 461 | " \n", 462 | " \n", 463 | " \n", 464 | " \n", 465 | " \n", 466 | " \n", 467 | " \n", 468 | " \n", 469 | " \n", 470 | " \n", 471 | " \n", 472 | " \n", 473 | " \n", 474 | " \n", 475 | " \n", 476 | " \n", 477 | " \n", 478 | " \n", 479 | " \n", 480 | " \n", 481 | " \n", 482 | " \n", 483 | " \n", 484 | " \n", 485 | " \n", 486 | " \n", 487 | " \n", 488 | " \n", 489 | " \n", 490 | " \n", 491 | " \n", 492 | " \n", 493 | " \n", 494 | " \n", 495 | " \n", 496 | " \n", 497 | " \n", 498 | " \n", 499 | " \n", 500 | " \n", 501 | " \n", 502 | " \n", 503 | " \n", 504 | " \n", 505 | " \n", 506 | " \n", 507 | " \n", 508 | " \n", 509 | " \n", 510 | " \n", 511 | " \n", 512 | " \n", 513 | " \n", 514 | " \n", 515 | " \n", 516 | " \n", 517 | " \n", 518 | " \n", 519 | " \n", 520 | " \n", 521 | " \n", 522 | " \n", 523 | " \n", 524 | " \n", 525 | " \n", 526 | " \n", 527 | " \n", 528 | " \n", 529 | " \n", 530 | " \n", 531 | " \n", 532 | " \n", 533 | " \n", 534 | " \n", 535 | " \n", 536 | " \n", 537 | " \n", 538 | " \n", 539 | " \n", 540 | " \n", 541 | " \n", 542 | " \n", 543 | " \n", 544 | " \n", 545 | " \n", 546 | " \n", 547 | " \n", 548 | " \n", 549 | " \n", 550 | " \n", 551 | " \n", 552 | " \n", 553 | " \n", 554 | " \n", 555 | " \n", 556 | " \n", 557 | " \n", 558 | " \n", 559 | " \n", 560 | " \n", 561 | " \n", 562 | " \n", 563 | " \n", 564 | " \n", 565 | " \n", 566 | " \n", 567 | " \n", 568 | " \n", 569 | " \n", 570 | " \n", 571 | " \n", 572 | " \n", 573 | " \n", 574 | "
  colourstdminmeanmaxprobability
sigma_resR       
lowlow995.3411.19282.08460.070.19
medium887.7667.53407.79622.350.12
high7108.03237.13541.32819.410.26
mediumlow634.92350.30434.90523.840.09
medium544.39398.42485.72650.980.06
high475.80414.21534.19814.430.11
highlow335.43630.24703.90794.810.06
medium233.95656.34725.15816.480.04
high139.51668.50755.65851.000.08
\n" 575 | ] 576 | }, 577 | "execution_count": 9, 578 | "metadata": {}, 579 | "output_type": "execute_result" 580 | } 581 | ], 582 | "execution_count": 9 583 | }, 584 | { 585 | "metadata": {}, 586 | "cell_type": "markdown", 587 | "source": "Congratulations, now you know how to use SimDec to get more insights on your problem!", 588 | "id": "3cdf58c4bd3dbbca" 589 | } 590 | ], 591 | "metadata": { 592 | "kernelspec": { 593 | "display_name": "Python 3 (ipykernel)", 594 | "language": "python", 595 | "name": "python3" 596 | }, 597 | "language_info": { 598 | "codemirror_mode": { 599 | "name": "ipython", 600 | "version": 3 601 | }, 602 | "file_extension": ".py", 603 | "mimetype": "text/x-python", 604 | "name": "python", 605 | "nbconvert_exporter": "python", 606 | "pygments_lexer": "ipython3", 607 | "version": "3.12.1" 608 | } 609 | }, 610 | "nbformat": 4, 611 | "nbformat_minor": 5 612 | } 613 | -------------------------------------------------------------------------------- /docs/tutorials.rst: -------------------------------------------------------------------------------- 1 | Executable tutorials 2 | ==================== 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | 7 | notebooks/structural_reliability 8 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: simdec 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | # required 6 | - python>=3.10 7 | - numpy 8 | - pandas 9 | - salib 10 | - seaborn 11 | # Dashboard 12 | - panel 13 | - cryptography 14 | # tests 15 | - pytest 16 | - pytest-cov 17 | # dev 18 | - pre-commit 19 | - hatch 20 | # doc 21 | - sphinx 22 | - pydata-sphinx-theme 23 | - accessible-pygments 24 | - numpydoc 25 | - myst-nb 26 | -------------------------------------------------------------------------------- /joss/paper.bib: -------------------------------------------------------------------------------- 1 | @article{Herman2017, 2 | doi = {10.21105/joss.00097}, 3 | year = {2017}, 4 | month = {jan}, 5 | publisher = {The Open Journal}, 6 | volume = {2}, 7 | number = {9}, 8 | author = {Jon Herman and Will Usher}, 9 | title = {{SALib}: An open-source {Python} library for Sensitivity Analysis}, 10 | journal = {The Journal of Open Source Software} 11 | } 12 | 13 | @article{Kozlova2024, 14 | title = {Uncovering heterogeneous effects in computational models for sustainable decision-making}, 15 | journal = {Environmental Modelling & Software}, 16 | volume = {171}, 17 | pages = {105898}, 18 | year = {2024}, 19 | issn = {1364-8152}, 20 | doi = {10.1016/j.envsoft.2023.105898}, 21 | author = {Mariia Kozlova and Robert J. Moss and Julian Scott Yeomans and Jef Caers}, 22 | keywords = {Global sensitivity analysis, Simulation decomposition, Monte Carlo simulation, Decision-making under uncertainty} 23 | } 24 | 25 | @article{Roy2023, 26 | doi = {10.21105/joss.05309}, 27 | year = {2023}, 28 | publisher = {The Open Journal}, 29 | volume = {8}, 30 | number = {84}, 31 | pages = {5309}, 32 | author = {Pamphile T. Roy and Art B. Owen and Maximilian Balandat and Matt Haberland}, 33 | title = {Quasi-Monte Carlo Methods in {Python}}, 34 | journal = {Journal of Open Source Software} 35 | } 36 | 37 | @article{Virtanen2020, 38 | title={SciPy 1.0: fundamental algorithms for scientific computing in {P}ython}, 39 | author={Virtanen, Pauli and Gommers, Ralf and Oliphant, Travis E and Haberland, Matt and Reddy, Tyler and Cournapeau, David and Burovski, Evgeni and Peterson, Pearu and Weckesser, Warren and Bright, Jonathan et al.}, 40 | journal={Nature methods}, 41 | volume={17}, 42 | number={3}, 43 | pages={261--272}, 44 | year={2020}, 45 | publisher={Nature Publishing Group}, 46 | doi={10.1038/s41592-019-0686-2} 47 | } 48 | 49 | @book{Saltelli2007, 50 | author = {Saltelli, Andrea and Ratto, Marco and Andres, Terry and Campolongo, Francesca and Cariboni, Jessica and Gatelli, Debora and Saisana, Michaela and Tarantola, Stefano}, 51 | booktitle = {Global Sensitivity Analysis. The Primer}, 52 | doi = {10.1002/9780470725184}, 53 | isbn = {9780470725184}, 54 | month = {dec}, 55 | pages = {237--275}, 56 | publisher = {John Wiley {\&} Sons, Ltd}, 57 | title = {{Global Sensitivity Analysis. The Primer}}, 58 | year = {2007} 59 | } 60 | 61 | @article{sobol1993, 62 | title={Sensitivity analysis for non-linear mathematical models, originally “Sensitivity estimates for non-linear mathematical models”}, 63 | author={Sobol, Ilya M}, 64 | journal={Math Model Comput Exp}, 65 | volume={1}, 66 | pages={407--414}, 67 | year={1993} 68 | } 69 | 70 | @report{europeancommission2021, 71 | title = {Better {{Regulation Toolbox}}}, 72 | author = {{European Commission}}, 73 | date = {2021-11}, 74 | url = {https://ec.europa.eu/info/law/law-making-process/planning-and-proposing-law/better-regulation-why-and-how/better-regulation-guidelines-and-toolbox_en}, 75 | city = {Brussels}, 76 | keywords = {\#nosource} 77 | } 78 | 79 | @software{panel, 80 | author = {Philipp Rudiger and 81 | Marc Skov Madsen and 82 | Simon Høxbro Hansen and 83 | Maxime Liquet and 84 | Andrew and 85 | Xavier Artusi and 86 | James A. Bednar and 87 | Chris B and 88 | Jean-Luc Stevens and 89 | Christoph Deil and 90 | Demetris Roumis and 91 | Julia Signell and 92 | Mateusz Paprocki and 93 | Jerry Wu and 94 | Jon Mease and 95 | Arne and 96 | Coderambling and 97 | Hugues-Yanis Amanieu and 98 | thuydotm and 99 | Simon and 100 | sdc50 and 101 | Luca Fabbri and 102 | kbowen and 103 | Theom and 104 | Joel Ostblom and 105 | Govinda Totla and 106 | Niko Föhr and 107 | TBym}, 108 | title = {holoviz/panel: Version 1.4.3}, 109 | month = may, 110 | year = 2024, 111 | publisher = {Zenodo}, 112 | version = {v1.4.3}, 113 | doi = {10.5281/zenodo.11261266}, 114 | url = {https://doi.org/10.5281/zenodo.11261266} 115 | } 116 | -------------------------------------------------------------------------------- /joss/paper.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Simulation Decomposition in Python' 3 | tags: 4 | - Python 5 | - SimDec 6 | - statistics 7 | - Sensitivity Analysis 8 | - Visualization 9 | authors: 10 | - name: Pamphile T. Roy 11 | affiliation: 1 12 | corresponding: true 13 | orcid: 0000-0001-9816-1416 14 | - name: Mariia Kozlova 15 | affiliation: 2 16 | orcid: 0000-0002-6952-7682 17 | affiliations: 18 | - name: Consulting Manao GmbH, Vienna, Austria 19 | index: 1 20 | - name: LUT Business School, LUT University, Lappeenranta, Finland 21 | index: 2 22 | date: 1 April 2024 23 | bibliography: paper.bib 24 | 25 | --- 26 | 27 | # Summary 28 | 29 | Uncertainties are everywhere. Whether you are developing a new Artificial Intelligence (AI) system, 30 | running complex simulations or making an experiment in a lab, uncertainties 31 | influence the system. Therefore, an approach is needed to understand how these uncertainties impact the system's performance. 32 | 33 | SimDec offers a novel visual way to understand the intricate role that 34 | uncertainties play. A clear Python Application Programming Interface (API) and a no-code interactive web 35 | dashboard make uncertainty analysis with SimDec accessible to everyone. 36 | 37 | # Statement of need 38 | 39 | From real life experiments to numerical simulations, uncertainties play a 40 | crucial role in the system under study. With the advent of AI 41 | and new regulations such as the [AI Act](https://artificialintelligenceact.eu) or the 42 | *Better Regulation Guideline* [@europeancommission2021], there is a growing need for explainability and 43 | impact assessments of systems under uncertainties. 44 | 45 | Traditional methods to analyse the uncertainties focus on quantitative methods 46 | to compare the importance of factors, there is a large body of literature and 47 | the field is known as: Sensitivity Analysis (SA) [@Saltelli2007]. The indices of Sobol' are a 48 | prominent example of such methods [@sobol1993]. 49 | 50 | Simulation Decomposition or SimDec moves the field of SA forward by supplementing the computation of sensitivity indices with the visualization of the type of interactions involved, which proves critical for understanding the system's behavior and decision-making [@Kozlova2024]. 51 | In short, SimDec is a hybrid uncertainty-sensitivity analysis approach 52 | that reveals the critical behavior of a computational model or an empirical 53 | dataset. It decomposes the distribution of the output 54 | (target variable) by automatically forming scenarios that reveal the most critical behavior of the system. The scenarios are formed out of the most influential input variables (defined with variance-based sensitivity indices) by breaking down their numeric ranges into states (e.g. _low_ and _high_) and creating an exhaustive list of their combinations (e.g. (i) _low_ _**A**_ & _low_ _**B**_, (ii) _low_ _**A**_ & _high_ _**B**_, (iii) _high_ **_A_** & _low_ **_B_**, and (iv) _high_ **_A_** and _high_ **_B_**). The resulting visualization shows how different 55 | output ranges can be achieved and what kind of critical interactions affect 56 | the output—as seen in \autoref{fig:simdec}. The method has shown value for 57 | various computational models from different fields, including business, 58 | environment, and engineering, as well as an emerging evidence of use for 59 | empirical data and AI. 60 | 61 | ![SimDec: explanation of output by most important inputs. A simulation dataset of a structural reliability model with one key output variable and four input variables is used for this case. Inputs 3 and 1 have the highest sensitivity indices and thus are automatically chosen for decomposition. The most influential input 3 divides the distribution of the output into three main states with distinct colors. Input 1 further subdivides them into shades. From the graph, it becomes obvious that input 1 influences the output when input 3 is low, but has a negligible effect when input 3 is medium or high.\label{fig:simdec}](simdec_presentation.png) 62 | 63 | Besides proposing a comprehensive yet simple API through a Python package 64 | available on PyPi, SimDec is also made available 65 | to practitioners through an online dashboard at [https://simdec.io](https://simdec.io). The project 66 | relies on powerful variance-based sensitivity analysis methods from SALib [@Herman2017] and 67 | SciPy [@Virtanen2020; @Roy2023]—notably the Quasi-Monte Carlo capabilities with 68 | `sp.stats.qmc` and in the future sensitivity indices with `sp.stats.sensitivity_indices`. 69 | The dashboard is made possible thanks to Panel [@panel]. 70 | 71 | # Acknowledgements 72 | 73 | The work on this open-source software was supported by grant #220177 from 74 | Finnish Foundation for Economic Foundation. 75 | 76 | # References 77 | -------------------------------------------------------------------------------- /joss/simdec_presentation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Simulation-Decomposition/simdec-python/c6ade75ebbe4e4f8dda3fec1aaa554dc47e72ee9/joss/simdec_presentation.png -------------------------------------------------------------------------------- /panel/Dockerfile: -------------------------------------------------------------------------------- 1 | ## Step 1/3: Base image 2 | FROM python:3.12-alpine as base 3 | 4 | ## Step 2/3: Build/Install app dependencies 5 | FROM base as builder 6 | # Allow statements and log messages to immediately appear in the Knative logs 7 | ENV PYTHONUNBUFFERED=True 8 | ENV PYTHONDONTWRITEBYTECODE=true 9 | 10 | COPY pyproject.toml README.md / 11 | 12 | RUN --mount=type=cache,mode=0777,target=/root/.cache/pip \ 13 | pip install --upgrade pip && \ 14 | pip install -e ".[dashboard]" 15 | 16 | RUN find /usr/local/lib/python3.12/site-packages -name "test" -depth -type d -exec rm -rf '{}' \; 17 | RUN find /usr/local/lib/python3.12/site-packages -name "tests" -depth -type d -exec rm -rf '{}' \; 18 | RUN find /usr/local/lib/python3.12/site-packages -name "docs" -depth -type d -exec rm -rf '{}' \; 19 | RUN find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete 20 | 21 | RUN rm -rf /usr/local/lib/python3.12/site-packages/panel/dist/bundled/deckglplot 22 | RUN rm -rf /usr/local/lib/python3.12/site-packages/panel/dist/bundled/abstractvtkplot 23 | RUN rm -rf /usr/local/lib/python3.12/site-packages/panel/dist/bundled/aceplot 24 | RUN rm -rf /usr/local/lib/python3.12/site-packages/panel/dist/bundled/bootstrap5 25 | RUN rm -rf /usr/local/lib/python3.12/site-packages/panel/dist/bundled/plotlyplot 26 | RUN rm -rf /usr/local/lib/python3.12/site-packages/panel/dist/bundled/bootstrap4 27 | 28 | # stats depends on spatial, special, sparse, linalg, ndimage, fft 29 | RUN rm -rf /usr/local/lib/python3.12/site-packages/scipy/signal 30 | RUN rm -rf /usr/local/lib/python3.12/site-packages/scipy/misc 31 | RUN rm -rf /usr/local/lib/python3.12/site-packages/scipy/cluster 32 | 33 | RUN mkdir -p /app/src 34 | COPY src /app/src 35 | COPY tests/data /app/tests/data 36 | COPY panel /app/panel 37 | COPY docs/_static /app/_static 38 | 39 | # Step 3/3: Image 40 | FROM base as panel 41 | # Allow statements and log messages to immediately appear in the Knative logs 42 | ENV PYTHONUNBUFFERED=True 43 | ENV PYTHONDONTWRITEBYTECODE=true 44 | ENV PYTHONPATH=/app/src 45 | ENV PYTHONIOENCODING=utf-8 46 | ENV MPLCONFIGDIR=/tmp/matplotlib 47 | EXPOSE 8080 48 | 49 | COPY --from=builder /usr/local/lib/python3.12/site-packages/ /usr/local/lib/python3.12/site-packages/ 50 | COPY --from=builder /usr/local/bin/panel /usr/local/bin/panel 51 | COPY --from=builder /app /app 52 | 53 | # Basic security hardening 54 | RUN rm -rf /usr/local/lib/python3.12/site-packages/pip 55 | RUN rm -rf /usr/local/lib/python3.12/site-packages/wheel 56 | RUN rm -rf /usr/local/lib/python3.12/site-packages/setuptools 57 | RUN apk --purge del apk-tools 58 | 59 | RUN addgroup -S app && adduser -S app -G app 60 | 61 | USER app 62 | WORKDIR /app 63 | 64 | # Run the web service on container startup. 65 | CMD ["panel", "serve", "panel/simdec_app.py", "panel/sampling.py", \ 66 | "--address", "0.0.0.0", "--port", "8080", \ 67 | "--num-procs", "2", \ 68 | "--allow-websocket-origin", "simdec.io", \ 69 | "--allow-websocket-origin", "www.simdec.io", \ 70 | "--allow-websocket-origin", "simdec-panel-h6musew72q-lz.a.run.app", \ 71 | "--cookie-secret", "panel_cookie_secret_oauth", \ 72 | "--basic-login-template", "panel/login.html", \ 73 | "--logout-template", "panel/logout.html", \ 74 | "--oauth-provider", "custom_google", \ 75 | "--index", "panel/index.html", \ 76 | "--static-dirs", "_static=_static", \ 77 | "--reuse-sessions", "--warm", \ 78 | "--global-loading-spinner"] 79 | -------------------------------------------------------------------------------- /panel/cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: gcr.io/cloud-builders/docker 3 | env: 4 | - DOCKER_BUILDKIT=1 5 | args: 6 | - build 7 | - '--no-cache' 8 | - '-t' 9 | - >- 10 | $_AR_HOSTNAME/$PROJECT_ID/cloud-run-source-deploy/simdec-python/simdec-panel:$COMMIT_SHA 11 | - . 12 | - '-f' 13 | - panel/Dockerfile 14 | id: Build 15 | - name: gcr.io/cloud-builders/docker 16 | args: 17 | - push 18 | - >- 19 | $_AR_HOSTNAME/$PROJECT_ID/cloud-run-source-deploy/simdec-python/simdec-panel:$COMMIT_SHA 20 | id: Push 21 | - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim' 22 | args: 23 | - run 24 | - deploy 25 | - simdec-panel 26 | - '--cpu=2' 27 | - '--memory' 28 | - '2Gi' 29 | - '--concurrency=5' 30 | - '--min-instances=0' 31 | - '--max-instances=2' 32 | - '--port=8080' 33 | - '--set-env-vars' 34 | - 'ENV=production' 35 | - '--set-env-vars' 36 | - 'PANEL_OAUTH_SCOPE=email' 37 | - '--set-secrets=PANEL_OAUTH_REDIRECT_URI=PANEL_OAUTH_REDIRECT_URI:latest' 38 | - '--set-secrets=PANEL_OAUTH_KEY=PANEL_OAUTH_KEY:latest' 39 | - '--set-secrets=PANEL_OAUTH_SECRET=PANEL_OAUTH_SECRET:latest' 40 | - '--set-secrets=PANEL_OAUTH_ENCRYPTION=PANEL_OAUTH_ENCRYPTION:latest' 41 | - '--allow-unauthenticated' 42 | - '--session-affinity' 43 | - '--timeout=60m' 44 | - '--service-account=simdec-panel@delta-entity-401706.iam.gserviceaccount.com' 45 | - >- 46 | --image=$_AR_HOSTNAME/$PROJECT_ID/cloud-run-source-deploy/simdec-python/simdec-panel:$COMMIT_SHA 47 | - >- 48 | --labels=managed-by=gcp-cloud-build-deploy-cloud-run,commit-sha=$COMMIT_SHA,gcb-build-id=$BUILD_ID,gcb-trigger-id=$_TRIGGER_ID 49 | - '--region=$_DEPLOY_REGION' 50 | id: Deploy 51 | entrypoint: gcloud 52 | - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim' 53 | args: 54 | - run 55 | - services 56 | - update-traffic 57 | - simdec-panel 58 | - '--region=$_DEPLOY_REGION' 59 | - '--to-latest' 60 | id: Serve 61 | entrypoint: gcloud 62 | images: 63 | - >- 64 | $_AR_HOSTNAME/$PROJECT_ID/cloud-run-source-deploy/simdec-python/simdec-panel:$COMMIT_SHA 65 | options: 66 | substitutionOption: ALLOW_LOOSE 67 | logging: CLOUD_LOGGING_ONLY 68 | substitutions: 69 | _AR_HOSTNAME: europe-north1-docker.pkg.dev 70 | _TRIGGER_ID: 8ebd7eb7-1e16-4c90-a93d-ba76058df26d 71 | _DEPLOY_REGION: europe-north1 72 | tags: 73 | - gcp-cloud-build-deploy-cloud-run 74 | - gcp-cloud-build-deploy-cloud-run-managed 75 | - simdec-panel 76 | -------------------------------------------------------------------------------- /panel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | Sensitivity Analysis Applications 8 | 9 | 11 | 12 | 13 | 15 | 16 | 17 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 91 | 92 | 238 | 239 | 240 | 241 | 242 |
243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | Click to toggle the Theme 252 |
253 |
254 | 255 | 256 | 257 | Click to visit the SimDec web site 258 |
259 |
260 |
261 |
262 | 265 |
266 | 285 |
286 |
287 | 288 |
289 |
290 |
291 | 301 | 302 | 303 | -------------------------------------------------------------------------------- /panel/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | SimDec App | Login 9 | 10 | 64 | 65 | 66 |
67 | 80 |
81 | 82 | 83 | -------------------------------------------------------------------------------- /panel/logout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | SimDec App | Logout 9 | 10 | 77 | 78 | 79 |
80 | 91 |
92 | 93 | 94 | -------------------------------------------------------------------------------- /panel/sampling.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import io 3 | 4 | import matplotlib.pyplot as plt 5 | import pandas as pd 6 | import panel as pn 7 | import seaborn as sns 8 | import scipy as sp 9 | 10 | 11 | # panel app 12 | pn.extension("floatpanel") 13 | 14 | pn.config.sizing_mode = "stretch_width" 15 | pn.config.throttled = True 16 | font_size = "12pt" 17 | blue_color = "#4099da" 18 | 19 | template = pn.template.FastGridTemplate( 20 | title="Data generation (sampling)", 21 | # logo="_static/logo.gif", 22 | favicon="_static/favicon.png", 23 | meta_description="Data generation (sampling)", 24 | meta_keywords=( 25 | "Random Sampling, Monte Carlo, Quasi-Monte Carlo, " "Sobol', Halton, LHS" 26 | ), 27 | accent=blue_color, 28 | shadow=False, 29 | main_layout=None, 30 | theme_toggle=False, 31 | corner_radius=3, 32 | ) 33 | 34 | 35 | @pn.cache 36 | def figure(sample): 37 | plt.close("all") 38 | grid = sns.pairplot(sample, corner=True) 39 | return grid.figure 40 | 41 | 42 | def csv_data(sample: pd.DataFrame) -> io.StringIO: 43 | sio = io.StringIO() 44 | sample.to_csv(sio, index=False) 45 | sio.seek(0) 46 | return sio 47 | 48 | 49 | dim = pn.widgets.IntInput( 50 | name="Number of variables", 51 | start=1, 52 | end=15, 53 | value=2, 54 | step=1, 55 | ) 56 | 57 | 58 | variables_details = pn.Card(title="Variables details") 59 | 60 | 61 | def create_variable(dim): 62 | variables_ = [] 63 | for i in range(dim): 64 | variable_name = pn.widgets.TextInput(name="Variable name", placeholder="X") 65 | distribution = pn.widgets.Select( 66 | name="Distribution", 67 | options={ 68 | "Uniform": sp.stats.uniform, 69 | "Normal": sp.stats.norm, 70 | "Beta": sp.stats.beta, 71 | }, 72 | ) 73 | parameters = pn.widgets.TextInput(name="Parameters", placeholder="") 74 | bounds = pn.widgets.EditableRangeSlider( 75 | name="Bounds", start=0, end=1, value=(0.0, 1.0), step=0.1 76 | ) 77 | 78 | variable = pn.Card( 79 | variable_name, 80 | distribution, 81 | parameters, 82 | bounds, 83 | title=f"X{i+1}", 84 | ) 85 | variables_.append(variable) 86 | 87 | variables_details[:] = variables_ 88 | 89 | 90 | dummy_create_variable_bind = pn.rx(create_variable)(dim) 91 | 92 | 93 | n_samples = pn.widgets.IntInput( 94 | name="Number of samples", 95 | start=10, 96 | end=2**20, 97 | value=1024, 98 | step=50, 99 | ) 100 | 101 | 102 | sampling_engine = pn.widgets.Select( 103 | name="Sampling method", 104 | options={ 105 | "Sobol'": sp.stats.qmc.Sobol, 106 | "Halton": sp.stats.qmc.Halton, 107 | "Latin Hypercube": sp.stats.qmc.LatinHypercube, 108 | }, 109 | ) 110 | 111 | 112 | interactive_sample = sampling_engine.rx()(d=dim).random(n_samples) 113 | 114 | 115 | def sample_to_distributions(sample, variables_details): 116 | for i, variable_details in enumerate(variables_details): 117 | dist = variable_details[1].value 118 | dist_name = dist.name 119 | params = variable_details[2].value 120 | if params != "": 121 | params = ast.literal_eval(params) 122 | dist = dist(**params) 123 | 124 | sample_ = dist.ppf(sample[:, i]).reshape(-1, 1) 125 | 126 | if dist_name != "uniform": 127 | sample_ = sp.stats.qmc.scale( 128 | sample_, l_bounds=min(sample_), u_bounds=max(sample_), reverse=True 129 | ) 130 | 131 | bounds = variable_details[3].value 132 | sample_ = sp.stats.qmc.scale(sample_, l_bounds=bounds[0], u_bounds=bounds[1]) 133 | 134 | sample[:, i] = sample_.flatten() 135 | 136 | return sample 137 | 138 | 139 | def sample_to_dataframe(sample, variables_details): 140 | columns = [] 141 | for i, variable_details in enumerate(variables_details): 142 | x_name = variable_details[0].value 143 | columns.append(x_name if x_name != "" else f"X{i+1}") 144 | 145 | df = pd.DataFrame(data=sample, columns=columns) 146 | return df 147 | 148 | 149 | interactive_sample_dist = pn.rx(sample_to_distributions)( 150 | sample=interactive_sample, 151 | variables_details=variables_details, 152 | ) 153 | 154 | interactive_dataframe = pn.rx(sample_to_dataframe)( 155 | sample=interactive_sample_dist, 156 | variables_details=variables_details, 157 | ) 158 | 159 | interactive_figure = pn.bind(figure, interactive_dataframe) 160 | 161 | indicator_discrepancy = pn.indicators.Number( 162 | name="Discrepancy:", 163 | value=pn.bind(sp.stats.qmc.discrepancy, interactive_sample.rx()), 164 | title_size=font_size, 165 | font_size=font_size, 166 | ) 167 | 168 | 169 | # App layout 170 | 171 | # Sidebar 172 | sidebar_area = pn.layout.WidgetBox( 173 | pn.pane.Markdown("## Variables", styles={"color": blue_color}), 174 | dim, 175 | pn.Spacer(height=10), 176 | variables_details, 177 | dummy_create_variable_bind, 178 | pn.Spacer(height=10), 179 | pn.pane.Markdown("## Sampling", styles={"color": blue_color}), 180 | n_samples, 181 | sampling_engine, 182 | pn.pane.Markdown("## Metrics", styles={"color": blue_color}), 183 | indicator_discrepancy, 184 | sizing_mode="stretch_width", 185 | ) 186 | 187 | template.sidebar.append(sidebar_area) 188 | 189 | # Main window 190 | template.main[0:4, 0:6] = pn.panel(interactive_figure, loading_indicator=True) 191 | 192 | 193 | ################################################################################################################ 194 | 195 | # Header 196 | icon_size = "1.5em" 197 | 198 | download_file_button = pn.widgets.FileDownload( 199 | callback=pn.bind( 200 | csv_data, 201 | interactive_dataframe, 202 | ), 203 | icon="file-download", 204 | icon_size=icon_size, 205 | button_type="success", 206 | filename="samples.csv", 207 | width=200, 208 | label="Download", 209 | align="center", 210 | ) 211 | 212 | info_button = pn.widgets.Button( 213 | icon="info-circle", 214 | icon_size=icon_size, 215 | button_type="light", 216 | name="About", 217 | width=150, 218 | align="center", 219 | ) 220 | info_button.js_on_click(code="""window.open('https://www.simdec.fi/')""") 221 | 222 | issue_button = pn.widgets.Button( 223 | icon="message-report", 224 | icon_size=icon_size, 225 | button_type="light", 226 | name="Feedback", 227 | width=150, 228 | align="center", 229 | ) 230 | issue_button.js_on_click( 231 | code="""window.open('https://github.com/Simulation-Decomposition/simdec-python/issues')""" 232 | ) 233 | 234 | logout_button = pn.widgets.Button(name="Log out", width=100) 235 | logout_button.js_on_click(code="""window.location.href = './logout'""") 236 | 237 | docs_button = pn.widgets.Button( 238 | icon="notes", 239 | icon_size=icon_size, 240 | button_type="light", 241 | name="Docs", 242 | width=150, 243 | align="center", 244 | ) 245 | docs = pn.Column(height=0, width=0) 246 | 247 | 248 | def callback_docs(event): 249 | docs[:] = [ 250 | pn.layout.FloatPanel( 251 | "This is some documentation", 252 | name="SimDec documentation", 253 | theme="info", 254 | contained=False, 255 | position="center", 256 | ) 257 | ] 258 | 259 | 260 | docs_button.on_click(callback_docs) 261 | 262 | 263 | header_area = pn.Row( 264 | pn.HSpacer(), 265 | download_file_button, 266 | docs, 267 | docs_button, 268 | info_button, 269 | issue_button, 270 | # logout_button, 271 | ) 272 | 273 | template.header.append(header_area) 274 | 275 | # serve the template 276 | template.servable() 277 | -------------------------------------------------------------------------------- /panel/simdec_app.py: -------------------------------------------------------------------------------- 1 | import bisect 2 | import io 3 | 4 | from bokeh.models import PrintfTickFormatter 5 | from bokeh.models.widgets.tables import NumberFormatter 6 | import matplotlib as mpl 7 | import matplotlib.pyplot as plt 8 | import numpy as np 9 | import pandas as pd 10 | from pandas.io.formats.style import Styler 11 | import panel as pn 12 | import seaborn as sns 13 | 14 | import simdec as sd 15 | from simdec.visualization import sequential_cmaps, single_color_to_colormap 16 | 17 | 18 | # panel app 19 | pn.extension("tabulator") 20 | pn.extension("floatpanel") 21 | 22 | pn.config.sizing_mode = "stretch_width" 23 | pn.config.throttled = True 24 | font_size = "12pt" 25 | blue_color = "#4099da" 26 | 27 | template = pn.template.FastGridTemplate( 28 | title="Simulation Decomposition Dashboard", 29 | # logo="_static/logo.gif", 30 | favicon="_static/favicon.png", 31 | meta_description="Simulation Decomposition", 32 | meta_keywords=( 33 | "Sensitivity Analysis, Visualization, Data Analysis, Auditing, " 34 | "Factors priorization, Colorization, Histogram" 35 | ), 36 | accent=blue_color, 37 | shadow=False, 38 | main_layout=None, 39 | theme_toggle=False, 40 | corner_radius=3, 41 | # save_layout=True, 42 | ) 43 | 44 | 45 | @pn.cache 46 | def load_data(text_fname): 47 | if text_fname is None: 48 | text_fname = "tests/data/stress.csv" 49 | else: 50 | text_fname = io.BytesIO(text_fname) 51 | 52 | data = pd.read_csv(text_fname) 53 | return data 54 | 55 | 56 | @pn.cache 57 | def column_inputs(data, output): 58 | inputs = list(data.columns) 59 | inputs.remove(output) 60 | return inputs 61 | 62 | 63 | @pn.cache 64 | def column_output(data): 65 | return list(data.columns) 66 | 67 | 68 | @pn.cache 69 | def filtered_data(data, output_name): 70 | try: 71 | return data[output_name] 72 | except KeyError: 73 | return data.iloc[:, 0] 74 | 75 | 76 | @pn.cache 77 | def sensitivity_indices(inputs, output): 78 | sensitivity_indices = sd.sensitivity_indices(inputs=inputs, output=output) 79 | if 0.01 < sum(sensitivity_indices.si) < 2.0: 80 | indices = sensitivity_indices.si 81 | else: 82 | indices = sensitivity_indices.first_order 83 | return indices 84 | 85 | 86 | @pn.cache 87 | def sensitivity_indices_table(si, inputs): 88 | var_names = inputs.columns 89 | var_order = np.argsort(si)[::-1] 90 | var_names = var_names[var_order].tolist() 91 | 92 | si = list(si[var_order]) 93 | sum_si = sum(si) 94 | 95 | var_names.append("Sum of Indices") 96 | si_numerics = si.copy() 97 | si.append(0) 98 | si_numerics.append(sum_si) 99 | 100 | d = {"Inputs": var_names, "Indices": si, "": si_numerics} 101 | df = pd.DataFrame(data=d) 102 | formatters = { 103 | "Indices": {"type": "progress", "max": sum_si, "color": "#007eff"}, 104 | "": NumberFormatter(format="0.00"), 105 | } 106 | widget = pn.widgets.Tabulator( 107 | df, 108 | show_index=False, 109 | formatters=formatters, 110 | theme="bulma", 111 | frozen_rows=[-1], 112 | # page_size=5, 113 | # pagination='local', 114 | layout="fit_columns", 115 | ) 116 | widget.style.apply( 117 | lambda x: ["font-style: italic"] * 3, axis=1, subset=df.index[-1] 118 | ) 119 | widget.style.apply(lambda x: ["font-size: 11pt"] * len(si)) 120 | return widget 121 | 122 | 123 | @pn.cache 124 | def explained_variance(si): 125 | return sum(si) + np.finfo(np.float64).eps 126 | 127 | 128 | def filtered_si(sensitivity_indices_table, input_names): 129 | df = sensitivity_indices_table.value 130 | si = [] 131 | for input_name in input_names: 132 | si.append(df.loc[df["Inputs"] == input_name, "Indices"]) 133 | return np.asarray(si).flatten() 134 | 135 | 136 | def explained_variance_80(sensitivity_indices_table): 137 | si = sensitivity_indices_table.value["Indices"] 138 | pos_80 = bisect.bisect_right(np.cumsum(si), 0.8) 139 | 140 | # pos_80 = max(2, pos_80) 141 | # pos_80 = min(len(si), pos_80) 142 | 143 | input_names = sensitivity_indices_table.value["Inputs"] 144 | input_names = input_names.to_list() 145 | input_names.remove("Sum of Indices") 146 | return input_names[: pos_80 + 1] 147 | 148 | 149 | @pn.cache 150 | def decomposition_(dec_limit, si, inputs, output): 151 | return sd.decomposition( 152 | inputs=inputs, 153 | output=output, 154 | sensitivity_indices=si, 155 | dec_limit=dec_limit, 156 | auto_ordering=False, 157 | ) 158 | 159 | 160 | @pn.cache 161 | def base_colors(res): 162 | colors = [] 163 | # ensure not more colors than states 164 | for cmap in sequential_cmaps()[: res.states[0]]: 165 | color = cmap(0.5) 166 | color = mpl.colors.rgb2hex(color, keep_alpha=False) 167 | colors.append(color) 168 | 169 | return colors 170 | 171 | 172 | def update_colors_select(event): 173 | colors = [color_picker.value for color_picker in color_pickers] 174 | colors_select.param.update( 175 | options=colors, 176 | value=colors, 177 | ) 178 | 179 | 180 | def create_color_pickers(states, colors): 181 | color_picker_list = [] 182 | for state, color in zip(states[0], colors): 183 | color_picker = pn.widgets.ColorPicker(name=str(state), value=color) 184 | color_picker.param.watch(update_colors_select, "value") 185 | color_picker_list.append(color_picker) 186 | color_pickers[:] = color_picker_list 187 | 188 | 189 | @pn.cache 190 | def palette_(states: list[list[str]], colors_picked: list[list[float]]): 191 | cmaps = [single_color_to_colormap(color_picked) for color_picked in colors_picked] 192 | # Reverse order as in figures high values take the first colors 193 | states = [len(states_) for states_ in states] 194 | return sd.palette(states, cmaps=cmaps[::-1]) 195 | 196 | 197 | @pn.cache 198 | def n_bins_auto(res): 199 | min_ = np.nanmin(res.bins) 200 | max_ = np.nanmax(res.bins) 201 | return len(np.histogram_bin_edges(res.bins, bins="auto", range=(min_, max_))) - 1 202 | 203 | 204 | def display_n_bins(kind): 205 | return False if kind not in ("Stacked histogram", "2 outputs") else True 206 | 207 | 208 | def display_2_output(kind): 209 | return False if kind != "2 outputs" else True 210 | 211 | 212 | @pn.cache 213 | def xlim_auto(output): 214 | return (np.nanmin(output) * 0.95, np.nanmax(output) * 1.05) 215 | 216 | 217 | @pn.cache 218 | def figure_pn( 219 | res, res2, palette, n_bins, xlim, ylim, r_scatter, kind, output_name, output_2_name 220 | ): 221 | plt.close("all") 222 | 223 | if kind != "2 outputs": 224 | fig, ax = plt.subplots() 225 | 226 | kind = "histogram" if kind == "Stacked histogram" else "boxplot" 227 | _ = sd.visualization( 228 | bins=res.bins, palette=palette, n_bins=n_bins, kind=kind, ax=ax 229 | ) 230 | ax.set(xlabel=output_name) 231 | ax.set_xlim(xlim) 232 | else: 233 | fig, axs = plt.subplots(2, 2, sharex="col", sharey="row", figsize=(8, 8)) 234 | 235 | axs[0][1].axison = False 236 | 237 | _ = sd.visualization( 238 | bins=res.bins, 239 | palette=palette, 240 | n_bins=n_bins, 241 | kind="histogram", 242 | ax=axs[0][0], 243 | ) 244 | axs[0][0].set_xlim(xlim) 245 | axs[0][0].set_box_aspect(aspect=1) 246 | axs[0][0].axis("off") 247 | 248 | data = pd.concat([pd.melt(res.bins), pd.melt(res2.bins)["value"]], axis=1) 249 | data.columns = ["c", "x", "y"] 250 | data = data.sample(int(r_scatter * len(data))) 251 | _ = sns.scatterplot( 252 | data, x="x", y="y", hue="c", palette=palette, ax=axs[1][0], legend=False 253 | ) 254 | axs[1][0].set(xlabel=output_name) 255 | axs[1][0].set(ylabel=output_2_name) 256 | axs[1][0].set_box_aspect(aspect=1) 257 | 258 | _ = sns.histplot( 259 | data, 260 | y="y", 261 | hue="c", 262 | multiple="stack", 263 | stat="probability", 264 | palette=palette, 265 | common_bins=True, 266 | common_norm=True, 267 | bins=40, 268 | legend=False, 269 | ax=axs[1][1], 270 | ) 271 | axs[1][1].set_ylim(ylim) 272 | axs[1][1].set_box_aspect(aspect=1) 273 | axs[1][1].axis("off") 274 | 275 | fig.subplots_adjust(wspace=-0.015, hspace=0) 276 | return fig 277 | 278 | 279 | @pn.cache 280 | def states_from_data(res, inputs): 281 | return sd.states_expansion(states=res.states, inputs=inputs) 282 | 283 | 284 | @pn.cache 285 | def tableau_pn(res, states, palette): 286 | # use a notebook to see the styling 287 | _, styler = sd.tableau( 288 | statistic=res.statistic, 289 | var_names=res.var_names, 290 | states=res.states, 291 | bins=res.bins, 292 | palette=palette, 293 | ) 294 | return styler 295 | 296 | 297 | @pn.cache 298 | def tableau_states(res, states): 299 | data = [] 300 | for var_name, states_, bin_edges in zip(res.var_names, states, res.bin_edges): 301 | for i, state in enumerate(states_): 302 | data.append([var_name, state, bin_edges[i], bin_edges[i + 1]]) 303 | 304 | table = pd.DataFrame(data, columns=["variable", "state", "min", "max"]) 305 | table.set_index(["variable", "state"], inplace=True) 306 | 307 | styler = table.style 308 | styler.format(precision=2) 309 | styler.set_table_styles([{"selector": "th", "props": [("text-align", "center")]}]) 310 | return styler 311 | 312 | 313 | def csv_data( 314 | sensitivity_indices: pn.widgets.Tabulator, scenario: Styler, states: Styler 315 | ) -> io.StringIO: 316 | sio = io.StringIO() 317 | 318 | si_table = sensitivity_indices.value[["Inputs", ""]] 319 | si_table.rename(columns={"": "Indices"}, inplace=True) 320 | si_table.to_csv(sio, index=False) 321 | scenario.data.to_csv(sio) 322 | states.data.to_csv(sio) 323 | 324 | sio.seek(0) 325 | return sio 326 | 327 | 328 | # Bindings 329 | text_fname = pn.widgets.FileInput(sizing_mode="stretch_width", accept=".csv") 330 | 331 | interactive_file = pn.bind(load_data, text_fname) 332 | 333 | interactive_column_output = pn.bind(column_output, interactive_file) 334 | # hack to make the default selection faster 335 | interactive_output_ = pn.bind(lambda x: x[0], interactive_column_output) 336 | selector_output = pn.widgets.Select( 337 | name="Output", value=interactive_output_, options=interactive_column_output 338 | ) 339 | interactive_output = pn.bind(filtered_data, interactive_file, selector_output) 340 | 341 | interactive_column_input = pn.bind(column_inputs, interactive_file, selector_output) 342 | selector_inputs_sensitivity = pn.widgets.MultiSelect( 343 | name="Inputs", value=interactive_column_input, options=interactive_column_input 344 | ) 345 | interactive_inputs = pn.bind( 346 | filtered_data, interactive_file, selector_inputs_sensitivity 347 | ) 348 | 349 | interactive_sensitivity_indices = pn.bind( 350 | sensitivity_indices, interactive_inputs, interactive_output 351 | ) 352 | interactive_explained_variance = pn.bind( 353 | explained_variance, interactive_sensitivity_indices 354 | ) 355 | 356 | interactive_sensitivity_indices_table = pn.bind( 357 | sensitivity_indices_table, interactive_sensitivity_indices, interactive_inputs 358 | ) 359 | 360 | interactive_explained_variance_80 = pn.bind( 361 | explained_variance_80, interactive_sensitivity_indices_table 362 | ) 363 | selector_inputs_decomposition = pn.widgets.MultiChoice( 364 | name="Select inputs for decomposition", 365 | value=interactive_explained_variance_80, 366 | options=selector_inputs_sensitivity, 367 | solid=False, 368 | ) 369 | interactive_inputs_decomposition = pn.bind( 370 | filtered_data, interactive_file, selector_inputs_decomposition 371 | ) 372 | 373 | interactive_filtered_si = pn.bind( 374 | filtered_si, interactive_sensitivity_indices_table, selector_inputs_decomposition 375 | ) 376 | interactive_filtered_explained_variance = pn.bind( 377 | explained_variance, interactive_filtered_si 378 | ) 379 | indicator_explained_variance = pn.indicators.Number( 380 | name="Explained variance ratio from selected inputs:", 381 | value=interactive_filtered_explained_variance, 382 | title_size=font_size, 383 | font_size=font_size, 384 | format="{value:.2f}", 385 | ) 386 | 387 | 388 | interactive_decomposition = pn.bind( 389 | decomposition_, 390 | interactive_explained_variance, 391 | interactive_filtered_si, 392 | interactive_inputs_decomposition, 393 | interactive_output, 394 | ) 395 | 396 | switch_type_visualization = pn.widgets.RadioButtonGroup( 397 | name="Type of visualization", 398 | options=["Stacked histogram", "Boxplot", "2 outputs"], 399 | ) 400 | show_n_bins = pn.rx(display_n_bins)(switch_type_visualization) 401 | show_2_output = pn.rx(display_2_output)(switch_type_visualization) 402 | 403 | selector_2_output = pn.widgets.Select( 404 | name="Second output", 405 | value=None, 406 | options=interactive_column_output, 407 | visible=show_2_output, 408 | ) 409 | interactive_2_output = pn.bind(filtered_data, interactive_file, selector_2_output) 410 | 411 | interactive_sensitivity_indices_2 = pn.bind( 412 | sensitivity_indices, interactive_inputs, interactive_2_output 413 | ) 414 | interactive_explained_variance_2 = pn.bind( 415 | explained_variance, interactive_sensitivity_indices_2 416 | ) 417 | 418 | interactive_sensitivity_indices_table_2 = pn.bind( 419 | sensitivity_indices_table, interactive_sensitivity_indices_2, interactive_inputs 420 | ) 421 | 422 | interactive_filtered_si_2 = pn.bind( 423 | filtered_si, interactive_sensitivity_indices_table_2, selector_inputs_decomposition 424 | ) 425 | 426 | interactive_decomposition_2 = pn.bind( 427 | decomposition_, 428 | interactive_explained_variance_2, 429 | interactive_filtered_si_2, 430 | interactive_inputs_decomposition, 431 | interactive_2_output, 432 | ) 433 | 434 | selector_r_scatter = pn.widgets.EditableFloatSlider( 435 | name="Share of data shown", 436 | start=0.0, 437 | end=1.0, 438 | value=1.0, 439 | step=0.1, 440 | # bar_color="#FFFFFF", # does not work 441 | visible=show_2_output, 442 | ) 443 | 444 | interactive_n_bins_auto = pn.rx(n_bins_auto)(interactive_decomposition) 445 | selector_n_bins = pn.widgets.EditableIntSlider( 446 | name="Number of bins", 447 | start=0, 448 | end=100, 449 | value=interactive_n_bins_auto, 450 | step=10, 451 | # bar_color="#FFFFFF", # does not work 452 | format=PrintfTickFormatter(format="%d bins"), 453 | visible=show_n_bins, 454 | ) 455 | 456 | interactive_xlim = pn.rx(xlim_auto)(interactive_output) 457 | selector_xlim = pn.widgets.EditableRangeSlider( 458 | name="X-lim", 459 | start=interactive_xlim.rx()[0], 460 | end=interactive_xlim.rx()[1], 461 | value=interactive_xlim.rx(), 462 | format="0.0[00]", 463 | step=0.1, 464 | ) 465 | 466 | interactive_ylim = pn.rx(xlim_auto)(interactive_2_output) 467 | selector_ylim = pn.widgets.EditableRangeSlider( 468 | name="Y-lim", 469 | start=interactive_ylim.rx()[0], 470 | end=interactive_ylim.rx()[1], 471 | value=interactive_ylim.rx(), 472 | format="0.0[00]", 473 | step=0.1, 474 | visible=show_2_output, 475 | ) 476 | 477 | 478 | def callback_xlim(start, end): 479 | selector_xlim.param.update(dict(value=(start, end))) 480 | 481 | 482 | selector_xlim.param.watch_values(fn=callback_xlim, parameter_names=["start", "end"]) 483 | 484 | 485 | def callback_ylim(start, end): 486 | selector_ylim.param.update(dict(value=(start, end))) 487 | 488 | 489 | selector_ylim.param.watch_values(fn=callback_ylim, parameter_names=["start", "end"]) 490 | 491 | 492 | interactive_states = pn.bind( 493 | states_from_data, interactive_decomposition, interactive_inputs_decomposition 494 | ) 495 | 496 | 497 | interactive_base_colors = pn.bind(base_colors, interactive_decomposition) 498 | 499 | 500 | color_pickers = pn.Card(title="Main color for states") 501 | colors_select = pn.widgets.MultiSelect( 502 | value=interactive_base_colors, 503 | options=interactive_base_colors, 504 | name="Colors", 505 | visible=False, 506 | ) 507 | 508 | dummy_color_pickers_bind = pn.bind( 509 | create_color_pickers, 510 | interactive_states, 511 | colors_select.param.value, 512 | ) 513 | 514 | interactive_palette = pn.bind(palette_, interactive_states, colors_select.param.value) 515 | 516 | interactive_figure = pn.bind( 517 | figure_pn, 518 | interactive_decomposition, 519 | interactive_decomposition_2, 520 | interactive_palette, 521 | selector_n_bins, 522 | selector_xlim, 523 | selector_ylim, 524 | selector_r_scatter, 525 | switch_type_visualization, 526 | selector_output, 527 | selector_2_output, 528 | ) 529 | 530 | interactive_tableau = pn.bind( 531 | tableau_pn, interactive_decomposition, interactive_states, interactive_palette 532 | ) 533 | interactive_tableau_states = pn.bind( 534 | tableau_states, interactive_decomposition, interactive_states 535 | ) 536 | 537 | # App layout 538 | 539 | # Sidebar 540 | sidebar_area = pn.layout.WidgetBox( 541 | pn.pane.Markdown("## Data", styles={"color": blue_color}), 542 | text_fname, 543 | selector_output, 544 | selector_inputs_sensitivity, 545 | pn.pane.Markdown("## Decomposition", styles={"color": blue_color}), 546 | selector_inputs_decomposition, 547 | indicator_explained_variance, 548 | pn.pane.Markdown("## Visualization", styles={"color": blue_color}), 549 | switch_type_visualization, 550 | selector_2_output, 551 | selector_n_bins, 552 | selector_r_scatter, 553 | selector_xlim, 554 | selector_ylim, 555 | dummy_color_pickers_bind, 556 | color_pickers, 557 | sizing_mode="stretch_width", 558 | ) 559 | 560 | template.sidebar.append(sidebar_area) 561 | 562 | # Main window 563 | template.main[0:4, 0:6] = pn.panel(interactive_figure, loading_indicator=True) 564 | 565 | template.main[0:4, 6:12] = pn.Column( 566 | pn.pane.Markdown("## Scenarios", styles={"color": blue_color}), 567 | pn.panel(interactive_tableau, loading_indicator=True), 568 | ) 569 | 570 | template.main[4:7, 6:12] = pn.Column( 571 | pn.pane.Markdown("## Details on inputs' states", styles={"color": blue_color}), 572 | pn.panel(interactive_tableau_states, loading_indicator=True), 573 | ) 574 | 575 | template.main[4:7, 0:4] = pn.Column( 576 | pn.pane.Markdown("## Sensitivity Indices", styles={"color": blue_color}), 577 | pn.panel(interactive_sensitivity_indices_table, loading_indicator=True), 578 | width_policy="fit", 579 | max_width=500, 580 | ) 581 | 582 | # Header 583 | icon_size = "1.5em" 584 | 585 | download_file_button = pn.widgets.FileDownload( 586 | callback=pn.bind( 587 | csv_data, 588 | interactive_sensitivity_indices_table, 589 | interactive_tableau, 590 | interactive_tableau_states, 591 | ), 592 | icon="file-download", 593 | icon_size=icon_size, 594 | button_type="success", 595 | filename="simdec.csv", 596 | width=200, 597 | label="Download", 598 | align="center", 599 | ) 600 | 601 | info_button = pn.widgets.Button( 602 | icon="info-circle", 603 | icon_size=icon_size, 604 | button_type="light", 605 | name="About", 606 | width=150, 607 | align="center", 608 | ) 609 | info_button.js_on_click(code="""window.open('https://www.simdec.fi/')""") 610 | 611 | issue_button = pn.widgets.Button( 612 | icon="message-report", 613 | icon_size=icon_size, 614 | button_type="light", 615 | name="Feedback", 616 | width=150, 617 | align="center", 618 | ) 619 | issue_button.js_on_click( 620 | code="""window.open('https://github.com/Simulation-Decomposition/simdec-python/issues')""" 621 | ) 622 | 623 | logout_button = pn.widgets.Button(name="Log out", width=100) 624 | logout_button.js_on_click(code="""window.location.href = './logout'""") 625 | 626 | docs_button = pn.widgets.Button( 627 | icon="notes", 628 | icon_size=icon_size, 629 | button_type="light", 630 | name="Docs", 631 | width=150, 632 | align="center", 633 | ) 634 | docs = pn.Column(height=0, width=0) 635 | 636 | 637 | def callback_docs(event): 638 | docs[:] = [ 639 | pn.layout.FloatPanel( 640 | "This is some documentation", 641 | name="SimDec documentation", 642 | theme="info", 643 | contained=False, 644 | position="center", 645 | ) 646 | ] 647 | 648 | 649 | docs_button.on_click(callback_docs) 650 | 651 | 652 | header_area = pn.Row( 653 | pn.HSpacer(), 654 | download_file_button, 655 | docs, 656 | docs_button, 657 | info_button, 658 | issue_button, 659 | # logout_button, 660 | ) 661 | 662 | template.header.append(header_area) 663 | 664 | # serve the template 665 | template.servable() 666 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.14.0"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "simdec" 7 | version = "1.3.0" 8 | description = "Sensitivity analysis using simulation decomposition" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = "BSD-3-Clause" 12 | authors = [ 13 | { name = "Pamphile Roy" }, 14 | ] 15 | maintainers = [ 16 | { name = "simdec contributors" }, 17 | ] 18 | classifiers = [ 19 | "Intended Audience :: End Users/Desktop", 20 | "Intended Audience :: Developers", 21 | "Intended Audience :: Science/Research", 22 | "License :: OSI Approved :: BSD License", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | ] 30 | 31 | dependencies = [ 32 | "numpy", 33 | "pandas", 34 | "SALib", 35 | "seaborn", 36 | ] 37 | 38 | [project.optional-dependencies] 39 | dashboard = [ 40 | "panel>=1.4.5", 41 | "cryptography", 42 | ] 43 | 44 | test = [ 45 | "pytest", 46 | "pytest-cov", 47 | ] 48 | 49 | doc = [ 50 | "sphinx", 51 | "pydata-sphinx-theme", 52 | "accessible-pygments", 53 | "numpydoc", 54 | "myst-nb", 55 | ] 56 | 57 | dev = [ 58 | "simdec[doc,test,dashboard]", 59 | "watchfiles", 60 | "pre-commit", 61 | "hatch", 62 | ] 63 | 64 | [project.urls] 65 | homepage = "https://www.simdec.fi/" 66 | documentation = "https://simdec.readthedocs.io" 67 | source = "https://github.com/Simulation-Decomposition/simdec-python" 68 | 69 | [tool.hatch] 70 | build.targets.wheel.packages = ["src/simdec"] 71 | build.targets.sdist.exclude = [ 72 | ".github", 73 | "docs", 74 | "panel", 75 | "tests", 76 | "*.rst", 77 | "*.yml", 78 | ".*", 79 | "Makefile", 80 | "Dockerfile", 81 | ] 82 | 83 | [project.entry-points."panel.auth"] 84 | custom_google = "simdec.auth:CustomGoogleLoginHandler" 85 | 86 | [tool.pytest.ini_options] 87 | addopts = "--durations 10" 88 | testpaths = [ 89 | "tests", 90 | ] 91 | 92 | [tool.ruff.per-file-ignores] 93 | "**/__init__.py" = ["F403", "F405"] 94 | -------------------------------------------------------------------------------- /src/simdec/__init__.py: -------------------------------------------------------------------------------- 1 | """SimDec main namespace.""" 2 | from simdec.decomposition import * 3 | from simdec.sensitivity_indices import * 4 | from simdec.visualization import * 5 | 6 | __all__ = [ 7 | "sensitivity_indices", 8 | "states_expansion", 9 | "decomposition", 10 | "visualization", 11 | "tableau", 12 | "palette", 13 | ] 14 | -------------------------------------------------------------------------------- /src/simdec/auth.py: -------------------------------------------------------------------------------- 1 | from panel.auth import GoogleLoginHandler 2 | from panel.io.resources import CDN_DIST 3 | 4 | 5 | class CustomGoogleLoginHandler(GoogleLoginHandler): 6 | def _simple_get(self): 7 | html = self._login_template.render(errormessage="", PANEL_CDN=CDN_DIST) 8 | self.write(html) 9 | 10 | async def get(self): 11 | if "login" in self.request.uri and "state" not in self.request.uri: 12 | self._simple_get() 13 | else: 14 | await super().get() 15 | 16 | async def post(self): 17 | await super().get() 18 | -------------------------------------------------------------------------------- /src/simdec/decomposition.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from hashlib import blake2b 5 | from typing import Literal 6 | 7 | import numpy as np 8 | import pandas as pd 9 | from scipy import stats 10 | 11 | 12 | __all__ = ["decomposition", "states_expansion"] 13 | 14 | 15 | def states_expansion(states: list[int], inputs: pd.DataFrame) -> list[list[str]]: 16 | """Expand states list to fully represent all scenarios.""" 17 | inputs = pd.DataFrame(inputs) 18 | expanded_states = [] 19 | for state in states: 20 | if isinstance(state, int): 21 | if state == 2: 22 | expanded_states.append(["low", "high"]) 23 | elif state == 3: 24 | expanded_states.append(["low", "medium", "high"]) 25 | else: 26 | expanded_states.append([i for i in range(state)]) 27 | else: 28 | expanded_states.append(state) 29 | 30 | # categorical for a given variable 31 | cat_cols = inputs.select_dtypes(exclude=["number"]) 32 | cat_cols_idx = [] 33 | states_cats_ = [] 34 | for cat_col in cat_cols: 35 | _, cats = pd.factorize(inputs[cat_col]) 36 | cat_cols_idx.append(inputs.columns.get_loc(cat_col)) 37 | states_cats_.append(cats) 38 | 39 | for i, states_cat_ in zip(cat_cols_idx, states_cats_): 40 | n_unique = np.unique(inputs.iloc[:, i]).size 41 | expanded_states[i] = list(states_cat_) if n_unique < 5 else expanded_states[i] 42 | 43 | return expanded_states 44 | 45 | 46 | @dataclass 47 | class DecompositionResult: 48 | var_names: list[str] 49 | statistic: np.ndarray 50 | bins: pd.DataFrame 51 | states: list[int] 52 | bin_edges: np.ndarray 53 | 54 | def __reduce__(self): 55 | h = blake2b(key=b"result hashing", digest_size=20) 56 | 57 | h.update(str(self.var_names).encode()) 58 | h.update(str(self.statistic).encode()) 59 | h.update(str(self.bins).encode()) 60 | h.update(str(self.states).encode()) 61 | h.update(str(self.bin_edges).encode()) 62 | 63 | return [h.hexdigest()] 64 | 65 | 66 | def decomposition( 67 | inputs: pd.DataFrame, 68 | output: pd.DataFrame, 69 | *, 70 | sensitivity_indices: np.ndarray, 71 | dec_limit: float = 1, 72 | auto_ordering: bool = True, 73 | states: list[int] | None = None, 74 | statistic: Literal["mean", "median"] | None = "mean", 75 | ) -> DecompositionResult: 76 | """SimDec decomposition. 77 | 78 | Parameters 79 | ---------- 80 | inputs : DataFrame of shape (n_runs, n_factors) 81 | Input variables. 82 | output : DataFrame of shape (n_runs, 1) 83 | Target variable. 84 | sensitivity_indices : ndarray of shape (n_factors, 1) 85 | Sensitivity indices, combined effect of each input. 86 | dec_limit : float 87 | Explained variance ratio to filter the number input variables. 88 | auto_ordering : bool 89 | Automatically order input columns based on the relative sensitivity_indices 90 | or use the provided order. 91 | states : list of int, optional 92 | List of possible states for the considered parameter. 93 | statistic : {"mean", "median"}, optional 94 | Statistic to compute in each bin. 95 | 96 | Returns 97 | ------- 98 | res : DecompositionResult 99 | An object with attributes: 100 | 101 | var_names : list of string (n_factors, 1) 102 | Variable names. 103 | statistic : ndarray of shape (n_factors, 1) 104 | Statistic in each bin. 105 | bins : DataFrame 106 | Multidimensional bins. 107 | states : list of int 108 | List of possible states for the considered parameter. 109 | 110 | """ 111 | var_names = inputs.columns 112 | 113 | cat_cols = inputs.select_dtypes(exclude=["number"]) 114 | for cat_col in cat_cols: 115 | codes, cat_states_ = pd.factorize(inputs[cat_col]) 116 | inputs[cat_col] = codes 117 | 118 | inputs = inputs.to_numpy() 119 | output = output.to_numpy() 120 | 121 | # 1. variables for decomposition 122 | var_order = np.argsort(sensitivity_indices)[::-1] 123 | 124 | # only keep the explained variance corresponding to `dec_limit` 125 | sensitivity_indices = sensitivity_indices[var_order] 126 | 127 | if auto_ordering: 128 | n_var_dec = np.where(np.cumsum(sensitivity_indices) < dec_limit)[0].size 129 | n_var_dec = max(1, n_var_dec) # keep at least one variable 130 | n_var_dec = min(5, n_var_dec) # use at most 5 variables 131 | else: 132 | n_var_dec = inputs.shape[1] 133 | 134 | # 2. states formation 135 | if states is None: 136 | states = 3 if n_var_dec < 3 else 2 137 | states = [states] * n_var_dec 138 | 139 | for i in range(n_var_dec): 140 | n_unique = np.unique(inputs[:, i]).size 141 | states[i] = n_unique if n_unique <= 5 else states[i] 142 | 143 | if auto_ordering: 144 | var_names = var_names[var_order[:n_var_dec]].tolist() 145 | inputs = inputs[:, var_order[:n_var_dec]] 146 | 147 | # 3. decomposition 148 | bins = [] 149 | 150 | statistic_methods = { 151 | "mean": np.mean, 152 | "median": np.median, 153 | } 154 | try: 155 | statistic_method = statistic_methods[statistic] 156 | except IndexError: 157 | msg = f"'statistic' must be one of {statistic_methods.values()}" 158 | raise ValueError(msg) 159 | 160 | def statistic_(inputs): 161 | """Custom function to keep track of the content of bins.""" 162 | bins.append(inputs) 163 | return statistic_method(inputs) 164 | 165 | # make bins with equal number of samples for a given dimension 166 | # sort and then split in n-state 167 | sorted_inputs = np.sort(inputs, axis=0) 168 | bin_edges = [] 169 | for i, states_ in enumerate(states): 170 | splits = np.array_split(sorted_inputs[:, i], states_) 171 | bin_edges_ = [splits_[0] for splits_ in splits] 172 | bin_edges_.append(splits[-1][-1]) # last point to close the edges 173 | # bin_edges_ = np.unique(bin_edges_) # remove duplicate points, sorted 174 | bin_edges_ += 1e-10 * np.linspace(0, 1, len(bin_edges_)) 175 | bin_edges.append(bin_edges_) 176 | 177 | res = stats.binned_statistic_dd( 178 | inputs, values=output, statistic=statistic_, bins=bin_edges 179 | ) 180 | 181 | bins = pd.DataFrame(bins[1:]).T 182 | 183 | if len(bins.columns) != np.prod(states): 184 | # mismatch with the number of states vs bins 185 | # when it happens, we have NaNs in the statistic 186 | # we can add empty columns with NaNs on these positions as bins 187 | # then are not present for these states 188 | nan_idx = np.argwhere(np.isnan(res.statistic).flatten()).flatten() 189 | 190 | for idx in nan_idx: 191 | bins = np.insert(bins, idx, np.nan, axis=1) 192 | 193 | bins = pd.DataFrame(bins) 194 | 195 | return DecompositionResult( 196 | var_names=var_names, 197 | statistic=res.statistic, 198 | bins=bins, 199 | states=states, 200 | bin_edges=res.bin_edges, 201 | ) 202 | -------------------------------------------------------------------------------- /src/simdec/sensitivity_indices.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import warnings 3 | 4 | import numpy as np 5 | import pandas as pd 6 | from scipy import stats 7 | 8 | 9 | __all__ = ["sensitivity_indices"] 10 | 11 | 12 | def number_of_bins(n_runs: int, n_factors: int) -> tuple[int, int]: 13 | """Optimal number of bins for first & second-order sensitivity_indices indices. 14 | 15 | Linear approximation of experimental results from (Marzban & Lahmer, 2016). 16 | """ 17 | n_bins_foe = 36 - 2.7 * n_factors + (0.0017 - 0.00008 * n_factors) * n_runs 18 | n_bins_foe = np.ceil(n_bins_foe) 19 | if n_bins_foe <= 30: 20 | n_bins_foe = 10 # setting a limit to fit the experimental results 21 | 22 | n_bins_soe = max(4, np.round(np.sqrt(n_bins_foe))) 23 | 24 | return n_bins_foe, n_bins_soe 25 | 26 | 27 | def _weighted_var(x: np.ndarray, weights: np.ndarray) -> np.ndarray: 28 | avg = np.average(x, weights=weights) 29 | variance = np.average((x - avg) ** 2, weights=weights) 30 | return variance 31 | 32 | 33 | @dataclass 34 | class SensitivityAnalysisResult: 35 | si: np.ndarray 36 | first_order: np.ndarray 37 | second_order: np.ndarray 38 | 39 | 40 | def sensitivity_indices( 41 | inputs: pd.DataFrame | np.ndarray, output: pd.DataFrame | np.ndarray 42 | ) -> SensitivityAnalysisResult: 43 | """Sensitivity indices. 44 | 45 | The sensitivity_indices express how much variability of the output is 46 | explained by the inputs. 47 | 48 | Parameters 49 | ---------- 50 | inputs : ndarray or DataFrame of shape (n_runs, n_factors) 51 | Input variables. 52 | output : ndarray or DataFrame of shape (n_runs, 1) 53 | Target variable. 54 | 55 | Returns 56 | ------- 57 | res : SensitivityAnalysisResult 58 | An object with attributes: 59 | 60 | si : ndarray of shape (n_factors, 1) 61 | Sensitivity indices, combined effect of each input. 62 | foe : ndarray of shape (n_factors, 1) 63 | First-order effects (also called 'main' or 'individual'). 64 | soe : ndarray of shape (n_factors, 1) 65 | Second-order effects (also called 'interaction'). 66 | 67 | Examples 68 | -------- 69 | >>> import numpy as np 70 | >>> from scipy.stats import qmc 71 | >>> import simdec as sd 72 | 73 | We define first the function that we want to analyse. We use the 74 | well studied Ishigami function: 75 | 76 | >>> def f_ishigami(x): 77 | ... return (np.sin(x[0]) + 7 * np.sin(x[1]) ** 2 78 | ... + 0.1 * (x[2] ** 4) * np.sin(x[0])) 79 | 80 | Then we generate inputs using the Quasi-Monte Carlo method of Sobol' in 81 | order to cover uniformly our space. And we compute outputs of the function. 82 | 83 | >>> rng = np.random.default_rng() 84 | >>> inputs = qmc.Sobol(d=3, seed=rng).random(2**18) 85 | >>> inputs = qmc.scale( 86 | ... sample=inputs, 87 | ... l_bounds=[-np.pi, -np.pi, -np.pi], 88 | ... u_bounds=[np.pi, np.pi, np.pi] 89 | ... ) 90 | >>> output = f_ishigami(inputs.T) 91 | 92 | We can now pass our inputs and outputs to the `sensitivity_indices` function: 93 | 94 | >>> res = sd.sensitivity_indices(inputs=inputs, output=output) 95 | >>> res.si 96 | array([0.43157591, 0.44241433, 0.11767249]) 97 | 98 | """ 99 | if isinstance(inputs, pd.DataFrame): 100 | cat_columns = inputs.select_dtypes(["category", "O"]).columns 101 | inputs[cat_columns] = inputs[cat_columns].apply( 102 | lambda x: x.astype("category").cat.codes 103 | ) 104 | inputs = inputs.to_numpy() 105 | if isinstance(output, pd.DataFrame): 106 | output = output.to_numpy() 107 | 108 | n_runs, n_factors = inputs.shape 109 | n_bins_foe, n_bins_soe = number_of_bins(n_runs, n_factors) 110 | 111 | # Overall variance of the output 112 | var_y = np.var(output) 113 | 114 | si = np.empty(n_factors) 115 | foe = np.empty(n_factors) 116 | soe = np.zeros((n_factors, n_factors)) 117 | 118 | for i in range(n_factors): 119 | # first order 120 | xi = inputs[:, i] 121 | 122 | bin_avg, _, binnumber = stats.binned_statistic( 123 | x=xi, values=output, bins=n_bins_foe 124 | ) 125 | # can have NaN in the average but no corresponding binnumber 126 | bin_avg = bin_avg[~np.isnan(bin_avg)] 127 | bin_counts = np.unique(binnumber, return_counts=True)[1] 128 | 129 | # weighted variance and divide by the overall variance of the output 130 | foe[i] = _weighted_var(bin_avg, weights=bin_counts) / var_y 131 | 132 | # second order 133 | for j in range(n_factors): 134 | if i == j or j < i: 135 | continue 136 | 137 | xj = inputs[:, j] 138 | 139 | bin_avg, *edges, binnumber = stats.binned_statistic_2d( 140 | x=xi, y=xj, values=output, bins=n_bins_soe, expand_binnumbers=False 141 | ) 142 | 143 | mean_ij = bin_avg[~np.isnan(bin_avg)] 144 | bin_counts = np.unique(binnumber, return_counts=True)[1] 145 | var_ij = _weighted_var(mean_ij, weights=bin_counts) 146 | 147 | # expand_binnumbers here 148 | nbin = np.array([len(edges_) + 1 for edges_ in edges]) 149 | binnumbers = np.asarray(np.unravel_index(binnumber, nbin)) 150 | 151 | bin_counts_i = np.unique(binnumbers[0], return_counts=True)[1] 152 | bin_counts_j = np.unique(binnumbers[1], return_counts=True)[1] 153 | 154 | # handle NaNs 155 | with warnings.catch_warnings(): 156 | warnings.simplefilter("ignore", RuntimeWarning) 157 | mean_i = np.nanmean(bin_avg, axis=1) 158 | mean_i = mean_i[~np.isnan(mean_i)] 159 | mean_j = np.nanmean(bin_avg, axis=0) 160 | mean_j = mean_j[~np.isnan(mean_j)] 161 | 162 | var_i = _weighted_var(mean_i, weights=bin_counts_i) 163 | var_j = _weighted_var(mean_j, weights=bin_counts_j) 164 | 165 | soe[i, j] = (var_ij - var_i - var_j) / var_y 166 | 167 | soe = np.clip(soe, a_min=0, a_max=None) 168 | soe = np.where(soe == 0, soe.T, soe) 169 | si[i] = foe[i] + soe[:, i].sum() / 2 170 | 171 | return SensitivityAnalysisResult(si, foe, soe) 172 | -------------------------------------------------------------------------------- /src/simdec/visualization.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import functools 3 | import itertools 4 | from typing import Literal 5 | 6 | import colorsys 7 | import matplotlib as mpl 8 | import matplotlib.pyplot as plt 9 | import numpy as np 10 | import seaborn as sns 11 | import pandas as pd 12 | from pandas.io.formats.style import Styler 13 | 14 | __all__ = ["visualization", "tableau", "palette"] 15 | 16 | 17 | SEQUENTIAL_PALETTES = [ 18 | "#DC267F", 19 | "#E8EA2F", 20 | "#26DCD1", 21 | "#C552E4", 22 | "#3F45D0", 23 | "Oranges", 24 | "Purples", 25 | "Reds", 26 | "Blues", 27 | "Greens", 28 | "YlOrBr", 29 | "YlOrRd", 30 | "OrRd", 31 | "PuRd", 32 | "RdPu", 33 | "BuPu", 34 | "GnBu", 35 | "PuBu", 36 | "YlGnBu", 37 | "PuBuGn", 38 | "BuGn", 39 | "YlGn", 40 | "Greys", 41 | ] 42 | 43 | 44 | @functools.cache 45 | def sequential_cmaps(): 46 | cmaps = [] 47 | for cmap in SEQUENTIAL_PALETTES: 48 | try: 49 | cmap_ = mpl.colormaps[cmap] 50 | except KeyError: 51 | color = mpl.colors.hex2color(cmap) 52 | cmap_ = single_color_to_colormap(color) 53 | cmaps.append(cmap_) 54 | return cmaps 55 | 56 | 57 | def single_color_to_colormap( 58 | rgba_color: list[float] | str, *, factor: float = 0.5 59 | ) -> mpl.colors.LinearSegmentedColormap: 60 | """Create a linear colormap using a single color.""" 61 | if isinstance(rgba_color, str): 62 | rgba_color = mpl.colors.hex2color(rgba_color) 63 | # discard alpha channel 64 | if len(rgba_color) == 4: 65 | *rgb_color, alpha = rgba_color 66 | else: 67 | alpha = 1.0 68 | rgb_color = rgba_color 69 | rgba_color = list(rgba_color) + [1] 70 | 71 | # lighten and darken from factor around single color 72 | hls_color = colorsys.rgb_to_hls(*rgb_color) 73 | 74 | lightness = hls_color[1] 75 | lightened_hls_color = (hls_color[0], lightness * (1 + factor), hls_color[2]) 76 | lightened_rgb_color = list(colorsys.hls_to_rgb(*lightened_hls_color)) 77 | 78 | darkened_hls_color = (hls_color[0], lightness * (1 - factor), hls_color[2]) 79 | darkened_rgb_color = list(colorsys.hls_to_rgb(*darkened_hls_color)) 80 | 81 | lightened_rgba_color = lightened_rgb_color + [alpha] 82 | darkened_rgba_color = darkened_rgb_color + [alpha] 83 | 84 | # convert to CMAP 85 | cmap = mpl.colors.LinearSegmentedColormap.from_list( 86 | "CustomSingleColor", 87 | [lightened_rgba_color, rgba_color, darkened_rgba_color], 88 | N=3, 89 | ) 90 | return cmap 91 | 92 | 93 | def palette( 94 | states: list[int], cmaps: list[mpl.colors.LinearSegmentedColormap] = None 95 | ) -> list[list[float]]: 96 | """Colour palette. 97 | 98 | The product of the states gives the number of scenarios. For each 99 | scenario, a colour is set. 100 | 101 | Parameters 102 | ---------- 103 | states : list of int 104 | List of possible states for the considered parameter. 105 | cmaps : list of LinearSegmentedColormap 106 | List of colormaps. Must have the same number of colormaps as the number 107 | of first level of states. 108 | Returns 109 | ------- 110 | palette : list of float of size (n, 4) 111 | List of colors corresponding to scenarios. RGBA formatted. 112 | """ 113 | n_cmaps = states[0] 114 | if cmaps is None: 115 | cmaps = sequential_cmaps()[:n_cmaps] 116 | else: 117 | cmaps = cmaps[:n_cmaps] 118 | if len(cmaps) != n_cmaps: 119 | raise ValueError( 120 | f"Must have the same number of cmaps ({len(cmaps)}) as the " 121 | f"number of first states ({n_cmaps})" 122 | ) 123 | 124 | colors = [] 125 | # one palette per first level state, could use more palette when there are 126 | # many levels 127 | n_shades = int(np.prod(states[1:])) 128 | for i in range(n_cmaps): 129 | cmap = cmaps[i].resampled(n_shades) 130 | colors.append(cmap(np.linspace(0, 1, n_shades))) 131 | 132 | return np.concatenate(colors).tolist() 133 | 134 | 135 | def visualization( 136 | *, 137 | bins: pd.DataFrame, 138 | palette: list[list[float]], 139 | n_bins: str | int = "auto", 140 | kind: Literal["histogram", "boxplot"] = "histogram", 141 | ax=None, 142 | ) -> plt.Axes: 143 | """Histogram plot of scenarios. 144 | 145 | Parameters 146 | ---------- 147 | bins : DataFrame 148 | Multidimensional bins. 149 | palette : list of int of size (n, 4) 150 | List of colours corresponding to scenarios. 151 | n_bins : str or int 152 | Number of bins or method from `np.histogram_bin_edges`. 153 | kind: {"histogram", "boxplot"} 154 | Histogram or Box Plot. 155 | ax : Axes, optional 156 | Matplotlib axis. 157 | 158 | Returns 159 | ------- 160 | ax : Axes 161 | Matplotlib axis. 162 | 163 | """ 164 | # needed to get the correct stacking order 165 | bins.columns = pd.RangeIndex(start=len(bins.columns), stop=0, step=-1) 166 | 167 | if kind == "histogram": 168 | ax = sns.histplot( 169 | bins, 170 | multiple="stack", 171 | stat="probability", 172 | palette=palette, 173 | common_bins=True, 174 | common_norm=True, 175 | bins=n_bins, 176 | legend=False, 177 | ax=ax, 178 | ) 179 | elif kind == "boxplot": 180 | ax = sns.boxplot( 181 | bins, 182 | palette=palette, 183 | orient="h", 184 | order=list(bins.columns)[::-1], 185 | ax=ax, 186 | ) 187 | else: 188 | raise ValueError("'kind' can only be 'histogram' or 'boxplot'") 189 | return ax 190 | 191 | 192 | def tableau( 193 | *, 194 | var_names: list[str], 195 | statistic: np.ndarray, 196 | states: list[int | list[str]], 197 | bins: pd.DataFrame, 198 | palette: np.ndarray, 199 | ) -> tuple[pd.DataFrame, Styler]: 200 | """Generate a table of statistics for all scenarios. 201 | 202 | Parameters 203 | ---------- 204 | var_names : list of str 205 | Variables name. 206 | statistic : ndarray of shape (n_factors, 1) 207 | Statistic in each bin. 208 | states : list of int or list of str 209 | For each variable, number of states. Can either be a scalar or a list. 210 | 211 | ``states=[2, 2]`` or ``states=[['a', 'b'], ['low', 'high']]`` 212 | bins : DataFrame 213 | Multidimensional bins. 214 | palette : list of int of size (n, 4) 215 | Ordered list of colours corresponding to each state. 216 | 217 | Returns 218 | ------- 219 | table : DataFrame 220 | Summary table of statistics for the scenarios. 221 | styler : Styler 222 | Object to style the table with colours and formatting. 223 | """ 224 | table = bins.describe(percentiles=[0.5]).T 225 | 226 | # get the index out to use a state id/colour 227 | table = table.reset_index() 228 | table.rename(columns={"index": "colour"}, inplace=True) 229 | 230 | # Default states for 2 or 3 231 | states_ = copy.deepcopy(states) 232 | for i, state in enumerate(states_): 233 | if isinstance(state, int): 234 | states_: list 235 | if state == 2: 236 | states_[i] = ["low", "high"] 237 | elif state == 3: 238 | states_[i] = ["low", "medium", "high"] 239 | 240 | # get the list of states 241 | gen_states = [range(x) if isinstance(x, int) else x for x in states_] 242 | states_ = np.asarray(list(itertools.product(*gen_states))) 243 | for i, var_name in enumerate(var_names): 244 | table.insert(loc=i + 1, column=var_name, value=states_[:, i]) 245 | 246 | # groupby on the variable names 247 | table.set_index(list(var_names), inplace=True) 248 | 249 | proba = table["count"] / sum(table["count"]) 250 | proba = np.asarray(proba) 251 | table["probability"] = proba 252 | table["mean"] = statistic.flatten() 253 | 254 | # only select/ordering interesting columns 255 | table = table[["colour", "std", "min", "mean", "max", "probability"]] 256 | 257 | table.insert(loc=0, column="N°", value=np.arange(1, stop=len(table) + 1)[::-1]) 258 | 259 | # style the colour background with palette 260 | cmap = mpl.colors.ListedColormap(palette) 261 | styler = table.style 262 | styler.format(precision=2) 263 | styler.background_gradient(subset=["colour"], cmap=cmap) 264 | styler.format(lambda x: "", subset=["colour"]) 265 | 266 | styler.set_table_styles([{"selector": "th", "props": [("text-align", "center")]}]) 267 | return table, styler 268 | -------------------------------------------------------------------------------- /tests/test_decomposition.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import numpy as np 4 | import numpy.testing as npt 5 | import pandas as pd 6 | 7 | import simdec as sd 8 | 9 | 10 | path_data = pathlib.Path(__file__).parent / "data" 11 | 12 | 13 | def test_decomposition(): 14 | fname = path_data / "stress.csv" 15 | data = pd.read_csv(fname) 16 | output_name, *v_names = list(data.columns) 17 | inputs, output = data[v_names], data[output_name] 18 | si = np.array([0.04, 0.50, 0.11, 0.28]) 19 | res = sd.decomposition(inputs=inputs, output=output, sensitivity_indices=si) 20 | 21 | assert res.var_names == ["sigma_res", "R", "Rp0.2", "Kf"] 22 | assert res.states == [2, 2, 2, 2] 23 | assert res.statistic.shape == (2, 2, 2, 2) 24 | npt.assert_allclose(res.bins.describe().T["mean"], res.statistic.flatten()) 25 | -------------------------------------------------------------------------------- /tests/test_sensitivity_indices.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import numpy as np 4 | import numpy.testing as npt 5 | import pandas as pd 6 | import pytest 7 | from scipy.stats import qmc 8 | 9 | import simdec as sd 10 | 11 | path_data = pathlib.Path(__file__).parent / "data" 12 | 13 | 14 | def f_ishigami(x): 15 | f_eval = np.sin(x[0]) + 7 * np.sin(x[1]) ** 2 + 0.1 * (x[2] ** 4) * np.sin(x[0]) 16 | return f_eval 17 | 18 | 19 | @pytest.fixture(scope="session") 20 | def ishigami_ref_indices(): 21 | """Reference values for Ishigami from Saltelli2007. 22 | 23 | Chapter 4, exercise 5 pages 179-182. 24 | 25 | S1 = [0.31390519, 0.44241114, 0. ] 26 | S2 = [[0. , 0. , 0.24368366], 27 | [0. , 0. , 0. ], 28 | [0.24368366, 0. , 0. ]] 29 | St = [0.55758886, 0.44241114, 0.24368366] 30 | """ 31 | a = 7.0 32 | b = 0.1 33 | 34 | var = 0.5 + a**2 / 8 + b * np.pi**4 / 5 + b**2 * np.pi**8 / 18 35 | v1 = 0.5 + b * np.pi**4 / 5 + b**2 * np.pi**8 / 50 36 | v2 = a**2 / 8 37 | v3 = 0 38 | v12 = 0 39 | # v13: mistake in the book, see other derivations e.g. in 10.1002/nme.4856 40 | v13 = b**2 * np.pi**8 * 8 / 225 41 | v23 = 0 42 | 43 | s_first = np.array([v1, v2, v3]) / var 44 | s_second = np.array([[0.0, 0.0, v13], [v12, 0.0, v23], [v13, v23, 0.0]]) / var 45 | s_total = s_first + s_second.sum(axis=1) 46 | 47 | return s_first, s_second, s_total 48 | 49 | 50 | def test_sensitivity_indices(ishigami_ref_indices): 51 | rng = np.random.default_rng(1655943881803900660874135192647741156) 52 | n_dim = 3 53 | 54 | inputs = qmc.Sobol(d=n_dim, seed=rng).random(2**18) 55 | inputs = qmc.scale( 56 | sample=inputs, l_bounds=[-np.pi, -np.pi, -np.pi], u_bounds=[np.pi, np.pi, np.pi] 57 | ) 58 | output = f_ishigami(inputs.T) 59 | 60 | res = sd.sensitivity_indices(inputs=inputs, output=output) 61 | 62 | assert res.si.shape == (3,) 63 | assert res.first_order.shape == (3,) 64 | assert res.second_order.shape == (3, 3) 65 | 66 | foe_ref = ishigami_ref_indices[0] 67 | soe_ref = ishigami_ref_indices[1] 68 | si_ref = ishigami_ref_indices[0] + np.sum(soe_ref / 2, axis=0) 69 | 70 | npt.assert_allclose(res.first_order, foe_ref, atol=1e-3) 71 | npt.assert_allclose(res.second_order, soe_ref, atol=1e-2) 72 | npt.assert_allclose(res.si, si_ref, atol=1e-2) 73 | 74 | 75 | @pytest.mark.parametrize( 76 | "fname, foe_ref, si_ref", 77 | [ 78 | (path_data / "stress.csv", [0.04, 0.50, 0.11, 0.28], [0.04, 0.51, 0.10, 0.35]), 79 | ( 80 | path_data / "crying.csv", 81 | [0.25, 0.22, 0.0, 0.0, 0.01, 0.38], 82 | [0.28, 0.25, 0.01, 0.01, 0.01, 0.44], 83 | ), 84 | ], 85 | ) 86 | def test_sensitivity_indices_dataset(fname, foe_ref, si_ref): 87 | data = pd.read_csv(fname) 88 | output_name, *v_names = list(data.columns) 89 | inputs, output = data[v_names], data[output_name] 90 | 91 | res = sd.sensitivity_indices(inputs=inputs, output=output) 92 | 93 | npt.assert_allclose(res.first_order, foe_ref, atol=5e-3) 94 | npt.assert_allclose(res.si, si_ref, atol=5e-2) 95 | --------------------------------------------------------------------------------