Привет, Хабр! Это Сергей Евстафьев и Дана Злочевская из команды ранжирования и поиска Lamoda Tech. Наша задача — помочь пользователю найти то, что ему нужно, и не потеряться в море доступных вариантов.
В каталоге Lamoda в наличии более полумиллиона модных товаров, однако 95% пользователей не просматривают больше первых 120 карточек. Поэтому в первую очередь важно показывать только самую релевантную подборку, для этого мы развиваем персональное ранжирование каталога. С его помощью каждый пользователь видит свою уникальную выдачу, которая собирается на основе его поведения, популярности товаров и других параметров.
Организовать такое ранжирование можно разными способами. Мы развивались поэтапно: в течение нескольких лет переходили от эвристик к внедрению ML, улучшая пайплайн ранжирования.
В этой статье поподробнее раскроем наш подход. Итак, нам предстоит обсудить:
Из каких этапов состоит персональное ранжирование каталога.
Как работает первый этап персонализации — отбор кандидатов.
Что из себя представляет второй этап — онлайн переранжирование топа выдачи.
Ранжирование каталога — один из самых крупных и важных продуктов для Lamoda.
Оно пережило три этапа улучшений:
В начале все ранжирование было основано на статистике и эвристических правилах. Мы делили пользователей на сегменты, в каждом из которых заранее рассчитывали свое оптимальное ранжирование товаров в каталоге на базе популярности товаров.
В 2020 году внедрили ML в ранжирование для сегментов пользователей. Теперь вероятность добавления в корзину предсказывается ML-моделью для каждого товара и сегмента.
В 2023 году внедрили ML-персонализацию для каждого пользователя, которая переранжирует топ товаров в выдаче в момент запроса.
Про первые два этапа мы рассказывали в предыдущей статье. Сейчас же хотим поделиться тем, какой путь мы прошли в персонализации каталога для каждого, с какими трудностями столкнулись и как планируем развиваться дальше.
Итак, у нас есть ранжирование всего каталога для крупных сегментов пользователей и оно работает по следующей схеме:
Но как переходить к персонализации?
Основная сложность в том, что персонализация целого каталога под каждого пользователя — ресурсоемкая задача: на Lamoda больше 600 000 товаров в наличии и их количество растет вместе с ростом бизнеса. Поэтому мы начали думать, как ограничить список товаров, которые будут ранжироваться персонально.
Конечно, мы не первые, кто сталкивается с такой задачей. Зарекомендовавший себя подход в индустрии, который уже стал классическим для рекомендательных систем, состоит из двух этапов:
Отбор кандидатов для ранжирования из разных источников. На этом этапе мы формируем список из нескольких сотен товаров, которые являются самыми релевантными для пользователя.
Переранжирование. Чтобы ранжировать эти товары оптимальным образом, мы применяем тяжелые модели с полным набором фичей по товару и пользователю — и отдаем итоговую сортировку.
Взяв эту схему за основу, нам предстояло реализовать ее с использованием имеющихся у нас инструментов.
ElasticSearch — это эффективное решение для запросов в большие поисковые индексы. Наш сервис каталога активно использует его как основной поисковый и ранжирующий движок, в нем же хранится информация по товарам и их атрибутам. Также в нем хранятся скоры релевантности товаров, рассчитанные для сегментов пользователей после первого этапа ранжирования.
Поэтому мы решили попробовать использовать этот инструмент, чтобы сразу учитывать персональные предпочтения пользователей. Таким образом мы можем объединить сегментное ранжирование и отбор кандидатов в один запрос в Elastic.
Для реализации этой идеи мы воспользовались функционалом function score в ElasticSearch, который позволяет добавить функцию скора/релевантности для товара, основываясь на его атрибутах и запросе пользователя. Эта функция может быть сложной, учитывающей разные атрибуты товара с разными весами. Вот как можно встроить эту функцию в запрос, указав в качестве весов релевантность конкретного атрибута пользователя товару:
{
"query": {
"bool": {
"filter": [
... // Простые фильтры в каталоге
],
"must": [
{
"function_score": {
"functions": [
// Персональные предпочтения
{
"filter": { "term": { "brand": "Zarina" } },
"weight": user_weight_brand_Zarina
},
{
"filter": { "term": { "color": "black" } },
"weight": user_weight_color_black
}
],
"score_mode": "sum",
"boost_mode": "sum",
"query": {
"function_score": {
"boost_mode": "replace",
"functions": [
{
"script_score": { ... } // Популярность + релевантность из сегментного ранжирования
}
],
"query": { ... } // Фильтрующий поисковый запрос
}
}
}
}
]
}
}
}
Идея в том, чтобы добавить к скору релевантности из сегментного ранжирования скор, отражающий предпочтения пользователя к атрибутам товара. Для этого можно использовать в качестве значений функции скора основные характеристики товаров, а в качестве весов — отношение пользователя к этим атрибутам с учетом их важности. Таким образом мы будем добавлять вес (поднимать выше в выдаче) товары, атрибуты которых «нравятся» пользователю.
Для товаров в разных категориях мы знаем более 30 атрибутов: к какому бренду принадлежит товар, его цвет, размер, наличие рисунка, и так далее. Также мы знаем, с какими товарами взаимодействовал каждый пользователь: нажимал на карточку товара, добавлял в корзину или избранное, покупал. У каждого такого взаимодействия будет свой вес. Чуть больший коэффициент — у заказов, поменьше — у добавлений в корзину и в избранное.
С помощью этих данных мы оцениваем предпочтения пользователя по атрибутам. Например, как часто он добавлял в корзину бренд Lime, выкупал вещи размера XS или сколько раз добавлял в избранное вещи розового цвета. На выходе мы получаем вектора предпочтений пользователя к значениям атрибутов (например, к разным брендам, цветам и тд), рассчитанные для разных категорий.
У себя эти признаки пользователя мы называем фасетными весами или просто фасетами.
Хорошо, для пользователей мы рассчитали предпочтения. Как теперь модифицировать ранжирующий score ElasticSearch, чтобы учесть предпочтения органически вместе с релевантностью из первого этапа ранжирования?
Для этого необходимо понять, с какими весами учитывать склонность пользователя к каждому атрибуту, потому что они не равнозначны. Ведь любовь пользователя к бренду может оказаться важнее предпочтений по длине рукавов.
Чтобы понять с каким весом учитывать разные атрибуты - обучим логистическую регрессию предсказывать вероятность добавления пользователем товара в корзину на фичах его склонности к атрибутам товара. А чтобы учесть скор-релевантность из первого этапа ранжирования, также добавим его как фичу. Модели будем учить отдельно для одежды, обуви, аксессуаров товаров для дома и косметики, чтобы, к примеру, вес бренда был разный у бутс abibas и футболки abibas.
Обучив таким образом модель, мы получим коэффициенты при атрибутах, которые будут отражать их важность. Дальше мы разделим каждый коэффициент на модуль коэффициента при фиче «скора-релевантности» из первого этапа ранжирования (sfPos на картинке ниже) и получим финальные коэффициенты. Эта нормировка нужна, чтобы органически учесть коэффициенты при атрибутах, вместе со скором релевантности из первого этапа ранжирования в момент работы запроса. При этом отрицательные коэффициенты мы не учитываем, просто обнуляя их, чтобы давать товарам только положительный дополнительный буст.
Таким образом мы получаем коэффициенты, на которые будем домножать персональные фасетные веса в function score, чтобы дать дополнительный буст товарам, релевантным пользователю.
Давайте посмотрим, как теперь будет выглядеть function_score в запросе к ElasticSearch с весами, полученными в примере выше. Для пользователя, отправившего запрос, мы перемножаем его предпочтения к брендам, размерам и цветам с нормированными коэффициентами из модели логистической регрессии (w_brand = 0.313, w_size = 0.137, w_color = 0.175).
{
"function_score": {
"boost_mode": "sum",
"score_mode": "sum",
"functions": [
{
"filter": { "term": { "brand": "Zarina" } },
"weight": 0.313 * 3
},
{
"filter": { "term": { "brand": "Mango" } },
"weight": 0.313 * 2.5
},
{
"filter": { "term": { "brand": "Abibas" } },
"weight": 0.313 * 0.1
},
{
"filter": { "term": { "size": "L" } },
"weight": 0.137 * 1.5
},
{
"filter": { "term": { "size": "M" } },
"weight": 0.137 * 3
},
{
"filter": { "term": { "color": "black" } },
"weight": 0.175 * 5
},
{
"filter": { "term": { "color": "pink" } },
"weight": 0.175 * 0.1
}
]
}
}
Предпочтения пользователя по атрибутам товаров рассчитываются ежедневно. При «холодном старте» будет использоваться только сегментное ранжирование.
Под капотом эластик просуммирует полученные значения по всем атрибутам со скором от сегментного ранжирования и таким образом мы получим персональный буст товаров.
После добавления буста персональных кандидатов, выдачи у пользователей кардинальным образом изменились. Посмотрим на примере девушки, которая предпочитает вещи определенных брендов и цветов.
На первой картинке — результат выдачи сегментного ранжирования, на второй — сегментного ранжирования с фасетными весами. По фасетным весам и выдаче видно, что девушка предпочитает одежду черного цвета, длинные юбки и платья, а также бренд Zarina. Без фасетов большинство из этих вещей не попало бы даже в топ-500.
Результат нам очень понравился! Мы сразу покатили это в А/В-тестирование.
Однако желаемых результатов мы не добились: поведенческие кликовые метрики выросли, но целевую метрику нам прокрасить не удалось — роста покупок и заказов не произошло. Мы немного расстроились, но главные ожидания были от второго этапа, тест которого был еще впереди.
Первой идеей реализации модели второго уровня для переранжирования было подключение LTR-плагина в ElasticSearch: в нем можно использовать в том числе градиентный бустинг. Так все наше ранжирование жило бы внутри эластика: сначала сегментное ранжирование и буст персональных кандидатов из первого этапа, а затем переранжирование небольшого топа. Однако, у этого решения есть два минуса. Во-первых, для его грамотного использования необходимо будет работать с нетипичным для нас Java стеком. А во-вторых, мы всегда будем ограничены реализованным функционалом плагина.
Поэтому мы решили пойти по пути создания отдельного сервиса на Golang — быстром, компилируемом языке, в котором у нас есть опыт и ресурс разработки.
Его задача — запускать регулярно переобучаемую модель на фичах, собранных из заданных баз данных, в тот момент, когда пользователь запрашивает страницу каталога. И поскольку сервис независим, мы никак не ограничены в способах формирования фичей и запуска модели.
Для обучения модели мы используем датасет из пользовательских сессий, соответствующих просмотру товаров в конкретной категории каталога или поиске. К каждому просмотру мы атрибуцируем событие добавления пользователем товара в корзину, таким образом, в полученной группе взаимодействий негативными примерами являются просмотры товара без целевого события, а позитивными — с ним.
Чтобы качественно обучить модель для всех видов устройств с учетом неравномерного трафика, мы сэмплируем фиксированное количество групп из одного дня логов по каждой из платформ (мобильный сайт, desktop, Android, iOS).
В качестве фичей мы используем товарные признаки из модели сегментного ранжирования, а также добавляем фасетные признаки пользователя, счетчики добавления в корзину, просмотров и заказов по временным окнам.
На наших данных наилучшее качество показала модель CatBoost c лоссом YetiRank. Интересно, что по экспериментам, негативное семплирование только 20% показов без целевого действия никак не ухудшает качество модели, зато позволяет использовать больше дней логов, что дает лучшее качество. Благодаря этому, сейчас мы ежедневно обучаемся на 17 последних днях.
Основной нашей метрикой для оффлайн-оценки является NDCG@60, но мы также мониторим ряд качественных метрик – разнообразие выдачи, цена топа и т.д.
Инфраструктура полученного решения выглядит следующим образом. Регулярные оффлайн-части пайплайна – сбор фичей, сбор трейн-датасета и обучение моделей происходят на нашем hadoop-кластере ежедневно с использованием Spark. После этого модель загружается в S3, мета информация по ней в etcd, а обновленные фичи по пользователю и товарам в Aerospike – key-value базу с быстрым доступом в онлайне. Из этих источников go-сервис подгружает саму модель и собирает фичи для конкретного запроса.
Давайте теперь резюмируем и пробежимся по всему пайплайну персонального ранжирования на проде. Приходит пользователь, что-то выбирает в каталоге или ищет в поиске, в это время на бэкенде формируется запрос в Service Catalog. Этот сервис обогащает запрос контекстом пользователя и отправляет его в Elastic, задача которого — отфильтровать подходящие запросу товары и сделать фасетное ранжирование.
После этого топ 300 кандидатов по итоговому скору Elastic отправляется в сервис переранжирования Service Ranking, где для них и пользователя подтягиваются все необходимые фичи для применения модели. И далее, наконец, происходит инференс CatBoost-модели, которая возвращает итоговое ранжирование в Service Catalog, а он в свою очередь отдает его пользователю.
И немного технических деталей: для сервиса Service Ranking мы стараемся держать SLA 250 мс, 50 перцентиль времени ответа составляет 50мс. Время ответа критически важно для качества вашего пайплайна в онлайне, поскольку оно напрямую влияет на бизнес-метрики.
Давайте теперь покопаемся в обученной модели, изучим полученную важность фичей. В топ выбились предпочтения пользователей к брендам, размерам и цветам, далее признаки конверсионности товаров, их популярности, цена, рейтинг и т. д. относительно равномерно распределяются по топу.
Первым экспериментом был А/В-тест по добавлению сервиса переранжирования для 150 кандидатов. Результат этого теста был очень успешным: прокрасилась вся воронка. То есть пользователи начали чаще кликать, чаще добавлять в корзину, чаще делать покупки. Мы сделали так, что результат понравился и пользователям, и бизнесу. По метрикам мы получили +2% NMV и +1.7% к конверсии в выкуп.
Следующим успешным запуском стал А/В-тест, в котором мы расширили работу сервиса переранжирования на поисковые запросы, а также увеличили число кандидатов до 300. Также мы начали ежедневно переобучать модель. Суммарно эти улучшения дали нам дополнительно +1.3% NMV.
Давайте теперь вернемся к той самой девушке, любительнице черного, длинных юбок и Zarina. Посмотрим, как изменилась ее выдача, когда мы добавили модель второго уровня:
Стиль выдачи сохранился и даже больше ушел в черное. Бренда Zarina стало чуть меньше, свободных штанов чуть больше. Визуально это не так заметно, как при применении только фасетов на первом этапе. Но бизнес-метрики при этом возросли существенно!
Подведем итог:
Персонализация ранжирования — работает и увеличивает
как поведенческие, так и бизнес-метрики.
Отбор кандидатов через Elasticsearch дает достаточную гибкость и фильтрацию «из коробки».
Отдельный сервис на Golang с CatBoost моделью второго уровня — рекомендуем! Это позволяет гибко управлять кандидатами, которые идут на вход модели, без проблем улучшать и тестировать модель в офлайне и делать быстрый инференс в онлайне.
Конечно, у нас много планов по дальнейшему развитию, вот небольшой шортлист:
Научиться добавлять кандидатов в топ из других источников/моделей
На данный момент единственным источником кандидатов для модели переранжирования является сегментное ранжирование с фасетной персонализацией. Мы хотим расширить количество источников кандидатов для модели второго уровня, чтобы обеспечить большее разнообразие и релевантность товаров в топе.
Увеличить количество кандидатов
Сейчас на втором этапе мы можем переранжировать только 300 кандидатов. Безусловно, хочется больше, поэтому предстоит масштабировать текущий сервис.
Отказаться от временного отставания
В текущей реаализации персонализация происходит с отставанием в день. Мы пока что не умеем в онлайне подхватывать действия пользователей, но двигаемся в эту сторону, добавляя в модель горячие фичи по пользователю.
Избавиться от сдвига в данных между обучением и применением модели
Сейчас фичи для обучения модели собираются на исторических данных кликстрима, а применяется модель на фичах, которые ежедневно заливаются в БД или берутся из прод-систем (например – доступность стока). Поэтому мы работаем над избавлением от сдвига между фичами на инференсе и при обучении, который так или иначе происходит. Мы хотим быть уверенными, чтобы фичи, которые мы используем для построения train dataset, были прям в точности те же самые, что и в момент применения модели. Для этого планируем логировать фичи в момент инференса модели и переобучать модель именно на них.
Как видите, впереди еще много интересных задач, обязательно будем делиться результатами здесь и в Телеграм-канале Lamoda Tech. Если вам понравилось то, что мы делаем, и вы хотите стать частью новых решений — сейчас есть возможность присоединиться к нашей команде ранжирования и поиска. Приходите в личку: Сергей, Дана.
Спасибо что прочитали, stay tuned!