基于词典规则的中文分词
人们很少做他们相信是对的事,他们做比较方便的事,然后后悔。
——鲍勃 · 迪伦
前言
中文分词算法大致分为基于词典规则与基于机器学习两大派别,不过在实践中多采用结合词典规则和机器学习的混合分词。由于中文文本是由连续的汉字所组成,因此不能使用类似英文以空格作为分隔符进行分词的方式,中文分词需要考虑语义以及上下文语境。本文主要介绍基于词典规则的中文分词。
基于词典规则的中文分词简单来说就是将中文文本按照顺序切分成连续词序,然后根据规则以及连续词序是否在给定的词典中来决定连续词序是否为最终的分词结果。不同规则对应最终的分词结果是不一样的。
假设现在有段中文文本"网易杭研大厦",并且词典中包含["网易", "杭研", "大厦", "网易杭研", "杭研大厦", "网易杭研大厦"]。基于这个简单的小词典不需要任何的理论知识可以非常容易的分成下面这四种结果:
-
网易 / 杭研 / 大厦 -
网易 / 杭研大厦 -
网易杭研 / 大厦 -
网易杭研大厦
在中文中越长的单词所表达的意义越丰富并且含义越明确,因此就有了第一条规则:在以某个下标递归查词的过程中,优先输出更长的单词,这种规则也被称为最长匹配算法。根据下标扫描顺序的不同分为:
正向最长匹配,下标的扫描顺序从前往后;
逆向最长匹配,下标的扫描顺序从后往前;
不过在介绍具体算法之前,先来看看如何使用Python加载HanLP的词典。
为了方便使用HanLP附带的迷你核心词典。这里以Ubuntu系统为例,如果不知道如何在Ubuntu中安装HanLP,可以参考下面这篇文章:
首先需要查看HanLP自带词典的具体路径,可以通过下面命令进行查看(需要进入安装HanLP的虚拟环境中):
hanlp -v
▲查看HanLP配置的默认目录
其中data路径中包含HanLP自带的一些数据文件,进入存放词典的"dictionary"文件中:
▲HanLP自带的词典
"CoreNatureDictionary.mini.txt"就是我们接下来要使用的迷你核心词典,使用head -n 5 CoreNatureDictionary.mini.txt查看迷你核心词典的前5行。
▲核心迷你词典的前5行
HanLP中的词典格式是一种以空格分隔的表格形式,第一列为单词本身,之后的两列分别表示词性和单词表示当前词性时的词频,单词可能不止一种词性,因此后面的列依次类推表示词性和单词表示当前词性时的词频。比如"x w 7 nx 1"表示"x"这个词以标点符号(w)的身份出现了7次,以字母专名(nx)的身份出现了1次,当然这里的词频是在某个语料库上进行统计的。不过在基于词典分词的过程中,词性和词频没有太大的用处,可以暂时忽略。
使用Python加载HanLP自带的迷你核心词典"CoreNatureDictionary.mini.txt"词典代码如下:
from pyhanlp import *
def load_dictionary():
"""
加载HanLP中的mini词库
:return: 一个set形式的词库
"""
# 利用JClass获取HanLP中的IOUtil工具类
IOUtil = JClass('com.hankcs.hanlp.corpus.io.IOUtil')
# 取得HanLP的配置项Config中的词典路径,并替换成CoreNatureDictionary.mini.txt词典
path = HanLP.Config.CoreDictionaryPath.replace('.txt', '.mini.txt')
# 读入加载列表中指定多个词典文件,返回的是Java Map对象
dic = IOUtil.loadDictionary([path])
print(type(dic))
# 不关心词性和词频引出只获取Map对象的键值KeySet,并将其转换成Python的set集合
return set(dic.keySet())
if __name__ == '__main__':
dic = load_dictionary()
print(len(dic))
print(list(dic)[0])
<class 'jpype._jclass.java.util.TreeMap'>
85584
度假村
注意:
JClass函数是连通Java和Python的桥梁,可以根据Java路径名获得Python类;
HanLP默认配置的词典是"CoreNatureDictionary.txt",如果想要使用迷你的"CoreNatureDictionary.mini.txt"只需要将配置文件中的".txt"替换成"mini.txt";
加载好了词典,在具体介绍正向最长匹配、逆向最长匹配以及双向最长匹配之前,先来看看什么是最长匹配算法?
最长匹配算法是基于词典进行匹配,首先选取词典中最长单词的汉字个数作为最长匹配的起始长度。比如现在词典中的最长单词中包含5个汉字,那么最长匹配的起始汉字个数就为5,如果与词典匹配不成功就减少一个汉字继续与词典进行匹配,循环往复,直至与词典匹配且满足规则或者剩下一个汉字。
正向最长匹配简单来说就是从前往后进行取词,假设此时词典中最长单词包含5个汉字,对"就读北京大学"进行分词,正向最长匹配的基本流程:
-
第一轮
-
正向从前往后选取5个汉字。"就读北京大",词典中没有对应的单词,匹配失败; -
减少一个汉字。"就读北京",词典中没有对应的单词,匹配失败; -
减少一个汉字。"就读北",词典中没有对应的单词,匹配失败; -
减少一个汉字。"就读",词典中有对应的单词,匹配成功;
扫描终止,输出第1个单词"就读",去除第1个单词开始第二轮扫描。
-
第二轮
-
去除"就读"之后,依然正向选择5个汉字,不过由于我们分词文本比较短,不足5个汉字,所以直接对剩下的4个汉字进行匹配。"北京大学",词典中有对应的单词,匹配成功;
至此,通过正向最大匹配对"就读北京大学"的匹配结果为:"就读 / 北京大学"。不过书中实现的正向最长匹配没有考虑设置最长匹配的起始长度,而是以正向逐渐增加汉字的方式进行匹配,如果此时匹配成功还需要进行下一次匹配,保留匹配成功且长度最长的单词作为最终的分词结果。
不过为了提升效率在实际使用中倾向于设置最长匹配的起始长度,如果想更进一步提升分词的速度,可以将词典按照不同汉字长度进行划分,每次匹配的时候搜索相对应汉字个数的词典。虽然代码和讲解有所不同,但是本质和结果都是一样的,越长单词的优先级越高,这里注意一下即可。
from utility import load_dictionary # 导入加载词典函数
def forward_segment(text, dic):
"""
:param text: 待分词的中文文本
:param dic: 词典
:return: 分词结果
"""
word_list = []
i = 0
while i < len(text):
longest_word = text[i]
for j in range(i + 1, len(text) + 1):
word = text[i:j]
if word in dic:
# 优先输出单词长度更长的单词
if len(word) > len(longest_word):
longest_word = word
word_list.append(longest_word)
# 提出匹配成功的单词,分词剩余的文本
i += len(longest_word)
return word_list
if __name__ == '__main__':
# 加载词典
dic = load_dictionary()
print(forward_segment('就读北京大学', dic))
['就读', '北京大学']
使用上面的代码对"就读北京大学"进行分词,具体代码流程如图所示:
▲正向最长匹配
-
第一轮
-
正向从后往前选取5个汉字。"究生命起源",词典中没有对应的单词,匹配失败; -
减少一个汉字。"生命起源",词典中没有对应的单词,匹配失败; -
减少一个汉字。"命起源",词典中没有对应的单词,匹配失败; -
减少一个汉字。"起源",词典中有对应的单词,匹配成功;
-
第二轮
-
去除"起源"之后,依然反向选择5个汉字,不过由于我们分词句子比较短,不足5个汉字,所以直接对剩下的4个汉字进行匹配。"研究生命",词典中没有对应的单词,匹配失败; -
减少一个汉字。"究生命",词典中没有对应的单词,匹配失败; -
减少一个汉字。"生命",词典中有对应的单词,匹配成功;
-
第三轮
-
去除"生命"之后,依然反向选择5个汉字,不过由于我们分词句子比较短,不足5个汉字,所以直接对剩下的2个汉字进行匹配。"研究",词典中有对应的单词,匹配成功;
from utility import load_dictionary # 导入加载词典函数
def backward_segment(text, dic):
"""
:param text:待分词的文本
:param dic:词典
:return:元素为分词结果的list列表
"""
word_list = []
# 扫描位置作为终点
i = len(text) - 1
while i >= 0:
longest_word = text[i]
for j in range(0, i):
word = text[j: i + 1]
if word in dic:
# 越长优先级越高
if len(word) > len(longest_word):
longest_word = word
break
# 逆向扫描,所以越先查出的单词在位置上越靠后
word_list.insert(0, longest_word)
i -= len(longest_word)
return word_list
if __name__ == '__main__':
# 加载词典
dic = load_dictionary()
print(backward_segment('研究生命起源', dic))
['研究', '生命', '起源']
使用上面的代码对"研究生命起源"进行分词,具体代码流程如图所示:
对"项目的研究"进行分词:
-
正向最长匹配:"项目 / 的 / 研究" -
逆向最长匹配:"项 / 目的 / 研究"
-
正向最长匹配:"研究生 / 命 / 起源" -
逆向最长匹配:"研究 / 生命 / 起源"
最长的单词所表达的意义越丰富并且含义越明确。如果正向最长匹配和逆向最长匹配分词后的词数不同,返回词数更少结果;
非词典词和单字词越少越好,在语言学中单字词的数量要远远小于非单字词。如果正向最长匹配和逆向最长匹配分词后的词数相同,返回非词典词和单字词最少的结果;
根据孙松茂教授的统计,逆向最长匹配正确的可能性要比正向最长匹配的可能性要高。如果正向最长匹配的词数以及非词典词和单字词都相同的情况下,优先返回逆向最长匹配的结果;
from backward_segment import backward_segment # 导入实现正向最长匹配的函数
from forward_segment import forward_segment # 导入实现逆向最长匹配的函数
from utility import load_dictionary # 导入加载词典的函数
def count_single_char(word_list: list): # 统计单字成词的个数
"""
统计单字词的个数
:param word_list:分词后的list列表
:return: 单字词的个数
"""
return sum(1 for word in word_list if len(word) == 1)
def bidirectional_segment(text, dic):
"""
双向最长匹配
:param text:待分词的中文文本
:param dic:词典
:return:正向最长匹配和逆向最长匹配中最优的结果
"""
f = forward_segment(text, dic)
b = backward_segment(text, dic)
print(f)
print(b)
# 词数更少优先级更高
if len(f) < len(b):
return f
elif len(f) > len(b):
return b
else:
# 单字词更少的优先级更高
if count_single_char(f) < count_single_char(b):
return f
else:
# 词数以及单字词数量都相等的时候,逆向最长匹配优先级更高
return b
if __name__ == '__main__':
# 加载词典
dic = load_dictionary()
print(bidirectional_segment('项目的研究', dic))
print(bidirectional_segment('研究生命起源', dic))
['项', '目的', '研究']
['研究', '生命', '起源']
参考:《自然语言处理入门》
本文代码来自《自然语言处理入门》
推荐阅读
(点击标题可跳转阅读)