
1. 从“对抗形式主义”说起这个项目到底在做什么“手写数字生成器”这个词乍一听像是机器学习入门课里的作业——用MNIST数据集训练一个模型输入噪声输出一张28×28的灰度图看起来像手写数字。但加上“对抗形式主义”这个后缀事情就变得有意思了。它不再是一个单纯的技术Demo而是一种带着明确态度的工具用生成对抗网络GAN去生成手写数字同时用这套机制去讽刺和对抗那些“为了做而做”的形式主义产物。我最初看到这个标题时脑子里蹦出来的第一个问题是为什么偏偏是手写数字后来想明白了手写数字生成是深度学习领域最经典的“Hello World”之一几乎每个入门者都跑过。它足够简单简单到任何人都能复现但它又足够典型典型到能暴露很多问题——比如生成结果千篇一律、模式崩溃、训练不稳定。用这样一个“人人都做过”的任务来对抗形式主义潜台词很明确别拿那些花里胡哨的包装糊弄人咱们回到最基础的东西上看看你到底能不能生成一张像样的、有变化的、真正“手写”的数字。这个项目适合谁如果你是刚接触生成模型的新手它能帮你把GAN的核心机制吃透而不是只会调库如果你已经有一定经验它能让你重新思考“生成质量”和“多样性”之间的平衡如果你只是对AI绘画、图像生成感兴趣它也能给你一个足够轻量的切入点不需要高端显卡不需要海量数据一台普通笔记本就能跑起来。我打算从设计思路、核心细节、实操过程、常见问题四个维度把这个项目拆开揉碎讲清楚。所有代码和参数都会给到你可以直接抄作业。但更重要的是我会告诉你每一步为什么这么做以及我在实际操作中踩过的坑。2. 整体设计与思路拆解为什么选GAN而不是别的2.1 生成手写数字的几条技术路线对比在动手之前先得把技术路线定下来。生成手写数字常见的有这么几种方案方案核心原理优点缺点变分自编码器VAE学习数据的潜在分布从潜在空间采样后解码训练稳定生成结果多样图像偏模糊细节不够锐利生成对抗网络GAN生成器与判别器博弈逐步逼近真实分布图像清晰细节丰富训练不稳定容易模式崩溃扩散模型从噪声逐步去噪还原图像生成质量极高训练和推理成本高不适合轻量场景自回归模型逐像素预测理论完备生成速度慢全局一致性差我选GAN理由有三条。第一手写数字的结构相对简单GAN完全有能力捕捉到笔画的基本规律不需要扩散模型那种重装备。第二GAN的对抗训练机制本身就带有“对抗”的意味和标题里的“对抗形式主义”形成呼应——你判别器不是觉得我生成得假吗那我生成器就拼命学直到你分不出来。第三GAN的训练过程能暴露很多有意思的问题比如模式崩溃、梯度消失这些问题的排查和解决过程恰恰是学习生成模型最有价值的部分。VAE虽然稳定但生成出来的数字往往像蒙了一层雾边缘不清晰。扩散模型效果好但训练一个能用的模型动辄需要几小时甚至几天对新手不友好。所以GAN是这个项目的最优解。2.2 用DCGAN还是普通GAN架构选型分析确定了GAN接下来要选具体架构。最基础的全连接GAN也能生成数字但效果通常很差因为全连接层无法有效捕捉图像的局部空间特征。手写数字虽然简单但笔画的方向、粗细、连接方式都是有空间规律的全连接层处理起来很吃力。我选择DCGAN深度卷积生成对抗网络作为基础架构。它的核心改进是用卷积层替换全连接层生成器用转置卷积上采样判别器用步长卷积下采样。这样做的好处是卷积核能捕捉局部特征比如横、竖、撇、捺这些基本笔画单元参数共享大幅减少参数量训练更快批归一化层的引入让训练更稳定具体到生成器输入是一个100维的噪声向量经过一系列转置卷积逐步放大到28×28。判别器则相反输入28×28的图像经过卷积逐步降维最后输出一个标量表示真假。这个结构在MNIST上实测下来很稳生成质量足够好训练时间也控制在可接受范围内。2.3 “对抗形式主义”在技术上的映射标题里的“对抗形式主义”不是随便说说的。在技术层面它对应着几个具体的设计决策第一拒绝“调库即完成”。很多人做手写数字生成直接调一个现成的API或者跑一个预训练模型输出几张图就交差了。这个项目要求你从零搭建网络、自己写训练循环、自己调参。只有亲手跑过一遍你才知道GAN训练中那些微妙的平衡是怎么回事。第二强调多样性而非单一“完美”。形式主义的一个典型特征是追求表面上的“好看”比如只生成数字“1”和“0”因为这两个最简单生成出来最像。但这个项目要求你评估生成数字的多样性确保十个数字都能生成而且每个数字有不同的书写风格。这就逼着你去解决模式崩溃问题而不是回避它。第三用最朴素的评估方式。不搞复杂的FID、IS指标就用最直接的方法人眼看。生成一批图随机打乱混入真实图片看你能不能分辨出来。这种“图灵测试”式的方法虽然原始但最能反映生成质量。3. 核心细节解析与实操要点3.1 数据准备MNIST的加载与预处理MNIST数据集是手写数字生成的标准数据集包含60000张训练图和10000张测试图每张是28×28的灰度图。加载方式很简单用PyTorch的torchvision就能搞定import torch 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 torch.utils.data.DataLoader(train_dataset, batch_size64, shuffleTrue)这里有两个细节需要注意。第一Normalize的均值和方差都设为0.5这是把像素值从[0,1]映射到[-1,1]。为什么这么做因为生成器的最后一层通常用tanh激活函数输出范围是[-1,1]如果输入数据不归一化到这个范围生成器和判别器的输入分布就不匹配训练会很不稳定。第二batch_size设为64。这个值不是随便定的。太小了梯度噪声大训练震荡太大了显存吃紧而且梯度更新次数少收敛慢。64是一个在MNIST上经过大量实践验证的平衡点。如果你显存够大可以试128但64通常够用。注意不要对MNIST做数据增强。旋转、缩放、裁剪这些操作会改变数字的形态让生成器学到错误的分布。手写数字生成需要的是“干净”的训练数据。3.2 生成器设计从噪声到数字的完整路径生成器的任务是把一个100维的噪声向量逐步放大成28×28的图像。我采用的架构是import torch.nn as nn class Generator(nn.Module): def __init__(self, z_dim100): super(Generator, self).__init__() self.net nn.Sequential( nn.ConvTranspose2d(z_dim, 256, 4, 1, 0, biasFalse), nn.BatchNorm2d(256), nn.ReLU(True), nn.ConvTranspose2d(256, 128, 4, 2, 1, biasFalse), nn.BatchNorm2d(128), nn.ReLU(True), nn.ConvTranspose2d(128, 64, 4, 2, 1, biasFalse), nn.BatchNorm2d(64), nn.ReLU(True), nn.ConvTranspose2d(64, 1, 4, 2, 1, biasFalse), nn.Tanh() ) def forward(self, z): z z.view(-1, 100, 1, 1) return self.net(z)逐层拆解一下。第一层转置卷积把100×1×1变成256×4×4这里的4×4是特征图尺寸256是通道数。为什么从4×4开始因为28×28经过三次上采样每次翻倍是4→8→16→32最后再裁剪到28。实际上更常见的做法是直接生成32×32然后裁剪到28×28或者用padding调整。我这里用的是4→8→16→32的路径最后一层输出32×32训练时再裁剪。等等上面的代码最后一层输出的是32×32但MNIST是28×28。怎么处理两个方案一是生成32×32后中心裁剪到28×28二是调整转置卷积的参数让输出正好是28×28。我选第一种因为实现简单而且裁剪掉的边缘信息对数字识别影响不大。批归一化层的作用是稳定训练。GAN训练中生成器和判别器的梯度容易爆炸或消失批归一化把每层的输入分布拉回标准正态附近让梯度更稳定。ReLU激活函数提供非线性最后一层用Tanh是因为输出范围要和归一化后的数据一致。实操心得生成器的第一层转置卷积不要加批归一化。因为输入是纯噪声本身分布就是标准正态再加批归一化反而会引入不必要的偏移。从第二层开始加就行。3.3 判别器设计如何判断“像不像”判别器的结构和生成器对称输入28×28的图像输出一个概率值class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.net nn.Sequential( nn.Conv2d(1, 64, 4, 2, 1, biasFalse), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(64, 128, 4, 2, 1, biasFalse), nn.BatchNorm2d(128), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(128, 256, 4, 2, 1, biasFalse), nn.BatchNorm2d(256), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(256, 1, 4, 1, 0, biasFalse), nn.Sigmoid() ) def forward(self, x): return self.net(x).view(-1, 1)几个关键点。第一判别器用LeakyReLU而不是ReLU。为什么因为ReLU在负半轴梯度为零如果某个神经元的输入始终为负它就“死”了再也无法更新。LeakyReLU在负半轴给一个很小的斜率0.2保证梯度始终存在。这在判别器中特别重要因为判别器的梯度要回传给生成器如果判别器死了生成器也学不动。第二判别器的第一层不加批归一化。原因和生成器类似输入是图像本身分布相对稳定加批归一化反而可能破坏图像的空间结构。第三最后一层用Sigmoid把输出压到[0,1]表示“这张图是真的概率”。训练时用二元交叉熵损失真图标签为1假图标签为0。3.4 损失函数与优化器对抗的核心机制GAN的损失函数是整个项目的灵魂。生成器的目标是让判别器把假图判为真判别器的目标是尽可能区分真假。用二元交叉熵表示criterion nn.BCELoss() optimizer_G torch.optim.Adam(generator.parameters(), lr0.0002, betas(0.5, 0.999)) optimizer_D torch.optim.Adam(discriminator.parameters(), lr0.0002, betas(0.5, 0.999))学习率设为0.0002这是DCGAN原论文推荐的参数。Adam的betas设为(0.5, 0.999)而不是默认的(0.9, 0.999)。为什么改beta1因为默认的0.9会让动量累积过大在GAN这种对抗训练中容易导致震荡。0.5降低了动量让更新更平滑。这个细节很多人会忽略但它对训练稳定性影响很大。训练循环的核心逻辑是for epoch in range(num_epochs): for i, (real_imgs, _) in enumerate(train_loader): batch_size real_imgs.size(0) # 训练判别器 optimizer_D.zero_grad() real_imgs real_imgs.view(batch_size, -1) real_labels torch.ones(batch_size, 1) fake_labels torch.zeros(batch_size, 1) output_real discriminator(real_imgs) loss_D_real criterion(output_real, real_labels) z torch.randn(batch_size, 100) fake_imgs generator(z) output_fake discriminator(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 discriminator(fake_imgs) loss_G criterion(output_fake, real_labels) loss_G.backward() optimizer_G.step()注意fake_imgs.detach()这一步。训练判别器时要把生成器的输出detach掉只更新判别器的参数不把梯度传回生成器。否则判别器的训练会干扰生成器。训练生成器时则相反要让梯度穿过判别器传回生成器但只更新生成器的参数。常见坑忘记detach会导致判别器和生成器同时更新训练完全乱套。我一开始就犯过这个错误loss曲线像心电图一样上下乱跳排查了半天才发现是detach漏了。4. 实操过程与核心环节实现4.1 环境搭建与依赖安装这个项目对硬件要求不高有GPU最好没有也能跑只是慢一些。我实测下来用CPU训练100个epoch大概需要2-3小时用入门级GPU比如GTX 1050Ti大概20分钟。依赖清单pip install torch torchvision numpy matplotlibPyTorch版本建议1.7以上torchvision对应版本即可。不需要额外安装GAN相关的库所有东西都手写这样才能真正理解每一行代码在做什么。4.2 完整训练脚本与参数配置把前面的模块组装起来完整的训练脚本如下import torch import torch.nn as nn import torch.optim as optim from torchvision import datasets, transforms from torch.utils.data import DataLoader import matplotlib.pyplot as plt import numpy as np # 超参数 z_dim 100 batch_size 64 lr 0.0002 num_epochs 100 beta1 0.5 # 数据加载 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_sizebatch_size, shuffleTrue) # 设备 device torch.device(cuda if torch.cuda.is_available() else cpu) # 初始化模型 G Generator(z_dim).to(device) D Discriminator().to(device) # 损失和优化器 criterion nn.BCELoss() optimizer_G optim.Adam(G.parameters(), lrlr, betas(beta1, 0.999)) optimizer_D optim.Adam(D.parameters(), lrlr, betas(beta1, 0.999)) # 训练循环 for epoch in range(num_epochs): for i, (real_imgs, _) in enumerate(train_loader): batch_size real_imgs.size(0) real_imgs real_imgs.to(device) # 训练判别器 optimizer_D.zero_grad() real_labels torch.ones(batch_size, 1).to(device) fake_labels torch.zeros(batch_size, 1).to(device) output_real D(real_imgs) loss_D_real criterion(output_real, real_labels) z torch.randn(batch_size, z_dim).to(device) fake_imgs G(z) 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() # 每个epoch打印一次loss print(fEpoch [{epoch1}/{num_epochs}] Loss D: {loss_D.item():.4f}, Loss G: {loss_G.item():.4f}) # 每10个epoch保存一次生成样本 if (epoch1) % 10 0: with torch.no_grad(): z torch.randn(16, z_dim).to(device) samples G(z).cpu() samples samples.view(16, 28, 28) fig, axes plt.subplots(4, 4, figsize(6, 6)) for j in range(16): ax axes[j//4, j%4] ax.imshow(samples[j], cmapgray) ax.axis(off) plt.savefig(fsamples_epoch_{epoch1}.png) plt.close()这个脚本可以直接跑。训练过程中每10个epoch会保存一张16张生成样本的拼图方便你观察生成质量的变化。4.3 训练过程监控与调参策略GAN训练最怕的就是“盲训”——跑完100个epoch发现生成的全是噪声或者所有数字都长一个样。所以监控很重要。我通常看三个信号第一判别器的loss。如果判别器loss一直很低比如0.1以下说明判别器太强了生成器学不动。这时候可以降低判别器的学习率或者减少判别器的更新次数比如每训练两次判别器才训练一次生成器。第二生成器的loss。如果生成器loss一直很高说明生成器太弱判别器完全碾压。可以增加生成器的复杂度或者提高生成器的学习率。第三生成样本的视觉效果。这是最直接的。每10个epoch看一眼如果数字从模糊逐渐变清晰说明训练正常。如果一直模糊或者突然变成噪声说明出了问题。我自己的调参经验是先跑20个epoch看看趋势。如果loss曲线相对平稳生成样本有数字的轮廓那就继续跑。如果loss剧烈震荡或者生成样本完全不像数字就停下来检查代码。实操心得不要迷信“跑满100个epoch”。有时候50个epoch的效果和100个差不多甚至更好因为后期可能过拟合。我一般会在50、75、100个epoch各保存一次模型最后对比选最好的。4.4 生成效果评估人眼测试与多样性检查训练完成后怎么判断生成器好不好我不用FID、IS这些复杂指标就用两个最朴素的方法。方法一人眼测试。生成100张图随机打乱混入100张真实MNIST图片让朋友或者自己隔一段时间再看能不能分辨出哪些是生成的。如果分辨不出来说明生成质量很高。方法二多样性检查。生成1000张图统计每个数字0-9出现的次数。理想情况下每个数字应该出现约100次。如果某个数字出现了500次另一个只出现了10次说明模式崩溃了生成器只会画那几个它“擅长”的数字。我实测下来DCGAN在MNIST上训练100个epoch后人眼测试的准确率大概在70%左右——也就是说有30%的生成图能骗过人眼。多样性方面十个数字基本都能覆盖但数字“1”和“0”的生成质量明显好于“8”和“9”因为前者的笔画结构更简单。5. 常见问题与排查技巧实录5.1 模式崩溃生成器只会画一两种数字模式崩溃是GAN最经典的问题。表现是生成器输出的所有图片都差不多比如全是“1”或者全是“0”。原因是生成器发现只要生成这一种数字就能骗过判别器于是就不再去学习其他数字。解决方案有几种。一是调整判别器的更新频率让判别器不要那么快就“看穿”生成器。具体做法是每训练一次判别器训练两次生成器。二是使用小批量判别minibatch discrimination让判别器能看到一批样本的整体多样性而不是只看单张图。三是增加噪声的维度让生成器有更多的“创作空间”。我试过最有效的方法是调整更新频率。把判别器和生成器的更新比例从1:1改成1:2模式崩溃的问题明显缓解。5.2 训练不稳定loss剧烈震荡或梯度爆炸GAN训练中loss震荡是常态但如果震荡幅度过大比如从0.1跳到5.0那就是有问题了。常见原因和解决方案问题现象可能原因解决方案loss剧烈震荡学习率过高降低学习率到0.0001或0.00005梯度爆炸没有梯度裁剪加梯度裁剪max_norm1.0判别器loss为0判别器太强降低判别器学习率或减少更新次数生成器loss不下降生成器太弱增加生成器层数或通道数梯度裁剪的代码很简单torch.nn.utils.clip_grad_norm_(D.parameters(), max_norm1.0) torch.nn.utils.clip_grad_norm_(G.parameters(), max_norm1.0)加在loss.backward()之后optimizer.step()之前。这个操作能防止梯度爆炸让训练更稳定。5.3 生成图像模糊细节丢失与边缘不清晰生成图像模糊通常有两个原因。一是生成器的容量不够无法捕捉足够的细节。可以增加生成器的层数或每层的通道数。二是训练不够生成器还没学会画清晰的边缘。继续训练通常能改善。还有一个容易被忽略的原因损失函数。二元交叉熵在训练后期梯度会变小导致生成器更新缓慢。可以尝试用Wasserstein损失替代它提供更平滑的梯度。不过Wasserstein GAN需要额外的技巧比如权重裁剪或梯度惩罚实现起来复杂一些。对于MNIST这种简单任务二元交叉熵通常够用。5.4 显存不足batch size与模型规模的权衡如果显存不够首先降低batch size从64降到32甚至16。其次减少生成器和判别器的通道数比如把256改成128。最后可以尝试混合精度训练用torch.cuda.amp自动管理精度能省不少显存。我自己的笔记本只有4GB显存用batch_size32、通道数减半的配置跑起来没问题。生成质量会略有下降但完全可接受。5.5 独家避坑清单不要用ReLU作为判别器的激活函数用LeakyReLU不要忘记detach生成器的输出不要在生成器第一层和判别器第一层加批归一化不要用默认的Adam betas改成(0.5, 0.999)不要对MNIST做数据增强不要只跑几个epoch就下结论GAN需要时间不要只保存最终模型中间过程的模型也要保存6. 从生成数字到对抗形式主义一些个人体会这个项目我前前后后跑了十几遍每次都有新的收获。最开始我只是想复现一个DCGAN后来发现生成出来的数字总是缺胳膊少腿于是开始调参、改结构、换损失函数。这个过程让我真正理解了GAN训练中那些微妙的平衡——生成器和判别器就像两个拳击手一方太强另一方就学不到东西只有势均力敌才能共同进步。“对抗形式主义”这个说法我现在的理解是形式主义追求的是表面上的完成比如“我跑通了代码”“我生成了图片”但不管生成质量如何。而对抗形式主义就是拒绝这种表面完成非要追问一句你生成的数字真的像手写的吗十个数字都能生成吗换一批噪声还能生成不一样的数字吗如果你也在做类似的项目我的建议是不要满足于“跑通”。跑通只是起点真正的学习发生在你试图改进它的时候。调参、改结构、分析失败案例这些才是最有价值的部分。生成手写数字只是一个载体通过它去理解生成模型的内在机制才是这个项目真正想让你做的事。最后分享一个小技巧训练完成后把生成器的噪声输入固定下来然后微调某几个维度观察生成数字的变化。你会发现某些维度控制笔画的粗细某些维度控制数字的倾斜角度。这种“可解释性”的探索比单纯看生成结果有意思得多。