From 00572a34297844b90f4525157f8d0bf4df66ccba Mon Sep 17 00:00:00 2001 From: kaireewu Date: Mon, 2 Nov 2026 22:11:30 +0800 Subject: [PATCH] fix(growth): require a 1000-view baseline on the rate board Keep small-base videos on the delta board only, and raise the smoothing prior to 1000 so videos that just clear the floor do not dominate the ranking. --- DEVELOPMENT.md | 2 +- config.docker.example.jsonc | 2 +- config.example.jsonc | 2 +- project.md | 4 +-- reaction_board/analytics.py | 2 +- reaction_board/config.py | 4 +-- reaction_board/web/queries.py | 19 ++++++++++++--- reaction_board/web/static/views/about.js | 2 +- reaction_board/web/static/views/rankings.js | 4 +-- tests/test_config.py | 6 ++--- tests/test_web_queries.py | 27 +++++++++++++-------- 11 files changed, 47 insertions(+), 27 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 3402ee4..766f429 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -608,7 +608,7 @@ interaction_rate = (like + coin + favorite) / max(view, 1) - 查询前一有效快照而不是填补缺失日期; - 缺失日期在图表中断开; -- 增速榜要求播放基数至少 200; +- 增速榜要求播放基数至少 1000; - 互动率榜要求播放至少 100; - 样本不足两天的视频明确标注,不伪造增长数据。 diff --git a/config.docker.example.jsonc b/config.docker.example.jsonc index 7465fc2..e069197 100644 --- a/config.docker.example.jsonc +++ b/config.docker.example.jsonc @@ -108,6 +108,6 @@ "video_shrink_minutes": 60, "shrink_minutes": null, "home_growth_days": "auto", - "growth_rate_prior": 500, + "growth_rate_prior": 1000, }, } diff --git a/config.example.jsonc b/config.example.jsonc index a81b24c..881715b 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -124,6 +124,6 @@ // "auto" = 7 日有数据用 7,否则 1 "home_growth_days": "auto", // 增速榜排序:Δ / (基线 + prior)。卡片上的百分比仍是 Δ / 基线。 - "growth_rate_prior": 500, + "growth_rate_prior": 1000, }, } diff --git a/project.md b/project.md index 90bdd13..75f5548 100644 --- a/project.md +++ b/project.md @@ -681,14 +681,14 @@ d7 = (view_today - view_{today-7}) / 实际间隔天数 r7 = (view_today - view_{today-7}) / max(view_{today-7}, 1) d1_published_today = view_today - 0 # 只进增量榜,不计算增速 d1_imported_today = view_today - view_open # 需同日第二次采样 -sort_rate = Δ / (基线 + analytics.growth_rate_prior) # 默认 prior=500,只用于增速榜排序 +sort_rate = Δ / (基线 + analytics.growth_rate_prior) # 默认 prior=1000,只用于增速榜排序 ``` | 榜 | 口径 | 基数门槛 | |---|---|---| | 累计热度 | §8.1 | — | | 近 7 天播放增量 | `d7(view)` | — | -| 近 7 天增速 | `r7(view)` | `view >= 200` | +| 近 7 天增速 | `r7(view)` | 基线播放 `>= 1000` | | 互动率 | `(like+coin+fav)/view` | `view >= 100` | | 单项榜 | 7 项直接排序 | — | | UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — | diff --git a/reaction_board/analytics.py b/reaction_board/analytics.py index 9f91a3e..77bc7dc 100644 --- a/reaction_board/analytics.py +++ b/reaction_board/analytics.py @@ -34,7 +34,7 @@ DEFAULT_HEAT_WEIGHTS: dict[str, float] = { } MIN_VIEW_FOR_INTERACTION = 100 -MIN_VIEW_FOR_GROWTH_RATE = 200 +MIN_VIEW_FOR_GROWTH_RATE = 1000 MIN_EFFICIENCY_DURATION = 600 DEFAULT_MATURITY_DAYS = 2 DEFAULT_SHRINK_MINUTES = 120.0 diff --git a/reaction_board/config.py b/reaction_board/config.py index 61128af..675b446 100644 --- a/reaction_board/config.py +++ b/reaction_board/config.py @@ -52,7 +52,7 @@ DEFAULT_ANALYTICS: dict[str, Any] = { "video_shrink_minutes": 60, "shrink_minutes": None, "home_growth_days": "auto", - "growth_rate_prior": 500, + "growth_rate_prior": 1000, } DEFAULT_CONFIG: dict[str, Any] = { @@ -595,7 +595,7 @@ def _build_config(data: Mapping[str, Any], path: Path, *, secrets_path: Path) -> ), home_growth_days=home_growth_days, growth_rate_prior=_as_int( - analytics_raw.get("growth_rate_prior", 500), + analytics_raw.get("growth_rate_prior", 1000), "analytics.growth_rate_prior", minimum=0, ), diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index c38fbc2..67a5c13 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -59,6 +59,19 @@ def _chunks(values: Sequence[int], size: int = 400) -> Iterable[Sequence[int]]: yield values[index:index + size] +def _growth_baseline_view(item: Mapping[str, Any]) -> int | None: + """增速分母:当前播放减去区间增量。zero 基线的增量等于当前播放,结果为 0。""" + growth = item.get("growth") or {} + current = (item.get("metrics") or {}).get("view") + delta = growth.get("delta") + if current is None or delta is None: + return None + try: + return int(current) - int(delta) + except (TypeError, ValueError): + return None + + def _date_minus(day: str, days: int) -> str: return (date.fromisoformat(day) - timedelta(days=int(days))).isoformat() @@ -1036,7 +1049,7 @@ class DashboardQueries: item for item in items if item.get("growth", {}).get("baseline") != "zero" and item.get("growth", {}).get("rate") is not None - and (item["metrics"] or {}).get("view", 0) >= MIN_VIEW_FOR_GROWTH_RATE + and (_growth_baseline_view(item) or 0) >= MIN_VIEW_FOR_GROWTH_RATE ] rate_prior = max(0, int(analytics.growth_rate_prior)) @@ -1087,8 +1100,8 @@ class DashboardQueries: available = False if growth_days == 1 and sort == "growth_rate": reason = ( - "近 1 日增速需要前一日快照,或更早发布的新入库当天至少两次成功采样;" - "当日发布不计入增速" + "近 1 日增速需要基线播放至少 " + f"{MIN_VIEW_FOR_GROWTH_RATE};当日发布不计入增速" ) elif growth_days == 1: reason = ( diff --git a/reaction_board/web/static/views/about.js b/reaction_board/web/static/views/about.js index 2492af8..b82eed2 100644 --- a/reaction_board/web/static/views/about.js +++ b/reaction_board/web/static/views/about.js @@ -50,7 +50,7 @@ export async function renderAbout(root) {
  • 播放 / 投币 / 收藏 / 评论:直接取当日快照;弹幕与转发不单列,已并入“互动”。
  • 互动:点赞 + 投币 + 收藏。综合热度也使用全部七项指标,按配置权重加权。
  • 互动率:(点赞 + 投币 + 收藏) / 播放;播放不足 100 不计算。
  • -
  • 播放增速:卡片上的百分比是增量 / 基线播放。排序用增量 / (基线 + ${analytics.growth_rate_prior}),避免小基数霸榜。当日发布没有基线,不进增速榜。
  • +
  • 播放增速:卡片上的百分比是增量 / 基线播放。基线播放不足 1000 不进增速榜。排序用增量 / (基线 + ${analytics.growth_rate_prior})。当日发布没有基线,不进增速榜。
  • 综合热度:每项先 log1p,再按中位数与 IQR 做稳健标准化,最后加权求和。分数只用于同一批数据内排序,不能跨日期比较。
  • 内容效率:播放 / 内容小时。内容小时 = 视频总时长(多 P 为各 P 之和)÷ 3600。榜单里的单条视频用收缩后的估计:(播放 + κ·μ₀) / (小时 + κ),κ 默认 ${analytics.video_shrink_minutes} 分钟、μ₀ 为全库播放/小时;详情页保留原始播放/小时。
  • 互动效率:互动 / 内容小时,互动口径与收缩方式同上。
  • diff --git a/reaction_board/web/static/views/rankings.js b/reaction_board/web/static/views/rankings.js index 34b296b..c4d2c95 100644 --- a/reaction_board/web/static/views/rankings.js +++ b/reaction_board/web/static/views/rankings.js @@ -187,8 +187,8 @@ function growthNote(sort, growthDays, prior) { ? `百分比是真实相对增量;排序按平滑增速(基线 + ${strength})。` : "百分比是真实相对增量;排序按平滑增速。"; const cold = Number(growthDays) === 1 - ? "当日发布不进增速榜。更早发布的新入库按入库时播放量起算,需要当天至少两次采样。" - : ""; + ? "基线播放不足 1000、以及当日发布,不进增速榜。" + : "基线播放不足 1000 不进增速榜。"; return `
    ${smooth}${cold}
    `; } if (sort !== "growth" || Number(growthDays) !== 1) return ""; diff --git a/tests/test_config.py b/tests/test_config.py index ba71dc4..e1f0ebf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -146,7 +146,7 @@ def test_analytics_defaults_and_validation(): assert cfg.analytics.video_shrink_minutes == 60 assert cfg.analytics.shrink_minutes is None assert cfg.analytics.home_growth_days == "auto" - assert cfg.analytics.growth_rate_prior == 500 + assert cfg.analytics.growth_rate_prior == 1000 assert cfg.schedule.recent_snapshot_minutes == 60 assert cfg.schedule.recent_video_days == 2 assert cfg.schedule.snapshot_at == "23:15" @@ -160,7 +160,7 @@ def test_analytics_defaults_and_validation(): "video_shrink_minutes": 30, "shrink_minutes": 90, "home_growth_days": 1, - "growth_rate_prior": 1000, + "growth_rate_prior": 2000, } data["schedule"] = { "discover_minutes": 30, @@ -178,7 +178,7 @@ def test_analytics_defaults_and_validation(): assert cfg.analytics.video_shrink_minutes == 30 assert cfg.analytics.shrink_minutes == 90 assert cfg.analytics.home_growth_days == 1 - assert cfg.analytics.growth_rate_prior == 1000 + assert cfg.analytics.growth_rate_prior == 2000 assert cfg.schedule.recent_snapshot_minutes == 15 assert cfg.schedule.recent_video_days == 3 diff --git a/tests/test_web_queries.py b/tests/test_web_queries.py index 59c72c8..fbb185c 100644 --- a/tests/test_web_queries.py +++ b/tests/test_web_queries.py @@ -492,7 +492,7 @@ def test_about_payload(): "video_shrink_minutes": 60, "shrink_minutes": None, "home_growth_days": "auto", - "growth_rate_prior": 500, + "growth_rate_prior": 1000, } assert about["heat_weights"]["view"] == 0.30 assert about["followers"]["videos_total"] == 3 @@ -758,8 +758,8 @@ def test_same_day_growth_baselines(): rate = q.list_videos(sort="growth_rate", growth_days=1, page_size=20) rate_aids = {item["aid"] for item in rate["items"]} - assert 103 not in rate_aids - assert 101 in rate_aids and 105 in rate_aids + assert 103 not in rate_aids and 105 not in rate_aids + assert 101 in rate_aids seven = q.list_videos(sort="growth", growth_days=7) seven_aids = {item["aid"] for item in seven["items"]} @@ -782,7 +782,7 @@ def test_same_day_growth_baselines(): def test_growth_rate_sort_uses_prior(): - """小基数的真实增速可以更高,排序分用基线 + prior,不按 raw rate 排。""" + """基线不足 1000 不进增速榜;达标后按基线 + prior 排序,不按 raw rate。""" with tempfile.TemporaryDirectory() as tmp: db, q = setup(tmp) try: @@ -791,7 +791,9 @@ def test_growth_rate_sort_uses_prior(): previous_day = DATES[-2] start = int(day_bounds(latest)[0]) pub = int(day_bounds("2026-09-01")[0]) - for aid, views in ((301, (80, 512)), (302, (2000, 4000))): + # 301:80→512,真实 +540%,基线低于 1000,只留在增量榜。 + # 304:1000→2500,真实增速高于 302,平滑后应排在 302 后面。 + for aid, views in ((301, (80, 512)), (302, (5000, 10000)), (304, (1000, 2500))): repo.upsert_from_fav( resource(aid, up_mid=1, up_name="UP A", pubdate=pub), FOLDER.media_id, was_added=True, now=start, @@ -807,11 +809,16 @@ def test_growth_rate_sort_uses_prior(): DASHBOARD_CACHE.clear() ranked = q.list_videos(sort="growth_rate", growth_days=1, page_size=20) order = [item["aid"] for item in ranked["items"]] - assert ranked["growth_rate_prior"] == 500 - assert order.index(302) < order.index(301) - small = next(item for item in ranked["items"] if item["aid"] == 301) - assert abs(small["growth"]["rate"] - 432 / 80) < 1e-12 - assert small["growth"]["delta"] == 432 + assert ranked["growth_rate_prior"] == 1000 + assert 301 not in order + assert order.index(302) < order.index(304) + modest = next(item for item in ranked["items"] if item["aid"] == 304) + assert abs(modest["growth"]["rate"] - 1500 / 1000) < 1e-12 + + growth = q.list_videos(sort="growth", growth_days=1, page_size=20) + grown = next(item for item in growth["items"] if item["aid"] == 301) + assert grown["growth"]["delta"] == 432 + assert abs(grown["growth"]["rate"] - 432 / 80) < 1e-12 finally: db.close()