ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

个人信用评估与机器学习:从WOE分箱到评分卡开发的完整实践指南

个人信用评估与机器学习:从WOE分箱到评分卡开发的完整实践指南

简介:面向机器学习初学者和课程设计学生,这套个人信用评估Python实现方案以阿里天池贷款违约预测赛题为背景,完整覆盖数据清洗、特征编码、降维、网格搜索调参与模型评估等环节,适合作为课程设计或入门数据挖掘的实战参考。资源包共82个文件,压缩后约5.16MB,核心包含13个Python脚本、62张PNG可视化分析图、2个CSV数据文件、1份Word设计报告及说明文档,其中脚本按模块组织,可视化图则覆盖变量分布、相关性热力图、主成分分析等结果,设计报告详细记录实验步骤与结论。目前已有741人学习。通过这套资料,读者既可复现贷款违约预测全流程,也能学习到信用评分场景中特征工程与模型调优的通用方法,代码结构清晰,便于迁移到类似风险预测任务中复用。

1. 个人信用评估:机器学习能替人工规则省下多少事

个人信用评估这个场景,几乎每个做过信贷风控的人都不陌生:申请人的年龄、收入、负债、历史还款记录摆在那里,让审批员凭经验打分,既慢又不一致。机器学习解决的就是这件事——用历史样本训练一个模型,把“会不会违约”转成一个可解释的概率或分数。刚入门的朋友拿它当练习项目非常合适,很多机器学习课程设计选题也会落在信用评估上;而真正在银行、消费金融公司做风控的,日常干的也是同一套流程。反直觉的一点是:这个任务里,模型算法不是门槛,数据样本定义、特征处理才是决定模型能不能用的关键。

2. 信用评估的数据从哪来:公开数据集与清洗流程

2.1 个人信用评估常用数据集:German Credit 与 Give Me Some Credit

做个人信用评估,第一步不是选模型,而是先解决数据。常见做法是先用公开数据集把流程跑通,再迁移到自己的业务数据上。

入门最常用的是 UCI 的 German Credit 数据集,1000 条样本、20 个变量,标签是好客户或坏客户。样本量小,适合拿来理解特征工程和评分卡流程,几分钟就能跑完一轮实验。另一个更接近真实业务的是 Kaggle 上的 Give Me Some Credit(GMSC),约 15 万条样本,12 个特征,标签字段是借款人未来两年内是否出现财务困境。这个数据集样本量够大,能真实反映出“坏样本比例很低”带来的问题,我一般建议用这个做完整流程演练。

拿到数据后先别急着建模。个人信用评估的样本单位是“人”而不是“行为”,所以一个客户只能对应一条记录;如果同一个客户有多条借贷记录,必须先在客户维度做聚合,否则训练集和测试集之间会泄露信息。这个步骤在公开数据集里已经被处理好了,但真实业务里极其常见。

2.2 用 pandas 把原始表洗干净:缺失值、异常值和标签处理

机器学习中的数据处理是什么?说白了就是让原始表变成能直接喂给监督学习算法的干净 DataFrame。信用数据里最常见的四个问题:缺失值、异常值、重复值、标签比例失衡。

GMSC 里 MonthlyIncome 字段缺失率大约 20%,DebtRatio 偶尔会出现几十甚至上百的极端值。下面是我惯用的清洗流程:

import pandas as pd import numpy as np # 读入原始数据,先看结构和缺失情况 df = pd.read_csv('cs-training.csv') print(df.info()) # MonthlyIncome 缺失率不低,初版用中位数填充 # 不用 mean 是因为收入分布右偏,个别高收入会把均值拉得很高 df.loc[:, 'MonthlyIncome'] = df['MonthlyIncome'].fillna( df['MonthlyIncome'].median() ) # 如果有全空行,直接丢弃 df = df.dropna(subset=['NumberOfDependents']) # 年龄小于等于 0 属于脏数据,用中位数替换而不是删除 df.loc[df['age'] <= 0, 'age'] = df['age'].median() # DebtRatio 超过 1 说明月负债超过月收入,现实中基本是录入错误 df = df[df['DebtRatio'] < 1] # 检查标签分布,这决定了后面所有参数怎么调 print(df['SeriousDlqin2yrs'].value_counts(normalize=True))

这里几个参数值得说清楚。fillna 用中位数而不是均值,是因为收入分布长尾严重,均值会被少数高收入样本带偏,中位数更稳健。DebtRatio 截断到 1 是业务常识:负债超过收入的人可能仍会被审批通过,但比率几十倍只有脏数据才做得出来。最后一行的 value_counts(normalize=True) 是必做动作,如果坏样本占比低于 10%,后面模型训练就必须处理类别不平衡,否则精度再高也没有意义。

提示:公开数据集清洗固然重要,但真实信贷数据里还有“观察期”和“表现期”的时间窗口问题,建议一开始就在工程文档里把这两个窗口定义清楚。

3. WOE 分箱与 IV 筛选:信用评估特征工程的核心步骤

3.1 为什么信用评分不用原始数值直接进模型

很多从普通机器学习入门的朋友会问:年龄、收入这些特征直接标准化后扔进逻辑回归不就行了吗?在信用评估里还真不行。

原因有两个。第一,年龄和违约概率不是线性关系——20 岁和 30 岁可能差异很大,但 40 岁和 50 岁差异很小,让原始数值充当线性输入,等于强行假设每增加一岁风险变化相同,这个假设在业务上站不住脚。第二,逻辑回归本身要求输入与输出呈线性关系,而信用评估中连续变量与违约概率通常呈“U 型”或“阶梯型”关系。机器学习线性回归实验里那套“直接标准化 + 回归”的做法,在信用评估里会带来解释性和稳定性双重问题。

行业常见解法是分箱 + WOE 编码。分箱就是把连续变量切成几段,WOE(Weight of Evidence,证据权重)则把每一段转换成“这一箱里好客户占比与坏客户占比之比的对数”。经过转换后,输入变量与违约概率的对数几率的线性关系才能成立,逻辑回归系数也才可解释。

3.2 等频分箱与 WOE 编码:可复现的 Python 实现

分箱方式有很多种,最常见的是等频分箱,也就是让每一箱的样本量大致相等。代码实现比较直接:

import pandas as pd import numpy as np def woe_bin(df, feat, target, bins=5): # 等频分箱:qcut 遇到重复分位点会报错,duplicates='drop' 是必带参数 df_cp = df.copy() df_cp['bin'] = pd.qcut(df_cp[feat], q=bins, duplicates='drop') grouped = df_cp.groupby('bin')[target].agg(['mean', 'sum', 'count']) grouped['good'] = grouped['count'] - grouped['sum'] # sum 是坏样本数 bad_total = grouped['sum'].sum() good_total = grouped['good'].sum() # WOE 公式:ln(好样本占比 / 坏样本占比) grouped['woe'] = np.log( (grouped['good'] / good_total) / (grouped['sum'] / bad_total) ) # IV 是每个箱的贡献累加,用于衡量变量区分度 grouped['iv'] = ( (grouped['good'] / good_total - grouped['sum'] / bad_total) * grouped['woe'] ) return grouped result = woe_bin(train, 'age', 'SeriousDlqin2yrs', bins=8) print(result)

这段代码的核心逻辑是:groupby 掉每个分箱后,用 sum 数出坏样本数,count 减 sum 得到好样本数,然后按 WOE 公式逐箱计算。注意 qcut 在数据存在大量重复值时会报“Bin edges must be unique”之类的错误,duplicates='drop' 会自动合并重复分位点,代价是实际箱数可能会小于设置的 bins,这属于可接受的行为。

bins 参数在信用评估里有讲究。我一般初版设置为 5~10 箱:太少会丢失梯度信息,太多则每一箱样本量过小,WOE 值被噪声主导。一个经验标准是每箱至少要有 30 个坏样本,如果做不到就降低箱数或合并相邻箱。

等频分箱是最容易上手的方案,但未必是最优的。卡方分箱会在建模时自动合并类别分布相似的相邻箱,生成的 WOE 更平滑,但实现复杂度也更高。建议先把等频分箱跑通,再看结果决定要不要升级。

3.3 IV 值筛选特征:留下真正有区分度的变量

WOE 算完之后,紧接着就是 IV(Information Value,信息价值)筛选。IV 本质上是 WOE 的加权求和,衡量一个变量对好坏的区分能力。

行业里通行的判断标准是:

IV 区间预测能力处理建议
< 0.02几乎无预测力直接剔除
0.02 ~ 0.1较弱可保留或结合业务判断
0.1 ~ 0.3中等优先保留
> 0.3较强保留,但需排查是否泄漏

这里有一个容易被忽略的坑:IV 超过 0.5 的变量要格外警惕,它可能泄露了标签信息。比如“当前逾期次数”在一个“未来两年是否违约”的模型里,IV 一定高得离谱,但它在业务上属于“过去状态”还是“未来结果”,取决于你定义观察期的方式。所以 IV 筛选只做排名,真正的取舍必须由业务人员确认变量含义。

特征筛选完成后,把所有变量的 WOE 值按样本拼成一张新表,原始数值就不再进模型了。这一步做完,才到模型训练环节。

4. 逻辑回归训练与评分卡转换:把概率翻译成分数

4.1 为什么信用评估场景首选逻辑回归而不是随机森林

很多刚入门的人会问:随机森林、XGBoost 的 AUC 明显更高,为什么信用评估还在用逻辑回归?这个问题的答案不在精度,而在“可解释性”和“可落地性”。

信贷审批涉及监管合规和客户申诉。客户被拒贷时,金融机构必须能解释为什么拒,逻辑回归的系数可以直白地告诉业务人员“年龄每增加一箱,分数变化多少”。而随机森林的决策路径极其复杂,连开发者自己都说不清单个样本为什么被分到坏客户那一类。另外,逻辑回归天然输出概率,配合评分卡转换后,分数可以沿用银行已经运行多年的阈值体系,替换成本低。

这并不意味着机器学习算法层面只能选逻辑回归。实际项目中常见的做法是:逻辑回归做底稿和评分卡,XGBoost 做对照模型验证变量的重要性和稳定性。如果 XGBoost 里某个变量的重要性极高但逻辑回归里系数不显著,说明这个变量存在非线性关系,需要回头检查分箱是否合理。

4.2 训练逻辑回归:类别不平衡与正则化参数怎么设

用 WOE 矩阵替代原始特征后,训练代码就很常规了:

from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score # 用 WOE 矩阵替代原始特征进模型 features = ['age_woe', 'income_woe', 'debt_ratio_woe'] X = train_woe[features] y = train_woe['SeriousDlqin2yrs'] # stratify=y 保证训练集和测试集的坏样本比例一致 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) model = LogisticRegression( C=0.1, # 正则化强度,越小惩罚越重 penalty='l2', # L2 让系数分布更平滑 class_weight='balanced', # 自动按类别比例反向加权 solver='liblinear', max_iter=500 ) model.fit(X_train, y_train) train_auc = roc_auc_score(y_train, model.predict_proba(X_train)[:, 1]) test_auc = roc_auc_score(y_test, model.predict_proba(X_test)[:, 1]) print('训练集 AUC:', train_auc) print('测试集 AUC:', test_auc)

C 值在信用评估里是一个需要反复试的参数。老版本 scikit-learn 默认 C=1.0,在几千条样本上经常过拟合,训练集 AUC 比测试集高出 0.05 以上。我一般从 C=0.05 开始试,到 0.2 之间画一条 AUC 曲线,选择测试集 AUC 最高且与训练集差距最小的点。

class_weight='balanced' 是最快的缓解坏样本过少的做法,但注意它改变的是损失函数里的样本权重,对系数绝对值也会有影响。如果后续要转评分卡,业务方可能会对“某个变量的系数为什么这么大”有疑问,那时候需要回到分箱环节调整。换用 SMOTE 过采样或对好样本下采样也可以,但初版用 class_weight 最省事。solvers 选 liblinear 是因为小样本二分类场景它收敛最稳,lbfgs 在高维稀疏矩阵上更快,但这里样本量不大,差别不明显。

4.3 从概率到评分卡:offset 与 factor 的换算

模型训练完成后,业务方真正要的不是违约概率,而是一个 300~900 分之间的整数分数。评分卡转换公式业界是通用的:

Score = offset - factor * ln(odds)

其中 odds = p / (1-p),p 是模型输出的违约概率。factor 和 offset 由两个业务参数决定:基准分对应的 odds,以及分数每升高多少分 odds 翻倍。

import numpy as np # 业务设定:600 分对应 odds = 1/10(坏好比 1:10) # 分数每升高 20 分,odds 翻倍(即客户变好的可能性翻倍) factor = 20 / np.log(2) offset = 600 - factor * np.log(1/10) def prob_to_score(p): odds = p / (1 - p) score = offset - factor * np.log(odds) return round(score) test_score = [prob_to_score(p) for p in model.predict_proba(X_test)[:, 1]]

这段换算本身不难,真正的难点在业务参数怎么定。基准分对应的 odds 和翻倍分值是风控委员会和业务方共同拍板的,不是建模的人自己定的。常见取值范围是:基准分 500~700,odds 在 1:5 到 1:20 之间,翻倍分值在 15~30 分之间。换一组参数,分数的绝对含义就变了,所以评分卡转换的第一步永远是先和业务对齐这两个参数。

转换完成后,正常的流程是对每个 WOE 变量计算其对应分数贡献,形成一张“评分卡”表格。这张表才是真正给业务审批系统用的东西:每个分箱区间对应多少分,加总后加基准分,就是最终信用评分。

5. 个人信用评估的 5 个典型踩坑:现象、原因与解决

5.1 坏样本比例太低,模型精度虚高

现象是 Accuracy 高达 96%,但 KS 和 AUC 都很低,模型完全没有区分能力。原因很简单:坏样本占比通常只有 3%~5%,模型只要全预测成好客户,精度就已经 95% 以上,但一个只会说“都通过”的模型在风控里毫无价值。

解决方法是把评估指标从 Accuracy 换成 AUC、KS 和 Recall。训练时用 class_weight='balanced' 或者对训练集做下采样,让好坏样本比例别太悬殊。最后选阈值时不要默认 0.5,而是看 KS 最大点或者业务能接受的召回率/误杀率来定阈值。这点在机器学习模型评估那一套流程里属于最基础但最容易犯的错。

注意:只看 Accuracy 判断模型好坏,在信贷场景里基本等于自欺欺人。

5.2 特征泄漏:把“未来”和“当前”混进训练集

现象是测试集 AUC 高得惊人(超过 0.85),还以为是模型效果好,结果上线后立刻翻车。原因通常是特征里面混进了“未来才知道的信息”。比如用客户“当前逾期次数”去预测“未来两年是否违约”,如果观察期定义不严格,这个变量就包含了结果本身的信息。更隐蔽的是在特征构造时用了全量数据的统计量,比如把整个数据集上客户均收入作为特征。

解决方法:所有特征必须在观察期结束时点就已确定。特征生成代码要在时间戳层面做校验,数据集划分按时间切分而不是随机切分。WOE 分箱边界只能在训练集上计算,测试集和上线后的新样本只能对已经生成的箱子做映射,不能重新分箱。

5.3 坏样本定义不统一,模型目标漂移

现象是同一个数据集,两个人各建一个模型,KS 差一大截,而且谁都说服不了谁。原因是坏样本标签的定义不统一。有人把逾期 30 天当成坏客户(M1),有人坚持逾期 90 天以上才算坏(M3),还有人把“历史曾经核销”当坏客户。标签定义不同,模型学习的目标就已经不同。

这个坑的解决方式靠流程规范:项目启动前写一份样本定义文档,明确观察期多长、表现期多长、逾期到第几天记为坏样本、逾期不足但多次发生的怎么算。整个机器学习应用流程里,这一步几乎决定了后面所有工作的价值,文档写清楚之后,后面的人拿到数据不需要再猜。

5.4 分箱后 WOE 不单调,逻辑回归系数不稳定

现象是模型系数符号和业务直觉相反,比如收入越高违约概率越高。原因很可能是分箱不合理,某几个箱子坏样本占比波动大,WOE 值上下跳动,逻辑回归为了拟合这种波动把系数搞偏了。等频分箱在样本量不足时尤其容易出现这种问题。

解决方法是合并相邻箱。WOE 值不单调时,把相邻的箱合并到一起,让每个箱的坏占比稳定下来。如果合并后还是不单调,那就换一个分箱方法,比如卡方分箱,或者考虑这个变量本身就不适合进入模型。变量筛选的目的是留下方向一致、解释合理的输入,不是为了凑 AUC。

5.5 模型上线后 KS 快速衰减,变量分布漂移

现象是回测表现很好的模型,上线一个月后 KS 掉到原来的三分之一。原因大多不是模型训练出了问题,而是客群结构变了。比如业务主推了新客群,年龄和收入分布整体迁移;或者市场环境变化导致某些行业客户收入骤降。机器学习模型只能学到训练集分布下的规律,分布一变,原模型就失效。

解决方法是上线前先设好监控体系:每个变量的 PSI 按月计算,超过 0.1 预警,超过 0.25 就要重新训练模型;评分整体分布同样按 PSI 监控。这里需要提醒的是,PSI 的分箱边界必须用训练集的分位点固定下来,不能拿当期数据重新切箱,否则漂移永远测不出来。

6. 上线前的验证:KS、PSI 与滚动回测的落地做法

模型训练完了,评分卡也转了,真正考验人的是上线前的验证。信用评估不像别的机器学习项目,随便拿一组留出集跑个 AUC 就能上线,它必须回答两个问题:模型在时间维度上稳不稳定,上线后分布漂移了能不能及时发现。

先说滚动回测。把历史数据按月份切成多段,比如用第 1~12 个月的样本训练,第 13~18 个月验证;再把训练窗口向后推 6 个月,第 7~18 个月训练,第 19~24 个月验证。这样做能看到 KS 在时间维度上的波动。如果每个窗口的 KS 都接近,模型才是真的稳;如果第一个窗口 KS 0.35,第二个窗口掉到 0.2,说明样本定义或特征里混了一些只在特定时间段成立的信号。很多入门项目只做一次随机切分,KS 很好看,一上时间序列验证就露馅。

再说 PSI 的计算。一个可复现的 PSI 函数如下:

import numpy as np import pandas as pd def psi(score_train, score_now, bins=10): # 以训练集分位点作为固定边界,不能用当期数据重新分箱 cuts = pd.qcut(score_train, q=bins, retbins=True)[1] train_pct = np.histogram(score_train, bins=cuts)[0] / len(score_train) now_pct = np.histogram(score_now, bins=cuts)[0] / len(score_now) # 避免分母为 0,加上极小值 now_pct = np.where(now_pct == 0, 1e-6, now_pct) return np.sum((now_pct - train_pct) * np.log(now_pct / train_pct))

这段代码里最关键的就是分箱边界固定逻辑。train_pct 和 now_pct 用的是同一组 cuts,如果每期都用当期数据重新算分位数,分布漂移就被“动态归一化”掩盖了,PSI 永远显示稳定。PSI 的判断标准是:小于 0.1 视为稳定,0.1~0.25 需要监测并排查原因,大于 0.25 建议触发模型重训。

我自己的习惯是模型上线前先制定一个“监控三件套”:评分分布的 PSI、每个特征变量的 PSI、按月滚动的 KS 报表。前两项可以用上面这个函数每天跑批,第三项每月重算一次。上线后前三个月是重点观察窗口,评分分布出现整体右移或左移,第一时间找业务要当期客群画像做对照。

以前我做信用评估模型,把 AUC 刷到 0.82 就觉得项目完工了,结果上线一个月 PSI 直接跳到 0.3,评分分布整体右移,才发现是业务在那个月集中推了一批高收入低负债的客群。后来养成的习惯是:不管模型多简单,先写样本定义文档,再做时间切分验证,最后把 PSI 监控挂上。这三件事做完,模型上线才不会提心吊胆。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进