SuperModelingFactory 0.2.0__py3-none-any.whl

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.
Files changed (79) hide show
  1. ExcelMaster/ExcelFormatTool.py +487 -0
  2. ExcelMaster/ExcelMaster.py +917 -0
  3. ExcelMaster/Template.py +525 -0
  4. ExcelMaster/Utility.py +233 -0
  5. ExcelMaster/__init__.py +3 -0
  6. Modeling_Tool/Core/Binning_Tool.py +1608 -0
  7. Modeling_Tool/Core/Binning_Tool.pyi +48 -0
  8. Modeling_Tool/Core/Check_DuckDB_Compatibility.py +835 -0
  9. Modeling_Tool/Core/Json_Data_Converter.py +621 -0
  10. Modeling_Tool/Core/Model_Registry_Tool.py +199 -0
  11. Modeling_Tool/Core/ODPS_Tool.py +284 -0
  12. Modeling_Tool/Core/Slope_Tool.py +356 -0
  13. Modeling_Tool/Core/Slope_Tool.pyi +31 -0
  14. Modeling_Tool/Core/XOR_Encryptor.py +207 -0
  15. Modeling_Tool/Core/XOR_Encryptor.pyi +26 -0
  16. Modeling_Tool/Core/__init__.py +99 -0
  17. Modeling_Tool/Core/kDataFrame.py +228 -0
  18. Modeling_Tool/Core/kDataFrame.pyi +43 -0
  19. Modeling_Tool/Core/sample_weight_utils.py +77 -0
  20. Modeling_Tool/Core/utils.py +2672 -0
  21. Modeling_Tool/Eval/Evaluation_Tool.py +1452 -0
  22. Modeling_Tool/Eval/Evaluation_Tool.pyi +47 -0
  23. Modeling_Tool/Eval/Model_Eval_Tool.py +2548 -0
  24. Modeling_Tool/Eval/Model_Eval_Tool.pyi +37 -0
  25. Modeling_Tool/Eval/__init__.py +54 -0
  26. Modeling_Tool/Eval/evaluate_model.py +2008 -0
  27. Modeling_Tool/Eval/evaluate_model.pyi +50 -0
  28. Modeling_Tool/Eval/weighted_eval_utils.py +326 -0
  29. Modeling_Tool/Explainability/Coalition_Structure.py +305 -0
  30. Modeling_Tool/Explainability/Model_Explainer.py +743 -0
  31. Modeling_Tool/Explainability/__init__.py +24 -0
  32. Modeling_Tool/Feature/Distribution_Tool.py +509 -0
  33. Modeling_Tool/Feature/Distribution_Tool.pyi +42 -0
  34. Modeling_Tool/Feature/Feature_Insights.py +762 -0
  35. Modeling_Tool/Feature/Feature_Insights.pyi +33 -0
  36. Modeling_Tool/Feature/PSI_Tool.py +1195 -0
  37. Modeling_Tool/Feature/PSI_Tool.pyi +29 -0
  38. Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +355 -0
  39. Modeling_Tool/Feature/__init__.py +40 -0
  40. Modeling_Tool/Model/Backward_Tool.py +778 -0
  41. Modeling_Tool/Model/Backward_Tool.pyi +45 -0
  42. Modeling_Tool/Model/GBM_Search_Tool.py +251 -0
  43. Modeling_Tool/Model/GBM_Tool.py +1610 -0
  44. Modeling_Tool/Model/GBM_Tool.pyi +90 -0
  45. Modeling_Tool/Model/LRM_Tool.py +1198 -0
  46. Modeling_Tool/Model/LRM_Tool.pyi +47 -0
  47. Modeling_Tool/Model/__init__.py +61 -0
  48. Modeling_Tool/Sample/Distribution_Adaptation.py +131 -0
  49. Modeling_Tool/Sample/Distribution_Adaptation.pyi +30 -0
  50. Modeling_Tool/Sample/Reject_Infer.py +413 -0
  51. Modeling_Tool/Sample/Reject_Infer.pyi +43 -0
  52. Modeling_Tool/Sample/Sample_Split.py +520 -0
  53. Modeling_Tool/Sample/Sample_Split.pyi +43 -0
  54. Modeling_Tool/Sample/__init__.py +31 -0
  55. Modeling_Tool/UAT/UAT_Consistency_Checker.py +1180 -0
  56. Modeling_Tool/UAT/__init__.py +19 -0
  57. Modeling_Tool/WOE/WOE_Adapter.py +204 -0
  58. Modeling_Tool/WOE/WOE_Adapter.pyi +21 -0
  59. Modeling_Tool/WOE/WOE_Master.py +491 -0
  60. Modeling_Tool/WOE/WOE_Master.pyi +40 -0
  61. Modeling_Tool/WOE/WOE_Monotone_Binner.py +3324 -0
  62. Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +71 -0
  63. Modeling_Tool/WOE/WOE_Plot_Tool.py +919 -0
  64. Modeling_Tool/WOE/WOE_Plot_Tool.pyi +46 -0
  65. Modeling_Tool/WOE/WOE_Report_Builder.py +214 -0
  66. Modeling_Tool/WOE/WOE_Report_Builder.pyi +24 -0
  67. Modeling_Tool/WOE/WOE_Tool.py +1094 -0
  68. Modeling_Tool/WOE/WOE_Tool.pyi +41 -0
  69. Modeling_Tool/WOE/__init__.py +86 -0
  70. Modeling_Tool/WOE/plot_woe_tool.py +290 -0
  71. Modeling_Tool/WOE/plot_woe_tool.pyi +25 -0
  72. Modeling_Tool/__init__.py +176 -0
  73. Report/Report_Tool.py +380 -0
  74. Report/__init__.py +3 -0
  75. supermodelingfactory-0.2.0.dist-info/METADATA +268 -0
  76. supermodelingfactory-0.2.0.dist-info/RECORD +79 -0
  77. supermodelingfactory-0.2.0.dist-info/WHEEL +5 -0
  78. supermodelingfactory-0.2.0.dist-info/licenses/LICENSE +102 -0
  79. supermodelingfactory-0.2.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,743 @@
1
+ # encoding: utf-8
2
+ """
3
+ Model explainability for the credit-modeling toolkit.
4
+
5
+ Global and local explanations for models trained with SuperModelingFactory:
6
+
7
+ * SHAP for attribution (global importance, summary / dependence plots, local contributions)
8
+ * Owen Value grouped attribution via SHAP PartitionExplainer
9
+ * PDP (partial dependence) for average marginal effects
10
+ * ICE (individual conditional expectation) for per-sample response curves
11
+ * ALE (accumulated local effects) for correlation-aware marginal effects
12
+ * LIME for local surrogate explanations and sampled global summaries
13
+
14
+ ``shap`` and ``lime`` are optional dependencies. They are imported lazily, only
15
+ when the corresponding explanation is actually computed, so ``import
16
+ Modeling_Tool`` never pulls them in. Install the full explainability extra with::
17
+
18
+ pip install supermodelingfactory[explain]
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from .Coalition_Structure import build_coalition_structure as _build_coalition_structure
26
+
27
+ __all__ = ["ModelExplainer"]
28
+
29
+ _TREE_MODEL_TYPES = frozenset({"lgb", "lightgbm", "xgb", "xgboost"})
30
+ _LINEAR_MODEL_TYPES = frozenset({"lr", "linear", "logisticregression"})
31
+
32
+
33
+ def _lazy_shap():
34
+ """Import :mod:`shap` on demand, raising a helpful error if it is missing."""
35
+ try:
36
+ import shap # noqa: WPS433 (intentional lazy import)
37
+ return shap
38
+ except ImportError as exc: # pragma: no cover - exercised via install matrix
39
+ raise ImportError(
40
+ "ModelExplainer requires the optional `shap` dependency for SHAP/Owen "
41
+ "methods, which is not installed.\n"
42
+ "Install it with: pip install supermodelingfactory[explain]"
43
+ ) from exc
44
+
45
+
46
+ def _lazy_lime():
47
+ """Import LIME on demand, raising a helpful error if it is missing."""
48
+ try:
49
+ from lime.lime_tabular import LimeTabularExplainer # noqa: WPS433
50
+ return LimeTabularExplainer
51
+ except ImportError as exc: # pragma: no cover - optional dependency path
52
+ raise ImportError(
53
+ "LIME explanations require the optional `lime` dependency, which is "
54
+ "not installed.\n"
55
+ "Install it with: pip install supermodelingfactory[explain]"
56
+ ) from exc
57
+
58
+
59
+ class ModelExplainer:
60
+ """Unified explainer for SuperModelingFactory models.
61
+
62
+ The explainer accepts either a SuperModelingFactory model wrapper
63
+ (``GradientBoostingModel`` or ``LRMaster``) or a raw fitted estimator. It
64
+ exposes SHAP attribution, Owen Value grouped attribution, plus model-agnostic
65
+ effect methods (PDP, ICE, ALE, and LIME).
66
+ """
67
+
68
+ def __init__(self, model, feature_names=None, model_type=None, background_data=None):
69
+ self.model = model
70
+ self.background_data = background_data
71
+ self.estimator = self._resolve_estimator(model)
72
+ self.model_type = (model_type or self._infer_model_type(model, self.estimator)).lower()
73
+ self.feature_names = self._resolve_feature_names(feature_names)
74
+
75
+ self._explainer = None
76
+ self._explainer_kind = None
77
+ self.shap_values_ = None
78
+ self.expected_value_ = None
79
+ self.explanation_ = None
80
+ self._last_X = None
81
+
82
+ self.coalition_structure_ = None
83
+ self._owen_explainer = None
84
+ self._owen_model_output = None
85
+ self.owen_values_ = None
86
+ self.owen_expected_value_ = None
87
+ self.owen_explanation_ = None
88
+ self._owen_last_X = None
89
+
90
+ # ------------------------------------------------------------------ #
91
+ # Resolution helpers
92
+ # ------------------------------------------------------------------ #
93
+ @staticmethod
94
+ def _resolve_estimator(model):
95
+ """Unwrap the underlying fitted estimator from an SMF wrapper."""
96
+ for wrapper_attr in ("_model", "model_instance"):
97
+ wrapper = getattr(model, wrapper_attr, None)
98
+ if wrapper is not None and getattr(wrapper, "model", None) is not None:
99
+ return wrapper.model
100
+ inner = getattr(model, "model", None)
101
+ if inner is not None and (hasattr(inner, "predict") or hasattr(inner, "coef_")):
102
+ return inner
103
+ return model
104
+
105
+ @staticmethod
106
+ def _infer_model_type(model, estimator):
107
+ """Best-effort detection of the model family."""
108
+ declared = getattr(model, "model_type", None)
109
+ if isinstance(declared, str) and declared:
110
+ return declared
111
+ cls = type(estimator).__name__.lower()
112
+ module = type(estimator).__module__.lower()
113
+ if "lightgbm" in module or cls.startswith("lgb"):
114
+ return "lgb"
115
+ if "xgboost" in module or cls.startswith("xgb"):
116
+ return "xgb"
117
+ if "logisticregression" in cls:
118
+ return "lr"
119
+ return cls
120
+
121
+ def _is_tree(self):
122
+ return self.model_type in _TREE_MODEL_TYPES
123
+
124
+ def _is_linear(self):
125
+ return self.model_type in _LINEAR_MODEL_TYPES
126
+
127
+ def _resolve_feature_names(self, feature_names):
128
+ """Infer feature ordering from explicit arg / wrapper / booster."""
129
+ if feature_names is not None:
130
+ return list(feature_names)
131
+ for attr in ("varlist", "feature_cols", "feature_names", "feature_names_"):
132
+ value = getattr(self.model, attr, None)
133
+ if value is not None and not callable(value):
134
+ try:
135
+ return list(value)
136
+ except TypeError:
137
+ pass
138
+ estimator = self.estimator
139
+ for attr in ("feature_names_in_", "feature_name_"):
140
+ value = getattr(estimator, attr, None)
141
+ if value is not None and not callable(value):
142
+ try:
143
+ return list(value)
144
+ except TypeError:
145
+ pass
146
+ booster_names = getattr(estimator, "feature_name", None)
147
+ if callable(booster_names):
148
+ try:
149
+ return list(booster_names())
150
+ except Exception: # pragma: no cover - defensive
151
+ pass
152
+ return None
153
+
154
+ # ------------------------------------------------------------------ #
155
+ # Data / prediction utilities
156
+ # ------------------------------------------------------------------ #
157
+ def _as_frame(self, X):
158
+ """Coerce input to a DataFrame aligned to ``feature_names`` when known."""
159
+ if X is None:
160
+ return None
161
+ if isinstance(X, pd.DataFrame):
162
+ cols = self.feature_names
163
+ if cols is not None and all(c in X.columns for c in cols):
164
+ return X.loc[:, cols]
165
+ return X
166
+ arr = np.asarray(X)
167
+ if arr.ndim == 1:
168
+ arr = arr.reshape(1, -1)
169
+ cols = self.feature_names
170
+ if cols is not None and len(cols) == arr.shape[1]:
171
+ return pd.DataFrame(arr, columns=cols)
172
+ return pd.DataFrame(arr)
173
+
174
+ def _predict_proba_pos(self, X):
175
+ """Positive-class probability callable for model-agnostic explanations."""
176
+ estimator = self.estimator
177
+ frame = self._as_frame(X)
178
+ if hasattr(estimator, "predict_proba"):
179
+ proba = np.asarray(estimator.predict_proba(frame))
180
+ return proba[:, -1] if proba.ndim == 2 else proba
181
+ return np.asarray(estimator.predict(frame)).ravel()
182
+
183
+ def _predict_log_odds(self, X, eps=1e-6):
184
+ """Log-odds prediction callable for reason-code style explanations."""
185
+ p = np.asarray(self._predict_proba_pos(X)).ravel()
186
+ p = np.clip(p, eps, 1.0 - eps)
187
+ return np.log(p / (1.0 - p))
188
+
189
+ def _predict_proba_2d(self, X):
190
+ """Two-column probability callable for LIME classification mode."""
191
+ pos = np.asarray(self._predict_proba_pos(X)).ravel()
192
+ return np.column_stack([1.0 - pos, pos])
193
+
194
+ def _sample_frame(self, X, sample_size=None, random_state=None):
195
+ frame = self._as_frame(X).copy()
196
+ if sample_size is not None and len(frame) > sample_size:
197
+ frame = frame.sample(n=sample_size, random_state=random_state)
198
+ return frame
199
+
200
+ def _feature_name(self, X, feature):
201
+ frame = self._as_frame(X)
202
+ if isinstance(feature, int):
203
+ return frame.columns[feature]
204
+ if feature not in frame.columns:
205
+ raise KeyError(f"Feature {feature!r} not found in X")
206
+ return feature
207
+
208
+ @staticmethod
209
+ def _numeric_grid(series: pd.Series, grid_resolution=50, percentiles=(0.05, 0.95)):
210
+ clean = pd.to_numeric(series, errors="coerce").dropna()
211
+ if clean.empty:
212
+ raise ValueError("Effect methods require a numeric feature with at least one non-missing value")
213
+ lo, hi = clean.quantile(list(percentiles)).to_numpy(dtype=float)
214
+ if np.isclose(lo, hi):
215
+ vals = np.array(sorted(clean.unique()), dtype=float)
216
+ return vals[:grid_resolution]
217
+ return np.linspace(lo, hi, int(grid_resolution))
218
+
219
+ @staticmethod
220
+ def _normalize_values(values, base_values=None):
221
+ values = np.asarray(values)
222
+ base = np.asarray(base_values) if base_values is not None else None
223
+ if values.ndim == 3:
224
+ values = values[..., -1]
225
+ if base is not None and base.ndim == 2:
226
+ base = base[..., -1]
227
+ return values, base
228
+
229
+ def _prediction_fn(self, model_output):
230
+ if model_output == "probability":
231
+ return self._predict_proba_pos
232
+ if model_output in {"log_odds", "logit"}:
233
+ return self._predict_log_odds
234
+ raise ValueError("model_output must be 'probability' or 'log_odds'")
235
+
236
+ # ------------------------------------------------------------------ #
237
+ # SHAP computation
238
+ # ------------------------------------------------------------------ #
239
+ def _build_explainer(self):
240
+ shap = _lazy_shap()
241
+ if self._is_tree():
242
+ data = None
243
+ if self.background_data is not None:
244
+ data = self._as_frame(self.background_data)
245
+ self._explainer = shap.TreeExplainer(self.estimator, data=data)
246
+ self._explainer_kind = "tree"
247
+ elif self._is_linear():
248
+ if self.background_data is None:
249
+ raise ValueError(
250
+ "Linear models require `background_data` (a representative "
251
+ "sample of the training features) to build a SHAP LinearExplainer."
252
+ )
253
+ self._explainer = shap.LinearExplainer(self.estimator, self._as_frame(self.background_data))
254
+ self._explainer_kind = "linear"
255
+ else:
256
+ if self.background_data is None:
257
+ raise ValueError(
258
+ f"Model type {self.model_type!r} needs a model-agnostic explainer, "
259
+ "which requires `background_data`."
260
+ )
261
+ masker = self._as_frame(self.background_data)
262
+ self._explainer = shap.Explainer(self._predict_proba_pos, masker)
263
+ self._explainer_kind = "generic"
264
+ return self._explainer
265
+
266
+ def _shap_values_for(self, X):
267
+ """Compute normalized (2-D) SHAP values for *X* without caching."""
268
+ if self._explainer is None:
269
+ self._build_explainer()
270
+ explanation = self._explainer(X)
271
+ values, base = self._normalize_values(explanation.values, explanation.base_values)
272
+ return values, base, explanation
273
+
274
+ def explain(self, X):
275
+ """Compute and cache SHAP values for a dataset."""
276
+ frame = self._as_frame(X)
277
+ values, base, explanation = self._shap_values_for(frame)
278
+ self.shap_values_ = values
279
+ self.expected_value_ = base
280
+ self.explanation_ = explanation
281
+ self._last_X = frame
282
+ return explanation
283
+
284
+ def _ensure_values(self, X):
285
+ if X is not None:
286
+ self.explain(X)
287
+ if self.shap_values_ is None or self._last_X is None:
288
+ raise RuntimeError("No SHAP values available. Call explain(X) first or pass X=...")
289
+ return self.shap_values_, self._last_X
290
+
291
+ def _resolved_names(self, X, n_features):
292
+ if self.feature_names is not None:
293
+ return list(self.feature_names)
294
+ if isinstance(X, pd.DataFrame):
295
+ return list(X.columns)
296
+ return [f"f{i}" for i in range(n_features)]
297
+
298
+ def feature_importance(self, X=None, normalize=False):
299
+ """Global feature importance as mean absolute SHAP value."""
300
+ values, X_used = self._ensure_values(X)
301
+ mean_abs = np.abs(values).mean(axis=0)
302
+ names = self._resolved_names(X_used, values.shape[1])
303
+ table = pd.DataFrame({"feature": names, "mean_abs_shap": mean_abs})
304
+ table = table.sort_values("mean_abs_shap", ascending=False).reset_index(drop=True)
305
+ if normalize:
306
+ total = table["mean_abs_shap"].sum()
307
+ table["importance_pct"] = table["mean_abs_shap"] / total if total else 0.0
308
+ return table
309
+
310
+ def summary_plot(self, X=None, max_display=20, plot_type="dot", show=True, save_path=None):
311
+ """SHAP summary (beeswarm / bar) plot."""
312
+ shap = _lazy_shap()
313
+ import matplotlib.pyplot as plt
314
+
315
+ values, X_used = self._ensure_values(X)
316
+ kwargs = {"max_display": max_display, "plot_type": plot_type, "show": False}
317
+ if not isinstance(X_used, pd.DataFrame) and self.feature_names is not None:
318
+ kwargs["feature_names"] = self.feature_names
319
+ shap.summary_plot(values, X_used, **kwargs)
320
+ return self._finalize_plot(plt, show, save_path)
321
+
322
+ def dependence_plot(self, feature, X=None, interaction_index="auto", show=True, save_path=None):
323
+ """SHAP dependence plot for a single feature."""
324
+ shap = _lazy_shap()
325
+ import matplotlib.pyplot as plt
326
+
327
+ values, X_used = self._ensure_values(X)
328
+ kwargs = {"interaction_index": interaction_index, "show": False}
329
+ if not isinstance(X_used, pd.DataFrame) and self.feature_names is not None:
330
+ kwargs["feature_names"] = self.feature_names
331
+ shap.dependence_plot(feature, values, X_used, **kwargs)
332
+ return self._finalize_plot(plt, show, save_path)
333
+
334
+ def explain_instance(self, x_row):
335
+ """Per-feature SHAP contributions for a single sample."""
336
+ if isinstance(x_row, pd.Series):
337
+ x_row = x_row.to_frame().T
338
+ elif isinstance(x_row, dict):
339
+ x_row = pd.DataFrame([x_row])
340
+ frame = self._as_frame(x_row)
341
+ if frame.shape[0] != 1:
342
+ frame = frame.iloc[[0]]
343
+
344
+ values, base, _ = self._shap_values_for(frame)
345
+ names = self._resolved_names(frame, values.shape[1])
346
+ table = pd.DataFrame(
347
+ {"feature": names, "value": np.ravel(np.asarray(frame.values)), "shap_value": np.ravel(values[0])}
348
+ )
349
+ order = table["shap_value"].abs().sort_values(ascending=False).index
350
+ table = table.loc[order].reset_index(drop=True)
351
+ table.attrs["base_value"] = float(np.ravel(base)[0]) if base is not None and base.size else float("nan")
352
+ return table
353
+
354
+ # ------------------------------------------------------------------ #
355
+ # Owen Value / Coalition SHAP
356
+ # ------------------------------------------------------------------ #
357
+ def build_coalition_structure(
358
+ self,
359
+ X=None,
360
+ prior_groups=None,
361
+ threshold=0.35,
362
+ method="complete",
363
+ corr_method="spearman",
364
+ min_group_size=1,
365
+ intra_dist=0.01,
366
+ inter_dist=0.99,
367
+ ):
368
+ """Build and cache a coalition structure for Owen Value explanations."""
369
+ data = X if X is not None else self.background_data
370
+ if data is None:
371
+ raise ValueError("build_coalition_structure requires X or background_data")
372
+ frame = self._as_frame(data)
373
+ cs = _build_coalition_structure(
374
+ frame,
375
+ prior_groups=prior_groups,
376
+ threshold=threshold,
377
+ method=method,
378
+ corr_method=corr_method,
379
+ min_group_size=min_group_size,
380
+ intra_dist=intra_dist,
381
+ inter_dist=inter_dist,
382
+ )
383
+ self.coalition_structure_ = cs
384
+ return cs
385
+
386
+ def _build_owen_explainer(self, coalition_structure, background_data=None, model_output="probability"):
387
+ shap = _lazy_shap()
388
+ background = background_data if background_data is not None else self.background_data
389
+ if background is None:
390
+ raise ValueError("Owen Value explanations require background_data")
391
+ background = self._as_frame(background)
392
+ features = coalition_structure.get("features")
393
+ if features is not None:
394
+ background = background.loc[:, list(features)]
395
+ masker = shap.maskers.Partition(background, clustering=coalition_structure["shap_lnk"])
396
+ self._owen_explainer = shap.PartitionExplainer(self._prediction_fn(model_output), masker)
397
+ self._owen_model_output = model_output
398
+ return self._owen_explainer
399
+
400
+ def explain_owen(
401
+ self,
402
+ X,
403
+ coalition_structure=None,
404
+ prior_groups=None,
405
+ threshold=0.35,
406
+ method="complete",
407
+ corr_method="spearman",
408
+ background_data=None,
409
+ model_output="probability",
410
+ rebuild=False,
411
+ **explain_kwargs,
412
+ ):
413
+ """Compute Owen Value attribution with SHAP PartitionExplainer.
414
+
415
+ ``model_output='probability'`` explains positive-class probability.
416
+ ``model_output='log_odds'`` is useful for credit reason-code reporting.
417
+ """
418
+ frame = self._as_frame(X)
419
+ if coalition_structure is None:
420
+ coalition_structure = self.coalition_structure_
421
+ if coalition_structure is None or prior_groups is not None:
422
+ base = background_data if background_data is not None else self.background_data
423
+ if base is None:
424
+ base = frame
425
+ coalition_structure = self.build_coalition_structure(
426
+ base,
427
+ prior_groups=prior_groups,
428
+ threshold=threshold,
429
+ method=method,
430
+ corr_method=corr_method,
431
+ )
432
+ features = coalition_structure.get("features")
433
+ if features is not None:
434
+ frame = frame.loc[:, list(features)]
435
+
436
+ if rebuild or self._owen_explainer is None or self._owen_model_output != model_output:
437
+ self._build_owen_explainer(coalition_structure, background_data=background_data, model_output=model_output)
438
+
439
+ explanation = self._owen_explainer(frame, **explain_kwargs)
440
+ values, base = self._normalize_values(explanation.values, explanation.base_values)
441
+ self.coalition_structure_ = coalition_structure
442
+ self.owen_values_ = values
443
+ self.owen_expected_value_ = base
444
+ self.owen_explanation_ = explanation
445
+ self._owen_last_X = frame
446
+ return explanation
447
+
448
+ def _ensure_owen_values(self, X=None):
449
+ if X is not None:
450
+ self.explain_owen(X)
451
+ if self.owen_values_ is None or self._owen_last_X is None or self.coalition_structure_ is None:
452
+ raise RuntimeError("No Owen values available. Call explain_owen(X) first or pass X=...")
453
+ return self.owen_values_, self._owen_last_X, self.coalition_structure_
454
+
455
+ def owen_feature_importance(self, X=None, normalize=False):
456
+ """Global feature importance as mean absolute Owen value."""
457
+ values, X_used, _ = self._ensure_owen_values(X)
458
+ names = self._resolved_names(X_used, values.shape[1])
459
+ table = pd.DataFrame({"feature": names, "mean_abs_owen": np.abs(values).mean(axis=0)})
460
+ table = table.sort_values("mean_abs_owen", ascending=False).reset_index(drop=True)
461
+ if normalize:
462
+ total = table["mean_abs_owen"].sum()
463
+ table["importance_pct"] = table["mean_abs_owen"] / total if total else 0.0
464
+ return table
465
+
466
+ def owen_group_importance(self, X=None, normalize=False):
467
+ """Aggregate Owen values to coalition groups."""
468
+ values, X_used, cs = self._ensure_owen_values(X)
469
+ names = list(X_used.columns)
470
+ rows = []
471
+ for group, feats in cs["groups"].items():
472
+ idxs = [names.index(feat) for feat in feats if feat in names]
473
+ if not idxs:
474
+ continue
475
+ contrib = values[:, idxs].sum(axis=1)
476
+ rows.append(
477
+ {
478
+ "group": group,
479
+ "n_features": len(idxs),
480
+ "features": [names[i] for i in idxs],
481
+ "mean_owen": float(np.mean(contrib)),
482
+ "mean_abs_owen": float(np.mean(np.abs(contrib))),
483
+ }
484
+ )
485
+ table = pd.DataFrame(rows).sort_values("mean_abs_owen", ascending=False).reset_index(drop=True)
486
+ if normalize and not table.empty:
487
+ total = table["mean_abs_owen"].sum()
488
+ table["importance_pct"] = table["mean_abs_owen"] / total if total else 0.0
489
+ return table
490
+
491
+ def owen_explain_instance(self, x_row=None, aggregate_groups=True):
492
+ """Return local Owen reason codes for one sample."""
493
+ if x_row is not None:
494
+ self.explain_owen(self._as_frame(x_row).iloc[[0]])
495
+ values, X_used, cs = self._ensure_owen_values(None)
496
+ row_values = values[0]
497
+ row_x = X_used.iloc[0]
498
+ if not aggregate_groups:
499
+ table = pd.DataFrame({"feature": list(X_used.columns), "value": row_x.values, "owen_value": row_values})
500
+ table["abs_owen_value"] = table["owen_value"].abs()
501
+ return table.sort_values("abs_owen_value", ascending=False).reset_index(drop=True)
502
+
503
+ rows = []
504
+ names = list(X_used.columns)
505
+ for group, feats in cs["groups"].items():
506
+ idxs = [names.index(feat) for feat in feats if feat in names]
507
+ if not idxs:
508
+ continue
509
+ contrib = float(row_values[idxs].sum())
510
+ rows.append(
511
+ {
512
+ "group": group,
513
+ "n_features": len(idxs),
514
+ "features": [names[i] for i in idxs],
515
+ "owen_value": contrib,
516
+ "abs_owen_value": abs(contrib),
517
+ }
518
+ )
519
+ table = pd.DataFrame(rows).sort_values("abs_owen_value", ascending=False).reset_index(drop=True)
520
+ base = self.owen_expected_value_
521
+ table.attrs["base_value"] = float(np.ravel(base)[0]) if base is not None and np.asarray(base).size else float("nan")
522
+ table.attrs["model_output"] = self._owen_model_output
523
+ return table
524
+
525
+ # ------------------------------------------------------------------ #
526
+ # PDP / ICE
527
+ # ------------------------------------------------------------------ #
528
+ def partial_dependence(self, X, feature, grid_resolution=50, percentiles=(0.05, 0.95), sample_size=None, random_state=None):
529
+ """Compute one-way partial dependence for a numeric feature."""
530
+ frame = self._sample_frame(X, sample_size=sample_size, random_state=random_state)
531
+ feature = self._feature_name(frame, feature)
532
+ grid = self._numeric_grid(frame[feature], grid_resolution=grid_resolution, percentiles=percentiles)
533
+ averages = []
534
+ for value in grid:
535
+ tmp = frame.copy()
536
+ tmp[feature] = value
537
+ averages.append(float(np.mean(self._predict_proba_pos(tmp))))
538
+ return pd.DataFrame({"feature": feature, "grid_value": grid, "average_prediction": averages})
539
+
540
+ def pdp_plot(self, X, feature, grid_resolution=50, percentiles=(0.05, 0.95), sample_size=None, random_state=None, show=True, save_path=None):
541
+ """Plot one-way partial dependence for a numeric feature."""
542
+ import matplotlib.pyplot as plt
543
+
544
+ df = self.partial_dependence(X, feature, grid_resolution, percentiles, sample_size, random_state)
545
+ fig, ax = plt.subplots(figsize=(7, 4), dpi=120)
546
+ ax.plot(df["grid_value"], df["average_prediction"], color="#336699", linewidth=2)
547
+ ax.set_xlabel(str(df["feature"].iloc[0]))
548
+ ax.set_ylabel("Average prediction")
549
+ ax.set_title(f"PDP: {df['feature'].iloc[0]}")
550
+ ax.grid(alpha=0.25)
551
+ return self._finalize_plot(plt, show, save_path)
552
+
553
+ def ice(self, X, feature, grid_resolution=50, percentiles=(0.05, 0.95), sample_size=200, random_state=None, centered=False):
554
+ """Compute individual conditional expectation curves."""
555
+ frame = self._sample_frame(X, sample_size=sample_size, random_state=random_state)
556
+ feature = self._feature_name(frame, feature)
557
+ grid = self._numeric_grid(frame[feature], grid_resolution=grid_resolution, percentiles=percentiles)
558
+ records = []
559
+ for value in grid:
560
+ tmp = frame.copy()
561
+ tmp[feature] = value
562
+ preds = np.asarray(self._predict_proba_pos(tmp)).ravel()
563
+ records.append(pd.DataFrame({"sample_index": frame.index, "grid_value": value, "prediction": preds}))
564
+ out = pd.concat(records, ignore_index=True)
565
+ out.insert(0, "feature", feature)
566
+ if centered:
567
+ base = out.groupby("sample_index")["prediction"].transform("first")
568
+ out["prediction"] = out["prediction"] - base
569
+ return out
570
+
571
+ def ice_plot(self, X, feature, grid_resolution=50, percentiles=(0.05, 0.95), sample_size=100, random_state=None, centered=False, show=True, save_path=None):
572
+ """Plot ICE curves for a numeric feature."""
573
+ import matplotlib.pyplot as plt
574
+
575
+ df = self.ice(X, feature, grid_resolution, percentiles, sample_size, random_state, centered)
576
+ fig, ax = plt.subplots(figsize=(7, 4), dpi=120)
577
+ for _, group in df.groupby("sample_index"):
578
+ ax.plot(group["grid_value"], group["prediction"], color="#336699", alpha=0.18, linewidth=0.8)
579
+ avg = df.groupby("grid_value", as_index=False)["prediction"].mean()
580
+ ax.plot(avg["grid_value"], avg["prediction"], color="#CC0033", linewidth=2.2, label="average")
581
+ ax.set_xlabel(str(df["feature"].iloc[0]))
582
+ ax.set_ylabel("Centered prediction" if centered else "Prediction")
583
+ ax.set_title(f"ICE: {df['feature'].iloc[0]}")
584
+ ax.legend()
585
+ ax.grid(alpha=0.25)
586
+ return self._finalize_plot(plt, show, save_path)
587
+
588
+ # ------------------------------------------------------------------ #
589
+ # ALE
590
+ # ------------------------------------------------------------------ #
591
+ def ale(self, X, feature, bins=20, sample_size=None, random_state=None):
592
+ """Compute first-order accumulated local effects for a numeric feature."""
593
+ frame = self._sample_frame(X, sample_size=sample_size, random_state=random_state)
594
+ feature = self._feature_name(frame, feature)
595
+ values = pd.to_numeric(frame[feature], errors="coerce")
596
+ valid = values.notna()
597
+ work = frame.loc[valid].copy()
598
+ values = values.loc[valid]
599
+ if work.empty:
600
+ raise ValueError(f"Feature {feature!r} has no non-missing numeric values")
601
+
602
+ quantiles = np.linspace(0, 1, int(bins) + 1)
603
+ edges = np.unique(values.quantile(quantiles).to_numpy(dtype=float))
604
+ if len(edges) < 2:
605
+ raise ValueError(f"Feature {feature!r} does not have enough unique values for ALE")
606
+ edges[0] = values.min()
607
+ edges[-1] = values.max()
608
+
609
+ bin_ids = np.searchsorted(edges, values.to_numpy(), side="right") - 1
610
+ bin_ids = np.clip(bin_ids, 0, len(edges) - 2)
611
+
612
+ effects = []
613
+ counts = []
614
+ centers = []
615
+ for idx in range(len(edges) - 1):
616
+ mask = bin_ids == idx
617
+ subset = work.loc[mask].copy()
618
+ counts.append(int(mask.sum()))
619
+ left, right = float(edges[idx]), float(edges[idx + 1])
620
+ centers.append((left + right) / 2.0)
621
+ if subset.empty:
622
+ effects.append(0.0)
623
+ continue
624
+ low = subset.copy()
625
+ high = subset.copy()
626
+ low[feature] = left
627
+ high[feature] = right
628
+ effects.append(float(np.mean(self._predict_proba_pos(high) - self._predict_proba_pos(low))))
629
+
630
+ ale_values = np.cumsum(effects)
631
+ counts_arr = np.asarray(counts, dtype=float)
632
+ if counts_arr.sum() > 0:
633
+ ale_values = ale_values - np.average(ale_values, weights=counts_arr)
634
+ else:
635
+ ale_values = ale_values - ale_values.mean()
636
+
637
+ return pd.DataFrame({"feature": feature, "bin_left": edges[:-1], "bin_right": edges[1:], "bin_center": centers, "ale_value": ale_values, "n": counts})
638
+
639
+ def ale_plot(self, X, feature, bins=20, sample_size=None, random_state=None, show=True, save_path=None):
640
+ """Plot first-order ALE for a numeric feature."""
641
+ import matplotlib.pyplot as plt
642
+
643
+ df = self.ale(X, feature, bins=bins, sample_size=sample_size, random_state=random_state)
644
+ fig, ax = plt.subplots(figsize=(7, 4), dpi=120)
645
+ ax.plot(df["bin_center"], df["ale_value"], color="#336699", marker="o", linewidth=2)
646
+ ax.axhline(0, color="#999999", linewidth=1, linestyle="--")
647
+ ax.set_xlabel(str(df["feature"].iloc[0]))
648
+ ax.set_ylabel("Accumulated local effect")
649
+ ax.set_title(f"ALE: {df['feature'].iloc[0]}")
650
+ ax.grid(alpha=0.25)
651
+ return self._finalize_plot(plt, show, save_path)
652
+
653
+ # ------------------------------------------------------------------ #
654
+ # LIME
655
+ # ------------------------------------------------------------------ #
656
+ def _build_lime_explainer(self, X_train, num_features=None, random_state=None, **lime_kwargs):
657
+ LimeTabularExplainer = _lazy_lime()
658
+ train = self._as_frame(X_train if X_train is not None else self.background_data)
659
+ if train is None:
660
+ raise ValueError("LIME requires X_train or background_data")
661
+ mode = lime_kwargs.pop("mode", "classification")
662
+ class_names = lime_kwargs.pop("class_names", ["class_0", "class_1"])
663
+ return LimeTabularExplainer(
664
+ training_data=np.asarray(train),
665
+ feature_names=list(train.columns),
666
+ class_names=class_names,
667
+ mode=mode,
668
+ random_state=random_state,
669
+ **lime_kwargs,
670
+ )
671
+
672
+ def lime_explain_instance(self, x_row, X_train=None, num_features=10, num_samples=5000, random_state=None, **lime_kwargs):
673
+ """Explain one sample with LIME."""
674
+ if isinstance(x_row, pd.Series):
675
+ x_row = x_row.to_frame().T
676
+ elif isinstance(x_row, dict):
677
+ x_row = pd.DataFrame([x_row])
678
+ frame = self._as_frame(x_row)
679
+ if frame.shape[0] != 1:
680
+ frame = frame.iloc[[0]]
681
+
682
+ train = X_train if X_train is not None else self.background_data
683
+ explainer = self._build_lime_explainer(train, num_features, random_state, **lime_kwargs)
684
+ explanation = explainer.explain_instance(
685
+ data_row=np.asarray(frame.iloc[0]),
686
+ predict_fn=self._predict_proba_2d,
687
+ num_features=num_features,
688
+ num_samples=num_samples,
689
+ )
690
+ rows = []
691
+ names = list(frame.columns)
692
+ for rule, weight in explanation.as_list():
693
+ matched = next((name for name in names if str(rule).startswith(name) or name in str(rule)), rule)
694
+ rows.append({"feature": matched, "feature_rule": rule, "weight": float(weight)})
695
+ out = pd.DataFrame(rows)
696
+ if out.empty:
697
+ return pd.DataFrame(columns=["feature", "feature_rule", "weight", "abs_weight"])
698
+ out["abs_weight"] = out["weight"].abs()
699
+ out = out.sort_values("abs_weight", ascending=False).reset_index(drop=True)
700
+ out.attrs["intercept"] = explanation.intercept
701
+ out.attrs["score"] = explanation.score
702
+ return out
703
+
704
+ def lime_global_importance(self, X, X_train=None, num_features=10, num_samples=2000, sample_size=100, random_state=None, **lime_kwargs):
705
+ """Aggregate LIME local weights across a sample as global importance."""
706
+ frame = self._sample_frame(X, sample_size=sample_size, random_state=random_state)
707
+ rows = []
708
+ for _, row in frame.iterrows():
709
+ local = self.lime_explain_instance(
710
+ row,
711
+ X_train=X_train,
712
+ num_features=num_features,
713
+ num_samples=num_samples,
714
+ random_state=random_state,
715
+ **lime_kwargs,
716
+ )
717
+ rows.append(local)
718
+ if not rows:
719
+ return pd.DataFrame(columns=["feature", "mean_abs_lime_weight", "frequency"])
720
+ all_rows = pd.concat(rows, ignore_index=True)
721
+ summary = all_rows.groupby("feature", as_index=False).agg(
722
+ mean_abs_lime_weight=("abs_weight", "mean"),
723
+ frequency=("feature", "size"),
724
+ )
725
+ return summary.sort_values("mean_abs_lime_weight", ascending=False).reset_index(drop=True)
726
+
727
+ # ------------------------------------------------------------------ #
728
+ # Internals
729
+ # ------------------------------------------------------------------ #
730
+ @staticmethod
731
+ def _finalize_plot(plt, show, save_path):
732
+ fig = plt.gcf()
733
+ if save_path:
734
+ fig.savefig(save_path, bbox_inches="tight", dpi=150)
735
+ if show:
736
+ plt.show()
737
+ else:
738
+ plt.close(fig)
739
+ return fig
740
+
741
+ def __repr__(self):
742
+ n_feat = len(self.feature_names) if self.feature_names else "?"
743
+ return f"ModelExplainer(model_type={self.model_type!r}, n_features={n_feat}, kind={self._explainer_kind!r})"