1. 项目缘起与整体思路拆解
1.1 为什么选贷款违约预测作为风控入门项目
金融风控这个领域,说大很大,说小也很小。大到一个银行几千人的风控团队,小到一个助贷平台三五个算法工程师就能撑起核心决策链路。但不管规模怎么变,贷款违约预测始终是这条链路上最基础、也最核心的一环。原因很简单:信贷业务赚的就是利差,而利差能不能覆盖住坏账,直接决定了这门生意是盈利还是亏损。
我最早接触这个方向的时候,踩过一个很典型的坑:拿到一份标注好的历史放款数据,二话不说先上XGBoost,AUC跑到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值筛选
WOE(Weight 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
风控模型不看准确率,因为样本不平衡,准确率没有意义。核心指标是:
KS(Kolmogorov-Smirnov)
KS衡量的是模型区分好坏样本的能力,计算公式是:
KS = max(累计坏样本占比 - 累计好样本占比)一般标准:
| KS值 | 模型效果 |
|---|---|
| < 0.2 | 差 |
| 0.2-0.3 | 一般 |
| 0.3-0.4 | 良好 |
| 0.4-0.5 | 优秀 |
| > 0.5 | 极好,但需警惕 |
AUC
AUC是ROC曲线下的面积,衡量的是模型排序能力。风控里一般要求AUC在0.7以上。
PSI(Population Stability Index)
PSI衡量的是模型分数在不同时间段的分布稳定性:
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(ascending=False).head(20))跑完这几行,你大概能知道:
- 数据量多大
- 违约率多少
- 哪些字段缺失严重
我一般会再画几个图:
# 标签分布 df['target'].value_counts().plot(kind='bar') plt.title('Target Distribution') plt.show() # 数值变量分布 num_cols = df.select_dtypes(include=[np.number]).columns df[num_cols].hist(figsize=(20, 15), bins=30) 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(f"Train: {train.shape}, Test: {test.shape}") print(f"Train bad rate: {train['target'].mean():.4f}") print(f"Test 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(name=var, dtype="numerical", solver="cp", max_n_bins=5) 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], metric="woe") test[var + '_woe'] = optb.transform(test[var], metric="woe")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(C=0.1, penalty='l2', solver='lbfgs', max_iter=1000) 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(f"Train AUC: {roc_auc_score(train['target'], train_pred):.4f}") print(f"Test AUC: {roc_auc_score(test['target'], test_pred):.4f}")评分卡转换的公式:
Score = A - B × ln(odds)其中:
odds = p / (1-p),p是违约概率A和B是常数,通常设定为:当odds=1时,Score=600;当odds每翻一倍,Score减少50分
# 评分卡转换 def scorecard(pred_prob, A=600, B=50): 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(f"Train Score Range: {train_score.min():.0f} - {train_score.max():.0f}") print(f"Test 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_estimators=500, learning_rate=0.05, max_depth=5, num_leaves=31, subsample=0.8, colsample_bytree=0.8, random_state=42 ) 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(f"LightGBM Train AUC: {roc_auc_score(y_train, lgb_train_pred):.4f}") print(f"LightGBM 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_type="bar") 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(f"LR Test KS: {calculate_ks(y_test, test_pred):.4f}") print(f"LGB Test KS: {calculate_ks(y_test, lgb_test_pred):.4f}")PSI计算:
def calculate_psi(expected, actual, buckets=10): # 等频分箱 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也是白搭。
这个项目后续还可以扩展的方向包括:引入图神经网络挖掘团伙欺诈、用生存分析做动态违约预测、结合文本数据做非结构化特征提取。但不管怎么扩展,样本定义、特征工程、跨时间验证这三板斧,永远是风控模型的根基。