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>
112 lines
4.2 KiB
Python
112 lines
4.2 KiB
Python
"""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)
|