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.
This commit is contained in:
kairee committed 2026-11-02 22:11:30 +08:00
1 parent 39a6537f67
commit 00572a3429
11 files changed
+47 -27

No files matched your search

+1 -1
View File
@@ -608,7 +608,7 @@ interaction_rate = (like + coin + favorite) / max(view, 1)
- 查询前一有效快照而不是填补缺失日期;
- 缺失日期在图表中断开;
- 增速榜要求播放基数至少 200;
- 增速榜要求播放基数至少 1000;
- 互动率榜要求播放至少 100;
- 样本不足两天的视频明确标注,不伪造增长数据。
+1 -1
View File
@@ -108,6 +108,6 @@
"video_shrink_minutes": 60,
"shrink_minutes": null,
"home_growth_days": "auto",
"growth_rate_prior": 500,
"growth_rate_prior": 1000,
},
}
+1 -1
View File
@@ -124,6 +124,6 @@
// "auto" = 7 日有数据用 7,否则 1
"home_growth_days": "auto",
// 增速榜排序:Δ / (基线 + prior)。卡片上的百分比仍是 Δ / 基线。
"growth_rate_prior": 500,
"growth_rate_prior": 1000,
},
}
+2 -2
View File
@@ -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 | — |
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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,
),
+16 -3
View File
@@ -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 = (
+1 -1
View File
@@ -50,7 +50,7 @@ export async function renderAbout(root) {
<li><strong>播放 / 投币 / 收藏 / 评论</strong>:直接取当日快照;弹幕与转发不单列,已并入“互动”。</li>
<li><strong>互动</strong>:点赞 + 投币 + 收藏。综合热度也使用全部七项指标,按配置权重加权。</li>
<li><strong>互动率</strong>:(点赞 + 投币 + 收藏) / 播放;播放不足 100 不计算。</li>
<li><strong>播放增速</strong>:卡片上的百分比是增量 / 基线播放。排序用增量 / (基线 + ${analytics.growth_rate_prior}),避免小基数霸榜。当日发布没有基线,不进增速榜。</li>
<li><strong>播放增速</strong>:卡片上的百分比是增量 / 基线播放。基线播放不足 1000 不进增速榜。排序用增量 / (基线 + ${analytics.growth_rate_prior})。当日发布没有基线,不进增速榜。</li>
<li><strong>综合热度</strong>:每项先 <code>log1p</code>,再按中位数与 IQR 做稳健标准化,最后加权求和。分数只用于同一批数据内排序,不能跨日期比较。</li>
<li><strong>内容效率</strong>:播放 / 内容小时。内容小时 = 视频总时长(多 P 为各 P 之和)÷ 3600。榜单里的单条视频用收缩后的估计:<code>(播放 + κ·μ₀) / (小时 + κ)</code>,κ 默认 ${analytics.video_shrink_minutes} 分钟、μ₀ 为全库播放/小时;详情页保留原始播放/小时。</li>
<li><strong>互动效率</strong>:互动 / 内容小时,互动口径与收缩方式同上。</li>
+2 -2
View File
@@ -187,8 +187,8 @@ function growthNote(sort, growthDays, prior) {
? `百分比是真实相对增量;排序按平滑增速(基线 + ${strength})。`
: "百分比是真实相对增量;排序按平滑增速。";
const cold = Number(growthDays) === 1
? "当日发布不进增速榜。更早发布的新入库按入库时播放量起算,需要当天至少两次采样。"
: "";
? "基线播放不足 1000、以及当日发布,不进增速榜。"
: "基线播放不足 1000 不进增速榜。";
return `<div class="section-note">${smooth}${cold}</div>`;
}
if (sort !== "growth" || Number(growthDays) !== 1) return "";
+3 -3
View File
@@ -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
+17 -10
View File
@@ -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()