很多刚接触Diffusion Model的人,看到的第一张图通常是这样:一排图片从清晰逐渐变模糊,最后变成一堆纯噪点;另一排从噪点开始,一步步恢复成一张高清图片。这张图太直观了,以至于绝大多数人忽略了背后一堆真正关键的问题:为什么每一步加的必须是高斯噪声?为什么网络预测的是噪声本身,而不是直接预测图像?为什么一行简单的MSE Loss,最后能训练出比GAN还稳定的生成效果?
我当初第一次跑通DDPM的代码时,对着loss函数愣了半天:整个训练循环不超过40行,既没有对抗损失也没有复杂的正则项,就是一个再普通不过的MSE。但它就是能生成出高质量图片,而且训练过程稳定得让人不太敢相信。这篇博文我不打算做科普式复读,而是从数学推导到代码实现,把DDPM这条技术线的每个关键选择讲透,最后再分享一些实际训练和采样中踩过的坑。适合两类人看:一类是准备在Stable Diffusion系项目里做定制开发或微调的人,另一类是刚读完论文但代码写不出来、想彻底搞清楚原理的读者。
1. 扩散模型到底在解决什么问题:先理解生成任务为什么难
1.1 为什么"一步直接生成"的路都走得不太顺
图像生成这个任务,用大白话说就是:从一堆训练图像里学到一个分布,然后从这个分布里采样出新图像。难点在于,图像分布处在一个非常高的维度空间里——一张256×256的RGB图片是196608维,而真实图片在这个高维空间里只占一个极小极小的低维流形。你让网络直接学习这个流形分布,本质上是在让它在几乎全空的巨大空间里找到一条小路,这就导致大多数直接拟合的方法都会遇到同一个问题:要么模型崩溃,要么生成的图千篇一律,要么始终模模糊糊。
早期的GAN用对抗博弈的思路绕开了显式密度估计,生成器和判别器互相博弈,生成器学到的分布越来越接近真实分布,这条路在当时效果很好,但训练非常脆弱,动不动就出现模式坍缩——生成器发现只要反复生成几张能骗过判别器的图片就能把loss压低,于是多样性全部丢光。VAE换了个思路,用隐变量z定义一个概率生成过程,训练时最大化变分下界,生成质量偏模糊,原因是ELBO里重构项和先验项之间存在难以调和的冲突,网络倾向于把隐变量空间里的点映射到平均的、平滑的图像上,细节就丢了。
所以无论是GAN还是VAE,本质上都试图用"一步到位"的方式解决一个本质上需要多步精修的任务。这和让一个刚学画画的人直接默写一幅油画一样,实在太难了。
1.2 从"做旧"到"修复":DDPM的核心思想
扩散模型最反直觉的一点是:训练网络之前,你先把所有训练图片"做旧"到面目全非。这不是比喻,而是字面意义上的操作——对一张干净图片反复叠加高斯噪声,加了几百步之后,图片就变成了一个接近标准正态分布的纯噪声图。
然后再训练一个网络,学会逆转这个过程:给它一张被噪声污染过的图,让它一步步去掉噪声,每一步恢复一点点细节。整个过程和"文物修复师"的工作逻辑是一模一样的:你先把一件新文物人为做旧到看不出本来面目,然后再训练一个人,让他学会从做旧的各种中间状态里一步步还原。听起来像个玩笑,但核心洞察就在这里——前向加噪是一个有明确数学定义的过程,每一步加多少噪声、图像会变成什么分布,全部可以精确推导;逆向去噪的每一步任务则被拆得非常简单,网络只需要"去掉一点点噪声"就够了。一个巨大的复杂任务被拆成了1000个微小且简单的子任务,这就是它的众多强大之处。
这种"拆细任务"的思想,其实也是课程学习的思路。网络不需要一步学会所有东西,它只需要在每一步学会一个很简单的去噪规则,但关键是这样组合起来的整体能力非常强。
1.3 三个生成范式放在同一张表里看
| 维度 | GAN | VAE | Diffusion Model |
|---|---|---|---|
| 建模思路 | 判别器与生成器对抗博弈 | 隐变量变分推断 | 逐步加噪再逐步去噪 |
| 训练目标 | 对抗损失 | 变分下界ELBO | 噪声预测的MSE |
| 是否显式建模密度 | 否 | 是(隐式) | 是(通过score) |
| 生成质量 | 好但不稳定 | 模糊、细节弱 | 高、清晰 |
| 多样性 | 容易坍缩 | 一般 | 好且可控 |
| 训练稳定性 | 差 | 稳定 | 很稳定 |
| 采样速度 | 快 | 快 | 慢(需多步) |
这张表基本概括了Diffusion Model能在近年超过GAN成为主流的原因:训练过程极其稳定,多样性高,而且通过后续的引导技术(classifier-free guidance等)还能精细控制生成内容。代价就是采样速度慢,原来GAN一次前向就能出图,扩散模型要循环几十上百次。这也正是后文要讲的加速技术存在的原因。
2. 前向加噪:把一张图片"彻底毁掉"的数学过程
2.1 单步加噪的严格定义:每一步都在做一个高斯转移
前向过程的核心是定义如何把一张干净图片变成噪声图。DDPM中,加噪是一个马尔可夫链:给定当前步图像,下一步的概率分布是一个高斯分布,均值为上一张图的某个缩放版本,方差则由一个逐步变化的超参数β_t控制。
q(x_t | x_{t-1}) = N(x_t; √(1-β_t) · x_{t-1}, β_t · I)
这里t从1到T,T在DDPM原论文里取1000,β_t是一个预设好的、逐步增大的值,论文里默认从0.0001线性增加到0.002。每次加噪都是采样一个高斯噪声叠加在图像上,但会乘一个√(1-β_t)的缩放系数,这个系数不是为了好看,而是为了控制整个过程的方差稳定,避免图像信号随着加噪步数增加而无限膨胀或萎缩。
从图像角度理解:每一步都让"原图的保留程度"下降一点点,让"噪声的占比"上升一点点。随着t增大,β_t越来越大,等于说每一步破坏得更狠一些,最终t=1000的时候,图像已经彻底被噪声淹没,几乎完全看不出原图任何信息。
2.2 为什么必须是高斯噪声:两个不可替代的性质
很多初学者会问,加噪为什么不用均匀噪声或者伯努利噪声?答案是高斯分布有两个对后面的推导来说决定性的性质。
第一个是可闭式累积性。高斯分布叠加高斯分布,结果还是高斯分布,而且均值和方差可以解析写下。这意味着我们不需要真的把加噪过程从第1步一步步模拟到第t步,可以直接一步算出任意中间状态x_t的分布。如果没有这个性质,训练时每一步都要串行跑1000次加噪,代码效率就直接崩了。
第二个是可重参数化性。高斯分布的采样可以写成"均值 + 标准差 × 标准正态噪声"的形式,这样随机性被隔离在一个独立的标准正态变量ε里,梯度可以通过这条路径回传。VAE之所以能训练,也是靠这个技巧。扩散模型把这两条都用到了极致。
2.3 累积加噪与重参数化:从x₀直接跳到x_t
推导一下前面的单步公式,把每一步的高斯分布反复叠乘起来,可以得到一个重要结论:任意时刻t的图像x_t,可以直接从原始图像x_0和一维标准正态噪声ε表示出来。
x_t = √(ᾱ_t) · x_0 + √(1 - ᾱ_t) · ε, 其中 α_t = 1 - β_t, ᾱ_t = ∏_{s=1}^{t} α_s
这个公式就是整个扩散模型训练效率的基础。训练时不需要真的跑1000步加噪,只需要随机采一个t,用这个公式一步就能得到x_t。其中√(ᾱ_t)表示信号保留的比例,√(1-ᾱ_t)表示噪声比例。当t=0时ᾱ_t=1,x_t就是原图;当t很大时ᾱ_t趋近于0,x_t接近纯高斯噪声。
代码实现里就是一行:x_t = torch.sqrt(alpha_bar[t]) * x_0 + torch.sqrt(1 - alpha_bar[t]) * eps。用这行代码,你可以在任何噪声水平下拿到x_t,送入网络训练。这是所有Diffusion代码里出现频率最高的一行。
2.4 噪声调度beta_schedule:linear和cosine怎么选
β_t从0.0001到0.002线性增长,这是DDPM原论文的默认配置,被称为linear schedule。但真正上手跑实验时你会发现,linear schedule有个隐藏问题:在t还很小的时候,ᾱ_t就已经快速下降,图像的原始信号在前几百步里就被破坏得差不多了,后面的步几乎全是在"已经接近纯噪声"的图像上做无用功。换句话说,加噪速度前快后慢,早期信息丢失太快。
后续论文提出了cosine schedule:让信号衰减曲线近似呈余弦形状,先缓慢后加速再缓慢,整个加噪过程对信息的破坏更均匀。实测下来cosine schedule在同样步数下训练更稳定,生成质量也能小幅提升,尤其是高分辨率图像。Stable Diffusion系列普遍用的是加了偏移的cosine或scaled linear变体,原因就是为了让加噪过程更"温和"。我的经验是,复现基础实验时先用linear schedule跑通,换cosine后注意观察不同t时刻的中间状态图,你就能直观感受到两者的区别。
3. 逆向去噪:网络到底在学什么
3.1 逆向过程先假设一个高斯分布
前向过程已经确定了:给定x_{t-1},x_t的分布是高斯。那么反过来,给定x_t,x_{t-1}的分布长什么样?在β_t足够小的情况下,逆向转移分布也可以用高斯来近似。这是一个数学上的结论,直觉上可以理解为:当每一步的破坏非常轻微时,从"被微扰的状态"恢复到"之前的状态"的路径不会有太多复杂性,用高斯分布描述已经足够。
于是我们把这个逆向分布参数化为p_θ(x_{t-1} | x_t) = N(x_{t-1}; μ_θ(x_t, t), Σ_θ(x_t, t)),其中网络需要预测的是均值μ_θ,方差Σ_θ在很多实现里被固定为预设值而不去学习。这个高斯假设是整个逆向过程能够被简化到极致的前提。
3.2 网络预测的是噪声残差,不是直接预测图像
训练网络时,最核心的问题是:让网络的输出对应什么目标?理论上可以直接让网络预测x_0,但实际操作效果不好,因为从被严重污染的x_t直接预测原图是个高度不适定问题,同一个x_t可能对应大量不同的x_0,网络学起来非常困难,输出容易变模糊。
DDPM的做法是让网络预测加进去的噪声ε。注意到前向过程x_t = √(ᾱ_t) · x_0 + √(1-ᾱ_t) · ε,这其实是一条线性混合关系。网络如果精确预测出ε,那么就可以反解出x_0:x_0 = (1/√(ᾱ_t)) · (x_t - √(1-ᾱ_t) · ε)。更重要的是,有了对噪声的估计,逆向过程中x_{t-1}的均值也可以被解析表述出来。所以预测噪声等价于掌握了完整的去噪路径。从另一个视角看,预测噪声等价于学习数据分布的score——也就是对数概率密度的梯度。这个视角来自score-based generative model研究线,扩散模型和score matching在数学上被证明是同一类东西。直观理解就是:在浓雾弥漫的山坡上,你不需要知道山脚在哪,你只需要每一步感受脚下的坡度,沿着最陡的方向往下走,一步一步就能下山。预测噪声就是让网络学会感知"当前在什么坡度上、该往哪个方向调整"。
3.3 为什么U-Net是标配
扩散模型的输入和输出都是图像,尺寸完全一致,这是一个典型的图像到图像任务。U-Net天然适合这个场景:Encoder通过多级下采样逐步提取抽象特征,Decoder通过上采样把特征放大回原图尺寸,中间的skip connection把Encoder每一层的细节特征直接拼到Decoder对应层上,确保浅层的颜色、纹理、边缘信息不丢失。
在文生图模型里,U-Net的中间层还会加入cross-attention模块,把文本编码器的输出作为条件注入到图像特征中,从而实现"注意力机制决定图片里哪里应该长什么"。在Stable Diffusion这类潜在扩散模型中,U-Net作用在压缩后的潜在空间上,分辨率更小、计算量更低,但结构本质没有变化。
3.4 时间步嵌入:让网络知道"现在毁到了什么程度"
同样一张加了噪的图,如果网络不知道当前处于整个加噪过程的第几步,它就无法判断"我应该均匀去噪还是锐化边缘"。所以t作为一个条件一定要喂进网络。DDPM的做法是把t编码成一个高维向量,做法和Transformer里的位置编码类似,用正弦余弦函数生成不同频率的嵌入,然后通过残差块调制到每一层的特征上。
这个时间嵌入看起来是个小细节,但删掉它训练几乎必然失败或者效果大幅退化。它给网络提供了"时间感",让同一个网络可以为不同噪声水平服务。实现中常用的方式是FiLM调制:时间嵌入经过线性层变换成scale和shift参数,对中间特征做缩放和偏移,让网络按当前时间选择最合适的去噪策略。
4. 训练目标:一个简单到令人怀疑的MSE Loss
4.1 从变分下界到简化损失
扩散模型本质上是潜在变量模型,最理想的目标是最大化对数似然 log p_θ(x_0),但这个量无法直接计算,所以转而最大化它的变分下界ELBO。把前向加噪过程和逆向去噪过程代入ELBO之后,可以逐项展开,展开后会发现ELBO可以分解为一系列KL散度之和,每一项都是在约束某个逆向高斯分布去匹配某个前向条件分布。
由于两个分布都是高斯,KL散度存在解析形式,经过一大堆配项整理之后,DDPM作者发现这些项里绝大部分权重影响很小,甚至可以去掉,最后整个训练目标退化成极其简洁的形式。
L_simple = E_{t, x_0, ε} [ || ε - ε_θ(x_t, t) ||² ]
也就是说,我们随机采样一个时间步t,随机采样一个高斯噪声ε,按2.3节公式合成x_t,让网络预测ε,算这个预测和真实ε之间的MSE,就完成了一次训练。整个过程没有任何对抗博弈,没有任何复杂的正则项,就是一个标准的回归任务。
4.2 为什么这个简洁的loss能生成高质量图片
很多人会困惑:回归loss不是容易让输出变模糊吗,为什么扩散模型最后能得到锐利的图像?
关键在于,扩散模型训练时的"回归"不是一次性预测图像,而是在每个时间步上只预测"噪声残差"这一小增量。每个小任务本身是确定且有明确解的,网络学到的是一系列"该往哪个方向微调"的梯度场,也就是数据分布的score。采样时从纯噪声出发,每走一步都沿着这个梯度场往高密度区域挪动一小步,本质上是在做随机微分方程的数值求解。由于是逐步迭代,即使每一步的预测带有一定误差,几百步下来依然能收敛到清晰的高概率区域,不会像单步回归那样靠平均来"和稀泥"。
这也是为什么扩散模型在相同网络容量下往往比GAN更能覆盖数据分布的多个模式,因为它不做单点映射,而是在高维空间里沿着流形漫游,多样性天然就高。
4.3 训练循环在PyTorch里的真实样子
贴上我在实验中实际使用的基础训练循环,代码很简短:
for batch, (x0, y) in enumerate(train_loader): # x0: [B, C, H, W], 像素值归一化到 [-1, 1] optimizer.zero_grad() t = torch.randint(0, T, (x0.size(0),), device=device).long() eps = torch.randn_like(x0) # 前向加噪:一步到位 x_t = torch.sqrt(alpha_bar[t]) * x0 + torch.sqrt(1 - alpha_bar[t]) * eps # 输入噪声图和时间步,预测噪声 eps_pred = model(x_t, t) loss = nn.functional.mse_loss(eps_pred, eps) loss.backward() optimizer.step()有几个细节需要在代码里注意。alpha_bar数组必须先按T=1000预计算好,且全部移到训练所用的设备上。t要作为tensor传入模型,模型内部会把它转成时间嵌入。loss就是mse_loss,不需要加任何权重,虽然理论上不同t对应的项权重不同,但DDPM实验证明去掉权重训练更稳定,生成质量也更好。
5. 采样过程与加速技巧:从1000步缩到20步
5.1 标准DDPM采样:一步一步往回走
训练好模型后,生成图像的过程就是反向模拟逆向马尔可夫链:从纯高斯噪声x_T出发,对t从T到1依次采样x_{t-1}。每一次的重建公式如下。
x_{t-1} = (1/√(α_t)) · (x_t - (1-α_t)/√(1-ᾱ_t) · ε_θ(x_t, t)) + σ_t · z
式子第一项是在"按照预测的噪声方向去噪",让图像从高噪声状态回到低噪声状态;第二项是加上一个随机高斯扰动z,σ_t通常取√β_t。最后一步t=1时不加噪声,避免给最终输出引入无谓的随机性。这个随机扰动很关键,它保证了采样过程是"带有探索性"的,如果每一步都只走确定性的去噪方向,模型容易陷入低质量区域。
标准DDPM采样需要跑满全部1000步,在GPU上生成单张图虽然不至于太慢,但放到实际应用里根本无法接受。这也催生了各种采样加速方法。
5.2 DDIM加速:把随机项去掉,变成确定性采样
DDIM的核心洞察是:DDPM的采样公式里"去噪项 + 随机噪声项"的每一步其实是在模拟一个随机微分方程,但这个随机微分方程存在一条等价的概率流常微分方程,意思是你可以用确定性的方式对同一条分布路径做离散化。去掉随机噪声项后,采样变成了一条确定的轨迹:
x_{t-1} = √(ᾱ_{t-1}) · (x_t - √(1-ᾱ_t) · ε_θ(x_t, t)) / √(ᾱ_t) + √(1-ᾱ_{t-1} - σ_t²) · ε_θ(x_t, t)
实际用起来,DDIM最大的好处就是可以大幅跳步:原本必须t从1000到1逐次递推,现在可以取一个步长,比如每20步采样一次,总共50步就能生成质量相当不错的图像。速度直接提升一个数量级。我的使用经验是,原版DDIM跑50步输出的图像和DDPM 1000步非常接近,但内存占用和耗时都下降很多。目前主流的采样器如DPM-Solver、DPM++、Euler等,基本都是在DDIM这个思路上进一步优化离散化格式,追求用更少的函数求值次数达到同样的采样质量。
5.3 Classifier-Free Guidance:可控生成的开关
扩散模型本身是条件生成模型,训练时你可以把类别或文本条件y一起输入,让网络学条件分布p(x_t | y)。但实际采样如果完全依赖条件,生成结果容易丢失多样性;完全不依赖条件,又无法控制生成内容。Classifier-Free Guidance(简称CFG)的思路很巧妙:在训练时随机让部分样本的条件被丢弃(用空标签替代),让同一个模型同时具备有条件预测和无条件预测的能力。采样时把两者的预测差值作为引导信号放大。
ε_hat = (1 + w) · ε_θ(x_t, t, y) - w · ε_θ(x_t, t, ∅)
当w=0时就是普通条件采样;w越大,生成结果越遵循条件提示,但多样性也随之下降。Stable Diffusion里CFG尺度默认7.5,是在"忠实于提示词"和"图像自然度"之间比较均衡的经验值。文本生成图像时这个技术几乎绕不开,它本质上是提供了一根调节"条件约束强度"的旋钮。
5.4 采样器与步数怎么配合:实测经验
接触过WebUI或ComfyUI的人一定见过一大串采样器名字,但很少有人理解它们之间的差异。按类别分其实很简单:
| 采样器类别 | 典型代表 | 步数建议 | 特点 |
|---|---|---|---|
| 多步噪声型 | DDPM | 1000 | 训练验证用,实际部署太少 |
| 确定性加速型 | DDIM | 20-50 | 平衡好,通用性强 |
| 高阶ODE求解型 | DPM-Solver、DPM++ 2M | 15-30 | 步数越少优势越明显 |
| 一阶快速型 | Euler、Heun | 20-40 | 简单稳定,适合初学调试 |
我的实际建议是,复现论文时老老实实用DDPM的1000步流程验证模型的正确性;一旦验证通过,就切到DDIM 50步做迭代,这样的开发效率最高。如果做视频或实时交互场景,再考虑DPM-Solver类采样器压到15步左右,配合蒸馏技术甚至能到4步出图。
6. 实际训练和采样时踩过的坑:几个可复现的排查经验
6.1 训练不收敛:先从这些点自查
扩散模型虽然训练稳定,但不代表什么都不管就能跑出好结果。如果训练了很久loss不降,或者生成的都是灰蒙蒙的图,按下面的顺序排查,大概率能定位问题:
第一,图像归一化是否正确。扩散模型约定图像数值范围在[-1, 1],很多代码库会把0-255的图除以127.5再减1,如果你用了[0,1]区间,网络输出和目标的数值尺度就不对齐,loss会偏高,生成效果也会偏色。第二,β_schedule是否从很小的值开始。如果初始β_t太大,前几步加噪就把信息全毁了,网络在大量"死图"上训练,学不到有效特征。第三,模型是否接收了正确的时间嵌入。检查t是否真正通过嵌入层送到了每个残差块里,很多人写结构时漏了这步,模型退化成"无时间条件"的盲去噪器,训练曲线看着下降但生成效果很差。第四,学习率是否过高。DDPM常见的学习率在2e-4到2e-5之间,用了Adam优化器,过高的学习率会导致loss剧烈震荡甚至NaN。
6.2 数值稳定性:细微的浮点问题能造成大影响
训练和采样过程中,数值稳定性几乎是最容易被忽略又最常出问题的环节。比如计算x_t时,如果ᾱ_t在t接近T时已经非常非常接近0,√(1-ᾱ_t)接近1,而√(ᾱ_t)非常小,此时x_t几乎完全由噪声主导,加噪公式本身没问题;但在反向采样DDIM时,如果代码里出现1 - alpha_bar[t],当alpha_bar[t]因浮点误差略大于1时,1 - alpha_bar[t]会变成负数,再开根号就是NaN。所以采样代码里必须对alpha_bar做clamp(0, 1),这是个非常容易踩的坑。
另一个常见问题是使用半精度训练时的溢出。loss本身是MSE,数值不大,但中间计算噪声和方差时可能产生极端值,用混合精度时要格外留意,最好先在FP32下跑通小规模实验,再切AMP。
6.3 EMA:一个免费提升生成质量的操作
指数移动平均可以说是扩散模型训练里的"免费午餐"。训练时对模型参数做滑动平均,保留一份"慢参数",采样时使用这份EMA参数而不是实时参数。因为扩散模型训练过程中的loss曲线会上下抖动,实时参数可能处于震荡的峰值附近,而EMA参数相当于对历史多个最优状态做了平滑,往往能带来更稳定的生成结果和更低的FID。
具体实现一般会维护一个复制模型的影子参数:shadow_param = decay * shadow_param + (1 - decay) * param,decay取0.9999左右,训练完成后加载shadow参数用于采样。这个操作用几行代码就能实现,收益却非常明显,我在CIFAR-10和FFHQ上都验证过,EMA后的FID通常能比无EMA低10-20%。
6.4 起步实验怎么设计:别一上来就追求高分辨率
最后分享一个非常实际的经验:想快速验证某个扩散模型改进点是否有效,千万别直接上256×256或512×512。先在MNIST 28×28或CIFAR-10 32×32上训练,几百轮epoch要不了多久,哪怕在单张消费级显卡上也能顺利跑完。训练过程中定期采样,并且把不同t的x_t和重建效果可视化出来,你就能直观看到整个去噪过程是否合理,问题出在哪个噪声水平阶段。
等你确认31×31小尺寸上的效果、训练稳定性和采样视觉效果都符合预期之后,再逐步提升分辨率、增加通道数。这样既能快速试错,又能避免在高分辨率上烧了大量算力才发现模型架构本身有bug。FID等其他指标可以作为量化评估的最终标准,但日常调试的第一判断依据永远是训练曲线和可视化图。
最后一个我自己在实际操作中的小习惯:在训练过程中把每个固定间隔步数采样的图像按时间拼接成动画,观察从纯噪声到清晰图像的整个演变过程。如果前几十步图像变化幅度很小,说明高噪声区域的去噪能力不足;如果最后几步还在大幅变化,说明低噪声区域拟合还不够细。这个观察方式比任何指标都直观,能帮你迅速定位扩散模型训练中的问题出在哪一段噪声水平上。