第一次把扩散生成模型的训练脚本跑起来,盯着屏幕上从雪花噪点里一点点浮出轮廓的画面时,我脑子里蹦出来的不是"这网络真强",而是"这不就是在退火吗"。噪声强度对应温度,加噪是升温让系统失去记忆,采样是降温让结构重新凝结,整个过程的数学骨架和统计物理里那些描述相变、涨落、弛豫的方程几乎是同一套东西。更有意思的是隐扩散模型:它把整个物理过程搬到了一个维度低得多的空间里做,而不是在像素层面硬扛。这个思路用物理语言讲特别顺——它本质上就是一次粗粒化,把微观自由度积分掉,只留下宏观序参量,然后在有效理论里跑退火程序。
这篇内容我想干的事很清楚:把扩散生成模型的每个关键设计(为什么终端是高斯、为什么学噪声而不是学图像、为什么噪声调度表决定成败、为什么隐空间能省算力还不掉质量、引导系数为什么会让画面过饱和),都用物理直觉重新讲一遍,并且配上可以直接抄的代码片段和我在实际调试中踩过的坑。适合已经跑通过至少一个扩散模型 Demo、但总觉得"知其然不知其所以然"的人看,也适合物理或统计背景出身、想快速把已有直觉迁移到生成建模上的朋友。不需要你懂多少泛函分析,但最好对概率密度、梯度、随机微分方程这些概念有点印象。
1. 从一杯静置的水说起:前向过程为什么必然走向高斯
1.1 熵增视角下,"加噪"这个操作到底在做什么
往一杯清水里滴一滴墨水,刚开始还能看清那团深色,几十秒后就均匀了。这个过程不可逆,不是因为动力学方程不对称(微观上牛顿方程是可逆的),而是因为初始状态对应的微观构型数量太少,均匀状态对应的构型数量极多,系统几乎必然往构型数多的方向走。这就是熵增的通俗版本。
扩散生成模型的前向加噪过程,本质上就是在做这件事。一张自然图像不是随机的,它处在整个像素空间里一个极小的低维流形附近,对应极低的熵状态。每次往图上加一点高斯噪声,就是把这个分布往四周"抹"开一点,让概率质量扩散到更多构型上去。加够几百上千步之后,图像原本的结构信息被彻底抹平,分布变成各向同性的标准高斯,这时候系统处在一个构型数量极多的状态——熵接近最大。
我特别喜欢这个视角,因为它解释了一件很多人没想明白的事:前向过程不是"破坏图像",而是"让图像失去信息"。这两件事在工程上看起来一样(都是越来越糊),但在概率上完全不是一回事。破坏意味着有个正确的目标要恢复,失去信息意味着原本就没有唯一答案——从纯噪声出发能生成无数张合理的图,正是因为在终点处,信息真的不存在了,而不是被藏起来了。理解这一点,你对"生成模型的输出为什么每次都不一样"就不会再纠结了。
1.2 终端分布为什么非得选高斯:一个最大熵的论证
很多人接受"最后变成标准高斯"是因为论文这么写的,但你如果问一句"为什么不是均匀分布、不是拉普拉斯分布",答案其实藏在最大熵原理里。
假设我们只知道这个终态分布的两个统计量:均值是零,协方差是某个给定矩阵 Σ。在所有满足这两个约束的分布里,熵最大的那个是高斯分布。这是个可以严格证明的结论,用变分法或者拉格朗日乘子都能推。换句话说,选高斯不是因为高斯好算(虽然它确实好算),而是因为高斯是"在不引入额外假设的前提下,最不偏不倚的选择"。你要换成均匀分布,等于额外假设了"所有取值等可能且必须有界",这个假设没有任何依据,还引入了人为边界。
这个论证对训练有直接指导意义。如果你的数据预处理里做了什么奇怪的操作,导致终态分布偏离了标准正态,比如把图像归一化到了一个奇怪的区间,或者对潜变量做了不对称的变换,那整个采样的起点就错位了。我见过一个案例,有人在自编码器输出上加了 ReLU 然后再做扩散,潜变量的分布严重右偏,训练损失曲线看着还挺正常,采样出来一片死气沉沉的灰。原因就在这里:终端分布不是高斯的,你从标准高斯采样出发,等于从一个训练时从未见过的区域起步,逆向过程自然走偏。
1.3 从离散加噪到连续时间:把"步"这个概念抹掉
最初的理解里,前向过程是 1000 步离散操作:每一步乘一个系数再加一点噪声。但物理上我们更习惯连续时间描述,因为离散步长是个纯粹的人为参数,连续化之后很多结论会更干净。
标准做法是写成一个随机微分方程。以最常见的方差保持形式为例,前向过程写成
$$dx = -\tfrac{1}{2}\beta(t),x,dt + \sqrt{\beta(t)},dw$$
这里的 β(t) 就是所谓的噪声调度函数,它在离散版本里对应每一步的噪声增量。这个方程有个名字叫 Ornstein-Uhlenbeck 过程,是物理学里描述带阻尼的布朗运动的经典模型。阻尼项 −½β(t)x 把状态往原点拉,扩散项 √β(t)dw 不断注入随机扰动,两者拉锯到最后达到平衡,平衡态恰好是标准高斯。
连续化带来的好处是,你可以用一套统一的语言同时描述前向和逆向,也可以用数值积分的方法论(显式欧拉、Heun、高阶指数积分器)来分析采样器的精度。后面讲采样器的时候会看到,不同采样器的差别其实就是数值积分格式的差别,不是什么玄学。
2. 朗之万动力学:漂移项和噪声项各干各的活
2.1 势能、温度、得分函数:三方关系的一次梳理
物理里描述一个粒子在势场中受热扰动运动的方程是
$$dx = -\nabla U(x),dt + \sqrt{2T},dw$$
其中 U 是势能,T 是温度。粒子往低势能处跑(漂移),同时被热噪声踢来踢去(扩散)。
现在看扩散模型的核心量:得分函数 ∇ₓ log p(x)。如果分布写成玻尔兹曼形式 p(x) ∝ exp(−U(x)/T),那么
$$\nabla_x \log p(x) = -\frac{1}{T}\nabla U(x)$$
也就是说,得分函数就是力场(差一个温度因子)。学习得分函数,等价于学习这个分布在每一点上的"受力方向"。这个类比我觉得比任何数学推导都更能建立直觉:训练好的网络在每个位置上告诉你"往哪推能更接近数据分布",采样就是让粒子顺着这个力场走,再加上适当的随机扰动保证多样性。
温度因子那个 1/T 很关键,它直接对应到噪声水平。在扩散模型里,不同时间步 t 对应不同的噪声水平 σ_t,也就对应不同的"温度"。高温时力场弱、噪声强,粒子乱窜;低温时力场强、噪声弱,粒子被牢牢吸在势阱里。整个采样过程就是一次连续降温,这也是为什么它跟模拟退火长得那么像。
2.2 前向和逆向其实是同一个方程的两种读法
把上面的直觉套到扩散模型上,前向过程是"从有序到无序"的朗之万动力学,逆向过程是"从无序到有序"的朗之万动力学。逆向方程长这样:
$$dx = \left[-\tfrac{1}{2}\beta(t)x - \beta(t)\nabla_x \log p_t(x)\right]dt + \sqrt{\beta(t)},d\bar{w}$$
对比一下前向:唯一多出来的就是那个得分项。也就是说,如果你知道每个时刻分布的得分函数,你就能把扩散过程精确地倒着走。整个扩散生成模型的理论核心就这一句话,剩下的全是工程。
这里有个细节值得说:逆向过程里的噪声项不能去掉。很多人第一次见到 DDIM 的时候会疑惑,为什么去掉噪声还能生成。答案是 DDIM 走的是概率流 ODE,也就是把随机性彻底抹掉、只保留漂移项的确定性轨迹。这在物理上对应"零温极限",粒子沿着力场最陡下降方向走。有意思的是,零温极限下轨迹是确定的,但如果你从不同的随机初值出发,最终落到同一个吸引子上的路径不同,生成的样本依然有多样性——多样性来自初值分布,而不是来自过程中的噪声。理解这一点,你调 DDIM 的 eta 参数时就知道自己在调什么了:eta 就是在随机性和确定性之间插值,eta=0 是纯 ODE,eta=1 是完整 SDE。
2.3 福克-普朗克方程:概率密度是怎么"流动"的
朗之万方程描述的是单条轨迹,福克-普朗克方程描述的是整个概率密度的演化。两者是同一件事的两种粒度,就像你看一条河可以看每个水分子的轨迹,也可以看整体水位的变化。
福克-普朗克的形式是
$$\frac{\partial p}{\partial t} = \nabla \cdot \left(p \nabla U\right) + D \nabla^2 p$$
第一项是漂移导致的概率流(对流项),第二项是扩散导致的概率扩散。这个方程在物理学里研究了几十年,一个很重要的结论是:不管初始分布长什么样,只要漂移项足够强,长时间后都会收敛到平衡分布。
这个收敛性给了扩散模型一个理论保证:只要前向时间足够长,终态一定接近标准高斯,无论数据分布多复杂。反过来,这也是一个警告:如果前向步数不够、或者噪声调度在末端衰减太快,终态跟高斯的偏差就会残留,逆向过程就会从一个"错的"起点出发。我在一个小的数据集上试过把总步数从 1000 砍到 200 直接复用原来的线性调度,训练损失看着降下去了,但采样结果明显偏"脏",细节处总有一层雾。后来把调度重新按 SNR 摊平才恢复正常。这个现象用福克-普朗克的语言解释特别清楚:你根本没让系统走到平衡态。
3. 时间反演为什么只要一个力场:得分匹配的物理意义
3.1 Tweedie 公式:去噪和得分之间只差一步换算
理论上优雅,实操上有个大麻烦:我们根本不知道真实分布的得分函数,数据就那么几万几十万张,你怎么算 ∇log p?
答案是绕道去噪。这里有个极其漂亮的恒等式(一般叫 Tweedie 公式):如果 x_t = α_t x_0 + σ_t ε,ε ~ N(0, I),那么
$$\mathbb{E}[x_0 \mid x_t] = \frac{x_t + \sigma_t^2 \nabla_{x_t}\log p_t(x_t)}{\alpha_t}$$
稍微变形一下,就能得到噪声和得分的关系:
$$\nabla_{x_t}\log p_t(x_t) = -\frac{\hat{\varepsilon}}{\sigma_t}$$
也就是说,预测噪声和预测得分只差一个负号和缩放。这一条换算关系是理解整个扩散模型训练的钥匙。你在代码里写的那个"预测 ε"的 MSE 损失,数学上就是在做得分匹配。
我用一段代码把这个换算写清楚,方便你对照着看:
import torch def eps_to_score(eps_hat, sigma_t): """把网络输出的噪声预测换算成得分函数""" return -eps_hat / sigma_t def score_to_x0(x_t, score, alpha_t, sigma_t): """Tweedie 公式:从得分反推 x0 的后验均值""" return (x_t + sigma_t**2 * score) / alpha_t def eps_to_x0(x_t, eps_hat, alpha_bar_t): """工程里更常见的写法,直接用累积系数算""" return (x_t - (1 - alpha_bar_t).sqrt() * eps_hat) / alpha_bar_t.sqrt()实际调试的时候我强烈建议你把这几个量在同一个 batch 上算一遍,验证它们互相能对上。我第一次做这件事的时候发现对不上,查了半天发现是我把 α_t 和 ᾱ_t 搞混了——一个是单步系数,一个是累积系数,这两个东西在代码里长得太像,抄公式的时候特别容易错。这个错误的表现是损失能降但采样全糊,非常隐蔽。
3.2 为什么不直接回归 x0:等价性背后的方差陷阱
既然能从 ε 推 x0,那为什么不直接让网络预测 x0,反而要绕一圈预测噪声?毕竟最终我们要的不就是图像吗。
这个问题在早期确实有过争论,两派做法都有人用,而且都能训出能用的模型。但物理视角能说清楚为什么"预测噪声"在多数情况下更稳。
关键在于不同时间步的回归难度差异巨大。在 t 很小的时候,x_t 几乎就是 x_0 加一点点噪声,这时候预测 x0 和预测 ε 都容易;但在 t 很大的时候,x_t 几乎是纯噪声,x_0 的信息所剩无几,此时预测 x0 等于让网络从几乎为零的线索里凭空重构整张图,误差的方差极大。而预测 ε 呢?不管 t 多大,ε 始终是标准高斯,方差恒定为 1,目标始终在同一个尺度上。这就是为什么预测噪声在数值上更稳定:它把目标归一化到了一个固定的动态范围。
不过话说回来,只做这个归一化还不够。真正让训练稳定的,是对不同时间步的损失做加权。后面第 7 节会专门讲这个加权怎么设计。
3.3 信噪比才是隐藏在时间步背后的真变量
讲到这里必须引入一个概念:信噪比 SNR(t) = ᾱ_t / (1 − ᾱ_t)。这个量比 t 本身重要得多,因为它才是刻画"当前状态有多糊"的真实指标。
为什么强调这个?因为不同的噪声调度下,同一个 t 对应的 SNR 可能差好几个数量级。你做实验对比两个调度表的时候,如果按 t 对齐去看效果,那就是在拿苹果比橘子。正确的做法是按 SNR 对齐。我调参的时候会在日志里把每个采样步的 SNR 打出来,画成一条曲线,这条曲线才是真正的"退火温度曲线",看它你就知道训练和采样在各个噪声档位上分别花了多少精力。
还有一个更实用的点:不同分辨率、不同数据集上,让模型表现最好的 SNR 区间其实是相对稳定的。这意味着你换数据集的时候,不用从头摸索整个调度,只需要保证"有效学习区间"落在相似的 SNR 范围里,剩下的微调量会小很多。这是我换了三四个数据集之后总结出来的经验,省了很多试错时间。
4. 粗粒化:隐扩散模型把物理系统压缩了多大
4.1 先算一笔自由度的账
一张 512×512 的 RGB 图,像素数 786432,乘以 3 个通道,接近 236 万个标量。扩散模型要在这么一个空间里跑几百步朗之万动力学,每一步都要过一遍 U-Net,算力开销是惊人的。
隐扩散模型的做法是:先用一个自编码器把图压到一个低维潜空间,比如 64×64×4,也就是 16384 个标量,然后在潜空间里跑扩散。压缩比大概 144 倍(按标量数算)。这个数字听起来夸张,但你要意识到自然图像本身的信息冗余度极高——相邻像素高度相关,高频细节大部分是感知上可有可无的。真正承载语义的自由度,远远少于像素数。
从物理角度看,这就是一次典型的粗粒化。统计力学里研究气体,你不会去追踪 10²³ 个分子的位置和动量,你只关心温度、压强、密度这些宏观量。分子层面的微观细节被"积分掉"了,留下的是有效自由度。自编码器干的事一模一样:把微观的像素构型映射成宏观的潜变量,丢掉的那些细节被解码器用先验补回来。
4.2 有效理论视角下的隐空间扩散
把自编码器理解为粗粒化之后,很多设计选择就顺理成章了。
第一,潜空间的维度不能压得太狠。你把 512×512 压到 8×8,自由度是少了,但信息损失太大,解码器再怎么努力也补不回细节,生成结果会明显发软、纹理重复。这就像用极粗的网格做流体仿真,涡旋结构全被抹平了。实践中的经验值是总下采样 8 倍左右(比如 512→64,256→32),这个尺度上语义信息和空间细节刚好平衡。
第二,粗粒化之后涨落变小了。潜变量是很多像素的聚合,随机性被平均掉了,这在物理上叫"涨落耗散"。好处是潜空间的分布更接近光滑的、类高斯的形态,扩散过程更容易学好;坏处是解码器必须承担"重新注入细节"的任务,所以解码器的质量直接决定最终图像的锐度。很多人调隐扩散模型时把注意力全放在 U-Net 上,忽略了自编码器,结果卡在一个质量天花板上不去。我的建议是先把自编码器的重建质量单独测一遍(不算扩散,直接 encode-decode 看 PSNR 和肉眼观感),这一步不过关,后面所有调参都是白费。
第三,潜空间里学到的"物理规律"是有效的,但不是基本的。这话听着绕,意思是:潜空间扩散学的是潜变量之间的统计关系,这套关系只在当前自编码器的意义下成立。你换一个自编码器,哪怕结构一样只是重训了一遍,之前的扩散模型权重就基本废了。这一点跟物理里的有效场论完全一致——有效理论依赖截断尺度,换了截断,系数全得重算。
4.3 那个被抄来抄去的缩放系数
几乎每份隐扩散模型的代码里都有这么一行:
z = vae.encode(x).latent_dist.sample() * 0.18215这个 0.18215 是什么?它是潜变量各通道标准差的一个估计值(大约 1/5.5)。乘上它之后,潜变量的整体尺度接近单位方差,这样扩散过程的标准高斯先验才匹配得上。
这个东西被无数人抄来抄去,但很多人不知道它的来源,换了自己的自编码器之后还在用这个数值,结果训练就是不稳。我见过最典型的表现是:训练初期损失下降很快然后突然爆掉,或者采样时前几步就把画面推向极端色块。
正确的做法是自己统计。在你自己的数据集上跑一遍编码,把潜变量拉平算全局标准差(或者逐通道算,取决于你的实现),用这个值。代码很短:
import torch @torch.no_grad() def estimate_scale(vae, loader, max_batches=50): vals = [] for i, batch in enumerate(loader): if i >= max_batches: break z = vae.encode(batch).latent_dist.mode() # 用众数更稳定,避免采样噪声 vals.append(z.flatten()) all_z = torch.cat(vals) return 1.0 / all_z.std().item()用mode()而不是sample()是有讲究的:采样会引入额外方差,让估计值偏大。这个细节很少有人提,但我实测下来差异能到 3% 到 5%,在小数据集上足以影响训练稳定性。
还有一点:缩放之后还要不要做逐通道归一化,取决于你的自编码器各通道尺度是否一致。如果某个通道的方差明显偏离其他通道,扩散模型会在这个通道上学得特别慢,因为它在前向过程里被淹没得最快。检查方法很简单,把潜变量的逐通道标准差打出来看一眼就行,差异超过 2 倍就该处理。
5. 噪声调度表就是温度程序:线性曲线错在哪里
5.1 线性 β 的直觉缺陷
最朴素的调度是把 β 从 1e-4 线性增长到 0.02,分 1000 步。这个设计非常符合"均匀加噪"的直觉:每一步加的噪声量线性递增,听起来很公平。
但用 SNR 一算就露馅了:末期 SNR 会断崖式下跌到接近零。这意味着在 t 接近 T 的那几十步里,图像早就变成纯噪声了,后面的步骤几乎没有提供新的信息,白白消耗算力。而在 t 接近 0 的那一端,SNR 变化又特别慢,系统长时间停留在"轻微加噪"的状态,同样低效。
用物理语言说:线性 β 的温度程序,在高温段和低温段都做了大量无用功,中间的相变区间反而被压缩了。而生成质量恰恰最依赖相变区间——那一段是结构信息被破坏/重建最剧烈的地方。
我自己做过一个对比,同一套网络、同一个数据集,只改调度,用固定的采样步数(比如 50 步 DDIM),线性调度出来的图细节明显偏软,边缘有雾感;换成余弦调度之后,同样步数下清晰度提升肉眼可见。这个差异在低步数下尤其明显,步数加到 200 以上才逐渐收敛。因为实际部署里步数就是延迟,这个改进的实用价值很高。
5.2 余弦调度:把信噪比在对数尺度上摊平
余弦调度的思路是直接定义 ᾱ_t 而不是 β_t:
import math import torch def cosine_alpha_bar(T=1000, s=0.008): """ 余弦噪声调度。 s 是一个小偏移量,防止 t=0 时 ᾱ 恰好为 1 导致除零。 返回长度 T+1 的累积系数,单调递减,abar[0]=1, abar[T]≈0。 """ steps = torch.linspace(0, T, T + 1) / T f = torch.cos((steps + s) / (1 + s) * math.pi / 2) ** 2 abar = f / f[0] abar[-1] = 0.0 # 强制末端归零,避免浮点残留 return abar def betas_from_alpha_bar(abar): """由累积系数反推单步 beta""" betas = 1 - abar[1:] / abar[:-1] return betas.clamp(0.0, 0.999)为什么余弦形状有效?因为它的 ᾱ_t 在中间段下降得最陡,对应 SNR 在对数尺度上分布更均匀。直白点说:它把更多的采样步分配给了"信息正在剧烈变化"的那个区间,而不是浪费在两端。
有个坑要提醒:abar[-1]那一行强制归零不是可有可无的。我遇到过余弦调度在 1000 步时末端 ᾱ 残留 1e-5 量级,理论上无所谓,但在混合精度下,这个残留乘以一个方差较大的潜变量后,会让"纯噪声"输入里带有一丝可辨识的结构痕迹,采样时表现为固定位置的伪影(每次生成都在同一块区域出现异常)。加上这一行彻底解决。
5.3 采样器和步数的匹配:一笔数值积分的账
采样器本质上就是 ODE/SDE 的数值积分器,选哪个、用多少步,是一笔精度和速度的账。
| 采样器类型 | 数值格式 | 典型可用步数 | 适用场景 |
|---|---|---|---|
| DDIM | 一阶确定性 | 50–200 | 快速预览,需要确定性复现 |
| 显式欧拉 | 一阶 | 100–500 | 最简单,调试用 |
| Heun | 二阶 | 25–50 | 质量与速度平衡较好的默认选择 |
| 高阶指数积分 | 二到三阶自适应 | 15–30 | 追求低延迟 |
规律很清楚:阶数越高,需要的步数越少,但每步的计算量越大。一阶方法在小步数下误差积累快,容易出现"过度平滑"(因为数值耗散),表现为画面发糊、细节丢失;二阶方法在 20 到 30 步就能逼近一阶 200 步的效果。
我一般的工作流是:调试阶段用二阶采样器 20 步快速看趋势,确定超参之后再用 50 步做最终对比。千万不要用一阶 10 步的结果去判断模型好坏,那基本是在评价积分器的误差而不是模型的能力。这一点我踩过很深的坑——有一次以为模型训练失败了,折腾了两天网络结构,最后发现只是 DDIM 步数设成了 12。
6. 外场耦合:条件引导到底在改什么
6.1 CFG 的数学形式与非保守场问题
条件生成的核心是分类器无关引导(CFG)。公式很短:
$$\hat{\varepsilon} = \varepsilon_\theta(x_t, \varnothing) + w\left(\varepsilon_\theta(x_t, c) - \varepsilon_\theta(x_t, \varnothing)\right)$$
从形式上看,这是在"无条件预测"和"条件预测"之间做外推,w 是外推系数。物理上可以理解为给系统加了一个外场,场强正比于 w,方向指向条件描述的区域。
但这里有个非常关键、又经常被忽略的问题:当 w ≠ 1 时,这个组合出来的得分不再对应任何真实概率分布的梯度。数学上的说法是它不再是保守场(旋度不为零),意味着你不能把它写成某个势函数的梯度。
这个结论的实践含义很直接:引导后的采样轨迹会漂出训练分布所在的流形。表现在画面上就是色彩过饱和、对比度异常、纹理重复、人脸皮肤像塑料。很多人以为这是"引导太强导致失真",但深层原因是力场不自洽——粒子被推到了一个不存在的势能地形上。
理解这一点之后,调 w 就不再是盲目试数字了。我会把它理解为"允许偏离数据流形多远"的旋钮:w 小,样本保守但可能不够贴合提示;w 大,贴合度高但开始失真。通常 3 到 7 是合理区间,超过 10 基本都会看到明显的过饱和。
6.2 动态引导:把外场强度做成时间相关的
既然高温段和低温段对引导的需求不同,让 w 随时间变化是很自然的想法。高温段(早期)结构还没成形,强引导有助于快速锁定大致布局;低温段(后期)细节正在凝结,强引导会破坏纹理的自然性。所以常见做法是前段 w 大、后段 w 小。
def dynamic_cfg_weight(step_ratio, base_w=6.0, min_w=1.0): """ step_ratio: 当前步占总步数的比例,0 到 1 早期强引导定布局,后期弱引导保细节 """ return max(min_w, base_w * (1.0 - step_ratio) ** 2 + min_w * step_ratio)这个函数的具体形式可以自己调,但"单调递减"这个趋势是普遍有效的。我用这个策略在同样的平均 w 下,既能保住提示词贴合度,又能明显减少后期细节的塑料感。
6.3 条件注入的几种通道,各自改了什么
条件信息怎么喂进网络,不同实现差别很大,效果也不一样。
最主流的是交叉注意力:把条件编码成一串向量,在 U-Net 的每个分辨率层级上通过注意力机制注入。物理上这相当于在不同尺度上施加外场——粗尺度加低频约束(整体构图),细尺度加高频约束(局部纹理)。这种多尺度注入的好处是条件信息不会在某一个尺度上被过度强化。
另一种是拼接注入,把条件直接拼到输入通道上。这种做法简单,但条件信号在深层网络里容易被稀释,通常需要配合额外的辅助损失才有好效果。
还有一种是把条件编码成调制参数(类似 FiLM 或者 AdaGN),作用在归一化层上。这种方式计算开销小,对大尺度全局条件的响应很强,但对细粒度的空间约束比较弱。
我实测下来的排序是:需要精确空间控制时用交叉注意力,需要对全局风格做整体调整时用调制,拼接方式一般作为辅助手段。三者也不互斥,很多实现里同时用了交叉注意力和调制,前者管语义对齐,后者管整体亮度色调,分工明确。
7. 最小可跑实现:从潜空间预处理到解码排伪影
7.1 潜空间预处理与缓存策略
隐扩散模型训练最容易被低估的一步是数据管道。因为自编码器是冻结的,潜变量可以在训练前一次性算好缓存下来,这样做能省掉大量重复编码的开销。
import torch @torch.no_grad() def build_latent_cache(vae, loader, scale, out_path): """一次性把所有样本编码成潜变量存盘""" cache = [] for batch in loader: dist = vae.encode(batch).latent_dist z = dist.mode() * scale # 用众数,避免采样噪声进缓存 cache.append(z.to(torch.float16).cpu()) # 半精度省一半空间 torch.save(torch.cat(cache), out_path)三个细节值得说。第一,用mode()而不是sample():缓存的目的是给扩散过程提供稳定的目标,把编码器本身的随机性也带进来,等于在目标上叠了一层噪声,会让损失有一个降不下去的地板。第二,用 float16 存盘:潜变量的动态范围不大,半精度基本无损,磁盘占用砍一半。第三,缓存时不要做随机裁剪。
第三点要展开说,因为它是个非常隐蔽的坑。如果在缓存阶段用了随机裁剪,缓存下来的潜变量就是"随机位置裁出来的"。训练时你读到的样本和真实推理时的分布就会不一致——推理通常是中心裁剪或者整图缩放。这个偏移在潜空间里会被放大,表现为生成的图像在边缘区域出现不自然的延续或重复纹理。
正确的做法是:缓存全图潜变量,训练时在潜空间层面做随机裁剪。这样每次读到的都是同一张全图的不同区域,分布一致,而且省了重复编码。代价是潜变量的存储量变大,但换来的是稳定性,我认为非常划算。
7.2 训练循环与损失加权
基础的训练循环不复杂,关键是损失加权。
import torch import torch.nn.functional as F def train_step(unet, z0, text_emb, optimizer, min_snr_gamma=5.0): b = z0.shape[0] device = z0.device # 时间步采样:logit-normal 比均匀采样更关注中间难度区间 u = torch.randn(b, device=device) * 1.0 t = torch.sigmoid(u) # 映射到 0~1 idx = (t * (num_train_timesteps - 1)).long() abar = alpha_bar[idx].view(b, 1, 1, 1) noise = torch.randn_like(z0) zt = abar.sqrt() * z0 + (1 - abar).sqrt() * noise eps_pred = unet(zt, idx, text_emb) # min-SNR 加权:抑制高 SNR(简单)和极低 SNR(几乎无信息)样本的权重 snr = abar / (1 - abar) w = torch.clamp(snr, max=min_snr_gamma) / snr loss = (w * F.mse_loss(eps_pred, noise, reduction="none").mean(dim=[1, 2, 3])).mean() optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(unet.parameters(), 1.0) optimizer.step() return loss.item()min-SNR 加权背后的道理,用物理语言讲就是:不同"温度"下的学习任务难度差异巨大,均匀加权等于让系统在所有温度上花同样的精力,而真正决定生成质量的往往是中间那个"相变区间"。这个加权把高 SNR 区间(太简单,梯度信息量小)和极低 SNR 区间(太难,梯度几乎是噪声)的权重压下去,把资源集中到中间。
时间步用 logit-normal 采样而不是均匀采样,理由类似:均匀采样会让大量样本落在两端,中间区间的样本偏少。logit-normal 在 0.5 附近有峰值,正好覆盖最关键的区间。这个改动我在几个不同规模的数据集上都试过,收敛速度的提升是能看出来的。
还有几个不能忘的训练细节:EMA(指数滑动平均)权重是必须的,通常衰减 0.999 或者 0.9999,它对采样质量的提升非常明显,因为它在参数空间里做了一次低通滤波,把高频抖动滤掉了;梯度裁剪也要加,防止个别 batch 的极端样本把参数打飞;混合精度下要注意损失缩放,尤其是在低 SNR 区间,梯度本身就很小时容易下溢。
7.3 采样、解码与几类高频伪影
采样阶段到解码,是伪影集中爆发的地方。我按遇到频率排几个常见的。
第一种是棋盘格纹理,通常出现在解码器上采样路径里。这是转置卷积的重叠不均匀导致的经典问题。处理方式是把转置卷积换成最近邻插值加普通卷积,或者用亚像素卷积配合适当初始化。这个问题在潜空间压缩比较大(下采样 16 倍以上)时特别明显。
第二种是固定位置的高频噪点,每次生成都在同一块区域。这个前面提过,多数是调度表末端 ᾱ 没归零,或者潜变量在某个通道的尺度异常。排查方法很直接:喂一个纯高斯噪声进解码器,看输出的空间分布是否均匀,如果有固定斑块,问题在解码器或缩放系数,不在扩散模型。
第三种是整体偏色或者对比度失真。这个八成是引导系数过大导致的非保守场漂移,先把 w 降下来试试。如果降下来之后色彩正常但细节变糊,那就是采样步数不够或者调度表在低噪端太粗糙。
第四种是结构崩坏但局部纹理合理,比如人体的肢体位置明显不对,但皮肤质感很好。这是典型的"高频学会了低频没学会",通常是训练时高层级分辨率的时间步采样不够,或者是低分辨率层的注意力被条件信息过度干扰。可以在 U-Net 的最粗几层加大条件注入的权重,或者在训练时对低分辨率输出加额外的监督。
解码最后一步还有个容易忽略的点:潜变量在喂给解码器之前,尺度必须和训练时完全一致。如果你训练时做了缩放,推理时忘了或者缩放系数不同,输出会整体发灰或者过曝。我建议把缩放系数写成一个配置项,训练和推理共用同一个值,绝对不要在两边各写一遍魔法数字。这个教训是血泪换来的,我曾经因为两个脚本里一个写 0.18215、一个写 0.18,排查了整整一个下午。
最后分享一个我最近常用的小技巧:在采样过程中每隔若干步做一次"潜变量统计检查",把当前的均值和标准差打出来,跟训练时潜变量缓存的统计量对比。如果偏差超过一定阈值,说明轨迹已经漂出正常区域了,这时候可以提前终止,省下后面的计算。这个方法帮我省了很多无效的等待时间,尤其是在试新调度表的时候。