把原理换到图像生成时踩过的坑

一开始我也纠结过:图像生成已经有 VAE 了,为什么还要折腾 GAN?

实际用下来发现两者的生成质量确实有明显差异。

为什么选择 GAN 而不是 VAE

一开始我也纠结过:图像生成已经有 VAE 了,为什么还要折腾 GAN?

实际用下来发现两者的生成质量确实有明显差异。VAE 生成的图像相对平滑但细节模糊,GAN 能生成更锐利、更逼真的图像,但训练过程稳定性和收敛性都不如 VAE。

对我来说选择 GAN 主要是这几个原因:

  • 生成质量更高,特别适合需要高保真度的场景
  • 理论结构简洁,两个神经网络就能搞定
  • 社区生态成熟,各种变种和预训练模型多

不过也得承认,GAN 的训练过程确实比其他生成模型更像是在"调参炼丹"。

GAN 的基本架构

GAN 的核心思想就是两个网络互相博弈:生成器(Generator)和判别器(Discriminator)。

flowchart LR Z[随机噪声 z] --> G[生成器 G] G --> Gz[生成图像 G z] Gx[真实图像 x] --> D[判别器 D] Gz --> D D --> Dx[D x] D --> DGz[D G z] Dx --> L1[真实损失] DGz --> L2[生成损失] L1 --> G_loss[生成器损失] L2 --> G_loss L1 --> D_loss[判别器损失] L2 --> D_loss G_loss --> |更新参数 G| G D_loss --> |更新参数 D| D

生成器负责从随机噪声中生成看起来像真的图像,判别器负责区分真实图像和生成图像。两者在训练过程中互相促进,最终达到一个纳什均衡。

用数学语言来说,就是两个网络在玩这个极小极大博弈:

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. 调整学习率:降低判别器的学习率,让生成器有更多追赶空间
  2. 标签平滑:把真实标签从 1 改为 0.9,给生成器留一点余地
  3. 网络结构:简化判别器,增强生成器

我试了标签平滑,效果还不错:

# 标签平滑
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 训练中常见问题,生成器发现只生成某一类图像就能骗过判别器,于是放弃生成多样性。

解决方案:

  1. Mini-batch Discrimination:让判别器能判断一个 batch 内的多样性
  2. 特征匹配:让生成器的特征统计分布与真实数据匹配
  3. 历史平均:对生成器参数做历史平均

我试了 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/) 转载或引用必须申明原指尖魔法屋来源及源地址!