🏠 总目录📚 本教程 01 · 第一天:从噪声里捞出一张图 ← →
📑 本页目录(点开跳转)

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,是同一笔账的另一种配法。

原图的分量一路降到 0,噪声的分量一路升到 1sqrt(abar) = 1.0000.0470.010sqrt(1-abar) = 0.999t=0t=99t=199⭐ 两条线在中间交叉 —— 那一段既不是原图也不是纯噪声,模型学得最费劲的正是那里数字取自本章实测输出,不是示意
看两条线**交叉的那一段**:既不是原图也不是纯噪声。⭐ 训练时每个 t 都要采到,而中间那一段最难 —— 这也是后面「为什么各个 t 的损失量级要均衡」的由来。

🔮 四、反过来走:训练 + 采样

前向是死规则,要学的只有反向那一步。训练目标简单到不像真的:随机挑一个时刻 $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 与高斯混合 这一章造数据用的「两团高斯」正式的名字叫高斯混合;那一章讲怎么反过来从点里估出这两团

✅ 检查点

  1. 前向加噪过程里有几个要学的参数?为什么可以「跳到第 137 步」而不用跑前面 136 步?
  2. $\sqrt{\bar\alpha_t}$ 和 $\sqrt{1-\bar\alpha_t}$ 各代表什么?t=199 时它们分别是多少?
  3. 训练时网络的输入是什么、输出是什么、损失是什么?
  4. 判断「模型学会了」看哪几个数字?模型采到的两团中心是多少?
  5. 同一个模型,「走完 200 步」和「只走 1 步反解」的命中率差多少?为什么一步不行?
  6. 不给网络 $t$ 会怎样?给出数字。
  7. 采样时去掉那一项随机噪声,「落在团里」变高了,为什么这反而是坏事?
👀 答案
  1. 零个 —— 前向是写死的规则,$\beta_t$ 是人定的表。能跳步是因为累乘量 $\bar\alpha_t=\prod(1-\beta_s)$ 给了闭式解 $x_t=\sqrt{\bar\alpha_t}x_0+\sqrt{1-\bar\alpha_t}\epsilon$。
  2. $\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)$。
  3. 输入是 $(x_t,\,t)$,输出是它猜的那个噪声 $\hat\epsilon$,损失是 $\hat\epsilon$ 和真实 $\epsilon$ 的均方误差 —— 就一行 ((model(xt, t) - eps) ** 2).mean()。
  4. 看两团的中心、团内 std、落在团里的比例。模型采到的中心是 (-2.05, -0.93) 和 (2.05, 1.04),真值 ±2 / ±1;团内 std 0.37,真值 0.35。
  5. 97.4% vs 15.8%(地板是纯噪声的 10.6%)。一步不行是因为反解要除以 $\sqrt{\bar\alpha_T}=0.047$,把网络的残差放大二十多倍,结果整体 std 只剩 0.99 / 0.84 而真值是 2.02 / 1.06。
  6. 命中率从 97.4% 掉到 74.6%,团内 std 从 0.37 涨到 0.565。因为「$x_t$ 里有多少是噪声」完全由 $t$ 决定,不给 $t$ 网络只能对所有时刻用同一套猜法。
  7. 因为团内 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

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