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,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)
|
||||
Reference in new issue
Block a user