简介:这份资源是围绕随机森林算法构建贷款违约预测模型的完整项目源码,面向计算机相关专业学生及需要项目实战练习的学习者,可用于课程设计、期末大作业或算法入门实践。项目经导师指导并获评审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(ascending=False) 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], inplace=True) else: df[col].fillna(df[col].median(), inplace=True) 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(), inplace=True) else: df.drop(columns=[col], inplace=True) # 连续变量 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(include="object").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_splits=5): df[col + "_te"] = np.nan kf = KFold(n_splits=n_splits, shuffle=True, random_state=42) 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(), inplace=True) return df high_card_cols = [c for c in df.select_dtypes(include="object").columns if df[c].nunique() >= 10] for col in high_card_cols: df = target_encode(df, col, "default") df.drop(columns=[col], inplace=True)目标编码最大的坑是数据泄漏:如果你直接用全量数据算类别均值再替换,模型在训练集上会看到未来信息,验证集表现虚高。用 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_size=0.3, stratify=y, random_state=42 ) # 基线随机森林 rf_base = RandomForestClassifier( n_estimators=200, max_depth=None, min_samples_split=2, min_samples_leaf=1, class_weight="balanced", random_state=42, 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_estimators=200是基线常用值,太少方差大,太多训练慢且收益递减;class_weight="balanced"让模型自动把少数类的权重调高;stratify=y保证切分后两边违约率一致,不然测试集可能全是正常样本,评估就失真了。
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_weight="balanced", random_state=42, n_jobs=-1 ) search = RandomizedSearchCV( rf, param_dist, n_iter=50, cv=5, scoring="roc_auc", random_state=42, n_jobs=-1 ) search.fit(X_train, y_train) print("最优参数:", search.best_params_) print("最优 AUC:", round(search.best_score_, 4))n_iter=50表示随机采样 50 组参数组合,cv=5是五折交叉验证。如果你的数据量很大,可以把n_iter降到 30,cv降到 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_weight="balanced",不行再试 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_repeats=10, random_state=42, 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", ascending=False) print(imp_df.head(15))n_repeats=10表示每个特征打乱 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, kind="average", grid_resolution=50 ) plt.tight_layout() plt.show()这个图在跟业务方沟通时特别有用。比如你发现past_due_count从 0 到 1 时违约概率跳升最快,从 3 到 4 反而平缓,那业务规则就可以针对 0 到 1 这个区间做重点拦截。grid_resolution=50控制横轴采样点数,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(axis=0) # 按分位切 10 档 score_bins = pd.qcut(avg_prob, q=10, labels=False, duplicates="drop") 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的duplicates="drop"是防止概率值大量重复导致分箱失败。输出表里,如果最高档的违约率是最低档的 5 倍以上,说明模型区分度不错;如果两档之间违约率跳变不明显,可能需要减少分箱数或者重新检查特征。
这个评分卡转换的好处是,业务方可以按档位设规则,比如最高两档直接拒绝,中间三档转人工审核,最低五档自动通过。模型更新时,只需要重新算分箱边界,规则框架不用动。
我自己踩过的一个坑是:一开始直接用概率当分数,业务方看不懂 0.23 和 0.24 有什么区别。换成 1 到 10 的整数分之后,沟通效率高了很多。另一个习惯是每次模型上线前,一定用最近三个月的时间外样本跑一遍评分卡,看各档违约率是否单调。如果不单调,说明模型在时间维度上不稳定,宁可不上线也不要硬推。希望帮到你。
本文还有配套的精品资源,点击获取