隐变量模型与VAE:从ELBO推导到训练避坑实战解析
发布时间:2026/10/13 5:17:09 锦皓数字建站

上个月照着一份编号为 CS236 的 Deep Generative Models 课程大纲在补生成模型的底子第一部分笔记整理完自回归模型的时候我以为接下来的内容无非是再叠加更大规模的 Transformer。结果第二部分一上来就把视角整个切换了从“逐个像素预测”转向“先想清楚再画图”也就是隐变量模型。说实话我以前用现成库跑过 VAE能跑通 MNIST也会画那个经典的潜在空间插值图但对“ELBO 为什么长这样”“为什么训练时 KL 项会莫名其妙掉到接近 0”“为什么 VAE 生成的图总是糊”这些问题一直没有真正说清楚。CS236 这门课的第二部分正好把这些坑一个一个按顺序讲明白了。这篇文章是系列笔记的第二篇重点覆盖隐变量模型这条技术路线。我会先从自回归模型的局限讲起说明为什么生成模型不能只走“逐元素分解”这一条路然后用手推的方式把 ELBO 这个核心目标拆开让你看清它到底在优化什么接着进入实战环节给出 VAE 的最小实现并详细说训练中容易踩的后验坍塌、重参数化、KL 退火这些坑最后聊聊 CVAE、层次 VAE 与后续内容的关系以及我做 MNIST 实验时的评估经验。如果你是正在刷这门课、或者想系统理解深度生成模型的学习者这篇笔记可以直接当第二部分的配套参考。1. 第一部分复习自回归模型的问题也是隐变量模型的起点1.1 自回归模型赢在哪儿又输在哪儿自回归模型的核心思想是把联合分布拆成条件概率的乘积。对于图像 x(x1,x2,...,xn)我们可以写成p(x) p(x1) p(x2|x1) ... p(xn|x1,...,x_{n-1})这个分解没有做任何近似它只是在说“逐个元素地生成”。只要每个条件分布都能建模好整个联合分布就被建模好了。PixelCNN、PixelRNN、以及现在各种自回归语言模型本质上都是这个框架。自回归模型最大的优点是可以直接用最大似然训练。因为对数似然可以分解成所有条件概率的对数之和训练时可以并行计算每个位置的交叉熵损失。而且理论上你能拿到精确的 log p(x)不是近似不是下界是真的算得出来。这个特点在生成模型里非常难得。但它有两个明显问题。第一生成阶段必须按顺序逐个采样。第 i 个元素依赖前 i-1 个元素所以不能像 GAN 那样一次性生成整张图。想要并行生成往往得靠蒸馏或者额外的并行解码方法代价不小。第二个问题更本质它把所有复杂性都压进了“下一个元素的条件分布”里。模型确实能学会极强的局部统计规律但很难直接得到一个紧凑的全局表示。你问它“这幅图里有没有一只猫”它不是先识别猫再画猫而是在画每一个局部的时候条件分布已经隐式地把猫的信息编码进去了。1.2 我为什么觉得自回归模型的视角有点反直觉我复习第一部分时有一个很直观的感受人看图并不是在逐像素预测。你看到一只猫是先形成“这是一只猫背对着我光线从左边来”的整体理解然后再补全毛发的纹理。如果只看局部条件分布模型确实能生成非常逼真的纹理但它缺少一个显式的“整体状态变量”。这就是隐变量模型的动机。我们引入一个没有直接出现在数据里、但可以解释数据生成的变量 z然后假设所有观测 x 都是先从某个 z 采样再在 z 的条件下生成的p(x) ∫ p(x|z) p(z) dz用大白话说每一张图背后先有一个抽象的“想法”这个想法决定物体的类别、姿态、颜色分布再根据这个想法渲染出具体的像素。那个“想法”就是隐变量 z。这比“逐像素猜下一个”更贴近人对图像的理解。需要注意的是隐变量模型和自回归模型不是对立的。解码器 p(x|z) 完全可以用自回归模型来实现例如 PixelVAE 就是用自回归解码器做 p(x|z)。课程里把它们分成两条路线是为了让你先看清每一条路线各自的代价和优势后面再讲怎么混合。1.3 引入隐变量需要付出的代价自回归模型之所以不用积分是因为联合分布被分解成了条件概率乘积每一项都可以直接估计。但 p(x) ∫ p(x|z) p(z) dz 这个积分在 z 是高维连续变量、p(x|z) 是神经网络输出的时候是算不出来的。我们当然知道理论上可以把 z 所在空间离散化然后暴力求和但维数稍微一高计算量就爆炸。EM 算法呢EM 需要先求后验 p(z|x)而这个后验同样是 intractable 的。这就形成了一个死结最大似然是监督学习里最自然的目标但在隐变量模型中log p(x) 根本无法直接优化。CS236 第二部分的所有内容本质上都是在回答一个问题既然 log p(x) 算不出来那我们应该优化一个什么样的替代目标才能让模型学到好的生成分布VAE 给出的答案是 ELBO也就是变分下界。接下来我们就要亲手把它推出来。2. 手动推导 ELBO把“算不了的似然”变成“能反向传播的目标”2.1 先写清楚问题我们到底要算什么假设生成模型由先验分布 p(z) 和条件分布 pθ(x|z) 组成θ 是生成网络的参数。对单个样本 x对数似然是log pθ(x) log ∫ pθ(x|z) p(z) dz如果 pθ(x|z) 是一个线性高斯模型这个积分有解析解直接用 EM 就能做。但深度生成模型里的 pθ(x|z) 是神经网络参数化的它可能是伯努利分布、高斯分布、甚至更复杂的分布积分 ∫ 在 z 的所有维度上展开没有任何封闭解。这时候一个经典思路是不直接算积分而是引入一个已知的、好采样的分布 q(z)把积分改写成期望的形式。具体来说对任意一个满足“在 pθ(x,z)0 的地方 q(z)0”的分布 q(z)我们都有log pθ(x) log ∫ pθ(x,z) dz log ∫ q(z) * pθ(x,z) / q(z) dz log E_{z~q}[ pθ(x,z) / q(z) ]这里 pθ(x,z) pθ(x|z) p(z) 是联合分布。这一步没有任何近似只是把积分重新解释成了关于 q(z) 的期望。2.2 Jensen 不等式出场由于 log 是凹函数根据 Jensen 不等式log E[·] ≥ E[log ·]。于是log pθ(x) log E_{z~q}[ pθ(x,z) / q(z) ] ≥ E_{z~q}[ log pθ(x,z) - log q(z) ]右边这个量就是 ELBO。如果我们把 q 写成参数化的条件分布 qφ(z|x)φ 是编码器参数那么 ELBO 可以拆成两项ELBO(θ, φ; x) E_{z~qφ(z|x)}[ log pθ(x|z) ] - KL( qφ(z|x) || p(z) )第一项叫重建项它要求从 qφ(z|x) 采样的隐变量能够很好地重建出 x第二项叫正则项它约束编码分布不要偏离先验 p(z) 太远。这两项合在一起就是我们要优化的目标。很多人第一次看到这里会问为什么 q 要选择 qφ(z|x)而不是一个与 x 无关的 q(z)原因很实际。如果 q(z) 与 x 无关那么对于不同的输入 x我们都在用同一个分布去估计 p(x,z)估计出来的下界会非常松训练效率很低。而 qφ(z|x) 相当于训练一个神经网络来做“从 x 到潜在 z 的软映射”等于在说给定当前这张图哪些隐变量最可能解释它这就是编码器的意义。它让下界更紧也让优化更高效。2.3 ELBO 与真实似然之间的差距还有一个非常重要的恒等式很多人只记住 ELBO 是下界却忘了它和 log pθ(x) 之间的差距是多少。其实可以精确算出来log pθ(x) ELBO(θ, φ; x) KL( qφ(z|x) || pθ(z|x) )其中 pθ(z|x) 是真实后验。推导不复杂只需要把 log pθ(x) 写成 log pθ(x,z) - log pθ(z|x)然后两边对 qφ(z|x) 取期望。因为 KL 永远非负所以 ELBO 是 log pθ(x) 的下界。这个恒等式告诉我们一件事优化 ELBO 时我们同时在让生成模型 pθ 更好也在让近似后验 qφ 离真实后验更近。前者让模型能更好地解释数据后者让变分推断更准确。两者耦合在一起正是 VAE 训练里很多奇怪现象的总根源。我还喜欢用三个视角来理解 ELBO方便在不同场景下切换思维方式从损失函数角度看重建项 正则项前者逼着模型“把话说清楚”后者逼着模型“不要胡说八道”。从变分推断角度看ELBO 是 log p(x) 的下界优化它等价于在你没法计算真实后验时做近似推断。从信息论角度看重建项代表解码器的对数似然KL 项代表编码 z 相对于先验所需要的额外比特数。这个视角在后续讲 bits-per-dimension 这种指标时特别好用。3. VAE 训练中的三个坑重参数化、KL 退火与后验坍塌看完理论推导接下来就要把它变成能跑的代码。我在做课程实验时用 PyTorch 写了最小实现这里直接给出核心结构再重点讲推导中不会提到、但训练时一定会踩的三个坑。import torch import torch.nn as nn import torch.nn.functional as F class VAE(nn.Module): def __init__(self, input_dim784, hidden_dim256, latent_dim32): super().__init__() self.encoder nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), ) self.mu_layer nn.Linear(hidden_dim, latent_dim) self.logvar_layer nn.Linear(hidden_dim, latent_dim) self.decoder nn.Sequential( nn.Linear(latent_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, input_dim), ) def encode(self, x): h self.encoder(x) return self.mu_layer(h), self.logvar_layer(h) def reparameterize(self, mu, logvar): std torch.exp(0.5 * logvar) eps torch.randn_like(std) return mu eps * std def forward(self, x): mu, logvar self.encode(x) z self.reparameterize(mu, logvar) recon self.decoder(z) return recon, mu, logvar def vae_loss(x, recon, mu, logvar): recon_loss F.binary_cross_entropy_with_logits(recon, x, reductionsum) / x.size(0) kl_loss -0.5 * torch.sum(1 logvar - mu.pow(2) - logvar.exp()) / x.size(0) return recon_loss kl_loss, recon_loss, kl_loss3.1 重参数化技巧为什么是革命性的编码器输出的是分布参数 μ 和 log σ²而不是隐变量 z 本身。训练时要计算解码器输出对编码器参数的梯度可如果直接写“从 N(μ, σ²I) 中采样 z”采样这个操作是不可导的。随机性来自分布本身梯度无法穿过采样过程回传到 μ 和 logvar这样编码器就完全学不到东西。重参数化技巧的核心是把随机性搬到外部先从标准正态分布里采样一个 ε然后计算 z μ σ * ε。这样一来z 仍然是随机的但随机性只存在于 ε 这个与模型参数无关的变量里而 μ 和 logvar 变成了一个确定性变换的系数梯度可以正常回传。课程里把这一步称作“让采样过程变成可微的数据流节点”非常准确。这个技巧不只是 VAE 能用。只要目标函数里有从参数化分布采样的期望都可以考虑重参数化。连续分布可以直接用这种“平移缩放”的方法离散分布则要用 Gumbel-Softmax 之类的近似。理解了这一点再看很多进阶生成模型时就不会晕。3.2 KL 退火训练初期不要直接上完整 KL我在第一个实验里直接用了完整损失函数 recon_loss kl_loss结果 KL 项在刚开始几个 epoch 就暴涨到很大重建还没学会编码器被迫把所有 z 都往标准正态方向压样本质量很差。后来我查了一些实现发现很多人会在训练初期给 KL 加一个权重 β并且让 β 从 0 慢慢升到 1这就是 KL 退火。为什么需要退火因为训练开始时重建项还很弱如果 KL 项权重过高模型会优先把 q(z|x) 拉向先验而不是去学怎么用 z 解释 x。这会形成一个很差的局部最优z 几乎不含信息重建永远模糊。让 β 从 0 起步等于先让模型学会利用 z 去重建再慢慢施加正则约束最后达到 ELBO 要求的平衡。我现在的习惯是前 10 个 epoch 让 β 线性增长到 1或者设置一个目标 KL 值训练前期只优化重建KL 超过阈值再参与优化。3.3 后验坍塌KL 掉到 0 不是好事训练一段时间后如果只看总 loss可能觉得很正常但分开看 KL 项时崩溃了KL 项快速掉到接近 0重建项也没有明显变好。这就是后验坍塌。此时编码器输出的 μ 接近 0logvar 接近 0qφ(z|x) 对所有 x 都变成了标准正态分布z 里根本没有输入 x 的信息。为什么会出现这种情况本质上是因为解码器太强了。如果 pθ(x|z) 本身已经能很好地建模边缘分布 p(x)那模型就不需要 z 了KL 降到 0 反而是 ELBO 的一个优化方向。对 MNIST 这种相对简单的数据只要解码器足够大这种现象很容易出现。减少隐变量维度、降低解码器容量、使用 free bits 给每个隐变量维度一个 KL 下限这些手段各有各的道理。我的经验是先把隐变量维度定小一点比如 32 维以下再考虑更复杂的正则技巧。另外训练时一定要把 KL 和重建 loss 分开画曲线我每次看到 KL 曲线异常都会先停一下而不是继续盲目调学习率。# 训练循环里建议分段返回 loss 分量 for batch_idx, (x, _) in enumerate(train_loader): x x.view(x.size(0), -1) recon, mu, logvar model(x) total_loss, recon_loss, kl_loss vae_loss(x, recon, mu, logvar) optimizer.zero_grad() total_loss.backward() optimizer.step()4. 从 VAE 到 CVAE 和层次 VAE隐变量模型家族怎么扩展4.1 条件 VAE让隐变量模型听指挥普通 VAE 的隐变量是无监督学出来的模型并不知道自己生成的是哪一类数字。如果你想让模型生成指定的类别比如“生成一张 7”那就需要给模型传入条件信息。条件 VAECVAE的做法非常直接把条件 y 同时送入编码器和解码器。生成过程变成 p(x|y)编码过程变成 q(z|x,y)ELBO 变成ELBO E_{z~qφ(z|x,y)}[ log pθ(x|y,z) ] - KL( qφ(z|x,y) || p(z|y) )在 MNIST 上y 就是数字类别标签在小规模生成任务里y 可以是文本、姿态参数、语音特征。有了条件信息模型生成时不再是“盲目地画一张图”而是“在指定语义下画一张图”。我在课程实验里把标签拼接进编码器和解码器的输入向量中效果立竿见影比训练多个单类别的 VAE 方便得多。需要注意的一个细节是 p(z|y)。很多简化实现直接假设 z 与 y 独立即 p(z|y)p(z)这样 KL 项和普通 VAE 完全一样。但严格来说如果 y 和 x 强相关z 中很可能还有一部分信息是与 y 相关的把 p(z|y) 建模成一个条件高斯会更合理。课程里会讨论这两种做法的差别做实际项目时先从简单假设开始通常就够用了。4.2 层次 VAE把隐变量空间变深普通 VAE 只有一个隐变量 z表达能力有限。层次 VAE 的思路是把 z 拆成多组比如 z1, z2, ..., zL生成过程变成p(x) ∑ p(x|z1) p(z1|z2) ... p(zL)这就像是先在顶层决定“这是一幅山水画”第二层决定“近处有山、远处有水”第三层再决定“山上的树该怎么分布”。每一层负责不同粒度的抽象这让模型能表达更复杂的结构。实际代表是 Ladder VAE、NVAE 这些模型。但层次 VAE 的训练难度比普通 VAE 高不少。每一层都可能发生后验坍塌而且越深的层越容易被当成无信息的先验噪声。课程里提醒过一个观点增加层数不等于一定能提升表现力如果训练技巧跟不上深层 VAE 的表现可能反而不如简洁的单层模型。我自己尝试复现一个两层版本时花了很大力气调 KL 权重最后才让每一层都“有事可做”。VQ-VAE 是另一种思路用离散隐变量替代连续高斯隐变量。它其实不是纯粹的 ELBO 框架而是先训练一个离散自编码器再单独训练一个自回归先验来学习隐变量之间的依赖。虽然这个模型不在 VAE 的严格范畴内但它很好地展示了“隐变量可以是离散的”这个方向后续很多高质量生成模型都继承了这个思想。4.3 指向标准化流的伏笔CS236 第二部分讲完 VAE 后紧接着会进入 Normalizing Flow。如果你理解了 VAE 的局限就很容易理解为什么要引入流模型。VAE 有两个主要限制一是先验 p(z) 通常取标准正态过于简单二是解码器 pθ(x|z) 为了可计算一般也取高斯或伯努利这类简单分布。模型整体能表达出的分布形状被这些简单分布限制住了。标准化流做的事情是构造一个一系列可逆变换组成的映射把简单分布逐步改造成复杂分布。这样既能保留“算得出来精确对数似然”的好处又比 VAE 中固定的先验灵活得多。在进阶模型里经常能看到用 flow 做 qφ(z|x)、用 flow 做 pθ(x|z)、或者用 flow 直接做生成模型的先验。课程把 VAE 放在标准化流前面就是为了让你先理解“显式密度模型需要面对积分难题”再理解“用可逆变换可以绕过这个难题”。5. 实验复现笔记MNIST 上的 VAE 长什么样以及如何评估5.1 实验设置和观察我在本地复现课程实验时按下面的配置跑了一遍 MNIST参数设置数据集MNIST训练集 60000测试集 10000二值化编码器/解码器MLP隐藏层 256ReLU隐变量维度32优化器Adam学习率 1e-3Batch size256Epoch50logvar 初始化全 0 向量训练中我记录了 recon loss 和 KL loss 两条曲线。前几个 epoch 重建 loss 下降很快KL loss 稳定上升到了后期重建 loss 变化很小KL loss 还在缓慢爬升。这说明模型在一步步把隐空间规整成先验形状。最终训练出来的模型从先验采样生成的手写数字能辨认但笔迹边缘偏模糊尤其是数字交叉的地方。这个现象在高斯解码器前提下几乎是必然的每个像素被当成条件独立的伯努利分布来建模无法表达像素之间的精细相关性。我建议你在复现时也把 recon_loss 和 kl_loss 分开打印这比只看总 loss 有用得多。我习惯每个 epoch 打印三行Epoch 050 | total loss: 112.34 | recon loss: 102.81 | kl loss: 9.53当 KL loss 掉到 0 附近而总 loss 还在慢慢下降时就要警惕是不是后验坍塌了。5.2 IWAE用重要性加权把 ELBO 变成更紧的评估量搞明白训练之后下一个问题是“这个模型到底好不好”。普通 VAE 的 ELBO 是 log p(x) 的一个下界下界到底紧不紧我们并不知道。一个更实用的评估方法是重要性加权自编码器IWAE。思路很直接对同一个 x从 qφ(z|x) 里采样 K 个 z然后计算log p(x) ≈ log( (1/K) ∑_{k1}^K pθ(x, z_k) / qφ(z_k|x) )这个式子给出了一个比普通 ELBO 更紧的下界而且 K 越大估计越接近真实 log p(x)。我在评估测试集时用 K100 采样来计算平均的负对数似然下界比直接看重建 loss 更能反映生成模型的好坏。需要注意的是IWAE 在训练时也可以用但课程里提醒过直接拿 IWAE 目标训练时梯度方差会比 ELBO 大实践中要做好权衡。如果还要对比不同生成模型的样本质量FID 这类指标也很有用。但课程里强调了一个核心观点样本质量和似然质量不是一回事。VAE 的样本可能偏模糊、但覆盖面广GAN 的样本锐利、但容易出现模式坍塌。你要结合自己的应用场景选择评估指标而不是只看某一个数字。5.3 插值实验隐空间到底学到了什么VAE 被正则化到接近标准正态分布的隐空间所以从先验采样是可行的。除了直接采样还有一个很好玩的实验是隐空间插值。任意取两个隐变量 z1 和 z2对 t 从 0 到 1 计算 z (1-t)z1 t z2把对应的 z 依次送进解码器就能看到一张图平滑地过渡成另一张图。我在 MNIST 上做这个实验时能看到数字从“3”慢慢变成“8”中间还会出现一些模棱两可的过渡形态。这个实验验证了隐变量的连续性。普通自编码器也能学出一个隐空间但因为缺乏先验正则它的隐空间里可能有漏洞插值时会出现大量不合理的样本VAE 的 KL 项强行把隐空间拉向先验所以插值路径上的每一个点都大致在合理范围内。如果你发现插值结果很怪比如中间出现完全无法辨认的图往往说明 KL 项权重太低或者模型训练没收敛。5.4 两个必须提的实现细节第一个细节是 MNIST 的二值化。MNIST 原始像素值是 0 到 1 的浮点数严格来说并不服从伯努利分布。如果你的解码器用 BCE 损失那么模型天然把每个像素当成伯努利随机变量最好先把像素随机二值化为 0/1或者至少在训练和评估时保持一致的预处理。否则模型会浪费一部分能力去拟合那些“半亮不亮”的中间值。第二个细节是 logvar 的初始化。我第一次实现时把 logvar 初始化为 0意味着编码器输出初始方差就是 1KL 项在训练刚开始会很大导致总 loss 以 KL 为主重建学得慢。后来我习惯把 logvar 初始化为 -1 或 -2让 KL 从较小值起步训练会平稳很多。这个小改动本身不影响最终效果但能明显缩短前期抖动。最后说一个我个人的体会。CS236 第二部分学完之后我看生成模型的方式发生了改变以前只关心“生成图好不好看”现在会先想“这个模型到底在优化什么目标”。自回归模型直接优化精确对数似然VAE 优化的是对数似然的下界GAN 连似然都没有只能靠判别器信号训练。没有哪一种是绝对更好的关键看你需要的是采样速度、样本锐度、似然可评估性还是可控生成。下一篇笔记我会把 Normalizing Flow 的推导和实验整理出来继续沿着“如何精确计算似然”这条线往下走。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。