↓ 跳过正文
  1. Posts/

Timestep Embedding 学习笔记:一个共享的 U-Net 怎么做条件去噪

·5259 字·11 分钟

卡在 U-Net 文章结尾的一个问题
#

上一篇结尾我写:DDPM 里的 U-Net 和 2015 年原版 U-Net 有几处关键扩展,第一个就是 Timestep Embedding——通过 sinusoidal embedding 将时间步 \(t\) 编码成向量,注入到每个 residual block 中。

当时只写了一句话,没有展开。但 \(\epsilon_\theta(x_t, t)\) 这个符号在之前两篇 DDPM 的推导里反复出现,我一直把 \(t\) 当成一个抽象的输入参数——“网络知道当前时间步”——没有认真想过一个很具体的工程问题:

一个标量整数 \(t\) 到底是怎么进入一个卷积神经网络的?

反向去噪那篇讲过,不同 timestep 对应不同难度的去噪任务。\(t\) 小的时候噪声轻,图片还看得出原本是什么;\(t\) 大的时候几乎全是噪声,原图信息基本丢光了。这两种情况下网络需要做的事完全不一样。

那 DDPM 定义了 \(T = 1000\) 个时间步,为什么不需要 1000 个独立的 U-Net?

去噪网络需要知道 t
#

先回到符号。前向扩散那篇推导过,给定原始图片 \(x_0\) 和时间步 \(t\),带噪图片可以一步构造:

$$ x_t = \sqrt{\bar\alpha_t}\, x_0 + \sqrt{1 - \bar\alpha_t}\, \epsilon, \quad \epsilon \sim \mathcal{N}(0, I) $$

去噪网络的任务是:看着 \(x_t\),预测其中被加进去的噪声 \(\epsilon\)。

如果网络只接受 \(x_t\) 作为输入,写成 \(\epsilon_\theta(x_t)\),问题在哪?

网络不知道 \(t\) 是多少。它看到的只是一张图片,但不知道这张图片是在 \(t = 50\)(轻微噪声)还是 \(t = 900\)(几乎纯噪声)的情况下构造出来的。对于 \(t = 50\),图中大部分是原始信号,噪声很轻,网络应该预测一个幅度很小的噪声;对于 \(t = 900\),图中几乎全是噪声,网络需要在极少的信号线索下预测一个大幅度的噪声分布。

不告诉网络当前的噪声程度,它没有依据判断应该做多大幅度的修正。

于是一个自然的做法:把 \(t\) 也作为输入,让网络变成 \(\epsilon_\theta(x_t, t)\)。

$$ \epsilon_\theta(x_t) \longrightarrow \epsilon_\theta(x_t, t) $$

这里要分清两件事。把 \(t\) 喂给网络,只是给了它"当前噪声有多重"这个信息。知道 \(t\) 不等于会根据 \(t\) 正确去噪——正确去噪的能力要靠训练获得。

那 \(t\) 是一个标量整数,怎么编码成网络可以使用的向量?

一眼认出来的编码方式
#

我第一次看到 DDPM 代码里 timestep embedding 的实现时,反应是:

这不就是 Transformer 里的 Sinusoidal Positional Encoding 吗?

公式几乎一模一样。以 Transformer 的 PE 为参照:

$$ PE(pos, 2i) = \sin\!\left(\frac{pos}{10000^{2i/d}}\right), \quad PE(pos, 2i+1) = \cos\!\left(\frac{pos}{10000^{2i/d}}\right) $$

DDPM 的 timestep embedding 做的事情是:把 \(pos\) 换成 \(t\),把 \(d\) 换成 embedding 维度,其他不变。频率从高到低排列,底数也是 10000。

写成更紧凑的形式,两者共享同一个结构:

$$ e(n) = [\sin(\omega_1 n), \cos(\omega_1 n), \sin(\omega_2 n), \cos(\omega_2 n), \ldots] $$

其中 \(n\) 在 Transformer 里是 token 位置,在 DDPM 里是时间步索引。

之前写 Sinusoidal PE 那篇花了很大篇幅推导这个编码的性质——值域有界在 \([-1, 1]\)、同一频率下 position 变化对应单位圆上的旋转、多频率提供多尺度的位置特征。这些性质对 timestep embedding 同样成立:

  • 时间步 \(t\) 的信息编码在 phase pattern 里,不在 magnitude 里。\(t = 50\) 和 \(t = 900\) 的编码向量每一维都在 \([-1, 1]\),不会因为 \(t\) 大就产生更大的数值。
  • 多个频率让网络同时获得细粒度和粗粒度的时间信息——高频分量对相邻 timestep 的差异敏感,低频分量提供更宏观的时间位置。

我后来想了一下,觉得这种复用其实很自然。扩散模型里的时间步 \(t\) 本来就是扩散过程这条时间轴上的"位置"。\(t = 0\) 是起点,\(t = T\) 是终点,每个 \(t\) 标记了噪声 schedule 上的一个位置。用位置编码来编码"位置",逻辑上完全说得通。

Transformer PE 与 DDPM TE 的编码路径对比:sin/cos 编码步骤相同,注入下游网络的方式不同

当然也需要说清楚差异。两个名字不同,不是因为编码机制不同,而是因为它们编码的对象和注入方式不同:

  • 编码对象:Transformer PE 编码的是 token 在序列中的位置;DDPM TE 编码的是时间步索引(通过 noise schedule 间接对应噪声水平)。
  • 注入方式:Transformer PE 直接加到 token embedding 上,一次性注入输入端;DDPM TE 经过 MLP 变换后,注入 U-Net 内部的多个 ResBlock——这个差异后面会详细讲。
  • 具体实现细节:不同代码库在频率设计、维度排列(sin/cos 交替还是拼接)、后续 MLP 结构上可能有差异,不能说所有实现都逐项完全一致。

还有一点:sin/cos 编码不是唯一的选择。可以用 learned embedding(给每个 timestep 分配一个可训练向量),或其他函数形式。DDPM 论文选择 sinusoidal 编码,是因为它不需要额外训练参数,且从 Transformer 的经验来看已经被证明有效。

学一个新模型时,发现它复用了自己之前学过的数学机制——而不只是记住一个新名词——这才是真正把知识串起来的时刻。 Timestep Embedding 不是扩散模型发明的新概念,它就是 Sinusoidal Positional Encoding 在一个新场景下的应用。

从编码到注入:时间条件怎么进入 U-Net
#

sin/cos 编码把标量 \(t\) 变成了一个固定维度的向量。但这个向量不能直接塞进 U-Net——还需要几步处理。

为什么需要 MLP
#

sin/cos 编码本身是固定的数学公式,没有可学习参数。它的输出维度由编码设计决定(比如 128 维),而 U-Net 内部每一层 feature map 的 channel 数可能不同(256、512 等)。

更重要的是,固定的编码只提供了时间步的"原始表示"。网络需要从这个表示中提取出对去噪有用的特征,这需要通过可学习的变换来完成。

DDPM 的做法是在 sin/cos 编码之后接一个两层 MLP(多层感知机):

$$ e_t = \text{SiLU}(W_1 \cdot \text{SinCos}(t) + b_1) $$$$ e_t' = W_2 \cdot e_t + b_2 $$

其中 SiLU(也叫 Swish)是激活函数。这个 MLP 的参数在训练过程中学习,让网络自己决定如何从固定的频率编码中提取有用信息。

MLP 的输出 \(e_t'\) 是一个和 U-Net 内部 channel 数匹配的向量。在 DDPM 原始实现中,这个向量的维度通常是模型基础 channel 数的 4 倍。

加到 feature map 上
#

拿到经过 MLP 处理的时间编码向量后,下一步是把它注入到 U-Net 的 feature map 中。

先看维度。U-Net 某一层的 feature map 是一个四维张量:

$$ h \in \mathbb{R}^{B \times C \times H \times W} $$

\(B\) 是 batch size,\(C\) 是 channel 数,\(H\) 和 \(W\) 是空间高宽。

时间编码向量是一个二维张量:

$$ e_t' \in \mathbb{R}^{B \times D} $$

\(D\) 是 MLP 输出维度。要把 \(e_t'\) 加到 \(h\) 上,首先需要通过一个线性层把维度从 \(D\) 投影到 \(C\):

$$ b_t = W_p \, e_t' + b_p, \quad b_t \in \mathbb{R}^{B \times C} $$

然后把 \(b_t\) reshape 成 \(\mathbb{R}^{B \times C \times 1 \times 1}\),利用广播(broadcasting)加到 feature map 上:

$$ h' = h + b_t $$

广播的含义是:\(b_t\) 在 \(H\) 和 \(W\) 这两个空间维度上自动复制,相当于同一个 channel 偏置应用到所有空间位置。

我一开始对这个"所有位置共享同一个时间条件"有点疑惑:不同位置的噪声难道不应该不同吗?

后来想明白了。前向扩散的公式里,\(\bar\alpha_t\) 对一张图片的所有像素是相同的——同一时间步下,所有位置面临的噪声水平是一样的。所以用同一个时间条件广播到所有空间位置,在物理上是合理的。空间维度上不同位置的差异化处理,交给卷积层的 spatial weights 来做。

时间编码注入 U-Net:t 经过 Sin/Cos 和 MLP 后,在多个 ResBlock 处注入 feature map

加法发生在 ResBlock 内部
#

在 DDPM 的原始实现(Ho et al., 2020)中,每个 ResBlock 的结构大致是:

1h → Conv → GroupNorm → SiLU → (+时间编码) → Conv → (+residual)

时间编码的加法发生在两次卷积之间。第一次卷积提取空间特征,加上时间编码后,第二次卷积在带有时间条件的特征上继续处理。

这里有一个容易滑过去的点:时间编码不是只在 U-Net 的输入端加一次,而是注入到每一个 ResBlock 中。 每个 ResBlock 有自己独立的线性投影层 \(W_p\),把同一个时间编码向量投影到当前层的 channel 维度。

为什么要在每一层都注入?因为 U-Net 不同层处理的特征尺度不同。浅层看局部纹理,深层看全局结构。每一层都需要知道当前的噪声程度,才能在自己的抽象层次上做出正确的处理。

比加法更灵活:Scale & Shift
#

DDPM 原始论文用的是简单的加法。后来的 Improved DDPM(Nichol & Dhariwal, 2021)引入了一种更灵活的方式——Adaptive Group Normalization,本质上是对 feature map 做 scale 和 shift:

$$ h' = (1 + s_t) \odot \text{GroupNorm}(h) + b_t $$

其中 \(s_t\) 和 \(b_t\) 都是从时间编码投影出来的。把它们拆开看:

  • \(b_t\) 是 shift(偏移),和之前的加法一样。
  • \(s_t\) 是 scale(缩放),可以放大或缩小每个 channel 归一化后的激活值。

加法只能平移特征分布,而 scale & shift 可以同时改变分布的位置和宽度。 这给了时间条件更大的调控能力——它可以让某些 channel 的响应变强或变弱,而不只是整体偏移。

ResBlock 内的时间条件注入:简单加法 vs Adaptive Group Normalization(Scale & Shift)

不管是加法还是 scale & shift,核心逻辑是一样的:时间编码通过调制 feature map 的 channel 激活,改变后续卷积层的输入分布,从而影响整个网络的计算行为。

还有一点需要说清楚。时间编码注入以后,网络不会自动知道该怎么去噪。调制只是提供了一个依赖于 \(t\) 的计算路径——不同 \(t\) 对应不同的特征激活模式——但正确利用这些信息的能力,完全依赖训练过程中在大量 \((x_t, t, \epsilon)\) 三元组上的梯度优化。

共享权重为什么可行
#

到这里,一个更根本的问题浮出来了。

即使 timestep embedding 让网络知道了当前的 \(t\),1000 个 timestep 对应的去噪任务毕竟不同。\(t = 50\) 时需要从清晰图片中去除轻微噪声,\(t = 900\) 时需要从纯噪声中想办法恢复结构。这两个任务的难度和性质差别巨大,凭什么同一套网络参数 \(\theta\) 能同时应付?

换个角度看这件事。\(\epsilon_\theta(x_t, t)\) 不是 1000 个独立函数,而是一个以 \((x_t, t)\) 为联合输入的单一函数。参数 \(\theta\) 固定之后,不同的 \(t\) 只是这个函数的不同输入值——就像 \(f(x, y) = x^2 + y\) 这个函数,\(y = 1\) 和 \(y = 100\) 的输出完全不同,但函数本身只有一套"参数"。

网络参数虽然共享,但网络计算的是一个依赖于 \(x_t\) 和 \(t\) 的条件函数。不同 \(t\) 下的实际计算路径——激活值、特征响应、卷积输出——都因为时间条件的调制而不同。

同一个 U-Net 在不同 timestep 下工作:参数 θ 共享,但每次的输入和时间条件不同,输出的噪声预测也不同

这种"一个网络接受额外条件输入,在共享参数的基础上完成不同任务"的设计思路,叫做 Conditional Neural Network(条件神经网络)。扩散模型不是第一个用这个思路的——图像生成里的 Conditional GAN 用类别标签做条件,语言模型用 prompt 做条件,机制不同但想法类似。

另一个支持参数共享的理由:相邻 timestep 之间的去噪任务并不是毫无关联的。\(t = 500\) 和 \(t = 501\) 的噪声水平差异很小,需要的去噪操作非常相似。参数共享让网络可以在相邻时间步之间复用学到的特征,而不是每个时间步各学一套完全独立的表示。

这不是说参数共享一定优于 1000 个独立网络。理论上,如果有无限的模型容量和训练数据,给每个 \(t\) 训练一个专用网络或许能做得更好。但在实践中,一个带条件输入的共享网络在模型大小、训练效率和泛化能力之间提供了一个很好的平衡——这也是扩散模型普遍采用的方案。

训练和推理的闭环
#

理解了时间条件怎么注入以后,把训练和推理的流程完整走一遍。

训练
#

这部分在反向去噪那篇已经讲过,这里只简要回顾,加上 timestep embedding 的视角。

每个训练 step:

  1. 从训练集取一张真实图片 \(x_0\)
  2. 随机采样一个时间步 \(t \sim \text{Uniform}(1, T)\)
  3. 随机采样噪声 \(\epsilon \sim \mathcal{N}(0, I)\)
  4. 用跳步公式构造 \(x_t = \sqrt{\bar\alpha_t}\, x_0 + \sqrt{1 - \bar\alpha_t}\, \epsilon\)
  5. 把 \(x_t\) 和 \(t\) 一起喂给网络,\(t\) 经过 sin/cos 编码和 MLP 后注入各层 ResBlock
  6. 网络输出噪声预测 \(\hat\epsilon = \epsilon_\theta(x_t, t)\)
  7. 计算损失并更新参数

损失函数:

$$ L = \mathbb{E}_{x_0, t, \epsilon}\left[\|\epsilon - \epsilon_\theta(x_t, t)\|^2\right] $$

这里的期望是对三个随机源取的:随机选的图片、随机选的时间步、随机采的噪声。每个 training step 只采一个 \(t\),但因为 \(t\) 是随机的,大量 step 之后,网络在所有 timestep 上都被训练过。整个训练过程就是在不同噪声水平下联合优化同一套参数 \(\theta\)。

推理
#

推理时反过来走。从纯噪声 \(x_T \sim \mathcal{N}(0, I)\) 出发,逐步去噪:

$$ x_T \rightarrow x_{T-1} \rightarrow \cdots \rightarrow x_1 \rightarrow x_0 $$

每一步,把当前的 \(x_t\) 和时间步 \(t\) 喂给网络,得到噪声预测 \(\epsilon_\theta(x_t, t)\)。然后由 Sampler(采样器)根据这个预测和 noise schedule 计算 \(x_{t-1}\)。

这里必须区分两个角色:

  • U-Net 负责预测噪声 \(\epsilon\)。它是一个纯粹的函数近似器。
  • Sampler 负责利用噪声预测结果、当前时间步和 noise schedule 的系数,执行具体的更新公式来计算下一步 \(x_{t-1}\)。

U-Net 不直接输出 \(x_{t-1}\)。从 \(\epsilon_\theta(x_t, t)\) 到 \(x_{t-1}\) 的转换由采样公式决定,不同的 sampler(DDPM、DDIM、Euler 等)使用不同的更新规则。

整个推理过程中,网络参数 \(\theta\) 始终不变,每一步变化的只有输入图片 \(x_t\) 和时间条件 \(t\)。同一个网络被反复调用,每次根据不同的 \(t\) 做出不同的噪声预测。

写在最后
#

回到最初的问题:为什么 1000 个 timestep 可以共用一个 U-Net?

走完这篇的内容,我觉得答案可以拆成三件事。

第一,需要一种方式把标量时间步编码成网络可用的向量表示。 DDPM 选择的 sinusoidal 编码和 Transformer 的 positional encoding 在数学机制上本质一致——都是通过多组不同频率的 sin/cos 把一个标量整数映射到高维空间。这不是巧合,也不是新发明。

第二,时间条件需要注入到网络的特征计算中,而不是只在输入端使用一次。 编码后的时间向量经过 MLP 变换,在每个 ResBlock 中通过加法或 scale & shift 调制 feature map 的 channel 激活。不同 \(t\) 产生不同的调制模式,进而改变后续所有卷积层的计算行为。

第三,跨 timestep 的联合训练让同一套参数学会在不同噪声水平下去噪。 训练时随机采样 \(t\),每个 step 只处理一个时间步,但整体上所有时间步的去噪任务共同优化同一个 \(\theta\)。相邻时间步的任务高度相关,参数共享反而有利于特征复用。

这种"条件信息控制共享网络行为"的思路并不限于 DDPM。Conditional GAN、Classifier-Free Guidance、文本引导的 Stable Diffusion 都在用类似的方式把条件信息注入生成网络。只是条件的来源不同——有的是类别标签,有的是文本 embedding,而 DDPM 里是时间步。

参考资料
#

相关文章

9 年后重温 CNN:剥掉算子细节后,真正留下了什么

最近在重新过 MIT 6.S191 Lecture 3(卷积神经网络)。2017 年我刚接触 CV 那会儿,CNN 算是吃饭的家伙,每天都在调。后来精力逐渐转到 ML Infra,成天跟 GPU 显存、通信拓扑和算子优化打交道,卷积网络的很多具体细节就慢慢生疏了——写个 nn.Conv2d 时 weight 的四维形状到底怎么排、Kaiming 初始化的方差怎么推、感受野怎么算,冷不丁被问到,脑子里还得卡壳一下。

从随机标签实验重新理解深度学习泛化:能记住,为什么还能泛化?

起因 # 看 6.S191 的课程内容时,讲到 generalization 相关的话题,提到了一篇 2017 年的论文。 说实话,我以前对"泛化"这件事没有太深入地想过。教科书上的经典说法——模型越复杂越容易过拟合,要用正则化控制复杂度——我一直就这么接受下来了。L1、L2、Dropout 这些方法背后的直觉也说得通:限制模型的有效复杂度,不让它过度拟合训练数据中的噪声。没觉得有什么问题。