From 39a6537f67770d1744a0f08798c4a4646128d769 Mon Sep 17 00:00:00 2001 From: kaireewu Date: Mon, 2 Nov 2026 22:06:41 +0800 Subject: [PATCH] fix(growth): rank the rate board by a smoothed score Show the real percentage on the card, but sort with delta / (baseline + prior) so a small baseline cannot dominate. Same-day publishes stay off the rate board. --- DEVELOPMENT.md | 2 +- config.docker.example.jsonc | 1 + config.example.jsonc | 2 + project.md | 5 ++- reaction_board/analytics.py | 23 +++++++++++ reaction_board/config.py | 7 ++++ reaction_board/web/queries.py | 33 +++++++++++++--- reaction_board/web/static/components.js | 14 +++++-- reaction_board/web/static/format.js | 6 +++ reaction_board/web/static/views/about.js | 1 + reaction_board/web/static/views/rankings.js | 18 +++++++-- tests/test_analytics.py | 15 +++++++ tests/test_config.py | 4 ++ tests/test_web_queries.py | 43 +++++++++++++++++++++ 14 files changed, 158 insertions(+), 16 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e2d4d35..3402ee4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -756,7 +756,7 @@ uv run python tests/test_moves.py - 粉丝采集对每条 `relation/stat` 加 45s 硬超时看门狗(防 DNS/传输层挂起),每 25 条输出 `up_profile.progress` 进度;单条失败不阻塞整批; - `app.py backfill-followers`:一次性回填历史视频粉丝数,支持 `--limit` / `--dry-run`; - 新增内容效率/互动效率榜与 UP 内容效率(Gamma–Poisson EB 收缩),过滤 < 600 秒与未满 2 天的视频; -- `analytics` 配置段:`min_duration_seconds`、`maturity_days`、`min_view_for_ranking`、`video_shrink_minutes`、`shrink_minutes`; +- `analytics` 配置段:`min_duration_seconds`、`maturity_days`、`min_view_for_ranking`、`video_shrink_minutes`、`shrink_minutes`、`growth_rate_prior`; - 主导航拆分:`/videos`(视频)与 `/ups`(UP主)独立;旧 `/rank` 保留兼容; - 新增 `/about` 数据说明页与 `/api/about`; - 卡片精简:列表只保留主指标与互动率,明细移到详情页; diff --git a/config.docker.example.jsonc b/config.docker.example.jsonc index 0af70f4..7465fc2 100644 --- a/config.docker.example.jsonc +++ b/config.docker.example.jsonc @@ -108,5 +108,6 @@ "video_shrink_minutes": 60, "shrink_minutes": null, "home_growth_days": "auto", + "growth_rate_prior": 500, }, } diff --git a/config.example.jsonc b/config.example.jsonc index f7b06d7..a81b24c 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -123,5 +123,7 @@ "shrink_minutes": null, // "auto" = 7 日有数据用 7,否则 1 "home_growth_days": "auto", + // 增速榜排序:Δ / (基线 + prior)。卡片上的百分比仍是 Δ / 基线。 + "growth_rate_prior": 500, }, } diff --git a/project.md b/project.md index b4935ce..90bdd13 100644 --- a/project.md +++ b/project.md @@ -679,8 +679,9 @@ w = {view:0.30, like:0.20, coin:0.20, favorite:0.15, ``` d7 = (view_today - view_{today-7}) / 实际间隔天数 r7 = (view_today - view_{today-7}) / max(view_{today-7}, 1) -d1_published_today = view_today - 0 +d1_published_today = view_today - 0 # 只进增量榜,不计算增速 d1_imported_today = view_today - view_open # 需同日第二次采样 +sort_rate = Δ / (基线 + analytics.growth_rate_prior) # 默认 prior=500,只用于增速榜排序 ``` | 榜 | 口径 | 基数门槛 | @@ -692,7 +693,7 @@ d1_imported_today = view_today - view_open # 需同日第二次采样 | 单项榜 | 7 项直接排序 | — | | UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — | -**冷启动**:近 1 日不要求跨日快照。当日发布相对 0;更早发布且当天入库的片子相对 `view_open`(第二次采样走晚间全量,不进小时快照)。近 7/30 日仍要跨日快照。缺失日不插值。 +**冷启动**:近 1 日不要求跨日快照。当日发布相对 0,只进播放增量、不进播放增速;更早发布且当天入库的片子相对 `view_open`(第二次采样走晚间全量,不进小时快照)。近 7/30 日仍要跨日快照。缺失日不插值。 ### 8.3 趋势 diff --git a/reaction_board/analytics.py b/reaction_board/analytics.py index 70043ab..9f91a3e 100644 --- a/reaction_board/analytics.py +++ b/reaction_board/analytics.py @@ -340,6 +340,29 @@ def compute_growth( ) +def smoothed_growth_rate( + delta: int | float | None, + previous: int | float | None, + prior: int | float, +) -> float | None: + """增速榜排序分:``Δ / (基线 + prior)``。 + + ``rate`` 仍是真实相对增速。``prior`` 只削弱小基数在排序里的优势。 + """ + if delta is None or previous is None or isinstance(delta, bool) or isinstance(previous, bool): + return None + if isinstance(prior, bool): + return None + try: + strength = float(prior) + base = max(0, int(previous)) + strength + except (TypeError, ValueError): + return None + if strength < 0 or base <= 0: + return None + return int(delta) / base + + def normalize_cover(url: object) -> str | None: """把 B 站封面统一为 https;空值返回 None。""" if not isinstance(url, str): diff --git a/reaction_board/config.py b/reaction_board/config.py index d241f15..61128af 100644 --- a/reaction_board/config.py +++ b/reaction_board/config.py @@ -52,6 +52,7 @@ DEFAULT_ANALYTICS: dict[str, Any] = { "video_shrink_minutes": 60, "shrink_minutes": None, "home_growth_days": "auto", + "growth_rate_prior": 500, } DEFAULT_CONFIG: dict[str, Any] = { @@ -210,6 +211,7 @@ class AnalyticsConfig: video_shrink_minutes: int shrink_minutes: int | None home_growth_days: str | int + growth_rate_prior: int @dataclass(frozen=True) @@ -592,6 +594,11 @@ 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.growth_rate_prior", + minimum=0, + ), ) return AppConfig( diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index e690efe..c38fbc2 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -20,6 +20,7 @@ from ..analytics import ( METRICS, MIN_VIEW_FOR_GROWTH_RATE, compute_growth, + smoothed_growth_rate, content_hours, eb_rate, effective_shrink_minutes, @@ -437,6 +438,8 @@ class DashboardQueries: growth = compute_growth(current, 0, 0, allow_zero_gap=True) payload = growth.as_dict() payload["baseline"] = "zero" + # previous=0 时 rate = view/1,会按原始播放碾压真日环比。 + payload["rate"] = None return payload try: open_at = snapshot.get("open_captured_at") @@ -1031,9 +1034,13 @@ class DashboardQueries: if sort == "growth_rate": items = [ item for item in items - if (item["metrics"] or {}).get("view", 0) >= MIN_VIEW_FOR_GROWTH_RATE + 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 ] + rate_prior = max(0, int(analytics.growth_rate_prior)) + def sort_key(item: Mapping[str, Any]): if sort == "added": return (int(item["first_seen"]), int(item["pubdate"])) @@ -1050,9 +1057,18 @@ class DashboardQueries: ) value = item.get(field) return value if value is not None else float("-inf") - if sort in {"growth", "growth_rate"}: - field = "delta" if sort == "growth" else "rate" - value = item.get("growth", {}).get(field) + if sort == "growth": + value = item.get("growth", {}).get("delta") + return value if value is not None else float("-inf") + if sort == "growth_rate": + growth = item.get("growth") or {} + delta = growth.get("delta") + current = (item.get("metrics") or {}).get("view") + if delta is None or current is None: + return float("-inf") + value = smoothed_growth_rate( + delta, int(current) - int(delta), rate_prior, + ) return value if value is not None else float("-inf") value = (item["metrics"] or {}).get(sort) return value if value is not None else -1 @@ -1069,7 +1085,12 @@ class DashboardQueries: reason = None if sort in {"growth", "growth_rate"} and not growth_available: available = False - if growth_days == 1: + if growth_days == 1 and sort == "growth_rate": + reason = ( + "近 1 日增速需要前一日快照,或更早发布的新入库当天至少两次成功采样;" + "当日发布不计入增速" + ) + elif growth_days == 1: reason = ( "近 1 日增长需要前一日快照;当日发布有一次成功采样即可;" "更早发布的新入库需要当天至少两次成功采样" @@ -1108,6 +1129,7 @@ class DashboardQueries: "available": available, "reason": reason, "duration_filter": duration_filter, + "growth_rate_prior": rate_prior if sort == "growth_rate" else None, } return self.db.read(run) @@ -1754,6 +1776,7 @@ class DashboardQueries: "video_shrink_minutes": cfg.analytics.video_shrink_minutes, "shrink_minutes": cfg.analytics.shrink_minutes, "home_growth_days": cfg.analytics.home_growth_days, + "growth_rate_prior": cfg.analytics.growth_rate_prior, }, "heat_weights": { metric: float(value) diff --git a/reaction_board/web/static/components.js b/reaction_board/web/static/components.js index 3f25c1b..327221c 100644 --- a/reaction_board/web/static/components.js +++ b/reaction_board/web/static/components.js @@ -1,6 +1,6 @@ import { coverUrl, escapeAttr, escapeHtml, formatCount, formatDate, - formatDelta, formatDuration, formatPercent, metricLabel, statusLabel, + formatDelta, formatDuration, formatPercent, formatSignedPercent, metricLabel, statusLabel, } from "./format.js"; export function cover(item, className = "thumb") { @@ -38,7 +38,7 @@ function growthBaselineTag(growth) { export function videoCard(item, { rank = null, primary = "view", primaryLabel = "", primaryText = null, - showStatus = false, + showStatus = false, growthField = "delta", } = {}) { const metrics = item.metrics || {}; const hasMetric = Object.prototype.hasOwnProperty.call(metrics, primary); @@ -49,7 +49,13 @@ export function videoCard(item, { const primaryValue = typeof raw === "number" ? formatCount(raw) : raw; const label = primaryLabel || metricLabel(primary); const growth = item.growth; - const delta = growth?.available ? growth.delta : null; + const showRate = growthField === "rate"; + const rate = showRate && growth?.available ? growth.rate : null; + const delta = !showRate && growth?.available ? growth.delta : null; + const change = showRate + ? (rate === null || rate === undefined ? null : formatSignedPercent(rate)) + : (delta === null || delta === undefined ? null : formatDelta(delta)); + const changeUp = showRate ? Number(rate) >= 0 : Number(delta) >= 0; const title = escapeHtml(item.title || "(无标题)"); const up = escapeHtml(item.up_name || "未知 UP"); const upMid = item.up_mid; @@ -79,7 +85,7 @@ export function videoCard(item, { ${escapeHtml(primaryValue ?? "—")}${escapeHtml(label)} - ${delta !== null ? `${formatDelta(delta)}${growthBaselineTag(growth)}` : ""} + ${change !== null ? `${change}${growthBaselineTag(growth)}` : ""} 互动 ${formatPercent(item.interaction_rate)} diff --git a/reaction_board/web/static/format.js b/reaction_board/web/static/format.js index 27c3603..618b407 100644 --- a/reaction_board/web/static/format.js +++ b/reaction_board/web/static/format.js @@ -46,6 +46,12 @@ export function formatPercent(value, digits = 2) { return `${(Number(value) * 100).toFixed(digits)}%`; } +export function formatSignedPercent(value, digits = 2) { + const text = formatPercent(value, digits); + if (text === "—" || Number(value) <= 0) return text; + return `+${text}`; +} + export function formatDate(timestamp) { if (!timestamp) return "—"; const date = new Date(Number(timestamp) * 1000); diff --git a/reaction_board/web/static/views/about.js b/reaction_board/web/static/views/about.js index 3416ade..2492af8 100644 --- a/reaction_board/web/static/views/about.js +++ b/reaction_board/web/static/views/about.js @@ -50,6 +50,7 @@ export async function renderAbout(root) {
  • 播放 / 投币 / 收藏 / 评论:直接取当日快照;弹幕与转发不单列,已并入“互动”。
  • 互动:点赞 + 投币 + 收藏。综合热度也使用全部七项指标,按配置权重加权。
  • 互动率:(点赞 + 投币 + 收藏) / 播放;播放不足 100 不计算。
  • +
  • 播放增速:卡片上的百分比是增量 / 基线播放。排序用增量 / (基线 + ${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 701c60c..34b296b 100644 --- a/reaction_board/web/static/views/rankings.js +++ b/reaction_board/web/static/views/rankings.js @@ -166,6 +166,7 @@ function listHtml(data, scope, sort, startRank) { return videoCard(item, { rank: startRank + index, primary: primary.primary, primaryLabel: primary.label, primaryText: primary.text, + growthField: sort === "growth_rate" ? "rate" : "delta", }); }).join("")}`; } @@ -179,9 +180,18 @@ function durationNote(data) { return `
    内容效率与互动效率:时长 ≥ ${filter.min_duration_seconds} 秒、发布满 ${filter.maturity_days} 天、播放 ≥ ${filter.min_view}${shrink};时长缺失不参与。
    `; } -function growthNote(sort, growthDays) { - if (sort !== "growth" && sort !== "growth_rate") return ""; - if (Number(growthDays) !== 1) return ""; +function growthNote(sort, growthDays, prior) { + if (sort === "growth_rate") { + const strength = Number(prior); + const smooth = Number.isFinite(strength) + ? `百分比是真实相对增量;排序按平滑增速(基线 + ${strength})。` + : "百分比是真实相对增量;排序按平滑增速。"; + const cold = Number(growthDays) === 1 + ? "当日发布不进增速榜。更早发布的新入库按入库时播放量起算,需要当天至少两次采样。" + : ""; + return `
    ${smooth}${cold}
    `; + } + if (sort !== "growth" || Number(growthDays) !== 1) return ""; return `
    近 1 日:当日发布按 0 起算;更早发布的新入库按入库时播放量起算,需要当天至少两次采样。
    `; } @@ -251,7 +261,7 @@ async function renderScope(root, { query }, scope) { ${controls({ scope, sort, within, year, growthDays, q, years })} ${durationNote(data)} - ${growthNote(sort, growthDays)} + ${growthNote(sort, growthDays, data.growth_rate_prior)} ${data.available === false ? `
    ${sort.startsWith("growth") ? "增长数据尚不可用" : "榜单筛选后为空"} ${escapeHtml(data.reason || "")} diff --git a/tests/test_analytics.py b/tests/test_analytics.py index f33f350..6054458 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -12,6 +12,7 @@ from reaction_board.analytics import ( # noqa: E402 METRICS, age_days, compute_growth, + smoothed_growth_rate, content_hours, eb_rate, effective_shrink_minutes, @@ -161,6 +162,19 @@ def test_compute_growth(): assert negative.per_day == -5.0 +def test_smoothed_growth_rate(): + # 80→512:真实增速 5.4;prior=500 时排序分 432/580 + assert abs(smoothed_growth_rate(432, 80, 500) - 432 / 580) < 1e-12 + assert abs(smoothed_growth_rate(432, 5000, 500) - 432 / 5500) < 1e-12 + assert abs(smoothed_growth_rate(432, 80, 1000) - 432 / 1080) < 1e-12 + # prior=0 时排序分退回真实相对增速(基线 ≥ 1) + assert smoothed_growth_rate(432, 80, 0) == 432 / 80 + assert smoothed_growth_rate(None, 80, 500) is None + assert smoothed_growth_rate(432, None, 500) is None + assert smoothed_growth_rate(10, 0, 0) is None + assert smoothed_growth_rate(10, 80, -1) is None + + def test_normalize_cover_and_hosts(): assert normalize_cover(None) is None assert normalize_cover("") is None @@ -251,6 +265,7 @@ def main_test(): test_score_percentiles() test_interaction_rate_thresholds() test_compute_growth() + test_smoothed_growth_rate() test_normalize_cover_and_hosts() test_content_efficiency_helpers() test_age_and_maturity() diff --git a/tests/test_config.py b/tests/test_config.py index 2824011..ba71dc4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -146,6 +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.schedule.recent_snapshot_minutes == 60 assert cfg.schedule.recent_video_days == 2 assert cfg.schedule.snapshot_at == "23:15" @@ -159,6 +160,7 @@ def test_analytics_defaults_and_validation(): "video_shrink_minutes": 30, "shrink_minutes": 90, "home_growth_days": 1, + "growth_rate_prior": 1000, } data["schedule"] = { "discover_minutes": 30, @@ -176,6 +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.schedule.recent_snapshot_minutes == 15 assert cfg.schedule.recent_video_days == 3 @@ -194,6 +197,7 @@ def test_analytics_defaults_and_validation(): ({"home_growth_days": 3}, "home_growth_days"), ({"home_growth_days": "7"}, "home_growth_days"), ({"home_growth_days": 0}, "home_growth_days"), + ({"growth_rate_prior": -1}, "growth_rate_prior"), ): data = base_config() data["analytics"] = bad diff --git a/tests/test_web_queries.py b/tests/test_web_queries.py index 0e7d72f..59c72c8 100644 --- a/tests/test_web_queries.py +++ b/tests/test_web_queries.py @@ -492,6 +492,7 @@ def test_about_payload(): "video_shrink_minutes": 60, "shrink_minutes": None, "home_growth_days": "auto", + "growth_rate_prior": 500, } assert about["heat_weights"]["view"] == 0.30 assert about["followers"]["videos_total"] == 3 @@ -748,12 +749,18 @@ def test_same_day_growth_baselines(): assert 106 not in by_aid and 107 not in by_aid assert by_aid[103]["growth"]["baseline"] == "zero" assert by_aid[103]["growth"]["delta"] == 200 + assert by_aid[103]["growth"]["rate"] is None assert by_aid[103]["growth"]["gap_days"] == 0 assert by_aid[105]["growth"]["baseline"] == "open" assert by_aid[105]["growth"]["delta"] == 300 assert by_aid[101]["growth"]["baseline"] == "snapshot" assert by_aid[101]["growth"]["delta"] == 100 + 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 + seven = q.list_videos(sort="growth", growth_days=7) seven_aids = {item["aid"] for item in seven["items"]} assert 103 not in seven_aids @@ -774,6 +781,41 @@ def test_same_day_growth_baselines(): db.close() +def test_growth_rate_sort_uses_prior(): + """小基数的真实增速可以更高,排序分用基线 + prior,不按 raw rate 排。""" + with tempfile.TemporaryDirectory() as tmp: + db, q = setup(tmp) + try: + repo = Repository(db) + latest = DATES[-1] + 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))): + repo.upsert_from_fav( + resource(aid, up_mid=1, up_name="UP A", pubdate=pub), + FOLDER.media_id, was_added=True, now=start, + ) + repo.upsert_snapshot( + previous_day, aid, metrics=_view_metrics(views[0]), + ok=True, err=None, captured_at=start - 86400, + ) + repo.upsert_snapshot( + latest, aid, metrics=_view_metrics(views[1]), + ok=True, err=None, captured_at=start + 60, + ) + 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 + finally: + db.close() + + def test_system_tasks_sorted_by_started_at_desc(): """最近任务按 started_at 倒序;缺开始时间的排末尾(含进行中无 finished)。""" import json @@ -835,6 +877,7 @@ def main_test(): test_snapshot_recent_next_run_follows_completion() test_overview_adjacent_previous_with_seed_and_dual_fields() test_same_day_growth_baselines() + test_growth_rate_sort_uses_prior() print("OK: Web 查询层总览/榜单/详情/UP/时长效率/粉丝/候选/系统/访问统计测试全部通过")