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.
383 lines
12 KiB
Python
383 lines
12 KiB
Python
# -*- 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"}
|