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>
65 lines
2.3 KiB
Python
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)
|