Skip to content

Commit 1405643

Browse files
authored
Update docs, change FOE/SOE to magic_binning, refactor print_indices (#61)
1 parent b4031b6 commit 1405643

3 files changed

Lines changed: 78 additions & 54 deletions

File tree

docs/tutorials.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ Executable tutorials
55
:maxdepth: 1
66

77
notebooks/structural_reliability
8+
Carbon Footprint <notebooks/3_Carbon_footprint>
9+
Investment Model <notebooks/3_Investment_model>
10+
Steel Structures <notebooks/3_Steel_structures>

src/simdec/sensitivity_indices.py

Lines changed: 73 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,53 @@
11
from dataclasses import dataclass
2+
import warnings
23

34
import numpy as np
45
import pandas as pd
56
from scipy import stats
67

7-
88
__all__ = ["sensitivity_indices"]
99

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+
1051

1152
def number_of_bins(n_runs: int, n_factors: int) -> tuple[int, int]:
1253
"""Optimal number of bins for first & second-order sensitivity_indices indices.
@@ -131,60 +172,33 @@ def sensitivity_indices(
131172
foe = np.empty(n_factors)
132173
soe = np.zeros((n_factors, n_factors))
133174

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+
134190
for i in range(n_factors):
135191
# 1. First-order effects (FOE)
136-
xi = inputs[:, i]
137-
138-
bin_avg, _, binnumber = stats.binned_statistic(
139-
x=xi, values=output, bins=n_bins_foe, statistic="mean"
140-
)
141-
142-
# Filter empty bins and get weights (counts)
143-
mask_foe = ~np.isnan(bin_avg)
144-
mean_i_foe = bin_avg[mask_foe]
145-
# binnumber starts at 1; 0 is for values outside range
146-
bin_counts_foe = np.unique(binnumber[binnumber > 0], return_counts=True)[1]
192+
foe[i] = _conditional_var(inputs[:, [i]], output, [edges_foe[i]]) / var_y
147193

148-
foe[i] = _weighted_var(mean_i_foe, weights=bin_counts_foe) / var_y
149-
150-
# 2. Second-order effects (SOE)
151194
for j in range(n_factors):
152195
if j <= i:
153196
continue
154197

155-
xj = inputs[:, j]
156-
157-
# 2D Binned Statistic for Var(E[Y|Xi, Xj])
158-
bin_avg_ij, x_edges, y_edges, binnumber_ij = stats.binned_statistic_2d(
159-
x=xi, y=xj, values=output, bins=n_bins_soe, expand_binnumbers=False
198+
var_ij = _conditional_var(
199+
inputs[:, [i, j]], output, [edges_soe[i], edges_soe[j]]
160200
)
161-
162-
mask_ij = ~np.isnan(bin_avg_ij)
163-
mean_ij = bin_avg_ij[mask_ij]
164-
counts_ij = np.unique(binnumber_ij[binnumber_ij > 0], return_counts=True)[1]
165-
var_ij = _weighted_var(mean_ij, weights=counts_ij)
166-
167-
# Marginal Var(E[Y|Xi]) using n_bins_soe to match MATLAB logic
168-
bin_avg_i_soe, _, binnumber_i_soe = stats.binned_statistic(
169-
x=xi, values=output, bins=n_bins_soe, statistic="mean"
170-
)
171-
mask_i = ~np.isnan(bin_avg_i_soe)
172-
counts_i = np.unique(
173-
binnumber_i_soe[binnumber_i_soe > 0], return_counts=True
174-
)[1]
175-
var_i_soe = _weighted_var(bin_avg_i_soe[mask_i], weights=counts_i)
176-
177-
# Marginal Var(E[Y|Xj]) using n_bins_soe to match MATLAB logic
178-
bin_avg_j_soe, _, binnumber_j_soe = stats.binned_statistic(
179-
x=xj, values=output, bins=n_bins_soe, statistic="mean"
180-
)
181-
mask_j = ~np.isnan(bin_avg_j_soe)
182-
counts_j = np.unique(
183-
binnumber_j_soe[binnumber_j_soe > 0], return_counts=True
184-
)[1]
185-
var_j_soe = _weighted_var(bin_avg_j_soe[mask_j], weights=counts_j)
186-
187-
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
188202

189203
# Mirror SOE and calculate Combined Effect (SI)
190204
# SI is FOE + half of all interactions associated with that variable
@@ -193,11 +207,18 @@ def sensitivity_indices(
193207
si[k] = foe[k] + (soe[:, k].sum() / 2)
194208

195209
if print_indices:
196-
df_foe = pd.DataFrame(foe, index=var_names, columns=["First-order effect"])
197-
df_soe = pd.DataFrame(soe, index=var_names, columns=var_names)
198-
df_si = pd.DataFrame(si, index=var_names, columns=["Combined effect"])
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"])
199220

200-
df_indices = pd.concat([df_foe, df_soe, df_si], axis=1)
201-
print(f"\n{df_indices}\n")
221+
df_indices = pd.concat([df_foe, df_soe, df_si], axis=1)
222+
display(df_indices)
202223

203224
return SensitivityAnalysisResult(si, foe, soe)

tests/test_sensitivity_indices.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,10 @@ def test_sensitivity_indices(ishigami_ref_indices):
7575
@pytest.mark.parametrize(
7676
"fname, foe_ref, si_ref",
7777
[
78-
(path_data / "stress.csv", [0.04, 0.50, 0.11, 0.28], [0.04, 0.51, 0.10, 0.35]),
78+
(path_data / "stress.csv", [0.037, 0.49, 0.11, 0.28], [0.04, 0.52, 0.10, 0.35]),
7979
(
8080
path_data / "crying.csv",
81-
[0.25, 0.22, 0.0, 0.0, 0.01, 0.38],
81+
[0.24, 0.22, 0.0, 0.0, 0.01, 0.38],
8282
[0.28, 0.25, 0.01, 0.01, 0.01, 0.44],
8383
),
8484
],

0 commit comments

Comments
 (0)