2021:噪聲中重建世界——擴散模型的 SDE 探案#
「先把蒙娜麗莎撕成噪聲,再把噪聲拼回蒙娜麗莎。」——生成模型審訊室的白板標語。
| 案件檔案 | 內容 |
|---|---|
| 案號 | SGM-2021-005 |
| 案發時間 | 2021 年 ICLR/NeurIPS 立案 |
| 案發地點 | 影像、語音與科學數據的生成現場 |
| 報案人 | 被 GAN 不穩定折磨的生成模型工程師 |
| 偵探 | Yang Song、Jascha Sohl-Dickstein、Diederik Kingma 等 |
| 失蹤者 | 被加噪前向過程毀掉的數據分佈 |
| 歸來者 | Anderson(1982)逆時 SDE |
| 兇器(實為鑰匙) | 分數網路 \(s_\theta\) 與去噪分數匹配損失 |
| 狀態 | 已結案,生成式 AI 典範轉移 |
案發現場#
2021 年之前,生成模型分贓為兩派,互不服氣。
一派是 SMLD(分數匹配 Langevin 動力學):用多個噪聲尺度估計分數 \(\nabla_x \log p(x)\) ,再用 Langevin 採樣一格一格爬回數據流形。
另一派是 DDPM(去噪擴散概率模型):設定一條前向加噪馬可夫鏈,再學一條反向去噪鏈,一步步把噪聲洗掉。
兩派在樣本品質上都打敗了 GAN,卻各有怪癖:SMLD 的噪聲尺度要手工排,DDPM 的離散步數要上千步,理論上也看不出它們是親戚。
案發現場的證物有兩件:SMLD 的退火 Langevin 更新,與 DDPM 的反向高斯核。
偵探 Song 盯著它們看了很久,突然在筆記本上寫道:「它們都是同一個 SDE 的不同離散化。」
與此同時,檔案室裡一份 1982 年的舊卷宗被人調出——Anderson 的逆時擴散理論。
Anderson 早在四十年前就證明:只要知道分數 \(\nabla_x \log p_t(x)\) ,任何前向擴散都可以倒著播回去。
失蹤多年的祖師爺,就這樣被一樁生成模型的案子請回了現場。
偵查過程#
偵探的第一步是統一前向過程:把一切加噪寫成同一個 Itô SDE。
其中 \(x = x(t)\) 是狀態, \(f\) 是漂移係數, \(g\) 是擴散係數, \(w\) 是標準 Wiener 過程,而 \(t \in [0, T]\) 從數據走向噪聲。
SMLD 對應方差爆炸(VE)版本:
DDPM 則對應方差保持(VP)版本:
其中 \(\sigma(t)\) 與 \(\beta(t)\) 分別是噪聲表,兩者在連續極限下只是 \(f\) 與 \(g\) 的不同選法。
下表是兩大門派的指紋比對:
| 門派 | 離散前向 | 連續極限( \(f\) , \(g\) ) | 採樣方式 |
|---|---|---|---|
| SMLD | 多尺度加噪 \(x + \sigma_i z\) | \(f = 0\) , \(g =\) VE 係數 | 退火 Langevin |
| DDPM | \(x_t = \sqrt{\alpha_t} x_{t-1} + \sqrt{1 - \alpha_t} z\) | \(f = -\beta x / 2\) , \(g = \sqrt{\beta}\) | 反向高斯鏈 |
| 統一 SDE | 同一條 \(dx = f dt + g dw\) | 同左 | 逆時 SDE+校正子 |
第二步是請出 Anderson:一旦前向是 SDE,逆向必為 SDE。
逆時 SDE 的形式如下,其中 \(\bar{w}\) 是逆時 Wiener 過程, \(s_\theta\) 是學到的分數:
嚴格而言,真正的逆時漂移應為 \(f(x,t) - g(t)^2 \nabla_x \log p_t(x)\) ,偵探用神經網路 \(s_\theta(x,t) \approx \nabla_x \log p_t(x)\) 代入。
這條公式是本案的定罪核心:生成,就是把前向 SDE 倒著解出來。
Anderson 在 1982 年寫下它時,世上還沒有深度學習能估計高維分數;四十年後,分數匹配補上了最後一塊拼圖。
第三步是訓練分數網路:去噪分數匹配(DSM)。
對連續時間加權,損失函數為:
其中 \(x_0 \sim p_{\mathrm{data}}\) , \(x_t \sim p_{0t}(\cdot | x_0)\) 是前向轉移核, \(\lambda(t) > 0\) 是時間權重。
由於前向核多為高斯,目標 \(\nabla \log p_{0t}\) 有解析式,訓練完全不需要知道真實分數,這正是 DSM 的狡猾之處。
偵探們還證明了概率流 ODE 的存在:去掉逆時 SDE 的布朗項,可得一條與之同邊際的確定性流,
它可以用常微分方程求解器快速採樣,並能精確計算似然。
第四步是收網比對:統一框架下的新戰績。
| 任務 | 舊方法步數 | SDE 框架步數/求解器 | 品質變化 |
|---|---|---|---|
| CIFAR-10 生成 | DDPM \(1000\) 步 | 逆時 SDE+PC 採樣 | FID 大幅下降 |
| 高分辨率影像 | SMLD 多尺度手調 | VE/VP+自適應校正 | 紋理更銳利 |
| 似然計算 | 變分下界鬆弛 | 概率流 ODE 精確積分 | 可比自迴歸模型 |
PC(Predictor–Corrector)採樣是點睛之筆:每用逆時 SDE 預測一步,就用 Langevin 校正一步,SMLD 與 DDPM 在此握手言和。
結案報告#
2021 年之後,教科書改寫了生成模型的章節標題:不再是「SMLD 與 DDPM」,而是「基於 SDE 的生成模型」。
本案的遺產有三件。
第一,理論統一:兩個看似無關的模型被證明是同一前向 SDE 的不同離散化,爭論多年的門派之爭一夜終結。
第二,祖師爺歸位:Anderson(1982)的逆時公式從控制與濾波的冷宮走出,成為 Stable Diffusion、語音合成與分子生成的數學地基。
第三,工具箱爆炸:逆時 SDE、概率流 ODE、PC 採樣、DSM 損失構成四件套,後續的 DDIM、Score Distillation、流匹配全是它們的變體。
探長 Song 在結案陳詞中寫道:「我們沒有發明新的噪聲,我們只是發現所有的噪聲本來就是同一條河。」
此案之後,隨機微積分從華爾街與實驗室走進了每一台手機的相機:你按下的每一次「AI 修圖」,都是一次逆時擴散。
而 Anderson 的舊卷宗,則被複印無數份,掛在每個生成實驗室的牆上。
證據與工具#
核心證物一:前向 SDE \(dx = f dt + g dw\) ,VE 與 VP 只是 \(f\) 與 \(g\) 的不同取值。
核心證物二:逆時 SDE \(dx = [f - g^2 s_\theta] dt + g d\bar{w}\) ,Anderson 1982 的歸來,分數即鑰匙。
核心證物三:DSM 損失 \(L(\theta)\) 與概率流 ODE,前者負責訓練,後者負責快速採樣與似然計算。
| 工具 | 用途 | 出處 |
|---|---|---|
| Anderson 逆時公式 | 從分數重建逆向漂移 | Anderson 1982 |
| SMLD | 多尺度分數估計先驅 | Song–Ermon 2019 |
| DDPM | 離散去噪鏈先驅 | Ho 等 2020 |
| 去噪分數匹配 | 無需真實分數的訓練目標 | Vincent 2011,Song 等 2021 |
| PC 採樣與概率流 | 高品質與快速生成的雙引擎 | Song 等 2021 |
辦案守則:先寫出前向 SDE,再請 Anderson 倒帶;分數學不準,一切免談;採樣時記得預測加校正,確定與隨機兩條腿走路。
補充:程式實作#
對應程式#
本節對應程式為 2021-score_diffusion_1d.py ,以一維雙峰分佈示範去噪分數匹配加退火 Langevin 生成。
理論呼應#
本文前向過程為 \(dx = f(x,t)dt + g(t)dw\) 形式,逆時生成靠 \(dx = [f - g^2 s_{\theta}]dt + g d\bar{w}\) 公式。 程式以 RBF 分數網路 \(s(x) = \Phi(x)w\) 逼近真實分數,呼應去噪分數匹配損失 \(L(\theta)\) 精神。 由大到小的噪聲位準退火正是 SMLD 與 DDPM 在統一 SDE 下的握手,取樣器即簡化版 PC 流程。 雙峰比例與峰谷比檢驗相當於一維版的 FID 量測,確認生成分佈確實回到資料流形。
執行方式#
在 隨機微積分 目錄下執行 python3 _code/2021-score_diffusion_1d.py 即可重現結果,只需 numpy。
實測輸出#
frac_pos = 0.4842 (err 0.0158, tol 0.1)
mean_neg = -1.9938 (target -2, tol 0.2)
mean_pos = 1.9970 (target +2, tol 0.2)
peak_l = 544, peak_r = 530, valley = 1, pv_ratio = 530.000 (tol > 2)
VERIFY frac_pos=0.4842 mean_neg=-1.9938 mean_pos=1.9970 pv=530.000
雙峰比例誤差僅 \(0.0158\) 且峰谷比高達 \(530.000\) ,兩峰均值亦緊貼 \(\pm 2\) 目標。
讀者實驗#
將每位準步數 STEPS = 100 改為 STEPS = 30 後重跑,觀察峰谷比 \(530.000\) 是否依然遠高於門檻。
完整程式如下:
"""2021 一維雙峰擴散生成:去噪分數匹配 + 多退火 Langevin。
對應 wiki:Song–Ermon (2019) NCSN 去噪分數匹配、Song et al. (2021)
Score-based SDE / 退火 Langevin 動力學。
資料:0.5 N(-2, 0.4²) + 0.5 N(+2, 0.4²)。
模型:score s(x) = Φ(x) w,Φ 為 K=40 RBF 特徵
φ_j = exp(-(x-c_j)²/(2h²)),c 均勻分佈 [-4,4],h=0.3,
外加線性項 x 與常數項 1(共 K+2 維)以修正 RBF 在尾部→0、
導致 Langevin 缺乏內拉力而樣本外偏的問題;
以 numpy 手刻去噪分數匹配(DSM)閉式 ridge 解學各噪聲位準的 score:
min_w E‖F(x̃)w + (x̃-x)/σ²‖²,x̃ = x + σε(RBF 懲罰 1.0,線性/常數 0.1)。
採樣:噪聲位準 [1.0, 0.7, 0.4, 0.2, 0.1] 由大到小退火,
每位準 Langevin 100 步,步長 α=0.1σ²(score 截幅 ±20,狀態截幅 ±5),5000 點。
驗證:雙峰比例 |P(x>0)-0.5| < 0.1;兩峰均值 ≈ ±2 誤差 < 0.2;
直方圖峰谷比 > 2(峰窗 ±[1.25,2.75] 對中央谷)。
只用 numpy,固定種子,不畫圖。
"""
import numpy as np
rng = np.random.default_rng(5)
# ---- 資料 ----
N_TRAIN = 10000
SIG_DATA = 0.4
comp = (rng.random(N_TRAIN) < 0.5).astype(float) # 0 -> -2, 1 -> +2
x_data = np.where(comp < 0.5, -2.0, 2.0) + SIG_DATA * rng.standard_normal(N_TRAIN)
# ---- RBF + 線性/常數 ----
K = 40
centers = np.linspace(-4.0, 4.0, K)
h = 0.3
def feat_mat(x):
d = x[:, None] - centers[None, :]
Phi = np.exp(-0.5 * (d / h) ** 2)
return np.column_stack([Phi, x, np.ones_like(x)])
# ---- 各噪聲位準 DSM ridge ----
sigmas = [1.0, 0.7, 0.4, 0.2, 0.1]
LAM = 1.0
Ws = []
for sg in sigmas:
eps = rng.standard_normal(N_TRAIN)
xt = x_data + sg * eps
F = feat_mat(xt) # (N, K+2)
tgt = -eps / sg # -(x̃-x)/σ²
P = np.eye(K + 2) * LAM
P[K, K] = 0.1
P[K + 1, K + 1] = 0.1
w = np.linalg.solve(F.T @ F + P, F.T @ tgt)
Ws.append(w)
# ---- 退火 Langevin 採樣 ----
M_S = 5000
x = 2.0 * rng.standard_normal(M_S) # N(0, 4) 初始化
STEPS = 100
for sg, w in zip(sigmas, Ws):
alpha = 0.1 * sg ** 2
sqa = np.sqrt(alpha)
for _ in range(STEPS):
s = feat_mat(x) @ w
s = np.clip(s, -20.0, 20.0)
x = x + 0.5 * alpha * s + sqa * rng.standard_normal(M_S)
x = np.clip(x, -5.0, 5.0)
frac_pos = float(np.mean(x > 0))
ratio_err = abs(frac_pos - 0.5)
pos = x[x > 0]
neg = x[x <= 0]
mean_pos = float(np.mean(pos)) if pos.size else np.nan
mean_neg = float(np.mean(neg)) if neg.size else np.nan
# 峰谷比:32 bins 於 [-4, 4]
counts, edges = np.histogram(x, bins=32, range=(-4.0, 4.0))
mid = (edges[:-1] + edges[1:]) / 2
peak_l = float(np.max(counts[(mid >= -2.75) & (mid <= -1.25)]))
peak_r = float(np.max(counts[(mid >= 1.25) & (mid <= 2.75)]))
valley = float(counts[np.argmin(np.abs(mid - 0.0))])
pv_ratio = float(min(peak_l, peak_r) / max(valley, 1.0))
print(f"frac_pos = {frac_pos:.4f} (err {ratio_err:.4f}, tol 0.1)")
print(f"mean_neg = {mean_neg:.4f} (target -2, tol 0.2)")
print(f"mean_pos = {mean_pos:.4f} (target +2, tol 0.2)")
print(f"peak_l = {peak_l:.0f}, peak_r = {peak_r:.0f}, valley = {valley:.0f}, pv_ratio = {pv_ratio:.3f} (tol > 2)")
print(f"VERIFY frac_pos={frac_pos:.4f} mean_neg={mean_neg:.4f} mean_pos={mean_pos:.4f} pv={pv_ratio:.3f}")
assert ratio_err < 0.1, f"雙峰比例偏差過大: {frac_pos}"
assert abs(mean_pos - 2.0) < 0.2, f"右峰均值偏差: {mean_pos}"
assert abs(mean_neg + 2.0) < 0.2, f"左峰均值偏差: {mean_neg}"
assert pv_ratio > 2.0, f"峰谷比不足: {pv_ratio}"