Files
pipu-music/services/recommender/pipu_rec/train/signals.py
T
mrleo1nidandClaude Opus 5.5 3afed02f23
ci / recommender / recommender (push) Successful in 37s
feat(recommender): nightly ALS with offline metrics and CF candidates in the wave (M6)
- Implicit scores from play events and reactions per the signals table
  (90-day half-life, weaker skips outside the wave, dislikes override).
- numpy ALS for implicit feedback; time-split evaluation on the last
  7 days (NDCG@10, Recall@50) against a popularity baseline; a new model
  is activated only if NDCG@10 does not regress by more than 2%.
- Vectors are written to track_embeddings.cf and user_profiles_rec.taste_cf,
  versions to model_versions; NightlyTrainer runs at TRAIN_HOUR and on
  first start, `pipu-rec train` runs it manually.
- Wave: cf and item_cf candidate sources and a 0.10 cf term in the v1
  score; impressions are tagged heuristic-v1+cf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:54:25 +03:00

65 lines
2.3 KiB
Python

"""Неявная оценка пары (пользователь, трек) из событий — таблица docs/recommendations.md#3."""
from __future__ import annotations
import math
from collections import defaultdict
from dataclasses import dataclass
HALF_LIFE_DAYS = 90.0
DAY_S = 86400.0
LIKE = 3.0
DISLIKE = -5.0
@dataclass(frozen=True)
class Interaction:
user: str
track: str
ts: float # unix-время события
weight: float # вклад до затухания
def event_weight(kind: str, played_ms: int | None, duration_ms: int | None, in_wave: bool) -> float | None:
"""Вклад одного прослушивания. Скип вне волны слабее (×0.5): человек мог просто листать альбом."""
played = played_ms or 0
if kind == "play_end":
return 1.0
if kind != "skip":
return None
if duration_ms and played >= 0.5 * duration_ms:
return 0.7 # засчитанное прослушивание без доигрывания
if played < 10_000:
w = -1.0
elif played < 30_000:
w = -0.7
else:
w = -0.2
return w if in_wave else w * 0.5
def decay(ts: float, now: float) -> float:
"""Экспоненциальное затухание с полупериодом 90 дней."""
return math.exp(-math.log(2) * max(now - ts, 0.0) / DAY_S / HALF_LIFE_DAYS)
def implicit_scores(
interactions: list[Interaction], now: float, before: float | None = None
) -> dict[str, dict[str, float]]:
"""user → track → implicit_score; before — учитывать только события раньше этого момента.
Дизлайк перекрывает всё: оценка трека становится DISLIKE (исключение из рекомендаций)."""
out: dict[str, dict[str, float]] = defaultdict(dict)
disliked: set[tuple[str, str]] = set()
for it in interactions:
if before is not None and it.ts >= before:
continue
if it.weight <= DISLIKE:
disliked.add((it.user, it.track))
continue
row = out[it.user]
row[it.track] = row.get(it.track, 0.0) + it.weight * decay(it.ts, now)
for user, track in disliked:
out[user][track] = DISLIKE
return dict(out)