一、从一句话就能让模型翻车说起
自然语言推理(Natural Language Inference,简称 NLI)不是什么高深概念,说白了就是让模型判断一句话和另一句话之间的关系——是“支持”、是“反对”、还是“毫无关联”。比如你给模型看“猫在吃鱼”,再给一句“猫在进食”,模型应该判断为“支持”。这种任务在智能客服、文本搜索、合同审核里都用得很广。
但有个很头疼的问题:这些看起来很聪明的模型,经常被你根本注意不到的小改动给骗了。打个比方,原文是“这部电影不好看”,你悄悄把“不”改成稍重一点的发音或者加个空格,模型就忽然认为它是好评。这种故意让人察觉不到的扰动,就叫“对抗扰动”。本文就像剥洋葱一样,一层层拆开这个脆弱性,然后告诉你两个特别实用的防护手段——软标签平滑和输入掩码。
二、脆弱的本质:模型太相信细节
神经网络本质上是在海量数据里找统计规律,但它没有人类那种常识性的“抗干扰能力”。比如下面这个简单的场景,你给 BERT 喂一段文本,哪怕只改一个字,推理结果就可能天差地别。
2.1 一个直观的小实验
假设我们做了一个 NLI 模型,用来判断两句话是否矛盾。正常情况:
- 前提:小明今天去了公园。
- 假设:小明今天去了公园散步。
- 正确关系:支持。
但如果有人在假设里加了个空格或者同音字,比如把“散步”写成“散 步”,模型可能就认为两者矛盾。这种扰动在自然语言里非常隐蔽,人类根本不会在意,但模型会掉坑。
2.2 为什么不能只靠更多数据?
很多人以为多喂数据就能解决,但对抗扰动是攻击者刻意设计的,他们知道模型喜欢的“小特征”,然后利用这些特征让模型犯错。靠增加普通数据很难覆盖到所有可能的扰动模式。所以我们需要主动在训练过程中给模型“打疫苗”。
三、软标签平滑:别让模型太自信
软标签平滑(Label Smoothing)是一种非常温柔的做法:不要用绝对的 0 或 1 作为监督信号,而是让标签带一点“不确定”。比如原本类别 A 的目标是 1,你可以改成 0.9,其他类别分到 0.0333 这样的小值。这样模型就学不会“只有某个特征才是答案”,从而对一些小噪音不那么敏感。
3.1 技术原理浅说
在 NLI 任务里,标准训练用的交叉熵损失函数,会把真实类别的概率推到 1,其他推到 0。一旦某个样本被轻微扰动,模型因为过于相信自己的判断,就容易从一个极端跳到另一个极端。软标签平滑相当于告诉模型:“别那么死板,学得模糊一点也能及格”。
3.2 Python 实战:如何加软标签平滑
我们使用 PyTorch + transformers 库,拿一个简单的 BERT 分类器来做示范。下面这个例子演示了如何修改损失函数,让训练时标签不是硬性 0/1。
import torch
import torch.nn as nn
from transformers import BertForSequenceClassification
# 选择技术栈:PyTorch + transformers
class LabelSmoothCrossEntropy(nn.Module):
def __init__(self, smoothing=0.1):
super().__init__()
self.smoothing = smoothing
def forward(self, pred, target):
# pred: [batch, num_classes] 模型输出
# target: [batch] 真实标签索引
n_class = pred.size(1)
# 构造软标签: 正确类别概率为 (1 - smoothing),其他均分 smoothing/(n_class-1)
tmp = torch.full_like(pred, self.smoothing / (n_class - 1))
# 把正确的索引位置填入 (1 - smoothing)
tmp.scatter_(1, target.unsqueeze(1), (1 - self.smoothing))
# 计算交叉熵
log_pred = torch.log_softmax(pred, dim=1)
return -(tmp * log_pred).sum(dim=1).mean()
# 载入预训练模型(这里只做演示,不真正训练)
model = BertForSequenceClassification.from_pretrained("bert-base-uncased", num_labels=3)
# 用自定义损失替换默认损失
model.loss_fct = LabelSmoothCrossEntropy(smoothing=0.1)
# 假设有一条数据
test_logits = torch.randn(1, 3) # 随机预测
test_target = torch.tensor([1]) # 真实类别为1
loss = model.loss_fct(test_logits, test_target)
print("Loss with label smoothing:", loss.item())
你可能会问:平滑值设多大?一般是 0.1 到 0.2,太小没效果,太大模型会变笨。在小数据集上尤其要小心。
3.3 用软标签平滑的好处
- 能有效降低模型对某些特征(比如输入中的小噪声)的过度敏感。
- 因为学习到的决策边界更平滑,模型在面对从未见过的扰动时往往更稳定。
- 实现简单,对原训练代码改动很小。
但也要注意:如果数据本身噪声就很大,软标签平滑可能会掩盖有效信号,导致欠拟合。而且它主要防护的是“通用扰动”,对精心设计的自适应性攻击效果有限。
四、输入掩码:干脆让模型少看点
输入掩码(Input Masking)是一种更直接的做法——在训练或推理时,故意随机遮盖住输入中的部分 token,迫使模型不能依赖完全精确的片段,从而学会利用上下文信息。这跟 BERT 预训练里的 Masked Language Model 很像,但这里我们用在 NLI 任务上,并且可以针对对抗扰动做特殊处理。
4.1 训练时加随机掩码
最简单的方法:在输入 embedding 层,随机把一部分 token 的 embedding 置为零(或替换成掩码符号)。这样模型就不能只靠某个词来做判断,必须综合整个句子。
4.2 实现一个掩码处理器
下面的 Python 代码展示了一个随机掩码的函数,它可以在每批数据送入模型前执行。
import torch
import random
def random_mask_tokens(input_ids, mask_token_id=103, mask_prob=0.1):
"""
随机遮盖输入中的 token,mask_token_id 对应 [MASK] 的 id(BERT 中为 103)
"""
# 复制一份以避免修改原数据
masked_input = input_ids.clone()
# 创建随机掩码矩阵,只对非特殊 token(比如 [CLS], [SEP])进行遮盖
special_tokens = {0, 101, 102} # [PAD], [CLS], [SEP] 的 id,具体看 tokenizer
mask = torch.ones_like(input_ids, dtype=torch.bool)
for idx in special_tokens:
mask &= (input_ids != idx)
# 根据概率生成遮盖位置
rand = torch.rand(input_ids.shape)
mask_positions = rand < mask_prob
# 实际遮盖,只对非特殊 token 生效
masked_input[mask_positions & mask] = mask_token_id
return masked_input
# 使用示例
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
texts = ["我今天很开心", "今天天气不太好"]
encoded = tokenizer(texts, padding=True, return_tensors="pt")
input_ids = encoded["input_ids"]
print("原始输入:\n", input_ids)
# 随机遮盖
masked_ids = random_mask_tokens(input_ids, mask_prob=0.15)
print("遮盖后输入:\n", masked_ids)
注意:如果遮盖比例太高(比如超过 50%),模型可能学不到有效特征。一般取 10%~20% 比较合理。另外,训练时遮盖,推理时通常不做遮盖,或者做一个保底的固定掩码。
4.3 对抗性掩码:只遮蔽关键部分
更高级的做法是:在训练过程中,用快速梯度信号检测出哪些 token 对最终分类贡献最大,然后专门屏蔽那些最容易受扰动的 token。这有点像对抗训练里的“注意力掩码”。但实现起来比随机掩码复杂,适合有一定基础的研究者。
五、把两个方法合起来用
软标签平滑和输入掩码并不是互斥的,它们就像一个保护组合:前者让模型学得模糊,后者让模型不得不看全局。实际项目中,建议先加软标签平滑,再在数据预处理阶段加上随机掩码。下面是一个完整的训练循环伪代码,展示了如何同时使用这两种技巧。
import torch
from torch.utils.data import DataLoader
from transformers import BertForSequenceClassification, BertTokenizer
from dataset import NliDataset # 假设你有一个NLI数据集
# 配置参数
smoothing = 0.1
mask_prob = 0.1
batch_size = 16
epochs = 5
# 模型和分词器
model = BertForSequenceClassification.from_pretrained("bert-base-uncased", num_labels=3)
tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
model.loss_fct = LabelSmoothCrossEntropy(smoothing=smoothing) # 使用之前定义的损失函数
# 数据加载
dataset = NliDataset("data/train.json")
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
# 优化器
optimizer = torch.optim.Adam(model.parameters(), lr=2e-5)
for epoch in range(epochs):
for batch in loader:
# 前提和假设一起编码
inputs = tokenizer(batch["premise"], batch["hypothesis"],
padding=True, truncation=True, return_tensors="pt")
# 对 input_ids 做随机掩码
inputs["input_ids"] = random_mask_tokens(inputs["input_ids"],
mask_token_id=tokenizer.mask_token_id,
mask_prob=mask_prob)
# 前向传播
outputs = model(**inputs, labels=batch["label"])
loss = outputs.loss
# 反向传播
loss.backward()
optimizer.step()
optimizer.zero_grad()
print(f"Epoch {epoch+1} done. Loss: {loss.item():.4f}")
注意:由于我们改了 loss 函数,模型输出中的 loss 实际上是自定义损失,而原始 outputs.loss 可能被覆盖为 None,上面代码中我们直接取 loss 变量即可。实际使用时建议显式调用我们自己的损失函数。
六、应用场景与注意事项
6.1 哪些场景最需要这类防护
- 智能客服系统:用户输入常带错别字、空格、emoji,这些可能被恶意利用。
- 法律文本审核:合同条款的一句话改动可能引发歧义,模型必须稳定。
- 内容审核:对抗性欺骗(比如把敏感词拆开)需要模型忽略无关细节。
6.2 技术优缺点一览
| 方法 | 优点 | 缺点 |
|---|---|---|
| 软标签平滑 | 实现简单,通用性强,对其他任务也有好处 | 对高噪声数据可能欠拟合;对针对性攻击效果下降 |
| 输入掩码(随机) | 迫使模型不依赖局部特征,可解释性好 | 过多掩码会破坏语义;需小心调整掩码概率 |
| 两者结合 | 互为补充,鲁棒性提升更显著 | 训练时间略增加,超参数调优更复杂 |
6.3 注意事项
- 不要盲目堆叠超参数:smoothing 和 mask_prob 需要做交叉验证,比如在验证集上观察 F1 或准确率。
- 这两种方法更适合用于对抗扰动较弱的场景(比如普通噪声),对高强度的自适应攻击(如 PGD 攻击)效果有限。真正需要强防护时,建议结合对抗训练(Adversarial Training)。
- 软标签平滑会自然降低训练集准确率,但泛化能力往往更好。不要因为训练 loss 没到零就停止优化。
七、文章总结
自然语言推理模型虽然越来越强大,但对微小扰动的脆弱性是天然的软肋。通过软标签平滑,我们给模型打了一针“温和剂”,让它不再死盯个别特征;输入掩码则像给模型戴了一副“近视镜”,迫使它依赖更广泛的上下文。这两个方法一起使用,可以在不显著增加训练成本的情况下,大幅提升模型对常见扰动(拼写错误、同音替换、随机噪声等)的鲁棒性。
当然这只是一道开胃菜,真正的安全还需要配合对抗训练、数据增强、集成模型等多种手段。但至少现在,你已经有了两个趁手的小工具,可以马上去提升自己模型的抗干扰能力了。
Comments