# ch23 时长预估建模 — 代码示例

本目录对应教材第 23 章，覆盖时长 Label 设计、加权二分类、离散化建模、经典时长预估方法、生成式时长预估与工业实践六个部分，共 **14 个可运行脚本**加 1 个公共模块。目录编号与正文节次对应，均可直接 `python3 <文件>.py` 运行。

- 合成数据由 `common.py` 统一生成，全程固定 `np.random.default_rng(2026)`，结果可复现；
- ch01–ch04 与 ch06 只依赖 **NumPy**（Logistic 回归、最小二乘、GMM/EM、EM 混合分布均手写，不调用 sklearn）；
- ch03 的 `soft_label.py`、ch04 的 `egmn.py` 与 ch05 的 `gr.py` / `flowtime.py` **可选**依赖 **PyTorch**：未安装时对应段落会打印提示并跳过（`soft_label.py` 只跳过 400 桶的受控实验，其余部分照常输出），不影响其余脚本。

---

## 目录结构

```
code/
├── common.py                                   公共：合成观看数据、线性/Logistic 回归、AUC/MAE/RMSE、表格输出
├── ch01_threshold_labels/                      §23.1 阈值化时长 Label
│   └── threshold_labels.py                     固定阈值 / 分桶阈值 / 校准阈值 / 分位阈值 / 多阈值联合
├── ch02_weighted_binary/                       §23.2 Weighted LR
│   └── weighted_lr.py                          曝光展开、迭代曲线、长尾误差、三种时长权重
├── ch03_discretization/                        §23.3 离散化与 Soft Label
│   └── soft_label.py                           One-Hot vs 高斯 Soft Label、自适应分位点、两种解码
├── ch04_classic_methods/                       §23.4 经典方法
│   ├── d2q.py                                  §23.4.1 分位数 Label + 共享模型
│   ├── dvr.py                                  §23.4.2 WTG 组内标准化 + 对抗去偏
│   ├── d2co.py                                 §23.4.3 两成分 GMM 估 w⁺/w⁻ + 线性/指数校正
│   ├── or_model.py                             §23.4.4 多阈值累计分类 + 生存积分解码
│   ├── cread.py                                §23.4.5 分类 + 恢复 + 序正则 + EAD 分桶
│   ├── tpm.py                                  §23.4.6 分位平衡树 + 叶分布 + 后门调整
│   └── egmn.py                                 §23.4.7 指数 + 高斯混合（torch）
├── ch05_generative/                            §23.5 生成式时长预估
│   ├── gr.py                                   §23.5.1 DQA 词表 + 贪心分解 + Transformer 自回归（torch）
│   └── flowtime.py                             §23.5.2 VAE + 条件 Planar Flow + 历史分布对齐（torch）
└── ch06_industrial_practice/                   §23.6 工业实践
    ├── label_design.py                         §23.6.1 EVR/LVR/SVR/FPR 的 Duration Bias 审计
    └── quantile_labels.py                      §23.6.2–§23.6.4 分位数 Label、分位曲线、分布漂移、长尾处理
```

---

## 快速运行

```bash
# 依赖：Python 3.12、NumPy；egmn / gr / flowtime 另需 torch

python3 ch01_threshold_labels/threshold_labels.py
python3 ch02_weighted_binary/weighted_lr.py
python3 ch03_discretization/soft_label.py

python3 ch04_classic_methods/d2q.py
python3 ch04_classic_methods/dvr.py
python3 ch04_classic_methods/d2co.py
python3 ch04_classic_methods/or_model.py
python3 ch04_classic_methods/cread.py
python3 ch04_classic_methods/tpm.py
python3 ch04_classic_methods/egmn.py        # torch，约 35 秒

python3 ch05_generative/gr.py               # torch，约 1 分 45 秒
python3 ch05_generative/flowtime.py         # torch，约 25 秒

python3 ch06_industrial_practice/label_design.py
python3 ch06_industrial_practice/quantile_labels.py
```

全部 14 个脚本均为 exit 0，总耗时约 5 分钟（其中 GR 占 2 分钟）。

---

## 合成数据的口径

`make_watch_data` 按正文 §23.4 的 D²Co 分解生成：

```
r     ~ Bernoulli(σ(β·u + γ·v))           潜在兴趣，不可观测
d     ~ LogNormal(log 45, 0.7)，截到 [12, 600]
c     ~ Bernoulli(σ(2.5 − 0.008d))        感兴趣时是否看完
W⁺    = c·d + (1−c)·min(d, Exp(mean=4))   看完则看满
W⁻    ~ Exponential(mean=2.5)，截到 d     不感兴趣：几秒噪声观看
w     = r·W⁺ + (1−r)·W⁻，再以 12% 概率置 0（曝光未播放）
x     = [u(8), v(8), log d]               共 17 维
```

测试集统计：均值 20.4 秒、P50 3.8 秒、P99 147.2 秒；训练集 20.93 ± 33.70 秒。**直接线性回归的基准是 MAE 19.80 / RMSE 28.45，「全预测训练均值」基线 MAE 23.39。** 下文所有对照都以这三个数为锚。

有两个设计要留意，它们直接决定了很多结论的方向：

1. **`r` 与 `d` 是独立生成的。** 没有「偏爱长内容的用户」，所以阈值 Label 拿不到 Duration Shortcut（`label_design.py` 的 ③ 会量化这点，⑤ 再人为注入相关性做对照）。
2. **观看时长不随 `d` 累积。** 不感兴趣就是几秒走人，感兴趣要么看完要么几秒后离开。因此「视频越长越容易凑够 12 秒」在这套数据上不成立，EVR 的正样本率反而随 `d` 下降——与正文「短视频天然有更高的有效播放率」方向一致，但成因不同。

---

## 各脚本要点与实测

### ch01 阈值化 Label（`threshold_labels.py`）

固定 10 秒阈值的各桶正样本率：0.441 → 0.408 → 0.353 → 0.292 → 0.137，极差 **0.304**；换成正文表 23.2 的分桶经验阈值，极差反而扩大到 **0.388**（最短桶 5 秒更松、最长桶 40 秒更严，两端同向推）。对每个桶反解一个使正样本率一致（0.393）的阈值后，极差降到 **0.024**。分位阈值 α=0.5/0.6/0.7 在五个桶上全部达标（5/5）。四个工业 Label 的短/长差距：EVR +0.163、LVR +0.166、SVR −0.112、FPR +0.165——**分段阈值并未压小差距**，因为阈值函数与真实观看分布不匹配。

### ch02 Weighted LR（`weighted_lr.py`）

把一次曝光展开成「零观看 1 条负样本 + 有观看 2 条」后，16,000 条曝光变成 30,089 条训练样本。WLR 最终 MAE **19.191** / RMSE 30.100，MSE 回归 **19.802** / 28.454，常数基线 23.388；长视频（≥120 秒）MAE 分别为 95.413 与 114.822。两者只差 0.6 秒——16 维线性特征表达力有限，换更宽的模型才会拉开。时长权重的三种处理：原始 t → 19.191、`min(t,120)` → 18.974、`log1p(t)` → 19.776，但**三者学的是不同的目标**，MAE 不能直接横比。极端样本（t=300、当前预测 10）的梯度：MSE 为 −580，WLR 为 −26.4，截断后 −10——**这是「谁在定义损失」的问题，不是精度问题**。

### ch03 离散化与 Soft Label（`soft_label.py`）

受控实验（400 桶、同结构同初始化）：One-Hot 最终 MAE **78.43**，高斯 Soft Label **65.17**，基线 91.92，**Soft Label 更低**——它的监督信号把邻近桶的概率一起抬起来。自适应分位点：同样 12 个坐标，短桶覆盖 0–20 秒、长桶覆盖几十到几百秒，分辨率跟着数据密度走。**「概率加权平均」与「生存函数积分」的 MAE/RMSE 完全相同（21.741 / 29.313）**，这是恒等式（分部求和），不是巧合——生存积分的价值是把 `E[T]=∫S(t)dt` 的截断误差显式化，而不是「更准」。

### ch04 经典方法

**D2Q**：分位变换后整体 MAE **16.40**（直接回归 19.80），但长视频组偏差从 +0.8 秒恶化到 **−33 秒**——Label 被压到 [0,1]、模型几乎学不到个体差异（q̂ 均值 0.51），反变换把长桶系统性回落到低处。M 从 5 增到 40 时 MAE 只从 16.60 降到 15.90，**没有出现正文说的「M 过大 MAE 回升」**，因为 2 万条还撑得住 40 组。

**DVR**：各桶 WT 均值 7.9 → 46.3，组内标准差 0.21 → 3.28 倍全局（最长桶仅 58 条样本，3.28 主要是噪声）。按 WT 排序的 Top-5 平均 Duration 93.7 秒（候选池 58.3），WTG 排序把它拉回 **61.2**，WTG@5 从 +0.450 升到 +0.577；**但 Top-5 真实兴趣命中率没变**（0.490 / 0.487 / 0.512）——桶内标准化把跨桶的兴趣差异也抹掉了。对抗前后，用分数预测 log(Duration) 的 R² 从 0.002 降到 0.000、斜率 +0.068 → −0.009；对照：原始 WT 预测对 log(Duration) 的 R² 是 **0.394**。

**D²Co**：40–60 秒区间 9,052 条，12.0% 零观看，另有一大团在 1–3 秒，37.9% 看满。GMM 估的 w⁻ 很准（2.4–2.7 对真实 2.1–2.3），w⁺ 则系统性高估 **8%–36%**（高成分抓的是「看完」尖峰，不是条件均值）。评价：

| 兴趣 Label | AUC(r) | MAE(p) | 桶内 AUC |
|---|---|---|---|
| 原始 w | 0.830 | — | 0.829 |
| PCR：w/d | 0.830 | 0.358 | 0.828 |
| D²Co(A) 线性 | **0.862** | 0.358 | 0.860 |
| D²Co(S) 指数 α=0.05 | 0.861 | 0.356 | 0.860 |
| 对照：真实条件均值做线性校正 | 0.859 | 0.370 | 0.857 |

收益在排序（+0.032）不在校准（MAE 不动）。**对照行 0.859 略低于 GMM 版 0.862**——条件均值把「感兴趣但只看三秒」也平均进来，分母被稀释。α 从 0.01 扫到 0.2：AUC 恒在 0.861–0.862，标准差 0.436 → 0.375、端点堆积 56.4% → 63.5%、MAE 0.356 → 0.376，**α 只改变离散程度，不是提升排序的旋钮**。

**OR**：K−1 ∈ {4, 9, 19, 39} 时，MAE 18.59 / 18.55 / 18.73 / 18.88（单调变差），RMSE 30.78 / 28.64 / 28.11 / 27.97（单调变好），单调违例率 7.3% / 37.6% / 61.6% / 76.3%。两个指标的相反排序正对应「学习误差 vs 修复误差」。cummin 后 MAE 18.55 → 18.20。两种解码：argmax 桶 **18.13 / 31.49**，生存积分 18.55 / 28.64——**argmax 在 MAE 上更好**，因为多数样本落在窄桶，「错得有限」。分桶看，[15,40) 区间 OR 反而更差（10.03 vs 13.13），[100,300) 两个模型都严重低估（−91 / −98）。

**CREAD**：等距分桶 MAE **17.02** / RMSE 29.19（最小组 0 条）、等频 17.23 / 28.97、EAD θ=0.4 17.35 / 28.71。消融：

| 训练目标 | MAE | RMSE | 单调违例率 |
|---|---|---|---|
| 仅 CE | 18.59 | 28.03 | 48.4% |
| CE + 恢复(Huber) | 17.35 | 28.71 | 61.6% |
| CE + 恢复 + 序正则 | 17.35 | 28.71 | 57.7% |

序正则只降违例率、不动 MAE——它罚「倒挂幅度」不罚「倒挂个数」。阈值数 8/17/35/70：最小组样本 1600/800/201/1，MAE 17.06/17.23/17.47/17.74——**与 OR 相反，CREAD 的 MAE 随阈值变多而变差**，因联合训练的梯度互相抵消。

**TPM**：按排名递归二分得到 **29 个叶子**（零观看 2,000 条合成一个叶，其余 28 个各约 500 条，覆盖 0.03–488 秒）。TPM MAE **19.35** / RMSE 27.90，直接回归 19.80 / 28.45。平均最大叶概率 0.18、熵 2.76。方差分位 0–25% → 误差 5.56，75–100% → 42.65，相关 **+0.785**。温度：原始 19.35、τ=0.8 → 19.42、τ=1.2 → 19.42，**锐化/平滑都没能同时赢下 MAE 和 RMSE**。后门：直接回归 MAE 19.50、与混杂源相关 +0.303；do 加权 20.26、−0.060——**去掉偏差的代价是加上方差**。

**EGMN**（torch）：仅 MLE 时权重负熵 −0.00、指数成分权重 1.00——**完全塌缩**。消融：

| 训练目标 | MAE | RMSE | NLL | 权重负熵 |
|---|---|---|---|---|
| 仅 MLE | 17.74 | 29.15 | 3.77 | 0.00 |
| MLE + 熵正则 | 18.00 | 36.11 | 3.28 | 1.18 |
| MLE + 熵 + MAE 回归 | **15.58** | 31.60 | 3.66 | 1.17 |
| MLE + MAE 回归（无熵） | 15.65 | 31.22 | 3.40 | 0.40 |

MAE 从线性的 19.80 降到 15.58，**RMSE 却从 28.45 升到 31.60**——MAE 目标把预测往条件中位数上拽，中位样本变准、长尾被放大。业务量：P(t>10) 0.494 vs 真实 0.391，P(t>0.8d) 0.307 vs 0.381，**P(t>d) 0.137 vs 真实 0.000**——EGM 的支撑集是 [0,∞)，它不知道视频只有 d 秒长，这是结构性问题，要靠 §23.5 的截断/生存模型修。

### ch05 生成式

**GR**（torch）：三种词表对比——

| 词表 | Token 数 | 最小(秒) | 平均长度 | 误差中位 | 超 0.1% 相对误差 | 最高频 Token 占比 |
|---|---|---|---|---|---|---|
| 固定 5 秒网格 | 98 | 5.00 | 0.5 | 1.56 | 99.5% | 13.4% |
| 纯分位点 | 48 | 0.10 | 1.7 | 0.04 | 73.0% | 15.1% |
| DQA | 24 | 0.23 | 1.7 | 0.08 | 86.6% | 10.8% |

DQA 只用 24 个 Slot 就把误差中位压到 0.08 秒。Token 化下界的 MAE/RMSE 是 **0.09 / 0.11**（w>0 样本的相对误差中位 1.06%）。生成对照：

| 训练方式 | 教师强制 MAE | 教师强制 RMSE | 自由生成 MAE | 自由生成 RMSE |
|---|---|---|---|---|
| CE 单训 | 15.83 | 30.83 | 17.61 | 36.15 |
| CE + Huber | 17.72 | 36.22 | 20.31 | 38.12 |
| CE + Huber + Mixup | 17.72 | 36.22 | 20.12 | 37.97 |

**在本例里加 Huber 反而更差**（Gumbel 采样噪声被 λ 放大）；教师强制 → 自由生成的 +11% 就是曝光偏差；Mixup 只影响自由生成（−0.19 MAE）。与直接回归比：MAE 更好（17.72 vs 19.80）、RMSE 更差（36.22 vs 28.45）——和 EGMN 同一件事。

**FlowTime**（torch）：仅 L_base（无 KL）采样 std **0.56**（后验塌缩），MAE 19.92；加 KL 后 MAE 反而降到 **19.22**、std 涨到 7.06；加流先验 19.83 / 8.48；再加 W-Dist **21.59** / 10.78——W-Dist 对齐的是人为构造的历史分位数，假目标给梯度加噪声。流先验把「生成分位 vs 历史分位」的 L1 从 58.86 压到 **55.24（−6%）**，方向对但幅度远小于「个性化」的期待。采样 std 与绝对误差相关 **+0.284**（TPM 那节是 +0.79）——有信号但不足以当难度指标。

### ch06 工业实践

**label_design.py**：四个 Label 的桶内 AUC ≈ 整体 AUC（EVR 0.874 vs 0.876），AUC(log d, label) 全部在 0.44–0.55——**在默认合成数据上，Duration 几乎不能解释这些 Label**，因为没有「长内容用户」。EVR 阈值扫描：4/8/12/20/40 秒 → AUC 0.805/0.865/**0.876**/0.821/0.693，AUC(log d, label) 0.456/0.446/0.442/0.550/0.749。注入 r–d 相关（+0.33）后，AUC(log d, EVR) 从 0.442 涨到 **0.620**，而桶内 AUC 反而升到 0.883——**Label 既携带真实兴趣、又和长度耦合**，离线指标无法区分两者。

**quantile_labels.py**：分位数 P70 Label 在每个 Duration 桶的正样本率恒为 **30.0%**（构造使然），固定 EVR 则是 45.4% → 28.1%。分位曲线在长桶出现**跳变**：[120,600) 的 P70 只有 8.3 秒、P80 却是 132.6 秒——分布本身是两个团，不是估计噪声。排序能力随分位单调下降：EVR 0.872、P60 0.847、P70 0.792、P80 0.695。分布漂移（视频变长 50%）：固定 EVR 38.6% → **39.5%**（几乎没动），分位数 P70 30.1% → **34.4%**（反而漂更多），重估阈值后回到 29.3%——**和「固定阈值先失效」的直觉相反**，因为这套生成过程里观看时长不随 d 累积。长尾处理：原始 19.80 / 28.45，截断 P99(152 秒) 19.63 / 28.47，log1p **17.88 / 32.92**——MAE 与 RMSE 再次反向。

---

## 实现约定与踩过的坑

- **`np.searchsorted(edges, d, "right")` 在 `edges[0] = -inf` 时要减 1**。`dvr.py` 的第一版漏了这一步，桶 0 恒空、最长桶越界，WTG 标签读到未初始化内存，回归系数一度飙到 4.26。现在统一写成 `searchsorted(...) - 1` 并用 `np.zeros` + assert 兜底。
- **GMM 要在 `log1p(w)` 上拟合**，回变换用 `expm1(μ + σ²/2)`；直接对原始秒数做混合，两个成分都会去追长尾。
- **OR / CREAD 的阈值不要从 0 分位开始**：12% 零观看会让 0.10/0.20 分位都落在 0，退化成一个「播没播」的分类。最低从 0.25 分位起。
- **CREAD 的 Huber 与序正则梯度符号**：`∂L/∂z` 是 `+dh·w·p(1−p)`，算负梯度时要减；写反会让 ŷ 在几十步内从 20 秒涨到 79 秒。
- **TPM 按排名切，不按阈值切**：0 值堆积处按阈值取中位数会切出空叶（第一版 32 个叶子里 4 个是空的）。
- **GR 的 `encode` 对短于最小 Token 一半的样本返回空序列**，不要退回最小 Token，否则零观看会被还原成 5 秒。
- **GR 的 Huber 项要作用在「求和后的时长」上**，且必须用 Gumbel/STE 这类可微采样。直接用 softmax 期望会被 24 类摊平、梯度方向错误；用 STE 只给 argmax 一路梯度、λ 一大就把序列压成「全 EOS」。
- **GR 的 DQA 必须显式卡住 Token 数**：只按「质量间隔 ≥ min_mass」保留，会在长尾留下 73 个 Slot。
- **教师强制解码要遇到 EOS 就停**，不能把所有 `≥3` 的位置都累加——EOS 之后没有监督，模型吐出的高数值 Token 会把 ŷ 抬爆。
- **EGMN 的 `surv` 与 `log_prob` 注意广播维度**：`t` 要升成 `[n,1]`，`lam` 也要 `[:,None]`，否则 `[n]` 与 `[n,K]` 相加会隐式扩展成 `[n,n]`。

---

## 关于正文的几处勘误

这些在脚本里都按「实测为准」处理，正文若要修订可参考：

1. **LVR 在 `duration ≤ 3s` 时要求 `playing_time > 10s` 不可能成立**（播放时长被物理时长封顶）。除非 `playing_time` 计入重播，否则这一行应删或改成 `w > d` 之类的口径。
2. **CREAD 的 K=80 在 2 万条样本上撑不住**：最小组只剩 1–2 条，每个阈值正样本不到 1%。正文的 K=80 是千万级数据上的结论，不能直接照搬。
3. **「生存积分解码比概率加权平均更准」不成立**：对离散分布二者是恒等式（分部求和），差别只在截断误差的定义方式。
4. **EGM 的支撑集是 [0,∞)**，而观看时长被 `d` 封顶；不带截断的混合分布会给 `P(t>d)` 分配 13.7% 的概率质量，而真实频率是 0。
