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

277 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Рекомендательная система и «Моя волна»
## 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 тысяч размеченных показов.