SuperModelingFactory 0.6.4__tar.gz → 0.6.5__tar.gz

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 (115) hide show
  1. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/_common.py +43 -0
  2. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/credit_model.py +275 -21
  3. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/feature_validation.py +26 -2
  4. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/field_meta.py +10 -0
  5. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/__init__.py +1 -1
  6. {supermodelingfactory-0.6.4/SuperModelingFactory.egg-info → supermodelingfactory-0.6.5}/PKG-INFO +2 -2
  7. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/README.md +1 -1
  8. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5/SuperModelingFactory.egg-info}/PKG-INFO +2 -2
  9. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/pyproject.toml +1 -1
  10. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/setup.py +1 -1
  11. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/ExcelMaster/ExcelFormatTool.py +0 -0
  12. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/ExcelMaster/ExcelMaster.py +0 -0
  13. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/ExcelMaster/Template.py +0 -0
  14. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/ExcelMaster/Utility.py +0 -0
  15. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/ExcelMaster/__init__.py +0 -0
  16. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/LICENSE +0 -0
  17. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/MANIFEST.in +0 -0
  18. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/Binning_Tool.py +0 -0
  19. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/Binning_Tool.pyi +0 -0
  20. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/Check_DuckDB_Compatibility.py +0 -0
  21. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/Json_Data_Converter.py +0 -0
  22. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/Model_Registry_Tool.py +0 -0
  23. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/ODPS_Tool.py +0 -0
  24. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/Parallel_Engine.py +0 -0
  25. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/Parallel_ODPS_Manager.py +0 -0
  26. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/Proc_Compare.py +0 -0
  27. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/Slope_Tool.py +0 -0
  28. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/Slope_Tool.pyi +0 -0
  29. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/XOR_Encryptor.py +0 -0
  30. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/XOR_Encryptor.pyi +0 -0
  31. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/__init__.py +0 -0
  32. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/kDataFrame.py +0 -0
  33. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/kDataFrame.pyi +0 -0
  34. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/sample_weight_utils.py +0 -0
  35. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Core/utils.py +0 -0
  36. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Eval/Evaluation_Tool.py +0 -0
  37. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Eval/Evaluation_Tool.pyi +0 -0
  38. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Eval/Model_Eval_Tool.py +0 -0
  39. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Eval/Model_Eval_Tool.pyi +0 -0
  40. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Eval/__init__.py +0 -0
  41. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Eval/evaluate_model.py +0 -0
  42. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Eval/evaluate_model.pyi +0 -0
  43. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Eval/weighted_eval_utils.py +0 -0
  44. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Explainability/Coalition_Structure.py +0 -0
  45. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Explainability/Model_Explainer.py +0 -0
  46. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Explainability/__init__.py +0 -0
  47. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/Distribution_Tool.py +0 -0
  48. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/Distribution_Tool.pyi +0 -0
  49. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/Feature_Insights.py +0 -0
  50. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/Feature_Insights.pyi +0 -0
  51. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/Feature_Screen.py +0 -0
  52. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/ODPS_Distribution_Tool.py +0 -0
  53. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/ODPS_Distribution_Tool.pyi +0 -0
  54. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/PSI_Tool.py +0 -0
  55. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/PSI_Tool.pyi +0 -0
  56. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +0 -0
  57. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/Weighted_Screen.py +0 -0
  58. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Feature/__init__.py +0 -0
  59. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Model/Backward_Tool.py +0 -0
  60. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Model/Backward_Tool.pyi +0 -0
  61. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Model/GBM_Search_Tool.py +0 -0
  62. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Model/GBM_Tool.py +0 -0
  63. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Model/GBM_Tool.pyi +0 -0
  64. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Model/LRM_Tool.py +0 -0
  65. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Model/LRM_Tool.pyi +0 -0
  66. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Model/__init__.py +0 -0
  67. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/__init__.py +0 -0
  68. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/mock_sample.py +0 -0
  69. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/orchestrator.py +0 -0
  70. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/reject_inference.py +0 -0
  71. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/sample_analysis.py +0 -0
  72. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/score_comparison.py +0 -0
  73. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/score_consistency_uat.py +0 -0
  74. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Pipeline/screening_artifact.py +0 -0
  75. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Sample/Distribution_Adaptation.py +0 -0
  76. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Sample/Distribution_Adaptation.pyi +0 -0
  77. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Sample/Reject_Infer.py +0 -0
  78. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Sample/Reject_Infer.pyi +0 -0
  79. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Sample/Sample_Split.py +0 -0
  80. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Sample/Sample_Split.pyi +0 -0
  81. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/Sample/__init__.py +0 -0
  82. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/UAT/UAT_Consistency_Checker.py +0 -0
  83. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/UAT/__init__.py +0 -0
  84. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Adapter.py +0 -0
  85. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Adapter.pyi +0 -0
  86. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Master.py +0 -0
  87. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Master.pyi +0 -0
  88. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Monotone_Binner.py +0 -0
  89. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +0 -0
  90. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Plot_Tool.py +0 -0
  91. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Plot_Tool.pyi +0 -0
  92. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Report_Builder.py +0 -0
  93. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Report_Builder.pyi +0 -0
  94. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Tool.py +0 -0
  95. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/WOE_Tool.pyi +0 -0
  96. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/__init__.py +0 -0
  97. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/plot_woe_tool.py +0 -0
  98. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/WOE/plot_woe_tool.pyi +0 -0
  99. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/_utils/__init__.py +0 -0
  100. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/_utils/nan_guard.py +0 -0
  101. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/_utils/robust.py +0 -0
  102. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/_utils/sentinels.py +0 -0
  103. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/ref_font/KaiTi.ttf +0 -0
  104. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/ref_font/WeiRuanYaHei.ttf +0 -0
  105. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/ref_font/__init__.py +0 -0
  106. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Modeling_Tool/ref_font/simsun.ttc +0 -0
  107. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Report/Report_Tool.py +0 -0
  108. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/Report/__init__.py +0 -0
  109. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/SuperModelingFactory.egg-info/SOURCES.txt +0 -0
  110. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/SuperModelingFactory.egg-info/dependency_links.txt +0 -0
  111. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/SuperModelingFactory.egg-info/not-zip-safe +0 -0
  112. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/SuperModelingFactory.egg-info/requires.txt +0 -0
  113. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/SuperModelingFactory.egg-info/top_level.txt +0 -0
  114. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/requirements.txt +0 -0
  115. {supermodelingfactory-0.6.4 → supermodelingfactory-0.6.5}/setup.cfg +0 -0
@@ -532,6 +532,49 @@ def copy_column_length_checked(
532
532
  dst[col] = src[col].to_numpy()
533
533
 
534
534
 
535
+ def resolve_missing_oot(
536
+ oos: pd.DataFrame,
537
+ synthesize: bool,
538
+ pipeline: str,
539
+ ) -> pd.DataFrame | None:
540
+ """Decide what to do when a run has no real OOT rows.
541
+
542
+ ``synthesize=True`` preserves the historical behavior — the OOS frame is
543
+ copied in as a stand-in OOT — but now emits a loud ``UserWarning`` so the
544
+ placeholder can never be mistaken for a real out-of-time sample.
545
+ ``synthesize=False`` returns ``None``: the caller must then leave OOT out
546
+ of its splits (missing key or empty frame) so no downstream stage
547
+ evaluates, searches, or reports against a synthetic OOT.
548
+ """
549
+ if synthesize:
550
+ warnings.warn(
551
+ f"{pipeline}: no real OOT rows found; OOS ({len(oos)} rows) was "
552
+ f"copied in as a stand-in OOT. Metrics labeled 'oot' are OOS "
553
+ f"metrics, NOT out-of-time performance. Set "
554
+ f"synthesize_missing_oot=False (or candidate_mode=True) to drop "
555
+ f"the placeholder instead.",
556
+ UserWarning,
557
+ stacklevel=3,
558
+ )
559
+ return oos.copy()
560
+ return None
561
+
562
+
563
+ def assert_split_allowed(
564
+ split: str,
565
+ forbidden: list[str] | set[str] | None,
566
+ component: str,
567
+ ) -> None:
568
+ """Hard gate for split governance: raise if ``component`` is about to
569
+ consume a split listed in ``forbidden_splits``."""
570
+ if forbidden and split in set(forbidden):
571
+ raise ValueError(
572
+ f"{component} requested split {split!r}, but it is listed in "
573
+ f"forbidden_splits {sorted(set(forbidden))}. Remove the split from "
574
+ f"the component's configuration or lift the restriction."
575
+ )
576
+
577
+
535
578
  def split_oot_by_flag(
536
579
  data: pd.DataFrame,
537
580
  oot_col: str | None,
@@ -17,6 +17,8 @@ from ._common import (
17
17
  merge_dict,
18
18
  persist_explain_outputs,
19
19
  predict_positive,
20
+ assert_split_allowed,
21
+ resolve_missing_oot,
20
22
  safe_to_csv,
21
23
  split_oot_by_flag,
22
24
  validate_woe_fit_query_columns,
@@ -88,6 +90,25 @@ class CreditModelPipelineConfig:
88
90
  backward_params: dict[str, Any] = field(default_factory=dict)
89
91
  use_backward_features: bool = True
90
92
 
93
+ # --- OOT governance (G10/G11/G12) -----------------------------------
94
+ # None means "not set explicitly": the effective value is resolved at
95
+ # run() start — legacy behavior outside candidate_mode, tightened
96
+ # defaults inside it. Explicit values conflicting with
97
+ # candidate_mode=True raise at validation time.
98
+ # NOTE (announced flip): synthesize_missing_oot=None currently resolves
99
+ # to True (legacy OOS-copy stand-in, now with a loud UserWarning);
100
+ # search_eval_splits=None resolves to ["oos", "oot"] and
101
+ # backward_report_splits=None to ["oot"]. The next minor release flips
102
+ # these resolved defaults to False / ["oos"] / [].
103
+ candidate_mode: bool = False
104
+ synthesize_missing_oot: bool | None = None
105
+ evaluation_splits: list[str] | None = None
106
+ forbidden_splits: list[str] = field(default_factory=list)
107
+ search_eval_splits: list[str] | None = None
108
+ search_objective_when_no_oot: str = "max_primary"
109
+ backward_validation_split: str = "oos"
110
+ backward_report_splits: list[str] | None = None
111
+
91
112
  optuna_models: list[str] = field(default_factory=lambda: ["lgb", "xgb", "cat"])
92
113
  optuna_n_trials: int = 5
93
114
  optuna_params: dict[str, Any] = field(default_factory=dict)
@@ -127,6 +148,7 @@ class CreditModelPipelineResult:
127
148
  selected_woe_features: list[str] = field(default_factory=list)
128
149
  model_paths: dict[str, str] = field(default_factory=dict)
129
150
  artifact_paths: dict[str, str] = field(default_factory=dict)
151
+ split_governance: dict[str, Any] = field(default_factory=dict)
130
152
 
131
153
 
132
154
  class CreditModelPipeline:
@@ -178,11 +200,15 @@ class CreditModelPipeline:
178
200
  self.config = config or CreditModelPipelineConfig()
179
201
  self.predict_positive_nan_stats: dict[str, dict[str, int]] = {}
180
202
  self._validate_gbm_feature_source_config()
203
+ # Resolved again at run() start; kept here so helpers (_split_data,
204
+ # _run_lr_search, ...) work when exercised directly in tests.
205
+ self._governance = self._resolve_split_governance()
181
206
 
182
207
  def run(self, data: pd.DataFrame) -> CreditModelPipelineResult:
183
208
  cfg = self.config
184
209
  feature_cols = self._resolve_feature_cols(data)
185
210
  self._validate_input(data, feature_cols)
211
+ self._governance = self._resolve_split_governance()
186
212
 
187
213
  output_dir = Path(cfg.output_dir)
188
214
  if cfg.write_outputs or cfg.write_excel:
@@ -198,7 +224,7 @@ class CreditModelPipeline:
198
224
  if cfg.save_models:
199
225
  make_dirs(self._model_output_dir(), output_dir / "artifacts")
200
226
 
201
- splits = self._split_data(data)
227
+ splits = self._apply_split_governance(self._split_data(data))
202
228
  fs_summary, final_features, screening_artifact = self._resolve_feature_selection(
203
229
  splits,
204
230
  feature_cols,
@@ -306,6 +332,7 @@ class CreditModelPipeline:
306
332
  selected_woe_features=list(selected_woe_features),
307
333
  model_paths=model_paths,
308
334
  artifact_paths=artifact_paths,
335
+ split_governance=dict(self._governance),
309
336
  )
310
337
 
311
338
  def _resolve_feature_cols(self, data: pd.DataFrame) -> list[str]:
@@ -361,6 +388,104 @@ class CreditModelPipeline:
361
388
  f"extra_eval_datasets[{name!r}] missing warm_start_score_col {cfg.warm_start_score_col!r}"
362
389
  )
363
390
 
391
+ _KNOWN_SPLITS = ("ins", "oos", "oot")
392
+
393
+ def _resolve_split_governance(self) -> dict[str, Any]:
394
+ """Resolve effective OOT-governance settings (G10/G11/G12).
395
+
396
+ Governance config fields default to None ("not set explicitly") and
397
+ resolve to legacy behavior outside candidate_mode or tightened
398
+ defaults inside it. Explicit values that contradict
399
+ candidate_mode=True raise instead of being silently overridden, and
400
+ the user's config object is never mutated.
401
+ """
402
+ cfg = self.config
403
+
404
+ def _norm(values: Any, field_name: str) -> list[str]:
405
+ names = [str(v).strip().lower() for v in as_list(values)]
406
+ unknown = sorted(set(names) - set(self._KNOWN_SPLITS))
407
+ if unknown:
408
+ raise ValueError(
409
+ f"{field_name} only supports splits {list(self._KNOWN_SPLITS)}; got {unknown}"
410
+ )
411
+ return names
412
+
413
+ forbidden = set(_norm(cfg.forbidden_splits, "forbidden_splits"))
414
+ if cfg.candidate_mode:
415
+ conflicts = []
416
+ if cfg.synthesize_missing_oot is True:
417
+ conflicts.append("synthesize_missing_oot=True")
418
+ if cfg.evaluation_splits is not None and "oot" in _norm(cfg.evaluation_splits, "evaluation_splits"):
419
+ conflicts.append("evaluation_splits contains 'oot'")
420
+ if cfg.search_eval_splits is not None and "oot" in _norm(cfg.search_eval_splits, "search_eval_splits"):
421
+ conflicts.append("search_eval_splits contains 'oot'")
422
+ if cfg.backward_report_splits is not None and "oot" in _norm(cfg.backward_report_splits, "backward_report_splits"):
423
+ conflicts.append("backward_report_splits contains 'oot'")
424
+ if str(cfg.backward_validation_split).strip().lower() == "oot":
425
+ conflicts.append("backward_validation_split='oot'")
426
+ if conflicts:
427
+ raise ValueError(
428
+ f"candidate_mode=True forbids any OOT consumption, but the config "
429
+ f"explicitly requests it: {conflicts}. Drop candidate_mode or the "
430
+ f"conflicting settings."
431
+ )
432
+ forbidden |= {"oot"}
433
+
434
+ synthesize = cfg.synthesize_missing_oot
435
+ if synthesize is None:
436
+ # Legacy default True (announced flip to False on the next minor
437
+ # release); candidate_mode never synthesizes.
438
+ synthesize = not cfg.candidate_mode
439
+ evaluation_splits = None
440
+ if cfg.evaluation_splits is not None:
441
+ evaluation_splits = _norm(cfg.evaluation_splits, "evaluation_splits")
442
+ elif cfg.candidate_mode:
443
+ evaluation_splits = ["ins", "oos"]
444
+ if cfg.search_eval_splits is not None:
445
+ search_eval = _norm(cfg.search_eval_splits, "search_eval_splits")
446
+ else:
447
+ search_eval = ["oos"] if cfg.candidate_mode else ["oos", "oot"]
448
+ if cfg.backward_report_splits is not None:
449
+ backward_report = _norm(cfg.backward_report_splits, "backward_report_splits")
450
+ else:
451
+ backward_report = [] if cfg.candidate_mode else ["oot"]
452
+ backward_validation = _norm([cfg.backward_validation_split], "backward_validation_split")[0]
453
+
454
+ # Fail fast on explicit forbidden-split consumption.
455
+ if evaluation_splits is not None:
456
+ for name in evaluation_splits:
457
+ assert_split_allowed(name, forbidden, "evaluation_splits")
458
+ for name in search_eval:
459
+ assert_split_allowed(name, forbidden, "search (lr_search/optuna) eval_sets")
460
+ for name in backward_report:
461
+ assert_split_allowed(name, forbidden, "backward test_data_dict")
462
+ assert_split_allowed(backward_validation, forbidden, "backward validation_data")
463
+
464
+ return {
465
+ "candidate_mode": bool(cfg.candidate_mode),
466
+ "synthesize_missing_oot": bool(synthesize),
467
+ "evaluation_splits": evaluation_splits,
468
+ "forbidden_splits": sorted(forbidden),
469
+ "search_eval_splits": search_eval,
470
+ "search_eval_splits_explicit": cfg.search_eval_splits is not None,
471
+ "backward_validation_split": backward_validation,
472
+ "backward_report_splits": backward_report,
473
+ "backward_report_splits_explicit": cfg.backward_report_splits is not None,
474
+ "oot_synthesized": False,
475
+ "oot_withheld": False,
476
+ }
477
+
478
+ def _apply_split_governance(self, splits: dict[str, pd.DataFrame]) -> dict[str, pd.DataFrame]:
479
+ """Remove forbidden splits from the working splits dict entirely, so
480
+ no downstream stage can consume them even by accident."""
481
+ governed = dict(splits)
482
+ for name in self._governance["forbidden_splits"]:
483
+ if name in governed:
484
+ governed.pop(name)
485
+ if name == "oot":
486
+ self._governance["oot_withheld"] = True
487
+ return governed
488
+
364
489
  def _gbm_weight_kwargs(self, train: pd.DataFrame, val: pd.DataFrame) -> dict[str, Any]:
365
490
  cfg = self.config
366
491
  if not cfg.weight_col:
@@ -415,7 +540,15 @@ class CreditModelPipeline:
415
540
  oot = work[lower == "oot"].copy()
416
541
  if len(ins) and len(oos):
417
542
  if not len(oot):
418
- oot = oos.copy()
543
+ synthesized = resolve_missing_oot(
544
+ oos,
545
+ self._governance["synthesize_missing_oot"],
546
+ "CreditModelPipeline",
547
+ )
548
+ if synthesized is None:
549
+ return {"ins": ins, "oos": oos}
550
+ self._governance["oot_synthesized"] = True
551
+ oot = synthesized
419
552
  return {"ins": ins, "oos": oos, "oot": oot}
420
553
  if cfg.split_col:
421
554
  raise ValueError(f"split_col {cfg.split_col!r} must contain non-empty ins and oos samples")
@@ -433,7 +566,15 @@ class CreditModelPipeline:
433
566
  )
434
567
  ins, oos = splitter.split_df(ins_oos, target=cfg.target_col)
435
568
  if len(oot) == 0:
436
- oot = oos.copy()
569
+ synthesized = resolve_missing_oot(
570
+ oos,
571
+ self._governance["synthesize_missing_oot"],
572
+ "CreditModelPipeline",
573
+ )
574
+ if synthesized is None:
575
+ return {"ins": ins.copy(), "oos": oos.copy()}
576
+ self._governance["oot_synthesized"] = True
577
+ oot = synthesized
437
578
  return {"ins": ins.copy(), "oos": oos.copy(), "oot": oot.copy()}
438
579
 
439
580
  def _resolve_screening_artifact(self) -> Any | None:
@@ -877,18 +1018,42 @@ class CreditModelPipeline:
877
1018
  try:
878
1019
  from Modeling_Tool import BackwardVariableEliminator
879
1020
 
1021
+ gov = self._governance
1022
+ validation_split = gov["backward_validation_split"]
1023
+ if validation_split not in splits:
1024
+ raise ValueError(
1025
+ f"backward_validation_split {validation_split!r} is not available "
1026
+ f"in this run (available: {sorted(splits)})."
1027
+ )
1028
+ test_data_dict: dict[str, pd.DataFrame] = {}
1029
+ for split_name in gov["backward_report_splits"]:
1030
+ if split_name not in splits:
1031
+ if gov["backward_report_splits_explicit"]:
1032
+ raise ValueError(
1033
+ f"backward_report_splits contains {split_name!r}, which is "
1034
+ f"not available in this run (available: {sorted(splits)})."
1035
+ )
1036
+ continue
1037
+ test_data_dict[split_name] = splits[split_name]
1038
+ user_init = dict(cfg.backward_params.get("init", {}))
1039
+ for split_name in (user_init.get("test_data_dict") or {}):
1040
+ assert_split_allowed(
1041
+ str(split_name).lower(),
1042
+ gov["forbidden_splits"],
1043
+ "backward test_data_dict (backward_params['init'])",
1044
+ )
880
1045
  params = merge_dict(
881
1046
  {
882
1047
  "train_data": splits["ins"],
883
1048
  "varlist": feature_cols,
884
1049
  "dep": cfg.target_col,
885
1050
  "model_type": f"{cfg.backward_model}m" if cfg.backward_model == "lgb" else cfg.backward_model,
886
- "validation_data": splits["oos"],
887
- "test_data_dict": {"oot": splits["oot"]},
1051
+ "validation_data": splits[validation_split],
1052
+ "test_data_dict": test_data_dict,
888
1053
  "weight_col": cfg.weight_col,
889
1054
  "validation_weight_col": cfg.weight_col,
890
1055
  },
891
- cfg.backward_params.get("init", {}),
1056
+ user_init,
892
1057
  )
893
1058
  bwd = BackwardVariableEliminator(**params)
894
1059
  run_params = merge_dict(
@@ -916,6 +1081,79 @@ class CreditModelPipeline:
916
1081
  except Exception as exc:
917
1082
  return pd.DataFrame({"step": ["backward"], "error": [repr(exc)]}), list(feature_cols)
918
1083
 
1084
+ def _build_search_eval_sets(
1085
+ self,
1086
+ splits: dict[str, pd.DataFrame],
1087
+ *,
1088
+ component: str,
1089
+ ) -> dict[str, pd.DataFrame]:
1090
+ """Build hyperparameter-search eval_sets from search_eval_splits.
1091
+
1092
+ Every requested split passes the forbidden_splits hard gate. A split
1093
+ missing from the run (e.g. no real OOT with synthesis disabled) is an
1094
+ error when search_eval_splits was set explicitly; when the field was
1095
+ left unset, unavailable splits are silently dropped from the resolved
1096
+ legacy default instead.
1097
+ """
1098
+ gov = self._governance
1099
+ eval_sets: dict[str, pd.DataFrame] = {}
1100
+ for name in gov["search_eval_splits"]:
1101
+ assert_split_allowed(name, gov["forbidden_splits"], component)
1102
+ if name not in splits:
1103
+ if gov["search_eval_splits_explicit"]:
1104
+ raise ValueError(
1105
+ f"{component}: search eval split {name!r} is not available in "
1106
+ f"this run (available: {sorted(splits)}). Drop it from "
1107
+ f"search_eval_splits or provide the split."
1108
+ )
1109
+ continue
1110
+ eval_sets[name] = splits[name]
1111
+ if not eval_sets:
1112
+ raise ValueError(
1113
+ f"{component}: no usable search eval splits "
1114
+ f"(requested {gov['search_eval_splits']}, available {sorted(splits)})."
1115
+ )
1116
+ return eval_sets
1117
+
1118
+ def _default_search_objective_params(self, eval_sets: dict[str, pd.DataFrame]) -> dict[str, Any]:
1119
+ has_oot = "oot" in eval_sets
1120
+ return {
1121
+ "objective": "oot_gap_penalized" if has_oot else str(self.config.search_objective_when_no_oot),
1122
+ "primary_set": "oos",
1123
+ "gap_ref_sets": ["oot"] if has_oot else [],
1124
+ "metric": "auc",
1125
+ }
1126
+
1127
+ def _validate_search_objective_params(
1128
+ self,
1129
+ params: dict[str, Any],
1130
+ eval_sets: dict[str, pd.DataFrame],
1131
+ *,
1132
+ component: str,
1133
+ ) -> None:
1134
+ primary = str(params.get("primary_set"))
1135
+ if primary not in eval_sets:
1136
+ raise ValueError(
1137
+ f"{component}: primary_set {primary!r} is not among the search eval "
1138
+ f"sets {sorted(eval_sets)}."
1139
+ )
1140
+ gap_refs = [str(x) for x in as_list(params.get("gap_ref_sets"))]
1141
+ missing = [name for name in gap_refs if name not in eval_sets]
1142
+ if missing:
1143
+ raise ValueError(
1144
+ f"{component}: gap_ref_sets {missing} are not among the search eval "
1145
+ f"sets {sorted(eval_sets)} — the split may be absent from this run "
1146
+ f"or listed in forbidden_splits."
1147
+ )
1148
+ objective = params.get("objective")
1149
+ if not callable(objective) and str(objective) == "oot_gap_penalized" and not gap_refs:
1150
+ raise ValueError(
1151
+ f"{component}: objective='oot_gap_penalized' requires a non-empty "
1152
+ f"gap_ref_sets, but no gap reference split is available in this run. "
1153
+ f"Use objective='max_primary' (see search_objective_when_no_oot) or "
1154
+ f"provide a real OOT split."
1155
+ )
1156
+
919
1157
  def _run_lr_search(
920
1158
  self,
921
1159
  splits: dict[str, pd.DataFrame],
@@ -945,22 +1183,17 @@ class CreditModelPipeline:
945
1183
  f"Allowed keys are {sorted(allowed_search_params)}. "
946
1184
  "LRMaster.grid_search_params uses holdout eval_sets and does not accept cv."
947
1185
  )
1186
+ eval_sets = self._build_search_eval_sets(splits, component="lr_search")
948
1187
  params = merge_dict(
949
- {
950
- "objective": "oot_gap_penalized",
951
- "primary_set": "oos",
952
- "gap_ref_sets": ["oot"],
953
- "metric": "auc",
954
- "refit": False,
955
- "verbose": False,
956
- },
1188
+ self._default_search_objective_params(eval_sets),
957
1189
  cfg.lr_search_params,
958
1190
  )
1191
+ self._validate_search_objective_params(params, eval_sets, component="lr_search")
959
1192
  results = lr.grid_search_params(
960
1193
  data=splits["ins"],
961
1194
  varlist=feature_cols,
962
1195
  tgt_name=cfg.target_col,
963
- eval_sets={"oos": splits["oos"], "oot": splits["oot"]},
1196
+ eval_sets=eval_sets,
964
1197
  param_grid=cfg.lr_search_param_grid,
965
1198
  weight_col=cfg.weight_col,
966
1199
  eval_weight_col=cfg.weight_col,
@@ -1055,23 +1288,36 @@ class CreditModelPipeline:
1055
1288
  continue
1056
1289
  splits = input_info["splits"]
1057
1290
  feature_cols = list(input_info["features"])
1291
+ user_common = dict(cfg.optuna_params.get("common", {}))
1292
+ user_eval_sets = user_common.get("eval_sets")
1293
+ if user_eval_sets is not None:
1294
+ # Legacy override kept, but every named split still passes the
1295
+ # governance hard gate — forbidden splits cannot sneak back in
1296
+ # through optuna_params.
1297
+ for split_name in user_eval_sets:
1298
+ assert_split_allowed(
1299
+ str(split_name).lower(),
1300
+ self._governance["forbidden_splits"],
1301
+ "optuna_search eval_sets (optuna_params['common'])",
1302
+ )
1303
+ eval_sets = dict(user_eval_sets)
1304
+ else:
1305
+ eval_sets = self._build_search_eval_sets(splits, component="optuna_search")
1058
1306
  common = merge_dict(
1059
1307
  {
1060
1308
  "varlist": feature_cols,
1061
1309
  "tgt_name": cfg.target_col,
1062
- "eval_sets": {"oos": splits["oos"], "oot": splits["oot"]},
1310
+ "eval_sets": eval_sets,
1063
1311
  "engine": "optuna",
1064
- "objective": "oot_gap_penalized",
1065
- "primary_set": "oos",
1066
- "gap_ref_sets": ["oot"],
1067
- "metric": "auc",
1312
+ **self._default_search_objective_params(eval_sets),
1068
1313
  "n_trials": cfg.optuna_n_trials,
1069
1314
  "refit": True,
1070
1315
  "verbose": False,
1071
1316
  "random_state": cfg.random_state,
1072
1317
  },
1073
- cfg.optuna_params.get("common", {}),
1318
+ user_common,
1074
1319
  )
1320
+ self._validate_search_objective_params(common, eval_sets, component="optuna_search")
1075
1321
  if self._warm_start_requested_for(name) and name == "cat":
1076
1322
  if cfg.warm_start_on_unsupported == "raise":
1077
1323
  raise NotImplementedError("CatBoost does not support warm-start init_score")
@@ -1118,6 +1364,9 @@ class CreditModelPipeline:
1118
1364
  min_bin_prop=cfg.perf_min_bin_prop,
1119
1365
  equal_freq=True,
1120
1366
  )
1367
+ allowed = self._governance.get("evaluation_splits")
1368
+ if allowed is not None:
1369
+ splits = {name: df for name, df in splits.items() if name in set(allowed)}
1121
1370
  eval_splits = {**splits, **model_extra}
1122
1371
  nan_stats: dict[str, int] = {}
1123
1372
  for ds_name, df in eval_splits.items():
@@ -1351,6 +1600,11 @@ class CreditModelPipeline:
1351
1600
  "warm_start_enabled": bool(cfg.warm_start_enabled and name in warm_start_requested),
1352
1601
  "warm_start_score_col": cfg.warm_start_score_col,
1353
1602
  "random_state": cfg.random_state,
1603
+ "candidate_mode": self._governance["candidate_mode"],
1604
+ "oot_synthesized": self._governance["oot_synthesized"],
1605
+ "oot_withheld": self._governance["oot_withheld"],
1606
+ "evaluation_splits": self._governance["evaluation_splits"],
1607
+ "forbidden_splits": self._governance["forbidden_splits"],
1354
1608
  }
1355
1609
  metrics = None
1356
1610
  if isinstance(perf_results.get(name), pd.DataFrame):
@@ -18,6 +18,7 @@ from ._common import (
18
18
  make_dirs,
19
19
  normalize_group_specs,
20
20
  normalize_split_values,
21
+ resolve_missing_oot,
21
22
  safe_to_csv,
22
23
  split_oot_by_flag,
23
24
  validate_woe_fit_query_columns,
@@ -109,6 +110,11 @@ class FeatureValidationPipelineConfig:
109
110
  selection_enabled: bool = False
110
111
  selection_params: dict[str, Any] = field(default_factory=dict)
111
112
  weight_col: str | None = None
113
+ # OOT governance (G10): None resolves to the legacy True (missing OOT is
114
+ # replaced by an OOS copy, now with a loud UserWarning). Set False to keep
115
+ # OOT empty instead — every OOT output is then simply absent. The next
116
+ # minor release flips the resolved default to False.
117
+ synthesize_missing_oot: bool | None = None
112
118
 
113
119
  write_outputs: bool = True
114
120
  write_excel: bool = True
@@ -959,6 +965,11 @@ class FeatureValidationPipeline:
959
965
  if "apply_quarter" in cfg.time_dims and "apply_quarter" not in data.columns:
960
966
  data["apply_quarter"] = dt.dt.to_period("Q").astype(str)
961
967
 
968
+ def _synthesize_missing_oot(self) -> bool:
969
+ """Resolve the None-default: legacy True until the announced flip."""
970
+ value = self.config.synthesize_missing_oot
971
+ return True if value is None else bool(value)
972
+
962
973
  def _split_data(self, data: pd.DataFrame, target_col: str | None) -> dict[str, pd.DataFrame]:
963
974
  cfg = self.config
964
975
  work = data.copy()
@@ -973,7 +984,14 @@ class FeatureValidationPipeline:
973
984
  oot = work[normalized.eq("oot").fillna(False)].copy()
974
985
  if len(ins) and len(oos):
975
986
  if not len(oot):
976
- oot = oos.copy()
987
+ synthesized = resolve_missing_oot(
988
+ oos,
989
+ self._synthesize_missing_oot(),
990
+ "FeatureValidationPipeline",
991
+ )
992
+ # FVP keeps the empty-OOT representation: downstream
993
+ # stages already guard every OOT consumer with len(oot).
994
+ oot = synthesized if synthesized is not None else oot
977
995
  splits = {"ins": ins, "oos": oos, "oot": oot}
978
996
  if cfg.split_col:
979
997
  reserved = set(splits)
@@ -1002,7 +1020,13 @@ class FeatureValidationPipeline:
1002
1020
  else:
1003
1021
  ins, oos = self._split_frame(ins_oos, None)
1004
1022
  if len(oot) == 0:
1005
- oot = oos.copy()
1023
+ synthesized = resolve_missing_oot(
1024
+ oos,
1025
+ self._synthesize_missing_oot(),
1026
+ "FeatureValidationPipeline",
1027
+ )
1028
+ if synthesized is not None:
1029
+ oot = synthesized
1006
1030
  return {"ins": ins.copy(), "oos": oos.copy(), "oot": oot.copy()}
1007
1031
 
1008
1032
  def _split_frame(self, data: pd.DataFrame, target_col: str | None) -> tuple[pd.DataFrame, pd.DataFrame]:
@@ -177,6 +177,14 @@ _FIELD_LABELS = {
177
177
  "backward_model": "逐步回归模型",
178
178
  "backward_params": "逐步回归参数",
179
179
  "use_backward_features": "使用逐步回归特征",
180
+ "candidate_mode": "候选模式(禁用 OOT)",
181
+ "synthesize_missing_oot": "缺 OOT 时用 OOS 副本代替",
182
+ "evaluation_splits": "评估 split 白名单",
183
+ "forbidden_splits": "禁用 split(硬闸)",
184
+ "search_eval_splits": "调参评估 split",
185
+ "search_objective_when_no_oot": "无 OOT 时调参目标",
186
+ "backward_validation_split": "逐步回归验证 split",
187
+ "backward_report_splits": "逐步回归报告 split",
180
188
  "optuna_models": "Optuna 模型",
181
189
  "optuna_n_trials": "Optuna 轮数",
182
190
  "optuna_params": "Optuna 参数",
@@ -354,6 +362,8 @@ _FIELD_OPTIONS = {
354
362
  "warm_start_models": ["lgb", "xgb", "cat"],
355
363
  "warm_start_on_unsupported": ["skip", "raise"],
356
364
  "feature_selection_mode": ["run", "from_artifact", "skip"],
365
+ "search_objective_when_no_oot": ["max_primary", "oot_gap_penalized"],
366
+ "backward_validation_split": ["ins", "oos", "oot"],
357
367
  "prescore_model_type": ["lgb", "xgb", "cat", "lr"],
358
368
  "ri_model_type": ["lgb", "xgb", "cat", "lr"],
359
369
  "lr_nan_handling": ["fillna_median", "fillna_mean", "fillna_0", "raise"],
@@ -1,7 +1,7 @@
1
1
  # encoding: utf-8
2
2
 
3
3
  __author__ = "Jingkai Sun"
4
- __version__ = "0.6.4"
4
+ __version__ = "0.6.5"
5
5
 
6
6
  from ._utils import SMF_MISSING_BIN
7
7
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SuperModelingFactory
3
- Version: 0.6.4
3
+ Version: 0.6.5
4
4
  Summary: Credit risk modeling factory: WOE binning, scorecards, LightGBM, Excel reporting.
5
5
  Home-page: https://github.com/Kyle-J-Sun/SuperModelingFactory
6
6
  Author: Kyle Sun
@@ -265,7 +265,7 @@ em.close_workbook()
265
265
 
266
266
  ## 版本
267
267
 
268
- - **Version**: 0.6.4
268
+ - **Version**: 0.6.5
269
269
  - **Author**: Jingkai Sun
270
270
 
271
271
  ## 许可证
@@ -208,7 +208,7 @@ em.close_workbook()
208
208
 
209
209
  ## 版本
210
210
 
211
- - **Version**: 0.6.4
211
+ - **Version**: 0.6.5
212
212
  - **Author**: Jingkai Sun
213
213
 
214
214
  ## 许可证
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SuperModelingFactory
3
- Version: 0.6.4
3
+ Version: 0.6.5
4
4
  Summary: Credit risk modeling factory: WOE binning, scorecards, LightGBM, Excel reporting.
5
5
  Home-page: https://github.com/Kyle-J-Sun/SuperModelingFactory
6
6
  Author: Kyle Sun
@@ -265,7 +265,7 @@ em.close_workbook()
265
265
 
266
266
  ## 版本
267
267
 
268
- - **Version**: 0.6.4
268
+ - **Version**: 0.6.5
269
269
  - **Author**: Jingkai Sun
270
270
 
271
271
  ## 许可证
@@ -12,7 +12,7 @@ build-backend = "setuptools.build_meta"
12
12
 
13
13
  [project]
14
14
  name = "SuperModelingFactory"
15
- version = "0.6.4"
15
+ version = "0.6.5"
16
16
  description = "Credit risk modeling factory: WOE binning, scorecards, LightGBM, Excel reporting."
17
17
  readme = "README.md"
18
18
  requires-python = ">=3.10"
@@ -15,7 +15,7 @@ def _read(path: str) -> str:
15
15
 
16
16
  setup(
17
17
  name="SuperModelingFactory",
18
- version=os.environ.get("SMF_VERSION", "0.6.4"),
18
+ version=os.environ.get("SMF_VERSION", "0.6.5"),
19
19
  description="Credit risk modeling factory: WOE binning, scorecards, LightGBM, Excel reporting.",
20
20
  long_description=_read("README.md"),
21
21
  long_description_content_type="text/markdown",