ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GAN训练不稳定的9个工程技巧:从损失函数到优化器全解

GAN训练不稳定的9个工程技巧:从损失函数到优化器全解

我第一次正经训练生成对抗网络(GAN)的时候,心里想的是“把损失函数写好,模型就能自己跑出结果”。现实很快就打脸了:判别器 loss 半路归零,生成器 loss 涨成一条竖线,生成出来的图要么是一团噪点,要么练到八十个 epoch 之后还是同一张脸。后来我把能找到的 tricks 一个个试过去,才发现 GAN 稳定训练根本不是某一个“神奇参数”,而是一整套互相咬合的工程习惯。这篇文章把我后来反复验证过的 9 个技巧一次性讲清楚,每一节都会说明原理、给出可以直接抄的配置,再附上我在实际训练里踩过的坑。无论你是在调 DCGAN、WGAN-GP,还是在写自己的生成模型,这 9 个点基本都通用。

1. 先把生成器与判别器的网络结构搭对,再谈稳定

1.1 技巧1:用卷积堆叠替代全连接,把池化彻底拿掉

很多刚接触 GAN 的人,习惯从最简单的全连接网络开始:两个 MLP 对打,在手写数字这类简单数据上勉强能出轮廓,但换到稍微复杂一点的数据集,问题立刻暴露。参数量失控、空间结构信息被压扁,生成器只能学到“像素统计特征”,学不到“物体结构特征”,训练当然稳不住。DCGAN 那批工作很早就给过结论:网络结构要按图像任务的习惯来搭,而不是直接把分类网络拿过来改。

这里有三条操作纪律,我建议直接写进代码规范里:

  • 生成器里用转置卷积做上采样,kernel size 设为 4 或 5。这样每次空间尺寸翻倍时,卷积核能覆盖足够的感受野,避免生成图出现明显的棋盘格纹路。
  • 判别器里不要用最大池化或平均池化做下采样,改成 stride=2 的卷积。卷积下采样是可学习的,它会在训练中自动找到更适合当前任务的降维方式,不会像池化那样硬生生丢掉纹理信息。
  • 除了最终的分类输出头,中间不要放全连接层。全连接层会把二维空间位置彻底打平,判别器一旦失去空间感知能力,就特别容易被生成器钻空子。

还有一个特别容易踩的坑:判别器的容量不能比生成器强太多。我在实际实验里见过不少训练崩溃的案例,问题根本不在损失函数,而在判别器的卷积核数量给得太多,把生成器的回传梯度直接“踩死”。一个比较保守的做法是,生成器每一层的通道数设为判别器同层的 1.5~2 倍,让两边保持一个“勉强能对抗、但谁也压不死谁”的状态。

顺带说一句,如果生成图像分辨率超过 128×128,只在基础结构上调还不够,建议在中间层加残差连接或者自注意力模块。残差连接能让梯度跨层流动得更顺畅,自注意力则能捕捉全局依赖,这两者对高分辨率训练的稳定性帮助非常明显。

1.2 技巧2:归一化层的位置比种类更关键,BatchNorm 不是乱放的

BatchNorm 几乎是 GAN 的标配,但“加了 BN”和“加对了 BN”是两回事。BN 做的是按 batch 维度统计均值和方差,再把特征拉回标准分布。生成器里,BN 一般放在转置卷积之后、激活函数之前,目的是让每一层输出的分布更平稳,避免生成器在训练早期剧烈震荡。

判别器里的 BN 就讲究多了。我见过很多人的判别器第一层就放 BN,这其实是个隐患:判别器的输入是真实样本和生成样本的混合,两者分布差异本来就大,BN 会在 batch 内把这种差异进一步放大,导致判别器“学得太快、分得太清”,生成器梯度迅速消失。常见的做法是:判别器第一层不加 BN,从第二层开始加。我在某个人脸生成实验里把判别器第一层的 BN 去掉之后,生成器的梯度信号立刻恢复正常,训练五十轮之后生成质量明显上了一个台阶。

再补充一个平时容易被忽略的归一化:PixelNorm。它不按 batch 统计,而是对每个像素位置的所有通道一起做归一化。当你的显卡只能跑到 batch size 8 或者 16 的时候,BatchNorm 的统计量会因为样本太少而剧烈抖动,这时候 PixelNorm 反而不依赖 batch 内统计,表现会更稳。我的建议是:batch size 小于 16 时,生成器用 PixelNorm 替换 BN;判别器保留 BN,但仍然从第二层开始放。这个组合在很多数据集上都很稳。

2. 标签与损失函数的小改动,能直接改写对抗斜率

2.1 技巧3:标签平滑——不要总把目标写成 1.0 和 0.0

如果你在判别器输出后面接 Sigmoid 加二元交叉熵,真实样本的目标值通常是 1.0,生成样本是 0.0。问题在于,Sigmoid 函数在输入接近正负无穷时梯度趋近于 0。判别器一旦快速收敛,把真实样本和生成样本分得特别开,生成器拿到的回传梯度就几乎为零,训练直接假死。

标签平滑的思路特别简单:把真实样本的目标从 1.0 改成 0.9,把生成样本的目标从 0.0 改成 0.1。目标值不再冲到极端区间,Sigmoid 的输入就不会太大,梯度能一直保持在一个可用量级。如果用 PyTorch,可以直接在BCEWithLogitsLoss里传label_smoothing=0.1;手写损失的话,就把真实目标的张量替换成 0.9。

实际使用中有三个细节:

  1. 平滑幅度不要太大。我见过有人把真实标签平滑到 0.7,结果判别器过于“宽容”,生成器反而不着急了,收敛明显变慢。
  2. 生成样本的标签可以用 0.1~0.2,不必非要 0.0,这样判别器的决策边界会更柔和。
  3. 标签平滑只改判别器的目标,生成器那边的目标仍然设为 1.0,不需要平滑,否则生成器的学习信号会变弱。

这个改动成本几乎为零,收益却立竿见影。我在某个公开数据集上试过,同样的代码,只把真实标签平滑到 0.9,训练到第六十轮的时候判别器 loss 不再归零,生成器 loss 曲线也平滑了很多。

2.2 技巧4:把交叉熵换成最小二乘损失,生成器会“持续有信号”

如果说标签平滑是小修,那换损失函数就是大改了。交叉熵损失天然有个问题:对于已经被正确分类的样本,它不再提供有效梯度。放在对抗训练里就是——一旦生成器暂时骗过了判别器,生成器就会“躺平”,梯度消失,训练停滞。

最小二乘 GAN(LSGAN)的思路是,把判别器的输出当成回归目标,让它直接逼近 1 和 0,而不是用 Sigmoid 把输出打到正负无穷。损失形式一般是这样的:

# 判别器 d_loss = 0.5 * torch.mean((d_real - 1) ** 2) + 0.5 * torch.mean(d_fake ** 2) # 生成器 g_loss = 0.5 * torch.mean((d_fake - 1) ** 2)

注意此时判别器最后不要接 Sigmoid,直接输出线性值。这样做的好处非常直观:不管样本被分类得对不对,都会产生梯度,生成器永远不会因为“骗过了判别器”而彻底失去信号。我在同样的网络结构下对比过,LSGAN 比 BCE 在中后期训练里的震荡幅度小很多,生成器 loss 突然飙升的现象明显减少。

不过 LSGAN 对离群点比较敏感,如果数据集里本身存在大量标注噪声或异常样本,建议配合标签平滑一起用,同时控制一下训练数据的干净程度。再提醒一句:如果代码里用的是原始 GAN 的判别器结构,切换到 LSGAN 之后,最后一层的初始化scale也可以适当调小一点,线性回归输出的数值范围比 Sigmoid 更敏感,初始化太激进会导致早期 loss 巨大。

3. 优化器的那几个“反直觉”超参数,关键时刻能救命

3.1 技巧5:学习率按 batch 补偿,beta1 从 0.9 改到 0.5

这一部分最容易被忽略,却往往见效最快。普通分类网络里,Adam 的默认配置是lr=1e-3, beta1=0.9, beta2=0.999。但 GAN 训练里如果直接照搬,大概率会看到生成器 loss 频繁震荡甚至发散。

原因有两个。第一,GAN 的优化目标是极小极大博弈,判别器和生成器的梯度方向经常相反。动量 beta1 如果太大,历史梯度信息会一直扰动当前的更新方向,导致参数在最优解附近反复横跳。第二,GAN 对学习率极其敏感,普通网络能接受的大学习率,在 GAN 里很容易让更新步长跨过“平衡点”。DCGAN 原论文给出的标准配置是lr=2e-4, beta1=0.5,这是经过大量实验得到的经验值,不是随手写的。

这里有个可以推而广之的小思路:修改 batch size 时,学习率要跟着按比例调整。batch 从 64 改成 128,学习率可以适当增大,但不要直接翻倍,1.5 倍左右比较安全;batch 从 64 降到 32,学习率降到 1e-4。这个规则虽然不精确,但能让你在换显存之后不用从头试错。

还有一个容易被忽略的细节:生成器和判别器可以设置不同的学习率。判别器学得太快是训练崩溃的主要来源之一,我一般把判别器的学习率设为生成器的 0.5~0.8 倍,给生成器留出追赶的空间。

3.2 一张可以直接抄的 Adam 配置表

下面这个配置范围,是我实际训练里经常用的起点,不同数据集和网络规模可能会有微调,但作为默认值足够可靠:

参数推荐值备注
优化器Adam多数场景比 SGD 和 RMSProp 稳定
生成器学习率2e-4batch=64 时的常见起点
判别器学习率1e-4 ~ 1.5e-4略低于生成器,防止判别器过强
beta10.5兼顾动量和稳定性
beta20.999一般保持默认
学习率调度线性/余弦衰减后期降低训练振荡,缓解模式坍塌
权重初始化N(0, 0.02)常见初始化,配合 BN 效果更稳

把学习率做成线性衰减或者余弦衰减,也能明显减少训练后期的抖动。我一般从第 150 轮开始把学习率线性衰减到原始值的 1/10,最后几十轮生成质量还能持续改善。

4. 判别器与生成器的更新节奏:先会打架,才会合作

4.1 技巧6:不要每轮同步更新——让判别器先多走两步

很多人在实现 GAN 时默认“判别器更新一次,生成器更新一次”,这在简单数据集上可能没问题,但一旦数据分布复杂起来,判别器会迅速变得比生成器聪明,生成器梯度消失,训练进入假死状态。我的习惯是:每更新一次生成器,先让判别器更新 2~3 次。这相当于给判别器做一个“预热”,让它的判别能力维持在一个刚刚好的水平。

在 PyTorch 风格里可以这样控制训练节奏:

for epoch in range(epochs): for batch in dataloader: # 判别器先更新多次 for _ in range(d_steps): d_loss = update_discriminator(batch) # 生成器再更新 g_loss = update_generator(batch)

判别器更新次数不是越多越好。如果d_steps超过 5,判别器很容易过拟合到当前 batch,梯度方向失去泛化意义。我建议从d_steps=2开始试,看生成器 loss 是否平滑,如果不稳再往上调。

还有一个更细腻的做法:隔 batch 更新判别器。也就是不是每个 batch 都更新判别器,而是每隔一个 batch 更新一次,这样能进一步错开两边的学习节奏。在 batch size 很小、显存不够的情况下特别有效。

4.2 判别器太强了怎么办:从指标到处理手段

判别器过强是“训练不稳定”最典型的前兆。怎么判断?看训练日志里真实样本和生成样本的判别准确率。如果真实样本的判别准确率快速逼近 99%,同时生成样本的判别准确率也逼近 99%,说明判别器已经把两个分布彻底分开了。这时候生成器的梯度就像没头苍蝇一样乱撞,loss 曲线会出现典型的锯齿状。

处理手段优先级大概是这样的:

  1. 暂时冻结判别器,跳过一次或两次判别器更新,给生成器一点喘息时间。
  2. 降低判别器学习率,比如从 1e-4 降到 5e-5。
  3. 给判别器最后一层加 Dropout,概率设 0.3 左右。这一步对抑制判别器过拟合很有帮助,而且对生成器几乎无副作用。

我自己最常用的是第三条,因为改动最小、效果直接。判别器过强的本质是容量和训练节奏不匹配,Dropout 相当于给判别器加了一点“不确定性”,让它不敢把边界画得太绝,生成器因此能拿到更连续的梯度。

5. Lipschitz 约束、输入噪声与固定噪声监控:三个隐形保险丝

5.1 技巧7:谱归一化与梯度惩罚的选型,别迷信其中一个

判别器本质上是一个函数:输入图像,输出分数。如果这个函数“太陡”,图像上一点微小的像素变动就会导致分数剧烈跳动,生成器得到的梯度就会很不稳定。所谓 Lipschitz 约束,就是限制这个函数的变化速率不超过某个常数。

谱归一化是实现这种约束的低成本方式:每次前向传播时估计权重矩阵的谱范数,然后把它归一化。主流深度学习框架里通常一行代码就能搞定:

from torch.nn.utils import spectral_norm conv = spectral_norm(nn.Conv2d(3, 64, 4, stride=2, padding=1))

谱归一化最大的优点是不引入新的超参数,也没有额外的损失项,稳定性提升非常明显,特别适合用 Sigmoid 交叉熵的原始 GAN 和条件 GAN。梯度惩罚(gradient penalty)则是另一种思路,它通过在判别器输出对输入的梯度上增加惩罚项,引导判别器保持在 Lipschitz 约束内。梯度惩罚多一个惩罚系数需要调,一般取 10。

选哪个?我的经验是:谱归一化适合做“默认选项”,稳定、无超参;梯度惩罚更适合你已经在使用 WGAN-GP 框架、愿意调惩罚系数的场景。还有一个组合用法:判别器主干用谱归一化,输出层附近再加一个轻柔的梯度惩罚,这个组合在梯度回传的稳定性上表现最好,代价是训练耗时更高。

5.2 技巧8:给判别器输入加高斯噪声,对抗的边缘别太锋利

判别器通常先于生成器学会分类,而判别的边界越锋利,生成器在边界附近拿到的梯度就越容易出现跳变。给判别器的输入加上小幅高斯噪声,可以让它学到的边界平滑一些。这不是为了让判别器变笨,而是让梯度信号在边界附近保持连续。

噪声幅度要随时间衰减。训练初期判别器还很弱,噪声幅度可以给大一点,比如 0.1 或 0.05;随着训练推进逐步衰减到 0.001。一个常见的实现方式:

def add_noise(images, alpha): noise = torch.randn_like(images) * alpha return images + noise

我在实际项目里验证过,加了输入噪声之后,同一个模型既避免了早期梯度消失,又在中后期保持了更平稳的 loss 曲线。有一点要提醒:噪声加在判别器的输入,不是加在判别器的中间特征上,更不是加在生成器的输出上。位置搞错了,效果会完全不同。

5.3 技巧9:固定一个噪声向量,把它当成训练仪表盘

最后一个技巧,也是工程层面最实用的技巧:稳定性练到最后,一定得是“可观测的”。在训练开始前,固定一个随机噪声向量,每隔一定轮数让生成器用这个固定向量生成样本,保存下来,训练结束之后像回放视频一样从头看一遍。

这个做法能同时暴露两个问题:

  • 如果生成样本在前 20 轮就稳定下来,之后再也不变化了,说明大概率陷入了模式坍塌。
  • 如果生成样本在每个保存节点都剧烈突变,从一个结构完全跳成另一个结构,说明训练震荡严重,需要回头查学习率或判别器节奏。

除了肉眼观察固定噪声图,还可以配合记录真实样本和生成样本在判别器中间层的特征均值误差。这个误差如果持续居高不下,很可能是模式坍塌的前兆。我训练时一定会开一个曲线日志,把判别器 loss、生成器 loss、特征误差、固定噪声样本可视化放到同一个看板里。一旦发现异常,马上降低学习率或冻结判别器,比等训练跑完再回头从头再来省的时间不是一点半点。

如果要从这些经验里提炼一句最想说的,那就是:GAN 的稳定性不是靠某一招,而是架构、损失、优化器、更新节奏和监控手段互相配合的结果。我自己现在排查训练崩溃的顺序已经固定下来了:先看固定噪声图;如果图完全不变,去查标签平滑和损失函数;如果图剧烈跳变,再回头看学习率和判别器的更新频率。这套流程练熟之后,定位一个稳定性问题基本在半小时以内。如果你刚开始调 GAN,建议先从改动量最小的三条下手——标签平滑、beta1 改成 0.5、固定噪声监控,这几步几乎不需要重写代码,却往往最容易见效。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进