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;
|
- 互动率榜要求播放至少 100;
|
||||||
- 样本不足两天的视频明确标注,不伪造增长数据。
|
- 样本不足两天的视频明确标注,不伪造增长数据。
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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 | — |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 "";
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user