如实反映情况的数据集划分
找出训练集和验证集之间泄漏的近重复图像,并按部署时会出现新样本的单位进行划分。
如果模型在验证集上得分为 95%,在生产环境中却只有 60%,通常是划分方式有问题。近乎相同的图像——相邻的视频帧、同一天由同一台相机拍摄的图像——被分到了两边,因此验证测的是记忆而非泛化。你会在一个真实数据集中找到这样的泄漏,然后用分组划分来修复。
实践操作
三种划分,各司其职#
| 划分 | 模型从中学习的内容 | 查看时机 |
|---|---|---|
| 训练集 | 模型:在这里拟合权重 | 每个 epoch |
| 验证 | 你: 超参数、epochs、模型大小、阈值 | 每次实验后 |
| 测试 | 没人:它只报告最终数值 | 一次,在所有决策都已做出之后 |
你每次查看一个数据集划分并据此做出决策,都会有少量信息泄漏到模型中。这正是验证集的作用。在测试集上调参,测试结果就不再是诚实的估计——你已经对自己的选择过拟合了。在 Ultralytics 中,model.val(split="test") 根据你的数据集 YAML 对测试集划分进行评分。
在真实数据集中发现泄漏#
这些医药药片图像的文件名是 Frame_0.jpg、Frame_4.jpg、Frame_8.jpg——它们是视频帧。这是一个警示信号:相邻帧几乎完全相同,而随机划分会让它们出现在训练集和验证集两边。你可以比较每张验证图像与每张训练图像的微小灰度指纹来检查:
from pathlib import Path
import numpy as np
from PIL import Image
from ultralytics.data.utils import check_det_dataset
data = check_det_dataset("medical-pills.yaml")
def thumbnail(path):
"""A 32x18 grayscale fingerprint: enough to spot near-identical frames."""
return np.asarray(Image.open(path).convert("L").resize((32, 18)), dtype=float)
train = {p.name: thumbnail(p) for p in sorted(Path(data["train"]).glob("*.jpg"))}
pairs = []
for p in sorted(Path(data["val"]).glob("*.jpg")):
v = thumbnail(p)
name, diff = min(((n, np.abs(v - t).mean()) for n, t in train.items()), key=lambda x: x[1])
pairs.append((diff, p.name, name))
pairs.sort()
print(f"{sum(d < 5 for d, _, _ in pairs)} of {len(pairs)} val images have a near-twin in train (difference < 5)")
for diff, val_name, train_name in pairs[:3]:
print(f" {val_name} ~ {train_name} difference {diff:.1f}")17 of 23 val images have a near-twin in train (difference < 5)
Frame_344.jpg ~ Frame_56.jpg difference 0.3
Frame_28.jpg ~ Frame_312.jpg difference 0.5
Frame_24.jpg ~ Frame_308.jpg difference 1.8在 0–255 的范围内,差值为 0.3 意味着两张图像几乎是同一张图片。把 val/Frame_24.jpg 和 train/Frame_308.jpg 并排打开,你会看到同一角度拍摄的相同泡罩包装。23 张验证图像中有 17 张在训练集中有对应的近似图像,因此这里的验证分数主要衡量模型对已见图像的记忆程度。这对于快速演示数据集来说没问题——但你绝不能用这样的数据集交付产品。

任何工具里都可能出现同样的陷阱。这个对话框自己的说明写着,它会对每张图像进行随机重新分配,因此适用于图像彼此独立的照片——但用于视频帧时就会造成泄漏。先运行近似重复检查;如果你希望最终数值没有经过调参,就要确保测试集占比高于 0%。
按组划分#
规则:划分必须以部署时会遇到的新单位为基准,并确保各组相互独立。
| 数据 | 划分依据 |
|---|---|
| 视频 | 片段或录制会话 |
| 固定摄像头组 | 摄像头或地点 |
| 随时间变化的场景 | 日期或周次 |
| 医疗、体育或人员数据 | 患者、运动员或受试者 |
| 连拍照片 | 拍摄事件 |
文件名通常包含分组键。这个示例用 6 个摄像头拍摄的图像构建了一个包含 900 张图像的数据集,并比较两种策略:
import random
from collections import defaultdict
# File names carry the unit that must never straddle splits: camera_day_frame.jpg
images = [f"cam{c:02d}_day{d}_{f:04d}.jpg" for c in range(1, 7) for d in range(1, 4) for f in range(50)]
# WRONG: shuffle individual frames, so near-identical neighbors land in both splits
random.seed(0)
shuffled = random.sample(images, len(images))
train, val = shuffled[: int(0.8 * len(images))], shuffled[int(0.8 * len(images)) :]
shared = {i.split("_")[0] for i in train} & {i.split("_")[0] for i in val}
print(f"per-image split: {len(shared)} of 6 cameras appear in both train and val")
# RIGHT: shuffle whole cameras, then expand them back into images
groups = defaultdict(list)
for image in images:
groups[image.split("_")[0]].append(image)
cameras = random.sample(sorted(groups), len(groups))
train = [i for cam in cameras[:5] for i in groups[cam]]
val = [i for cam in cameras[5:] for i in groups[cam]]
print(f"per-camera split: train {len(train)} images, val {len(val)} images, val cameras {cameras[5:]}")per-image split: 6 of 6 cameras appear in both train and val
per-camera split: train 750 images, val 150 images, val cameras ['cam05']按图像划分时,验证集从未包含新摄像头拍摄的图像。按摄像头划分时,分数估计的是模型在从未见过的摄像头上的表现——这才是你需要的数值。
常见问题#
症状:验证结果远好于首次实地测试。解决方法:找出分组键——它通常藏在文件名或采集元数据中——按它划分数据,并运行近重复项检查进行确认。
症状:某个稀有类别没有 AP,或者 AP 只基于两个实例。解决方法:按组划分后,统计每个划分中的各类别数量,并交换完整分组,直到每个类别的占比都相近。sklearn.model_selection.StratifiedGroupKFold 可以自动完成这项工作。
症状:测试集数值在多次实验中不断上升。解决方法:冻结测试集,选择符合生产环境条件的数据,并在每个实验完成后只报告一次。对于小型数据集,请使用 K 折交叉验证对多个划分取平均,而不是重复使用同一个划分。
正确划分数据集介绍了比例设置,以及如何在完整数据集中落实所有这些做法。
显示解答
只要提供一个提取分组键的函数,就能对任意文件名列表运行泄漏检查:
def leaked_groups(splits, key):
"""Return every group key that appears in more than one split."""
owner, leaks = {}, set()
for split, files in splits.items():
for name in files:
group = key(name)
if owner.setdefault(group, split) != split:
leaks.add(group)
return sorted(leaks)
# 将这些列表替换为你的文件名,例如 [p.name for p in Path("images/train").glob("*.jpg")]
splits = {
"train": ["cam01_day1_0001.jpg", "cam01_day1_0002.jpg", "cam02_day1_0001.jpg"],
"val": ["cam03_day2_0001.jpg", "cam01_day1_0003.jpg"],
"test": ["cam04_day3_0001.jpg"],
}
print(leaked_groups(splits, key=lambda name: name.split("_")[0])) # 按摄像头分组['cam01']cam01 同时出现在 train 和 val 中,因此这个划分存在泄漏。将每张 cam01 图像移到同一个划分中,然后重新运行,直到输出 []。对于 medical-pills,来自同一视频的帧会反复拍到相同视角,文件名中没有合适的分组键:你可以录制不同药片包装的新验证视频,也可以根据近似重复指纹对图像进行聚类,并将整个簇分配到同一个划分中。