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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -44,6 +45,8 @@ _AFFINITY_TAGS, _AFFINITY_NICHES, _AFFINITY_CREATORS = 6, 3, 3
|
||||||
_PER_TAG, _PER_NICHE, _PER_CREATOR = 60, 60, 40
|
_PER_TAG, _PER_NICHE, _PER_CREATOR = 60, 60, 40
|
||||||
_UUID_ZERO = uuid.UUID(int=0)
|
_UUID_ZERO = uuid.UUID(int=0)
|
||||||
_BLACKLIST_KINDS = frozenset({"tag", "niche", "creator"})
|
_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 ---------------------------------------------------------------------------
|
# ---- schematy ---------------------------------------------------------------------------
|
||||||
|
|
@ -251,7 +254,10 @@ def _visible_clauses(device_id: str, bl: dict[str, set[str]]) -> list:
|
||||||
return clauses
|
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]:
|
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."""
|
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 {}
|
tags = _tags_for(session, [r[0] for r in rows]) if with_tags else {}
|
||||||
return [
|
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
|
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
|
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]:
|
def _affinity_candidate_ids(session: Session, aff: cf.Affinity, rng) -> set[uuid.UUID]:
|
||||||
ids: set[uuid.UUID] = set()
|
ids: set[uuid.UUID] = set()
|
||||||
for tag in aff.top("tags", _AFFINITY_TAGS):
|
for tag in aff.top("tags", _AFFINITY_TAGS):
|
||||||
|
|
@ -352,7 +384,13 @@ def clip_feed(
|
||||||
picked_aff: list[cf.Candidate] = []
|
picked_aff: list[cf.Candidate] = []
|
||||||
if n_aff:
|
if n_aff:
|
||||||
cand_ids = _affinity_candidate_ids(session, aff, rng)
|
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}
|
taken = {c.id for c in picked_aff}
|
||||||
|
|
||||||
# Explore: okno po PK; gdy w oknie brak widocznych, przesuwamy sie dalej (max 3 okna).
|
# 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)
|
row, aff = _load_affinity(session, device_id)
|
||||||
changed = 0
|
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:
|
for e in events:
|
||||||
session.add(
|
session.add(
|
||||||
ClipFeedEvent(
|
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
|
przejrzyste: da się wytłumaczyć użytkownikowi, dlaczego coś widzi, i skasować jednym
|
||||||
wywołaniem.
|
wywołaniem.
|
||||||
|
|
||||||
**Punktacja kandydata** = Σ wag tagów + 2·waga niszy + 3·waga twórcy + 0,3·ln(1+lajki)
|
**Punktacja kandydata** (od 2026-10-02, uzasadnienie przy `score_candidate`) = log(Σ waga
|
||||||
+ 0,5 gdy klip z ostatnich 7 dni. Strona: 80 % z afiniczności (max 2 klipy jednego twórcy),
|
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.
|
20 % explore, przemieszane deterministycznie z seeda.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -186,9 +188,60 @@ class Candidate:
|
||||||
published_at: datetime | None
|
published_at: datetime | None
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
niches: 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)
|
now = now or datetime.now(UTC)
|
||||||
s = sum(aff.tags.get(t, 0.0) for t in c.tags)
|
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)
|
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
|
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ę."""
|
"""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] = []
|
out: list[Candidate] = []
|
||||||
per: dict[str, int] = {}
|
per: dict[str, int] = {}
|
||||||
for c in ranked:
|
for c in ranked:
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,11 @@ class Clip(UUIDPKMixin, TimestampMixin, Base):
|
||||||
hls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
hls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
likes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
likes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
views: 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.
|
#: 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)
|
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
dead_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_affinity,
|
||||||
pick_explore,
|
pick_explore,
|
||||||
score_candidate,
|
score_candidate,
|
||||||
|
score_candidate_v1,
|
||||||
split_counts,
|
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)]
|
cands = [_cand("a", likes=0), _cand("b", likes=10**6)]
|
||||||
out = pick_explore(cands, 2, random.Random(1))
|
out = pick_explore(cands, 2, random.Random(1))
|
||||||
assert {c.creator for c in out} == {"a", "b"}
|
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