1. 程式人生 > 實用技巧 >NLP之統計句法分析(PCFG+CYK演算法)

NLP之統計句法分析(PCFG+CYK演算法)

一、認識句法分析

首先,瞭解一下句法分析到底是什麼意思?是做什麼事情呢?顧名思義,感覺是學習英語時候講的各種句法語法。沒錯!這裡就是把句法分析過程交給計算機處理,讓它分析一個句子的句法組成,然後更好理解句子的語義資訊。這就是NLP的目的,也就是AI的目標。

句法分析(syntactic parsing)是自然語言處理中的關鍵技術之一,基本任務是確定句子的句法結構(syntactic structure)或句子中詞彙之間的依存關係。句法分析分為:句法結構分析和依存關係分析。本博文將詳細介紹句法結構分析的一種方法:基於概率上下文無關文法(PCFG)的統計句法分析,使用的演算法是CYK演算法,對輸入的單詞序列(句子)分析出合乎語法規則的句子語法結構,自然語言處理重要技術實踐之一:句法分析。本篇詳細記錄學習總結和分享經驗方法,python實現使用CYK演算法對上下無關文法(PCFG)的句法分析,通過核心演算法講解深入理解統計句法分析的思想並掌握具體演算法程式碼實現,得到一個句子的語法樹。

這篇也是在NLP前兩個任務的基礎上,進一步讓計算機理解人類自然語言的意義,前兩個基礎任務分別是:

  1. 分詞:雙向最大匹配演算法——基於詞典規則的中文分詞(Java實現)【https://www.cnblogs.com/chenzhenhong/p/13748042.html】
  2. 詞性標註:Java實現:拋開jieba等工具,寫HMM+維特比演算法進行詞性標註【https://www.cnblogs.com/chenzhenhong/p/13850687.html】

二、CYK演算法

在句法分析方法的細分中,結構分析有許多方法,這裡採用概率上下文無關文法(PCFG)的統計句法分析,具體實現的演算法選擇了其中一個:CYK演算法。顧名思義,由三位大牛(Cocke-Younger-Kasami)共同提出,演算法的思想巧妙地運用了維特比動態規劃的方法,實在佩服!來瞅瞅是什麼厲害的演算法。

給定一個句子s 和一個上下文無關文法PCFG,G=(T, N, S, R, P),定義一個跨越單詞 i到j的概率最大的語法成分π:π(i,j,X)(i,j∈1…n ,X∈N),目標是找到一個屬於π[1,n,S]的所有樹中概率最大的那棵。

  1. T代表終端符集合
  2. N代表非終端符集合
  3. S代表初始非端結符
  4. R代表產生語法規則集
  5. P 代表每條產生規則的統計概率

下面是我根據演算法思想整理寫出的演算法虛擬碼,比較容易理解:

function CKY(words, grammar) :
//初始化
    score = new double[#(words)+1][#(words)+1][#(nonterms)]
    back 
= new Pair[#(words)+1][#(words)+1][#nonterms]] //填葉結點 for i=0; i<#(words); i++ for A in nonterms if A -> words[i] in grammar score[i][i+1][A] = P(A -> words[i]) //處理一元規則 boolean added = true while added added = false //生成新的語法需要加入 for A, B in nonterms if score[i][i+1][B] > 0 && A->B in grammar prob = P(A->B)*score[i][i+1][B] if prob > score[i][i+1][A] score[i][i+1][A] = prob back[i][i+1][A] = B added = true //自底向上處理非葉結點 for span = 2 to #(words) for begin = 0 to #(words)- span//該層結點個數 end = begin + span for split = begin+1 to end-1 for A,B,C in nonterms prob=score[begin][split][B]*score[split][end][C]*P(A->BC) //計算每種分裂概率,儲存最大概率路徑 if prob > score[begin][end][A] score[begin]end][A] = prob back[begin][end][A] = new Triple(split,B,C) //處理一元語法 boolean added = true while added added = false for A, B in nonterms prob = P(A->B)*score[begin][end][B]; if prob > score[begin][end][A] score[begin][end][A] = prob back[begin][end][A] = B added = true //返回最佳路徑樹 return buildTree(score, back)

score存放最大概率,back存放分裂點資訊以便回溯,在接下來的具體演算法實現,將用特別的資料結構實現資料資訊的儲存。

score[0][0]
score[1][1]
score[2][2]
score[3][3]

用矩陣的方式儲存資訊,以每個單詞作為對角線上的元素,也就是樹結構的葉結點。運用動態規劃的思想進行填表,直到右上角計算出來,整棵樹的結點資訊就全部計算處理。

三、python實現:核心CYK演算法

1、資料結構的選擇及初始化

利用了python語言的優勢,將字典和列表兩種資料結構結合,實現概率的儲存和路徑資訊的儲存。

    word_list = sentence.split()
    best_path = [[{} for _ in range(len(word_list))] for _ in range(len(word_list))]
 
    # 初始化
    for i in range(len(word_list)):  # 下標為0開始
        for j in range(len(word_list)):
            for x in non_terminal:  # 初始化每個字典,每個語法規則概率及路徑為None,避免溢位和空指標
                best_path[i][j][x] = {'prob': 0.0, 'path': {'split': None, 'rule': None}}

2、葉結點的計算

這裡還需提前普及一下語法規則的形式,形如:◼VP→VP PP ◼ S → Aux NP VP ◼ NP->astronomers等就是一條語法規則,可以發現左邊只有一個非終端符(詞性),指向右邊一個/多個非終端符或終端符(單詞)。為了保證演算法處理的統一性,我們要將語法規則通過某種方式統一起來,這就引申出CNF(喬姆斯基正規化)

如果一個上下文無關文法的每個產生式的形式為:A->BC或A->a,即規則的右部或者是兩個非終端符或者是一個終端符。所以,本次實驗資料給出了CNF的語法規則,方便了計算過程。

關鍵部分是,要實現①非終端符-單詞的規則,然後再一次掃描語法規則集,將“新規則”②非終端符--①非終端符加入該葉結點的語法集合。

    # 填葉結點,計算得到每個單詞所有語法組成的概率
    for i in range(len(word_list)):  # 下標為0開始
        for x in non_terminal:  # 遍歷非終端符,找到並計算此條非終端-終端語法的概率
            if word_list[i] in rules_prob[x].keys():
                best_path[i][i][x]['prob'] = rules_prob[x][word_list[i]]  # 儲存概率
                best_path[i][i][x]['path'] = {'split': None, 'rule': word_list[i]}  # 儲存路徑
                # 生成新的語法需要加入
                for y in non_terminal:
                    if x in rules_prob[y].keys():
                        best_path[i][i][y]['prob'] = rules_prob[x][word_list[i]] * rules_prob[y][x]
                        best_path[i][i][y]['path'] = {'split': i, 'rule': x}

3、非葉結點

這是CYK演算法的核心部分,填非葉結點。註釋比較詳細解釋了每步的作用。

for l in range(1, len(word_list)):
    # 該層結點個數
    for i in range(len(word_list) - l):  # 第一層:0,1,2
        j = i + l  # 處理第二層結點,(0,j=1),(1,2),(2,3)   1=0+1,2=1+1.3=2+1
        for x in non_terminal:  # 獲取每個非終端符
            tmp_best_x = {'prob': 0, 'path': None}
 
            for key, value in rules_prob[x].items():  # 遍歷該非終端符所有語法規則
                if key[0] not in non_terminal:
                    break
                # 計算產生的分裂點概率,保留最大概率
                for s in range(i, j):  # 第一個位置可分裂一個(0,0--1,1)
                    # for A in best_path[i][s]
                    if len(key) == 2:
                        tmp_prob = value * best_path[i][s][key[0]]['prob'] * best_path[s + 1][j][key[1]]['prob']
                    else:
                        tmp_prob = value * best_path[i][s][key[0]]['prob'] * 0
                    if tmp_prob > tmp_best_x['prob']:
                        tmp_best_x['prob'] = tmp_prob
                        tmp_best_x['path'] = {'split': s, 'rule': key}  # 儲存分裂點和生成的可用規則
            best_path[i][j][x] = tmp_best_x  # 得到一個規則中最大概率
 
        # print("score[", i, "][", j, "]:", best_path[i][j])
best_path = best_path

擴充套件的CYK演算法需要處理一元語法規則,所以我用了一個判斷語句,避免一元規則計算時候的陣列越界。

for s in range(i, j):  # 第一個位置可分裂一個(0,0--1,1)
# for A in best_path[i][s]
    if len(key) == 2:
        tmp_prob = value * best_path[i][s][key[0]]['prob'] * best_path[s + 1][j][key[1]]['prob']
    else:
        tmp_prob = value * best_path[i][s][key[0]]['prob'] * 0

4、回溯構建語法樹

這步驟花了不少debug時間,遇到了樹結點遍歷為空的情況,很明顯邊界沒有處理好。這是我開始先序遍歷樹的方法,遞迴得到語法樹。

# 回溯路徑,先序遍歷樹
def back(best_path, left, right, root, ind=0):
    node = best_path[left][right][root]
    if node['path']['split'] is not None:  # 判斷是否存在分裂點,值為下標
        print('\t' * ind, (root,))  
        # 遞迴呼叫
            back(best_path, left, node['path']['split'], node['path']['rule'][0], ind + 1)  # 左子樹
            back(best_path, node['path']['split'] + 1, right, node['path']['rule'][1], ind + 1)  # 右子樹
    else:
        print('\t' * ind, (root,))
        print('--->', node['path']['rule'])

出錯如圖:TypeError: 'NoneType' object is not subscriptable

我排查了許久,發現是遞迴遍歷了不存在的結點。成功解決之後修改程式如下:

def back(best_path, left, right, root, ind=0):
    node = best_path[left][right][root]
    if node['path']['split'] is not None:  # 判斷是否存在分裂點,值為下標
        print('\t' * ind, (root,node['prob']))  # self.rules_prob[root].get(node['path']['rule']
        # 遞迴呼叫
        if len(node['path']['rule']) == 2:  # 如果規則為二元,遞迴呼叫左子樹、右子樹,如 NP-->NP NP
            back(best_path, left, node['path']['split'], node['path']['rule'][0], ind + 1)  # 左子樹
            back(best_path, node['path']['split'] + 1, right, node['path']['rule'][1], ind + 1)  # 右子樹
        else:  # 否則,只遞迴左子樹,如 NP-->N
            back(best_path, left, node['path']['split'], node['path']['rule'][0], ind + 1)
    else:
        print('\t' * ind, (root,node['prob']))
        print('--->', node['path']['rule'])

四、句法分析詳例解讀

給定以下 PCFG,實現句子“fish people fish tanks ”最可能的統計句法樹,並將最終樹以串形式或樹形式列印。

第一層,葉結點的計算結果,得到葉節點的語法概率以及分裂點。

fish----> 'V': {'prob': 0.6, 'path': {'split': None, 'rule': 'fish'}},'N': {'prob': 0.2, 'path': {'split': None, 'rule': 'fish'}}, 'NP': {'prob': 0.13999999999999999, 'path': {'split': 0, 'rule': 'N'}},'VP': {'prob': 0.06, 'path': {'split': 0, 'rule': 'V'}}}
 
people---> 'V': {'prob': 0.1, 'path': {'split': None, 'rule': 'people'}},  'N': {'prob': 0.5, 'path': {'split': None, 'rule': 'people'}}, 'NP': {'prob': 0.35, 'path': {'split': 1, 'rule': 'N'}},'VP': {'prob':0.010000000000000002, 'path': {'split': 1, 'rule': 'V'}}}
 
fish---> 'V': {'prob': 0.6, 'path': {'split': None, 'rule': 'fish'}}, 'N': {'prob': 0.2, 'path': {'split': None, 'rule': 'fish'}}, 'NP': {'prob': 0.13999999999999999, 'path': {'split': 2, 'rule': 'N'}},'VP': {'prob': 0.06, 'path': {'split': 2, 'rule': 'V'}}}
 
tanks---> 'V': {'prob': 0.3, 'path': {'split': None, 'rule': 'tanks'}},  'N': {'prob': 0.2, 'path': {'split': None, 'rule': 'tanks'}}, 'NP': {'prob': 0.13999999999999999, 'path': {'split': 3, 'rule': 'N'}},'VP': {'prob': 0.03, 'path': {'split': 3, 'rule': 'V'}}}

直觀地展示就是:

非葉節點層,通過CYK演算法,自底向上計算非葉節點,儲存了各個規則的最大概率以及分裂點。

score[ 0 ][ 1 ]: { 'NP': {'prob': 0.004899999999999999, 'path': {'split': 0, 'rule': ('NP', 'NP')}}, 'S': {'prob': 0.0012600000000000003, 'path': {'split': 0, 'rule': ('NP', 'VP')}}, 'VP': {'prob': 0.105, 'path': {'split': 0, 'rule': ('V', 'NP')}}}
 
score[ 1 ][ 2 ]: {'NP': {'prob': 0.004899999999999999, 'path': {'split': 1, 'rule': ('NP', 'NP')}}, 'S': {'prob': 0.0189, 'path': {'split': 1, 'rule': ('NP', 'VP')}}, 'VP': {'prob': 0.006999999999999999, 'path': {'split': 1, 'rule': ('V', 'NP')}}}
 
score[ 2 ][ 3 ]: {'NP': {'prob': 0.0019599999999999995, 'path': {'split': 2, 'rule': ('NP', 'NP')}}, 'S': {'prob': 0.00378, 'path': {'split': 2, 'rule': ('NP', 'VP')}}, 'VP': {'prob': 0.041999999999999996, 'path': {'split': 2, 'rule': ('V', 'NP')}}}
 
score[ 0 ][ 2 ]: {'NP': {'prob': 6.859999999999997e-05, 'path': {'split': 0, 'rule': ('NP', 'NP')}}, 'S': {'prob': 0.0008819999999999999, 'path': {'split': 0, 'rule': ('NP', 'VP')}},'VP': {'prob': 0.0014699999999999997, 'path': {'split': 0, 'rule': ('V', 'NP')}}}
 
score[ 1 ][ 3 ]: {'NP': {'prob': 6.859999999999997e-05, 'path': {'split': 1, 'rule': ('NP', 'NP')}}, 'S': {'prob': 0.013229999999999999, 'path': {'split': 1, 'rule': ('NP', 'VP')}},'VP': {'prob': 9.799999999999998e-05, 'path': {'split': 1, 'rule': ('V', 'NP')}}}
 
score[ 0 ][ 3 ]: {'NP': {'prob': 9.603999999999995e-07, 'path': {'split': 0, 'rule': ('NP', 'NP')}}, 'S': {'prob': 0.00018521999999999994, 'path': {'split': 1, 'rule': ('NP', 'VP')}}, 'V': {'prob': 0, 'path': None}, 'VP': {'prob': 2.0579999999999993e-05, 'path': {'split': 0, 'rule': ('V', 'NP')}}

從根節點的開始標誌S出發,按照之前保留的路徑找出概率最大句法樹。下圖為直觀的回溯過程。

回看實際的資料儲存結構,我們已經將路徑path儲存在字典中,以及回溯的rule和分裂點,這樣就在程式實現操作比較容易實現。

五、實驗結果:語法樹結構

結果與實際手寫推算一致,畫出的語法樹為:

六、分析總結

  本篇實現了基於概率上下文無關文法(PCFG)的統計句法分析,使用的演算法是CYK演算法。本篇記錄詳細步驟python實現使用CYK演算法對上下無關文法(PCFG)的句法分析,通過核心演算法講解深入理解統計句法分析的思想並掌握具體演算法程式碼實現,得到一個句子的語法樹。

  在給定的PCFG語法規則,實現對特定句子的句法分析,得到最可能的統計句法樹,首先用程式實現,需要找到合適的資料結構對語法規則和概率,非終端符和終端符進行儲存,所以我才用了字典和列表兩種結果儲存資料。第二步,核心演算法CYK的具體實現,這也是對以上資料結構中資料的操作計算過程,對於本作業,還需處理一元規則,使用到擴充套件的CYK演算法。第三步,通過CYK演算法,得到了最佳路徑,需要根據分裂點通過回溯輸出最終的語法樹

  在完成核心部分CYK的過程,遇到了許多問題,主要容易出錯的地方包括:葉節點語法規則加入到字典中、非葉節點最大概率的規則加入和不同分裂點的儲存、回溯路徑樹結構的結果輸出。這三個部分重點在於邊界處理,遇到過溢位和陣列越界、值為空等問題,這導致在回溯樹時候會出現問題,所以,為了解決以上問題,正確設定斷點,單步除錯程式是很重要有效的排錯方法,我不斷進行除錯,在重要語句設定斷點觀察程式執行情況,修正程式的bug,優化演算法結構,保證清晰的程式思路,最終得到正確的結果。相對來說,這次選擇了程式實現方式,花費的時間較長,但是在不斷除錯debug過程,對整個CYK演算法的思想有了更加深刻地理解。


我的部落格園:https://www.cnblogs.com/chenzhenhong/p/14028527.html

我的CSDN部落格:https://blog.csdn.net/Charzous/article/details/109671138