简介面向希望系统掌握深度卷积生成对抗网络与PyTorch实践的开发者这份项目提供从随机噪声生成人脸图像的完整训练范例覆盖生成器与判别器的卷积及转置卷积结构、交替优化流程、数据加载预处理和损失函数设置适合具备基础深度学习知识、想复现GAN实验的读者。压缩包共31个文件约17.35MB包含8个py源码文件、14个pyc编译缓存、4张png结果图、3个txt记录文件以及md说明和gif演示源码、运行日志与训练效果相互配套便于对照理解目录组织与实验过程。已有3780人学习下载。借助说明文档和结果图片读者可梳理数据集读取、模型搭建、训练调参与生成效果评估的完整链路并参考既有训练记录排查复现问题快速建立对DCGAN工程实现的整体认识。1. 从一团灰色噪点说起第一次跑生成对抗网络的人十个里有八个会在第三个 epoch 之后盯着屏幕发呆判别器的 loss 一路掉到 0.01生成器吐出来的还是一坨灰噪点。这份 DCGAN 的 PyTorch 实现带了可用的数据集和说明文件解压后改一个数据路径就能开训能省掉的正是「网络结构抄错、权重初始化漏了、优化器 betas 填反」这几轮排查。DCGAN 结构不新但它至今是理解 GAN 训练动态最干净的起点生成器和判别器都是规规矩矩的卷积堆叠没有梯度惩罚也没有多余的超参魔法出了问题基本能归因到具体某一行。适合谁想把 GAN 完整跑通一次看效果的人、要给自己的项目加一个图像生成 baseline 的人、还有带课程设计需要一份能读懂的最小实现的人。整份代码一个屏幕翻得完读着不费劲改起来也方便。2. 项目骨架从解压到第一次启动要摸清的几条路径拿到一个深度学习压缩包最忌讳的是直接双击 train.py 就跑。这类项目真正的复杂度不在代码行数上而在「数据在哪、模型存哪、日志写哪」这些东西的隐式约定里。先把目录摸一遍比后面花两小时看 traceback 划算得多。2.1 目录是怎么铺开的这份包的结构是常见的三段式数据、模型、输出各自占一个位置互不干扰。数据放在data/下模型定义拆成netG.py和netD.py两个文件训练过程产生的东西全部丢进outputs/包括每个 epoch 的可视化样例图和定期保存的权重。这种拆法看着朴素但对二次开发很友好——想换网络结构只动 models 目录想换数据集只动 data 目录两边不打架。dcgan-pytorch/ ├── data/ │ └── faces/ │ └── all/ # ImageFolder 的根图片直接堆在这一层 ├── models/ │ ├── netG.py │ └── netD.py ├── outputs/ │ ├── samples/ # 每个 epoch 生成的网格图用来肉眼验收 │ └── checkpoints/ # netG_epoch_xx.pth / netD_epoch_xx.pth ├── train.py └── README.md # 说明文件环境和启动命令都在这说明文件里最该先确认的三件事Python 和 PyTorch 的版本要求、数据集的解压位置和层级、启动命令带哪些参数。很多人卡在第一步不是代码有问题而是把图片放进了data/faces/all/all/这种双层同名目录ImageFolder 会把all当成类别名读进去最后类别数是 1 但路径全错。判断方法很简单跑一行print(len(dataset.classes))正常应该输出 1。提示如果说明文件里提到的图片尺寸和代码里image_size的默认值不一致先改代码再去下载数据别反过来。2.2 数据加载这一段是整个项目的隐式契约GAN 的数据管道比普通分类任务更挑剔因为它要求输入分布和生成器输出分布严格对齐。看这段代码import torchvision.datasets as dset import torchvision.transforms as transforms from torch.utils.data import DataLoader image_size 64 # 必须是 2 的幂次后面上采样次数由它决定 dataroot ./data/faces # ImageFolder 根目录 dataset dset.ImageFolder(rootdataroot, transformtransforms.Compose([ transforms.Resize(image_size), # 短边缩到 64保持长宽比 transforms.CenterCrop(image_size), # 居中裁成 64x64 正方形 transforms.ToTensor(), # PIL - [0,1] 的 CHW 张量 transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), # [0,1] - [-1,1] ])) dataloader DataLoader( dataset, batch_size128, shuffleTrue, num_workers2, drop_lastTrue, # 关键丢掉最后一个不满的 batch避免 BatchNorm 报错 )这里的核心是最后那个Normalize。(0.5, 0.5, 0.5)作为均值、(0.5, 0.5, 0.5)作为标准差等价于把像素从[0,1]线性映射到[-1,1]。之所以要这么做是因为生成器最后一层用的是Tanh输出范围天然就是[-1,1]如果真实图像还停在[0,1]判别器一眼就能靠整体亮度区分真假训练立刻崩。这个对应关系是 DCGAN 里最容易被忽略的一处改数据集的时候忘了同步改归一化现象就是生成图永远偏暗或偏亮。drop_lastTrue同样不是可选项。训练集大小除以 batch_size 有余数时最后一个 batch 可能只剩 1 张图而判别器里有多个BatchNorm2d在 batch 维度只有 1 个样本时会直接抛Expected more than 1 value per channel。这个报错信息很难懂但原因就这一句话。num_workers建议从 2 起步。设成 0 会让数据加载和计算串行GPU 利用率掉一半设成 8 在机械硬盘上反而容易卡住因为每个 worker 都要复制一份数据集索引。我一般先试 2观察nvidia-smi的利用率低于 60% 再往上加。2.3 超参表哪些必须对齐哪些可以随手调参数典型值能不能随便改改错的后果nz噪声维度100可以64128 都行太小生成多样性不足太大收敛慢batch_size128受显存限制太小 BatchNorm 不稳太大会稀释梯度lrG 和 D2e-4不建议动大于 1e-3 直接震荡小于 1e-5 训练像蜗牛beta10.5不建议动用默认的 0.9 会明显更难收敛image_size64可以但要是 2 的幂非 2 的幂会导致上采样尺寸对不上epochs2550看数据量太多会模式崩塌太少还是噪点lr2e-4和beta10.5这两个值是原论文反复调出来的换来的是「判别器更新不过快」这个平衡点。Adam 默认的beta10.9会给梯度加很重的动量在 GAN 这种两个网络互相追着跑的场景里动量会让判别器一步迈太大生成器就再也追不上。这一点在别的任务里几乎感觉不到在 GAN 里是生死线。3. 网络模块拆解转置卷积怎么把 100 维噪声堆成一张脸DCGAN 相对早期 GAN 的改进全在两个网络的搭法上去掉全连接层、用步长卷积代替池化、每层卷积后接 BatchNorm、生成器输出用 Tanh、判别器中间层用 LeakyReLU。这五条规矩每一条都有明确的动机抄代码的时候最好知道自己在抄什么。3.1 生成器四次上采样每次翻一倍import torch.nn as nn class Generator(nn.Module): def __init__(self, nz100, ngf64, nc3): super().__init__() self.net nn.Sequential( # (nz) x 1 x 1 - (ngf*8) x 4 x 4 nn.ConvTranspose2d(nz, ngf * 8, kernel_size4, stride1, padding0, biasFalse), nn.BatchNorm2d(ngf * 8), nn.ReLU(inplaceTrue), # (ngf*8) x 4 x 4 - (ngf*4) x 8 x 8 nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, biasFalse), nn.BatchNorm2d(ngf * 4), nn.ReLU(inplaceTrue), # - (ngf*2) x 16 x 16 nn.ConvTranspose2d(ngf * 4, ngf * 2, 4, 2, 1, biasFalse), nn.BatchNorm2d(ngf * 2), nn.ReLU(inplaceTrue), # - (ngf) x 32 x 32 nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, biasFalse), nn.BatchNorm2d(ngf), nn.ReLU(inplaceTrue), # - (nc) x 64 x 64最后一层不要 BatchNorm nn.ConvTranspose2d(ngf, nc, 4, 2, 1, biasFalse), nn.Tanh(), ) def forward(self, z): return self.net(z)尺寸怎么算转置卷积的输出边长公式是(H_in - 1) * stride - 2 * padding kernel_size。第一层输入是1x1stride1, padding0, kernel4代进去得(1-1)*1 - 0 4 4得到 4x4后面四层都是stride2, padding1, kernel4每层边长翻倍4 → 8 → 16 → 32 → 64。所以image_size设成 128 的话就得多堆一层转置卷积不能只改参数。biasFalse这个细节值得说一句。后面紧跟着BatchNorm2dBN 会先减均值再除标准差卷积层自带的偏置在减均值那一步就被消掉了留着纯属浪费参数。整个网络里所有后接 BN 的卷积都关掉 bias这是标准做法。最后一层为什么不要 BN因为输出要过TanhBN 会把输出强行拉成零均值单位方差等于把 Tanh 挤在中间那段近似线性的区域图像对比度上不去。同理最后一层用Tanh而不是 ReLU是因为我们要的是[-1,1]的连续输出ReLU 会把负半轴全砍掉生成图必然偏色。ngf64是通道基数第一层直接给到ngf*8512。这个数在 64x64 的任务上够用如果换到 128x128很多实现会把它降到 32 来省显存代价是细节变糊。3.2 判别器用步长卷积做下采样class Discriminator(nn.Module): def __init__(self, nc3, ndf64): super().__init__() self.net nn.Sequential( # (nc) x 64 x 64 - (ndf) x 32 x 32第一层不接 BatchNorm nn.Conv2d(nc, ndf, 4, 2, 1, biasFalse), nn.LeakyReLU(0.2, inplaceTrue), # - (ndf*2) x 16 x 16 nn.Conv2d(ndf, ndf * 2, 4, 2, 1, biasFalse), nn.BatchNorm2d(ndf * 2), nn.LeakyReLU(0.2, inplaceTrue), # - (ndf*4) x 8 x 8 nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, biasFalse), nn.BatchNorm2d(ndf * 4), nn.LeakyReLU(0.2, inplaceTrue), # - (ndf*8) x 4 x 4 nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, biasFalse), nn.BatchNorm2d(ndf * 8), nn.LeakyReLU(0.2, inplaceTrue), # - 1 x 1 x 1压成一个标量概率 nn.Conv2d(ndf * 8, 1, 4, 1, 0, biasFalse), nn.Sigmoid(), ) def forward(self, x): return self.net(x).view(-1, 1)判别器是生成器的镜像四次stride2的普通卷积把 64x64 压到 4x4最后一次stride1, padding0, kernel4直接压成 1x1输出一个标量。view(-1, 1)把形状从(N,1,1,1)拉成(N,1)方便后面接BCELoss。两个细节。第一层不接 BN输入是原始像素本身就做过归一化再套一层 BN 相当于把真实图像和生成图像的统计差异抹掉了判别器少了一个重要线索。中间层用LeakyReLU(0.2)而不是 ReLU判别器在训练后期梯度很容易死掉负半轴斜率 0.2 保证梯度始终有一条细水流过去这就是所谓「防止梯度稀疏」的实际含义。最后一层加Sigmoid是因为配BCELoss输出解释成「这张图是真的概率」。如果后面想换成BCEWithLogitsLoss数值更稳记得把这层Sigmoid删掉两个一起用等于做了两次压缩。3.3 权重初始化那一行 0.02 不是玄学def weights_init(m): 按 DCGAN 论文的方式初始化卷积用 N(0, 0.02)BN 的权重用 N(1, 0.02) 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) netG.apply(weights_init) netD.apply(weights_init)PyTorch 默认的卷积初始化是 Kaiming 均匀分布标准差大概在1/sqrt(fan_in)量级对 512 通道的层来说偏大。DCGAN 原论文用的 0.02 是个明显更小的值效果是把两个网络的初始输出都压在接近 0 的位置判别器一开始对真假样本给出的概率都在 0.5 附近谁也没占先手。如果跳过这一步常见现象是训练前几百个 batch 里 D 的 loss 迅速掉到接近 0然后 G 彻底学不动——这就是初始化不平衡导致的「D 一上来就赢」。netG.apply(weights_init)会递归遍历所有子模块用类名匹配来判断类型。这种写法有点糙但足够用唯一要注意的是别把ConvTranspose2d漏掉——它的类名里含Conv能被正常匹配上。4. 训练循环一个 batch 里发生两次反向传播GAN 的训练循环和普通网络最大的区别是每个 batch 要更新两个网络而且两个网络的梯度不能互相污染。这一段写错的方式五花八门但排查思路是统一的——看梯度有没有流到不该流的地方。4.1 判别器的两步前向真样本和假样本分开算import torch criterion torch.nn.BCELoss() optimizerD torch.optim.Adam(netD.parameters(), lr2e-4, betas(0.5, 0.999)) optimizerG torch.optim.Adam(netG.parameters(), lr2e-4, betas(0.5, 0.999)) fixed_noise torch.randn(64, nz, 1, 1, devicedevice) # 固定噪声用来看训练进度 for epoch in range(num_epochs): for i, data in enumerate(dataloader): real data[0].to(device) bsz real.size(0) # ---------- 1. 更新判别器 ---------- netD.zero_grad() out_real netD(real) loss_real criterion(out_real, torch.ones_like(out_real)) loss_real.backward() # 真样本的梯度先累积 noise torch.randn(bsz, nz, 1, 1, devicedevice) fake netG(noise) out_fake netD(fake.detach()) # detach不让梯度回流到 G loss_fake criterion(out_fake, torch.zeros_like(out_fake)) loss_fake.backward() # 假样本的梯度继续累积在同一份 .grad 上 loss_D loss_real loss_fake optimizerD.step() # ---------- 2. 更新生成器 ---------- netG.zero_grad() out_gen netD(fake) # 这次不 detach要穿过 D 回到 G loss_G criterion(out_gen, torch.ones_like(out_gen)) loss_G.backward() optimizerG.step()判别器这一段的核心是「两次backward()累加到同一份梯度上」。第一次用真样本目标是 1第二次用生成样本目标是 0两次都没调zero_grad()所以.grad自然叠加。最后一次性step()等价于在「真样本 假样本」的联合 batch 上做了一次更新。这种写法比拼接 batch 省显存也比分两次step()更稳。fake.detach()这一行是关键。如果不 detach假样本的梯度会穿过判别器一路算到生成器的参数上而此时我们只想更新 D。结果是生成器被朝着「让判别器认为是真的」方向推了一步但优化器根本没被调用这些梯度就白白留在缓冲区里下一轮netG.zero_grad()之前又被污染一次。表现是训练极不稳定loss 曲线像心电图。4.2 生成器的目标把判别器骗到输出 1生成器这一段重用了上面算出来的fake直接再喂一次判别器。注意这时候不能detach因为我们要的就是梯度从 D 的最后一层一路回传到 G 的输入层。目标是ones也就是让 D 把假图判成真图。有人会问为什么不重新生成一批噪声因为复用同一批fake能让 D 和 G 在同一批样本上做对抗梯度方向更一致收敛更快。代价是每轮只对抗一次实践里够用。loss_G的数值变化规律值得记住训练健康的时候它在 0.7 到 1.5 之间来回晃偶尔冲到 3 也别慌只要生成图在变清晰就不是问题。真正危险的是它长时间贴在 4 以上不动那说明 D 已经把真假分得门儿清G 完全没梯度可用。4.3 存盘和可视化别等训完才想起来看from torchvision.utils import save_image import os if i % 200 0: with torch.no_grad(): # 采样不需要梯度省显存 fake_fixed netG(fixed_noise).detach().cpu() save_image(fake_fixed, foutputs/samples/epoch{epoch}_iter{i}.png, nrow8, normalizeTrue) if epoch % 5 0: torch.save(netG.state_dict(), foutputs/checkpoints/netG_epoch{epoch}.pth) torch.save(netD.state_dict(), foutputs/checkpoints/netD_epoch{epoch}.pth)固定噪声fixed_noise是整个训练过程里最有用的调试工具。它只生成一次之后每个采样点都用同一批噪声这样生成图的演化过程才可比——你能清楚看到第 1 个 epoch 是噪点、第 5 个 epoch 出现模糊轮廓、第 15 个 epoch 五官成型。如果每次都用新噪声你根本分不清变化是模型进步了还是噪声碰巧不同。save_image的normalizeTrue会把[-1,1]的数据重新拉伸到[0,1]再存盘不加这个参数存出来会是全黑或者过曝。nrow8表示每行排 8 张64 张正好排成 8x8 的网格。保存权重时只存state_dict()而不是整个模型对象。前者是一份字典体积小、跨版本兼容后者把类的定义也序列化了换个目录就加载不了。这是我踩过坑之后改掉的习惯。5. 排查手册loss 不降、模式崩塌和显存爆炸的现场处理训练 GAN 最难受的地方在于它不报错。代码跑得好好的loss 也在打印但生成图就是没变化。下面五条是我在不同数据集上反复撞见的问题每条都按「现象 → 原因 → 解决」记下来遇到时可以直接对照。5.1 判别器 loss 掉到 0.01生成器完全不动现象前两三百个 iteration 里loss_D从 0.69 一路跌到 0.01 附近并锁死loss_G反向涨到 4 以上采样图始终是灰噪点。原因判别器学得太快把真假分得百分之百分开生成器拿到的梯度接近 0。常见诱因有三个——忘了做权重初始化、G 和 D 用了同一个优化器、或者数据集太小而 D 容量太大。解决先确认weights_init确实被 apply 了把 D 的学习率降到 G 的一半比如 G 用 2e-4、D 用 1e-4对真样本标签做单边平滑把torch.ones_like(out_real)换成torch.full_like(out_real, 0.9)。标签平滑这一招几乎是零成本能让 D 不那么自信给 G 留出活路。5.2 生成图全都长一个样模式崩塌现象采样网格里 64 张脸看着像同一个人只是表情或角度微调换成别的数据集则是同一片颜色反复出现。loss_G稳定在一个不高不低的值上看不出异常。原因生成器找到了一个能骗过当前判别器的「万能样本」于是所有噪声输入都被映射到同一个输出点。这不是 bug是 GAN 的固有倾向数据多样性越差越容易触发。解决先加大nz从 100 提到 128再给数据加RandomHorizontalFlip增强多样性如果还是不行就减少训练轮数——模式崩塌往往发生在收敛之后早停反而是有效的。另外把batch_size从 128 降到 64 有时也能缓解因为每个 batch 内的样本差异占比变大了。5.3 RuntimeError: Expected more than 1 value per channel现象训练跑到某个 iteration 突然抛这个错堆栈指向判别器里的BatchNorm2d。原因最后一个 batch 只剩 1 张图BN 在 batch 维度上算不出方差。训练集大小不是batch_size整数倍时必现。解决DataLoader加drop_lastTrue。如果不想丢数据就改成batch_size能整除训练集大小的值。这个问题在别的任务里可能被 BN 的track_running_stats掩盖过去在 GAN 里因为两个网络都密集用了 BN撞上的概率高得多。5.4 生成图整体偏灰、对比度低现象图像结构能看出来但颜色发闷像是蒙了一层灰纱。原因归一化和反归一化没对齐。要么训练时Normalize用了 ImageNet 的(0.485, 0.456, 0.406)要么生成器最后一层不是Tanh。还有一种情况是save_image没加normalizeTrue[-1,1]的值被直接截断成[0,1]负半轴全变黑。解决数据管道统一用(0.5,0.5,0.5)均值方差生成器末尾确认是Tanh保存时检查normalizeTrue。三条对上颜色立刻正常。5.5 显存越跑越满最后 OOM现象第一个 epoch 显存占用 6G第十个 epoch 涨到 10G 然后崩掉。原因采样那段没包torch.no_grad()每次netG(fixed_noise)都建了一张计算图挂在显存里或者在循环里反复创建 matplotlib figure 没关掉后台越积越多。解决所有推理性质的调用都套with torch.no_grad():可视化统一用torchvision.utils.save_image别在训练循环里碰 matplotlib如果必须用记得plt.close(all)。这一条我在三个项目里都栽过现在写采样代码已经形成肌肉记忆了。6. 二次开发换数据集和升级损失函数的两条路这份实现的骨架足够小往两个方向改最划算——换数据或者把损失函数升级。换数据集只需要动三处。第一处是dataroot指向新目录图片按 ImageFolder 要求铺好第二处是image_size和网络层数64 改 128 就得多加一层ConvTranspose2d(ngf, nc, 4, 2, 1)在最后同时判别器前面补一层对应下采样第三处是nc通道数灰度图设 1RGB 设 3忘了改会在第一次 forward 时报通道不匹配。升级到 WGAN-GP 的改动集中在损失函数网络结构基本不用动但要砍掉两样东西判别器最后的Sigmoid以及所有BatchNorm2d——WGAN-GP 对每个样本独立算梯度惩罚BN 的跨样本统计会破坏这个假设通常换成InstanceNorm2d或者干脆去掉。def gradient_penalty(netD, real, fake, device, lambda_gp10): WGAN-GP 的核心在真假样本连线上采样惩罚梯度范数偏离 1 alpha torch.rand(real.size(0), 1, 1, 1, devicedevice) interpolated (alpha * real (1 - alpha) * fake).requires_grad_(True) score netD(interpolated) grad torch.autograd.grad( outputsscore, inputsinterpolated, grad_outputstorch.ones_like(score), create_graphTrue, retain_graphTrue, only_inputsTrue, )[0] grad_norm grad.view(grad.size(0), -1).norm(2, dim1) return lambda_gp * ((grad_norm - 1) ** 2).mean()这段里的create_graphTrue不能省因为惩罚项本身还要参与反向传播是对梯度的梯度。lambda_gp10是原论文的经验值改小到 1 会约束不足改大到 100 会让训练变慢且判别器欠拟合。接进训练循环时判别器的总损失变成loss_fake - loss_real gp生成器依然是最大化netD(fake)的均值且判别器每更新一次、生成器更新一次比例要改成 1:1 而不是 DCGAN 那种可以调的比例。验证改造成不成功别只看 loss。我的做法是跑完第一个 epoch 就把outputs/samples里最早的图和最新的图并排看如果连轮廓都没出来后面的训练基本白搭同时把loss_D - loss_G的差值打到日志里WGAN 系列的判别器 loss 应该是缓慢下降的平滑曲线忽高忽低说明惩罚系数没调对。从那次把一个 128x128 的模型硬塞进 8G 显存、跑到第 14 个 epoch 才 OOM 之后我每次改网络结构都强制先跑 20 个 iteration 的 dry run用torch.cuda.max_memory_allocated()打一下峰值显存再决定 batch_size。这个两分钟的习惯救回来的时间大概是几十个小时。希望帮到你。本文还有配套的精品资源点击获取