SuperModelingFactory 0.3.15__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.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/ODPS_Tool.py +102 -9
  2. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/PSI_Tool.py +107 -34
  3. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Weighted_Screen.py +16 -2
  4. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/_common.py +109 -3
  5. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/credit_model.py +36 -8
  6. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/feature_validation.py +64 -21
  7. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/reject_inference.py +58 -4
  8. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/sample_analysis.py +50 -0
  9. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/score_comparison.py +20 -3
  10. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/score_consistency_uat.py +54 -6
  11. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Reject_Infer.py +63 -7
  12. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Sample_Split.py +33 -22
  13. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/__init__.py +1 -1
  14. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/PKG-INFO +2 -2
  15. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/README.md +1 -1
  16. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/PKG-INFO +2 -2
  17. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/pyproject.toml +1 -1
  18. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/setup.py +1 -1
  19. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/ExcelMaster/ExcelFormatTool.py +0 -0
  20. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/ExcelMaster/ExcelMaster.py +0 -0
  21. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/ExcelMaster/Template.py +0 -0
  22. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/ExcelMaster/Utility.py +0 -0
  23. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/ExcelMaster/__init__.py +0 -0
  24. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/LICENSE +0 -0
  25. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/MANIFEST.in +0 -0
  26. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Binning_Tool.py +0 -0
  27. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Binning_Tool.pyi +0 -0
  28. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Check_DuckDB_Compatibility.py +0 -0
  29. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Json_Data_Converter.py +0 -0
  30. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Model_Registry_Tool.py +0 -0
  31. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Parallel_Engine.py +0 -0
  32. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Parallel_ODPS_Manager.py +0 -0
  33. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Proc_Compare.py +0 -0
  34. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Slope_Tool.py +0 -0
  35. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/Slope_Tool.pyi +0 -0
  36. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/XOR_Encryptor.py +0 -0
  37. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/XOR_Encryptor.pyi +0 -0
  38. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/__init__.py +0 -0
  39. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/kDataFrame.py +0 -0
  40. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/kDataFrame.pyi +0 -0
  41. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/sample_weight_utils.py +0 -0
  42. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Core/utils.py +0 -0
  43. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/Evaluation_Tool.py +0 -0
  44. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/Evaluation_Tool.pyi +0 -0
  45. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/Model_Eval_Tool.py +0 -0
  46. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/Model_Eval_Tool.pyi +0 -0
  47. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/__init__.py +0 -0
  48. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/evaluate_model.py +0 -0
  49. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/evaluate_model.pyi +0 -0
  50. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Eval/weighted_eval_utils.py +0 -0
  51. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Explainability/Coalition_Structure.py +0 -0
  52. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Explainability/Model_Explainer.py +0 -0
  53. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Explainability/__init__.py +0 -0
  54. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Distribution_Tool.py +0 -0
  55. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Distribution_Tool.pyi +0 -0
  56. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Feature_Insights.py +0 -0
  57. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Feature_Insights.pyi +0 -0
  58. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/Feature_Screen.py +0 -0
  59. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/PSI_Tool.pyi +0 -0
  60. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +0 -0
  61. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Feature/__init__.py +0 -0
  62. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/Backward_Tool.py +0 -0
  63. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/Backward_Tool.pyi +0 -0
  64. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/GBM_Search_Tool.py +0 -0
  65. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/GBM_Tool.py +0 -0
  66. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/GBM_Tool.pyi +0 -0
  67. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/LRM_Tool.py +0 -0
  68. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/LRM_Tool.pyi +0 -0
  69. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Model/__init__.py +0 -0
  70. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/__init__.py +0 -0
  71. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/mock_sample.py +0 -0
  72. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/orchestrator.py +0 -0
  73. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Pipeline/screening_artifact.py +0 -0
  74. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Distribution_Adaptation.py +0 -0
  75. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Distribution_Adaptation.pyi +0 -0
  76. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Reject_Infer.pyi +0 -0
  77. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/Sample_Split.pyi +0 -0
  78. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/Sample/__init__.py +0 -0
  79. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/UAT/UAT_Consistency_Checker.py +0 -0
  80. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/UAT/__init__.py +0 -0
  81. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Adapter.py +0 -0
  82. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Adapter.pyi +0 -0
  83. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Master.py +0 -0
  84. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Master.pyi +0 -0
  85. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Monotone_Binner.py +0 -0
  86. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +0 -0
  87. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Plot_Tool.py +0 -0
  88. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Plot_Tool.pyi +0 -0
  89. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Report_Builder.py +0 -0
  90. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Report_Builder.pyi +0 -0
  91. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Tool.py +0 -0
  92. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/WOE_Tool.pyi +0 -0
  93. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/__init__.py +0 -0
  94. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/plot_woe_tool.py +0 -0
  95. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/WOE/plot_woe_tool.pyi +0 -0
  96. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/ref_font/KaiTi.ttf +0 -0
  97. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/ref_font/WeiRuanYaHei.ttf +0 -0
  98. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/ref_font/__init__.py +0 -0
  99. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Modeling_Tool/ref_font/simsun.ttc +0 -0
  100. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Report/Report_Tool.py +0 -0
  101. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/Report/__init__.py +0 -0
  102. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/SOURCES.txt +0 -0
  103. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/dependency_links.txt +0 -0
  104. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/not-zip-safe +0 -0
  105. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/requires.txt +0 -0
  106. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/SuperModelingFactory.egg-info/top_level.txt +0 -0
  107. {supermodelingfactory-0.3.15 → supermodelingfactory-0.4.2}/requirements.txt +0 -0
  108. {supermodelingfactory-0.3.15 → 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):
@@ -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)
@@ -113,11 +113,25 @@ def _weighted_bin_distribution(
113
113
  w: np.ndarray,
114
114
  content: float,
115
115
  ) -> pd.Series:
116
+ """Return the weighted bin distribution as an observed-only frequency
117
+ Series.
118
+
119
+ Fix (0.4.2, N23): the pre-0.4.2 version unioned ``_MISSING_BIN`` into the
120
+ returned index and clipped every entry to ``content`` (a small positive
121
+ floor such as ``1e-5``). When only one side of a PSI comparison had missing
122
+ values, that forced a large synthetic mass on the missing bin of the side
123
+ that actually observed zero missings — which then propagated through
124
+ ``_psi_from_distributions`` as a large spurious PSI component. The fix is
125
+ to return only the bins actually observed in ``bins``; the alignment and
126
+ zero-safety are done exactly once, in ``_psi_from_distributions``, so both
127
+ sides are treated symmetrically. The ``content`` parameter is retained for
128
+ signature stability but is now unused in this helper.
129
+ """
130
+ del content # retained for signature stability; alignment is elsewhere.
116
131
  df = pd.DataFrame({"bin": bins, "w": w})
117
132
  dist = df.groupby("bin", dropna=False)["w"].sum()
118
133
  total = float(dist.sum()) or 1.0
119
- dist = dist / total
120
- return dist.reindex(dist.index.union([_MISSING_BIN]), fill_value=0.0).clip(lower=content)
134
+ return dist / total
121
135
 
122
136
 
123
137
  def _psi_from_distributions(expected: pd.Series, actual: pd.Series, content: float) -> float:
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import re
5
+ import warnings
5
6
  from dataclasses import fields, is_dataclass
6
7
  from pathlib import Path
7
8
  from typing import Any, Iterable, Mapping
@@ -307,9 +308,45 @@ def predict_positive(model: Any, data: pd.DataFrame, feature_cols: list[str]) ->
307
308
  raise TypeError(f"Model {type(model)!r} does not expose predict/predict_proba")
308
309
 
309
310
  pred_arr = np.asarray(pred)
310
- if pred_arr.ndim == 2 and pred_arr.shape[1] > 1:
311
- return pred_arr[:, 1]
312
- return pred_arr.reshape(-1)
311
+ n_rows = len(data)
312
+
313
+ if pred_arr.ndim == 2:
314
+ if pred_arr.shape[1] == 2:
315
+ # sklearn convention: column 1 is positive class
316
+ result = pred_arr[:, 1]
317
+ elif pred_arr.shape[1] == 1:
318
+ result = pred_arr[:, 0]
319
+ else:
320
+ raise ValueError(
321
+ f"predict_proba returned unexpected shape {pred_arr.shape}; "
322
+ f"expected 1-D or 2-D with 1-2 columns."
323
+ )
324
+ elif pred_arr.ndim == 1:
325
+ result = pred_arr
326
+ else:
327
+ raise ValueError(
328
+ f"predict_proba returned unexpected ndim={pred_arr.ndim} "
329
+ f"(shape={pred_arr.shape}); expected 1-D or 2-D."
330
+ )
331
+
332
+ result = result.reshape(-1)
333
+ if len(result) != n_rows:
334
+ raise ValueError(
335
+ f"predict_positive length mismatch: got {len(result)} predictions "
336
+ f"for {n_rows} input rows. Model {type(model).__name__} may have "
337
+ f"dropped rows silently."
338
+ )
339
+
340
+ finite_mask = np.isfinite(result)
341
+ if not finite_mask.all():
342
+ n_bad = int((~finite_mask).sum())
343
+ warnings.warn(
344
+ f"predict_positive: {n_bad}/{n_rows} predictions are NaN/Inf "
345
+ f"from model {type(model).__name__}. Downstream scoring may be affected.",
346
+ RuntimeWarning,
347
+ stacklevel=2,
348
+ )
349
+ return result
313
350
 
314
351
 
315
352
  def add_dataset_with_optional_weight(
@@ -364,3 +401,72 @@ def write_basic_excel(
364
401
 
365
402
  em.close_workbook()
366
403
  return report_path
404
+
405
+
406
+ def copy_column_length_checked(
407
+ dst: pd.DataFrame,
408
+ src: pd.DataFrame,
409
+ col: str,
410
+ *,
411
+ dst_name: str,
412
+ src_name: str,
413
+ ) -> None:
414
+ """Positionally copy ``src[col]`` into ``dst[col]`` with a length guard.
415
+
416
+ ``warm_start_score_col`` in credit_model relies on WOE-transformed splits
417
+ lining up row-for-row with the raw splits they were transformed from. That
418
+ assumption held in practice, but the historical implementation used a bare
419
+ ``dst[col] = src[col].to_numpy()`` which would corrupt the join whenever the
420
+ two frames drifted in length (fit-query filtering, prior ``.dropna``, etc.)
421
+ with no error. This helper enforces the invariant explicitly.
422
+
423
+ Values are copied by position (``.to_numpy()``) — not by index alignment —
424
+ which matches the way ``adapter.transform`` returns rows in the same order
425
+ they entered. Lengths must match exactly; otherwise raise ``ValueError``
426
+ with the frame names so the caller can debug.
427
+ """
428
+ if len(dst) != len(src):
429
+ raise ValueError(
430
+ f"Cannot copy column {col!r} from {src_name!r} (len={len(src)}) to "
431
+ f"{dst_name!r} (len={len(dst)}): row counts must match. This usually "
432
+ f"means WOE transformation dropped or reordered rows relative to the "
433
+ f"source frame — check woe_fit_query / dropna behavior."
434
+ )
435
+ dst[col] = src[col].to_numpy()
436
+
437
+
438
+ def split_oot_by_flag(
439
+ data: pd.DataFrame,
440
+ oot_col: str | None,
441
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
442
+ """Split a frame into (ins_oos, oot) based on an OOT flag column.
443
+
444
+ Rows where ``oot_col`` equals 0 (after numeric coercion) are treated as
445
+ in-sample/out-of-sample, everything else is out-of-time.
446
+
447
+ The column is coerced with ``pd.to_numeric(errors="raise")`` so string /
448
+ boolean / mixed-dtype flags raise a clear error instead of silently routing
449
+ every row to ``oot`` (previous behavior: string ``"0"`` != integer ``0``,
450
+ so every row ended up in ``oot`` and ``ins_oos`` came out empty).
451
+
452
+ If ``oot_col`` is falsy or absent from ``data``, the full frame is returned
453
+ as ``ins_oos`` and an empty frame with matching columns as ``oot`` — same
454
+ as the historical fallback.
455
+ """
456
+ if not oot_col or oot_col not in data.columns:
457
+ empty = pd.DataFrame(columns=data.columns)
458
+ return data, empty
459
+
460
+ raw = data[oot_col]
461
+ try:
462
+ numeric = pd.to_numeric(raw, errors="raise")
463
+ except (ValueError, TypeError) as exc:
464
+ raise TypeError(
465
+ f"oot_col {oot_col!r} must be numeric (0 = ins_oos, non-zero = oot); "
466
+ f"got dtype={raw.dtype} with non-numeric values. Original error: {exc}"
467
+ ) from exc
468
+
469
+ mask_oot = numeric.fillna(0).astype(float) != 0.0
470
+ ins_oos = data.loc[~mask_oot].copy()
471
+ oot = data.loc[mask_oot].copy()
472
+ return ins_oos, oot
@@ -11,11 +11,13 @@ from ._common import (
11
11
  add_dataset_with_optional_weight,
12
12
  apply_woe_fit_query,
13
13
  as_list,
14
+ copy_column_length_checked,
14
15
  make_dirs,
15
16
  merge_dict,
16
17
  persist_explain_outputs,
17
18
  predict_positive,
18
19
  safe_to_csv,
20
+ split_oot_by_flag,
19
21
  validate_woe_fit_query_columns,
20
22
  validate_woe_fit_query_syntax,
21
23
  write_basic_excel,
@@ -417,8 +419,7 @@ class CreditModelPipeline:
417
419
  raise ValueError(f"split_col {cfg.split_col!r} must contain non-empty ins and oos samples")
418
420
 
419
421
  if cfg.oot_col and cfg.oot_col in work.columns:
420
- ins_oos = work[work[cfg.oot_col] == 0].copy()
421
- oot = work[work[cfg.oot_col] != 0].copy()
422
+ ins_oos, oot = split_oot_by_flag(work, cfg.oot_col)
422
423
  else:
423
424
  ins_oos = work
424
425
  oot = pd.DataFrame(columns=work.columns)
@@ -600,10 +601,22 @@ class CreditModelPipeline:
600
601
  if cfg.warm_start_enabled and cfg.warm_start_score_col:
601
602
  for name, df in woe_splits.items():
602
603
  if cfg.warm_start_score_col not in df.columns and cfg.warm_start_score_col in splits[name].columns:
603
- df[cfg.warm_start_score_col] = splits[name][cfg.warm_start_score_col].to_numpy()
604
+ copy_column_length_checked(
605
+ df,
606
+ splits[name],
607
+ cfg.warm_start_score_col,
608
+ dst_name=f"woe_splits[{name!r}]",
609
+ src_name=f"splits[{name!r}]",
610
+ )
604
611
  for name, df in extra_eval.items():
605
612
  if cfg.warm_start_score_col not in df.columns and cfg.warm_start_score_col in cfg.extra_eval_datasets[name].columns:
606
- df[cfg.warm_start_score_col] = cfg.extra_eval_datasets[name][cfg.warm_start_score_col].to_numpy()
613
+ copy_column_length_checked(
614
+ df,
615
+ cfg.extra_eval_datasets[name],
616
+ cfg.warm_start_score_col,
617
+ dst_name=f"extra_eval[{name!r}]",
618
+ src_name=f"extra_eval_datasets[{name!r}]",
619
+ )
607
620
 
608
621
  return {
609
622
  "engine": adapter,
@@ -697,11 +710,23 @@ class CreditModelPipeline:
697
710
  if cfg.warm_start_enabled and cfg.warm_start_score_col:
698
711
  for name, df in woe_splits.items():
699
712
  if cfg.warm_start_score_col not in df.columns:
700
- df[cfg.warm_start_score_col] = splits[name][cfg.warm_start_score_col].to_numpy()
713
+ copy_column_length_checked(
714
+ df,
715
+ splits[name],
716
+ cfg.warm_start_score_col,
717
+ dst_name=f"woe_splits[{name!r}]",
718
+ src_name=f"splits[{name!r}]",
719
+ )
701
720
  for name, df in extra_eval.items():
702
721
  if cfg.warm_start_score_col not in df.columns:
703
722
  source = cfg.extra_eval_datasets[name]
704
- df[cfg.warm_start_score_col] = source[cfg.warm_start_score_col].to_numpy()
723
+ copy_column_length_checked(
724
+ df,
725
+ source,
726
+ cfg.warm_start_score_col,
727
+ dst_name=f"extra_eval[{name!r}]",
728
+ src_name=f"extra_eval_datasets[{name!r}]",
729
+ )
705
730
 
706
731
  return {
707
732
  "engine": engine,
@@ -808,7 +833,9 @@ class CreditModelPipeline:
808
833
  if name == "lr" and cfg.use_lr_search_params and hasattr(self, "_lr_best_params"):
809
834
  params = merge_dict(params, getattr(self, "_lr_best_params", {}))
810
835
  if name == "lr":
811
- lr = LRMaster(params=params or None, standardize=bool(params.pop("standardize", False)) if params else False)
836
+ lr_params = dict(params) if params else {}
837
+ standardize = bool(lr_params.pop("standardize", False))
838
+ lr = LRMaster(params=lr_params or None, standardize=standardize)
812
839
  lr.fit(
813
840
  data=train,
814
841
  varlist=feature_cols,
@@ -999,7 +1026,8 @@ class CreditModelPipeline:
999
1026
  results = {}
1000
1027
  if not as_list(cfg.optuna_models):
1001
1028
  return results
1002
- search_spaces = cfg.optuna_params.get("search_spaces") or self._default_search_spaces()
1029
+ user_search_spaces = cfg.optuna_params.get("search_spaces")
1030
+ search_spaces = self._default_search_spaces() if user_search_spaces is None else user_search_spaces
1003
1031
  for raw_name in as_list(cfg.optuna_models):
1004
1032
  name = str(raw_name).lower()
1005
1033
  if name not in {"lgb", "xgb", "cat"} or name not in search_spaces: