GAN入门:生成对抗网络原理、训练流程与实战避坑指南
1. 从“左右互搏”说起GAN到底在解决一个什么问题如果你翻过几篇深度学习论文大概率会撞见“生成对抗网络”这个词缩写叫GAN。很多人第一次看到这个名字的反应是生成、对抗、网络每个字都认识连起来不知道在干嘛。我当初也一样盯着论文里的公式看了半天最后是靠一个特别土的类比才想明白的。想象一个造假币的团伙和一个验钞专家。造假币的想尽办法做出以假乱真的钞票验钞专家则拼命想分辨出哪张是真、哪张是假。一开始造假币的手艺很烂验钞专家一眼就能看穿但随着被识破的次数越来越多造假币的不断改进工艺直到某一天验钞专家面对两张钞票犹豫了半天也分不出真假——这时候造假币的手艺就“出师”了。GAN的核心就是这个逻辑。它由两个网络组成一个叫生成器负责“造假”一个叫判别器负责“验真”。生成器的目标是让判别器分不出自己生成的东西是真是假判别器的目标是尽可能准确地区分真实数据和生成数据。两者在训练过程中不断博弈最终生成器能产出高度逼真的样本。这个思路在2014年被提出时算是给深度学习领域扔了一颗炸弹。在此之前想让机器“生成”东西主流方法是自编码器或者玻尔兹曼机那一套生成出来的图像往往模糊、发灰像是隔着毛玻璃看东西。GAN的出现让生成质量上了一个大台阶后来才有了风格迁移、人脸生成、图像修复这些让人眼前一亮的应用。这篇是“小白学GAN系列”的第一篇我打算把最基础的概念、原理、训练流程和踩坑经验讲清楚。不堆公式不拽术语尽量用你能听懂的话把这件事说明白。如果你刚接触深度学习或者被GAN的各种变体搞得头晕那这篇就是写给你的。2. 拆开看生成器和判别器各自在干什么2.1 生成器从一团噪声里“捏”出一张图生成器的输入通常是一组随机噪声比如一个100维的向量每个维度都是从标准正态分布里采样出来的数。你可以把这组噪声想象成一块未经雕琢的泥巴生成器的任务就是把这块泥巴捏成一张看起来像真实数据的图片。具体怎么捏靠的是神经网络。输入一个100维的向量经过几层全连接或者转置卷积最后输出一张和真实图片尺寸一样的张量。比如你想生成28×28的手写数字图片那生成器的输出就是一个28×28的矩阵每个像素值在0到1之间。这里有个关键点生成器本身并不知道什么是“数字”它只知道要把输入噪声映射成某种输出使得判别器认为这个输出是真实的。换句话说生成器的“知识”完全来自于判别器的反馈。判别器说“这张不像”生成器就调整参数下次生成得再像一点。我刚开始学的时候总觉得生成器应该有个“模板”或者“参考图”但实际上它就是从随机噪声开始一步步试错最后找到了一种能把噪声映射到真实数据分布的变换方式。这个过程有点像盲人摸象但摸的次数足够多之后还真能摸出个大概轮廓。2.2 判别器一个二分类器的“自我修养”判别器的结构就朴素多了本质上就是一个二分类网络。输入一张图片输出一个标量表示这张图片是真实数据的概率。通常用Sigmoid函数把输出压到0到1之间接近1表示“我认为这是真的”接近0表示“我认为这是假的”。判别器的训练数据来自两个渠道一部分是真实数据集里的图片标签为1另一部分是生成器当前产出的图片标签为0。判别器的损失函数就是标准的二分类交叉熵目标是把真实图片判为1、把生成图片判为0。但这里有个微妙的地方判别器不能训练得太好也不能训练得太差。如果判别器太强一眼就能看穿生成器的把戏那生成器收到的梯度信号就会非常弱参数几乎不更新训练就卡住了。反过来如果判别器太弱生成器随便生成什么它都说“真的”那生成器就没有动力去改进质量。所以实际训练中判别器和生成器的能力需要保持一种动态平衡。这个平衡怎么维持后面讲训练技巧的时候会细说。2.3 对抗的本质一个极小极大博弈把生成器和判别器放在一起看整个GAN的训练目标可以用一个极小极大公式来描述。生成器想最小化判别器分辨真假的能力判别器想最大化自己分辨真假的能力。两者目标相反所以叫“对抗”。用大白话翻译这个公式判别器希望自己给真实数据打高分、给生成数据打低分生成器希望自己生成的假数据被判别器打高分。最终理想状态是生成器生成的数据分布和真实数据分布完全重合判别器面对任何一张图片都只能给出0.5的概率——因为它实在分不出来了。这个博弈过程在理论上有一个纳什均衡点但实际训练中能不能走到那个点取决于网络结构、超参数、优化器选择等一大堆因素。这也是GAN训练被称为“玄学”的原因之一。3. 训练流程两个网络怎么交替“过招”3.1 一次完整的训练迭代长什么样假设你已经准备好了真实数据集定义好了生成器和判别器的网络结构接下来就是训练循环。一次典型的迭代包含两个阶段第一阶段训练判别器。从真实数据集里取一批图片标签设为1从生成器生成一批假图片标签设为0。把这两批数据合在一起喂给判别器计算损失反向传播更新判别器的参数。注意这一步只更新判别器生成器的参数冻结不动。第二阶段训练生成器。重新采样一批噪声输入生成器得到假图片。把这些假图片喂给判别器但这次标签全部设为1——也就是说生成器希望判别器把这些假图片当成真的。计算损失反向传播但只更新生成器的参数判别器的参数冻结。这两个阶段交替进行通常每训练一次判别器就训练一次生成器。也有实践者会在一轮里多训练几次判别器再训练一次生成器这个比例是个可调的超参数。用代码框架来示意的话大概是这个结构for epoch in range(num_epochs): for real_images, _ in dataloader: # 训练判别器 noise torch.randn(batch_size, noise_dim) fake_images generator(noise).detach() d_loss discriminator_loss(discriminator(real_images), discriminator(fake_images)) d_optimizer.zero_grad() d_loss.backward() d_optimizer.step() # 训练生成器 noise torch.randn(batch_size, noise_dim) fake_images generator(noise) g_loss generator_loss(discriminator(fake_images)) g_optimizer.zero_grad() g_loss.backward() g_optimizer.step()注意fake_images.detach()这一步在训练判别器时生成器的输出需要从计算图中分离出来否则梯度会回传到生成器那就乱套了。3.2 为什么不能同时训练两个网络有人可能会想既然两个网络都要训练为什么不把损失加起来一起反向传播这个问题我当初也纠结过。原因在于生成器和判别器的优化目标是相反的。如果同时更新判别器想拉大真假样本的分数差距生成器想缩小这个差距两个梯度方向打架参数更新会互相抵消训练根本没法收敛。所以必须分开训练而且训练其中一个的时候另一个的参数必须冻结。这就像下棋你走一步我走一步不能两个人同时落子。3.3 损失函数的选择从原始GAN到Wasserstein原始GAN论文里用的损失函数是基于JS散度的。判别器的损失是L_D -E[log(D(x))] - E[log(1 - D(G(z)))]生成器的损失是L_G E[log(1 - D(G(z)))]但这个损失函数有个问题当判别器训练得太好时生成器的梯度会消失。具体来说如果判别器能完美区分真假那D(G(z))接近0log(1 - D(G(z)))接近0梯度几乎为零生成器就学不动了。后来有人提出了Wasserstein GAN用Earth Mover距离代替JS散度即使判别器很强生成器也能收到有意义的梯度。再后来还有Least Squares GAN、Hinge GAN等变体各有各的适用场景。对于刚入门的朋友我的建议是先用原始GAN的损失函数跑通流程理解训练动态再尝试WGAN等改进版本。不要一上来就追求最先进的损失函数那样反而容易迷失在细节里。4. 实战中绕不开的那些坑4.1 模式崩溃生成器只会画同一张脸模式崩溃是GAN训练中最常见也最让人头疼的问题。表现是不管输入什么噪声生成器输出的图片都差不多甚至一模一样。比如你训练一个人脸生成器结果它只会生成同一张脸换任何噪声输入都是那张脸。这个问题的根源在于生成器发现了一个“捷径”只要生成某一张特别像真实的图片就能骗过判别器于是它就不再尝试生成其他样式的图片了。判别器面对这张图片虽然知道它可能是假的但也找不到明显的破绽于是生成器就躺平了。解决模式崩溃的方法有几种。一是使用小批量判别器让判别器不仅看单张图片还看一批图片的多样性如果一批图片都差不多就判为假。二是使用WGAN的损失函数理论上能缓解这个问题。三是在训练过程中加入噪声或者使用标签平滑让判别器的判断不那么绝对。我自己的经验是模式崩溃往往在训练中期出现表现为生成图片的多样性突然下降。这时候可以检查一下判别器的损失是不是降得太低如果是说明判别器太强了需要降低它的学习率或者减少它的训练次数。4.2 判别器太强或太弱怎么判断和调整判别器太强的信号判别器损失接近0生成器损失居高不下生成的图片质量长期没有提升。这时候生成器收到的梯度信号很弱几乎学不到东西。判别器太弱的信号判别器损失一直在0.7左右徘徊生成的图片模糊、杂乱但判别器就是分不出来。这时候生成器没有足够的压力去改进质量。判断方法很简单盯着两个网络的损失曲线看。健康的训练过程中判别器损失应该在0.5到0.8之间波动生成器损失逐渐下降但不会降到0。如果判别器损失长期低于0.2说明它太强了如果长期高于1.0说明它太弱了。调整手段包括修改学习率、调整两个网络的训练次数比例、增加或减少判别器的层数、在判别器输入中加入噪声等。这些手段没有哪个是万能的需要根据具体情况试。4.3 训练不稳定损失震荡和梯度爆炸GAN的训练损失曲线通常不会像普通分类网络那样平滑下降而是上下震荡。适度的震荡是正常的说明两个网络在博弈。但如果震荡幅度过大或者出现梯度爆炸那就需要干预了。梯度爆炸的表现是损失突然变成NaN或者参数值变得极大。解决方法包括使用梯度裁剪、降低学习率、使用Batch Normalization、换用更稳定的优化器如Adam的beta1设为0.5而不是默认的0.9。Batch Normalization在GAN里特别重要它能让每一层的输入分布保持稳定减少内部协变量偏移。我在实践中发现生成器和判别器都加上Batch Normalization之后训练稳定性会有明显提升。4.4 生成图片模糊、发灰原因和对策早期GAN生成的图片往往有一种“灰蒙蒙”的感觉像是蒙了一层雾。这个问题的根源通常在于损失函数的选择和网络结构的设计。原始GAN的JS散度在生成分布和真实分布没有重叠时梯度会消失导致生成器学不到细节。换成WGAN或者Least Squares损失可以缓解这个问题。另外生成器的最后一层激活函数也很关键如果用Sigmoid输出会被压缩到0到1之间容易导致像素值集中在中间区域看起来就发灰。换成Tanh或者不用激活函数效果会好一些。还有一个容易被忽略的点真实数据的归一化方式。如果真实图片的像素值在-1到1之间但生成器的输出在0到1之间那判别器很容易就能区分真假生成器就学不到东西。确保生成器的输出范围和真实数据的范围一致这是最基本的要求。5. 一个最小可运行的GAN实现长什么样5.1 用MNIST手写数字跑通第一个GAN理论说了这么多不如动手跑一遍。我用MNIST数据集搭了一个最简版的GAN生成器和判别器都只用全连接层代码量不到100行但能把整个流程跑通。生成器的结构输入100维噪声经过两层全连接256维、512维最后输出784维向量reshape成28×28的图片。激活函数用ReLU最后一层用Sigmoid把值压到0到1。判别器的结构输入784维向量经过两层全连接512维、256维最后输出一个标量用Sigmoid压到0到1。激活函数用LeakyReLU斜率设为0.2。训练参数batch size设为64噪声维度100学习率0.0002优化器用Adambeta1设为0.5。每训练一次判别器就训练一次生成器总共跑50个epoch。跑完之后生成器输出的图片虽然比不上真实手写数字那么清晰但已经能看出数字的轮廓了。有些图片能明显认出是“3”或者“7”有些则比较模糊。这个效果对于第一个GAN来说已经足够了。5.2 代码关键细节detach、zero_grad和训练比例在实现过程中有几个细节特别容易出错我一个个说。detach的使用。训练判别器时生成器输出的假图片需要调用.detach()把它从计算图中分离出来。如果不加这一步反向传播时梯度会回传到生成器导致生成器的参数被意外更新。这个错误很隐蔽因为代码不会报错但训练结果会莫名其妙地变差。zero_grad的时机。PyTorch默认会累积梯度所以每次反向传播之前必须调用optimizer.zero_grad()清空之前的梯度。但要注意判别器和生成器各有自己的优化器清空梯度时要分别操作不能搞混。训练比例。原始论文里是1:1交替训练但实际中可以根据情况调整。如果判别器太强可以改成训练一次判别器、训练两次生成器反之亦然。这个比例没有固定答案需要根据损失曲线动态调整。5.3 跑通之后怎么判断效果好不好跑完训练之后怎么知道生成的图片质量如何最直接的方法是肉眼观察。把生成器在不同epoch输出的图片保存下来对比看质量有没有提升。更客观的方法是计算Inception Score或者FID。Inception Score衡量生成图片的清晰度和多样性FID衡量生成分布和真实分布的距离。这两个指标都需要预训练的分类模型计算起来稍微麻烦一点但对于评估生成质量很有参考价值。不过对于初学者来说我建议先用肉眼观察。如果生成的数字能看出是什么数字多样性也还可以那就说明训练基本成功了。指标的事情可以后面再研究。6. 从原始GAN到各种变体你该先学哪个6.1 DCGAN把卷积引入GAN原始GAN用的是全连接层生成图片的质量有限。DCGAN把卷积层引入进来生成器用转置卷积上采样判别器用步长卷积下采样生成质量有了质的飞跃。DCGAN还给出了一系列架构设计准则生成器和判别器都不使用池化层用步长卷积代替生成器和判别器都使用Batch Normalization生成器的输出层用Tanh其他层用ReLU判别器的所有层用LeakyReLU。这些准则至今仍然是GAN设计的重要参考。如果你已经跑通了原始GAN下一步强烈建议试试DCGAN。把全连接换成卷积之后生成图片的清晰度会有肉眼可见的提升。6.2 WGAN让训练更稳定WGAN的核心贡献是用Wasserstein距离代替JS散度从理论上解决了梯度消失的问题。具体做法是判别器最后一层去掉Sigmoid输出一个实数损失函数里不取对数直接算真假样本输出均值的差每次更新判别器参数后把参数裁剪到[-c, c]之间。WGAN还给出了一个很有用的指标判别器的损失值可以反映生成质量。损失越小说明生成分布和真实分布越接近。这个指标比原始GAN的损失曲线直观多了。后来有人提出了WGAN-GP用梯度惩罚代替参数裁剪训练更稳定收敛更快。如果你在原始GAN上遇到了训练不稳定的问题换WGAN-GP往往能立竿见影。6.3 条件GAN让生成过程可控原始GAN生成什么完全由随机噪声决定你没法控制它生成“数字3”还是“数字7”。条件GAN解决了这个问题在生成器和判别器的输入里都加入标签信息这样你就可以指定生成某个类别的图片。条件GAN的实现很简单把标签做Embedding之后和噪声拼接在一起输入生成器判别器则把图片和标签一起作为输入判断“这张图片是否属于这个标签”。训练完之后你就可以通过改变标签来控制生成的内容。这个思路后来被广泛应用到图像翻译、风格迁移等任务中是GAN走向实用的重要一步。7. 学GAN的一点个人体会我刚开始学GAN的时候最大的困惑是为什么两个网络交替训练就能生成东西这个困惑持续了挺长时间直到我自己动手跑了一遍代码看着损失曲线上下波动看着生成的图片从一团噪声慢慢变成能辨认的数字才真正有了感觉。GAN的训练确实有“玄学”成分同样的代码换个随机种子、换个学习率结果可能天差地别。但这不代表它不可捉摸。多跑、多看、多调慢慢就能培养出对训练动态的直觉。比如看到判别器损失降得太低就知道该削弱判别器了看到生成图片多样性下降就知道可能出现了模式崩溃。另外不要一上来就追求最先进的模型。原始GAN虽然生成质量一般但它的训练流程最清晰最容易理解对抗的本质。把原始GAN跑通、调稳之后再去看DCGAN、WGAN、StyleGAN你会发现很多设计都是为了解决原始GAN的某个具体问题理解起来会顺畅很多。最后说一个实操中的小技巧保存训练过程中的检查点。GAN的训练经常出现“跑着跑着就崩了”的情况如果没保存中间结果之前的训练就白费了。我习惯每隔几个epoch保存一次生成器的参数和生成的样本这样即使后面训练崩了也能回退到之前的状态继续调。这个系列后面还会继续写下一篇打算聊DCGAN的具体实现和调参经验。如果你在跑原始GAN的过程中遇到了什么问题欢迎一起交流。