图像生成 —— GAN
GAN 是一场固定博弈里的两个神经网络。一个画画,一个挑刺。它们一起变强,直到画作骗过了评审。
类型: Build 语言: Python 前置要求: 阶段 4 第 03 课(CNN)、阶段 3 第 06 课(优化器)、阶段 3 第 07 课(正则化) 预计时间: ~75 分钟
学习目标
- 解释生成器和判别器之间的极小极大博弈,以及为什么均衡对应于 p_model = p_data
- 用 PyTorch 实现一个 DCGAN,在 60 行以内让它生成连贯的 32x32 合成图像
- 用三个标准技巧稳定 GAN 训练:非饱和损失、谱归一化、TTUR(双时间尺度更新规则)
- 读懂训练曲线,区分健康收敛与模式崩溃、震荡、判别器完胜
问题背景
分类教网络把图像映射到标签。生成把问题反过来:采样出看起来像是来自同一分布的新图像。没有"正确"的输出可以拿来 diff;只有一个你想模仿的分布。
标准损失函数(MSE、交叉熵)衡量不了"这个样本是不是来自真实分布"。最小化逐像素误差产出的是模糊的平均,不是逼真的样本。突破在于把损失学出来:训练第二个网络,它的活儿是分辨真假,用它的判断去推动生成器。
GAN(Goodfellow 等人,2014)定义了那个框架。到 2018 年,StyleGAN 已经在产出与照片无法区分的 1024x1024 人脸。此后扩散模型在质量和可控性上夺了王座,但每个让扩散变得实用的技巧——归一化选择、潜空间、特征损失——都是先在 GAN 上被理解的。
核心概念
两个网络
flowchart LR
Z["z ~ N(0, I)<br/>噪声"] --> G["生成器<br/>转置卷积"]
G --> FAKE["假图像"]
REAL["真图像"] --> D["判别器<br/>卷积分类器"]
FAKE --> D
D --> OUT["P(real)"]
style G fill:#dbeafe,stroke:#2563eb
style D fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a生成器 G 拿一个噪声向量 z 输出一张图像。判别器 D 拿一张图像输出一个标量:这张图像是真的的概率。
博弈
G 想让 D 出错。D 想做对。形式上:
min_G max_D E_x[log D(x)] + E_z[log(1 - D(G(z)))]从右往左读:D 在最大化对真(log D(real))图像和假(log (1 - D(fake)))图像的准确率。G 在最小化 D 对假图的准确率——它想让 D(G(z)) 高。
Goodfellow 证明了这个极小极大有一个全局均衡,此时 p_G = p_data,D 处处输出 0.5,生成分布和真实分布之间的 Jensen-Shannon 散度为零。难的是怎么到那儿。
非饱和损失
上面那个形式数值不稳定。训练早期,每张假图的 D(G(z)) 都接近零,所以 log(1 - D(G(z))) 关于 G 的梯度消失。修法:把 G 的损失翻过来。
L_D = -E_x[log D(x)] - E_z[log(1 - D(G(z)))]
L_G = -E_z[log D(G(z))] # 非饱和现在当 D(G(z)) 接近零时,G 的损失很大,它的梯度有信息量。每个现代 GAN 都用这个变体训练。
DCGAN 架构规则
Radford、Metz、Chintala(2015)把多年失败的实验提炼成了五条让 GAN 训练稳定的规则:
- 用带 stride 的卷积替换池化(两个网络都是)。
- 在生成器和判别器都用批归一化,除了 G 的输出和 D 的输入。
- 在更深的架构上去掉全连接层。
- G 在除输出外的所有层用 ReLU(输出用 tanh 落到 [-1, 1])。
- D 在所有层用 LeakyReLU(negative_slope=0.2)。
每个现代基于卷积的 GAN(StyleGAN、BigGAN、GigaGAN)仍然从这些规则起步,再一个一个替换部件。
失败模式及其特征
flowchart LR
M1["模式崩溃<br/>G 产出一组<br/>狭窄的输出"] --> S1["D 损失低,<br/>G 损失震荡,<br/>样本多样性下降"]
M2["梯度消失<br/>D 完胜"] --> S2["D 准确率约 100%,<br/>G 损失巨大且静止"]
M3["震荡<br/>G 和 D 永远<br/>来回交换胜负"] --> S3["两个损失都剧烈<br/>摆动,没有下降趋势"]
style M1 fill:#fecaca,stroke:#dc2626
style M2 fill:#fecaca,stroke:#dc2626
style M3 fill:#fecaca,stroke:#dc2626- 模式崩溃:G 找到一张能骗过 D 的图像,就只产那一张。修法:加 minibatch discrimination、谱归一化,或标签条件化。
- 判别器完胜:D 太快变得太强,G 的梯度消失。修法:更小的 D、更低的 D 学习率,或对真标签做标签平滑。
- 震荡:两个网络来回交换胜负,从不接近均衡。修法:TTUR(D 比 G 快 2-4 倍学习),或换成 Wasserstein 损失。
评估
GAN 没有真值,那你怎么知道它在起作用?
- 样本检查 —— 每个 epoch 末就看 64 个样本。没得商量。
- FID(Fréchet Inception Distance) —— 真实集和生成集的 Inception-v3 特征分布之间的距离。越低越好。社区标准。
- Inception Score —— 更老、更脆;优先用 FID。
- 生成模型的 Precision/Recall —— 分别衡量质量(precision)和覆盖(recall)。比单看 FID 信息量大。
对小型合成数据的运行,样本检查就够了。
cv-gan-image动手构建
第 1 步:生成器
一个小 DCGAN 生成器,拿 64 维噪声产出一张 32x32 图像。
import torch
import torch.nn as nn
class Generator(nn.Module):
def __init__(self, z_dim=64, img_channels=3, feat=64):
super().__init__()
self.net = nn.Sequential(
nn.ConvTranspose2d(z_dim, feat * 4, kernel_size=4, stride=1, padding=0, bias=False),
nn.BatchNorm2d(feat * 4),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(feat * 4, feat * 2, kernel_size=4, stride=2, padding=1, bias=False),
nn.BatchNorm2d(feat * 2),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(feat * 2, feat, kernel_size=4, stride=2, padding=1, bias=False),
nn.BatchNorm2d(feat),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(feat, img_channels, kernel_size=4, stride=2, padding=1, bias=False),
nn.Tanh(),
)
def forward(self, z):
return self.net(z.view(z.size(0), -1, 1, 1))四个转置卷积,每个都是 kernel_size=4, stride=2, padding=1,让它们干净地把空间尺寸翻倍。通过 tanh 输出 [-1, 1] 的激活。
第 2 步:判别器
生成器的镜像。LeakyReLU、带 stride 的卷积,以一个标量 logit 收尾。
class Discriminator(nn.Module):
def __init__(self, img_channels=3, feat=64):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(img_channels, feat, kernel_size=4, stride=2, padding=1),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(feat, feat * 2, kernel_size=4, stride=2, padding=1, bias=False),
nn.BatchNorm2d(feat * 2),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(feat * 2, feat * 4, kernel_size=4, stride=2, padding=1, bias=False),
nn.BatchNorm2d(feat * 4),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(feat * 4, 1, kernel_size=4, stride=1, padding=0),
)
def forward(self, x):
return self.net(x).view(-1)最后一个卷积把 4x4 特征图缩成 1x1。每张图输出一个标量;只在算损失时应用 sigmoid。
第 3 步:训练步
交替进行:每个 batch 先更新 D 一次,再更新 G 一次。
import torch.nn.functional as F
def train_step(G, D, real, z, opt_g, opt_d, device):
real = real.to(device)
bs = real.size(0)
# D 步
opt_d.zero_grad()
d_real = D(real)
d_fake = D(G(z).detach())
loss_d = (F.binary_cross_entropy_with_logits(d_real, torch.ones_like(d_real))
+ F.binary_cross_entropy_with_logits(d_fake, torch.zeros_like(d_fake)))
loss_d.backward()
opt_d.step()
# G 步
opt_g.zero_grad()
d_fake = D(G(z))
loss_g = F.binary_cross_entropy_with_logits(d_fake, torch.ones_like(d_fake))
loss_g.backward()
opt_g.step()
return loss_d.item(), loss_g.item()D 步里的 G(z).detach() 很关键:在更新 D 时我们不想让梯度流进 G。忘了它就是经典的新手 bug。
第 4 步:在合成形状上的完整训练循环
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
def synthetic_images(num=2000, size=32, seed=0):
rng = np.random.default_rng(seed)
imgs = np.zeros((num, 3, size, size), dtype=np.float32) - 1.0
for i in range(num):
r = rng.uniform(6, 12)
cx, cy = rng.uniform(r, size - r, size=2)
yy, xx = np.meshgrid(np.arange(size), np.arange(size), indexing="ij")
mask = (xx - cx) ** 2 + (yy - cy) ** 2 < r ** 2
color = rng.uniform(-0.5, 1.0, size=3)
for c in range(3):
imgs[i, c][mask] = color[c]
return torch.from_numpy(imgs)
device = "cuda" if torch.cuda.is_available() else "cpu"
data = synthetic_images()
loader = DataLoader(TensorDataset(data), batch_size=64, shuffle=True)
G = Generator(z_dim=64, img_channels=3, feat=32).to(device)
D = Discriminator(img_channels=3, feat=32).to(device)
opt_g = torch.optim.Adam(G.parameters(), lr=2e-4, betas=(0.5, 0.999))
opt_d = torch.optim.Adam(D.parameters(), lr=2e-4, betas=(0.5, 0.999))
for epoch in range(10):
for (batch,) in loader:
z = torch.randn(batch.size(0), 64, device=device)
ld, lg = train_step(G, D, batch, z, opt_g, opt_d, device)
print(f"epoch {epoch} D {ld:.3f} G {lg:.3f}")Adam(lr=2e-4, betas=(0.5, 0.999)) 是 DCGAN 默认——低 beta1 让动量项不至于把这场对抗博弈稳得过了头。
第 5 步:采样
@torch.no_grad()
def sample(G, n=16, z_dim=64, device="cpu"):
G.eval()
z = torch.randn(n, z_dim, device=device)
imgs = G(z)
imgs = (imgs + 1) / 2
return imgs.clamp(0, 1)采样前永远切到 eval 模式。对 DCGAN 这很要紧,因为这时用的是批归一化的运行统计量,而不是这个 batch 的统计量。
第 6 步:谱归一化
判别器里 BN 的即插即换替代品,保证网络是 1-Lipschitz 的。修掉大多数"D 赢得太狠"的失败。
from torch.nn.utils import spectral_norm
def build_sn_discriminator(img_channels=3, feat=64):
return nn.Sequential(
spectral_norm(nn.Conv2d(img_channels, feat, 4, 2, 1)),
nn.LeakyReLU(0.2, inplace=True),
spectral_norm(nn.Conv2d(feat, feat * 2, 4, 2, 1)),
nn.LeakyReLU(0.2, inplace=True),
spectral_norm(nn.Conv2d(feat * 2, feat * 4, 4, 2, 1)),
nn.LeakyReLU(0.2, inplace=True),
spectral_norm(nn.Conv2d(feat * 4, 1, 4, 1, 0)),
)把 Discriminator 换成 build_sn_discriminator(),你通常就不需要 TTUR 技巧了。谱归一化是你能用的最容易的单项鲁棒性升级。
实际使用
要认真做生成,用预训练权重,或者换成扩散。两个标准库:
torch_fidelity在你的生成器上算 FID / IS,不用写自定义评估代码。pytorch-gan-zoo(遗留)和StudioGAN提供 DCGAN、WGAN-GP、SN-GAN、StyleGAN 和 BigGAN 经过测试的实现。
在 2026 年,GAN 仍然是这些场景的最佳选择:实时图像生成(延迟 <10 ms)、风格迁移、带精确控制的图到图翻译(Pix2Pix、CycleGAN)。扩散在照片级真实感和文本条件化上胜出。
拿去用
这一课产出:
outputs/prompt-gan-training-triage.md—— 一个 prompt,读一段训练曲线描述,挑出失败模式(模式崩溃、D 完胜、震荡)外加唯一推荐的修法。outputs/skill-dcgan-scaffold.md—— 一个 skill,从z_dim、目标image_size和num_channels写出一个 DCGAN 脚手架,含训练循环和样本保存器。
练习
- (简单) 在合成圆形数据集上训练上面的 DCGAN,每个 epoch 末保存 16 个样本的网格。到第几个 epoch,生成的圆变得明显是圆的?
- (中等) 把判别器的批归一化换成谱归一化。两个版本并排训练。哪个收敛更快?哪个在三个种子上方差更低?
- (困难) 实现一个条件 DCGAN:把类别标签喂进 G 和 D(在 G 里把 one-hot 拼到噪声上,在 D 里拼一个类别嵌入通道)。在第 7 课的合成"圆 vs 方"数据集上训练,通过用特定标签采样,展示类别条件化起了作用。
关键术语
| 术语 | 大家嘴上怎么说 | 它实际是什么 |
|---|---|---|
| 生成器(G) | "画东西的网" | 把噪声映射到图像;被训练来骗过判别器 |
| 判别器(D) | "评审" | 二分类器;被训练来区分真图和生成图 |
| 极小极大 | "博弈" | 一个对抗损失对 G 取 min、对 D 取 max;均衡是 p_G = p_data |
| 非饱和损失 | "数值上理智的版本" | G 的损失是 -log(D(G(z))) 而非 log(1 - D(G(z))),避免训练早期梯度消失 |
| 模式崩溃 | "生成器只做一种东西" | G 只产出数据分布的一小个子集;用 SN、minibatch discrimination 或更大 batch 修 |
| TTUR | "两个学习率" | D 比 G 快地学习,通常 2-4 倍;稳定训练 |
| 谱归一化 | "1-Lipschitz 层" | 一种权重归一化,约束每层的 Lipschitz 常数;阻止 D 变得任意陡 |
| FID | "Fréchet Inception Distance" | 真实集和生成集的 Inception-v3 特征分布之间的距离;标准评估指标 |
延伸阅读
- Generative Adversarial Networks (Goodfellow et al., 2014) —— 开启这一切的那篇论文
- DCGAN (Radford, Metz, Chintala, 2015) —— 让 GAN 可训练的架构规则
- Spectral Normalization for GANs (Miyato et al., 2018) —— 最有用的单项稳定技巧
- StyleGAN3 (Karras et al., 2021) —— SOTA GAN;读起来像过去十年每个技巧的精选集