别再死记GAN公式了!用PyTorch手写一个DCGAN,带你直观理解损失函数loss的‘对抗’过程
从零构建DCGAN:用PyTorch实战揭秘对抗训练的损失博弈
在咖啡厅里第一次看到GAN生成的逼真人脸时,我的手抖得差点打翻杯子——这些像素组成的笑容居然完全出自算法之手。作为最早接触生成对抗网络的实践者之一,我至今记得调试第一个DCGAN时,盯着损失曲线那种又爱又恨的心情。本文将带你用PyTorch亲手搭建一个在MNIST数据集上生成手写数字的DCGAN,重点不是复现论文公式,而是通过代码和可视化,让你真正感受到判别器和生成器之间那场惊心动魄的"猫鼠游戏"。
1. 环境准备与数据加载
工欲善其事,必先利其器。我们先准备好实验所需的工具包和数据管道。建议使用Python 3.8+和PyTorch 1.10+环境,以下是我的常用配置组合:
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
print(f"PyTorch版本: {torch.__version__}")
print(f"CUDA可用: {torch.cuda.is_available()}")
MNIST数据集的加载需要特别注意预处理步骤。不同于普通分类任务,GAN对输入数据的尺度非常敏感。以下是经过实战验证的参数配置:
transform = transforms.Compose([
transforms.Resize(64), # DCGAN通常需要更大的输入尺寸
transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,)) # 将像素值归一化到[-1,1]区间
])
dataset = datasets.MNIST('data', train=True, download=True, transform=transform)
dataloader = DataLoader(dataset, batch_size=128, shuffle=True, num_workers=4)
提示:将像素值归一化到[-1,1]区间而非[0,1]是GAN训练的常见技巧,这能让模型在训练初期获得更好的梯度信号。
2. 构建DCGAN网络结构
2.1 生成器设计
我们的生成器采用典型的转置卷积结构,将100维的随机噪声逐步上采样为28x28的手写数字图像。注意每个转置卷积层后都跟着BatchNorm和ReLU激活(最后一层使用Tanh):
class Generator(nn.Module):
def __init__(self, latent_dim=100):
super().__init__()
self.main = nn.Sequential(
# 输入: latent_dim维噪声
nn.ConvTranspose2d(latent_dim, 512, 4, 1, 0, bias=False),
nn.BatchNorm2d(512),
nn.ReLU(True),
# 当前尺寸: (512,4,4)
nn.ConvTranspose2d(512, 256, 4, 2, 1, bias=False),
nn.BatchNorm2d(256),
nn.ReLU(True),
# 当前尺寸: (256,8,8)
nn.ConvTranspose2d(256, 128, 4, 2, 1, bias=False),
nn.BatchNorm2d(128),
nn.ReLU(True),
# 当前尺寸: (128,16,16)
nn.ConvTranspose2d(128, 1, 4, 2, 1, bias=False),
nn.Tanh()
# 输出尺寸: (1,32,32)
)
def forward(self, input):
return self.main(input)
关键参数说明:
| 参数名称 | 取值 | 作用说明 |
|---|---|---|
| latent_dim | 100 | 噪声向量的维度 |
| nn.ConvTranspose2d | 多层 | 实现上采样操作 |
| nn.BatchNorm2d | 除输出层外 | 稳定训练过程 |
| nn.Tanh() | 输出层 | 将像素值约束到[-1,1]区间 |
2.2 判别器设计
判别器是标准的卷积神经网络,但需要注意以下几点:
- 不使用池化层,改用带步长的卷积进行下采样
- 除最后一层外都使用LeakyReLU激活
- 输出层使用Sigmoid产生0-1的概率值
class Discriminator(nn.Module):
def __init__(self):
super().__init__()
self.main = nn.Sequential(
# 输入: (1,32,32)
nn.Conv2d(1, 64, 4, 2, 1, bias=False),
nn.LeakyReLU(0.2, inplace=True),
# 当前尺寸: (64,16,16)
nn.Conv2d(64, 128, 4, 2, 1, bias=False),
nn.BatchNorm2d(128),
nn.LeakyReLU(0.2, inplace=True),
# 当前尺寸: (128,8,8)
nn.Conv2d(128, 256, 4, 2, 1, bias=False),
nn.BatchNorm2d(256),
nn.LeakyReLU(0.2, inplace=True),
# 当前尺寸: (256,4,4)
nn.Conv2d(256, 1, 4, 1, 0, bias=False),
nn.Sigmoid()
)
def forward(self, input):
return self.main(input).view(-1)
3. 训练过程的对抗博弈
3.1 初始化与损失函数
我们先初始化模型和优化器。关键点在于:
- 使用Adam优化器(GAN训练的金标准)
- 为两个网络设置不同的学习率
- 使用BCELoss作为损失函数
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
G = Generator().to(device)
D = Discriminator().to(device)
criterion = nn.BCELoss()
# 使用不同的学习率
d_optimizer = optim.Adam(D.parameters(), lr=0.0002, betas=(0.5, 0.999))
g_optimizer = optim.Adam(G.parameters(), lr=0.0001, betas=(0.5, 0.999))
3.2 训练循环中的动态平衡
下面是训练过程的核心代码,我们逐段分析对抗训练的精妙之处:
fixed_noise = torch.randn(64, 100, 1, 1, device=device)
d_losses, g_losses = [], []
for epoch in range(50):
for i, (real_images, _) in enumerate(dataloader):
batch_size = real_images.size(0)
real_images = real_images.to(device)
# 训练判别器
d_optimizer.zero_grad()
# 真实图像的损失
real_labels = torch.ones(batch_size, device=device)
real_output = D(real_images)
d_loss_real = criterion(real_output, real_labels)
# 生成图像的损失
noise = torch.randn(batch_size, 100, 1, 1, device=device)
fake_images = G(noise)
fake_labels = torch.zeros(batch_size, device=device)
fake_output = D(fake_images.detach())
d_loss_fake = criterion(fake_output, fake_labels)
# 总判别器损失
d_loss = d_loss_real + d_loss_fake
d_loss.backward()
d_optimizer.step()
# 训练生成器
g_optimizer.zero_grad()
output = D(fake_images)
g_loss = criterion(output, real_labels) # 骗过判别器
g_loss.backward()
g_optimizer.step()
# 记录损失
d_losses.append(d_loss.item())
g_losses.append(g_loss.item())
对抗训练的关键观察点:
-
判别器损失组成:
d_loss_real:判断真实图像的能力d_loss_fake:识别生成图像的能力- 两者相加才是完整的判别器损失
-
生成器训练技巧:
- 使用
fake_images而非fake_images.detach() - 目标标签是
real_labels(试图骗过判别器)
- 使用
-
梯度更新顺序:
- 先更新判别器,再更新生成器
- 每次迭代中两者各更新一次
3.3 损失曲线的秘密语言
将训练过程中的损失变化可视化,你会看到GAN特有的"对抗舞蹈":
plt.figure(figsize=(10,5))
plt.plot(d_losses, label='Discriminator Loss')
plt.plot(g_losses, label='Generator Loss')
plt.xlabel('Iterations')
plt.ylabel('Loss')
plt.legend()
plt.show()
典型训练过程中可能观察到的现象:
-
初期阶段:
- 判别器损失快速下降(轻松区分真假)
- 生成器损失较高(生成的图像很假)
-
中期阶段:
- 判别器损失开始回升(生成器进步)
- 生成器损失震荡下降(尝试各种策略)
-
后期阶段:
- 两者损失达到动态平衡
- 剧烈震荡可能意味着模式崩溃
注意:理想的训练状态是判别器损失维持在0.5-0.7之间,这表明判别器对真假图像的判断存在合理的不确定性。
4. 实战调试技巧与可视化
4.1 训练稳定性技巧
经过数十次实验,我总结了这些提升DCGAN稳定性的技巧:
-
学习率调整:
- 判别器学习率通常是生成器的2倍
- 可以使用学习率衰减策略
-
标签平滑:
real_labels = torch.FloatTensor(batch_size).uniform_(0.9, 1.0) # 代替1.0 fake_labels = torch.FloatTensor(batch_size).uniform_(0.0, 0.1) # 代替0.0 -
噪声添加:
# 在判别器输入中加入噪声 real_images += 0.01 * torch.randn_like(real_images)
4.2 生成效果可视化
定期查看生成样本能直观评估训练效果:
def visualize_generator(G, fixed_noise, epoch):
with torch.no_grad():
fake_images = G(fixed_noise).detach().cpu()
fig = plt.figure(figsize=(8,8))
for i in range(64):
plt.subplot(8,8,i+1)
plt.imshow(fake_images[i,0], cmap='gray')
plt.axis('off')
plt.suptitle(f'Epoch {epoch}')
plt.show()
# 每5个epoch可视化一次
if epoch % 5 == 0:
visualize_generator(G, fixed_noise, epoch)
4.3 常见问题诊断
当训练出现异常时,可以通过这些信号快速定位问题:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 生成器损失降为0 | 判别器太弱 | 增强判别器容量或减少生成器学习率 |
| 判别器损失接近0 | 判别器过强 | 添加噪声或减弱判别器 |
| 生成图像模糊 | 模式崩溃 | 尝试Wasserstein GAN |
| 损失剧烈震荡 | 学习率过高 | 降低学习率或使用Adam的beta参数 |
在调试过程中,保持耐心至关重要。记得我最早训练DCGAN时,花了整整两周时间才让生成的手写数字看起来像模像样。有次因为忘记zero_grad(),白白浪费了三天时间——这个错误现在想来可笑,但当时确实让我抓狂不已。
更多推荐
所有评论(0)