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:
1 parent
39a6537f67
commit
00572a3429
11 files changed
+47
-27
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