1. 从零手写DDPM:为什么我不建议你直接跑现成仓库
很多人第一次接触扩散模型,都是从调用现成的推理脚本开始的——输入一句提示词,等几秒,出一张图,感觉很神奇。但如果你真的想搞明白扩散模型到底在干什么,想自己改结构、调采样、做实验,那绕不开的一步就是:亲手把DDPM(Denoising Diffusion Probabilistic Models)实现一遍。
我这次做的事情,就是在一个图像生成项目里,从零实现DDPM的完整训练和采样流程,然后把它和Stable Diffusion的潜空间扩散思路做对比。整个过程踩了不少坑,有些是数学推导上的理解偏差,有些是工程实现上的细节疏忽,还有一些是训练稳定性方面的经验缺失。这篇文章就是把这些东西全部摊开来讲,给同样想动手实现DDPM的朋友省点时间。
DDPM这个东西,表面上看公式不多,核心就是前向加噪和反向去噪两个过程。但真正写代码的时候你会发现,每一个变量的维度、每一个系数的计算方式、每一个时间步的调度策略,都会直接影响最终能不能训出东西来。它不像分类网络那样,结构对了、损失对了,基本就能跑。扩散模型对细节的敏感度要高得多。
这篇文章适合谁看?如果你已经了解过扩散模型的基本概念,知道前向过程是逐步加高斯噪声、反向过程是学习去噪,但还没有完整实现过一遍,那这篇内容就是为你准备的。如果你已经在跑Stable Diffusion的推理,想深入理解它背后的DDPM训练逻辑,这篇文章也能帮你把底层链路串起来。我会从整体设计思路讲起,然后逐层拆解核心细节,接着给出完整的实操流程和参数选择依据,最后把我在训练过程中遇到的典型问题和排查方法整理出来。
整个实现基于PyTorch,数据集用的是一个小规模的图像集合(具体名称不重要,任何32x32或64x64的图像集都可以替代),训练环境是一块消费级显卡。代码结构不复杂,但每一个模块的设计都有它的道理,我会把这些道理讲清楚。
2. 整体设计思路与方案选型拆解
2.1 为什么选择DDPM而不是其他生成模型
在动手之前,先想清楚一个问题:生成模型那么多,GAN、VAE、Flow-based模型都能做图像生成,为什么偏偏选DDPM?
我当时的考量是这样的。GAN的训练稳定性是出了名的难搞,判别器和生成器的博弈很容易崩溃,调参成本极高。VAE生成的图像往往偏模糊,因为它的重建损失和KL散度之间存在天然的张力。Flow-based模型要求可逆变换,结构设计受限很大。而DDPM的优势在于:训练目标非常简单——就是预测噪声,本质上是一个回归任务;训练过程稳定,不像GAN那样需要精心平衡两个网络;生成质量上限高,这一点后来被Stable Diffusion等大规模模型验证了。
当然,DDPM的缺点也很明显:采样速度慢。标准DDPM需要上千步才能生成一张图,这也是后来DDIM、DPM-Solver等加速采样方法出现的原因。但对于学习和理解扩散模型的核心机制来说,DDPM是最合适的起点。
提示:如果你只是想快速出图,直接用现成的推理管线就行。但如果你想理解扩散模型为什么能work,手写DDPM是最短路径。
2.2 前向过程与反向过程的数学直觉
DDPM的核心思想可以用一句话概括:把一张图像逐步加噪变成纯高斯噪声,然后训练一个网络学会从噪声中逐步恢复出图像。
前向过程是一个马尔可夫链,定义为:
q(x_t | x_{t-1}) = N(x_t; sqrt(1-β_t) * x_{t-1}, β_t * I)
其中β_t是每一步的噪声方差,通常从1e-4线性增加到0.02,总共T步(一般T=1000)。这个过程的妙处在于,它允许我们直接计算任意时间步t的加噪结果:
q(x_t | x_0) = N(x_t; sqrt(ᾱ_t) * x_0, (1-ᾱ_t) * I)
其中α_t = 1 - β_t,ᾱ_t是α_t的累积乘积。这意味着我们不需要真的迭代t次加噪,而是一步就能得到x_t。这个性质在训练时极其重要,因为它让每次训练迭代的计算成本与T无关。
反向过程则是学习一个网络ε_θ(x_t, t)来预测加入的噪声,然后据此逐步去噪。训练损失就是预测噪声和真实噪声之间的MSE:
L = E[||ε - ε_θ(sqrt(ᾱ_t) * x_0 + sqrt(1-ᾱ_t) * ε, t)||²]
这个损失函数看起来简单得不像话,但它背后有变分下界的数学推导支撑。实际实现时,你不需要去推导ELBO,只需要记住:训练就是让网络学会从加噪图像中预测出被加入的噪声。
2.3 网络结构的选择:U-Net为什么是标配
DDPM原文用的是U-Net结构,这不是随便选的。U-Net的编码器-解码器结构配合跳跃连接,能够同时捕捉全局语义信息和局部细节。对于去噪任务来说,网络需要理解整张图像的上下文才能判断哪些是噪声、哪些是信号,而跳跃连接又能保留高频细节,避免在降采样过程中丢失太多信息。
我在实现时对U-Net做了几个关键设计:
- 时间步嵌入:把整数时间步t通过正弦位置编码转成向量,再经过两层MLP,注入到每个残差块中。这样网络能感知当前处于哪个噪声水平。
- 注意力机制:在U-Net的中间层和部分下采样层加入自注意力,让网络能在不同空间位置之间建立长距离依赖。
- 残差块:每个分辨率层级用两个残差块,配合GroupNorm和SiLU激活函数。
这些设计在DDPM原文和后续实现中都是标配,我基本沿用了,但在通道数、注意力头数等超参数上根据自己的数据集做了调整。
2.4 训练策略的整体框架
训练流程可以概括为:
- 从数据集中采样一批图像x_0
- 为每张图像随机采样一个时间步t
- 采样标准高斯噪声ε
- 计算加噪图像x_t = sqrt(ᾱ_t) * x_0 + sqrt(1-ᾱ_t) * ε
- 把x_t和t输入网络,得到预测噪声ε_θ
- 计算MSE损失并反向传播
这个流程每个step只需要一次前向和一次反向,计算效率很高。但有几个细节需要注意:时间步t的采样策略、噪声调度的选择、学习率的warmup等,这些都会影响训练效果。
3. 核心细节解析与实操要点
3.1 噪声调度:线性还是余弦
噪声调度决定了β_t如何随时间步变化。DDPM原文用的是线性调度,从β_1=1e-4线性增加到β_T=0.02。但我实际训练时发现,线性调度在低分辨率图像上表现还行,但在稍高分辨率的数据上,后期时间步的信噪比下降太快,导致模型在接近纯噪声的区域学不到有效信息。
后来我换成了余弦调度,这是Improved DDPM论文提出的方案:
ᾱ_t = cos²((t/T + s) / (1 + s) * π/2) / cos²(s / (1 + s) * π/2)
其中s是一个小的偏移量,通常取0.008,防止β_t在t=0附近太小。余弦调度的优势在于,它让ᾱ_t在中间时间步下降得更平缓,保证了每个时间步的信噪比分布更均匀,模型在各个噪声水平上都能得到充分的训练。
实测下来,余弦调度在相同训练步数下,生成的图像细节更丰富,尤其是在纹理复杂的区域。代价是实现稍微复杂一点,需要预先计算好ᾱ_t的数组。
注意:如果你用的是线性调度,建议把β_T从0.02适当降低到0.01左右,避免后期信噪比过低。但这个调整需要配合你的数据集和分辨率来定,没有万能值。
3.2 时间步嵌入的实现细节
时间步嵌入是DDPM实现中容易被忽视但非常关键的一环。网络需要知道当前处理的是哪个噪声水平,才能做出正确的去噪决策。如果时间步嵌入做得不好,模型可能会在不同噪声水平之间混淆,导致生成质量下降。
我采用的是正弦位置编码加MLP的方案:
class SinusoidalPositionEmbedding(nn.Module): def __init__(self, dim): super().__init__() self.dim = dim def forward(self, t): device = t.device half_dim = self.dim // 2 emb = math.log(10000) / (half_dim - 1) emb = torch.exp(torch.arange(half_dim, device=device) * -emb) emb = t[:, None] * emb[None, :] emb = torch.cat([emb.sin(), emb.cos()], dim=-1) return emb这个编码方式的好处是,不同时间步之间的编码向量有平滑的过渡,网络能学到时间步之间的连续性。MLP部分我用了两层线性层加SiLU激活,维度从128扩展到512再映射回目标维度。
踩过的坑:一开始我把时间步嵌入的维度设得太小(只有32),结果模型在中间时间步的去噪效果很差,生成的图像在中等噪声水平下会出现明显的伪影。后来把嵌入维度提高到128,问题就解决了。经验是:时间步嵌入的维度不要低于64,对于64x64以上的图像,建议128起步。
3.3 损失函数的设计与权重调整
DDPM的标准损失是简单的MSE,但实际训练时我发现,不同时间步的损失量级差异很大。在t接近0时,加噪很少,预测噪声很容易;在t接近T时,图像几乎全是噪声,预测难度也相对固定。真正难的是中间时间步,此时信号和噪声混合在一起,网络需要仔细分辨。
Improved DDPM提出了一个改进方案:让网络同时预测噪声和方差,并用一个混合损失来训练。但我实际试下来,对于小规模数据集,单纯预测噪声的简单MSE已经足够,加入方差预测反而增加了训练不稳定性。
不过,我确实对损失做了一个小调整:对不同时间步的损失进行加权。具体来说,我按照1/sqrt(1-ᾱ_t)的权重对每个时间步的损失进行缩放,让中间时间步的损失获得更大的梯度。这个权重来自DDPM原文的简化推导,实测能让模型更快收敛。
def compute_loss(model, x_0, t): noise = torch.randn_like(x_0) alpha_bar_t = extract(alpha_bars, t, x_0.shape) x_t = torch.sqrt(alpha_bar_t) * x_0 + torch.sqrt(1 - alpha_bar_t) * noise predicted_noise = model(x_t, t) loss = F.mse_loss(predicted_noise, noise, reduction='none') loss = loss.mean(dim=[1, 2, 3]) weight = 1.0 / torch.sqrt(1 - alpha_bar_t.squeeze()) loss = (loss * weight).mean() return loss提示:损失加权不是必须的,但如果你发现模型在中间时间步的去噪效果不理想,可以试试这个方案。权重系数可以根据实际情况微调。
3.4 采样过程的实现与加速
训练完成后,采样过程就是从纯噪声x_T开始,逐步去噪得到x_0。标准DDPM采样需要迭代T次(通常1000步),每一步都要调用一次网络,速度很慢。
采样公式为:
x_{t-1} = (1/sqrt(α_t)) * (x_t - (β_t/sqrt(1-ᾱ_t)) * ε_θ(x_t, t)) + σ_t * z
其中σ_t = sqrt(β_t),z是标准高斯噪声(t=0时不加)。这个公式的推导来自贝叶斯后验的均值方差计算,实现时直接套用即可。
为了加速采样,我实现了DDIM采样。DDIM的核心思想是,把采样过程变成一个确定性的过程(或者可以调节随机性),并且允许跳步。具体来说,DDIM采样时不需要每一步都加噪声,而是用一个可调的η参数控制随机性。当η=0时,采样完全确定,可以用更少的步数(比如50步或100步)得到不错的结果。
def ddim_sample(model, shape, ddim_steps=100, eta=0.0): x = torch.randn(shape) times = torch.linspace(T-1, 0, ddim_steps).long() for i in range(len(times)-1): t = times[i] t_next = times[i+1] alpha_bar_t = alpha_bars[t] alpha_bar_next = alpha_bars[t_next] pred_noise = model(x, t) pred_x0 = (x - torch.sqrt(1-alpha_bar_t) * pred_noise) / torch.sqrt(alpha_bar_t) pred_x0 = torch.clamp(pred_x0, -1, 1) sigma = eta * torch.sqrt((1-alpha_bar_next)/(1-alpha_bar_t) * (1-alpha_bar_t/alpha_bar_next)) c = torch.sqrt(1 - alpha_bar_next - sigma**2) x = torch.sqrt(alpha_bar_next) * pred_x0 + c * pred_noise + sigma * torch.randn_like(x) return x实测下来,DDIM用100步采样的效果已经接近DDPM用1000步的效果,速度提升了10倍。如果对生成质量要求不高,50步也能出可用的结果。
4. 完整实操流程与核心环节实现
4.1 环境准备与依赖安装
环境配置这块,我踩的第一个坑就是版本兼容性。PyTorch的版本和CUDA驱动之间的匹配问题就不多说了,这是老生常谈。我想说的是,扩散模型的实现中用到的一些操作(比如GroupNorm、SiLU)在不同PyTorch版本下的行为可能有细微差异,建议固定一个稳定版本。
我用的环境是:
- Python 3.9
- PyTorch 1.13.1 + CUDA 11.7
- torchvision 0.14.1
- numpy、tqdm、matplotlib(用于可视化)
安装命令很简单:
pip install torch==1.13.1+cu117 torchvision==0.14.1+cu117 -f https://download.pytorch.org/whl/torch_stable.html pip install numpy tqdm matplotlib数据集方面,我用的是一个32x32的图像集合,通过torchvision的DataLoader加载,做了随机水平翻转和归一化到[-1, 1]的预处理。归一化到[-1, 1]而不是[0, 1]是因为DDPM的噪声调度假设数据均值为0,这样加噪后的分布更接近标准高斯。
4.2 噪声调度参数的计算与存储
在训练开始之前,需要预先计算好所有时间步的α_t、ᾱ_t、β_t等参数。这些参数在训练和采样中都会反复用到,预先计算好存成数组,避免重复计算。
T = 1000 beta_start = 1e-4 beta_end = 0.02 # 线性调度 betas = torch.linspace(beta_start, beta_end, T) alphas = 1.0 - betas alpha_bars = torch.cumprod(alphas, dim=0) # 余弦调度(备选) # s = 0.008 # steps = torch.arange(T + 1, dtype=torch.float32) # f = torch.cos((steps / T + s) / (1 + s) * math.pi / 2) ** 2 # alpha_bars = f / f[0] # betas = 1 - alpha_bars[1:] / alpha_bars[:-1] # alphas = 1.0 - betas # alpha_bars = alpha_bars[1:]这里有一个细节:alpha_bars的最后一个值应该接近0,表示x_T几乎是纯噪声。如果alpha_bars[T-1]太大(比如大于0.01),说明加噪不够充分,采样时从纯高斯噪声开始会与训练分布不匹配。我一开始用线性调度时,alpha_bars[T-1]大约是0.00004,没问题。但如果你调整了beta_end,一定要检查这个值。
注意:alpha_bars的计算必须用cumprod,不能用cumsum。这个坑我踩过,当时不小心写成了cumsum,结果训练损失一直不下降,排查了半天才发现是这里的问题。
4.3 U-Net模型的搭建与参数配置
U-Net的结构我按照DDPM原文的设计来,但做了一些简化。基础通道数设为64,通道倍数设为[1, 2, 4],即三个分辨率层级。每个层级包含两个残差块,中间层加自注意力。
class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, time_emb_dim): super().__init__() self.norm1 = nn.GroupNorm(8, in_channels) self.conv1 = nn.Conv2d(in_channels, out_channels, 3, padding=1) self.time_mlp = nn.Linear(time_emb_dim, out_channels) self.norm2 = nn.GroupNorm(8, out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, 3, padding=1) self.residual_conv = nn.Conv2d(in_channels, out_channels, 1) if in_channels != out_channels else nn.Identity() def forward(self, x, t_emb): h = self.norm1(x) h = F.silu(h) h = self.conv1(h) t_emb = self.time_mlp(F.silu(t_emb)) h = h + t_emb[:, :, None, None] h = self.norm2(h) h = F.silu(h) h = self.conv2(h) return h + self.residual_conv(x)参数配置方面,我总结了几条经验:
- 基础通道数:32x32图像用64就够了,64x64建议128起步。通道数太少会导致模型容量不足,生成图像模糊;太多则训练慢且容易过拟合。
- 注意力头数:中间层用4个头,每个头32维。头数太多在小分辨率下收益不大。
- GroupNorm的组数:固定为8,或者设为通道数的约数。组数太少会导致归一化效果不好,太多则计算开销增加。
- Dropout:在残差块中加0.1的Dropout,有助于防止过拟合,尤其是在小数据集上。
模型总参数量大约在30M左右,对于32x32图像来说足够了。训练时用AdamW优化器,学习率2e-4,配合余弦退火调度和1000步的warmup。
4.4 训练循环的实现与监控
训练循环的实现看起来直接,但有几个细节决定了训练能不能收敛。
def train_step(model, x_0, optimizer): model.train() batch_size = x_0.shape[0] t = torch.randint(0, T, (batch_size,), device=x_0.device).long() noise = torch.randn_like(x_0) alpha_bar_t = alpha_bars.to(x_0.device)[t] alpha_bar_t = alpha_bar_t.view(-1, 1, 1, 1) x_t = torch.sqrt(alpha_bar_t) * x_0 + torch.sqrt(1 - alpha_bar_t) * noise predicted_noise = model(x_t, t) loss = F.mse_loss(predicted_noise, noise) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() return loss.item()关键点说明:
- 时间步采样:用torch.randint均匀采样。有研究表明,对时间步进行非均匀采样(比如更关注中间时间步)可能效果更好,但我实测下来均匀采样已经够用。
- 梯度裁剪:必须加,阈值设为1.0。扩散模型的梯度在某些时间步可能会突然变大,不裁剪容易导致训练不稳定。
- 混合精度训练:用torch.cuda.amp可以显著减少显存占用并加速训练。但要注意,混合精度下MSE损失可能会溢出,需要用GradScaler来处理。
训练过程中我监控三个指标:损失值、梯度范数、以及每隔一定步数生成的样本。损失值不用太在意绝对值,关键是看趋势是否稳定下降。梯度范数如果持续很大,说明学习率可能太高。生成的样本是最直观的反馈,能帮你判断模型是否在学到有意义的东西。
我训练了大约50个epoch,在单卡上花了不到一天时间。损失从最初的0.15左右降到了0.03左右,生成的图像虽然不算精致,但已经能看出清晰的轮廓和纹理。
4.5 采样与结果可视化
训练完成后,采样过程就是前面提到的DDPM或DDIM采样。我写了一个简单的可视化脚本,把采样过程中的中间结果保存下来,方便观察去噪的逐步过程。
@torch.no_grad() def sample_and_visualize(model, n_samples=16, ddim_steps=100): model.eval() samples = ddim_sample(model, (n_samples, 3, 32, 32), ddim_steps=ddim_steps) samples = (samples + 1) / 2 samples = samples.clamp(0, 1) grid = torchvision.utils.make_grid(samples, nrow=4) plt.figure(figsize=(8, 8)) plt.imshow(grid.permute(1, 2, 0).cpu().numpy()) plt.axis('off') plt.savefig('samples.png', dpi=150, bbox_inches='tight')实测下来,DDIM用100步采样的结果和DDPM用1000步的结果在视觉上差异很小,但速度快了10倍。如果只是做实验验证,DDIM是更好的选择。
5. 常见问题与排查技巧实录
5.1 训练损失不下降或震荡
这是最常见的问题,可能的原因有多个。我按照排查优先级列一下:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 损失完全不降 | alpha_bars计算错误 | 检查alpha_bars[T-1]是否接近0 | 用cumprod而非cumsum |
| 损失震荡剧烈 | 学习率过高 | 打印梯度范数 | 降低学习率或加warmup |
| 损失下降后反弹 | 过拟合 | 对比训练集和验证集损失 | 加Dropout或数据增强 |
| 损失卡在某个值 | 时间步嵌入有问题 | 可视化时间步嵌入向量 | 增大嵌入维度或检查实现 |
我遇到过一次损失完全不降的情况,排查后发现是alpha_bars的计算用错了函数。这个错误很隐蔽,因为代码不会报错,只是数值不对。建议在训练前打印几个关键时间步的alpha_bars值,确认它们符合预期。
5.2 生成图像全是噪声或模糊
训练损失正常下降,但采样出来的图像全是噪声或者非常模糊,这通常说明模型没有真正学到去噪能力。
可能的原因:
- 采样步数太少:DDPM需要足够的步数才能逐步去噪,如果步数太少,去噪不充分。建议至少100步(DDIM)或500步(DDPM)。
- 网络容量不足:如果U-Net太浅或通道数太少,模型无法拟合复杂的去噪函数。尝试增加通道数或层数。
- 训练不充分:扩散模型通常需要较长时间训练,小数据集上至少几十个epoch。如果损失还在下降,继续训。
- 数据归一化问题:如果数据没有归一化到[-1, 1],加噪后的分布会和标准高斯有偏差,影响训练。检查你的数据预处理。
我踩过的一个坑是:采样时忘记把模型设为eval模式,Dropout还在起作用,导致生成的图像有随机噪声。这个错误很低级,但很容易忽略。
5.3 显存不足与训练速度优化
扩散模型的训练显存占用主要来自U-Net的中间激活值。对于32x32图像和30M参数的模型,batch size=64时大约需要8GB显存。如果显存不够,有几个优化方向:
- 减小batch size:最直接的方法,但可能会影响训练稳定性。可以用梯度累积来补偿。
- 混合精度训练:用torch.cuda.amp,显存占用可以减少约40%。
- 梯度检查点:用torch.utils.checkpoint,用计算时间换显存空间。
- 减小模型通道数:如果数据集分辨率低,不需要太大的模型。
训练速度方面,除了混合精度,还可以用channels_last内存格式和cudnn.benchmark=True来加速卷积运算。实测下来,这些优化能让训练速度提升30%左右。
5.4 采样速度太慢的优化方案
标准DDPM采样1000步,每步都要调用一次网络,速度确实慢。除了前面提到的DDIM,还有几个加速方案:
- DPM-Solver:用高阶求解器,20步左右就能达到不错的效果。实现比DDIM复杂,但加速效果更明显。
- 知识蒸馏:训练一个学生模型来模仿教师模型的采样轨迹,可以用更少的步数。但训练成本高。
- 潜空间扩散:这就是Stable Diffusion的思路,先把图像编码到低维潜空间,在潜空间做扩散,最后解码回像素空间。潜空间的维度远低于像素空间,采样速度自然快。
我实际对比过:DDPM 1000步在32x32图像上大约需要20秒(单张),DDIM 100步只需要2秒,DPM-Solver 20步不到1秒。如果做实验,DDIM的性价比最高。
5.5 与Stable Diffusion的关联与差异
实现完DDPM之后,再看Stable Diffusion,很多设计就变得清晰了。Stable Diffusion本质上是在潜空间上做DDPM(更准确地说,是DDPM的变体),核心差异在于:
- 扩散空间:DDPM在像素空间扩散,Stable Diffusion在VAE编码的潜空间扩散。潜空间维度通常是64x64x4(对于512x512图像),比像素空间小得多。
- 条件机制:Stable Diffusion通过交叉注意力注入文本条件,DDPM是无条件的。
- 噪声调度:Stable Diffusion用了scaled linear调度,针对潜空间做了调整。
- 采样器:Stable Diffusion支持多种采样器(DDIM、Euler、DPM-Solver等),用户可以根据需要选择。
理解了DDPM的训练和采样逻辑,再看Stable Diffusion的代码就不会觉得陌生了。它只是在DDPM的基础上加了条件控制和潜空间映射,核心的去噪训练目标是一样的。
6. 一些实操心得与后续扩展方向
6.1 训练扩散模型的几个经验法则
经过这次实现,我总结了几个训练扩散模型的经验法则,不一定适用于所有情况,但可以作为参考:
- 从简单数据集开始:不要一上来就用高分辨率、复杂的数据集。32x32的CIFAR级别数据是很好的起点,训练快、容易调试。
- 先跑通再优化:不要一开始就追求SOTA效果。先把训练和采样流程跑通,确认能生成有意义的图像,再逐步调优。
- 监控采样结果比监控损失更重要:损失值只能告诉你模型是否在拟合噪声预测任务,但生成质量需要看采样结果。建议每几个epoch就采样一次看看。
- 超参数不要一次调太多:扩散模型的超参数之间有关联,一次调一个,记录变化,才能搞清楚每个参数的作用。
6.2 从DDPM到Stable Diffusion的扩展路径
如果你已经实现了DDPM,想进一步扩展到Stable Diffusion级别的系统,大致路径是:
- 加入条件机制:把类别标签或文本嵌入通过交叉注意力注入U-Net。
- 引入潜空间:训练一个VAE,把图像编码到低维潜空间,在潜空间做扩散。
- 扩大模型和数据:Stable Diffusion的U-Net参数量在800M以上,训练数据是数十亿级别的图像-文本对。
- 优化采样器:实现DDIM、DPM-Solver等快速采样器,支持少步生成。
- 工程优化:混合精度、梯度检查点、分布式训练等,支撑大规模训练。
每一步都有大量的细节需要处理,但核心的DDPM逻辑是不变的。把DDPM吃透,后面的扩展就是工程和规模的问题了。
6.3 我踩过的最大的三个坑
最后分享三个我踩过的最大的坑,希望能帮你避开:
第一个坑:alpha_bars的计算。前面提过,用cumsum代替cumprod,导致训练完全失效。这个错误的隐蔽性在于代码不报错,损失也在下降(因为模型在拟合一个错误的分布),但采样出来的全是噪声。排查方法很简单:打印alpha_bars的前几个值和最后一个值,确认它们符合预期。
第二个坑:时间步嵌入的维度。一开始设了32维,训练损失正常,但采样结果在中间时间步出现明显伪影。后来把维度提高到128,问题解决。经验是:时间步嵌入的维度不要省,它直接影响模型对不同噪声水平的区分能力。
第三个坑:采样时的模型模式。忘记调用model.eval(),Dropout在采样时仍然生效,导致生成的图像带有随机噪声。这个错误很低级,但在调试时很容易忽略,因为训练时的损失看起来完全正常。
6.4 后续可以尝试的改进方向
如果你已经跑通了基础DDPM,以下几个方向可以作为下一步的尝试:
- 改进噪声调度:试试余弦调度或sigmoid调度,对比生成质量。
- 加入方差预测:让网络同时预测噪声和方差,用混合损失训练,可能提升生成质量。
- 条件生成:加入类别标签或文本条件,实现可控生成。
- 加速采样:实现DPM-Solver或知识蒸馏,把采样步数降到10步以内。
- 潜空间扩散:训练VAE,在潜空间做扩散,为扩展到Stable Diffusion级别做准备。
我个人在实际操作中的体会是,扩散模型的实现难度不在于代码量,而在于对细节的把握。每一个变量、每一个系数、每一个时间步的处理,都会影响最终结果。把DDPM从头实现一遍,比读十篇论文都管用。希望这篇内容能帮你少走一些弯路,顺利跑通自己的扩散模型。