zoukankan      html  css  js  c++  java
  • 自然语言处理入门 何晗 读书笔记 第2章 词典分词

    中文分词指的是将一段文本拆分为一系列单词的过程,这些单词顺序拼接后等于原文本。中文分词算法大致分为基于词典规则与基于机器学习这两大派别。本章先从简单的规则入手,为读者介绍一些高效的词典匹配算法。

    词典分词 是最简单、最常见的分词算法,仅需一部词典和一套查词典的规则即可,适合初学者入门。给定一部词典,词典分词就是一个确定的查词与输出的规则系统。词典分词的重点不在于分词本身,而在于支撑词典的数据结构。

    本章先介绍词的定义与性质,然后给出一部词典。

    2.1 什么是词

    2.1.1 词的定义

    在基于词典的中文分词中,词的定义要现实得多:词典中的字符串就是词。根据此定义,词典之外的字符串就不是词了。这个推论或许不符合读者的期望,但这就是词典分词故有的弱点。事实上,语言中的词汇数量是无穷的,无法用任何词典完整收录,

    2.1.2 词的性质----齐夫定律

    齐夫定律:一个单词的词频与它的词频排名成反比。就是说,虽然存在很多生词,但生词的词频较小,趋近于0,平时很难碰到。至少在常见的单词的切分上,可以放心地试一试词典分词。

    实现词典分词的第一个步骤,当然是准备一份词典了。

    2.2 词典

    互联网上有许多公开的中文词库,

    比如搜狗实验室发布的互联网词库(SogouW,其中有15万个词条)  https://www.sogou.com/labs/resource/w.php,

    清华大学开放中文词库(THUOCL),http://thunlp.org

    何晗发布的千万级巨型汉语词库(千万级词条):http://www.hankcs.com/nlp/corpus/tens-of-millions-of-giant-chinese-word-library-share.html

    2.2.1 HanLP词典

    CoreNatureDictionary.mini.txt文件

    第一列是单词本身,之后每两列分别表示词性与相应的词频。希望这个词以动词出现了386次,以名词的身份出现了96次。

    2.2.2 词典的加载

     利用HanLP,读取CoreNatureDictionary.mini.txt文件,只需一行代码

    TreeMap<String, CoreDictionary.Attribute> dictionary =
                IOUtil.loadDictionary("data/dictionary/CoreNatureDictionary.mini.txt");

    得了一个TreeMap,它的键宿舍单词本身,而值是CoreDictionary.Attribute

    查看这份词典的大小,以及按照字典序排列的第一个单词:

    System.out.printf("词典大小:%d个词条
    ", dictionary.size());
    System.out.println(dictionary.keySet().iterator().next());

    2.3 切分算法

    2.3.1 完全切分

    完全切分指的是,找出一段文本中的所有单词。朴素的完全切分算法其实非常简单,只要遍历文本中的连续序列,查询该序列是否在词典中即可。定义词典为dic,文本为text,当前的处理位置为i,完全切分的python算法如下:

    def fully_segment(text, dic):
        word_list = []
        for i in range(len(text)):                  # i 从 0 到text的最后一个字的下标遍历
            for j in range(i + 1, len(text) + 1):   # j 遍历[i + 1, len(text)]区间
                word = text[i:j]                    # 取出连续区间[i, j]对应的字符串
                if word in dic:                     # 如果在词典中,则认为是一个词
                    word_list.append(word)
        return word_list

    代码详见tests/book/ch02/fully_segment.py

    主函数

    if __name__ == '__main__':
        dic = load_dictionary()
    
        print(fully_segment('商品和服务', dic))

    运行结果:

     Java代码

    /**
         * 完全切分式的中文分词算法
         *
         * @param text       待分词的文本
         * @param dictionary 词典
         * @return 单词列表
         */
        public static List<String> segmentFully(String text, Map<String, CoreDictionary.Attribute> dictionary)
        {
            List<String> wordList = new LinkedList<String>();
            for (int i = 0; i < text.length(); ++i)
            {
                for (int j = i + 1; j <= text.length(); ++j)
                {
                    String word = text.substring(i, j);
                    if (dictionary.containsKey(word))
                    {
                        wordList.add(word);
                    }
                }
            }
            return wordList;
        }

    // 完全切分
    System.out.println(segmentFully("就读北京大学", dictionary));

    结果:

    2.3.2 正向最长匹配

    完全切分的结果比较没有意义,我们更需要那种有意义的词语序列,而不是所有出现在词典中的单词所构成的链表。 所以需要完善一下处理规则,考虑到越长的单词表达的意义越丰富,于是我们定义单词越长优先级越高。具体说来,就是在以某个下标为起点递增查词的过程中,优先输出更长的单词,这种规则被称为最长匹配算法。扫描顺序从前往后,则称为正向最长匹配,反之则为逆向最长匹配。

    Python代码

    def forward_segment(text, dic):
        word_list = []
        i = 0
        while i < len(text):
            longest_word = text[i]                      # 当前扫描位置的单字
            for j in range(i + 1, len(text) + 1):       # 所有可能的结尾
                word = text[i:j]                        # 从当前位置到结尾的连续字符串
                if word in dic:                         # 在词典中
                    if len(word) > len(longest_word):   # 并且更长
                        longest_word = word             # 则更优先输出
            word_list.append(longest_word)              # 输出最长词
            i += len(longest_word)                      # 正向扫描
        return word_list

    调用

    if __name__ == '__main__':
        dic = load_dictionary()
    
        print(forward_segment('就读北京大学', dic))
        print(forward_segment('研究生命起源', dic))

     Java代码

     /**
         * 正向最长匹配的中文分词算法
         *
         * @param text       待分词的文本
         * @param dictionary 词典
         * @return 单词列表
         */
        public static List<String> segmentForwardLongest(String text, Map<String, CoreDictionary.Attribute> dictionary)
        {
            List<String> wordList = new LinkedList<String>();
            for (int i = 0; i < text.length(); )
            {
                String longestWord = text.substring(i, i + 1);
                for (int j = i + 1; j <= text.length(); ++j)
                {
                    String word = text.substring(i, j);
                    if (dictionary.containsKey(word))
                    {
                        if (word.length() > longestWord.length())
                        {
                            longestWord = word;
                        }
                    }
                }
                wordList.add(longestWord);
                i += longestWord.length();
            }
            return wordList;
        }

     2.3.3 逆向最长匹配

    Python代码

    def backward_segment(text, dic):
        word_list = []
        i = len(text) - 1
        while i >= 0:                                   # 扫描位置作为终点
            longest_word = text[i]                      # 扫描位置的单字
            for j in range(0, i):                       # 遍历[0, i]区间作为待查询词语的起点
                word = text[j: i + 1]                   # 取出[j, i]区间作为待查询单词
                if word in dic:
                    if len(word) > len(longest_word):   # 越长优先级越高
                        longest_word = word
            word_list.insert(0, longest_word)           # 逆向扫描,所以越先查出的单词在位置上越靠后
            i -= len(longest_word)
        return word_list

    Java代码

    /**
         * 逆向最长匹配的中文分词算法
         *
         * @param text       待分词的文本
         * @param dictionary 词典
         * @return 单词列表
         */
        public static List<String> segmentBackwardLongest(String text, Map<String, CoreDictionary.Attribute> dictionary)
        {
            List<String> wordList = new LinkedList<String>();
            for (int i = text.length() - 1; i >= 0; )
            {
                String longestWord = text.substring(i, i + 1);
                for (int j = 0; j <= i; ++j)
                {
                    String word = text.substring(j, i + 1);
                    if (dictionary.containsKey(word))
                    {
                        if (word.length() > longestWord.length())
                        {
                            longestWord = word;
                        }
                    }
                }
                wordList.add(0, longestWord);
                i -= longestWord.length();
            }
            return wordList;
        }

     结果还是出现问题,因此有人提出综合两种规则,期待它们取长补短,称为双向最长匹配。

    2.3.4 双向最长匹配

    统计显示,正向匹配错误而逆向匹配正确的句子占9.24%。

    双向最长匹配规则集,流程如下:

    (1)同时执行正向和逆向最长匹配,若两者的词数不同,则返回词数更少的那一个。

    (2)否则,返回两者中单字更少的那一个。当单字数也相同时,优先返回逆向最长匹配的结果。

    Python代码

    from backward_segment import backward_segment
    from forward_segment import forward_segment
    from utility import load_dictionary
    
    
    def count_single_char(word_list: list):  # 统计单字成词的个数
        return sum(1 for word in word_list if len(word) == 1)
    
    def bidirectional_segment(text, dic):
        f = forward_segment(text, dic)
        b = backward_segment(text, dic)
        if len(f) < len(b):                                  # 词数更少优先级更高
            return f
        elif len(f) > len(b):
            return b
        else:
            if count_single_char(f) < count_single_char(b):  # 单字更少优先级更高
                return f
            else:
                return b                                     # 都相等时逆向匹配优先级更高
    
    
    if __name__ == '__main__':
        dic = load_dictionary()
        print(bidirectional_segment('研究生命起源', dic))
    

    Java版本

    /**
         * 双向最长匹配的中文分词算法
         *
         * @param text       待分词的文本
         * @param dictionary 词典
         * @return 单词列表
         */
        public static List<String> segmentBidirectional(String text, Map<String, CoreDictionary.Attribute> dictionary)
        {
            List<String> forwardLongest = segmentForwardLongest(text, dictionary);
            List<String> backwardLongest = segmentBackwardLongest(text, dictionary);
            if (forwardLongest.size() < backwardLongest.size())
                return forwardLongest;
            else if (forwardLongest.size() > backwardLongest.size())
                return backwardLongest;
            else
            {
                if (countSingleChar(forwardLongest) < countSingleChar(backwardLongest))
                    return forwardLongest;
                else
                    return backwardLongest;
            }
        }

    主函数调用部分代码

    // 双向最长匹配
            String[] text = new String[]{
                "项目的研究",
                "商品和服务",
                "研究生命起源",
                "当下雨天地面积水",
                "结婚的和尚未结婚的",
                "欢迎新老师生前来就餐",
            };
            for (int i = 0; i < text.length; i++)
            {
                System.out.printf("| %d | %s | %s | %s | %s |
    ", i + 1, text[i],
                                  segmentForwardLongest(text[i], dictionary),
                                  segmentBackwardLongest(text[i], dictionary),
                                  segmentBidirectional(text[i], dictionary)
                );
            }

     比较之后发现,双向最长匹配在2、3、5这3种情况下选择出了最好的结果,但在4号句子上选择了错误的结果,使得最终正确率3/6反而小于逆向最长匹配的4/6。由此,规则系统的脆弱可见一斑。规则集的维护有时是拆东墙补西墙,有时是帮倒忙。

    2.3.5 速度评测

    词典分词的规则没有技术含量,消除歧义的效果不好。词典分词的核心价值不在于精度,而在于速度。

    Python

    def evaluate_speed(segment, text, dic):
        start_time = time.time()
        for i in range(pressure):
            segment(text, dic)
        elapsed_time = time.time() - start_time
        print('%.2f 万字/秒' % (len(text) * pressure / 10000 / elapsed_time))
    
    
    if __name__ == '__main__':
        text = "江西鄱阳湖干枯,中国最大淡水湖变成大草原"
        pressure = 10000
        dic = load_dictionary()
    
        print('由于JPype调用开销巨大,以下速度显著慢于原生Java')
        evaluate_speed(forward_segment, text, dic)
        evaluate_speed(backward_segment, text, dic)
        evaluate_speed(bidirectional_segment, text, dic)

     Java

    public static void evaluateSpeed(Map<String, CoreDictionary.Attribute> dictionary)
        {
            String text = "江西鄱阳湖干枯,中国最大淡水湖变成大草原";
            long start;
            double costTime;
            final int pressure = 10000;
    
            System.out.println("正向最长");
            start = System.currentTimeMillis();
            for (int i = 0; i < pressure; ++i)
            {
                segmentForwardLongest(text, dictionary);
            }
            costTime = (System.currentTimeMillis() - start) / (double) 1000;
            System.out.printf("%.2f万字/秒
    ", text.length() * pressure / 10000 / costTime);
    
            System.out.println("逆向最长");
            start = System.currentTimeMillis();
            for (int i = 0; i < pressure; ++i)
            {
                segmentBackwardLongest(text, dictionary);
            }
            costTime = (System.currentTimeMillis() - start) / (double) 1000;
            System.out.printf("%.2f万字/秒
    ", text.length() * pressure / 10000 / costTime);
    
            System.out.println("双向最长");
            start = System.currentTimeMillis();
            for (int i = 0; i < pressure; ++i)
            {
                segmentBidirectional(text, dictionary);
            }
            costTime = (System.currentTimeMillis() - start) / (double) 1000;
            System.out.printf("%.2f万字/秒
    ", text.length() * pressure / 10000 / costTime);
        }

     总结:

    1、Python的运行速度比Java慢,效率只有Java的一半不到

    2、正向匹配与逆向匹配的速度差不多,是双向的两倍。因为双向做了两倍的工作

    3、Java实现的正向匹配比逆向匹配快。

    2.4 字典树

    2.4.1 什么是字典树

    字符串集合常用字典树存储,这是一种字符串上的树形数据结构。字典树中每条边都对应一个字,从根节点往下的路径构成一个个字符串。字典树并不直接在节点上存储字符串,而是将词语视作根节点到某节点之间的一条路径,并在终点节点上做个标记"该节点对应词语的结尾".字符串就是一条路径,要查询一个单词,只需顺着这条路径从根节点往下走。如果能走到特殊标记的节点,则说明该字符串在集合中,否则说明不存在。

     蓝色标记着该节点是一个词的结尾,数字是人为的编号。这棵树中存储的词典如下所示:

    入门:  0--1--2

    自然:  0--3--4

    自然人: 0--3--4--5 

    2.4.2 字典树的节点实现

    约定用值为None表示节点不对应词语,虽然这样就不能插入值为None的键了,但实现起来更简单。

    节点的Python描述如下:

    class Node(object):
        def __init__(self, value) -> None:
            self._children = {}
            self._value = value
    
        def _add_child(self, char, value, overwrite=False):
            child = self._children.get(char)
            if child is None:
                child = Node(value)
                self._children[char] = child
            elif overwrite:
                child._value = value
            return child

    2.4.3 字典树的增删改查实现

    "删改查"其实是一回事,都是查询。删除操作就是将终点的值设为None而已,修改操作无非是将它的值设为另一个值而已。

    从确定有限状态自动机的角度来讲,每个节点都是一个状态,状态表示当前已查询到的前缀。

    状态      前缀

    0        “(空白)

    1        入

    2        入门

    。。。。

    从父节点到子节点的移动过程可以看作一次状态转移。

    ”增加键值对“其实还是查询,只不过在状态转移失败的时候,则创建相应的子节点,保证转移成功。

    字典树的完整实现如下:

    class Trie(Node):
        def __init__(self) -> None:
            super().__init__(None)
    
        def __contains__(self, key):
            return self[key] is not None
    
        def __getitem__(self, key):
            state = self
            for char in key:
                state = state._children.get(char)
                if state is None:
                    return None
            return state._value
    
        def __setitem__(self, key, value):
            state = self
            for i, char in enumerate(key):
                if i < len(key) - 1:
                    state = state._add_child(char, None, False)
                else:
                    state = state._add_child(char, value, True)

     写一些测试:

    if __name__ == '__main__':
        trie = Trie()
        #
        trie['自然'] = 'nature'
        trie['自然人'] = 'human'
        trie['自然语言'] = 'language'
        trie['自语'] = 'talk    to oneself'
        trie['入门'] = 'introduction'
        assert '自然' in trie
        #
        trie['自然'] = None
        assert '自然' not in trie
        #
        trie['自然语言'] = 'human language'
        assert trie['自然语言'] == 'human language'
        #
        assert trie['入门'] == 'introduction'

    2.4.4 首字散列其余二分的字典树

    读者也许听说过散列函数,它用来将对象转换为整数。散列函数必须满足的基本要求是:对象相同,散列值必须相同。散列函数设计不当,则散列表的内存效率和查找效率都不高。Python没有char类型,字符被视作长度为1的字符串,所以实际调用的就是str的散列函数。在64位系统上,str的散列函数返回64位的整数。但Unicode字符总共也才136690个,远远小于2^64。这导致两个字符在字符集中明明相邻,然而散列值却相差万里。

    Java中的字符散列函数则要友好一些,Java中字符的编码为UTF-16。每个字符都可以映射为16位不重复的连续整数,恰好是完美散列。这个完美的散列函数输出的是区间[0,65535]内的正整数,用来索引子节点非常合适。具体做法是创建一个长为65536的数组,将子节点按对应的字符整型值作为下标放入该数组中即可。这样每次状态转移时,只需访问对应下标就行了,这在任何编程语言中都是极快的。然而这种待遇无法让每个节点都享受,如果词典中的词语最长为l,则最坏情况下字典树第l层的数组容量之和为O(65536^l)。内存指数膨胀,不现实。一个变通的方法是仅在根节点实施散列策略。

  • 相关阅读:
    ASP.NET 错误
    linux下使用蓝牙设备【转】
    AIDL Android中的远程接口 [转]
    Handler理解
    Hid Report Descriptor
    Android kernel x86 编译方法
    Android Init Language
    DBUS 资源
    Analysing Bluetooth Keyboard Traffic with hcidump
    DBUS基础知识
  • 原文地址:https://www.cnblogs.com/coderying/p/11892565.html
Copyright © 2011-2022 走看看