我最初接触 GAN 是在一个图像生成的实战项目里当时跟着教程跑通了 DCGAN、WGAN 等经典模型但总有一种代码能跑但心里没底的感觉。后来我决定从最基础的任务开始彻底重写一遍用 PyTorch 搭建 GAN 生成 MNIST 手写数字。从网络架构到训练循环从损失函数到调参观察一路记录下了不少值得沉淀的经验。这篇文章不打算做成一份照着敲一遍就能跑的保姆级教程更想跟你分享我在完整走完流程之后的底层思考——包括每一步为什么这样设计、训练时最容易踩哪些坑、以及当你能稳定生成像模像样的数字之后下一步应该往哪个方向走。MNIST 是 GAN 入门的理想战场它的样本是 28×28 的单通道灰度图数据分布相对简单用一个不算深的网络就能在普通 GPU甚至纯 CPU 也勉强能训练上看到生成质量的明显进步。相比动不动就几百 MB 的 ImageNet 或者高清人脸数据集MNIST 让你在几分钟内就能跑完一个完整 epoch这意味着你可以频繁做实验、反复调参用最快的方式建立起对 GAN 训练动态的直觉。这篇内容适合以下几类朋友刚开始学 PyTorch 但还没碰过生成模型的人已经读过不少 GAN 理论、公式背得挺熟但实际训练时总觉得不太对劲的人以及想快速做图像生成实验、但不想把时间浪费在环境配置和网络调试上的人。我会从一个非常具体的角度切入不是教你搬运别人的代码而是带着你把数据、网络、损失、训练循环这些零件各就各位理解它们是如何咬合在一起的。1. GAN 到底在干什么一个伪造者与鉴别者的博弈框架1.1 从损失函数反推 GAN 的设计初衷我在带新人的时候经常发现一个现象很多人把 GAN 当成一个普通的生成网络试图用传统的回归或者分类思路去理解它结果越看越糊涂。这里的关键在于GAN 框架里其实有两套损失在同时运转生成器要最小化它判别器却要最大化同一个目标从数学上看这是典型的极小极大博弈minimax game。用最朴素的话讲生成器 G 是个伪造者它的任务是从一个低维的随机噪声向量 z 出发生成一张足以乱真的图像G(z)。判别器 D 是个鉴别者它拿到的输入要么是真实样本 x来自 MNIST 训练集要么是伪造样本 G(z)它的任务是尽可能准确判断这张图是真的还是假的。判别器输出的是一个概率值接近于 1 表示它确信这张图来自真实数据分布接近 0 表示它认定这是伪造的。两者互相较劲D 越强G 就越拿不到好评被逼着提升伪造技术G 越强D 的区分难度就越大又反过来促进 D 的能力。这个对抗过程不涉及任何监督标签所以 GAN 本质上是无监督学习。你可能会问既然是博弈那最终会停在哪个状态理论上当 G 生成的分布完全等于真实数据分布时D 无论怎么努力都无法区分只能对每张图都输出 0.5这时博弈达到纳什均衡。实际训练中几乎不可能精确到这个点但生成质量逼近真实分布这个方向始终是我们要优化的目标。1.2 为什么用 MNIST 而不是其他数据集我把 MNIST 当成 GAN 实验的标准小白鼠有几个非常现实的理由第一数据量适中。训练集有 6 万张测试集有 1 万张做全量训练不需要担心显存爆炸。PyTorch 的 DataLoader 配合 batch_size64一个 epoch 也就九百多个迭代。第二图像分辨率低对网络容量的要求极低。28×28×1 的输入意味着全连接层也能胜任如果你只是想验证 GAN 的基本原理甚至都不用上卷积层。当然我会建议你从带卷积的 DCGAN 结构起步因为它在图像任务上的稳定性明显更好。第三类别含义清晰。虽然是做无条件生成没有使用标签但训练完后你能直观看到模型学会了写 0 到 9。这给你提供了一种非常简单的人工评估手段生成出来的图像像不像手写数字一眼就能判断。1.3 一个直观的类比签名伪造者的困局想象一个小作坊里伪造者 A 想仿造大师的签名鉴别者 B 负责判断签名是真是假。最开始 A 的仿制品漏洞百出B 一眼就指出破绽A 被打击后开始照着真迹反复练慢慢模仿得越来越像B 也升级自己的经验从只看字形结构到观察笔锋细节。日子久了A 已经能写出足以乱真的签名B 也练就了火眼金睛——但这个困局里有个关键机制A 的进步完全建立在 B 不断指出错误的基础上如果 B 罢工了A 也无从知道自己的漏洞在哪。GAN 的网络设计正是把这个逻辑原样搬进了梯度下降框架D 的梯度信号是 G 学习的老师反馈。没有 DG 只能生成一堆也许看起来平均但毫无细节的模糊图片——这一点在后续训练观察里你会看得非常明显。2. 环境准备与数据集加载把最无聊的部分做到位2.1 依赖库与硬件要求我用的是 PyTorch 2.x配合 torchvision 里现成的 MNIST 接口。如果你打算用 GPU 训练建议装好 CUDA 版本的 PyTorch如果没有独显CPU 训练理论上也跑得动只是速度慢好几倍。我的个人建议是第一次实验哪怕用 CPU 也要把代码完整跑通一次因为这会逼着你把模型和 batch size 都调小反而能更清楚地看到训练中间状态的变化。import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader import matplotlib.pyplot as plt import numpy as np训练前我习惯把随机种子固定下来方便复现结果def set_seed(seed42): torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) np.random.seed(seed) set_seed(42)2.2 DataLoader 的细节灰度图为何要归一化到 [-1, 1]MNIST 本身是 PIL 图像像素范围在 [0, 255]torchvision 的ToTensor()会把它转成 [0, 1] 的浮点张量。但对 GAN 来说输入范围最好是[-1, 1]而不是[0, 1]这背后的原因我绕了很久才真正想通。原因是生成器输出层通常用Tanh激活函数输出范围天然是[-1, 1]如果真实数据是[0, 1]那生成器哪怕拟合得再好也只能缩在半个区间里取值白白损失表达能力。对应的判别器输入的 0 和 1 也不再是极端值能让网络处理起来更平缓。所以我在 transform 里加了Normalize((0.5,), (0.5,))等于把 [0,1] 线性映射到 [-1,1]。transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_dataset torchvision.datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers2)选择 batch_size64 也有讲究太小比如 8会导致梯度噪声很大训练震荡明显太大比如 512则每一步对真实分布和生成分布的估计都太平均生成的数字会显得模糊且缺少多样性。64 到 128 是一个在大多数 GAN 实验里表现比较稳定的区间。3. 生成器与判别器的网络设计构造一对旗鼓相当的对手3.1 生成器从 100 维噪声到 28×28 图像生成器的输入是随机噪声 z我取 100 维服从标准正态分布。你在很多论文里会看到 z 的维度被写成 100这不是随便拍的太低了可用的创作自由度不够生成结果容易模式单一太高了会让生成器难以在众多维度之间建立协调关系训练反而变慢。100 是在大量实验中沉淀出来的经验值。我采用的是 DCGAN 风格的结构全连接层先把 100 维噪声映射到 128×7×7 的特征图然后经过转置卷积逐级上采样最终得到 1×28×28 的输出。每一层卷积之间夹着 BatchNorm 和 ReLU最后输出层用 Tanh 压到 [-1, 1]。class Generator(nn.Module): def __init__(self, latent_dim100): super(Generator, self).__init__() self.latent_dim latent_dim self.fc nn.Linear(latent_dim, 128 * 7 * 7) self.deconv nn.Sequential( nn.BatchNorm2d(128), nn.ReLU(True), nn.ConvTranspose2d(128, 64, kernel_size4, stride2, padding1), nn.BatchNorm2d(64), nn.ReLU(True), nn.ConvTranspose2d(64, 1, kernel_size4, stride2, padding1), nn.Tanh() ) def forward(self, z): x self.fc(z) x x.view(-1, 128, 7, 7) x self.deconv(x) return x这里我特意没有在生成器第一层使用 BatchNorm 之前再接激活函数因为全连接输出是线性映射紧接着 BatchNorm 再激活是更规范的顺序。上采样的几何逻辑是7×7 → 14×14 → 28×28每一层用 stride2 的转置卷积把尺寸放大一倍这正好对应 DCGAN 里的经典选择。3.2 判别器提取特征并输出真/假概率判别器的结构相对简单通过卷积逐级下采样从 1×28×28 变成 1×1最后接 Sigmoid 输出概率。我同样用 BatchNorm但在第一层不加——因为输入是原始图像频繁遇到真实数据和生成数据的分布差异第一层直接接 BN 容易造成不稳定的统计量估计。class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.conv nn.Sequential( nn.Conv2d(1, 32, kernel_size4, stride2, padding1), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(32, 64, kernel_size4, stride2, padding1), nn.BatchNorm2d(64), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(64, 128, kernel_size4, stride2, padding1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2, inplaceTrue), ) self.fc nn.Linear(128 * 3 * 3, 1) def forward(self, x): x self.conv(x) x x.view(x.size(0), -1) x self.fc(x) return torch.sigmoid(x)3.3 怎么判断判别器和生成器势均力敌这里有个经验值如果一期训练里判别器的准确率一直维持在 99% 以上说明生成器太弱D 跟 G 之间的博弈失衡了梯度信号会大规模消失。如果 D 的准确率长期在 50% 附近徘徊且是在训练初期出现那可能是 D 太弱根本给不出有用的反馈。理想的曲线是两者此消彼长、交替领先整体向 50% 收敛。我通常会在每 500 步打印一轮d_loss和g_loss并记录下来用于观察。4. 训练循环核心细节损失如何设计优化器如何选择输出如何可视化4.1 BCEWithLogitsLoss 与数值稳定性训练中最常见的错误之一是把Sigmoid和BCELoss分开使用这会导致反向传播时的数值稳定性偏差。更规范的做法是用BCEWithLogitsLoss它会把 Sigmoid 的计算和交叉熵合并成一个数值稳定的公式。也就是说判别器最后一层输出的是未经过 Sigmoid 的 logits直接在损失函数里算交叉熵。criterion nn.BCEWithLogitsLoss()如果用torch.sigmoidBCELoss在 logits 极大或极小的情况下log(σ(x))可能因为精度问题变成nan而BCEWithLogitsLoss内部做了特殊处理。4.2 优化器选型为什么通常默认 Adam我记得最初在图解 GAN 的文章里看到作者反复强调用 Adam别用 SGD原因有两层Adam 对学习率不那么敏感而且在梯度比较稀疏的场景下表现更好。生成器和判别器各用一个 Adam 优化器两个优化器的学习率可以设成相同也可以让生成器稍微大一点点。我这里统一设为1e-4beta 用默认的(0.5, 0.999)。注意GAN 训练中 beta1 建议从默认的 0.9 降到 0.5因为 0.9 会让梯度的指数移动平均拖得太长导致训练震荡。4.3 一个 epoch 内的训练循环拆解训练循环的核心分四步第一步从 DataLoader 里取真实图片 batch。它是一个形状为[64, 1, 28, 28]的张量范围在 [-1, 1]。给它构造标签全 1期望判别器认为它们是真实的。第二步生成假图片。torch.randn(batch_size, 100)生成噪声过生成器得到fake_images标签全 0期望判别器认为它们是假的。第三步判别器反向传播。把真实图过 D 得到d_real_logits假图过 D 得到d_fake_logits两部分损失相加求平均反向传播梯度更新 D 的参数。第四步生成器反向传播。这里有个小技巧由于前面已经更新过 D 的参数再次生成一批噪声或者复用旧噪声把生成结果输入 D但这次标签全部置 1——意思是骗 D 认为假图是真的。生成器的损失只计算这部分反向传播更新 G 的参数。for epoch in range(num_epochs): for i, (real_imgs, _) in enumerate(train_loader): batch_size real_imgs.size(0) real_imgs real_imgs.to(device) valid torch.ones(batch_size, 1, devicedevice) fake torch.zeros(batch_size, 1, devicedevice) # Train Discriminator optimizer_D.zero_grad() d_real_logits discriminator(real_imgs) d_real_loss criterion(d_real_logits, valid) z torch.randn(batch_size, latent_dim, devicedevice) fake_imgs generator(z) d_fake_logits discriminator(fake_imgs.detach()) d_fake_loss criterion(d_fake_logits, fake) d_loss (d_real_loss d_fake_loss) / 2 d_loss.backward() optimizer_D.step() # Train Generator optimizer_G.zero_grad() z torch.randn(batch_size, latent_dim, devicedevice) fake_imgs generator(z) g_logits discriminator(fake_imgs) g_loss criterion(g_logits, valid) g_loss.backward() optimizer_G.step()注意fake_imgs.detach()这行更新判别器的时候我们只希望梯度流经判别器不希望反向传播到生成器。如果不detach那么 D 的 backward 也会顺带更新 G 的参数导致训练节奏完全错乱。4.4 生成样本可视化边训练边看效果只盯 loss 数字是不够的。我在每个 epoch 结束后会固定取同一个 z 向量比如 16 个样本把生成图片保存成一张网格图这样可以不受噪声随机性的影响直观看到同一个 latent code 在不同训练轮次的演化过程。def save_generated_images(generator, epoch, z_fixed, output_path./gen_images): generator.eval() with torch.no_grad(): fake generator(z_fixed).cpu() fake (fake 1) / 2 # 从 [-1,1] 还原到 [0,1] grid torchvision.utils.make_grid(fake, nrow4) plt.imsave(f{output_path}/epoch_{epoch:03d}.png, grid.permute(1, 2, 0).numpy()) generator.train()这种可视化方法在调试时几乎是必须的。loss 数值可能偶尔不变但图片画质有没有提升一眼就能看出来。5. 训练中的常见翻车现场从模式崩塌到损失不下降的排查链路5.1 模式崩塌生成器只学会了写某几个数字模式崩塌是 GAN 训练中最经典也最抓狂的问题——生成的图片全都是 6、全都是 0就是不会出其他数字。我当时第一次遇到时还以为是网络结构写错了折腾了好几天才发现是训练策略的问题。原因在于生成器的目标函数只关心能否骗过 D一旦它发现某个数字最容易骗过 D就会把所有噪声 z 都映射到这个数字上放弃多样性。判别器再怎么努力它也只会看到真实集中该数字与假集中该数字的分布差异但因为假图全是同一类D 很容易学会看到这个模式就判假可 G 依然不断尝试收敛到那个局部最优。针对性地解决我从两个方向入手一是给判别器加label smoothing。把真实标签从 1 改成0.9~1.0之间的随机值把假标签从 0 改成0~0.1之间的随机值。这会让 D 对非常确定的真/假不再过分自信梯度信号更平滑也能小幅抑制模式崩塌。valid torch.full((batch_size, 1), 0.9, devicedevice) fake torch.full((batch_size, 1), 0.0, devicedevice)二是引入mini-batch discrimination思路的简易替代每隔几个 epoch 从固定 noise 的生成结果里统计类别分布如果发现某个数字占比过高就适当提高该数字在真实样本中的出现频率人为 oversampling或增大该方向的对抗样本权重。这个方法比较 hack但实战中管用。5.2 判别器过强导致生成器梯度消失如果你观察 D 的 loss 从第一轮就稳定在 0.01 以下说明判别器已经把真假分得太清楚了生成器几乎收不到梯度。这时你应该考虑第一降低 D 的学习率让 G 的更新幅度相对更大。我通常会让lr_D lr_G * 0.5或者更低。第二不要在同一个 batch 里让 D 反复看真假两种样本后还在同一轮多次更新。可以先固定住 D 的权重让 G 多更新几次再来训练 D。这就是先让 G 跑几步再让 D 来抓的游戏节奏。第三给判别器加 Dropout。这能让 D 始终保留一定的不确定性而不是把特征学得太死。我一般加在最后一个全连接之前概率 0.2~0.3。5.3 损失不下降但图像看起来还行这是正常的瓶颈期GAN 的 loss 不像分类任务那样单调下降。在训练中你会看到 d_loss 和 g_loss 在某个范围里震荡而你生成的图片可能已经具备一定质量但还没到精致的程度。这往往说明博弈进入了一种相对均衡的状态——不需要慌可以适当调整学习率或者训练轮次。我踩过更隐蔽的坑保存模型和加载模型时如果一轮只保存了 G 而没保存 D下一次想继续训练就得重新从随机状态开始对抗效果会明显退化。所以我每次都把 G、D 和两个优化器的 state_dict 一起打包存下来。5.4 一次完整的排查链路复盘假设你遇到生成图像全是噪声且 loss 始终卡在某个值的情况我给你一个排查顺序确认数据预处理是否正确。如果Normalize的参数写反了比如把 mean 和 std 都设成了 0.5 却忘了和 ToTensor 搭配数据分布会是错的所有图像都变成黑白颠倒或灰色模型练到天荒地老也学不出结构。检查生成器输出层激活函数。如果是 Sigmoid 但数据被归一化到了 [-1, 1]损失会明显不收敛。检查判别器最后的输出层是否接了 Sigmoid 但又搭配了 BCEWithLogitsLoss。重复套用激活函数会让概率被二次压缩梯度异常。打印几个中间层的 shape确认从[64, 1, 28, 28]到[64, 1]的维度变化没有哪一步出错。把 D 先冻结单独训练 G 几百个 iter看生成图像是否至少能变成某种有结构的形状。这可以帮你判断问题出在 G 还是整体博弈过程。6. 从能跑到跑得好一些被低估的训练技巧6.1 不同优化器对收敛过程的影响我在实验里对比过 Adam 和 RMSprop、SGDMomentum最终坚定的选择是 Adam且配合 beta10.5。RMSprop 在 WGAN 里表现不错但在原始 GAN 加上 BCE 损失时对学习率太敏感。SGD 则需要特别仔细地调学习率否则很容易震荡。如果你不想把太多精力放在调参上Adam 是最省心的选择。6.2 训练轮数不是越多越好我见过很多人把num_epochs直接设成 200然后跑一整天结果发现后面几十轮图像几乎没有变化。MNIST 这种规模DCGAN 结构大约在 20~30 个 epoch 就能看到不错的生成效果。我曾用 batch_size64 在单张普通 GPU 上训练大约第 12 个 epoch 开始数字已经成形第 20 个 epoch 基本能稳定输出清晰的手写体。更长的训练有时反而会让 D 陷入过拟合判断力下降间接导致 G 的质量停滞。所以我建议用一个简单的早停策略每隔 5 个 epoch 把生成图像保存下来人工对比哪些轮次的图像最自然。6.3 把 latent space 玩出花来插值与编辑训练稳定之后你会发现 latent space 有很好的语义连续性。可以随机取两个不同的 z在它们之间做线性插值然后逐步生成图像你会看到数字从 3 慢慢平滑过渡到 8 的中间状态。这就是所谓的latent space interpolation它直接证明了 GAN 学习到的不是一个记忆库而是一个有结构的抽象表征空间。更有趣的是简单编辑实验如果已知某些 latent 维度对应笔画粗细或者倾斜角度你可以手动往对应方向加一点扰动观察生成的数字风格变化。MNIST 规模小这种实验成本极低却能让你对生成模型的隐空间产生非常直观的把握。6.4 训练速度和显存占用的小账我简单估算一下一个 epoch 大约是 938 个 batch每个 batch 前向加反向计算量并不大。在入门级 GPU 上单 epoch 大约 10~20 秒20 个 epoch 就是 4~7 分钟。如果发现训练极慢先检查num_workers是不是 0这会严重拖慢数据加载再看有没有把模型误传到 CPU 上。这类问题排查起来不难但很消耗耐心提前规避为好。7. 后续可以怎么演进从 DCGAN 到更深更稳定的变种7.1 从普通 GAN 到 DCGAN一次结构升级如果你已经跑通了全连接或轻量卷积版本下一步我建议完整迁移到 DCGAN 结构去掉全连接层除了输入输出之间的过渡层统一用带 stride 的卷积和转置卷积完成下采样和上采样生成器和判别器都不使用 Pooling 层。DCGAN 在 MNIST 上的稳定性提升非常明显这主要是因为卷积感受野更小、更擅长捕捉图像的局部笔触特征。7.2 引入标签条件生成变成有监督的扩展题MNIST 本身带标签如果训练时把标签也喂给网络就是Conditional GANcGAN。它把类别信息拼接到噪声向量或中间特征层里让生成器能学会说生成 3 就生成 3。这一步不需要换数据集代码改动也很小却能让你对如何给生成过程加条件约束有第一手的认识。7.3 WGAN-GP从损失函数角度解决训练不稳定WGAN-GP 用 Wasserstein 距离替代了 JS 散度加上梯度惩罚项来约束判别器评论家的 Lipschitz 连续性。它在 MNIST 上能更快收敛且几乎看不到模式崩塌。不过我建议你先扎实跑通普通 GAN因为 WGAN-GP 的很多设计是在解决普通 GAN 暴露的问题——没有前者的痛感很难真正理解后者的妙处。如果还有精力可以继续尝试SS-GAN自监督辅助损失、SAGAN引入注意力机制等更复杂的结构。但无论如何MNIST 始终是你迭代想法的最佳试验场数据集小、训练快、效果直观可以把所有精力集中在设计实验→观察现象→修正假设这个循环上。我在实际训练中最深的一点体会是GAN 的调试过程非常依赖图像反馈而不是数值反馈。你可以盯着 loss 曲线看一个小时也未必能确定问题在哪但打开一张生成图像网格立刻就能看出网络从噪声中学到了什么结构、还欠缺什么细节。所以我建议从头开始就养成每轮保存图像的习惯配合固定的 z 向量做对照你会看到模型逐渐结构成型的完整过程这是个非常有成就感的体验。