# Рекомендательная система и «Моя волна» ## 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 жанра; `required` — не пройден и своей истории < 10 треков), `PUT /me/onboarding` → `user_profiles_rec.onboarding`; пустой выбор — пропуск. Рекомендатель, пока своих оценок < 30, добавляет псевдооценки: треки выбранных исполнителей +1, жанров +0.5 (до 300 треков). Веб показывает онбординг на главной вместо волны. - **Новый трек**: сразу доступен через `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 тысяч размеченных показов.