CH 23 / SOURCE EXAMPLE
d2co.py
ch23 时长预估建模/code/ch04_classic_methods/d2co.py
这个示例讲什么
│ ├── d2co.py §23.4.3 两成分 GMM 估 w⁺/w⁻ + 线性/指数校正
D²Co:两成分 GMM 估 w⁺/w⁻,再用校正函数还原兴趣(第23章 §23.4.3) 正文的分解: w = p_x^r · w_d⁺ + (1 − p_x^r) · w_d⁻ 其中 w_d⁺ 是「感兴趣时的平均观看时长」(含 Duration Bias), w_d⁻ 是「不感兴趣时的噪声观看」(几秒的误触/滑走,Noisy Watching)。 流程: 1) 按 Duration 分组,在每组内用两成分 GMM 拟合观看时长分布, 得到 ŵ_d⁺ 与 ŵ_d⁻; 2) 用频次加权的双向移动平均抹平相邻 Duration 上的估计噪声; 3) 线性校正 D²Co(A):r = (w − w_d⁻) / (w_d⁺ − w_d⁻); 指数校正 D²Co(S):r = (e^{αw} − e^{αw_d⁻}) / (e^{
上面来自章节 README 或源码自身的说明,不是自动验证的运行结果,也不能代替 PDF 的书中论断。讲解时请结合实际代码、输入输出与原书页码。
源码结构与阅读入口
_log_norm_pdf· 第 47 行fit_gmm1d· 第 51 行返回按均值升序排列的 (mu, sd, pi)。gmm_means· 第 93 行在 log(1+W) 空间拟合,回变换成秒:E[W] = exp(μ+σ²/2) − 1。main· 第 103 行
1"""2D²Co:两成分 GMM 估 w⁺/w⁻,再用校正函数还原兴趣(第23章 §23.4.3)34正文的分解:5 w = p_x^r · w_d⁺ + (1 − p_x^r) · w_d⁻6其中 w_d⁺ 是「感兴趣时的平均观看时长」(含 Duration Bias),7w_d⁻ 是「不感兴趣时的噪声观看」(几秒的误触/滑走,Noisy Watching)。89流程:10 1) 按 Duration 分组,在每组内用两成分 GMM 拟合观看时长分布,11 得到 ŵ_d⁺ 与 ŵ_d⁻;12 2) 用频次加权的双向移动平均抹平相邻 Duration 上的估计噪声;13 3) 线性校正 D²Co(A):r = (w − w_d⁻) / (w_d⁺ − w_d⁻);14 指数校正 D²Co(S):r = (e^{αw} − e^{αw_d⁻}) / (e^{αw_d⁺} − e^{αw_d⁻})。1516本例的特殊之处:合成数据里保留了真实兴趣 r,所以可以拿 GMM 估出来的17w⁺/w⁻ 去和「按 r 分组的真实均值」对比——这是线上做不到的,只在这里18用来判断估计误差有多大、又会怎样传到最终 Label 上。1920工程取舍:GMM 拟合在 log(1+W) 上做,而不是原始秒数。原始 W 右偏且被人为21封顶在 [0, d],直接套高斯混合会让两个成分都去追那条长尾。log 空间里22「长视频看满」与「划走」才是两个可分的团。回变换用 E[W]=exp(μ+σ²/2)−1,23带方差修正,否则会系统性低估均值。2425运行:python3 ch04_classic_methods/d2co.py26"""2728from __future__ import annotations2930import pathlib31import sys3233import numpy as np3435sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))3637from common import ( # noqa: E40238 auc, mae, make_watch_data, split, table,39)4041SEED = 2026424344# --------------------------------------------------------------------------45# 一维两成分 GMM(EM,多次随机重启,取对数似然最大的解)46# --------------------------------------------------------------------------47def _log_norm_pdf(x: np.ndarray, mu: float, sd: float) -> np.ndarray:48 return -0.5 * np.log(2 * np.pi) - np.log(sd) - 0.5 * ((x - mu) / sd) ** 2495051def fit_gmm1d(x: np.ndarray, k: int = 2, iters: int = 300,52 restarts: int = 4, seed: int = SEED):53 """54 返回按均值升序排列的 (mu, sd, pi)。5556 第 0 次用分位点初始化(稳定),其余用随机样本初始化(避免所有重启57 都落在同一个局部最优)。EM 的对数似然单调不减,所以「多重启取最大」58 是安全的。59 """60 rng = np.random.default_rng(seed)61 best = None62 for r in range(restarts):63 if r == 0:64 mu = np.sort(np.quantile(x, np.linspace(0.15, 0.85, k)))65 else:66 mu = np.sort(rng.choice(x, size=k, replace=False))67 # 随机重启可能抽到几乎重合的两点,加一点抖动保证可分68 mu = mu + rng.normal(0, 1e-3, size=k)69 sd = np.full(k, max(x.std(), 1e-3))70 pi = np.full(k, 1.0 / k)71 ll_old = -np.inf72 for _ in range(iters):73 logp = np.stack([_log_norm_pdf(x, mu[j], sd[j]) for j in range(k)], axis=1)74 logp = logp + np.log(pi + 1e-300)75 m = logp.max(axis=1, keepdims=True)76 lse = m[:, 0] + np.log(np.exp(logp - m).sum(axis=1))77 resp = np.exp(logp - lse[:, None])78 nk = resp.sum(axis=0) + 1e-30079 mu = (resp * x[:, None]).sum(axis=0) / nk80 sd = np.sqrt((resp * (x[:, None] - mu) ** 2).sum(axis=0) / nk) + 1e-681 pi = nk / len(x)82 ll = float(lse.sum())83 if ll - ll_old < 1e-6:84 break85 ll_old = ll86 if best is None or ll > best[0]:87 best = (ll, mu.copy(), sd.copy(), pi.copy())88 _, mu, sd, pi = best89 order = np.argsort(mu)90 return mu[order], sd[order], pi[order]919293def gmm_means(x: np.ndarray):94 """在 log(1+W) 空间拟合,回变换成秒:E[W] = exp(μ+σ²/2) − 1。"""95 mu, sd, pi = fit_gmm1d(x)96 means = np.expm1(mu + 0.5 * sd ** 2)97 return means, mu, sd, pi9899100# --------------------------------------------------------------------------101# 主流程102# --------------------------------------------------------------------------103def main() -> None:104 print("D²Co:两成分 GMM 估 w⁺/w⁻ + 校正还原兴趣(§23.4.3)\n")105106 data = make_watch_data(n=40000, dim=16, seed=SEED)107 X, w, d, r, p = (data["x"], data["w"].astype(np.float64), data["d"],108 data["r"], data["p"])109 idx = np.arange(len(X))110 tr, te = split(idx, len(X), 0.8)111112 # ---------------- 1) 分布长什么样 ----------------113 print("① 固定 Duration 区间内的观看时长分布:真的有两个团吗?")114 band = (d >= 40) & (d < 60)115 wb = w[band]116 rows = []117 for lo, hi in ((0, 0.5), (0.5, 2), (2, 5), (5, 10), (10, 20),118 (20, 40), (40, 60), (60, 1e9)):119 m = (wb >= lo) & (wb < hi)120 rows.append([f"[{lo:g},{hi:g})" if hi < 1e9 else f"[{lo:g},∞)",121 int(m.sum()), f"{m.mean():.1%}"])122 table(["观看时长区间(秒)", "样本数", "占比"], rows)123 print(f"\n {int(band.sum()):,} 条 40–60 秒视频:{np.mean(wb == 0):.1%} 是零观看,")124 print(" 另有一大团挤在 1–3 秒(划走/误触),剩下的才是不连续的「看点内容」。")125 print(" 两条曲线叠在一起,这就是 w⁺/w⁻ 分解在数据上的样子。\n")126127 # ---------------- 2) 按 Duration 分组拟合 GMM ----------------128 print("② 每个 Duration 组内拟合两成分 GMM,与「按真实兴趣分组」的均值对比")129 edges = np.unique(np.quantile(d[tr], np.linspace(0, 1, 13)))130 edges[0], edges[-1] = -np.inf, np.inf131 n_b = len(edges) - 1132 kb = np.searchsorted(edges, d, side="right") - 1133134 est_p = np.full(n_b, np.nan) # GMM 估的 w⁺135 est_n = np.full(n_b, np.nan) # GMM 估的 w⁻136 cnt = np.zeros(n_b)137 rows = []138 for b in range(n_b):139 m = (kb == b) & (w > 0) & np.isin(idx, tr)140 cnt[b] = ((kb == b) & np.isin(idx, tr)).sum()141 if m.sum() < 50:142 continue143 means, mu, sd, pi = gmm_means(np.log1p(w[m]))144 est_n[b], est_p[b] = means[0], means[-1]145 # 真实分量:直接用生成时的 r。注意 GMM 是在 w>0 的样本上拟合的,146 # 所以对照的基准也取 w>0,否则「12% 的零观看」会把基准压低,147 # 让误差看起来比实际大。148 mt = (kb == b) & np.isin(idx, tr)149 true_p = w[mt & (r == 1) & (w > 0)].mean()150 true_n = w[mt & (r == 0)].mean()151 lo = edges[b] if np.isfinite(edges[b]) else d.min()152 hi = edges[b + 1] if np.isfinite(edges[b + 1]) else d.max()153 rows.append([f"[{lo:.0f},{hi:.0f})", int(cnt[b]),154 f"{true_p:.1f}", f"{est_p[b]:.1f}",155 f"{true_n:.1f}", f"{est_n[b]:.1f}",156 f"{est_p[b] / true_p - 1:+.1%}"])157 table(["Duration 组", "样本数", "真实 w⁺", "GMM w⁺", "真实 w⁻", "GMM w⁻",158 "w⁺ 相对误差"], rows)159 print("\n w⁻ 估得很准(几秒的噪声观看本来就好分辨),w⁺ 则是系统性高估,")160 print(" 短的组 8%、最长的组 36%。原因是高成分抓到的其实是一个「看完」的")161 print(" 尖峰:感兴趣的人里有一半以上看到视频结束,w 就贴在 d 上,")162 print(" log 空间里它是一个又窄又高的峰,回变换后的均值自然高于")163 print(" 「把部分观看也平均进来」的 E[W|r=1]。这不是 GMM 收敛不好的抖动,")164 print(" 而是「高成分 ≠ 条件均值」这个结构性差异,它会直接进分母。\n")165166 # ---------------- 3) 频次加权移动平均 ----------------167 print("③ 频次加权双向移动平均(T=2)平滑相邻 Duration 的估计")168 T = 2169 sm_p, sm_n = est_p.copy(), est_n.copy()170 for b in range(n_b):171 lo, hi = max(0, b - T), min(n_b, b + T + 1)172 sl = slice(lo, hi)173 valid = np.isfinite(est_p[sl])174 wts = cnt[sl][valid]175 if wts.sum() == 0:176 continue177 sm_p[b] = (est_p[sl][valid] * wts).sum() / wts.sum()178 sm_n[b] = (est_n[sl][valid] * wts).sum() / wts.sum()179 pick = [b for b in range(n_b) if np.isfinite(est_p[b])][:8]180 rows = []181 for b in pick:182 lo = edges[b] if np.isfinite(edges[b]) else d.min()183 hi = edges[b + 1] if np.isfinite(edges[b + 1]) else d.max()184 rows.append([f"[{lo:.0f},{hi:.0f})", f"{est_p[b]:.1f}", f"{sm_p[b]:.1f}",185 f"{est_n[b]:.2f}", f"{sm_n[b]:.2f}"])186 table(["Duration 组", "w⁺ 原始", "w⁺ 平滑", "w⁻ 原始", "w⁻ 平滑"], rows)187 print("\n 平滑主要作用在样本量小的组:长 Duration 组只有一两千条,")188 print(" GMM 的单次估计抖动明显;借相邻组的样本量加权后曲线更连续。")189 print(" 注意它只能降方差,降不了②里那种系统性低估——偏差要靠模型结构解决。\n")190191 # ---------------- 4) 校正与评价 ----------------192 print("④ 三种兴趣 Label 与真实兴趣 r 的一致性(测试集)")193 wte, dte, rte, pte = w[te], d[te], r[te], p[te]194 kte = np.searchsorted(edges, dte, side="right") - 1195 kte = np.clip(kte, 0, n_b - 1)196 wp = sm_p[kte]197 wn = sm_n[kte]198199 pcr = np.clip(wte / np.maximum(dte, 1e-6), 0, 1)200 lin = np.clip((wte - wn) / np.maximum(wp - wn, 1e-6), 0, 1)201202 def expo_label(alpha: float) -> np.ndarray:203 # (e^{αw} − e^{αw⁻})/(e^{αw⁺} − e^{αw⁻}),公因子 e^{αw⁻} 直接约掉,204 # 用 expm1 计算差值,既避免大数溢出的相减抵消,也不会因 α 小而丢精度。205 num = np.expm1(alpha * (wte - wn))206 den = np.expm1(alpha * (wp - wn))207 return np.clip(num / np.maximum(den, 1e-12), 0, 1)208209 # 对照:直接用「真实的」条件均值 E[W|r=1]、E[W|r=0] 做同一套线性校正。210 # 它不是上限——真实条件均值同样不等于 GMM 的高成分,而且它混入了211 # 「感兴趣但只看几秒」的样本,分母反而更像噪声。212 true_p = np.array([w[(kb == b) & (r == 1)].mean() if np.isfinite(est_p[b])213 else np.nan for b in range(n_b)])214 true_n = np.array([w[(kb == b) & (r == 0)].mean() if np.isfinite(est_p[b])215 else np.nan for b in range(n_b)])216 tp, tn = true_p[kte], true_n[kte]217 orc = np.clip((wte - tn) / np.maximum(tp - tn, 1e-6), 0, 1)218219 # 桶内 AUC:把 Duration 的影响先扣掉,只看组内排序220 def within_bin_auc(label: np.ndarray) -> float:221 tot, nsum = 0.0, 0222 for b in range(n_b):223 m = kte == b224 if m.sum() > 10 and 0 < rte[m].sum() < m.sum():225 tot += auc(rte[m], label[m]) * m.sum()226 nsum += m.sum()227 return tot / max(nsum, 1)228229 rows = [230 ["原始观看时长 w(不归一)", f"{auc(rte, wte):.3f}", "—",231 f"{within_bin_auc(wte):.3f}"],232 ["PCR:w/d", f"{auc(rte, pcr):.3f}", f"{mae(pte, pcr):.3f}",233 f"{within_bin_auc(pcr):.3f}"],234 ["D²Co(A):线性校正", f"{auc(rte, lin):.3f}", f"{mae(pte, lin):.3f}",235 f"{within_bin_auc(lin):.3f}"],236 ["D²Co(S):指数校正 α=0.05", f"{auc(rte, expo_label(0.05)):.3f}",237 f"{mae(pte, expo_label(0.05)):.3f}",238 f"{within_bin_auc(expo_label(0.05)):.3f}"],239 ["对照:真实条件均值做线性校正", f"{auc(rte, orc):.3f}",240 f"{mae(pte, orc):.3f}", f"{within_bin_auc(orc):.3f}"],241 ]242 table(["兴趣 Label", "AUC(r)", "MAE(p)", "桶内 AUC"], rows)243 print("\n 三个数各自说明一件事:")244 print(" · AUC(r):D²Co(A) 0.862 vs PCR 0.830,+0.032。改善来自")245 print(" 「先减掉 w⁻ 再加权」这个结构——它把「光划走」的样本压到 0 附近,")246 print(" 让真正看了内容的人和它们分开;")247 print(" · MAE(p):0.358 → 0.358,几乎没动。Label 更适合排序,")248 print(" 但直接把它当兴趣概率用(比如拿去算 pCTR)并不比 PCR 准,")249 print(" 因为分母 w⁺ 高估,Label 整体被压缩向 0;")250 print(" · 桶内 AUC:0.829 → 0.860,和整体 AUC 几乎同幅提升。")251 print(" 说明增益不是「Duration 信息被扣掉了」的假象,")252 print(" 而是同一 Duration 内的排序确实变好了。")253 print(" 对照行更值得注意:用「真实条件均值」做同一套线性校正只有 0.859,")254 print(" 还略低于 GMM 版本的 0.862。条件均值把「感兴趣但只看三秒」")255 print(" 也平均进来,分母被稀释;GMM 的高成分更接近「看完」的尖峰,")256 print(" 恰好把这类样本的贡献压了下去。所以「估得越像 E[W|r=1] 越好」")257 print(" 在这里并不成立——高成分不是条件均值,而是条件均值里的一支。")258 print(" 作为参照,直接用生成用的兴趣概率 p 当分数,AUC(r) 只有 0.817。")259 print(" 这不构成「上限」——r 是按 p 抽样出来的,而 w 又是在 r 的基础上")260 print(" 再生成的,所以 w 天然比 p 多带了信息。有意义的比较只有同一行内")261 print(" 横向看:w / PCR / D²Co 三者用的是同一份观测,D²Co 多出 0.032。\n")262 print(" 正文说 w⁺ 和 w⁻ 接近时分母小、误差被放大;本例里 w⁻ 稳定在 2.5 秒,")263 print(" w⁺ 从十几秒到上百秒,分母并不小。真正放大误差的是反方向:")264 print(" 高成分高估 8%~36%,且长视频组更严重,于是长视频的 Label 被压得")265 print(" 更靠近 0,排序时它们的相对差异被缩小——但方向没变,所以 AUC 仍涨。\n")266267 # ---------------- 5) α 的稳健性 ----------------268 print("⑤ 指数校正的 α:它控制对估计误差的敏感度")269 rows = []270 for alpha in (0.01, 0.02, 0.05, 0.1, 0.2):271 lab = expo_label(alpha)272 rows.append([f"{alpha:.2f}", f"{auc(rte, lab):.3f}", f"{mae(pte, lab):.3f}",273 f"{lab.std():.3f}", f"{np.mean((lab < 1e-6) | (lab > 1 - 1e-6)):.1%}"])274 table(["α", "AUC(r)", "MAE(p)", "Label 标准差", "落在端点的比例"], rows)275 print("\n α→0 时 expm1(αx)/α → x,指数校正精确退化成线性校正,")276 print(" 所以 0.862 这条基线不会因为换公式而改变。α 变大后,")277 print(" 落在两端的样本变多(56% → 64%)、标准差反而下降(0.436 → 0.375):")278 print(" 中间地带被推到 0 或 1,分布从「连续」变成「两端堆积」。")279 print(" 代价在 MAE 上:α=0.05 之前 0.356,α=0.2 时劣化到 0.376。")280 print(" 也就是说 α 在这里不是提升排序的旋钮,只是改变 Label 的离散程度;")281 print(" 论文按数据分布调它,换数据集不能照搬。\n")282283 print("⑥ 一句话:D²Co 把观看时长拆成「兴趣 × 偏差 + (1−兴趣) × 噪声」,")284 print(" 再用同 Duration 下的大量样本把两个潜在分量估出来反解兴趣。")285 print(" 它的收益主要体现在排序(AUC +0.03)而不是概率校准(MAE 不动),")286 print(" 而高成分估的是「看完」那一支、不是条件均值——这一点决定了")287 print(" 它的 Label 会比「用真实条件均值」更锐利,也更容易受长尾影响。")288289290if __name__ == "__main__":291 main()如何用于学习或编写新代码
- 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
- 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
- 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
- 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。