自然語言處理入門 何晗 讀書筆記 第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個詞條\n", 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 |\n", 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萬字/秒\n", 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萬字/秒\n", 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萬字/秒\n", 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)。內存指數膨脹,不現實。一個變通的方法是僅在根節點實施散列策略。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM