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:
parent
617dda0bbe
commit
4666fcb04a
5 changed files with 241 additions and 8 deletions
55
alembic/versions/20261002_0052_clips_feed_counters.py
Normal file
55
alembic/versions/20261002_0052_clips_feed_counters.py
Normal 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
|
||||
""")
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue