4. 数据集划分与组织:训练集/验证集/测试集划分原则、目录结构与配置文件

数据集做好了,标注也完成了。接下来这一步,很多人觉得简单——不就是把文件分几个文件夹吗?

但我见过太多项目,因为这一步没做好,后面训练出来的模型要么过拟合,要么评估结果虚高,根本没法用。说白了,数据集划分和组织,决定了你后面所有工作的可信度。

4.1 训练集 / 验证集 / 测试集:各自干什么用

先搞清楚三者的角色。我习惯用一个比喻:

  • 训练集——学生的课本。模型从这里学知识。
  • 验证集——随堂测验。每次学完一个阶段,测一下学得怎么样,用来调超参数、选模型。
  • 测试集——期末考试。模型全部训练完成后,最后考一次,看看真实水平。

注意,测试集在整个训练过程中都不能碰。我见过有人用测试集的结果反复调参,那等于把期末考试答案提前给学生看了,分数再高也没意义。

核心原则:测试集只能用于最终评估,不能参与任何训练决策。

4.2 划分比例:没有万能公式,但有经验值

数据集大小不同,划分比例也不一样。我列一个常用的参考表:

数据集规模 训练集 验证集 测试集 我的建议
小(< 1,000 张) 70% 20% 10% 尽量用 K 折交叉验证
中(1,000 ~ 10,000 张) 80% 10% 10% 最常用的比例
大(> 10,000 张) 90% 5% 5% 验证集够用就行

为什么小数据集要留更多验证集?因为样本少,验证集太小的话,评估结果波动会很大,你根本分不清是模型变好了还是随机运气。

我的经验:如果数据集少于 500 张,我建议直接用 K 折交叉验证(比如 5 折),而不是固定划分。这样每个样本都有机会做验证,评估更稳定。

4.3 划分方式:随机 vs 分层 vs 按场景

光有比例还不够,怎么分也很关键。三种常见方式:

  • 随机划分:最简单,适合数据分布均匀的情况。但万一随机抽到的验证集全是某个类别,那就麻烦了。
  • 分层划分:保证每个类别在训练/验证/测试集中的比例一致。我强烈推荐这种方式,尤其是类别不平衡的时候。
  • 按场景划分:比如按视频序列、按拍摄时间、按地点。防止同一场景的相似图片同时出现在训练集和测试集里,造成评估虚高。

我曾经做过一个项目,用随机划分,测试集准确率 92%。结果一上线,直接掉到 70%。为什么?因为训练集和测试集里有很多同一段视频的连续帧,模型其实是在「记住」场景,而不是「学会」检测。后来改成按视频序列划分,才拿到真实指标。

避坑:如果你的数据来自视频抽帧,一定要按视频 ID 划分,不能随机分帧。否则测试集里很可能出现训练集的「邻居」,评估结果会虚高 10%~20%。

4.4 目录结构:YOLO 的标准组织方式

YOLO 对数据集目录有约定俗成的结构。我个人习惯这样组织:

dataset/
├── images/
│   ├── train/        # 训练集图片
│   ├── val/          # 验证集图片
│   └── test/         # 测试集图片
├── labels/
│   ├── train/        # 训练集标注
│   ├── val/          # 验证集标注
│   └── test/         # 测试集标注
└── data.yaml         # 数据集配置文件

注意几个关键点:

  • 图片和标注文件名必须一一对应。比如 img_001.jpg 对应 img_001.txt,扩展名不同但主文件名完全一致。
  • 标注文件是 .txt 格式,每行一个目标:class_id x_center y_center width height,所有坐标都是归一化的(0~1)。
  • 图片格式建议统一。我一般用 .jpg,压缩率适中,读取速度快。

小技巧:用 Python 的 os.symlink() 创建软链接,而不是复制文件。尤其是图片很大的时候,能省大量磁盘空间。

4.5 数据集配置文件:data.yaml

YOLO 通过一个 YAML 文件来告诉训练脚本:数据集在哪、有多少类别、类别名是什么。

一个标准的 data.yaml 长这样:

# 数据集路径(相对或路径均可)
path: ../dataset

# 各子集路径(相对于 path)
train: images/train
val: images/val
test: images/test    # 可选,训练时用不到

# 类别数量
nc: 3

# 类别名称(索引从 0 开始)
names:
  0: person
  1: car
  2: bicycle

几个容易踩坑的地方:

  • 路径不要写错。YOLO 会拼接 path + train 得到完整路径。如果 path 是相对路径,注意当前工作目录。
  • nc 必须和 names 列表长度一致。我见过有人改了 names 忘了改 nc,训练直接报错。
  • test 字段是可选的。训练时 YOLO 只读 train 和 val,test 是留给后续评估用的。

注意:YOLOv5/v8 对 YAML 的缩进非常敏感。不要用 Tab,统一用两个空格缩进。我曾经因为编辑器自动插入了 Tab,排查了半小时才找到原因。

4.6 自动化划分脚本

手动分文件太累了,尤其数据集大了以后。我写了一个简单的 Python 脚本,每次做新项目都直接复用:

import os
import random
import shutil
from sklearn.model_selection import train_test_split

# 配置
image_dir = 'raw_images'        # 原始图片目录
label_dir = 'raw_labels'        # 原始标注目录
output_dir = 'dataset'          # 输出目录
train_ratio = 0.8
val_ratio = 0.1
test_ratio = 0.1

# 获取所有图片文件
images = [f for f in os.listdir(image_dir) if f.endswith('.jpg')]

# 分层划分(按类别比例)
# 这里简化处理,实际项目中建议用 StratifiedShuffleSplit
train_val, test = train_test_split(
    images, test_size=test_ratio, random_state=42
)
train, val = train_test_split(
    train_val, test_size=val_ratio/(train_ratio+val_ratio), random_state=42
)

# 创建目录结构
for split in ['train', 'val', 'test']:
    os.makedirs(f'{output_dir}/images/{split}', exist_ok=True)
    os.makedirs(f'{output_dir}/labels/{split}', exist_ok=True)

# 复制文件
def copy_files(file_list, split):
    for img_file in file_list:
        # 复制图片
        shutil.copy(
            f'{image_dir}/{img_file}',
            f'{output_dir}/images/{split}/{img_file}'
        )
        # 复制对应的标注文件(.jpg -> .txt)
        label_file = img_file.replace('.jpg', '.txt')
        if os.path.exists(f'{label_dir}/{label_file}'):
            shutil.copy(
                f'{label_dir}/{label_file}',
                f'{output_dir}/labels/{split}/{label_file}'
            )

copy_files(train, 'train')
copy_files(val, 'val')
copy_files(test, 'test')

print(f'划分完成:训练集 {len(train)},验证集 {len(val)},测试集 {len(test)}')

这个脚本做了几件事:

  • 读取原始图片列表
  • 按比例随机划分(实际项目建议改成按类别分层)
  • 自动创建 YOLO 标准目录结构
  • 同时复制图片和对应的标注文件

改进建议:如果数据来自多个视频,建议先按视频 ID 分组,再以组为单位划分。这样能避免前面提到的「场景泄露」问题。

4.7 整体流程:一张图看懂

下面这张 SVG 图,把数据集划分与组织的完整流程串起来了。你可以把它当作操作清单:

数据集划分与组织流程 原始图片 + 标注 划分策略选择 随机 / 分层 / 按场景 训练集 (70~90%) 验证集 (5~20%) 测试集 (5~10%) 标准目录结构 dataset/ ├── images/ (train/ val/ test/) ├── labels/ (train/ val/ test/) └── data.yaml 图片与标注文件同名,一一对应 标注格式:class_id x_center y_center width height(归一化) 配置文件 data.yaml path / train / val / nc / names ⚠️ 路径不要写错 nc 必须与 names 长度一致

从原始数据开始,先选划分策略,再分成三份,然后按标准目录结构组织,最后写一个 data.yaml 配置文件。每一步都有坑,但每一步也都有固定的解法。

4.8 验证划分结果

划分完了,别急着训练。先做两件事:

  1. 检查文件数量。训练集、验证集、测试集的图片数量和标注文件数量是否一致?有没有漏标的?
  2. 检查类别分布。每个集合里的类别比例是否接近?如果验证集里某个类别只有一两张,评估结果会很不稳定。

我习惯写一个简单的统计脚本:

import os

def check_dataset(data_path):
    for split in ['train', 'val', 'test']:
        img_dir = f'{data_path}/images/{split}'
        label_dir = f'{data_path}/labels/{split}'
        
        imgs = set(os.listdir(img_dir))
        labels = set(os.listdir(label_dir))
        
        # 检查文件名是否对应
        img_names = {f.replace('.jpg', '') for f in imgs}
        label_names = {f.replace('.txt', '') for f in labels}
        
        missing_labels = img_names - label_names
        missing_imgs = label_names - img_names
        
        print(f'{split}:')
        print(f'  图片 {len(imgs)} 张,标注 {len(labels)} 个')
        if missing_labels:
            print(f'  缺少标注的图片: {missing_labels}')
        if missing_imgs:
            print(f'  缺少图片的标注: {missing_imgs}')

check_dataset('dataset')

这一步花不了几分钟,但能避免后面训练时各种莫名其妙的报错。

总结一下:数据集划分不是简单的文件搬运。划分策略、目录结构、配置文件,每一步都影响最终模型的可信度。花 10 分钟把这一步做好,后面能省 10 小时的排查时间。