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
@@ -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