proscore 0.2.1__tar.gz → 0.2.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 (73) hide show
  1. {proscore-0.2.1/src/proscore.egg-info → proscore-0.2.2}/PKG-INFO +54 -3
  2. {proscore-0.2.1 → proscore-0.2.2}/README.md +53 -2
  3. {proscore-0.2.1 → proscore-0.2.2}/pyproject.toml +1 -1
  4. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/__init__.py +1 -1
  5. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/_pipeline_config.py +10 -3
  6. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/evaluate/_diagnose.py +2 -2
  7. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/inspect/__init__.py +2 -2
  8. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/inspect/_stability.py +87 -45
  9. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/rules/_miner.py +16 -0
  10. {proscore-0.2.1 → proscore-0.2.2/src/proscore.egg-info}/PKG-INFO +54 -3
  11. {proscore-0.2.1 → proscore-0.2.2}/tests/test_inspect.py +26 -10
  12. {proscore-0.2.1 → proscore-0.2.2}/tests/test_pipeline_rules.py +4 -0
  13. {proscore-0.2.1 → proscore-0.2.2}/tests/test_rules.py +30 -0
  14. {proscore-0.2.1 → proscore-0.2.2}/LICENSE +0 -0
  15. {proscore-0.2.1 → proscore-0.2.2}/setup.cfg +0 -0
  16. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/__main__.py +0 -0
  17. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/_data/__init__.py +0 -0
  18. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/_spec.py +0 -0
  19. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/__init__.py +0 -0
  20. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_adjust.py +0 -0
  21. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_base.py +0 -0
  22. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_binning.py +0 -0
  23. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_categorical.py +0 -0
  24. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_chi.py +0 -0
  25. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_distance.py +0 -0
  26. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_frequency.py +0 -0
  27. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_tree.py +0 -0
  28. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_woe.py +0 -0
  29. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/evaluate/__init__.py +0 -0
  30. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/evaluate/_metrics.py +0 -0
  31. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/inspect/_correlation.py +0 -0
  32. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/inspect/_detect.py +0 -0
  33. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/inspect/_quality.py +0 -0
  34. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/modeling/__init__.py +0 -0
  35. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/modeling/_scorecard.py +0 -0
  36. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/monitor/__init__.py +0 -0
  37. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/monitor/_monitor.py +0 -0
  38. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/report/__init__.py +0 -0
  39. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/report/_builder.py +0 -0
  40. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/rules/__init__.py +0 -0
  41. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/selection/__init__.py +0 -0
  42. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/selection/_filter.py +0 -0
  43. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/selection/_screen.py +0 -0
  44. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/selection/_stepwise.py +0 -0
  45. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/transform/__init__.py +0 -0
  46. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/transform/_woe.py +0 -0
  47. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/utils/__init__.py +0 -0
  48. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/utils/_config.py +0 -0
  49. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/utils/_exceptions.py +0 -0
  50. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/utils/_presets.py +0 -0
  51. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/utils/_psi.py +0 -0
  52. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/viz/__init__.py +0 -0
  53. {proscore-0.2.1 → proscore-0.2.2}/src/proscore/viz/_plots.py +0 -0
  54. {proscore-0.2.1 → proscore-0.2.2}/src/proscore.egg-info/SOURCES.txt +0 -0
  55. {proscore-0.2.1 → proscore-0.2.2}/src/proscore.egg-info/dependency_links.txt +0 -0
  56. {proscore-0.2.1 → proscore-0.2.2}/src/proscore.egg-info/entry_points.txt +0 -0
  57. {proscore-0.2.1 → proscore-0.2.2}/src/proscore.egg-info/requires.txt +0 -0
  58. {proscore-0.2.1 → proscore-0.2.2}/src/proscore.egg-info/top_level.txt +0 -0
  59. {proscore-0.2.1 → proscore-0.2.2}/tests/test_binning.py +0 -0
  60. {proscore-0.2.1 → proscore-0.2.2}/tests/test_diagnose.py +0 -0
  61. {proscore-0.2.1 → proscore-0.2.2}/tests/test_docs_examples.py +0 -0
  62. {proscore-0.2.1 → proscore-0.2.2}/tests/test_evaluate.py +0 -0
  63. {proscore-0.2.1 → proscore-0.2.2}/tests/test_evaluate_period.py +0 -0
  64. {proscore-0.2.1 → proscore-0.2.2}/tests/test_filter.py +0 -0
  65. {proscore-0.2.1 → proscore-0.2.2}/tests/test_pipeline.py +0 -0
  66. {proscore-0.2.1 → proscore-0.2.2}/tests/test_presets.py +0 -0
  67. {proscore-0.2.1 → proscore-0.2.2}/tests/test_report.py +0 -0
  68. {proscore-0.2.1 → proscore-0.2.2}/tests/test_scorecard.py +0 -0
  69. {proscore-0.2.1 → proscore-0.2.2}/tests/test_screen.py +0 -0
  70. {proscore-0.2.1 → proscore-0.2.2}/tests/test_spec.py +0 -0
  71. {proscore-0.2.1 → proscore-0.2.2}/tests/test_stepwise.py +0 -0
  72. {proscore-0.2.1 → proscore-0.2.2}/tests/test_transform.py +0 -0
  73. {proscore-0.2.1 → proscore-0.2.2}/tests/test_woe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proscore
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Production-grade scorecard development toolkit
5
5
  Author: Liqiwei
6
6
  License-Expression: MIT
@@ -45,10 +45,47 @@ Dynamic: license-file
45
45
  **生产级评分卡开发工具包**
46
46
  端到端的确定性评分卡建模管线,为银行和金融机构的信用评分卡建模场景设计, 满足对可解释性、合规性和稳定性的要求。
47
47
 
48
+ ## Why ProScore
49
+
50
+ ProScore 不是通用机器学习框架,而是面向金融评分卡落地的**工程化工具包**。
51
+ 目标是把“能建模”升级为“可评审、可复现、可上线、可监控”。
52
+
53
+ 适合以下场景:
54
+
55
+ - 银行/消金/互金团队做信用评分卡开发与迭代
56
+ - 研发与业务分析师需要通过 Python + Excel 协同建模
57
+ - 需要输出监管/评审材料,并建立投产后监控闭环
58
+
59
+ ## 核心亮点
60
+
61
+ 1. **单调性工程化(关键差异)**
62
+ - 支持变量级单调方向配置(increasing/decreasing/u/inverted_u/none)
63
+ - 支持自动单调调整,减少人工反复调箱
64
+ - 单调配置可模板化复用,跨项目保持一致性
65
+
66
+ 2. **端到端确定性流程**
67
+ - `detect -> prefilter -> bin -> refine -> transform -> select -> fit -> evaluate -> diagnose -> report -> monitor`
68
+ - 同样输入得到同样输出,便于审计、复盘和团队协作
69
+
70
+ 3. **三种使用方式统一口径**
71
+ - 模块化 API(灵活)
72
+ - 链式 API(高效)
73
+ - Excel 配置驱动(零代码)
74
+ - 三种入口共享同一建模逻辑,减少“口径不一致”
75
+
76
+ 4. **诊断与报告一体化**
77
+ - `diagnose()` 提供 4 层结构化诊断(区分力/过拟合/稳定性/变量质量)
78
+ - 支持阈值自定义(`thresholds=...`)
79
+ - `ReportBuilder` 自动纳入诊断章节,提升评审效率
80
+
81
+ 5. **投产后监控闭环**
82
+ - 支持 PSI、KS 衰减、规则告警、分期追踪
83
+ - 帮助形成“上线—监控—重训”的持续运营机制
48
84
  ---
49
85
 
50
86
  ## 目录
51
87
 
88
+ - [入门教程(Notebook)](#入门教程notebook)
52
89
  - [三种使用方式](#三种使用方式)
53
90
  - [核心功能概览](#核心功能概览)
54
91
  - [安装](#安装)
@@ -57,6 +94,17 @@ Dynamic: license-file
57
94
 
58
95
  ---
59
96
 
97
+ ## 入门教程(Notebook)
98
+
99
+ 推荐按下面顺序阅读,先跑通再深入:
100
+
101
+ | Notebook | 适合谁 | 你会得到什么 |
102
+ |----------|--------|--------------|
103
+ | [**ProScore快速开始**](notebooks/ProScore快速开始.ipynb) | 第一次上手 | 5–10 分钟链式单路径,只看 KS/AUC/PSI、入模变量、诊断摘要 |
104
+ | [**ProScore完整建模流程**](notebooks/ProScore完整建模流程.ipynb) | 准备落地生产 | 模块化 + 链式对照、CFG 参数单一真源、规则挖掘、监控、报告、诊断 |
105
+
106
+ > 快速开始刻意保持精简(不含规则挖掘等可选步骤);完整版是权威样例,含 `[主线]` / `[可选]` 章节导航与一致性断言。
107
+
60
108
  ## 三种使用方式
61
109
 
62
110
  ProScore 提供三种递进的使用方式,从零代码到完全自定义,按需选择。
@@ -106,9 +154,11 @@ p = (
106
154
 
107
155
  > `train` 必传,`test` 和 `oot` 可选。分箱/WOE 只在 train 上拟合;逐步回归用 test 监控过拟合;OOT 仅用于最终评估。
108
156
  >
109
- > 完整教程见 [notebooks/ProScore完整建模流程.ipynb](notebooks/ProScore完整建模流程.ipynb)
157
+ > Notebook 教程见上方 [入门教程](#入门教程notebook)
158
+ >
159
+ > **诊断增强**(v0.2+):`.evaluate().diagnose()` 生成 4 层结构化健康报告(含根因变量),支持 `thresholds=...` 自定义阈值。
110
160
  >
111
- > **诊断增强**(v0.2+):`.evaluate().diagnose()` 生成 4 层结构化健康报告(含根因变量),支持 `thresholds=...` 自定义阈值,适配不同机构/产品风控偏好。
161
+ > **参数单一真源(推荐)**:`CFG` + `PipelineSpec`(`apply(spec)`)确保模块化与链式同参同结果,详见 [pipeline-spec.md](docs/使用指南/pipeline-spec.md)
112
162
 
113
163
  ### C. Excel 配置驱动
114
164
 
@@ -140,6 +190,7 @@ proscore run my_project/pipeline_template.xlsx --output-script run.py
140
190
  |------------|-----------------------------------------------|---------------------------------------|
141
191
  | 数据探查 | IV/AUC/KS 三指标 + PSI 时序稳定性 + 相关性/VIF | 快速筛选优质变量,识别分布漂移风险 |
142
192
  | 分箱 | 4 种单调趋势 + 5 种分箱方法 + 两阶段趋势校验 | 确保 WOE 趋势符合业务逻辑,满足监管 |
193
+ | 规则挖掘 | 单变量/交叉规则 + Lift/Precision/Recall 联合筛选 | 产出可解释策略规则,与评分卡变量互斥 |
143
194
  | 逐步回归 | 双向选择 + 五重约束(p值/符号/VIF/相关/来源) | 严谨的多重共线性控制与维度归属管理 |
144
195
  | 模型监控 | Score/Feature PSI + 规则引擎告警 + JSON 持久化 | 投产后持续验证,自动风险预警 |
145
196
  | 报告生成 | 7 章自动 Markdown 报告(含图表) | 银保监合规文档一键生成 |
@@ -7,10 +7,47 @@
7
7
  **生产级评分卡开发工具包**
8
8
  端到端的确定性评分卡建模管线,为银行和金融机构的信用评分卡建模场景设计, 满足对可解释性、合规性和稳定性的要求。
9
9
 
10
+ ## Why ProScore
11
+
12
+ ProScore 不是通用机器学习框架,而是面向金融评分卡落地的**工程化工具包**。
13
+ 目标是把“能建模”升级为“可评审、可复现、可上线、可监控”。
14
+
15
+ 适合以下场景:
16
+
17
+ - 银行/消金/互金团队做信用评分卡开发与迭代
18
+ - 研发与业务分析师需要通过 Python + Excel 协同建模
19
+ - 需要输出监管/评审材料,并建立投产后监控闭环
20
+
21
+ ## 核心亮点
22
+
23
+ 1. **单调性工程化(关键差异)**
24
+ - 支持变量级单调方向配置(increasing/decreasing/u/inverted_u/none)
25
+ - 支持自动单调调整,减少人工反复调箱
26
+ - 单调配置可模板化复用,跨项目保持一致性
27
+
28
+ 2. **端到端确定性流程**
29
+ - `detect -> prefilter -> bin -> refine -> transform -> select -> fit -> evaluate -> diagnose -> report -> monitor`
30
+ - 同样输入得到同样输出,便于审计、复盘和团队协作
31
+
32
+ 3. **三种使用方式统一口径**
33
+ - 模块化 API(灵活)
34
+ - 链式 API(高效)
35
+ - Excel 配置驱动(零代码)
36
+ - 三种入口共享同一建模逻辑,减少“口径不一致”
37
+
38
+ 4. **诊断与报告一体化**
39
+ - `diagnose()` 提供 4 层结构化诊断(区分力/过拟合/稳定性/变量质量)
40
+ - 支持阈值自定义(`thresholds=...`)
41
+ - `ReportBuilder` 自动纳入诊断章节,提升评审效率
42
+
43
+ 5. **投产后监控闭环**
44
+ - 支持 PSI、KS 衰减、规则告警、分期追踪
45
+ - 帮助形成“上线—监控—重训”的持续运营机制
10
46
  ---
11
47
 
12
48
  ## 目录
13
49
 
50
+ - [入门教程(Notebook)](#入门教程notebook)
14
51
  - [三种使用方式](#三种使用方式)
15
52
  - [核心功能概览](#核心功能概览)
16
53
  - [安装](#安装)
@@ -19,6 +56,17 @@
19
56
 
20
57
  ---
21
58
 
59
+ ## 入门教程(Notebook)
60
+
61
+ 推荐按下面顺序阅读,先跑通再深入:
62
+
63
+ | Notebook | 适合谁 | 你会得到什么 |
64
+ |----------|--------|--------------|
65
+ | [**ProScore快速开始**](notebooks/ProScore快速开始.ipynb) | 第一次上手 | 5–10 分钟链式单路径,只看 KS/AUC/PSI、入模变量、诊断摘要 |
66
+ | [**ProScore完整建模流程**](notebooks/ProScore完整建模流程.ipynb) | 准备落地生产 | 模块化 + 链式对照、CFG 参数单一真源、规则挖掘、监控、报告、诊断 |
67
+
68
+ > 快速开始刻意保持精简(不含规则挖掘等可选步骤);完整版是权威样例,含 `[主线]` / `[可选]` 章节导航与一致性断言。
69
+
22
70
  ## 三种使用方式
23
71
 
24
72
  ProScore 提供三种递进的使用方式,从零代码到完全自定义,按需选择。
@@ -68,9 +116,11 @@ p = (
68
116
 
69
117
  > `train` 必传,`test` 和 `oot` 可选。分箱/WOE 只在 train 上拟合;逐步回归用 test 监控过拟合;OOT 仅用于最终评估。
70
118
  >
71
- > 完整教程见 [notebooks/ProScore完整建模流程.ipynb](notebooks/ProScore完整建模流程.ipynb)
119
+ > Notebook 教程见上方 [入门教程](#入门教程notebook)
120
+ >
121
+ > **诊断增强**(v0.2+):`.evaluate().diagnose()` 生成 4 层结构化健康报告(含根因变量),支持 `thresholds=...` 自定义阈值。
72
122
  >
73
- > **诊断增强**(v0.2+):`.evaluate().diagnose()` 生成 4 层结构化健康报告(含根因变量),支持 `thresholds=...` 自定义阈值,适配不同机构/产品风控偏好。
123
+ > **参数单一真源(推荐)**:`CFG` + `PipelineSpec`(`apply(spec)`)确保模块化与链式同参同结果,详见 [pipeline-spec.md](docs/使用指南/pipeline-spec.md)
74
124
 
75
125
  ### C. Excel 配置驱动
76
126
 
@@ -102,6 +152,7 @@ proscore run my_project/pipeline_template.xlsx --output-script run.py
102
152
  |------------|-----------------------------------------------|---------------------------------------|
103
153
  | 数据探查 | IV/AUC/KS 三指标 + PSI 时序稳定性 + 相关性/VIF | 快速筛选优质变量,识别分布漂移风险 |
104
154
  | 分箱 | 4 种单调趋势 + 5 种分箱方法 + 两阶段趋势校验 | 确保 WOE 趋势符合业务逻辑,满足监管 |
155
+ | 规则挖掘 | 单变量/交叉规则 + Lift/Precision/Recall 联合筛选 | 产出可解释策略规则,与评分卡变量互斥 |
105
156
  | 逐步回归 | 双向选择 + 五重约束(p值/符号/VIF/相关/来源) | 严谨的多重共线性控制与维度归属管理 |
106
157
  | 模型监控 | Score/Feature PSI + 规则引擎告警 + JSON 持久化 | 投产后持续验证,自动风险预警 |
107
158
  | 报告生成 | 7 章自动 Markdown 报告(含图表) | 银保监合规文档一键生成 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proscore"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Production-grade scorecard development toolkit"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -19,7 +19,7 @@ from proscore.rules import RuleMiner
19
19
  from proscore.selection import Filter, StepwiseSelector, assess_screen
20
20
  from proscore.transform import WOETransformer
21
21
 
22
- __version__ = "0.2.1"
22
+ __version__ = "0.2.2"
23
23
 
24
24
 
25
25
  class ProScore:
@@ -157,6 +157,10 @@ _PARAM_SPEC = {
157
157
  "决策树最大深度(tree 模式)", "rules"),
158
158
  "rm_min_lift": (3.0, None, "float", 1.0, 10.0,
159
159
  "最小 Lift(precision / 整体坏账率)", "rules"),
160
+ "rm_min_precision": (None, None, "float", 0.0, 1.0,
161
+ "最小 Precision(留空表示不启用)", "rules"),
162
+ "rm_min_recall": (None, None, "float", 0.0, 1.0,
163
+ "最小 Recall(留空表示不启用)", "rules"),
160
164
  "rm_min_hit_rate": (0.02, None, "float", 0.001, 0.5,
161
165
  "最小命中率(覆盖样本占比)", "rules"),
162
166
  "rm_max_hit_rate": (0.20, None, "float", 0.01, 0.8,
@@ -364,7 +368,8 @@ class PipelineConfig:
364
368
  # ── Rules section: bare Excel keys → rm_ prefixed _PARAM_SPEC keys ──
365
369
  if section == "rules":
366
370
  valid = ("method", "max_depth", "max_tree_depth",
367
- "min_lift", "min_hit_rate", "max_hit_rate",
371
+ "min_lift", "min_precision", "min_recall",
372
+ "min_hit_rate", "max_hit_rate",
368
373
  "max_rules", "random_state", "export_csv")
369
374
  # Accept both bare keys and legacy rm_ prefixed keys
370
375
  bare_key = key.removeprefix("rm_")
@@ -872,7 +877,8 @@ class PipelineConfig:
872
877
  kw: dict[str, Any] = {}
873
878
  cfg = self.rules_cfg
874
879
  for key in ("method", "max_depth", "max_tree_depth", "min_lift",
875
- "min_hit_rate", "max_hit_rate", "max_rules", "random_state"):
880
+ "min_precision", "min_recall", "min_hit_rate",
881
+ "max_hit_rate", "max_rules", "random_state"):
876
882
  if key in cfg:
877
883
  kw[key] = cfg[key]
878
884
  return kw
@@ -1188,7 +1194,8 @@ def generate_template(out_dir: str = ".") -> str:
1188
1194
 
1189
1195
  # ── Rules ───────────────────────────────────────────────────────────
1190
1196
  _write_params_sheet(writer, "Rules",
1191
- ["method", "max_depth", "max_tree_depth", "min_lift", "min_hit_rate",
1197
+ ["method", "max_depth", "max_tree_depth", "min_lift",
1198
+ "min_precision", "min_recall", "min_hit_rate",
1192
1199
  "max_hit_rate", "max_rules", "random_state", "export_csv"],
1193
1200
  section="rules")
1194
1201
 
@@ -381,8 +381,8 @@ def _oot_decay_suggestion(
381
381
  period_eval: pd.DataFrame | None,
382
382
  ) -> str:
383
383
  parts = []
384
- if stability is not None and "stability" in stability.columns:
385
- unstable = stability[stability["stability"].isin(["unstable", "trending_down"])]
384
+ if stability is not None and "psi_flag" in stability.columns:
385
+ unstable = stability[stability["psi_flag"] == "unstable"]
386
386
  if len(unstable) > 0:
387
387
  u_vars = unstable["variable"].unique()[:3]
388
388
  parts.append(f"不稳定变量: {', '.join(u_vars)}")
@@ -1,9 +1,9 @@
1
1
  from proscore.inspect._correlation import correlation, vif
2
2
  from proscore.inspect._detect import detect
3
3
  from proscore.inspect._quality import list_supported_estimators, quality
4
- from proscore.inspect._stability import stability, stability_summary
4
+ from proscore.inspect._stability import period_bad_rate, stability, stability_summary
5
5
 
6
6
  __all__ = [
7
7
  "correlation", "detect", "list_supported_estimators",
8
- "quality", "stability", "stability_summary", "vif",
8
+ "quality", "stability", "stability_summary", "period_bad_rate", "vif",
9
9
  ]
@@ -1,4 +1,8 @@
1
- """Time-series variable stability analysis: bad_rate trend, PSI drift."""
1
+ """Time-series stability analysis.
2
+
3
+ - ``stability``: variable-level distribution stability (PSI only)
4
+ - ``period_bad_rate``: portfolio-level target bad-rate trend by period
5
+ """
2
6
 
3
7
  from __future__ import annotations
4
8
 
@@ -15,20 +19,15 @@ def stability(
15
19
  time_col: str,
16
20
  features: list[str] | None = None,
17
21
  n_bins: int = 5,
18
- bad_rate_trend_threshold: float = 0.5,
19
22
  psi_warn_threshold: float = 0.1,
20
23
  ) -> pd.DataFrame:
21
24
  """
22
- Time-series stability analysis for each feature.
23
-
24
- For each time period, computes sample count, bad rate, distribution PSI
25
- (vs first period and vs previous period), and **two independent flags**:
25
+ Variable-level time-series stability analysis.
26
26
 
27
- - ``psi_flag``: distribution drift vs the first period (PSI).
28
- - ``bad_rate_flag``: bad-rate trend vs the first period (relative change).
29
-
30
- ``bad_rate_change`` is the relative change from the first period:
31
- ``(bad_rate[t] - bad_rate[0]) / bad_rate[0]``.
27
+ This function focuses on **feature distribution drift only**:
28
+ PSI (vs first period / previous period) and PSI-based stability flag.
29
+ Portfolio-level target bad-rate trend is intentionally separated into
30
+ :func:`period_bad_rate`.
32
31
 
33
32
  Parameters
34
33
  ----------
@@ -45,19 +44,14 @@ def stability(
45
44
  (excludes *target* and *time_col*).
46
45
  n_bins : int
47
46
  Number of equal-frequency bins for continuous PSI calculation.
48
- bad_rate_trend_threshold : float
49
- Relative change in bad rate that triggers a ``"trending"`` flag on
50
- ``bad_rate_flag``. For example, 0.5 means a 50% increase or decrease
51
- from the first period is flagged.
52
47
  psi_warn_threshold : float
53
48
  PSI threshold above which ``psi_flag`` is set to ``"unstable"``.
54
49
 
55
50
  Returns
56
51
  -------
57
52
  pd.DataFrame
58
- Columns: ``variable | time_period | n | bad_rate |
59
- bad_rate_change | psi_vs_first | psi_vs_prev | mean | std |
60
- psi_flag | bad_rate_flag``.
53
+ Columns: ``variable | time_period | n | psi_vs_first | psi_vs_prev |
54
+ mean | std | psi_flag``.
61
55
  """
62
56
  if target not in df.columns:
63
57
  raise KeyError(f"target column {target!r} not in DataFrame")
@@ -89,28 +83,13 @@ def stability(
89
83
  base_bins = _distribution_bins(base_data, cat, n_bins, all_cats)
90
84
  base_dist = _distribution(base_data, base_bins, cat)
91
85
 
92
- first_bad_rate = None
93
86
  prev_dist = None
94
87
 
95
88
  for p_idx, period in enumerate(periods):
96
89
  mask = df[time_col] == period
97
- # Exclude rows where target is NaN for bad-rate calculation
98
- sub = df.loc[mask, [target, col]].dropna(subset=[target])
99
- sub_target = sub[target]
100
90
  sub_data = series[mask].dropna() # full data for PSI/distribution
101
91
 
102
- n = len(sub_target)
103
- bad = sub_target.sum()
104
- bad_rate = bad / n if n > 0 else np.nan
105
-
106
- if p_idx == 0:
107
- first_bad_rate = bad_rate
108
-
109
- # Bad rate change vs first period
110
- if first_bad_rate is not None and first_bad_rate > 0 and not np.isnan(bad_rate):
111
- br_change = (bad_rate - first_bad_rate) / first_bad_rate
112
- else:
113
- br_change = np.nan
92
+ n = len(sub_data)
114
93
 
115
94
  # PSI
116
95
  cur_dist = _distribution(sub_data, base_bins, cat)
@@ -129,16 +108,11 @@ def stability(
129
108
  "variable": col,
130
109
  "time_period": period,
131
110
  "n": int(n),
132
- "bad_rate": round(bad_rate, 6) if not np.isnan(bad_rate) else np.nan,
133
- "bad_rate_change": round(br_change, 4) if not np.isnan(br_change) else np.nan,
134
111
  "psi_vs_first": round(psi_first, 6),
135
112
  "psi_vs_prev": round(psi_prev, 6) if not np.isnan(psi_prev) else np.nan,
136
113
  "mean": round(float(mean_val), 4) if not np.isnan(mean_val) else np.nan,
137
114
  "std": round(float(std_val), 4) if not np.isnan(std_val) else np.nan,
138
115
  "psi_flag": _psi_flag(p_idx, psi_first, psi_warn_threshold),
139
- "bad_rate_flag": _bad_rate_flag(
140
- p_idx, br_change, bad_rate_trend_threshold,
141
- ),
142
116
  })
143
117
 
144
118
  prev_dist = cur_dist
@@ -153,7 +127,7 @@ def stability(
153
127
  def stability_summary(
154
128
  stability_result: pd.DataFrame,
155
129
  *,
156
- metric: str = "bad_rate",
130
+ metric: str = "psi_vs_first",
157
131
  ) -> pd.DataFrame:
158
132
  """
159
133
  Pivot long-form :func:`stability` output to one row per variable.
@@ -163,14 +137,14 @@ def stability_summary(
163
137
  stability_result : pd.DataFrame
164
138
  Output of :func:`stability`.
165
139
  metric : str
166
- Column to pivot: ``"bad_rate"``, ``"psi_vs_first"``, or
167
- ``"bad_rate_change"``.
140
+ Column to pivot: ``"psi_vs_first"``, ``"psi_vs_prev"``,
141
+ ``"mean"``, ``"std"``, or ``"n"``.
168
142
 
169
143
  Returns
170
144
  -------
171
145
  pd.DataFrame
172
- Index ``variable``; columns are time periods; extra columns
173
- ``latest_psi_flag`` and ``latest_bad_rate_flag`` from the last period.
146
+ Index ``variable``; columns are time periods; extra column
147
+ ``latest_psi_flag`` from the last period.
174
148
  """
175
149
  if metric not in stability_result.columns:
176
150
  raise KeyError(f"metric {metric!r} not in stability result columns")
@@ -189,10 +163,78 @@ def stability_summary(
189
163
  .last()
190
164
  )
191
165
  wide["latest_psi_flag"] = latest["psi_flag"].reindex(wide.index)
192
- wide["latest_bad_rate_flag"] = latest["bad_rate_flag"].reindex(wide.index)
193
166
  return wide.reset_index()
194
167
 
195
168
 
169
+ def period_bad_rate(
170
+ df: pd.DataFrame,
171
+ target: str,
172
+ time_col: str,
173
+ *,
174
+ bad_rate_trend_threshold: float = 0.5,
175
+ ) -> pd.DataFrame:
176
+ """Portfolio-level target bad-rate trend by time period.
177
+
178
+ Parameters
179
+ ----------
180
+ df : pd.DataFrame
181
+ Input data containing *target* and *time_col*.
182
+ target : str
183
+ Binary target (1=bad).
184
+ time_col : str
185
+ Time period column.
186
+ bad_rate_trend_threshold : float
187
+ Relative change vs first period that triggers trend flags.
188
+
189
+ Returns
190
+ -------
191
+ pd.DataFrame
192
+ Columns: ``time_period | n | bad | bad_rate | bad_rate_change |
193
+ bad_rate_flag``.
194
+ """
195
+ if target not in df.columns:
196
+ raise KeyError(f"target column {target!r} not in DataFrame")
197
+ if time_col not in df.columns:
198
+ raise KeyError(f"time_col {time_col!r} not in DataFrame")
199
+
200
+ periods = sorted(df[time_col].dropna().unique())
201
+ if len(periods) < 2:
202
+ raise ValueError(f"Need at least 2 distinct time periods; got {len(periods)}")
203
+
204
+ rows: list[dict] = []
205
+ first_bad_rate = None
206
+ for p_idx, period in enumerate(periods):
207
+ sub = df.loc[df[time_col] == period, [target]].dropna(subset=[target])
208
+ n = len(sub)
209
+ bad = int(sub[target].sum()) if n > 0 else 0
210
+ bad_rate = (bad / n) if n > 0 else np.nan
211
+
212
+ if p_idx == 0:
213
+ first_bad_rate = bad_rate
214
+
215
+ if first_bad_rate is not None and first_bad_rate > 0 and not np.isnan(bad_rate):
216
+ br_change = (bad_rate - first_bad_rate) / first_bad_rate
217
+ else:
218
+ br_change = np.nan
219
+
220
+ rows.append(
221
+ {
222
+ "time_period": period,
223
+ "n": int(n),
224
+ "bad": int(bad),
225
+ "bad_rate": round(bad_rate, 6) if not np.isnan(bad_rate) else np.nan,
226
+ "bad_rate_change": round(br_change, 4) if not np.isnan(br_change) else np.nan,
227
+ "bad_rate_flag": _bad_rate_flag(p_idx, br_change, bad_rate_trend_threshold),
228
+ }
229
+ )
230
+
231
+ result = pd.DataFrame(rows)
232
+ result["time_period"] = pd.Categorical(
233
+ result["time_period"], categories=periods, ordered=True
234
+ )
235
+ return result.sort_values("time_period").reset_index(drop=True)
236
+
237
+
196
238
  # ── internal helpers ───────────────────────────────────────────────────────
197
239
 
198
240
 
@@ -45,6 +45,10 @@ class RuleMiner:
45
45
  Maximum depth of the decision tree (tree mode).
46
46
  min_lift : float
47
47
  Minimum Lift (precision / overall_bad_rate).
48
+ min_precision : float or None
49
+ Minimum precision threshold. ``None`` disables this filter.
50
+ min_recall : float or None
51
+ Minimum recall threshold. ``None`` disables this filter.
48
52
  min_hit_rate : float
49
53
  Minimum fraction of total samples a rule must cover.
50
54
  max_hit_rate : float
@@ -61,6 +65,8 @@ class RuleMiner:
61
65
  max_depth: int = 3,
62
66
  max_tree_depth: int = 4,
63
67
  min_lift: float = 3.0,
68
+ min_precision: float | None = None,
69
+ min_recall: float | None = None,
64
70
  min_hit_rate: float = 0.01,
65
71
  max_hit_rate: float = 0.20,
66
72
  max_rules: int = 20,
@@ -69,10 +75,16 @@ class RuleMiner:
69
75
  _valid = {"exhaustive", "tree", "apriori"}
70
76
  if method not in _valid:
71
77
  raise ValueError(f"Unknown method: {method!r}. Valid: {sorted(_valid)}")
78
+ if min_precision is not None and not (0.0 <= min_precision <= 1.0):
79
+ raise ValueError("min_precision must be within [0, 1] or None")
80
+ if min_recall is not None and not (0.0 <= min_recall <= 1.0):
81
+ raise ValueError("min_recall must be within [0, 1] or None")
72
82
  self.method = method
73
83
  self.max_depth = max_depth
74
84
  self.max_tree_depth = max_tree_depth
75
85
  self.min_lift = min_lift
86
+ self.min_precision = min_precision
87
+ self.min_recall = min_recall
76
88
  self.min_hit_rate = min_hit_rate
77
89
  self.max_hit_rate = max_hit_rate
78
90
  self.max_rules = max_rules
@@ -314,6 +326,10 @@ class RuleMiner:
314
326
 
315
327
  if lift < self.min_lift:
316
328
  return None
329
+ if self.min_precision is not None and precision < self.min_precision:
330
+ return None
331
+ if self.min_recall is not None and recall < self.min_recall:
332
+ return None
317
333
 
318
334
  rstr = self._format_rule(feat_bounds)
319
335
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proscore
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Production-grade scorecard development toolkit
5
5
  Author: Liqiwei
6
6
  License-Expression: MIT
@@ -45,10 +45,47 @@ Dynamic: license-file
45
45
  **生产级评分卡开发工具包**
46
46
  端到端的确定性评分卡建模管线,为银行和金融机构的信用评分卡建模场景设计, 满足对可解释性、合规性和稳定性的要求。
47
47
 
48
+ ## Why ProScore
49
+
50
+ ProScore 不是通用机器学习框架,而是面向金融评分卡落地的**工程化工具包**。
51
+ 目标是把“能建模”升级为“可评审、可复现、可上线、可监控”。
52
+
53
+ 适合以下场景:
54
+
55
+ - 银行/消金/互金团队做信用评分卡开发与迭代
56
+ - 研发与业务分析师需要通过 Python + Excel 协同建模
57
+ - 需要输出监管/评审材料,并建立投产后监控闭环
58
+
59
+ ## 核心亮点
60
+
61
+ 1. **单调性工程化(关键差异)**
62
+ - 支持变量级单调方向配置(increasing/decreasing/u/inverted_u/none)
63
+ - 支持自动单调调整,减少人工反复调箱
64
+ - 单调配置可模板化复用,跨项目保持一致性
65
+
66
+ 2. **端到端确定性流程**
67
+ - `detect -> prefilter -> bin -> refine -> transform -> select -> fit -> evaluate -> diagnose -> report -> monitor`
68
+ - 同样输入得到同样输出,便于审计、复盘和团队协作
69
+
70
+ 3. **三种使用方式统一口径**
71
+ - 模块化 API(灵活)
72
+ - 链式 API(高效)
73
+ - Excel 配置驱动(零代码)
74
+ - 三种入口共享同一建模逻辑,减少“口径不一致”
75
+
76
+ 4. **诊断与报告一体化**
77
+ - `diagnose()` 提供 4 层结构化诊断(区分力/过拟合/稳定性/变量质量)
78
+ - 支持阈值自定义(`thresholds=...`)
79
+ - `ReportBuilder` 自动纳入诊断章节,提升评审效率
80
+
81
+ 5. **投产后监控闭环**
82
+ - 支持 PSI、KS 衰减、规则告警、分期追踪
83
+ - 帮助形成“上线—监控—重训”的持续运营机制
48
84
  ---
49
85
 
50
86
  ## 目录
51
87
 
88
+ - [入门教程(Notebook)](#入门教程notebook)
52
89
  - [三种使用方式](#三种使用方式)
53
90
  - [核心功能概览](#核心功能概览)
54
91
  - [安装](#安装)
@@ -57,6 +94,17 @@ Dynamic: license-file
57
94
 
58
95
  ---
59
96
 
97
+ ## 入门教程(Notebook)
98
+
99
+ 推荐按下面顺序阅读,先跑通再深入:
100
+
101
+ | Notebook | 适合谁 | 你会得到什么 |
102
+ |----------|--------|--------------|
103
+ | [**ProScore快速开始**](notebooks/ProScore快速开始.ipynb) | 第一次上手 | 5–10 分钟链式单路径,只看 KS/AUC/PSI、入模变量、诊断摘要 |
104
+ | [**ProScore完整建模流程**](notebooks/ProScore完整建模流程.ipynb) | 准备落地生产 | 模块化 + 链式对照、CFG 参数单一真源、规则挖掘、监控、报告、诊断 |
105
+
106
+ > 快速开始刻意保持精简(不含规则挖掘等可选步骤);完整版是权威样例,含 `[主线]` / `[可选]` 章节导航与一致性断言。
107
+
60
108
  ## 三种使用方式
61
109
 
62
110
  ProScore 提供三种递进的使用方式,从零代码到完全自定义,按需选择。
@@ -106,9 +154,11 @@ p = (
106
154
 
107
155
  > `train` 必传,`test` 和 `oot` 可选。分箱/WOE 只在 train 上拟合;逐步回归用 test 监控过拟合;OOT 仅用于最终评估。
108
156
  >
109
- > 完整教程见 [notebooks/ProScore完整建模流程.ipynb](notebooks/ProScore完整建模流程.ipynb)
157
+ > Notebook 教程见上方 [入门教程](#入门教程notebook)
158
+ >
159
+ > **诊断增强**(v0.2+):`.evaluate().diagnose()` 生成 4 层结构化健康报告(含根因变量),支持 `thresholds=...` 自定义阈值。
110
160
  >
111
- > **诊断增强**(v0.2+):`.evaluate().diagnose()` 生成 4 层结构化健康报告(含根因变量),支持 `thresholds=...` 自定义阈值,适配不同机构/产品风控偏好。
161
+ > **参数单一真源(推荐)**:`CFG` + `PipelineSpec`(`apply(spec)`)确保模块化与链式同参同结果,详见 [pipeline-spec.md](docs/使用指南/pipeline-spec.md)
112
162
 
113
163
  ### C. Excel 配置驱动
114
164
 
@@ -140,6 +190,7 @@ proscore run my_project/pipeline_template.xlsx --output-script run.py
140
190
  |------------|-----------------------------------------------|---------------------------------------|
141
191
  | 数据探查 | IV/AUC/KS 三指标 + PSI 时序稳定性 + 相关性/VIF | 快速筛选优质变量,识别分布漂移风险 |
142
192
  | 分箱 | 4 种单调趋势 + 5 种分箱方法 + 两阶段趋势校验 | 确保 WOE 趋势符合业务逻辑,满足监管 |
193
+ | 规则挖掘 | 单变量/交叉规则 + Lift/Precision/Recall 联合筛选 | 产出可解释策略规则,与评分卡变量互斥 |
143
194
  | 逐步回归 | 双向选择 + 五重约束(p值/符号/VIF/相关/来源) | 严谨的多重共线性控制与维度归属管理 |
144
195
  | 模型监控 | Score/Feature PSI + 规则引擎告警 + JSON 持久化 | 投产后持续验证,自动风险预警 |
145
196
  | 报告生成 | 7 章自动 Markdown 报告(含图表) | 银保监合规文档一键生成 |
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import pandas as pd
6
6
  import pytest
7
7
 
8
- from proscore.inspect import correlation, detect, quality, stability, vif
8
+ from proscore.inspect import correlation, detect, period_bad_rate, quality, stability, vif
9
9
 
10
10
 
11
11
  class TestDetect:
@@ -84,17 +84,16 @@ class TestStability:
84
84
  has_psi_col = "psi" in result.columns or "psi_vs_first" in result.columns
85
85
  assert has_psi_col
86
86
 
87
- def test_has_separate_stability_flags(self, full_df):
87
+ def test_has_psi_flag_only(self, full_df):
88
88
  result = stability(
89
89
  full_df, target="bad_flag", time_col="apply_date",
90
90
  features=["income"],
91
91
  )
92
92
  assert "psi_flag" in result.columns
93
- assert "bad_rate_flag" in result.columns
94
- assert "stability" not in result.columns
93
+ assert "bad_rate_flag" not in result.columns
94
+ assert "bad_rate" not in result.columns
95
95
 
96
- def test_psi_and_bad_rate_flags_independent(self, full_df):
97
- """PSI unstable does not force bad_rate trending, and vice versa."""
96
+ def test_psi_flag_values(self, full_df):
98
97
  result = stability(
99
98
  full_df, target="bad_flag", time_col="apply_date",
100
99
  features=["income", "debt_ratio"],
@@ -102,11 +101,7 @@ class TestStability:
102
101
  non_base = result[result["time_period"] != result["time_period"].min()]
103
102
  if len(non_base) == 0:
104
103
  return
105
- # Columns are evaluated separately — no merged label
106
104
  assert set(non_base["psi_flag"].unique()).issubset({"stable", "unstable"})
107
- assert set(non_base["bad_rate_flag"].unique()).issubset(
108
- {"stable", "trending_up", "trending_down"}
109
- )
110
105
 
111
106
  def test_multiple_periods(self, full_df):
112
107
  result = stability(
@@ -118,6 +113,27 @@ class TestStability:
118
113
  assert result[time_col].nunique() >= n_periods - 1
119
114
 
120
115
 
116
+ class TestPeriodBadRate:
117
+ def test_returns_dataframe(self, full_df):
118
+ result = period_bad_rate(full_df, target="bad_flag", time_col="apply_date")
119
+ assert isinstance(result, pd.DataFrame)
120
+ assert not result.empty
121
+
122
+ def test_has_expected_columns(self, full_df):
123
+ result = period_bad_rate(full_df, target="bad_flag", time_col="apply_date")
124
+ expected = {"time_period", "n", "bad", "bad_rate", "bad_rate_change", "bad_rate_flag"}
125
+ assert expected.issubset(set(result.columns))
126
+
127
+ def test_flag_values(self, full_df):
128
+ result = period_bad_rate(full_df, target="bad_flag", time_col="apply_date")
129
+ non_base = result[result["time_period"] != result["time_period"].min()]
130
+ if len(non_base) == 0:
131
+ return
132
+ assert set(non_base["bad_rate_flag"].unique()).issubset(
133
+ {"stable", "trending_up", "trending_down"}
134
+ )
135
+
136
+
121
137
  class TestVIF:
122
138
  def test_returns_dataframe(self, sample_df):
123
139
  num_cols = ["x1", "x2", "x4"]
@@ -44,9 +44,13 @@ class TestRulesExcel:
44
44
  cfg = PipelineConfig.from_excel(str(template_xlsx))
45
45
  cfg.rules_cfg["method"] = "apriori"
46
46
  cfg.rules_cfg["max_rules"] = 5
47
+ cfg.rules_cfg["min_precision"] = 0.12
48
+ cfg.rules_cfg["min_recall"] = 0.03
47
49
  kw = cfg._build_rules_kw()
48
50
  assert kw["method"] == "apriori"
49
51
  assert kw["max_rules"] == 5
52
+ assert kw["min_precision"] == pytest.approx(0.12)
53
+ assert kw["min_recall"] == pytest.approx(0.03)
50
54
  assert all(not k.startswith("rm_") for k in kw)
51
55
 
52
56
  def test_mine_rules_requires_refine(self, template_xlsx: Path, tmp_path: Path) -> None:
@@ -104,6 +104,36 @@ class TestRuleMiner:
104
104
  with pytest.raises(ValueError):
105
105
  RuleMiner(method="invalid")
106
106
 
107
+ def test_min_precision_filter(self, rule_data, bin_table):
108
+ df, y = rule_data
109
+ baseline = RuleMiner(method="exhaustive", min_lift=1.0, max_rules=100)
110
+ baseline.fit(df, y, bin_table=bin_table)
111
+ assert len(baseline.rules_table_) > 0
112
+
113
+ strict = RuleMiner(
114
+ method="exhaustive",
115
+ min_lift=1.0,
116
+ min_precision=0.95,
117
+ max_rules=100,
118
+ )
119
+ strict.fit(df, y, bin_table=bin_table)
120
+ if len(strict.rules_table_) > 0:
121
+ assert (strict.rules_table_["precision"] >= 0.95).all()
122
+ assert len(strict.rules_table_) <= len(baseline.rules_table_)
123
+
124
+ def test_min_recall_filter(self, rule_data, bin_table):
125
+ df, y = rule_data
126
+ rm = RuleMiner(method="exhaustive", min_lift=1.0, min_recall=0.1, max_rules=100)
127
+ rm.fit(df, y, bin_table=bin_table)
128
+ if len(rm.rules_table_) > 0:
129
+ assert (rm.rules_table_["recall"] >= 0.1).all()
130
+
131
+ def test_invalid_precision_recall_threshold_raises(self):
132
+ with pytest.raises(ValueError, match="min_precision"):
133
+ RuleMiner(min_precision=1.2)
134
+ with pytest.raises(ValueError, match="min_recall"):
135
+ RuleMiner(min_recall=-0.1)
136
+
107
137
  def test_empty_rules_table(self, rule_data, bin_table):
108
138
  df, y = rule_data
109
139
  rm = RuleMiner(method="exhaustive", min_lift=100.0)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes