1. 为什么虚拟对抗训练值得单独拿出来讲
第一次读到 Virtual Adversarial Training 这篇工作的时候,我正被半监督学习的落地问题折腾得够呛。手头标注数据少得可怜,模型在训练集上很快过拟合,验证集准确率来回震荡,加数据增强、调学习率、换优化器,能试的都试了一遍,提升始终有限。后来在整理对抗训练相关资料时重新翻到 VAT,才意识到它解决的正是一个很实际的问题:在没有额外标注数据的情况下,怎么让模型学得更稳、泛化更好。
VAT 全称 Virtual Adversarial Training,中文一般叫虚拟对抗训练。它属于对抗训练家族里比较优雅的一支,和 FGSM、PGD 这类依赖真实标签的对抗样本方法不同,VAT 的核心思路是构造一种"虚拟"的扰动方向——这个扰动不是朝着让预测标签出错的方向走,而是朝着让模型输出分布变化最大的方向走。换句话说,它不关心标签对不对,只关心模型在输入附近是否足够"平滑"。这个区别非常关键,因为它让 VAT 天然适合半监督场景:没有标签的样本也能参与训练。
这篇文章我打算按四个层次来拆:先讲清楚 VAT 的整体设计思路和它要解决的核心矛盾,再把数学推导和算法流程一步步拆开,接着给出可以直接跑的核心代码实现,最后把我自己在复现过程中踩过的坑和排查经验整理出来。适合已经了解基础深度学习、想深入理解对抗训练和半监督学习的朋友,也适合想直接拿 VAT 用到自己项目里的工程师。读完你应该能自己从零实现一个可用的 VAT 训练流程,并且知道每个参数背后的取舍逻辑。
2. VAT 的整体设计思路与核心矛盾拆解
2.1 从对抗样本到虚拟对抗:一个关键的方向转变
要理解 VAT,得先理解它和标准对抗训练的分歧点在哪里。标准对抗训练(比如用 FGSM 生成对抗样本)的逻辑是:给定输入 x 和标签 y,找到一个扰动 r,使得模型在 x+r 上的预测尽量偏离 y,然后把 (x+r, y) 加入训练,让模型学会抵抗这种攻击。这个流程里,标签 y 是必需的,因为扰动的方向是由损失函数对输入的梯度决定的,而损失函数需要标签才能计算。
VAT 把这个逻辑改了一下。它不再问"怎么让模型预测错",而是问"怎么让模型的输出分布变化最大"。具体来说,它构造的扰动方向是让模型输出分布 p(y|x) 和 p(y|x+r) 之间的散度最大化的方向。这个散度通常用 KL 散度来衡量。注意,这里完全没有用到真实标签,只用到模型自己的输出。这就是"虚拟"二字的来源——扰动不是针对真实标签的对抗,而是针对模型自身输出一致性的对抗。
这个转变带来的直接好处是:无标签数据也能用。你只需要把无标签样本喂进去,让模型在它附近保持输出一致,就能起到正则化效果。对于半监督学习来说,这等于凭空多出了一大批可用的训练信号。
2.2 为什么"局部平滑"能提升泛化
很多人第一次接触 VAT 会有一个疑问:让模型在输入附近输出一致,为什么就能提升泛化?这背后的直觉其实不复杂。
想象一下你在做一个图像分类任务,同一张猫的图片,稍微加一点噪声、稍微平移几个像素,人眼看起来还是猫,但模型可能会因为这点微小变化就预测成狗。这说明模型的决策边界在输入空间里太"陡峭"了,局部不稳定。VAT 做的事情,就是强行把这种陡峭的地方压平——它要求模型在输入点附近的一个小邻域内,输出分布不能有剧烈变化。
从流形学习的角度看,真实数据往往分布在一个低维流形上,流形上的点语义相近,流形外的点才是噪声。VAT 相当于在流形局部施加了一个平滑约束,让模型学到的函数在数据分布附近更连续。这种连续性正是泛化的基础:测试样本和训练样本不会完全一样,但如果模型在训练样本附近足够平滑,它就能对测试样本给出合理预测。
我个人的理解是,VAT 本质上是一种基于模型自身一致性的正则化,它和 dropout、权重衰减这些传统正则化手段是互补的。dropout 约束的是网络内部激活的稳定性,VAT 约束的是输入到输出映射的稳定性,两者作用层面不同。
2.3 VAT 与其它半监督方法的对比取舍
半监督学习的方法很多,VAT 不是唯一选择。把它和几个常见方案放一起对比,能更清楚它的定位。
| 方法 | 核心思想 | 是否需要标签 | 计算开销 | 适用场景 |
|---|---|---|---|---|
| 伪标签 | 用模型预测当标签 | 部分需要 | 低 | 类别少、模型较准 |
| 熵最小化 | 让预测分布更尖锐 | 不需要 | 低 | 分类边界清晰 |
| Π-Model | 两次前向输出一致 | 不需要 | 中 | 通用 |
| Mean Teacher | 学生追教师输出 | 不需要 | 中高 | 通用、稳定 |
| VAT | 对抗方向输出一致 | 不需要 | 中高 | 通用、鲁棒性强 |
VAT 相比 Π-Model 的区别在于,Π-Model 的扰动是随机的(比如 dropout 噪声),而 VAT 的扰动是"最坏方向"的,所以约束更强、更有效。相比 Mean Teacher,VAT 不需要维护一个教师模型,实现更简单,但稳定性略逊。实际项目里,我经常把 VAT 和 Mean Teacher 结合使用,效果比单用任何一个都好。
选择 VAT 的典型场景是:标注数据少、无标签数据多、任务对鲁棒性有要求。如果你的数据量已经很大,VAT 带来的边际收益会下降,这时候优先考虑数据质量而不是正则化。
3. 数学推导与算法流程逐步拆解
3.1 从损失函数看 VAT 的优化目标
VAT 的完整损失函数由两部分组成:有标签数据的监督损失,加上无标签数据的局部平滑损失。写成公式大概是这样:
L = L_supervised + α · L_VAT
其中 L_supervised 就是常规的交叉熵损失,只在有标签数据上计算。L_VAT 是 VAT 的核心,定义如下:
L_VAT = D_KL( p(y|x, θ) || p(y|x + r_vadv, θ) )
这里的 r_vadv 就是虚拟对抗扰动,它的定义是:
r_vadv = argmax_r D_KL( p(y|x, θ) || p(y|x + r, θ) ),且 ||r|| ≤ ε
翻译成人话:r_vadv 是在半径为 ε 的球内,能让模型输出分布变化最大的那个方向。α 是平衡两项的超参数,ε 是扰动幅度上限。
这个目标函数的设计意图很明确:监督损失保证模型在有标签数据上学对,VAT 损失保证模型在无标签数据附近保持输出稳定。两项联合优化,模型既准又稳。
3.2 虚拟对抗扰动的近似求解
直接求解 r_vadv 需要解一个带约束的优化问题,计算量很大。VAT 论文给出的做法是用二阶泰勒展开近似,再配合幂迭代法求解。这个推导是整篇论文最精妙的部分,我把它拆开讲。
首先,定义散度 D(r) = D_KL( p(y|x, θ) || p(y|x+r, θ) )。当 r=0 时,D(0)=0,且 D(r) 在 r=0 处的一阶梯度为 0(因为散度在原点取极小值)。所以对 D(r) 在 r=0 处做泰勒展开,一阶项消失,只剩下二阶项:
D(r) ≈ (1/2) · r^T · H · r
其中 H 是 D(r) 在 r=0 处的 Hessian 矩阵。于是问题转化为:在 ||r|| ≤ ε 约束下,最大化 r^T H r。这个问题的解是 H 的最大特征值对应的特征向量方向,即 r_vadv ∝ H 的主特征向量。
直接算 Hessian 太贵,VAT 用幂迭代法来近似主特征向量。幂迭代的核心是反复用矩阵乘向量,让向量逐渐收敛到主特征方向。这里的关键技巧是:H·v 可以通过两次反向传播高效计算,不需要显式构造 Hessian。具体来说,H·v 等于对 (∇_r D · v) 再求一次关于 r 的梯度。
3.3 幂迭代法的具体执行步骤
幂迭代的流程我按实际操作顺序列一下,这是代码实现的核心依据:
- 初始化扰动向量 d 为随机向量(通常从正态分布采样),归一化到单位长度。
- 重复 K 次(K 一般取 1 到 10):
- 前向传播计算 p(y|x+d, θ)。
- 计算 KL 散度 D = KL( p(y|x, θ) || p(y|x+d, θ) )。
- 对 d 求梯度 g = ∇_d D。
- 归一化 g,更新 d = g / ||g||。
- 最终扰动 r_vadv = ε · d。
这里有个细节值得注意:每次迭代都要重新计算 p(y|x+d, θ),因为 d 在变。K 的取值是个权衡,K 越大近似越准,但计算开销线性增长。论文里实验表明 K=1 就已经能work,K=10 效果更好但提升有限。我实测下来,K 取 1 到 3 是性价比最高的区间。
提示:幂迭代里对 d 的归一化不能省,否则 d 的模长会失控,导致扰动幅度远超 ε,训练直接崩掉。
3.4 完整训练流程的伪代码梳理
把上面的部分串起来,VAT 的完整训练流程是这样的:
for each batch: # 有标签部分 logits_labeled = model(x_labeled) loss_sup = cross_entropy(logits_labeled, y_labeled) # 无标签部分 logits_clean = model(x_unlabeled) p_clean = softmax(logits_clean) # 幂迭代求虚拟对抗扰动 d = random_normal(shape=x_unlabeled.shape) d = normalize(d) for k in range(K): logits_perturbed = model(x_unlabeled + xi * d) p_perturbed = softmax(logits_perturbed) kl = kl_divergence(p_clean, p_perturbed) g = gradient(kl, d) d = normalize(g) # 用最终扰动计算 VAT 损失 logits_adv = model(x_unlabeled + epsilon * d) p_adv = softmax(logits_adv) loss_vat = kl_divergence(p_clean, p_adv) # 总损失 loss = loss_sup + alpha * loss_vat update_model(loss)注意这里有两个扰动幅度参数:xi 是幂迭代过程中用的步长,epsilon 是最终扰动幅度。论文里通常设 xi = epsilon,但也可以分开调。我一般让 xi 略小于 epsilon,这样迭代过程更稳定。
4. 核心代码实现与关键细节注释
4.1 环境准备与依赖说明
代码用 PyTorch 实现,版本建议 1.10 以上,因为要用到一些自动求导的高级特性。依赖很简单:
pip install torch torchvision numpy不需要额外的第三方库,VAT 的实现完全基于 PyTorch 原生算子。这一点我很喜欢,意味着你可以把它轻松嵌入任何现有训练框架,不用引入新的依赖负担。
4.2 虚拟对抗扰动的核心实现
这是整个 VAT 最关键的一段代码,我把它单独封装成一个函数,方便复用:
import torch import torch.nn.functional as F def virtual_adversarial_perturbation(model, x, xi=1e-6, epsilon=1.0, num_iters=1): """ 计算虚拟对抗扰动 model: 模型,输出 logits x: 输入,shape [batch, ...] xi: 幂迭代步长 epsilon: 最终扰动幅度 num_iters: 幂迭代次数 """ # 干净样本的输出分布,detach 避免梯度回传到模型 with torch.no_grad(): logits_clean = model(x) p_clean = F.softmax(logits_clean, dim=-1) # 初始化扰动向量 d = torch.randn_like(x) d = d / (d.norm(p=2, dim=list(range(1, x.dim())), keepdim=True) + 1e-8) # 幂迭代 for _ in range(num_iters): d.requires_grad_(True) logits_perturbed = model(x + xi * d) p_perturbed = F.softmax(logits_perturbed, dim=-1) # KL 散度,注意 p_clean 是常量 kl = F.kl_div(p_perturbed.log(), p_clean, reduction='batchmean') # 对 d 求梯度 grad = torch.autograd.grad(kl, d, retain_graph=False)[0] # 归一化 d = grad / (grad.norm(p=2, dim=list(range(1, x.dim())), keepdim=True) + 1e-8) d = d.detach() # 最终扰动 r_vadv = epsilon * d return r_vadv这段代码有几个地方容易写错,我逐个说明。
第一,p_clean必须用torch.no_grad()包起来,因为它是常量,不需要梯度。如果忘了这一步,计算图会变得很大,显存直接爆掉。
第二,KL 散度的方向不能反。F.kl_div的第一个参数是预测分布的对数,第二个参数是目标分布。这里我们要的是 KL(p_clean || p_perturbed),所以写成F.kl_div(p_perturbed.log(), p_clean)。写反了虽然也能跑,但物理意义就变了。
第三,归一化时的维度处理。对于图像输入,shape 是 [batch, channel, height, width],归一化要在除 batch 外的所有维度上做,所以dim=list(range(1, x.dim()))。如果只对最后一维归一化,扰动会集中在某些通道上,效果大打折扣。
第四,d.detach()不能省。每次迭代结束后要把 d 从计算图里摘出来,否则下一轮迭代会累积计算图。
4.3 训练循环的完整搭建
有了扰动函数,训练循环就好写了。下面是一个完整的训练步骤示例:
def train_step(model, optimizer, x_labeled, y_labeled, x_unlabeled, alpha=1.0, epsilon=1.0): model.train() optimizer.zero_grad() # 监督损失 logits_labeled = model(x_labeled) loss_sup = F.cross_entropy(logits_labeled, y_labeled) # VAT 损失 if x_unlabeled is not None and x_unlabeled.size(0) > 0: with torch.no_grad(): logits_clean = model(x_unlabeled) p_clean = F.softmax(logits_clean, dim=-1) r_vadv = virtual_adversarial_perturbation(model, x_unlabeled, epsilon=epsilon) logits_adv = model(x_unlabeled + r_vadv) p_adv = F.softmax(logits_adv, dim=-1) loss_vat = F.kl_div(p_adv.log(), p_clean, reduction='batchmean') else: loss_vat = torch.tensor(0.0, device=x_labeled.device) # 总损失 loss = loss_sup + alpha * loss_vat loss.backward() optimizer.step() return loss_sup.item(), loss_vat.item()这里有个工程上的小技巧:如果某个 batch 没有无标签数据,直接跳过 VAT 部分,不要强行构造空张量去算,容易出 NaN。
4.4 参数选择与调优经验
VAT 的超参数不多,但每个都影响很大。我把常用的取值范围和调优经验整理成表:
| 参数 | 含义 | 常用范围 | 调优建议 |
|---|---|---|---|
| epsilon | 扰动幅度 | 0.1 ~ 10.0 | 输入归一化后取 1.0 起步 |
| xi | 幂迭代步长 | 1e-6 ~ epsilon | 一般取 epsilon 的 1e-3 到 1e-1 |
| num_iters | 幂迭代次数 | 1 ~ 10 | 从 1 开始,不够再加 |
| alpha | VAT 损失权重 | 0.1 ~ 10.0 | 从 1.0 起步,观察验证集 |
epsilon 是最敏感的参数。取值太小,扰动没效果,VAT 退化成普通训练;取值太大,扰动超出数据流形,模型学到的平滑约束是错的,反而伤害性能。我的经验是:如果输入做了标准化(均值 0 方差 1),epsilon 从 1.0 开始试;如果输入是原始像素值(0 到 255),epsilon 要相应放大到几十甚至上百。
alpha 的调整要看任务。如果无标签数据量远大于有标签数据,alpha 可以适当调大,让 VAT 损失占主导;反之则调小。我一般会监控两个损失的比值,让 VAT 损失和有监督损失在同一量级,这样梯度不会一边倒。
注意:epsilon 和 alpha 不要同时调,否则你分不清是哪个参数起的作用。固定一个调另一个,这是调参的基本纪律。
5. 常见问题与排查技巧实录
5.1 训练不收敛或 loss 变 NaN 的排查
VAT 训练比普通训练更容易出 NaN,这是我在复现时遇到的第一个大坑。原因通常有几个:
一是扰动幅度过大。epsilon 设得太大,x + r_vadv 跑到了模型完全没见过的区域,输出分布极端,KL 散度爆炸。解决办法是把 epsilon 调小,或者对输入做更严格的归一化。
二是幂迭代里的归一化除零。如果梯度 norm 恰好为 0,除以 0 就出 NaN。代码里加个 1e-8 的平滑项能解决大部分情况。
三是 KL 散度计算时 p_clean 里有 0。softmax 输出理论上不会精确为 0,但数值上可能下溢。用 log_softmax 代替 softmax 再取 log 更稳。
排查顺序我一般是这样:先看 NaN 出现在第几个 batch,如果是第一个 batch 就出,多半是参数问题;如果是训练一段时间后出,多半是梯度累积或学习率问题。然后打印每步的 loss 和梯度 norm,定位到具体哪一步开始异常。
5.2 效果不明显甚至变差的可能原因
有时候 VAT 加进去,验证集准确率不升反降,这让人很沮丧。我总结了几种常见原因:
第一种是 epsilon 和 alpha 没调好。这两个参数对结果影响极大,默认值往往不适合你的任务。建议先做个小规模网格搜索,epsilon 取 [0.1, 1.0, 10.0],alpha 取 [0.1, 1.0, 10.0],9 组实验跑一遍,基本能定位到合适区间。
第二种是无标签数据和有标签数据分布不一致。VAT 假设无标签数据来自同一分布,如果无标签数据里混入了大量噪声或异类样本,VAT 会把模型往错误方向拉。这时候要么清洗无标签数据,要么降低 alpha。
第三种是模型容量不够。VAT 相当于增加了任务难度,如果模型本身容量就吃紧,加上 VAT 约束后可能学不动。这时候要么换更大的模型,要么先在有标签数据上充分预训练,再加 VAT 微调。
5.3 计算开销优化技巧
VAT 的额外开销主要来自幂迭代,每次迭代都要一次前向和一次反向。K=1 时开销大约是普通训练的 2 到 3 倍,K=10 时能到 10 倍以上。如果算力紧张,有几个优化方向:
一是减少 num_iters。前面说过 K=1 通常就够用,没必要追求精确近似。
二是只在部分 batch 上算 VAT。比如每隔一个 batch 算一次,或者只对无标签数据的一个子集算。这样能显著降低平均开销。
三是用混合精度训练。VAT 的梯度计算对精度不敏感,用 fp16 能提速 30% 到 50%,显存占用也降一半。注意 KL 散度计算部分要保持在 fp32,避免数值问题。
四是把幂迭代的中间结果缓存起来。如果同一个 batch 要多次用到扰动,可以算一次存下来复用。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| loss 变 NaN | epsilon 过大 / 除零 | 打印每步 loss | 调小 epsilon,加平滑项 |
| 验证集不升 | 参数没调好 | 网格搜索 | 调 epsilon 和 alpha |
| 训练变慢 | 幂迭代开销 | 计时各阶段 | 减 num_iters,用 fp16 |
| 梯度爆炸 | 扰动超出流形 | 监控梯度 norm | 梯度裁剪,调小 epsilon |
| 无标签数据无效 | 分布不一致 | 检查数据来源 | 清洗数据,降 alpha |
6. 从复现到落地的几点个人体会
VAT 这个工作我前后复现过三次,第一次是纯为了理解论文,第二次是把它用到一个小规模半监督分类任务上,第三次是把它和 Mean Teacher 结合做鲁棒性增强。三次下来,最大的体会是:VAT 的代码实现不难,难的是参数调优和数据配比。
论文里给的超参数是在特定数据集上搜出来的,直接搬到你的任务上大概率不合适。我建议的做法是:先用小规模数据快速跑通流程,确认代码没 bug,然后固定模型结构,只调 epsilon 和 alpha 这两个参数,用验证集做早停。等这两个参数稳定了,再考虑调 num_iters 和 xi。
另一个体会是,VAT 和别的正则化手段叠加使用时,要注意权重平衡。我曾经同时开了 dropout、权重衰减和 VAT,结果模型欠拟合,训练集都学不好。后来把 dropout 关掉,只留 VAT 和轻量权重衰减,效果反而更好。这说明正则化不是越多越好,VAT 本身已经提供了很强的平滑约束,其它手段要相应减弱。
最后分享一个实用的小技巧:如果你不确定 epsilon 该取多少,可以先跑一个"扰动敏感性测试"。取一批无标签数据,对每个样本施加不同幅度的随机扰动,观察模型输出分布的变化。如果 epsilon=1.0 时输出分布已经面目全非,说明这个幅度太大了;如果 epsilon=10.0 时输出几乎不变,说明太小。找到输出开始明显变化的临界点,那个幅度就是合适的 epsilon 量级。这个方法比盲目网格搜索快得多,我每次换新任务都会先做一遍。
这个内容后续还可以往两个方向扩展:一是把 VAT 扩展到目标检测、分割这类结构化输出任务,这时候 KL 散度要换成更适合的散度度量;二是研究 VAT 和对比学习的结合,用虚拟对抗扰动来增强对比学习的正样本对,这个方向目前还有一些开放问题值得探索。