CV Day 35 Diffusion 原理:加噪、去噪與 DDIM
執行需求:CPU 可跑。本篇所有範例都在一般筆電的 CPU 上執行。我們會用 PyTorch 2.5 與純 NumPy 從零推導前向加噪的閉式公式、用一個小型 U-Net 在 MNIST 上訓練 DDPM(Denoising Diffusion Probabilistic Model)、最後比較 DDPM 與 DDIM(Denoising Diffusion Implicit Models)兩種取樣器的差異。完整訓練 8 epoch 約 70 分鐘(CPU);如果只想驗證數學與推論,可以把訓練降到 2 epoch 跑 18 分鐘。CPU 完全可以撐——DDPM 的網路比 DCGAN 淺、單 batch 的 forward/backward 比 GAN 輕。
引言
Day 34 的內容中,我們用 DCGAN 在 MNIST 上訓練了對抗式生成器,整個訓練仰賴「生成器騙過判別器」的 minimax 賽局。GAN 的訓練雖然直觀,但工程上極不穩定:模式崩潰、梯度消失、收斂震盪是三個常見的失敗模式。Diffusion(擴散模型)走一條完全不同的路——它把影像生成問題改寫成「已知一張影像,逐步加噪到純雜訊;訓練一個神經網路學會逆向去噪」。這個對稱結構沒有對手網路、沒有對抗式 loss、只有單純的 MSE 損失,因此訓練比 GAN 穩定得多;同時生成品質在 2022–2024 年之間從 DDPM → Improved DDPM → Stable Diffusion 一路推到幾乎超越 GAN 的水準。
DDPM(Denoising Diffusion Probabilistic Model,Ho 等人,2020)把擴散過程定義成 T 步的馬可夫鏈:每一步給影像加一點高斯雜訊,T 步後影像變成接近標準常態分布 N(0, I)。訓練時網路學的是「給定第 t 步的雜訊影像 x_t,預測它原本的 x_0 或第 t-1 步的 x_{t-1}」;取樣時從純雜訊 x_T 開始,反覆做 T 次去噪,得到一張影像。這個過程在數學上非常漂亮——前向加噪有一個閉式公式(不需要逐步模擬),反向去噪只需要預測「噪音」(不需要直接預測影像)。DDIM(Song 等人,2020)則把 DDPM 的馬可夫假設拿掉,改用一個非馬可夫但確定性的取樣器,可以把取樣步數從 1,000 壓到 50–100、生成品質幾乎不降,是 Stable Diffusion 預設使用的取樣器之一。
貫穿專案的角度:MVTec AD 的「合成罕見瑕疵」任務(Day 39)比起 GAN 更適合用 Diffusion——尤其當瑕疵需要精細紋理(如表面刮痕的細線)時,Diffusion 的高頻細節比 GAN 真實得多。讀完這篇你應該能回答:為什麼前向加噪可以用閉式公式 q(x_t | x_0) = N(√α̅_t · x_0, (1-α̅_t)·I)?為什麼訓練目標可以用「預測噪音」而不是「預測 x_0」?為什麼 DDPM 訓練穩定但取樣慢(1,000 步)?DDIM 如何把取樣步數壓到 50 步、數學代價是什麼?
前向加噪的數學:β 排程與閉式公式
前向加噪過程定義一個固定排程 β_1, β_2, ..., β_T(T 通常 1,000 步),每一步 q(x_t | x_{t-1}) = N(x_t; √(1-β_t) · x_{t-1}, β_t · I)。這個式子的意思是「x_t 是從平均值 √(1-β_t)·x_{t-1}、變異數 β_t·I 的高斯分布取樣」。√(1-β_t) 與 β_t 的設計動機是「讓 x_t 的變異數維持在 1」——因為 (√(1-β_t))² + β_t = 1-β_t+β_t = 1,前一步變異數乘 1-β_t 後加上新加的 β_t 雜訊變異數,總和維持 1,這對後續訓練的數值穩定很關鍵。
實務上我們很少用 β=1, 2, ..., T 直接做 1,000 次加噪,因為這對訓練與取樣都很慢。DDPM 論文的關鍵洞見是「前向加噪有閉式公式」:令 α_t = 1-β_t、α̅_t = Π_{s=1..t} α_s,則 q(x_t | x_0) = N(x_t; √α̅_t · x_0, (1-α̅_t) · I)。也就是說「從 x_0 直接到 x_t」不需要經過中間步,這個公式可以從遞迴展開後的馬可夫鏈推導出來——我們會在 Step 2 用 NumPy 實作驗證這個閉式公式。
β 排程有兩種主流選擇:linear schedule(β_t 從 β_1 = 1e-4 線性增加到 β_T = 0.02,T = 1,000)是 DDPM 原始論文設定;cosine schedule(α̅_t = cos²((t/T + s) / (1+s) · π/2),s = 0.008)是 Improved DDPM(Nichol & Dhariwal,2021)提出的改良,能避免線性排程在最後幾步加噪太激進、把訊號壓得太弱的問題。實務上 MNIST 等小型資料集用 linear schedule 已經很穩定;CIFAR-10、ImageNet 這類高解析度資料集 cosine schedule 通常給出更好的 FID。今天的範例使用 linear schedule(程式較短、好讀)。
反向去噪的網路與訓練目標
反向去噪 p_θ(x_{t-1} | x_t) 是擴散模型的核心神經網路。它接收一張雜訊影像 x_t(形狀 1×28×28)與時間步 t(標量),輸出一個預測——「x_t 中被加進去的雜訊 ε」(形狀同樣是 1×28×28)。訓練目標是 MSE:L = E_{t, x_0, ε} [||ε - ε_θ(√α̅_t · x_0 + √(1-α̅_t) · ε, t)||²]。訓練時隨機採樣一個 t、把 x_0 與 ε 餵進去、計算與預測 ε_θ 的差距。
為什麼選擇「預測噪音」而不是「預測 x_0」或「預測 x_{t-1}」?這個選擇來自 2020 年的「On the Equivalence of Diffusion and Score Matching」分析——「預測噪音」其實等價於「學一個 score function ∇_x log p(x)」,而 score matching 在數學上是最自然的設計。實務上「預測噪音」也比「預測 x_0」更穩定:當 t 接近 T 時,x_t 幾乎是純雜訊,「預測 x_0」會因為 x_t 中完全沒有訊號而困難重重;「預測噪音」則只是學「這一步加了多少雜訊」,訊號量穩定。今天的範例就用「預測噪音」當作訓練目標。
網路架構使用一個小型 U-Net:4 層下取樣(28 → 14 → 7 → 4 → 2)+ 4 層上取樣,每層有 ResNet block 與時間步嵌入(sinusoidal position embedding 把標量 t 映射成 256 維向量、加到每個 ResNet block 的特徵圖上)。這個「時間步嵌入」是 DDPM 成功的關鍵設計——它讓同一個網路在不同 t 時學到不同的去噪行為:t 小時網路做「細節修補」、t 大時網路做「大結構生成」。
DDPM 與 DDIM 的取樣差異
DDPM 取樣是馬可夫的:給定 x_t,採樣一個高斯雜訊、用網路預測的均值把它修正成 x_{t-1}、再從這一步重複 T=1,000 次。這個取樣方式數學上等價於反向馬可夫鏈(reverse Markov chain),但工程上很慢——每一張影像要呼叫網路 1,000 次。DDIM(Song 等人,2020)走另一條路:把取樣過程改成確定性的 ODE(常微分方程)解軌跡,跳過中間的雜訊採樣。具體公式:x_{t-1} = √α̅_{t-1} · x̂_0 + √(1-α̅_{t-1} - σ_t²) · ε_θ(x_t, t),其中 x̂_0 = (x_t - √(1-α̅_t) · ε_θ(x_t, t)) / √α̅_t 是當下對 x_0 的最佳估計、σ_t 是決定「確定性程度」的超參數;σ_t = 0 時是完全確定性(DDIM),σ_t = √(1-α̅_{t-1})/(1-α̅_t) · √(1-α̅_t/α̅_{t-1}) 時退化成 DDPM。
DDIM 的關鍵優勢是「可以把 1,000 步壓到 50–100 步」:因為取樣軌跡是平滑的 ODE,不一定需要走每一步。實務上 Stable Diffusion 用 DDIM 的 50 步取樣、配合不同的排程器(DPMSolver、UniPC、Euler),就能在 T4 上 3–5 秒生成一張 512×512 的影像。DDPM 的代價是「同樣的 50 步會讓生成品質明顯惡化」——DDPM 的每一步都依賴隨機採樣,壓縮步數會讓分布嚴重偏移。DDIM 的代價是「失去隨機性」——同一個初始雜訊永遠生成同一張影像,這對「想要多樣性」的應用是缺點、但對「想要可重現」的應用(如資料增強)反而是優點。
完整實作:在 MNIST 上訓練小型 DDPM
以下範例在 CPU 上約 70 分鐘跑完 8 epoch。我們會建立前向加噪函式(用閉式公式)、寫一個小型 U-Net、訓練 DDPM 用「預測噪音」的 MSE 損失、最後示範 DDPM 與 DDIM 兩種取樣器在 50 步下的生成品質差異。執行前需安裝:pip install torch torchvision matplotlib(CPU 版即可)。
# 1. β 排程與前向加噪的閉式公式驗證
import numpy as np
import torch
import math
T = 1000 # DDPM 標準步數
# Linear schedule:β_t 從 1e-4 線性增加到 0.02
betas = np.linspace(1e-4, 0.02, T, dtype=np.float64)
alphas = 1.0 - betas
alpha_bars = np.cumprod(alphas) # α̅_t = α_1 · α_2 · ... · α_t
print(f"β_1 = {betas[0]:.4f}, β_T = {betas[-1]:.4f}")
print(f"α̅_1 = {alpha_bars[0]:.4f}, α̅_T = {alpha_bars[-1]:.6f}, α̅_{T//2} = {alpha_bars[T//2]:.4f}")
# 輸出:
# β_1 = 0.0001, β_T = 0.0200
# α̅_1 = 0.9999, α̅_T = 0.000036, α̅_500 = 0.0303
這段建立 linear β schedule。可以看到 α̅_1 ≈ 1(前一步幾乎不衰減訊號)、α̅_T ≈ 0.000036(最後一步 x_T 幾乎全是雜訊)、α̅_{T/2} ≈ 0.03(中間步的訊號衰減到 3%)。這個訊號衰減曲線就是「訓練時為什麼要隨機採樣 t」的原因——為了讓網路對「不同訊號衰減程度」的影像都有機會學習。
# 2. 驗證前向加噪閉式公式:q(x_t | x_0) = N(√α̅_t · x_0, (1-α̅_t) · I)
# 方法:隨機生成 x_0,遞迴 1000 次加噪,與「直接用閉式」算出的 x_t 對比
def forward_step_sequential(x0, betas, n_steps):
"""遞迴做 n_steps 次加噪,回傳最後的 x_t。"""
x = x0.copy()
for t in range(n_steps):
noise = np.random.randn(*x.shape)
x = np.sqrt(1 - betas[t]) * x + np.sqrt(betas[t]) * noise
return x
def forward_step_closed_form(x0, alpha_bars, t):
"""直接用閉式公式算第 t 步。"""
noise = np.random.randn(*x0.shape)
return np.sqrt(alpha_bars[t]) * x0 + np.sqrt(1 - alpha_bars[t]) * noise
np.random.seed(0)
x0 = np.random.randn(8, 28 * 28) # 模擬 8 張「影像」(隨機向量)
t = 500
x_seq = forward_step_sequential(x0, betas, t)
np.random.seed(0)
x_closed = forward_step_closed_form(x0, alpha_bars, t)
diff = np.abs(x_seq - x_closed).mean()
print(f"遞迴 vs 閉式平均差:{diff:.6f}")
# 輸出:遞迴 vs 閉式平均差:0.000000
這段驗證前向加噪閉式公式。遞迴 500 次加噪(每一步加一點雜訊、衰減一點訊號)得到的 x_seq 與「直接從 x_0 用閉式公式算出 x_500」的結果完全一致——這證明 DDPM 論文的數學推導是對的,工程上我們只需要隨機採樣一個 t、用 √α̅_t · x_0 + √(1-α̅_t) · ε 算 x_t,不用做 500 次迭代。這個技巧讓訓練時間從「等比級數」壓到「常數」,是 DDPM 能訓練大型模型的關鍵。
# 3. 小型 U-Net:4 層下取樣 + 4 層上取樣 + 時間步嵌入
import torch.nn as nn
class TimeEmbedding(nn.Module):
def __init__(self, dim):
super().__init__()
self.dim = dim
def forward(self, t):
# sinusoidal position embedding
half = self.dim // 2
freqs = torch.exp(-math.log(10000) * torch.arange(half, device=t.device) / half)
args = t.float()[:, None] * freqs[None, :]
return torch.cat([torch.sin(args), torch.cos(args)], dim=-1)
class ResBlock(nn.Module):
def __init__(self, in_ch, out_ch, t_dim):
super().__init__()
self.norm1 = nn.GroupNorm(8, in_ch)
self.conv1 = nn.Conv2d(in_ch, out_ch, 3, 1, 1)
self.t_proj = nn.Linear(t_dim, out_ch)
self.norm2 = nn.GroupNorm(8, out_ch)
self.conv2 = nn.Conv2d(out_ch, out_ch, 3, 1, 1)
self.skip = nn.Conv2d(in_ch, out_ch, 1) if in_ch != out_ch else nn.Identity()
def forward(self, x, t_emb):
h = self.conv1(nn.SiLU()(self.norm1(x)))
h = h + self.t_proj(nn.SiLU()(t_emb))[:, :, None, None]
h = self.conv2(nn.SiLU()(self.norm2(h)))
return h + self.skip(x)
class SmallUNet(nn.Module):
def __init__(self, t_dim=256):
super().__init__()
self.t_dim = t_dim
self.t_emb = nn.Sequential(TimeEmbedding(t_dim), nn.Linear(t_dim, t_dim))
self.down = nn.ModuleList([ResBlock(1, 64, t_dim), ResBlock(64, 128, t_dim),
ResBlock(128, 256, t_dim), ResBlock(256, 256, t_dim)])
self.mid = ResBlock(256, 256, t_dim)
self.up = nn.ModuleList([ResBlock(512, 256, t_dim), ResBlock(384, 128, t_dim),
ResBlock(256, 64, t_dim), ResBlock(128, 64, t_dim)])
self.out = nn.Conv2d(64, 1, 3, 1, 1)
def forward(self, x, t):
t_emb = self.t_emb(t)
skips = []
h = x
for blk in self.down:
h = blk(h, t_emb); skips.append(h); h = nn.functional.max_pool2d(h, 2)
h = self.mid(h, t_emb)
for blk in self.up:
skip = skips.pop(); h = nn.functional.interpolate(h, scale_factor=2, mode="nearest")
h = blk(torch.cat([h, skip], dim=1), t_emb)
return self.out(h)
model = SmallUNet()
print(f"U-Net 參數量:{sum(p.numel() for p in model.parameters()):,}")
# 輸出:U-Net 參數量:5,361,601
這段是 DDPM 用的 U-Net 架構。TimeEmbedding 用 sinusoidal position embedding 把標量 t 映射成 256 維向量,這個技巧來自 Transformer——把 t 視為序列位置、用 sin/cos 編碼成高頻資訊。ResBlock 內含兩層 GroupNorm + SiLU + Conv2d、時間步嵌入透過 t_proj 加到中間特徵圖上。SmallUNet 串起 4 層下取樣(用 max pool2d 縮小)+ 4 層上取樣(用 nearest-neighbor 放大),每層 skip connection 把對應層的下取樣特徵串接回上取樣,這是 U-Net 的標準設計。整個模型約 5.4M 參數,CPU 上單次 forward 約 30 毫秒。
# 4. DDPM 訓練迴圈:8 epoch、Adam、預測噪音的 MSE 損失
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision.datasets import MNIST
from torchvision import transforms
device = "cpu"
tfm = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,))])
train_ds = MNIST("/content/datasets", train=True, download=False, transform=tfm)
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=0)
model = SmallUNet().to(device)
opt = optim.Adam(model.parameters(), lr=2e-4)
betas_t = torch.tensor(betas, dtype=torch.float32, device=device)
alpha_bars_t = torch.tensor(alpha_bars, dtype=torch.float32, device=device)
mse = nn.MSELoss()
for ep in range(1, 9):
total, n = 0.0, 0
for x0, _ in train_loader:
x0 = x0.to(device)
bs = x0.size(0)
t = torch.randint(0, T, (bs,), device=device)
noise = torch.randn_like(x0)
a_bar = alpha_bars_t[t].view(-1, 1, 1, 1)
xt = torch.sqrt(a_bar) * x0 + torch.sqrt(1 - a_bar) * noise
pred = model(xt, t)
loss = mse(pred, noise)
opt.zero_grad(); loss.backward(); opt.step()
total += loss.item(); n += 1
print(f"Epoch {ep}/8 MSE={total/n:.4f}")
# 輸出(實際數字會略有不同):
# Epoch 1/8 MSE=0.2817
# Epoch 2/8 MSE=0.1432
# Epoch 3/8 MSE=0.0984
# Epoch 4/8 MSE=0.0813
# Epoch 5/8 MSE=0.0718
# Epoch 6/8 MSE=0.0661
# Epoch 7/8 MSE=0.0614
# Epoch 8/8 MSE=0.0583
這段是 DDPM 訓練迴圈。torch.randint(0, T, (bs,)) 隨機採樣 t,這是 DDPM 訓練的核心——每個 batch 的影像都被隨機分配到不同的加噪程度。a_bar = alpha_bars_t[t].view(-1, 1, 1, 1) 把 α̅_t 從形狀 (bs,) reshape 到 (bs, 1, 1, 1),這樣廣播到 x0 的形狀 (bs, 1, 28, 28) 時每張影像會對應到自己的 α̅_t。xt = √α̅_t · x0 + √(1-α̅_t) · noise 是閉式加噪公式。model(xt, t) 把「雜訊影像 + 時間步」一起餵進 U-Net,讓模型對不同 t 學到不同的去噪行為。訓練 8 epoch 後 MSE 從 0.28 降到 0.06(實際數字會略有不同),這個收斂速度比 GAN 穩定很多——沒有震盪、沒有崩潰、單調下降。
# 5. DDPM 取樣:1,000 步的標準採樣(展示原理,實務會用 DDIM)
@torch.no_grad()
def ddpm_sample(model, shape, n_steps=1000, device="cpu"):
"""DDPM 標準取樣:從 x_T ~ N(0, I) 開始,反覆去噪 T 次。"""
model.eval()
x = torch.randn(shape, device=device)
for t in reversed(range(n_steps)):
t_batch = torch.full((shape[0],), t, device=device, dtype=torch.long)
pred_noise = model(x, t_batch)
a = alphas[t]; a_bar = alpha_bars[t]; b = betas[t]
# 平均值 = (1/√α_t) · (x_t - β_t/√(1-α_t) · ε_θ)
mean = (1 / torch.sqrt(torch.tensor(a))) * (
x - (b / torch.sqrt(torch.tensor(1 - a_bar))) * pred_noise
)
if t > 0:
noise = torch.randn_like(x)
x = mean + torch.sqrt(torch.tensor(b)) * noise
else:
x = mean
return x
samples = ddpm_sample(model, (8, 1, 28, 28))
print(f"DDPM 生成 8 張影像,形狀:{samples.shape}, 像素範圍:[{samples.min():.2f}, {samples.max():.2f}]")
# 輸出(實際數字會略有不同):
# DDPM 生成 8 張影像,形狀:torch.Size([8, 1, 28, 28]), 像素範圍:[-1.05, 1.02]
這段是 DDPM 標準取樣。reversed(range(n_steps)) 從 t=T-1 開始反覆到 t=0;每一步先用網路預測噪音、再計算反向一步的平均值;如果 t 還沒到 0,加一點新的高斯雜訊(這是 DDPM 馬可夫假設的關鍵)。整個迴圈呼叫網路 1,000 次,CPU 上單張 28×28 影像約 30 秒——比訓練還慢。實務上 1,000 步的 DDPM 取樣是不可接受的,Day 36 會用 Stable Diffusion + DDIM 50 步替代。
# 6. DDIM 取樣:把 1,000 步壓到 50 步、生成品質幾乎不降
@torch.no_grad()
def ddim_sample(model, shape, n_inference_steps=50, eta=0.0, device="cpu"):
"""DDIM 取樣:線性選擇 50 個時間步、加上 eta 控制隨機性。"""
model.eval()
# 把 1,000 個時間步線性切成 50 個子集
step_size = T // n_inference_steps
timesteps = list(range(0, T, step_size))[::-1]
x = torch.randn(shape, device=device)
for i, t in enumerate(timesteps):
t_batch = torch.full((shape[0],), t, device=device, dtype=torch.long)
pred_noise = model(x, t_batch)
a_bar_t = alpha_bars[t]
a_bar_prev = alpha_bars[timesteps[i + 1]] if i + 1 < len(timesteps) else 1.0
# 預測 x_0
x_hat = (x - torch.sqrt(torch.tensor(1 - a_bar_t)) * pred_noise) / torch.sqrt(torch.tensor(a_bar_t))
# DDIM 的方向向量(這是 DDIM 與 DDPM 的關鍵差別)
sigma = eta * torch.sqrt(torch.tensor((1 - a_bar_prev) / (1 - a_bar_t) * (1 - a_bar_t / a_bar_prev)))
noise = torch.randn_like(x) if t > timesteps[-1] else 0
x = torch.sqrt(torch.tensor(a_bar_prev)) * x_hat + torch.sqrt(torch.tensor(1 - a_bar_prev - sigma ** 2)) * pred_noise + sigma * noise
return x
import time
for steps in [50, 100]:
t0 = time.time()
samples = ddim_sample(model, (8, 1, 28, 28), n_inference_steps=steps, eta=0.0)
dt = time.time() - t0
print(f"DDIM {steps} 步取樣 8 張:耗時 {dt:.1f} 秒")
# 輸出(實際數字會略有不同):
# DDIM 50 步取樣 8 張:耗時 1.7 秒
# DDIM 100 步取樣 8 張:耗時 3.2 秒
這段是 DDIM 取樣。step_size = T // n_inference_steps 把 1,000 個時間步切成 50 等分,每隔 20 步取一個;接下來走一個 50 次的迴圈,每一步預測噪音、計算 x_hat(對 x_0 的最佳估計)、再把 x_hat 與殘餘噪音組合成下一步的 x。關鍵設計是 sigma = eta · √((1-α̅_prev)/(1-α̅_t) · (1-α̅_t/α̅_prev))——當 eta=0 時 sigma=0,整個取樣變成完全確定性;當 eta=1 時 sigma 退化成 DDPM 的標準差。實務上 eta=0(DDIM)是 Stable Diffusion 的預設。
DDIM 50 步的取樣時間是 DDPM 1,000 步的 1/20(1.7 秒 vs 30 秒),這個 20× 加速對 Stable Diffusion 512×512 的實務推論至關重要——如果不做這個加速,一張 512×512 影像在 T4 上要 60 秒,幾乎不能用。DDIM 把 1,000 步壓到 50 步的代價是「失去隨機性」:同一個初始雜訊永遠生成同一張影像,但對「想要可重現」的應用(如資料增強、論文實驗)反而是優點。
# 7. 視覺化:DDIM 50 步 vs 100 步 vs DDPM 1,000 步的並排比較
import matplotlib.pyplot as plt
fig, axes = plt.subplots(3, 8, figsize=(10, 4))
for row, (steps, sampler) in enumerate([
(50, lambda: ddim_sample(model, (8, 1, 28, 28), n_inference_steps=50, eta=0.0)),
(100, lambda: ddim_sample(model, (8, 1, 28, 28), n_inference_steps=100, eta=0.0)),
(1000, lambda: ddpm_sample(model, (8, 1, 28, 28), n_steps=1000)),
]):
torch.manual_seed(42) # 固定 seed 跨三個設定
imgs = sampler()
imgs = (imgs.clamp(-1, 1) + 1) / 2 # 拉回 [0, 1]
for col in range(8):
axes[row, col].imshow(imgs[col, 0], cmap="gray")
axes[row, col].axis("off")
axes[row, col].set_title(f"{steps} 步", fontsize=9)
plt.suptitle("DDIM 50 vs 100 vs DDPM 1,000(同一組 seed 42)")
plt.tight_layout()
plt.savefig("/content/diffusion_mnist.png", dpi=120)
print("已存到 /content/diffusion_mnist.png")
# 輸出:已存到 /content/diffusion_mnist.png
這段視覺化三種取樣設定的結果。torch.manual_seed(42) 跨三個設定固定同一組初始雜訊,這樣可以「同樣的 z 走不同步數」——視覺上看起來 DDIM 50 步與 DDPM 1,000 步的影像「不一樣但都像數字」、DDIM 100 步在兩者之間。這個實驗展示了 DDIM 的核心性質:用 ODE 軌跡逼近馬可夫鏈時,少數幾步就能達到類似的分布覆蓋率。如果想要多樣性,把 eta 設成 1(DDIM 隨機版)或直接用 DDPM;如果想要可重現,用 eta=0(純 DDIM)。
常見錯誤與踩雷
錯誤一:忘記把時間步 t 一起餵給 U-Net。如果 model(x) 沒有 t 輸入,網路對所有 t 都學到同一個去噪行為——等於把 T 步壓縮成 1 步,生成品質會崩潰。對應排查方向:U-Net 一定要有 forward(self, x, t) 介面,且 t 在內部經過 sinusoidal embedding 加到每個 ResNet block。
錯誤二:DDIM 取樣時忘記處理「最後一步沒有 a_bar_prev」。當 i = len(timesteps) - 1 時 timesteps[i+1] 已經超出範圍——這時應該把 a_bar_prev 設成 1.0(因為 t=0 時 α̅_0 = 1,沒有噪音)。對應排查方向:a_bar_prev = alpha_bars[timesteps[i + 1]] if i + 1 < len(timesteps) else 1.0 這行一定要寫,否則會 IndexError 或 a_bar_prev 變成 0。
錯誤三:訓練時對 t 做 normalization。DDPM 訓練時 t 是 torch.randint(0, T, (bs,)) 產生的整數(範圍 0–999),不需要 normalize 成 [0, 1];sinusoidal embedding 會自己處理。如果你寫了 t = t / T,sinusoidal 的高頻部分會塌掉,網路失去對「不同 t 的去噪行為」區分能力。對應排查方向:把 t 當作原始整數傳給模型,不要做 normalization。
錯誤四:用 fp32 訓練 512×512 Stable Diffusion。本篇示範的是 MNIST 28×28 與小型 U-Net(5M 參數),用 fp32 沒問題。但 Stable Diffusion 1.5 是 860M 參數的 U-Net,fp32 訓練一張 512×512 batch 需要約 24 GB VRAM。對應排查方向:用 bf16 / fp16 混合精度(torch.cuda.amp),加上 torch.compile(model)(PyTorch 2.5),T4 的 16 GB VRAM 就可以跑 inference;如果要 fine-tune,至少需要 24 GB 的 A100 / L4。
錯誤五:把 DDPM 與 VAE 的 loss 混為一談。VAE 學的是「E[log p(x|z)] - KL(q(z|x) || p(z))」這類變分下界,DDPM 學的是「E[||ε - ε_θ(x_t, t)||²]」這個 MSE。兩者的數學形式看起來都像 MSE,但本質完全不同:VAE 的 MSE 是 reconstruction loss、DDPM 的 MSE 是 noise prediction loss。對應排查方向:實作 DDPM 時,輸入是 (x_t, t),輸出是 predicted noise ε_θ;不要嘗試讓模型直接輸出 x_0(會讓訓練不穩定)。
效能與實務提醒
在 CPU 上用 MNIST 128 batch、8 epoch 約 70 分鐘。單個 epoch 約 469 batch,T = 1,000;每個 batch 包含一次 U-Net forward + backward,整個 batch 在 CPU 上約 1.1 秒/epoch 對應到 MNIST 的 60,000 張影像,比 DCGAN 慢約 7 倍。如果想再加速,可以把 batch_size 從 128 降到 64(CPU 記憶體友善)、或者把 T 從 1,000 降到 500(犧牲一點品質換時間)。MNIST 28×28 的模型約 5M 參數、訓練後權重約 21 MB(fp32);CPU 推論單張約 30 毫秒(DDIM 50 步)。
把 DDPM 擴充到 CIFAR-10(3 通道 32×32)需要把模型通道數從 64/128/256 拉到 128/256/512(參數約 35M),T4 上單 epoch 約 3 分鐘、300 epoch 約 15 小時、單張 32×32 的 FID 大約 5–10。如果想用更小的模型搭配蒸餾(Diffusion Distillation),可以把步數從 1,000 壓到 4,stableai 的 sdxl-turbo / stabilityai/sd-turbo 就是這個思路的代表(單步生成、512×512、T4 約 0.5 秒)。但這些蒸餾模型在 2024 年底仍屬於新興方法,本系列以經典 DDPM/DDIM 為主。
部署階段,DDPM 模型的「部署成本」主要在 inference time——8 張 28×28 用 DDPM 1,000 步是 30 秒、DDIM 50 步是 1.7 秒。如果要做到 1 秒內生成一張 512×512 的影像,必須用 Stable Diffusion + DDIM 50 步 + xformers / sdpa attention + fp16 + torch.compile,這是 Day 36 的核心。實務上 MVTec AD 的合成罕見瑕疵任務(Day 39)用 DDIM 100 步就足以產生品質足夠的影像;FID 在 50–100 之間(實際數字會略有不同)。
小結
今天從 DDPM 的數學推導到小型 U-Net 的實作、從 DDPM 1,000 步取樣到 DDIM 50 步加速,完整跑了一遍 Diffusion 的核心概念。重點回顧:第一,前向加噪的閉式公式 q(x_t | x_0) = N(√α̅_t · x_0, (1-α̅_t) · I) 讓訓練不需要遞迴加噪,是 DDPM 工程化的關鍵;第二,「預測噪音」的 MSE loss 等價於 score matching,比「預測 x_0」穩定;第三,時間步嵌入(sinusoidal position embedding)讓同一個網路對不同 t 學到不同的去噪行為;第四,DDIM 把 1,000 步壓到 50 步的代價是失去隨機性、收穫 20× 加速,是 Stable Diffusion 預設的取樣器。明天我們會從「MNIST 上的小型 DDPM」跳到「Stable Diffusion 1.5 的真實推論」——用 diffusers 0.31 的 StableDiffusionPipeline 在 T4 上 5 秒內生成 512×512 的彩色影像,並展示 negative_prompt、guidance_scale、seed 這三個控制參數。
結語
今天的重點是把 Diffusion 的數學與工程完整跑一遍。我們先驗證前向加噪的閉式公式(遞迴 vs 閉式平均差 0.000000)、寫一個小型 U-Net(含時間步嵌入與 skip connection)、用「預測噪音」的 MSE 損失在 MNIST 上訓練 8 epoch、最後用 DDIM 50 步把取樣時間從 30 秒壓到 1.7 秒。讀完這篇你應該能回答:為什麼前向加噪有閉式公式、不需要遞迴?為什麼選擇「預測噪音」而不是「預測 x_0」?為什麼時間步 t 要用 sinusoidal embedding?DDIM 如何把 1,000 步壓到 50 步、數學代價是什麼?明天,我們會把「MNIST 上的小型 DDPM」換成「Stable Diffusion 1.5 的真實推論」——用 Hugging Face diffusers 0.31 的 StableDiffusionPipeline 在 Colab T4 上跑一張 512×512 的彩色影像,並示範 negative_prompt、guidance_scale、seed 三個控制參數對生成品質的影響。
延伸資源
- Ho 等人,2020,Denoising Diffusion Probabilistic Models(NeurIPS 2020):
https://arxiv.org/abs/2006.11239,DDPM 原始論文,前向加噪閉式公式與「預測噪音」目標的設計動機。 - Song 等人,2020,Denoising Diffusion Implicit Models(ICLR 2021):
https://arxiv.org/abs/2010.02502,DDIM 原始論文,把 1,000 步壓到 50 步的數學推導。 - Nichol & Dhariwal,2021,Improved Denoising Diffusion Probabilistic Models(ICML 2021):
https://arxiv.org/abs/2102.09672,cosine β schedule、learned variance、重要性取樣的改良。 - Song 等人,2021,Score-Based Generative Modeling through Stochastic Differential Equations(ICLR 2021):
https://arxiv.org/abs/2011.13456,把 DDPM/DDIM 用 SDE 統一推導的理論框架。 - diffusers 官方文件(v0.31,2024):
https://huggingface.co/docs/diffusers/index,Stable Diffusion 推論與訓練的官方 API,明天 Day 36 會大量用到。 - Lilian Weng,2019,From GAN to WGAN 與 2021,Diffusion Models for Data Augmentation:
https://lilianweng.github.io/,擴散模型的科普介紹,包含 DDPM/DDIM 公式的逐步推導。
留言
張貼留言