☰
PyTorch实现DDPM:扩散模型训练与采样全解析
2026/10/14 5:44:22 网站建设 项目流程

简介:面向深度学习与扩散模型研究者的一份可运行PyTorch源码包,完整复现了DDPM(去噪扩散概率模型)的构建、训练与采样全流程。压缩包共27个文件,大小约22.21MB,包含8个Python脚本、1个模型权重文件、MNIST训练与测试数据集、依赖清单及README说明;脚本按数据获取、DDPM类定义、去噪神经网络、训练与采样等模块划分,目录结构清晰,便于直接运行和二次修改。目前已有225人学习下载。资源采用U-Net作为去噪网络,在MNIST上完成了从数据集处理、模型设计到训练与结果分析的完整实验,生成图像在视觉上与真实样本接近;同时对比了不同网络架构对生成效果的影响,并总结了复现过程中的关键点、常见问题与注意事项。适合希望快速上手扩散模型的学生、研究者或工程人员,是一份可直接运行、省去从零搭建时间的参考实现。

1. 扩散模型不是黑匣子:DDPM,一个能稳定生成图像的 PyTorch 实现

第一次跑通 DDPM(Denoising Diffusion Probabilistic Models)时,最让我意外的不是生成效果,而是它的训练居然比 GAN 简单那么多:没有判别器,没有对抗损失,只有一个预测噪声的 MSE 和一个不断加噪的前向过程。把这两件事拼起来,居然就能从纯噪声里还原出清晰的图像。这份「DDPM 的 PyTorch 实现[可运行源码]」正是围绕这条主线展开的——它把前向扩散、UNet 主干、训练循环和反向采样完整地串在一起,适合两类人:一类是刚接触扩散模型、想搞清楚「加噪再去噪」到底怎么落地的初学者;另一类是已经跑过分类模型、想把生成式方法快速用手跑一遍的工程师。下面我按自己复现这份源码时走过的路线来拆解它,哪些是核心逻辑,哪些参数不能乱动,哪些地方玄学但有效,都放在后面。

2. 前向扩散与 UNet:先搞懂噪声怎么加、模型怎么接

2.1 前向过程的数学结构与代码对应

DDPM 的前向过程看着复杂,其实核心只有一句话:给定一张干净图像 x0,按时间步 t 逐步往里面加高斯噪声,加噪加到 T 步之后,图像几乎变成纯噪声。这个过程的公式是:

xt = sqrt(alpha_bar_t) * x0 + sqrt(1 - alpha_bar_t) * epsilon

其中 epsilon 是标准高斯噪声,alpha_bar_t 是前 t 步噪声调度 alpha 的累积乘积。这里最关键的一点是:DDPM 的作者在推导时发现,不需要一步步把 x0 加到 xt,可以直接用这个闭合公式一步跳到任意时间步 t。我在复现时就是靠这一条跳过了一个大坑——如果按朴素方式循环加噪,训练速度会慢一个量级,而且在梯度反传时容易出数值问题。

噪声调度的选择,在源码里通常是线性调度(linear schedule):beta 从 1e-4 均匀升到 0.02,T 取 1000。这个范围不是拍脑袋定的,理论上讲,beta 太大会让前向过程过早进入纯噪声,模型来不及学中间状态;太小则 1000 步结束之后噪声占比不足,xT 不接近标准高斯分布,采样起点就会偏离训练分布。所以复现时尽量不要把 T 改成 500 或 2000 去「试效果」,除非你连 alpha_bar 的累积计算一并调整。我在实际测试中把 T 改成 500 后,训练 loss 确实降得更快,但生成的图明显有偏灰的色带,原因就是噪声没有完全盖过数据分布。

看源码里对应的实现,一般长这样:

# diffusion.py 中的前向加噪部分 def q_sample(x_start, t, alphas_cumprod, noise=None): """ x_start: 干净图像,形状为 [B, C, H, W] t: 随机采样的时间步,形状为 [B] alphas_cumprod: 预先算好的累积 alpha 乘积 """ if noise is None: noise = torch.randn_like(x_start) # 每个像素独立的标准高斯噪声 sqrt_alphas_cumprod_t = torch.sqrt(alphas_cumprod[t]) sqrt_one_minus_alphas_cumprod_t = torch.sqrt(1.0 - alphas_cumprod[t]) # 注意这里的形状调整:t 是 [B],要变成 [B, 1, 1, 1] 才能和图像广播相乘 sqrt_alphas_cumprod_t = sqrt_alphas_cumprod_t[:, None, None, None] sqrt_one_minus_alphas_cumprod_t = sqrt_one_minus_alphas_cumprod_t[:, None, None, None] x_noisy = sqrt_alphas_cumprod_t * x_start + sqrt_one_minus_alphas_cumprod_t * noise return x_noisy, noise

代码逻辑上分三步:先判断外面是否传入了 noise,如果没有就自己生成一份标准高斯噪声;然后把累积 alpha 的平方根和 1 减去它后的平方根算出来;最后按公式做线性组合。这里有个容易看走眼的地方:t 作为整数下标进入 alphas_cumprod 张量后,得到的形状是 [B] 而不是 [B, 1],如果不加[:, None, None, None]这层 reshape,PyTorch 会报广播错误,或者更隐蔽的情况下广播到错误的维度,导致加噪结果错位。

参数说明上,alphas_cumprod 的定义是torch.cumprod(1 - betas, dim=0)得到的。也就是说先有 betas,再有 alphas = 1 - betas,最后算累积乘积。如果你想改用余弦调度(cosine schedule),需要自己按论文公式重算 betas,不能直接复用线性调度的 alphas_cumprod,否则前向过程的信噪比分布和假设完全不匹配,训练出来的模型采样时会发现后半段去噪步长很奇怪。

2.2 UNet 的设计选择和时间步嵌入

前向过程负责制造训练样本,真正干活的是逆过程的神经网络。DDPM 用的主干是 UNet,理由不复杂:图像生成任务里,输入和输出都是完整图像,UNet 通过下采样提取语义特征、再上采样还原细节的结构,天然适合这种 dense prediction 任务。但 DDPM 里的 UNet 比图像分割用的 UNet 多了两个关键设计。

第一个是时间步嵌入(time embedding)。因为模型要处理不同时间步的噪声图像,每一步对应的噪声强度不一样,模型必须知道「现在这个输入是在哪一步被加噪的」,否则它无法区分细节该恢复多少。源码里通常的做法是把 t 先做正弦位置编码,再过一个小的 MLP,得到一个向量,然后在每个残差块里通过 scale 和 shift 的方式注入特征图。我一般会把 embedding 维度设成 256 或者和 UNet 第一层通道数一致,这不算敏感参数,但太小(比如 32)会明显影响训练速度——模型需要花更多迭代才能学会时间条件的作用。

第二个是残差块加注意力。在 UNet 的中间层(分辨率最低的地方)插入多头注意力模块,是让扩散模型能生成全局一致结构的关键。没有注意力时,模型很容易出现「局部清晰、整体构图散乱」的问题,在简单数据集上不明显,一旦换到分辨率稍高或者物体结构复杂的数据集,问题就会被放大。源码里一般只在中层和最深一两层加注意力,避免计算量失控。

UNet 各层通道数对显存和效果的影响也很直接。一个常见的配置是初始通道 64,然后每下采样一次翻倍,到最底层 512;上采样路径再对称降回来。如果你的显存只有 8GB 左右,初始通道要降到 32,或者减少下采样层数,否则直接在 64x64 分辨率上训练会 OOM。但要注意,通道数减半之后,层数如果不变,感受野没变,生成效果下降主要是模型容量不够,不是训练迭代数的问题,需要同时增加训练步数来弥补。

# model.py 中的基础 UNet 块结构(简化版) class UNetBlock(nn.Module): def __init__(self, in_ch, out_ch, time_emb_dim, has_attn=False): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1) self.norm1 = nn.GroupNorm(8, in_ch) self.norm2 = nn.GroupNorm(8, out_ch) self.time_mlp = nn.Sequential( nn.SiLU(), nn.Linear(time_emb_dim, out_ch * 2) ) def forward(self, x, t_emb): h = self.norm1(x) h = nn.functional.silu(h) h = self.conv1(h) # 注入时间条件:让模型知道当前噪声程度 time_scale_shift = self.time_mlp(t_emb) scale, shift = time_scale_shift.chunk(2, dim=1) scale = scale[:, :, None, None] shift = shift[:, :, None, None] h = h * (1 + scale) + shift h = self.norm2(h) h = nn.functional.silu(h) h = self.conv2(h) return h + x if in_ch == out_ch else h + self.skip_conv(x)

这里我把时间注入放在第一个卷积之后、归一化之前,这是比较常见的 FiLM 式做法。scale 和 shift 分别对特征图做乘性和加性变换,让模型能根据时间步动态调整特征响应的幅度。注意 skip connection 处理:如果输入输出通道不一致,必须加一个 1x1 卷积把 x 投影到 out_ch,否则维度对不上直接报错。

我在第一次复现时忽略了一个细节:GroupNorm 的组数。很多分类模型里习惯用 BatchNorm,但扩散模型里 BatchNorm 是坑——batch size 小的时候统计量抖动很大,生成效果非常不稳定。用 GroupNorm 就稳得多,基本不受 batch size 变化影响。源码里一般用 num_groups=8,如果你的输入通道数是 64,8 组就是每组 8 个通道,这个比例是安全的。改通道数时记得同时检查组数是否能被整除。

3. 训练思路与代码实现:预测噪声而不是预测图像

3.1 为什么损失函数选 MSE 噪声预测

DDPM 训练的核心逻辑说起来反直觉:网络输入一张加了噪声的图像和对应时间步,输出不是去噪后的图像,而是「我加的噪是什么」。也就是预测噪声 epsilon,然后用预测值和真实噪声算 MSE 损失。为什么不直接预测 x0?理论上预测 x0 也可以收敛,但实践中有两个问题。第一,x0 的数值范围在归一化之后是 [-1, 1],而不同时间步加入噪声后的 xt 分布方差差别很大,直接预测 x0 在不同时间步上损失贡献不均衡,模型会更倾向于优化噪声占比小的步,导致高频细节学不好。第二,预测噪声在数学推导中和变分下界直接对应,训练更稳。

所以源码里的损失函数一般就是这段逻辑:

# diffusion.py 中的训练损失计算 def p_losses(denoise_model, x_start, t, noise=None): """ denoise_model: UNet,输入噪声图像和时间步,输出预测噪声 x_start: 干净原图 t: 随机采样的时间步 """ if noise is None: noise = torch.randn_like(x_start) x_noisy, _ = q_sample(x_start, t, alphas_cumprod, noise) # 模型输出通道数和输入一致,都是 3 通道图像 predicted_noise = denoise_model(x_noisy, t) # 直接对原始噪声计算 MSE,不做加权 loss = F.mse_loss(predicted_noise, noise) return loss

这里有个值得注意的选择:损失没有对不同时间步做加权。原论文在简化目标里就是简单 MSE,不加任何权重。我之前试过给中间步加权重,理论上应该更合理,实际效果反而变差。原因大概是中间步的样本量足够多,网络容量有限,不加权重时模型会自动把容量分配给更常出现的噪声尺度,加权反而破坏了这种自然的容量分配。

参数说明:训练时 t 是从 0 到 T-1 里均匀随机采样,每个 batch 内每张图的 t 可以不同,这个随机性是训练稳定的关键。如果改成固定顺序采样,比如从 0 到 999 循环,模型会在相近的噪声级别上反复训练,泛化能力大打折扣。

3.2 训练循环核心与超参设定

训练循环本身比常见分类任务还简单,主要步骤是:从数据加载器取一个 batch 的图,归一化到 [-1, 1],随机采样 t,计算加噪后的图,送入 UNet 预测噪声,算 MSE,反传更新。这里没有判别器、没有对抗损失、没有梯度惩罚,代码写起来反而清爽。

# 训练配置示例 --batch_size 32 --epochs 200 --lr 1e-4 --T 1000 --image_size 64 --channels 3 --save_dir ./checkpoints

这个配置在单张 24GB 显卡上可以跑 64x64 的 CIFAR-10 级别数据集。如果你的显卡是 12GB,batch size 要降到 16 甚至 8,但注意学习率要跟着降——我发现 batch size 从 32 降到 8 时,lr 保持 1e-4 会导致 loss 曲线明显震荡,降到 5e-5 就平稳很多。

训练循环的骨架:

# train.py 核心循环 optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.0) for epoch in range(args.epochs): for step, (images, _) in enumerate(train_loader): images = images.to(device) # 图像归一化到 [-1, 1],扩散模型训练的前置条件 images = images * 2.0 - 1.0 t = torch.randint(0, args.T, (images.size(0),), device=device) loss = p_losses(model, images, t) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() if step % 100 == 0: print(f"Epoch {epoch} Step {step} Loss {loss.item():.4f}")

AdamW 的 weight_decay 我建议保持 0。这不是原论文的明确要求,但我在复现中发现加 L2 正则对扩散模型没有正面作用,因为模型本身没有过拟合到需要正则化,反而拖慢了收敛。梯度裁剪设 max_norm=1.0 是一个性价比很高的操作,扩散模型训练早期梯度可能突然变大导致 loss 变成 NaN,裁剪之后这种问题基本消失。

学习率的设定上,我用经验值 1e-4 起步,配合 cosine 衰减到 0。没有用 warmup 也能训,但损失在最初几千步内会从很高的值快速下降,如果不想看到 loss 曲线早期飞太高,加一个 500 步的 warmup 会更平滑。EMA(指数滑动平均)在训练结束前的作用很关键,这个放到后面专门讲。

优化器的选择也有讲究。Adam 和 AdamW 都可以,但我发现 AdamW 在 batch size 比较小时的稳定性更好一点,原因可能是权重衰减的实现方式不同带来的数值差异,属于玄学范畴,不过既然两种都能跑通,跟着源码用 AdamW 就行。混合精度训练用 auto_cast 能省不少显存,但要注意前向加噪的 q_sample 里涉及 sqrt 和乘加操作,把 alphas_cumprod 保持在 float32 可以避免精度损失。我自己实践时会把模型、优化器和混合精度都设成 fp16,但 alphas_cumprod 这个调度张量固定用 float32,否则采样时会出现细微的噪声偏差。

训练过程的监控指标就是 loss 本身,没有 val loss 的概念。你只需要看它是否在平稳下降。我一般会跑到 loss 从最初的 1.5 左右降到 0.2 以下,才开始评估生成效果。如果 loss 在 0.2 附近就完全不降了,常见原因是数据集太大而模型容量不够,或者学习率衰减太快。

4. 反向采样:从纯噪声一步步还原图像

4.1 采样公式的推导直觉

训练完成后,生成图像是一个反向过程:从标准高斯噪声 xT 开始,用训练好的 UNet 逐步预测并去除噪声,最终得到 x0。采样时每步的核心公式是:

x_{t-1} = 1 / sqrt(alpha_t) * (x_t - beta_t / sqrt(1 - alpha_bar_t) * epsilon_theta) + sigma_t * z

这里 epsilon_theta 是模型预测的噪声,sigma_t 是这一反推步引入的随机噪声的标准差。理解这个公式不需要把推导完全吃透,你只需要抓住一个直觉:模型告诉你这一步图像里「有哪些内容其实是噪声」,你把这些噪声按比例减掉,回到更接近干净图像的一步;但因为单步去噪不够完美,还要补一点随机噪声来维持分布多样性。

这里有个容易误解的地方:采样时的 sigma_t 如果设成 0,就是 DDIM 的确定性采样,生成仍然有效,但多样性下降;如果按 DDPM 原版取 sigma_t^2 = beta_t,就是带随机性的采样。源码里通常用的是后者。我最初为了图省事,直接把 sigma_t 设成 0,结果确实能生成图像,但同一类样本的构图非常雷同,说明失去随机性导致多样性受损。所以除非你有明确的加速需求,否则不要轻易去掉这一步随机噪声。

倒数第一步有个细节:当 t=0 时一般不施加随机噪声,直接输出均值。原因是最后一步已经接近干净图像,再加噪声反而会让图像出现颗粒感。源码里一般通过判断 t==0 来跳过 sigma 项。

4.2 完整采样流程与后处理

采样代码是完整可运行的内核,整个生成过程如下:

# sample.py 中的采样循环 @torch.no_grad() def p_sample(model, x_t, t, betas, alphas, alphas_cumprod, alphas_cumprod_prev): """ 单步反向采样:从 x_t 得到 x_{t-1} t: 当前时间步,标量 """ beta_t = betas[t] alpha_t = alphas[t] alpha_cumprod_t = alphas_cumprod[t] alpha_cumprod_prev_t = alphas_cumprod_prev[t] # 模型预测当前噪声 pred_noise = model(x_t, t) # 计算去噪后的均值项 mu = (x_t - beta_t / torch.sqrt(1.0 - alpha_cumprod_t) * pred_noise) / torch.sqrt(alpha_t) if t == 0: return mu # 后验方差:这一步的随机性来自反向过程的方差 var = beta_t * (1.0 - alpha_cumprod_prev_t) / (1.0 - alpha_cumprod_t) noise = torch.randn_like(x_t) return mu + torch.sqrt(var) * noise def p_sample_loop(model, shape, betas, alphas, alphas_cumprod, alphas_cumprod_prev): """ 从纯噪声逐步生成图像 shape: 生成图像的形状 [B, C, H, W] """ device = next(model.parameters()).device x_t = torch.randn(shape, device=device) for t in reversed(range(len(betas))): t_tensor = torch.full((x_t.size(0),), t, device=device, dtype=torch.long) x_t = p_sample(model, x_t, t_tensor, betas, alphas, alphas_cumprod, alphas_cumprod_prev) # 从 [-1, 1] 还原到 [0, 1] x_t = torch.clamp(x_t, -1.0, 1.0) images = (x_t + 1.0) / 2.0 return images

逻辑说明:整个循环从 t=999 反向走到 t=0,每一步都调用 p_sample。reversed(range(len(betas))) 生成的顺序是 999, 998, ..., 0,正好对应从高噪声到低噪声的过程。t_tensor 的构造是因为模型期望接收一个 batch 的时间步张量,即使 batch 里所有图用的都是同一个 t,形状还是要符合 [B]。

参数说明:这里有个细节值得记住——模型中输入的时间步 t 应该用原始整数还是归一化后的值?源码里一般直接用整数,因为时间嵌入层内部会做正弦位置编码,不需要预先归一化。如果你发现模型生成效果很差,检查一下采样时传入的时间步是否和训练时一致,我在一次复现中因为把时间步当成浮点数传入,位置编码维度错位,生成结果几乎全是花斑噪声,排查了很久才发现是这个原因。

采样速度上,完整 1000 步迭代意味着要调用 1000 次 UNet 前向,这是 DDPM 被诟病最多的地方。如果训练用的是 64x64 分辨率、batch size 8,单张 24GB 显卡上生成一批图大约需要几十秒到几分钟不等。如果嫌慢,可以只step 500 步做快速验证,但注意 alpha_cumprod_prev 要相应截取,不能用完整序列去和 500 步的采样过程匹配,否则方差计算会错位。

5. DDPM 复现常见问题与排查:五条实测翻车记录

5.1 现象清单与原因分析

我在拆解这份源码时专门做了几次破坏性实验,把常见问题按「现象 → 原因 → 解决」整理成下面几条,基本覆盖了新手最容易踩的坑。

坑一:生成图像全是黑白颗粒噪点。现象:训练结束后采样,输出的图像像是没有训练过,全是椒盐噪声。原因:图像归一化方向反了。如果训练时把 [-1, 1] 的数据映射成了 [0, 1],或者采样后处理时没有做 clamp,输出会超出可视化范围。解决:检查训练前归一化是否做了images * 2 - 1,采样后是否做了(x + 1) / 2并 clamp。这个问题最常见,因为前向加噪公式假设图像范围在 [-1, 1],一旦输入范围不对,信噪比计算全部偏差。

坑二:loss 降得很低但生成图是灰色雾面。现象:训练 loss 已经降到 0.2 以下,但生成的图像清晰度很差,像隔着一层雾,边缘完全模糊。原因:T 设置得太大,或者 beta 上界过高,导致前向过程在时间步还比较小的时候信噪比就已经特别低,模型学到的主要是纯噪声到噪声的映射,真正的图像结构没有充分暴露。解决:把 beta 上界从 0.02 降回 0.01 到 0.015 试试,或者保持 beta 不变但减小 T 到 800,重新训练观察变化。我在实验中把 beta 上界设成 0.03 时,同样迭代数下生成效果明显劣化。

坑三:CUDA OOM,但模型看起来并不大。现象:batch size 32、初始通道 64、64x64 图像,显存 24GB 却爆了。原因:不是模型参数太大,而是训练循环中 q_sample 里的中间张量占用了显存。sqrt_alphas_cumprod 和 sqrt_one_minus 两个张量虽然都是 [B, 1, 1, 1] 形状,但和 [B, C, H, W] 的图像相乘时,会广播出完整的中间结果。另外混合精度没有生效时,临时张量都是 fp32。解决:先把 batch size 降到 16,然后检查是否用了torch.cuda.amp.autocast();如果还在爆,把初始通道降到 48,或者减少一个下采样层。

坑四:不同 batch size 训练效果差异巨大。现象:同一条代码,batch size 8 训 100 轮效果明显好于 batch size 32 训 100 轮。原因:如果 UNet 里用了 BatchNorm,batch size 小的统计量噪声反而可能起到正则作用,而 batch size 大时统计量更稳定,但模型容量不变,每轮更新次数少,所以总迭代数不足。解决:用 GroupNorm 替代 BatchNorm,让模型对 batch size 不敏感;同时把训练步数作为主要控制变量而不是 epoch 数。我在读源码时注意它内部统一用的是 GroupNorm,如果你改名成 BatchNorm,大概率会遇到这个问题。

坑五:训练 loss 正常,但采样时出现 NaN。现象:训练阶段 loss 没异常,采样循环跑到中间某一步时输出突然变成 NaN。原因:最常见的是 alpha_cumprod_prev 计算错误。如果 alphas_cumprod_prev 的定义是F.pad(alphas_cumprod[:-1], (1, 0), value=1.0),那么当 t=0 时,alpha_cumprod_prev_t 应该是 1.0,但如果代码里没有做这个 padding,或者用的是alphas_cumprod[:-1]直接索引,就会下标越界或者取到错误的值,导致方差项变成负数或零,sqrt 之后就是 NaN。解决:检查 alpha_cumprod_prev 的构造,确保长度是 T+1;另一个可能是模型输出变成了 NaN,这时需要检查是否开启了混合精度且没有做梯度裁剪。

5.2 如何快速定位问题

遇到模型生成效果不对时,不要盲目调参,我有一套固定排查流程。第一步,先做「重建测试」:取一张训练集中的图 x0,不经过训练,直接加噪到某个中等时间步 t(比如 500),看模型预测的噪声和真实噪声相差多少。如果这一步都错得很离谱,说明模型训练本身有问题,和采样无关。第二步,检查时间步嵌入是否有效:可以把 t 全部替换成 0,如果模型输出变化不大,说明时间条件没有被有效利用,问题多半出在 UNet 时间注入的结构上。第三步,检查图像范围:在训练循环里加一个 assert,确保输入 x_start 的范围是 [-1, 1]。这一步能过滤掉绝大多数「生成结果奇怪」的问题。

一个有用的诊断脚本片段:

# 重建测试:加噪到中间步,看模型预测与实际噪声的差异 with torch.no_grad(): img = next(iter(train_loader))[0][:1].to(device) img = img * 2.0 - 1.0 t = torch.tensor([500], device=device) x_noisy, real_noise = q_sample(img, t, alphas_cumprod) pred_noise = model(x_noisy, t) diff = (pred_noise - real_noise).abs().mean().item() print(f"重建噪声误差: {diff:.4f}")

如果把 t 选择为 500 时误差在 0.1 以下,说明模型对中间噪声步的理解已经到位;如果误差在 0.3 以上,先检查训练步数够不够,再看图像归一化是否正确。如果训练已经很久误差还是偏高,考虑降低初始通道数,因为模型容量可能没有充分利用,这也算一种玄学诊断:容量不够时 lr 跑不稳,loss 会呈现出锯齿状下降,重建误差也会卡在某个值附近。

6. 进阶技巧:EMA、采样加速与验证

6.1 EMA 与混合精度的习惯

训练快结束时,我发现一个现象:直接用模型权重采样,图像偶尔会出现细小的条纹伪影;而用 EMA 权重采样,伪影几乎消失,整体也更干净。EMA 的做法是维护一个权重影子副本,每次 optimizer.step 之后把模型参数按一定比例融合进去,常用衰减系数是 0.999。这个系数很敏感:太大则收敛慢,太小则起不到平滑作用。实践下来,训练步数总数在十万到几十万之间时,0.999 是比较保险的选择,如果你迭代数少,降到 0.995 更合适。

混合精度在这个资源里属于性价比很高的优化项。开启 autocast 之后显存占用能减少约 30%,训练速度也能提升。但有两个注意点:一是梯度裁剪要在 gradients 被缩放之前做,否则裁剪阈值会被放大 2 的幂次倍,导致裁剪失效;二是 q_sample 中的 alpha_cumprod 建议保持在 float32,避免反复 sqrt 后精度离散化,让采样过程中的方差项产生轻微的系统性偏移。

这部分的实践代码可以直接在训练循环里加:

ema_params = {} for name, param in model.named_parameters(): ema_params[name] = param.detach().clone() # optimizer.step 之后更新 EMA for name, param in model.named_parameters(): ema_params[name] = decay * ema_params[name] + (1 - decay) * param.detach()

6.2 定量验证:FID 之外先做重建检查

很多人复现完第一件事就是跑 FID,但我觉得 FID 对初学者是个陷阱——它需要一批真实图像做参考,还要预处理到特定尺寸,统计分布差异时对生成质量的小问题不敏感。我最推荐先做两件事:第一是刚才说的重建误差检查,它告诉你模型对加噪-去噪这个映射学得是否准确;第二是「自洽性检查」:采样一张图后,再把它加噪到较大的 t,然后用同一个模型逐步还原,看还原出来的图是否和原始生成图一致。如果自洽性都做不好,FID 再好看也说明模型只是碰巧抓住了分布。

我在复现这份源码时,习惯把训练损失曲线的形状也当作质量信号。DDPM 的 loss 曲线应该是一条平滑下降的指数形状:前 1/5 的步数里下降最快,之后渐趋平缓。如果你看到曲线在某个平台值附近反复震荡,不是学习率的问题,而更可能是 UNet 容量不够或者时间步嵌入维度太小。那条平台值对应的模型能力上限很难通过继续训练突破,换参数比硬训更值得投入时间。

回顾整个复现过程,最深刻的教训就是:扩散模型的门槛不在代码本身,而在于对噪声调度和图像范围这两个看似不起眼的细节保持敏感。现在每当我换一个数据集或分辨率跑这套流程,都会强制走一遍重建测试和自洽性检查,这两个过程能在两小时内暴露八成的问题。希望这份拆解能帮你在复现 DDPM 时少走这些我已经踩过的弯路,直接看到清晰而稳定的生成结果。

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

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

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

立即咨询