- 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.
22 KiB
Рекомендательная система и «Моя волна»
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 треков. Матрица взаимодействий разрежена: коллаборативная фильтрация полезна, но не может быть основой — особенно для новых пользователей и треков, которые никто не слушал. Поэтому система гибридная:
- Звучание трека (аудиоанализ) — работает с первого дня для любого трека.
- Личный вкус — по собственной истории пользователя.
- Коллективный опыт — 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 треками сессии,cfdot-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):
- Пересчёт
user_track_stats.implicit_score(затухание), artist / genre affinity. - ALS (свой на numpy —
pipu_rec/train/als.py, 64 фактора, confidencec = 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. - Кластеризация вкусов (центроиды) для каждого пользователя.
- Обучение LightGBM-ранжировщика на показах за 90 дней (time-split: последние 7 дней — валидация).
- Проверка метрик, запись
model_versions, горячая подмена моделей в памяти сервиса. - Пересчёт глобальной популярности, «похожих исполнителей», 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 тысяч размеченных показов.