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.
- ExcelMaster/ExcelFormatTool.py +487 -0
- ExcelMaster/ExcelMaster.py +917 -0
- ExcelMaster/Template.py +525 -0
- ExcelMaster/Utility.py +233 -0
- ExcelMaster/__init__.py +3 -0
- Modeling_Tool/Core/Binning_Tool.py +1608 -0
- Modeling_Tool/Core/Binning_Tool.pyi +48 -0
- Modeling_Tool/Core/Check_DuckDB_Compatibility.py +835 -0
- Modeling_Tool/Core/Json_Data_Converter.py +621 -0
- Modeling_Tool/Core/Model_Registry_Tool.py +199 -0
- Modeling_Tool/Core/ODPS_Tool.py +284 -0
- Modeling_Tool/Core/Slope_Tool.py +356 -0
- Modeling_Tool/Core/Slope_Tool.pyi +31 -0
- Modeling_Tool/Core/XOR_Encryptor.py +207 -0
- Modeling_Tool/Core/XOR_Encryptor.pyi +26 -0
- Modeling_Tool/Core/__init__.py +99 -0
- Modeling_Tool/Core/kDataFrame.py +228 -0
- Modeling_Tool/Core/kDataFrame.pyi +43 -0
- Modeling_Tool/Core/sample_weight_utils.py +77 -0
- Modeling_Tool/Core/utils.py +2672 -0
- Modeling_Tool/Eval/Evaluation_Tool.py +1452 -0
- Modeling_Tool/Eval/Evaluation_Tool.pyi +47 -0
- Modeling_Tool/Eval/Model_Eval_Tool.py +2548 -0
- Modeling_Tool/Eval/Model_Eval_Tool.pyi +37 -0
- Modeling_Tool/Eval/__init__.py +54 -0
- Modeling_Tool/Eval/evaluate_model.py +2008 -0
- Modeling_Tool/Eval/evaluate_model.pyi +50 -0
- Modeling_Tool/Eval/weighted_eval_utils.py +326 -0
- Modeling_Tool/Explainability/Coalition_Structure.py +305 -0
- Modeling_Tool/Explainability/Model_Explainer.py +743 -0
- Modeling_Tool/Explainability/__init__.py +24 -0
- Modeling_Tool/Feature/Distribution_Tool.py +509 -0
- Modeling_Tool/Feature/Distribution_Tool.pyi +42 -0
- Modeling_Tool/Feature/Feature_Insights.py +762 -0
- Modeling_Tool/Feature/Feature_Insights.pyi +33 -0
- Modeling_Tool/Feature/PSI_Tool.py +1195 -0
- Modeling_Tool/Feature/PSI_Tool.pyi +29 -0
- Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +355 -0
- Modeling_Tool/Feature/__init__.py +40 -0
- Modeling_Tool/Model/Backward_Tool.py +778 -0
- Modeling_Tool/Model/Backward_Tool.pyi +45 -0
- Modeling_Tool/Model/GBM_Search_Tool.py +251 -0
- Modeling_Tool/Model/GBM_Tool.py +1610 -0
- Modeling_Tool/Model/GBM_Tool.pyi +90 -0
- Modeling_Tool/Model/LRM_Tool.py +1198 -0
- Modeling_Tool/Model/LRM_Tool.pyi +47 -0
- Modeling_Tool/Model/__init__.py +61 -0
- Modeling_Tool/Sample/Distribution_Adaptation.py +131 -0
- Modeling_Tool/Sample/Distribution_Adaptation.pyi +30 -0
- Modeling_Tool/Sample/Reject_Infer.py +413 -0
- Modeling_Tool/Sample/Reject_Infer.pyi +43 -0
- Modeling_Tool/Sample/Sample_Split.py +520 -0
- Modeling_Tool/Sample/Sample_Split.pyi +43 -0
- Modeling_Tool/Sample/__init__.py +31 -0
- Modeling_Tool/UAT/UAT_Consistency_Checker.py +1180 -0
- Modeling_Tool/UAT/__init__.py +19 -0
- Modeling_Tool/WOE/WOE_Adapter.py +204 -0
- Modeling_Tool/WOE/WOE_Adapter.pyi +21 -0
- Modeling_Tool/WOE/WOE_Master.py +491 -0
- Modeling_Tool/WOE/WOE_Master.pyi +40 -0
- Modeling_Tool/WOE/WOE_Monotone_Binner.py +3324 -0
- Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +71 -0
- Modeling_Tool/WOE/WOE_Plot_Tool.py +919 -0
- Modeling_Tool/WOE/WOE_Plot_Tool.pyi +46 -0
- Modeling_Tool/WOE/WOE_Report_Builder.py +214 -0
- Modeling_Tool/WOE/WOE_Report_Builder.pyi +24 -0
- Modeling_Tool/WOE/WOE_Tool.py +1094 -0
- Modeling_Tool/WOE/WOE_Tool.pyi +41 -0
- Modeling_Tool/WOE/__init__.py +86 -0
- Modeling_Tool/WOE/plot_woe_tool.py +290 -0
- Modeling_Tool/WOE/plot_woe_tool.pyi +25 -0
- Modeling_Tool/__init__.py +176 -0
- Report/Report_Tool.py +380 -0
- Report/__init__.py +3 -0
- supermodelingfactory-0.2.0.dist-info/METADATA +268 -0
- supermodelingfactory-0.2.0.dist-info/RECORD +79 -0
- supermodelingfactory-0.2.0.dist-info/WHEEL +5 -0
- supermodelingfactory-0.2.0.dist-info/licenses/LICENSE +102 -0
- 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})"
|