語法分析器描述了一個句子的語法結構,用來幫助其他的應用進行推理。自然語言引入了很多意外的歧義,以我們對世界的瞭解可以迅速地發現這些歧義。舉一個我很喜歡的例子:
They ate the pizza with anchovies
正確的解析是連線“with”和“pizza”,而錯誤的解析將“with”和“eat”聯絡在了一起:
過去的一些年,自然語言處理(NLP)社群在語法分析方面取得了很大的進展。現在,小小的 Python 實現可能比廣泛應用的 Stanford 解析器表現得更出色。
解析器 準確度 速度(詞/秒) 語言 程式碼行數
Stanford 89.6% 19 Java > 50,000[1]
parser.py 89.8% 2,020 Python ~500
Redshift 93.6% 2,580 Cython ~4,000
文章剩下的部分首先設定了問題,接著帶你瞭解為此準備的簡潔實現。parser.py 程式碼中的前 200 行描述了詞性的標註者和學習者(這裡)。除非你非常熟悉 NLP 方向的研究,否則在研究這篇文章之前至少應該略讀。
Cython 系統和 Redshift 是為我目前的研究而寫的。和麥考瑞大學的合同到期後,我計劃六月份對它進行改進,用於一般用途。目前的版本託管在 GitHub 上。
問題描述
在你的手機中輸入這樣一條指令是非常友善的:
Set volume to zero when I’m in a meeting, unless John’s school calls.
接著進行適當的策略配置。在 Android 系統上,你可以應用 Tasker 做這樣的事情,而 NL 介面會更好一些。接收可以編輯的語義表示,你就能瞭解到它認為你表達的意思,並且可以修正他的想法,這樣是特別友善的。
這項工作有很多問題需要解決,但一些種類的句法形態絕對是必要的。我們需要知道:
Unless John’s school calls, when I’m in a meeting, set volume to zero
是解析指令的又一種方式,而
Unless John’s school, call when I’m in a meeting
表達了完全不同的意思。
依賴解析器返回一個單詞與單詞間的關係圖,使推理變得更容易。關係圖是樹形結構,有向邊,每個節點(單詞)有且僅有一個入弧(頭部依賴)。
用法示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
>>> parser = parser.Parser() >>> tokens = "Set the volume to zero when I 'm in a meeting unless John 's school calls".split() >>> tags, heads = parser.parse(tokens) >>> heads [-1, 2, 0, 0, 3, 0, 7, 5, 7, 10, 8, 0, 13, 15, 15, 11] >>> for i, h in enumerate(heads): ... head = tokens[heads[h]] if h >= 1 else 'None' ... print(tokens[i] + ' <-- ' + head]) Set <-- None the <-- volume volume <-- Set to <-- Set zero <-- to when <-- Set I <-- 'm 'm <-- when in <-- 'm a <-- meeting meeting <-- in unless <-- Set John <-- 's 's <-- calls school <-- calls calls <-- unless |
一種觀點是通過語法分析進行推導比字串應該稍稍容易一些。語義分析對映有望比字面意義對映更簡單。
這個問題最讓人困惑的是正確性是由慣例,即註釋指南決定的。如果你沒有閱讀指南並且不是一個語言學家,就不能判斷解析是否正確,這使整個任務顯得奇怪和虛假。
例如,在上面的解析中存在一個錯誤:根據 Stanford 的註釋指南規定,“John’s school calls” 存在結構錯誤。而句子這部分的結構是指導註釋器如何解析一個類似於“John’s school clothes”的例子。
這一點值得深入考慮。理論上講,我們已經制定了準則,所以“正確”的解析應該相反。如果我們違反約定,有充分的理由相信解析任務會變得更加困難,因為任務和其他語>法的一致性會降低。【2】但是我們可以測試經驗,並且我們很高興通過反轉策略獲得優勢。
我們確實需要慣例中的差異——我們不希望接收相同的結構,否則結果不會很有用。註釋指南在哪些區別使下游應用有效和哪些解析器可以輕鬆預測之間取得平衡。
對映樹
在決定構建什麼樣子的關係圖時,我們可以進行一項特別有效的簡化:對將要處理的關係圖結構進行限制。它不僅在易學性方面有優勢,在加深演算法理解方面也有作用。大部分的>英文解析工作中,我們遵循約束的依賴關係圖就是對映樹:
- 樹。除了根外,每個單詞都有一個弧頭。
- 對映關係。針對每對依賴關係 (a1, a2)和 (b1, b2),如果 a1 < b2, 那麼 a2 >= b2。換句話說,依賴關係不能交叉。不可能存在一對 a1 b1 a2 b2 或者 b1 a1 b2 a2 形式的依賴關係。
在解析非對映樹方面有豐富的文獻,解析無環有向圖方面的文獻相對而言少一些。我將要闡述的解析演算法用於對映樹領域。
貪婪的基於轉換的解析
我們的語法分析器以字串符號列表作為輸入,輸出代表關係圖中邊的弧頭索引列表。如果第 i 個弧頭元素是 j, 依賴關係包括一條邊 (j, i)。基於轉換的語法分析器>是有限狀態轉換器;它將 N 個單詞的陣列對映到 N 個弧頭索引的輸出陣列。
start MSNBC reported that Facebook bought WhatsApp for $16bn root
0 2 9 2 4 2 4 4 7 0
弧頭陣列表示了 MSNBC 的弧頭:MSNBC 的單詞索引是1,reported 的單詞索引是2, head[1] == 2。你應該已經發現為什麼樹形結構如此方便——如果我們輸出一個 DAG 結構,這種結構中的單詞可能包含多個弧頭,樹形結構將不再工作。
雖然 heads 可以表示為一個陣列,我們確實喜歡保持一定的替代方式來訪問解析,以方便高效的提取特徵。Parse 類就是這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Parse(object): def __init__(self, n): self.n = n self.heads = [None] * (n-1) self.lefts = [] self.rights = [] for i in range(n+1): self.lefts.append(DefaultList(0)) self.rights.append(DefaultList(0)) def add_arc(self, head, child): self.heads[child] = head if child < head: self.lefts[head].append(child) else: self.rights[head].append(child) |
和語法解析一樣,我們也需要跟蹤句子中的位置。我們通過在 words 陣列中置入一個索引和引入棧機制實現,棧中可以壓入單詞,設定單詞的弧頭時,彈出單詞。所以我們的狀態資料結構是基礎。
- 一個索引 i, 活動於符號列表中
- 到現在為止語法解析器中的加入的依賴關係
- 一個包含索引 i 之前產生的單詞的棧,我們已為這些單詞宣告瞭弧頭。
解析過程的每一步都應用了三種操作之一:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
SHIFT = 0; RIGHT = 1; LEFT = 2 MOVES = [SHIFT, RIGHT, LEFT] def transition(move, i, stack, parse): global SHIFT, RIGHT, LEFT if move == SHIFT: stack.append(i) return i + 1 elif move == RIGHT: parse.add_arc(stack[-2], stack.pop()) return i elif move == LEFT: parse.add_arc(i, stack.pop()) return i raise GrammarError("Unknown move: %d" % move) |
LEFT 和 RIGHT 操作新增依賴關係並彈棧,而 SHIFT 壓棧並增加快取中 i 值。
因此,語法解析器以一個空棧開始,快取索引為0,沒有依賴關係記錄。選擇一個有效的操作,應用到當前狀態。繼續選擇操作並應用直到棧為空且快取索引到達輸入陣列的終點。(沒有逐步跟蹤是很難理解這種演算法的。嘗試準備一個句子,畫出對映解析樹,接著通過選擇正確的轉換序列遍歷完解析樹。)
下面是程式碼中的解析迴圈:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
class Parser(object): ... def parse(self, words): tags = self.tagger(words) n = len(words) idx = 1 stack = [0] deps = Parse(n) while stack or idx < n: features = extract_features(words, tags, idx, n, stack, deps) scores = self.model.score(features) valid_moves = get_valid_moves(i, n, len(stack)) next_move = max(valid_moves, key=lambda move: scores[move]) idx = transition(next_move, idx, stack, parse) return tags, parse def get_valid_moves(i, n, stack_depth): moves = [] if i < n: moves.append(SHIFT) if stack_depth >= 2: moves.append(RIGHT) if stack_depth >= 1: moves.append(LEFT) return moves |
我們以標記的句子開始,進行狀態初始化。然後將狀態對映到一個採用線性模型評分的特徵集合。接著尋找得分最高的有效操作,應用到狀態中。
這裡的評分模型和詞性標註中的一樣工作。如果對提取特徵和使用線性模型評分的觀點感到困惑,你應該複習這篇文章。下面是評分模型如何工作的提示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Perceptron(object) ... def score(self, features): all_weights = self.weights scores = dict((clas, 0) for clas in self.classes) for feat, value in features.items(): if value == 0: continue if feat not in all_weights: continue weights = all_weights[feat] for clas, weight in weights.items(): scores[clas] += value * weight return scores |
這裡僅僅對每個特徵的類權重求和。這通常被表示為一個點積,然而我發現處理很多類時就不太適合了。
定向解析器(RedShift)遍歷多個候選元素,但最終只會選擇最好的一個。我們將關注效率和簡便而忽略其準確性。我們只進行了單一的分析。我們的搜尋策略將是完全貪婪的,就像詞性標記一樣。我們將鎖定在選擇的每一步。
如果認真閱讀了詞性標記,你可能會發現下面的相似性。我們所做的是將解析問題對映到一個使用“扁平化”解決的序列標記問題,或者非結構化的學習演算法(通過貪婪搜尋)。
特徵集
特徵提取程式碼總是很醜陋。語法分析器的特徵指的是上下文中的一些標識。
- 快取中的前三個單詞 (n0, n1, n2)
- 堆疊中的棧頂的三個單詞 (s0, s1, s2)
- s0 最左邊的兩個孩子 (s0b1, s0b2);
- s0 最右邊的兩個孩子 (s0f1, s0f2);
- n0 最左邊的兩個孩子 (n0b1, n0b2);
我們指出了上述12個標識的單詞表,詞性標註,和標識關聯的左右孩子數目。
因為使用的是線性模型,特徵指的是原子屬性組成的三元組。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
def extract_features(words, tags, n0, n, stack, parse): def get_stack_context(depth, stack, data): if depth >;= 3: return data[stack[-1]], data[stack[-2]], data[stack[-3]] elif depth >= 2: return data[stack[-1]], data[stack[-2]], '' elif depth == 1: return data[stack[-1]], '', '' else: return '', '', '' def get_buffer_context(i, n, data): if i + 1 >= n: return data[i], '', '' elif i + 2 >= n: return data[i], data[i + 1], '' else: return data[i], data[i + 1], data[i + 2] def get_parse_context(word, deps, data): if word == -1: return 0, '', '' deps = deps[word] valency = len(deps) if not valency: return 0, '', '' elif valency == 1: return 1, data[deps[-1]], '' else: return valency, data[deps[-1]], data[deps[-2]] features = {} # Set up the context pieces --- the word, W, and tag, T, of: # S0-2: Top three words on the stack # N0-2: First three words of the buffer # n0b1, n0b2: Two leftmost children of the first word of the buffer # s0b1, s0b2: Two leftmost children of the top word of the stack # s0f1, s0f2: Two rightmost children of the top word of the stack depth = len(stack) s0 = stack[-1] if depth else -1 Ws0, Ws1, Ws2 = get_stack_context(depth, stack, words) Ts0, Ts1, Ts2 = get_stack_context(depth, stack, tags) Wn0, Wn1, Wn2 = get_buffer_context(n0, n, words) Tn0, Tn1, Tn2 = get_buffer_context(n0, n, tags) Vn0b, Wn0b1, Wn0b2 = get_parse_context(n0, parse.lefts, words) Vn0b, Tn0b1, Tn0b2 = get_parse_context(n0, parse.lefts, tags) Vn0f, Wn0f1, Wn0f2 = get_parse_context(n0, parse.rights, words) _, Tn0f1, Tn0f2 = get_parse_context(n0, parse.rights, tags) Vs0b, Ws0b1, Ws0b2 = get_parse_context(s0, parse.lefts, words) _, Ts0b1, Ts0b2 = get_parse_context(s0, parse.lefts, tags) Vs0f, Ws0f1, Ws0f2 = get_parse_context(s0, parse.rights, words) _, Ts0f1, Ts0f2 = get_parse_context(s0, parse.rights, tags) # Cap numeric features at 5? # String-distance Ds0n0 = min((n0 - s0, 5)) if s0 != 0 else 0 features['bias'] = 1 # Add word and tag unigrams for w in (Wn0, Wn1, Wn2, Ws0, Ws1, Ws2, Wn0b1, Wn0b2, Ws0b1, Ws0b2, Ws0f1, Ws0f2): if w: features['w=%s' % w] = 1 for t in (Tn0, Tn1, Tn2, Ts0, Ts1, Ts2, Tn0b1, Tn0b2, Ts0b1, Ts0b2, Ts0f1, Ts0f2): if t: features['t=%s' % t] = 1 # Add word/tag pairs for i, (w, t) in enumerate(((Wn0, Tn0), (Wn1, Tn1), (Wn2, Tn2), (Ws0, Ts0))): if w or t: features['%d w=%s, t=%s' % (i, w, t)] = 1 # Add some bigrams features['s0w=%s, n0w=%s' % (Ws0, Wn0)] = 1 features['wn0tn0-ws0 %s/%s %s' % (Wn0, Tn0, Ws0)] = 1 features['wn0tn0-ts0 %s/%s %s' % (Wn0, Tn0, Ts0)] = 1 features['ws0ts0-wn0 %s/%s %s' % (Ws0, Ts0, Wn0)] = 1 features['ws0-ts0 tn0 %s/%s %s' % (Ws0, Ts0, Tn0)] = 1 features['wt-wt %s/%s %s/%s' % (Ws0, Ts0, Wn0, Tn0)] = 1 features['tt s0=%s n0=%s' % (Ts0, Tn0)] = 1 features['tt n0=%s n1=%s' % (Tn0, Tn1)] = 1 # Add some tag trigrams trigrams = ((Tn0, Tn1, Tn2), (Ts0, Tn0, Tn1), (Ts0, Ts1, Tn0), (Ts0, Ts0f1, Tn0), (Ts0, Ts0f1, Tn0), (Ts0, Tn0, Tn0b1), (Ts0, Ts0b1, Ts0b2), (Ts0, Ts0f1, Ts0f2), (Tn0, Tn0b1, Tn0b2), (Ts0, Ts1, Ts1)) for i, (t1, t2, t3) in enumerate(trigrams): if t1 or t2 or t3: features['ttt-%d %s %s %s' % (i, t1, t2, t3)] = 1 # Add some valency and distance features vw = ((Ws0, Vs0f), (Ws0, Vs0b), (Wn0, Vn0b)) vt = ((Ts0, Vs0f), (Ts0, Vs0b), (Tn0, Vn0b)) d = ((Ws0, Ds0n0), (Wn0, Ds0n0), (Ts0, Ds0n0), (Tn0, Ds0n0), ('t' + Tn0+Ts0, Ds0n0), ('w' + Wn0+Ws0, Ds0n0)) for i, (w_t, v_d) in enumerate(vw + vt + d): if w_t or v_d: features['val/d-%d %s %d' % (i, w_t, v_d)] = 1 return features |
訓練
學習權重和詞性標註使用了相同的演算法,即平均感知器演算法。它的主要優勢是,它是一個線上學習演算法:例子一個接一個流入,我們進行預測,檢查真實答案,如果預測錯誤則調整意見(權重)。
迴圈訓練看起來是這樣的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class Parser(object): ... def train_one(self, itn, words, gold_tags, gold_heads): n = len(words) i = 2; stack = [1]; parse = Parse(n) tags = self.tagger.tag(words) while stack or (i + 1) < n: features = extract_features(words, tags, i, n, stack, parse) scores = self.model.score(features) valid_moves = get_valid_moves(i, n, len(stack)) guess = max(valid_moves, key=lambda move: scores[move]) gold_moves = get_gold_moves(i, n, stack, parse.heads, gold_heads) best = max(gold_moves, key=lambda move: scores[move]) self.model.update(best, guess, features) i = transition(guess, i, stack, parse) # Return number correct return len([i for i in range(n-1) if parse.heads[i] == gold_heads[i]]) |
訓練過程中最有趣的部分是 get_gold_moves。 通過Goldbery 和 Nivre (2012),我們的語法解析器的效能可能會有所提升,他們曾指出我們錯了很多年。
在詞性標註文章中,我提醒大家,在訓練期間,你要確保傳遞的是最後兩個預測標記做為當前標記的特徵,而不是最後兩個黃金標記。測試期間只有預測標記,如果特徵是基於訓練過程中黃金序列的,訓練環境就不會和測試環境保持一致,因此將會得到錯誤的權重。
在語法分析中我們面臨的問題是不知道如何傳遞預測序列!通過採用黃金標準樹結構,並發現可以轉換為樹的過渡序列,等等,使得訓練得以工作,你獲得返回的動作序列,保證執行運動,將得到黃金標準的依賴關係。
問題是,如果語法分析器處於任何沒有沿著黃金標準序列的狀態時,我們不知道如何教它做出的“正確”運動。一旦語法分析器發生了錯誤,我們不知道如何從例項中訓練。
這是一個大問題,因為這意味著一旦語法分析器開始發生錯誤,它將停止在不屬於訓練資料的任何一種狀態——導致出現更多的錯誤。
對於貪婪解析器而言,問題是具體的:一旦使用方向特性,有一種自然的方式做結構化預測。
像所有的最佳突破一樣,一旦你理解了這些,解決方案似乎是顯而易見的。我們要做的就是定義一個函式,此函式提問“有多少黃金標準依賴關係可以從這種狀態恢復”。如果能定義這個函式,你可以依次進行每種運動,進而提問,“有多少黃金標準依賴關係可以從這種狀態恢復?”。如果採用的操作可以讓少一些的黃金標準依賴實現,那麼它就是次優的。
這裡需要領會很多東西。
因此我們有函式 Oracle(state):
Oracle(state) = | gold_arcs ∩ reachable_arcs(state) |
我們有一個操作集合,每種操作返回一種新狀態。我們需要知道:
- shift_cost = Oracle(state) – Oracle(shift(state))
- right_cost = Oracle(state) – Oracle(right(state))
- left_cost = Oracle(state) – Oracle(left(state))
現在,至少一種操作返回0。Oracle(state)提問:“前進的最佳路徑的成本是多少?”最佳路徑的第一步是轉移,向右,或者向左。
事實證明,我們可以得出 Oracle 簡化了很多過渡系統。我們正在使用的過渡系統的衍生品 —— Arc Hybrid 是 Goldberg 和 Nivre (2013)提出的。
我們把oracle實現為一個返回0-成本的運動的方法,而不是實現一個功能的Oracle(state)。這可以防止我們做一堆昂貴的複製操作。希望程式碼中的推理不是太難以理解,如果感到困惑並希望刨根問底的花,你可以參考 Goldberg 和 Nivre 的論文。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
def get_gold_moves(n0, n, stack, heads, gold): def deps_between(target, others, gold): for word in others: if gold[word] == target or gold[target] == word: return True return False valid = get_valid_moves(n0, n, len(stack)) if not stack or (SHIFT in valid and gold[n0] == stack[-1]): return [SHIFT] if gold[stack[-1]] == n0: return [LEFT] costly = set([m for m in MOVES if m not in valid]) # If the word behind s0 is its gold head, Left is incorrect if len(stack) >= 2 and gold[stack[-1]] == stack[-2]: costly.add(LEFT) # If there are any dependencies between n0 and the stack, # pushing n0 will lose them. if SHIFT not in costly and deps_between(n0, stack, gold): costly.add(SHIFT) # If there are any dependencies between s0 and the buffer, popping # s0 will lose them. if deps_between(stack[-1], range(n0+1, n-1), gold): costly.add(LEFT) costly.add(RIGHT) return [m for m in MOVES if m not in costly] |
進行“動態 oracle”訓練過程會產生很大的精度差異——通常為1-2%,和執行時的方式沒有區別。舊的“靜態oracle”貪婪訓練過程已經完全過時;沒有任何理由那樣做了。
總結
我感覺,語言技術,特別是那些相關語法,特別神祕。我不能想象什麼樣的程式可以實現。
我認為對於人們來說,最好的解決方案可能相當複雜是很自然的。200,000 行的Java包感覺為宜。
但是,僅僅實現一個單一演算法時,演算法程式碼往往很短。當你只實現一種演算法時,在寫之前你確實知道要寫什麼,你不需要關注任何不必要的具有很大效能影響的抽象概念。
註釋
[1] 我真的不確定如何計算Stanford解析器的程式碼行數。它的jar檔案裝載了200k大小內容,包括大量不同的模型。這並不重要,但在50k左右似乎是安全的。
[2]例如,如何解析“John’s school of music calls”?你需要確認“John’s school”短語和“John’s school calls”、“John’s school of music calls”有相同的結構。對可以放入短語的不同的“插槽”進行推理是我們推理句法分析的關鍵途徑。你能想到每個短語為具有不同形狀的聯結器,你需要插入不同的插槽——每個短語也有一定數量不同形狀的插槽。我們正試圖弄清楚什麼樣的聯結器在什麼地方,因此可以搞清句子是如何連線在一起的。
[3]這裡有使用了“深度學習”技術的 Stanford 解析器更新版本,此版本準確性更高。但是,最終模型的準確度仍排在最好的移進歸約分析器後面。這是一篇偉大的文章,該想法在一個語法分析器上實現,這個語法分析器是不是最先進其實並不重要。
[4]一個細節:Stanford 依賴關係實際上是給定黃金標準短語結構樹自動生成的。參考這裡的Stanford依賴轉換器頁面:http://nlp.stanford.edu/software/stanford-dependencies.shtml。
無根據猜測
長期以來,增量語言處理演算法是科學界的主要興趣。如果你想要編寫一個語法分析器來測試人類語句處理器如何工作的理論,那麼,這個分析器需要建立部分直譯器。這裡有充分的證據,包括常識性反思,它設立我們不快取的輸入,說話者完成表達立即分析。
但與整齊的科學特徵相比,當前演算法勝出!盡我所能告訴大家,勝出的祕訣就是:
- 增量。早期的文字限制搜尋。
- 錯誤驅動。訓練包含一個發生錯誤即更新的操作假設。
和人類語句處理的聯絡看起來誘人。我期待看到這些工程的突破是否帶來一些心理語言學方面的進步。
參考書目
NLP 的文獻幾乎完全開放。所有相關論文都可以在這裡找到:http://aclweb.org/anthology/。
我所描述的解析器是動態oracle arc-hybrid 系統的實現:
Goldberg, Yoav; Nivre, Joakim
Training Deterministic Parsers with Non-Deterministic Oracles
TACL 2013
然而,我編寫了自己的特徵。arc-hybrid 系統的最初描述在這裡:
Kuhlmann, Marco; Gomez-Rodriguez, Carlos; Satta, Giorgio
Dynamic programming algorithms for transition-based dependency parsers
ACL 2011
這裡最初描述了動態oracle訓練方法:
A Dynamic Oracle for Arc-Eager Dependency Parsing
Goldberg, Yoav; Nivre, Joakim
COLING 2012
當Zhang 和 Clark 研究定向搜尋時,這項工作依賴於以轉換為基礎的解析器在準確性上的重大突破。他們發表了很多論文,但首選的引用是:
Zhang, Yue; Clark, Steven
Syntactic Processing Using the Generalized Perceptron and Beam Search
Computational Linguistics 2011 (1)
另外一篇重要的文章是這個短篇的特徵工程文章,這篇文章進一步提高了準確性:
Zhang, Yue; Nivre, Joakim
Transition-based Dependency Parsing with Rich Non-local Features
ACL 2011
作為定向解析器的學習框架,廣義的感知器來自這篇文章
Collins, Michael
Discriminative Training Methods for Hidden Markov Models: Theory and Experiments with Perceptron Algorithms
EMNLP 2002
實驗細節
文章開頭的結果引用了華爾街日報語料庫第22條。Stanford 解析器執行如下:
1 2 |
java -mx10000m -cp "$scriptdir/*:" edu.stanford.nlp.parser.lexparser.LexicalizedParser \ -outputFormat "penn" edu/stanford/nlp/models/lexparser/englishFactored.ser.gz $* |
應用了一個小的後處理,撤銷Stanford 解析器為數字新增的假設標記,使數字符合 PTB 標記:
1 2 3 4 5 6 7 8 9 10 11 |
"""Stanford parser retokenises numbers. Split them.""" import sys import re qp_re = re.compile('\xc2\xa0') for line in sys.stdin: line = line.rstrip() if qp_re.search(line): line = line.replace('(CD', '(QP (CD', 1) + ')' line = line.replace('\xc2\xa0', ') (CD ') print line |
由此產生的PTB格式的檔案轉換成使用 Stanford 轉換器的依賴關係:
1 2 3 4 5 6 7 |
for f in $1/*.mrg; do echo $f grep -v CODE $f > "$f.2" out="$f.dep" java -mx800m -cp "$scriptdir/*:" edu.stanford.nlp.trees.EnglishGrammaticalStructure \ -treeFile "$f.2" -basic -makeCopulaHead -conllx > $out done |
我不能輕易的讀取了,但它應該只是使用相關文獻的一般設定,將一個目錄下的每個.mrg檔案轉換成一個CoNULL格式的 Stanford 基本依賴檔案。
接著我從華爾街日報語料庫第22條轉換了黃金標準樹進行評估。準確的分數是指所有未標記標識中未標記的附屬分數(如弧頭索引)
為了訓練 parser.py,我將華爾街日報語料庫 02-21 的黃金標準 PTB 樹結構輸出到同一個轉換指令碼中。
一言以蔽之,Stanford 模型和 parser.py 在同一組語句中進行訓練,在我們知道答案的持有測試集上進行預測。準確性是指我們答對多少正確的語句首詞。
在一個 2.4Ghz 的 Xeon 處理器上測試速度。我在伺服器上進行了實驗,為 Stanford 解析器提供了更多記憶體。parser.py 系統在我的MacBook Air上執行良好。在parser.py 的實驗中,我使用了PyPy;與早期的基準相比,CPython大約快了一半。
parser.py 執行如此之快的一個原因是它進行未標記解析。根據以往的實驗,經標記的解析器可能慢400倍,準確度提高大約1%。如果你能訪問資料,使程式適應已標記的解析器對讀者來說將是很好的鍛鍊機會。
RedShift 解析器的結果是從版本 b6b624c9900f3bf 取出的,執行如下:
1 2 3 |
./scripts/train.py -x zhang+stack -k 8 -p ~/data/stanford/train.conll ~/data/parsers/tmp ./scripts/parse.py ~/data/parsers/tmp ~/data/stanford/devi.txt /tmp/parse/ ./scripts/evaluate.py /tmp/parse/parses ~/data/stanford/dev.conll |