简介:一份面向机器学习初学者与金融风控从业者的贷款违约预测实践源码,基于Python实现完整建模流程。项目包含随机森林、决策树、梯度提升等多种模型训练与对比,附带数据分析与预测脚本,覆盖数据预处理、特征探索、模型评估等关键环节,适合用于信用评分与信贷风险管理场景。压缩包共23个文件,由7个Python源码、11张PNG可视化图、2个CSV数据集、1个Excel字典文件及说明文档组成,整体约13.77MB,目录结构清晰,便于按模块复现实验。已有560人学习下载。通过该资源可掌握贷款数据清洗、模型调参与结果解读的整套思路,既可作为课程设计参考,也能为金融机构的风险评估提供可扩展的建模基线。
1. 贷款违约预测的机器学习项目到底在解决什么问题
银行和消费金融公司每天都要回答同一个问题:这笔钱借出去,对方还会不会还?传统做法是人审加规则评分,但人工审核覆盖不了海量进件,规则分数卡又难以捕捉借款人复杂的行为模式。贷款违约预测就是把这个决策过程交给机器学习,用历史借贷数据训练一个分类器,在放款前给出违约概率。
这个任务和一般分类问题的最大区别在于样本极不平衡——好客户通常占九成以上,坏客户可能只有百分之二三;欠得少的短期逾期和彻底坏账在业务上又是两回事。所以项目里的每一项设计,从特征构造、样本切分到阈值选择,都要围绕这个不平衡展开。适合阅读这篇文章的读者是刚接触信贷风控的数据分析师、想系统走一遍机器学习应用流程的Python工程师,以及需要从零搭建一个可解释风控Demo的团队。标题里提到的源码,常见做法就是把特征工程、模型训练、评估和决策输出拆成模块,下面按这一套方案逐步展开。
2. 贷款违约预测的特征工程:分箱、WOE编码与IV筛选
2.1 为什么贷款违约预测不直接喂原始特征
信贷数据里既有年龄、收入这类连续值,也有婚姻状况、职业、住房类型这类类别变量。直接把原始数值丢给机器学习模型并不是不可以,但可解释性和稳定性都会打折扣:极端值会让逻辑回归系数失真,类别出现频率不均时one-hot会产生大量稀疏列。更现实的问题在于,很多字段是缺失的,直接丢弃会损失信息。
业界常见的做法是做分箱加WOE编码。WOE(Weight of Evidence)衡量的是每个分箱内坏客户分布与好客户分布的差异,本质上是把原始特征转换成对数胜算比,让特征与目标之间呈现单调的线性关系。这在评分卡场景里有几十年的成熟应用,逻辑回归输入WOE值后,每一项的系数乘以WOE再求和,就能还原成一张加加减减的评分表。IV(Information Value)则用来衡量特征整体预测力,IV小于0.02通常视为无用变量,0.1以上才有保留价值。
2.2 分箱代码与参数设置
import pandas as pd import numpy as np def equal_freq_bin(series, bins=5, target=None): """等频分箱:让每个箱内的样本量尽量接近""" try: # qcut按分位数切分,duplicates='drop'处理切分点重复 cut, bins_edges = pd.qcut(series, q=bins, retbins=True, duplicates='drop') except ValueError: # 分位数相同导致切分失败时退化为等宽分箱 cut, bins_edges = pd.cut(series, bins=bins, retbins=True, include_lowest=True) df = pd.DataFrame({'feature': series, 'bin': cut, 'label': target}) grouped = df.groupby('bin', observed=False)['label'].agg(['sum', 'count']) grouped.columns = ['bad', 'total'] grouped['good'] = grouped['total'] - grouped['bad'] # 加平滑项避免除零 grouped['bad_rate'] = (grouped['bad'] + 0.5) / (grouped['total'] + 1) return grouped, bins_edges def calc_woe_iv(grouped): """根据分箱统计计算WOE和IV""" total_bad = grouped['bad'].sum() total_good = grouped['good'].sum() grouped['bad_dist'] = grouped['bad'] / total_bad grouped['good_dist'] = grouped['good'] / total_good # WOE = ln(坏客户分布 / 好客户分布) grouped['woe'] = np.log((grouped['bad_dist'] + 1e-6) / (grouped['good_dist'] + 1e-6)) # IV = Σ(坏分布 - 好分布) * WOE grouped['iv'] = (grouped['bad_dist'] - grouped['good_dist']) * grouped['woe'] return groupedqcut是等频分箱的核心,按分位数切分能让每个箱内样本数大致相等,避免某个箱只有零星几条导致WOE波动剧烈。duplicates='drop'是必填的,因为当特征有大量重复值时,分位数会返回相同边界,不处理直接报错。groupby(..., observed=False)是pandas 2.0以上针对类别型分组的新默认行为,防止切分后出现空箱告警。落在bad_rate里的+0.5平滑项来自贝叶斯估计,避免某些箱坏样本为0时算出的WOE无穷大。
2.3 特征筛选的数值尺子
这里有个常见误区:IV越好越要保留。实际上IV过高(超过0.5)的变量要警惕,它往往意味着字段本身包含了目标信息,比如“是否被法院执行”这种强制度变量,在线上申请时根本拿不到同源数据,训练时放进模型会造成严重的样本偏差。我一般会先看IV排序,再逐个核对字段的获取时点,只有申请时点之前就能拿到的变量才有资格进入模型。
| IV区间 | 预测力 | 处理建议 |
|---|---|---|
| < 0.02 | 几乎没有 | 直接剔除 |
| 0.02 ~ 0.1 | 较弱 | 结合业务判断保留 |
| 0.1 ~ 0.3 | 中等 | 优先保留 |
| 0.3 ~ 0.5 | 较强 | 保留并做相关性检查 |
| > 0.5 | 异常 | 查数据来源,警惕未来函数 |
还有一类变量不在上表里:缺失率超过70%的字段,无论IV多高都要先跟业务确认缺失原因。如果缺失本身是随机发生的,可以考虑单独用“是否缺失”作为一个二值特征交给模型学;如果缺失代表着某种拒绝策略,这个字段就直接废弃。
2.4 类别特征与时间特征的处理
类别特征的处理需要单独提出,因为贷款场景里的类别和图像分类的类别不同,它是“低频但重要”的。婚姻状态、学历这种高基数类别(学历有几十种学校类型时要小心)不适合直接one-hot。常见做法是把类别合并成“好客户占比”的统计编码,然后和WOE做一次线性变换。实操上我会额外构造一个“该类别在训练集出现次数”的字段,用来抑制冷门类别的过拟合,这在机器学习分类器里是比单纯编码更稳的技巧。
时间特征容易被忽略,但信贷数据里时间就是风险本身。借款期限、历史平均使用额度的时间窗口长度、距离上次申请的天数,这些特征决定了模型能不能识别出“突然缺钱”的信号。连续值特征全部做分箱再WOE编码之后,逻辑回归会变得异常稳定,这也是为什么很多金融公司仍然没有放弃逻辑回归的原因——特征工程吸收非线性,模型只负责线性加权,出了坏账还能追查是哪个分箱出了问题。
3. 贷款违约预测的模型选型与训练:逻辑回归还是XGBoost
3.1 分类器选择的两个极端
在贷款违约预测这个具体问题里,模型选型通常在两极之间摇摆。一端是逻辑回归,简单透明,系数直接对应风险因子,监管审计时能讲清楚“为什么拒绝这个人”;另一端是梯度提升树,如XGBoost、LightGBM,非线性拟合能力强,在相同特征下通常比LR高出几个点的AUC。中间地带的随机森林在信贷场景里反而少见,因为它的预测是大量树投票的结果,解释性弱于LR,精度又难和梯度提升拉开差距,调试成本却不低。
我的建议是两条腿走路:先用逻辑回归搭一个基线,跑通全流程和特征有效性验证;再用XGBoost做精度上限测试。如果XGBoost相对LR的提升不足1%的AUC,生产环境就继续用LR,毕竟线上稳定性和排查问题的成本都更低。反之,如果差距明显,就要考虑上树模型,并在解释层面对特征做SHAP归因。
3.2 训练集与验证集的切分原则
贷款违约预测有一个有别于普通机器学习任务的切分原则:不能随机打乱切分。信贷模型训练的是“过去预测未来”,如果随机抽样,训练集里混入未来时间段的数据,等于提前泄露了宏观环境变化的信息。正确做法是按时序切分:用历史24个月的数据训练,最近3个月做验证。更严格一点,还需要把验证集再按放款月份分层,分别看每个月的KS值是否稳定。
from sklearn.model_selection import TimeSeriesSplit from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score import numpy as np import pandas as pd # 假设df已按申请日期排序,且特征已经完成WOE编码 X = df[feature_columns].values y = df['is_default'].values # TimeSeriesSplit:保证训练集永远在验证集之前 tscv = TimeSeriesSplit(n_splits=4) auc_scores = [] for train_index, val_index in tscv.split(X): X_train, X_val = X[train_index], X[val_index] y_train, y_val = y[train_index], y[val_index] # class_weight='balanced':缓解正负样本不平衡 lr = LogisticRegression( C=1.0, solver='liblinear', class_weight='balanced', max_iter=200 ) lr.fit(X_train, y_train) y_pred_proba = lr.predict_proba(X_val)[:, 1] auc_scores.append(roc_auc_score(y_val, y_pred_proba)) print(f"各折AUC: {np.round(auc_scores, 4)}") print(f"平均AUC: {np.mean(auc_scores):.4f}")TimeSeriesSplit不是简单地把数据切成几份,而是保证第n折的训练集包含前n-1折的所有数据,验证集永远在时间上更靠后。class_weight='balanced'让逻辑回归内部的损失函数自动放大少数类样本的惩罚,对于违约率只有3%的数据集来说,这是性价比最高的不平衡处理方案,比上采样更不容易过拟合。solver='liblinear'是二分类小数据集的首选,坐标下降法对L2正则的收敛比lbfgs更稳。
3.3 XGBoost的超参数搜索与早停
XGBoost在这个任务上要想打败逻辑回归,关键不在树的数量,而在以下几个参数:
import xgboost as xgb from sklearn.model_selection import GridSearchCV xgb_model = xgb.XGBClassifier( objective='binary:logistic', eval_metric='auc', learning_rate=0.05, max_depth=4, subsample=0.8, colsample_bytree=0.8, n_estimators=500, reg_alpha=0.1, reg_lambda=1.0, random_state=42 ) param_grid = { 'max_depth': [3, 4, 5], 'min_child_weight': [1, 3, 5], 'learning_rate': [0.01, 0.05, 0.1] } grid = GridSearchCV( xgb_model, param_grid, scoring='roc_auc', cv=3, n_jobs=-1, verbose=0 ) grid.fit(X_train, y_train) print(f"最佳参数: {grid.best_params_}")max_depth限制在5以内是刻意为之。信贷特征在分箱WOE编码后是平滑的,过深的树容易记住分箱边界上的噪声。min_child_weight在XGBoost里是“叶子节点所需的最小样本权重和”,调大它等于强制每个叶子有足够的样本支撑,对抑制违约样本稀疏导致的过拟合效果显著。learning_rate配合n_estimators要一起看:学习率降到0.01时,树的数量通常要上千才能收敛,训练时间和收益不成正比,所以0.05是一个折中点。
实际调参时我还会固定一个评估集,开启XGBoost自带的早停机制,找到最优迭代轮数后再用这个轮数重新在完整训练集上拟合,这比盲调n_estimators更省时间。网格搜索在这个场景里别把范围开太大,三组参数、每组三个取值足够覆盖最优区间,再多的组合只是把时间浪费在相同精度的区域。
3.4 样本不平衡是否要做重采样
不少教程会把SMOTE、随机过采样当作贷款违约预测的标配,但信贷场景里要谨慎。违约样本的生成机制和普通分类不同——违约是“一段时间后的结果”,不是“当下的属性”。对少数类做过采样,相当于人为重复了某些借款人的记录,模型会把这些重复样本的模式当作普遍规律,一旦这批人在时间上是聚类的,过采样就会放大周期性风险。
提示:在金融风控里,优先考虑调整
class_weight和决策阈值;重采样只在AUC提升明显且经过时间外样本验证后才引入。
实践中的经验是,SMOTE用在AUC提升上通常不超过0.005,而它带来的分布偏移风险却是实实在在的。如果坚持要用,务必把过采样放在TimeSeriesSplit的每一折内部去执行,让训练集和验证集保持真实的时序隔离。
4. 贷款违约预测的结果评估与阈值选择:不只是看准确率
4.1 为什么准确率在这里没有参考价值
假设违约率是3%,一个把所有借款人都预测为“好客户”的模型,准确率高达97%。但这个模型没有任何风控价值。贷款违约预测关注的是能否把有限的坏账损失降低,评估指标必须聚焦在排序能力和区分度上。AUC是常用的排序指标,它衡量“随机抽一个好客户和一个坏客户,模型把坏客户排在前面”的概率,不受阈值影响,适合初选模型。但AUC也有盲区:它在整体排序上打分,如果模型在头部1%的区间段区分度差,AUC可能依然好看。
所以要叠加KS值。KS是风控领域最常用的指标,计算方式是把样本按预测分值从低到高排列后,分成十等份,在每一档上计算累计好客户占比和累计坏客户占比的差,取最大差值。KS在0.2以上模型可用,0.4以上属于优秀。它比AUC更直观的地方在于,KS直接告诉你模型在哪个分数区间区分能力最强。
4.2 阈值选择的代价敏感分析
模型输出的是违约概率,但放款决策只有“通过”和“拒绝”两种。阈值的选定不是拍脑袋定一个0.5,而是要结合业务成本。拒掉一个好客户,损失的是这笔贷款的利息收入;放过一个坏客户,损失的是本金加利息。两边成本不一样,阈值就应该往成本高的一边偏移。
# 代价矩阵:假设好客户收益 = 贷款金额 * 利率差 # 坏客户损失 = 贷款金额 * (1 - 回收率) loan_amount = 10000 # 单笔贷款金额 interest_margin = 0.12 # 年化净息差 loss_given_default = 0.6 # 违约损失率,即坏账收回后净损失比例 # 预测概率和真实标签 # y_val为验证集真实标签(1=违约),y_pred_proba为模型输出概率 results = pd.DataFrame({ 'true_label': y_val, 'pred_proba': y_pred_proba }) # 在0.1到0.5之间扫描阈值 thresholds = np.arange(0.10, 0.51, 0.01) profit_records = [] for thr in thresholds: results['pred_label'] = (results['pred_proba'] >= thr).astype(int) # 放行且未违约:赚利息 good_approved = (results['pred_label'] == 0) & (results['true_label'] == 0) # 放行且违约:损失本金 bad_approved = (results['pred_label'] == 0) & (results['true_label'] == 1) # 拒绝且违约:本来会损失,现在规避 bad_rejected = (results['pred_label'] == 1) & (results['true_label'] == 1) total_profit = ( good_approved.sum() * loan_amount * interest_margin - bad_approved.sum() * loan_amount * loss_given_default ) profit_records.append({'threshold': thr, 'total_profit': total_profit}) profit_df = pd.DataFrame(profit_records) best_idx = profit_df['total_profit'].idxmax() print(profit_df.loc[best_idx])这段代码的核心逻辑是把“通过”和“拒绝”拆成四种业务结果分列计算,然后把阈值当作一个业务参数去优化,而不是统计参数。loss_given_default在真实业务里不是一个固定值,不同担保方式、不同抵押物会有很大差异,要按产品线分开测算。我在实际项目中会把利润率甚至资金占用成本都折算进代价矩阵,做完这步之后,模型的输出就不再是一个抽象概率,而是一张“放款边界评分卡”。
4.3 机器学习模型的稳定性和可解释性验证
阈值选定之后,模型上线前还要过稳定性验证这一关。常用的是PSI(Population Stability Index),它衡量模型打分在训练样本和上线后新样本之间的分布偏移程度。PSI在0.1以内表示稳定,0.1到0.25需要注意,超过0.25就说明客群发生结构性变化,模型需要重新训练。逻辑回归的场景下,还可直接计算各个特征的PSI,定位具体是哪个变量发生了偏移,这比只看整体分数要有用得多。
可解释性验证则要看特征系数方向是否符合业务直觉。比如“收入”的WOE系数必须为正,“历史逾期次数”的系数必须为负,如果方向反了,大概率是特征编码出了问题,而不是模型发现了一个新规律。这个过程虽然不产出一个量化的指标,但在模型评审时是最常被追问的部分。
5. 用Python组织贷款违约预测源码:从脚本到可复现工程
5.1 源码目录怎么拆分最合理
很多第一次写这类项目的开发者会把所有逻辑堆在一个Jupyter Notebook里,训练一次跑一遍全部流程。Notebook适合探索,不适合落地,因为特征工程、模型训练、参数调优、阈值选择混在一起,任何一步重跑都会产生不同的中间状态。我会把源码按职责拆成四个模块:数据读取与清洗、特征工程、模型训练与评估、决策与输出。
loan_default_prediction/ ├── config/ │ └── config.yaml # 参数配置与阈值设置 ├── data/ │ ├── raw/ # 原始数据(不落库) │ └── processed/ # 中间特征 ├── src/ │ ├── data_loader.py # 数据读取与缺失值处理 │ ├── feature_engineering.py # 分箱、WOE、IV计算 │ ├── model_training.py # LR与XGBoost训练入口 │ ├── model_evaluation.py # AUC、KS、PSI计算 │ └── decision.py # 阈值优化与放款决策 ├── models/ # 训练产物,按日期命名 └── main.py # 流水线入口配置和代码分离是这套结构里最关键的设计。阈值、代价矩阵、分箱数、模型超参全部写进config.yaml,改参数不需要动代码,重跑时也能通过配置文件追溯当时的实验条件。数据文件不放进源码仓库,原始数据按日期归档在独立存储里,processed中间表只保留最新的,避免磁盘膨胀。
5.2 可复现训练的三件套:随机种子、实验记录、模型版本
贷款违约预测的模型需要定期重新训练,如果每次跑出来的结果对不上,后续监控和对比都无从谈起。固定随机种子是最基本的要求,但只固定Python内置的random.seed(42)是不够的,NumPy和XGBoost各自维护独立的随机数生成器,需要分别设置。
import random import numpy as np import xgboost as xgb def set_seed(seed=42): """统一固定各库随机种子""" random.seed(seed) np.random.seed(seed) # XGBoost在sklearn接口里通过random_state传入 xgb.set_config(verbosity=0, use_rmm=True)注意:
xgb.set_config里的use_rmm只在启用RAPIDS内存管理时生效,浅层函数不设置也不影响,真正要固定的是传入XGBClassifier(random_state=seed)的参数。
实验记录可以简化为一个CSV,每跑一次训练就往里追加一行:日期、数据版本、特征列表、模型类型、超参JSON、AUC、KS、阈值、PSI。不要小看这个表,模型上线三个月后出了问题,唯一能快速定位问题范围的就是这份记录。模型文件本身的命名规则建议带时间戳和指标后缀,例如lr_20250115_auc0831.pkl,避免用“final”这种覆盖式命名。
5.3 完整流水线的一次性运行
# main.py 简化版 import yaml import joblib import pandas as pd from src.data_loader import load_data from src.feature_engineering import build_features from src.model_training import train_lr, train_xgb from src.model_evaluation import evaluate_model from src.decision import optimize_threshold def main(): with open('config/config.yaml', 'r') as f: config = yaml.safe_load(f) df = load_data(config['data_path']) features = build_features(df, config['feature_params']) X_train, X_val, y_train, y_val = features['train_valid_split'] which_model = config['model']['name'] if which_model == 'lr': model = train_lr(X_train, y_train, config['model']['params']) elif which_model == 'xgb': model = train_xgb(X_train, y_train, config['model']['params']) metrics = evaluate_model(model, X_val, y_val) threshold = optimize_threshold(model, X_val, y_val, config['cost_matrix']) joblib.dump(model, f"models/{which_model}_{config['data_version']}.pkl") pd.Series(metrics).to_csv("models/metrics.csv", mode='a', header=False) if __name__ == "__main__": set_seed() main()这个入口函数的职责只是串流程,不包含任何业务逻辑。load_data返回的DataFrame在进入特征工程前必须保证字段齐全且类型已被校验,一个常见的坑是日期列被读成字符串,导致分箱前的排序错乱,所以在data_loader.py里要先做pd.to_datetime()类型转换。
5.4 关于源码开源的三个补全建议
如果在公开渠道发布这套源码,有三处细节需要额外打磨。第一是脱敏,特征字段名不能直接暴露真实业务字段,常见做法是改成f1, f2, f3并附一个字段说明映射表,让使用者能理解特征含义但不触碰敏感信息。第二是替换数据,真实借款数据绝对不能随源码分发,用make_classification或公开数据集生成一个结构类似的模拟数据即可跑通全流程。第三是补充一个快速开始文档,把环境依赖用requirements.txt锁死版本,特别是pandas、scikit-learn和xgboost这三者的版本组合,跨大版本升级会导致WOE分箱边界变化,复现结果对不上。
最后再补一个验证技巧:模型落地前在decision.py里加一个“拒绝推断”的回测逻辑,把模型拒绝掉的那部分样本标记出来,在下一个时间周期观察实际表现。如果被拒样本的违约率明显低于模型预测的违约率,说明阈值定得过于激进,模型对尾部风险的估计偏低,这时就要重新校正概率校准曲线。这一步虽然不直接改进AUC,但能帮你在真实业务里少交学费。
本文还有配套的精品资源,点击获取