1. 为什么选择 DCGAN 作为生成模型的入门实战
搞深度学习的人多少都动过“让模型自己画东西”的念头。图像分类、目标检测这些任务做多了,总会想试试生成模型到底是怎么从一堆随机噪声里“无中生有”造出图像的。而 DCGAN(Deep 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设置stride=2。
引入批归一化。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_size=4, stride=2, padding=1,这个组合能让输出尺寸正好翻倍。计算公式是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_dim=100): 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_size=4, stride=2, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), # 输出: (batch, 128, 8, 8) nn.ConvTranspose2d(128, 64, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), # 输出: (batch, 64, 16, 16) nn.ConvTranspose2d(64, 1, kernel_size=4, stride=2, padding=1), 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_size=4的卷积直接压成一个值,也可以用全连接。我用卷积,因为更简洁。
class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.net = nn.Sequential( # 输入: (batch, 1, 32, 32) nn.Conv2d(1, 64, kernel_size=4, stride=2, padding=1), nn.LeakyReLU(0.2, inplace=True), # 输出: (batch, 64, 16, 16) nn.Conv2d(64, 128, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2, inplace=True), # 输出: (batch, 128, 8, 8) nn.Conv2d(128, 256, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(256), nn.LeakyReLU(0.2, inplace=True), # 输出: (batch, 256, 4, 4) nn.Conv2d(256, 1, kernel_size=4, stride=1, padding=0), 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', train=True, download=True, transform=transform) dataloader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2)batch_size 设 128 是 DCGAN 论文的推荐值,实测对 MNIST 也合适。太小会导致训练不稳定,太大则显存吃紧且梯度更新次数少。num_workers 根据你的机器来,设 2 或 4 都行,Windows 下如果报错就设 0。
4.2 损失函数与优化器配置
损失函数用二分类交叉熵nn.BCELoss。优化器用 Adam,学习率 0.0002,beta1 设 0.5。这里 beta1 是个关键参数,默认的 0.9 对 GAN 来说动量太大,容易导致训练震荡。DCGAN 论文明确建议用 0.5,我实测下来确实更稳。
import torch.optim as optim criterion = nn.BCELoss() optimizerG = optim.Adam(netG.parameters(), lr=0.0002, betas=(0.5, 0.999)) optimizerD = optim.Adam(netD.parameters(), lr=0.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, device=device) # 固定噪声用于观察生成效果变化 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, device=device) output_real = netD(real_imgs) lossD_real = criterion(output_real, real_labels) # 生成图像 noise = torch.randn(batch_size, noise_dim, device=device) fake_imgs = netG(noise) fake_labels = torch.zeros(batch_size, 1, device=device) 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(f'Epoch [{epoch+1}/{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, f'output/epoch_{epoch+1}.png', nrow=8, normalize=True)normalize=True会把 -1 到 1 的值映射回 0 到 1,方便查看。nrow=8 表示每行放 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 张图里大部分长得一样,基本就是模式崩溃了。
解决方案有几个。一是降低判别器的学习能力,比如减少判别器层数或降低学习率,让生成器有更多机会学习。二是引入标签信息,用条件 GAN(cGAN)把数字标签也输入生成器,强制它按类别生成。三是调整损失函数,比如用 WGAN 的 Wasserstein 距离替代原始 GAN 损失。对于 MNIST 这种简单数据集,模式崩溃不算常见,但如果遇到了,优先试第一个方案。
5.2 判别器过强导致生成器梯度消失
训练初期判别器太容易区分真假,导致生成器的损失梯度接近零,生成器学不动。表现是 LossD 很快降到接近 0,LossG 居高不下。
解决办法:一是给判别器的输入加噪声,增加判别难度。二是降低判别器的更新频率,比如每更新两次生成器才更新一次判别器。三是用标签平滑(Label Smoothing),把真实标签从 1 改成 0.9,让判别器不要那么自信。
# 标签平滑示例 real_labels = torch.full((batch_size, 1), 0.9, device=device)标签平滑是我最常用的技巧,改动小、效果好。实测下来,不加平滑时 LossD 经常掉到 0.01 以下,加了之后稳定在 0.2 到 0.4 之间,生成器训练明显更顺。
5.3 生成图像出现棋盘伪影
转置卷积有个固有缺陷:当 kernel_size 不能被 stride 整除时,输出图像会出现棋盘格状的伪影。DCGAN 用的kernel_size=4, stride=2, padding=1正好能整除,所以一般不会出现。但如果你改了参数,比如用kernel_size=3, stride=2,就可能看到棋盘纹。
解决办法:要么保持kernel_size=4, stride=2的组合,要么把转置卷积换成“最近邻上采样 + 普通卷积”。后者生成质量更平滑,但计算量稍大。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 生成图像全是噪声 | 学习率过大或初始化不当 | 检查 LossD 是否骤降 | 降低学习率,加权重初始化 |
| 只生成一种数字 | 模式崩溃 | 观察生成网格图 | 降低 D 学习率,加标签平滑 |
| LossG 不下降 | D 过强,梯度消失 | 看 LossD 是否接近 0 | 标签平滑,降低 D 更新频率 |
| 生成图像有棋盘纹 | 转置卷积参数不匹配 | 检查 kernel 和 stride | 用 kernel=4, stride=2 |
| 训练中途崩溃 | 学习率或 batch size 不当 | 看损失是否变 NaN | 降学习率,检查数据归一化 |
| 显存不足 | batch size 太大 | 看报错信息 | 降到 64 或 32 |
5.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(), }, f'checkpoint/epoch_{epoch+1}.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, dataformats='NCHW')这套流程跑下来,DCGAN 在 MNIST 上的效果是相当可观的。生成器最终能输出清晰的手写数字,虽然偶尔有瑕疵,但整体质量足以让人对生成模型建立直观认识。后续如果想进一步提升,可以尝试条件生成、WGAN-GP、或者把数据集换成 FashionMNIST、CIFAR-10 来增加难度。我个人在实际操作中的体会是,GAN 训练三分靠架构、七分靠调参和耐心,遇到问题别急着改结构,先把学习率、batch size、标签平滑这几个基础项调一遍,往往就能解决大半问题。