ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

机器学习信用风险评分卡:从WOE分箱到PDO分数映射实战

2026/9/29 2:32:16 拓冰建站 浏览量
机器学习信用风险评分卡:从WOE分箱到PDO分数映射实战 简介面向银行与消费金融机构的机器学习信用卡申请风险评分系统源码包。资源围绕申请人历史数据处理、特征工程与信用等级建模覆盖变量类型自动识别、标签编码、连续变量分箱、探索性单变量与多变量交叉分析并集成逻辑回归、随机森林、极限梯度提升等主流模型评估流程支持混淆矩阵、ROC曲线与KS曲线可视化配合 toad 完成从数据预处理到信用评分的完整链路。压缩包共四个文件包括说明文档、特征映射脚本、交互式分析笔记与样例训练数据整体约18.19MB。目前已有149人学习下载适合具备Python与机器学习基础的风控建模或数据分析人员在交互式环境中运行即可复现完整建模流程也可按业务自定义分箱策略与评估指标进一步扩展为符合监管要求的信用评分卡系统。1. 机器学习信用风险等级评分系统从“敢不敢放”到“该给多少分”“机器学习信用风险等级评分系统”听起来像竞赛 Demo真正做过机器学习项目实战的人都知道它其实是信贷风控里最要命的落地环节。这个系统要回答的不只是“用户会不会逾期”而是“该给这单授信打多少分、分到哪个风险等级、是放款、降额还是转人工”。我见过太多团队把模型 AUC 刷到 0.85最后却卡在标签口径和评分映射上。这套玩法并不要求你把神经网络搬出来反而把逻辑回归和分箱做到位就能在助贷机构、消金公司和中小银行内部稳定跑上两三年。下面按我自己的落地顺序讲从原始表、坏客户定义、WOE 分箱、PDO 评分映射到上线后的稳定性和踩坑记录每一步都带着能直接改的参数和代码。2. 先把数据理清楚标签、时间窗口与样本权重2.1 好坏客户定义逾期 30 天还是 90 天决定了模型的上限机器学习模型只负责“拟合标签”标签定义错了后面全白做。信用风险评分系统的第一步永远是回答什么样的人算“坏客户”。我见过最省事的做法是把“只要发生逾期”都标成坏结果模型疯狂学习用户是不是粗心最后 KS 低得没法看。通用的坏客户口径是“表现期内最大逾期天数 max_dpd 30”。这个口径不是拍脑袋逾期 1 到 7 天的用户很可能只是临时忘还款逾期 30 天以上往往意味着还款能力和还款意愿已经出问题。如果你的业务是中长期房贷、企业授信坏样本会更稀疏可以把口径放宽到 90 天反过来做现金贷或信用卡用 30 天更合适。还有一个被忽略的“灰户”问题。表现期内逾期 1 到 29 天的人既不算好也不能算坏。把这些灰户硬塞进好客户里会给模型注入噪声直接丢掉又会损失样本量。我的做法是先按 10 天内好、30 天以上坏、中间删除跑一版模型看效果如果坏样本太少再尝试把灰户按风险加权或把表现期从 12 个月缩短到 6 个月。坏客户占比最好在 5% 到 15% 之间太低模型学不到规律太高说明客群质量本身很差评分卡区分度会受影响。2.2 观察期和表现期怎么切为什么随机切分会让你上线后“翻车”标签定义好之后第二个容易埋雷的点是时间窗。评分系统需要区分两个窗口观察期用来计算用户特征表现期用来观察用户是否变坏。举个例子我拿到一家消金公司的数据放款时间是 2019 年到 2022 年。如果我要预测一个 2022 年 6 月放款的用户特征只能用 2022 年 6 月之前已经发生的数据比如历史借贷、收入证明、在这家平台的存量还款记录表现期则从放款日往后数 12 个月看这段时间里 max_dpd 有没有达到 30。这里最常犯的错误是把数据随机 split 成 train 和 test。随机切分意味着 2022 年 6 月的用户可能同时出现在训练集和验证集里而模型已经透过时间重叠“看到”了客群迁移规律验证指标自然虚高。正确做法是按放款月份切比如用 2019 到 2021 年放款的样本训练用 2022 年放款的样本验证。样本权重也不是配角。不同放款月份对应不同获客策略客群质量会波动。如果 2020 年放款量特别大、客群偏优质模型会被这一时期的样本主导。常见做法是把每个月的放款样本数量压到接近比如权重 该月放款量的倒数再训练一版作为对比。评分卡系统里我不建议一上来就加权先看不加权的模型能不能通过时间外验证如果月度好坏比波动太大再引入权重。2.3 用 Python 从还款计划表构造训练标签最小可运行代码我一般把数据拆成两张表放款账户表loan_accounts.csv和还款计划表repayment_schedule.csv。前者有loan_id, user_id, issue_d, amount, term后者有loan_id, due_date, pay_date。下面这段代码构造“表现期内最大逾期天数 30 坏客户”的标签import pandas as pd import numpy as np loans pd.read_csv(loan_accounts.csv, parse_dates[issue_d]) plans pd.read_csv(repayment_schedule.csv, parse_dates[due_date, pay_date]) # 表现期放款后 12 个月 loans[perf_end] loans[issue_d] pd.DateOffset(months12) # 把放款日期和表现期合并到还款计划上只保留表现期内的还款流水 plans plans.merge( loans[[loan_id, issue_d, perf_end]], onloan_id ) plans plans[ (plans[due_date] plans[issue_d]) (plans[due_date] plans[perf_end]) ] # 逾期天数 实际还款日 - 应还款日 plans[dpd] (plans[pay_date] - plans[due_date]).dt.days # 每笔贷款在表现期内的最大逾期天数 perf plans.groupby(loan_id)[dpd].max().rename(max_dpd).reset_index() # 好坏标签max_dpd 30 记为 1坏客户 perf[label] (perf[max_dpd] 30).astype(int)代码逻辑比较简单但有两个参数需要重点说明DateOffset(months12)是表现期窗口它直接影响坏样本数量建议至少试 6 个月和 12 个月两版 30是坏客户阈值对应逾期 30 天。实际业务中如果用户一直没还款pay_date可能为空dpd算出来是缺失值这时我会用perf_end兜底填充把未还记录当成已经严重逾期避免漏记坏样本。还要注意如果同一笔贷款在表现期内已经发生过逾期后续还款状态不需要继续跟踪因为标签已经定了。3. 特征工程与 WOE/IV 分箱把机器学习结果翻译成信用分的关键3.1 为什么评分系统先做 WOE 分箱而不是把原始值直接喂给模型很多从机器学习竞赛转过来的人第一反应是把连续特征做标准化后直接丢进 XGBoost。这个思路能做预测但做不成“信用风险等级评分系统”。评分卡业务方需要看到的是收入 5000 到 8000 的用户风险分是多少负债率超过 1.5 的用户风险分是多少。直接把原始收入喂给逻辑回归模型读到的是“收入每增加 1 元违约概率下降多少”这在业务上没法解释。所以常见做法是先把连续变量分箱再把每一箱映射成 WOEWeight of Evidence。WOE 定义是ln(箱内好客户占比 / 箱内坏客户占比)。收入越高坏客户占比越低WOE 就越高分值贡献为正收入越低WOE 为负。这样做有三个实际好处一是分箱天然处理了异常值和缺失值尾箱被拴在两个边界内二是变连续为类别逻辑回归不再受极端值干扰三是 WOE 与坏客户概率之间存在近似线性关系正好匹配评分卡“各项分数相加”的最终形态。我知道有人会说 XGBoost 不用分箱AUC 还更高。这在机器学习应用流程里确实是常有的树模型能自己找到切割点非线性关系学习得更好。但评分系统上线后要面对监管、审计和业务解释逻辑回归加 WOE 是成熟路线。XGBoost 可以当作基准模型去验证特征和信息量但最终对外发布评分卡刻度时我坚持用 WOE LR。3.2 IV 筛选提特征时卡多少阈值才不冤枉也不放过特征量一大光靠模型自选的变量不够还要在进模型之前做一次 IV 信息值筛选。IV 的计算基于上一节的分箱IV sigma((好客户占比 - 坏客户占比) * WOE)。IV 的参考阈值在业内已经形成共识小于 0.02 基本没有区分度0.02 到 0.1 较弱0.1 到 0.3 中等可用大于 0.3 要警惕因为单个特征信息量过大往往意味着泄漏。但阈值不能死板。我在做企业主贷时遇到过“企业存续年限”这个变量IV 只有 0.015业务上却必须保留因为它能压住空壳企业。反过来一个叫“是否曾发起提前结清”的变量IV 高达 0.35但仔细一查这个行为发生在表现期之后属于未来信息必须删掉。所以 IV 只作为排序工具保留哪些变量还要结合特征的业务含义和特征发生时间。缺失值不要直接删除。我习惯把缺失单独分成一个箱计算自己的 WOE。很多评分系统用均值填充其实是在给模型塞假数据。缺失本身可能是用户没有某类资质比如负债为 0可能反而代表低风险单独成箱才能保留这个信号。3.3 等频分箱、WOE/IV 计算代码与单调性合并下面这段代码演示“收入”变量的分箱、WOE 和 IV 计算。pd.qcut做等频分箱duplicatesdrop处理大量重值情况缺失值单独放一个箱import numpy as np import pandas as pd # data 至少包含 income 和 label 两列label1 表示坏客户 def calc_woe_iv(df, feature, targetlabel, bins8): tmp df[df[feature].notna()].copy() tmp[bin] pd.qcut(tmp[feature], qbins, duplicatesdrop) grp tmp.groupby(bin, observedTrue)[target].agg([sum, count]) grp.columns [bad, count] grp[good] grp[count] - grp[bad] total_bad grp[bad].sum() total_good grp[good].sum() # 箱内增益实际上是该箱的好客户占比 / 总好客户占比 grp[bad_pct] grp[bad] / total_bad grp[good_pct] grp[good] / total_good # WOE正数表示该箱相对优质负数表示该箱风险偏高 grp[woe] np.log(grp[good_pct] / grp[bad_pct]) grp[iv] (grp[good_pct] - grp[bad_pct]) * grp[woe] return grp result calc_woe_iv(data, income, bins6) print(result[[count, bad, good, good_pct, bad_pct, woe, iv]])这段代码里最容易错的是一个地方bad_pct和good_pct分别除以全局坏客户总数和好客户总数而不是除以箱子自身样本量。很多人算成了“箱内坏客户占比”得到的是风险趋势但 IV 公式要求的是“该箱坏客户占全体坏客户的比例”和“该箱好客户占全体好客户的比例”之间的差异。符号上高收入箱的 good_pct 更高WOE 为正说明优质低收入箱 WOE 为负说明风险偏高符合直觉。分箱之后还要做一次单调性检查。业务上“收入越高风险越低”应该表现为 WOE 从一个低到高的单调趋势。如果某个中间箱的 WOE 突然跳变我会把相邻箱合并。怎么判断直接看result[woe]的前后排序出现凹凸点时把相邻箱做groupby合并。等频分箱只是起点后续手动调箱是评分卡项目里的日常不要让代码替你把业务逻辑定死。4. 模型训练与评分映射用 PDO 把违约概率换算成 300-850 分4.1 逻辑回归和 XGBoost 怎么选评分卡系统不等于“必须用复杂算法”特征处理完之后下一步是训练模型但这里有一个经常被绕进去的“机器学习八股”是不是模型越复杂越好我说不是。信用风险等级评分系统的核心交付物是一张可更新、可解释的评分卡不是最高 AUC 的黑匣子。逻辑回归在这个场景里叫好叫座既保留了每个特征的线性权重大小又和前面的 WOE 天然契合。每一箱的 WOE 乘以回归系数再加到截距项上就是一个分数贡献。XGBoost、LightGBM 在客群复杂的场景里确实能打出更高 AUC比如多特征组合、行为序列建模但解释难度和维护成本都高。我见过一个团队把 XGBoost 部署上线后来风控部门要求解释“为什么这个用户分数骤降 20 分”XGBoost 给不出逐项贡献最后被迫离线解释代价非常大。我的常见做法是双轨用 WOE LR 做主评分卡用 XGBoost 做一个影子模型去挖掘新特征然后把高价值特征再转成 WOE 加入 LR。既享受机器学习的特征发现能力又保住评分卡的线性结构。如果你确实想直接上线一个树模型评分也不是不可以但要做概率校准并且准备好一份复杂得多的监控文档。对大多数评分系统来说逻辑回归才是那个能长期维护的选手。4.2 概率到分数的线性映射factor、offset、PDO 到底怎么算模型输出的违约概率 p 不能直接拿给业务看业务要的是整数分数。常见做法是把 odds好客户概率除以坏客户概率映射到分数score offset factor * log(odds_good)。这里的 odds_good (1-p) / p概率越低分数越高。PDOPoint of Double Odds是评分系统的灵魂参数衡量“分数每升高多少分好客户相对坏客户的比例翻倍”。我常用的基准设定是odds_good 50 时score 600PDO 20。也就是说当某个客户的好概率是坏概率的 50 倍时信用分是 600 分每升高 20 分好客户比例翻一倍。数学上factor PDO / ln(2)ln(2) 约 0.693所以 factor 约 28.85。offset 600 - factor * ln(50) ≈ 600 - 28.85 * 3.912 ≈ 487.1。有了这两个常数任意概率 p 都能换算成分数。这个线性映射必须和逻辑回归的 log-odds 保持一致性。逻辑回归得到的是log(p/(1-p))也就是 log(odds_bad)那么log(odds_good) -log(odds_bad)。把回归输出的线性预测值先取负号再代入 score 公式就能得到分数。这里最容易翻车的是正负号写反一旦写成 score offset factor * log(odds_bad)坏客户概率越高分越高整个评分系统就废了。我每次上线前都会先拿一个坏概率 0.5 的样本验证如果分数低于基准分 600说明方向对了如果高于马上检查符号。4.3 训练、验证和评分映射的完整代码下面这段代码跑通 WOE 特征 LR 训练 映射到分数。注意base_odds指基准好壞比分数随坏概率提高而降低import numpy as np from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score # train_w 是做过 WOE 分箱并拼接好的训练集 # 特征列名为 woe_income, woe_debt_ratio 等 features [woe_income, woe_debt_ratio, woe_query_count] X train_w[features].values y train_w[label].values # 按时间切分更稳这里做一个常规 random split 作为演示 X_tr, X_val, y_tr, y_val train_test_split( X, y, test_size0.2, random_state2024 ) model LogisticRegression(C1.0, solverlbfgs, max_iter200) model.fit(X_tr, y_tr) # 坏客户概率 p_val model.predict_proba(X_val)[:, 1] print(val AUC:, roc_auc_score(y_val, p_val)) # 评分参数 PDO 20.0 # 分数每高 20 分好客户/坏客户比例翻倍 base_odds 50.0 # 基准好壞比 base_score 600.0 # 基准分 factor PDO / np.log(2) offset base_score - factor * np.log(base_odds) # 坏概率做截断避免 log 中出现 0 p_clip np.clip(p_val, 1e-6, 1 - 1e-6) odds_good (1 - p_clip) / p_clip score_val offset factor * np.log(odds_good) score_val score_val.round().astype(int)代码里的C1.0是正则化系数评分卡样本量小我一般用 0.5 到 1.0避免权重过度放大。PDO20.0和base_odds50.0不是绝对标准你可以把基准分设成 500base_odds 设成 30最终分数范围会整体平移。如果你希望分数落在 300 到 850可以打印score_val.min()和max()反过来调整offset。这一步我强烈建议多试一组参数比如 PDO20、base_odds30 和 PDO25、base_odds50看分数分布是否贴合业务通过率再定。5. 验证与上线避坑KS、AUC、PSI以及 5 条血泪踩坑记录5.1 区分度指标怎么看KS 和 AUC 不是一回事验证评分模型时AUC 和 KS 被用得最多但两个指标回答的问题不一样。AUC 衡量整体排序能力随机抽一个好客户和一个坏客户模型给坏客户打出更低分更高风险的概率。KS 衡量好坏客户累计分布的最大间隔把预测分数排完序按分数段累计好客户占比和坏客户占比找两组的最大差距。KS 越大说明存在一个切点能让好坏客户分得越开。实际落地时我一般看三个数验证集 AUC、时间外 AUC、KS。如果训练 AUC 0.82时间外 AUC 掉到 0.65说明模型吃掉了时间趋势如果 KS 超过 0.6先不要高兴大概率是标签泄漏或某个强特征穿仓。AUC 达到 0.75 以上、KS 0.3 到 0.5这个模型已经能支撑审批策略。还有一个小技巧在验证集上把分数切成 10 等份统计每段的坏样本率。正常情况下坏样本率应该从低分段到高分段单调下行中间不能有突然反弹一旦反弹说明分数映射没有和风险趋势对齐。5.2 稳定性指标 PSI 的计算与阈值评分系统上线后客群漂移是常态。PSIPopulation Stability Index用来比较“训练期分数分布”和“上线后分数分布”的偏差。计算公式分成两步先按训练集分数分位把分数切成区间再统计新样本在每个区间的占比最后套公式PSI Σ (实际占比 - 基准占比) * ln(实际占比 / 基准占比)。经验阈值是PSI 0.1 表示稳定0.1 到 0.25 表示漂移上升需要关注 0.25 表示必须重新训练或检查线上特征。我直接把 PSI 写成一个复用函数def calc_psi(train_score, eval_score, bins10): # 以训练集分位数作为基准区间 edges np.percentile(train_score, np.linspace(0, 100, bins 1)) train_cnt, _ np.histogram(train_score, binsedges) eval_cnt, _ np.histogram(eval_score, binsedges) train_pct train_cnt / train_cnt.sum() 1e-6 eval_pct eval_cnt / eval_cnt.sum() 1e-6 psi np.sum((eval_pct - train_pct) * np.log(eval_pct / train_pct)) return psi这个函数里加1e-6是为了防止区间占比为 0 时log(0)报错这是 PSI 计算最常见的坑。我建议每个月对分模型、分客群各算一次 PSI同时监控每个特征自己的 PSI。分数 PSI 稳定但某个特征的 PSI 爆表说明模型输入源出了问题要单独排。5.3 5 条踩坑记录现象、原因、解决1. 训练 AUC 0.84上线一个月后新客 AUC 只有 0.61。现象模型在验证集上漂亮真实业务上一落千丈。原因用了随机切分而不是按放款时间切分训练集和验证集时间重叠模型学到了“哪个时间段放款更安全”而不是“哪类人更可能逾期”。解决严格按放款月份切比如 2019-01 到 2021-06 训练2021-07 到 2022-06 做时间外验证。以后所有机器学习模型迭代都保留这套时间切分基线。2. 坏客户定义用了“逾期 1 天”导致坏样本占比 35%模型排序能力极差。现象客群明明很优质但模型把大量正常还款用户标成高风险。原因把“忘记还款”和“真正逾期”混为一谈标签噪声太大。解决改成 max_dpd 30 为坏并剔除 max_dpd 在 1 到 29 之间的灰样。坏样本率降到 8%模型 KS 从 0.17 升到 0.4。3. 离线评分和线上评分差 5 到 8 分审批团队天天逼问原因。现象同一个用户离线测试分数和线上接口返回分数不一致。原因训练时特征名按字典序进入模型线上接口按 DataFrame 的原始列名排序逻辑回归的权重被错位加载缺失值离线用 0 填充线上用均值填充也会造成差异。解决把feature_list.json、各缺失值填充量、分箱边界一起保存到模型包线上加载时冻结特征顺序。这套“模型处方”现在是我所有评分系统的标配。4. 上线后分数 PSI 0.32所有客群分数普遍升高但拒绝率也升高了。现象模型看起来“变乐观”了业务却不买账。原因营销团队换渠道新客群年龄和负债分布整体变化分数分布漂移是客群漂移不一定是模型退化。解决分新客/老客、分渠道算 PSI发现只有新客渠道漂移。策略让新客渠道先过渡一个月再把新客样本罚入重训集。不要一看到 PSI 高就重训先定位漂移来源。5. 直接用 XGBoost 概率映射分数分数在某几个分段疯狂跳变业务无法解释。现象分数分布呈锯齿状相邻两档坏账率忽高忽低。原因树模型输出的概率并不是线性可加的概率密度的不均匀导致分数映射无法保持单调。解决改用 WOE 逻辑回归做主评分卡XGBoost 只做影子模型。如果一定要用树模型至少对概率做等频分箱后再平滑映射但解释性问题依然难解决。6. 最后一步把分数切成等级并固化特征顺序一个值得长期做的验证习惯评分模型最终要输出“等级”不是一堆分数。常见做法是把 300 到 850 分切成 A、B、C、D 四档比如 750 分以上 A700 到 749 B650 到 699 C650 以下 D。阈值不能闭着眼拍我一般看验证集每个分数段的通过率和坏账预期倒推阈值。如果业务要求整体通过率 60%就去找验证集分数分布的 40 分位点这个点附近的分值就是 A/B 边界。这样做出来的等级既有统计意义也方便审批策略直接引用。等级映射建议写成配置不要散落在代码里def score_to_grade(score: int) - str: if score 750: return A if score 700: return B if score 650: return C return D但比等级更重要的是“模型处方”的习惯。我现在每次训练评分卡都会同时保存四件东西WOE 分箱表、特征顺序列表、缺失值填充参数、逻辑回归权重。上线以后每个月复核一次 PSI每季度把新样本追加进训练集重训。这个习惯救过我很多次尤其是特征顺序错乱这种问题如果没有处方线上接接口时很难排查。希望帮到你。本文还有配套的精品资源点击获取