探索 Python、機器學習和 NLTK 庫

pythontab發表於2018-01-30

挑戰:使用機器學習對 RSS 提要進行分類

最近,我接到一項任務,要求為客戶建立一個 RSS 提要分類子系統。目標是讀取幾十個甚至幾百個 RSS 提要,將它們的許多文章自動分類到幾十個預定義的主題領域當中。客戶網站的內容、導航和搜尋功能都將由這個每日自動提要檢索和分類結果驅動。

客戶建議使用機器學習,或許還會使用 Apache Mahout 和 Hadoop 來實現該任務,因為客戶最近閱讀了有關這些技術的文章。但是,客戶的開發團隊和我們的開發團隊都更熟悉 Ruby,而不是 Java™ 技術。本文將介紹解決方案的技術之旅、學習過程和最終實現。

什麼是機器學習?

我的第一個問題是,“究竟什麼是機器學習?” 我聽說過這個術語,並且隱約知道超級計算機 IBM® Watson 最近使用該技術在一場 Jeopardy 比賽中擊敗了人類競爭者。作為購物者和社交網路活動參與者,我也知道 Amazon.com 和 Facebook 根據其購物者資料在提供建議(如產品和人)方面表現良好。總之,機器學習取決於 IT、數學和自然語言的交集。它主要關注以下三個主題,但客戶的解決方案最終僅涉及前兩個主題:

分類。根據類似專案的一組訓練資料,將相關的項分配到任意預定義的類別

建議。根據類似專案的觀察來建議採用的項

叢集。在一組資料內確定子組

Mahout 和 Ruby 的選擇

理解了機器學習是什麼之後,下一步是確定如何實現它。根據客戶的建議,Mahout 是一個合適的起點。我從 Apache 下載了程式碼,並開始了學習使用 Mahout 及其兄弟 Hadoop 實現機器學習的過程。不幸的是,我發現即使對於有經驗的 Java 開發人員而言,Mahout 的學習曲線也很陡峭,並且不存在可用的樣例程式碼。同樣不幸的是,機器學習缺乏基於 Ruby 的框架或 gem。

發現 Python 和 NLTK

我繼續搜尋解決方案,並且在結果集中一直遇到 "Python"。作為一名 Ruby 開發人員,雖然我還沒有學過該語言,但我也知道 Python 是一個面向相似物件的、基於文字的、可理解和動態的程式語言。儘管兩種語言之間存在一些相似之處,但我多年來都忽視了學習 Python,將它視為一項多餘的技能集。Python 是我的 “盲點”,我懷疑許多 Ruby 開發人員同行都是這樣認為的。

搜尋機器學習的書籍,並更深入研究它們的目錄,我發現,有相當高比例的此類系統在使用 Python 作為其實現語言,並使用了一個被稱為 Natural Language Toolkit(NLTK,自然語言工具包)的庫。透過進一步的搜尋,我發現 Python 的應用比我意識到的還要廣泛,如 Google App Engine、YouTube 和使用 Django 框架構建的網站。它甚至還預安裝在我每天都使用的 Mac OS X 工作站上!此外,Python 為數學、科學和工程提供了有趣的標準庫(例如,NumPy 和 SciPy)。

我決定推行一個 Python 解決方案,因為我找到了非常好的編碼示例。例如,下面這一行程式碼就是透過 HTTP 讀取 RSS 提要並列印其內容所需的所有程式碼:

print feedparser.parse("http://feeds.nytimes.com/nyt/rss/Technology")

快速掌握 Python

執行 Python 程式同樣很簡單。獲得一個名稱為 locomotive_main.py 的程式和三個引數,然後您就可以使用 Python 程式編譯並執行它:

$ python locomotive_main.py arg1 arg2 arg3

Python 使用 清單 1 中的 if __name__ == "__main__": 語法來確定檔案本身是從命令列執行的還是從其他程式碼匯入的。為了讓檔案變得可以執行,需要新增 "__main__" 檢測。

清單 1. Main 檢測

import sys
import time
import locomotive
 
if __name__ == "__main__":
    start_time = time.time()
    if len(sys.argv) > 1:
        app = locomotive.app.Application()
        ... additional logic ...

virtualenv

大多數 Ruby 開發人員熟悉系統範圍的庫或 gem 的問題。使用一組系統範圍內的庫的做法一般是不可取的,因為您的其中一個專案可能依賴於某個給定的庫的版本 1.0.0,而另一個專案則依賴於版本 1.2.7。同樣,Java 開發人員都知道系統範圍的 CLASSPATH 存在同樣的問題。就像 Ruby 社群使用其 rvm 工具,而 Python 社群使用 virtualenv 工具(請參閱 參考資料,以獲得相關連結)來建立獨立的執行環境,其中包含特定版本的 Python 和一組庫。清單 2 中的命令顯示瞭如何為您 p1 專案建立一個名為 p1_env 的虛擬環境,其中包含 feedparser、numpy、scipy 和 nltk 庫。

清單 2. 使用 virualenv 建立一個虛擬環境的命令

$ sudo pip install virtualenv
$ cd ~
$ mkdir p1
$ cd p1
$ virtualenv p1_env --distribute
$ source p1_env/bin/activate 
(p1_env)[~/p1]$ pip install feedparser
(p1_env)[~/p1]$ pip install numpy
(p1_env)[~/p1]$ pip install scipy
(p1_env)[~/p1]$ pip install nltk
(p1_env)[~/p1]$ pip freeze

每次在一個 shell 視窗使用您的專案時,都需要 “獲得” 您的虛擬環境啟用指令碼。請注意,在啟用指令碼被獲得後,shell 提示符會改變。當在您的系統上建立和使用 shell 視窗,輕鬆地導航到您的專案目錄,並啟動其虛擬環境時,您可能想在您的 ~/.bash_profile 檔案中新增以下條目:

$ alias p1="cd ~/p1 ; source p1_env/bin/activate"

程式碼庫結構

在完成簡單的單檔案 “Hello World” 程式的編寫之後,Python 開發人員需要理解如何正確地組織其程式碼庫的目錄和檔名。Java 和 Ruby 語言在這方面都有各自的要求,Python 也沒有什麼不同。簡單來說,Python 使用包 的概念對相關的程式碼進行分組,並提供了明確的名稱空間。出於演示目的,在本文中,程式碼存在於某個給定專案的根目錄中,例如 ~/p1。在這個目錄中,存在一個用於相同名稱的 Python 包的 locomotive 目錄。 清單 3 顯示了這個目錄結構。

清單 3. 示例目錄結構

locomotive_main.py
locomotive_tests.py
 
locomotive/
    __init__.py
    app.py
    capture.py
    category_associations.py
    classify.py
    news.py
    recommend.py
    rss.py
 
locomotive_tests/
    __init__.py
    app_test.py
    category_associations_test.py
    feed_item_test.pyc
    rss_item_test.py

請注意名稱古怪的 __init__.py 檔案。這些檔案指示 Python 為您的包載入必要的庫和特定的應用程式程式碼檔案,它們都位於相同的目錄中。 清單 4 顯示了檔案 locomotive/__init__.py 的內容。

清單 4. locomotive/__init__.py

# system imports; loads installed packages
import codecs
import locale
import sys
 
# application imports; these load your specific *.py files
import app
import capture
import category_associations
import classify
import rss
import news
import recommend

有了結構如 清單 4 所示的 locomotive 包之後,在專案的根目錄中的主程式就可以匯入並使用它。例如,檔案 locomotive_main.py 包含以下匯入:

import sys         # >-- system library
import time        # >-- system library
import locomotive  # >-- custom application code library in the "locomotive" directory

測試

Python unittest 標準庫提供一個非常好的測試解決方案。熟悉 JUnit 的 Java 開發人員和熟悉 Test::Unit 框架的 Ruby 開發人員應該會覺得 清單 5 中的 Python unittest 程式碼很容易理解。

清單 5. Python unittest

class AppTest(unittest.TestCase):
 
    def setUp(self):
        self.app = locomotive.app.Application()
 
    def tearDown(self):
        pass
 
    def test_development_feeds_list(self):
        feeds_list = self.app.development_feeds_list()
        self.assertTrue(len(feeds_list) == 15)
        self.assertTrue('feed://news.yahoo.com/rss/stock-markets' in feeds_list)

清單 5 中的程式碼還演示了 Python 的一個顯著的特點:所有的程式碼必須一致縮排,否則無法成功編譯。tearDown(self) 方法可能在開始時看起來有點古怪。您可能會問,為什麼測試總是被硬編碼為透過?事實上並非如此。這只是在 Python 中編寫空方法的一種方式。

工具

我真正需要的是一個具備語法突出顯示、程式碼完成和斷點除錯功能的整合開發環境 (IDE),用該環境幫助我掌握我的 Python 學習曲線。作為使用 Eclipse IDE 進行 Java 開發的一名使用者,pyeclipse 外掛是我考慮的下一個工具。雖然該外掛有時比較慢,但它工作得相當不錯。我最終投資了 PyCharm IDE,它滿足了我的所有 IDE 要求。

在掌握了 Python 及其生態系統的基本知識之後,終於來到開始實現機器學習解決方案的時候。

使用 Python 和 NLTK 實現分類

實現解決方案涉及捕獲模擬的 RSS 提要、整理其文字、使用一個 NaiveBayesClassifier 和 kNN 演算法對類別進行分類。下面將會介紹這些操作中的每一個。

捕獲和解析提要

該專案特別具有挑戰性,因為客戶還沒有定義目標 RSS 提要列表。因此,也不存在 “訓練資料”。所以,在初始開發期間必須模擬提要和訓練資料。

我用來獲得示例提要資料的第一個方法是隻提取在某個文字檔案中指定的列表中的 RSS 提要。Python 提供了一個很好的 RSS 提要解析庫,其名稱為 feedparser,它抽象不同的 RSS 和 Atom 格式之間的差異。簡單的基於文字的物件序列化的另一個有用的庫被幽默地稱為 pickle(泡菜)。這兩個庫在 清單 6 的程式碼中均有使用,清單 6 中的程式碼將每一個 RSS 提要捕獲為 “醃製過的” 物件檔案,以備後用。如您所見,Python 程式碼非常簡潔,且功能強大。

清單 6. CaptureFeeds 類

import feedparser
import pickle
 
class CaptureFeeds:
 
    def __init__(self):
        for (i, url) in enumerate(self.rss_feeds_list()):
            self.capture_as_pickled_feed(url.strip(), i)
 
    def rss_feeds_list(self):
        f = open('feeds_list.txt', 'r')
        list = f.readlines()
        f.close
        return list
 
    def capture_as_pickled_feed(self, url, feed_index):
        feed = feedparser.parse(url)
        f = open('data/feed_' + str(feed_index) + '.pkl', 'w')
        pickle.dump(feed, f)
        f.close()
 
if __name__ == "__main__":
    cf = CaptureFeeds()

下一步的挑戰性之大是出乎意料的。現在,我有了樣例提要資料,必須對它進行分類,以便將它用作訓練資料。訓練資料 是向您的分類演算法提供的資料集,以便您能從中進行學習。

例如,我使用的樣例提要包括了體育電視網路公司 ESPN。提要的專案之一是關於 Denver Broncos 橄欖球隊的 Tim Tebow 被轉會到 New York Jets 橄欖球隊,在同一時間,Broncos 簽了他們新的四分衛 Peyton Manning。提要結果中的另一個專案是 Boeing Company 和它的新噴氣式飛機 (jet)。所以,這裡的問題是,應該將哪些具體的類別值分配給第一個故事?tebow、broncos、manning、jets、quarterback、trade 和 nfl 這些值都是合適的。但只有一個值可以在訓練資料中被指定為訓練資料類別。同樣,在第二個故事中,類別應該是 boeing 還是 jet?困難的部分在於這些細節。如果您的演算法要產生精確的結果,那麼大型訓練資料集的準確手工分類非常關鍵。要做到這一點,不應該低估所需的時間。

我需要使用更多的資料,而且這些資料必須已進行了準確的分類,這種情況很快就變得明顯。我可以在哪裡找到這樣的資料呢?進入 Python NLTK。除了是一個出色的語言文字處理庫之外,它甚至還帶有可下載的示例資料集,或是其術語中的文集,以及可以輕鬆訪問此下載資料的應用程式程式設計介面。要安裝 Reuters 文集,可以執行如下所示的命令。會有超過 10,000 篇新聞文章將下載到您的 ~/nltk_data/corpora/reuters/ 目錄中。與 RSS 提要專案一樣,每篇 Reuters 新聞文章中都包含一個標題和一個正文,所以這個 NLTK 預分類的資料非常適合於模擬 RSS 提要。

$ python               # enter an interactive Python shell
>>> import nltk        # import the nltk library
>>> nltk.download()    # run the NLTK Downloader, then enter 'd' Download
Identifier> reuters    # specify the 'reuters' corpus

特別令人感興趣的是檔案 ~/nltk_data/corpora/reuters/cats.txt。它包含了一個列表,其中包含文章檔名稱,以及為每個文章檔案分配的類別。檔案看起來如下所示,所以,子目錄 test 中的檔案 14828 中的文章與主題 grain 有關。

test/14826 trade
test/14828 grain

自然語言是混亂的

RSS 提要分類演算法的原始輸入,當然是以英語書寫的文字。原始,確實如此。

從計算機處理的角度來看,英語或任何自然語言(口語或普通的語言)都是極不規範和不準確的。首先,存在大小寫的問題。單詞 Bronco 是否等於 bronco?答案是,也許是。接下來,您要應付標點和空格。bronco. 是否等於 bronco 或 bronco,?算是吧。然後,有複數形式和相似的單詞。run、running 和 ran 是否相等?這取決於不同的情況。這三個詞有一個共同的 詞根。如果將自然語言詞彙嵌入在標記語言(如 HTML)中,情況會怎麼樣呢?在這種情況下,您必須處理像 <strong>bronco</strong> 這樣的文字。最後,還有一個問題,就是那些經常使用但基本上毫無意義的單詞,像 a、and 和 the。這些所謂的停用詞非常礙事。自然語言非常凌亂;在處理之前,需要對它們進行整理。

幸運的是,Python 和 NLTK 讓您可以收拾這個爛攤子。在 清單 7 中,RssItem 類的 normalized_words 方法可以處理所有這些問題。請特別注意 NLTK 如何只使用一行程式碼就能夠清潔嵌入式 HTML 標記的原始文章文字!使用一個正規表示式刪除標點,然後每個單詞被拆分,並規範化為小寫。

清單 7. RssItem 類

class RssItem:
    ...
    regex = re.compile('[%s]' % re.escape(string.punctuation))
    ...
    def normalized_words(self, article_text):
        words   = []
        oneline = article_text.replace('\n', ' ')
        cleaned = nltk.clean_html(oneline.strip())
        toks1   = cleaned.split()
        for t1 in toks1:
            translated = self.regex.sub('', t1)
            toks2 = translated.split()
            for t2 in toks2:
                t2s = t2.strip().lower()
                if self.stop_words.has_key(t2s):
                    pass
                else:
                    words.append(t2s)
        return words

只需這一行程式碼就可以從 NLTK 獲得停用詞列表;並且還支援其他自然語言:

nltk.corpus.stopwords.words('english')

NLTK 還提供了一些 “詞幹分析器” 類,以便進一步規範化單詞。請檢視有關詞幹、詞形歸併、句子結構和語法的 NLTK 文件,瞭解有關的更多資訊。

使用 Naive Bayes 演算法進行分類

演算法在 NLTK 中被廣泛使用並利用nltk.NaiveBayesClassifier 類實現。Bayes 演算法根據特性在其資料集中的每個存在或不存在對專案進行分類。在 RSS 提要專案的情況下,每一個特性都是自然語言的一個給定的(清潔過的)單詞。該演算法是 “樸實” 的,因為它假設特性(在本例中,單詞)之間沒有任何關係。

然而,英語這種語言包含超過 250,000 個單詞。當然,我不希望為了將 RSS 提要專案傳遞給演算法就要為每個 RSS 提要專案建立一個包含 250,000 個布林值的物件。那麼,我會使用哪些單詞?簡單來說,答案是在培訓資料組中除了停用詞之外最常見的單詞。NLTK 提供了一個優秀的類,即 nltk.probability.FreqDist,我可以用它來識別這些最常用的單詞。在 清單 8 中,collect_all_words 方法返回來自所有培訓文章的所有單詞的一個陣列。

然後,此陣列被傳遞給 identify_top_words 方法,以確定最頻繁的單詞。nltk.FreqDist 類的一個有用的特性是,它實質上是一個雜湊,但是它的鍵按其對應的值或計數 排序。因此,使用 [:1000] Python 語法可以輕鬆獲得最頻繁的 1000 個單詞。

清單 8. 使用 nltk.FreqDist 類

def collect_all_words(self, items):
    all_words = []
    for item in items:
        for w in item.all_words:
            words.append(w)
    return all_words
 
def identify_top_words(self, all_words):
    freq_dist = nltk.FreqDist(w.lower() for w in all_words)
    return freq_dist.keys()[:1000]

對於利用 NLTK Reuters 文章資料模擬的 RSS 提要專案,我需要確定每個專案的類別。為此,我讀取前面提到的 ~/nltk_data/corpora/reuters/cats.txt 檔案。用 Python 讀取一個檔案非常簡單,如下所示:

def read_reuters_metadata(self, cats_file):
    f = open(cats_file, 'r')
    lines = f.readlines()
    f.close()
    return lines

接下來的步驟是獲得每個 RSS 提要專案的特性。RssItem 類的 features 方法(如下所示)可以做到這一點。在該方法中,在文章中的 all_words 陣列首先被減少到一個較小的 set 物件,以消除重複的單詞。然後會遍歷 top_words,並在該 set 中進行比較,確定是否存在重複的單詞。隨後返回 1000 個布林值組成的一個雜湊,以 w_ 為鍵,後面是單詞本身。這個 Python 非常簡潔。

def features(self, top_words):
    word_set = set(self.all_words)
    features = {}
    for w in top_words:
        features["w_%s" % w] = (w in word_set)
    return features

接下來,我收集了訓練集的 RSS 提要專案和它們各自的特性,並將它們傳遞給演算法。清單 9 中的程式碼演示了這個任務。請注意,分類器被訓練成為只有一行程式碼。

清單 9. 訓練 nltk.NaiveBayesClassifier

def classify_reuters(self):
      ...
      training_set = []
      for item in rss_items:
          features = item.features(top_words)
          tup = (features, item.category)  # tup is a 2-element tuple
          featuresets.append(tup)
      classifier = nltk.NaiveBayesClassifier.train(training_set)

NaiveBayesClassifier 在執行中的 Python 程式的記憶體中,它現在是經過訓練的。現在,我只需遍歷需要進行分類的 RSS 提要專案集,並要求分類器猜測每個專案的類別。這很簡單。

for item in rss_items_to_classify:
    features = item.features(top_words)
    category = classifier.classify(feat)

變得不那麼樸實

如前所述,演算法假設每個特性之間是沒有關係的。因此,像 "machine learning" 和 "learning machine",或者 "New York Jet" 和 "jet to New York" 這樣的短語是等效的(to 是一個停用詞)。在自然的語言上下文中,這些單詞之間有明顯的關係。所以,我怎麼會讓演算法變得 “不那麼天真”,並識別這些單詞的關係?

其中一個技巧是在特性集內包括常見的雙字詞(兩個單詞為一組)和三字詞(三個單詞為一組)。NLTK 以 nltk.bigrams(...) 和 nltk.trigrams(...) 的形式對此提供了支援,現在我們對此應該不再感到驚訝了。正如可以從訓練資料組收集最常用的 n 個單詞那樣,也可以識別最常用的雙字詞和三字詞,並將它們用作特性。

您的結果會有所不同

對資料和演算法進行完善是一門藝術。您是否應該進一步規範化單詞集,也許應該包括詞根?或者包括超過 1000 個最常用單詞?少一點是否合適?或者是否應該使用更大的訓練資料集?是否應該新增更多信用詞或 “停用詞根”?這些都是您要問自己的正確問題。使用它們進行實驗,透過試錯法,您可以會為您的資料實現最佳演算法。我發現,85% 是一個很好的分類成功率。

利用 k-Nearest Neighbors 演算法提出建議

客戶希望顯示在選定類別或相似類別中的 RSS 提要專案。現在,這些專案已經用 Naive Bayes 演算法進行分類,這一要求的第一部分已得到了滿足。較難的部分是實現 “或相似類別” 的要求。這是機器學習建議器系統開始發揮作用的地方。建議器系統 根據其他專案的相似性來建議一個專案。Amazon.com 的產品建議和 Facebook 的朋友建議就是此功能的很好的示例。

k-Nearest Neighbors (kNN) 是最常用的建議演算法。思路是向它提供一組標籤(即類別),並且每個標籤都對應一個資料集。然後,該演算法對各資料集進行了比較,以識別相似的專案。資料集由多個數值陣列構成,數值的範圍往往被規範化為從 0 到 1。然後,它可以從資料集識別相似的標籤。與只產生一個結果的 Naive Bayes 不同,kNN 可以產生一個有排名的列表,其中包含若干(即,k 的值)個建議。

我發現,建議器演算法比分類演算法更容易理解和實現,但對於本文來說,其程式碼過於冗長,並且有複雜的數學,無法在這裡詳述。請參閱由 Manning 出版的一本很好的新書 Machine Learning in Action,獲取 kNN 編碼示例(請參閱 參考資料 中的連結)。在 RSS 提要專案實現的過程中,標籤值是專案類別,而資料集是最常用的 1000 個單詞的值陣列。同樣,在構建這個陣列時,一部分屬於科學範疇,一部分屬於數學範疇,還有一部分屬於藝術範疇。在陣列中,每個單詞的值都可以是簡單的 0 或 1 的布林值、文章中單詞出現次數的百分比、該百分比的指數值,或一些其他值。

結束語

探索 Python、NLTK 和機器學習一直是一個有趣的、令人愉快的經驗。Python 語言強大而又簡潔,現在已成為我的開發工具包的核心部分。它非常適合於機器學習、自然語言和數學/科學應用程式。雖然本文中並沒有提到,但我還發現 Python 對於圖表和繪圖非常有用。

相關文章