把原理换到图像生成时踩过的坑
一开始我也纠结过:图像生成已经有 VAE 了,为什么还要折腾 GAN?
实际用下来发现两者的生成质量确实有明显差异。
为什么选择 GAN 而不是 VAE
一开始我也纠结过:图像生成已经有 VAE 了,为什么还要折腾 GAN?
实际用下来发现两者的生成质量确实有明显差异。VAE 生成的图像相对平滑但细节模糊,GAN 能生成更锐利、更逼真的图像,但训练过程稳定性和收敛性都不如 VAE。
对我来说选择 GAN 主要是这几个原因:
- 生成质量更高,特别适合需要高保真度的场景
- 理论结构简洁,两个神经网络就能搞定
- 社区生态成熟,各种变种和预训练模型多
不过也得承认,GAN 的训练过程确实比其他生成模型更像是在"调参炼丹"。
GAN 的基本架构
GAN 的核心思想就是两个网络互相博弈:生成器(Generator)和判别器(Discriminator)。
生成器负责从随机噪声中生成看起来像真的图像,判别器负责区分真实图像和生成图像。两者在训练过程中互相促进,最终达到一个纳什均衡。
用数学语言来说,就是两个网络在玩这个极小极大博弈:
min_G max_D V(D, G) = E_x~p_data(x)[log D(x)] + E_z~p_z(z)[log(1 - D(G(z)))]
从简单的 MNIST 开始
第一次实战我选择从 MNIST 数据集开始,理由很简单:数据量小、训练快、容易判断效果好坏。
环境配置:
python==3.9
pytorch==2.0.1
torchvision==0.15.2
numpy==1.24.3
matplotlib==3.7.1
生成器网络结构
生成器接收一个随机噪声向量,通过全连接层和转置卷积生成图像:
import torch
import torch.nn as nn
class Generator(nn.Module):
def __init__(self, input_dim=100, output_dim=784):
super(Generator, self).__init__()
self.model = nn.Sequential(
nn.Linear(input_dim, 256),
nn.LeakyReLU(0.2),
nn.BatchNorm1d(256),
nn.Linear(256, 512),
nn.LeakyReLU(0.2),
nn.BatchNorm1d(512),
nn.Linear(512, 1024),
nn.LeakyReLU(0.2),
nn.BatchNorm1d(1024),
nn.Linear(1024, output_dim),
nn.Tanh() # 输出到[-1,1]范围
)
def forward(self, x):
return self.model(x)
这里有个细节需要注意:输出层用了 Tanh 激活函数,把输出映射到 [-1, 1] 范围。这是因为 MNIST 图像在预处理时也会归一化到这个范围,保持数据分布一致有利于训练稳定。
判别器网络结构
判别器就是一个二分类网络,输出图像是真实的概率:
class Discriminator(nn.Module):
def __init__(self, input_dim=784):
super(Discriminator, self).__init__()
self.model = nn.Sequential(
nn.Linear(input_dim, 1024),
nn.LeakyReLU(0.2),
nn.Dropout(0.3),
nn.Linear(1024, 512),
nn.LeakyReLU(0.2),
nn.Dropout(0.3),
nn.Linear(512, 256),
nn.LeakyReLU(0.2),
nn.Dropout(0.3),
nn.Linear(256, 1),
nn.Sigmoid() # 输出概率
)
def forward(self, x):
return self.model(x)
判别器里加了 Dropout 层,主要是为了防止过拟合。GAN 训练中判别器过拟合会导致生成器无法有效学习。
训练循环
训练循环交替更新判别器和生成器:
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# 数据预处理
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,)) # 归一化到[-1,1]
])
# 加载数据
dataset = datasets.MNIST(root='./data', train=True,
download=True, transform=transform)
dataloader = DataLoader(dataset, batch_size=64, shuffle=True)
# 初始化网络
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
generator = Generator().to(device)
discriminator = Discriminator().to(device)
# 优化器
optimizer_g = optim.Adam(generator.parameters(), lr=0.0002, betas=(0.5, 0.999))
optimizer_d = optim.Adam(discriminator.parameters(), lr=0.0002, betas=(0.5, 0.999))
# 损失函数
criterion = nn.BCELoss()
num_epochs = 100
for epoch in range(num_epochs):
for i, (real_images, _) in enumerate(dataloader):
batch_size = real_images.size(0)
real_images = real_images.view(batch_size, -1).to(device)
# 真实标签和生成标签
real_labels = torch.ones(batch_size, 1).to(device)
fake_labels = torch.zeros(batch_size, 1).to(device)
# 训练判别器
optimizer_d.zero_grad()
outputs = discriminator(real_images)
d_loss_real = criterion(outputs, real_labels)
z = torch.randn(batch_size, 100).to(device)
fake_images = generator(z)
outputs = discriminator(fake_images.detach())
d_loss_fake = criterion(outputs, fake_labels)
d_loss = d_loss_real + d_loss_fake
d_loss.backward()
optimizer_d.step()
# 训练生成器
optimizer_g.zero_grad()
z = torch.randn(batch_size, 100).to(device)
fake_images = generator(z)
outputs = discriminator(fake_images)
g_loss = criterion(outputs, real_labels) # 生成器希望判别器认为是真的
g_loss.backward()
optimizer_g.step()
print(f'Epoch [{epoch+1}/{num_epochs}], '
f'D Loss: {d_loss.item():.4f}, G Loss: {g_loss.item():.4f}')
第一次踩坑:梯度消失
运行到第 20 个 epoch 时,发现判别器损失迅速下降到接近 0,而生成器损失一直在 0.7 左右震荡。生成的图像越来越模糊,基本看不出是什么数字。
这是典型的梯度消失问题。判别器太强,把所有生成图像都轻松识别为假的,生成器收不到有效的梯度反馈。
解决方案有几个:
- 调整学习率:降低判别器的学习率,让生成器有更多追赶空间
- 标签平滑:把真实标签从 1 改为 0.9,给生成器留一点余地
- 网络结构:简化判别器,增强生成器
我试了标签平滑,效果还不错:
# 标签平滑
real_labels = torch.ones(batch_size, 1).to(device) * 0.9
fake_labels = torch.zeros(batch_size, 1).to(device)
同时也调整了学习率,生成器保持 0.0002,判别器降到 0.0001。
第二次踩坑:模式崩溃
解决了梯度消失后,又遇到了模式崩溃问题。生成器开始只生成数字 “1”,其他数字完全消失了。
模式崩溃是 GAN 训练中常见问题,生成器发现只生成某一类图像就能骗过判别器,于是放弃生成多样性。
解决方案:
- Mini-batch Discrimination:让判别器能判断一个 batch 内的多样性
- 特征匹配:让生成器的特征统计分布与真实数据匹配
- 历史平均:对生成器参数做历史平均
我试了 WGAN-GP,用 Wasserstein 距离代替 JS 散度,训练稳定性明显提升:
import torch.autograd as autograd
def compute_gradient_penalty(discriminator, real_samples, fake_samples):
batch_size = real_samples.size(0)
alpha = torch.rand(batch_size, 1)
alpha = alpha.expand_as(real_samples).to(device)
interpolates = (alpha * real_samples + ((1 - alpha) * fake_samples))
interpolates = interpolates.requires_grad_(True)
disc_interpolates = discriminator(interpolates)
gradients = autograd.grad(outputs=disc_interpolates,
inputs=interpolates,
grad_outputs=torch.ones(disc_interpolates.size()).to(device),
create_graph=True,
retain_graph=True,
only_inputs=True)[0]
gradient_penalty = ((gradients.norm(2, dim=1) - 1) ** 2).mean()
return gradient_penalty
WGAN-GP 的关键是用梯度惩罚代替权重裁剪,让判别器的 Lipschitz 约束更稳定。
从 MNIST 到 DCGAN
MNIST 练手后,我尝试用 DCGAN 生成更复杂的图像,比如人脸照片。
DCGAN 的主要改进是把全连接层换成卷积层,更适合图像数据:
import torch.nn as nn
class DCGAN_Generator(nn.Module):
def __init__(self, nz=100, ngf=64, nc=3):
super(DCGAN_Generator, self).__init__()
self.main = nn.Sequential(
# 输入: nz x 1 x 1
nn.ConvTranspose2d(nz, ngf * 8, 4, 1, 0, bias=False),
nn.BatchNorm2d(ngf * 8),
nn.ReLU(True),
# 状态: (ngf*8) x 4 x 4
nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 4),
nn.ReLU(True),
# 状态: (ngf*4) x 8 x 8
nn.ConvTranspose2d(ngf * 4, ngf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 2),
nn.ReLU(True),
# 状态: (ngf*2) x 16 x 16
nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf),
nn.ReLU(True),
# 状态: (ngf) x 32 x 32
nn.ConvTranspose2d(ngf, nc, 4, 2, 1, bias=False),
nn.Tanh()
# 输出: nc x 64 x 64
)
def forward(self, input):
return self.main(input)
class DCGAN_Discriminator(nn.Module):
def __init__(self, nc=3, ndf=64):
super(DCGAN_Discriminator, self).__init__()
self.main = nn.Sequential(
# 输入: nc x 64 x 64
nn.Conv2d(nc, ndf, 4, 2, 1, bias=False),
nn.LeakyReLU(0.2, inplace=True),
# 状态: (ndf) x 32 x 32
nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 2),
nn.LeakyReLU(0.2, inplace=True),
# 状态: (ndf*2) x 16 x 16
nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 4),
nn.LeakyReLU(0.2, inplace=True),
# 状态: (ndf*4) x 8 x 8
nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 8),
nn.LeakyReLU(0.2, inplace=True),
# 状态: (ndf*8) x 4 x 4
nn.Conv2d(ndf * 8, 1, 4, 1, 0, bias=False),
nn.Sigmoid()
)
def forward(self, input):
return self.main(input)
DCGAN 的几个关键设计:
- 使用卷积层代替全连接层,保留空间结构信息
- 生成器用转置卷积上采样,判别器用卷积下采样
- 使用 BatchNorm 稳定训练
- 使用 LeakyReLU 避免 ReLU 的"死神经元"
- 判别器输出用 Sigmoid,生成器输出用 Tanh
调参经验总结
调了一段时间 GAN,总结出几个规律:
学习率设置:生成器和判别器的学习率要平衡,通常 0.0002 是个不错的起点。判别器学习率过高会导致生成器跟不上,太低会导致训练停滞。
Batch Size:太小会导致梯度不稳定,太大会增加显存压力。64-128 是个折中点,如果显存不够可以降到 32,但要注意同步调整学习率。
噪声维度:100 维是个常见选择,太小会限制表达能力,太大会增加训练难度。如果生成质量不够,可以尝试增加到 128 或 256。
优化器选择:Adam 优化器比 SGD 更适合 GAN,尤其是 beta1=0.5 的设置。
网络平衡:判别器不能太强也不能太弱。太强会导致梯度消失,太弱会导致生成器学不到东西。可以通过调整网络深度或学习率来平衡。
尝试 StyleGAN
DCGAN 虽然比原始 GAN 强很多,但生成的人脸还是不够逼真。后来又试了 StyleGAN,效果确实惊艳。
StyleGAN 的核心思想是把风格和内容分离,通过 AdaIN(Adaptive Instance Normalization)来控制生成图像的风格:
class AdaIN(nn.Module):
def __init__(self, num_features, style_dim):
super().__init__()
self.norm = nn.InstanceNorm2d(num_features)
self.fc = nn.Linear(style_dim, num_features * 2)
def forward(self, x, style):
h = self.norm(x)
style = self.fc(style).unsqueeze(2).unsqueeze(3)
gamma, beta = torch.chunk(style, 2, 1)
return gamma * h + beta
StyleGAN 的结构更复杂,训练也更耗资源,但生成质量确实大幅提升。特别是能够控制年龄、表情、光照等属性的变化。
实际应用场景
除了生成图像,GAN 在其他领域也有应用:
- 数据增强:用 GAN 生成额外训练样本,特别是标注数据稀缺的场景
- 图像修复:修复老照片、去除水印、填充缺失区域
- 风格迁移:把一张照片转换成特定艺术风格
- 超分辨率:把低分辨率图像提升到高分辨率
我自己试过用 GAN 做数据增强,在小样本分类任务上确实有帮助,但要注意控制生成数据的质量,避免引入噪声。
收尾时的思考
折腾 GAN 这段时间,最大的感受是:理论上看起来简单的概念,实际落地时细节特别多。
调参像是在走钢丝,要平衡生成器和判别器的能力,要处理梯度消失和模式崩溃,还要注意各种训练不稳定的因素。
但看到训练出的人脸越来越逼真时,那种成就感也是实打实的。GAN 的核心思想 —— 对抗博弈,某种程度上也反映了技术发展的本质:在矛盾中前进。
现在扩散模型火了,生成质量也确实比 GAN 更稳定。但 GAN 的简洁性和生成速度在某些场景下仍然有优势,特别是在实时生成的应用里。
技术迭代很快,但背后的原理值得细琢磨。这篇文章记录的是我踩过的一些坑,希望能帮到同样在折腾 GAN 的你。
参考资料
- Ian Goodfellow 等人 2014 年的 GAN 原始论文
- DCGAN 原始论文
- WGAN-GP 论文
- StyleGAN 论文和官方实现
- PyTorch 官方文档和示例代码
版权声明: 本文首发于 指尖魔法屋-把原理换到图像生成时踩过的坑(https://blog.thinkmoon.cn/post/172-gan-deep-dive-principle-image-generation/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。