2. 扩散模型基础:前向扩散过程、逆向去噪过程、马尔可夫链、扩散核与噪声调度
好,咱们正式进入扩散模型的核心。说实话,我刚接触这个概念时也觉得有点绕,但一旦理解了它的根本逻辑,你会发现它其实非常优雅。
扩散模型的核心思想,说白了就是三步:先往数据里慢慢加噪声,直到它变成纯噪声;然后学一个“逆过程”,一步步把噪声变回干净的数据。 就这么简单。但每一步背后都有扎实的数学支撑,我们一个个来看。
2.1 前向扩散过程:加噪的艺术
前向过程,也叫扩散过程。它定义了一个马尔可夫链,逐步向原始数据 x₀ 中添加高斯噪声,经过 T 步后,数据会变成各向同性的高斯噪声,也就是 xT ~ N(0, I)。
为什么必须是马尔可夫链?这里有个关键点:每一步的加噪只依赖当前状态,跟之前的历史无关。 这极大简化了问题。我个人习惯把这个过程想象成“烤焦面包”——每烤一秒,面包就更黑一点,直到变成一块黑炭。你没法从黑炭状态直接看出它本来是白面包还是全麦面包。
数学上,前向过程定义为:
q(x₁, x₂, ..., xT | x₀) = ∏t=1T q(xt | xt-1)
其中,单步转移核:
q(xt | xt-1) = N(xt ; √(1-βt) · xt-1, βtI )
这里的 βt 就是噪声调度参数,控制每一步加多少噪声。嗯,这里要注意:βt 通常很小,比如 0.0001 到 0.02 之间,确保变化是渐进的。
关键性质:得益于高斯分布的可叠加性,我们可以直接从 x₀ 一步采样到任意 xt,而不需要迭代整个链。
即:q(xt | x₀) = N(xt ; √(ᾱt) · x₀, (1-ᾱt)I )
其中 αt = 1-βt,ᾱt = ∏i=1t αi。
我记得在最初实现时,很多人会写一个循环去生成每一步的 xt,其实没必要。直接用这个封闭公式就能高效采样出任意时间步的噪声数据,训练时效率高很多。
2.2 逆向去噪过程:从噪声中恢复数据
逆向过程是扩散模型的“灵魂”。如果我们知道前向过程的逆分布 q(xt-1 | xt),就能从纯噪声 xT 一步步还原出原始数据。但问题是——这个逆分布我们算不出来。它依赖于整个数据分布。
怎么办呢?我们用一个神经网络去近似这个逆过程。记作 pθ(xt-1 | xt)。这也是一个高斯分布,但均值和方差由神经网络预测。
pθ(xt-1 | xt) = N(xt-1 ; μθ(xt, t), σt²I )
其中,μθ(xt, t) 是需要学习的值,σt² 通常固定为 βt 或 (1-ᾱt)/(1-ᾱt-1)·βt。
这里有个经典的“避坑”经验:直接预测 μθ 效果不好。 我曾经试过,模型训练半天就是不收敛。后来发现,更好的做法是让网络去预测噪声 ε,而不是直接预测图像本身。也就是:
μθ(xt, t) = (1/√αt) · ( xt - βt/(√(1-ᾱt)) · εθ(xt, t) )
采样过程重新参数化后:
xt-1 = (1/√αt) · ( xt - βt/(√(1-ᾱt)) · εθ(xt, t) ) + σt · z, 其中 z ~ N(0, I)
为什么这么做?你想想看,预测噪声本质上是一个回归问题,目标值分布稳定;而预测原始图像则涉及大量低频和高频信息,学习难度大很多。
2.3 马尔可夫链与扩散核
扩散模型中的“链”就是我们前面提到的马尔可夫链。但这里有个概念容易混淆:扩散核 (Diffusion Kernel) 和 转移核 (Transition Kernel) 是两码事。
- 转移核:定义单步转移
q(xt | xt-1),即从t-1到t的分布。 - 扩散核:定义从初始状态
x₀到任意xt的分布q(xt | x₀)。
扩散核的作用在于:训练时我们不需要模拟整个链来生成 xt,而是直接从 x₀ 一步跳到 xt。我把它叫做“时光跳跃”——跳过中间所有步骤,直接拿到加了指定量噪声的结果。
实用技巧:在你的训练代码中,每次迭代都应该这样做:
- 从数据集中随机采样一个
x₀。 - 随机采样时间步
t(通常均匀采样自1..T)。 - 采样噪声
ε ~ N(0, I)。 - 用扩散核直接计算
xt = √(ᾱt) · x₀ + √(1-ᾱt) · ε。 - 网络预测噪声
εθ(xt, t),与真实噪声ε计算 MSE 损失。
这样训练效率大幅提升,因为不需要迭代 t 步。
2.4 噪声调度策略
噪声调度 {βt} 或 {ᾱt} 的选择直接影响模型性能。常用的调度策略有:
| 调度名称 | 定义 | 特点 |
|---|---|---|
| 线性调度 (Linear) | βt 从 0.0001 线性增长到 0.02 | DDPM 原文方案,简单稳定 |
| 余弦调度 (Cosine) | ᾱt = cos²( (t/T + s) / (1+s) · π/2 ) | 加噪更平滑,末期仍有信息留存 |
| 平方根调度 (Sqrt) | ᾱt = 1 - √(t/T) 的变形 | 强调前期的扩散速度 |
我个人更偏爱余弦调度。为什么呢?我曾经用线性调度训练过分子生成模型,发现生成的小分子有很多无效结构。后来换成余弦调度,加噪过程更温和,在后期步骤中保留了更多结构信息,生成的化学有效性提升了不少。
下面是余弦调度的PyTorch实现示例:
def cosine_beta_schedule(timesteps, s=0.008):
"""
生成余弦噪声调度
:param timesteps: 扩散步数 T
:param s: 平滑参数,通常 0.008
:return: β 序列, 长度 timesteps
"""
steps = timesteps + 1
t = torch.linspace(0, timesteps, steps, dtype=torch.float64)
alphas_cumprod = torch.cos(
(t / timesteps + s) / (1 + s) * torch.pi * 0.5
) ** 2
alphas_cumprod = alphas_cumprod / alphas_cumprod[0]
betas = 1 - (alphas_cumprod[1:] / alphas_cumprod[:-1])
return torch.clamp(betas, max=0.999)
注意:分子生成与图像生成不同。分子的有效结构非常脆弱,稍微过度的噪声就可能破坏化学键信息。建议将余弦调度的最大 β 限制在 0.5 以内,而不是像图像任务那样可以到 0.999。
2.5 损失函数与训练机制
训练扩散模型其实就是最小化一个简单的MSE损失。我记得第一次跑通训练时,看到loss曲线稳定下降,心里还挺爽的。
简化版损失函数:
Lsimple(θ) = Et, x₀, ε[ ||ε - εθ(√ᾱt·x₀ + √(1-ᾱt)·ε, t)||² ]
这个损失函数的含义是:对每个时间步 t,我们都让网络学会预测当前加进去的那个噪声。所有时间步的权重相同——这也是“simple”名字的由来。虽然简单,但它就是DDPM实际用的目标函数。
经验分享:实际训练时,我通常使用 AdamW 优化器,学习率设为 2e-4。对于分子生成,batch size 在 128-256 之间效果不错。另外,建议在训练过程中对损失按时间步做无偏估计,这样每个 t 都能被公平训练到。
说到这里,你应该已经掌握了扩散模型最核心的四块拼图:前向过程、逆向过程、马尔可夫链和相关公式、噪声调度策略。这些是后面所有变体(DDIM、Score-based、Latent Diffusion)的基石。