Разделение, которое показывает правду
Найди почти дублирующиеся изображения, попавшие и в обучающую, и в валидационную выборку, а затем раздели данные по единице, которая будет новой при эксплуатации.
Если модель набирает 95% на валидации и 60% в рабочей среде, скорее всего, разделение данных оказалось обманчивым. Почти идентичные изображения — соседние кадры видео или снимки с одной камеры за один день — попали в обе выборки, поэтому валидация проверяла запоминание, а не способность к обобщению. Ты обнаружишь именно такую утечку в реальном наборе данных, а затем исправишь её групповым разделением.
Практические занятия
Три выборки — три задачи#
| Выборка | На чём обучается модель | Когда её просматривают |
|---|---|---|
| Обучающая | Модель: здесь подбираются веса | Каждую эпоху |
| Валидация | Ты: гиперпараметры, эпохи, размер модели, пороговые значения | После каждого эксперимента |
| Тест | Никто: он сообщает итоговое значение | Один раз, после принятия всех решений |
Каждое решение, которое ты принимаешь, глядя на отдельную выборку, понемногу передаёт информацию из неё модели. Для этого и нужна валидационная выборка. Настраивай модель на тестовой выборке — и её показатель перестанет быть честной оценкой: ты переобучился на собственных решениях. В 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.3 по шкале 0–255 означает, что изображения практически одинаковы. Открой val/Frame_24.jpg и train/Frame_308.jpg рядом — и увидишь одни и те же упаковки таблеток с одного ракурса. У 17 из 23 изображений валидационной выборки есть близнец в обучающей выборке, поэтому оценка в основном показывает, насколько хорошо модель запомнила уже виденные изображения. Для быстрого демонстрационного датасета это нормально — но именно такой результат нельзя выпускать в продакшен.

Та же ловушка есть в любом инструменте. В описании этого диалога сказано, что он случайным образом перераспределяет каждое изображение. Это идеально, если изображения — независимые фотографии, но приводит к утечке, если это кадры видео. Сначала проверь данные на почти дубликаты и оставь для теста больше 0%, если хочешь получить итоговую оценку, на которую никто не подбирал модель.
Разделяй по группам#
Правило: разделяй данные так, чтобы единица, новая при развёртывании модели, была независимой.
| Данные | Разделять по |
|---|---|
| Видео | Клипу или сеансу записи |
| Парк стационарных камер | Камере или объекту |
| Сцены, меняющиеся со временем | Дню или неделе |
| Медицинские данные, спорт или данные о людях | Пациенту, спортсмену или участнику исследования |
| Серии фотографий | Событию съёмки |
В именах файлов часто содержится ключ группы. В этом примере создаются 900 изображений с 6 камер, а затем сравниваются два способа разделения:
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, где кадры одного видео возвращаются к одному и тому же ракурсу, по именам файлов не определить надёжный ключ группы: сними новые видео упаковок другого вида для валидации либо сгруппируй изображения по отпечатку почти дубликатов и помести целые группы в одну часть.