Как я вообще туда попал

Я крайне редко на фрилансе получал заказы связанные с DS/ML, специалистов для таких задач обычно ищут не там. Причины разные: они требуют долгой интеграции, заказчик сам не понимает задачу, DS более конфиденциален, DS часто возникают внутри продукта, да и в последнее время этот сегмент на фрилансе съедается при помощи LLM: AI integration, RAG боты например. По отдельности эти факторы не страшны, но их совокупность уменьшает количество таких проектов на российском фрилансе почти до 0.

Но, внезапно, мне в личку постучались с таким проектом.

Я откликнулся, задал кучу уточняющих вопросов - есть ли датасет, кто размечает, какая модель, где крутится. Заказчик ответил подробно, видно было что человек понимает задачу изнутри. Он написал:

Датасет надо делать, в этом и смысл задачи
Целевые проценты даны для ориентира, никто не знает что на самом деле достижимо. Готов принять и худшие результаты, если они объективно обоснованы

Вот это порадовало! Человек хочет не красивых цифр, а честный результат.
Предложил 140к, три этапа, поэтапная оплата - сделали этап, сдали, оплатили. Если что-то не устроит - можно уйти к другому разрабу с результатами. Так риски меньше для обеих сторон. Он принял:

Ваше предложение выглядит лучшим, как по адекватности понимания задачи, так и по репутации на площадке. Не вижу смысла дальше мучить себя и других...

Ну и поехали.

Требования, от которых голова кругом

На бумаге всё выглядело чётко:

  • Минимум 200 тегов

  • 99%+ фото должны получить хотя бы один тег

  • Не более 3% фото с одинаковым набором тегов

  • Precision и recall — ориентир 90%

  • Работает локально, на CPU

  • Теги должны быть понятны обычному человеку без инструкции

И вот тут - штука, которая определила половину инженерных решений. Заказчик сразу обозначил:

Продукт включает только dlib для распознавания лиц. Он добавляет всего 5-10 МБ. Все модели из OpenCLIP потребуют torch, а он сам что-то вроде 300+ МБ

То есть нельзя просто взять SOTA-модель и сказать «вот, короче, работает». Она может не влезть в продукт. Это не академическая задача, а продуктовая. Ну и разница между ними - как между «решить уравнение на доске» и «построить мост, который не упадет».

Почему это не «ещё одна классификация картинок»

Я поначалу думал - ну, классификации, embedding, cosine similarity, дело техники)
Но..! ошибался.

Пользовательские архивы - это хаос. Типичный набор фотографий с телефона - это фото детей вперемешку со скриншотами переписок, мемами из чатов, фотографиями доков, случайными картинками и десятками дублей. Примерно треть архива - это даже не фотографии в привычном смысле. Но именно этот хаос и нужен)

Люди ищут не объекты, а воспоминания. Когда человеку нужно найти фото, он не думает «покажи объекты класса nature». Он думает: «где фото с той прогулки» или «найди фотки с моря», «убери все скрины». Это совершенно другая задача.

Теги должны работать как фильтры. Не «вот 50 тегов на фото, разбирайся сам» - а «вот 2-3 точных тега, которые позволяют сузить архив из 100 000 фото до 20». Заказчик сам привёл пример:

Желание найти фото "женщина в красном платье на фоне моря" может быть описано тремя тегами "портрет женщины", "море", "яркая одежда". Этого достаточно чтобы сузить архив из 100 000 фотографий до двух десятков и выбрать нужную глазами.

Красивая постановка. Осталось реализовать)

Как выяснилось, что в интернете нет ответа на главный вопрос

Вот здесь начались отхождения от начального ТЗ.

Заказчик был убежден, что можно пойти в интернет и найти то, как люди ищут свои фотографии. Что где-то на Reddit, в Google Photos, в обзорах - люди обсуждают, какие теги они ставят, как классифицируют свой архив. Он даже скинул мне свой диалог с DeepSeek на эту тему.

Я сел изучать. И выяснил, что этого не существует.

К Google Photos чужим - нет доступа. Нельзя посмотреть, как другие люди организуют свои фотки, какие альбомы создают, по каким словам ищут. Это все закрыто. Да и в России не много кто намеренно разбирает фотки в Google Photos. Скорее они по дефолту иногда туда загружаются. Reddit - просто никто об этом не говорит. Гипотеза о том, что люди в интернете обсуждают, как они классифицируют свои фотки, - оказалась неверна. Может, лет двадцать назад это было актуально. Сейчас - УВЫ! Люди не думают про организацию архива, пока у них не накопится 50 000 фото и они не захотят найти конкретное.

На стоках типа Shutterstock, Unsplash, Pexels - теги есть, но они коммерческие. Они нужны для SEO, чтобы фото находили в интернете. «Happy», «business», «success» - это маркетинг, а не память. Огромная разница между тем, как интернет по алгоритму классифицирует фотографии, и тем, как человек для себя их обозначает. И вот тут мы с заказчиком пришли к выводу, что нельзя найти �� интернете готовый ответ на вопрос «как люди для себя классифицируют фотки». Либо данных нет, либо их дистилляция заняла бы столько времени, что это был бы отдельный проект.

Недолго думая, я решил: сам придумаю. Я же человек. Пообщался с окружающими, с женой, сам с собой подумал - на какие сцены можно разделить фотографии. Не абстрактно, а конкретно: вот я открываю галерею, мне нужно найти фото - о чём я думаю в этот момент? И понял одну важную вещь. Люди мыслят не объектами. Не «красивый лес» и не «два человека». Люди определяют фотографии по месту, по ощущениям, по времени и настроению. «Это было летом на даче». «Это когда мы тусовались в парке». «Это тот вечер в центре, когда шел дождь». Память работает через контекст, а не через перечисление объектов.

Вот так я начал выращивать первое древо тегов - не сверху вниз от возможностей модели, а от того, как устроена человеческая память.

Оси памяти

Получилось шесть основных координат:

  1. Тип контента - фото, скриншот, мем, документ, коллаж

  2. Место / сцена - дом, улица, парк, пляж, кафе, горы

  3. Активность - прогулка, спорт, еда, шоппинг, путешествие

  4. Событие - день рождения, свадьба, Новый год

  5. Люди —- мужчина, женщина, ребенок (не конкретные лица — это уже было в продукте)

  6. Качество и условия - темное, размытое, засвеченное, дождь, снег

Каждая ось - координата, по которой человек может сузить поиск. Комбинация 2-3 осей даёт достаточно узкую выборку, чтобы найти нужное фото глазами.

Как я собирал 122 000 фотографий

Заказчик сразу сказал: нужно минимум 100 000 фото для тестирования. Свои он давать не готов - они пойдут на валидацию результатов. Предложил: "Возьмите свои фотки и фотки знакомых/родных. Плюс можно поскачивать из интернета"

Я начал с идеи собрать по знакомым. Быстро понял - никто не хочет делиться своими фотками. Это личный архив, там все - от нелепых селфи до документов. Люди не готовы это отдавать. И даже если бы согласились - это не набрало бы 100 000. То есть суммарно мне скинули около 400 фоток.

Тогда я пошел в Telegram. Написал парсер на Telethon (userbot на Python), который проходил по каналам, проверял последние 20 000 сообщений и выгружал фотографии. Только открытые каналы, только публичный контент.

Но не случайные каналы. Я уже имел черновое древо тегов и для каждого тега искал тематический канал. Собаки - каналы про собак. Дети — родительские чаты. Тусовки, концерты, еда, путешествия, горы, дача - на каждый тег свой источник. Плюс обязательно - каналы, где люди просто делятся своими фото, без тематики. Потому что типичный архив - это не тематическая подборка, а хаос. Много рандомных фото нашел в источниках типа "фото для фейка", "фото на аву". Были классные каналы фотографов - прям удовольствие местами получил

Потребовалось около 70 каналов. На выходе - 122 000 изображений: фотографии разного качества, скриншоты, мемы, документы, дубли, случайный мусор.
К концу, правда, понял, что для некоторых тегов эталонных фото не хватало - не все можно найти в открытых каналах. Но для MVP хватило.

Этап 1 — "Мы не оптимизируем. Мы выясняем, где сигнал"

Принцип первого этапа я зафиксировал жестко: никакой оптимизации. Только проверка - есть ли вообще сигнал?.. Потому что соблазн "а давайте ещё вот это попробуем" — он на каждом шагу. И если не ставить четких границ - будет страшно.

Максимально тупой pipeline

Для первого прохода - простой подход. Без обучения, без fine-tuning. Чистый zero-shot:

image → CLIP embedding (ViT-B-32)
tag → набор текстовых промптов
score = cosine_similarity(image, prompt)
if score >= threshold → assign tag

Каждый тег описывался несколькими текстовыми промптами. Один глобальный порог. 39 тегов. 122 000 фото. Один вопрос: работает или нет?

Результат удивил

Я взял top-30 фотографий по каждому тегу и глазами проверил.

21 тег - точность 90-100%. Пляж, парк, дом, селфи, еда, прогулка, пикник, транспорт. Без единого обучающего примера. Просто по текстовому описанию.

Ещt 9 тегов - 70-89%. И 9 провалились ниже 70%.

Думал будет больше шума, а оно работает)
На бытовых категориях zero-shot CLIP дает адекватный результат.

Но тут же вылезла системная проблема

Текстовые промпты оказались слабым звеном.

birthdayweddingnew_year — для CLIP это почти одно и то же. Люди, еда, украшения, помещение. Модель не виновата - она видит визуально похожие сцены, и никакая формулировка промпта не помогает их различить.

document путался с фотографиями книг. screenshot - с селфи в зеркало. meme - со скриншотами переписок.

Текст - это уже лексическая ловушка. Промпт может быть сколь угодно точным по смыслу, но если визуальное пространство двух классов пересекается - слова не помогут.

Что сказал заказчик

Заказчик подтвердил приемку первого этапа и сразу обозначил направление:

Вижу, что вы строили работу вокруг текстовых промптов, для первой стадии это вполне естественно. Однако, если строить классификатор не по текстовым промптам, а по эмбеддингам картинок - мы вообще будем не привязаны к лексическому модулю, и сможем использовать не только CLIP, но и любую другую архитектуру.

Заказчик сам предложил ключевой поворот проекта. Не потому что ML-инженер - а потому что продуктовый разработчик, который думал на шаги вперед. Если мы завязаны на текстовый энкодер CLIP - мы завязаны на весь стек! А это, так то, 300+ МБ в дистрибутиве.

Вывод первого этапа уместился в одну фразу: сигнал есть, текст -узкое место.

Ключевой поворот: от слов к изображениям

Самый важный архитектурный момент проекта. Он изменил всё.

Было: класс = набор текстовых описаний.
Стало: класс = набор эталонных фотографий.

Вместо того чтобы объяснять модели текстом, что такое "пляж" - мы показываем ей 120 фотографий пляжей. Модель считает средний эмбеддинг - "центроид класса". Новое фото сравнивается не с текстом, а с этим вот центроидом.

Что это даёт:

Нет зависимости от текстового энкодера. Можно использовать любую модель, которая превращает картинку в вектор: CLIP, ResNet, MobileNet, dlib - да что угодно!
Нет лексической ловушки. "День рождения" и "свадьба" различаются не по словам, а по визуальным паттернам.
Объяснимость. Можно посмотреть, из каких фото состоит класс. Понять, почему модель ошиблась. Улучшить - добавив или убрав примеры, например.
Масштабируемость. Новый тег = новая папка с фотографиями. Никакого переобучения.

Провал с hard negatives

Помимо положительного центроида нужен был отрицательный — чтобы штрафовать за «похож на общий фон». Рабочая формула:

score(tag) = cos(image, centroid_positive) - cos(image, centroid_negative

Это сильно снизило false positives. Но дальше я попробовал hard negatives - вручную подобранные «похожие, но не то». Если beach путается с pool - добавим фото бассейнов как negative.

Результат жесть. Слишком узкие negatives сломали шкалу скоров - модель начала видеть "пляж" в совершенно случайных фото. Массовый over-tagging.
Ну надо запомнить: negative нужен, но нейтральный фон датасета работает лучше ручного подбора. А для конфликтующих классов - нужен отдельный фильтр постфактум.

Этап 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 соседей в каждом из трех режимов, для сложных тегов (birthdayweddingscreenshotwalk и других) - 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, потому что затрагивает ещё и поиск похожих фото.

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

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

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

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

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

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