feat(recommender): nightly ALS with offline metrics and CF candidates in the wave (M6)
ci / recommender / recommender (push) Successful in 37s
ci / recommender / recommender (push) Successful in 37s
- 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>
This commit is contained in:
1 parent
a3f212d253
commit
3afed02f23
12 files changed
+623
-8
No files matched your search
@@ -0,0 +1,64 @@
|
||||
"""Неявная оценка пары (пользователь, трек) из событий — таблица 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)
|
||||
Reference in new issue
Block a user