原理解读
这篇文章想搞清楚的一点是:为什么可以实现分布的学习与生成,以及如何实现的。
噪声学习与真实分布学习的等价
数据分布建模
首先要严肃理解一句话:我们希望建模一个数据分布 $p_{data}(x)$ ,这句话意味着什么。
现在假设给你一堆图像样本,比如 MNIST
的手写数字图,又或者 CelebA
的人脸照片,你会发现这些图像不是乱来的,而是有规律:
- 人脸都朝前
- 数字都写在中间
- 灰度值分布有形状
这些规律就是所谓的数据生成机制,也是我们所关注的数据的 feature
。而我们可以假设这些图像是从一个未知的 “自然规律” 中采样得到:
这里的 $x \in \mathbb{R}^D$ 是图像(展开后的向量,如 32 x 32 x 3
图像展开后就是 $x \in \mathbb{R}^{3072}$),这里的 $p_{data}$ 是定义在相同维度上的概率密度,它描述的是任意一个点 $x$ 的概率密度有多高(也即从自然分布中采样时落在 $x$ 附近的概率大不大)。举个例子,假设在某个数据集上,已经知道 $p_{data}$ 是一个高斯分布:
那么输入一个 $x$,通过这个概率密度函数可以知道:
这个 $x$ 是不是常见的,也就是说这是否是一张有意义的图片而不是乱码。如果它靠近 $\mu$,密度值就高;远离 $\mu$ 那么密度值就减小。图像上的意义就是,你给的这个"人脸图像"有多像训练集中经常出现的脸。
我们并不是真的想得到 $p_{data}(x)$ 的表达式,那么建模这个分布的目的是什么?我们要的功能是:
想做的事 | 为什么需要分布 |
---|---|
生成新图像 $x \sim p(x)$ | 知道分布后就可以对其进行采样 |
判断一个图像是否"合理" | 可以观察 $p(x)$ 的值高不高 |
做概率推理/压缩 | 对一些低熵区域可以更好地编码 |
使用 score function 走回高密度区 |
计算 $\nabla_x \log p(x)$ |
对于 Diffusion
模型而言,知道了这个分布,就知道了一张图像在真实世界中“出现的自然性”有多高,那么我们就可以通过逼近得到一些符合真实世界的有意义的图片。
模型学习
现在我们明确了学习目标:真实世界的图片的数据分布。但是面临的有以下几个问题:
- 无法得到真实的数据分布,数据量过大以致于无法有效描述与表征。
- 分布过于复杂,难以学习。
我们首先来解决第二个问题。对于一个复杂的分布而言,一个很自然的思路就是:从简单的高斯分布开始,一点一点区学习去逼近 。而这就是扩散模型的做法。人为构造一个序列分布,起点是需要学习的数据分布,终点是一个标准正态分布,在前向过程中每一次都添加一个随机高斯噪声实现分布的变换:
$$ q\left(x_{t} \mid x_{t-1}\right)=\mathcal{N}\left(x_{t} ; \sqrt{1-\beta_{t}} x_{t-1}, \beta_{t} \mathbf{I}\right) $$在学习过程中,我们关注的是它的逆过程。不能直接计算 $q(x_{t-1}|x_t)$(因为涉及到真实数据的后验),所以引入一个模型:$p_{\theta}(x_{t-1}|x_t)$ ,希望通过训练这个模型,使得其近似真实后验 $p_{\theta}(x_{t-1}|x_t) \approx q(x_{t-1}|x_t)$ 。借助可学习的参数 $\theta$ 可以表达逆向的参数化模型:
$$ p_{\theta}\left(x_{0}, x_{1}, \ldots, x_{T}\right)=p\left(x_{T}\right) \prod_{t=1}^{T} p_{\theta}\left(x_{t-1} \mid x_{t}\right)\ \ \ \ p(x_T)=\mathcal{N}(0,I) $$由于真实数据分布我们无法得到,只能通过一些样本对其进行估计,而这里的样本就是训练模型时构造的数据集。为了更够更好地使用样本估计整体,就可以使用简单直观的 最大似然估计 :我们尝试去最大化每一个样本在分布(该分布为模型训练到的分布)中出现的概率,以此作为我们的优化目标。也即对任意真实样本 $x_0$ ,我们希望最大化其概率:
$$ \log p_{\theta}(x_0) $$但是这个概率不能直接计算(积分太复杂),考虑使用变分推断构造一个下界,然后尽可能让该下界的值更大。引入前向加噪分布 $q(x_{1:T}|x_0)$ 作为“变分分布”,可以得到:
$$ \begin{align*} \log p_{\theta}\left(x_{0}\right) &= \mathbb{E}_{q\left(x_{1: T} \mid x_{0}\right)}\left[\log p_{\theta}\left(x_{0}\right)\right] \\ &=\mathbb{E}_{q}\left[\log \frac{p_{\theta}\left(x_{0}, x_{1: T}\right)}{p_{\theta}\left(x_{1: T} \mid x_{0}\right)}\right] \\ &= \mathbb{E}_{q}\left[\log \frac{p_{\theta}\left(x_{0}, x_{1: T}\right)}{q\left(x_{1: T} \mid x_{0}\right)}\right]+\underbrace{\mathbb{E}_{q}\left[\log \frac{q\left(x_{1: T} \mid x_{0}\right)}{p_{\theta}\left(x_{1: T} \mid x_{0}\right)}\right]}_{\text {KL Divergence } \geq 0} \\ &\geq \mathbb{E}_{q}\left[\log \frac{p_{\theta}\left(x_{0}, x_{1: T}\right)}{q\left(x_{1: T} \mid x_{0}\right)}\right] := \mathrm{ELBO} \end{align*} $$将两个链式展开(对整个轨迹建模)有:
$$ \begin{align*} \mathrm{ELBO} &= \mathbb{E}_{q}\left[\log \frac{p\left(x_{T}\right) \prod_{t=1}^{T} p_{\theta}\left(x_{t-1} \mid x_{t}\right)}{\prod_{t=1}^{T} q\left(x_{t} \mid x_{t-1}\right)}\right] \\ &= \underbrace{\mathbb{E}_{q}\left[\log p\left(x_{T}\right)\right]}_{\text {简单常数 }}+\sum_{t=1}^{T} \mathbb{E}_{q}\left[\log \frac{p_{\theta}\left(x_{t-1} \mid x_{t}\right)}{q\left(x_{t} \mid x_{t-1}\right)}\right] \end{align*} $$由于我们设计了 $q(x_t|x_{t-1})$ 是高斯分布,已知表达式,而我们希望用神经网络去你和每一步的逆向分布 $p_{\theta}(x_{t-1}|x_t)$,这个 KL
项就是优化目标。不过不得不提的是,在论文《DDPM》指出,直接去拟合 $p_{\theta}(x_{t-1}|x_t)$ 还是太难了,反过来思考,如果我们用神经网络去预测添加到样本 $x_0$ 上的噪声 $\epsilon$,即可重建 $x_{t-1}$。定义一个简化损失:
这是一个 MSE
损失,但也是 ELBO
中所有 KL
项的近似形式,因此优化这个损失就等于在优化原始目标 $\log p_{\theta}(x_0)$ 的下界,逻辑闭环。
中间的思维推理过程可以概括如下:
- 建立目标:最大化 $\log p_{\theta}(x_0)$
- 构造变分下界:$\mathrm{ELBO}=\mathbb{E}_q[\log \frac{p_{\theta}}{q}]$
- 根据马尔科夫建立链式模型:加噪链 $q$,建模逆链 $p_{\theta}$
- 推导损失:
KL
项为主导,简化为噪声预测MSE
- 结论:噪声预测训练目标等价于最大化数据似然的变分下界,等价于学习真实空间的数据分布
Diffusion 中的变分推断
变分推断的基本思想
可以从贝叶斯角度出发来看待这个问题。
设我们有一些潜变量(latent varible) $z$,数据 $x$ 是从 $p(x,z)$ 中联合生成的。我们想优化:
$$ \log p(x)=\log \int p(x, z) d z $$这就是所谓的 “边缘对数似然”,也是生成模型的目标 —— 但这个积分通常不可积(尤其是高维 $z$)。所以退而求其次,引入一个 “可控”的分布 $q(z|x)$ 来近似真实的后验 $p(z|x)$。假设有个 “助手分布” $q(z|x)$,我们人为指定它的形式,在优化过程中同时学习 $q(z|x)$ 和生成模型 $p(x|z)$,使它们在数学上相互靠近,这里的 $q(z|x)$ 被称之为变分分布(variational distribution),是我们优化的目标之一。近似之后,优化的目标变成了:
- 最大化 $\textrm{ELBO}$
- 最小化 $D_{\mathrm{KL}}(q(z \mid x) \| p(z \mid x))$
第二点无法做到,由此通过最大化 $\mathrm{ELBO}$ 来实现优化。
到底在做什么
在 Diffusion
模型中,我们把:
- 潜变量换成为扩散链中的中间状态:$x_1,x_2,\cdots,x_T$
- 数据 $x_0 \sim p_{data}$
- 引入了一个人为构造的变分分布:$q(x_{1:T}|x_0)$,从数据加噪而来,表示数据的加噪轨迹
我们希望学习一个模型分布:
$$ p_{\theta}\left(x_{0}, x_{1}, \ldots, x_{T}\right)=p\left(x_{T}\right) \prod_{t=1}^{T} p_{\theta}\left(x_{t-1} \mid x_{t}\right) $$这与上文所述 $p(x,z)$ 完全一致,只是潜变量变成了多个时间步下的噪声。于是可以写出:
$$ \log p_{\theta}\left(x_{0}\right) \geq \mathbb{E}_{q\left(x_{1: T} \mid x_{0}\right)}\left[\log \frac{p_{\theta}\left(x_{0}, x_{1: T}\right)}{q\left(x_{1: T} \mid x_{0}\right)}\right] $$这就是在 Diffusion
中的 $\mathrm{ELBO}$,最终优化这个下界。
Q&A
Question1:条件扩散的实现
文本的
text_emb
在扩散过程中是如何影响噪声的预测的?
对于某个时间步 $t$,模型输入输出如下:
内容 | 具体描述 |
---|---|
输入 $x_t$ | 当前 timestep 下的 noisy 图像 |
输入 $t$ | 时间步编码(sinusoidal embedding 或 learned ) |
可选 $c$ | 条件信息(比如文本 prompt embedding ) |
输出 $\hat{\epsilon}$ | 模型预测的噪声,用于反推 $x_0$ 或 $x_{t-1}$ |
文本 embedding
本质上是作为条件 $c$ 引入到神经网络中,它不会直接影响网络的输入,但会在 U-Net 的中间层通过注意力机制调控噪声预测过程。U-Net
是由多个 ResBlock + AttentionBlock
组成的结构,其中每个 AttentionBlock
(或 TransformerBlock
)中都可能包含一个 Cross-Attention Layer 。注意力层计算为:
其中的每一项为:
- $Q \in \mathbb{R}^{N \times d}$,来自图像噪声的
query
,其中 $N = H \times W$ 或latent dim
- $K,V \in \mathbb{R}^{L \times d}$,来自条件 $c$ (如文本
embedding
)的Key
和Value
在没有文本的时候,U-Net
只能“盲猜”哪个位置的噪声应该消成什么图样。有了文本 embedding
,U-Net 的注意力层就能在图像和文本之间建立对齐关系,从而指导模型朝着 prompt
中的语义进行生成,会在合适的图像区域还原条件特征并添加相应细节。研究表明,不同层级的注意力层对生成结果又不同的影响:
-
高分辨率层(如 64×64): 关注图像的局部细节,如纹理和边缘。
-
中等分辨率层(如 32×32): 处理中等尺度的特征,影响物体的形状和结构。
-
低分辨率层(如 16×16): 捕捉全局信息,决定图像的整体布局和构图。
Question2:模型输出的确定性与随机性
对于同一个噪声与同一个时间步,同一个文本,所有的 embedding 方式相同时,模型预测出的噪声是固定的吗?
这个问题和采样方式有关,$\text{DDPM}$ 采样会在每一个时间步 $t$ 生成 $x_{t-1}$ 的时候加一点采样噪声 $x_{t-1} \sim \mathcal{N}\left(\mu_{\theta}\left(x_{t}, t\right), \Sigma_{\theta}\right)$,导致即使从同一个 $x_T$ 出发每一步都会有轻微不同。但是 $\text{DDIM}$ 是一种确定性映射,只依赖预测噪声,此处讨论默认采用 $\text{DDIM}$ 采样。给出扩散模型汇总的噪声预测函数:
$$ \hat{\epsilon}_{\theta}\left(x_{t}, t, c\right)=\mathrm{U}\text{-}\mathrm{Net}\left(x_{t} ; t, c\right) $$因此在模型参数固定,输入所有变量固定的前提下,输出就一定是确定性的结果。
那么哪些因素可以体现生成的多样性,换句话说哪些机制可能让模型的预测变的不确定?主要行为有:
机制 | 如何影响预测稳定性 |
---|---|
不同初始噪声 | 改变 $x_T$,多样性的主要体现,通常使用随机种子生成 |
$\text{Dropout}$ | 修改内部参数传递,一般训练时开启,推理时通常关闭 |
$\text{Classifer-Free Guidance}$ | 会跑两次:有条件/无条件,之后组合 |
启发式 noise offsets |
有些模型会使用 latent offsets |
模型 weight noise |
修改模型权重 |
不同 scheduler 参数 |
去噪轨迹不同 |
Question3:预测噪声的生成
给定不同时间步,预测的噪声都是通过同一个
U-Net
网络的相同权重参数进行预测的,这里预测的是不同分布之间应该如何转换,却使用了相同的预测方式,这种方式是否合理呢?
这种设计是合理的,而且也是 Diffusion
成功的核心之一。如果用完全不同的网络去拟合每一个 $t$,反而会带来极大的参数冗余和训练不稳定性。这个 U-Net
可以理解为一个时间条件模型,通过 “时间步编码 $t$” 来模拟不同时间步上的 score function
,调控网络的形式在不同的时间步拥有不同的 “功能状态”。U-Net
的前几层会接受一个时间步嵌入 $\tau_{t}=\text{TimeEmbedding}(t)$,这个向量:
- 是一个以自己独立编码方式生成的时间特征
- 会被加到
ResBlock
的中间状态,或者用于调节attention weights
- 是一个显式控制信号,让网络知道现在是“第几步去噪”
模拟连续扩散
Score-based Diffusion
在理论上是一个连续时间随机过程(SDE),网络学习的是:
如果每个 $t$ 都用不同的网络,很难拟合连续的轨迹(不稳定,不平滑),而共享网络通过 $t$ 来做条件控制,就可以自然地模拟一个连续场 $s(x,t)$。
时间可以作为功能选择器
U-Net
可以通过 $t$ 这个时间信号选择不同的建模行为,是一种参数共享但功能变化的设计。
时间步 $t$ | 网络任务(简化理解) |
---|---|
小 $t$(噪声少) | 学习细节(纹理、边缘) |
中间 $t$ | 学习中尺度结构(物体轮廓) |
大 $t$(噪声多) | 学习大轮廓(姿势、背景、布局) |
训练效率高
这样设置只需一个模型,性能更加稳定,采样更加自然。
代码总览
在使用 Stable Diffusion 进行生成任务时,编码部分主要包括以下核心组件:
- 文本编码器(Text Encoder):将输入的文本提示(prompt)转换为计算机可理解的语义向量表示。Stable Diffusion 使用预训练的 CLIP(Contrastive Language-Image Pre-training)模型作为文本编码器,确保生成的图像与输入文本在语义上保持一致。
- 变分自编码器(VAE, Variational Autoencoder):负责在潜在空间(latent space)和像素空间之间进行转换。VAE 的编码器将输入图像压缩到低维的潜在表示,解码器则将潜在表示还原为高维的图像数据。这样可以在较低维度的空间中进行扩散过程,提高计算效率。
- U-Net 模型:一个用于预测噪声的神经网络,具有编码器-解码器的对称结构。在本文中不考虑 Transformer 的变形,只对 U-Net 展开基本的讨论。U-Net 在扩散过程中,通过逐步去除潜在表示中的噪声,生成清晰的图像。其中特别引入了交叉注意力(Cross-Attention)机制,使模型能够结合文本编码器的语义信息,对图像生成进行条件控制。
- 调度器(Scheduler):调度器定义了扩散过程中的去噪步骤和策略,控制从随机噪声到生成图像的演变过程。不同的调度器(如 PNDM、DDIM、K-LMS 等)会影响生成图像的质量和生成速度。
常用 diffusers
库进行代码的编写与修改,在这里使用 MV-Adapter
中的 i2mv
进行代码展示,具体代码参考这里。
Scheduler 与时间步的生成
时间步 timestep
生成代码如下:
# Prepare timesteps
timesteps, num_inference_steps = retrieve_timesteps(
self.scheduler, num_inference_steps, device, timesteps
)
这一段会调用调度器,根据指定的推理部署计算出一个时间步序列,而这个序列通常是从高噪声到低噪声的一个递减的序列,每个实践部代表了扩散过程中的一个特定噪声级别。这里返回的 num_inference_step
为实际的推理步数,而 timesteps
为一个具体的时间步序列,用于逐步去噪。
# 假设给定 num_inference_steps = 50
# 可能生成的序列为:
timesteps = [1000, 980, 960, ..., 20, 0] # 从高噪声到低噪声的递减序列
通过生成的序列,可以决定扩散过程的精细程度。步数越多,生成质量也会越好,计算时间也会越长。而且时间步的分布会对生成结果的质量有重要影响,是扩散模型能够逐步将随机噪声转变为清晰图像的关键机制之一。函数 retrieve_timesteps
对时间步的生成支持三种设置方式:自定义 timesteps
列表,自定义 sigma
以及使用默认线性均匀采样,具体可见 源码链接 。
对于 Scheduler
部分,在 这里 给出了常见的一些调度器:
# 将此类型设为枚举类型,这简化了文档中的使用,并在调度器模块 SchedulerMixin 中在 "_compatibles" 时防止了循环导入
# 当它在 pipeline 中用作类型时,实际上是一个联合类型,实际的调度器实例同样会被传入
class KarrasDiffusionSchedulers(Enum):
DDIMScheduler = 1
DDPMScheduler = 2
PNDMScheduler = 3
LMSDiscreteScheduler = 4
EulerDiscreteScheduler = 5
HeunDiscreteScheduler = 6
EulerAncestralDiscreteScheduler = 7
DPMSolverMultistepScheduler = 8
DPMSolverSinglestepScheduler = 9
KDPM2DiscreteScheduler = 10
KDPM2AncestralDiscreteScheduler = 11
DEISMultistepScheduler = 12
UniPCMultistepScheduler = 13
DPMSolverSDEScheduler = 14
EDMEulerScheduler = 15
而对于一个调度器类,我们更关心的是下面的几个常用函数,具体写法可以阅读 DDIMScheduler 源代码,在此仅做函数功能介绍:
class KarrasDiffusionSchedulers:
def set_timesteps(self, num_inference_steps, device=None, timesteps=None, **kwargs):
"""设置采样的时间步"""
pass
def scale_model_input(self, sample, timestep):
"""缩放模型输入"""
pass
def step(self, model_output, timestep, sample, **kwargs):
"""执行单个去噪步骤"""
pass
def add_noise(self, original_samples, noise, timesteps):
"""添加噪声到样本"""
pass
扩散过程的推理
接下来需要构建整个扩散过程的起点,也即初始的随机噪声:
# Prepare latent variables
num_channels_latents = self.unet.config.in_channels
latents = self.prepare_latents(
batch_size * num_images_per_prompt,
num_channels_latents,
height,
width,
prompt_embeds.dtype,
device,
generator,
latents,
)
接下来对部分参数做出解释说明:
- 第一个参数为
batch_size
,也即对应的这里传的多了一个num_images_per_prompt
参数表达了对每个prompt
生成多少张图片,通常为 1。 - 第二个参数
num_channels_latents
对齐的是VAE
编码后的latent space
下的通道数,通常为 4,也和后续的unet
通道数对齐,同理后续的H
和W
也和latent space
下的数据对齐,并不会和原图尺寸对齐。 generator
是一个随机数生成器,之前定义的随机种子就是传给了这里,可以控制随机性。
通过这样一个函数,提供了扩散起点,确保了数据格式的正确,返回的是一个 [b, c, h, w]
的随机化的张量,同时支持批处理生成,随机数发生器的设定也保证了生成过程的可重复性。
接下来对输入的参考图像进行编码处理:
# Preprocess reference image
reference_image = self.image_processor.preprocess(reference_image)
reference_latents = self.prepare_image_latents(
reference_image,
timesteps[:1].repeat(batch_size * num_images_per_prompt), # no use
batch_size,
1,
prompt_embeds.dtype,
device,
generator,
add_noise=False,
)
函数 image_processor.preprocess
会将图片进行标准化处理:将图片转换为张量格式,调整尺寸,标准化像素值(标准化后的像素值的合理数据范围为[0,1])并转换颜色通道顺序(预期顺序为 RGB),最终输出一个 [1, 3, H, W]
的张量。而函数 prepare_image_latents
实现的是 VAE encoding
相关内容。这里的 add_noise=False
表示保留原始图片信息,不进行随机扰动,在部分模型中这里会加入随机扰动(跟时间步有关,这里为 False
所以传入的 timesteps
没有被使用),其目的是提供变化的起点,允许图像有创造性的变化,一般用于 i2i
的生成与图像编辑,风格生成等。经过编码之后,返回的是一个 [b, 4, h/8, w/8]
的张量,数据类型和设备与模型保持一致。
得到了输入参考图片在 latent space
下的表示后,需要从中提取一些图片特征,这里采用了零时间步来获取干净图像的特征表示:
ref_timesteps = torch.zeros_like(timesteps[0])
ref_hidden_states = {}
self.unet(
reference_latents,
ref_timesteps,
encoder_hidden_states=prompt_embeds[-1:],
added_cond_kwargs={
"text_embeds": add_text_embeds[-1:],
"time_ids": add_time_ids[-1:],
},
cross_attention_kwargs={
"cache_hidden_states": ref_hidden_states,
"use_mv": False,
"use_ref": False,
},
return_dict=False,
)
为什么使用全零时间步?
在扩散模型中,时间步表示噪声程度,
t=0
时表示完全无噪声的原始图像,以此经过unet
后可以提取纯净的图像特征,避免噪声干扰。
在拿到参考图像的特征知道后,代码中将其存到了 ref_hidden_states
当中,在最后循环去噪的过程中作为指导信息一并传入:
cross_attention_kwargs={
"mv_scale": mv_scale, # 多视图注意力的缩放因子
"ref_hidden_states": {k: v.clone() for k, v in ref_hidden_states.items()}, # 参考图像的特征
"ref_scale": reference_conditioning_scale, # 参考特征的影响强度
**(self.cross_attention_kwargs or {}),
}
# - 控制不同注意力机制的行为
# - 平衡参考图像和生成内容的影响
之后进入真正的去噪过程:
for i, t in enumerate(timesteps):
if self.interrupt:
continue
# ...
# predict the noise residual
noise_pred = self.unet(
latent_model_input, # 当前去噪步骤的潜在表示
t, # Size: [1] 表示当前时间步
encoder_hidden_states=prompt_embeds, # 文本提示的编码特征 Size: [b, seq_len, hidden_size]
timestep_cond=timestep_cond, # 时间步条件嵌入
cross_attention_kwargs=cross_attention_kwargs, # 控制不同注意力机制的行为
down_intrablock_additional_residuals=down_intrablock_additional_residuals, # 来自 Scheduler 的额外特征,用于精确控制
added_cond_kwargs=added_cond_kwargs, # 额外条件参数
return_dict=False,
)[0]
# perform guidance CFG
if self.do_classifier_free_guidance:
noise_pred_uncond, noise_pred_text = noise_pred.chunk(2)
noise_pred = noise_pred_uncond + self.guidance_scale * (
noise_pred_text - noise_pred_uncond
)
# ...
# compute the previous noisy sample x_t -> x_t-1
latents_dtype = latents.dtype
latents = self.scheduler.step(
noise_pred, t, latents, **extra_step_kwargs, return_dict=False
)[0]
# ...
对于 unet
传入参数,组合效果可以达到的是:
- 内容控制:文本提示控制主题,参考图像提供视觉知道,额外特征提供细节控制。
- 生成过程控制:时间步控制去噪程度,注意力参数控制特征融合,条件参数提供额外指导。
- 质量控制:多个条件在协同作用,需要超参数平衡不同特征的影响,精确控制生成细节。
之后经过多轮去噪便可得出最后的结果图,然后保存输出即可。以上即为 Stable Diffusion
推理部分的代码实例解读。