• 结巴分词2--基于前缀词典及动态规划实现分词


    作者:zhbzz2007 出处:http://www.cnblogs.com/zhbzz2007 欢迎转载,也请保留这段声明。谢谢!

    1 简介

    jieba分词主要是基于统计词典,构造一个前缀词典;然后利用前缀词典对输入句子进行切分,得到所有的切分可能,根据切分位置,构造一个有向无环图;通过动态规划算法,计算得到最大概率路径,也就得到了最终的切分形式。

    2 实例讲解

    以“去北京大学玩”为例,作为待分词的输入文本。

    离线统计的词典形式如下,每一行有三列,第一列是词,第二列是词频,第三列是词性。

    ...
    北京大学 2053 nt
    大学 20025 n
    去 123402 v
    玩 4207 v
    北京 34488 ns
    北 17860 ns
    京 6583 ns
    大 144099 a
    学 17482 n
    ...
    

    2.1 前缀词典构建

    首先是基于统计词典构造前缀词典,如统计词典中的词“北京大学”的前缀分别是“北”、“北京”、“北京大”;词“大学”的前缀是“大”。统计词典中所有的词形成的前缀词典如下所示,你也许会注意到“北京大”作为“北京大学”的前缀,但是它的词频却为0,这是为了便于后面有向无环图的构建。

    ...
    北京大学 2053
    北京大 0
    大学 20025
    去 123402
    玩 4207
    北京 34488
    北 17860
    京 6583
    大 144099
    学 17482
    ...
    

    2.2 有向无环图构建

    然后基于前缀词典,对输入文本进行切分,对于“去”,没有前缀,那么就只有一种划分方式;对于“北”,则有“北”、“北京”、“北京大学”三种划分方式;对于“京”,也只有一种划分方式;对于“大”,则有“大”、“大学”两种划分方式,依次类推,可以得到每个字开始的前缀词的划分方式。

    在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.3 最大概率路径计算

    在得到所有可能的切分方式构成的有向无环图后,我们发现从起点到终点存在多条路径,多条路径也就意味着存在多种分词结果,例如,

    # 路径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.__init__.py中实现了jieba分词接口函数cut(self, sentence, cut_all=False, HMM=True)。

    jieba分词接口主入口函数,会首先将输入文本解码为Unicode编码,然后根据入参,选择不同的切分方式,本文主要以精确模式进行讲解,因此cut_all和HMM这两个入参均为默认值;

    切分方式选择,

    re_han = re_han_default
    re_skip = re_skip_default
    

    块切分方式选择,

    cut_block = self.__cut_DAG
    

    函数__cut_DAG(self, sentence)首先构建前缀词典,其次构建有向无环图,然后计算最大概率路径,最后基于最大概率路径进行分词,如果遇到未登录词,则调用HMM模型进行切分。本文主要涉及前三个部分,基于HMM的分词方法则在下一文章中详细说明。

    3.2 前缀词典构建

    get_DAG(self, sentence)函数会首先检查系统是否初始化,如果没有初始化,则进行初始化。在初始化的过程中,会构建前缀词典。

    构建前缀词典的入口函数是gen_pfdict(self, f),解析离线统计词典文本文件,每一行分别对应着词、词频、词性,将词和词频提取出来,以词为key,以词频为value,加入到前缀词典中。对于每个词,再分别获取它的前缀词,如果前缀词已经存在于前缀词典中,则不处理;如果该前缀词不在前缀词典中,则将其词频置为0,便于后续构建有向无环图。

    jieba分词中gen_pfdict函数实现如下,

    # f是离线统计的词典文件句柄
    def gen_pfdict(self, f):
        # 初始化前缀词典
        lfreq = {}
        ltotal = 0
        f_name = resolve_filename(f)
        for lineno, line in enumerate(f, 1):
            try:
                # 解析离线词典文本文件,离线词典文件格式如第2章中所示
                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]
                    # 如果某前缀词不在前缀词典中,则将对应词频设置为0,
                    # 如第2章中的例子“北京大”
                    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
    

    为什么jieba没有使用trie树作为前缀词典存储的数据结构?

    参考jieba中的issue--不用Trie,减少内存加快速度;优化代码细节 #187,本处直接引用该issue的comment,如下,

    对于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空间效率低下的问题。

    同时改善了一些代码的细节,遵循PEP8的格式,优化了几个逻辑判断。

    3.2 有向无环图构建

    有向无环图,directed acyclic graphs,简称DAG,是一种图的数据结构,顾名思义,就是没有环的有向图。

    DAG在分词中的应用很广,无论是最大概率路径,还是其它做法,DAG都广泛存在于分词中。因为DAG本身也是有向图,所以用邻接矩阵来表示是可行的,但是jieba采用了Python的dict结构,可以更方便的表示DAG。最终的DAG是以{k : [k , j , ..] , m : [m , p , q] , ...}的字典结构存储,其中k和m为词在文本sentence中的位置,k对应的列表存放的是文本中以k开始且词sentence[k: j + 1]在前缀词典中的 以k开始j结尾的词的列表,即列表存放的是sentence中以k开始的可能的词语的结束位置,这样通过查找前缀词典就可以得到词。

    get_DAG(self, sentence)函数进行对系统初始化完毕后,会构建有向无环图。

    从前往后依次遍历文本的每个位置,对于位置k,首先形成一个片段,这个片段只包含位置k的字,然后就判断该片段是否在前缀词典中,

    1. 如果这个片段在前缀词典中,

      1.1 如果词频大于0,就将这个位置i追加到以k为key的一个列表中;

      1.2 如果词频等于0,如同第2章中提到的“北京大”,则表明前缀词典存在这个前缀,但是统计词典并没有这个词,继续循环;

    2. 如果这个片段不在前缀词典中,则表明这个片段已经超出统计词典中该词的范围,则终止循环;

    3. 然后该位置加1,然后就形成一个新的片段,该片段在文本的索引为[k:i+1],继续判断这个片段是否在前缀词典中。

    jieba分词中get_DAG函数实现如下,

    # 有向无环图构建主函数
    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
                # 将该片段加入到有向无环图中
                # 否则,继续循环
                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
    

    以“去北京大学玩”为例,最终形成的有向无环图为,

    {0: [0], 1: [1,2,4], 2: [2], 3: [3,4], 4: [4], 5: [5]}
    

    3.3 最大概率路径计算

    3.2章节中构建出的有向无环图DAG的每个节点,都是带权的,对于在前缀词典里面的词语,其权重就是它的词频;我们想要求得route = (w1,w2,w3,...,wn),使得 (sum weight(w_{i})) 最大。

    如果需要使用动态规划求解,需要满足两个条件,

    • 重复子问题
    • 最优子结构

    我们来分析一下最大概率路径问题,是否满足动态规划的两个条件。

    重复子问题

    对于节点wi和其可能存在的多个后继节点Wj和Wk,

    任意通过Wi到达Wj的路径的权重 = 该路径通过Wi的路径权重 + Wj的权重,也即{Ri -> j} = {Ri + weight(j)}
    任意通过Wi到达Wk的路径的权重 = 该路径通过Wi的路径权重 + Wk的权重,也即{Ri -> k} = {Ri + weight(k)}
    

    即对于拥有公共前驱节点Wi的节点Wj和Wk,需要重复计算达到Wi的路径的概率。

    最优子结构

    对于整个句子的最优路径Rmax和一个末端节点Wx,对于其可能存在的多个前驱Wi,Wj,Wk...,设到达Wi,Wj,Wk的最大路径分别是Rmaxi,Rmaxj,Rmaxk,有,

    Rmax = max(Rmaxi,Rmaxj,Rmaxk,...) + weight(Wx)
    

    于是,问题转化为,求解Rmaxi,Rmaxj,Rmaxk,...等,

    组成了最优子结构,子结构里面的最优解是全局的最优解的一部分。

    状态转移方程为,

    Rmax = max{(Rmaxi,Rmaxj,Rmaxk,...) + weight(Wx)}
    

    jieba分词中计算最大概率路径的主函数是calc(self, sentence, DAG, route),函数根据已经构建好的有向无环图计算最大概率路径。

    函数是一个自底向上的动态规划问题,它从sentence的最后一个字(N-1)开始倒序遍历sentence的每个字(idx)的方式,计算子句sentence[idx ~ N-1]的概率对数得分。然后将概率对数得分最高的情况以(概率对数,词语最后一个位置)这样的元组保存在route中。

    函数中,logtotal为构建前缀词频时所有的词频之和的对数值,这里的计算都是使用概率对数值,可以有效防止下溢问题。

    jieba分词中calc函数实现如下,

    def calc(self, sentence, DAG, route):
        N = len(sentence)
        # 初始化末尾为0
        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])
    

    4 Reference

    jieba分词学习笔记(三)

  • 相关阅读:
    C#获取配置文件中的文件数据
    wpf MVVMLight的DataGrid绑定数据
    扫码支付自动跳转,可以使用第三方网站实现扫码二维码付款然后跳转到想要的页面展示想要内容或者是解压码或者是某个资源的下载页呢 具体步骤(我以你上传一个压缩包到某种网盘或者可以下载的地址等让人付费解压为例):
    oracle数据库如何创建用户以及分配权限
    ORA-12547: TNS: 丢失连接
    springmvc中applicationapplicationContext头部代码
    No mapping found for HTTP request with URI
    在Navicat新建用户
    myeclipse 项目引入 com.sun.image.codec.jpeg 的api报错解决方法
    java.lang.NullPointerException at org.apache.jsp.**_jsp.jspInit(**_jsp.java)tomcat启动异常解决方法
  • 原文地址:https://www.cnblogs.com/zhbzz2007/p/6084196.html
Copyright © 2020-2023  润新知