一份「数据 → 特征 → 增强」的文本预处理施工手册,上半篇用五个统计动作把语料的毛病看出来(标签均衡、长度分布、语料质量),下半篇把原始文本变成模型能吃的等长数值张量(n-gram、截断补齐),外加回译增强和三个扩展专题(TF+Cosine / SBERT / FastText Subword)。
一、这份材料解决什么问题
day01《自然语言处理入门》回答的是”NLP 是什么、这条技术路线长什么样”;day02 回答的是紧接着的第一个工程问题:原始文本怎么才能喂进模型。
这件事在两个完全不同的技术路线里都是第一步,但做法不同:
| 路线 | 预处理要产出什么 | 本材料的对应章节 |
|---|---|---|
| 传统机器学习(TF-IDF + 随机森林) | 分词后的词序列 → 稀疏数值特征 | 第 1、2 章(数据分析 + 特征处理) |
| 深度学习(FastText / BERT / LLM) | 等长 token id 序列 / 子词序列 | 第 2.2 节(长度规范)、第 7 章(Subword) |
材料开篇给出的定位非常直接:文本数据分析的作用是「帮助理解语料,快速检查出语料可能存在的问题,指导模型训练过程中超参数的选择」。它要解决的其实是四类问题:
- 数据有没有毛病,标签均衡吗?长度分布正常吗?语料里有没有和标签矛盾的内容?
- 超参数怎么定,
max_length取多少?这是从句子长度分布里”读”出来的,不是拍出来的。 - 特征够不够用,词袋丢词序,”喜欢深度学习”和”讨厌深度学习”会被算得很像,靠 n-gram 补。
- 样本够不够,数据量小就做回译增强。
为什么这一遍必须过:这条链路上每一个数字都会被追问。PPT 里出现的 15.6 万词、12162 词表、48/80/70/65/39 这些长度,是”你真的看过数据”的唯一证据;而”停用词表把「不」删掉了”这种坑,是评审者用来区分”跑通过”和”背过”的经典问题。
二、核心结论
- 预处理的第一步不是清洗,是”看见”数据。五个动作:标签数量分布 → 句子长度分布 → 正负样本长度散点 → 词频统计 → 高频形容词词云。前两个定超参,后三个查质量。
- 中文和英文的分词根本不是同一个问题。英文靠空格天然切好;中文没有词间空格,字符序列 c_1c_2\cdots c_n 存在指数级的切分方案,所以分词是一个歧义消解任务。jieba 的三种模式就是三种取舍:精确模式(默认,建模用)、全模式(只求召回,弃)、搜索引擎模式(长词再切,索引用)。
max_length由句子长度分布决定,不是最大值。取”覆盖绝大多数样本”的长度,长截短补;Keraspad_sequences的默认是padding='pre'/truncating='pre',RNN 场景官方建议改post。- n-gram 是给传统机器学习补词序的最小手段:材料里”我 喜欢 深度学习” vs “我 讨厌 深度学习”的 TF+Cos 相似度,用 unigram 是 0.75,加了 2-gram 降到 0.57。材料同时明确了边界:现代深度学习 NLP 流程几乎不用 n-gram 特征,那是 BERT 的活。
- 停用词是双刃剑。它的收益是减维度、降噪声(IDF 只能压低权重、删不掉维度);代价是删错词会让语义翻车,情感分类里「不」「没」「未」一旦被误删,「不满意」就变成「满意」。
- 回译增强最多连翻 3 次。中文→韩文→日语→英文→中文这种多跳能降低重复率,但材料写得很清楚:更多次数会产生语义失真,短文本单次回译的重复率可能高到”并不能有效增大样本”。
三、知识地图
按材料的原始章节层级整理。材料的”学习目标”只写”掌握几种常见方法”,没有逐条标注权重,下表的 ★掌握 / 理解 / 了解 是我按它是否进入第 8 章高频题、第 9 章作业、以及是否被反复用于代码推断的。
day02 文本预处理
├── 1 文本数据分析 ★掌握(第 8 章第 1 题、第 1 章全部代码)
│ ├── 1.1 作用与方法:标签数量分布 / 句子长度分布 / 词频统计与词云
│ ├── 1.2 数据集说明:中文酒店评论 train.tsv(2960,2) + dev.tsv(1000,2),二分类 0/1
│ │ ├── 标签数量分布 sns.countplot —— 查均衡,二分类 ACC 基线 50%、正负 1:1
│ │ ├── 句子长度分布 sns.histplot(bins=50, kde=True) —— 定 max_length
│ │ ├── 正负样本长度散点 sns.stripplot —— 定位异常点做人工审查
│ │ ├── 词频统计 jieba.lcut + chain + Counter —— 词表大小
│ │ └── 高频形容词词云 pseg.lcut 取 flag=="a" + WordCloud —— 查标签矛盾
│ └── 1.3 小结
│
├── 2 文本特征处理 ★掌握(第 8 章第 2、3 题)
│ ├── 2.1 n-gram 特征(1/2/3-gram;滑坡窗口 + zip 实现) ★掌握
│ ├── 2.2 文本长度规范(截断 + PAD 补齐;python 手写 / tf.sequence)★掌握
│ └── 2.3 小结
│
├── 3 文本数据增强 ★掌握(第 8 章第 5 题)
│ ├── 3.1 回译数据增强法(deep_translator / translate) ★掌握
│ │ ├── 优势:操作简便、新语料质量高
│ │ ├── 问题:短文本重复率高
│ │ └── 解法:连续多语言翻译(≤3 次)
│ └── 3.2 小结
│
├── 4 jieba 词性对照表 理解(查表用,不必背)
│ └── a/ad/ag/an/n/nr/ns/nt/nz/v/vn/… + hanlp 对照表(NR/NT/LC/PN/…)
│
├── 5 扩展-TF 方法计算句子相似度 理解(第 8 章第 4 题的对比题)
│ ├── 5.2 Unigram TF → cos = 0.75
│ ├── 5.3 Unigram + 2-gram TF → cos ≈ 0.57
│ └── 5.4 代码:CountVectorizer / TfidfVectorizer + ngram_range=(1,1)/(1,2)/(1,3)
│
├── 6 扩展-SBERT 句向量计算相似度 理解
│ ├── 6.2 核心思想:BERT 上下文 → Mean Pooling → 句向量 → L2 归一化 → 余弦
│ ├── 6.3 公式:s = (1/n)Σhᵢ ;ŝ = s/‖s‖
│ └── 6.4 实测:all-MiniLM-L6-v2,384 维,相似度 0.8185
│
├── 7 扩展-FastText 的 Subword 建模 ★掌握(与项目路线直接相关)
│ └── v_w = Σ z_g(字符 n-gram,常用 n=3~6)→ 缓解 OOV
│
├── 8 常见高频题(6 组) ★掌握
│ └── 分析指标 / 长度规范 / n-gram 作用 / TF-Cos vs SBERT / 回译风险 / 数据泄漏
│
└── 9 代码作业(2 题) ★掌握
├── fasttext 预训练词向量:get_word_vector / get_nearest_neighbors
└── nn.Embedding 手工词表 + 平均句向量
材料本身没写、但”文本预处理”这个题目必然要考的四块(本报告第四节的 4.2~4.4、4.7 补齐):分词的模式与自定词典、归一化(大小写/全半角/繁简/标点)、停用词表的取舍、词表与 OOV 的词元设计。这四块我用标注区分开:材料正文有的用”材料原话”引用,通用工程知识明确标出是补充。
四、原理详解
4.1 文本数据分析:先”看见”数据,再谈清洗
本节所有代码与数字都来自材料第 1 章(真实跑过的输出)。
4.1.1 标签数量分布:查均衡
材料原话:”在深度学习模型评估中,一般使用 ACC 作为评估指标,通常将二分类任务的 ACC 基线设在 50% 左右,需要正负样本比例在 1:1 左右。” 这句话是这一节的灵魂,标签不均衡时,ACC 会失效(90% 正样本的语料,全猜正就有 90% 准确率),所以训练前必须先看分布。
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
train_data = pd.read_csv(r"./data/train.tsv", sep='\t') # 酒店评论带表头,默认 header=0
print(train_data.head()) # 列:sentence, label
# sns.countplot:绘制离散值的频次直方图
sns.countplot(x="label", data=train_data, hue="label", legend=False)
plt.title("训练集标签数量分布"); plt.show()
材料实测:train.tsv 形状 (2960, 2),dev.tsv 形状 (1000, 2);标签是 0(消极)/ 1(积极)。
4.1.2 句子长度分布:max_length 的来源
train_data["length"] = train_data["sentence"].apply(lambda x: len(x))
# 等价写法:train_data['length'] = list(map(lambda x: len(x), train_data['sentence']))
# sns.histplot:绘制连续值的区间分布;bins=柱子数,kde=是否显示核密度曲线
sns.histplot(train_data["length"], bins=50, kde=True)
plt.title("训练集句子长度分布"); plt.show()
材料原文的关键判断:”模型要求输入为固定长度的张量,句子长度分布可以指导之后的句子截断补齐为合理长度”。实测前 5 条训练样本的 length 是 48 / 80 / 70 / 65 / 39,长度差异一倍以上,这就是必须做长度规范的原因。
这里有个容易被忽略的细节:
len(x)算的是字符数,不是词数。中文语料里两者差别不大(几十倍以内),但如果下游按词表索引序列长度算,就该改成len(jieba.lcut(x))。用哪个口径,要和max_length的单位一致。
4.1.3 正负样本长度散点分布:定位异常点
# sns.stripplot:绘制离散标签上的连续值散点分布
sns.stripplot(x="label", y="length", data=train_data, hue="label", legend=False)
plt.title("训练集正负样本的句子长度散点分布"); plt.show()
材料的用途说明:”通过查看正负样本长度散点图,可以有效定位异常点,进行人工审查。” 比如某个标签下突然出现一批超长文本,很可能是抓取时把多条评论拼在一起了。
4.1.4 词频统计:词表有多大
import jieba
from itertools import chain
from collections import Counter
# 1) 每句分词 2) chain 把 list of list 拍平成一个大列表
train_words = train_data["sentence"].apply(lambda x: jieba.lcut(x))
train_all_words = list(chain(*train_words.tolist()))
print(f"训练集所有词的数量:{len(train_all_words)}") # 156952
train_words_count = Counter(train_all_words)
print(f"训练集词表大小:{len(train_words_count)}") # 12162
材料实测数字(背下来,这是”看过数据”的证据):
| 数据集 | 总词数(token) | 去重词表大小(type) |
|---|---|---|
| 训练集 train.tsv | 156952 | 12162 |
| 测试集 dev.tsv | 52422 | 6857 |
注意 len(train_all_words) 和 len(train_words_count) 的差别正是 NLP 里 token 与 type 的区别:156952 个 token 只落在 12162 个不同的词上,平均每词出现约 13 次,这个比值同时告诉你”词表不大、可以用 TF-IDF”,以及”长尾里一定有大量只出现 1 次的词”。
4.1.5 高频形容词词云:查标签与语料是否矛盾
import jieba.posseg as pseg
from wordcloud import WordCloud
def get_adj(words_list):
results = []
for word, flag in pseg.lcut(words_list): # 分词 + 词性标注
if flag == "a": # a = 形容词
results.append(word)
return results
def show_cloud(words_list, title="训练集正样本的高频形容词词云"):
word_cloud = WordCloud(font_path=r"./data/SimHei.ttf", max_words=100,
background_color="white")
word_cloud.generate(" ".join(words_list)) # 词云要求空格分隔的字符串
plt.figure(figsize=(8, 8)); plt.imshow(word_cloud)
plt.axis("off"); plt.title(title); plt.show()
train_a_list_1 = train_data[train_data['label'] == 1]["sentence"].apply(get_adj)
train_a_list_1 = list(chain(*train_a_list_1.tolist()))
show_cloud(train_a_list_1, title="训练集正样本的高频形容词词云")
材料对结论的表述很克制:”上图中的正样本多数是褒义词,而负样本多数是贬义词,基本符合要求,但是负样本词云中也存在『方便』,可以进一步审查。” 这就是词云的真实价值,它不是可视化装饰,而是一次廉价的质量抽检:如果好评里高频出现”不方便””不是很好”,说明语料标注有问题。
4.2 分词:中文预处理真正的起点(补充,工程必备)
英文 "I love deep learning" 用空格切就是 4 个 token;中文「我喜欢深度学习」没有空格,是 7 个字。字粒度语义太散(BERT 中文确实用字,但它靠海量预训练补回来),词粒度才有人能理解的语义单元。所以中文必须先分词。
分词的核心困难是切分歧义和未登录词(OOV)。”南京市长江大桥”可以切成「南京市/长江大桥」或「南京/市长/江大桥」。工业级分词(jieba)的路线是:
- 基于前缀词典把句子构造成一张 DAG(有向无环图),每个字出发的所有可能成词路径都放进去;
- 用动态规划在 DAG 上找一条概率最大的路径(等价于最大概率的切分);
- 对词典里根本没出现的连续汉字片段,用 HMM + Viterbi 做序列标注切分。
我用 jieba 0.42.1 实测了材料里没有的三种模式(这是最容易追问的点):
import jieba
s = "我来到北京清华大学"
print('/'.join(jieba.lcut(s))) # 精确模式(默认)
print('/'.join(jieba.lcut(s, cut_all=True))) # 全模式
print('/'.join(jieba.lcut_for_search(s))) # 搜索引擎模式
真实输出:
精确: 我/来到/北京/清华大学
全模式: 我/来到/北京/清华/清华大学/华大/大学
搜索: 我/来到/北京/清华/华大/大学/清华大学
| 模式 | 行为 | 输出特点 | 该用在哪 |
|---|---|---|---|
精确模式 cut(s) / lcut(s) |
最可能的唯一路径 | 词数少、无重叠 | 一切建模场景(默认,就用这个) |
全模式 cut_all=True |
输出所有可能成词 | 有重叠、有噪声(清华/清华大学/华大) | 只用在对召回敏感的检索前置,建模绝不用 |
搜索引擎模式 lcut_for_search() |
在精确模式基础上,把长词再切出子词,长词放最后 | 粒度更细且覆盖长词 | 倒排索引 / 关键词匹配 |
工程上还必须会这几件事(同样实测过):
# 1) 自定义词典:项目专有词默认会被切碎
print(jieba.lcut("大模型项目用BERT做新闻标题分类"))
# ['大', '模型', '项目', '用', 'BERT', '做', '新闻标题', '分类'] ← "大模型"没被识别
jieba.add_word("大模型", freq=1000, tag="nz") # 运行时加词
print(jieba.lcut("大模型项目用BERT做新闻标题分类"))
# ['大模型', '项目', '用', 'BERT', '做', '新闻标题', '分类']
jieba.load_userdict("userdict.txt") # 从文件批量加载,每行:词 [词频] [词性]
jieba.cut()返回生成器,jieba.lcut()返回 list,迭代一次就空的坑基本都来自这里。- 词性标注用
jieba.posseg.lcut(s),返回pair(词, 词性);材料第 4 章的对照表就是查这个 flag 用的(a形容词、n名词、nr人名、ns地名、nt机构团体名、nz其他专名、v动词、vn名动词……)。 - 分词器必须训练/推理完全一致:jieba 的版本、自定义词典、停用词表,任何一样变了,向量空间就悄悄错位,指标会掉且不报错。
4.3 归一化:大小写 / 全半角 / 繁简 / 标点与数字(补充,工程必备)
iPhone 和 iphone、NLP 和 NLP、臺灣 和 台湾,在人眼里是同一个东西,在词表里是三个互不相干的 token。归一化的目标就是让”本来相同的字符串真的相同”,减少词表里的伪维度。
(1) 大小写:英文统一小写 text.lower();但专有名词有代价,Apple(苹果公司)和 apple(苹果)会被合并。分类/检索任务通常照合,NER 任务要慎重。
(2) 全角 ↔ 半角:全角字符的 Unicode 码位有规律,U+FF01–U+FF5E 与 U+0021–U+007E 一一对应,差值是 0xFEE0(实测:ord('A')-ord('A') == 0xFEE0,ord('1')-ord('1') == 0xFEE0);全角空格是独立的 U+3000。
def to_halfwidth(s: str) -> str:
out = []
for ch in s:
code = ord(ch)
if code == 0x3000: # 全角空格 → 半角空格
out.append(' ')
elif 0xFF01 <= code <= 0xFF5E: # 全角 ASCII → 半角
out.append(chr(code - 0xFEE0))
else:
out.append(ch)
return ''.join(out)
print(repr(to_halfwidth('NLP 2026,模型A!')))
# 'NLP 2026,模型A!' ← 全角字母/数字/逗号/空格都归一半角,汉字(模型)不动
(3) 繁简转换:简体训练、繁体测试是中文项目的经典事故。工具是 OpenCC(pip install opencc,OpenCC('t2s').convert(text) 做繁转简,还有 t2s/s2t/t2tw 等配置)。jieba 官方也声明支持繁体分词,但对多音字/异体字归一最好还是显式转一次。
(4) 标点、特殊符号与数字:材料第 1 章的数据分析里,标点其实是被当成词参与词频统计的(我用 jieba 实测过,「我 觉得 这个 酒店 位置 很 好,但是 房间 没有 窗户」里的「,」是独立 token)。所以要么把它们放进停用词表,要么用正则统一处理:
import re
raw = '<p>限时8折!iPhone15 Pro 只要¥7999!!! 详情 https://t.cn/abc123 @好友 #双十一#</p>\n'
t = re.sub(r'<[^>]+>', '', raw) # 去 HTML 标签
t = re.sub(r'https?://\S+|www\.\S+', '', t) # 去 URL
t = re.sub(r'@[\w\u4e00-\u9fa5-]+', '', t) # 去 @提及
t = re.sub(r'#([^#]+)#', r'\1', t) # 去话题井号、保留内容
t = re.sub(r'\s+', ' ', t).strip() # 空白归一
print(repr(t)) # '限时8折!iPhone15 Pro 只要¥7999!!! 详情 双十一'
t = re.sub(r'\d+', '<NUM>', t) # 数字归一为占位符
t = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9<>\s]', '', t) # 只保留中文/英文/数字/占位符/空格
print(repr(t)) # '限时<NUM>折iPhone<NUM> Pro 只要<NUM> 详情 双十一'
数字到底删还是留?材料没有给结论,工程上的判断标准是”数字对标签有没有区分力”:
- 新闻分类/情感分析:具体数值往往是噪声,映射成一个
<NUM>占位符比直接删更好,它保留了”这里有个数字”这个模式(如”降 8 折””3 天到货”)。 - 商品价格、股票、时间序列任务:数字是核心特征,要单独做数值化处理,绝不能删。
至于 '限时8折' 在 [^\u4e00-\u9fa5…] 过滤后变成 '限时<NUM>折',中文和数字之间没有空格,这一步只去掉了标点,不会切断词。要注意这个正则不会插入分隔符,所以它不能替代分词。
4.4 停用词:为什么去,代价是什么(补充,工程必备)
为什么要去:两重收益。
- 降维:中文里”的/了/是/在/和”这类词能占到 token 总量的很大比例,删掉它们能让词表显著变小,TF-IDF 矩阵更稀疏、训练更快。
- 降噪:这些词几乎所有文档都有,IDF 本来就会把它们的权重压得很低,但注意,IDF 只是”压低权重”,词还占着矩阵的一列;
stop_words参数是直接把整列删掉。这也是项目里要额外准备一份停用词表、而不是纯靠 IDF 的理由。
代价:删错词 = 语义翻转。最典型的是否定词「不/没/未/无/别」。比如「这个酒店不干净」去掉「不」就是「这个酒店干净」,正负标签直接反了。所以有一条硬纪律:
停用词表是任务相关的,不存在”通用最优值”。情感分析/评论分类必须人工过一遍停用词表,把否定词、程度副词(很/太/非常/有点)从表里踢出去。
可用代码(实测输出):
import re, jieba
stop = set('的 了 是 在 我 你 他 和 就 都 而 及 与 着 或 一个 没有 我们 这 那 很'.split())
sent = '我觉得这个酒店的位置很好,但是房间没有窗户'
words = [w for w in jieba.lcut(sent)
if w.strip() # 去空白 token
and w not in stop # 过停用词表
and not re.fullmatch(r'[\W_]+', w)] # 去过纯标点 token
print(words)
# ['觉得', '这个', '酒店', '位置', '好', '但是', '房间', '窗户']
注意这里 好 留下了、很 被停用词表吃掉了,如果下游是情感分析,这个停用词表就是错的(”很好”和”好”是两种强度)。词表的每一行都要有任务视角的解释,这也是第九节作业里”人工审查”的意义。
4.5 文本长度规范:截断与补齐(材料 2.2 节)
一个 batch 里句子长度是 48、80、70、65、39……而模型要的是形状固定的张量 (B, L)。所以必须把 L 统一:长的砍,短的补。
材料原话,”一般模型的输入需要等序列长度的矩阵,需要统一每条文本序列长度 sequence_length,根据句子长度分布得到多数文本的合理长度,对长文本进行截断,对短文本进行补齐(一般使用填充符号 PAD)。” 补的元素是索引 0,也就是 <PAD>。
材料给了两种实现,输出完全一致(下面是材料实测输出,我复现过)。
# ---- 方法一:python 手写 ----
MAX_LENGTH = 10
def demo01(sentences, max_length=MAX_LENGTH):
results = []
for sentence in sentences:
if len(sentence) > max_length:
sentence = sentence[:max_length] # 长 → 从尾部截断
else:
padding_length = max_length - len(sentence)
sentence = sentence + [0] * padding_length # 短 → 尾部补 0
results.append(sentence)
return results
# ---- 方法二:tensorflow ----
from tensorflow.keras.preprocessing import sequence
def demo02(sentences, max_length=MAX_LENGTH):
return sequence.pad_sequences(sentences, maxlen=max_length,
padding="post", truncating="post")
sentences = [[0,1,2,3,4,5,6,7,8,9,10], [4,5,6,7,8,9,10], [9,10],
[0,1,2,3,4,5,6,7,8,9,1,2,3,4,5,6,7,8,9,10]]
print(demo01(sentences))
# [[0,1,2,3,4,5,6,7,8,9], [4,5,6,7,8,9,10,0,0,0], [9,10,0,0,0,0,0,0,0,0], [0,1,2,3,4,5,6,7,8,9]]
print(demo02(sentences)) # 返回 numpy 数组,数值与上面逐行相同
四个参数的含义(材料注释 + 我的补充):
| 参数 | 取值 | 材料里的写法 | 说明 |
|---|---|---|---|
maxlen |
整数 | MAX_LENGTH=10 |
目标长度,来自 4.1.2 的长度分布 |
padding |
'pre' / 'post' |
显式传 "post" |
补齐位置。Keras 默认是 'pre'(前面补) |
truncating |
'pre' / 'post' |
显式传 "post" |
截断位置。Keras 默认也是 'pre'(从头砍,保留句尾) |
value |
数值 | 默认 0 | PAD 的填充值 |
两个必须记住的点:
- 材料代码显式写了
padding="post", truncating="post",但 Keras 的默认值是'pre'/'pre'。不要把”代码里写的”记成”API 默认的”。truncating='pre'意味着长句保留句尾,对新闻标题要慎用(标题信息量最大的词通常在开头)。 - TensorFlow 官方文档在讲 masking & padding 时明确建议:用 RNN 层时应使用
postpadding,这样才能用上 cuDNN 的加速实现。所以 LSTM/GRU/Transformer 场景下post是更常见的选择。
max_length 怎么定:看长度分布直方图,取一个能覆盖绝大多数样本(经验上是 95%~99% 分位)的长度。取最大值 = 大量无意义的 PAD(浪费算力,还会让 attention 被 padding 稀释);取太小 = 关键信息被截掉。
4.6 n-gram 特征(材料 2.1 节)
词袋模型把句子当”词的集合”,”我 喜欢 深度学习”和”我 讨厌 深度学习”只差一个词,所以被算得很像。n-gram 就是把相邻词的组合也当成一个新特征加进去,让词序重新有了一点信息。
机制与示例(材料原表格):
| 项目 | 说明 |
|---|---|
| 定义 | 连续 n 个 token(词或字)组成的新特征,用于捕捉局部上下文信息,n 通常取 2 或 3 |
| 常见类型 | 1-gram(unigram)单词;2-gram(bigram)连续两词;3-gram(trigram)连续三词 |
| 应用场景 | 文本分类(情感分析、垃圾邮件识别)、搜索与推荐、传统机器学习 NLP 的特征工程;现代 NLP 流程几乎不用 n-gram 特征 |
| 使用方式 | 2-gram:在 1-gram 基础上额外加入;3-gram:在 1-gram 基础上同时加入 2-gram 和 3-gram |
句子「我 喜欢 自然 语言 处理」的展开:
1-gram:我, 喜欢, 自然, 语言, 处理
2-gram:我喜欢, 喜欢自然, 自然语言, 语言处理
3-gram:我喜欢自然, 喜欢自然语言, 自然语言处理
材料的索引化例子讲得很清楚:分词列表 ["是谁", "敲动", "我心"] 对应索引 [1, 34, 21],把相邻词也编成特征(假设 1000 代表”是谁+敲动”、1001 代表”敲动+我心”),列表就变成 [1, 34, 21, 1000, 1001]。
用滑动窗口实现的通用函数(材料代码,实测输出一致):
ngram_n = 3
def create_ngram_features(input_list, ngram_n=2):
sliced_list = [input_list[i:] for i in range(ngram_n)] # 错位切片
return set(zip(*sliced_list)) # zip 组合成元组集合
print(create_ngram_features([1, 3, 2, 1, 5, 3], 2))
# {(1, 3), (3, 2), (2, 1), (1, 5), (5, 3)} —— 5 个 2-gram
print(create_ngram_features([1, 3, 2, 1, 5, 3], 3))
# {(1, 3, 2), (3, 2, 1), (2, 1, 5), (1, 5, 3)} —— 4 个 3-gram
注意这里用的是 set:同一组合在句子里出现多次会被去重(这也是为什么材料例子里 (1,3,2) 只出现一次,尽管序列里有两个 1 和两个 3)。真正做特征时要换成 Counter 保留频次。
材料对时代边界的表述(常被问):
| 流程阶段 | 传统机器学习 NLP | 现代深度学习 NLP |
|---|---|---|
| 第一步 | 文本 | 文本 |
| 第二步 | 分词 | Tokenizer(分词器) |
| 第三步 | 特征工程(TF-IDF + n-gram) | 预训练语言模型(BERT / GPT / Transformer) |
| 第四步 | 机器学习模型(LR / SVM / Naive Bayes) | 下游任务微调 |
| 第五步 | 分类 / 预测 | 分类 / 预测 |
一句话记法:n-gram 是 Transformer 出现之前用来补词序的权宜之计;有了 self-attention,模型自己就能学”喜欢”和”讨厌”之后跟什么。
4.7 词表与 OOV(材料第 7 章 + 第 9 章作业)
模型不认识字,只认识整数。所以要有一步「token → id」的映射,这份映射就是词表(vocabulary)。问题是:训练集见过的词,测试时几乎必然出现没见过的词(OOV, Out-Of-Vocabulary)。词表必须预留位置给”未知”和”填充”。
手工建词表的路线是”先把两个特殊符号占位,再按出现顺序给词编号”:
train = ["酒店 位置 很好", "房间 没有 窗户"]
vocab = {"<PAD>": 0, "<UNK>": 1} # 0 留给 padding,1 留给未知词
for s in train:
for w in s.split():
if w not in vocab:
vocab[w] = len(vocab)
print(vocab) # {'<PAD>':0, '<UNK>':1, '酒店':2, '位置':3, '很好':4, '房间':5, '没有':6, '窗户':7}
test = "酒店 隔音 很差".split()
print([vocab.get(w, vocab["<UNK>"]) for w in test]) # [2, 1, 1]
四个特殊 token 的职责(材料作业里只用到了前两个):
| token | 作用 | 出现位置 |
|---|---|---|
<PAD> |
补齐到 max_length 的填充位,不参与损失计算 |
4.5 节的长度规范 |
<UNK> |
词表外的词统一映射到这里 | OOV 兜底 |
<CLS> / <SEP> |
BERT 的句子首标记与分隔标记 | 材料 day12 的 BERT 部分 |
OOV 怎么缓解,这就是材料第 7 章 FastText Subword 的价值。FastText 不只学”整词”,还学字符 n-gram(子词),常用 n=3\sim6,词向量由子词向量聚合:
\mathbf{v}_w = \sum_{g \in G(w)} \mathbf{z}_g
其中 G(w) 是词 w 的子词集合,\mathbf{z}_g 是子词 g 的向量。材料举的例子是英文词 where(边界符号写成 <where>):3-gram 子词包括 <wh, whe, her, ere, re>;即使测试时遇到没见过的 wheres,也能从它的子词(whe, her, res)组合出可用向量。材料的三条原因总结是:缓解 OOV、提升低频词质量、适合形态变化丰富的语言。一句话收尾:Word2Vec 学”整词”,FastText 学”整词 + 子词”,所以真实场景更稳健。
我用手工 nn.Embedding 的路线把”词表 → 向量”补齐(材料第 9 章作业 2 的写法):nn.Embedding(num_embeddings=len(vocab), embedding_dim=100) 建表,句子 ids = [vocab[t] for t in tokens if t in vocab],取 vecs.mean(dim=0) 就是平均句向量。关键坑:if t in vocab 过滤掉的 OOV 词是静默丢弃的,如果一句话的 token 全被丢光,mean(dim=0) 会得到一个 0 向量,线下不会报错,线上就是一个随机预测。
材料作业 1 里 fasttext.load_model('./model/fil9.bin') + get_word_vector(word) + get_nearest_neighbors(word, k=5) 的实测输出值得注意:查询”深度学习”得到的近邻是 mcgreevey / triggs / gilford / cushing / mcvey(相似度 0.63~0.66),词向量维度 100。这些近邻显然是英文人名,说明 fil9.bin 是一个英文语料(fil9 ≈ 维基百科 fil9 语料)训练出来的英文词向量模型,用它查中文词得到的向量没有语义意义。这是”预训练词向量必须和你的语料/语言匹配”的活教材,被追问时可以直接当例子讲。
4.8 数据增强:回译(材料第 3 章)
翻译过去再翻回来,句子意思不变、说法变了,这就是一条同标签的新样本。
机制(材料原话):”回译数据增强是效果较好的方法,一般基于 google、有道等翻译接口,将文本数据翻译成另外一种语言(一般选择小语种),再翻译回原语言,即可得到与原语料同标签的新语料。”
优势是”操作简便,获得新语料质量高”;问题是”在短文本回译过程中,新语料与原语料可能存在很高的重复率,并不能有效增大样本”。解法是连续多语言翻译,如 中文→韩文→日语→英文→中文,并且给出了经验上限:最多只采用 3 次连续翻译,更多次数将产生语义失真。
from deep_translator import GoogleTranslator # pip install deep_translator
from translate import Translator # pip install translate(国内可用)
def translate_text(text, target_language):
return GoogleTranslator(source='auto', target=target_language).translate(text)
def translate_text2(text, target_language):
return Translator(to_lang=target_language).translate(text)
if __name__ == '__main__':
text = "曾经有一段真挚的爱情放在我的面前,可惜我当时在考英语四级"
r1 = translate_text(text, 'ko') # 中文 → 韩文
print(f"翻译结果:{r1}")
r2 = translate_text(r1, 'zh-CN') # 韩文 → 中文
print(f"翻译结果:{r2}")
材料实测输出(很有意思,值得记下来当例子):
翻译结果:한때 내 앞에는 진정한 사랑이 있었지만 안타깝게도 그 당시 나는 CET-4 시험을 치르고 있었습니다.
翻译结果:曾经有一份真爱在我的面前,但不幸的是,当时我正在参加大学英语四级考试。
翻译结果:한때 제 앞에 진심어린 사랑이 있었지만, 안타깝게도 저는 영어 레벨 4 시험을 치르고 있었습니다.
翻译结果:我曾经有一个真诚的爱在我面前,但不幸的是,我参加了英语四级考试。
对比原句和回译句就能看出回译的两个特征:语义保住了(爱情→真爱、英语四级→大学英语四级考试),表述变了(”放在我的面前”→”在我的面前”,”我当时在考”→”我正在参加”)。这就是数据增强要的。同时也能看出为什么次数不能多:每次翻译都在做”同义改写”,3 次之后原文的用词风格已经面目全非,标签之外的噪声就盖过信号了。
4.9 词性标注与对照表(材料第 4 章)
材料第 4 章给了 jieba 词性对照表(约 50 项)和 hanlp 词性对照表。对预处理最常打交道的几条:
| 标签 | 含义 | 标签 | 含义 |
|---|---|---|---|
a |
形容词 | n |
名词 |
ad / an |
副形词 / 名形词 | nr / ns / nt |
人名 / 地名 / 机构团体名 |
d |
副词 | nz |
其他专名 |
m / mq |
数词 / 数量词 | v / vn |
动词 / 名动词 |
p |
介词 | u / uj / ul / uz |
助词 / 的 / 了 / 着 |
c |
连词 | x |
非语素词(标点常落在这里) |
实测一句话:
import jieba.posseg as pseg
print(pseg.lcut("小明硕士毕业于中国科学院计算所,后在日本京都大学深造"))
# [('小明','nr'), ('硕士','n'), ('毕业','n'), ('于','p'), ('中国科学院','nt'), ('计算所','n'),
# (',','x'), ('后在','t'), ('日本京都大学','nt'), ('深造','v')]
预处理里它的两个用途:
- 按词性筛词,材料 4.1.5 就是靠
flag == "a"挑形容词做词云;同理可以”只保留名词+动词”来抽关键词。 - 按词性过滤停用词,把
d(副词)、u(助词)、p(介词)、c(连词)整类视作停用词,比维护一张词表更”自动”,但风险和 4.4 一样(不是副词,一刀切会把它删掉)。
hanlp 的标签体系是另一套(NR 专有名词、NT 时间名词、PN 代词、M 单位词、VV 动词、AD 副词、PU 标点、FW 外国词语……),换工具时标签体系要跟着换,不能拿 jieba 的 n 去比 hanlp 的 NN。
4.10 句子相似度的两条路线:TF+Cosine vs SBERT(材料第 5、6 章)
这是本材料最”研究向”的两节,也是第 4 题的正面回答。
路线一:TF(词频)+ 余弦相似度
给定句子 A=”我喜欢深度学习”、B=”我讨厌深度学习”,词表 ["我","喜欢","讨厌","深度","学习"]:
| 句子 | 我 | 喜欢 | 讨厌 | 深度 | 学习 |
|---|---|---|---|---|---|
| A | 1 | 1 | 0 | 1 | 1 |
| B | 1 | 0 | 1 | 1 | 1 |
\cos(A,B) = \frac{3}{\sqrt{4}\cdot\sqrt{4}} = 0.75
0.75 太高了,两个情感相反的句子被算成了高度相似,因为 unigram 根本分不清”喜欢”和”讨厌”。
加上 2-gram 后,特征表变成 10 维,两个句子的向量:
| 句子 | 我 | 喜欢 | 讨厌 | 深度 | 学习 | 我 喜欢 | 喜欢 深度 | 深度 学习 | 我 讨厌 | 讨厌 深度 |
|---|---|---|---|---|---|---|---|---|---|---|
| A | 1 | 1 | 0 | 1 | 1 | 1 | 1 | 1 | 0 | 0 |
| B | 1 | 0 | 1 | 1 | 1 | 0 | 0 | 1 | 1 | 1 |
\cos(A,B) = \frac{4}{\sqrt{7}\cdot\sqrt{7}} = \frac{4}{7} \approx 0.57
材料给的评价是:”添加 2-gram 后,相似度降低,更能反映差异。”
一个要提醒的细节:材料这两行公式里,unigram 那一步写的是 \frac{3}{\sqrt{4}\cdot\sqrt{4}}=0.75(正确:两个向量的点积就是 3,各含 4 个 1,模长都是 2);但 2-gram 那一步材料写的是 \frac{3}{\sqrt{7}\cdot\sqrt{7}}\approx 0.57,分子笔误了:两个 7 维向量(各含 7 个 1)的点积是 4(共有的是”我/深度/学习/深度 学习”四项),4/7\approx0.5714 才对得上 0.57;按材料写的 3/7 算出来是 0.4286,与它自己的结论矛盾。结论方向(加 2-gram 后相似度下降)没问题,抄公式时按 4/7 记。
代码用 sklearn 一遍跑完三种 n-gram 配置(材料代码,我复现过输出):
import jieba
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
texts = ["我喜欢深度学习", "我不喜欢深度学习"]
def chinese_tokenizer(text):
return jieba.lcut(text)
for ngram in [(1,1), (1,2), (1,3)]:
vectorizer = CountVectorizer(tokenizer=chinese_tokenizer, ngram_range=ngram)
X = vectorizer.fit_transform(texts)
print("特征:", vectorizer.get_feature_names_out())
print("向量:\n", X.toarray())
print("相似度:\n", cosine_similarity(X))
实测的相似度随 n-gram 单调下降(这是本节最值得背的一组数字):
| 向量化方式 | ngram_range=(1,1) | (1,2) | (1,3) |
|---|---|---|---|
| CountVectorizer(TF) | 0.894 | 0.756 | 0.674 |
| TfidfVectorizer(TF-IDF) | 0.818 | 0.615 | 0.515 |
越细的 n-gram,把两个句子的差异放得越大。但注意这不是免费的:n-gram 让特征维度爆炸(这句里 3-gram 的”学习”词表从 5 维涨到 14 维),而且数据稀疏、泛化差,所以它只适合小规模传统方法。
顺带一个工程细节:我复现时用了
TfidfVectorizer(tokenizer=jieba.lcut, token_pattern=None, ...)。sklearn 的默认token_pattern是(?u)\b\w\w+\b,要求 token 至少两个字符,中文不分词时整句会被当成一个 token,即使已经 jieba 分好词,所有单字(我/不/的)也会被静默丢掉。中文 TF-IDF 必须改这个参数(或改成r"(?u)\S+"),否则你会得到一个”莫名其妙少了一半特征”的词表。
路线二:SBERT 句向量 + 余弦
TF 路线是”词频统计”,不知道”喜欢”和”讨厌”是反义;SBERT 是”语义编码”,同义句(我喜欢深度学习 / 我热爱深度学习)应该相似度 0.8~0.95,无关句应该 < 0.2。
机制三步:
- BERT 编码上下文:每个 token 拿到一个融合了上下文的向量 h_1, h_2, \dots, h_n(”喜欢”的向量会因为它后面跟的是”深度”还是”讨厌”而不同);
- Mean Pooling 得到句向量:\mathbf{s} = \frac{1}{n}\sum_{i=1}^{n}\mathbf{h}_i;
- L2 归一化:\hat{\mathbf{s}} = \frac{\mathbf{s}}{\|\mathbf{s}\|},作用是”消除向量模长差异,只保留语义方向信息,便于向量检索与点积计算”。
归一化之后,余弦相似度退化成点积:
\cos(\theta) = \frac{\mathbf{s}_1 \cdot \mathbf{s}_2}{\|\mathbf{s}_1\|\|\mathbf{s}_2\|} = \hat{\mathbf{s}}_1 \cdot \hat{\mathbf{s}}_2
材料给的经验区间:同义句 ≈ 0.8~0.95;低相关 ≈ 0.3~0.5;无关句 < 0.2。
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("all-MiniLM-L6-v2") # 384 维密集向量空间
sentences = ["我喜欢深度学习", "我喜欢玩游戏"]
embeddings = model.encode(sentences)
v1, v2 = embeddings
print(f"句向量维度:{v1.shape}")
print("句子相似度:", np.dot(v1, v2))
print("句子相似度:", model.similarity(embeddings, embeddings))
材料实测输出:
句向量维度:(384,)
句子相似度: 0.81849515
句子相似度: tensor([[1.0000, 0.8185],
[0.8185, 1.0000]])
“我喜欢深度学习”和”我喜欢玩游戏”得到 0.8185,语义确实相近(都喜欢),但不算同义句,落在材料的”同义句 0.8~0.95″区间下沿附近,符合”不应过高也不应接近 0″的预期。注意 encode() 里 normalize_embeddings=True 被注释掉了,此时直接用 np.dot(v1, v2) 得到 0.8185 是巧合般的接近(因为 MiniLM 输出的向量模长接近 1),严谨写法还是要开归一化,或者直接调 model.similarity()。
两条路线的对比(也是第 4 题的答案):
| 维度 | TF + Cosine | SBERT + Cosine |
|---|---|---|
| 表示 | 词频/TF-IDF 稀疏向量,词表维度 | 384/768 维稠密句向量 |
| 词序与语义 | 丢词序(靠 n-gram 补救),无同义/反义概念 | 有上下文,能区分同义与反义 |
| 成本 | 极低,CPU 毫秒级,无需模型权重 | 需要预训练模型,GPU 明显更快 |
| 数据要求 | 无(纯统计) | 无(用现成模型)或需要句对微调 |
| 典型用途 | 关键词匹配、小规模基线、可解释 | 语义检索、聚类、去重、洗数据 |
| 材料原话 | “基于词频,轻量但语义能力弱” | “基于上下文句向量,语义表达更强,工程中更常用” |
五、易错点与常见误解
① 常见说法:”英文和中文用同一套切分逻辑就行”;实际上,英文按空格切、中文必须分词。
为什么:英文的词之间天然有空格,tokenizer 只要按空格/标点切;中文书写单位是字,词与词之间没有分隔符,字符序列 c_1c_2\cdots c_n 的切分方案数随长度指数增长,”南京市长江大桥”就有歧义。所以中文的”分词”是一个需要词典+统计模型的歧义消解任务,不是字符串操作。
② 常见说法:”全模式分词切得更全,训练用它没坏处”;实际上,建模只用精确模式。
原因在于,全模式会输出所有可能成词(实测 我/来到/北京/清华/清华大学/华大/大学),同一段文字被重复计数、产生大量无意义特征(”华大”),而且切分结果不唯一、不可复现。全模式是给检索召回用的,不是给建模用的。
③ 常见说法:”停用词表随便找一份通用的就行”;实际上,停用词表是任务相关的,必须人工过一遍。
背后的道理是,最常见的事故是通用表里含否定词「不/没/未/无」。情感分类里”不满意”去掉「不」就变成”满意”,标签直接反了。项目里 749 个停用词是给 TF-IDF 用的(材料明确写”用于后续 tf-idf 参数”),不是给所有模型通用的。
④ 常见说法:”max_length 就取句子里最长的那个长度,信息不丢”;实际上,取能覆盖绝大多数样本的分位数长度。
之所以如此,取最大值意味着绝大多数短样本被大量 PAD 填充,浪费算力,而且 PAD 会把 self-attention 的分布稀释、拉低有效信息占比。反过来取太短会切掉关键实体。正确做法是 4.1.2 的长度分布直方图上挑一个 95%~99% 分位的值。
⑤ 常见说法:”PAD 用 0 填充很自然”;实际上,0 是 <PAD> 的 id 才行,且 PAD 必须被 mask 掉。
为什么:如果词表里 0 已经被某个真实词占了(比如手工建表时没预留),补齐的 0 就会被模型当成真实词。而且即使 0 是 <PAD>,它仍然会进入 attention 计算,必须靠 padding_mask / attention_mask 屏蔽掉,否则短句的结果会被整片 PAD 影响。
⑥ 常见说法:”Keras pad_sequences 默认就是后面补/后面截”;实际上,默认是 padding='pre'、truncating='pre'。
原因在于,材料代码里显式传了 "post",容易让人误记成默认值。实际 API 默认是前面补、从头截(也就是保留句尾)。对新闻标题这类”关键词在头部”的文本,truncating='pre' 是有害的;而 RNN 场景官方建议用 post padding 才能走 cuDNN 加速实现。结论:这两个参数永远显式传。
⑦ 常见说法:”先在全量数据上建词表、算 IDF,再切训练/验证集,省事”;实际上,先切分,再只用训练集 fit。
背后的道理是,这是数据泄漏。TfidfVectorizer 的 fit 会同时建词表和算 idf_,如果在全量数据上 fit,验证集/测试集的词分布就通过 IDF 泄漏进了训练过程,报告的指标不可信。同一条纪律也适用于停用词表、词表大小、max_length 的统计。材料第 8 章第 6 题原话:”先划分数据集,再构建词表与统计特征;任何归一化/统计仅在训练集拟合,再应用到验证/测试集。”
⑧ 常见说法:”现代深度学习里也该加 n-gram 特征”;实际上,现代流程几乎不用。
之所以如此,材料自己写了”n-gram 特征主要在传统机器学习 NLP 流程中使用,现在深度学习 NLP 流程中几乎不用”。因为 BERT 这类模型的 self-attention 直接在 token 之间建模,n-gram 能提供的那点局部词序信息已经被完全覆盖,额外加只会带来维度爆炸和稀疏。n-gram 在今天的位置是:FastText 的 wordNgrams 参数、检索的倒排索引、以及传统基线。
⑨ 常见说法:”回译次数越多,生成的样本越多样”;实际上,最多 3 次。
为什么:每次翻译都是一次”同义改写”,误差会累积。材料的经验值是”最多只采用 3 次连续翻译,更多的翻译次数将产生语义失真”。另一头的风险是次数太少:短文本单次回译的重复率可能高到”并不能有效增大样本”,相当于白做。
⑩ 常见说法:”len(train_data['sentence']) 就是句子的词数”;实际上,它算的是字符数。
原因在于,len() 作用在字符串上返回字符数,中文里字符数≈词数的 1.5~2 倍。用字符数统计出来的长度分布去定按词索引的序列 max_length,会系统性偏大(长句被切得更狠或短句补得更长),必须和下游序列的单位对齐。
⑪ 常见说法:”材料里 n-gram 相似度的公式直接抄就行”;实际上,抄数值、核对公式。
背后的道理是,材料第 5.3 节写的是 \cos(A,B)=\frac{3}{\sqrt{7}\cdot\sqrt{7}}\approx0.57,两个 7 维向量(各含 7 个 1)的点积实际是 4,4/7\approx0.5714 才等于它给的 0.57,分子是笔误,按 3 算出来是 0.4286。被追问时如果被要求”现场推一下这个例子”,分子报错会很尴尬。这类”结论对、中间推导写错”的情况在课件里并不罕见,处理原则是数值和方向照记、公式自己验一遍。
六、横向对比与研究脉络
6.1 分词工具对比
| 工具 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 空格切分(英文天然) | 按空白符 split | 零成本、可复现 | 中文不可用;标点粘连需额外清洗 | 英文语料 |
| jieba | 前缀词典 + DAG + 动态规划求最大概率路径;未登录词用 HMM+Viterbi | 开箱即用、三种模式、支持自定义词典与词性标注、纯 Python 易部署 | 词典固定,领域新词需自己加;不感知上下文 | 中文预处理默认选择(课程全程使用) |
| HanLP | 多模型可选(词典/CRF/深度学习) | 功能全、词性体系规范 | 依赖重、配置复杂 | 需要高质量 POS/NER 的场景 |
| LTP(哈工大讯飞)/ THULAC(清华)/ pkuseg(北大) | 各自的分词+词性模型 | 学术与工业使用广泛 | 生态与中文文档不如 jieba | 需要更细粒度标注时对比选型 |
| 字级切分 | 逐字成 token | 零 OOV、词表小、鲁棒 | 序列变长、语义单元弱 | BERT 中文常用;项目里 FastText 按字分词实测效果最好 |
| BPE / WordPiece(子词) | 从字符开始按频率合并(或按概率切分) | 兼顾词表大小与 OOV 鲁棒性 | 需要预训练过程、切分结果不可读 | BERT / GPT 等预训练模型的 tokenizer |
6.2 三种”文本 → 向量”路线对比
| 维度 | TF-IDF(+n-gram) | FastText(词+子词) | BERT / SBERT(上下文) |
|---|---|---|---|
| 表示单元 | 词 | 词 + 字符 n-gram 子词 | 子词/字,带上下文 |
| 词序 | 无(n-gram 部分补偿) | 弱(词向量平均 + wordNgrams) | 强(self-attention) |
| OOV | 直接丢弃(transform 时静默变全 0 向量) |
可由子词组合出向量 | 子词切分,基本无 OOV |
| 训练成本 | 秒级 CPU | 分钟级 CPU | GPU 小时级起 |
| 语义能力 | 弱,无语义相似概念 | 中,词向量有相似性 | 强,区分同义/反义 |
| 在本材料里的位置 | 第 5 章 TF+Cos 示例 | 第 7 章 Subword | 第 6 章 SBERT 示例 |
6.3 研究脉络:谁解决了谁的什么问题
① 空格切分(英文)
└─问题── 中文没有空格,词是什么都定义不了
↓
② 词典 + 统计分词(jieba:DAG + 动态规划 + HMM)
└─问题── 词典固定 → 领域新词切错;词表 12k+ 且测试集必然有 OOV
↓
③ 子词建模(BPE / WordPiece / FastText 字符 n-gram)
└─问题── 词向量是静态的,「苹果」的向量永远是一个值,分不清水果和公司
↓
④ 上下文向量(BERT / SBERT:token 向量 + Mean Pooling → 句向量)
└─问题── 相似度计算需要两两拼接计算量过大 → 句向量 + 余弦检索
每一层都留着上一层的方法在合适的场景继续用:今天你做新闻分类,第一级基线仍然是 TF-IDF + 随机森林(新闻分类项目里就是 81.48%),因为它是分钟级的成本下限。
6.4 五种数据分析方法对比(材料第 1、2 章)
| 方法 | 目的 | 图表/API | 产出的决策 |
|---|---|---|---|
| 标签数量分布 | 查类别均衡 | sns.countplot |
是否需要重采样/加权;二分类 ACC 基线 50% |
| 句子长度分布 | 定 max_length |
sns.histplot(bins, kde) |
截断补齐的长度(分位数) |
| 正负样本长度散点 | 定位异常样本 | sns.stripplot |
人工审查清单 |
| 词频统计 | 看词表规模 | jieba.lcut + chain + Counter |
是否加停用词、是否限 max_features |
| 高频形容词词云 | 查标签与语料是否矛盾 | pseg 取 flag=="a" + WordCloud |
语料质量整改 |
七、在新闻分类项目中的落地:18w 标题的预处理流水线
7.1 项目的数据规格(先把事实钉死)
| 项目 | 规格 |
|---|---|
| 任务 | 类今日头条新闻分类,10 个类别 |
| 数据来源 | 企业内部的成品数据 |
| 训练 / 验证 / 测试 | 18w / 1w / 1w |
| 数据格式 | \t 分隔了新闻标题和标签 |
| 停用词 | 749 个,用于后续 TF-IDF 参数 |
| 技术路线 | TF-IDF+随机森林 → FastText → BERT 微调 → LLM(DeepSeek API) |
| 部署 | Flask(服务端)+ Streamlit(客户端) |
材料只给了”749 个”这个数字,没有给它的来源。下面是实现上的常规做法(标出来避免被当成课件的原始说明):先用一份公开的中文停用词表(哈工大 / 百度 / 四川大学机器智能实验室等版本是业内常见的底表)作为起点,再用 18w 标题做词频统计,把”高频但对区分 10 个类别毫无贡献的词”补进去(新闻标题里大量出现的模板词、通用虚词都属于这一类),最后去重、排序、固定版本号,落盘成 stopwords.txt,项目里的 TF-IDF 脚本读的正是 ./data/data/stopwords.txt。关键纪律是:这份表一旦定下来就不能再改,训练/验证/测试/线上必须用同一份。而它只喂给 TF-IDF,FastText 和 BERT 都不套这个词表。
7.2 标题\t标签 到底怎么读
课件里酒店语料是带表头的 train.tsv(第一行是 sentence\tlabel),所以材料第 1 章的代码是直接:
train_data = pd.read_csv(r"./data/train.tsv", sep='\t') # 默认 header=0,第一行当列名
而新闻分类项目的原始文件是一行一条、无表头的 标题\t标签,材料里的处理脚本是”逐行读取 → 去掉首尾空白 → 用 \t 切出文本与标签”。两种写法不能混:
import pandas as pd
# 写法 A:无表头原始文件(这种无表头原始文件)
df = pd.read_csv("./data/train.txt", sep="\t", header=None,
names=["text", "label"], encoding="utf-8")
# 写法 B:逐行处理时可读性最好,也更稳
rows = []
with open("./data/train.txt", encoding="utf-8") as f:
for line in f:
line = line.strip() # 去行尾 \n 和首尾空白
if not line:
continue # 跳过空行
text, label = line.split("\t", 1) # maxsplit=1:标题里万一还有 \t 也不会串列
rows.append((text, label))
df = pd.DataFrame(rows, columns=["text", "label"])
\t 分隔的三个实际坑:
- 表头错位:无表头文件按写法 A 之外的方式读(比如直接用
pd.read_csv(path, sep='\t')),第一行标题会被当成列名,训练集凭空少一条,而且列名变成一个长句子。 - 串列:如果标题里本身含
\t(抓取残留),不写maxsplit=1就会ValueError: too many values to unpack,或者 10 类标签错位。split("\t", 1)是廉价的保险。 - 编码:中文文件不显式给
encoding="utf-8",在 Windows 上会按 GBK 解码,出现乱码时看起来像”分词坏了”,实际是读文件时就坏了。
7.3 完整流水线(八步)
┌ 1 读取 ──────── 逐行 split('\t', 1) → (标题, 标签);10 类映射为 0~9
├ 2 清洗 ──────── 去 URL / @提及 / 话题符号 / HTML 残留 / 多余空白;全角转半角
├ 3 分词 ──────── jieba.lcut(标题);项目专有词先 load_userdict 自定义词典
├ 4 停用词 ────── 过 749 词集合(只用在 TF-IDF 路线;否定词必须人工确认没被删)
├ 5 落盘 ──────── 用空格拼回字符串,写入 train_new.csv 的 words 列
│ (材料脚本:只保留每行前 30 个元素做截断,验证集/测试集走完全相同的代码)
├ 6 TF-IDF 路线 ─ TfidfVectorizer(stop_words=停用词).fit_transform(训练集)
│ → 随机森林 → 基线(项目里为 81.48%)
├ 7 FastText 路线 转成 __label__标签 文本 → train_supervised()(按字分词实测更好)
└ 8 BERT 路线 ─── 不套停用词,用 tokenizer 自带子词切分 + max_length 截断补齐
第 3 步的分词器与自定义词典,可以直接用我在 4.2 节实测的例子说明为什么需要:
import jieba
print(jieba.lcut("大模型项目用BERT做新闻标题分类"))
# ['大', '模型', '项目', '用', 'BERT', '做', '新闻标题', '分类']
jieba.add_word("大模型") # 默认词典里没有这个词,会被切成 大/模型
新闻标题里满是新词、专有名词、缩写(人名、公司名、产品名),这些正好是分类信号最强的地方,而它们最容易在默认词典里被切碎。所以”分词 + 自定义词典 + 停用词”是项目里明确的一条链路(材料导图里写的就是这个顺序)。
第 5 步的”前 30 个词”就是这个材料 4.1.2 / 4.5 讲的长度规范在项目里的具体化:新闻标题短,30 个词足够覆盖绝大多数样本;三个模型复用同一份分词结果,避免重复分词,也保证”训练/验证/测试走完全相同的预处理代码”。
7.4 这份材料的知识在项目里各自钉在哪
| 材料章节 | 项目里的落点 | 关键参数 / 坑 |
|---|---|---|
| 1.2 标签数量分布 | 10 类的样本量必然不均,决定主指标不能只看 ACC | 类别不均衡 → 看 precision/recall/F1 四件套 |
| 1.2 句子长度分布 | 定标题的截断长度(脚本用 30 个词) | 经验值,要回头核对长度分布的均值/方差 |
| 1.2 词频统计 | 决定 TF-IDF 的 max_features / min_df |
长尾词太多,不限制会让矩阵巨大且噪声高 |
| 2.1 n-gram | 传统路线的可选增强(ngram_range=(1,2)) |
只在 TF-IDF 路线上考虑;BERT 不需要 |
| 2.2 长度规范 | 三个模型都要:TF-IDF 侧截断、FastText 侧拼空格串、BERT 侧 max_length |
padding/truncating 显式传;PAD 要 mask |
| 3.1 回译增强 | 样本量小或某类稀少时的补充手段 | ≤3 次;生成样本必须先隔离,不能跨进验证集 |
| 4 词性对照 | 语料质检、关键词抽取、按词性过滤停用词 | 换工具要换标签体系 |
| 5 TF+Cos | 语义相似度基线、类间相似度分析、错例分析 | 中文必须改 token_pattern |
| 6 SBERT | 语义检索 / 标签相似度分析 / 数据去重(清洗 18w 里的近似重复标题) | 走 model.similarity() 或显式归一化 |
| 7 FastText Subword | 项目第 2 级模型(按字分词实测最好) | 子词缓解 OOV 的机制要能口述 |
四个模型共用一套预处理纪律(这是最容易被追问答不上来的地方):
- 分词口径:TF-IDF 和 FastText 都用 jieba 分好的
words列(FastText 实测按字更好,所以它走的是”按字”那条线);BERT 用tokenizer.batch_encode_plus()自己的子词切分,不套 749 停用词。 - 切分纪律:18w/1w/1w 的划分在任何统计量(词表、IDF、
max_length)之前完成。 fit/transform纪律:TfidfVectorizer只在训练集上fit_transform,验证/测试/线上只transform。测试集上误用fit_transform= 列对不上 + IDF 被污染。- 一致性纪律:停用词表、自定义词典、jieba 版本、截断长度,任何一样变了都要重新跑全线;不一致不会报错,只会掉点。
八、重点问答
Q1:文本预处理都有哪些步骤?你在项目里是怎么做的?
A:我一般分三大块。第一块是先看数据,不急着清洗,用 countplot 看标签分布、用 histplot 看句子长度分布、用 stripplot 看正负样本长度散点、用 jieba 加 Counter 统计词频和词表大小、再用形容词词云抽检语料质量。这一块的产出很实在:长度分布决定我的 max_length,标签分布决定我的评估指标不能只看准确率。第二块是把文本变成模型能吃的特征,包括统一大小写和全半角、去 URL 和特殊符号、分词、过停用词、截断补齐到固定长度,传统路线上还会加 n-gram。第三块是数据增强,样本不够时用回译。我做的新闻标题分类里,18 万条数据先逐行按制表符切成标题和标签,再用 jieba 分词、过 749 个停用词、拼成空格串落盘,然后这份分词结果直接复用到 TF-IDF 和 FastText 两条线上。
Q2:中文分词为什么不能像英文那样按空格切?jieba 有哪几种模式,你用哪种?
A:英文单词之间天然有空格,按空格切就行;中文书写单位是字,词和词之间没有分隔符,一段 n 个字的文本可能有指数级的切分方案,”南京市长江大桥”就是经典歧义,所以中文分词本质是一个歧义消解任务。jieba 走的是前缀词典建 DAG、再用动态规划找最大概率路径,遇到词典里没有的连续汉字片段用 HMM 加 Viterbi 切。它有三种模式:精确模式是默认的、输出最可能的一条路径,实测”我来到北京清华大学”切成”我/来到/北京/清华大学”;全模式输出所有可能成词,会切出”清华/清华大学/华大/大学”这种重叠结果;搜索引擎模式是在精确模式基础上把长词再切出子词。建模我只用精确模式,全模式会重复计数、产生噪声,搜索引擎模式是给倒排索引和关键词匹配用的。另外我一定会挂自定义词典,因为新闻标题里的新词和专有名词在默认词典里容易被切碎。
Q3:停用词为什么要去?去掉有什么代价?
A:去的理由有两个:一是降维,中文里”的、了、是、在”这类词占 token 的比例很高,删掉词表能明显变小、矩阵更稀疏;二是降噪,这些词几乎每篇文档都有,区分度为 0。要强调一点:IDF 只能把它们的权重压低,删不掉矩阵里那一列,stop_words 参数是直接把列删掉,所以我们才额外准备了一份停用词表。代价是删错词会让语义翻车,最典型的是否定词”不、没、未、无”,情感分类里”不满意”去掉”不”就变成”满意”,标签直接反了。所以我有一条纪律:停用词表是任务相关的,不存在通用最优值,评论和情感类的任务必须人工过一遍词表,把否定词和程度副词留在里面。我们项目里那 749 个停用词只喂给 TF-IDF,FastText 和 BERT 都不套它。
Q4:max_length 怎么选?截断和补齐怎么实现?
A:max_length 不是拍脑袋来的,是先画句子长度分布直方图、取一个能覆盖绝大多数样本的分位数(经验上 95% 到 99%)。取最大值会让大量短句子被 PAD 填满,浪费算力,PAD 还会稀释 attention;取太小又会切掉关键信息。实现上补的元素是 <PAD>、索引 0,长句截断短句补齐。Keras 有现成的 pad_sequences,但要注意它默认是 padding='pre'、truncating='pre',也就是前面补、从头截、保留句尾,对新闻标题这种关键词在头部的文本就有害了,而且 TensorFlow 官方建议用 RNN 时用 post padding 才能走 cuDNN 加速。所以我这两个参数永远显式传。还有两个必须配套的动作:0 必须是 <PAD> 的 id,而且 PAD 位要用 attention mask 屏蔽掉,否则短句结果会被整片 PAD 影响。我们项目里新闻标题短,材料里这套长度统计落到工程上就是”每条标题只保留前 30 个词”。
Q5:n-gram 是什么?什么时候用、什么时候不用?
A:n-gram 是连续 n 个 token 组成的特征,用来捕捉局部词序。1-gram 是单词,2-gram 是相邻两词组合,3-gram 是三词组合,常用的就是 2 和 3。它的价值我一般用一个例子讲:句子”我喜欢深度学习”和”我讨厌深度学习”,用 unigram 的词频算余弦相似度是 0.75,明显偏高,因为词袋根本分不清喜欢和讨厌;加上 2-gram 之后相似度降到 0.57,更能反映差异。实测在 sklearn 里相似度会随 n-gram 变细单调下降,TF-IDF 从 ngram_range=(1,1) 的 0.818 降到 (1,3) 的 0.515。但边界也很清楚:n-gram 是传统机器学习路线的特征工程手段,现代深度学习流程几乎不用,因为 BERT 这类模型的 self-attention 本来就在 token 之间建模,n-gram 提供的那点局部信息已经被覆盖,加了只会带来维度爆炸和稀疏。今天它主要出现在三个地方:FastText 的 wordNgrams 参数、检索的倒排索引、以及传统基线的特征。
Q6:TF+Cosine 和 SBERT 算句子相似度有什么区别?
A:一句话概括:前者是词频统计,后者是语义编码。TF-IDF 加余弦是把句子变成词表维度上的稀疏向量,算出来的相似度只反映”用词重叠多少”,没有同义和反义的概念,所以”我喜欢深度学习”和”我讨厌深度学习”会得到 0.75 这种偏高的分数;它轻量、CPU 毫秒级、可解释,适合小规模基线和关键词匹配。SBERT 是先让 BERT 编码出带上下文的 token 向量,做 Mean Pooling 得到句向量,再做 L2 归一化,最后算余弦;归一化之后余弦就退化成点积,方便做向量检索。它的语义能力强,同义句大概 0.8 到 0.95、低相关 0.3 到 0.5、无关句小于 0.2。我实测过”我喜欢深度学习”和”我喜欢玩游戏”用 all-MiniLM-L6-v2 得到 0.8185,模型输出是 384 维,语义相近但不完全同义,数值落在这个区间的下沿,符合直觉。工程上清洗数据、做语义去重和检索我会选 SBERT,做快速基线对比或者资源受限的场景就用 TF-IDF。
Q7:OOV 是什么?有哪些手段处理?
A:OOV 是未登录词,就是推理时出现、但训练词表里没有的 token。它的危害在传统路线上特别隐蔽:TfidfVectorizer.transform() 遇到词表外的词是静默丢弃的,一条全是生词的文本会得到一个全 0 向量,不报错,线上就是一个相当于瞎猜的结果。处理手段分三层:第一层是词表设计,一定要预留 <UNK> 和 <PAD>,OOV 统一映射到 <UNK>,别让它把索引打乱;第二层是子词建模,FastText 会把词拆成字符 n-gram(常用 3 到 6),词向量由子词向量聚合,等于未见过的新词也能用它的子词拼出向量,这就是它对 OOV 更鲁棒的原因,BERT 的 WordPiece 也是同一思路;第三层是分词粒度,中文按字切分基本没有 OOV,我们项目里 FastText 四组实验的结果就是”按字分词 + 手动调参”最好,原因之一就是按词分词会让测试集遇到更多生词。
Q8:怎么避免训练和验证之间的数据泄漏?
A:三条纪律。第一,先划分数据集,再做任何统计,词表、IDF、停用词表、max_length 这些统计量都只能在训练集上算,否则验证集的信息会通过它们泄漏进训练过程。第二,fit 和 transform 严格分开:TfidfVectorizer.fit_transform() 只能在训练集上用一次,验证集、测试集、线上推理一律用 transform(),复用训练集的词表和 idf_;如果在测试集上误用 fit_transform,既会让特征列对不上,也会让 idf_ 被测试分布污染,报出来的指标不可信。第三,预处理代码要完全一致,训练、验证、测试走同一套分词、停用词和截断逻辑,任何一样变了都不报错、只是指标悄悄掉。另外做数据增强的时候,生成的样本必须和验证集隔离,不能把回译出来的句子混进验证集。
九、延伸阅读
数据集 / 语料
– 中文酒店评论语料(本材料第 1 章使用的二分类情感分析语料,train.tsv 2960 条 / dev.tsv 1000 条)
工具与官方文档
– jieba 分词:https://github.com/fxsjy/jieba (三种模式、自定义词典、词性标注、pseg)
– OpenCC 繁简转换:https://github.com/BYVoid/OpenCC
– scikit-learn 文本特征提取 CountVectorizer / TfidfVectorizer(注意 token_pattern 默认 (?u)\b\w\w+\b 对中文的影响):https://scikit-learn.org/stable/modules/feature_extraction.html
– TensorFlow / Keras pad_sequences API(padding / truncating 默认值均为 pre):https://www.tensorflow.org/api_docs/python/tf/keras/utils/pad_sequences
– TensorFlow 官方 masking & padding 指南(RNN 场景建议 post padding):https://www.tensorflow.org/guide/keras/understanding_masking_and_padding
– Sentence-Transformers(SBERT)与 all-MiniLM-L6-v2:https://www.sbert.net/
– FastText(subword 建模与预训练词向量):https://fasttext.cc/
– deep_translator / translate(回译增强用的免费翻译接口)
– WordCloud(词云):https://github.com/amueller/word_cloud
– 中文停用词表常用来源(多份可对照,用于自建项目词表的底表):https://github.com/goto456/stopwords
论文 / 综述方向(按主题检索,不锁定具体年份与作者)
– Chinese word segmentation 的词典 + 统计混合方法(jieba 所属的技术路线)
– Subword / BPE / WordPiece 与 OOV 鲁棒性
– Sentence-BERT:句向量与语义相似度
– 数据增强综述:回译(back-translation)与同义改写