feat(edge): nowa punktacja feedu z historii klipu u nas, 25% w grupie kontrolnej

Odtworzenie uczenia na 189 tys. zdarzen (wagi zdarzenie po zdarzeniu, bez
zagladania w przyszlosc, test na 30% najnowszych): stara punktacja porzadkowala
klipy w obrebie urzadzenia z AUC 0,576 (lajk albo >= 10 s vs ucieczka < 2 s).
Tagi RedGifs sa liczne i ogolne, wiec ich suma dominowala, a mowila malo; nisze
prawie nic, popularnosc z RedGifs slabo, tworca i przyjecie klipu u nas najwiecej.

Nowa: log(waga tagu x rzadkosc tagu) + 0,25 log(nisze) + 2,5 log(tworca)
+ 0,8 ln(1+lajki RedGifs) + 120 (odsetek lajkow u nas - 3%) - 15 (odsetek
ucieczek - 50%) + 0,3 za swiezosc. Na tescie 0,652, regresja na tych samych
cechach 0,654. Liczniki clips.feed_views/skips/likes (migracja 0052, wypelnione
z historii), bumpowane w POST /clips/events. 25% urzadzen (hash id) zostaje na
starej punktacji do porownania na zywym ruchu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-10-02 11:47:02 +02:00
parent 617dda0bbe
commit 4666fcb04a
5 changed files with 241 additions and 8 deletions

View file

@ -0,0 +1,55 @@
"""Liczniki klipu z feedu Edge: wyswietlenia, ucieczki (< 2 s) i lajki w samym Goonie.
Odtworzenie uczenia feedu na 189 tys. zdarzen (2026-10-02) pokazalo, ze najmocniejszym
sygnalem tego, czy klip sie spodoba, jest to, jak przyjeli go inni u nas: odsetek lajkow
(AUC w obrebie urzadzenia 0,600) bije wagi tagow (0,571), a popularnosc z RedGifs prawie
nic nie mowi (0,532). Punktacja (app/clips_feed.py) potrzebuje tych liczb w chwili
ukladania strony, stad kolumny przy klipie zamiast agregatu po clip_feed_events.
ADD COLUMN ze stalym DEFAULT nie przepisuje tabeli (Postgres 11+). Wypelnienie z
dotychczasowych zdarzen jednym UPDATE (~70 tys. klipow).
Revision ID: 0052_clips_feed_counters
Revises: 0051_movies_tpdb_checked_at
Create Date: 2026-10-02
"""
from __future__ import annotations
from alembic import op
revision = "0052_clips_feed_counters"
down_revision: str | None = "0051_movies_tpdb_checked_at"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("""
ALTER TABLE clips
ADD COLUMN IF NOT EXISTS feed_views integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS feed_skips integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS feed_likes integer NOT NULL DEFAULT 0
""")
op.execute("""
UPDATE clips c SET feed_views = a.v, feed_skips = a.s, feed_likes = greatest(a.l, 0)
FROM (
SELECT clip_id,
count(*) FILTER (WHERE kind IN ('complete', 'skip', 'impression', 'autoswipe')) AS v,
count(*) FILTER (WHERE kind = 'skip') AS s,
count(*) FILTER (WHERE kind = 'like') - count(*) FILTER (WHERE kind = 'unlike') AS l
FROM clip_feed_events WHERE clip_id IS NOT NULL
GROUP BY clip_id
) a
WHERE a.clip_id = c.id
""")
def downgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("""
ALTER TABLE clips
DROP COLUMN IF EXISTS feed_views,
DROP COLUMN IF EXISTS feed_skips,
DROP COLUMN IF EXISTS feed_likes
""")

View file

@ -17,6 +17,7 @@ bez image proxy: RedGifs nie sprawdza Referera, a proxy tylko dodałoby VPS do t
from __future__ import annotations
import logging
import math
import threading
import time
import uuid
@ -44,6 +45,8 @@ _AFFINITY_TAGS, _AFFINITY_NICHES, _AFFINITY_CREATORS = 6, 3, 3
_PER_TAG, _PER_NICHE, _PER_CREATOR = 60, 60, 40
_UUID_ZERO = uuid.UUID(int=0)
_BLACKLIST_KINDS = frozenset({"tag", "niche", "creator"})
#: Zdarzenia oznaczajace, ze klip zostal obejrzany (zejscie ze strony), do clips.feed_views.
_VIEW_KINDS = frozenset({"complete", "skip", "impression", "autoswipe"})
# ---- schematy ---------------------------------------------------------------------------
@ -251,7 +254,10 @@ def _visible_clauses(device_id: str, bl: dict[str, set[str]]) -> list:
return clauses
_CAND_COLS = (Clip.id, Clip.creator, Clip.likes, Clip.published_at, Clip.niches)
_CAND_COLS = (
Clip.id, Clip.creator, Clip.likes, Clip.published_at, Clip.niches,
Clip.feed_views, Clip.feed_skips, Clip.feed_likes,
)
def _rows_to_candidates(session: Session, rows, *, with_tags: bool) -> list[cf.Candidate]:
@ -259,7 +265,10 @@ def _rows_to_candidates(session: Session, rows, *, with_tags: bool) -> list[cf.C
wyłącznie lajkami, więc ciągnięcie tagów dla 300 kandydatów było czystym kosztem."""
tags = _tags_for(session, [r[0] for r in rows]) if with_tags else {}
return [
cf.Candidate(id=r[0], creator=r[1], likes=r[2], published_at=r[3], tags=tags.get(r[0], []), niches=list(r[4] or []))
cf.Candidate(
id=r[0], creator=r[1], likes=r[2], published_at=r[3], tags=tags.get(r[0], []), niches=list(r[4] or []),
feed_views=r[5] or 0, feed_skips=r[6] or 0, feed_likes=r[7] or 0,
)
for r in rows
]
@ -294,6 +303,29 @@ def _explore_candidates(
return _rows_to_candidates(session, rows, with_tags=False), (end_id if not last else _UUID_ZERO), last
# Rzadkosc tagow (IDF) do punktacji: ln(liczba klipow / klipy z tagiem). Liczenie to jedno
# GROUP BY po ~1,7 mln clip_tags (~1 s), slownik zmienia sie wolno, wiec cache 6 h w procesie.
_IDF_TTL_SEC = 6 * 3600
_idf_cache: tuple[float, dict[str, float]] | None = None
_idf_lock = threading.Lock()
def _tag_idf(session: Session) -> dict[str, float]:
global _idf_cache
hit = _idf_cache
if hit is not None and time.monotonic() - hit[0] < _IDF_TTL_SEC:
return hit[1]
with _idf_lock:
hit = _idf_cache
if hit is not None and time.monotonic() - hit[0] < _IDF_TTL_SEC:
return hit[1]
n_all = session.execute(select(func.count()).select_from(Clip)).scalar_one()
rows = session.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 rows}
_idf_cache = (time.monotonic(), idf)
return idf
def _affinity_candidate_ids(session: Session, aff: cf.Affinity, rng) -> set[uuid.UUID]:
ids: set[uuid.UUID] = set()
for tag in aff.top("tags", _AFFINITY_TAGS):
@ -352,7 +384,13 @@ def clip_feed(
picked_aff: list[cf.Candidate] = []
if n_aff:
cand_ids = _affinity_candidate_ids(session, aff, rng)
picked_aff = cf.pick_affinity(_candidates(session, list(cand_ids), clauses), aff, n_aff)
picked_aff = cf.pick_affinity(
_candidates(session, list(cand_ids), clauses),
aff,
n_aff,
idf=_tag_idf(session),
v1=cf.uses_v1_scoring(device_id),
)
taken = {c.id for c in picked_aff}
# Explore: okno po PK; gdy w oknie brak widocznych, przesuwamy sie dalej (max 3 okna).
@ -432,6 +470,27 @@ def clip_events(
}
row, aff = _load_affinity(session, device_id)
changed = 0
# Liczniki przyjecia klipu u nas (clips.feed_*), z nich korzysta punktacja feedu.
counters: dict[uuid.UUID, list[int]] = {}
for e in events:
d = counters.setdefault(e.clip_id, [0, 0, 0])
if e.kind in _VIEW_KINDS:
d[0] += 1
if e.kind == "skip":
d[1] += 1
if e.kind == "like":
d[2] += 1
elif e.kind == "unlike":
d[2] -= 1
for cid, (dv, ds, dl) in counters.items():
if dv or ds or dl:
session.execute(
text(
"UPDATE clips SET feed_views = feed_views + :v, feed_skips = feed_skips + :s, "
"feed_likes = greatest(feed_likes + :l, 0) WHERE id = :id"
),
{"v": dv, "s": ds, "l": dl, "id": cid},
)
for e in events:
session.add(
ClipFeedEvent(

View file

@ -14,8 +14,10 @@ zapomina stare gusta, cap trzyma słowniki małe (60/40/40). Wszystko celowo pro
przejrzyste: da się wytłumaczyć użytkownikowi, dlaczego coś widzi, i skasować jednym
wywołaniem.
**Punktacja kandydata** = Σ wag tagów + 2·waga niszy + 3·waga twórcy + 0,3·ln(1+lajki)
+ 0,5 gdy klip z ostatnich 7 dni. Strona: 80 % z afiniczności (max 2 klipy jednego twórcy),
**Punktacja kandydata** (od 2026-10-02, uzasadnienie przy `score_candidate`) = log(Σ waga
tagu × rzadkość tagu) + 0,25·log(nisze) + 2,5·log(twórca) + 0,8·ln(1+lajki RedGifs)
+ 120·(odsetek lajków u nas − 3 %) − 15·(odsetek ucieczek u nas − 50 %) + 0,3 za świeżość.
25 % urządzeń zostaje na starej punktacji jako grupa kontrolna. Strona: 80 % z afiniczności (max 2 klipy jednego twórcy),
20 % explore, przemieszane deterministycznie z seeda.
"""
from __future__ import annotations
@ -186,9 +188,60 @@ class Candidate:
published_at: datetime | None
tags: list[str]
niches: list[str]
#: Przyjecie klipu u nas (kolumny clips.feed_*, migracja 0052).
feed_views: int = 0
feed_skips: int = 0
feed_likes: int = 0
def score_candidate(c: Candidate, aff: Affinity, now: datetime | None = None) -> float:
# Punktacja od 2026-10-02, dobrana na odtworzeniu 189 tys. zdarzen (wagi urzadzenia
# liczone zdarzenie po zdarzeniu, bez zagladania w przyszlosc; trening na 70 % najstarszych,
# test na reszcie). Miara: czy wyzszy wynik u TEGO urzadzenia znaczy lajk albo >= 10 s
# ogladania, a nie ucieczke < 2 s (AUC w obrebie urzadzenia). Stara punktacja: 0,576,
# ta: 0,652, regresja logistyczna na tych samych cechach: 0,654. Wnioski:
# * suma wag tagow niewiele mowi, bo tagi RedGifs sa liczne i ogolne, a lajk dokladal
# +5 kazdemu z ~10 tagow klipu; waga razy rzadkosc tagu (IDF) i log daje wiecej,
# * nisze prawie nic (0,54), stad 0,25 zamiast 2,
# * tworca to najlepszy sygnal z wag (0,58),
# * najmocniejszy jest odsetek lajkow klipu u nas (0,60), wygladzony, zeby klip bez
# historii nie dostawal skrajnych ocen; popularnosc z RedGifs slaba, ale nie zerowa.
W_NICHE = 0.25
W_CREATOR = 2.5
W_POP = 0.8
W_FEED_LIKE = 120.0
W_FEED_SKIP = 15.0
W_FRESH = 0.3
#: Wygladzanie odsetkow: klip zaczyna od 10 „wirtualnych" wyswietlen ze srednimi wartosciami.
PRIOR_VIEWS = 10.0
PRIOR_LIKE_RATE = 0.03
PRIOR_SKIP_RATE = 0.5
def _slog(x: float) -> float:
return math.copysign(math.log1p(abs(x)), x)
def score_candidate(
c: Candidate, aff: Affinity, now: datetime | None = None, idf: dict[str, float] | None = None
) -> float:
"""`idf`: tag -> ln(N/df) po calym katalogu klipow; bez niego kazdy tag waży 1."""
now = now or datetime.now(UTC)
default_idf = max(idf.values()) if idf else 1.0
tag = sum(aff.tags.get(t, 0.0) * (idf.get(t, default_idf) if idf else 1.0) for t in c.tags)
s = _slog(tag)
s += W_NICHE * _slog(sum(aff.niches.get(n, 0.0) for n in c.niches))
s += W_CREATOR * _slog(aff.creators.get(c.creator or "", 0.0))
s += W_POP * math.log1p(max(c.likes, 0))
like_rate = (c.feed_likes + PRIOR_VIEWS * PRIOR_LIKE_RATE) / (c.feed_views + PRIOR_VIEWS)
skip_rate = (c.feed_skips + PRIOR_VIEWS * PRIOR_SKIP_RATE) / (c.feed_views + PRIOR_VIEWS)
s += W_FEED_LIKE * (like_rate - PRIOR_LIKE_RATE) - W_FEED_SKIP * (skip_rate - PRIOR_SKIP_RATE)
if c.published_at and c.published_at >= now - timedelta(days=FRESH_DAYS):
s += W_FRESH
return s
def score_candidate_v1(c: Candidate, aff: Affinity, now: datetime | None = None) -> float:
"""Punktacja sprzed 2026-10-02, zostaje dla grupy kontrolnej (patrz `uses_v1_scoring`)."""
now = now or datetime.now(UTC)
s = sum(aff.tags.get(t, 0.0) for t in c.tags)
s += 2.0 * sum(aff.niches.get(n, 0.0) for n in c.niches)
@ -199,9 +252,30 @@ def score_candidate(c: Candidate, aff: Affinity, now: datetime | None = None) ->
return s
def pick_affinity(cands: list[Candidate], aff: Affinity, n: int, *, max_per_creator: int = MAX_PER_CREATOR) -> list[Candidate]:
#: Odsetek urzadzen na starej punktacji (grupa kontrolna). Przydzial staly po hashu id
#: urzadzenia, wiec do porownania wystarczy ten sam hash przy analizie zdarzen.
V1_CONTROL_PERCENT = 25
def uses_v1_scoring(device_id: str) -> bool:
h = int(hashlib.sha256(device_id.encode("utf-8")).hexdigest()[:8], 16)
return h % 100 < V1_CONTROL_PERCENT
def pick_affinity(
cands: list[Candidate],
aff: Affinity,
n: int,
*,
max_per_creator: int = MAX_PER_CREATOR,
idf: dict[str, float] | None = None,
v1: bool = False,
) -> list[Candidate]:
"""Najlepsze `n` po punktach, max `max_per_creator` klipów jednego twórcy na stronę."""
ranked = sorted(cands, key=lambda c: -score_candidate(c, aff))
if v1:
ranked = sorted(cands, key=lambda c: -score_candidate_v1(c, aff))
else:
ranked = sorted(cands, key=lambda c: -score_candidate(c, aff, idf=idf))
out: list[Candidate] = []
per: dict[str, int] = {}
for c in ranked:

View file

@ -68,6 +68,11 @@ class Clip(UUIDPKMixin, TimestampMixin, Base):
hls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
likes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
views: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
#: Przyjecie klipu w samym Goonie (feed Edge): wyswietlenia, ucieczki < 2 s, lajki.
#: Licznik bumpuje POST /clips/events, punktacja feedu z tego korzysta (migracja 0052).
feed_views: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
feed_skips: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
feed_likes: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
#: Data publikacji u źródła (RedGifs `createDate`), do odznaki „nowe" i sortu.
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
dead_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View file

@ -15,7 +15,9 @@ from app.clips_feed import (
pick_affinity,
pick_explore,
score_candidate,
score_candidate_v1,
split_counts,
uses_v1_scoring,
)
@ -95,3 +97,41 @@ def test_pick_explore_weighted_but_complete():
cands = [_cand("a", likes=0), _cand("b", likes=10**6)]
out = pick_explore(cands, 2, random.Random(1))
assert {c.creator for c in out} == {"a", "b"}
def test_feed_history_dominates_ranking():
# Klip, ktory u innych zbiera lajki, idzie przed takim samym z ucieczkami (2026-10-02).
aff = Affinity(tags={"x": 5.0})
liked = Candidate(id=uuid.uuid4(), creator="a", likes=0, published_at=None, tags=["x"], niches=[],
feed_views=50, feed_skips=10, feed_likes=8)
skipped = Candidate(id=uuid.uuid4(), creator="a", likes=0, published_at=None, tags=["x"], niches=[],
feed_views=50, feed_skips=45, feed_likes=0)
fresh_clip = Candidate(id=uuid.uuid4(), creator="a", likes=0, published_at=None, tags=["x"], niches=[])
assert score_candidate(liked, aff) > score_candidate(fresh_clip, aff) > score_candidate(skipped, aff)
def test_rare_tag_counts_more_than_common():
aff = Affinity(tags={"rare": 5.0, "common": 5.0})
idf = {"rare": 8.0, "common": 0.5}
rare = _cand("a", tags=["rare"])
common = _cand("a", tags=["common"])
assert score_candidate(rare, aff, idf=idf) > score_candidate(common, aff, idf=idf)
# stara punktacja ich nie rozroznia
assert score_candidate_v1(rare, aff) == score_candidate_v1(common, aff)
def test_many_generic_tags_do_not_swamp_creator():
# Stara punktacja: 12 ogolnych tagow po 5 pkt bilo ulubionego tworce (60 > 3*10).
aff = Affinity(tags={f"t{i}": 5.0 for i in range(12)}, creators={"fav": 10.0})
idf = {f"t{i}": 0.3 for i in range(12)}
generic = _cand("other", tags=[f"t{i}" for i in range(12)])
favourite = _cand("fav")
assert score_candidate(favourite, aff, idf=idf) > score_candidate(generic, aff, idf=idf)
assert score_candidate_v1(generic, aff) > score_candidate_v1(favourite, aff)
def test_v1_control_group_is_stable_and_about_a_quarter():
ids = [uuid.uuid4().hex for _ in range(4000)]
share = sum(uses_v1_scoring(d) for d in ids) / len(ids)
assert 0.21 < share < 0.29
assert all(uses_v1_scoring(d) == uses_v1_scoring(d) for d in ids[:50])