做目标检测或者训练自己的数据集的朋友,应该都经历过这种让人抓狂的时刻:用YOLOv8训练自己的数据集,训练集loss一路降得漂漂亮亮,最后用训练好的权重一测验证集,mAP却低得离谱;或者反过来,验证集上分数很高,一部署到实际场景就完全不是那么回事。很多人第一反应是“代码写错了”“数据标错了”“学习率没调好”,于是开始疯狂调参。但我见过的大多数情况,问题都出在一个更基础的地方:训练集、验证集、测试集这三个集合的职责没理清楚,尤其是验证集到底为什么存在、该拿来干什么,很多人其实是一笔糊涂账。
这篇文章就把这件事彻底讲明白。我会先用一个容易理解的类比把三个集合的分工讲清楚,再重点解释为什么非要有验证集不可,然后结合“YOLOv8训练自己的数据集”这类常见实操场景,给出划分数据集的可行方案,最后聊聊数据泄漏和交叉验证——这些才是你真正动起手来最容易踩坑的地方。
1. 考试逻辑:训练集、验证集、测试集各自管什么
1.1 训练集:教材、练习册,还有刷题的错题本
训练集是模型学习的直接素材。模型在训练集上做前向传播、计算损失、反向传播更新权重,本质上就是学生对着课本和练习册反复刷题。训练集里的每一张图片、每一个标注框、每一段文本,都是模型用来“归纳规律”的原材料。
这里有个很多人没意识到的问题:深度学习模型的容量非常大,尤其是现在动辄上亿参数的神经网络。一个容量足够大的模型,理论上可以把训练集里的样本“背”下来,而不是真正理解其中的规律。这就是为什么训练集loss低根本不值得高兴——你只看到模型在熟悉的题上考得好,却不知道它在陌生题上会不会直接交白卷。
1.2 验证集:模拟考试,用来判断该不该调整备考策略
验证集不参与模型的梯度更新,但它参与你的决策过程。你用它来观察模型在“没做过的新题”上的表现,然后根据这个表现决定:
- 学习率该调大还是调小
- 网络深度要不要改
- 数据增强策略要不要换
- 正则化系数怎么设
- 训练到什么轮次该停
每一次你根据验证集的结果调整了某个超参数,其实都是在做一次“模型选择”。验证集的价值就在于:它可以被反复看、反复用,用来指导你在训练过程中的各种决定,而不会污染最终评估的公正性。
1.3 测试集:最终高考,考完就封卷
测试集在整个训练和调参过程中都应该被“雪藏”。它只在最后用来评估最终模型的泛化能力,做完一次评估之后,这个分数才有了真正的意义。测试集的原则是:能不看就不看,全程最多碰一次。
三者的区别可以用下面这张表说清楚:
| 集合 | 参与梯度更新 | 参与模型选择 | 使用频率 | 核心目的 |
|---|---|---|---|---|
| 训练集 | 是 | 间接影响 | 每个batch都在用 | 学习模型参数 |
| 验证集 | 否 | 是 | 每个epoch或每隔几轮 | 选择超参数与模型结构 |
| 测试集 | 否 | 否 | 全程只碰一次 | 评估最终泛化能力 |
1.4 三个集合背后是同一个核心问题:泛化
我们要训练模型,最终目标不是让它记住训练集,而是让它对没见过的数据也能有好的表现,这个能力叫“泛化”。训练集负责教会模型,验证集负责在过程中帮你纠偏,测试集负责给你一个诚实的评分。三者缺一不可,但很多教程只讲了“要划分数据”,没讲清楚为什么这样划分,导致新手把验证集当成测试集用,或者干脆不要验证集,最后只能靠“玄学调参”。
2. 没有验证集,你的模型会变成“背题机器”
这一节回答标题里那句“为什么要使用验证集”。我先说结论:验证集存在的根本原因,是让你有一个可以在训练过程中反复查看成绩、又不会让最终评估失效的“试错窗口”。没有它,你会陷入两种尴尬境地。
2.1 训练集loss持续走低,模型却在过拟合
深度神经网络拟合能力极强,训练集上loss降到0.01都不稀奇。你监控训练集loss,会发现它一路上涨的情况很少,绝大多数时候都稳定下降。但过拟合的本质是:模型把训练样本里的噪声、标注误差、个别特例也当成了规律。
最典型的图像就是训练loss不断下降,验证loss却在某个epoch之后开始反弹——这条“V型曲线”是过拟合的标志。如果没有验证集,你根本看不见这条曲线,只会看着训练loss继续傻等到训练结束,最后拿到的模型在真实场景里一塌糊涂。
2.2 只有训练集和测试集时,你会被迫“偷看答案”
假设你的数据只分成训练集和测试集两堆。现在你想调学习率,该看什么指标?训练集loss不能说明问题,唯一有参考价值的就是测试集。于是你只能去测试集上跑一下,发现效果不好,回去改超参数,再跑一次测试集。跑完之后又觉得哪里不对,再改,再测。
这个过程重复十次之后,你的模型已经在测试集上“学习”了十次。你每次根据测试结果做的调整,都把测试集的信息间接注入到了模型选择里。测试集不再是未知的考卷,它的统计特征已经被你摸透了。这在机器学习里叫“测试集污染”(test set contamination),结果就是测试分数虚高,部署上线立刻翻车。
2.3 验证集真正管的是“选择”,测试集管的是“报告”
一句话总结:训练集决定模型“长什么样”,验证集决定模型“选哪个”,测试集负责报告模型“到底行不行”。
更直白一点:验证集是你训练过程中可以反复查看的模拟考成绩单,测试集是最终高考成绩单。高考前你可以考无数次模拟,但正式高考只有一次,而且你得等到真正上考场那天才知道自己真实水平。如果提前偷看了高考题,那这次成绩就完全失去参考价值了。
这一点在做early stopping(早停)时体现得最明显。没有验证集,你不知道该在哪个epoch保存权重;有了验证集,你可以在验证loss开始上升的瞬间把模型停下来,保存“验证集上表现最好”的那份权重,而不是最后一个epoch的权重。YOLOv8训练时默认保存的best.pt和last.pt就是这个逻辑——best.pt是基于验证集指标挑选出来的。
3. 划分数据集实操:YOLOv8训练自己的数据怎么切
前面讲的是原理,这一节落到具体的实操。既然热搜词里大量是“yolov8/yolov5/yolo11训练自己的数据集”“mmrotate训练dota数据集”这种目标检测场景,我就以检测任务为例,其他任务思路完全一样。
3.1 先定划分比例,再谈其他
划分比例没有绝对标准,取决于数据量:
| 数据规模 | 建议划分 | 说明 |
|---|---|---|
| 上万张以上 | 98% / 1% / 1% | 验证集和测试集各几百张就足够稳定 |
| 几千到一万张 | 8:1:1 或 9:0.5:0.5 | 常规做法 |
| 几百到一千张 | 尽量多留训练数据,验证和测试各取10%~20% | 再少就要上交叉验证 |
| 几百张以下 | 不建议用固定划分 | 直接用K折交叉验证,见第5节 |
对目标检测任务,划分的单位是“整张图片”,不是“标注框”。有些人统计完标注框数量后,想把同一张图里的框拆到不同集合,这绝对是错误做法——同一张图的信息泄漏到了两个集合,验证指标会虚高。
3.2 分层划分:保证每个集合都有足够的每一类样本
如果你的数据里某个类别只有几十张,随机划分很可能导致这个类别的样本全部跑到训练集,验证集里一个都没有。后果是验证集上这类目标的指标波动极大,甚至因为缺失而无法计算。
解决办法是分层抽样(stratified split)。对单标签分类任务,直接按类别标签分层;对YOLO这种多标签检测任务,按“每张图片包含的类别组合”做分层。下面这个脚本可以直接用:
import os import random import shutil from collections import defaultdict from sklearn.model_selection import train_test_split random.seed(42) IMG_DIR = "datasets/images" LBL_DIR = "datasets/labels" OUT_DIR = "datasets/split" # 读取每张图包含的类别,返回类别组合字符串 def stratify_key(img_name: str) -> str: label_path = os.path.join(LBL_DIR, img_name.replace(".jpg", ".txt")) classes = set() if os.path.exists(label_path): with open(label_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() if parts: classes.add(parts[0]) return "-".join(sorted(classes)) if classes else "empty" images = [f for f in os.listdir(IMG_DIR) if f.endswith(".jpg")] keys = [stratify_key(img) for img in images] # 第一次切分:分出测试集 train_val, test = train_test_split( images, test_size=0.1, random_state=42, stratify=keys ) # 第二次切分:训练集和验证集 train, val = train_test_split( train_val, test_size=0.1 / 0.9, random_state=42, stratify=[stratify_key(img) for img in train_val] )切分完成后,把文件复制到YOLO要求的目录结构里,同时记得把标注文件一并复制,并生成对应的train.txt/val.txt/test.txt路径列表。
3.3 同分布铁律:按场景切,而不是随机切
这是目标检测数据划分里最坑、也最容易被忽略的一条。
如果你在做视频抽帧数据集,或者用无人机、卫星影像切片做检测(比如DOTA数据集那类大图切tile的操作),同一段视频、同一张大图切出来的若干小图,画面内容高度相似。这时候如果直接随机划分,train和val里会出现大量“同源”图片,模型在val上的表现会虚高得可怕,因为它其实已经“见过”这些场景了。
正确做法是:先按“场景”分组,再保证同一个场景的所有图片只进入一个集合。视频数据按视频片段分组,切片数据按原始大图分组。mmrotate这类旋转检测框架处理DOTA数据时尤其要注意这一点——DOTA官方提供的是大图,你切成小图后如果按小图随机分,基本等于数据泄漏。
3.4 验证集和测试集在训练过程中的处理差异
还有一个实操细节:数据增强只加在训练集上。验证集和测试集只做resize、normalize这类必要预处理,不要做随机翻转、随机裁剪、mosaic这些增强。
原因很简单:验证集和测试集要代表“真实世界的数据分布”,而真实世界的图片不会自带随机翻转和mosaic。如果你想评估模型在增强策略下的表现,那就把增强当成超参数,在训练集上做消融实验,而不是去增强验证集。有些人为了“让验证结果更好看”,给验证集也加上测试时增强(TTA),这在对比实验里是允许的,但你必须明确记录,否则最终分数没有可比性。
4. 验证集分数虚高的元凶:数据泄漏的隐蔽形态
很多朋友训练时遇到“验证集mAP很高,部署到现场就崩”的情况,第一反应是模型不行,其实更常见的原因是数据泄漏。数据泄漏不一定是“train和val图片重复”这种肉眼可见的低级错误,它有很多隐蔽形态。
4.1 泄漏的常见来源
第一类:预处理泄漏。你计算归一化用的均值和方差时,使用了全部数据(包括验证集和测试集)。这样验证集/测试集的统计信息就被编码进了模型输入,属于标准的泄漏。正确做法是只用训练集计算均值和方差,然后把它固定下来,应用到所有集合。
第二类:特征选择泄漏。如果你在做传统机器学习或特征工程,在划分数据之前就对全量数据做了特征筛选,那最终评估结果也会虚高。特征选择的依据来自测试集,这等于偷看了答案。
第三类:相似样本泄漏。train和val里存在大量重复或近似重复的图片。比如同一场景的不同角度、同一物体的不同帧,这些图片视觉上不完全一样,但模型很容易“记住”它们的共同特征,导致val分数虚高。
第四类:手动整理数据时造成的泄漏。很多人习惯先把所有图片按类别归档、人工清洗,然后再划分。清洗过程中如果人工把“看起来像的”分到一起,或者为了凑数量把某个类别的图片从别处复制过来,都会造成隐性重复。
4.2 怎么排查你的划分是否泄漏
我常用的排查手段有三个:
一是计算训练集和验证集之间的图片相似度。可以用感知哈希(pHash)或者简单的像素差来判断是否有重复或近似重复图片,发现相似度超过阈值的样本对,就要检查是否跨集合了。
二是对比每个集合的类别分布。如果某个类别在训练集占比30%,在验证集占比只有2%,那你需要检查划分过程是否出了问题,或者数据本身类别不平衡。
三是观察训练过程中的loss曲线。正常情况下,训练集loss应该略低于验证集loss。如果你发现两个loss曲线几乎完全重合、验证loss下降速度和训练loss一样快,并且全程没有差距,这往往意味着验证集和训练集过于相似,存在泄漏风险。
4.3 另一个让人翻车的方向:验证集不等于真实场景
有时候划分没问题、也没泄漏,但模型上线还是崩,原因在于验证集的“来源”和真实部署场景不一致。比如你用行车记录仪白天视频做训练和验证,部署时却要处理夜间场景,那验证集再准确也没用。
所以要尽量让测试集(甚至验证集)的采集条件贴近真实的部署环境。这属于领域漂移(domain shift)问题,没有统计公式能帮你解决,只能靠你对业务场景的理解来设计数据采集方案。我见过不少项目,模型效果差不是训练问题,而是从一开始的验证集就选错了“考试范围”。
5. 数据太少怎么办:K折交叉验证与嵌套交叉验证
数据量少的时候,固定划分会让每一份数据都很珍贵。如果总共只有500张图,按7:2:1划分,训练集只有350张,验证集100张,测试集50张——验证集和测试集都小得可怜,指标波动会非常大,你根本分不清调参的效果是变好了还是噪声。
5.1 K折交叉验证的原理
K折交叉验证把数据分成K份,每次拿K-1份训练,1份做验证,轮流做K次,最后把K次验证指标平均。这样每一份数据都既当过训练数据,也当过验证数据,模型的选择和评估都更稳定。
from sklearn.model_selection import KFold kf = KFold(n_splits=5, shuffle=True, random_state=42) for fold, (train_idx, val_idx) in enumerate(kf.split(images)): train_imgs = [images[i] for i in train_idx] val_imgs = [images[i] for i in val_idx] # 用 train_imgs 训练,val_imgs 验证,记录指标 # fold_metrics.append(val_mAP)注意两点:一是要设置shuffle=True和random_state,保证每次划分可复现;二是如果类别不平衡,优先用StratifiedKFold做分层交叉验证。
5.2 目标检测中用交叉验证的代价
交叉验证不是免费的。5折交叉验证意味着同样的训练要跑5次,训练时间直接变成5倍。对于YOLOv8这种需要GPU跑几十分钟甚至几个小时的模型,这个成本很现实。
所以我的建议是:数据量在几千张以上,直接固定划分就够了,没必要交叉验证;数据量只有几百张,交叉验证的价值远大于额外训练时间的成本;数据量大但某一类特别少,可以对类别做分层固定划分,再决定是否交叉验证。
5.3 嵌套交叉验证:兼顾模型选择和评测
如果你既要调超参数,又要得到诚实的测试分数,在数据量很小的情况下可以使用嵌套交叉验证(nested cross-validation)。外层循环负责评估,内层循环负责模型选择。外层每一折都把数据分成训练+验证两部分,内层在训练部分上再做一次K折,挑出最好的超参数,然后用这个超参数在外层的验证部分上测试。
嵌套交叉验证的代价更高,不适合日常训练,但它能给出一个相对无偏的模型性能估计。做学术实验、写论文、做算法选型调研时,这招非常有用。
6. 我踩过的坑:关于验证集的几条血泪经验
这一节不讲理论,只讲我在实际项目中犯过的错误,希望你能绕开。
6.1 坑一:不按类别分层,验证集指标上下乱跳
有一次做工业质检分类,数据集里有一个瑕疵类别只占5%。我当时直接随机划分8:2,结果划分完才发现这个瑕疵类别在验证集里只有3张图。训练过程中验证准确率一会儿95%一会儿70%,看起来像模型不稳定,实际是验证集样本太少了。后来改成按类别分层划分,曲线立刻稳定下来。从那以后,任何数据集划分我都会先统计每个类别的数量,确认分布一致再动手。
6.2 坑二:视频抽帧直接随机划分
做视频目标检测时,我从监控视频里每5秒抽一帧,攒了三千多张图,然后随机划分训练集和验证集。训练时验证mAP高达0.9,心里挺高兴,结果部署到另一段完全不同的视频上,mAP直接掉到0.4,场面非常难看。后来排查才发现,同一镜头的相邻帧被同时分到了训练集和验证集,验证指标根本不诚实。最后改成按视频片段分组,保证同一场景的帧只进入一个集合,才得到真实可用的模型。
6.3 坑三:用了测试集结果调参,还不自知
早期做项目时,为了赶进度,我一边调参一边拿测试集测效果。当时没意识到这样做有什么问题,只觉得“测试分数一直有提升,项目进展很顺利”。后来看了不少资料才反应过来,那部分测试集已经被我污染了。项目交付时我重新标了一批新数据当测试集,分数比之前低了七八个点,幸好发现得早。现在我的铁律是:测试集一旦划定,任何人不得以任何理由去看测试集的结果,除非是最终评估。
6.4 我现在固定下来的工作流
总结一下,我现在每接一个训练任务,划分数据时都会按这个流程走:
- 先按场景/视频/原始大图分组,保证同组数据不进不同集合;
- 统计每个类别的数量,用分层抽样做第一次划分,切出测试集并锁死;
- 从剩余数据里再分层划分出验证集;
- 只在训练集上计算归一化参数;
- 训练过程中只盯训练集loss和验证集指标,测试集全程不看;
- 保存模型只用验证集上表现最好的权重,而不是最后一个epoch的权重;
- 最终评估时,测试集只跑一次,记录结果后不再改动。
这套流程看起来朴素,但能挡住绝大多数数据划分和验证集使用上的坑。说实话,模型结构、损失函数、数据增强这些东西,很多开源框架已经帮你优化得很好了,真正拉开项目落地差距的,往往就是你对待验证集和测试集的严谨程度。