机器学习验证集与测试集:评估流程、数据泄漏与划分方案
2026/9/19 2:58:28 网站建设 项目流程

训练到第 37 轮的时候,验证集上的准确率开始往下掉,我盯着终端里那行跳动的数字犹豫了很久,最后还是忍不住把测试集跑了一遍——0.91。当时挺高兴,第二天换了一组超参数又跑,0.93。第三次跑完发现测试集掉到 0.88,我才意识到自己已经把一个本该只用来验收的集合,当成第二个验证集在反复调参了。这个问题在机器学习里极其常见,常见到你身边十个做过项目的人里,可能有六七个都干过同样的事,只是程度深浅不同。验证集和测试集到底有什么区别,看起来是个入门级问题,但真正把它想透,会直接影响你评估模型时的可信度,影响你对“这个模型到底能不能上线”的判断。

如果你正在入门机器学习、准备做课程项目、写实验报告,或者已经工作但一直没搞清楚评估流程该怎么走,那这篇内容应该能帮你把这条线理顺。我会先从三个集合的职责讲起,再拆到泛化误差和信息泄漏这些底层逻辑,然后给一套可以直接复用的数据划分方案和代码,最后把我在实际项目和各类作业里踩过的坑整理成一张速查表。核心关键词就三个:机器学习、验证集、测试集,全程围绕它们展开,但不会只停留在“验证集调参、测试集评估”这种一句话结论上。

1. 先把三个角色的分工掰开揉碎

很多人第一次接触数据划分,记住的是“训练集、验证集、测试集,比例 6:2:2”。这句话没错,但它只告诉了你切几块,没告诉你为什么必须切、每块的身份是什么。身份搞不清,比例记得再熟也会用错。

1.1 训练集、验证集、测试集的真实职责

把机器学习建模想象成一场开卷考试的准备过程。训练集是你手头的教材和习题册,模型在这上面反复做题、修正参数,把规律一点点刻进权重里。这个过程是“允许犯错并且鼓励犯错”的,损失函数就是那个不断告诉你错在哪的老师。模型在训练集上的表现,本质上只说明它“记住了多少给定样本的答案”,说明不了别的。

验证集扮演的是模拟考的角色。它不参与梯度更新,模型看不到它的标签去反传,但你会用它来做一系列决策:这个网络是选三层还是五层,学习率定 1e-3 还是 3e-4,正则项系数取多大,早停要停在第几轮。所有这些选择都会“消费”验证集的信息。你每根据验证集分数调整一次超参数,验证集的独立性就被削弱一分。

测试集是真正的高考,而且是一场只能考一次的考试。它不出现在任何训练、调参、特征筛选、模型选择的环节里,只在最后用一次,用完之后那个数字就是你对这个模型能力的最终陈述。关键就在于“一次”这两个字,后面会展开讲为什么。

集合是否参与参数更新是否用于决策使用次数典型比例
训练集反复迭代60%~98%
验证集是(调参、选型、早停)多次1%~20%
测试集一次10%~20%

1.2 一句话抓住核心差异:验证集参与决策,测试集只负责验收

如果只允许用一句话区分验证集和测试集,我会说:验证集是模型的一部分,测试集不是。这句话听起来有点绝对,但逻辑上站得住。因为你在调参的时候,人的判断已经通过验证集分数注入到了模型配置里,模型最终的形态里包含了验证集的信息。换句话说,验证集间接参与了模型的构建过程,它是建模这条流水线上的一环。

测试集一旦被纳入决策,它就从“验收员”变成了“教练组成员”。你看到测试集分数低,回头改了模型再测一次,这时测试集的信息就泄漏进模型了,它测出来的数字不再是你对真实场景表现的估计,而是你针对这批特定样本反复优化后的结果。这就是为什么很多论文里的高分模型一换数据集就露馅,也是为什么工业界对测试集的管控会严格到单独隔离、专人保管、只跑一次的程度。

注意:判断一个集合是验证集还是测试集,不看它的名字或文件名,只看你拿它做过什么。如果你对某个集合做过任何一次“因为它的分数而修改模型”的操作,它就是验证集,哪怕你一直叫它 test。

1.3 一个生活类比:习题册、模拟考和高考

用备考来类比会更直观。习题册(训练集)上的题你反复做,做到能背下来都不奇怪,但背下习题册不代表会考试。模拟考(验证集)的价值在于,它出的题和习题册不一样,能暴露你“只会背原题”的问题;同时它的难度和真实考试接近,所以你用它来判断“我现在的水平够不够”。模拟考可以考很多次,每次考完分析错题、调整复习策略,这都很合理。

但高考(测试集)只有一次。如果你提前偷看高考题并据此调整复习重点,那考出来的分数就不再能反映你的真实水平了——它反映的是你“针对这套题准备过”的水平。机器学习里的测试集就是同样的道理,它存在的唯一意义,是给你一个无偏的、对未知数据表现的估计。

2. 从泛化误差说起:为什么非要这么别扭地分三份

明白了分工,下一个问题自然是:为什么不能只用训练集和验证集,非要留一个测试集不用?为什么验证集用完还要留一手?这背后是泛化误差这个概念的完整逻辑。

2.1 模型在训练集上表现好,说明不了任何事情

一个参数量足够大的模型,理论上可以完美拟合任意训练样本,哪怕标签是随机生成的噪声。这不是危言耸听,而是有过经典实验验证的:把真实数据的标签随机打乱后训练,模型依然能把训练集损失降到接近零,但它在任何新数据上的表现都等同于瞎猜。这说明模型优化的目标(训练损失)和你真正关心的目标(在新数据上的表现)之间,隔着一层很厚的墙。

这层墙的来源有两个。一是模型容量太大、数据太少,模型有足够的自由度去“记住”每个样本的细节,包括噪声;二是数据分布本身可能存在偏移,训练时见过的样本不能代表未来的样本。无论哪种情况,训练集分数都会系统性高估真实能力。你需要一个模型没见过的集合来戳破这个错觉,这就是验证集存在的第一层理由。

2.2 验证集的偏置是怎么一点点累积出来的

验证集本身是干净的,问题出在人的反复使用上。一个典型的调参过程大概是这样:先试学习率 1e-2,验证集 0.82;换成 1e-3,0.87;再加一层 dropout,0.89;把 batch size 从 32 调到 64,0.905;换优化器,0.91;继续调权重衰减……你一共试了四十组配置,最后选了验证集分数最高的那组。

这个流程本身没问题,问题在于你做了四十次比较,相当于在验证集上做了四十次“选择”。选择次数越多,你选中的那组配置在验证集上的分数就越可能带有运气成分。极端情况下,如果验证集只有 200 条样本,你试够多的配置,总能找到一组在这 200 条上刚好表现好的,但这组配置换到别的数据上未必好。这种现象就是验证集过拟合,它的本质是多重比较带来的选择性偏置。

大数据集下这个问题不算严重,因为验证集足够大,随机波动会被平均掉。但在小数据集上,比如只有一两千条样本的课程项目,验证集可能只有两三百条,这时候反复调参带来的偏置会非常明显,测试集和验证集的分数差距经常能到三到五个百分点。

提示:如果你发现测试集分数明显低于验证集分数(比如低 5% 以上),先别急着说“测试集更难”,大概率是你在验证集上调得太狠了。可以先检查一下调参次数和验证集规模。

2.3 测试集为什么只能用一次

测试集的使命是提供一个无偏估计。所谓无偏,是指这个估计不受你任何主观选择的影响。一旦你根据测试集结果做了任何决策,这个无偏性就被破坏了。破坏的路径有好几条,而且往往很隐蔽:看到测试集某个类别表现特别差,回去补了那类的数据;发现测试集分数不理想,换了个模型结构再测一遍;甚至只是简单地“多跑几次取最好的那个结果”,这些操作都会让测试集逐步变成第二个验证集。

更麻烦的是,这种信息泄漏通常没有明显的外部迹象。你只是觉得“再试一次就好”,试到第五次的时候你自己都记不清之前试过什么了,但测试集的信息已经实实在在进入了你的决策链。所以业界的标准做法是:测试集划分出来后,除了最终评估那一次,平时开发阶段碰都不碰,连看都不看。需要用数据做决策的时候,一律用验证集,哪怕验证集有偏也比污染测试集强。

这里有个常见的困惑:如果只用验证集,那最终报告的数字该用哪个?答案是用测试集的数字报告模型性能,用验证集的数字指导开发过程,两者各司其职,不要混用。如果实在没有条件留独立测试集(比如数据量极小),退而求其次的做法是用交叉验证的折外预测作为测试集的替代,但这属于权宜之计,需要在报告里说清楚。

3. 落地实操:一份能直接抄的数据划分方案

道理讲完了,接下来是能跑起来的方案。数据划分看着简单,但实际项目里翻车的地方特别多,尤其是涉及到类别不平衡、分组结构、时间序列这几类数据时,随手写的 train_test_split 大概率会出问题。

3.1 划分比例到底怎么定

教科书上的 6:2:2 是个不错的起点,但它不是铁律。比例的确定要考虑两个因素:数据总量和调参的强度。

数据量在十万条以上时,验证集和测试集各取 5% 到 10% 通常就够了,多出来的数据留给训练集收益更大。因为样本量大,5% 的验证集可能都有五千条,足够支撑几十次超参数比较了。数据量在一万条左右时,10% 到 15% 比较合适。数据量在一千条以下时,比例本身已经不是主要矛盾了,这时候应该考虑交叉验证,因为固定留出的验证集太小,分数波动会大到没法做决策。

还有一个容易被忽略的点是:验证集要能支撑你做决策,而不是仅仅够跑通流程。如果你打算比较十组超参数,那验证集至少要保证分数差异超过随机波动才行。一个粗略的经验是,二分类任务的验证集里每类样本最好不少于 100 条,否则准确率的置信区间会宽到让所有配置看起来都差不多。

数据量级训练集验证集测试集备注
>10 万90%5%5%验证集足够大,可放心调参
1 万~10 万80%10%10%最常见的配置
1000~1 万70%15%15%小数据集,注意调参次数
<1000改用 K 折交叉验证更稳

3.2 分层抽样、分组切分、时序切分:三种必须避开的坑

类别不平衡时必须分层抽样。假设你做一个 1:99 的欺诈检测任务,一万条数据里只有一百条正样本。如果随机切分,验证集里可能只有十条正样本,甚至更少。这时候验证集准确率几乎完全由负样本决定,模型把所有样本都判为负类也能拿到 99% 的分数,你根本看不出问题。分层抽样保证每个集合里的类别比例和原始数据一致,这是处理不平衡数据时的基本操作。

存在分组结构时必须按组切分。这个坑在医疗、用户行为、工业设备这类数据里特别常见。比如同一个病人有多次检查记录,或者同一个用户有多条行为日志。如果随机切分,同一个病人的记录可能一部分在训练集、一部分在验证集,模型只要记住这个病人的特征就能在验证集上拿到高分,但这是作弊。正确做法是按病人 ID 或用户 ID 分组,保证同一组的所有记录落在同一个集合里。踩过这个坑的人都知道,按组切分和按行切分的分数差距有时候能到十几个百分点。

时间序列必须按时间顺序切分。任何带有时间属性的数据都不能随机打乱,因为随机切分会让模型用未来的数据预测过去,这种泄漏在验证集上会表现为好得离谱的分数,上线后一落千丈。正确做法是取前 70% 做训练、中间 15% 做验证、最后 15% 做测试,或者用滚动窗口的方式构造多组训练验证对。这里还有一个细节:如果你在训练前做了标准化或归一化,统计量(均值和方差)必须只用训练集计算,然后应用到验证集和测试集上,否则也会造成泄漏。

3.3 把划分逻辑写成可复用的代码

实际项目里,我建议把数据划分单独写成一个函数,明确参数,避免每次都在 Notebook 里随手写一行 train_test_split。下面这份是基于 scikit-learn 的实现,覆盖了分层和分组两种常见需求:

import numpy as np from sklearn.model_selection import train_test_split, GroupShuffleSplit def split_dataset(X, y, groups=None, test_size=0.15, val_size=0.15, stratify=True, random_state=42): """ 三段式数据划分,返回 train/val/test 的索引。 先切出测试集,再从剩余部分切出验证集,保证训练集最大。 """ idx = np.arange(len(X)) if groups is not None: # 按组切分:同一组的样本不会跨集合 gss = GroupShuffleSplit(n_splits=1, test_size=test_size, random_state=random_state) train_val_idx, test_idx = next(gss.split(idx, y, groups)) gss_val = GroupShuffleSplit( n_splits=1, test_size=val_size / (1 - test_size), random_state=random_state) train_idx, val_idx = next( gss_val.split(train_val_idx, y[train_val_idx], groups[train_val_idx])) train_idx = train_val_idx[train_idx] val_idx = train_val_idx[val_idx] else: strat = y if stratify else None train_val_idx, test_idx = train_test_split( idx, test_size=test_size, stratify=strat, random_state=random_state) strat_val = y[train_val_idx] if stratify else None train_idx, val_idx = train_test_split( train_val_idx, test_size=val_size / (1 - test_size), stratify=strat_val, random_state=random_state) return train_idx, val_idx, test_idx

这段代码有两个设计点值得说。第一,先切测试集,再从剩下的里面切验证集,而不是先切训练集。这样能保证测试集的比例是精确的,不会因为两次切分的舍入产生偏差。第二,随机种子固定成常量,保证每次跑出来的划分完全一致。这一点看起来是小事,但如果没有固定种子,你调试模型时突然发现分数变了,排查半天最后发现是数据划分变了,那种体验相当消耗耐心。

提示:划分完成后建议写一段校验代码,打印每个集合的样本量、类别分布、以及分组 ID 的重叠情况。我习惯把这段校验和划分放在同一个脚本里,每次跑数据都自动检查一遍,能挡掉九成以上的划分错误。

如果你用的是 PyTorch 的 Dataset 或 TensorFlow 的 tf.data,划分逻辑是一样的,只是把索引换成对应的 sampler 或者文件列表。核心原则不变:先确定测试集,再确定验证集,训练集拿剩下的。

4. 交叉验证能不能替代验证集

聊到数据划分,几乎一定会有人问:交叉验证不是更充分利用数据吗,为什么还要单独留验证集?答案是交叉验证和固定验证集解决的问题不完全一样,各有各的适用场景。

4.1 K 折交叉验证的适用边界

K 折交叉验证的做法是把训练数据分成 K 份,每次拿 K-1 份训练、1 份验证,跑 K 次取平均。它的优势在于:每条样本都当过验证数据,评估结果比单次划分稳定得多,尤其在数据量小的时候。如果你的数据集只有八百条,固定切出 120 条做验证,这 120 条恰好偏难或偏易都会严重影响判断,而 5 折交叉验证每次用 160 条验证、平均五次,结果就稳多了。

但交叉验证有一个隐形成本:训练次数变成 K 倍。深度模型动辄训练几小时,5 折就是五倍时间,很多时候预算不允许。所以交叉验证更适合传统机器学习模型(逻辑回归、随机森林、梯度提升树等)和小规模数据。深度学习项目里,如果数据量足够,固定验证集通常是更实际的选择。

4.2 嵌套交叉验证解决的是另一个问题

有一种更严谨的做法叫嵌套交叉验证,外层用来估计泛化性能,内层用来选超参数。它的出现正是为了解决前面提到的验证集过拟合问题:外层循环评估的是“整套建模流程”,包括调参这个步骤本身,所以它给出的估计是无偏的。

嵌套交叉验证的计算量是 K 外乘以 K 内,也就是 K 平方次训练。5 折嵌套就是 25 次训练,一般只在论文或者对评估严谨度要求极高的场景下使用。日常项目和工程落地里,固定验证集加固定测试集的组合已经够用了,没必要为了理论上的严谨性把训练成本翻二十几倍。

4.3 什么样的数据量适合什么方案

选择哪种评估策略,我的经验是按数据量和训练成本两个维度来分:

数据量训练成本推荐方案
小(<1000)5 折或 10 折交叉验证
小(<1000)单次固定划分 + 多次随机种子取平均
中(1000~5 万)5 折交叉验证
中(1000~5 万)固定验证集 + 固定测试集
大(>5 万)任意固定验证集 + 固定测试集

多次随机种子取平均是个性价比很高的折中方案。用三个不同的随机种子各划分一次,跑三次得到三组验证分数,取平均作为决策依据。这样既避免了 K 折的时间开销,又能部分抵消单次划分的随机性。我在几个工业项目里用过这个做法,验证分数的波动明显比单次划分小。

5. 常见误用与排查实录

下面这部分是我自己在项目和各类实验报告里真实踩过的坑,也是我看到别人踩过最多的坑。整理成速查表的形式,方便你对照检查。

5.1 六个高频踩坑案例

案例一:在验证集上跑了几十次,最后报告最好成绩。这是最普遍的问题。做法本身不算错,错在报告的分数可能偏高。改进方式是记录所有尝试的配置和对应分数,最终报告时同时给出验证集分数和测试集分数,让两者的差距可见。

案例二:数据预处理放在了划分之前。比如先对全量数据做了标准化,再划分训练验证测试。这时候验证集和测试集的均值方差已经通过标准化泄漏到了训练过程里。正确顺序永远是先划分、再在训练集上 fit 预处理器、然后 transform 验证集和测试集。

案例三:特征选择用了全量数据。先在全量数据上算卡方值或互信息,选出前 50 个特征,再划分数据集。这也是泄漏,因为特征选择看到了验证集和测试集的信息。正确做法是把特征选择放进 Pipeline,只在训练折上 fit。

案例四:按行切分存在分组结构的数据。前面已经讲过,同一个用户或病人的多条记录不能跨集合。判断方法是看同一个 ID 是否在多个集合中出现,出现就是错的。

案例五:时间序列随机打乱。这个错误在小项目里出镜率极高,尤其是用 train_test_split 默认的 shuffle=True 处理带时间戳的数据。症状是验证集分数高得离谱,上线后完全不行。

案例六:测试集用完之后又改了模型。改完之后又跑了一次测试集,然后报告第二次的结果。这时候测试集已经不是测试集了。如果确实需要重新评估,就应该重新划分测试集,或者明确说明这是新的评估轮次。

5.2 问题速查表

现象可能原因排查动作
验证分数远高于测试分数验证集过度调参检查调参次数,增大验证集
验证分数异常高数据泄漏检查预处理、特征选择顺序
验证分数波动极大验证集太小换交叉验证或增大验证集比例
某一类指标特别差类别不平衡未分层改为 stratify 划分
线上效果远低于测试时间泄漏或分布偏移检查是否按时间切分
重新划分后分数大变划分未固定种子固定 random_state 并记录

5.3 一套可以直接抄的完整工作流

把前面的内容串起来,我在日常项目里用的流程大概是这样的:

第一步,拿到数据先看结构。有没有时间列,有没有用户 ID 列,标签分布是否均衡。这三件事决定了划分策略。

第二步,按策略划分。有时间列就按时间切,有分组结构就按组切,标签不平衡就分层,都没有就普通随机切。划分完立刻保存索引或文件列表,后续所有脚本都读同一份划分,绝不在不同脚本里各切一次。

第三步,用验证集做所有决策。模型选型、超参数搜索、早停、阈值选择,全部基于验证集。每做一次决策记录一条日志,方便后面回溯。

第四步,开发全部完成后,跑一次测试集。只跑一次,结果直接记录,不做任何后续修改。

第五步,如果对结论有疑问,宁可重新划分一组新的测试集,也不要在旧测试集上反复跑。

注意:训练集和验证集之间可以有任何形式的交互,验证集和测试集之间的交互只能是零。牢记这条线,你就不会犯大的评估错误。

最后分享一个我用了很久的小习惯。每次划分完数据,我会在项目根目录写一个 split_info.json,里面记录每次划分的参数、时间、样本量和对应的随机种子。半年后回头看某个实验为什么当时是这个结果,翻出这个文件基本都能还原。这个习惯看起来多余,但在需要复现结果或者对比不同版本模型的时候,能省下大量翻聊天记录和 Notebook 的时间。机器学习的评估流程本质上是一套防止自欺欺人的机制,验证集让你在开发中诚实地迭代,测试集让你在结束时诚实地面对自己,把它们守住,比多调出两个百分点重要得多。

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

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

立即咨询