作者这个版本(0.37)中使用前缀字典实现了词库的存储(即dict.txt文件中的内容),而弃用之前版本的trie树存储词库,python中实现的trie树是基于dict类型的数据结构而且dict中又嵌套dict 类型,这样嵌套很深,导致内存耗费严重,具体点这里,下面是@gumblex commit的内容:
对于get_DAG()函数来说,用Trie数据结构,特别是在Python环境,内存使用量过大。经实验,可构造一个前缀集合解决问题。
该集合储存词语及其前缀,如set([‘数’, ‘数据’, ‘数据结’, ‘数据结构’])。在句子中按字正向查找词语,在前缀列表中就继续查找,直到不在前缀列表中或超出句子范围。大约比原词库增加40%词条。
该版本通过各项测试,与原版本分词结果相同。测试:一本5.7M的小说,用默认字典,64位Ubuntu,Python 2.7.6。
Trie:第一次加载2.8秒,缓存加载1.1秒;内存277.4MB,平均速率724kB/s
前缀字典:第一次加载2.1秒,缓存加载0.4秒;内存99.0MB,平均速率781kB/s
此方法解决纯Python中Trie空间效率低下的问题。
jieba0.37版本中实际使用是前缀字典具体实现(对应代码中Tokenizer.FREQ字典),即就是利用python中的dict把dict.txt中出现的词作为key,出现频次作为value,比如sentece : “北京大学”,处理后的结果为:{u’北’:17860, u’北京’ :34488,u’北京大’: 0,u’北京大学’: 2053},具体详情见代码:
def gen_pfdict(self, f_name):
lfreq = {} # 字典存储 词条:出现次数
ltotal = 0 # 所有词条的总的出现次数
with open(f_name, 'rb') as f: # 打开文件 dict.txt
for lineno, line in enumerate(f, 1): # 行号,行
try:
line = line.strip().decode('utf-8') # 解码为Unicode
word, freq = line.split(' ')[:2] # 获得词条 及其出现次数
freq = int(freq)
lfreq[word] = freq
ltotal += freq
for ch in xrange(len(word)):# 处理word的前缀
wfrag = word[:ch + 1]
if wfrag not in lfreq: # word前缀不在lfreq则其出现频次置0
lfreq[wfrag] = 0
except ValueError:
raise ValueError(
'invalid dictionary entry in %s at Line %s: %s' % (f_name, lineno, line))
return lfreq, ltotal
DAG根据我们生成的前缀字典来构造一个这样的DAG,对一个sentence DAG是以{key:list[i,j…], …}的字典结构存储,其中key是词的在sentence中的位置,list存放的是在sentence中以key开始且词sentence[key:i+1]在我们的前缀词典中 的以key开始i结尾的词的末位置i的列表,即list存放的是sentence中以位置key开始的可能的词语的结束位置,这样通过查字典得到词, 开始位置+结束位置列表。
例如句子”去北京大学玩“对应的DAG为:
{0 : [0], 1 : [1, 2, 4], 2 : [2], 3 : [3, 4], 4 : [4], 5 : [5]}
例如DAG中{0:[0]} 这样一个简单的DAG, 就是表示0位置对应的是词, 就是说0~0,即”去”这个词 在dict.txt中是词条。DAG中{1:[1,2,4]}, 就是表示1位置开始, 在1,2,4位置都是词, 就是说1~1,1~2,1~4 即 “北”,“北京”,“北京大学”这三个词 在dict.txt对应文件的词库中。
通过上面两小节可以得知,我们已经有了词库(dict.txt)的前缀字典和待分词句子sentence的DAG,基于词频的最大切分 要在所有的路径中找出一条概率得分最大的路径,该怎么做呢?
jieba中的思路就是使用动态规划方法,从后往前遍历,选择一个频度得分最大的一个切分组合。
具体实现见代码,已给详细注释。
#动态规划,计算最大概率的切分组合
def calc(self, sentence, DAG, route):
N = len(sentence)
route[N] = (0, 0)
# 对概率值取对数之后的结果(可以让概率相乘的计算变成对数相加,防止相乘造成下溢)
logtotal = log(self.total)
# 从后往前遍历句子 反向计算最大概率
for idx in xrange(N - 1, -1, -1):
# 列表推倒求最大概率对数路径
# route[idx] = max([ (概率对数,词语末字位置) for x in DAG[idx] ])
# 以idx:(概率对数最大值,词语末字位置)键值对形式保存在route中
# route[x+1][0] 表示 词路径[x+1,N-1]的最大概率对数,
# [x+1][0]即表示取句子x+1位置对应元组(概率对数,词语末字位置)的概率对数
route[idx] = max((log(self.FREQ.get(sentence[idx:x + 1]) or 1) -
logtotal + route[x + 1][0], x) for x in DAG[idx])
从代码中可以看出calc是一个自底向上的动态规划(重叠子问题、最优子结构),它从sentence的最后一个字(N-1)开始倒序遍历sentence的字(idx)的方式,计算子句sentence[isdx~N-1]概率对数得分(这里利用DAG及历史计算结果route实现,同时赞下 作者的概率使用概率对数 这样有效防止 下溢问题)。然后将概率对数得分最高的情况以(概率对数,词语最后一个字的位置)这样的tuple保存在route中。
根据上面的结束写了如下的测试:
#coding:utf8
'''
测试jieba __init__文件
'''
import os
import logging
import marshal
import re
from math import log
_get_abs_path = lambda path: os.path.normpath(os.path.join(os.getcwd(), path))
DEFAULT_DICT = _get_abs_path("../jieba/dict.txt")
re_eng = re.compile('[a-zA-Z0-9]', re.U)
#print DEFAULT_DICT
class Tokenizer(object):
def __init__(self, dictionary=DEFAULT_DICT):
self.dictionary = _get_abs_path(dictionary)
self.FREQ = {}
self.total = 0
self.initialized = False
self.cache_file = None
def gen_pfdict(self, f_name):
lfreq = {} # 字典存储 词条:出现次数
ltotal = 0 # 所有词条的总的出现次数
with open(f_name, 'rb') as f: # 打开文件 dict.txt
for lineno, line in enumerate(f, 1): # 行号,行
try:
line = line.strip().decode('utf-8') # 解码为Unicode
word, freq = line.split(' ')[:2] # 获得词条 及其出现次数
freq = int(freq)
lfreq[word] = freq
ltotal += freq
for ch in xrange(len(word)):# 处理word的前缀
wfrag = word[:ch + 1]
if wfrag not in lfreq: # word前缀不在lfreq则其出现频次置0
lfreq[wfrag] = 0
except ValueError:
raise ValueError(
'invalid dictionary entry in %s at Line %s: %s' % (f_name, lineno, line))
return lfreq, ltotal
# 从前缀字典中获得此的出现次数
def gen_word_freq(self, word):
if word in self.FREQ:
return self.FREQ[word]
else:
return 0
def check_initialized(self):
if not self.initialized:
abs_path = _get_abs_path(self.dictionary)
if self.cache_file:
cache_file = self.cache_file
# 默认的cachefile
elif abs_path:
cache_file = "jieba.cache"
load_from_cache_fail = True
# cachefile 存在
if os.path.isfile(cache_file):
try:
with open(cache_file, 'rb') as cf:
self.FREQ, self.total = marshal.load(cf)
load_from_cache_fail = False
except Exception:
load_from_cache_fail = True
if load_from_cache_fail:
self.FREQ, self.total = self.gen_pfdict(abs_path)
#把dict前缀集合,总词频写入文件
try:
with open(cache_file, 'w') as temp_cache_file:
marshal.dump((self.FREQ, self.total), temp_cache_file)
except Exception:
#continue
pass
# 标记初始化成功
self.initialized = True
def get_DAG(self, sentence):
self.check_initialized()
DAG = {}
N = len(sentence)
for k in xrange(N):
tmplist = []
i = k
frag = sentence[k]
while i < N and frag in self.FREQ:
if self.FREQ[frag]:
tmplist.append(i)
i += 1
frag = sentence[k:i + 1]
if not tmplist:
tmplist.append(k)
DAG[k] = tmplist
return DAG
#动态规划,计算最大概率的切分组合
def calc(self, sentence, DAG, route):
N = len(sentence)
route[N] = (0, 0)
# 对概率值取对数之后的结果(可以让概率相乘的计算变成对数相加,防止相乘造成下溢)
logtotal = log(self.total)
# 从后往前遍历句子 反向计算最大概率
for idx in xrange(N - 1, -1, -1):
# 列表推倒求最大概率对数路径
# route[idx] = max([ (概率对数,词语末字位置) for x in DAG[idx] ])
# 以idx:(概率对数最大值,词语末字位置)键值对形式保存在route中
# route[x+1][0] 表示 词路径[x+1,N-1]的最大概率对数,
# [x+1][0]即表示取句子x+1位置对应元组(概率对数,词语末字位置)的概率对数
route[idx] = max((log(self.FREQ.get(sentence[idx:x + 1]) or 1) -
logtotal + route[x + 1][0], x) for x in DAG[idx])
# DAG中是以{key:list,...}的字典结构存储
# key是字的开始位置
def cut_DAG_NO_HMM(self, sentence):
DAG = self.get_DAG(sentence)
route = {}
self.calc(sentence, DAG, route)
x = 0
N = len(sentence)
buf = ''
while x < N:
y = route[x][1] + 1
l_word = sentence[x:y]# 得到以x位置起点的最大概率切分词语
if re_eng.match(l_word) and len(l_word) == 1:#数字,字母
buf += l_word
x = y
else:
if buf:
yield buf
buf = ''
yield l_word
x = y
if buf:
yield buf
buf = ''
if __name__ == '__main__':
s = u'去北京大学玩'
t = Tokenizer()
dag = t.get_DAG(s)
# 打印s的前缀字典
print(u'\"%s\"的前缀字典:' % s)
for pos in xrange(len(s)):
print s[:pos+1], t.gen_word_freq(s[:pos+1])
print(u'\"%s\"的DAG:' % s)
for d in dag:
print d, ':', dag[d]
route = {}
t.calc(s, dag, route)
print 'route:'
print route
print('/'.join(t.cut_DAG_NO_HMM(u'去北京大学玩')))
输出结果为:
“去北京大学玩”的前缀字典:
去 123402
去北 0
去北京 0
去北京大 0
去北京大学 0
去北京大学玩 0
“去北京大学玩”的DAG:
0 : [0]
1 : [1, 2, 4]
2 : [2]
3 : [3, 4]
4 : [4]
5 : [5]
route:
{0: (-26.039894284878688, 0), 1: (-19.851543754900984, 4), 2: (-26.6931716802707, 2), 3: (-17.573864399983357, 4), 4: (-17.709674112779485, 4), 5: (-9.567048044164698, 5), 6: (0, 0)}
去/北京大学/玩
测试代码,这里。
好了,基于 DAG 的中文分词算法就介绍完毕了。下面将介绍对于分词中未登录词的切分方法。