feat(recommender): nightly ALS with offline metrics and CF candidates in the wave (M6)
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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 17:54:25 +03:00
1 parent a3f212d253
commit 3afed02f23
12 files changed
+623 -8

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
"""ALS для неявной обратной связи (Hu, Koren, Volinsky, 2008) на numpy.
На масштабе проекта (~100 пользователей × десятки тысяч треков) решение каждой строки — система 64×64:
весь проход — секунды, без компилируемых зависимостей. Положительная оценка — предпочтение 1 с уверенностью
c = 1 + α·score; отрицательная — предпочтение 0 с повышенной уверенностью (модель учит «не это»).
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
F32 = NDArray[np.float32]
@dataclass
class ALSModel:
users: list[str]
items: list[str]
user_factors: F32 # (n_users, k)
item_factors: F32 # (n_items, k)
def user_vec(self, user: str) -> F32 | None:
try:
i = self.users.index(user)
except ValueError:
return None
vec: F32 = self.user_factors[i]
return vec
def scores(self, user: str) -> NDArray[np.float64]:
u = self.user_vec(user)
if u is None:
return np.zeros(len(self.items))
return (self.item_factors @ u).astype(np.float64)
def _solve_side(
fixed: F32,
rows: list[tuple[NDArray[np.intp], NDArray[np.float64], NDArray[np.float64]]],
reg: float,
) -> F32:
"""Пересчёт одной стороны: для каждой строки (индексы, уверенность, предпочтение) — закрытое решение
(YᵀY + Yᵀ(C−I)Y + λI) x = YᵀCp, где YᵀY общий для всех строк."""
k = fixed.shape[1]
yty = fixed.T @ fixed
out = np.zeros((len(rows), k), dtype=np.float32)
eye = reg * np.eye(k)
for r, (idx, conf, pref) in enumerate(rows):
if len(idx) == 0:
continue
y = fixed[idx].astype(np.float64)
a = yty + (y.T * (conf - 1.0)) @ y + eye
b = (y.T * conf) @ pref
out[r] = np.linalg.solve(a, b)
return out
def train_als(
scores: dict[str, dict[str, float]],
factors: int = 64,
reg: float = 0.1,
alpha: float = 20.0,
iterations: int = 12,
seed: int = 0,
) -> ALSModel | None:
"""Обучает ALS по implicit_score. None — данных нет."""
users = sorted(u for u, row in scores.items() if row)
items = sorted({t for row in scores.values() for t in row})
if not users or not items:
return None
upos = {u: i for i, u in enumerate(users)}
ipos = {t: i for i, t in enumerate(items)}
by_user: list[list[tuple[int, float]]] = [[] for _ in users]
by_item: list[list[tuple[int, float]]] = [[] for _ in items]
for u, row in scores.items():
if u not in upos:
continue
for t, s in row.items():
by_user[upos[u]].append((ipos[t], s))
by_item[ipos[t]].append((upos[u], s))
def prep(
lists: list[list[tuple[int, float]]],
) -> list[tuple[NDArray[np.intp], NDArray[np.float64], NDArray[np.float64]]]:
out = []
for lst in lists:
idx = np.array([i for i, _ in lst], dtype=np.intp)
s = np.array([v for _, v in lst], dtype=np.float64)
conf = 1.0 + alpha * np.abs(s) * np.where(s > 0, 1.0, 0.5)
pref = (s > 0).astype(np.float64)
out.append((idx, conf, pref))
return out
u_rows, i_rows = prep(by_user), prep(by_item)
k = min(factors, max(2, len(items) - 1))
rng = np.random.default_rng(seed)
x = (rng.standard_normal((len(users), k)) * 0.01).astype(np.float32)
y = (rng.standard_normal((len(items), k)) * 0.01).astype(np.float32)
for _ in range(iterations):
x = _solve_side(y, u_rows, reg)
y = _solve_side(x, i_rows, reg)
return ALSModel(users=users, items=items, user_factors=x, item_factors=y)
def normalize(v: F32) -> F32:
"""L2-нормировка строк (косинусное сходство в волне)."""
n = np.linalg.norm(v, axis=-1, keepdims=True)
return (v / np.where(n > 0, n, 1.0)).astype(np.float32)