Files
re-xxm/reaction_board/analytics.py
T
kairee 00572a3429 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.
2026-11-02 22:11:30 +08:00

383 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""看板指标计算。
这里的函数不访问数据库、网络或时钟,便于离线单元测试。SQL 查询与数据整形
放在 ``reaction_board.web.queries``,前端只负责展示。
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from statistics import median as _stat_median
from statistics import pstdev
from typing import Iterable, Mapping, Sequence
METRICS: tuple[str, ...] = (
"view",
"like",
"coin",
"favorite",
"share",
"reply",
"danmaku",
)
DEFAULT_HEAT_WEIGHTS: dict[str, float] = {
"view": 0.30,
"like": 0.20,
"coin": 0.20,
"favorite": 0.15,
"share": 0.10,
"reply": 0.03,
"danmaku": 0.02,
}
MIN_VIEW_FOR_INTERACTION = 100
MIN_VIEW_FOR_GROWTH_RATE = 1000
MIN_EFFICIENCY_DURATION = 600
DEFAULT_MATURITY_DAYS = 2
DEFAULT_SHRINK_MINUTES = 120.0
_IQR_TO_SIGMA = 1.349
def percentile(values: Sequence[float], p: float) -> float:
"""线性插值分位数;``p`` 取 0~1。空序列返回 0。"""
if not values:
return 0.0
ordered = sorted(float(value) for value in values)
if len(ordered) == 1:
return ordered[0]
p = min(max(float(p), 0.0), 1.0)
position = (len(ordered) - 1) * p
lower = math.floor(position)
upper = math.ceil(position)
if lower == upper:
return ordered[lower]
weight = position - lower
return ordered[lower] * (1 - weight) + ordered[upper] * weight
def robust_center_scale(values: Sequence[float]) -> tuple[float, float]:
"""中位数与稳健标准差(IQR / 1.349)。
当 IQR 为 0(指标全相同或样本过少)时退化为总体标准差;仍为 0 时返回
``(center, 0)``,调用方应把该维度的 z 值视为 0。
"""
if not values:
return 0.0, 0.0
numbers = [float(value) for value in values]
center = float(_stat_median(numbers))
scale = (percentile(numbers, 0.75) - percentile(numbers, 0.25)) / _IQR_TO_SIGMA
if scale <= 1e-12:
scale = float(pstdev(numbers)) if len(numbers) > 1 else 0.0
if scale <= 1e-12:
scale = 0.0
return center, scale
def _metric_value(row: Mapping[str, object], metric: str) -> float:
value = row.get(metric)
if value is None or isinstance(value, bool):
return 0.0
try:
return max(0.0, float(value))
except (TypeError, ValueError):
return 0.0
def content_hours(duration_seconds: object) -> float | None:
"""把视频总时长(秒)换算为内容小时;缺失或非正数返回 ``None``。
多 P 视频的 ``duration`` 已是各 P 之和,因此这里不需要再乘 P 数。
"""
if duration_seconds is None or isinstance(duration_seconds, bool):
return None
try:
seconds = float(duration_seconds)
except (TypeError, ValueError):
return None
if seconds <= 0:
return None
return seconds / 3600.0
def views_per_hour(view: object, duration_seconds: object) -> float | None:
"""播放/内容小时;时长缺失或非正数时返回 ``None``,不做除零。"""
hours = content_hours(duration_seconds)
if hours is None:
return None
return _metric_value({"view": view}, "view") / hours
def interactions_per_hour(
metrics: Mapping[str, object] | None,
duration_seconds: object,
*,
interaction_metrics: Sequence[str] = ("like", "coin", "favorite"),
) -> float | None:
"""互动/内容小时;统计缺失或时长无效时返回 ``None``。"""
hours = content_hours(duration_seconds)
if hours is None or metrics is None:
return None
interactions = sum(_metric_value(metrics, metric) for metric in interaction_metrics)
return interactions / hours
def age_days(pubdate: object, reference: int | float) -> int | None:
"""发布到参考时点(通常为最新数据日)的整天数;无效发布时间返回 ``None``。"""
if pubdate is None or isinstance(pubdate, bool):
return None
try:
published = int(pubdate)
now = int(reference)
except (TypeError, ValueError):
return None
if published <= 0:
return None
return max(0, (now - published) // 86400)
def is_mature(
pubdate: object,
reference: int | float,
maturity_days: int = DEFAULT_MATURITY_DAYS,
) -> bool:
"""是否已达到成熟窗口;发布时间缺失视为未成熟。"""
days = age_days(pubdate, reference)
return days is not None and days >= max(0, int(maturity_days))
def median_of(values: Iterable[object]) -> float | None:
"""忽略无效值的中位数;没有有效值返回 ``None``。"""
numbers: list[float] = []
for value in values:
if value is None or isinstance(value, bool):
continue
try:
numbers.append(float(value))
except (TypeError, ValueError):
continue
if not numbers:
return None
return float(_stat_median(numbers))
def effective_shrink_minutes(
per_up_minutes: Iterable[object],
fallback: float = DEFAULT_SHRINK_MINUTES,
) -> float:
"""自适应收缩强度:各 UP 总内容分钟的中位数,缺失时回退固定值。"""
positives: list[float] = []
for minutes in per_up_minutes:
if minutes is None or isinstance(minutes, bool):
continue
try:
value = float(minutes)
except (TypeError, ValueError):
continue
if value > 0:
positives.append(value)
value = median_of(positives)
if value is None or value <= 0:
return float(fallback)
return value
def eb_rate(
total: object,
exposure: object,
prior_rate: float,
prior_strength: float,
) -> float | None:
"""Gamma–Poisson 经验贝叶斯收缩后的速率。
``exposure`` 与 ``prior_strength`` 必须使用同一单位(本项目中为分钟),
返回单位与 ``prior_rate`` 一致(每分钟)。
"""
if prior_strength < 0:
raise ValueError("prior_strength 不能为负")
try:
total_value = max(0.0, float(total))
exposure_value = max(0.0, float(exposure))
prior = max(0.0, float(prior_rate))
except (TypeError, ValueError):
return None
if prior_strength <= 0:
return None if exposure_value <= 0 else total_value / exposure_value
return (total_value + prior_strength * prior) / (exposure_value + prior_strength)
def normalized_weights(weights: Mapping[str, float] | None = None) -> dict[str, float]:
source = weights or DEFAULT_HEAT_WEIGHTS
result: dict[str, float] = {}
for metric in METRICS:
value = source.get(metric, DEFAULT_HEAT_WEIGHTS[metric])
try:
result[metric] = max(0.0, float(value))
except (TypeError, ValueError):
result[metric] = 0.0
total = sum(result.values())
if total <= 0:
return {metric: 0.0 for metric in METRICS}
return {metric: value / total for metric, value in result.items()}
def heat_scores(
rows: Iterable[Mapping[str, object]],
weights: Mapping[str, float] | None = None,
) -> dict[int, float]:
"""对每行最新指标计算综合热度。
每个指标先 ``log1p``,再按中位数/IQR 做稳健标准化,最后加权求和。结果只
用于同一批数据内的排序,不跨日期比较。
"""
items = list(rows)
if not items:
return {}
normalized = normalized_weights(weights)
log_values: dict[str, list[float]] = {}
centers: dict[str, float] = {}
scales: dict[str, float] = {}
for metric in METRICS:
values = [math.log1p(_metric_value(row, metric)) for row in items]
log_values[metric] = values
centers[metric], scales[metric] = robust_center_scale(values)
scores: dict[int, float] = {}
for index, row in enumerate(items):
if "aid" not in row:
raise ValueError("heat_scores 需要每行包含 aid")
score = 0.0
for metric in METRICS:
weight = normalized[metric]
if weight == 0:
continue
scale = scales[metric]
if scale <= 0:
z = 0.0
else:
z = (log_values[metric][index] - centers[metric]) / scale
score += weight * z
scores[int(row["aid"])] = score
return scores
def score_percentiles(scores: Mapping[int, float]) -> dict[int, float]:
"""把分数映射为 0~100 的百分位,最高者 100,最低者 0。"""
if not scores:
return {}
ordered = sorted(scores.values())
total = len(ordered)
if total == 1:
return {aid: 100.0 for aid in scores}
result: dict[int, float] = {}
for aid, score in scores.items():
below = sum(1 for value in ordered if value < score)
result[aid] = round(below / (total - 1) * 100, 2)
return result
def interaction_rate(
metrics: Mapping[str, object],
*,
min_view: int = MIN_VIEW_FOR_INTERACTION,
) -> float | None:
"""互动率 = (点赞 + 投币 + 收藏) / 播放;播放不足门槛返回 None。"""
view = _metric_value(metrics, "view")
if view < min_view or view <= 0:
return None
interactions = sum(_metric_value(metrics, metric) for metric in ("like", "coin", "favorite"))
return interactions / view
@dataclass(frozen=True)
class Growth:
available: bool
delta: int | None = None
per_day: float | None = None
rate: float | None = None
gap_days: int | None = None
def as_dict(self) -> dict[str, object]:
return {
"available": self.available,
"delta": self.delta,
"per_day": self.per_day,
"rate": self.rate,
"gap_days": self.gap_days,
}
def compute_growth(
current: int | float | None,
previous: int | float | None,
gap_days: int | float | None,
*,
allow_zero_gap: bool = False,
) -> Growth:
"""计算累计差值、日均差值和相对增速。
缺失基线或间隔为负时返回不可用。默认间隔不足 1 天也不可用,不把 0 当成真实增长。
``allow_zero_gap=True`` 时允许同一天的两次采样(或相对 0)计算增量,``per_day`` 为 None。
"""
if current is None or previous is None or gap_days is None:
return Growth(available=False)
gap = int(gap_days)
if gap < 0:
return Growth(available=False)
if gap == 0 and not allow_zero_gap:
return Growth(available=False)
current_value = max(0, int(current))
previous_value = max(0, int(previous))
delta = current_value - previous_value
return Growth(
available=True,
delta=delta,
per_day=None if gap == 0 else delta / gap,
rate=delta / max(previous_value, 1),
gap_days=gap,
)
def smoothed_growth_rate(
delta: int | float | None,
previous: int | float | None,
prior: int | float,
) -> float | None:
"""增速榜排序分:``Δ / (基线 + prior)``。
``rate`` 仍是真实相对增速。``prior`` 只削弱小基数在排序里的优势。
"""
if delta is None or previous is None or isinstance(delta, bool) or isinstance(previous, bool):
return None
if isinstance(prior, bool):
return None
try:
strength = float(prior)
base = max(0, int(previous)) + strength
except (TypeError, ValueError):
return None
if strength < 0 or base <= 0:
return None
return int(delta) / base
def normalize_cover(url: object) -> str | None:
"""把 B 站封面统一为 https;空值返回 None。"""
if not isinstance(url, str):
return None
value = url.strip()
if not value:
return None
if value.startswith("//"):
return "https:" + value
if value.startswith("http://"):
return "https://" + value[len("http://"):]
return value
def is_loopback(host: str) -> bool:
value = (host or "").strip().lower()
return value in {"127.0.0.1", "localhost", "::1"}