feat(recommender): LightGBM lambdarank ranker with impression features (M6)
The wave logs each served candidate's feature vector to rec_impression_features. Nightly training joins them with impression outcomes, validates on the last week and activates the model only when it beats the v1 order and the active model. The service loads the active ranker and uses its order in place of the v1 score. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fb07f962eb
commit
58bd14ef38
12 files changed
+551
-14
No files matched your search
@@ -152,3 +152,60 @@ def test_bandit_favours_successful_source() -> None:
|
||||
)
|
||||
picks = build_batch(index, good, Session(), [], n=5, rng=np.random.default_rng(1))
|
||||
assert sum(p.source == "popular" for p in picks) >= 4
|
||||
|
||||
|
||||
def test_ranker_learns_what_v1_misses() -> None:
|
||||
from pipu_rec.train.ranker import V1_COLUMN, should_activate, train
|
||||
from pipu_rec.wave.engine import FEATURE_NAMES
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
now = 200 * DAY
|
||||
rows = []
|
||||
col = FEATURE_NAMES.index("sim_session")
|
||||
for g in range(400):
|
||||
ts = now - (1 if g >= 320 else 30) * DAY # последние сессии — отложенная неделя
|
||||
for _ in range(8):
|
||||
x = rng.random(len(FEATURE_NAMES)).tolist()
|
||||
# Исход определяется sim_session, а оценка v1 в признаках — случайный шум.
|
||||
outcome = "like" if x[col] > 0.85 else "complete" if x[col] > 0.5 else "skip_early"
|
||||
x[V1_COLUMN] = float(rng.random())
|
||||
rows.append((f"s{g}", x, outcome, ts))
|
||||
booster, m = train(rows, now, min_rows=100)
|
||||
assert booster is not None, m
|
||||
assert m["ndcg@10"] > m["ndcg@10_v1"] + 0.1, m
|
||||
assert should_activate(m, None)
|
||||
assert not should_activate({"ndcg@10": 0.5, "ndcg@10_v1": 0.6}, None)
|
||||
assert not should_activate({"ndcg@10": 0.7, "ndcg@10_v1": 0.6}, {"ndcg@10": 0.8})
|
||||
_, few = train(rows[:50], now)
|
||||
assert "skipped" in few
|
||||
|
||||
|
||||
def test_engine_uses_ranker_and_logs_features() -> None:
|
||||
from pipu_rec.wave.engine import FEATURE_NAMES
|
||||
|
||||
n = 40
|
||||
ids = [f"t{i}" 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
|
||||
)
|
||||
profile = Profile(
|
||||
scores={},
|
||||
disliked=set(),
|
||||
disliked_artists=set(),
|
||||
recent=set(),
|
||||
played=set(),
|
||||
liked_recent=set(),
|
||||
rediscover=set(),
|
||||
exploration=0.0,
|
||||
)
|
||||
|
||||
class LastFirst:
|
||||
version = "fake-ranker"
|
||||
|
||||
def predict(self, x: np.ndarray) -> np.ndarray:
|
||||
return -x[:, FEATURE_NAMES.index("popularity")] + np.arange(len(x)) * 1e-3
|
||||
|
||||
picks = build_batch(index, profile, Session(), [], n=5, rng=np.random.default_rng(0), ranker=LastFirst())
|
||||
assert len(picks) == 5
|
||||
assert all(p.features is not None and len(p.features) == len(FEATURE_NAMES) for p in picks)
|
||||
assert model_version(index, profile, LastFirst()) == "fake-ranker"
|
||||
Reference in new issue
Block a user