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()