jieba分词源码分析

jieba分词中文分词:源码地址:https://github.com/fxsjy/jieba

其特点:

  • 支持三种分词模式:

    • 精确模式,试图将句子最精确地切开,适合文本分析;
    • 全模式,把句子中所有的可以成词的词语都扫描出来, 速度非常快,但是不能解决歧义;
    • 搜索引擎模式,在精确模式的基础上,对长词再次切分,提高召回率,适合用于搜索引擎分词。
  • 支持繁体分词

  • 支持自定义词典

  • MIT 授权协议

jieba分词中用到的算法:

  • 基于字典树结构实现高效的词图扫描,生成句子中汉字所有可能成词情况所构成的有向无环图(DAG)
  • 采用了动态规划查找最大概率路径, 找出基于词频的最大切分组合
  • 对于未登录词,采用了基于汉字成词能力的HMM模型,使用了Viterbi算法

一、基于字典树结构实现高效的词图扫描,生成句子中汉字所有可能成词情况所构成的有向无环图(DAG)

gen_pfdict加载dict.txt生成字典树,lfreq存储dict.txt每个词出现了多少次,以及每个词的所有前缀,前缀的频数置为0,ltotal是所有词出现的总次数,得到的字典树存在FREQ中。

get_DAG函数是根据FREQ对于每个句子sentence生成一个有向无环图,图信息存在字典DAG中,其中DAG[pos]是一个列表[a, b, c...],pos从0到len(sentence) - 1,表示sentence[pos : a + 1],sentence[pos, b + 1]...这些单词出现在了dict中。

def gen_pfdict(f):
    lfreq = {}
    ltotal = 0
    #f_name = resolve_filename(f)
    f_name = f.split('/')[-1]
    #既遍历索引,又遍历元素,且指定起始索引为 1 
    #对于遍历对象是文件而言,元素为每一行的文本
    with open(f,'r',encoding = 'utf-8') as fr:
        for lineno, line in enumerate(fr.readlines(), 1):
            try:
                #print(line.strip())
                line = line.strip()
                word, freq = line.split(' ')[:2]
                freq = int(freq)
                lfreq[word] = freq            #将文件中的词和频率构成字典
                ltotal += freq                #ltotal是所有频率之和
                #以下是将字典中词的拆分词也加入字典
                #例如:不拘一格是在字典中的,然后将:不:0;不拘:0;不拘一:0;也加入字典中
                for ch in range(len(word)):
                    wfrag = word[:ch + 1]
                    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生成有向无环图
#举例实现过程:
#以frag变量的变化:但(在字典中) 但也(在上一个的基础上加一个字,判断是否在字典中,在,则继续,不在,则换下一个字开始,如也)
    
def get_DAG(FREQ, sentence):
    #self.check_initialized()
    DAG = {}
    N = len(sentence)
    for k in range(N):
        tmplist = []
        i = k
        frag = sentence[k]
        while i < N and frag in FREQ:  #从第k个字起始,直到找到在字典(不是字典树,字典树含词的前缀)中的最长的词
            if FREQ[frag]:             #退出条件是:最长的词在加一个词,不在字典树中
                tmplist.append(i)    
            i += 1                   
            frag = sentence[k:i + 1]
        if not tmplist:  #若tmplist为空时(若该词不在字典中),则直接将该词在句中的序号直接放到tmplist
            tmplist.append(k)
        DAG[k] = tmplist
    return DAG

二、采用了动态规划查找最大概率路径, 找出基于词频的最大切分组合

扫描二维码关注公众号,回复: 3618546 查看本文章
#动态规划求解最大路径,其中route中的值是元组,元组的第一个元素是Rmaxi,而第二个元素则保存的切分的位置信息
"""
当sentence = '我知道你们很好'时,产生以下结果:
route:利用cal函数计算出来的
{0: (-33.64133614006646, 0),
 1: (-28.433112642565263, 2),
 2: (-27.243655624028406, 2),
 3: (-21.18538549744644, 4),
 4: (-20.529392523698085, 4),
 5: (-13.244324151007824, 5),
 6: (-6.476124447406034, 6),
 7: (0, 0)}
__cut_DAG_NO_HMM函数则根据上述计算出的最佳路径,得到拆分结果:
例如:0-1,1-3,3-5,5-6,6-7,即:x(上一个的结尾,初始为0)-route[x][1]+1,
"""
def calc(FREQ, total, sentence, DAG, route):
    N = len(sentence)
    route[N] = (0, 0)
    logtotal = log(total)
    #log(FREQ.get(sentence[idx:x + 1]) or 1)获取句中任意两个相邻字的在字典中的频率,如果,该词不在,则词频为0
    #route的形式是字典,值是元组形式,元组前一个是该词的频率的log形式-总log频率
    #对于整个句子的最优路径Rmax和一个末端节点Wx,可能存在对个前驱Wi,Wj,Wk,设到达Wi,Wj,Wk的最大路径分别为Rmaxi,Rmaxj,Rmaxk,则:
    #Rmax = max(Rmaxi,Rmaxj,Rmaxk)+weight(Wx)
    #重复子问题是:从倒数第二个字往前遍历句子,遍历的字作为末端节点,不断求Rmax
    for idx in range(N - 1, -1, -1):
        route[idx] = max((log(FREQ.get(sentence[idx:x + 1]) or 1) -
                          logtotal+ route[x + 1][0] , x) for x in DAG[idx])

__cut_DAG_NO_HMM是不使用hmm的精确模式,首先调用calc,得到的route[i][1]中保存的是切分的位置信息,然后遍历输出切分方式。

#jieba字典字典中没有单个的26个字母,因此对于连续的英文,默认是不拆分,但与中文是分开的
#中文则按照动态规划求解的最大路径
def __cut_DAG_NO_HMM(FREQ,total,sentence):
    DAG = get_DAG(FREQ,sentence)
    route = {}
    calc(FREQ, total, sentence, DAG, route)
    x = 0
    N = len(sentence)
    buf = ''
    while x < N:
        y = route[x][1] + 1
        l_word = sentence[x:y]
        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 = ''

三、对于未登录词,采用了基于汉字成词能力的HMM模型,使用了Viterbi算法

__cut_DAG同时使用最大概率路径和hmm,对于利用动态规划计算出的最大概率切分后,用buf将连续的单字收集以及未登录词收集起来,再调用finalseg.cut利用hmm进行分词。

该函数封装在finalseg模块中,主要通过 __cut 函数来进行进一步的分词,代码如下:

def __cut(sentence):
    global emit_P
    prob, pos_list = viterbi(sentence, 'BMES', start_P, trans_P, emit_P)
    begin, nexti = 0, 0
    # print pos_list, sentence
    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:]

其中,__cut()通过调用viterbi算法得到概率和path之后,对sentence进行分词。

viterbi算法代码如下:

#维特比算法,给定了模型参数和观察序列之后求隐藏状态序列,
#其中对于分词,观察序列就是句子本身,而隐藏序列就是一个由{B, M, E, S}组成的序列,B表示词的开始,M表示词的中间,E表示词的结尾,S表示单字成词。
#输入参数中obs是输入的观察序列,即句子本身,states表示隐藏状态的集合,即{B, M, E, S},
#start_p表示第一个字分别处于{B, M, E, S}这几个隐藏状态的概率,
#trans_p是状态转移矩阵,记录了隐藏状态之间的转化概率,emit_p是发射概率矩阵,表示从一个隐藏状态转移到一个观察状态的概率
def viterbi(obs, states, start_p, trans_p, emit_p):
    V = [{}]   #V是一个列表,V[i][j]表示对于子观察序列obs[0 ~ i],在第i个位置时隐藏状态为j的最大概率
    path = {} #记录了状态转移的路径。
    for y in states:  # init
        V[0][y] = start_p[y] + emit_p[y].get(obs[0], MIN_FLOAT) #计算初始状态概率,由于概率值做了对数化,所以乘号变成了加号
        path[y] = [y]
    for t in range(1, len(obs)):
        V.append({})
        newpath = {}
        for y in states:
            em_p = emit_p[y].get(obs[t], MIN_FLOAT)
            (prob, state) = max(
                [(V[t - 1][y0] + trans_p[y0].get(y, MIN_FLOAT) + em_p, y0) for y0 in PrevStatus[y]])
            V[t][y] = prob
            newpath[y] = path[state] + [y] #剪枝,只保存概率最大的一种路径
        path = newpath
    #求出最后一个字哪一种状态的对应概率最大,最后一个字只可能是两种情况:E(结尾)和S(独立词)
    (prob, state) = max((V[len(obs) - 1][y], y) for y in 'ES')
    return (prob, path[state])

猜你喜欢

转载自blog.csdn.net/LZH_12345/article/details/81708518