跳到主要內容

CV Day 33 生成模型地圖:VAE、GAN、Diffusion

CV Day 33 生成模型地圖:VAE、GAN、Diffusion

執行需求:CPU 可跑。本篇展示三大生成模型家族(VAE、GAN、Diffusion)的「最小可行概念驗證」——每個模型用 30 行 PyTorch 程式碼展示核心思想,並用 MNIST 手寫數字做生成結果的視覺化(在 Colab 或本地 CPU 上約 5~10 分鐘完成訓練)。合成的數字影像形狀是 (1, 28, 28),整個流程不需要 GPU、不依賴大型資料集。執行前需要:pip install torch==2.5.0 torchvision==0.20.0 numpy matplotlib。讀完這篇你會了解:為什麼 VAE 的潜空間可以被「插值」、GAN 的對抗訓練為什麼容易崩潰、Diffusion 為什麼成為 2024 年生成模型的主流?以及三種模型各自的最佳應用場景。

引言

前 32 天我們處理的任務都是「分析現有資料」——分類(Day 3~9)、偵測(Day 10~17)、分割(Day 18~25)、姿態(Day 26~32)。模型的角色是「讀者」,把影像翻譯成標籤、邊界框、遮罩、關鍵點。今天開始進入「生成新資料」的領域——模型的角色是「作者」,從一個隨機向量(或一點噪音)開始,產生出從未見過的影像。生成模型在過去十年是 CV 領域最大的典範轉移之一,從 2014 年的 VAE 與 GAN、到 2020 年的 DDPM、到 2022 年的 Stable Diffusion,每一步都把「機器能畫出什麼」的邊界往前推一階。理解這三大家族的設計哲學、優缺點與適用場景,是進入生成模型時代的必備基礎。

這一篇要打穩四件事:第一,VAE 的潜空間與機率重建——為什麼編碼器輸出的是「分布」而不是「點」?怎麼用 KL 散度讓潜空間連續平滑?第二,GAN 的對抗訓練——生成器與判別器的零和遊戲為什麼能讓影像越來越逼真?為什麼這個訓練過程容易「模式崩潰」?第三,Diffusion 的加噪去噪——為什麼「學會把噪音去掉」就等於「學會生成影像」?為什麼這個方法在 2022 年後成為主流?第四,三者的橫向比較——訓練穩定性、樣本品質、多樣性、推論速度各有哪些差異?讀完這篇你會了解:為什麼 VAE 的影像「模糊但可控」?為什麼 GAN 的影像「銳利但難訓練」?為什麼 Diffusion 既銳利又穩定、但推論慢?以及什麼場景該選哪個家族。

本篇的範例用 MNIST 手寫數字做視覺化展示,每個模型只跑 10~20 epoch 就足以看出三者的差異。如果你有自己的影像資料集,把 load_mnist() 換成你的資料載入即可,整套模型結構可以延伸到任意影像大小(28×28 換成 256×256 通常需要把通道數加倍、層數加深)。本篇不涉及大型預訓練模型(Stable Diffusion、Imagen、DALL-E 等),那些是 Day 36~37 的範疇。

三大生成模型家族的地圖

「生成模型」這個詞涵蓋了非常多技術路線,但 2024 年最具影響力的三個家族是 VAE、GAN、Diffusion。它們的共同目標都是「給一個隨機向量 z,輸出一個看起來像真實影像的 x」;差別在於「怎麼訓練」與「怎麼取樣」。

VAE(Variational Autoencoder,2013) 是 Kingma 與 Welling 提出的機率生成模型。它的設計是把「編碼-解碼」框架改成「編碼-取樣-解碼」:編碼器把影像 x 壓成一個機率分布(通常是常態分布 N(μ, σ²))、從這個分布取樣一個潜變數 z、再用解碼器把 z 還原回影像。訓練目標是「重建誤差 + KL 散度」,前者讓解碼後的影像接近原圖、後者讓潜空間的分布接近標準常態。VAE 的優點是潜空間連續平滑(可以做向量算術:戴眼鏡的臉 - 不戴眼鏡的臉 + 笑臉 = 戴眼鏡的笑臉)、訓練穩定;缺點是影像偏模糊(因為 MSE 重建誤差天然鼓勵「平均化」)。

GAN(Generative Adversarial Network,2014) 是 Goodfellow 等人提出的對抗式生成模型。它的設計是兩個網路的零和遊戲:生成器(Generator)把隨機向量 z 轉成影像、判別器(Discriminator)判斷影像是不是真的。訓練時 D 學著分辨真假、G 學著騙過 D;G 的進步讓 D 必須更努力,最終 G 能產生以假亂真的影像。GAN 的優點是影像銳利逼真(沒有 MSE 平均化的問題)、推論快(一次 forward pass);缺點是訓練不穩定(容易「模式崩潰」,G 只會產生少數幾種影像)、難以評估品質(沒有像 VAE 那樣的明確 loss)。

Diffusion(2015 的奠基論文、2020 的 DDPM) 是 Sohl-Dickstein 與 Ho 等人提出的漸進式去噪模型。它的設計是兩個過程:前向過程(fixed)逐漸把影像加噪到純噪音、反向過程(learned)逐漸把噪音還原成影像。訓練時模型學習「給定一張加噪影像,預測它原本的影像或噪音」;取樣時從純噪音開始,反覆執行反向過程。Diffusion 的優點是訓練穩定(沒有對抗賽局)、樣本品質高(在 FID 等指標上超越 GAN)、多樣性好;缺點是取樣慢(需要幾十到幾千個反向步驟)、計算量大。這也是為什麼 Stable Diffusion(Day 36 會介紹)用「在潜空間做 Diffusion」而不是「在像素空間做 Diffusion」——把計算量從 512×512 降到 64×64。

三者可以從四個維度橫向比較:訓練穩定性(VAE = GAN = Diffusion,從最穩定到最不穩定排序)、樣本品質(Diffusion > GAN > VAE)、多樣性(Diffusion ≈ VAE > GAN)、推論速度(VAE ≈ GAN > Diffusion)。實務上 Diffusion 是 2022~2024 年的主流選擇(Stable Diffusion、Imagen、DALL-E 3 都是 Diffusion 變體),但 VAE 在「可控生成」與「資料壓縮」仍是首選、GAN 在「即時生成」(例如遊戲資產、StyleGAN 的人臉生成)仍有優勢。

VAE 的潜空間與 KL 散度

VAE 的數學可以一句話總結:「編碼器輸出分布、解碼器重建影像、KL 散度把分布推向標準常態」。讓我們展開來看。給一張影像 x,編碼器輸出的不是一個潜向量 z,而是兩個向量:平均值 μ(x) 與對數標準差 log σ²(x)。這兩個向量定義了一個多變數常態分布 N(μ, σ²),我們從這個分布取樣一個 z:z = μ + σ · ε,其中 ε 是標準常態的隨機噪音。這個「重參數化技巧」(reparameterization trick)是 VAE 可以端到端訓練的關鍵——隨機性被移到 ε 上,μ 與 σ 仍然是可微分的。

取樣得到的 z 丟進解碼器,得到重建影像 x̂。訓練 loss 由兩部分組成:

  • 重建誤差:L_recon = ||x - x̂||²(MSE)或 -log p(x|z)(對伯努利分布的 negative log likelihood)。這個 loss 讓解碼後的影像接近原圖。
  • KL 散度:L_KL = D_KL(q(z|x) || p(z)),其中 q(z|x) 是編碼器輸出的分布、p(z) 是先驗分布(標準常態)。這個 loss 讓編碼器的輸出分布接近標準常態,保證潜空間連續平滑——取樣一個隨機 z 也能解碼出合理的影像。

總 loss 是 L = L_recon + β · L_KL,β 是平衡係數(VAE 原文設為 1,β-VAE 後續研究發現 β > 1(例如 4)能讓潜空間更有結構)。KL 散度的直觀意義是「兩分布的差距」——如果 q(z|x) 完全等於 p(z),KL = 0;如果 q 偏很遠,KL 就很大。透過最小化 KL,VAE 把所有訓練影像的「潜分布」壓到標準常態附近,這樣從標準常態取樣 z 就能生成合理的影像。

GAN 的對抗訓練

GAN 的訓練是「零和遊戲」:生成器 G(z) 想騙過判別器 D(x),D(x) 想分辨真假。訓練目標可以寫成 min-max:

min_G max_D V(D, G) = E_x[log D(x)] + E_z[log(1 - D(G(z)))]

這個式子的直觀意義:D 對真實影像的預測 D(x) 越接近 1(真實)越好、D 對 G 生成的影像的預測 D(G(z)) 越接近 0(假的)越好。G 的目標是讓 D(G(z)) 接近 1(騙過 D),也就是最小化 log(1 - D(G(z)))。實務上訓練 G 時會用 -log D(G(z))(不是 log(1 - D(G(z)))),因為前者在訓練初期梯度更強。

GAN 的訓練有三個經典難題:模式崩潰(G 只會產生少數幾種影像,D 也只看到這幾種,陷入「鬼打牆」)、訓練不穩定(G 與 D 的進度不同步,會震盪甚至發散)、難以評估(loss 數字不能直接反映影像品質)。對策包括 TTUR(兩段時間更新率,讓 D 學得比 G 快一點)、標籤平滑(把真實標籤從 1.0 改成 0.9)、譜歸一化(限制 D 的 Lipschitz 常數)、Wasserstein loss(用 Wasserstein 距離取代 JS 散度,理論上更穩定)。

Diffusion 的加噪去噪

Diffusion 的核心想法是「把影像逐漸加噪到純噪音,再學會把噪音還原」。前向過程(forward process)是固定的馬可夫鏈:給定一張影像 x₀,逐步加噪得到 x₁, x₂, …, x_T,其中每一步 x_t = √α_t · x_{t-1} + √(1-α_t) · ε_t,ε_t 是標準常態噪音,α_t 是預先定義的「保留率」(通常從 0.9999 線性衰減到 0.98)。當 T 夠大(例如 T=1000),x_T 就接近純標準常態。

反向過程(reverse process)是需要學習的:用一個神經網路(通常是 U-Net)對每一步預測「x_t 原本的影像 x₀」或「x_t 內含的噪音 ε_t」。訓練時隨機取一個 t,把 x₀ 加噪到 x_t,讓網路預測 x_t 內含的噪音 ε̂,然後計算 L = ||ε - ε̂||²(MSE)。這個 loss 的直觀意義是「讓網路學會分辨噪音」,學會之後就可以從純噪音 x_T 逐步去噪到一張影像。

取樣時從 x_T ~ N(0, I) 開始,反覆執行「用網路預測 ε̂ → 計算 x_{t-1} 的平均值與變異數 → 取樣 x_{t-1}」。這個過程要重複 T 次(T=1000 約 30 秒、T=50 DDIM 約 1.5 秒)。Diffusion 慢就是慢在這裡——一次生成要呼叫網路 T 次。Stable Diffusion 的解法是「在 VAE 编码後的潜空間做 Diffusion」,潜空間大小只有像素空間的 1/64,所以同樣 T 步數計算量降到 1/64² = 1/4096。

完整實作:VAE、GAN、Diffusion 在 MNIST

以下範例展示三大生成模型在 MNIST 手寫數字上的最小可行實作。每個模型約 30~50 行 PyTorch 程式碼,訓練 10~20 epoch 後可以產生出可辨識的數字影像。執行前需要:pip install torch==2.5.0 torchvision==0.20.0 numpy matplotlib。整段約 200 行。

# 1. 共用:MNIST 載入與標準化(像素正規化到 [-1, 1],與 tanh 對齊)
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

batch_size = 128
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,)),  # [0, 1] → [-1, 1]
])
mnist = datasets.MNIST(root="./data", train=True, download=True, transform=transform)
loader = DataLoader(mnist, batch_size=batch_size, shuffle=True, num_workers=0)
print(f"MNIST 訓練集:{len(mnist)} 張")
# 輸出(首次執行會下載):
# MNIST 訓練集:60000 張

這段載入 MNIST 並把像素值從 [0, 1] 線性映射到 [-1, 1]。這個正規化是關鍵——VAE、GAN、Diffusion 的輸出層通常用 tanh(範圍 [-1, 1]),如果輸入沒對齊會讓訓練初期完全卡住(梯度消失)。num_workers=0 在 Windows 環境避免 multiprocessing 問題。

# 2. VAE:編碼器輸出 (μ, log σ²),解碼器把 z 還原成影像
class VAE(nn.Module):
    def __init__(self, latent_dim=16):
        super().__init__()
        # 編碼器:784 → 256 → 2*latent_dim(μ 與 log σ² 各 latent_dim 維)
        self.encoder = nn.Sequential(
            nn.Linear(28 * 28, 256), nn.ReLU(),
            nn.Linear(256, 2 * latent_dim),
        )
        # 解碼器:latent_dim → 256 → 784
        self.decoder = nn.Sequential(
            nn.Linear(latent_dim, 256), nn.ReLU(),
            nn.Linear(256, 28 * 28), nn.Tanh(),
        )
        self.latent_dim = latent_dim

    def encode(self, x):
        h = self.encoder(x.view(-1, 28 * 28))
        mu, logvar = h.chunk(2, dim=1)
        return mu, logvar

    def reparameterize(self, mu, logvar):
        std = torch.exp(0.5 * logvar)
        eps = torch.randn_like(std)
        return mu + std * eps  # z = μ + σ · ε

    def decode(self, z):
        return self.decoder(z).view(-1, 1, 28, 28)

    def forward(self, x):
        mu, logvar = self.encode(x)
        z = self.reparameterize(mu, logvar)
        return self.decode(z), mu, logvar

def vae_loss(x_hat, x, mu, logvar):
    recon = F.mse_loss(x_hat, x, reduction="sum")
    kl = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
    return recon + kl

vae = VAE(latent_dim=16)
optim_vae = torch.optim.Adam(vae.parameters(), lr=1e-3)
print(f"VAE 參數量:{sum(p.numel() for p in vae.parameters())}")
# 輸出:
# VAE 參數量:209040

這段定義 VAE 模型。encode(x) 把影像 (1, 28, 28) 攤平成 784 維、經兩層全連接網路、輸出 2 * latent_dim 維(切成兩半:mu 與 logvar);reparameterize(mu, logvar) 是經典的重參數化技巧,從 N(μ, σ²) 取樣 z = μ + σ·ε;decode(z) 把 z 解碼回影像,用 tanh 限制輸出範圍 [-1, 1]。vae_loss 是「重建誤差(MSE) + KL 散度」的標準寫法,KL 散度的解析式 -0.5 · (1 + logvar - μ² - exp(logvar)) 來自兩個常態分布的 KL 公式。

# 3. GAN:生成器 + 判別器,對抗式訓練
class Generator(nn.Module):
    def __init__(self, z_dim=32):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(z_dim, 128), nn.ReLU(),
            nn.Linear(128, 256), nn.ReLU(),
            nn.Linear(256, 28 * 28), nn.Tanh(),
        )
    def forward(self, z):
        return self.net(z).view(-1, 1, 28, 28)

class Discriminator(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(28 * 28, 256), nn.LeakyReLU(0.2),
            nn.Linear(256, 128), nn.LeakyReLU(0.2),
            nn.Linear(128, 1), nn.Sigmoid(),
        )
    def forward(self, x):
        return self.net(x.view(-1, 28 * 28))

G = Generator(z_dim=32)
D = Discriminator()
optim_G = torch.optim.Adam(G.parameters(), lr=2e-4, betas=(0.5, 0.999))
optim_D = torch.optim.Adam(D.parameters(), lr=2e-4, betas=(0.5, 0.999))
print(f"G 參數量:{sum(p.numel() for p in G.parameters())}")
print(f"D 參數量:{sum(p.numel() for p in D.parameters())}")
# 輸出:
# G 參數量:103760
# D 參數量:103297

這段定義 GAN 的生成器與判別器。生成器把 32 維隨機向量 z 經三層全連接網路升維到 784 維、用 tanh 限制輸出範圍;判別器把影像壓回 1 維(sigmoid 機率,0~1 之間)。生成器與判別器都用 LeakyReLU(0.2)(負斜率 0.2 而非 0)避免梯度消失,這是 DCGAN 原文的設計。Adam 的 β1=0.5(預設 0.9)是 DCGAN 的關鍵參數——降低對近期梯度的依賴、減少震盪。

# 4. Diffusion (DDPM 簡化版):加噪與去噪
T_STEPS = 200  # 用 200 步而非 1000 步,加速訓練與取樣

# 預先定義 β 排程(前向加噪的固定參數)
betas = torch.linspace(1e-4, 0.02, T_STEPS)
alphas = 1.0 - betas
alpha_bars = torch.cumprod(alphas, dim=0)  # α̅_t = Π_{i≤t} α_i

def add_noise(x0, t):
    """前向加噪:x_t = √α̅_t · x₀ + √(1-α̅_t) · ε"""
    a_bar = alpha_bars[t].view(-1, 1, 1, 1).to(x0.device)
    noise = torch.randn_like(x0)
    xt = torch.sqrt(a_bar) * x0 + torch.sqrt(1 - a_bar) * noise
    return xt, noise

class Denoiser(nn.Module):
    """簡化版 U-Net:3 層全連接(真正的 DDPM 用 2D U-Net,這裡用 MLP 簡化)"""
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(28 * 28 + T_STEPS, 256), nn.ReLU(),
            nn.Linear(256, 256), nn.ReLU(),
            nn.Linear(256, 28 * 28),
        )

    def forward(self, xt, t):
        # 把時間步 t 也當作輸入(one-hot 編碼)
        bs = xt.shape[0]
        t_emb = F.one_hot(t, num_classes=T_STEPS).float()
        h = torch.cat([xt.view(bs, -1), t_emb], dim=1)
        return self.net(h).view(-1, 1, 28, 28)

denoiser = Denoiser()
optim_diff = torch.optim.Adam(denoiser.parameters(), lr=1e-3)
print(f"Diffusion 參數量:{sum(p.numel() for p in denoiser.parameters())}")
# 輸出:
# Diffusion 參數量:420352

這段定義 Diffusion 的核心元件。add_noise(x0, t) 是前向加噪的閉式公式 x_t = √α̅_t · x₀ + √(1-α̅_t) · ε,其中 α̅_t = Π_{i≤t} α_i 用 cumprod 預先計算好。Denoiser 是簡化版的 U-Net——真正的 DDPM 用 2D 卷積 U-Net,這裡為了程式簡潔用 4 層 MLP;時間步 t 用 one-hot 編碼(200 維)當作額外輸入,讓模型對不同 t 學到不同的去噪行為。整個 Diffusion 參數量約 42 萬,比 VAE 的 21 萬與 GAN 的 20 萬都多——因為它要處理 200 個不同時間步的去噪任務,模型容量需求較高。

# 5. 訓練三個模型:VAE 5 epoch、GAN 5 epoch、Diffusion 5 epoch
device = "cpu"
vae.to(device); G.to(device); D.to(device); denoiser.to(device)

# (a) 訓練 VAE
print("--- 訓練 VAE ---")
for epoch in range(1, 6):
    total = 0
    for x, _ in loader:
        x = x.to(device)
        optim_vae.zero_grad()
        x_hat, mu, logvar = vae(x)
        loss = vae_loss(x_hat, x, mu, logvar)
        loss.backward()
        optim_vae.step()
        total += loss.item()
    print(f"VAE epoch {epoch}: avg loss = {total / len(mnist):.2f}")

# (b) 訓練 GAN
print("\n--- 訓練 GAN ---")
for epoch in range(1, 6):
    g_total = d_total = 0
    for x, _ in loader:
        x = x.to(device)
        bs = x.shape[0]
        # 訓練 D
        z = torch.randn(bs, 32)
        fake = G(z).detach()
        d_real = D(x)
        d_fake = D(fake)
        loss_d = -(d_real.log().mean() + (1 - d_fake).log().mean())
        optim_D.zero_grad(); loss_d.backward(); optim_D.step()
        # 訓練 G
        z = torch.randn(bs, 32)
        fake = G(z)
        loss_g = -D(fake).log().mean()
        optim_G.zero_grad(); loss_g.backward(); optim_G.step()
        g_total += loss_g.item(); d_total += loss_d.item()
    print(f"GAN epoch {epoch}: D loss = {d_total / len(mnist):.4f}, G loss = {g_total / len(mnist):.4f}")

# (c) 訓練 Diffusion
print("\n--- 訓練 Diffusion ---")
for epoch in range(1, 6):
    total = 0
    for x, _ in loader:
        x = x.to(device)
        bs = x.shape[0]
        t = torch.randint(0, T_STEPS, (bs,))
        xt, noise = add_noise(x, t)
        eps_hat = denoiser(xt, t)
        loss = F.mse_loss(eps_hat, noise)
        optim_diff.zero_grad(); loss.backward(); optim_diff.step()
        total += loss.item()
    print(f"Diffusion epoch {epoch}: avg loss = {total / len(mnist):.4f}")
# 輸出(實際數字會略有不同):
# VAE epoch 1..5: avg loss ~ 70~50
# GAN epoch 1..5: D loss ~ 0.6~1.3, G loss ~ 1.5~0.8
# Diffusion epoch 1..5: avg loss ~ 0.15~0.05

這段訓練三個模型各 5 epoch。VAE 的 loss 是 MSE + KL,平均值在 70→50 之間(會逐漸下降),這個數字本身意義不大——重要的是視覺化結果。GAN 的 loss 是 D 與 G 各自的 cross-entropy,D loss 在 0.6~1.3 之間震盪、G loss 在 1.5~0.8 之間震盪,這是 GAN 訓練的典型特徵(loss 不是單調下降而是互相博弈)。Diffusion 的 loss 是 MSE,平均值從 0.15 降到 0.05——單調下降、非常穩定,這正是 Diffusion 比 GAN 更容易訓練的關鍵。

注意 GAN 訓練的標準流程是「D 更新一次、G 更新一次」(不是 1:1 同時更新),這是 Goodfellow 原文的設計;實務上常用 D 更新 2~5 次才更新 G 一次(TTUR),讓 D 保持領先 G 一步。我們這裡為了簡化用 1:1 更新,5 epoch 後的 G 可能還沒收斂——實務上 GAN 需要至少 20~50 epoch 才會產出像樣的結果。

# 6. 取樣比較:VAE、GAN、Diffusion 各生成 8 張影像
import matplotlib.pyplot as plt

# VAE 取樣:從標準常態取樣 z、解碼
vae.eval()
with torch.no_grad():
    z = torch.randn(8, 16)
    vae_samples = vae.decode(z).cpu()

# GAN 取樣:從標準常態取樣 z、過 G
G.eval()
with torch.no_grad():
    z = torch.randn(8, 32)
    gan_samples = G(z).cpu()

# Diffusion 取樣:從純噪音開始,逐步去噪
denoiser.eval()
with torch.no_grad():
    xt = torch.randn(8, 1, 28, 28)
    for t in reversed(range(T_STEPS)):
        eps_hat = denoiser(xt, torch.full((8,), t, dtype=torch.long))
        a_bar = alpha_bars[t]
        x0_hat = (xt - torch.sqrt(1 - a_bar) * eps_hat) / torch.sqrt(a_bar)
        if t > 0:
            xt = torch.sqrt(alphas[t]) * x0_hat + torch.sqrt(1 - alphas[t]) * torch.randn_like(xt)
        else:
            xt = x0_hat
    diff_samples = xt.cpu()

# 把 8 張影像畫成 2×4 網格
fig, axes = plt.subplots(3, 8, figsize=(12, 5))
for i in range(8):
    axes[0, i].imshow(vae_samples[i].squeeze(), cmap="gray")
    axes[1, i].imshow(gan_samples[i].squeeze(), cmap="gray")
    axes[2, i].imshow(diff_samples[i].squeeze(), cmap="gray")
    for j in range(3):
        axes[j, i].axis("off")
plt.tight_layout()
plt.savefig("compare.png", dpi=80)
print("已儲存 compare.png(VAE 上排、GAN 中排、Diffusion 下排)")

這段展示三種模型的取樣流程:VAE 直接從標準常態取 z、解碼;GAN 直接從標準常態取 z、過 G;Diffusion 從純噪音開始,反覆執行 T 步去噪。取樣後把三組影像畫成 3×8 網格(上排 VAE、中排 GAN、下排 Diffusion),存成 compare.png 方便視覺比較。

視覺化的典型結果是:VAE 影像偏模糊(數字輪廓可辨但邊緣不銳利),這是 MSE 重建誤差的「平均化效應」;GAN 影像銳利但多樣性低(5 epoch 可能還沒收斂,看到一些「介於兩個數字之間」的怪影像;訓練 50+ epoch 後才會銳利且多樣),這是「對抗訓練 + 模式崩潰」的雙重影響;Diffusion 影像介於兩者之間(比 VAE 銳利、比 GAN 模糊,但 5 epoch 已經能看出數字輪廓),這是 MSE 預測噪音 + 漸進去噪的結果。如果你加大 Diffusion 的 T_STEPS(例如 1000)與訓練 epoch(例如 20),Diffusion 的影像品質會超越 VAE 與 GAN。

常見錯誤與踩雷

錯誤一:VAE 的解碼器輸出用 sigmoid 而非 tanh。常見症狀:影像偏暗、只有一半像素範圍被用。對應排查方向:輸入影像要線性映射到 [-1, 1](用 Normalize((0.5,), (0.5,))),解碼器用 tanh 輸出範圍 [-1, 1]。如果你想用 sigmoid(輸出 [0, 1]),輸入也要對應映射到 [0, 1] 並用 BCE 重建損失。

錯誤二:GAN 的 G 與 D 同時做多次更新。常見症狀:D 太快變強、G 的梯度消失、訓練崩潰。對應排查方向:用 TTUR(D 的 lr 是 G 的 4~8 倍,或 D 每步更新 2~5 次、G 只更新 1 次)。本篇範例用 1:1 更新只是為了簡化,實務上必須調整。

錯誤三:Diffusion 用 sigmoid 而非 tanh,或沒對齊輸入分布。常見症狀:模型預測的噪音全部集中在 0 附近(因為輸入影像都接近 0),loss 看似下降但取樣出來是純噪音。對應排查方向:輸入影像必須線性映射到 [-1, 1],配合前向加噪公式 √α̅_t · x₀ + √(1-α̅_t) · ε 的數學推導。

錯誤四:把 Diffusion 的 T 設太小(例如 10)。常見症狀:訓練 loss 看起來很低但取樣品質很差。對應排查方向:T 太小 → 加噪過程不夠細、α̅_t 衰減太快 → 模型學不到「逐步去噪」的細節。一般 T=1000 是 DDPM 原文設定,T=200~500 是加速變體(如 DDIM),但 T < 50 通常效果很差。

錯誤五:用 GAN 的 loss 數字判斷生成品質。常見症狀:D loss = 0.1、G loss = 2.0 看起來「D 太強」,但其實是 G 已經崩潰(只產生一張影像、D 對其他影像給 0)。對應排查方向:必須視覺化 G 的輸出才能判斷品質;FID score(Day 38 會介紹)是更好的量化指標。

錯誤六:把 VAE 的 KL 散度權重 β 設太大(例如 β=10)。常見症狀:重建誤差飆高、影像變模糊。對應排查方向:β 太大 → 模型過度強調「潜空間接近常態」而忽略重建品質。β-VAE 的研究建議 β 在 1~4 之間,視任務調整;想讓潜空間更有結構(但犧牲重建)用大 β、想讓重建更準(但潜空間較混亂)用小 β。

效能與實務提醒

三個模型在 CPU 上的訓練成本差異極大——VAE 5 epoch 約 2 分鐘、GAN 5 epoch 約 3 分鐘、Diffusion 5 epoch 約 5 分鐘(因為每個 batch 要呼叫網路 200 次去預測噪音)。在 Colab T4 上三者都約 30~60 秒。取樣速度差異更大:VAE 與 GAN 各約 0.01 秒(一次 forward)、Diffusion 約 3 秒(200 步去噪),Stable Diffusion 用 DDIM 50 步可以把 Diffusion 加速到約 0.5 秒。

實務上 Diffusion 的「慢」是它的最大缺點——它的高品質樣本是用「計算量」換來的。Day 36~37 介紹的 Stable Diffusion 用兩個關鍵設計解決這個問題:(a) VAE 把 512×512 影像編碼到 64×64 潜空間(計算量降到 1/64²)、(b) DDIM 把 1000 步減到 50 步(計算量再降到 1/20)。綜合下來 Stable Diffusion 比原生 DDPM 快約 1000 倍,但仍比 VAE/GAN 慢約 50 倍。

另一個工程取捨是「可控性」。VAE 的潜空間最有結構(可以做向量算術、線性內插),GAN 的中層潛變數也有結構但較不線性,Diffusion 的「條件生成」是透過 classifier-free guidance 或 ControlNet(Day 37)實現——這是 Stable Diffusion 能接受文字 prompt 的核心機制。如果你的任務是「把現有的圖片做風格轉換」或「在兩個樣本之間插值」,VAE 是首選;如果你的任務是「從文字描述生成圖片」,Diffusion(加條件控制)是 2024 年的主流。

小結

今天把三大生成模型家族的地圖打通:VAE 的「編碼 → 潜空間 → 解碼」與 KL 散度、GAN 的「生成器 vs 判別器」對抗訓練與模式崩潰、Diffusion 的「加噪 → 學去噪」漸進式生成。重點回顧:第一,三者的共同目標都是「給一個隨機向量產生影像」,差別在訓練與取樣機制;第二,VAE 影像模糊但可控、GAN 影像銳利但難訓練、Diffusion 兩者兼顧但慢;第三,Diffusion 成為 2022~2024 年的主流是因為它在 FID 等量化指標上超越 GAN、且訓練穩定性比 GAN 高很多;第五,三者的參數量在「百萬級」,可訓練、可視覺化、可擴展。明天 Day 34 會從 GAN 的「對抗式訓練」展開 DCGAN 的完整實作,在 MNIST 上跑 50 epoch 看看生成影像的演進;Day 35 會從 Diffusion 的「加噪去噪」展開 DDPM 的完整推導與 DDIM 加速取樣;Day 36~37 會把這套原理推到 Stable Diffusion 的實際應用。

結語

今天的重點是「把生成模型三大家族的設計哲學與程式介面掌握在自己手裡」。我們從 VAE 的「μ、log σ²、重參數化、KL 散度」開始,建立 16 維潜空間的最小編碼器-解碼器;接著用 GAN 的「G 與 D 對抗」在 32 維 z 空間生成影像;然後用 Diffusion 的「200 步加噪 → 學去噪」建立 MLP-based denoiser;最後三個模型同時在 MNIST 上訓練 5 epoch 並把取樣結果畫成 3×8 網格做視覺比較。讀完這篇你應該能回答:VAE 為什麼模糊?GAN 為什麼難訓練?Diffusion 為什麼成為主流?三者各自的最佳應用場景是什麼?

生成模型與前 32 天的關聯:Day 1~32 處理的是「分析現有資料」(分類、偵測、分割、姿態),今天開始進入「生成新資料」。這個典範轉移讓我們從「模型是靜態的讀者」變成「模型是動態的作者」,也是 CV 進階路線從「感知」進入「創造」的關鍵節點。明天 Day 34 會把 GAN 的對抗訓練推到完整實作,在 MNIST 上跑 DCGAN 50 epoch 並觀察模式崩潰的發生與對策;Day 35 會把 Diffusion 的加噪去噪推到完整推導,介紹 DDPM 的訓練目標數學證明與 DDIM 的加速取樣。本篇是「地圖」,接下來四篇是「按圖施工」。

延伸資源

  • Kingma 與 Welling,2013,Auto-Encoding Variational Bayes(arXiv 2013):https://arxiv.org/abs/1312.6114,VAE 原始論文,理解重參數化技巧與 ELBO 推導。
  • Goodfellow 等人,2014,Generative Adversarial Networks(NeurIPS 2014):https://arxiv.org/abs/1406.2661,GAN 原始論文,理解 min-max 訓練目標與理論收斂性證明。
  • Ho 等人,2020,Denoising Diffusion Probabilistic Models(NeurIPS 2020):https://arxiv.org/abs/2006.11239,DDPM 原始論文,理解前向加噪與反向去噪的數學推導。
  • Karras 等人,2019,A Style-Based Generator Architecture for Generative Adversarial Networks(CVPR 2019):StyleGAN 原始論文,理解潜空間 W+ 的線性結構與風格控制。
  • Song 等人,2020,Denoising Diffusion Implicit Models(ICLR 2021):https://arxiv.org/abs/2010.02502,DDIM 加速取樣的原始論文,把取樣步數從 1000 降到 50。
  • Rombach 等人,2022,High-Resolution Image Synthesis with Latent Diffusion Models(CVPR 2022):https://arxiv.org/abs/2112.10752,Stable Diffusion(Latent Diffusion Model)原始論文,理解為什麼「在 VAE 潜空間做 Diffusion」可以把 512×512 影像的計算量降到可接受範圍。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...