资讯动态

CS231n作业3:从零实现GAN生成MNIST手写数字的全记录

发布时间:2026/10/20 22:16:15 来源:尧图企业网站定制
CS231n这个编号在视觉和深度学习圈子里应该不陌生。第三次作业的最后一道题就是让你亲手实现一个生成对抗网络GAN。我这次把assignment3完整刷了一遍尤其是GAN那部分从头到尾没有直接抄现有模型连续调了几晚最后在MNIST上稳定出图。这篇文章想把这门作业的题面、思路、踩坑和结果完整复盘出来。如果你还没到写CS231n作业的阶段只是想搞懂GAN到底是怎么回事也完全可以读下去。我会把生成器、判别器、损失函数、训练循环这些概念拆开讲尽量用大白话解释清楚。顺便说一句这类作业最大的价值不在于最后生成那张图有多惊艳而在于逼着你手写每一个模块让你在报错和调参里真正理解对抗训练的本质。1. 项目概述与任务拆解1.1 作业到底让你做什么先还原一下assignment3中GAN部分的题面。通常它不会一上来就直接让你生成高清人脸而是分了好几步第一步实现一个简单的二维数据分布拟合任务比如让GAN去学习一个月牙形或者几个高斯点聚成的分布用可视化的方法观察生成结果是否落在目标分布上。第二步把网络搬到MNIST手写数字数据集上用MLP实现生成器和判别器生成28x28灰度图。第三步可选内容一般是条件GAN给生成器输入额外标签让它能按指定数字生成结果。这个设计其实很合理。二维数据拟合是“单元测试”它让你不用关心图像细节在低维空间里快速验证训练流程有没有问题。我当时把第一步调通以后后面的MNIST训练就少踩了很多坑。很多初学者一上来就想做高清生成结果模型不收敛也不知道问题出在数据、网络结构还是损失函数上。跟着作业的节奏走先低维、后高维先判别、后生成其实才是最省时间的学习路径。1.2 为什么从零手写GAN值得做市面上封装好的GAN代码随处可以抄几行调用就能出一张图那为什么这门课还非要你手写我的理解是GAN和普通分类网络最大的区别在于训练范式。普通监督学习有固定标签损失函数一路下降就对了。而GAN里有两个网络在互相博弈生成器变强会导致判别器损失上升判别器变强又会导致生成器损失上升单看某一条loss曲线根本无法直接判断训练健康不健康。这种动态平衡恰恰是实战中最容易翻车的地方。如果不亲手写一遍前向传播和梯度更新很难体会到“判别器太强”和“生成器太弱”在损失曲线上分别长什么样也很难理解为什么要把生成器输入转成图片、为什么detach()操作会影响梯度流。另外一点是从零实现强迫你明确每个变量的shape。我记得第一次写生成器时把z_dim和输出维度搞混前向传播报错报了好几次。手写两遍以后这些形状转换就像肌肉记忆一样清晰了。2. 理论模型与损失函数设计拆解2.1 判别器和生成器各干什么活用一个通俗的类比来解释GAN生成器像一个做假画的画师判别器像一个鉴定师。画师的目标是画出足以骗过鉴定师的赝品鉴定师的目标是把真画和赝品严格分开。两者在反复较量中画师的手艺会越来越好鉴定师的眼光也会越来越毒。放到代码里生成器的输入是一个随机噪声向量z一般从标准正态分布采样通过一个MLP映射到数据空间。MNIST作业里数据空间就是784维向量对应28x28图像。判别器则正好相反输入一张图像输出一个0到1之间的分数分数越接近1说明越像真实数据。这里有个容易被忽略的细节作业的第一阶段二维任务中你可以在图上直接画出生成器的输出点和真实数据点一眼就能看出两者是否重合。但到了MNIST阶段图像维度变得稀疏光看单独几个样本矩阵是看不出分布的必须做成网格图肉眼检查。2.2 对抗目标函数如何帮你完成训练标准的GAN目标函数可以写成这样V(D, G) E_x[log D(x)] E_z[log(1 - D(G(z)))]判别器想让这个函数变大也就是对真实图给出高分对生成图给出低分。生成器想让这个函数变小也就是让判别器对生成图给出高分。实际写代码时我们通常不直接写这个min-max公式而是拆成两个独立的损失项。判别器用二元交叉熵真实图标签为1生成图标签为0生成器的目标则是让判别器把生成图误判为1所以它希望log D(G(z))尽量大。这里有一个看似奇怪但实际很重要的改进早期GAN的生成器损失写成minimize log(1 - D(G(z)))但这样在生成器很差的时候梯度很小训练容易停止。作业里一般用“非饱和”形式让生成器最大化log D(G(z))这样梯度在早期更大训练更顺。我当时第一次实现时用了前者结果训练了十几个epoch损失纹丝不动就是被这个sigmoid函数把梯度压缩没了。3. 从零实现关键模块与代码走读3.1 数据准备与网络搭建我用的是PyTorch框架先加载MNIST数据集做标准化和DataLoader封装import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize([0.5], [0.5]) ]) train_dataset datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransform ) train_loader DataLoader( train_dataset, batch_size128, shuffleTrue, drop_lastTrue )注意Normalize的参数是0.5经过ToTensor后像素值在0到1之间减0.5再除0.5就能把范围转换到-1到1。这样做的原因是生成器最后用Tanh激活输出范围正好是-1到1和数据分布对齐。生成器我用了三层全连接class Generator(nn.Module): def __init__(self, z_dim100, hidden_dim256, output_dim784): super().__init__() self.model nn.Sequential( nn.Linear(z_dim, hidden_dim), nn.ReLU(inplaceTrue), nn.Linear(hidden_dim, hidden_dim * 2), nn.ReLU(inplaceTrue), nn.Linear(hidden_dim * 2, output_dim), nn.Tanh() ) def forward(self, z): return self.model(z)判别器和生成器对称但激活函数改成LeakyReLU最后加Sigmoid输出分数class Discriminator(nn.Module): def __init__(self, input_dim784, hidden_dim256): super().__init__() self.model nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.LeakyReLU(0.2, inplaceTrue), nn.Linear(hidden_dim, hidden_dim * 2), nn.LeakyReLU(0.2, inplaceTrue), nn.Linear(hidden_dim * 2, 1), nn.Sigmoid() ) def forward(self, x): return self.model(x)这里最关键的是隐藏层维度要和生成器对应否则后面训练时梯度回传会悄悄少算一层。我建议打印每一层的输出形状比如用一个随机的z跑一遍生成器看一眼是不是(batch, 784)。3.2 训练循环与优化策略的细节作业里面通常会要求你用Adam优化器并且给出特别建议lr2e-4betas(0.5, 0.999)。很多人看到0.5会觉得奇怪因为默认Adam的beta10.9。这里降低beta1是为了让动量衰减更快防止判别器在对抗过程中惯性太大把生成器的梯度压没。在每一个训练步里我按作业推荐的方式执行criterion nn.BCELoss() d_optimizer torch.optim.Adam(discriminator.parameters(), lr2e-4, betas(0.5, 0.999)) g_optimizer torch.optim.Adam(generator.parameters(), lr2e-4, betas(0.5, 0.999)) for epoch in range(epochs): for batch_idx, (real_images, _) in enumerate(train_loader): current_batch real_images.size(0) real_images real_images.view(current_batch, -1) # 构造真实图和生成图的标签 real_labels torch.ones(current_batch, 1) fake_labels torch.zeros(current_batch, 1) # 1. 训练判别器 d_optimizer.zero_grad() d_real_pred discriminator(real_images) d_real_loss criterion(d_real_pred, real_labels) z torch.randn(current_batch, z_dim) fake_images generator(z) d_fake_pred discriminator(fake_images.detach()) d_fake_loss criterion(d_fake_pred, fake_labels) d_loss d_real_loss d_fake_loss d_loss.backward() d_optimizer.step() # 2. 训练生成器 g_optimizer.zero_grad() z torch.randn(current_batch, z_dim) fake_images generator(z) d_fake_pred discriminator(fake_images) g_loss criterion(d_fake_pred, real_labels) g_loss.backward() g_optimizer.step()写这个循环时有三个地方需要特别注意。第一训练判别器时传给判别器的生成图必须经过detach()。否则梯度会同时流向生成器这一步里生成器也会被更新但你还没给它准备单独的optimizer.zero_grad()状态会乱掉。第二生成器更新时用新采样的z重新生成图片不要复用上一轮已经算过的fake_images。这一步看起来只是重复计算但能防止梯度的顺序依赖影响到对抗效果。第三不能用同一个d_fake_pred又算判别器损失又算生成器损失。因为判别器的Sigmoid输出虽然很小但依然会有数值精度上的梯度连带关系让生成器把判别器参数的梯度也算进去。3.3 生成结果的检查与可视化训练完成后作业一般要求你把生成的图像可视化。我的做法是固定一个随机种子采样一批z通过网络生成图片再reshape成图像网格import matplotlib.pyplot as plt import numpy as np def show_samples(generator, z, save_pathgan_samples.png): generator.eval() with torch.no_grad(): samples generator(z).view(-1, 1, 28, 28) samples (samples.numpy() 1) / 2 fig, axes plt.subplots(4, 4, figsize(8, 8)) for i, ax in enumerate(axes.flat): ax.imshow(samples[i][0], cmapgray) ax.axis(off) plt.tight_layout() plt.savefig(save_path, dpi150) plt.show() generator.train()固定随机种子的原因在于你希望每次看结果时用的是同一个潜变量空间。否则你很难分辨模型是稳定生成了某个数字还是这次只是随机碰巧抽到了好的z。当时我最开始没固定z每跑一个epoch看结果都感觉在抽奖排查问题特别费劲。4. 常见问题与排查技巧实录4.1 训练不稳定的三种典型表现我在运行GAN时遇到过三种非常典型的情况作业里也经常会考到。第一种是判别器损失爆炸式下降很快接近0生成器损失一直很大。这说明判别器过于强大一眼就能分辨出真假。原因可能是生成器太弱或者判别器网络太宽。这时候生成器梯度几乎消失更新方向近似随机噪声。第二种是生成器损失降到很低但生成图像全都是同一张或者少数几张相同的图案。这是在顶着A哥死路的边缘试探——模式崩溃也叫mode collapse。生成器发现只要输出某几个固定样本就能骗过判别器的大部分判断于是不再继续学习更多样化的分布。第三种是损失曲线反复振荡训练几十个epoch还是出不来清晰的数字轮廓。这往往意味着两个网络在互相追赶每一次更新都会把对方刚建立的判别标准推翻。为了检查模型到底处于什么状态我一般会在每个epoch结束后额外保存一组固定z的生成图。如果连续几个epoch生成的图案非常相似即使损失还在下降我也能意识到模式崩溃已经发生了。只盯着loss曲线是不行的因为这个博弈博弈里的loss本身并不单调。4.2 参数调整与修复顺序我把实际调参经验整理成了一张表供参考症状可能原因建议调法判别器损失提前归零判别器太强降低判别器学习率或增加Dropout生成器损失骤降但图像重复模式崩溃减小生成器更新频率增大batch size两者loss同时长时间不动梯度消失换LeakyReLU使用非饱和损失图像整体灰蒙蒙Tanh输出和标准化不一致检查数据预处理是否到-1到1图像里有大量噪点判别器欠拟合增加训练轮数加大网络容量在具体修复时我习惯遵循这样一个顺序先检查数据和损失实现确认标签和激活函数匹配。再调Adam参数尤其beta1很多默认设置问题可以通过改为0.5解决。如果还不行给判别器加Dropout让它的判别结果不要那么确定。最后才考虑调整网络结构比如把全连接层换成卷积层。这个顺序能避免你还没搞清问题就把网络改得面目全非。我当时曾因为生成器效果不好反复增加隐藏层宽度结果只让训练变慢问题一点没解决。后来发现是判别器忘记加LeakyReLU了梯度在负半轴完全消失生成器自然学不到信息。5. 进阶方向从普通GAN到更可控的生成5.1 条件GAN给生成器加一个标签输入作业的加分项是实现条件GAN。普通GAN只能生成随机数字你没法指定它生成一个7。条件GAN的做法是在生成器输入端除了z之外再把数字标签也拼接进去。实现上并不复杂。如果标签是离散值比如MNIST的0到9可以先把标签映射成嵌入向量再和z拼接。对于判别器把真实图像的标签和生成图像的标签也拼接进去让判别器判断的不仅仅是图像真伪而是“这个图像是不是这个标签下的真图像”。我做了条件GAN之后明显感觉到训练比无条件GAN更容易控制。因为你给了生成器一个明确的条件约束它不再需要自行摸索整个数据分布的结构只需要去学习每个子分布的细节。有一个陷阱要提醒标签和图像在维度上差太多时拼接后标签对损失的影响会被图像主导。我当时把标签转成了one-hot向量再和784维图像直接拼接发现判别器几乎完全忽略标签后来改成先经过一层嵌入层再拼接效果才真正改善。5.2 从全连接到卷积生成器的三条经验如果不想停留在MNIST作业本身想尝试CIFAR-10或者更真实的图像全连接网络就不够用了。卷积生成器也就是DCGAN是作业之后最顺理成章的延伸。第一条经验是生成器要用转置卷积而不是简单插值上采样。ConvTranspose2d可以让每个卷积核都学会如何放大特征图生成的图像结构更自然。判别器则用普通卷积加BatchNorm2d逐步下采样。第二条经验是尽量去掉生成器和判别器里的全连接层。全连接层的参数量巨大但感受野不均匀对局部纹理建模能力差。换成卷积层以后参数减少收敛速度和生成质量反而会上来。第三条经验是关于BatchNorm的位置。生成器和判别器里都要加BatchNorm但生成器最后一层不要加让Tanh直接接触输出否则图像亮度会被强行归一化到另一个分布。判别器则可以在第一层不加BatchNorm因为它输入的是真实图像归一化容易把真实分布的均值信息抹掉。6. 写到最后我最想留给大家的实操提醒这遍刷下来我对GAN最大的感触是它不是一种“搭好网络就能用”的模型必须把整个系统看成动态博弈。看任何一条单独的训练曲线都不足以判断训练是否成功。比较可靠的做法是同时监控真实图像判别分数、生成图像判别分数和固定z的生成图三样东西。第二个很实用的习惯是刚开始调参时把所有随机种子都固定下来。虽然GAN本质上不是每轮完全复现的但固定种子至少能让你在比较两次改动时排除掉一部分随机性。等到网络稳定以后再慢慢去掉种子测试泛化能力。最后如果你现在还在和loss曲线搏斗不妨先退回去重新做一遍作业里的二维分布拟合。那个任务把问题缩到最小你很快就能看到生成点是不是真的覆盖了真实点。等二维分布完全重合再往MNIST上搬整个流程都会顺很多。这个小方法我后来用在其他生成任务里也一直能救命。

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

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

免费获取报价 →
↑