
聊生成对抗网络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. 动手实现一个最小可运行的 GAN3.1 实验配置与超参数选择下面这份配置是我跑通 G1 的常用基底不一定是最优但足够稳。数据集公开的手写数字图像集单通道 28×28图像预处理缩放到 [-1, 1]优化器Adam学习率 2e-4beta10.5beta20.999batch size64噪声维度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_dim100, feat128): super().__init__() self.fc nn.Linear(noise_dim, feat * 7 * 7) self.block nn.Sequential( nn.BatchNorm2d(feat), nn.LeakyReLU(0.2, inplaceTrue), nn.Upsample(scale_factor2, modebilinear, align_cornersFalse), nn.Conv2d(feat, feat // 2, kernel_size3, padding1), nn.BatchNorm2d(feat // 2), nn.LeakyReLU(0.2, inplaceTrue), nn.Upsample(scale_factor2, modebilinear, align_cornersFalse), nn.Conv2d(feat // 2, 1, kernel_size3, padding1), 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, feat64): super().__init__() self.conv nn.Sequential( nn.Conv2d(1, feat, kernel_size4, stride2, padding1), nn.LeakyReLU(0.2, inplaceTrue), nn.Dropout(0.3), nn.Conv2d(feat, feat * 2, kernel_size4, stride2, padding1), nn.BatchNorm2d(feat * 2), nn.LeakyReLU(0.2, inplaceTrue), 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(), lr2e-4, betas(0.5, 0.999)) optD optim.Adam(netD.parameters(), lr2e-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, devicedevice) 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, devicedevice) 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 接近 0lossG 非常大判别器过强生成器梯度消失减少判别训练频率增强生成器提高学习率下限保护lossD 一直约 0.69 且几乎不动判别器没学会或真假样本均衡但能力弱增加判别器训练步数去掉过强 DropoutlossG 和 lossD 同步下降可能是判别器被“带偏”后期容易崩检查标签随机翻转或引入标签平滑两个 loss 震荡剧烈图像在清晰与模糊间来回横跳学习率过大更新幅度太猛降低学习率增大 batch size生成图像多样性很低画面清晰但单一模式崩塌增大噪声维度降低学习率加正则注意 loss 曲线的绝对数值本身没有太大意义你需要看的是“相对走势”。GAN 不像分类任务loss 单调下降就是好它在对抗环境里天然就是拉锯战。5. 生成质量怎么评估不只靠眼睛看5.1 为什么损失值不能直接比较很多人跑完 GAN第一件事看 loss 曲线然后问我“我这个 loss 是不是达标了”。这个问题没法用单个数字回答。原因刚才提过两个网络是零和博弈。同一套模型换一次初始化完全相同的训练 epochloss 曲线可能长得完全不一样。判别器被随机初始化成很弱的状态生成器的 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 这份最小实现吃透训练中的手感比任何花哨技巧都重要。