GAN这个名字,做深度学习的人多少都听过,但真正从头手写一个可用项目又是另一回事。用PyTorch实现GAN生成MNIST类似的手写数字,算是所有接触生成模型的开发者绕不开的一道入门关卡。这个项目的训练过程很特别,你会在同一套代码里看到两个网络互相较劲:一个负责伪造数字,一个负责分辨真假,最后双方的博弈居然能把噪声“逼”成一张有模有样的28x28灰度图。这篇文章不打算泛泛讲理论,而是把完整方案、每一个结构设计的原因、训练代码和调参经验都铺开聊一遍,适合已经会PyTorch基本操作、想正式接触生成模型的读者,也适合那些跑通代码但经常遇到训练不稳定的同学拿来对照排查。
1. 项目整体思路与方案选型
1.1 先理清GAN在做什么:两个网络互相抬杠
GAN的全称是Generative Adversarial Network,翻译过来就是生成对抗网络。它不像是普通分类网络那样有一个明确的正向目标,而是同时训练两个网络,让它们互为监督信号。咱们这个项目里,负责生成数据的网络叫生成器G,负责判断数据真假的网络叫判别器D。
打个比方:G是个赝品画师,D是个古画鉴定师。G每次画一幅“假画”,D每次判断这幅画是真迹还是伪造。刚开始G画得稀烂,D一眼就能识破;但随着训练推进,G会不断根据D的反馈调整自己的画法,D也会不断更新自己的鉴定标准。到最后,G画出来的图已经能以假乱真,D被逼得只能靠瞎猜来分辨。这个“互相抬杠、共同进化”的过程,正是整个项目最核心的机制。
用公式表达就是下面这个极小极大博弈:
min_G max_D E[log D(x)] + E[log(1 - D(G(z)))]其中x是真实MNIST图像,z是随机噪声向量,D要尽量识别出真实图和假图,G要尽量骗过D。咱们代码里做的事情,本质上就是交替执行这个目标的优化。
1.2 为什么拿MNIST做练手项目最合适
MNIST是手写数字数据集,每张图是28x28像素的灰度图,维度在图像数据里算比较低的。为什么我会推荐用MNIST做GAN入门?原因有几个。第一,数据简单但又不至于毫无挑战,它能让你直观判断生成质量。一张生成图像不像手写数字,几乎只要扫一眼就知道。第二,训练集有6万张图,数据量足够大,对GAN这种数据饥渴的模型非常友好,又不会给你带来加载和预处理的负担。第三,它在PyTorch里集成在torchvision里,一行代码就能下载,不用自己造轮子。最后,生成28x28像素图对算力要求很低,即便只用CPU也能跑起来出效果,只是慢一些。
很多人在这个项目上容易犯一个毛病:拿到MNIST就直接开跑,结果生成图像一塌糊涂也不知道问题出在哪。其实从项目定位来看,MNIST这种低分辨率单通道图出身就决定了它非常适合作为调参试错场,你在这个项目上积累的GAN训练经验,之后迁移到CIFAR、CelebA等更复杂数据上时几乎都能复用。
1.3 方案选型:为什么选DCGAN结构而不是纯全连接网络
刚开始接触GAN,最常见的结构是原始论文里的全连接网络:G和D都由多层Linear加激活函数组成。这种做法代码最简单,但在MNIST之外的图像上往往表现不佳,而且训练特别容易不稳定。我这个项目用的是DCGAN结构,也就是在GAN中引入卷积和转置卷积层。
为什么DCGAN会成为主流选择?我直接给一张对比表:
| 对比维度 | 全连接GAN | DCGAN(本文采用) |
|---|---|---|
| 参数量 | 较小,堆不起来 | 卷积共享权重,层次深但不臃肿 |
| 空间结构建模 | 很差,像素位点独立映射 | 卷积对局部纹理敏感,能建模空间关系 |
| 训练稳定性 | 差,容易模式崩溃 | 配合BatchNorm后明显更稳定 |
| 生成质量 | 边缘模糊,数字笔画扭曲 | 笔画清晰,数字结构更完整 |
| 可扩展性 | 换到复杂数据集基本失效 | 沿用同样结构可扩展到大图项目 |
DCGAN的几个核心设计点:G中不用池化层,而是用转置卷积做上采样;D中用带strided的卷积做下采样,不靠池化;两个网络都尽量用BatchNorm稳定训练;G隐藏层用ReLU激活,最后一层用Tanh;D隐藏层用LeakyReLU激活。这些设计不是拍脑袋想出来的,每一条背后都有明确原因,下面会逐一展开。
2. 模型架构与数据管道搭建
2.1 生成器(Generator)结构拆解
生成器的职责很简单:输入一个随机噪声向量,输出一张28x28的灰度图。但怎么把这个低维向量映射到高维图像空间,是有讲究的。
我给生成器设定的输入是100维的标准高斯噪声向量,这是DCGAN论文里比较经典的经验值。100这个数字并没有一个特别严格的数学推导,但维度太低时表达空间不足,生成结果的多样性会下降;维度太高又增加无用参数消耗,训练更容易不稳定。实际测试下来50到200这个区间都可行,100算是均衡点。
生成器的核心流程分两段:先经过一个全连接层,把100维噪声一次性放大到128x7x7的特征图,再通过两层转置卷积逐步上采样,最终变成28x28。为什么中间要变成7x7?因为MNIST是28x28,而转置卷积每次会用stride=2把尺寸翻倍,7到14再到28刚好是两次翻倍,这个路径对应关系非常工整。
具体结构如下:
import torch import torch.nn as nn class Generator(nn.Module): def __init__(self, z_dim=100): super(Generator, self).__init__() # 先用全连接把噪声拉成 128 * 7 * 7 self.fc = nn.Sequential( nn.Linear(z_dim, 128 * 7 * 7), nn.BatchNorm1d(128 * 7 * 7), nn.ReLU(True) ) # 转置卷积上采样,7 -> 14 -> 28 self.deconv = nn.Sequential( nn.ConvTranspose2d(128, 64, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(64), nn.ReLU(True), nn.ConvTranspose2d(64, 1, kernel_size=4, stride=2, padding=1), nn.Tanh() ) def forward(self, z): x = self.fc(z) x = x.view(-1, 128, 7, 7) # reshape为特征图 x = self.deconv(x) return x这里有三个细节我想单独强调。
第一个是转置卷积的输出尺寸计算。规则是out = (input - 1) * stride - 2 * padding + kernel_size。第一层转置卷积输入7,stride=2,padding=1,kernel=4,算出来就是14;第二层输入14,算出来是28,正好对上MNIST尺寸。写代码时看着不算,但如果你后续换数据尺寸,这个公式一定有地方要用。
第二个是BatchNorm的作用。生成器每一层都加了BN,它能对中间层的特征分布做归一化,让网络每一层都在一个相对稳定的范围内工作。GAN训练本质上是动态博弈,没有BN的话G的梯度波动会很剧烈,训练特别容易炸。
第三个是最后一层为什么用Tanh而不是ReLU或Sigmoid。因为我们在数据预处理阶段会把真实MNIST图像归一化到[-1, 1]区间,使用Tanh能让生成器输出范围与真实数据的取值范围完全一致,这样判别器在判断时就不会因为量纲不匹配而产生莫名其妙的偏差。
2.2 判别器(Discriminator)结构拆解
判别器的结构和生成器基本是图形对称的:输入一张28x28灰度图,输出一个表明“真伪”的概率值,介于0和1之间。它的目标是尽可能把真实图和生成图区分开。
第一层卷积用64个卷积核,stride=2把尺寸从28压缩到14;第二层卷积用128个卷积核,stride=2把尺寸压缩到7。扁平化之后接一个全连接层输出1个标量。注意判别器的激活函数用的是LeakyReLU,斜率设为0.2。
class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.conv = nn.Sequential( nn.Conv2d(1, 64, kernel_size=4, stride=2, padding=1), nn.LeakyReLU(0.2), nn.Conv2d(64, 128, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2), ) self.fc = nn.Sequential( nn.Linear(128 * 7 * 7, 1), nn.Sigmoid() ) def forward(self, x): x = self.conv(x) x = x.view(x.size(0), -1) x = self.fc(x) return x这里有两个容易被忽略的设计细节。
首先是LeakyReLU。普通ReLU在x小于0时输出恒为0,梯度也跟着归零,这会让判别器在部分输入下无法更新。判别器面对真假样本混杂的batch,激活函数一旦“死掉”,整个网络反馈信号就断了。LeakyReLU在负半轴保留了一个很小的斜率,比如0.2,让反向传播在负区间也能流动。
其次是判别器第一层不接BatchNorm。这是因为BN计算的是整个batch内样本的均值和方差,而判别器的输入一半是真实图、一半是生成图,真假两种样本统计分布差异巨大。如果一进来就做BN,等于把两类样本先拉到相近的分布范围,判别器反而更难区分真假。实际操作下来,在第一层屏蔽BN确实比全部都用BN更稳定。
2.3 MNIST数据预处理与DataLoader设置
数据加载这块不复杂,但有个关键点:归一化范围必须和生成器输出范围对齐。torchvision自带的MNIST数据默认像素范围是0到1,我们用Normalize((0.5,), (0.5,))把均值设为0.5、标准差设为0.5,这样数据就会被映射到[-1, 1]区间,正好对应生成器Tanh的输出范围。
from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_dataset = datasets.MNIST(root="./data", train=True, transform=transform, download=True) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2, pin_memory=True)这里shuffle=True是必须的。数据如果不打乱顺序,每个batch的内容就会呈现某种规律性,判别器会更容易“投机取巧”,比如靠记忆数据分布而不是真正学到图像特征。shuffle之后每个batch的数据更具随机性,模型的泛化能力才能上来。
batch_size我通常选64。GAN对batch size比较敏感,批量太小比如8或16,BN统计的均值和方差波动太剧烈,训练很不稳;批量太大比如256或512,又会拖慢单次迭代速度,判别器和生成器的更新节奏也不好控制。从我的经验看,MNIST任务上64是个甜点值,显存不足的话降到32也还能接受。
3. 训练机制与调优要点
3.1 判别器与生成器的训练节奏
GAN训练的核心难点在于两个网络必须交替更新,而不是像分类任务那样把模型一步训练到位。在我的实现里,每一个batch迭代都做两次更新:先更新判别器,再更新生成器。这个1:1的更新比例在MNIST上是比较成熟的做法。
为什么不能先把判别器训练到完美再训练生成器?因为判别器一旦过于强大,生成器给它的任何输出都会被瞬间识破。判别器输出0的置信度极高,生成器的梯度反而会变得非常平缓,几乎学不到任何有效信息。反过来,如果生成器太强,判别器也会很快崩掉。对抗训练的微妙之处就在这个平衡上。
实际操作时我建议每跑一个batch都同时打印D和G的loss,观察两者的相对趋势。正常状态是判别器loss在0.69附近上下波动,生成器loss同样在一个较小区间内震荡,双方都不会大幅压倒另一方。如果某一方的loss突然往0掉,说明平衡已经破裂,需要停下来检查。
3.2 损失函数:BCELoss怎么用
这个项目里损失函数选的是二分类交叉熵损失BCELoss。判别器最后一层已经通过Sigmoid输出了0到1之间的概率值,真实样本标签为1,生成样本标签为0。
具体到代码里,判别器的损失由两部分组成:真实图片的损失加上生成图片的损失。
criterion = nn.BCELoss() real_label = torch.ones(batch, 1).to(device) fake_label = torch.zeros(batch, 1).to(device) # 判别器损失:希望真实判1越高越好,生成判0越高越好 loss_D = criterion(d_real, real_label) + criterion(d_fake, fake_label)这里有一个容易踩坑的细节:计算生成器损失时,我们要把生成图片传入判别器,然后用真实标签去计算损失。也就是说,生成器的目标不是把自己的图片打上0标签,而是让判别器以为这些图片是真实的,所以标签要反过来用1。
# 生成器损失:希望判别器对自己生成的图片给出接近1的概率 loss_G = criterion(d_fake, real_label)还有个小细节要提:在判别器计算损失时,生成器传给判别器的图像需要用detach()把梯度截断。否则判别器反向传播时梯度会顺着计算图流到生成器参数上,相当于同时更新了生成器,这不是我们想要的。
d_fake = D(fake_imgs.detach())3.3 优化器选择:Adam的beta1为什么取0.5
优化器我选的是Adam,这也是DCGAN和绝大多数GAN项目的主流选择。GAN的目标函数是非凸的、动态变化的,用普通SGD很容易出现振荡;Adam虽然也是基于梯度做参数更新,但会为每个参数维护一阶矩和二阶矩估计,训练前期的稳定性要好得多。
但标准Adam的默认参数在GAN里不全适用。PyTorch默认的beta1是0.9,这个值表示对梯度一阶矩的衰减系数,0.9意味着历史梯度占据相当大的比重。对普通监督学习来说这没问题,但在GAN这种双方动态博弈的场景下,过强的动量会让网络对损失变化反应迟缓,导致训练曲线大幅震荡。
DCGAN论文里把beta1改成0.5,目的是让模型更快地“忘记”旧梯度,更敏捷地响应对方网络的实时变化。这个改动虽然只是一个小数字,但我在实践里验证过很多次,效果差异非常明显。完整超参如下:
| 超参数 | 取值 | 说明 |
|---|---|---|
| z_dim | 100 | 输入噪声维度 |
| batch_size | 64 | 每批训练样本数 |
| learning_rate | 0.0002 | Adam初始学习率 |
| beta1 | 0.5 | 一阶矩衰减系数 |
| beta2 | 0.999 | 二阶矩衰减系数 |
| epochs | 50 | 训练轮数,可视效果增减 |
学习率0.0002看起来很小,但在GAN里属于比较稳妥的起始值。调高到0.001虽然前期收敛快,但后期很容易爆发式震荡;调到0.0001会更稳定,但出图速度明显变慢。建议先按0.0002跑完整流程,等熟悉了再去手动试探边界。
4. 完整实操流程与代码解析
4.1 环境准备与依赖安装
这个项目的依赖非常干净,核心就三个:PyTorch、torchvision、matplotlib。如果你用的是PyTorch的CPU版本也没关系,MNIST这种小图CPU训练虽然慢,但50个epoch依然能出效果;有NVIDIA显卡的话记得先确认CUDA可用。
import torch print(torch.cuda.is_available())输出True说明可以用GPU。设备选择统一用下面的写法,代码就能在CPU和GPU之间自动切换:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print("使用设备:", device)如果第一次跑,MNIST数据会从网上自动下载到本地,后面再跑就不会重复下载。
4.2 完整超参与数据加载代码
把前面提到的最关键设置集中在一起,这部分代码直接原样复制就能跑:
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms import matplotlib.pyplot as plt device = torch.device("cuda" if torch.cuda.is_available() else "cpu") z_dim = 100 batch_size = 64 learning_rate = 0.0002 epochs = 50 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_dataset = datasets.MNIST(root="./data", train=True, transform=transform, download=True) train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=2, pin_memory=True)到这里,数据和模型的前置条件就全都准备好了。你可以把这些代码块保存成一个Python文件,一步一步看着输出结果。
4.3 生成器与判别器实现
生成器和判别器的代码在前面已经分别给出,这里把它们和训练循环放成一个完整可运行的闭环。我把定义网络的代码再黏贴一遍方便对照:
class Generator(nn.Module): def __init__(self, z_dim=100): super(Generator, self).__init__() self.fc = nn.Sequential( nn.Linear(z_dim, 128 * 7 * 7), nn.BatchNorm1d(128 * 7 * 7), nn.ReLU(True) ) self.deconv = nn.Sequential( nn.ConvTranspose2d(128, 64, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(64), nn.ReLU(True), nn.ConvTranspose2d(64, 1, kernel_size=4, stride=2, padding=1), nn.Tanh() ) def forward(self, z): x = self.fc(z) x = x.view(-1, 128, 7, 7) x = self.deconv(x) return x class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.conv = nn.Sequential( nn.Conv2d(1, 64, kernel_size=4, stride=2, padding=1), nn.LeakyReLU(0.2), nn.Conv2d(64, 128, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2), ) self.fc = nn.Sequential( nn.Linear(128 * 7 * 7, 1), nn.Sigmoid() ) def forward(self, x): x = self.conv(x) x = x.view(x.size(0), -1) x = self.fc(x) return x这里再补充一个实际使用时会遇到的问题:如果你在生成器全连接层加了BatchNorm1d,那生成器的输入z必须是二维形状(batch, z_dim),也就是标准正态采样得到的噪声。原始噪声在没经过任何归一化的情况下,均值和方差基本符合标准正态分布,所以不需要额外处理。
4.4 训练主循环实现
训练主循环是整个项目的灵魂。核心逻辑分三步:先训练判别器,再训练生成器,最后记录和打印指标。
criterion = nn.BCELoss() G = Generator(z_dim).to(device) D = Discriminator().to(device) opt_G = optim.Adam(G.parameters(), lr=learning_rate, betas=(0.5, 0.999)) opt_D = optim.Adam(D.parameters(), lr=learning_rate, betas=(0.5, 0.999)) for epoch in range(epochs): for i, (real_imgs, _) in enumerate(train_loader): real_imgs = real_imgs.to(device) batch = real_imgs.size(0) real_label = torch.ones(batch, 1).to(device) fake_label = torch.zeros(batch, 1).to(device) # 1. 训练判别器 opt_D.zero_grad() z = torch.randn(batch, z_dim).to(device) fake_imgs = G(z) d_real = D(real_imgs) d_fake = D(fake_imgs.detach()) loss_D = criterion(d_real, real_label) + criterion(d_fake, fake_label) loss_D.backward() opt_D.step() # 2. 训练生成器 opt_G.zero_grad() z = torch.randn(batch, z_dim).to(device) fake_imgs = G(z) d_fake = D(fake_imgs) loss_G = criterion(d_fake, real_label) loss_G.backward() opt_G.step() # 打印每个epoch的平均loss print(f"Epoch [{epoch+1}/{epochs}] D_loss: {loss_D.item():.4f} G_loss: {loss_G.item():.4f}")代码里每轮迭代都重新采样了一次噪声z。你可能注意得到,在判别器训练阶段用了一次G(z),生成器训练阶段又用了一次G(z),两次用的是不同的随机噪声。这样做可以让同一batch内生成多样性更强的假图,判别器接触到的样本分布也更丰富,有助于稳定训练。
这里我特别想提一下可视化的重要性。只看loss输出很难直观判断生成质量,所以我习惯在每个epoch结束后把生成图片画出来看一眼。下面的函数把[-1,1]区间的生成结果映射回[0,1],再用imshow显示成灰度图:
def show_generated(num=16): G.eval() with torch.no_grad(): z = torch.randn(num, z_dim).to(device) fake = G(z).cpu() fake = (fake + 1) / 2 # 从[-1,1]映射回[0,1] plt.figure(figsize=(8, 8)) for i in range(num): plt.subplot(4, 4, i + 1) plt.imshow(fake[i, 0].numpy(), cmap="gray") plt.axis("off") plt.show() G.train()G.eval()和G.train()这一点不能写反。BatchNorm在训练和推理状态下行为不同,如果不切到eval模式,生成可视化图像时BN会继续用当前batch统计量去更新,影响生成结果。
模型保存也很简单,直接保存生成器的权重即可。建议每10个epoch存一次,方便中途回溯:
if (epoch + 1) % 10 == 0: torch.save(G.state_dict(), f"generator_epoch{epoch+1}.pth")4.5 训练过程的渐进规律与效果验证
第一次跑的时候,你可能会对loss曲线感到困惑:它不像分类任务那样单调下降,而是长期在某个区间波动。观察生成图像的变化倒是更有意思。
大概在第一个epoch,生成图基本就是一团随机噪声。第10个epoch左右,图像开始出现模糊的结构,有的像数字7的骨架,有的像数字3的轮廓。等到第20到30个epoch,常见数字比如0、1、7、9已经能看出清晰形状,笔画边缘也干净了不少。最后十个epoch主要是在打磨细节和增加多样性。
有个容易让人误解的点:GAN的loss不能像监督学习那样越低越好。判别器loss降到太低说明生成器太弱、被压制住了,生成器loss降到太低说明判别器已经失效。真正健康的状态是两者都有震荡但保持在一个相对均衡的水平。
5. 常见问题与排查技巧
5.1 模式崩溃:生成结果品种单一
模式崩溃是GAN最出名的问题,现象是生成器只学会生成某一种或少数几种数字,比如每次都输出一个模糊的7,其他数字几乎看不到。我在自己项目里也踩过,通常在epoch 20后出现。
产生原因主要有两个:一是生成器找到了一条让判别器最难分辨的“捷径”,只生成最容易骗过D的样本;二是训练超参数设置不当,让G的探索能力不足。
我实际用的几个对策:把学习率从0.0002降到0.0001,给判别器加一点Dropout,提升生成器的z输入维度,或者对真实标签做标签平滑把1改成0.9,降低判别器的“自信程度”,给生成器留出学习空间。其中标签平滑最有效,后面会细说。
5.2 生成图像模糊或全是噪点
如果你的损失一直在下降,但生成图像完全没有数字轮廓,看起来像打满马赛克的噪声图,大概率是两个原因:训练轮数远远不够,或者输入噪声维度太低。MNIST项目我至少需要30个epoch才开始有像样的结果,如果只跑10个epoch就下结论,多数情况就是噪声团。
转置卷积还有一个特殊的“棋盘伪影”问题,因为反卷积的核重叠区域不均匀,生成图会出现棋盘状纹路。如果碰上了,最简单的解决方案是把ConvTranspose2d换成Upsample加Conv2d的组合,或者改用nn.PixelShuffle。MNIST这种低分辨率图像不太容易出现严重棋盘伪影,但换到大图项目时几乎必踩,提前知道有好处。
5.3 判别器损失太低,生成器完全学不动
判别器的loss快速掉到0.01甚至0,直观感受是判别器“赢麻了”,但生成器彻底没救。原因就是判别器能力太强,生成器输出的每一个假图都被瞬间识别,反向传播到G的梯度非常小,几乎等于冻结。
现实里遇到这个情况,我会依次做三件事:一是减小判别器容量,比如把第二个卷积层的128通道改成64通道;二是在判别器里加Dropout,让它的判断不那么敏锐;三是给真实标签做标签平滑,把原本的1改成0.8到0.9之间的数,给模型一个“容错空间”。
标签平滑的代码很简单:
real_label = torch.full((batch, 1), 0.9).to(device)这个改动背后的逻辑是:不要训练判别器对真实样本过于自信,否则它给出的误差信号会变得极端,生成器在极端梯度下很难找到有效的优化方向。
5.4 训练震荡不收敛
loss曲线像心电图一样上下乱跳,训练几十个epoch后生成图像还是没有什么像样的数字,这是典型的“不收敛”。我最早跑GAN时就是这个状况,差点以为是网络结构写错了。
排查顺序是这样:先检查数据有没有做[-1,1]归一化,没做的话G的输出范围和数据范围对不上,一切都白搭;然后看学习率是不是太高;接着确认生成器和判别器的loss是否出现单边压倒,如果是,按上一小节的方法调节;最后再看看噪声维度是不是太小,低于20就是纯粹在自找麻烦。
还有一个比较容易忽视的坑:批量太小。如果batch_size只有4或8,BatchNorm统计不稳定,训练过程会非常颠簸,损失曲线自然就来回震荡。这种情况我建议保持batch_size至少32。
5.5 实战排查速查表
把上面这些经验整理成一张速查表,遇到问题直接对照处理,比重新翻理论更能救急:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 生成图像品种单一 | 模式崩溃,G走了捷径 | 降学习率、加Dropout、标签平滑 |
| 图像全是模糊噪声 | 训练轮数不够或z_dim太低 | 增加epoch,z_dim调到80以上 |
| D_loss逼近0而G_loss高 | D能力过强,压制了G | 降D容量、加Dropout、标签平滑 |
| G_loss逼近0而D_loss高 | G过强,D失效 | 增强D、降低G容量、调整学习率 |
| 训练过程剧烈震荡 | 学习率偏高或batch_size过小 | 降低lr到0.0001,增大batch到64 |
| 生成图有棋盘纹路 | 转置卷积的核重叠不均匀 | 换Upsample+Conv2d或PixelShuffle |
| 结果看着不错但重复度高 | 隐变量多样性不足 | 增大z_dim,尝试更多随机采样 |
最后再分享一点个人经验
我在第一次跑这个项目时,吃过最大的亏就是只盯着终端里打印的loss数字,结果模型早在第20个epoch左右就出现模式崩溃,我还以为训练顺利。后来养成习惯:每200个iteration就把生成图打印一次,哪怕只是草草地看一眼网格图,也比单看loss靠谱得多。另外一个经验是固定随机种子。GAN训练对初始噪声很敏感,同一套代码结果可能每次跑都不一样。用torch.manual_seed(42)固定住初始条件,调参时才能判断是代码改动了效果还是随机性在起作用。如果后续想继续深入,可以试试在这个模型上加条件信息,把MNIST的标签一起喂给生成器,那就是Conditional GAN的方向,能指定生成某个数字,是一个很自然的扩展玩法。先把基础GAN调稳,后面所有变体都会顺手很多。