☰
DDPM扩散模型从原理到PyTorch实现:噪声调度、UNet训练与采样避坑
2026/10/2 10:45:30 网站建设 项目流程

简介:一份经典的人工智能前沿论文PDF,以英文原文完整呈现,适合深度学习、计算机视觉与生成模型方向的研发人员、研究生及竞赛选手阅读。论文围绕去噪扩散概率模型(DDPM)展开,讲解如何从非平衡热力学视角构造潜变量模型,并通过Langevin动力学与去噪评分匹配实现高质量图像生成;同时给出CIFAR10上Inception Score9.46、FID3.17等关键结果,便于读者对标复现。资源包仅1个PDF文件,约9.79MB,移动端或桌面端均可直接打开阅读。当前已有430人学习下载。该论文是扩散模型领域的奠基性工作之一,既阐述了扩散模型与GAN、VAE、能量模型的联系,也讨论了图像、文本、音频等多模态扩展与应用前景;随文还提供官方开源实现链接,非常适合作为理解AIGC图像生成原理、复现经典实验或撰写技术综述的参考文献。

1. Denoising Diffusion Probabilistic Models 到底是什么

先说结论:这份 PDF 不是又一篇刷榜的新网络结构论文,它把生成模型的问题重新定义成了“去噪”。Denoising Diffusion Probabilistic Models 这篇 2020 年的工作证明了一件反直觉的事:生成一张图,不一定要直接学数据分布,反而可以先给数据反复加噪声再加到完全随机,然后训练一个网络一步步把噪声“擦掉”。当这一步做得足够细,生成质量能追平甚至超过当时的 GAN,且训练过程稳定很多。适合谁读?想入门扩散模型、想复现一个能出图的生成模型、或者想在自己的数据上做无条件生成/条件生成任务的人。它能解决的核心问题是:如何把数学上收敛的扩散过程,落成能训练、能采样、能调参的工程实现。下面按我做这类模型时的惯用路径展开,先讲数学骨架,再给代码,最后是踩坑记录。

2. 前向扩散的噪声表:为什么 T=1000、beta 从 1e-4 线性爬到 0.02

2.1 前向过程与闭合重参数化:x_t 如何从原始样本一路加噪

扩散模型的前向过程定义成一个马尔可夫链:给定一张真实图像 $x_0$,每一步往里面加一点高斯噪声,经过 $T$ 步后,图像完全变成噪声。严格写出来是:

$$q(x_t | x_{t-1}) = \mathcal{N}(x_t; \sqrt{1-\beta_t}x_{t-1}, \beta_t I)$$

其中 $\beta_t$ 是第 $t$ 步的噪声强度,$t=1$ 到 $T$。这条式子说的是:从 $x_{t-1}$ 出发,均值被缩小到原来的 $\sqrt{1-\beta_t}$ 倍,同时叠一个方差为 $\beta_t$ 的高斯噪声。之所以要把均值缩小,是为了把整个序列的能量控制在某个范围内,避免每步加噪后数值一路膨胀。

实际工程里几乎不会真的按时间步逐级加噪,那太慢了。因为高斯分布对高斯分布还是高斯分布,所以可以直接从 $x_0$ 一步跳到任意 $x_t$。推导后得到闭合形式:

$$x_t = \sqrt{\bar{\alpha}_t}x_0 + \sqrt{1-\bar{\alpha}_t}\varepsilon, \quad \varepsilon \sim \mathcal{N}(0, I)$$

其中 $\alpha_t = 1-\beta_t$,$\bar{\alpha}t = \prod{s=1}^t \alpha_s$。这意味着训练时不用模拟 $t$ 步,只要随机抽一个时间步 $t$,就能立刻拿到对应的 $x_t$。这个闭合形式是整个训练能高效进行的基础,也直接决定了代码里sqrt_alphas_cumprod和sqrt_one_minus_alphas_cumprod这两组张量要预先算好。

2.2 反向过程的 Gaussian 假设成立的条件

前向好定义,真正的难点是反向:已知 $x_t$,怎么估计 $x_{t-1}$?直接求 $q(x_{t-1}|x_t)$ 是不行的,因为它依赖未知的 $x_0$。DDPM 的关键假设是:当每一步的噪声强度 $\beta_t$ 足够小的时候,反向转移也可以用高斯分布来近似。也就是说:

$$p_\theta(x_{t-1}|x_t) = \mathcal{N}(x_{t-1}; \mu_\theta(x_t, t), \Sigma_\theta(x_t, t))$$

为什么这个近似成立?直观理解是:如果每步只加入很小的噪声,那么从 $x_t$ 往回走一步的分布也会接近高斯。这给了网络一个很轻的任务——不是直接输出整张图,而是输出当前噪声的估计值。但注意,这个近似成立的前提就是步长要足够细。这也是论文把 $T$ 定到 1000 而不是 100 或 50 的直接原因:步数越多,每一步 $\beta_t$ 越小,高斯近似越准,模型越好学。

2.3 噪声表设计的两个极端

$\beta_t$ 从 $10^{-4}$ 线性爬到 $0.02$,是一个经过反复校验的默认选择。如果把 $\beta_t$ 定得过小,前向过程在头几百步几乎没有变化,模型很难从微弱的信号里学到有效特征,训练 loss 会下降得极慢。如果 $\beta_t$ 定得过大,反向过程的每一步跨度太大,高斯近似失效,模型输出的图像容易出现结构断裂和色块异常。

我自己在自定义数据集上的经验是:这个线性 schedule 通常不需要大改,但如果你发现生成样本的整体对比度偏低,或者末尾步数对应的 SNR 太低,可以考虑改用 cosine schedule,它在中间阶段放慢了变化速度,对分辨率较高的图更友好。判断标准很简单:看 $\bar{\alpha}T$ 是否接近 0。按论文默认参数,$\bar{\alpha}{1000} \approx 1.67\times 10^{-4}$,这保证 $x_T$ 几乎就是标准高斯噪声。如果这个值还比较大,说明前向加噪强度不够,模型在采样的最后几步会很难办。

3. 训练目标如何化简成“预测被加的噪声”:L_simple 的推导与取舍

3.1 从 ELBO 到每步 KL:变分下界拆成三份

DDPM 不是直接用最大似然训练,而是优化变分下界。对数据进行对数似然可以分解成三项:

$$L = L_T + \sum_{t=2}^{T} L_{t-1} + L_0$$

第一项 $L_T$ 衡量最终噪声分布 $q(x_T|x_0)$ 和先验 $p(x_T)$ 的 KL 散度,由于 $x_T$ 已经接近标准正态,这一项在训练时基本不起作用。第二项是中间每一步反向转移与真实后验 $q(x_{t-1}|x_t, x_0)$ 之间的 KL。第三项 $L_0$ 是把最后的连续分布离散化成像素值时的损失,对应生成质量的细节。

训练时真正起作用的是中间那 $T-1$ 项。而 $q(x_{t-1}|x_t, x_0)$ 有一个漂亮的解析形式:给定 $x_0$ 和 $x_t$,它依然是一个高斯分布,均值为:

$$\tilde{\mu}t(x_t, x_0) = \frac{\sqrt{\bar{\alpha}{t-1}}\beta_t}{1-\bar{\alpha}t}x_0 + \frac{\sqrt{\alpha_t}(1-\bar{\alpha}{t-1})}{1-\bar{\alpha}_t}x_t$$

这个式子看着复杂,但它的意义在于:模型其实不需要直接预测 $x_{t-1}$,只需要去拟合这个真实后验的均值。正是因为真实后验已知,扩散模型的训练才能变成监督学习。

3.2 均值重参数化:为什么网络输出 ε 而不是直接输出 μ

接下来是论文里最关键的简化。把 $x_t = \sqrt{\bar{\alpha}_t}x_0 + \sqrt{1-\bar{\alpha}_t}\varepsilon$ 代入 $\tilde{\mu}_t$,可以重新整理成:

$$\tilde{\mu}_t(x_t, x_0) = \frac{1}{\sqrt{\alpha_t}}\left(x_t - \frac{\beta_t}{\sqrt{1-\bar{\alpha}_t}}\varepsilon\right)$$

网络要做的事就明确了:输入 $x_t$ 和时间步 $t$,输出一个对噪声 $\varepsilon$ 的估计 $\varepsilon_\theta(x_t, t)$,然后按上式恢复均值。也就是说,表面上看模型在预测“噪声”,实际上它是在决定“从当前图像中减去多大程度的扰动”。这是整个 DDPM 工程实现里最反直觉、也最需要想清楚的一步。

3.3 去掉权重后反而更好:L_simple 与 L_vlb 的分工

如果严格按 KL 散度来加权,每个时间步的损失量级差很多,训练容易不稳定。论文发现,把所有 KL 项直接简化成一个逐像素的 MSE 损失:

$$L_{simple} = \mathbb{E}{t,x_0,\varepsilon}\left[\left|\varepsilon - \varepsilon\theta(\sqrt{\bar{\alpha}_t}x_0 + \sqrt{1-\bar{\alpha}_t}\varepsilon, t)\right|^2\right]$$

在这个目标下,每个时间步的权重被强制拉平,训练反而更稳定。为什么“错”的权重反而更好?因为采样质量更看重中低噪声区间,这个区间对应图像的粗结构,而高噪声区间主要决定整体色调。均匀权重相当于让模型在每个复杂度层级上都花同样的力气,最后出来的样本在视觉上更完整。这一点在复现时不要乱改,把 $L_{simple}$ 作为主损失,把按 KL 加权后的 $L_{vlb}$ 作为一个监控指标即可。

4. 用 PyTorch 跑通最小 DDPM:CIFAR-10 训练代码骨架与超参

4.1 网络骨架:时间 embedding、UNet 通道数与 attention 位置

DDPM 的骨干网络是 UNet,但和图像分割用的 UNet 有一个关键差异:每一层都要接收时间步 $t$ 的信息。常见做法是把 $t$ 用正弦位置编码映射成一个向量,再通过两个全连接层变换到各个通道深度,然后加到每个残差块的输入里。CIFAR-10 是 32x32 的小图,原论文用的是宽 ResNet 风格的下采样/上采样结构,通道数从 128 开始逐层翻倍,在 16x16 分辨率处插入 attention 层。实际复现时先把通道数降到 64 起步,显存压力小很多。下面这个类只保留核心结构,方便先跑通:

import torch import torch.nn as nn import math def time_embedding(t, dim): half = dim // 2 freqs = torch.exp(-math.log(10000) * torch.arange(half) / half).to(t.device) args = t.float().unsqueeze(-1) * freqs return torch.cat([torch.sin(args), torch.cos(args)], dim=-1) class SimpleDDPM(nn.Module): def __init__(self, in_channels=3, base_channels=64, time_dim=256): super().__init__() self.time_dim = time_dim self.t_embed = nn.Sequential( nn.Linear(time_dim, time_dim * 2), nn.SiLU(), nn.Linear(time_dim * 2, time_dim * 2) ) self.conv_in = nn.Conv2d(in_channels, base_channels, 3, padding=1) # 下采样路径:分辨率从 32 -> 16 -> 8 -> 4 self.down1 = nn.Conv2d(base_channels, base_channels * 2, 4, 2, 1) self.down2 = nn.Conv2d(base_channels * 2, base_channels * 4, 4, 2, 1) self.down3 = nn.Conv2d(base_channels * 4, base_channels * 8, 4, 2, 1) # 中间瓶颈,分辨率最低,通道最多 self.mid = nn.Conv2d(base_channels * 8, base_channels * 8, 3, padding=1) # 上采样路径:4 -> 8 -> 16 -> 32 self.up3 = nn.ConvTranspose2d(base_channels * 8, base_channels * 4, 4, 2, 1) self.up2 = nn.ConvTranspose2d(base_channels * 4, base_channels * 2, 4, 2, 1) self.up1 = nn.ConvTranspose2d(base_channels * 2, base_channels, 4, 2, 1) self.out = nn.Conv2d(base_channels, in_channels, 3, padding=1) def forward(self, x, t): te = self.t_embed(time_embedding(t, self.time_dim)) h = self.conv_in(x) d1 = self.down1(h) # 直接简化,实际 UNet 每层要加时间 embedding d2 = self.down2(d1) d3 = self.down3(d2) m = self.mid(d3) u3 = self.up3(m) u2 = self.up2(u3) u1 = self.up1(u2) return self.out(u1)

这个骨架为了展示清晰做了大量简化,真正的 DDPM 每层之间还有残差连接、SiLU 激活、attention 和时间 embedding 的注入。实际工程里不建议自己从零拼 UNet,直接基于开源的openai/improved-diffusion或lucidrains/denoising-diffusion-pytorch改,省掉大量调试时间。但无论如何,你要清楚每个卷积的步长和通道变化,否则后面排查网络输出尺寸不匹配会很痛苦。

4.2 训练循环:均匀时间步采样、Adam 与 EMA

训练循环的核心动作就三步:随机采时间步、加噪声、让网络预测噪声。默认超参是 Adam、学习率 2e-4、batch size 128(按显存可以缩减)、EMA 指数平均系数 0.9999。EMA 在扩散模型里不是可选优化,而是稳定生成质量的常规操作,建议保留。

import torch import torch.nn.functional as F T = 1000 betas = torch.linspace(1e-4, 0.02, T) alphas = 1.0 - betas alphas_cumprod = torch.cumprod(alphas, dim=0) sqrt_alphas_cumprod = torch.sqrt(alphas_cumprod) sqrt_one_minus_alphas_cumprod = torch.sqrt(1.0 - alphas_cumprod) def train_step(model, optimizer, x0, device): # x0: 已归一化到 [-1, 1] batch_size = x0.shape[0] t = torch.randint(1, T + 1, (batch_size,), device=device) eps = torch.randn_like(x0) xt = ( sqrt_alphas_cumprod.to(device)[t - 1, None, None, None] * x0 + sqrt_one_minus_alphas_cumprod.to(device)[t - 1, None, None, None] * eps ) pred = model(xt, t) loss = F.mse_loss(pred, eps) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()

这里的t范围是 1 到 1000,取索引时用t - 1,和预计算的张量对齐。像素值必须在输入前先归一化,通常是x0 = (x0 - 0.5) * 2。如果你发现刚开始训练时 loss 一直在 2 以下徘徊,先检查数据归一化是否正确,这是最常见的起步问题。

4.3 生成循环:1000 步反向去噪的公式落进代码

训练完成后,采样是一个从纯噪声开始的循环。每一步先用网络估计噪声,再按公式得到当前步的均值,然后决定是否叠加一个随机噪声。论文默认最后一步不加噪声,因为如果加了,生成的亮度会有细微偏差。这一段代码值得反复对照公式看:

def sample(model, shape, device, use_ema_weights=True): model.eval() x = torch.randn(shape, device=device) for i in range(T - 1, -1, -1): t = torch.full((shape[0],), i + 1, device=device, dtype=torch.long) eps_pred = model(x, t) # 这一步是 3.2 节里的均值公式 x = ( x - betas[i] / sqrt_one_minus_alphas_cumprod[i] * eps_pred ) / torch.sqrt(alphas[i]) if i > 0: noise = torch.randn_like(x) x = x + torch.sqrt(betas[i]) * noise # 从 [-1,1] 还原到 [0,1] return (x.clamp(-1, 1) + 1) / 2

注意每一步x更新时,均值项里除以的是sqrt(alphas[i]),噪声项里乘的是sqrt(betas[i])。这两个系数极其容易写混。如果采样结果一塌糊涂,先打印中间几个t的值看是否出现 NaN,再逐行检查系数。EMA 权重的用法是把临时参数替换成滑动平均,PyTorch 里可以直接用torch.optim.swa_utils.AveragedModel包装,但要注意采样前需要调用一次update_parameters把均值参数落进去。

5. DDPM 复现避坑:五个高频翻车现场的排查记录

5.1 现象:训练 loss 降到 0.05,生成图却全是灰色噪点

这是我第一次复现时印象最深的问题。loss 一直降,监控曲线非常漂亮,但采样的结果看起来像蒙了一层雾的噪点,完全分不清类别。原因出在采样循环的噪声叠加逻辑上,具体是我在最后一步i=0时也加了噪声。最后一轮的噪声项应该被去掉,否则生成结果会残留一层高斯扰动,让整体对比度被压低。解决方式是严格按照论文的采样伪代码,最后一步x = x,不加torch.sqrt(betas[0]) * noise。此外,检查 EMA 是否生效也很关键,如果用了 EMA 但采样时没切到平均权重,结果也会偏糊。

5.2 现象:生成的图似乎“差不多”,但多样性明显不足

训练结束后生成的图每一张单独看都算干净,但十来张放一起全是同一个姿态的相近物体。这类问题大多出在x_T的起点上,也就是采样初始噪声的范围不对。扩散模型的先验是标准高斯分布,如果你手动把x_T缩放到了别的数值范围,比如从[-1,1]均匀采样,那生成结果只会落在整个分布的一个小邻域里。解决方式是把初始噪声严格设为torch.randn,并且检查有没有不小心对x_T做了 clamp。另一个常见相关问题是 $\bar{\alpha}_T$ 不够接近 0,导致最后几步模型经验不足,这时可以适当把末端 $\beta$ 调大,或者换成 cosine schedule。

5.3 现象:batch size 只能开到 8,训练一整天 loss 还没降下来

损失下降慢不全是模型问题,更多时候是有效 batch size 太小。DDPM 在 CIFAR-10 上原论文用的是 batch size 128,并且训练了好几万步。显存不足时不要只调 batch size,优先考虑混合精度训练。PyTorch 里用torch.cuda.amp.autocast()把前向和 loss 计算包起来,再用GradScaler做反向传播,通常能把 batch size 翻一倍。还有个办法是梯度累积,比如 batch size 32 分 4 次累积,也能接近 128 的效果。但要注意 EMA 不受梯度累积影响,它只跟参数更新次数相关。

5.4 现象:UNet 的中间特征尺寸对不上,报错集中在 down/up 路径

这个问题在小图上尤其容易犯。CIFAR-10 是 32x32,经过一次stride=2的卷积变成 16x16,再变成 8x8、4x4。但上采样路径用转置卷积恢复尺寸时,如果卷积核大小和 stride 搭配不好,输出尺寸会差一个像素。遇到这类报错先不要改网络结构,检查输入的边长是否满足 2 的幂次。32、64、128 都安全,35 这种尺寸在 UNet 里属于自找麻烦。工程里更简单的做法是直接把输入 reszie 到 64x64,省去一堆奇数尺寸处理逻辑。

5.5 现象:采样 1000 步太慢,一次生成 100 张图要等十几分钟

这是扩散模型落地时绕不开的痛,但不是 bug,而是数学模型决定的代价。解决思路有两个方向:一是对 T 的子集做重排,只在[1, T]里均匀取 100 个时间点做采样,效果损失不大;二是用后续的 DDIM 采样,把步数压到 20 到 50 步。原论文里没有 DDIM,但作为工程优化完全可以引入,因为它和 DDPM 共用同一个训练好的网络,不需要重新训练。要注意的是,步数压缩后,$\sigma_t$ 的处理方式要改成 DDIM 的形式,不能沿用原版采样公式,否则会出现明显的条带伪影。

6. 别等 1000 步:DDIM 采样、条件控制与质量验收的实战顺序

6.1 子序列采样与 DDIM 的共同点

如果你已经成功跑通 1000 步采样,接下来最值得花时间做的是把生成速度拉上去。DDIM 的核心思想是:采样时不严格走前向过程定义的马尔可夫链,而是构造一份更快、每步跨度更大的确定性轨迹。它的更新公式比 DDPM 简洁很多,无需随机噪声项,但要求预先建立一个子序列,比如从 1000 个时间步中均匀抽出 50 个点,然后按相邻时间点计算步长。实现时不要把原模型的betas索引搞混,子序列里的每一对(prev_t, cur_t)决定了噪声缩放的系数。如果你只改采样步数,不改公式,那生成结果会明显发糊。

6.2 条件生成其实只改两行

DDPM 网络本身并不区分条件还是无条件。做类别条件生成时,最直接的改法是把类别标签做成 embedding,在时间 embedding 之后拼接到 UNet 的隐层里。具体实现时,可以在每一层残差块前加一个nn.Embedding(num_classes, channel)和一层nn.Linear,把标签向量广播到空间维度再相加。这个改动对训练流程完全没有影响,只在模型 forward 里多传一个label参数。条件模型对 FID 的提升往往非常可观,因为它解决了无条件模型里“一类图吃掉另一类”的模式坍缩问题。

6.3 质量验收的习惯:先看样本,再谈指标

最后说一个我自己的验收习惯。复现 DDPM 时,不要一上来就盯 FID。FID 计算需要足够的样本数量,通常至少要 5000 到 10000 张,而且采样分布的细微偏移会被指标放大。更高效的做法是:固定一组初始噪声z,训练过程中每隔几百步用同一组z生成样本,把它拼成网格图观察。同一组z在训练前后的变化能直观反映模型学到了什么,如果中途出现细密噪点说明网络容量不足,如果出现大面积色块说明训练还没收敛。等网格图稳定了再去算 FID,才有意义。

我现在的默认工作流是:先用 DDPM 原版采样 T=1000 步验证代码正确性,再切到 DDIM 50 步验证速度收益,最后才考虑加标签、改损失、调噪声表。这个顺序从来没让我翻过车,也省下了大量“指标很好但图没法看”的无效调参时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询