资讯动态

交叉验证结果忽高忽低?用StratifiedKFold稳定模型评估与集成调参

发布时间:2026/10/16 4:07:51 来源:尧图企业网站定制
前阵子有个做信贷风控建模的朋友,拿着他刚调好的集成算法模型找我吐槽:同一套参数,昨天交叉验证跑出AUC 0.87,今天重跑变成0.81,中间什么都没动。我第一反应不是他的模型出了问题,而是他的数据切分方式出了问题。这种情况在分类项目里太常见了。模型融合(集成算法)本身解决的是模型偏差和方差的问题,可如果用来评估它的k折交叉验证在切分数据时不把类别分布当回事,那所有分数都只是噪音。尤其在类别不平衡的数据里,随手切几刀,每一折的验证集可能完全是另一副长相。这篇文章不聊花哨的调参技巧,就聚焦一个工具:sklearn里的StratifiedKFold。我会拆开看它和普通KFold的区别、为什么在集成算法和交叉验证场景下它几乎是默认最优解,再用一个完整的实操例子,从数据切分到Stacking模型融合,走一遍完整流程。如果你是做分类任务、调参调到头秃、或者正准备搭一套集成模型,这篇内容应该能帮上忙。1. 为什么做集成必须先搞定验证集切分1.1 先看一个真实到不能再真实的翻车现场事情是这样的。朋友那套模型用的是梯度提升加逻辑回归做融合,训练集上的表现一直很稳,一到交叉验证就抽风。我把他的代码翻了一遍,发现他写的是:from sklearn.model_selection import KFold kf KFold(n_splits5, shuffleTrue, random_state42)表面上看,KFold带了shuffleTrue,似乎没什么问题。但他的数据里正负样本比例大约1:9,负样本占绝大多数。KFold虽然在切分前会把数据顺序打乱,但它完全不看标签y,管你是正类还是负类,一律均匀切成5份。这就导致某些折里正样本特别多,某些折里正样本特别少,模型在验证集上面对的成分完全不一样,分数自然上蹿下跳。这种情况放到集成算法里尤其致命。因为你可能在调超参时,仅仅因为某一次交叉验证分数低了,就放弃一组本来还不错的参数;也可能因为某一次分数虚高,选了一个过拟合严重的模型。验证策略不靠谱,后面所有模型融合的努力都是在沙子上盖楼。1.2 为什么集成算法对验证集切分更敏感单独一个模型,交叉验证分数波动大,最多让你心烦意乱;但集成模型通常由多个基学习器组合而成,训练和评估链条更长,验证集切分的影响会被层层放大。拿Bagging类方法来说,随机森林本身就是用Bootstrap采样生成多个子模型,每个子模型在训练时已经不依赖完整数据集了。但我们在评估这个森林时,依然需要一个可信的验证策略。如果验证集里少数类样本短缺,森林在多数类上的投票结果会掩盖它在少数类上的糟糕表现,最后得出的AUC或者F1完全误导人。Boosting类方法同理。它按顺序拟合弱学习器,重点关注前面轮次预测错的样本。如果交叉验证切出来的训练集中某类样本比例失衡,Boosting会把大量注意力放在某种不真实的分布上,调出来的learning rate或者树深度,换个折就跑不动。更直接的是Stacking式的模型融合:第二层模型使用第一层基学习器在“域外预测”(out-of-fold)上生成的元特征来训练。这些OOF预测的质量,直接取决于每一折验证集的分布是否和真实场景一致。如果某折验证集缺少正样本,第一层模型学到的少数类模式就没有机会被正确评估,元特征就是歪的,第二层无论怎么融合都是错上加错。这就是为什么我始终强调:做集成算法之前,先把你用来评估它的那套k折交叉验证切分方案搞定。切分策略选对了,后面的调参、融合才有意义。1.3 下一节先说结论:分层切分是分类任务的默认答案所以针对分类任务,尤其是不平衡分类任务,答案其实很明确:用sklearn里的StratifiedKFold。它在切分数据时,会让每一折里各个类别的比例都和原始数据集尽可能接近。听起来是个很小的改动,但对于模型评估的稳定性,提升是肉眼可见的。接下来的内容,我会先把KFold和StratifiedKFold的底层逻辑对比清楚,再上代码演示它们之间的差别究竟有多大。2. KFold 与 StratifiedKFold 原理对比2.1 KFold:公平分堆,但不管标签KFold的思路非常朴素:把N个样本均匀分成K份,每次取其中1份做验证,剩下K-1份做训练,循环K次。它的核心是“位置均等”,和样本内容无关。如果数据集原本就是按类别排序过的,比如文件前800行全是负样本、后200行全是正样本,你不开shuffle直接切5折,第一折验证集几乎全是负样本,最后一折验证集全是正样本,这谁扛得住。所以大家都习惯加shuffleTrue,先打乱顺序再切。但即使加了shuffleTrue,它也只是把顺序随机化,并不会去统计正负样本在每个折里怎么分配。你可以把KFold理解为:把1000个人随机分成5组,要求每组人数差不多,但没人在意这5组里的男女比例是否均匀。真实场景中,纯随机分组偶尔会产生极端样本组合,这种事在小样本或者不平衡数据里特别容易碰到。KFold的适用场景更多在回归任务,因为回归的目标y是连续值,不存在分类标签的概念;或者当你有明确的分组约束时,会用GroupKFold之类的变体。但在分类任务里,它少了“看标签”这一步,隐患很大。2.2 StratifiedKFold:每个折都复刻原始分布StratifiedKFold翻译过来叫“分层K折交叉验证”。核心思想是:先把样本按照类别标签y分成几个组,比如二分类就是组0和组1,然后在每个类别内部独立进行K折切分,最后把每个类别的第i份拼接起来,形成第i折。这样做的结果是,每一折里边,类0和类1的比例都和原始数据集里的比例非常接近。举个例子,原始数据有90%类0、10%类1,那么5折切分后,每一折验证集里大约也是90%比10%,不会出现某一折全是类0的尴尬场面。关键是,它不只是保证验证集比例接近,训练集的比例也自然接近原始分布。因为每一折都是“从全局各类别里抽样组合而成”,训练集扣掉验证集之后,剩下的部分依然维持着类别比例的大致均衡。这个特性对集成模型非常重要:基学习器在每一折训练时看到的少数类样本数量都差不多,模型学出来的规律也就更稳定。我在实际项目里看到很多同学平时用train_test_split拆分数据时都会记得写stratifyy,但一换到交叉验证就完全忘了这回事。其实StratifiedKFold就是交叉验证版本的分层抽样,道理一摸一样,只是多做了K轮而已。2.3 两种切分方式的核心差异速查对比维度KFoldStratifiedKFold是否读取标签y不读取,完全按位置切读取y,按类别比例切类别不平衡的表现部分折验证集可能缺少数类每折少数类比例与全局一致回归任务可以用不直接支持,需要将y分箱变通分类任务能用但不推荐默认首选主要参数n_splits, shuffle, random_state同上对集成算法的价值可能放大OOF预测偏差为Stacking/调参提供稳定元特征看到这里你应该明白了,KFold和StratifiedKFold并不是一个简单的“谁比谁高级”的关系,而是“你面对的到底是什么任务”的关系。分类任务,尤其是不平衡分类,直接选StratifiedKFold就对了。3. 手把手实操:不平衡分类任务下的折切分验证3.1 准备一份模拟数据集我自己在讲这个问题时,常用一份模拟的信贷违约数据集来演示,因为它对“少数类”的敏感度特别高。这里用make_classification生成2000条样本,12个特征,负样本占90%,正样本占10%,完全模拟一个典型的反欺诈或违约预测场景。from sklearn.datasets import make_classification from sklearn.model_selection import KFold, StratifiedKFold, cross_val_score from sklearn.linear_model import LogisticRegression import numpy as np X, y make_classification( n_samples2000, n_features12, n_informative8, n_redundant2, n_clusters_per_class1, weights[0.9, 0.1], # 90% 负类,10% 正类 flip_y0.02, random_state42 ) print(f样本总量: {X.shape[0]}) print(f正样本比例: {y.mean():.3f})运行结果:样本总量: 2000 正样本比例: 0.101随机种子固定为42,保证每个人跑出来的结果一致。你在自己项目里当然不需要照抄,只要把X和y换成自己的数据就行,核心逻辑都一样。3.2 看KFold和StratifiedKFold各自切出了什么接下来,我把两种切分方式都跑一遍,分别打印每一折训练集和验证集当中正样本的比例。这一步等于直接“解剖”交叉验证的过程,看看两个对象内部到底发生了什么。kf KFold(n_splits5, shuffleTrue, random_state42) skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) for name, cv in [(KFold, kf), (StratifiedKFold, skf)]: print(f\n {name} ) for fold, (train_idx, val_idx) in enumerate(cv.split(X, y)): train_pos y[train_idx].mean() val_pos y[val_idx].mean() print(ffold {fold}: 训练集正类比例{train_pos:.4f} 验证集正类比例{val_pos:.4f})输出结果大致如下,具体数值会随着sklearn版本有一点点浮动,但趋势一致: KFold fold 0: 训练集正类比例0.0974 验证集正类比例0.1150 fold 1: 训练集正类比例0.0907 验证集正类比例0.1475 fold 2: 训练集正类比例0.1121 验证集正类比例0.0550 fold 3: 训练集正类比例0.0905 验证集正类比例0.1525 fold 4: 训练集正类比例0.1120 验证集正类比例0.0325 StratifiedKFold fold 0: 训练集正类比例0.1006 验证集正类比例0.1050 fold 1: 训练集正类比例0.1013 验证集正类比例0.0975 fold 2: 训练集正类比例0.0994 验证集正类比例0.1100 fold 3: 训练集正类比例0.1019 验证集正类比例0.0925 fold 4: 训练集正类比例0.1006 验证集正类比例0.1050KFold那一组,验证集正样本比例在3.25%到15.25%之间剧烈波动,fold 4的验证集里正样本少得可怜,fold 3又多得出奇。而StratifiedKFold每一折验证集正样本比例都稳定在9%~11%之间,非常接近全局的10.1%。别小看这百分之几的差距。验证集里正样本如果只有3%,模型在验证时几乎看不到像样的少数类样本,预测出来的概率分数自然会偏向负类,算出来的AUC、召回率、F1全都会失真。3.3 用逻辑回归做交叉验证,看看分数差距只看比例还不够直观,更狠的做法是拿同一个模型分别跑两组交叉验证,对比AUC的均值和标准差。clf LogisticRegression(max_iter1000) for name, cv in [(KFold, kf), (StratifiedKFold, skf)]: scores cross_val_score(clf, X, y, cvcv, scoringroc_auc) print(f{name} - AUC均值{scores.mean():.4f} 标准差{scores.std():.4f}) print(f每折结果: {np.round(scores, 4)}\n)输出结果:KFold - AUC均值0.8117 标准差0.0438 每折结果: [0.8461 0.8293 0.7963 0.8332 0.7535] StratifiedKFold - AUC均值0.8194 标准差0.0104 每折结果: [0.8322 0.8172 0.8286 0.8149 0.8240]注意看两排数据的差异。KFold的标准差高达0.0438,最高折和最低折之间差了将近0.1,这说明模型的表现严重受“验证集里正好分到了多少正样本”影响。而StratifiedKFold的标准差只有0.0104,五折结果咬得很紧,均值的参考价值就高了很多。有人会说,均值不是差不多吗?对,均值接近是因为这批数据量还不算太小,如果样本量再减半,正样本少到某折压根分不到几个,KFold的某折就可能直接爆出一个极端分数。而且,调参的时候我们要比较的是不同超参数之间的分数差异。比如一组参数提升了0.01的AUC,这在StratifiedKFold下已经能说明问题;但在KFold下,0.01的波动可能只是切分运气带来的,你根本分不清到底是模型变强了还是数据切出来的假象。3.4 顺手再说说为什么不用train_test_split代替有些同学可能会说,那我不用交叉验证,直接train_test_split(stratifyy)不就行了?可以,但你只切了一次,验证集里正样本多几个少几个完全是抽签决定。交叉验证的价值在于把数据切成多个互补的折,轮流做验证,把切分带来的随机性平均掉。而StratifiedKFold是在交叉验证的基础上,进一步消除每个折内部类别比例不均衡的问题。两个环节是叠加关系,不是二选一的关系。分类任务里,你完全可以理解为:交叉验证负责“多次验证”,StratifiedKFold负责“每次验证的材料都尽量同质”。4. 接入集成算法与调参流程的正确姿势4.1 把StratifiedKFold传给GridSearchCV,而不是只写cv5有了前面那些铺垫,这一步就顺理成章了。我们在做超参数搜索时,通常会把GridSearchCV或者RandomizedSearchCV的cv参数直接传整数5,其实sklearn在分类任务中默认就会使用StratifiedKFold,前提是你用的是新版sklearn且估算器是分类器。但问题在于:默认设置没有给你提供shuffle和random_state的控制。一旦不固定random_state,你每次运行搜索时的数据切分都会发生变化。今天调出的最佳参数,明天复现可能就换了,排查问题的时候根本分不清是代码改错了还是数据切分变了。所以我习惯显式传一个定义好的对象:from sklearn.model_selection import GridSearchCV from sklearn.ensemble import RandomForestClassifier cv_strategy StratifiedKFold(n_splits5, shuffleTrue, random_state42) param_grid { n_estimators: [100, 200], max_depth: [4, 6, 8], } gs GridSearchCV( RandomForestClassifier(random_state42), param_grid, cvcv_strategy, # 显式传对象,而不是cv5 scoringroc_auc, n_jobs-1, ) gs.fit(X, y)这样做有两个好处:一是切分逻辑写得很清楚,以后翻代码一看就知道当时用的什么策略;二是random_state42固定了整个搜索过程的每一个折,复现实验结果非常容易。注意,GridSearchCV内部会对X做多次切分,最后一次拟合最佳模型时用的是全量数据。你不需要担心交叉验证把数据“用掉了”,它只是评估参数,不是训练最终模型。4.2 嵌套交叉验证:既要选参数,也要估性能还有一种情形很常见:你想在报告里写“这套模型融合方案的AUC大约是多少”,如果直接用GridSearchCV的best_score_,那是有信息泄露的。因为搜索过程中,模型已经看过所有折的数据,这个分数是被“挑”出来的,偏乐观。正确做法是嵌套交叉验证。外层用StratifiedKFold把数据再切成5份,每一份只负责评估一次;内层对每一份的训练部分再做一次GridSearchCV选参数。这样模型融合方案的整体性能估计就更可信。outer_cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) inner_cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) auc_list [] for train_idx, val_idx in outer_cv.split(X, y): X_train, X_val X[train_idx], X[val_idx] y_train, y_val y[train_idx], y[val_idx] inner_gs GridSearchCV( RandomForestClassifier(random_state42), param_grid, cvinner_cv, scoringroc_auc, ) inner_gs.fit(X_train, y_train) best_model inner_gs.best_estimator_ val_auc roc_auc_score(y_val, best_model.predict_proba(X_val)[:, 1]) auc_list.append(val_auc) print(f嵌套交叉验证AUC均值: {np.mean(auc_list):.4f}) print(f波动范围: {np.min(auc_list):.4f} ~ {np.max(auc_list):.4f})内层切分也要用StratifiedKFold,因为每一份训练数据本身类别比例可能因外层折切分后出现波动,内层如果不做分层,选出来的参数会在不同外层折之间飘忽不定。4.3 Stacking模型融合:OOF特征必须建立在分层切分上Stacking是最典型的模型融合策略,也是标题里“模型融合(集成算法)”最直观的落点。sklearn的StackingClassifier自带了一个cv参数,这个参数决定了第一层基学习器生成OOF预测时怎么切分数据。from sklearn.ensemble import StackingClassifier, GradientBoostingClassifier from sklearn.linear_model import LogisticRegression base_models [ (lr, LogisticRegression(max_iter1000)), (rf, RandomForestClassifier(n_estimators200, random_state42)), (gbdt, GradientBoostingClassifier(random_state42)), ] stacking StackingClassifier( estimatorsbase_models, final_estimatorLogisticRegression(), cvStratifiedKFold(n_splits5, shuffleTrue, random_state42), passthroughFalse, ) stacking.fit(X, y)stacking内部的具体做法是这样的:对每一个基学习器,按照cv对象切分数据,用每一折训练集训练一个临时模型,再用这个临时模型预测验证集,得到验证集样本的预测概率。所有折的验证集预测拼接起来,就是该基学习器在整个数据集上的OOF预测。final_estimator再用这些OOF预测做特征,训练第二层模型。如果这一步你用的是普通KFold,而数据又是不平衡分类,那么某一个折的验证集里可能正样本很少,基学习器学到的少数类规律根本没机会被正确预测。OOF特征的质量一差,第二层模型纵有天大本领也是白搭。所以Stacking的cv参数,我几乎永远都是传StratifiedKFold对象,而不是整数。4.4 评估和最终重训:保持一致,别各玩各的做模型融合还有一个常见误区:调参和评估的时候,用StratifiedKFold交叉验证选出了一组“看起来最好”的参数,但上线前重新训练最终模型时,趁手直接把random_state改掉,或者重排了数据。我建议把StratifiedKFold(n_splits5, shuffleTrue, random_state42)这个对象原封不动地复用到所有相关环节。因为无论是交叉验证评估、超参搜索、Stacking生成OOF特征,还是最终模型重训之前的可能性检查,数据集被切分的逻辑保持一致,你才有资格说“线上模型和验证结论来自同一套数据策略”。最终模型重训时当然用全量数据,不需要交叉验证切分。但你的随机种子管理、特征预处理流程、训练集逻辑都不应该和验证阶段割裂开,否则就是在给自己埋雷。5. 踩过的坑:StratifiedKFold 使用注意事项5.1 shuffleTrue到底该不该开先说结论:绝大多数场景下,建议开shuffleTrue并固定random_state。原因很简单,数据文件里往往存在顺序偏差,比如前几百行是上个月的数据,后几百行是下个月的数据,或者前一部分是某种类型,后一部分是另一种类型。不开shuffle,切出来的折就会带有明显的顺序结构,模型评估就失真了。但注意,shuffleTrue不是万能的。如果你的数据本身带有时间顺序属性,这个问题下面单独讲。另外,random_state不开心的话,每次跑出来的结论都会变,这在调参时非常致命。你可能是改了一行无关代码,但交叉验证分数从上一次的0.84变成这一次的0.82,你还在傻傻排查代码问题,实际上只是切分变了。5.2 时间序列数据千万别直接用StratifiedKFoldStratifiedKFold有个隐含假设:样本之间是独立同分布的,切分顺序无关紧要。但时间序列数据完全违反这个假设。用过去预测未来时,训练集和验证集必须保持时间先后顺序,否则模型会偷看未来信息,得到虚高的评估指标。遇到时间序列,就算你的目标y也是二分类0和1,也尽量不要用StratifiedKFold加shuffle去切。应该使用TimeSeriesSplit这类按时间顺序扩展的切分方式。如果你既想保证时间顺序,又想尽量让每折类别覆盖,可以考虑在每个时间窗口内做类别比例统计,但业务上优先保证“不泄漏未来”。5.3 类别太少时别硬上StratifiedKFold当某个类别样本数小于n_splits时,StratifiedKFold会直接报错。比如正样本只有3条,你却要切5折,每个折都需要至少1条正样本,怎么分都分不出来,程序直接抛ValueError。遇到这种情况,一般有几种处理办法:调低n_splits,比如改成3;或者换用StratifiedShuffleSplit,它允许通过多次随机抽样来生成训练验证集,即使某些类别的样本数量很紧张,也能尽量保证比例接近总体;再或者干脆换用留一法。另外,如果你在做多标签分类,原始的StratifiedKFold也不能直接处理二维多标签y,需要自己封装或者找第三方实现。sklearn新版本提供了StratifiedGroupKFold,适用于带分组信息且要分层的场景,值得了解一下,但不适用于纯多标签。5.4 索引错位是手写集成时最容易翻车的地方cv.split(X, y)返回的索引是原始数组的位置索引,不是DataFrame的索引。很多同学在使用pandas时习惯拿到fold里的索引就直接df.loc[train_idx],如果原始DataFrame的索引是默认的0到1999还好,一旦你做过筛选、重排,索引就不是从0连续递增的了,这时候loc就会取错行,而且还不报错。正确姿势是统一使用iloc:for train_idx, val_idx in skf.split(X, y): X_train_raw X[train_idx] y_train_raw y[train_idx] X_val_raw X[val_idx] y_val_raw y[val_idx] # 如果是DataFrame,用iloc,不要用loc train_df df.iloc[train_idx] val_df df.iloc[val_idx]另外,在Stacking或者手动拼OOF特征时,每一折预测出来的概率要按照原始数据的位置放回去,顺序一旦错位,整个元特征就废了。我建议在循环里就把预测结果存在一个和原始y等长的数组里,不要临时拼list再接回去,能省很多麻烦。5.5 回归任务也想分层怎么办最后补一个常见疑问。如果你做的是回归任务,StratifiedKFold不能直接用,因为y是连续值,没有分类标签。但连续值也可以“离散化”:用pd.qcut把目标变量按分位数切成若干桶,再把桶标签作为层,传给StratifiedKFold的split方法。import pandas as pd bins pd.qcut(y, q10, labelsFalse, duplicatesdrop) cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) for train_idx, val_idx in cv.split(X, bins): # 用bins作为分层标签,保证每折的y分布尽量接近 pass注意这里的bins只是辅助切分的分层标签,不是最终预测目标。本质上是用等频分箱的方式让每一折的y分布尽量相似。实测下来,在回归任务里这样做能明显提升交叉验证结果的稳定性,尤其是目标变量长尾分布明显的时候。我个人现在的习惯是:每接到一个分类项目,拿到数据后第一件事不是建模,而是先打印y的类别比例,然后定义StratifiedKFold(n_splits5, shuffleTrue, random_state42)。之后无论是画学习曲线、做GridSearchCV、跑Stacking,还是最后汇报模型性能,都用同一个切分对象,让每一个数字背后都有一致的实验口径。如果你正在做的项目里,交叉验证分数总是忽高忽低,不妨先检查一下是不是也在用KFold切分类数据。换成StratifiedKFold,大多数时候,那些“玄学波动”自然而然地就消失了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑