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

+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")