数据挖掘基础-2.中文分词

一、中文分词

分词是文本相似度的基础,不同于英文分词,中文没有空格,所以在进行自然语言处理处理前,需要先进行中文分词。

1.常用方法-基于词典匹配

即有个用于匹配的词典,一般采用最大长度查找法,可以分为前向查找,后向查找。

前向查找:待切分的句子从前往后切分,如果有存在一个最大长度的词在词典中,就在这切分。后向查找:句子从后往前切分,原理和前向相同。一般来说后向切分效果会好一点,因为中文重心一般位于句子后面。

举例:有带切分的句子“北京大学生活动中心”。通过前向查找,发现“北”、“北京”、“北京大”、“北京大学”都在词典中,但是没有“北京大学生”,因此判断可切分的最大长度就是“北京大学”,因此就在这里切分,依次类推可以切分出“生活“、”动”、“中心”,这结果显然不是很合理。如果通过后向查找:将会先切分出“中心”、接下来依次是“生活”、“大学生”、“北京”,切分的效果会比前向的好。由于中文的重心一般位于句子后面,所以后向查找的效果总体效果会比前向查找好。

2.概率语言模型

自然语言处理可以分为两个阶段:基于规则和基于统计。基于规则即基于语言的语法来分析句子,基于统计则是根据统计来得出处理结果。后来语义分析渐入瓶颈,而基于统计逐渐大放光彩,成为现在的绝对主流。有兴趣的可以阅读吴军的《数学之美》,讲的非常有意思。

从统计思想的角度来看,分词问题的输入是一个字串C=c1,c2……cn ,输出是一个词串S=w1,w2……wm ,其中m<=n。对于一个特定的字符串C,会有多个切分方案S对应,假设这两种切分方法分别叫做S1和S2。计算条件概率P(S1|C)和P(S2|C),然后根据P(S1|C)和P(S2|C)的值大小来决定选择S1还是S2。通过贝叶斯公式可以将P(S|C)的计算转换成如下形式。

• P(S|C)是由字符串C产生切分S的概率,也就是对输入字符串切分出最有可能的词序列概率值。

• P(C)只是一个用来归一化的固定值,即这个句子在语料库中占的比例。从词串恢复到汉字串的概率只有一种可能,所以P(C|S)=1。比较P(S1|C)和P(S2|C)的大小变成比较P(S1)和P(S2) 的大小。

p(S1|C)/p(S2|C) = p(S1)/p(S2)

例如:对于输入字符串C“南京市长江大桥”,有下面两种切分可能:

– S1:南京市 / 长江 / 大桥

– S2:南京 / 市长 / 江大桥

假设语料库中有1万个句子,其中有一句是 “南京市长江大桥”, 那么P(C)=P(“南京市长江大桥” )=万分之一。

假设每个词出现的概率互相独立,因为P(S1)=P(南京市,长江,大桥)=P(南京市)*P(长江)*P(大桥)> P(S2)=P(南京,市长,江大桥),所以选择切分方案S1。

3.一元模型

假设每个词出现的概率独立的,即上下文无关。对于不同的S(切分方案),m(分词的数量)的值是不一样的,一般来说m越大,P(S)会越小,也就是说,分出的词越多,概率越小(但是也不一定,只是有这个倾向)。词出现的概率计算公式 如下:

由于这个值可能会很小,所以采用log进行计算,防止值向下溢出,即logP(wi ) = log(Freq w ) - logN。

因此基于一元模型的计算公式如下:

P(S) = P(w1,w 2,...,wm ) P(w1)×P(w 2 )×...×P(wm )logP(w1) +logP(w 2 ) +...+ logP(wm )

其中,P(w) 就是这个词出现在语料库中的概率。 ∝是正比符号,因为词的概率小于1,所以取log后是负数。

4.N元模型

一元模型假设词的出现是独立,但是实际上词与词之间出现经常是上下文相关的,为了切分更准确,要考虑词所处的上下文。N元模型使用n个单词组成的序列来衡量切分方案的合理性,所以这里就需要使用条件概率。单词w1后出现w2的概率,根据条件概率的定义:

可以得到:P(w1,w2)= P(w1)P(w2|w1),同理:P(w1,w2,w3)= P(w1,w2)P(w3|w1,w2)

所以有:P(w1,w2,w3)= P(w1)P(w2|w1)P(w3|w1,w2),更加一般的形式如下:

P(S)=P(w1,w2,...,wn)= P(w1)P(w2|w1)P(w3|w1,w2)…P(wn|w1w2…wn-1),这叫做概率的链规则。

• 如果一个词的出现不依赖于它前面出现的词,叫做一元模型(Unigram)。如果简化成一个词的出现仅依赖于它前面出现的一个词,那么就称为二元模型(Bigram)。公式如下:

P(S)=P(w1,w2,...,wn)=P(w1) P(w2|w1) P(w3|w1,w2)…P(wn|w1w2…wn-1)≈P(w1) P(w2|w1) P(w3|w2)…P(wn|wn-1)

• 如果简化成一个词的出现仅依赖于它前面出现的两个词,就称之为三元模型(Trigram)。一般使用较多的就是这几种模型,更高阶的模型使用较少。

二、Jieba分词

1.概述

源码下载地址:http://github.com/fxsjy/jieba

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

• 支持三种分词模式

– 精确模式:将句子最精确的分开,适合文本分析

– 全模式:句子中所有可以成词的词语都扫描出来,速度快,不能解决歧义

– 搜索引擎模式:在精确模式基础上,对长词再次切分,提高召回

• 支持繁体分词、支持自定义字典

2.jieba分词思路

下面的内容参考:https://segmentfault.com/a/1190000004061791http://www.cnblogs.com/zhbzz2007的学习笔记。

jieba分词对已收录词和未收录词都有相应的算法进行处理,其处理的思路很简单,当然,过于简单的算法也是制约其召回率的原因之一。其主要的处理思路如下:

1)加载词典dict.txt,形成前缀词典

2)根据内存的前缀词典,构建该句子的DAG(有向无环图)

3)对于词典中未收录词,使用HMM模型的viterbi算法尝试分词处理

4)已收录词和未收录词全部分词完毕后,使用dp寻找DAG的最大概率路径

5)输出分词结果

3.Jieba分词详解

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模型进行切分。

1)词典加载

jieba分词默认的模型使用了一些语料来做训练集,来源主要有两个,一个是网上能下载到的1998人民日报的切分语料,还有一个msr的切分语料。另一个是作者收集的一些txt小说,用ictclas把他们切分(可能有一定误差)。 然后用python脚本统计词频。

语料库中所有的词语被用来做两件事情:对词语的频率进行统计,作为登录词使用;对单字在词语中的出现位置进行统计,使用BMES模型进行统计,供HMM模型Viterbi算法使用。统计后的结果保存在dict.txt中,摘录其部分结构如下:

上访 212 v
上访事件 3 n
上访信 3 nt
上访户 3 n
上访者 5 n
上证 120 j
上证所 8 nt

其中,第一列是中文词语,第二列是词频,第三列是词性,jieba分词现在的版本除了分词也提供词性标注等其他功能,这个不在本文讨论范围内,可以先忽略第三列。接下来需要将词典加载到内存。

jieba分词为了快速地索引词典以加快分词性能,使用了前缀词典。在旧版本的jieba分词中,jieba采用trie树的数据结构来存储,其实对于python来说,使用trie树显得非常多余,下面将对新老版本的字典加载分别进行分析。

2)trie树

trie树又叫字典树,是一种常见的数据结构,用于在一个字符串列表中进行快速的字符串匹配。其核心思想是将拥有公共前缀的单词归一到一棵树下以减少查询的时间复杂度,其主要缺点是占用内存太大了。

trie树按如下方法构造:trie树的根节点是空,不代表任何含义;其他每个节点只有一个字符,词典中所有词的第一个字的集合作为第一层叶子节点,以字符α开头的单词挂在以α为根节点的子树下,所有以α开头的单词的第二个字的集合作为α子树下的第一层叶子节点,以此类推;从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。一个以and at as cn com构造的trie树如下图:

查找过程如下:

从根结点开始一次搜索;

取得要查找关键词的第一个字母,并根据该字母选择对应的子树并转到该子树继续进行检索;

在相应的子树上,取得要查找关键词的第二个字母,并进一步选择对应的子树进行检索。

迭代过程……

在某个结点处,关键词的所有字母已被取出,则读取附在该结点上的信息,即完成查找。其他操作类似处理。

如查询at,可以找到路径root-a-t的路径,对于单词av,从root找到a后,在a的叶子节点下面不能找到v结点,则查找失败。

trie树的查找时间复杂度为O(k),k = len(s),s为目标串。二叉查找树的查找时间复杂度为O(lgn),比起二叉查找树,trie树的查找和结点数量无关,因此更加适合词汇量大的情况。但是trie树对空间的消耗是很大的,是一个典型的空间换时间的数据结构。

3)前缀词典

在2014年的某次PR中(https://github.com/fxsjy/jieba/pull/187 ),提交者将trie树改成前缀词典,大大地减少了内存的使用,加快了查找的速度。现在jieba分词对于词典的操作,改为了一层word:freq的结构,存于lfreq中,其具体操作如下:

1.对于每个收录词,直接放入lfreq词典中,并且将词频加到ltotal中。

2.对该收录词的所有前缀,如单词'北京大学',则前缀为“北”、“北京”、“北京大”,判断这些词是否已经在lfreq词典中了,如果在不做处理,如果不在就加入到lfreq词典中,并且词频记为0。

# 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,
                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的词典加载。现在来了句子,就可以采取不同的分词模式,对句子进行切分。

4)分词模式

jieba分词有多种模式可供选择。

# encoding=utf-8
import jieba
 
seg_list = jieba.cut("我来到北京清华大学", cut_all=True)
print("Full Mode: " + "/ ".join(seg_list))  # 全模式
 
seg_list = jieba.cut("我来到北京清华大学", cut_all=False)
print("Default Mode: " + "/ ".join(seg_list))  # 精确模式
 
seg_list = jieba.cut("他来到了网易杭研大厦")  # 默认是精确模式
print(", ".join(seg_list))
 
seg_list = jieba.cut_for_search("小明硕士毕业于中国科学院计算所,后在日本京都大学深造")  # 搜索引擎模式
print(", ".join(seg_list))

的结果为:

【全模式】: 我/ 来到/ 北京/ 清华/ 清华大学/ 华大/ 大学
 
【精确模式】: 我/ 来到/ 北京/ 清华大学
 
【新词识别】:他, 来到, 了, 网易, 杭研, 大厦    (此处,“杭研”并没有在词典中,但是也被Viterbi算法识别出来了)
 
【搜索引擎模式】: 小明, 硕士, 毕业, 于, 中国, 科学, 学院, 科学院, 中国科学院, 计算, 计算所, 后, 在, 日本, 京都, 大学, 日本京都大学, 深造

值得详细研究的模式是精确模式,以及其用于识别新词的HMM模型和Viterbi算法。

5)jieba.cut()

在载入词典之后,jieba分词要进行分词操作,在代码中就是核心函数jieba.cut(),代码如下:

 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.
        '''
        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_block = self.__cut_all
        elif HMM:
            cut_block = self.__cut_DAG
        else:
            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

默认的模式,精确分词 + HMM模型开启。第12-23行进行了变量配置。

第24行做的事情是对句子进行中文的切分,把句子切分成一些只包含能处理的字符的块(block),丢弃掉特殊字符,因为一些词典中不包含的字符可能对分词产生影响。24行中re_han默认值为re_han_default,是一个正则表达式,定义如下:

# \u4E00-\u9FD5a-zA-Z0-9+#&\._ : All non-space characters. Will be handled with re_han
re_han_default = re.compile("([\u4E00-\u9FD5a-zA-Z0-9+#&\._]+)", re.U)

可以看到诸如空格、制表符、换行符之类的特殊字符在这个正则表达式被过滤掉。

25-40行使用yield实现了返回结果是一个迭代器,即文档中所说:jieba.cut 以及 jieba.cut_for_search 返回的结构都是一个可迭代的 generator,可以使用 for 循环来获得分词后得到的每一个词语(unicode)

其中,31-40行,如果遇到block是非常规字符,就正则验证一下直接输出这个块作为这个块的分词结果。如标点符号等等,在分词结果中都是单独一个词的形式出现的,就是这十行代码进行的。

关键在28-30行,如果是可分词的block,那么就调用函数cut_block,默认是cut_block = self.__cut_DAG,进行分词。

6)jieba.__cut_DAG()

__cut_DAG的作用是按照DAG,即有向无环图进行切分单词。其代码如下:

def __cut_DAG(self, sentence):
        DAG = self.get_DAG(sentence)
        route = {}
        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:
                        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

对于一个sentence,首先获取到其有向无环图DAG,然后利用dp对该有向无环图进行最大概率路径的计算。
计算出最大概率路径后迭代,如果是登录词,则输出,如果是单字,将其中连在一起的单字找出来,这些可能是未登录词,使用HMM模型进行分词,分词结束之后输出。至此,分词结束。

其中,值得跟进研究的是第2行获取DAG,第4行计算最大概率路径和第20和34行的使用HMM模型进行未登录词的分词。

4.有向无环图:(DAG)

有向无环图,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 如果词频大于0,就将这个位置i追加到以k为key的一个列表中;

   1.2 如果词频等于0,则表明前缀词典存在这个前缀,但是统计词典并没有这个词,继续循环;

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

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

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

frag即fragment,可以看到代码循环切片句子,FREQ即是前缀词典。因为在载入词典的时候已经将word和word的所有前缀加入了词典,所以一旦frag not in FREQ,即可以断定frag和以frag为前缀的词不在词典里,可以跳出循环。由此得到了DAG,下一步就是使用dp动态规划对最大概率路径进行求解。

5.动态规划——最大概率路径计算

有向无环图DAG的每个节点,都是带权的,对于在前缀词典里面的词语,其权重就是它的词频;我们想要求得route = (w1,w2,w3,...,wn),使得 ∑weight(wi) 最大。如果需要使用动态规划求解,需要满足两个条件,重复子问题、最优子结构。

重复子问题:

对于节点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):#遍历0-N-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])
        print "root:",route #这里是自己加的,为了看到每次输出的route,在实际代码中没有

例如:DAG :{0: [0, 1], 1: [1], 2: [2, 3, 5], 3: [3], 4: [4, 5], 5: [5], 6: [6, 7], 7: [7]}

计算过程:

root: {8: (0, 0), 7: (-8.702713881905304, 7)}
root: {8: (0, 0), 6: (-8.682096638586806, 7), 7: (-8.702713881905304, 7)}
root: {8: (0, 0), 5: (-18.81251125649701, 5), 6: (-8.682096638586806, 7), 7: (-8.702713881905304, 7)}
root: {8: (0, 0), 4: (-25.495037477673915, 5), 5: (-18.81251125649701, 5), 6: (-8.682096638586806, 7), 7: (-8.702713881905304, 7)}
root: {3: (-34.70541057789988, 3), 4: (-25.495037477673915, 5), 5: (-18.81251125649701, 5), 6: (-8.682096638586806, 7), 7: (-8.702713881905304, 7), 8: (0, 0)}
root: {2: (-25.495037477673915, 5), 3: (-34.70541057789988, 3), 4: (-25.495037477673915, 5), 5: (-18.81251125649701, 5), 6: (-8.682096638586806, 7), 7: (-8.702713881905304, 7), 8: (0, 0)}
root: {1: (-33.72931380325669, 1), 2: (-25.495037477673915, 5), 3: (-34.70541057789988, 3), 4: (-25.495037477673915, 5), 5: (-18.81251125649701, 5), 6: (-8.682096638586806, 7), 7: (-8.702713881905304, 7), 8: (0, 0)}
root: {0: (-34.76895126093703, 1), 1: (-33.72931380325669, 1), 2: (-25.495037477673915, 5), 3: (-34.70541057789988, 3), 4: (-25.495037477673915, 5), 5: (-18.81251125649701, 5), 6: (-8.682096638586806, 7), 7: (-8.702713881905304, 7), 8: (0, 0)}

三、马尔科夫模型

对于未登录词,需要使用隐马尔可夫模型进行分词。这里先介绍马尔科夫模型。

1.马尔科夫模型

• 每个状态只依赖之前有限个状态,对于N阶马尔科夫,依赖之前n个状态。1阶马尔科夫仅仅依赖前一个状态,即2元模型。

p(w1,w2,w3,w4…wn) = p(w1)p(w2|w1)p(w3|w1,w2)……p(wn|w1,w2,……,wn-1) =p(w1) p(w2|w1) p(w3|w2)……p(wn|wn-1)

• 参数

  – 状态,由数字表示,假设共有M个(有多少字就有多少个状态)

  – 初始概率,由πk表示

                   

  – 状态转移概率,由表示ak,l表示,词k变换到词l的概率

                

这些参数值用统计的方法来获得,即最大似然估计法。

2.最大似然法

最大似然估计,就是利用已知的样本结果,反推最有可能(最大概率)导致这样结果的参数值,在这里就是根据样本的情况,近似地得到概率值,所以下面的等号严格来说是约等号。

– 状态转移概率ak,l,P(St+1=l|St=k)=l紧跟k出现的次数/k出现的总次数

– 初始概率πk,P(S1=k)=k作为序列开始的次数/观测序列总数

马尔科夫模型是对一个序列数据建模,但有时需要对两个序列数据建模,所以需要隐马尔可夫模型。例如如下几个场景:

• 机器翻译:源语言序列 <-> 目标语言序列

• 语音识别:语音信号序列 <-> 文字序列

• 词性标注:文字序列 <-> 词性序列

       – 写/一个/程序

      – Verb/Num/Noun

3.隐马尔科夫模型HMM

1)观察序列和隐藏序列

通常其中一个序列是观察到的,背后隐藏的序列是要寻找的,把观察到的序列表示为O,隐藏的序列表示为S。观察序列O中的数据通常是由对应的隐藏序列数据决定的。隐藏序列数据间相互依赖,通常构成了马尔科夫序列。例如,语音识别中声波信号每段信号都是相互独立的,由对应的文字决定,对应的文字序列中相邻的字相互依赖,构成Markov链。观察和隐藏序列共同构成隐马模型。

• O(o1o2 … oT):观测序列,ot只依赖于st

• S(s1s2 … sT):状态序列(隐藏序列),S是Markov序列,假设1阶Markov序列,则st+1只依赖于st

2)HMM参数

– 状态s,由数字表示,假设共有M个

– 观测o,由数字表示,假设共有N个

– 初始概率,由πk表示

– 状态转移概率,由ak,l表示

– 发射概率,由bk(u) 表示

 

3)HMM生成过程

生成第一个状态,然后依次由当前状态生成下一个状态,最后每个状态发射出一个观察值。

四、jieba中的HMM详解

基于前缀词典和动态规划方法可以实现分词,但是如果没有前缀词典或者有些词不在前缀词典中,jieba分词一样可以分词,基于汉字成词能力的HMM模型识别未登录词。利用HMM模型进行分词,主要是将分词问题视为一个序列标注(sequence labeling)问题,其中,句子为观测序列,分词结果为状态序列。首先通过语料训练出HMM相关的模型,然后利用Viterbi算法进行求解,最终得到最优的状态序列,然后再根据状态序列,输出分词结果。

1.序列标注

序列标注,就是将输入句子和分词结果当作两个序列,句子为观测序列,分词结果为状态序列,当完成状态序列的标注,也就得到了分词结果。

以“去北京大学玩”为例,“去北京大学玩”的分词结果是“去 / 北京大学 / 玩”。对于分词状态,由于jieba分词中使用的是4-tag,因此我们以4-tag进行计算。4-tag,也就是每个字处在词语中的4种可能状态,B、M、E、S,分别表示Begin(这个字处于词的开始位置)、Middle(这个字处于词的中间位置)、End(这个字处于词的结束位置)、Single(这个字是单字成词)。具体如下图所示,“去”和“玩”都是单字成词,因此状态就是S,“北京大学”是多字组合成的词,因此“北”、“京”、“大”、“学”分别位于“北京大学”中的B、M、M、E。

https://images2015.cnblogs.com/blog/668850/201611/668850-20161118123105545-1599810853.png

2.HMM模型作的两个基本假设

1.齐次马尔科夫性假设,即假设隐藏的马尔科夫链在任意时刻t的状态只依赖于其前一时刻的状态,与其它时刻的状态及观测无关,也与时刻t无关;

P(states[t] | states[t-1],observed[t-1],...,states[1],observed[1]) = P(states[t] | states[t-1]) t = 1,2,...,T

2.观测独立性假设,即假设任意时刻的观测只依赖于该时刻的马尔科夫链的状态,与其它观测和状态无关;

P(observed[t] | states[T],observed[T],...,states[1],observed[1]) = P(observed[t] | states[t]) t = 1,2,...,T

3.HMM模型三个基本问题

1.概率计算问题,给定模型 λ=(A,B,π)和观测序列 O=(o1,o2,...,oT),怎样计算在模型λ下观测序列O出现的概率 P(O|λ),也就是前向(Forward-backward)算法;

2.学习问题,已知观测序列 O=(o1,o2,...,oT)O=(o1,o2,...,oT) ,估计模型 λ=(A,B,π)λ=(A,B,π) ,使得在该模型下观测序列的概率 P(O|λ)P(O|λ) 尽可能的大,即用极大似然估计的方法估计参数;

3.预测问题,也称为解码问题,已知模型 λ=(A,B,π)和观测序列 O=(o1,o2,...,oT),求对给定观测序列条件概率 P(S|O)P(S|O) 最大的状态序列 I=(s1,s2,...,sT),即给定观测序列,求最有可能的对应的状态序列;

其中,jieba分词主要涉及第三个问题,也即预测问题。

这里仍然以“去北京大学玩”为例,那么“去北京大学玩”就是观测序列。而“去北京大学玩”对应的“SBMMES”则是隐藏状态序列,我们将会注意到B后面只能接(M或者E),不可能接(B或者S);而M后面也只能接(M或者E),不可能接(B或者S)。

状态初始概率表示,每个词初始状态的概率;jieba分词训练出的状态初始概率模型如下所示。

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

其中的概率值都是取对数之后的结果(可以让概率相乘转变为概率相加),其中-3.14e+100代表负无穷,对应的概率值就是0。这个概率表说明一个词中的第一个字属于{B、M、E、S}这四种状态的概率,如下可以看出,E和M的概率都是0,这也和实际相符合:开头的第一个字只可能是每个词的首字(B),或者单字成词(S)。这部分对应jieba/finaseg/ prob_start.py,具体可以进入源码查看。

状态转移概率是马尔科夫链中很重要的一个知识点,一阶的马尔科夫链最大的特点就是当前时刻T = i的状态states(i),只和T = i时刻之前的n个状态有关,即{states(i-1),states(i-2),...,states(i-n)}。再看jieba中的状态转移概率,其实就是一个嵌套的词典,数值是概率值求对数后的值,如下所示,

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}}

P['B']['E']代表的含义就是从状态B转移到状态E的概率,由P['B']['E'] = -0.58971497368-54513,表示当前状态是B,下一个状态是E的概率对数是-0.5897149736854513,对应的概率值是0.6,相应的,当前状态是B,下一个状态是M的概率是0.4,说明当我们处于一个词的开头时,下一个字是结尾的概率要远高于下一个字是中间字的概率,符合我们的直觉,因为二个字的词比多个字的词更常见。这部分对应jieba/finaseg/prob_trans.py,具体可以查看源码。

状态发射概率,根据HMM模型中观测独立性假设,发射概率,即观测值只取决于当前状态值,也就如下所示,

P(observed[i],states[j]) = P(states[j]) * P(observed[i] | states[j])

其中,P(observed[i] | states[j])就是从状态发射概率中获得的。

P={'B': {'一': -3.6544978750449433,
       '丁': -8.125041941842026,
       '七': -7.817392401429855,
...
'S': {':': -15.828865681131282,
  '一': -4.92368982120877,
  '丁': -9.024528361347633,
...

P['B']['一']代表的含义就是状态处于'B',而观测的字是‘一’的概率对数值为P['B']['一'] = -3.6544978750449433。这部分对应jieba/finaseg/prob_emit.py,具体可以查看源码。

有了初始概率、发射概率和转移概率后,HMM的模型也就准备完毕,现在就需要根据输入的句子,计算出背后的隐藏序列,并选取出概率最大的隐藏序列作为分词的结果。(词性一旦标注完,就知道BMES的具体位置,只要在S和E的位置进行切分就可以得到切分结果,这很容易理解)假设输入13个汉字,每个汉字背后有10个不同的状态,为了得到最大概率的序列,朴素的做法就是进行10的13次方计算,从中选出概率最大的那个。这个计算量是非常巨大的,因此jieba使用维特比算法来求解概率最大的路径。

五、viterbi算法

Viterbi算法实际上是用动态规划求解HMM模型预测问题,即用动态规划求概率路径最大(最优路径)。一条路径对应着一个状态序列。如图所示,x代表着状态序列。

viterbi算法的核心思想是:

1.如果概率最大的路径P经过某个点,假设是X22,那么这条路径上从起始点S到X22的这段子路径Q,一定是S到X22之间最短的路径。否则,用另外一条路径R代替Q,就构成了另外一条比P概率更大的路径,这是矛盾的。(即最大最是最大,没有其他更大的,听起来感觉没什么用的样子)

2.从S到E的路径必定经过第i时刻的某个状态,假定第i时刻有k个状态,那么如果记录了从S到第i个状态的所有k个节点的最短路径,最终的最短路径必经过其中的一条。这样,在任何时刻,只要考虑非常有限条最短路径即可。

3.综合上面两点,假定从状态i到状态i+1,从S到状态i上各个节点的最短路径已经找到,并且记录在节点上,那么计算从起点S到第i+1状态的某个节点Xi+1的最短路径时,只要考虑S到前一个状态i所有的k个节点的最短路径,以及从这个k个节点到Xi+1的距离即可。

这样时间复杂度不超过:O(N*D^2),N代表总共几个字,D代表状态最多的那个字的状态数目。

jieba分词会首先调用函数cut(sentence),cut函数会先将输入句子进行解码,然后调用__cut函数进行处理。__cut函数就是jieba分词中实现HMM模型分词的主函数。__cut函数会首先调用viterbi算法,求出输入句子的隐藏状态,然后基于隐藏状态进行分词。

def __cut(sentence):
    global emit_P
    # 通过viterbi算法求出隐藏状态序列
    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:]

jieba分词实现Viterbi算法是在viterbi(obs, states, start_p, trans_p, emit_p)函数中实现。viterbi函数会先计算各个初始状态的对数概率值,然后递推计算,每时刻某状态的对数概率值取决于上一时刻的对数概率值、上一时刻的状态到这一时刻的状态的转移概率、这一时刻状态转移到当前的字的发射概率三部分组成。

def viterbi(obs, states, start_p, trans_p, emit_p):
    V = [{}]  # tabular
    path = {}
    # 时刻t = 0,初始状态
    for y in states:  # init
        V[0][y] = start_p[y] + emit_p[y].get(obs[0], MIN_FLOAT)
        path[y] = [y]
    # 时刻t = 1,...,len(obs) - 1
    for t in xrange(1, len(obs)):
        V.append({})
        newpath = {}
        # 当前时刻所处的各种可能的状态
        for y in states:
            # 获取发射概率对数
            em_p = emit_p[y].get(obs[t], MIN_FLOAT)
            # 分别获取上一时刻的状态的概率对数,该状态到本时刻的状态的转移概率对数,本时刻的状态的发射概率对数
            # 其中,PrevStatus[y]是当前时刻的状态所对应上一时刻可能的状态
            (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
    # 最后一个时刻
    (prob, state) = max((V[len(obs) - 1][y], y) for y in 'ES')
    # 返回最大概率对数和最优路径
    return (prob, path[state])

输出分词结果

Viterbi算法得到状态序列,根据状态序列得到分词结果。其中状态以B开头,离它最近的以E结尾的一个子状态序列或者单独为S的子状态序列,就是一个分词。以去北京大学玩的隐藏状态序列”SBMMES“为例,则分词为”S / BMME / S“,对应观测序列,也就是 / 北京大学 /

六、实践

1.中文分词和webserver

下载jieba和web模块。模拟用户在浏览器输入中文句子,浏览器返回一个切分结果。

#encoding=utf-8
import web
import sys
sys.path.append("./")//这里需要把jieba里面的jieba模块加载进去,所以要注意路径问题。
import jieba
import jieba.posseg
import jieba.analyse

urls = (
    '/', 'index',默认的方式
    '/test', 'test',
)

app = web.application(urls, globals())
class index:
    def GET(self):
        params = web.input()
        context = params.get('context', '')//自定义输入http://192.168.101.10:9999/? context=语句

        seg_list = jieba.cut(context)
        result = ", ".join(seg_list)
        print("=====>", result)打印结果
        return result

class test: //只是用来测试用的,http://192.168.101.10:9999/test返回都是222
    def GET(self):
        print web.input()
        return '222'
if __name__ == "__main__":
    app.run()

[root@master segment]# python web_seg.py 9999

http://192.168.101.10:9998/?context=加入购物车,结果为乱码,需要在浏览器修改编码方式,在选项卡里面工具,修改为自动检测或者utf-8等。

结果:加入, 购物车

2.jieba和mapreduce结合

如果输入的句子非常庞大,就需要将jieba分词和MapReduce相结合起来,否则很难在一台机子上搞定。现在假设有如下的数据,其中第一列代表该句子的id,第二列代表句子。我们的目的是将句子进行切分,当用户的输入一旦包含切分的词,就会返回对应的id值,即查询到该条记录。

8920791333    天路MV-韩红

8920845333    初音未来PV【世界第一公主殿下】

8920849333    曼莉(dj电音舞曲)

8920888333    《我是歌手》第四场无歌单惊呆众歌手!

以最后一条为例,加入切分成:我、是、歌手、第四场、无歌单、惊呆、众歌手,当用户输入“歌手”,就返回8920888333记录进行展示。

1)run.sh

这里使用到hadoop-streaming,可以参考大数据基础学习-2.Hadoop1.0、MapReduce中的内容进行学习。

#HADOOP_CMD="/usr/local/src/hadoop-1.2.1/bin/hadoop"
#STREAM_JAR_PATH="/usr/local/src/hadoop-1.2.1/contrib/streaming/hadoop-streaming-1.2.1.jar"

HADOOP_CMD="/usr/local/src/hadoop-2.6.0-cdh5.7.0/bin/hadoop"
STREAM_JAR_PATH="/usr/local/src/hadoop-2.6.0-cdh5.7.0/share/hadoop/tools/lib/hadoop-streaming-2.6.0-cdh5.7.0.jar"

INPUT_FILE_PATH_1="/music_meta.txt.small"
OUTPUT_Z_PATH="/output_z_fenci"
OUTPUT_D_PATH="/output_d_fenci"

$HADOOP_CMD fs -rmr $OUTPUT_Z_PATH
$HADOOP_CMD fs -rmr $OUTPUT_D_PATH

# Step 1.
$HADOOP_CMD jar $STREAM_JAR_PATH \
    -input $INPUT_FILE_PATH_1 \
    -output $OUTPUT_Z_PATH \
    -mapper "python map.py mapper_func" \
    -jobconf "mapred.reduce.tasks=0" \
    -jobconf  "mapred.job.name=jieba_fenci_demo" \
    -file "./jieba.tar.gz" \
    -file "./map.py"

# Step 2.
$HADOOP_CMD jar $STREAM_JAR_PATH \
    -input $OUTPUT_Z_PATH \
    -output $OUTPUT_D_PATH \
    -mapper "python map_inverted.py mapper_func" \
    -reducer "python red_inverted.py reducer_func" \
    -jobconf "mapred.reduce.tasks=2" \
    -jobconf  "mapred.job.name=jieba_fenci" \
    -file "./map_inverted.py" \
    -file "./red_inverted.py"

2)map.py

#!/usr/bin/python
import os
import sys
os.system('tar xvzf jieba.tar.gz > /dev/null')//先把jieba中的jieba模块的压缩包进行解压
reload(sys)
sys.setdefaultencoding('utf-8')
sys.path.append("./")//将jieba模块加载进来

import jieba
import jieba.posseg
import jieba.analyse

def mapper_func():
    for line in sys.stdin: //music_meta.txt.small读进来
        ss = line.strip().split('\t')
        if len(ss) != 2:
            continue
        music_id = ss[0].strip()
        music_name = ss[1].strip()

        tmp_list = []
        for x, w in jieba.analyse.extract_tags(music_name, withWeight=True): //x为词,w为词的if-idf值
            tmp_list.append((x, float(w))) //单词和权重
        final_token_score_list = sorted(tmp_list, key=lambda x: x[1], reverse=True) //倒序排序, 结尾再添加[:3]可取出前top3个
        print '\t'.join([music_id, music_name, '^A'.join(['^B'.join([t_w[0], str(t_w[1])]) for t_w in final_token_score_list])])


if __name__ == "__main__":
    module = sys.modules[__name__]
    func = getattr(module, sys.argv[1])
    args = None
    if len(sys.argv) > 1:
        args = sys.argv[2:]
    func(*args)

结果如下,形成了一个正排表:

9056597333	哈利波特电影主题曲 - 钢琴版	哈利波2.98869187572主题曲2.95530902757钢琴2.06380768858电影1.6644699323
9056759333	李玉刚《日日红上海》	李玉刚3.98492250097日日2.99114132227上海1.52787922045
9056887333	佛教音乐	佛教3.42225118394音乐3.31483455685
9057033333	美久广场舞2013_神曲来袭_摇一摇.zero	美久1.70782392899zero1.70782392899摇一摇1.7078239289920131.70782392899神曲1.46615592746来袭1.35454814917广场1.01644142957
9057091333	爱在女儿乡	女儿6.01572470622

得到正排表后,就需要将数据进行处理,形成倒排表,这是搜索的基础。

3)map_inverted.py

#!/usr/bin/python
import os
import sys

def mapper_func():
    for line in sys.stdin:
        ss = line.strip().split('\t')
        if len(ss) != 3:
            continue
        music_id = ss[0].strip()
        music_name = ss[1].strip()
        music_fealist = ss[2].strip()

        for fea in music_fealist.split('^A'):
            token, weight = fea.strip().split('^B')
            print '\t'.join([token, music_name, weight])

if __name__ == "__main__":
    module = sys.modules[__name__]
    func = getattr(module, sys.argv[1])
    args = None
    if len(sys.argv) > 1:
        args = sys.argv[2:]
    func(*args)

4)red_inverted.py

#!/usr/bin/python

import os
import sys

def reducer_func():
    cur_token = None
    m_list = []
    for line in sys.stdin:
        ss = line.strip().split('\t')
        if len(ss) != 3:
            continue
        token = ss[0].strip()
        name = ss[1].strip()
        weight = float(ss[2].strip())

        if cur_token == None:
            cur_token = token
        if cur_token != token:
            final_list = sorted(m_list, key=lambda x: x[1], reverse=True)
            print '\t'.join([cur_token, '^A'.join(['^B'.join([name_weight[0], str(name_weight[1])]) for name_weight in final_list])])
            cur_token = token
            m_list = []
        m_list.append((name, weight))
    final_list = sorted(m_list, key=lambda x: x[1], reverse=True)
    print '\t'.join([cur_token, '^A'.join(['^B'.join([name_weight[0], str(name_weight[1])]) for name_weight in final_list])])

if __name__ == "__main__":
    module = sys.modules[__name__]
    func = getattr(module, sys.argv[1])
    args = None
    if len(sys.argv) > 1:
        args = sys.argv[2:]
    func(*args)
9993740333	张宇《不要来找我》 	张宇5.97738375145 不要 2.44647159005
9993857333	韩磊《爱的箴言》	韩磊 5.97738375145 箴言 5.1871585637
9993869333	茜拉《If I Ain`t Got You》	Got 5.97738375145 Ain 5.97738375145

 

  • 2
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值