简介:围绕 Python 语言与 toad 库实现信用评分卡构建的完整示例资源,面向金融风控、数据分析及智能风控方向的开发学习者,可解决从特征处理到评分卡落地的全流程实操需求。资源压缩包共 2 个文件,一个 Python 脚本承载完整示例代码,覆盖特征选择、分箱、WOE 转换、模型评估与评分卡生成等关键环节;一个 txt 文档作为配套说明,便于理解代码逻辑与运行结果。压缩包整体大小 5.8MB,轻量易用,适合本地快速运行与调试。该资源目前已有 273 人学习下载,适用于希望快速上手评分卡建模的初学者与进阶开发者。通过上手这份示例,读者可以系统掌握 toad 库处理信用评分问题的常用方法,了解每个分箱变量分数计算与最终评分卡生成的思路,同时熟悉特征选择、分箱、WOE 转换等核心概念的实际应用,为个人或企业信用风险评估模型的构建提供直接可复用的代码模板。
1. 把评分卡从手工流程搬到 toad:先看它帮你省下什么
评分卡在信贷风控里的位置很特殊:它不追求最好的分类精度,而要一份人人能看懂、评审敢签字、上线能解释的分数产品。手工做评分卡,特征筛选、分箱、WOE、逻辑回归、分数换算各干各的,最烦的是分箱边界和分数映射来回同步,一步大意就对不上。toad 是专门把这条链路串起来的 Python 库,最大价值在于:只要数据口径和标签正确,从原始表到可导出评分卡能在几十行代码里完成。
下面会用一份可复现的数据集完整演示 toad 实现评分卡,再讲清楚参数怎么调、哪些坑我实际踩过。适合正在做信贷风控建模、审批策略和反欺诈评分的读者;新手跟着跑一遍,也能理解评分卡流程每个环节在干什么。
2. toad 生成评分卡的核心:分箱、WOE、IV 与流程设计
2.1 评分卡为什么必须基于 WOE,而不是直接使用原始变量
评分卡的输出是一张给业务同事看的分数表,不是黑匣子。直接拿年龄、收入、负债比这些原始数值丢给逻辑回归,得到一个概率值,再用概率换算分数,这听起来最省事,实际却很难落地。原因是原始变量对坏样本的影响往往不线性,年龄在 25 岁和 45 岁处风险不同,负债率在 0.3 和 0.6 处的边际效应也不同,硬塞进回归模型会让系数不稳,线上分数稍有波动,业务人员也无法解释为什么涨了 3 分。
所以业内通用的做法是先分箱,再用每个箱子里的坏样本占比来构造一个叫 WOE 的指标。WOE 全称是 Weight of Evidence,公式写作 ln(坏样本分布比例 / 好样本分布比例)。好样本和坏样本在每个箱子里占比差异越大,WOE 的绝对值就越大,说明这个箱子对区分好坏样本的贡献越强。变量进入模型之前换成它所在箱子的 WOE 值,模型面对的就是一组经过平滑、尺度统一的编码,而不是容易出尖峰的原始字段。
分箱还有个隐藏价值:它能吸收异常值。收入这个字段经常有 0、缺工、极端值,直接建模时这些点会把回归系数拉偏;分箱以后,极端值只会落进最边缘的箱,风险和分数影响被限制住。评分卡每一步都要求可解释,分箱天然就是可解释的加工方式,这也是 WOE 方法在信贷风控里熬了几十年还没被替代的原因。
IV 则是衡量一个变量整体区分能力的指标,由每个箱子的 WOE 加权求和得到。IV 低不代表一定没用,但 IV 太低大概率意味着噪声太多,放进模型只会增加过拟合风险。toad 在做特征初筛时默认按 IV 排序并过滤,背后用的就是这套逻辑。
2.2 toad 的模块化设计:从清洗到评分卡的六个环节
toad 把评分卡常用步骤拆成六个区域,每个区域对应一类方法,代码风格接近 sklearn,学习成本不算高。
| 环节 | toad 对应模块 | 典型方法 | 输出 |
|---|---|---|---|
| 质量检查 | toad.detect | detect | 变量缺失率、IV、分布报告 |
| 清洗 | toad.clean | clean | 填充 NaN 和无穷值的表 |
| 特征筛选 | toad.selection | select | 初筛后特征集和淘汰清单 |
| 分箱与 WOE | toad.transform | Combiner, WOETransformer | 分箱边界和 WOE 转换后数据 |
| 指标评估 | toad.metrics | KS, AUC, PSI | 区分度和稳定性指标 |
| 评分卡生成 | toad.ScoreCard | fit, export | 可直接导出的评分卡表 |
我一般会优先用 toad.detect 先看字段体检,再决定要不要 clean。很多人拿到数据直接建模,跑完才发现一半特征超过八成缺失,这等于把模型建立在一堆噪声上。toad 的 select 会把空值率、IV、相关性三道门槛一次性设好,省掉很多重复代码。真正和手写流程拉开差距的是 WOETransformer:它对训练集做分箱拟合,同一套分箱规则直接套测试集和新数据,边界不会错位。
2.3 数据集准备:字段结构、标签定义与切分习惯
toad 做评分卡,对输入数据的要求不复杂但很固定。至少要有一张二维表,每行是一个客户,包含若干个特征字段和一个目标字段。目标字段必须是二分类,通常 1 表示坏客户、0 表示好客户,反过来也行,但整套流程里必须保持一致。
特征字段建议和真实审批场景对齐,常见的有年龄、收入、负债率、征信查询次数、逾期天数、贷款用途、在贷机构数等。连续型和分类型最好都有,这样后面能演示连续变量分箱和类别变量分箱两种处理方式。
样本量方面,我建议起步不要低于 3000 行,太少了分箱会切出一个特别小的箱,WOE 波动很大。切分数据时,时间窗口切分比随机切分更贴近实际生产,比如用 2022 到 2023 的数据做训练,用 2024 上半年做验证。演示场景里我会先用随机切分跑通流程,真实项目里再开时间切分,因为评分卡上线后要面对的是未来客群,时间外样本才有代表性。
3. 用 toad 跑通完整评分卡:示例代码、输出和参数
3.1 准备一份可复现的数据集
为了让你不用到处找数据就能复现,我用一个生成函数构造模拟数据集。字段设计成常见信贷申请变量,违约概率由一个隐藏的评分函数决定,这样训练集和测试集都有一条真实规律,toad 才能跑出有意义的指标。
import numpy as np import pandas as pd def make_credit_sample(n=5000, seed=2024): rng = np.random.default_rng(seed) df = pd.DataFrame() df['age'] = rng.normal(36, 9, n).round().astype(int) df['income'] = rng.lognormal(8.2, 0.6, n).round(-1) df['debt_ratio'] = np.clip(rng.normal(0.35, 0.18, n), 0, 0.95) df['credit_line_used'] = np.clip(rng.normal(0.4, 0.25, n), 0, 1) df['overdue_days_90d'] = rng.choice( [0, 3, 7, 15, 30, 60, 90], n, p=[0.55, 0.15, 0.10, 0.08, 0.06, 0.04, 0.02]) df['query_count_30d'] = rng.poisson(1.2, n) df['loan_purpose'] = rng.choice( ['car', 'home', 'education', 'medical', 'other'], n, p=[0.20, 0.15, 0.20, 0.15, 0.30]) df['record_months'] = rng.integers(3, 96, n) # 构造违约概率:多个特征共同作用,模拟真实场景 score_z = ( -3.2 + 0.02 * (df['age'] - 30) + 0.25 * (np.log1p(df['income']) - 7.5) - 1.8 * df['debt_ratio'] - 1.2 * df['credit_line_used'] - 0.03 * df['overdue_days_90d'] - 0.06 * df['query_count_30d'] - 0.08 * (df['record_months'] / 12) ) bad_prob = 1 / (1 + np.exp(-score_z)) df['target'] = (bad_prob > rng.random(n)).astype(int) return df train = make_credit_sample(4000, seed=2024).reset_index(drop=True) test = make_credit_sample(1000, seed=7).reset_index(drop=True) train.to_csv('train.csv', index=False) test.to_csv('test.csv', index=False) print('训练集坏样本占比: %.2f%%' % (train['target'].mean() * 100)) print('测试集坏样本占比: %.2f%%' % (test['target'].mean() * 100))代码里比较关键的是 score_z 的设计。它把年龄、收入、负债率、逾期、查询次数、在贷时长都放进了对数几率,再用 sigmoid 转成概率,最后和随机数比较生成 0/1 标签。真实数据和这个生成过程不同,但结构相似,所以后面所有 toad 方法都能原样迁移。两个数据集用不同随机种子,避免训练集和测试集来自同一批随机数而高估模型效果。
3.2 数据质量检查与 toad.clean
数据生成后,第一步先做质量体检。toad.detect 会返回一张报告表,包含每个字段的缺失率、IV、唯一值个数等。这一步能让你在建模前就知道哪些字段需要处理,而不是被模型里的警告搞得一头雾水。
import toad print(toad.detect(train).head(20)) train_clean = toad.clean(train, target='target', nan=-1, inf=-1) test_clean = toad.clean(test, target='target', nan=-1, inf=-1)toad.clean 的作用是把 NaN 和无穷值替换成指定值,这里统一替换成 -1。替换成 -1 的做法在评分卡里很常见,因为 -1 落在所有正常分箱之外,会被单独分进一个缺失箱,而不是被随机塞进某个正常区间。你要注意,训练集和测试集必须用相同的填充值,否则线上数据缺失值处理口径会不一致,导致分箱映射错位。
3.3 特征筛选:空值率、IV 与相关性三重过滤
特征筛选是评分卡里最容易被跳过的一步。很多项目直接把几十个字段全部丢进模型,结果变量之间高度相关,模型系数忽正忽负,评分卡根本没法解释。toad.selection.select 把三个常用门槛合并成一次调用。
feature_cols = [c for c in train_clean.columns if c != 'target'] selected, dropped = toad.selection.select( train_clean[feature_cols], train_clean['target'], empty=0.9, iv=0.02, corr=0.7, return_drop=True, ) print('保留变量:', selected.columns.tolist()) print('剔除变量:', dropped.columns.tolist())empty=0.9 表示缺失率超过 90% 的变量直接剔除,iv=0.02 表示 IV 低于 0.02 的变量不进入后续环节,corr=0.7 表示两两相关系数超过 0.7 时只保留其中一个。这三个值都是行业里的保守默认值,实际项目里我会根据样本量调:样本量越大,iv 阈值可以降到 0.01 以下,因为大样本里低 IV 变量也可能贡献微弱但稳定的区分能力。
剔除清单千万不要直接丢进垃圾桶,建议看一眼被删的是什么。业务上重要的变量即使 IV 略低,也值得人工复核后再决定是否保留。
3.4 分箱与 WOE 转换:训练集测试集必须使用同一套边界
特征筛选之后进入评分卡最核心的加工环节:分箱和 WOE 转换。toad 的 WOETransformer 会自动完成每个特征的分箱拟合,并把分箱结果应用到测试集。这里最容易犯的错误是训练集和测试集分开拟合,导致两边箱子边界不一致,测试集分数分布失真。
woe_transformer = toad.transform.WOETransformer() woe_train = woe_transformer.fit_transform( train_clean[selected.columns], train_clean['target'], ) woe_test = woe_transformer.transform( test_clean[selected.columns] ) print('WOE转换后的训练集:') print(woe_train.head())fit_transform 会学习每个特征的最优分箱边界,并在同一批数据上把原始值替换成所在箱的 WOE 值。transform 只做映射,不重新学习边界,这样能保证线上样本拿到的是和训练时完全一致的 WOE 编码。这一步做完,woe_train 里每个字段的值就变成了一个个小数,范围大概在正负几之间,模型输入变成统一尺度。
3.5 逻辑回归与 KS、AUC 验证
评分卡里最常用的模型是逻辑回归,因为它系数天然对应每个特征的方向和权重,打分公式清晰,监管评审时容易解释。toad 没有重新造一个模型包,而是直接兼容 sklearn 的逻辑回归,建模部分不用改变习惯。
from sklearn.linear_model import LogisticRegression from toad.metrics import KS, AUC lr = LogisticRegression(C=0.1, solver='liblinear', max_iter=200) lr.fit(woe_train, train_clean['target']) pred_train = lr.predict_proba(woe_train)[:, 1] pred_test = lr.predict_proba(woe_test)[:, 1] print('训练集 KS:', round(KS(train_clean['target'], pred_train), 4)) print('测试集 KS:', round(KS(test_clean['target'], pred_test), 4)) print('训练集 AUC:', round(AUC(train_clean['target'], pred_train), 4)) print('测试集 AUC:', round(AUC(test_clean['target'], pred_test), 4))C 是逻辑回归的正则化强度,C 越小惩罚越重,C=0.1 是我习惯的保守起调值,它能抑制系数过大。solver 选 liblinear 主要因为数据量不大且样本维度不高,收敛快。KS 表示好坏样本分布的最大差距,一般大于 0.25 算能看,大于 0.4 算不错;AUC 稳定在 0.75 以上基本可以进入评分卡生成环节。如果这里测试集指标比训练集掉太多,先回头查分箱边界或特征筛选,不要急着调正则化。
3.6 把模型换算成评分卡并导出
模型验证通过后,最后一步是把逻辑回归输出概率映射成整数分数,并导出成可读的评分卡表。
from toad import ScoreCard from toad.scorecard import predict card = ScoreCard( X=woe_train, y=train_clean['target'], model=lr, P0=600, PD0=0.02, rate=2, ) card.fit() card_table = card.export() print(card_table.head(10)) score_train = predict(card, woe_train) score_test = predict(card, woe_test) print('训练集分数描述:') print(pd.Series(score_train).describe())ScoreCard 的 P0、PD0、rate 分别表示基准分数、基准点对应的违约概率、分数随风险翻倍的增量。export 得到的表就是经典的评分卡形式:每个特征一行或多行,每一行是一个分箱区间,最后一列是该区间对应的加减分。这张表可以直接交给业务同事评审,也可以存成配置供线上决策引擎使用。
4. 参数怎么调:分箱方法、评分刻度与筛选阈值
4.1 分箱粒度:method、n_bins、min_samples 三个控制点
toad 默认的分箱策略已经能跑出不错的结果,但真实项目里分箱边界往往要人工微调。toad.transform 里单独提供了 Combiner,专门做分箱边界控制。你可以先看每个特征的边界,再决定要不要合并或者限制箱数。
combiner = toad.transform.Combiner() combiner.fit( train_clean[['age', 'income', 'debt_ratio']], train_clean['target'], method='chi', min_samples=0.05, n_bins=8, ) print(combiner.export()['age'])method 控制分箱算法,chi 表示卡方分箱,它会根据好坏样本分布差异把近似的相邻区间合并;dt 表示决策树分箱,分出的边界更容易和交叉特征对齐。n_bins 限制最多分几个箱,min_samples 限制每个箱最少占总样本的比例。这两个参数要一起看,如果你的特征取值特别集中,n_bins 设成 10 但 min_samples 设成 0.05,最终可能只能分出两三个箱,因为剩余箱的样本量不够。
我一般会让连续变量保留四到六个箱,太少会丢掉趋势信息,太多会让评分卡冗长且每个箱的坏样本率波动大。类别变量如果类别很多,优先让每个类别独立成箱,但类别样本占比太小时要合并成 other 箱,避免线上出现训练时没见过的类别。
4.2 评分刻度三件套:P0、PD0、rate 之间的锚点关系
评分卡不是直接把概率乘以 100 那么粗暴,业界常用公式是分数等于一个常数减去另一个常数乘以对数几率。这样做的目的是让分数呈线性变化,每增加固定分数代表风险降低固定倍数。P0、PD0、rate 就是给这条线性变换提供两个锚点和一个变化步长。
P0 是基准分,通常取 600 或 650,选一个业务上容易记住的整数。PD0 是这个基准分对应的违约概率,比如设 0.02,表示 600 分的客户预计有 2% 的坏账概率。rate 是 odds 每翻一倍时分数要变化多少,常见值在 20 到 60 之间。rate=2 表示风险比每翻倍,分数降 2 分;rate 设得越大,分数对风险越敏感,分数分布越散,业务分档越容易拉开差距。
调这些参数时,先看分数分布再定档位。如果大部分客户集中在 580 到 620 之间,说明 rate 太小,区分度不够,可以往上加。反过来,如果分数两极分化严重,可能是 PD0 和 P0 的锚点没放准。调完记住重跑一遍 KS 和 AUC,因为分数分布的区分能力和模型概率的区分能力不是一回事,分数是模型概率的线性变换,但落在业务阈值附近才有实际意义。
4.3 筛选阈值怎么取舍:empty、iv、corr 不是越小越好
empty、iv、corr 这三个参数设在 toad.selection.select 里,表面看是很机械的过滤规则,实际上藏着建模师的经验判断。empty 设 0.9 表示允许变量缺失九成,这个值适合变量池特别大的场景;如果变量池只有十几个,empty 设到 0.5 都有点可惜,因为丢掉一个变量可能就丢掉一个独有信息。样本量小的时候,我宁愿保留缺失率 60% 的变量,让它分出一个缺失箱,也比少一个视角好。
iv 阈值是最需要小心的。IV 小于 0.02 的变量通常被认为区分能力弱,但有些业务变量天然 IV 低,比如学历在征信数据里区分度通常不高,但授信策略里它是拒绝性变量。如果一票否决会导致策略变量被模型丢掉,后续评审解释起来会很痛苦。我的习惯是先把 select 的阈值放宽,比如 iv 降到 0.005,把变量保留下来,等建模后看系数和方向,再结合业务判断决定去留。
corr 相关性过滤也要区分场景。负债率和收入通常会相关,但它们在经济含义上属于不同维度,如果系统只按相关系数过滤,可能把负债率删了,最后模型只剩收入,策略上就少了近因信息。我会先看两个相关性高的变量分别和其他变量的关系,再手动保留业务上更核心的字段。
5. toad 踩坑:五个容易出现并耽误上线的现场
5.1 现象一:目标列被 WOE 转换搞没,建模标签全乱
有次我图省事,把整张表直接丢给 fit_transform,没做列排除,结果 WOE 转换后原来的 target 列被当成普通特征分箱了,标签箱子的 WOE 数值变成了模型的自变量,建模输出的指标异常高,但评分卡完全没法解释。
原因是 WOETransformer 会默认处理所有输入列,它不知道哪一列是目标列。解决方法是严格分离特征和目标:先定义 feature_cols 为除 target 外所有列,fit_transform 只传 feature_cols,目标列单独存储。转换后再把 target 拼回去用于训练,千万不要让 target 参与 WOE 拟合。类似地,客户 ID、申请时间这类不参与评分的字段,也不要进转换器,否则它们也会被分箱。
5.2 现象二:类别变量出现新值,转换后打分异常
生成数据里的 loan_purpose 有五个类别,训练集和测试集用相同分布采样时,小样本测试集可能恰好没有 education 这个类别,但真实上线的客户会带来全新类别。分箱映射表里没有这个键,转化结果会出现空缺值,ScoreCard 打分时该字段贡献变成 0,整张评分卡就像漏掉一个维度。
原因就是类别映射表不完备。解决方法是给类别变量做一个白名单映射:训练时把所有类别列出,把出现概率低于阈值的类别合并进 other;上线前对每个类别做校验,只要不属于白名单,一律替换成 other。这个动作要在 toad 外面先做,放在 toad.transform 前面,当作数据清洗的一部分。另外还要检查 transform 后的结果有没有出现意料之外的 NaN,一旦出现,优先怀疑是类别新值而不是模型问题。
5.3 现象三:IV 筛选把业务重要变量误删
模拟数据里某个字段如果隐藏逻辑和 rule 定义得不强,IV 会很低,select 直接把字段删掉,看起来模型更精简,但业务同事会问为什么放款金额这个变量没进模型。原因是 select 的 iv 阈值只衡量单变量区分度,没有考虑它在模型里和其他变量组合后的贡献。
解决方法是把 return_drop=True 返回的剔除清单当成候选池,每次筛选后人工复核一遍。业务有明确策略含义的变量,即使 IV 低于阈值,也可以手动放回特征集,让模型决定它的作用。我习惯在筛选阶段制作一张变量决策表,列名分别为字段、IV、缺失率、业务含义、保留原因,这样后续评审能直接看到每个变量的去留理由。
5.4 现象四:评分卡分数方向反了
评分卡第一次跑出来,画分布图发现好客户的分数反而比坏客户低,说明分数方向和业务定义反了。导致这个问题的常见原因是目标编码方向不一致。如果 target 中 1 表示好客户,0 表示坏客户,逻辑回归拟合出来的概率是好客户概率,toad 生成的分数会和坏客户概率呈反方向,业务习惯往往是希望分数越高代表坏账概率越低,这里就对不上了。
解决方法是统一目标编码,把 1 固定为坏客户。如果数据源给的方向相反,就在读入时做一次映射,例如 df['target'] = 1 - df['target']。测完再跑一个相关验证:计算分数列和 target 的斯皮尔曼相关系数,正常情况应为负相关,也就是分数越高,坏客户标签的占比越小。这个验证放进每次评分卡生成后的最小检查项里,能避免把反方向分数带到评审会现场。
5.5 现象五:上线前不看 PSI,分数分布整体漂移也不知道
评分卡在训练集和测试集上 KS 都很好,但上线后分数分布和训练集对不上,审批通过率莫名其妙下滑。原因是我当时漏掉了数据分布稳定性检查,只盯着区分度,没看整体分布偏移。toad.metrics 里的 PSI 就是干这个的,它计算两个样本分数分布的差异指数。
from toad.metrics import PSI psi_value = PSI(pd.Series(score_train), pd.Series(score_test)) print('训练集与测试集 PSI:', round(psi_value, 4))PSI 小于 0.1 表示分布几乎没变,0.1 到 0.25 表示有偏移但可接受,超过 0.25 就要认真排查。PSI 偏高时先看两边的按箱客户占比,找到是哪一个分数段贡献了偏移,再回头查这段时间的数据来源、字段口径或准入规则是否有调整。评分卡上线后的监控里,PSI 是一个必跑指标,最好做成每日任务。
6. 把分数变成决策线:分档、单调性验证和坏样本密度检查
评分卡生成后,下一步是把连续分数切成几个业务档位,通常是拒绝、关注、观察、通过、优质几档。分档不能只看分数分布,还要看每个档位的坏样本占比是否单调下降。这里我习惯把分数按 50 分一档切开,计算每个档位的坏样本率和客户占比。
import pandas as pd eval_df = pd.DataFrame({ 'score': score_test, 'target': test_clean['target'].values, }) eval_df['score_bin'] = pd.cut( eval_df['score'], bins=range(300, 1000, 50), right=False, ) group_summary = eval_df.groupby('score_bin', observed=True)['target'].agg( bad_count='sum', total_count='count', ) group_summary['bad_rate'] = group_summary['bad_count'] / group_summary['total_count'] print(group_summary)判断结果时先看最后一列 bad_rate 是否随分数段单调下降,分数越高坏样本率越低才符合业务直觉。如果不单调,说明某个分箱的 WOE 方向和模型整体方向冲突,回去检查这个变量的分箱,必要时合并相邻箱。再看 total_count,如果某个档位客户占比过低,说明这一档在业务上没有操作意义,需要放宽阈值。
我还会额外做一个分位数验证。把测试集分数按十分位切分,计算每个十分位的坏样本率,做成一张十分位表。它比 50 分档更细,能看出分数在两端是否灵敏度够。实际项目里,评审同事最常问的问题就是:你们在 580 分附近的客户坏账率是多少,和 620 分的差多少。提前跑好这张十分位表,评审现场就不会冷场。
分档阈值定好后,还要把它换算回业务动作。比如 550 分以下拒绝,550 到 620 转人工,620 以上自动通过,那就要用当前样本的通过率做一个收益模拟:通过率提高多少,坏账损失增加多少。这个模拟建议直接接上决策引擎的回放数据,而不是只用模型训练集,因为训练集的坏样本占比往往和真实通过客群不一致。
我自己的习惯是把这段分档代码写成一个固定函数,输入评分卡、验证集、分段阈值,输出单调性报表和坏样本率表。每次重训模型后只改数据路径,其他部分不动,这样模型换代时评审看到的格式永远一样,不容易因为格式变化被质疑。还有一个教训是,P0、PD0、rate 这三个参数在模型重训后千万不要随意改,否则分数线和历史分数没有任何可比性,监控报表全部作废。如果想调业务阈值,先平移业务决策线,而不是重新改评分刻度。
这套流程用 toad 跑起来之后,真正要反复打磨的是数据口径和特征业务含义,而不是重复写分箱和转换代码。希望这份笔记能帮你把评分卡链路一次性跑通,少走我之前走过的弯路。
本文还有配套的精品资源,点击获取