讀古今文學網 > 機器學習實戰 > 4.5 使用Python進行文本分類 >

4.5 使用Python進行文本分類

要從文本中獲取特徵,需要先拆分文本。具體如何做呢?這裡的特徵是來自文本的詞條(token),一個詞條是字符的任意組合。可以把詞條想像為單詞,也可以使用非單詞詞條,如URL、IP地址或者任意其他字符串。然後將每一個文本片段表示為一個詞條向量,其中值為1表示詞條出現在文檔中,0表示詞條未出現。

以在線社區的留言板為例。為了不影響社區的發展,我們要屏蔽侮辱性的言論,所以要構建一個快速過濾器,如果某條留言使用了負面或者侮辱性的語言,那麼就將該留言標識為內容不當。過濾這類內容是一個很常見的需求。對此問題建立兩個類別:侮辱類和非侮辱類,使用1和0分別表示。

接下來首先給出將文本轉換為數字向量的過程,然後介紹如何基於這些向量來計算條件概率,並在此基礎上構建分類器,最後還要介紹一些利用Python實現樸素貝葉斯過程中需要考慮的問題。

4.5.1 準備數據:從文本中構建詞向量

我們將把文本看成單詞向量或者詞條向量,也就是說將句子轉換為向量。考慮出現在所有文檔中的所有單詞,再決定將哪些詞納入詞彙表或者說所要的詞彙集合,然後必須要將每一篇文檔轉換為詞彙表上的向量。接下來我們正式開始。打開文本編輯器,創建一個叫bayes.py的新文件,然後將下面的程序清單添加到文件中。

程序清單4-1 詞表到向量的轉換函數

def loadDataSet:
    postingList=[[\'my\', \'dog\', \'has\', \'flea\',  \'problems\', \'help\', \'please\'],
                 [\'maybe\', \'not\', \'take\', \'him\', \'to\', \'dog\', \'park\', \'stupid\'],
                 [\'my\', \'dalmation\', \'is\', \'so\', \'cute\', \'I\', \'love\', \'him\'],
                 [\'stop\', \'posting\', \'stupid\', \'worthless\', \'garbage\'],
                 [\'mr\', \'licks\', \'ate\', \'my\', \'steak\', \'how\',\'to\', \'stop\', \'him\'],
                 [\'quit\', \'buying\', \'worthless\', \'dog\', \'food\', \'stupid\']]
    classVec = [0,1,0,1,0,1]           #1代表侮辱性文字,0代表正常言論
    return postingList,classVec

def createVocabList(dataSet):
    #❶ 創建一個空集
    vocabSet = set()
    for document in dataSet:
        #❷  創建兩個集合的並集  
        vocabSet = vocabSet | set(document)
    return list(vocabSet)

def setOfWords2Vec(vocabList, inputSet):
    #❸ 創建一個其中所含元素都為0的向量 
    returnVec = [0]*len(vocabList)
    for word in inputSet:
        if word in vocabList:
            returnVec[vocabList.index(word)] = 1
        else: print \"the word: %s is not in my Vocabulary!\" % word
    return returnVec
  

第一個函數loadDataSet創建了一些實驗樣本。該函數返回的第一個變量是進行詞條切分後的文檔集合,這些文檔來自斑點犬愛好者留言板。這些留言文本被切分成一系列的詞條集合,標點符號從文本中去掉,後面會探討文本處理的細節。loadDataSet( )函數返回的第二個變量是一個類別標籤的集合。這裡有兩類,侮辱性和非侮辱性。這些文本的類別由人工標注,這些標注信息用於訓練程序以便自動檢測侮辱性留言。

下一個函數createVocabList會創建一個包含在所有文檔中出現的不重複詞的列表,為此使用了Python的set數據類型。將詞條列表輸給set構造函數,set就會返回一個不重複詞表。首先,創建一個空集合❶,然後將每篇文檔返回的新詞集合添加到該集合中❷。操作符|用於求兩個集合的並集,這也是一個按位或(OR)操作符(參見附錄C)。在數學符號表示上,按位或操作與集合求並操作使用相同記號。

獲得詞彙表後,便可以使用函數setOfWords2Vec,該函數的輸入參數為詞彙表及某個文檔,輸出的是文檔向量,向量的每一元素為1或0,分別表示詞彙表中的單詞在輸入文檔中是否出現。函數首先創建一個和詞彙表等長的向量,並將其元素都設置為0❸。接著,遍歷文檔中的所有單詞,如果出現了詞彙表中的單詞,則將輸出的文檔向量中的對應值設為1。一切都順利的話,就不需要檢查某個詞是否還在vocabList中,後邊可能會用到這一操作。

現在看一下這些函數的執行效果,保存bayes.py文件,然後在Python提示符下輸入:

>>> import bayes
>>> listOPosts,listClasses = bayes.loadDataSet
>>> myVocabList = bayes.createVocabList(listOPosts)
>>> myVocabList
[\'cute\', \'love\', \'help\', \'garbage\', \'quit\', \'I\', \'problems\', \'is\', \'park\',
\'stop\', \'flea\', \'dalmation\', \'licks\', \'food\', \'not\', \'him\', \'buying\',
\'posting\', \'has\', \'worthless\', \'ate\', \'to\', \'maybe\', \'please\', \'dog\',
\'how\', \'stupid\', \'so\', \'take\', \'mr\', \'steak\', \'my\']
 

檢查上述詞表,就會發現這裡不會出現重複的單詞。目前該詞表還沒有排序,需要的話,稍後可以對其排序。

下面看一下函數setOfWords2Vec的運行效果:

>>> bayes.setOfWords2Vec(myVocabList, listOPosts[0])
[0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1,
0, 0, 0, 0, 0, 0, 1]
>>> bayes.setOfWords2Vec(myVocabList, listOPosts[3])
[0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0,
0, 1, 0, 0, 0, 0, 0]  
  

該函數使用詞彙表或者想要檢查的所有單詞作為輸入,然後為其中每一個單詞構建一個特徵。一旦給定一篇文檔(斑點犬網站上的一條留言),該文檔就會被轉換為詞向量。接下來檢查一下函數的有效性。myVocabList中索引為2的元素是什麼單詞?應該是單詞help。該單詞在第一篇文檔中出現,現在檢查一下看看它是否出現在第四篇文檔中。

4.5.2 訓練算法:從詞向量計算概率****

前面介紹了如何將一組單詞轉換為一組數字,接下來看看如何使用這些數字計算概率。現在已經知道一個詞是否出現在一篇文檔中,也知道該文檔所屬的類別。還記得3.2節提到的貝葉斯準則?我們重寫貝葉斯準則,將之前的x、y 替換為w。粗體w表示這是一個向量,即它由多個數值組成。在這個例子中,數值個數與詞彙表中的詞個數相同。

我們將使用上述公式,對每個類計算該值,然後比較這兩個概率值的大小。如何計算呢?首先可以通過類別i(侮辱性留言或非侮辱性留言)中文檔數除以總的文檔數來計算概率p(ci)。接下來計算p(w|ci),這裡就要用到樸素貝葉斯假設。如果將w展開為一個個獨立特徵,那麼就可以將上述概率寫作p(w0,w1,w2..wN|ci)。這裡假設所有詞都互相獨立,該假設也稱作條件獨立性假設,它意味著可以使用p(w0|ci)p(w1|ci)p(w2|ci)...p(wN|ci)來計算上述概率,這就極大地簡化了計算的過程。

該函數的偽代碼如下:

計算每個類別中的文檔數目
對每篇訓練文檔:
    對每個類別: 
        如果詞條出現在文檔中→ 增加該詞條的計數值
        增加所有詞條的計數值
    對每個類別:
        對每個詞條:
            將該詞條的數目除以總詞條數目得到條件概率
    返回每個類別的條件概率  
  

我們利用下面的代碼來實現上述偽碼。打開文本編輯器,將這些代碼添加到bayes.py文件中。該函數使用了NumPy的一些函數,故應確保將from numpy import *語句添加到bayes.py文件的最前面。

程序清單4-2 樸素貝葉斯分類器訓練函數

def trainNB0(trainMatrix,trainCategory):
    numTrainDocs = len(trainMatrix)
    numWords = len(trainMatrix[0])
    pAbusive = sum(trainCategory)/float(numTrainDocs)
    #❶ (以下兩行)初始化概率 
    p0Num = zeros(numWords); p1Num = zeros(numWords)
    p0Denom = 0.0; p1Denom = 0.0
    for i in range(numTrainDocs):
        if trainCategory[i] == 1:
            #❷(以下兩行)向量相加 
            p1Num += trainMatrix[i]
            p1Denom += sum(trainMatrix[i])
        else:
            p0Num += trainMatrix[i]
            p0Denom += sum(trainMatrix[i])
    p1Vect = p1Num/p1Denom #change to log
    #❸ 對每個元素做除法
    p0Vect = p0Num/p0Denom #change to log
    return p0Vect,p1Vect,pAbusive
  

代碼函數中的輸入參數為文檔矩陣trainMatrix,以及由每篇文檔類別標籤所構成的向量trainCategory。首先,計算文檔屬於侮辱性文檔(class=1)的概率,即P(1)。因為這是一個二類分類問題,所以可以通過1-P(1)得到P(0)。對於多於兩類的分類問題,則需要對代碼稍加修改。

計算p(wi|c1)p(wi|c0),需要初始化程序中的分子變量和分母變量❶。由於w中元素如此眾多,因此可以使用NumPy數組快速計算這些值。上述程序中的分母變量是一個元素個數等於詞彙表大小的NumPy數組。在for循環中,要遍歷訓練集trainMatrix中的所有文檔。一旦某個詞語(侮辱性或正常詞語)在某一文檔中出現,則該詞對應的個數(p1Num或者p0Num)就加1,而且在所有的文檔中,該文檔的總詞數也相應加1❷。對於兩個類別都要進行同樣的計算處理。

最後,對每個元素除以該類別中的總詞數❸。利用NumPy可以很好實現,用一個數組除以浮點數即可,若使用常規的Python列表則難以完成這種任務,讀者可以自己嘗試一下。最後,函數會返回兩個向量和一個概率。

接下來試驗一下。將程序清單4-2中的代碼添加到bayes.py文件中,在Python提示符下輸入:

>>> from numpy import *  
>>> reload(bayes)  
>>> listOPosts,listClasses = bayes.loadDataSet   
  

該語句從預先加載值中調入數據

>>> myVocabList = bayes.createVocabList(listOPosts)

至此我們構建了一個包含所有詞的列表myVocabList

>>> trainMat=
>>> for postinDoc in listOPosts:
... trainMat.append(bayes.setOfWords2Vec(myVocabList, postinDoc))   
...  
  

for循環使用詞向量來填充trainMat列表。下面給出屬於侮辱性文檔的概率以及兩個類別的概率向量。

>>> p0V,p1V,pAb=bayes.trainNB0(trainMat,listClasses)  
  

接下來看這些變量的內部值:

>>> pAb
0.5
  

這就是任意文檔屬於侮辱性文檔的概率。

>>> p0V
array([ 0.04166667,   0.04166667,   0.04166667,   0.       ,   0.        ,
                   .
                   .
        0.04166667,   0.        ,   0.04166667,   0.       ,   0.04166667,
        0.04166667,   0.125     ])
>>> p1V
array([ 0.        ,   0.        ,   0.        ,   0.05263158,   0.05263158,
                        .
                        .
        0.        ,   0.15789474,   0.        ,   0.05263158,   0.        ,
        0.        ,   0.        ])
  

首先,我們發現文檔屬於侮辱類的概率pAb為0.5,該值是正確的。接下來,看一看在給定文檔類別條件下詞彙表中單詞的出現概率,看看是否正確。詞彙表中的第一個詞是cute,其在類別0中出現1次,而在類別1中從未出現。對應的條件概率分別為0.041 666 67與0.0。該計算是正確的。我們找找所有概率中的最大值,該值出現在P(1)數組第26個下標位置,大小為0.157 894 74。在myVocabList的第26個下標位置上可以查到該單詞是stupid。這意味著stupid是最能表徵類別1(侮辱性文檔類)的單詞。

使用該函數進行分類之前,還需解決函數中的一些缺陷。

4.5.3 測試算法:根據現實情況修改分類器

利用貝葉斯分類器對文檔進行分類時,要計算多個概率的乘積以獲得文檔屬於某個類別的概率,即計算p(w0|1)p(w1|1)p(w2|1)。如果其中一個概率值為0,那麼最後的乘積也為0。為降低這種影響,可以將所有詞的出現數初始化為1,並將分母初始化為2。

在文本編輯器中打開bayes.py文件,並將trainNB0( )的第4行和第5行修改為:

p0Num = ones(numWords); p1Num = ones(numWords)
p0Denom = 2.0; p1Denom = 2.0
  

另一個遇到的問題是下溢出,這是由於太多很小的數相乘造成的。當計算乘積p(w0|ci)p(w1|ci)p(w2|ci)...p(wN|ci)時,由於大部分因子都非常小,所以程序會下溢出或者得到不正確的答案。(讀者可以用Python嘗試相乘許多很小的數,最後四捨五入後會得到0。)一種解決辦法是對乘積取自然對數。在代數中有ln(a*b) = ln(a)+ln(b),於是通過求對數可以避免下溢出或者浮點數捨入導致的錯誤。同時,採用自然對數進行處理不會有任何損失。圖4-4給出函數f(x)ln(f(x))的曲線。檢查這兩條曲線,就會發現它們在相同區域內同時增加或者減少,並且在相同點上取到極值。它們的取值雖然不同,但不影響最終結果。通過修改return前的兩行代碼,將上述做法用到分類器中:

p1Vect = log(p1Num/p1Denom)
p0Vect = log(p0Num/p0Denom)
  

圖4-4 函數f(x)ln(f(x))會一塊增大。這表明想求函數的最大值時,可以使用該函數的自然對數來替換原函數進行求解

現在已經準備好構建完整的分類器了。當使用NumPy向量處理功能時,這一切變得十分簡單。打開文本編輯器,將下面的代碼添加到bayes.py中:

程序清單4-3 樸素貝葉斯分類函數

def classifyNB(vec2Classify, p0Vec, p1Vec, pClass1):
    #❶ 元素相乘
    p1 = sum(vec2Classify * p1Vec) + log(pClass1)
    p0 = sum(vec2Classify * p0Vec) + log(1.0 - pClass1)
    if p1 > p0:
        return 1
    else:
        return 0

def testingNB:
    listOPosts,listClasses = loadDataSet
    myVocabList = createVocabList(listOPosts)
    trainMat=
    for postinDoc in listOPosts:
        trainMat.append(setOfWords2Vec(myVocabList, postinDoc))
    p0V,p1V,pAb = trainNB0(array(trainMat),array(listClasses))
    testEntry = [\'love\', \'my\', \'dalmation\']
    thisDoc = array(setOfWords2Vec(myVocabList, testEntry))
    print testEntry,\'classified as: \',classifyNB(thisDoc,p0V,p1V,pAb)
    testEntry = [\'stupid\', \'garbage\']
    thisDoc = array(setOfWords2Vec(myVocabList, testEntry))
    print testEntry,\'classified as: \',classifyNB(thisDoc,p0V,p1V,pAb)   
 

程序清單4-3的代碼有4個輸入:要分類的向量vec2Classify以及使用函數trainNB0計算得到的三個概率。使用NumPy的數組來計算兩個向量相乘的結果❶。這裡的相乘是指對應元素相乘,即先將兩個向量中的第1個元素相乘,然後將第2個元素相乘,以此類推。接下來將詞彙表中所有詞的對應值相加,然後將該值加到類別的對數概率上。最後,比較類別的概率返回大概率對應的類別標籤。這一切不是很難,對吧?

代碼的第二個函數是一個便利函數(convenience function),該函數封裝所有操作,以節省輸入4.3.1節中代碼的時間。

下面來看看實際結果。將程序清單4-3中的代碼添加之後,在Python提示符下輸入:

>>> reload(bayes)
<module \'bayes\' from \'bayes.pyc\'>
>>>bayes.testingNB
[\'love\', \'my\', \'dalmation\'] classified as: 0
[\'stupid\', \'garbage\'] classified as: 1 
  

對文本做一些修改,看看分類器會輸出什麼結果。這個例子非常簡單,但是它展示了樸素貝葉斯分類器的工作原理。接下來,我們會對代碼做些修改,使分類器工作得更好。

4.5.4 準備數據:文檔詞袋模型

目前為止,我們將每個詞的出現與否作為一個特徵,這可以被描述為詞集模型(set-of-words model)。如果一個詞在文檔中出現不止一次,這可能意味著包含該詞是否出現在文檔中所不能表達的某種信息,這種方法被稱為詞袋模型(bag-of-words model)。在詞袋中,每個單詞可以出現多次,而在詞集中,每個詞只能出現一次。為適應詞袋模型,需要對函數setOfWords2Vec稍加修改,修改後的函數稱為bagOfWords2Vec

下面的程序清單給出了基於詞袋模型的樸素貝葉斯代碼。它與函數setOfWords2Vec幾乎完全相同,唯一不同的是每當遇到一個單詞時,它會增加詞向量中的對應值,而不只是將對應的數值設為1。

程序清單4-4 樸素貝葉斯詞袋模型

def bagOfWords2VecMN(vocabList, inputSet):
    returnVec = [0]*len(vocabList)
    for word in inputSet:
        if word in vocabList:
            returnVec[vocabList.index(word)] += 1
    return returnVec
  

現在分類器已經構建好了,下面我們將利用該分類器來過濾垃圾郵件。