Привет, Хабр! Меня зовут Павел Дудукин, руководитель Data Science команды в Центре развития финансовых технологий Россельхозбанка. Сегодня мы хотим продолжить цикл статей статей про решенные нами Data Science задачи и рассказать о построении и внедрении рекомендательной системы в одну из наших платформ по продаже фермерских продуктов “Свое Родное”. А узнать из каких этапов, с какими особенностями мы столкнулись при разработке решения и как мы использовали кулинарные рецепты для наших рекомендаций можно узнать ниже. Как часто при поиске нужной информации мы слышим знаменитую фразу «Google/Yandex в помощь». Стремительный рост объема информации и количества пользователей Интернета создали проблему информационной перегрузки, варианты выбора удивляют своим многообразием. Поиск информации является одной из наиболее распространенных и одновременно наиболее сложных задач, с которыми приходится сталкиваться в Интернете любому пользователю. Не исключением стали и пользователи нашей платформы “Своё Родное”. Количество представленных фермеров и товаров, предлагаемых фермерами, растет (за последний год кол-во продуктов увеличилось почти в 3 раза - с 30 до 80 тысяч наименований). Мы не могли остаться в стороне, и нашли решение – необходимо разработать рекомендательную систему, которая послужит компасом в океане фермерских продуктов. Наша рекомендательная система на платформе “Свое Родное” состоит из 3 этапов о которых далее будет рассказано более детально: На основе ранжирования/рейтингов. На основе контента (item-based). На основе пользовательских предпочтений (user-based). Стоит сразу отметить, что специфика платформы “Свое Родное” подразумевает геозависимую выдачу фермеров и их товаров. Это означает, что условный посетитель из Москвы увидит один набор фермеров, а пользователь из Владивостока - другой. При этом фермеры могут иметь оффлайн точку продаж в том или ином городе или подключить доставку продуктов через СДЭК и доставлять их по всей стране, так они будут доступны везде, где поблизости есть пункты выдачи заказов СДЭК. Из-за этой специфики любая выдача продуктов, в том числе и рекомендованных системой должна быть геозависима. На первый этап мы выбрали довольно несложную логику, чтобы, во-первых, построить API (о ней мы расскажем чуть ниже, когда речь пойдет о технике), которую в дальнейшем будет проще развивать, а во-вторых, чтобы выстроить и протестировать интеграцию решения с самой платформой “Свое Родное”. На этом этапе мы хотим рекомендовать “популярные” товары и фермеров. Под ”популярностью” здесь мы понимаем некоторую логику ранжирования. Топ-N товаров/фермеров, отсортированных по этой логике показываются всем посетителям и отличаются только по геолокации. Мы знаем, что предпочтения наших пользователей от региона к региону меняются, поэтому мы решили обратить внимание пользователей на те товары, которые чаще всего покупались в его регионе. У такого подхода есть социальная теория, согласно которой, люди склонны доверять мнению большинства. Для этого нами была определена следующая логика рекомендаций: Выделяем топ-50 “популярных” товаров. Популярность здесь определяем количеством покупок того или иного товара за последнее время. По умолчанию стоит срок в 3 месяца и сделано это для того, чтобы список популярных товаров не был статичным, а в топ выходили товары, наиболее продаваемые относительно недавно. Если вдруг хотим, чтобы список таких товаров менялся более часто, то рассматриваемый период времени надо уменьшать. Равномерно распределяем товары по категориям. Это сделано для того, чтобы среди 50 рекомендуемых товаров было некоторое разнообразие. К примеру, у нас на платформе представлено очень много сыров и их часто покупают. Есть риск получить топ-50 товаров именно из этой категории и, таким образом, проигнорировать другие, не менее интересные категории.	 Случайно перемешиваем. На фронте результат наших рекомендаций мы планируем вывести в блок в разделе “Продукты” в виде карусели. Соответственно в карусели все 50 продуктов не поместятся и мы специально перемешиваем рекомендованные товары для того, чтобы товары одной и той же категории не стояли рядом и не вызывали у посетителя впечатление, что популярными у нас являются только лишь товары этой единственной категории. В желании помочь пользователям в регионах, мы пошли дальше и составили подборку популярных товаров для каждой категории. Когда у пользователя нет возможности увидеть и попробовать товар вживую, велика вероятность, что он будет ориентироваться на мнение других покупателей. Топ-10 товаров популярных за последнее время (те же 3 месяца по умолчанию) по числу заказов. Показываем только в верхнеуровневых категориях товаров. Для удобства поиска мы добавили фильтр, который сортирует товары по частоте покупок. Пользователю целесообразно показывать самые востребованные товары фермера Ранжируем товары фермера от самых популярных за последнее время до менее популярных. Здесь популярность также обусловлена кол-вом заказов того или иного товара, но уже у конкретного фермера. На фронте это будет выглядеть как дополнительный фильтр, ранжирующий товары фермера по популярности. Мы помогаем и фермерам, обращая вниманием наших пользователей на фермеров, у которых чаще всего покупают. Выбираем топ-20 фермеров по двум критериям: количество исполненных успешно заказов и среднему времени обработки заказа.  Выше был использован термин “за последнее время”. Как я писал ранее, мы используем интервал в 3 месяца, отсчитывая назад от текущей даты. Причем данные в модели обновляются ежедневно по расписанию (но можно обновлять чаще или даже по запросу), тем самым актуализируя продажи тех или иных товары, да и сам продуктовый каталог. Для реализации рекомендательной системы и ее интеграции с платформой нам был необходим некий REST API сервис, получающий на вход json определенного формата и выдающий на выходе некоторый другой json. Для этого нами был выбран ASGI-фреймворк FastAPI со встроенной валидацией данных (Pydantic). Более того, FastAPI имеет встроенную поддержку swagger, что оказалось очень удобно в нашей случае для тестирования сервиса. Пообщавшись с back-end разработчиками платформы “Свое Родное” мы определили универсальный формат входящих запросов к сервису. Вот, к примеру, как он выглядит для рекомендаций в рамках первого этапа, описанного выше: Формат исходящих данных: Такой формат, по нашему мнению, довольно легко модифицировать под следующие этапы, например, если хотим рекомендовать похожие товары к просматриваемому в данный момент товару, то во входящий запрос достаточно добавить атрибут “productId”. На этом этапе хотим рекомендовать товары либо похожие на те, которые посетитель просматривает в текущий момент, либо такие товары, которые можно приобрести, чтобы приготовить блюдо по некоторому рецепту (сопутствующие).  Почему именно такую логику мы выбрали для сопутствующих товаров?  Сопутствующие товары обычно представляют собой кросс-продажу товаров в дополнение к основному, но у нас на площадке есть рецепты, так почему бы нам не рекомендовать продукты в соответствие с рецептами, тем самым еще и пополняя их базу. Тем более, если решать эту задачу классическими методами market basket анализа, то буквально получим, что в основном (не случайным образом) покупают огурцы вместе с помидорами, мы же, очевидно, хотим рекомендовать что-то более интересное. Чтобы дать возможность пользователю более подробно изучить продуктовую линейку без необходимости вводить что-то каждый раз в строку поиска, мы собрали подборку похожих товаров. Для построения таких рекомендации мы решили использовать лишь названия и описания товаров, а точнее их векторное представление. Этого оказалось вполне достаточно, чтобы выдавать очень похожие товары. Чтобы построить такие рекомендации нам нужны две вещи:  эмбеддинги товаров из каталога; матрица косинусных расстояний. Для получения эмбеддингов товаров мы решили не брать какие-либо предобученные bert-like модели, а просто обучить с нуля fasttext методом train_unsupervised на предварительно очищенных текстах из продуктового каталога. Причем при обучении мы ограничили параметр bucket_size c 2_000_000 до 50_000, что позволило без потери качества модели уменьшить ее размер с почти 800Мб до ~30Мб. На момент написания статьи в каталоге было порядка 80000 товаров, чего более чем достаточно для обучения модели. Чтобы выдавать похожие товары, нужно посчитать косинусное расстояние между просматриваемым товаром и всеми остальными из каталога (пусть даже заранее отфильтрованного с учетом геолокации посетителя и доступных в регионе товаров). Хранить постоянно расширяющуюся матрицу косинусных расстояний размером 80к х 80к, говорят, большой грех, и мы сначала хотели использовать sklearn’овский pairwise_distances_chunked, но в итоге решили использовать поисковый индекс, созданный с помощью библиотеки nmslib. Вот так, к примеру, можно создать индекс на основе данных (эмбеддингов) в data: А вот так можно найти 5 похожих (+ сам продукт) товаров: Пример результата работы такого алгоритма ниже: Мы обратили внимание, что пользователи нашей платформы интересуются рецептами. Пользователь, по нашему мнению, не должен тратить много времени на поиск отдельных продуктов для приготовления блюда, поэтому мы решили сформировать список необходимых продуктов для приготовления того или иного рецепта, тем самым популяризируя раздел “Рецепты” и увеличивая кросс-продажи продуктов. Здесь логика рекомендаций выглядит примерно так (не пугайтесь, сейчас все объясним :)): Давайте разберемся по шагам, что же тут происходит. Напомним, что наша цель - рекомендовать сопутствующие (относительно некоторого списка рецептов) товары к просматриваемому посетителем нашей платформы товару в текущий момент.  Допустим посетитель смотрит карточку товара “Масло подсолнечное на дубовом прессе”. Этому продукту находим наиболее подходящий ингредиент - “Масло подсолнечное”. Далее в базе рецептов находим рецепты с этим ингредиентом. На схеме выше это “Омлет с картофелем и беконом”, а также “Шоколадный торт ‘Депрессия’”. Ранжируем рецепты некоторым образом (об этом чуть позже) и находим наиболее подходящий. Допустим, это торт. Смотрим на ингредиенты этого рецепта. В частности это “Мука пшеничная” и “Уксус яблочный” и, возможно еще какие-то, которые мы не стали отображать на схеме. И, наконец, рекомендуем посетителю продукты, соответствующие этим ингредиентам - “Мука пшеничная цельнозерновая” и “Уксус яблочный натуральный 250 мл”. В такой, казалось бы, несложной логике есть несколько интересных моментов, например: Где взять достаточно большую базу рецептов с ингредиентами, чтобы она покрыла бОльшую часть продуктов платформы? Как подобрать подходящий ингредиент к конкретному продукту? Какой рейтинг ввести, чтобы “правильным” образом ранжировать рецепты и выбрать подходящий? Датасет для рекомендаций товаров по рецептам мы собирали из нескольких источников. Во-первых, это конечно же данные платформы “Свое Родное”, а во-вторых мы дополнили ее данными рецептов из открытых источников. В настоящий момент у нас в базе около 40000 рецептов. На них и строили алгоритм. Для того, чтобы сматчить продукты с ингредиентами мы сперва выделили список всех ингредиентов из базы рецептов, а дальше, построив эмбеддинги продуктов и ингредиентов с помощью предобученного fasttext’а, воспользовались косинусной близостью между наименованием продукта и списком ингредиентов. Таким образом, находим самый близкий ингредиент. Надо заметить, что для некоторых продуктов может и не быть подходящих ингредиентов из-за недостаточной близости. Что касается рейтинга, то нам в первую очередь интересно рекомендовать рецепты с нашей платформы, нежели какой-то другой рецепт из внешних источников. Поэтому мы ввели некоторый рейтинг, для ранжирования рецептов, учитывающий два фактора:  Приоритизацию рецептов с различных площадок. Для рецептов с нашей платформы рейтинг по умолчанию равен 1, а для всех остальных рейтинг определяется как доля положительных отзывов на рецепт из соответствующего источника.  Релевантность рецепта. Мы могли пойти легким путем и случайным образом выбирать один из рецептов с одинаковым рейтингом. Тем не менее, мы решили придумать более надежный способ сортировки: для каждого рецепта мы считаем отношение количества доступных в регионе пользователя ингредиентов к общему числу ингредиентов, используемых в рецепте. То есть при формировании рейтинга мы будем учитывать, сможет ли клиент в итоге приготовить блюдо. А также в топ, более вероятно, выйдут простые в приготовлении рецепты. Таким образом, итоговый рейтинг рецепта формируем как сумма первоначального рейтинга и рейтинга релевантности. Теперь мы имеем лучший по рейтингу рецепт. Это значит, что он: вероятнее всего, с нашей платформы; в нем большая доля ингредиентов, доступных в регионе посетителя; к его ингредиентам мы ранее нашли соответствующие продукты.  Соответственно эти продукты и попадут в нашу рекомендацию. Также планируем указывать ссылку на сам рецепт, если он доступен на нашей платформе. Разработанная нами система рекомендаций показывает пользователю персонализированные предложения, тем самым давая понять, что его ценят и о нем заботятся. На данном этапе мы решили реализовать несколько сценариев персональных рекомендаций: на основе просмотров и на основе заказов и после тестирования выбрать наиболее подходящий вариант. В любом из этих случаев рекомендации будут построены на основе предыдущих сессий посетителя. В одной из прошлых статей мы рассказывали про инструменты, с помощью которых мы видим действия посетителя на фронте (логи посещений веб-страниц наших платформ). Используя эти данные мы смогли выделить пользовательские сессии, отделив их друг от друга по времени отсутствия активности на платформе).  Строить такую модель мы решили на датасете последовательностей просмотров товаров в рамках различных пользовательских сессий. Соответственно, идея под такой логикой была следующая: просматривая один товар за товаром посетитель площадки как бы “связывает” товары между собой, исходя из какой-то логики, известной лишь ему одному. Это могут быть как похожие товары, так и товары каким-то образом связанные друг с другом, например, товары какого-то определенного фермера. В итоге наш набор данных для обучения модели выглядит примерно так: После обучения на таком датасете Word2vec модели мы можем выдавать "похожие" к просматриваемому пользователем товары. Схематически это выглядит следующим образом: Результат работы модели представлен ниже (в порядке “близости” продуктов относительно целевого на основе цепочек просмотров товаров): В этой категории рекомендаций мы будем рекомендовать те товары, которые другие пользователи часто приобретали вместе. В отличие от рекомендаций на основе просмотров, здесь мы прибегнем к часто используемой коллаборативной фильтрации. Но обо всем по порядку. Стандартным образом мы собрали датасет с заказами клиентов за все время существования площадки и на его основе составили матрицу заказов, в которой каждая строка показывает количество заказов того или иного продукта клиентами. Кажется, его видели все, кто хоть раз строил модели коллаборативной фильтрации. Пользователь/Продукт Говядина Клубника Хлеб Петя 3 0 1 Ваня 0 0 2 Саша 1 2 1 Здесь, к примеру, Петя трижды заказывал Говядину, один раз Хлеб и ни разу не заказал Клубнику. Из полученной матрицы мы легко можем получить пользователей, когда-либо купивших продукт, который сейчас просматривается на площадке и рекомендовать к нему топ-5 наиболее популярных товаров по количеству заказов среди этих пользователей. В нашем примере для товара Хлеб самый покупаемый товар среди тех, кто покупал хлеб - Говядина (4 заказа), далее Клубника (2 заказа).  Все пользователи нашей платформы делятся на просто посетителей и клиентов (зарегистрировавшихся на платформе и имеющих возможность сделать заказ), которые при регистрации из существенных данных оставляют разве что свое имя. Рекомендации так или иначе мы хотим делать на всех, то есть на любого посетителя платформы. Так становится очевидно, что проблема “холодного старта” не обойдет нас стороной. Напомню, что её смысл в том что user-based модели не могут дать рекомендации пользователю без какой-либо истории действий. В таком случае вместо “модельных” предсказаний мы планируем показывать ему некоторые популярные на платформе товары (см. Этап 1) и в то же время накапливать историю его действий. Рекомендательные системы в первую очередь делают продукт удобнее для пользователя, что, в свою очередь, помогает маркетплейсам увеличивать срок жизни, частоту покупок и средний чек пользователя. Но эти метрики меняются медленно и зависят от многих других факторов, поэтому их не получится использовать для быстрой оценки качества рекомендаций.  Поэтому на этапе тестирования мы планируем сконцентрироваться на мониторинге того, насколько активно пользователи взаимодействуют с блоком рекомендаций (как часто кликают по предложенным товарам) и как это меняет их поведение на коротком промежутке времени (сколько страниц просматривают, как много времени проводят на площадке, возвращаются ли, в какие действия конвертируются).  Для того, чтобы исключить другие факторы, влияющие на эти метрики, запускать рекомендации планируем в рамках эксперимента с контрольной и тестовой группой. Помимо этого, мы планируем разделить посетителей на группы в зависимости от того, зачем им может быть нужна рекомендательная система, так как это влияет на выбор ключевых показателей.  Например: Новые посетители (первый визит на площадку): их мы хотим познакомить с продуктом, заинтересовать. Поэтому в этой группе планируем следить в первую очередь за глубиной просмотра, длительностью сессии и возвращаемостью; Активные покупатели (регулярно заказывают продукты): эти посетители уже хорошо ориентируются на площадке, но чаще всего заказывают товары из ограниченного ассортимента, и рекомендации помогают им подбирать что-то новое. Поэтому в этой группе важна конверсия в просмотр и покупку новых товаров. Кроме того, для контроля качества рекомендаций мы планируем настроить сбор метрики mean average precision at K (map@k), где K - количество элементов в блоке рекомендаций, а “релевантными” элементами считаются те, по которым пользователь кликнул.  Data Science Lead