От 6 часов до 20 минут: как мы ускорили коллаборативную модель в рекомендациях Авито

—

от автора

Привет, я Салават Динмухаметов, senior ML-engineer в команде рекомендаций Авито. В статье расскажу, как мы изменили систему рекомендаций на главной странице. 

Одна из важных частей этой системы — коллаборативная модель avitofm. Раньше она обновлялась раз в 6 часов. Проблема в том, что товары из хороших объявлений разбирают быстрее: они не успевали оказаться в рекомендациях. 

Мы решили ускорить обучение — и за несколько итераций сделали neartime-архитектуру, которая обновляется каждые 20 минут. Это повысило качество, бизнес-метрики и сэкономило память.

Статья будет полезна ML-инженерам и тимлидам, которые строят рекомендательные системы и упираются в свежесть: когда модель даёт нормальное качество, но узнаёт о новых айтемах слишком поздно.

Как устроены рекомендации на главной Авито

На Авито 72 млн пользователей в месяц и 240 млн активных объявлений. Половину всех просмотров и около 30% контактов продавцам приносят рекомендации их товаров на главной. 

Страница решает две задачи:

1️⃣ Exploitation — показываем пользователю максимально релевантные товары. Мы знаем его интент и предлагаем то, с чем он, скорее всего, будет взаимодействовать. Так растим business value.

2️⃣ Exploration — разбавляем выдачу товарами, которые могут привлечь внимание. Например, если человек интересуется автомобилями, можем подкинуть ему запчасти. Так растим разнообразие ассортимента (diversity), новизну товаров для юзера (novelty) и попадание в неочевидные интересы (serendipity), а заодно больше узнаём о пользователе.

Архитектура рекомендаций стандартная

Весь каталог из 240 млн айтемов показать нельзя, поэтому сначала отбираем небольшой сет кандидатов. 

Источники кандидатов — назовём их движками — делятся на две группы: 

  • user-online — user2item-similars, graph, user2vec, crosscat 

  • offline — там живёт модель collab, она же avitofm. 

Дальше кандидаты идут в ранжирующую модель, потом блендятся по категориям, к ним применяются бизнес-правила — и получается выдача.

Дальше речь пойдёт про последний, оффлайновый источник — модель avitofm. У неё есть особенность, из которой вырос весь этот рассказ: она обучается раз в 6 часов.

Тут еще больше контента

Почему в 6 часов — это долго

Хорошее объявление на Авито живёт недолго. Если товар интересный и цена адекватная, он быстро улетает. Пик CTR приходится на первые часы, дальше кривая падает.

Поэтому у нас появилась гипотеза: что будет, если попробовать ускорить обучение модели и успеть подхватить этот пик?

Как устроена avitofm и почему её нельзя просто выкинуть

У этой модели три роли:

1️⃣ Recall главной. Это один из крупнейших источников кандидатов для ленты.

2️⃣ Кандидатогенератор u2i-sim. Похожие товары ищем на эмбеддингах от collab.

3️⃣ Фича ранкера. Для каждого объявления и пользователя дополнительно получаем признак collab_score, которую используем в модели ранжирования. Он стабильно держится в топ-10 SHAP.

Сама модель устроена просто. Есть таблица эмбеддингов для айтемов и таблица для пользователей. Мы хотим, чтобы они жили в одном пространстве: тогда векторы можно перемножить, получить скор, отранжировать по нему, взять топ-K и отдать дальше.

Обучаемся только на кликстриме, никаких дополнительных данных не используем. Позитив — взвешенный сигнал по действиям пользователя, негативы — sampled, с поправкой на популярность. Скалярное произведение user_emb · item_emb идёт в CrossEntropyLoss. Пользователя инициализируем как среднее эмбеддингов тех айтемов, с которыми он взаимодействовал. Для нового айтема берём контентный вектор, а предсказание — это поиск топ-K похожих айтемов.

📋 Подробнее про модель

Минусы: работа по чанкам

В Авито 52 категории айтемов и 85 регионов — для каждого пересечения категория×регион мы обучаем свою модель.

Почему так? Во-первых, исторически сложилось. Во-вторых, раньше не было мощных GPU, а такая модель при разбивке по чанкам влезает в GPU целиком. К тому же чанки между собой отличаются: в каких-то категориях больше товаров и взаимодействий между продавцами и покупателями, в других — меньше, и это нужно учитывать отдельными параметрами.

Отсюда и минусы:

🔴 Слишком много моделей. 52×85=4420. Каждую нужно обучать, поддерживать и следить, что они не разъехались. Такие данные не просто агрегировать. 

🔴 Дубли эмбеддингов. Пользователь может взаимодействовать сразу в нескольких категориях: например, смотреть недвижимость в Москве и Петербурге. В чанках появляются одинаковые эмбеддинги.

🔴 GPU не работают на полную. Раньше разбивка по чанкам была плюсом. А теперь, когда используем GPU с большим объёмом памяти — наоборот, потому что они не используются полностью.

Первый шаг: дообучаемся только на свежем

Самый простой baseline, который приходит в голову: оставить архитектуру как есть, но дообучаться на лету. Так появилась инкрементальная модель avitofm.

В ней те же векторы и та же близость — avitofm никуда не девается: продолжает учиться раз в 6 часов и отдаёт «опорные» эмбеддинги как стартовую точку. Поверх основной модели мы докидываем инкрементальную, которая подучивает эти векторы на свежих событиях, а не с нуля. Новые товары и новых пользователей инициализируем, всё остальное не трогаем.

Ключевой приём в том, что новый товар получает вектор из контента, а новый пользователь — среднее его товаров. В таблице эмбеддингов мы обновляем только затронутые строки: доли процента таблицы вместо всей, как в батче.

Что это дало:

+6% Recall@K — офлайн-прирост качества.

+1% контактов — по результатам A/B-теста.

Свежесть: часы → минуты. Новые объявления попадают в выдачу почти сразу после публикации.

Но появился минус, который съел часть выигрыша: в проде теперь две модели. Прошлая, батчевая, требовала много GPU. К ней добавилась инкрементальная — она, правда, может спокойно инференситься на CPU, потому что дообучать нужно немного. 

Итог: двойная поддержка, ×2 ресурсов и — главное — сложнее проводить A/B. Если захочется покатить что-то ещё, ресурсы понадобятся уже под четыре модели.

Жми сюда!

Neartime: одна модель вместо двух

Следующий шаг — убрать дубль и оставить одну near-real-time модель на категорию.

Берём исходную модель, забираем её эмбеддинги как опорные и дообучаемся по батчам, которые приходят раз в N минут. Минимум, на который мы вышли, — 20 минут: именно с таким шагом получается обрабатывать каждый батч. avitofm при этом остаётся, но трогаем её раз в сутки — только ради стартовой точки, дальше модель автономна.

Полностью выпилить avitofm не получится: это одна из самых первых моделей в Авито, её предсказания используются в куче других продуктов как базовый источник кандидатов. Зато её можно оставить как базу для инициализации векторов.

Как всё устроено. Кликстрим лежит в ceph. Отдельный CPU-под prep нарезает его на окна по 20 минут и кладёт их в очередь queue:work. N GPU-воркеров смотрят в эту очередь, составляют расписание и разбирают самое старое необработанное окно. После обучения воркер отдаёт рекомендации в Redis, откуда сервис складывает снапшот затронутых эмбеддингов обратно в хранилище. Если какое-то окно не поднялось или воркер упал, остальные это подхватят.

Всё это крутится в Авифлоу — нашей платформе оркестрации ML-пайплайнов. Она держит расписание, GPU-аллокацию, авто-рестарты, логи и метрики, и в ней же зарегистрированы сами пайплайны.

При нарезке окон важны две вещи:

Watermark — читаем данные ровно с момента последнего обработанного окна. Это существенно, потому что надо обновлять основную модель, когда она обучается. Для этого накапливается окно, которое потом пересчитается.

Дедупликация по ключу (user, item, event, time) — читаем из Kafka, где случаются пересылки, поэтому оставляем только уникальные события.

Сколько времени на это уходит: 

  • ~10 минут копим окно из Kafka 

  • ~4 минуты идёт обучение

  •  ~2 минуты — расчёт top-K с гео-маской

  • ~3 минуты — выгрузка в Redis

  • ~1 минута — ожидание в очереди. 

С учётом grace и очереди укладываемся в p99 «событие → рекомендация» ≤ 25 минут с запасом. И это на все 52 категории сразу.

Инициализация векторов. avitofm обучается раз в сутки ночью. Пока она учится, окна копятся в очереди — но происходит это ночью, поэтому очередь разгребается быстро: достаточно добавить пару воркеров.

Дальше из чанковой модели нужно собрать единую. На bootstrap мы объединяем id всех чанков и усредняем дубли — получается одна модель категории. Здесь же сама собой решается проблема дубликатов векторов из батч-чанков. А чтобы догнать новую базу, мы сбрасываем события с последнего обучения и заново перебучаемся с того момента, когда началось обучение avitofm.

Что пришлось придумать, чтобы это поехало

Одна модель на категорию вместо тысяч. От чанков «категория × гео» хотелось уйти: одно дело инференсить 52 модели, другое — четыре с лишним тысячи.

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

SparseAdam на крупных категориях. Большие категории вроде авто держать в памяти можно, но они очень большие. Dense Adam обновляет все моменты — O(N·dim), включая ложные апдейты неактивных строк. SparseAdam обновляет только те строки, которые мы действительно затронули, — O(nnz·dim).

Так мы сняли OOM без потери качества. SparseAdam включаем на категориях от 5 ГБ, а на мелких оставили dense — там от sparse страдает качество. На крупной категории потребление памяти снизилось в 3 раза: меньше GPU для инференса — тут тоже выигрыш.

Надёжность. Поды периодически падают: то ресурсы не выделяются, то проблемы приходят из соседних очередей. Поэтому делаем:

  • Снапшоты. Раз в ~4 часа кладём anchor — полный снимок накопленного состояния, а каждые 20 минут — лёгкие delta, только обновлённые строки, они примерно в 100 раз меньше. Упал под — делаем cold-load с последнего снапшота.

  • Перераспределение нагрузки. Если под упал и на нём инференсились, скажем, 20 категорий, остальные воркеры понимают это по растущей очереди в Redis, забирают эти категории себе и полностью восстанавливают модель из хранилища. Саму модель мы не теряем — она воспроизводится на других воркерах.

  • Кэш моделей. Чтобы не таскать веса между RAM и памятью GPU, горячие категории держим на GPU (HOT), тёплые — в RAM (WARM), плюс sticky-привязка к поду. Смотрим в очередь, понимаем, какие категории скоро пойдут в обучение, и подтягиваем их из RAM заранее.

  • Мониторинг дрейфа. Эмбеддинги категории, которую мы долго дообучаем инкрементально, могут сильно разъехаться с тем, что получилось бы при полном обучении модели. На каждом шаге считаем l2-скор эмбеддингов к baseline — если расходится, срабатывает алерт.

В итоге sizing и SLO такие: ~6 ГБ на категорию, ~4 пода A100/H100 на все категории, p99 свежести ≤ 25 минут.

Что получилось и что дальше

Три цифры, ради которых всё затевалось:

6 часов → 20 минут — свежесть айтемов. Новое объявление попадает на главную за 25 минут вместо 6 часов.

+1% контактов — наша главная метрика, проверено A/B.

Что в планах:

Ускорить downloader. Сейчас данные из Kafka попадают к нам не напрямую: отдельный воркер выкачивает кусок, перекладывает на s3, это обрабатывается, и только потом prep нарезает окна. Так исторически сложилось, и это откровенный костыль. Хотим перевести на Spark и обучаться ещё быстрее, чем раз в 20 минут.

Перейти на свою инициализацию. Сейчас раз в сутки обучается avitofm, и на это тоже уходят ресурсы. Хотим оставить пайплайн обучения с нуля только на случай, когда что-то идёт не так.

Добавить информацию с прошлых батчей. Чтобы бороться с забыванием и уменьшить дрейф эмбеддингов, докидываем в обучение информацию из предыдущих прогонов. Сразу это не завелось, но сейчас уже сделано и ждёт своей очереди в A/B.

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

Кликни здесь и узнаешь

Коротко

👉 Коллаборативная модель avitofm обеспечивает существенную долю рекомендаций на главной Авито, но обучалась батчами раз в 6 часов — а хорошие объявления «улетают» за первые часы.

👉 Первая попытка — инкрементальное дообучение поверх старой модели — сработала по метрикам, но привела к двум параллельным моделям, двойным ресурсам и усложнённому A/B.

👉 Финальная архитектура — одна neartime-модель на категорию: batch-avitofm даёт стартовую точку раз в сутки, а дальше модель дообучается сама каждые 20 минут на потоке из Kafka через пайплайны Авифлоу (на Kubeflow).

👉 Заодно избавились от обучения по чанкам: было 4200+ моделей на категорию×регион, стало 52 — по одной на категорию с гео-маской на инференс, и сократили память на крупных категориях за счёт SparseAdam.

👉 Итог: свежесть объявлений в выдаче — с 6 часов до 20 минут, контакты — плюс 1% по A/B, всё уместилось в 4 GPU-пода на 52 категории.

👉 Дальше — быстрее забирать данные из Kafka, полностью уйти от батч-инициализации и продолжать эксперименты с архитектурой самой модели.

На этом всё. Если у вас есть вопросы или свой опыт ускорения оффлайн-моделей — буду рад обсудить в комментариях.

Кстати, а вы заметили, что с ИИ-агентами приходится принимать гораздо больше решений — и от этого мозг устаёт даже сильнее, чем в доагенсткую эпоху?

Об этом мы недавно спросили DS-инженеров в нашем тг-канале «Доска AI-объявлений» → заходите узнать ответы. Там много и других интересных постов, например, недавно рассказали: 

🔗 Как съездили на KDD на Чеджу

🔗 Как проходят наши стажировки: мини-интервью с участниками

ссылка на оригинал статьи https://habr.com/ru/articles/1086210/