做生成模型的这几年,我花在生成对抗网络(GAN)训练上的时间,有一半是在跟 nan loss 和模式崩塌打架。判别器(D)和生成器(G)互相博弈,听起来很完美,但真正跑起来就像两个人在悬崖边推搡:方向对了,步子一乱就双双坠崖。这篇文章不聊密集推导,直接把我实际调参时验证过、真正能稳住训练过程的 9 个 tricks 拆开讲清楚,包括每个 trick 解决的问题、为什么有效、参数怎么选、以及搭配时的坑。适合那些已经在跑 GAN、但被训练崩溃折磨到想换模型的人。
1. 核心问题:GAN 为什么这么容易训崩
1.1 训练不稳定的三种典型表现
我在不同的任务里见过 GAN 翻车,总结下来逃不出三种症状。第一种是梯度消失:D 太强,G 的梯度直接趋近于零,loss 纹丝不动,生成结果永远是噪声。第二种是振荡:D 和 G 的 loss 像心电图一样上下乱跳,每分钟都在互爆,训练 100 轮后画面毫无进展。第三种最烦人,就是模式崩塌——G 找到了 D 的漏洞,只用几样相同或者极其相似的样本来骗过 D,你翻看生成结果会发现,几百张图看起来像同一个东西。
这三种症状其实来自同一个病根:D 和 G 的博弈是动态的,任何一方跑得太快,另一方就会掉队,训练就成了单向碾压。更麻烦的是,GAN 本身的优化目标并不是简单的最小化某个 loss,而是一个极小极大博弈,传统深度学习里的收敛理论在这里基本帮不上忙,所以只能靠工程手段来“托底”。
1.2 9 大 tricks 的分类思维
别把这 9 个 tricks 当成孤立的技巧,我习惯把它们分成三条路线。第一条是“改造损失函数与梯度”:让 D 的输出不要那么自信,让 G 拿到的梯度信号更平缓,对应标签平滑、谱归一化、梯度惩罚。第二条是“重塑 G 的学习目标”:让 G 不只是钻 D 的空子,而是真正模仿真实数据的特征分布,对应特征匹配、小批量统计、历史平均。第三条是“管理训练流程与优化器”:从更新频率、学习率、样本重复使用层面控制博弈节奏,对应不对称更新、经验回放、噪声注入。
有这个分类,你遇到问题时能快速定位:如果是梯度消失,优先走第一条;如果是模式崩塌,优先走第二条;如果是整体振荡,第三条方案最有效。下面逐个展开。
2. 前三招:从损失与梯度层面稳住 GAN
2.1 标签平滑:让判别器别那么“自信”
如果你刚跑完一个标准的 DCGAN,一定熟悉 D 的 loss 一路猛降到接近于 0 的场景。这时候 D 对真实样本的预测概率接近 1.0,对生成样本接近 0.0,它太确定自己在做什么了。问题在于,D 的过度自信会让它对所有生成样本都产生极强的梯度,G 在这种“狂风暴雨”里一步都挪不动,梯度直接消失。
标签平滑的处理非常简单:把真实标签从 1.0 改成 0.9,把假标签从 0.0 改成 0.1,然后正常计算交叉熵。这等于告诉 D:“你不用把话说死,留一点余地。” 我试过把真实标签设为 0.9、假标签设为 0.1,训练稳定性提升非常明显,尤其是在用 ReLU 作为 D 激活函数的网络里。
注意一点:平滑幅度太大也不行。我曾经把真实标签直接设成 0.8,结果 D 判别力严重下降,G 觉得自己已经赢了,生成结果变得模糊。一般建议真实标签在 0.9 附近,假标签在 0.1 附近,如果你用的损失函数不是标准交叉熵,比如 LSGAN 的最小二乘损失,标签平滑的写法要相应调整。
2.2 谱归一化:给判别器上一把无情的锁
谱归一化是我工作中最常用的标准化手段。它解决一个数学问题:如果 D 是一个神经网络,理论上它可能拥有任意大的 Lipschitz 常数,也就是说输入像素稍微变化,输出可能剧烈变化。这让 G 很难通过梯度信号找到稳定方向,训练自然就崩了。
谱归一化的做法是:在每次前向传播之前,估计权重矩阵的最大奇异值,然后把整个权重矩阵除以这个值,强制矩阵的谱范数不超过 1。这样做之后,D 的每层映射都不会过度放大输入变化,整个网络对外界扰动的响应就被限制住了,梯度信号会更平滑、更可控。
具体实现时不需要真的做完整奇异值分解,那太慢了。通常用幂迭代法,每次前向只要迭代一两次就能拿到足够近似的最大奇异值,我用下来感觉对训练速度的影响可以接受。这个方法对比原始的权重裁剪有个天然优势:裁剪需要手动设阈值,不同的任务适应不同数值;谱归一化则不用设超参数,接入现有网络就可以跑。
不过有一点必须提醒:谱归一化会让 D 的 loss 数值看起来“普遍偏高”,这是正常的,因为 D 的能力被主动限制了。别看到 D 收敛不到很小的 loss 就以为它没在训练,关键看 G 的输出是否在变好。
2.3 梯度惩罚:把梯度范数掰回安全区
梯度惩罚是 WGAN-GP 这一系列改进的核心思想之一,曾让大面积图像生成任务稳定了不少。它针对的是真实数据和生成数据之间那条“插值路径”上的梯度行为:不约束 D 对所有输入的梯度,只约束它对“真实样本和生成样本连线上”的梯度范数接近 1。
实际代码里,我会随机在真实样本和生成样本之间取一个插值权重,格式大概是 epsilon 在 0 到 1 之间,然后得到插值样本,D 对插值样本的输出再对输入求梯度,计算梯度范数与 1 的差的平方,最后乘以系数 lambda 加到损失里。lambda 通常取 10,这是我在多个任务里验证过比较稳的默认值。
这里有个新手容易犯的错:在 D 和 G 的损失里都加梯度惩罚。正确做法是只在 D 的损失里加,因为约束对象是 D 的梯度,G 完全不需要这个惩罚项。加了之后 D 的梯度不会突然爆炸,G 能稳定且持续地获得高质量学习信号。另外,如果你已经使用了谱归一化,梯度惩罚这一项可以适当降低权重,甚至不做,两者功能有重叠,叠加时需要小心训练速度变慢。
3. 中间三招:让生成器真正向数据分布靠拢
3.1 特征匹配:给生成器一个“可追赶”的目标
原始 GAN 里,G 的梯度完全来自 D 最后一层的判断,这其实是很脆弱的信号——只要 G 生成的样本在某一个方向骗过了 D,G 就失去了改进动力。特征匹配的思路是:不让 G 直接跟 D 的最终判断较劲,而是让 G 的中间层特征尽量接近真实样本经过 D 时产生的中间层特征。
具体来说,我会取 D 某个中间层的输出 f,计算真实样本输入通过该层得到的特征平均值,再计算生成样本通过该层得到的特征平均值,然后让 G 去最小化这两类特征均值之间的均方误差。这相当于告诉 G:“你不用骗过 D 的最后判断,你只要让自己的内部特征像真实数据就行。” 我做过一个实验,训练只要跑到后期,特征匹配往往能持续降低 FID,不容易出现突然崩溃。
需要注意:特征匹配适合作为 G 的辅助损失,不适合完全替代对抗损失。如果只靠特征匹配,G 会倾向生成“平均脸”一样的保守样本,多样性会下降。最佳实践是把特征匹配项乘一个权重,加在原来的对抗损失旁边,权重一般 0.1 到 1.0 之间,根据效果调整。
3.2 小批量统计:打破独立样本生成的僵局
模式崩塌有一个明显的特征:G 把每个批次中的样本当成独立个体来生成,完全不考虑“这批样本整体的多样性”。标准 GAN 的 D 也是逐样本判断真假,它意识不到一个批里面样本是否重复。当 G 输出几乎相同的图像且 D 判断为假时,G 的梯度可能会让它在“这个模式”上继续加强,于是崩塌越来越严重。
小批量统计(Minibatch Statistics)通过给 D 提供整个批次的统计信息来解决这个问题。实现时,我会在 D 中间某个特征层,计算该批次内所有样本在某几个通道上的标准差,再把标准差作为额外的特征拼接到每个样本上。这样一来,如果 G 生成一个批次全是相似样本,这些标准差就会趋近于 0,D 便能敏锐地发现“这批样本太单调了”,然后对 G 施加强烈的多样性惩罚。
后续的 StyleGAN 等模型里也常能看到类似思想的变体,比如在判别器尾部加入 minibatch standard deviation 层。它的计算成本很低,却能高效抑制模式崩塌,我强烈推荐默认加上。
3.3 历史平均:给优化过程加个阻尼器
如果你观察训练曲线,会发现 G 和 D 的 loss 像是两个互相追逐的兔子,经常出现周期性振荡。振荡的根源是:参数更新只依赖于当前批次的梯度,而当前的梯度可能被噪声放大,造成大步抖动。历史平均的思想很朴素:在原本的损失函数里加一项“当前参数与历史参数均值之差的平方”。
我常用的写法是:维护一个参数的历史均值,在每一步更新时,把当前参数和历史均值的 L2 距离乘以一个权重加入损失。实际代码里我通常记录过去若干步的平均值,或者用指数滑动平均。这个做法可以显著抑制振荡,让 G 的学习过程更平滑。缺点是会稍微增加参数更新计算量,同时如果权重设置太大,会让模型收敛变慢。权重范围我一般设在 1e-3 到 1e-2,需要根据任务微调。
4. 最后三招:从训练流程、优化器和随机性上做文章
4.1 不对称学习率与更新频率:谁能打,谁多练
训练 GAN 时,D 和 G 的能力差距如果太大,训练就不稳定。我见过最典型的失败案例是:G 一开始太弱,D 完美分辨真假,G 拿到的是接近零的梯度,从此再也没有翻身机会。解决方案之一是让 D 比 G 多更新几次,比如每更新 1 次 G 就更新 3 到 5 次 D,让 D 保持足够好的判别力。
但这里有个反直觉的点:在 SNGAN 等模型里,社区也常用 G 和 D 学习率不对称的做法,通常 D 的学习率低于 G,比如 D 为 1e-4,G 为 2e-4。我一开始很困惑,后来想明白了,更新频率高是让 D 保持“敏锐”,而学习率低是防止 D 单次步长过大导致过拟合。两者并不矛盾,关键是不同架构要调不同组合。我建议你先用 D 更新 5 次、G 更新 1 次再加一个 D 学习率小于 G 的设置跑 300 轮,然后观察生成质量曲线是否平稳。
另外,优化器参数同样重要。Adam 的 beta1 默认是 0.9,但在 GAN 里我一般设成 0.5,因为 0.9 会把历史梯度的动量积累得太强,容易冲过头。beta2 设为 0.999 基本不用动。很多人跑崩 GAN 的原因不在模型结构,而在于用了默认的 beta1,生成结果抖成筛子。
4.2 经验回放:让生成器见见世面
经验回放最早在强化学习领域大规模使用,后来被引入 GAN。它做的事情特别简单:维护一个队列,保存 G 过去生成的样本,在训练 D 时,不只拿当前 G 生成的新样本,还随机从队列里取一部分旧样本一起参与训练。
为什么要这样做?因为 D 的记忆很短暂。它只看到当前生成结果,万一大批旧模式在 500 步前出现过,D 早就忘了,G 就会反复产出已经骗过 D 的老模式。经验回放相当于给 D 一个“长期记忆”,让假样本的判别标准更稳定。我一般是让队列保存最近几千到两万个样本,每批次 50% 用最新样本,50% 从回放队列里采样。
注意内存开销:如果生成图像分辨率高,比如 256x256,两万个样本很容易占掉大量内存。我的做法是只存生成图的 latent 或者低分辨率缩略图,或者干脆压缩回放队列的长度,不要追求极致数量,否则容易把训练内存撑爆。
4.3 噪声注入:给网络留出“试错”空间
随机性听起来跟稳定性矛盾,但在 GAN 里,适度的噪声注入反而是稳定训练的润滑剂。这种方法通常是给 D 的输入加一点高斯噪声,或者给 G 的输出加一点噪声,噪声强度随时间逐步下降。它的原理和“模拟退火”有点像:训练早期,梯度方向太锐利,网络容易困在尖锐的局部震荡里;加入噪声可以让梯度平滑,网络能跳出坏区域,随着训练推进,噪声变小,网络逐步精细化。
我试过给 D 的输入加标准差 0.01 到 0.05 的高斯噪声,然后按每轮线性衰减到 0,效果是训练速度平稳不少。还有一种变体,给 G 的隐藏层加扰动,能让 G 的分布不至于过于集中,等于给模式崩塌加了一重物理屏障。注意噪声强度别太大,否则图像产生雪花点,后期衰减要耐心,我习惯用 cosine 衰减而不是线性,后半程更细致。
5. 实战搭配与参考实现要点
5.1 不同场景下,9 大 tricks 的组合策略
这 9 个技巧并不是越多越好。我自己按场景分了四套常用组合。
- 场景一:小数据集、256 以下分辨率,用 DCGAN 风格架构。我会优先加标签平滑、谱归一化、不对称更新频率和特征匹配。小数据集最怕 D 过拟合,谱归一化和标签平滑能压住 D 的暴力判别,特征匹配给 G 提供更稳的梯度。
- 场景二:大规模高清图像,比如 512 以上。我对梯度惩罚、噪声注入、经验回放更有信心,再加小批量统计防崩塌。高清任务里 D 的感受野和输入像素差异都很敏感,梯度惩罚能稳定 D 的梯度尺度,经验回放可以避免风格回溯。
- 场景三:训练已经中途开始崩溃。我通常会保留谱归一化和梯度惩罚,同时加入历史平均,把学习率调低并增大 D 的更新次数,先稳住再推进。
- 场景四:追求极致的 FID 与样本多样性。我会在生成器侧使用特征匹配和小批量统计,同时严格控制标签平滑系数不要太大,否则多样性受损。
5.2 一个参考实现的伪代码要点
我在这里给一个组合方案的训练循环伪代码,它不是完整可运行代码,但点出了关键操作的位置,方便你自己接入。
# 假设已有 D、G、Adam优化器(beta1=0.5) # replay_buffer 维护 G 的旧样本 for step in range(total_steps): # 1. 先预训练 D,多次更新 for d_iter in range(5): real = sample_batch_from_dataset(batch_size) fake = G(sample_noise(batch_size)).detach() # 把 fake 的一部分替换成 replay_buffer 里的旧样本 if replay_buffer.size() > 0: mixed_fake = sample_batch_from_replay(replay_buffer, batch_size // 2) fake = concat(fake[:batch_size // 2], mixed_fake) # 2. 梯度惩罚只在 D 损失里算 epsilon = random.uniform(0.0, 1.0, shape=real.shape) interp = epsilon * real + (1 - epsilon) * fake grad = gradients(D(interp), interp) gp = mean((sqrt(sum(grad**2, axis=1)) - 1)**2) * lambda_gp d_loss = - (logit_hinge_loss(real, fake) + gp) d_loss.backward() d_opt.step() # 3. 把当前部分 fake 存入 replay_buffer replay_buffer.add(fake.detach().numpy()) # 4. 更新 G fake = G(sample_noise(batch_size)) g_loss = logit_hinge_loss_fake(fake) # 常规对抗 loss # 特征匹配辅助项 fm_loss = mse_loss(D_feature(real), D_feature(fake)) g_loss = g_loss + 0.1 * fm_loss g_loss.backward() g_opt.step()这段伪代码把最重要的几个操作混在一起演示:D 多步更新、经验回放、梯度惩罚、特征匹配辅助、Adam beta1。实际使用时你要根据自己的框架把 loss 形式换成对应的实现,比如把 logit_hinge_loss 换成 BCE 也能跑,只是稳定性表现不一样。
6. 常见问题与避坑指南
6.1 我踩过的典型坑:批归一化、平滑系数与梯度惩罚叠加问题
第一个大坑是批归一化(BN)在 GAN 里的滥用。D 里用 BN 会让每个样本的判别结果依赖同批其他样本,造成统计量漂移,训练时表现极不稳定。我早期经常看到 D 的 loss 在迭代间剧烈波动,后来把 D 的 BN 换成 Instance Norm 或干脆去掉,问题立刻缓解。G 里保留 BN 问题不大,但 D 里要非常谨慎。
第二个坑是标签平滑系数设置过大。前面提过设 0.8 会削弱 D,实际表现是 G 的收敛慢、图像模糊。我在一个模拟项目里把真实标签设成 0.85,生成了 200 轮还是糊的,改成 0.9 后两轮就看得出清晰轮廓。这种细小差异在 GAN 里影响会被循环放大,所以推荐以 0.9 为起点慢慢调。
第三个坑是谱归一化和梯度惩罚同时叠满后训练极慢。我自己曾经天真地全加,结果一轮迭代时间增加快两倍,而效果并没有显著提升。现在的经验是,两者取其一作为主要稳定项,另一个可以作为备选,除非遇到明显问题,不要同时拉满。
6.2 最实用的六条实战心得
第一,时刻盯住 D 对真实和生成样本的准确率。如果准确率一直稳定在 50% 到 70% 之间,训练大概率健康;如果超过 90%,说明 D 太强,赶紧加标签平滑或调低学习率;如果低于 40%,D 太弱,训练可能已经失控。
第二,记录生成样本的多样性和 loss 曲线同等重要。只看 loss 数值会被表面欺骗,我印象最深的一次是 loss 曲线极其平滑,但生成图全部塌缩成纯色背景。大数据视觉反馈是不可替代的。
第三,不要每次只调一个参数然后干等。我习惯把训练时间缩短,比如只跑 1000 步,用生成图快速判断哪个方向有效,再精细调参。GAN 长训练试错成本极高,短跑筛选是节省时间的核心技巧。
第四,学习率衰减策略最好前期线性衰减,后期余弦衰减。线性衰减让网络快速进入正确区域,余弦衰减让后面训练更平滑,这个配合在实际项目里效果不错。
第五,尽量把 G 的输入噪声维度设大一些,比如 128 维,不要吝啬。低维噪声容易导致生成样本多样性差,是模式崩塌的隐性推手。
第六,如果你用了条件 GAN 或者风格迁移,别忘了在损失里加上条件一致性约束,比如让生成样本的属性向量与输入条件匹配。这个约束有时比上面所有 tricks 都更能稳定训练。
最后再分享一个小技巧:训练时我习惯额外记录 G 的梯度范数。梯度范数如果突然从 1.5 跳到 30 以上,说明马上要崩,此时可以提前降低学习率或恢复一个早期 checkpoint。用这个监控信号,你能比看 loss 曲线更早发现崩溃迹象,少浪费好几个小时的算力。GAN 训练就是一个不断试错和踩坑的过程,上面这些技巧不是银弹,但至少能让你的模型少崩几次、多出几张好图。