CH 23 / SOURCE EXAMPLE

flowtime.py

ch23 时长预估建模/code/ch05_generative/flowtime.py

这个示例讲什么

│ └── flowtime.py §23.5.2 VAE + 条件 Planar Flow + 历史分布对齐(torch)

FlowTime:VAE 连续生成 + 基于流的个性化先验 + 历史分布对齐(第23章 §23.5.2) 正文的三层结构: 1) VAE:编码器 q_φ(z|y,x) → 解码器 p_θ(y|z,x),重参数化采样; 2) 个性化先验 p_ψ(z|x):把用户/视频历史观看时长的分位数序列编码成 h_prior,再用条件 Planar Flow 把 N(0,I) 变换成 z_K; 3) 三个损失: L_base = Huber(y, ŷ) 观测空间准确性 L_latent= KL(q_φ(z₀|y,x) ‖ N(0,I)) 潜空间稳定 L_W-Dist= 生成样本的顺序统计量 与 历史分位数 的 L1 对齐 本脚本实测四件事: ① 点预

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

源码结构与阅读入口

  1. hist_quantiles · 第 58 行用特征的一个固定投影模拟「用户历史观看分位数」,形状 [n, L_Q]。
  2. run_torch · 第 74 行
  3. main · 第 318 行

完整源码 · 324 行

1"""2FlowTime:VAE 连续生成 + 基于流的个性化先验 + 历史分布对齐(第23章 §23.5.2)34正文的三层结构:5    1) VAE:编码器 q_φ(z|y,x) → 解码器 p_θ(y|z,x),重参数化采样;6    2) 个性化先验 p_ψ(z|x):把用户/视频历史观看时长的分位数序列编码成7       h_prior,再用条件 Planar Flow 把 N(0,I) 变换成 z_K;8    3) 三个损失:9         L_base  = Huber(y, ŷ)                    观测空间准确性10         L_latent= KL(q_φ(z₀|y,x) ‖ N(0,I))       潜空间稳定11         L_W-Dist= 生成样本的顺序统计量 与 历史分位数 的 L1 对齐1213本脚本实测四件事:14    ① 点预测:VAE 均值 vs 直接回归(均值坍缩有没有被缓解);15    ② 消融:L_base / +KL / +W-Dist 三项各自的贡献;16    ③ 先验:标准高斯 vs 条件 Planar Flow——采样出的分布是否更像17       该用户自己的分布;18    ④ 多次采样:给出的分布能不能当不确定性用(std 与真实误差的关系)。1920需要说明的一处实现选择:正文的 L_latent 是对 N(0,I) 求 KL,而不是对21个性化先验 p_ψ(z|x) 求 KL。这意味着**流的参数不从 KL 拿梯度**,22只能通过 L_W-Dist(生成样本要对齐历史分位数)来学。这个设计在论文里23是为了「先验只管生成时的形状、不干扰后验的可识别性」,但代价是24流的训练信号弱——③里会看到它学到的东西有限。2526历史分位数:合成数据没有真实的历史行为序列(每条曝光只有一个 y),27所以这里用特征的一个固定投影模拟「该用户的历史观看分位数」,28只为了让 L_W-Dist 有个可对齐的目标;它不是真实历史,结论要打折扣看。2930依赖 torch;没有 torch 时直接跳过。3132运行:python3 ch05_generative/flowtime.py33"""3435from __future__ import annotations3637import pathlib38import sys3940import numpy as np4142sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))4344from common import (  # noqa: E40245    fit_linear, make_watch_data, mae, predict_linear, rmse, split,46    standardize, table,47)4849SEED = 202650ZDIM = 251L_Q = 5          # 历史分位数的个数52N_FLOW = 3       # Planar Flow 层数53STEPS = 150054BATCH = 25655LR = 1e-3565758def hist_quantiles(X: np.ndarray, rng: np.random.Generator) -> np.ndarray:59    """60    用特征的一个固定投影模拟「用户历史观看分位数」,形状 [n, L_Q]。6162    构造方式:把「用户整体观看水平」定义成一个与 x 线性相关、再取指数的63    正数 s,历史分位数取 q_l = s · c_l,c_l 是一组递增的全局参考值。64    这样 s 大的用户历史更长、分布更靠右——量级与真实数据一致,65    并且每个用户的分位数互不相同,L_W-Dist 才有「个性化」可言。66    """67    proj = rng.normal(0, 0.25, size=X.shape[1])68    s = np.exp(X @ proj) * 6.0 + 1.069    c = np.array([0.4, 1.2, 2.6, 6.0, 14.0])[:L_Q]70    q = s[:, None] * c[None, :]71    return np.clip(q + rng.normal(0, 0.3, size=q.shape) * s[:, None], 0, None)727374def run_torch() -> None:75    try:76        import torch77        import torch.nn as nn78        import torch.nn.functional as F79    except ImportError:80        print("  (未安装 torch,跳过 FlowTime)\n")81        return8283    torch.manual_seed(SEED)84    data = make_watch_data(n=20000, dim=16, seed=SEED)85    X, w = data["x"], data["w"].astype(np.float64)86    idx = np.arange(len(X))87    tr, te = split(idx, len(X), 0.8)88    Xtr, Xte, _, _ = standardize(X[tr], X[te])89    wtr, wte = w[tr], w[te]9091    rng = np.random.default_rng(SEED)92    Qtr = hist_quantiles(Xtr, rng)93    Qte = hist_quantiles(Xte, rng)9495    th0 = fit_linear(Xtr, wtr)96    pred0 = np.clip(predict_linear(th0, Xte), 0, None)97    print("① 参照:直接 MSE 回归")98    print(f"  MAE {mae(wte, pred0):.2f}、RMSE {rmse(wte, pred0):.2f};"99          f"常数基线 MAE {np.abs(wte - wtr.mean()).mean():.2f}\n")100101    Xt = torch.from_numpy(Xtr).float()102    yt = torch.from_numpy(wtr).float()103    Qt = torch.from_numpy(Qtr).float()104    Xs = torch.from_numpy(Xte).float()105    ys = torch.from_numpy(wte).float()106    Qs = torch.from_numpy(Qte).float()107    dim = Xt.shape[1]108109    class Prior(nn.Module):110        """111        个性化先验:h_prior = [h^u ‖ h^v],这里简化成对历史分位数做112        一层线性投影(正文用 Transformer + MeanPool;合成数据没有序列,113        用不上位置编码)。114115        Planar Flow(条件版):116            z' = z + u(h)·tanh(w(h)ᵀ z + b(h))117            log|det J| = log|1 + u·w·(1 − tanh²(·))|118        u 用一个「反权重」技巧初始化,让每层初始接近恒等变换,119        否则多层叠加后 z 会直接被推到饱和区。120        """121122        def __init__(self, dim_q: int, zdim: int, n_flow: int):123            super().__init__()124            self.enc_q = nn.Linear(dim_q, 32)125            self.u = nn.ModuleList([nn.Linear(32, zdim) for _ in range(n_flow)])126            self.w = nn.ModuleList([nn.Linear(32, zdim) for _ in range(n_flow)])127            self.b = nn.ModuleList([nn.Linear(32, 1) for _ in range(n_flow)])128129        def h(self, q):130            return torch.tanh(self.enc_q(q))131132        def forward(self, z0, q, return_logdet=False):133            h = self.h(q)134            z, logdet = z0, torch.zeros(len(z0))135            for u_l, w_l, b_l in zip(self.u, self.w, self.b):136                u = u_l(h)137                w = w_l(h)138                b = b_l(h)139                a = (w * z).sum(1, keepdim=True) + b140                t = torch.tanh(a)141                z = z + u * t142                # log|1 + uᵀ w (1 − tanh²)|,一维求和即可143                d = 1.0 + (u * w).sum(1, keepdim=True) * (1 - t ** 2)144                logdet = logdet + torch.log(d.abs() + 1e-6).squeeze(1)145            if return_logdet:146                return z, logdet147            return z148149    class FlowTime(nn.Module):150        def __init__(self, d: int, zdim: int = ZDIM):151            super().__init__()152            self.enc = nn.Sequential(nn.Linear(d + 1, 64), nn.ReLU(),153                                     nn.Linear(64, 32), nn.ReLU())154            self.mu = nn.Linear(32, zdim)155            self.lv = nn.Linear(32, zdim)156            self.dec = nn.Sequential(nn.Linear(d + zdim, 64), nn.ReLU(),157                                     nn.Linear(64, 32), nn.ReLU())158            self.out = nn.Linear(32, 2)     # 均值与 log 方差159160        def posterior(self, x, y):161            h = self.enc(torch.cat([x, y[:, None]], 1))162            return self.mu(h), self.lv(h).clamp(-6, 4)163164        def decode(self, x, z):165            h = self.dec(torch.cat([x, z], 1))166            o = self.out(h)167            return o[:, 0], o[:, 1].clamp(-6, 4)168169    def train(use_flow: bool, lam_kl: float, lam_dist: float,170              steps: int = STEPS, seed: int = SEED):171        """返回 (模型, 先验);lam_dist=0 时不建先验。"""172        torch.manual_seed(seed)173        model = FlowTime(dim)174        prior = Prior(L_Q, ZDIM, N_FLOW) if use_flow else None175        params = list(model.parameters()) + (list(prior.parameters()) if prior else [])176        opt = torch.optim.Adam(params, lr=LR)177        rng2 = np.random.default_rng(seed)178        for _ in range(steps):179            b = torch.from_numpy(rng2.integers(0, len(Xt), BATCH))180            x_b, y_b, q_b = Xt[b], yt[b], Qt[b]181            mu, lv = model.posterior(x_b, y_b)182            sd = torch.exp(0.5 * lv)183            eps = torch.randn_like(sd)184            z0 = mu + sd * eps185            z = prior(z0, q_b) if use_flow else z0186            m, lv2 = model.decode(x_b, z)187            err = m - y_b188            a = err.abs()189            loss = torch.where(a <= 1.0, 0.5 * err ** 2, a - 0.5).mean()190            if lam_kl > 0:191                kl = (0.5 * (mu ** 2 + sd ** 2 - 1 - lv)).sum(1).mean()192                loss = loss + lam_kl * kl193            if lam_dist > 0 and use_flow:194                # 多种几个 z,取生成结果的顺序统计量对齐历史分位数195                zz = torch.randn(len(x_b), L_Q, ZDIM)196                zk = prior(zz.reshape(-1, ZDIM),197                           q_b.repeat_interleave(L_Q, dim=0)).reshape(-1, L_Q, ZDIM)198                xr = x_b.repeat_interleave(L_Q, dim=0).reshape(-1, L_Q, dim)199                gen, _ = model.decode(xr.reshape(-1, dim), zk.reshape(-1, ZDIM))200                gen = gen.reshape(-1, L_Q).sort(dim=1).values201                # 目标分位数也按升序,L1 对齐202                tgt = q_b.sort(dim=1).values203                loss = loss + lam_dist * (gen - tgt).abs().mean()204            opt.zero_grad()205            loss.backward()206            nn.utils.clip_grad_norm_(params, 5.0)207            opt.step()208        return model, prior209210    @torch.no_grad()211    def predict(model, prior, Xb, Qb, n_draw: int = 20):212        """213        推理:z0 ~ N(0,I) → 先验(可选)→ 解码。214        返回 (逐样本均值, 逐样本标准差, 单次抽样均值)。215        """216        n = len(Xb)217        if n_draw == 1:218            z0 = torch.randn(n, ZDIM)219            z = prior(z0, Qb) if prior is not None else z0220            m, _ = model.decode(Xb, z)221            return m.numpy(), np.zeros(n), m.numpy()222        z0 = torch.randn(n, n_draw, ZDIM)223        if prior is not None:224            z = prior(z0.reshape(-1, ZDIM),225                      Qb.repeat_interleave(n_draw, dim=0)).reshape(n, n_draw, ZDIM)226        else:227            z = z0228        m, _ = model.decode(Xb.unsqueeze(1).expand(n, n_draw, -1).reshape(-1, dim),229                            z.reshape(-1, ZDIM))230        m = m.reshape(n, n_draw)231        return m.mean(1).numpy(), m.std(1).numpy(), m.mean(1).numpy()232233    # ---------------- ② 消融 ----------------234    print("② 三项损失的消融(测试集,20 次采样取均值)")235    rows, store = [], {}236    cfgs = (("仅 L_base(普通 VAE,无 KL)", False, 0.0, 0.0),237            ("L_base + KL", False, 0.05, 0.0),238            ("L_base + KL + 流先验", True, 0.05, 0.0),239            ("L_base + KL + 流先验 + W-Dist", True, 0.05, 0.05))240    for name, uf, lk, ld in cfgs:241        model, prior = train(use_flow=uf, lam_kl=lk, lam_dist=ld)242        mu_p, sd_p, _ = predict(model, prior, Xs, Qs)243        rows.append([name, f"{mae(wte, mu_p):.2f}", f"{rmse(wte, mu_p):.2f}",244                     f"{mae(wte, np.clip(mu_p, 0, None)):.2f}",245                     f"{sd_p.mean():.2f}"])246        store[name] = (model, prior, mu_p, sd_p)247    table(["配置", "MAE(原始)", "RMSE", "MAE(截断到≥0)", "采样 std 均值"], rows)248    print("\n  三点观察:")249    print("    · 不加 KL 时,后验几乎退化成 delta(采样 std 只有 0.56),")250    print("      解码器只靠 x 就够了,z 被忽略——这就是后验塌缩;")251    print("    · 加 KL 反而把 MAE 从 19.92 压到 19.22。KL 在训练早期起到")252    print("      类似正则的作用,把后验的方差约束住,解码器被迫学一个")253    print("      更平滑的函数;但 std 一下涨到 7.06,采样出的值开始分散;")254    print("    · W-Dist 把 MAE 推到 21.59——它对齐的是造出来的历史分位数,")255    print("      对齐一个假目标就是给梯度加噪声,点预测被拖差。")256    print("      这与上一节的 GR 同理:加一项损失不等于加一份准确率。\n")257258    # ---------------- ③ 先验的作用 ----------------259    print("③ 流先验:采样出的分布是否更像「该用户自己的分布」")260    rows = []261    for name in ("L_base + KL", "L_base + KL + 流先验 + W-Dist"):262        model, prior, mu_p, sd_p = store[name]263        # 每个样本的预测分布:用 5 个分位点与目标历史分位点的 L1 距离264        with torch.no_grad():265            n_draw = L_Q266            z0 = torch.randn(len(Xs), n_draw, ZDIM)267            if prior is not None:268                z = prior(z0.reshape(-1, ZDIM),269                          Qs.repeat_interleave(n_draw, dim=0)).reshape(-1, n_draw, ZDIM)270            else:271                z = z0272            m, _ = model.decode(Xs.unsqueeze(1).expand(-1, n_draw, -1).reshape(-1, dim),273                                z.reshape(-1, ZDIM))274            gen = m.reshape(-1, n_draw).sort(dim=1).values.numpy()275        l1 = np.abs(gen - Qte).mean()276        rows.append([name, f"{l1:.2f}", f"{sd_p.mean():.2f}",277                     f"{np.median(sd_p):.2f}"])278    table(["配置", "生成分位 vs 历史分位 L1", "采样 std 均值", "std 中位"], rows)279    print("\n  加了 W-Dist 之后 L1 从 58.86 降到 55.24(−6%),")280    print("  说明流先验确实在往历史分布上靠,但幅度远小于「个性化」这个词")281    print("  给人的期待。原因有两层——一是 KL 不经过先验(见文件头),")282    print("  流只能靠 W-Dist 一个弱信号学,1500 步内学不到有意义的形变;")283    print("  二是「个性化先验」要成立,前提是历史分位数里真的含有")284    print("  当前样本的分布信息,而这里的历史是造的,信息量本就有限。")285    print("  正文那个「先验 → 更贴合的多模态分布」的链条,在真实数据上")286    print("  依赖两件合成数据给不了的东西:真实历史序列,以及足够多的样本")287    print("  让 W-Dist 能压过噪声。\n")288289    # ---------------- ④ 分布作为不确定性 ----------------290    print("④ 采样分布能不能当不确定性用")291    model, prior, mu_p, sd_p = store["L_base + KL + 流先验 + W-Dist"]292    err = np.abs(wte - mu_p)293    rows = []294    for lo, hi in ((0, 25), (25, 50), (50, 75), (75, 100)):295        a, b = np.percentile(sd_p, [lo, hi])296        m = (sd_p >= a) & (sd_p <= b if hi == 100 else sd_p < b)297        rows.append([f"{lo}–{hi}%", int(m.sum()), f"{sd_p[m].mean():.2f}",298                     f"{err[m].mean():.2f}"])299    table(["采样 std 分位", "样本数", "平均 std", "平均绝对误差"], rows)300    corr = float(np.corrcoef(sd_p, err)[0, 1])301    print(f"\n  采样 std 与绝对误差的相关系数 {corr:+.3f}——有信号,但很弱。")302    print("  TPM 那节用叶分布方差拿到 +0.79,这里只有 +0.28。差别在于:")303    print("  TPM 的方差来自「叶概率有多分散」,直接对应分类的不确定性;")304    print("  VAE 的 std 来自「z 抖动多少」,只要解码器没学会利用 z,")305    print("  这个抖动就偏向一个与样本无关的常数。这里 std 从 8.27 涨到")306    print("  13.36、误差从 14.55 涨到 29.89,说明它还没完全退化成噪声,")307    print("  但远不足以当「这个样本有多难」的可靠指标。")308    print("  这正是 FlowTime 用 W-Dist 强行把 z 和历史分布绑定的动机:")309    print("  不给 z 一个必须承担的任务,它就会被解码器忽略。\n")310311    print("⑤ 一句话:FlowTime 把时长预估写成「VAE 连续生成 + 流个性化先验")312    print("   + 历史分布对齐」,思路是让 z 承担「用户此刻的行为模式」,")313    print("   而不是让它当噪声;但在本合成数据上,z 的信息量、")314    print("   先验的训练信号和历史分位数的真实性都不够,")315    print("   收益主要体现在「能给出分布」,而不是「点预测更准」。")316317318def main() -> None:319    print("FlowTime:VAE + 流先验 + 历史分布对齐(§23.5.2)\n")320    run_torch()321322323if __name__ == "__main__":324    main()

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

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