语言的原子:走进分词的世界 / The Atoms of Language: Into the World of Tokenization
语言的原子:走进分词的世界
The Atoms of Language: Into the World of Tokenization
想象一下,你手里有一堆积木,每一块积木看起来都差不多,但当你用不同的方式把它们组合在一起时,就能搭出汽车、城堡、或者一只看起来有点奇怪的恐龙。在自然语言处理的世界里,分词就是那个决定“积木应该怎么切”的关键步骤。切得太大,后面的模型就像拿着整块积木却不知道该放在哪里;切得太碎,模型又会被淹没在信息的海洋里不知所措。分词,看似简单的一个步骤,实际上决定了整个NLP系统的基因。
这个比喻可能有点抽象,让我们换一个更贴近生活的场景。假设你是一个刚学会阅读的小朋友,有人递给你一句话:“今天天气真好我们出去玩吧”。如果老师没有提前告诉你词语之间应该怎么断开,你可能会读成:“今天天气真好/我们出去玩吧”,也可能读成:“今天/天气/真好我们/出去玩吧”——后者听起来就有点莫名其妙了。人类的语言理解能力让我们能够轻松处理这种模糊性,但对于计算机来说,这种模糊性简直是噩梦的开始。
中文分词(Chinese Word Segmentation)之所以特别有趣,正是因为汉语的书写系统天然地在词语之间留白。英文、法文这些语言用空格明确标记词语边界,而中文、日文(不使用空格分词)等语言则把这个“断句”的任务留给了读者——或者说,留给了算法。想象一下,如果英文也去掉所有空格,“Thequickbrownfoxjumpsoverthelazydog”看起来是不是也很让人头疼?这就是中文NLP每天面对的挑战。
让我们先从最简单的分词方法说起——空格分词(Space-based Tokenization)。这几乎是所有西方语言分词的第一课,因为它们本身就自带词语分隔符。在Python中,实现一个基本的空格分词器只需要一行代码:
```python text = "The quick brown fox jumps over the lazy dog" tokens = text.split() print(tokens)
['The', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog']
```
简单得令人感动,对吧?但这份简单是有代价的。空格分词无法处理标点符号、连字符、缩写词等情况。"Dr. Smith"会被分成三个token,I'm会变成"I"和"m",而"state-of-the-art"这个技术词汇更是会被拆得七零八落。在实际应用中,我们通常需要先做一些预处理,比如去除标点符号、统一大小写、处理特殊符号等。但即便如此,空格分词对于英文这样的语言仍然是一个不错的起点。
现在,让我们把目光转向中文。中文分词的历史几乎与中国计算机科学的发展同步。1970年代末期,中国科学家开始尝试让计算机理解中文,最早的中文分词系统诞生于这个时期。那时候的算法还很粗糙,基本上是基于词典的机械匹配——系统手里拿着一本词典,对着输入的句子从头到尾扫描,遇到词典里有的词就切一刀。
这种方法的代表就是最大匹配法(Maximum Matching),听起来很高大上,其实原理很简单。假设你有一本词典,里面收录了“北京”、“北京大学”、“大学生”、“大学”、“学生”等词。现在要分“北京大学生”这个句子:
正向最大匹配(Forward Maximum Matching, FMM)的做法是:从句子开头开始,尽量匹配词典里最长的词。它会先看“北京大学”是不是一个词(是),于是切出“北京大学”;然后再看剩下的“大学生”是不是一个词(是),于是又切出“大学生”。最终结果就是“北京大学生”。一个词都没切错,看起来很完美。
但让我们换一个句子试试:“北京大学生研究生物。”按照同样的逻辑,正向最大匹配会先匹配“北京大学生”,然后匹配“研究生”,最后剩下“生物”。结果是:“北京大学生/研究生/生物”。这个切分在语法上是合理的,但语义上却可能出问题——这里的“生物”很可能是指“生物学”或者作为“生物”的名词用法,而不是“生物(研究)”这个动词短语与“物”字的组合。
逆向最大匹配(Backward Maximum Matching, BMM)则是从句子末尾开始向回扫描,同样尽量匹配最长的词。对于“北京大学生研究生物”,它会先看“研究生物”是不是一个词(是,“研究生”加“研究”),不对,这样说太复杂了。直接从末尾开始:最后一个词可能是“生物”,往前看,“研究生物”是不是一个词?不是。往前看,“学生物”是不是?不是。再往前,“研究生”是一个词!于是切出“研究生”。然后剩下“北京大学生”,继续从后往前匹配...最终结果可能是“北京/大学生/研究生/物”。这看起来更加奇怪了。
为什么会出现这种情况?因为中文的语法结构有时会让较短的词在前面组成更合理的搭配。语言学家把这种现象叫做组合歧义(Combinatorial Ambiguity)——不同的切分方式在语法上都说得通,但表达的语义不同或者优先级不同。
还有一种更麻烦的歧义叫做交叉歧义(Crossing Ambiguity)。考虑“中外科学合作”这个短语。它可以被切分为“中外/科学/合作”(中国和外国的科学合作)或者“中/外科/学合作”(医学和科学领域的合作)。这种歧义涉及相邻词语边界的重叠交叉,更难处理。
为了解决这些问题,学者们提出了双向最大匹配(Bidirectional Maximum Matching)。它的思路很直接:分别用正向和逆向最大匹配对同一句话进行分词,然后比较结果。如果两种方法得到的结果完全相同,那就认为分词是正确的;如果不同,则需要进一步判断哪种切分更合理,通常会选择词数较少的那种(这被称为“最少词数原则”)。
然而,这些基于词典的方法有一个根本性的缺陷:未登录词问题(Out-of-Vocabulary, OON)。所谓未登录词,就是那些不在词典里但确实是词的词语。新词汇每天都在产生:网络流行语(“内卷”、“躺平”、“yyds”)、人名(“马斯克”、“谷爱凌”)、地名(“雄安新区”)、机构名(“字节跳动”)……任何词典都不可能收录所有的词。而且,中文里还有大量的专有名词、数字、日期、量词短语等,这些也很难全部收入词典。
一个典型的未登录词例子是“王者荣耀”。这个词对于2015年之前编写的任何词典都是未登录的——当时这款游戏还没有诞生。但对于现代的分词系统来说,识别出这是一个专有名词(游戏名)而不是切分成“王/者/荣耀”非常重要。
面对这些挑战,单纯的词典匹配已经力不从心了。研究者们开始思考:能否让机器自动从数据中学习分词的规律,而不是依赖于人工编写的词典?这种思路直接催生了基于统计的分词方法。
统计的力量:从规则到学习
统计分词的核心思想是把分词问题转化成一个数学问题。给定一个句子$s = c_1c_2...c_n$(其中$c_i$是每个字符),我们需要找到一种最优的分词方式$T = t_1, t_2, ..., t_m$,使得$P(T|S)$最大。根据贝叶斯定理:
$$P(T|S) = \frac{P(S|T) \cdot P(T)}{P(S)}$$
由于$P(S)$对于所有可能的分词方案都是相同的,最大化$P(T|S)$等价于最大化$P(S|T) \cdot P(T)$。这里$P(T)$是语言模型概率(衡量分词结果的“通顺程度”),$P(S|T)$是生成概率(衡量在这种分词下生成该句子的可能性)。
在实际应用中,我们通常采用一种更直观的方法:最大概率分词。假设我们已经知道每个词的概率$P(w)$,以及词与词之间的转移概率,那么一个句子的分词方案就是使整句话概率最大的那个切分。用数学语言来说:
$$\hat{T} = \arg\max_T \prod_{i=1}^{m} P(w_i)$$
这个公式的意思是:对于所有可能的分词方案$T$,选择让词语序列概率乘积最大的那个。词语概率可以从大规模的语料库中统计得到。比如在1亿字的中文语料中,“的”字出现了约5000万次,那么$P(“的”)$就是0.5;“葡萄”可能出现5000次,$P(“葡萄”)$就是0.00005。
但这里有一个关键问题:概率乘积会非常小,连乘可能导致下溢。解决办法是对概率取对数,把乘法变成加法:
$$\hat{T} = \arg\max_T \sum_{i=1}^{m} \log P(w_i)$$
现在,即使某个句子被切分成很多很多个词,概率的对数之和仍然是一个合理的数值。
让我们用一个简单的例子来理解这个过程。假设我们有以下词语概率(对数形式):
| 词语 | $\log P(w)$ | |------|-------------| | 研究 | -2.3 | | 生 | -4.5 | | 物 | -3.8 | | 研究生物 | -6.5 | | 生物 | -4.2 |
对于“研究生物”这个字符串,两种可能的切分是:
- 切分1:研究/生物 → 对数概率和 = -2.3 + (-4.2) = -6.5
- 切分2:研究生/物 → 对数概率和 = -6.5 + (-3.8) = -10.3
显然,切分1的概率更高,因此我们选择“研究/生物”。
隐马尔可夫模型:让机器学会“猜”
虽然最大概率分词在很多情况下效果不错,但它仍然有一个局限:它只考虑了单个词的概率,而没有考虑词与词之间的上下文关系。比如,“研究生物”和“研究生事物”这两个短语中,每个单字组合可能都是合法的词,但哪种搭配更常见呢?
隐马尔可夫模型(Hidden Markov Model, HMM)提供了一种优雅的方式来处理这个问题。HMM最早用于语音识别,后来被引入到中文分词领域,效果出乎意料地好。
HMM的核心假设是:虽然我们观察不到真正的“状态”(这里指词语边界),但每个观测(字符)只与当前状态相关。应用到分词上,这意味着每个汉字只与它所属的词语状态有关,而不与整个句子结构相关。
在分词场景中,HMM定义四种状态,分别对应一个汉字在词语中的位置:
- **B(Begin)**:词语的开始
- **M(Middle)**:词语的中间
- **E(End)**:词语的结束
- **S(Single)**:单独成词的字(即整个词只有一个字)
例如,“我们”的标注是“S/S”(两个单字词),“研究”的标注是“B/E”(开始字和结束字),“研究生”的标注是“B/M/E”(开始、中间、结束)。
HMM需要学习两组参数:
1. 初始概率 $P(S_i)$:一个句子开头处于某种状态的概率 2. 转移概率 $P(S_j|S_i)$:从一个状态转移到另一个状态的概率 3. 发射概率 $P(c|S)$:在某个状态下产生某个汉字的概率
一旦这些参数从训练语料中学习得到,给定一个新的句子,我们就可以使用维特比算法(Viterbi Algorithm)来找到最可能的状态序列。维特比算法是一种动态规划算法,它通过保存中间结果来高效地找到全局最优解。
```python import numpy as np
def viterbi(obs, states, start_p, trans_p, emit_p): """ 维特比算法实现 obs: 观测序列(字符列表) states: 状态列表 ['B', 'M', 'E', 'S'] start_p: 初始概率 trans_p: 转移概率矩阵 emit_p: 发射概率 """ V = [{}] # V[t][state] = 最大概率
for state in states: V[0][state] = start_p[state] * emit_p[state].get(obs[0], 1e-10)
for t in range(1, len(obs)): V.append({}) for state in states: max_prob = 0 for prev_state in states: prob = V[t-1][prev_state] * trans_p[prev_state].get(state, 0) if prob > max_prob: max_prob = prob V[t][state] = max_prob * emit_p[state].get(obs[t], 1e-10)
回溯找到最优路径
path = [] max_final = 0 for state in states: if V[-1][state] > max_final: max_final = V[-1][state] path = [state]
for t in range(len(obs) - 2, -1, -1): for prev_state in states: if V[t][prev_state] * trans_p[prev_state].get(path[0], 0) == V[t+1][path[0]]: path.insert(0, prev_state) break
return path
def decode_hmm(path, sentence): """将状态序列转换为分词结果""" words = [] word = "" for i, state in enumerate(path): char = sentence[i] if state == 'S': words.append(char) elif state == 'B': word = char elif state == 'M': word += char elif state == 'E': word += char words.append(word) word = "" return words ```
HMM的一个显著优点是它能够处理未登录词。因为模型学习的是字符与状态之间的对应关系,而不是具体的词语,所以对于训练语料中从未出现过的词,只要它的组成字符模式是合理的,模型仍然能够正确分词。
当然,HMM也有它的局限性。由于它假设每个状态只依赖于前一个状态(即一阶马尔可夫假设),这在某些情况下过于简化了。例如,在“乒乓球拍卖完了”这个句子中,“乒乓球”和“拍卖”之间的歧义可能需要更长的上下文才能准确判断。
条件随机场:考虑更多上下文的利器
条件随机场(Conditional Random Field, CRF)是另一种强大的序列标注模型,它在分词任务上通常比HMM表现更好。CRF的核心思想是:为整个序列建立一个联合概率模型,而不是像HMM那样假设每个观测只与当前状态相关。
在CRF中,我们定义特征函数来表示输入序列中各种上下文信息对当前标注的影响。常见的特征包括:
- 当前字符
- 当前字符的前一个和后一个字符
- 字符类型(是否数字、是否字母、是否标点)
- 字符组合(如“ABC”、“XYZ”)
- 字符在词语中的位置(开头、结尾等)
- n-gram特征(如前两个字符、前一个字符加当前字符)
特征函数的形式化定义是:$f_j(y_{i-1}, y_i, x, i)$,其中$y_{i-1}$是前一个标签,$y_i$是当前标签,$x$是输入序列,$i$是当前位置。CRF模型给每个特征函数分配一个权重,然后通过计算特征函数值的线性组合来得到每个标签序列的得分。
CRF的参数学习是一个优化问题:我们需要找到一组权重,使得标注正确的序列的概率最大。这个过程通常使用梯度下降或拟牛顿法(如L-BFGS)来完成。一旦模型训练完成,给定新的句子,我们就可以用维特比算法找到得分最高的标签序列。
```python
使用CRF++风格的特征模板
def extract_features(sentence, i): """提取当前位置的特征""" features = [] char = sentence[i]
基本特征
features.append(f'C0:{char}') # 当前字符 features.append(f'C-1:{sentence[i-1] if i > 0 else "BOS"}') # 前一个字符 features.append(f'C1:{sentence[i+1] if i < len(sentence)-1 else "EOS"}') # 后一个字符
字符类型特征
features.append(f'T0:{get_char_type(char)}') # 当前字符类型
上下文组合特征
if i > 0: features.append(f'C-1C0:{sentence[i-1]}{char}') if i < len(sentence) - 1: features.append(f'C0C1:{char}{sentence[i+1]}')
位置特征
if i == 0: features.append('BOS') if i == len(sentence) - 1: features.append('EOS')
return features ```
CRF在中文分词上的表现通常优于HMM,主要原因是它能够利用更丰富的上下文信息。但CRF的训练成本也更高,需要更多的计算资源和标注数据。
分词歧义:永远的朋友与敌人
无论采用多么复杂的算法,分词歧义问题始终是悬在NLP研究者头顶的一把剑。歧义不是程序的bug,而是语言本身的特点。让我们看几个经典的歧义案例:
组合歧义的经典案例是“马上”。这个词可以是“马/上”(名词+方位词,表示在马背上),也可以是“马上”