1. 程式人生 > >自然語言處理3 -- 詞性標註

自然語言處理3 -- 詞性標註

系列文章,請多關注
Tensorflow原始碼解析1 – 核心架構和原始碼結構
帶你深入AI(1) - 深度學習模型訓練痛點及解決方法
自然語言處理1 – 分詞
自然語言處理2 – jieba分詞用法及原理
自然語言處理3 – 詞性標註
自然語言處理4 – 句法分析
自然語言處理5 – 詞向量
自然語言處理6 – 情感分析

1 概述

詞性標註在自然語言處理中也屬於基礎性的模組,為句法分析、資訊抽取等工作打下基礎。和分詞一樣,中文詞性標註也存在著很多難點,比如一詞多詞性,未登入詞處理等諸多問題。通過基於字串匹配的字典查詢演算法和基於統計的詞性標註演算法,可以很好的解決這些問題。一般需要先將語句進行分詞,然後再進行詞性標註。

2 詞性標註難點

詞性作為詞語基本的語法屬性,是詞語和語句的關鍵性特徵。詞性種類也很多,ICTCLAS 漢語詞性標註集歸納的詞性種類及其表示見 https://www.cnblogs.com/chenbjin/p/4341930.html。詞性標註中的難點主要有

  1. 相對於英文,中文缺少詞形態變化,不能從詞的形態來識別詞性
  2. 一詞多詞性很常見。統計發現,一詞多詞性的概率高達22.5%。而且越常用的詞,多詞性現象越嚴重。比如“研究”既可以是名詞(“基礎性研究”),也可以是動詞(“研究電腦科學”)。
  3. 詞性劃分標準不統一。詞類劃分粒度和標記符號等,目前還沒有一個廣泛認可的統一的標準。比如LDC標註語料中,將漢語一級詞性劃分為33類,而北京大學語料庫則將其劃分為26類。詞類劃分標準和標記符號的不統一,以及分詞規範的含糊,都給詞性標註帶來了很大的困難。jieba分詞采用了使用較為廣泛的ICTCLAS 漢語詞性標註集規範。
  4. 未登入詞問題。和分詞一樣,未登入詞的詞性也是一個比較大的課題。未登入詞不能通過查詢字典的方式獲取詞性,可以採用HMM隱馬爾科夫模型等基於統計的演算法。

3 詞性標註演算法

和分詞一樣,詞性標註演算法也分為兩大類,基於字串匹配的字典查詢演算法和基於統計的演算法。jieba分詞就綜合了兩種演算法,對於分詞後識別出來的詞語,直接從字典中查詢其詞性。而對於未登入詞,則採用HMM隱馬爾科夫模型和viterbi演算法來識別。

3.1 基於字串匹配的字典查詢演算法

先對語句進行分詞,然後從字典中查詢每個詞語的詞性,對其進行標註即可。jieba詞性標註中,對於識別出來的詞語,就是採用了這種方法。這種方法比較簡單,通俗易懂,但是不能解決一詞多詞性的問題,因此存在一定的誤差。

下圖即為jieba分詞中的詞典的一部分詞語。每一行對應一個詞語,分為三部分,分別為詞語名 詞數 詞性。因此分詞完成後只需要在字典中查詢該詞語的詞性即可對其完成標註。

image.png | left | 623x276

3.2 基於統計的詞性標註演算法

和分詞一樣,我們也可以通過HMM隱馬爾科夫模型來進行詞性標註。觀測序列即為分詞後的語句,隱藏序列即為經過標註後的詞性標註序列。起始概率 發射概率和轉移概率和分詞中的含義大同小異,可以通過大規模語料統計得到。觀測序列到隱藏序列的計算可以通過viterbi演算法,利用統計得到的起始概率 發射概率和轉移概率來得到。得到隱藏序列後,就完成了詞性標註過程。

4 jieba詞性標註原理

jieba在分詞的同時,可以進行詞性標註。利用jieba.posseg模組來進行詞性標註,會給出分詞後每個詞的詞性。詞性標示相容ICTCLAS 漢語詞性標註集,可查閱網站https://www.cnblogs.com/chenbjin/p/4341930.html

import jieba.posseg as pseg
words = pseg.cut("我愛北京天安門")
for word, flag in words:
...    print('%s %s' % (word, flag))
...
我 r        # 代詞
愛 v        # 動詞
北京 ns        # 名詞
天安門 ns        # 名詞

下面來對pseg.cut()進行詳細的分析,其主要流程為

  1. 準備工作:check字典是否初始化好,如果沒有則先初始化字典。將語句轉為UTF-8或者GBK。根據正則匹配,將輸入文字分隔成一個個語句。
  2. 遍歷語句list,對每個語句進行單獨分詞和詞性標註。
  3. 對於未登入詞,使用HMM隱馬爾科夫模型處理。

4.1 準備工作

準備工作中做的事情和jieba分詞基本一致,check字典是否初始化好,如果沒有則先初始化字典。將語句轉為UTF-8或者GBK。根據正則匹配,將輸入文字分隔成一個個語句。程式碼如下。

    def __cut_internal(self, sentence, HMM=True):
        # 如果沒有字典沒有初始化,則先載入字典。否則直接使用字典快取即可。
        self.makesure_userdict_loaded()
        
        # 將語句轉為UTF-8或者GBK
        sentence = strdecode(sentence)
        
        # 根據正則匹配,將輸入文字分隔成一個個語句。分隔符包括空格 逗號 句號等。
        blocks = re_han_internal.split(sentence)
        
        # 根據是否採用了HMM模型來進行不同方法的選擇
        if HMM:
            cut_blk = self.__cut_DAG
        else:
            cut_blk = self.__cut_DAG_NO_HMM

        # 遍歷正則匹配分隔好的語句,對每個語句進行單獨的分詞和詞性標註
        for blk in blocks:
            if re_han_internal.match(blk):
                # 分詞和詞性標註
                for word in cut_blk(blk):
                    yield word
            else:
                tmp = re_skip_internal.split(blk)
                for x in tmp:
                    if re_skip_internal.match(x):
                        yield pair(x, 'x')
                    else:
                        for xx in x:
                            if re_num.match(xx):
                                yield pair(xx, 'm')
                            elif re_eng.match(x):
                                yield pair(xx, 'eng')
                            else:
                                yield pair(xx, 'x')

4.2 遍歷語句,進行分詞和詞性標註

步驟和jieba分詞基本一致,主體步驟如下,詳細的每個步驟見 自然語言處理2jieba分詞用法及原理

  1. 得到語句的有向無環圖DAG
  2. 動態規劃構建Route,計算從語句末尾到語句起始,DAG中每個節點到語句結束位置的最大路徑概率,以及概率最大時節點對應詞語的結束位置
  3. 遍歷每個節點的Route,組裝詞語組合。
  4. 如果詞語不在字典中,也就是新詞,使用HMM隱馬爾科夫模型進行分割
  5. 通過yield將詞語逐個返回。
    def __cut_DAG(self, sentence):
        # 構建DAG有向無環圖,得到語句分詞所有可能的路徑
        DAG = self.tokenizer.get_DAG(sentence)
        route = {}

        # 動態規劃,計算從語句末尾到語句起始,DAG中每個節點到語句結束位置的最大路徑概率,以及概率最大時節點對應詞語的結束位置
        self.tokenizer.calc(sentence, DAG, route)

        # 遍歷每個節點的Route,組裝詞語組合。
        x = 0
        buf = ''
        N = len(sentence)
        while x < N:
            # y表示詞語的結束位置,x為詞語的起始位置
            y = route[x][1] + 1
            # 從起始位置x到結束位置y,取出一個詞語
            l_word = sentence[x:y]
            if y - x == 1:
                # 單字,一個漢字構成的一個詞語
                buf += l_word
            else:
                # 多漢字詞語
                if buf:
                    if len(buf) == 1:
                        # 單字直接從字典中取出其詞性。使用pair將分詞和詞性一起輸出。
                        yield pair(buf, self.word_tag_tab.get(buf, 'x'))
                    elif not self.tokenizer.FREQ.get(buf):
                        # 詞語不在字典中,也就是新詞,使用HMM隱馬爾科夫模型進行分割
                        recognized = self.__cut_detail(buf)
                        for t in recognized:
                            yield t
                    else:
                        # 詞語在字典中,直接查詢字典並取出詞性。
                        for elem in buf:
                            yield pair(elem, self.word_tag_tab.get(elem, 'x'))
                    buf = ''
                yield pair(l_word, self.word_tag_tab.get(l_word, 'x'))

            # 該節點取詞完畢,跳到下一個詞語的開始位置
            x = y

        # 通過yield,逐詞返回上一步切分好的詞語
        if buf:
            if len(buf) == 1:
                yield pair(buf, self.word_tag_tab.get(buf, 'x'))
            elif not self.tokenizer.FREQ.get(buf):
                recognized = self.__cut_detail(buf)
                for t in recognized:
                    yield t
            else:
                for elem in buf:
                    yield pair(elem, self.word_tag_tab.get(elem, 'x'))

其中word_tag_tab在初始化載入詞典階段構建得到,它使用詞語為key,對應詞性為value。程式碼如下

    def load_word_tag(self, f):
        self.word_tag_tab = {}
        f_name = resolve_filename(f)
        
        # 遍歷字典的每一行。每一行對應一個詞語。包含詞語 詞數 詞性三部分
        for lineno, line in enumerate(f, 1):
            try:
                # 去除首尾空格符
                line = line.strip().decode("utf-8")
                if not line:
                    continue
                # 利用空格將一行分隔為詞語 詞數 詞性三部分
                word, _, tag = line.split(" ")
                # 使用詞語為key,詞性為value,構造Dict
                self.word_tag_tab[word] = tag
            except Exception:
                raise ValueError(
                    'invalid POS dictionary entry in %s at Line %s: %s' % (f_name, lineno, line))
        f.close()

4.3 未登入詞,HMM隱馬爾科夫模型處理

和分詞一樣,詞性標註中,也使用HMM隱馬爾科夫模型來處理未登入詞。通過大規模語料統計,得到起始概率 發射概率和轉移概率。分別對應prob_start.py prob_emit.py和prob_trans.py三個檔案,他們給出了詞語在BEMS四種情況下,每種詞性對應的概率。然後使用viterbi演算法,利用得到的三個概率,將觀測序列(分詞後的語句)轉化得到隱藏序列(詞性標註序列)。這樣就完成了未登入詞的詞性標註。程式碼如下。

    # 通過HMM隱馬爾科夫模型獲取詞性標註序列,解決未登入的問題
    def __cut(self, sentence):
        # 通過viterbi演算法,利用三個概率,由語句觀測序列,得到詞性標註隱藏序列
        # prob為
        # pos_list對應每個漢字,包含分詞標註BEMS和詞語詞性兩部分。
        prob, pos_list = viterbi(
            sentence, char_state_tab_P, start_P, trans_P, emit_P)
        begin, nexti = 0, 0

        # 遍歷語句的每個漢字,如果是E或者S時,也就是詞語結束或者單字詞語,則分隔得到詞語和詞性pair
        for i, char in enumerate(sentence):
            pos = pos_list[i][0]
            if pos == 'B':
                # B表示詞語的開始
                begin = i
            elif pos == 'E':
                # E表示詞語的結束,此時輸出詞語和他的詞性
                yield pair(sentence[begin:i + 1], pos_list[i][1])
                nexti = i + 1
            elif pos == 'S':
                # S表示單字詞語,此時也輸出詞語和他的詞性
                yield pair(char, pos_list[i][1])
                nexti = i + 1

        # 一般不會走到這兒,以防萬一。對剩餘的所有漢字一起輸出一個詞語和詞性。
        if nexti < len(sentence):
            yield pair(sentence[nexti:], pos_list[nexti][1])

觀測序列到隱藏序列的計算,則通過viterbi演算法實現。程式碼如下

# 通過viterbi演算法,由觀測序列,也就是語句,來得到隱藏序列,也就是BEMS標註序列和詞性標註序列
# obs為語句,states為"BEMS"四種狀態,
# start_p為起始概率, trans_p為轉移概率, emit_p為發射概率,三者通過語料訓練得到
def viterbi(obs, states, start_p, trans_p, emit_p):
    V = [{}]  # 每個漢字的每個BEMS狀態的最大概率。
    mem_path = [{}]   # 分詞路徑

    # 初始化每個state,states為"BEMS"
    all_states = trans_p.keys()
    for y in states.get(obs[0], all_states):  # init
        V[0][y] = start_p[y] + emit_p[y].get(obs[0], MIN_FLOAT)
        mem_path[0][y] = ''

    # 逐字進行處理
    for t in xrange(1, len(obs)):
        V.append({})
        mem_path.append({})
        #prev_states = get_top_states(V[t-1])
        prev_states = [
            x for x in mem_path[t - 1].keys() if len(trans_p[x]) > 0]

        prev_states_expect_next = set(
            (y for x in prev_states for y in trans_p[x].keys()))
        obs_states = set(
            states.get(obs[t], all_states)) & prev_states_expect_next

        if not obs_states:
            obs_states = prev_states_expect_next if prev_states_expect_next else all_states

        # 遍歷每個狀態
        for y in obs_states:
            # 計算前一個狀態到本狀態的最大概率和它的前一個狀態
            prob, state = max((V[t - 1][y0] + trans_p[y0].get(y, MIN_INF) +
                               emit_p[y].get(obs[t], MIN_FLOAT), y0) for y0 in prev_states)
            # 將該漢字下的某狀態(BEMS)的最大概率記下來
            V[t][y] = prob
            # 記錄狀態轉換路徑
            mem_path[t][y] = state

    last = [(V[-1][y], y) for y in mem_path[-1].keys()]
    # if len(last)==0:
    #     print obs
    prob, state = max(last)

    route = [None] * len(obs)
    i = len(obs) - 1
    while i >= 0:
        route[i] = state
        state = mem_path[i][state]
        i -= 1
    return (prob, route)

5 總結

jieba可以在分詞的同時,完成詞性標註,因此標註速度可以得到保證。通過查詢字典的方式獲取識別詞的詞性,通過HMM隱馬爾科夫模型來獲取未登入詞的詞性,從而完成整個語句的詞性標註。但可以看到查詢字典的方式不能解決一詞多詞性的問題,也就是詞性歧義問題。故精度上還是有所欠缺的。

系列文章,請多關注
Tensorflow原始碼解析1 – 核心架構和原始碼結構
帶你深入AI(1) - 深度學習模型訓練痛點及解決方法
自然語言處理1 – 分詞
自然語言處理2 – jieba分詞用法及原理
自然語言處理3 – 詞性標註
自然語言處理4 – 句法分析
自然語言處理5 – 詞向量
自然語言處理6 – 情感分析