1. 从一张模糊噪点图到清晰图像:DDPM到底在做什么
第一次接触DDPM(Denoising Diffusion Probabilistic Models)的人,大概率会被论文里那一堆公式劝退。但如果你把它的核心思想翻译成一句人话,其实特别朴素:给一张干净图片不断加噪声,直到它变成纯噪声;然后训练一个网络,让它学会从纯噪声里一步步把图片还原回来。
这就像你把一滴墨水滴进一杯清水,墨水逐渐扩散到整杯水里,最后完全看不出原来的形状。DDPM做的事情就是反过来——训练一个模型,让它看着这杯均匀的墨水水,一步步倒推出"这滴墨水原来长什么样"。
我刚开始学DDPM的时候,最大的困惑是:为什么不能一步到位,直接从噪声生成图片?非要搞几百上千步?后来自己动手写了训练循环才明白,一步到位意味着网络要在一个极其复杂的分布上做映射,难度极大。而拆成1000个小步,每一步只需要预测"当前这步加了多少噪声",任务简单得多,网络也更容易学。这就是扩散模型的核心设计哲学:把难问题拆成一堆简单问题。
DDPM属于图像生成大模型家族里的一条重要技术路线。和GAN(生成对抗网络)相比,它训练更稳定,不容易出现模式崩溃;和VAE(变分自编码器)相比,它生成的图像细节更丰富、更逼真。代价是推理速度慢——生成一张图要跑几百上千次网络前向传播。这也是后来DDIM、潜在扩散模型(Latent Diffusion)等一系列改进的出发点。
这篇文章我会从工程落地的角度,把DDPM拆开讲透:前向加噪的数学原理、UNet噪声预测网络的结构设计、训练循环怎么写、采样怎么加速、以及我自己踩过的那些坑。适合有一定PyTorch基础、想真正把扩散模型跑起来的读者。如果你只是想了解概念,前两节看完就够了;如果你想自己训一个模型出来,建议从头到尾跟着走一遍。
2. 前向扩散与反向去噪:两个过程必须一起理解
2.1 前向过程:一个不需要学习的"破坏"流程
前向扩散过程(Forward Diffusion)是整个DDPM里最"友好"的部分,因为它没有任何需要学习的参数。你只需要定义一个噪声调度表(noise schedule),然后按照固定规则往图片上加噪声就行。
具体来说,给定一张干净图片 $x_0$,我们定义一系列时间步 $t = 1, 2, ..., T$(通常T=1000)。每一步都往当前图片里加入一小撮高斯噪声:
$$q(x_t | x_{t-1}) = \mathcal{N}(x_t; \sqrt{1-\beta_t} x_{t-1}, \beta_t \mathbf{I})$$
这里的 $\beta_t$ 是每一步的噪声方差,通常从 $10^{-4}$ 线性增长到 $0.02$。$\sqrt{1-\beta_t}$ 这个系数是为了让图片的方差保持稳定——如果不乘这个系数,加了几百步噪声之后图片的数值会爆炸。
但实际训练时,我们不可能真的循环1000次来加噪,那样太慢了。DDPM论文里给出了一个重参数化技巧,可以直接从 $x_0$ 一步算出任意时刻 $t$ 的 $x_t$:
$$x_t = \sqrt{\bar{\alpha}_t} x_0 + \sqrt{1-\bar{\alpha}_t} \epsilon, \quad \epsilon \sim \mathcal{N}(0, \mathbf{I})$$
其中 $\alpha_t = 1 - \beta_t$,$\bar{\alpha}t = \prod{s=1}^{t} \alpha_s$。
这个公式是DDPM训练效率的关键。我实测下来,用这个公式可以在一个batch里同时采样不同时间步的噪声图片,训练速度比逐步加噪快几十倍。你可以把它理解成:我们不需要真的走完1000步,只需要知道第t步的"加噪配方"就行。
注意:$\bar{\alpha}_t$ 会随着t增大而快速衰减。当T=1000时,$\bar{\alpha}_T$ 已经接近0,意味着 $x_T$ 几乎就是纯高斯噪声了。如果你发现训练时模型在后期时间步上loss特别大,很可能是噪声调度表设置得不合理。
2.2 反向过程:网络真正要学的东西
反向过程(Reverse Process)才是DDPM的核心。我们希望学到一个分布 $p_\theta(x_{t-1} | x_t)$,能够从噪声一步步还原出图片。
理论上,如果 $\beta_t$ 足够小,反向过程也可以近似为高斯分布:
$$p_\theta(x_{t-1} | x_t) = \mathcal{N}(x_{t-1}; \mu_\theta(x_t, t), \Sigma_\theta(x_t, t))$$
DDPM的巧妙之处在于,它不直接预测 $x_{t-1}$,而是让网络预测当前步加入的噪声 $\epsilon$。然后通过贝叶斯公式推导出 $x_{t-1}$ 的均值:
$$\mu_\theta(x_t, t) = \frac{1}{\sqrt{\alpha_t}} \left( x_t - \frac{\beta_t}{\sqrt{1-\bar{\alpha}t}} \epsilon\theta(x_t, t) \right)$$
这个公式看起来复杂,但工程上你只需要记住一件事:网络的输出是噪声,不是图片。训练目标就是让预测噪声和真实噪声的MSE最小:
$$\mathcal{L} = \mathbb{E}{t, x_0, \epsilon} \left[ | \epsilon - \epsilon\theta(\sqrt{\bar{\alpha}_t} x_0 + \sqrt{1-\bar{\alpha}_t} \epsilon, t) |^2 \right]$$
我第一次看到这个loss的时候觉得太简单了——就一个MSE?后来才理解,这个简单的loss背后是变分下界的简化推导。DDPM论文做了大量消融实验证明,去掉那些复杂的加权系数,直接用简单MSE效果反而最好。
2.3 为什么预测噪声比预测图片更好
这里有个很多人会问的问题:为什么不直接让网络预测 $x_0$ 或者 $x_{t-1}$,非要预测噪声?
我自己的理解是:预测噪声相当于让网络学习"残差"。在时间步t很大时,$x_t$ 几乎全是噪声,此时预测 $x_0$ 非常困难,但预测噪声相对容易,因为噪声本身就是高斯的,分布简单。反过来,在t很小时,$x_t$ 已经很接近 $x_0$,预测噪声和预测图片难度差不多。
从梯度角度看,预测噪声的loss在不同时间步之间更均衡。如果预测 $x_0$,早期时间步的loss会非常大,导致训练不稳定。这也是为什么后来很多改进工作(如v-prediction)都是在噪声预测的基础上做参数化调整。
3. UNet噪声预测网络:结构设计与关键细节
3.1 为什么是UNet而不是Transformer
DDPM原论文用的是UNet作为噪声预测网络。你可能会问:现在Transformer这么火,为什么不用Transformer?
原因很实际:UNet的归纳偏置(inductive bias)天然适合图像任务。它的编码器-解码器结构配合跳跃连接(skip connection),能够同时捕捉全局语义和局部细节。而扩散模型的去噪过程恰恰需要这两种信息——既要理解整张图的语义结构,又要精确还原每个像素的噪声。
当然,后来DiT(Diffusion Transformer)证明了Transformer也能做扩散模型,但那需要更大的数据量和算力。对于中小规模任务,UNet仍然是性价比最高的选择。
3.2 UNet在DDPM中的具体结构
DDPM用的UNet和原始医学图像分割的UNet有几个关键区别:
| 组件 | 原始UNet | DDPM UNet |
|---|---|---|
| 下采样 | 最大池化 | 步长卷积 |
| 上采样 | 转置卷积 | 最近邻插值+卷积 |
| 归一化 | BatchNorm | GroupNorm |
| 激活函数 | ReLU | SiLU (Swish) |
| 时间步信息 | 无 | 正弦位置编码+MLP |
| 注意力机制 | 无 | 中间层+部分下采样层 |
时间步嵌入是DDPM UNet最特殊的地方。因为同一个网络要在不同时间步上工作,必须告诉它"现在是第几步"。具体做法是用正弦位置编码把时间步t映射成一个向量,然后通过两层MLP,再注入到每个残差块中。
我踩过的一个坑是:时间步嵌入的维度不能太小。一开始我用了64维,结果模型在早期时间步和晚期时间步上表现差异很大。后来改成256维,问题明显改善。经验值是时间步嵌入维度至少要和网络基础通道数相当。
3.3 残差块的设计细节
DDPM的残差块(ResBlock)结构大致是这样的:
class ResBlock(nn.Module): def __init__(self, in_ch, out_ch, time_emb_dim): super().__init__() self.norm1 = nn.GroupNorm(32, in_ch) self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1) self.time_mlp = nn.Linear(time_emb_dim, out_ch) self.norm2 = nn.GroupNorm(32, out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1) self.skip = nn.Conv2d(in_ch, out_ch, 1) if in_ch != out_ch else nn.Identity() def forward(self, x, t_emb): h = self.conv1(F.silu(self.norm1(x))) h = h + self.time_mlp(F.silu(t_emb))[:, :, None, None] h = self.conv2(F.silu(self.norm2(h))) return h + self.skip(x)几个关键点:
- GroupNorm的组数:通常设为32或min(32, channels)。组数太少归一化效果差,太多则计算开销大。
- 时间步嵌入的注入方式:是加法不是拼接。加法更节省参数,效果也够用。
- skip connection:当输入输出通道数不同时,需要用1x1卷积调整通道。
3.4 注意力层的放置策略
DDPM在UNet的中间层(bottleneck)和部分下采样层加了自注意力。但注意力层的计算复杂度是 $O(N^2)$,其中N是特征图的空间位置数。在64x64的特征图上,N=4096,注意力矩阵就是4096x4096,显存占用很大。
我的经验是:只在16x16及以下分辨率的特征图上加注意力。这样既能捕捉全局依赖,又不会爆显存。如果你做的是高分辨率图像生成,可以考虑用线性注意力或窗口注意力来替代。
提示:如果你发现训练时显存不够,优先检查注意力层的位置和数量。把注意力层从32x32特征图上移除,通常能省下30%以上的显存。
4. 训练循环的工程实现与调参经验
4.1 数据预处理:归一化到[-1, 1]
DDPM的输入图片需要归一化到[-1, 1]范围。这是因为前向加噪过程假设数据是零均值的,而 $\bar{\alpha}_t$ 和 $\sqrt{1-\bar{\alpha}_t}$ 的系数设计也是基于这个假设。
transform = transforms.Compose([ transforms.Resize(64), transforms.CenterCrop(64), transforms.ToTensor(), # [0, 1] transforms.Normalize([0.5], [0.5]) # [-1, 1] ])别小看这一步。我有一次忘了做归一化,直接用[0,1]的图片训练,结果模型生成的图片全是灰蒙蒙的,loss也降不下去。排查了半天才发现是数据范围的问题。
4.2 训练循环的核心代码
def train_step(model, x0, optimizer, noise_schedule): batch_size = x0.shape[0] t = torch.randint(0, T, (batch_size,), device=x0.device) noise = torch.randn_like(x0) sqrt_alpha_bar = extract(noise_schedule.sqrt_alpha_bar, t, x0.shape) sqrt_one_minus_alpha_bar = extract(noise_schedule.sqrt_one_minus_alpha_bar, t, x0.shape) xt = sqrt_alpha_bar * x0 + sqrt_one_minus_alpha_bar * noise noise_pred = model(xt, t) loss = F.mse_loss(noise_pred, noise) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() return loss.item()几个实操要点:
- 时间步采样:用均匀采样
torch.randint就行。有些实现会用重要性采样,但DDPM论文证明均匀采样效果已经很好。 - 梯度裁剪:扩散模型的梯度有时候会突然变大,加个clip_grad_norm很必要。阈值设1.0通常够用。
- EMA(指数移动平均):这是DDPM训练的一个关键技巧。维护一份模型参数的EMA副本,采样时用EMA参数而不是原始参数,生成质量会明显提升。
class EMA: def __init__(self, model, decay=0.9999): self.model = copy.deepcopy(model) self.decay = decay @torch.no_grad() def update(self, model): for ema_param, param in zip(self.model.parameters(), model.parameters()): ema_param.data.mul_(self.decay).add_(param.data, alpha=1-self.decay)4.3 学习率与batch size的搭配
DDPM原论文用了batch size 128、学习率2e-4、训练800k步。但这是在大规模数据集(如ImageNet)上的配置。如果你在小数据集(如CIFAR-10或自己的小图库)上训练,需要调整。
我的经验配置:
| 数据集规模 | batch size | 学习率 | 训练步数 |
|---|---|---|---|
| < 10k | 32-64 | 1e-4 | 50k-100k |
| 10k-100k | 64-128 | 2e-4 | 200k-500k |
| > 100k | 128-256 | 2e-4 | 500k-1000k |
学习率调度方面,DDPM用了warmup+cosine decay。前5000步线性warmup到最大学习率,然后cosine衰减到0。这个策略对训练稳定性帮助很大。
4.4 我踩过的三个训练坑
坑一:loss不降反升。原因是噪声调度表的 $\beta_t$ 设置不当。如果 $\beta_T$ 太大,最后几步的噪声完全覆盖了信号,网络学不到有用信息。建议 $\beta_T$ 不要超过0.02。
坑二:生成图片有网格状伪影。这是UNet上采样用了转置卷积导致的。改成最近邻插值+3x3卷积后,伪影消失。
坑三:训练后期loss震荡。原因是学习率没有衰减。加上cosine decay后,loss曲线平滑了很多。
5. 采样加速:从1000步到50步的实用方案
5.1 DDPM原始采样为什么慢
DDPM的采样过程需要从 $t=T$ 到 $t=1$ 逐步去噪,总共1000次网络前向传播。生成一张64x64的图片,在V100上大约需要20秒。这个速度在实际应用中完全不可接受。
慢的根本原因是:DDPM的采样必须遵循马尔可夫链,每一步都依赖前一步的结果。你不能并行化,只能串行跑1000次。
5.2 DDIM:确定性采样的突破
DDIM(Denoising Diffusion Implicit Models)的核心洞察是:前向过程不一定非要是马尔可夫链。我们可以定义一个新的前向过程,它产生和DDPM相同的边缘分布,但反向过程可以是确定性的。
DDIM的采样公式:
$$x_{t-1} = \sqrt{\bar{\alpha}{t-1}} \hat{x}0 + \sqrt{1-\bar{\alpha}{t-1}} \epsilon\theta(x_t, t)$$
其中 $\hat{x}_0 = \frac{x_t - \sqrt{1-\bar{\alpha}t} \epsilon\theta(x_t, t)}{\sqrt{\bar{\alpha}_t}}$。
这个公式的好处是:你可以跳过中间步骤。比如从1000步里只取50步,采样质量下降很小。我实测下来,50步DDIM采样的FID只比1000步DDPM差一点点,但速度快了20倍。
def ddim_sample(model, shape, steps=50, eta=0.0): x = torch.randn(shape) timesteps = torch.linspace(T-1, 0, steps).long() for i in range(len(timesteps)-1): t = timesteps[i] t_next = timesteps[i+1] noise_pred = model(x, t) x0_pred = (x - sqrt_one_minus_alpha_bar[t] * noise_pred) / sqrt_alpha_bar[t] x0_pred = x0_pred.clamp(-1, 1) x = sqrt_alpha_bar[t_next] * x0_pred + sqrt_one_minus_alpha_bar[t_next] * noise_pred return x5.3 其他加速思路
除了DDIM,还有几条加速路线:
- DPM-Solver:把采样过程看成ODE求解,用高阶数值方法加速。10-20步就能达到不错的效果。
- 知识蒸馏:训练一个学生模型,让它一步预测多步的结果。但蒸馏过程本身很复杂。
- 潜在扩散模型(LDM):不在像素空间做扩散,而是在VAE的潜在空间做。这样每步的计算量大幅降低。Stable Diffusion就是这条路线的代表。
注意:DDIM的eta参数控制随机性。eta=0是确定性采样,eta=1退化为DDPM。实际使用中eta=0通常效果最好,而且可以复现。
6. 从DDPM到潜在扩散:工程落地的演进路线
6.1 DDPM在像素空间的瓶颈
DDPM直接在像素空间做扩散,这意味着网络要处理64x64x3甚至256x256x3的张量。计算量和显存占用都很大。生成一张256x256的图片,UNet的参数量可能要上亿。
更关键的是,像素空间里很多信息是冗余的。一张自然图片的像素之间高度相关,真正决定图像内容的"语义信息"其实维度低得多。
6.2 潜在扩散模型的核心思想
潜在扩散模型(Latent Diffusion Model, LDM)的思路很直接:先用一个VAE把图片压缩到潜在空间,然后在潜在空间做扩散。
比如256x256x3的图片,经过VAE编码器后变成32x32x4的潜在表示。空间尺寸缩小了8倍,通道数也少了。在这个潜在空间上做扩散,计算量降低了几十倍。
Stable Diffusion就是LDM的典型应用。它的UNet在32x32的潜在空间上工作,配合交叉注意力机制接受文本条件,实现了文本到图像的生成。
6.3 条件生成的实现方式
无条件DDPM只能随机生成图片,无法控制生成内容。实际应用中,我们通常需要条件生成——比如根据类别标签、文本描述或其他图片来生成。
条件DDPM的实现方式主要有两种:
- 分类器引导(Classifier Guidance):额外训练一个分类器,在采样时用分类器的梯度来引导生成。效果不错,但需要额外训练分类器。
- 无分类器引导(Classifier-Free Guidance):训练时随机丢弃条件,让同一个网络既能做条件生成也能做无条件生成。采样时把两者的预测做加权组合。
无分类器引导现在是主流方案,因为它不需要额外模型,而且效果更好。实现也很简单:
# 训练时 if random.random() < 0.1: condition = None # 10%概率丢弃条件 # 采样时 noise_pred_uncond = model(x, t, condition=None) noise_pred_cond = model(x, t, condition=condition) noise_pred = noise_pred_uncond + guidance_scale * (noise_pred_cond - noise_pred_uncond)guidance_scale通常设7.5左右。设太大图像会过饱和,设太小条件控制力不够。
6.4 实际项目中的选型建议
如果你要做一个图像生成项目,我的建议是:
- 小规模实验/学习:直接用DDPM在CIFAR-10或MNIST上跑,理解原理。
- 中等规模应用:用DDIM采样加速,UNet结构可以适当缩小。
- 生产级应用:直接用Stable Diffusion的开源权重做微调,不要从头训练。从头训练一个高质量的LDM需要几十张A100和数百万美元的数据成本。
7. 那些文档里不会写的实操心得
7.1 噪声调度表的微调
DDPM原论文用的是线性调度表,但后来很多工作发现cosine调度表效果更好,尤其是在高分辨率图像上。cosine调度的 $\bar{\alpha}_t$ 定义为:
$$\bar{\alpha}_t = \frac{f(t)}{f(0)}, \quad f(t) = \cos\left(\frac{t/T + s}{1 + s} \cdot \frac{\pi}{2}\right)^2$$
其中s是一个小偏移量,通常取0.008。cosine调度的好处是:在中间时间步,噪声增加的速度更均匀,网络能学到更丰富的去噪能力。
我实测对比过,在64x64图像上,cosine调度比线性调度的FID低了约15%。这个提升不需要改任何网络结构,只是换个调度表,性价比很高。
7.2 采样时的clamp技巧
在DDIM采样中,预测的 $\hat{x}_0$ 需要clamp到[-1, 1]。这一步看似不起眼,但不做的话生成质量会明显下降。原因是网络预测的 $\hat{x}_0$ 可能超出合理范围,如果不clamp,误差会在后续步骤中累积放大。
另外,有些实现会在每一步都对 $x_t$ 做clamp,这也是可以的,但要注意clamp的范围要略大于[-1, 1],比如[-1.5, 1.5],否则会损失信息。
7.3 如何判断模型是否训练充分
看loss曲线是最直接的方法,但loss低不代表生成质量好。我的经验是:
- 每隔一定步数采样几张图,肉眼观察生成质量的变化。
- 计算FID(如果有参考数据集),FID持续下降说明模型在进步。
- 检查不同时间步的loss。如果早期时间步的loss明显大于晚期,说明模型在噪声大的时候去噪能力不足,可能需要增加网络容量或调整调度表。
7.4 显存优化的几个实用技巧
- 混合精度训练(AMP):用torch.cuda.amp,显存占用能降低40%左右,速度也有提升。
- 梯度累积:如果batch size受显存限制,可以用梯度累积模拟大batch。
- 检查点重计算(Gradient Checkpointing):用时间换显存,适合深层UNet。
- 注意力层的显存优化:用Flash Attention或Memory-Efficient Attention替代标准注意力。
提示:如果你在消费级显卡(如RTX 3060 12G)上训练,建议从32x32的图像开始,UNet通道数减半,batch size设16,配合AMP。这样大概能跑起来。
7.5 关于UNet模型改进的一些观察
最近几年UNet在扩散模型里的改进主要集中在几个方向:
- 注意力机制的优化:从全局注意力到窗口注意力、线性注意力,降低计算复杂度。
- 归一化层的替换:有些工作用RMSNorm替代GroupNorm,训练更稳定。
- 激活函数的调整:SiLU仍然是主流,但也有工作尝试GELU或Mish。
- 残差连接的改进:比如用U-Net的变体(如U-ViT)融合Transformer结构。
但说实话,对于大多数应用场景,原版DDPM的UNet结构已经足够好了。改进带来的提升往往需要在大规模数据和算力下才能体现。如果你只是做小规模实验,不建议在结构上花太多时间,把训练流程和采样策略调好收益更大。
8. 一个完整的DDPM训练与采样流程回顾
把前面所有内容串起来,一个完整的DDPM项目流程大致是这样的:
第一步:确定任务和数据。明确你要生成什么图像,分辨率多少,数据集多大。这决定了网络规模和训练配置。
第二步:搭建UNet。按照第3节的结构,实现带时间步嵌入的UNet。建议先用小通道数(如base_channels=64)验证流程,再逐步扩大。
第三步:定义噪声调度。实现线性或cosine调度表,预计算 $\bar{\alpha}_t$、$\sqrt{\bar{\alpha}_t}$、$\sqrt{1-\bar{\alpha}_t}$ 等系数。
第四步:写训练循环。包括数据加载、时间步采样、前向加噪、噪声预测、MSE loss、反向传播、EMA更新。
第五步:训练与监控。定期采样图片观察质量,记录loss曲线,保存checkpoint。
第六步:采样。用DDIM或DPM-Solver加速采样,配合无分类器引导做条件生成。
第七步:评估与调优。计算FID等指标,根据结果调整网络结构、调度表或训练超参。
整个流程跑通一遍,你对扩散模型的理解会从"看论文似懂非懂"变成"真正知道每一步在干什么"。我第一次完整跑通DDPM的时候,看到模型从纯噪声里一步步生成出清晰的数字图片,那种感觉比看一百篇论文都管用。
最后分享一个我自己的习惯:每次改一个变量。扩散模型的超参很多,如果同时改学习率、batch size和网络结构,出了问题根本不知道是哪个导致的。一次只动一个地方,记录结果,这样才能积累出对自己任务最有效的配置。