From 4666fcb04ac413042acb87dd93d069e269a64119 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Fri, 2 Oct 2026 11:47:02 +0200 Subject: [PATCH] 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 --- .../20261002_0052_clips_feed_counters.py | 55 ++++++++++++ app/api/clips.py | 65 +++++++++++++- app/clips_feed.py | 84 +++++++++++++++++-- app/models/clip.py | 5 ++ tests/test_clips_feed.py | 40 +++++++++ 5 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 alembic/versions/20261002_0052_clips_feed_counters.py diff --git a/alembic/versions/20261002_0052_clips_feed_counters.py b/alembic/versions/20261002_0052_clips_feed_counters.py new file mode 100644 index 0000000..a7a19ef --- /dev/null +++ b/alembic/versions/20261002_0052_clips_feed_counters.py @@ -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 + """) diff --git a/app/api/clips.py b/app/api/clips.py index d4a4d22..2b4db99 100644 --- a/app/api/clips.py +++ b/app/api/clips.py @@ -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( diff --git a/app/clips_feed.py b/app/clips_feed.py index fc36780..b044363 100644 --- a/app/clips_feed.py +++ b/app/clips_feed.py @@ -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: diff --git a/app/models/clip.py b/app/models/clip.py index ae29e94..150d303 100644 --- a/app/models/clip.py +++ b/app/models/clip.py @@ -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) diff --git a/tests/test_clips_feed.py b/tests/test_clips_feed.py index b3bbb41..932dbf8 100644 --- a/tests/test_clips_feed.py +++ b/tests/test_clips_feed.py @@ -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])