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,1198 @@
|
|
|
1
|
+
# Logistic Regression Model Toolkit
|
|
2
|
+
# Optimized version with complete docstrings and class wrapper
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.linear_model import LogisticRegression
|
|
7
|
+
import logging
|
|
8
|
+
from Modeling_Tool.Core.sample_weight_utils import resolve_sample_weight
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _sanitize_lr_params(params):
|
|
14
|
+
"""
|
|
15
|
+
Sanitize LogisticRegression parameters for cross-version sklearn compatibility.
|
|
16
|
+
|
|
17
|
+
Some sklearn versions expose fitted LogisticRegression.get_params() with
|
|
18
|
+
multi_class='deprecated', while older sklearn versions only accept
|
|
19
|
+
{'auto', 'ovr', 'multinomial'} during fit/clone.
|
|
20
|
+
"""
|
|
21
|
+
params = {} if params is None else dict(params)
|
|
22
|
+
if params.get("multi_class") == "deprecated":
|
|
23
|
+
params["multi_class"] = "auto"
|
|
24
|
+
return params
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _patch_calibrated_model(cal_model):
|
|
28
|
+
"""
|
|
29
|
+
Backward-compatibility patch for pickled calibrated classifiers.
|
|
30
|
+
|
|
31
|
+
sklearn 1.2 renamed the constructor parameter (and instance attribute)
|
|
32
|
+
``base_estimator`` to ``estimator``. Models serialised with sklearn <= 1.1
|
|
33
|
+
may therefore lack the ``estimator`` attribute on both the outer
|
|
34
|
+
``CalibratedClassifierCV`` and its fitted ``_CalibratedClassifier``
|
|
35
|
+
instances, causing::
|
|
36
|
+
|
|
37
|
+
AttributeError: '_CalibratedClassifier' object has no attribute 'estimator'
|
|
38
|
+
|
|
39
|
+
Add the new alias before any calibrated prediction method is called. This
|
|
40
|
+
is a no-op for models already using the current sklearn attribute layout.
|
|
41
|
+
"""
|
|
42
|
+
if cal_model is None:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
if not hasattr(cal_model, 'estimator') and hasattr(cal_model, 'base_estimator'):
|
|
46
|
+
cal_model.estimator = cal_model.base_estimator
|
|
47
|
+
|
|
48
|
+
for classifier in getattr(cal_model, 'calibrated_classifiers_', []):
|
|
49
|
+
if not hasattr(classifier, 'estimator') and hasattr(classifier, 'base_estimator'):
|
|
50
|
+
classifier.estimator = classifier.base_estimator
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def lr_model(mdlx, mdly, valx, valy, params_dict, sample_weight=None):
|
|
54
|
+
"""
|
|
55
|
+
Train a Logistic Regression model.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
mdlx : pandas.DataFrame or numpy.ndarray
|
|
60
|
+
Training feature matrix
|
|
61
|
+
mdly : pandas.Series or numpy.ndarray
|
|
62
|
+
Training target variable
|
|
63
|
+
valx : pandas.DataFrame or numpy.ndarray
|
|
64
|
+
Validation feature matrix (used for reference only)
|
|
65
|
+
valy : pandas.Series or numpy.ndarray
|
|
66
|
+
Validation target variable (used for reference only)
|
|
67
|
+
params_dict : dict
|
|
68
|
+
Dictionary of parameters for LogisticRegression
|
|
69
|
+
sample_weight : array-like, optional
|
|
70
|
+
Per-sample weights passed to ``LogisticRegression.fit``.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
sklearn.linear_model.LogisticRegression
|
|
75
|
+
Trained logistic regression model
|
|
76
|
+
"""
|
|
77
|
+
params_dict = _sanitize_lr_params(params_dict)
|
|
78
|
+
model = LogisticRegression(**params_dict)
|
|
79
|
+
model.fit(mdlx, mdly, sample_weight=sample_weight)
|
|
80
|
+
return model
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def lr_varimp(model):
|
|
84
|
+
"""
|
|
85
|
+
Get variable importance from a Logistic Regression model.
|
|
86
|
+
|
|
87
|
+
Computes the absolute value of the model coefficients as a measure of
|
|
88
|
+
variable importance.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
model : sklearn.linear_model.LogisticRegression
|
|
93
|
+
Trained logistic regression model
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
pandas.DataFrame
|
|
98
|
+
DataFrame with columns ['varlist', 'coef', 'importance'] sorted by
|
|
99
|
+
importance in descending order
|
|
100
|
+
"""
|
|
101
|
+
if hasattr(model, 'feature_names_in_'):
|
|
102
|
+
varnames = model.feature_names_in_.tolist()
|
|
103
|
+
else:
|
|
104
|
+
varnames = [f'x{i}' for i in range(len(model.coef_[0]))]
|
|
105
|
+
|
|
106
|
+
varimp_df = pd.DataFrame({
|
|
107
|
+
'varlist': varnames,
|
|
108
|
+
'coef': model.coef_[0],
|
|
109
|
+
'importance': np.abs(model.coef_[0])
|
|
110
|
+
})
|
|
111
|
+
return varimp_df.sort_values('importance', ascending=False).reset_index(drop=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_lr_statsmodel_summary(model, x, y, feature_names=None):
|
|
115
|
+
"""
|
|
116
|
+
Generate a statsmodels-style summary for a sklearn LogisticRegression model.
|
|
117
|
+
|
|
118
|
+
Computes standard errors, z-scores, p-values, and confidence intervals
|
|
119
|
+
for the logistic regression coefficients using the observed Fisher information
|
|
120
|
+
matrix.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
model : sklearn.linear_model.LogisticRegression
|
|
125
|
+
Trained logistic regression model
|
|
126
|
+
x : pandas.DataFrame or numpy.ndarray
|
|
127
|
+
Feature matrix used for training
|
|
128
|
+
y : pandas.Series or numpy.ndarray
|
|
129
|
+
Target variable used for training
|
|
130
|
+
feature_names : list of str, optional
|
|
131
|
+
Feature names (inferred from x if not provided)
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
pandas.DataFrame
|
|
136
|
+
Summary table with columns: ['coef', 'std_err', 'z', 'p_value',
|
|
137
|
+
'ci_lower', 'ci_upper']
|
|
138
|
+
"""
|
|
139
|
+
from scipy import stats
|
|
140
|
+
|
|
141
|
+
if feature_names is None:
|
|
142
|
+
if hasattr(x, 'columns'):
|
|
143
|
+
feature_names = x.columns.tolist()
|
|
144
|
+
elif hasattr(model, 'feature_names_in_'):
|
|
145
|
+
feature_names = model.feature_names_in_.tolist()
|
|
146
|
+
else:
|
|
147
|
+
feature_names = [f'x{i}' for i in range(x.shape[1])]
|
|
148
|
+
|
|
149
|
+
x_arr = x.values if hasattr(x, 'values') else np.array(x)
|
|
150
|
+
y_arr = y.values if hasattr(y, 'values') else np.array(y)
|
|
151
|
+
|
|
152
|
+
prob = model.predict_proba(x_arr)[:, 1]
|
|
153
|
+
w = prob * (1 - prob)
|
|
154
|
+
W = np.diag(w)
|
|
155
|
+
X_design = np.hstack([np.ones((x_arr.shape[0], 1)), x_arr])
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
cov_matrix = np.linalg.inv(X_design.T @ W @ X_design)
|
|
159
|
+
except np.linalg.LinAlgError:
|
|
160
|
+
cov_matrix = np.linalg.pinv(X_design.T @ W @ X_design)
|
|
161
|
+
|
|
162
|
+
intercept = model.intercept_[0]
|
|
163
|
+
coefs = model.coef_[0]
|
|
164
|
+
all_coefs = np.concatenate([[intercept], coefs])
|
|
165
|
+
|
|
166
|
+
std_errs = np.sqrt(np.diag(cov_matrix))
|
|
167
|
+
z_scores = all_coefs / std_errs
|
|
168
|
+
p_values = 2 * (1 - stats.norm.cdf(np.abs(z_scores)))
|
|
169
|
+
ci_lower = all_coefs - 1.96 * std_errs
|
|
170
|
+
ci_upper = all_coefs + 1.96 * std_errs
|
|
171
|
+
|
|
172
|
+
all_names = ['Intercept'] + feature_names
|
|
173
|
+
|
|
174
|
+
summary_df = pd.DataFrame({
|
|
175
|
+
'coef': all_coefs,
|
|
176
|
+
'std_err': std_errs,
|
|
177
|
+
'z': z_scores,
|
|
178
|
+
'p_value': p_values,
|
|
179
|
+
'ci_lower': ci_lower,
|
|
180
|
+
'ci_upper': ci_upper
|
|
181
|
+
}, index=all_names)
|
|
182
|
+
|
|
183
|
+
return summary_df
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _compute_log_likelihood(model, x, y, sample_weight=None):
|
|
187
|
+
"""Compute log-likelihood for a fitted logistic regression model."""
|
|
188
|
+
x_arr = x.values if hasattr(x, 'values') else np.array(x)
|
|
189
|
+
y_arr = y.values if hasattr(y, 'values') else np.array(y)
|
|
190
|
+
weight = None if sample_weight is None else np.asarray(sample_weight, dtype=float)
|
|
191
|
+
|
|
192
|
+
prob = model.predict_proba(x_arr)[:, 1]
|
|
193
|
+
prob = np.clip(prob, 1e-15, 1 - 1e-15)
|
|
194
|
+
point_ll = y_arr * np.log(prob) + (1 - y_arr) * np.log(1 - prob)
|
|
195
|
+
if weight is None:
|
|
196
|
+
return float(np.sum(point_ll))
|
|
197
|
+
return float(np.sum(weight * point_ll))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def compute_aic(model, x, y, sample_weight=None):
|
|
201
|
+
"""
|
|
202
|
+
Compute AIC (Akaike Information Criterion) for a logistic regression model.
|
|
203
|
+
|
|
204
|
+
Parameters
|
|
205
|
+
----------
|
|
206
|
+
model : sklearn.linear_model.LogisticRegression
|
|
207
|
+
Fitted logistic regression model
|
|
208
|
+
x : pandas.DataFrame or numpy.ndarray
|
|
209
|
+
Feature matrix
|
|
210
|
+
y : pandas.Series or numpy.ndarray
|
|
211
|
+
Target variable
|
|
212
|
+
sample_weight : array-like, optional
|
|
213
|
+
Per-sample weights for log-likelihood / information criteria.
|
|
214
|
+
|
|
215
|
+
Returns
|
|
216
|
+
-------
|
|
217
|
+
float
|
|
218
|
+
AIC value (lower is better)
|
|
219
|
+
"""
|
|
220
|
+
log_likelihood = _compute_log_likelihood(model, x, y, sample_weight=sample_weight)
|
|
221
|
+
k = model.coef_.shape[1] + 1 # number of params including intercept
|
|
222
|
+
aic = 2 * k - 2 * log_likelihood
|
|
223
|
+
return aic
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def compute_bic(model, x, y, sample_weight=None):
|
|
227
|
+
"""
|
|
228
|
+
Compute BIC (Bayesian Information Criterion) for a logistic regression model.
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
model : sklearn.linear_model.LogisticRegression
|
|
233
|
+
Fitted logistic regression model
|
|
234
|
+
x : pandas.DataFrame or numpy.ndarray
|
|
235
|
+
Feature matrix
|
|
236
|
+
y : pandas.Series or numpy.ndarray
|
|
237
|
+
Target variable
|
|
238
|
+
sample_weight : array-like, optional
|
|
239
|
+
Per-sample weights for log-likelihood / information criteria.
|
|
240
|
+
|
|
241
|
+
Returns
|
|
242
|
+
-------
|
|
243
|
+
float
|
|
244
|
+
BIC value (lower is better)
|
|
245
|
+
"""
|
|
246
|
+
x_arr = x.values if hasattr(x, 'values') else np.array(x)
|
|
247
|
+
weight = None if sample_weight is None else np.asarray(sample_weight, dtype=float)
|
|
248
|
+
log_likelihood = _compute_log_likelihood(model, x, y, sample_weight=weight)
|
|
249
|
+
k = model.coef_.shape[1] + 1
|
|
250
|
+
n = float(np.sum(weight)) if weight is not None else x_arr.shape[0]
|
|
251
|
+
bic = k * np.log(n) - 2 * log_likelihood
|
|
252
|
+
return bic
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class FeatureSelectionAnalyzer:
|
|
256
|
+
"""
|
|
257
|
+
Feature selection analyzer using statistical tests.
|
|
258
|
+
|
|
259
|
+
Analyzes feature relevance using chi-squared tests, correlation analysis,
|
|
260
|
+
and variance inflation factor (VIF) for multicollinearity detection.
|
|
261
|
+
|
|
262
|
+
Parameters
|
|
263
|
+
----------
|
|
264
|
+
significance_level : float, default 0.05
|
|
265
|
+
Significance level for statistical tests
|
|
266
|
+
|
|
267
|
+
Examples
|
|
268
|
+
--------
|
|
269
|
+
>>> analyzer = FeatureSelectionAnalyzer(significance_level=0.05)
|
|
270
|
+
>>> results = analyzer.chi2_selection(train_df, feature_cols, 'target')
|
|
271
|
+
>>> vif_df = analyzer.compute_vif(train_df[feature_cols])
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def __init__(self, significance_level=0.05):
|
|
275
|
+
"""
|
|
276
|
+
Initialize FeatureSelectionAnalyzer.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
significance_level : float, default 0.05
|
|
281
|
+
Significance level threshold for feature selection
|
|
282
|
+
"""
|
|
283
|
+
self.significance_level = significance_level
|
|
284
|
+
self.selected_features_ = None
|
|
285
|
+
self.chi2_results_ = None
|
|
286
|
+
|
|
287
|
+
def chi2_selection(self, data, feature_cols, target_col):
|
|
288
|
+
"""
|
|
289
|
+
Select features using chi-squared test.
|
|
290
|
+
|
|
291
|
+
Parameters
|
|
292
|
+
----------
|
|
293
|
+
data : pd.DataFrame
|
|
294
|
+
Input data
|
|
295
|
+
feature_cols : list of str
|
|
296
|
+
Feature column names to evaluate
|
|
297
|
+
target_col : str
|
|
298
|
+
Target variable column name
|
|
299
|
+
|
|
300
|
+
Returns
|
|
301
|
+
-------
|
|
302
|
+
pd.DataFrame
|
|
303
|
+
Results with columns ['feature', 'chi2', 'p_value', 'selected']
|
|
304
|
+
"""
|
|
305
|
+
from sklearn.feature_selection import chi2
|
|
306
|
+
from sklearn.preprocessing import MinMaxScaler
|
|
307
|
+
|
|
308
|
+
x = data[feature_cols].fillna(0)
|
|
309
|
+
y = data[target_col]
|
|
310
|
+
|
|
311
|
+
scaler = MinMaxScaler()
|
|
312
|
+
x_scaled = scaler.fit_transform(x)
|
|
313
|
+
|
|
314
|
+
chi2_vals, p_vals = chi2(x_scaled, y)
|
|
315
|
+
|
|
316
|
+
results = pd.DataFrame({
|
|
317
|
+
'feature': feature_cols,
|
|
318
|
+
'chi2': chi2_vals,
|
|
319
|
+
'p_value': p_vals,
|
|
320
|
+
'selected': p_vals < self.significance_level
|
|
321
|
+
}).sort_values('chi2', ascending=False).reset_index(drop=True)
|
|
322
|
+
|
|
323
|
+
self.chi2_results_ = results
|
|
324
|
+
self.selected_features_ = results.loc[results['selected'], 'feature'].tolist()
|
|
325
|
+
return results
|
|
326
|
+
|
|
327
|
+
def compute_vif(self, data):
|
|
328
|
+
"""
|
|
329
|
+
Compute Variance Inflation Factor (VIF) for multicollinearity detection.
|
|
330
|
+
|
|
331
|
+
Parameters
|
|
332
|
+
----------
|
|
333
|
+
data : pd.DataFrame
|
|
334
|
+
Feature matrix (should not include target variable)
|
|
335
|
+
|
|
336
|
+
Returns
|
|
337
|
+
-------
|
|
338
|
+
pd.DataFrame
|
|
339
|
+
DataFrame with columns ['feature', 'VIF'] sorted by VIF descending
|
|
340
|
+
"""
|
|
341
|
+
from statsmodels.stats.outliers_influence import variance_inflation_factor
|
|
342
|
+
|
|
343
|
+
x = data.fillna(0).values
|
|
344
|
+
vif_data = pd.DataFrame({
|
|
345
|
+
'feature': data.columns,
|
|
346
|
+
'VIF': [variance_inflation_factor(x, i) for i in range(x.shape[1])]
|
|
347
|
+
}).sort_values('VIF', ascending=False).reset_index(drop=True)
|
|
348
|
+
|
|
349
|
+
return vif_data
|
|
350
|
+
|
|
351
|
+
def correlation_filter(self, data, threshold=0.8):
|
|
352
|
+
"""
|
|
353
|
+
Remove highly correlated features.
|
|
354
|
+
|
|
355
|
+
Parameters
|
|
356
|
+
----------
|
|
357
|
+
data : pd.DataFrame
|
|
358
|
+
Feature matrix
|
|
359
|
+
threshold : float, default 0.8
|
|
360
|
+
Correlation threshold above which features are removed
|
|
361
|
+
|
|
362
|
+
Returns
|
|
363
|
+
-------
|
|
364
|
+
list of str
|
|
365
|
+
List of features to keep (low correlation subset)
|
|
366
|
+
"""
|
|
367
|
+
corr_matrix = data.corr().abs()
|
|
368
|
+
upper = corr_matrix.where(np.triu(np.ones(corr_matrix.shape), k=1).astype(bool))
|
|
369
|
+
to_drop = [col for col in upper.columns if any(upper[col] > threshold)]
|
|
370
|
+
return [col for col in data.columns if col not in to_drop]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class LRMaster:
|
|
374
|
+
"""
|
|
375
|
+
Logistic Regression Master Class.
|
|
376
|
+
|
|
377
|
+
A unified wrapper for logistic regression modeling that encapsulates:
|
|
378
|
+
- Model training and prediction
|
|
379
|
+
- Variable importance analysis
|
|
380
|
+
- Statistical summary generation
|
|
381
|
+
- Stepwise variable selection
|
|
382
|
+
- Holdout-based hyperparameter grid search
|
|
383
|
+
- AIC/BIC calculation
|
|
384
|
+
- Optional feature standardization (off by default)
|
|
385
|
+
|
|
386
|
+
Parameters
|
|
387
|
+
----------
|
|
388
|
+
params : dict, optional
|
|
389
|
+
Parameters for sklearn LogisticRegression, e.g., {'C': 1.0, 'solver': 'lbfgs'}
|
|
390
|
+
|
|
391
|
+
Attributes
|
|
392
|
+
----------
|
|
393
|
+
params : dict
|
|
394
|
+
Model parameters
|
|
395
|
+
model : sklearn.linear_model.LogisticRegression
|
|
396
|
+
Trained model (None until fit() is called)
|
|
397
|
+
varlist : list
|
|
398
|
+
List of feature names
|
|
399
|
+
tgt_name : str
|
|
400
|
+
Target variable name
|
|
401
|
+
standardize : bool
|
|
402
|
+
Whether feature standardization is enabled
|
|
403
|
+
standardizer : sklearn-like scaler or None
|
|
404
|
+
Fitted scaler (None until fit() runs with standardize=True)
|
|
405
|
+
best_params_ : dict or None
|
|
406
|
+
Best hyperparameters from grid_search_params (None until it runs)
|
|
407
|
+
search_results_ : pandas.DataFrame or None
|
|
408
|
+
Full grid_search_params results table (None until it runs)
|
|
409
|
+
|
|
410
|
+
Examples
|
|
411
|
+
--------
|
|
412
|
+
>>> lr = LRMaster(params={'C': 1.0, 'solver': 'lbfgs'})
|
|
413
|
+
>>> lr.fit(train_df, ['age', 'income'], 'target')
|
|
414
|
+
>>> predictions = lr.predict(test_df)
|
|
415
|
+
>>> importance = lr.get_variable_importance()
|
|
416
|
+
|
|
417
|
+
>>> # With standardization (defaults to StandardScaler)
|
|
418
|
+
>>> lr = LRMaster(params={'C': 1.0}, standardize=True)
|
|
419
|
+
>>> lr.fit(train_df, ['age', 'income'], 'target')
|
|
420
|
+
>>> proba = lr.predict_proba(test_df) # test_df is scaled with the fitted scaler
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
def __init__(self, params=None, model=None, varlist=None, tgt_name=None,
|
|
424
|
+
standardize=False, scaler=None):
|
|
425
|
+
"""
|
|
426
|
+
Initialize LRMaster instance.
|
|
427
|
+
|
|
428
|
+
Parameters
|
|
429
|
+
----------
|
|
430
|
+
params : dict, optional
|
|
431
|
+
LogisticRegression parameters
|
|
432
|
+
model : sklearn-like LogisticRegression object, optional
|
|
433
|
+
Existing fitted LR model object. If provided, LRMaster will wrap this model directly.
|
|
434
|
+
varlist : list, optional
|
|
435
|
+
Feature names used by the existing model. Required when model does not have
|
|
436
|
+
`feature_names_in_`.
|
|
437
|
+
tgt_name : str, optional
|
|
438
|
+
Target variable name. Useful when wrapping an existing fitted model and later
|
|
439
|
+
calling summary/evaluation methods.
|
|
440
|
+
standardize : bool, default False
|
|
441
|
+
If True, fit a scaler on the training features during `fit` /
|
|
442
|
+
`stepwise_selection` and apply it consistently in every prediction /
|
|
443
|
+
evaluation entry point. Default False keeps the original behavior
|
|
444
|
+
(no standardization) for full backward compatibility.
|
|
445
|
+
scaler : sklearn-like transformer, optional
|
|
446
|
+
Custom scaler prototype to use when `standardize=True` (e.g.
|
|
447
|
+
`MinMaxScaler()`). The prototype is cloned before fitting, so the
|
|
448
|
+
passed instance is never mutated. Defaults to `StandardScaler` when
|
|
449
|
+
not provided.
|
|
450
|
+
"""
|
|
451
|
+
self.params = _sanitize_lr_params(params)
|
|
452
|
+
self.model = model
|
|
453
|
+
self.calibrated_model = None
|
|
454
|
+
self.varlist = varlist
|
|
455
|
+
self.tgt_name = tgt_name
|
|
456
|
+
self._data = None
|
|
457
|
+
self.standardize = standardize
|
|
458
|
+
# Unfitted prototype used to derive the fitted scaler during fit().
|
|
459
|
+
self._scaler_proto = scaler
|
|
460
|
+
# Fitted scaler; stays None until fit()/stepwise runs with standardize=True.
|
|
461
|
+
self.standardizer = None
|
|
462
|
+
# Populated by grid_search_params(): best param dict + full results table.
|
|
463
|
+
self.best_params_ = None
|
|
464
|
+
self.search_results_ = None
|
|
465
|
+
|
|
466
|
+
def _make_scaler(self):
|
|
467
|
+
"""
|
|
468
|
+
Return a fresh, unfitted scaler instance for standardization.
|
|
469
|
+
|
|
470
|
+
Uses the user-provided `scaler` prototype when given (cloned so the
|
|
471
|
+
original stays unfitted); otherwise defaults to `StandardScaler`.
|
|
472
|
+
"""
|
|
473
|
+
from sklearn.preprocessing import StandardScaler
|
|
474
|
+
if self._scaler_proto is not None:
|
|
475
|
+
from sklearn.base import clone as _sk_clone
|
|
476
|
+
return _sk_clone(self._scaler_proto)
|
|
477
|
+
return StandardScaler()
|
|
478
|
+
|
|
479
|
+
def _fit_standardizer(self, x):
|
|
480
|
+
"""
|
|
481
|
+
Fit a scaler on `x` and store it as `self.standardizer`.
|
|
482
|
+
|
|
483
|
+
Returns the standardized `x` (a DataFrame when `x` is one). When
|
|
484
|
+
`self.standardize` is False this is a no-op that clears any scaler and
|
|
485
|
+
returns `x` unchanged.
|
|
486
|
+
"""
|
|
487
|
+
if not self.standardize:
|
|
488
|
+
self.standardizer = None
|
|
489
|
+
return x
|
|
490
|
+
scaler = self._make_scaler()
|
|
491
|
+
scaler.fit(x)
|
|
492
|
+
self.standardizer = scaler
|
|
493
|
+
return self._apply_standardizer(x)
|
|
494
|
+
|
|
495
|
+
def _apply_standardizer(self, x):
|
|
496
|
+
"""
|
|
497
|
+
Apply the fitted standardizer to `x`, preserving DataFrame layout.
|
|
498
|
+
|
|
499
|
+
No-op when no fitted standardizer is present (e.g. `standardize=False`
|
|
500
|
+
or when wrapping an externally fitted model), so existing behavior is
|
|
501
|
+
unchanged unless standardization was explicitly enabled and fitted.
|
|
502
|
+
"""
|
|
503
|
+
if self.standardizer is None:
|
|
504
|
+
return x
|
|
505
|
+
values = self.standardizer.transform(x)
|
|
506
|
+
if hasattr(x, 'columns'):
|
|
507
|
+
return pd.DataFrame(values, columns=x.columns, index=x.index)
|
|
508
|
+
return values
|
|
509
|
+
|
|
510
|
+
def set_data(self, data):
|
|
511
|
+
"""
|
|
512
|
+
Store reference data for later use (e.g., calibration).
|
|
513
|
+
|
|
514
|
+
Parameters
|
|
515
|
+
----------
|
|
516
|
+
data : pd.DataFrame
|
|
517
|
+
Training data to store
|
|
518
|
+
|
|
519
|
+
Returns
|
|
520
|
+
-------
|
|
521
|
+
self
|
|
522
|
+
"""
|
|
523
|
+
self._data = data
|
|
524
|
+
return self
|
|
525
|
+
|
|
526
|
+
def fit(self, data, varlist, tgt_name, val_data=None, val_varlist=None, val_tgt_name=None, weight_col=None):
|
|
527
|
+
"""
|
|
528
|
+
Train the logistic regression model.
|
|
529
|
+
|
|
530
|
+
When `standardize=True`, a scaler is fitted on the training features and
|
|
531
|
+
stored as `self.standardizer`; the model is then trained on the scaled
|
|
532
|
+
features. The same scaler is reused at prediction / evaluation time.
|
|
533
|
+
|
|
534
|
+
Parameters
|
|
535
|
+
----------
|
|
536
|
+
data : pd.DataFrame
|
|
537
|
+
Training dataset containing features and target
|
|
538
|
+
varlist : list of str
|
|
539
|
+
Feature column names to use for training
|
|
540
|
+
tgt_name : str
|
|
541
|
+
Target variable column name
|
|
542
|
+
val_data : pd.DataFrame, optional
|
|
543
|
+
Validation dataset (currently used for reference; not used in fitting)
|
|
544
|
+
val_varlist : list of str, optional
|
|
545
|
+
Validation feature column names
|
|
546
|
+
val_tgt_name : str, optional
|
|
547
|
+
Validation target variable column name
|
|
548
|
+
weight_col : str, optional
|
|
549
|
+
Column in ``data`` with per-sample training weights (non-negative).
|
|
550
|
+
Mutually exclusive with passing ``sample_weight`` to lower-level helpers.
|
|
551
|
+
|
|
552
|
+
Returns
|
|
553
|
+
-------
|
|
554
|
+
self
|
|
555
|
+
"""
|
|
556
|
+
self.varlist = varlist
|
|
557
|
+
self.tgt_name = tgt_name
|
|
558
|
+
self._data = data
|
|
559
|
+
|
|
560
|
+
train_x = data[varlist]
|
|
561
|
+
if self.standardize:
|
|
562
|
+
train_x = self._fit_standardizer(train_x)
|
|
563
|
+
else:
|
|
564
|
+
self.standardizer = None
|
|
565
|
+
|
|
566
|
+
val_x = val_data[val_varlist] if val_data is not None and val_varlist is not None else None
|
|
567
|
+
val_y = val_data[val_tgt_name] if val_data is not None and val_tgt_name is not None else None
|
|
568
|
+
if val_x is not None:
|
|
569
|
+
val_x = self._apply_standardizer(val_x)
|
|
570
|
+
|
|
571
|
+
sample_weight = resolve_sample_weight(data=data, weight_col=weight_col, expected_len=len(data))
|
|
572
|
+
self.model = lr_model(train_x, data[tgt_name], val_x, val_y, self.params, sample_weight=sample_weight)
|
|
573
|
+
return self
|
|
574
|
+
|
|
575
|
+
def calibrate_model(self, model=None, train_df=None, method='sigmoid', cv=5, weight_col=None, sample_weight=None):
|
|
576
|
+
"""Model calibration with optional sample weights."""
|
|
577
|
+
from sklearn.calibration import CalibratedClassifierCV
|
|
578
|
+
from sklearn.base import clone
|
|
579
|
+
|
|
580
|
+
if train_df is None:
|
|
581
|
+
train_df = self._data
|
|
582
|
+
|
|
583
|
+
if model is None:
|
|
584
|
+
model = self.model
|
|
585
|
+
|
|
586
|
+
if hasattr(model, "feature_names_in_"):
|
|
587
|
+
varlist = model.feature_names_in_.tolist()
|
|
588
|
+
elif self.varlist is not None:
|
|
589
|
+
varlist = self.varlist
|
|
590
|
+
else:
|
|
591
|
+
raise ValueError(
|
|
592
|
+
"Cannot infer feature list from model. Please provide `varlist` when initializing LRMaster."
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
if hasattr(model, "get_params") and model.get_params().get("multi_class") == "deprecated":
|
|
596
|
+
if cv == "prefit":
|
|
597
|
+
model.set_params(multi_class="auto")
|
|
598
|
+
else:
|
|
599
|
+
model = clone(model)
|
|
600
|
+
model.set_params(multi_class="auto")
|
|
601
|
+
|
|
602
|
+
if cv == "prefit" and not hasattr(model, "classes_"):
|
|
603
|
+
raise ValueError(
|
|
604
|
+
"cv='prefit' requires a fitted model with `classes_`. "
|
|
605
|
+
"Please pass a fitted LR model object or use cv=5 to refit during calibration."
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
# Standardize calibration features with the fitted scaler so the
|
|
609
|
+
# calibrated model operates in the same feature space as self.model.
|
|
610
|
+
cal_x = self._apply_standardizer(train_df[varlist])
|
|
611
|
+
|
|
612
|
+
# sklearn 1.6 deprecated cv="prefit" in favour of wrapping the fitted
|
|
613
|
+
# estimator in FrozenEstimator, and 1.8 removed "prefit" entirely. Use
|
|
614
|
+
# FrozenEstimator when available, falling back to cv="prefit" on <1.6.
|
|
615
|
+
estimator = model
|
|
616
|
+
calib_kwargs = {"method": method}
|
|
617
|
+
if cv == "prefit":
|
|
618
|
+
try:
|
|
619
|
+
from sklearn.frozen import FrozenEstimator
|
|
620
|
+
estimator = FrozenEstimator(model)
|
|
621
|
+
except ImportError:
|
|
622
|
+
calib_kwargs["cv"] = "prefit"
|
|
623
|
+
else:
|
|
624
|
+
calib_kwargs["cv"] = cv
|
|
625
|
+
|
|
626
|
+
# sklearn 1.2+ renamed base_estimator -> estimator; support both
|
|
627
|
+
try:
|
|
628
|
+
calibrated_model = CalibratedClassifierCV(estimator=estimator, **calib_kwargs)
|
|
629
|
+
except TypeError:
|
|
630
|
+
calibrated_model = CalibratedClassifierCV(base_estimator=estimator, **calib_kwargs)
|
|
631
|
+
fit_weight = resolve_sample_weight(
|
|
632
|
+
data=train_df,
|
|
633
|
+
weight_col=weight_col,
|
|
634
|
+
sample_weight=sample_weight,
|
|
635
|
+
expected_len=len(train_df),
|
|
636
|
+
)
|
|
637
|
+
calibrated_model.fit(cal_x, train_df[self.tgt_name], sample_weight=fit_weight)
|
|
638
|
+
|
|
639
|
+
self.calibrated_model = calibrated_model
|
|
640
|
+
|
|
641
|
+
return self
|
|
642
|
+
|
|
643
|
+
def eval_calibrated_outcome(self, evalset, plot=False, weight_col=None, sample_weight=None):
|
|
644
|
+
"""Evaluate calibrated vs raw probabilities on a holdout set."""
|
|
645
|
+
from sklearn.calibration import calibration_curve
|
|
646
|
+
from sklearn.metrics import brier_score_loss
|
|
647
|
+
|
|
648
|
+
y_val = evalset[self.tgt_name]
|
|
649
|
+
eval_weight = resolve_sample_weight(
|
|
650
|
+
data=evalset,
|
|
651
|
+
weight_col=weight_col,
|
|
652
|
+
sample_weight=sample_weight,
|
|
653
|
+
expected_len=len(evalset),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
# 原始概率
|
|
657
|
+
prob_raw = self.predict_proba(evalset)[:, 1]
|
|
658
|
+
# 校准后概率(Platt Scaling)
|
|
659
|
+
prob_cal = self.predict_proba(evalset, calibrated_model=True)[:, 1]
|
|
660
|
+
|
|
661
|
+
# 1. Brier Score(越小越好)
|
|
662
|
+
logger.info(
|
|
663
|
+
"Raw Brier: %.6f",
|
|
664
|
+
brier_score_loss(y_val, prob_raw, sample_weight=eval_weight),
|
|
665
|
+
)
|
|
666
|
+
logger.info(
|
|
667
|
+
"Cal Brier: %.6f",
|
|
668
|
+
brier_score_loss(y_val, prob_cal, sample_weight=eval_weight),
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
# 2. 可靠性曲线
|
|
672
|
+
curve_kwargs = {} if eval_weight is None else {"sample_weight": eval_weight}
|
|
673
|
+
try:
|
|
674
|
+
fraction_of_positives_raw, mean_predicted_value_raw = calibration_curve(
|
|
675
|
+
y_val, prob_raw, n_bins=10, **curve_kwargs
|
|
676
|
+
)
|
|
677
|
+
fraction_of_positives_cal, mean_predicted_value_cal = calibration_curve(
|
|
678
|
+
y_val, prob_cal, n_bins=10, **curve_kwargs
|
|
679
|
+
)
|
|
680
|
+
except TypeError:
|
|
681
|
+
fraction_of_positives_raw, mean_predicted_value_raw = calibration_curve(
|
|
682
|
+
y_val, prob_raw, n_bins=10
|
|
683
|
+
)
|
|
684
|
+
fraction_of_positives_cal, mean_predicted_value_cal = calibration_curve(
|
|
685
|
+
y_val, prob_cal, n_bins=10
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
if plot:
|
|
689
|
+
import matplotlib.pyplot as plt
|
|
690
|
+
plt.plot(mean_predicted_value_raw, fraction_of_positives_raw, 's-', label='Raw')
|
|
691
|
+
plt.plot(mean_predicted_value_cal, fraction_of_positives_cal, 'o-', label='Platt')
|
|
692
|
+
plt.plot([0,1], [0,1], 'k--', label='Perfect')
|
|
693
|
+
plt.xlabel('Mean Predicted Probability')
|
|
694
|
+
plt.ylabel('Fraction of Positives')
|
|
695
|
+
plt.legend()
|
|
696
|
+
plt.show()
|
|
697
|
+
|
|
698
|
+
def predict(self, data, varlist=None, calibrated_model = False):
|
|
699
|
+
"""
|
|
700
|
+
Predict using the trained model.
|
|
701
|
+
|
|
702
|
+
When standardization is enabled, the input features are scaled with the
|
|
703
|
+
scaler fitted during `fit` before being passed to the model.
|
|
704
|
+
|
|
705
|
+
Parameters
|
|
706
|
+
----------
|
|
707
|
+
data : pandas.DataFrame
|
|
708
|
+
Input data for prediction
|
|
709
|
+
varlist : list, optional
|
|
710
|
+
Feature names (uses training features if None)
|
|
711
|
+
|
|
712
|
+
Returns
|
|
713
|
+
-------
|
|
714
|
+
numpy.ndarray
|
|
715
|
+
Predicted class labels
|
|
716
|
+
"""
|
|
717
|
+
if varlist is None:
|
|
718
|
+
varlist = self.varlist
|
|
719
|
+
|
|
720
|
+
x = self._apply_standardizer(data[varlist])
|
|
721
|
+
|
|
722
|
+
if calibrated_model:
|
|
723
|
+
_patch_calibrated_model(self.calibrated_model)
|
|
724
|
+
return self.calibrated_model.predict(x)
|
|
725
|
+
|
|
726
|
+
return self.model.predict(x)
|
|
727
|
+
|
|
728
|
+
def predict_proba(self, data, varlist=None, calibrated_model = False):
|
|
729
|
+
"""
|
|
730
|
+
Predict class probabilities.
|
|
731
|
+
|
|
732
|
+
When standardization is enabled, the input features are scaled with the
|
|
733
|
+
scaler fitted during `fit` before being passed to the model.
|
|
734
|
+
|
|
735
|
+
Parameters
|
|
736
|
+
----------
|
|
737
|
+
data : pandas.DataFrame
|
|
738
|
+
Input data for prediction
|
|
739
|
+
varlist : list, optional
|
|
740
|
+
Feature names (uses training features if None)
|
|
741
|
+
|
|
742
|
+
Returns
|
|
743
|
+
-------
|
|
744
|
+
numpy.ndarray
|
|
745
|
+
Array of shape (n_samples, 2) with class probabilities
|
|
746
|
+
"""
|
|
747
|
+
if varlist is None:
|
|
748
|
+
varlist = self.varlist
|
|
749
|
+
|
|
750
|
+
x = self._apply_standardizer(data[varlist])
|
|
751
|
+
|
|
752
|
+
if calibrated_model:
|
|
753
|
+
_patch_calibrated_model(self.calibrated_model)
|
|
754
|
+
return self.calibrated_model.predict_proba(x)
|
|
755
|
+
|
|
756
|
+
return self.model.predict_proba(x)
|
|
757
|
+
|
|
758
|
+
def get_variable_importance(self):
|
|
759
|
+
"""
|
|
760
|
+
Get variable importance (coefficients) from the model.
|
|
761
|
+
|
|
762
|
+
Returns
|
|
763
|
+
-------
|
|
764
|
+
pandas.DataFrame
|
|
765
|
+
DataFrame with columns ['varlist', 'coef', 'importance'] sorted by
|
|
766
|
+
importance in descending order
|
|
767
|
+
|
|
768
|
+
Notes
|
|
769
|
+
-----
|
|
770
|
+
When standardization is enabled the coefficients are expressed in the
|
|
771
|
+
standardized feature space (i.e. they are directly comparable in
|
|
772
|
+
magnitude across features).
|
|
773
|
+
"""
|
|
774
|
+
return lr_varimp(self.model)
|
|
775
|
+
|
|
776
|
+
def get_statsmodel_summary(self, data=None, varlist=None, tgt_name=None):
|
|
777
|
+
"""
|
|
778
|
+
Generate a statsmodels-style summary for the trained LR model.
|
|
779
|
+
|
|
780
|
+
Parameters
|
|
781
|
+
----------
|
|
782
|
+
data : pd.DataFrame, optional
|
|
783
|
+
Data for computing the summary (uses stored training data if None)
|
|
784
|
+
varlist : list of str, optional
|
|
785
|
+
Feature names (uses stored varlist if None)
|
|
786
|
+
tgt_name : str, optional
|
|
787
|
+
Target variable name (uses stored tgt_name if None)
|
|
788
|
+
|
|
789
|
+
Returns
|
|
790
|
+
-------
|
|
791
|
+
pandas.DataFrame
|
|
792
|
+
Summary table with coefficients, standard errors, z-scores and p-values
|
|
793
|
+
|
|
794
|
+
Notes
|
|
795
|
+
-----
|
|
796
|
+
When standardization is enabled the summary is computed on the
|
|
797
|
+
standardized feature space, consistent with how the model was trained.
|
|
798
|
+
"""
|
|
799
|
+
if data is None:
|
|
800
|
+
data = self._data
|
|
801
|
+
if varlist is None:
|
|
802
|
+
varlist = self.varlist
|
|
803
|
+
if tgt_name is None:
|
|
804
|
+
tgt_name = self.tgt_name
|
|
805
|
+
|
|
806
|
+
return get_lr_statsmodel_summary(
|
|
807
|
+
self.model,
|
|
808
|
+
self._apply_standardizer(data[varlist]),
|
|
809
|
+
data[tgt_name],
|
|
810
|
+
feature_names=varlist
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
def get_aic(self, data=None, varlist=None, tgt_name=None, weight_col=None):
|
|
814
|
+
"""
|
|
815
|
+
Compute AIC for the trained model.
|
|
816
|
+
|
|
817
|
+
Parameters
|
|
818
|
+
----------
|
|
819
|
+
data : pd.DataFrame, optional
|
|
820
|
+
varlist : list of str, optional
|
|
821
|
+
tgt_name : str, optional
|
|
822
|
+
|
|
823
|
+
Returns
|
|
824
|
+
-------
|
|
825
|
+
float
|
|
826
|
+
"""
|
|
827
|
+
if data is None:
|
|
828
|
+
data = self._data
|
|
829
|
+
if varlist is None:
|
|
830
|
+
varlist = self.varlist
|
|
831
|
+
if tgt_name is None:
|
|
832
|
+
tgt_name = self.tgt_name
|
|
833
|
+
return compute_aic(
|
|
834
|
+
self.model,
|
|
835
|
+
self._apply_standardizer(data[varlist]),
|
|
836
|
+
data[tgt_name],
|
|
837
|
+
sample_weight=resolve_sample_weight(data=data, weight_col=weight_col, expected_len=len(data)),
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
def get_bic(self, data=None, varlist=None, tgt_name=None, weight_col=None):
|
|
841
|
+
"""
|
|
842
|
+
Compute BIC for the trained model.
|
|
843
|
+
|
|
844
|
+
Parameters
|
|
845
|
+
----------
|
|
846
|
+
data : pd.DataFrame, optional
|
|
847
|
+
varlist : list of str, optional
|
|
848
|
+
tgt_name : str, optional
|
|
849
|
+
|
|
850
|
+
Returns
|
|
851
|
+
-------
|
|
852
|
+
float
|
|
853
|
+
"""
|
|
854
|
+
if data is None:
|
|
855
|
+
data = self._data
|
|
856
|
+
if varlist is None:
|
|
857
|
+
varlist = self.varlist
|
|
858
|
+
if tgt_name is None:
|
|
859
|
+
tgt_name = self.tgt_name
|
|
860
|
+
return compute_bic(
|
|
861
|
+
self.model,
|
|
862
|
+
self._apply_standardizer(data[varlist]),
|
|
863
|
+
data[tgt_name],
|
|
864
|
+
sample_weight=resolve_sample_weight(data=data, weight_col=weight_col, expected_len=len(data)),
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
def stepwise_selection(
|
|
868
|
+
self,
|
|
869
|
+
data,
|
|
870
|
+
varlist,
|
|
871
|
+
tgt_name,
|
|
872
|
+
criterion='aic',
|
|
873
|
+
direction='both',
|
|
874
|
+
max_iter=100,
|
|
875
|
+
verbose=True,
|
|
876
|
+
weight_col=None,
|
|
877
|
+
):
|
|
878
|
+
"""
|
|
879
|
+
Perform stepwise variable selection.
|
|
880
|
+
|
|
881
|
+
Iteratively adds or removes features based on AIC/BIC improvement.
|
|
882
|
+
|
|
883
|
+
When `standardize=True`, all interim fits and the final model are
|
|
884
|
+
trained on standardized features, and the fitted scaler for the selected
|
|
885
|
+
columns is stored on the instance for later prediction.
|
|
886
|
+
|
|
887
|
+
Parameters
|
|
888
|
+
----------
|
|
889
|
+
data : pd.DataFrame
|
|
890
|
+
Training data
|
|
891
|
+
varlist : list of str
|
|
892
|
+
Initial feature list
|
|
893
|
+
tgt_name : str
|
|
894
|
+
Target variable name
|
|
895
|
+
criterion : str, default 'aic'
|
|
896
|
+
Selection criterion, 'aic' or 'bic'
|
|
897
|
+
direction : str, default 'both'
|
|
898
|
+
Direction of stepwise selection: 'forward', 'backward', or 'both'
|
|
899
|
+
max_iter : int, default 100
|
|
900
|
+
Maximum number of iterations
|
|
901
|
+
verbose : bool, default True
|
|
902
|
+
Whether to print progress
|
|
903
|
+
|
|
904
|
+
Returns
|
|
905
|
+
-------
|
|
906
|
+
list of str
|
|
907
|
+
Selected feature list
|
|
908
|
+
"""
|
|
909
|
+
if criterion == 'aic':
|
|
910
|
+
score_fn = lambda model, x, y: compute_aic(
|
|
911
|
+
model, x, y, sample_weight=resolve_sample_weight(data=data, weight_col=weight_col, expected_len=len(data))
|
|
912
|
+
)
|
|
913
|
+
else:
|
|
914
|
+
score_fn = lambda model, x, y: compute_bic(
|
|
915
|
+
model, x, y, sample_weight=resolve_sample_weight(data=data, weight_col=weight_col, expected_len=len(data))
|
|
916
|
+
)
|
|
917
|
+
sample_weight = resolve_sample_weight(data=data, weight_col=weight_col, expected_len=len(data))
|
|
918
|
+
|
|
919
|
+
# When standardizing, operate on a once-standardized feature frame.
|
|
920
|
+
# Column-wise scalers (StandardScaler / MinMaxScaler) make slicing a
|
|
921
|
+
# subset of columns equivalent to standardizing that subset.
|
|
922
|
+
if self.standardize:
|
|
923
|
+
interim_scaler = self._make_scaler()
|
|
924
|
+
interim_scaler.fit(data[varlist])
|
|
925
|
+
work = pd.DataFrame(
|
|
926
|
+
interim_scaler.transform(data[varlist]),
|
|
927
|
+
columns=list(varlist), index=data.index,
|
|
928
|
+
)
|
|
929
|
+
else:
|
|
930
|
+
work = data
|
|
931
|
+
|
|
932
|
+
current_vars = list(varlist) if direction != 'forward' else []
|
|
933
|
+
remaining_vars = list(varlist) if direction == 'forward' else []
|
|
934
|
+
|
|
935
|
+
best_model = lr_model(
|
|
936
|
+
work[current_vars] if current_vars else pd.DataFrame(index=data.index),
|
|
937
|
+
data[tgt_name], None, None, self.params, sample_weight=sample_weight
|
|
938
|
+
) if current_vars else None
|
|
939
|
+
|
|
940
|
+
best_score = score_fn(best_model, work[current_vars], data[tgt_name]) if best_model else float('inf')
|
|
941
|
+
|
|
942
|
+
for iteration in range(max_iter):
|
|
943
|
+
improved = False
|
|
944
|
+
|
|
945
|
+
# Forward step
|
|
946
|
+
if direction in ('forward', 'both') and remaining_vars:
|
|
947
|
+
scores = {}
|
|
948
|
+
for var in remaining_vars:
|
|
949
|
+
trial_vars = current_vars + [var]
|
|
950
|
+
try:
|
|
951
|
+
model = lr_model(work[trial_vars], data[tgt_name], None, None, self.params, sample_weight=sample_weight)
|
|
952
|
+
scores[var] = score_fn(model, work[trial_vars], data[tgt_name])
|
|
953
|
+
except Exception:
|
|
954
|
+
continue
|
|
955
|
+
if scores:
|
|
956
|
+
best_var = min(scores, key=scores.get)
|
|
957
|
+
if scores[best_var] < best_score:
|
|
958
|
+
current_vars.append(best_var)
|
|
959
|
+
remaining_vars.remove(best_var)
|
|
960
|
+
best_score = scores[best_var]
|
|
961
|
+
improved = True
|
|
962
|
+
if verbose:
|
|
963
|
+
logger.info(f"[Step {iteration+1}] ADD '{best_var}', {criterion.upper()}={best_score:.4f}")
|
|
964
|
+
|
|
965
|
+
# Backward step
|
|
966
|
+
if direction in ('backward', 'both') and len(current_vars) > 1:
|
|
967
|
+
scores = {}
|
|
968
|
+
for var in current_vars:
|
|
969
|
+
trial_vars = [v for v in current_vars if v != var]
|
|
970
|
+
try:
|
|
971
|
+
model = lr_model(work[trial_vars], data[tgt_name], None, None, self.params, sample_weight=sample_weight)
|
|
972
|
+
scores[var] = score_fn(model, work[trial_vars], data[tgt_name])
|
|
973
|
+
except Exception:
|
|
974
|
+
continue
|
|
975
|
+
if scores:
|
|
976
|
+
worst_var = min(scores, key=scores.get)
|
|
977
|
+
if scores[worst_var] < best_score:
|
|
978
|
+
current_vars.remove(worst_var)
|
|
979
|
+
if direction == 'both':
|
|
980
|
+
remaining_vars.append(worst_var)
|
|
981
|
+
best_score = scores[worst_var]
|
|
982
|
+
improved = True
|
|
983
|
+
if verbose:
|
|
984
|
+
logger.info(f"[Step {iteration+1}] REMOVE '{worst_var}', {criterion.upper()}={best_score:.4f}")
|
|
985
|
+
|
|
986
|
+
if not improved:
|
|
987
|
+
break
|
|
988
|
+
|
|
989
|
+
if verbose:
|
|
990
|
+
logger.info(f"Stepwise selection complete. Selected {len(current_vars)} features.")
|
|
991
|
+
|
|
992
|
+
self.varlist = current_vars
|
|
993
|
+
self.tgt_name = tgt_name
|
|
994
|
+
self._data = data
|
|
995
|
+
|
|
996
|
+
if self.standardize:
|
|
997
|
+
self.standardizer = self._make_scaler()
|
|
998
|
+
self.standardizer.fit(data[current_vars])
|
|
999
|
+
final_x = self._apply_standardizer(data[current_vars])
|
|
1000
|
+
else:
|
|
1001
|
+
self.standardizer = None
|
|
1002
|
+
final_x = data[current_vars]
|
|
1003
|
+
|
|
1004
|
+
self.model = lr_model(final_x, data[tgt_name], None, None, self.params, sample_weight=sample_weight)
|
|
1005
|
+
return current_vars
|
|
1006
|
+
|
|
1007
|
+
def grid_search_params(self, data, varlist, tgt_name, eval_sets, param_grid,
|
|
1008
|
+
objective='oot_gap_penalized', primary_set=None,
|
|
1009
|
+
gap_ref_sets=None, metric='auc', refit=True, verbose=True,
|
|
1010
|
+
weight_col=None, eval_weight_col=None):
|
|
1011
|
+
"""
|
|
1012
|
+
Grid-search LogisticRegression hyperparameters over a holdout-based objective.
|
|
1013
|
+
|
|
1014
|
+
For every combination in ``param_grid`` (Cartesian product), a candidate model is
|
|
1015
|
+
trained on ``data`` and scored by AUC on each dataset in ``eval_sets``. The best
|
|
1016
|
+
combination is chosen by ``objective`` (default rewards a high primary-set AUC while
|
|
1017
|
+
penalizing the train/holdout AUC gap, i.e. overfitting). This is a **holdout** search
|
|
1018
|
+
(not k-fold CV), intended for the typical INS/OOS/OOT credit-scoring setup.
|
|
1019
|
+
|
|
1020
|
+
When ``standardize=True`` on this instance, every candidate inherits the same
|
|
1021
|
+
standardization config (each candidate fits its own scaler on ``data``), so the
|
|
1022
|
+
search runs in the same feature space the final (optionally refit) model uses.
|
|
1023
|
+
|
|
1024
|
+
Parameters
|
|
1025
|
+
----------
|
|
1026
|
+
data : pandas.DataFrame
|
|
1027
|
+
Training dataset (e.g. the in-sample set used for fitting).
|
|
1028
|
+
varlist : list
|
|
1029
|
+
Feature column names.
|
|
1030
|
+
tgt_name : str
|
|
1031
|
+
Target column name.
|
|
1032
|
+
eval_sets : dict of {str: pandas.DataFrame}
|
|
1033
|
+
Ordered mapping of datasets to score by AUC, e.g.
|
|
1034
|
+
``{'ins': ins_df, 'oos': oos_df, 'oot': oot_df}``.
|
|
1035
|
+
param_grid : dict of {str: iterable}
|
|
1036
|
+
Hyperparameter search space, e.g. ``{'C': np.logspace(-3, 2, 31)}``.
|
|
1037
|
+
Multiple keys are combined as a Cartesian product.
|
|
1038
|
+
objective : str or callable, default 'oot_gap_penalized'
|
|
1039
|
+
How to score each candidate from its per-set AUCs:
|
|
1040
|
+
|
|
1041
|
+
- ``'oot_gap_penalized'`` : ``AUC[primary] - |mean(AUC[gap_refs]) - AUC[primary]|``
|
|
1042
|
+
(maximize the primary set while penalizing the overfitting gap).
|
|
1043
|
+
- ``'max_primary'`` : ``AUC[primary]``.
|
|
1044
|
+
- callable : ``f(auc_dict) -> float`` where ``auc_dict`` maps set name to AUC.
|
|
1045
|
+
primary_set : str, optional
|
|
1046
|
+
Key of ``eval_sets`` whose AUC to maximize. Defaults to the last key.
|
|
1047
|
+
gap_ref_sets : list of str, optional
|
|
1048
|
+
Set names whose mean AUC forms the gap reference. Defaults to all sets except
|
|
1049
|
+
``primary_set``. Only used by ``'oot_gap_penalized'``.
|
|
1050
|
+
metric : str, default 'auc'
|
|
1051
|
+
Evaluation metric. Currently only ``'auc'`` is supported.
|
|
1052
|
+
refit : bool, default True
|
|
1053
|
+
If True, refit ``self`` on ``data`` with the best parameters after searching.
|
|
1054
|
+
verbose : bool, default True
|
|
1055
|
+
Print progress / best result.
|
|
1056
|
+
|
|
1057
|
+
Returns
|
|
1058
|
+
-------
|
|
1059
|
+
pandas.DataFrame
|
|
1060
|
+
Search results sorted by ``score`` descending, with columns: the param name(s)
|
|
1061
|
+
+ ``AUC_<name>`` per eval set + ``gap`` (gap objective only) + ``score``.
|
|
1062
|
+
|
|
1063
|
+
Side Effects
|
|
1064
|
+
------------
|
|
1065
|
+
Sets ``self.best_params_`` (dict) and ``self.search_results_`` (the returned table),
|
|
1066
|
+
and merges the best combo into ``self.params``; if ``refit=True``, also retrains
|
|
1067
|
+
``self.model`` on ``data``.
|
|
1068
|
+
|
|
1069
|
+
Examples
|
|
1070
|
+
--------
|
|
1071
|
+
>>> tuner = LRMaster(params={'C': 1.0, 'solver': 'lbfgs'})
|
|
1072
|
+
>>> res = tuner.grid_search_params(
|
|
1073
|
+
... data=ins_fit, varlist=woe_cols, tgt_name='bad_flag',
|
|
1074
|
+
... eval_sets={'ins': ins_woe, 'oos': oos_woe, 'oot': oot_woe},
|
|
1075
|
+
... param_grid={'C': np.logspace(-3, 2, 31)},
|
|
1076
|
+
... primary_set='oot', gap_ref_sets=['ins', 'oos'], refit=False,
|
|
1077
|
+
... )
|
|
1078
|
+
>>> best_C = tuner.best_params_['C']
|
|
1079
|
+
"""
|
|
1080
|
+
import itertools
|
|
1081
|
+
from sklearn.metrics import roc_auc_score
|
|
1082
|
+
|
|
1083
|
+
if metric != 'auc':
|
|
1084
|
+
raise ValueError("Only metric='auc' is currently supported.")
|
|
1085
|
+
if not eval_sets:
|
|
1086
|
+
raise ValueError("eval_sets must be a non-empty {name: DataFrame} mapping.")
|
|
1087
|
+
|
|
1088
|
+
set_names = list(eval_sets.keys())
|
|
1089
|
+
if primary_set is None:
|
|
1090
|
+
primary_set = set_names[-1]
|
|
1091
|
+
if primary_set not in eval_sets:
|
|
1092
|
+
raise ValueError("primary_set '{0}' not in eval_sets {1}".format(primary_set, set_names))
|
|
1093
|
+
if gap_ref_sets is None:
|
|
1094
|
+
gap_ref_sets = [n for n in set_names if n != primary_set]
|
|
1095
|
+
|
|
1096
|
+
# Validate columns up-front for a clear error instead of a deep KeyError.
|
|
1097
|
+
missing = [c for c in (list(varlist) + [tgt_name]) if c not in data.columns]
|
|
1098
|
+
if missing:
|
|
1099
|
+
raise KeyError("training data missing columns: {0}".format(missing))
|
|
1100
|
+
for _name, _df in eval_sets.items():
|
|
1101
|
+
_miss = [c for c in (list(varlist) + [tgt_name]) if c not in _df.columns]
|
|
1102
|
+
if _miss:
|
|
1103
|
+
raise KeyError("eval set '{0}' missing columns: {1}".format(_name, _miss))
|
|
1104
|
+
|
|
1105
|
+
param_names = list(param_grid.keys())
|
|
1106
|
+
combos = list(itertools.product(*[list(param_grid[k]) for k in param_names]))
|
|
1107
|
+
use_gap = (not callable(objective)) and objective == 'oot_gap_penalized' and len(gap_ref_sets) > 0
|
|
1108
|
+
|
|
1109
|
+
if verbose:
|
|
1110
|
+
print("grid_search_params: {0} 组合 (params={1}), 训练集 {2:,} 行, eval={3}".format(
|
|
1111
|
+
len(combos), param_names, len(data), set_names))
|
|
1112
|
+
|
|
1113
|
+
def _score(auc_dict):
|
|
1114
|
+
if callable(objective):
|
|
1115
|
+
return objective(auc_dict)
|
|
1116
|
+
if objective == 'max_primary':
|
|
1117
|
+
return auc_dict[primary_set]
|
|
1118
|
+
if objective == 'oot_gap_penalized':
|
|
1119
|
+
primary = auc_dict[primary_set]
|
|
1120
|
+
if gap_ref_sets:
|
|
1121
|
+
ref = float(np.mean([auc_dict[n] for n in gap_ref_sets]))
|
|
1122
|
+
return primary - abs(ref - primary)
|
|
1123
|
+
return primary
|
|
1124
|
+
raise ValueError("Unknown objective: {0}".format(objective))
|
|
1125
|
+
|
|
1126
|
+
rows = []
|
|
1127
|
+
for combo in combos:
|
|
1128
|
+
combo_dict = dict(zip(param_names, combo))
|
|
1129
|
+
# Candidates inherit this instance's standardization config so the
|
|
1130
|
+
# search happens in the same feature space the final model uses.
|
|
1131
|
+
cand = LRMaster(
|
|
1132
|
+
params={**self.params, **combo_dict},
|
|
1133
|
+
standardize=self.standardize,
|
|
1134
|
+
scaler=self._scaler_proto,
|
|
1135
|
+
)
|
|
1136
|
+
cand.fit(data, varlist, tgt_name, weight_col=weight_col)
|
|
1137
|
+
|
|
1138
|
+
auc_dict = {}
|
|
1139
|
+
for name, df_eval in eval_sets.items():
|
|
1140
|
+
proba = cand.predict_proba(df_eval, varlist)[:, 1]
|
|
1141
|
+
eval_sw = resolve_sample_weight(data=df_eval, weight_col=eval_weight_col, expected_len=len(df_eval))
|
|
1142
|
+
auc_dict[name] = roc_auc_score(df_eval[tgt_name], proba, sample_weight=eval_sw)
|
|
1143
|
+
|
|
1144
|
+
row = dict(combo_dict)
|
|
1145
|
+
for name in set_names:
|
|
1146
|
+
row['AUC_{0}'.format(name)] = round(auc_dict[name], 5)
|
|
1147
|
+
if use_gap:
|
|
1148
|
+
ref = float(np.mean([auc_dict[n] for n in gap_ref_sets]))
|
|
1149
|
+
row['gap'] = round(ref - auc_dict[primary_set], 5)
|
|
1150
|
+
row['score'] = round(_score(auc_dict), 5)
|
|
1151
|
+
rows.append(row)
|
|
1152
|
+
|
|
1153
|
+
search_df = pd.DataFrame(rows).sort_values('score', ascending=False).reset_index(drop=True)
|
|
1154
|
+
|
|
1155
|
+
# Best params from the (unrounded) top row; cast numpy scalars to native
|
|
1156
|
+
# Python types for a clean repr / JSON-serializable params.
|
|
1157
|
+
best_row = search_df.iloc[0]
|
|
1158
|
+
|
|
1159
|
+
def _native(v):
|
|
1160
|
+
return v.item() if hasattr(v, 'item') else v
|
|
1161
|
+
|
|
1162
|
+
self.best_params_ = {k: _native(best_row[k]) for k in param_names}
|
|
1163
|
+
self.search_results_ = search_df
|
|
1164
|
+
self.params = {**self.params, **self.best_params_}
|
|
1165
|
+
|
|
1166
|
+
# Round float param columns for display only (does not affect best_params_).
|
|
1167
|
+
for k in param_names:
|
|
1168
|
+
if pd.api.types.is_float_dtype(search_df[k]):
|
|
1169
|
+
search_df[k] = search_df[k].round(5)
|
|
1170
|
+
|
|
1171
|
+
if verbose:
|
|
1172
|
+
print("★ best: {0} | score={1:.5f} | AUC_{2}={3:.5f}".format(
|
|
1173
|
+
self.best_params_, best_row['score'], primary_set,
|
|
1174
|
+
best_row['AUC_{0}'.format(primary_set)]))
|
|
1175
|
+
|
|
1176
|
+
if refit:
|
|
1177
|
+
self.fit(data, varlist, tgt_name, weight_col=weight_col)
|
|
1178
|
+
|
|
1179
|
+
return search_df
|
|
1180
|
+
|
|
1181
|
+
def clone(self):
|
|
1182
|
+
"""
|
|
1183
|
+
Create a copy of this LRMaster with the same parameters.
|
|
1184
|
+
|
|
1185
|
+
The standardization configuration (`standardize` flag and scaler
|
|
1186
|
+
prototype) is carried over, but no fitted model or fitted scaler is
|
|
1187
|
+
copied.
|
|
1188
|
+
|
|
1189
|
+
Returns
|
|
1190
|
+
-------
|
|
1191
|
+
LRMaster
|
|
1192
|
+
New instance with same params/standardization config but no fitted model
|
|
1193
|
+
"""
|
|
1194
|
+
return LRMaster(
|
|
1195
|
+
params=dict(self.params),
|
|
1196
|
+
standardize=self.standardize,
|
|
1197
|
+
scaler=self._scaler_proto,
|
|
1198
|
+
)
|