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

+6 -2
View File
@@ -210,8 +210,12 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
Ночной пайплайн (03:00, APScheduler в `recommender`): Ночной пайплайн (03:00, APScheduler в `recommender`):
1. Пересчёт `user_track_stats.implicit_score` (затухание), artist / genre affinity. 1. Пересчёт `user_track_stats.implicit_score` (затухание), artist / genre affinity.
2. **ALS** (`implicit`, 64 фактора, confidence `c = 1 + 20·max(score, 0)`, отрицательные — через исключение 2. **ALS** (свой на numpy — `pipu_rec/train/als.py`, 64 фактора, confidence `c = 1 + 20·score` для положительных;
и штраф в ранжировании) → `track_embeddings.cf`, `user_profiles_rec.taste_cf`. отрицательные — предпочтение 0 с уверенностью `1 + 10·|score|`) → `track_embeddings.cf`, `user_profiles_rec.taste_cf`.
Проверка: последние 7 дней — тест (впервые понравившиеся треки), NDCG@10 / Recall@50 против популярности;
новая модель активируется, если NDCG@10 не хуже активной более чем на 2%. В волне ALS даёт источники `cf`
и `item_cf` и слагаемое `0.10·cf_score`; версия модели в показах — `heuristic-v1+cf`.
Запуск: `TRAIN_HOUR` (по умолчанию 3, `-1` — выключено), вручную — `pipu-rec train`.
3. Кластеризация вкусов (центроиды) для каждого пользователя. 3. Кластеризация вкусов (центроиды) для каждого пользователя.
4. Обучение LightGBM-ранжировщика на показах за 90 дней (time-split: последние 7 дней — валидация). 4. Обучение LightGBM-ранжировщика на показах за 90 дней (time-split: последние 7 дней — валидация).
5. Проверка метрик, запись `model_versions`, горячая подмена моделей в памяти сервиса. 5. Проверка метрик, запись `model_versions`, горячая подмена моделей в памяти сервиса.
+7
View File
@@ -10,6 +10,7 @@ import uvicorn
from pipu_rec.analysis.worker import Worker from pipu_rec.analysis.worker import Worker
from pipu_rec.api.app import create_app from pipu_rec.api.app import create_app
from pipu_rec.settings import Settings from pipu_rec.settings import Settings
from pipu_rec.train.pipeline import NightlyTrainer, train_all
from pipu_rec.wave.contexts import load_contexts from pipu_rec.wave.contexts import load_contexts
from pipu_rec.wave.service import WaveService from pipu_rec.wave.service import WaveService
@@ -27,6 +28,9 @@ def _serve(settings: Settings) -> None:
await rdb.ping() await rdb.ping()
wave = WaveService(settings.database_url, settings.redis_url, load_contexts(settings.contexts_path)) wave = WaveService(settings.database_url, settings.redis_url, load_contexts(settings.contexts_path))
if settings.train_hour >= 0:
# После активации новой модели каталог перечитывается сразу — горячая подмена векторов.
NightlyTrainer(settings.database_url, settings.train_hour, on_done=wave.index.refresh).start()
app = create_app(settings, {"postgres": check_postgres, "redis": check_redis}, wave) app = create_app(settings, {"postgres": check_postgres, "redis": check_redis}, wave)
uvicorn.run(app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level) uvicorn.run(app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level)
@@ -47,12 +51,15 @@ def main() -> None:
sub.add_parser("serve", help="внутренний API рекомендаций") sub.add_parser("serve", help="внутренний API рекомендаций")
analyze = sub.add_parser("analyze", help="воркер аудиоанализа") analyze = sub.add_parser("analyze", help="воркер аудиоанализа")
analyze.add_argument("--workers", type=int, default=4) analyze.add_argument("--workers", type=int, default=4)
sub.add_parser("train", help="обучить модели сейчас (то же, что ночной прогон)")
args = parser.parse_args() args = parser.parse_args()
settings = Settings() settings = Settings()
logging.basicConfig(level=settings.log_level.upper()) logging.basicConfig(level=settings.log_level.upper())
if args.cmd == "serve": if args.cmd == "serve":
_serve(settings) _serve(settings)
elif args.cmd == "train":
log.info("training: %s", train_all(settings.database_url))
else: else:
_analyze(settings, args.workers) _analyze(settings, args.workers)
@@ -17,4 +17,5 @@ class Settings(BaseSettings):
library_host_root: str = "/library-host" library_host_root: str = "/library-host"
ffmpeg_path: str = "ffmpeg" ffmpeg_path: str = "ffmpeg"
contexts_path: str = Field(default="", description="contexts.yaml; пусто — встроенный") contexts_path: str = Field(default="", description="contexts.yaml; пусто — встроенный")
train_hour: int = Field(default=3, ge=-1, le=23, description="час ночного обучения; -1 — выключено")
version: str = Field(default="0.0.0-dev", alias="PIPU_VERSION") version: str = Field(default="0.0.0-dev", alias="PIPU_VERSION")
@@ -0,0 +1 @@
"""Ночное обучение: неявные оценки, ALS, офлайн-метрики (docs/recommendations.md#8)."""
+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)
@@ -0,0 +1,39 @@
"""Офлайн-метрики на отложенной по времени выборке: NDCG@10, Recall@50 (docs/recommendations.md#1)."""
from __future__ import annotations
import math
from collections.abc import Callable, Iterable
def ndcg_at_k(ranked: list[str], relevant: set[str], k: int = 10) -> float:
dcg = sum(1.0 / math.log2(i + 2) for i, t in enumerate(ranked[:k]) if t in relevant)
ideal = sum(1.0 / math.log2(i + 2) for i in range(min(k, len(relevant))))
return dcg / ideal if ideal > 0 else 0.0
def recall_at_k(ranked: list[str], relevant: set[str], k: int = 50) -> float:
if not relevant:
return 0.0
return len(set(ranked[:k]) & relevant) / len(relevant)
def evaluate(
users: Iterable[str],
relevant: dict[str, set[str]],
rank: Callable[[str], list[str]],
) -> dict[str, float]:
"""Средние метрики по пользователям, у которых в тесте есть хотя бы один релевантный трек."""
n = 0
ndcg = rec = 0.0
for u in users:
rel = relevant.get(u)
if not rel:
continue
ranked = rank(u)
ndcg += ndcg_at_k(ranked, rel, 10)
rec += recall_at_k(ranked, rel, 50)
n += 1
if n == 0:
return {"users": 0, "ndcg@10": 0.0, "recall@50": 0.0}
return {"users": n, "ndcg@10": round(ndcg / n, 4), "recall@50": round(rec / n, 4)}
@@ -0,0 +1,220 @@
"""Ночной пайплайн (docs/recommendations.md#8): события → implicit_score → ALS → проверка метрик →
векторы в track_embeddings.cf / user_profiles_rec.taste_cf и запись в model_versions."""
from __future__ import annotations
import datetime as dt
import json
import logging
import threading
import time
from collections import Counter
from typing import Any
import numpy as np
import psycopg
from pipu_rec.train.als import ALSModel, normalize, train_als
from pipu_rec.train.metrics import evaluate
from pipu_rec.train.signals import DISLIKE, LIKE, Interaction, event_weight, implicit_scores
log = logging.getLogger(__name__)
HOLDOUT_DAYS = 7
# Новая модель активируется, только если NDCG@10 не хуже активной более чем на 2%.
MAX_REGRESSION = 0.02
MIN_INTERACTIONS = 50
ADVISORY_LOCK = 0x70697075 # «pipu»: один прогон обучения на кластер
def load_interactions(conn: psycopg.Connection[Any]) -> list[Interaction]:
out: list[Interaction] = []
for user, track, ts, kind, played, duration, in_wave in conn.execute(
"""
SELECT e.user_id::text, e.track_id::text, extract(epoch FROM e.occurred_at)::float8, e.type,
e.played_ms, t.duration_ms, (e.wave_session_id IS NOT NULL OR e.context_type = 'wave')
FROM play_events e JOIN tracks t ON t.id = e.track_id
WHERE e.type IN ('play_end', 'skip') AND e.occurred_at > now() - interval '365 days'
"""
):
w = event_weight(kind, played, duration, bool(in_wave))
if w is not None:
out.append(Interaction(user, track, float(ts), w))
for user, track, ts, reaction in conn.execute(
"SELECT user_id::text, track_id::text, extract(epoch FROM created_at)::float8, reaction::text "
"FROM track_reactions"
):
out.append(Interaction(user, track, float(ts), LIKE if reaction == "like" else DISLIKE))
return out
def holdout(
interactions: list[Interaction], now: float, days: int = HOLDOUT_DAYS
) -> tuple[dict[str, dict[str, float]], dict[str, set[str]]]:
"""Разбиение по времени: обучение — до отсечки, тест — треки, впервые понравившиеся после неё."""
cutoff = now - days * 86400
train = implicit_scores(interactions, now, before=cutoff)
after: dict[str, dict[str, float]] = {}
for it in interactions:
if it.ts >= cutoff:
row = after.setdefault(it.user, {})
row[it.track] = row.get(it.track, 0.0) + it.weight
relevant = {u: {t for t, w in c.items() if w > 0 and t not in train.get(u, {})} for u, c in after.items()}
return train, {u: r for u, r in relevant.items() if r}
def offline_metrics(interactions: list[Interaction], now: float) -> dict[str, Any]:
"""ALS против популярности на отложенной неделе."""
train, relevant = holdout(interactions, now)
model = train_als(train)
if model is None or not relevant:
return {"users": 0}
pop = Counter(t for row in train.values() for t, s in row.items() if s > 0)
popular = [t for t, _ in pop.most_common()]
def rank_als(u: str) -> list[str]:
seen = train.get(u, {})
order = np.argsort(-model.scores(u))
return [model.items[i] for i in order if model.items[i] not in seen][:50]
def rank_pop(u: str) -> list[str]:
seen = train.get(u, {})
return [t for t in popular if t not in seen][:50]
users = list(relevant)
return {"als": evaluate(users, relevant, rank_als), "popularity": evaluate(users, relevant, rank_pop)}
def vec_text(v: np.ndarray) -> str:
return "[" + ",".join(f"{x:.6f}" for x in v.tolist()) + "]"
def active_metrics(conn: psycopg.Connection[Any], kind: str) -> dict[str, Any] | None:
row = conn.execute(
"SELECT metrics FROM model_versions WHERE kind = %s AND is_active ORDER BY created_at DESC LIMIT 1",
(kind,),
).fetchone()
return row[0] if row and row[0] else None
def should_activate(new: dict[str, Any], current: dict[str, Any] | None) -> bool:
"""Без активной модели или без тестовых данных — активируем; иначе NDCG@10 не хуже на > 2%."""
if current is None:
return True
new_ndcg = new.get("als", {}).get("ndcg@10")
cur_ndcg = (current.get("offline") or {}).get("als", {}).get("ndcg@10")
if new_ndcg is None or not cur_ndcg:
return True
return float(new_ndcg) >= float(cur_ndcg) * (1 - MAX_REGRESSION)
def save_als(conn: psycopg.Connection[Any], model: ALSModel, version: str, metrics: dict[str, Any]) -> None:
items = normalize(model.item_factors)
users = normalize(model.user_factors)
with conn.transaction():
with conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO track_embeddings (track_id, cf, updated_at) VALUES (%s, %s::vector, now())
ON CONFLICT (track_id) DO UPDATE SET cf = EXCLUDED.cf, updated_at = now()
""",
[(t, vec_text(items[i])) for i, t in enumerate(model.items)],
)
cur.execute(
"UPDATE track_embeddings SET cf = NULL "
"WHERE cf IS NOT NULL AND NOT (track_id::text = ANY(%s))",
(model.items,),
)
cur.executemany(
"""
INSERT INTO user_profiles_rec (user_id, taste_cf, updated_at) VALUES (%s, %s::vector, now())
ON CONFLICT (user_id) DO UPDATE SET taste_cf = EXCLUDED.taste_cf, updated_at = now()
""",
[(u, vec_text(users[i])) for i, u in enumerate(model.users)],
)
conn.execute("UPDATE model_versions SET is_active = false WHERE kind = 'als' AND is_active")
conn.execute(
"INSERT INTO model_versions (id, kind, metrics, is_active) VALUES (%s, 'als', %s, true)",
(version, json.dumps(metrics)),
)
def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
"""Один прогон обучения. Возвращает сводку (её же пишет в лог и model_versions)."""
now = now or time.time()
with psycopg.connect(dsn) as conn:
if not conn.execute("SELECT pg_try_advisory_lock(%s)", (ADVISORY_LOCK,)).fetchone()[0]: # type: ignore[index]
return {"skipped": "another training is running"}
try:
interactions = load_interactions(conn)
if len(interactions) < MIN_INTERACTIONS:
return {"skipped": f"only {len(interactions)} interactions"}
started = time.monotonic()
offline = offline_metrics(interactions, now)
model = train_als(implicit_scores(interactions, now))
if model is None:
return {"skipped": "no data"}
version = "als-" + dt.datetime.fromtimestamp(now, dt.UTC).strftime("%Y%m%d%H%M")
metrics = {
"offline": offline,
"users": len(model.users),
"items": len(model.items),
"interactions": len(interactions),
"train_s": round(time.monotonic() - started, 1),
}
if not should_activate(offline, active_metrics(conn, "als")):
conn.execute(
"INSERT INTO model_versions (id, kind, metrics, is_active) VALUES (%s, 'als', %s, false) "
"ON CONFLICT (id) DO NOTHING",
(version, json.dumps(metrics)),
)
log.warning("als %s not activated: offline metrics regressed %s", version, offline)
return {"version": version, "activated": False, **metrics}
save_als(conn, model, version, metrics)
log.info("als %s activated: %s", version, metrics)
return {"version": version, "activated": True, **metrics}
finally:
conn.execute("SELECT pg_advisory_unlock(%s)", (ADVISORY_LOCK,))
class NightlyTrainer:
"""Обучение раз в сутки в hour:00 по местному времени сервера; при первом запуске без модели — сразу.
on_done вызывается после успешной активации (горячая подмена каталога в памяти)."""
def __init__(self, dsn: str, hour: int = 3, on_done: Any = None) -> None:
self.dsn = dsn
self.hour = hour
self.on_done = on_done
self._stop = threading.Event()
def start(self) -> None:
threading.Thread(target=self._loop, name="nightly-trainer", daemon=True).start()
def stop(self) -> None:
self._stop.set()
def seconds_until_next(self, now: dt.datetime) -> float:
target = now.replace(hour=self.hour, minute=0, second=0, microsecond=0)
if target <= now:
target += dt.timedelta(days=1)
return (target - now).total_seconds()
def _run(self) -> None:
try:
result = train_all(self.dsn)
log.info("training finished: %s", result)
if result.get("activated") and self.on_done:
self.on_done()
except Exception:
log.exception("training failed")
def _loop(self) -> None:
try:
with psycopg.connect(self.dsn) as conn:
never = active_metrics(conn, "als") is None
except Exception:
never = False
if never:
self._run()
while not self._stop.wait(self.seconds_until_next(dt.datetime.now())):
self._run()
@@ -0,0 +1,64 @@
"""Неявная оценка пары (пользователь, трек) из событий — таблица 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)
+47 -1
View File
@@ -10,6 +10,8 @@ from numpy.typing import NDArray
from pipu_rec.wave.contexts import FEATURES, Rule, fit from pipu_rec.wave.contexts import FEATURES, Rule, fit
MODEL_VER = "heuristic-v1" MODEL_VER = "heuristic-v1"
# С коллаборативным сигналом (ALS) — суффикс к версии модели в показах.
CF_SUFFIX = "+cf"
Vec = NDArray[np.float32] Vec = NDArray[np.float32]
@@ -24,10 +26,15 @@ class CatalogIndex:
has_emb: NDArray[np.bool_] has_emb: NDArray[np.bool_]
features: dict[str, NDArray[np.float64]] # NaN — нет признака features: dict[str, NDArray[np.float64]] # NaN — нет признака
popularity: NDArray[np.float64] # 0..1 popularity: NDArray[np.float64] # 0..1
cf: Vec = field(default_factory=lambda: np.zeros((0, 0), dtype=np.float32)) # (n, k) ALS, нормирован
has_cf: NDArray[np.bool_] = field(default_factory=lambda: np.zeros(0, dtype=bool))
pos: dict[str, int] = field(default_factory=dict) pos: dict[str, int] = field(default_factory=dict)
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.pos = {t: i for i, t in enumerate(self.ids)} self.pos = {t: i for i, t in enumerate(self.ids)}
if self.has_cf.shape[0] != len(self.ids):
self.cf = np.zeros((len(self.ids), 1), dtype=np.float32)
self.has_cf = np.zeros(len(self.ids), dtype=bool)
def __len__(self) -> int: def __len__(self) -> int:
return len(self.ids) return len(self.ids)
@@ -40,6 +47,7 @@ class CatalogIndex:
features: dict[str, list[float | None]], features: dict[str, list[float | None]],
plays: list[int], plays: list[int],
dim: int = 256, dim: int = 256,
raw_cf: list[Vec | None] | None = None,
) -> CatalogIndex: ) -> CatalogIndex:
n = len(ids) n = len(ids)
emb = np.zeros((n, dim), dtype=np.float32) emb = np.zeros((n, dim), dtype=np.float32)
@@ -59,7 +67,17 @@ class CatalogIndex:
} }
p = np.log1p(np.array(plays, dtype=np.float64)) p = np.log1p(np.array(plays, dtype=np.float64))
pop = p / p.max() if n and p.max() > 0 else np.zeros(n) pop = p / p.max() if n and p.max() > 0 else np.zeros(n)
return CatalogIndex(ids=ids, artist=artist, emb=emb, has_emb=has, features=feats, popularity=pop) cf_dim = next((len(v) for v in raw_cf or [] if v is not None), 0)
cf = np.zeros((n, max(cf_dim, 1)), dtype=np.float32)
has_cf = np.zeros(n, dtype=bool)
for i, v in enumerate(raw_cf or []):
if v is not None and len(v) == cf_dim:
norm = float(np.linalg.norm(v))
if norm > 0:
cf[i], has_cf[i] = v / norm, True
return CatalogIndex(
ids=ids, artist=artist, emb=emb, has_emb=has, features=feats, popularity=pop, cf=cf, has_cf=has_cf
)
@dataclass @dataclass
@@ -74,6 +92,8 @@ class Profile:
liked_recent: set[str] # лайкнутые, игравшие за 24 ч — штраф liked_recent: set[str] # лайкнутые, игравшие за 24 ч — штраф
rediscover: set[str] # любимые, не игравшие > 60 дней rediscover: set[str] # любимые, не игравшие > 60 дней
exploration: float = 0.15 exploration: float = 0.15
taste_cf: list[float] | None = None # фактор пользователя ALS
recent_likes: list[str] = field(default_factory=list) # последние лайки, новые первыми (для item_cf)
@dataclass @dataclass
@@ -145,6 +165,21 @@ def update_session(
session.vec = vec.tolist() if np.any(vec) else None session.vec = vec.tolist() if np.any(vec) else None
def cf_scores(index: CatalogIndex, profile: Profile) -> NDArray[np.float64] | None:
"""Косинус треков к фактору пользователя ALS; None — нет модели или пользователь в неё не попал."""
if profile.taste_cf is None or not index.has_cf.any():
return None
u = np.array(profile.taste_cf, dtype=np.float32)
if u.shape[0] != index.cf.shape[1]:
return None
u = u / (float(np.linalg.norm(u)) or 1.0)
return np.where(index.has_cf, index.cf @ u, 0.0).astype(np.float64)
def model_version(index: CatalogIndex, profile: Profile) -> str:
return MODEL_VER + (CF_SUFFIX if cf_scores(index, profile) is not None else "")
def _top(scores: NDArray[np.float64], mask: NDArray[np.bool_], k: int) -> NDArray[np.intp]: def _top(scores: NDArray[np.float64], mask: NDArray[np.bool_], k: int) -> NDArray[np.intp]:
idx = np.nonzero(mask)[0] idx = np.nonzero(mask)[0]
if len(idx) <= k: if len(idx) <= k:
@@ -189,6 +224,7 @@ def build_batch(
sim_session = index.emb @ svec if svec is not None else np.zeros(size) sim_session = index.emb @ svec if svec is not None else np.zeros(size)
sim_taste = np.where(index.has_emb, sim_taste, 0.0) sim_taste = np.where(index.has_emb, sim_taste, 0.0)
sim_session = np.where(index.has_emb, sim_session, 0.0) sim_session = np.where(index.has_emb, sim_session, 0.0)
cf_score = cf_scores(index, profile)
# Привязанность к исполнителю — сглаженное среднее оценок его треков. # Привязанность к исполнителю — сглаженное среднее оценок его треков.
totals: dict[str, list[float]] = {} totals: dict[str, list[float]] = {}
@@ -217,6 +253,15 @@ def build_batch(
never = np.array([t not in profile.played for t in index.ids]) never = np.array([t not in profile.played for t in index.ids])
add(_top(sim_taste + 0.3 * sim_session, allowed & index.has_emb & never, 50), "fresh") add(_top(sim_taste + 0.3 * sim_session, allowed & index.has_emb & never, 50), "fresh")
add(_top(query, allowed & index.has_emb, 150), "audio_sim") add(_top(query, allowed & index.has_emb, 150), "audio_sim")
# Коллективный опыт (ALS): похожие по вкусу других слушателей и «с этим слушают» к последним лайкам.
if cf_score is not None:
add(_top(cf_score, allowed & index.has_cf, 100), "cf")
likes = [
index.pos[t] for t in profile.recent_likes[:10] if t in index.pos and index.has_cf[index.pos[t]]
]
if likes:
item_sim = (index.cf @ index.cf[likes].T).max(axis=1).astype(np.float64)
add(_top(np.where(index.has_cf, item_sim, -1.0), allowed & index.has_cf, 60), "item_cf")
liked_artists = {a for a, v in affinity.items() if v > 0.3} liked_artists = {a for a, v in affinity.items() if v > 0.3}
if liked_artists: if liked_artists:
by_artist = [int(i) for i in np.nonzero(allowed)[0] if index.artist[i] in liked_artists] by_artist = [int(i) for i in np.nonzero(allowed)[0] if index.artist[i] in liked_artists]
@@ -234,6 +279,7 @@ def build_batch(
+ 0.25 * sim_taste[cand] + 0.25 * sim_taste[cand]
+ 0.15 * artist_aff[cand] + 0.15 * artist_aff[cand]
+ 0.05 * index.popularity[cand] + 0.05 * index.popularity[cand]
+ (0.10 * cf_score[cand] if cf_score is not None else 0.0)
+ 0.02 * rng.random(len(cand)) # разбивает ничьи, особенно при холодном старте + 0.02 * rng.random(len(cand)) # разбивает ничьи, особенно при холодном старте
) )
liked_recent = np.array([index.ids[i] in profile.liked_recent for i in cand]) liked_recent = np.array([index.ids[i] in profile.liked_recent for i in cand])
@@ -9,7 +9,7 @@ import psycopg
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pipu_rec.wave.contexts import Contexts, Rule from pipu_rec.wave.contexts import Contexts, Rule
from pipu_rec.wave.engine import MODEL_VER, Pick, build_batch, taste_centroids, update_session from pipu_rec.wave.engine import Pick, build_batch, model_version, taste_centroids, update_session
from pipu_rec.wave.store import IndexHolder, SessionStore, load_profile, seed_tracks from pipu_rec.wave.store import IndexHolder, SessionStore, load_profile, seed_tracks
MAX_SERVED_MEMORY = 300 MAX_SERVED_MEMORY = 300
@@ -89,5 +89,6 @@ class WaveService:
session.recent_artists = (session.recent_artists + artists)[-10:] session.recent_artists = (session.recent_artists + artists)[-10:]
self.sessions.put(req.session_id, session) self.sessions.put(req.session_id, session)
return BatchResponse( return BatchResponse(
tracks=[BatchTrack(id=p.id, source=p.source, score=p.score) for p in picks], model_ver=MODEL_VER tracks=[BatchTrack(id=p.id, source=p.source, score=p.score) for p in picks],
model_ver=model_version(index, profile),
) )
+14 -3
View File
@@ -39,7 +39,8 @@ def load_index(conn: psycopg.Connection[Any]) -> CatalogIndex:
WHERE ta.track_id = t.id AND ta.role = 'main' ORDER BY ta.position LIMIT 1), WHERE ta.track_id = t.id AND ta.role = 'main' ORDER BY ta.position LIMIT 1),
al.album_artist_id::text), al.album_artist_id::text),
e.audio::text, f.bpm, f.energy, f.danceability, f.valence, f.acousticness, e.audio::text, f.bpm, f.energy, f.danceability, f.valence, f.acousticness,
COALESCE((SELECT sum(play_count) FROM user_track_stats s WHERE s.track_id = t.id), 0) COALESCE((SELECT sum(play_count) FROM user_track_stats s WHERE s.track_id = t.id), 0),
e.cf::text
FROM tracks t FROM tracks t
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
JOIN media_files mf ON mf.id = t.primary_file_id AND mf.missing_since IS NULL JOIN media_files mf ON mf.id = t.primary_file_id AND mf.missing_since IS NULL
@@ -60,6 +61,7 @@ def load_index(conn: psycopg.Connection[Any]) -> CatalogIndex:
raw_emb=[parse_vector(r[2]) for r in rows], raw_emb=[parse_vector(r[2]) for r in rows],
features=feats, features=feats,
plays=[int(r[8]) for r in rows], plays=[int(r[8]) for r in rows],
raw_cf=[parse_vector(r[9]) for r in rows],
) )
@@ -123,11 +125,15 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
scores[tid] = raw * math.exp(-math.log(2) * (now - ts) / 86400 / HALF_LIFE_DAYS) scores[tid] = raw * math.exp(-math.log(2) * (now - ts) / 86400 / HALF_LIFE_DAYS)
disliked: set[str] = set() disliked: set[str] = set()
liked: set[str] = set() liked: set[str] = set()
recent_likes: list[str] = []
for tid, reaction in conn.execute( for tid, reaction in conn.execute(
"SELECT track_id::text, reaction::text FROM track_reactions WHERE user_id = %s", (user_id,) "SELECT track_id::text, reaction::text FROM track_reactions WHERE user_id = %s "
"ORDER BY created_at DESC",
(user_id,),
): ):
if reaction == "like": if reaction == "like":
liked.add(tid) liked.add(tid)
recent_likes.append(tid)
scores[tid] = scores.get(tid, 0.0) + 3.0 scores[tid] = scores.get(tid, 0.0) + 3.0
else: else:
disliked.add(tid) disliked.add(tid)
@@ -149,8 +155,11 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
(user_id,), (user_id,),
) )
} }
row = conn.execute("SELECT exploration FROM user_profiles_rec WHERE user_id = %s", (user_id,)).fetchone() row = conn.execute(
"SELECT exploration, taste_cf::text FROM user_profiles_rec WHERE user_id = %s", (user_id,)
).fetchone()
exploration = float(row[0]) if row else 0.15 exploration = float(row[0]) if row else 0.15
taste_cf = parse_vector(row[1]) if row else None
if len(played) < 30: if len(played) < 30:
exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках
day = 86400 day = 86400
@@ -163,6 +172,8 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
liked_recent={t for t in liked if now - last.get(t, 0) < day}, liked_recent={t for t in liked if now - last.get(t, 0) < day},
rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day}, rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day},
exploration=exploration, exploration=exploration,
taste_cf=taste_cf.tolist() if taste_cf is not None else None,
recent_likes=recent_likes,
) )
+110
View File
@@ -0,0 +1,110 @@
"""ALS, неявные оценки, офлайн-метрики и правило активации модели."""
import numpy as np
from pipu_rec.train.als import train_als
from pipu_rec.train.metrics import evaluate, ndcg_at_k, recall_at_k
from pipu_rec.train.pipeline import holdout, should_activate
from pipu_rec.train.signals import DISLIKE, Interaction, event_weight, implicit_scores
from pipu_rec.wave.engine import CatalogIndex, Profile, Session, build_batch, model_version
DAY = 86400.0
def test_event_weights_follow_spec() -> None:
assert event_weight("play_end", 200_000, 200_000, True) == 1.0
assert event_weight("skip", 120_000, 200_000, True) == 0.7 # ≥ 50% — засчитано
assert event_weight("skip", 5_000, 200_000, True) == -1.0
assert event_weight("skip", 20_000, 200_000, True) == -0.7
assert event_weight("skip", 5_000, 200_000, False) == -0.5 # вне волны — слабее
assert event_weight("play_start", 0, 200_000, True) is None
def test_implicit_scores_decay_and_dislike() -> None:
now = 1000 * DAY
inter = [
Interaction("u", "a", now, 1.0),
Interaction("u", "b", now - 90 * DAY, 1.0), # полупериод — половина веса
Interaction("u", "c", now, 3.0),
Interaction("u", "c", now, DISLIKE), # дизлайк перекрывает
]
s = implicit_scores(inter, now)["u"]
assert s["a"] == 1.0
assert abs(s["b"] - 0.5) < 1e-9
assert s["c"] == DISLIKE
def two_taste_world() -> dict[str, dict[str, float]]:
"""Две группы слушателей с непересекающимися вкусами: rock_* и jazz_*."""
rng = np.random.default_rng(1)
scores: dict[str, dict[str, float]] = {}
for g in ("rock", "jazz"):
for u in range(15):
liked = rng.choice(30, size=12, replace=False)
scores[f"{g}_user{u}"] = {f"{g}_{i}": 1.0 + float(rng.random()) for i in liked}
return scores
def test_als_learns_taste_groups() -> None:
model = train_als(two_taste_world(), factors=8, iterations=10)
assert model is not None
s = model.scores("rock_user0")
rock = np.mean([s[i] for i, t in enumerate(model.items) if t.startswith("rock")])
jazz = np.mean([s[i] for i, t in enumerate(model.items) if t.startswith("jazz")])
assert rock > jazz + 0.3
def test_metrics() -> None:
assert ndcg_at_k(["a", "b"], {"a"}) == 1.0
assert 0 < ndcg_at_k(["x", "a"], {"a"}) < 1
assert recall_at_k(["a", "x"], {"a", "b"}) == 0.5
res = evaluate(["u", "v"], {"u": {"a"}}, lambda u: ["a"])
assert res == {"users": 1, "ndcg@10": 1.0, "recall@50": 1.0}
def test_holdout_keeps_only_new_positives() -> None:
now = 100 * DAY
inter = [
Interaction("u", "old", now - 30 * DAY, 1.0),
Interaction("u", "old", now - DAY, 1.0), # уже был в обучении — не «новый»
Interaction("u", "new", now - DAY, 1.0),
Interaction("u", "skipped", now - DAY, -1.0),
]
train, relevant = holdout(inter, now)
assert set(train["u"]) == {"old"}
assert relevant == {"u": {"new"}}
def test_activation_gate() -> None:
cur = {"offline": {"als": {"ndcg@10": 0.30}}}
assert should_activate({"als": {"ndcg@10": 0.295}}, cur) # −1.7% — допустимо
assert not should_activate({"als": {"ndcg@10": 0.28}}, cur)
assert should_activate({"users": 0}, cur) # не на чем проверить
assert should_activate({"als": {"ndcg@10": 0.1}}, None)
def test_engine_uses_cf_source() -> None:
n = 40
ids = [f"t{i}" for i in range(n)]
cf = [
np.array([1.0, 0.0], dtype=np.float32) if i < 20 else np.array([0.0, 1.0], dtype=np.float32)
for i in range(n)
]
index = CatalogIndex.build(
ids=ids, artist=[f"a{i}" for i in range(n)], raw_emb=[None] * n, features={}, plays=[0] * n, raw_cf=cf
)
profile = Profile(
scores={},
disliked=set(),
disliked_artists=set(),
recent=set(),
played=set(),
liked_recent=set(),
rediscover=set(),
exploration=0.0,
taste_cf=[1.0, 0.0],
)
picks = build_batch(index, profile, Session(), [], n=5, rng=np.random.default_rng(0))
assert len(picks) == 5
assert sum(int(p.id[1:]) < 20 for p in picks) >= 4 # почти всё — из «своей» половины по ALS
assert model_version(index, profile).endswith("+cf")