🏠 总目录📚 本教程 02 · 加噪与去噪 ← →
📑 本页目录(点开跳转)

02 · 加噪:一条不用学的公式

⏱ 32 分钟 | ⭐ 前向过程零个可训练参数,而它决定了训练能不能并行


🎯 一句话

扩散有两条方向相反的路,只有反向那条要学。 这一页讲前向:加噪是一条写死的公式,零个可训练参数 —— 而它最要紧的性质是能一步跳到任意时刻,训练能并行全靠这一条。


🌫️ 一、前向过程:一条不用学的公式

从原图 $x_0$ 出发,每一步往里掺一点高斯噪声:

$$x_t = \sqrt{1-\beta_t}\; x_{t-1} + \sqrt{\beta_t}\; \varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, I)$$

人话:把上一步的图按 $\sqrt{1-\beta_t}$ 缩小一丁点,再掺进 $\sqrt{\beta_t}$ 那么多的纯噪声。$\beta_t$ 是个很小的数(DDPM 原文从 0.0001 线性涨到 0.02),所以单看一步几乎什么都没变 —— 变化是 $T$ 步(典型 $T=1000$)累出来的。

前向:一条写死的链,零个可训练参数x₀原图x₁x₂…x_T纯噪声每一步 +√β_t·ε,β 是一张表(DDPM 里 0.0001 → 0.02,T=1000)⭐ 闭式采样:一步跳到任意 t,而且是恒等不是近似实测 t=1000 时 0.29 ms,迭代法要 182.5 ms —— 训练能并行全靠它
上面那条实线是定义:一步步加噪,每步都要算。下面那条绿色虚线是捷径:直接跳到任意 t。⭐ 两条路终点完全一样 —— 所以训练时永远走下面那条。

⭐ 三件事现在就说清楚:

事实 说明
这里没有一个参数 $\beta_1 \dots \beta_T$ 是事先定好的一张表,从头到尾不参与训练,也不需要梯度
它是一条马尔可夫链 $x_t$ 只依赖 $x_{t-1}$,跟更早的怎么来的无关
那两个系数不是随手挑的 它们凑成一对,让方差在整条链上不变 —— 见下一小节

🧪 为什么偏偏是 √(1−β) 和 √β

假设原图每个像素的方差是 1。代进公式:$\mathrm{Var}(x_t) = (1-\beta_t)\cdot 1 + \beta_t \cdot 1 = 1$。加完噪声,方差还是 1。 这就是所谓的 variance preserving(方差保持):整条链上每一步的数值范围都差不多,网络不会在 $t=10$ 时看到 $\pm 1$ 的输入、在 $t=900$ 时看到 $\pm 30$ 的输入。

跑一下就看得见:

# √(1-β_t) 这个系数不是随手挑的:它让方差在整条链上保持不变
import torch

torch.manual_seed(0)
T = 1000
abar = torch.cumprod(1 - torch.linspace(1e-4, 0.02, T), dim=0)

for name, x0 in [("方差=1 的数据", torch.randn(200000)),
                 ("方差≠1 的数据", torch.randn(200000) * 3)]:
    stds = []
    for t in [1, 250, 500, 750, 1000]:
        a = abar[t - 1]
        xt = a.sqrt() * x0 + (1 - a).sqrt() * torch.randn_like(x0)
        stds.append(f"{xt.std():.3f}")
    print(f"{name}(std={x0.std():.2f})→ 各步的 std:{' '.join(stds)}")

真实输出:

方差=1 的数据(std=1.00)→ 各步的 std:1.000 0.998 0.999 1.000 0.999
方差≠1 的数据(std=3.00)→ 各步的 std:3.003 2.283 1.277 1.016 1.001

⚠️ 看第二行:数据方差不是 1 的时候,输入尺度会从 3.00 一路漂到 1.00 —— 同一个网络在不同 $t$ 上看到的量级差三倍。

⭐ 这就是「图像要先归一化到 −1 到 1」的真正理由。 不是玄学预处理,是方差保持这条性质要求输入方差在 1 附近才成立。


⚡ 二、闭式采样:训练能并行的全部原因

记 $\alpha_t = 1-\beta_t$,$\bar\alpha_t = \alpha_1 \alpha_2 \cdots \alpha_t$。那么任意时刻都可以一步到位:

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

人话:想知道第 $t$ 步长什么样,不用真的走 $t$ 步 —— 把原图乘一个数、把一份噪声乘另一个数,加起来就完了。$\sqrt{\bar\alpha_t}$ 是「原图还剩多少」,$\sqrt{1-\bar\alpha_t}$ 是「噪声占了多少」,两者平方和恒等于 1。

📐 为什么能合并(想看再点)

把两步展开:

$$x_2 = \sqrt{\alpha_2}\left(\sqrt{\alpha_1} x_0 + \sqrt{1-\alpha_1}\,\varepsilon_1\right) + \sqrt{1-\alpha_2}\,\varepsilon_2$$

$$= \sqrt{\alpha_1\alpha_2}\, x_0 + \underbrace{\sqrt{\alpha_2(1-\alpha_1)}\,\varepsilon_1 + \sqrt{1-\alpha_2}\,\varepsilon_2}_{\text{两个独立高斯相加}}$$

⭐ 关键一步:两个独立的零均值高斯相加,结果还是高斯,而且方差直接相加:

$$\alpha_2(1-\alpha_1) + (1-\alpha_2) = 1 - \alpha_1\alpha_2$$

所以那一整坨等价于一个方差为 $1-\alpha_1\alpha_2$ 的高斯。归纳下去就得到 $\bar\alpha_t = \prod_{s\le t}\alpha_s$。

⚠️ 合并成立只因为噪声与数据无关、而且每步都是线性的。反向那条路两个条件都不满足,所以合并不了(见第五节)。

🔬 亲手验证一下

两种算法应该给出同一个分布 —— 迭代法每步重采一次噪声、走 $t$ 次;闭式法只采一次噪声、算一次乘加:

# 验证:一步步加 T 次噪 vs 闭式一步跳到 t —— 两种算法得到同一个分布
import time
import torch

torch.manual_seed(0)

T = 1000
betas = torch.linspace(1e-4, 0.02, T)     # DDPM 原论文的 linear 调度
alphas = 1.0 - betas
abar = torch.cumprod(alphas, dim=0)       # ᾱ_t = α_1 α_2 … α_t

x0 = torch.full((20000,), 3.0)            # 2 万份「同一张图」,这里把图简化成一个数

def iterative(x0, t):                     # 走法 A:老老实实迭代 t 次
    x = x0.clone()
    for i in range(t):
        eps = torch.randn_like(x)         # ⭐ 每一步都要重新采一次噪声
        x = alphas[i].sqrt() * x + betas[i].sqrt() * eps
    return x

def closed_form(x0, t):                   # 走法 B:闭式,一步到位
    eps = torch.randn_like(x0)            # ⭐ 只采【一次】噪声
    return abar[t - 1].sqrt() * x0 + (1 - abar[t - 1]).sqrt() * eps

for t in [10, 100, 500, 1000]:
    s = time.perf_counter(); a = iterative(x0, t);   ta = time.perf_counter() - s
    s = time.perf_counter(); b = closed_form(x0, t); tb = time.perf_counter() - s
    print(f"t={t:4d} | 迭代 mean={a.mean():+.4f} std={a.std():.4f} ({ta*1000:6.1f} ms)"
          f" | 闭式 mean={b.mean():+.4f} std={b.std():.4f} ({tb*1000:5.2f} ms)"
          f" | 理论 mean={(abar[t-1].sqrt()*3).item():+.4f} std={(1-abar[t-1]).sqrt().item():.4f}")

真实输出:

t=  10 | 迭代 mean=+2.9967 std=0.0435 (   3.1 ms) | 闭式 mean=+2.9971 std=0.0438 ( 0.22 ms) | 理论 mean=+2.9972 std=0.0435
t= 100 | 迭代 mean=+2.8393 std=0.3217 (  15.4 ms) | 闭式 mean=+2.8381 std=0.3187 ( 0.29 ms) | 理论 mean=+2.8413 std=0.3209
t= 500 | 迭代 mean=+0.8287 std=0.9592 (  90.2 ms) | 闭式 mean=+0.8384 std=0.9678 ( 0.18 ms) | 理论 mean=+0.8410 std=0.9599
t=1000 | 迭代 mean=+0.0265 std=1.0017 ( 182.5 ms) | 闭式 mean=+0.0140 std=0.9954 ( 0.29 ms) | 理论 mean=+0.0191 std=1.0000

三列的均值和标准差逐行对得上,闭式解不是近似,是恒等。⚠️ 毫秒数每次跑都不一样,要看的是数量级:闭式那一列不随 $t$ 增长,迭代那一列线性涨。

⭐⭐ 这一条是训练能并行的全部原因。 训练时 $t$ 是随机抽的:一个 batch 里 512 张图,每张各抽各的 $t$,全部一次乘加算完。 要是没有闭式解,你得为 batch 里每一张图各跑一遍长达几百步的循环 —— 训练成本会直接乘上几百倍。


🌑 三、终点 x_T:和原图已经没关系了

一直加下去,最后剩什么?直接量 $x_0$ 和 $x_t$ 的相关系数:

# x_T 还剩多少「原图的影子」:直接量 x_0 和 x_t 的相关系数
import torch

torch.manual_seed(1)
T = 1000
abar = torch.cumprod(1 - torch.linspace(1e-4, 0.02, T), dim=0)

x0 = torch.rand(200000) * 2 - 1            # 20 万个像素值,归一化到 [-1, 1]

for t in [1, 100, 300, 500, 700, 900, 1000]:
    a = abar[t - 1]
    xt = a.sqrt() * x0 + (1 - a).sqrt() * torch.randn_like(x0)
    corr = torch.corrcoef(torch.stack([x0, xt]))[0, 1].item()
    print(f"t={t:4d}  sqrt(abar_t)={a.sqrt():.4f}   corr(x0, x_t) = {corr:+.4f}")

真实输出:

t=   1  sqrt(abar_t)=0.9999   corr(x0, x_t) = +0.9998
t= 100  sqrt(abar_t)=0.9471   corr(x0, x_t) = +0.8611
t= 300  sqrt(abar_t)=0.6296   corr(x0, x_t) = +0.4215
t= 500  sqrt(abar_t)=0.2803   corr(x0, x_t) = +0.1696
t= 700  sqrt(abar_t)=0.0835   corr(x0, x_t) = +0.0462
t= 900  sqrt(abar_t)=0.0166   corr(x0, x_t) = +0.0126
t=1000  sqrt(abar_t)=0.0064   corr(x0, x_t) = +0.0031

到 $t=1000$,相关系数只剩 0.0031 —— $x_T$ 已经和原图基本无关,就是一团标准高斯噪声。

⭐ 这才是生成能启动的原因。 如果 $x_T$ 还带着原图的影子,你就没法「凭空开始」—— 得先有一张图才能生成。 正因为终点和输入无关,采样时才可以直接 torch.randn(...) 起手。

⚠️ 但「几乎无关」不是「完全无关」。 看最后一行:$\sqrt{\bar\alpha_T} = 0.0064$,不是 0。也就是说训练时模型在最后一步见到的输入里,还残留着原图约 0.6% 的信息 —— 其中最显眼的是整幅画面的平均亮度。而采样时你喂进去的是纯噪声,那 0.6% 是空的。这个「训练时有、推理时没有」的口径不一致叫 signal leak,症状是模型很难生成整体极亮或极暗的画面(结果的平均亮度总往中灰靠)。修法是把调度改成终点严格 $\bar\alpha_T = 0$,即 zero terminal SNR。


🔗 这一章连到哪里

去哪 为什么
03 · 训练目标为什么是预测噪声 闭式采样是训练循环的第 4 步 —— 那一页讲拿它拼出 $x_t$ 之后要让网络做什么
02b · 调度表与反向过程 β 这张表具体该长什么样,以及反向那一半为什么不能照抄前向
机器学习的数学原理 01b · KL 散度 「两个独立高斯相加、方差直接相加」这类性质,是后面变分下界推导的地基
NumPy 与向量化思维 03 · 广播的三条规则 闭式采样要对一整个 batch 同时算不同的 $t$,靠的就是广播

✅ 检查点

  1. 前向过程里有多少个可训练参数?$\beta_t$ 是学出来的吗?
  2. 为什么系数写成 $\sqrt{1-\beta_t}$ 和 $\sqrt{\beta_t}$ 而不是别的?这条性质反过来对输入数据提了什么要求?
  3. 闭式采样式 $x_t = \sqrt{\bar\alpha_t}x_0 + \sqrt{1-\bar\alpha_t}\varepsilon$ 是精确的还是近似的?它成立靠的是哪个数学事实?
  4. 如果没有闭式采样,训练要多付出什么代价?
  5. $t=1000$ 时 $x_0$ 和 $x_t$ 的相关系数是多少?这个数字为什么决定了「生成能不能凭空启动」?
👀 答案
  1. 零个。 $\beta_t$ 是一张事先定好的表(DDPM 里从 0.0001 线性涨到 0.02),不参与训练、不需要梯度。整个前向过程是写死的公式。
  2. 因为 $(1-\beta_t) + \beta_t = 1$,所以方差保持不变(variance preserving):输入方差是 1,加噪后还是 1。代码里量到方差为 1 的数据各步 std 是 1.000 0.998 0.999 1.000 0.999。要求:输入数据方差必须在 1 附近 —— 这就是图像要归一化到 −1 至 1 的理由;方差为 3 的数据 std 会从 3.00 一路漂到 1.00。
  3. 精确的,是恒等不是近似。 靠的是「两个独立零均值高斯相加仍是高斯、且方差直接相加」:$\alpha_2(1-\alpha_1) + (1-\alpha_2) = 1-\alpha_1\alpha_2$。
  4. 一个 batch 里每张图抽的 $t$ 都不同,没有闭式解就得为每张图各跑一遍几百步的循环,训练成本乘上几百倍。代码里 $t=1000$ 时迭代法 182.5 ms、闭式法 0.29 ms,而且闭式那一列不随 $t$ 增长。
  5. +0.0031(此时 $\sqrt{\bar\alpha_T} = 0.0064$)。因为终点几乎和输入无关,采样时才能直接从 torch.randn(...) 起手 —— 否则你得先有一张图才能生成。

🛑 可以停在这里

读到这里,扩散的前向那一半你已经完整了:它是一条写死的公式,而闭式采样让训练能并行。

⚠️ 什么时候看下一页:你想知道 β 那张表具体该怎么定,或者好奇「既然前向能一步跳,反向为什么不能」。

⚡ 走神救援

先记住这几件事

下一节 👉 02b-调度表与反向过程.md

打卡记录保存在你的浏览器里,首页能看到总进度