11from dataclasses import dataclass
2+ import warnings
23
34import numpy as np
45import pandas as pd
56from 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
1152def 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 )
0 commit comments