feat(recommender): Thompson-sampling bandit over candidate sources (M6)
ci / recommender / recommender (push) Failing after 4s
ci / db / db (push) Failing after 2m21s

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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 22:31:24 +03:00
1 parent f7722e28ad
commit ff0aafdb1e
6 files changed
+130 -2

No files matched your search

@@ -93,6 +93,9 @@ class Profile:
rediscover: set[str] # любимые, не игравшие > 60 дней
exploration: float = 0.15
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)
@@ -113,6 +116,9 @@ class Pick:
score: float
# Вес бонуса бандита к оценке (θ − 0.5 ∈ [−0.5, 0.5]).
BANDIT_WEIGHT = 0.2
# Вклад реакции в сессионный вектор: s ← normalize(s + η·r·e).
REACTION_WEIGHT = {"complete": 1.0, "like": 2.0, "skip_early": -1.0, "dislike": -2.0, "skip": -0.5}
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])
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. Соответствие контексту; если отсеялось слишком много — порог ослабляется.
if rules:
+3 -1
View File
@@ -156,10 +156,11 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
)
}
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()
exploration = float(row[0]) if row else 0.15
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:
exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках
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},
exploration=exploration,
taste_cf=taste_cf.tolist() if taste_cf is not None else None,
bandit=bandit,
recent_likes=recent_likes,
)