对抗性学习基础: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 时,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 的基础就铺垫好了。接下来你会看到它如何在域适应里大显身手。