查看全课大纲(第 32 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

扩散模型:前向加噪与反向去噪

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

学完本节,你将能回答:扩散模型是如何通过一个逐步加噪的前向过程破坏数据,再训练一个神经网络去学习反向去噪,从而生成全新样本的?本节是第 7 章《生成模型》的第三节,会拆开三个关键机制:前向过程的封闭解、反向过程的噪声参数化、以及那个极其简洁的简化训练目标。先修:变分自编码器(VAE)的概率图模型思想。后续:下一节把扩散过程搬进潜在空间,讲清 Stable Diffusion 这类潜在扩散模型与 DDIM 采样加速。

💡 核心导读

  1. 扩散模型定义:一种通过训练参数化马尔可夫链(反向过程)来逆转一个固定、逐步加噪的马尔可夫链(前向过程)的生成模型。
  2. 前向过程:一个固定方差的、逐步向数据添加高斯噪声的过程,其任意时刻的噪声数据可以通过一个封闭公式直接采样得到。
  3. 反向过程:一个学习去噪的神经网络,其目标被证明可以简化为预测每一步所添加的噪声。
  4. 训练目标:一个去掉了权重系数(即非加权)、经重参数化简化的均方误差(MSE)损失,直接优化噪声预测的准确性。
  5. 采样生成:从一个纯高斯噪声开始,使用训练好的反向过程模型,逐步进行去噪采样,最终得到清晰的数据样本。

🧭 前置知识

  1. 变分下界(ELBO):理解变分自编码器(VAE)中用于优化证据下界的基本思想。不展开推导,见站内数学基础课。
  2. 高斯分布的性质:了解高斯分布的加法、乘法规则,以及重参数化技巧。
  3. 马尔可夫链:知道当前状态只依赖于前一状态的基本概念。
  4. 均方误差(MSE)损失:torch.nn.functional.mse_loss 的基本用法。

扩散模型的基本框架

扩散模型(Diffusion Probabilistic Models)属于潜变量模型,其联合分布 pθ(x0:T)p_\theta(\mathbf{x}_{0:T}) 称为反向过程。它被定义为一个从纯噪声 xT\mathbf{x}_T 开始,逐步去噪生成数据 x0\mathbf{x}_0 的马尔可夫链: pθ(x0:T):=p(xT)∏t=1Tpθ(xt−1∣xt),p(xT)=N(xT;0,I)p_\theta(\mathbf{x}_{0:T}) := p(\mathbf{x}_T) \prod_{t=1}^{T} p_\theta(\mathbf{x}_{t-1} | \mathbf{x}_t), \quad p(\mathbf{x}_T) = \mathcal{N}(\mathbf{x}_T; \mathbf{0}, \mathbf{I}) 其中 pθ(xt−1∣xt):=N(xt−1;μθ(xt,t),Σθ(xt,t))p_\theta(\mathbf{x}_{t-1} | \mathbf{x}_t) := \mathcal{N}(\mathbf{x}_{t-1}; \boldsymbol{\mu}_\theta(\mathbf{x}_t, t), \boldsymbol{\Sigma}_\theta(\mathbf{x}_t, t)),均值和方差由神经网络参数化。

与之对应的是一个固定的前向过程(或扩散过程)q(x1:T∣x0)q(\mathbf{x}_{1:T} | \mathbf{x}_0),它也是一个马尔可夫链,逐步向数据 x0\mathbf{x}_0 添加高斯噪声: q(x1:T∣x0):=∏t=1Tq(xt∣xt−1),q(xt∣xt−1):=N(xt;1−βtxt−1,βtI)q(\mathbf{x}_{1:T} | \mathbf{x}_0) := \prod_{t=1}^{T} q(\mathbf{x}_t | \mathbf{x}_{t-1}), \quad q(\mathbf{x}_t | \mathbf{x}_{t-1}) := \mathcal{N}(\mathbf{x}_t; \sqrt{1-\beta_t} \mathbf{x}_{t-1}, \beta_t \mathbf{I}) 这里的 βt∈(0,1)\beta_t \in (0, 1) 是预先定义好的方差调度表(variance schedule),控制着每一步添加的噪声量。

整个过程的概率图模型如下所示(一个双向的马尔可夫链):

前向 q   : x_0 --> x_1 --> ... --> x_T     (固定加噪)
反向 p_θ : x_T --> x_{T-1} --> ... --> x_0 (学习去噪)

我们的目标是训练反向过程 pθp_\theta,使其能够逆转前向过程 qq,从而从噪声 xT\mathbf{x}_T 采样出逼真的数据 x0\mathbf{x}_0。

前向过程:任意步加噪的封闭解

前向过程的一个关键性质是,我们可以直接从原始数据 x0\mathbf{x}_0 采样出任意中间时刻 tt 的加噪数据 xt\mathbf{x}_t,而无需逐步迭代 tt 次。这极大地提高了训练效率。

定义 αt:=1−βt\alpha_t := 1 - \beta_t, αˉt:=∏s=1tαs\bar{\alpha}_t := \prod_{s=1}^{t} \alpha_s。根据高斯噪声的叠加性质,可以推导出(推导过程见论文附录): q(xt∣x0)=N(xt;αˉtx0,(1−αˉt)I)q(\mathbf{x}_t | \mathbf{x}_0) = \mathcal{N}(\mathbf{x}_t; \sqrt{\bar{\alpha}_t} \mathbf{x}_0, (1 - \bar{\alpha}_t) \mathbf{I})

这意味着,xt\mathbf{x}_t 是原始数据 x0\mathbf{x}_0 按系数 αˉt\sqrt{\bar{\alpha}_t} 缩放后,再加上一个方差为 (1−αˉt)(1 - \bar{\alpha}_t) 的高斯噪声。我们可以使用重参数化技巧来采样: xt=αˉtx0+1−αˉtϵ,ϵ∼N(0,I)\mathbf{x}_t = \sqrt{\bar{\alpha}_t} \mathbf{x}_0 + \sqrt{1 - \bar{\alpha}_t} \boldsymbol{\epsilon}, \quad \boldsymbol{\epsilon} \sim \mathcal{N}(\mathbf{0}, \mathbf{I})

让我们通过代码来直观感受前向过程。我们将设置一个总步数 T=5T=5 的简化调度表,并观察一个简单数据点如何被逐步加噪。

import torch
import math

torch.manual_seed(0)

# 1. 定义前向过程参数(简化版,T=5)
T = 5
# 定义一个简单的线性增长的 beta_t,从 0.1 到 0.5
betas = torch.linspace(0.1, 0.5, T)
print("beta_t (噪声方差):", [round(b, 4) for b in betas.tolist()])

alphas = 1. - betas
print("alpha_t (保留系数):", [round(a, 4) for a in alphas.tolist()])

alphas_cumprod = torch.cumprod(alphas, dim=0)  # alpha_bar_t
print("alpha_bar_t (累积保留系数):", [round(acp, 4) for acp in alphas_cumprod.tolist()])

# 2. 构造一个简单的 4 维数据点 x_0
x_0 = torch.tensor([1.0, 0.5, -0.5, -1.0])
print("\n原始数据 x_0:", [round(v, 4) for v in x_0.tolist()])

# 3. 演示逐步加噪(迭代方式)
x_prev = x_0.clone()
print("\n--- 逐步加噪演示 (迭代) ---")
for t in range(1, T+1):
    beta_t = betas[t-1]
    # 根据公式 q(x_t | x_{t-1}) 采样
    noise = torch.randn_like(x_prev)
    x_t = math.sqrt(1 - beta_t) * x_prev + math.sqrt(beta_t) * noise
    print(f"t={t}: x_{t} =", [round(v, 4) for v in x_t.tolist()])
    x_prev = x_t

# 4. 演示任意步加噪(封闭解,直接从 x_0 到 x_t)
print("\n--- 任意步加噪演示 (封闭解) ---")
for t in [1, 3, 5]:  # 选择几个时间步
    alpha_bar_t = alphas_cumprod[t-1]
    # 根据公式 q(x_t | x_0) 采样
    noise = torch.randn_like(x_0)
    x_t_direct = math.sqrt(alpha_bar_t) * x_0 + math.sqrt(1 - alpha_bar_t) * noise
    print(f"t={t}: x_{t} (直接采样) =", [round(v, 4) for v in x_t_direct.tolist()])
    # 注意:由于噪声随机,这里的值与迭代结果不同,但分布相同。
beta_t (噪声方差): [0.1, 0.2, 0.3, 0.4, 0.5]
alpha_t (保留系数): [0.9, 0.8, 0.7, 0.6, 0.5]
alpha_bar_t (累积保留系数): [0.9, 0.72, 0.504, 0.3024, 0.1512]

原始数据 x_0: [1.0, 0.5, -0.5, -1.0]

--- 逐步加噪演示 (迭代) ---
t=1: x_1 = [1.436, 0.3816, -1.1633, -0.7689]
t=2: x_2 = [0.7994, -0.2842, -0.8601, -0.313]
t=3: x_3 = [0.2749, -0.4587, -1.0464, -0.1621]
t=4: x_4 = [-0.3289, 0.3408, -1.488, -0.048]
t=5: x_5 = [-0.633, 0.5048, -1.6829, -1.101]

--- 任意步加噪演示 (封闭解) ---
t=1: x_1 (直接采样) = [1.0658, 0.9349, -0.1771, -0.7037]
t=3: x_3 (直接采样) = [0.8451, 1.245, -1.2638, -1.2671]
t=5: x_5 (直接采样) = [0.3696, -0.4675, 0.2834, -1.5981]

可以看到,随着 tt 增大,alpha_bar_t 单调下降,xt\mathbf{x}_t 中的原始信号 x0\mathbf{x}_0 成分越来越弱,噪声成分越来越强。上面的 T=5T=5 只是便于手算的玩具设置;在真实配置(T=1000T=1000,且设计使 αˉT≈0\bar{\alpha}_T \approx 0)下,xT\mathbf{x}_T 几乎完全由噪声构成,其分布接近标准高斯分布 N(0,I)\mathcal{N}(\mathbf{0}, \mathbf{I})。这正是我们期望的:前向过程最终把任何数据都“扩散”成了纯噪声。

反向过程:从噪声预测到去噪均值

训练扩散模型需要优化变分下界(ELBO)。经过推导(详见 Denoising Diffusion Probabilistic Models(Ho et al., 2020,arXiv:2006.11239)论文第 2、3 节),损失函数可以写成若干 KL 散度项之和。其中最关键的一项 LtL_t 是让反向过程 pθ(xt−1∣xt)p_\theta(\mathbf{x}_{t-1} | \mathbf{x}_t) 去匹配一个可计算的前向过程后验分布 q(xt−1∣xt,x0)q(\mathbf{x}_{t-1} | \mathbf{x}_t, \mathbf{x}_0)。

这个后验分布也是一个高斯分布: q(xt−1∣xt,x0)=N(xt−1;μ~t(xt,x0),β~tI)q(\mathbf{x}_{t-1} | \mathbf{x}_t, \mathbf{x}_0) = \mathcal{N}(\mathbf{x}_{t-1}; \tilde{\boldsymbol{\mu}}_t(\mathbf{x}_t, \mathbf{x}_0), \tilde{\beta}_t \mathbf{I}) 其中均值和方差为: μ~t(xt,x0):=αˉt−1βt1−αˉtx0+αt(1−αˉt−1)1−αˉtxt,β~t:=1−αˉt−11−αˉtβt\tilde{\boldsymbol{\mu}}_t(\mathbf{x}_t, \mathbf{x}_0) := \frac{\sqrt{\bar{\alpha}_{t-1}} \beta_t}{1 - \bar{\alpha}_t} \mathbf{x}_0 + \frac{\sqrt{\alpha_t}(1 - \bar{\alpha}_{t-1})}{1 - \bar{\alpha}_t} \mathbf{x}_t, \quad \tilde{\beta}_t := \frac{1 - \bar{\alpha}_{t-1}}{1 - \bar{\alpha}_t} \beta_t

我们的反向过程也参数化为高斯分布 pθ(xt−1∣xt)=N(xt−1;μθ(xt,t),σt2I)p_\theta(\mathbf{x}_{t-1} | \mathbf{x}_t) = \mathcal{N}(\mathbf{x}_{t-1}; \boldsymbol{\mu}_\theta(\mathbf{x}_t, t), \sigma_t^2 \mathbf{I})。论文发现,将方差 σt2\sigma_t^2 设为固定的 βt\beta_t 或 β~t\tilde{\beta}_t 效果相似,且能简化训练。因此,训练的关键在于让 μθ(xt,t)\boldsymbol{\mu}_\theta(\mathbf{x}_t, t) 去逼近 μ~t(xt,x0)\tilde{\boldsymbol{\mu}}_t(\mathbf{x}_t, \mathbf{x}_0)。

然而,μ~t\tilde{\boldsymbol{\mu}}_t 依赖于未知的原始数据 x0\mathbf{x}_0。我们可以利用前向过程的封闭解 xt=αˉtx0+1−αˉtϵ\mathbf{x}_t = \sqrt{\bar{\alpha}_t} \mathbf{x}_0 + \sqrt{1 - \bar{\alpha}_t} \boldsymbol{\epsilon} 将 x0\mathbf{x}_0 表示为 x0=(xt−1−αˉtϵ)/αˉt\mathbf{x}_0 = (\mathbf{x}_t - \sqrt{1 - \bar{\alpha}_t} \boldsymbol{\epsilon}) / \sqrt{\bar{\alpha}_t}。将其代入 μ~t\tilde{\boldsymbol{\mu}}_t 的表达式,经过化简(论文正文把 x0x_0 代回式(7)),可以得到一个极其简洁的形式: μ~t(xt,x0)=1αt(xt−βt1−αˉtϵ)\tilde{\boldsymbol{\mu}}_t(\mathbf{x}_t, \mathbf{x}_0) = \frac{1}{\sqrt{\alpha_t}} \left( \mathbf{x}_t - \frac{\beta_t}{\sqrt{1 - \bar{\alpha}_t}} \boldsymbol{\epsilon} \right)

这个公式揭示了本质:前向过程后验的均值 μ~t\tilde{\boldsymbol{\mu}}_t,是在当前噪声数据 xt\mathbf{x}_t 的基础上,减去一个由所加噪声 ϵ\boldsymbol{\epsilon} 缩放后的项。

因此,一个自然的参数化策略是:让神经网络去预测这个噪声 ϵ\boldsymbol{\epsilon}。即,我们定义: μθ(xt,t)=1αt(xt−βt1−αˉtϵθ(xt,t))\boldsymbol{\mu}_\theta(\mathbf{x}_t, t) = \frac{1}{\sqrt{\alpha_t}} \left( \mathbf{x}_t - \frac{\beta_t}{\sqrt{1 - \bar{\alpha}_t}} \boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t) \right) 其中 ϵθ(xt,t)\boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t) 是一个以 xt\mathbf{x}_t 和时间步 tt 为输入,输出与 xt\mathbf{x}_t 同维度的神经网络,其目标就是预测在前向过程中添加到 x0\mathbf{x}_0 上的噪声 ϵ\boldsymbol{\epsilon}。

将这种参数化代入 KL 散度项 LtL_t,并忽略一些权重系数后,可以得到一个非常简单的训练目标(论文式(14)): Lsimple:=Et,x0,ϵ[∥ϵ−ϵθ(αˉtx0+1−αˉtϵ,t)∥2]L_{\text{simple}} := \mathbb{E}_{t, \mathbf{x}_0, \boldsymbol{\epsilon}} \left[ \lVert \boldsymbol{\epsilon} - \boldsymbol{\epsilon}_\theta( \sqrt{\bar{\alpha}_t} \mathbf{x}_0 + \sqrt{1 - \bar{\alpha}_t} \boldsymbol{\epsilon}, t ) \rVert^2 \right]

这就是扩散模型训练的核心:随机采样一个时间步 tt、一个数据样本 x0\mathbf{x}_0 和一个随机噪声 ϵ\boldsymbol{\epsilon},构造出加噪样本 xt\mathbf{x}_t,然后让神经网络 ϵθ\boldsymbol{\epsilon}_\theta 去预测这个噪声 ϵ\boldsymbol{\epsilon},并用均方误差(MSE)作为损失函数。

下面我们用代码实现一个极简的噪声预测网络和训练步骤。

import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)

# 1. 定义超参数
input_dim = 4      # 数据维度
hidden_dim = 16
T = 5              # 总扩散步数(为了演示简化)
batch_size = 2

# 2. 定义调度参数(与之前一致)
betas = torch.linspace(0.1, 0.5, T)
alphas = 1. - betas
alphas_cumprod = torch.cumprod(alphas, dim=0)
# 用于计算的辅助张量,扩展维度便于批量操作
sqrt_alphas_cumprod = torch.sqrt(alphas_cumprod)
sqrt_one_minus_alphas_cumprod = torch.sqrt(1. - alphas_cumprod)

# 3. 定义一个简单的噪声预测网络
class SimpleNoisePredictor(nn.Module):
    def __init__(self, input_dim, hidden_dim):
        super().__init__()
        # 时间步嵌入层:将标量t映射为向量
        self.time_embed = nn.Sequential(
            nn.Linear(1, hidden_dim),
            nn.SiLU(),
            nn.Linear(hidden_dim, hidden_dim)
        )
        # 主网络:将 [x_t, time_emb] 拼接后预测噪声
        self.main = nn.Sequential(
            nn.Linear(input_dim + hidden_dim, hidden_dim),
            nn.SiLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.SiLU(),
            nn.Linear(hidden_dim, input_dim)  # 输出预测的噪声,维度与x_t相同
        )

    def forward(self, x, t):
        # x: (batch, input_dim)
        # t: (batch, ) 或标量,表示批次内每个样本的时间步
        t_emb = self.time_embed(t.view(-1, 1).float())  # (batch, hidden_dim)
        x_in = torch.cat([x, t_emb], dim=-1)            # (batch, input_dim+hidden_dim)
        return self.main(x_in)                          # (batch, input_dim)

# 4. 实例化模型和优化器
model = SimpleNoisePredictor(input_dim, hidden_dim)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

# 5. 模拟一个极简的训练步骤
def show(name, batch):
    """逐样本打印,避免单行过长在手机端横向溢出"""
    for i, row in enumerate(batch.tolist()):
        print(f"  {name}[{i}]:", [round(v, 4) for v in row])

print("--- 模拟单次训练步骤 ---")
# 生成随机"数据"
x_0_batch = torch.randn(batch_size, input_dim)
print("原始数据批次 x_0 shape:", x_0_batch.shape)
show("x_0", x_0_batch)

# 随机采样时间步 t (从 1 到 T)
t_batch = torch.randint(1, T+1, (batch_size,))
print("采样的时间步 t:", t_batch.tolist())

# 采样随机噪声 epsilon
epsilon_batch = torch.randn_like(x_0_batch)
show("epsilon", epsilon_batch)

# 根据前向过程公式构造 x_t
# 注意:需要根据每个样本不同的 t 选取对应的 alpha_bar_t
alpha_bar_t_batch = alphas_cumprod[t_batch - 1].unsqueeze(-1)  # (batch, 1)
sqrt_alpha_bar_t = torch.sqrt(alpha_bar_t_batch)
sqrt_one_minus_alpha_bar_t = torch.sqrt(1. - alpha_bar_t_batch)

x_t_batch = sqrt_alpha_bar_t * x_0_batch + sqrt_one_minus_alpha_bar_t * epsilon_batch
show("x_t", x_t_batch)

# 前向传播:模型预测噪声
predicted_epsilon = model(x_t_batch, t_batch)
show("epsilon_theta", predicted_epsilon)

# 计算简化损失 L_simple (MSE)
loss = F.mse_loss(predicted_epsilon, epsilon_batch)
print(f"损失值 (MSE): {loss.item():.6f}")

# 反向传播与优化(演示用,这里只做一步)
optimizer.zero_grad()
loss.backward()
# 模拟一次参数更新
# optimizer.step()
print("训练步骤完成(优化器 step 已注释,仅演示流程)。")
--- 模拟单次训练步骤 ---
原始数据批次 x_0 shape: torch.Size([2, 4])
  x_0[0]: [1.1062, -1.7757, -2.7676, -1.236]
  x_0[1]: [-0.5107, -0.7649, -0.0038, -1.572]
采样的时间步 t: [4, 5]
  epsilon[0]: [1.4628, 0.1323, 0.5416, -0.0844]
  epsilon[1]: [0.0426, 0.2129, -0.1061, -0.2153]
  x_t[0]: [1.8301, -0.866, -1.0696, -0.7502]
  x_t[1]: [-0.1594, -0.1013, -0.0992, -0.8096]
  epsilon_theta[0]: [0.1376, 0.2833, -0.3778, 0.1369]
  epsilon_theta[1]: [0.1484, 0.2555, -0.3263, 0.1038]
损失值 (MSE): 0.354541
训练步骤完成(优化器 step 已注释,仅演示流程)。

可以看到,模型在初始随机权重下预测的噪声 epsilon_theta 与真实的 epsilon 几乎不相关,损失值停在 0.3545 左右——与“直接预测全零”的随机水平(本批噪声的均方值约 0.32)同量级,说明这一步模型还没学到任何东西。训练过程就是通过大量这样的样本,不断调整模型参数,使其预测的噪声越来越准。

采样生成:从噪声到数据

训练好噪声预测模型 ϵθ\boldsymbol{\epsilon}_\theta 后,我们就可以进行采样,从随机噪声 xT∼N(0,I)\mathbf{x}_T \sim \mathcal{N}(\mathbf{0}, \mathbf{I}) 开始,逐步生成数据 x0\mathbf{x}_0。

采样算法(论文 Algorithm 2)如下:

  1. 采样 xT∼N(0,I)\mathbf{x}_T \sim \mathcal{N}(\mathbf{0}, \mathbf{I})
  2. for t=T,T−1,...,1t = T, T-1, ..., 1 do:
  3. 采样 z∼N(0,I)\mathbf{z} \sim \mathcal{N}(\mathbf{0}, \mathbf{I}) if t>1t > 1, else z=0\mathbf{z} = \mathbf{0}
  4. xt−1=1αt(xt−βt1−αˉtϵθ(xt,t))+σtz\mathbf{x}_{t-1} = \frac{1}{\sqrt{\alpha_t}} \left( \mathbf{x}_t - \frac{\beta_t}{\sqrt{1 - \bar{\alpha}_t}} \boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t) \right) + \sigma_t \mathbf{z}
  5. end for
  6. return x0\mathbf{x}_0

其中 σt2\sigma_t^2 是反向过程的方差,我们可以选择 σt2=βt\sigma_t^2 = \beta_t 或 β~t\tilde{\beta}_t。这一步添加的噪声 z\mathbf{z} 是为了引入随机性,对应着生成过程的不确定性。当 t=1t=1 时,通常不再添加噪声。

下面的代码演示了使用一个未经训练的模型进行采样的过程。由于模型是随机的,生成的“数据”也将是随机的,但这展示了完整的采样流程。

import torch
import torch.nn as nn
import math

torch.manual_seed(42)  # 使用不同的种子以便观察

# 1. 重新定义模型和调度参数(确保本代码块自包含)
class SimpleNoisePredictor(nn.Module):
    def __init__(self, input_dim=4, hidden_dim=16):
        super().__init__()
        self.time_embed = nn.Sequential(nn.Linear(1, hidden_dim), nn.SiLU(), nn.Linear(hidden_dim, hidden_dim))
        self.main = nn.Sequential(
            nn.Linear(input_dim + hidden_dim, hidden_dim),
            nn.SiLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.SiLU(),
            nn.Linear(hidden_dim, input_dim)
        )
    def forward(self, x, t):
        t_emb = self.time_embed(t.view(-1, 1).float())
        return self.main(torch.cat([x, t_emb], dim=-1))

T = 5
betas = torch.linspace(0.1, 0.5, T)
alphas = 1. - betas
alphas_cumprod = torch.cumprod(alphas, dim=0)
# 计算反向过程方差,这里演示两种选择
sigma_t_beta = torch.sqrt(betas)          # 对应 sigma_t^2 = beta_t
# 计算 tilde_beta_t
tilde_beta_t = (1. - torch.cat([torch.tensor([1.0]), alphas_cumprod[:-1]])) / (1. - alphas_cumprod) * betas
sigma_t_tilde = torch.sqrt(tilde_beta_t)  # 对应 sigma_t^2 = tilde_beta_t

model = SimpleNoisePredictor()
model.eval()  # 切换到评估模式

# 2. 采样函数(DDPM 的单步采样:均值 + 按 sigma_t 加噪)
def ddpm_sample_step(x_t, t, use_tilde_var=False):
    """从 x_t 采样 x_{t-1}。使用未训练的模型,仅演示流程。"""
    with torch.no_grad():
        predicted_noise = model(x_t, torch.tensor([t]))
        # 计算均值部分
        coef1 = 1. / math.sqrt(alphas[t-1])
        coef2 = betas[t-1] / math.sqrt(1. - alphas_cumprod[t-1])
        mean = coef1 * (x_t - coef2 * predicted_noise)
        # 添加随机噪声
        sigma = sigma_t_tilde[t-1] if use_tilde_var else sigma_t_beta[t-1]
        if t > 1:
            z = torch.randn_like(x_t)
            x_prev = mean + sigma * z
        else:
            x_prev = mean  # 最后一步通常不加噪
        return x_prev

# 3. 执行采样流程(从纯噪声开始)
print("--- 扩散模型采样生成演示 (使用未训练模型) ---")
x_t = torch.randn(1, 4)  # 从标准高斯噪声开始,batch_size=1
print(f"初始噪声 x_{T}:", [[round(v, 4) for v in row] for row in x_t.tolist()])

for t in range(T, 0, -1):
    x_t = ddpm_sample_step(x_t, t, use_tilde_var=False)
    print(f"去噪后 x_{t-1}:", [[round(v, 4) for v in row] for row in x_t.tolist()])

print("\n由于模型未经训练,预测的噪声不准,因此生成的 x_0 看起来仍是随机噪声。")
print("训练有素的模型能够准确预测噪声,从而逐步去除噪声,还原出有意义的数据分布。")
--- 扩散模型采样生成演示 (使用未训练模型) ---
初始噪声 x_5: [[0.1005, 0.2325, 0.7064, 0.3693]]
去噪后 x_4: [[-0.3938, -0.6571, 0.591, 0.7774]]
去噪后 x_3: [[-1.4524, -1.3522, 1.3453, -0.3695]]
去噪后 x_2: [[-1.0207, -2.1272, 1.4005, -0.4243]]
去噪后 x_1: [[-1.6041, -2.7243, 1.5934, -0.2372]]
去噪后 x_0: [[-1.6553, -2.9259, 1.5998, -0.3255]]

由于模型未经训练,预测的噪声不准,因此生成的 x_0 看起来仍是随机噪声。
训练有素的模型能够准确预测噪声,从而逐步去除噪声,还原出有意义的数据分布。

(注:模型未经训练时 epsilon_theta 的输出接近 0,采样均值退化为 xt/αt\mathbf{x}_t / \sqrt{\alpha_t},于是 x_t 的幅度被逐步放大,得到的结果自然没有意义。这正说明训练的重要性:只有预测出非平凡的噪声,反向过程才能把纯噪声还原成数据。)

📝 动手练一练

问题:在采样算法中,反向过程的方差 σt2\sigma_t^2 可以选择 βt\beta_t 或 β~t\tilde{\beta}_t。请编写代码计算在 T=5,betas = [0.1, 0.2, 0.3, 0.4, 0.5] 的情况下,每个时间步 tt 对应的 βt\beta_t 和 β~t\tilde{\beta}_t 的值,并说明哪一个通常更小。这如何影响采样生成的结果?

👉 点击查看参考答案

思路:根据公式 β~t=1−αˉt−11−αˉtβt\tilde{\beta}_t = \frac{1 - \bar{\alpha}_{t-1}}{1 - \bar{\alpha}_t} \beta_t 进行计算。αˉt\bar{\alpha}_t 是 αt=1−βt\alpha_t = 1-\beta_t 的累积乘积。β~t\tilde{\beta}_t 是前向过程后验 q(xt−1∣xt,x0)q(\mathbf{x}_{t-1}|\mathbf{x}_t, \mathbf{x}_0) 的方差,它通常比前向过程单步方差 βt\beta_t 要小,因为它利用了 x0\mathbf{x}_0 的信息。在采样中,使用更小的方差意味着每一步添加的随机噪声更少,采样过程可能更“确定”,但多样性可能降低。

import torch
torch.manual_seed(0)
T = 5
betas = torch.linspace(0.1, 0.5, T)
alphas = 1. - betas
alphas_cumprod = torch.cumprod(alphas, dim=0)
# 计算 tilde_beta_t,注意处理 t=1 时 alpha_bar_0 定义为 1
alpha_bar_prev = torch.cat([torch.tensor([1.0]), alphas_cumprod[:-1]])
tilde_beta_t = (1. - alpha_bar_prev) / (1. - alphas_cumprod) * betas

print("时间步 t | beta_t     | tilde_beta_t")
print("-" * 40)
for t in range(T):
    print(f"  {t+1:2d}     | {betas[t]:.6f}   | {tilde_beta_t[t]:.6f}")
print(f"\n结论:对于所有 t>1,tilde_beta_t 均小于 beta_t。")
print("在采样时,使用 tilde_beta_t 作为方差会添加更少的随机噪声,")
print("可能使生成过程更稳定,但样本多样性可能略低于使用 beta_t。")
时间步 t | beta_t     | tilde_beta_t
----------------------------------------
   1     | 0.100000   | 0.000000
   2     | 0.200000   | 0.071429
   3     | 0.300000   | 0.169355
   4     | 0.400000   | 0.284404
   5     | 0.500000   | 0.410933

结论:对于所有 t>1,tilde_beta_t 均小于 beta_t。
在采样时,使用 tilde_beta_t 作为方差会添加更少的随机噪声,
可能使生成过程更稳定,但样本多样性可能略低于使用 beta_t。

常见错误

错误理解或写法现象或报错原因正确做法
在训练时对每个样本使用相同的时间步 t模型无法学习到不同噪声程度下的去噪能力,生成质量差。损失函数 LsimpleL_{\text{simple}} 的期望包含对时间步 tt 的均匀采样。每个 batch 中的样本应关联不同的 t。在每步训练中,使用 torch.randint(1, T+1, (batch_size,)) 为 batch 内每个样本独立采样时间步。
采样时忘记在 t>1t>1 的步骤添加随机噪声 z同一初始噪声永远得到同一结果,样本多样性显著低于预期。反向过程 pθ(xt−1∣xt)p_\theta(\mathbf{x}_{t-1} \mid \mathbf{x}_t) 是一个高斯分布,采样时应从该分布中随机抽取;只取均值等于偷偷把方差改成了 0,与训练时假设的方差设定不一致。若要做 DDPM 采样,严格按算法在 t>1t>1 时采样 z∼N(0,I)\mathbf{z} \sim \mathcal{N}(\mathbf{0}, \mathbf{I}) 并乘上 σt\sigma_t 加到均值上;若本来就想做确定性采样(DDIM),应显式说明并按 DDIM 的公式实现。
混淆 alpha_bar_t 与 alpha_t 在计算公式中的使用前向加噪或损失计算错误,导致训练不收敛。alpha_bar_t (αˉt\bar{\alpha}_t) 是累积乘积,用于计算 q(xt∣x0)q(\mathbf{x}_t \mid \mathbf{x}_0)。alpha_t (αt\alpha_t) 是单步系数,用于计算 μθ\boldsymbol{\mu}_\theta。仔细对照论文公式:前向采样用 αˉt\sqrt{\bar{\alpha}_t} 和 1−αˉt\sqrt{1-\bar{\alpha}_t};计算反向均值用 1αt\frac{1}{\sqrt{\alpha_t}} 和 βt1−αˉt\frac{\beta_t}{\sqrt{1-\bar{\alpha}_t}}。
未对时间步 t 进行嵌入(embedding)直接输入网络模型无法区分不同时间步,去噪性能严重下降。时间步 t 是离散标量,网络难以直接利用其信息。需要将其映射到连续向量空间。设计一个时间步嵌入层(如通过线性层+激活函数),将标量 t 转换为与网络隐藏层维度匹配的向量,再与输入拼接或相加。

本章小结

  1. 扩散模型通过训练一个反向去噪的马尔可夫链,来逆转一个固定的前向加噪链。
  2. 前向过程允许从原始数据 x0\mathbf{x}_0 直接采样任意时刻的加噪数据 xt\mathbf{x}_t,公式为 xt=αˉtx0+1−αˉtϵ\mathbf{x}_t = \sqrt{\bar{\alpha}_t} \mathbf{x}_0 + \sqrt{1-\bar{\alpha}_t} \boldsymbol{\epsilon}。
  3. 反向过程的核心是训练一个噪声预测网络 ϵθ(xt,t)\boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t),其训练目标简化为预测前向过程所加噪声的均方误差(MSE)。
  4. 采样时,从纯噪声开始,利用训练好的噪声预测网络,按照公式 xt−1=1αt(xt−βt1−αˉtϵθ)+σtz\mathbf{x}_{t-1} = \frac{1}{\sqrt{\alpha_t}} ( \mathbf{x}_t - \frac{\beta_t}{\sqrt{1-\bar{\alpha}_t}} \boldsymbol{\epsilon}_\theta ) + \sigma_t \mathbf{z} 逐步去噪,最终生成数据。
  5. 反向过程的方差 σt2\sigma_t^2 可以固定为 βt\beta_t 或 β~t\tilde{\beta}_t,后者通常更小,可能使采样更稳定。

📋 行动清单

  • 修改前向过程代码,将 T 增加到 100,并观察 alpha_bar_t 在最后几步是否接近 0,理解为何 xT\mathbf{x}_T 的分布接近标准正态分布。
  • 在“动手练一练”的代码中,把线性 betas 换成余弦调度(定义见 Improved Denoising Diffusion Probabilistic Models,Nichol & Dhariwal, 2021,arXiv:2102.09672 的式(17)),重新计算并比较 beta_t 与 tilde_beta_t 的变化趋势。
  • 查阅 PyTorch 官方文档,了解 nn.Embedding 层,思考如何用它来替代我们自定义的 time_embed 线性层序列,对离散时间步进行嵌入。

延伸阅读

本段 PyTorch 官方文档/教程条目的框架出处:PyTorch: An Imperative Style, High-Performance Deep Learning Library(Paszke et al., 2019,arXiv:1912.01703);页面内容以 PyTorch 2.14.1 为准。

  1. Denoising Diffusion Probabilistic Models(Ho et al., 2020,arXiv:2006.11239)—— 本节核心论文,提出了噪声预测的参数化和简化损失。
  2. Improved Denoising Diffusion Probabilistic Models(Nichol & Dhariwal, 2021,arXiv:2102.09672)—— 余弦噪声调度与可学习方差。
  3. PyTorch 官方文档 mse_loss:https://pytorch.org/docs/stable/generated/torch.nn.functional.mse_loss.html —— 均方误差损失函数的实现。

—— 小象教研组