feat(recommender): nightly ALS with offline metrics and CF candidates in the wave (M6)
ci / recommender / recommender (push) Successful in 37s
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:
1 parent
a3f212d253
commit
3afed02f23
12 files changed
+623
-8
No files matched your search
@@ -10,6 +10,8 @@ from numpy.typing import NDArray
|
||||
from pipu_rec.wave.contexts import FEATURES, Rule, fit
|
||||
|
||||
MODEL_VER = "heuristic-v1"
|
||||
# С коллаборативным сигналом (ALS) — суффикс к версии модели в показах.
|
||||
CF_SUFFIX = "+cf"
|
||||
|
||||
Vec = NDArray[np.float32]
|
||||
|
||||
@@ -24,10 +26,15 @@ class CatalogIndex:
|
||||
has_emb: NDArray[np.bool_]
|
||||
features: dict[str, NDArray[np.float64]] # NaN — нет признака
|
||||
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)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
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:
|
||||
return len(self.ids)
|
||||
@@ -40,6 +47,7 @@ class CatalogIndex:
|
||||
features: dict[str, list[float | None]],
|
||||
plays: list[int],
|
||||
dim: int = 256,
|
||||
raw_cf: list[Vec | None] | None = None,
|
||||
) -> CatalogIndex:
|
||||
n = len(ids)
|
||||
emb = np.zeros((n, dim), dtype=np.float32)
|
||||
@@ -59,7 +67,17 @@ class CatalogIndex:
|
||||
}
|
||||
p = np.log1p(np.array(plays, dtype=np.float64))
|
||||
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
|
||||
@@ -74,6 +92,8 @@ class Profile:
|
||||
liked_recent: set[str] # лайкнутые, игравшие за 24 ч — штраф
|
||||
rediscover: set[str] # любимые, не игравшие > 60 дней
|
||||
exploration: float = 0.15
|
||||
taste_cf: list[float] | None = None # фактор пользователя ALS
|
||||
recent_likes: list[str] = field(default_factory=list) # последние лайки, новые первыми (для item_cf)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -145,6 +165,21 @@ def update_session(
|
||||
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]:
|
||||
idx = np.nonzero(mask)[0]
|
||||
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_taste = np.where(index.has_emb, sim_taste, 0.0)
|
||||
sim_session = np.where(index.has_emb, sim_session, 0.0)
|
||||
cf_score = cf_scores(index, profile)
|
||||
|
||||
# Привязанность к исполнителю — сглаженное среднее оценок его треков.
|
||||
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])
|
||||
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")
|
||||
# Коллективный опыт (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}
|
||||
if 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.15 * artist_aff[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)) # разбивает ничьи, особенно при холодном старте
|
||||
)
|
||||
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 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
|
||||
|
||||
MAX_SERVED_MEMORY = 300
|
||||
@@ -89,5 +89,6 @@ class WaveService:
|
||||
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_VER
|
||||
tracks=[BatchTrack(id=p.id, source=p.source, score=p.score) for p in picks],
|
||||
model_ver=model_version(index, profile),
|
||||
)
|
||||
@@ -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),
|
||||
al.album_artist_id::text),
|
||||
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
|
||||
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
|
||||
@@ -60,6 +61,7 @@ def load_index(conn: psycopg.Connection[Any]) -> CatalogIndex:
|
||||
raw_emb=[parse_vector(r[2]) for r in rows],
|
||||
features=feats,
|
||||
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)
|
||||
disliked: set[str] = set()
|
||||
liked: set[str] = set()
|
||||
recent_likes: list[str] = []
|
||||
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":
|
||||
liked.add(tid)
|
||||
recent_likes.append(tid)
|
||||
scores[tid] = scores.get(tid, 0.0) + 3.0
|
||||
else:
|
||||
disliked.add(tid)
|
||||
@@ -149,8 +155,11 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
|
||||
(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
|
||||
taste_cf = parse_vector(row[1]) if row else None
|
||||
if len(played) < 30:
|
||||
exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках
|
||||
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},
|
||||
rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day},
|
||||
exploration=exploration,
|
||||
taste_cf=taste_cf.tolist() if taste_cf is not None else None,
|
||||
recent_likes=recent_likes,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user