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.
- {proscore-0.2.1/src/proscore.egg-info → proscore-0.2.2}/PKG-INFO +54 -3
- {proscore-0.2.1 → proscore-0.2.2}/README.md +53 -2
- {proscore-0.2.1 → proscore-0.2.2}/pyproject.toml +1 -1
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/__init__.py +1 -1
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/_pipeline_config.py +10 -3
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/evaluate/_diagnose.py +2 -2
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/inspect/__init__.py +2 -2
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/inspect/_stability.py +87 -45
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/rules/_miner.py +16 -0
- {proscore-0.2.1 → proscore-0.2.2/src/proscore.egg-info}/PKG-INFO +54 -3
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_inspect.py +26 -10
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_pipeline_rules.py +4 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_rules.py +30 -0
- {proscore-0.2.1 → proscore-0.2.2}/LICENSE +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/setup.cfg +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/__main__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/_data/__init__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/_spec.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/__init__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_adjust.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_base.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_binning.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_categorical.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_chi.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_distance.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_frequency.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_tree.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/binning/_woe.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/evaluate/__init__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/evaluate/_metrics.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/inspect/_correlation.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/inspect/_detect.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/inspect/_quality.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/modeling/__init__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/modeling/_scorecard.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/monitor/__init__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/monitor/_monitor.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/report/__init__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/report/_builder.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/rules/__init__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/selection/__init__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/selection/_filter.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/selection/_screen.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/selection/_stepwise.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/transform/__init__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/transform/_woe.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/utils/__init__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/utils/_config.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/utils/_exceptions.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/utils/_presets.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/utils/_psi.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/viz/__init__.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore/viz/_plots.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore.egg-info/SOURCES.txt +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore.egg-info/dependency_links.txt +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore.egg-info/entry_points.txt +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore.egg-info/requires.txt +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/src/proscore.egg-info/top_level.txt +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_binning.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_diagnose.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_docs_examples.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_evaluate.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_evaluate_period.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_filter.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_pipeline.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_presets.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_report.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_scorecard.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_screen.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_spec.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_stepwise.py +0 -0
- {proscore-0.2.1 → proscore-0.2.2}/tests/test_transform.py +0 -0
- {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.
|
|
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
|
-
>
|
|
157
|
+
> Notebook 教程见上方 [入门教程](#入门教程notebook)。
|
|
158
|
+
>
|
|
159
|
+
> **诊断增强**(v0.2+):`.evaluate().diagnose()` 生成 4 层结构化健康报告(含根因变量),支持 `thresholds=...` 自定义阈值。
|
|
110
160
|
>
|
|
111
|
-
>
|
|
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
|
-
>
|
|
119
|
+
> Notebook 教程见上方 [入门教程](#入门教程notebook)。
|
|
120
|
+
>
|
|
121
|
+
> **诊断增强**(v0.2+):`.evaluate().diagnose()` 生成 4 层结构化健康报告(含根因变量),支持 `thresholds=...` 自定义阈值。
|
|
72
122
|
>
|
|
73
|
-
>
|
|
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 报告(含图表) | 银保监合规文档一键生成 |
|
|
@@ -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", "
|
|
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
|
-
"
|
|
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",
|
|
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 "
|
|
385
|
-
unstable = stability[stability["
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 |
|
|
59
|
-
|
|
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(
|
|
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 = "
|
|
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: ``"
|
|
167
|
-
``"
|
|
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
|
|
173
|
-
``latest_psi_flag``
|
|
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.
|
|
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
|
-
>
|
|
157
|
+
> Notebook 教程见上方 [入门教程](#入门教程notebook)。
|
|
158
|
+
>
|
|
159
|
+
> **诊断增强**(v0.2+):`.evaluate().diagnose()` 生成 4 层结构化健康报告(含根因变量),支持 `thresholds=...` 自定义阈值。
|
|
110
160
|
>
|
|
111
|
-
>
|
|
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
|
|
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 "
|
|
93
|
+
assert "bad_rate_flag" not in result.columns
|
|
94
|
+
assert "bad_rate" not in result.columns
|
|
95
95
|
|
|
96
|
-
def
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|