NLP文本语料怎么体检?数据分析、n-gram特征与回译增强

文本语料怎么体检?数据分析、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 这个数字,要立刻问自己三个问题:

  1. 要不要设 min_freq(比如砍掉只出现 1 次的词)?实测这类 hapax 词通常占词表的一半以上。
  2. 词表要不要用全量语料建,以压低 OOV(见 2.3 节,OOV 率 24.68%)。
  3. 能不能退到字级别?中文常用字 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 次(差评反而更多)

为什么?因为真实评论是转折结构 :「房间很豪华,但是服务太差了」。形容词本身是中性的,情感由转折词和否定词决定。

这个发现直接推出两个工程结论:

  1. 词袋模型(BoW)在这份语料上天花板有限。把「不错」当一个特征,它对两个类别的区分度接近于零。
  2. 必须引入能捕捉词序或搭配的特征------这正是下一节 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.countplotsns.displot,画长度到底该用哪个?

两个都画,因为它们在回答不同的问题。

countplot 把长度当离散类别,每个整数长度一根柱子。它的优点是能看出「哪个长度最多」------第四节的众数 23 就是这么来的。缺点是长尾部分会变成一片看不清的细毛刺。

displot(或 displot(kind='kde'))把长度当连续量,画出密度曲线。它能直接告诉你分布的形状:峰值在哪、主体区间在哪、长尾有多长------但它会平滑掉「23 这个具体整数出现 65 次」这样的细节。

结论:用 countplot 找众数,用 displot 看范围,用 describe() + quantile() 出分位数表。三者缺一不可。

Q2:为什么我算出来的句子长度,和别人那张图对不上?

最可能的原因是口径不一致,且通常有三种:

  1. 你用的是 len(text)(字符数),原图可能画的是分词后的词数(字符:词 ≈ 1.58)。
  2. 你画的是 density 曲线,原图画的是 count 柱状图------两者纵轴含义不同,峰值位置会差。
  3. 你看的是整份数据,原图可能只画了某个标签的子集(第六节实测:差评中位数 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:验证集准确率很高,一上线就崩,最可能的原因是什么?

按顺序排查三件事:

  1. 训练/验证泄漏 ------这是第一位。实测这份语料有 36.70% 的验证集文本在训练集里出现过,且标签 100% 一致。检查方法:len(set(train.text) & set(dev.text))
  2. 重复样本------训练集 14.80% 完全重复会让模型反复记忆同一批样本,验证集里的重复则让指标方差被低估。
  3. 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% 很少被提起
  • 词频与形容词词云psegflag=='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大模型

相关推荐
Dawson Zhu16 分钟前
从虚拟内存到 Agent 记忆:把大模型的“脑补“变成“查表“
人工智能·语言模型·架构·aigc·agi
Raas10016 分钟前
MAI Gateway(魔芋企业级AI网关)详解:AI网关在架构中的位置,一文读懂企业AI流量治理
大数据·人工智能·架构·gateway·ai网关·mai gateway
天天代码码天天20 分钟前
不用 Paddle、ONNX Runtime,纯 C OCR 现在支持 Android 了
人工智能
V哥AI增长31 分钟前
六大AI引擎引用偏好差异的技术机制与实证分析
人工智能
Mr数据杨32 分钟前
自行车需求预测实战解析 从 Kaggle 回归赛题理解时序建模
人工智能·数据分析·kaggle竞赛
武子康32 分钟前
图像生成为什么需要独立的 Gateway 抽象:参数、重试与幂等设计
人工智能·llm·agent
tachibana232 分钟前
微调和 RAG 各自的优劣势是什么?
人工智能·ai·大模型·llm·agent