在本期文章中,小生向您介紹了自然語言工具包(Natural Language Toolkit),它是一個將學術語言技術應用於文本數據集的 Python 庫。稱為“文本處理”的程序設計是其基本功能;更深入的是專門用於研究自然語言的語法以及語義分析的能力。
鄙人並非見多識廣, 語言處理(linguistic processing) 是一個相對新奇的領域。如果在對意義非凡的自然語言工具包(NLTK)的說明中出現了錯誤,請您諒解。NLTK 是使用 Python 教學以及實踐計算語言學的極好工具。此外,計算語言學與人工智能、語言/專門語言識別、翻譯以及語法檢查等領域關系密切。
NLTK 會被自然地看作是具有棧結構的一系列層,這些層構建於彼此基礎之上。那些熟悉人工語言(比如 Python)的文法和解析的讀者來說,理解自然語言模型中類似的 —— 但更深奧的 —— 層不會有太大困難。
盡管 NLTK 附帶了很多已經預處理(通常是手工地)到不同程度的全集,但是概念上每一層都是依賴於相鄰的更低層次的處理。首先是斷詞;然后是為單詞加上 標簽;然后將成組的單詞解析為語法元素,比如名詞短語或句子(取決於幾種技術中的某一種,每種技術都有其優缺點);最后對最終語句或其他語法單元進行分類。通過這些步驟,NLTK 讓您可以生成關於不同元素出現情況的統計,並畫出描述處理過程本身或統計合計結果的圖表。
在本文中,您將看到關於低層能力的一些相對完整的示例,而對大部分高層次能力將只是進行簡單抽象的描述。現在讓我們來詳細分析文本處理的首要步驟。
斷詞(Tokenization)
您可以使用 NLTK 完成的很多工作,尤其是低層的工作,與使用 Python 的基本數據結構來完成相比,並沒有太大的區別。不過,NLTK 提供了一組由更高的層所依賴和使用的系統化的接口,而不只是簡單地提供實用的類來處理加過標志或加過標簽的文本。具體講,nltk.tokenizer.Token
類被廣泛地用於存儲文本的有注解的片斷;這些注解可以標記很多不同的特性,包括詞類(parts-of-speech)、子標志(subtoken)結構、一個標志(token)在更大文本中的偏移位置、語形詞干(morphological stems)、文法語句成分,等等。實際上,一個 Token
是一種特別的字典 —— 並且以字典形式訪問 —— 所以它可以容納任何您希望的鍵。在 NLTK 中使用了一些專門的鍵,不同的鍵由不同的子程序包所使用。
讓我們來簡要地分析一下如何創建一個標志並將其拆分為子標志:
1 >>> from nltk.tokenize import * 2 3 >>> t = Token(TEXT='This is my first test sentence') 4 5 >>> WSTokenizer().tokenize(t, addlocs=True) # break on whitespace 6 7 >>> print t['TEXT'] This is my first test sentence 8 9 >>> print t['SUBTOKENS'] [@[0:4c], @[5:7c], @[8:10c], @[11:16c], @[17:21c], @[22:30c]] 10 11 >>> t['foo'] = 'bar' 12 13 >>> t @[0:4c], @[5:7c], @[8:10c], @[11:16c], @[17:21c], @[22:30c]]> 14 15 >>> print t['SUBTOKENS'][0] @[0:4c] 16 17 >>> print type(t['SUBTOKENS'][0])
概率(Probability)
對於語言全集,您可能要做的一件相當簡單的事情是分析其中各種 事件(events) 的頻率分布,並基於這些已知頻率分布做出概率預測。NLTK 支持多種基於自然頻率分布數據進行概率預測的方法。我將不會在這里介紹那些方法(參閱 參考資料 中列出的概率教程),只要說明您肯定會 期望的那些與您已經 知道的 那些(不止是顯而易見的縮放比例/正規化)之間有着一些模糊的關系就夠了。
基本來講,NLTK 支持兩種類型的頻率分布:直方圖和條件頻率分布(conditional frequency)。 nltk.probability.FreqDist
類用於創建直方圖;例如,可以這樣創建一個單詞直方圖:
>>> from nltk.probability import * >>> article = Token(TEXT=open('cp-b17.txt').read()) >>> WSTokenizer().tokenize(article) >>> freq = FreqDist() >>> for word in article['SUBTOKENS']: ... freq.inc(word['TEXT']) >>> freq.B() 1194 >>> freq.count('Python') 12
概率教程討論了關於更復雜特性的直方圖的創建,比如“以元音結尾的詞后面的詞的長度”。 nltk.draw.plot.Plot
類可用於直方圖的可視化顯示。當然,您也可以這樣分析高層次語法特性或者甚至是與 NLTK 無關的數據集的頻率分布。條件頻率分布可能比普通的直方圖更有趣。條件頻率分布是一種二維直方圖 —— 它按每個初始條件或者“上下文”為您顯示一個直方圖。例如,教程提出了一個對應每個首字母的單詞長度分布問題。我們就以這樣分析:
>>> cf = ConditionalFreqDist() >>> for word in article['SUBTOKENS']: ... cf[word['TEXT'][0]].inc(len(word['TEXT'])) ... >>> init_letters = cf.conditions() >>> init_letters.sort() >>> for c in init_letters[44:50]: ... print "Init %s:" % c, ... for length in range(1,6): ... print "len %d/%.2f," % (length,cf[c].freq(n)), ... print ... Init a: len 1/0.03, len 2/0.03, len 3/0.03, len 4/0.03, len 5/0.03, Init b: len 1/0.12, len 2/0.12, len 3/0.12, len 4/0.12, len 5/0.12, Init c: len 1/0.06, len 2/0.06, len 3/0.06, len 4/0.06, len 5/0.06, Init d: len 1/0.06, len 2/0.06, len 3/0.06, len 4/0.06, len 5/0.06, Init e: len 1/0.18, len 2/0.18, len 3/0.18, len 4/0.18, len 5/0.18, Init f: len 1/0.25, len 2/0.25, len 3/0.25, len 4/0.25, len 5/0.25,
條件頻率分布在語言方面的一個極好應用是分析全集中的語段分布 —— 例如,給出一個特定的詞,接下來最可能出現哪個詞。當然,語法會帶來一些限制;不過,對句法選項的選擇的研究屬於語義學、語用論和術語范疇。
詞干提取(Stemming)
nltk.stemmer.porter.PorterStemmer
類是一個用於從英文單詞中獲得符合語法的(前綴)詞干的極其便利的工具。這一能力尤其讓我心動,因為我以前曾經用 Python 創建了一個公用的、全文本索引的搜索工具/庫(見 Developing a full-text indexer in Python 中的描述,它已經用於相當多的其他項目中)。盡管對大量文檔進行關於一組確切詞的搜索的能力是非常實用的( gnosis.indexer
所做的工作),但是,對很多搜索用圖而言,稍微有一些模糊將會有所幫助。也許,您不能特別確定您正在尋找的電子郵件是否使用了單詞 “complicated”、“complications”、“complicating”或者“complicates”,但您卻記得那是大概涉及的內容(可能與其他一些詞共同來完成一次有價值的搜索)。
NLTK 中包括一個用於單詞詞干提取的極好算法,並且讓您可以按您的喜好定制詞干提取算法:
>>> from nltk.stemmer.porter import PorterStemmer >>> PorterStemmer().stem_word('complications') 'complic'
實際上,您可以怎樣利用 gnosis.indexer 及其衍生工具或者完全不同的索引工具中的詞干提取功能,取決於您的使用情景。幸運的是,gnosis.indexer 有一個易於進行專門定制的開放接口。您是否需要一個完全由詞干構成的索引?或者您是否在索引中同時包括完整的單詞和詞干?您是否需要將結果中的詞干匹配從確切匹配中分離出來?在未來版本的 gnosis.indexer 中我將引入一些種類詞干的提取能力,不過,最終用戶可能仍然希望進行不同的定制。
無論如何,一般來說添加詞干提取是非常簡單的:首先,通過特別指定 gnosis.indexer.TextSplitter
來從一個文檔中獲得詞干;然后,當然執行搜索時,(可選地)在使用搜索條件進行索引查找之前提取其詞干,可能是通過定制您的 MyIndexer.find()
方法來實現。
在使用 PorterStemmer
時我發現 nltk.tokenizer.WSTokenizer
類確實如教程所警告的那樣不好用。它可以勝任概念上的角色,但是對於實際的文本而言,您可以更好地識別出什么是一個 “單詞”。幸運的是, gnosis.indexer.TextSplitter
是一個健壯的斷詞工具。例如:
>>> from nltk.tokenizer import * >>> article = Token(TEXT=open('cp-b17.txt').read()) >>> WSTokenizer().tokenize(article) >>> from nltk.probability import * >>> from nltk.stemmer.porter import * >>> stemmer = PorterStemmer() >>> stems = FreqDist() >>> for word in article['SUBTOKENS']: ... stemmer.stem(word) ... stems.inc(word['STEM'].lower()) ... >>> word_stems = stems.samples() >>> word_stems.sort() >>> word_stems[20:40] ['"generator-bas', '"implement', '"lazili', '"magic"', '"partial', '"pluggable"', '"primitives"', '"repres',
'"secur', '"semi-coroutines."', '"state', '"understand', '"weightless', '"whatev', '#', '#-----', '#----------', '#-------------',
'#---------------', '#b17:']
查看一些詞干,集合中的詞干看起來並不是都可用於索引。很多根本不是實際的單詞,還有其他一些是用破折號連接起來的組合詞,單詞中還被加入了一些不相干的標點符號。讓我們使用更好的斷詞工具來進行嘗試:
>>> article = TS().text_splitter(open('cp-b17.txt').read()) >>> stems = FreqDist() >>> for word in article: ... stems.inc(stemmer.stem_word(word.lower())) ... >>> word_stems = stems.samples() >>> word_stems.sort() >>> word_stems[60:80] ['bool', 'both', 'boundari', 'brain', 'bring', 'built', 'but', 'byte', 'call', 'can', 'cannot', 'capabl', 'capit', 'carri', 'case', 'cast', 'certain', 'certainli', 'chang', 'charm']
在這里,您可以看到有一些單詞有多個可能的擴展,而且所有單詞看起來都像是單詞或者詞素。斷詞方法對隨機文本集合來說至關重要;公平地講,NLTK 捆綁的全集已經通過 WSTokenizer()
打包為易用且准確的斷詞工具。要獲得健壯的實際可用的索引器,需要使用健壯的斷詞工具。
添加標簽(tagging)、分塊(chunking)和解析(parsing)
NLTK 的最大部分由復雜程度各不相同的各種解析器構成。在很大程度上,本篇介紹將不會解釋它們的細節,不過,我願意大概介紹一下它們要達成什么目的。
不要忘記標志是特殊的字典這一背景 —— 具體說是那些可以包含一個 TAG
鍵以指明單詞的語法角色的標志。NLTK 全集文檔通常有部分專門語言已經預先添加了標簽,不過,您當然可以將您自己的標簽添加到沒有加標簽的文檔。
分塊有些類似於“粗略解析”。也就是說,分塊工作的進行,或者基於語法成分的已有標志,或者基於您手工添加的或者使用正則表達式和程序邏輯半自動生成的標志。不過,確切地說,這不是真正的解析(沒有同樣的生成規則)。例如:
>>> from nltk.parser.chunk import ChunkedTaggedTokenizer >>> chunked = "[ the/DT little/JJ cat/NN ] sat/VBD on/IN [ the/DT mat/NN ]" >>> sentence = Token(TEXT=chunked) >>> tokenizer = ChunkedTaggedTokenizer(chunk_node='NP') >>> tokenizer.tokenize(sentence) >>> sentence['SUBTOKENS'][0] (NP: ) >>> sentence['SUBTOKENS'][0]['NODE'] 'NP' >>> sentence['SUBTOKENS'][0]['CHILDREN'][0] >>> sentence['SUBTOKENS'][0]['CHILDREN'][0]['TAG'] 'DT' >>> chunk_structure = TreeToken(NODE='S', CHILDREN=sentence['SUBTOKENS']) (S: (NP: ) (NP: ))
所提及的分塊工作可以由 nltk.tokenizer.RegexpChunkParser
類使用偽正則表達式來描述構成語法元素的一系列標簽來完成。這里是概率教程中的一個例子:
>>> rule1 = ChunkRule('?*', ... 'Chunk optional det, zero or more adj, and a noun') >>> chunkparser = RegexpChunkParser([rule1], chunk_node='NP', top_node='S') >>> chunkparser.parse(sentence) >>> print sent['TREE'] (S: (NP: ) (NP: ))
真正的解析將引領我們進入很多理論領域。例如,top-down 解析器可以確保找到每一個可能的產品,但可能會非常慢,因為要頻繁地(指數級)進行回溯。Shift-reduce 效率更高,但是可能會錯過一些產品。不論在哪種情況下,語法規則的聲明都類似於解析人工語言的語法聲明。本專欄曾經介紹了其中的一些: SimpleParse
、 mx.TextTools
、 Spark
和 gnosis.xml.validity
(參閱 參考資料)。
甚至,除了 top-down 和 shift-reduce 解析器以外,NLTK 還提供了“chart 解析器”,它可以創建部分假定,這樣一個給定的序列就可以繼而完成一個規則。這種方法可以是既有效又完全的。舉一個生動的(玩具級的)例子:
>>> from nltk.parser.chart import * >>> grammar = CFG.parse(''' ... S -> NP VP ... VP -> V NP | VP PP ... V -> "saw" | "ate" ... NP -> "John" | "Mary" |
"Bob" | Det N | NP PP ... Det -> "a" | "an" | "the" | "my" ... N -> "dog" | "cat" | "cookie" ... PP -> P NP ...
P -> "on" | "by" | "with" ... ''') >>> sentence = Token(TEXT='John saw a cat with my cookie') >>> WSTokenizer().tokenize(sentence) >>> parser = ChartParser(grammar, BU_STRATEGY, LEAF='TEXT') >>> parser.parse_n(sentence) >>> for tree in sentence['TREES']: print tree (S: (NP: ) (VP: (VP: (V: ) (NP: (Det: ) (N: )))
(PP: (P: ) (NP: (Det: ) (N: ))))) (S: (NP: ) (VP: (V: ) (NP: (NP: (Det: ) (N: ))
(PP: (P: ) (NP: (Det: ) (N: ))))))
probabilistic context-free grammar(或者說是 PCFG)是一種上下文無關語法,它將其每一個產品關聯到一個概率。同樣,用於概率解析的解析器也捆綁到了 NLTK 中。