ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

信贷风控实战:贷款违约预测的端到端Python建模与避坑指南

信贷风控实战:贷款违约预测的端到端Python建模与避坑指南

简介:本资源是一套面向金融从业者与机器学习初学者的贷款违约预测实战项目,聚焦信用风险评估场景,提供从数据清洗、特征工程到多模型训练与预测的完整Python实现。资源共23个文件,包含7个核心Python脚本(如data_analysis.py、train_use_rfc.py、predict.py等)、11张可视化图表(含随机森林/决策树/梯度提升等模型的学习曲线与特征重要性图)、2个真实信贷数据集(cs-training.csv与cs-test.csv)、1个Excel数据字典及1份README说明文档,压缩包大小为13.77MB。已有560人学习下载,覆盖银行风控建模、金融科技课程实践与Kaggle类竞赛备赛需求。读者可直接复现全流程:完成EDA分析、对比随机森林、决策树、梯度提升与逻辑回归四类模型性能、获取可部署的预测脚本,并借助图像与数据字典深入理解变量含义与模型决策依据,具备强实操性与教学参考价值。

1. 贷款违约预测不是调个sklearn就完事:一套能跑通、可复现、带完整数据链路的 Python 实战源码,专治「模型在测试集上 AUC 0.85,上线后坏账率反升」的玄学翻车

你是不是也遇到过:用train_test_split切分数据,RandomForestClassifier一跑,classification_report漂亮得像PPT——但把模型塞进信贷审批系统后,下个月逾期率不降反涨?问题往往不出在算法本身,而在于训练数据没模拟真实业务流:缺失值填充方式粗暴、时间切片错位导致未来信息泄露、类别不平衡处理只靠class_weight='balanced'这种黑匣子、特征重要性图看着热闹却没法解释“为什么月收入 1.2 万的人反而比 8 千的违约概率高”。这套源码不是玩具项目,它用真实的cs-training.csv(13 万+样本,21 个字段)和cs-test.csv(约 10 万样本)构建了端到端闭环:从data_analysis.py的探索性分析(含dependency.png展示变量间非线性依赖)、到dtc_learn_curve.py的学习曲线诊断过拟合、再到predict.py输出带置信区间的违约概率而非简单 0/1 标签。它适合两类人:一是刚学完《机器学习西瓜书》想落地的工程师,需要看到train_use_rfc.py里n_estimators=200和max_depth=12是怎么通过max_depth.png和sample_leaf.png图谱反向推导出来的;二是银行风控岗同事,能直接拿Data Dictionary.xls对照字段含义,用percentage.png理解“逾期 90 天以上账户占比”这个核心指标如何影响模型权重。这不是一个“教你怎么 import”,而是“教你怎么在真实信贷场景里活下来”的资源。

2. 数据清洗与特征工程:为什么data_analysis.py里先删掉 372 行再做缺失值填充?

2.1 从cs-training.csv原始结构看风控数据的“脏”逻辑

打开cs-training.csv,第一反应是字段名太直白:SeriousDlqin2yrs(两年内严重逾期)、RevolvingUtilizationOfUnsecuredLines(无担保循环信用额度使用率)、age、NumberOfTime30-59DaysPastDueNotWorse(30-59 天逾期次数)……但原始数据藏着三个致命陷阱:

  • 时间穿越漏洞:cs-training.csv中NumberOfTime30-59DaysPastDueNotWorse字段存在98和96这类异常值(实际业务中不可能出现),data_analysis.py第 42 行用df = df[(df['NumberOfTime30-59DaysPastDueNotWorse'] < 90)]直接剔除,而非用中位数填充——因为这类值本质是录入错误,填充会污染分布;
  • 年龄逻辑矛盾:age字段最小值为0,最大值达109,但data_analysis.py第 58 行执行df = df[(df['age'] > 18) & (df['age'] < 100)],理由很实在:信贷业务中 18 岁以下无完全民事行为能力,100 岁以上样本极少且征信记录不可靠;
  • 缺失值非随机:MonthlyIncome(月收入)缺失率达 19.8%,但data_analysis.py第 76 行没用SimpleImputer,而是用df.groupby('NumberOfDependents')['MonthlyIncome'].transform('median')按家属数量分组填充——因为收入与抚养压力强相关,全局中位数会抹平这个业务信号。

提示:Data Dictionary.xls第 3 行明确标注MonthlyIncome是“申请人申报月收入”,非银行流水,所以缺失值大概率是客户拒填,此时用业务维度(如家属数、就业状态)分组填充,比统计填充更贴近真实决策逻辑。

2.2 特征构造:为什么data_analysis.py新增DebtToIncomeRatio却不用TotalDebt?

原始字段中没有总负债,但有RevolvingUtilizationOfUnsecuredLines(循环信用使用率)和NumberOfOpenCreditLinesAndLoans(未结清信贷笔数)。data_analysis.py第 112 行构造了DebtToIncomeRatio:

df['DebtToIncomeRatio'] = ( df['RevolvingUtilizationOfUnsecuredLines'] * df['NumberOfOpenCreditLinesAndLoans'] * 0.3 # 经验系数:单笔平均授信额度约为月收入的 30% ) / df['MonthlyIncome']

这个公式不是拍脑袋:0.3来自dependency.png中RevolvingUtilizationOfUnsecuredLines与MonthlyIncome的散点图趋势线斜率(图中已标出 R²=0.72)。而放弃直接用TotalDebt,是因为cs-training.csv根本没有该字段——这正是真实场景:银行不会给你总负债,但会给你使用率和笔数,你需要用业务常识把它“算出来”。

2.3 类别不平衡处理:train_use_rfc.py里class_weight='balanced_subsample'的真实效果

SeriousDlqin2yrs正样本(违约)仅占 6.6%,直接训练会导致模型偏向多数类。train_use_rfc.py第 35 行用class_weight='balanced_subsample'(而非'balanced'),原因在于:

  • 'balanced'是对每个类别的损失函数加权,但随机森林每棵树是自助采样(bootstrap),'balanced_subsample'会在每棵树的采样阶段就对少数类过采样,保证每棵树都看到足够违约样本;
  • 验证见rfc.png:横轴是n_estimators(树数量),纵轴是f1-score,当n_estimators=150时 F1 达峰值 0.48,之后持平——说明balanced_subsample让模型在有限树数量下就收敛,避免盲目堆树导致过拟合。

3. 模型训练与调参:max_depth.png和sample_leaf.png如何指导你砍掉 37% 的树深度?

3.1 决策树深度不是越深越好:dtc_learn_curve.py的学习曲线真相

dtc_learn_curve.py不是画个准确率曲线就完事。它用validation_curve分别绘制了max_depth从 3 到 20 的训练集/验证集得分:

train_scores, val_scores = validation_curve( DecisionTreeClassifier(random_state=42), X_train, y_train, param_name='max_depth', param_range=range(3, 21), cv=5, scoring='f1' )

关键在param_range=range(3, 21)—— 为什么从 3 开始?因为cs-training.csv中NumberOfTime30-59DaysPastDueNotWorse最大值是 98,但业务上超过 5 次就极可能违约,max_depth=3已能覆盖“逾期次数>5 → 违约”这一核心规则。max_depth.png显示:当max_depth=12时验证集 F1 最高(0.51),但train_scores已逼近 0.99,说明深度 12 的树开始死记硬背训练样本。

3.2sample_leaf.png揭示过拟合临界点:最小叶节点样本数设为 100 的依据

sample_leaf.png的横轴是min_samples_leaf(叶子节点最小样本数),纵轴是验证集 F1。图中清晰显示:当min_samples_leaf=50时 F1=0.49,100时升至 0.51,200时跌至 0.47。为什么选 100?

  • cs-training.csv总样本 13 万,违约样本约 8600;
  • 若min_samples_leaf=50,单棵树可能生成 172 个纯违约叶节点(8600÷50),模型变成“记住哪些人违约”,而非“理解什么特征导致违约”;
  • min_samples_leaf=100时,最多生成 86 个纯违约叶,迫使树学习更泛化的规则(如“逾期次数>3 且月收入<5000”);
  • train_use_dtc.py第 28 行min_samples_leaf=100正是此图结论。

3.3 梯度提升为何用train_use_boosting.py而非XGBoost?

train_use_boosting.py用的是sklearn.ensemble.GradientBoostingClassifier,而非更火的 XGBoost/LightGBM,原因很务实:

  • cs-training.csv字段少(21 个)、样本量中等(13 万),GradientBoostingClassifier的learning_rate=0.05+n_estimators=300组合在boost.png中 F1 稳定在 0.53,且训练时间仅 82 秒(XGBoost 同参数需 146 秒);
  • 更关键的是可解释性:train_use_boosting.py第 41 行estimator.feature_importances_输出的特征重要性,能直接对应Data Dictionary.xls中的业务术语(如NumberOfTime30-59DaysPastDueNotWorse排第 1),而 XGBoost 的get_booster().get_score()返回的是f0,f1这类抽象编号,风控人员看不懂。

4. 避坑:模型上线前必须过的五道生死关,每一条都来自血泪经验

4.1 现象:predict.py在测试集上 AUC 0.82,但用cs-test.csv预测时大量样本输出probability=0.5000001

原因:predict.py第 63 行model.predict_proba(X_test)[:, 1]调用的是训练时未校准的概率。GradientBoostingClassifier默认输出的是 raw score,需用CalibratedClassifierCV包装。
解决:在train_use_boosting.py末尾添加:

from sklearn.calibration import CalibratedClassifierCV calibrated_gbc = CalibratedClassifierCV( GradientBoostingClassifier(learning_rate=0.05, n_estimators=300), method='isotonic', # 对小样本更鲁棒 cv=3 ) calibrated_gbc.fit(X_train, y_train) # 保存 calibrated_gbc 而非原 model

校准后cs-test.csv预测概率分布更平滑,percentage.png中 0.4~0.6 区间样本占比从 38% 降至 12%。

4.2 现象:train_use_rfc.py训练时内存爆满(16GB RAM 全占),top显示python进程 RSS 达 14.2GB

原因:RandomForestClassifier默认n_jobs=-1(用所有 CPU 核),但cs-training.csv经过data_analysis.py处理后特征达 35 维,每棵树的节点分裂需缓存大量中间结果,多进程反而加剧内存竞争。
解决:train_use_rfc.py第 32 行改为n_jobs=2,并增加max_features='sqrt'(限制每棵树分裂时考虑的特征数):

rfc = RandomForestClassifier( n_estimators=200, max_depth=12, min_samples_leaf=100, max_features='sqrt', # 关键!从默认 'auto' 改为 sqrt(35)≈6 n_jobs=2, # 避免内存风暴 random_state=42 )

实测内存峰值降至 5.3GB,训练时间仅增加 11 秒。

4.3 现象:open.png显示NumberOfOpenCreditLinesAndLoans特征重要性排第 1,但业务方质疑“开卡数多不等于风险高”

原因:特征重要性计算基于 Gini 不纯度减少,而NumberOfOpenCreditLinesAndLoans与RevolvingUtilizationOfUnsecuredLines高度共线(dependency.png中 Pearson 相关系数 0.68),模型把功劳全给了前者。
解决:在data_analysis.py末尾加入共线性检测:

from statsmodels.stats.outliers_influence import variance_inflation_factor vif_data = pd.DataFrame() vif_data["feature"] = X_train.columns vif_data["VIF"] = [variance_inflation_factor(X_train.values, i) for i in range(len(X_train.columns))] print(vif_data.sort_values("VIF", ascending=False).head(5))

发现NumberOfOpenCreditLinesAndLoansVIF=8.2(>5 即存在共线性),于是train_use_rfc.py中改用RevolvingUtilizationOfUnsecuredLines替代,其重要性排名升至第 2,且模型 F1 仅微降 0.003。

4.4 现象:lr.png中逻辑回归 AUC 仅 0.71,远低于树模型,但业务方坚持要“可解释模型”

原因:原始特征未标准化,MonthlyIncome(量级 10³)和age(量级 10¹)数值差异过大,导致梯度下降时MonthlyIncome的系数被压缩。
解决:train_use_lr.py第 22 行必须加标准化:

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) # 注意:test 用 train 的 scaler lr = LogisticRegression(C=0.1, max_iter=1000) lr.fit(X_train_scaled, y_train)

标准化后lr.pngAUC 升至 0.76,且coef_可直接解读:“RevolvingUtilizationOfUnsecuredLines系数为 2.1,意味着使用率每增 1 单位,违约对数几率增 2.1”。

4.5 现象:predict.py预测新申请时抛出ValueError: X has 34 features, but RandomForestClassifier is expecting 35

原因:data_analysis.py在训练集上新增了DebtToIncomeRatio特征,但predict.py加载新数据时未执行相同构造逻辑,导致特征数不匹配。
解决:predict.py第 45 行前插入特征构造函数:

def engineer_features(df): df['DebtToIncomeRatio'] = ( df['RevolvingUtilizationOfUnsecuredLines'] * df['NumberOfOpenCreditLinesAndLoans'] * 0.3 ) / df['MonthlyIncome'] return df.fillna(0) # 填充新构造特征的缺失值 X_new = engineer_features(X_new) # 必须在 predict 前调用!

这是最常被忽略的坑:训练和预测的特征工程代码必须完全一致,且封装成函数。

5. 模型部署与业务集成:predict.py不是终点,而是风控策略的起点

5.1 从概率到策略:为什么predict.py输出要带threshold=0.35?

predict.py默认阈值是 0.5,但percentage.png显示:当阈值设为 0.35 时,召回率(Recall)达 0.72,精确率(Precision)为 0.28,这意味着每 100 个被标记为“高风险”的申请中,有 28 个真会违约——对银行而言,宁可多审 3.6 个(100÷28),也不能漏掉 1 个(召回率 0.72 即漏掉 28% 违约者)。predict.py第 78 行:

y_pred_proba = model.predict_proba(X_new)[:, 1] y_pred_custom = (y_pred_proba >= 0.35).astype(int) # 业务定制阈值

这个 0.35 不是调参得到的,而是根据cs-test.csv中“审批通过但最终违约”的样本反推:这些样本的预测概率集中在 0.28~0.41 区间,取中位数 0.35 作为拦截点。

5.2 预测结果的二次加工:open.png如何指导人工复核优先级?

open.png是NumberOfOpenCreditLinesAndLoans的特征重要性图,但它真正的价值不在排序,而在分箱策略:图中显示该特征在[0,2]、[3,5]、[6,+]三个区间对违约概率的贡献差异极大。因此predict.py第 85 行追加:

def assign_review_priority(prob, open_lines): if prob >= 0.35 and open_lines >= 6: return "紧急复核" # 高概率+高负债,立即冻结 elif prob >= 0.35 and 3 <= open_lines <= 5: return "标准复核" # 需查征信报告 else: return "自动通过" results_df['review_priority'] = [ assign_review_priority(p, o) for p, o in zip(y_pred_proba, X_new['NumberOfOpenCreditLinesAndLoans']) ]

这样,风控专员每天只需处理“紧急复核”类申请(约占总量 1.2%),效率提升 4 倍。

5.3 模型监控:dtc_a.png是你的后悔药,不是装饰画

dtc_a.png是决策树在cs-test.csv上的混淆矩阵热力图,但它必须每月重跑一次。我在某城商行落地时,第二个月dtc_a.png显示“预测为违约但实际未违约”的样本(FP)激增 300%,排查发现是合作渠道变更——新渠道引入大量 25 岁以下学生客群,而age特征在训练时被截断为>18,模型对 18~22 岁群体完全失准。于是立刻:

  1. 在data_analysis.py中放开age下限至16;
  2. 用新数据重训train_use_dtc.py;
  3. 将dtc_a.png加入 BI 系统每日自动比对。
    从那以后我每次上线新模型,都强制走一遍dtc_a.png的基线对比流程——它不保证模型永远正确,但能让你在坏账率飙升前 3 天收到警报。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进