simdec 1.5.1__tar.gz → 1.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simdec-1.5.1 → simdec-1.5.2}/.gitignore +5 -0
- {simdec-1.5.1 → simdec-1.5.2}/PKG-INFO +6 -2
- {simdec-1.5.1 → simdec-1.5.2}/README.md +1 -0
- simdec-1.5.2/netlify.toml +24 -0
- simdec-1.5.2/package.json +10 -0
- {simdec-1.5.1 → simdec-1.5.2}/pyproject.toml +6 -2
- simdec-1.5.2/scripts/build_wasm.sh +140 -0
- {simdec-1.5.1 → simdec-1.5.2}/src/simdec/__init__.py +3 -0
- {simdec-1.5.1 → simdec-1.5.2}/src/simdec/decomposition.py +1 -1
- simdec-1.5.2/src/simdec/heterogeneity_indices.py +642 -0
- {simdec-1.5.1 → simdec-1.5.2}/src/simdec/sensitivity_indices.py +92 -52
- {simdec-1.5.1 → simdec-1.5.2}/src/simdec/visualization.py +183 -2
- {simdec-1.5.1 → simdec-1.5.2}/CODE_OF_CONDUCT.md +0 -0
- {simdec-1.5.1 → simdec-1.5.2}/CONTRIBUTING.md +0 -0
- {simdec-1.5.1 → simdec-1.5.2}/LICENSE.txt +0 -0
- {simdec-1.5.1 → simdec-1.5.2}/joss/paper.bib +0 -0
- {simdec-1.5.1 → simdec-1.5.2}/joss/paper.md +0 -0
- {simdec-1.5.1 → simdec-1.5.2}/joss/simdec_presentation.png +0 -0
- {simdec-1.5.1 → simdec-1.5.2}/src/simdec/auth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: simdec
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.2
|
|
4
4
|
Summary: Sensitivity analysis using simulation decomposition
|
|
5
5
|
Project-URL: homepage, https://www.simdec.fi/
|
|
6
6
|
Project-URL: documentation, https://simdec.readthedocs.io
|
|
@@ -30,6 +30,7 @@ Requires-Dist: panel>=1.4.5; extra == 'dashboard'
|
|
|
30
30
|
Provides-Extra: dev
|
|
31
31
|
Requires-Dist: accessible-pygments; extra == 'dev'
|
|
32
32
|
Requires-Dist: cryptography; extra == 'dev'
|
|
33
|
+
Requires-Dist: ipython>=9.1; extra == 'dev'
|
|
33
34
|
Requires-Dist: myst-nb; extra == 'dev'
|
|
34
35
|
Requires-Dist: numpydoc; extra == 'dev'
|
|
35
36
|
Requires-Dist: panel>=1.4.5; extra == 'dev'
|
|
@@ -39,6 +40,8 @@ Requires-Dist: pytest; extra == 'dev'
|
|
|
39
40
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
40
41
|
Requires-Dist: sphinx; extra == 'dev'
|
|
41
42
|
Requires-Dist: watchfiles; extra == 'dev'
|
|
43
|
+
Provides-Extra: display
|
|
44
|
+
Requires-Dist: ipython>=9.1; extra == 'display'
|
|
42
45
|
Provides-Extra: doc
|
|
43
46
|
Requires-Dist: accessible-pygments; extra == 'doc'
|
|
44
47
|
Requires-Dist: myst-nb; extra == 'doc'
|
|
@@ -94,5 +97,6 @@ Foundation, and Finnish Foundation for Economic Education.
|
|
|
94
97
|
If you use SimDec in your research we would appreciate a citation to the
|
|
95
98
|
following publications:
|
|
96
99
|
|
|
100
|
+
- Kozlova, M., Ahola, A., Roy, P., & Yeomans, J. S. (2025). Simple Binning Algorithm and SimDec Visualization for Comprehensive Sensitivity Analysis of Complex Computational Models. _Journal of Environmental Informatics Letters_, 13(1), 38-56. [https://arxiv.org/pdf/2310.13446](https://arxiv.org/pdf/2310.13446)
|
|
97
101
|
- 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)
|
|
98
102
|
- 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://github.com/Simulation-Decomposition/SimDec-book/blob/74ce72c1d3dda650eba1c59e3b215a4bb35c6be0/chapters/02_SimDec_algorithm_and_instructions/Ch2.pdf).
|
|
@@ -42,5 +42,6 @@ Foundation, and Finnish Foundation for Economic Education.
|
|
|
42
42
|
If you use SimDec in your research we would appreciate a citation to the
|
|
43
43
|
following publications:
|
|
44
44
|
|
|
45
|
+
- Kozlova, M., Ahola, A., Roy, P., & Yeomans, J. S. (2025). Simple Binning Algorithm and SimDec Visualization for Comprehensive Sensitivity Analysis of Complex Computational Models. _Journal of Environmental Informatics Letters_, 13(1), 38-56. [https://arxiv.org/pdf/2310.13446](https://arxiv.org/pdf/2310.13446)
|
|
45
46
|
- 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
47
|
- 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://github.com/Simulation-Decomposition/SimDec-book/blob/74ce72c1d3dda650eba1c59e3b215a4bb35c6be0/chapters/02_SimDec_algorithm_and_instructions/Ch2.pdf).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build]
|
|
2
|
+
command = "npm run build"
|
|
3
|
+
publish = "dist/pyodide"
|
|
4
|
+
|
|
5
|
+
[dev]
|
|
6
|
+
ignore = ["dist", ".wasm-build-venv", "panel/data"]
|
|
7
|
+
port = 8888
|
|
8
|
+
|
|
9
|
+
[build.environment]
|
|
10
|
+
PYTHON_VERSION = "3.11"
|
|
11
|
+
NODE_VERSION = "20"
|
|
12
|
+
CACHE_VERSION = "2"
|
|
13
|
+
|
|
14
|
+
[[headers]]
|
|
15
|
+
for = "/*"
|
|
16
|
+
[headers.values]
|
|
17
|
+
Cross-Origin-Embedder-Policy = "require-corp"
|
|
18
|
+
Cross-Origin-Opener-Policy = "same-origin"
|
|
19
|
+
|
|
20
|
+
[[headers]]
|
|
21
|
+
for = "/*.whl"
|
|
22
|
+
[headers.values]
|
|
23
|
+
Content-Type = "application/zip"
|
|
24
|
+
Access-Control-Allow-Origin = "*"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "simdec-wasm-site",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Static Panel WASM build wrapper",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "bash ./scripts/build_wasm.sh",
|
|
8
|
+
"serve": "mkdir -p dist/pyodide && python3 -m http.server 8000 --directory dist/pyodide"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "simdec"
|
|
7
|
-
version = "1.5.
|
|
7
|
+
version = "1.5.2"
|
|
8
8
|
description = "Sensitivity analysis using simulation decomposition"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -41,6 +41,10 @@ dashboard = [
|
|
|
41
41
|
"cryptography",
|
|
42
42
|
]
|
|
43
43
|
|
|
44
|
+
display = [
|
|
45
|
+
"ipython>=9.1"
|
|
46
|
+
]
|
|
47
|
+
|
|
44
48
|
test = [
|
|
45
49
|
"pytest",
|
|
46
50
|
"pytest-cov",
|
|
@@ -55,7 +59,7 @@ doc = [
|
|
|
55
59
|
]
|
|
56
60
|
|
|
57
61
|
dev = [
|
|
58
|
-
"simdec[doc,test,dashboard]",
|
|
62
|
+
"simdec[doc,test,dashboard, display]",
|
|
59
63
|
"watchfiles",
|
|
60
64
|
"pre-commit",
|
|
61
65
|
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
# Logging
|
|
4
|
+
set -x
|
|
5
|
+
|
|
6
|
+
# Setup paths and environment
|
|
7
|
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
8
|
+
OUT_DIR="${ROOT_DIR}/dist/pyodide"
|
|
9
|
+
|
|
10
|
+
# Wipe old build before starting
|
|
11
|
+
if [ -d "${OUT_DIR}" ]; then
|
|
12
|
+
echo "Cleaning old build..."
|
|
13
|
+
rm -rf "${OUT_DIR}"
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
cd "${ROOT_DIR}"
|
|
17
|
+
|
|
18
|
+
# Determine which Python interpreter to use
|
|
19
|
+
HOST_PYTHON="${PYTHON_BIN:-}"
|
|
20
|
+
if [[ -z "${HOST_PYTHON}" ]]; then
|
|
21
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
22
|
+
HOST_PYTHON="python3"
|
|
23
|
+
elif command -v python >/dev/null 2>&1; then
|
|
24
|
+
HOST_PYTHON="python"
|
|
25
|
+
else
|
|
26
|
+
echo "Error: No python interpreter found. Install python3 and retry."
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# Create build virtual environment
|
|
32
|
+
VENV_DIR="${ROOT_DIR}/.wasm-build-venv"
|
|
33
|
+
if [[ ! -d "${VENV_DIR}" ]]; then
|
|
34
|
+
echo "Creating build virtual environment at ${VENV_DIR}..."
|
|
35
|
+
"${HOST_PYTHON}" -m venv "${VENV_DIR}"
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
PYTHON_BIN="${VENV_DIR}/bin/python"
|
|
39
|
+
|
|
40
|
+
# Install build dependencies and build the wheel
|
|
41
|
+
echo "Installing build tools and generating local wheel..."
|
|
42
|
+
"${PYTHON_BIN}" -m pip install --upgrade pip build \
|
|
43
|
+
"panel==1.8.10" \
|
|
44
|
+
"bokeh==3.8.0" \
|
|
45
|
+
"matplotlib==3.10.8" \
|
|
46
|
+
"seaborn==0.13.2" \
|
|
47
|
+
"scipy==1.17.1" \
|
|
48
|
+
"SALib==1.5.2"
|
|
49
|
+
# Clean old builds to avoid picking up the wrong wheel
|
|
50
|
+
rm -rf dist/*.whl
|
|
51
|
+
"${PYTHON_BIN}" -m build --wheel .
|
|
52
|
+
|
|
53
|
+
# Identify the generated wheel file
|
|
54
|
+
SIMDEC_WHEEL_PATH=$(ls dist/*.whl | head -n 1 || echo "")
|
|
55
|
+
|
|
56
|
+
if [[ -z "${SIMDEC_WHEEL_PATH}" ]]; then
|
|
57
|
+
echo "Error: No wheel file found in dist/. Build failed."
|
|
58
|
+
exit 1
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
WHEEL_FILENAME=$(basename "${SIMDEC_WHEEL_PATH}")
|
|
62
|
+
|
|
63
|
+
# Prepare output directory - USE ABSOLUTE PATH
|
|
64
|
+
OUT_DIR="${ROOT_DIR}/dist/pyodide"
|
|
65
|
+
mkdir -p "${OUT_DIR}/_static"
|
|
66
|
+
mkdir -p "${OUT_DIR}/data"
|
|
67
|
+
|
|
68
|
+
# Copy the wheel into the output directory so it's accessible via HTTP
|
|
69
|
+
cp "${SIMDEC_WHEEL_PATH}" "${OUT_DIR}/"
|
|
70
|
+
|
|
71
|
+
# Copy the assets to where the Python scripts expect them
|
|
72
|
+
mkdir -p panel/_static
|
|
73
|
+
if [ -d "docs/_static" ]; then
|
|
74
|
+
# Use || true to prevent failure if it's empty
|
|
75
|
+
cp -r docs/_static/* panel/_static/ || true
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
echo "Converting Panel apps to Pyodide worker output..."
|
|
79
|
+
export PYTHONPATH="${ROOT_DIR}/src:${PYTHONPATH:-}"
|
|
80
|
+
|
|
81
|
+
# Change directory into 'panel' so relative paths resolve correctly
|
|
82
|
+
cd "${ROOT_DIR}/panel"
|
|
83
|
+
|
|
84
|
+
# Bring the wheel into the current folder so we can pass just the filename
|
|
85
|
+
cp "${ROOT_DIR}/${SIMDEC_WHEEL_PATH}" .
|
|
86
|
+
|
|
87
|
+
# Safe file checking that won't trigger set -e
|
|
88
|
+
echo "Checking necessary files locally before conversion..."
|
|
89
|
+
if [[ ! -f "simdec_app.py" ]]; then echo "Warning: simdec_app.py not found in panel directory!"; fi
|
|
90
|
+
if [[ ! -f "sampling.py" ]]; then echo "Warning: sampling.py not found in panel directory!"; fi
|
|
91
|
+
|
|
92
|
+
# Ensure data/stress.csv exists relative to the current 'panel' dir
|
|
93
|
+
if [[ ! -f "data/stress.csv" ]]; then
|
|
94
|
+
echo "Warning: panel/data/stress.csv missing."
|
|
95
|
+
if [[ -f "../data/stress.csv" ]]; then
|
|
96
|
+
echo "Found stress.csv in root. Copying to panel/data..."
|
|
97
|
+
mkdir -p data
|
|
98
|
+
cp "../data/stress.csv" data/
|
|
99
|
+
else
|
|
100
|
+
echo "Error: stress.csv not found anywhere! Conversion may fail."
|
|
101
|
+
fi
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
# Copy stress.csv to output directory
|
|
105
|
+
cp "data/stress.csv" "${OUT_DIR}/data/"
|
|
106
|
+
|
|
107
|
+
# Run conversion
|
|
108
|
+
"${PYTHON_BIN}" -m panel convert \
|
|
109
|
+
simdec_app.py \
|
|
110
|
+
sampling.py \
|
|
111
|
+
--to pyodide-worker \
|
|
112
|
+
--out "${OUT_DIR}" \
|
|
113
|
+
--requirements "${WHEEL_FILENAME}" numpy pandas matplotlib seaborn scipy SALib \
|
|
114
|
+
--resources data/stress.csv
|
|
115
|
+
|
|
116
|
+
cp "./${WHEEL_FILENAME}" "${OUT_DIR}/"
|
|
117
|
+
|
|
118
|
+
# Clean up the copied wheel
|
|
119
|
+
rm "${WHEEL_FILENAME}"
|
|
120
|
+
|
|
121
|
+
# Step back out to the root directory for the rest of the script
|
|
122
|
+
cd "${ROOT_DIR}"
|
|
123
|
+
|
|
124
|
+
# Copy custom index page and static assets
|
|
125
|
+
echo "Copying custom index page and static assets..."
|
|
126
|
+
|
|
127
|
+
# Copy images/thumbnails from docs/_static if they exist
|
|
128
|
+
if [ -d "docs/_static" ]; then
|
|
129
|
+
cp -r docs/_static/* "${OUT_DIR}/_static/" || true
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
# Overwrite default index.html with your custom homepage
|
|
133
|
+
if [ -f "panel/index.html" ]; then
|
|
134
|
+
cp panel/index.html "${OUT_DIR}/index.html"
|
|
135
|
+
else
|
|
136
|
+
echo "Warning: panel/index.html not found. Using default Panel index."
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
echo "---"
|
|
140
|
+
echo "WASM site successfully generated at ${OUT_DIR}"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""SimDec main namespace."""
|
|
2
2
|
from simdec.decomposition import *
|
|
3
|
+
from simdec.heterogeneity_indices import *
|
|
3
4
|
from simdec.sensitivity_indices import *
|
|
4
5
|
from simdec.visualization import *
|
|
5
6
|
|
|
@@ -8,6 +9,8 @@ __all__ = [
|
|
|
8
9
|
"states_expansion",
|
|
9
10
|
"decomposition",
|
|
10
11
|
"visualization",
|
|
12
|
+
"two_output_visualization",
|
|
11
13
|
"tableau",
|
|
12
14
|
"palette",
|
|
15
|
+
"heterogeneity_indices",
|
|
13
16
|
]
|
|
@@ -138,7 +138,7 @@ def decomposition(
|
|
|
138
138
|
n_var_dec = sensitivity_indices.size
|
|
139
139
|
|
|
140
140
|
n_var_dec = max(1, n_var_dec) # keep at least one variable
|
|
141
|
-
n_var_dec = min(
|
|
141
|
+
n_var_dec = min(4, n_var_dec) # use at most 4 variables
|
|
142
142
|
else:
|
|
143
143
|
n_var_dec = inputs.shape[1]
|
|
144
144
|
|
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import math
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
import simdec
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"HeterogeneityDetail",
|
|
13
|
+
"HeterogeneityResult",
|
|
14
|
+
"heterogeneity_indices",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
_DEFAULT_N_REGIONS = 5
|
|
18
|
+
# Computational safeguard, not a guarantee of statistical stability.
|
|
19
|
+
_MIN_REGION_SIZE = 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class HeterogeneityDetail:
|
|
24
|
+
"""Detailed results for one partitioning variable.
|
|
25
|
+
|
|
26
|
+
Attributes
|
|
27
|
+
----------
|
|
28
|
+
raw_profiles : pandas.DataFrame
|
|
29
|
+
Raw regional combined sensitivity profiles. Rows are regions and
|
|
30
|
+
columns are model inputs.
|
|
31
|
+
normalized_profiles : pandas.DataFrame
|
|
32
|
+
Regional profiles normalized to sum to one within each region. These
|
|
33
|
+
profiles are used to calculate H.
|
|
34
|
+
regional_sums : pandas.Series
|
|
35
|
+
Sum of the raw combined sensitivity indices in each region.
|
|
36
|
+
region_counts : pandas.Series
|
|
37
|
+
Number of observations in each region.
|
|
38
|
+
individual_contributions : pandas.Series
|
|
39
|
+
Input-level contributions C_{i,Z}. These sum to H_Z. For a categorical
|
|
40
|
+
input partition, the partitioning input itself is omitted because it is
|
|
41
|
+
constant within each of its own categories.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
raw_profiles: pd.DataFrame
|
|
45
|
+
normalized_profiles: pd.DataFrame
|
|
46
|
+
regional_sums: pd.Series
|
|
47
|
+
region_counts: pd.Series
|
|
48
|
+
individual_contributions: pd.Series
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
n_regions, n_inputs = self.raw_profiles.shape
|
|
52
|
+
return (
|
|
53
|
+
"HeterogeneityDetail("
|
|
54
|
+
f"regions={n_regions}, inputs={n_inputs}; "
|
|
55
|
+
"raw_profiles, normalized_profiles, regional_sums, "
|
|
56
|
+
"region_counts, individual_contributions)"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class HeterogeneityResult:
|
|
62
|
+
"""Results returned by :func:`heterogeneity_indices`.
|
|
63
|
+
|
|
64
|
+
The main heterogeneity estimates are available through ``indices``.
|
|
65
|
+
Complete regional diagnostics are stored in ``details``.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
indices: pd.Series
|
|
69
|
+
details: dict
|
|
70
|
+
_input_order: tuple
|
|
71
|
+
|
|
72
|
+
def __getitem__(self, partition):
|
|
73
|
+
"""Return H for one partition, e.g. ``H[\"X1\"]``."""
|
|
74
|
+
return self.indices.loc[partition]
|
|
75
|
+
|
|
76
|
+
def __repr__(self) -> str:
|
|
77
|
+
if self.indices.empty:
|
|
78
|
+
return "HeterogeneityResult\n\nNo heterogeneity indices were computed."
|
|
79
|
+
table = self.indices.rename("H").to_frame()
|
|
80
|
+
return "HeterogeneityResult\n\n" + table.to_string()
|
|
81
|
+
|
|
82
|
+
def plot(self, partition=None, ax=None):
|
|
83
|
+
"""Plot raw regional sensitivity profiles.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
partition : str or sequence of str, optional
|
|
88
|
+
Partition(s) to plot. If omitted, all available partitions are
|
|
89
|
+
plotted. Examples: ``H.plot(\"X1\")`` or
|
|
90
|
+
``H.plot([\"Y\", \"X1\", \"X2\"])``.
|
|
91
|
+
ax : matplotlib.axes.Axes, optional
|
|
92
|
+
Existing axes. Only valid when plotting one partition.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
matplotlib.axes.Axes or numpy.ndarray
|
|
97
|
+
Axes containing the plot(s).
|
|
98
|
+
"""
|
|
99
|
+
return _plot_heterogeneity(self, partition=partition, ax=ax)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _as_dataframe(inputs) -> pd.DataFrame:
|
|
103
|
+
X = pd.DataFrame(inputs).reset_index(drop=True).copy()
|
|
104
|
+
if X.shape[1] == 0:
|
|
105
|
+
raise ValueError("'inputs' must contain at least one input variable.")
|
|
106
|
+
if X.columns.has_duplicates:
|
|
107
|
+
raise ValueError("'inputs' must have unique column names.")
|
|
108
|
+
return X
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _as_series(values, name=None) -> pd.Series:
|
|
112
|
+
if isinstance(values, pd.Series):
|
|
113
|
+
s = values.reset_index(drop=True).copy()
|
|
114
|
+
else:
|
|
115
|
+
s = pd.Series(values).reset_index(drop=True)
|
|
116
|
+
if name is not None and s.name is None:
|
|
117
|
+
s.name = name
|
|
118
|
+
return s
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_categorical(series: pd.Series) -> bool:
|
|
122
|
+
"""Identify natural categorical partitions.
|
|
123
|
+
|
|
124
|
+
Object, string, boolean, pandas categorical, and binary variables are
|
|
125
|
+
treated as categorical. Numeric variables with more than two levels are
|
|
126
|
+
treated as continuous unless explicitly stored with pandas ``category``
|
|
127
|
+
dtype. This avoids silently interpreting low-cardinality integer-valued
|
|
128
|
+
continuous variables as categories.
|
|
129
|
+
"""
|
|
130
|
+
non_missing = series.dropna()
|
|
131
|
+
n_unique = non_missing.nunique()
|
|
132
|
+
return (
|
|
133
|
+
isinstance(series.dtype, pd.CategoricalDtype)
|
|
134
|
+
or pd.api.types.is_object_dtype(series)
|
|
135
|
+
or pd.api.types.is_string_dtype(series)
|
|
136
|
+
or pd.api.types.is_bool_dtype(series)
|
|
137
|
+
or n_unique <= 2
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _make_regions(
|
|
142
|
+
z: pd.Series,
|
|
143
|
+
n_regions: int,
|
|
144
|
+
name: str,
|
|
145
|
+
) -> tuple[pd.Series, bool]:
|
|
146
|
+
categorical = _is_categorical(z)
|
|
147
|
+
|
|
148
|
+
if categorical:
|
|
149
|
+
regions = z.astype("category").cat.remove_unused_categories()
|
|
150
|
+
else:
|
|
151
|
+
try:
|
|
152
|
+
regions = pd.qcut(z, q=n_regions, duplicates="drop")
|
|
153
|
+
except ValueError as exc:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"Failed to divide '{name}' into {n_regions} equal-frequency "
|
|
156
|
+
f"regions: {exc}"
|
|
157
|
+
) from exc
|
|
158
|
+
|
|
159
|
+
if not hasattr(regions, "cat") or len(regions.cat.categories) < 2:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"At least two regions are required to compute heterogeneity for "
|
|
162
|
+
f"'{name}'."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return regions, categorical
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _safe_output_variance(y: pd.Series) -> float | None:
|
|
169
|
+
try:
|
|
170
|
+
values = pd.to_numeric(y, errors="raise").to_numpy(dtype=float)
|
|
171
|
+
except (TypeError, ValueError):
|
|
172
|
+
return None
|
|
173
|
+
if len(values) < 2:
|
|
174
|
+
return 0.0
|
|
175
|
+
return float(np.var(values, ddof=1))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _mean_pairwise_tv(
|
|
179
|
+
normalized_profiles: pd.DataFrame,
|
|
180
|
+
) -> tuple[float, pd.Series]:
|
|
181
|
+
"""Mean pairwise total-variation distance and input contributions."""
|
|
182
|
+
profiles = normalized_profiles.to_numpy(dtype=float)
|
|
183
|
+
n_regions, n_inputs = profiles.shape
|
|
184
|
+
|
|
185
|
+
if n_regions < 2:
|
|
186
|
+
raise ValueError("At least two normalized regional profiles are required.")
|
|
187
|
+
|
|
188
|
+
contribution_sum = np.zeros(n_inputs, dtype=float)
|
|
189
|
+
n_pairs = 0
|
|
190
|
+
|
|
191
|
+
for r in range(n_regions - 1):
|
|
192
|
+
for s in range(r + 1, n_regions):
|
|
193
|
+
contribution_sum += 0.5 * np.abs(profiles[r] - profiles[s])
|
|
194
|
+
n_pairs += 1
|
|
195
|
+
|
|
196
|
+
contributions = contribution_sum / n_pairs
|
|
197
|
+
H = float(contributions.sum())
|
|
198
|
+
|
|
199
|
+
# For nonnegative compositional profiles, H is theoretically in [0, 1].
|
|
200
|
+
if np.nanmin(profiles) >= -1e-12:
|
|
201
|
+
H = float(np.clip(H, 0.0, 1.0))
|
|
202
|
+
|
|
203
|
+
return H, pd.Series(
|
|
204
|
+
contributions,
|
|
205
|
+
index=normalized_profiles.columns,
|
|
206
|
+
dtype=float,
|
|
207
|
+
name="C_i",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _compute_partition(
|
|
212
|
+
*,
|
|
213
|
+
y: pd.Series,
|
|
214
|
+
X: pd.DataFrame,
|
|
215
|
+
z: pd.Series,
|
|
216
|
+
partition_name,
|
|
217
|
+
n_regions: int,
|
|
218
|
+
remove_partition_input: bool,
|
|
219
|
+
) -> tuple[float, HeterogeneityDetail]:
|
|
220
|
+
regions, partition_is_categorical = _make_regions(
|
|
221
|
+
z,
|
|
222
|
+
n_regions=n_regions,
|
|
223
|
+
name=str(partition_name),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
X_profile = X
|
|
227
|
+
if remove_partition_input and partition_is_categorical:
|
|
228
|
+
X_profile = X.drop(columns=[partition_name])
|
|
229
|
+
if X_profile.shape[1] == 0:
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"Cannot compute H for categorical input '{partition_name}' "
|
|
232
|
+
"because no other inputs remain in the regional sensitivity "
|
|
233
|
+
"profile."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
profile_rows = []
|
|
237
|
+
counts = {}
|
|
238
|
+
|
|
239
|
+
# Every intended region must be admissible. H is not computed on a silently
|
|
240
|
+
# reduced subset of the requested partition.
|
|
241
|
+
for region in regions.cat.categories:
|
|
242
|
+
mask = regions == region
|
|
243
|
+
n_in_region = int(mask.sum())
|
|
244
|
+
counts[region] = n_in_region
|
|
245
|
+
|
|
246
|
+
if n_in_region < _MIN_REGION_SIZE:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"Region {region!r} of '{partition_name}' contains only "
|
|
249
|
+
f"{n_in_region} observations; at least {_MIN_REGION_SIZE} are "
|
|
250
|
+
"required for regional sensitivity analysis."
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
X_sub = X_profile.loc[mask]
|
|
254
|
+
y_sub = y.loc[mask]
|
|
255
|
+
|
|
256
|
+
variance = _safe_output_variance(y_sub)
|
|
257
|
+
if variance is not None and variance < 1e-12:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"The output is constant, or approximately constant, in region "
|
|
260
|
+
f"{region!r} of '{partition_name}'. Regional variance-based "
|
|
261
|
+
"sensitivity indices and H are therefore undefined for this "
|
|
262
|
+
"partition."
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
result = simdec.sensitivity_indices(inputs=X_sub, output=y_sub)
|
|
267
|
+
si_values = np.asarray(result.si, dtype=float).ravel()
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"Regional sensitivity calculation failed in region {region!r} "
|
|
271
|
+
f"of '{partition_name}': {exc}"
|
|
272
|
+
) from exc
|
|
273
|
+
|
|
274
|
+
if len(si_values) != X_profile.shape[1]:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"Sensitivity calculation for region {region!r} of "
|
|
277
|
+
f"'{partition_name}' returned {len(si_values)} indices for "
|
|
278
|
+
f"{X_profile.shape[1]} inputs."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if not np.all(np.isfinite(si_values)):
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"Non-finite sensitivity indices were returned for region "
|
|
284
|
+
f"{region!r} of '{partition_name}'."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Remove numerical dust only. Material negative estimates are preserved
|
|
288
|
+
# and flagged below rather than silently altered.
|
|
289
|
+
si_values[np.abs(si_values) < 1e-12] = 0.0
|
|
290
|
+
|
|
291
|
+
profile_rows.append(
|
|
292
|
+
pd.Series(si_values, index=X_profile.columns, name=region, dtype=float)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
raw = pd.DataFrame(profile_rows)
|
|
296
|
+
raw.index.name = "region"
|
|
297
|
+
|
|
298
|
+
regional_sums = raw.sum(axis=1)
|
|
299
|
+
regional_sums.name = "sum_S_i"
|
|
300
|
+
|
|
301
|
+
bad_sums = (~np.isfinite(regional_sums)) | (np.abs(regional_sums) < 1e-12)
|
|
302
|
+
if bad_sums.any():
|
|
303
|
+
bad_regions = list(regional_sums.index[bad_sums])
|
|
304
|
+
raise ValueError(
|
|
305
|
+
f"Regional sensitivity profiles for '{partition_name}' cannot be "
|
|
306
|
+
"normalized because their sensitivity-index sum is zero or "
|
|
307
|
+
f"non-finite in region(s) {bad_regions}."
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
normalized = raw.div(regional_sums, axis=0)
|
|
311
|
+
|
|
312
|
+
if (normalized.to_numpy(dtype=float) < -1e-12).any():
|
|
313
|
+
warnings.warn(
|
|
314
|
+
f"Negative combined sensitivity indices were found in regional "
|
|
315
|
+
f"profiles for '{partition_name}'. H is still calculated from the "
|
|
316
|
+
"normalized profiles, but the strict [0, 1] total-variation "
|
|
317
|
+
"interpretation assumes nonnegative sensitivity compositions.",
|
|
318
|
+
RuntimeWarning,
|
|
319
|
+
stacklevel=3,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
H, contributions = _mean_pairwise_tv(normalized)
|
|
323
|
+
|
|
324
|
+
region_counts = pd.Series(counts, dtype=int, name="n")
|
|
325
|
+
region_counts.index.name = "region"
|
|
326
|
+
|
|
327
|
+
detail = HeterogeneityDetail(
|
|
328
|
+
raw_profiles=raw,
|
|
329
|
+
normalized_profiles=normalized,
|
|
330
|
+
regional_sums=regional_sums,
|
|
331
|
+
region_counts=region_counts,
|
|
332
|
+
individual_contributions=contributions,
|
|
333
|
+
)
|
|
334
|
+
return H, detail
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def heterogeneity_indices(
|
|
338
|
+
output,
|
|
339
|
+
inputs,
|
|
340
|
+
n_regions: int = _DEFAULT_N_REGIONS,
|
|
341
|
+
custom_partition=None,
|
|
342
|
+
) -> HeterogeneityResult:
|
|
343
|
+
"""Calculate heterogeneity in regional sensitivity profiles.
|
|
344
|
+
|
|
345
|
+
The heterogeneity index H measures how much the relative sensitivity
|
|
346
|
+
profile of a model changes across regions. H is calculated as the mean
|
|
347
|
+
pairwise total-variation distance between normalized regional combined
|
|
348
|
+
sensitivity profiles.
|
|
349
|
+
|
|
350
|
+
With no ``custom_partition``, the function performs a full standard scan:
|
|
351
|
+
H_Y is calculated across regions of the output and H_Xi across regions of
|
|
352
|
+
every input. Continuous partitioning variables are divided into
|
|
353
|
+
``n_regions`` equal-frequency regions; categorical variables use their
|
|
354
|
+
observed categories.
|
|
355
|
+
|
|
356
|
+
If ``custom_partition`` is supplied, only H_Z for that partition is
|
|
357
|
+
calculated. A continuous custom partition is divided into ``n_regions``
|
|
358
|
+
equal-frequency regions. A categorical custom partition uses its natural
|
|
359
|
+
categories, in which case ``n_regions`` is ignored.
|
|
360
|
+
|
|
361
|
+
Parameters
|
|
362
|
+
----------
|
|
363
|
+
output : pandas.Series or array-like
|
|
364
|
+
Model output vector.
|
|
365
|
+
inputs : pandas.DataFrame or array-like
|
|
366
|
+
Model input matrix. DataFrame column names are used as input names.
|
|
367
|
+
n_regions : int, default 5
|
|
368
|
+
Number of equal-frequency regions used for continuous partitioning
|
|
369
|
+
variables.
|
|
370
|
+
custom_partition : pandas.Series or array-like, optional
|
|
371
|
+
User-defined partitioning variable Z. When provided, only H_Z is
|
|
372
|
+
calculated. The partition is used only to assign observations to
|
|
373
|
+
regions and is not added to the model inputs.
|
|
374
|
+
|
|
375
|
+
Returns
|
|
376
|
+
-------
|
|
377
|
+
HeterogeneityResult
|
|
378
|
+
``H.indices`` contains the calculated heterogeneity indices.
|
|
379
|
+
``H.details`` contains raw and normalized regional profiles, regional
|
|
380
|
+
sensitivity-index sums, observation counts, and input-level
|
|
381
|
+
contributions for every calculated partition.
|
|
382
|
+
|
|
383
|
+
Notes
|
|
384
|
+
-----
|
|
385
|
+
For a categorical input X_i, X_i is removed from its own regional
|
|
386
|
+
sensitivity profiles because it is constant within each category.
|
|
387
|
+
|
|
388
|
+
H_Y is not calculated for a categorical output. Partitioning a categorical
|
|
389
|
+
output by its categories makes the output constant within every region, so
|
|
390
|
+
regional variance-based sensitivity indices are undefined. H_Xi is still
|
|
391
|
+
calculated for all inputs for which the regional analysis is admissible.
|
|
392
|
+
|
|
393
|
+
Multi-level numeric categorical variables should use pandas ``category``
|
|
394
|
+
dtype. Binary numeric variables are recognized as categorical
|
|
395
|
+
automatically.
|
|
396
|
+
"""
|
|
397
|
+
if not isinstance(n_regions, (int, np.integer)) or n_regions < 2:
|
|
398
|
+
raise ValueError("'n_regions' must be an integer greater than or equal to 2.")
|
|
399
|
+
|
|
400
|
+
y = _as_series(output, name="Y")
|
|
401
|
+
X = _as_dataframe(inputs)
|
|
402
|
+
|
|
403
|
+
if len(y) != len(X):
|
|
404
|
+
raise ValueError(
|
|
405
|
+
"'output' and 'inputs' must contain the same number of observations "
|
|
406
|
+
f"({len(y)} != {len(X)})."
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
input_order = tuple(X.columns)
|
|
410
|
+
indices = {}
|
|
411
|
+
details = {}
|
|
412
|
+
|
|
413
|
+
# ------------------------------------------------------------
|
|
414
|
+
# Custom partition: calculate only H_Z.
|
|
415
|
+
# ------------------------------------------------------------
|
|
416
|
+
if custom_partition is not None:
|
|
417
|
+
z = _as_series(custom_partition)
|
|
418
|
+
if len(z) != len(y):
|
|
419
|
+
raise ValueError(
|
|
420
|
+
"'custom_partition' must contain the same number of observations "
|
|
421
|
+
"as output and inputs."
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
partition_name = z.name if z.name is not None else "Z"
|
|
425
|
+
custom_is_categorical = _is_categorical(z)
|
|
426
|
+
|
|
427
|
+
if custom_is_categorical and n_regions != _DEFAULT_N_REGIONS:
|
|
428
|
+
warnings.warn(
|
|
429
|
+
"'n_regions' is ignored because 'custom_partition' is "
|
|
430
|
+
"categorical. Its observed categories are used as regions.",
|
|
431
|
+
UserWarning,
|
|
432
|
+
stacklevel=2,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
H_value, detail = _compute_partition(
|
|
436
|
+
y=y,
|
|
437
|
+
X=X,
|
|
438
|
+
z=z,
|
|
439
|
+
partition_name=partition_name,
|
|
440
|
+
n_regions=n_regions,
|
|
441
|
+
remove_partition_input=False,
|
|
442
|
+
)
|
|
443
|
+
indices[partition_name] = H_value
|
|
444
|
+
details[partition_name] = detail
|
|
445
|
+
|
|
446
|
+
return HeterogeneityResult(
|
|
447
|
+
indices=pd.Series(indices, dtype=float, name="H").rename_axis("partition"),
|
|
448
|
+
details=details,
|
|
449
|
+
_input_order=input_order,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# ------------------------------------------------------------
|
|
453
|
+
# Standard scan: H_Y and H_Xi for all inputs.
|
|
454
|
+
# ------------------------------------------------------------
|
|
455
|
+
if _is_categorical(y):
|
|
456
|
+
warnings.warn(
|
|
457
|
+
"H_Y was not calculated because the output is categorical. "
|
|
458
|
+
"Partitioning a categorical output by its own categories makes "
|
|
459
|
+
"the output constant within each region, so regional variance-based "
|
|
460
|
+
"sensitivity indices are undefined. H_Xi will still be calculated "
|
|
461
|
+
"for all inputs where the output varies within the resulting "
|
|
462
|
+
"regions.",
|
|
463
|
+
UserWarning,
|
|
464
|
+
stacklevel=2,
|
|
465
|
+
)
|
|
466
|
+
else:
|
|
467
|
+
try:
|
|
468
|
+
H_y, detail_y = _compute_partition(
|
|
469
|
+
y=y,
|
|
470
|
+
X=X,
|
|
471
|
+
z=y,
|
|
472
|
+
partition_name="Y",
|
|
473
|
+
n_regions=n_regions,
|
|
474
|
+
remove_partition_input=False,
|
|
475
|
+
)
|
|
476
|
+
indices["Y"] = H_y
|
|
477
|
+
details["Y"] = detail_y
|
|
478
|
+
except ValueError as exc:
|
|
479
|
+
warnings.warn(
|
|
480
|
+
f"H_Y could not be calculated: {exc}",
|
|
481
|
+
UserWarning,
|
|
482
|
+
stacklevel=2,
|
|
483
|
+
)
|
|
484
|
+
indices["Y"] = np.nan
|
|
485
|
+
|
|
486
|
+
for input_name in X.columns:
|
|
487
|
+
try:
|
|
488
|
+
H_x, detail_x = _compute_partition(
|
|
489
|
+
y=y,
|
|
490
|
+
X=X,
|
|
491
|
+
z=X[input_name],
|
|
492
|
+
partition_name=input_name,
|
|
493
|
+
n_regions=n_regions,
|
|
494
|
+
remove_partition_input=True,
|
|
495
|
+
)
|
|
496
|
+
indices[input_name] = H_x
|
|
497
|
+
details[input_name] = detail_x
|
|
498
|
+
except ValueError as exc:
|
|
499
|
+
warnings.warn(
|
|
500
|
+
f"H for input '{input_name}' could not be calculated: {exc}",
|
|
501
|
+
UserWarning,
|
|
502
|
+
stacklevel=2,
|
|
503
|
+
)
|
|
504
|
+
indices[input_name] = np.nan
|
|
505
|
+
|
|
506
|
+
return HeterogeneityResult(
|
|
507
|
+
indices=pd.Series(indices, dtype=float, name="H").rename_axis("partition"),
|
|
508
|
+
details=details,
|
|
509
|
+
_input_order=input_order,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _format_region_label(region) -> str:
|
|
514
|
+
if isinstance(region, pd.Interval):
|
|
515
|
+
left = f"{region.left:.3g}"
|
|
516
|
+
right = f"{region.right:.3g}"
|
|
517
|
+
return f"{left}-{right}"
|
|
518
|
+
if isinstance(region, (float, np.floating)):
|
|
519
|
+
return f"{region:.3g}"
|
|
520
|
+
return str(region)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _plot_heterogeneity(
|
|
524
|
+
result: HeterogeneityResult,
|
|
525
|
+
partition=None,
|
|
526
|
+
ax=None,
|
|
527
|
+
):
|
|
528
|
+
available = list(result.details)
|
|
529
|
+
if not available:
|
|
530
|
+
raise ValueError("The result contains no regional profiles to plot.")
|
|
531
|
+
|
|
532
|
+
if partition is None:
|
|
533
|
+
names = available
|
|
534
|
+
elif isinstance(partition, str) or not hasattr(partition, "__iter__"):
|
|
535
|
+
names = [partition]
|
|
536
|
+
else:
|
|
537
|
+
names = list(partition)
|
|
538
|
+
|
|
539
|
+
missing = [name for name in names if name not in result.details]
|
|
540
|
+
if missing:
|
|
541
|
+
raise KeyError(
|
|
542
|
+
f"Partition(s) {missing} are not available. Choose from {available}."
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
if ax is not None and len(names) != 1:
|
|
546
|
+
raise ValueError("'ax' can only be supplied when plotting one partition.")
|
|
547
|
+
|
|
548
|
+
input_order = list(result._input_order)
|
|
549
|
+
cmap = plt.colormaps["terrain"]
|
|
550
|
+
colors = {
|
|
551
|
+
name: cmap(pos)
|
|
552
|
+
for name, pos in zip(
|
|
553
|
+
input_order,
|
|
554
|
+
np.linspace(0.05, 0.95, max(len(input_order), 1)),
|
|
555
|
+
)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if len(names) == 1:
|
|
559
|
+
if ax is None:
|
|
560
|
+
_, ax = plt.subplots(figsize=(7.0, 4.8))
|
|
561
|
+
axes = np.array([ax], dtype=object)
|
|
562
|
+
else:
|
|
563
|
+
ncols = min(3, len(names))
|
|
564
|
+
nrows = math.ceil(len(names) / ncols)
|
|
565
|
+
_, grid = plt.subplots(
|
|
566
|
+
nrows,
|
|
567
|
+
ncols,
|
|
568
|
+
figsize=(5.2 * ncols, 4.2 * nrows),
|
|
569
|
+
squeeze=False,
|
|
570
|
+
)
|
|
571
|
+
axes = grid.ravel()
|
|
572
|
+
|
|
573
|
+
used_inputs = []
|
|
574
|
+
|
|
575
|
+
for axis, name in zip(axes, names):
|
|
576
|
+
detail = result.details[name]
|
|
577
|
+
raw = detail.raw_profiles
|
|
578
|
+
order = [item for item in input_order if item in raw.columns]
|
|
579
|
+
order += [item for item in raw.columns if item not in order]
|
|
580
|
+
data = raw.loc[:, order]
|
|
581
|
+
used_inputs.extend(item for item in order if item not in used_inputs)
|
|
582
|
+
|
|
583
|
+
data.plot(
|
|
584
|
+
kind="bar",
|
|
585
|
+
stacked=True,
|
|
586
|
+
ax=axis,
|
|
587
|
+
color=[colors.get(item, cmap(0.5)) for item in order],
|
|
588
|
+
edgecolor="white",
|
|
589
|
+
linewidth=0.4,
|
|
590
|
+
width=0.82,
|
|
591
|
+
legend=False,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
H_value = result.indices.loc[name]
|
|
595
|
+
axis.set_title(f"{name}: H = {H_value:.3f}")
|
|
596
|
+
axis.set_ylabel(r"Combined sensitivity index, $S_i$")
|
|
597
|
+
axis.set_xlabel("Region")
|
|
598
|
+
axis.set_xticklabels(
|
|
599
|
+
[_format_region_label(region) for region in raw.index],
|
|
600
|
+
rotation=45,
|
|
601
|
+
ha="right",
|
|
602
|
+
)
|
|
603
|
+
axis.grid(axis="y", linestyle=":", alpha=0.30)
|
|
604
|
+
axis.set_axisbelow(True)
|
|
605
|
+
|
|
606
|
+
for axis in axes[len(names) :]:
|
|
607
|
+
axis.remove()
|
|
608
|
+
|
|
609
|
+
handles = [
|
|
610
|
+
plt.Rectangle(
|
|
611
|
+
(0, 0),
|
|
612
|
+
1,
|
|
613
|
+
1,
|
|
614
|
+
facecolor=colors.get(name, cmap(0.5)),
|
|
615
|
+
edgecolor="white",
|
|
616
|
+
label=str(name),
|
|
617
|
+
)
|
|
618
|
+
for name in used_inputs
|
|
619
|
+
]
|
|
620
|
+
|
|
621
|
+
if len(names) == 1:
|
|
622
|
+
if handles:
|
|
623
|
+
axes[0].legend(
|
|
624
|
+
handles=handles,
|
|
625
|
+
title="Inputs",
|
|
626
|
+
bbox_to_anchor=(1.02, 1),
|
|
627
|
+
loc="upper left",
|
|
628
|
+
)
|
|
629
|
+
axes[0].figure.tight_layout()
|
|
630
|
+
return axes[0]
|
|
631
|
+
|
|
632
|
+
if handles:
|
|
633
|
+
axes[0].figure.legend(
|
|
634
|
+
handles=handles,
|
|
635
|
+
title="Inputs",
|
|
636
|
+
loc="lower center",
|
|
637
|
+
bbox_to_anchor=(0.5, 0.01),
|
|
638
|
+
ncol=min(5, len(handles)),
|
|
639
|
+
frameon=False,
|
|
640
|
+
)
|
|
641
|
+
axes[0].figure.subplots_adjust(bottom=0.16, hspace=0.48, wspace=0.28)
|
|
642
|
+
return axes
|
|
@@ -1,12 +1,53 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
|
+
import warnings
|
|
2
3
|
|
|
3
4
|
import numpy as np
|
|
4
5
|
import pandas as pd
|
|
5
6
|
from scipy import stats
|
|
6
7
|
|
|
7
|
-
|
|
8
8
|
__all__ = ["sensitivity_indices"]
|
|
9
9
|
|
|
10
|
+
try:
|
|
11
|
+
from IPython.display import display
|
|
12
|
+
|
|
13
|
+
HAS_IPYTHON = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
HAS_IPYTHON = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _quantile_edges(x: np.ndarray, n_bins: int) -> np.ndarray:
|
|
19
|
+
"""Bin edges holding approximately the same number of points.
|
|
20
|
+
|
|
21
|
+
Bins are defined by value, so identical values always fall in the same
|
|
22
|
+
bin. Duplicated edges are dropped: a factor with many ties gets fewer
|
|
23
|
+
than ``n_bins`` bins, and a constant factor gets a single one.
|
|
24
|
+
|
|
25
|
+
Discrete variables (few unique values relative to n_bins) get one bin
|
|
26
|
+
per unique value instead of quantile-based edges, to avoid collapsing
|
|
27
|
+
minority categories into a majority bin.
|
|
28
|
+
"""
|
|
29
|
+
unique_vals = np.unique(x[~np.isnan(x)])
|
|
30
|
+
if len(unique_vals) <= n_bins:
|
|
31
|
+
# midpoints between consecutive unique values, so each value gets
|
|
32
|
+
# its own bin
|
|
33
|
+
if unique_vals.size == 1:
|
|
34
|
+
return np.array([unique_vals[0], np.nextafter(unique_vals[0], np.inf)])
|
|
35
|
+
midpoints = (unique_vals[:-1] + unique_vals[1:]) / 2
|
|
36
|
+
return np.concatenate([[unique_vals[0]], midpoints, [unique_vals[-1]]])
|
|
37
|
+
|
|
38
|
+
edges = np.unique(np.nanquantile(x, np.linspace(0, 1, n_bins + 1)))
|
|
39
|
+
if edges.size < 2:
|
|
40
|
+
edges = np.array([edges[0], np.nextafter(edges[0], np.inf)])
|
|
41
|
+
return edges
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _conditional_var(sample: np.ndarray, y: np.ndarray, edges: list) -> float:
|
|
45
|
+
"""Var(E[Y | bins]), each bin weighted by its number of points."""
|
|
46
|
+
mean, *_ = stats.binned_statistic_dd(sample, y, statistic="mean", bins=edges)
|
|
47
|
+
count, *_ = stats.binned_statistic_dd(sample, y, statistic="count", bins=edges)
|
|
48
|
+
valid = count > 0
|
|
49
|
+
return _weighted_var(mean[valid], weights=count[valid])
|
|
50
|
+
|
|
10
51
|
|
|
11
52
|
def number_of_bins(n_runs: int, n_factors: int) -> tuple[int, int]:
|
|
12
53
|
"""Optimal number of bins for first & second-order sensitivity_indices indices.
|
|
@@ -37,7 +78,9 @@ class SensitivityAnalysisResult:
|
|
|
37
78
|
|
|
38
79
|
|
|
39
80
|
def sensitivity_indices(
|
|
40
|
-
inputs: pd.DataFrame | np.ndarray,
|
|
81
|
+
inputs: pd.DataFrame | np.ndarray,
|
|
82
|
+
output: pd.DataFrame | np.ndarray,
|
|
83
|
+
print_indices: bool = False,
|
|
41
84
|
) -> SensitivityAnalysisResult:
|
|
42
85
|
"""Sensitivity indices.
|
|
43
86
|
|
|
@@ -50,6 +93,8 @@ def sensitivity_indices(
|
|
|
50
93
|
Input variables.
|
|
51
94
|
output : ndarray or DataFrame of shape (n_runs, 1)
|
|
52
95
|
Target variable.
|
|
96
|
+
print_indices : bool, default False
|
|
97
|
+
If True, displays computed indices.
|
|
53
98
|
|
|
54
99
|
Returns
|
|
55
100
|
-------
|
|
@@ -97,11 +142,18 @@ def sensitivity_indices(
|
|
|
97
142
|
"""
|
|
98
143
|
# Handle inputs conversion
|
|
99
144
|
if isinstance(inputs, pd.DataFrame):
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
145
|
+
var_names = inputs.columns.tolist()
|
|
146
|
+
cat_cols = inputs.select_dtypes(include=["category", "O", "string"]).columns
|
|
147
|
+
if not cat_cols.empty:
|
|
148
|
+
inputs = inputs.copy() # Avoid SettingWithCopyWarning
|
|
149
|
+
inputs[cat_cols] = inputs[cat_cols].apply(
|
|
150
|
+
lambda x: x.astype("category").cat.codes
|
|
151
|
+
)
|
|
104
152
|
inputs = inputs.to_numpy()
|
|
153
|
+
else:
|
|
154
|
+
inputs = np.asarray(inputs)
|
|
155
|
+
# Fallback names if it's just a numpy array
|
|
156
|
+
var_names = [f"x{i}" for i in range(inputs.shape[1])]
|
|
105
157
|
|
|
106
158
|
# Handle output conversion first, then flatten
|
|
107
159
|
if isinstance(output, (pd.DataFrame, pd.Series)):
|
|
@@ -120,60 +172,33 @@ def sensitivity_indices(
|
|
|
120
172
|
foe = np.empty(n_factors)
|
|
121
173
|
soe = np.zeros((n_factors, n_factors))
|
|
122
174
|
|
|
175
|
+
edges_foe = [
|
|
176
|
+
_quantile_edges(inputs[:, k], int(n_bins_foe)) for k in range(n_factors)
|
|
177
|
+
]
|
|
178
|
+
edges_soe = [
|
|
179
|
+
_quantile_edges(inputs[:, k], int(n_bins_soe)) for k in range(n_factors)
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
# Marginal Var(E[Y|Xk]) on the SOE binning, identical for every pair
|
|
183
|
+
var_marginal = np.array(
|
|
184
|
+
[
|
|
185
|
+
_conditional_var(inputs[:, [k]], output, [edges_soe[k]])
|
|
186
|
+
for k in range(n_factors)
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
|
|
123
190
|
for i in range(n_factors):
|
|
124
191
|
# 1. First-order effects (FOE)
|
|
125
|
-
|
|
192
|
+
foe[i] = _conditional_var(inputs[:, [i]], output, [edges_foe[i]]) / var_y
|
|
126
193
|
|
|
127
|
-
bin_avg, _, binnumber = stats.binned_statistic(
|
|
128
|
-
x=xi, values=output, bins=n_bins_foe, statistic="mean"
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
# Filter empty bins and get weights (counts)
|
|
132
|
-
mask_foe = ~np.isnan(bin_avg)
|
|
133
|
-
mean_i_foe = bin_avg[mask_foe]
|
|
134
|
-
# binnumber starts at 1; 0 is for values outside range
|
|
135
|
-
bin_counts_foe = np.unique(binnumber[binnumber > 0], return_counts=True)[1]
|
|
136
|
-
|
|
137
|
-
foe[i] = _weighted_var(mean_i_foe, weights=bin_counts_foe) / var_y
|
|
138
|
-
|
|
139
|
-
# 2. Second-order effects (SOE)
|
|
140
194
|
for j in range(n_factors):
|
|
141
195
|
if j <= i:
|
|
142
196
|
continue
|
|
143
197
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
# 2D Binned Statistic for Var(E[Y|Xi, Xj])
|
|
147
|
-
bin_avg_ij, x_edges, y_edges, binnumber_ij = stats.binned_statistic_2d(
|
|
148
|
-
x=xi, y=xj, values=output, bins=n_bins_soe, expand_binnumbers=False
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
mask_ij = ~np.isnan(bin_avg_ij)
|
|
152
|
-
mean_ij = bin_avg_ij[mask_ij]
|
|
153
|
-
counts_ij = np.unique(binnumber_ij[binnumber_ij > 0], return_counts=True)[1]
|
|
154
|
-
var_ij = _weighted_var(mean_ij, weights=counts_ij)
|
|
155
|
-
|
|
156
|
-
# Marginal Var(E[Y|Xi]) using n_bins_soe to match MATLAB logic
|
|
157
|
-
bin_avg_i_soe, _, binnumber_i_soe = stats.binned_statistic(
|
|
158
|
-
x=xi, values=output, bins=n_bins_soe, statistic="mean"
|
|
159
|
-
)
|
|
160
|
-
mask_i = ~np.isnan(bin_avg_i_soe)
|
|
161
|
-
counts_i = np.unique(
|
|
162
|
-
binnumber_i_soe[binnumber_i_soe > 0], return_counts=True
|
|
163
|
-
)[1]
|
|
164
|
-
var_i_soe = _weighted_var(bin_avg_i_soe[mask_i], weights=counts_i)
|
|
165
|
-
|
|
166
|
-
# Marginal Var(E[Y|Xj]) using n_bins_soe to match MATLAB logic
|
|
167
|
-
bin_avg_j_soe, _, binnumber_j_soe = stats.binned_statistic(
|
|
168
|
-
x=xj, values=output, bins=n_bins_soe, statistic="mean"
|
|
198
|
+
var_ij = _conditional_var(
|
|
199
|
+
inputs[:, [i, j]], output, [edges_soe[i], edges_soe[j]]
|
|
169
200
|
)
|
|
170
|
-
|
|
171
|
-
counts_j = np.unique(
|
|
172
|
-
binnumber_j_soe[binnumber_j_soe > 0], return_counts=True
|
|
173
|
-
)[1]
|
|
174
|
-
var_j_soe = _weighted_var(bin_avg_j_soe[mask_j], weights=counts_j)
|
|
175
|
-
|
|
176
|
-
soe[i, j] = (var_ij - var_i_soe - var_j_soe) / var_y
|
|
201
|
+
soe[i, j] = (var_ij - var_marginal[i] - var_marginal[j]) / var_y
|
|
177
202
|
|
|
178
203
|
# Mirror SOE and calculate Combined Effect (SI)
|
|
179
204
|
# SI is FOE + half of all interactions associated with that variable
|
|
@@ -181,4 +206,19 @@ def sensitivity_indices(
|
|
|
181
206
|
for k in range(n_factors):
|
|
182
207
|
si[k] = foe[k] + (soe[:, k].sum() / 2)
|
|
183
208
|
|
|
209
|
+
if print_indices:
|
|
210
|
+
if not HAS_IPYTHON:
|
|
211
|
+
warnings.warn(
|
|
212
|
+
"print_indices=True requires ipython to be installed. "
|
|
213
|
+
"Install it with: pip install simdec[display]. Table skipped.",
|
|
214
|
+
stacklevel=2,
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
df_foe = pd.DataFrame(foe, index=var_names, columns=["First-order effect"])
|
|
218
|
+
df_soe = pd.DataFrame(soe, index=var_names, columns=var_names)
|
|
219
|
+
df_si = pd.DataFrame(si, index=var_names, columns=["Combined effect"])
|
|
220
|
+
|
|
221
|
+
df_indices = pd.concat([df_foe, df_soe, df_si], axis=1)
|
|
222
|
+
display(df_indices)
|
|
223
|
+
|
|
184
224
|
return SensitivityAnalysisResult(si, foe, soe)
|
|
@@ -10,9 +10,18 @@ import numpy as np
|
|
|
10
10
|
import seaborn as sns
|
|
11
11
|
import pandas as pd
|
|
12
12
|
from pandas.io.formats.style import Styler
|
|
13
|
+
import warnings
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
from simdec.decomposition import DecompositionResult
|
|
15
16
|
|
|
17
|
+
__all__ = ["visualization", "two_output_visualization", "tableau", "palette"]
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from IPython.display import display
|
|
21
|
+
|
|
22
|
+
HAS_IPYTHON = True
|
|
23
|
+
except ImportError:
|
|
24
|
+
HAS_IPYTHON = False
|
|
16
25
|
|
|
17
26
|
SEQUENTIAL_PALETTES = [
|
|
18
27
|
"#DC267F",
|
|
@@ -127,7 +136,8 @@ def palette(
|
|
|
127
136
|
n_shades = int(np.prod(states[1:]))
|
|
128
137
|
for i in range(n_cmaps):
|
|
129
138
|
cmap = cmaps[i].resampled(n_shades)
|
|
130
|
-
colors.append(cmap(np.linspace(0, 1, n_shades)))
|
|
139
|
+
# colors.append(cmap(np.linspace(0, 1, n_shades)))
|
|
140
|
+
colors.append(cmap(np.linspace(1, 0, n_shades)))
|
|
131
141
|
|
|
132
142
|
return np.concatenate(colors).tolist()
|
|
133
143
|
|
|
@@ -139,6 +149,8 @@ def visualization(
|
|
|
139
149
|
n_bins: str | int = "auto",
|
|
140
150
|
kind: Literal["histogram", "boxplot"] = "histogram",
|
|
141
151
|
ax=None,
|
|
152
|
+
print_legend: bool = False,
|
|
153
|
+
decomposition: DecompositionResult | None = None,
|
|
142
154
|
) -> plt.Axes:
|
|
143
155
|
"""Histogram plot of scenarios.
|
|
144
156
|
|
|
@@ -154,6 +166,10 @@ def visualization(
|
|
|
154
166
|
Histogram or Box Plot.
|
|
155
167
|
ax : Axes, optional
|
|
156
168
|
Matplotlib axis.
|
|
169
|
+
print_legend: Boolean, optional
|
|
170
|
+
Prints plot legend.
|
|
171
|
+
decomposition: DecompositionResult, optional
|
|
172
|
+
Required for print_legend.
|
|
157
173
|
|
|
158
174
|
Returns
|
|
159
175
|
-------
|
|
@@ -186,9 +202,174 @@ def visualization(
|
|
|
186
202
|
)
|
|
187
203
|
else:
|
|
188
204
|
raise ValueError("'kind' can only be 'histogram' or 'boxplot'")
|
|
205
|
+
|
|
206
|
+
if print_legend:
|
|
207
|
+
if not HAS_IPYTHON or decomposition is None:
|
|
208
|
+
missing_requirements = []
|
|
209
|
+
if not HAS_IPYTHON:
|
|
210
|
+
missing_requirements.append(
|
|
211
|
+
"ipython needs to be installed. "
|
|
212
|
+
"Install it with: pip install simdec[display]"
|
|
213
|
+
)
|
|
214
|
+
if decomposition is None:
|
|
215
|
+
missing_requirements.append("the decomposition parameter")
|
|
216
|
+
warnings.warn(
|
|
217
|
+
f"print_legend=True requires {' and '.join(missing_requirements)}. Table skipped.",
|
|
218
|
+
stacklevel=2,
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
_, styler = tableau(
|
|
222
|
+
var_names=decomposition.var_names,
|
|
223
|
+
statistic=decomposition.statistic,
|
|
224
|
+
states=decomposition.states,
|
|
225
|
+
bins=decomposition.bins,
|
|
226
|
+
palette=palette,
|
|
227
|
+
)
|
|
228
|
+
display(styler)
|
|
229
|
+
|
|
189
230
|
return ax
|
|
190
231
|
|
|
191
232
|
|
|
233
|
+
def two_output_visualization(
|
|
234
|
+
*,
|
|
235
|
+
bins: pd.DataFrame,
|
|
236
|
+
bins2: pd.DataFrame,
|
|
237
|
+
palette: list[list[float]],
|
|
238
|
+
n_bins: str | int = "auto",
|
|
239
|
+
output_name: str = "Output 1",
|
|
240
|
+
output_name2: str = "Output 2",
|
|
241
|
+
xlim: tuple[float, float] | None = None,
|
|
242
|
+
ylim: tuple[float, float] | None = None,
|
|
243
|
+
r_scatter: float = 1.0,
|
|
244
|
+
print_legend: bool = False,
|
|
245
|
+
decomposition: DecompositionResult | None = None,
|
|
246
|
+
) -> tuple[plt.Figure, np.ndarray]:
|
|
247
|
+
"""Two-output visualization.
|
|
248
|
+
|
|
249
|
+
Produces a 2x2 figure
|
|
250
|
+
* top-left : stacked histogram for *output 1* (axes hidden)
|
|
251
|
+
* bottom-left : scatter of output 1 vs output 2, coloured by scenario
|
|
252
|
+
* bottom-right: rotated stacked histogram for *output 2* (axes hidden)
|
|
253
|
+
* top-right : empty
|
|
254
|
+
|
|
255
|
+
Parameters
|
|
256
|
+
----------
|
|
257
|
+
bins : DataFrame
|
|
258
|
+
Multidimensional bins for the primary output.
|
|
259
|
+
bins2 : DataFrame
|
|
260
|
+
Multidimensional bins for the secondary output.
|
|
261
|
+
palette : list of int of size (n, 4)
|
|
262
|
+
List of colours corresponding to scenarios.
|
|
263
|
+
n_bins : str or int
|
|
264
|
+
Number of bins for the histograms.
|
|
265
|
+
output_name : str, default "Output 1"
|
|
266
|
+
Axis label for the primary output.
|
|
267
|
+
output_name2 : str, default "Output 2"
|
|
268
|
+
Axis label for the secondary output.
|
|
269
|
+
xlim : tuple of float, optional
|
|
270
|
+
Limits for the primary output axis (scatter x / top histogram).
|
|
271
|
+
ylim : tuple of float, optional
|
|
272
|
+
Limits for the secondary output axis (scatter y / right histogram).
|
|
273
|
+
r_scatter : float, default 1.0
|
|
274
|
+
Fraction of data points shown in the scatter plot.
|
|
275
|
+
print_legend: Boolean, optional
|
|
276
|
+
Prints plot legend.
|
|
277
|
+
decomposition: DecompositionResult, optional
|
|
278
|
+
Required for print_legend.
|
|
279
|
+
|
|
280
|
+
Returns
|
|
281
|
+
-------
|
|
282
|
+
fig : Figure
|
|
283
|
+
axs : ndarray of shape (2, 2)
|
|
284
|
+
|
|
285
|
+
"""
|
|
286
|
+
fig, axs = plt.subplots(2, 2, sharex="col", sharey="row", figsize=(8, 8))
|
|
287
|
+
|
|
288
|
+
axs[0, 1].axis("off")
|
|
289
|
+
|
|
290
|
+
visualization(bins=bins.copy(), palette=palette, n_bins=n_bins, ax=axs[0, 0])
|
|
291
|
+
if xlim is not None:
|
|
292
|
+
axs[0, 0].set_xlim(xlim)
|
|
293
|
+
axs[0, 0].set_box_aspect(1)
|
|
294
|
+
axs[0, 0].axis("off")
|
|
295
|
+
|
|
296
|
+
# Match the ordering visualization() uses
|
|
297
|
+
bins_plot = bins.copy()
|
|
298
|
+
bins_plot.columns = pd.RangeIndex(start=len(bins_plot.columns), stop=0, step=-1)
|
|
299
|
+
bins2_plot = bins2.copy()
|
|
300
|
+
bins2_plot.columns = pd.RangeIndex(start=len(bins2_plot.columns), stop=0, step=-1)
|
|
301
|
+
|
|
302
|
+
data = pd.concat([pd.melt(bins_plot), pd.melt(bins2_plot)["value"]], axis=1)
|
|
303
|
+
data.columns = ["c", "x", "y"]
|
|
304
|
+
if r_scatter < 1.0:
|
|
305
|
+
data = data.sample(frac=r_scatter)
|
|
306
|
+
|
|
307
|
+
hue_order = sorted(data["c"].unique())
|
|
308
|
+
sns.scatterplot(
|
|
309
|
+
data=data,
|
|
310
|
+
x="x",
|
|
311
|
+
y="y",
|
|
312
|
+
hue="c",
|
|
313
|
+
hue_order=hue_order,
|
|
314
|
+
palette=palette,
|
|
315
|
+
ax=axs[1, 0],
|
|
316
|
+
legend=False,
|
|
317
|
+
)
|
|
318
|
+
axs[1, 0].set(xlabel=output_name, ylabel=output_name2)
|
|
319
|
+
if xlim is not None:
|
|
320
|
+
axs[1, 0].set_xlim(xlim)
|
|
321
|
+
if ylim is not None:
|
|
322
|
+
axs[1, 0].set_ylim(ylim)
|
|
323
|
+
axs[1, 0].set_box_aspect(1)
|
|
324
|
+
|
|
325
|
+
sns.histplot(
|
|
326
|
+
data,
|
|
327
|
+
y="y",
|
|
328
|
+
hue="c",
|
|
329
|
+
hue_order=hue_order,
|
|
330
|
+
multiple="stack",
|
|
331
|
+
stat="probability",
|
|
332
|
+
palette=palette,
|
|
333
|
+
common_bins=True,
|
|
334
|
+
common_norm=True,
|
|
335
|
+
bins=40,
|
|
336
|
+
legend=False,
|
|
337
|
+
ax=axs[1, 1],
|
|
338
|
+
)
|
|
339
|
+
if ylim is not None:
|
|
340
|
+
axs[1, 1].set_ylim(ylim)
|
|
341
|
+
axs[1, 1].set_box_aspect(1)
|
|
342
|
+
axs[1, 1].axis("off")
|
|
343
|
+
|
|
344
|
+
fig.subplots_adjust(wspace=-0.015, hspace=0)
|
|
345
|
+
|
|
346
|
+
if print_legend:
|
|
347
|
+
if not HAS_IPYTHON or decomposition is None:
|
|
348
|
+
missing_requirements = []
|
|
349
|
+
if not HAS_IPYTHON:
|
|
350
|
+
missing_requirements.append(
|
|
351
|
+
"ipython needs to be installed. "
|
|
352
|
+
"Install it with: pip install simdec[display]"
|
|
353
|
+
)
|
|
354
|
+
if decomposition is None:
|
|
355
|
+
missing_requirements.append("the decomposition parameter")
|
|
356
|
+
warnings.warn(
|
|
357
|
+
f"print_legend=True requires {' and '.join(missing_requirements)}. Table skipped.",
|
|
358
|
+
stacklevel=2,
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
_, styler = tableau(
|
|
362
|
+
var_names=decomposition.var_names,
|
|
363
|
+
statistic=decomposition.statistic,
|
|
364
|
+
states=decomposition.states,
|
|
365
|
+
bins=decomposition.bins,
|
|
366
|
+
palette=palette[::-1],
|
|
367
|
+
)
|
|
368
|
+
display(styler)
|
|
369
|
+
|
|
370
|
+
return fig, axs
|
|
371
|
+
|
|
372
|
+
|
|
192
373
|
def tableau(
|
|
193
374
|
*,
|
|
194
375
|
var_names: list[str],
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|