文本特征处理与文本数据增强,这两块在很多项目里被当作“预处理杂活”随手带过,但真正上线过的同学应该都有体会:模型效果卡住不动,回头一查,八成问题出在这一层。同一份原始语料,特征处理方式换一换,分类 F1 能差出五到八个点;数据增强做对了是小样本场景的救命稻草,做歪了就是往训练集里灌噪声,越训越差。这篇内容把这两个环节拆开讲透,从清洗、分词、统计特征、稠密表示,到规则增强、回译、表示层扰动和对抗训练,配上可以直接跑起来的代码和我在实际项目里踩过的坑。适合刚接触文本任务、想搭一条靠谱流水线的同学,也适合做了几个项目但总觉得效果“差一口气”、想回头补基础的老手。
1. 先把这条流水线的骨架搭清楚
1.1 文本和图像到底差在哪,决定了处理思路
很多人把文本增强直接类比成图像增强,觉得旋转、裁剪、加噪声那一套平移过来就行,结果一上手就翻车。根本原因在于文本是离散符号序列,一个字符的改动就可能让语义整体翻转,“不喜欢”删掉一个“不”字,标签直接从负面跳到正面;图像上把猫旋转十五度还是猫,像素层面的扰动对语义的破坏是连续的、渐进的,而文本是突变的、离散的。这个差异直接决定了两件事:文本增强必须做语义校验和标签校验,不能无脑套用;文本的特征表示也比图像难得多,图像一个像素点就是三个通道的数值,文本一个词得先变成一个几百维甚至上千维的向量,中间隔着分词、词表、上下文依赖好几层。
再往深一层说,文本的特征空间是稀疏且高维的。一份十万条的短文本语料,词表轻松上万,词袋表示下每条样本是一个上万维的稀疏向量,绝大部分位置是零。这种结构下,线性模型反而经常打得过复杂模型,TF-IDF 加逻辑回归的基线在不少业务场景里能顶到 85 分以上,先把这个基线跑出来,你才知道后面的深度模型到底有没有真本事。我见过太多项目跳过基线直接上大模型,最后效果只比随机猜好一点,还找不到问题出在哪。
所以这条流水线的正确顺序应该是:清洗规范化 → 分词与词表构建 → 统计特征 / 稠密特征 → 特征选择与拼接 → 数据增强 → 质量过滤 → 训练与验证。每一步都有明确的输入输出契约,中间任何一步偷懒,后面都得加倍还回来。
1.2 一张图看清各环节的职责边界
我用一张表把每个环节的职责、常见误区和判断标准列出来,方便你对照自查。
| 环节 | 核心职责 | 常见误区 | 判断标准 |
|---|---|---|---|
| 清洗规范化 | 统一编码、去噪、还原可读文本 | 清洗过猛,把有效信号也删了 | 清洗前后抽样对比,语义不变 |
| 分词与词表 | 切分粒度合理,词典覆盖领域词 | 只用通用词典,专业词全被切碎 | 领域词切分准确率抽查 |
| 统计特征 | TF-IDF、n-gram、长度类特征 | 忽略停用词在不同任务的差异 | 特征重要性排序是否合理 |
| 稠密表示 | 词向量、句向量、上下文向量 | 混用不同来源向量不做归一化 | 相似句余弦相似度是否符合直觉 |
| 特征选择 | 降维、去冗余、控制过拟合 | 在全量数据上做选择导致泄漏 | 选择过程只在训练折内完成 |
| 数据增强 | 扩充样本、平衡长尾 | 增强后不打乱、验证集也增强 | 增强样本与原样本标签一致 |
| 质量过滤 | 剔除低质增强样本 | 只看数量不看质量 | 相似度阈值 + 人工抽检 |
这张表我建议贴在工位上,每次项目开始前过一遍。特别是“特征选择泄漏”这一条,坑了无数人——你在全量数据上算 TF-IDF 的 IDF,再划分训练验证集,等于把验证集的词频信息提前泄露给了模型,指标虚高,上线就掉。正确做法是把向量化器放进 Pipeline,只在训练折上 fit。
2. 文本特征处理:从原始字符串到模型能吃的数字
2.1 清洗与规范化,先保证输入是干净的
清洗这一步看着简单,实际最考验分寸感。核心原则只有一条:去掉的是噪声,不是信号。什么叫噪声?HTML 标签、多余空白、全角半角混用、编码错乱、重复的广告尾巴、无意义的 URL 片段,这些删了不会影响语义。什么叫信号?否定词、程度副词、表情符号、标点连用带来的语气(比如“???”表示强烈质疑),这些在情感、意图类任务里是有信息量的,删了就是自断经脉。
我一般按这个顺序做:先做 Unicode 规范化(NFKC 能把全角字符、各种兼容字符统一成标准形式),再处理结构噪声,最后做空白压缩。代码不长,但能省掉后面一堆麻烦。
import re import unicodedata def normalize_text(text: str) -> str: if not isinstance(text, str): return "" # NFKC 归一化:全角转半角、兼容字符统一 text = unicodedata.normalize("NFKC", text) # 去掉 HTML / XML 标签 text = re.sub(r"<[^>]{1,200}>", " ", text) # 去掉 URL 和邮箱(多数任务里是噪声,NER 等任务要保留则注释掉) text = re.sub(r"https?://\S+|www\.\S+", " ", text) text = re.sub(r"\S+@\S+\.\S+", " ", text) # 连续重复标点压缩,但保留一个(语气信息) text = re.sub(r"([!?。,,.!])\1{2,}", r"\1\1", text) # 空白压缩 text = re.sub(r"\s+", " ", text) return text.strip()注意:URL 和邮箱这两条不是所有任务都该删。做客服工单分类时,用户贴的订单号、链接里往往藏着关键信息;做 NER 时直接把 URL 抹掉等于丢实体。清洗规则一定要跟着任务走,别搞一套万能模板到处套。
还有一个高频问题是重复样本。语料里同一句话出现几十遍的情况太常见了,尤其是爬来的数据。去重不能只做完全匹配,得做近似去重:先按长度分桶,桶内两两算 SimHash 或者 MinHash,汉明距离小于阈值就判为重复。十万条数据做下来通常能砍掉 5% 到 15%,这部分省下来的训练时间很可观,而且能缓解模型对高频句的过拟合。
2.2 分词与子词切分,中文英文走的是两条路
中文分词是绕不过去的坎。通用分词工具对日常文本够用,但一进专业领域就开始乱切:“心肌梗死”切成“心肌/梗死”还算好的,碰到“非小细胞肺癌”这种,通用工具经常切成“非/小/细胞/肺癌”,标签全乱。解决办法是加载领域自定义词典,把业务词表、产品名、缩略语全塞进去,再打开新词发现模式。词典的质量直接决定分词质量,这个投入是值得的。
import jieba jieba.load_userdict("domain_dict.txt") # 每行:词语 [词频] [词性] jieba.suggest_freq(("非", "小细胞肺癌"), tune=True) # 强制成词 def cut(text: str): # HMM=True 开启新词发现,领域词多时建议开着 return [w for w in jieba.lcut(text, HMM=True) if w.strip()]英文侧相对简单,空格切分基本够,但要注意缩写、连字符复合词、带撇号的所属关系。更现代的做法是直接上子词切分(BPE、WordPiece、SentencePiece),好处是彻底解决未登录词问题,词表规模可控,而且和预训练模型的分词器天然对齐。如果你后面要用预训练模型,那分词这一步就别自己另起炉灶了,直接用模型自带的 tokenizer,否则你精心构建的词表和模型词表对不上,embedding 全错位。
这里有个细节值得说:子词切分粒度是可以调的。词表设小一点(比如 8000),切得更碎,泛化好但序列变长,推理变慢;词表设大一点(比如 50000),切得粗,序列短但未登录词处理能力下降。中文场景下我一般把词表控制在 2 万到 3 万之间,配合覆盖率检查——拿验证集统计一下,未登录字符比例超过 1% 就得回头扩词表。
2.3 停用词、n-gram 与统计特征的正确用法
停用词表是另一个被滥用的东西。很多教程上来就给你一份几百词的停用词表让你全删,实际上这非常危险。情感分析里“不”“没”“别”是核心信号,删了模型直接失去判断力;检索任务里“的”“了”确实没用,删掉能提速;短文本分类里停用词占比本来就低,删不删影响不大,反而可能删掉连词导致语义断裂。我的做法是先不删,跑一遍基线,看特征重要性排序,再决定删哪些——用数据说话,别用直觉。
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer # 字符级 n-gram:中文短文本的稳妥选择,避开分词误差 char_vec = TfidfVectorizer( analyzer="char_wb", # 字符级,边界对齐 ngram_range=(1, 3), # 一元到三元,覆盖大部分搭配 min_df=3, # 出现少于3次的丢掉,压噪声 max_df=0.9, # 出现在90%以上文档的丢掉,等价于自动停用词 sublinear_tf=True, # 对数缩放,抑制高频词主导 max_features=200000, ) # 词级 n-gram:需要先分词,适合长文本 word_vec = TfidfVectorizer( tokenizer=cut, token_pattern=None, ngram_range=(1, 2), min_df=2, )min_df和max_df这两个参数,实际作用比停用词表更可靠。max_df=0.9会自动把那些几乎出现在所有文档里的词降权,等于数据驱动的停用词过滤,还不用担心误删否定词——因为否定词的出现频率通常没那么高。sublinear_tf=True也值得开,TF-IDF 里词频是线性的,一个词出现 100 次和出现 10 次,权重差 10 倍,但实际信息量远没有这么大,取个对数更合理。
除了 TF-IDF,还有一批“手工特征”在业务场景里特别管用,而且经常被忽略:文本长度、标点密度、数字占比、大写字母占比、重复字符比例、是否包含特定关键词。这些特征在反垃圾、风控、质量评估这类任务里,往往比语义特征还准。我做过一个内容质量判别,纯手工特征就能到 0.8 的 AUC,加语义特征才推到 0.88。所以别急着上模型,先把这些便宜的信号榨干。
2.4 稠密表示:从词袋到上下文向量
稀疏特征能解决大部分分类问题,但遇到语义相似、字面不同的场景就歇菜了。“手机没电了”和“电池耗尽了”,词袋重叠度极低,模型学不到它们是一回事。这时候就得上稠密向量。
路由大致是这样:数据量小、任务简单,用预训练词向量做平均池化就够;数据量大、任务复杂、需要理解上下文,上预训练语言模型取句向量。词向量层面,word2vec 和 fastText 都是老将,fastText 的优势是天然支持子词,未登录词也能给个像样的向量,中文场景挺实用。用的时候注意两点:一是词向量必须和你的分词粒度对齐,别人用字符级训练的向量你拿去配词级分词,效果肯定打折;二是不同来源的向量不要直接拼接,量纲和分布不一样,先做标准化或者白化。
句向量这边,直接用 CLS 位置的输出做句表示,在很多模型上效果其实一般,因为 CLS 没有经过针对性的对比学习。更稳的做法是对最后一层的 token 向量做注意力池化或者平均池化,平均池化虽然朴素,但在句相似度任务上经常打得过 CLS。如果追求更好的效果,可以用专门的句子向量模型,它们通常经过对比学习训练,语义相似度排序的质量明显更高。
import numpy as np def mean_pooling(last_hidden_state, attention_mask): # last_hidden_state: [B, L, H]; attention_mask: [B, L] mask = attention_mask.unsqueeze(-1).float() summed = (last_hidden_state * mask).sum(dim=1) counts = mask.sum(dim=1).clamp(min=1e-6) return summed / counts def l2_normalize(v): return v / np.clip(np.linalg.norm(v, axis=-1, keepdims=True), 1e-8, None)池化之后一定要做 L2 归一化,再做余弦相似度,否则向量模长会干扰排序。这个细节很小,但我见过不止一个项目因为漏了归一化,相似度检索结果乱七八糟。
2.5 特征选择与降维,别在错误的层级上省事
特征维度上去之后,过拟合和训练慢的问题就来了,这时候需要特征选择。方法分三类:过滤式(卡方、互信息、方差阈值)、包裹式(递归特征消除)、嵌入式(L1 正则、树模型重要性)。文本场景我一般先用过滤式快速砍一刀,卡方检验对高维稀疏特征特别友好,计算快,能迅速把几十万维降到几万维,再用模型自带的重要性做二次筛选。
from sklearn.feature_selection import SelectKBest, chi2 from sklearn.decomposition import TruncatedSVD selector = SelectKBest(chi2, k=30000) X_selected = selector.fit_transform(X_train_tfidf, y_train) # 只在训练集上 fit # 降维:稀疏矩阵用 TruncatedSVD(LSA),不要用 PCA svd = TruncatedSVD(n_components=256, random_state=42) X_dense = svd.fit_transform(X_train_tfidf)注意:稀疏矩阵千万别直接喂给 PCA,会报错或者把内存吃爆,必须用 TruncatedSVD。另外 SVD 的
n_components别设太大,256 到 512 在文本上通常够用,再大收益递减还容易过拟合。
还有一个常被忽略的点:特征拼接时的尺度问题。稀疏 TF-IDF 特征和稠密向量拼在一起,前者取值范围是 0 到 1,后者可能是负几十到正几十,直接 concat 会让稠密特征完全主导。正确做法是对稠密部分做标准化,或者给两部分各加一个可学习的权重。我在一个多模态检索项目里就吃过这个亏,拼完之后稠密特征权重占比 95%,稀疏特征等于没加。
3. 文本数据增强:不是随便造句子
3.1 先想清楚该不该增强,再想怎么增强
数据增强不是万能药,用错场景比不用还糟。判断标准其实很朴素:看你的验证集和训练集的分布差距大不大,看你的类别是不是严重不均衡,看你的数据量是不是真的不够。如果训练集十万条、验证集指标和训练集差不多,那你增强纯属浪费算力;如果训练集只有八百条、长尾类别只有二十条,那增强就是刚需。
还有一个更隐蔽的判据:看模型是不是过拟合到了字面模式。训练集准确率 99%、验证集 85%,且错误集中在同义表达上,说明模型在背句子而不是学语义,这时候增强有效。反过来,如果训练集和验证集都低,那是欠拟合,增强帮不上忙,该去调模型或者加特征。
增强的目标也很重要。分类任务增强是为了提升泛化,追求的是语义不变、表达多样;序列标注任务增强是为了覆盖更多实体上下文,要求实体边界和标签严格对齐;检索/匹配任务增强是为了构造更难的负样本,那方向就完全相反了,要造“看起来像但语义不同”的样本。目标不同,方法选择天差地别,这点后面会细说。
3.2 规则类增强:便宜、可控,但要做质量闸门
规则增强是最容易落地的,四种基本操作——同义词替换、随机插入、随机交换、随机删除,业界常说的 EDA 就是这套。优点是零成本、可复现、语义扰动可控;缺点是同义词表质量决定一切,用一份粗糙的同义词表,替换出来的句子能把你气笑。
我的经验是:同义词表必须领域定制,通用词表只做兜底。比如医疗场景里“好转”和“缓解”可以互换,但“阳性”和“阴性”绝对不能碰;电商场景里“便宜”和“实惠”能换,“正品”和“仿品”换了就是灾难。所以同义词替换一定要配一个“禁替换词表”,把否定词、程度词、领域专有名词、数字、单位全部保护起来。
import random def synonym_replace(tokens, syn_dict, alpha=0.1, protected=None): """alpha: 替换比例;protected: 禁止替换的词集合""" protected = protected or set() n = max(1, int(len(tokens) * alpha)) candidates = [i for i, t in enumerate(tokens) if t not in protected and t in syn_dict] if not candidates: return tokens[:] idxs = random.sample(candidates, min(n, len(candidates))) out = tokens[:] for i in idxs: out[i] = random.choice(syn_dict[tokens[i]]) return out def random_insert(tokens, vocab, n=2, protected=None): protected = protected or set() out = tokens[:] for _ in range(n): pos = random.randint(0, len(out)) out.insert(pos, random.choice(vocab)) return out def random_swap(tokens, n=2): out = tokens[:] for _ in range(n): if len(out) < 2: break i, j = random.sample(range(len(out)), 2) out[i], out[j] = out[j], out[i] return out def random_delete(tokens, alpha=0.1, protected=None): protected = protected or set() keep = [t for t in tokens if t in protected] drop_n = max(1, int(len(tokens) * alpha)) idxs = [i for i, t in enumerate(tokens) if t not in protected] drop_set = set(random.sample(idxs, min(drop_n, len(idxs)))) return [t for i, t in enumerate(tokens) if i not in drop_set]四种操作里,随机插入和随机删除对语义的破坏最大,随机交换次之,同义词替换相对最安全。短文本(少于 15 个字)建议只用同义词替换,长度本身就不够,再删再插容易把句子搞成乱码。长文本可以组合使用,但总扰动比例控制在 10% 到 20% 之间比较稳,超过 30% 质量下降非常明显。
还有一类规则增强经常被低估:模板回填。业务文本往往有固定结构,比如“订单 X 已发货,预计 Y 天送达”,把 X、Y 换成不同的值就能批量造样本,语义完全可控,标签绝对准确。这类增强在意图识别、槽位填充任务里的性价比远高于通用 EDA,值得优先投入。
3.3 回译与生成式改写,质量和成本的权衡
回译的思路是:中文翻成英文,再翻回中文,中间经过一次语义压缩和重构,句式会变但语义大体保留。这个方法的好处是生成的句子通顺度高,坏处是成本不低,而且对专有名词、领域术语的保留能力差——“非小细胞肺癌”翻一圈回来可能变成“非小细胞肺肿瘤”,术语就飘了。所以回译更适合通用领域文本,专业文本要么别用,要么配一个术语回填机制。
生成式改写是近两年更主流的做法,用序列到序列的模型做指令式改写,比如让模型“用不同说法重写这句话,保持语义不变”。质量上限比回译高,可控性也更强(可以通过指令控制改写幅度),但有两个坑必须防:一是幻觉,模型可能悄悄改了事实;二是同质化,同一批样本改出来的句子风格高度相似,多样性反而不如规则方法。
实操建议:生成式增强一定要配一个“语义一致性校验”。简单做法是用句向量算原句和改写句的余弦相似度,低于 0.85 的直接丢弃;严格的可以用一个自然语言推理模型判断两句话是否互相蕴含。这一步能挡掉大部分跑偏样本,别嫌麻烦。
成本上算笔账:规则增强基本零成本,回译按字符计费,生成式按 token 计费。如果预算有限,优先级应该是规则增强 > 模板回填 > 回译 > 生成式。很多项目做完前两步就够用了,不必迷信大模型改写。
3.4 表示层增强与对抗训练,不改变文本本身的花招
前面几种都是在文本层面动手,还有一类方法在向量层面做文章,好处是不用担心生成质量,坏处是可解释性差。
最直接的是在 embedding 上加高斯噪声,模拟输入的微小扰动,配合一致性损失训练,让模型对扰动不敏感。类似于图像里的加噪增强,但文本上加噪要控制幅度,标准差建议在 0.01 到 0.05 之间,太大了语义直接崩掉。另一类是 Mixup 的文本变体,把两个样本的向量按比例插值,标签也按同样比例插值。这个方法在文本分类上有效,但要注意:插值后的向量可能落在没有任何真实语义的区域,所以插值系数一般取 0.2 以内,且只在同类或者相近类之间做,跨类别插值容易产生语义混乱。
对抗训练是另一条路,思路是找让损失最大的扰动方向,在这个方向上做梯度上升,提升模型鲁棒性。FGM 是最轻量的一种,只在 embedding 上加一次扰动,几乎不增加训练成本,效果稳定;PGD 做多步迭代,效果更好但慢好几倍。我的建议是:资源有限就先上 FGM,作为常规训练的正则项,几乎没有副作用。
import torch class FGM: def __init__(self, model, eps=1.0): self.model = model self.eps = eps 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 and not torch.isnan(norm): r_at = self.eps * 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 = {}用的时候在反向传播之后调attack(),再前向一次算对抗损失,最后restore()恢复参数。这三步顺序不能乱,忘了 restore 会让参数永久带扰动,训练直接崩。
3.5 标签保持与增强倍数,最容易翻车的地方
增强最核心的约束是标签必须保持。分类任务相对简单,只要语义不变,标签就沿用原样本。但序列标注任务(NER、分词、槽位填充)就是灾难现场:你删掉一个词,实体的位置索引全变了;你替换了一个实体内部的词,实体边界可能就对不上了;你插入了新词,标签序列得同步插入一个“O”。这些都得在代码里严格处理,不能拿分类任务的增强器直接套。
我的做法是:序列标注任务只做“整体替换”和“实体级替换”,不做随机插入删除。实体级替换是指,把实体 span 整体换成一个同类实体,比如把人名换成另一个人名,标签不变;把地点换成另一个地点,标签不变。这样索引对齐的问题就不存在了。同义词替换也只在非实体位置做,实体位置加白名单保护。这套约束下来,增强效率低了,但基本不会出脏数据。
增强倍数也需要控制。经验值是原始数据的 1 到 2 倍,长尾类别可以到 5 倍,但超过这个量级收益迅速衰减,而且会把增强器的偏差放大。更稳的做法是分阶段:先增强 1 倍,训练一版,看验证集指标;有提升再加到 2 倍;没提升就说明增强方向不对,别硬堆。
还有一个铁律:验证集和测试集绝对不能增强。我见过有人为了“让验证集更充分”把验证集也扩了,结果指标虚高,上线打脸。验证集必须保持原始分布,哪怕是长尾类别样本很少,也只能用分层抽样保证每类至少有几个,不能靠增强凑数。真要评估鲁棒性,单独造一个扰动测试集,和主验证集分开看。
4. 从脏数据到可训练增强集的完整实操
4.1 环境与依赖准备
pip install jieba scikit-learn numpy pandas pip install torch transformers pip install simhash # 近似去重用环境没什么特殊的,注意版本对齐就行。sklearn 的TfidfVectorizer在不同小版本间行为有过细微变化,团队协作时最好锁版本。transformers 的 tokenizer 版本要和模型匹配,混用容易出奇怪的分词结果。
4.2 特征处理流水线的落地写法
整个流程我习惯包成一个类,保证 fit 只在训练集上发生,避免泄漏。
from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression from sklearn.feature_extraction.text import TfidfVectorizer def build_baseline(): return Pipeline([ ("tfidf", TfidfVectorizer( analyzer="char_wb", ngram_range=(1, 3), min_df=3, max_df=0.9, sublinear_tf=True, max_features=200000, )), ("clf", LogisticRegression( C=1.0, max_iter=2000, class_weight="balanced", n_jobs=-1 )), ]) pipe = build_baseline() pipe.fit(X_train, y_train) # 内部只在训练集上 fit 向量化器 score = pipe.score(X_valid, y_valid)这段代码短,但几个参数都是有讲究的。class_weight="balanced"在类别不均衡时几乎是必开项,代价是可能牺牲一点点整体准确率换少数类的召回。C=1.0是默认值,文本高维稀疏场景下经常需要调小到 0.1 到 0.5 来加强正则,具体靠交叉验证定。max_iter调大是因为稀疏特征下 lbfgs 收敛慢,默认 100 经常不收敛还给你一堆警告。
跑完这个基线,你手上就有了一个可靠的参照系。后面所有增强、换模型、调参的操作,都要跟这个数字比。提升不到 1 个点,基本可以认为是噪声,别急着庆祝。
4.3 增强器的实现与组合策略
把前面几个增强函数组装成一个增强器,核心是随机选择策略、控制扰动强度、记录来源便于追溯。
import random class TextAugmenter: def __init__(self, syn_dict, vocab, protected, seed=42): self.syn_dict = syn_dict self.vocab = vocab self.protected = set(protected) random.seed(seed) def augment(self, tokens, ops=("sr", "ri", "rs", "rd"), p=0.3): if random.random() > p: return tokens[:] op = random.choice(ops) if op == "sr": return synonym_replace(tokens, self.syn_dict, 0.1, self.protected) if op == "ri": return random_insert(tokens, self.vocab, 1, self.protected) if op == "rs": return random_swap(tokens, 1) if op == "rd": return random_delete(tokens, 0.1, self.protected) return tokens[:] def batch_augment(self, corpus, labels, times=1): out_x, out_y, out_src = list(corpus), list(labels), ["orig"] * len(corpus) for _ in range(times): for text, label in zip(corpus, labels): toks = cut(text) new_toks = self.augment(toks) if new_toks != toks: out_x.append("".join(new_toks)) out_y.append(label) out_src.append("aug") return out_x, out_y, out_srcout_src这个字段在排查问题时特别好用。当增强后效果反而下降,你能快速筛出增强样本单独看,判断是策略问题还是比例问题。别嫌多写一行代码,省的是后面几个小时的手工排查。
组合策略上,我的默认配置是:短文本(少于 15 字)只开同义词替换,概率 0.3;中等长度(15 到 50 字)开同义词替换和随机交换,概率 0.3;长文本(大于 50 字)四种全开,概率 0.5。这个配置不是最优解,但是个不容易出错的起点,拿去跑一轮再根据验证集结果微调。
4.4 质量校验与效果验证,怎样判断增强有没有用
增强完不能直接扔进训练,得先过一道质量闸门。我一般设三道:
| 校验项 | 方法 | 阈值建议 | 处理方式 |
|---|---|---|---|
| 字面相似度 | Jaccard 或编辑距离 | Jaccard > 0.5 | 低于则丢弃 |
| 语义相似度 | 句向量余弦相似度 | > 0.85 | 低于则丢弃 |
| 标签一致性 | 小模型预测一致性 | 与原标签一致 | 不一致则人工抽检 |
| 长度合理性 | 长度比 | 0.6 ~ 1.5 倍 | 超出则丢弃 |
def jaccard(a, b): sa, sb = set(a), set(b) union = sa | sb if not union: return 0.0 return len(sa & sb) / len(union) def quality_gate(orig, aug, sim_fn, thr=0.85): if not aug.strip(): return False if not (0.6 * len(orig) <= len(aug) <= 1.5 * len(orig)): return False if jaccard(orig, aug) < 0.3: return False if sim_fn(orig, aug) < thr: return False return True三道闸门过一遍,通常会砍掉 20% 到 40% 的增强样本,看起来浪费,但这些样本留下的价值远超数量上的损失。我做过对比,不加闸门的增强在验证集上只涨 0.5 个点,加了闸门能涨 1.8 个点,而且训练更稳定。
最后是效果验证。正确姿势是:用增强后的训练集训练,用原始验证集评估,同时留一个小规模的原始训练子集做对照实验。如果增强后训练集指标暴涨但验证集不动,说明增强样本和真实分布偏差过大;如果验证集涨了但测试集不涨,说明验证集和测试集分布不一致,得回头查数据划分。
5. 踩坑记录与速查表
5.1 常见问题速查
| 现象 | 最可能的原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 验证指标虚高,上线大跌 | 向量化器在全量数据上 fit,信息泄漏 | 检查是否用 Pipeline 包裹 | 改为只在训练折 fit |
| 增强后效果反而下降 | 增强样本语义漂移或标签错位 | 抽样看增强样本 | 加质量闸门,降增强倍数 |
| 少数类召回始终上不去 | 增强只做了多数类 | 统计增强前后的类别分布 | 按类别分层增强 |
| 中文分词把专业词切碎 | 未加载领域词典 | 抽查分词结果 | 加自定义词典 + 强制成词 |
| 句向量相似度算出来怪 | 未做 L2 归一化 | 检查向量模长 | 归一化后再算余弦 |
| 训练不收敛、loss 震荡 | 对抗训练忘了 restore 参数 | 检查 FGM 调用顺序 | attack 后必须 restore |
| 序列标注增强后标签错位 | 用了分类任务的增强器 | 检查增强代码是否重排索引 | 改用实体级整体替换 |
| 长文本分类效果差 | 直接截断丢失信息 | 看截断位置分布 | 分块 + 池化 / 滑窗预测 |
| 手工特征拼接后没效果 | 量纲差异导致被主导 | 看各特征方差 | 标准化或加权拼接 |
| 增强样本高度同质 | 生成式改写温度太低 | 看出来源多样性 | 提高采样温度 / 混用规则增强 |
5.2 几条不外传的经验
第一条,先建立“不做任何增强”的基线,把它的混淆矩阵打印出来,看看错误到底集中在哪些样本上。很多时候你以为需要增强,实际是需要清理脏数据或者调整类别权重。我在一个工单分类项目里,本来准备做大规模回译增强,结果把错分样本拉出来一看,30% 是标签标错了,改完标签直接涨了 4 个点,比任何增强都管用。所以顺序永远是:先查数据质量,再查特征,最后才考虑增强。
第二条,增强的同义词表千万别用网上下载的通用表直接跑。通用表里全是“美丽—漂亮”这种书面词,实际业务语料里根本不会出现。更靠谱的做法是从你自己的语料里挖:用词向量算近邻,取相似度在 0.7 到 0.85 之间的作为候选同义词,再人工过一遍过滤掉反义、上下位关系。这个区间很关键,相似度太高(0.9 以上)基本是同义词,换了等于没换;太低就是语义漂移了。
第三条,如果你的验证集只有几百条,别太相信单次实验结果。同一个配置换个随机种子,指标上下浮动 1 到 2 个点是常事。判断增强有没有用,至少跑三个种子取平均,或者做交叉验证。我吃过这个亏,某次增强涨了 1.2 个点,兴冲冲写了总结,结果换种子复现不出来,白忙一场。
第四条,文本长度这个特征看起来最不起眼,但在很多任务里是最强的单特征。做内容质量判别、垃圾识别、意图分类,长度分布往往直接反映类别。所以预处理阶段一定要保留原始长度信息,别在清洗的时候顺手把短样本过滤掉了——很多短样本恰恰是最难分类的那批,正是模型该学的。
最后分享一个小技巧:增强样本训练时,可以给它们一个略低的样本权重(比如 0.8),让模型知道这些是“二手数据”。这个做法在标签噪声比较大、增强质量参差不齐的场景下特别有效,我一般用 0.7 到 0.9 之间的值,配合学习率稍微降一点,训练曲线会平滑很多。这个参数不用精调,粗粒度试两三个值就够,边际收益很有限,别在这上面耗太久。
真正拉开项目差距的,往往不是用了多新的模型,而是这些预处理和增强环节里一个个不起眼的决定:停用词删不删、max_df 设多少、增强倍数给几倍、质量闸门卡在哪。这些决定没有标准答案,只能靠一次次实验去试,试多了手感自然就来了。