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,50 @@
1
+ # =============================================================================
2
+ # Modeling_Tool.Eval.evaluate_model
3
+ # -----------------------------------------------------------------------------
4
+ # Copyright (c) 2026 Kyle Sun <github.com/Kyle-J-Sun>. All rights reserved.
5
+ # SuperModelingFactory — Licensed under the Business Source License 1.1.
6
+ #
7
+ # This stub describes the public API of a closed-source module compiled to a
8
+ # native extension (.so / .pyd). The original source is not distributed.
9
+ # Production / commercial use requires a separate commercial license.
10
+ #
11
+ # FINGERPRINT: SMF-EVALUATEMODE-566c8a99
12
+ # (Unique trace marker. Do not remove or alter — used for plagiarism
13
+ # detection across the public internet.)
14
+ # =============================================================================
15
+
16
+ import os
17
+ from tracemalloc import start
18
+ import numpy as np
19
+ import pandas as pd
20
+ from pandas.core.groupby.generic import NamedAgg
21
+ from sklearn.metrics import roc_curve, precision_recall_curve, auc
22
+ from matplotlib.font_manager import FontProperties, findfont
23
+ import seaborn as sns
24
+ import matplotlib.pyplot as plt
25
+ from sklearn.utils.extmath import density
26
+ import time
27
+ from functools import wraps
28
+ def timeit_decorator(func): ...
29
+ def calc_pr(y_true, y_score, sample_weight=None): ...
30
+ def summarize_pr(pr_df): ...
31
+ def plot_pr_curve(pr_dfs, square_figsize = 8, to_show = True, save_path = None): ...
32
+ def calc_roc(y_true, y_score, sample_weight=None): ...
33
+ def summarize_roc(roc_df): ...
34
+ def plot_ks_curve(roc_df, square_figsize = 8, to_show = True, save_path = None): ...
35
+ def plot_roc_curve(roc_dfs, square_figsize = 8, fontdicts = fontdicts['main'], to_show = True, save_path = None): ...
36
+ def plot_kde_curve(y_true, y_score_dict, bins = 20, square_figsize = 8, fontdicts = fontdicts['main'], to_show = True, save_path = None): ...
37
+ def calc_equid_dist(y_true, y_score, y_group = None, bins = 10, sample_weight = None): ...
38
+ def calc_equid_pct(y_true, y_score, y_group = None, bins = 10, ascending = True, sample_weight = None): ...
39
+ def calc_fixed_pct(y_true, y_score, y_group = None, bin_edges = None, ascending = True, sample_weight = None): ...
40
+ def summarize_pct(pct_df, ascending = True): ...
41
+ def plot_dist_curve(dist_dfs, square_figsize = 8, fontdicts = fontdicts['main'], to_show = True, save_path = None): ...
42
+ def plot_cumdist_curve(dist_dfs, square_figsize = 8, fontdicts = fontdicts['main'], to_show = True, save_path = None): ...
43
+ def plot_pct_curve(pct_dfs, square_figsize = 8, fontdicts = fontdicts['main'], to_show = True, save_path = None): ...
44
+ def plot_cumpct_curve(pct_dfs, square_figsize = 8, fontdicts = fontdicts['main'], to_show = True, save_path = None): ...
45
+ def plot_gain_curve(pct_dfs, square_figsize = 8, fontdicts = fontdicts['main'], to_show = True, save_path = None): ...
46
+ def evaluate_performance(datasets, dist_bins = 20, pct_bins = 10, square_figsize = 5, fontdicts = fontdicts['sub'], to_show = True, save_path = None, gains_table = True, equal_freq = True, pct_bin_edges = None): ...
47
+ def resturct_gains(gains_table): ...
48
+ def evaluate_distribution(datasets, dist_bins = 10, square_figsize = 5, fontdicts = fontdicts['sub'], toplot = True, save_path = None): ...
49
+ def comparison_performance(datasets, pct_bins = 10, square_figsize = 5, fontdicts = fontdicts['sub'], to_show = True, save_path = None): ...
50
+ def calc_lift_apt(y_true, y_score, start, stop, step, score_ascending = True, sample_weight = None): ...
@@ -0,0 +1,326 @@
1
+ # encoding: utf-8
2
+ """Native weighted evaluation helpers.
3
+
4
+ Shared implementation for public Eval APIs when ``sample_weight`` or
5
+ ``weight_col`` is supplied. Unweighted callers keep using the historical
6
+ implementations in ``evaluate_model.py`` and ``Model_Eval_Tool.py``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections import OrderedDict
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ from sklearn.metrics import precision_recall_curve, roc_auc_score, roc_curve
15
+
16
+ from Modeling_Tool.Core.sample_weight_utils import resolve_sample_weight
17
+
18
+
19
+ def resolve_weights(data=None, weight_col=None, sample_weight=None, expected_len=None, wgt=None, wgt_col=None):
20
+ if weight_col is None:
21
+ weight_col = wgt_col
22
+ if sample_weight is None:
23
+ sample_weight = wgt
24
+ return resolve_sample_weight(
25
+ data=data,
26
+ weight_col=weight_col,
27
+ sample_weight=sample_weight,
28
+ expected_len=expected_len,
29
+ )
30
+
31
+
32
+ def safe_weighted_average(values, weights=None):
33
+ values = np.asarray(values, dtype=float)
34
+ if weights is None:
35
+ return float(np.mean(values)) if len(values) else np.nan
36
+ weights = np.asarray(weights, dtype=float)
37
+ total = float(np.sum(weights))
38
+ if total == 0:
39
+ return np.nan
40
+ return float(np.average(values, weights=weights))
41
+
42
+
43
+ def safe_auc(y_true, y_score, sample_weight=None):
44
+ try:
45
+ return float(roc_auc_score(y_true, y_score, sample_weight=sample_weight))
46
+ except Exception:
47
+ return np.nan
48
+
49
+
50
+ def calc_roc(y_true, y_score, sample_weight=None):
51
+ y_true = np.asarray(y_true, dtype=float)
52
+ y_score = np.asarray(y_score, dtype=float)
53
+ weight = None if sample_weight is None else np.asarray(sample_weight, dtype=float)
54
+
55
+ mask = np.isfinite(y_true) & np.isfinite(y_score)
56
+ if weight is not None:
57
+ mask = mask & np.isfinite(weight)
58
+ weight = weight[mask]
59
+ y_true = y_true[mask]
60
+ y_score = y_score[mask]
61
+
62
+ if len(y_true) == 0:
63
+ return pd.DataFrame(columns=["fpr", "tpr", "thresholds", "thresholds_percentile", "FPR", "TPR", "KS"])
64
+
65
+ fpr, tpr, thresholds = roc_curve(y_true, y_score, sample_weight=weight)
66
+ out = pd.DataFrame({"fpr": fpr, "tpr": tpr, "thresholds": thresholds})
67
+ if weight is None:
68
+ out["thresholds_percentile"] = [100 * np.mean(y_score <= x) for x in thresholds]
69
+ else:
70
+ total_weight = float(weight.sum()) or 1.0
71
+ out["thresholds_percentile"] = [
72
+ 100 * float(weight[y_score <= x].sum()) / total_weight for x in thresholds
73
+ ]
74
+ out["FPR"] = out["fpr"]
75
+ out["TPR"] = out["tpr"]
76
+ out["KS"] = (out["tpr"] - out["fpr"]).abs()
77
+ return out
78
+
79
+
80
+ def calc_pr(y_true, y_score, sample_weight=None):
81
+ precision, recall, thresholds = precision_recall_curve(
82
+ y_true,
83
+ y_score,
84
+ sample_weight=sample_weight,
85
+ )
86
+ thresholds = np.r_[thresholds, np.nan]
87
+ return pd.DataFrame(
88
+ {
89
+ "precision": precision,
90
+ "recall": recall,
91
+ "thresholds": thresholds,
92
+ }
93
+ )
94
+
95
+
96
+ def rank_bins(score, weight, nbins):
97
+ score = np.asarray(score, dtype=float)
98
+ weight = np.ones(len(score), dtype=float) if weight is None else np.asarray(weight, dtype=float)
99
+ order = np.lexsort((np.arange(len(score)), -score))
100
+ bins = np.empty(len(score), dtype=int)
101
+ total = float(weight.sum()) or float(len(score)) or 1.0
102
+ cum_weight = np.cumsum(weight[order])
103
+ labels = np.ceil(cum_weight / total * int(nbins)).astype(int)
104
+ labels = np.clip(labels, 1, int(nbins))
105
+ bins[order] = labels
106
+ return bins
107
+
108
+
109
+ def get_gains_table(data, dep, score, nbins=10, weight_col=None, weighted_binning=None, **kwargs):
110
+ cols = [dep, score]
111
+ if weight_col is not None and weight_col in data.columns:
112
+ cols.append(weight_col)
113
+ df = data[cols].copy()
114
+ weight = resolve_weights(df, weight_col=weight_col, expected_len=len(df))
115
+ if weight is None:
116
+ weight = np.ones(len(df), dtype=float)
117
+
118
+ y = df[dep].astype(float).to_numpy()
119
+ s = df[score].astype(float).to_numpy()
120
+ df["_bin_num"] = rank_bins(s, weight, nbins)
121
+ df["_bin_range"] = df["_bin_num"]
122
+ df["_w"] = weight
123
+ df["_bad_w"] = weight * y
124
+ df["_good_w"] = weight * (1.0 - y)
125
+ grouped = df.groupby(["_bin_num", "_bin_range"], sort=True, dropna=False)
126
+ out = grouped.agg(
127
+ MIN=(score, "min"),
128
+ MAX=(score, "max"),
129
+ N=("_w", "sum"),
130
+ N_RAW=(dep, "size"),
131
+ PERF_CNT=("_w", "sum"),
132
+ N_BAD=("_bad_w", "sum"),
133
+ N_GOOD=("_good_w", "sum"),
134
+ AVG_SCORE=(score, lambda x: safe_weighted_average(x, df.loc[x.index, "_w"])),
135
+ UNIQUE_SCORE=(score, "nunique"),
136
+ )
137
+
138
+ total_weight = float(out["N"].sum()) or 1.0
139
+ total_bad = float(out["N_BAD"].sum()) or 1.0
140
+ total_good = float(out["N_GOOD"].sum()) or 1.0
141
+ overall_bad_rate = float(out["N_BAD"].sum()) / total_weight if total_weight else np.nan
142
+
143
+ out["PROP"] = out["N"] / total_weight
144
+ out["AVG_BAD"] = out["N_BAD"] / out["N"].replace(0, np.nan)
145
+ out["AVG_GOOD"] = out["N_GOOD"] / out["N"].replace(0, np.nan)
146
+ out["BAD_PCT_IN_EACH_BIN"] = out["N_BAD"] / total_bad
147
+ out["GOOD_PCT_IN_EACH_BIN"] = out["N_GOOD"] / total_good
148
+ out["N_CUM_BAD"] = out["N_BAD"].cumsum()
149
+ out["N_CUM_GOOD"] = out["N_GOOD"].cumsum()
150
+ out["CUM_BAD_PCT"] = out["BAD_PCT_IN_EACH_BIN"].cumsum()
151
+ out["CUM_GOOD_PCT"] = out["GOOD_PCT_IN_EACH_BIN"].cumsum()
152
+ out["KS_PER_BIN"] = (out["CUM_BAD_PCT"] - out["CUM_GOOD_PCT"]).abs()
153
+ out["KS"] = out["KS_PER_BIN"]
154
+ out["LIFT"] = out["AVG_BAD"] / overall_bad_rate if overall_bad_rate else np.nan
155
+ out["TRUE_BAD_SHIFT"] = out["AVG_BAD"].shift(1) / out["AVG_BAD"] - 1
156
+ out["RANK_ORDER_BUMP"] = out["TRUE_BAD_SHIFT"].apply(lambda x: 1 if x < 0 else 0)
157
+ with np.errstate(divide="ignore", invalid="ignore"):
158
+ out["WOE"] = np.log(out["BAD_PCT_IN_EACH_BIN"] / out["GOOD_PCT_IN_EACH_BIN"])
159
+ out["WOE"] = out["WOE"].replace([np.inf, -np.inf], 0).fillna(0)
160
+ out["IV"] = (out["BAD_PCT_IN_EACH_BIN"] - out["GOOD_PCT_IN_EACH_BIN"]) * out["WOE"]
161
+ out["AUC"] = safe_auc(y, s, sample_weight=weight)
162
+ return out
163
+
164
+
165
+ def calc_lift_apt(y_true, y_score, start=1.0, stop=3.0, step=0.1, sample_weight=None):
166
+ y_true = np.asarray(y_true, dtype=float)
167
+ y_score = np.asarray(y_score, dtype=float)
168
+ weight = np.ones(len(y_true), dtype=float) if sample_weight is None else np.asarray(sample_weight, dtype=float)
169
+ gains = get_gains_table(
170
+ pd.DataFrame({"y": y_true, "s": y_score, "w": weight}),
171
+ "y",
172
+ "s",
173
+ nbins=100,
174
+ weight_col="w",
175
+ )
176
+ vals = []
177
+ for target in np.arange(start, stop + step / 2.0, step):
178
+ idx = (gains["LIFT"] - target).abs().idxmin()
179
+ vals.append(float(gains.loc[idx, "LIFT"]))
180
+ return np.asarray(vals)
181
+
182
+
183
+ def calc_equid_dist(y_true, y_score, bins=10, sample_weight=None, **kwargs):
184
+ weight = np.ones(len(y_true), dtype=float) if sample_weight is None else np.asarray(sample_weight, dtype=float)
185
+ return get_gains_table(
186
+ pd.DataFrame({"y": y_true, "s": y_score, "w": weight}),
187
+ "y",
188
+ "s",
189
+ nbins=bins,
190
+ weight_col="w",
191
+ )
192
+
193
+
194
+ def calc_equid_pct(y_true, y_score, bins=10, sample_weight=None, **kwargs):
195
+ return calc_equid_dist(y_true, y_score, bins=bins, sample_weight=sample_weight, **kwargs)
196
+
197
+
198
+ def calc_fixed_pct(y_true, y_score, sample_weight=None, **kwargs):
199
+ return calc_equid_dist(y_true, y_score, sample_weight=sample_weight, **kwargs)
200
+
201
+
202
+ def dataset_summary(name, data, tgt_name, scr_name, weight_col=None, nbins=10):
203
+ weight = resolve_weights(data, weight_col=weight_col, expected_len=len(data))
204
+ y_true = data[tgt_name].to_numpy()
205
+ y_score = data[scr_name].to_numpy()
206
+ roc_df = calc_roc(y_true, y_score, sample_weight=weight)
207
+ gains = get_gains_table(data, tgt_name, scr_name, nbins=nbins, weight_col=weight_col)
208
+ return {
209
+ "index": name,
210
+ "dataset": name,
211
+ "DATASET": name,
212
+ "AUC": safe_auc(y_true, y_score, sample_weight=weight),
213
+ "KS": float(roc_df["KS"].max()) if "KS" in roc_df else np.nan,
214
+ "LIFT": float(gains["LIFT"].max()) if "LIFT" in gains else np.nan,
215
+ "IV": float(gains["IV"].sum()) if "IV" in gains else np.nan,
216
+ "N": float(np.sum(weight)) if weight is not None else float(len(data)),
217
+ "N_RAW": int(len(data)),
218
+ "avgTrue": safe_weighted_average(y_true, weight),
219
+ "avgScore": safe_weighted_average(y_score, weight),
220
+ }
221
+
222
+
223
+ def get_perf_summary(train=None, validation=None, oot=None, tgt_name=None, scr_name=None, weight_col=None, nbins=10, **kwargs):
224
+ rows = []
225
+ for name, data in (("ins", train), ("oos", validation), ("oot", oot)):
226
+ if data is not None:
227
+ rows.append(dataset_summary(name, data, tgt_name, scr_name, weight_col=weight_col, nbins=nbins))
228
+ return pd.DataFrame(rows)
229
+
230
+
231
+ def evaluate_performance(datasets=None, tgt_name=None, scr_name=None, sample_weight=None, nbins=10, **kwargs):
232
+ rows = []
233
+ if datasets is None:
234
+ return pd.DataFrame(rows)
235
+ for name, payload in datasets.items():
236
+ if isinstance(payload, dict) and "data" in payload:
237
+ data = payload.get("data")
238
+ weight = payload.get("sample_weight", sample_weight)
239
+ else:
240
+ data = pd.DataFrame(
241
+ {
242
+ tgt_name: payload["y_true"],
243
+ scr_name: payload["y_score"],
244
+ }
245
+ )
246
+ weight = payload.get("sample_weight", sample_weight)
247
+ if data is None:
248
+ continue
249
+ tmp = data.copy()
250
+ tmp["_w"] = np.ones(len(tmp), dtype=float) if weight is None else weight
251
+ rows.append(dataset_summary(name, tmp, tgt_name, scr_name, weight_col="_w", nbins=nbins))
252
+ return pd.DataFrame(rows)
253
+
254
+
255
+ class GainsTableCalculator:
256
+ def __init__(self, data=None, dep=None, score=None, nbins=10, weight_col=None, weighted_binning=None, **kwargs):
257
+ self.data = data
258
+ self.dep = dep
259
+ self.score = score
260
+ self.nbins = nbins
261
+ self.weight_col = weight_col
262
+ self.weighted_binning = weighted_binning
263
+ self.kwargs = kwargs
264
+
265
+ def calculate(self, weight_col=None, **kwargs):
266
+ return get_gains_table(
267
+ self.data,
268
+ self.dep,
269
+ self.score,
270
+ nbins=self.nbins,
271
+ weight_col=self.weight_col if weight_col is None else weight_col,
272
+ weighted_binning=self.weighted_binning,
273
+ **{**self.kwargs, **kwargs},
274
+ )
275
+
276
+
277
+ class PerformanceEvaluator:
278
+ def __init__(self, tgt_name=None, scr_name=None, weight_col=None, nbins=10, **kwargs):
279
+ self.tgt_name = tgt_name
280
+ self.scr_name = scr_name
281
+ self.weight_col = weight_col
282
+ self.nbins = nbins
283
+ self.kwargs = kwargs
284
+ self.datasets = OrderedDict()
285
+
286
+ def add_dataset(self, name, data, weight_col=None, **kwargs):
287
+ self.datasets[name] = (data, weight_col)
288
+ return self
289
+
290
+ def evaluate(self, weight_col=None, to_show=False, display=False, **kwargs):
291
+ rows = []
292
+ for name, (data, ds_weight_col) in self.datasets.items():
293
+ wc = ds_weight_col or weight_col or self.weight_col
294
+ rows.append(dataset_summary(name, data, self.tgt_name, self.scr_name, weight_col=wc, nbins=self.nbins))
295
+ return pd.DataFrame(rows)
296
+
297
+
298
+ def cross_risk_weighted_mean(data, agg_col, sample_weight, score_list, margin_name="Total_Avg_Risk"):
299
+ """Weighted-mean cross-risk table after bin columns are assigned."""
300
+ weight = np.asarray(sample_weight, dtype=float)
301
+ values = pd.to_numeric(data[agg_col], errors="coerce").to_numpy(dtype=float)
302
+ frame = data[["_bin_num1", "_bin_range1", "_bin_num2", "_bin_range2"]].copy()
303
+ frame["_w"] = weight
304
+ frame["_wv"] = values * weight
305
+
306
+ numerator = pd.crosstab(
307
+ [frame["_bin_num1"], frame["_bin_range1"]],
308
+ [frame["_bin_num2"], frame["_bin_range2"]],
309
+ values=frame["_wv"],
310
+ aggfunc="sum",
311
+ margins=True,
312
+ margins_name=margin_name,
313
+ rownames=[score_list[0], score_list[0]],
314
+ colnames=[score_list[1], score_list[1]],
315
+ )
316
+ denominator = pd.crosstab(
317
+ [frame["_bin_num1"], frame["_bin_range1"]],
318
+ [frame["_bin_num2"], frame["_bin_range2"]],
319
+ values=frame["_w"],
320
+ aggfunc="sum",
321
+ margins=True,
322
+ margins_name=margin_name,
323
+ rownames=[score_list[0], score_list[0]],
324
+ colnames=[score_list[1], score_list[1]],
325
+ )
326
+ return numerator / denominator.replace(0, np.nan)
@@ -0,0 +1,305 @@
1
+ # encoding: utf-8
2
+ """Coalition structure utilities for Owen Value explanations.
3
+
4
+ The functions in this module build feature groups for SHAP
5
+ ``PartitionExplainer``. They combine data-driven correlation clustering with
6
+ optional business priors, then convert the final groups to the linkage matrix
7
+ accepted by ``shap.maskers.Partition``.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Dict, Iterable, List, Mapping, Optional, Sequence
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ from scipy.cluster.hierarchy import fcluster, linkage
16
+ from scipy.spatial.distance import squareform
17
+
18
+ __all__ = [
19
+ "CREDIT_PRIOR_GROUPS",
20
+ "compute_correlation_linkage",
21
+ "auto_cluster",
22
+ "apply_prior",
23
+ "validate_groups",
24
+ "group_correlation_summary",
25
+ "groups_to_shap_clustering",
26
+ "build_coalition_structure",
27
+ ]
28
+
29
+ CREDIT_PRIOR_GROUPS = {
30
+ "delinquency": [
31
+ "max_dpd_12m", "dpd_cnt_6m", "ever_dpd30",
32
+ "dpd_cnt_3m", "max_dpd_6m", "ever_dpd90",
33
+ ],
34
+ "multi_lending": [
35
+ "inquiries_3m", "inquiries_6m", "inquiries_12m",
36
+ "active_loans", "loan_cnt_12m",
37
+ ],
38
+ "affordability": [
39
+ "monthly_income", "debt_to_income",
40
+ "monthly_obligation", "net_income", "dsr",
41
+ ],
42
+ "device_fraud": [
43
+ "device_risk", "ip_risk", "device_age_days",
44
+ "proxy_flag", "emulator_flag",
45
+ ],
46
+ "apply_behavior": [
47
+ "apply_hour", "form_fill_secs", "typo_cnt",
48
+ "paste_cnt", "apply_wday",
49
+ ],
50
+ "channel": ["channel_risk", "promo_code_used", "referral_flag"],
51
+ }
52
+
53
+ _ALLOWED_LINKAGE_METHODS = frozenset({"complete", "average", "single"})
54
+
55
+
56
+ def _as_numeric_frame(X: pd.DataFrame) -> pd.DataFrame:
57
+ if not isinstance(X, pd.DataFrame):
58
+ raise TypeError("Coalition structure requires X to be a pandas DataFrame")
59
+ if X.shape[1] == 0:
60
+ raise ValueError("X must contain at least one feature")
61
+ frame = X.copy()
62
+ for col in frame.columns:
63
+ frame[col] = pd.to_numeric(frame[col], errors="coerce")
64
+ return frame
65
+
66
+
67
+ def _check_method(method: str) -> str:
68
+ if method not in _ALLOWED_LINKAGE_METHODS:
69
+ raise ValueError(
70
+ "method must be one of {'complete', 'average', 'single'} for "
71
+ "precomputed correlation distances"
72
+ )
73
+ return method
74
+
75
+
76
+ def _correlation_distance(X: pd.DataFrame, corr_method: str = "spearman") -> pd.DataFrame:
77
+ frame = _as_numeric_frame(X)
78
+ corr = frame.corr(method=corr_method).abs()
79
+ corr = corr.reindex(index=frame.columns, columns=frame.columns).fillna(0.0)
80
+ corr = corr.clip(lower=0.0, upper=1.0)
81
+ np.fill_diagonal(corr.values, 1.0)
82
+ dist = 1.0 - corr
83
+ np.fill_diagonal(dist.values, 0.0)
84
+ return dist
85
+
86
+
87
+ def compute_correlation_linkage(
88
+ X: pd.DataFrame,
89
+ method: str = "complete",
90
+ corr_method: str = "spearman",
91
+ ) -> np.ndarray:
92
+ """Return a scipy linkage matrix from absolute correlation distances.
93
+
94
+ Distance is defined as ``1 - abs(corr)``. Constant or all-missing columns are
95
+ retained and treated as uncorrelated with other features.
96
+ """
97
+ method = _check_method(method)
98
+ if X.shape[1] < 2:
99
+ return np.empty((0, 4), dtype=float)
100
+ dist = _correlation_distance(X, corr_method=corr_method)
101
+ condensed = squareform(dist.values, checks=False)
102
+ return linkage(condensed, method=method)
103
+
104
+
105
+ def auto_cluster(
106
+ X: pd.DataFrame,
107
+ threshold: float = 0.35,
108
+ method: str = "complete",
109
+ corr_method: str = "spearman",
110
+ min_group_size: int = 1,
111
+ ) -> Dict[str, List[str]]:
112
+ """Build data-driven feature groups using hierarchical clustering."""
113
+ if threshold < 0 or threshold > 1:
114
+ raise ValueError("threshold must be between 0 and 1")
115
+ features = list(X.columns)
116
+ if len(features) == 1:
117
+ return {"auto_cluster_1": features}
118
+
119
+ lnk = compute_correlation_linkage(X, method=method, corr_method=corr_method)
120
+ labels = fcluster(lnk, t=threshold, criterion="distance")
121
+ groups: Dict[str, List[str]] = {}
122
+ for feat, gid in zip(features, labels):
123
+ groups.setdefault(f"auto_cluster_{int(gid)}", []).append(feat)
124
+
125
+ if min_group_size > 1:
126
+ singletons = [key for key, feats in groups.items() if len(feats) < min_group_size]
127
+ merged: List[str] = []
128
+ for key in singletons:
129
+ merged.extend(groups.pop(key))
130
+ if merged:
131
+ groups["auto_singleton"] = merged
132
+ return groups
133
+
134
+
135
+ def _prior_duplicates(prior_groups: Mapping[str, Sequence[str]], features: Sequence[str]) -> Dict[str, List[str]]:
136
+ seen: Dict[str, List[str]] = {}
137
+ feature_set = set(features)
138
+ for group_name, feats in prior_groups.items():
139
+ for feat in feats:
140
+ if feat in feature_set:
141
+ seen.setdefault(feat, []).append(group_name)
142
+ return {feat: groups for feat, groups in seen.items() if len(groups) > 1}
143
+
144
+
145
+ def apply_prior(
146
+ auto_groups: Mapping[str, Sequence[str]],
147
+ prior_groups: Optional[Mapping[str, Sequence[str]]],
148
+ features: Sequence[str],
149
+ ) -> Dict[str, List[str]]:
150
+ """Merge business priors with data-driven groups.
151
+
152
+ Business priors win. Remaining features keep their automatic cluster with a
153
+ ``residual_`` prefix. Priors may contain feature names absent from X; those
154
+ are ignored. Repeated valid feature names across prior groups are rejected.
155
+ """
156
+ if not prior_groups:
157
+ return {str(k): list(v) for k, v in auto_groups.items()}
158
+
159
+ duplicates = _prior_duplicates(prior_groups, features)
160
+ if duplicates:
161
+ detail = "; ".join(f"{feat}: {groups}" for feat, groups in duplicates.items())
162
+ raise ValueError(f"Features appear in multiple prior groups: {detail}")
163
+
164
+ assigned = set()
165
+ merged: Dict[str, List[str]] = {}
166
+ feature_set = set(features)
167
+
168
+ for group_name, feats in prior_groups.items():
169
+ valid = [feat for feat in feats if feat in feature_set]
170
+ if valid:
171
+ merged[str(group_name)] = valid
172
+ assigned.update(valid)
173
+
174
+ for group_name, feats in auto_groups.items():
175
+ leftover = [feat for feat in feats if feat not in assigned]
176
+ if leftover:
177
+ key = f"residual_{group_name}"
178
+ merged.setdefault(key, []).extend(leftover)
179
+ assigned.update(leftover)
180
+
181
+ uncovered = [feat for feat in features if feat not in assigned]
182
+ if uncovered:
183
+ merged["ungrouped"] = uncovered
184
+ return merged
185
+
186
+
187
+ def validate_groups(groups: Mapping[str, Sequence[str]], features: Sequence[str], raise_error: bool = True) -> bool:
188
+ """Validate complete, non-overlapping feature coverage."""
189
+ all_assigned = [feat for feats in groups.values() for feat in feats]
190
+ feature_set = set(features)
191
+ missing = feature_set - set(all_assigned)
192
+ unknown = set(all_assigned) - feature_set
193
+ duplicated = {feat for feat in all_assigned if all_assigned.count(feat) > 1}
194
+ ok = not missing and not duplicated and not unknown
195
+ if not ok and raise_error:
196
+ parts = []
197
+ if missing:
198
+ parts.append(f"missing={sorted(missing)}")
199
+ if duplicated:
200
+ parts.append(f"duplicated={sorted(duplicated)}")
201
+ if unknown:
202
+ parts.append(f"unknown={sorted(unknown)}")
203
+ raise ValueError("Invalid coalition groups: " + ", ".join(parts))
204
+ return ok
205
+
206
+
207
+ def group_correlation_summary(
208
+ X: pd.DataFrame,
209
+ groups: Mapping[str, Sequence[str]],
210
+ corr_method: str = "spearman",
211
+ ) -> pd.DataFrame:
212
+ """Summarize within-group absolute correlation."""
213
+ frame = _as_numeric_frame(X)
214
+ corr = frame.corr(method=corr_method).abs().reindex(index=frame.columns, columns=frame.columns).fillna(0.0)
215
+ rows = []
216
+ for group_name, feats in groups.items():
217
+ valid = [feat for feat in feats if feat in corr.columns]
218
+ if len(valid) > 1:
219
+ sub = corr.loc[valid, valid].values
220
+ vals = sub[np.triu_indices_from(sub, k=1)]
221
+ avg_corr = float(np.nanmean(vals)) if vals.size else float("nan")
222
+ max_corr = float(np.nanmax(vals)) if vals.size else float("nan")
223
+ else:
224
+ avg_corr = float("nan")
225
+ max_corr = float("nan")
226
+ rows.append(
227
+ {
228
+ "group": group_name,
229
+ "n_features": len(valid),
230
+ "mean_abs_corr": round(avg_corr, 3) if np.isfinite(avg_corr) else np.nan,
231
+ "max_abs_corr": round(max_corr, 3) if np.isfinite(max_corr) else np.nan,
232
+ "features": valid,
233
+ }
234
+ )
235
+ return pd.DataFrame(rows).set_index("group")
236
+
237
+
238
+ def groups_to_shap_clustering(
239
+ groups: Mapping[str, Sequence[str]],
240
+ features: Sequence[str],
241
+ intra_dist: float = 0.01,
242
+ inter_dist: float = 0.99,
243
+ ) -> np.ndarray:
244
+ """Convert feature groups to the linkage matrix used by SHAP Partition."""
245
+ features = list(features)
246
+ validate_groups(groups, features, raise_error=True)
247
+ n = len(features)
248
+ if n < 2:
249
+ return np.empty((0, 4), dtype=float)
250
+
251
+ feat_idx = {feat: idx for idx, feat in enumerate(features)}
252
+ dist_mat = np.full((n, n), float(inter_dist), dtype=float)
253
+ np.fill_diagonal(dist_mat, 0.0)
254
+
255
+ for feats in groups.values():
256
+ idxs = [feat_idx[feat] for feat in feats if feat in feat_idx]
257
+ for i in idxs:
258
+ for j in idxs:
259
+ if i != j:
260
+ dist_mat[i, j] = float(intra_dist)
261
+
262
+ condensed = squareform(dist_mat, checks=False)
263
+ return linkage(condensed, method="complete")
264
+
265
+
266
+ def build_coalition_structure(
267
+ X: pd.DataFrame,
268
+ prior_groups: Optional[Mapping[str, Sequence[str]]] = None,
269
+ threshold: float = 0.35,
270
+ method: str = "complete",
271
+ corr_method: str = "spearman",
272
+ min_group_size: int = 1,
273
+ intra_dist: float = 0.01,
274
+ inter_dist: float = 0.99,
275
+ ) -> dict:
276
+ """Build a complete coalition structure for Owen Value explanations.
277
+
278
+ Returns a dict containing final groups, automatic groups, correlation
279
+ linkage, SHAP-compatible linkage, and a within-group correlation summary.
280
+ """
281
+ frame = _as_numeric_frame(X)
282
+ features = list(frame.columns)
283
+ corr_lnk = compute_correlation_linkage(frame, method=method, corr_method=corr_method)
284
+ auto = auto_cluster(
285
+ frame,
286
+ threshold=threshold,
287
+ method=method,
288
+ corr_method=corr_method,
289
+ min_group_size=min_group_size,
290
+ )
291
+ final = apply_prior(auto, prior_groups, features)
292
+ validate_groups(final, features, raise_error=True)
293
+ summary = group_correlation_summary(frame, final, corr_method=corr_method)
294
+ shap_lnk = groups_to_shap_clustering(final, features, intra_dist=intra_dist, inter_dist=inter_dist)
295
+ return {
296
+ "groups": final,
297
+ "shap_lnk": shap_lnk,
298
+ "corr_lnk": corr_lnk,
299
+ "auto_groups": auto,
300
+ "summary": summary,
301
+ "features": features,
302
+ "threshold": threshold,
303
+ "method": method,
304
+ "corr_method": corr_method,
305
+ }