简介:面向银行与消费金融机构的机器学习信用卡申请风险评分系统源码包。资源围绕申请人历史数据处理、特征工程与信用等级建模,覆盖变量类型自动识别、标签编码、连续变量分箱、探索性单变量与多变量交叉分析,并集成逻辑回归、随机森林、极限梯度提升等主流模型评估流程,支持混淆矩阵、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(months=12) # 把放款日期和表现期合并到还款计划上,只保留表现期内的还款流水 plans = plans.merge( loans[['loan_id', 'issue_d', 'perf_end']], on='loan_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(months=12)是表现期窗口,它直接影响坏样本数量,建议至少试 6 个月和 12 个月两版;>= 30是坏客户阈值,对应逾期 30 天。实际业务中如果用户一直没还款,pay_date可能为空,dpd算出来是缺失值,这时我会用perf_end兜底填充,把未还记录当成已经严重逾期,避免漏记坏样本。还要注意,如果同一笔贷款在表现期内已经发生过逾期,后续还款状态不需要继续跟踪,因为标签已经定了。
3. 特征工程与 WOE/IV 分箱:把机器学习结果翻译成信用分的关键
3.1 为什么评分系统先做 WOE 分箱,而不是把原始值直接喂给模型
很多从机器学习竞赛转过来的人,第一反应是把连续特征做标准化后直接丢进 XGBoost。这个思路能做预测,但做不成“信用风险等级评分系统”。评分卡业务方需要看到的是:收入 5000 到 8000 的用户风险分是多少,负债率超过 1.5 的用户风险分是多少。直接把原始收入喂给逻辑回归,模型读到的是“收入每增加 1 元,违约概率下降多少”,这在业务上没法解释。
所以常见做法是先把连续变量分箱,再把每一箱映射成 WOE(Weight 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做等频分箱,duplicates='drop'处理大量重值情况,缺失值单独放一个箱:
import numpy as np import pandas as pd # data 至少包含 income 和 label 两列,label=1 表示坏客户 def calc_woe_iv(df, feature, target='label', bins=8): tmp = df[df[feature].notna()].copy() tmp['bin'] = pd.qcut(tmp[feature], q=bins, duplicates='drop') grp = tmp.groupby('bin', observed=True)[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', bins=6) 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,概率越低,分数越高。PDO(Point of Double Odds)是评分系统的灵魂参数,衡量“分数每升高多少分,好客户相对坏客户的比例翻倍”。
我常用的基准设定是:odds_good = 50 时,score = 600,PDO = 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_size=0.2, random_state=2024 ) model = LogisticRegression(C=1.0, solver='lbfgs', max_iter=200) 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)代码里的C=1.0是正则化系数,评分卡样本量小,我一般用 0.5 到 1.0,避免权重过度放大。PDO=20.0和base_odds=50.0不是绝对标准,你可以把基准分设成 500,base_odds 设成 30,最终分数范围会整体平移。如果你希望分数落在 300 到 850,可以打印score_val.min()和max(),反过来调整offset。这一步我强烈建议多试一组参数,比如 PDO=20、base_odds=30 和 PDO=25、base_odds=50,看分数分布是否贴合业务通过率再定。
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 的计算与阈值
评分系统上线后,客群漂移是常态。PSI(Population Stability Index)用来比较“训练期分数分布”和“上线后分数分布”的偏差。计算公式分成两步:先按训练集分数分位把分数切成区间,再统计新样本在每个区间的占比,最后套公式PSI = Σ (实际占比 - 基准占比) * ln(实际占比 / 基准占比)。
经验阈值是:PSI < 0.1 表示稳定,0.1 到 0.25 表示漂移上升需要关注,> 0.25 表示必须重新训练或检查线上特征。我直接把 PSI 写成一个复用函数:
def calc_psi(train_score, eval_score, bins=10): # 以训练集分位数作为基准区间 edges = np.percentile(train_score, np.linspace(0, 100, bins + 1)) train_cnt, _ = np.histogram(train_score, bins=edges) eval_cnt, _ = np.histogram(eval_score, bins=edges) 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 分以上 A,700 到 749 B,650 到 699 C,650 以下 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,每季度把新样本追加进训练集重训。这个习惯救过我很多次,尤其是特征顺序错乱这种问题,如果没有处方,线上接接口时很难排查。
希望帮到你。
本文还有配套的精品资源,点击获取