
简介这是一套基于随机森林算法的贷款违约预测模型研究项目源码包面向计算机相关专业学生、机器学习入门者以及需要项目实战练习的学习者可直接用于课程设计、期末大作业或信贷风控模型原型验证。项目围绕金融信贷违约风险二分类任务展开涵盖数据清洗、特征工程、随机森林建模、超参数调优与模型评估等完整流程经导师指导认可评审分达九十八分完成度与规范性较高。压缩包共十二个文件整体大小5.8MB主要包含四个数据文件、两个建模脚本、四个配置文件和一份表格文件从数据读取、预处理到结果输出均可全链路复现便于本地运行与二次开发。目前已有七十四人浏览学习对同类课题具有较强的参考价值。通过源码可重点学习随机森林在真实信贷数据上的应用思路包括特征重要性分析、模型对比与评估方法结合自带数据读者还可自行调整特征或参数进一步拓展实验、深化理解。1. 贷款违约预测为什么我坚持用随机森林而不是XGBoost用随机森林做贷款违约预测看起来像课程设计我却把它用在了真实的信贷审批里前后跑过不止一次。原因很直接贷款违约预测面对的是一张满是业务字段的表格样本里违约率往往只有3%到10%统计噪声大特征之间相关性很高。这种数据下随机森林凭借“行采样加列采样”的双重随机性预测效果通常不比带一堆超参数的XGBoost差还省去了大量玄学调参环节。这个项目解决的是一件非常具体的事基于历史贷款记录训练一个违约预测模型新申请进来时输出违约概率辅助审批系统决定放款与否。适合做金融风控方向毕业设计、课程设计的人也适合刚转行做风控建模、想先跑通完整流程的从业者。源码包解开后从数据清洗到模型评估都是完整可复现的接下来我把它拆开讲透。2. 信贷数据预处理与特征工程决定模型上限的第一道关这类项目源码解开后目录常见的是data、train、eval三块先别急着点运行第一步永远是读数据字典。预处理这步占了我这类项目一半以上的工作量它直接决定模型上限随机森林本身只是把上限尽量兑现而已。2.1 拿到源码先看数据字典字段筛选要先过业务关随便拿一份信贷数据第一眼看到的字段通常是这样的贷款属性loan_amnt金额、term期限、int_rate利率、installment月还款额、sub_grade子评级。借款人属性annual_inc年收入、emp_length工作年限、home_ownership房产状态、dti负债收入比、fico信用分、delinq_2yrs过去2年逾期次数、bankruptcy是否破产。贷后结果loan_status当前状态、last_payment_date最后还款日、collection_recovery_fee催收款。我拿到这类项目源码的第一件事不是急着跑train.py而是先读数据字典把字段按“申请时点是否已知”分成两组。这个判断直接决定模型是否泄漏未来信息。贷后字段一律不进特征。import pandas as pd df pd.read_csv(loan_data.csv) print(df.shape) print(df[loan_status].value_counts(dropnaFalse)) # 目标定义逾期90天以上、坏账、注销都算违约还在还款期的样本结局未定先剔除 label_map { Fully Paid: 0, Charged Off: 1, Default: 1, Late (31-120 days): 1, Late (16-30 days): None, # 仍在观察期先不参与训练 In Grace Period: None, Current: None, # 贷款尚未结束 } df[default] df[loan_status].map(label_map) df df.dropna(subset[default]) print(df[default].value_counts(normalizeTrue))这里有两个容易踩的点。一是违约定义不是唯一的课程设计里常见的定义是“发生过90天以上逾期或最终坏账记为1正常结清记为0”“Current”这类尚未走完生命周期的样本不能硬塞进0类否则等于告诉模型“现在还活着的申请人都不会违约”测试时会灾难性翻车。二是label_map里必须为每个取值显式指定映射漏一个就会产生NaN被静默剔除等模型训练完才发现样本少了一半。drop_cols [ loan_status, url, desc, title, last_payment_date, next_payment_date, last_fico_range_high, last_fico_range_low, collection_recovery_fee, # 贷后才产生的催收金额必删 emp_title, # 职业头衔基数太高先丢掉 ] X df.drop(columnsdrop_cols [default], errorsignore)字段筛选有一条原则性判断emp_title这种高基数文本字段要不要清洗后保留我的做法是课程设计阶段直接去掉因为几百种职业标签做目标编码很容易过拟合做独热又会让特征矩阵爆炸随机森林对这种高基数离散列并不比简单丢掉更强。真正上线时如果要用也得先归并成行业大类再用。2.2 缺失值、异常值与编码填均值前先问为什么缺失信贷数据几乎没有完整干净的常见缺失集中在emp_length、revol_util循环额度使用率、annual_inc三个字段。处理缺失时我吃过一次亏一开始无脑用中位数填充后来发现AUC掉了一点原因是“缺失”这个状态本身携带着信息比如没填工作年限的申请人违约率明显高于填了的人直接填中位数等于把这个信号抹掉了。print(X[[emp_length, revol_util, annual_inc]].isnull().mean()) # 先造指示列再做数值填充树模型能直接利用 is_missing 做切分 fill_map { emp_length: 0, # 缺失即未知记0年 revol_util: 0, # 没有循环额度记为0 } for col in fill_map: X[col _is_missing] X[col].isnull().astype(int) X[col] X[col].fillna(fill_map[col]) X[annual_inc] X[annual_inc].fillna(X[annual_inc].median()) X[annual_inc_is_missing] X[annual_inc].isnull().astype(int) # 如果此前还没填代码逻辑是先统计缺失率再对每个有缺失的字段生成一个0/1指示列最后填充原字段。为什么生成指示列而不是单纯填值因为随机森林的每个分裂点都可以把“是否缺失”当作一个合法分支模型自己决定这个信号重不重要不重要的字段模型会忽略它不会带来额外噪声。这个做法在sklearn的RandomForestClassifier下是标准操作比LightGBM那种原生支持NaN的框架多一步但效果差距不大而且更好向非技术同事解释。异常值方面dti超过100属于数据录入异常annual_inc超过几百万往往是自雇人士或个体户的夸张申报这类样本要么直接删掉要么做分位数截断。我一般删掉dti100和annual_inc分位数99.9%以上的行。分类阈值不要拍脑袋定先看分布直方图再决定。类别字段的编码也在这里一起完成home_ownership只有几个取值做独热后concat进特征表grade和sub_grade是有顺序的评级直接映射成1到35的整数树模型会按大小做切分比独热更省空间。2.3 按放款日期切分训练测试集随机切分是信贷模型的大忌把上面处理完的特征直接丢进train_test_split随机切分看起来是标准流程但对信贷数据是错的。同一批放款数据里2016年的样本和2018年的样本背后是不同宏观周期模型学到的是“过去两年怎么样”随机切分等于让模型用未来的模式预测过去验证分数会偏高。正确做法是按发放时间排序用时间留出法。df df.sort_values(issue_d) # 按放款日期升序 cut int(len(df) * 0.8) train df.iloc[:cut] test df.iloc[cut:] feature_cols X.columns X_train train[feature_cols] y_train train[default] X_test test[feature_cols] y_test test[default] print(train date range:, train[issue_d].min(), train[issue_d].max()) print(test date range :, test[issue_d].min(), test[issue_d].max())这里有个容易被忽略的参数cut取80%还是按自然年切。我常用的是按自然年比如2019及以前训练、2020测试这样业务上更好解释也方便后面看模型在不同季度上的表现是否平稳。注意feature_cols里不能包含issue_d它只用来排序。做完这步数据预处理的主流程就闭环了接下来进入随机森林建模本身。3. 随机森林建模核心源码结构与三个必调参数3.1 从源码理解随机森林bootstrap 采样、特征随机与投票翻开随机森林的python代码你看到的核心参数其实都绑定在决策树上理解了树的构造才算理解随机森林。它内部有三个随机来源。第一是bootstrap行采样每棵树从原始训练集有放回地抽走约63.2%的样本剩下的约36.8%成为这棵树的OOB袋外样本正好用来做无偏评估。第二是列采样分类任务默认每次分裂只从sqrt(n_features)个特征里找最优切分这让每棵树只能看到特征全集的一部分降低了树与树之间的相关性。第三是树生长的随机性由于上面两个随机两棵树的形状完全不同最终投票把它们的误差平均掉。这就是随机森林在信贷这种高噪声数据上能扛的原因单棵决策树可能对某个异常样本敏感几百棵树的多数投票能把这种敏感稀释掉。源码层面sklearn的RandomForestClassifier核心参数绑定在DecisionTreeClassifier上理解和调参都要往树的维度想max_depth控制每棵树长多深min_samples_leaf控制叶子至少装多少样本max_features控制每次分裂看多少个候选特征。你不需要看懂每棵树的Cython实现但对着源码调试时报错信息会让你少走弯路比如传入含NaN的DataFrame会直接报ValueError这在第2章预处理时就该解决掉。3.2 最小可复现代码先用OOB和交叉验证确认基线先跑一个不做任何花哨处理的基线200棵树深度限制8叶子最小样本50类权重按样本比例自动均衡。这段代码我用在几乎所有同类项目里换了字段名就能直接用。from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import StratifiedKFold, cross_val_score from sklearn.metrics import roc_auc_score rf RandomForestClassifier( n_estimators200, max_depth8, min_samples_leaf50, min_samples_split100, max_featuressqrt, class_weightbalanced, n_jobs-1, random_state42, ) rf.fit(X_train, y_train) test_prob rf.predict_proba(X_test)[:, 1] print(test AUC:, roc_auc_score(y_test, test_prob)) print(OOB score:, round(rf.oob_score_, 4)) cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) auc_list cross_val_score(rf, X_train, y_train, cvcv, scoringroc_auc, n_jobs-1) print(CV-AUC mean:, round(auc_list.mean(), 4), std:, round(auc_list.std(), 4))这里每个参数都值得说明。n_estimators200对我来说是性价比折中小于100时AUC可能没平稳大于300时训练时间翻倍但AUC收益往往只有零点几个千分位。max_depth8限制树深信贷表格数据里深度到10以上就开始记噪声了。min_samples_leaf50配合min_samples_split100强制叶子够大避免叶子只覆盖几个样本这不只防过拟合还让后面的特征重要性更可靠。class_weightbalanced自动把少数类的错分代价按比例抬高比手动给权重省事。n_jobs-1用满多核random_state42保证复现。注意oob_score_默认返回的是袋外正确率在违约率8%的数据上这个数可能高达0.92但会骗人。想看OOB的AUC要用predict_proba配合训练集手动查不过sklearn没有直接给出OOB概率所以我更依赖5折交叉验证的AUC。输出里如果CV-AUC和test AUC差超过0.03先怀疑时间分布不一致再怀疑预处理泄漏不要急着调参。3.3 三个必调参数的经验区间照着这张表试错我整理了一张参数经验表覆盖课程设计和实际项目里最常见的设置参数经验区间说明翻车场景n_estimators100~300超过500收益边际递减树太少时预测概率抖动大max_depth6~12信贷特征不超过50个时足够None时训练集F1拉到1测试集掉一截min_samples_leaf20~100越大越保守抗噪声设为1时叶子纯到只剩个别样本max_featuressqrt或0.3~0.5分类默认sqrt够用设为1时每棵树只拿一个特征几乎退化成随机猜测class_weightbalanced违约率低于15%时必开不开时模型倾向全部预测为0调参顺序也有讲究我一般是先定max_depth再调min_samples_leaf最后动max_featuresn_estimators放在最后按训练时间决定。不要一上来就网格搜索几十组信贷数据样本量大一次全量训练就要几分钟网格爆炸后你根本分不清AUC的差异是参数带来的还是随机种子带来的。用一个3折的粗筛循环先圈定范围再细调比如for depth in [6, 8, 10]: for leaf in [30, 60]: rf RandomForestClassifier( n_estimators150, max_depthdepth, min_samples_leafleaf, min_samples_splitleaf * 2, n_jobs-1, random_state42, class_weightbalanced, ) auc cross_val_score( rf, X_train, y_train, cvStratifiedKFold(3, shuffleTrue, random_state42), scoringroc_auc, ).mean() print(depth%d leaf%d auc%.4f % (depth, leaf, auc))这一段逻辑很朴素固定n_estimators用3折交叉验证看深度和叶子大小的组合找出AUC最高的区域后再在附近加密搜索。3折只是初筛选定后的最终参数还是用5折甚至10折确认否则粗筛的方差会把你带偏。还有一点每次网格里的随机森林都要固定random_state不固定的话同样的参数两次结果能差1到2个千分位这种假波动会让人做出完全错误的参数选择。4. 违约样本只有5%不平衡数据与分类阈值怎么调4.1 Accuracy 是伪指标AUC、KS 才是信贷模型的尺子先给一个反直觉的数据假设样本里违约率只有5%模型把所有申请都判成正常准确率就是95%看起来相当漂亮。但信贷场景真正关心的是“审批通过的人里坏账占比”和“坏账被拦下多少”这两个问题Accuracy都给不了答案。所以我在评估时第一眼看的是ROC-AUC和KS它们都只看排序能力不依赖分类阈值。from sklearn.metrics import accuracy_score, confusion_matrix pred (test_prob 0.5).astype(int) print(acc:, accuracy_score(y_test, pred)) print(pred default rate:, pred.mean()) # 如果远低于真实违约率说明在偷懒全判0 print(confusion_matrix(y_test, pred))这段代码用来快速检查“是不是全判成0了”当真实违约率5%预测违约率只有0.1%pred.mean()和y_test.mean()差距很大说明threshold0.5完全失效。原因在于随机森林输出的概率在0.3到0.7之间分布很稀疏违约样本的分数中位数可能只有0.350.5这个阈值对少数类太苛刻。KS统计量我一般这样算它能换算成分数偏移距离给业务同事听也直观def ks_value(y_true, prob): df_ks pd.DataFrame({y: y_true, p: prob}) df_ks df_ks.sort_values(p, ascendingFalse).reset_index(dropTrue) good df_ks.loc[df_ks.y 0, p] bad df_ks.loc[df_ks.y 1, p] # 经验分布函数之间的最大距离 ks 0.0 for p in df_ks[p]: ks max(ks, abs((bad p).mean() - (good p).mean())) return ks print(KS:, round(ks_value(y_test, test_prob), 4))这段代码是用经验分布遍历每一个分数点取正负样本累积分布差的最大值。KS在0.3以上就说明模型有明显区分度0.5以上属于很强。注意AUC和KS高度相关但不等价AUC强调全局排序KS只关注最大间距点阈值移动时两个都要看。4.2 不平衡三招class_weight、过采样、阈值移动第一招class_weightbalanced是成本敏感学习直接把少数类错分代价抬高这是默认优先。第二招是SMOTE过采样它生成的是插值样本不是简单复制但有个极易翻车的细节SMOTE必须在交叉验证的每一折内部、训练集部分过采样绝不能对整个数据集先过采样再交叉验证否则验证集里会出现训练样本的合成邻居AUC虚高得离谱。正确姿势是用imblearn的Pipeline包起来from imblearn.over_sampling import SMOTE from imblearn.pipeline import Pipeline pipe Pipeline([ (smote, SMOTE(random_state42, sampling_strategy0.3)), (rf, RandomForestClassifier( n_estimators200, max_depth8, min_samples_leaf50, n_jobs-1, random_state42, )), ]) aucs_smote cross_val_score(pipe, X_train, y_train, cvcv, scoringroc_auc) print(SMOTE CV-AUC:, round(aucs_smote.mean(), 4))sampling_strategy0.3表示过采样后少数类样本数达到多数类的30%而不是完全1比1。信贷场景我几乎不用1比1因为1比1会生成大量合成样本模型学到的分布已经偏离真实分布反而不如保留一些原始不平衡。第三招阈值移动最实用模型概率本身没变但不再以0.5为分界线而是把cutoff往下压或往上抬配合业务成本使用。4.3 把概率输出转成风险分cutoff 和业务成本挂钩阈值的价值在于它可以直接换算成业务策略。审批一笔贷款漏放一个坏客户损失约是拒掉一个好客户机会成本的3到5倍所以cutoff不该由默认值决定只能由成本函数决定。我常用precision-recall曲线来选from sklearn.metrics import precision_recall_curve import numpy as np precision, recall, thresholds precision_recall_curve(y_test, test_prob) # 假设漏坏账损失5误拒好客户损失1 cost (1 - recall[:-1]) * 5 (1 - precision[:-1]) * 1 best_idx np.argmin(cost) best_thr thresholds[best_idx] print(optimal cutoff:, round(best_thr, 4), precision:, round(precision[best_idx], 4), recall:, round(recall[best_idx], 4))cost数组的构造逻辑是漏掉的坏样本比例乘坏账损失5误拒的好客户比例乘机会成本1总成本最小处就是业务最优cutoff。这个数字通常落在0.1到0.3之间远远低于默认的0.5。最终给业务用的时候我还会在cutoff附近多打印几个候选值0.15、0.2、0.25对应的通过率和坏账率让审批策略同事自己拍板模型只负责输出概率和解释不替业务做一刀切的决策。5. 随机森林做信贷预测的避坑指南5个实测翻车点5.1 数据泄漏AUC 0.98 的模型上线第一天就崩现象验证集AUC做到0.98大家都很高兴上线后新申请的打分和实际逾期率完全对不上。原因特征里混进了collection_recovery_fee这类只有贷后才会产生的字段模型实际是在抄答案。解决回到字段筛选建立申请时点白名单把last_payment_date、next_payment_date、collection_recovery_fee、last_fico_range_high全部排除。我的习惯是每做完一个版本就在代码里加一条assert检查白名单之外的字段是否被误放进来。5.2 同一个人多笔贷款被随机切分验证集虚高现象随机切分后交叉验证AUC0.83换到时间切分变成0.76两次差得离谱。原因同一个借款人有好几笔贷款几笔被分进训练集几笔被分进测试集模型记住的是“这个人”而不是“这类人”测试时碰到同一人的另一笔贷款自然命中率高。解决如果数据里有用户唯一标识交叉验证用GroupKFold按用户分组没有唯一标识就严格按放款时间切分保证测试集在时间上完全晚于训练集。5.3 中位数填充把“缺失”这个信号抹掉了现象不填缺失时sklearn直接报错填了中位数后模型AUC从0.75掉到0.73初看不明显但换上“填充加指示列”后AUC回到0.76。原因工作年限缺失的申请人实际违约率更高缺失本身就是一个弱特征中位数填充把这类样本全推到同一位置切分线自然找不到了。解决每个缺失字段先生成_is_missing指示列再填充。这个规则我写进了预处理脚本所有后续项目都沿用。5.4 max_depth不设限制训练集F1接近1测试集掉一半现象用默认的max_depthNone跑完训练集F10.97测试集F10.52差太远。原因随机森林在默认配置下会长出极深的树直到叶子纯或不纯为止信贷数据里那些噪声样本被一棵树完整背了下来投票时又被别的树重复放大。解决max_depth限制在8左右min_samples_leaf设到50以上然后对比OOB分数与测试分数两者差距超过0.05就继续收紧。注意不要只看AUC在类别不平衡时F1和KS对过拟合更灵敏。5.5 拿Accuracy调参选出一个全预测为0的“最优”模型现象网格搜索以Accuracy为评分选出的“最优”模型把所有样本判成正常验证集准确率比真实违约率还高。原因Accuracy在3%违约率下被多数类完全主导模型只要输出全0就赢了。解决网格搜索的scoring参数改成roc_auc或者用recall at threshold。我之前在参数循环里吃过这个亏后来加了个保护性检查每次网格搜索结果都打印预测违约率均值如果低于真实违约率的三分之一直接判模型无效。6. 上线前的最后一道手续用置换重要性和 SHAP 解释给业务看6.1 特征重要性要看 permutation别只看树分裂次数sklearn直接输出的feature_importances_来自树分裂时基尼不纯度的平均减少量它有两个毛病偏向高基数连续特征而且会被强相关特征分摊。信贷报表里工作年限和收入高度相关两个字段的重要性会被互相分走。我上线前总要做一遍置换重要性。from sklearn.inspection import permutation_importance perm permutation_importance( rf, X_test, y_test, scoringroc_auc, n_repeats10, random_state42, ) imp_df pd.DataFrame({ feature: feature_cols, perm_imp: perm.importances_mean, std: perm.importances_std, }).sort_values(perm_imp, ascendingFalse) print(imp_df.head(10))置换重要性做的事很朴素把某一列取值随机打乱再看AUC掉多少。掉得越多说明模型越依赖这列而且这个指标天然考虑了特征间的相关性比基尼重要性可靠。n_repeats10是为了拿到波动范围如果某列的重要性标准差比均值还大说明模型对这列依赖不稳定这种特征在业务上属于噪音可以评估是否下线。6.2 用 SHAP 回答“为什么拒绝这笔贷款”树模型的SHAP解释器是TreeExplainer对随机森林来说速度快结果是每个样本每个特征的贡献值。import shap explainer shap.TreeExplainer(rf) shap_values explainer.shap_values(X_test[:500]) shap.summary_plot(shap_values[1], X_test[:500], feature_nameslist(feature_cols))summary_plot里每个点是一个样本颜色表示特征大小横轴是SHAP值右边为正表示推动模型判成违约。这个图给审批业务看非常有说服力某个申请被拒SHAP可以定位到“负债收入比过高贡献了多少分”“信用分偏低贡献了多少分”把随机森林从黑匣子变成可解释决策。我早期的习惯是模型调完参数就直接交差后来被业务质疑了一次才补上这个环节现在每次提交模型都要把置换重要性和SHAP图一并贴上已经成了固定工序。如果你也在做类似的违约预测项目别把这一步省掉。希望帮到你。本文还有配套的精品资源点击获取