中文分词:机械分词算法详解与实践总结

引子:上一篇文章我们从中文分词的历程、发展、问题等方面进行了理论性的探讨,从概念方面对中文分词有了一定的了解与认识,今天,我们主要探讨中文分词中的机械分词。

回顾:中文分词:历程、问题、发展

机械分词

简介

按照一定的策略将待分析的字符串与一个"充分大的"机器词典中的词条进行匹配,若在词典中找到某个字符串,则匹配成功(识别出一个词)。

需要先建立词典,再通过匹配的方法进行分词。

常见的算法

  • 正向最大匹配法(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): 南京市 / 长江 / 大 / 桥

结语

机械分词,以其轻量级的实现与高速的响应能力,在特定场景中仍占据不可替代的地位。然而,它的局限亦如影随形:词典依赖性 使其难以应对新词洪流,切分歧义 问题暴露了规则逻辑的脆弱性。当面对"南京市长江大桥"这类嵌套歧义时,再精巧的匹配策略也难逃"一刀切"的宿命。

机械分词的短板,恰恰催生了统计分词与深度学习模型的崛起。或许,它的价值不在于完美,而在于以最朴素的方式证明:语言的解构,始于对规则的敬畏与试探

Resources

相关推荐
王中阳Go4 小时前
从超市收银到航空调度:贪心算法如何破解生活中的最优决策谜题?
java·后端·算法
车队老哥记录生活7 小时前
【MPC】模型预测控制笔记 (3):无约束输出反馈MPC
笔记·算法
地平线开发者8 小时前
BEV 感知算法评价指标简介
算法·自动驾驶
不过四级不改名6778 小时前
用c语言实现简易c语言扫雷游戏
c语言·算法·游戏
C++ 老炮儿的技术栈10 小时前
手动实现strcpy
c语言·开发语言·c++·算法·visual studio
倔强的石头_10 小时前
【数据结构与算法】利用堆结构高效解决TopK问题
后端·算法
倔强的石头_10 小时前
【数据结构与算法】详解二叉树下:实践篇————通过链式结构深入理解并实现二叉树
后端·算法
哎写bug的程序员11 小时前
leetcode复盘(1)
算法·leetcode·职场和发展
风靡晚11 小时前
用于汽车毫米波雷达的四维高分辨率点云图像
人工智能·算法·机器学习·计算机视觉·汽车·信息与通信·信号处理