扩散模型说人话:从擦掉噪点到造出新东西

"加噪再去噪"这句话解释不了最关键的一件事:去噪只是把脏图擦干净,怎么能擦出一张从来不存在的图?这篇用一个不装任何库、复制就能跑的一维小例子讲透。

“从数据造噪声很容易;从噪声造数据,才叫生成模型。”

这是 Song 等人那篇经典论文(arXiv:2011.13456)的开场第一句,我认为它是关于扩散模型最好的一句话总结。

你大概听过这样的解释:“扩散模型就是先给图片加噪声,再训练模型把噪声去掉。“这句话没错,但它解释不了最关键的一件事——去噪听起来只是把脏图擦干净,那它怎么能”擦”出一张世界上从来不存在的图?

这篇就死磕这一个问题。全程不需要你会随机微分方程,最后会给你一个不用装任何库、复制粘贴就能跑的小例子,让你亲手看着噪声变成数据。

名词速查

术语一句话解释
生成模型会”造”新样本的模型(造图、造文),相对于只会”判断”的模型(这是猫还是狗)
前向过程(加噪)把干净数据一步步弄脏,直到变成纯噪声。这一步不需要学,是人为规定的
反向过程(去噪)从纯噪声一步步还原成数据。这一步才是神经网络要学的东西
去噪器那个神经网络。输入”脏数据 + 有多脏”,输出”我猜干净的原样是什么”
采样步数从噪声走到成品用了几步。步数是速度和质量的旋钮
分数(score)专业叫法,直觉就是”往哪个方向走更像真实数据”的箭头

一、先说清楚”生成”到底难在哪

要理解扩散的聪明之处,得先感受一下它绕过的那个难题。

“生成一张图片”用数学讲就是:从一个概率分布里随机抽一个样本出来。 这个分布是”所有看起来像真实照片的图片”的分布。

难点在于这个分布长得极其古怪。想象一张 256×256 的彩色图,它有 256×256×3 ≈ 20 万个数字。这 20 万个数字的所有可能组合构成一个巨大的空间,而”看起来像真实照片”的那些点,在这个空间里稀薄得可怕——你随机填 20 万个数字,得到的一定是雪花噪点,不可能碰巧是一只猫。

所以问题变成了:这个分布你既写不出公式,又没法直接采样。怎么从里面抽样本?

二、核心花招:把一个难问题拆成一千个简单问题

这就是扩散模型的整个思想,一句话:

让你一笔画出蒙娜丽莎,你画不出来;让你画一千笔、每笔只要求比上一笔清楚一点点,你就能画出来。

“从纯噪声直接跳到一张猫”是个难到没法学的任务。但换成这个任务:

这里有一张稍微有噪点的猫,请把噪点去掉一点。

这个任务简单得多——因为图里已经有猫的轮廓了,模型只需要做局部的、小幅的修补。

扩散模型就是把生成拆成了一千个这样的小任务。每一步都不需要聪明,只需要”稍微变清楚一点”。 一千步小聪明叠起来,就完成了那个原本学不会的大任务。

这个”拆步”的思路最早由 Sohl-Dickstein 等人在 2015 年提出(arXiv:1503.03585,标题里那个”非平衡热力学”听着吓人,思想就是上面这段),五年后被 Ho 等人的 DDPM(arXiv:2006.11239)真正跑成了能出高质量图的方法。

三、训练:为什么”加噪声”是个免费的老师

现在问题来了:怎么训练一个”会去掉一点噪点”的模型?训练需要标准答案,标准答案从哪来?

这是扩散最漂亮的地方,也是最容易被讲漏的一步:

标准答案是你自己造的。

流程是这样的:

  1. 拿一张干净的真实照片(这个有,互联网上一堆)
  2. 你自己往上加一定量的噪声——加多少你说了算,所以你完全知道加了什么
  3. 把加噪后的图喂给模型,让它猜”原来那张干净的长什么样”
  4. 你手里有原图,所以能立刻算出它猜得对不对,然后修正它

看出来了吗?第 2 步是免费的、无限量的标签生成器。 你不需要人工标注,不需要另一个模型当裁判,你想要多少训练样本就有多少——同一张图配不同的噪声量,就是无数个不同的训练样本。

这也解释了扩散模型为什么比 GAN 好训练。GAN 要让两个网络互相博弈,容易崩;扩散的训练目标就是个老老实实的”猜得准不准”,是标准的监督学习。

四、最小的例子:一维世界里的扩散

抽象讲到这儿就够了。现在把整件事缩小到你能用计算器验算的规模。

我们的世界只有一个数字。 数据分布简单到极致:真实数据只有两种,+3-3,各占一半。你可以把 +3 想成”猫”,-3 想成”狗”——这就是我们这个小世界里全部的”真实照片”。

前向加噪:给真实值加上一个随机数,噪声的大小用 σ 表示。σ 很大时,+3-3 被彻底搅浑,看不出原来是哪个。

去噪器要做的事:给你一个脏数字(比如 0.5)和当前的噪声量 σ,猜原来是 +3 还是 -3

这个玩具世界的好处是,最优去噪器能直接写出来(不用训练神经网络):

x^0=3tanh(3xtσ2)\hat{x}_0 = 3 \cdot \tanh\left(\frac{3 x_t}{\sigma^2}\right)

不用管 tanh 是什么,只要知道它把任何数字压到 −1 和 +1 之间就行。我把这个公式和”暴力枚举两种可能算加权平均”的结果对照验算过:

σ(噪声量)x_t(看到的脏数字)公式算的暴力算的
3.00.50.49540.49541e-16
3.02.01.74831.74830
3.0−1.0−0.9645−0.96453e-16
1.00.52.71542.71549e-16
1.02.03.00003.00004e-16
1.0−1.0−2.9852−2.98524e-16

两列完全一致(差异只是浮点误差),说明公式没写错。

现在读懂这张表,你就读懂了去噪器在干什么:

  • 噪声大(σ=3)时,看到 0.5,它只敢猜 0.495——“我看不太清,感觉稍微偏正”
  • 噪声小(σ=1)时,同样看到 0.5,它就敢猜 2.72——“这么清楚了,肯定是 +3”

去噪器不是在擦噪点,它是在表态:“根据我看到的这点线索,真相更可能偏哪边。” 噪声越小,它越敢下重注。这就是那个”箭头”、那个专业叫法里的”分数(score)“。

五、跑一趟:噪声真的变成了数据

把去噪器接成一条链就能采样了:从纯噪声出发,每一步让去噪器猜一次原样,然后按下一档(更小的)噪声量重新加一点噪,再猜。σ 一档档降下去,猜测就一步步收紧。

这是我实际跑出来的一条轨迹(σ 每步减半,随机种子 7):

σ当前的脏数字 x_t模型猜的原样
08.0−2.047−0.287
14.01.7590.955
22.00.5031.080
31.00.7652.940
40.52.4753.000
50.252.9473.000

最终落点:+3.000

从一个毫无意义的随机数 −2.047 出发,六步之后,它精确地落在了我们这个小世界的”真实数据”上。前三步模型都在含糊其辞(−0.287、0.955、1.080,都是”我不确定”),第 4 步开始态度突然坚决(2.940 → 3.000)。这就是”从噪声造出数据”,没有任何魔法。

跑两万次(种子 123),落到 +3 的比例是 0.5028,距离 ±3 的平均偏差在 0.0001 量级。也就是说,它不只是造出了合法数据,还准确还原了”两种各一半”这个真实分布——生成模型要的就是这个。(不设种子每次会在 0.5 附近小幅波动,我另一次跑出 0.4932。)

六、回答开头那个问题:为什么”擦”能造出新东西

现在可以正面回答了。你可能会想:“模型不就是学会了把脏数字推回 ±3 吗?那它只是在复现训练数据,哪来的’新’?”

关键在于:去噪器学到的不是”答案是什么”,而是一张”往哪边走更像真实数据”的方向图。 在我们的小世界里,这张图说的是”正数往 +3 推,负数往 −3 推,越接近越用力”。放到图片上,这张图说的是”往’更像真实照片’的方向推一点”——而”像真实照片”的地方有无穷多个,不只是训练集里那几张。

新东西就是这么来的:方向图是连续的,起点是随机的,所以你会落到训练集里没有的、但同样”合法”的点上。 就像你学会了”往山谷低处走”这条规则,就能从任何位置出发走到谷底——包括你从没走过的路线。

而”随机性到底从哪儿进来的”,我原本以为答案很显然:起点不同,落点就不同。跑了个对照实验之后发现不对。

采样方式落到 +3 的比例终点与起点同号的比例
每步重新加噪(标准做法)0.50280.5538
不加噪,纯确定性去噪0.50531.0000

(两万次采样,同一组起点,种子 123)

看第二列:标准做法下,终点和起点同号的比例只有 55%——几乎等于瞎猜。 起点是正的,终点很可能是负的,我上面那条轨迹就是活例子(起点 −2.047,终点 +3)。真正决定结果的,是去噪途中每一步注入的那点新噪声,不是起点。

而关掉途中的加噪之后,同号比例变成 100%——起点完全决定终点。

两种做法都还原出了正确的分布(0.5028 和 0.5053,都在 0.5 附近)。所以结论是:

多样性可以来自起点,也可以来自途中,两条路都能到达正确的分布。

这不是纯理论趣味,它对应一个你可能遇到过的现象:用 DDIM(arXiv:2010.02502,那篇让采样快 10–50 倍的论文)这类不注入随机性的采样器时,固定种子就能复现出同一张图;而随机性更强的采样器,同样的种子也可能给你不同的结果。原因就在上面这张表里。

七、从图片到文字:为什么文本要换个玩法

到这里你已经理解了图片扩散。那扩散语言模型是怎么回事?

卡点只有一个:上面全部的推理都依赖”可以加一点点噪声”。

像素值是连续的,128 加一点噪声变成 131.7,完全合理。但文字是离散的 token——“猫”加一点噪声等于什么?没有”半个猫”这种东西。 那条从数据到噪声的连续通道,在文字上不存在。

解决办法是换一种”弄脏”的定义:不加噪声,改成挖空。 加噪 = 按一定比例把 token 换成 [MASK];去噪 = 把空填回来。噪声越大就是挖得越多,纯噪声就是整句全挖空。

这个思路来自 D3PM(arXiv:2107.03006)对离散数据加噪方式的系统研究,其中”挖空”这种选择叫吸收态。于是整套框架平移过来了:

图片扩散文本扩散
数据连续的像素值离散的 token
怎么弄脏加高斯噪声换成 [MASK]
脏到极致纯雪花噪点整句全是 [MASK]
去噪器干什么猜干净的像素猜被挖掉的词
步数是什么去噪迭代次数分几批把空填完

想清楚这一层,你会发现文本扩散忽然不神秘了——它本质就是一个”挖空比例可以任意变化”的完形填空模型。 挖 15% 是 BERT,挖 0% 到 100% 随机变化并学会在任意比例下填空,就是扩散语言模型。

这条路已经跑到 8B 规模并验证可行(LLaDA,arXiv:2502.09992)。它省下的步数从哪儿来、代价是什么、为什么在代码任务上行而在知识推理上掉队——那是另一篇的内容:自回归不是物理定律这篇讲扩散是什么,那篇讲扩散在语言上划不划算。

八、四个常见误解

预设一下你读到这儿可能还存着的疑问:

误解一:“扩散就是个高级降噪滤镜。” 滤镜的输入是一张有内容的脏图。扩散采样的输入是纯噪声,里面没有任何内容。它不是在恢复已有信息,是在无中生有——第五节那条轨迹从 −2.047 开始,那个数字里没有”3”的任何信息。

误解二:“步数越多质量一定越好。” 步数多确实让每步的近似误差更小,但收益会饱和,而时间是线性涨的。DDIM 那篇论文的整个卖点就是用远少于原来的步数拿到可比的质量(摘要报的是 10–50× 加速)。步数是个旋钮,不是”越大越好”的指标。

误解三:“起点的随机种子决定了结果。” 这个我上面用实验反驳了——在标准的随机采样里,起点只贡献 55% 的同号率,接近无关。只有关掉途中噪声(确定性采样)时,种子才完全决定结果。

误解四:“扩散必须一步步来,所以注定慢。” “拆成很多步”是训练时的思想,采样时步数可以压缩,甚至可以把多步蒸馏进一步。图像领域已经有几步出图的模型了。慢不是扩散的宿命,是早期实现的状态。

九、小白自测

能用自己的话答出来,这篇就算读进去了:

  1. 为什么”从纯噪声一步生成图片”学不会,“去掉一点噪声”却学得会?
  2. 扩散训练的标准答案是从哪来的?为什么说它是免费的?
  3. 第四节那张表里,同样看到 0.5,为什么 σ=3 时猜 0.495、σ=1 时猜 2.72?
  4. 去噪器学到的到底是”答案”还是”方向”?这个区别为什么决定了它能造出新东西?
  5. 标准采样里,多样性主要来自起点还是来自途中?确定性采样呢?
  6. 文字为什么不能像像素那样”加一点点噪声”?换成了什么?

带得走的东西

如果只留一句:扩散模型的本质,是把”直接从分布里采样”这个学不会的难题,换成了”反复回答’往哪边走更像真实数据‘“这个学得会的简单问题。

这个换法的适用范围比生成图片大得多。以后你看到任何”迭代精修”的方法——反复改写、反复投票、反复自我修正——都可以拿同一把尺子去量:它是在一步里要求一个聪明的答案,还是把难题拆成了很多步不需要多聪明的小改进?

诚实的提醒

这篇里的两个玩具实验(第四节的对照表、第五、六节的轨迹与统计)都是我亲手跑出来的,脚本就在下面,你可以复现或推翻。论文的编号、标题和引用的加速倍数是当场核实过的(DDPM、DDIM、Sohl-Dickstein、Song 的 SDE 那篇、D3PM、LLaDA 六篇的 arXiv 页面都打开确认过)。

需要说明的简化,免得你拿这个玩具去对照真实实现时困惑:

  • 我用的加噪写法是 x_t = x_0 + σ·ε(只加噪、不缩放原数据),这是所谓”方差爆炸”的简单写法。真实的 DDPM 会同时把原数据按比例缩小,多一个系数。
  • 我的采样器是”猜原样 → 按下一档噪声重新加噪”的简化退火版本,抓的是主干思想,与 DDPM 的原始采样公式在细节上不同。
  • 那个 tanh 公式只对”数据只有 ±3 两个值”这个玩具分布成立,它是解析解,不是训练出来的。真实模型是用神经网络逼近同一个东西。
  • DDIM 的”确定性采样”对应它论文里不注入随机性的那个特例;摘要本身只强调 10–50× 加速,没有用”确定性”这个词,这一层是我的理解。

最低成本的亲手验证实验(不需要 GPU、不需要 pip install,标准库就够,我跑通过):

import math, random
a = 3.0                                     # 真实数据只有 +3 和 -3
den = lambda x, s: a * math.tanh(a*x/s**2)  # 最优去噪器
sigmas = [8.0, 4.0, 2.0, 1.0, 0.5, 0.25]    # 噪声一档档降

def sample(stochastic=True):
    x = sigmas[0] * random.gauss(0, 1)      # 起点:纯噪声
    for i, s in enumerate(sigmas):
        x0 = den(x, s)                      # 猜原样
        nxt = sigmas[i+1] if i+1 < len(sigmas) else None
        x = x0 + (nxt*random.gauss(0,1) if (stochastic and nxt) else 0.0)
    return x

hits = [sample() for _ in range(20000)]
print("落到 +3 的比例:", sum(v > 0 for v in hits)/len(hits))
print("距 ±3 的平均偏差:", sum(abs(abs(v)-a) for v in hits)/len(hits))

三个值得你自己动手改的地方:把 sigmas 改短(比如只留 [8.0, 0.25]),看质量怎么崩——那就是”步数不够”的样子;把 stochastic 改成 False,验证一下我第六节那张表;把数据分布从 ±3 改成三个值(比如 −3、0、+3,去噪器公式要跟着改),看看它是否还能还原出各三分之一。

参考与继续读

论文(按时间顺序,也是这个领域的脉络)

  • arXiv:1503.03585 — Deep Unsupervised Learning using Nonequilibrium Thermodynamics(Sohl-Dickstein 等,2015):把生成拆成一串小步骤的原始想法
  • arXiv:2006.11239 — Denoising Diffusion Probabilistic Models(DDPM,Ho 等,2020):让这个想法真正出高质量图,CIFAR-10 上 FID 3.17
  • arXiv:2010.02502 — Denoising Diffusion Implicit Models(DDIM,ICLR 2021):同样的训练目标,采样快 10–50 倍
  • arXiv:2011.13456 — Score-Based Generative Modeling through SDEs(Song 等,ICLR 2021 Oral):本文开头那句话的出处,把分数匹配和扩散统一进同一个框架
  • arXiv:2107.03006 — D3PM(NeurIPS 2021):离散数据怎么加噪,“挖空”作为吸收态
  • arXiv:2502.09992 — LLaDA:掩码扩散撑到 8B 规模的语言模型

站内相关