"""Неявная оценка пары (пользователь, трек) из событий — таблица 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)