
前面我们已经见过几类生成模型。GAN 像是一场对抗游戏,让生成器一步生成出逼真的样本;AutoEncoder 在学习压缩与重建;VAE 则开始显式地建模概率分布,可以从潜变量中采样。而到了 DDPM,思路又发生了变化:
它不是一步把图像画出来,而是从一团噪声开始,一点一点把图像“洗”出来。
这件事第一次听起来会有点奇怪。去噪,不就是把一张脏图变干净吗?这和生成有什么关系?为什么从纯噪声开始,反复去噪,最后就能得到一张真实图像?
DDPM (Ho et al. 2020),也就是 Denoising Diffusion Probabilistic Model,这个名字其实已经把它最重要的三个部分写出来了:
- Diffusion:先让真实数据逐渐扩散成噪声;
- Denoising:再学习如何一步一步把噪声还原回来;
- Probabilistic:整个过程不是一条固定的确定性变换,而是由一系列概率分布连接起来的随机过程。
这一节我们先不急着完整推导 DDPM,而是先建立它的整体地图。后面的 14.2、14.3 和 14.4 会分别把前向加噪、反向去噪、训练目标和采样过程拆开。这里真正需要先想清楚的是:
为什么一个生成问题,可以被重新写成一个去噪问题?
import math
import random
import dnnlpy
import matplotlib.pyplot as plt
import torch
import torchvision.datasets as datasets
import torchvision.transforms.v2 as v2
dnnlpy.set_matplotlib_format('highdpi')
print('PyTorch version:', torch.__version__)
PyTorch version: 2.13.0+cpu
14.1.1 生成:加噪的逆过程
我们先从一个非常简单的过程开始。
假设手里有一张真实图片 \(x_0\)。如果不断往图像里加入高斯噪声,那么它会逐渐失去原来的结构:刚开始可能只是边缘有些模糊,继续加噪之后局部纹理开始消失,再往后连整体轮廓也会越来越难辨认。当噪声足够强时,最后得到的 \(x_T\) 就可以非常接近一个标准高斯随机变量。
也就是说,我们可以构造这样一条路径:
\[
x_0 \rightarrow x_1 \rightarrow x_2 \rightarrow \cdots \rightarrow x_T
\]
其中,左边是真实数据,右边是一个非常简单、非常熟悉的高斯分布。
这件事本身并不难。我们甚至不需要训练神经网络,只要人为规定每一步加入多少噪声,就可以把任意图像逐渐破坏掉。真正有意思的问题是:
既然真实图像可以沿着这条路径慢慢变成噪声,那么能不能把这条路反过来走?
如果可以,我们就不需要直接学习一个非常困难的
\[
\text{noise} \rightarrow \text{image}
\]
映射,而是可以学习很多个更小的变化:
\[
\text{noise}
\rightarrow \text{slightly less noisy}
\rightarrow \text{slightly less noisy}
\rightarrow \cdots
\rightarrow \text{image}
\]
下面先做一个直觉实验。为了让最后一步真的接近纯高斯噪声,我们不直接写 x0 + sigma * noise,而是让图像信号和噪声按照不同权重混合:
\[
x_t = \sqrt{s_t}\,x_0 + \sqrt{1-s_t}\,\epsilon, \qquad \epsilon\sim\mathcal N(0,I)
\]
这里的 \(s_t\) 我们暂时只把它理解成还剩多少原图信号即可。\(s_t=1\) 时完全是原图,\(s_t=0\) 时完全是高斯噪声。后面我们会看到,DDPM 里的 \(\bar\alpha_t\) 就扮演着类似的角色。
root = dnnlpy.get_data_root()
transform = v2.Compose([v2.ToImage(), v2.ToDtype(torch.float32, scale=True)])
ds = datasets.MNIST(root, train=False, download=True, transform=transform)
idx = random.randrange(len(ds))
x0 = ds[idx][0].squeeze(0)
x0 = x0 * 2.0 - 1.0 # [0, 1] -> [-1, 1]
signal_levels = [1.0, 0.8, 0.5, 0.25, 0.1, 0.0]
fig = plt.figure(1, figsize=(8, 2))
axes = fig.subplots(1, len(signal_levels))
for ax, level in zip(axes, signal_levels, strict=True):
noise = torch.randn_like(x0)
xt = math.sqrt(level) * x0 + math.sqrt(1.0 - level) * noise
ax.imshow(xt, cmap='gray', vmin=-1.0, vmax=1.0)
ax.axis('off')
ax.set_title(f'Signal Level: {level:.2f}', fontsize=10)
fig.tight_layout(pad=0.5)
plt.show()
这里最重要的不是某一张中间图片长什么样,而是整个变化趋势。随着信号比例降低,原图信息越来越少,噪声则越来越强;当 \(s_t=0\) 时,得到的就是一次高斯噪声采样。
因此,DDPM 并不是假设纯噪声里还藏着一张我们肉眼能够辨认出来的图像。到了足够大的时间步,单个 \(x_T\) 本身几乎已经看不出对应的 \(x_0\)。模型之所以还能从噪声生成图像,不是因为它在噪声中找到原图,而是因为训练之后,它学会了什么样的局部修正会让样本逐渐回到真实数据分布附近。
这个区别很重要。扩散模型做的不是图像恢复意义上的“把某张已经被坏掉的图片找回来”,而是在学习一个生成过程。生成时的初始噪声本来就没有对应某一张真实训练图片,最终生成出来的样本也是模型沿着学到的反向过程逐渐构造出来的。
14.1.2 为什么一步一步容易,而一步到位难?
假设现在让模型完成两个任务。
任务 A:一步到位生成一张猫的图片。
输入是一团随机噪声,输出直接是一张完整、自然、结构合理、细节丰富的猫图。模型一次就要决定大量彼此相关的因素:猫的姿态是什么?脸朝哪里?身体和四肢之间是什么几何关系?背景是什么?毛发纹理怎么画?光照和阴影如何保持一致?局部细节又如何服从整体结构?
从概率分布的角度看,这相当于让模型直接把一个简单的高斯分布变成非常复杂的数据分布。这个映射并不是不可能学习,GAN 就在做类似的事情,但整个变化非常大。
任务 B:给一张已经有一些结构、只是被噪声扰动的图,让模型只修正一点点。
这个任务通常更局部。模型不需要在一次计算里决定最终图片的所有细节,而只需要判断:在当前噪声水平下,这个样本应该往什么方向移动一点,才会更像真实数据?
如果把生成过程想成从山脚走到山顶,一步生成就像要求模型直接从起点跳到终点;DDPM 则更像先把路线切成很多小段,每一次只决定下一小步怎么走。单独看任何一步,变化都没有那么剧烈。
这里还有一个更深一点的原因。假设当前的 \(x_t\) 只比 \(x_{t-1}\) 多了一点噪声,那么两个相邻分布之间的差异也比较小。DDPM 不需要让神经网络一次学习两个差异巨大的分布之间的完整映射,而是学习很多个相邻噪声水平之间的反向转移。
于是,一个困难的全局生成问题被拆成了很多个相对简单的局部去噪问题:
\[
p_\theta(x_{t-1}\mid x_t)
\]
它表达的是:
已经知道当前样本 \(x_t\) 时,稍微更干净一点的 \(x_{t-1}\) 应该服从什么分布?
这里需要注意,反向过程严格来说不是简单地从图片里减掉网络预测的噪声。网络预测的结果会被放进一个由噪声调度决定的反向更新公式中,而且原始 DDPM 的采样过程本身通常还包含随机项。因此,更准确的说法是:网络提供了反向转移所需要的信息,而采样公式再根据这些信息从 \(x_t\) 得到 \(x_{t-1}\)。具体形式我们会在 14.3 里展开。
所以,diffusion model 真正利用的是一种逐步生成(iterative generation)的思想:复杂任务不是消失了,而是被分摊到了很多时间步上。当然,代价也很直接:如果反向过程有 \(T\) 步,那么生成一张图片就可能需要调用网络很多次。DDPM 训练稳定,但原始采样速度较慢,这也是后面 DDIM、蒸馏以及各种快速采样方法不断出现的重要原因。
14.1.3 DDPM 的核心思路:先定义破坏,再学习恢复
DDPM 的整体结构可以分成两个方向完全相反的过程。
第一部分是前向加噪过程。
我们从真实数据 \(x_0\sim p_{\text{data}}(x)\) 出发,按照预先规定好的规则逐步加入高斯噪声:
\[
x_0 \rightarrow x_1 \rightarrow x_2 \rightarrow \cdots \rightarrow x_T
\]
这个过程通常记作 \(q\)。它不是神经网络,也没有需要训练的参数,而是我们人为定义好的概率过程。每一步只依赖前一步,因此可以写成:
\[
q(x_{1:T}\mid x_0) = \prod_{t=1}^{T}q(x_t\mid x_{t-1})
\]
这就是一个马尔可夫链。当前的 \(x_t\) 如何产生,只需要知道 \(x_{t-1}\),不需要重新访问更早的 \(x_{t-2},x_{t-3},\ldots\)。
随着 \(t\) 增大,数据中的结构被越来越多的高斯噪声覆盖。如果噪声调度设计得合适,那么最终的边缘分布 \(q(x_T)\) 会接近一个简单的标准高斯分布:
\[
q(x_T)\approx\mathcal N(0,I)
\]
为什么要特意把终点设计成高斯分布?因为高斯分布非常容易采样。生成时我们不可能先从真实数据分布里取一个 \(x_0\),否则就失去生成的意义。但我们可以随时生成一个随机张量:
xT = torch.randn(1, 28, 28)
所以,前向过程做了一件很关键的事:它在人为构造一条从复杂但未知的数据分布到简单且容易采样的高斯分布的道路。
第二部分是反向去噪过程。
现在我们想把这条路反过来走:
\[
x_T \rightarrow x_{T-1} \rightarrow x_{T-2} \rightarrow \cdots \rightarrow x_0
\]
如果真实的反向条件分布 \(q(x_{t-1}\mid x_t)\) 都已知,那么理论上从 \(x_T\sim\mathcal N(0,I)\) 一路反向采样,就可以回到数据分布。但是问题在于,这个真实反向过程与未知的数据分布有关,我们不能直接拿到。
于是 DDPM 用一个带参数的模型去近似它:
\[
p_\theta(x_{t-1}\mid x_t)
\]
把所有反向步骤连起来,就得到模型定义的生成过程:
\[
p_\theta(x_{0:T}) = p(x_T) \prod_{t=1}^{T}p_\theta(x_{t-1}\mid x_t)
\]
其中起点通常选择:
\[
p(x_T)=\mathcal N(0,I)
\]
从这个角度看,DDPM 的生成逻辑就很清楚了:
- 我们知道怎么从高斯分布采样;
- 我们训练模型近似每一个反向转移;
- 把这些反向转移串起来,就得到一条从高斯噪声回到数据分布的生成路径。
这也是为什么扩散模型不应该只理解成一个很强的图片去噪器。单独看某一个时间步,它确实在做去噪;但把所有时间步连接起来之后,它实际上定义了一个完整的概率生成模型。
还有一点很容易被忽略:虽然前向过程画出来是一条很长的链,但训练时并不需要真的先算出 \(x_1\),再算 \(x_2\),一直走到随机选中的 \(x_t\)。DDPM 的前向过程有一个非常方便的闭式形式,可以直接由 \(x_0\) 一步采样得到任意 \(x_t\):
\[
x_t = \sqrt{\bar\alpha_t}\,x_0 + \sqrt{1-\bar\alpha_t}\,\epsilon,
\qquad \epsilon\sim\mathcal N(0,I)
\]
所以在训练阶段,我们通常只需要随机抽一个时间步 \(t\),直接构造这个噪声水平下的 \(x_t\)。前向过程虽然概念上有 \(T\) 步,但一次训练样本并不需要真的执行 \(T\) 次加噪。这一点使 DDPM 的训练可以像普通监督学习一样高效地做 minibatch 并行。
下一节我们会专门推导这条公式从哪里来,以及 \(\alpha_t\)、\(\bar\alpha_t\) 和常见的 \(\beta_t\) 到底是什么关系。
14.1.4 DDPM 的训练目标:在任意噪声水平下猜噪声
现在来看训练时神经网络到底学什么。
利用刚才的闭式形式,我们先从真实数据集中取一张 \(x_0\),随机选择一个时间步 \(t\),再采样一份高斯噪声 \(\epsilon\):
\[
\epsilon\sim\mathcal N(0,I)
\]
然后直接构造:
\[
x_t = \sqrt{\bar\alpha_t}\,x_0 + \sqrt{1-\bar\alpha_t}\,\epsilon
\]
现在,\(x_t\) 是我们故意制造出来的带噪样本,而且因为噪声就是我们自己采样的,所以真实的 \(\epsilon\) 也是已知的。这意味着 DDPM 不需要人工给每张图片标注里面有哪些噪声,训练目标可以自动生成。
最经典的 DDPM 参数化让网络预测这份噪声:
\[
\epsilon_\theta(x_t,t)\approx\epsilon
\]
于是可以使用一个非常普通的均方误差:
\[
\mathcal L_{\text{simple}} = \mathbb E_{x_0,t,\epsilon}
\left[ \left\| \epsilon- \epsilon_\theta(x_t,t) \right\|_2^2 \right]
\]
这个目标看上去甚至有点不像训练生成模型。输入是一张带噪图片,target 是一张随机噪声,最后做 MSE。真正让它变成生成模型的,是这些去噪任务覆盖了从几乎干净到几乎纯噪声的所有时间步。
这里的时间步 \(t\) 不是可有可无的输入。考虑两个极端情况:
- 当 \(t\) 很小时,\(x_t\) 仍然很接近真实图片,模型只需要做很轻微的修正;
- 当 \(t\) 很大时,\(x_t\) 可能已经被强噪声覆盖,模型面对的是完全不同难度的任务。
如果只给网络 \(x_t\),却不告诉它现在处于哪个噪声水平,相当于让同一个网络在不知道题目难度的情况下同时完成很多种不同的去噪任务。因此实际模型会把时间步 \(t\) 编码成一个向量,再和图像特征一起输入网络。后面介绍 DDPM 网络结构时,我们会具体看到 timestep embedding 是怎样进入 U-Net 的。
从训练循环的角度看,一次 DDPM 训练可以概括成:
\[
x_0
\xrightarrow{\text{sample }t,\epsilon}
x_t
\xrightarrow{\epsilon_\theta(x_t,t)}
\hat\epsilon
\xrightarrow{\text{MSE with }\epsilon}
\mathcal L
\]
伪代码几乎就是:
x0 = sample_real_data()
t = sample_timestep()
eps = sample_gaussian_noise()
xt = add_noise(x0, t, eps)
eps_pred = model(xt, t)
loss = mse(eps_pred, eps)
这里还有一个很重要的训练与生成不对称性。训练时,我们可以随机抽任意一个 \(t\),直接构造 \(x_t\),所以不同时间步可以并行地出现在同一个 batch 中;生成时却必须从 \(x_T\) 开始,按照 \(T,T-1,\ldots,1\) 的顺序逐步得到下一个状态。
表 14.1.4 DDPM 的训练与生成
| 训练 |
真实样本 \(x_0\) |
随机采样 \(t\) |
根据 \((x_t,t)\) 预测噪声 |
不需要,可以直接构造任意 \(x_t\) |
| 生成 |
高斯噪声 \(x_T\) |
从 \(T\) 走到 \(1\) |
为每一步反向采样提供预测 |
需要,后一步依赖前一步结果 |
这也解释了 diffusion model 一个很典型的特点:训练可以高度并行,但原始采样是串行的。每一步 \(x_{t-1}\) 都依赖刚刚得到的 \(x_t\),因此不能像训练那样把所有时间步一次性并行算完。
最后再强调一个记号上的问题。本章主要按照原始 DDPM 最常见的 \(\epsilon\)-prediction 来讲,也就是让网络预测噪声。但“扩散模型一定预测噪声”并不是一个普遍定律。模型也可能直接预测 \(x_0\),或者预测由 \(x_0\) 和 \(\epsilon\) 组合得到的 \(v\)。但对现在这一节来说,我们先抓住最经典的 \(\epsilon\)-prediction,就足够建立 DDPM 的基本框架。
14.1.5 本章小结
到这里,我们还没有推导 DDPM 的完整数学细节,但已经可以把它的整体逻辑连起来了。
DDPM 首先人为定义一个前向扩散过程。真实样本 \(x_0\) 会随着时间步逐渐加入高斯噪声,最终让 \(x_T\) 接近一个简单的标准高斯分布:
\[
x_0 \xrightarrow{q} \cdots \xrightarrow{q} x_T\approx\mathcal N(0,I)
\]
这个过程不需要学习。真正需要神经网络学习的是反方向:给定某个噪声水平下的 \(x_t\),估计反向转移所需要的信息,让样本能够逐渐走向更干净、更符合真实数据分布的区域:
\[
x_T \xrightarrow{p_\theta} \cdots \xrightarrow{p_\theta} x_0
\]
在最经典的 DDPM 参数化中,网络通过预测加入到 \(x_t\) 中的高斯噪声来完成这个任务:
\[
\epsilon_\theta(x_t,t)\approx\epsilon
\]
于是,一个看起来非常复杂的图像生成问题,最后被转化成了大量不同噪声水平下的去噪学习问题。
如果只记住这一节最重要的逻辑,可以把 DDPM 压缩成下面四步:
- 把真实数据逐渐加噪,连接到一个容易采样的高斯分布;
- 让神经网络学习每个噪声水平下的反向修正;
- 训练时随机抽时间步,直接构造 \(x_t\) 并预测噪声;
- 生成时从纯高斯噪声开始,按时间顺序逐步反向采样。
这里最关键的转变是:DDPM 不再要求模型一次学会生成一张图,而是要求它在任何噪声水平下,都知道下一小步应该怎样往数据分布靠近。大量这样的小步骤连接起来,就构成了完整的生成过程。
不过,现在还有几个问题没有解决。我们一直说“逐渐加入高斯噪声”,但每一步到底怎么加?\(\beta_t\)、\(\alpha_t\) 和 \(\bar\alpha_t\) 分别是什么?为什么可以不经过 \(x_1,x_2,\ldots,x_{t-1}\),直接从 \(x_0\) 得到 \(x_t\)?
下一节我们就从前向过程开始,把这些问题正式写成概率分布和公式。理解了前向加噪之后,反向去噪为什么能够写成高斯分布,以及最后的噪声预测目标从哪里来,就会自然得多。