文本语料怎么体检?数据分析、n-gram特征与回译增强
关键词:文本数据分析、句子长度分布、n-gram特征、文本长度规范、回译数据增强、OOV
目录
- 一、为什么「先分析语料再训练」不是一句空话
- [1.1 数据分析到底在回答哪三个问题](#1.1 数据分析到底在回答哪三个问题 "#three-questions")
- [1.2 一个反直觉的开头:定错 cutlen 会毁掉 71.5% 的字符](#1.2 一个反直觉的开头:定错 cutlen 会毁掉 71.5% 的字符 "#cutlen-disaster")
- 二、动手之前:先查三处容易被忽略的硬伤
- [2.1 硬伤一:训练集 14.80% 是完全重复样本](#2.1 硬伤一:训练集 14.80% 是完全重复样本 "#wound-dup")
- [2.2 硬伤二:验证集 36.70% 的文本,训练集里见过](#2.2 硬伤二:验证集 36.70% 的文本,训练集里见过 "#wound-leak")
- [2.3 硬伤三:验证集 24.68% 的词汇是 OOV](#2.3 硬伤三:验证集 24.68% 的词汇是 OOV "#wound-oov")
- [三、标签数量分布:0.950:1 到底算不算不均衡](#三、标签数量分布:0.950:1 到底算不算不均衡 "#label-dist")
- 四、句子长度分布:「20~25」是一个被误读的数字
- [4.1 三种长度口径给出三组完全不同的答案](#4.1 三种长度口径给出三组完全不同的答案 "#length-caliber")
- [4.2 众数是峰值,不是覆盖范围](#4.2 众数是峰值,不是覆盖范围 "#mode-trap")
- [五、把长度分位变成 cutlen:从测量到超参数](#五、把长度分位变成 cutlen:从测量到超参数 "#cutlen-choice")
- [六、正负样本长度散点:那个 3370 字符的异常点](#六、正负样本长度散点:那个 3370 字符的异常点 "#scatter-outlier")
- [七、不同词汇总数统计:12162 与 6857 背后的取舍](#七、不同词汇总数统计:12162 与 6857 背后的取舍 "#vocab-size")
- 八、词频与形容词词云:褒义词为什么会出现在差评里
- [九、n-gram 特征:让「不」和「好」不再被拆开](#九、n-gram 特征:让「不」和「好」不再被拆开 "#ngram")
- [十、文本长度规范:pad_sequences 的四个参数怎么选](#十、文本长度规范:pad_sequences 的四个参数怎么选 "#pad-sequences")
- 十一、回译数据增强:什么时候该用,什么时候不该用
- 十二、常见问题
- [十三、和 AI 大模型开发的关系](#十三、和 AI 大模型开发的关系 "#llm-relation")
- 十四、总结
一、为什么「先分析语料再训练」不是一句空话
几乎所有 NLP 教程都会告诉你「建模之前先做数据分析」,然后就滑过去了。但这件事的真实分量,得靠一个具体数字来掂量。
本篇围绕一份中文应用评论二分类语料展开。为了让你不用额外下载数据集就能跑通,演示代码全部基于一份自带的应用商店评论 样例。整套流程覆盖三块内容:文本数据分析 、文本特征处理 、文本数据增强。
不过我没有按常见教程的目录顺序讲。常见教程的顺序是「标签分布 → 长度分布 → 散点 → 词表 → 词云 → n-gram → 长度规范 → 回译」,我把它重排成了一条排查主线:
先查病 (重复/泄漏/OOV)→ 再体检 (标签、长度、散点、词表、词云)→ 定超参 (cutlen)→ 造特征 (n-gram + padding)→ 不够就造数据(回译)
这么排是有理由的:先查病 这三步常规流程里一个字都没有,但它们决定你后面所有指标是否可信;而 cutlen 的取值本质上是长度分布分析的直接产物,把它留在「特征处理」章节讲,会让人误以为它和经验无关。
1.1 数据分析到底在回答哪三个问题
这一步一般被归纳成三条作用:理解语料、快速检查语料问题、指导超参数选择。这三条里,第三条才是真正值钱的------前两条是描述,第三条是决策。
图一:文本数据分析五问与对应决策

(图一:文本数据分析常见的五张图,每一张都对应一个具体决策,而不只是「看一眼」)
看清楚这层对应关系之后,数据分析就不再是「画五张图交差」,而是五道必须给出数字回答的判断题。
小结:数据分析的产出不是图,是超参数。画完图却答不出「cutlen 取多少」「要不要加权采样」「词表开多大」,等于白画。
1.2 一个反直觉的开头:定错 cutlen 会毁掉 71.5% 的字符
先说结论。我在 2960 行训练语料上跑了一遍核算:
- 语料总字符数:247753
- 若按「长度大部分在 20~25」这种说法设
cutlen=25:- 只有 18.0% 的样本能完整保留
- 被截断丢弃的字符数:177165 ,占总字符的 71.5%
也就是说,一次「照着结论抄参数」的操作,会让你丢掉近四分之三的文本内容。这不是精度下降几个点的问题,是模型根本没看到大部分信息。
这个数字是我写这篇的直接动机。下面从「先查病」开始,一步步把每个数字还原出来。
小结 :cutlen 不是一个可以拍脑袋的超参数,它必须由长度分布的分位数推导,否则代价是字符级的、不可见的。
二、动手之前:先查三处容易被忽略的硬伤
这是本篇和常见讲法差异最大的一节。多数资料默认给你的语料是干净的,但真实语料(包括不少公开示例数据集)往往不是。
以下三项全部是我在配套语料 train.tsv(2960 行)与 dev.tsv(1000 行)上实测的结果。
图二:容易被忽略的三处硬伤

(图二:重复样本、训练/验证泄漏、OOV 三项问题,常规流程很少覆盖,但对指标影响显著)
2.1 硬伤一:训练集 14.80% 是完全重复样本
统计口径:把 sentence 列当成字符串,看有多少行的文本与其他行完全相同。
python
import pandas as pd
# 演示用:应用商店评论二分类语料,0=差评 1=好评
raw = pd.DataFrame(
[
("更新之后一直闪退,根本进不去,差评", 0),
("界面清爽,运行流畅,好评", 1),
("广告太多了,体验很差", 0),
("客服响应快,问题解决了,不错", 1),
("闪退闪退闪退,第三次了,忍不了", 0),
("功能齐全,就是偶尔会有卡顿,希望后续能优化一下", 0),
("很稳定,用了一年没出过问题,值得推荐", 1),
("加载速度太慢,每次打开都要等半天,流量还跑得快", 0),
("操作方便,家里的老人也能轻松上手,非常满意", 1),
("占用内存大,手机发烫,后台还杀不掉", 0),
("整体不错,期待后续优化", 1),
("更新之后反而更卡了,还不如上一个版本好用", 0),
("界面美观,用着舒服", 1),
("登录不上,一直转圈圈,验证码也收不到", 0),
("内容丰富,每天都要打开看看,已经离不开了", 1),
("推送太频繁,关都关不掉,卸载了", 0),
("比同类应用好用,推荐给朋友了", 1),
("问题太多,无法正常使用的情况下只能弃用", 0),
("简洁无广告,非常满意", 1),
("一般般,没什么特别的", 0),
("闪退", 0),
("不错", 1),
("卡顿严重,希望能修复一下,不然真的没法用,已经反馈很多次了,客服也不回复,太失望了", 0),
("很好用,界面干净,功能也齐全,最重要的是没有广告,五星好评", 1),
],
columns=["sentence", "label"],
)
total = len(raw)
unique = raw["sentence"].nunique()
dup_rows = total - unique
print(f"总行数 {total},唯一文本 {unique},重复行 {dup_rows}({dup_rows / total * 100:.2f}%)")
# 标签冲突:同一条文本被打上了不同标签
conflict = raw.groupby("sentence")["label"].nunique()
print(f"标签冲突的文本数:{(conflict > 1).sum()}")
在 2960 行的真实语料上,这段逻辑给出:
yaml
重复行数 438(14.80%)------ 2960 行里只有 2522 行是唯一文本
标签冲突文本 1 条(涉及 2 行,占 0.07%)
标签冲突只有 0.07%,说明标注质量本身没问题;但 14.80% 的重复意味着:你名义上有 2960 条样本,实际信息量只有 2522 条,而且重复的样本会在每个 epoch 里被重复学习,等于给这部分样本隐式加权。
去重之后标签比例也会动:从 0.950:1 变成 0.905:1(1324 : 1198)。
2.2 硬伤二:验证集 36.70% 的文本,训练集里见过
这一条最致命,也最难自查。
python
train_texts = set(train["sentence"].astype(str))
dev_texts = set(dev["sentence"].astype(str))
leak = train_texts & dev_texts
print(f"跨集相同文本 {len(leak)} 条,占验证集的 {len(leak) / len(dev_texts) * 100:.2f}%")
# 再确认这些泄漏样本的标签是否一致(一致说明是切分没去重,不是巧合)
label_of_train = train.drop_duplicates("sentence").set_index("sentence")["label"].to_dict()
hit = dev[dev["sentence"].astype(str).isin(leak)]
same = sum(1 for _, r in hit.iterrows() if label_of_train.get(str(r["sentence"])) == r["label"])
print(f"其中标签一致 {same} 条({same / len(hit) * 100:.1f}%)")
输出:
erlang
跨集相同文本 349 条,占验证集的 36.70%
其中标签一致 349 条(100.0%)
100% 标签一致 这个结果很关键------它排除了「两条不同的评论恰好写得一模一样」的可能性,确认就是切分前没有去重。
后果是:你的验证准确率里,有超过三分之一的样本是模型「背过答案」的。任何基于这个验证集做出的调参结论("我加了 Attention 涨了 2 个点")都不可信。
修法也很直接:先按文本哈希全局去重,再做切分 ;或者用 GroupShuffleSplit 把相同文本强制分到同一侧。
2.3 硬伤三:验证集 24.68% 的词汇是 OOV
常规流程只统计「不同词汇总数」就停了,没有往下走一步。而真正影响泛化的是 OOV(Out-Of-Vocabulary)率:
python
import jieba
from itertools import chain
# chain(*map(...)) 把「每条句子的词列表」摊平成一个大迭代器
train_vocab = set(chain(*map(jieba.lcut, train["sentence"].astype(str))))
dev_vocab = set(chain(*map(jieba.lcut, dev["sentence"].astype(str))))
oov = dev_vocab - train_vocab
print(f"train 词表 {len(train_vocab)},dev 词表 {len(dev_vocab)}")
print(f"OOV {len(oov)} 词,占 dev 词表的 {len(oov) / len(dev_vocab) * 100:.2f}%")
输出:
yaml
train 词表 12162,dev 词表 6857
OOV 1692 词,占 dev 词表的 24.68%
(顺带一提:这两个数能稳定复现,说明统计口径本身没有歧义。)
接近四分之一的验证集词汇,模型在训练时从没见过 ,它们只能统一落到 <UNK>。这意味着你的验证指标天然被压低,而你还以为是模型能力不行。
两条修法:一是用全量语料(train + dev + test)建词表------注意这只在「建词表」这一步合法,绝不能把 dev 的标签信息用进来;二是直接改用字向量或子词切分,中文场景下字级别 embedding 对 OOV 的鲁棒性明显更好。
小结:重复、泄漏、OOV 三项都不在常规流程里,但它们共同决定了「你的指标能不能信」。修它们的成本远低于调模型。
三、标签数量分布:0.950:1 到底算不算不均衡
常规做法是用 sns.countplot(x='label', data=train_data) 画标签分布,然后给出结论「正负样本稍有不均衡,需要调整」。
我把这个「稍」字量化了一下:
python
import seaborn as sns
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"] # Windows 中文显示
plt.rcParams["axes.unicode_minus"] = False
sns.countplot(x="label", data=train_data)
plt.title("训练集标签分布")
plt.show()
实测结果:
| 数据集 | 总行数 | label=0 | label=1 | 少数:多数 |
|---|---|---|---|---|
| train | 2960 | 1518(51.28%) | 1442(48.72%) | 0.950 : 1 |
| dev | 1000 | 482(48.20%) | 518(51.80%) | 0.931 : 1 |
两个数据集的比例都在 0.93 以上 。作为参照,业界通常把 1:10 以下 才叫严重不均衡,需要上过采样/欠采样/类别权重;1:3 以内一般直接忽略。
0.950:1 这个量级,加 class_weight 的收益基本落在噪声里。 更值得注意的是:train 和 dev 的多数类是相反的(train 里 0 多,dev 里 1 多)。如果你的模型在验证集上表现异常好,第一反应不该是"模型强",而该是"是不是泄漏了"------这在上一节已经证实。
另外注意一个易错点:sns.countplot 画的是离散计数。当你要看的是「连续量的分布密度」(比如长度),它就不是最优选,下一节会讲。
小结:标签分布这一步的价值不在于"看一眼均不均",而在于把「不均衡」量化成比例,再判断要不要动手。0.950:1 不需要动手。
四、句子长度分布:「20~25」是一个被误读的数字
这一步最常见的结论是:句子长度范围大部分处于 20~25 之间。这句话被无数笔记原样转载,但它在同一份语料上是站不住的。
4.1 三种长度口径给出三组完全不同的答案
先澄清一件事:「句子长度」至少有三种算法,它们互不相同。
python
import jieba
# 口径 A:字符数 ------ 绝大多数示例代码用的就是这个
train_data["slen"] = list(map(lambda x: len(x), train_data["sentence"]))
# 口径 B:jieba 分词后的词数 ------ 词表统计/词云走这个口径
train_data["tlen"] = train_data["sentence"].map(lambda x: len(jieba.lcut(x)))
# 口径 C:countplot 的峰值(众数)
print(train_data["slen"].value_counts().head(3))
图三:句子长度口径对决

(图三:同一份语料,字符数、jieba 词数、分布峰值三个口径给出三组完全不同的数字)
实测的三个口径(train, 2960 条):
| 口径 | 中位数 | P75 | P90 | P95 | 均值 | 最大值 |
|---|---|---|---|---|---|---|
| A 字符数 | 57 | 102 | 175 | 237 | 83.7 | 3370 |
| B jieba 词数 | 36 | 65 | 110 | 150 | 53.0 | 2173 |
| C 分布峰值 | --- | --- | --- | --- | --- | len=23(65 次) |
字符数 ÷ 词数 ≈ 1.58,这就是中文里一个词平均占 1.58 个字。
注意:三个口径里没有任何一个的集中趋势落在 20~25。字符数中位数 57,词数中位数 36。
4.2 众数是峰值,不是覆盖范围
那「20~25」到底是哪来的?答案在 sns.countplot 的柱子上:
go
出现次数最多的 6 个长度:
len=23 (65 次) len=20 (61 次) len=29 (60 次)
len=31 (52 次) len=21 (51 次) len=25 (51 次)
len=23 是所有长度里出现次数最多的一个 ------它是众数(mode)。这句话想表达的其实是「分布峰值在 20~25 附近」,这个观察本身没错。
错的是把「峰值所在」说成了「大部分所在」。实测:
erlang
20~25 区间的样本数:322,占比 10.88%
[0, 25] 累计样本数:532,占比 17.97%
20~25 只覆盖了 10.88% 的样本,八成以上都不在这个区间。 峰值高,是因为短评集中;但整体分布是一条长尾,中位数在 57,P90 拉到了 175。
这里也顺带说明为什么 sns.displot 要一起用:countplot 对每一个整数长度画一根柱子,长尾部分会变成一片细密毛刺,读不出形状;displot(带 KDE)把长度当连续量处理,能直接看出「峰值在 23、主体在 30~100、长尾拖到 300+」这种结构。离散计数用 countplot,看密度形状用 displot,两者是互补而不是二选一。
小结 :分布的峰值和分布的范围是两件事。countplot 让你看到峰值,displot 让你看到范围,只有分位数表能告诉你 cutlen 该取多少。
五、把长度分位变成 cutlen:从测量到超参数
通常 cutlen 被放在「文本特征处理」这一步讲,取值的说法是「覆盖 90% 左右语料的最短长度」。这句话是对的,但必须换算成具体数字才可执行------换算工具就是分位数。
我把这一步提前到这里,因为它本质上是上一节长度分布的直接产物。
图四:cutlen 取值的覆盖率与代价

(图四:cutlen 每调一档的覆盖率与截断代价,P90=175 是收益/成本曲线的拐点)
完整代价表(train,总字符 247753):
| cutlen | 覆盖率 | 截断丢弃字符 | 丢弃占比 | 需补齐的 padding 量 |
|---|---|---|---|---|
| 25 | 18.0% | 177165 | 71.5% | 3412 |
| 50 | 45.0% | 127699 | 51.5% | 27946 |
| 64 | 56.7% | 107061 | 43.2% | 48748 |
| 100 | 74.6% | 71327 | 28.8% | 119574 |
| 128 | 82.5% | 53825 | 21.7% | 184952 |
| 175(P90) | 90.2% | 35185 | 14.2% | 305432 |
| 237(P95) | 95.0% | 22104 | 8.9% | 475871 |
| 256 | 95.8% | 19526 | 7.9% | 529533 |
这张表要横着读才有用:
- 从 25 到 175 :覆盖率从 18% 跳到 90%,丢弃字符从 71.5% 降到 14.2%。这一段是纯赚。
- 从 175 到 237 :覆盖率只多 4.8 个百分点 ,序列长度却涨了 35% ,padding 量从 30 万涨到 47 万。这一段是亏的。
所以 P90 = 175 就是拐点,这也是「覆盖 90%」这个经验值的数学根据。
值得强调的是右侧那列「需补齐的 padding 量」------很多人只盯着截断损失,忘了补齐也是成本。cutlen 越大,padding 越多,而 padding 位置对 RNN 来说是要一步步跑过去的无效计算,对 Transformer 来说是要被 attention_mask 屏蔽的无效注意力。选 cutlen 时,丢弃的信息量和引入的噪声量必须一起算。
小结:cutlen 不是拍出来的,是从分位数表上读出来的。拐点在 P90,而不是峰值。
六、正负样本长度散点:那个 3370 字符的异常点
散点图一般用 sns.stripplot(y='sentence_length', x='label', data=train_data) 来画,作用是分层看分布------这个视角是 countplot 给不了的。
python
train_data["sentence_length"] = list(map(lambda x: len(x), train_data["sentence"]))
sns.stripplot(y="sentence_length", x="label", data=train_data)
plt.title("正负样本长度散点分布")
plt.show()
有一种流传较广的说法是「正样本有一个接近 3500 长度的异常点」。我用 P99 × 3 做阈值把它捞了出来:
python
thr = train_data["sentence_length"].quantile(0.99) * 3 # 435 * 3 = 1305
outliers = train_data[train_data["sentence_length"] > thr]
print(f"阈值 {thr:.0f},命中 {len(outliers)} 条")
for _, r in outliers.iterrows():
print(r["sentence_length"], r["label"], str(r["sentence"])[:50])
结果:
ini
train: P99*3 = 1305 → 1 条
len=3370 label=1 「此期间预订,入住首日酒店赠送每间房10元洗衣券一张,通过携程预订...」
dev: P99*3 = 1119 → 0 条
数字对上了,而且验证集一条都没有。
再分标签看长度(train):
| label | 样本数 | 均值 | 中位数 | 最大值 |
|---|---|---|---|---|
| 0(差评) | 1518 | 93.1 | 63 | 975 |
| 1(好评) | 1442 | 73.8 | 51 | 3370 |
一个有意思的现象:差评的平均长度(93.1)比好评(73.8)长了 26%,中位数也高(63 vs 51)。这符合直觉------不满意的用户更愿意写详细。这条信息本身就是可用的特征,甚至可以作为弱先验。
关于异常点要不要删:我的建议是不要急着删 。这一条 3370 字符的样本只占 0.03%,删它不会改变任何统计量,但如果你的 cutlen=175,它会被截断成 175 字符------截断本身就是一种处理。真正需要清洗的是「长度异常且标签可疑」的组合,比如一条 3000 字但内容全是重复字符的样本。
小结:散点图的价值在于分层。它告诉你的不是"有没有异常点",而是"异常点集中在哪一类、这一类整体是不是更长"。
七、不同词汇总数统计:12162 与 6857 背后的取舍
统计词表这行代码很短,但信息密度很高:
python
from itertools import chain
import jieba
# map: 每条句子 -> 词列表
# chain(*...): 把 N 个词列表摊平成一个迭代器
# set: 去重得到词表
train_vocab = set(chain(*map(lambda x: jieba.lcut(x), train_data["sentence"])))
print("训练集词汇总数:", len(train_vocab))
输出 12162(train)与 6857(dev),和公开示例给出的结果一致。
这里有两个容易被跳过的点。
第一,chain(*map(...)) 用星号展开是有代价的。 如果语料有 100 万条句子,* 会先把 100 万个列表一次性传给 chain,内存峰值会很难看。更稳的写法是生成器:
python
from itertools import chain
def build_vocab(sentences, tokenizer=jieba.lcut):
"""流式建词表,避免 chain(*map(...)) 的一次性展开"""
vocab = set()
for s in sentences:
vocab.update(tokenizer(str(s)))
return vocab
train_vocab = build_vocab(train_data["sentence"])
语义完全一样,内存占用从 O(N) 个列表降到 O(1)。
第二,词表大小决定 Embedding 层的参数量。 一个 12162 词、128 维的 Embedding 就是 12162 × 128 ≈ 156 万参数 。如果你的训练集只有 2960 条,这个 Embedding 层的参数量是样本量的 500 倍------这才是真正的过拟合风险来源,比模型层数重要得多。
所以看到 12162 这个数字,要立刻问自己三个问题:
- 要不要设
min_freq(比如砍掉只出现 1 次的词)?实测这类 hapax 词通常占词表的一半以上。 - 词表要不要用全量语料建,以压低 OOV(见 2.3 节,OOV 率 24.68%)。
- 能不能退到字级别?中文常用字 3500 个,字表比词表小一个量级,且天然无 OOV。
小结:词汇总数不是一个统计数字,它是 Embedding 层的参数预算,也是 OOV 风险的直接来源。
八、词频与形容词词云:褒义词为什么会出现在差评里
这一步有个很聪明的做法:不看所有词,只看形容词。
python
import jieba.posseg as pseg
from wordcloud import WordCloud
def get_adj_list(text):
"""用词性标注筛出形容词:flag == 'a'"""
return [g.word for g in pseg.lcut(text) if g.flag == "a"]
def draw_cloud(texts, title):
words = []
for t in texts:
words.extend(get_adj_list(str(t)))
wc = WordCloud(
font_path="./SimHei.ttf", # 中文词云必须指定中文字体,否则全是方块
max_words=100,
background_color="white",
).generate(" ".join(words))
plt.imshow(wc)
plt.axis("off")
plt.title(title)
plt.show()
draw_cloud(train_data[train_data["label"] == 1]["sentence"], "好评形容词词云")
draw_cloud(train_data[train_data["label"] == 0]["sentence"], "差评形容词词云")
只筛形容词的理由很实在:中文评论的情感主要由形容词承载,全量词云会被「的」「了」「酒店」这类无情感词淹没。
有一个流传较广的观察:负样本的词云里也存在「豪华」这样的褒义词 。我当时觉得这只是个孤例,实测之后发现它是系统性的。
图五:褒贬形容词交叉污染

(图五:正负样本高频形容词 TOP60 有 32 个是重合的,「不错」在差评里出现 119 次)
实测高频形容词(train,jieba 词性 a,过滤单字):
| 好评 TOP6 | 差评 TOP6 | ||
|---|---|---|---|
| 1 | 不错(791) | 1 | 一般(187) |
| 2 | 方便(316) | 2 | 不错(119) |
| 3 | 干净(179) | 3 | 方便(105) |
| 4 | 一般(136) | 4 | 干净(68) |
| 5 | 很大(81) | 5 | 最差(66) |
| 6 | 舒服(57) | 6 | 很小(54) |
正负样本高频形容词 TOP60 的交集有 32 个。 具体看几个:
- 「不错」:好评 791 次,差评 119 次
- 「豪华」:好评 55 次,差评 49 次(几乎持平)
- 「一般」:好评 136 次,差评 187 次(差评反而更多)
为什么?因为真实评论是转折结构 :「房间很豪华,但是服务太差了」。形容词本身是中性的,情感由转折词和否定词决定。
这个发现直接推出两个工程结论:
- 词袋模型(BoW)在这份语料上天花板有限。把「不错」当一个特征,它对两个类别的区分度接近于零。
- 必须引入能捕捉词序或搭配的特征------这正是下一节 n-gram 存在的理由。
小结 :词云在这儿的用途不是"好看",是查标签噪声和排查无效特征。看到褒义词出现在差评里,先别急着怀疑标注,先想想模型能不能看到转折。
九、n-gram 特征:让「不」和「好」不再被拆开
上一节证明了单看形容词分不清褒贬。n-gram 就是针对这个问题的:把相邻的 n 个词绑成一个特征,让「不好」不再被拆成「不」+「好」。
n-gram 有个很短的实现,用了个漂亮技巧:
python
def create_ngram_set(input_list, ngram_range=2):
"""
把序列切成所有长度为 ngram_range 的连续片段,返回去重后的集合。
例:input_list = [1, 3, 2, 1, 5, 3],ngram_range=2
-> {(1,3), (3,2), (2,1), (1,5), (5,3)}
"""
return set(zip(*[input_list[i:] for i in range(ngram_range)]))
拆开看 zip(*[input_list[i:] for i in range(2)]):
scss
input_list[0:] = [1, 3, 2, 1, 5, 3]
input_list[1:] = [3, 2, 1, 5, 3]
zip 逐个对齐 -> (1,3) (3,2) (2,1) (1,5) (5,3)
^ 最后一项 3 没有配对,被 zip 自动丢弃
zip 以最短的输入为准截断,所以末尾的孤立项被自然丢掉,不需要额外处理边界。这是这段代码最值得记住的一点。
在应用评论场景里套用:
python
import jieba
NEGATORS = {"不", "没", "无", "别", "不太", "不够"}
class NGramSentimentFeature:
"""应用评论的 n-gram 情感特征抽取器(演示用,不依赖深度学习框架)"""
def __init__(self, ngram_range=2, keep_negation=True):
self.ngram_range = ngram_range
self.keep_negation = keep_negation
self.vocab_ = {} # n-gram -> 特征 id
self.fitted_ = False
def _tokenize(self, text):
return [w for w in jieba.lcut(str(text)) if w.strip()]
def _grams(self, tokens):
# 与上面的写法同构:zip(*[tokens[i:] for i in range(n)]),末尾孤立项由 zip 自动丢弃
return set(zip(*[tokens[i:] for i in range(self.ngram_range)]))
def fit(self, texts):
gram_set = set()
for t in texts:
gram_set |= self._grams(self._tokenize(t))
# 保留含否定词的二元组 ------ 它们是情感反转的关键信号
if self.keep_negation:
gram_set |= {
g for g in gram_set if any(tok in NEGATORS for tok in g)
}
self.vocab_ = {g: i for i, g in enumerate(sorted(gram_set))}
self.fitted_ = True
return self
def transform(self, texts):
"""输出每条文本命中的 n-gram 特征 id 列表(稀疏表示)"""
out = []
for t in texts:
ids = [
self.vocab_[g]
for g in self._grams(self._tokenize(t))
if g in self.vocab_
]
out.append(sorted(ids))
return out
@property
def n_features(self):
return len(self.vocab_)
feat = NGramSentimentFeature(ngram_range=2).fit(raw["sentence"])
print(f"二元组特征数:{feat.n_features}")
print("示例命中:", feat.transform(["更新之后一直闪退,根本进不去"]))
n 取多少?经验区间是 2~3:
- n=1(unigram):就是词袋,解决不了上一节的问题。
- n=2(bigram):能抓住「不好」「不错」「卡顿」「闪退」,性价比最高。
- n=3(trigram):特征数会爆炸式增长,2960 条样本撑不住,稀疏到几乎学不到东西。
代价也要说清楚:n 每加 1,特征空间就是平方级到立方级 的增长。在 2960 条样本上,bigram 特征数轻松破万,训练样本却只有 2960 条------这是一个 p >> n 的问题,必须配合 min_freq 截断或降维使用。
小结:n-gram 用特征维度的爆炸,换取词序信息。在数据规模小的时候,n=2 是最优解。
十、文本长度规范:pad_sequences 的四个参数怎么选
神经网络要求一个 batch 内所有样本长度一致,所以必须把所有序列规范到同一长度。Keras 的 pad_sequences 干的就是这件事,但它的四个参数各有讲究。
python
from keras.preprocessing import sequence
cutlen = 175 # 来自第五节:train 的 P90
x_train = sequence.pad_sequences(
sequences=train_seq, # 每条样本已转成的 id 序列
maxlen=cutlen,
padding="post", # 短了往「后」补 0
truncating="post", # 长了从「后」截断
value=0, # 填充用的值,一般保持 0(0 已预留给 <PAD>)
)
四个参数的取舍:
① maxlen ------ 见第五节,取 P90。这是唯一需要数据推导的参数。
② padding: post 还是 pre?
这是最常被无脑复制的一个参数,但它和模型结构强相关:
- 单向 RNN / LSTM :建议
padding='pre'。RNN 是逐步累积的,序列末尾的状态信息最新鲜;把 padding 放前面,真实内容就落在尾部,最后一步的 hidden state 才是有信息量的。 - Transformer / BERT :用
padding='post'更自然,因为这类模型靠attention_mask屏蔽 padding 位置,位置编码也不依赖「从哪边开始有效」,post 更符合[CLS] 内容 [SEP] [PAD]...的惯例。
常见写法是 post,对 TextCNN / Transformer 这类结构没问题;如果你接下来要接 RNN,记得换成 pre。
③ truncating: post 还是 pre?
post:保留开头,砍掉结尾。pre:保留结尾,砍掉开头。
中文评论通常是先叙事后下结论 ("房间不错,但服务太差,不推荐"),结论在尾部,所以 truncating='pre' 理论上更能保住情感信号。但常见写法是 post,在 cutlen=175 已经覆盖 90% 样本的前提下,两者差异很小------这也是选对 cutlen 的另一个好处:它让截断方向的争议变得不重要。
④ value ------ 保持 0 即可,但前提是你的词表已经把 0 预留给了 <PAD>。如果 0 被某个真实词占用了,padding 会悄悄污染样本,而且完全不报错。这是新手最常踩的静默 bug。
小结 :maxlen 靠数据定,padding 靠模型定,truncating 靠语感定但影响有限,value 必须和词表的预留位对齐。
十一、回译数据增强:什么时候该用,什么时候不该用
样本不够时,回译(Back Translation)是成本最低的中文文本增强手段:中文 → 中间语言 → 中文,得到语义相近但表述不同的新样本。
python
import hashlib
import time
import requests
YOUDAO_URL = "http://fanyi.youdao.com/translate"
class BackTranslator:
"""有道翻译回译增强器(演示骨架,生产环境请换成带鉴权的官方 SDK)"""
def __init__(self, chain_langs=("zh-CHS", "ko", "zh-CHS"), max_hops=3):
# 中间语言链:中 -> 韩 -> 中。一般建议连续翻译不超过 3 次
self.chain = chain_langs
self.max_hops = min(max_hops, 3) # 硬性封顶,防语义漂移
self.cache = {}
def _translate_once(self, text, from_lang, to_lang):
payload = {
"doctype": "json",
"type": f"{from_lang}2{to_lang}",
"i": text,
}
# 简单的失败重试:翻译 API 是外部依赖,必须假定它会挂
for attempt in range(3):
try:
r = requests.post(YOUDAO_URL, data=payload, timeout=5)
r.raise_for_status()
return r.json()["translateResult"][0][0]["tgt"]
except Exception:
if attempt == 2:
return None
time.sleep(0.5 * (attempt + 1))
return None
def augment(self, text):
"""返回回译后的文本;任一步失败或结果与原句相同则返回 None"""
key = hashlib.md5(text.encode()).hexdigest()
if key in self.cache:
return self.cache[key]
cur = text
for src, tgt in zip(self.chain[:-1], self.chain[1:]):
cur = self._translate_once(cur, src, tgt)
if cur is None:
return None
# 短文本回译极易「翻译回来了」,必须显式去重
result = None if cur.strip() == text.strip() else cur
self.cache[key] = result
return result
bt = BackTranslator()
for s in raw["sentence"].head(8):
new = bt.augment(s)
print(f"原句:{s}\n回译:{new}\n")
三个必须知道的限制:
① 短文本重复率高。 「不错」这种两字文本,翻译成韩语再翻回来大概率还是「不错」------增强了个寂寞。所以代码里加了 cur.strip() == text.strip() 的判断,重复的直接丢弃 。回译对长度在 15 字以上的样本才有稳定收益,这也和第四节的实测互相印证:这份语料中位数是 57 字符,主体样本是够长的。
② 连续翻译次数不超过 3 次。 每多一跳,语义就漂移一次。中→韩→中 是 2 跳;中→韩→日→英→中 是 4 跳,已经越过安全线,会出现「房间很干净」变成「这个住所整洁」这种虽然不算错但语域明显跑偏的结果。代码里用 min(max_hops, 3) 做了硬封顶。
③ 回译改变的是表述,不是标签。 它无法修复第八节发现的标签噪声------如果原句标注错了,回译出来的四条还是错的。更糟的是,回译会放大 这类噪声:一条错标样本变成五条错标样本。所以先清洗,再增强,顺序不能反。
什么时候不该用回译? 三种情况:样本已经过万(收益被噪声淹没)、任务对句式极其敏感(如语法纠错)、以及你没有可用的翻译 API 配额。
小结:回译是「表述层面」的增强,不是「标签层面」的修复。先清洗去重,再对 15 字以上的样本做 2 跳回译,并用相似度去重兜底。
十二、常见问题
Q1:sns.countplot 和 sns.displot,画长度到底该用哪个?
两个都画,因为它们在回答不同的问题。
countplot 把长度当离散类别,每个整数长度一根柱子。它的优点是能看出「哪个长度最多」------第四节的众数 23 就是这么来的。缺点是长尾部分会变成一片看不清的细毛刺。
displot(或 displot(kind='kde'))把长度当连续量,画出密度曲线。它能直接告诉你分布的形状:峰值在哪、主体区间在哪、长尾有多长------但它会平滑掉「23 这个具体整数出现 65 次」这样的细节。
结论:用 countplot 找众数,用 displot 看范围,用 describe() + quantile() 出分位数表。三者缺一不可。
Q2:为什么我算出来的句子长度,和别人那张图对不上?
最可能的原因是口径不一致,且通常有三种:
- 你用的是
len(text)(字符数),原图可能画的是分词后的词数(字符:词 ≈ 1.58)。 - 你画的是 density 曲线,原图画的是 count 柱状图------两者纵轴含义不同,峰值位置会差。
- 你看的是整份数据,原图可能只画了某个标签的子集(第六节实测:差评中位数 63,好评中位数 51,混在一起是 57)。
排查方法很简单:先 df['slen'].describe() 出中位数和均值,跟图上的峰值比一下。中位数 57 和峰值 23 差了 2.5 倍,这本身就是信号------它说明分布是右偏的,峰值远小于中位数。
Q3:cutlen 取 P90 还是直接取 max,反正 padding 会补齐?
取 max 是个陷阱。 这份语料的 max 是 3370,而 P90 只有 175。取 max 意味着:
- 每条样本都要被 padding 到 3370,序列长度是 P90 方案的 19 倍
- RNN 要跑 19 倍的步数,Transformer 的 attention 矩阵是 361 倍的显存
- 而这 19 倍成本,只换来覆盖率从 90.2% 涨到 100%------其中绝大部分是那条 3370 字符的孤本异常点
反过来,取 P95(237)比 P90 多 4.8 个百分点覆盖率,代价是 35% 的长度增长。P90 是收益/成本曲线的拐点,这也是"覆盖 90%"这个经验值的由来。
Q4:padding='post' 能不能一路用到底?
不能,它和模型结构绑定。
单向 RNN/LSTM 应该用 padding='pre':RNN 的状态是逐步累积的,序列末尾的 hidden state 才包含了全部有效信息。如果 padding 在后面,最后一步的状态就是一堆 0 之后的结果,等于把信息冲淡了。
Transformer/BERT 用 post 没问题 :它们靠 attention_mask 屏蔽 padding,且是并行读取,不存在"最后一步"的概念。
判断方法:如果你的模型要取「最后一个时间步的输出」做分类,用 pre;如果要取「整个序列的池化/CLS」,用 post。
Q5:回译调 API 老是失败/太慢,有没有不用联网的替代方案?
有,但要清楚各自的代价:
- 同义词替换:用词向量或同义词词典随机替换非核心词。零成本,但中文同义词典质量参差,容易造出「房间很清洁」这种不自然的句子。
- EDA(随机插入/删除/交换/替换) :实现简单,但随机删除会直接丢掉否定词,把「不好用」变成「好用」------标签反转,这是致命的。中文场景慎用。
- 模板/规则改写:最可控,但需要人工写模板,规模化成本高。
如果一定要用回译,生产环境的正确做法是:离线批跑 + 结果落盘 + 缓存去重 ,而不是在训练时实时调用。代码里那个 self.cache 就是干这个的。
Q6:验证集准确率很高,一上线就崩,最可能的原因是什么?
按顺序排查三件事:
- 训练/验证泄漏 ------这是第一位。实测这份语料有 36.70% 的验证集文本在训练集里出现过,且标签 100% 一致。检查方法:
len(set(train.text) & set(dev.text))。 - 重复样本------训练集 14.80% 完全重复会让模型反复记忆同一批样本,验证集里的重复则让指标方差被低估。
- OOV ------验证集 24.68% 的词汇训练集没见过。注意这一条会让指标偏低而不是偏高,所以如果你同时存在泄漏(虚高)和 OOV(偏低),两者会互相掩盖,让指标看起来"很正常"。
这三项都不在常规流程里,但它们才是"上线就崩"的高频原因。
十三、和 AI 大模型开发的关系
文本数据分析这套方法论,在大模型时代不但没过时,反而更关键了------因为现在要处理的不再是 2960 条标注语料,而是几十万条微调数据和几百万字的知识库。
场景一:LoRA 微调前的数据集质检
微调数据里的重复和泄漏,比传统任务更致命------大模型记忆能力强,泄漏样本会被"背下来",让评估彻底失效。把第二节那套检查直接搬过来:
python
import hashlib
import pandas as pd
def audit_sft_dataset(df, text_col="instruction", split_col="split"):
"""微调数据集上线前体检:重复率 / 跨集泄漏 / 长度分位
返回 dict,任何一项超标都应该在训练前修掉,而不是训练后解释"""
texts = df[text_col].astype(str)
report = {
"total": len(df),
"unique": texts.nunique(),
"dup_rate": round(1 - texts.nunique() / len(df), 4),
}
# 长度分位:直接决定 max_seq_len(等价于本文的 cutlen)
lens = texts.map(len)
for q in (50, 90, 95, 99):
report[f"p{q}"] = int(lens.quantile(q / 100))
report["max_seq_len_suggest"] = int(lens.quantile(0.90)) # P90 拐点
# 跨集泄漏:按文本哈希比对 train / eval
if split_col in df.columns:
tr = set(df[df[split_col] == "train"][text_col].astype(str).map(
lambda s: hashlib.md5(s.encode()).hexdigest()))
ev = df[df[split_col] == "eval"][text_col].astype(str).map(
lambda s: hashlib.md5(s.encode()).hexdigest())
leak = sum(1 for h in ev if h in tr)
report["leak_cnt"] = leak
report["leak_rate"] = round(leak / max(len(ev), 1), 4)
return report
# df = pd.read_json("sft_data.jsonl", lines=True)
# print(audit_sft_dataset(df))
场景二:RAG 知识库分块前的 chunk_size 推导
RAG 里 chunk_size 的选择,和本文 cutlen 是同一个问题------都是"用分位数推导截断长度",而不是拍脑袋写 512:
python
def suggest_chunk_size(docs, overlap_ratio=0.15, target_cover=0.90):
"""按文档长度分位反推 chunk_size,避免大量文档被硬切碎
逻辑与本文第五节完全一致:找到覆盖 target_cover 文档的最短长度"""
import numpy as np
lens = [len(d) for d in docs]
size = int(np.quantile(lens, target_cover))
return {
"chunk_size": size,
"chunk_overlap": int(size * overlap_ratio),
"cover_rate": round(sum(1 for L in lens if L <= size) / len(lens), 4),
"p50": int(np.median(lens)),
"p95": int(np.quantile(lens, 0.95)),
}
# 实测价值:很多团队默认 chunk_size=512,但文档 P90 可能只有 300,
# 结果是 70% 的 chunk 带大量 padding,检索时 embedding 被稀释
场景三:用户反馈日志的标签分布与异常检测
线上大模型的用户反馈(点赞/点踩、转人工、重复提问),同样适用第三节的标签分布分析和第六节的散点异常检测:
python
def feedback_health(feedback_df, score_col="score", text_col="query"):
"""对话质量反馈体检:分布是否倾斜、差评是否更长(差评更长通常意味着问题更具体)"""
dist = feedback_df[score_col].value_counts(normalize=True).sort_index()
fb = feedback_df.copy()
fb["len"] = fb[text_col].astype(str).map(len)
bad = fb[fb[score_col] == 0]["len"]
good = fb[fb[score_col] == 1]["len"]
return {
"score_dist": dist.round(4).to_dict(),
"imbalance_ratio": round(dist.min() / max(dist.max(), 1e-9), 3), # <0.3 才考虑加权
"bad_len_median": int(bad.median()),
"good_len_median": int(good.median()),
# 差评显著更长 -> 用户愿意描述细节,这批数据是高价值优化素材
"bad_longer": bool(bad.median() > good.median() * 1.2),
}
场景四:用大模型本身做数据增强,替代回译 API
回译依赖外部翻译 API,而大模型可以直接完成"同义改写"------本质相同,但可控性更强(可以明确规定"不要改变情感倾向"):
python
AUGMENT_PROMPT = """你是一个数据增强助手。请对下面的用户评论做同义改写。
严格要求:
1. 保持原始情感倾向不变(差评改写后必须仍是差评)
2. 不要改变具体事实(如"闪退三次"不能改成"闪退两次")
3. 输出 3 个不同表述,每行一个,不要编号,不要解释
原评论:{text}
"""
def llm_augment(client, texts, model="qwen-plus"):
"""用大模型做增强;注意:增强前必须先去重清洗,否则会成倍放大标签噪声"""
results = []
for t in texts:
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": AUGMENT_PROMPT.format(text=t)}],
temperature=0.9, # 提高温度换取表述多样性
)
lines = [x.strip() for x in resp.choices[0].message.content.split("\n") if x.strip()]
results.extend(lines)
return results
四个场景串起来看:文本数据分析在大模型时代的角色,从"建模前的准备"变成了"数据资产的质检流程"。模型能力越强,脏数据的破坏力越大------一个 70B 的模型记住 14.8% 的重复样本,比一个小模型要容易得多。
十四、总结
这一篇把「文本预处理」后半段的三大模块走了一遍,但顺序按排查主线重排过:
文本数据分析
- 标签数量分布 :
sns.countplot→ 实测 0.950:1,属于可忽略的轻微倾斜,不需要加权采样 - 句子长度分布 :
countplot+displot→ 实测 P50=57、P90=175、P95=237;常见说法里的「20~25」其实是众数,只覆盖 10.88% 样本 - 正负样本长度散点 :
sns.stripplot→ 实测仅 1 条超过 P99×3(len=3370,label=1);差评均值 93.1 比好评 73.8 长 26% - 不同词汇总数 :
set(chain(*map(jieba.lcut, ...)))→ 实测 12162 / 6857,与常见教程一致;但 OOV 率 24.68% 很少被提起 - 词频与形容词词云 :
pseg取flag=='a'+WordCloud→ 实测褒贬 TOP60 形容词交集 32 个,「不错」在差评出现 119 次
文本特征处理
- n-gram 特征 :
set(zip(*[tokens[i:] for i in range(n)])),中文取 n=2 性价比最高 - 文本长度规范 :
pad_sequences(maxlen=cutlen),我把cutlen的推导提前到了第五节(取 P90=175),padding方向按模型结构选(RNN 用 pre,Transformer 用 post)
文本数据增强
- 回译数据增强:中→外→中,连续翻译不超过 3 跳,短文本重复率高必须去重,且必须在清洗之后做
以及三处容易被忽略、但必须先修的硬伤:训练集 14.80% 完全重复、验证集 36.70% 泄漏、验证集 24.68% OOV。
如果只能记住一句话,那就记这句:cutlen 取 P90,不取峰值;先查泄漏,再谈调参。
#文本数据分析 #句子长度分布 #n-gram特征 #文本长度规范 #pad_sequences #回译数据增强 #OOV #NLP #AI大模型