SuperModelingFactory 0.3.15__tar.gz → 0.5.0__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.5.0}/Modeling_Tool/Core/ODPS_Tool.py +102 -9
  2. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Feature/PSI_Tool.py +203 -53
  3. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Feature/Weighted_Screen.py +16 -2
  4. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Pipeline/_common.py +109 -3
  5. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Pipeline/credit_model.py +36 -8
  6. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Pipeline/feature_validation.py +64 -21
  7. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Pipeline/reject_inference.py +58 -4
  8. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Pipeline/sample_analysis.py +50 -0
  9. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Pipeline/score_comparison.py +20 -3
  10. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Pipeline/score_consistency_uat.py +54 -6
  11. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Sample/Reject_Infer.py +63 -7
  12. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Sample/Sample_Split.py +33 -22
  13. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Monotone_Binner.py +76 -0
  14. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/__init__.py +1 -1
  15. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/PKG-INFO +2 -2
  16. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/README.md +1 -1
  17. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/SuperModelingFactory.egg-info/PKG-INFO +2 -2
  18. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/pyproject.toml +1 -1
  19. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/setup.py +1 -1
  20. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/ExcelMaster/ExcelFormatTool.py +0 -0
  21. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/ExcelMaster/ExcelMaster.py +0 -0
  22. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/ExcelMaster/Template.py +0 -0
  23. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/ExcelMaster/Utility.py +0 -0
  24. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/ExcelMaster/__init__.py +0 -0
  25. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/LICENSE +0 -0
  26. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/MANIFEST.in +0 -0
  27. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/Binning_Tool.py +0 -0
  28. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/Binning_Tool.pyi +0 -0
  29. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/Check_DuckDB_Compatibility.py +0 -0
  30. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/Json_Data_Converter.py +0 -0
  31. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/Model_Registry_Tool.py +0 -0
  32. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/Parallel_Engine.py +0 -0
  33. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/Parallel_ODPS_Manager.py +0 -0
  34. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/Proc_Compare.py +0 -0
  35. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/Slope_Tool.py +0 -0
  36. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/Slope_Tool.pyi +0 -0
  37. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/XOR_Encryptor.py +0 -0
  38. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/XOR_Encryptor.pyi +0 -0
  39. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/__init__.py +0 -0
  40. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/kDataFrame.py +0 -0
  41. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/kDataFrame.pyi +0 -0
  42. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/sample_weight_utils.py +0 -0
  43. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Core/utils.py +0 -0
  44. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Eval/Evaluation_Tool.py +0 -0
  45. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Eval/Evaluation_Tool.pyi +0 -0
  46. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Eval/Model_Eval_Tool.py +0 -0
  47. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Eval/Model_Eval_Tool.pyi +0 -0
  48. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Eval/__init__.py +0 -0
  49. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Eval/evaluate_model.py +0 -0
  50. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Eval/evaluate_model.pyi +0 -0
  51. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Eval/weighted_eval_utils.py +0 -0
  52. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Explainability/Coalition_Structure.py +0 -0
  53. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Explainability/Model_Explainer.py +0 -0
  54. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Explainability/__init__.py +0 -0
  55. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Feature/Distribution_Tool.py +0 -0
  56. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Feature/Distribution_Tool.pyi +0 -0
  57. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Feature/Feature_Insights.py +0 -0
  58. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Feature/Feature_Insights.pyi +0 -0
  59. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Feature/Feature_Screen.py +0 -0
  60. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Feature/PSI_Tool.pyi +0 -0
  61. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +0 -0
  62. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Feature/__init__.py +0 -0
  63. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Model/Backward_Tool.py +0 -0
  64. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Model/Backward_Tool.pyi +0 -0
  65. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Model/GBM_Search_Tool.py +0 -0
  66. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Model/GBM_Tool.py +0 -0
  67. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Model/GBM_Tool.pyi +0 -0
  68. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Model/LRM_Tool.py +0 -0
  69. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Model/LRM_Tool.pyi +0 -0
  70. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Model/__init__.py +0 -0
  71. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Pipeline/__init__.py +0 -0
  72. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Pipeline/mock_sample.py +0 -0
  73. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Pipeline/orchestrator.py +0 -0
  74. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Pipeline/screening_artifact.py +0 -0
  75. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Sample/Distribution_Adaptation.py +0 -0
  76. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Sample/Distribution_Adaptation.pyi +0 -0
  77. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Sample/Reject_Infer.pyi +0 -0
  78. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Sample/Sample_Split.pyi +0 -0
  79. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/Sample/__init__.py +0 -0
  80. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/UAT/UAT_Consistency_Checker.py +0 -0
  81. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/UAT/__init__.py +0 -0
  82. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Adapter.py +0 -0
  83. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Adapter.pyi +0 -0
  84. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Master.py +0 -0
  85. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Master.pyi +0 -0
  86. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +0 -0
  87. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Plot_Tool.py +0 -0
  88. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Plot_Tool.pyi +0 -0
  89. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Report_Builder.py +0 -0
  90. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Report_Builder.pyi +0 -0
  91. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Tool.py +0 -0
  92. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/WOE_Tool.pyi +0 -0
  93. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/__init__.py +0 -0
  94. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/plot_woe_tool.py +0 -0
  95. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/WOE/plot_woe_tool.pyi +0 -0
  96. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/ref_font/KaiTi.ttf +0 -0
  97. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/ref_font/WeiRuanYaHei.ttf +0 -0
  98. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/ref_font/__init__.py +0 -0
  99. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Modeling_Tool/ref_font/simsun.ttc +0 -0
  100. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Report/Report_Tool.py +0 -0
  101. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/Report/__init__.py +0 -0
  102. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/SuperModelingFactory.egg-info/SOURCES.txt +0 -0
  103. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/SuperModelingFactory.egg-info/dependency_links.txt +0 -0
  104. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/SuperModelingFactory.egg-info/not-zip-safe +0 -0
  105. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/SuperModelingFactory.egg-info/requires.txt +0 -0
  106. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/SuperModelingFactory.egg-info/top_level.txt +0 -0
  107. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/requirements.txt +0 -0
  108. {supermodelingfactory-0.3.15 → supermodelingfactory-0.5.0}/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
  # ============================================================================
@@ -50,7 +58,8 @@ class PSICalculator:
50
58
  equal_freq: bool = True,
51
59
  min_bin_prop: float = 0.05,
52
60
  content: float = 1e-6,
53
- precision: int = 5
61
+ precision: int = 5,
62
+ missing_policy: str = "include"
54
63
  ):
55
64
  """
56
65
  Initialize PSICalculator with configuration parameters.
@@ -67,12 +76,26 @@ class PSICalculator:
67
76
  Small value to prevent division by zero. Default is 1e-6.
68
77
  precision : int, optional
69
78
  Decimal precision for rounding. Default is 5.
79
+ missing_policy : {"drop", "include", "warn_and_drop"}, optional
80
+ How NaN rows are handled. Default in 0.5.0 is "include", which routes
81
+ NaN rows through a dedicated "__MISSING__" bin so missing-rate drift
82
+ contributes to the PSI. This is the recommended production behaviour
83
+ and became the default in 0.5.0 (previously "drop" in 0.4.2 for
84
+ numeric backward-compat). Pass "drop" to reproduce pre-0.5.0
85
+ numbers, or "warn_and_drop" for legacy numbers with a RuntimeWarning
86
+ naming the NaN counts.
70
87
  """
88
+ if missing_policy not in {"include", "drop", "warn_and_drop"}:
89
+ raise ValueError(
90
+ f"PSICalculator.__init__: missing_policy must be one of "
91
+ f"'include', 'drop', 'warn_and_drop'; got {missing_policy!r}."
92
+ )
71
93
  self.buckets = buckets
72
94
  self.equal_freq = equal_freq
73
95
  self.min_bin_prop = min_bin_prop
74
96
  self.content = content
75
97
  self.precision = precision
98
+ self.missing_policy = missing_policy
76
99
 
77
100
  # def _calculate_single_psi(
78
101
  # self,
@@ -410,7 +433,8 @@ class PSICalculator:
410
433
  varlist: List[str],
411
434
  group_by: Optional[str] = None,
412
435
  group_name: Optional[str] = None,
413
- return_details = False
436
+ return_details = False,
437
+ missing_policy: Optional[str] = None
414
438
  ) -> pd.DataFrame:
415
439
  """
416
440
  Calculate grouped PSI comparing two datasets, using expected as benchmark.
@@ -427,12 +451,18 @@ class PSICalculator:
427
451
  Column to group by in both datasets.
428
452
  group_name : str, optional
429
453
  Specific group column name for multi-group calculation.
454
+ missing_policy : {"drop", "include", "warn_and_drop"}, optional
455
+ How NaN rows are handled. If None (default), falls back to the value
456
+ configured on ``self.missing_policy``. Pass an explicit value to
457
+ override the class-level default for this call only. See
458
+ ``PSICalculator.__init__`` for the semantics of each mode.
430
459
 
431
460
  Returns
432
461
  -------
433
462
  pandas.DataFrame
434
463
  Grouped PSI results.
435
464
  """
465
+ effective_policy = missing_policy if missing_policy is not None else self.missing_policy
436
466
  return calculate_multigroup_psi_two_sets(
437
467
  expected_df = expected_df,
438
468
  actual_df = current_data,
@@ -444,7 +474,8 @@ class PSICalculator:
444
474
  content = self.content,
445
475
  precision = self.precision,
446
476
  group_name = group_name,
447
- return_details = return_details
477
+ return_details = return_details,
478
+ missing_policy = effective_policy
448
479
  )
449
480
 
450
481
 
@@ -460,14 +491,29 @@ def _calculate_single_psi(
460
491
  return_details: bool = False,
461
492
  min_bin_prop: float = 0.05,
462
493
  content: float = 1e-6,
463
- precision: int = 5
494
+ precision: int = 5,
495
+ missing_policy: str = "include",
464
496
  ) -> Union[float, Tuple[float, pd.DataFrame]]:
465
497
  """
466
498
  Calculate Population Stability Index (PSI) for a single variable.
467
-
499
+
468
500
  This function bins both expected and actual series using the same breakpoints,
469
501
  then calculates PSI based on the distribution difference between them.
470
-
502
+
503
+ Missing handling (0.4.2, N29)
504
+ ------------------------------
505
+ Before 0.4.2, both series were unconditionally passed through ``.dropna()``
506
+ before binning. Any drift in the *missing rate itself* — the single most
507
+ common early signal of a data-pipeline break — was silently invisible in
508
+ the returned PSI. 0.4.2 adds the ``missing_policy`` parameter so callers
509
+ can opt in to the corrected behaviour. The default remains ``"drop"`` for
510
+ strict backward compatibility with the pre-0.4.2 numeric output; the next
511
+ minor release will flip the default to ``"include"``. Pass
512
+ ``missing_policy="include"`` to route NaN rows through a dedicated
513
+ ``"__MISSING__"`` bin on both sides so missing-rate drift contributes to
514
+ the PSI. Pass ``missing_policy="warn_and_drop"`` to keep the old numbers
515
+ but at least surface a ``RuntimeWarning`` naming the two NaN counts.
516
+
471
517
  Parameters
472
518
  ----------
473
519
  expected_series : pandas.Series
@@ -486,62 +532,112 @@ def _calculate_single_psi(
486
532
  Small value to avoid division by zero. Default is 1e-6.
487
533
  precision : int, optional
488
534
  Decimal precision. Default is 5.
489
-
535
+ missing_policy : {"drop", "include", "warn_and_drop"}, optional
536
+ How NaN rows are handled. ``"drop"`` (default in 0.4.2) reproduces the
537
+ pre-0.4.2 behaviour and silently excludes NaN rows from both sides
538
+ before binning. ``"include"`` treats NaN as its own ``"__MISSING__"``
539
+ bin so missing-rate drift shows up in the PSI — recommended for any
540
+ production drift monitor, and will become the default in the next
541
+ minor release. ``"warn_and_drop"`` behaves like ``"drop"`` but emits
542
+ a ``RuntimeWarning`` naming the two NaN counts so the caller sees what
543
+ was dropped.
544
+
490
545
  Returns
491
546
  -------
492
547
  float or tuple
493
548
  If return_details is False: Returns total PSI value.
494
549
  If return_details is True: Returns tuple of (PSI value, details DataFrame).
495
-
550
+
496
551
  Notes
497
552
  -----
498
553
  PSI Formula: Σ (Actual% - Expected%) * ln(Actual% / Expected%)
499
554
  A PSI < 0.1 indicates stable population, 0.1-0.25 suggests some change,
500
555
  and > 0.25 indicates significant drift.
501
556
  """
502
- # Drop NA
557
+ if missing_policy not in {"include", "drop", "warn_and_drop"}:
558
+ raise ValueError(
559
+ f"_calculate_single_psi: missing_policy must be one of "
560
+ f"'include', 'drop', 'warn_and_drop'; got {missing_policy!r}."
561
+ )
562
+
563
+ # Record NaN counts before splitting so we can (a) re-attach the missing
564
+ # bin under "include" and (b) warn under "warn_and_drop".
565
+ n_expected = int(len(expected_series))
566
+ n_actual = int(len(actual_series))
567
+ n_expected_na = int(expected_series.isna().sum())
568
+ n_actual_na = int(actual_series.isna().sum())
569
+
570
+ if missing_policy == "warn_and_drop" and (n_expected_na or n_actual_na):
571
+ warnings.warn(
572
+ f"_calculate_single_psi: dropping NaN rows before binning. "
573
+ f"expected: {n_expected_na}/{n_expected} NaN, "
574
+ f"actual: {n_actual_na}/{n_actual} NaN. "
575
+ f"Pass missing_policy='include' to treat NaN as its own bin so "
576
+ f"missing-rate drift is captured.",
577
+ RuntimeWarning,
578
+ stacklevel=2,
579
+ )
580
+
503
581
  expected_clean = expected_series.dropna()
504
582
  actual_clean = actual_series.dropna()
505
-
583
+
506
584
  # Bin the expected data to get breakpoints
507
585
  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,
586
+ pd.DataFrame(expected_clean),
587
+ expected_clean.name,
588
+ labels=None,
589
+ nbins=buckets,
590
+ precision=precision,
591
+ equal_freq=equal_freq,
592
+ right=True,
515
593
  include_lowest=False,
516
- min_bin_prop=min_bin_prop,
517
- tree_binning=False,
518
- target=None,
594
+ min_bin_prop=min_bin_prop,
595
+ tree_binning=False,
596
+ target=None,
519
597
  random_state=42
520
598
  )
521
-
599
+
522
600
  # Bin the actual data using the same breakpoints
523
601
  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,
602
+ pd.DataFrame(actual_clean),
603
+ actual_clean.name,
604
+ labels=None,
605
+ nbins=list(breakpoints),
606
+ precision=precision,
607
+ equal_freq=equal_freq,
608
+ right=True,
531
609
  include_lowest=False,
532
- min_bin_prop=min_bin_prop,
533
- tree_binning=False,
534
- target=None,
610
+ min_bin_prop=min_bin_prop,
611
+ tree_binning=False,
612
+ target=None,
535
613
  random_state=42
536
614
  )
537
-
538
- # Get bin counts
615
+
616
+ # Get bin counts (finite-value rows only, from quick_binning)
539
617
  expected_count = expected_bins.value_counts(normalize=False, sort=False)
540
618
  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)
619
+
620
+ if missing_policy == "include":
621
+ # Re-attach the __MISSING__ bin so that missing-rate drift contributes
622
+ # to the PSI on both sides. The denominators use the *original* row
623
+ # counts (before dropna) so the fractions are directly comparable.
624
+ if n_expected_na > 0:
625
+ expected_count = pd.concat(
626
+ [expected_count, pd.Series({_MISSING_BIN: n_expected_na})]
627
+ )
628
+ if n_actual_na > 0:
629
+ actual_count = pd.concat(
630
+ [actual_count, pd.Series({_MISSING_BIN: n_actual_na})]
631
+ )
632
+ expected_denom = float(n_expected) if n_expected > 0 else 1.0
633
+ actual_denom = float(n_actual) if n_actual > 0 else 1.0
634
+ expected_percents = expected_count / expected_denom
635
+ actual_percents = actual_count / actual_denom
636
+ else:
637
+ # "drop" / "warn_and_drop": legacy behaviour, denominators are the
638
+ # NaN-dropped row counts, so missing-rate drift is invisible.
639
+ expected_percents = expected_bins.value_counts(normalize=True, sort=False)
640
+ actual_percents = actual_bins.value_counts(normalize=True, sort=False)
545
641
 
546
642
  # Ensure both series have the same bin indices
547
643
  all_bins = expected_percents.index.union(actual_percents.index)
@@ -579,7 +675,8 @@ def calculate_psi(
579
675
  return_details: bool = False,
580
676
  min_bin_prop: float = 0.05,
581
677
  content: float = 1e-6,
582
- precision: int = 5
678
+ precision: int = 5,
679
+ missing_policy: str = "include"
583
680
  ) -> Union[float, pd.DataFrame, Tuple[Dict, Dict]]:
584
681
  """
585
682
  Calculate Population Stability Index (PSI) for a variable, optionally by groups.
@@ -609,6 +706,14 @@ def calculate_psi(
609
706
  Small value to avoid division by zero. Default is 1e-6.
610
707
  precision : int, optional
611
708
  Decimal precision. Default is 5.
709
+ missing_policy : {"drop", "include", "warn_and_drop"}, optional
710
+ How NaN rows are handled. Default in 0.5.0 is "include", which routes
711
+ NaN rows through a dedicated "__MISSING__" bin so missing-rate drift
712
+ contributes to the PSI. This is the recommended production behaviour
713
+ and became the default in 0.5.0 (previously "drop" in 0.4.2 for
714
+ numeric backward-compat). Pass "drop" to reproduce pre-0.5.0
715
+ numbers, or "warn_and_drop" for legacy numbers with a RuntimeWarning
716
+ naming the NaN counts.
612
717
 
613
718
  Returns
614
719
  -------
@@ -661,7 +766,8 @@ def calculate_psi(
661
766
  True,
662
767
  min_bin_prop,
663
768
  content,
664
- precision
769
+ precision,
770
+ missing_policy
665
771
  )
666
772
  results[group] = psi_value
667
773
  details_dict[group] = detail
@@ -674,7 +780,8 @@ def calculate_psi(
674
780
  False,
675
781
  min_bin_prop,
676
782
  content,
677
- precision
783
+ precision,
784
+ missing_policy
678
785
  )
679
786
 
680
787
  if return_details:
@@ -692,7 +799,8 @@ def calculate_psi(
692
799
  True,
693
800
  min_bin_prop,
694
801
  content,
695
- precision
802
+ precision,
803
+ missing_policy
696
804
  )
697
805
  else:
698
806
  return _calculate_single_psi(
@@ -703,7 +811,8 @@ def calculate_psi(
703
811
  False,
704
812
  min_bin_prop,
705
813
  content,
706
- precision
814
+ precision,
815
+ missing_policy
707
816
  )
708
817
 
709
818
 
@@ -718,7 +827,8 @@ def calculate_within_psi(
718
827
  min_bin_prop: float = 0.05,
719
828
  content: float = 1e-6,
720
829
  precision: int = 5,
721
- benchmark_display_name: Optional[str] = None
830
+ benchmark_display_name: Optional[str] = None,
831
+ missing_policy: str = "include"
722
832
  ) -> Union[pd.DataFrame, Dict]:
723
833
  """
724
834
  Calculate PSI values within a single dataset, comparing groups to a benchmark.
@@ -750,6 +860,14 @@ def calculate_within_psi(
750
860
  Decimal precision. Default is 5.
751
861
  benchmark_display_name : str, optional
752
862
  Custom name for benchmark in results.
863
+ missing_policy : {"drop", "include", "warn_and_drop"}, optional
864
+ How NaN rows are handled. Default in 0.5.0 is "include", which routes
865
+ NaN rows through a dedicated "__MISSING__" bin so missing-rate drift
866
+ contributes to the PSI. This is the recommended production behaviour
867
+ and became the default in 0.5.0 (previously "drop" in 0.4.2 for
868
+ numeric backward-compat). Pass "drop" to reproduce pre-0.5.0
869
+ numbers, or "warn_and_drop" for legacy numbers with a RuntimeWarning
870
+ naming the NaN counts.
753
871
 
754
872
  Returns
755
873
  -------
@@ -785,7 +903,8 @@ def calculate_within_psi(
785
903
  return_details=True,
786
904
  min_bin_prop=min_bin_prop,
787
905
  content=content,
788
- precision=precision
906
+ precision=precision,
907
+ missing_policy=missing_policy
789
908
  )
790
909
  res_dict[obs_value] = psi
791
910
  detail_dict[obs_value] = details
@@ -799,7 +918,8 @@ def calculate_within_psi(
799
918
  return_details=False,
800
919
  min_bin_prop=min_bin_prop,
801
920
  content=content,
802
- precision=precision
921
+ precision=precision,
922
+ missing_policy=missing_policy
803
923
  )
804
924
  res_dict[obs_value] = psi
805
925
 
@@ -822,7 +942,8 @@ def calculate_psi_within_dataset(
822
942
  buckets: int = 10,
823
943
  min_bin_prop: float = 0.05,
824
944
  content: float = 1e-6,
825
- precision: int = 5
945
+ precision: int = 5,
946
+ missing_policy: str = "include"
826
947
  ) -> pd.DataFrame:
827
948
  """
828
949
  Calculate PSI for multiple variables within a dataset, comparing groups to a benchmark.
@@ -850,6 +971,14 @@ def calculate_psi_within_dataset(
850
971
  Small value to avoid division by zero. Default is 1e-6.
851
972
  precision : int, optional
852
973
  Decimal precision. Default is 5.
974
+ missing_policy : {"drop", "include", "warn_and_drop"}, optional
975
+ How NaN rows are handled. Default in 0.5.0 is "include", which routes
976
+ NaN rows through a dedicated "__MISSING__" bin so missing-rate drift
977
+ contributes to the PSI. This is the recommended production behaviour
978
+ and became the default in 0.5.0 (previously "drop" in 0.4.2 for
979
+ numeric backward-compat). Pass "drop" to reproduce pre-0.5.0
980
+ numbers, or "warn_and_drop" for legacy numbers with a RuntimeWarning
981
+ naming the NaN counts.
853
982
 
854
983
  Returns
855
984
  -------
@@ -873,7 +1002,8 @@ def calculate_psi_within_dataset(
873
1002
  return_details=False,
874
1003
  min_bin_prop=min_bin_prop,
875
1004
  content=content,
876
- precision=precision
1005
+ precision=precision,
1006
+ missing_policy=missing_policy
877
1007
  ).sort_values([grp_name]).reset_index(drop=True)
878
1008
 
879
1009
  single_psi['var'] = var
@@ -891,7 +1021,8 @@ def calculate_multivar_psi_two_sets(
891
1021
  equal_freq: bool = True,
892
1022
  min_bin_prop: float = 0.05,
893
1023
  content: float = 1e-6,
894
- precision: int = 5
1024
+ precision: int = 5,
1025
+ missing_policy: str = "include"
895
1026
  ) -> pd.DataFrame:
896
1027
  """
897
1028
  Calculate PSI for multiple variables by comparing two different datasets.
@@ -919,6 +1050,14 @@ def calculate_multivar_psi_two_sets(
919
1050
  Small value to avoid division by zero. Default is 1e-6.
920
1051
  precision : int, optional
921
1052
  Decimal precision. Default is 5.
1053
+ missing_policy : {"drop", "include", "warn_and_drop"}, optional
1054
+ How NaN rows are handled. Default in 0.5.0 is "include", which routes
1055
+ NaN rows through a dedicated "__MISSING__" bin so missing-rate drift
1056
+ contributes to the PSI. This is the recommended production behaviour
1057
+ and became the default in 0.5.0 (previously "drop" in 0.4.2 for
1058
+ numeric backward-compat). Pass "drop" to reproduce pre-0.5.0
1059
+ numbers, or "warn_and_drop" for legacy numbers with a RuntimeWarning
1060
+ naming the NaN counts.
922
1061
 
923
1062
  Returns
924
1063
  -------
@@ -938,7 +1077,8 @@ def calculate_multivar_psi_two_sets(
938
1077
  target_col=var,
939
1078
  group_by=group_by,
940
1079
  buckets=buckets,
941
- return_details=False
1080
+ return_details=False,
1081
+ missing_policy=missing_policy
942
1082
  )
943
1083
  if group_by is None:
944
1084
  single_psi = pd.DataFrame([single_psi], columns=['psi'])
@@ -1078,7 +1218,8 @@ def calculate_multigroup_psi_two_sets(
1078
1218
  content: float = 1e-6,
1079
1219
  precision: int = 5,
1080
1220
  group_name: Optional[str] = None,
1081
- return_details: bool = False
1221
+ return_details: bool = False,
1222
+ missing_policy: str = "include"
1082
1223
  ) -> Union[pd.DataFrame, Dict[str, pd.DataFrame]]:
1083
1224
  """
1084
1225
  Calculate grouped PSI using expected DataFrame as benchmark, applied to actual DataFrame groups.
@@ -1108,6 +1249,12 @@ def calculate_multigroup_psi_two_sets(
1108
1249
  return_details : bool, optional
1109
1250
  是否返回详细分箱信息。若为 True,返回字典 {'psi': psi_df, 'details': details_df},
1110
1251
  details_df 包含列:['bin', 'expected_percent', 'actual_percent', 'psi_component', group_name, 'var']
1252
+ missing_policy : {"drop", "include", "warn_and_drop"}, optional
1253
+ NaN 行处理策略。0.5.0 起默认为 "include",将 NaN 行汇入独立的
1254
+ "__MISSING__" 桶,使缺失率漂移进入 PSI 计算;这是推荐的生产行为,
1255
+ 并在 0.5.0 成为默认值(0.4.2 为 "drop" 以保持数值向后兼容)。
1256
+ 传入 "drop" 可复现 0.5.0 之前的数值,或 "warn_and_drop" 保留旧数值
1257
+ 同时发出 RuntimeWarning 报告两侧 NaN 数量。
1111
1258
  """
1112
1259
  if group_name is not None:
1113
1260
  if actual_df[group_name].isna().sum() > 0:
@@ -1130,7 +1277,8 @@ def calculate_multigroup_psi_two_sets(
1130
1277
  return_details=True,
1131
1278
  min_bin_prop=min_bin_prop,
1132
1279
  content=content,
1133
- precision=precision
1280
+ precision=precision,
1281
+ missing_policy=missing_policy
1134
1282
  )
1135
1283
 
1136
1284
  psi_records.append({group_name: group, 'var': var, 'psi': psi_val})
@@ -1177,7 +1325,8 @@ def calculate_multigroup_psi_two_sets(
1177
1325
  return_details=True,
1178
1326
  min_bin_prop=min_bin_prop,
1179
1327
  content=content,
1180
- precision=precision
1328
+ precision=precision,
1329
+ missing_policy=missing_policy
1181
1330
  )
1182
1331
  psi_records.append({'var': var, 'psi': psi_val})
1183
1332
 
@@ -1212,7 +1361,8 @@ def calculate_multigroup_psi_two_sets(
1212
1361
  equal_freq=equal_freq,
1213
1362
  min_bin_prop=min_bin_prop,
1214
1363
  content=content,
1215
- precision=precision
1364
+ precision=precision,
1365
+ missing_policy=missing_policy
1216
1366
  )
1217
1367
 
1218
1368
  return group_psi
@@ -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: