feat(growth): same-day 1-day baselines for new videos #6
No files matched your search
+1
-1
@@ -608,7 +608,7 @@ interaction_rate = (like + coin + favorite) / max(view, 1)
|
||||
|
||||
- 查询前一有效快照而不是填补缺失日期;
|
||||
- 缺失日期在图表中断开;
|
||||
- 增速榜要求播放基数至少 200;
|
||||
- 增速榜要求播放基数至少 1000;
|
||||
- 互动率榜要求播放至少 100;
|
||||
- 样本不足两天的视频明确标注,不伪造增长数据。
|
||||
|
||||
|
||||
@@ -108,6 +108,6 @@
|
||||
"video_shrink_minutes": 60,
|
||||
"shrink_minutes": null,
|
||||
"home_growth_days": "auto",
|
||||
"growth_rate_prior": 500,
|
||||
"growth_rate_prior": 1000,
|
||||
},
|
||||
}
|
||||
@@ -124,6 +124,6 @@
|
||||
// "auto" = 7 日有数据用 7,否则 1
|
||||
"home_growth_days": "auto",
|
||||
// 增速榜排序:Δ / (基线 + prior)。卡片上的百分比仍是 Δ / 基线。
|
||||
"growth_rate_prior": 500,
|
||||
"growth_rate_prior": 1000,
|
||||
},
|
||||
}
|
||||
+2
-2
@@ -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 | — |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user