Files
pipu-music/docs/recommendations.md
T
mrleo1nid fff91c446b
ci / ts / ts (packages + web) (push) Successful in 3m19s
ci / db / db (push) Successful in 53s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 51s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / recommender / recommender (push) Failing after 16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
feat(recommendations): collect demographics and advance age cohorts
2026-10-22 14:19:52 +03:00

25 KiB
Raw Blame History

Рекомендательная система и «Моя волна»

1. Цели и как измеряем успех

Волна должна: (а) почти не включать то, что пользователь пропустит; (б) регулярно находить новое, что ему понравится; (в) мгновенно реагировать на реакции и выбранный контекст.

Метрика (онлайн, в админке) Что показывает Цель
Listen-through rate Доля треков волны, прослушанных ≥ 50% > 70%
Early skip rate Доля пропусков в первые 30 с < 20%
Like rate Лайков на 100 треков волны растёт
Discovery rate Доля впервые услышанных треков, прослушанных ≥ 50% 20–40%
Session length Медианная длительность сессии волны растёт

Офлайн-метрики при обучении: NDCG@10, Recall@50 на отложенной по времени выборке, AUC предсказания раннего скипа.

2. Масштаб и следствия

~100 пользователей × ≥ 11k треков. Матрица взаимодействий разрежена: коллаборативная фильтрация полезна, но не может быть основой — особенно для новых пользователей и треков, которые никто не слушал. Поэтому система гибридная:

  1. Звучание трека (аудиоанализ) — работает с первого дня для любого трека.
  2. Личный вкус — по собственной истории пользователя.
  3. Коллективный опыт — ALS и обученный на всех пользователях ранжировщик; вес растёт вместе с объёмом данных.

Весь объём (эмбеддинги 11k–100k × 256, векторы ~100 пользователей) помещается в память одного процесса — кандидаты считаются за миллисекунды, без распределённой инфраструктуры.

3. Сигналы и неявная оценка

Из play_events для каждой пары (пользователь, трек) считается implicit_score — сумма вкладов с экспоненциальным затуханием по времени (полупериод 90 дней):

Сигнал Вклад
Доиграл до конца +1.0
Засчитанное прослушивание (≥ 50%) без доигрывания +0.7
Скип в первые 10 с −1.0
Скип 10–30 с −0.7
Скип 30 с – 50% −0.2
Лайк +3.0
Дизлайк −5.0 и исключение из рекомендаций
Повтор по своей инициативе (не волна) за 24 ч +0.5
Нашёл через поиск и включил +0.5

Поправка на контекст: скип внутри волны — сильный сигнал (×1.0); скип при прослушивании альбома / исполнителя — слабее (×0.5), потому что человек мог просто листать. Перемотка к концу трека засчитывается как скип в точке начала перемотки.

Производные агрегаты:

  • artist affinity — сглаженное среднее implicit_score треков исполнителя (с байесовским приором к 0);
  • genre prefs — то же по жанрам (тегам и предсказанным моделью);
  • familiarity — сколько раз трек слышан, давность последнего прослушивания.

4. Представление трека

Заполняет сервис analyzer (Essentia + предобученные модели Essentia Models):

Признак Модель / алгоритм
Аудио-эмбеддинг discogs-effnet-bs64 (1280-d, среднее по патчам) → PCA до 256, L2-нормализация
Жанры (400 стилей Discogs) genre_discogs400 поверх эмбеддинга, топ-5 с вероятностями → track_genres(source='analysis')
Настроение mood_happy, mood_sad, mood_relaxed, mood_aggressive, mood_party
Valence / arousal deam / emomusic регрессия
Танцевальность, вокал / инструментал danceability, voice_instrumental
BPM TempoCNN (+ уверенность)
Тональность KeyExtractor
Громкость EBU R128 (LoudnessEBUR128) → loudness_lufs
Energy Комбинация RMS / spectral flux / arousal, нормирована по библиотеке в 0..1
Длина вступления Онсеты + энергия → intro_ms (для мягкого старта)

Производительность: анализируются 3 фрагмента по 30 с (из начала, середины и конца) — ~2–4 с на трек на CPU. 11k треков ≈ 3–6 часов при 4 воркерах при первом запуске; новые треки — в течение минут после скана. Пайплайн версионируется (analyzer_ver); смена версии ставит фоновый пересчёт.

Плюс метаданные: исполнители, год, теги-жанры, глобальная популярность.

5. Профиль пользователя

Долгосрочный (user_profiles_rec, пересчитывается онлайн после каждого события и полностью ночью):

  • taste_audio — взвешенное среднее аудио-эмбеддингов треков с положительным implicit_score (веса = score × затухание). Хранится также несколько центроидов (k-means, k = 3–8 в зависимости от объёма истории): у людей обычно несколько «вкусов», одно среднее их размазывает;
  • taste_cf — пользовательский фактор ALS;
  • genre_prefs, artist affinity;
  • параметры бандита по источникам кандидатов.

Сессионный (Redis, на время сессии волны):

  • session_vec — экспоненциальное скользящее среднее эмбеддингов треков сессии с учётом реакций: s ← normalize(s + η · r · e_track), где r = +1 доиграл, +2 лайк, −1 ранний скип, −2 дизлайк, η = 0.3;
  • счётчик подряд идущих скипов, использованные исполнители, история выдачи.

6. Конвейер «Моей волны»

 контекст (занятие, настроение, seed) + профиль + сессия
              │
   1. Кандидаты (≈ 500)  ← несколько источников
              │
   2. Фильтры  (дизлайки, недоступные, недавно игравшие)
              │
   3. Ранжирование  (v1: формула → v2: LightGBM)
              │
   4. Соответствие контексту  (занятие / настроение)
              │
   5. Переранжирование  (разнообразие, правила, исследование)
              │
        пачка из 5 треков → клиент

6.1 Источники кандидатов

Источник Как Сколько
audio_sim kNN по audio к сессионному вектору и центроидам вкуса 150
cf kNN по cf-факторам к taste_cf (ALS) 100
item_cf «С этим треком слушают»: соседи последних лайков по ALS item-item 60
artist Треки любимых исполнителей + похожих исполнителей (по эмбеддингу исполнителя = среднее треков) 60
genre Популярные внутри предпочитаемых жанров 40
fresh Ещё не слышанные пользователем треки, близкие к вкусу (с ослабленным порогом) 50
rediscover Любимые треки, не игравшие > 60 дней 30
popular Глобально популярные за 30 дней (для холодного старта) 30
seed Если волна запущена от исполнителя / трека — его соседи 100

Пулы кандидатов кэшируются в Redis на 10 минут; пересчитываются, если сильно сдвинулся сессионный вектор.

6.2 Фильтры

  • дизлайкнутые треки и исполнители — исключены навсегда (пока не снята реакция);
  • трек недоступен (источник офлайн, файл пропал) или не проанализирован — исключён из audio_sim;
  • сыгран в последние 3 часа — исключён; лайкнутый сыгран в последние 24 ч — штраф;
  • уже выдан в этой сессии — исключён.

6.3 Ранжирование

v1 (эвристика, с первого дня):

score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artist_affinity
      + 0.10·genre_pref + 0.10·cf_score + 0.05·popularity − penalties

v2 (LightGBM, lambdarank) — обучается на wave_impressions всех пользователей. Метка: 3 — лайк, 2 — доиграл, 1 — ≥ 50%, 0 — скип после 30 с, −1 → 0 с весом ×2 — ранний скип / дизлайк. Признаки:

  • сходства: с session_vec, с каждым центроидом (max / mean), с последними 5 треками сессии, cf dot-product;
  • трек: аудиопризнаки, популярность, возраст в библиотеке, длительность;
  • пользователь × трек: implicit_score, play / skip counts, давность, artist affinity, genre pref;
  • контекст: занятие, настроение, час суток, день недели, позиция в сессии, число скипов подряд, context_fit (см. 6.4), источник кандидата;
  • взаимодействия «контекст × аудиопризнаки» модель находит сама.

Обучение — ночью; новая модель активируется, только если офлайн-NDCG@10 не хуже активной более чем на 2%. Пока размеченных показов меньше ~20k, используется v1 (переключение автоматическое по порогу).

Реализация (pipu_rec/train/ranker.py): при каждом показе волна пишет вектор признаков кандидата в rec_impression_features (29 признаков, FEATURE_NAMES в wave/engine.py; порядок версионируется FEATURES_VERSION). Ночью признаки соединяются с исходами wave_impressions, группа — сессия волны; валидация — сессии последних 7 дней. Модель включается, если её NDCG@10 не ниже порядка по оценке v1 и не хуже активной более чем на 2%. Модель хранится строкой в model_versions.artifact (kind ranker), сервис загружает активную при старте и после ночного обучения; в выдаче её порядок заменяет оценку v1, MMR-разнообразие и квоты остаются. Пока модели нет, model_ver показа — версия v1. Онлайн-сравнение — админка «Библиотека → Моя волна»: доля доигрываний, ранних скипов и лайков за 7 дней отдельно по каждому model_ver показа.

6.4 Занятия и настроения

Каждый контекст — профиль целевых значений аудиопризнаков (services/recommender/config/contexts.yaml, меняется без перекомпиляции). context_fit = произведение гауссовых «колоколов» по признакам профиля; трек с context_fit ниже порога выпадает, остальные домножаются. Если после фильтра осталось < 50 кандидатов, порог ослабляется (лучше чуть мимо, чем повторы).

Контекст Профиль
🏃 Бегаю BPM 150–185 (или половина в 75–92 — двойной темп), energy ≥ 0.7, danceability ≥ 0.5, стабильный темп
💪 Тренировка energy ≥ 0.75, arousal ≥ 0.7, aggressive допускается
☀️ Просыпаюсь траектория: energy 0.3 → 0.7 за первые 20 минут сессии, valence ≥ 0.5, без aggressive
🎧 Работаю / фокус energy 0.3–0.6, instrumentalness ≥ 0.6 (предпочтительно), низкая вариативность, без резких переходов
🚗 В дороге energy 0.4–0.8, ближе к ядру вкуса (меньше исследования)
🛋 Отдыхаю energy ≤ 0.5, relaxed ≥ 0.5
🌙 Засыпаю траектория: energy 0.35 → 0.15, BPM ≤ 95, instrumental предпочтительно, громкость ровная
⚡ Энергичное energy ≥ 0.7, arousal ≥ 0.65
😊 Весёлое valence ≥ 0.6, mood_happy ≥ 0.5
🌿 Спокойное arousal ≤ 0.4, mood_relaxed ≥ 0.5
🌧 Грустное valence ≤ 0.4, mood_sad ≥ 0.5

Занятие и настроение комбинируются (пересечение профилей). Если не выбрано ничего — context_fit = 1. Ранжировщик v2 дополнительно выучивает, как конкретный пользователь реагирует в каждом контексте.

6.5 Переранжирование и правила

  • Разнообразие — MMR: λ·score − (1−λ)·max_sim(с уже выбранными), λ = 0.75;
  • не более 1 трека исполнителя в пачке и не более 2 на 10 последовательных треков;
  • переходы: штраф за резкий скачок energy / BPM между соседними треками (кроме «Тренировки»);
  • исследование: доля «новых» треков = exploration пользователя (по умолчанию 15%, адаптируется бандитом);
  • 3 скипа подряд ⇒ «смена курса»: сессионный вектор сбрасывается к ближайшему другому центроиду вкуса, следующая пачка собирается из другого кластера.

6.6 Бандит по источникам

Для каждого пользователя и источника — Beta(α, β); награда = трек прослушан ≥ 50% или лайкнут. При сборке пачки слоты распределяются по источникам семплированием Томпсона. Так система сама узнаёт, что одному пользователю лучше заходит fresh, а другому — rediscover. Параметры затухают (×0.99 в день), чтобы подстраиваться под смену вкуса.

Реализация (pipu_rec/train/bandit.py): параметры пересчитываются ночью по wave_impressions за 90 дней (награда — complete / like) в user_profiles_rec.bandit; при сборке пачки источник получает бонус 0.2·(θ − 0.5), где θ ~ Beta(α, β) (источник без наблюдений — нейтральные 0.5). exploration следует за успехом fresh относительно остальных источников (0.05–0.4).

6.7 Реакция в реальном времени

  • Клиент держит в очереди 2 трека вперёд и запрашивает новую пачку, когда осталось ≤ 2.
  • Запрос next несёт реакции на уже выданные треки → сессионный вектор обновляется до расчёта пачки.
  • Лайк / дизлайк / ранний скип дополнительно вызывают немедленный пересчёт: клиент отбрасывает ещё не начатые треки очереди волны и берёт свежую пачку.
  • Смена занятия / настроения (PATCH /wave/sessions/{id}) — очередь волны пересобирается сразу.

7. Холодный старт

  • Новый пользователь: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды. Без онбординга — первые пачки из popular и максимально разнообразных кластеров библиотеки (по одному треку из разных кластеров), exploration 40% на первые 30 треков.
  • Онбординг: GET /onboarding возвращает 40 исполнителей, 24 жанра, сохранённый choice и required. Анкета показывается на главной в вебе/desktop и Android до первого заполнения или пропуска, независимо от истории и наличия библиотеки. Её можно повторно открыть в настройках профиля.
  • PUT /me/onboarding сохраняет исполнителей, жанры, необязательный пол (male, female, other) и возрастную группу (under_13, 13_19, 20_29, 30_39, 40_49, 50_59, 60_plus) в user_profiles_rec.onboarding. Пустые списки и null для пола/возраста означают пропуск.
  • Вместе с исходной возрастной группой сервер сохраняет год выбора (age_group_year); клиент не может подменить его. SQL-функция rec_age_group прибавляет прошедшие годы к середине выбранного диапазона (для открытых диапазонов: 6 и 65 лет). Например, 13–19 лет в 2006 → примерно 30–39 лет в 2026. API возвращает текущую приблизительную группу. При сохранении той же текущей группы исходная группа и год остаются прежними; выбор другой группы задаёт новую точку отсчёта. Это оценка, не дата рождения.
  • Пока собственных оценок меньше 30, рекомендатель добавляет псевдооценки: исполнители +1, жанры +0.5, а треки, понравившиеся минимум двум другим незаблокированным слушателям с тем же полом и текущей возрастной группой, — до +0.25. Если указано одно поле, сравнивается только оно; если ни одного — демографического сигнала нет. Личные оценки не перезаписываются, дизлайки исключаются. На каждый источник действует лимит 300 треков. Музыкальные предпочтения пола/возраста заранее не задаются.
  • Запасная Go-волна учитывает выбранных исполнителей/жанры и те же демографические когорты после личных лайков и любимых исполнителей. Случайные треки сохраняют разнообразие.
  • Администраторский сброс удаляет профиль целиком, включая все ответы и год выбора. Клиенты обновляют состояние анкеты каждые 30 секунд, а веб также при возвращении на главную/в окно. Пользователь проходит настройку снова; текущее воспроизведение не прерывается.
  • Новый трек: сразу доступен через audio_sim / fresh (как только проанализирован), без единого прослушивания.

8. Обучение на всех пользователях

Ночной пайплайн (03:00, APScheduler в recommender):

  1. Пересчёт user_track_stats.implicit_score (затухание), artist / genre affinity.
  2. ALS (свой на numpy — pipu_rec/train/als.py, 64 фактора, confidence c = 1 + 20·score для положительных; отрицательные — предпочтение 0 с уверенностью 1 + 10·|score|) → track_embeddings.cf, user_profiles_rec.taste_cf. Проверка: последние 7 дней — тест (впервые понравившиеся треки), NDCG@10 / Recall@50 против популярности; новая модель активируется, если NDCG@10 не хуже активной более чем на 2%. В волне ALS даёт источники cf и item_cf и слагаемое 0.10·cf_score; версия модели в показах — heuristic-v1+cf. Запуск: TRAIN_HOUR (по умолчанию 3, -1 — выключено), вручную — pipu-rec train.
  3. Кластеризация вкусов (центроиды) для каждого пользователя.
  4. Обучение LightGBM-ранжировщика на показах за 90 дней (time-split: последние 7 дней — валидация).
  5. Проверка метрик, запись model_versions, горячая подмена моделей в памяти сервиса.
  6. Пересчёт глобальной популярности, «похожих исполнителей», top-tracks.

Время на ~100 пользователей и ~100k треков — минуты.

9. Отказоустойчивость

  • Таймаут вызова api → recommender — 300 мс. При ошибке api отдаёт fallback-волну (Go): лайки и треки любимых исполнителей + случайные треки из предпочитаемых жанров, с теми же фильтрами дизлайков.
  • Если анализ трека не завершён — трек участвует только в источниках без эмбеддинга.

10. Требования к производительности

  • POST /wave/sessions/{id}/next: p95 < 150 мс (включая вызов recommender).
  • Онлайн-обновление профиля по событию: < 50 мс, асинхронно из Redis Stream.

11. Что умеет волна

Эвристическое ранжирование, контексты (занятие и настроение), сессионный вектор и запасной алгоритм в api работают всегда. ALS, LightGBM, бандит, центроиды вкуса и онбординг включаются на накопленных прослушиваниях: ранжировщик — после примерно 20 тысяч размеченных показов.