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 的书中论断。讲解时请结合实际代码、输入输出与原书页码。
源码结构与阅读入口
hist_quantiles· 第 58 行用特征的一个固定投影模拟「用户历史观看分位数」,形状 [n, L_Q]。run_torch· 第 74 行main· 第 318 行
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()如何用于学习或编写新代码
- 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
- 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
- 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
- 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。