简介:面向金融风控与数据建模人员的信用风险评分系统完整实现,基于机器学习对信用卡申请历史数据进行清洗、特征工程与建模,输出信用风险等级评估,帮助银行、消费金融等机构高效筛选高风险客户,提升审批效率与自动化水平。资源包为Zip压缩格式,共4个文件,涵盖说明文档、脚本、交互式笔记本及数据文件,压缩包整体约18.19MB。已有149人学习下载。读者可借助交互式笔记本完整复现数据分箱、标签编码、模型训练与评估流程,并结合准确率、召回率、F1值、AUC等指标及混淆矩阵、ROC曲线与KS曲线进行诊断优化,基于toad工具构建评分卡;脚本与数据可直接修改适配自定义场景,附带说明文档便于快速上手,适合具备一定Python基础的建模初学者及业务分析师参考。
1. 信用评分系统为什么要转向机器学习:先想清楚再动手
「基于机器学习信用风险等级评分系统」这个名字听起来很大,但我交付过的机器学习项目最终都收敛到一个朴素事实:离线 AUC 最高的模型不一定能上线,能上线的是「分数解释得清、阈值调得动、出问题找得到原因」的模型。这套系统本质上不是炫技,而是把借款人的逾期概率折算成一个 300 到 900 分的分数和一个风险等级,再交给信审策略决定通过、拒绝还是人工复核。
适合谁?做信贷审批、消费金融、小微企业授信的建模工程师和策略同学。痛点很具体:全靠经验卡额度,客群一变就得重调;只堆复杂模型,又过不了合规解释和客诉核查。所以这里要的不是黑匣子,而是一条能把「为什么给这个分、为什么调到这个等级」讲清楚的决策链路。
开局第一道选择题就来了:主模型用逻辑回归还是梯度提升树?这道题没有标准答案,但会决定后面所有特征工程和分数映射的写法。我先把问题留到第 4 章,从数据口径讲起。
2. 数据口径先于模型:好客户怎么定义、观察期与表现期怎么切
建模翻车的原因里,目标变量定义错误排在第一位。很多人拿到数据后第一件事是跑模型,这是机器学习应用流程里最常见的误区。信用评分项目里,好客户、坏客户、观察期、表现期这四个词如果没对齐,后面所有 AUC 都是自欺欺人。
2.1 好坏客户定义:别拿逾期 90 天一刀切
「坏客户」不是拍脑袋决定的,它直接决定坏样本率和模型的学习目标。常见定义有以下几类:
| 定义方式 | 坏样本率区间 | 适用场景 |
|---|---|---|
| 表现期内最大逾期天数 ≥ 90 天(M3) | 3% - 8% | 消费金融默认口径 |
| 逾期 60 天且逾期金额超过 500 元 | 5% - 10% | 小额短期产品 |
| 核销或进入诉讼流程 | 0.5% - 2% | 大额长期贷款 |
| 表现期内任意一笔逾期 30 天 | 10% - 20% | 现金贷短周期产品 |
关键不是哪个定义「正确」,而是它与你后续的额度损失校准是否一致。如果目标变量定义过松,模型学到的「坏」更多是还款意愿波动,而不是真实违约;定义过严,坏样本太少,模型分数整体偏移。我一般先跑一遍 Vintage(账龄)分析再定口径:把放款月份作为横轴、账龄作为纵轴,看逾期率曲线什么时候进入平台期。表现期至少要覆盖曲线从爬升到平台期的长度,消费贷通常 6 到 12 个月,长期贷款建议 12 到 24 个月。
表现期太短的直接后果,是最近几个放款月份的客户还没走完风险暴露期就被标成了「好客户」,标签噪声变大。这个阶段做扎实,后面「坏样本不足」的问题能缓解一大半。
2.2 观察期、表现期与样本切分:一段可复制的 Pandas 代码
数据切分的常见做法是「观察期 6 个月 + 表现期 12 个月」:观察期用来取特征,表现期用来定标签。下面的代码按放款日期切样本,并生成 0/1 标签。
import pandas as pd loan = pd.read_csv('loan_sample.csv', parse_dates=['loan_date', 'first_overdue_date']) obs_start = pd.Timestamp('2022-01-01') # 观察期起点 obs_end = pd.Timestamp('2022-06-30') # 观察期终点 perf_end = pd.Timestamp('2023-06-30') # 表现期截止 train = loan[(loan['loan_date'] >= obs_start) & (loan['loan_date'] <= obs_end)].copy() # 标签:表现期内最大逾期天数 >= 90 记为坏客户 train['label'] = ((train['first_overdue_date'] <= perf_end) & (train['max_dpd'] >= 90)).astype(int) # observe_window 只用于样本切分和时间外验证,不能进特征 train['observe_window'] = (train['loan_date'] - obs_start).dt.days这段代码有几个容易忽略的点。label的规则必须同时满足「逾期发生日在表现期截止前」和「最大逾期天数达标」,只满足一个都不算坏。observe_window是样本在观察期内的位置,很多人顺手把它当特征,结果模型学到了「放款越晚风险越高」这种时间伪规律,上线后立刻失效。另外,训练集、验证集、测试集要用时间顺序切分,不要用随机切分。随机切分会把同一时期的客户分散到两边,相当于变相使用了未来信息做验证,上线后分数表现大概率缩水。
如果数据历史不够 12 个月,我一般会缩短表现期到 6 个月,同时把坏定义放宽到 M2(60 天逾期),而不是硬凑一个 12 个月表现期。模型可以迭代,标签口径不对才没有后悔药。
2.3 缺失值和极端值:先做「是否缺失」再看分布
信贷数据的缺失率普遍偏高,工作单位、月收入、学历、居住状态经常大面积缺失。我的处理顺序是:缺失率超过 30% 的候选变量先放一边,但「是否缺失」本身保留为新的 0/1 特征——填了和没填,风险差别往往很大。不要用均值填充,信用场景里均值填充会把「缺失信号」抹掉。
极端值处理也尽量不做主观截断。收入为 0 的、月进件次数异常的,直接交给后一阶段的分箱去吸收,但有一个前提:分箱时要把「0 收入」和「低收入」区分开,否则坏率会被平均掉。特征工程做完后,每一箱都要检查坏率是否单调,不单调的箱要回头查业务含义。数据口径这部分是后面 WOE 分箱的基础,下一章开始给可复现的代码。
3. 特征工程:WOE 分箱与 IV 筛选的落地代码和参数
拿到干净样本后,进入特征工程。信用评分领域最经典的做法是 WOE 分箱 + IV 筛选。为什么不把年龄、收入这些原始值直接喂给机器学习模型?这里面的道理值得先说清楚。
3.1 为什么信用评分要先做 WOE 变换
WOE(Weight of Evidence,证据权重)的直观含义,是每个分箱里坏样本分布与好样本分布的比值取对数。WOE 越高,该箱的坏浓度越高。原始年龄、收入与风险的关系经常是非单调的:年龄很小风险高、中年风险低、老年风险又上升。直接用线性模型拟合这种 U 型关系,效果很差;分箱后再做 WOE 变换,变量与 log-odds 之间就近似线性了。
WOE 变换的第二个好处是天然处理缺失值:把缺失单独作为一个分箱,缺失本身的风险由数据说话,而不是拍脑袋填一个值。第三个好处是量纲统一,逻辑回归这类线性模型对输入尺度敏感,WOE 变换后所有变量的取值都在一个可比较的区间里。
树模型(如 XGBoost)理论上可以不依赖 WOE,但我在同一个项目里习惯把候选变量统一算成 WOE,这样逻辑回归和 XGBoost 可以在同一特征空间下横向对比,后面切分数刻度时也只用一套映射逻辑。
3.2 分箱、WOE 与 IV 的计算代码
下面这段 Python 代码可以直接跑通单变量的分箱、WOE 和 IV 计算。
import numpy as np import pandas as pd def compute_woe_iv(x, y, max_bins=10, min_bin_pct=0.05): df = pd.DataFrame({'x': x, 'y': y}).dropna(subset=['y']) # 缺失值单独装箱:统一填充到 -999 df['x'] = df['x'].fillna(-999) # 初始分箱用分位数,duplicates='drop' 避免重复分位点报错 n_bins = min(max_bins, df['x'].nunique()) df['bin'] = pd.qcut(df['x'], n_bins, duplicates='drop') grouped = df.groupby('bin', observed=True)['y'].agg(['sum', 'count']) grouped.columns = ['bad', 'total'] grouped['good'] = grouped['total'] - grouped['bad'] bad_total = grouped['bad'].sum() good_total = grouped['good'].sum() # 坏分布 / 好分布 grouped['bad_dist'] = grouped['bad'] / bad_total grouped['good_dist'] = grouped['good'] / good_total grouped['woe'] = np.log(grouped['bad_dist'] / grouped['good_dist']) grouped['iv'] = (grouped['bad_dist'] - grouped['good_dist']) * grouped['woe'] return grouped.reset_index()代码逻辑分四步:先对缺失值占位,让缺失样本落到独立分箱;再用pd.qcut做分位数分箱;然后按箱聚合好样本数和坏样本数;最后按公式算出 WOE 和 IV。两个参数需要注意:max_bins控制分箱粒度,我一般取 8 到 10,箱数太多会让每箱样本量不足;min_bin_pct是最小箱占比,这段简化代码没有强制执行它,生产环境里我会在分箱后检查每箱数量占比是否低于 5%,低于就合并相邻箱。
这段代码还有一个边界坑:如果某箱的good或bad为 0,bad_dist / good_dist会变成 0 或无穷,np.log直接报错。我处理这类零箱的方式是先把空箱合并到相邻箱再计算,而不是手动加一个极小值平滑——加平滑会改变 WOE 的方向性,后面解释分数时容易翻车。
IV 的计算公式是(坏分布 - 好分布) * WOE求和。单变量 IV 的参考阈值:小于 0.02 基本没有区分能力,直接丢弃;0.02 到 0.1 是弱变量,可以保留备选;0.1 到 0.3 是有效变量;大于 0.5 要警惕,大概率用了未来信息或者分箱过度拟合。
3.3 IV 筛选与相关性去重:留下的变量还得过业务直觉
完成所有候选变量的 WOE 计算后,按以下顺序筛选:
- 剔除 IV 小于 0.02 的变量。
- 计算两两相关系数,绝对值大于 0.7 时保留 IV 更高的那个。比如「月收入」和「年收入」相关性通常超过 0.9,只留 IV 高者。
- 对每个高分箱做业务检查:分箱坏率是否符合直觉,箱内样本量是否足够。
这里最容易犯的错是「唯 IV 论」。我见过一个变量 IV 高达 0.4,分箱后发现 40 岁以上样本的坏率骤增,但该箱只有不到 2% 的样本,完全是噪声拟合出来的。遇到这种情况,哪怕 IV 再高我也先剔除。另一个常见坑是把「落选变量」彻底删掉,其实它只是当前分箱方式下区分能力弱,换一种分箱方式可能就有效了。我会把落选变量存到单独的文件里,下一轮迭代再评估。
做完特征筛选,变量数量一般控制在 15 到 30 个。接下来是建模和分数映射,这是整个机器学习项目里最体现工程细节的部分。
4. 建模与分数映射:把模型输出折算成 300-900 分的完整链路
信用评分的分数不是模型的概率本身,而是概率经过刻度变换后的结果。这一步决定你交付给业务方的到底是「一个概率数字」还是「一套能被理解和使用的评分系统」。
4.1 逻辑回归与 XGBoost:怎么选,怎么配合
逻辑回归的优势是可解释性:每个变量有明确的系数,能直接还原成「年龄扣 5 分、负债比扣 12 分」这样可解释的加减分项。XGBoost 的优势是非线性拟合能力强,AUC 通常更高,但分数解释困难,监管问询和客诉核查时很难交代。
我一般并行跑两套模型:XGBoost 用于特征探索和拒绝样本的排序实验,逻辑回归用于线上主评分卡。如果团队没有强合规压力,且坏样本率极低(低于 2%),可以以 XGBoost 为主模型,但必须配一套概率校准,否则分数刻度会失真。实际选型可以参考下面这张表:
| 业务场景 | 建议主模型 |
|---|---|
| 监管问询多、需要逐项解释扣分 | 逻辑回归 |
| 样本量小于 5 万 | 逻辑回归 |
| 坏样本率极低(小于 2%) | XGBoost + 概率校准 |
| 特征间非线性强、样本量大 | XGBoost 为主,逻辑回归做对照 |
逻辑回归训练时,特征必须是 WOE 变换后的值,而不是原始值。WOE 在逻辑回归里还有一个额外价值:分箱后的 WOE 值天然带方向性,如果某个变量的 WOE 方向和业务直觉相反,在系数表里一眼就能看出来。
4.2 评分映射公式:从 logit 到分数的推导与代码
分数映射的通用的公式是:
score = offset + factor * ln(odds)
这里odds定义为好/坏(good/bad),分数越高风险越低。两个刻度参数:factor是斜率,由 PDO(odds 翻倍需要的分数增量)决定;offset是截距,由基准分和基准 odds 决定。
import numpy as np PDO = 20 # odds 每翻一倍,分数增加 20 分 base_score = 600 # 基准分数 base_odds = 19 # 基准好坏比 good/bad = 19:1 factor = PDO / np.log(2) offset = base_score - factor * np.log(base_odds) def score_from_logit(logit): # logit = ln(坏概率 / 好概率),越大表示风险越高 log_odds_good_bad = -logit return offset + factor * log_odds_good_bad # 逻辑回归:logit = intercept + sum(coef * woe_i) logit_lr = 0.35 print(score_from_logit(logit_lr)) # XGBoost:用 predict_proba 的坏样本概率还原 logit,再套同一公式 p_bad = 0.3 logit_xgb = np.log(p_bad / (1 - p_bad)) print(score_from_logit(logit_xgb))注意这里有一个符号约定:代码里的logit是坏概率的对数几率,数值越大风险越高,所以score_from_logit内部取负,把好/坏的对数几率还原出来。逻辑回归的decision_function返回的就是 logit,系数与 WOE 相乘再求和后套用这个函数即可。XGBoost 则要先predict_proba拿到坏样本概率,再转成 logit。
参数怎么设取决于业务敏感度。PDO 取 20 是行业常见值,表示风险每翻一倍分数变化 20 分。想让分数对风险更敏感,PDO 可以取到 30;但我不建议低于 15,否则分数跳动太频繁,客服和信审每天都要处理大量「为什么上个月 720 这个月 680」的疑问。基准分 600、基准 odds 19:1 是常见起点,如果目标客群的基础逾期率就是 5%,基准 odds 可以调到 19 对应的水平,让分数分布落在 300 到 900 之间。
4.3 风险等级阈值切分:把分数切到 A/B/C/D
分数映射完成后,还要切成等级。示例决策矩阵如下:
| 等级 | 分数区间 | 建议策略 |
|---|---|---|
| A | ≥ 720 | 自动通过,标准额度 |
| B | 650 - 719 | 通过,额度打折或降额 |
| C | 580 - 649 | 转人工复核或补充资产证明 |
| D | < 580 | 拒绝 |
阈值不是拍出来的,而是根据通过率目标和坏账容忍度倒推。上线初期目标通过率 60%,就把阈值放到通过率刚好 60% 的位置,再看该位置对应的坏账率是否在容忍范围内。具体做法是:先在验证集上画分数分布直方图和 KS 曲线,找区分度最高的分数段,再按通过率目标左右平移阈值。
提示:上线首月阈值宁可保守一点,多拒绝几个边界客户,也不要让坏账率冲高。评分卡上线后有的是机会调阈值,但第一波坏账表现会直接影响业务方对整个机器学习项目的信心。
5. 避坑:信用评分项目里最常见的 4 个翻车现场
信用评分项目的坑和普通机器学习项目不太一样,很多问题不在算法层面,而在「时间」和「样本」层面。下面 4 个都是我在实际项目中踩过或复盘过的,每条按现象、原因、解决三步写。
5.1 样本不均衡让分数整体偏移
现象:坏样本占比低于 2% 时,模型输出的 logit 整体偏小,分数普遍偏高;按固定阈值切分,通过率虚高,坏账率超预期。
原因:逻辑回归对正负样本比例敏感,大量好样本把截距拉低,导致所有客户的 logit 都被压缩,分数分布整体上移。排序能力受影响较小,所以只看 AUC 看不出问题。
解决:训练时给坏样本加权重,class_weight='balanced'或手动设置权重比,然后用验证集上真实的坏样本比例重新校准阈值。不要直接调阈值去「校正」分数偏移,那样只是掩盖了模型问题。用 SMOTE 等合成样本要格外慎重,合成的坏样本可能与真实坏账模式不符,反而污染 WOE 分箱。
5.2 时间穿越:特征里混进了表现期的信息
现象:离线 AUC 高达 0.85,上线后 KS 掉到 0.2 以下,业务方直接质疑模型质量。
原因:最常见的是分箱时用了全量样本,或者特征里混进了「放款日后才发生」的变量,比如催收次数、放款后的还款记录、以及 2.2 节提到的observe_window。这类未来信息在离线验证里表现极好,上线后完全不成立。
解决:观察期特征只取放款日之前发生的数据;分箱边界只 fit 训练集,验证集和测试集沿用训练集的边界,不能重新分箱。上线前对每个特征做时点可用性审查,列出每个变量的数据采集时间字段,无法确认采集时点的变量一律不进模型。
5.3 拒绝推断没做,模型对拒绝人群是黑的
现象:模型在通过人群上表现不错,但在拒绝边界附近的客群上分层能力差,被拒绝的人如果当初被通过,实际违约率可能远高于模型预估。
原因:好/坏标签只在被通过的人身上观测得到,被拒绝的样本没有表现期,模型学不到这部分人群的真实风险规律。这是信贷场景特有的样本选择偏差。
解决:常见做法是两阶段模型或软标签法。先用一个宽松模型(或现有高分卡)对历史拒绝样本打伪标签,再并入训练集;资源不够时,至少做一轮拒绝样本回捞实验,把原本拒绝但被人工捞回的样本纳入观察,才能估计偏差方向。跳过这一步的项目,上线后通常会在阈值附近集中翻车。
5.4 分数上线后表现大幅下跌
现象:上线首月 KS 比离线验证低 40% 以上,分数分布整体偏移。
原因:训练样本只取了最近 3 个月,或客群发生了结构性变化,比如进件渠道换了一批、合作方导流人群变了。这是「柜台前后两套样」的典型表现。
解决:训练样本至少要覆盖 12 到 18 个月,并且保留最后 3 个月做时间外验证(OOT),不能用随机划分代替。上线后按周监控分数分布和 PSI(群体偏移指数),PSI 超过 0.1 就要开始查客群变化,超过 0.25 强烈建议重训。注意区分短期波动和模型失效:先看样本量,再看偏移方向,不要因为一两周的数据波动就急着重训。
6. 上线前的最后一公里:分桶回测、决策矩阵复核与配置版本化
这一章讲三个验证技巧,都能直接用在项目收尾阶段,也是我每次交付前必做的三步。
6.1 分桶坏账率回测
把验证集分数按分位数切成 20 桶,从高到低逐一检查每桶的真实坏账率。一个可靠的评分系统,坏账率应该随分数升高单调下降。
def bucket_check(score, y, n_buckets=20): df = pd.DataFrame({'score': score, 'y': y}) df['bucket'] = pd.qcut(df['score'], n_buckets, duplicates='drop') table = df.groupby('bucket', observed=True)['y'].agg(['mean', 'count']) table['bad_rate'] = table['mean'] return table.sort_index(ascending=False)如果最高分桶的坏账率反而上升,说明分数在高端区间失真,优先检查 WOE 方向是否写反,或者特征里混入了过拟合变量。
6.2 决策矩阵人工复核
自动化的指标再漂亮,也要过一遍人工。我会把 A/B 等级边界和 C/D 等级边界上下 10 分内的样本各抽 100 个,让策略同事不看分数、只看原始资料,先给出「你认为该不该拒」,再和模型等级对拍。不一致率超过 5%,说明某个变量的业务含义和模型学到的方向有偏差,需要补变量或重新审视分箱。
6.3 把变量字典、分箱边界、系数、阈值、等级表统一存成配置
这是我被坑过一次之后养成的习惯。曾有一次回测用的分箱边界和线上配置不一致,导致分数对不上,排查了三天,最后发现是发布时配置文件版本落后了一版。后来所有评分卡在发布时都带同一个版本配置文件,线上评分和线下回测读同一份 JSON:
{ "version": "2024-03-01", "model": "lr_woe_v7", "params": {"offset": 515.0, "factor": 28.85}, "bins": {"age": [25, 35, 45], "income": [3000, 8000, 20000]}, "grades": {"A": 720, "B": 650, "C": 580} }每次迭代只改版本号,旧版本保留可回溯。这个习惯帮我省掉了大量「线上分数和线下对不上」的排查时间,也希望帮你在上线前少走一段弯路。
本文还有配套的精品资源,点击获取