生成模型 / 表示与压缩 原理笔记入门 17 分钟阅读 查看源文件 ↗

VAE 与隐空间表示#

本页内容5 个部分
查看概览图
FOUNDATIONS · 04c

四项 loss 各修一种病,
少一项就塌一块

SD 的 VAE 常结合重建、感知、对抗与 KL 损失;KL 是正则项,不是后续扩散的必要条件。

INPUT x 512 × 512 × 3
ENCODER μ, σ 重参数化 z = μ + σ⊙ε
LATENT z 64 × 64 × c
DECODER x̂ 重建
重参数化让采样这一步可导——梯度能穿过随机节点回到 encoder
损失项作用如果去掉会怎样
L1 重建像素级对齐图像整体偏移、颜色不对
LPIPS 感知VGG 特征空间对齐,保留结构和纹理像素对齐但模糊(平均化高频细节)
GAN 对抗patch 判别器逼迫输出「真实」纹理重建清晰但纹理不自然、缺锐度
KL 正则约束隐空间分布接近 N(0, I)隐空间不连续,扩散模型无法在其中有效操作
✗两种失败模式

模糊和「塑料感」不是同一个病

  • L2 与模糊在多解条件回归中,逐像素均值可能抹平细节;可精确重建的任务不必模糊,L1 也不保证清晰。
    L1 + LPIPS 修
  • 纹理不真实高频回来了但分布不对:边缘锐利但皮肤像蜡像——统计特征(毛孔尺寸、密度、随机性)不像真实材质。
    GAN 修
两条路线 连续 KL
vs 离散 VQ
VQ离散化的替代路线

三个损失项各更新一部分

  1. ①
    重建损失 更新 encoder + decoder(通过 STE) 让系统学到有意义的编码和重建
  2. ②
    Codebook 更新 只更新 codebook 向量 e_k 让 codebook 追着 encoder 输出走(系数 1.0,全力追)
  3. ③
    Commitment 只更新 encoder 让 encoder 输出靠近 codebook(系数 0.25,轻轻靠)
版本latent 通道空间压缩来源备注
SD 1.x / 2.x48×CompVis KL-f8社区所有 1.5 微调模型共用同一个 VAE
SDXL48×改进版,更低重建误差社区常用的 “SDXL VAE fix” 进一步修复色偏
SD 3 / Flux168×全新训练更多通道 → 保留更多细节,但 latent 维度更高
EMA 更新
解决了训练动力学,没解决死码

EMA 提供独立于梯度优化器的码本更新与平滑,效果取决于 decay 和采样。但 N_k 和 m_k 同步衰减意味着 dead code 原地不动——也不会自愈。一个从一开始就远离所有 z_e 的码,永远等不到第一次被选中。

KL-VAE · 连续隐空间VQ-VAE · 离散 codebook死码不会自愈

组件阅读路线 · ELBO · 重参数化 · SD VAE 编解码器 · 向量量化

1. VAE 基础原理#

1.1 从 AE 到 VAE:为什么要概率化#

普通 autoencoder 把图像压缩到一个确定性的隐向量 z=f(x)z = f(x),只关心重建。问题:训练集中没出现的 zz 区域是空白的,在隐空间随便采样一个点解码出来基本是噪声——隐空间没有"分布",只有一堆离散的训练点。这对生成模型而言是致命的。

VAE 的解法:让编码器输出一个分布 q(z∣x)=N(μ(x),σ2(x)I)q(z|x) = \mathcal{N}(\mu(x), \sigma^2(x) I) 而不是一个点,并用 KL 散度把所有 q(z∣x)q(z|x) 都拉向同一个先验 N(0,I)\mathcal{N}(0, I)。这样隐空间被"填满"成一个连续光滑的高斯,任意采样都能解码出有意义的图像。

VAE 的核心思想(一句话)

学一个编码器 q(z∣x)q(z|x) 把图像映射到隐变量 zz,学一个解码器 p(x∣z)p(x|z) 从 zz 重建图像。用 KL 散度约束 q(z∣x)q(z|x) 接近标准正态 N(0,I)\mathcal{N}(0,I),这样隐空间连续、平滑、可插值。

1.2 ELBO:VAE 在优化什么#

VAE 最大化 Evidence Lower BOund(ELBO):

log⁡p(x)≥Eq(z∣x)[log⁡p(x∣z)]⏟重建项−DKL(q(z∣x)  ∥  p(z))⏟KL 正则项=ELBO\log p(x) \geq \underbrace{\mathbb{E}_{q(z|x)}[\log p(x|z)]}_{\text{重建项}} - \underbrace{D_\text{KL}(q(z|x)\;\|\;p(z))}_{\text{KL 正则项}} = \text{ELBO}

p(z)=N(0,I)p(z) = \mathcal{N}(0, I) 是先验。最大化 ELBO = 最大化重建质量 + 最小化隐分布与标准高斯的距离

两项的含义:

  • 重建项:让解码器从 zz 尽可能还原原图。实际中用 MSE 或 L1 loss
  • KL 项:让编码器输出的 q(z∣x)=N(μ,σ2I)q(z|x) = \mathcal{N}(\mu, \sigma^2 I) 接近 N(0,I)\mathcal{N}(0, I)。有闭式解:

DKL=−12∑j=1d(1+log⁡σj2−μj2−σj2)D_\text{KL} = -\frac{1}{2}\sum_{j=1}^{d}\left(1 + \log\sigma_j^2 - \mu_j^2 - \sigma_j^2\right)

dd 是隐变量维度。每个维度独立计算,不需要采样估计。

1.3 重参数化技巧#

编码器输出 μ\mu 和 log⁡σ2\log\sigma^2,采样 z∼N(μ,σ2)z \sim \mathcal{N}(\mu, \sigma^2)。但随机采样不可导,梯度传不回编码器。

重参数化:把随机性外包给独立噪声 ϵ∼N(0,I)\epsilon \sim \mathcal{N}(0, I):

z=μ+σ⊙ϵ,ϵ∼N(0,I)z = \mu + \sigma \odot \epsilon, \quad \epsilon \sim \mathcal{N}(0, I)

μ\mu 和 σ\sigma 是确定性函数,梯度可以直接回传到编码器参数

面试追问:为什么输出 log⁡σ2\log\sigma^2 而不是 σ\sigma?

  • σ\sigma 必须 > 0,直接输出 σ\sigma 需要额外的非负约束(如 softplus)
  • log⁡σ2\log\sigma^2 可以是任意实数,网络输出不受约束,数值更稳定
  • 计算 σ\sigma 时:σ=exp⁡(0.5×log⁡σ2)\sigma = \exp(0.5 \times \log\sigma^2)

1.4 KL 正则化的作用#

LVAE=∥x−x^∥2⏟重建损失+β⋅DKL(q(z∣x)  ∥  N(0,I))⏟KL 正则\mathcal{L}_\text{VAE} = \underbrace{\|x - \hat{x}\|^2}_{\text{重建损失}} + \beta \cdot \underbrace{D_\text{KL}(q(z|x)\;\|\;\mathcal{N}(0,I))}_{\text{KL 正则}}

KL 项的作用:让编码器输出的隐空间分布接近标准高斯 N(0,I)\mathcal{N}(0, I)。这样扩散模型在 TT 步加噪后也是 N(0,I)\mathcal{N}(0, I),采样起点才有意义。

如果没有 KL 正则,编码器可能把不同图像映射到隐空间中互不相干的区域,导致隐空间"不连续"——扩散模型在这种空间中插值和去噪都不自然。

2. KL-VAE:Stable Diffusion 中的落地#

Stable Diffusion 社区把它用的 VAE 叫"KL-VAE",但其实就是上一节的标准 VAE(Kingma & Welling, 2014)——这个名字只是为了和后面会讲的 VQ-VAE 做区分,强调用的是 KL 散度正则化(连续隐空间),而非 VQ-VAE 的离散 codebook。

2.1 SD VAE 的编解码器架构#

① Encoder   vs   ② Decoder(对称结构)

Encoder(下采样 / 压缩)

输入图像

512 × 512 × 3

初始 Conv 3×3

3 → 128 ch

512 × 512 × 128

Down Block 1

ResNet × 2

512 × 512 × 128

↓ Downsample ×½

256 × 256 × 128

Down Block 2

ResNet × 2

256 × 256 × 256

↓ Downsample ×½

128 × 128 × 256

Down Block 3

ResNet × 2

128 × 128 × 512

↓ Downsample ×½

64 × 64 × 512

Down Block 4

ResNet × 2 (不下采样)

64 × 64 × 512

Middle Block

ResNet → Attn → ResNet

64 × 64 × 512

输出层

GroupNorm + Conv 3×3

512 → 8 ch

64 × 64 × 8

μ 均值

64×64×4

log σ² 方差

64×64×4

重参数化   z = μ + σ·ε

隐变量 z 输出

64 × 64 × 4

→ 送入扩散模型

共享

Middle Block

(对称的最深处)

Decoder(上采样 / 还原)

隐变量 z 输入

64 × 64 × 4

来自扩散模型去噪结果

初始 Conv 3×3

4 → 512 ch

64 × 64 × 512

Middle Block

ResNet → Attn → ResNet

64 × 64 × 512

Up Block 1

ResNet × 2 (不上采样)

64 × 64 × 512

Up Block 2

ResNet × 2

64 × 64 × 512

↑ Upsample ×2

128 × 128 × 256

Up Block 3

ResNet × 2

128 × 128 × 256

↑ Upsample ×2

256 × 256 × 128

Up Block 4

ResNet × 2

256 × 256 × 128

↑ Upsample ×2

512 × 512 × 128

输出层

GroupNorm + SiLU + Conv 3×3

128 → 3 ch

512 × 512 × 3

重建图像 x̂

512 × 512 × 3

RGB 像素图像

③ ResNet Block 内部细节

输入 x

H × W × C_in

GroupNorm

按通道组归一化,不依赖 batch size

SiLU 激活

x · sigmoid(x),平滑非线性

Conv 3×3

C_in → C_out  |  padding=1

GroupNorm

SiLU 激活

Conv 3×3

C_out → C_out

+

输出

H × W × C_out

Skip Connection
C_in = C_out → Identity
C_in ≠ C_out → Conv 1×1

ResNet Block 核心要点

  • 📌 残差学习:F(x)+x,网络只学"增量"而非全量映射,更容易优化
  • 📌 梯度高速公路:skip 路径提供直接梯度通路,缓解深层优化困难;不保证所有方向的梯度都不消失
  • 📌 GroupNorm:按通道组归一化,不依赖 batch size,适合小 batch 训练
  • 📌 Conv 1×1 投影:通道数变化时对 skip 路径做维度对齐,保证相加合法

具体维度流程#

# 编码
x:      (B, 3, 512, 512)     # 输入 RGB 图像
↓ Encoder
μ:      (B, 4, 64, 64)       # 均值
logσ²:  (B, 4, 64, 64)       # 对数方差
z:      (B, 4, 64, 64)       # 采样 z = μ + σ·ε

# 解码
z_0:    (B, 4, 64, 64)       # 去噪后的隐变量
↓ Decoder
x_hat:  (B, 3, 512, 512)     # 重建图像

# 实际中 SD 会对 z 做 scaling:z = z * 0.18215
# 这个 magic number 让 z 的方差接近 1

VAE 质量 = 生成质量上限

VAE 解码器的重建能力决定了最终图像的清晰度上限。即使 U-Net 去噪完美,如果 VAE 解码器重建模糊,最终图像也会模糊。这就是为什么 SDXL 升级了 VAE——更好的解码器直接提升了全局画质。

2.2 SD VAE 的完整损失函数#

SD 的 VAE 不只用 ELBO 的两项,而是四项损失协同训练:

L=λrec⋅∥x−x^∥1⏟重建损失 (L1)+λperc⋅LLPIPS(x,x^)⏟感知损失+λadv⋅LGAN(x^)⏟对抗损失+λkl⋅DKL⏟KL 正则\mathcal{L} = \underbrace{\lambda_\text{rec} \cdot \|x - \hat{x}\|_1}_{\text{重建损失 (L1)}} + \underbrace{\lambda_\text{perc} \cdot \mathcal{L}_\text{LPIPS}(x, \hat{x})}_{\text{感知损失}} + \underbrace{\lambda_\text{adv} \cdot \mathcal{L}_\text{GAN}(\hat{x})}_{\text{对抗损失}} + \underbrace{\lambda_\text{kl} \cdot D_\text{KL}}_{\text{KL 正则}}

损失项 作用 如果去掉会怎样
L1 重建 像素级对齐 图像整体偏移、颜色不对
LPIPS 感知 VGG 特征空间对齐,保留结构和纹理 像素对齐但模糊(平均化高频细节)
GAN 对抗 patch 判别器逼迫输出"真实"纹理 重建清晰但纹理不自然、缺锐度
KL 正则 约束隐空间分布接近 N(0,I)\mathcal{N}(0,I) 隐空间不连续,扩散模型无法在其中有效操作

为什么需要 GAN 判别器?

纯 L1/L2 + KL 训练的 VAE 重建是模糊的——因为 L2 对所有可能输出取期望,会"平均化"高频细节。加入 patch discriminator 后,解码器必须生成判别器也认为"真实"的纹理,重建锐度大幅提升。这种 VAE+GAN 混合训练在 VQGAN(Esser et al., 2021)中首次提出,后被 SD 直接采用。

概念区分:高频细节 vs 纹理

"高频"和"纹理"在文献里经常被混用,但严格来说不是同一回事——而且 L1/LPIPS/GAN 解决的是不同层面的问题。

  • 高频细节是信号处理概念:图像像素值在小范围内快速变化的成分(傅里叶频谱外圈)。锐利的边缘、单根头发丝、棋盘格线、甚至随机噪声——只要"快速变化"都算。低频则是缓变成分:大块单色、平滑渐变、整体光照。
  • 纹理是视觉感知概念:物体表面看起来"是什么材质"的视觉模式。木纹、布料经纬、皮肤毛孔、砖墙颗粒、金属划痕——它有结构、有统计规律、和材质语义绑定。

关系:纹理几乎都由高频组成,但高频不一定是纹理。一根锐利边缘是高频但不是纹理(只是边,没有面);随机白噪声是高频但不是纹理(无结构);皮肤毛孔既是高频也是纹理。

失败模式 问题 谁负责修
L2 模糊 高频被平均掉了:L2 对每个像素独立取均值,逼近所有合理重建的像素均值,高频部分(快速振荡)在均值里互相抵消变成 0。这是数学事实,跟"是不是纹理"无关。 L1(中位数更稳健)+ LPIPS(在 VGG 特征空间度量距离,对模糊敏感)
纹理不真实 高频回来了但分布不对:脸部边缘很锐利,但皮肤看起来像塑料/像蜡像——高频成分在但统计特征(毛孔尺寸、密度、随机性)不像真实材质。 GAN(patch discriminator 学的就是"什么样的高频分布看起来像真实材质")

反例:早期 ESRGAN 出现"过锐化"——边缘极其锋利,高频拉满,但人脸看起来不真实,因为皮肤纹理的统计被破坏了。GAN loss 调好之后才能两者兼顾。这也是为什么 SD VAE 三种损失要叠加:L1 管像素对齐,LPIPS 管"特征级"的高频保留(顺便对纹理也敏感,因为 VGG 学过纹理),GAN 管纹理分布是否符合自然图像统计。

2.3 Scaling Factor 0.18215#

SD 1.x/2.x 的代码中随处可见:

z = vae.encode(x).latent_dist.sample() * 0.18215  # 编码后缩放
x = vae.decode(z / 0.18215)                        # 解码前反缩放

这个 magic number =1/std(ztrain)= 1/\text{std}(z_\text{train}),即训练集经 VAE 编码后隐变量标准差的倒数。乘上它让 zz 的方差接近 1,与扩散过程的 N(0,I)\mathcal{N}(0,I) 假设对齐。

SDXL 起改用可学习的 scaling factor(作为模型参数存储),不再硬编码。SD 3 的 16 通道 VAE 同样使用学到的 scaling。

2.4 各版本 VAE 差异#

版本 latent 通道 空间压缩 来源 备注
SD 1.x / 2.x 4 8× CompVis KL-f8 社区所有 1.5 微调模型共用同一个 VAE
SDXL 4 8× 改进版,更低重建误差 社区常用的 "SDXL VAE fix" 进一步修复色偏
SD 3 / Flux 16 8× 全新训练 更多通道 → 保留更多细节,但 latent 维度更高

16 通道 vs 4 通道

SD 3 / Flux 把 latent 通道从 4 扩到 16,隐空间维度从 64×64×4=16,38464\times64\times4=16{,}384 增长到 64×64×16=65,53664\times64\times16=65{,}536(4× 增加)。好处是 VAE 重建质量更高(更多通道 = 更多信息保留),代价是扩散模型需要处理更高维的隐变量。DiT 的 scaling 能力使得这个代价可以接受。

2.5 VAE 的训练与微调实践#

  • 独立预训练:VAE 与 U-Net/DiT 分开训练,先把 VAE 训好再开扩散训练。
  • 扩散训练时冻结:VAE 参数在扩散训练时通常不更新。微调 LoRA/DreamBooth 也只改 U-Net/DiT,不动 VAE。
  • 社区 VAE 替换:部分社区 checkpoint 附带改进的 VAE 权重(如 "blessed VAE"),只替换解码器端以减少色偏或模糊。这是可行的,因为 VAE 的隐空间分布由编码器 + KL 正则决定,解码器可以独立升级。
  • 视频 VAE 扩展:视频生成(如 SVD、CogVideoX)通常需要把 2D VAE 扩展为 3D VAE(加入时序卷积),在视频数据上微调以学习时间维度的压缩。

3. VQ-VAE:离散化的替代路线#

到这里 KL-VAE 的故事讲完了。但 VAE 这条路还有另一个分支:VQ-VAE,用离散 codebook 取代连续高斯。它的提出动机不是"为了离散而离散",而是为了解决标准 VAE 的一个老毛病——后验坍缩。

3.1 后验坍缩:VAE 的固有缺陷#

VAE 的 loss 由两项组成,梯度流向不同:

  • 重建损失 ∥x−x^∥2\|x - \hat{x}\|^2:梯度经过 decoder → zz → encoder,训练整条链
  • KL 散度 DKL(q(z∣x)∥N(0,I))D_{KL}(q(z|x) \| \mathcal{N}(0,I)):只涉及 encoder 输出的 μ,σ\mu, \sigma,梯度只给 encoder,把 q(z∣x)q(z|x) 推向先验

正常训练时两项制衡:KL 把 encoder 往先验推,重建损失把 encoder 往"编码有意义信息"的方向拉。

后验坍缩(posterior collapse):当 decoder 太强时(比如自回归 decoder,训练时用 teacher forcing 能看到 ground truth 前缀),重建效果好不好跟 zz 无关,制衡被打破:

  1. 重建损失对 zz 的梯度 ≈ 0 → encoder 收不到来自重建损失的训练信号
  2. 只剩 KL 项在训练 encoder → encoder 直接输出 N(0,I)\mathcal{N}(0,I) 来最小化 KL
  3. 不管输入是什么,q(z∣x)q(z|x) 都坍缩成同一个先验分布 → zz 不携带任何关于 xx 的信息

为什么和连续隐空间有关:连续空间里 μ,σ\mu, \sigma 是连续参数,KL 的梯度每步都能把它们往 μ→0,σ→1\mu \to 0, \sigma \to 1 推一点点,平滑过渡毫无阻力。一旦 decoder 不需要 zz,没有任何结构性障碍阻止坍缩。

3.2 VQ-VAE 的核心思路#

VQ-VAE(van den Oord et al., 2017):用离散 codebook 替代连续分布,从结构上堵死坍缩的退路。

  • 隐空间变成一本"码本"(codebook),包含 KK 个可学习的向量 {e1,e2,…,eK}\{e_1, e_2, \ldots, e_K\}
  • encoder 输出连续向量后,强制量化为最近的 codebook 条目——decoder 只能看到 KK 个离散向量之一

为什么离散化能解决后验坍缩?

  • 没有 KL 项:VQ-VAE 的 loss 里不存在"把 zz 往先验推"的力,坍缩的驱动力被消除
  • 信息必须经过离散瓶颈:decoder 的唯一输入就是选中的 codebook 向量,没有别的路可以绕过。不存在"平滑地忽略 zz"——你选了 code #42 和 code #87,decoder 必须对不同 code 做出不同响应

3.3 结构:编码 → 量化 → 解码#

输入图像 x (256×256×3)
    │
    ▼
[Encoder CNN]
    │
    ▼
连续特征 z_e (32×32×256)     ← 每个空间位置是一个 256 维向量
    │
    ▼ 对每个位置,L2 最近邻查找
[Codebook] K=8192 个 256 维向量
    │
    ▼
量化特征 z_q (32×32×256)     ← 每个位置替换为最近的 codebook 向量
    │
    ▼
[Decoder CNN]
    │
    ▼
重建图像 x̂ (256×256×3)

量化层的核心操作:对特征图的每个空间位置(32×32 = 1024 个),独立找 codebook 中距离最近的向量替换:

ek=arg⁡min⁡j∥ze(x)−ej∥2e_k = \arg\min_{j} \|z_e(x) - e_j\|_2

3.4 梯度问题与 STE#

问题:arg⁡min⁡\arg\min 是离散选择操作,不可微。真实的 ∂zq/∂ze\partial z_q / \partial z_e 几乎处处为 0(zez_e 微小变动不改变选中的 index),梯度链在量化层断开——decoder 的重建损失无法回传到 encoder。

Straight-Through Estimator(STE):前向用量化值 zqz_q,反向时跳过量化层,把 decoder 的梯度直接复制给 encoder。实现方式:

zq=ze+sg[zq−ze]z_q = z_e + \text{sg}[z_q - z_e]

前向:ze+(zq−ze)=zqz_e + (z_q - z_e) = z_q(用量化值)。反向:sg\text{sg} 让 (zq−ze)(z_q - z_e) 梯度为零,PyTorch 认为 zq=ze+常数z_q = z_e + \text{常数},∂zq∂ze=1\frac{\partial z_q}{\partial z_e} = 1

STE 不是数学上精确的梯度(真实梯度是 0,STE 用 1 代替),而是一个"有用的近似"。注意 STE 只影响重建损失的梯度回传——另外两项损失各自有 stop gradient,本来就不需要穿过量化层。

3.5 损失函数#

L=∥x−x^∥22⏟(1) 重建损失+∥sg[ze(x)]−ek∥22⏟(2) codebook 更新+β∥ze(x)−sg[ek]∥22⏟(3) commitment 损失\mathcal{L} = \underbrace{\|x - \hat{x}\|_2^2}_{\text{\text{(1)} 重建损失}} + \underbrace{\|\text{sg}[z_e(x)] - e_k\|_2^2}_{\text{\text{(2)} codebook 更新}} + \underbrace{\beta \|z_e(x) - \text{sg}[e_k]\|_2^2}_{\text{\text{(3)} commitment 损失}}

sg[⋅]\text{sg}[\cdot] = stop gradient(停止梯度传播),β=0.25\beta = 0.25

三项损失,各自更新不同的参数:

损失项 更新谁 作用
① 重建损失 encoder + decoder(通过 STE) 让系统学到有意义的编码和重建
② Codebook 更新 只更新 codebook 向量 eke_k 让 codebook 追着 encoder 输出走(系数 1.0,全力追)
③ Commitment 只更新 encoder 让 encoder 输出靠近 codebook(系数 0.25,轻轻靠)

⚠️ 为什么 ② 和 ③ 要拆成两项?

两项形式相似,但用 stop gradient 分别控制更新方向和力度。不能合并成一个 ∥ze−ek∥2\|z_e - e_k\|^2 不加 stop gradient,因为:encoder 同时接收重建损失和对齐损失的梯度,PyTorch 的学习率是对整个参数组的,没法区分"这个梯度来自哪个 loss"。拆开后用 β=0.25\beta = 0.25 可以选择性缩放——重建损失全力训练 encoder,对齐损失只轻推,防止 encoder 为了靠近 codebook 而牺牲重建质量。

3.6 Codebook 更新机制:从 loss 到 EMA#

上一节的第 ② 项 loss ∥sg[ze]−ek∥2\|\text{sg}[z_e] - e_k\|^2 理论上能更新 codebook,但实际工程里几乎没人用——原 VQ-VAE 论文的附录就推荐了一个更稳的替代:指数滑动平均(EMA)更新。这一节单独把它讲透。

为什么 ② 项 loss 不好用#

  1. 学习率耦合:② 项的梯度走优化器,意味着 codebook 的更新步长被绑死在 encoder/decoder 的学习率上。但这两者最优学习率往往不同——encoder 想小一点稳,codebook 想快速逼近质心。
  2. 更新慢:每步只能挪 η⋅2(ek−ze)\eta \cdot 2(e_k - z_e) 一小步,要等很多 batch 才能爬到合理位置。
  3. 噪声大:单 batch 梯度本身就抖,直接灌到 codebook 上波动明显。
  4. 跟其他 loss 抢梯度:走 autograd 路径意味着 ②③ 两路在 encoder 处合并,互相干扰。

EMA 的核心思路#

抛弃梯度,直接做**"在线版 K-means"**:对每个码 eke_k,把它定义为"所有曾经被分配到它的 zez_e 的(加权)均值"。

但有个工程问题:训练横跨无数 batch,我们不可能存下所有历史 zez_e 然后每步重算均值。解法是用两个滑动平均量,在线维护这个均值:

  • NkN_k:"有效计数"——过去被分配到 eke_k 的样本总数(按时间加权)
  • mkm_k:"有效向量和"——过去被分配到 eke_k 的所有 zez_e 之和(按时间加权)
  • 当前质心 = 加权和 ÷ 加权计数:  ek=mk/Nk\;e_k = m_k / N_k

每一 batch 的更新规则:

Nk(t)=γ⋅Nk(t−1)+(1−γ)⋅nk(t)N_k^{(t)} = \gamma \cdot N_k^{(t-1)} + (1-\gamma) \cdot n_k^{(t)}

mk(t)=γ⋅mk(t−1)+(1−γ)⋅∑i: assigned to kzeim_k^{(t)} = \gamma \cdot m_k^{(t-1)} + (1-\gamma) \cdot \sum_{i:\,\text{assigned to }k} z_e^i

nk(t)n_k^{(t)} = 当前 batch 里被分到 eke_k 的 zez_e 个数;γ≈0.99\gamma \approx 0.99 是衰减率(过去信息的保留比例)

一个具体的数值演算#

假设 γ=0.99\gamma = 0.99,初始 ek=(0,0)e_k = (0, 0), Nk=0N_k = 0, mk=(0,0)m_k = (0, 0)。看连续 3 个 batch:

Batch 1:3 个 zez_e 被分到 eke_k,分别是 (1,0),(2,1),(3,0)(1,0), (2,1), (3,0)

  • nk(1)=3n_k^{(1)} = 3,∑z=(6,1)\sum z = (6, 1)
  • Nk=0.99⋅0+0.01⋅3=0.03N_k = 0.99 \cdot 0 + 0.01 \cdot 3 = \mathbf{0.03}
  • mk=0.99⋅(0,0)+0.01⋅(6,1)=(0.06, 0.01)m_k = 0.99 \cdot (0,0) + 0.01 \cdot (6, 1) = \mathbf{(0.06,\, 0.01)}
  • ek=mk/Nk=(2, 0.33)e_k = m_k / N_k = \mathbf{(2,\, 0.33)}  ← 正好是这一 batch 的均值

Batch 2:0 个 zez_e 被分到 eke_k(本步是死码)

  • nk(2)=0n_k^{(2)} = 0
  • Nk=0.99⋅0.03=0.0297N_k = 0.99 \cdot 0.03 = \mathbf{0.0297}  ← 慢慢衰减
  • mk=0.99⋅(0.06,0.01)=(0.0594, 0.0099)m_k = 0.99 \cdot (0.06, 0.01) = \mathbf{(0.0594,\, 0.0099)}  ← 也慢慢衰减
  • ek=(0.0594,0.0099)/0.0297=(2, 0.33)e_k = (0.0594, 0.0099) / 0.0297 = \mathbf{(2,\, 0.33)}  ← 原地不动!

Batch 3:2 个 zez_e 被分到 eke_k:(0,0),(4,2)(0,0), (4,2)

  • nk(3)=2n_k^{(3)} = 2,∑z=(4,2)\sum z = (4, 2)
  • Nk=0.99⋅0.0297+0.01⋅2=0.0494N_k = 0.99 \cdot 0.0297 + 0.01 \cdot 2 = \mathbf{0.0494}
  • mk=0.99⋅(0.0594,0.0099)+0.01⋅(4,2)=(0.0988, 0.0298)m_k = 0.99 \cdot (0.0594, 0.0099) + 0.01 \cdot (4, 2) = \mathbf{(0.0988,\, 0.0298)}
  • ek=mk/Nk≈(2.0, 0.60)e_k = m_k / N_k \approx \mathbf{(2.0,\, 0.60)}  ← 朝 batch 3 均值 (2,1)(2, 1) 移了一点,但因为历史里还有 batch 1 的影响,没完全跟过去

三个关键观察:

  1. 活码更新极快:第一步就直接到了 batch 1 的均值,完全不需要"梯度慢慢爬"——这是 EMA 比 loss-② 快几个数量级的根本原因。
  2. 无人选中的码原地不动:Batch 2 里 NkN_k 和 mkm_k 都按同一个比例 γ\gamma 衰减,比值 ek=mk/Nke_k = m_k / N_k 数值上保持不变。这正是死码的根源——EMA 不会让死码"漂移",但也根本不会让它复活,它只会原地等待下一次有数据落过来。
  3. 历史和新数据自动加权:Batch 3 收到新数据后没有完全跳过去,而是按 NkN_k 自动混合了历史均值和新均值。这是 EMA 比"硬重置成 batch 均值"稳定的原因——单一异常 batch 不会把 codebook 抖飞。

为什么要拆成 NkN_k 和 mkm_k 两个量?#

也许你会问:为什么不直接 ek←γ ek+(1−γ) zˉkbatche_k \leftarrow \gamma\, e_k + (1-\gamma)\, \bar{z}_k^{\text{batch}}(其中 zˉkbatch\bar{z}_k^{\text{batch}} 是当前 batch 里被分到 eke_k 的 zez_e 的均值)?

因为这样所有 batch 的权重相同。但实际上一个 batch 里可能有 100 个 zez_e 分到 eke_k,另一个 batch 里只有 1 个——前者应该对质心位置有更大影响。NkN_k / mkm_k 拆开维护可以自动按"被分配次数"加权,等价于"把所有历史 zez_e 拿过来按时间衰减后求加权均值",这是单一 EMA eke_k 做不到的。

EMA 解决了什么、没解决什么#

解决 没解决
EMA 更新 学习率耦合、更新慢、噪声大、跟其他 loss 抢梯度 死码问题——NkN_k 和 mkm_k 同步衰减意味着 dead code 的位置原地不动,但也不会自愈。一个从一开始就远离所有 zez_e 的码,永远等不到第一次被选中,永远是死的

这就是为什么实际工程里的标准配方是 EMA + Code Reset:EMA 负责把活码更新得又快又稳,Reset 负责消灭 dead code。下一节专门处理后者。

3.7 Codebook 死码问题#

codebook 有 K=8192K=8192 个向量,但实际常只有 10-20% 被使用。初始化时离 encoder 输出较远的向量永远不被选中 → 没有更新 → 永远不被选中,恶性循环。上一节我们看到 EMA 让活码更新得更快更稳,但对 dead code 无能为力——EMA 的更新规则要求 nk>0n_k > 0,从未被选中的码 mkm_k 不增、NkN_k 衰减,质心位置原地冻结。本节看几种真正能让 dead code 复活或避免它产生的方案。

解决方案#

方案 做法 优点 缺点
Code Reset 监控每个码的 NkN_k(EMA 已经在维护),发现 NkN_k 长期接近 0 的死码,直接把这个 eke_k 重新初始化到当前某个被频繁选中的 zez_e 附近 真·死码复活术;和 EMA 天然兼容(共用 NkN_k 做监控信号);工程实现简单 需要额外监控逻辑;reset 频率/阈值是超参
Codebook Splitting 初始少量 code(如 KK=64),训练中对每个码加减小扰动一分为二,逐步扩展到目标数量 逐步扩展码本有助于利用率;不能保证每个码都被使用 训练流程更复杂;codebook 大小不是固定的
Gumbel-Softmax 用可微的软加权替代硬选择(训练时加权混合所有 code,推理时 argmax 选一个) 软分配提供更多梯度通路,但仍可能发生利用率不均或塌缩 训练时不是真正离散,训练/推理行为有 gap
FSQ 不用 codebook,直接对每个维度标量量化(如 {−1,0,1}\{-1,0,1\}),dd 维 = 3d3^d 种隐式组合 没有可学习码向量的死码更新问题,也无需 codebook loss;组合码利用率仍需观测 表达力受限于量化级数;隐式 codebook 不可学习、不可解释

3.8 VQ-VAE 在生成模型中的位置#

  • DALL-E(2021):用 VQ-VAE 的 codebook 作为"视觉词汇表"(8192 个视觉 token),把图像变成 token 序列,用自回归 Transformer 在文本 + 视觉 token 上联合建模
  • 视频生成:VideoGPT 等用 3D VQ-VAE 把视频编码为离散 token 序列,再用 Transformer 自回归生成
  • 现代趋势:Stable Diffusion 把离散 codebook 换回了连续的 KL 正则化(KL-VAE),这里讨论的是连续 latent 上的 diffusion / flow matching;也存在离散扩散模型。但离散 tokenization 在语言-视觉统一建模中仍有生命力(Chameleon、Emu 等)

VQ-VAE 与 KL-VAE 的关系

VQ-VAE 本身不是扩散模型,但它是 Latent Diffusion(Stable Diffusion)的重要前驱。SD 的 autoencoder 正是从 VQ-VAE 的设计演化而来——只是把离散 codebook 换回了连续的 KL 正则化,LDM 也可以在 VQ 自编码器的连续嵌入表示上训练,不能把 KL 当成唯一选择。理解 VQ-VAE 有助于理解为什么 Latent Diffusion 要先做图像压缩、以及 codebook 设计的各种权衡。两条路线本质上是一个权衡:连续 + 平滑 vs. 离散 + 可 token 化。

4. 理解检查#

4.1 QSD 的 VAE 和普通 VAE 有什么区别?#

参考答案

结构上区别不大——都是编码器输出 μ,log⁡σ2\mu, \log\sigma^2,重参数化采样 zz,解码器重建。核心区别在训练损失:普通 VAE 只用重建+KL,SD 的 VAE 额外加了感知损失(LPIPS)和对抗损失(patch discriminator)。这让 SD VAE 的重建质量远超普通 VAE,但训练复杂度也更高。另外 SD VAE 的编解码器用的是更深的 ResNet 架构(非简单 MLP),且对 2D 图像有针对性设计(多级下采样/上采样 + skip connection)。

4.2 QKL 正则对 Latent Diffusion 为什么重要?#

参考答案

KL 正则约束编码器后验的尺度和分布,帮助权衡重建与潜空间规整性;但 latent diffusion 不要求干净 latent z0z_0 已服从标准高斯。充分加噪的终端 zTz_T 才需要接近采样先验,反向模型学习的是实际 latent 数据分布。LDM 原论文同时研究 KL 与 VQ 正则化的自编码器,因此“没有 KL 就不能做 latent diffusion”不成立。

4.3 Q为什么 VAE 重建总是比原图模糊?#

参考答案

两个原因:(1) 信息瓶颈——512×512×3=786K512\times512\times3=786K 维压缩到 64×64×4=16K64\times64\times4=16K 维,48× 压缩必然丢失信息,主要丢的是高频细节;(2) KL 正则的代价——KL 项迫使编码器不要"过度编码"(否则隐空间不连续),某种程度上限制了编码器的信息承载量。这就是为什么 SD 3 增加到 16 通道——更多通道 = 更多信息保留 = 更清晰重建。

**面试总结:**SD 的 VAE(KL-VAE)= 标准 VAE + 感知损失 + 对抗损失 + 更深的 ResNet 架构。它是独立预训练、扩散训练时冻结的。隐空间的质量(连续性、信息保留)直接决定了最终生成图像的质量上限。

原始资料:Auto-Encoding Variational Bayes、VQ-VAE、Latent Diffusion。

延伸阅读#

相关阅读路线

理解生成模型 ↗