Сегодня я расскажу, про базовое решение задачи рекомендации текстового контента на конкретном примере — ленте одной российской социальной сети. Посмотрим, что под капотом у сервиса рекомендаций, какие данные нужны для построения векторов пользователей, как ранжируются посты и к какой архитектуре рекомендательной системы мы пришли спустя несколько месяцев экспериментов. Казалось бы: Facebook, Instagram и VK набрали миллионы юзеров безо всяких рекомендаций. Может быть, они и не нужны? Пользователь вполне может самостоятельно собрать себе ленту, оставив в ней только интересные источники. В принципе, для юзера это путь, но не для новой соцсети. Перечисленные гиганты включили умные ленты не со скуки и не ради тренда. Просто рекомендации работают. Люди дольше листают и больше лайкают персональные подборки контента. Мы имели дело с сетапом, у котором могут быть рекомендации трех типов: текстового контента,  картинок, видео. В этом проекте мы начали с простого — текстов. Заказчик не уточнял, какие технологии желает видеть, так что мы были вольны выбирать. Нейросети подходят для многих задач, в том числе и для рекомендательных систем, но это не всегда оптимальное решение. Они тяжелые и для работы с текстовыми постами попросту избыточны. Возможно, мы вернемся к нейронкам, когда (и если) будем делать рекомендации фото и видео. Ну а пока мы стали решать классическую задачу машинного обучения классическими эмэлевскими способами — благо, есть библиотеки, сущности и алгоритмы, заточенные именно под рекомендации. Нужно было только подобрать подходящие. Мы рассмотрели много альтернатив: от базового амазонского алгоритма коллаборативной фильтрации до навороченного отечественного CatBoost. Было неясно, хватит ли у заказчика мощностей на что-то серьезное, так что в итоге мы остановились на градиентном бустинге попроще — взяли библиотеку LightFM. Этот алгоритм хорошо работает с тем, чего раньше не встречал. Например, коллаборативной фильтрации нужно время, чтобы собрать информацию про юзера или пост и начать их ранжировать. LightFM обходит эту проблему: он ранжирует новых юзеров и посты, определяя по ключевым фичам, на какие знакомые объекты похожи новички. Впрочем, если фичи не настроить, LightFM будет работать аналогично обычной коллаборативной фильтрации. Он быстрый. По крайней мере с тем числом юзеров, с которым мы работали. Это важно для пользователей: никто не хочет ждать загрузки ленты рекомендаций дольше пары секунд. LightFM можно подсунуть в такое место пайплайна, чтобы он быстро давал результаты для определенной группы пользователей. Это позволило нам разделить обработку данных на потоки. Сейчас объясню, как это работает, но сперва щепотка теории. Суть работы ленты рекомендаций проста:  собрать предпочтения пользователя;  установить, насколько им соответствует каждая единица контента; отобрать и показать контент, наиболее подходящий под предпочтения. Получается, что рекомендательная система стоит на трех китах: пользователи; контент (в нашем случае текстовые посты);  users post interaction — взаимодействия юзеров с контентом. Если на пальцах, то рекомендательная система работает по принципу ранжирования interaction пользователя и поста. Алгоритмы показывают то, с чем пользователи чаще и охотнее взаимодействуют. А если не на пальцах — то масса нюансов связана с тем, какие данные о пользователях и взаимодействиях собирать и обрабатывать. Особенно на холодном старте, когда человек только зашел в соцсеть, а нам уже надо показать интересные посты, чтобы он их полайкал, прокомментировал и в дальнейшем получил подходящие рекомендации. Мы знали о каждом пользователе не так уж много: пол, возраст, географическое положение и какой транспорт водит. Не мудрствуя лукаво, мы использовали эти фичи, чтобы сформировать вектор пользователя и сделать предположения о его интересах на старте, опираясь на данные о похожих пользователях, которые уже давно зарегистрированы в соцсети. Сперва это выглядит так: LightFM заранее определяет признаки постов и вектора, что называется, «скорит посты». Разумеется, на этом этапе выяснилось много неожиданного. Во-первых, быстро понимаешь, что когда у вас мало данных о пользователе, лишние обобщения ухудшают рекомендации. Можно подумать, «Щас подкрутим, станет лучше», — ан нет, оказалось, не надо за людей додумывать, что им нравится. На холодном старте при достаточном наборе данных правильнее всего справляется обычная коллаборативная фильтрация. Во-вторых, неплохо работает самая базовая социология. Например, возраст нельзя использовать по принципу «объединим сорокалетних с сорокалетними». Иначе получится по сотне фич на каждого юзера: на возрасте 40 будет стоять единица, на остальных возрастах — нули. Окей, меньше сотни, люди обычно столько не живут, да и не с младенчества сидят в соцсетях. Но все равно: слишком много, чтобы построить матрицы.  Следовательно, людей нужно делить на возрастные группы. Мы взяли всем известные поколения: бумеры, джениксеры, миллениалы и зумеры. В-третьих, нас слегка подколола география. Сперва хотели рубануть с плеча: зачем нам страны, если можно определить, где человек живет, по названию города? Быстро выяснилось, как много в разных странах городов с одинаковыми названиями. Вернулись к классике: страна, город. И в-четвертых, что для меня самое впечатляющее: некоторые фичи, например, связанные с транспортом («есть у пользователя машина, самокат, велосипед или ничего нет») вообще пришлось убрать из рассмотрения. На практике выяснилось, что они ухудшали точность ранжирования. Разгадку мы нашли. Бо́льшая часть пользователей не вносила данные о своем транспортном средстве в профиль. Получается, отсутствие транспорта объединяет очень разных людей. Соответственно, их рекомендации теряют точность. Что касается тех, кто указал транспорт, — исключение этих фич из рассмотрения, их ленте не навредило. Поскольку это редкий признак, он выделял очень маленькие группы и заваливал их тематическими рекомендациями. Например, велосипедисты получали гору постов про велосипеды, среди которых терялся остальной контент.  Первая фича, которую мы ввели для постов, — география. Это было легко, а дальше пошла семантика. Мы начали с анализа текста. Сперва с помощью усредненного Word2vec получали семантический вектор одного поста. Но поскольку вектор — это вещь не дискретная, с ней работать в рамках матрицы LightFM достаточно сложно. Поэтому дальше кластеризовали эти тексты. И уже эти кластеры использовали как фичи. Получилось около 250 кластеров. Практика показала, что этот метод можно использовать как основной. Он хорошо обобщает.  Самый объемный кластер — нулевой. Он собрал околопустые посты, вида «:)» или «Я и Маша». Обычно это подписи к фотографиям. Семантически мы их не анализируем, потому что у текста нет самостоятельной смысловой нагрузки. Для них нужно выделять семантику из картинок, но это отдельная задача, к которой мы еще только подступаемся. Больше ценности с точки зрения ранжирования у маленьких кластеров. Так, например, у нас выделился условный «пост про поездку на шашлыки». Туда попадают самые разные тексты, но в них мелькают фразы наподобие: «поехали на природу», «мясо», «шашлыки». Тут все относительно просто.  У нас есть похожие друг на друга пользователи, у нас есть похожие друг на друга посты. Вот женщина-миллениалка из Таганрога — накидаем ей на холодном старте таких же публикаций, как ее ровесницам-землячкам. А когда (и если) она посмотрит, лайкнет и прокомментирует что-нибудь — уже добавим больше постов, похожих на те, что ей нравятся. Однако, нужно учитывать, что не все взаимодействия имеют одинаковый вес.Так, просмотр у нас идет с чуть ли не отрицательным коэффициентом: ведь если человек посмотрел пост, но не лайкнул и не прокомментил, значит, его не очень то и зацепило. Лайк идет с плюсовым коэффициентом. Коммент — с еще большим плюсом.  Сейчас заказчик переходит от лайков к реакциям, как в Facebook. Если нововведение приживется — мы в одночасье получим еще с десяток типов взаимодействий. Потребуется тюнинг, но мы строили систему гибкой — как раз на случай, если ее придется срочно переучивать. Общая идея архитектуры рекомендательной системы такова. Есть потоки данных, которые идут к нам с бэкенда социальной сети. В качестве транспорта мы выбрали Kafka, как решение, которое может пережевывать большие объемы данных, пускай и не со 100%-ным шансом доставки. Этот брокер хорошо масштабируется и позволяет быстро смонтировать данные. Данные о юзерах, постах и взаимодействиях проходят через препроцессинг. На этом этапе выделяются признаки (фичи) для каждого взаимодействия, пользователя или поста, информация о которых поступает из социальной сети. Они складываются в базу MongoDB. Каюсь: не помню точно все аргументы в ее пользу, но решающим было то, что это не реляционка. В постах может быть всякая всячина: фото, аудио, граффити со стенки. Хранить это в реляционной базе мы не можем — нам надо просто положить объект и забыть о формате. Мы условно разделяем базу на блоки: фича-стор;  область, где хранятся скоры;  хранилище с сырыми данными и так далее.  Сейчас стоит поближе взглянуть на фича-стор. Там лежат юзеры, их фичи, а также три больших таблички и три соответствующих им больших ветки. Это, как вы уже, наверное, догадались, наши киты: users, posts, interactions. Из MongoDB эти данные поступают в два потока — чуть ниже вы поймете, о чем речь… Первый поток связан с Model retraining — это микросервис, который переобучает модель. Поскольку мы имеем дело с соцсетью, наполненной живыми людьми, ее ландшафт постоянно меняется: появляются новые пользователи и контент, а у старых меняются интересы. Данные постоянно обновляются.  Поэтому мы перетренировываем модель раз в день на всех пользователях, постах и интерэкшенах. Правда, чтобы не затягивать обучение, мы используем посты только за последний месяц. Соответственно, посты старше месяца в рекомендации не попадают — хоть и продолжают храниться на серверах. Пользователи могут найти их самостоятельно. Результаты тренировки сохраняются в Model Artifact Storage. В то же время, раз в сутки, ночью, сразу после обучения модели, запускается офлайн-скоринг.  LightFM проводит скоринг вектора каждого пользователя и каждого поста, а затем проскоривает все посты относительно каждого пользователя. Таким образом рекомендательная система считает предположительное отношение каждого юзера к каждой публикации.  Результаты офлайн-скоринга попадают в холодное хранилище и лежат там до востребования. Кроме того, предусмотрен Instance Always On Model, который пересчитывает скоринги по запросу в реалтайме. «Онлайн-скоринг» нужен, чтобы ранжировать выдачу для новых пользователей на холодном старте. Как только юзер провзаимодействует с постами, его данные попадут в пайплайн офлайн-скоринга, где мы будем раз в сутки перескоривать для него весь контент.  В рамках онлайн-скоринга работает несколько инстансов. Когда приходит новый пользователь, мы используем чанки — чтобы какие-то результаты быстро писать с ходу. Предсказываем сразу 10 тыс. постов.  Чтобы распределить нагрузку и избежать «очередей» за контентом, мы поднимаем все это в контейнерах. Кстати, в MongoDB у нас тоже кластерная реализация, которая масштабируется в зависимости от нагрузки. Redis — пока в одном инстансе, но Redis в принципе хорошо масштабируется. Это и есть два потока данных. Первый поток работает в реалтайме, не переставая. Второй поток на фоне пересчитывает для всех пользователей каждый день нашу модель, чтобы она была релевантна тому, что происходит в сети. По сути, одновременный офлайн- и онлайн-скоринг — это компромисс между «идеально отранжировать, но не сразу» и «хоть как-то отранжировать, но сразу», и он работает. Выдача рекомендаций на фронт реализована при помощи GRPC-сервера.  Когда пользователь авторизуется в социальной сети, мы фиксируем это как событие и проверяем, новый он или нет. Если новый — заполняем Redis его данными, чтобы потом быстро выдать через GRPC. Иначе пришлось бы с каждым запросом ходить в MongoDB, а это не самая быстрая в мире вещь. За перенос данных из MongoDB в Redis отвечает модуль рекформера. С его помощью мы также отслеживаем просмотры постов и исключаем то, что пользователь уже видел, из новых рекомендаций. Кстати, это слегка разгружает базы данных: если юзер посмотрел пост, мы удаляем соответствующий скор. В общем, всю систему мы строили так, чтобы она могла масштабироваться и работать под высокой нагрузкой. Если перестанет пережевывать данные — что ж, поднимем новые машинки, выделим новые мощности и запустим дополнительные инстансы тех или иных сервисов. Единственное узкое место, которое здесь сложно масштабировать, — это тренировка модели. Был момент, когда она съедала всю доступную память и с грохотом падала. С этим пришлось отдельно бороться. Во-первых, мы максимально освобождали память. Например, взял данные в матрице, которые требуются для обучения, — преобразовал — удалил пандас-датафрейм. Принцип «что не нужно — сразу удаляй» пришлось внедрить во всем пайплайне. Во-вторых, LightFM работает либо с пандас-датафреймом, либо с разреженной матрицей. Первый сложнее обрабатывать, а вторую сложнее готовить, но это можно сделать заранее. Да, таким образом появляется узкое место, в момент, когда мы готовим разреженную матрицу. В будущем этот момент стоит доработать, но пока что система вывозит, а тренировка проходит легче, как мы и хотели.  Кстати, в разгар разработки LightFM начал преподносить сюрпризы. Например, попался юзер из города, которого нет в базе — все упало. На этом я на несколько дней завис: в документации напрямую не сказано, что LightFM не умеет взаимодействовать с неизвестными фичами. Хотя казалось бы — должен. Пришлось настроить фильтрацию: отсеивать новые фичи из поступающих данных и подсовывать их в пайплайн только в момент перетренировки модели. Если говорить о статистике, то на данный момент переобучение модели занимает около 2,5 часов. Время постепенно растет вслед за числом пользователей и объемом созданного ими контента, но это нормальный процесс. Фич так много, что и не сосчитать, но каждая фича преобразуется One-Hot Encoding в единицу или ноль. Например: пользователь из Павлодара? Есть только два ответа: «Да» и «Нет». И так везде. Поэтому фичи не занимают много места. Кстати, возможно, в какой-то момент придется вынести это в препроцессинг, чтобы фичи превращались в единицы и нули еще до тренировки. Но это задача далеко не приоритетная.  Для обучения мы используем только последние 30 дней. Это число я называл, но не сказал, какая это экономия. Если за месяц мы обрабатываем десятки тысяч постов, то за все время мы бы обрабатывали миллионы. Пользователей больше 10 миллионов. Но, напомню, мы взаимодействуем только с активными пользователями. Их меньше. Цифры внушительные, но они ничего не говорят об эффективности рекомендаций с точки зрения пользователей и, соответственно эффективности рекомендательной системы. Куда более показательны базовые метрики пользовательской вовлеченности, которые относятся не к ML, а к самой соцсети: лайки и охваты. После ввода рекомендаций стало больше лайков по отношению к просмотрам? Отлично. Средний пост просматривает больше народу? Тоже чудесно. Однако есть и полезные технические метрики, которые стоит использовать.Это стандартные Precision, Accuracy и Recall и пара показателей по-сложнее: ROC AUC и mNCG. ROC-кривая отражает то, насколько хорошо алгоритм присваивает объектам признаки и, следовательно, площадь под ней позволяет оценить то, насколько хорошо он упорядочивает объекты, в нашем случае — ранжирует посты.  Суть mean normalized cumulative gain проще объяснить на примере. Предположим, у нас всего 10 постов. Пользователь лайкнет 6 из них, а 4 проигнорирует. Рекомендательная система должна показать эти публикации в таком порядке, чтобы пользователь, просмотрел меньше 10 постов: 9, 8, 7, а в идеале — 6. И чем меньше постов юзер видел на тот момент, когда поставил максимум лайков, тем лучше рекомендательная система. В реальности постов тысячи, и дело не ограничивается одними только лайками. Реакций может быть много. К тому же далеко не все юзеры листают ленту до конца. Поэтому приходится измерять скорость, с которой юзер лайкает посты, и прикидывать, в какой момент он теоретически увидел бы последний интересный для себя пост. Чем раньше случится — тем лучше. Мы усредняем этот показатель для всех пользователей и получаем график, который показывает, на какой глубине просмотра в среднем заканчиваются интересные пользователям публикации. Это и есть показатель mNCG.  Естественно, мы сравниваем его с базовым: когда юзер листает посты подряд. В итоге, даже принципиально не сложная рекомендательная система увеличила метрику почти на 40%, а значит — не стыдно выдать коллегам полезных советов :) Думайте об имеющихся опциях. Планируйте не идеальный проект, а наилучший из возможных. Банально, но спасает. Недавно работаете с ML — позовите в команду ментора. Зачем? Он позволяет на первых этапах исключить многие неправильные шаги. Всю архитектуру не отстроит, баги не выловит, но направит работу в нужное русло с самого начала.  Крупнейшая проблема машинного обучения в том, что нет никаких гарантий, что продукт будет функционировать по задумке. Слишком много решений доступно, можно экспериментировать с ними месяцами и годами. Ментор же задаст правильный вектор для разработки и сбережет много времени.  Сначала продумайте схему архитектуры, потом согласовывайте, а потом разрабатывайте. А то что-нибудь не впишется — будет обидно. Например, нейросетка при достаточной нагрузке может попросту не успевать обучаться. Придется обучать реже, чем планировали, или вводить дополнительные мощности, или еще как-то выкручиваться. Или не согласуете схему, а разработчики сделают базу, из которой сложно вытянуть данные. В общем, не суть. Суть: нет согласованной схемы — есть много дополнительных сложностей. Все время думайте о параллелизации. И о том, как в перспективе все масштабировать.  Собирайте структуру проекта в Jupyter Notebook — это надежный помощник дата-сайентистов.  Но не пишите в Jupyter Notebook готовую модель — ее сложно переводить в продакшен. Пишите сразу в коде, потому что когда-нибудь в него придется превращать даже идеальную структуру. Jupyter — скорее инструмент для исследования: посмотреть какой-нибудь график, построить распределение, проанализировать результаты или типа того. Если есть возможность уходить в нампай — уходите. Чем на более низком уровне уйдете, тем меньше потом нагрузите систему. Модель или библиотека принимает нампаевские итераторы? Уходите добровольно. И чем раньше, тем лучше. Не держите до последнего пандас-датафрейм, если вам оно не надо. Я понимаю, исследовать пандас-датафрейм удобно. Вы видите, какие есть колонки, можете быстро провести всякие распрямления, исследования и так далее. Но когда вы уже пытаетесь запихнуть это все в прод, — переводите в нампай. Будет работать быстрее. Если вам есть, что сказать — пожалуйста, не сдерживайтесь, приходите в комментарии. Мы с командой будем рады и вопросам, и поправкам, и советам на будущее. Тим лид команды по машинному обучению в Магнус Тех