对抗性学习基础:GAN 原理

GAN 这东西,说白了就是让两个网络互相“打架”。一个负责造假,一个负责抓假。打到后来,造假的那个手艺越来越精,抓假的那个眼光越来越毒。我在 2017 年刚接触这个方向时,总觉得它像个黑魔法——两个网络同时训练,梯度互相影响,稍不留神就崩了。但你想想看,正是这种对抗机制,让域适应有了新的突破口。

GAN 的核心结构

标准的 GAN 由两部分组成:生成器(Generator)和判别器(Discriminator)。生成器负责把随机噪声变成看起来像真实数据的样本;判别器则判断输入是真实数据还是生成的数据。两个网络玩的是一个极小极大博弈。

我曾经在一个图像翻译项目里尝试过最简单的 GAN,结果生成器直接“摆烂”——输出全黑图片。后来才发现,是判别器收敛太快,生成器根本没机会学。所以平衡训练节奏是个大学问。

核心目标函数(Minimax Game)

min_G max_D V(D,G) = E_{x~p_data}[log D(x)] + E_{z~p_z}[log(1 - D(G(z)))]

判别器想让 D(x) 接近 1,D(G(z)) 接近 0;生成器想让 D(G(z)) 接近 1——两边对着干。

整个对抗过程就像警察抓假币贩子。警察(判别器)不断升级验钞机,假币贩子(生成器)不断改进印刷技术。最后假币贩子造出来的钞票,警察肉眼已经分不出了。嗯,这就是 GAN 的理想状态——生成分布完全拟合真实分布。

生成对抗网络(GAN)训练流程 GAN 对抗训练流程 随机噪声 z 生成器 G (假币贩子) 假样本 G(z) 真实数据 x 判别器 D (警察) 真/假概率 D(·) ∈ [0,1] 反向传播更新参数 训练技巧 • 交替更新 G 和 D,每次只训练一方 • 判别器更新次数通常多于生成器

训练中的平衡策略

GAN 最难的就是训练稳定。我记得第一次调 GAN 时,loss 曲线抖得像心电图,生成器跟傻子一样输出噪点。后来我总结出几条经验:

  • 交替更新:每次迭代只更新一个网络,另一个冻结。比如先更新 k 次判别器,再更新一次生成器。k 通常取 1 或 3。
  • 标签平滑:真实标签不用 1,用 0.9;假标签不用 0,用 0.1。这样判别器不会过于自信,梯度更温和。
  • 输入加噪声:给判别器的输入增加少量高斯噪声,相当于正则化。我在车牌识别项目里用过这个技巧,生成的样本终于不那么模糊了。

损失函数的设计演进

原始的 GAN 使用二分类交叉熵损失,但梯度容易消失。后来出现了各种变体。我整理了一个对比表格,方便你理解各家思路:

损失函数 核心思想 常见问题 我的建议
标准 GAN (minimax) 交叉熵,D 和 G 互相博弈 梯度消失,训练不稳定 只适合玩具数据
非饱和 GAN (NS-GAN) G 的损失改为 -log(D(G(z))) 容易模式崩塌 配合标签平滑可用
WGAN (Wasserstein) 用 Wasserstein 距离替代 JS 散度 需要截断权重(后来用梯度惩罚改进) 推荐,收敛明显更稳定
LSGAN (Least Squares) 用最小二乘损失,惩罚远离决策边界的样本 生成样本可能模糊 适合图像生成任务

经验之谈:如果你刚开始上手 GAN,我建议直接用 WGAN-GP(梯度惩罚版本)。代码改动很小,但训练稳定性提升一个数量级。我曾经在某个工业检测项目里,用 WGAN-GP 做数据增强,生成的缺陷样本连老师傅都分辨不出真假。

特征融合视角下的 GAN

现在我们把 GAN 的概念往域适应上引。传统的 GAN 只能做图像生成,但域适应需要的是“特征级别的对抗”。什么意思呢?

域适应的目标是把源域和目标域的特征分布拉近。我们可以让一个判别器判断特征是来自源域还是目标域,然后特征提取器(相当于生成器)尝试“骗过”这个判别器。这样学到的特征就是域无关的。

说白了,就是把 GAN 里的像素空间对抗,升级成了特征空间对抗。我参与的某个跨场景行人重识别项目,就是用这种思路把白天场景学的特征迁移到夜间场景,准确率提升了将近 15 个百分点。

避坑指南:我曾经在特征对抗里直接把源域和目标域的特征输入判别器,结果判别器学到了“批次统计量”这种无关特征(比如源域 batch 均值偏高,目标域 batch 均值偏低)。后来我加了自监督的批次归一化统计量对齐,才算真正解决了问题。

代码骨架:一个最小 GAN

下面给一个 PyTorch 风格的最小 GAN 实现骨架。注意这只是一个精简演示——真正的域适应代码会复杂得多。

import torch
import torch.nn as nn

# 生成器:把 100 维噪声映射到 784 维(28x28 图像)
class Generator(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(100, 256),
            nn.ReLU(),
            nn.Linear(256, 512),
            nn.ReLU(),
            nn.Linear(512, 784),
            nn.Tanh()  # 输出归一化到 [-1,1]
        )
    def forward(self, z):
        return self.net(z)

# 判别器:区分真实还是生成
class Discriminator(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(784, 512),
            nn.LeakyReLU(0.2),
            nn.Linear(512, 256),
            nn.LeakyReLU(0.2),
            nn.Linear(256, 1),
            nn.Sigmoid()
        )
    def forward(self, x):
        return self.net(x)

# 训练循环(简化)
G = Generator()
D = Discriminator()
criterion = nn.BCELoss()
optim_G = torch.optim.Adam(G.parameters(), lr=0.0002)
optim_D = torch.optim.Adam(D.parameters(), lr=0.0002)

for epoch in range(100):
    # 1. 更新判别器
    for _ in range(3):  # 判别器多更新几次
        z = torch.randn(64, 100)
        fake = G(z)
        real = real_data  # 假设从 dataloader 取
        d_real = D(real)
        d_fake = D(fake.detach())
        loss_D = criterion(d_real, real_labels) + criterion(d_fake, fake_labels)
        optim_D.zero_grad()
        loss_D.backward()
        optim_D.step()
    # 2. 更新生成器
    z = torch.randn(64, 100)
    fake = G(z)
    d_fake = D(fake)
    loss_G = criterion(d_fake, real_labels)  # 用 real_labels 骗过 D
    optim_G.zero_grad()
    loss_G.backward()
    optim_G.step()

注意上面的判别器更新了 3 次,生成器只更新 1 次。这就是我说的交替不平衡策略。你可以根据实际收敛情况调整这个比例。

GAN 在域适应中的定位

理解 GAN 是理解对抗性域适应的第一步。后面章节我们会把生成器和判别器替换成特征提取器和域判别器,那时候你会发现整个框架几乎一模一样——只是输入从图像变成了特征。

嗯,到这里 GAN 的基础就铺垫好了。接下来你会看到它如何在域适应里大显身手。