feat(recommender): Thompson-sampling bandit over candidate sources (M6)
Nightly Beta(α, β) per user and source from wave impressions (reward: complete or like, ×0.99 daily decay) stored in user_profiles_rec.bandit; the wave adds 0.2·(θ − 0.5) per candidate source, sources without data stay neutral. The user's exploration share now follows how well `fresh` does against the other sources. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f7722e28ad
commit
ff0aafdb1e
6 files changed
+130
-2
No files matched your search
@@ -191,6 +191,11 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
|
|||||||
что одному пользователю лучше заходит `fresh`, а другому — `rediscover`. Параметры затухают
|
что одному пользователю лучше заходит `fresh`, а другому — `rediscover`. Параметры затухают
|
||||||
(×0.99 в день), чтобы подстраиваться под смену вкуса.
|
(×0.99 в день), чтобы подстраиваться под смену вкуса.
|
||||||
|
|
||||||
|
Реализация (`pipu_rec/train/bandit.py`): параметры пересчитываются ночью по `wave_impressions` за 90 дней
|
||||||
|
(награда — `complete` / `like`) в `user_profiles_rec.bandit`; при сборке пачки источник получает бонус
|
||||||
|
`0.2·(θ − 0.5)`, где θ ~ Beta(α, β) (источник без наблюдений — нейтральные 0.5). `exploration` следует за
|
||||||
|
успехом `fresh` относительно остальных источников (0.05–0.4).
|
||||||
|
|
||||||
### 6.7 Реакция в реальном времени
|
### 6.7 Реакция в реальном времени
|
||||||
|
|
||||||
- Клиент держит в очереди 2 трека вперёд и запрашивает новую пачку, когда осталось ≤ 2.
|
- Клиент держит в очереди 2 трека вперёд и запрашивает новую пачку, когда осталось ≤ 2.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Бандит по источникам кандидатов (docs/recommendations.md#6.6): Beta(α, β) на пару (пользователь, источник).
|
||||||
|
|
||||||
|
Награда — трек из источника доигран или лайкнут; скип и дизлайк — неудача. Наблюдения затухают ×0.99 в день,
|
||||||
|
чтобы бандит подстраивался под смену вкуса. Доля «нового» (exploration) следует за успехом источника `fresh`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
DAILY_DECAY = 0.99
|
||||||
|
REWARD = {"complete": 1.0, "like": 1.0, "skip": 0.0, "skip_early": 0.0, "dislike": 0.0}
|
||||||
|
EXPLORATION_MIN, EXPLORATION_MAX = 0.05, 0.4
|
||||||
|
|
||||||
|
Params = dict[str, list[float]] # источник → [α, β]
|
||||||
|
|
||||||
|
|
||||||
|
def compute(rows: list[tuple[str, str, str, float]], now: float) -> dict[str, Params]:
|
||||||
|
"""rows: (user, source, outcome, served_at) → user → source → [α, β] с приором Beta(1, 1)."""
|
||||||
|
out: dict[str, Params] = defaultdict(dict)
|
||||||
|
for user, source, outcome, ts in rows:
|
||||||
|
r = REWARD.get(outcome)
|
||||||
|
if r is None:
|
||||||
|
continue
|
||||||
|
w = DAILY_DECAY ** max((now - ts) / 86400.0, 0.0)
|
||||||
|
ab = out[user].setdefault(source, [1.0, 1.0])
|
||||||
|
ab[0] += w * r
|
||||||
|
ab[1] += w * (1.0 - r)
|
||||||
|
return {u: {s: [round(a, 4), round(b, 4)] for s, (a, b) in p.items()} for u, p in out.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def exploration(params: Params, default: float = 0.15) -> float:
|
||||||
|
"""Доля «нового»: успешнее `fresh` относительно остальных источников — больше исследования."""
|
||||||
|
fresh = params.get("fresh")
|
||||||
|
others = [a / (a + b) for s, (a, b) in params.items() if s != "fresh"]
|
||||||
|
if fresh is None or not others:
|
||||||
|
return default
|
||||||
|
rate = fresh[0] / (fresh[0] + fresh[1])
|
||||||
|
base = sum(others) / len(others)
|
||||||
|
value = default * (rate / base if base > 0 else 1.0)
|
||||||
|
return round(min(max(value, EXPLORATION_MIN), EXPLORATION_MAX), 3)
|
||||||
|
|
||||||
|
|
||||||
|
def update_bandits(conn: psycopg.Connection[Any], now: float) -> int:
|
||||||
|
"""Пересчитывает параметры по показам волны за 90 дней. Возвращает число пользователей."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ws.user_id::text, wi.candidate_source, wi.outcome, extract(epoch FROM wi.served_at)::float8
|
||||||
|
FROM wave_impressions wi JOIN wave_sessions ws ON ws.id = wi.wave_session_id
|
||||||
|
WHERE wi.outcome IS NOT NULL AND wi.served_at > now() - interval '90 days'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
params = compute([(u, s, o, float(ts)) for u, s, o, ts in rows], now)
|
||||||
|
with conn.transaction(), conn.cursor() as cur:
|
||||||
|
cur.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_profiles_rec (user_id, bandit, exploration, updated_at)
|
||||||
|
VALUES (%s, %s, %s, now())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET bandit = EXCLUDED.bandit, exploration = EXCLUDED.exploration,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
[(u, json.dumps(p), exploration(p)) for u, p in params.items()],
|
||||||
|
)
|
||||||
|
return len(params)
|
||||||
@@ -15,6 +15,7 @@ import numpy as np
|
|||||||
import psycopg
|
import psycopg
|
||||||
|
|
||||||
from pipu_rec.train.als import ALSModel, normalize, train_als
|
from pipu_rec.train.als import ALSModel, normalize, train_als
|
||||||
|
from pipu_rec.train.bandit import update_bandits
|
||||||
from pipu_rec.train.metrics import evaluate
|
from pipu_rec.train.metrics import evaluate
|
||||||
from pipu_rec.train.signals import DISLIKE, LIKE, Interaction, event_weight, implicit_scores
|
from pipu_rec.train.signals import DISLIKE, LIKE, Interaction, event_weight, implicit_scores
|
||||||
|
|
||||||
@@ -157,9 +158,11 @@ def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
|
|||||||
if not conn.execute("SELECT pg_try_advisory_lock(%s)", (ADVISORY_LOCK,)).fetchone()[0]: # type: ignore[index]
|
if not conn.execute("SELECT pg_try_advisory_lock(%s)", (ADVISORY_LOCK,)).fetchone()[0]: # type: ignore[index]
|
||||||
return {"skipped": "another training is running"}
|
return {"skipped": "another training is running"}
|
||||||
try:
|
try:
|
||||||
|
# Бандит считается всегда — ему хватает и пары сессий волны, в отличие от ALS.
|
||||||
|
bandit_users = update_bandits(conn, now)
|
||||||
interactions = load_interactions(conn)
|
interactions = load_interactions(conn)
|
||||||
if len(interactions) < MIN_INTERACTIONS:
|
if len(interactions) < MIN_INTERACTIONS:
|
||||||
return {"skipped": f"only {len(interactions)} interactions"}
|
return {"skipped": f"only {len(interactions)} interactions", "bandit_users": bandit_users}
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
offline = offline_metrics(interactions, now)
|
offline = offline_metrics(interactions, now)
|
||||||
model = train_als(implicit_scores(interactions, now))
|
model = train_als(implicit_scores(interactions, now))
|
||||||
@@ -171,6 +174,7 @@ def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
|
|||||||
"users": len(model.users),
|
"users": len(model.users),
|
||||||
"items": len(model.items),
|
"items": len(model.items),
|
||||||
"interactions": len(interactions),
|
"interactions": len(interactions),
|
||||||
|
"bandit_users": bandit_users,
|
||||||
"train_s": round(time.monotonic() - started, 1),
|
"train_s": round(time.monotonic() - started, 1),
|
||||||
}
|
}
|
||||||
if not should_activate(offline, active_metrics(conn, "als")):
|
if not should_activate(offline, active_metrics(conn, "als")):
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ class Profile:
|
|||||||
rediscover: set[str] # любимые, не игравшие > 60 дней
|
rediscover: set[str] # любимые, не игравшие > 60 дней
|
||||||
exploration: float = 0.15
|
exploration: float = 0.15
|
||||||
taste_cf: list[float] | None = None # фактор пользователя ALS
|
taste_cf: list[float] | None = None # фактор пользователя ALS
|
||||||
|
bandit: dict[str, list[float]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
) # источник → [α, β] (docs/recommendations.md#6.6)
|
||||||
recent_likes: list[str] = field(default_factory=list) # последние лайки, новые первыми (для item_cf)
|
recent_likes: list[str] = field(default_factory=list) # последние лайки, новые первыми (для item_cf)
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +116,9 @@ class Pick:
|
|||||||
score: float
|
score: float
|
||||||
|
|
||||||
|
|
||||||
|
# Вес бонуса бандита к оценке (θ − 0.5 ∈ [−0.5, 0.5]).
|
||||||
|
BANDIT_WEIGHT = 0.2
|
||||||
|
|
||||||
# Вклад реакции в сессионный вектор: s ← normalize(s + η·r·e).
|
# Вклад реакции в сессионный вектор: s ← normalize(s + η·r·e).
|
||||||
REACTION_WEIGHT = {"complete": 1.0, "like": 2.0, "skip_early": -1.0, "dislike": -2.0, "skip": -0.5}
|
REACTION_WEIGHT = {"complete": 1.0, "like": 2.0, "skip_early": -1.0, "dislike": -2.0, "skip": -0.5}
|
||||||
ETA = 0.3
|
ETA = 0.3
|
||||||
@@ -284,6 +290,13 @@ def build_batch(
|
|||||||
)
|
)
|
||||||
liked_recent = np.array([index.ids[i] in profile.liked_recent for i in cand])
|
liked_recent = np.array([index.ids[i] in profile.liked_recent for i in cand])
|
||||||
score = score - 0.2 * liked_recent
|
score = score - 0.2 * liked_recent
|
||||||
|
# Бандит: семплирование Томпсона по источникам — удачные для пользователя источники получают бонус.
|
||||||
|
# Источник без наблюдений — нейтральные 0.5 (без шума, иначе холодный старт ранжировался бы случайно).
|
||||||
|
theta: dict[str, float] = {}
|
||||||
|
for src in set(sources.values()):
|
||||||
|
ab = profile.bandit.get(src)
|
||||||
|
theta[src] = float(rng.beta(ab[0], ab[1])) if ab else 0.5
|
||||||
|
score = score + BANDIT_WEIGHT * (np.array([theta[sources[int(i)]] for i in cand]) - 0.5)
|
||||||
|
|
||||||
# 4. Соответствие контексту; если отсеялось слишком много — порог ослабляется.
|
# 4. Соответствие контексту; если отсеялось слишком много — порог ослабляется.
|
||||||
if rules:
|
if rules:
|
||||||
|
|||||||
@@ -156,10 +156,11 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT exploration, taste_cf::text FROM user_profiles_rec WHERE user_id = %s", (user_id,)
|
"SELECT exploration, taste_cf::text, bandit FROM user_profiles_rec WHERE user_id = %s", (user_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
exploration = float(row[0]) if row else 0.15
|
exploration = float(row[0]) if row else 0.15
|
||||||
taste_cf = parse_vector(row[1]) if row else None
|
taste_cf = parse_vector(row[1]) if row else None
|
||||||
|
bandit = {str(k): [float(x) for x in v] for k, v in (row[2] or {}).items()} if row else {}
|
||||||
if len(played) < 30:
|
if len(played) < 30:
|
||||||
exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках
|
exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках
|
||||||
day = 86400
|
day = 86400
|
||||||
@@ -173,6 +174,7 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
|
|||||||
rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day},
|
rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day},
|
||||||
exploration=exploration,
|
exploration=exploration,
|
||||||
taste_cf=taste_cf.tolist() if taste_cf is not None else None,
|
taste_cf=taste_cf.tolist() if taste_cf is not None else None,
|
||||||
|
bandit=bandit,
|
||||||
recent_likes=recent_likes,
|
recent_likes=recent_likes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -116,3 +116,39 @@ def test_cf_vectors_are_padded_to_column_size() -> None:
|
|||||||
v = np.ones((3, 5), dtype=np.float32)
|
v = np.ones((3, 5), dtype=np.float32)
|
||||||
assert pad(v).shape == (3, CF_DIM)
|
assert pad(v).shape == (3, CF_DIM)
|
||||||
assert pad(np.ones((2, 80), dtype=np.float32)).shape == (2, CF_DIM)
|
assert pad(np.ones((2, 80), dtype=np.float32)).shape == (2, CF_DIM)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bandit_params_and_exploration() -> None:
|
||||||
|
from pipu_rec.train.bandit import compute, exploration
|
||||||
|
|
||||||
|
now = 100 * DAY
|
||||||
|
rows = [("u", "fresh", "complete", now)] * 8 + [("u", "fresh", "skip", now)] * 2
|
||||||
|
rows += [("u", "audio_sim", "complete", now)] * 2 + [("u", "audio_sim", "skip_early", now)] * 8
|
||||||
|
rows += [("u", "cf", "complete", now - 69 * DAY)] # затухание: ~0.5
|
||||||
|
p = compute(rows, now)["u"]
|
||||||
|
assert p["fresh"] == [9.0, 3.0]
|
||||||
|
assert p["audio_sim"] == [3.0, 9.0]
|
||||||
|
assert 1.4 < p["cf"][0] < 1.6
|
||||||
|
assert exploration(p) > 0.15 # «новое» заходит лучше остального — исследования больше
|
||||||
|
assert exploration({"fresh": [1.0, 9.0], "audio_sim": [9.0, 1.0]}) == 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def test_bandit_favours_successful_source() -> None:
|
||||||
|
n = 60
|
||||||
|
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
|
||||||
|
)
|
||||||
|
good = Profile(
|
||||||
|
scores={},
|
||||||
|
disliked=set(),
|
||||||
|
disliked_artists=set(),
|
||||||
|
recent=set(),
|
||||||
|
played=set(),
|
||||||
|
liked_recent=set(),
|
||||||
|
rediscover=set(),
|
||||||
|
exploration=0.0,
|
||||||
|
bandit={"popular": [50.0, 1.0], "explore": [1.0, 50.0]},
|
||||||
|
)
|
||||||
|
picks = build_batch(index, good, Session(), [], n=5, rng=np.random.default_rng(1))
|
||||||
|
assert sum(p.source == "popular" for p in picks) >= 4
|
||||||
Reference in new issue
Block a user