feat(recommender): LightGBM lambdarank ranker with impression features (M6)
ci / db / db (push) Failing after 1m15s
ci / api / api (push) Successful in 17s
ci / recommender / recommender (push) Successful in 32s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m41s

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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 22:41:26 +03:00
1 parent fb07f962eb
commit 58bd14ef38
12 files changed
+551 -14

No files matched your search

+32 -2
View File
@@ -2,18 +2,22 @@
from __future__ import annotations
import logging
from typing import Literal
import numpy as np
import psycopg
from pydantic import BaseModel, Field
from pipu_rec.train.ranker import RankerHolder
from pipu_rec.wave.contexts import Contexts, Rule
from pipu_rec.wave.engine import Pick, build_batch, model_version, taste_centroids, update_session
from pipu_rec.wave.engine import Context, Pick, build_batch, model_version, taste_centroids, update_session
from pipu_rec.wave.store import IndexHolder, SessionStore, load_profile, seed_tracks
MAX_SERVED_MEMORY = 300
log = logging.getLogger(__name__)
Outcome = Literal["complete", "skip", "skip_early", "like", "dislike"]
@@ -55,6 +59,12 @@ class WaveService:
self.index = IndexHolder(dsn)
self.sessions = SessionStore(redis_url)
self.contexts = contexts
self.ranker = RankerHolder(dsn)
def reload(self) -> None:
"""После ночного обучения: перечитать каталог (векторы ALS) и активный ранжировщик."""
self.index.refresh()
self.ranker.reload()
def rules(self, activity: str | None, mood: str | None) -> list[Rule]:
"""Занятие и настроение комбинируются — пересечение профилей; неизвестный ключ игнорируется."""
@@ -74,6 +84,7 @@ class WaveService:
update_session(
index, session, [(f.track_id, f.outcome) for f in req.feedback], taste_centroids(index, profile)
)
ranker = self.ranker.get()
picks: list[Pick] = build_batch(
index,
profile,
@@ -83,12 +94,31 @@ class WaveService:
exclude=set(req.exclude_ids),
seed_ids=seeds,
rng=np.random.default_rng(),
ranker=ranker,
ctx=Context(has_activity=bool(req.activity), has_mood=bool(req.mood)),
)
self.log_features(req.session_id, picks)
session.served = (session.served + [p.id for p in picks])[-MAX_SERVED_MEMORY:]
artists = [index.artist[index.pos[p.id]] for p in picks]
session.recent_artists = (session.recent_artists + artists)[-10:]
self.sessions.put(req.session_id, session)
return BatchResponse(
tracks=[BatchTrack(id=p.id, source=p.source, score=p.score) for p in picks],
model_ver=model_version(index, profile),
model_ver=model_version(index, profile, ranker),
)
def log_features(self, session_id: str, picks: list[Pick]) -> None:
"""Признаки показанных треков — обучающие данные ранжировщика v2. Сбой записи не ломает волну."""
rows = [(session_id, p.id, p.features) for p in picks if p.features is not None]
if not rows:
return
try:
with psycopg.connect(self.dsn, autocommit=True) as conn, conn.cursor() as cur:
cur.executemany(
"INSERT INTO rec_impression_features (wave_session_id, track_id, features) "
"VALUES (%s, %s, %s) "
"ON CONFLICT DO NOTHING",
rows,
)
except psycopg.Error as e:
log.warning("impression features not saved: %s", e)