CH 23 / SOURCE EXAMPLE

weighted_lr.py

ch23 时长预估建模/code/ch02_weighted_binary/weighted_lr.py

这个示例讲什么

│ └── weighted_lr.py 曝光展开、迭代曲线、长尾误差、三种时长权重

Weighted LR 与 MSE 回归:两条路径都指向 E[t|x],但梯度行为不同(第23章 §23.2) 正文的两个论点,本脚本都用可跑的对照实验验证: 1) WLR 把一次曝光展开成「负样本(权重 1)+ 正样本(权重 t)」, 损失为 L = −t·log p − log(1−p);最优解满足 p/(1−p) = E[t|x], 所以推理时用 e^z 即可反解出观看时长。 2) WLR 与 MSE 在最优解上都逼近 E[t|x],但 · MSE 的梯度是 2(t̂ − t),长时长样本的误差被平方放大; · WLR 的梯度是 t·σ(−z) − σ(z),长时长以权重形式进入分类损失, 可以用 min(t, T_max) 或 log(1+t) 截断长尾。 这里还顺带展示:权重一改,反解的目标就不再是 E[t],而是 E[w(t)]。

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

源码结构与阅读入口

  1. fit_wlr · 第 36 行直接对 WLR 损失做梯度下降:L = mean(w·softplus(−z) + softplus(z))。
  2. wlr_train_curve · 第 69 行固定其它条件,只记录不同迭代步数时的验证集 MAE。
  3. mse_train_curve · 第 96 行MSE 同样用梯度下降,为了与 WLR 的曲线可比(闭式解会一步到最优)。
  4. main · 第 116 行

完整源码 · 218 行

1"""2Weighted LR 与 MSE 回归:两条路径都指向 E[t|x],但梯度行为不同(第23章 §23.2)34正文的两个论点,本脚本都用可跑的对照实验验证:561) WLR 把一次曝光展开成「负样本(权重 1)+ 正样本(权重 t)」,7   损失为 L = −t·log p − log(1−p);最优解满足 p/(1−p) = E[t|x],8   所以推理时用 e^z 即可反解出观看时长。9102) WLR 与 MSE 在最优解上都逼近 E[t|x],但11   · MSE 的梯度是 2(t̂ − t),长时长样本的误差被平方放大;12   · WLR 的梯度是 t·σ(−z) − σ(z),长时长以权重形式进入分类损失,13     可以用 min(t, T_max) 或 log(1+t) 截断长尾。14   这里还顺带展示:权重一改,反解的目标就不再是 E[t],而是 E[w(t)]。1516运行:python3 ch02_weighted_binary/weighted_lr.py17"""1819from __future__ import annotations2021import pathlib22import sys2324import numpy as np2526sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))2728from common import (  # noqa: E40229    fit_linear, fit_logistic, make_watch_data, mae, predict_linear,30    predict_logistic, rmse, sigmoid, split, standardize, table,31)3233SEED = 2026343536def fit_wlr(X: np.ndarray, t: np.ndarray, w: np.ndarray | None = None,37            l2: float = 1e-3, iters: int = 300, lr: float = 0.5,38            clip: float | None = 1.0) -> np.ndarray:39    """40    直接对 WLR 损失做梯度下降:L = mean(w·softplus(−z) + softplus(z))。4142    每个样本的梯度:−w·σ(−z)·x + σ(z)·x43    与正文 lstlisting 里的 `duration * softplus(-logits) + softplus(logits)` 完全对应。4445    clip 是对「平均梯度」的 L2 范数裁剪。实测不加裁剪时,初始 z=0、权重46    t 最大到 300,第一步就能把 logit 推到 20 以上(e^z ≈ 5 亿秒),验证集47    MAE 直接爆到几千秒。这正是正文说的「直接用原始时长当权重,极端长时长48    样本会产生过大梯度」,工业实现里对应梯度裁剪或更低的学习率。49    裁剪只影响轨迹、不影响最优解(最优解处梯度本来就小)。50    """51    n, dim = X.shape52    Xb = np.concatenate([X, np.ones((n, 1))], axis=1)53    theta = np.zeros(dim + 1)54    ww = t.astype(np.float64) if w is None else w.astype(np.float64)55    for _ in range(iters):56        z = Xb @ theta57        # ∂L/∂z = −w·σ(−z) + σ(z)58        g = -ww * sigmoid(-z) + sigmoid(z)59        grad = Xb.T @ g / n60        grad[:-1] += l2 * theta[:-1]61        if clip is not None:62            norm = float(np.linalg.norm(grad))63            if norm > clip:64                grad *= clip / norm65        theta -= lr * grad66    return theta676869def wlr_train_curve(X: np.ndarray, t: np.ndarray, Xte: np.ndarray,70                    tte: np.ndarray, marks: list[int],71                    clip: float | None = 1.0) -> dict[int, float]:72    """固定其它条件,只记录不同迭代步数时的验证集 MAE。"""73    out, n, dim = {}, len(X), X.shape[1]74    Xb = np.concatenate([X, np.ones((n, 1))], axis=1)75    Xte_b = np.concatenate([Xte, np.ones((len(Xte), 1))], axis=1)76    theta = np.zeros(dim + 1)77    ww = t.astype(np.float64)78    done = 079    for mark in marks:80        for _ in range(mark - done):81            z = Xb @ theta82            g = -ww * sigmoid(-z) + sigmoid(z)83            grad = Xb.T @ g / n84            grad[:-1] += 1e-3 * theta[:-1]85            if clip is not None:86                norm = float(np.linalg.norm(grad))87                if norm > clip:88                    grad *= clip / norm89            theta -= 0.5 * grad90        done = mark91        pred = np.exp(np.clip(Xte_b @ theta, -10, 10))92        out[mark] = mae(tte, pred)93    return out949596def mse_train_curve(X: np.ndarray, t: np.ndarray, Xte: np.ndarray,97                    tte: np.ndarray, marks: list[int], lr: float = 0.298                    ) -> dict[int, float]:99    """MSE 同样用梯度下降,为了与 WLR 的曲线可比(闭式解会一步到最优)。"""100    out, n, dim = {}, len(X), X.shape[1]101    Xb = np.concatenate([X, np.ones((n, 1))], axis=1)102    Xte_b = np.concatenate([Xte, np.ones((len(Xte), 1))], axis=1)103    theta = np.zeros(dim + 1)104    done = 0105    for mark in marks:106        for _ in range(mark - done):107            r = Xb @ theta - t108            grad = 2.0 * Xb.T @ r / n109            grad[:-1] += 1e-3 * theta[:-1]110            theta -= lr * grad111        done = mark112        out[mark] = mae(tte, Xte_b @ theta)113    return out114115116def main() -> None:117    print("Weighted LR vs MSE(§23.2)\n")118    data = make_watch_data(n=20000, dim=16, seed=SEED)119    X, t = data["x"], data["w"]120    idx = np.arange(len(X))121    tr, te = split(idx, len(X), 0.8)122    Xtr, Xte, _, _ = standardize(X[tr], X[te])123    ttr, tte = t[tr].astype(np.float64), t[te].astype(np.float64)124125    # ---------------- 1) 样本展开 ----------------126    print("① 把一次曝光展开成两条训练样本:")127    pos = ttr > 0128    rows = [129        ["有观看(t>0)", f"{pos.sum():,}", "2", "y=0,w=1 与 y=1,w=t"],130        ["零观看(t=0)", f"{(~pos).sum():,}", "1", "只有 y=0,w=1"],131        ["等价样本数", f"{int(pos.sum()) * 2 + int((~pos).sum()):,}",132         "—", f"原始 {len(ttr):,} 条曝光"],133    ]134    table(["样本", "条数", "展开条数", "说明"], rows)135    print("\n  零观看样本只贡献负样本损失——这正是 WLR 相对 MSE 的优势:")136    print("  大量曝光未观看的样本不会被当成一堆 0 去做回归,而是被当作「没兴趣」的")137    print("  负证据,让模型先把 p 压下去,再靠有观看的样本把 Odds 抬起来。\n")138139    # ---------------- 2) 训练过程 ----------------140    print("② 验证集 MAE 随迭代步数的变化(同一组特征、同一初始化):")141    marks = [1, 2, 5, 10, 20, 50]142    c_wlr = wlr_train_curve(Xtr, ttr, Xte, tte, marks)143    c_mse = mse_train_curve(Xtr, ttr, Xte, tte, marks)144    rows = [[m, f"{c_wlr[m]:.3f}", f"{c_mse[m]:.3f}"] for m in marks]145    table(["迭代步数", "WLR MAE(秒)", "MSE MAE(秒)"], rows)146    print(f"\n  两边都在几十步内收敛(50 步之后 {c_wlr[50]:.3f} / "147          f"{c_mse[50]:.3f} 不再变化)。WLR 的曲线在 5~20 步之间有轻微反弹,")148    print("  这是梯度裁剪 + 固定学习率的正常轨迹;MSE 是凸二次,单调下降。")149    print("  两者最终的 MAE 只差 0.6 秒左右——16 维线性特征能表达的东西有限,")150    print("  换更宽的模型才会拉开差距。这里真正要看的不是「谁收敛快」,")151    print("  而是不加裁剪时 WLR 第一步就把 logit 推到 20 以上(见 fit_wlr 的注释):")152    print("  原始时长做权重,极端样本的梯度必须处理,否则训练根本走不稳。\n")153154    # ---------------- 3) 最终效果 ----------------155    print("③ 训练完成后:MAE / RMSE 与长尾样本上的表现")156    th_w = fit_wlr(Xtr, ttr)157    z_w = predict_logistic(th_w, Xte)158    pred_w = np.exp(np.clip(z_w, -10, 10))159160    th_m = fit_linear(Xtr, ttr)161    pred_m = predict_linear(th_m, Xte)162    pred_m = np.clip(pred_m, 0, None)163164    long_ = tte >= 120165    rows = [166        ["WLR(e^z 反解)", f"{mae(tte, pred_w):.3f}", f"{rmse(tte, pred_w):.3f}",167         f"{mae(tte[long_], pred_w[long_]):.3f}" if long_.any() else "—"],168        ["MSE 回归", f"{mae(tte, pred_m):.3f}", f"{rmse(tte, pred_m):.3f}",169         f"{mae(tte[long_], pred_m[long_]):.3f}" if long_.any() else "—"],170        ["全预测均值(基线)", f"{mae(tte, np.full_like(tte, ttr.mean())):.3f}",171         f"{rmse(tte, np.full_like(tte, ttr.mean())):.3f}",172         f"{mae(tte[long_], np.full_like(tte[long_], ttr.mean())):.3f}"],173    ]174    table(["模型", "整体 MAE", "整体 RMSE", "长视频(≥120s) MAE"], rows)175    print(f"\n  测试集真实时长:均值 {tte.mean():.1f}s,P50 {np.median(tte):.1f}s,"176          f"P99 {np.quantile(tte, 0.99):.1f}s——典型的长尾。")177    print("  两个模型都在逼近同一个条件期望,但误差结构不同:MSE 的平方损失让")178    print("  少数长时长样本主导训练,WLR 用 Odds 空间把损失压到更平缓的尺度。")179    print("  不过也不能断言 WLR 的 MAE 一定更低,正文特意强调过这一点:两种损失的")180    print("  尺度本身不同,比较的是「对长尾的鲁棒性」而不是某一次实验的排名。\n")181182    # ---------------- 4) 时长权重的三种处理 ----------------183    print("④ 时长权重 w(t) 的三种处理,以及它对反解目标的影响:")184    variants = {185        "原始 t(正文默认)": (ttr, lambda z: np.exp(np.clip(z, -10, 10)), "E[t|x]"),186        "截断 min(t, 120)": (np.minimum(ttr, 120.0),187                             lambda z: np.exp(np.clip(z, -10, 10)), "E[min(t,120)|x]"),188        "对数压缩 log(1+t)": (np.log1p(ttr),189                              lambda z: np.expm1(np.clip(z, -10, 10)), "E[log(1+t)|x]"),190    }191    rows = []192    for name, (wt, decode, target) in variants.items():193        th = fit_wlr(Xtr, ttr, w=wt)194        pred = decode(predict_logistic(th, Xte))195        rows.append([name, target, f"{mae(tte, pred):.3f}",196                     f"{pred[long_].mean():.1f}" if long_.any() else "—"])197    table(["权重", "模型实际学的目标", "MAE(秒)", "长视频平均预测"], rows)198    print("\n  换了权重,模型学的就不再是 E[t|x]:截断学的是「封顶后的期望」,")199    print("  对数压缩学的是「对数期望」,反解要配套用 expm1。工业上选哪种,")200    print("  取决于你希望长时长样本在排序里占多大话语权,而不只是看 MAE。\n")201202    # ---------------- 5) 长尾的极端样本 ----------------203    print("⑤ 极端样本的梯度:一个 t=300 的样本对两个模型各产生多大推动力")204    n_long = int((ttr >= 250).sum())205    print(f"  训练集里 t≥250 秒的样本有 {n_long} 条(占 {n_long / len(ttr):.2%})。")206    print("  假设模型此刻对它们都预测 10 秒:")207    print("    MSE 的梯度 2(t̂−t) = 2×(10−300) = −580,几乎被这几条样本独占;")208    print("    WLR 的梯度 −t·σ(−z)+σ(z),z=log(10),即 −300×0.091+0.909 ≈ −26.4,")209    print("    量级小一个数量级;但换用 min(t,120) 后只剩 −120×0.091+0.909 ≈ −10。")210    print("  这不是精度问题,而是「谁在定义损失」的问题。\n")211212    print("⑥ 一句话:WLR 用一个二分类器编码 Odds,MSE 直接回归均值;")213    print("   前者天然兼容 CTR 框架和样本加权,后者输出即物理量。")214    print("   选哪个取决于长尾有多重、下游要不要和分类任务共享损失。")215216217if __name__ == "__main__":218    main()

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

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