
Музыкальные стриминговые сервисы давно перестали быть просто «цифровыми полками» с треками — они превратились в персонализированные медиаплатформы, на которых ключевую роль играют рекомендательные системы. От Spotify и Apple Music до Яндекс.Музыки, VK Музыки и Звука — все они стремятся не просто хранить музыку, а предугадывать, что пользователь захочет услышать прямо сейчас. Рекомендации покрывают большое количество различных сценариев: плейлисты дня, подборки новинок, экспериментальные плейлисты в смежных для пользователя жанрах и многое другое.
В этой статье мы хотим обсудить один из самых часто используемых и один из самых сложных с технической точки зрения сценариев: персональный поток треков (Персональная Волна).
Что такое «Волна» и почему она сложна

Волна позволяет пользователю включить музыку и слушать её непрерывно, без необходимости выбирать следующий трек вручную. Рекомендации в этом сценарии могут основываться как на знаниях о пользователе (истории прослушиваний, коллекции и добавлениях в плейлист), так и о текущем контексте взаимодействия (дне недели, времени суток, устройстве воспроизведения), а также о непосредственных действиях пользователя в рамках текущей сессии в сервисе.
Как устроены рекомендации в музыкальных сервисах
Основные подходы, используемые в музыкальных рекомендациях:
Коллаборативная фильтрация: «пользователи, похожие на вас, слушали это».
Content‑based фильтрация (фильтрация на основе содержимого): анализ аудиосигнала, метаданных (жанра, темпа, тональности), поиск похожих по звучания треков.
DL: историю пользователя можно представить как последовательность взаимодействия с токенами треков (релизов, артистов, жанров) и использовать sequence‑based модели (RNN, трансформеры и их свежие модификации).
Ранжирующие модели: для отбора наиболее подходящих треков в моменте, а также для учёта негативного сигнала от пользователя (градиентный бустинг, нейросетевые ранкеры и др.)
Особенности потоковой генерации рекомендаций

Потоковая генерация рекомендаций влечёт дополнительные сложности при использовании вышеописанных методов: нужно в реальном времени подхватывать сигналы от пользователя и перестраивать входные данные для моделей (добавлять новые токены в последовательность для трансформера, пересчитывать фичи для бустинга, искать похожие треки для свежих прослушиваний и лайков). При этом новые сигналы могут появляться не только в самой Волне, но и на других «поверхностях»: при прослушивании коллекции и плейлистов, в поисковых запросах. Недостаточно просто раз в день собирать подходящие треки для пользователя и выдавать их в определённом порядке (хотя такой подход тоже применяют как один из источников кандидатов).
Ещё одна из важных частей общего конвейера генерирования рекомендаций для Волны — ручная фильтрация итоговой выдачи (бизнес правила). Простой пример: фильтрация треков и артистов, которых пользователь уже дизлайкнул ранее. Более сложный вариант: зачастую модели предлагают треки, которые пользователь уже слышал в сервисе (и, возможно, уже добавил в коллекцию). Понятно, что показывать пользователю один и тот же трек несколько раз в день, быть может, и выгодно с точки зрения модели (на этот трек пользователь уже отреагировал положительно), но на пользовательский опыт это влияет негативно. Похожая ситуация с выдачей подряд треков одного и того же артиста.
Пайплайн генерации рекомендаций для Волны
В общем виде модель потоковой генерации обычно выглядит следующим образом:
На вход подают информацию о пользователе (историю прослушиваний, коллекцию, эмбеддинговое представление пользователя, различную статистику), свежие взаимодействия пользователя в рамках текущей сессии и контекст текущего запроса.
Собирают некоторый набор подходящих треков из различных моделей «кандидатогенераторов» (коллаборативных, контентных, sequence‑based). Причём кандидатогенераторы могут рассчитывать как онлайн, так и запускать заранее, с сохранением результатов в отдельную БД.
Общий набор кандидатов ранжируют относительно полной истории и текущей сессии.
Итоговый список фильтруют от дизлайков, слишком частых повторов треков и артистов, и, возможно, балансируют с точки зрения предпочтительного разнообразия для пользователя.
Именно последнему этапу — управлению разнообразием и балансом между exploration и exploitation — мы решили уделить особое внимание.
Проблема: знакомое или новое
Зачастую кандидатогенераторы имеют склонность предлагать треки, которые пользователь уже слышал. С увеличением длины истории взаимодействий в сервисе, доля новых треков и артистов почти всегда начинает падать (хотя бы потому, что треков конечное количество, а подходящих под вкусы пользователя — тем более). Можно, разумеется, использовать отдельные модели, которые предлагают только новинки, но возникает другой вопрос: «Как сделать так, чтобы старые треки не задавили их в общем наборе кандидатов?»
Относительно простой подход можно использовать для фильтра повторов треков и артистов: не показывать трек или артиста чаще, чем раз в N треков и/или чаще чем раз в T минут (причём можно использовать как суммарное время, проведённое в сервисе, так и просто время, прошедшее с момента предыдущего прослушивания). Понятно, что у каждого пользователя своё субъективное восприятие и способность оценить, как часто повторяются треки или артисты, но даже такой подход уже даёт достаточное, относительно неплохое разнообразие предлагаемых треков внутри Волны. Хотя здесь остаётся вопрос, как правильно подобрать эти параметры, и они, говоря по правде, могли бы быть для каждого пользователя свои.
Более сложный аспект — баланс между выдачей известных пользователю треков и новых треков (баланс между exploration и exploitation). Здесь тоже можно применить довольно простой подход: посмотреть на статистику взаимодействия пользователя с повторами и новыми треками (как в самой Волне, так и за её пределами) и построить выдачу пропорционально этой статистике. Если пользователь за пределами Волны в основном слушает коллекцию, а новые треки в Волне пролистывает, то будем давать ему больше треков из истории. Если, наоборот, пользователь в Волне новые треки слушает, а уже известные ему пролистывает, а за пределами Волны предпочитает плейлисты Новинки или Эксперименты, значит в Волне должно быть больше новых треков.
Такой подход к балансу exploration и exploitation требует ручного подбора разных коэффициентов, а также плохо масштабируется на большее количество категорий. В частности, во время анализа, было выявлено, что повторы треков, которые уже лежат в коллекции, слушают несколько хуже, чем повторы треков, которые пользователь ещё не лайкал. При разделении треков уже на три категории (коллекция/история/новое) модель на статистиках стало бы заметно сложнее оптимизировать.
Решение: контекстный многорукий бандит

На самом деле, исходная модель очень похожа на RL‑модель, а именно на контекстного многорукого бандита (Contextual Multi‑Armed Bandit). С той лишь разницей, что коэффициенты там подобраны вручную (поэтому контекст довольно простой). Так что логичным следующим шагом стала попытка заменить простую статистическую модель на контекстный многорукий бандит.
В первой итерации использовали линейный бандит, у которого в качестве ручек (action) выбрали три варианта: выдать трек из коллекции, из истории или новый трек. Контекст (state) состоял из набора статистики (фич), похожих на используемые в прошлой модели, но несколько расширенный:
доля взаимодействий с каждой ручкой (коллекция, история, новое) — по всей истории, по свежим взаимодействиям, по взаимодействиям внутри Волны и в рамках текущей сессии;
доля прослушиваний среди взаимодействий с каждой ручкой — по всей истории, по свежим взаимодействиям, по взаимодействиям внутри Волны и в рамках текущей сессии.
Оффлайн‑обучение: как обучить бандит без A/Б‑теста
Обучение в реальных условиях было бы слишком дорогим и долгим, к тому же пришлось бы зафиксировать всего несколько сетапов обучения (состав фич контекста, способа выбора ручек внутри бандита, гиперпараметры). В итоговом виде использовали один из подходов для оффлайн‑обучения бандита:
Историю за определённый период времени размечали согласно действиям бандита: мы знаем для каждого взаимодействия, было ли это первое прослушивания этого трека пользователем и был ли к этому моменту трек добавлен в коллекцию.
Для каждого взаимодействия из истории считали фичи контекста, которые были бы у пользователя в этот момент.
Для каждого взаимодействия определяли награду (reward) для бандита: положительную для прослушивания и лайка и отрицательную для пролистывания (скипа) и дизлайка.
Весь датасет обучения разбивали на относительно небольшие батчи, на которых последовательно дообучали: для каждого взаимодействия из батча определяли действие, которое бандит в данный момент считал наилучшим. Затем из текущей порции взаимодействий оставляли только те, где действие бандита совпало с исторической разметкой, и на этих данных обновляли веса бандита.
Для оценки качества полученной модели, на валидационной части датасета считали различные метрики:
Итоговую долю попаданий выбора бандита в историческую разметку.
Среднюю награду по попаданиям, когда мы точно можем узнать реакцию пользователя на действие бандита. Потенциально, чем выше эта метрика, тем лучше новая модель предсказывает что пользователю может понравиться в моменте.
Среднюю награду по промахам, когда мы не знаем реакцию на действие бандита, но знаем реакцию на другое действие. Потенциально, чем ниже эта метрика, тем лучше новая модель предсказывает, что пользователю может НЕ понравиться в моменте.
В идеале, при оптимизации параметров хочется максимизировать разницу между наградой по попаданиям и по промахам, с учётом общей доли попаданий (которая, по сути, определяет уверенность в каждой из метрик по средним наградам).
Ещё один полезный бонус оффлайн‑обучения заключается в том, что поскольку для каждого события мы знаем историю не только в прошлое, но и в будущее, то мы можем учитывать при формировании награды не только моментальную реакцию пользователя, но и общую длину всей сессии, время возвращения в сервис (время до следующей сессии), а для более старых взаимодействий — ещё и факт продления подписки. Таким образом можно настроить модель на оптимизацию более важных для сервиса в целом долгосрочных метрик, таких как доля удержания пользователей (retention).
Оценка качества и результаты A/Б‑теста
По итогам А/Б‑тестов, замена модели, определяющей баланс между exploration и exploitation со статистической (старый и новый трек) на многорукого бандита (трек из коллекции, трек из истории и новый трек) дал прирост почти всех метрик:
среднее время прослушивания в Волне (+4,5%);
доля прослушиваний (+1,5%);
количество уникальных треков с достаточным количеством прослушиваний в Волне (+16%) — прокси‑метрика разнообразия выдачи Волны;
количество активных дней в сервисе (+1%) — прокси‑метрика retention, которую можно посчитать за относительно небольшой период теста.
Немного уменьшилась только доля лайков (-2%), за счёт того, что вся выдача немного сместилась в сторону коллекции и истории, а лайки чаще всего ставят именно новым трекам.
Выводы
Внедрение RL‑модели многорукого бандита не только увеличило почти все основные метрики (включая долгосрочные), но и открыло возможности для потенциального расширения фильтра exploration/exploitation (например добавление дополнительных категорий знакомый/незнакомый исполнитель), а также для потенциального использования при смешивании треков из разных кандидатогенераторов (когда общая ранжирующая модель имеет слишком сильный перекос в пользу одной из моделей).
Владислав Епифанов, Senior Data scientist Дивизиона технологического развития рекомендательной платформы Сбера