CH 23 / SOURCE EXAMPLE

d2q.py

ch23 时长预估建模/code/ch04_classic_methods/d2q.py

这个示例讲什么

│ ├── d2q.py §23.4.1 分位数 Label + 共享模型

D2Q:Duration 分组 → 组内分位数 Label → 共享模型 → 分位反变换(第23章 §23.4.1) 正文的三步: 1) 按视频物理时长的经验分位数把训练数据分成 M 个组(各组样本量近似相等); 2) 在每个组内用经验 CDF 把观看时长 w 变成分位数 q = Φ_k(w); 3) 用同一个模型 h(x) 回归 q,推理时再按样本所属组的经验分位函数反变换 ŵ = Φ_k^{-1}(q̂)。 本脚本对比: · 直接回归原始观看时长(MSE); · D2Q,并扫 M ∈ {5, 10, 20, 40},观察正文说的「分组太少去偏不够、 分组太多 CDF 估计噪声大」的折中。 评价除了整体 MAE,还看每个 Duration 组的平均偏差(预测均值 − 真实均值): 直接回归往往在中长视频上系统性高估,

上面来自章节 README 或源码自身的说明,不是自动验证的运行结果,也不能代替 PDF 的书中论断。讲解时请结合实际代码、输入输出与原书页码。

源码结构与阅读入口

  1. group_edges · 第 37 行按 Duration 的经验分位数切 M 组,返回 M+1 个边界(两端开)。
  2. assign · 第 44 行
  3. fit_d2q · 第 48 行返回 (组边界, 各组排序后的观看时长, 训练集分位数 Label)。
  4. predict_d2q · 第 63 行
  5. by_group_report · 第 74 行
  6. main · 第 90 行

完整源码 · 174 行

1"""2D2Q:Duration 分组 → 组内分位数 Label → 共享模型 → 分位反变换(第23章 §23.4.1)34正文的三步:5    1) 按视频物理时长的经验分位数把训练数据分成 M 个组(各组样本量近似相等);6    2) 在每个组内用经验 CDF 把观看时长 w 变成分位数 q = Φ_k(w);7    3) 用同一个模型 h(x) 回归 q,推理时再按样本所属组的经验分位函数反变换8       ŵ = Φ_k^{-1}(q̂)。910本脚本对比:11    · 直接回归原始观看时长(MSE);12    · D2Q,并扫 M ∈ {5, 10, 20, 40},观察正文说的「分组太少去偏不够、13      分组太多 CDF 估计噪声大」的折中。1415评价除了整体 MAE,还看每个 Duration 组的平均偏差(预测均值 − 真实均值):16直接回归往往在中长视频上系统性高估,这正是 Duration Bias 在预测端的表现。1718运行:python3 ch04_classic_methods/d2q.py19"""2021from __future__ import annotations2223import pathlib24import sys2526import numpy as np2728sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))2930from common import (  # noqa: E40231    fit_linear, make_watch_data, mae, predict_linear, split, standardize, table,32)3334SEED = 2026353637def group_edges(d: np.ndarray, M: int) -> np.ndarray:38    """按 Duration 的经验分位数切 M 组,返回 M+1 个边界(两端开)。"""39    edges = np.quantile(d, np.linspace(0.0, 1.0, M + 1))40    edges[0], edges[-1] = -np.inf, np.inf41    return edges424344def assign(d: np.ndarray, edges: np.ndarray) -> np.ndarray:45    return np.searchsorted(edges, d, side="right") - 1464748def fit_d2q(Xtr: np.ndarray, wtr: np.ndarray, dtr: np.ndarray, M: int):49    """返回 (组边界, 各组排序后的观看时长, 训练集分位数 Label)。"""50    edges = group_edges(dtr, M)51    ktr = assign(dtr, edges)52    groups = [np.sort(wtr[ktr == k]) for k in range(M)]53    qtr = np.empty(len(wtr))54    for k in range(M):55        m = ktr == k56        if not m.any():57            continue58        qtr[m] = np.searchsorted(groups[k], wtr[m], side="right") / len(groups[k])59    qtr = np.clip(qtr, 1e-4, 1 - 1e-4)60    return edges, groups, qtr616263def predict_d2q(theta, Xte, dte, edges, groups) -> np.ndarray:64    qhat = np.clip(predict_linear(theta, Xte), 1e-3, 1 - 1e-3)65    kte = assign(dte, edges)66    out = np.empty(len(dte))67    for k in range(len(groups)):68        m = kte == k69        if m.any():70            out[m] = np.quantile(groups[k], qhat[m])71    return out727374def by_group_report(dte, wte, pred, edges, M) -> list[list]:75    kte = assign(dte, edges)76    rows = []77    for k in range(M):78        m = kte == k79        if not m.any():80            continue81        lo = edges[k] if np.isfinite(edges[k]) else 082        hi = edges[k + 1] if np.isfinite(edges[k + 1]) else 60083        rows.append([f"[{lo:.0f},{hi:.0f})", int(m.sum()),84                     f"{wte[m].mean():.1f}", f"{pred[m].mean():.1f}",85                     f"{pred[m].mean() - wte[m].mean():+.1f}",86                     f"{mae(wte[m], pred[m]):.2f}"])87    return rows888990def main() -> None:91    print("D2Q:分位数 Label + 共享模型(§23.4.1)\n")92    data = make_watch_data(n=20000, dim=16, seed=SEED)93    X, w, d = data["x"], data["w"].astype(np.float64), data["d"]94    idx = np.arange(len(X))95    tr, te = split(idx, len(X), 0.8)96    Xtr, Xte, _, _ = standardize(X[tr], X[te])97    wtr, wte, dtr, dte = w[tr], w[te], d[tr], d[te]9899    # ---------------- 1) 基准:直接回归 ----------------100    print("① 基准:直接对原始观看时长做 MSE 回归")101    theta0 = fit_linear(Xtr, wtr)102    pred0 = np.clip(predict_linear(theta0, Xte), 0, None)103    edges0 = group_edges(dtr, 5)104    rows = by_group_report(dte, wte, pred0, edges0, 5)105    table(["Duration 组", "样本数", "真实均值", "预测均值", "偏差", "MAE"], rows)106    print(f"\n  整体 MAE {mae(wte, pred0):.2f} 秒。越往长视频走,偏差越大——")107    print("  模型把「Duration 长」和「看得久」直接关联起来,长视频被系统性高估。\n")108109    # ---------------- 2) D2Q 主实验 ----------------110    print("② D2Q:M=10 个 Duration 组")111    M = 10112    edges, groups, qtr = fit_d2q(Xtr, wtr, dtr, M)113    theta = fit_linear(Xtr, qtr)114    pred = predict_d2q(theta, Xte, dte, edges, groups)115    rows = by_group_report(dte, wte, pred, edges, 10)116    table(["Duration 组", "样本数", "真实均值", "预测均值", "偏差", "MAE"], rows)117    print(f"\n  整体 MAE {mae(wte, pred):.2f} 秒,直接回归是 {mae(wte, pred0):.2f} 秒。")118    print(f"  训练集分位数 Label 的均值 {qtr.mean():.3f}、标准差 {qtr.std():.3f}——")119    print("  Label 被压到 0~1 的统一尺度,长视频的「权重」不再由绝对秒数决定。")120    print("  但各组的偏差并没有变好:长视频组从 +0.8 秒变成 -33 秒。原因有两层:")121    print(f"    · 训练集 q 的标准差只有 {qtr.std():.3f},模型几乎学不到个体差异,")122    print("      q̂ 基本落在 0.5 附近(实测均值 0.51);")123    print("    · 而每个组的观看时长分布都严重右偏(长视频组均值 43 秒、中位数只有")124    print("      2.6 秒),q=0.5 反变换回来就是中位数,不是均值。")125    print("  所以 D2Q 降低的是「整体 MAE 被少数长样本拉爆」的程度,")126    print("  不是把每个组的预测校准到组均值——后者需要模型真的学到 q 的差异。")127    print("  这一点在正文里没有展开,但它是把 D2Q 落到线上时必须面对的问题。\n")128129    # ---------------- 3) M 的折中 ----------------130    print("③ Duration 组数 M 的折中(正文:太少去偏不足,太多 CDF 估计有噪声)")131    rows = []132    for M_ in (5, 10, 20, 40):133        edges_, groups_, qtr_ = fit_d2q(Xtr, wtr, dtr, M_)134        th_ = fit_linear(Xtr, qtr_)135        pr_ = predict_d2q(th_, Xte, dte, edges_, groups_)136        sizes = np.array([len(g) for g in groups_])137        bias = []138        kte = assign(dte, edges_)139        for k in range(M_):140            m = kte == k141            if m.any():142                bias.append(abs(pr_[m].mean() - wte[m].mean()))143        rows.append([M_, f"{sizes.min():,}", f"{mae(wte, pr_):.2f}",144                     f"{max(bias):.2f}"])145    table(["M", "最小组样本数", "整体 MAE", "最大组偏差"], rows)146    print("\n  最小组样本数随 M 线性下降,M=40 时已经出现空组(0 条)。")147    print("  但整体 MAE 随 M 增大只有轻微下降,最大组偏差反而单调变大:")148    print("  组越细分,最长那组里的观看时长分布越极端(均值被少数长观看拉高),")149    print("  而模型输出的 q̂ 仍挤在 0.5 附近,反变换的落点离组均值越远。")150    print("  这里没有出现正文说的「M 太大 MAE 回升」,因为 20000 条还撑得住")151    print("  40 个组;当数据量降到每个组只有几百条时,经验 CDF 尾部的噪声")152    print("  才会开始主导。M 是一个必须按数据量、按目标(MAE 还是校准)")153    print("  分别调的工程参数,不存在普遍最优。\n")154155    # ---------------- 4) 反变换的尾部处理 ----------------156    print("④ 分位反变换的尾部:q̂ 落在 (0.99, 1) 时怎么处理")157    tail = (predict_linear(theta, Xte) > 0.99).mean()158    print(f"  测试集有 {tail:.1%} 的样本 q̂ > 0.99。")159    print("  本例的线性模型把 q̂ 压在 0.5 附近,尾部根本没被触发,")160    print("  所以这一段只能说明规则、不能说明现象:模型表达力越强、")161    print("  q̂ 越分散,尾部处理就越重要。")162    print("  直接反变换会取到组内最大值附近(全局最大 %.0f 秒),"163          % np.concatenate(groups).max())164    print("  如果测试分布比训练集更重尾,这部分样本会被系统性低估。")165    print("  工业实现通常做法是:对 q̂ 截断到 [q_lo, q_hi](例如 [0.005, 0.995]),")166    print("  或者对尾部单独建一个「超过某阈值」的桶。\n")167168    print("⑤ 一句话:D2Q 没有改模型结构,改的是 Label 的坐标系——")169    print("   把每个 Duration 组内的观看时长投影到分位数空间,让共享模型")170    print("   学「相对水平」,把绝对秒数留给反变换处理。")171172173if __name__ == "__main__":174    main()

如何用于学习或编写新代码

  1. 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
  2. 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
  3. 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
  4. 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。