自然语言处理入门-词典分词
语言学定义:具备独立意义的最小单位。
基于词典的中文分词中的定义:词典中的字符串就是词。
齐夫定律:哈弗大学语言学家乔治 . 金斯利 . 齐夫于 1949 年发表,一个单词的词频与它的词频排名成反比。
实验:基于 MSR 语料库(微软亚洲研究院语料库)上的统计结果验证 “齐夫定律”。
图 2-1 MSR 语料库前 30 个常用词的词频统计[(',', 173173), ('的', 128146), ('。', 81757), ('、', 40695), ('在', 28445), ('了', 27103), ('和', 24398), ('是', 18068), ('”', 16867), ('“', 16686), ('一', 11503), ('有', 9905), ('对', 9654), ('为', 9516), ('中', 9444), ('上', 8408), ('不', 7222), ('这', 7198), ('与', 7197), ('他', 7062), ('就', 6485), ('人', 6338), ('到', 6316), ('等', 6008), (':', 5988), ('发展', 5976), ('说', 5973), ('也', 5801), ('要', 5660), ('将', 5651)]
横坐标:按词频降序排列的前 30 个常用词;纵坐标:相应的词频。
这条曲线大致符合 ,即满足幂律分布(power law distribution),也称长尾效应、二八原则、马太效应等。也就是说,虽然存在很多生词,但越靠后词频越小,趋近于 0。
互联网上公开的中文词典:搜狗实验室发布的互联网词库(SogouW,其中有 15 万个词条)、清华大学开放中文词库(THUOCL)、HanLP 词典。
以HanLP 附带的迷你核心词典为例,其路径为 "site-packages/pyhanlp/static/data/dictionary/CoreNatureDictionary.txt"。这是一个纯文本文件,用记事本打开后,可以观察到如下格式:
希望 v 7685 vn 616 希望村 ns 2 希杰 nrf 2 希泊妮 nz 2 希波克拉底 nrf 1
HanLP 中的词典格式:一种以空格分隔的表格形式,第一列是单词本身,之后每两列分别表示词性与相应的词频。比如第 1 行 “希望 v 7685 vn 616” 表示 “希望” 这个词以动词的身份出现了 7685 次,以动名词的身份出现了 616 次。
如果单词本身有空格,那该怎么办呢?比如 iPhone X、Macbook Pro,此时可以使用英文逗号分隔的 .csv 文件。
iPhone X, n, n
Macbook Pro, n , 1
注:如果用户的词语都是名词,或者不关心词性的话,可以省略词性部分。
"""
加载HanLP中的mini词库
"""
from pyhanlp import JClass, HanLP
def load_dictionary():
"""
加载HanLP中的mini词库
:return: 一个set形式的词库
"""
# 根据 Java 路径名获取 HanLp 中的 IOUtil 工具类
IOUtil = JClass("com.hankcs.hanlp.corpus.io.IOUtil")
# 获取 HanLP 的配置项 Config 中的词典路径
path = HanLP.Config.CoreDictionaryPath.replace(".text", ".mini.text")
# 加载词典数据,参数可以传一个路径字符串,也可以传一个路径字符串列表,返回一个 Java Map 对象
# dic = IOUtil.loadDictionary(path)
dic = IOUtil.loadDictionary([path])
# 将 Java Map 对象转换为 Python 原生的 Set 对象,并返回
return set(dic.keySet())
if __name__ == "__main__":
dic = load_dictionary()
print(len(dic))
print(list(dic)[0])
153091
沙特阿尔阿赫利
词典查找的规则:完全切分、正向最长匹配、逆向最长匹配、双向最长匹配。
完全切分:找出一段文本中的所有单词。
"""完全切分的中文分词算法"""
import os
import sys
sys.path.append(os.pardir) # 为了导入父目录的文件而进行的设定
from ch02.utifily import load_dictionary
def completely_segment(text, dic):
"""
完全切分的中文分词算法
:param text: 待切分的文本
:param dic: 词典
:return: 单词列表
"""
word_list = []
for i in range(len(text)): # i从0遍历到text的最后一个字符的下标
for j in range(i + 1, len(text) + 1): # j遍历[i+1, len(text)+1] 区间
word = text[i: j] # 去除连续区间[i, j]对应的字符串
if word in dic: # 如果在词典中,则认为是一个词
word_list.append(word)
return word_list
if __name__ == "__main__":
dic = load_dictionary()
print(completely_segment("商品和服务", dic))
print(completely_segment("就读北京大学", dic))
['商', '商品', '品', '和', '和服', '服', '服务', '务']
['就', '就读', '读', '北', '北京', '北京大学', '京', '大', '大学', '学']
最长匹配算法:在以某个下标为起点递增查词的过程中,优先输出更长的词,这种规则被称为最长匹配算法。
正向最长匹配:在以某个下标为起点从前往后递增查词的过程中,优先输出更长的词,这种规则被称为正向最长匹配。
"""正向最大匹配的中文分词算法"""
import os
import sys
sys.path.append(os.pardir) # 为了导入父目录的文件而进行的设定
from ch02.utifily import load_dictionary
def forward_segment(text, dictionary):
"""
正向最大匹配的中文分词算法
:param text: 待切分的文本
:param dictionary: 词典
: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 dictionary: # 在词典中
if len(word) > len(longest_word): # 并且更长
longest_word = word # 则更优先输出
word_list.append(longest_word) # 输出最长词
i += len(longest_word) # 正向扫描
return word_list
if __name__ == "__main__":
dictionary = load_dictionary()
print(forward_segment("就读于北京大学", dictionary))
print(forward_segment("研究生命的起源", dictionary))
['项目', '的', '研究']
['商品', '和服', '务']
['研究生', '命', '起源']
['当下', '雨天', '地面', '积水']
['结婚', '的', '和尚', '未', '结婚', '的']
['欢迎', '新', '老师', '生前', '来', '就餐']
逆向最长匹配:在以某个下标为起点从后往前递增查词的过程中,优先输出更长的词,这种规则被称为逆向最长匹配。
"""逆向最大匹配的中文分词算法"""
import os
import sys
sys.path.append(os.pardir) # 为了导入父目录的文件而进行的设定
from ch02.utifily import load_dictionary
def backward_segment(text, dictionary):
""""
逆向最大匹配的中文分词算法
:param text: 待切分的文本
:param dictionary: 词典
:return: 单词列表
"""
word_list = []
i = len(text) - 1
while i >= 0: # 扫描位置作为终点
longest_word = text[i] # 扫描位置的单词
for j in range(0, i): # 遍历[0, i]区间作为待查询词语的起点
word = text[j: i+1] # 取[j, i+1]区间作为待查询单词
if word in dictionary: # 在词典中
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__":
dictionary = load_dictionary()
print(backward_segment("项目的研究", dictionary))
print(backward_segment("商品和服务", dictionary))
print(backward_segment("研究生命起源", dictionary))
print(backward_segment("当下雨天地面积水", dictionary))
print(backward_segment("结婚的和尚未结婚的", dictionary))
print(backward_segment("欢迎新老师生前来就餐", dictionary))
['项', '目的', '研究']
['商品', '和', '服务']
['研究', '生命', '起源']
['当', '下雨天', '地面', '积水']
['结婚', '的', '和', '尚未', '结婚', '的']
['欢', '迎新', '老', '师生', '前来', '就餐']
双向最长匹配:融合了正向最长匹配和逆向最长匹配的复杂规则集,流程如下。
(1)同时执行正向和逆向最长匹配,若两者的词数不同,则返回词数更少的那一个。
(2)否则,返回两者中单字更少的那一个。当单字数也相同时,优先返回逆向最长匹配的结果。3.5 速度测评
这种规则的出发点来自语言学上的启发——汉语中单字词的数量要远远小于非单字词。因此,算法应当尽量减少结果中的单字,保留更多的完整词语,这样的算法也称为启发式算法。
"""双向最长匹配"""
import os
import sys
sys.path.append(os.pardir) # 为了导入父目录的文件而进行的设定
from ch02.utifily import load_dictionary
from ch02.forward_segment import forward_segment
from ch02.backward_segment import backward_segment
def count_single_char(word_list):
"""
统计单字成词的个数
:param word_list: 单词列表
:return: 单字个数
"""
count = 0
for word in word_list:
if len(word) == 1:
count += 1
# return sum(1 for word in word_list if len(word) == 1)
return count
def bidirectional_segment(text, dictionary):
""""
双向最大匹配的中文分词算法
:param text: 待切分的文本
:param dictionary: 词典
:return: 单词列表
"""
forward_list = forward_segment(text, dictionary)
backward_list = backward_segment(text, dictionary)
if len(forward_list) < len(backward_list): # 词数更少优先级更高
return forward_segment
elif len(forward_list) > len(backward_list):
return backward_list
elif len(forward_list) == len(backward_list):
if count_single_char(forward_list) < count_single_char(backward_list): # 单字数更少优先级更高
return forward_list
else: # 词数相等、单字数相等,逆向匹配优先级更高
return backward_list
if __name__ == "__main__":
dictionary = load_dictionary()
print(bidirectional_segment("项目的研究", dictionary))
print(bidirectional_segment("商品和服务", dictionary))
print(bidirectional_segment("研究生命起源", dictionary))
print(bidirectional_segment("当下雨天地面积水", dictionary))
print(bidirectional_segment("结婚的和尚未结婚的", dictionary))
print(bidirectional_segment("欢迎新老师生前来就餐", dictionary))
序号 | 原文 | 完全切分 | 正向最长匹配 | 逆向最长匹配 | 双向最长匹配 |
---|---|---|---|---|---|
1 | 项目的研究 | ['项', '项目', '目', '目的', '的', '研', '研究', '究'] | ['项目', '的', '研究'] | ['项', '目的', '研究'] | ['项', '目的', '研究'] |
2 | 商品和服务 | ['商', '商品', '品', '和', '和服', '服', '服务', '务'] | ['商品', '和服', '务'] | ['商品', '和', '服务'] | ['商品', '和', '服务'] |
3 | 研究生命起源 | ['研', '研究', '研究生', '究', '生', '生命', '命', '起', '起源', '源'] | ['研究生', '命', '起源'] | ['研究', '生命', '起源'] | ['研究', '生命', '起源'] |
4 | 当下雨天地面积水 | ['当', '当下', '下', '下雨', '下雨天', '雨', '雨天', '天', '天地', '地', '地面', '面', '面积', '积', '积水', '水'] |
['当下', '雨天', '地面', '积水'] | ['当', '下雨天', '地面', '积水'] | ['当下', '雨天', '地面', '积水'] |
5 | 结婚的和尚未结婚的 | ['结', '结婚', '婚', '的', '和', '和尚', '尚', '尚未', '未', '结', '结婚', '婚', '的'] | ['结婚', '的', '和尚', '未', '结婚', '的'] | ['结婚', '的', '和', '尚未', '结婚', '的'] | ['结婚', '的', '和', '尚未', '结婚', '的'] |
6 | 欢迎新老师生前来就餐 | ['欢', '欢迎', '迎', '迎新', '新', '老', '老师', '师', '师生', '生', '生前', '前', '前来', '来', '就', '就餐', '餐'] |
['欢迎', '新', '老师', '生前', '来', '就餐'] | ['欢', '迎新', '老', '师生', '前来', '就餐'] | ['欢', '迎新', '老', '师生', '前来', '就餐'] |
实验通过对 6 个中文句子进行切分,正向最长匹配的正确率为 1/6,逆向最长匹配的正确率为 4/6,双向最长匹配的正确率为 3/6。由此规则系统的脆弱可见一斑。规则集的维护有时是拆东墙补西墙,有时是帮倒忙。
实验:基于词典分词中文分词的 4 中规则,分别对文本 “江西鄱阳湖干枯,中国最大淡水湖变成大草原。” 进行 10000 次的分词操作,对分词速度进行对比。
图 2-2 词典分词中文分词4中规则四度对比正向匹配和逆向匹配的速度差不多,是双向的两倍。这在意料之中,因为双向匹配做了两倍的工作。
Python 代码:
"""速度测评"""
import os
import sys
import time
from matplotlib import pyplot as plt
sys.path.append(os.pardir) # 为了导入父目录的文件而进行的设定
from ch02.utifily import load_dictionary
from ch02.completely_segment import completely_segment
from ch02.forward_segment import forward_segment
from ch02.backward_segment import backward_segment
from ch02.bidirectional_segment import bidirectional_segment
def evaluate_speed(segment, text, dictionary):
"""
评测速度
:param segment: 匹配规则
:param text: 待切分的文本
:param dictionary: 词典
:return: 运行速度
"""
start_time = time.time()
for i in range(pressure):
segment(text, dictionary)
elapsed_time = time.time() - start_time
return len(text) * pressure / 10000 / elapsed_time
if __name__ == "__main__":
text = "江西鄱阳湖干枯,中国最大淡水湖变成大草原。"
pressure = 10000
segment_list = [{
"name": "完全切分",
"segment": completely_segment
}, {
"name": "正向",
"segment": forward_segment
}, {
"name": "逆向",
"segment": backward_segment
}, {
"name": "双向",
"segment": bidirectional_segment
}]
dic = load_dictionary()
count_list = []
x_list = []
for segment in segment_list:
speed = evaluate_speed(segment.get("segment"), text, dic)
count_list.append(speed)
x_list.append(segment.get("name"))
plt.rcParams["font.sans-serif"] = ['SimHei'] # 正常显示中文
plt.rcParams["axes.unicode_minus"] = False # 正常显示负号
plt.bar(x_list, count_list, width=0.3, color="#409eff", label="python")
plt.legend()
plt.xlabel("匹配规则")
plt.ylabel("万字/秒")
plt.title("词典分词中文4种规则速度对比")
for a, b in zip(x_list, count_list): # 柱子上的数字显示
plt.text(a, b, "%.2f" % b, ha="center", va="bottom", fontsize=10)
plt.show()