PyTorch GAN 实战:生成二次元头像全流程与训练避坑

发布时间:2026/9/18 4:13:51
PyTorch GAN 实战:生成二次元头像全流程与训练避坑 你在搜 GAN 教程的时候大概率会看到两种东西一种是通篇公式推导、看完不知道代码怎么写另一种是扔一段代码就跑跑出来一片雪花点也没人告诉你为什么。二次元人物头像生成这个题目尤其如此——它看起来门槛很低一张显卡一个下午就能出图但真正卡人的地方往往在第 3000 步之后生成器突然开始重复画同一张脸或者判别器损失直接躺平到 0训练彻底崩掉。这篇就把 PyTorch 实现 GAN 生成二次元头像的整条链路拆开讲——数据集怎么清洗、生成器和判别器为什么长成那样、原始 GAN 的损失函数里那个符号到底是怎么回事、训练崩了怎么救。代码是可以直接抄的但更想让你明白每一行为什么这么写。1. 从原始 GAN 损失里那个消失的负号讲起1.1 判别器的二元交叉熵本来就没有正号很多人第一次读 DCGAN 的训练代码会犯迷糊论文里明明写的是最大化 $\mathbb{E}[\log D(x)] \mathbb{E}[\log(1-D(G(z)))]$可代码里却是criterion(out_real, ones) criterion(out_fake, zeros)看不出哪里取了负号。原因在于 PyTorch 的nn.BCELoss定义本身就是负的$$ \text{BCE}(p, y) -\left[y\log p (1-y)\log(1-p)\right] $$它是按最小化写的所以当你把真样本标签设成 1、假样本标签设成 0代入进去得到的就是 $-[\log D(x)]$ 和 $-[\log(1-D(G(z)))]$。而优化器Adam/SGD默认做的是最小化最小化负的对数似然等价于最大化对数似然。所以你不需要手动加负号负号已经在损失函数内部了。这也是为什么你去看很多人手写的 GAN 训练脚本D 的损失看起来干干净净其实每一步都在做最大化。把这个逻辑捋顺之后判别器的目标就非常直观让 $D(x)$ 尽可能接近 1让 $D(G(z))$ 尽可能接近 0。说白了它就是一个二分类器只不过类别是真图 / 假图。1.2 生成器为什么要换成非饱和形式生成器这边才是真正的坑。原始论文里 G 的目标是最小化 $\mathbb{E}[\log(1-D(G(z)))]$注意这里前面是正号因为 G 想压低被判别为假的对数概率。理论上没问题实践中会出事。我们来算一下梯度。设 $D$ 的输出为 $d D(G(z))$对 $\log(1-d)$ 关于 G 的参数求导链式法则展开后主要项是$$ \frac{\partial}{\partial \theta_G}\log(1-d) -\frac{1}{1-d}\cdot\frac{\partial d}{\partial \theta_G} $$关键就在这个系数 $-\frac{1}{1-d}$。训练早期 D 很容易把假图轻松识别出来$d \approx 0$此时 $\frac{1}{1-d} \approx 1$梯度系数很小——G 几乎收不到有效信号学不动。这就是所谓的梯度饱和。解决办法是把 G 的损失换成非饱和形式non-saturating loss也就是让 G 去最大化$\mathbb{E}[\log D(G(z))]$。对应到代码就是loss_G criterion(D(fake), real_label) # 把假图标签当成 1此时梯度系数变成 $-\frac{1}{d}$。还是 $d \approx 0$ 的早期阶段$\frac{1}{d}$ 是个很大的数梯度信号充足G 能快速往骗过 D的方向走。这就是为什么几乎所有 DCGAN 复现都用非饱和形式而不是老老实实照抄论文。所以问题里说的负号——D 的损失负号藏在 BCELoss 里G 的损失则是主动换了一种等价但梯度更健康的写法两者都不是随手写的。1.3 最优判别器长什么样不动点在哪儿顺着这个博弈往下推一步其实能推出训练是否收敛的判据。固定 G 的情况下判别器的最优解是$$ D^*(x) \frac{p_{\text{data}}(x)}{p_{\text{data}}(x) p_g(x)} $$这个式子很好理解如果某个位置真实数据密度高、生成数据密度低判别器就倾向于判真两者相等时输出 0.5也就是它分辨不出来。把这个最优解代回生成器的损失会得到$$ \mathbb{E}{x\sim p{\text{data}}}[\log D^(x)] \mathbb{E}_{z}[\log(1-D^(G(z)))] -\log 4 2\cdot \text{JSD}(p_{\text{data}},|,p_g) $$也就是说GAN 的训练本质上是在最小化真实分布和生成分布之间的Jensen-Shannon 散度。当 $p_g p_{\text{data}}$ 时 JS 散度为 0损失取到理论下界 $-\log 4 \approx -1.386$。这个数字很有用你在训练日志里如果看到 G 的损失长期在这个值附近游荡说明分布已经比较接近了不用慌为什么损失不往下掉——它本来就掉不下去了。但这里也埋着一个结构性隐患当两个分布几乎没有重叠时JS 散度是个常数梯度直接为 0这就是原始 GAN 难训练、后期要靠 WGAN 用 Wasserstein 距离替换的理论根源。新手不用一上来就上 WGAN-GP但知道这一层遇到训着训着梯度就没了时心里有底。2. 二次元头像数据集的清洗与预处理2.1 图片从哪来以及版权这件事必须先说清数据集这块最省事的做法是找公开的动漫头像数据集社区里有不少整理好的 packs动辄几万到十几万张。如果你打算自己收集通用的思路是从公开图站按标签筛选后批量下载但这里有个绕不开的问题版权和使用范围。二次元头像很多是画师的个人作品用来做学术实验、个人练手一般问题不大但如果要发布模型权重、做商业化产品务必确认授权别把别人的原创角色图训练出来的模型对外提供服务。这个坑不是技术坑但比技术坑更容易翻车。数据量上我的经验是64×64 分辨率、单卡训练8000 到 20000 张是比较舒服的区间。低于 3000 张判别器太容易过拟合生成结果会明显往训练集里的少数几张脸靠拢高于 5 万张单轮 epoch 时间拉长调参周期变慢对新手不友好。先小规模跑通流程再放大数据集是更理性的路径。2.2 裁剪到人脸区域比整图缩放重要得多这是很多人忽略的一步。二次元头像原始图往往是半身或者全身背景占比很大。如果你直接 resize 到 64×64网络会把大量容量浪费在学背景色块上生成出来的人脸又小又糊。正确做法是先检测头部区域再裁剪。对于二次元图像用现成的人脸检测器比如 OpenCV 的 Haar 级联、或基于深度学习的检测器效果不一定稳因为画风差异大。更实用的方案是用一个动漫人脸检测模型或者退一步用人物主体检测 上半部分裁剪的近似做法。裁剪时建议保留一点额头和下巴边缘比例控制在接近正方形比如以检测框中心为准向外扩 1.2 倍再截取正方形区域。原因是如果卡得太紧生成器学会的都是极近特写你后面想看半身构图就没有了。裁剪完之后再用双线性插值统一缩放到 64×64如果想出 128×128 的清晰图数据集就统一到 128×128。这里有个细节先裁剪再缩放而不是先缩放再裁剪否则小图上检测框坐标精度损失太大。2.3 归一化到 [-1, 1] 与 DataLoader 的工程细节预处理流水线里ToTensor()会把像素值压到 [0, 1]但 GAN 的生成器最后一层通常用Tanh输出范围是 [-1, 1]。所以数据侧必须配套做归一化from torchvision import transforms, datasets transform transforms.Compose([ transforms.Resize((64, 64)), transforms.CenterCrop(64), transforms.ToTensor(), transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]) # [0,1] - [-1,1] ]) dataset datasets.ImageFolder(root./anime_faces, transformtransform) dataloader torch.utils.data.DataLoader( dataset, batch_size64, shuffleTrue, num_workers4, drop_lastTrue, pin_memoryTrue )Normalize([0.5]*3, [0.5]*3)的作用就是 $(x - 0.5)/0.5$把 [0,1] 映射到 [-1,1]和 Tanh 的输出范围对齐。如果这一步忘了生成器最后一层还是 Tanh但目标是 [0,1] 的图结果就是整体偏灰、对比度发闷你会以为是模型不行其实是数据没对齐。几个 DataLoader 的实践细节drop_lastTrue很关键因为 GAN 训练中同一个 batch 里 batch size 变化会让 BatchNorm 的统计量抖动最后一个小 batch 往往是毒药直接丢掉更省心。num_workers设成 CPU 核心数的 2/3 左右一般够用配pin_memoryTrue能让 GPU 数据搬运快一点。如果你在 Windows 上跑并且用了多 worker记得把训练主逻辑包在if __name__ __main__:里否则会报重复启动的错——这个错误我第一次遇到的时候排查了半个多小时。还有一点值得留意数据集里如果有大量重复图或者高度相似的图比如同一角色的不同截图判别器会记住这些高频脸生成器也就只学这几张。上线训练前做个简单的去重感知哈希 pHash 就够能省掉后面的很多麻烦。3. 生成器与判别器的结构设计思路3.1 生成器从 100 维噪声拧成一张 64×64 的脸生成器干的事情可以类比成画家从一团随机的颜料开始一层层地勾出轮廓、铺色、最后描细节。输入是一个 100 维的随机向量 $z$我们需要把它逐步放大成 64×64×3。核心组件是转置卷积ConvTranspose2d也就是常说的反卷积。它和普通卷积方向相反普通卷积把特征图越卷越小转置卷积把它越卷越大。一个kernel_size4, stride2, padding1的转置卷积能把边长恰好翻倍。所以我们从 1×1 开始经过 5 次翻倍1 → 4 → 8 → 16 → 32 → 64正好到 64×64。import torch.nn as nn class Generator(nn.Module): def __init__(self, nz100, ngf64, nc3): super().__init__() self.net nn.Sequential( nn.ConvTranspose2d(nz, ngf * 8, 4, 1, 0, biasFalse), nn.BatchNorm2d(ngf * 8), nn.ReLU(True), nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, biasFalse), nn.BatchNorm2d(ngf * 4), nn.ReLU(True), nn.ConvTranspose2d(ngf * 4, ngf * 2, 4, 2, 1, biasFalse), nn.BatchNorm2d(ngf * 2), nn.ReLU(True), nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, biasFalse), nn.BatchNorm2d(ngf), nn.ReLU(True), nn.ConvTranspose2d(ngf, nc, 4, 2, 1, biasFalse), nn.Tanh() ) def forward(self, x): return self.net(x)几个设计要点解释一下。第一层的输入尺寸是(batch, nz, 1, 1)不是一维向量所以采样噪声的时候要写成torch.randn(bs, nz, 1, 1)否则会报维度不匹配——这是新手最常撞的报错之一。通道数从ngf*8逐层减半到ngf最后压到 3 通道层次上对应先学整体结构再学局部细节。3.2 判别器一路下坡的二分类器判别器就是生成器的镜像用普通卷积不断下采样最后输出一个标量概率class Discriminator(nn.Module): def __init__(self, nc3, ndf64): super().__init__() self.net nn.Sequential( nn.Conv2d(nc, ndf, 4, 2, 1, biasFalse), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(ndf, ndf * 2, 4, 2, 1, biasFalse), nn.BatchNorm2d(ndf * 2), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, biasFalse), nn.BatchNorm2d(ndf * 4), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, biasFalse), nn.BatchNorm2d(ndf * 8), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(ndf * 8, 1, 4, 1, 0, biasFalse), nn.Sigmoid() ) def forward(self, x): return self.net(x).view(-1)注意最后一层的Sigmoid它把 logit 压到 [0,1]才能和 BCELoss 配合。如果你后面想换 WGAN-GP就要把它去掉因为 Wasserstein 距离不要求概率输出。.view(-1)是把形状(bs, 1, 1, 1)展平成(bs,)跟torch.full((bs,), ...)生成的标签形状对齐不然 loss 计算会广播出错误的结果——而且这种错误不会报异常只会静默地让 loss 数值变得很奇怪非常阴险。3.3 那些容易写错的细节BN、激活函数与初始化结构里有几个约定俗成的选择背后都有原因。生成器最后不加 BatchNorm。因为 BN 会把特征归一化到均值 0、方差 1紧接着又进 Tanh输出分布会被过度压缩颜色容易糊成一片。同理判别器第一层也不加 BN输入已经是 [-1,1] 的图再加 BN 相当于对输入做二次标准化会破坏原始像素的相对关系。激活函数的选择是不对称的。生成器用ReLU判别器用LeakyReLU(0.2)。原因是判别器要在整个图像空间给出有意义的梯度ReLU 在负半轴输出恒为 0会杀死一部分梯度LeakyReLU 保留一个很小的负斜率0.2 是原论文的经验值让梯度能透到深层。生成器内部用 ReLU 没问题但输出层必须换成 Tanh。权重初始化别偷懒。用默认初始化也能跑但收敛明显慢。按 DCGAN 论文的建议卷积层权重要用均值 0、标准差 0.02 的正态分布初始化BN 的缩放因子初始化为 1、偏置为 0def weights_init(m): classname m.__class__.__name__ if classname.find(Conv) ! -1: nn.init.normal_(m.weight.data, 0.0, 0.02) elif classname.find(BatchNorm) ! -1: nn.init.normal_(m.weight.data, 1.0, 0.02) nn.init.constant_(m.bias.data, 0) G.apply(weights_init) D.apply(weights_init)这个初始化我在自己的项目里对比过加上之后前 5 个 epoch 的生成质量提升是肉眼可见的能让收敛速度明显快一截。4. 训练循环的完整实现与关键超参4.1 两个优化器、两次反向传播的交替节奏GAN 的训练循环和普通网络最大的不同是同一个 batch 里要做两次独立的反向传播先更新 D再更新 G。这里有个必须做对的细节——更新 G 的时候要让 D 的参数不参与梯度计算但又要保留计算图让梯度能传到 G。标准写法是用detach()import torch import torch.optim as optim from torchvision.utils import save_image device torch.device(cuda if torch.cuda.is_available() else cpu) nz, batch_size, lr 100, 64, 2e-4 G Generator(nz).to(device) D Discriminator().to(device) G.apply(weights_init) D.apply(weights_init) criterion nn.BCELoss() optG optim.Adam(G.parameters(), lrlr, betas(0.5, 0.999)) optD optim.Adam(D.parameters(), lrlr, betas(0.5, 0.999)) real_label_value 0.9 # 标签平滑 for epoch in range(100): for i, (real, _) in enumerate(dataloader): real real.to(device) bs real.size(0) # ---------- 更新判别器 ---------- optD.zero_grad() real_labels torch.full((bs,), real_label_value, devicedevice) fake_labels torch.zeros(bs, devicedevice) out_real D(real) loss_real criterion(out_real, real_labels) loss_real.backward() noise torch.randn(bs, nz, 1, 1, devicedevice) fake G(noise) out_fake D(fake.detach()) # detach切断到 G 的梯度 loss_fake criterion(out_fake, fake_labels) loss_fake.backward() optD.step() # ---------- 更新生成器 ---------- optG.zero_grad() out D(fake) # 这次不 detach loss_G criterion(out, real_labels) # 非饱和损失 loss_G.backward() optG.step()fake.detach()这一步如果漏了更新 D 的梯度会顺着计算图一路传回 G等于一边训练 D 一边白改 G训练会非常不稳定。这个 bug 很隐蔽因为程序不报错只是效果变差。优化器的超参也不是随便定的。lr2e-4是 DCGAN 论文给出的经验值比普通分类任务常用的 1e-3 小一个量级因为 GAN 是博弈过程步长太大容易两个网络互相甩开。betas(0.5, 0.999)里第一个参数从默认的 0.9 降到 0.5是为了降低动量让优化器对新梯度更敏感——博弈场景下动量太大容易过冲。我实测过把 beta1 改回 0.9训练早期震荡明显加剧能感觉到来回摆。4.2 标签平滑为什么真样本标签设成 0.9 而不是 1.0代码里real_label_value 0.9是个刻意偏离 1.0 的设置叫单边标签平滑。逻辑是这样的如果真样本标签严格是 1判别器会变得过度自信输出一个接近饱和的 logit对应的梯度会非常小G 反而学不到东西。把它设成 0.9判别器始终留一点余地输出不会顶到饱和区梯度持续存在。这个技巧对判别器偏强的情况特别有效能有效延缓 D 的过早收敛。代价是损失曲线数值上不再有绝对清晰的解释真样本理想损失不是 0所以你读曲线的时候要考虑到这一点。有些实现还会加实例噪声就是给判别器的输入图加一点高斯噪声标准差 0.1 左右同样是为了削弱 D 的记忆能力。这些手段都属于给判别器上镣铐原理上都是在防止它提前把任务学得太完美。4.3 可视化、保存与断点续训GAN 训练周期长必须边训边看。我一般每 100 到 200 个 batch 就固定一组噪声生成同样一批图比如 64 张拼成 8×8 网格保存下来这样能直观对比不同 epoch 的进步if i % 200 0: with torch.no_grad(): fixed_noise torch.randn(64, nz, 1, 1, devicedevice) fake_grid G(fixed_noise) save_image(fake_grid, fout/epoch{epoch}_step{i}.png, nrow8, normalizeTrue) print(f[{epoch}/{100}][{i}/{len(dataloader)}] flossD{loss_D.item():.4f} lossG{loss_G.item():.4f})normalizeTrue很重要因为输出是 [-1,1]不归一化存出来的图会发暗。固定噪声的意义在于控制变量如果每次都用新噪声你分不清画面的变化是模型进步了还是这次抽到的种子碰巧好。这一个小习惯能帮你省掉大量到底有没有变好的纠结。保存模型建议同时存 G、D 和两个优化器的状态字典方便中途中断后接着训torch.save({ G: G.state_dict(), D: D.state_dict(), optG: optG.state_dict(), optD: optD.state_dict(), epoch: epoch }, fckpt/gan_epoch{epoch}.pth)只存 G 看着省空间但一旦你想继续训练或者换 dataset 微调优化器状态丢了就得从头热身反而更亏。5. 训练不收敛时的排查链路5.1 判别器太强loss 躺平到 0 的典型现场症状很明确训练日志里lossD迅速降到接近 0同时lossG一路飙升到一个很大的数生成的图全是噪声或者纯色块。这说明判别器已经能 100% 准确区分真假G 的任何输出都被无情识破梯度信号断了。根因是 D 和 G 的能力失衡。常见诱因有几个判别器的学习率相对偏高、判别器比生成器更新得更频繁、或者数据集太小导致 D 直接记住了所有真实样本。对应的调整手段按优先级排先检查是否每步都在同步更新 G 和 D正确的节奏是每个 batch 各更新一次再去调低 D 的学习率比如把 D 的 lr 减半而 G 保持不变还可以加标签平滑和输入噪声。极端情况下可以暂时冻结 D 几个 step 只训 G让它追上来。补一句不要迷信lossD 越低越好。GAN 里 D 太强和太弱都是病理想的健康状态是lossD在 0.5 到 0.8 之间波动lossG在 1 到 3 之间来回振荡两个损失都不是单调下降的。看到曲线剧烈震荡先别慌这很可能是正常博弈不是训练失败。5.2 模式崩塌生成器开始复制粘贴模式崩塌的表现特别有辨识度翻看每个 epoch 的网格图会发现 64 张图里有大量几乎一样的脸或者干脆是同一个姿势、同一种发色的重复。生成器学会了只要画出这一张脸就能骗过判别器于是偷懒不再探索多样性。它的本质是 G 找到了一条高回报的捷径缺少多样性奖励。缓解办法有这么几类。第一是增大小批量内的多样性压力比如在一张图上拼接多个样本让 D 去判断minibatch discrimination 的思想迫使 D 关注样本之间的差异。第二是把噪声维度调大或者尝试在小批量里对噪声做不同采样。第三是降低 D 的更新频率给 G 更多探索空间。我个人最常用且见效快的做法是降低生成器的学习率让它的策略更新更保守不那么快锁死在一条路径上。同时配合前面说的标签平滑。真正彻底解决模式崩塌需要 WGAN-GP 或者谱归一化这类结构性改进但那是进阶话题先把基础版本稳住更重要。5.3 棋盘伪影、全黑图和色彩偏移棋盘伪影是转置卷积的胎里病。当 kernel 大小不能被 stride 整除时转置卷积的输出像素会有的被覆盖多次、有的只覆盖一次形成规则的网格状明暗交替。在 4×4 kernel、stride2 的配置下这个问题通常较轻如果换成了 stride3 之类的参数就会非常明显。检查方法很简单把生成的图放大看是否有规则的方格纹。如果确实有把转置卷积换成先上采样Upsample双线性再普通卷积的组合能基本消除。全黑图或纯色图一般不是模型结构问题先检查数据归一化是否和 Tanh 对齐——前面说过忘了Normalize就会导致输出灰度偏移。如果数据没问题那就是训练崩了回到 5.1 去看 D 的损失。色彩偏移比如所有图都偏蓝或偏黄通常是数据集本身的色彩分布就不均衡或者判别器的某一层 BN 统计量跑偏了。前者靠检查数据集统计量解决后者可以通过降低学习率缓解。6. 从能画出脸到画得好看的调优经验6.1 损失曲线到底该怎么看给几条我自己的读图经验。第一看趋势不看绝对值。GAN 的损失绝对数值没有明确的物理意义尤其加了标签平滑之后关注的是两者是否保持在一个动态平衡区间。第二看 G 的损失有没有持续单调下降如果它一直掉而 D 的损失一直涨这通常意味着 D 在被 G 压着打很快就要崩。第三结合生成图判断损失曲线只是辅助最终还是要看图。我自己就遇到过曲线看着完美但生成的图全是渐变色的情况光看数字完全发现不了。一个比较健康的状态是lossD和lossG都有波动但都不过分接近 0也不出现某一方单调发散。6.2 潜在空间插值检验模型有没有理解人脸训练到一定阶段做一个潜空间插值会有很有意思的发现。取两个随机噪声 $z_1$ 和 $z_2$在它们之间做线性插值或者球面插值把每一帧都喂给 G连成动画with torch.no_grad(): z1 torch.randn(1, nz, 1, 1, devicedevice) z2 torch.randn(1, nz, 1, 1, devicedevice) frames [] for alpha in torch.linspace(0, 1, 10): z (1 - alpha) * z1 alpha * z2 frames.append(G(z)) frames torch.cat(frames, dim0) save_image(frames, interp.png, nrow10, normalizeTrue)如果生成的序列是平滑过渡——发色从浅变深、脸型从圆变尖——说明模型学到了连续的人脸语义空间这是个好模型。如果中间突然跳变、或者出现一堆无意义的鬼脸说明潜在空间是断裂的模型质量还不够。这个技巧不光用来评估还能帮你挑选不同区域的风格种子。6.3 想再上一个台阶可以往这几个方向走基础 DCGAN 跑通之后如果还想提质量我建议按这个顺序尝试。第一是分辨率从 64 提到 128 是最直接的观感提升但要同步把生成器加一层转置卷积、判别器加一层下采样显存占用也会翻几倍得确认硬件扛得住。第二是 WGAN-GP用 Wasserstein 距离加梯度惩罚替换原始损失训练稳定性提升非常明显代价是每个 batch 多一次梯度惩罚计算速度慢一些。第三是谱归一化用torch.nn.utils.spectral_norm包一层判别器的卷积层就行几乎零成本地约束 D 的 Lipschitz 常数对抑制 D 过强很有效。再往上就是条件生成把标签信息喂进去控制发色、方向和 StyleGAN 这类架构了但那些已经不是保姆级范畴先把手上的 64×64 版本训出一张能看的图再往下走才踏实。最后分享一个踩过好几次坑才总结出来的小技巧训练前先跑 50 步做一个冒烟测试确认数据能正常加载、两个损失能正常反向传播、生成的图能成功保存。完整的训练动辄几个小时如果跑到第 2000 步才发现保存路径写错了或者数据归一化漏了那才是真的浪费。用最小成本验证整条链路永远比事后排查划算。