美文网首页nltk机器学习
第3章 中文分词技术

第3章 中文分词技术

作者: 又双叒叕苟了一天 | 来源:发表于2018-07-12 22:31 被阅读54次

    本章要点:

    1. 中文分词的概念与分类
    2. 常用分词的技术介绍
    3. 开源中文分词工具-Jieba简介
    4. 实战分词之高频词提取

    中文分词简介

    把句子分成有意义的单词,英文以空格分割而汉语中则不然,有时会存在歧义,例如:

    • 结婚/的/和/尚未/结婚/的
    • 结婚/的/和尚/未结婚/的

    中文自动分词被提出以来,提出了很多方法:

    • 规则分词(人为设立词库,处理不了新词)
    • 统计分词(随统计机器学习兴起而兴起,以来语料的质量)
    • 混合分词(上面两种的混合)

    规则分词

    将语句的每个字符串与词表的词进行逐一匹配,找到则切分,否则不予切分。

    按照匹配切分的方式,主要有:

    • 正向最大匹配法
    • 逆向醉打匹配法
    • 双向最大匹配法

    正向最大匹配法(Maximum Match Method MM)

    算法描述

    1. 从左向右取待切分语句的m个字符作为匹配字段,m为词典中最长词条的字符数。
    2. 根据词典进行匹配,匹配到则将其切分出去,未匹配到则去掉匹配字段的最后一个字,剩下的再和词典匹配,直到匹配成功。
    3. 重复以上步骤直到切分出所有词。

    例如:匹配“南京市长江大桥“,词典最大长度的词为5,包含”南京市长“,”长江大桥“

    1. 首先,取出5个词“南京市长江”未匹配成功。
    2. 然后去掉最后一个字,拿“南京市长”去匹配,匹配成功。
    3. 剩余的“江大桥”一样匹配,最后匹配出“江”,将其切分出去。
    4. 最后,再且分出“大桥”。

    所得的切分为:南京市长/江/大桥,可见这种方法并不好。

    逆向最大匹配法(Reverse Maximum Match Method RMM)

    MM的反向操作,从最后开始匹配。

    实际操作时,先生成逆序文档和逆序词典,然后用MM处理。

    由于汉语中偏正结构较多,从后向前匹配可以适当提高精度。

    统计结果表明,正向的错误率为1/169,反向为1/245。

    # 逆向最大匹配
    class IMM(object):
        def __init__(self, dic_path):
            self.dictionary = set()
            self.maximum = 0
            # 读取词典
            with open(dic_path, 'r', encoding='utf8') as f:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    self.dictionary.add(line)
                    if len(line) > self.maximum:
                        self.maximum = len(line)
    
        def cut(self, text):
            result = []
            index = len(text)
            while index > 0:
                word = None
                for size in range(self.maximum, 0, -1):#size从词典最长长度的词开始递减到1
                    if index - size < 0:#size比剩余文本长度还长,直接下一轮,让size-1再看看
                        continue
                    piece = text[(index - size):index]#从最后截取size个字
                    if piece in self.dictionary:#如果词存在词典中
                        word = piece
                        result.append(word)#将其加入结果的列表
                        index -= size#index向前减size个,开始下个分割
                        break
                if word is None:#如果循环完了都没找到,index-1,就不去管它了
                    index -= 1
            return result[::-1]#返回一个逆序列表
    
    
    def main():
        text = "南京市长江大桥"
    
        tokenizer = IMM('./data/imm_dic.utf8')
        print(tokenizer.cut(text))
    
    main()
    

    双向最大匹配法(Bi-direction Matching method)

    采用MM和RMM,当结果不同时,才有切分数较小的那个。

    例如:

    南京市/长江/大桥

    南京市/长江大桥

    我们采用第二种。

    统计分词

    主要思想是统计相连的字在不同的文本中出现的次数。次数越多,就证明这几个相连的字可能是一个词。因此我们通过字与字相邻的频率来反应成词的可靠度。统计语料库中这些组合的频率,当高于一个阈值时,我们可认为这些字构成一个词语。

    基于统计的分词,一般要做以下两步操作:

    1. 建立统计语言模型
    2. 对句子进行单词划分,然后对划分结果进行概率计算,获得概率最大的分词方式。

    用到的统计学方法:

    • 隐含马儿可夫(HMM)
    • 条件随机场(CRF)

    语言模型

    为长度为m的字符串确定其概率分布P(w_1,w_2,\cdots,w_m)w_1w_m依次表示文本中的各个词语。一般采用链式法则计算其概率:
    P(w_1,w_2,\cdots,w_m)=p(w_1)P(w_2|w_1)P(w_3|w_1,w_2) \cdots P(w_i|w_1,w_2,\cdots,w_{i-1})\cdots P(w_m|w_1,w_2,\cdots,w_{m-1})
    上式可以看出,句子越长,越难计算。为了解决这个问题,有人提出n元模型(n-gram model)来降低计算难度。

    所谓n元模型就是只算前n个单词对下一个产生的影响:
    P(w_i|w_1,w_2,\cdots,w_{i-1})\approx P(w_i|w_{i-(n-1)},\cdots,w_{i-1})
    当n=1时,各个单词被当成独立的,丢失了语序的信息

    n越大,语序信息保留越多,但是计算成本就越高。

    一般使用频率计数的比例来计算n元条件概率:
    P(w_i|w_{i-(n-1)},\cdots,w_{i-1})=\frac{count(w_{i-(n-1)},\cdots,w_{i-1},w_i)}{count(w_{i-(n-1)},\cdots,w_{i-1},w_{i-1})}
    count(w_{i-(n-1)},\cdots,w_{i-1},w_{i-1})表示w_{i-(n-1)},\cdots,w_{i-1},w_{i-1}在语料库出现的次数。

    但是count(w_{i-(n-1)},\cdots,w_{i-1},w_{i-1})可能会为0,而造成分子分母为0的情况,所以一般n元模型需要配合响应平滑算法来解决该问题,如:拉普拉斯平滑算法。

    隐含马尔科夫模型(HMM)

    HMM通过将分词作为字在字串中的序列标注任务来实现,例如:

    中文/分词/是/文本处理/不可或缺/的/一步!

    标注成

    中/B文/E分/B词/E是/S文/B本/M处/M理/E不/B可/M或/M缺/E的/S一/B步/E!/S

    B表示词首,M表示词中,E表示词尾,S单独成词

    这些标签用o表示,每个字用\lambda表示

    那么理想的输出为:
    \mathrm{max}=\mathrm{max}P(o_1o_2\cdots o_n|\lambda_1\lambda_2\cdots\lambda_n)
    因为这个不太好算这里,所以引入观测独立性假设,即每个字的输出仅仅与当前字有关,则得到下式:
    P(o_1o_2\cdots o_n|\lambda_1\lambda_2\cdots\lambda_n)=P(o_1|\lambda_1)P(o_2|\lambda_2)\cdots P(o_n|\lambda_n)
    虽然P(o_k|\lambda_k)要好算,但是完全不考虑上下文,会出现不合理情况,例如:B后面只能接M或E,但是可能会出现BBB的情况。

    HMM就用来解决该问题。在上面我们要求的是P(o|\lambda),通过贝叶斯公式能够得到:
    P(o|\lambda)=\frac{P(o,\lambda)}{P(\lambda)}=\frac{P(\lambda|o)P(o)}{P(\lambda)}
    因为P(\lambda)为常数,所以最大化P(o|\lambda)等价于最大化P(\lambda|o)P(o)

    根据马尔科夫假设,得到:
    P(\lambda|o)=P(\lambda_1|o_1)P(\lambda_2|o_2)\cdots P(\lambda_n|o_n)(式1)
    同时,对P(o):
    P(o)=P(o_1)P(o_2|o_1)P(o_3|o_1,o_2)\cdots P(o_n|o_1,o_2,\cdots,o_{n-1})
    这里HMM做了另一个假设(齐次马尔可夫假设),每个输出仅仅与上一个输出有关,那么:
    P(o)=P(o_1)P(o_2|o_1)P(o_3|o_2)\cdots P(o_n|o_{n-1})(式2)
    将(式1)和(式2)称一下,去掉P(o_1)这项(实际代码实现时还是加了这项的),得到下面这个近似的式子:
    P(\lambda|o)P(o)\sim P(\lambda_1|o_1)P(o_2|o_1)P(\lambda_2|o_2)P(o_3|o_2)\cdots P(o_n|o_{n-1})P(\lambda_n|o_n)
    P(\lambda_k|o_k)称为发射概率,P(o_k|o_{k-1})称为转移概率。通过设置某些P(o_k|o_{k-1})=0可以排除类似BBB、EM的不合理组合。

    事实上,上式的假设就是一个二元语言模型,当马尔可夫假设改为每个输出和前两个有关时,就变成了三元语言模型。大多数实际情况都用的二元,因为计算量小很多。

    在HMM中,求解\mathrm{max}P(\lambda|o)P(o)的常用方法是Veterbi算法:因为由概率转移函数可以看出,通过o_i最优的时候,通过o_{i-1}肯定也最优,所以到第i-1个节点的路径的全算出来,取最优的情况。

    下面是python实现的HMM

    class HMM(object):
        def __init__(self):
            import os
    
            # 主要是用于存取算法中间结果,不用每次都训练模型
            self.model_file = './data/hmm_model.pkl'
    
            # 状态值集合
            self.state_list = ['B', 'M', 'E', 'S']
            # 参数加载,用于判断是否需要重新加载model_file
            self.load_para = False
    
        # 用于加载已计算的中间结果,当需要重新训练时,需初始化清空结果
        def try_load_model(self, trained):
            if trained:
                import pickle
                with open(self.model_file, 'rb') as f:
                    self.A_dic = pickle.load(f)
                    self.B_dic = pickle.load(f)
                    self.Pi_dic = pickle.load(f)
                    self.load_para = True
    
            else:
                # 状态转移概率(状态->状态的条件概率)
                self.A_dic = {}
                # 发射概率(状态->词语的条件概率)
                self.B_dic = {}
                # 状态的初始概率
                self.Pi_dic = {}
                self.load_para = False
    
        # 计算转移概率、发射概率以及初始概率
        def train(self, path):
    
            # 重置几个概率矩阵
            self.try_load_model(False)
    
            # 统计状态出现次数,求p(o)
            Count_dic = {}
    
            # 初始化参数
            def init_parameters():
                for state in self.state_list:
                    self.A_dic[state] = {s: 0.0 for s in self.state_list}
                    self.Pi_dic[state] = 0.0
                    self.B_dic[state] = {}
    
                    Count_dic[state] = 0
    
            def makeLabel(text):
                out_text = []
                if len(text) == 1:
                    out_text.append('S')
                else:
                    out_text += ['B'] + ['M'] * (len(text) - 2) + ['E']
    
                return out_text
    
            init_parameters()
            line_num = -1
            # 观察者集合,主要是字以及标点等
            words = set()
            with open(path, encoding='utf8') as f:
                for line in f:
                    line_num += 1
    
                    line = line.strip()
                    if not line:
                        continue
    
                    word_list = [i for i in line if i != ' ']
                    words |= set(word_list)  # 更新字的集合
    
                    linelist = line.split()
    
                    line_state = []
                    for w in linelist:
                        line_state.extend(makeLabel(w))
                    
                    assert len(word_list) == len(line_state)
    
                    for k, v in enumerate(line_state):
                        Count_dic[v] += 1
                        if k == 0:
                            self.Pi_dic[v] += 1  # 每个句子的第一个字的状态,用于计算初始状态概率
                        else:
                            self.A_dic[line_state[k - 1]][v] += 1  # 计算转移概率
                            self.B_dic[line_state[k]][word_list[k]] = \
                                self.B_dic[line_state[k]].get(word_list[k], 0) + 1.0  # 计算发射概率
            
            self.Pi_dic = {k: v * 1.0 / line_num for k, v in self.Pi_dic.items()}
            self.A_dic = {k: {k1: v1 / Count_dic[k] for k1, v1 in v.items()}
                          for k, v in self.A_dic.items()}
            #加1平滑
            self.B_dic = {k: {k1: (v1 + 1) / Count_dic[k] for k1, v1 in v.items()}
                          for k, v in self.B_dic.items()}
            #序列化
            import pickle
            with open(self.model_file, 'wb') as f:
                pickle.dump(self.A_dic, f)
                pickle.dump(self.B_dic, f)
                pickle.dump(self.Pi_dic, f)
    
            return self
    
        def viterbi(self, text, states, start_p, trans_p, emit_p):
            V = [{}]
            path = {}
            for y in states:
                V[0][y] = start_p[y] * emit_p[y].get(text[0], 0)
                path[y] = [y]
            for t in range(1, len(text)):
                V.append({})
                newpath = {}
                
                #检验训练的发射概率矩阵中是否有该字
                neverSeen = text[t] not in emit_p['S'].keys() and \
                    text[t] not in emit_p['M'].keys() and \
                    text[t] not in emit_p['E'].keys() and \
                    text[t] not in emit_p['B'].keys()
                for y in states:
                    emitP = emit_p[y].get(text[t], 0) if not neverSeen else 1.0 #设置未知字单独成词
                    (prob, state) = max(
                        [(V[t - 1][y0] * trans_p[y0].get(y, 0) *
                          emitP, y0)
                         for y0 in states if V[t - 1][y0] > 0])
                    V[t][y] = prob
                    newpath[y] = path[state] + [y]
                path = newpath
                
            if emit_p['M'].get(text[-1], 0)> emit_p['S'].get(text[-1], 0):
                (prob, state) = max([(V[len(text) - 1][y], y) for y in ('E','M')])
            else:
                (prob, state) = max([(V[len(text) - 1][y], y) for y in states])
            
            return (prob, path[state])
    
        def cut(self, text):
            import os
            if not self.load_para:
                self.try_load_model(os.path.exists(self.model_file))
            prob, pos_list = self.viterbi(text, self.state_list, self.Pi_dic, self.A_dic, self.B_dic)      
            begin, next = 0, 0    
            for i, char in enumerate(text):
                pos = pos_list[i]
                if pos == 'B':
                    begin = i
                elif pos == 'E':
                    yield text[begin: i+1]
                    next = i+1
                elif pos == 'S':
                    yield char
                    next = i+1
            if next < len(text):
                yield text[next:]
    hmm = HMM()
    hmm.train('./data/trainCorpus.txt_utf8')
    
    text = '这是一个非常棒的方案!'
    res = hmm.cut(text)
    print(text)
    print(str(list(res)))
    #['这是', '一个', '非常', '棒', '的', '方案', '!']
    

    其他统计分词算法

    条件随机场(CRF)也是一种基于马尔可夫思想的统计模型。在HMM中,当前状态只与前一个状态有关这是不太合理的。在CRF中,当前状态还与后面的状态有关。

    混合分词

    目前,上面的几种分词又或者是深度学习的分词方法,最后效果都差不多。往往都是几种在一起辅助使用的。

    中文分词工具-Jieba

    官网:中文分词工具-Jieba

    结合了基于规则和基于统计的两类方法。

    提供了三种分词模式:

    1. 精确模式:最精准的切开,适合文本分析。
    2. 全模式:所有可以成词的词语都扫描出来,速度快,但不能解决歧义问题。
    3. 搜索引擎模式:在精准模式下,将长词再次切分,提高召回率,适合用于搜索引擎分词。

    三种模式对比

    import jieba
    sent = '中文分词是文本处理不可或缺的一步!'
    seg_list = jieba.cut(sent, cut_all=True)
    print('全模式:','/'.join(seg_list))
    #全模式: 中文/分词/是/文本/文本处理/本处/处理/不可/不可或缺/或缺/的/一步//
    seg_list = jieba.cut(sent, cut_all=False)
    print('精确模式:','/'.join(seg_list))
    #精确模式: 中文/分词/是/文本处理/不可或缺/的/一步/!
    seg_list = jieba.cut(sent)
    print('默认精确模式:','/'.join(seg_list))
    #默认精确模式: 中文/分词/是/文本处理/不可或缺/的/一步/!
    seg_list = jieba.cut_for_search(sent)
    print('搜索引擎模式:','/'.join(seg_list))
    #搜索引擎模式: 中文/分词/是/文本/本处/处理/文本处理/不可/或缺/不可或缺/的/一步/!
    

    实战之高频词提取

    # jieba分词示例
    def get_content(path):
        with open(path, 'r', encoding='gbk', errors='ignore') as f:
            content = ''
            for l in f:
                l = l.strip()
                content += l
            #print(content)
            return content
    
    
    def get_TF(words, topK=10):
        tf_dic = {}
        for w in words:
            tf_dic[w] = tf_dic.get(w, 0) + 1
        return sorted(tf_dic.items(), key=lambda x: x[1], reverse=True)[:topK]
    
    #将停用词载入进一个列表
    def stop_words(path):
        with open(path,encoding='utf-8') as f:
            return [l.strip() for l in f]
    
    
    stop_words('./data/stop_words.utf8')
    
    
    # 分词
    def main():
        import glob
        import random
        import jieba
    
        files = glob.glob('./data/news/C000013/*.txt')
        #print(files)#glob可以返回匹配该路径的所有文件的路径
        corpus = [get_content(x) for x in files[:5]]#取前五个文件
        #print(corpus)
    
        sample_inx = random.randint(0, len(corpus))#中间随便选取一个
        #sample_inx = 3
    
        #import jieba.posseg as psg
    
        split_words = [x for x in jieba.cut(corpus[sample_inx]) if x not in stop_words('./data/stop_words.utf8')]
        print('样本之一:' + corpus[sample_inx])
        print('样本分词效果:' + '/ '.join(split_words))
        print('样本的topK(10)词:' + str(get_TF(split_words)))
    
    
    main()
    

    事实上,我们不用像上面一样自己载入停用词,我们只要按格式:

    朝三暮四 3(频数,可省略) i(词性,可省略)
    大数据
    说(每行写一个)
    

    写成一个文档,通过代码:

    jieba.load_userdict('path')
    

    载入即可。

    相关文章

      网友评论

      本文标题:第3章 中文分词技术

      本文链接:https://www.haomeiwen.com/subject/zqrdpftx.html