引子:上一篇文章我们从中文分词的历程、发展、问题等方面进行了理论性的探讨,从概念方面对中文分词有了一定的了解与认识,今天,我们主要探讨中文分词中的机械分词。
机械分词
简介
按照一定的策略将待分析的字符串与一个"充分大的"机器词典中的词条进行匹配,若在词典中找到某个字符串,则匹配成功(识别出一个词)。
需要先建立词典,再通过匹配的方法进行分词。
常见的算法
-
正向最大匹配法(forward maximum matching method,FMM)
正向最大匹配法是指从左到右逐渐匹配词库中的词语,匹配到最长的词语为止。
-
逆向最大匹配法(backward maximum matching method,BMM)
逆向算法,即从右往左匹配,其他逻辑和前向算法相同
-
双向最大匹配法(Bidirectional Maximum Matching)
同时使用正向最长匹配和逆向最长匹配,并根据规则选择分词结果。
-
全切分法(Full Segment)
将句子中所有在词典中出现的词汇都找出来。正向遍历所有窗口大小的ngram,如果能够匹配词典中的词,那么就作为一个切分输出。
-
N-最短路径方法
最短路径分词算法首先将一句话中的所有词匹配出来,构成词图(有向无环图DAG),之后寻找从起始点到终点的最短路径作为最佳组合方式
这些算法本质上是查词典,为避免无效的扫描,提高分词速度,可以添加约束,如词的最大长度,遇到停用词跳出循环等等。这些方法简单、快速,并且只需要一个足够大的词典即可。
匹配算法中,存在较多切分歧义问题。切分歧义研究包括歧义发现和歧义消解,歧义消解主要采用规则和统计的方法,如基于N-gram语言模型的分词方法
由于算法简单,机械分词具有分词速度快的天然优势。然而,分词准确率与词典的好坏正相关,在未登录词较多的情况下,算法的准确率无法保证。
代码实践
基础处理
dict.txt
创建的查询词典,具体可自行创建
arduino
# dict.txt
计算机
科学
研究
自然语言
处理
机器
学习
大学
学生
北京大学
北京大学生
清华大学
人工智能
自动化
软件
硬件
系统
程序
开发
设计
测试
运行
....
python
class Dictionary:
"""中文词典类,用于加载、存储和查询中文词汇"""
def __init__(self, dict_file=None):
"""
初始化词典
"""
self.words = set()
self.max_len = 0
if dict_file:
self.load(dict_file)
def load(self, dict_file):
"""
从文件加载词典
"""
with open(dict_file, 'r', encoding='utf-8') as f:
for line in f:
word = line.strip()
if word:
self.add(word)
def add(self, word):
"""
添加词汇到词典
"""
self.words.add(word)
self.max_len = max(self.max_len, len(word))
def contains(self, word):
"""
检查词典是否包含某个词
"""
return word in self.words
def get_max_len(self):
"""
获取词典中最长词的长度
"""
return self.max_len
正向最大匹配法
原理
前向最大匹配算法,顾名思义,前向即从左往右取词,取词最大长度为词典中长词的长度,每次右边减一个字,直到词典中存在或剩下1个单字。
基本思想为:假定分词词典中的最长词有i个汉字字符,则用被处理文挡的当前字串中的前i个字作为匹配字段,查找字典。若字典中存在这样的一个字词,则匹配成功,匹配字段被作为一个词切分出来。如果词典中找不到这样的一个i字词,则匹配失败,将匹配宇段中的最后一个字去掉,对剩下的字串重新进行匹配处理如此进行下去,直到匹配成功,即切分出一个词或剩余字串的长度为零为止。这样就完成了一轮匹配,然后取下一个 字字串进行匹配处理,直到文档被扫描完为止。
代码
python
def fmm(text, dictionary) -> list:
"""
正向最大匹配法(Forward Maximum Matching):从左到右,在每一步取当前位置开始的最长词
:param text: 待分词的文本
:param dictionary: 词典对象
:return: 分词结果列表
"""
result = []
max_len = dictionary.get_max_len()
i = 0
text_len = len(text)
while i < text_len:
matched = False
for j in range(max_len, 0, -1):
if i + j > text_len:
continue
word = text[i:i+j]
if dictionary.contains(word):
result.append(word)
i += j
matched = True
break
# 若没有匹配,则将单字切分出来
if not matched:
result.append(text[i])
i += 1
return result
scss
from wordsegment.dict import Dictionary
dictionary = Dictionary()
dictionary.load("../dict/dict.txt")
text = "北京大学生前来应聘"
result = fmm(text, dictionary)
print(result)
css
['北京大学生', '前', '来', '应', '聘']
逆向最大匹配法
原理
基本原理与 MM法相同,不同的是分词切分的方向与MM法相反逆向最大匹配法从被处理文挡的末端开始匹配扫描,每次取最末端的i个字符(i为词典中最长词数)作为匹配字段,若匹配失败,则去掉匹配字段前面的一个字,继续匹配。相应地,它使用的分词词典是逆序词典, 其中的每个个词条都将按逆序方式存放。在实际处理时,先将文档进行倒排处理,生成逆序文档然后,根据逆序词典,对逆序文档用正向最大匹配法处理即可。
考虑到有些情况下,中文词汇信息后置情况,可以使用逆向最长匹配。例如"欢迎新老师生前来就餐",使用正向最长匹配和逆向最长匹配分词结果不一样,后置才正确。在实现上需要注意,逆向匹配最先输出后面的词,因此完全切分后需要对结果做倒序。
汉语中偏正结构较多,若从后向前匹配,可以适当提高精确度
代码
ini
def bmm(text, dictionary):
"""
逆向最大匹配法(Backward Maximum Matching):从右到左,在每一步取当前位置结束的最长词
:param text: 待分词的文本
:param dictionary: 词典对象
:return: 分词结果列表
"""
result = []
max_len = dictionary.get_max_len()
text_len = len(text)
i = text_len
while i > 0:
matched = False
for j in range(max_len, 0, -1):
if i - j < 0:
continue
word = text[i-j:i]
if dictionary.contains(word):
result.insert(0, word)
i -= j
matched = True
break
# 若没有匹配,则将单字切分出来
if not matched:
result.insert(0, text[i-1])
i -= 1
return result
bash
# 执行方法和语料和上面FMM一样
['北京大学生', '前', '来', '应', '聘']
makefile
比如:
词典中加入:欢迎、老师、师生、用餐
正向最大匹配结果:
欢迎 / 新 / 老师 / 生 / 前 / 来 / 用餐
逆向最大匹配结果:
欢迎 / 新 / 老 / 师生 / 前 / 来 / 用餐
双向最大匹配法
原理
同时使用正向和逆向最大匹配,然后根据一些规则来选择更合适的结果:
- 如果词表有词频(概率)信息,可以选择词频最大的切分
- 可以选择词数最少的切分
- 可以选择细粒度(单字)最少的切分
- 更多的选择规则可以根据badcase来设计,并组合多种规则
代码
python
def bidirectional_mm(text, dictionary):
"""
双向最大匹配法(Bidirectional Maximum Matching):同时使用正向和逆向最大匹配,然后根据某些规则决定使用哪个结果
规则:
1. 分词数量少的优先
2. 如果分词数量相同,单字较少的优先
3. 如果以上都相同,使用正向最大匹配的结果
:param text: 待分词的文本
:param dictionary: 词典对象
:return: 分词结果列表
"""
# 获取正向和逆向的分词结果
fmm_result = fmm(text, dictionary)
bmm_result = bmm(text, dictionary)
# 如果正向和逆向结果相同,则直接返回
if fmm_result == bmm_result:
return fmm_result
# 规则1:分词数量少的优先
if len(fmm_result) != len(bmm_result):
return fmm_result if len(fmm_result) < len(bmm_result) else bmm_result
# 规则2:单字较少的优先
fmm_single = sum(1 for word in fmm_result if len(word) == 1)
bmm_single = sum(1 for word in bmm_result if len(word) == 1)
if fmm_single != bmm_single:
return fmm_result if fmm_single < bmm_single else bmm_result
# 规则3:相同情况下,使用正向最大匹配的结果
return fmm_result
makefile
双向最大匹配结果:
欢迎 / 新 / 老师 / 生 / 前 / 来 / 用餐
全切分法
原理
全切分严格来说不算是分词,而是查词。它正向遍历所有窗口大小的ngram,如果能够匹配词典中的词,那么就作为一个切分输出。真是因为如此,全切分不满足分词的完整性,即分词结果无法还原原来的句子。
代码
python
def full_segment(text, dictionary):
"""
全切分法:找出文本中所有可能的词,不管位置是否重叠
:param text: 待分词的文本
:param dictionary: 词典对象
:return: 包含所有可能词的列表,每个元素是(start_pos, end_pos, word)的元组
"""
results = []
text_len = len(text)
for i in range(text_len):
for j in range(i + 1, min(i + dictionary.get_max_len() + 1, text_len + 1)):
word = text[i:j]
if dictionary.contains(word):
results.append((i, j, word))
return results
基于DAG的最短路径分词法
基于全切分构建有向无环图(DAG),然后使用动态规划找出最短路径。
ini
def dag_segment(text, dictionary):
"""
基于全切分构建有向无环图(DAG),然后查找最佳路径
:param text: 待分词的文本
:param dictionary: 词典对象
:return: 分词结果列表
"""
# 获取所有可能的词
words = full_segment(text, dictionary)
# 构建DAG
dag = {}
for i in range(len(text) + 1):
dag[i] = []
# 添加边
for start, end, word in words:
dag[start].append((end, word))
# 使用动态规划查找最佳路径
routes = {len(text): (0, '')} # (cost, word)
for idx in range(len(text) - 1, -1, -1):
routes[idx] = (float('inf'), '')
for end, word in dag[idx]:
if end in routes:
r = routes[end]
cost = r[0] - 1 # 词数越少越好,所以每个词减1
if cost < routes[idx][0]:
routes[idx] = (cost, word)
# 如果没有找到词,则使用单字
if routes[idx][0] == float('inf'):
if idx + 1 in routes:
routes[idx] = (routes[idx+1][0] - 1, text[idx])
# 从DAG中提取最佳路径
result = []
idx = 0
while idx < len(text):
word = routes[idx][1]
result.append(word)
idx += len(word)
return result
N-最短路径分词法
基于N-最短路径分词算法,其基本思想是根据词典,找出字串中所有可能的词,构造词语切分有向无环图。每个词对应图中的一条有向边,并赋给相应的边长(权 值)。然后针对该切分图,在起点到终点的所有路径中,求出长度值按严格升序排列(任何两个不同位置上的值一定不等,下同)依次为第1,第2,...,第 i,...,第N的路径集合作为相应的粗分结果集。如果两条或两条以上路径长度相等,那么他们的长度并列第 i,都要列入粗分结果集,而且不影响其他路径的排列序号,最后的粗分结果集合大小大于或等于N。N一最短路径方法实际上是最短路径方法和全切分的有机结 合。该方法的出发点是尽量减少切分出来的词数,这和最短路径分词方法是完全一致的;同时又要尽可能的包含最终结果,这和全切分的思想是共通的。通过这种综 合,一方面避免了最短路径分词方法大量舍弃正 确结果的可能,另一方面又大大解决了全切分搜索空间过大,运行效率差的弊端。N一最短路径方法相对的不足就是粗分结果不唯一 ,后续过程需要处理多个粗分结果。 但是 ,对于预处理过程来讲,粗分结果的高召回率至关重要。因为低召回率就意味着没有办法 再作后续的补救措施。预处理一旦出错,后续处理只能是一错再错 ,基本上得不到正确的最终 结果。而少量的粗分结果对后续过程的运行效率影响不会太大,后续处理可以进一步优选排 错,如词性标注、句法分析等。
基于全切分构建有向无环图(DAG),然后使用改进的Dijkstra算法找出N条最短路径。
ini
def n_shortest(text, dictionary, n=5):
"""
N-最短路径方法:基于全切分构建有向无环图(DAG),然后找出N条最短路径
:param text: 待分词的文本
:param dictionary: 词典对象
:param n: 寻找的最短路径数量
:return: 包含N个分词结果的列表,每个元素是一个元组(cost, [词列表])
"""
# 获取所有可能的词
words = full_segment(text, dictionary)
# 构建DAG
dag = {}
for i in range(len(text) + 1):
dag[i] = []
# 添加边
for start, end, word in words:
dag[start].append((end, word))
# 确保每个位置都有至少一个后继(单字)
for i in range(len(text)):
if not dag[i]:
dag[i].append((i + 1, text[i]))
# 使用Dijkstra算法的变体找出N条最短路径
queue = [(0, 0, [])] # (cost, position, path)
results = []
while queue and len(results) < n:
cost, pos, path = heapq.heappop(queue)
if pos == len(text):
results.append((cost, path[:]))
continue
for next_pos, word in dag[pos]:
new_cost = cost + 1 # 每个词的代价为1
new_path = path + [word]
heapq.heappush(queue, (new_cost, next_pos, new_path))
return results
makefile
正向最大匹配结果:
南京市 / 长江大桥
逆向最大匹配结果:
南京市 / 长江大桥
双向最大匹配结果:
南京市 / 长江大桥
DAG分词结果:
南京市 / 长江 / 大 / 桥
N-最短路径结果:
路径1 (代价=2): 南京市 / 长江大桥
路径2 (代价=4): 南京市 / 长江 / 大 / 桥
结语
机械分词,以其轻量级的实现与高速的响应能力,在特定场景中仍占据不可替代的地位。然而,它的局限亦如影随形:词典依赖性 使其难以应对新词洪流,切分歧义 问题暴露了规则逻辑的脆弱性。当面对"南京市长江大桥"这类嵌套歧义时,再精巧的匹配策略也难逃"一刀切"的宿命。
机械分词的短板,恰恰催生了统计分词与深度学习模型的崛起。或许,它的价值不在于完美,而在于以最朴素的方式证明:语言的解构,始于对规则的敬畏与试探 。