mAP50 = 0,99 за 22 минуты на CPU. Жаль, что цифра ничего не значит. dhash.. dhash. ultralytics.. dhash. ultralytics. yolo.. dhash. ultralytics. yolo. валидация.. dhash. ultralytics. yolo. валидация. датасеты.. dhash. ultralytics. yolo. валидация. датасеты. компьютерное зрение.. dhash. ultralytics. yolo. валидация. датасеты. компьютерное зрение. утечка данных.

Я дообучил 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 отчёт выглядит так:

 Отчёт dataset-doctor по medical-pills: 115 фото, 2022 рамки, одна критичная проблема

Отчёт dataset‑doctor по medical‑pills: 115 фото, 2 022 рамки, одна критичная проблема

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

Вот как такие пары выглядят глазами. Слева кадр из val, справа — ближайший к нему кадр из train:

Три пары кадров val/train из medical-pills: отличия на уровне дрожания камеры

Три пары кадров val/train из medical‑pills: отличия на уровне дрожания камеры

Отличия — на уровне сдвига камеры на пару пикселей и чуть другого блика на фольге. Для детектора это одна и та же картинка.

Но 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. Если у вас есть датасет, собранный из видео, — прогоните проверку и напишите в комментариях, сколько пар нашлось. Подозреваю, что ноль будет редко.

Автор: 8MVR3

Источник