生成对抗网络 GAN 入门:DCGAN
本教程共 60 篇 · 第 40 篇 · 更新于 2026-08-17 · 约 4 分钟阅读
本节目标:理解生成器与判别器相互对抗的核心思想,掌握 DCGAN 的网络结构要点,能看懂并写出一个最小训练循环,知道 GAN 为什么难训。
前面几十章的网络都在「看」数据:分类、检测、分割。生成对抗网络(Generative Adversarial Network,GAN)第一次让网络学会「造」数据——给它一堆名人照片,它就能造出不存在的新面孔。GAN 由 Ian Goodfellow 在 2014 年提出,是生成模型(generative model)里最出名的一支。
先讲个比喻。造假币的和验钞员互相较劲:造假币的越做越像,验钞员越练越精。最后假币逼真到验钞员只能靠猜。GAN 就是这场戏:生成器(Generator)负责造假图,判别器(Discriminator)当验钞员。
具体点说,生成器吃一串随机噪声 z,吐出一张图 G(z);判别器吃一张图,输出「这是真图的概率」。生成器想骗过判别器,判别器想识破骗局。双方对着练,谁也压不倒谁的时候,生成器就出师了。这在数学上叫极小极大博弈(minimax game),两个网络各有一个优化器,各算各的梯度。
这串噪声有个学名:隐向量(latent vector)。它像一张密码表,每个值都对应画面的某个属性。训练完成后,在隐空间里沿某个方向平移,生成的人脸会连续变化——笑不笑、戴不戴眼镜,都藏在向量里。这也是 GAN 迷人的地方:它不仅学会画图,还学会了「风格旋钮」。
GAN 和前面学的模型还有个根本差异:没有明确的正确答案。分类有标签,检测有真值框,GAN 的「真值」是数据本身的分布。训练信号天生模糊,这解释了它为什么难训。
DCGAN 的结构
DCGAN 是 GAN 的经典变体,全称深度卷积生成对抗网络(Deep Convolutional GAN)。它把卷积带进了两个网络,还立了一套结构规矩:判别器用跨步卷积下采样,激活用 LeakyReLU;生成器用转置卷积上采样,激活用 ReLU;两个网络都用 BatchNorm 帮助梯度流动。输出层是特例——生成器最后一层用 tanh 把像素压到 [-1, 1],判别器最后一层用 Sigmoid 输出概率。
生成器的代码长这样,噪声进来、图片出去:
import torch
import torch.nn as nn
nz, ngf, nc = 100, 64, 3 # 噪声长度、特征图基数、通道数
class Generator(nn.Module):
"""100 维噪声 → 3×64×64 图片"""
def __init__(self):
super().__init__()
self.main = nn.Sequential(
nn.ConvTranspose2d(nz, ngf * 8, 4, 1, 0, bias=False),
nn.BatchNorm2d(ngf * 8), nn.ReLU(True), # 4×4
nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 4), nn.ReLU(True), # 8×8
nn.ConvTranspose2d(ngf * 4, ngf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 2), nn.ReLU(True), # 16×16
nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf), nn.ReLU(True), # 32×32
nn.ConvTranspose2d(ngf, nc, 4, 2, 1, bias=False),
nn.Tanh(), # 64×64
)
def forward(self, x):
return self.main(x)
判别器正好反着来,图片进来、概率出去:
ndf = 64
class Discriminator(nn.Module):
"""3×64×64 图片 → 真伪概率"""
def __init__(self):
super().__init__()
self.main = nn.Sequential(
nn.Conv2d(nc, ndf, 4, 2, 1, bias=False),
nn.LeakyReLU(0.2, inplace=True), # 32×32
nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 2), nn.LeakyReLU(0.2, inplace=True), # 16×16
nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 4), nn.LeakyReLU(0.2, inplace=True), # 8×8
nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 8), nn.LeakyReLU(0.2, inplace=True), # 4×4
nn.Conv2d(ndf * 8, 1, 4, 1, 0, bias=False),
nn.Sigmoid(),
)
def forward(self, x):
return self.main(x)
看一眼形状就对上了:生成器把 100 维噪声放大成 3×64×64 的图,判别器把它压回一个概率。既然生成器输出在 [-1, 1],训练数据也要跟着归一化到 [-1, 1](mean=0.5、std=0.5),两边数值对上才训得动。DCGAN 论文还有个建议:权重从均值 0、标准差 0.02 的正态分布初始化,BatchNorm 的权重初始化成均值 1:
def weights_init(m):
if m.__class__.__name__.find("Conv") != -1:
nn.init.normal_(m.weight.data, 0.0, 0.02)
elif m.__class__.__name__.find("BatchNorm") != -1:
nn.init.normal_(m.weight.data, 1.0, 0.02)
nn.init.constant_(m.bias.data, 0)
netG.apply(weights_init)
netD.apply(weights_init)
apply 会把函数递归套到模型的每个子层上,很省事。
训练:一场拉锯战
两个网络共用一套训练循环,但各管各的优化器。训判别器:真图给标签 1,假图给标签 0。训生成器:造一批假图,却骗判别器说「这些是真的」。损失函数就用二元交叉熵 nn.BCELoss——它期望输入是 0 到 1 的概率,判别器末尾的 Sigmoid 正好把它压进这个范围:
import torch.optim as optim
netG, netD = Generator(), Discriminator()
criterion = nn.BCELoss()
optimizerG = optim.Adam(netG.parameters(), lr=0.0002, betas=(0.5, 0.999))
optimizerD = optim.Adam(netD.parameters(), lr=0.0002, betas=(0.5, 0.999))
b_size = 64
real_label, fake_label = 1.0, 0.0
# ── 第一步:训判别器 ──
# 真图算一遍损失,假图算一遍损失,梯度累加后再更新
netD.zero_grad()
real = torch.randn(b_size, 3, 64, 64) # 真实数据(示例用随机数代替)
output = netD(real).view(-1)
errD_real = criterion(output, torch.full((b_size,), real_label))
errD_real.backward()
noise = torch.randn(b_size, nz, 1, 1)
fake = netG(noise)
output = netD(fake.detach()).view(-1) # detach:梯度别算到生成器头上
errD_fake = criterion(output, torch.full((b_size,), fake_label))
errD_fake.backward()
optimizerD.step()
# ── 第二步:训生成器 ──
# 假图冒充真图,梯度穿过判别器流回生成器
netG.zero_grad()
output = netD(fake).view(-1)
errG = criterion(output, torch.full((b_size,), real_label))
errG.backward()
optimizerG.step()
真实训练就是把这个过程包进 epoch 和 batch 两层循环,数据来自 DataLoader。经典论文里每轮先更新 k 次判别器、再更新一次生成器,让判别器始终处于热身状态;现在多数实现直接用 1:1,靠调学习率找平衡。还有一个好习惯:训练前固定一批噪声 fixed_noise,每个 epoch 后让生成器画一遍它,用 torchvision.utils.make_grid 拼成网格图,几轮对比就能看出人脸从噪声里长出来的过程,这是训练 GAN 最大的乐趣。
Tip两个细节别弄错。训判别器时,假图要
detach(),把生成器摘出计算图,不然梯度会顺着回流,白训一场。训生成器时反过来,梯度必须穿过判别器流回生成器,这时不能 detach。
WarningGAN 出了名的难训。最常见的问题是模式崩溃(mode collapse):生成器偷懒,只造有限的几种样子,却能骗过判别器。还有梯度消失、训练震荡。入门阶段先别急着调参,把结构跑通、看懂损失曲线的含义更重要。
怎么看训练得怎么样
训练时盯两个数:D(x) 是判别器对真图的平均打分,D(G(z)) 是对假图的平均打分。理想情况两者都收敛到 0.5——判别器已经分不出真假了,生成器大功告成。数值本身会剧烈抖动,别被单次波动吓到,看整体趋势。如果 D 的损失一路奔向 0,说明判别器太强,生成器学不动,得给判别器降降温,比如降低它的学习率。
说到底,训练的本质是维持平衡:判别器太强,生成器收到的梯度全是「你不行」,学不动;生成器太强,判别器先崩溃,后面全靠蒙。两个网络的学习率、容量都要大致门当户对。
提醒一句:完整训练 CelebA 这类数据集,一张像样的 GPU 也要跑小半天。初学先用小数据集、小分辨率把流程验证通,再上大场面。想动手的话,第一步永远是跑通官方示例:把随机数换成 MNIST 手写数字,生成 28×28 的小图,训练快、反馈直观,最适合把本章代码吃透。
GAN 家族很庞大,应用也早超出了人脸生成:WGAN 换了个更平滑的距离度量,谱归一化(spectral normalization)稳住判别器的梯度,StyleGAN 把「风格」拆开精细控制人脸生成;超分辨率把糊图变高清,pix2pix、CycleGAN 让白天照片变夜景、把马变成斑马,还有给老照片上色。顺着这条线走下去,就会碰到如今大火的扩散模型(diffusion model)——不过那是后话了。
最后送一句经验:训 GAN 训不出来是常态,把损失曲线看明白、知道问题出在哪,就是最大的收获。