做自然语言推理(NLI)任务时,一个常见的尴尬现象是:把前提文本整个删掉,只把假设句喂给模型,准确率居然还能达到七成以上。这说明模型并没有真正学会推理,而是抓住了数据集里的统计捷径——比如假设句里出现“animal”就大概率是中立类,出现“not”就大概率是矛盾类。这类问题统称为数据集偏差,而对抗性过滤和难负例挖掘是目前公认最有效的两类清洗手段。本文将完整拆解这两种方法的原理、流程与代码实现。

NLI数据集偏差从哪里来
NLI数据集的偏差主要来自三个层面。第一是标注者的书写习惯。以SNLI为例,假设句是由众包标注者根据图片手动写出来的,不同人对同一类图片的描述措辞高度趋同,导致某些词汇与特定标签形成了强相关。研究者统计发现,仅用一个逻辑回归模型对假设句做词袋特征分类,就能在SNLI测试集上拿到67%左右的准确率,远超三分之一的随机基线。
第二是标注规则的系统性倾向。在多选(MultiNLI)的构造过程中,标注者被要求“写一句前提中无法直接判断真假的句子”作为中立样本,结果中立假设普遍比蕴含和矛盾假设更长、包含更多具体名词。模型一旦学到“句子长就是中立”这类伪规律,在真实场景下就会频繁误判。
第三是负例分布过窄。蕴含样本天然是真实语料,而矛盾和中立样本往往是人工构造的,措辞风格与蕴含句差异明显。模型可以轻松靠文风差异区分正负例,根本不需要理解语义关系。这也是难负例挖掘要解决的核心问题——让负例和正例在表层数据分布上尽量接近。
对抗性过滤:让偏差检测模型替你筛数据
对抗性过滤(Adversarial Filtering,AF)的核心思想很直接:训练一个只能利用偏差特征的“弱模型”,凡是它能轻松答对的样本,说明偏差信号太强,应该被移除或重写。与简单去掉高频词不同,AF是一种数据驱动的迭代式清洗,能自动发现人类难以察觉的统计捷径。
标准流程分为四步。第一步,用全部数据训练一个仅接收假设句(不看前提)的分类器,作为偏差探测器。第二步,让该分类器对训练集打分,按预测置信度排序,置信度越高的样本偏差越严重。第三步,移除顶部一定比例(通常10%到30%)的高置信样本,或用生成模型改写这些样本的措辞。第四步,在过滤后的数据上重新训练探测模型,重复以上过程直到探测模型的准确率收敛到接近随机水平。下面是一个基于PyTorch的简化实现:
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
class HypothesisOnlyClassifier(nn.Module):
"""只看假设句的偏差探测模型,故意屏蔽前提输入"""
def __init__(self, vocab_size, embed_dim=128, hidden=256, num_labels=3):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
self.encoder = nn.LSTM(embed_dim, hidden, batch_first=True, bidirectional=True)
self.classifier = nn.Linear(hidden * 2, num_labels)
def forward(self, hyp_ids):
# hyp_ids: 只包含假设句 token 的输入,前提被刻意丢弃
emb = self.embedding(hyp_ids)
_, (h, _) = self.encoder(emb)
feat = torch.cat([h[0], h[1]], dim=-1)
return self.classifier(feat)
def adversarial_filtering(dataset, remove_ratio=0.2, max_rounds=5):
filtered = dataset
for round_id in range(max_rounds):
model = HypothesisOnlyClassifier(vocab_size=filtered.vocab_size)
train(model, filtered.train_split) # 训练偏差探测器
confs = predict_confidence(model, filtered.all_split)
threshold = sorted(confs)[int(len(confs) * (1 - remove_ratio))]
# 移除探测模型高置信的样本,这些样本携带强偏差信号
filtered = [s for s, c in zip(filtered, confs) if c < threshold]
acc = evaluate(model, filtered.dev_split)
if acc < 0.40: # 探测准确率接近随机,说明偏差已基本清除
break
return filtered
使用AF时有几个实用建议。过滤比例不宜一步到位,单轮移除超过40%容易把正常样本也误伤,多轮小比例过滤的效果更稳定。其次,不要只看假设句偏差,可以再训练一个“只看前提长度和词频特征”的探测模型,两类探测器交叉过滤能覆盖更多偏差类型。最后,过滤后的数据集规模会缩小,如果训练数据不够,可以考虑用生成式改写替代直接删除,即让大语言模型重写高偏差样本的假设句,保留标签的同时消除特征性措辞。
难负例挖掘:把负例做得更像正例
如果说对抗性过滤是在“减法”,难负例挖掘就是在做“加法”——主动构造那些表面积相似但语义关系相反的样本,逼着模型放弃捷径。所谓难负例,指的是模型当前参数下容易分错的负样本,通常表现为与正例词汇重叠度高、句式结构接近。
第一种常用策略是词级扰动。对蕴含样本的假设句做最小编辑,比如把一个名词替换成上位词或反义词,把否定词插入谓语前,从而生成矛盾样本。这类方法实现成本低,但需要注意语法合法性,替换后最好过一遍语言模型的困惑度检测,过滤掉不通顺的句子。示例代码如下:
import random
def word_level_perturb(hypothesis, antonym_dict, insert_negation=True):
tokens = hypothesis.split()
hard_negs = []
for i, tok in enumerate(tokens):
if tok.lower() in antonym_dict:
neg = tokens.copy()
neg[i] = antonym_dict[tok.lower()]
hard_negs.append(" ".join(neg))
if insert_negation:
for i, tok in enumerate(tokens):
if tok.lower() in {"is", "are", "was", "were", "can", "will"}:
neg = tokens.copy()
neg.insert(i + 1, "not")
hard_negs.append(" ".join(neg))
return hard_negs
def filter_by_lm_fluency(candidates, lm_model, max_ppl=80.0):
# 用语言模型困惑度过滤语法不合法的扰动样本
return [c for c in candidates if lm_model.perplexity(c) < max_ppl]
第二种策略是反事实生成。给定一个前提和一个正确标签的假设,通过编辑假设中的关键片段(比如把“three dogs”改成“two dogs”)生成一个标签必然翻转的新样本。反事实方法的优势在于能精确控制“决定标签的那一个语义差异”,让模型学会真正的因果判断而非表面关联。可以先训练一个片段删除模型找出最小充分片段,再用条件生成模型在该位置填充新内容。
第三种是基于检索的负例挖掘。把所有候选句子编码成向量,用句子嵌入检索与正例假设最相似的句子,再由人工或弱模型确认其与前提的真实关系,将容易被误判的样本加入训练集。这种方法工业界用得最多,因为它能从海量无标注语料里自动扩充负例池。需要注意控制难负例的难度上限:如果加入的负例连人工都无法判断,只会给训练引入标签噪声。经验做法是让弱模型在候选负例上的错误率落在40%到80%区间,太简单没有价值,太难则可能本身就有歧义。
两种方法如何配合使用
对抗性过滤和难负例挖掘并不是二选一的关系,推荐的处理流水线是:先做对抗性过滤清除存量偏差,再做难负例挖掘补足负例质量。顺序不能颠倒——如果数据里还残留大量捷径样本,挖掘出的难负例会被偏差信号淹没,模型依然学不到语义推理。
在工程落地时,建议保留一份未清洗的原始验证集用于回归测试。如果模型只在清洗后的测试集上提升明显、在原始测试集上反而下降,说明清洗过程可能过度拟合了探测模型的偏差定义,需要调低过滤比例或放宽难负例的难度阈值。此外,训练阶段可以配合重要性加权使用:给被AF判定为高偏差但语义正常的样本赋予较低权重,而不是直接删除,这样既压制了偏差又保留了数据多样性。多轮实验对比显示,AF加难负例挖掘的组合方案在挑战集(如HANS)上的提升通常能达到15到25个百分点,代价仅是训练数据量增加一倍左右,性价比相当可观。