约 25 分钟 · 需要 Python 字符串与基础概率

读完你能: 把一个文本任务拆成输入、表示、预测和评估四步;按任务选择清洗方式;构造词袋、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

词表有 VV 个词,每篇文档就是一个 VV 维向量。真实语料的词表很大,而一篇文档只用到其中很少的词,所以向量里绝大多数位置是 0,适合用稀疏格式存储。

词袋的缺点就写在名字里:它把词“装进袋子”,丢掉了顺序。dog bites man 和 man bites dog 的计数完全相同,谁咬谁却正好相反。

6. 余弦相似度

对非零向量 x,yx,y:

cos⁡(x,y)=xTy∥x∥2∥y∥2.\operatorname{cos}(x,y)=\frac{x^{T}y}{\lVert x\rVert_2\lVert y\rVert_2}.

D1 与 D2 只有 cat 相同,内积为 1;两个向量的长度都是 3\sqrt3,所以相似度为 1/31/3。D1 与 D3 有两个词相同,相似度为 2/32/3。

余弦只比较两个向量的方向,不受长度影响。因此长短不同的文本也能直接比较;把一篇文档整篇复制一遍,词频翻倍,方向不变,余弦也不变。

零向量(比如清洗后一个词都不剩的文档)没有方向,余弦在数学上没有定义。实现时必须事先规定怎么处理:返回 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 是连续 nn 个词元组成的片段。cat eats fish 有两个 bigram(cat eats、eats fish)和一个 trigram(cat eats fish)。

长度为 TT 的序列,在 T≥nT\ge n 时共有 T−n+1T-n+1 个 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/32/3。这说明余弦不受篇幅长短影响。

2. “not good”被处理成了“good”,错误出在表示环节还是分类模型?

出在预处理:否定信息在进入模型之前就被删掉了。后面的模型即使训练得再好,也无法恢复已经丢失的信息。