第1章 文本预处理:从字符串到词袋与 n-gram
读完你能: 把一个文本任务拆成输入、表示、预测和评估四步;按任务选择清洗方式;构造词袋、n-gram 并计算余弦相似度;识别“清洗过头”和数据泄漏。
1. 自然语言处理要把什么变成什么
自然语言处理不是某一个算法,而是一组把文字变成可计算对象的方法。不同任务的输入输出差别很大:
| 任务 | 输入 | 输出 | 容易混淆的地方 |
|---|---|---|---|
| 情感分类 | 一段评价 | 正面、负面等类别 | 模型给出的类别概率,并不说明它“为什么”这样判断 |
| 信息抽取 | 一段新闻 | 人名、机构、关系 | 找出两个机构名,不等于知道它们之间是什么关系 |
| 文档检索 | 查询与文档库 | 排好序的候选文档 | 与查询相似,不等于内容正确 |
| 文本生成 | 上下文 | 后续文本 | 写得流畅,不等于内容可靠 |
大多数系统都可以看成一条流水线:原始文本 → 切分与规范化 → 数值表示 → 模型 → 评估。规则、统计和神经网络方法可以在同一条流水线里配合使用,而不是非此即彼。比如客服工单系统可以先用正则识别订单号,再用分类器判断工单主题。
2. 清洗不是越干净越好
看三句话:I like it.、I do not like it.、C++ is useful.。如果一律删掉停用词和标点,not 可能被当作停用词删掉,C++ 可能只剩下 c——恰好丢掉了任务最关心的信息。
| 操作 | 常见用途 | 需要保留的例外 |
|---|---|---|
| 转小写 | 把 Cat 和 cat 当作同一个词 |
人名、缩写、区分大小写的标识符 |
| Unicode 规范化 | 统一全角字符和兼容字符 | 先确认不会改动专用符号 |
| 去除 HTML | 抽出网页正文 | 正文里的代码和数学符号 |
| 数字归一化 | 把不同金额、数量合并成一类 | 日期、产品型号、需要比较大小的数量 |
| 去停用词 | 稀疏检索时减少噪声 | 否定词、情态词、表示关系的词 |
所以要先写清任务,再决定怎样清洗。同一批文本,用于情感分类和用于关键词检索,往往需要两套不同的预处理。
3. 正则表达式与分词
正则表达式适合处理表面结构明确的模式,例如编号、空白和简单的词;它不理解句子的意思。
import re
import unicodedata
text = "Cats, cats! Don't erase C++ or version 2.0."
normalized = unicodedata.normalize("NFKC", text)
words = re.findall(r"[a-z]+(?:'[a-z]+)?", normalized.lower())
print(words)
# ['cats', 'cats', "don't", 'erase', 'c', 'or', 'version']
这个结果有意暴露了局限:它保留了英文单词里的撇号(don't),却把 C++ 变成了 c,版本号 2.0 也整个丢了。这条规则适合统计普通英文单词,不适合识别编程语言名称。遇到复杂符号时,要么为它们补专门的规则,要么换一个分词器。
中文不能照搬“按空格切词”。 “研究生命起源”可以切成“研究 / 生命 / 起源”,也可能被错切成“研究生 / 命 / 起源”。切分结果直接决定后面统计的是什么。子词切分还会把一个词拆成更小的单元,这时“词元”的个数就不再等于自然语言里的词数。
句子切分也不是遇到句号就切:小数、缩写和网址里都可能有句号。
4. 词干提取与词形还原
- 词干提取(stemming):按规则削掉词缀,目的是把同一个词的不同表面形式合并起来。结果不一定是词典里的词,例如有的规则会把
studies变成studi。 - 词形还原(lemmatization):借助词典、词性或上下文,还原出词的原形,例如动词
was还原为be。
同一个 saw,作名词是“锯子”,作动词可能是 see 的过去式。词形还原如果不知道词性,就可能还原错。另外,这两种方法都不处理同义词:car 和 automobile 仍然是两个不同的特征。
先问为什么要合并
搜索时把 connect 和 connected 合并,通常能找回更多相关文档;但做作者风格分析时,作者习惯用哪种词形,本身就是有用的信息。没有一种规范化方式适合所有任务。
5. 词袋:只看出现了什么,不看顺序
用三个句子组成一个小语料:
- D1:
cat eats fish - D2:
cat likes milk - D3:
dog eats fish
把词表顺序固定为 cat, dog, eats, fish, likes, milk,每篇文档记下各词出现的次数:
| 文档 | cat | dog | eats | fish | likes | milk |
|---|---|---|---|---|---|---|
| D1 | 1 | 0 | 1 | 1 | 0 | 0 |
| D2 | 1 | 0 | 0 | 0 | 1 | 1 |
| D3 | 0 | 1 | 1 | 1 | 0 | 0 |
词表有 个词,每篇文档就是一个 维向量。真实语料的词表很大,而一篇文档只用到其中很少的词,所以向量里绝大多数位置是 0,适合用稀疏格式存储。
词袋的缺点就写在名字里:它把词“装进袋子”,丢掉了顺序。dog bites man 和 man bites dog 的计数完全相同,谁咬谁却正好相反。
6. 余弦相似度
对非零向量 :
D1 与 D2 只有 cat 相同,内积为 1;两个向量的长度都是 ,所以相似度为 。D1 与 D3 有两个词相同,相似度为 。
余弦只比较两个向量的方向,不受长度影响。因此长短不同的文本也能直接比较;把一篇文档整篇复制一遍,词频翻倍,方向不变,余弦也不变。
零向量(比如清洗后一个词都不剩的文档)没有方向,余弦在数学上没有定义。实现时必须事先规定怎么处理:返回 0、标记为缺失,或者单独处理。
from collections import Counter
from math import sqrt
corpus = ["cat eats fish", "cat likes milk", "dog eats fish"]
tokens = [text.split() for text in corpus]
vocab = sorted({word for doc in tokens for word in doc})
counts = [Counter(doc) for doc in tokens]
vectors = [[count[word] for word in vocab] for count in counts]
def cosine(a, b):
denominator = sqrt(sum(x*x for x in a) * sum(y*y for y in b))
return sum(x*y for x, y in zip(a, b)) / denominator if denominator else 0.0
assert abs(cosine(vectors[0], vectors[2]) - 2/3) < 1e-12
7. n-gram:补回局部顺序
n-gram 是连续 个词元组成的片段。cat eats fish 有两个 bigram(cat eats、eats fish)和一个 trigram(cat eats fish)。
长度为 的序列,在 时共有 个 n-gram。把 bigram 加进特征,dog bites 和 man bites 就能区分开。也可以用字符级 n-gram,它对拼写变化更稳健,代价是特征维度更大。
def ngrams(tokens, n):
if n < 1:
raise ValueError("n must be positive")
return [tuple(tokens[i:i+n]) for i in range(len(tokens)-n+1)]
assert ngrams("cat eats fish".split(), 2) == [("cat", "eats"), ("eats", "fish")]
注意区分两件事:把 n-gram 当作特征来计数,和训练 n-gram 语言模型不是一回事。后者还要估计“前面几个词给定时下一个词的条件概率”,并对没见过的组合做平滑。
8. 别让测试数据参与预处理
词表、停用词阈值、数字归一化的规则,凡是“从语料里统计出来”的东西,都只能用训练集来确定。如果先用全部数据建好词表、再划分训练集和测试集,测试集里的信息就提前泄漏进了模型:某些只在测试集出现的词被收进了词表,评估结果会比真实情况乐观。正确顺序是先划分,再只在训练集上拟合预处理规则,然后用同一套规则去转换测试集。
9. 小结
清洗要保留任务真正需要的信号;分词决定统计的基本单位;词干提取和词形还原以不同程度合并词形;词袋简单但丢掉了顺序,n-gram 补回一部分局部顺序;余弦相似度比较的是向量方向。所有需要从语料中学习的预处理规则,都只能在训练集上拟合。
自测
1. 把 D1 中每个词重复两遍,它的计数向量与 D3 的余弦会怎样变?
D1 的向量变成原来的 2 倍,内积和 D1 的长度也都变成 2 倍,两者相除后余弦仍为 。这说明余弦不受篇幅长短影响。
2. “not good”被处理成了“good”,错误出在表示环节还是分类模型?
出在预处理:否定信息在进入模型之前就被删掉了。后面的模型即使训练得再好,也无法恢复已经丢失的信息。
- 感谢你赐予我前进的力量

