Как я учил компьютер понимать 120 000 фотографий. Часть 2
Шесть моделей и честные precision/recall вместо coverage, 81 тег с индивидуальными порогами, ловушка красивых чисел при снижении размерности и итоговые цифры проекта.В первой части — таксономия из шести осей памяти, 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 нельзя принимать решение.
Как пришлось перестроить всё
- Собрал GT-датасет — 5500 фото, размеченных вручную по 15 тегам
- Посчитал честный Precision / Recall / F1 для каждой модели
- Добавил режимы сравнения: target P ≥ 0.9, target R ≥ 0.8
- Добавил метаданные: размер, лицензии, runtime, CPU latency
- Прогнал 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 МБ весов.
фото ─▶ RN50 (ONNX) ─▶ PCA 512d ─▶ пороги
Этап 3 — «меньше экспериментов, больше фиксации»
Третий этап не про исследования, а про то, чтобы зафиксировать и упаковать всё, что выжило после первых двух.
81 тег
Система выросла с 39 до 81 тега по всем шести осям. Места, активности, события, типы контента, люди, качество и условия — от beach до moon, от selfie до stairwell.
Индивидуальные пороги
На первом этапе — один глобальный порог. На третьем каждый тег получил свой, подобранный по precision-recall кривой. Для beach — высокий (модель уверена). Для birthday — компромисс между «хоть что-то» и «не врать».
Conflict-фильтр
screenshot и meme: скриншот переписки часто содержит мем. birthday и wedding: модель может поставить оба. Если два конфликтующих тега прошли порог — остаётся тот, у которого score выше.
screenshot 0.71 ┐ meme 0.63 ┘─▶ screenshot birthday 0.58 ┐ wedding 0.66 ┘─▶ wedding
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 не поможет.
Инструмент — усилитель. Но усиливает он то, что ты в него вложил.
Проект выполнен как фриланс-заказ для десктопного приложения каталогизации фотоархивов. Все данные — из реальных отчётов и переписки.