jieba中文分词源码分析(三)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/gfsfg8545/article/details/48223135

一、前缀字典

作者这个版本(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根据我们生成的前缀字典来构造一个这样的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 的中文分词算法就介绍完毕了。下面将介绍对于分词中未登录词的切分方法。


参考

  1. http://blog.csdn.net/rav009/article/details/12310077
  2. http://book.51cto.com/art/201106/269048.htm

猜你喜欢

转载自blog.csdn.net/gfsfg8545/article/details/48223135