Files
pipu-music/docs/recommendations.md
T
mrleo1nid 1e3b350dfc
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / api / api (push) Successful in 45s
ci / recommender / recommender (push) Successful in 1m50s
ci / ts / ts (packages + web) (push) Failing after 6m53s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m13s
feat(upload): enhance audio upload handling and user feedback
- Introduced a new warning for users uploading non-FLAC or WAV formats, indicating that these formats should only be used as a last resort.
- Added functionality to highlight files needing format attention in the upload queue.
- Updated the UI to provide clearer guidance on acceptable audio formats during uploads.
- Improved documentation to reflect changes in upload requirements and user interface updates.
2026-10-21 10:07:43 +03:00

260 lines
22 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 жанра; `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 тысяч размеченных показов.