NLP对抗训练实战:FGM、PGD、FreeLB原理与调参指南
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低低常规文本分类、NER0.5-1.0PGD高中高鲁棒性要求任务0.3-0.8FreeLB中高需要兼顾正常精度的任务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, sparseFalse)这样梯度就是稠密的后续的归一化和扰动计算都方便。代价是显存占用会稍微增加因为所有词表的梯度都要存下来。对于词表几万、嵌入维度几百的模型这个开销可以接受。另一个细节是扰动应该加在嵌入层的输出上而不是嵌入层的权重上。因为权重是共享的你扰动权重会影响所有样本扰动输出则只影响当前batch。FGM的标准实现是保存原始嵌入输出加上扰动后重新赋值给嵌入层输出前向传播走一遍算完loss后再恢复原始嵌入。3.2 一个可复现的FGM训练循环下面是我在实际项目中反复使用的一个FGM训练循环模板基于PyTorch和HuggingFace的模型接口class FGM: def __init__(self, model, epsilon0.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, epsilon0.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或者混合精度训练AMPFGM的实现需要额外注意。梯度累积时嵌入层的梯度会在多个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任务里最头疼的是边界模糊的实体。比如“北京大学第三医院”模型可能把“北京大学”识别成组织机构“第三医院”识别成医疗机构但整体应该是一个机构名。对抗训练在这种场景下特别有用因为扰动嵌入会让模型更关注上下文语义而不是局部字符模式。我做过一个对比实验在同样的BERTCRF架构下不加对抗训练的模型在边界实体上的F1是0.85加FGM后提升到0.91。但代价是训练时间增加了约40%因为每个batch要多做一次前向和反向。如果你的GPU资源有限这个开销需要提前评估。4.3 句子相似度对抗训练的双刃剑句子相似度任务比如语义匹配对抗训练的效果比较微妙。一方面扰动嵌入能让模型对同义改写更鲁棒另一方面如果扰动过大模型可能把原本不相似的句子判成相似因为嵌入被推到了同一个区域。我试过在STS-B数据集上做实验epsilon0.3时效果最好epsilon0.8时反而比基线差。这说明对抗训练的epsilon需要按任务调不能一套参数走天下。分类任务可以激进一点相似度任务要保守。我的经验是先用小epsilon跑一轮看验证集上的皮尔逊相关系数如果下降就继续减小epsilon直到找到拐点。4.4 什么情况下对抗训练会失效有几种情况我实测下来对抗训练基本没用甚至有害训练数据本身噪声极大如果标注数据里有30%以上的错误标签对抗训练会让模型去拟合这些错误扰动反而放大了噪声。模型容量严重不足比如用一个小型LSTM做复杂情感分析模型本身就没学好对抗训练只会让它更迷茫。任务本身对词序不敏感比如关键词提取扰动嵌入对词序的影响很小对抗训练带来的收益微乎其微。推理阶段无法复现扰动对抗训练只在训练时加扰动推理时不加。如果训练和推理的嵌入分布差异太大模型可能过拟合到“带扰动的嵌入空间”推理时反而变差。这个问题在FGM上不明显但在PGD多步扰动后容易出现。5. 调参实战epsilon、步数与训练轮次的联动5.1 epsilon不是越大越好epsilon控制扰动幅度是FGM和PGD最核心的超参数。我见过有人直接设epsilon1.0然后抱怨模型不收敛一问才知道他的嵌入维度只有128归一化后的扰动向量每个维度都在0.1左右累积起来把整个嵌入推偏了。epsilon的合理范围跟嵌入维度有关。经验公式是epsilon的初始值设为嵌入维度的平方根分之一。比如嵌入维度是768epsilon初始值约0.036嵌入维度是128epsilon初始值约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条样本的情感分类任务上epsilon0.5时对抗训练比基线差epsilon0.2时比基线好3个点。所以小样本场景下epsilon要往小了调宁可扰动弱一点也不要让模型学偏。另外可以结合数据增强先用同义词替换扩充训练集再加对抗训练效果比单独用对抗训练好。6.4 推理阶段的“无扰动”一致性对抗训练只在训练时加扰动推理时不加。这会导致训练和推理的嵌入分布存在微小差异。对于大多数任务这个差异可以忽略但如果你的模型对嵌入分布极其敏感比如用了对比学习损失就需要在推理时也做某种“平滑”。一个简单的做法是在推理时对嵌入做随机丢弃类似Dropout但只在推理时用让模型见到更多样的嵌入分布。这个技巧在句子相似度任务上我试过能提升1到2个点的鲁棒性。但要注意推理时的随机性会带来结果不稳定同一个输入两次推理可能得到不同输出。如果你的业务要求确定性输出这个技巧就不适用。7. 一个完整的对抗训练实验记录7.1 实验设置与基线对比我用一个中文情感分类数据集做了完整对比实验。数据集包含8万条训练样本、1万条验证样本、1万条测试样本标签为正面/负面。基线模型是BERT-base-chinese最大序列长度128batch size 32学习率2e-5训练5个epoch。基线模型在测试集上的准确率是93.2%F1是0.931。然后我分别加了FGM、PGD、FreeLB参数如下方法epsilon步数训练epoch测试准确率对抗测试准确率基线--593.2%78.5%FGM0.51694.1%86.3%PGD0.53794.3%88.1%FreeLB0.53694.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.193.5%80.2%0.393.8%84.1%0.594.1%86.3%0.893.9%85.7%1.093.2%83.4%1.591.8%79.6%可以看到epsilon0.5时对抗测试准确率最高超过0.8后开始下降。正常测试准确率在epsilon0.5时也达到峰值说明适度的对抗扰动确实能提升泛化能力。epsilon1.5时正常准确率掉到91.8%说明扰动过大已经损害了模型的基本能力。这个曲线验证了一个观点对抗训练存在一个“甜点区”不是越强越好。找到这个甜点区的方法就是网格搜索虽然费时但比拍脑袋设参数靠谱得多。7.4 对抗训练后的错误分析我对比了基线和FGM模型在测试集上的错误样本。基线模型的错误主要集中在三类带错别字的评论、带反讽的评论、以及包含否定词的复杂句式。FGM模型在带错别字和否定词句式的错误率下降了约40%但在反讽评论上的错误率只下降了10%。这说明对抗训练对表面扰动的鲁棒性提升明显但对语义层面的复杂推理帮助有限。反讽需要模型理解上下文和常识这不是嵌入层扰动能解决的。所以如果你的任务里反讽、隐喻占比很高对抗训练只是第一步还需要结合其他方法比如提示学习或外部知识注入。8. 落地建议与常见问题速查8.1 什么时候该上对抗训练如果你的模型满足以下条件对抗训练值得投入训练数据标注质量较高、模型在测试集上表现尚可但鲁棒性差、任务对错判代价敏感、GPU资源允许增加30%到50%的训练时间。如果训练数据本身噪声大、模型容量小、或者任务对实时性要求极高推理时不能有任何额外开销对抗训练的优先级可以往后放。8.2 常见报错与解决方案报错现象可能原因解决方案loss变成NaNepsilon太大或梯度溢出减小epsilon嵌入梯度转float32验证集准确率下降对抗过强或学习率太高降低epsilon降低学习率训练时间翻倍PGD步数太多改用FGM或减少PGD步数嵌入层梯度为None嵌入层被冻结解冻嵌入层或改用输出扰动恢复嵌入后参数未更新梯度清零顺序错误先restore再zero_grad8.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已经能解决大部分问题。先把基础方法跑通再考虑进阶方案这是我踩过多次坑之后最深的体会。