Как я учил компьютер понимать 120 000 фотографий. Часть 2

Николай Носков 10 мин

В первой части — таксономия из шести осей памяти, 122 000 фотографий из Telegram и zero-shot, который показал, что узкое место не модель, а слова. Здесь — сравнение моделей, этап фиксации и ловушка красивых чисел.

Этап 2 — шесть моделей и момент, когда заказчик сказал «это отстой»

Зачем сравнивать, если CLIP работает

Потому что продуктовые ограничения. PyTorch — 300+ МБ. У заказчика уже есть dlib — 5-10 МБ. Если эмбеддинги можно считать через dlib или TFLite — продукт распухнет на 50-80 МБ вместо 700. Это не академический интерес, это натуральное бизнес-требование.

Шесть кандидатов

  • CLIP RN50 — основной кандидат
  • CLIP ViT-L/14 — потолок качества (для контекста, не для прода)
  • MobileNet v3 Small — компактный
  • ResNet50 (ImageNet), EfficientNet-B0
  • dlib модели — ResNet34, ResNet50, ViT-S-16

Первый прогон: красивые цифры, которые ничего не значили

Первую версию сравнения я построил на coverage — какой процент фото получает тег. MobileNet показал лучший coverage. Я был доволен.

Но не заказчик:

Мы померяли охваты моделей, но это не то же самое что recall. У нас нет числовых показателей precision/recall. Сложно так принимать решение о выборе.

И далее:

Честно говоря, цифры немного расстраивают… RN50 конечно лучше, но тоже отстой.

Тут важно сказать одну вещь: когда заказчик говорит «отстой», я не воспринимаю это как претензию к своей работе, я же не изобретаю нейросеть с нуля. Я делаю research: беру готовые предобученные модели, делаю эмбеддинги, показываю результат. Если результат слабый — это факт о модели, а не результат моей работы. Это отчётность о чём-то фактическом. Я выявил результат, и точка.

Но пинок был правильный. Потому что coverage — действительно не recall. И без честного P/R/F1 нельзя принимать решение.

Как пришлось перестроить всё

  1. Собрал GT-датасет — 5500 фото, размеченных вручную по 15 тегам
  2. Посчитал честный Precision / Recall / F1 для каждой модели
  3. Добавил режимы сравнения: target P ≥ 0.9, target R ≥ 0.8
  4. Добавил метаданные: размер, лицензии, runtime, CPU latency
  5. Прогнал SOTA-ceiling — ViT-L/14

Заказчик ещё отдельно ткнул в мою ошибку с dlib:

Почему вы из dlib взяли dlib_face? Это же для распознавания лиц, а не картинок. Там же есть resnet50_1000_imagenet_classifier, vit-s-16. Почему не попробовали их?

Действительно. Я взял face-модель как feature backend — проверить принцип работы dlib. Но нужно было брать classifier-модели. Протестировал дополнительно.

Результат

На GT (5500 фото, 15 тегов):

Модель Precision Recall F1
RN50 (CLIP) 0.466 0.853 0.557
ViT-L/14 (ceiling) 0.644 0.565 0.538
MobileNet v3 0.457 0.712 0.514
dlib ResNet34 0.442 0.450 0.339
dlib ViT-S-16 0.432 0.200 0.203

Три наблюдения.

Первое. Разрыв между RN50 и потолком (ViT-L/14) небольшой. При этом ViT-L/14 в три с лишним раза медленнее (153 vs 45 мс/фото). Потолок качества подтверждён — но он не настолько далеко, чтобы оправдывать тяжёлую модель.

Второе. dlib провалился. ImageNet-обученные модели дают эмбеддинги под 1000 фиксированных классов, а у нас multilabel, пользовательские сцены, абстрактные теги типа walk и travel. Заказчик сам это резюмировал:

Они же на 1000 классах учили, а не на контрасте как CLIP

Другая постановка — другие требования к пространству эмбеддингов.

Третье. MobileNet был быстрее и компактнее, но ручная валидация показала: на сложных тегах больше borderline-случаев, однотипные результаты. Когда RN50 путает день рождения со свадьбой — это всё-таки визуально близко. Когда MobileNet путает день рождения с любым столом с едой — это уже раздражает.

Финальное решение: RN50 (CLIP) через ONNX Runtime. Без PyTorch в продакшене. ~45 мс на фото на CPU. ~77 МБ весов.

Этап 3 — «меньше экспериментов, больше фиксации»

Третий этап не про исследования, а про то, чтобы зафиксировать и упаковать всё, что выжило после первых двух.

81 тег

Система выросла с 39 до 81 тега по всем шести осям. Места, активности, события, типы контента, люди, качество и условия — от beach до moon, от selfie до stairwell.

Индивидуальные пороги

На первом этапе — один глобальный порог. На третьем каждый тег получил свой, подобранный по precision-recall кривой. Для beach — высокий (модель уверена). Для birthday — компромисс между «хоть что-то» и «не врать».

Conflict-фильтр

screenshot и meme: скриншот переписки часто содержит мем. birthday и wedding: модель может поставить оба. Если два конфликтующих тега прошли порог — остаётся тот, у которого score выше.

Fallback для покрытия

Без fallback — 52.73% фото с тегами. С fallback — 99.07%.

Если ни один тег не прошёл порог — берётся лучший тег с минимальным порогом. Это не «точный тег», а «лучшее предположение». Но для навигации лучше, чем ничего. В отчётах я всегда считал эти цифры отдельно, потому что coverage с fallback — продуктовая метрика, а coverage без — качественная. То есть не путать их.

Дополнительный этап — уменьшение размерности

После того как RN50+CLIP через ONNX стал финальным выбором, заказчик вернулся к вопросу, который поднял ещё на этапе 2:

Снижение размерности. 1024-мерный вектор — избыточен для 81 тега. Можно ужать?

Вопрос не праздный: меньше размерность — быстрее retrieval, меньше памяти. Договорились начать с 512 и смотреть, что теряется. Проверяли два метода: PCA 512d и Random Projection 512d.

Ловушка красивых чисел

Автоматические метрики выдали неожиданную картину:

Режим Precision Recall F1 overlap@10
Baseline 1024d 0.823 0.474 0.542 —
PCA 512d 0.344 0.998 0.484 0.810
RP 512d 0.828 0.440 0.514 0.735

PCA показывает recall 0.998 — почти идеальный. Выглядит как победа. Но precision 0.344 — печаль. Модель начала ставить теги буквально всему. Та же история с coverage: загрубил — получил красивую цифру. Только здесь не намеренно: пространство схлопнулось, все эмбеддинги стали ближе друг к другу, и старые пороги поехали.

RP ведёт себя честнее по агрегатным метрикам, но хуже держит retrieval: overlap@10 = 0.735 против 0.810 у PCA.

По числам непонятно, нужна ручная проверка.

Что показала ручная валидация

Подготовил артефакты для визуального сравнения: для 30+ query-изображений top-10 соседей в каждом из трёх режимов, для сложных тегов (birthday, wedding, screenshot, walk и других) top-30 кандидатов по score. Около 2000 примеров прошли ручную оценку.

Результат по retrieval — доля визуально осмысленных соседей:

  • baseline: 0.940
  • pca_512: 0.947
  • rp_512: 0.927

PCA не только не деградировал — он чуть лучше держит retrieval, чем baseline. По classification на сложных тегах деградации, заметной для пользователя, не выявлено.

Вывод простой: агрегатные метрики врали. Precision 0.344 — артефакт порогов, которые нужно перекалибровать под новое пространство. Визуальное поведение системы осталось рабочим.

Финал: PCA 512d в продакшене. Вдвое меньше размерность, быстрее retrieval, меньше памяти — без практической потери качества.

Итоговые цифры

GT-оценка (7000 фото, 30 тегов)

  • Precision: 0.654
  • Recall: 0.634
  • F1: 0.566

Полный прогон (122 263 фото)

  • Coverage с fallback: 99.07%
  • Coverage без fallback: 52.73%
  • Среднее тегов на фото: 1.558
  • Уникальных наборов тегов: 2023

Финальная конфигурация

RN50 (CLIP) → ONNX Runtime → PCA 512d. Размер модели ~77 МБ, ~45 мс на фото на CPU, 512-мерный эмбеддинг вместо 1024.

Разбор

F1 = 0.566 — это не 90% из ориентира. Но давайте разберём.

Macro-усреднение считает каждый тег одинаково. Сильные теги (beach, selfie, park) дают 95-100%. Слабые (birthday, eating) тянут среднее вниз. Ищешь «фото с пляжа» — работает отлично. «Фото с дня рождения» — шумнее.

Потолок подтверждён. SOTA-модель (ViT-L/14) на том же GT — F1 = 0.538. Не лучше RN50. Ограничение не в модели, а в сложности самой задачи.

52.73% без fallback — не «модель работает наполовину». На половине фото модель уверена для жёсткого порога. Остальные получают тег через fallback — менее уверенно, но полезно для навигации.

Про LLM в этом проекте

Весь код написан через LLM. Скрипты эмбеддинга, классификации, прогонов, валидации, экспорта в ONNX — всё сгенерировано. Я работал в основном с Codex, там пока лимиты повышены на версию 5.3.

Конечно, были тупежи. Самый показательный: на третьем этапе мы с заказчиком чётко зафиксировали, что работаем с RN50. Всё к этому подготовили, и я смотрю — а Codex мне генерирует скрипт эмбеддинга на совершенно другую модель. ViT-S-16, вроде как. Короче, не RN50. Я ему: «В спецификации конкретно написано, что мы работаем только с RN50». А он просто проигнорировал контекст и взял то, что ему показалось подходящим.

Галлюцинации немного удлиняли процесс. Но в целом всё равно это намного быстрее, чем писать все скрипты руками. А их было достаточно много: парсер для сбора фото, эмбеддинги, прогоны по моделям, валидация, визуальные сэмплы, GT-оценка, экспорт ONNX, conflict-фильтр, fallback-логика. Руками это было бы в разы дольше.

Но — и это важно — архитектура, таксономия тегов, выбор модели, интерпретация результатов — это инженерная работа. Нейросеть не скажет тебе, что coverage — не recall. Не скажет, что дни рождения путаются со свадьбами потому, что это контекстно разные, но визуально похожие события. И уж точно не придумает за тебя оси памяти, основанные на том, как реальные люди вспоминают свои фотографии.

Это должен понять человек.

Пять вещей, которые я вынес

Сначала таксономия, потом модель. beach работает на 100% не из-за модели, а потому что пляж — визуально однозначная категория. birthday не работает не из-за модели, а потому что день рождения — контекст, а не визуальный паттерн. Если не зафиксирован язык тегов — сравнение моделей бессмысленно.

Zero-shot — лучший разведчик, худший продакшен. Для первого прохода гениально: за день можно проверить 50 гипотез. Строить на нём продукт — ловушка.

Coverage — опасная метрика. Можно загрубить пороги и показать 95%. А потом пользователь увидит, что на его борще стоит тег beach, и удалит приложение.

Продуктовые ограничения формируют архитектуру. Размер дистрибутива, CPU latency, лицензии — это не «потом разберёмся». Это часть архитектурного решения с первого дня.

Заказчик, который говорит «отстой», — хороший заказчик. Каждый его пинок заставлял меня перестроить что-то конкретное. Каждый раз результат становился лучше. Проект, где заказчик только говорит «ок, супер», — это проект, где никто не проверяет качество.

Что дальше

Проект завершён, развитие — отдельные треки:

Расширение до 200 тегов. Черновая иерархия на 190 готова. Нужен пересбор прототипов и калибровка.

Квантизация. FP16 работает без заметных потерь. INT8 — нужна отдельная валидация.

Снижение размерности. 1024-мерный вектор избыточен для 81 тега. Заказчик сам предложил гипотезу — можно ужать до 10-20 параметров. Но это отдельный scope, потому что затрагивает ещё и поиск похожих фото.

Усиление сложных тегов. Событийные классы (birthday, travel, eating) требуют не смены модели, а расширения и доочистки прототипов.

Вместо заключения

Фотографии — это не изображения. Это фрагменты памяти. И когда строишь систему навигации по ним, самый важный вопрос не «какая модель лучше», а «как человек будет это искать».

Если ответ правильный — даже простой cosine similarity с прототипами даёт результат, которым можно пользоваться. Если неправильный — никакой ViT-L/14 не поможет.

Инструмент — усилитель. Но усиливает он то, что ты в него вложил.

Проект выполнен как фриланс-заказ для десктопного приложения каталогизации фотоархивов. Все данные — из реальных отчётов и переписки.