本文始發於個人公眾號:TechFlow
上一篇文章當中我們介紹了朴素貝葉斯模型的基本原理。
朴素貝葉斯的核心本質是假設樣本當中的變量服從某個分布,從而利用條件概率計算出樣本屬於某個類別的概率。一般來說一個樣本往往會含有許多特征,這些特征之間很有可能是有相關性的。為了簡化模型,朴素貝葉斯模型假設這些變量是獨立的。這樣我們就可以很簡單地計算出樣本的概率。
想要回顧其中細節的同學,可以點擊鏈接回到之前的文章:
在我們學習算法的過程中,如果只看模型的原理以及理論,總有一些紙上得來終覺淺的感覺。很多時候,道理說的頭頭是道,可是真正要上手的時候還是會一臉懵逼。或者是勉強能夠搞一搞,但是過程當中總會遇到這樣或者那樣各種意想不到的問題。一方面是我們動手實踐的不夠, 另一方面也是理解不夠深入。
今天這篇文章我們實際動手實現模型,並且在真實的數據集當中運行,再看看我們模型的運行效果。
朴素貝葉斯與文本分類
一般來說,我們認為狹義的事件的結果應該是有限的,也就是說事件的結果應該是一個離散值而不是連續值。所以早期的貝葉斯模型,在引入高斯混合模型的思想之前,針對的也是離散值的樣本(存疑,筆者推測)。所以我們先拋開連續特征的場景,先來看看在離散樣本當中,朴素貝葉斯模型有哪些實際應用。
在機器學習廣泛的應用場景當中,有一個非常經典的應用場景,它的樣本一定是離散的,它就是自然語言處理(Natural Language Processing)。在語言當中,無論是什么語言,無論是一個語句或是一段文本,它的最小單位要么是一個單詞,要么是一個字。這些單元都是離散的,所以天生和朴素貝葉斯模型非常契合。
我們這次做的模型針對的場景是垃圾郵件的識別,這應該是我們生活當中經常接觸到的功能。現在的郵箱基本上都有識別垃圾郵件的功能,如果發現是垃圾郵件,往往會直接屏蔽,不會展示給用戶。早期的垃圾郵件和垃圾短信識別的功能都是通過朴素貝葉斯實現的。
在這個實驗當中,我們用的是UCI的數據集。UCI大學的機器學習數據集非常出名,許多教材和課本上都使用了他們的數據集來作為例子。我們可以直接通過網頁下載他們的數據,UCI的數據集里的數據都是免費的。
下載完成之后,我們先挑選其中幾條來看看:
ham Go until jurong point, crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat...
ham Ok lar... Joking wif u oni...
spam Free entry in 2 a wkly comp to win FA Cup final tkts 21st May 2005. Text FA to 87121 to receive entry question(std txt rate)T&C's apply 08452810075over18's
ham U dun say so early hor... U c already then say...
這份數據是以txt文件類型保存,每行文本的第一個單詞表示文本的類別,其中ham表示正常,spam表示是垃圾郵件。
我們首先讀取文件,將文件當中的內容先讀取到list當中,方便我們后續的處理。
def read_file(filename):
file = open(filename, 'r')
content = []
for line in file.readlines():
content.append(line)
return content
我們查看一下前三條數據:

可以發現類別和正文之間通過\t (tab)分開了,我們可以直接通過python的split方法將類別和正文分開。其中類別也就是我們想要模型學習的結果,在有監督學習當中稱為label。文本部分也就是模型做出預測的依據,稱為特征。在文本分類場景當中,特征就是文本信息。
我們將label和文本分開:
labels = []
data = []
for i in smsTxt:
row = i.split('\t')
if len(row) == 2:
labels.append(row[0])
data.append(row[1])
過濾標點符號
將文本和label分開之后,我們就需要對文本進行處理了。在進行處理之前,我們先隨便拿一條數據來查看一下,這里我們選擇了第一條:
'Go until jurong point, crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat...\n'
這是一條非常典型的未處理之前的文本,當中不僅大小寫字母混用,並且還有一些特殊符號。所以文本處理的第一步就是把所有字母全部小寫,以及去除標點符號。
說起來比較復雜,但只要使用正則表達式,我們可以很方便地實現:
import re
# 只保留英文字母和數字
rule = re.compile("[^a-zA-Z\d ]")
for i in range(len(data)):
data[i] = re.sub("[^a-zA-Z\d ]", '', data[i].lower())
最后得到的結果如下:
go until jurong point crazy available only in bugis n great world la e buffet cine there got amore wat
這里正則表達式非常簡單,就是只保留英文字母和數字以及空格,其余所有的內容全部過濾。我們在傳入的時候做了大小寫轉換,會把所有的大寫字母轉成小寫。到這里為止,所有的特殊字符就都處理掉了,接下來就可以進行分詞了。
英文的分詞很簡單,我們直接根據空格split即可。如果是中文分詞,可以使用一些第三方庫完成,之前的文章里介紹過,這里就不贅述了。
安裝nltk
在接下來的文本處理當中,我們需要用到一個叫做nltk的自然語言處理的工具庫。當中集成了很多非常好用的NLP工具,和之前的工具庫一樣,我們可以直接使用pip進行安裝:
pip3 install nltk
這里強烈建議使用Python3,因為Python2已經不再維護了。這步結束之后,只是裝好了nltk庫,nltk當中還有很多其他的資源文件需要我們下載。我們可以直接通過python進行下載:
import nltk
nltk.download()
調用這個代碼之后會彈出一個下載窗口:

我們全選然后點擊下載即可,不過這個數據源在國外,在國內直接下載可能會很慢。除了曲線上網之外,另一種方法是可以直接在github里下載對應的資源數據:https://github.com/nltk/nltk_data
需要注意的是,必須要把數據放在指定的位置,具體的安裝位置可以調用一下download方法之后查看紅框中的路徑。或者也可以使用清華大學的鏡像源,使用命令:
pip3 install -i https://pypi.tuna.tsinghua.edu.cn/simple/nltk
下載好了之后,我們在Python當中執行:
fron nltk.book import *
如果出現以下結果,就說明已經安裝完畢:

去除停用詞
裝好了nltk之后,我們要做的第一個預處理是去除停用詞。
停用詞英文是stop words,指的是文本當中對語義無關緊要的詞匯。包含了常見的虛詞、助詞、介詞等等。這些詞語大部分只是修飾作用,對文本的語義內容起不到決定作用。因此在NLP領域當中,可以將其過濾,從而減少計算量提升模型精度。
Nltk當中為常見的主流語言提供了停用詞表(不包括中文),我們傳入指定的語言,將會返回一個停用詞的list。我們在分詞之后根據停用詞表進行過濾即可。

我們可以打印出所有英文的停用詞看一下,大部分都是一些虛詞和助詞,可能出現在所有語境當中,對我們對文本進行分類幾乎沒有幫助。
詞性歸一化
眾所周知,英文當中的單詞有很多形態。比如名詞有單復數形式,有些特殊的名詞復數形式還很不一樣。動詞有過去、現在以及未來三種時態,再加上完成時和第三人稱一般時等,又有很多變化。
舉例來說,do這個動詞在文本當中會衍生出許多小詞來。比如does, did, done, doing等,這些單詞雖然各不相同,但是表示的意思完全一樣。因此,在做英文NLP模型的時候,需要將這些時態的單詞都還原成最基本的時態,這被稱為是詞性歸一化。
原本這是一項非常復雜的工作,但我們有了nltk之后,這個工作變得簡單了很多。要做單詞歸一化,我們需要用到nltk當中的兩個工具。
第一個方法叫做pos_tag, 它接收一個單詞的list作為入參。返回也是一個tuple的list,每個tuple當中包含兩個值,一個是單詞本身,第二個參數就是我們想要的詞性。
舉個例子:

我們傳入只有一個單詞apple的list,在返回的結果當中除了apple之外,還多了一個NN,它表示apple是一個名詞nouns。
關於返回的詞性解釋,感興趣的可以自行查看官方文檔的說明。
我們這里並不需要區分那么細,只需要區分最常用的動詞、名詞、形容詞、副詞就基本上夠了。
我們可以直接根據返回結果的首字母做個簡單的映射:
from nltk import word_tokenize, pos_tag
from nltk.corpus import wordnet
from nltk.stem import WordNetLemmatizer
# 獲取單詞的詞性
def get_wordnet_pos(tag):
if tag.startswith('J'):
return wordnet.ADJ
elif tag.startswith('V'):
return wordnet.VERB
elif tag.startswith('N'):
return wordnet.NOUN
elif tag.startswith('R'):
return wordnet.ADV
else:
return None
通過pos_tag方法我們很容易就可以拿到單詞的詞性,但是這還不夠,我們還需要將它還原成最基礎的形態。這個時候需要用到另一個工具:WordNetLemmatizer
它的用途是根據單詞以及單詞的詞性返回單詞最一般的形態,也就是歸一化的操作。
舉個例子:

我們傳入了box的復數形式:boxes,以及box對應的名詞,它返回的結果正是我們想要的box。
我們結合剛剛實現的查詢單詞詞性的方法,就可以完成單詞的歸一化了。
到這里為止,關於文本的初始化就算是差不多結束了。除了剛剛提到的內容之外,nltk還包含許多其他非常方便好用的工具庫。由於篇幅的限制,我們不能一一窮盡,感興趣的同學可以自行鑽研。
下面,我們把剛才介紹的幾種文本預處理的方法一起用上,對所有的短信進行預處理:
for i in range(len(data)):
data[i] = re.sub("[^a-zA-Z ]", '', data[i].lower())
tokens = data[i].split(' ') # 分詞
tagged_sent = pos_tag([i for i in tokens if i and not i in stopwords.words('english')]) # 獲取單詞詞性
wnl = WordNetLemmatizer()
lemmas_sent = []
for tag in tagged_sent:
wordnet_pos = get_wordnet_pos(tag[1]) or wordnet.NOUN
lemmas_sent.append(wnl.lemmatize(tag[0], pos=wordnet_pos))
data[i] = lemmas_sent
通過nltk的工具庫,我們只需要幾行代碼,就可以完成文本的分詞、停用詞的過濾以及詞性的歸一化等工作。
接下來,我們就可以進行朴素貝葉斯的模型的訓練與預測了。
首先,我們需要求出背景概率。所謂的背景概率,也就是指在不考慮任何特征的情況下,這份樣本中信息當中天然的垃圾郵件的概率。
這個其實很簡單,我們只需要分別其實正常的郵件與垃圾郵件的數量然后分別除以總數即可:
def base_prob(labels):
pos, neg = 0.0, 0.0
for i in labels:
if i == 'ham':
neg += 1
else:
pos += 1
return pos / (pos + neg), neg / (pos + neg)
我們run一下測試一下結果:

可以看到垃圾郵件的概率只占13%,大部分郵件都是正常的。這也符合我們的生活經驗,畢竟垃圾郵件是少數。
接下來我們需要求出每個單詞屬於各個類別的概率,也就是要求一個單詞的概率表。這段代碼稍微復雜一些,但是也不麻煩:
def word_prob(data, labels):
n = len(data)
# 創建詞表
word_dict = {}
for i in range(n):
lab = labels[i]
# 先轉set再轉list,去除重復的常規操作
dat = list(set(data[i]))
for word in dat:
# 單詞不在dict中的時候創建dict,默認從1開始計數,為了防止除0
if word not in word_dict:
word_dict[word] = {'ham' : 1, 'spam': 1} # 拉普帕斯平滑避免除0
word_dict[word][lab] += 1
# 將數量轉化成概率
for i in word_dict:
dt = word_dict[i]
ham = dt['ham']
spam = dt['spam']
word_dict[i]['ham'] = ham / float(ham + spam)
word_dict[i]['spam'] = spam / float(ham + spam)
return word_dict
同樣,我們運行一下測試一下結果:

這些都有了之后,就是預測的重頭戲了。這里有一點需要注意,根據我們上文當中的公式,我們在預測文本的概率的時候,會用到多個概率的連乘。由於浮點數有精度限制,所以我們不能直接計算乘積,而是要將它轉化成對數相加,這樣我們就可以通過加法來代替乘法,就可以避免連乘帶來的精度問題了。
import math
def predict(samples, word_prob, base_p, base_n):
ret = []
for sam in samples:
neg = math.log(base_n)
pos = math.log(base_p)
for word in sam:
if word not in word_prob:
continue
neg += math.log(word_prob[word]['spam'])
pos += math.log(word_prob[word]['ham'])
ret.append('ham' if pos > neg else 'spam')
return ret
預測的方法也非常簡單,我們分別計算出一個文本屬於spam以及ham的概率,然后選擇概率較大的那個作為最終的結果即可。
我們將原始數據分隔成訓練集以及預測集,調用我們剛剛編寫的算法獲取預測的結果:
from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test = train_test_split(data, labels, test_size=0.25)
base_p, base_n = base_prob(y_train)
word_dt = word_prob(x_train, y_train)
ret = predict(x_test, word_dt, base_p, base_n)
最后,我們調用一下sklearn當中的classification_report方法來獲取貝葉斯模型的預測效果:

從上圖當中看,貝葉斯模型的預測效果還是不錯的。對於垃圾文本識別的准確率有90%,可惜的是召回率低了一點,說明有一些比較模糊的垃圾文本沒有識別出來。這也是目前這個場景下問題的難點之一,但總的來說,貝葉斯模型的原理雖然簡單,但是效果不錯,也正因此,時至今日,它依舊還在發揮着用處。
NLP是當今機器學習領域非常復雜和困難的應用場景之一,關於文本的預處理以及模型的選擇和優化都存在着大量的操作。本文當中列舉的只是其中最簡單也是最常用的部分。
到這里,關於朴素貝葉斯的實踐就結束了。我想親手從零開始寫出一個可以用的模型,一定是一件非常讓人興奮的事情。但關於朴素貝葉斯模型其實還沒有結束,它仍然有許多細節等待着大家去思考,也有很多引申的知識。模型雖然簡單,但仍然值得我們用心地體會。
今天的文章就到這里,掃碼關注我的公眾號,查看更多文章,你們的支持是我最大的動力。

