文本处理与经典 NLP


文档摘要

文本处理与经典 NLP 文本处理把原始字符转换成模型能够消化的结构化表示。本文件涵盖分词(词级、子词、BPE、WordPiece)、文本归一化、编辑距离、TF-IDF、n-gram 语言模型、POS 标注、NER 和情感分析——这条经典的 NLP 流水线至今仍是现代系统的基石。 原始文本是杂乱的。在任何 NLP 模型能够处理语言之前,文本都必须先被清洗、归一化,并转换成结构化的表示。本文件讲的是从原始字符到模型可用特征的整条流水线,以及在深度学习兴起之前统治 NLP 的那些经典算法。 文本归一化(text normalisation)把原始文本转换成一个规范形式。目标是削减无关紧要的变体,让 "Hello"、"hello"、"HELLO" 和 "héllo" 得到恰当的处理。

文本处理与经典 NLP

文本处理把原始字符转换成模型能够消化的结构化表示。本文件涵盖分词(词级、子词、BPE、WordPiece)、文本归一化、编辑距离、TF-IDF、n-gram 语言模型、POS 标注、NER 和情感分析——这条经典的 NLP 流水线至今仍是现代系统的基石。

  • 原始文本是杂乱的。在任何 NLP 模型能够处理语言之前,文本都必须先被清洗、归一化,并转换成结构化的表示。本文件讲的是从原始字符到模型可用特征的整条流水线,以及在深度学习兴起之前统治 NLP 的那些经典算法。

  • **文本归一化(text normalisation)**把原始文本转换成一个规范形式。目标是削减无关紧要的变体,让 "Hello"、"hello"、"HELLO" 和 "héllo" 得到恰当的处理。

  • **大小写折叠(case folding)**把文本统一转为小写。这样 "The" 和 "the" 就坍缩成同一个词元。对大多数任务都有帮助,但在某些情况下会毁掉有用的信息:"US"(国家)对 "us"(代词),或 "Apple"(公司)对 "apple"(水果)。

  • Unicode 归一化处理的是同一个字符可以有多种编码方式这一事实。字符 "é" 可以是一个单独的码点(U+00E9),也可以是一个基础字符 "e" 加上一个组合用变音符号(U+0065 + U+0301)。NFC 归一化把它们组合成一个码点;NFD 则把它们拆开。如果不归一化,两个看起来一模一样的字符串可能根本匹配不上。

  • **编辑距离(edit distance)**衡量两个字符串相差多少。**Levenshtein 距离(Levenshtein distance)**计算的是把一个字符串变成另一个所需要的最少的单字符插入、删除和替换次数。"kitten" → "sitting" 的编辑距离是 3(k→s、e→i、插入 g)。

  • 编辑距离用动态规划来算(我们在算法那一章会复习)。把 D[i][j] 定义为字符串 s 的前 i 个字符与字符串 t 的前 j 个字符之间的距离:

D[i][j] = \begin{cases} j & \text{if } i = 0 \\ i & \text{if } j = 0 \\ D[i{-}1][j{-}1] & \text{if } s[i] = t[j] \\ 1 + \min(D[i{-}1][j], \; D[i][j{-}1], \; D[i{-}1][j{-}1]) & \text{otherwise} \end{cases}
  • 编辑距离是拼写纠错、模糊匹配和 DNA 序列比对的引擎。在 NLP 里,它被用来处理错别字和查找相似的词。

  • **分词(tokenisation)**把文本切分成模型能够处理的离散单元(词元 token)。这是第一个、也可以说是最重要的预处理步骤。分词策略的选择会深刻影响模型的行为。

  • **空白分词(whitespace tokenisation)**按空格切分。简单但天真:"New York" 会变成两个词元,"don't" 是一个词元(或者根据切分器被切成 "don" 和 "'t"),而像中文、日文这些语言,词与词之间根本没有空格。

  • **基于规则的分词(rule-based tokenisation)**用手工编写的模式(正则表达式)来处理缩写、标点和各种特殊情况。"I'm" → "I" + "'m","U.S.A." 保持为一个词元。每种语言都需要自己的规则,非常费时费力。

  • **子词分词(subword tokenisation)**是现代的解决方案。它不再在词边界处切分,而是从数据中学习一个由高频子词单元组成的词表。这优雅地处理了未登录词:如果 "unhappiness" 不在词表里,它可能被切成 "un" + "happi" + "ness",保留了形态结构。

对 "unhappiness" 和 "transformers" 进行词级、字符级和子词分词的对比

  • **字节对编码(Byte-Pair Encoding,BPE)**以单个字符作为初始词表,然后不断找出出现最频繁的相邻符号对,把它们合并成一个新词元。经过足够多的合并之后,常见的词就成了单个词元,而罕见词则被切成高频的子词片段。

  • BPE 算法:

    1. 用训练语料中所有单个字符初始化词表
    2. 统计每个相邻词元对的频率
    3. 把频率最高的那对合并成一个新词元
    4. 重复第 2-3 步,直到达到预期的合并次数(词表大小)
  • 举个例子,初始有 "l o w"(出现 5 次)、"l o w e r"(2 次)、"n e w e s t"(6 次):频率最高的对可能是 "e s" → 合并成 "es"。然后 "es t" → "est"。接着 "n e w" → "new"。最终的词表里既有完整的词,也有子词片段。

  • WordPiece(BERT 使用)和 BPE 类似,但选择合并的依据是似然而不是频率。它合并的是那个能让训练数据的语言模型似然提升最大的对。不在词首的子词词元会加上 "##" 前缀(例如 "playing" → "play" + "##ing")。

  • Unigram(SentencePiece 使用)走的是相反的路线:先建一个大词表,然后不断移除那些被移除后对训练数据似然损害最小的词元。最终词表就是最能解释整个语料的那一组子词单元。

  • SentencePiece 是一个与语言无关的分词库,它把输入当作原始字节流来处理(不预先按空格切分)。这让它对任何语言都适用,包括那些没有空格的语言。它同时实现了 BPE 和 Unigram 两种算法。

  • 词表大小是一个关键超参数。常见取值在 30,000 到 100,000 个词元之间。词表更大意味着每个序列的词元更少(更高效),但嵌入表也更大。词表更小则意味着更多的子词切分和更长的序列。

  • 词干提取和词形还原这两种技术都是把词还原到基础形式,但思路不同。

  • **词干提取(stemming)**用粗暴的规则砍掉后缀。Porter 词干器把 "running" 还原成 "run","happiness" 还原成 "happi","studies" 还原成 "studi"。它快但不精确:"university" 和 "universe" 毫无关系,却都被还原成 "univers"。

  • **词形还原(lemmatisation)**借助词汇表和形态分析找到真正的词典形式(词目 lemma)。"Running" → "run","better" → "good","mice" → "mouse"。它需要知道词类:"saw" 作为动词还原成 "see",但作为名词则保持 "saw"。

  • 现代子词分词在神经网络 NLP 中已经基本取代了词干提取和词形还原,但在信息检索以及使用较小模型或数据有限的场景下,它们依然有用。

  • **词性标注(part-of-speech tagging,POS 标注)**给每个词分配一个语法类别:名词、动词、形容词、限定词等。这是最古老的 NLP 任务之一,也是句法分析的基础。

  • 对英语来说,最常用的是 Penn Treebank 标签集,共 36 个标签(NN 表示单数名词、NNS 表示复数名词、VB 表示动词原形、VBD 表示过去时、JJ 表示形容词等)。

  • POS 标注棘手的地方在于很多词有歧义。"Book" 可以是名词("the book"),也可以是动词("book a flight")。"Run" 跨越多种词性有几十种意义。语境至关重要。

  • 早期的标注器用的是第 5 章里的隐马尔可夫模型(Hidden Markov Models,HMM)。隐藏状态是 POS 标签,观测是词。转移概率刻画标签序列(限定词后面很可能跟着名词或形容词),发射概率刻画哪些词会出现带哪些标签。Viterbi 算法找出最可能的标签序列。

  • 用于 POS 标注的 HMM 模型:

\hat{t}_{1:n} = \arg\max_{t_{1:n}} \prod_{i=1}^{n} P(w_i \mid t_i) \cdot P(t_i \mid t_{i-1})
  • 现代的 POS 标注器使用神经网络(双向 LSTM 或 Transformer),在英语上能达到 97% 以上的准确率,已接近人类水平。

  • **命名实体识别(Named Entity Recognition,NER)**识别并分类文本中的专有名词和其他特定实体:人物、组织、地点、日期、金额等。

  • 在 "Apple CEO Tim Cook announced the event in Cupertino on Monday," 中,NER 系统应该识别出:Apple(ORG)、Tim Cook(PER)、Cupertino(LOC)、Monday(DATE)。

  • NER 通常被框定为**序列标注(sequence labelling)**问题,使用 BIO 标注(也叫 IOB 标注)。每个词元得到一个标签:

    • B-TYPE:某个 TYPE 类型实体的开头
    • I-TYPE:某个 TYPE 类型实体的内部(延续)
    • O:在任何实体之外
  • "Tim Cook visited New York" 会变成:Tim/B-PER Cook/I-PER visited/O New/B-LOC York/I-LOC。B 标签标记了一个新实体的起点,这在两个同类实体相邻时很重要。

带有颜色编码 BIO 标签的句子:B-PER(红)、I-PER(红)、O(灰)、B-LOC(蓝)、I-LOC(蓝)

  • 经典的 NER 用第 5 章里的条件随机场(Conditional Random Fields,CRF),它建模的是给定输入下整个标签序列的条件概率。和生成式的 HMM(建模 P(x, y))不同,CRF 是判别式的,直接建模 P(y \mid x)。一个线性链 CRF 定义为:
P(y_{1:n} \mid x_{1:n}) = \frac{1}{Z(x)} \exp\!\left(\sum_{i=1}^{n} \left[\sum_k \lambda_k f_k(y_i, x, i) + \sum_j \mu_j g_j(y_i, y_{i-1}, x, i)\right]\right)
  • 这里 f_k发射特征(在位置 i 的输入给定下,标签 y_i 有多可能),g_j转移特征(在前一个标签 y_{i-1} 给定下,标签 y_i 有多可能)。

  • 配分函数 Z(x) = \sum_{y'} \exp(\ldots) 对所有可能的标签序列求和来归一化分布。训练最大化条件对数似然,这需要用前向算法(第 5 章)高效地计算 Z(x)

  • 相比对每个词元独立分类,CRF 的关键优势在于:它的转移特征能强制施加结构约束(例如 I-PER 只能跟在 B-PER 或 I-PER 后面,绝不能出现在 O 之后)。

  • 现代 NER 把一个 CRF 叠在一个神经编码器之上(BiLSTM-CRF 或 BERT-CRF),其中神经网络产出发射分数,CRF 层学习转移结构。

  • **句法分析(syntactic parsing)**把一个句子转换成它的句法结构,要么是一棵成分树,要么是一棵依存树(两者都来自第 1 节)。

  • CYK 算法(Cocke-Younger-Kasami)用动态规划来分析上下文无关文法的句子。

  • 它要求文法处于乔姆斯基范式(Chomsky Normal Form)(每条规则的右侧要么是两个非终结符,要么是一个终结符)。它自底向上填满一张三角表:每个格子代表句子的一段区间,格子内存放的是能生成该区间的非终结符。

  • CYK 的运行时间是 O(n^3 \cdot |G|),其中 n 是句子长度,|G| 是文法大小。它是精确的,但对大文法来说很慢。

  • 移进-归约分析(shift-reduce parsing)从左到右处理句子,维护一个栈。每一步,它要么移进(把下一个词压入栈),要么归约(从栈中弹出若干元素,用一个短语替换它们)。一个训练好的分类器决定每一步的动作。它运行在 O(n) 时间,比 CYK 快得多。

  • **依存分析(dependency parsing)**在实践中现在比成分分析更常见。基于转移的依存分析器(类似移进-归约)和基于图的分析器(给所有可能的边打分,再找最大生成树)是两大主流。使用 BiLSTM 或 Transformer 的神经依存分析器取得了最优结果。

  • 在词嵌入出现之前,NLP 用简单的计数方法把文档表示成向量。

  • **词袋模型(bag-of-words,BoW)**把一个文档表示成一个词频向量,完全无视词序。如果词表有 V 个词,每个文档就是 \mathbb{R}^V 中的一个向量(呼应第 1 章的向量空间)。词 w 对应的那一维就是 w 在文档中出现的次数。

词袋模型:一篇文档被转换成一张词频表,再转换成 \mathbb{R}^V 中的一个稀疏向量,每个词表词对应一维

  • BoW 简单,但在文档分类和垃圾邮件过滤这类任务上出奇地有效。它的主要弱点是把每个词都看得同等重要:"the" 和 "revolutionary" 拿到一样的权重。

  • TF-IDF(Term Frequency-Inverse Document Frequency,词频-逆文档频率)通过根据词的 informativeness(信息量)来加权,解决了这个问题。在一个文档里频繁出现、但在整个语料中很少见的词,对这个文档来说很可能很重要。

\text{TF-IDF}(t, d) = \text{TF}(t, d) \times \text{IDF}(t)
  • 词频(term frequency) \text{TF}(t, d) 通常取词 t 在文档 d 中的原始计数(或取对数:1 + \log(\text{count}))。

  • 逆文档频率(inverse document frequency) \text{IDF}(t) = \log\frac{N}{|\{d : t \in d\}|},其中 N 是文档总数。出现在每个文档里的词(比如 "the")的 IDF 接近 0。罕见词则拿到高 IDF。

  • TF-IDF 向量可以用余弦相似度(第 1 章)来比较,以衡量文档之间的相似度。这是经典信息检索和搜索引擎的基础。

  • **语言模型(language model)**给一个词序列分配一个概率。它回答的是:这个句子有多可能?语言模型是机器翻译、语音识别、拼写纠错和文本生成的核心。

  • 一个句子 w_1, w_2, \ldots, w_n 的概率,按概率的链式法则(第 5 章)是:

P(w_1, w_2, \ldots, w_n) = \prod_{i=1}^{n} P(w_i \mid w_1, \ldots, w_{i-1})
  • 这式子精确但不实用:你得为每一种可能的历史都存下概率。**马尔可夫假设(Markov assumption,第 5 章)**把历史截断到最近的 k-1 个词,由此得到一个 n-gram 模型(其中 n = k)。

  • 一个二元模型(bigram model)n = 2)只以前一个词为条件:

P(w_i \mid w_1, \ldots, w_{i-1}) \approx P(w_i \mid w_{i-1})
  • 一个三元模型(trigram model)n = 3)以前两个词为条件。n-gram 概率通过在语料中计数来估计:
P(w_i \mid w_{i-1}) = \frac{\text{count}(w_{i-1}, w_i)}{\text{count}(w_{i-1})}
  • **困惑度(perplexity)**衡量一个语言模型在测试集上预测得有多好。它是测试集概率的倒数,并按词数归一化:
\text{PPL} = P(w_1, \ldots, w_N)^{-1/N} = \exp\!\left(-\frac{1}{N} \sum_{i=1}^{N} \log P(w_i \mid w_{<i})\right)
  • 困惑度越低,说明模型对测试数据越"不意外",因而越好。一个在 10,000 词的词表上均匀分配概率的模型,困惑度是 10,000。一个好的二元模型大概能达到 200 左右的困惑度。现代神经语言模型能把困惑度压到 20 以下。

  • 注意,困惑度就是交叉熵(第 5 章信息论)取指数。训练时最小化交叉熵损失,也就直接最小化了困惑度。

  • **平滑(smoothing)**处理的是零概率问题:如果某个 n-gram 在训练中从未出现,模型就给它分配概率 0,这会让整句的概率变成 0。**拉普拉斯平滑(Laplace smoothing,加 1)**给每个 n-gram 都加上一个小计数:

P_{\text{Laplace}}(w_i \mid w_{i-1}) = \frac{\text{count}(w_{i-1}, w_i) + 1}{\text{count}(w_{i-1}) + V}
  • 对大词表来说这太激进了(它从已观测的 n-gram 那里偷走太多概率)。Kneser-Ney 平滑是 n-gram 模型的黄金标准。它结合了两点:绝对折扣(absolute discounting)和一个用于回退的延续概率(continuation probability)。

  • 首先,绝对折扣从每个已观测的计数里减去一个固定的折扣 d(通常 d \approx 0.75),而不是加伪计数。被释放出来的概率质量重新分配给未见的 n-gram。插值形式是:

P_{\text{KN}}(w_i \mid w_{i-1}) = \frac{\max(\text{count}(w_{i-1}, w_i) - d, \; 0)}{\text{count}(w_{i-1})} + \lambda(w_{i-1}) \cdot P_{\text{cont}}(w_i)
  • 其中 \lambda(w_{i-1}) 是一个用来分配被折扣质量的归一化常数。关键创新是延续概率 P_{\text{cont}}(w_i),它衡量的是 w_i 出现在多少种不同的语境里,而不是它总共出现了多少次:
P_{\text{cont}}(w_i) = \frac{|\{w' : \text{count}(w', w_i) > 0\}|}{|\{(w', w'') : \text{count}(w', w'') > 0\}|}
  • 分子统计的是语料中有多少个不同的词出现在 w_i 之前。像 "Francisco" 这样的词只出现在极少数语境里(几乎总是跟在 "San" 后面),所以即便 "San Francisco" 非常频繁,"Francisco" 的延续概率也很低,不会在其他语境里被错误地预测出来。

  • 反过来,像 "the" 这样的常见词出现在很多不同的词之后,延续概率就高。这捕捉到了一个直觉:一个词的"多面手程度"比它的原始频率更影响回退估计。

  • n-gram 模型曾统治了几十年。它快、可解释、不需要训练(只要计数)。但它处理不了长程依赖("The keys that I left on the table are missing" 需要知道主语 "keys" 是复数,而它离动词很远)。从 RNN 开始、以 Transformer 收尾的神经语言模型,正是为了解决这一局限。

编程练习(使用 CoLab 或 notebook)

  1. 用动态规划实现 Levenshtein 编辑距离。在词对上测试它,并用它做简单的拼写纠错。
import jax.numpy as jnp def edit_distance(s, t): """用动态规划计算 Levenshtein 编辑距离。""" m, n = len(s), len(t) D = [[0] * (n + 1) for _ in range(m + 1)] for i in range(m + 1): D[i][0] = i for j in range(n + 1): D[0][j] = j for i in range(1, m + 1): for j in range(1, n + 1): if s[i-1] == t[j-1]: D[i][j] = D[i-1][j-1] else: D[i][j] = 1 + min(D[i-1][j], D[i][j-1], D[i-1][j-1]) return D[m][n] # 测试 pairs = [("kitten", "sitting"), ("sunday", "saturday"), ("hello", "hallo")] for s, t in pairs: print(f"d('{s}', '{t}') = {edit_distance(s, t)}") # 简单的拼写纠错 dictionary = ["the", "their", "there", "then", "than", "this", "that", "these", "those"] misspelled = "thier" corrections = sorted(dictionary, key=lambda w: edit_distance(misspelled, w)) print(f"\nClosest to '{misspelled}': {corrections[:3]}")
  1. 从零开始实现 BPE 分词。从字符级词元开始,迭代地合并最频繁的符号对。
from collections import Counter def get_pairs(corpus): """统计所有词中相邻的词元对。""" pairs = Counter() for word, freq in corpus.items(): symbols = word.split() for i in range(len(symbols) - 1): pairs[(symbols[i], symbols[i+1])] += freq return pairs def merge_pair(pair, corpus): """在语料中合并某符号对的所有出现。""" new_corpus = {} bigram = ' '.join(pair) replacement = ''.join(pair) for word, freq in corpus.items(): new_word = word.replace(bigram, replacement) new_corpus[new_word] = freq return new_corpus # 带词频的训练语料 text = "low low low low low lower lower newest newest newest newest newest newest" word_freqs = Counter(text.split()) # 初始化:把每个词按字符拆开,并加上词尾标记 corpus = {' '.join(word) + ' _': freq for word, freq in word_freqs.items()} print("Initial corpus:") for word, freq in corpus.items(): print(f" {word}: {freq}") # 运行 10 次 BPE 合并 for i in range(10): pairs = get_pairs(corpus) if not pairs: break best_pair = max(pairs, key=pairs.get) corpus = merge_pair(best_pair, corpus) print(f"\nMerge {i+1}: {best_pair} (freq={pairs[best_pair]})") for word, freq in corpus.items(): print(f" {word}: {freq}")
  1. 构建一个二元语言模型,并在一个测试句子上计算困惑度。尝试拉普拉斯平滑。
from collections import Counter, defaultdict import math # 训练语料 train = """the cat sat on the mat . the dog chased the cat . the cat ran from the dog . a dog sat on a mat .""".split() # 统计 bigram 和 unigram bigrams = Counter(zip(train[:-1], train[1:])) unigrams = Counter(train) vocab_size = len(set(train)) def bigram_prob(w2, w1, alpha=0): """带可选拉普拉斯平滑的 P(w2 | w1)。""" return (bigrams[(w1, w2)] + alpha) / (unigrams[w1] + alpha * vocab_size) # 计算困惑度 test = "the cat sat on a mat .".split() for alpha in [0, 1, 0.1]: log_prob = 0 for w1, w2 in zip(test[:-1], test[1:]): p = bigram_prob(w2, w1, alpha=alpha) if p > 0: log_prob += math.log(p) else: log_prob += float('-inf') ppl = math.exp(-log_prob / (len(test) - 1)) if log_prob > float('-inf') else float('inf') print(f"Smoothing α={alpha}: perplexity = {ppl:.2f}")
  1. 从零实现 TF-IDF,并用余弦相似度找出与查询最相似的文档。
import jax.numpy as jnp import math from collections import Counter documents = [ "the cat sat on the mat", "the dog chased the cat around the park", "a mat was placed on the floor by the door", "the quick brown fox jumped over the lazy dog", ] # 构建词表 vocab = sorted(set(word for doc in documents for word in doc.split())) word_to_idx = {w: i for i, w in enumerate(vocab)} V = len(vocab) N = len(documents) # 计算 TF-IDF 矩阵 doc_freq = Counter() for doc in documents: for word in set(doc.split()): doc_freq[word] += 1 tfidf_matrix = jnp.zeros((N, V)) for i, doc in enumerate(documents): word_counts = Counter(doc.split()) for word, count in word_counts.items(): tf = 1 + math.log(count) idf = math.log(N / doc_freq[word]) j = word_to_idx[word] tfidf_matrix = tfidf_matrix.at[i, j].set(tf * idf) # 查询 query = "cat on the mat" query_vec = jnp.zeros(V) query_counts = Counter(query.split()) for word, count in query_counts.items(): if word in word_to_idx: tf = 1 + math.log(count) idf = math.log(N / doc_freq.get(word, 1)) query_vec = query_vec.at[word_to_idx[word]].set(tf * idf) # 余弦相似度(来自第 1 章) def cosine_sim(a, b): return jnp.dot(a, b) / (jnp.linalg.norm(a) * jnp.linalg.norm(b) + 1e-8) print(f"Query: '{query}'\n") for i, doc in enumerate(documents): sim = cosine_sim(query_vec, tfidf_matrix[i]) print(f" Doc {i} (sim={sim:.3f}): '{doc}'")

作者与出处
原作者: HenryNdubuaku
来源:HenryNdubuaku
许可证:Apache-2.0
整理: 灏天文库整理
由灏天文库结构化整理,提供目录导航、全文检索与在线阅读,便于系统化学习
发布者: 作者: HenryNdubuaku 转发
评论区 (0)
U