1. 对抗训练在NLP里到底在对抗什么
很多人第一次听到“对抗训练”这个词,脑子里浮现的是GAN那种生成器与判别器互相博弈的画面。但在NLP的文本任务里,对抗训练的逻辑其实更朴素——它不是在造一个以假乱真的生成器,而是在给模型“找茬”。你训练一个情感分类模型,它在训练集上准确率冲到98%,你满心欢喜上线,结果遇到一条带错别字的评论“这个产品真辣鸡”,模型直接判成正面。问题出在哪?模型学到的是“辣鸡”这个词没见过的字符组合,而不是“垃圾”这个语义。对抗训练要解决的,就是这种“表面特征依赖”问题。
具体来说,NLP中的对抗训练核心思路是:在训练过程中,对输入的词嵌入(word embedding)施加微小的扰动,构造出“对抗样本”,然后让模型在这些对抗样本上也保持正确的预测。这个扰动不是随便加的,它沿着能让损失函数上升最快的方向走——也就是梯度方向。你可以把它理解成:模型本来在一条平坦的路上走得好好的,对抗训练故意在它脚边挖几个小坑,逼着它学会跨过去,而不是绕开。跨过去的能力,就是泛化能力。
这个思路最早在图像领域被验证有效,后来被迁移到文本。但文本和图像有一个本质区别:图像是连续像素值,加扰动很自然;文本是离散的token序列,你不能把“猫”这个词改成“猫+0.01”。所以NLP里的对抗训练几乎都绕不开一个操作——在嵌入层做文章。词嵌入是连续向量,扰动加在这里既保留了语义空间的可微性,又不会破坏词表结构。这也是为什么你看到的NLP对抗训练代码,几乎清一色是在Embedding层挂钩子。
那它到底适合谁用?如果你正在做文本分类、情感分析、命名实体识别、句子相似度匹配这类任务,并且发现模型在测试集上表现不错但一换数据分布就崩,那对抗训练值得一试。它不需要你改模型结构,也不需要额外标注数据,本质上是一种正则化手段,成本低、收益稳。但如果你做的是纯生成任务比如机器翻译,对抗训练的效果就没那么立竿见影,因为生成任务的输出空间太大,扰动嵌入层带来的收益会被解码过程稀释。
还有一个常见的误解需要澄清:对抗训练不等于数据增强。数据增强是你在输入空间做同义词替换、回译、随机删除,造出新的合法样本;对抗训练是在嵌入空间做梯度扰动,造出的是“模型眼中的难样本”。两者可以叠加使用,但机制完全不同。我见过有人把同义词替换也叫对抗训练,这在概念上是混淆的。真正的对抗训练,扰动方向必须依赖当前模型的梯度,是动态的、样本相关的,而不是预先定义好的替换规则。
2. 从FGM到PGD:三种主流扰动策略的取舍逻辑
2.1 FGM:最快但最“短视”的对抗
FGM全称Fast Gradient Method,是NLP对抗训练里最常被拿来当baseline的方法。它的操作极其简单:前向传播算出loss,反向传播算出嵌入层的梯度,然后把这个梯度归一化后乘上一个扰动系数epsilon,直接加到原始嵌入上。用公式说就是:扰动 = epsilon * g / ||g||,其中g是嵌入层的梯度。
为什么说它“短视”?因为它只走一步。就像你闭着眼睛往山坡上迈一步,这一步确实是当前最陡的方向,但迈完之后你可能发现前面是个悬崖。FGM只保证单步扰动能让loss上升,但不保证这个扰动是“最优”的。不过在实际NLP任务里,这一步往往就够了。原因在于文本的嵌入空间维度通常只有几百维,而且词与词之间的语义关系相对平滑,单步扰动已经能制造出足够让模型“难受”的样本。
FGM的代码实现有一个关键细节:嵌入层的梯度需要手动清零。因为PyTorch默认的梯度累积机制,你在算完对抗扰动后,如果不把嵌入层的梯度恢复,后续的参数更新会被污染。标准做法是先用emb_backup保存原始嵌入,算完扰动后恢复,再清空嵌入层梯度。这个坑我踩过不止一次,症状是模型训练几个epoch后loss突然爆炸,排查半天才发现是梯度没清干净。
另一个细节是epsilon的取值。在文本分类任务里,epsilon通常在0.5到1.0之间。太小了扰动没效果,太大了模型直接学偏。我的经验是:先用0.5跑一轮,看验证集loss曲线,如果对抗训练后的loss比正常训练高出一大截且不收敛,说明epsilon太大;如果两者几乎重合,说明epsilon太小。这个调参过程没有理论公式,只能靠实验。
2.2 PGD:多步迭代的“谨慎版”对抗
PGD全称Projected Gradient Descent,本质上是FGM的迭代版本。它不满足于走一步,而是走多步,每步都重新计算梯度,然后把扰动投影回epsilon球内。你可以把它理解成:FGM是扔一块石头看水花,PGD是反复调整角度直到水花最大。
PGD在图像领域是公认的强对抗方法,但在NLP里它的性价比需要权衡。多步迭代意味着计算量成倍增加,训练时间可能翻三到五倍。而且文本嵌入空间的扰动半径本来就小,多步之后容易陷入局部最优,反而制造出一些“不自然”的对抗样本——比如把“苹果”的嵌入推到“香蕉”附近,但语义上这两个词在某个上下文里可能确实相近,模型学到的可能是噪声而非鲁棒特征。
我的建议是:如果你的任务对鲁棒性要求极高,比如金融风控文本分类,错判代价很大,那PGD值得投入。如果只是常规的电商评论情感分析,FGM的性价比更高。PGD的步数一般设2到3步,步长设为epsilon除以步数,这样每步的扰动幅度可控。
2.3 FreeLB:把对抗训练和正常训练揉在一起
FreeLB的思路和FGM、PGD都不一样。它不是在正常训练之外额外做对抗,而是把对抗扰动嵌入到正常的前向传播里。具体做法是:对同一个batch做多次前向传播,每次都在嵌入上加不同的扰动,然后把这些loss平均起来一起反向传播。这样做的结果是,模型在更新参数时,同时考虑了正常样本和多个对抗样本的梯度。
FreeLB的好处是训练效率高,因为它复用了前向传播的计算图。而且它天然地让模型在“正常”和“对抗”之间找平衡,不会因为对抗训练过猛导致正常样本的准确率下降。但它的实现复杂度比FGM高不少,需要处理多次前向传播的梯度累积和嵌入恢复。如果你用的是HuggingFace的Trainer框架,FreeLB需要自定义训练循环,不能直接套用。
三种方法的对比如下:
| 方法 | 计算开销 | 实现难度 | 适用场景 | epsilon建议 |
|---|---|---|---|---|
| FGM | 低 | 低 | 常规文本分类、NER | 0.5-1.0 |
| PGD | 高 | 中 | 高鲁棒性要求任务 | 0.3-0.8 |
| FreeLB | 中 | 高 | 需要兼顾正常精度的任务 | 0.5-1.0 |
选哪个没有绝对答案。我通常的做法是:先上FGM,如果验证集鲁棒性提升不明显,再换PGD;如果训练时间不允许,就用FreeLB。但不管选哪个,都要记住一点——对抗训练是正则化,不是万能药。如果模型本身容量不够,或者训练数据噪声太大,对抗训练反而会放大问题。
3. 嵌入层扰动的工程实现:从理论到可跑代码
3.1 嵌入层挂钩子的正确姿势
在PyTorch里实现FGM,核心是拿到嵌入层的梯度。但这里有一个容易忽略的点:嵌入层的梯度默认是稀疏的。因为每个token只对应词表中的一个索引,反向传播时只有被激活的那些行有梯度。如果你直接用emb.weight.grad,拿到的是一个稀疏张量,归一化时会出问题。
正确的做法是在模型初始化时,把嵌入层的sparse属性设为False,或者手动把稀疏梯度转成稠密梯度。我通常会在模型定义里加一行:
self.embedding = nn.Embedding(vocab_size, emb_dim, sparse=False)这样梯度就是稠密的,后续的归一化和扰动计算都方便。代价是显存占用会稍微增加,因为所有词表的梯度都要存下来。对于词表几万、嵌入维度几百的模型,这个开销可以接受。
另一个细节是:扰动应该加在嵌入层的输出上,而不是嵌入层的权重上。因为权重是共享的,你扰动权重会影响所有样本;扰动输出则只影响当前batch。FGM的标准实现是保存原始嵌入输出,加上扰动后重新赋值给嵌入层输出,前向传播走一遍,算完loss后再恢复原始嵌入。
3.2 一个可复现的FGM训练循环
下面是我在实际项目中反复使用的一个FGM训练循环模板,基于PyTorch和HuggingFace的模型接口:
class FGM: def __init__(self, model, epsilon=0.5): self.model = model self.epsilon = epsilon self.backup = {} def attack(self): for name, param in self.model.named_parameters(): if param.requires_grad and 'embedding' in name: self.backup[name] = param.data.clone() norm = torch.norm(param.grad) if norm != 0: r_at = self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data = self.backup[name] self.backup = {}训练循环里这样用:
fgm = FGM(model, epsilon=0.5) for batch in dataloader: # 正常前向 outputs = model(**batch) loss = criterion(outputs, batch['labels']) loss.backward() # 对抗训练 fgm.attack() outputs_adv = model(**batch) loss_adv = criterion(outputs_adv, batch['labels']) loss_adv.backward() fgm.restore() # 更新参数 optimizer.step() optimizer.zero_grad()这里有一个关键点:loss.backward()之后不能直接optimizer.step(),因为对抗训练的梯度还没算。正确的顺序是正常loss反向传播、对抗loss反向传播、恢复嵌入、更新参数、清空梯度。这个顺序错了,对抗训练就白做了。
3.3 梯度累积与混合精度的兼容问题
如果你用了梯度累积(gradient accumulation)或者混合精度训练(AMP),FGM的实现需要额外注意。梯度累积时,嵌入层的梯度会在多个batch之间累加,如果你在每个batch都做对抗攻击,扰动会基于累积梯度计算,方向可能偏离当前batch的最优方向。解决办法是在对抗攻击前先做一次梯度缩放,或者干脆在梯度累积的最后一个batch才做对抗训练。
混合精度训练下,嵌入层的梯度可能是半精度浮点数,归一化时容易溢出。我通常会把嵌入层的梯度强制转成float32再计算扰动,算完后再转回去。这个操作会增加一点计算开销,但能避免NaN问题。
还有一个坑是:如果你用的是HuggingFace的Trainer,它内部已经封装了训练循环,你没法直接插入FGM的attack和restore。这时候要么自定义Trainer,要么用callback机制。我倾向于自定义Trainer,因为callback的时机不好控制,容易在错误的阶段插入对抗攻击。
4. 对抗训练在真实NLP任务中的效果边界
4.1 文本分类:提升明显但有天花板
在我做过的一个电商评论情感分析项目里,基线模型用BERT-base,在测试集上F1是0.92。加上FGM对抗训练后,F1提升到0.94,看起来不多,但关键是在对抗样本上的表现:我构造了一批带错别字、带干扰符号的评论,基线模型F1掉到0.78,对抗训练后的模型F1保持在0.89。这个差距在业务上意味着什么?意味着每天少误判几百条评论,客服工单量直接下降。
但对抗训练不是无限提升。当epsilon超过1.5之后,验证集F1开始下降,因为扰动太大,模型学到的不是鲁棒特征而是噪声。而且对抗训练对长文本的效果比短文本好,因为长文本的嵌入空间更大,扰动有更多“回旋余地”。短文本比如只有五六个词的评论,扰动很容易把整个语义推偏。
4.2 命名实体识别:边界样本的救星
NER任务里最头疼的是边界模糊的实体。比如“北京大学第三医院”,模型可能把“北京大学”识别成组织机构,“第三医院”识别成医疗机构,但整体应该是一个机构名。对抗训练在这种场景下特别有用,因为扰动嵌入会让模型更关注上下文语义而不是局部字符模式。
我做过一个对比实验:在同样的BERT+CRF架构下,不加对抗训练的模型在边界实体上的F1是0.85,加FGM后提升到0.91。但代价是训练时间增加了约40%,因为每个batch要多做一次前向和反向。如果你的GPU资源有限,这个开销需要提前评估。
4.3 句子相似度:对抗训练的双刃剑
句子相似度任务比如语义匹配,对抗训练的效果比较微妙。一方面,扰动嵌入能让模型对同义改写更鲁棒;另一方面,如果扰动过大,模型可能把原本不相似的句子判成相似,因为嵌入被推到了同一个区域。我试过在STS-B数据集上做实验,epsilon=0.3时效果最好,epsilon=0.8时反而比基线差。
这说明对抗训练的epsilon需要按任务调,不能一套参数走天下。分类任务可以激进一点,相似度任务要保守。我的经验是:先用小epsilon跑一轮,看验证集上的皮尔逊相关系数,如果下降就继续减小epsilon,直到找到拐点。
4.4 什么情况下对抗训练会失效
有几种情况我实测下来对抗训练基本没用,甚至有害:
- 训练数据本身噪声极大:如果标注数据里有30%以上的错误标签,对抗训练会让模型去拟合这些错误,扰动反而放大了噪声。
- 模型容量严重不足:比如用一个小型LSTM做复杂情感分析,模型本身就没学好,对抗训练只会让它更迷茫。
- 任务本身对词序不敏感:比如关键词提取,扰动嵌入对词序的影响很小,对抗训练带来的收益微乎其微。
- 推理阶段无法复现扰动:对抗训练只在训练时加扰动,推理时不加。如果训练和推理的嵌入分布差异太大,模型可能过拟合到“带扰动的嵌入空间”,推理时反而变差。这个问题在FGM上不明显,但在PGD多步扰动后容易出现。
5. 调参实战:epsilon、步数与训练轮次的联动
5.1 epsilon不是越大越好
epsilon控制扰动幅度,是FGM和PGD最核心的超参数。我见过有人直接设epsilon=1.0然后抱怨模型不收敛,一问才知道他的嵌入维度只有128,归一化后的扰动向量每个维度都在0.1左右,累积起来把整个嵌入推偏了。
epsilon的合理范围跟嵌入维度有关。经验公式是:epsilon的初始值设为嵌入维度的平方根分之一。比如嵌入维度是768,epsilon初始值约0.036;嵌入维度是128,epsilon初始值约0.088。但这个公式只是起点,实际还要根据任务调整。文本分类可以在这个基础上乘2到3倍,相似度任务乘0.5到1倍。
另一个判断标准是看对抗loss和正常loss的比值。如果对抗loss是正常loss的3倍以上,说明epsilon太大;如果两者差距在20%以内,说明epsilon太小。理想状态是1.5到2倍之间。
5.2 训练轮次需要重新规划
加对抗训练后,模型收敛需要的epoch数通常会增加。因为每个batch的梯度里混入了对抗梯度,参数更新的方向更“曲折”。我通常会把总epoch数增加20%到30%,同时把学习率降低10%到20%。比如原来跑10个epoch、学习率2e-5,加对抗训练后跑12到13个epoch、学习率1.5e-5。
还有一个技巧是预热对抗训练:前两个epoch不加对抗,让模型先学到基本的语义表示,从第三个epoch开始加对抗。这样能避免模型一开始就被扰动带偏。这个策略在低资源场景下特别有效,因为训练数据少的时候,模型本身就不稳定,一上来就对抗容易崩。
5.3 验证集的选择比训练集更重要
对抗训练的效果不能只看训练集loss。训练集loss在加对抗后通常会升高,这是正常的,因为模型在拟合更难样本。关键看验证集上的鲁棒性指标。我通常会构造一个“对抗验证集”:用同义词替换、随机插入删除、字符扰动等方式生成一批对抗样本,然后看模型在这批样本上的表现。如果对抗验证集上的F1比正常验证集低超过15%,说明模型的鲁棒性还不够,需要继续调epsilon或增加对抗轮次。
但要注意,对抗验证集不能和训练时的扰动方式完全一样,否则就是“用考试题训练”。我一般会用两种不同的扰动策略:训练时用FGM,验证时用同义词替换,这样能更真实地评估泛化能力。
6. 对抗训练之外:那些容易被忽略的鲁棒性细节
6.1 嵌入层归一化与对抗扰动的相互作用
很多模型在嵌入层之后会接一个LayerNorm或BatchNorm。如果你在这些归一化层之后加扰动,效果会打折扣,因为归一化会把扰动“抹平”。正确的做法是在归一化之前加扰动,让扰动经过归一化后仍然保留一定的方向性。这个细节在HuggingFace的BERT实现里需要手动调整,因为BERT的嵌入层后面直接接了LayerNorm。
我试过在LayerNorm之后加FGM,验证集鲁棒性提升只有0.5个点;改到LayerNorm之前,提升到2.3个点。差距很明显。所以如果你用的是现成的预训练模型,一定要确认扰动加在了正确的位置。
6.2 对抗训练与Dropout的叠加效应
Dropout本身也是一种正则化,它随机丢弃神经元,让模型不依赖特定路径。对抗训练和Dropout叠加时,效果不是简单相加。我实测下来,如果Dropout率已经设到0.3以上,再加对抗训练,验证集提升会变得很小,因为两者都在做“让模型别太自信”这件事,边际收益递减。
我的建议是:如果加了对抗训练,Dropout率可以适当降低,比如从0.3降到0.2。这样既保留了两种正则化的优势,又避免了过度正则化导致的欠拟合。但具体降多少要看任务,分类任务可以降,生成任务要谨慎。
6.3 对抗训练在小样本场景下的特殊处理
小样本场景(比如每类只有几十条标注数据)下,对抗训练的效果波动很大。因为模型本身就没见过多少样本,扰动可能把它推到完全错误的区域。这时候需要降低epsilon,同时增加对抗训练的轮次,让模型慢慢适应。
我做过一个实验:在每类50条样本的情感分类任务上,epsilon=0.5时对抗训练比基线差,epsilon=0.2时比基线好3个点。所以小样本场景下,epsilon要往小了调,宁可扰动弱一点,也不要让模型学偏。另外可以结合数据增强,先用同义词替换扩充训练集,再加对抗训练,效果比单独用对抗训练好。
6.4 推理阶段的“无扰动”一致性
对抗训练只在训练时加扰动,推理时不加。这会导致训练和推理的嵌入分布存在微小差异。对于大多数任务,这个差异可以忽略,但如果你的模型对嵌入分布极其敏感(比如用了对比学习损失),就需要在推理时也做某种“平滑”。一个简单的做法是在推理时对嵌入做随机丢弃(类似Dropout但只在推理时用),让模型见到更多样的嵌入分布。这个技巧在句子相似度任务上我试过,能提升1到2个点的鲁棒性。
但要注意,推理时的随机性会带来结果不稳定,同一个输入两次推理可能得到不同输出。如果你的业务要求确定性输出,这个技巧就不适用。
7. 一个完整的对抗训练实验记录
7.1 实验设置与基线对比
我用一个中文情感分类数据集做了完整对比实验。数据集包含8万条训练样本、1万条验证样本、1万条测试样本,标签为正面/负面。基线模型是BERT-base-chinese,最大序列长度128,batch size 32,学习率2e-5,训练5个epoch。
基线模型在测试集上的准确率是93.2%,F1是0.931。然后我分别加了FGM、PGD、FreeLB,参数如下:
| 方法 | epsilon | 步数 | 训练epoch | 测试准确率 | 对抗测试准确率 |
|---|---|---|---|---|---|
| 基线 | - | - | 5 | 93.2% | 78.5% |
| FGM | 0.5 | 1 | 6 | 94.1% | 86.3% |
| PGD | 0.5 | 3 | 7 | 94.3% | 88.1% |
| FreeLB | 0.5 | 3 | 6 | 94.0% | 87.2% |
对抗测试集是我用同义词替换和字符扰动生成的,包含1万条样本。可以看到,PGD在对抗测试集上表现最好,但训练时间也最长,比基线多了约2.5倍。FGM的性价比最高,训练时间只多40%,对抗准确率提升了近8个点。
7.2 训练过程中的loss曲线分析
加FGM后,训练loss在前两个epoch比基线高约15%,从第三个epoch开始逐渐收敛到和基线相近的水平。验证loss在前三个epoch波动较大,之后趋于稳定。这说明模型需要一定时间适应对抗扰动,如果只跑3个epoch就停,可能看不到对抗训练的全部收益。
PGD的loss曲线更“抖”,因为多步扰动让每个batch的梯度方向变化更剧烈。我不得不把学习率从2e-5降到1.5e-5,否则loss会震荡不收敛。FreeLB的loss曲线最平滑,因为它把对抗loss和正常loss平均了,梯度方差更小。
7.3 不同epsilon下的鲁棒性曲线
我固定FGM方法,测试了epsilon从0.1到1.5的对抗测试准确率:
| epsilon | 正常测试准确率 | 对抗测试准确率 |
|---|---|---|
| 0.1 | 93.5% | 80.2% |
| 0.3 | 93.8% | 84.1% |
| 0.5 | 94.1% | 86.3% |
| 0.8 | 93.9% | 85.7% |
| 1.0 | 93.2% | 83.4% |
| 1.5 | 91.8% | 79.6% |
可以看到,epsilon=0.5时对抗测试准确率最高,超过0.8后开始下降。正常测试准确率在epsilon=0.5时也达到峰值,说明适度的对抗扰动确实能提升泛化能力。epsilon=1.5时正常准确率掉到91.8%,说明扰动过大已经损害了模型的基本能力。
这个曲线验证了一个观点:对抗训练存在一个“甜点区”,不是越强越好。找到这个甜点区的方法就是网格搜索,虽然费时,但比拍脑袋设参数靠谱得多。
7.4 对抗训练后的错误分析
我对比了基线和FGM模型在测试集上的错误样本。基线模型的错误主要集中在三类:带错别字的评论、带反讽的评论、以及包含否定词的复杂句式。FGM模型在带错别字和否定词句式的错误率下降了约40%,但在反讽评论上的错误率只下降了10%。
这说明对抗训练对表面扰动的鲁棒性提升明显,但对语义层面的复杂推理帮助有限。反讽需要模型理解上下文和常识,这不是嵌入层扰动能解决的。所以如果你的任务里反讽、隐喻占比很高,对抗训练只是第一步,还需要结合其他方法比如提示学习或外部知识注入。
8. 落地建议与常见问题速查
8.1 什么时候该上对抗训练
如果你的模型满足以下条件,对抗训练值得投入:训练数据标注质量较高、模型在测试集上表现尚可但鲁棒性差、任务对错判代价敏感、GPU资源允许增加30%到50%的训练时间。如果训练数据本身噪声大、模型容量小、或者任务对实时性要求极高(推理时不能有任何额外开销),对抗训练的优先级可以往后放。
8.2 常见报错与解决方案
| 报错现象 | 可能原因 | 解决方案 |
|---|---|---|
| loss变成NaN | epsilon太大或梯度溢出 | 减小epsilon,嵌入梯度转float32 |
| 验证集准确率下降 | 对抗过强或学习率太高 | 降低epsilon,降低学习率 |
| 训练时间翻倍 | PGD步数太多 | 改用FGM或减少PGD步数 |
| 嵌入层梯度为None | 嵌入层被冻结 | 解冻嵌入层或改用输出扰动 |
| 恢复嵌入后参数未更新 | 梯度清零顺序错误 | 先restore再zero_grad |
8.3 一个容易被忽略的细节:对抗训练与早停
加了对抗训练后,验证集loss的波动会变大,早停策略需要调整。如果还用原来的patience(比如3个epoch不下降就停),可能会在loss震荡时过早停止。我通常会把patience增加到5到7个epoch,同时监控对抗验证集的指标而不是正常验证集。这样能确保模型真正学到了鲁棒特征,而不是在正常验证集上过拟合。
另外,对抗训练的最佳checkpoint不一定在最后一个epoch。我遇到过验证集loss在epoch 4最低,但对抗验证集准确率在epoch 6最高的情况。所以保存模型时最好同时记录两个指标,选综合表现最好的那个。
8.4 对抗训练的未来扩展方向
从我的实践来看,对抗训练在NLP里还有几个值得探索的方向。一是分层对抗:不只扰动嵌入层,还对中间层的表示做扰动,这样能提升模型对深层语义扰动的鲁棒性。二是自适应epsilon:根据每个样本的难度动态调整扰动幅度,简单样本扰动小一点,难样本扰动大一点。三是对抗训练与提示学习的结合:在prompt tuning的场景下,扰动可以加在连续提示向量上,这比扰动词嵌入更灵活。
但这些方向目前还处于实验阶段,工程落地的案例不多。如果你只是想提升现有模型的鲁棒性,FGM加合适的epsilon已经能解决大部分问题。先把基础方法跑通,再考虑进阶方案,这是我踩过多次坑之后最深的体会。