Всем привет! Меня зовут Татьяна Онофрюк, я аналитик в команде персонализации HiFi-стриминга Звук, и сегодня я расскажу про работу нашей команды с рекомендательными системами и кластеризацией по исполнителям и жанрам стриминга.

С чего все начиналось?

В работе с рекомендательными системами в Звуке мы достаточно часто сталкиваемся с проблемой «холодного старта» — возможности применения системы для новых пользователей. И действительно, когда истории прослушиваний нет, то и рекомендовать человеку что-либо довольно сложно. Сегодня мы хотим поделиться одним из своих подходов к работе с такими случаями.

Как правило, любой стриминг начинается с онбординга и предложения выбрать то, что пользователю больше всего по душе. Однако и на этом этапе рекомендации могут варьироваться достаточно сильно. 

Для начала определимся, в чем заключается наша задача? Во-первых, уже на старте предложить слушателям стриминга более релевантных исполнителей.

Во-вторых, мы работаем с тем, что имеем и что будем иметь на старте в проде. В рамках нашей работы нам доступны эмбеддинги — векторное представление интересов пользователей подписки, состоящее из его поведенческих параметров. С этими данными наша команда и решила работать, предположив, что, с одной стороны, они достаточно полные, а с другой — позволяют минимизировать набор доступных вводных на старте.

Методология и работа над проектом

Следующим шагом нужно было определить, какова цель кластеризации и какими результатами команда будет оценивать ее успешное выполнение. Ряд необходимых условий приведен ниже:

  1. Кластеры должны быть воспроизводимыми. 

  2. Количество выбросов должно быть минимизировано.

  3. Должно существовать разнообразие кластеров по исполнителям и жанрам.

  4. Размер кластеров должен быть равномерным.

Прежде всего, давайте посмотрим на имеющиеся данные. В нашем случае это id слушателя и эмбеддинг его характеристик. Всего в выборке порядка 1 миллиона пользователей.

df.user_id.nunique()
>>> Out: 919068

Важно понять размер эмбеддинга: его длина составила порядка 300 элементов, что могло усложнить дальнейшую кластеризацию и ее интерпретацию. Поэтому мы решили проверить возможность проведения факторного анализа на наших переменных. Для последующей работы мы преобразовали столбец embedding в array и нормализовали данные с помощью базового StandardScaler из библиотеки sklearn.

Для того, чтобы определить, подходят ли полученные данные для факторного анализа, мы проверили их на критерий Бартлетта и провели тест Кайзера-Мейера-Олкина (КМО).

# Критерий Бартлетта
from factor_analyzer import FactorAnalyzer
from factor_analyzer.factor_analyzer import calculate_kmo
from factor_analyzer.factor_analyzer import calculate_bartlett_sphericity

c_sq, p_v = calculate_bartlett_sphericity(X)
print(f"chi square: {c_sq}, p-value: {p_v}")
# Тест Кайзера-Мейера-Олкина (КМО)
kmo_all, kmo_res = calculate_kmo(X)
print(f"KMO: {kmo_res}")

>>> Out: chi square: 684126157.6610376, p-value: 0.0
KMO: 0.8987628983161072

Значения KMO более 0.85 и p-value, близкое к 0, говорят нам о том, что тест статистически значим, а данные подходят для проведения факторного анализа и сокращения размерности. 

Мы выбрали стандартный метод главных компонент. Оптимальное число компонент было определено с помощью метода локтя и создания экземпляра PCA с заданным порогом доли объясненной дисперсии. Наша команда получила оптимальное число компонент, равное 50. Далее вводные были преобразованы для получения сокращенного числа переменных. 

print(f"Оптимальное число компонент: {len(X_optimal[1]}")

>>> Out: 
Оптимальное число компонент: 50

Для дальнейшей работы необходимо было перевести данные в 2-мерную плоскость. Мы опробовали такие методы, как Umap, OpenTSNE и классический TSNE из библиотеки sklearn. Последний дал более разреженный результат, который подразумевал большее дальнейшее разнообразие кластеров. И именно тут нас выручило предварительное применение PCA — TSNE происходило быстрее.

В результате TSNE-преобразования (n_components=2) мы получили следующую визуализацию:

Для проведения дальнейшей кластеризации было бы полезно проверить расстояние между точками и их ближайшими соседями:

# Вычисляем расстояние до k-ближайшего соседа для каждой точки
>>> Out:
median dist:0.06164845890206941
75_percentile of dist:0.09267211671789391
max dist:5.377178691693254

Видно, что большая часть точек размещена достаточно скученно, однако есть и случаи большего расстояния. Далее дело за кластеризацией: мы выбрали метод DBScаn, так как он позволяет обозначить кластеры в соответствии с плотностью данных. 

Сначала нужно было определиться с базовыми гиперпамаметрами. В нашем случае подбор происходил с помощью перебора. На старте мы ввели условие: получить разнообразие кластеров при минимизации числа выбросов.

Исходя из этого, ниже мы привели список параметров, которые потенциально могут отвечать такому условию:

Условие
>>> Out:
eps=2.1, min_n=700, n_clusters=76, n_outs=396784, top_clusters = [(np.int64(-1), 396784), (np.int64(1), 210753), (np.int64(0), 94626), (np.int64(2), 27044), (np.int64(6), 16253)]

eps=2.2, min_n=700, n_clusters=60, n_outs=259409, top_clusters = [(np.int64(0), 398677), (np.int64(-1), 259409), (np.int64(7), 32311), (np.int64(3), 30797), (np.int64(6), 21934)]

eps=2.2, min_n=750, n_clusters=70, n_outs=359320, top_clusters = [(np.int64(-1), 359320), (np.int64(1), 221228), (np.int64(0), 101966), (np.int64(2), 29433), (np.int64(6), 18885)]

eps=2.2, min_n=800, n_clusters=73, n_outs=466973, top_clusters = [(np.int64(-1), 466973), (np.int64(1), 124824), (np.int64(0), 81979), (np.int64(5), 25590), (np.int64(3), 18749)]

eps=2.3, min_n=800, n_clusters=64, n_outs=322953, top_clusters = [(np.int64(-1), 322953), (np.int64(1), 240504), (np.int64(0), 107228), (np.int64(2), 30255), (np.int64(6), 20670)]


eps=2.4, min_n=800, n_clusters=48, n_outs=206206, top_clusters = [(np.int64(0), 447174), (np.int64(-1), 206206), (np.int64(6), 64580), (np.int64(3), 31639), (np.int64(4), 26162)]

eps=2.4, min_n=900, n_clusters=62, n_outs=379305, top_clusters = [(np.int64(-1), 379305), (np.int64(1), 220919), (np.int64(0), 98665), (np.int64(2), 29538), (np.int64(6), 17440)]

eps=2.5, min_n=800, n_clusters=29, n_outs=126547, top_clusters = [(np.int64(0), 639264), (np.int64(-1), 126547), (np.int64(3), 31141), (np.int64(1), 30063), (np.int64(2), 13958)]

eps=2.5, min_n=900, n_clusters=53, n_outs=252715, top_clusters = [(np.int64(0), 411497), (np.int64(-1), 252715), (np.int64(6), 57926), (np.int64(3), 31419), (np.int64(4), 18872)]

eps=2.6, min_n=700, n_clusters=9, n_outs=42431, top_clusters = [(np.int64(0), 854224), (np.int64(-1), 42431), (np.int64(2), 9921), (np.int64(4), 3725), (np.int64(3), 2860)]

eps=2.6, min_n=750, n_clusters=12, n_outs=56828, top_clusters = [(np.int64(0), 826745), (np.int64(-1), 56828), (np.int64(2), 9779), (np.int64(3), 7048), (np.int64(4), 4903)]

eps=2.6, min_n=800, n_clusters=16, n_outs=78840, top_clusters = [(np.int64(0), 794092), (np.int64(-1), 78840), (np.int64(1), 9558), (np.int64(3), 4641), (np.int64(4), 4565)]

eps=2.6, min_n=900, n_clusters=39, n_outs=157316, top_clusters = [(np.int64(0), 584306), (np.int64(-1), 157316), (np.int64(1), 29139), (np.int64(3), 28416), (np.int64(2), 12549)]

Далее мы построили кластеризации в соответствии с выбранными гиперпараметрами и проверили результаты:

Графиков получилось столько, что глаза разбегаются от выбора. Но мы помнили про два стартовых условия: кластеры должны отличаться по жанрам исполнителей, а еще они должны быть относительно равномерно устроены. В противном случае с нашей стороны было бы довольно рискованно использовать данные микро-кластера для раскатки на многомиллионную базу.

Изучив приведенные графики на предмет наличия укрупненных кластеров, мы приняли решение взять параметры eps=2.4, min_n=800 за отправную точку. Также кластеры с численностью пользователей менее 5000 были определены в кластер -1 ввиду того, что при малом количестве слушателей могли получиться не совсем репрезентативные данные. 

В результате получился следующий график:

Кластеры получили, но что делать дальше? Нужно было проверить их на предмет разнообразия исполнителей, которые слушали пользователи стриминга. Для анализа мы исключили единичных, но самых прослушиваемых небольшим числом пользователей исполнителей. Также в качестве метрики популярности мы использовали суммарную длительность прослушки более 30 секунд.

Несмотря на то, что позиции 1-2 почти всех кластеров представлены одними и тем же исполнителями, далее для кластеров исполнители разнятся, образуя разнообразие. 

Однако у нас все еще имелся один большой кластер 0. Нужно было проверить, целесообразно ли дробить его на части. Поскольку мы не заинтересованы в расширении шумового кластера, команда обратилась к знакомому многим методу моделей гауссовой смеси.

Для начала было определено оптимальное число кластеров. Затем мы увидели, что для дальнейшего анализа целесообразно разбить кластер на 8 мини-кластеров:  

Визуализировали эту кластеризацию:

Проанализировав список исполнителей, мы увидели, что дробление кластера 0 на множество вполне допустимо. Также целесообразно было объединить кластеры 4 и -1 из первой и второй кластеризации (соответственно), а также кластеры 5 и 0 ввиду схожести жанров исполнителей. 

Как итог финальная кластеризация имела следующий вид:

Также в качестве дополнительной проверки мы провели tf-idf анализ над списками получившихся артистов. Увидели, что есть общность артистов, но часть кластеров все же скашивается в сторону присутствия определенного жанра. Общность же может обосновываться как трендом, так и склонностью людей к меломании, что, на наш взгляд, нормально.

Проверка стабильности кластеров и работа в проде

Следующим этапом нужно было проверить стабильность получившихся кластеров. Напомню, что исходные преобразования начинались с получения эмбеддингов. И именно эти данные были взяты для определения кластеров новых пользователей. Преобразования PCA и TSNE на данном этапе не применялись из-за того, что состав датасета напрямую влиял на результаты обработки. 

В нашем случае, при попытке применить PCA и TSNE на свежих данных, новые эмбеддинги влияли на значения компонент. Как итог, исходная кластеризация буквально начала вращаться в области координат, что не позволило бы нам корректно определить кластер и оптимальных исполнителей для нового слушателя. 

В ситуации использования первоначальных данных, смещения старых пользователей не произойдет: новые векторы будут классифицироваться в соответствии с их значениями. Для определения номера кластера был использован классификатор CatBoost. 

Результат кросс-валидации обучения классификатора получился достаточно хорошим:  accuracy более 75%, F-мера большей частью также выше 70%. Это дало основание использовать классификатор при работе с кластерами. 

Мы почти на финишной прямой! Но для уверенности приняли решение дополнительно проверить стабильность кластеров на постоянных пользователях. Что будет, если изменится вектор уже существующего пользователя? Не расползутся ли наши кластеры? 

В идеале мы должны были с небольшим изменением эмбеддинга получить стабильный кластер. В случаях пограничных пользователей такое изменение кластера допустимо. Для проверки мы взяли эмбеддинги существующих аккаунтов (назовем их t1) в следующем периоде (t2) и с помощью обученного CatBoost классифицировали пользователей еще раз.

Далее мы проверили как пользователи t1 распределились в классификации t2:

Видно, что большая часть пользователей осталась в своих кластерах. Часть перешла в кластер -1, что нормально, так как это слушатели, близкие к краю кластера. 

Для проверки смещений жанров кластеров мы также векторизовали наши жанры и рассчитали косинусное расстояние в рамках каждого кластера. Смещение оказалось совсем небольшим, что в целом говорит о стабильности кластеров в своих жанрах.

Наконец, для проверки покрытия жанров был рассчитан accuracy в рамках каждого кластера и жанра в целом. Таким образом, можно увидеть, что в целом покрытие жанров со временем сохраняется.

В результате мы получили следующие кластеры:

Итак, мы подобрались к финалу статьи!

С помощью кластеризации нашей команде удалось решить задачу онбординга будущих слушателей. Проверка стабильности показала, что классификатор возможно применить на новых данных. Конечно, мы понимаем, что вкусы (как и эмбеддинги) со временем могут меняться. С целью учета этих моментов имеет смысл периодически проводить пересчет данной кластеризации. 

Спасибо, что прочитали. Буду рада ответить на все ваши вопросы и комментарии!

Комментарии (1)


  1. OcMaRUS
    27.09.2024 13:21
    +1

    Я то все гадал, почему предложения музыки такие тупые ....

    Не, конечно спасибо за подробности процесса, но это уровень полного пренебрежения сутью вопроса. Цель показать начальнику что сделана работа, в которой он ничего не поймёт ;)