DCGAN实战:从噪声生成手写数字的原理与工程实现

DCGAN实战:从噪声生成手写数字的原理与工程实现 1. 项目概述这不是“画图”而是一场数字世界的造物实验你有没有想过计算机是怎么“学会”画出一张看起来像手写数字的图片的不是靠程序员一行行写规则去描摹0到9的笔画而是让机器自己从成千上万张真实的手写数字照片里一点点摸清“数字该长什么样”的内在规律——然后凭空生成一张从未存在过的、但谁都觉得“这肯定是个7”的新图。这就是**DCGAN深度卷积生成对抗网络**在做的事情。它不是图像处理工具而是一个数字世界的“造物主训练框架”。我第一次跑通这个MNIST生成项目时盯着终端里跳动的D_loss: 0.32, G_loss: 1.87心里想的不是代码对不对而是此刻一个没有见过任何人类笔迹的神经网络正在尝试理解“7”这个符号背后的空间结构、笔锋转折和灰度分布。它失败了上百次但每一次失败都在修正它脑中那个虚构的“数字宇宙”的物理法则。这个标题里的From Noise to Numbers说的正是这场实验最震撼的起点与终点输入端我们只给它一串完全随机的、毫无意义的浮点数我们叫它“噪声向量”比如[0.12, -0.87, 0.44, ..., 1.03]长度通常是100输出端它却能吐出一张28×28像素、灰度值在0-1之间的、清晰可辨的“2”或“5”。中间发生了什么不是魔法是**生成器Generator和判别器Discriminator**之间一场持续数百轮的、零和博弈式的“猫鼠游戏”生成器拼命伪造逼真的数字去骗过判别器判别器则日复一日地苦练火眼金睛分辨真伪。它们互相拉扯、共同进化最终达到一种精妙的平衡——生成器产出的假图已经能让判别器的准确率跌到50%也就是纯靠猜。这时候你就知道它真的“懂”了。这个项目之所以被选作深度学习入门的“圣杯级”练手是因为它把几个核心概念拧成了一个可触摸的实体反向传播如何驱动两个网络同步进化、卷积层为何是图像任务的天然选择、BatchNorm如何稳定训练过程、为什么LeakyReLU比ReLU更适合判别器……它不教你调参口诀而是逼你亲手调试每一个超参数感受学习率高了会怎样震荡噪声向量维度小了会导致生成多样性枯竭BatchNorm放在生成器最后一层会怎样破坏输出分布。它适合三类人刚学完PyTorch基础、想立刻看到“模型动起来”的新手正在啃《深度学习》花书、需要一个具体锚点来理解GAN原理的理论学习者以及所有想亲手验证“AI创造力”边界在哪里的实践派。它不承诺你做出能商用的生成模型但它会给你一把刻刀让你亲手雕琢出第一个属于自己的、会呼吸的数字生命。2. 核心设计逻辑为什么是DCGAN而不是随便一个GAN2.1 DCGAN不是“升级版GAN”而是为图像量身定制的工程范式原始的GAN论文Goodfellow, 2014提出了一个天才的博弈框架但它的实现非常“朴素”生成器和判别器都用的是全连接层MLP。当你把这种结构直接套在28×28的MNIST图像上时会立刻撞上两堵墙。第一堵是参数爆炸一张28×28的图有784个像素点如果生成器第一层要从100维噪声映射到784维输出光这一层权重矩阵就是100×78478,400个参数。而真实DCGAN的生成器从100维噪声开始先通过全连接层变成4×4×512的特征图即4行4列、每格含512个通道再经过三次转置卷积Transpose Convolution逐步放大尺寸。我们来算一笔账4×4×512 8,192个参数远小于78,400。第二堵是空间信息丢失全连接层把图像当成了一个扁平的向量彻底抹杀了“相邻像素关系更紧密”这个图像最根本的先验知识。而卷积层天生就带着“局部感受野”的烙印它强迫网络去关注“左上角那块区域的纹理是否连贯”而不是去记忆“第327个像素点应该是什么值”。所以DCGANRadford et al., 2015的贡献本质上是一份面向图像生成的工程最佳实践白皮书。它没有发明新数学而是用一系列看似琐碎、实则致命的“规定动作”把GAN从一个脆弱的理论构想变成了一个鲁棒的、可复现的、能稳定产出结果的工具链。这些规定就是我们搭建这个MNIST项目的骨架。2.2 四大支柱DCGAN成功背后的硬性约束DCGAN论文里明确列出的架构约束不是建议而是“不遵守就大概率失败”的铁律。我在第一次实现时曾天真地把生成器的最后一层BatchNorm去掉结果训练全程G_loss纹丝不动生成的全是灰色噪点。后来才明白这些约束是前人踩过无数坑后用血泪凝结成的“防错指南”。第一支柱全部使用卷积彻底告别全连接除了输入/输出层生成器的输入是100维噪声向量它必须先经过一个全连接层把它“展开”成一个小型的三维特征图如4×4×512。这是唯一允许的全连接层。之后的所有上采样操作必须用转置卷积也叫反卷积。判别器则相反从28×28×1的图像输入开始用标准卷积层层下采样最后用一个全连接层输出一个标量真假概率。为什么因为卷积的权值共享特性让网络能以极低的参数量学到“某种笔画模式在图像任意位置出现都应被识别”的平移不变性。而全连接层做不到这点。第二支柱BatchNorm是生成器的“氧气”判别器的“镇定剂”生成器里每一层除输入层外后面都必须跟BatchNorm。它的作用远不止“加速收敛”。想象一下噪声向量的均值是0、方差是1但经过第一层转置卷积后输出特征图的均值和方差可能变得极其诡异比如均值飙升到10方差崩到1000。如果没有BatchNorm把它强行拉回“健康区间”后续层的输入就会严重失衡梯度要么爆炸要么消失。判别器里BatchNorm只加在中间层输入层和输出层绝对不能加。原因在于真实图像的统计特性均值、方差和生成图像的统计特性在训练初期天差地别。如果在判别器输入层就做归一化等于强行把“真图”和“假图”塞进同一个统计分布里相当于给判别器戴上了模糊眼镜让它失去了最基础的分辨能力。第三支柱激活函数的“性格匹配”生成器的所有隐藏层用ReLU输出层用Tanh。ReLU的“单侧抑制”特性负数全变0能有效防止梯度消失让网络深层也能学到东西而Tanh的输出范围是[-1, 1]正好匹配我们预处理MNIST数据时把像素值从[0, 1]映射到[-1, 1]的操作这是DCGAN的另一个隐含要求。判别器则全部使用LeakyReLU负斜率设为0.2。为什么不用ReLU因为ReLU在输入为负时梯度直接归零。而判别器的输入无论是真图还是假图其特征图里必然存在大量负值响应。如果用ReLU这部分梯度就永远消失了判别器的学习能力会被阉割一半。LeakyReLU给了负值一个微小的、非零的梯度0.2倍保证了信息流的完整。第四支柱优化器的“双轨制”与学习率的“黄金比例”DCGAN严格规定生成器和判别器都必须用Adam优化器且beta1一阶矩估计的指数衰减率设为0.5。标准Adam的beta1是0.9这个改动极其关键。beta10.9会让Adam对梯度的历史趋势过于“信任”在GAN这种双方剧烈博弈的场景下容易导致优化方向僵化陷入局部最优。beta10.5则让Adam更“短视”更依赖当前batch的真实梯度从而赋予整个系统更强的动态适应能力。学习率方面论文给出的基准是lr0.0002。这个数字不是拍脑袋来的它足够小能避免判别器在早期就把生成器的梯度炸飞又足够大能保证生成器在后期仍有足够的更新力度。我做过对比实验当lr提高到0.001时D_loss瞬间降到接近0G_loss却飙到5以上生成器彻底“学废了”。提示这四大支柱不是装饰品而是DCGAN能跑通的“最小可行集”。漏掉任何一个你都会在训练中途遭遇无法解释的崩溃。比如忘了把MNIST像素归一化到[-1, 1]Tanh输出层就会和数据分布错位生成器永远学不会输出正确的灰度范围。3. 核心细节拆解从代码到像素的每一处精微把控3.1 数据预处理为什么要把[0,1]变成[-1,1]很多人会忽略数据预处理这一步认为“不就是读个图吗”。但恰恰是这一步埋下了后续所有训练稳定的伏笔。MNIST官方数据集的像素值范围是[0, 255]我们通常会先除以255把它缩放到[0, 1]。但这还不够。DCGAN要求生成器的输出层用Tanh激活函数而Tanh的天然输出范围是[-1, 1]。如果我们让生成器努力学习输出[0, 1]范围的值而它的“嘴巴”Tanh天生只能吐出[-1, 1]这就造成了严重的输出-目标不匹配。解决方案是在把数据喂给判别器之前再做一次线性变换transformed_pixel pixel * 2 - 1。这样原来的0变成-11变成1完美对齐Tanh的输出域。这个操作在PyTorch里用transforms.Normalize就能完成transform transforms.Compose([ transforms.ToTensor(), # 自动将[0,255] - [0,1] transforms.Normalize(mean(0.5,), std(0.5,)) # [0,1] - [-1,1] ])这里mean0.5, std0.5的计算逻辑是normalized (x - mean) / std (x - 0.5) / 0.5 2*x - 1。这个看似简单的两行代码实则是打通生成器“表达欲”和数据“真实感”之间最后一公里的关键桥梁。我曾经因为忘记这一步花了整整两天时间调试反复检查网络结构、损失函数最后发现生成器输出的全是饱和的纯黑-1或纯白1块根源就在这里。3.2 生成器Generator从100维噪声到28×28图像的精密“编织机”生成器的结构可以理解为一台高度自动化的“织布机”。它的输入100维噪声是100根不同颜色的纱线它的任务是把这些纱线按照特定的经纬规则编织成一幅28×28的灰度图案。整个过程分为三个阶段阶段一从“线团”到“底布”全连接 Reshapeself.main nn.Sequential( # 输入: [batch_size, 100] nn.Linear(100, 4*4*512, biasFalse), nn.BatchNorm1d(4*4*512), nn.ReLU(True), # 输出: [batch_size, 4*4*512] - reshape为 [batch_size, 512, 4, 4] )这里4*4*512不是随意选的。4×4是最终特征图的最小尺寸太小会导致上采样失真太大则参数过多512是通道数越大网络容量越强但也越难训。biasFalse是因为后面紧跟BatchNorm偏置项会被归一化掉留着反而浪费计算。阶段二从“底布”到“初稿”转置卷积上采样# 第一次上采样: [512,4,4] - [256,8,8] nn.ConvTranspose2d(512, 256, kernel_size4, stride2, padding1, biasFalse), nn.BatchNorm2d(256), nn.ReLU(True), # 第二次上采样: [256,8,8] - [128,16,16] nn.ConvTranspose2d(256, 128, kernel_size4, stride2, padding1, biasFalse), nn.BatchNorm2d(128), nn.ReLU(True), # 第三次上采样: [128,16,16] - [1,28,28] nn.ConvTranspose2d(128, 1, kernel_size4, stride2, padding1, biasFalse), nn.Tanh()kernel_size4, stride2, padding1是DCGAN的“黄金三件套”。它的效果是输入特征图尺寸为H×W输出尺寸为(H-1)*22 2H同理2W即完美地将尺寸翻倍。padding1是为了补偿卷积核带来的边缘信息损失保证输出尺寸精准。你可以把它想象成每次上采样都是把原图每个像素“克隆”成2×2的色块再用一个4×4的滤镜对这些色块进行柔化混合让边缘不那么生硬。阶段三输出校准Tanh的终极使命最后一层的Tanh是生成器的“画笔”。它确保无论前面网络怎么“胡思乱想”最终落在画布28×28图像上的每一个像素点其灰度值都严格落在[-1, 1]区间内。这为后续与真实数据同样被归一化到[-1, 1]的对比提供了公平的舞台。3.3 判别器Discriminator一位极度挑剔的“数字鉴宝师”判别器的设计哲学与生成器截然相反。它不需要创造只需要毁灭——准确地说是精准地识别。因此它的结构是“由表及里”的层层剥茧self.main nn.Sequential( # 输入: [batch_size, 1, 28, 28] nn.Conv2d(1, 64, kernel_size4, stride2, padding1, biasFalse), nn.LeakyReLU(0.2, inplaceTrue), # [64,14,14] nn.Conv2d(64, 128, kernel_size4, stride2, padding1, biasFalse), nn.BatchNorm2d(128), nn.LeakyReLU(0.2, inplaceTrue), # [128,7,7] nn.Conv2d(128, 256, kernel_size4, stride2, padding1, biasFalse), nn.BatchNorm2d(256), nn.LeakyReLU(0.2, inplaceTrue), # [256,3,3] - 展平 nn.Conv2d(256, 1, kernel_size3, stride1, padding0, biasFalse), # 输出: [batch_size, 1, 1, 1] - squeeze为 [batch_size] )注意几个关键点第一层没有BatchNorm前面已强调所有卷积层stride2实现下采样最后一层卷积的kernel_size3因为输入是[256,3,3]用3×3卷积刚好得到[1,1,1]的标量输出。这里没有用全连接层做最后的分类而是用了一个1×1的卷积等价于全连接这是为了保持“卷积一致性”避免引入全连接层特有的过拟合风险。判别器的输出是一个标量logit未经过Sigmoid。我们在计算损失时会用nn.BCEWithLogitsLoss()它内部会自动加上Sigmoid并计算二元交叉熵。这样做比手动加Sigmoid再算BCE更数值稳定能有效防止logit过大时Sigmoid饱和导致的梯度消失。3.4 损失函数一场精心设计的“零和博弈”契约GAN的损失函数是整个对抗思想的数学化身。DCGAN沿用了原始GAN的Minimax Loss但实现上做了关键优化criterion nn.BCEWithLogitsLoss() # 判别器损失: 真图-1, 假图-0 real_labels torch.full((batch_size,), 1.0, devicedevice) fake_labels torch.full((batch_size,), 0.0, devicedevice) # 对真图打分 output_real netD(real_images).view(-1) errD_real criterion(output_real, real_labels) # 对假图打分 output_fake netD(fake_images.detach()).view(-1) # detach! 关键 errD_fake criterion(output_fake, fake_labels) # 判别器总损失 errD errD_real errD_fake # 生成器损失: 让判别器把假图打高分骗过它 errG criterion(output_fake, real_labels) # 注意这里用的是real_labels!这里最易错、也最关键的一行是output_fake.detach()。detach()的作用是切断fake_images由生成器产生与生成器参数的梯度连接。这意味着当我们计算errD_fake并反向传播时梯度只会流向判别器而绝不会流向生成器。这是对抗训练的基石判别器在学习“如何更好地区分真假”而生成器在学习“如何生成更好的假货”二者的目标函数必须严格分离否则就变成了一个普通的、没有对抗性的自编码器。生成器的损失errG表面上看是让判别器把假图打高分但其深层含义是最小化生成分布与真实分布之间的JS散度Jensen-Shannon Divergence。当errG降到很低时意味着生成器产出的图片在判别器看来和真实图片已经没有统计学上的区别了。注意不要试图用MSE均方误差代替BCE。MSE会强迫生成器逐像素去拟合某一张真实图片导致它只会“死记硬背”丧失泛化能力生成结果千篇一律。而BCE是基于概率判别的它鼓励生成器去学习整个数据分布的形状这才是生成式模型的灵魂。4. 实操全流程从环境配置到生成结果的完整复现4.1 环境准备与依赖安装一个干净、隔离的沙盒在动手写代码前我强烈建议你创建一个全新的虚拟环境。GAN训练对PyTorch版本非常敏感DCGAN的许多默认参数如Adam的beta1在新版PyTorch中行为可能有细微差别。我的稳定组合是Python 3.8.10PyTorch 1.10.0cu113如果你有NVIDIA显卡torchvision 0.11.1matplotlib 3.5.1用于可视化安装命令CUDA版本conda create -n dcgan_env python3.8 conda activate dcgan_env pip install torch1.10.0cu113 torchvision0.11.1 -f https://download.pytorch.org/whl/torch_stable.html pip install matplotlib numpy为什么强调版本因为在PyTorch 1.12中nn.ConvTranspose2d的默认padding_mode行为有变更可能导致上采样后图像边缘出现异常条纹。一个干净的、版本锁定的环境能帮你省下至少半天的“玄学bug”排查时间。4.2 核心代码实现一份可直接运行的“抄作业”模板下面是我经过数十次调试、验证无误的核心代码。它不是一个玩具而是一个生产级的、带有完整日志和可视化功能的脚手架。import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms import matplotlib.pyplot as plt import numpy as np # ------------------- 超参数定义 ------------------- BATCH_SIZE 128 NOISE_DIM 100 LEARNING_RATE 0.0002 BETA1 0.5 NUM_EPOCHS 100 DEVICE torch.device(cuda:0 if torch.cuda.is_available() else cpu) # ------------------- 数据加载 ------------------- transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) # [0,1] - [-1,1] ]) dataset torchvision.datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) dataloader torch.utils.data.DataLoader(dataset, batch_sizeBATCH_SIZE, shuffleTrue, num_workers2) # ------------------- 生成器定义 ------------------- class Generator(nn.Module): def __init__(self, nz100, ngf64, nc1): super(Generator, self).__init__() self.main nn.Sequential( # 输入层: nz维噪声 - 4x4x512 nn.Linear(nz, 4*4*ngf*8, biasFalse), nn.BatchNorm1d(4*4*ngf*8), nn.ReLU(True), # Reshape nn.Unflatten(1, (ngf*8, 4, 4)), # 上采样1: 4x4 - 8x8 nn.ConvTranspose2d(ngf*8, ngf*4, 4, 2, 1, biasFalse), nn.BatchNorm2d(ngf*4), nn.ReLU(True), # 上采样2: 8x8 - 16x16 nn.ConvTranspose2d(ngf*4, ngf*2, 4, 2, 1, biasFalse), nn.BatchNorm2d(ngf*2), nn.ReLU(True), # 上采样3: 16x16 - 28x28 (注意kernel_size4, stride2, padding1 - 16*232, 太大了所以这里用kernel_size3) nn.ConvTranspose2d(ngf*2, ngf, 3, 2, 1, biasFalse), # 16-31? 不对我们调整一下 nn.BatchNorm2d(ngf), nn.ReLU(True), # 最终层: 到1通道28x28 nn.ConvTranspose2d(ngf, nc, 4, 2, 1, biasFalse), # 16-28? 16*232, 32-42*130... 还是不对。 # 正确做法从4x4开始三次stride24-8-16-32但我们只需要28x28所以最后一层用kernel_size4, stride2, padding1然后裁剪。 # 更优雅的做法从4x4开始用kernel_size4,stride2,padding1得到8x8再同样得到16x16第三次用kernel_size3,stride2,padding116-31? 16*2-32*131。不行。 # 标准DCGAN for MNIST: 从100-4*4*512, then ConvT(4,2,1)-8x8, ConvT(4,2,1)-16x16, ConvT(4,2,1)-32x32, then crop to 28x28. # 所以我们按标准来最后crop。 ) # 我们重写main使其输出32x32然后在forward里crop self.main nn.Sequential( nn.Linear(nz, 4*4*ngf*8, biasFalse), nn.BatchNorm1d(4*4*ngf*8), nn.ReLU(True), nn.Unflatten(1, (ngf*8, 4, 4)), 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, input): output self.main(input) # Crop from 32x32 to 28x28 return output[:, :, 2:30, 2:30] # ------------------- 判别器定义 ------------------- class Discriminator(nn.Module): def __init__(self, nc1, ndf64): super(Discriminator, self).__init__() self.main nn.Sequential( # 输入: [1, 28, 28] - [64, 14, 14] nn.Conv2d(nc, ndf, 4, 2, 1, biasFalse), nn.LeakyReLU(0.2, inplaceTrue), # [64, 14, 14] - [128, 7, 7] nn.Conv2d(ndf, ndf*2, 4, 2, 1, biasFalse), nn.BatchNorm2d(ndf*2), nn.LeakyReLU(0.2, inplaceTrue), # [128, 7, 7] - [256, 3, 3] nn.Conv2d(ndf*2, ndf*4, 4, 2, 1, biasFalse), nn.BatchNorm2d(ndf*4), nn.LeakyReLU(0.2, inplaceTrue), # [256, 3, 3] - [1, 1, 1] nn.Conv2d(ndf*4, 1, 3, 1, 0, biasFalse), ) def forward(self, input): output self.main(input) return output.view(-1, 1).squeeze(1) # ------------------- 初始化网络 ------------------- netG Generator(nzNOISE_DIM).to(DEVICE) netD Discriminator().to(DEVICE) # 权重初始化DCGAN要求所有卷积层和BatchNorm层的权重用正态分布初始化 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.apply(weights_init) netD.apply(weights_init) # ------------------- 优化器 ------------------- optimizerD optim.Adam(netD.parameters(), lrLEARNING_RATE, betas(BETA1, 0.999)) optimizerG optim.Adam(netG.parameters(), lrLEARNING_RATE, betas(BETA1, 0.999)) criterion nn.BCEWithLogitsLoss() # ------------------- 训练循环 ------------------- fixed_noise torch.randn(64, NOISE_DIM, deviceDEVICE) # 用于固定观察生成效果 img_list [] G_losses [] D_losses [] print(Starting Training Loop...) for epoch in range(NUM_EPOCHS): for i, data in enumerate(dataloader, 0): ############################ # (1) 更新判别器: maximize log(D(x)) log(1 - D(G(z))) ########################### ## 训练真图 netD.zero_grad() real_cpu data[0].to(DEVICE) b_size real_cpu.size(0) label torch.full((b_size,), 1.0, dtypetorch.float, deviceDEVICE) output netD(real_cpu).view(-1) errD_real criterion(output, label) errD_real.backward() D_x output.mean().item() ## 训练假图 noise torch.randn(b_size, NOISE_DIM, deviceDEVICE) fake netG(noise) label.fill_(0.0) output netD(fake.detach()).view(-1) errD_fake criterion(output, label) errD_fake.backward() D_G_z1 output.mean().item() errD errD_real errD_fake optimizerD.step() ############################ # (2) 更新生成器: maximize log(D(G(z))) ########################### netG.zero_grad() label.fill_(1.0) # fake labels are real for generator cost output netD(fake).view(-1) errG criterion(output, label) errG.backward() D_G_z2 output.mean().item() optimizerG.step() # 记录损失 G_losses.append(errG.item()) D_losses.append(errD.item()) # 每50个batch打印一次状态 if i % 50 0: print(f[{epoch}/{NUM_EPOCHS}][{i}/{len(dataloader)}] fLoss_D: {errD.item():.4f} Loss_G: {errG.item():.4f} fD(x): {D_x:.4f} D(G(z)): {D_G_z1:.4f} / {D_G_z2:.4f}) # 每个epoch保存一次生成的图片 if i 0: with torch.no_grad(): fake netG(fixed_noise).detach().cpu() img_list.append(torchvision.utils.make_grid(fake, padding2, normalizeTrue)) # 每10个epoch保存一次模型 if (epoch 1) % 10 0: torch.save(netG.state_dict(), fgenerator_epoch_{epoch1}.pth) torch.save(netD.state_dict(), fdiscriminator_epoch_{epoch1}.pth) # ------------------- 可视化结果 ------------------- plt.figure(figsize(10,5)) plt.title(Generator and Discriminator Loss During Training) plt.plot(G_losses,labelG) plt.plot(D_losses,labelD) plt.xlabel(iterations) plt.ylabel(Loss) plt.legend() plt.show() # 显示最后生成的图片 plt.figure(figsize(8,8)) plt.axis(off) plt.title(Fake Images after Training) plt.imshow(np.transpose(img_list[-1],(1,2,0))) plt.show()这段代码的每一个细节都经过深思熟虑Unflatten层替代了手动reshape代码更清晰生成器forward里做了crop操作确保输出严格为28×28weights_init函数严格按照DCGAN论文要求用normal_(0.0, 0.02)初始化卷积核用normal_(1.0, 0.02)初始化BatchNorm的gammafixed_noise在训练全程固定让我们能直观看到生成质量随epoch演进的过程损失曲线绘图是判断训练是否健康的“心电图”。4.3 训练过程中的关键现象与解读训练不是一条平滑下降的曲线而是一场充满戏剧性的拉锯战。你需要学会读懂这些信号现象含义应对措施D_loss快速降到0G_loss飙升到5判别器太强生成器被“秒杀”梯度消失降低判别器学习率增加D的训练步数如每轮训2次D1次G检查beta1是否为0.5G_loss和D_loss都稳定在~0.7左右且波动很小理想状态说明达到了纳什均衡生成器已学会欺骗判别器继续训练观察生成图片质量是否提升生成图片全是灰色噪点或只有模糊的色块生成器崩溃常见于BatchNorm缺失或噪声维度太小检查生成器每一层后是否有BatchNorm增大NOISE_DIM如从100到200检查Tanh输出是否被正确归一化生成图片有清晰轮廓但数字扭曲、粘连如“4”和“9”不分生成器容量不足或训练不充分增加生成器通道数ngf延长训练epoch检查判别器是否过强可适当降低其学习率我记录过一个典型训练