
简介这份PDF是生成式AI领域经典论文《Denoising Diffusion Probabilistic Models》英文原版作者为加州大学伯克利分校的Jonathan Ho等人发表于NeurIPS 2020。内容面向具备一定深度学习基础、希望系统理解扩散模型原理的研究者与AI工程师尤其适合关注ChatGPT、AIGC等前沿生成技术的读者。资源包共1个文件类型为pdf大小约9.79MB体积精简方便离线阅读与打印。论文结构完整从摘要、引言到方法、实验与结论均有收录作者从非平衡热力学视角引入扩散概率模型系统推导前向加噪与反向去噪过程重点讲解去噪评分匹配与Langevin动力学之间的联系以及加权变分下界的构造思路并给出CIFAR10、LSUN等数据集上的生成效果与评估指标。目前已有430人学习下载既适合逐字精读原文也可作为复现扩散模型、撰写技术综述或准备AI面试时的核心参考资料。1. 为什么 DDPM 到现在还是绕不开一篇 PDF 吃透生成模型的分水岭手头这份《Denoising Diffusion Probabilistic Models》PDF是 2020 年 NeurIPS 上的那篇经典论文作者是 UC Berkeley 的 Jonathan Ho、Ajay Jain 和 Pieter Abbeel。它定义了扩散模型的标准训练方式和采样器CIFAR10 无条件下训练拿到 FID 3.17、Inception Score 9.46256×256 的 LSUN 上样本质量接近 ProgressiveGAN。现在 AI 绘画工具里大量出现的“去噪”“潜变量”“噪声预测”这些词源头基本都能追溯到这里。这篇文章不打算复述摘要而是把这份 PDF 里的理论链条、公式推导和复现时真正卡人的参数细节拆开落到能跑的训练循环和采样器上。适合两类人一是被各种开源实现绕晕想回到原始论文把公式看懂的人二是已经有梯度下降基础打算从零复现一遍 DDPM 做二次开发的人。2. 从变分下界到去噪得分匹配参数化选择背后的理论拐点2.1 扩散模型是一台强行加噪的马尔可夫链DDPM 的定义其实很短。它把数据 x0 当作潜变量模型中的观测变量x1 到 xT 是和 x0 同维度的潜变量。前向过程 q(x1:T|x0) 是一个固定的马尔可夫链每一步只做一件事把当前状态朝着零方向收缩一点同时叠加一个高斯噪声。公式里写的是 q(xt|xt−1) N(xt; √(1−βt) xt−1, βt I)其中 β1 到 βT 是一次性定好的方差调度。这个设计有一个关键性质当 βt 足够小的时候反向转移也可以用高斯分布来近似所以作者把反向过程 pθ(xt−1|xt) 也定义成高斯转移。训练目标就是常规的变分下界。论文里的公式 (3) 把负对数似然的上界写成三项LT 对应从数据加噪到纯噪声那一步的 KL 散度L0 对应最后从 x1 恢复到 x0 的离散解码器中间 L1 到 LT−1 则是每一层反向转移与真实后验 q(xt−1|xt, x0) 之间的 KL 散度。这几个 KL 散度都在两个高斯之间计算所以全部有闭式解不需要蒙特卡洛采样。这也是 DDPM 训练能稳定的一个前提跟早年很多隐变量模型靠高方差估计完全不是一回事。从工程视角看这份 PDF 最重要的贡献不是提出“扩散”这个概念而是把训练目标一步步揉成了一个非常简单的形式。读论文时要注意一个细节前向过程虽然叫“扩散过程”但它完全没有可学习参数βt 直接定成常数。这意味着 LT 在训练中是个常数可以忽略。真正需要优化的是中间那一串 KL 散度而这一串最终会被化简成去噪目标。2.2 重参数化从预测后验均值到预测噪声前向过程有一个方便的性质由于每一步都是高斯给定 x0 之后任意时刻的 xt 都可以闭式计算。论文公式 (4) 给出 q(xt|x0) N(xt; √ᾱt x0, (1−ᾱt) I)其中 αt 1−βtᾱt 是 α1 到 αt 的连乘。这个式子意味着训练时不需要真的跑 T 步前向过程随机抽一个时间步 t用 x0 和一个标准高斯噪声 ε 就能直接合成 xt √ᾱt x0 √(1−ᾱt) ε。反向过程的均值 µθ(xt, t) 有两种自然的参数化方式。第一种最简单直接让网络去预测前向过程的后验均值 µ̃t(xt, x0)也就是把去噪结果直接当作回归目标。第二种是重参数化之后产生的把公式 (4) 代入后验均值公式会发现 µθ 可以等价地表示成由噪声 ε 构造出来的式子。论文公式 (11) 写得很清楚µθ(xt, t) (1/√αt) · (xt − (βt/√(1−ᾱt)) · εθ(xt, t))。这里的 εθ 是一个以 xt 和时间步 t 为输入的函数近似器输出的是预测噪声。为什么非要把均值预测改成噪声预测因为代入之后中间那一串 KL 散度会被简化成 E[||ε − εθ(xt, t)||²] 的形式和去噪得分匹配的目标函数几乎一样。这是 DDPM 论文的一个核心洞察用变分推断去拟合一条有限时间的 Langevin 采样链和用去噪得分匹配去训练一个多尺度噪声预测器数学上是等价的。作者的消融实验也印证了这一点直接预测 x0 在训练早期会造成明显更差的样本质量而预测 ε 从头到尾都更稳。后面所有扩散模型的实现不管是 Stable Diffusion 还是各种加速采样器基本都是沿着这个 ε-prediction 的路线走下去的。2.3 简化目标函数到底丢了什么论文第 3.4 节给出了最终的简化目标。完整的变分下界每一项有一个权重 βt² / (2σt² αt (1−ᾱt))这个权重随时间步变化很大早期 t 对应的权重很小后期 t 权重很大。直接按这个权重训练会让模型过分关注后期的高噪声样本质量反而不见得好。作者做了一个非常工程化的决定把权重全部丢弃让所有时间步等权重地去回归噪声。这就是 L_simple E[||ε − εθ(xt, t)||²]。后面的实验表明简化目标不仅在数值上更好优化采样质量也更好。丢掉权重不等于丢掉理论框架。LT 是常数可以忽略L0 则被设计成一个独立的离散解码器。图像像素被线性缩放到 [−1, 1] 之后论文用公式 (13) 那种分段积分方式计算每个像素的离散对数似然。这样做的好处是变分下界仍然对应真实离散数据的无损编码长度不需要额外往数据里注入噪声。采样结束展示结果时直接把 µθ(x1, 1) 取出来不加噪声这是作者一个很小的实践细节却避免了很多复现者遇到的“结果带雪花点”问题。3. 前向加噪与噪声表把 βt、ᾱt、σt 三套符号理清再写代码3.1 固定线性噪声表T1000β 从 1e-4 增长到 0.02论文中最标准的配置是 T1000β1 取 1e-4βT 取 0.02中间线性插值。之所以叫“固定”是因为 βt 不参与训练。使用前需要把三个量全部预计算出来βt、αt 1−βt、ᾱt Π αs。这三组数组长度都是 T要预先放到模型所在的设备上。一个很容易踩的坑是只算了 β 和 α忘记算累积乘积 ᾱt后面几乎所有闭式公式都会用到它。def make_noise_schedule(T1000, beta_start1e-4, beta_end0.02): betas torch.linspace(beta_start, beta_end, T) alphas 1.0 - betas alphas_cumprod torch.cumprod(alphas, dim0) return betas, alphas, alphas_cumprod逻辑上很好理解linspace 生成从 1e-4 到 0.02 的 1000 个值α 是 β 的互补量ᾱt 是 α_t 累积乘积。注意 cumprod 返回的第一个值就是 α1不是 α0数组索引从 0 开始所以后续代码里 t0 时取到的其实是 β1。这个错位在实际实现中很常见我习惯在生成后打印首尾几个值确认单调性。后面的实现里 σt² 有两种可选方案一种是直接用 βt另一种是用公式 β̃t (1−ᾱt−1)/(1−ᾱt) · βt这个稍后会专门讲。后续很多工作把线性调度改成了 cosine 调度那是为了后期加噪不要太快但这份 PDF 的结论就是线性调度在 CIFAR10 和 LSUN 上都够用。3.2 用闭式采样直接拿 xt告别逐步扩散循环训练时最核心的一个函数是前向加噪。有了公式 (4)不需要真的循环 T 步只需要随机采样一个 t然后按 ᾱt 和 1−ᾱt 的平方根对 x0 和噪声做加权相加。注意这里噪声 ε 必须和 x0 形状完全一致而且要在函数外部生成再传入。之所以要返回 noise是因为 L_simple 的回归目标就是它。def q_sample(x0, t, alphas_cumprod): sqrt_alphas_cumprod torch.sqrt(alphas_cumprod)[t].view(-1, 1, 1, 1) sqrt_one_minus_alphas_cumprod torch.sqrt(1.0 - alphas_cumprod)[t].view(-1, 1, 1, 1) noise torch.randn_like(x0) xt sqrt_alphas_cumprod * x0 sqrt_one_minus_alphas_cumprod * noise return xt, noise这里 t 的形状必须是一维的长度等于 batch size。sqrt_alphas_cumprod 通过索引 t 取出对应值再用 view 扩展成 (batch, 1, 1, 1) 才能和图像张量广播。如果你少写了这个 viewPyTorch 会在乘法时报维度不匹配或者因为广播规则静默地给出错误结果。这个函数的输入输出都是形状敏感的x0 是 (batch, channels, height, width)返回的 xt 和 noise 形状完全相同。阈值上如果数据已经缩放到 [−1, 1]xt 也在同等量级如果没缩放训练会在早期出现 loss 下降但生成图像发灰的怪现象。3.3 σt² 的两个选择βt 与 β̃t哪个更“正统”反向过程的方差 σt² 在论文里被设成不训练的时间依赖常数作者明确做了两个极端选择σt² βt以及 σt² β̃t (1−ᾱt−1)/(1−ᾱt) · βt。这两个值的理论含义不同。βt 是在 x0 服从标准正态分布的假设下最优的方差上界β̃t 是 x0 固定为一个点时后验方差的表达式。实际图像数据的坐标大致在 [−1, 1] 区间内真实的最优值应该夹在这两个极端之间。作者实验后说两个选择效果接近所以复现时选哪个都不算错。但从数值稳定性来看我一般默认用 β̃t因为它和真实后验 q(xt−1|xt, x0) 的方差一致在采样早期不容易出现方差过大的抖动。实现时只需要额外算一个数组和 βt 平行保存def make_sigma_squared(betas, alphas_cumprod): alphas_cumprod_prev torch.cat([torch.tensor([1.0]), alphas_cumprod[:-1]]) sigma_squared (1.0 - alphas_cumprod_prev) / (1.0 - alphas_cumprod) * betas return sigma_squared这段代码把 ᾱt−1 定义为数组整体右移一位并在头部补 1保证 t1 时能引用到 ᾱ01数学上不会越界。真正写采样器时用的是 σt 而不是 σt²也就是要对这个数组开根号。注意 t0 的时候不应该加任何随机噪声这一点后面采样循环里会专门处理。这样一套数值流程走下来前向过程就算完全定住了。后面模型训练的输入就是这套调度参数不需要再改。4. PyTorch 训练循环与采样器从损失函数到去噪图像的完整落地4.1 训练循环数据归一化、随机时间步、MSE 损失训练循环本身很短但每个细节都有讲究。数据先缩放到 [−1, 1]这一步不是可选项而是硬前提因为反向过程的起点 p(xT) 是标准正态分布如果输入像素还在 [0, 1] 范围模型要同时学会“去噪”和“范围迁移”收敛会慢很多。随后每个 batch 随机采样一个时间步 t用 q_sample 生成带噪图像把 xt 和时间步 t 一起喂给模型让模型预测噪声 ε损失就是预测噪声和真实噪声之间的均方误差。这样每个 step 只优化一项 L_simple而不是完整的变分下界整体训练效率和稳定性都好得多。def train_step(model, optimizer, x0, device, T, alphas_cumprod): x0 (x0 - 0.5) * 2.0 # 从 [0,1] 缩放到 [-1,1] batch_size x0.shape[0] t torch.randint(0, T, (batch_size,), devicedevice).long() xt, noise q_sample(x0, t, alphas_cumprod) noise_pred model(xt, t) loss F.mse_loss(noise_pred, noise) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()代码里的 t 必须是 long 类型因为后面要用它索引数组。randint 的上界取 T 而不是 T−1这样 t 的取值范围是 0 到 999恰好对应论文里的 β1 到 β1000。模型输出和真实噪声都是 (batch, channels, height, width) 的形状mse_loss 默认对所有元素求平均。这里没有对 loss 做任何加权论文实验表明等权重训练效果最好。优化器一般用 Adam学习率 2e-4batch size 在 CIFAR10 上取 128 是典型配置如果你显存不够batch 缩到 32 也能跑但 FID 会有可感知的下降这是工程上的常见妥协。4.2 采样器去噪循环的逐行拆解采样是训练的反向过程。从标准正态分布 xT 出发逐步执行 T 次去噪每次用模型预测当前噪声从 xt 中按公式移除一部分再加回一个小幅随机扰动。这个扰动项就是 σt z作用是保持生成结果的多样性。如果完全不加热噪声生成结果会变成确定性映射细节纹理容易僵化。def p_sample_loop(model, shape, T, alphas, alphas_cumprod, sigma_squared, device): x torch.randn(shape, devicedevice) for t in reversed(range(T)): t_tensor torch.full((shape[0],), t, devicedevice, dtypetorch.long) noise_pred model(x, t_tensor) alpha alphas[t] alpha_cumprod alphas_cumprod[t] sigma torch.sqrt(sigma_squared[t]) coef1 1.0 / torch.sqrt(alpha) coef2 (1.0 - alpha) / torch.sqrt(1.0 - alpha_cumprod) x_mean coef1 * (x - coef2 * noise_pred) if t 0: z torch.randn_like(x) x x_mean sigma * z else: x x_mean return xreversed(range(T)) 从 999 一路走到 0。每一步都重新构造一个整批相同 t 的张量避免直接传 Python int 导致模型里的时间嵌入维度判断出错。coef1 和 coef2 分别对应公式里的 1/√αt 和 (1−αt)/√(1−ᾱt)这两个系数组合出来就是去掉当前预测噪声后的干净均值 x_mean。关键边界条件在 t0最后一步直接输出 x_mean不再加噪声。如果在这里仍然采样生成的图像会出现肉眼可见的雪花噪点这是复现时最高频的问题之一。采样循环的速度就是 T 次模型前向的时间1000 步在普通 GPU 上生成一张图需要几秒到十几秒后续加速采样器DDIM 那类就是从这个循环基础上改出来的。4.3 模型自变量的两个细节时间步嵌入与条件输入训练循环里 model(xt, t) 这个接口看起来简单但模型如何利用时间步 t 是影响性能的细节。论文里用的是 U-Net 结构输入有三部分带噪图像 xt、时间步 t 的嵌入向量、以及可选的类别标签。时间步嵌入最常见的实现是正弦位置编码把标量 t 映射成一组高频向量再通过一个 MLP 投影到与 U-Net 特征通道数一致。这里并不需要发明新东西但也别省略这步。直接把 t 作为单一标量拼进网络会让模型很难区分不同噪声水平FID 会掉一截。def get_time_embedding(t, dim256): half dim // 2 freqs torch.exp(torch.arange(half) * (-math.log(10000.0) / (half - 1))) args t.float() * freqs return torch.cat([torch.sin(args), torch.cos(args)], dim-1)这段代码把一个时间步 t 编码成 256 维的向量频率从低到高覆盖了从整图结构到细节纹理的不同尺度。t 可以是整数张量也可以是归一化到 [0, 1] 的浮点张量两者都有开源实现在用。关键是嵌入向量必须在每个 time step 保持确定性同一个 t 永远得到同一个向量模型才能学会把噪声水平映射到对应的去噪行为。综合来看训练循环、采样循环和模型输入是 DDPM 复现的三个支柱哪一边省略细节都会让实验结果变得难以解释。5. 避坑指南复现 DDPM 时最难查的五个翻车点复现这份论文的过程里真正让人卡住大半天的往往不是公式而是数值范围、形状对齐、边界条件这一类小问题。下面五条是我踩过或者帮别人排查时见过最多的高频翻车点每一条都按现象、原因、解决来写。5.1 数据没归一到 [−1,1]loss 在降但生成结果发灰现象训练几百步后 loss 降得挺漂亮但采样出来每张图都是灰蒙蒙一片对比度极低像隔了一层雾。 原因反向过程的起点是标准正态分布模型内部的所有特征也默认输入在零附近。如果数据还停留在 [0, 1] 范围前向加噪的 xt 整体偏正模型学到的去噪方向也随之偏移。 解决在进入训练循环之前把图像张量从 [0, 1] 线性映射到 [−1, 1]也就是 (x−0.5)×2。采样结束后保存图片前再反向操作一次把输出从 [−1, 1] 映射回 [0, 1]。这一步看起来简单但遗忘率极高。5.2 广播维度没对齐前向加噪出现静默错误现象训练不报错但生成结果要么全黑要么带有明显的棋盘格纹理。模型像是学了什么东西但学到的不是合理的图像分布。 原因q_sample 里索引生成的系数张量没有 reshape 成 (batch, 1, 1, 1)直接和 (batch, C, H, W) 的图像相乘导致系数被错误地广播到通道或空间维度部分位置被错误缩放。 解决养成写 .view(-1, 1, 1, 1) 的习惯且在使用前打印系数的形状。t 的一维形状也要做成和 batch size 完全一致不要用标量 t 起步。这种 bug 的最大风险是不会立刻报错而是悄悄破坏所有后续训练数据拖到很晚才发现。5.3 采样最后一步还在加随机噪声图像全是雪花点现象采样输出勉强看得出轮廓但整张图布满高频噪点像是没画完就保存了。 原因把 t0 的采样也当成了普通时间去噪步骤强行叠加了 σ0·z。按论文定义最后一步应该直接输出均值 x_mean不加随机扰动。 解决在 p_sample_loop 里把 t0 和 t0 分开处理t0 时 z 不参与计算直接返回 x_mean。另一个容易混淆的点是这里 z 的生成条件应该是“t0”而不是“采样时有没有开启随机种子”用确定性采样的想法去加噪声同样会得出一堆突兀的颗粒。5.4 σt 和 βt 两个方差概念混用导致采样发散现象训练正常前向加噪正常但采样结果在中间步骤出现数值爆炸某些通道变成 NaN 或者极端正负值。 原因把 βt 直接当作采样项的方差系数。βt 是前向过程噪声的方差而反向采样需要的方差是 σt²两者在数值上并不完全相等。βt 在前向过程里要和图像缩放系数配合使用反向过程里如果直接拿 βt 开根号当扰动幅度高噪声步会加得过大。 解决严格按照论文定义单独计算 σt² 数组默认用 β̃t 那一版本。如果坚持用 βt 作为反向方差务必确认 αt 和 ᾱt 的配合。建议在采样循环里逐步打印 xt 的均值和标准差一旦发现单调发散立即中断。5.5 没收敛就评估 FID数字高得让人想放弃项目现象训练才几十轮拿生成的图和测试集一算 FID出了一个几百的数值立刻觉得模型思路有问题四处翻代码找 bug。 原因扩散模型在早期训练时还停留在“从噪声中恢复低频轮廓”的阶段远离高分辨率细节。没收敛的模型没法反映真实能力而且 FID 本身对样本数量、预处理方式、特征层选择都非常敏感不同仓库算出来的数字并不严格可比。 解决训练初期不测 FID只看 L_simple 下降曲线和固定种子的生成预览。等损失曲线明显变平再用 5 万张以上生成样本和测试集做评估。如果只是复现论文结论优先复现“损失下降”“样本肉眼可看”“渐进解码形态符合预期”三件事再讨论指标。另外跨随机种子的小差距说明不了模型优劣除非你已经确认采样流程完全一致。6. 验证与进阶训练之外值得多花时间做的事6.1 三个低成本验证手段训练运行起来之后不要只盯着 loss。第一个必须做的是固定随机种子的渐进采样也就是把采样循环里的中间状态 x_t 保存下来隔几百步存一张观察去噪过程是否从纯噪声逐渐浮现结构。如果中间状态一直是模糊色块没有一个逐步清晰的趋势说明模型还没学会多尺度去噪这比 FID 数值暴露问题更早。第二个是前向加噪的可视化拿同一张 x0分别按 t100、t500、t900 加噪确认越靠后图像越接近纯噪声。这一步能快速验证调度参数和索引是否写对。第三个是噪声预测的热力图直接看模型对一张已知噪声的 xt 预测出的 εθ 和真实 ε 的残差分布。残差应该接近随机噪声如果出现结构性图案说明模型在抄捷径训练目标并没有被真正优化。def quick_validate(model, fixed_x0, alphas_cumprod, T): for t in [100, 500, 900]: xt, noise q_sample(fixed_x0, torch.tensor([t]), alphas_cumprod) pred model(xt, torch.tensor([t])) mse F.mse_loss(pred, noise).item() print(ft{t}, mse{mse:.4f})这个验证块只跑三次前向几分钟内就能给出反馈。MSE 应该随 t 增大而增大因为高噪声步的回归目标更难如果 t100 的误差比 t900 还大说明模型没有建立好噪声水平的分层感知时间嵌入的编码多半有问题。6.2 进阶方向从噪声表对比再到后续采样器这份 PDF 跑通之后值得做的第一个扩展是换噪声表。把线性调度换成 cosine 调度重新训练同一个模型对比固定种子的生成质量和损失收敛速度。很多复现者会惊讶于一个看起来细微的调度变化对训练稳定性的影响这比盲目调学习率更有启发。第二个扩展是写一个确定性采样器对比它与 DDIM 这类后续工作的加速关系。DDIM 的思路是从这份论文的采样循环中衍生出来的原理并不复杂但只有自己手写过一遍才会理解为什么它能用更少的步数逼近同一分布。第三个方向是回到公式 (5) 里的 L0 项拿一小批验证集计算真实的 bits/dim跟论文报告的数字对照这时候你对变分下界每个分量的理解会比只跑 L_simple 深得多。从那以后我每次换数据集或调度参数都会强制走一遍上面的 quick_validate再决定要不要继续往下训练。这个过程帮我挡掉了不下五次因为索引写错而浪费大量算力的翻车希望帮到你。本文还有配套的精品资源点击获取