NLP对抗样本生成与防御实战:从FGSM到PGD的中文模型鲁棒性提升
先说一个我最近遇到的事。某天凌晨我在线上环境看到一个情感分析模型在测试集上表现非常好准确率接近95%结果第二天被一批“加了几个奇怪字符”的用户反馈冲击下来。我扒开日志一看用户只是把“这家店真的很好吃”写成了“这家店真地很好吃”模型就翻车了从正向跌到负向。那一刻我意识到NLP模型的鲁棒性问题不是理论课上的论文题目它是实实在在会咬人的东西。这个现象背后就是对抗样本在起作用。所谓对抗样本就是在原始输入上施加一圈人类几乎察觉不到的扰动却让模型以高置信度给出错误输出。在自然语言处理里对抗样本的生成与防御已经成了从大模型落地到业务系统都必须认真面对的话题。这篇文章我想围绕一个模拟项目X的完整经历把对抗样本的生成原理、中文场景下的特殊坑、一套可直接运行的Python实现以及三层防御机制的设计思路全部讲清楚。适合正在做NLP模型上线、想评估模型鲁棒性、或者纯粹想搞懂对抗训练是怎么回事的开发者参考。1. 为什么NLP模型在“小误导”面前如此脆弱1.1 从一次误判说起语义没变输出却变了上面说的“真地”例子乍看像是文本清洗不彻底的问题我一开始也没往对抗样本上想只觉得是个别错误输入。但后来我把这类输入集中起来测了一轮发现一个更扎心的现象模型对“表层的字面变化”极度敏感对“深层语义”反而没那么敏感。举个例子我拿一条测试样本“这家餐厅的菜品质很好服务也很到位”模型给出0.93的正向概率。只改动一个词把“品质很好”换成“品位很好”模型输出直接掉到0.41。这两个词在人类理解里基本等价但模型眼里的它们是词嵌入空间里两个距离很远的点。顺着这个思路继续试添加一个空格、把“的”改成“地”、把简体字临时换成繁体都能对置信度造成肉眼可见的冲击。这种敏感性的根源在于NLP模型的映射方式。模型把文本token化后映射到高维语义空间再利用数十层网络在这些向量之间做非线性变换最后通过softmax输出概率。决策边界并不是平滑的球面而是极其崎岖的高维曲面。大部分训练样本落在“安全区”但决策边界附近聚集了大量可能被“轻轻一推”就越过边界的脆弱点。你可以想象一个高度近视的人站在远处分辨一只猫和一只狗这时候有人在他和猫之间插了一根细树枝他的判断就立刻改变了——模型的“视力”其实比你想象的更差。1.2 脆弱性为什么值得认真对待有人可能会说用户又不是攻击者谁会故意把文本改得那么刁钻。但现实里拼写错误、方言表达、同音字、输入法联想、自动翻译残留的痕迹本质上都是一种“非恶意扰动”。用户不会故意攻击你的模型但他们会天然地用各种非标准方式表达同一个意思。如果模型不能对这些扰动免疫那么上线后的真实准确率注定会低于测试集准确率。对抗样本的意义恰好就是把这些“非标准变化”中最极端的情况系统性地生成出来。它不是为了让攻击者捣乱而是给模型的鲁棒性做压力测试。用对抗样本生成器去扫描模型就像给建筑做抗震测试一样震不倒才敢住人。这个领域里大家一般把对抗样本的研究分成两派一派专注生成穷尽各种手段让模型犯错另一派专注防御想办法让模型“不怕打”。两派交替上升才有了今天的对抗训练、输入消毒、梯度掩蔽等一整套方法论。2. 对抗样本的生成原理从梯度符号法到多步迭代攻击2.1 扰动到底“加”在哪里Embedding空间比Token空间更顺手做图像对抗样本时扰动是直接加在像素矩阵上的人眼看不出差别。但文本是离散的token序列不能像像素那样随便微调——你把“好”改成“好加一点点”这个字根本不存在。所以NLP里的对抗样本生成有两种常见操作空间。第一种是token替换也就是真正改变文本内容。比如把“很棒”换成“超棒”这是离散变换得到的结果可读性高但搜索空间巨大生成效率低。第二种是embedding空间扰动即把原始token映射成词向量之后直接在连续向量上加上一个微小偏移再让模型基于带偏移的向量做预测。这种做法的好处是工程上无缝衔接梯度计算可以直接复用深度学习框架的自动求导能力。缺点是最终拿到的是一组“偏移过的向量”不容易直接还原成一段人类可读的对抗文本。在实际的防御评估和对抗训练流程中我几乎都用embedding空间扰动因为要的是“让模型在训练中见过被扰动的特征分布”而不是让人类读者去阅读这些攻击文本。2.2 FGSM最朴素的单步攻击快速梯度符号法核心思想极其简单既然模型是根据梯度学习来最小化损失那攻击者就反其道而行之根据梯度方向最大化损失。公式长这样x_adv x eps * sign(grad_x L(x, y))其中x是原始输入向量L是损失函数grad_x L表示损失对输入的梯度sign只保留方向eps控制扰动幅度。之所以用sign而不是直接用梯度是因为我们只关心“哪个方向会让loss增加得最快”不关心这个方向上的梯度模长是多少。固定步长扰动均匀操作干净。放在BERT这样的模型里实现时x指的是embedding层的输出张量维度是[seq_len, hidden_size]。举个例子bert-base-chinese的hidden_size是768那你就是在每个token的768维向量上加上一个eps大小的符号扰动。这种攻击的效果出奇地好原因在于高维空间中哪怕每个维度只动一小步768个维度累积起来的方向性偏移也足够把样本推到决策边界的另一侧。FGSM的优点是快一次反向传播就完事。缺点是攻击力度有限因为“只看一步方向”常常会冲到某个局部最优方向扰动效率不高。它更适合当基线方法。2.3 PGD把单步变多步攻击强度立刻上台阶如果FGSM是“闭眼冲一步”那投影梯度下降就是“睁着眼小步快跑每步都校准方向”。PGD的流程是先从原始样本出发在一个允许的扰动范围内反复执行多次小幅迭代攻击每次迭代之后把累积扰动投影回一个范围里保证扰动不跑太远。伪代码如下delta 0 for t in range(iters): x_adv x delta x_adv.requires_grad_(True) loss loss_fn(model(x_adv), y) delta delta alpha * grad_x_adv(loss).sign() delta clamp(delta, -eps, eps)每一步走alpha那么大的距离alpha通常比eps小一个数量级比如eps0.1alpha0.01跑10步。每走一步都检查扰动是否超出界超出就拉回来。PGD的攻击强度相比FGSM有明显的跃升因为它利用模型当前的梯度反复修正方向相当于“摸着决策边界走”最后停在一个非常危险的位置。现在做鲁棒性评估我基本都用PGD作为默认攻击方法FGSM只是用来做一个快速摸底。2.4 DeepFool和CW更精细的攻击也更昂贵再往下走还有两类方法值得了解。DeepFool的思路是寻找最近的决策边界把样本往边界方向垂直推动直到越过边界为止它追求的是“用最小扰动达成攻击”。CW方法则把攻击建模成一个优化问题目标函数里同时包含扰动大小和一个控制攻击成功率的惩罚项用梯度下降直接求解一个尽量小且能骗过模型的扰动。我用一个表格概括它们在实战中的定位方法扰动约束方式计算开销攻击强度工程中用途FGSM单步固定幅度低弱快速摸底PGD多步、投影到边界中较强鲁棒性评估默认选择DeepFool逼近边界、扰动小中中分析决策边界结构CW优化最小范数高强衡量防御机制的上限如果你的目标是上线前评估模型抗打击能力做PGD就足够了。CW虽然攻击力更强但它包含大量超参数调优运行时间也长更适合用在学术论文式的严谨对抗对比里不太适合快速工程迭代。3. 中文场景下的对抗样本字符级攻击与同音替换的隐蔽性3.1 中文没有空格攻击方式完全不同我最早看资料时参考了很多英文NLP对抗样本的文章里面大量采用“同义词替换”和“词序调换”比如把“great”换成“excellent”。但换成中文场景这一套并不是最优解。英文天然有空格分词词与词的边界清晰攻击者容易定位到“哪个词是弱点”。中文是连续字符串词边界需要靠分词器划分这就衍生出两类中文特有的攻击入口一类是字符级别的近似替换另一类是破坏分词一致性的干扰操作。字符级攻击里最常见的是同音字替换。中文里同音字极多“的/地/得”、“做/作”、“在/再”这种错误在真实用户输入里就很常见。对抗样本生成器如果把这些作为扰动方向刻意构造“这家店真地很好吃”这类句子模型很容易被骗。原因在于BERT类模型虽然用了字级别的subword建模但它对字与字之间的共现模式极度敏感训练语料里“真的”共现频率远高于“真地”一旦换成低频组合模型就会进入低置信度区域。3.2 形近字、繁简转换和插入符号每条路都能通向误判除了同音字中文还有一批非常隐蔽的扰动方式形近字替换把“人”换成“入”、“日”换成“曰”、“天”换成“夭”字形上几乎无法察觉但token完全不同。简繁转换把“体验”写成“體驗”。如果训练语料是简体为主繁体序列等于把模型推向分布外。插入无意义符号在字与字之间加空格或者标点表面上没改变内容但token切分被彻底打乱。拼音缩写比如“真的”写成“zd”这个词完全不在词表里模型需要靠猜。这些扰动之所以有效深层原因是模型不是在“读字义”而是在“统计字形组合”。一个汉字在嵌入表里对应一个固定向量这个向量是由它在语料中和哪些字共现决定的。当你把“人”替换成“入”模型对这个位置的上下文预测全部失效语义链条瞬间断裂。3.3 中文攻击表该怎么构建做中文对抗样本生成没法直接套英文的WordNet同义词库得自己构建扰动字典。我是这样做的先整理一个高频字集合再用拼音库和字形结构库给每个字生成候选替换列表分成同音、形近、繁简三组。攻击时优先替换那些在模型注意力分数里权重最高的字符。这样攻击成功率能提升不少因为替换掉“模型最关注的那个字”比替换一个边角字有效得多。当然这种攻击方式也有弱点它对人类来说可读性仍然很高所以如果你想做防御侧的“输入消毒”完全可以反向利用这套字典把这些变体归一化回标准写法。4. 用Python实现一套中文NLP对抗样本生成流程4.1 环境准备与模型加载模拟项目X的情感分析二分类任务我用的环境是Python 3.10 PyTorch 2.0 Hugging Face Transformers。先安装依赖pip install transformers torch pandas scikit-learn加载一个轻量的中文预训练模型这里用bert-base-chinese做示例from transformers import AutoTokenizer, AutoModelForSequenceClassification import torch device torch.device(cuda if torch.cuda.is_available() else cpu) model_name bert-base-chinese tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained( model_name, num_labels2 ).to(device) model.eval()注意我们做对抗样本生成时不希望Dropout层干扰计算必须切到eval模式。这个细节很容易被忽略在训练模式下生成的对抗样本扰动方向会被随机噪声污染攻击效果会明显打折。4.2 FGSM攻击器在Embedding上做文章接下来实现一个朴素的FGSM攻击器。核心思路是先正常跑一次前向拿到loss后反向传播取出embedding层梯度再在embedding输出上加扰动。import torch.nn.functional as F def fgsm_attack(text, label, eps0.1): inputs tokenizer( text, return_tensorspt, truncationTrue, max_length128 ).to(device) label_tensor torch.tensor([label]).to(device) # 获取embedding输出作为可扰动变量 embedding_layer model.get_input_embeddings() embedded embedding_layer(inputs[input_ids]) embedded.requires_grad_(True) # 模型前向传入embedding和attention_mask logits model( inputs_embedsembedded, attention_maskinputs[attention_mask] ).logits loss F.cross_entropy(logits, label_tensor) model.zero_grad() loss.backward() # 关键一步取梯度的符号方向 grad embedded.grad if grad is None: raise RuntimeError( Embedding gradient is None. Check requires_grad setting. ) adv_embedded embedded eps * grad.sign() return adv_embedded, inputs[attention_mask]这里有个特别容易踩的坑如果你用常规方式直接调用model(text)然后取梯度拿到的是对input_ids整数张量的梯度而整数张量本身不可导梯度回传不到输入空间。所以必须显式拿到embedding层的输出把它作为inputs_embeds传给模型。这样才能保证扰动是加在连续的embedding向量上而不是离散的token id上。攻击完之后的验证逻辑也不复杂用adv_embedded再过一次模型把输出的预测标签和原始标签做对比若不一致就算攻击成功。实际跑下来在测试集上FGSM的攻击成功率通常在40%到60%之间浮动具体取决于epsilon设置和模型本身鲁棒性单步攻击的能力就这么大。4.3 PGD攻击器多步迭代效果立竿见影PGD是FGSM的自然延伸。把单步改成多步迭代并且在每一步之后把扰动裁剪回边界内。def pgd_attack(text, label, eps0.1, alpha0.01, iters10): inputs tokenizer( text, return_tensorspt, truncationTrue, max_length128 ).to(device) mask inputs[attention_mask] label_tensor torch.tensor([label]).to(device) embedding_layer model.get_input_embeddings() origin_embedded embedding_layer(inputs[input_ids]) delta torch.zeros_like(origin_embedded) for _ in range(iters): delta.requires_grad_(True) adv_embedded origin_embedded delta logits model( inputs_embedsadv_embedded, attention_maskmask ).logits loss F.cross_entropy(logits, label_tensor) model.zero_grad() loss.backward() grad delta.grad # 沿着梯度上升方向走一步 delta_data delta alpha * grad.sign() # 投影回epsilon球内 delta_data torch.clamp(delta_data, -eps, eps) delta delta_data.detach() adv_embedded origin_embedded delta return adv_embedded, mask注意每次迭代都要zero_grad否则梯度会在不同步之间累加扰动方向就会失真。另外扰动裁剪用的是符号边界也就是每个元素的绝对值不能超过eps。这是最常用的无穷范数约束它保证的是“每个位置的改动都有一个上限”。我在实验里常用eps0.1、alpha0.01、iters10这组参数。单看攻击成功率PGD比FGSM高出15到25个百分点非常明显。而且PGD拿到的对抗样本在语义层面上往往比FGSM更“平静”因为它是逐步逼近决策边界的不会粗暴地把所有维度都推到一个极端值。4.4 评估指标以及我建议的记录方式生成对抗样本只是第一步关键还是如何评估。我每次做鲁棒性评估必看三个指标攻击成功率对抗样本中被模型预测为错误标签的比例越高说明模型越脆弱。置信度下降幅度原始样本预测概率减去对抗样本预测概率的平均值这个指标比攻击成功率更细腻能反映出模型即使没被攻击成功也已经被明显动摇。人工可读性随机抽几十条对抗样本人工判断语义是否保留。如果攻击结果变成了一堆乱码那这种攻击对业务场景的参考意义就要大打折扣。我还习惯把每轮生成的对抗样本存成文件。这样做的好处是当模型迭代之后我可以拿同一批对抗样本重新测一遍直接对比模型在“历史短板”上的改进情况。这相当于给模型的鲁棒性建了一份“体检档案”比每次临时生成样本要科学得多。5. 三层防御机制设计对抗训练、输入消毒与梯度掩蔽5.1 对抗训练不是玄学是成本最低的强化手段什么是防御里效果最直接的方法我的答案是对抗训练没有之一。做法说起来也简单在训练过程中每次拿到一个batch先用当前模型对这批样本做若干步攻击生成对抗样本再把对抗样本连同原始样本一起喂给模型做正常的梯度下降。这样模型等于是在“挨打中学会挨打”它见过的扰动多了决策边界自然会被拉平。我在模拟项目X里做了一组对比实验。基线模型在正常测试集上准确率是95.2%在PGD攻击下直接掉到59.8%。用同一个模型继续做3个epoch的PGD对抗训练后正常准确率微降到93.6%但PGD攻击下的准确率回升到78.4%。这个提升非常可观付出的代价只是正常任务上1.6个点的准确率完全可以接受。不过对抗训练有几个实操上的坑。第一训练刚开始的时候不要直接上对抗样本最好先正常训练几个epoch让模型具备基础能力否则模型在训练初期什么都学不到。第二epsilon不要设太大太大了扰动后的样本语义会彻底撕裂模型学到的反而是噪声模式。我常用的范围是0.05到0.2。第三对抗训练容易让loss曲线震荡需要配合warmup和梯度裁剪否则训练过程会很痛苦。5.2 输入消毒在入口把变体拦下来对抗训练解决的是模型内部鲁棒性而输入消毒解决的是“脏东西别进管道”。我设计了一套预处理规则在推理之前对文本做归一化繁体统一转简体这一步能直接消除繁简扰动。高频同音字纠错比如“真地”归一化为“真的”“做菜”和“作菜”根据语境做映射。去掉字与字之间插入的多余空格、连续标点。把全角符号转半角避免tokenizer在这些符号上产生分叉。这套规则的实现成本很低用简单的字典和正则就行。但必须注意别误伤正常表达。比如“地”在“慢慢地走”里是正常副词用法如果规则简单粗暴地把所有“地”都替换成“的”会产生一大批新的错误样本。更稳妥的做法是只处理高频的、明显是错别字的组合比如“真地”、“在再”、“做的”这类固定搭配。输入消毒不能替代对抗训练它更像是一道廉价的前置防线。把80%的“低端扰动”拦在门外让对抗训练专注于解决更深层的分布外问题。5.3 梯度掩蔽的局限性不能单独依赖第三种常见手段是梯度掩蔽思路是让模型对输入梯度的变化不敏感这样攻击者无法根据梯度方向构造有效扰动。具体实现包括防御蒸馏也就是用一个大模型的软标签去训练一个小模型让决策边界变得平滑从而削弱梯度信号。这个方法实现起来也不难但我要提醒一句它绝不能单独作为防御策略。对抗样本有一个臭名昭著的属性叫可迁移性攻击者用另一个黑盒模型生成的对抗样本经常可以直接骗过你的模型。这就意味着即使你把自己的梯度藏得再好攻击者还是可以借助一个公开的模型来生成攻击样本然后把这份攻击“搬”到你的模型上来。我在实验里就验证过对蒸馏后的模型做白盒攻击成功率确实下降了但换成用普通BERT生成的对抗样本去做黑盒迁移攻击蒸馏模型的防御效果几乎归零。所以梯度掩蔽只能作为整个防御体系中的一个环节和对抗训练、输入消毒配合使用而不是单独撑起安全防线。5.4 三层防御架构的落地划分我在模拟项目X里最终采用的方案是三层防御第一层推理入口的输入预处理规则引擎负责归一化同音字、繁简、符号等初级扰动。第二层模型内部的PGD对抗训练让模型在面对强扰动时仍有稳定的判断。第三层输出端的置信度过滤当模型给出的概率低于某个阈值时不直接返回结果而是标记为“低置信度待人工确认”。三层各管一段效果是叠加的。从最后实验数据来看三层架构把PGD攻击下的准确率从59.8%一路抬到86.5%而正常样本上的准确率只下降了1.2个百分点。作为一套工程方案这个性价比已经相当高了。6. 实验评估与实战踩坑记录6.1 实验配置与结果汇总模拟项目X的实验配置如下配置项参数模型bert-base-chinese任务中文情感二分类训练集自建1万条标注数据测试集2000条独立样本攻击方法FGSM(eps0.1)PGD(eps0.1, alpha0.01, iters10)对抗训练PGD-AT3个epocheps0.1关键结果方案正常准确率PGD攻击后准确率基线95.2%59.8%输入消毒94.8%66.1%对抗训练93.6%78.4%三层全开94.0%86.5%可以看到每一层防御都对最终结果有正向贡献其中对抗训练的贡献最大。输入消毒单独使用效果有限因为它只能拦住规则类扰动对PGD在embedding空间里制造的连续扰动无能为力。但当它和对抗训练叠加时等于把攻击者的工作量和成功率都压低了。6.2 排查链路一个让我卡了两天的Embedding梯度问题这次实验里我最想分享的一个踩坑经过是embedding梯度根本取不到的问题。当时我写FGSM攻击器第一次实现是这样的logits model(**inputs).logits loss F.cross_entropy(logits, label_tensor) loss.backward() grad inputs[input_ids].grad # 结果为None运行结果就是grad为None。我一开始怀疑是模型没有调用zero_grad导致梯度累积排查了很久才发现问题出在更底层的地方input_ids是整数张量整数张量本身不可导PyTorch不会为它分配梯度存储所以就算backward()执行了你也在它身上拿不到任何梯度。正确的做法是我在上文代码里展示的那样先把input_ids喂给embedding_layer得到连续的embedding输出再对这个输出设置requires_grad_(True)。之后模型前向时改传inputs_embeds梯度就会正确落到这个embedding张量上。这个问题让我意识到NLP对抗样本生成的工程难点很多时候不是算法本身而是要把“梯度传播路径”理解透。CPU上跑代码报错还算友好最怕的是在GPU上静默返回None找半天都不知道错在哪。6.3 更多实战中的小坑除了梯度问题还有几个实战里高频出现的小坑值得记录。第一token type ids会悄悄破坏前向计算。很多人在用inputs_embeds传入模型时只记得传attention_mask忘了把token_type_ids一起传。对单句分类任务影响不大但对句子对任务就会产生诡异的结果因为模型接收了错误的segment信息。第二epsilon设置过大时对抗样本会失去研究价值。我把epsilon从0.1调到0.5测试过攻击成功率确实接近100%但生成的“对抗文本”在语义层面已经完全不成样子。这种攻击对防御训练没有任何指导意义因为它只是在教模型对抗乱码而不是对抗自然语言变体。第三对抗训练过程中的loss震荡问题。我发现直接用原始学习率跑对抗训练loss曲线会像心电图一样跳。解决方法是给优化器加warmup同时设置梯度裁剪阈值比如max_grad_norm1.0这样训练过程会稳定很多。第四防御蒸馏后的模型需要重新评估正常样本上的表现。蒸馏模型在正常样本上的准确率往往有小幅下降如果业务对准确率极其敏感就需要在“防御能力”和“正常表现”之间找平衡而不是无脑追求鲁棒性。6.4 鲁棒性评估应该成为上线流程的一部分做完这个项目之后我的习惯变了。现在每次训练完一个NLP模型我不会只看它在测试集上的准确率还会固定跑一轮PGD攻击把“攻击后的准确率”一起写进模型上线评审里。我把这组指标叫作“模型的抗压分数”在同等正常准确率的情况下抗压分数更高的模型我更愿意放它到线上。这个习惯帮我躲过了好几次上线事故。有些模型在测试集上看起来比旧模型强了2个点但PGD攻击后直接崩到50%这种模型上线后用户随便一些非标准表达就能把它击穿。我也越来越确信一件事对抗样本不是模型的对立面它就是模型的镜子。模型在对抗样本上暴露出的每一个错误都对应着它某一种没有真正学会的语言模式。把对抗样本生成器当成单元测试的一部分而不是当成研究玩具是我在这个项目里最想传递给读者的经验。