变分自编码器(VAE)基础:编码器-解码器结构,重构损失与KL散度
4.1 从自编码器到变分自编码器——我们到底在解决什么问题?
要讲VAE,得先从自编码器(AE)说起。自编码器这东西,说白了就是个"压缩-解压"的网络。你给它一个输入,它先压成一个低维向量,再从这个向量重建出原始输入。听起来挺合理的对吧?
但我自己在做分子生成的时候发现,AE有个致命问题——它的潜在空间是"不连续的"。你拿两个已知分子的潜在向量做插值,中间出来的东西很可能根本不是个分子。为什么会这样?因为AE只管把训练样本"记住",它不管潜在空间里那些没被训练覆盖到的区域长什么样。
VAE就是来解决这个问题的。它不再把输入编码成一个点,而是编码成一个概率分布。然后从这个分布里采样,再解码。嗯,就这么一个小改动,潜在空间突然变得"光滑"了,插值也能出合理的东西了。
4.2 编码器-解码器结构
VAE的结构其实不复杂。我来拆解一下。
编码器:从分子到分布。编码器接收一个分子表示(比如SMILES字符串的one-hot编码,或者分子图),然后输出两个东西:均值向量 μ 和对数方差向量 log σ²。注意,它输出的是分布的参数,而不是一个确定的潜在向量。
解码器:从潜在变量到分子。解码器从潜在空间采样一个向量 z,然后把它映射回分子空间。这个映射过程跟AE里的解码器很像,但因为训练时z是从分布里采样的,解码器被迫学会处理"带噪声"的输入,反而更鲁棒。
我记得自己第一次看VAE论文时,对"编码器输出分布参数"这个设计困惑了好一阵。后来在项目中调试一个分子生成模型才发现,如果编码器直接输出一个点,生成的新分子样例会非常"贫瘠"——说白了就是只会模仿,不会创造。而加上分布采样后,模型的"创造力"就出来了。
下面这张图展示了VAE的完整架构,包括数据流向和两个损失函数的位置:
4.3 重构损失——让生成像真分子
重构损失衡量的是解码器输出的 x' 跟原始输入 x 有多像。在分子生成里,这个损失函数的选择直接影响生成分子的"合法性"。
如果分子的表示是离散的(比如SMILES字符串的one-hot编码),我们一般用二值交叉熵(BCE)。但我在项目里踩过一个坑——直接用BCE对SMILES做重构,模型学到的其实是"记忆"而不是"理解"。稍微改一下输入顺序,生成的SMILES就乱套了。
后来我换成了逐位置的交叉熵损失,把每个token的分类概率都考虑进去,效果才稳定下来。
4.4 KL散度——约束潜在空间的正则化项
KL散度是VAE跟AE最本质的区别。它的作用是让编码器输出的分布 q(z|x) 尽量接近标准正态分布 N(0,1)。
你想想看,如果没有KL散度,编码器会怎么做?它会偷偷把方差σ²压到几乎为零,这样"采样"就变成了"取均值",VAE就退化成了普通AE。我刚开始调VAE时就碰到过这个现象——KL散度项的权重设得太小,训练出来的模型生成效果跟AE没区别。
KL散度的计算公式长这样:
KL(q(z|x) || N(0,1)) = -0.5 * Σ (1 + log(σ²) - μ² - σ²)
这个式子看着唬人,其实物理含义很直观:它惩罚那些跟标准正态分布"差得太远"的潜在分布。
| 维度 | 重构损失 | KL散度 |
|---|---|---|
| 核心目标 | 保证解码器能准确还原输入 | 约束潜在空间的结构 |
| 对潜在空间的影响 | 让潜在变量"记住"输入信息 | 让潜在变量"规范化" |
| 权重过大的后果 | 过拟合,生成多样性低 | 潜在变量失效,生成质量差 |
| 权重过小的后果 | 生成结果不准确 | 退化为AE,空间不连续 |
| 在分子生成中的典型值 | 权重=1.0(基准) | 权重=0.1~0.5(需调参) |
4.5 重参数化技巧——让梯度流动起来
这个技巧,说白了就是为了让"采样"这个操作变得可导。你想想,模型里有一个采样节点 z ~ N(μ, σ²),采样本身是随机的,梯度没法回传。但如果不采样,那又跟AE没区别了。
重参数化的做法很简单:把"从N(μ, σ²)采样"拆成两步——先从标准正态分布 N(0,1) 采样一个ε,再计算 z = μ + σ * ε。这样一来,随机性被"冻结"在ε里,μ和σ的梯度就能正常回传了。
我之前带新人做分子生成项目时,好几个人都卡在重参数化这一步。他们觉得"反正都是采样,直接调采样器的API不就行了?"——不行,那样梯度断掉了,编码器根本学不到东西。
采样 = 确定性的变换 + 独立的随机噪声
z = μ + σ ⊙ ε,其中 ε ~ N(0, I)
4.6 VAE损失函数的完整实现
把所有东西拼起来,VAE的损失函数就是:重构损失 + KL散度。下面是我在项目里常用的PyTorch实现,加了点注释方便理解:
def vae_loss(x, x_recon, mu, log_var):
"""
x: 原始输入 (batch_size, seq_len, vocab_size)
x_recon: 解码器输出 (batch_size, seq_len, vocab_size)
mu: 编码器输出的均值 (batch_size, latent_dim)
log_var: 编码器输出的对数方差 (batch_size, latent_dim)
"""
# 重构损失:逐位置的交叉熵
recon_loss = F.cross_entropy(
x_recon.reshape(-1, x_recon.size(-1)),
x.reshape(-1),
reduction='mean'
)
# KL散