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