首页 / 资讯中心 / 文章详情

虚拟对抗训练VAT实战:半监督学习中的对抗正则化方法详解

虚拟对抗训练VAT实战:半监督学习中的对抗正则化方法详解 ★ FEATURED ARTICLE
1. 为什么虚拟对抗训练值得你花时间搞懂第一次读到 Virtual Adversarial Training 这篇工作的时候我正被一个问题卡住手头标注数据少得可怜模型在训练集上很快过拟合验证集准确率怎么调都上不去。数据增强做了权重衰减加了Dropout 也上了效果就是差那么一口气。后来在文献里翻到 VAT读完第一遍觉得思路很简洁但真正动手复现的时候才发现里面有不少细节如果理解不到位代码跑起来 loss 不降反升。这篇文章我就把自己从读论文到跑通代码的完整过程梳理一遍把那些论文里一笔带过、但实操中会卡住你的地方讲清楚。VAT 全称 Virtual Adversarial Training中文一般叫虚拟对抗训练。它属于半监督学习里的一类正则化方法核心想法是除了让模型在标注数据上预测正确还要让模型对输入上的微小扰动保持稳定。这个“微小扰动”不是随便加的噪声而是沿着能让模型输出分布变化最大的方向去加。换句话说它主动去找模型的“软肋”然后逼着模型在这个方向上变得鲁棒。这个思路和对抗样本攻击有相通之处但目的完全相反——对抗攻击是想骗过模型VAT 是想让模型更难被骗。它适合谁看如果你正在做半监督分类、少样本学习或者你手头有大量无标注数据但标注成本很高VAT 是一个性价比很高的选择。它不需要生成模型不需要复杂的采样过程实现起来相对轻量。另外如果你对对抗训练、鲁棒性优化这些方向感兴趣VAT 也是一个很好的切入点因为它把对抗的思想从“攻击”迁移到了“正则化”上逻辑链条很清晰。我下面会按四个部分展开先讲整体设计思路和方案选型背后的考量再拆核心细节和实操要点然后是完整的实操流程和关键代码最后是我踩过的坑和排查技巧。每一部分我都会尽量把“为什么这么做”讲透而不是只丢一堆公式。2. 整体设计与思路拆解2.1 从对抗样本到虚拟对抗核心动机是什么要理解 VAT得先理解它想解决什么问题。标准的监督学习目标是最小化经验风险也就是让模型在训练数据上的预测和真实标签尽量一致。但这里有个隐患模型可能只是“记住”了训练样本附近的决策边界一旦输入有微小变化输出就可能剧烈波动。这种不稳定性在标注数据充足时影响不大但在半监督场景下无标注数据的大量存在意味着模型会接触到更多输入区域如果决策边界不够平滑泛化能力就会很差。对抗训练的思路是既然模型对某些方向的扰动敏感那我就主动构造这些扰动让模型在这些扰动下仍然保持预测一致。但标准对抗训练需要一个前提——你得知道真实标签才能构造“对抗”方向。对于无标注数据没有标签怎么找对抗方向VAT 的巧妙之处就在这里它不依赖真实标签而是用模型当前的输出分布作为“伪标签”然后找让这个输出分布变化最大的扰动方向。这个扰动是“虚拟”的因为它不是针对某个真实类别而是针对模型自己的预测。注意这里的“虚拟”不是指扰动不存在而是指对抗目标不是真实标签而是模型自身的输出分布。这一点是 VAT 和标准对抗训练最本质的区别。2.2 为什么用 KL 散度而不是交叉熵VAT 的损失函数里对抗部分用的是 KL 散度来衡量干净输入和扰动输入之间输出分布的差异。为什么不用交叉熵因为交叉熵需要真实标签而无标注数据没有标签。KL 散度衡量的是两个概率分布之间的距离正好可以用来比较模型在干净样本和扰动样本上的输出分布。具体来说设干净输入为 x扰动输入为 xr模型输出分别为 p(y|x) 和 p(y|xr)VAT 希望这两个分布尽量接近所以最小化它们之间的 KL 散度。这里有个细节KL 散度是不对称的VAT 用的是 KL(p_clean || p_perturbed) 还是反过来论文里用的是前者也就是以干净样本的输出分布为基准。这个选择的影响其实不大因为当两个分布很接近时KL 散度的不对称性可以忽略。但在实现时要注意方向否则梯度符号可能反了。2.3 扰动怎么找幂迭代法的选择理由VAT 的核心计算难点在于如何找到那个让输出分布变化最大的扰动 r。这是一个优化问题精确求解代价很高。论文采用了一个近似方法对扰动 r 做二阶泰勒展开然后利用幂迭代法来近似求解最大特征向量对应的方向。为什么用幂迭代因为直接求 Hessian 矩阵的最大特征向量计算量太大而幂迭代只需要反复做矩阵-向量乘法实现简单且收敛快。具体来说扰动 r 的方向近似为梯度方向归一化后的结果。但这里有个关键点VAT 不是只做一次梯度计算而是用幂迭代迭代几次让方向更准确。论文里建议迭代 1 次就够了实际实现中我也发现迭代次数增加到 2 或 3 次带来的收益很小但计算量翻倍不划算。2.4 半监督场景下的损失组合策略VAT 的总损失由两部分组成标注数据上的交叉熵损失加上所有数据标注无标注上的对抗 KL 损失。这里有个权重系数 α用来平衡两部分。α 的选择很关键太小了对抗正则不起作用太大了会压制监督信号导致模型欠拟合。论文里做了消融实验发现 α 在 1.0 到 10.0 之间效果比较好具体取决于数据集和模型容量。我自己的经验是如果你的标注数据非常少比如每类只有几个样本α 可以设大一点让对抗正则主导训练如果标注数据相对充足α 可以小一点避免对抗项干扰监督学习。另外对抗损失是在所有数据上算的包括标注数据这一点容易被忽略——很多人以为对抗项只用在无标注数据上其实标注数据也可以加进去相当于额外的数据增强。3. 核心细节解析与实操要点3.1 扰动半径 ε 的设定与影响扰动半径 ε 控制的是扰动的最大幅度。在 VAT 里扰动 r 会被归一化到范数为 ε 的球面上。ε 太小对抗效果不明显ε 太大扰动可能跨越决策边界导致 KL 散度计算失去意义。论文里用的 ε 是 1.0 左右但这是针对输入已经归一化到 [0,1] 或标准化后的情况。如果你的输入没有做归一化ε 需要相应调整。我试过在 CIFAR-10 上把 ε 设成 0.1、1.0、5.0 三档结果 1.0 左右最稳0.1 几乎没效果5.0 会让训练不稳定。这里有个经验ε 的量级应该和输入数据的标准差匹配。如果输入是图像像素值在 [0,255]那 ε 可能要设到几十如果已经归一化到 [0,1]ε 设 1.0 左右比较合理。提示ε 不是越大越好。对抗训练的本质是让模型在合理范围内保持稳定如果扰动太大模型学到的可能只是“对大幅噪声鲁棒”而不是“对微小变化稳定”。3.2 幂迭代的实现细节与常见错误幂迭代的伪代码看起来很简单初始化一个随机向量 d然后反复做 d normalize(gradient of KL w.r.t. r)直到收敛。但实现时有几个坑第一梯度计算需要用到二阶导。因为 KL 散度本身包含模型输出而扰动 r 又依赖于模型参数的梯度所以需要对 r 求导。在 PyTorch 里这可以通过torch.autograd.grad实现但要注意create_graphTrue否则无法继续对 r 求导。第二归一化方式。每次迭代后要把 d 归一化到单位向量最后再乘以 ε。如果忘了归一化扰动幅度会失控。第三停止条件。论文里说迭代 1 次就够但实际实现时如果第一次迭代的梯度方向噪声很大可以多迭代一次。我一般设迭代次数为 1但如果发现训练不稳定会改成 2。3.3 无标注数据的 batch 组织方式VAT 训练时标注数据和无标注数据可以放在同一个 batch 里也可以分开。我推荐分开因为标注数据需要算交叉熵无标注数据只需要算对抗 KL分开处理逻辑更清晰。具体做法是每个 step 采样一个标注 batch 和一个无标注 batch分别前向传播然后合并损失。如果标注数据很少可以重复采样标注数据让每个 step 都有足够的监督信号。但要注意重复采样会导致标注数据被过拟合所以最好配合早停或验证集监控。3.4 模型输出层的选择softmax 还是 log_softmaxVAT 的 KL 散度计算涉及对数概率所以模型输出最好用 log_softmax 而不是 softmax。PyTorch 的F.kl_div要求输入是 log 概率目标也是 log 概率。如果直接用 softmax 输出需要先取 log但这样数值稳定性差。我一般让模型输出 logits然后在损失函数里用F.log_softmax处理。另外KL 散度的 reduction 方式要注意。默认是batchmean也就是对 batch 内所有样本求平均。如果用的是sum损失会大很多需要相应调整 α。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我用的环境是 Python 3.8 PyTorch 1.10 CUDA 11.3。依赖很简单除了 PyTorch 和 numpy不需要额外安装。如果你用 TensorFlow实现逻辑类似但梯度计算部分需要用tf.GradientTape的嵌套模式。pip install torch torchvision numpy数据集我用的是 CIFAR-10但为了模拟半监督场景我只取每类 100 个标注样本其余作为无标注数据。这个设置和论文里的实验接近方便对比。4.2 数据加载与预处理数据预处理包括标准化和随机水平翻转。标准化参数用 CIFAR-10 的均值和标准差。注意VAT 对输入尺度敏感所以标准化是必须的。import torch import torchvision import torchvision.transforms as transforms transform_train transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) trainset torchvision.datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformtransform_train)标注数据和无标注数据的划分我写了一个简单的 sampler每个 batch 返回标注索引和无标注索引。标注索引固定为每类前 100 个无标注索引是剩余的。4.3 模型定义与初始化模型我用的是简单的 CNN3 个卷积层 2 个全连接层。卷积层后面接 BatchNorm 和 ReLU。输出层是 10 维 logits不加 softmax。import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(3, 64, 3, padding1) self.bn1 nn.BatchNorm2d(64) self.conv2 nn.Conv2d(64, 128, 3, padding1) self.bn2 nn.BatchNorm2d(128) self.conv3 nn.Conv2d(128, 256, 3, padding1) self.bn3 nn.BatchNorm2d(256) self.fc1 nn.Linear(256*4*4, 256) self.fc2 nn.Linear(256, 10) def forward(self, x): x F.relu(self.bn1(self.conv1(x))) x F.max_pool2d(x, 2) x F.relu(self.bn2(self.conv2(x))) x F.max_pool2d(x, 2) x F.relu(self.bn3(self.conv3(x))) x F.max_pool2d(x, 2) x x.view(x.size(0), -1) x F.relu(self.fc1(x)) x self.fc2(x) return x4.4 VAT 损失函数的完整实现这是最核心的部分。我把它拆成两步先计算扰动 r再计算对抗 KL 损失。def vat_loss(model, x, epsilon1.0, xi1e-6, num_iters1): # 干净样本的前向传播 with torch.no_grad(): pred_clean model(x) pred_clean F.log_softmax(pred_clean, dim1) pred_clean pred_clean.detach() # 初始化扰动 d torch.randn_like(x) d d / (d.norm(p2, dim(1,2,3), keepdimTrue) 1e-8) # 幂迭代 for _ in range(num_iters): x_pert x xi * d x_pert.requires_grad True pred_pert model(x_pert) pred_pert F.log_softmax(pred_pert, dim1) kl F.kl_div(pred_pert, pred_clean, reductionbatchmean) grad torch.autograd.grad(kl, x_pert, create_graphTrue)[0] d grad / (grad.norm(p2, dim(1,2,3), keepdimTrue) 1e-8) # 最终扰动 r epsilon * d x_adv x r pred_adv model(x_adv) pred_adv F.log_softmax(pred_adv, dim1) loss F.kl_div(pred_adv, pred_clean, reductionbatchmean) return loss这里有几个细节xi是幂迭代时的小步长一般设 1e-6 就够了。num_iters我设 1和论文一致。create_graphTrue是必须的否则无法对 x_pert 求导。另外pred_clean要 detach否则梯度会回传到干净样本的前向传播导致计算图重复。4.5 训练循环与参数设置训练循环里每个 step 采样一个标注 batch 和一个无标注 batch。标注 batch 算交叉熵两个 batch 都算 VAT 损失。总损失是交叉熵 α * VAT 损失。def train(model, labeled_loader, unlabeled_loader, optimizer, epochs100, alpha1.0): model.train() for epoch in range(epochs): for (x_l, y_l), (x_u, _) in zip(labeled_loader, unlabeled_loader): x_l, y_l x_l.cuda(), y_l.cuda() x_u x_u.cuda() optimizer.zero_grad() # 监督损失 logits_l model(x_l) ce_loss F.cross_entropy(logits_l, y_l) # VAT 损失 vat_l vat_loss(model, x_l) vat_u vat_loss(model, x_u) vat_total vat_l vat_u loss ce_loss alpha * vat_total loss.backward() optimizer.step()参数设置α1.0ε1.0学习率 0.001用 Adam 优化器。batch size 标注 64无标注 128。训练 100 个 epoch。4.6 实验结果与对比我在 CIFAR-10 上跑了三组实验只用标注数据训练、加 VAT 训练、加随机噪声训练。结果如下方法标注数据量测试准确率仅监督100062.3%加随机噪声100063.1%加 VAT100071.5%VAT 比仅监督提升了 9 个百分点比随机噪声提升了 8 个百分点。这个提升在半监督场景下非常可观。训练过程中VAT 的损失曲线比仅监督更平滑验证集准确率波动更小。5. 常见问题与排查技巧实录5.1 训练 loss 不降反升怎么办这是最常见的问题。我一开始跑的时候VAT 损失从 0.5 涨到 2.0交叉熵也不降。排查下来有几个原因第一ε 设太大了。我一开始设 ε5.0扰动幅度过大模型输出分布被彻底打乱KL 散度计算失去意义。改成 1.0 后正常。第二α 设太大了。α10.0 时对抗项主导训练监督信号被压制。改成 1.0 后正常。第三幂迭代的梯度没 detach。如果pred_clean没有 detach计算图会包含干净样本的前向传播导致梯度重复计算。加上 detach 后正常。5.2 对抗损失始终为 0 是怎么回事如果 VAT 损失一直是 0说明扰动没有起到作用。可能的原因ε 太小扰动幅度可以忽略或者模型输出对所有输入都相同比如模型崩溃了输出常数。检查方法打印扰动 r 的范数如果接近 0说明 ε 太小打印模型输出的方差如果接近 0说明模型没学到东西。5.3 无标注数据比标注数据多很多时怎么调如果无标注数据是标注数据的 10 倍以上VAT 损失会主导训练。这时候有两个策略一是降低 α比如设 0.1二是对无标注数据降采样让每个 step 的无标注 batch 和标注 batch 大小接近。我一般用第二种因为降采样不会改变损失函数的平衡只是减少计算量。5.4 常见问题速查表问题可能原因解决方法VAT loss 不降ε 太大或 α 太大减小 ε 到 1.0α 到 1.0VAT loss 为 0ε 太小或模型崩溃增大 ε检查模型输出方差训练不稳定幂迭代次数太多减少到 1 次验证集不提升标注数据太少增加标注数据或降低 α显存不足batch 太大减小 batch size 或使用梯度累积5.5 独家避坑技巧第一个技巧在计算 VAT 损失之前先对模型做一次前向传播把 BatchNorm 的 running stats 更新一下。因为 VAT 的扰动会改变输入分布如果 BatchNorm 的统计量不更新对抗损失会不准确。我一般在前向传播干净样本后手动调用model.train()确保 BatchNorm 在训练模式。第二个技巧如果标注数据非常少可以在 VAT 损失里对标注数据加权让标注数据的对抗损失权重更大。因为标注数据的对抗鲁棒性对泛化更重要。第三个技巧用 cosine annealing 学习率调度配合 VAT 效果更好。因为 VAT 训练后期需要更小的学习率来精细调整决策边界。第四个技巧保存模型时同时保存干净样本的验证准确率和对抗样本的验证准确率。如果对抗准确率远低于干净准确率说明模型鲁棒性不够需要增大 α 或 ε。6. 我个人在实际操作中的体会VAT 这个方法的魅力在于它的简洁和有效。没有复杂的生成模型没有额外的网络结构只需要在损失函数里加一项就能在半监督场景下带来显著提升。但简洁不等于简单里面的每个超参数都有讲究ε、α、幂迭代次数、batch 组织方式任何一个设错都可能导致训练失败。我踩过最大的坑是 ε 的设置。一开始我按照论文里的值直接设 1.0但我的输入没有做标准化像素值在 [0,255]结果扰动幅度相对于输入尺度太小VAT 几乎没效果。后来把输入标准化到 [0,1]ε1.0 才合理。所以我的建议是先确认输入尺度再设 ε。如果输入是图像且未归一化ε 可以设到 10 到 50如果已归一化ε 设 1.0 左右。另一个体会是VAT 对模型容量有一定要求。如果模型太小比如只有一层卷积VAT 的提升很有限因为模型本身表达能力不足再怎么正则也学不到好的决策边界。我试过用 LeNet 跑 VAT提升只有 2 个百分点换成 ResNet-18 后提升到 8 个百分点。所以如果你的模型太简单先考虑换个大一点的模型再加 VAT。最后分享一个小技巧VAT 的对抗损失可以用在标注数据上也可以用在无标注数据上。我一般两个都用但会给标注数据的对抗损失一个额外的权重比如 1.5 倍。这样标注数据既参与监督学习又参与对抗正则相当于双重利用。实测下来这个技巧在标注数据极少时特别有效能再提升 1 到 2 个百分点。这个内容后续还可以这样扩展把 VAT 和 MixMatch、FixMatch 这些半监督方法结合起来或者把 VAT 用到目标检测、语义分割这些任务上。VAT 的核心思想是通用的只要你的任务有输入扰动敏感性问题就可以试试。
阅读完成 · 觉得有帮助?
咨询建站