fix(growth): rank the rate board by a smoothed score
Show the real percentage on the card, but sort with delta / (baseline + prior) so a small baseline cannot dominate. Same-day publishes stay off the rate board.
This commit is contained in:
1 parent
0f2ac612f5
commit
39a6537f67
14 files changed
+158
-16
No files matched your search
@@ -20,6 +20,7 @@ from ..analytics import (
|
||||
METRICS,
|
||||
MIN_VIEW_FOR_GROWTH_RATE,
|
||||
compute_growth,
|
||||
smoothed_growth_rate,
|
||||
content_hours,
|
||||
eb_rate,
|
||||
effective_shrink_minutes,
|
||||
@@ -437,6 +438,8 @@ class DashboardQueries:
|
||||
growth = compute_growth(current, 0, 0, allow_zero_gap=True)
|
||||
payload = growth.as_dict()
|
||||
payload["baseline"] = "zero"
|
||||
# previous=0 时 rate = view/1,会按原始播放碾压真日环比。
|
||||
payload["rate"] = None
|
||||
return payload
|
||||
try:
|
||||
open_at = snapshot.get("open_captured_at")
|
||||
@@ -1031,9 +1034,13 @@ class DashboardQueries:
|
||||
if sort == "growth_rate":
|
||||
items = [
|
||||
item for item in items
|
||||
if (item["metrics"] or {}).get("view", 0) >= MIN_VIEW_FOR_GROWTH_RATE
|
||||
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
|
||||
]
|
||||
|
||||
rate_prior = max(0, int(analytics.growth_rate_prior))
|
||||
|
||||
def sort_key(item: Mapping[str, Any]):
|
||||
if sort == "added":
|
||||
return (int(item["first_seen"]), int(item["pubdate"]))
|
||||
@@ -1050,9 +1057,18 @@ class DashboardQueries:
|
||||
)
|
||||
value = item.get(field)
|
||||
return value if value is not None else float("-inf")
|
||||
if sort in {"growth", "growth_rate"}:
|
||||
field = "delta" if sort == "growth" else "rate"
|
||||
value = item.get("growth", {}).get(field)
|
||||
if sort == "growth":
|
||||
value = item.get("growth", {}).get("delta")
|
||||
return value if value is not None else float("-inf")
|
||||
if sort == "growth_rate":
|
||||
growth = item.get("growth") or {}
|
||||
delta = growth.get("delta")
|
||||
current = (item.get("metrics") or {}).get("view")
|
||||
if delta is None or current is None:
|
||||
return float("-inf")
|
||||
value = smoothed_growth_rate(
|
||||
delta, int(current) - int(delta), rate_prior,
|
||||
)
|
||||
return value if value is not None else float("-inf")
|
||||
value = (item["metrics"] or {}).get(sort)
|
||||
return value if value is not None else -1
|
||||
@@ -1069,7 +1085,12 @@ class DashboardQueries:
|
||||
reason = None
|
||||
if sort in {"growth", "growth_rate"} and not growth_available:
|
||||
available = False
|
||||
if growth_days == 1:
|
||||
if growth_days == 1 and sort == "growth_rate":
|
||||
reason = (
|
||||
"近 1 日增速需要前一日快照,或更早发布的新入库当天至少两次成功采样;"
|
||||
"当日发布不计入增速"
|
||||
)
|
||||
elif growth_days == 1:
|
||||
reason = (
|
||||
"近 1 日增长需要前一日快照;当日发布有一次成功采样即可;"
|
||||
"更早发布的新入库需要当天至少两次成功采样"
|
||||
@@ -1108,6 +1129,7 @@ class DashboardQueries:
|
||||
"available": available,
|
||||
"reason": reason,
|
||||
"duration_filter": duration_filter,
|
||||
"growth_rate_prior": rate_prior if sort == "growth_rate" else None,
|
||||
}
|
||||
return self.db.read(run)
|
||||
|
||||
@@ -1754,6 +1776,7 @@ class DashboardQueries:
|
||||
"video_shrink_minutes": cfg.analytics.video_shrink_minutes,
|
||||
"shrink_minutes": cfg.analytics.shrink_minutes,
|
||||
"home_growth_days": cfg.analytics.home_growth_days,
|
||||
"growth_rate_prior": cfg.analytics.growth_rate_prior,
|
||||
},
|
||||
"heat_weights": {
|
||||
metric: float(value)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
coverUrl, escapeAttr, escapeHtml, formatCount, formatDate,
|
||||
formatDelta, formatDuration, formatPercent, metricLabel, statusLabel,
|
||||
formatDelta, formatDuration, formatPercent, formatSignedPercent, metricLabel, statusLabel,
|
||||
} from "./format.js";
|
||||
|
||||
export function cover(item, className = "thumb") {
|
||||
@@ -38,7 +38,7 @@ function growthBaselineTag(growth) {
|
||||
|
||||
export function videoCard(item, {
|
||||
rank = null, primary = "view", primaryLabel = "", primaryText = null,
|
||||
showStatus = false,
|
||||
showStatus = false, growthField = "delta",
|
||||
} = {}) {
|
||||
const metrics = item.metrics || {};
|
||||
const hasMetric = Object.prototype.hasOwnProperty.call(metrics, primary);
|
||||
@@ -49,7 +49,13 @@ export function videoCard(item, {
|
||||
const primaryValue = typeof raw === "number" ? formatCount(raw) : raw;
|
||||
const label = primaryLabel || metricLabel(primary);
|
||||
const growth = item.growth;
|
||||
const delta = growth?.available ? growth.delta : null;
|
||||
const showRate = growthField === "rate";
|
||||
const rate = showRate && growth?.available ? growth.rate : null;
|
||||
const delta = !showRate && growth?.available ? growth.delta : null;
|
||||
const change = showRate
|
||||
? (rate === null || rate === undefined ? null : formatSignedPercent(rate))
|
||||
: (delta === null || delta === undefined ? null : formatDelta(delta));
|
||||
const changeUp = showRate ? Number(rate) >= 0 : Number(delta) >= 0;
|
||||
const title = escapeHtml(item.title || "(无标题)");
|
||||
const up = escapeHtml(item.up_name || "未知 UP");
|
||||
const upMid = item.up_mid;
|
||||
@@ -79,7 +85,7 @@ export function videoCard(item, {
|
||||
<span class="primary-stat">
|
||||
<b>${escapeHtml(primaryValue ?? "—")}</b><small>${escapeHtml(label)}</small>
|
||||
</span>
|
||||
${delta !== null ? `<span class="delta ${delta >= 0 ? "up" : "down"}">${formatDelta(delta)}${growthBaselineTag(growth)}</span>` : ""}
|
||||
${change !== null ? `<span class="delta ${changeUp ? "up" : "down"}">${change}${growthBaselineTag(growth)}</span>` : ""}
|
||||
<span class="secondary-stat">互动 ${formatPercent(item.interaction_rate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,12 @@ export function formatPercent(value, digits = 2) {
|
||||
return `${(Number(value) * 100).toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
export function formatSignedPercent(value, digits = 2) {
|
||||
const text = formatPercent(value, digits);
|
||||
if (text === "—" || Number(value) <= 0) return text;
|
||||
return `+${text}`;
|
||||
}
|
||||
|
||||
export function formatDate(timestamp) {
|
||||
if (!timestamp) return "—";
|
||||
const date = new Date(Number(timestamp) * 1000);
|
||||
|
||||
@@ -50,6 +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>:每项先 <code>log1p</code>,再按中位数与 IQR 做稳健标准化,最后加权求和。分数只用于同一批数据内排序,不能跨日期比较。</li>
|
||||
<li><strong>内容效率</strong>:播放 / 内容小时。内容小时 = 视频总时长(多 P 为各 P 之和)÷ 3600。榜单里的单条视频用收缩后的估计:<code>(播放 + κ·μ₀) / (小时 + κ)</code>,κ 默认 ${analytics.video_shrink_minutes} 分钟、μ₀ 为全库播放/小时;详情页保留原始播放/小时。</li>
|
||||
<li><strong>互动效率</strong>:互动 / 内容小时,互动口径与收缩方式同上。</li>
|
||||
|
||||
@@ -166,6 +166,7 @@ function listHtml(data, scope, sort, startRank) {
|
||||
return videoCard(item, {
|
||||
rank: startRank + index, primary: primary.primary,
|
||||
primaryLabel: primary.label, primaryText: primary.text,
|
||||
growthField: sort === "growth_rate" ? "rate" : "delta",
|
||||
});
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
@@ -179,9 +180,18 @@ function durationNote(data) {
|
||||
return `<div class="section-note">内容效率与互动效率:时长 ≥ ${filter.min_duration_seconds} 秒、发布满 ${filter.maturity_days} 天、播放 ≥ ${filter.min_view}${shrink};时长缺失不参与。</div>`;
|
||||
}
|
||||
|
||||
function growthNote(sort, growthDays) {
|
||||
if (sort !== "growth" && sort !== "growth_rate") return "";
|
||||
if (Number(growthDays) !== 1) return "";
|
||||
function growthNote(sort, growthDays, prior) {
|
||||
if (sort === "growth_rate") {
|
||||
const strength = Number(prior);
|
||||
const smooth = Number.isFinite(strength)
|
||||
? `百分比是真实相对增量;排序按平滑增速(基线 + ${strength})。`
|
||||
: "百分比是真实相对增量;排序按平滑增速。";
|
||||
const cold = Number(growthDays) === 1
|
||||
? "当日发布不进增速榜。更早发布的新入库按入库时播放量起算,需要当天至少两次采样。"
|
||||
: "";
|
||||
return `<div class="section-note">${smooth}${cold}</div>`;
|
||||
}
|
||||
if (sort !== "growth" || Number(growthDays) !== 1) return "";
|
||||
return `<div class="section-note">近 1 日:当日发布按 0 起算;更早发布的新入库按入库时播放量起算,需要当天至少两次采样。</div>`;
|
||||
}
|
||||
|
||||
@@ -251,7 +261,7 @@ async function renderScope(root, { query }, scope) {
|
||||
</header>
|
||||
${controls({ scope, sort, within, year, growthDays, q, years })}
|
||||
${durationNote(data)}
|
||||
${growthNote(sort, growthDays)}
|
||||
${growthNote(sort, growthDays, data.growth_rate_prior)}
|
||||
${data.available === false ? `<div class="notice-row warning">
|
||||
<strong>${sort.startsWith("growth") ? "增长数据尚不可用" : "榜单筛选后为空"}</strong>
|
||||
<span>${escapeHtml(data.reason || "")}</span>
|
||||
|
||||
Reference in new issue
Block a user