PyTorch实战DCGAN:从零生成MNIST手写数字

发布时间:2026/10/20 5:25:28
PyTorch实战DCGAN:从零生成MNIST手写数字
1. 为什么选择 DCGAN 作为生成模型的入门实战搞深度学习的人多少都动过“让模型自己画东西”的念头。图像分类、目标检测这些任务做多了总会想试试生成模型到底是怎么从一堆随机噪声里“无中生有”造出图像的。而 DCGANDeep Convolutional Generative Adversarial Networks深度卷积生成对抗网络几乎是所有人绕不开的第一站。它结构清晰、代码量可控、训练成本低配合 MNIST 这种轻量数据集一台普通带显卡的机器甚至 CPU 都能跑起来看效果。我当初选 DCGAN 入手核心原因有三个。第一它把 GAN 里最让人头疼的全连接层换成了卷积结构生成器和判别器都用卷积网络搭建这让图像的空间特征能被有效捕捉而不是像原始 GAN 那样把图像拉平成一维向量硬怼。第二DCGAN 提出了一套相对明确的架构设计准则比如生成器用转置卷积上采样、判别器用步长卷积下采样、引入批归一化、去掉全连接层、生成器输出层用 Tanh、判别器用 LeakyReLU 等。这些准则不是拍脑袋来的而是大量实验总结出来的稳定训练配方对新手极其友好。第三MNIST 数据集只有 6 万张 28x28 的灰度图训练速度快几十分钟就能看到生成器从输出噪声逐渐变成能辨认的数字轮廓这种正反馈对建立信心特别重要。这个实战项目的目标很明确用 PyTorch 从零搭建一个 DCGAN在 MNIST 上训练最终让生成器能够根据输入的随机噪声生成手写数字图像。适合的读者是对 PyTorch 有基本了解、知道卷积和反向传播大概怎么回事、但没怎么接触过生成模型的人。如果你连nn.Conv2d的参数都还不清楚建议先补一下卷积基础再来看这篇不然有些细节会卡住。整个流程我会拆成几个部分来讲先讲整体设计思路和架构选型的理由再深入生成器和判别器的每一层细节然后给出完整的实操代码和训练过程记录最后整理我在调试过程中踩过的坑和排查方法。代码我会尽量写得可以直接复制运行参数也会解释清楚为什么这么设。2. 整体架构设计与核心思路拆解2.1 生成对抗网络的基本博弈逻辑GAN 的核心思想其实很像“造假者和鉴定师”的博弈。生成器 G 负责把随机噪声 z 映射成假图像判别器 D 负责判断一张图是真实的还是生成的。训练过程中D 努力提升自己的鉴别能力G 努力骗过 D。两者交替优化最终理想状态下 G 生成的图像足以以假乱真D 的输出概率趋近于 0.5也就是完全分不清真假。用数学语言描述这是一个极小极大博弈min_G max_D V(D, G) E[log D(x)] E[log(1 - D(G(z)))]其中 x 是真实图像z 是随机噪声。D 想最大化这个值把真实图判为真、生成图判为假G 想最小化它让 D 把生成图判为真。实际训练时不会真的去解这个博弈的全局最优而是用梯度下降交替更新 D 和 G 的参数。这里有个关键点很多人一开始会绕进去G 的损失函数在实践中有两种写法。一种是log(1 - D(G(z)))最小化另一种是log(D(G(z)))最大化。后者在训练初期梯度更大收敛更快所以实际代码里通常用后者。我在下面的实现里也会采用这种写法到时候会具体说明。2.2 DCGAN 架构准则与选型理由原始 GAN 用全连接层处理图像参数量巨大且生成质量差。DCGAN 论文提出了几条关键准则我逐条解释为什么这么设计用转置卷积替代上采样加卷积。生成器需要把低维噪声逐步放大成图像。传统做法是先插值放大再卷积但转置卷积也叫反卷积把上采样和卷积合在一起让网络自己学习如何放大效果更好。PyTorch 里对应nn.ConvTranspose2d。判别器用步长卷积替代池化。池化层是固定的下采样操作没有可学习参数。用步长大于 1 的卷积来做下采样让网络自己学怎么降维保留更多信息。PyTorch 里就是nn.Conv2d设置stride2。引入批归一化。GAN 训练不稳定是出了名的批归一化BatchNorm能稳定训练、加速收敛。但要注意生成器的输出层和判别器的输入层不加 BatchNorm因为输出层需要保持 Tanh 的饱和特性输入层加 BN 会破坏原始图像的分布信息。去掉全连接层。全连接层参数多且容易过拟合DCGAN 全部用卷积结构生成器输入是一个四维张量而不是一维向量。激活函数的选择。生成器中间层用 ReLU输出层用 Tanh输出范围 -1 到 1判别器所有层用 LeakyReLU负斜率 0.2输出层用 Sigmoid输出概率 0 到 1。LeakyReLU 是为了避免负区间梯度为零导致的“神经元死亡”。这些准则不是死规矩但作为入门照着做能避开大量坑。等你跑通了再尝试改结构心里才有底。2.3 针对 MNIST 的适配调整MNIST 图像是 28x28 单通道灰度图比 DCGAN 论文里用的 LSUN、ImageNet 小得多。直接套用论文里的 64x64 或 128x128 架构会显得过重而且 28 这个尺寸对下采样不太友好28 除以 2 得 14再除以 2 得 7再除就成小数了。我的处理方式是把图像 resize 到 32x32这样经过两次步长 2 的卷积后得到 8x8再经过一次得到 4x4维度变化很规整。生成器的输入噪声维度设为 100这是 DCGAN 论文的默认值实测对 MNIST 也够用。噪声先经过一个全连接层映射成 4x4x256 的特征图然后通过转置卷积逐步放大到 32x32。判别器反过来从 32x32 逐步下采样到 4x4最后通过一个卷积或全连接输出单个概率值。注意把 28x28 resize 到 32x32 会引入轻微插值模糊但对 MNIST 这种简单图像影响很小换来的是架构的规整性我认为是值得的。如果你坚持用 28x28生成器最后一层输出尺寸要仔细计算否则会报维度不匹配的错。3. 生成器与判别器的逐层细节解析3.1 生成器网络结构设计生成器的任务是把 100 维噪声变成 32x32 的图像。我把它分成几个阶段第一阶段噪声向量经过全连接层变成 4x4x256 的张量然后 reshape 成(batch, 256, 4, 4)。这一步相当于给噪声一个空间结构让它后续能做卷积操作。第二阶段用转置卷积把 4x4 放大到 8x8通道数从 256 降到 128。转置卷积的参数是kernel_size4, stride2, padding1这个组合能让输出尺寸正好翻倍。计算公式是output (input - 1) * stride - 2 * padding kernel_size代入 4、2、1、4 得(4-1)*2 - 2 4 8正确。第三阶段8x8 放大到 16x16通道数从 128 降到 64参数同上。第四阶段16x16 放大到 32x32通道数从 64 降到 1输出层用 Tanh 把值压到 -1 到 1。每一层转置卷积后面接 BatchNorm 和 ReLU但最后一层只接 Tanh不加 BatchNorm。原因前面说过输出层需要保持原始输出范围。import torch import torch.nn as nn class Generator(nn.Module): def __init__(self, noise_dim100): super(Generator, self).__init__() self.noise_dim noise_dim self.fc nn.Linear(noise_dim, 256 * 4 * 4) self.net nn.Sequential( # 输入: (batch, 256, 4, 4) nn.ConvTranspose2d(256, 128, kernel_size4, stride2, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), # 输出: (batch, 128, 8, 8) nn.ConvTranspose2d(128, 64, kernel_size4, stride2, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), # 输出: (batch, 64, 16, 16) nn.ConvTranspose2d(64, 1, kernel_size4, stride2, padding1), nn.Tanh() # 输出: (batch, 1, 32, 32) ) def forward(self, z): x self.fc(z) x x.view(-1, 256, 4, 4) return self.net(x)这里有个细节值得说nn.Linear的输入是(batch, noise_dim)输出是(batch, 256*4*4)然后 view 成四维。如果你输入的噪声是(batch, noise_dim, 1, 1)形状那全连接层就不适用了得改用nn.ConvTranspose2d从 1x1 开始放大。两种写法都行我用全连接是因为更直观。3.2 判别器网络结构设计判别器是生成器的镜像把 32x32 图像逐步下采样成单个概率值。第一层卷积把 1 通道变成 64 通道步长 2尺寸从 32x32 降到 16x16。这一层不加 BatchNorm因为输入是原始图像加 BN 会破坏像素间的原始关系。激活用 LeakyReLU。第二层64 通道变 128 通道16x16 降到 8x8加 BatchNorm 和 LeakyReLU。第三层128 通道变 256 通道8x8 降到 4x4加 BatchNorm 和 LeakyReLU。第四层把 4x4x256 的特征图通过卷积变成 1x1x1然后 Sigmoid 输出概率。这一层可以用kernel_size4的卷积直接压成一个值也可以用全连接。我用卷积因为更简洁。class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.net nn.Sequential( # 输入: (batch, 1, 32, 32) nn.Conv2d(1, 64, kernel_size4, stride2, padding1), nn.LeakyReLU(0.2, inplaceTrue), # 输出: (batch, 64, 16, 16) nn.Conv2d(64, 128, kernel_size4, stride2, padding1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2, inplaceTrue), # 输出: (batch, 128, 8, 8) nn.Conv2d(128, 256, kernel_size4, stride2, padding1), nn.BatchNorm2d(256), nn.LeakyReLU(0.2, inplaceTrue), # 输出: (batch, 256, 4, 4) nn.Conv2d(256, 1, kernel_size4, stride1, padding0), nn.Sigmoid() # 输出: (batch, 1, 1, 1) ) def forward(self, x): return self.net(x).view(-1, 1)最后那个view(-1, 1)是为了把(batch, 1, 1, 1)压成(batch, 1)方便后面算损失。如果你用nn.BCELoss它要求输入和目标形状一致所以这个 reshape 不能省。3.3 参数初始化与权重初始化策略DCGAN 论文里提到所有卷积层和全连接层的权重应该从均值为 0、标准差为 0.02 的正态分布中初始化。这个初始化方式能让训练初期更稳定。PyTorch 默认的初始化是 Kaiming 均匀分布对 GAN 来说不一定最优。def 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) # 使用方式 netG Generator().apply(weights_init) netD Discriminator().apply(weights_init)BatchNorm 的权重初始化为均值 1.0、标准差 0.02偏置初始化为 0。这是因为 BatchNorm 的权重默认是 1表示不做缩放初始化时保持接近 1 但加一点扰动有助于打破对称性。实操心得权重初始化对 GAN 训练的影响比普通分类网络大得多。我有一次偷懒没做自定义初始化生成器输出全是灰色噪声训练了 20 个 epoch 都没起色。加上 0.02 正态初始化后第 5 个 epoch 就能看到数字轮廓了。这个细节别省。4. 完整训练流程与实操记录4.1 数据准备与预处理MNIST 用torchvision.datasets.MNIST直接下载。预处理做两件事resize 到 32x32归一化到 -1 到 1。归一化参数用 0.5 和 0.5因为(x - 0.5) / 0.5能把 0 到 1 的像素值映射到 -1 到 1正好匹配生成器输出层的 Tanh 范围。from torchvision import datasets, transforms from torch.utils.data import DataLoader transform transforms.Compose([ transforms.Resize(32), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) dataloader DataLoader(train_dataset, batch_size128, shuffleTrue, num_workers2)batch_size 设 128 是 DCGAN 论文的推荐值实测对 MNIST 也合适。太小会导致训练不稳定太大则显存吃紧且梯度更新次数少。num_workers 根据你的机器来设 2 或 4 都行Windows 下如果报错就设 0。4.2 损失函数与优化器配置损失函数用二分类交叉熵nn.BCELoss。优化器用 Adam学习率 0.0002beta1 设 0.5。这里 beta1 是个关键参数默认的 0.9 对 GAN 来说动量太大容易导致训练震荡。DCGAN 论文明确建议用 0.5我实测下来确实更稳。import torch.optim as optim criterion nn.BCELoss() optimizerG optim.Adam(netG.parameters(), lr0.0002, betas(0.5, 0.999)) optimizerD optim.Adam(netD.parameters(), lr0.0002, betas(0.5, 0.999))学习率 0.0002 也是论文推荐值。我试过 0.001判别器损失直接崩到 0生成器完全学不动。也试过 0.00005收敛太慢50 个 epoch 才勉强有形状。0.0002 是个甜点值。4.3 训练循环的逐步实现训练循环是 GAN 的核心逻辑上每一轮先更新判别器再更新生成器。判别器的更新分两步先用真实图像算一次损失再用生成图像算一次损失两次梯度累加后一起更新。生成器的更新只有一步生成假图像让判别器判断然后根据判别器的输出算生成器的损失。device torch.device(cuda if torch.cuda.is_available() else cpu) netG Generator().to(device) netD Discriminator().to(device) netG.apply(weights_init) netD.apply(weights_init) epochs 50 noise_dim 100 fixed_noise torch.randn(64, noise_dim, devicedevice) # 固定噪声用于观察生成效果变化 for epoch in range(epochs): for i, (real_imgs, _) in enumerate(dataloader): batch_size real_imgs.size(0) real_imgs real_imgs.to(device) # --------------------- # 更新判别器 # --------------------- netD.zero_grad() # 真实图像 real_labels torch.ones(batch_size, 1, devicedevice) output_real netD(real_imgs) lossD_real criterion(output_real, real_labels) # 生成图像 noise torch.randn(batch_size, noise_dim, devicedevice) fake_imgs netG(noise) fake_labels torch.zeros(batch_size, 1, devicedevice) output_fake netD(fake_imgs.detach()) lossD_fake criterion(output_fake, fake_labels) lossD lossD_real lossD_fake lossD.backward() optimizerD.step() # --------------------- # 更新生成器 # --------------------- netG.zero_grad() output_fake_for_G netD(fake_imgs) lossG criterion(output_fake_for_G, real_labels) # 生成器希望判别器把假图判为真 lossG.backward() optimizerG.step() # 每个 epoch 打印一次损失 print(fEpoch [{epoch1}/{epochs}] LossD: {lossD.item():.4f} LossG: {lossG.item():.4f})有几个地方需要特别注意。第一更新判别器时生成图像的fake_imgs要.detach()切断梯度回传到生成器否则会同时更新生成器参数这是错的。第二更新生成器时损失函数的目标标签是real_labels全 1因为生成器希望判别器把假图判为真。第三netD.zero_grad()和netG.zero_grad()要分别调用不能混。4.4 训练过程监控与生成效果观察光看损失值不够直观最好每个 epoch 用固定噪声生成一批图像保存下来观察生成质量的变化。我通常每 5 个 epoch 保存一次用torchvision.utils.save_image拼成网格。from torchvision.utils import save_image if (epoch 1) % 5 0: with torch.no_grad(): fake_samples netG(fixed_noise).cpu() save_image(fake_samples, foutput/epoch_{epoch1}.png, nrow8, normalizeTrue)normalizeTrue会把 -1 到 1 的值映射回 0 到 1方便查看。nrow8 表示每行放 8 张图64 张正好 8x8 网格。我实际训练时的观察记录第 1 个 epoch 输出全是噪声看不出形状第 5 个 epoch 开始出现模糊的圆形轮廓第 10 个 epoch 能看出数字的大致形状但边缘毛糙第 20 个 epoch 数字清晰可辨偶尔有失败样本第 40 个 epoch 基本稳定生成的数字肉眼很难和真实 MNIST 区分。整个过程在单张中端显卡上大约 30 分钟。注意损失值不是判断生成质量的可靠指标。有时候 LossD 和 LossG 都在 0.5 附近震荡但生成质量很好有时候 LossD 很低生成质量反而差。一定要结合生成的图像来判断。5. 常见问题与排查技巧实录5.1 模式崩溃与生成多样性不足模式崩溃Mode Collapse是 GAN 最经典的问题生成器只生成少数几种样本比如只生成数字“1”其他数字完全不出现。原因是生成器发现只要骗过判别器就行没必要覆盖所有模式。排查方法观察生成的网格图如果 64 张图里大部分长得一样基本就是模式崩溃了。解决方案有几个。一是降低判别器的学习能力比如减少判别器层数或降低学习率让生成器有更多机会学习。二是引入标签信息用条件 GANcGAN把数字标签也输入生成器强制它按类别生成。三是调整损失函数比如用 WGAN 的 Wasserstein 距离替代原始 GAN 损失。对于 MNIST 这种简单数据集模式崩溃不算常见但如果遇到了优先试第一个方案。5.2 判别器过强导致生成器梯度消失训练初期判别器太容易区分真假导致生成器的损失梯度接近零生成器学不动。表现是 LossD 很快降到接近 0LossG 居高不下。解决办法一是给判别器的输入加噪声增加判别难度。二是降低判别器的更新频率比如每更新两次生成器才更新一次判别器。三是用标签平滑Label Smoothing把真实标签从 1 改成 0.9让判别器不要那么自信。# 标签平滑示例 real_labels torch.full((batch_size, 1), 0.9, devicedevice)标签平滑是我最常用的技巧改动小、效果好。实测下来不加平滑时 LossD 经常掉到 0.01 以下加了之后稳定在 0.2 到 0.4 之间生成器训练明显更顺。5.3 生成图像出现棋盘伪影转置卷积有个固有缺陷当 kernel_size 不能被 stride 整除时输出图像会出现棋盘格状的伪影。DCGAN 用的kernel_size4, stride2, padding1正好能整除所以一般不会出现。但如果你改了参数比如用kernel_size3, stride2就可能看到棋盘纹。解决办法要么保持kernel_size4, stride2的组合要么把转置卷积换成“最近邻上采样 普通卷积”。后者生成质量更平滑但计算量稍大。5.4 常见问题速查表问题现象可能原因排查方法解决方案生成图像全是噪声学习率过大或初始化不当检查 LossD 是否骤降降低学习率加权重初始化只生成一种数字模式崩溃观察生成网格图降低 D 学习率加标签平滑LossG 不下降D 过强梯度消失看 LossD 是否接近 0标签平滑降低 D 更新频率生成图像有棋盘纹转置卷积参数不匹配检查 kernel 和 stride用 kernel4, stride2训练中途崩溃学习率或 batch size 不当看损失是否变 NaN降学习率检查数据归一化显存不足batch size 太大看报错信息降到 64 或 325.5 独家避坑技巧第一个技巧训练前先用一个 batch 的数据过一遍生成器和判别器确认输出维度正确。我见过太多人卡在维度不匹配上浪费大量时间。用print(x.shape)在 forward 里打日志跑通一次再正式训练。第二个技巧固定随机种子。GAN 训练对随机性很敏感固定种子能让每次实验可复现方便对比不同参数的效果。torch.manual_seed(42) if torch.cuda.is_available(): torch.cuda.manual_seed_all(42)第三个技巧保存模型检查点。GAN 训练时间长中途可能因为各种原因中断。每个 epoch 保存一次netG.state_dict()和netD.state_dict()中断了也能接着训。torch.save({ epoch: epoch, netG: netG.state_dict(), netD: netD.state_dict(), optimizerG: optimizerG.state_dict(), optimizerD: optimizerD.state_dict(), }, fcheckpoint/epoch_{epoch1}.pth)第四个技巧如果条件允许用 TensorBoard 记录损失曲线和生成图像。可视化能帮你快速定位问题比盯着终端输出强得多。from torch.utils.tensorboard import SummaryWriter writer SummaryWriter(runs/dcgan_mnist) writer.add_scalar(LossD, lossD.item(), epoch) writer.add_scalar(LossG, lossG.item(), epoch) writer.add_image(Generated, fake_samples, epoch, dataformatsNCHW)这套流程跑下来DCGAN 在 MNIST 上的效果是相当可观的。生成器最终能输出清晰的手写数字虽然偶尔有瑕疵但整体质量足以让人对生成模型建立直观认识。后续如果想进一步提升可以尝试条件生成、WGAN-GP、或者把数据集换成 FashionMNIST、CIFAR-10 来增加难度。我个人在实际操作中的体会是GAN 训练三分靠架构、七分靠调参和耐心遇到问题别急着改结构先把学习率、batch size、标签平滑这几个基础项调一遍往往就能解决大半问题。