☰
10折交叉验证:从一行代码到一套方法论
2026/11/3 22:29:24 网站建设 项目流程

做模型评估这些年,我见过太多人把交叉验证当成一个“调包就完事”的步骤:cross_val_score(model, X, y, cv=10)一行代码跑完,看到一个均值加标准差,就心安理得地写进报告。可真要追问一句“这10折到底怎么切的、为什么是10、方差大说明什么、分层和普通切法差在哪”,能讲清楚的人并不多。10折交叉验证看着简单,背后牵扯的是偏差-方差权衡、数据泄漏防范、评估指标的稳定性,以及一个经常被忽略的事实——它评估的是“流程”,不是“模型对象”。

这篇东西就是想把10折交叉验证从“一行代码”拆回到“一套方法论”。我会从它解决的问题讲起,把切分逻辑、参数选择、分层与重复的取舍、实操代码、常见翻车场景一个个掰开。适合刚入门机器学习、正在做第一个完整项目的人,也适合已经用了很久但没系统想过“为什么是10折”的从业者。读完你至少能做到两件事:一是能根据数据规模和任务类型自己决定折数和切分策略,而不是无脑填10;二是能识别出那些让交叉验证结果虚高或失真的隐蔽陷阱。

1. 先搞清楚10折交叉验证到底在解决什么问题

1.1 单次划分的致命缺陷:你评估的到底是模型还是运气

很多人做评估的第一步是train_test_split,切个 8:2 或者 7:3,训练集拟合、测试集打分,完事。这个做法本身没错,问题在于它的结果极不稳定。同一份数据,你换个random_state,测试集换了批样本,准确率可能从 0.82 跳到 0.76。这不是模型变了,是“考卷”变了。

我用一个生活化的类比:你想知道一个学生的真实水平,只给他出一套卷子,这套卷子恰好考了他擅长的题型,他考了95;换一套全是他没复习到的,他考了60。你拿哪次成绩代表他?都不行。单次划分的本质问题是测试集太小且随机,评估结果的方差极大,你无法区分“模型好”和“这次切分运气好”。

10折交叉验证的思路就是把“出一套卷子”变成“出十套卷子轮流考”。把数据均分成10份,每次拿1份当测试、其余9份当训练,轮10次,每个样本都恰好被测试一次。最后把10次的得分平均,得到一个更稳的估计。这个“更稳”不是玄学,是统计学上的方差缩减:单次评估的方差被10次独立(近似)评估平均掉了。

这里有个关键认知必须先立住:交叉验证评估的不是某一个训练出来的模型,而是“训练流程”。每次折都会产出一个不同的模型参数,你最后拿到的均值,衡量的是“用这套特征、这个算法、这组超参数、在这个数据分布上跑一遍训练流程”的期望表现。理解这一点,后面很多困惑(比如“我交叉验证完该用哪个模型”)就迎刃而解了。

1.2 为什么偏偏是10折:偏差与方差的那杆秤

折数 k 的选择是一场权衡,不是拍脑袋。先把两个极端想清楚:

  • k 越大(比如留一法,k=n):每次训练用的数据几乎等于全量,训练集规模接近真实部署时的数据量,所以评估的偏差小,更接近“用全部数据训练后”的真实表现。但代价是训练次数多(n次),计算成本高;而且各折训练集高度重叠,折与折之间的模型高度相关,平均之后方差反而不一定降得下来,评估结果对数据扰动敏感。
  • k 越小(比如 k=3 或 5):训练集只用了全量的 2/3 或 4/5,训练数据偏少,模型表现被低估,偏差大;但训练次数少、成本低,各折训练集重叠度低,平均后方差较小。

10折落在一个经验上的甜点区。训练集占 90%,偏差已经很小,和全量训练的差距可以接受;同时训练10次的计算成本对绝大多数中小数据集完全扛得住。业内有大量实验和经验总结指向 5 到 10 折是比较稳的区间,10 折因为“训练比例 90%”这个整数好记、偏差更小,成了默认首选。但我要强调:10 不是教条。数据量很大(几十万上百万样本)时,5 折甚至 3 折就够,因为此时训练集 80% 和 90% 的差别对模型影响微乎其微,而计算成本差一倍;数据量极小(几十上百条)时,10 折可能每折测试集只有几条样本,评估噪声极大,这时候反而要考虑留一法或者重复多次的 5 折。

下面这张表是我自己选折数时的判断依据,可以直接参考:

数据规模推荐折数理由
小于 200 条5 折 + 重复多次,或留一法10 折每折测试集太小,单折得分噪声大
200 到 1 万条10 折(默认)偏差与成本平衡点,最通用
1 万到 10 万条5 折训练比例差异影响小,省一半算力
大于 10 万条3 到 5 折计算成本主导,偏差已可忽略

注意:折数不是越多越“严谨”。我见过有人在小数据集上硬上 20 折,结果每折测试集就三五条样本,算出来的标准差大得离谱,反而误导判断。折数的下限应该保证每折测试集有足够样本让指标有意义。

1.3 交叉验证结果的三个组成部分:均值、标准差、以及你没看到的那些折

跑完 10 折,你会拿到 10 个得分。绝大多数人只看mean(),稍微认真点的会看std()。但这两个数字背后藏着更多信息,值得展开。

均值代表流程的期望表现,这是你写进报告的主数字。标准差反映评估的稳定性:标准差大,说明模型在不同数据子集上表现差异大,可能意味着模型对数据分布敏感、样本量不足、或者数据本身异质性强。我个人的经验阈值是:如果标准差超过均值的 5% 到 10%,就要警惕了,得去看看是不是某几折特别差。

但真正有价值的是逐折得分本身。我习惯把 10 个得分打印出来看分布,而不是只看汇总。有一次做一个分类任务,均值 0.88 看着很漂亮,但逐折一看,有两折只有 0.71,其余都在 0.92 以上。追下去发现是那两折恰好把某个少数类样本几乎全划进了测试集,模型没见过这类样本自然崩。这个问题在只看均值时完全被掩盖了。所以我的习惯是:永远先看逐折明细,再看汇总。

2. 切分逻辑与分层策略:魔鬼全在细节里

2.1 普通K折的切分过程:一次把机制讲透

普通 K 折(KFold)的切分逻辑很直白,但值得完整走一遍,因为后面所有变体都是在这个基础上改的。

假设有 100 条样本,索引 0 到 99,k=10。标准做法是把索引打乱后均分成 10 段,每段 10 条。第 1 轮:第 1 段当测试,第 2 到 10 段当训练;第 2 轮:第 2 段当测试,其余当训练;以此类推。每个样本在 10 轮里恰好当过一次测试样本,当九次训练样本。

这里有个容易被忽略的细节:打乱(shuffle)到底要不要开。如果数据本身是按某种顺序排列的(比如按时间、按类别、按采集批次),不打乱直接切分会导致某一折的测试集全是同一类样本,评估彻底失真。所以绝大多数情况下shuffle=True是必须的。但反过来,如果是时间序列数据,打乱又是大忌——那会造成“用未来预测过去”的泄漏。这个矛盾后面单独讲。

我用一段伪代码把机制固定下来,方便你对照理解:

import numpy as np def manual_kfold(n_samples, k=10, shuffle=True, seed=42): idx = np.arange(n_samples) if shuffle: rng = np.random.RandomState(seed) rng.shuffle(idx) folds = np.array_split(idx, k) for i in range(k): test_idx = folds[i] train_idx = np.concatenate([folds[j] for j in range(k) if j != i]) yield train_idx, test_idx

这段代码和 sklearn 的KFold行为一致,自己写一遍的好处是你能清楚看到“训练集是其余九折拼接”这个动作,理解为什么每次训练的模型都不同、为什么评估的是流程而非单一模型。

2.2 分层K折:分类任务几乎必须开的开关

普通 K 折有个隐患:它只保证样本数量均分,不保证类别比例均分。如果数据类别不平衡(比如正负样本 9:1),随机切分很可能出现某一折测试集里正样本扎堆、另一折里几乎没有,导致逐折得分剧烈波动,均值也不可信。

分层 K 折(StratifiedKFold)解决的就是这个:切分时保证每一折的类别比例和整体一致。100 条样本、正负 9:1,那每折 10 条里大致就是 9 正 1 负。这样每折的评估条件可比,方差显著下降。

我的实操建议很直接:只要是分类任务,默认用 StratifiedKFold,不要用 KFold。代价几乎为零,收益是评估稳定性明显提升。回归任务没有“类别”概念,用普通 KFold 即可;但如果回归目标分布严重偏斜,可以考虑先对目标分箱再分层(sklearn 的StratifiedKFold只接受分类标签,回归场景需要自己构造分箱标签)。

这里有个坑要提醒:分层的前提是每个类别在每折里都至少有一个样本。如果某个类别总共只有 3 条样本,你却要 10 折,那必然有折分不到这个类别,StratifiedKFold会直接报错或者行为异常。这种情况要么减少折数,要么对极少数类做特殊处理(合并、过采样后再评估,但要注意过采样只能在训练折内做,绝不能在全量数据上做,否则泄漏)。

2.3 分组K折:当样本之间存在“血缘关系”时

这是最容易被忽视、也最容易造成评估虚高的场景。假设你在做医疗影像分类,同一个病人有多张影像;或者做用户行为预测,同一个用户有多条记录。这些样本之间不是独立的——同一病人的影像高度相似,同一用户的行为高度相关。

如果直接用普通 K 折或分层 K 折,同一个病人的影像可能一部分在训练集、一部分在测试集。模型在训练时“见过”这个病人的特征,测试时自然表现好,但这个好是假的——它评估的是“对见过的病人做预测”,而真实部署时你要预测的是没见过的病人。这就是典型的数据泄漏,评估结果会显著虚高。

分组 K 折(GroupKFold)的做法是:按组划分,保证同一组的所有样本要么全在训练集、要么全在测试集。上面例子里就是以病人 ID 为组,同一个病人的影像不会跨训练测试。这样评估出来的才是“对全新个体”的泛化能力。

判断要不要用分组切分,问自己一个问题:样本之间是否存在“同源”关系,使得模型能通过记忆同源样本而非学到通用规律来得分?病人、用户、设备、采集批次、文档来源,这些都是典型的组。我踩过一次坑:做一个文本分类,同一篇长文被切成了多个段落当独立样本,普通切分下准确率 0.95,换成按文档分组后掉到 0.78。那 0.17 的差距全是泄漏撑起来的假象。

2.4 时间序列切分:绝不能打乱的那一类

时间序列数据有严格的时序依赖,普通 K 折的随机打乱会造成“用未来信息预测过去”,这是最隐蔽也最严重的泄漏之一。举个直观的例子:你要预测某天的销量,如果训练集里包含了这天之后的数据,模型实际上“偷看”了未来,评估结果毫无意义。

时间序列的正确切法是滚动向前:用前面的时间段训练、紧邻的后面一段测试,然后窗口整体前移。sklearn 提供TimeSeriesSplit,它的行为是:第 1 折用前一段训练、下一段测试;第 2 折把测试段并入训练、再取下一段测试;以此类推。注意它和普通 K 折有个本质区别——训练集是累积增长的,且测试段永远在训练段之后。

这里没有“10 折”的固定说法,折数取决于你留了多少个测试窗口。我的经验是:至少留 3 到 5 个测试窗口,每个窗口的样本量要足够让指标稳定。窗口太窄,单折指标噪声大;窗口太宽,测试次数少,均值不稳。另外要留意,时间序列交叉验证的逐折得分通常会有明显趋势(越往后数据越多、模型越好,或者分布漂移导致越往后越差),这时候看趋势比看均值更有信息量。

3. 从一行代码到一套流程:实操落地与参数取舍

3.1 最小可用代码:分类与回归两套模板

先把最常用的两套模板给出来,可以直接抄。分类任务用分层 10 折:

from sklearn.model_selection import StratifiedKFold, cross_val_score from sklearn.ensemble import RandomForestClassifier import numpy as np skf = StratifiedKFold(n_splits=10, shuffle=True, random_state=42) model = RandomForestClassifier(n_estimators=200, random_state=42) scores = cross_val_score(model, X, y, cv=skf, scoring='f1_macro', n_jobs=-1) print("逐折得分:", np.round(scores, 4)) print("均值: %.4f 标准差: %.4f" % (scores.mean(), scores.std()))

回归任务用普通 K 折,指标换成负均方误差(sklearn 的 scoring 约定是“越大越好”,所以误差类指标带负号):

from sklearn.model_selection import KFold, cross_val_score from sklearn.linear_model import Ridge kf = KFold(n_splits=10, shuffle=True, random_state=42) scores = cross_val_score(Ridge(alpha=1.0), X, y, cv=kf, scoring='neg_mean_squared_error', n_jobs=-1) rmse_scores = np.sqrt(-scores) print("逐折RMSE:", np.round(rmse_scores, 4)) print("均值: %.4f 标准差: %.4f" % (rmse_scores.mean(), rmse_scores.std()))

几个参数值得说明。shuffle=True配合固定random_state保证可复现,这在写报告和对比实验时是刚需。n_jobs=-1用满所有核并行跑折,10 折在多数机器上能明显提速。scoring千万别用默认——分类默认是 accuracy,类别不平衡时它会骗你;我一般分类用f1_macro或roc_auc,回归用neg_mean_squared_error或neg_mean_absolute_error,具体看业务更在意哪类错误。

3.2 交叉验证 + 超参数搜索:嵌套才是正确姿势

这是最容易做错的一环。很多人这么干:先用交叉验证调超参数,选出最优参数,然后报告这个最优参数下的交叉验证得分。这个得分是偏高的,因为你在调参过程中已经用测试折的信息挑过模型了,相当于“用考卷答案选出了最会做这套卷子的学生,再拿这套卷子给他打分”。

正确做法是嵌套交叉验证:外层交叉验证负责评估,内层交叉验证负责在每折的训练数据内部调参。外层每折的测试数据从头到尾没参与过任何调参决策,评估才是干净的。sklearn 里用GridSearchCV套在cross_val_score里就能实现:

from sklearn.model_selection import GridSearchCV, StratifiedKFold, cross_val_score from sklearn.svm import SVC inner_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=1) outer_cv = StratifiedKFold(n_splits=10, shuffle=True, random_state=2) param_grid = {'C': [0.1, 1, 10], 'gamma': ['scale', 0.01, 0.001]} clf = GridSearchCV(SVC(), param_grid, cv=inner_cv, scoring='f1_macro', n_jobs=-1) nested_scores = cross_val_score(clf, X, y, cv=outer_cv, scoring='f1_macro', n_jobs=-1) print("嵌套CV均值: %.4f" % nested_scores.mean())

代价是计算量变成外层折数乘以内层折数乘以参数组合数,10×5×9 就是 450 次训练。数据量大时确实肉疼,但如果你要报告一个可信的泛化性能,这个代价值得付。如果实在扛不住,退一步的做法是:调参用一套独立的验证集,最终评估再用交叉验证,但前提是验证集和评估数据严格分开。

3.3 重复交叉验证:小数据集的稳定器

10 折跑一次,切分方案是固定的(给定随机种子)。如果数据量不大,单次 10 折的均值本身也有波动——换个种子,均值可能差个零点几。这时候用重复 K 折(RepeatedStratifiedKFold)能进一步降方差:把整个 10 折流程重复若干次,每次用不同的随机切分,最后把所有折的得分一起平均。

from sklearn.model_selection import RepeatedStratifiedKFold rskf = RepeatedStratifiedKFold(n_splits=10, n_repeats=5, random_state=42) scores = cross_val_score(model, X, y, cv=rskf, scoring='f1_macro', n_jobs=-1) print("重复CV均值: %.4f 标准差: %.4f" % (scores.mean(), scores.std()))

重复次数怎么定?我的经验是小数据集(几百条以内)重复 5 到 10 次,中等数据集重复 3 到 5 次,大数据集一次就够。注意重复交叉验证的得分之间不再独立(同一批样本被反复评估),所以它的标准差不能按独立样本那样解读,更多是作为稳定性的参考。这一点很多教程不讲,容易让人误读。

3.4 数据预处理必须放进Pipeline:否则泄漏找上门

这是交叉验证实操里最高频的翻车点,没有之一。标准化、归一化、缺失值填充、特征选择、降维、过采样——这些步骤如果在全量数据上先做一遍,再拿去做交叉验证,测试折的信息就通过预处理参数泄漏进了训练过程。

举个具体的:你在全量数据上算均值方差做标准化,这个均值方差里包含了测试折的样本信息;模型在训练时用的标准化参数“知道”测试集长什么样。结果就是评估虚高。正确做法是把预处理和模型打包进Pipeline,让交叉验证在每一折内部独立完成“拟合预处理器 + 转换 + 训练模型”:

from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.feature_selection import SelectKBest, f_classif pipe = Pipeline([ ('scaler', StandardScaler()), ('select', SelectKBest(f_classif, k=20)), ('clf', RandomForestClassifier(n_estimators=200, random_state=42)) ]) scores = cross_val_score(pipe, X, y, cv=skf, scoring='f1_macro', n_jobs=-1)

Pipeline 的关键价值在于:交叉验证每次调用fit时,scaler 和 selector 只在当前折的训练数据上拟合,测试折对它们完全不可见。特征选择尤其要注意——在全量数据上做特征选择再交叉验证,是文献里被反复点名的泄漏重灾区,虚高幅度可能达到十几个百分点。

4. 常见翻车场景与排查速查表

4.1 得分虚高的五大元凶

交叉验证得分异常漂亮时,先别高兴,按下面这张表逐项排查。我这些年遇到的虚高案例,九成逃不出这几类:

现象可能原因排查方法修复方式
得分接近 1.0 或异常高预处理在全量数据上做过检查是否有 fit 在切分之前全部塞进 Pipeline
得分远高于单次留出验证特征选择/调参用了全量数据回溯特征选择时机特征选择进 Pipeline,调参用嵌套 CV
分类得分虚高同源样本跨训练测试检查是否有组结构改用 GroupKFold
时间序列得分虚高随机打乱造成未来泄漏检查是否 shuffle改用 TimeSeriesSplit
逐折得分波动极大类别不平衡未分层看每折类别比例改用 StratifiedKFold

这里我想多说一句“得分接近 1.0”这个信号。真实业务数据上,除非任务极其简单或者数据有强泄漏,否则 0.99 这种数字基本不可信。我个人的经验是:如果一个 baseline 模型在没怎么调的情况下就冲到 0.98 以上,第一反应应该是“哪里漏了”,而不是“我的模型真强”。这种直觉救过我很多次。

4.2 逐折得分诊断:把标准差用起来

前面提过要看逐折明细,这里给一个具体的诊断流程。拿到 10 个得分后,我一般做三件事:

第一,算变异系数(标准差除以均值)。超过 0.1 就要警惕,说明评估不稳定。第二,看最差的那一折,如果它和均值差距超过两个标准差,去查这一折的测试集有什么特殊——是不是某个类别扎堆、是不是样本量异常、是不是数据质量有问题。第三,如果是分类任务,把每折的混淆矩阵打出来看,往往能发现某一折某个类别几乎全错,这比看总分有用得多。

from sklearn.model_selection import cross_validate results = cross_validate(pipe, X, y, cv=skf, scoring=['f1_macro', 'accuracy', 'roc_auc'], return_train_score=True, n_jobs=-1) for metric in ['test_f1_macro', 'test_accuracy', 'test_roc_auc']: s = results[metric] print("%s: 均值 %.4f, 标准差 %.4f, 最差折 %.4f" % (metric, s.mean(), s.std(), s.min()))

cross_validate比cross_val_score更好用的地方在于能一次算多个指标,还能返回训练得分。训练得分和测试得分的差距是判断过拟合的直接依据:训练得分远高于测试得分,说明模型在记忆训练数据;两者都低,说明欠拟合。这个对比在单次划分里也能做,但交叉验证下更稳。

4.3 类别不平衡下的评估陷阱

类别不平衡是交叉验证评估的另一个重灾区。假设正负样本 99:1,一个把所有样本都预测为负的“傻子模型”,准确率也有 99%。如果你用 accuracy 做交叉验证指标,会得到一个漂亮但毫无意义的数字。

这种场景下,指标选择比折数选择重要得多。我一般用f1_macro(对每个类别一视同仁)或者roc_auc(对阈值不敏感),如果更关注少数类的查全率,用recall或f1的少数类版本。另外,分层切分在这里是必须的,否则某些折可能一个正样本都没有,指标直接算不出来。

还有一个隐蔽的坑:过采样必须在训练折内做。如果你先用 SMOTE 把全量数据过采样到平衡,再做交叉验证,那合成的少数类样本可能同时出现在训练和测试折里(因为过采样是基于全量分布的),造成泄漏。正确做法是把过采样器放进 Pipeline,只在每折的训练数据上拟合和生成。

from imblearn.pipeline import Pipeline as ImbPipeline from imblearn.over_sampling import SMOTE pipe = ImbPipeline([ ('smote', SMOTE(random_state=42)), ('clf', RandomForestClassifier(random_state=42)) ]) scores = cross_val_score(pipe, X, y, cv=skf, scoring='f1_macro', n_jobs=-1)

注意这里用的是 imblearn 的 Pipeline 而不是 sklearn 的,因为 sklearn 的 Pipeline 不支持在 fit 阶段改变样本数量。这个细节很多人不知道,直接用 sklearn 的 Pipeline 套 SMOTE 会报错或者行为不对。

4.4 计算成本失控时的取舍

10 折交叉验证乘以超参数搜索乘以重复次数,计算量很容易爆炸。我遇到过最夸张的一次,一个同事把 10 折、5 次重复、网格 200 组参数、内层 5 折全叠一起,单次实验要跑大半天。这种时候要有取舍意识。

我的优先级排序是:先保证评估干净(Pipeline、分层、分组正确),再考虑降计算量。降成本的手段按推荐顺序:一是减少重复次数(大数据集一次就够);二是用随机搜索代替网格搜索(RandomizedSearchCV,参数空间大时效率高得多);三是先用小折数(5 折)做粗筛,选出有希望的参数区间再用 10 折精评;四是n_jobs=-1并行。最不该做的就是“为了快而把预处理挪到切分外面”,那是拿评估可信度换时间,得不偿失。

5. 几个容易被问倒的细节问题

5.1 交叉验证之后,最终模型用哪个?

这是新手最常问的。答案取决于你要干什么。如果目的是报告泛化性能,那交叉验证的均值就是答案,你不需要“选一个模型”,因为评估的是流程。如果目的是部署一个最终模型,标准做法是用全部数据、以交叉验证选定的超参数重新训练一个模型——注意这个最终模型没有对应的“交叉验证得分”,它的性能估计就是之前交叉验证的均值。

有人会问:那 10 折训练出来的 10 个模型,能不能挑一个最好的部署?不建议。那 10 个模型各自只用了 90% 的数据,而且“挑最好”本身引入了选择偏差。用全量数据重训是更干净的做法。如果数据量小到重训不稳定,可以考虑把 10 个模型做集成平均,但这是另一个话题了。

5.2 折数和随机种子要不要写进报告?

要。可复现性是评估的基本要求。我写报告或实验记录时,一定注明:折数、是否分层、是否分组、随机种子、评估指标、预处理是否在 Pipeline 内。这几项缺一项,别人就无法复现你的数字。尤其是随机种子——同样的 10 折,种子不同均值可能差零点几,不写清楚会让人误以为结果不可靠。

5.3 交叉验证得分和真实上线表现差很多,怎么办?

这是最扎心也最常见的问题。交叉验证假设“训练数据和未来数据同分布”,如果线上数据分布漂移了,交叉验证再严谨也预测不准。这时候要做的是:检查线上数据和训练数据的分布差异(特征分布、类别比例),考虑用时间上更近的数据做验证,或者引入在线监控持续跟踪。交叉验证是离线评估工具,它保证的是“在历史数据分布下流程的期望表现”,不保证未来。理解这个边界,才不会对交叉验证有不切实际的期待。

我个人在实际操作中的体会是,10 折交叉验证的价值不在于那个均值数字本身,而在于它逼你把整个流程想清楚:数据怎么切、预处理在哪做、指标选什么、结果怎么解读。一行代码能跑出数字,但跑不出可信度。每次我怀疑结果有问题时,回头查的永远是切分逻辑和预处理时机这两件事,十有八九问题就出在那儿。最后分享一个小习惯:任何一次交叉验证,我都会先把逐折得分和训练/测试得分对比打出来看一眼,再决定要不要信这个均值——这个动作花不了几秒钟,但帮我挡掉过不少假象。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询