📑 本页目录(点开跳转)
01 · 第一天:从噪声里捞出一张图
⏱ 52 分钟(读完全章;⭐ 真正动手那段 CPU 十几秒跑完)| ⭐ 五十行 PyTorch,亲眼看见纯噪声变回一个分布
🎯 一句话
扩散模型只学一件事:把一点噪声擦掉。然后把这个动作重复几百次。 这一章不解释为什么能这样,先让你在自己机器上看见它真的发生 —— 后面几章再回来算账。
🧪 一、为什么第一天不跑真的文生图
跑一个真的文生图模型要下载几个 GB 的权重,多半还要一块显卡。那不是「第一天」该干的事,而且跑完你看到的只是一张图,看不见里面在发生什么。
所以这一章把维度从 400 万降到 2:数据是平面上的两团点,去噪网络是一个三层 MLP。
| 真的文生图模型 | 本章的玩具 | |
|---|---|---|
| 数据 | 512×512×3 的图片 | 平面上一个点 (x, y) |
| 去噪网络 | U-Net / DiT,上亿参数 | 三层 MLP,约 2 万参数 |
| 步数 T | 1000 | 200,⭐ CPU 十几秒训完 |
| 怎么看结果 | 肉眼看图 | ⭐ 看数字:采样点的中心、团内散布、落点比例 |
⭐ 除了这几行,别的一模一样:同一条加噪公式、同一个训练目标、同一套采样循环。 ⚠️ 用数字而不是图看结果是刻意的 —— 肉眼看图容易自我欺骗,「看着挺像」和「分布对不对」是两回事。这个区别到 09 章讲评估时会变成正题。
🎲 二、先造一个「分布」
得先有个东西给模型学。这里用平面上的两团点:中心分别在 (-2, -1) 和 (2, 1),各占一半,每团自己散开 0.35。
import torch
torch.manual_seed(0)
CENTERS = torch.tensor([[-2.0, -1.0], [2.0, 1.0]]) # 两团的中心
def make_data(n):
return CENTERS[torch.randint(0, 2, (n,))] + 0.35 * torch.randn(n, 2)
x0 = make_data(4096)
lab = torch.cdist(x0, CENTERS).argmin(1) # 每个点离哪个中心近
print("整体std:", [round(v, 3) for v in x0.std(0).tolist()])
for k in (0, 1):
g = x0[lab == k]
print(f"第 {k} 团 {len(g)} 个点 中心 {[round(v,3) for v in g.mean(0).tolist()]}"
f" 团内std {round(g.std(0).mean().item(), 3)}")
整体std: [2.042, 1.061]
第 0 团 2007 个点 中心 [-2.016, -1.006] 团内std 0.356
第 1 团 2089 个点 中心 [2.007, 0.995] 团内std 0.35
⭐ 记住这三样:中心 ±2 / ±1、团内散布 0.35、两团各一半。判断模型学没学会,靠的就是它们。
🌫️ 三、加噪:把它一路糊成纯噪声
前向过程是写死的规则,一个要学的参数都没有:给每一步定一个 $\beta_t$,每步往数据里掺一点新噪声。累乘出来的 $\bar\alpha_t=\prod_{s\le t}(1-\beta_s)$ 让你跳过循环、一步算出任意时刻的 $x_t$:
$$x_t=\sqrt{\bar\alpha_t}\;x_0+\sqrt{1-\bar\alpha_t}\;\epsilon,\qquad \epsilon\sim\mathcal N(0,I)$$
人话:$\sqrt{\bar\alpha_t}$ 是「原图还剩多少」,$\sqrt{1-\bar\alpha_t}$ 是「噪声占多少」,两个系数的平方和恒为 1。 想看第 137 步长什么样,不必把前面 136 步跑一遍。
import torch
torch.manual_seed(0)
T = 200
betas = torch.linspace(1e-4, 0.06, T) # 每一步掺多少新噪声
abar = torch.cumprod(1.0 - betas, dim=0) # ᾱ_t = (1-β₁)(1-β₂)…(1-β_t) ⭐ 秘密全在它身上
CENTERS = torch.tensor([[-2.0, -1.0], [2.0, 1.0]])
which = torch.randint(0, 2, (4096,))
x0 = CENTERS[which] + 0.35 * torch.randn(4096, 2)
# ⚠️ 表头用 ASCII:Windows 控制台默认 gbk,打 √ 和 ᾱ 会直接抛 UnicodeEncodeError
print(f"{'t':>4}{'abar':>9}{'sqrt(abar)':>12}{'sqrt(1-abar)':>14}{'两团中心间距':>13}{'整体std':>9}")
for t in (0, 49, 99, 149, 199):
a = abar[t]
xt = a.sqrt() * x0 + (1 - a).sqrt() * torch.randn_like(x0) # ⭐ 一步到位,不用循环
gap = (xt[which == 0].mean(0) - xt[which == 1].mean(0)).norm()
print(f"{t:>4}{a:>9.4f}{a.sqrt():>12.3f}{(1-a).sqrt():>14.3f}{gap:>13.3f}{xt.std():>9.3f}")
print("原始两团中心间距:", round((CENTERS[0] - CENTERS[1]).norm().item(), 3))
t abar sqrt(abar) sqrt(1-abar) 两团中心间距 整体std
0 0.9999 1.000 0.010 4.493 1.627
49 0.6869 0.829 0.560 3.709 1.453
99 0.2198 0.469 0.883 2.117 1.169
149 0.0324 0.180 0.984 0.799 1.023
199 0.0022 0.047 0.999 0.205 0.996
原始两团中心间距: 4.472
⭐ 这张表怎么读:盯「两团中心间距」这一列 —— 原本 4.472,到 t=199 只剩 0.205,两团彻底认不出来了;同时整体 std 收到 0.996,也就是 $\mathcal N(0,I)$。信息是被均匀擦干净的,不是被搬走的。
⚠️ $\beta$ 的上限(这里 0.06)是配着 T=200 挑的:T 越小每步就得掺得越猛,否则最后一步还留着原图的形状,而采样时你却从纯噪声起步 —— 两头对不上。DDPM 原论文用 T=1000、$\beta$ 到 0.02,是同一笔账的另一种配法。
🔮 四、反过来走:训练 + 采样
前向是死规则,要学的只有反向那一步。训练目标简单到不像真的:随机挑一个时刻 $t$,按上面的公式造出 $x_t$,让网络看着 $x_t$ 和 $t$ 猜出刚才掺进去的那个 $\epsilon$,用均方误差罚它。就一行。
采样反过来:从纯噪声起步,每一步用网络猜出的噪声往回退一点,退 200 次。
⚠️ 下面这段是完整可跑的一整个文件,复制到空文件里 python xxx.py 就行。
import math, time, torch
import torch.nn as nn
torch.manual_seed(0)
# ---------- ① 数据:两个高斯团 ----------
CENTERS = torch.tensor([[-2.0, -1.0], [2.0, 1.0]])
def make_data(n):
return CENTERS[torch.randint(0, 2, (n,))] + 0.35 * torch.randn(n, 2)
# ---------- ② 噪声表 ----------
T = 200
betas = torch.linspace(1e-4, 0.06, T)
alphas = 1.0 - betas
abar = torch.cumprod(alphas, 0)
# ---------- ③ 去噪网络:输入 (x_t, t),输出「我猜掺进去的噪声是什么」----------
class Denoiser(nn.Module):
def __init__(self, dim=2, h=128, temb=32):
super().__init__()
self.temb = temb
self.net = nn.Sequential(
nn.Linear(dim + temb, h), nn.SiLU(),
nn.Linear(h, h), nn.SiLU(),
nn.Linear(h, dim))
def forward(self, x, t):
half = self.temb // 2
freq = torch.exp(-math.log(1000.0) * torch.arange(half) / half)
ang = t[:, None].float() * freq[None, :]
emb = torch.cat([ang.sin(), ang.cos()], 1) # 告诉网络「现在噪声有多大」
return self.net(torch.cat([x, emb], 1))
model = Denoiser()
opt = torch.optim.Adam(model.parameters(), lr=2e-3)
# ---------- ④ 训练:唯一的损失就是「噪声猜得准不准」----------
t0, run = time.time(), 0.0
for step in range(4000):
x0 = make_data(256)
t = torch.randint(0, T, (256,))
eps = torch.randn_like(x0)
a = abar[t][:, None]
xt = a.sqrt() * x0 + (1 - a).sqrt() * eps # 前向加噪,一步到位
loss = ((model(xt, t) - eps) ** 2).mean() # ⭐ 全部训练目标就这一行
opt.zero_grad(); loss.backward(); opt.step()
run = loss.item() if step == 0 else 0.99 * run + 0.01 * loss.item()
if step % 1000 == 0 or step == 3999:
print(f"step {step:4d} 平滑后的 loss {run:.4f}")
print(f"训练用时 {time.time() - t0:.1f} 秒")
# ---------- ⑤ 采样:从纯噪声倒着走 200 步 ----------
@torch.no_grad()
def sample(n):
x = torch.randn(n, 2) # t = T-1,纯噪声
for i in reversed(range(T)):
t = torch.full((n,), i, dtype=torch.long)
mean = (x - betas[i] / (1 - abar[i]).sqrt() * model(x, t)) / alphas[i].sqrt()
x = mean if i == 0 else mean + betas[i].sqrt() * torch.randn_like(x)
return x
# ---------- ⑥ 对照:不迭代,直接从纯噪声反解一次 ----------
@torch.no_grad()
def one_shot(n):
x = torch.randn(n, 2)
t = torch.full((n,), T - 1, dtype=torch.long)
return (x - (1 - abar[-1]).sqrt() * model(x, t)) / abar[-1].sqrt()
def report(name, pts):
d = torch.cdist(pts, CENTERS); lab = d.argmin(1)
hit = (d.min(1).values < 1.0).float().mean() * 100 # ⭐ 落在任一团 1.0 半径内的比例
c = [[round(v, 2) for v in pts[lab == k].mean(0).tolist()] for k in (0, 1)]
s = [round(pts[lab == k].std(0).mean().item(), 3) for k in (0, 1)]
print(f"{name:16s} 落在团里 {hit:5.1f}% 两团中心 {c[0]} {c[1]} "
f"团内std {s} 整体std {[round(v, 2) for v in pts.std(0).tolist()]}")
torch.manual_seed(1)
report("真实数据", make_data(4000))
report("走完 200 步", sample(4000))
report("只走 1 步反解", one_shot(4000))
report("什么都不做", torch.randn(4000, 2))
我这台普通笔记本(CPU,PyTorch 2.13)上的真实输出:
step 0 平滑后的 loss 1.0095
step 1000 平滑后的 loss 0.2697
step 2000 平滑后的 loss 0.2605
step 3000 平滑后的 loss 0.2596
step 3999 平滑后的 loss 0.2600
训练用时 17.3 秒
真实数据 落在团里 98.2% 两团中心 [-1.99, -1.0] [1.99, 0.99] 团内std [0.349, 0.355] 整体std [2.02, 1.06]
走完 200 步 落在团里 97.4% 两团中心 [-2.05, -0.93] [2.05, 1.04] 团内std [0.37, 0.365] 整体std [2.08, 1.05]
只走 1 步反解 落在团里 15.8% 两团中心 [-0.36, -0.71] [1.01, 0.33] 团内std [0.63, 0.736] 整体std [0.99, 0.84]
什么都不做 落在团里 10.6% 两团中心 [-0.72, -0.33] [0.71, 0.34] 团内std [0.819, 0.825] 整体std [1.0, 1.0]
⚠️ 你的绝对数字会和这里不同(机器、torch 版本、线程数都影响),要看的是四行之间的关系。训练用时我这里 10 到 60 多秒都出现过,取决于机器当时忙不忙。
🛑 读到这里可以停 —— 已经读了约 33 分钟。 最后一段还有(约 20 分钟):读这四行 · 三个值得自己改一下的地方 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。
🔍 五、读这四行
⭐ 「走完 200 步」那一行和真实数据几乎重合:两团中心猜到了 (-2.05, -0.93) 和 (2.05, 1.04)(真值 ±2 / ±1),团内 std 0.37(真值 0.35),落在团里 97.4% 对真实数据的 98.2%。模型从来没见过「中心在哪」这个信息,它只被要求猜噪声。
纯噪声什么都不做只有 10.6%,那是地板。
而「只走 1 步反解」值得多看一眼 —— 它用的是同一个训练好的模型、同一份权重,只是拿 $x_T$ 直接反解出 $\hat x_0=(x_T-\sqrt{1-\bar\alpha_T}\,\hat\epsilon)/\sqrt{\bar\alpha_T}$。此时 $\sqrt{\bar\alpha_T}=0.047$,除以它等于把网络那点残差放大二十多倍,结果整体 std 只剩 0.99 / 0.84(真值 2.02 / 1.06),命中率 15.8%,比地板高不了多少。
⭐ 这就是「为什么必须一步步走」最直接的证据:不是迭代更精细,而是一步跨过去的那个除法在数值上根本不成立。02 章会把这件事算清楚。
⚠️ 另外注意损失稳在 0.26 附近就不再降,这是正常的:同一个 $x_t$ 可能由无数组 $(x_0,\epsilon)$ 产生,网络再准也消不掉这份固有的不确定性。看 loss 的绝对值判断扩散模型训得好不好基本没用 —— 要看采样结果。
🧯 六、三个值得自己改一下的地方
每一行都只改上面那段代码的一处,我把结果也跑出来了(判据是同一个「落在团里」,外加团内 std,真值 0.35):
| 改什么 | 怎么改 | 落在团里 | 团内 std |
|---|---|---|---|
| —— | 不改 | 97.4% | 0.370 / 0.365 |
| 训练不够 | range(4000) → range(400) |
94.7% | 0.396 / 0.411 |
| 不告诉网络现在是第几步 | forward 里加一行 emb = torch.zeros_like(emb) |
⚠️ 74.6% | 0.565 / 0.563 |
| 采样时不加那一项随机噪声 | x = mean if i == 0 else mean + … 改成 x = mean |
99.7% | 💀 0.093 / 0.084 |
⭐ 第三行是这一章最该记住的一条:$x_t$ 里「有多少是原图、有多少是噪声」完全由 $t$ 决定 —— 不给它 $t$,它只能对所有时刻用同一套猜法,命中率当场从 97.4% 掉到 74.6%。时间嵌入不是装饰。
💀 第四行是个陷阱:去掉采样里那一项随机噪声,「落在团里」反而涨到 99.7%,看上去更好。但团内 std 塌到 0.09(真值 0.35):所有样本挤成了两个点,多样性全没了。
⚠️ 单看一个指标会骗你。 一个「更准」的采样器可能只是更保守。这件事在 04 章(DDIM 也是确定性采样)和 09 章(FID 与 CLIP Score 各自看不见什么)还会各出现一次。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| ML 基础 15 · PyTorch 实战手册 | 上面那段训练循环看着吃力的话,那一章有一份验证过能跑的完整模板,还有「六个高频 Bug」 |
| ML 基础 09 · 优化器与学习率 | 代码里的 Adam(lr=2e-3) 为什么是这个数、调大调小会怎样 |
| 全景导论 10b · 图像生成 | 想马上知道从这个玩具到真的文生图还缺哪些零件(VAE、文本编码器、CFG、ControlNet),那一页 14 分钟列全 |
| 数学原理 13 · EM 与高斯混合 | 这一章造数据用的「两团高斯」正式的名字叫高斯混合;那一章讲怎么反过来从点里估出这两团 |
✅ 检查点
- 前向加噪过程里有几个要学的参数?为什么可以「跳到第 137 步」而不用跑前面 136 步?
- $\sqrt{\bar\alpha_t}$ 和 $\sqrt{1-\bar\alpha_t}$ 各代表什么?t=199 时它们分别是多少?
- 训练时网络的输入是什么、输出是什么、损失是什么?
- 判断「模型学会了」看哪几个数字?模型采到的两团中心是多少?
- 同一个模型,「走完 200 步」和「只走 1 步反解」的命中率差多少?为什么一步不行?
- 不给网络 $t$ 会怎样?给出数字。
- 采样时去掉那一项随机噪声,「落在团里」变高了,为什么这反而是坏事?
👀 答案
- 零个 —— 前向是写死的规则,$\beta_t$ 是人定的表。能跳步是因为累乘量 $\bar\alpha_t=\prod(1-\beta_s)$ 给了闭式解 $x_t=\sqrt{\bar\alpha_t}x_0+\sqrt{1-\bar\alpha_t}\epsilon$。
- $\sqrt{\bar\alpha_t}$ 是原图还剩多少,$\sqrt{1-\bar\alpha_t}$ 是噪声占多少,平方和恒为 1。t=199 时是 0.047 和 0.999:两团中心间距从 4.472 掉到 0.205、整体 std 0.996,已经是 $\mathcal N(0,I)$。
- 输入是 $(x_t,\,t)$,输出是它猜的那个噪声 $\hat\epsilon$,损失是 $\hat\epsilon$ 和真实 $\epsilon$ 的均方误差 —— 就一行
((model(xt, t) - eps) ** 2).mean()。 - 看两团的中心、团内 std、落在团里的比例。模型采到的中心是
(-2.05, -0.93)和(2.05, 1.04),真值 ±2 / ±1;团内 std 0.37,真值 0.35。 - 97.4% vs 15.8%(地板是纯噪声的 10.6%)。一步不行是因为反解要除以 $\sqrt{\bar\alpha_T}=0.047$,把网络的残差放大二十多倍,结果整体 std 只剩 0.99 / 0.84 而真值是 2.02 / 1.06。
- 命中率从 97.4% 掉到 74.6%,团内 std 从 0.37 涨到 0.565。因为「$x_t$ 里有多少是噪声」完全由 $t$ 决定,不给 $t$ 网络只能对所有时刻用同一套猜法。
- 因为团内 std 塌到 0.09(真值 0.35)—— 所有样本挤成两个点,多样性没了。⭐「落在团里」这个指标奖励保守,单看一个指标会骗你。
🛑 可以停在这里
你已经在自己机器上跑出了一个能工作的扩散模型,并且知道了它的三个要害:前向是死规则、训练只学「猜噪声」、采样必须一步步走。停在这里完全可以。
⚠️ 什么时候回来:想知道为什么损失稳在 0.26 而不趋近 0、为什么目标是猜噪声而不是直接猜原图,或者那 200 步能不能少走几步 —— 分别是 02、03、04 章。
⚡ 走神救援
⭐ 扩散模型只学一件事:把一点噪声擦掉,然后重复几百次。 这一章在二维玩具数据上从头训了一个,CPU 十几秒;除了维度(400 万 → 2)和网络(U-Net → 三层 MLP),公式、目标、采样循环和真模型一模一样。数据是平面上两团点,中心
(-2,-1)和(2,1)、团内散开 0.35。🌫️ 前向加噪是写死的规则,零个可学参数。关键量是累乘 $\bar\alpha_t=\prod(1-\beta_s)$,它给出闭式解 $x_t=\sqrt{\bar\alpha_t}x_0+\sqrt{1-\bar\alpha_t}\epsilon$:前一个系数是原图还剩多少、后一个是噪声占多少,平方和恒为 1,所以能一步跳到任意时刻。t=199 时两者是 0.047 / 0.999,两团中心间距从 4.472 掉到 0.205、整体 std 0.996。⚠️ β 上限要配着 T 挑,T 小就得掺得更猛。
🔮 训练目标就一行:随机挑时刻 t 造出 $x_t$,让网络看着 $(x_t,t)$ 猜出刚掺进去的 $\epsilon$,均方误差。采样从纯噪声起步倒着走 200 步。结果:两团中心 (-2.05,-0.93) / (2.05,1.04)(真值 ±2/±1),团内 std 0.37(真值 0.35),落在团里 97.4%,真实数据 98.2%、纯噪声只有 10.6%。它从没见过中心在哪,只被要求猜噪声。 ⚠️ 损失稳在 0.26 不再降是正常的 —— 同一个 $x_t$ 能由无数组 $(x_0,\epsilon)$ 产生,别拿 loss 判断扩散模型好坏。
⚠️ 只走 1 步反解,命中率掉到 15.8%:要除以 $\sqrt{\bar\alpha_T}=0.047$,把残差放大二十多倍,整体 std 只剩 0.99/0.84(真值 2.02/1.06)。这就是「必须一步步走」最直接的证据。🧯 三个改一行的实验:只训 400 步 → 94.7%;不告诉网络现在是第几步 → 74.6%(⭐ 时间嵌入不是装饰);采样去掉随机项 → 落在团里涨到 99.7%,团内 std 却塌到 0.09(真值 0.35)——💀 样本挤成两个点,看着更准其实只是更保守,单看一个指标会骗你。
下一节 👉 02-加噪与去噪.md