SuperModelingFactory 0.3.14__tar.gz → 0.4.2__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 (108) hide show
  1. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/ODPS_Tool.py +102 -9
  2. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Distribution_Tool.py +34 -0
  3. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Distribution_Tool.pyi +1 -0
  4. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Feature_Insights.py +6 -5
  5. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Feature_Screen.py +30 -0
  6. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/PSI_Tool.py +107 -34
  7. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +3 -3
  8. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Weighted_Screen.py +128 -3
  9. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/_common.py +109 -3
  10. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/credit_model.py +36 -8
  11. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/feature_validation.py +81 -24
  12. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/reject_inference.py +58 -4
  13. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/sample_analysis.py +50 -0
  14. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/score_comparison.py +20 -3
  15. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/score_consistency_uat.py +54 -6
  16. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/screening_artifact.py +4 -0
  17. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Reject_Infer.py +63 -7
  18. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Sample_Split.py +33 -22
  19. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/__init__.py +1 -1
  20. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/PKG-INFO +2 -2
  21. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/README.md +1 -1
  22. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/PKG-INFO +2 -2
  23. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/pyproject.toml +1 -1
  24. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/setup.py +1 -1
  25. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/ExcelMaster/ExcelFormatTool.py +0 -0
  26. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/ExcelMaster/ExcelMaster.py +0 -0
  27. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/ExcelMaster/Template.py +0 -0
  28. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/ExcelMaster/Utility.py +0 -0
  29. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/ExcelMaster/__init__.py +0 -0
  30. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/LICENSE +0 -0
  31. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/MANIFEST.in +0 -0
  32. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Binning_Tool.py +0 -0
  33. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Binning_Tool.pyi +0 -0
  34. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Check_DuckDB_Compatibility.py +0 -0
  35. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Json_Data_Converter.py +0 -0
  36. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Model_Registry_Tool.py +0 -0
  37. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Parallel_Engine.py +0 -0
  38. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Parallel_ODPS_Manager.py +0 -0
  39. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Proc_Compare.py +0 -0
  40. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Slope_Tool.py +0 -0
  41. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Slope_Tool.pyi +0 -0
  42. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/XOR_Encryptor.py +0 -0
  43. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/XOR_Encryptor.pyi +0 -0
  44. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/__init__.py +0 -0
  45. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/kDataFrame.py +0 -0
  46. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/kDataFrame.pyi +0 -0
  47. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/sample_weight_utils.py +0 -0
  48. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/utils.py +0 -0
  49. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/Evaluation_Tool.py +0 -0
  50. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/Evaluation_Tool.pyi +0 -0
  51. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/Model_Eval_Tool.py +0 -0
  52. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/Model_Eval_Tool.pyi +0 -0
  53. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/__init__.py +0 -0
  54. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/evaluate_model.py +0 -0
  55. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/evaluate_model.pyi +0 -0
  56. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/weighted_eval_utils.py +0 -0
  57. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Explainability/Coalition_Structure.py +0 -0
  58. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Explainability/Model_Explainer.py +0 -0
  59. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Explainability/__init__.py +0 -0
  60. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Feature_Insights.pyi +0 -0
  61. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/PSI_Tool.pyi +0 -0
  62. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/__init__.py +0 -0
  63. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/Backward_Tool.py +0 -0
  64. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/Backward_Tool.pyi +0 -0
  65. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/GBM_Search_Tool.py +0 -0
  66. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/GBM_Tool.py +0 -0
  67. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/GBM_Tool.pyi +0 -0
  68. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/LRM_Tool.py +0 -0
  69. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/LRM_Tool.pyi +0 -0
  70. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/__init__.py +0 -0
  71. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/__init__.py +0 -0
  72. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/mock_sample.py +0 -0
  73. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/orchestrator.py +0 -0
  74. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Distribution_Adaptation.py +0 -0
  75. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Distribution_Adaptation.pyi +0 -0
  76. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Reject_Infer.pyi +0 -0
  77. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Sample_Split.pyi +0 -0
  78. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/__init__.py +0 -0
  79. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/UAT/UAT_Consistency_Checker.py +0 -0
  80. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/UAT/__init__.py +0 -0
  81. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Adapter.py +0 -0
  82. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Adapter.pyi +0 -0
  83. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Master.py +0 -0
  84. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Master.pyi +0 -0
  85. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Monotone_Binner.py +0 -0
  86. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +0 -0
  87. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Plot_Tool.py +0 -0
  88. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Plot_Tool.pyi +0 -0
  89. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Report_Builder.py +0 -0
  90. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Report_Builder.pyi +0 -0
  91. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Tool.py +0 -0
  92. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Tool.pyi +0 -0
  93. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/__init__.py +0 -0
  94. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/plot_woe_tool.py +0 -0
  95. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/plot_woe_tool.pyi +0 -0
  96. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/ref_font/KaiTi.ttf +0 -0
  97. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/ref_font/WeiRuanYaHei.ttf +0 -0
  98. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/ref_font/__init__.py +0 -0
  99. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Modeling_Tool/ref_font/simsun.ttc +0 -0
  100. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Report/Report_Tool.py +0 -0
  101. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/Report/__init__.py +0 -0
  102. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/SOURCES.txt +0 -0
  103. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/dependency_links.txt +0 -0
  104. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/not-zip-safe +0 -0
  105. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/requires.txt +0 -0
  106. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/top_level.txt +0 -0
  107. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/requirements.txt +0 -0
  108. {supermodelingfactory-0.3.14 → supermodelingfactory-0.4.2}/setup.cfg +0 -0
@@ -247,7 +247,7 @@ class ODPSRunner(object):
247
247
 
248
248
  return table_schema
249
249
 
250
- def upload_df(self, df, table_name, table_schema=None, partition=None):
250
+ def upload_df(self, df, table_name, table_schema=None, partition=None, atomic=True):
251
251
  """上传数据集至mc中创建新表
252
252
 
253
253
  Parameters
@@ -260,20 +260,113 @@ class ODPSRunner(object):
260
260
  数据集
261
261
  partition: string
262
262
  保存分区
263
+ atomic: bool, default True
264
+ When ``True`` (recommended, default from 0.4.2), the target table is
265
+ replaced through a temp-table + rename swap so that a failure between
266
+ ``delete_table`` and the completed write never leaves the caller
267
+ with a dropped-and-empty table. When ``False``, the pre-0.4.2
268
+ behaviour is used: the existing table is dropped first and then
269
+ recreated, so any exception during write leaves the target table
270
+ missing. Only set ``atomic=False`` if you deliberately want the
271
+ legacy behaviour, e.g. for tables whose downstream readers expect
272
+ a specific object identity.
263
273
  """
264
274
  if table_schema is None:
265
275
  df.loc[:, "py_inserttime"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
266
276
  table_schema = self.cre_table_schema(df=df, partition_name=None)
267
277
 
268
- self.o.delete_table(table_name, if_exists=True)
269
- t = self.o.create_table(table_name, table_schema)
278
+ if not atomic:
279
+ # Legacy pre-0.4.2 non-atomic path — retained under an explicit
280
+ # opt-out for callers that need the old identity semantics.
281
+ self.o.delete_table(table_name, if_exists=True)
282
+ t = self.o.create_table(table_name, table_schema)
283
+ if bool(partition):
284
+ with t.open_writer(partition=partition, create_partition=True) as writer:
285
+ writer.write(df.values.tolist())
286
+ else:
287
+ with t.open_writer() as writer:
288
+ writer.write(df.values.tolist())
289
+ logger.info(f'<<<< 完成数据入表{table_name}: shape={df.shape} >>>>')
290
+ return
291
+
292
+ # Atomic swap path (default from 0.4.2). If any step before the final
293
+ # swap fails, the original table is untouched.
294
+ ts = datetime.now().strftime('%Y%m%d%H%M%S%f')
295
+ tmp_name = f"{table_name}__tmp_{ts}"
296
+ old_name = f"{table_name}__old_{ts}"
297
+
298
+ # Step 1: write data into the temp table. If this fails, drop the
299
+ # partial temp table and re-raise; the target table is untouched.
300
+ self.o.delete_table(tmp_name, if_exists=True)
301
+ tmp_table = self.o.create_table(tmp_name, table_schema)
302
+ try:
303
+ if bool(partition):
304
+ with tmp_table.open_writer(partition=partition, create_partition=True) as writer:
305
+ writer.write(df.values.tolist())
306
+ else:
307
+ with tmp_table.open_writer() as writer:
308
+ writer.write(df.values.tolist())
309
+ except Exception:
310
+ try:
311
+ self.o.delete_table(tmp_name, if_exists=True)
312
+ except Exception as cleanup_exc: # noqa: BLE001
313
+ logger.warning(
314
+ f"upload_df: failed to drop temp table {tmp_name} after write error: {cleanup_exc!r}"
315
+ )
316
+ raise
317
+
318
+ # Step 2: swap. If the original table exists, rename it aside first so
319
+ # that the rename of tmp -> target is guaranteed to succeed. Then drop
320
+ # the old copy (best-effort, warn on failure).
321
+ target_exists = self.o.exist_table(table_name)
322
+ if target_exists:
323
+ try:
324
+ self.o.run_sql(f"ALTER TABLE {table_name} RENAME TO {old_name};")
325
+ except Exception:
326
+ # Rename of the live target failed — do not touch it. Drop the
327
+ # tmp table so we don't leak it, then re-raise.
328
+ try:
329
+ self.o.delete_table(tmp_name, if_exists=True)
330
+ except Exception as cleanup_exc: # noqa: BLE001
331
+ logger.warning(
332
+ f"upload_df: failed to drop temp table {tmp_name} after rename-target failure: {cleanup_exc!r}"
333
+ )
334
+ raise
335
+
336
+ try:
337
+ self.o.run_sql(f"ALTER TABLE {tmp_name} RENAME TO {table_name};")
338
+ except Exception:
339
+ # tmp -> target rename failed. If we already moved the original,
340
+ # try to restore it so the caller is not left with a missing table.
341
+ if target_exists:
342
+ try:
343
+ self.o.run_sql(f"ALTER TABLE {old_name} RENAME TO {table_name};")
344
+ logger.warning(
345
+ f"upload_df: swap of {tmp_name} -> {table_name} failed; restored original from {old_name}"
346
+ )
347
+ except Exception as restore_exc: # noqa: BLE001
348
+ logger.error(
349
+ f"upload_df: swap of {tmp_name} -> {table_name} failed AND restore of "
350
+ f"{old_name} -> {table_name} failed: {restore_exc!r}. "
351
+ f"Manual recovery required — original data is in {old_name}."
352
+ )
353
+ try:
354
+ self.o.delete_table(tmp_name, if_exists=True)
355
+ except Exception: # noqa: BLE001
356
+ pass
357
+ raise
358
+
359
+ # Step 3: drop the moved-aside original — best-effort, do not fail the
360
+ # call if this cleanup errors, since the swap already succeeded.
361
+ if target_exists:
362
+ try:
363
+ self.o.delete_table(old_name, if_exists=True)
364
+ except Exception as cleanup_exc: # noqa: BLE001
365
+ logger.warning(
366
+ f"upload_df: swap succeeded but failed to drop old copy {old_name}: {cleanup_exc!r}. "
367
+ f"Safe to delete manually."
368
+ )
270
369
 
271
- if bool(partition):
272
- with t.open_writer(partition=partition, create_partition=True) as writer:
273
- writer.write(df.values.tolist())
274
- else:
275
- with t.open_writer() as writer:
276
- writer.write(df.values.tolist())
277
370
  logger.info(f'<<<< 完成数据入表{table_name}: shape={df.shape} >>>>')
278
371
 
279
372
  def insert_df(self, df, table_name, overwrite=True, partition=None):
@@ -186,6 +186,40 @@ def proc_means_by_grp(data, varlist, groupby=None, spec_missing_value=None, q=No
186
186
  return means_rpt
187
187
 
188
188
 
189
+ _SCREENING_MEANS_COLS = ["attribute", "N_ALL", "N", "MISSING_RATE", "MIN", "MEAN", "MAX"]
190
+
191
+
192
+ def proc_means_for_screening(data, varlist, spec_missing_value=None, q=None):
193
+ """Build a uniform means table for IV screening reports.
194
+
195
+ ``proc_means_by_grp`` returns object stats (unique/top/freq) when numeric and
196
+ categorical columns are melted together. This helper splits by dtype so MIN,
197
+ MEAN, and MAX are always present (NaN for non-numeric features).
198
+ """
199
+ if not varlist:
200
+ return pd.DataFrame(columns=_SCREENING_MEANS_COLS)
201
+
202
+ numeric = [v for v in varlist if v in data.columns and pd.api.types.is_numeric_dtype(data[v])]
203
+ other = [v for v in varlist if v not in numeric and v in data.columns]
204
+ frames = []
205
+
206
+ if numeric:
207
+ frames.append(proc_means_by_grp(data, numeric, spec_missing_value=spec_missing_value, q=q))
208
+
209
+ for var in other:
210
+ part = proc_means_by_grp(data, [var], spec_missing_value=spec_missing_value, q=q)
211
+ for col in ("MIN", "MEAN", "MAX"):
212
+ if col not in part.columns:
213
+ part[col] = np.nan
214
+ frames.append(part)
215
+
216
+ if not frames:
217
+ return pd.DataFrame(columns=_SCREENING_MEANS_COLS)
218
+
219
+ out = pd.concat(frames, ignore_index=True)
220
+ return out[_SCREENING_MEANS_COLS]
221
+
222
+
189
223
  class DistributionShiftAnalyzer:
190
224
  """分布偏移分析器。
191
225
 
@@ -25,6 +25,7 @@ class proc_means:
25
25
  def group_sum(self): ...
26
26
  def __call__(self, q = None): ...
27
27
  def proc_means_by_grp(data, varlist, groupby = None, spec_missing_value = None, q = None): ...
28
+ def proc_means_for_screening(data, varlist, spec_missing_value = None, q = None): ...
28
29
 
29
30
  class DistributionShiftAnalyzer:
30
31
  def __init__(self, data, grp_name, benchmark_value): ...
@@ -6,7 +6,7 @@
6
6
  import pandas as pd
7
7
  from tqdm import tqdm
8
8
 
9
- from .Distribution_Tool import proc_means_by_grp
9
+ from .Distribution_Tool import proc_means_by_grp, proc_means_for_screening
10
10
  import logging
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -208,14 +208,15 @@ class VarExtractionInsights:
208
208
  high_iv_summary = iv_info_res.query(f"IV >= {iv_cut}").round(4)
209
209
  high_iv_varlist = high_iv_summary['var'].tolist()
210
210
 
211
- means = proc_means_by_grp(data, high_iv_varlist, spec_missing_value=self.missing_rate_ref)
212
-
213
211
  if len(high_iv_varlist) == 0:
214
212
  logger.info(f"WARNING: No variable with IV >= {iv_cut}")
215
- means = means.rename(columns={"index": "attribute"})
213
+
214
+ means = proc_means_for_screening(
215
+ data, high_iv_varlist, spec_missing_value=self.missing_rate_ref,
216
+ )
216
217
 
217
218
  fnl_summary = high_iv_summary.merge(
218
- means[['attribute', 'N_ALL', 'N', 'MISSING_RATE', 'MIN', 'MEAN', 'MAX']],
219
+ means,
219
220
  left_on=['var'],
220
221
  right_on=['attribute'],
221
222
  how='left'
@@ -13,6 +13,7 @@ from Modeling_Tool.Core.sample_weight_utils import resolve_sample_weight
13
13
 
14
14
  from .Weighted_Screen import (
15
15
  WeightedScreenResult,
16
+ _apply_missing_rate_stage,
16
17
  _apply_stage_keep,
17
18
  _corr_dedup_weighted,
18
19
  _legacy_unweighted_screen,
@@ -58,6 +59,8 @@ class FeatureScreenConfig:
58
59
  corr_use_woe_bins: bool = False
59
60
  corr_nan_policy: Literal["pairwise", "median_fill", "raise"] = "pairwise"
60
61
  on_empty_stage: Literal["keep_all_warn", "raise"] = "keep_all_warn"
62
+ missing_rate_threshold: float | None = None
63
+ missing_rate_ref: float | int = -999999
61
64
  woe_engine: str = "equal_freq"
62
65
  woe_fit_query: str | None = None
63
66
  woe_params: dict[str, Any] = field(
@@ -105,6 +108,8 @@ def screen_config_from_mapping(
105
108
  corr_use_woe_bins=bool(cfg.get("corr_use_woe_bins", False)),
106
109
  corr_nan_policy=str(cfg.get("corr_nan_policy", "pairwise")), # type: ignore[arg-type]
107
110
  on_empty_stage=str(cfg.get("on_empty_stage", "keep_all_warn")), # type: ignore[arg-type]
111
+ missing_rate_threshold=cfg.get("missing_rate_threshold"),
112
+ missing_rate_ref=cfg.get("missing_rate_ref", -999999),
108
113
  woe_engine=str(woe_engine or cfg.get("woe_engine", "equal_freq")),
109
114
  woe_fit_query=woe_fit_query if woe_fit_query is not None else cfg.get("woe_fit_query"),
110
115
  woe_params=dict(FeatureScreenConfig().woe_params | dict(woe_params or cfg.get("woe_params") or {})),
@@ -211,6 +216,14 @@ def _woe_bins_unweighted_screen(
211
216
  ins, oos, oot = splits["ins"], splits["oos"], splits["oot"]
212
217
  current = list(feature_cols)
213
218
  summary_rows = [_summary_row("initial", len(feature_cols), len(current), None, None)]
219
+ current, missing_rate_table, missing_rate_dropped = _apply_missing_rate_stage(
220
+ ins,
221
+ current,
222
+ summary_rows,
223
+ missing_rate_threshold=config.missing_rate_threshold,
224
+ missing_rate_ref=config.missing_rate_ref,
225
+ on_empty_stage=config.on_empty_stage,
226
+ )
214
227
  binner = _resolve_screening_binner(splits, feature_cols, target_col, config, prefit_woe_engine)
215
228
 
216
229
  psi_table = pd.DataFrame(columns=["var", "psi_ins_oos", "psi_ins_oot", "psi_max"])
@@ -303,6 +316,8 @@ def _woe_bins_unweighted_screen(
303
316
  psi_table=psi_table,
304
317
  corr_dropped=corr_dropped,
305
318
  summary=pd.DataFrame(summary_rows),
319
+ missing_rate_table=missing_rate_table,
320
+ missing_rate_dropped=missing_rate_dropped,
306
321
  )
307
322
 
308
323
 
@@ -328,6 +343,15 @@ def _weighted_woe_bins_screen(
328
343
  y_ins = ins[target_col].astype(float).to_numpy()
329
344
  current = list(feature_cols)
330
345
  summary_rows = [_summary_row("initial", len(feature_cols), len(current), None, weight_col)]
346
+ current, missing_rate_table, missing_rate_dropped = _apply_missing_rate_stage(
347
+ ins,
348
+ current,
349
+ summary_rows,
350
+ missing_rate_threshold=config.missing_rate_threshold,
351
+ missing_rate_ref=config.missing_rate_ref,
352
+ weight_col=weight_col,
353
+ on_empty_stage=config.on_empty_stage,
354
+ )
331
355
 
332
356
  binner = prefit_woe_engine
333
357
  if binner is None:
@@ -438,6 +462,8 @@ def _weighted_woe_bins_screen(
438
462
  psi_table=psi_table,
439
463
  corr_dropped=corr_dropped,
440
464
  summary=pd.DataFrame(summary_rows),
465
+ missing_rate_table=missing_rate_table,
466
+ missing_rate_dropped=missing_rate_dropped,
441
467
  )
442
468
 
443
469
 
@@ -485,6 +511,8 @@ def feature_screen(
485
511
  corr_nan_policy=cfg.corr_nan_policy,
486
512
  on_empty_stage=cfg.on_empty_stage,
487
513
  prefit_woe_engine=prefit_woe_engine,
514
+ missing_rate_threshold=cfg.missing_rate_threshold,
515
+ missing_rate_ref=cfg.missing_rate_ref,
488
516
  )
489
517
 
490
518
  if use_woe_bins or prefit_woe_engine is not None:
@@ -515,6 +543,8 @@ def feature_screen(
515
543
  plot_outputs=cfg.plot_outputs,
516
544
  iv_equal_freq=cfg.iv_equal_freq,
517
545
  on_empty_stage=cfg.on_empty_stage,
546
+ missing_rate_threshold=cfg.missing_rate_threshold,
547
+ missing_rate_ref=cfg.missing_rate_ref,
518
548
  )
519
549
 
520
550
 
@@ -7,12 +7,20 @@ to measure the distribution drift between expected and actual datasets.
7
7
  Author: Matrix Agent
8
8
  """
9
9
 
10
+ import warnings
11
+
10
12
  import numpy as np
11
13
  import pandas as pd
12
14
  from typing import Union, List, Dict, Optional, Tuple, Callable, Any
13
15
  from tqdm import tqdm
14
16
  from Modeling_Tool.Core.Binning_Tool import quick_binning
15
17
 
18
+ # Missing bucket label used across PSI helpers when ``missing_policy="include"``
19
+ # (the 0.4.2 default). Kept identical to the sentinel already used by
20
+ # ``Feature.Weighted_Screen`` and ``WOE_Adapter`` so downstream aggregators can
21
+ # recognise it without special-casing.
22
+ _MISSING_BIN = "__MISSING__"
23
+
16
24
  # ============================================================================
17
25
  # Classes
18
26
  # ============================================================================
@@ -460,14 +468,29 @@ def _calculate_single_psi(
460
468
  return_details: bool = False,
461
469
  min_bin_prop: float = 0.05,
462
470
  content: float = 1e-6,
463
- precision: int = 5
471
+ precision: int = 5,
472
+ missing_policy: str = "drop",
464
473
  ) -> Union[float, Tuple[float, pd.DataFrame]]:
465
474
  """
466
475
  Calculate Population Stability Index (PSI) for a single variable.
467
-
476
+
468
477
  This function bins both expected and actual series using the same breakpoints,
469
478
  then calculates PSI based on the distribution difference between them.
470
-
479
+
480
+ Missing handling (0.4.2, N29)
481
+ ------------------------------
482
+ Before 0.4.2, both series were unconditionally passed through ``.dropna()``
483
+ before binning. Any drift in the *missing rate itself* — the single most
484
+ common early signal of a data-pipeline break — was silently invisible in
485
+ the returned PSI. 0.4.2 adds the ``missing_policy`` parameter so callers
486
+ can opt in to the corrected behaviour. The default remains ``"drop"`` for
487
+ strict backward compatibility with the pre-0.4.2 numeric output; the next
488
+ minor release will flip the default to ``"include"``. Pass
489
+ ``missing_policy="include"`` to route NaN rows through a dedicated
490
+ ``"__MISSING__"`` bin on both sides so missing-rate drift contributes to
491
+ the PSI. Pass ``missing_policy="warn_and_drop"`` to keep the old numbers
492
+ but at least surface a ``RuntimeWarning`` naming the two NaN counts.
493
+
471
494
  Parameters
472
495
  ----------
473
496
  expected_series : pandas.Series
@@ -486,62 +509,112 @@ def _calculate_single_psi(
486
509
  Small value to avoid division by zero. Default is 1e-6.
487
510
  precision : int, optional
488
511
  Decimal precision. Default is 5.
489
-
512
+ missing_policy : {"drop", "include", "warn_and_drop"}, optional
513
+ How NaN rows are handled. ``"drop"`` (default in 0.4.2) reproduces the
514
+ pre-0.4.2 behaviour and silently excludes NaN rows from both sides
515
+ before binning. ``"include"`` treats NaN as its own ``"__MISSING__"``
516
+ bin so missing-rate drift shows up in the PSI — recommended for any
517
+ production drift monitor, and will become the default in the next
518
+ minor release. ``"warn_and_drop"`` behaves like ``"drop"`` but emits
519
+ a ``RuntimeWarning`` naming the two NaN counts so the caller sees what
520
+ was dropped.
521
+
490
522
  Returns
491
523
  -------
492
524
  float or tuple
493
525
  If return_details is False: Returns total PSI value.
494
526
  If return_details is True: Returns tuple of (PSI value, details DataFrame).
495
-
527
+
496
528
  Notes
497
529
  -----
498
530
  PSI Formula: Σ (Actual% - Expected%) * ln(Actual% / Expected%)
499
531
  A PSI < 0.1 indicates stable population, 0.1-0.25 suggests some change,
500
532
  and > 0.25 indicates significant drift.
501
533
  """
502
- # Drop NA
534
+ if missing_policy not in {"include", "drop", "warn_and_drop"}:
535
+ raise ValueError(
536
+ f"_calculate_single_psi: missing_policy must be one of "
537
+ f"'include', 'drop', 'warn_and_drop'; got {missing_policy!r}."
538
+ )
539
+
540
+ # Record NaN counts before splitting so we can (a) re-attach the missing
541
+ # bin under "include" and (b) warn under "warn_and_drop".
542
+ n_expected = int(len(expected_series))
543
+ n_actual = int(len(actual_series))
544
+ n_expected_na = int(expected_series.isna().sum())
545
+ n_actual_na = int(actual_series.isna().sum())
546
+
547
+ if missing_policy == "warn_and_drop" and (n_expected_na or n_actual_na):
548
+ warnings.warn(
549
+ f"_calculate_single_psi: dropping NaN rows before binning. "
550
+ f"expected: {n_expected_na}/{n_expected} NaN, "
551
+ f"actual: {n_actual_na}/{n_actual} NaN. "
552
+ f"Pass missing_policy='include' to treat NaN as its own bin so "
553
+ f"missing-rate drift is captured.",
554
+ RuntimeWarning,
555
+ stacklevel=2,
556
+ )
557
+
503
558
  expected_clean = expected_series.dropna()
504
559
  actual_clean = actual_series.dropna()
505
-
560
+
506
561
  # Bin the expected data to get breakpoints
507
562
  expected_bins, breakpoints = quick_binning(
508
- pd.DataFrame(expected_clean),
509
- expected_clean.name,
510
- labels=None,
511
- nbins=buckets,
512
- precision=precision,
513
- equal_freq=equal_freq,
514
- right=True,
563
+ pd.DataFrame(expected_clean),
564
+ expected_clean.name,
565
+ labels=None,
566
+ nbins=buckets,
567
+ precision=precision,
568
+ equal_freq=equal_freq,
569
+ right=True,
515
570
  include_lowest=False,
516
- min_bin_prop=min_bin_prop,
517
- tree_binning=False,
518
- target=None,
571
+ min_bin_prop=min_bin_prop,
572
+ tree_binning=False,
573
+ target=None,
519
574
  random_state=42
520
575
  )
521
-
576
+
522
577
  # Bin the actual data using the same breakpoints
523
578
  actual_bins, _ = quick_binning(
524
- pd.DataFrame(actual_clean),
525
- actual_clean.name,
526
- labels=None,
527
- nbins=list(breakpoints),
528
- precision=precision,
529
- equal_freq=equal_freq,
530
- right=True,
579
+ pd.DataFrame(actual_clean),
580
+ actual_clean.name,
581
+ labels=None,
582
+ nbins=list(breakpoints),
583
+ precision=precision,
584
+ equal_freq=equal_freq,
585
+ right=True,
531
586
  include_lowest=False,
532
- min_bin_prop=min_bin_prop,
533
- tree_binning=False,
534
- target=None,
587
+ min_bin_prop=min_bin_prop,
588
+ tree_binning=False,
589
+ target=None,
535
590
  random_state=42
536
591
  )
537
-
538
- # Get bin counts
592
+
593
+ # Get bin counts (finite-value rows only, from quick_binning)
539
594
  expected_count = expected_bins.value_counts(normalize=False, sort=False)
540
595
  actual_count = actual_bins.value_counts(normalize=False, sort=False)
541
-
542
- # Get bin proportions
543
- expected_percents = expected_bins.value_counts(normalize=True, sort=False)
544
- actual_percents = actual_bins.value_counts(normalize=True, sort=False)
596
+
597
+ if missing_policy == "include":
598
+ # Re-attach the __MISSING__ bin so that missing-rate drift contributes
599
+ # to the PSI on both sides. The denominators use the *original* row
600
+ # counts (before dropna) so the fractions are directly comparable.
601
+ if n_expected_na > 0:
602
+ expected_count = pd.concat(
603
+ [expected_count, pd.Series({_MISSING_BIN: n_expected_na})]
604
+ )
605
+ if n_actual_na > 0:
606
+ actual_count = pd.concat(
607
+ [actual_count, pd.Series({_MISSING_BIN: n_actual_na})]
608
+ )
609
+ expected_denom = float(n_expected) if n_expected > 0 else 1.0
610
+ actual_denom = float(n_actual) if n_actual > 0 else 1.0
611
+ expected_percents = expected_count / expected_denom
612
+ actual_percents = actual_count / actual_denom
613
+ else:
614
+ # "drop" / "warn_and_drop": legacy behaviour, denominators are the
615
+ # NaN-dropped row counts, so missing-rate drift is invisible.
616
+ expected_percents = expected_bins.value_counts(normalize=True, sort=False)
617
+ actual_percents = actual_bins.value_counts(normalize=True, sort=False)
545
618
 
546
619
  # Ensure both series have the same bin indices
547
620
  all_bins = expected_percents.index.union(actual_percents.index)
@@ -19,7 +19,7 @@ from .Feature_Insights import (
19
19
  VarExtractionInsights as _BaseVarExtractionInsights,
20
20
  var_corr_filter,
21
21
  )
22
- from .Distribution_Tool import proc_means_by_grp
22
+ from .Distribution_Tool import proc_means_for_screening
23
23
 
24
24
 
25
25
  def _psi_from_bins(expected_bins: pd.Series, current_bins: pd.Series, content: float) -> float:
@@ -110,9 +110,9 @@ def _screening_summary_from_engine(
110
110
  high_iv_summary = pd.DataFrame(rows).query(f"iv >= {iv_cut}").round(4)
111
111
  high_iv_varlist = high_iv_summary["var"].tolist()
112
112
  if high_iv_varlist:
113
- means = proc_means_by_grp(data, high_iv_varlist, spec_missing_value=missing_rate_ref)
113
+ means = proc_means_for_screening(data, high_iv_varlist, spec_missing_value=missing_rate_ref)
114
114
  summary = high_iv_summary.merge(
115
- means[["attribute", "N_ALL", "N", "MISSING_RATE", "MIN", "MEAN", "MAX"]],
115
+ means,
116
116
  left_on="var",
117
117
  right_on="attribute",
118
118
  how="left",