1. 为什么要在 NLP 里做数据增广
先把话说在前面:很多人一听到“数据增广”,第一反应是计算机视觉那套——翻转、裁剪、调亮度,一张图变十张图。这个思路放到 NLP 里,天然就容易踩坑。原因很简单:图像是连续的像素,你翻转一下,猫还是那只猫,语义没变;但文本是离散的 token,一个词替换错了,整句话的意思可能就反了。
我见过不少刚接触这个方向的人,上来就抱着“增广 = 复制粘贴 + 改几个词”的心态,最后训练出来的模型在验证集上漂漂亮亮,一到真实场景就露馅。真正有效的 NLP 数据增广,不是靠量堆,是靠保持语义不变的前提下制造输入扰动。
这篇文章要聊的,是基于 Hugging Face Transformers 库做 NLP 数据增广的 4 种常用方法。为什么单拎 Transformers 出来说?因为现在做 NLP 基本绕不开这个生态,不管是加载预训练模型、做 token 级别的替换,还是用生成模型做 paraphrase,Transformers 都提供了现成的接口,你不用自己从头写 tokenizer、不用手动实现 attention mask,省掉一大半脏活累活。
适用的人也很明确:你手上有一批标注好的文本数据,想扩大训练集;或者你的类别严重不平衡,想给少数类补点样本;又或者你就是单纯想让模型更鲁棒,别一换说法就识别不出来。这篇文章会把这 4 种方法的原理、代码、坑全部捋一遍,照着抄就行。
2. 动手之前先想清楚:什么场景才真正需要增广
先泼一盆冷水:不是所有项目都需要数据增广。如果你的原始数据已经有大几万条,模型是分类任务且类别分布均衡,那增广带来的收益可能非常有限,反而增加训练时间和过拟合风险。增广真正有价值的场景,我总结下来大概是下面这三类:
第一类是标注成本高的任务。比如命名实体识别、关系抽取、医疗文本分类,这些任务找人来标,一条数据的成本可能高达几块钱甚至几十块。你手里可能只有两三千条标注数据,想训练一个稍大点的预训练模型(比如 BERT-base),那数据量是明显不够的。这种时候增广的价值就非常直接:让几百条数据发挥几千条的效果。
第二类是类别不平衡明显的场景。二分类任务里正样本只占 5%,负样本占 95%,常见做法是过采样少数类,但过采样只是重复原样本,模型容易记住那几条样本的“长相”。增广的价值在于:换一种说法保留同样的标签,让模型学到的是类别的本质特征,而不是个别样本的表面模式。
第三类是鲁棒性敏感的场景。例如在线客服系统里的意图识别,用户说话千奇百怪,“我东西没到”和“我的包裹怎么还没送来”和“物流是不是卡住了”,在意图层面是一回事,在字面上差别巨大。如果训练数据里只有一种说法,模型部署后就特别脆。增广能让你在训练时就模拟出这种表达多样性。
想清楚自己属于哪一类,再往下看方法的选型。要是你就是想“多点数据总觉得安心”,那我建议先停一下,拿一版增广前和增广后的模型跑一遍对比测试,用数据说话,别凭感觉决定。
3. 四种常用增广方法的原理与实操
3.1 同义词替换:最基础也最容易失控的方法
同义词替换的思路一句话就能说清:把句子里的某些词,换成它的同义词或近义词,得到一个语义一致的新句子。
比如“这部电影真的非常精彩”,把“精彩”换成“出色”或者“好看”,训练语料就多了一种表达方式。这个方法源自 EDA(Easy Data Augmentation)那篇论文,原文用的是 WordNet 同义词库,但实操里遇到两个问题:一是 WordNet 对中文支持极差,二是它不考虑上下文,同一个词在不同语境下意思可能完全不同,“苹果好吃”和“苹果系统流畅”里的“苹果”根本不是一个东西。
一个更可靠的做法是借助预训练语言模型来选替换词。Transformers 生态里的pipeline("fill-mask")就是干这个的。你给模型一个带[MASK]的句子,让它预测这个位置最可能的几个词,然后从候选项里挑一个和原词含义相近的填回去。
这里贴一段我实际用过的代码:
from transformers import pipeline unmasker = pipeline( "fill-mask", model="bert-base-chinese", top_k=20 ) def synonym_replace(sentence, mask_index=0): tokens = sentence.split(" ") target_word = tokens[mask_index] masked_sentence = " ".join( tokens[:mask_index] + ["[MASK]"] + tokens[mask_index + 1:] ) candidates = unmasker(masked_sentence) # 过滤掉和原词完全一样的,剩下的按概率取 replaced = [item["token_str"] for item in candidates if item["token_str"] != target_word] if not replaced: return None tokens[mask_index] = replaced[0] return " ".join(tokens) sentence = "这家 餐厅 的 菜品 非常 美味" print(synonym_replace(sentence, mask_index=5))注意一个细节:中文文本必须先分词,BERT 的 tokenizer 会把“美味”拆成“美”和“味”这种子词,如果你直接对原始字符串做替换,token 对不上,出来的结果会很奇怪。我一开始就是偷懒直接传整句,结果fill-mask返回的都是单字,根本没法用。
这个方法的主要问题在“度”的控制上。如果一句话里 1/3 的词都被替换了,句子可能变得很别扭;如果只换一个词,增广强度又不够。业内常见做法是:根据句子长度,按比例随机选词替换,短句换 1 个,中等句子换 1-2 个,长句最多换 3 个,不要贪多。
3.2 随机插入与随机删除:成本最低、效果看天
随机插入和随机删除是 EDA 里的另外两个基础操作。随机插入是在句子里随机位置插入一个“合理的词”;随机删除是按一定概率删掉句子里的词。
这两个方法听起来简单到不值一提,但实际用起来效果很有意思:随机删除在多数分类任务上的表现往往比随机插入更稳。为什么?因为删除词会迫使模型更关注剩余的关键词,类似于一种隐式的 dropout 正则。插入操作如果插入的词语义关联太弱,反而会引入噪声。当然,插入时如果你用同义词库或语言模型挑选“和上下文相关性较高的词”,效果会更好。
下面是一段配合 Transformers 生态实现的随机删除代码:
import random def random_delete(sentence, delete_prob=0.1): words = sentence.split(" ") # 保证至少保留一个词,别删空了 if len(words) <= 1: return sentence new_words = [ word for word in words if random.random() > delete_prob ] if not new_words: # 万一全删完了,随机保一个 new_words = [random.choice(words)] return " ".join(new_words) sentence = "这家 餐厅 的 菜品 非常 美味" for _ in range(3): print(random_delete(sentence, delete_prob=0.2))实操经验里有一个重要提醒:删除概率不要超过 0.3。我做过一组对比实验,当删除概率从 0.1 升到 0.3 时,文本分类的 F1 值先升后降,0.3 以上基本就是纯粹破坏语义。而且要注意,像“不”、“没”、“非”这类否定词,尽量别删。删了“我不喜欢”变成“我喜欢”,标签反转,这就不是增广,是在制造脏数据。
这也是为什么我强调要加一个“保护词表”的思路:
protected_words = {"不", "没", "没有", "非", "无", "很", "非常"} def safe_random_delete(sentence, delete_prob=0.1): words = sentence.split(" ") if len(words) <= 1: return sentence new_words = [] for word in words: if word in protected_words: new_words.append(word) continue if random.random() > delete_prob: new_words.append(word) if not new_words: new_words = [random.choice(words)] return " ".join(new_words)3.3 回译:效果稳定的“万金油”
回译是 NLP 增广里公认质量较高的方法。思路很简单:把原句翻译成另一种语言,再翻译回原语言。因为两种语言的表达习惯不同,回来的句子天然会换一种说法,但大概率保留原意。
比如“这家餐厅的菜品非常美味”,先翻译成英语变成 "The food at this restaurant is very delicious",再翻译回中文,可能变成“这家餐厅的食物很好吃”。瞧,词换了,句式也换了,但意思还在。
这在 Transformers 生态里怎么实现?两个核心步骤:
- 用
pipeline("translation", model="Helsinki-NLP/opus-mt-zh-en")做中译英 - 用
pipeline("translation", model="Helsinki-NLP/opus-mt-en-zh")做英译中
代码参考:
from transformers import pipeline zh_to_en = pipeline( "translation", model="Helsinki-NLP/opus-mt-zh-en" ) en_to_zh = pipeline( "translation", model="Helsinki-NLP/opus-mt-en-zh" ) def back_translate(sentence): en_text = zh_to_en(sentence)[0]["translation_text"] zh_text = en_to_zh(en_text)[0]["translation_text"] return zh_text sentence = "这家餐厅的菜品非常美味" for i in range(3): print(back_translate(sentence))注意几个坑:
第一,回译结果有一定随机性。使用同样的模型和输入,多次执行结果可能不完全一样(beam search 等解码策略会影响输出)。这不是 bug,增广要的就是这种多样性,你可以利用这一点生成多条增广样本。
第二,回译需要过滤掉和原句完全相同的输出。尤其是一些短句,“你好”翻成英文再翻回来大概率还是“你好”,这种就去掉,避免重复样本浪费训练开销。
第三,不要选太长的句子直接回译。句子超过 30 个词时,翻译模型的错误率明显上升,回来可能面目全非。长句建议先按标点或子句切分,分段回译再拼接,质量会稳很多。
相比同义词替换,回译有一个很大的优势:它不只替换词,还会改变语序和句式结构,增广出来的样本多样性明显更高。缺点是慢,依赖外部翻译模型的推理时间。我的建议是:增广量大的场景,提前把回译结果批处理离线生成,不要放在训练循环里实时做,不然训练速度会让你怀疑人生。
3.4 基于生成模型的文本改写:最灵活但最“吃”资源的方法
如果你已经接受了 Transformers 生态,大概率也听说过 T5 或 GPT 这类生成模型。把它们用在增广上,核心思路是:把原始句子输入给生成模型,用 prompt 让模型生成一个“语义相同但表达不同”的句子。
这个方法的灵活性是前面几种没法比的。同义词替换只能改词,回译只能依赖翻译,而生成式改写可以改句式、改语序、改风格、改详略,甚至可以把短句扩写成带更多上下文的长句。对于对话系统、生成式任务的数据扩充,这个方法几乎是唯一能够提供高质量多样性的方案。
以中文场景为例,一个简单的 prompt 写法:
改写下面的句子,保持意思不变,但换一种表达方式: 原始句子:这家餐厅的菜品非常美味。 改写结果:配合 Transformers 的生成 pipeline:
from transformers import pipeline generator = pipeline( "text2text-generation", model="mengzi-ftp/mengzi-t5-base" ) def generate_rewrite(sentence): prompt = ( "改写下面的句子,保持意思不变,但换一种表达方式:\n" f"原始句子:{sentence}\n" "改写结果:" ) result = generator( prompt, max_length=64, num_return_sequences=3, do_sample=True, top_p=0.9 ) return [item["generated_text"] for item in result] sentence = "这家餐厅的菜品非常美味" print(generate_rewrite(sentence))有一个参数值得专门说:num_return_sequences。这是控制一次生成多少条改写结果的关键。如果只取 1 条,效果往往和原句差别不大;取 3-5 条时,多样性会明显提升,但也会出现质量参差不齐的情况。所以我建议生成之后加一道过滤规则:和原句字符级相似度过高的不要,完全驴唇不对马嘴的不要。
实际操作中,我更喜欢把生成式改写纳入一个更完整的流程:
- 先用规则判断句子长度,太短的句子不适合改写(信息量太少,生成容易跑偏)
- 用生成模型产出 5 条候选
- 用一个小模型或者相似度算法(比如句向量 cosine similarity)筛出和原句最相近但不相同的 2-3 条
- 人工抽检一小批,确认筛选阈值合理
这个流程成本较高,但效果是真的好。如果你的任务重要且项目周期允许,值得投入。
4. 四种方法放在一起怎么选
聊到这儿,很多人会问:到底用哪个?我的建议是别只用一种,也别全用。先看一张对比表:
| 方法 | 多样性 | 语义保持度 | 速度 | 实现成本 | 适用场景 |
|---|---|---|---|---|---|
| 同义词替换 | 低 | 中 | 快 | 低 | 分类任务、快速扩充 |
| 随机插入/删除 | 低 | 中低 | 极快 | 极低 | 鲁棒性提升、简单诊断 |
| 回译 | 中 | 高 | 中慢 | 中 | 大部分通用 NLP 任务 |
| 生成式改写 | 高 | 高(需审核) | 慢 | 高 | 对话、生成、小样本场景 |
实际项目里有三种组合法我试下来最顺手:
方案 A:快速起步型。相同领域任务、时间紧迫,直接用同义词替换 + 随机删除,批量生成,成本最低。
方案 B:均衡稳健型。大多数实际项目的最优解。用回译作为主增广,再叠加少量同义词替换增加局部扰动,质量和成本都比较平衡。
方案 C:高质量攻坚型。数据量极少但任务重要的场景。以生成式改写为主,配合回译生成候选池,再结合人工筛选或模型打分会话保留高质量样本。
选择的时候还要看你任务的下游模型。如果下游是 BERT 这类 encoder-only 模型,生成式改写带来的收益最直接;如果是 BART 这类 seq2seq 模型,回译的效果通常更稳。没有绝对的最优,每个项目跑一轮对比实验,让指标说话。
5. 实操中的常见坑与排查建议
这里集中整理我在实操中踩过、以及帮别人排查过的典型问题,每一条都对应了具体的解决方法。
坑一:增广后的数据全部和原句重复
这个问题最容易出现在回译和生成式改写里。原因通常是解码参数里没有引入随机性(比如do_sample=False、beam_search固定),输出每次都相同。解决办法是设置do_sample=True并配合top_p或temperature随机采样。
坑二:中文分词不一致导致 token 替换失败
我在同义词替换那节提过,中文用 BERT 相关模型时,tokenizer 会做 subword 切分,直接按空格切词和模型内部的 token 映射对不上。建议定义一个“词边界匹配”的逻辑,先分好词,再逐个词去模型里查tokenizer.convert_tokens_to_ids,能查到且不是[UNK]的才作为候选替换位。
坑三:增广样本破坏标签
这个最隐蔽。比如否定词被删、专有名词被替换成不相关词,都会让样本的实际语义偏离原始标签。我的经验是:增广后的数据要做一个快速的一致性检查,比如用一个已经训练好的模型对增广样本做预测,如果预测标签和原标签不一致,这批样本要么丢弃要么人工复核。
坑四:增广比例过大导致过拟合反而变严重
增广量不是越大越好。我从实践中得到的一个参考范围:增广样本量控制在原始数据的 1 到 3 倍之间,效果最理想。超过 5 倍,模型开始出现明显的重复样本记忆现象。
坑五:生成式改写产生与原文相似度极低的“自由发挥”样本
常见于 prompt 写得过于开放的时候。给模型的指令越具体,它越不容易跑偏。如果还是出现离谱样本,就加一层相似度过滤:用句向量计算和原句的 cosine 相似度,低于 0.7 的样本直接丢弃。
坑六:回译引入不符合目标领域表达的洋腔洋调
比如剧本对话、口语化文本,回译后可能变得书面化。处理方式是在回译后,再用一个领域内的小模型做风格迁移或人工抽检。短期看成本增加,长期看数据质量高了,模型迭代省下的时间远大于这部分投入。
6. 增广质量怎么评估
很多人增广完直接合并数据训练,不检查质量,这是不对的。我建议至少做三件事。
第一,手动抽检。从增广后的数据里随机抽 50-100 条,人工看一遍语义是否保持、表达是否自然。这个成本很小,但能发现很多规则无法量化的“别扭感”问题。
第二,加一个小规模测试集。从原始数据中留出原始标注测试集不动,分别用“原数据训练”和“原数据+增广训练”跑一遍测试集指标,对比准确率/F1 的提升幅度。如果增广后指标反而下降,说明增广方案有问题,别急着微调模型,先回头查数据。
第三,做消融实验。如果你用了两种以上增广方法,可以分别尝试“只有 A 增广”“只有 B 增广”“A+B 增广”三种配置,直观看到每种方法的贡献度。这能帮你后续省下很多不必要的增广成本。
7. 最后想说的几句实话
数据增广这件事,看着简单,做起来全是细节。同一套方法,有人用来提升 2 个点,有人把模型搞崩了。差别往往不在代码,而在筛选规则、在质量把控、在适可而止。
我个人在做了几轮增广实验后,最深的体会是:增广不是技术问题,是“度”的把握问题。方法只是工具,真正值钱的是你判断一条增广样本“该不该留”的能力。这种能力没有捷径,就是多跑实验、多抽检、多对比。
如果你也是第一次在自己的项目里引入 NLP 数据增广,我建议从小处着手:先用回译或同义词替换,在小数据集上跑一个完整的对比实验,看看增益到底有多大,再决定要不要上生成式改写这种重方法。毕竟增广本质上是为你服务,不是让你为增广服务。