feat(growth): same-day 1-day baselines for new videos #6

Merged
kairee merged 5 commits from cursor/same-day-growth-baseline into main 2026-11-02 22:12:18 +08:00
11 changed files with 47 additions and 27 deletions
Showing only changes of commit 00572a3429 - Show all commits

No files matched your search

+1 -1
View File
@@ -608,7 +608,7 @@ interaction_rate = (like + coin + favorite) / max(view, 1)
- 查询前一有效快照而不是填补缺失日期; - 查询前一有效快照而不是填补缺失日期;
- 缺失日期在图表中断开; - 缺失日期在图表中断开;
- 增速榜要求播放基数至少 200; - 增速榜要求播放基数至少 1000;
- 互动率榜要求播放至少 100; - 互动率榜要求播放至少 100;
- 样本不足两天的视频明确标注,不伪造增长数据。 - 样本不足两天的视频明确标注,不伪造增长数据。
+1 -1
View File
@@ -108,6 +108,6 @@
"video_shrink_minutes": 60, "video_shrink_minutes": 60,
"shrink_minutes": null, "shrink_minutes": null,
"home_growth_days": "auto", "home_growth_days": "auto",
"growth_rate_prior": 500, "growth_rate_prior": 1000,
}, },
} }
+1 -1
View File
@@ -124,6 +124,6 @@
// "auto" = 7 日有数据用 7,否则 1 // "auto" = 7 日有数据用 7,否则 1
"home_growth_days": "auto", "home_growth_days": "auto",
// 增速榜排序:Δ / (基线 + prior)。卡片上的百分比仍是 Δ / 基线。 // 增速榜排序:Δ / (基线 + 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) 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 # 需同日第二次采样 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 | — | | 累计热度 | §8.1 | — |
| 近 7 天播放增量 | `d7(view)` | — | | 近 7 天播放增量 | `d7(view)` | — |
| 近 7 天增速 | `r7(view)` | `view >= 200` | | 近 7 天增速 | `r7(view)` | 基线播放 `>= 1000` |
| 互动率 | `(like+coin+fav)/view` | `view >= 100` | | 互动率 | `(like+coin+fav)/view` | `view >= 100` |
| 单项榜 | 7 项直接排序 | — | | 单项榜 | 7 项直接排序 | — |
| UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — | | 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_INTERACTION = 100
MIN_VIEW_FOR_GROWTH_RATE = 200 MIN_VIEW_FOR_GROWTH_RATE = 1000
MIN_EFFICIENCY_DURATION = 600 MIN_EFFICIENCY_DURATION = 600
DEFAULT_MATURITY_DAYS = 2 DEFAULT_MATURITY_DAYS = 2
DEFAULT_SHRINK_MINUTES = 120.0 DEFAULT_SHRINK_MINUTES = 120.0
+2 -2
View File
@@ -52,7 +52,7 @@ DEFAULT_ANALYTICS: dict[str, Any] = {
"video_shrink_minutes": 60, "video_shrink_minutes": 60,
"shrink_minutes": None, "shrink_minutes": None,
"home_growth_days": "auto", "home_growth_days": "auto",
"growth_rate_prior": 500, "growth_rate_prior": 1000,
} }
DEFAULT_CONFIG: dict[str, Any] = { 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, home_growth_days=home_growth_days,
growth_rate_prior=_as_int( growth_rate_prior=_as_int(
analytics_raw.get("growth_rate_prior", 500), analytics_raw.get("growth_rate_prior", 1000),
"analytics.growth_rate_prior", "analytics.growth_rate_prior",
minimum=0, 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] 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: def _date_minus(day: str, days: int) -> str:
return (date.fromisoformat(day) - timedelta(days=int(days))).isoformat() return (date.fromisoformat(day) - timedelta(days=int(days))).isoformat()
@@ -1036,7 +1049,7 @@ class DashboardQueries:
item for item in items item for item in items
if item.get("growth", {}).get("baseline") != "zero" if item.get("growth", {}).get("baseline") != "zero"
and item.get("growth", {}).get("rate") is not None 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)) rate_prior = max(0, int(analytics.growth_rate_prior))
@@ -1087,8 +1100,8 @@ class DashboardQueries:
available = False available = False
if growth_days == 1 and sort == "growth_rate": if growth_days == 1 and sort == "growth_rate":
reason = ( reason = (
"近 1 日增速需要前一日快照,或更早发布的新入库当天至少两次成功采样;" "近 1 日增速需要基线播放至少 "
"当日发布不计入增速" f"{MIN_VIEW_FOR_GROWTH_RATE};当日发布不计入增速"
) )
elif growth_days == 1: elif growth_days == 1:
reason = ( 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>:点赞 + 投币 + 收藏。综合热度也使用全部七项指标,按配置权重加权。</li> <li><strong>互动</strong>:点赞 + 投币 + 收藏。综合热度也使用全部七项指标,按配置权重加权。</li>
<li><strong>互动率</strong>:(点赞 + 投币 + 收藏) / 播放;播放不足 100 不计算。</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>:每项先 <code>log1p</code>,再按中位数与 IQR 做稳健标准化,最后加权求和。分数只用于同一批数据内排序,不能跨日期比较。</li>
<li><strong>内容效率</strong>:播放 / 内容小时。内容小时 = 视频总时长(多 P 为各 P 之和)÷ 3600。榜单里的单条视频用收缩后的估计:<code>(播放 + κ·μ₀) / (小时 + κ)</code>,κ 默认 ${analytics.video_shrink_minutes} 分钟、μ₀ 为全库播放/小时;详情页保留原始播放/小时。</li> <li><strong>内容效率</strong>:播放 / 内容小时。内容小时 = 视频总时长(多 P 为各 P 之和)÷ 3600。榜单里的单条视频用收缩后的估计:<code>(播放 + κ·μ₀) / (小时 + κ)</code>,κ 默认 ${analytics.video_shrink_minutes} 分钟、μ₀ 为全库播放/小时;详情页保留原始播放/小时。</li>
<li><strong>互动效率</strong>:互动 / 内容小时,互动口径与收缩方式同上。</li> <li><strong>互动效率</strong>:互动 / 内容小时,互动口径与收缩方式同上。</li>
+2 -2
View File
@@ -187,8 +187,8 @@ function growthNote(sort, growthDays, prior) {
? `百分比是真实相对增量;排序按平滑增速(基线 + ${strength})。` ? `百分比是真实相对增量;排序按平滑增速(基线 + ${strength})。`
: "百分比是真实相对增量;排序按平滑增速。"; : "百分比是真实相对增量;排序按平滑增速。";
const cold = Number(growthDays) === 1 const cold = Number(growthDays) === 1
? "当日发布不进增速榜。更早发布的新入库按入库时播放量起算,需要当天至少两次采样。" ? "基线播放不足 1000、以及当日发布,不进增速榜。"
: ""; : "基线播放不足 1000 不进增速榜。";
return `<div class="section-note">${smooth}${cold}</div>`; return `<div class="section-note">${smooth}${cold}</div>`;
} }
if (sort !== "growth" || Number(growthDays) !== 1) return ""; 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.video_shrink_minutes == 60
assert cfg.analytics.shrink_minutes is None assert cfg.analytics.shrink_minutes is None
assert cfg.analytics.home_growth_days == "auto" 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_snapshot_minutes == 60
assert cfg.schedule.recent_video_days == 2 assert cfg.schedule.recent_video_days == 2
assert cfg.schedule.snapshot_at == "23:15" assert cfg.schedule.snapshot_at == "23:15"
@@ -160,7 +160,7 @@ def test_analytics_defaults_and_validation():
"video_shrink_minutes": 30, "video_shrink_minutes": 30,
"shrink_minutes": 90, "shrink_minutes": 90,
"home_growth_days": 1, "home_growth_days": 1,
"growth_rate_prior": 1000, "growth_rate_prior": 2000,
} }
data["schedule"] = { data["schedule"] = {
"discover_minutes": 30, "discover_minutes": 30,
@@ -178,7 +178,7 @@ def test_analytics_defaults_and_validation():
assert cfg.analytics.video_shrink_minutes == 30 assert cfg.analytics.video_shrink_minutes == 30
assert cfg.analytics.shrink_minutes == 90 assert cfg.analytics.shrink_minutes == 90
assert cfg.analytics.home_growth_days == 1 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_snapshot_minutes == 15
assert cfg.schedule.recent_video_days == 3 assert cfg.schedule.recent_video_days == 3
+17 -10
View File
@@ -492,7 +492,7 @@ def test_about_payload():
"video_shrink_minutes": 60, "video_shrink_minutes": 60,
"shrink_minutes": None, "shrink_minutes": None,
"home_growth_days": "auto", "home_growth_days": "auto",
"growth_rate_prior": 500, "growth_rate_prior": 1000,
} }
assert about["heat_weights"]["view"] == 0.30 assert about["heat_weights"]["view"] == 0.30
assert about["followers"]["videos_total"] == 3 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 = q.list_videos(sort="growth_rate", growth_days=1, page_size=20)
rate_aids = {item["aid"] for item in rate["items"]} rate_aids = {item["aid"] for item in rate["items"]}
assert 103 not in rate_aids assert 103 not in rate_aids and 105 not in rate_aids
assert 101 in rate_aids and 105 in rate_aids assert 101 in rate_aids
seven = q.list_videos(sort="growth", growth_days=7) seven = q.list_videos(sort="growth", growth_days=7)
seven_aids = {item["aid"] for item in seven["items"]} 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(): def test_growth_rate_sort_uses_prior():
"""小基数的真实增速可以更高,排序分用基线 + prior,不按 raw rate 排。""" """基线不足 1000 不进增速榜;达标后按基线 + prior 排序,不按 raw rate。"""
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp) db, q = setup(tmp)
try: try:
@@ -791,7 +791,9 @@ def test_growth_rate_sort_uses_prior():
previous_day = DATES[-2] previous_day = DATES[-2]
start = int(day_bounds(latest)[0]) start = int(day_bounds(latest)[0])
pub = int(day_bounds("2026-09-01")[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( repo.upsert_from_fav(
resource(aid, up_mid=1, up_name="UP A", pubdate=pub), resource(aid, up_mid=1, up_name="UP A", pubdate=pub),
FOLDER.media_id, was_added=True, now=start, FOLDER.media_id, was_added=True, now=start,
@@ -807,11 +809,16 @@ def test_growth_rate_sort_uses_prior():
DASHBOARD_CACHE.clear() DASHBOARD_CACHE.clear()
ranked = q.list_videos(sort="growth_rate", growth_days=1, page_size=20) ranked = q.list_videos(sort="growth_rate", growth_days=1, page_size=20)
order = [item["aid"] for item in ranked["items"]] order = [item["aid"] for item in ranked["items"]]
assert ranked["growth_rate_prior"] == 500 assert ranked["growth_rate_prior"] == 1000
assert order.index(302) < order.index(301) assert 301 not in order
small = next(item for item in ranked["items"] if item["aid"] == 301) assert order.index(302) < order.index(304)
assert abs(small["growth"]["rate"] - 432 / 80) < 1e-12 modest = next(item for item in ranked["items"] if item["aid"] == 304)
assert small["growth"]["delta"] == 432 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: finally:
db.close() db.close()