SuperModelingFactory 0.6.7__tar.gz → 0.6.9__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 (116) hide show
  1. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/Feature_Screen.py +38 -11
  2. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/Screen_Gates.py +27 -6
  3. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/Weighted_Screen.py +154 -23
  4. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/credit_model.py +11 -1
  5. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/feature_validation.py +53 -12
  6. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Monotone_Binner.py +84 -0
  7. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/__init__.py +1 -1
  8. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/PKG-INFO +2 -2
  9. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/README.md +1 -1
  10. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/SuperModelingFactory.egg-info/PKG-INFO +2 -2
  11. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/pyproject.toml +1 -1
  12. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/setup.py +1 -1
  13. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/ExcelMaster/ExcelFormatTool.py +0 -0
  14. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/ExcelMaster/ExcelMaster.py +0 -0
  15. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/ExcelMaster/Template.py +0 -0
  16. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/ExcelMaster/Utility.py +0 -0
  17. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/ExcelMaster/__init__.py +0 -0
  18. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/LICENSE +0 -0
  19. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/MANIFEST.in +0 -0
  20. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/Binning_Tool.py +0 -0
  21. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/Binning_Tool.pyi +0 -0
  22. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/Check_DuckDB_Compatibility.py +0 -0
  23. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/Json_Data_Converter.py +0 -0
  24. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/Model_Registry_Tool.py +0 -0
  25. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/ODPS_Tool.py +0 -0
  26. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/Parallel_Engine.py +0 -0
  27. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/Parallel_ODPS_Manager.py +0 -0
  28. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/Proc_Compare.py +0 -0
  29. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/Slope_Tool.py +0 -0
  30. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/Slope_Tool.pyi +0 -0
  31. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/XOR_Encryptor.py +0 -0
  32. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/XOR_Encryptor.pyi +0 -0
  33. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/__init__.py +0 -0
  34. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/kDataFrame.py +0 -0
  35. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/kDataFrame.pyi +0 -0
  36. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/sample_weight_utils.py +0 -0
  37. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Core/utils.py +0 -0
  38. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Eval/Evaluation_Tool.py +0 -0
  39. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Eval/Evaluation_Tool.pyi +0 -0
  40. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Eval/Model_Eval_Tool.py +0 -0
  41. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Eval/Model_Eval_Tool.pyi +0 -0
  42. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Eval/__init__.py +0 -0
  43. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Eval/evaluate_model.py +0 -0
  44. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Eval/evaluate_model.pyi +0 -0
  45. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Eval/weighted_eval_utils.py +0 -0
  46. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Explainability/Coalition_Structure.py +0 -0
  47. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Explainability/Model_Explainer.py +0 -0
  48. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Explainability/__init__.py +0 -0
  49. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/Distribution_Tool.py +0 -0
  50. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/Distribution_Tool.pyi +0 -0
  51. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/Feature_Insights.py +0 -0
  52. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/Feature_Insights.pyi +0 -0
  53. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/ODPS_Distribution_Tool.py +0 -0
  54. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/ODPS_Distribution_Tool.pyi +0 -0
  55. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/PSI_Tool.py +0 -0
  56. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/PSI_Tool.pyi +0 -0
  57. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +0 -0
  58. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Feature/__init__.py +0 -0
  59. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Model/Backward_Tool.py +0 -0
  60. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Model/Backward_Tool.pyi +0 -0
  61. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Model/GBM_Search_Tool.py +0 -0
  62. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Model/GBM_Tool.py +0 -0
  63. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Model/GBM_Tool.pyi +0 -0
  64. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Model/LRM_Tool.py +0 -0
  65. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Model/LRM_Tool.pyi +0 -0
  66. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Model/__init__.py +0 -0
  67. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/__init__.py +0 -0
  68. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/_common.py +0 -0
  69. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/field_meta.py +0 -0
  70. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/mock_sample.py +0 -0
  71. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/orchestrator.py +0 -0
  72. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/reject_inference.py +0 -0
  73. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/sample_analysis.py +0 -0
  74. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/score_comparison.py +0 -0
  75. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/score_consistency_uat.py +0 -0
  76. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Pipeline/screening_artifact.py +0 -0
  77. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Sample/Distribution_Adaptation.py +0 -0
  78. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Sample/Distribution_Adaptation.pyi +0 -0
  79. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Sample/Reject_Infer.py +0 -0
  80. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Sample/Reject_Infer.pyi +0 -0
  81. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Sample/Sample_Split.py +0 -0
  82. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Sample/Sample_Split.pyi +0 -0
  83. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/Sample/__init__.py +0 -0
  84. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/UAT/UAT_Consistency_Checker.py +0 -0
  85. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/UAT/__init__.py +0 -0
  86. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Adapter.py +0 -0
  87. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Adapter.pyi +0 -0
  88. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Master.py +0 -0
  89. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Master.pyi +0 -0
  90. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +0 -0
  91. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Plot_Tool.py +0 -0
  92. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Plot_Tool.pyi +0 -0
  93. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Report_Builder.py +0 -0
  94. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Report_Builder.pyi +0 -0
  95. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Tool.py +0 -0
  96. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/WOE_Tool.pyi +0 -0
  97. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/__init__.py +0 -0
  98. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/plot_woe_tool.py +0 -0
  99. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/WOE/plot_woe_tool.pyi +0 -0
  100. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/_utils/__init__.py +0 -0
  101. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/_utils/nan_guard.py +0 -0
  102. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/_utils/robust.py +0 -0
  103. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/_utils/sentinels.py +0 -0
  104. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/ref_font/KaiTi.ttf +0 -0
  105. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/ref_font/WeiRuanYaHei.ttf +0 -0
  106. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/ref_font/__init__.py +0 -0
  107. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Modeling_Tool/ref_font/simsun.ttc +0 -0
  108. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Report/Report_Tool.py +0 -0
  109. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/Report/__init__.py +0 -0
  110. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/SuperModelingFactory.egg-info/SOURCES.txt +0 -0
  111. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/SuperModelingFactory.egg-info/dependency_links.txt +0 -0
  112. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/SuperModelingFactory.egg-info/not-zip-safe +0 -0
  113. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/SuperModelingFactory.egg-info/requires.txt +0 -0
  114. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/SuperModelingFactory.egg-info/top_level.txt +0 -0
  115. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/requirements.txt +0 -0
  116. {supermodelingfactory-0.6.7 → supermodelingfactory-0.6.9}/setup.cfg +0 -0
@@ -19,14 +19,16 @@ from .Weighted_Screen import (
19
19
  _apply_missing_rate_stage,
20
20
  _apply_stage_keep,
21
21
  _corr_dedup_weighted,
22
+ _gate_ranking_iv_map,
22
23
  _iv_band_keep,
23
24
  _legacy_unweighted_screen,
24
25
  _psi_from_distributions,
25
26
  _resolve_splits,
26
27
  _summary_row,
28
+ _unit_weight_floored_iv,
27
29
  _weighted_bin_distribution,
28
30
  _weighted_corr_for_screen,
29
- _weighted_iv_from_assigned_bins,
31
+ _weighted_iv_detail,
30
32
  _weighted_screen_impl,
31
33
  )
32
34
 
@@ -322,6 +324,7 @@ def _woe_bins_unweighted_screen(
322
324
  summary_rows.append(_summary_row("psi", n_before, len(current), config.psi_threshold, None))
323
325
 
324
326
  iv_table = pd.DataFrame(columns=["var", "iv_weighted", "n_bins", "missing_rate"])
327
+ iv_floored_map: dict[str, float] = {}
325
328
  if config.iv_enabled:
326
329
  use_binner = config.iv_use_woe_bins and binner is not None
327
330
  vi = VarExtractionInsights(
@@ -348,7 +351,20 @@ def _woe_bins_unweighted_screen(
348
351
  )
349
352
  if iv is not None and not iv.empty:
350
353
  iv_table = iv.rename(columns={"iv": "iv_weighted"})[["var", "iv_weighted", "n_bins", "missing_rate"]]
351
- keep = _iv_band_keep(iv, "iv", config.iv_threshold, config.iv_upper_threshold, dropped_rows)
354
+ if config.iv_upper_threshold is not None:
355
+ gate_frame = iv[["var", "iv"]].copy()
356
+ gate_frame["iv_floored"] = _unit_weight_floored_iv(
357
+ ins, list(gate_frame["var"]), target_col,
358
+ iv_bins=config.iv_bins, min_bin_prop=config.iv_min_bin_prop,
359
+ content=config.content,
360
+ )
361
+ iv_floored_map = dict(zip(gate_frame["var"], gate_frame["iv_floored"]))
362
+ keep = _iv_band_keep(
363
+ gate_frame, "iv", config.iv_threshold, config.iv_upper_threshold,
364
+ dropped_rows, upper_col="iv_floored",
365
+ )
366
+ else:
367
+ keep = _iv_band_keep(iv, "iv", config.iv_threshold, config.iv_upper_threshold, dropped_rows)
352
368
  n_before = len(current)
353
369
  current = _apply_stage_keep(
354
370
  current, keep, "iv", summary_rows,
@@ -372,9 +388,7 @@ def _woe_bins_unweighted_screen(
372
388
 
373
389
  from .Screen_Gates import apply_post_corr_gates
374
390
 
375
- gate_iv_map = (
376
- dict(zip(iv_table["var"], iv_table["iv_weighted"])) if not iv_table.empty else {}
377
- )
391
+ gate_iv_map = _gate_ranking_iv_map(iv_table, iv_floored_map, config.iv_upper_threshold)
378
392
  current = apply_post_corr_gates(
379
393
  ins, current, config, selection_evidence, gate_iv_map,
380
394
  summary_rows, dropped_rows, stage_tables,
@@ -553,7 +567,9 @@ def _weighted_woe_bins_screen(
553
567
  psi_table = pd.DataFrame(columns=["var", "psi_ins_oos", "psi_ins_oot", "psi_max"])
554
568
 
555
569
  iv_records: list[dict] = []
570
+ iv_floored_map: dict[str, float] = {}
556
571
  if config.iv_enabled:
572
+ floor_content = config.content if config.iv_upper_threshold is not None else None
557
573
  for var in current:
558
574
  if var not in ins.columns or ins[var].nunique(dropna=False) <= 1:
559
575
  continue
@@ -564,12 +580,16 @@ def _weighted_woe_bins_screen(
564
580
  else:
565
581
  # Categorical/object feature: IV itself is computed from the
566
582
  # already-assigned bins; ``x`` only feeds np.isfinite() inside
567
- # _weighted_iv_from_assigned_bins to derive the weighted
583
+ # _weighted_iv_detail to derive the weighted
568
584
  # missing rate. A hard float cast crashes on string levels
569
585
  # (e.g. '4.高中'), so encode observed -> 1.0 / missing -> NaN,
570
586
  # matching the notna semantics of _missing_rate_for_series.
571
587
  x_ins = np.where(x_series.notna().to_numpy(), 1.0, np.nan)
572
- iv_val, n_b, miss = _weighted_iv_from_assigned_bins(y_ins, w_ins, bins_ins, x_ins)
588
+ iv_val, n_b, miss, iv_floored = _weighted_iv_detail(
589
+ y_ins, w_ins, bins_ins, x_ins, content=floor_content,
590
+ )
591
+ if floor_content is not None:
592
+ iv_floored_map[var] = iv_floored
573
593
  iv_records.append({
574
594
  "var": var,
575
595
  "iv_weighted": iv_val,
@@ -580,7 +600,15 @@ def _weighted_woe_bins_screen(
580
600
  columns=["var", "iv_weighted", "n_bins", "missing_rate"],
581
601
  )
582
602
  if not iv_table.empty:
583
- keep = _iv_band_keep(iv_table, "iv_weighted", config.iv_threshold, config.iv_upper_threshold, dropped_rows)
603
+ if config.iv_upper_threshold is not None:
604
+ gate_frame = iv_table[["var", "iv_weighted"]].copy()
605
+ gate_frame["iv_floored"] = gate_frame["var"].map(iv_floored_map)
606
+ keep = _iv_band_keep(
607
+ gate_frame, "iv_weighted", config.iv_threshold,
608
+ config.iv_upper_threshold, dropped_rows, upper_col="iv_floored",
609
+ )
610
+ else:
611
+ keep = _iv_band_keep(iv_table, "iv_weighted", config.iv_threshold, config.iv_upper_threshold, dropped_rows)
584
612
  n_before = len(current)
585
613
  current = _apply_stage_keep(
586
614
  current, keep, "iv", summary_rows,
@@ -614,9 +642,7 @@ def _weighted_woe_bins_screen(
614
642
 
615
643
  from .Screen_Gates import apply_post_corr_gates
616
644
 
617
- gate_iv_map = (
618
- dict(zip(iv_table["var"], iv_table["iv_weighted"])) if not iv_table.empty else {}
619
- )
645
+ gate_iv_map = _gate_ranking_iv_map(iv_table, iv_floored_map, config.iv_upper_threshold)
620
646
  current = apply_post_corr_gates(
621
647
  ins, current, config, selection_evidence, gate_iv_map,
622
648
  summary_rows, dropped_rows, stage_tables,
@@ -737,6 +763,7 @@ def feature_screen(
737
763
  missing_rate_ref=cfg.missing_rate_ref,
738
764
  gates_config=cfg,
739
765
  selection_evidence=selection_evidence,
766
+ content=cfg.content,
740
767
  )
741
768
 
742
769
 
@@ -27,20 +27,41 @@ import pandas as pd
27
27
  from .Weighted_Screen import _apply_stage_keep, _summary_row
28
28
 
29
29
 
30
- def point_biserial_direction(x: pd.Series, y: pd.Series) -> int:
30
+ def point_biserial_direction(
31
+ x: pd.Series, y: pd.Series, sample_weight: np.ndarray | None = None
32
+ ) -> int:
31
33
  """Sign of the point-biserial association between a numeric feature and a
32
34
  binary target: +1 when higher x means higher bad rate (WOE increasing),
33
35
  -1 for the opposite, 0 when undefined. The single shared definition of
34
- "direction" for G03/G04."""
36
+ "direction" for G03/G04.
37
+
38
+ ``sample_weight`` is positional-aligned with the passed ``x``/``y``
39
+ (before NaN masking). A class whose total weight is zero — like a class
40
+ with no rows — and a weighted tie both return 0, mirroring the
41
+ unweighted NaN/tie semantics."""
35
42
  xv = pd.to_numeric(x, errors="coerce")
36
43
  yv = pd.to_numeric(y, errors="coerce")
37
44
  mask = xv.notna() & yv.notna()
38
45
  if not bool(mask.any()):
39
46
  return 0
40
- xv = xv[mask]
41
- yv = yv[mask]
42
- mean_bad = xv[yv == 1].mean()
43
- mean_good = xv[yv == 0].mean()
47
+ if sample_weight is None:
48
+ xv = xv[mask]
49
+ yv = yv[mask]
50
+ mean_bad = xv[yv == 1].mean()
51
+ mean_good = xv[yv == 0].mean()
52
+ else:
53
+ mask_np = mask.to_numpy()
54
+ w = np.asarray(sample_weight, dtype=float)[mask_np]
55
+ xa = xv.to_numpy(dtype=float)[mask_np]
56
+ ya = yv.to_numpy(dtype=float)[mask_np]
57
+ w_bad = w[ya == 1]
58
+ w_good = w[ya == 0]
59
+ mean_bad = (
60
+ float(np.average(xa[ya == 1], weights=w_bad)) if w_bad.sum() > 0 else float("nan")
61
+ )
62
+ mean_good = (
63
+ float(np.average(xa[ya == 0], weights=w_good)) if w_good.sum() > 0 else float("nan")
64
+ )
44
65
  if pd.isna(mean_bad) or pd.isna(mean_good) or mean_bad == mean_good:
45
66
  return 0
46
67
  return 1 if mean_bad > mean_good else -1
@@ -72,24 +72,94 @@ def _iv_band_keep(
72
72
  upper: float | None,
73
73
  dropped_rows: list | None = None,
74
74
  var_col: str = "var",
75
+ upper_col: str | None = None,
75
76
  ) -> list[str]:
76
77
  """IV band gate (G02): keep lower <= iv, and iv <= upper when an upper
77
78
  threshold is set. Upper-bound drops (suspiciously high IV — leakage /
78
- look-ahead candidates) are recorded in dropped_rows."""
79
+ look-ahead candidates) are recorded in dropped_rows.
80
+
81
+ ``upper_col`` names an alternative column for the upper test only. The
82
+ degenerate-guarded IV excludes class-pure bins, so near-perfect
83
+ separators — the exact features the upper band targets — understate it;
84
+ callers pass a zero-cell-floored IV here while the lower band and the
85
+ reported table keep the legacy column. Rows with NaN in ``upper_col``
86
+ fall back to ``iv_col``."""
79
87
  keep_mask = iv_frame[iv_col] >= lower
80
88
  if upper is not None:
81
- above = iv_frame[iv_frame[iv_col] > upper]
82
- keep_mask &= iv_frame[iv_col] <= upper
89
+ if upper_col is not None and upper_col in iv_frame.columns:
90
+ gate = iv_frame[upper_col].where(iv_frame[upper_col].notna(), iv_frame[iv_col])
91
+ metric = upper_col
92
+ else:
93
+ gate = iv_frame[iv_col]
94
+ metric = "iv"
95
+ above = iv_frame[gate > upper]
96
+ keep_mask &= gate <= upper
83
97
  if dropped_rows is not None:
84
- for _, row in above.iterrows():
98
+ for idx, row in above.iterrows():
85
99
  dropped_rows.append({
86
- "var": row[var_col], "stage": "iv", "metric": "iv",
87
- "value": float(row[iv_col]), "threshold": upper,
100
+ "var": row[var_col], "stage": "iv", "metric": metric,
101
+ "value": float(gate.loc[idx]), "threshold": upper,
88
102
  "reason": "iv_above_upper",
89
103
  })
90
104
  return iv_frame.loc[keep_mask, var_col].tolist()
91
105
 
92
106
 
107
+ def _unit_weight_floored_iv(
108
+ ins: pd.DataFrame,
109
+ varlist: list[str],
110
+ target_col: str,
111
+ *,
112
+ iv_bins: int,
113
+ min_bin_prop: float,
114
+ content: float,
115
+ precision: int = 5,
116
+ ) -> list[float]:
117
+ """Zero-cell-floored equal-freq IV with unit weights, for the G02 upper
118
+ band on the gains-table path: its filliv convention zeroes pure bins and
119
+ would otherwise understate hard leakage the same way the degenerate
120
+ guard does. Non-numeric columns return NaN so the gate falls back to
121
+ the reported IV. The remainder-IV warning is suppressed — only the
122
+ floored diagnostic is consumed here."""
123
+ y = ins[target_col].astype(float).to_numpy()
124
+ w = np.ones(len(ins), dtype=float)
125
+ out: list[float] = []
126
+ for var in varlist:
127
+ if var not in ins.columns or not pd.api.types.is_numeric_dtype(ins[var]):
128
+ out.append(float("nan"))
129
+ continue
130
+ with warnings.catch_warnings():
131
+ warnings.simplefilter("ignore", UserWarning)
132
+ _, _, _, floored = _weighted_iv_for_var(
133
+ ins[var].to_numpy(dtype=float), y, w, iv_bins, min_bin_prop, precision,
134
+ var_name=var, on_null_edges="silent", content=content,
135
+ )
136
+ out.append(floored)
137
+ return out
138
+
139
+
140
+ def _gate_ranking_iv_map(
141
+ iv_table: pd.DataFrame,
142
+ iv_floored_map: dict[str, float] | None,
143
+ iv_upper_threshold: float | None,
144
+ ) -> dict[str, float]:
145
+ """Ranking metric for the post-corr gates (G05 truncation, G06 VIF
146
+ tie-break). Legacy basis is the degenerate-GUARDED reported IV. When the
147
+ G02 upper band is armed, leaks are already dropped by the band, so
148
+ ranking survivors on the guarded IV would understate legit features with
149
+ class-pure bins; substitute the zero-cell-floored IV where finite (NaN
150
+ floored values — e.g. non-numeric columns on the unit-weight diagnostic —
151
+ keep the guarded value, matching _iv_band_keep's fallback). upper=None
152
+ returns the legacy map verbatim."""
153
+ gate_iv_map = (
154
+ dict(zip(iv_table["var"], iv_table["iv_weighted"])) if not iv_table.empty else {}
155
+ )
156
+ if iv_upper_threshold is not None:
157
+ for var, floored in (iv_floored_map or {}).items():
158
+ if var in gate_iv_map and np.isfinite(floored):
159
+ gate_iv_map[var] = float(floored)
160
+ return gate_iv_map
161
+
162
+
93
163
  def _resolve_splits(data: pd.DataFrame, split_col: str) -> dict[str, pd.DataFrame]:
94
164
  if split_col not in data.columns:
95
165
  raise KeyError(f"Missing split_col {split_col!r}")
@@ -209,7 +279,7 @@ def _psi_from_distributions(expected: pd.Series, actual: pd.Series, content: flo
209
279
  return float(((a - e) * np.log(a / e)).sum())
210
280
 
211
281
 
212
- def _weighted_iv_from_assigned_bins(
282
+ def _weighted_iv_detail(
213
283
  y: np.ndarray,
214
284
  w: np.ndarray,
215
285
  bins: np.ndarray,
@@ -217,7 +287,21 @@ def _weighted_iv_from_assigned_bins(
217
287
  *,
218
288
  var_name: str | None = None,
219
289
  include_missing_bin: bool = False,
220
- ) -> tuple[float, int, float]:
290
+ content: float | None = None,
291
+ ) -> tuple[float, int, float, float]:
292
+ """Weighted IV plus an optional zero-cell-floored variant.
293
+
294
+ The reported IV (first element) excludes class-degenerate bins via
295
+ iv_guard. That is right for the *lower* informativeness bound, but it
296
+ understates IV for near-perfectly separating features: a pure bin
297
+ carries the strongest possible signal yet contributes nothing, so the
298
+ exact leakage candidates iv_upper_threshold (G02) exists to catch sail
299
+ under the band. When ``content`` is given, the fourth element floors
300
+ each bin's class shares at ``content`` (the same floor the PSI path
301
+ uses) so pure bins contribute a large finite term, matching the
302
+ WOE-engine convention; features with no degenerate bins get
303
+ iv_floored == iv. When ``content`` is None the fourth element is NaN.
304
+ """
221
305
  included = np.ones(len(bins), dtype=bool)
222
306
  if not include_missing_bin:
223
307
  included &= bins != _MISSING_BIN
@@ -225,9 +309,10 @@ def _weighted_iv_from_assigned_bins(
225
309
  total_good = float(np.sum(w[included] * (1.0 - y[included])))
226
310
  if total_bad <= 0 or total_good <= 0:
227
311
  missing_rate = 1.0 - float(np.sum(w[np.isfinite(x)])) / float(np.sum(w) or 1.0)
228
- return 0.0, 0, missing_rate
312
+ return 0.0, 0, missing_rate, (0.0 if content is not None else float("nan"))
229
313
 
230
314
  iv = 0.0
315
+ iv_floored = 0.0 if content is not None else float("nan")
231
316
  n_bins = 0
232
317
  n_contributing_bins = 0
233
318
  n_degenerate = 0
@@ -238,7 +323,13 @@ def _weighted_iv_from_assigned_bins(
238
323
  m = bins == b
239
324
  bad_w = float(np.sum(w[m] * y[m]))
240
325
  good_w = float(np.sum(w[m] * (1.0 - y[m])))
241
- contrib, is_degenerate = iv_guard(bad_w / total_bad, good_w / total_good)
326
+ bad_share = bad_w / total_bad
327
+ good_share = good_w / total_good
328
+ contrib, is_degenerate = iv_guard(bad_share, good_share)
329
+ if content is not None:
330
+ floor_bad = max(bad_share, content)
331
+ floor_good = max(good_share, content)
332
+ iv_floored += float((floor_bad - floor_good) * np.log(floor_bad / floor_good))
242
333
  if is_degenerate:
243
334
  n_degenerate += 1
244
335
  continue
@@ -254,7 +345,7 @@ def _weighted_iv_from_assigned_bins(
254
345
  stacklevel=2,
255
346
  )
256
347
  missing_rate = 1.0 - float(np.sum(w[np.isfinite(x)])) / float(np.sum(w) or 1.0)
257
- return 0.0, 0, missing_rate
348
+ return 0.0, 0, missing_rate, iv_floored
258
349
 
259
350
  if n_degenerate:
260
351
  prefix = f"{var_name}: " if var_name else ""
@@ -265,7 +356,22 @@ def _weighted_iv_from_assigned_bins(
265
356
  stacklevel=2,
266
357
  )
267
358
  missing_rate = 1.0 - float(np.sum(w[np.isfinite(x)])) / float(np.sum(w) or 1.0)
268
- return float(iv), n_bins, missing_rate
359
+ return float(iv), n_bins, missing_rate, iv_floored
360
+
361
+
362
+ def _weighted_iv_from_assigned_bins(
363
+ y: np.ndarray,
364
+ w: np.ndarray,
365
+ bins: np.ndarray,
366
+ x: np.ndarray,
367
+ *,
368
+ var_name: str | None = None,
369
+ include_missing_bin: bool = False,
370
+ ) -> tuple[float, int, float]:
371
+ iv, n_bins, missing_rate, _ = _weighted_iv_detail(
372
+ y, w, bins, x, var_name=var_name, include_missing_bin=include_missing_bin,
373
+ )
374
+ return iv, n_bins, missing_rate
269
375
 
270
376
 
271
377
  def _weighted_iv_for_var(
@@ -279,16 +385,18 @@ def _weighted_iv_for_var(
279
385
  var_name: str | None = None,
280
386
  include_missing_bin: bool = False,
281
387
  on_null_edges: Literal["raise", "warn_and_zero", "silent"] = "raise",
282
- ) -> tuple[float, int, float]:
388
+ content: float | None = None,
389
+ ) -> tuple[float, int, float, float]:
283
390
  edges = _weighted_equal_freq_edges(x, w, n_bins, min_bin_prop, precision=precision)
284
391
  bins = _assign_bins(x, edges, name=var_name, on_null_edges=on_null_edges)
285
- return _weighted_iv_from_assigned_bins(
392
+ return _weighted_iv_detail(
286
393
  y,
287
394
  w,
288
395
  bins,
289
396
  x,
290
397
  var_name=var_name,
291
398
  include_missing_bin=include_missing_bin,
399
+ content=content,
292
400
  )
293
401
 
294
402
 
@@ -721,6 +829,7 @@ def _legacy_unweighted_screen(
721
829
  missing_rate_ref: Any = None,
722
830
  gates_config: Any | None = None,
723
831
  selection_evidence: Any | None = None,
832
+ content: float = 1e-6,
724
833
  ) -> WeightedScreenResult:
725
834
  from Modeling_Tool import CorrelationFilter, PSICalculator, VarExtractionInsights
726
835
 
@@ -772,6 +881,7 @@ def _legacy_unweighted_screen(
772
881
  summary_rows.append(_summary_row("psi", n_before, len(current), psi_threshold, None))
773
882
 
774
883
  iv_table = pd.DataFrame(columns=["var", "iv_weighted", "n_bins", "missing_rate"])
884
+ iv_floored_map: dict[str, float] = {}
775
885
  if iv_enabled:
776
886
  vi = VarExtractionInsights(
777
887
  data=ins,
@@ -794,7 +904,19 @@ def _legacy_unweighted_screen(
794
904
  plot_path=plot_path,
795
905
  )
796
906
  iv_table = iv.rename(columns={"iv": "iv_weighted"})[["var", "iv_weighted", "n_bins", "missing_rate"]]
797
- keep = _iv_band_keep(iv, "iv", iv_threshold, iv_upper_threshold, dropped_rows)
907
+ if iv_upper_threshold is not None:
908
+ gate_frame = iv[["var", "iv"]].copy()
909
+ gate_frame["iv_floored"] = _unit_weight_floored_iv(
910
+ ins, list(gate_frame["var"]), target_col,
911
+ iv_bins=iv_bins, min_bin_prop=iv_min_bin_prop, content=content,
912
+ )
913
+ iv_floored_map = dict(zip(gate_frame["var"], gate_frame["iv_floored"]))
914
+ keep = _iv_band_keep(
915
+ gate_frame, "iv", iv_threshold, iv_upper_threshold, dropped_rows,
916
+ upper_col="iv_floored",
917
+ )
918
+ else:
919
+ keep = _iv_band_keep(iv, "iv", iv_threshold, iv_upper_threshold, dropped_rows)
798
920
  n_before = len(current)
799
921
  current = _apply_stage_keep(
800
922
  current, keep, "iv", summary_rows,
@@ -816,9 +938,7 @@ def _legacy_unweighted_screen(
816
938
  if gates_config is not None:
817
939
  from .Screen_Gates import apply_post_corr_gates
818
940
 
819
- gate_iv_map = (
820
- dict(zip(iv_table["var"], iv_table["iv_weighted"])) if not iv_table.empty else {}
821
- )
941
+ gate_iv_map = _gate_ranking_iv_map(iv_table, iv_floored_map, iv_upper_threshold)
822
942
  current = apply_post_corr_gates(
823
943
  ins, current, gates_config, selection_evidence, gate_iv_map,
824
944
  summary_rows, dropped_rows, stage_tables,
@@ -941,11 +1061,13 @@ def _weighted_screen_impl(
941
1061
  psi_table = pd.DataFrame(columns=["var", "psi_ins_oos", "psi_ins_oot", "psi_max"])
942
1062
 
943
1063
  iv_records: list[dict] = []
1064
+ iv_floored_map: dict[str, float] = {}
944
1065
  if iv_enabled:
1066
+ floor_content = content if iv_upper_threshold is not None else None
945
1067
  for var in current:
946
1068
  if var not in ins.columns or ins[var].nunique(dropna=False) <= 1:
947
1069
  continue
948
- iv_val, n_b, miss = _weighted_iv_for_var(
1070
+ iv_val, n_b, miss, iv_floored = _weighted_iv_for_var(
949
1071
  ins[var].to_numpy(dtype=float),
950
1072
  y_ins,
951
1073
  w_ins,
@@ -953,7 +1075,10 @@ def _weighted_screen_impl(
953
1075
  min_bin_prop,
954
1076
  precision,
955
1077
  var_name=var,
1078
+ content=floor_content,
956
1079
  )
1080
+ if floor_content is not None:
1081
+ iv_floored_map[var] = iv_floored
957
1082
  iv_records.append({
958
1083
  "var": var,
959
1084
  "iv_weighted": iv_val,
@@ -964,7 +1089,15 @@ def _weighted_screen_impl(
964
1089
  columns=["var", "iv_weighted", "n_bins", "missing_rate"],
965
1090
  )
966
1091
  if not iv_table.empty:
967
- keep = _iv_band_keep(iv_table, "iv_weighted", iv_threshold, iv_upper_threshold, dropped_rows)
1092
+ if iv_upper_threshold is not None:
1093
+ gate_frame = iv_table[["var", "iv_weighted"]].copy()
1094
+ gate_frame["iv_floored"] = gate_frame["var"].map(iv_floored_map)
1095
+ keep = _iv_band_keep(
1096
+ gate_frame, "iv_weighted", iv_threshold, iv_upper_threshold,
1097
+ dropped_rows, upper_col="iv_floored",
1098
+ )
1099
+ else:
1100
+ keep = _iv_band_keep(iv_table, "iv_weighted", iv_threshold, iv_upper_threshold, dropped_rows)
968
1101
  n_before = len(current)
969
1102
  current = _apply_stage_keep(
970
1103
  current, keep, "iv", summary_rows,
@@ -994,9 +1127,7 @@ def _weighted_screen_impl(
994
1127
  if gates_config is not None:
995
1128
  from .Screen_Gates import apply_post_corr_gates
996
1129
 
997
- gate_iv_map = (
998
- dict(zip(iv_table["var"], iv_table["iv_weighted"])) if not iv_table.empty else {}
999
- )
1130
+ gate_iv_map = _gate_ranking_iv_map(iv_table, iv_floored_map, iv_upper_threshold)
1000
1131
  current = apply_post_corr_gates(
1001
1132
  ins, current, gates_config, selection_evidence, gate_iv_map,
1002
1133
  summary_rows, dropped_rows, stage_tables,
@@ -892,8 +892,18 @@ class CreditModelPipeline:
892
892
  },
893
893
  cfg.monotone_woe_params,
894
894
  )
895
+ # fit()-only kwargs must not reach MonotoneWOEBinner.__init__ —
896
+ # n_jobs / chi2_p / chi2_init_size in monotone_woe_params used to
897
+ # raise TypeError on this self-fit path (the screening-side
898
+ # fit_screening_woe_engine already splits init vs fit keys).
899
+ fit_kwargs = {
900
+ key: params.pop(key)
901
+ for key in ("chi2_binning", "chi2_p", "chi2_init_size", "n_jobs")
902
+ if key in params
903
+ }
904
+ fit_kwargs["chi2_binning"] = bool(fit_kwargs.get("chi2_binning", False))
895
905
  binner = MonotoneWOEBinner(**params)
896
- binner.fit(fit_ins, chi2_binning=bool(cfg.monotone_woe_params.get("chi2_binning", False)))
906
+ binner.fit(fit_ins, **fit_kwargs)
897
907
  if cfg.write_outputs and cfg.plot_outputs:
898
908
  binner.plot_woe_graph(graph_path=str(Path(cfg.output_dir) / "figs" / "mono_woe"))
899
909
  adapter = as_woe_engine(binner, woe_suffix=woe_suffix)
@@ -1447,12 +1447,29 @@ class FeatureValidationPipeline:
1447
1447
  return adapter.assign_bins_frame(data, usable)
1448
1448
 
1449
1449
  @staticmethod
1450
- def _evidence_iv(bins: np.ndarray, target_values: np.ndarray) -> float:
1450
+ def _evidence_iv(
1451
+ bins: np.ndarray,
1452
+ target_values: np.ndarray,
1453
+ sample_weight: np.ndarray | None = None,
1454
+ ) -> float:
1451
1455
  """Same WOE/IV formula as _ivks_from_assigned_bins_grouped, minus the
1452
- iv_cut/min_group_size report filters (gates need unfiltered evidence)."""
1456
+ iv_cut/min_group_size report filters (gates need unfiltered evidence).
1457
+
1458
+ ``sample_weight`` (positional-aligned with ``bins``) switches counts
1459
+ to weighted mass so gate evidence matches the weighted modeling
1460
+ basis; None reproduces the unweighted arithmetic exactly. Weights are
1461
+ expected on frequency scale (e.g. FT weights): the max(total, 1.0)
1462
+ floors are kept verbatim, so a zero-mass class or slice degrades to
1463
+ iv == 0.0 (never NaN) and sub-unit total mass is damped — both err
1464
+ toward dropping under a lower IV floor."""
1453
1465
  bin_codes, _ = pd.factorize(bins, sort=False)
1454
- counts = np.bincount(bin_codes).astype(float)
1455
- bad = np.bincount(bin_codes, weights=target_values)
1466
+ if sample_weight is None:
1467
+ counts = np.bincount(bin_codes).astype(float)
1468
+ bad = np.bincount(bin_codes, weights=target_values)
1469
+ else:
1470
+ w = np.asarray(sample_weight, dtype=float)
1471
+ counts = np.bincount(bin_codes, weights=w)
1472
+ bad = np.bincount(bin_codes, weights=w * np.asarray(target_values, dtype=float))
1456
1473
  good = counts - bad
1457
1474
  bad_pct = bad / max(float(bad.sum()), 1.0)
1458
1475
  good_pct = good / max(float(good.sum()), 1.0)
@@ -1467,7 +1484,12 @@ class FeatureValidationPipeline:
1467
1484
  screen_cfg: Any,
1468
1485
  ) -> Any | None:
1469
1486
  """Lazy G03/G04 evidence closures over INS, priced only on the
1470
- post-corr survivor set the gates hand them."""
1487
+ post-corr survivor set the gates hand them.
1488
+
1489
+ Evidence IVs and directions respect ``cfg.weight_col`` so gate
1490
+ decisions share the weighted modeling basis; the ``n`` column stays a
1491
+ RAW observation count (min_group_n gauges statistical sufficiency,
1492
+ which weights do not add to)."""
1471
1493
  cfg = self.config
1472
1494
  group_gates_on = any(
1473
1495
  getattr(screen_cfg, name, None) is not None
@@ -1483,6 +1505,13 @@ class FeatureValidationPipeline:
1483
1505
 
1484
1506
  ins = splits["ins"]
1485
1507
  primary = target_cols[0]
1508
+ w_ins = None
1509
+ if cfg.weight_col:
1510
+ from Modeling_Tool.Core.sample_weight_utils import resolve_sample_weight
1511
+
1512
+ w_ins = resolve_sample_weight(
1513
+ data=ins, weight_col=cfg.weight_col, expected_len=len(ins)
1514
+ )
1486
1515
  group_dims = list(cfg.selection_group_dims or [])
1487
1516
  if group_gates_on:
1488
1517
  if not group_dims:
@@ -1498,7 +1527,9 @@ class FeatureValidationPipeline:
1498
1527
  )
1499
1528
 
1500
1529
  def per_group_iv_fn(features: list[str]) -> pd.DataFrame:
1501
- data = ins[ins[primary].notna()]
1530
+ obs_mask = ins[primary].notna()
1531
+ data = ins[obs_mask]
1532
+ w_data = w_ins[obs_mask.to_numpy()] if w_ins is not None else None
1502
1533
  bins_frame = self._evidence_bins_frame(data, features, primary, woe_artifacts)
1503
1534
  if bins_frame is None:
1504
1535
  raise ValueError(
@@ -1514,6 +1545,7 @@ class FeatureValidationPipeline:
1514
1545
  if isinstance(group_value, tuple) else str(group_value)
1515
1546
  )
1516
1547
  y_g = y[positions]
1548
+ w_g = w_data[positions] if w_data is not None else None
1517
1549
  sub = data.iloc[positions]
1518
1550
  for var in features:
1519
1551
  if var not in bins_frame.columns:
@@ -1524,15 +1556,18 @@ class FeatureValidationPipeline:
1524
1556
  "var": var,
1525
1557
  "group": label,
1526
1558
  "n": int(len(positions)),
1527
- "iv": self._evidence_iv(bins, y_g),
1528
- "direction": point_biserial_direction(sub[var], sub[primary]),
1559
+ "iv": self._evidence_iv(bins, y_g, sample_weight=w_g),
1560
+ "direction": point_biserial_direction(
1561
+ sub[var], sub[primary], sample_weight=w_g
1562
+ ),
1529
1563
  })
1530
1564
  return pd.DataFrame(rows, columns=["var", "group", "n", "iv", "direction"])
1531
1565
 
1532
1566
  def per_target_fn(features: list[str]) -> pd.DataFrame:
1533
1567
  rows: list[dict] = []
1534
1568
  for target in target_cols:
1535
- data = ins[ins[target].notna()]
1569
+ obs_mask = ins[target].notna()
1570
+ data = ins[obs_mask]
1536
1571
  if not len(data):
1537
1572
  rows.extend(
1538
1573
  {"var": var, "target": target, "iv": np.nan,
@@ -1540,13 +1575,16 @@ class FeatureValidationPipeline:
1540
1575
  for var in features
1541
1576
  )
1542
1577
  continue
1578
+ w_t = w_ins[obs_mask.to_numpy()] if w_ins is not None else None
1543
1579
  bins_frame = self._evidence_bins_frame(data, features, target, woe_artifacts)
1544
1580
  y = data[target].astype(int).to_numpy()
1545
1581
  for var in features:
1546
1582
  if bins_frame is None or var not in bins_frame.columns:
1547
1583
  rows.append({
1548
1584
  "var": var, "target": target, "iv": np.nan,
1549
- "direction": point_biserial_direction(data[var], data[target])
1585
+ "direction": point_biserial_direction(
1586
+ data[var], data[target], sample_weight=w_t
1587
+ )
1550
1588
  if var in data.columns else 0,
1551
1589
  "status": "engine_missing",
1552
1590
  })
@@ -1556,8 +1594,10 @@ class FeatureValidationPipeline:
1556
1594
  rows.append({
1557
1595
  "var": var,
1558
1596
  "target": target,
1559
- "iv": self._evidence_iv(bins, y),
1560
- "direction": point_biserial_direction(data[var], data[target]),
1597
+ "iv": self._evidence_iv(bins, y, sample_weight=w_t),
1598
+ "direction": point_biserial_direction(
1599
+ data[var], data[target], sample_weight=w_t
1600
+ ),
1561
1601
  "status": "ok",
1562
1602
  })
1563
1603
  return pd.DataFrame(rows, columns=["var", "target", "iv", "direction", "status"])
@@ -1627,6 +1667,7 @@ class FeatureValidationPipeline:
1627
1667
  "vif_enabled": screen_cfg.vif_enabled,
1628
1668
  "vif_threshold": screen_cfg.vif_threshold,
1629
1669
  "selection_group_dims": list(cfg.selection_group_dims or []),
1670
+ "evidence_weight_col": cfg.weight_col,
1630
1671
  })
1631
1672
  selection_summary = {
1632
1673
  "initial_features": list(new_features),