信贷违约预测一直是金融风控领域最经典的入门实战场景,也几乎每期数据竞赛都会作为练手题目出现。用随机森林做Baseline是大多数参赛者走的第一步棋——不用复杂特征工程、不需要调参技巧、更不用深度模型,却能稳定拿到一个还算体面的分数,帮助你快速跑通“数据理解 → 特征处理 → 建模 → 提交”的完整流程。这篇内容我结合自己参赛和做风控项目的经验,把从零搭建一个随机森林信贷违约预测Baseline的完整思路、代码细节和踩坑记录都整理出来,无论你是第一次打比赛,还是想系统梳理风控建模流程,都可以直接照抄思路去实践。
1. 内容整体设计与思路拆解
1.1 信贷违约预测到底在预测什么
信贷违约预测是一个典型的二分类问题:给定借款人的历史信息、借款行为、还款记录等特征,预测该借款人未来是否会违约(也就是是否还不上钱)。在竞赛平台给的训练数据里,这条信息通常以标签列的形式出现,比如“isDefault”或者“label”,取值为1表示违约,0表示正常还款。
业务上这件事的价值很好理解:如果在放贷前能相对准确地识别出高风险用户,平台就可以拒绝借款、降低额度或者提高利率,从而控制坏账损失。竞赛题目把这个真实业务场景简化成“给你一批历史样本,训练模型去预测另一批用户的违约概率”,评估标准通常是AUC值,少数平台也会用KS值或者准确率。
第一次接触这个题目的人容易陷入一个误区,就是急着去堆模型、找高阶算法。但Baseline阶段的任务不是把分数刷到最高,而是把整条流水线跑通:确保数据能正确读入、特征能正确处理、模型能训练完成、预测结果能按照要求的格式提交上去。这个流程本身的价值远大于模型分数高低,因为后续所有的优化都是在这条流水线上迭代的。
1.2 为什么Baseline选择随机森林而不是XGBoost或深度学习
很多人问过我同样的问题:既然XGBoost和LightGBM在表格数据上通常表现更好,为什么Baseline还要选随机森林?我的回答是:随机森林是容错率最高的起点。
随机森林是Bagging策略的代表算法,通过并行训练多棵决策树并对结果进行投票/平均来降低方差。它有几个特点对新手特别友好:
- 对缺失值有一定容忍度,不需要在第一步就做极其精细的缺失值填补。
- 对特征尺度不敏感,不需要做标准化或归一化,省掉了一大堆预处理工作。
- 能输出特征重要性,帮助快速理解哪些字段对预测有贡献。
- 参数虽然多,但默认参数在大部分场景下都能跑出可接受的结果。
XGBoost虽然性能上限更高,但它对特征工程、参数调节、缺失值处理的要求都更敏感,新手在还没建立正确评估体系时去调它,很容易陷入“调参一小时、提升零点零零几”的困境。而深度学习在表格数据上更是杀鸡用牛刀,在数据量不够大时反而容易过拟合。
另外一个很现实的原因是:随机森林的预测速度、训练速度和内存占用都相对可控,尤其是当数据量在几万到几十万这个量级时,普通个人电脑就能轻松跑完。作为Baseline,它能在最短时间内给你一个可提交的结果,让你先站上起跑线。
1.3 Baseline在竞赛流程中的真实定位
Baseline这个词在竞赛圈里有明确含义:它不是为了赢,而是为了建立参考系。你需要知道“一个不做任何精细优化的模型大概能拿多少分”,之后所有特征工程和模型优化的效果,都是和这个分数对比来衡量的。
比如Baseline跑出了0.75的AUC,然后你加了一个特征工程变成了0.76,那说明这个特征工程有效;如果你加了一个复杂的交叉特征,分数反而降到0.749,那说明这个特征引入的是噪声。没有Baseline作为锚点,你根本无法判断自己的优化方向是否正确。
同时,Baseline也能帮你提前发现很多“坑”:数据里有没有明显的异常值?标签是否极度不平衡?测试集和训练集的特征分布是否一致?提交格式有没有强制要求?这些问题越早暴露,留给后续优化排雷的时间就越多。所以我的建议很直接:花最少的时间把Baseline跑通,拿到一个初版分数,然后把80%的精力投入到特征工程和数据清洗中去。
2. 核心细节解析与实操要点
2.1 数据理解阶段的三个关键检查
动手写模型代码之前,先做数据理解(EDA——Exploratory Data Analysis)。这不是走形式,三个关键检查能帮你省去后面大量返工时间。
第一个检查是标签分布。信贷违约数据集通常面临类别不平衡问题:正常还款用户远多于违约用户。如果你的数据里正样本只占5%到10%,那么模型评估时AUC依然有效,但准确率会失真。比如全部预测为“不违约”准确率也有90%以上,但这样的模型毫无价值。所以先统计一下标签列中1和0的比例,心里有数。
第二个检查是特征缺失情况。用info()方法或者isnull().sum()统计每个特征的缺失数量。如果某些特征缺失超过50%,通常意味着这个字段在采集阶段就不稳定,要么直接删除,要么需要认真设计填补策略。随机森林虽然能处理缺失值,但过高比例的缺失依然会影响分裂点的选择质量。
第三个检查是数据类型。把特征分成数值型、类别型、时间型三类。数值型特征直接作为输入;类别型特征需要编码,比如用one-hot或者LabelEncoder,但树模型其实可以直接用数值编码(把类别映射成整数);时间型特征通常要转换成“到某个基准日期的天数差”或者提取年、月、日等周期性特征,这在信贷场景里很常见——比如“注册日期到借款日期的间隔天数”就比原始日期本身有信息量。
2.2 特征工程的“够用”标准:不做花活但必须做对
Baseline阶段的特征工程不要求你创造很多新特征,但有几类操作是一定要做的,做对了就不会让模型一开始就处于劣势。
连续性特征要处理极值。信贷数据里收入和负债字段经常有异常大或异常小的值,可能是数据录入错误,也可能是真实情况(比如极其高收入的借款人)。随机森林对单个特征的尺度不敏感,但极值会影响分裂阈值的分布,所以可以用分位数截断(比如保留2%到98%的范围)来处理,简单有效。
缺失值要显式填充。虽然随机森林支持缺失值处理(很多实现是在分裂时忽略缺失样本),但我建议在喂给模型前先做一步填充。数值特征用中位数填充,类别特征用众数填充。原因很简单:显式填充后你可以准确知道模型看到的输入是什么,调试起来不玄学。这一步用SimpleImputer就能搞定。
类别特征要做编码。把字符串类型的特征(如职业、居住城市等级、借款用途)通过LabelEncoder或者pd.factorize转换成整数。这里需要注意的是:不要用one-hot编码处理高基数类别特征(比如有几百个唯一值的城市编码),因为one-hot会带来维度爆炸,而树模型本身能处理整数编码的顺序关系,虽然这个“顺序”没有实际语义,但在实践中足够用。
我个人在Baseline里还会做的一个特征是“历史违约次数”:如果数据里有用户历史的借款违约记录次数,这个特征通常对预测有很强的区分度。如果原始数据没有这个字段,那就用分组统计的方式去构造:按照用户ID分组,统计其历史借款记录里的违约次数。这类聚合特征是风控场景的“祖传技能”,比很多花哨的衍生特征都管用。
2.3 数据划分:需要独立的验证集
有些选手在Baseline阶段会犯一个错误:把全部训练数据拿去训练,然后用平台自带的验证集来评估。这样做的风险是你无法在本地快速迭代——每改一个特征就得提交一次,等待评分,效率极低。
正确的做法是在训练数据里再切出一部分作为本地验证集。用train_test_split,test_size=0.2,并且带上stratify参数按标签分层采样,保证验证集和训练集的正负样本比例一致。这样你在本地就能快速评估模型效果,AUC稳定之后再去提交测试集预测结果。
有一个细节值得注意:如果比赛数据带有时间特征(比如借款月份),简单的随机切分可能会引入时间泄漏——即训练集里包含时间上晚于验证集的数据。这种情况下应该按时间排序后切分,用前80%的时间段做训练,后20%做验证。这是金融时序场景里必须养成的习惯,Baseline阶段虽然不强制,但提前按正确方式去做,后续会发现省心很多。
2.4 随机森林核心参数的选择逻辑
随机森林在实现上最常用的库是scikit-learn,它的RandomForestClassifier参数不多,Baseline阶段真正需要关注的只有五个。
n_estimators是森林里决策树的数量。太少数值模型不稳定,太多训练时间浪费。我的经验值是300起步,数据量大可以提到500,超过500后AUC几乎不再提升,纯粹是烧CPU。
max_depth控制每棵树的深度。默认是None(树可以无限分裂直到叶节点纯净),但这在高维特征下容易过拟合,而且训练变慢。实践中我会先不限制,如果验证集AUC和训练集AUC差距过大(比如训练集0.98,验证集0.75),再设置max_depth=10到15做一个约束。
min_samples_split和min_samples_leaf分别是内部节点分裂所需的最小样本数和叶子节点的最小样本数。它们的作用都是防止树过于细化,相当于正则化参数。我习惯设置min_samples_leaf=50到100,让叶子节点至少包含几十个样本,这样预测会更平滑,不易被单个异常样本带偏。
random_state是随机种子。把它固定下来,保证每次跑出来的结果完全一致,便于对比不同特征工程方案的好坏。不加随机种子的话,你可能无法判断分数变化是特征带来的还是随机性引起的。
class_weight建议直接设置成“balanced”。在信贷违约这种正负样本比例悬殊的场景下,这个参数让模型自动调整类别权重,相当于对少数类样本的错误惩罚加大,通常能提升AUC几个点。
3. 实操过程与核心环节实现
3.1 从数据加载到第一次信息核验
假设你已经从竞赛平台下载了训练集和测试集,文件是CSV格式。第一步是加载数据并做基础信息核验,下面这段代码是每次比赛我都会先跑一遍的“开场白”:
import pandas as pd import numpy as np train = pd.read_csv('train.csv') test = pd.read_csv('test.csv') print(train.shape, test.shape) print(train.columns.tolist()) print(train['isDefault'].value_counts()) print(train.info())这里重点关注三件事:训练集和测试集的列数是否一致(测试集通常少一列标签)、标签列取值是否是0和1、每个特征的缺失情况。如果测试集的特征列和训练集有差异,比如列名不一致或者多出奇怪的字段,先停下来搞清楚原因再继续。
3.2 特征处理流水线的搭建
接下来把特征分成数值列和类别列,分别做处理和拼接。这里我给出一个相对规范的Baseline处理流程:
# 分离特征与标签 feature_cols = [col for col in train.columns if col not in ['isDefault', 'id']] X_train = train[feature_cols] y_train = train['isDefault'] X_test = test[feature_cols] # 区分数值列和类别列 num_cols = X_train.select_dtypes(include=['int64', 'float64']).columns.tolist() cat_cols = X_train.select_dtypes(include=['object']).columns.tolist() print('数值列:', num_cols) print('类别列:', cat_cols)然后做缺失值填补和类别编码。数值列用中位数填补,类别列用众数填补。处理完后统一用pd.get_dummies或者自己维护一个LabelEncoder映射器。这里有一点经验:如果类别列的取值非常多(比如超过50个),我不建议直接one-hot,而是先用频率编码处理,把每个类别替换成它在训练集中出现的频次,这样既保留了类别的分布信息,又不会带来维度灾难。
from sklearn.impute import SimpleImputer from sklearn.preprocessing import LabelEncoder # 数值列中位数填补 imputer_num = SimpleImputer(strategy='median') X_train_num = imputer_num.fit_transform(X_train[num_cols]) X_test_num = imputer_num.transform(X_test[num_cols]) # 类别列编码与填补 for col in cat_cols: le = LabelEncoder() combined = pd.concat([X_train[col], X_test[col]]).astype(str) le.fit(combined) # 用众数填补缺失后再编码 X_train[col] = X_train[col].fillna(X_train[col].mode()[0]) X_test[col] = X_test[col].fillna(X_test[col].mode()[0]) X_train[col] = le.transform(X_train[col].astype(str)) X_test[col] = le.transform(X_test[col].astype(str)) # 拼接处理后的特征 X_train_processed = np.hstack([X_train_num, X_train[cat_cols].values]) X_test_processed = np.hstack([X_test_num, X_test[cat_cols].values])这段代码里的关键点是:LabelEncoder拟合时一定要把训练集和测试集的类别放到一起拟合(combined),因为测试集中可能包含训练集里没出现过的、新的类别取值。如果不这样做,测试集在transform时就会报错,或者出现编码顺序不一致的问题。
3.3 划分验证集并完成第一次训练
特征准备完毕后,划分训练集和本地验证集,然后初始化随机森林模型进行训练:
from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import roc_auc_score X_tr, X_va, y_tr, y_va = train_test_split( X_train_processed, y_train, test_size=0.2, random_state=42, stratify=y_train ) model = RandomForestClassifier( n_estimators=300, random_state=42, class_weight='balanced', n_jobs=-1 ) model.fit(X_tr, y_tr) y_va_pred = model.predict_proba(X_va)[:, 1] print('本地验证AUC:', roc_auc_score(y_va, y_va_pred))这里特别说明一下为什么用predict_proba而不是predict。竞赛平台评估信贷违约模型时通常用的是AUC,而AUC计算需要的是预测概率值而非0/1的类别判断。即使在最终提交时,平台往往也需要你提交概率值。所以从Baseline阶段就要养成习惯:提交的概率结果,而不是标签类别。
3.4 预测与提交文件的生成
拿到验证集AUC后,对测试集做预测并生成提交文件。提交文件的格式通常会在竞赛页面的“提交样例”里给出,一般就两列:一列是ID,一列是预测概率。保持顺序一致非常重要——千万不要把索引搞乱了。
y_test_pred = model.predict_proba(X_test_processed)[:, 1] sub = pd.DataFrame({ 'id': test['id'], 'isDefault': y_test_pred }) sub.to_csv('baseline_rf.csv', index=False)在提交之前,把生成的csv文件打开看一眼,确认行数等于测试集行数,确认没有空值,确认第一列顺序和原始test.csv一致。这些检查看似琐碎,却是最常导致提交无效的低级错误。
4. 常见问题与排查技巧实录
4.1 本地验证AUC很高但线上分数很低的排查思路
这是竞赛里最经典的问题,Baseline阶段也经常遇到。核心原因通常是过拟合或者特征泄漏。
过拟合的场景是:本地验证集AUC跑到0.95以上,但线上分数只有0.7左右。这通常说明模型把训练集里某些特殊模式“背”了下来,而这些模式在测试集上并不存在。排查方法是看train和test的特征分布是否有明显差异,尤其是均值、缺失率、取值范围。
特征泄漏的场景更隐蔽:如果你用了某个和标签强相关的特征做训练,而这个特征在真实推理时无法获取(或者本身就是标签的某种变换),就会造成训练阶段表现极好、线上崩盘的结果。比如某些竞赛数据里可能会有“当前逾期天数”这种特征,它几乎就是违约的直接指标,直接放进模型就会泄漏。在处理特征时,需要仔细审视每个字段的业务含义,判断它在预测时是否可得、是否天然包含了结果信息。
我的建议是:Baseline阶段不要追求高AUC,如果本地验证AUC超过0.9,反而应该警惕,花时间检查是否有泄漏特征混进来了。
4.2 类别不平衡导致模型“全部预测为多数类”
当你看到验证集AUC在0.5附近徘徊,说明模型基本没有区分能力,很可能是因为正样本太少、树模型难以学到有效分裂规则。除了设置class_weight='balanced'之外,还可以在建模阶段尝试对多数类做下采样、对少数类做上采样,比如使用imbalanced-learn库里的RandomUnderSampler。但Baseline阶段我一般不自造样本,因为这会增加代码复杂度,而且数据集不是极度不平衡时(正样本占比高于15%)class_weight='balanced'通常就够了。
还有一个从实践来的细节:如果你发现模型预测的概率值几乎全部集中在0.0到0.1之间,并不代表模型不可用,这恰恰说明数据中大部分样本的违约风险都不高。关键是AUC是否大于0.5,而不是预测概率的绝对大小。
4.3 随机森林训练慢到怀疑人生
数据量大时(比如几十万行、几百个特征),随机森林训练时间可能变得不可接受。这时候先从两个角度优化。
第一个角度是降低树的数量。把n_estimators从300降到100,训练时间基本线性下降,AUC通常只损失0.002到0.005。对于Baseline来说完全可以接受。
第二个角度是限制单棵树复杂度。设置max_features='sqrt',让每棵树在分裂时只考虑特征总数平方根个候选特征,不仅能显著降低单棵树训练时间,有时还能因为增加了树之间的多样性而略微提升整体效果。
另外一定记得n_jobs=-1,让随机森林并行化训练,利用全部CPU核心。大部分新手会忽略这个参数,导致明明电脑配置不差,训练却慢得像单线程运行。
4.4 特征重要性分析:看模型学到了什么
训练完成后,利用随机森林自带的feature_importances_属性,把特征重要性可视化一下,能帮你快速判断是否有噪声特征混入。我用过很多次,每次都有效果:
importance = pd.Series(model.feature_importances_, index=feature_cols).sort_values(ascending=False) print(importance.head(20))如果排名靠前的特征确实在业务上有解释力(比如历史违约记录、负债比例、借款金额),说明模型学到的模式比较合理。如果排名第一的特征从业务上看明显不靠谱(比如用户ID),就要警惕数据泄漏或者特征含义理解错了。
这个分析还有另一个用途:Baseline跑通后,后续优化时可以只保留重要性累积占比到80%左右的前若干特征训练一个简化版模型,作为和全特征模型的对比。很多时候Top特征子集的AUC几乎不下降,但训练速度和稳定性会大幅提升。
4.5 提交后AUC远低于本地验证AUC的排查核查单
最后给一份我每次竞赛提交前都会口头过一遍的核查单,几乎涵盖所有导致提交分偏低的常见原因:
- 提交文件行数是否与测试集一致?多一行少一行都会评分失败。
- 提交文件中的ID顺序是否与测试集原始ID顺序一致?不能用索引混乱后的顺序。
- 预测概率是否有空值?是否有NaN?随机森林理论上不会产生NaN,但特征处理时的bug可能导致输入有问题,所以核验一下。
- 本地验证集划分时是否用了stratify?没分层划分可能导致验证集和训练集的正负比例差异很大,评估失真。
- 是否在训练模型时使用了测试集数据进行了特征处理(比如用测试集的均值去填充训练集的缺失值)?这会造成轻微的数据泄露,让验证集分数偏高。
把这五条逐项过一遍,往往能快速定位问题所在。其实很多所谓的“提交分数低”并不是模型不行,而是文件格式这些基础环节出了岔子。养成提交前用脚本核验文件格式的习惯,能少走很多弯路。
5. 从Baseline出发的下一步迭代路径
跑通Baseline只是第一步,拿到了参考分数之后,接下来的迭代方向大概有三个层次,我按性价比从高到低排列。
第一层是特征工程。信贷违约预测里信息量最大的通常是“借款行为特征”和“用户历史行为特征”,可以从这个方向衍生新特征。比如计算借款金额与年收入的比值、统计用户历史借款次数、计算历史正常还款次数占总借款次数的比例等。聚合特征用groupby操作就能做,思路简单但提升明显。我见过不少参赛者靠扎实的特征工程,用随机森林模型本身就把AUC从0.75推到了0.8以上。
第二层是模型融合。把随机森林和逻辑回归、XGBoost、LightGBM等模型的结果做加权平均或者简单堆叠。模型之间存在差异时,融合往往比单个模型效果更好。但这一步要建立在各模型已经达到自身最优状态的基础上,否则“融合三个烂模型”并不会变成好模型。
第三层是参数优化。用贝叶斯优化或者简单的网格搜索找随机森林最优参数组合。但根据我的经验,在这个阶段参数优化的收益通常只有1%到3%,远不如特征工程带来的收益大。所以我的建议是:把精力放在理解数据和构造特征上,参数调优留到最后再说。
我个人在实际操作中的体会是:Baseline的价值不在于那一行分数,而在于它帮你把整个建模流程的每个环节都走了一遍。第一次参赛的人最容易犯的错误就是花两周时间打磨一个复杂模型,最后却因为提交格式错误拿不到分数;而那些先认真跑通Baseline的人,反而能在后续迭代中从容地在特征工程上精雕细琢。这个顺序合理了,成绩就不会差到哪里去。
从Baseline到更高分数的路还很长,但至少你已经站在了一个稳稳的起点上。把这套流程吃透,然后下一次迭代时,你会发现每次改动都能可量化地评估效果,这种对模型的掌控感,才是打竞赛最宝贵的收获。