本人初学nlp,使用的是机械工业出版社的《python自然语言处理实战核心技术与算法》,学习到了双向最大匹配法,于是写下这篇文章记录一下整个代码的工作原理以及相应的理解。
一、匹配切分
在中文分词技术中的匹配切分输入规则分词方式,这是一种机械分词的方式,我们通过机器词典中的单词与语句中的每个词语进行匹配,如果匹配成功则切分,如果匹配失败则不与切分。
在匹配切分中,原则是“长词优先”,那么为什么是长词优先?这是因为在nlp中最需要考虑的几个问题是:速度与精度。以下是我的个人理解(主要是真的没查到相关的内容):
大家仔细想一想,我们对一段话进行分词,是不是需要一直循环这段话与机器词典中的单词进行匹配操作,那么循环是不是一个很浪费性能的工作?如果我们一个字一个字的匹配,那么就等于这段话有多少个字,我们要循环多少次,更重要的是,我们是分词,不是分字,分字的话那么我们究竟是做新华字典还是做nlp对吧?所以我们通过长词优先,假设最长的词是4个字的成语,每一次循环就可能清理掉4个字,这样是不是有可能很大程度节约了性能?当然,最差的情况必定还是一个词都分不出来,这样的话理论上会比从小开始更消耗性能(因为算法中会依次减少匹配的字符数,具体的时候在代码中讨论),但是我们做中文分词,肯定不至于这句话中一个词语都没有在各个数据集中出现过对吧,所以长词优先可以增加相应的运行速度。
匹配切分中分为正向最大匹配法(Maximum Match Method,aka MM),逆向最大匹配法(Reverse Maximum Match Method,aka RMM),双向最大匹配法(Bi-directction Matching Method)
虽然本文讨论的是python实现双向最大匹配法,这种算法也运用了MM和RMM,所以下面就一起进行讨论。
二、算法代码及详谈
代码我引用的《python自然语言处理实战核心技术与算法》中的例子,词典为:
self.dic = ['研究', '研究生', '生命', '命', '的', '起源']
那么由此可见机器词典最长词条字符数(窗口值)为3:
self.window_size = 3
这里是直接设定的机器词典最长词条字符数,但是我个人觉得在实际工程中,应该有个方法来判断机器词典中的最长词条字符数。比如使用len(max(list, key = len))这样的方法取。
文本为:
text = '研究生命的起源'
1. 正向最大匹配法
这里先上代码,注释写得比较完善,具体的算法思想等都写在代码中了:
def MM_cut(self):
"""
正向最大匹配法的方法
算法思想:
1. 从左向右取待切分汉语句的m个字符作为匹配字符, m为机器词典中最长词条的字符数
2. 查找机器词典并进行匹配,若匹配成功, 则将这个匹配字段作为一个词切分出来。
若匹配不成功, 则将这个匹配字段的最后一个字去掉, 剩下的字符串作为新的匹配字段,
进行再次匹配, 重复以上过程, 直到切分出所有词为止。
:return MM_result: 正向最大匹配法匹配结果
"""
MM_result = []
MM_index = 0
MM_text_length = len(self.text)
MM_piece = None
while MM_index < MM_text_length:
# MM的循环
for size in range(self.window_size + MM_index, MM_index, -1):
# 每一轮循环从新的字符串的"索引位置(起始位置) + 机器词典中最长的词条字符数"位置开始匹配字符
# 如果这一轮循环匹配失败,则将要匹配的字符数进行-1操作,进行新一轮的匹配
# 最后一轮匹配为一个字符匹配
MM_piece = self.text[MM_index: size]
if MM_piece in self.dic:
# 如果这串字符在机器词典中,那么移动索引至匹配了的字符串的最后一个字符的下标处(将匹配了的字符串移出这个线性表)
MM_index = size - 1
break
# 将索引移动到下一轮匹配的开始字符位置,即如果匹配成功,将之前成功匹配的字符移除线性表
# 如果匹配失败,则是将第一个字符移除线性表
MM_index += 1
MM_result.append(MM_piece)
return MM_result
我这里使用一张图进行算法第一轮循环的讲解,这样也方便初学者进行理解以及之后的循环推演。
第一轮循环肯定是满足初始下标小于文本长度的,如果等于的话我怀疑这个丢个空字符串的人脑壳被门夹了。所以可以先直接看for循环。
for循环是从range(self.window_size + MM_index, MM_index, -1)中获取一个size(大小),我们通过手动计算第一个size = 3(这里就满足了长词优先原则),再看第一片匹配的字符串的下标是[0]至[2],于是绘出下面的图,是不是很像个线性表啊:
这个size是[3, 2, 1],如果不清楚这一步的就自己去查一下range()函数,这里不多赘述。
接着在if语句中,把这3个字丢到机器词典dic中进行匹配,结果发现,芜湖,还真匹配上了,于是这个时候索引(MM_index)移动到“生”这个位置,即下图这样:
可能看到这里读者就会有些疑问了,为什么索引移动到的是2而不是3?这里先卖个关子,等我把如果匹配失败的情况说了再继续。
现在我们假设匹配失败了,[‘研’, ‘研究’, ‘研究生’]都不在机器词典中,那么这时候for循环就第二轮,即size = 2,这时候匹配的就是’研究’两字,再接着size = 1,匹配‘研’字,但是发现都没匹配到,于是for循环结束,开始while之后的循环内容。
如果匹配成功,这个时候索引会向前移动一个位置,即移动到‘命’这个地方,如果匹配失败,则移动到‘究’这个地方,这也就是为什么之前说的匹配成功后索引移动到的是size - 1的位置,就是为了增加代码复用性,方便这里移动索引。
而无论是匹配成功还是失败,相当于之前的内容已经被移除这个线性表了。
最后会将匹配的内容添加至结果的列表中,这里也要分两种情况:
1.如果匹配成功,则是成功的词语放至列表中。
2.如果匹配失败,即词语不在机器词典中或者就不是词语,那么就是把第一个字放入结果中,就好比“我爱你”,这就不是个词语,而“我”也就可以直接放入结果中,作为一个分词结果。
以上就是正向最大匹配法的内容,下面讨论逆向最大匹配法。
2. 逆向最大匹配法
同样,先上代码:
def RMM_cut(self):
"""
逆向最大匹配法
RMM的算法思想:
1.先将文档进行倒排处理(reverse),生成逆序文档,然后根据逆序词典,对逆序文档用正向最大匹配法处理
2.从左向右取待切分汉语句的m个字符作为匹配字符, m为机器词典中最长词条的字符数
3.查找机器词典并进行匹配,若匹配成功, 则将这个匹配字段作为一个词切分出来。
若匹配不成功, 则将这个匹配字段的最后一个字去掉, 剩下的字符串作为新的匹配字段,
进行再次匹配, 重复以上过程, 直到切分出所有词为止。
该应用的算法思想:
没有使用reverse处理,而是直接从后向前匹配,只是匹配的结果进行了reverse处理
(因为匹配的结果第一个是"起源",最后一个是"研究")
:return RMM_result: 逆向最大匹配法匹配结果
"""
RMM_result = []
RMM_index = len(self.text)
RMM_piece = None
while RMM_index > 0:
# RMM的循环
for size in range(RMM_index - self.window_size, RMM_index):
# 匹配最后的3个字符串,如果匹配就进行下一轮while循环,否则字符数-1,进行下一轮for循环
RMM_piece = self.text[size: RMM_index]
if RMM_piece in self.dic:
# 如果这串字符在机器词典中,那么移动索引至成功匹配的第一个字符的下标处(将匹配了的字符串移出这个线性表)
RMM_index = size + 1
break
# 将索引移动到下一轮匹配的开始字符位置,即如果匹配成功,将之前成功匹配的字符移除线性表
# 如果匹配失败,则是将最后一个字符移除线性表
RMM_index -= 1
RMM_result.append(RMM_piece)
RMM_result.reverse()
return RMM_result
关于正向和逆向的区别为了防止许多同学没看过书,所以我这里还是copy一下:
由于汉语中偏正结构较多,若从后向前匹配,可以适当提高精确度。所以,逆向最大匹配法比正向最大匹配法的误差要小。
书上写的具体的算法是先将词典和文本都逆序,再进行正向最大匹配法,而具体使用的情况是逆序来看,最后的匹配结果逆序。
就我个人而言,我个人倾向于书上应用的方式,即直接从后向前匹配,最后结果再reverse,因为我是觉得如果使用第一种方式,中间你想测试下代码,然后print一下,我怀疑你会怀疑人生。
具体的过程和正向的类似,这里不多赘述(主要是懒),我将图放出来,大家就可以自行举一反三了。
3.双向最大匹配法
双向最大匹配法是建立在正向最大匹配法和逆向最大匹配法之上的,是对两者结果的比较,选出更优的那一个作为结果。
书上对这个情况说明为:
据SunM.S.和Benjamin K.T. (1995)的研究表明,中文中90.0%左右的句子,正向最大匹配法和逆向最大匹配法完全重合且正确,只有大概9.0%的句子两种切分得到的结果不一样,但其中必有一个是正确的(歧义检测成功),只有不到1.0%的句子,使用正向最大匹配法和逆向最大匹配法的切分虽重合却是错的,或者正向最大匹配法和逆向最大匹配法切分不同但两个都不对(歧义检测失败)。
所以看出我们要解决的就是那9.0%的问题,毕竟精度也是我们需要考虑的问题,免得到时候搜索一个吴彦祖的照片结果搜出我们的照片了对吧。
整体代码如下:
def get_best_matching_result(MM_result, RMM_result):
"""
比较两个分词方法分词的结果
比较方法:
1. 如果正反向分词结果词数不同,则取分词数量较少的那个
2. 如果分词结果词数相同:
2.1 分词结果相同,说明没有歧义,可返回任意一个
2.2 分词结果不同,返回其中单字较少的那个
:param MM_result: 正向最大匹配法的分词结果
:param RMM_result: 逆向最大匹配法的分词结果
:return:
1.词数不同返回词数较少的那个
2.词典结果相同,返回任意一个(MM_result)
3.词数相同但是词典结果不同,返回单字最少的那个
"""
if len(MM_result) != len(RMM_result):
# 如果两个结果词数不同,返回词数较少的那个
return MM_result if (len(MM_result) < len(RMM_result)) else RMM_result
else:
if MM_result == RMM_result:
# 因为RMM的结果是取反了的,所以可以直接匹配
# 词典结果相同,返回任意一个
return MM_result
else:
# 词数相同但是词典结果不同,返回单字最少的那个
MM_word_1 = 0
RMM_word_1 = 0
for word in MM_result:
# 判断正向匹配结果中单字出现的词数
if len(word) == 1:
MM_word_1 += 1
for word in RMM_result:
# 判断逆向匹配结果中单字出现的词数
if len(word) == 1:
RMM_word_1 += 1
return MM_result if (MM_word_1 < RMM_word_1) else RMM_result
这里没什么好说的,就是比较的过程,返回我使用的三目运算符,具体的可以在csdn查看相应的说明。
在判断单字情况这里,我看到许多前辈大佬使用的是lambda表达式中使用过滤器filter,这里我没有使用,主要是lambda太简洁了,太python了,就有时候可能会造成可读性较差的情况,而且毕竟底层的逻辑都是相同的循环,在时间复杂度和空间复杂度上没有区别,只是看着就一行,舒服些。而我倾向于写for循环,因为我感觉这样方便注释以及后人来查看更改维护代码。
而这里大家也就可以更清晰地看到什么叫“最大匹配”或者说“长词优先”。无论是第一步返回分词最少(即单个词更长)的结果,还是判断单字最少的情况,都是在最开始提到的“最大匹配”。
三、总体代码与结果
class BiDirectctionMatchingMethod(object):
"""
双向最大匹配法
算法思想:
1. 如果正反向分词结果词数不同,则取分词数量较少的那个
2. 如果分词结果词数相同:
2.1 分词结果相同,说明没有歧义,可返回任意一个
2.2 分词结果不同,返回其中单字较少的那个
Attribute:
window_size: 机器词典最长词条字符数
dic: 机器词典
text: 需要匹配的字符串(文本)
"""
def __init__(self, text):
self.window_size = 3
self.dic = ['研究', '研究生', '生命', '命', '的', '起源']
self.text = text
def MM_cut(self):
"""
正向最大匹配法的方法
算法思想:
1. 从左向右取待切分汉语句的m个字符作为匹配字符, m为机器词典中最长词条的字符数
2. 查找机器词典并进行匹配,若匹配成功, 则将这个匹配字段作为一个词切分出来。
若匹配不成功, 则将这个匹配字段的最后一个字去掉, 剩下的字符串作为新的匹配字段,
进行再次匹配, 重复以上过程, 直到切分出所有词为止。
:return MM_result: 正向最大匹配法匹配结果
"""
MM_result = []
MM_index = 0
MM_text_length = len(self.text)
MM_piece = None
while MM_index < MM_text_length:
# MM的循环
for size in range(self.window_size + MM_index, MM_index, -1):
# 每一轮循环从新的字符串的"索引位置(起始位置) + 机器词典中最长的词条字符数"位置开始匹配字符
# 如果这一轮循环匹配失败,则将要匹配的字符数进行-1操作,进行新一轮的匹配
# 最后一轮匹配为一个字符匹配
MM_piece = self.text[MM_index: size]
if MM_piece in self.dic:
# 如果这串字符在机器词典中,那么移动索引至匹配了的字符串的最后一个字符的下标处(将匹配了的字符串移出这个线性表)
MM_index = size - 1
break
# 将索引移动到下一轮匹配的开始字符位置,即如果匹配成功,将之前成功匹配的字符移除线性表
# 如果匹配失败,则是将第一个字符移除线性表
MM_index += 1
MM_result.append(MM_piece)
return MM_result
def RMM_cut(self):
"""
逆向最大匹配法
RMM的算法思想:
1.
先将文档进行倒排处理(reverse),生成逆序文档,然后根据逆序词典,对逆序文档用正向最大匹配法处理
2.
从左向右取待切分汉语句的m个字符作为匹配字符, m为机器词典中最长词条的字符数
3.
查找机器词典并进行匹配,若匹配成功, 则将这个匹配字段作为一个词切分出来。
若匹配不成功, 则将这个匹配字段的最后一个字去掉, 剩下的字符串作为新的匹配字段,
进行再次匹配, 重复以上过程, 直到切分出所有词为止。
该应用的算法思想:
没有使用reverse处理,而是直接从后向前匹配,只是匹配的结果进行了reverse处理
(因为匹配的结果第一个是"起源",最后一个是"研究")
:return RMM_result: 逆向最大匹配法匹配结果
"""
RMM_result = []
RMM_index = len(self.text)
RMM_piece = None
while RMM_index > 0:
# RMM的循环
for size in range(RMM_index - self.window_size, RMM_index):
# 匹配最后的3个字符串,如果匹配就进行下一轮while循环,否则字符数-1,进行下一轮for循环
RMM_piece = self.text[size: RMM_index]
if RMM_piece in self.dic:
# 如果这串字符在机器词典中,那么移动索引至成功匹配的第一个字符的下标处(将匹配了的字符串移出这个线性表)
RMM_index = size + 1
break
# 将索引移动到下一轮匹配的开始字符位置,即如果匹配成功,将之前成功匹配的字符移除线性表
# 如果匹配失败,则是将最后一个字符移除线性表
RMM_index -= 1
RMM_result.append(RMM_piece)
RMM_result.reverse()
return RMM_result
def get_best_matching_result(MM_result, RMM_result):
"""
比较两个分词方法分词的结果
比较方法:
1. 如果正反向分词结果词数不同,则取分词数量较少的那个
2. 如果分词结果词数相同:
2.1 分词结果相同,说明没有歧义,可返回任意一个
2.2 分词结果不同,返回其中单字较少的那个
:param MM_result: 正向最大匹配法的分词结果
:param RMM_result: 逆向最大匹配法的分词结果
:return:
1.词数不同返回词数较少的那个
2.词典结果相同,返回任意一个(MM_result)
3.词数相同但是词典结果不同,返回单字最少的那个
"""
if len(MM_result) != len(RMM_result):
# 如果两个结果词数不同,返回词数较少的那个
return MM_result if (len(MM_result) < len(RMM_result)) else RMM_result
else:
if MM_result == RMM_result:
# 因为RMM的结果是取反了的,所以可以直接匹配
# 词典结果相同,返回任意一个
return MM_result
else:
# 词数相同但是词典结果不同,返回单字最少的那个
MM_word_1 = 0
RMM_word_1 = 0
for word in MM_result:
# 判断正向匹配结果中单字出现的词数
if len(word) == 1:
MM_word_1 += 1
for word in RMM_result:
# 判断逆向匹配结果中单字出现的词数
if len(word) == 1:
RMM_word_1 += 1
return MM_result if (MM_word_1 < RMM_word_1) else RMM_result
if __name__ == '__main__':
text = '研究生命的起源'
tokenizer = BiDirectctionMatchingMethod(text)
MM_result = tokenizer.MM_cut()
RMM_result = tokenizer.RMM_cut()
best_result = get_best_matching_result(MM_result, RMM_result)
print("MM_result:", MM_result)
print("RMM_result:", RMM_result)
print("best_result:", best_result)
运行结果如下:
四、改进方式
毕竟我也只是一个初学者,也没有好的办法可以改进,但是我考古了一篇论文《基于最大匹配算法的中文分词模型改进》(相关链接我放在最后了),文中提到的观点是:
何为最大匹配?
作者认为我们这种匹配方式并非是真正意义的最大匹配,而只是局部的最大匹配(比如只匹配最前面这几个字),作者的方式有点类似于KMP算法,就是拿模式串的最长词的长度来进行匹配,匹配成功切分匹配部分,匹配不成功,则下标后移一位进行匹配。
但是我个人觉得,作者这种方式是以时间和空间为代价,来换“最大匹配”四个字,因为论文真的太老了,作者只是在摘要中轻浮的提了一句“改进后的算法在速度和效率方面比现有的正向和反向匹配分词算法都有所提高”,但是文中既没有写效率提升了多少,也没有写精度提升了多少,我只能手算时间复杂度(如果有错请见谅并指出)。(而且我感觉那流程图也是错的……)
以最坏情况来看,即每一轮循环都没匹配到想要的结果,就每一轮都匹配结束,到最后一个字时才匹配完成。作者这种方式的时间复杂度如下:
O(n * (S(1+S)) / 2)
n为字符串长度,S为机器词典中最长的单词词数。
而书中这种方法的时间复杂度如下:
O(n * S)
而空间上就更不用说了,作者这种方式,假设把中间的截了,那么就成两个字符串了。
但是这确实给了我们一个思考的方向,就是如何考虑其他的字符串处理方式,并进行相应的融合。
五、参考
[1] 林关成.基于最大匹配算法的中文分词模型改进[J].科技信息(学术研究),2008(36):419-420.
[2] 涂铭,刘祥,刘树春.python自然语言处理实战核心技术与算法[M].机械工业出版社:北京,2018:38.