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.
@@ -38,3 +38,8 @@ app.html
38
38
 
39
39
  # Virtual environment
40
40
  venv/
41
+ .venv/
42
+ .wasm-build-venv/
43
+
44
+ # Local Netlify folder
45
+ .netlify
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: simdec
3
- Version: 1.5.1
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.1"
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(5, n_var_dec) # use at most 5 variables
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, output: 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
- 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
- )
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
- xi = inputs[:, i]
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
- xj = inputs[:, j]
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
- mask_j = ~np.isnan(bin_avg_j_soe)
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
- __all__ = ["visualization", "tableau", "palette"]
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