一、概述
最原始、最基础的 DDPM 论文(2020)确实是无条件生成(Unconditional Generation)。也就是"随机开盲盒",你没法控制它具体生成什么。
但需要稍微纠正一下的是,它并不是真正的"胡乱"生成。如果用一堆猫的图片训练它,它去噪到底就会随机生成一只猫;如果是用ImageNet这种包含万物的庞大数据集训练,它每次就会随机从这几千个类别里抽一个生成。它的生成结果完全取决于训练集的数据分布。
显然,只能"开盲盒"在实际应用中是不够的。我们希望模型能听懂人话,比如指定生成"狗"或者输入一段文本"一只戴墨镜的猫"。因此,在基础 DDPM 的之上,有了条件扩散模型(Conditional Diffusion Models)。
目前主流的"加条件"方法有以下几种,它们彻底改变了扩散模型:
1. 分类器引导 (Classifier Guidance)
这是较早的一种做法。思路是:在 DDPM 去噪的过程中,额外引入一个已经训练好的图像分类器(当"裁判")。
- 在去噪的每一步,裁判都会看一眼当前的噪声图,计算它的梯度(比如"这团噪声有多像猫")。
- 然后利用这个梯度,硬拉着 DDPM 往"猫"的方向去噪。
2. 无分类器引导 (Classifier-Free Guidance, CFG)
这是目前常用的(像 Stable Diffusion、Midjourney 等都在用这个核心机制)。
- 它不需要额外的"裁判"。而是在训练 DDPM 时,有的时候给它条件(比如"猫"的标签),有的时候故意把条件抹掉(无条件)。
- 在生成时,模型同时预测"有条件"和"无条件"的结果,然后用一个数学公式放大两者之间的差异。这样就能生成极其贴合你给定条件的图像。
3. 交叉注意力机制 (Cross-Attention)
如果不仅仅是想加个"猫"这样的简单标签,而是想输入一段复杂的句子(文本提示词 Prompt),就需要用到这个。
- 它可以把文字转换成特征向量,然后在 DDPM 去噪的 U-Net 网络中,通过注意力机制将文本的特征"注入"到图像的特征中,实现文本对图像的精准控制。
二、Classifier Guidance
要想控制扩散模型生成特定的图(比如条件 yyy 是"猫"),公式是这样的:
(用一次贝叶斯定理,然后求梯度,其中有一项梯度为0,不难推出以下公式)
∇xtlogp(xt∣y)=∇xtlogp(xt)+∇xtlogp(y∣xt)\nabla_{\mathbf{x}_t} \log p(\mathbf{x}t|y) = \nabla{\mathbf{x}_t} \log p(\mathbf{x}t) + \nabla{\mathbf{x}_t} \log p(y|\mathbf{x}_t)∇xtlogp(xt∣y)=∇xtlogp(xt)+∇xtlogp(y∣xt)
- 等号左边 ∇xtlogp(xt∣y)\nabla_{\mathbf{x}_t} \log p(\mathbf{x}_t|y)∇xtlogp(xt∣y):我们最终想要的"带条件去噪方向"。
- 等号右边第一项 ∇xtlogp(xt)\nabla_{\mathbf{x}_t} \log p(\mathbf{x}_t)∇xtlogp(xt):扩散模型原本的"无条件去噪方向"。
- 等号右边第二项 ∇xtlogp(y∣xt)\nabla_{\mathbf{x}_t} \log p(y|\mathbf{x}_t)∇xtlogp(y∣xt):额外引入的分类器(由图片推类别,当然是分类器)。
为了让条件的控制力更强,给分类器的"修改意见"乘上了一个权重 sss(Guidance Scale):
∇xtlogp(xt∣y)=∇xtlogp(xt)+s∇xtlogp(y∣xt)\nabla_{\mathbf{x}_t} \log p(\mathbf{x}t|y) = \nabla{\mathbf{x}_t} \log p(\mathbf{x}t) + s \nabla{\mathbf{x}_t} \log p(y|\mathbf{x}_t)∇xtlogp(xt∣y)=∇xtlogp(xt)+s∇xtlogp(y∣xt)
当 sss 越大,模型就越听分类器的话。
1. 怎么训练?(需要练两个模型)
- 模型 A(画师): 训练一个基础的、无条件的 DDPM 模型。它的任务就是把纯噪声一步步还原成清晰的图像,不管画的是什么。
- 模型 B(裁判): 训练一个图像分类器 (比如 ResNet)。因为去噪过程中的图像全是雪花点,所以这个分类器必须用加了各级噪声的图像来专门训练。它的任务是:即便满屏雪花,也要能认出图里是不是"猫"。
2. 怎么推理?(双机协作)
假设我们要生成"猫":
- 第 ttt 步: 拿着当前的雪花图,先问"画师"(DDPM):"你下一步准备怎么画?" 画师给出一个无条件的去噪方向 ∇xtlogp(xt)\nabla_{\mathbf{x}_t} \log p(\mathbf{x}_t)∇xtlogp(xt)。
- 同时: 把这张雪花图扔给"裁判"(分类器),裁判算一下当前图像被判别为"猫"的概率,并对图像像素求梯度,得出一个修改意见 ∇xtlogp(y∣xt)\nabla_{\mathbf{x}_t} \log p(y|\mathbf{x}_t)∇xtlogp(y∣xt)。
- 合并: ****把画师的基础方向和裁判的修改意见加在一起,得到这一步最终的去噪方向。
- 循环: 拿着修改后的图,进入第 t−1t-1t−1 步,重复上述过程,直到生成最终图像。
老方法的致命缺点: 那个"裁判"太难伺候了!训练一个能在各种噪声下都能准确认物的分类器极其困难,而且分类器的梯度往往带有对抗性,会让生成的图像出现奇怪的伪影。
三、Classifier-Free Guidance
整个推导的核心目的只有一个:我们想生成符合条件的图像,但我们不想再额外训练一个笨重且耗时的图像分类器来当"裁判"了。
原公式: ∇xtlogp(xt∣y)=∇xtlogp(xt)+∇xtlogp(y∣xt)\nabla_{\mathbf{x}_t} \log p(\mathbf{x}t|y) = \nabla{\mathbf{x}_t} \log p(\mathbf{x}t) + \nabla{\mathbf{x}_t} \log p(y|\mathbf{x}_t)∇xtlogp(xt∣y)=∇xtlogp(xt)+∇xtlogp(y∣xt)
现在,我们不想用分类器了。怎么把公式里的 ∇xtlogp(y∣xt)\nabla_{\mathbf{x}_t} \log p(y|\mathbf{x}_t)∇xtlogp(y∣xt) 替换掉呢?
回到第一步的原始公式,通过简单的移项,把分类器这一项单独留在等号左边:
∇xtlogp(y∣xt)=∇xtlogp(xt∣y)−∇xtlogp(xt)\nabla_{\mathbf{x}_t} \log p(y|\mathbf{x}t) = \nabla{\mathbf{x}_t} \log p(\mathbf{x}t|y) - \nabla{\mathbf{x}_t} \log p(\mathbf{x}_t)∇xtlogp(y∣xt)=∇xtlogp(xt∣y)−∇xtlogp(xt)
这个等式揭示了:分类器的修改意见 = 有条件生成的方向 - 无条件生成的方向
这意味着,我们根本不需要额外的分类器!只要让同一个扩散模型同时算出"有条件的预测"和"无条件的预测",两者一减,就等于自带了一个虚拟分类器。
代入到 Classifier Guidance 的公式中:
最终方向=∇xtlogp(xt)+s(∇xtlogp(xt∣y)−∇xtlogp(xt))\text{最终方向} = \nabla_{\mathbf{x}_t} \log p(\mathbf{x}t) + s \big( \nabla{\mathbf{x}_t} \log p(\mathbf{x}t|y) - \nabla{\mathbf{x}_t} \log p(\mathbf{x}t) \big)最终方向=∇xtlogp(xt)+s(∇xtlogp(xt∣y)−∇xtlogp(xt))=(1−s)∇xtlogp(xt)+s∇xtlogp(xt∣y)= (1-s)\nabla{\mathbf{x}_t} \log p(\mathbf{x}t) + s\nabla{\mathbf{x}_t} \log p(\mathbf{x}_t|y)=(1−s)∇xtlogp(xt)+s∇xtlogp(xt∣y)
这就是大名鼎鼎的 Classifier-Free Guidance (CFG) 公式了。
1. 怎么训练?
- 我们只训练一个带有条件接收接口的 DDPM 模型(比如内部带有交叉注意力机制,能听懂文本提示词)。
- 每次给模型喂数据(图片 + 对应的提示词"猫")时,有一定的概率,我们故意把提示词"猫"抹掉,替换成一个空字符串(Null Token, 记作 ∅\emptyset∅)。
- 强迫这一个模型同时学会两样本事。当给它提示词时,它学的是"有条件生成";当给它空字符串时,它学的是"无条件生成"。
2. 怎么推理?
假设我们还是要生成"猫",并且设置 Guidance Scale s=7.5s = 7.5s=7.5。
模型把当前雪花图和提示词"猫"一起输入给模型,得到有条件方向。把同一张雪花图和 ∅\emptyset∅ 一起输入给同一个模型,得到无条件方向。
最终方向=无条件方向+7.5×(有条件方向−无条件方向)\text{最终方向} = \text{无条件方向} + 7.5 \times (\text{有条件方向} - \text{无条件方向})最终方向=无条件方向+7.5×(有条件方向−无条件方向)
用这个算出来的最终方向更新图像,进入下一步。
因为无条件和有条件的方向是同一个模型算出来的,它们的特征空间完全对齐,做减法得到的"差值(纯粹的条件特征)"极其精准。所以目前 DALL-E 3、Midjourney、Stable Diffusion 全都在用 CFG。
四、代码
CFG 的训练与推理
1. 训练阶段
以一定的概率(通常 10%~20%)丢弃条件,强迫模型同时学会无条件生成。
python
import torch
import random
def train_step(model, images, conditions):
# 1. 给干净的图像加噪
noise = torch.randn_like(images)
noisy_images = add_noise(images, noise, timestep)
# 2. CFG 的核心魔法:条件 Dropout (比如 10% 的概率)
if random.random() < 0.1:
# 把条件变成空(比如全0的向量,或者特定的 Null Token)
conditions = torch.zeros_like(conditions)
# 3. 模型预测噪声 (注意:此时模型既见过了有条件,也见过了无条件)
predicted_noise = model(noisy_images, timestep, conditions)
# 4. 计算 Loss 并反向传播
loss = mse_loss(predicted_noise, noise)
loss.backward()
2. 推理阶段
核心魔法在于:把同一个批次的数据复制一份,一半给条件,一半不给条件,算出结果后做向量外推。
python
def inference_step(model, noisy_images, text_conditions, guidance_scale=7.5):
# 1. 构造"精神分裂"的输入:把图像复制成两份拼在一起 (Batch Size 翻倍)
# 假设 noisy_images shape 为 [1, 3, 64, 64] -> double_noisy_images 为 [2, 3, 64, 64]
double_noisy_images = torch.cat([noisy_images, noisy_images], dim=0)
# 2. 构造条件:一半是空条件(无条件),一半是真实文本条件
null_conditions = torch.zeros_like(text_conditions)
double_conditions = torch.cat([null_conditions, text_conditions], dim=0)
# 3. 一次前向传播,同时算出两个结果!(高效)
# double_preds 形状为 [2, 3, 64, 64]
double_preds = model(double_noisy_images, timestep, double_conditions)
# 4. 把结果拆开
uncond_pred, cond_pred = double_preds.chunk(2, dim=0)
# 5. CFG
final_pred = uncond_pred + guidance_scale * (cond_pred - uncond_pred)
return final_pred
Cross Attention 注入条件
当我们的条件是文本(一串向量),而图像是特征图(网格状的像素)时,直接相加是不行的。Cross Attention 完美解决了"图文跨界相亲"的问题。
图像特征作为 Query(Q,我想寻找什么特征),文本特征作为 Key 和 Value(K, V,我能提供什么特征)。
python
import torch.nn as nn
class CrossAttention(nn.Module):
def __init__(self, embed_dim):
super().__init__()
# 图像生成 Q
self.to_q = nn.Linear(embed_dim, embed_dim)
# 文本(条件)生成 K 和 V
self.to_k = nn.Linear(embed_dim, embed_dim)
self.to_v = nn.Linear(embed_dim, embed_dim)
# 缩放因子 (防止 Softmax 梯度消失)
self.scale = embed_dim ** -0.5
def forward(self, image_features, text_conditions):
# image_features: [batch, sequence_length, dim] (图像展平后的特征)
# text_conditions: [batch, text_length, dim] (文本特征)
Q = self.to_q(image_features)
K = self.to_k(text_conditions)
V = self.to_v(text_conditions)
# 1. 图像和文本算相似度 (注意力矩阵)
attention_scores = torch.matmul(Q, K.transpose(-1, -2)) * self.scale
attention_probs = attention_scores.softmax(dim=-1)
# 2. 根据相似度,提取文本中的特征融入图像
injected_features = torch.matmul(attention_probs, V)
return injected_features
全局相加注入 ( Timestep / Class Embedding)
通常用于注入全局信息(比如当前是第几步 Timestep,或者你要生成的是第几类类别条件)。
它非常粗暴有效:把条件通过一个线性层映射成和图像特征一样的维度,然后直接加在一起。
python
class SimpleConditionEmbedding(nn.Module):
"""适合教程的全局条件注入模块"""
def __init__(self, feature_dim, cond_dim):
super().__init__()
# 把外部条件(如类别、标签、当前时间步)映射到与特征相同的维度
self.cond_proj = nn.Linear(cond_dim, feature_dim)
# 融合后的处理层
self.linear_1 = nn.Linear(feature_dim, feature_dim)
self.act = nn.SiLU() # 扩散模型常用的激活函数
self.linear_2 = nn.Linear(feature_dim, feature_dim)
def forward(self, x, condition=None):
# x: 当前的图像特征或时间步特征
if condition is not None:
# 【核心】:直接把条件投射后,加到特征上
x = x + self.cond_proj(condition)
# 经过全连接层进一步融合
x = self.act(self.linear_1(x))
x = self.linear_2(x)
return x
Adaptive Normalization
在生成模型(如 DiT, StyleGAN)中,基本都喜欢用条件来直接控制归一化层的缩放(Scale)和平移(Shift)。这种方式能极其丝滑地改变整张图的"风格"或"全局状态"。
python
class TutorialAdaLayerNorm(nn.Module):
def __init__(self, feature_dim, cond_dim):
super().__init__()
# 标准的 LayerNorm,但去掉了自带的可学习参数 (weight 和 bias)
# 因为我们要用条件来代替它们!
self.norm = nn.LayerNorm(feature_dim, elementwise_affine=False)
# 把条件映射为两倍的特征维度(一半做缩放,一半做平移)
self.cond_mlp = nn.Sequential(
nn.SiLU(),
nn.Linear(cond_dim, feature_dim * 2)
)
def forward(self, x, condition):
# 1. 基础的归一化 (让数据变成均值0,方差1)
normalized_x = self.norm(x)
# 2. 根据条件计算出专属的 scale(伽马) 和 shift(贝塔)
cond_params = self.cond_mlp(condition)
scale, shift = torch.chunk(cond_params, 2, dim=-1)
# 3. 【核心魔法】:用条件来重塑数据的分布
# 注意这里是 (1 + scale),这是一种残差设计的技巧,让初始缩放比例接近 1
output = normalized_x * (1 + scale) + shift
return output