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,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
|
+
}
|