![](https://habrastorage.org/getpro/habr/upload_files/5e3/433/98b/5e343398b1b4a254617c52936906ac91.png)
Всем привет! Я Денис Красильников, работаю в отделе персонализации Т-Банка. Мы занимаемся всеми рекомендациями экосистемы. Это и ранжирование постов в пульсе, и построение лент кэшбэков, и даже подсказки для работников поддержки — всем занимается наш отдел, в том числе пишем научные статьи по рекомендательным системам и публикуем их на конференциях.
Расскажу, как мы научились делать тематические подборки у себя в контентной ленте лучше профессиональных редакторов и на какие шишки наступили.
Устройство контентной ленты и подборок
Контентная лента находится в меню «Город»:
![](https://habrastorage.org/getpro/habr/upload_files/b3b/eb3/10b/b3beb310b16407ebdcf6ae5f960f5c20.png)
Лента состоит из карточек, каждая карточка относится к одному из типов контента. В ленте можно забронировать ресторан, оплатить билет в кино, почитать статью в Т—Ж или купить что-то Долями. Все это примеры разного типа контента.
Мы хотим, чтобы пользователю было приятно находиться в приложении и нравилось то, что он видит в ленте, поэтому ранжируем ленту персонально под каждого клиента. Все карточки проходят модерацию. Разные карточки имеют разные наборы фич, их объединяет только, что у каждой карточки есть описание, заголовок и картинка.
![Пример карточки в ленте. В данном случае это карточка типа «Кино» Пример карточки в ленте. В данном случае это карточка типа «Кино»](https://habrastorage.org/getpro/habr/upload_files/edf/1b3/5b9/edf1b35b9082ac6d83829e31762ee910.png)
Как такового user-generated-контента у нас нет, это немного упрощает задачу и не создает ситуацию, когда мы рекомендуем откровенно странные вещи.
Подборки — это набор карточек из ленты, которые сгруппированы по общим признакам и удовлетворяют трем правилам:
имеют одинаковый тип контента;
тематически едины;
дополняют ленту, а не заменяют ее.
![Персональная подборка находится в самом верху ленты Персональная подборка находится в самом верху ленты](https://habrastorage.org/getpro/habr/upload_files/c0d/8f3/a3d/c0d8f3a3ddd63f47a2dec98925513a1c.png)
Раньше мы брали готовые подборки, которые собирали внутренние редакторы, обогащали их фичами и ранжировали персонально для каждого пользователя. При таком подходе мы работали с сущностью «Карусель». Каждая Каруселька уникальна, то есть тематика не менялась со временем, но могло меняться ее наполнение. Мы генерировали очень много фич для Карусели по ее наполнению. Пайплайн такого подхода был простой и отработанный — известный градиентный бустинг.
![](https://habrastorage.org/getpro/habr/upload_files/4cd/ede/306/4cdede3064542164968264bc8920f49a.png)
Новый пайплайн в Каруселях
Минусы стали ощутимо перевешивать плюсы, и в начале этого года мы решили поменять пайплайн в Каруселях. Вместо ранжирования готовых Каруселей, собранных редакторами, мы стали собирать их самостоятельно.
Но требования к Каруселям все так же предъявляются:
Карусель должна быть персональной — наполнение должно зависеть от действий пользователя в ленте.
Мы не должны получать ленту в ленте, поэтому алгоритмы для наполнения ленты и для наполнения Каруселей должны отличаться.
Карусели должны быть тематическими — значит, это должно быть заложено в алгоритме.
Мы должны получать uplift по бизнес-метрикам.
Если собрать все требования в одно предложение, мы хотим, чтобы пользователю быть понятно, почему карточки вошли в подборку. Например, есть Каруселька «Сейчас в кино» и понятно, что контент там меняется, но, по сути, это все время одна и та же Карусель.
Метрики и алгоритмы
Engagement rate — самая главная метрика для бизнеса, именно по ней принимаются все решения. Это число пользователей, которые пролистали или кликнули по Каруселям к числу пользователей, которые просто увидели Карусель.
![](https://habrastorage.org/getpro/habr/upload_files/191/432/60a/19143260ae57533b131419f03c03931d.png)
У нас есть побочные метрики, такие как:
CTR — число кликов на число просмотров.
Swipe rate — число свайпов на число просмотров.
Глубина свайпов — как много свайпов в среднем по одной Карусельке делают пользователи.
Вот какими алгоритмами мы можем собирать Карусели.
Попробовать эвристику — самые популярные карточки, статьи Т—Ж или истории, ближайшие концерты или самые бронированные рестораны. Можно придумать кучу таких подборок, ну и собственно ранжировать их.
Но они все еще не будут ни персонализированными, ни самыми динамичными, и это не самое интересное решение.
Попробовать коллаборативную фильтрацию — ALS, EASE, VAE и прочие модели. Коллаборативную фильтрацию легко обучать, pipeline решения тоже довольно простой. И в предположении, что одинаковые по темам карточки нравятся одинаковым пользователям, все должно в принципе работать.
Ряд проблем, по которым не подходит коллаборативная фильтрация:
Слишком динамичный контент умеет устаревать и пропадать. Поэтому проблема cold start стоит особенно остро. Вечно актуального контента у нас не так много, и данные очень разрежены.
Коллаборативные модели участвуют в основном пайплайне ленты, и это может привести к дублированию.
Нет никаких гарантий на однородность Карусели. Мы можем только верить, что это действительно так. Если одинаковые по теме карточки нравятся одинаковым по поведению пользователям.
Устройство основной ленты
Наша основная модель была устроена так:
![Пайплайн основной модели Пайплайн основной модели](https://habrastorage.org/getpro/habr/upload_files/0c9/b2f/63a/0c9b2f63ad9f588e9436f48463c23477.png)
Работал стандартный двухуровневый пайплайн: на первом уровне трансдуктивные модели для генерации кандидатов, плюс всякие бейзлайны, которые закидывали интересных кандидатов. Затем мы обогащали кандидатов признаками, среди которых довольно много статистических фич вроде CTR за последние дни, и переранжировали их бустингом.
Такой пайплайн приводил к тому, что новым карточкам очень тяжело попасть в ленту. И получался замкнутый круг, ведь без хороших статистик они не рекомендовались, а без рекомендаций — не набирали статистик.
Мы решили, что нужна модель для прогрева новых карточек. Она должна опираться только на контент карточки: на ее картинку и текст, чтобы мы могли рекомендовать совсем новые карточки, по которым никто не кликнул и даже никто не видел.
Мы хотели получить примерно такой пайплайн:
![Модель A по контенту карточки возвращает некоторый эмбединг. Модель B по действиям пользователя тоже возвращает некоторый эмбединг из того же пространства, что и эмбединг карточки. Сравниваем, насколько вектор карточки и вектор пользователя похожи Модель A по контенту карточки возвращает некоторый эмбединг. Модель B по действиям пользователя тоже возвращает некоторый эмбединг из того же пространства, что и эмбединг карточки. Сравниваем, насколько вектор карточки и вектор пользователя похожи](https://habrastorage.org/getpro/habr/upload_files/36f/12d/4d7/36f12d4d75dc8213acb8ec27754d7559.png)
Как получать вектор карточки. Процесс может выглядеть примерно так: кодируем текстовым энкодером заголовок и описание карточки, а картинку — некоторым картиночным энкодером. Получаем два вектора, которые как-нибудь потом можем склеить в один.
![](https://habrastorage.org/getpro/habr/upload_files/a8f/597/b53/a8f597b535e60d0b6e9561b24694aabd.png)
После небольшого числа экспериментов мы решили на первое время отказаться от картиночного энкодера и оставили только текстовый по ряду причин:
картинки малоинформативны;
картинки плохо кластеризуются;
не накидывают офлайн-метрик;
неудобно хранить;
нужно придумать красивый способ склеить эмбединги.
Итоговый пайплайн: из карточки мы достаем ее название, описание, склеиваем их, прогоняем через BERT-like-модель, которую дообучили на наших карточках, и на выходе получаем эмбединг размерности 312.
![Процесс получения текстового эмбеддинга Процесс получения текстового эмбеддинга](https://habrastorage.org/getpro/habr/upload_files/16e/ca3/4ee/16eca34ee2b4bf4321e84f374cbd53cc.png)
Как получать вектор пользователя. Когда мы научились получать эмбединг карточки, нам нужно как-то научиться представлять пользователя в том же пространстве.
Самый простой вариант представить пользователя — усреднить все его клики, то есть все карточки, на которые он кликнул. Метод простой, немного шумный, но рабочий. При этом мы можем еще и добавить некоторые веса перед кликами и делать взвешенные усреднения. Например, отдавать больший вес тем карточкам, на которые пользователь дольше смотрел, или тем, на которые он недавно кликнул. Такой подход мы назвали MeanRec:
![](https://habrastorage.org/getpro/habr/upload_files/05d/47c/9de/05d47c9dede785edb1c869154d0a7398.png)
Другой подход интереснее, с использованием ML. Обучим SASRec, но с небольшими изменениями. SASRec идеально ложится на нашу задачу — угадать следующую карточку по предыдущей. Плюс это довольно стабильный и известный подход, который много раз проверен и хорошо себя зарекомендовал.
Если вдруг кто-то не знаком с архитектурой — это такой декодер блок трансформера, почти как gpt, но только в рекомендациях. Обучать будем на всех данных в ленте, чтобы получить максимально репрезентативную картину пользователя.
Эмбеддинг карточек мы берем из текстового энкодера и фризим их, чтобы получить возможность рекомендовать прям новые карточки. Получается, что обучается у нас только сам SASRec, а эмбеддинги айтемов не меняются.
Добавляем feedforward-слой перед тем, как добавить позиционные эмбеддинги, чтобы перегнать вектора из текстового пространства в латентное. И добавим еще один feedforward-слой на выходе для декодирования обратно.
![Схема Content SASRec Схема Content SASRec](https://habrastorage.org/getpro/habr/upload_files/65c/ae8/44b/65cae844b25f28b7a9b1db05fc336d5b.png)
Задача модели — по предыдущим кликам пользователя угадать следующий. Тут все стандартно: маскированный механизм внимания и кроссэнтропия как функция потерь.
Инференс тоже довольно простой: берем последнее скрытое представление последовательности кликов пользователя в качестве юзер эмбеддинга.
![Инференс Content SASRec Инференс Content SASRec](https://habrastorage.org/getpro/habr/upload_files/6f5/371/a24/6f5371a24148d59b68788dca5135bf36.png)
Дело за малым — сравнить пользователя и карточку. Есть два распространенных подхода:
![DSM-like — сложный интересный подход, но есть более простой и более эффективный — это скалярное произведение или косинусная близость DSM-like — сложный интересный подход, но есть более простой и более эффективный — это скалярное произведение или косинусная близость](https://habrastorage.org/getpro/habr/upload_files/fc5/4f6/3d8/fc54f63d896bf889dbcd25524b9af38f.png)
В своем решении мы пока остановились на простом подходе и взяли косинус.
Сборка карусели с помощью KISS
Рассмотрим игрушечный пример:
Пусть у нас есть пользователь в некотором двумерном пространстве эмбеддингов и есть кластер карточек. Самый простой вариант собрать Карусельку — жадно набрать ближайшие к пользователю карточки.
![Кластер кружочков — это статьи про котиков, кластер треугольничков — истории про инвестиции, а квадратики — билеты в кино Кластер кружочков — это статьи про котиков, кластер треугольничков — истории про инвестиции, а квадратики — билеты в кино](https://habrastorage.org/getpro/habr/upload_files/ac8/d66/1b5/ac8d661b5516eb4a57231c88b0487578.png)
Например, соберем Карусельки размером 3: берем пользователя и ищем к нему ближайшую карточку — это какая-то карточка про кино. Затем ищем следующую ближайшую — это будет карточка про инвестиции. Ну и последняя — карточка про котика. Получилась Каруселька, но не очень тематическая — сборная солянка и, наверное, решение не самое хорошее, хоть и простое
![](https://habrastorage.org/getpro/habr/upload_files/b1c/7e0/e43/b1c7e0e43883345d4744eef3f652e1f8.png)
Такое решение мы можем немного улучшить. Давайте будем жадно находить первую карточку, якорную, а к ней набирать уже похожие на нее. Рассмотрим на примере Карусели из трех элементов.
Мы снова находим самую ближайшую карточку — это кино:
![](https://habrastorage.org/getpro/habr/upload_files/75f/284/14a/75f28414a01a717c53494fd8702e4b79.png)
Но теперь мы находим ближайшие карточки не к пользователю, а к нашей опорной карточке, то есть другие киношки:
![](https://habrastorage.org/getpro/habr/upload_files/d01/262/60e/d0126260e97cc9804669611b54c6b4db.png)
Получается такая однородная Карусель полностью из кино. Вроде все хорошо, но есть один неочевидный недостаток.
Допустим, у нас рядом с кино есть еще и билеты в театр. Согласитесь, что они немного похожи, поэтому довольно вероятно могут быть рядом. И представим, что вчера пользователь посмотрел почти все карточки про кино, а так как мы не хотим показывать их второй раз — должны выкинуть их из кандидатов.
![Полупрозрачные квадратики – то кино, которое пользователь уже видел Полупрозрачные квадратики – то кино, которое пользователь уже видел](https://habrastorage.org/getpro/habr/upload_files/b9f/756/c95/b9f756c955f409e9fddc7d6d7a270efc.png)
Если мы предыдущим алгоритмом начнем собирать Карусельку, скорее всего, первой карточкой у нас будет кино, а за ней — два театра, что немного неконсистентно.
Мы можем легко это исправить — соберем несколько Каруселек. Затем посчитаем центр каждой как среднюю всех координат карточек. И тогда значение релевантности — это будет не только расстояние до якорной карточки, но и до центра кластера, чтобы, если наша Каруселька получилась неоднородной, мы не показывали ее пользователю.
На самом деле мы можем использовать не расстояние до центра кластера, а что угодно другое. Например, это может быть максимальное расстояние до карточки в карусели или среднее расстояние от пользователя до всех карточек в карусели.
Можно придумать много способов, но мы остановились на расстоянии до центра карусели.
![Вариации формулы релевантности Вариации формулы релевантности](https://habrastorage.org/getpro/habr/upload_files/c3d/e93/644/c3de93644af1537d74042dce4ca2daaf.png)
Мы можем выбирать, что нам хочется больше учитывать, за счет коэффициента альфа, который стоит перед вторыми слогами.
Такой подход позволяет нам получить хорошую Карусельку — однородную и при этом в меру релевантную пользователю.
Первые результаты. Мы успели провести полностью два A/B-теста. В первом тестировали MeanRec и якорный подход против нашего старого подхода, когда ранжировали готовые Карусели.
По итогам теста мы стазначимо увеличили CTR карточек внутри каруселей на 13%, а глубину свайпов — на 10%, но engagement rate, нашу основную метрику, мы, к сожалению, не статзначимо просадили.
Мы нашли интересный инсайт в данных, из-за которого проиграли и набили все технические шишки, чтобы последующие тесты прошли гладко.
Посмотрим на engagement rate нашей модели относительно контрольной:
![Контрольная модель — это красная пунктирная линия, а синий график — тестовая моделька Контрольная модель — это красная пунктирная линия, а синий график — тестовая моделька](https://habrastorage.org/getpro/habr/upload_files/37d/6e2/83e/37d6e283efe60692dd7847f27f24c6c1.png)
Наш тест шел две недели, и мы почти всегда выигрывали, кроме четырех дней. А эти четыре дня, конечно же, оказались выходные — две субботы и два воскресенья.
Мы посмотрели на разные типы контента и обнаружили, что по выходным людям очень нравится кино. Почти половина всех взаимодействий с Каруселью приходится именно на кино. Ну и получается, что всего лишь из-за четырех дней мы на самом деле стазначимо проиграли по engagement rate.
Поэтому мы запустили следующий тест — контентный SASRec. Все с тем же якорным методом, но добавили некоторое смещение по выходным в кино. И в этот раз мы опять же тестировали против ранжирования редакторских подборок.
Самое важное изменение — это пользовательский энкодер. Теперь вместо MeanRec у нас SASRec. Второй тест тоже длился две недели, по итогам мы смогли статзначимо нарастить главную бизнесовую метрику engagement rate на 15%.
Swipe-rate при этом вырос на 21%, а CTR — на 18%.
![Аплифт относительно контрольной группы Аплифт относительно контрольной группы](https://habrastorage.org/getpro/habr/upload_files/d69/a95/a63/d69a95a63360ea3b5330b1a54a455c30.png)
Выводы
Модель выиграла по всем фронтам. Но мы немного утянули внимание с основной ленты и на 4% стазначимо посадили engagement rate остальных элементов в ленте. То есть люди начали чаще кликать в Карусельку и выходить из ленты, а не просто оставаться в ней.
Но так как engagement rate остальных элементов — метрика побочная, при условии большого uplift в других метриках решили оставить модель на проде.
![](https://habrastorage.org/getpro/habr/upload_files/bfa/0be/ddb/bfa0beddb7687660eb083a9b00d3e085.png)
Теперь нам хочется научиться рекомендовать, что называется, вдолгую. И поэтому следующей моделькой мы решили попробовать offline RL модельку TD3+BC в качестве энкодера-пользователя в таком же степе обучения, как и SASRec со следующими деталями:
Эмбеддинги айтемов у нас все также заморожены;
State — это эмбеддинг пользователя из Content SASRec;
Action — это рекомендация айтема;
Reward — это клик-не-клик по рекомендации.
Почему T3+BC? У нас уже есть положительный опыт его применения в рекомендациях. Мы знаем, что это работает. Плюс по офлайн-метрикам мы наблюдаем прирост. Она довольно простая, поэтому решили начать с нее.
Прямо сейчас идет третий A/B-тест, где T3+BC в тестовой группе и Content SASRec в контрольной. Пока результатов нет и борьба почти на равных.
Даже если сейчас мы не победим статзначимо, это может говорить о том, что в будущем мы получим ту самую долгосрочную выгоду, если оставим T3+BC.