ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

随机森林贷款违约预测实战:从特征工程到评分卡落地

随机森林贷款违约预测实战:从特征工程到评分卡落地 简介这份资源是围绕随机森林算法构建贷款违约预测模型的完整项目源码面向计算机相关专业学生及需要项目实战练习的学习者可用于课程设计、期末大作业或算法入门实践。项目经导师指导并获评审98分聚焦信用数据建模这一典型金融风控场景帮助读者理解从数据探索到模型评估的全流程。压缩包共12个文件约5.8MB包含4个csv数据与结果文件、4个ini配置、2个Python脚本以及1个xls表格其中脚本分别承担信用数据分析与模型训练任务csv则记录基准测试与中间结果结构清晰便于复现。目前已有74人学习下载。读者可据此掌握随机森林在违约预测中的特征处理、参数调优与性能对比思路并直接参考高分项目的组织方式与实现细节快速迁移到同类分类任务中。1. 贷款违约预测为什么随机森林在这个场景里比逻辑回归更抗造信贷风控里有一个很反直觉的现象同样一份用户申请表用逻辑回归跑出来的 KS 值可能只有 0.28换成随机森林直接跳到 0.42但把随机森林的树加到 800 棵之后测试集上的表现反而开始往下掉。这不是玄学是违约预测这个场景本身的数据结构决定的——正负样本极度不平衡、特征之间大量非线性交互、缺失值成片出现这三件事叠在一起线性模型天然吃亏而树模型只要参数没调崩下限就很高。这篇笔记讲的就是基于随机森林算法的贷款违约预测模型从数据清洗、特征工程、类别不平衡处理到模型训练、超参数搜索、阈值调优和可解释性输出整条链路我会按自己实际做过的顺序拆开。适合两类人看一类是刚接触风控建模、想拿一个完整项目练手的同学另一类是在业务里已经用过逻辑回归评分卡、想试试树模型能不能把区分度再往上抬一截的从业者。源码包和数据集我会在关键步骤里给出对应的处理逻辑你照着改路径就能跑。2. 数据准备与特征工程把原始申请表变成模型能吃的矩阵2.1 先搞清楚违约预测的数据长什么样贷款违约预测的原始数据通常是一张宽表一行一个借款申请列分成几类用户基本信息年龄、职业类型、教育程度、申请信息贷款金额、期限、利率、历史行为过往逾期次数、查询次数、已有账户数、以及外部征信衍生变量。目标列一般是二值的0 表示正常还款1 表示违约。拿到数据第一件事不是急着喂模型而是先做三件事看正负样本比例、看缺失分布、看每个特征的取值类型。我一般会先跑一段探查代码把这三件事一次性看清楚。import pandas as pd import numpy as np # 读取原始数据假设是 csv 格式 df pd.read_csv(loan_data.csv) # 1. 正负样本比例 print(违约率:, df[default].mean()) print(df[default].value_counts()) # 2. 缺失值分布按缺失比例降序 missing df.isnull().mean().sort_values(ascendingFalse) print(missing[missing 0]) # 3. 特征类型概览 print(df.dtypes.value_counts())这段代码的逻辑很直接default列的均值就是违约率如果只有 3% 到 8%说明是典型的不平衡场景后面必须处理。缺失值排序能帮你快速判断哪些列直接删、哪些列需要填补。类型统计则决定你后面用哪种编码方式。参数上没什么好调的但有一个坑要注意如果default列不是 0/1 而是 Y/N 或者 正常/违约你得先映射成数值不然后面所有模型都跑不了。2.2 缺失值填补和异常值处理的具体策略缺失值处理没有万能方案我的经验是按缺失比例分三档走缺失比例处理方式理由低于 5%中位数或众数填补影响小简单填补即可5% 到 30%单独作为一类或模型预测填补保留信息量避免均值填补引入偏差高于 30%考虑直接删除该列填补噪声太大反而拖累模型异常值方面连续变量用 IQR 方法做截断比直接删除更稳因为风控数据里极端值往往有业务含义删掉可能丢失重要信号。# 缺失值分档处理 for col in df.columns: miss_rate df[col].isnull().mean() if miss_rate 0: continue elif miss_rate 0.05: if df[col].dtype object: df[col].fillna(df[col].mode()[0], inplaceTrue) else: df[col].fillna(df[col].median(), inplaceTrue) elif miss_rate 0.3: # 数值列用中位数填补并加一个缺失标记列 if df[col].dtype ! object: df[col _is_missing] df[col].isnull().astype(int) df[col].fillna(df[col].median(), inplaceTrue) else: df.drop(columns[col], inplaceTrue) # 连续变量 IQR 截断 def iqr_clip(series): q1, q3 series.quantile(0.25), series.quantile(0.75) iqr q3 - q1 lower, upper q1 - 1.5 * iqr, q3 1.5 * iqr return series.clip(lower, upper) num_cols df.select_dtypes(include[np.number]).columns for col in num_cols: if col ! default: df[col] iqr_clip(df[col])这里的关键参数是 1.5 倍 IQR这是箱线图的标准定义。如果你发现截断后模型效果反而下降可以放宽到 3 倍说明那些极端值确实携带了违约信号。2.3 类别特征编码为什么我很少用独热编码贷款数据里的类别特征往往基数不小比如职业类型可能有几十种省份有三十多个。独热编码一上去特征维度直接爆炸随机森林虽然对高维不敏感但训练时间和内存占用会明显上升。我一般用两种方式替代基数低于 10 的用标签编码基数高于 10 的用目标编码。from sklearn.preprocessing import LabelEncoder # 低基数类别特征标签编码 low_card_cols [c for c in df.select_dtypes(includeobject).columns if df[c].nunique() 10] le LabelEncoder() for col in low_card_cols: df[col] le.fit_transform(df[col].astype(str)) # 高基数类别特征目标编码用 K 折防止泄漏 from sklearn.model_selection import KFold def target_encode(df, col, target, n_splits5): df[col _te] np.nan kf KFold(n_splitsn_splits, shuffleTrue, random_state42) for train_idx, val_idx in kf.split(df): means df.iloc[train_idx].groupby(col)[target].mean() df.loc[df.index[val_idx], col _te] df.iloc[val_idx][col].map(means) # 用全局均值填补没覆盖到的类别 df[col _te].fillna(df[target].mean(), inplaceTrue) return df high_card_cols [c for c in df.select_dtypes(includeobject).columns if df[c].nunique() 10] for col in high_card_cols: df target_encode(df, col, default) df.drop(columns[col], inplaceTrue)目标编码最大的坑是数据泄漏如果你直接用全量数据算类别均值再替换模型在训练集上会看到未来信息验证集表现虚高。用 K 折的方式每一折的编码只用其他折的数据算才能模拟真实场景。这个细节很多人翻车血泪经验。3. 随机森林建模从基线到调参的完整路径3.1 为什么选随机森林而不是 XGBoost 或 LightGBM这个问题我被问过很多次。XGBoost 和 LightGBM 在结构化数据上确实经常比随机森林强但随机森林有三个优势在风控场景里很实在第一对超参数不敏感默认参数就能跑出一个不差的结果适合快速出基线第二不容易过拟合因为它是 Bagging 思路方差天然比 Boosting 小第三可解释性工具成熟特征重要性和部分依赖图都很直观。如果你的数据量在十万行以内、特征在几百个以内随机森林完全够用。数据量再大、追求极致效果再考虑上 Boosting 系列。我一般会先用随机森林出一个基线 KS然后再用 LightGBM 对比如果提升不到 2 个点就继续用随机森林省得调参调到头秃。3.2 基线模型训练与类别不平衡处理随机森林在 sklearn 里有一个class_weight参数直接设成balanced就能自动按类别频率加权这是处理不平衡最省事的方式。另一个思路是用 SMOTE 做过采样但我个人更倾向先用class_weight因为它不改变数据分布只是改变分裂时的权重计算。from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score, roc_curve # 分离特征和目标 X df.drop(columns[default]) y df[default] # 分层抽样保证训练集和测试集违约率一致 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, stratifyy, random_state42 ) # 基线随机森林 rf_base RandomForestClassifier( n_estimators200, max_depthNone, min_samples_split2, min_samples_leaf1, class_weightbalanced, random_state42, n_jobs-1 ) rf_base.fit(X_train, y_train) # 评估 y_prob rf_base.predict_proba(X_test)[:, 1] auc roc_auc_score(y_test, y_prob) print(基线 AUC:, round(auc, 4)) # 计算 KS fpr, tpr, thresholds roc_curve(y_test, y_prob) ks max(tpr - fpr) print(基线 KS:, round(ks, 4))这段代码里几个参数值得说清楚n_estimators200是基线常用值太少方差大太多训练慢且收益递减class_weightbalanced让模型自动把少数类的权重调高stratifyy保证切分后两边违约率一致不然测试集可能全是正常样本评估就失真了。AUC 和 KS 是两个最常用的指标。AUC 衡量排序能力KS 衡量最大区分度。风控里 KS 更受关注一般 0.3 以上算可用0.4 以上算不错。3.3 超参数搜索网格搜索和随机搜索怎么选随机森林要调的核心参数就四个n_estimators、max_depth、min_samples_split、min_samples_leaf。网格搜索适合参数空间小的情况随机搜索在参数多的时候效率更高。我一般先用随机搜索粗筛再用网格搜索在最优区域精调。from sklearn.model_selection import RandomizedSearchCV from scipy.stats import randint param_dist { n_estimators: randint(100, 600), max_depth: [None, 5, 8, 12, 16], min_samples_split: randint(2, 20), min_samples_leaf: randint(1, 10), max_features: [sqrt, log2, 0.5] } rf RandomForestClassifier( class_weightbalanced, random_state42, n_jobs-1 ) search RandomizedSearchCV( rf, param_dist, n_iter50, cv5, scoringroc_auc, random_state42, n_jobs-1 ) search.fit(X_train, y_train) print(最优参数:, search.best_params_) print(最优 AUC:, round(search.best_score_, 4))n_iter50表示随机采样 50 组参数组合cv5是五折交叉验证。如果你的数据量很大可以把n_iter降到 30cv降到 3先跑出一个大致方向。max_features这个参数容易被忽略它控制每棵树分裂时考虑的特征数sqrt是分类任务的默认值但在特征相关性高的时候调大一点可能更好。4. 避坑与排查那些让模型效果突然崩掉的细节4.1 数据泄漏验证集 AUC 0.95 但上线就废现象本地交叉验证 AUC 稳定在 0.95 以上KS 超过 0.6但把模型部署到测试环境后实际区分度掉到 0.3 以下。原因最常见的是目标编码泄漏或者特征里混入了未来信息。比如最近一次还款状态这个字段如果它是在违约发生之后才更新的那它本身就是标签的另一种表达模型学到的是作弊信号。解决把所有特征按时间戳排序确保每个特征在预测时点已经存在。目标编码必须用 K 折或时间窗口方式做。做完之后用一个时间外样本做验证而不是随机切分。4.2 类别不平衡处理过度召回率上去了但精确率崩了现象用了 SMOTE 之后少数类召回率从 0.4 提到 0.8但精确率从 0.6 掉到 0.15业务上根本没法用。原因SMOTE 在少数类样本之间做线性插值如果少数类本身分布很散插出来的样本可能落在正常样本的区域里导致模型把大量正常用户判成违约。解决先试class_weightbalanced不行再试 SMOTE 的变体如SMOTEENN或BorderlineSMOTE。同时把评估指标从准确率换成 AUC 和 KS不要只看召回率。4.3 特征重要性全是零树没长起来现象训练完模型feature_importances_输出一看大部分特征重要性接近零只有一两个特征占了 0.9 以上。原因要么是max_depth设得太小树根本没分裂几次要么是特征尺度差异太大虽然随机森林对尺度不敏感但如果某个特征方差极小分裂增益也会很小。解决先把max_depth设成None跑一遍看重要性分布是否正常。如果还是集中检查是否有常数特征或近似常数特征直接删掉。另外min_samples_leaf设得太大也会导致树过于保守。4.4 阈值默认 0.5业务成本和模型输出脱节现象模型 AUC 不错但按 0.5 阈值做决策时通过率太低或者坏账率太高业务方不满意。原因0.5 是数学上的默认值不是业务上的最优值。风控里拒绝一个好用户的成本和放过一个坏用户的成本完全不对等。解决画 KS 曲线找到 KS 最大点对应的阈值或者根据业务成本矩阵算一个期望损失最小的阈值。这个后面最后一章会展开。4.5 训练集和测试集分布不一致随机切分掩盖了时间漂移现象随机切分下模型表现很好但按时间切分后测试集表现明显下降。原因贷款数据有时间维度用户的违约行为会受宏观经济影响随机切分让训练集和测试集共享了同一时期的数据分布掩盖了漂移。解决用时间切分代替随机切分比如前 70% 时间做训练后 30% 做测试。如果效果下降太多说明模型对时间不稳定需要考虑加入时间相关的特征或者做滚动训练。5. 阈值调优与可解释性让模型从能跑变成能用5.1 用 KS 曲线找最佳切分点模型输出的是概率业务需要的是决策。阈值就是那个把概率变成决策的开关。我一般会画三条线不同阈值下的通过率、坏账率、以及 KS 值然后找 KS 最大且通过率在业务可接受范围内的点。import numpy as np import matplotlib.pyplot as plt thresholds np.arange(0.05, 0.95, 0.01) ks_values [] approval_rates [] bad_rates [] for t in thresholds: y_pred (y_prob t).astype(int) # 通过率预测为正常的比例 approval_rates.append(1 - y_pred.mean()) # 坏账率预测为违约的人里实际违约的比例 if y_pred.sum() 0: bad_rates.append(y_test[y_pred 1].mean()) else: bad_rates.append(0) # KS fpr, tpr, _ roc_curve(y_test, y_prob) ks_values.append(max(tpr - fpr)) best_idx np.argmax(ks_values) best_threshold thresholds[best_idx] print(最佳阈值:, round(best_threshold, 3)) print(对应 KS:, round(ks_values[best_idx], 4)) print(对应通过率:, round(approval_rates[best_idx], 4))这段代码的逻辑是遍历阈值对每个阈值算通过率和坏账率。实际业务里通过率通常有下限要求比如不能低于 60%那就在满足这个条件的阈值里找 KS 最大的。参数上np.arange的步长 0.01 够用了太细没必要。5.2 特征重要性排序与业务解释随机森林的feature_importances_给的是基于不纯度下降的重要性优点是快缺点是偏向高基数特征。更稳的方式是用 permutation importance打乱某个特征后看模型效果下降多少。from sklearn.inspection import permutation_importance perm_imp permutation_importance( rf_base, X_test, y_test, n_repeats10, random_state42, n_jobs-1 ) imp_df pd.DataFrame({ feature: X_test.columns, importance_mean: perm_imp.importances_mean, importance_std: perm_imp.importances_std }).sort_values(importance_mean, ascendingFalse) print(imp_df.head(15))n_repeats10表示每个特征打乱 10 次取平均次数越多越稳但越慢。输出里importance_mean是平均下降幅度importance_std是波动如果某个特征均值高但标准差也很大说明它的重要性不稳定需要谨慎解释。5.3 用部分依赖图看单特征对违约概率的影响特征重要性只告诉你哪个特征重要不告诉你它怎么影响预测。部分依赖图能画出某个特征取值变化时模型输出的平均变化。from sklearn.inspection import PartialDependenceDisplay features_to_plot [age, loan_amount, past_due_count] PartialDependenceDisplay.from_estimator( rf_base, X_test, features_to_plot, kindaverage, grid_resolution50 ) plt.tight_layout() plt.show()这个图在跟业务方沟通时特别有用。比如你发现past_due_count从 0 到 1 时违约概率跳升最快从 3 到 4 反而平缓那业务规则就可以针对 0 到 1 这个区间做重点拦截。grid_resolution50控制横轴采样点数50 一般够平滑了。6. 把模型塞进业务流程一个可复现的评分卡转换技巧模型跑通只是第一步真正落地的时候业务方要的不是一个.pkl文件而是一张能解释、能监控、能手动调整的评分卡。随机森林本身是黑箱但我们可以用它的叶子节点路径做一件事把每棵树的输出概率做分箱然后映射成分数。具体做法是对每个样本取所有树的predict_proba平均值作为最终概率然后按概率分位切 10 档每档给一个分数分数越高违约风险越大。这样业务方看到的就是一张表分数区间、对应违约率、建议动作。# 取每棵树的预测概率做平均 tree_probs np.array([tree.predict_proba(X_test)[:, 1] for tree in rf_base.estimators_]) avg_prob tree_probs.mean(axis0) # 按分位切 10 档 score_bins pd.qcut(avg_prob, q10, labelsFalse, duplicatesdrop) score_df pd.DataFrame({prob: avg_prob, bin: score_bins, y: y_test.values}) # 每档的违约率和样本量 summary score_df.groupby(bin).agg( sample_count(y, size), default_rate(y, mean), min_prob(prob, min), max_prob(prob, max) ).reset_index() print(summary)qcut的duplicatesdrop是防止概率值大量重复导致分箱失败。输出表里如果最高档的违约率是最低档的 5 倍以上说明模型区分度不错如果两档之间违约率跳变不明显可能需要减少分箱数或者重新检查特征。这个评分卡转换的好处是业务方可以按档位设规则比如最高两档直接拒绝中间三档转人工审核最低五档自动通过。模型更新时只需要重新算分箱边界规则框架不用动。我自己踩过的一个坑是一开始直接用概率当分数业务方看不懂 0.23 和 0.24 有什么区别。换成 1 到 10 的整数分之后沟通效率高了很多。另一个习惯是每次模型上线前一定用最近三个月的时间外样本跑一遍评分卡看各档违约率是否单调。如果不单调说明模型在时间维度上不稳定宁可不上线也不要硬推。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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