1. 项目整体设计与思路拆解
1.1 为什么选择DCGAN来生成动漫头像
打开这篇文章的朋友,多半已经看过我前面两篇DCGAN实战文章了。第一篇我们讲了GAN的基本对抗思想,第二篇把DCGAN在MNIST和CIFAR上的代码过了一遍。这次正好进入最有意思的部分——用DCGAN生成二次元动漫头像。说实话,GAN这个方向能玩出视觉冲击力的任务不多,动漫头像生成算是其中之一,训练过程也比较直观,生成结果肉眼可见地变好,正反馈很强。
之所以选DCGAN而不是原始的GAN或者别的变体,核心原因是DCGAN把卷积网络引入生成器和判别器之后,生成质量的稳定性大幅提升。原始GAN用全连接网络处理图像,参数量大、特征提取能力弱,到64x64这种分辨率基本就开始费劲了。DCGAN在结构上有几个关键改动——用带步长的卷积代替池化做下采样、用转置卷积做上采样、在卷积层后面加批归一化(BatchNorm)、激活函数统一换成ReLU族。这些改动组合起来,让网络在中等分辨率图像上也能学出有意义的结构特征,动漫头像这种边缘锐利、色块分明的图片,恰好是DCGAN擅长的范围。
另外一个现实原因是资源开销。动漫生成最出名的StyleGAN系列我也想跑,但一张RTX 3060显存只有12G,StyleGAN动辄几十个小时的训练时间对个人项目来说太奢侈了。DCGAN不一样,模型轻、显存占用小,一张普通消费级显卡就能在几小时内完成训练。我之前在CIFAR-10上面跑了一遍DCGAN,50个epoch大约半小时搞定,换成64x64的动漫头像,200个epoch也就在两到三小时左右,完全在个人可接受的范围内。
1.2 整体技术路线与前置条件
这次项目的整体流程可以分为四步:准备数据集、构建模型、训练、评估生成效果。听起来简单,但每一步都有不少容易踩的坑,稍后我会挨个细说。
先交代一下我使用的环境,已经在前面文章里详细写过安装步骤,这里只列关键版本:
- Python 3.9+
- PyTorch 1.13(后续2.0、2.1版本也兼容)
- torchvision(自带数据集加载功能)
- CUDA 11.7(如果没有NVIDIA显卡,纯CPU也能跑,只是速度慢非常多)
- matplotlib 用于可视化生成结果
提示:如果你没有GPU环境,建议把图片分辨率降到32x32、batch_size降到64,这样在CPU上也能跑通,但训练时间会很长。我的建议依然是先想办法搞到一支支持CUDA的NVIDIA显卡,哪怕是老一点的GTX 1060 6G,训练体验也完全不一样。
模型层面我会完整实现一个DCGAN的生成器和判别器,这两个网络加起来大概只有几百万个参数,比很多分类网络还要轻量。数据层面使用公开的动漫人脸数据集,图像统一缩放到64x64分辨率,这个尺寸是DCGAN原始论文验证过的标准配置,不需要额外调参。训练时每轮固定生成一组随机噪声,喂给生成器得到一组中间结果,直观观察模型收敛过程。
2. 数据集准备与预处理
2.1 动漫头像数据从哪来
动手训练之前最头疼的其实是数据,这一点相信很多做过图像生成的朋友都有体会。我当时先尝试了自己从零开始收集,结果发现二次元头像的公开资源非常零散,质量参差不齐,很多图片还带水印,清洗成本极高。后来决定使用社区内广泛使用的公开动漫人脸数据集,在Kaggle上可以搜到一份比较经典的anime faces数据集,里面包含数万张已经裁剪好的头像图片,分辨率基本都在96x96以上,够用。
如果你还没用过Kaggle,注册后在数据集页面选择Download,解压之后就能看到大量jpg图片。用PyTorch的ImageFolder来读取这类纯图片目录非常方便,不需要自己写复杂的数据解析逻辑。可能有人会问为什么不直接用torchvision自带的下载脚本,因为torchvision并没有内置动漫头像数据集,所以走ImageFolder是最灵活的方案。
注意:务必确认自己拿到的数据集是公开合规的。看license时不能只看数据集本身,还要看图片版权归属。个人学习实验一般没有太大问题,但如果你要商用模型权重,来源问题一定要查清楚。
2.2 图像预处理的关键细节
拿到原始图片后,预处理环节直接决定训练能不能收敛。我踩过一次很深的坑,最初把图片读进来直接扔给模型,结果生成器损失完全不动。后来检查发现,问题出在没有把像素值归一化到[-1, 1]区间。很多入门资料会告诉你归一化很重要,但很少解释原因——GAN的生成器输出层一般用的是tanh激活函数,输出范围正好是[-1, 1],如果真实图片却在[0, 1]区间,判别器很容易通过学习输出值的大小直接区分真实和生成图片,根本不需要学习图像结构特征,这样一来生成器学到的信息几乎为零。
正确的做法是用torchvision.transforms做两步处理:先转换成Tensor(自动把像素缩放到[0,1]),再用Normalize把数值变换到[-1,1]。代码很简短:
transform = transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ])Resize到64x64的尺寸是DCGAN原始论文验证过的标准配置。也许你会问,为什么不用更大的128x128?DCGAN这个结构的生成器在超过64x64之后容易出现棋盘伪影,因为转置卷积叠加时会有重复像素的问题。我们用64x64可以规避大量调参工作,后续如果想让头像更清晰,可以再加一层上采样模块,但训练稳定性会明显变差,得不偿失。
2.3 DataLoader的batch_size与数据增强选择
数据处理这块,第二个容易纠结的点是batch_size。理论上batch越大训练越稳定,但显存容量有限。我在12G显存的显卡上测试过,DCGAN生成器和判别器加起来占用大约3-4G,batch_size给到128甚至256都没问题。如果显存只有4G,建议降到64。
在动漫头像这个任务里,我建议不要加太多数据增强。翻转、裁剪、色彩抖动这些常规增强手段大多是为分类任务设计的,GAN训练中增强会改变真实图片的分布,导致判别器评估失真。我实际测试下来,只做一个随机水平翻转就已经足够了,其他增强反而让生成图片出现过拟合式的不自然纹理。
DataLoader构建代码:
dataset = torchvision.datasets.ImageFolder( root='./anime_faces', transform=transform ) dataloader = torch.utils.data.DataLoader( dataset, batch_size=128, shuffle=True, num_workers=2, pin_memory=True )数据集大小对最终效果影响非常明显。我试过用5000张图训练,生成的图片风格非常单一,五官细节也容易崩坏;换到几万张图之后,生成结果出现明显改观。动漫人脸的结构模式相对统一,数据集如果太小,模型会陷入对所有样本的“平均脸”记忆,丢失多样性。所以我的结论是:数据量能多就不凑合,至少20000张以上。
3. 模型结构与核心参数
3.1 生成器结构设计
理解了数据之后,下一步把两个网络搭建起来。DCGAN的灵魂在于生成器和判别器如何分布卷积层。我从原始论文出发,用代码一行行实现。
生成器的输入是一个100维的随机噪声向量z,噪声从标准正态分布中采样。这个100维的向量可以理解为模型的“创意空间”,每个维度代表某种图像属性,模型在训练中会学会把这些随机数字映射成有意义的图像特征。接下来的问题是,100维向量怎么变成64x64x3的图片?
DCGAN的思路是连续做四次转置卷积(ConvTranspose2d),每次把特征图尺寸翻倍。为了方便理解,可以把整个结构分成几个阶段:
- 第一阶段:把100维向量通过全连接层映射成512x4x4的特征图,相当于把噪声“铺开”成一张极低分辨率的特征图;
- 第二阶段:转置卷积上采样到8x8,通道数从1024降到512;
- 第三阶段:上采样到16x16,通道数降到256;
- 第四阶段:上采样到32x32,通道数降到128;
- 第五阶段:上采样到64x64,通道数降到3,得到RGB图像。
中间每一层都接BatchNorm2d和ReLU激活函数,最后一层使用tanh把输出值映射到[-1,1]。
生成器代码:
class Generator(nn.Module): def __init__(self, latent_dim=100): super(Generator, self).__init__() self.latent_dim = latent_dim self.fc = nn.Sequential( nn.Linear(latent_dim, 512 * 4 * 4), nn.BatchNorm1d(512 * 4 * 4), nn.ReLU(True) ) self.conv_layers = nn.Sequential( # 4x4 -> 8x8 nn.ConvTranspose2d(512, 256, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(256), nn.ReLU(True), # 8x8 -> 16x16 nn.ConvTranspose2d(256, 128, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(128), nn.ReLU(True), # 16x16 -> 32x32 nn.ConvTranspose2d(128, 64, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(64), nn.ReLU(True), # 32x32 -> 64x64 nn.ConvTranspose2d(64, 3, kernel_size=4, stride=2, padding=1), nn.Tanh() ) def forward(self, z): x = self.fc(z) x = x.view(x.size(0), 512, 4, 4) x = self.conv_layers(x) return x细心的读者可能注意到,这个结构比很多精简版多了一个BatchNorm层,这是我个人坚持保留的。去掉了BatchNorm的DCGAN虽然也能训练,但训练稳定性会明显下降,尤其在batch_size不够大的情况下,判别器和生成器的loss波动会变得异常剧烈。动漫头像生成这种细粒度任务,稳定的训练过程比任何花哨的技巧都重要。
3.2 判别器结构设计
判别器的任务是判断输入图片是真实图片还是生成图片。结构上正好与生成器镜像,但方向相反。输入一张64x64x3的图片,经过四次普通卷积操作,每次把特征图尺寸减半、通道数翻倍,最后输出一个标量,代表"这张图是真的"的概率。
卷积层之间我使用LeakyReLU而不是ReLU,这是DCGAN论文中一个容易被忽略但非常重要的细节。LeakyReLU在负半轴有一个很小的斜率(通常设为0.2),这意味着即使输入的特征值非常小,梯度也能够通过反向传播传递回去。如果换成普通的ReLU,负值全部被截断,训练早期判别器性能较强时,梯度很容易消失。
判别器代码:
class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.conv_layers = nn.Sequential( # 64x64 -> 32x32 nn.Conv2d(3, 64, kernel_size=4, stride=2, padding=1), nn.LeakyReLU(0.2, inplace=True), # 32x32 -> 16x16 nn.Conv2d(64, 128, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2, inplace=True), # 16x16 -> 8x8 nn.Conv2d(128, 256, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(256), nn.LeakyReLU(0.2, inplace=True), # 8x8 -> 4x4 nn.Conv2d(256, 512, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(512), nn.LeakyReLU(0.2, inplace=True) ) self.classifier = nn.Sequential( nn.Conv2d(512, 1, kernel_size=4, stride=1, padding=0), nn.Sigmoid() ) def forward(self, x): x = self.conv_layers(x) x = self.classifier(x) return x.squeeze()这里我用了Conv2d加Sigmoid的方式替代全连接层来输出单一数值。另一条常见路线是在最后一层用Linear(51244, 1),两种方案效果接近。保留卷积结构的好处是参数更少,对输入的局部结构敏感度更高。不过需要说明,使用Sigmoid是为了配合BCELoss,如果你喜欢用BCEWithLogitsLoss,那么最后一层卷积输出之后不要加Sigmoid,计算损失时由loss函数内部完成Sigmoid转换,数值上更稳定,这是PyTorch官方推荐的做法。
3.3 权重初始化与优化器选择
网络结构定了之后,有一个经常被新手忽略的操作——权重初始化。DCGAN论文专门做了一个实验证明恰当的权重初始化对训练至关重要:所有卷积层和全连接层的权重应该从均值为0、标准差为0.02的正态分布中随机初始化,偏置项统一置为0。之所以强调0.02这个值,是因为它能在不过度激活网络的前提下,保证信息可以在深层网络中顺利传播。
初始化代码:
def weights_init(m): classname = m.__class__.__name__ if classname.find('Conv') != -1: nn.init.normal_(m.weight.data, 0.0, 0.02) if m.bias is not None: nn.init.constant_(m.bias.data, 0) elif classname.find('BatchNorm') != -1: nn.init.normal_(m.weight.data, 1.0, 0.02) nn.init.constant_(m.bias.data, 0) netG = Generator().to(device) netD = Discriminator().to(device) netG.apply(weights_init) netD.apply(weights_init)优化器的选择同样没有悬念。DCGAN论文的标准配置是Adam,学习率设为0.0002,但关键的改动是把betas参数中第一项设置为0.5。Adam默认的beta1是0.9,这个值会让动量累积过大,导致训练震荡。很多人在复现DCGAN时发现loss剧烈波动、图像质量差,很可能就是忘了改这个参数。
lr = 0.0002 beta = 0.5 optimizerG = torch.optim.Adam(netG.parameters(), lr=lr, betas=(beta, 0.999)) optimizerD = torch.optim.Adam(netD.parameters(), lr=lr, betas=(beta, 0.999))损失函数用标准的二分类交叉熵BCELoss。训练中有一个自然的认知冲击:判别器要区分真假,生成器要把假得变真,两者目标恰好相反,但又互相促进。这就是GAN的对抗本质。
4. 训练实操与调参经验
4.1 训练循环核心代码
理论准备完毕,下面进入真正硬核的训练循环。GAN训练的代码逻辑不复杂,但顺序和细节非常讲究。每轮训练分为两步:先更新判别器,再更新生成器。
判别器更新时,需要准备两批输入:一批是真实图片,标签记为1;另一批是生成器伪造的图片,标签记为0。这里有一个容易混淆的地方——生成图片时要固定当前噪声,不能每次重新采样,否则生成器一次迭代被多次反向传播更新,梯度方向会不稳定。
生成器更新时,我们把生成器的输出再送入判别器,但这次目标标签置为1,意思是希望判别器把生成的图片错认为真实图片。计算梯度后只更新生成器参数,整个过程中判别器处于冻结状态。
我自己封装了一个简洁的训练循环:
criterion = nn.BCELoss() fixed_noise = torch.randn(64, latent_dim, 1, 1, device=device) for epoch in range(num_epochs): for i, (real_images, _) in enumerate(dataloader): batch_size = real_images.size(0) real_images = real_images.to(device) label_real = torch.full((batch_size,), 1, device=device) label_fake = torch.full((batch_size,), 0, device=device) # 训练判别器 netD.zero_grad() output = netD(real_images.view(-1, 3, 64, 64)).view(-1) lossD_real = criterion(output, label_real) noise = torch.randn(batch_size, latent_dim, 1, 1, device=device) fake_images = netG(noise) output = netD(fake_images.detach()).view(-1) lossD_fake = criterion(output, label_fake) lossD = lossD_real + lossD_fake lossD.backward() optimizerD.step() # 训练生成器 netG.zero_grad() noise = torch.randn(batch_size, latent_dim, 1, 1, device=device) fake_images = netG(noise) output = netD(fake_images).view(-1) lossG = criterion(output, label_real) lossG.backward() optimizerG.step()我在代码里对真实图片先调用.view(-1, 3, 64, 64)来确保维度正确,因为ImageFolder返回的数据在batch维度上会有差异。如果张量形状不对,后续的卷积层会直接报错,新手经常会卡在这个细节上。
4.2 训练中的标签平滑技巧
训练一段时间后你会发现一个棘手的问题:判别器变得越来越强大,真实图片的判别loss很快就接近0,而生成器完全跟不上了。用GAN圈子里的说法,这是“判别器把生成器碾压了”。一旦出现这种情况,梯度就无法有效传递给生成器,生成结果会长期停留在噪声阶段。
我在这里提供一个经验性的解决方案——标签平滑(Label Smoothing)。把真实图片的标签从1改成0.9,而不是1.0。这样判别器的输出目标不是严格等于1,只需要接近0.9就算正确,降低了判别器的过信程度,给生成器留出喘息空间。
实现方式只需把label_real定义一行代码改为:
label_real = torch.full((batch_size,), 0.9, device=device)这个小改动在动漫头像任务上效果非常明显。我最初几轮训练用了严格标签,到80个epoch时判别器loss已经趋近于0,生成器输出一张全灰色图片;换成0.9平滑后,训练立即恢复到正常状态,生成图片开始出现眼睛和发丝轮廓。
4.3 训练周期与训练过程的观察方法
关于训练轮数,我在这个数据集上尝试过不同的epoch配置,最终的推荐是200到300轮。少于100轮,生成头像五官轮廓基本是全糊的,只能勉强分辨出人脸区域;超过300轮,边际收益非常小,还容易进入过拟合状态。
训练过程中的直观判断方法,是每轮训练后固定一组64个随机噪声输入生成器,保存生成的图片并拼成一张8x8的网格。每个epoch结束记录一个grid,拼成gif或直接翻看对比。我在训练中看到的变化大致是这样:
- 前20轮:几乎都是彩色噪声,没有任何人形结构,loss还在剧烈波动;
- 30到60轮:开始出现模糊的人脸轮廓,肤色区域隐约可见,但仍然混沌;
- 80到120轮:五官位置逐渐稳定,眼睛、嘴巴形状开始出现,但细节仍然粗糙;
- 150轮以后:发色、眼睛颜色、面部朝向这些高级特征逐步成形,部分图片已经非常接近真实二次元头像。
我的监控代码是这样写的:
with torch.no_grad(): fake = netG(fixed_noise).detach().cpu() img_grid = torchvision.utils.make_grid(fake, padding=2, normalize=True) plt.imshow(img_grid.permute(1, 2, 0)) plt.savefig(f'./outputs/epoch_{epoch:03d}.png') plt.close()make_grid函数的normalize=True参数很关键,因为生成器输出范围是[-1,1],直接显示会整体偏黑,normalize会把数值映射回[0,1]区间再显示。
5. 常见问题与排查实录
5.1 生成图片模糊,没有识别度怎么办
训练完查看生成结果,最常见的抱怨就是“脸是出来了,但全是糊的”。出现这个现象有几个原因,按优先级从高到低排查:
第一,检查是否真的训练充分。我见过很多人在第30个epoch就停下来,说图片模糊,其实只是训练不够。动漫头像比MNIST手写数字复杂得多,至少训练到150轮再下结论。
第二,检查batch_size是否过小。batch_size为16或32时,BatchNorm计算出的均值和方差波动太大,生成图片质量不稳。建议不低于64。
第三,检查判别器是否太弱了。如果判别器过于简陋(比如用两层全连接),它无法从图片中有效提取特征,生成器也就无法知道应该优化哪些细节。可以适当增加判别器卷积层的通道数来提升判别能力。
5.2 模式崩塌与loss异常
模式崩塌是GAN训练的经典问题,现象是生成的图片虽多,但几乎千篇一律,全是同一个脸型的头像。模型把所有随机噪声都映射到了同一个输出区域。这种问题我遇到的少一些,但一旦出现,光调学习率几乎无效。几个有效的应对方案分别是:增大潜变量维度(从100提升到128)、把生成器的dropout概率适当增大(在多层之间随机丢弃部分神经元以破坏记忆)、以及使用更强烈的标签平滑(从0.9进一步降到0.8)。
loss异常方面,最典型的是loss变成NaN。这种情况90%是学习率过大导致梯度爆炸。解决方法是把学习率从0.0002降到0.0001,同时检查是否存在除以零的情况,比如view操作后输入尺寸不对。如果用的是BCEWithLogitsLoss而最后一层没有去掉Sigmoid,也会导致数值不稳定,需要检查两者是否匹配。
5.3 我的排障顺序总结
训练出现问题的时候,别急着乱调参数。经过这些天的折腾,我总结出一个相对稳定的排障顺序:
第一步先看loss数量级和变化趋势。判别器loss维持在0.6到1.0之间波动,生成器loss逐步下降,这是健康状态。如果判别器loss瞬间降到0,马上停止训练,检查数据归一化。
第二步看中间输出图片。固定噪声的生成结果如果始终是彩色噪声,说明生成器没有有效学到特征;如果全是灰色模糊块,说明判别器过强。两种情况对应的调整方向相反。
第三步看训练设备。GPU显存利用率是否正常,会不会因为num_workers设置过大导致数据加载瓶颈。我在刚开始训练时把num_workers设成了8,CPU经常跑满而GPU利用率很低,后来调回2才算正常。
至少对这些判断顺序有概念之后,训练中遇到问题,就不会手忙脚乱了。先把表象症状弄清楚,再决定是调整数据、结构还是超参,每一步都有依据。
6. 一些实际操作中的体会
最后再聊一些超出教程本身的经验。我最初跑DCGAN动漫头像生成,犯过一个特别愚蠢的错误——忘记把生成器和判别器分别调用train()模式。在PyTorch中,BatchNorm和Dropout在训练和推理模式下行为不同,没有调用train()会导致生成器训练时完全不使用BatchNorm的更新机制,结果就是训练了100轮,生成结果始终是没有细节的色块。这个问题排查了很久,最后还是靠逐步打印每一层输出来定位的。
另一个容易忽视的点是模型的文件保存,一定要冻结网络结构后保存state_dict而不是整个模型。我之前图省事直接torch.save(model),换了一台机器加载时报了一堆版本兼容性错误。官方推荐的写法是:
torch.save(netG.state_dict(), './saved_model/generator.pth')加载的时候新建一个生成器结构,再用load_state_dict恢复参数。这个方法对后续跨机器部署非常友好。
如果你训练完之后,想进一步拉高生成图片的质量,下一步可以考虑在DCGAN基础上加一个VAE式的latent空间约束,或者把它升级为WGAN-GP。这两个方向我都做过一些尝试,改造成本都不高,但对训练稳定性和生成质量的提升非常明显,尤其WGAN-GP几乎可以彻底避免模式崩塌的问题。这些后续有机会再单独写文章分享。