0、写在前面
学习NLP也有一段时间了,对其中一些算法也有了比较系统的了解,所以最近就打算阅读一些nlp领域的开源代码,一方面是想查漏补缺完善一下自然语言处理的一些基础技术以及实现;另一方面是学习学习coding的规范以及tricks。关于源码平时使用较多的是python语言的jieba库,这原本是主打中文分词的一个库,但是现在的功能可远不止分词。所以在nlp技术上应该还是比较全面的,就决定是它啦。
1、jieba系统简介
“jieba”中文分词:做最好的Python中文分词组件。(这是它的广告词...)
1.1、特点:
支持三种分词模式:
- 精确模式,试图将句子最精确地切开,适合文本分析;
- 全模式,把句子中所有的可以成词的词语都扫描出来, 速度非常快,但是不能解决歧义;
- 搜索引擎模式,在精确模式的基础上,对长词再次切分,提高召回率,适合用于搜索引擎分词。
支持繁体分词
支持自定义词典
MIT 授权协议
1.2、主要算法:
- 基于前缀词典实现词图扫描,生成句子中汉字所有可能成词情况所构成的有向无环图(DAG),采用动态规划查找最大概率路径,找出基于词频的最大切分组合;
- 对于未登录词,采用了基于汉字成词能力的 HMM模型,采用Viterbi算法进行计算;
- 基于Viterbi算法的词性标注;
- 分别基于tfidf和textrank模型抽取关键词;
1.3、代码框架:
2、基于前缀词典及动态规划实现分词
jieba分词主要是基于统计词典,构造一个前缀词典;然后利用前缀词典对输入句子进行切分,得到所有的切分可能,根据切分位置,构造一个有向无环图(DAG);通过动态规划算法,计算得到最大概率路径,也就得到了最终的切分形式。
2.1、举个栗子
以“去北京大学玩”为例,作为待分词的输入文本。
离线统计的词典形式如下,每一行有三列,第一列是词,第二列是词频,第三列是词性。
... 北京大学 2053 nt
大学 20025 n
去 123402 v
玩 4207 v
北京 34488 ns
北 17860 ns
京 6583 ns
大 144099 a
学 17482 n .
..
2.2、前缀词典的构建
首先是基于统计词典构造前缀词典,如统计词典中的词“北京大学”的前缀分别是“北”、“北京”、“北京大”;词“大学”的前缀是“大”。统计词典中所有的词形成的前缀词典如下所示,你也许会注意到“北京大”作为“北京大学”的前缀,但是它的词频却为0,这是为了便于后面有向无环图的构建。
...
北京大学 2053
北京大 0
大学 20025
去 123402
玩 4207
北京 34488
北 17860
京 6583
大 144099
学 17482
...
2.3、有向无环图的构建
然后基于前缀词典,对输入文本进行切分,对于“去”,没有前缀,那么就只有一种划分方式;对于“北”,则有“北”、“北京”、“北京大学”三种划分方式;对于“京”,也只有一种划分方式;对于“大”,则有“大”、“大学”两种划分方式,依次类推,可以得到每个字开始的前缀词的划分方式。
在jieba分词中,对每个字都是通过在文本中的位置来标记的,因此可以构建一个以位置为key,相应划分的末尾位置构成的列表为value的映射,如下所示,
0: [0]
1: [1,2,4]
2: [2]
3: [3,4]
4: [4]
5: [5]
对于0: [0],表示位置0对应的词,就是0 ~ 0,就是“去”;对于1: [1,2,4],表示位置1开始,在1,2,4位置都是词,就是1 ~ 1,1 ~ 2,1 ~ 4,即“北”,“北京”,“北京大学”这三个词。
对于每一种划分,都将相应的首尾位置相连,例如,对于位置1,可以将它与位置1、位置2、位置4相连接,最终构成一个有向无环图,如下所示,
2.4、最大概率路径计算
在得到所有可能的切分方式构成的有向无环图后,我们发现从起点到终点存在多条路径,多条路径也就意味着存在多种分词结果,例如,
# 路径1 0 -> 1 -> 2 -> 3 -> 4 -> 5
# 分词结果1 去 / 北 / 京 / 大 / 学 / 玩
# 路径2 0 -> 1 , 2 -> 3 -> 4 -> 5
# 分词结果2 去 / 北京 / 大 / 学 / 玩
# 路径3 0 -> 1 , 2 -> 3 , 4 -> 5
# 分词结果3 去 / 北京 / 大学 / 玩
# 路径4 0 -> 1 , 2 , 3 , 4 -> 5
# 分词结果4 去 / 北京大学 / 玩 ...
因此,我们需要计算最大概率路径,也即按照这种方式切分后的分词结果的概率最大。在计算最大概率路径时,jieba分词采用从后往前这种方式进行计算。为什么采用从后往前这种方式计算呢?因为,我们这个有向无环图的方向是从前向后指向,对于一个节点,我们只知道这个节点会指向后面哪些节点,但是我们很难直接知道有哪些前面的节点会指向这个节点。
在采用动态规划计算最大概率路径时,每到达一个节点,它前面的节点到终点的最大路径概率已经计算出来。
3、源码分析
3.1、算法流程
jieba分词的主函数cut(self, sentence, cut_all=False, HMM=True)位于jieba.__init__.py文件中
- 使用__cut__DAG(self, sentence)函数构建前缀词典;
- 使用get_DAG(self, sentence)函数构建有向无环图;
- 使用calc(self, sentence, DAG, route)基于最大概率路径进行分词,如果遇到未登陆词,则调用HMM模型进行切分。
3.2、前缀词典的构建
"""
构建前缀词典,解析离线统计词典文本文件f(每一行分别对应着词,词频,词性),
将词和词频提取出来,形成key-value对,加入到前缀词典中lfreq
对于每个词,再分别获得它的前缀词,如果前缀词已经存在于前缀词典中,则不处理;
如果不在,则将词频设为0便于后续DAG构建
"""
def gen_pfdict(self, f):
#f是离线统计的词典文件路径
lfreq = {} #用于存储的字典
ltotal = 0 #所有频率之和
f_name = resolve_filename(f)
for lineno, line in enumerate(f, 1):
try:
#解析离线词典文本文件
line = line.strip().decode('utf-8')
#提取词和词频加入字典中
word, freq = line.split(' ')[:2]
freq = int(freq)
lfreq[word] = freq
ltotal += freq
#获取该词的所有前缀词
for ch in xrange(len(word)):
wfrag = word[:ch + 1]
#如果wfrag不在前缀词中,则将词频设为0
if wfrag not in lfreq:
lfreq[wfrag] = 0
except ValueError:
raise ValueError(
'invalid dictionary entry in %s at Line %s: %s' % (f_name, lineno, line))
f.close()
return lfreq, ltotal
PS:为什么jieba没有使用trie树作为前缀词典存储的数据结构?
对于get_DAG()函数来说,用Trie数据结构,特别是在Python环境,内存使用量过大。经实验,可构造一个前缀集合解决问题。该集合储存词语及其前缀,如set(['数', '数据', '数据结', '数据结构'])。在句子中按字正向查找词语,在前缀列表中就继续查找,直到不在前缀列表中或超出句子范围。大约比原词库增加40%词条。
3.3、有向无环图的构建
关于DAG的实现,jieba这里用了python的字典结构,最终的效果是{k : [k , j , ..] , m : [m , p , q] , ...},其中k和m为词S在文本中的位置,k对应的列表存放的是文本中以k开始且词S[k: j+1]在前缀词典中以k开始以j结尾的词的列表,即列表存档的是S中以k开始的可能的词语的结束位置。
"""
从前往后依次遍历文本的每个位置,对于位置k,首先形成一个片段,
这个片段只包含位置k的字,然后就判断该片段是否在前缀词典中,如果这个片段在前缀词典中,
1.1 如果词频大于0,就将这个位置i追加到以k为key的一个列表中;
1.2 如果词频等于0,如同前面提到的“北京大”,则表明前缀词典存在这个前缀,
但是统计词典并没有这个词,继续循环;
如果这个片段不在前缀词典中,则表明这个片段已经超出统计词典中该词的范围,则终止循环;
然后该位置加1,然后就形成一个新的片段,该片段在文本的索引为[k:i+1],
继续判断这个片段是否在前缀词典中。
"""
def get_DAG(self, sentence):
#检查是否初始化
self.check_initialized()
#DAG存储数据结构是dict
DAG = {}
N = len(sentence)
#依次遍历文本中的每个位置
for k in xrange(N):
tmplist = []
i = k
#位置k形成的片段
frag = sentence[k]
#判断片段是否在前缀词典中,如果不在则跳出循环,
#即该片段已经超出统计词典中该词的长度(即不是同一个词)
while i < N and frag in self.FREQ:
#如果在词典中:
#词频大于0则将该片段加入到DAG中。否则循环继续
if self.FREQ[frag]:
tmplist.append(i)
#片段末尾位置加1
i += 1
#新的片段较旧的片段右边新增一个字
frag = sentence[k:i + 1]
if not tmplist:
tmplist.append(k)
DAG[k] = tmplist
return DAG
如上节中“去北京大学玩”,最终形成的DAG为:
DAG = {0:[0], 1:[1,2,4], 2:[2], 3:[3, 4], 4:[4], 5:[]5}
3.4、最大概率计算
在上一小节当中,我们构建出的DAG的每个节点,都是带权的。对于在前缀词典中的词语,其权重就是它的词频。
我们想要求得route=(w1,w2,...,wn),使得∑weight(wi)最大。对于该问题可以使用动态规划来解。
"""
函数是一个自底向上的动态规划问题,它从sentence的最后一个字(N-1)开始倒序遍历
sentence的每个字(idx)的方式,计算子句sentence[idx ~ N-1]的概率对数得分。
然后将概率对数得分最高的情况以(概率对数,词语最后一个位置)这样的元组保存在route中。
"""
def calc(self, sentence, DAG, route):
N = len(sentence)
#初始化末尾为0
route[N] = (0, 0)
#logtotal为构建前缀词频时所有的词频之和的对数值,
#这里的计算使用概率对数值,可以有效防止下溢问题。
logtotal = log(self.total)
for idx in xrange(N - 1, -1, -1):
#这里max函数的参数是元祖,比较的标准的第一个元素的大小
#!!!!下面代码看不太懂 TT
route[idx] = max((log(self.FREQ.get(sentence[idx:x + 1]) or 1) -
logtotal + route[x + 1][0], x) for x in DAG[idx])
关于未登陆词的分词采用的是HMM模型,写在下一篇里吧~
以上~