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>
111 lines
4.4 KiB
Python
111 lines
4.4 KiB
Python
"""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")
|