1. GAN到底在做什么:对抗训练的核心思路
先聊个认知上的东西。很多刚接触GAN的同学,第一眼看到"对抗网络"这四个字,下意识觉得这是一个特别复杂、特别高深的模型,门槛肯定很高。实际上它背后的道理非常简单:你和造假者之间的一场博弈。
造假者负责伪造你喜欢的画作,鉴定师负责辨别真假。造假者一开始水平稀烂,画出来的东西一眼假;鉴定师稍微认真一点就能识破。但每次失败后,造假者会琢磨:"这次是哪里露馅了?轮廓太粗糙?颜色不对?比例失调?"然后针对性地改进。鉴定师也在进步:"上次我根据轮廓判断,这次他轮廓学像了,我得看看光影细节。"双方互相逼着对方提升水平。等到这场博弈收敛的时候,造假者画出来的东西已经足以以假乱真,鉴定师再也分不出真假了——这就是GAN训练好之后的样子。
放在深度学习语境里,造假者就是生成器(Generator,通常缩写为G),它负责把一段随机噪声变成一张以假乱真的图像。鉴定师就是判别器(Discriminator,通常缩写为D),它负责判断输入图像是真来自数据集,还是假来自生成器。两者共用同一个损失景观,交替优化,最终达到纳什均衡的近似状态。
这套思路最早被提出时,学界高分贝地追捧,因为它解决了一个此前生成模型很难突破的问题:如何让模型学会数据分布,而不需要显式地设计密度函数或复杂的概率推断。传统自编码器、变分自编码器虽然也能生成图像,但输出往往模糊、缺乏锐利度。GAN用"鉴别器作为学习信号"的方式,绕开了显式密度估计,直接用对抗博弈逼模型逼近真实分布,因此生成的图像天然更锐利、更逼近真实细节。
这篇内容针对的是想从零开始把GAN跑起来、并且搞懂每一行代码背后逻辑的读者。我会用一个相对完整但不冗长的PyTorch实现为主线,从设计思路、网络结构、损失函数、训练循环、调参经验到常见坑位全部走一遍。建议你在阅读时同时打开编辑器跟着写,光看不敲,很快会忘。
所谓"超详细"不是堆概念,而是把每一段代码、每一个超参数选择的原因都讲明白。后面你会发现,GAN的代码量其实不大,复杂的是训练过程中的各种玄学问题,以及你为应付这些问题所做的各种工程化调整。
2. 代码能跑之前的准备:环境、数据集与网络结构设计
2.1 运行环境与依赖选择
先说我用的环境:Python 3.9,PyTorch 1.12,CUDA 11.3。实际上PyTorch 2.x完全兼容这套代码,版本差异不大。依赖就那么几个:torch、torchvision、numpy、matplotlib。
很多初学者栽在第一个坑:环境装不对,后面全是眼泪。我的建议是直接用Anaconda建虚拟环境,别在基础环境里折腾。创建环境这一步看起来多余,但GAN训练经常要反复调整库版本,虚拟环境能让你玩坏了随时重来。
conda create -n gan_lab python=3.9 conda activate gan_lab pip install torch torchvision numpy matplotlib有条件用GPU的同学,训练速度会快几十倍,CPU也不是不能跑,只是建议把图像分辨率调小,迭代轮次减少,先验证流程通不通。跑通了再上完整配置。
2.2 数据集的选取:从玩具数据集起步
实验用数据集我选的是公开的手写数字数据集(MNIST)。原因很简单:28x28灰度图,结构简单,训练速度快,非常适合验证GAN的收敛情况。
我经常跟别人说,不要一上来就挑战人脸数据集或者高分辨率街景数据。你在手写数字上跑不动、训练失败、图像崩坏,到复杂数据集上大概率也是同样问题,而且还更难调试。先用简单数据集把对抗训练的直觉建立起来,比什么都重要。
关于数据预处理,只有一个关键动作:把像素值归一化到[-1, 1]区间。为什么是[-1, 1]而不是[0, 1]?因为生成器最后一般用tanh激活函数,它的输出范围是[-1, 1],只有让真实数据也落在这个区间,判别器才不至于依靠"像素范围不同"这种低级特征作弊。
再看一眼数据加载代码:
transform = transforms.Compose([ transforms.ToTensor(), # [0,1]归一化 transforms.Normalize((0.5,), (0.5,)) # (x - 0.5) / 0.5 => [-1,1] ]) dataset = torchvision.datasets.MNIST( root='./data', train=True, transform=transform, download=True) dataloader = torch.utils.data.DataLoader( dataset, batch_size=64, shuffle=True, num_workers=4)注意Normalize((0.5,), (0.5,)),mean=0.5且std=0.5,算完就把[0,1]映射到了[-1,1]。batch_size取64比较中庸,太大判别器更新频率相对下降,太小梯度噪声大。这些细节后面训练部分还会细聊。
2.3 生成器设计:从噪声到图像的升采样路径
生成器本质是一个升采样网络:输入一个低维随机向量,输出一张图像。核心操作是转置卷积(ConvTranspose2d),配合批量归一化和ReLU激活。
这里有个新手非常容易犯的错误:以为转置卷积就是"反卷积",能把卷积恢复成原始输入。实际上转置卷积只是形状上的逆操作,数值上并不会还原卷积的输出。它做的事情是把每个输入像素扩展成一个区域,通过可学习的权重叠加出更大尺寸的特征图。理解了这个,你调试生成图像尺寸的时候就不会懵。
我给的生成器结构如下:
class Generator(nn.Module): def __init__(self, latent_dim=100): super().__init__() self.model = nn.Sequential( # 输入: [N, 100] → 转成 [N, 256, 7, 7] nn.Linear(latent_dim, 256 * 7 * 7), nn.BatchNorm1d(256 * 7 * 7), nn.ReLU(inplace=True), nn.Unflatten(1, (256, 7, 7)), # 7x7 -> 14x14 nn.ConvTranspose2d(256, 128, 4, 2, 1, bias=False), nn.BatchNorm2d(128), nn.ReLU(inplace=True), # 14x14 -> 28x28 nn.ConvTranspose2d(128, 1, 4, 2, 1, bias=False), nn.Tanh() ) def forward(self, z): return self.model(z)重点解释几个设计决策:
第一,latent_dim选100。这是从业界经验里继承下来的常见取值,100维足够表达一定的数据变异度,又不会高到让映射变稀疏。有人用128,也有人用256,差别不大。你完全可以实验。
第二,每组转置卷积之前都接BatchNorm。BatchNorm的作用是稳定特征分布,避免某一层的输出过大或过小,这对训练容易崩塌的GAN来说是刚需。但它要求batch_size不能太小,否则统计量不稳定,所以batch_size也不建议低于32。
第三,最后一层用Tanh而不是Sigmoid或ReLU。前面说过,Tanh让输出落在[-1,1]区间,和真实数据预处理后的范围对齐。如果你改成ReLU,生成图像会大量出现全黑区域,就是因为网络倾向于输出0附近的负值被ReLU截断。
坊间有一张经典的DCGAN结构图,把生成器的中间通道数和卷积核参数标注得很清楚。如果你照着DCGAN论文来,生成器里每个ConvTranspose2d层的kernel_size=4、stride=2、padding=1,这个配置能让特征图尺寸精确翻倍。想自己改分辨率的同学,务必先算清楚这组超参对尺寸的影响:H_out = (H_in - 1) × stride - 2 × padding + kernel_size,代入7、2、1、4,得到(7-1)×2 - 2 + 4 = 14。对,刚好翻倍。
2.4 判别器设计:二分类器的标准套路
判别器的任务简单得多:输入一张图,输出一个0到1之间的真伪得分。所以它是一个降采样网络,两层卷积加全连接,最后一层用Sigmoid。
class Discriminator(nn.Module): def __init__(self): super().__init__() self.model = nn.Sequential( # 输入: [N, 1, 28, 28] -> [N, 128, 14, 14] nn.Conv2d(1, 128, 4, 2, 1, bias=False), nn.LeakyReLU(0.2, inplace=True), # [N, 128, 14, 14] -> [N, 256, 7, 7] nn.Conv2d(128, 256, 4, 2, 1, bias=False), nn.BatchNorm2d(256), nn.LeakyReLU(0.2, inplace=True), nn.Flatten(), nn.Linear(256 * 7 * 7, 1), nn.Sigmoid() ) def forward(self, x): return self.model(x)这里面有几个细节,很多人跑通代码后也不会注意:
第一,判别器用LeakyReLU且负斜率设0.2。生成器用普通ReLU,判别器却用LeakyReLU,为什么?因为判别器如果用了ReLU,负区间的梯度全会变成0,网络更新就容易死掉。LeakyReLU让负数部分保留一个小梯度,避免神经元死亡。这算是GAN训练中"判别器更强壮"的工程手段之一。
第二,第一层卷积没有接BatchNorm。这个说法不止出现在一篇流派文章里,DCGAN作者也遵循类似原则。原因是BatchNorm会把输入数据的分布强行拉向均值0方差1,如果第一层就做归一化,判别器可能丢掉原始像素的均值、方差等信息,导致判别能力下降。很多实测也表明第一层不加BatchNorm收敛更稳定。
第三,判别器输出只有1个神经元,经过Sigmoid得到概率。你也可以不加Sigmoid,配合后面的BCEWithLogitsLoss一起用,数值上更稳定。我为了讲起来直观,显式加了Sigmoid,损失函数就用BCELoss。
3. 核心实现:损失函数、优化器与对抗博弈的落地
3.1 损失函数为什么是二元交叉熵
GAN的目标函数在论文里被写成一个极小极大问题:
min_G max_D V(D,G) = E[log D(x)] + E[log(1 - D(G(z)))]
看着吓人,拆开看就是二分类交叉熵的两项。
判别器的目标:对真实图像,让D(x)尽量接近1;对生成图像,让D(G(z))尽量接近0。所以判别器的损失是两项相加,第一项是真图的BCE,第二项是假图的BCE。生成器的目标则反过来:让D(G(z))尽量接近1,也就是骗过判别器。
代码里用torch.nn.BCELoss实现:
criterion = nn.BCELoss()然后训练循环里分别构造真假标签。这里有一个实操中常用的技巧:标签平滑(Label Smoothing)。不要把真实标签设成严格的1,而是设成0.9或0.8左右的随机浮动值。这么做可以降低判别器过拟合的风险,减少梯度的剧烈震荡。后面我会在训练部分展示用法。
3.2 优化器选择:Adam是标配,但不是随便用
两个网络都使用Adam优化器,学习率设0.0002,beta1设0.5。这里beta1默认值是0.9,但GAN训练里几乎所有实践者都会手动改成0.5。为什么?
因为beta1控制着动量项的衰减速度。0.9意味着梯度历史要累积非常久,在GAN这种稳定需要小心伺候的博弈场景下,过长的动量记忆容易导致震荡甚至训练崩溃。0.5让动量更短视,反而有助于收敛。这也是DCGAN论文里明确提到的经验。
另外,两个网络的学习率可以不一致。很多改进方案里判别器学习率更低,生成器学习率稍高,用于抑制判别器过强。对我来说,从0.0002对称起步,之后再按情况进行非对称调整,是比较稳妥的做法。
lr = 0.0002 g_optim = optim.Adam(gen.parameters(), lr=lr, betas=(0.5, 0.999)) d_optim = optim.Adam(dis.parameters(), lr=lr, betas=(0.5, 0.999))3.3 对抗训练的完整循环拆解
整段训练代码其实就是一个循环里交替做三件事:更新判别器、更新生成器、记录损失。重点在于更新顺序和梯度清零。
标准伪代码如下:
for epoch in range(epochs): for i, (real_images, _) in enumerate(dataloader): batch_size = real_images.size(0) real_images = real_images.to(device) z = torch.randn(batch_size, latent_dim, device=device) fake_images = gen(z) # ---------- 1. 更新判别器 ---------- dis.zero_grad() real_valid = dis(real_images) fake_valid = dis(fake_images.detach()) d_loss = criterion(real_valid, real_labels) + criterion(fake_valid, fake_labels) d_loss.backward() d_optim.step() # ---------- 2. 更新生成器 ---------- gen.zero_grad() fake_valid = dis(fake_images) g_loss = criterion(fake_valid, real_labels) g_loss.backward() g_optim.step()这里有一个非常值得展开的细节:第19行,更新生成器时,我们再次把fake_images送进判别器,但没有调用dis.zero_grad()之前,梯度早就通过上一个步骤d_loss.backward()累积在判别器参数上了。所以每次更新前必须清理梯度缓存,否则两个网络的反向传播会互相污染,梯度累积到一个完全失控的状态。
还有detach()这一步。第11行计算判别器损失时,传入fake_images.detach(),目的很明确:切断反向传播到生成器的梯度路径。因为我们当前只想更新判别器,不想让这一轮反向传播顺藤摸瓜去更新生成器。如果不detach,PyTorch会沿着计算图一路回传到生成器,使得生成器也被更新一次,这不但不符合我们先修判别器的意图,还会让两边的更新节奏乱套。
我建议初学者把这段代码亲手敲三遍,敲完之后你就对detach、zero_grad、backward、step这些操作的生效对象有一个肌肉记忆级别的理解。说得夸张一点,能默写这一段,GAN就掌握了三四成。
4. 训练过程:三种损失排序、标签平滑、可视化检查
4.1 训练日志的读取:不是看Loss高低,而是看Loss关系
训练GAN和训练分类网络完全不一样。分类网络的loss下降,说明模型在变好;GAN的loss上升下降并不能直接说明模型在变好,关键要看判别器损失和生成器损失的相对关系。
一个健壮训练的典型特征是:两个损失围绕某个水平线小幅波动,谁也不彻底压过谁。如果判别器损失突然一路狂跌到趋近0,那几乎肯定意味着生成器崩了——生成的图像太假,判别器不费吹灰之力就能识别。如果生成器损失狂跌但判别器损失狂涨,则意味着判别器已经废了,生成器不管生成什么都骗得过它,生成图像大概率也非常辣眼。
我习惯每50个batch打印一次损失,把epoch拉长到50到100轮,观察损失曲线的形态。早期阶段生成器损失有上升趋势其实是好事,说明它在变强,判别器对它的惩罚在加大。
4.2 特征匹配与标签平滑:为了稳定收敛的工程手段
除了标准对抗损失,业界还有一个很常见的增强:特征匹配(Feature Matching)。做法是把判别器倒数第二个全连接层的输出当作特征,让生成器去匹配"真图特征"和"假图特征"之间的均值差异。简单说,判别器除了给出真伪概率,还额外给出一个特征向量;生成器的目标变成"让假图的特征分布靠近真图的特征分布"。
这个做法的优点很明显:提供了一条比对抗信号更平滑的梯度路径,极大降低训练不稳定性。代价是生成图像的锐利度可能略下降,因为对抗信号被稀释了。
我先不把特征匹配写进主线代码里,因为新手阶段还是要先建立"纯对抗训练"的直觉。把基础版本调通后,再往里面揉特征匹配,才是正确节奏。
标签平滑的具体实现非常简答:
real_labels = torch.full((batch_size,), 0.9, device=device) fake_labels = torch.zeros(batch_size, device=device)注意生成器的目标依然可以理解为"把假图标签骗成接近1",所以生成器那边用的标签仍然是强度1。只不过判别器那边的真图标签用0.9。这样一个小改动,很多情况下能让训练从"震荡到崩溃"变成"缓慢但稳定地收敛"。
4.3 图像输出的可视化:裸跑代码等于盲操作
训练GAN务必全程可视化输出。我见过太多新手训练了10个小时,只看一个loss值,完全不知道模型生成图像是什么质量。因为GAN每一轮生成的图像变化非常明显,这种反馈对调参是决定性的,你只看数字根本没法快速定位模式崩塌、塌缩等问题。
在每个epoch结束之后,用同样的一个固定噪声向量fixed_noise生成一批图片,拼成网格保存下来:
with torch.no_grad(): gen.eval() fake_grid = gen(fixed_noise).detach().cpu() grid = torchvision.utils.make_grid(fake_grid, nrow=8, normalize=True) save_image(grid, f'outputs/epoch_{epoch}.png') gen.train()为什么要固定同一个噪声向量?因为如果每次都用新采样的噪声,你看到的两张图之间的差异既包含了模型的变化,也包含了输入的随机变化,你就无法判断模型是否真的在稳定地学习。固定噪声向量后,你看到的每一轮迭代输出,纯粹反映模型参数的演化,这才是有效的可视化。
我的经验习惯是:前30个epoch每轮保存一次,后面可以每5轮保存一次。初期变化大,值得高频记录;后期变化小,保存太多都是占空间。
5. 训练中的坑:常见问题与排查实录
5.1 模式崩塌(Mode Collapse):生成来生成去就那几个图案
模式崩塌是GAN最经典的一大难题。表现是生成器发现某一个伪造模式特别容易骗过判别器,于是就一直产生这个模式,多样性急剧下降。放在手写数字上,就是模型只学会生成几个特定数字,其他数字一概不会。
我排查的思路是:先看噪声输入z是否起了作用。把同一个噪声向量重复喂给生成器十次,如果输出完全一致,那基本可以确认网络已经退化到输入无关的状态了。这种情况网络结构和训练方式肯定有严重问题。
常用的应对手段包括:
- 减小学习率,别让生成器更新得太激进;
- 给判别器加Dropout,削弱它的记忆能力,让生成器有更多喘息空间;
- 引入小批量判别(Mini-batch Discrimination),让判别器可以同时对比一批样本的多样性;
- 采用WGAN的梯度惩罚策略,从损失函数层面缓解崩塌。
对初学者,我建议先按顺序尝试前两种,成本低、效果明显。
5.2 判别器太强/太弱的平衡问题
第二个高发问题是判别器和生成器的能力严重不对等。判别器太强,生成器无论怎么改进都会立刻被识破,梯度因为过于确定而方向单一,生成器什么都学不到。判别器太弱,生成器随便画一张模糊图都能骗过判别器,它就没有动力继续提升。
平衡之道前面已经埋了伏笔:不与BatchNorm一层、LeakyReLU、Adam的beta1=0.5都是让判别器能力"收着点"的手段。如果你发现判别器损失长期接近0,可以考虑三个方向:
- 下调判别器学习率,让它学得慢一点;
- 给判别器增加权重衰减(weight_decay),限制模型复杂度;
- 减少判别器的层数或者通道数,降低它拟合能力。
需要强调一点,"判别器太弱"有时候不单指网络规模,也指训练频率。如果你每一轮迭代都让判别器更新两次、生成器更新一次,效果等同于加强判别器。默认对称的1:1更新比例更适合入门;等熟悉了再根据情况调整这个比例。
5.3 梯度消失与梯度爆炸速查表
我整理了一个排查速查表,基本上你跑GAN时遇到的九成问题都能在里面找到方向:
| 现象 | 可能原因 | 优先尝试方案 |
|---|---|---|
| 判别器Loss趋近0 | 判别器过强 | 降低判别器学习率、减少层数、加Dropout |
| 判别器Loss持续高位 | 生成器过强 | 降低生成器学习率、增强判别器 |
| 生成图像模糊 | 网络容量不足或特征匹配抑制锐度 | 增加通道数、缩短训练周期观察 |
| 生成图像全是噪点 | 输出没有Tanh / 激活函数失配 | 修正最后一层为Tanh |
| 生成图像多样性极差 | 模式崩塌 | 标签平滑、Mini-batch Discrimination |
| 损失剧烈震荡 | 学习率过大 | 从0.0002往下调,或换WGAN |
这个表是我自己在多个项目里沉淀出来的,参考价值比长篇理论大得多。碰到问题先看表,对症下药,见效最快。
6. 从玩具到实用:主流改进方向与选择建议
6.1 DCGAN:稳定卷积GAN的基石
前面代码里其实已经大量借鉴了DCGAN的设计,比如用ConvTranspose2d、LeakyReLU、BN层、Adam的beta1=0.5。DCGAN可以说是把GAN从"能跑"推向"能稳定训练"的关键一步,它系统地提出了卷积GAN的结构设计准则,让后续研究有了可靠的基础。
现在的项目很少照抄DCGAN本身,但几乎所有图像GAN的工程实现都沿用了DCGAN的设计哲学。我的建议是:如果你要搭建自己的GAN,默认网络结构可以先用DCGAN风格开局,然后根据实际数据逐项调整。
6.2 WGAN-GP:缓解不稳定的一剂强药
WGAN及其改进版WGAN-GP是我在实际项目中觉得最有价值的一类变体。它把判别器从"二分类器"改成"评分器"输出一个分数,然后用Wasserstein距离作为优化目标,特别使用梯度惩罚来约束判别器的Lipschitz连续性。
换成工程语言:去掉最后一层Sigmoid,损失函数改用WGAN损失(对真图求期望减对假图求期望),同时给判别器参数加一个梯度惩罚项。做了这些改动之后,训练稳定性会大幅提升,模式崩塌概率也显著下降。很多工业项目里直接就用WGAN-GP替代原始GAN。
它的代价是梯度惩罚计算会额外引入一些算力开销,而且需要额外调一个惩罚系数lambda,我习惯取默认值10。
6.3 cGAN / Conditional GAN:让生成可控
以上讨论的都是无条件生成:给定一个随机噪声,输出一张图,但你无法控制是数字"3"还是"8"。条件GAN(Conditional GAN)解决的就是这个问题,具体做法是把类别标签作为额外输入,同时送入生成器和判别器。
在训练代码层面,改动也不复杂。生成器输入从z变成[z, label],把标签先做Embedding再和噪声拼接。判别器输入变成[image, label],同样在通道维度上拼接标签的特征表示。这种设计在诸多实际场景里几乎是标配:超分辨率、图像翻译、文本生成图像都有它的影子。
给一个最小参考:
class ConditionalGenerator(nn.Module): def __init__(self, latent_dim=100, num_classes=10, img_size=28, channels=1): super().__init__() self.label_embedding = nn.Embedding(num_classes, latent_dim) self.model = nn.Sequential(...) # 结构与之前一致,但输入已知是 concat(z, label_emb) def forward(self, z, labels): label_emb = self.label_embedding(labels) z = torch.cat([z, label_emb], dim=1) return self.model(z)有条件生成是让GAN从"炫技玩具"走向"实际应用"的重要一步,我建议你在把无条件版本完全跑通之后再开始折腾它。
6.4 训练策略小结:从技术选型到资源规划
我把过去几个项目里用着最顺的训练策略做个汇总,作为参考基线:
- 数据集层面:先小后大,用小数据集验证曲线形态。
- 网络结构层面:DCGAN骨架起步,优先稳定训练,再谈提升画质。
- 超参数层面:Adam,lr=0.0002,beta1=0.5,batch_size=64。
- 训练策略层面:标签平滑开启,固定噪声可视化,50个epoch起步。
- 问题排查层面:损失关系优先,图像形态其次,最后才调整网络结构。
这个基线的训练效率不一定最高,但它是"最不容易翻车"的组合。跑通之后,你再根据GPU显存条件调整分辨率、通道数和epoch数量。
7. 写在最后:一点实战经验分享
我练手GAN的第一个项目是拿手写数字做生成,当时天真地以为只要把代码贴上去就能稳定收敛。实际上第一次训练跑了400轮,生成的图像仍然是一坨模糊的噪点。后来我发现问题出在三处:第一,生成器最后一层没用Tanh,导致输出图像范围错乱;第二,判别器第一层加了BatchNorm,表面上看loss下降很快,实际上判别器早就退化了;第三,学习率设成0.01,比推荐的0.0002大太多,梯度震荡到几乎无法收敛。
这三处问题逐一修正之后,第二次训练在30轮左右就看到了清晰数字的雏形。从那以后我养成了一个习惯:任何GAN项目先不要追求高大上的改进方案,先用最朴素的DCGAN基线跑通,保留下每一轮的损失和图像,再一步步调整。这个习惯救了我大把的时间。
最后再分享一个小技巧:如果你跟我一样在远程服务器上训练,记得用torchvision.utils.save_image定时保存图像结果,再配合TensorBoard的add_image接口把图像直接记录到可视化的面板里,而不是每次等到训练结束才去看结果。毕竟GAN训练崩盘是常事,你越早发现模型崩掉,越早干预调整,浪费的GPU时间就越少。
这大概就是关于GAN代码实现流程,我全部想说的内容了。代码不多,但里面的每一条经验几乎都来自被坑之后的复盘。希望这篇记录能帮你稍微少踩几个坑。