金融风控入门实战:贷款违约预测模型从数据到评分卡全流程
1. 项目缘起与整体思路拆解1.1 为什么选贷款违约预测作为风控入门项目金融风控这个领域说大很大说小也很小。大到一个银行几千人的风控团队小到一个助贷平台三五个算法工程师就能撑起核心决策链路。但不管规模怎么变贷款违约预测始终是这条链路上最基础、也最核心的一环。原因很简单信贷业务赚的就是利差而利差能不能覆盖住坏账直接决定了这门生意是盈利还是亏损。我最早接触这个方向的时候踩过一个很典型的坑拿到一份标注好的历史放款数据二话不说先上XGBoostAUC跑到0.78就以为大功告成。结果上线之后发现模型在训练集上表现很好一到真实审批流里就“失灵”——后来复盘才明白问题出在样本定义和时间窗口上。训练集里用的是“放款后12个月是否逾期90天以上”作为标签但实际审批时很多客户连第一期的还款行为都还没发生模型学到的很多特征其实是“未来信息”也就是典型的数据穿越。所以这个项目我打算从头讲清楚一份贷款违约预测模型从拿到原始数据到最终能上线跑分中间到底要经历哪些环节每个环节的关键决策点在哪里以及那些只有真正做过的人才会知道的坑。这个内容适合谁看如果你是有一定Python基础、想往风控算法方向转的数据分析同学或者已经在做风控策略、想了解模型侧是怎么一回事的策略分析师再或者你是刚入行一两年、想系统梳理一遍完整流程的算法工程师那这篇内容应该能给你不少参考。我会尽量把代码和业务逻辑揉在一起讲而不是单纯贴一段sklearn的调用。1.2 整体方案设计从原始数据到可解释的评分卡这个项目的整体思路我把它拆成六个阶段每个阶段都有明确的输入和输出阶段核心任务关键产出数据理解与探索搞清楚字段含义、分布、缺失情况数据字典、EDA报告样本定义与标签构建明确好坏样本定义、观察期与表现期带标签的建模宽表特征工程衍生变量、分箱、WOE编码特征库、IV值排序模型训练与调优逻辑回归/XGBoost/LightGBM对比最优模型、超参数模型评估与验证KS、AUC、PSI、跨时间验证评估报告、稳定性分析评分卡转换与部署分数映射、 cutoff 确定评分卡、决策阈值为什么这么设计因为风控模型和推荐模型、图像模型有一个本质区别可解释性往往比精度更重要。监管要能说清楚“为什么拒绝这个客户”业务要能理解“哪些因素在驱动风险”所以逻辑回归WOE评分卡这套“老古董”至今仍然是很多银行的主力不是没有道理的。当然现在很多互联网平台也会用集成树模型做排序但最终往往还是要输出一个分数方便策略同学做cutoff。我个人的做法是先用逻辑回归WOE搭一个baseline把特征工程和样本定义跑通然后再上LightGBM看能提升多少。如果提升不明显那就老老实实用评分卡因为可解释性和稳定性更好如果提升显著再考虑用树模型做线上打分但一定要做好模型监控和解释性工具比如SHAP。1.3 数据集说明与字段理解这个项目用的数据集是一份公开的信贷违约数据包含约3万条放款记录字段大致分为几类客户基本信息年龄、职业、学历、婚姻状况等信贷历史历史贷款笔数、历史逾期次数、信用额度使用率等本次申请信息贷款金额、期限、利率、还款方式等行为数据近6个月查询次数、近12个月还款表现等标签字段是否违约0/1拿到数据第一步我习惯先做三件事看字段类型和缺失率缺失率超过60%的字段除非业务上特别重要否则直接砍掉。看标签分布违约率大概在5%-8%左右属于典型的不平衡样本。看时间跨度如果数据跨越了不同年份一定要按时间切分训练集和测试集不能随机切。注意很多教程喜欢用train_test_split随机切分这在风控里是大忌。因为信贷数据有时间效应随机切分会导致未来信息泄露模型评估结果会虚高。2. 核心细节解析与实操要点2.1 样本定义观察期与表现期怎么切这是整个项目里最容易被忽视、但影响最大的环节。所谓样本定义说白了就是用哪个时间点的信息去预测哪个时间段的行为。标准做法是观察期取申请日前一段时间的数据比如近6个月、近12个月的行为表现。表现期放款后一段时间内是否发生违约比如12个月内是否逾期90天以上。成熟期表现期结束后的缓冲确保所有样本都有足够时间“表现”。举个例子假设你在2023年6月做模型那么取2022年1月到2022年6月放款的客户作为样本观察期取放款前6个月2021年7月-2021年12月表现期取放款后12个月2022年1月-2022年12月2023年1月之后才能确认这些客户是否违约这样切出来的样本才是真正“当时能拿到什么信息就预测什么结果”。我见过不少团队为了赶进度直接用“当前逾期状态”作为标签结果模型学到的全是“已经逾期的人有什么特征”而不是“哪些人未来会逾期”。这两者天差地别。2.2 特征工程从原始字段到WOE编码特征工程这块我分三步走第一步衍生变量原始字段往往不够用需要根据业务逻辑衍生。比如额度使用率 已用额度 / 总额度负债收入比 月还款额 / 月收入查询密度 近3个月查询次数 / 3逾期占比 历史逾期次数 / 历史贷款笔数这些衍生变量往往比原始字段更有区分度。我实测下来额度使用率和查询密度这两个指标IV值通常能排进前五。第二步分箱连续变量直接放进逻辑回归解释性很差而且容易受异常值影响。所以要做分箱常用的方法有等频分箱每个箱样本量差不多等距分箱按数值区间均匀切卡方分箱基于统计检验自动找切点决策树分箱用树模型找最优切分点我一般用卡方分箱因为它能自动处理单调性问题。分箱之后每个箱的WOE值应该呈现单调趋势否则说明分箱不合理。第三步WOE编码与IV值筛选WOEWeight of Evidence的计算公式WOE ln(坏样本占比 / 好样本占比) IV Σ (坏样本占比 - 好样本占比) × WOEIV值用来衡量变量的预测能力一般标准是IV值范围预测能力 0.02几乎无预测力0.02-0.1弱0.1-0.3中等0.3-0.5强 0.5极强但需警惕过拟合我一般保留IV值在0.02以上的变量但也不会盲目追求高IV因为IV太高往往意味着这个变量和标签有某种“巧合”关系跨时间验证时容易失效。2.3 模型选择逻辑回归 vs LightGBM逻辑回归和LightGBM我一般都会跑一遍对比KS和AUC。逻辑回归的优势可解释性强系数直接反映变量影响方向配合WOE编码天然适合评分卡训练快稳定性好LightGBM的优势自动捕捉非线性关系对缺失值和异常值不敏感通常KS能比逻辑回归高2-5个点但LightGBM的缺点也很明显黑盒。你很难跟业务解释“为什么这个客户被拒了”。所以我的做法是如果LightGBM比逻辑回归KS高不到3个点直接用逻辑回归如果高3个点以上用LightGBM做线上打分但同时用SHAP值做解释性分析无论用哪个模型都要做跨时间验证2.4 评估指标KS、AUC、PSI风控模型不看准确率因为样本不平衡准确率没有意义。核心指标是KSKolmogorov-SmirnovKS衡量的是模型区分好坏样本的能力计算公式是KS max(累计坏样本占比 - 累计好样本占比)一般标准KS值模型效果 0.2差0.2-0.3一般0.3-0.4良好0.4-0.5优秀 0.5极好但需警惕AUCAUC是ROC曲线下的面积衡量的是模型排序能力。风控里一般要求AUC在0.7以上。PSIPopulation Stability IndexPSI衡量的是模型分数在不同时间段的分布稳定性PSI Σ (实际占比 - 预期占比) × ln(实际占比 / 预期占比)PSI值稳定性 0.1稳定0.1-0.25需关注 0.25不稳定需重新训练我一般会按月计算PSI如果连续两个月PSI超过0.1就会触发模型预警。3. 实操过程与核心环节实现3.1 数据加载与初步探索先上代码。数据加载这块没什么好说的pandas直接读import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns # 加载数据 df pd.read_csv(loan_data.csv) print(df.shape) print(df[target].value_counts()) print(df.isnull().sum().sort_values(ascendingFalse).head(20))跑完这几行你大概能知道数据量多大违约率多少哪些字段缺失严重我一般会再画几个图# 标签分布 df[target].value_counts().plot(kindbar) plt.title(Target Distribution) plt.show() # 数值变量分布 num_cols df.select_dtypes(include[np.number]).columns df[num_cols].hist(figsize(20, 15), bins30) plt.show()这一步的目的是建立直觉。你得知道每个字段大概长什么样有没有极端值分布是不是偏态。3.2 样本切分与标签构建前面说了不能随机切分。我的做法是按时间切# 假设数据里有放款时间字段 df[loan_date] pd.to_datetime(df[loan_date]) # 按时间排序 df df.sort_values(loan_date) # 前70%做训练后30%做测试 split_point int(len(df) * 0.7) train df.iloc[:split_point] test df.iloc[split_point:] print(fTrain: {train.shape}, Test: {test.shape}) print(fTrain bad rate: {train[target].mean():.4f}) print(fTest bad rate: {test[target].mean():.4f})如果训练集和测试集的违约率差异很大说明时间切分点选得不好需要调整。3.3 特征分箱与WOE编码实现分箱这块我一般用optbinning这个库比手写卡方分箱方便很多from optbinning import OptimalBinning # 选择需要分箱的变量 bin_vars [age, income, loan_amount, credit_score, debt_ratio, query_3m, query_6m] # 对每个变量做分箱 binning_dict {} for var in bin_vars: optb OptimalBinning(namevar, dtypenumerical, solvercp, max_n_bins5) optb.fit(train[var], train[target]) binning_dict[var] optb # 打印分箱结果 binning_table optb.binning_table.build() print(f\n{var} 分箱结果:) print(binning_table[[Bin, Count, Bad Rate, WoE, IV]])跑完这段你会得到每个变量的分箱表和WOE值。重点看两个东西WOE是否单调如果不单调说明分箱有问题需要调整。IV值大小IV太低的变量可以直接砍掉。分箱完成后把WOE值映射回原始数据# WOE编码 for var in bin_vars: optb binning_dict[var] train[var _woe] optb.transform(train[var], metricwoe) test[var _woe] optb.transform(test[var], metricwoe)3.4 逻辑回归模型训练与评分卡转换WOE编码之后就可以上逻辑回归了from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, roc_curve # 选择WOE变量 woe_cols [col for col in train.columns if col.endswith(_woe)] # 训练逻辑回归 lr LogisticRegression(C0.1, penaltyl2, solverlbfgs, max_iter1000) lr.fit(train[woe_cols], train[target]) # 预测 train_pred lr.predict_proba(train[woe_cols])[:, 1] test_pred lr.predict_proba(test[woe_cols])[:, 1] # 评估 print(fTrain AUC: {roc_auc_score(train[target], train_pred):.4f}) print(fTest AUC: {roc_auc_score(test[target], test_pred):.4f})评分卡转换的公式Score A - B × ln(odds)其中odds p / (1-p)p是违约概率A和B是常数通常设定为当odds1时Score600当odds每翻一倍Score减少50分# 评分卡转换 def scorecard(pred_prob, A600, B50): odds pred_prob / (1 - pred_prob) score A - B * np.log(odds) return score train_score scorecard(train_pred) test_score scorecard(test_pred) print(fTrain Score Range: {train_score.min():.0f} - {train_score.max():.0f}) print(fTest Score Range: {test_score.min():.0f} - {test_score.max():.0f})3.5 LightGBM模型对比与SHAP解释LightGBM这块我一般用默认参数先跑一版然后再调import lightgbm as lgb # 准备数据 feature_cols [col for col in train.columns if col not in [target, loan_date]] X_train train[feature_cols] y_train train[target] X_test test[feature_cols] y_test test[target] # 训练LightGBM lgb_model lgb.LGBMClassifier( n_estimators500, learning_rate0.05, max_depth5, num_leaves31, subsample0.8, colsample_bytree0.8, random_state42 ) lgb_model.fit(X_train, y_train) # 预测 lgb_train_pred lgb_model.predict_proba(X_train)[:, 1] lgb_test_pred lgb_model.predict_proba(X_test)[:, 1] print(fLightGBM Train AUC: {roc_auc_score(y_train, lgb_train_pred):.4f}) print(fLightGBM Test AUC: {roc_auc_score(y_test, lgb_test_pred):.4f})SHAP解释性分析import shap explainer shap.TreeExplainer(lgb_model) shap_values explainer.shap_values(X_test) # 特征重要性 shap.summary_plot(shap_values, X_test, plot_typebar) shap.summary_plot(shap_values, X_test)SHAP图能告诉你哪些特征最重要以及每个特征对预测结果的影响方向。这个对于跟业务解释模型非常有帮助。3.6 KS与PSI计算实现KS计算def calculate_ks(y_true, y_pred): fpr, tpr, thresholds roc_curve(y_true, y_pred) ks max(tpr - fpr) return ks print(fLR Test KS: {calculate_ks(y_test, test_pred):.4f}) print(fLGB Test KS: {calculate_ks(y_test, lgb_test_pred):.4f})PSI计算def calculate_psi(expected, actual, buckets10): # 等频分箱 breakpoints np.percentile(expected, np.linspace(0, 100, buckets 1)) breakpoints[0] -np.inf breakpoints[-1] np.inf expected_percents np.histogram(expected, breakpoints)[0] / len(expected) actual_percents np.histogram(actual, breakpoints)[0] / len(actual) # 避免除零 expected_percents np.where(expected_percents 0, 0.0001, expected_percents) actual_percents np.where(actual_percents 0, 0.0001, actual_percents) psi np.sum((actual_percents - expected_percents) * np.log(actual_percents / expected_percents)) return psi # 按月计算PSI test[month] pd.to_datetime(test[loan_date]).dt.to_period(M) for month in test[month].unique(): month_data test[test[month] month] psi calculate_psi(train_pred, month_data[pred_prob]) print(f{month} PSI: {psi:.4f})4. 常见问题与排查技巧实录4.1 模型AUC高但KS低是怎么回事这种情况我遇到过好几次。AUC高说明模型整体排序能力不错但KS低说明在某个关键阈值附近好坏样本的区分度不够。常见原因有两个一是样本不平衡太严重。如果违约率只有1%AUC容易虚高但KS上不去。解决办法是做重采样或者调整样本权重。二是分数分布太集中。如果大部分样本的预测概率都在0.05-0.15之间那KS自然高不了。这时候需要检查特征工程看看是不是WOE编码把信息压缩得太厉害了。4.2 跨时间验证KS下降超过20%怎么办这是风控模型最头疼的问题。我一般按这个顺序排查检查特征PSI如果某个特征的PSI超过0.25说明这个特征的分布发生了显著变化需要考虑剔除或重新分箱。检查样本定义是不是表现期不够长导致部分样本标签还不成熟检查模型过拟合训练集KS和测试集KS差距多大如果超过15%说明过拟合严重需要正则化或减少特征。检查业务变化是不是这段时间风控策略调整了导致通过人群发生了变化4.3 WOE单调性不满足怎么调整WOE不单调说明分箱切点没选好。我一般这么调合并相邻的箱直到WOE单调如果合并后箱数太少少于3箱考虑换一种分箱方法如果怎么调都不单调说明这个变量和标签的关系本身就很弱直接砍掉4.4 常见问题速查表问题可能原因排查方法解决方案AUC高但KS低样本不平衡、分数集中看分数分布直方图重采样、调整权重跨时间KS下降大特征漂移、过拟合计算特征PSI剔除高PSI特征、正则化WOE不单调分箱切点不合理看分箱表合并相邻箱、换分箱方法模型上线后效果差数据穿越、样本定义错检查特征时间窗口重新定义观察期和表现期PSI持续升高客群变化、策略调整按月计算PSI重新训练模型4.5 实操心得与避坑技巧最后分享几个我踩过的坑第一不要迷信高IV。有些变量IV高达0.8看起来很强但跨时间验证时直接失效。我一般会限制单变量IV不超过0.5超过的要么是标签泄露要么是过拟合。第二WOE编码要用训练集的映射。测试集和线上打分时必须用训练集算出来的WOE映射表不能重新计算。否则会导致分数分布不一致。第三评分卡分数要定期校准。随着客群变化同样的分数对应的违约概率会漂移。我一般每季度做一次分数校准确保分数和风险的对齐关系稳定。第四模型监控比模型训练更重要。上线只是开始后续的PSI监控、KS监控、特征漂移监控才是保证模型持续有效的关键。我见过太多团队模型上线就不管了结果半年后坏账率飙升才发现模型早就失效了。第五业务解释性不能丢。哪怕你用LightGBM也要准备一套SHAP解释工具能随时跟业务说清楚“为什么这个客户被拒了”。否则策略同学不敢用你的模型再高的KS也是白搭。这个项目后续还可以扩展的方向包括引入图神经网络挖掘团伙欺诈、用生存分析做动态违约预测、结合文本数据做非结构化特征提取。但不管怎么扩展样本定义、特征工程、跨时间验证这三板斧永远是风控模型的根基。