chore(edge): skrypty odtworzenia i oceny punktacji feedu

edge_replay_features.py liczy na prodzie cechy znane w chwili kazdego
wyswietlenia, edge_tuning_analysis.py ocenia formuly (AUC w obrebie
urzadzenia, podzial w czasie). Na nich dobrana punktacja z 4666fcb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-10-02 11:48:17 +02:00
parent 6c2ebeecf5
commit 251032a13b
2 changed files with 177 additions and 0 deletions

View file

@ -0,0 +1,95 @@
"""Odtworzenie uczenia feedu Edge zdarzenie po zdarzeniu (bez zagladania w przyszlosc).
Dla kazdego wyswietlenia (complete/skip/impression/autoswipe) zapisuje cechy znane W TEJ
CHWILI: skladniki obecnej punktacji z wag urzadzenia sprzed zdarzenia oraz liczniki klipu
z wczesniejszych zdarzen wszystkich urzadzen. Wynik CSV na stdout.
Uzyte do przestrojenia punktacji 2026-10-02 (patrz app/clips_feed.py, score_candidate).
Wybor tagow na starcie (onboarding) nie jest zdarzeniem, wiec tu go brakuje.
docker compose exec -T worker sh -c "cd /srv && python3 scripts/edge_replay_features.py | gzip -c" > edge.csv.gz
Analiza lokalnie: python scripts/edge_tuning_analysis.py edge.csv (pandas + scikit-learn).
"""
import csv
import math
import sys
from collections import defaultdict
from datetime import timedelta
from sqlalchemy import text
from app import clips_feed as cf
from app.db import session_scope
with session_scope() as s:
ev = s.execute(text("""
select device_id, clip_id, kind, dwell_ms, reason, slot, created_at
from clip_feed_events where clip_id is not null order by created_at""")).all()
ids = list({e.clip_id for e in ev})
clips = {}
for i in range(0, len(ids), 5000):
for r in s.execute(text("""select id, creator, likes, published_at, niches, duration_sec, width, height,
has_audio, creator_verified
from clips where id = any(:ids)"""), {"ids": ids[i:i + 5000]}).all():
clips[r.id] = r
tags = defaultdict(list)
for i in range(0, len(ids), 5000):
for cid, t in s.execute(text("select clip_id, tag from clip_tags where clip_id = any(:ids)"), {"ids": ids[i:i + 5000]}).all():
tags[cid].append(t)
n_all = s.execute(text("select count(*) from clips")).scalar()
tag_df = dict(s.execute(text("select tag, count(*) from clip_tags group by tag")).all())
idf = {t: math.log((n_all + 1) / (n + 1)) for t, n in tag_df.items()}
print(f"zdarzen {len(ev)}, klipow {len(clips)}, tagow {len(idf)}", file=sys.stderr)
aff = defaultdict(cf.Affinity)
last_day = {}
dev_n = defaultdict(int)
# liczniki klipu z wczesniejszych zdarzen (wszystkie urzadzenia)
c_seen = defaultdict(int)
c_comp = defaultdict(int)
c_skip = defaultdict(int)
c_like = defaultdict(int)
liked = set((e.device_id, e.clip_id) for e in ev if e.kind == "like")
w = csv.writer(sys.stdout)
w.writerow(["dev", "ts", "slot", "kind", "dwell", "liked", "tag_sum", "tag_pos", "tag_n", "niche_sum", "creator_w",
"tag_idf_sum", "tag_idf_pos", "pop", "fresh", "dur", "vertical", "audio", "verified", "c_seen", "c_comp", "c_skip", "c_like", "dev_n"])
OUTCOME = {"complete", "skip", "impression", "autoswipe"}
for e in ev:
c = clips.get(e.clip_id)
if c is None:
continue
a = aff[e.device_id]
day = e.created_at.date()
prev = last_day.get(e.device_id)
if prev is not None and day > prev:
for _ in range((day - prev).days):
a.tags = cf.decay_weights(a.tags)
a.niches = cf.decay_weights(a.niches)
a.creators = cf.decay_weights(a.creators)
last_day[e.device_id] = day
t = tags.get(e.clip_id, [])
niches = list(c.niches or [])
if e.kind in OUTCOME:
ts_vals = [a.tags.get(x, 0.0) for x in t]
fresh = int(bool(c.published_at and c.published_at >= e.created_at - timedelta(days=cf.FRESH_DAYS)))
vert = int(bool(c.width and c.height and c.height > c.width))
w.writerow([e.device_id[:12], e.created_at.isoformat(), e.slot or "", e.kind, e.dwell_ms or 0,
int((e.device_id, e.clip_id) in liked),
round(sum(ts_vals), 3), sum(1 for v in ts_vals if v > 0), len(t),
round(sum(a.niches.get(n, 0.0) for n in niches), 3),
round(a.creators.get(c.creator or "", 0.0), 3),
round(sum(a.tags.get(x, 0.0) * idf.get(x, 0.0) for x in t), 3),
round(sum(idf.get(x, 0.0) for x in t if a.tags.get(x, 0.0) > 0), 3),
round(math.log1p(max(c.likes or 0, 0)), 3), fresh, round(c.duration_sec or 0, 1), vert,
int(bool(c.has_audio)), int(bool(c.creator_verified)),
c_seen[e.clip_id], c_comp[e.clip_id], c_skip[e.clip_id], c_like[e.clip_id], dev_n[e.device_id]])
c_seen[e.clip_id] += 1
if e.kind == "complete":
c_comp[e.clip_id] += 1
elif e.kind == "skip":
c_skip[e.clip_id] += 1
dev_n[e.device_id] += 1
elif e.kind == "like":
c_like[e.clip_id] += 1
cf.apply_event(a, kind=e.kind, dwell_ms=e.dwell_ms, reason=e.reason, tags=t, niches=niches, creator=c.creator)

View file

@ -0,0 +1,82 @@
"""Ocena punktacji feedu Edge na cechach z scripts/edge_replay_features.py.
Trening na 70% najstarszych wyswietlen, test na reszcie. Glowna miara: AUC W OBREBIE
URZADZENIA (czy wyzszy wynik u tego samego urzadzenia znaczy lepsza reakcje), bo globalne
AUC miesza aktywnych z nowymi. Dwie etykiety: lajk albo >= 10 s ogladania (odporna na
dlugosc klipu) oraz dokonczenie albo lajk. F5 to formula wdrozona 2026-10-02.
Wymaga pandas i scikit-learn (lokalnie, nie w kontenerze).
"""
import sys
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
df = pd.read_csv(sys.argv[1], parse_dates=["ts"])
A = 10.0
P_LIKE, P_SKIP = 0.03, 0.5
df["like_rate"] = (df.c_like + A * P_LIKE) / (df.c_seen + A)
df["skip_rate"] = (df.c_skip + A * P_SKIP) / (df.c_seen + A)
df["popul"] = df["pop"]
slog = lambda x: np.sign(x) * np.log1p(np.abs(x)) # noqa: E731
for c in ["tag_sum", "tag_idf_sum", "creator_w", "niche_sum"]:
df["s_" + c] = slog(df[c])
def within(d, col):
num = den = 0.0
for _, g in d.groupby("dev"):
if len(g) < 30 or g.y.nunique() < 2 or g[col].nunique() < 2:
continue
num += roc_auc_score(g.y, g[col]) * len(g)
den += len(g)
return num / den if den else float("nan")
def labelled(kind):
if kind == "dwell":
good = (df.liked == 1) | (df.dwell >= 10000)
else:
good = (df.kind == "complete") | (df.liked == 1)
bad = df.kind == "skip"
d = df[good | bad].copy()
d["y"] = good[good | bad].astype(int)
return d.sort_values("ts")
def cur(d):
return d.tag_sum + 2 * d.niche_sum + 3 * d.creator_w + 0.3 * d.popul + 0.5 * d.fresh
def formula(d, w):
return (w["tag"] * d.s_tag_idf_sum + w["creator"] * d.s_creator_w + w["pop"] * d.popul
+ w["like"] * (d.like_rate - P_LIKE) - w["skip"] * (d.skip_rate - P_SKIP))
for kind in ("dwell", "complete"):
d = labelled(kind)
cut = int(len(d) * 0.7)
tr, te = d.iloc[:cut].copy(), d.iloc[cut:].copy()
print(f"\n===== etykieta {kind}: trening {len(tr)}, test {len(te)}")
print(" AUC w obrebie urzadzenia (test):")
for f in ["s_tag_sum", "s_tag_idf_sum", "tag_idf_pos", "s_creator_w", "s_niche_sum", "popul", "like_rate"]:
print(f" {f:14s} {within(te, f):.3f}")
cols = ["s_tag_idf_sum", "s_creator_w", "s_niche_sum", "popul", "like_rate", "skip_rate"]
m = LogisticRegression(max_iter=3000).fit(tr[cols], tr.y)
coef = dict(zip(cols, m.coef_[0]))
base = coef["s_tag_idf_sum"]
print(" wspolczynniki w jednostkach tagu:", {c: round(v / base, 2) for c, v in coef.items()})
te["cur"] = cur(te)
te["model"] = m.decision_function(te[cols])
for name, w in {
"F1": {"tag": 1.0, "creator": 1.0, "pop": 0.3, "like": 20.0, "skip": 2.0},
"F2": {"tag": 1.0, "creator": 1.5, "pop": 0.5, "like": 30.0, "skip": 3.0},
"F3": {"tag": 1.0, "creator": 1.0, "pop": 0.5, "like": 40.0, "skip": 4.0},
"F4": {"tag": 1.0, "creator": 2.5, "pop": 0.8, "like": 120.0, "skip": 15.0},
}.items():
te[name] = formula(te, w)
print(" porownanie (test): AUC globalnie / w obrebie urzadzenia")
te["F5"] = formula(te, {"tag": 1.0, "creator": 2.5, "pop": 0.8, "like": 120.0, "skip": 15.0}) + 0.25 * te.s_niche_sum + 0.3 * te.fresh
for f in ["cur", "model", "F1", "F2", "F3", "F4", "F5"]:
print(f" {f:6s} {roc_auc_score(te.y, te[f]):.3f} / {within(te, f):.3f}")