mAP50 = 0,99 за 22 минуты на CPU. Жаль, что цифра ничего не значит

Я дообучил YOLO11n на датасете medical‑pills из документации Ultralytics: 60 эпох, 22 минуты на ноутбучном Ryzen 7, mAP50 = 0,991 на валидации. По всем признакам — успех: можно делать скриншот и рассказывать, как легко сегодня учить детекторы.
Потом я прогнал тот же датасет через dataset‑doctor — свою проверялку датасетов — и узнал, что у каждого из 23 валидационных кадров есть почти точная копия в train. Валидация измеряла не «как модель справится с новыми таблетками», а «как хорошо модель помнит то, что уже видела».
Под катом: что именно нашлось, почему метрика при этом не врёт в прямом смысле, но и не говорит ничего полезного, как ловить такую утечку за два десятка строк на Python (dHash плюс корзины) и как делить данные, чтобы её не было.
Датасет и обучение
medical‑pills — маленький набор из документации Ultralytics. Сами авторы называют его proof‑of‑concept: 115 фото таблеток, 2 022 рамки, один класс pill, архив на 8 МБ. Разбиение идёт из коробки: 92 кадра в train, 23 в val. Файлы называются Frame_24, Frame_308 и так далее — уже отсюда видно, что это кадры видео, а не отдельные фотографии.
Учил стандартным скриптом, ничего не подкручивая:
from ultralytics import YOLO
model = YOLO("yolo11n.pt")
model.train(data="medical-pills.yaml", epochs=60, imgsz=640, batch=16)
metrics = model.val()
yolo11n.pt — веса, предобученные на COCO. Видеокарты под рукой не было, поэтому считалось на CPU, остальные параметры по умолчанию. Метрики в таблице — из model.val() после обучения.
Что | Значение |
|---|---|
mAP50 на val | 0,991 |
mAP50-95 на val | 0,797 |
Эпох / размер входа | 60 / 640 |
Железо и время | CPU Ryzen 7 4 800 H, 22 минуты |
Сама по себе цифра 0,99 по mAP50 на одном классе крупных контрастных объектов на ровном фоне не выглядит невозможной. Проблема не в том, что число большое. Проблема в том, на чём оно измерено.
Что нашёл dataset‑doctor
dataset‑doctor — утилита, которая прогоняет датасет через набор проверок до обучения и пишет отчёт человеческим языком: что сломано, насколько это критично и что с этим делать. Репозиторий — github.com/8MVR3/dataset‑doctor, ставится и запускается так, прямо на папке в том виде, в каком её скачивает Ultralytics:
pip install git+https://github.com/8MVR3/dataset-doctor.git
dataset-doctor datasets/medical-pills --out report.html
На medical‑pills отчёт выглядит так:

Одна критичная проблема: «Похожие кадры в разных выборках — 73». Это 73 пары «кадр из train — кадр из val», которые отличаются друг от друга не больше чем на 6 бит из 64 по dHash (что это за хеш — ниже). Для масштаба: всего возможных пар train‑val 92 × 23 = 2116, то есть «почти одинаковых» среди них 3,4%.
Вот как такие пары выглядят глазами. Слева кадр из val, справа — ближайший к нему кадр из train:

Отличия — на уровне сдвига камеры на пару пикселей и чуть другого блика на фольге. Для детектора это одна и та же картинка.
Но 73 пары — это ещё не самое интересное. Интересно, что получится, если для каждого кадра val найти ближайший кадр train:
Кадр val | Ближайший кадр train | Расстояние, бит |
|---|---|---|
Frame_24 | Frame_308 | 2 |
Frame_28 | Frame_312 | 0 |
Frame_40 | Frame_324 | 2 |
Frame_48 | Frame_332 | 1 |
Frame_64 | Frame_352 | 2 |
Frame_84 | Frame_380 | 0 |
Frame_144 | Frame_448 | 2 |
Frame_172 | Frame_168 | 4 |
Frame_192 | Frame_196 | 1 |
Frame_248 | Frame_244 | 5 |
Frame_264 | Frame_260 | 6 |
Frame_276 | Frame_272 | 4 |
Frame_292 | Frame_12 | 3 |
Frame_344 | Frame_56 | 0 |
Frame_360 | Frame_68 | 1 |
Frame_364 | Frame_72 | 1 |
Frame_384 | Frame_92 | 1 |
Frame_392 | Frame_96 | 2 |
Frame_416 | Frame_116 | 2 |
Frame_420 | Frame_120 | 3 |
Frame_432 | Frame_132 | 1 |
Frame_452 | Frame_152 | 2 |
Frame_456 | Frame_152 | 2 |
У каждого — каждого — из 23 кадров val есть почти копия в train. У трёх расстояние равно нулю: хеши совпадают полностью. У 17 из 23 расстояние не больше 2 бит. Самый «далёкий» кадр val отстоит от train на 6 бит. Ни одного по‑настоящему нового кадра в валидации нет.
Откуда копии
В таблице видны две разные истории.
Первая — ожидаемая: соседние кадры. Frame_172 ↔ Frame_168, Frame_192 ↔ Frame_196, Frame_248 ↔ Frame_244, Frame_264 ↔ Frame_260, Frame_276 ↔ Frame_272 — номера отличаются на 4, это буквально следующий или предыдущий сохранённый кадр. Так бывает всегда, когда кадры одного видео делят на train и val случайно, по одному.
Вторая история неожиданная. Frame_28 из val совпадает с Frame_312 из train с расстоянием 0 — между ними 284 кадра. И это не единичный случай: Frame_24 ↔ Frame_308, Frame_40 ↔ Frame_324, Frame_144 ↔ Frame_448, а дальше в обратную сторону — Frame_292 ↔ Frame_12, Frame_344 ↔ Frame_56, Frame_456 ↔ Frame_152. Сдвиг везде почти одинаковый, от 280 до 304 кадров. Причём «далёкие» пары ближе друг к другу, чем соседние кадры: из 18 далёких пар у 16 расстояние не больше 2 бит, а четыре из пяти соседних пар отстоят на 4–6 бит. Моя гипотеза: в видео одна и та же сцена снята или смонтирована дважды.
Ещё одно наблюдение в ту же копилку: номера кадров идут ровно через 4, от 0 до 456, без единого пропуска — это 115 номеров, ровно столько фото в датасете. Похоже, весь датасет — это каждый четвёртый кадр одного ролика длиной около 460 кадров. Если так, то честно разделить его на train и val в принципе невозможно: источник один.
Хочу подчеркнуть: это не претензия к Ultralytics. Датасет честно назван proof‑of‑concept, его задача — показать, что пайплайн обучения работает, и с ней он справляется. Претензия к тому, как мы (я) читаем цифры, которые на нём получаются.
Почему метрика ничего не значит
Валидационная выборка нужна, чтобы ответить на один вопрос: как модель поведёт себя на данных, которых она не видела. Если val — это train плюс дрожание камеры, вопрос незаметно подменяется другим: насколько хорошо модель запомнила train. Сеть, которая 60 эпох подряд смотрит на 92 кадра одной сцены, эту сцену запоминает — и 0,991 на кадрах той же сцены говорит в первую очередь об этом.
Важно: число 0,991 не «неправильное». Это честно посчитанный mAP на этих 23 картинках. Оно просто отвечает не на тот вопрос. А на нужный вопрос — что будет на фотографии другого блистера, при другом свете, с другого телефона — по этой валидации ответить нельзя вообще. Может быть, там будет 0,95. Может быть, 0,6. Я не знаю, и в этом и состоит проблема: цифра выглядит как ответ, но ответом не является.
С mAP50-95 = 0,797 та же история: разница с mAP50 говорит о точности рамок, но измерена она на тех же кадрах.
Почему это важно за пределами учебного датасета. Утечка между train и val — самый тихий баг в ML: ничего не падает, лосс красиво снижается, метрики на валидации отличные, и всё это до первого дня в проде. А датасеты из видео — норма: камеры на производстве, регистраторы, записи с дронов, ролики с телефона. Если из такого видео надёргать кадров и разделить их random.shuffle или train_test_split, утечка гарантирована — и ни один стандартный инструмент обучения про неё не скажет.
Как работает проверка
dHash: 64 бита на картинку
dHash (difference hash) — перцептивный хеш. Идея: уменьшить картинку до 9 × 8 пикселей в градациях серого и в каждой из 8 строк сравнить каждый пиксель с правым соседом. Получается 8 × 8 = 64 бита: единица там, где слева светлее, ноль — где темнее. Хеш описывает не пиксели, а «рельеф яркости», и поэтому почти не меняется при сжатии, изменении размера или лёгком сдвиге, но заметно меняется, когда меняется содержимое кадра.
def _dhash(img: Image.Image) -> int:
small = img.convert("L").resize((9, 8), Image.BILINEAR)
px = small.tobytes()
bits = 0
for row in range(8):
for col in range(8):
left, right = px[row * 9 + col], px[row * 9 + col + 1]
bits = (bits << 1) | (left > right)
return bits
Похожесть двух картинок — расстояние Хэмминга между хешами, то есть число несовпадающих бит: bin(a ^ b).count("1"). В dataset‑doctor порог по умолчанию — 6 бит из 64.
Как распределены расстояния между всеми 6 555 парами medical‑pills (хвост обрезан на 20):
бит: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20+
пар: 6 22 17 20 30 33 40 37 38 34 48 42 31 41 51 47 59 72 77 96 5714
Пар с расстоянием не больше 6 — 168, это 2,6% от всех. Обратите внимание: никакого провала около порога нет, пар с расстоянием 7, 8, 9 примерно столько же, сколько с расстоянием 6. Порог 6 — консервативный, он ловит только совсем близкие кадры. Подними я его до 10, «почти одинаковых» пар стало бы заметно больше. Главный вывод от порога не зависит: все 23 кадра val уложились в 6 бит.
Как не сравнивать все пары со всеми
Для 115 картинок 6 555 сравнений — ничто. Для 100 тысяч картинок пар уже пять миллиардов, и на чистом Python это часы. Поэтому в dataset‑doctor стоит простой трюк из семейства locality‑sensitive hashing.
64-битный хеш режем на 8 кусков по 8 бит. Ключ корзины — номер куска и его значение. Каждая картинка попадает в 8 корзин. Дальше принцип Дирихле: если два хеша отличаются не больше чем на 7 бит, хотя бы один из 8 кусков у них совпадает целиком, а значит, они обязательно окажутся в одной корзине. Сравнивать попарно достаточно только внутри корзин, и при пороге ≤ 7 мы не потеряем ни одной пары.
# поиск пар почти одинаковых кадров через корзины по 8 бит
buckets = defaultdict(list)
for i in ok_idx:
h = infos[i].dhash
for band in range(8):
buckets[(band, (h >> (band * 8)) & 0xFF)].append(i)
near_pairs = set()
for members in buckets.values():
if len(members) < 2 or len(members) > 2000:
continue
for a, b in combinations(members, 2):
if (a, b) in exact_pairs or (a, b) in near_pairs:
continue
if bin(infos[a].dhash ^ infos[b].dhash).count("1") <= near_threshold:
near_pairs.add((a, b))
Здесь infos — записи про каждую картинку с уже посчитанным хешем, ok_idx — индексы картинок, которые вообще удалось открыть, exact_pairs — точные копии, найденные раньше отдельной проверкой (их не считаем второй раз). Две детали:
Корзина только отбирает кандидатов. Расстояние для каждой пары внутри корзины всё равно считается честно, так что ложных срабатываний трюк не добавляет.
Корзины больше 2 000 элементов пропускаются. Это защита от вырожденных случаев: тысячи однотонных кадров дают одинаковый кусок хеша и превращают одну корзину обратно в миллионы сравнений. Плата — если единственная общая корзина какой‑то пары оказалась переполненной, пара потеряется. На реальных датасетах это редкость, но знать об этом надо.
Чего dHash не ловит
Хеш находит почти одинаковые картинки — и только их. Он не найдёт тот же блистер, снятый с другого угла; тот же стол с другими таблетками; ту же сцену, снятую в другой день. Утечка через «один и тот же объект в разных выборках» остаётся на совести того, кто делит данные. Так что «dataset‑doctor ничего не нашёл» не значит «утечки нет». Он ловит самый частый и самый обидный случай — и этого уже много.
Что делать
Делить данные по источникам, а не по кадрам. Все кадры одного видео, одного дня съёмки, одного объекта должны целиком уйти либо в train, либо в val. В scikit‑learn для этого есть GroupShuffleSplit и GroupKFold, где группа — идентификатор видео. Если видео одно, как здесь, честного разбиения внутри него нет: val из того же ролика отвечает только на вопрос «обучение вообще завелось?», а для оценки качества нужны другие фото — другие блистеры, другой свет, другой телефон.
Проверять датасет до обучения, а не после. Обучение заняло 22 минуты; разбираться, почему модель с mAP 0,99 не работает на реальных фото, можно неделю. Проверка на дубликаты, пустые разметки, перекосы классов и прочие болезни стоит минуту и ничего не требует, кроме папки с данными.
И самое простое: если файлы в датасете называются Frame_0001, насторожиться стоит ещё до запуска любых проверок.
Итого
medical‑pills: 115 кадров, train 92 / val 23. У всех 23 кадров val есть почти копия в train (≤ 6 бит по dHash), у 17 — не больше 2 бит, у 3 — ровно 0.
YOLO11n за 22 минуты на CPU даёт mAP50 = 0,991. Цифра честная, но измеряет запоминание, а не обобщение.
Поймать это можно за 20 строк: dHash + расстояние Хэмминга + корзины по 8 бит.
Лечится не порогом, а разбиением по источникам.
dataset‑doctor лежит здесь: github.com/8MVR3/dataset‑doctor. Если у вас есть датасет, собранный из видео, — прогоните проверку и напишите в комментариях, сколько пар нашлось. Подозреваю, что ноль будет редко.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.