【NLP】【二】jieba源码分析之分词

【一】词典加载

利用jieba进行分词时,jieba会自动加载词典,这里jieba使用python中的字典数据结构进行字典数据的存储,其中key为word,value为frequency即词频。

1. jieba中的词典如下:

jieba/dict.txt

X光 3 n
X光线 3 n
X射线 3 n
γ射线 3 n
T恤衫 3 n
T型台 3 n

该词典每行一个词,每行数据分别为:词 词频 词性

2. 词典的加载

jieba/_init_.py,源码解析如下:

    # 加载词典
    def gen_pfdict(self, f):
        # 定义字典,key = word, value = freq
        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)
                # 记录词频与词的关系,这里直接采用dict存储,没有采用Trie树结构
                lfreq[word] = freq
                ltotal += freq
                # 对多个字组成的词进行查询
                for ch in xrange(len(word)):
                    # 从头逐步取词,如 电风扇,则会一次扫描 电,电风,电风扇
                    wfrag = word[:ch + 1]
                    # 如果该词不在词频表中,将该词插入词频表,并设置词频为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

在词典初始化时,会调用该接口,将词频表存储到变量FREQ中,后续进行分词时,会直接进行该词频表的查询。

调用点如下:

def initialize(self, dictionary=None):
    self.FREQ, self.total = self.gen_pfdict(self.get_dict_file())

【二】分词

jieba进行分词时,使用词典的最大前缀匹配的方式。当使用精确匹配模型且启动HMM时,对于未登录词(即词典中不存在的词),jieba会使用HMM模型对未登录词进行分词,具体的算法是viterbi算法。

jieba分词的第一步是将待分配的文本,依据词典,生成动态图DAG。

1. 动态图的生成。

    # 生成动态图
    '''
    例如:sentence = '我爱北京天安门'
         id        = 0 1 2 3 4 5 6
         则 DAG = {
                    0:[0],
                    1:[1],
                    2:[2,3],
                    3:[3],
                    4:[4,5,6],
                    5:[5],
                    6:[6]
         }
    
    '''
    def get_DAG(self, sentence):
        # 查看词典是否已经初始化,若没有,则加载词典,初始化词频表。
        # 由此可以看出,jieba采用的是词典懒加载模式
        self.check_initialized()
        # 使用字典结构存储动态图
        DAG = {}
        # 获取待分词的句子的长度
        N = len(sentence)
        # 从头到尾,逐字扫描
        for k in xrange(N):
            tmplist = []
            i = k
            # 获取句子中第k个位置的字
            frag = sentence[k]
            # 如果该字在词频表中,则继续扫描,直到扫描出的词不再词频表中为止
            # 即jieba采用的最大前缀匹配法来搜索词
            # 例如: 句子为: 我爱北京天安门,frag = 北,则该while循环会一直搜索到京,
            #        即搜索到北京,而tmplist里面会存储:北,北京两个词
            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

2. 分词接口流程分析

# 分词接口
    def cut(self, sentence, cut_all=False, HMM=True):
        '''
        The main function that segments an entire sentence that contains
        Chinese characters into seperated words.

        Parameter:
            - sentence: The str(unicode) to be segmented.
            - cut_all: Model type. True for full pattern, False for accurate pattern.
            - HMM: Whether to use the Hidden Markov Model.
        '''
        # 对待分词的序列进行编解码,解码成utf-8的格式
        sentence = strdecode(sentence)

        # 依据是否使用全模式,确定待使用的正则表达式式,
        # 结巴先使用正则表达式对待分割内容进行预处理,主要是去除标点符号
        if cut_all:
            re_han = re_han_cut_all
            re_skip = re_skip_cut_all
        else:
            re_han = re_han_default
            re_skip = re_skip_default
        # 依据分割模式,设置分词接口,有点类似于函数指针的意思哈
        if cut_all:
            # 全模式,使用__cut_all
            cut_block = self.__cut_all
        elif HMM:
            # 精确匹配模式,且使用HMM对未登录词进行分割,则使用__cut_DAG
            cut_block = self.__cut_DAG
        else:
            # 精确匹配模式,但不使用HMM对未登录词进行分割,则使用__cut_DAG_NO_HMM
            cut_block = self.__cut_DAG_NO_HMM
        # 使用正则表达式对待分词序列进行预处理
        blocks = re_han.split(sentence)
        for blk in blocks:
            if not blk:
                continue
            if re_han.match(blk):
                # 调用分词接口进行分词
                for word in cut_block(blk):
                    yield word
            else:
                tmp = re_skip.split(blk)
                for x in tmp:
                    if re_skip.match(x):
                        yield x
                    elif not cut_all:
                        for xx in x:
                            yield xx
                    else:
                        yield x

3. 全模式分词

    def __cut_all(self, sentence):
        # 生成动态图
        '''
        例如:sentence = '我爱北京天安门'
             id        = 0 1 2 3 4 5 6
             则 DAG = {
                        0:[0],
                        1:[1],
                        2:[2,3],
                        3:[3],
                        4:[4,5,6],
                        5:[5],
                        6:[6]
             }
        
        '''
        dag = self.get_DAG(sentence)
        old_j = -1
        # 扫描动态图
        for k, L in iteritems(dag):
            # 如果只有一个字,且该字未在前面的词中出现过,则生成一个词,否则跳过
            # 例如: ‘北京’已经成词了,再次扫描到‘京’时,需要跳过
            if len(L) == 1 and k > old_j:
                yield sentence[k:L[0] + 1]
                old_j = L[0]
            else:
                #对于至少2个字的词,如 4:[4,5,6], 则分割为 天安,天安门 两个词
                # 这符合jieba的全模式定义:尽量细粒度的分词
                for j in L:
                    if j > k:
                        yield sentence[k:j + 1]
                        old_j = j

4. 精确模式分词,且使用HMM对未登录词进行分割

 def __cut_DAG(self, sentence):
        DAG = self.get_DAG(sentence)
        route = {}
        # 使用动态规划算法,选取概率最大的路径进行分词
        '''
        例如:sentence = '我爱北京天安门'
             id        = 0 1 2 3 4 5 6
             则 DAG = {
                        0:[0],
                        1:[1],
                        2:[2,3],
                        3:[3],
                        4:[4,5,6],
                        5:[5],
                        6:[6]
             }
        
        '''
        # 在进行 4:[4,5,6], 分词时,计算出成词的最大概率路径为4~6,即‘天安门’的概率大于‘天安’
        self.calc(sentence, DAG, route)
        x = 0
        buf = ''
        N = len(sentence)
        while x < N:
            y = route[x][1] + 1
            l_word = sentence[x:y]
            if y - x == 1:
                buf += l_word
            else:
                if buf:
                    if len(buf) == 1:
                        yield buf
                        buf = ''
                    else:
                        # 对于未登录词,使用HMM模型进行分词
                        if not self.FREQ.get(buf):
                            recognized = finalseg.cut(buf)
                            for t in recognized:
                                yield t
                        else:
                            for elem in buf:
                                yield elem
                        buf = ''
                yield l_word
            x = y

        if buf:
            if len(buf) == 1:
                yield buf
            elif not self.FREQ.get(buf):
                recognized = finalseg.cut(buf)
                for t in recognized:
                    yield t
            else:
                for elem in buf:
                    yield elem

5. 动态规划算法求解最大概率路径。

jieba在使用精确模式进行分词时,会将‘天安门’分割成‘天安门’,而不是全模式下的‘天安’和‘天安门’两个词,jieba时如何做到的呢?

其实,求解的核心在于‘天安门’的成词概率比‘天安’大。

5.1 先看看jieba的动态规划后的结果

    '''
        例如:输入的动态图如下
        DAG = {
                    0:[0],
                    1:[1],
                    2:[2,3],
                    3:[3],
                    4:[4,5,6],
                    5:[5],
                    6:[6]
         }
        则,返回值为:
        R = {
            0:(f1,0),
            1:(f2,1),
            2:(f3,3),
            3:(f3,3),
            4:(f4,6),
            5:(f5,5),
            6:(f6,6)
        }

        这个返回值是什么含义呢?
        例如:0:(f1,0)  ----> id从0到0成词概率最大,最大概率为f1
             4:(f4,6), ----> id从4到6成词概率最大,最大概率为f4
        依据返回值R,可以得到成词下标,0->0,1->1,2->3,4->6,即:我/爱/北京/天安门
    '''
    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((log(self.FREQ.get(sentence[idx:x + 1]) or 1) -
                              logtotal + route[x + 1][0], x) for x in DAG[idx])

5.2 动态规划算法

jieba使用的是1-gram模型,即 P(S) = P(W1)*P(W2)*....P(W3). 要求得P(S)取得最大值时,P(W1)/P(W2)..../P(Wn)依次取得什么值。

这里采用动态规划算法来求解该问题。即:argmax P(S) = argmax ( P(Wn)*P(Sn-1)) <=> argmax (log(P(Wn))+log(P(Sn-1)))

当使用jieba中的Route来存储最大概率路径时,即得到 R[i] = argmax(log(Wi/V) + R[i-1]),jieba中使用了一个小技巧,即倒着扫描,这与DAG中的list存储着后向节点有关系。即R[i] = argmax(log(Wi/V) + R[i+1]),于是便有了上述代码。

6. 关于未登录词的HMM求解。

关于HMM介绍这里不做赘述,这里仅描述一下,jieba是怎么依据HMM模型进行分词的。

6.1 问题建模

jieba 使用 BMES对词进行建模。比如:我爱北京天安门,用BMES表示为:SSBEBME。只要拿到了SSBEBME这个字符串,就可以对“我爱北京天安门”进行分词,按照该字符串,分词结果为: 我/爱/北京/天安门。

那么问题来了,如何由“我爱北京天安门”得到“SSBEBME”这个字符串呢?

我们可以将“我爱北京天安门”理解为观察结果,“SSBEBME”理解为隐藏的词的状态的迁移结果,即HMM中的隐式状态转移。那么问题就成了:如何求解:P(S|O)= P(隐式状态迁移序列|观测序列).

6.2 问题求解

依据贝叶斯公式: P(S|O) = P(S,O)/P(O) = P(O|S)P(S)/P(O)

结合HMM相关知识,进一步求解P(S|O) = P(St|Ot) = P(Ot|St)*P(St|St-1)/P(Ot),由于每一个t时刻,P(Ot)都一样,可以去掉,因此:

P(St|Ot) = P(Ot|St)*P(St|St-1),其中 P(Ot|St)为发射概率,即HMM的参数Bij,P(St|St-1)是HMM的状态转移矩阵参数,即Aij。

另外还已知初始状态向量参数,因此可以求解出P(S|O)的最大值时的概率路径,也就是 “SSBEBME”。

6.3 jieba中的HMM参数值

初始状态概率在 jieba/finalseg/prop_start.py里面。

P={'B': -0.26268660809250016,
 'E': -3.14e+100,
 'M': -3.14e+100,
 'S': -1.4652633398537678}

状态转移矩阵参数在 jieba/finalseg/prop_trans.py

P={'B': {'E': -0.510825623765990, 'M': -0.916290731874155},
 'E': {'B': -0.5897149736854513, 'S': -0.8085250474669937},
 'M': {'E': -0.33344856811948514, 'M': -1.2603623820268226},
 'S': {'B': -0.7211965654669841, 'S': -0.6658631448798212}}

发射概率在 jieba/finalseg/prop_emit.py

6.4 jieba中这些HMM的参数是怎么来的呢?

据jieba官方介绍,是采用人明日报的语料库 和另外一个分词工具训练来的。总而言之,这些参数是依据特殊语料统计得到的。如果使用jieba的默认参数导致分词不理想时,应该考虑到重新训练自己的HMM参数。

6.5 jieba HMM源码解析

源码路径在 jieba/finalseg/_init_.py

主体代码流程如下:

def cut(sentence):
    # 先解码
    sentence = strdecode(sentence)
    # 再按照正则表达式进行初步分割
    blocks = re_han.split(sentence)
    for blk in blocks:
        if re_han.match(blk):
            # 依据HMM模型,对未登录词进行分割
            for word in __cut(blk):
                if word not in Force_Split_Words:
                    yield word
                else:
                    for c in word:
                        yield c
        else:
            tmp = re_skip.split(blk)
            for x in tmp:
                if x:
                    yield x

HMM分词如下:

def __cut(sentence):
    global emit_P
    # 使用viterbi算法进行HMM求解,即生成 BMES的状态迁移序列
    prob, pos_list = viterbi(sentence, 'BMES', start_P, trans_P, emit_P)
    begin, nexti = 0, 0
    # print pos_list, sentence
    # 依据 BMES的状态迁移序列,进行分词
    for i, char in enumerate(sentence):
        pos = pos_list[i]
        if pos == 'B':
            begin = i
        elif pos == 'E':
            yield sentence[begin:i + 1]
            nexti = i + 1
        elif pos == 'S':
            yield char
            nexti = i + 1
    if nexti < len(sentence):
        yield sentence[nexti:]

这里使用了viterbi算法求解状态转移序列。关于viterbi算法,注意两点:该算法的推导时自顶向下,但是最终的计算是自底向上,体现在分词上,就是从前往后计算。求解的目的也是找到一条最大概率路径,因此也是动态规划算法。这里就不推导了。

【三】总结

jieba分词,对于登录词,使用最大前缀匹配的方法,其中精确模式使用了动态规划算法来计算最大概率路径,进而得到最佳分词。对于未登录词,jieba使用HMM模型来求解最佳分词。

两种方式,都用到了词典与模型的HMM模型参数。因此,对于某些分词场景,可以使用自己的词典和自己的语料库训练出来的HMM模型参数进行中文分词,进而提升分词准确性。

jieba分词的核心理论基础为:统计和概率论。不知道基于深度学习的算法,是否可以进行中文分词。

猜你喜欢

转载自my.oschina.net/u/3800567/blog/2414008