聊生成对抗网络(GAN)之前,我真不建议一上来就背公式。G1 在我这里指的是第一代教学实战项目,核心就一句话:让生成器和判别器两个网络互相较劲,最后逼出以假乱真的数据生成能力。这个系列我已经带过不少同学跑通,很多人的困惑其实不在算法本身,而在“为什么训练这么不稳”“生成结果总是一坨”。这篇文章就按我从零搭建 G1 的完整过程来写,包含架构拆解、最小可运行代码、训练中的坑和评估方法,适合刚入门想亲手跑一个 GAN 又不想被论文吓退的人。我会尽量少堆术语,该给公式的地方给公式,该给代码的地方给代码,但更重要的是把每个选择背后的原因讲清楚。
1. GAN 到底在解决什么问题
1.1 伪造者与鉴定师的博弈
很多人第一次接触 GAN 都会被那个“生成器 vs 判别器”的比喻绕晕,其实它特别像一个长期演化的猫鼠游戏。
想象一个伪造者,目标是用假画骗过鉴定师;鉴定师的目标是准确分辨真画和假画。刚开始,伪造者只会乱涂,鉴定师一眼就能看出来。可随着博弈继续,鉴定师越来越懂画作的细节,伪造者也不得不研究颜料的颗粒、笔触的方向、画布的纹理。最后,伪造者画出来的东西已经和真画难以区分。
放到 GAN 里,伪造者就是生成器,鉴定师就是判别器。生成器输入一段随机噪声,输出一张伪造图像;判别器输入一张图像,输出一个真假概率。两者通过对抗损失不断更新。
我见过不少初学者把注意力全放在“网络结构”上,却忽略了这种博弈关系。结构只是一副骨架,真正让 GAN 起作用的,是“生成器骗、判别器防”这套动态过程。所以看任何 GAN 变体,我都会先问一个问题:它的博弈目标变了没有,还是只是换了网络骨架。
1.2 极大极小目标函数:一场零和游戏
既然是对抗,自然需要一个双方共同的目标函数。GAN 原文里给的是这样一个形式:
[ \min_G \max_D ; V(D,G) = E_{x \sim p_{data}} [\log D(x)] + E_{z \sim p_z} [\log(1 - D(G(z)))] ]
看着吓人,拆开就清楚了。
判别器 D 希望自己判断能力强,所以对真样本 x,它要让 D(x) 接近 1,也就是 log(D(x)) 接近 0;对生成样本 G(z),它要让 D(G(z)) 接近 0,也就是 log(1 - D(G(z))) 接近 0。整体就是让 V 尽量大。
生成器 G 正好相反,它希望自己生成的样本被判别器认成真的,所以要让 D(G(z)) 接近 1,也就是让 log(1 - D(G(z))) 趋近负无穷,整体希望 V 尽量小。
于是两个网络在同一个目标函数上拔河,一个拉高,一个压低。这个 min-max 结构是 GAN 的灵魂,后面几十种改进版本,像 WGAN、LSGAN、StyleGAN,本质上都在改这个目标函数或者训练方式,而不是换个花哨网络。
1.3 为什么这活儿特别难干
把目标函数写出来简单,真正训练起来处处是坑。
原因在于两个网络是交替更新的,而不是联合优化的。每一步你都在“一边动、一边改目标”,有点像两个人在黑暗中互相追逐,谁也不知道对方下一步会往哪走。稍微控制不好,整个博弈就会陷入震荡。
另一个更隐蔽的问题是,原始 GAN 目标函数里生成器在早期得到的梯度很不友好。当判别器太强、一眼就能识破假样本时,生成器收到的梯度会趋近于零,几乎学不到东西。很多人的第一次训练都卡在这个阶段:生成器 loss 不断上升,图像却永远是一团噪声。
这也是为什么后面会引入各种替代损失函数和训练技巧。理解了这一点,你在调参时才不会觉得是在“瞎试”,而是知道自己在改善博弈的哪个环节。
2. 核心架构:生成器和判别器怎么设计
2.1 生成器:从噪声到“像样数据”的映射
生成器的输入通常是一段低维随机噪声,比如 100 维,服从标准正态分布。它要做的是把这个噪声“翻译”成一张图,所以整体结构像是一个解码器:逐层上采样,把特征图从粗糙放大到精细。
我在 G1 里常用的生成器结构并不复杂:
- 输入层:100 维噪声,经过一个全连接层映射到足够大的向量;
- reshape 成类似 7×7×128 的特征图;
- 多次转置卷积或上采样卷积,把空间分辨率逐步抬到 28×28;
- 最后一层用 Tanh,把输出压缩到 [-1, 1],因为我已经把输入图像归一化到同样区间。
中间每一层我会接 BatchNorm 和 LeakyReLU。BatchNorm 的作用不只是加速收敛,它还能让每层输入的分布相对稳定,对 GAN 这种训练非常敏感的场景帮助很大。LeakyReLU 比普通 ReLU 更适合生成器,因为负半轴不会直接把梯度截断,信息流动更顺畅。
很多人问为什么生成器最后不用 Sigmoid。原因很简单,真实图像被归一化到 [-1, 1] 后,Sigmoid 输出 [0, 1] 和真实分布不匹配,会让生成器天然处于劣势。Tanh 更合适。
2.2 判别器:一个不断升级的“品味裁判”
判别器的结构更像一个标准分类器,但我在 G1 中不会做太深。
输入是一张 28×28 的图像,经过几层卷积提取特征,再用 LeakyReLU 和 Dropout 增加稳定性,最后接一个全连接输出一个 logit。这个 logit 通过损失函数里的 Sigmoid 操作转换成概率。
判别器要比生成器“稍微强一点”,但不能强太多。强太多,生成器学不到梯度;弱太多,生成器很容易骗过它,最后生成的图也不够好。这个平衡很微妙。
我在实现里会给判别器加 Dropout。很多人以为 Dropout 只是防过拟合,但在 GAN 里它还有一个意外的作用:给判别器增加一点“不确定性”。这种不确定性会迫使生成器生成更通用的特征,而不是死记硬背某几张图片的模式。实测下来,带 Dropout 的判别器训练过程稳定不少。
2.3 交替训练:为什么不能一起更新
训练 GAN 的时候,你永远不会把生成器和判别器的梯度同时做一次更新。标准流程是每个 batch 分两步:
- 固定生成器,用真实图像 + 生成图像训练判别器;
- 固定判别器,用生成图像训练生成器。
为什么不能像普通网络那样一把梭?因为如果两个网络同时更新,判别器会不断追着生成器变,目标函数本身也在变,优化过程很容易发散。交替更新至少让每一方的“对手”在一小段时间内保持相对固定,整个博弈节奏才能稳定下来。
实际操作中我还会在两步之间区分梯度隔离,也就是只计算当前更新网络的梯度,另一个网络保持不反传。写过几次代码之后你就会明白,这一步如果不注意,两个网络的参数会被一起更新,整个 GAN 就废了。
3. 动手实现:一个最小可运行的 GAN
3.1 实验配置与超参数选择
下面这份配置是我跑通 G1 的常用基底,不一定是最优,但足够稳。
- 数据集:公开的手写数字图像集,单通道 28×28;
- 图像预处理:缩放到 [-1, 1];
- 优化器:Adam,学习率 2e-4,beta1=0.5,beta2=0.999;
- batch size:64;
- 噪声维度:100;
- 训练轮数:先跑到 50 轮看趋势。
Adam 在学习率上要比一般分类任务小一个量级,这是因为生成器对更新幅度非常敏感,步子大了容易直接把判别器“喂”出绝对优势。beta1 不要用默认的 0.9,我用 0.5 更常见,原因是它对应 RMSProp 风格的动量,能减弱梯度震荡。
你会发现我没有提学习率衰减。在小规模 GAN 上,前期固定学习率往往比衰减更容易观察问题。等你能稳定生成清晰图像之后,再考虑动态调节不迟。
3.2 生成器与判别器的网络定义
下面用类 PyTorch 的伪代码给一个最小实现,重点看结构和维度变化。
import torch import torch.nn as nn # 生成器 class Generator(nn.Module): def __init__(self, noise_dim=100, feat=128): super().__init__() self.fc = nn.Linear(noise_dim, feat * 7 * 7) self.block = nn.Sequential( nn.BatchNorm2d(feat), nn.LeakyReLU(0.2, inplace=True), nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False), nn.Conv2d(feat, feat // 2, kernel_size=3, padding=1), nn.BatchNorm2d(feat // 2), nn.LeakyReLU(0.2, inplace=True), nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False), nn.Conv2d(feat // 2, 1, kernel_size=3, padding=1), nn.Tanh() ) def forward(self, z): h = self.fc(z).view(z.size(0), -1, 7, 7) return self.block(h) # 判别器 class Discriminator(nn.Module): def __init__(self, feat=64): super().__init__() self.conv = nn.Sequential( nn.Conv2d(1, feat, kernel_size=4, stride=2, padding=1), nn.LeakyReLU(0.2, inplace=True), nn.Dropout(0.3), nn.Conv2d(feat, feat * 2, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(feat * 2), nn.LeakyReLU(0.2, inplace=True), nn.Dropout(0.3), ) self.fc = nn.Linear(feat * 2 * 7 * 7, 1) def forward(self, x): h = self.conv(x) return self.fc(h.flatten(1))生成器里的上采样我选的是双线性插值加 3×3 卷积,而不是转置卷积。原因很现实:转置卷积容易出现棋盘效应,那种明暗不均匀的伪影,新手排查起来特别头疼。双线性上采样加卷积虽然有一点点额外计算量,但图像纹理更干净。
判别器的最后输出是 logit,没有接 Sigmoid。因为后面损失函数用的是带 logits 的 BCE,数值上更稳定,单独扩一个 Sigmoid 再算交叉熵反而容易出现梯度消失。
3.3 训练循环与损失计算
完整训练循环我一般这样写:
import torch.optim as optim netG = Generator() netD = Discriminator() optG = optim.Adam(netG.parameters(), lr=2e-4, betas=(0.5, 0.999)) optD = optim.Adam(netD.parameters(), lr=2e-4, betas=(0.5, 0.999)) criterion = nn.BCEWithLogitsLoss() real_label = 1.0 fake_label = 0.0 for epoch in range(epochs): for real_img, _ in dataloader: real_img = real_img.to(device) batch_size = real_img.size(0) # 第一步:训练判别器 netD.zero_grad() real_pred = netD(real_img).view(-1) real_target = torch.full_like(real_pred, real_label) loss_real = criterion(real_pred, real_target) z = torch.randn(batch_size, noise_dim, device=device) fake_img = netG(z).detach() fake_pred = netD(fake_img).view(-1) fake_target = torch.full_like(fake_pred, fake_label) loss_fake = criterion(fake_pred, fake_target) lossD = loss_real + loss_fake lossD.backward() optD.step() # 第二步:训练生成器 netG.zero_grad() z = torch.randn(batch_size, noise_dim, device=device) fake_img = netG(z) fake_pred = netD(fake_img).view(-1) real_target = torch.full_like(fake_pred, real_label) lossG = criterion(fake_pred, real_target) lossG.backward() optG.step()注意生成器训练时传给判别器的目标是real_label,也就是 1。它不需要生成器自己去拟合真实分布,只需要骗过判别器。这是很多初学代码的人最容易犯迷糊的地方。
另外在判别器训练阶段,生成图要.detach()。如果你忘了,梯度会顺着生成图回传到生成器,导致生成器在“训练判别器”这一步也被更新,噪声很大,训练完全失控。
训练过程中我会每 5 轮把固定噪声 z 重新生成一遍,保存成网格图。固定噪声这个细节特别重要,它让你每次看到的是同一批输入在不同训练阶段的表现,而不是生成器偷懒只对某些噪声有效果。
4. 训练 GAN 的经典问题与排查经验
4.1 模式崩塌:生成结果千篇一律
模式崩塌是 GAN 训练最出名的问题,表现是生成器找到了一两个“安全区”,只要生成这几类图就能稳定骗过当前判别器,于是再也不探索其他模式。
最典型的现象:手写数字任务里,你希望它把 0 到 9 都生成出来,结果它只会写 1,或者只会写 7。图里来回变,变不出第二种数字。
原因在于生成器一旦发现某个捷径能让判别器上当,它的损失就会很低,而对抗博弈又缺乏一个机制逼它覆盖完整分布。我在实际项目中处理模式崩塌的顺序是:
- 先调大噪声维度,比如从 100 调到 256,给生成器更强的表达空间;
- 降低学习率到 1e-4,避免更新步幅过大直接跳过其他模式;
- 引入标签平滑,把真实标签从 1 改成 0.9 左右,给判别器留一点容错空间;
- 如果还不行,就给生成器加一点多样性正则。
有人说模式崩塌必须上 WGAN,但我在 G1 这个规模里,先动超参数往往就能明显改善。用更大架构去掩盖不合适的超参数,反而会让问题更难排查。
4.2 判别器太强或太弱
训练不平衡是第二个高频问题。
判别器太强时,生成器每次的输出都会被一眼识破,生成器损失居高不下,梯度要么消失要么爆炸。具体信号是lossD快速降到 0 附近,而lossG一路上涨。此时我会:
- 减少判别器的训练频率,比如每训练一次生成器,判别器只训练一次甚至半次;
- 给生成器输入更大噪声,让它的输出更多样,不那么容易被抓住规律;
- 增加判别器的 Dropout,削弱它的判断稳定性。
判别器太弱时恰好相反,lossD一直在 0.69 附近下不去,生成器很容易骗过它,结果画面模糊。这种情况我一般会让判别器每步多训练几次,或者把生成器的学习率调低,先让判别者追上节奏。
判断“太强”还是“太弱”,不能只看单次 loss,最好观察 10 到 20 个 batch 的平均趋势。单 batch 波动极大,情绪容易跟着一起起伏。
4.3 损失曲线速查表
我整理了一张自己常用的速查表,用来快速判断训练状态:
| 观察信号 | 可能原因 | 优先尝试 |
|---|---|---|
| lossD 接近 0,lossG 非常大 | 判别器过强,生成器梯度消失 | 减少判别训练频率,增强生成器,提高学习率下限保护 |
| lossD 一直约 0.69 且几乎不动 | 判别器没学会,或真假样本均衡但能力弱 | 增加判别器训练步数,去掉过强 Dropout |
| lossG 和 lossD 同步下降 | 可能是判别器被“带偏”,后期容易崩 | 检查标签随机翻转或引入标签平滑 |
| 两个 loss 震荡剧烈,图像在清晰与模糊间来回横跳 | 学习率过大,更新幅度太猛 | 降低学习率,增大 batch size |
| 生成图像多样性很低,画面清晰但单一 | 模式崩塌 | 增大噪声维度,降低学习率,加正则 |
注意 loss 曲线的绝对数值本身没有太大意义,你需要看的是“相对走势”。GAN 不像分类任务,loss 单调下降就是好,它在对抗环境里天然就是拉锯战。
5. 生成质量怎么评估:不只靠眼睛看
5.1 为什么损失值不能直接比较
很多人跑完 GAN,第一件事看 loss 曲线,然后问我“我这个 loss 是不是达标了”。这个问题没法用单个数字回答。
原因刚才提过,两个网络是零和博弈。同一套模型换一次初始化,完全相同的训练 epoch,loss 曲线可能长得完全不一样。判别器被随机初始化成很弱的状态,生成器的 loss 就会很低;换一个初始化,判别器很强,生成器又变成高损失。但两者都能产生质量相似的图像。
更麻烦的是,早期 GAN 的判别器 loss 收敛到 log2 附近,也就是约 0.69,看起来像没学。这其实是理想均衡状态。所以不要用普通深度学习中“loss 越低越好”的惯性来理解 GAN。
5.2 在实践中到底看什么
我自己的评估方式是三层递进:
第一层是眼睛看,直接生成 8×8 或者 6×6 的网格图,看清晰度、多样性、是否存在棋盘伪影。这是最快的一步,能帮你过滤 90% 的失败实验。
第二层是看分布覆盖率。我会把生成的图像和真实图像分别降维到二维平面,然后观察两个分布是否大致重叠。如果生成样本只集中在真实分布的某一小块,那就是典型的模式崩塌。
第三层才是指标量化。常用的 FID 或 Inception Score 可以给一个相对客观的数字,但它们依赖预训练的特征提取器,只适合在不同实验之间做横向比较。小规模项目里我不会一开始就上这些指标,因为计算累、对调参反馈慢。
这里还有一个容易被忽略的经验:保存每个中间 checkpoint,不只是最后一轮。GAN 训练可能在第 30 轮效果很好,到第 50 轮反而崩了。没有 checkpoint,你只能重新跑一遍,浪费大量时间。
另外我习惯固定随机种子。GAN 训练对初始化很敏感,固定随机种子能让你在改动一个变量时,确认变化的源头是那个变量,而不是随机波动。别小看这个习惯,它能帮你省下一半的 debug 时间。
最后聊一句我的个人体会。跑 GAN 和跑常规分类网络心态很不一样,分类网络给足数据、调好学习率,一般就能收敛;GAN 却是一场“谈判”,你既要给生成器成长空间,又要给判别器足够压力,还得随时准备在两者失衡时手动干预。每次踩过坑之后再回看,我对博弈的理解都会更深一层。
如果打算继续往下走,可以从两个方向扩展:一是把网络结构换成更强大的残差结构,二是在损失函数上尝试 WGAN 的风格。但不管换什么,先把 G1 这份最小实现吃透,训练中的手感比任何花哨技巧都重要。