资讯动态

基于MNIST的DCGAN实战:从零实现手写数字生成

发布时间:2026/10/21 21:20:20 来源:尧图企业网站定制
1. 项目缘起与整体设计思路手写数字生成这个方向我在刚接触生成模型那会儿就折腾过。当时用全连接网络搭了个最朴素的GAN训出来的结果惨不忍睹——要么全是噪点要么所有样本都长一个样模式坍塌得彻彻底底。后来换成DCGANDeep Convolutional Generative Adversarial Networks深度卷积生成对抗网络才真正看到像样的手写数字从噪声里长出来。这篇分享就把我基于MNIST数据集做DCGAN实战的完整过程拆开讲从设计思路到代码落地再到踩过的坑尽量说透。先说清楚这个项目是什么、能做什么。DCGAN本质上是一组对抗训练的双网络结构一个**生成器Generator负责把随机噪声映射成图像一个判别器Discriminator**负责判断输入图像是真实的还是生成的。两者互相博弈最终生成器能骗过判别器输出以假乱真的手写数字。MNIST是入门生成模型最经典的练手数据集6万张28×28的灰度手写数字图体量小、干净、加载快非常适合验证网络结构和训练流程是否正确。适合谁来参考如果你已经会用PyTorch搭简单的CNN分类网络想往生成模型方向迈第一步这个项目就是很好的切入点。它不涉及复杂的注意力机制或扩散模型核心就是卷积、转置卷积、批归一化和对抗损失这几样东西。但别小看它DCGAN里那些训练不稳定、模式坍塌、超参敏感的问题在更大的模型里同样存在把MNIST这关过了后面上手更复杂的生成任务会顺很多。整体设计上我遵循DCGAN原论文的几个关键约束这些约束不是拍脑袋定的每一条都有明确的工程理由用步长卷积替代池化层判别器里不用MaxPool改用stride2的卷积做下采样生成器里用转置卷积做上采样。原因是池化操作会丢失空间信息而生成任务对空间结构的保留要求很高让网络自己学习下采样方式比固定规则更灵活。生成器和判别器都加批归一化BatchNorm除了生成器的输出层和判别器的输入层其余卷积层后都接BN。BN能稳定训练、缓解初始化敏感问题这是DCGAN能训起来的关键之一。去掉全连接层用全卷积结构让网络对空间特征的处理更纯粹。生成器的输入噪声先reshape成向量再逐步上采样。激活函数的选择生成器中间层用ReLU输出层用Tanh把像素值压到[-1,1]判别器统一用LeakyReLU负斜率0.2避免梯度死亡。这套设计在MNIST上跑大概几十个epoch就能看到清晰的数字轮廓。下面我把每个环节拆开细讲。2. 核心细节解析与实操要点2.1 生成器结构从100维噪声到28×28图像生成器的任务是把一个100维的随机向量逐步放大成28×28的灰度图。这里有个容易踩的坑很多人一上来就想直接输出28×28结果转置卷积的尺寸计算总是对不上。我的做法是先上采样到一个稍大的特征图再通过一次卷积调整到目标尺寸。具体结构我这样设计输入100维噪声向量reshape成(batch, 100, 1, 1)第一层转置卷积100→256通道kernel4stride1padding0输出(batch, 256, 4, 4)接BN和ReLU第二层转置卷积256→128通道kernel4stride2padding1输出(batch, 128, 8, 8)接BN和ReLU第三层转置卷积128→64通道kernel4stride2padding1输出(batch, 64, 16, 16)接BN和ReLU第四层转置卷积64→1通道kernel4stride2padding1输出(batch, 1, 32, 32)接Tanh最后加一个卷积或裁剪把32×32调整到28×28这里转置卷积的输出尺寸公式是out (in - 1) * stride - 2 * padding kernel。以第二层为例in4, stride2, padding1, kernel4算出来(4-1)*2 - 2 4 8正好是8×8。这个公式必须记牢尺寸对不上十有八九是这里算错了。提示最后一层从32×32裁到28×28我试过两种方式——直接切片x[:, :, 2:30, 2:30]或者再接一个kernel5、stride1、padding0的普通卷积。切片更简单且不引入额外参数实测效果没差别推荐切片。2.2 判别器结构逐步下采样到单值输出判别器是个二分类网络输入28×28图像输出一个标量表示真实的概率。结构上和生成器镜像对称输入(batch, 1, 28, 28)第一层卷积1→64通道kernel4stride2padding1输出(batch, 64, 14, 14)接LeakyReLU注意这层不加BN因为是输入层第二层卷积64→128通道kernel4stride2padding1输出(batch, 128, 7, 7)接BN和LeakyReLU第三层卷积128→256通道kernel4stride2padding1输出(batch, 256, 3, 3)接BN和LeakyReLU第四层卷积256→1通道kernel3stride1padding0输出(batch, 1, 1, 1)接Sigmoid最后flatten成(batch, 1)判别器输入层不加BN是有讲究的。BN会对每个batch做归一化如果直接作用在原始像素上会破坏图像本身的数值分布反而影响判别器学习真实图像的特征。这个细节在原始论文里明确提到但很多复现教程会忽略。2.3 损失函数与优化器配置损失用标准的二元交叉熵BCE Loss。训练时判别器的目标是对真实图像输出接近1对生成图像输出接近0生成器的目标是让判别器对生成图像输出接近1。优化器我选Adam学习率设0.0002beta1设0.5。这里beta1从默认的0.9改成0.5是DCGAN论文的推荐值原因是动量太大会让对抗训练震荡加剧降低beta1能让更新更平滑。实测下来beta10.5确实比0.9稳定不少尤其是训练前期。学习率0.0002也是个经验值。我试过0.001判别器loss直接崩到0附近生成器完全学不动试过0.00005收敛太慢几十个epoch还是模糊的。0.0002在MNIST这个规模上比较均衡。2.4 数据预处理的关键细节MNIST原始像素值是[0,1]但生成器输出层用Tanh范围是[-1,1]。所以预处理必须把图像归一化到[-1,1]否则生成器和真实数据的分布对不上判别器会轻易区分真假训练直接失败。具体操作transform transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,))])。ToTensor把像素压到[0,1]Normalize用均值0.5、标准差0.5做变换得到(x-0.5)/0.5 2x-1正好落在[-1,1]。这个细节看似小但漏掉的话训练根本跑不起来。注意生成图像可视化时要把[-1,1]反归一化回[0,1]再显示否则matplotlib画出来会一片黑或者对比度异常。反变换就是x * 0.5 0.5。3. 完整实操流程与核心代码实现3.1 环境准备与依赖安装环境这块没什么特殊要求PyTorch 1.8以上都行有GPU最好没有的话CPU也能跑只是慢一些。MNIST数据量小CPU上一个epoch大概一两分钟能接受。pip install torch torchvision matplotlib numpy如果你有CUDA装对应版本的torch即可。我这边用的是torch 2.0 CUDA 11.8跑起来很顺。3.2 数据加载与批处理import torch import torch.nn as nn import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader # 预处理归一化到[-1, 1] transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) # 加载MNIST训练集 train_dataset torchvision.datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransform ) train_loader DataLoader( train_dataset, batch_size128, shuffleTrue, num_workers2 )batch_size我设128。试过64和25664训练更稳但慢256偶尔会震荡128是个折中。num_workers根据你机器核数调整设2或4都行。3.3 生成器与判别器的代码实现class Generator(nn.Module): def __init__(self, noise_dim100): super(Generator, self).__init__() self.net nn.Sequential( # 输入: (batch, 100, 1, 1) - (batch, 256, 4, 4) nn.ConvTranspose2d(noise_dim, 256, 4, 1, 0, biasFalse), nn.BatchNorm2d(256), nn.ReLU(True), # (batch, 256, 4, 4) - (batch, 128, 8, 8) nn.ConvTranspose2d(256, 128, 4, 2, 1, biasFalse), nn.BatchNorm2d(128), nn.ReLU(True), # (batch, 128, 8, 8) - (batch, 64, 16, 16) nn.ConvTranspose2d(128, 64, 4, 2, 1, biasFalse), nn.BatchNorm2d(64), nn.ReLU(True), # (batch, 64, 16, 16) - (batch, 1, 32, 32) nn.ConvTranspose2d(64, 1, 4, 2, 1, biasFalse), nn.Tanh() ) def forward(self, x): x self.net(x) # 裁剪到28x28 return x[:, :, 2:30, 2:30] class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.net nn.Sequential( # (batch, 1, 28, 28) - (batch, 64, 14, 14) nn.Conv2d(1, 64, 4, 2, 1, biasFalse), nn.LeakyReLU(0.2, inplaceTrue), # (batch, 64, 14, 14) - (batch, 128, 7, 7) nn.Conv2d(64, 128, 4, 2, 1, biasFalse), nn.BatchNorm2d(128), nn.LeakyReLU(0.2, inplaceTrue), # (batch, 128, 7, 7) - (batch, 256, 3, 3) nn.Conv2d(128, 256, 4, 2, 1, biasFalse), nn.BatchNorm2d(256), nn.LeakyReLU(0.2, inplaceTrue), # (batch, 256, 3, 3) - (batch, 1, 1, 1) nn.Conv2d(256, 1, 3, 1, 0, biasFalse), nn.Sigmoid() ) def forward(self, x): return self.net(x).view(-1, 1)注意生成器里所有ConvTranspose2d都设了biasFalse因为后面接了BatchNormBN本身有偏置项再加卷积偏置是冗余的还会增加参数量。判别器同理。这个细节能省一点显存也让训练更干净。3.4 训练循环与损失计算device torch.device(cuda if torch.cuda.is_available() else cpu) G Generator().to(device) D Discriminator().to(device) criterion nn.BCELoss() optimizer_G torch.optim.Adam(G.parameters(), lr0.0002, betas(0.5, 0.999)) optimizer_D torch.optim.Adam(D.parameters(), lr0.0002, betas(0.5, 0.999)) noise_dim 100 epochs 50 for epoch in range(epochs): for i, (real_imgs, _) in enumerate(train_loader): batch_size real_imgs.size(0) real_imgs real_imgs.to(device) # 真实/生成标签 real_labels torch.ones(batch_size, 1).to(device) fake_labels torch.zeros(batch_size, 1).to(device) # --------------------- # 训练判别器 # --------------------- optimizer_D.zero_grad() # 真实图像损失 output_real D(real_imgs) loss_D_real criterion(output_real, real_labels) # 生成图像损失 noise torch.randn(batch_size, noise_dim, 1, 1).to(device) fake_imgs G(noise) output_fake D(fake_imgs.detach()) loss_D_fake criterion(output_fake, fake_labels) loss_D loss_D_real loss_D_fake loss_D.backward() optimizer_D.step() # --------------------- # 训练生成器 # --------------------- optimizer_G.zero_grad() output_fake D(fake_imgs) loss_G criterion(output_fake, real_labels) # 生成器希望判别器认为是真的 loss_G.backward() optimizer_G.step() print(fEpoch [{epoch1}/{epochs}] | Loss_D: {loss_D.item():.4f} | Loss_G: {loss_G.item():.4f})训练判别器时对生成图像要加.detach()切断梯度回传到生成器。这一步很关键不加的话生成器的梯度会被判别器的反向传播污染训练直接乱套。我一开始就栽在这loss曲线看着正常但生成结果一直是噪声排查了半天才发现是漏了detach。3.5 训练过程监控与结果可视化每训练几个epoch我会保存一批生成图像看看效果。可视化代码import matplotlib.pyplot as plt def show_generated(G, noise_dim, device, n16): G.eval() with torch.no_grad(): noise torch.randn(n, noise_dim, 1, 1).to(device) fake G(noise).cpu() # 反归一化到[0,1] fake fake * 0.5 0.5 fake fake.clamp(0, 1) fig, axes plt.subplots(4, 4, figsize(6, 6)) for idx, ax in enumerate(axes.flatten()): ax.imshow(fake[idx, 0], cmapgray) ax.axis(off) plt.tight_layout() plt.show() G.train()注意可视化前要G.eval()切到推理模式BN层用移动平均而不是当前batch统计量这样生成的图像更稳定。看完再切回G.train()继续训练。这个细节很多人不注意导致可视化结果和实际训练状态不一致。训练过程的典型表现是这样的前5个epoch生成器输出基本是模糊的灰色块10到20个epoch开始出现数字轮廓但笔画断裂、形状扭曲30个epoch以后数字逐渐清晰能认出0到950个epoch基本稳定偶尔有几个模糊样本但整体质量不错。如果50个epoch还是噪声那肯定是哪里配置错了重点检查归一化范围、detach、学习率这三项。4. 常见问题与排查技巧实录4.1 训练不收敛的典型症状与对策DCGAN训练不稳定是出了名的我把实际遇到过的几类问题整理成速查表症状可能原因排查与解决生成器输出全是噪声漏了detach或归一化范围不对检查判别器对fake的输入是否detach确认数据在[-1,1]所有生成样本一模一样模式坍塌生成器找到判别器盲点降低生成器学习率或给判别器加噪声Loss_D一直接近0判别器太强生成器学不动降低判别器学习率或减少判别器更新频率Loss_G持续上升不下降生成器太弱或学习率过高检查生成器结构适当降低学习率生成图像有网格状伪影转置卷积的棋盘效应改用最近邻上采样普通卷积替代转置卷积模式坍塌是最头疼的问题。我遇到过一次生成器无论输入什么噪声输出都是同一个1。后来把生成器的学习率从0.0002降到0.0001同时给判别器的真实标签做了标签平滑从1.0改成0.9才慢慢恢复多样性。标签平滑是个实用技巧能防止判别器过于自信给生成器更多学习空间。4.2 转置卷积的棋盘效应转置卷积有个固有缺陷叫棋盘效应checkerboard artifact生成图像上会出现规律性的网格纹路。原因是转置卷积的卷积核在重叠区域会产生不均匀的覆盖。在MNIST这种小图上不太明显但放大看还是能发现。解决办法有两个一是把转置卷积换成最近邻上采样普通卷积的组合先放大特征图再用卷积平滑二是调整kernel和stride的组合让重叠区域均匀。我试过第一种方案生成质量确实更平滑但训练速度稍慢一点。如果对图像质量要求高推荐用上采样卷积的方案。4.3 显存不足与batch_size调整MNIST的DCGAN模型不大显存占用主要来自batch_size。如果你显卡显存小比如4Gbatch_size128可能报OOM。这时候别急着改网络结构先把batch_size降到64或32。batch_size变小后BN的统计量会不太稳定可以适当把学习率也调小一点比如0.0001。另外训练时可以用torch.cuda.empty_cache()定期清理缓存尤其是在频繁保存图像或切换模型模式的时候。这个操作能释放一些碎片化的显存虽然治标不治本但应急够用。4.4 评估生成质量的实用方法GAN没有像分类任务那样的准确率指标评估生成质量一直是个难题。我在实践中用这几个方法组合判断肉眼观察最直接每5个epoch保存一批图看数字是否清晰、多样。判别器得分统计判别器对生成图像的平均输出理想情况在0.5左右判别器分不清真假。如果长期低于0.2说明生成质量差长期高于0.8说明判别器太弱。多样性检查固定噪声看输出变化或者统计生成图像的像素均值方差方差太小说明模式坍塌。提示别只看loss曲线判断训练好坏。GAN的loss和生成质量没有直接对应关系loss_D下降不代表生成变好有时候反而是判别器过强的信号。以肉眼观察为主loss为辅。4.5 几个容易被忽略的实操心得第一随机种子固定。调试阶段固定torch和numpy的随机种子保证每次运行结果可复现方便对比不同配置的效果。正式训练时可以放开。第二保存模型检查点。每10个epoch保存一次生成器和判别器的state_dict万一训练中途崩了或者想回退到某个阶段不至于从头再来。我吃过这个亏训了40个epoch机器重启全没了。第三噪声维度别乱改。noise_dim100是DCGAN的默认值改成10或1000都试过10维信息量不够生成多样性差1000维训练变慢且容易过拟合。100是个经过验证的平衡点。第四判别器不要训太狠。有些教程让判别器每个batch更新多次在MNIST上没必要反而容易压死生成器。1:1的更新频率就够用。第五可视化时用固定噪声。每次可视化用同一组噪声向量这样能直观看到同一个噪声在不同训练阶段生成结果的变化比每次随机噪声更能反映训练进展。5. 从MNIST到更复杂数据集的扩展思路MNIST跑通之后很多人会想往更难的数据集上迁移。我顺着这个方向试过几个分享一些经验。往彩色图像扩展最直接的是换到CIFAR-10。输入从1通道变3通道生成器输出层通道数改成3其他结构基本不用动。但CIFAR-10的复杂度比MNIST高一个量级同样的网络结构训出来效果会差很多需要加深网络层数、增加通道数训练epoch也要翻几倍。我当时的经验是MNIST上50个epoch能出好结果CIFAR-10至少要200个epoch而且还得调学习率衰减策略。往高分辨率扩展比如生成128×128的人脸网络深度和参数量都要大幅增加。这时候转置卷积的棋盘效应会更明显建议直接改用上采样卷积的方案。另外高分辨率训练对显存要求高batch_size可能要降到16甚至8训练时间成倍增长。还有一个方向是条件生成也就是cGAN。给生成器和判别器都加上标签输入就能控制生成指定数字的图像。这个改动不大生成器输入从纯噪声变成噪声标签embedding判别器输入变成图像标签。做出来之后可以指定生成3或7实用性更强。这个我后面会单独写一篇展开。从工程角度看MNIST上的DCGAN是个很好的试验田。网络小、训练快、问题暴露得充分把这里的问题都踩一遍迁移到更大规模任务时心里有底。我个人的体会是生成模型调参的功夫一大半是在小数据集上练出来的——因为反馈快能快速验证想法。等到了大模型上一次实验几小时甚至几天试错成本太高没有小数据集上积累的直觉根本玩不转。最后分享一个我常用的调试习惯每次改配置只改一个变量其他保持不动跑完对比结果。GAN的超参之间耦合很强一次改多个出了问题根本不知道是哪个引起的。这个习惯看着笨但能省下大量排查时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑