在我刚开始接触生成模型的时候,第一个上手的项目就是GAN生成手写数字,数据集用的正是MNIST——很多文章喜欢把名字简写成MINST,其实正确写法是MNIST,全称Modified National Institute of Standards and Technology database,里面收集了几万张28×28的手写数字灰度图。这个项目看起来简单,但把GAN的对抗训练机制、损失函数设计、超参调节和训练稳定性问题全都能走一遍,特别适合作为入门生成模型的第一个里程碑。这篇文章就从原理到代码、从踩坑到调参,把我做这个项目的过程和心得完整整理出来,给正在学习GAN、准备做课程项目或者复现论文实验的同学参考。
1. GAN的核心思想与设计思路
1.1 生成器与判别器的对抗博弈
GAN的全称是Generative Adversarial Network,中文一般叫生成式对抗网络。它由两个神经网络组成:一个是生成器(Generator),负责把随机噪声变成看起来像真实数据的样本;另一个是判别器(Discriminator),负责判断输入到底是真实数据还是生成器伪造的假数据。
要理解这个结构,最常用的类比是造假者和鉴定师。生成器就像一个造假者,天天琢磨怎么造出以假乱真的画作;判别器就像经验丰富的鉴定师,努力分辨眼前这幅画是真迹还是赝品。造假者每造出一幅新作品,都会拿去给鉴定师过目,鉴定师指出破绽,造假者就根据反馈改进行骗手法。如此循环往复,最终造假者造出来的画可能连鉴定师都分不清真假——放到GAN里,就是生成器生成的样本已经和真实数据在分布上几乎无法区分了。
这里有个关键点:生成器并不直接"看到"真实数据,它只通过判别器的反馈来调整自己。这意味着生成器学到的不是简单地复制某张图片,而是学会"真实数据长什么样",也就是真实数据背后的概率分布。而判别器始终在提供"这次还差多远"的梯度信号,引导生成器一步步逼近目标。
1.2 对抗训练为什么能学会数据分布
从数学上看,生成器把服从某个简单分布(比如标准正态分布)的随机噪声向量z映射到数据空间,得到G(z)。理想情况下,我们希望生成样本的分布P_g能无限接近真实数据的分布P_data。判别器则是一个二分类器,输入一个样本x,输出它来自真实数据的概率D(x)。
训练过程可以看成一个两人博弈:生成器想让D(G(z))尽量接近1,也就是骗过判别器;判别器想让D(x)对真实样本输出接近1、对生成样本输出接近0。两者在对抗中共同进化,最终达到纳什均衡——判别器再也分不清真假,输出恒等于0.5,此时生成器就完美复刻了真实数据分布。
为什么对抗训练能做到这一点,而不是生成器随便学个能骗过当前判别器的简单模式?关键在于判别器也在不断变强。生成器想骗过判别器,就必须让自己的输出在判别器关注的特征空间里和真实数据对齐。判别器每次发现新的差异点,都会迫使生成器去补上这个短板,于是生成器被迫从粗糙的模仿逐步走向精细的拟合。这个动态博弈的过程,就是GAN能学会复杂数据分布的本质原因。
1.3 值得了解的经典变体
在我做MNIST项目之前,也简单调研过GAN的大家族,这里提几个最常见的变体,方便大家建立坐标系:
- DCGAN:用卷积和转置卷积代替全连接层,稳定性和生成质量比原始GAN高很多,图像生成任务的首选基线。
- WGAN / WGAN-GP:通过更换损失函数(Wasserstein距离)缓解训练不稳定的问题,对超参不那么敏感。
- CGAN:在生成器和判别器中都加入类别标签条件,可以实现"指定生成某个数字/某个类别"的效果。
- StyleGAN:在特征Style层面做控制,生成人脸效果极好,但结构复杂、训练成本高,不适合入门。
这些变体的基础都是原始GAN的对抗框架,先把MNIST这个最简单的全连接版实现跑通,再往上加卷积、加条件、换损失,就会顺利很多。我见过不少同学一上来就啃StyleGAN代码,被一堆模块绕晕,最后连基础对抗逻辑都没搞清楚,这是走了弯路。
2. 损失函数与训练原理拆解
2.1 从极大极小博弈到二分类交叉熵
原始GAN的目标函数是一个极小极大博弈,写成公式就是:
min_G max_D V(D, G) = E_{x~P_data}[log D(x)] + E_{z~P_z}[log(1 - D(G(z)))]
这个公式看着唬人,拆开其实很直白。判别器要做的事情是最大化这个V:对于真实样本x,希望log D(x)大,即D(x)接近1;对于生成样本G(z),希望log(1 - D(G(z)))大,即D(G(z))接近0。生成器要做的事情恰好相反,它要最小化V,也就是让log(1 - D(G(z)))小,让D(G(z))接近1,骗过判别器。
为什么用log而不是直接用概率值?因为log函数在概率接近0的时候梯度特别大,能给生成器提供更强的学习信号。如果直接用线性概率,早期生成器生成的图片完全不像样,判别器一眼识破,这时梯度会很小,模型学不动。log的存在让"错得很离谱"的样本也能得到足够大的梯度来推动改进。
放到PyTorch里,这个目标函数就对应二分类交叉熵损失(BCELoss)。判别器训练时用真实标签1和真实图片算一遍、用假标签0和生成图片算一遍,各取BCE再相加;生成器训练时则用真实标签1去骗判别器的输出,让D(G(z))尽量接近1。理解了这个对应关系,看后面的代码就不会晕。
2.2 模型结构中的关键设计细节
模型结构看着简单,但每个地方都有讲究。生成器方面,输入是latent_dim维的噪声向量,我通常设100维,来自标准正态分布。中间用若干全连接层逐步升维,激活函数用ReLU,每层后面接BatchNorm1d来稳定训练,最后一层用Tanh把输出压缩到[-1, 1]区间。
为什么要用Tanh而不是Sigmoid?因为MNIST预处理时我会把像素值归一化到[-1, 1],Tanh的输出范围正好匹配这个区间。如果用Sigmoid(输出[0,1]),就需要把真实数据也归一化到[0,1],不是不行,但Tanh配合[-1,1]是图像GAN的通行做法,能更好地保留梯度信息。
判别器方面,输入是784维的展平像素向量,中间层用LeakyReLU激活。这里不用普通ReLU是因为ReLU在负数区域梯度为0,判别器很容易"死掉";LeakyReLU保留了负半轴的少量梯度(斜率一般取0.2),能防止神经元死亡。每层之间加Dropout做正则化,防止判别器过强。
还有一个细节:生成器最后一层和判别器第一层通常不做BatchNorm。原因是BN会在batch层面建立相关性,而生成器的输出是逐样本独立的,对最后一层做BN容易产生batch之间的伪影;判别器第一层输入分布变化剧烈,做BN反而引入不稳定。这些经验不是我拍脑袋想的,全是实际训练中对比出来的。
2.3 超参数选择与背后的逻辑
超参数是GAN训练的灵魂,先给一张我实测下来比较稳的参数表:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 优化器 | Adam | 对GAN这类非凸博弈问题,Adam比SGD稳得多 |
| 学习率 | 0.0002 | 原始GAN论文和DCGAN的标配,基本够用 |
| beta1 | 0.5 | Adam一阶矩衰减系数,默认0.9会导致训练震荡 |
| batch size | 128 | 太小则梯度噪声大,太大会降低更新频率 |
| latent_dim | 100 | 噪声向量维度,图像数据集通用选择 |
| 训练轮数 | 200 | MNIST全连接版200轮足够看到好效果 |
beta1要改成0.5而不是默认的0.9,这是DCGAN论文里验证过的关键调参。默认的0.9会让Adam在梯度方向变化大时保留太多的历史动量,而GAN训练中两个网络交替更新,梯度方向经常剧烈变化,动量大会导致震荡甚至发散。0.5相当于更"健忘",能更快适应当前的对抗形势。
除此之外,还有一个原则:生成器和判别器的学习率一般保持一致。如果给判别器更高的学习率,它会迅速变得过于聪明,生成器的梯度直接消失;反过来生成器学太快,又会找到某种"骗过当前判别器但质量很差"的捷径。保持对称、交替更新,是基础GAN训练的默认节奏。
3. MNIST手写数字生成:从零实现
3.1 环境准备与数据加载
我用的是PyTorch,版本2.x,配合torchvision加载MNIST数据集。装好torch和torchvision之后,数据加载部分非常简短:
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms import torchvision.utils as vutils import matplotlib.pyplot as plt import numpy as np transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize([0.5], [0.5]) ]) train_dataset = datasets.MNIST( root='./data', train=True, download=True, transform=transform ) train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') print('Using device:', device)这里transform里两个操作要分开理解。ToTensor()把PIL图像转成Tensor,像素值从[0,255]变成[0,1];Normalize([0.5], [0.5])做的是(x - 0.5) / 0.5,把像素值映射到[-1,1]。这个[-1,1]区间不是为了好看,而是为了和生成器最后接的Tanh激活函数对齐。很多新手在这里栽跟头:生成器输出范围是[-1,1],真实数据范围是[0,1],两者根本对不上,判别器随便一看就能找出分布偏移,训练直接崩。
训练时每轮迭代都从train_loader拿一个batch的真实图片,shape是[128, 1, 28, 28],稍后要展平成[128, 784]喂给全连接判别器。MNIST是单通道灰度图,不用像CIFAR或ImageNet那样考虑三通道。
3.2 生成器与判别器的代码实现
模型部分我直接用全连接网络,结构简单、训练快,适合讲清楚原理。生成器代码如下:
class Generator(nn.Module): def __init__(self, latent_dim=100, img_dim=784): super(Generator, self).__init__() self.img_dim = img_dim self.fc = nn.Sequential( nn.Linear(latent_dim, 256), nn.ReLU(inplace=True), nn.BatchNorm1d(256), nn.Linear(256, 512), nn.ReLU(inplace=True), nn.BatchNorm1d(512), nn.Linear(512, 1024), nn.ReLU(inplace=True), nn.BatchNorm1d(1024), nn.Linear(1024, img_dim), nn.Tanh() ) def forward(self, z): return self.fc(z)生成器从100维噪声一路升到1024维,最后输出784维。中间每一层都用ReLU加BatchNorm的组合。BatchNorm在这里起到两个作用:一是让每层输入分布更稳定,二是防止生成器一开始输出太集中导致模式崩塌。判别器的结构则做了完全不同的设计:
class Discriminator(nn.Module): def __init__(self, img_dim=784): super(Discriminator, self).__init__() self.fc = nn.Sequential( nn.Linear(img_dim, 512), nn.LeakyReLU(0.2), nn.Dropout(0.3), nn.Linear(512, 256), nn.LeakyReLU(0.2), nn.Dropout(0.3), nn.Linear(256, 1), nn.Sigmoid() ) def forward(self, x): return self.fc(x)判别器输出一个标量概率,经过Sigmoid压到(0,1)之间,配合BCELoss使用。Dropout只在判别器里加,是为了给它"制造些麻烦",防止它过快碾压生成器。如果你在实验中感觉生成器迟迟学不会,可以适当调大判别器的Dropout或者降低它的学习率,让"鉴定师"不要那么敏锐,给"造假者"留出成长空间。
3.3 训练循环:交替更新的完整流程
训练的核心是交替更新判别器和生成器,每一轮迭代先训练判别器,再训练生成器。代码实现如下:
criterion = nn.BCELoss() lr = 0.0002 beta1 = 0.5 generator = Generator(latent_dim=100).to(device) discriminator = Discriminator().to(device) optimizerG = optim.Adam(generator.parameters(), lr=lr, betas=(beta1, 0.999)) optimizerD = optim.Adam(discriminator.parameters(), lr=lr, betas=(beta1, 0.999)) num_epochs = 200 for epoch in range(num_epochs): for i, (imgs, _) in enumerate(train_loader): current_batch = imgs.size(0) real_imgs = imgs.view(current_batch, -1).to(device) # ---------- 更新判别器 ---------- discriminator.zero_grad() real_labels = torch.ones(current_batch, 1).to(device) fake_labels = torch.zeros(current_batch, 1).to(device) real_loss = criterion(discriminator(real_imgs), real_labels) z = torch.randn(current_batch, latent_dim).to(device) fake_imgs = generator(z) fake_loss = criterion(discriminator(fake_imgs.detach()), fake_labels) d_loss = real_loss + fake_loss d_loss.backward() optimizerD.step() # ---------- 更新生成器 ---------- generator.zero_grad() z = torch.randn(current_batch, latent_dim).to(device) fake_imgs = generator(z) g_loss = criterion(discriminator(fake_imgs), real_labels) g_loss.backward() optimizerG.step() if i % 100 == 0: print(f'Epoch [{epoch}/{num_epochs}] Step [{i}/{len(train_loader)}] ' f'D Loss: {d_loss.item():.4f} | G Loss: {g_loss.item():.4f}')这里有几个容易忽略的细节。第一个是fake_imgs.detach(),训练判别器时,生成器的参数不需要被更新,所以要把生成图片从计算图中分离出来,否则反向传播会把梯度传到生成器参数上,造成"顺带更新"的错误。第二个是生成器训练时复用了同一批fake_imgs但没有detach,因为此时我们需要梯度流回生成器,让G根据D的判定来更新参数。第三个细节是生成器的标签用全1(real_labels),这是"骗"的策略:生成器的目标是让判别器把假图判成真图,所以标签设为目标类别1,而不是让fake的D输出逼近0。
每个epoch结束可以保存一次生成的图片做可视化,我习惯把固定的一组噪声向量z_fixed在每个epoch喂给生成器,这样能看到同一个起点上的样本随着训练不断变清晰的过程。
3.4 结果可视化与保存
可视化部分很简单,用torchvision的make_grid把batch的生成图片拼成网格,再通过matplotlib保存:
def save_images(generator, epoch, output_path='./generated'): generator.eval() with torch.no_grad(): z_fixed = torch.randn(64, latent_dim).to(device) gen_imgs = generator(z_fixed).view(64, 1, 28, 28).cpu() grid = vutils.make_grid(gen_imgs, nrow=8, normalize=True) plt.imsave(f'{output_path}/epoch_{epoch:03d}.png', grid.numpy().transpose(1, 2, 0)) generator.train()注意保存时用normalize=True,make_grid会把tensor范围重新映射到可视化友好的区间。训练结束之后,你会看到一组从纯噪声逐渐变成清晰数字的序列图——这个过程非常直观,能明显感受到"造假的水平在提高"。另外建议把每个epoch的D Loss和G Loss记录到列表里,最后画成曲线,观察两者的对抗变化趋势。
4. 训练中的常见问题与排查实录
4.1 模式崩塌:生成样本千篇一律
我训练中遇到过最典型的问题就是模式崩塌(Mode Collapse),具体表现是生成器造出来的图片反复只有某几个数字,甚至只有一种字形,多样性严重不足。比如整个batch里全是同一个数字的相似变体,看着像复读机。
原因在于生成器找到了一个能骗过当前判别器的"捷径":既然某个数字最容易骗过人,那就只生成这个数字,反正判别器也能过。GAN的博弈如果不加约束,生成器确实有动机收敛到这种低风险的局部最优。
对付模式崩塌我有几个实际经验:第一,把学习率调小一点,慢一点让生成器"固化";第二,适当增强判别器的能力,让它能识别出"你总是生成同一种图";第三,最有效的是在Z向量中加入更多随机性,并坚持使用较大的latent_dim。如果问题严重,可以上一些专用方法,比如Mini-Batch Discrimination,让判别器看到整个batch的多样性后再下判断,或者试验Unrolled GAN。不过在MNIST这个简单任务上,只要按我给的参数跑,崩塌概率不高,真遇到了先调lr和Dropout。
4.2 判别器太强:生成器梯度消失
另一种常见情况是判别器的loss很快降到接近0,而生成器的loss居高不下甚至越学越差。这是典型的判别器过度碾压:它太聪明了,一眼看穿所有假图,导致生成器从"被骗真相"中几乎拿不到有效梯度,停滞在随机噪声状态。
我遇到过最直观的场景是:D Loss在几十个epoch后就掉到0.001以下,G Loss却稳定在9以上,生成的图片像电视雪花点,毫无结构。这时候应该立刻做三件事:第一,把判别器的Dropout从0.3提高到0.5,给它加约束;第二,降低判别器学习率(比如从0.0002降到0.0001),让"鉴定师"进步慢一点;第三,考虑标签平滑(Label Smoothing),把真实标签从1换成0.9,给判别器留出"判断余地",防止它对真实样本过于自信导致决策边界过陡。
4.3 生成器太强:判别器loss趋零
和上一情况相反,有时判别器会率先崩溃,D Loss一路跌到接近0,而生成器的图片看着还行或者也开始变差。这说明生成器已经能稳定骗过判别器,但判别器失去了指导能力,后续训练就变成"盲人摸象"。
这种状态下,可以想办法给判别器"松绑":降低Dropout、提高判别器学习率、增加判别器的网络容量。更根本的做法是引入WGAN的Wasserstein损失,它能提供比二分类交叉熵更平滑的梯度信号。我个人的做法是:如果只是MNIST实验,直接保存当前生成器效果、停止训练即可,因为模型可能已经处于一个视觉上不错但无法继续优化的阶段;如果是更大项目,通常配合梯度惩罚(WGAN-GP)重写损失函数。
4.4 如何判断训练是否成功:别只看loss
新手常犯的错误是把G Loss和D Loss当作训练好坏的直接度量。实际上GAN的loss曲线不能像普通分类任务那样解读,因为两个玩家你追我赶,loss本身是交替起伏的。我见过训练最终效果很好,但loss曲线一直在震荡的情况;也见过loss曲线很平滑,但生成图片全糊的例子。
更可靠的判断方式是看生成图片的视觉质量和多样性:图片是否清晰、是否有多种数字、是否有结构崩塌的伪影。进阶一点可以计算FID(Fréchet Inception Distance)或Inception Score,不过在MNIST这种小任务上,肉眼检查加简单统计(比如用训练好的分类器统计生成图片的数字类别分布是否均匀)就够用了。我个人习惯是每隔25个epoch保存一组图片,最后拼成一张大图看整体演化趋势,比盯着一堆loss数字直观得多。
这里把常见问题整理成速查表,方便大家对照:
| 症状 | 可能原因 | 优先尝试 |
|---|---|---|
| 生成图片单一重复 | 模式崩塌 | 调小lr、增强D、增大latent_dim |
| D loss过低,G学不动 | 判别器太强 | 提高D的Dropout、调低D的lr、标签平滑 |
| D loss趋零,G图片尚可 | 判别器崩溃 | 降低D的Dropout、调高D的lr、考虑WGAN |
| G和D都停在0.69左右 | 训练没进展 | 检查数据预处理是否[-1,1]、确认模型是否收敛 |
| 图片有噪声但轮廓正确 | 训练早期 | 继续训练,观察后续epoch |
5. 实操心得与进阶方向
5.1 从全连接到卷积:向DCGAN演进
把全连接版跑通之后,我做了一次升级:把生成器和判别器都换成卷积结构,也就是DCGAN。生成器用转置卷积把100维噪声逐步扩成28×28图像,判别器用普通卷积提取特征再输出概率。实测下来,DCGAN的收敛速度更快,生成的字形也更锐利,这是因为卷积核天然利用了图像的局部空间结构,而全连接层会把每个像素当作独立特征,信息利用效率低很多。
升级过程中有一个让我印象深刻的坑:转置卷积很容易产生棋盘伪影(Checkerboard Artifacts),图像上出现明显的格纹。原因是转置卷积在学习过程中对kernel size和stride的组合敏感,stride为2且kernel size无法被stride整除时,某些位置的像素会被不均匀地覆盖。解决办法是先用上采样(Upsample)加普通卷积替代转置卷积,或者把kernel size设成stride的偶数倍。这个细节建议做DCGAN的朋友重点留意。
5.2 进阶之路:条件生成与更多玩法
跑通无条件GAN之后,我给生成器和判别器都加上了数字类别标签,改造成了条件GAN(CGAN)。改动其实不大:把one-hot的label向量和噪声向量拼接后输入生成器,同时把label向量和图像展平向量拼接后输入判别器。训练好后就能指定生成"某个数字"的图片,比如让生成器画一个"7",效果很直观,也让我第一次感受到"可控生成"的魅力——这也为后来理解扩散模型、文本到图像生成等更复杂任务打下了很好的基础。
另一个推荐的方向是把BCELoss换成WGAN-GP损失。MNIST上可能感受不深,但当你切换到更复杂的数据集(比如人脸、街景)时会体会到:普通GAN在训练中期经常出现loss崩坏,而WGAN-GP因为用了Wasserstein距离和梯度惩罚,训练过程要平稳得多。我建议在学习路线上分三步走:先吃透原始GAN的对抗逻辑,再动手改DCGAN,最后用WGAN-GP对比训练稳定性变化。
5.3 学习顺序与资源建议
给新手的学习顺序建议是这样:先把本文的全连接版代码复现一遍,改一改latent_dim、学习率、网络层数,观察训练过程的差异——很多参数感悟只有亲手改了才知道;然后去读原始GAN论文和DCGAN论文,重点不是看公式,而是看作者对训练技巧的分析;接着跑DCGAN和CGAN,试着加一些模块,比如类别条件、不同的激活函数;最后再碰WGAN或StyleGAN这类复杂模型。
开源社区里有大量现成的GAN实现了,但我还是建议至少在MNIST上自己把训练循环完整写一遍。GAN的代码量不大,最难的是理解"两个网络交替更新、各自有各自的目标、共享同一批数据"这套逻辑。我把这个逻辑亲手写顺了以后,再看任何GAN变体,基本都能很快抓住它的改动点在哪儿。练完MNIST再练其他任务,你会发现很多生成模型的通病和处理思路其实是互通的。
最后分享一个小技巧:如果你在训练中断或者想调参重来,不要急着从头跑200轮,先加载上次保存的模型权重继续训练。GAN的训练对随机种子的依赖比普通模型大得多,换个seed可能重新训练的效果完全不同,所以在关键实验上固定seed(比如设为42),你会少很多"这次怎么又不收敛"的困惑。另外,每50个epoch备份一次生成器的权重和对应的固定噪声生成图,这样即使后来训练崩了,也能回溯到某个效果不错的时间点。这些习惯看起来不起眼,但实际做项目时真的能帮你省下大量重复训练的时间。