機器學習應用-「垃圾訊息偵測」與「TF-IDF介紹」(含範例程式)

Tommy Huang
20 min readJul 12, 2018

--

[2019/02/27] kaggle內的spam.csv將我範例有效訊息的label從genuine改成ham(這樣才和UCI載下來的資料label一樣),所以如果要直接用我的程式,最簡單的方式就是ham改回genuine,文章後續內容針對這部分我就沒有介紹,但我最下方的完整範例code有修改好(加了line 24~26的內容即可),就可以直接run了。

如果有看我文章的人,應該大多只看到分類器之類的方法介紹,我鮮少做特徵工程的介紹。這篇文章剛好因為是實際應用,所以從最原始的資料,怎麼做到可以分類或是預測,中間會有一些手法在裡面,包含文字探勘(特徵工程),所以還是有介紹一下TF-IDF這個文字探勘的手法。

此篇應用是以UCI database上面的一個資料庫「SMS Spam Collection Dataset」當作範例來講,這個資料庫同步在Kaggle上也有,兩個連結我都列在下面:
https://archive.ics.uci.edu/ml/datasets/sms+spam+collection
https://www.kaggle.com/uciml/sms-spam-collection-dataset#spam.csv

這個資料庫目的是什麼

資料庫名稱: SMS Spam Collection Dataset

概述: Collection of SMS messages tagged as spam or legitimate

目的: 所以簡單說就是給你一串SMS字串,然後要判斷他是垃圾訊息(spam)還是有效訊息(legitimate)。

資料內容描述

你到剛剛說的連結都可以載到這個資料庫的資料

如果是去UCI資料庫下載,裡面除了說明檔(readme)之外,只有一個檔案檔名為「SMSSpamCollection」(可以用csv打開看)。
如果去Kaggle下載,裡面只有一個檔案「spam.csv」。

我們將檔案打開看就會有兩欄的資料,如下圖所示:
第一欄就是資料的ground truth也就是被標記好的類別只有「spam(垃圾訊息)」和「genuine(有效訊息)」兩類。
第二欄就是訊息本身的內容。

spam.cvs用excel打開。Note: genuine的label名稱已經改成ham了。

特徵工程: TF-IDF介紹

做垃圾訊息分類之前,我們需要先對訊息本身的文字進行探勘,將訊息內容轉換成特徵向量,在進行分類程序。
因為我本身不是專門做文字探勘或語意分析,以前有碰過短暫的語意分析,大概玩到SVD就沒碰了,所以我對較新的文字處理方法比較不熟悉,比如word2vec或是sentence2vec那些方法。這邊會用到的方法稱為Term Frequency — Inverse Document Frequency (TF-IDF),對所有的文字進行分析與運用,進而取得必要的特徵向量,作為後續分類的參考依據。TF-IDF 是一種常用於資訊檢索與文字探勘的統計方法,用來評估「詞」對於「文件」的重要程度,所以TF-IDF方法裡面關鍵的部分是「詞」和「文件」。

在本範例
「詞」就是每個「字」,如OK、Free、I…類似的單詞。
「文件」就是每一個SMS訊息,每一個訊息都是一個文件。

Note:英文的斷「詞」稍微容易一些,比如:「The weather is good today.」,所以可以拆成「The」、「weather」、「is」、「good」和「today」五個字,英文幾乎可以一個字拆成一個「詞」,雖然中間的冠詞或是b動詞不一定有用,可以設條件刪除這些詞。但在中文的斷詞比較麻煩,比如:「今天天氣很好」,中文的詞比須斷成「今天」、「天氣」和「很好」三個詞,但「今天天氣很好」也可以斷成「今」、「天」、「天」、「氣」、「很」、「好」五個字,但有意義嗎? 這個我也不專長,中研院有一套斷詞系統叫 CKIP(http://ckipsvr.iis.sinica.edu.tw/),可以參考看看。

將TF-IDF兩個字拆開來看

TF(term frequency): 詞頻
從詞頻這兩個字來看可以得知,我們應該要算「詞」出現在該文件中的次數,第t個詞在第d個文件出現的次數用nt,d表示。但因為在每個文件中字數不一定相同,假設「文件1」有1000個字,「文件2」只有10個,「詞1」在「文件1」出現了10次 (n1,1=10),「詞1」在「文件2」也出現了5次(n1,2=5),我們不能從次數下個結論說「詞1」在「文件1」比較重要,因為就比例來說「詞1」在「文件2」占了整體50%,但在「文件」1只佔了1%。為了消除這個疑慮,我們必須將所有的「詞」在每個文件中都各自標準化,讓「詞」在各自「文件」中出現只剩下頻率(比例),通常用tf(t,d)表示,代表第t個詞在第d個文件出現的頻率。

IDF(inverse document frequency): 逆向文件頻率
TF是處理每一個「文件」中所有「詞」的問題。
IDF是處理每一個「詞」在所有「文件」中的問題。

假設「詞t」在總共在dt篇文章中出現過,「詞t」的IDF定義為

什麼意思哩?
假設「詞1」總共出現在100文章內,「詞1」的IDF就是

假設D是「所有文件的總數」(但也可能為「所有詞的總數」),由公式可以得知「詞」在越多「文件」中出現代表,相對應的idf會比較小,也就是這個「詞」可能沒什麼屁用,比如說「is、with、the」這類型的「詞」。

TF-IDF

TF-IDF就是透過TF和IDF算每一個「詞」對每一篇「文件」的分數(score),定義為

所以假設「詞t」很常出現在「文件d」,那tft,d會大,相對的「詞t」很少出現在其他「文件」中,idft就也會大,所以整體scoret,d也會大,TF-IDF矩陣如下圖:

Spam分類實際應用TF-IDF

實際應用在TF-IDF這部分滿有趣的,前一章介紹了TF-IDF的方法,但真的是直接這樣用嗎?

從我們這次的應用,共有5572筆SMS「文件」,那共會有多少「詞」,有些「詞」根本指會在某一個「文件」出現,比如說有個訊息是「Chih-Sheng Huang can speak Chinese.」,這時候可以知道「Chih-Sheng」應該是只會出現在這個「文件」,其他應該不會出現,那這種「詞」就很沒有用,且TF-IDF是將訊息內容轉換成特徵向量,所以「詞」的數量決定了特徵向量的維度數,所以基本上我們不會把所有的「詞」都列出來。

這邊我們就會介紹如何用手法取出比較重要的「詞」,什麼是重要的「詞」

什麼是重要的詞
垃圾訊息很長出現的「詞」,在正常訊息希望這個「詞」不要太長出現。
正常訊息很長出現的「詞」,在垃圾訊息希望這個「詞」不要太長出現。
比如: 垃圾訊息「Congratulations. You won USD$200.」,這種「won」和「USD$200」就比較不會是正常訊息會出現的「詞」,這種「詞」就會很重要。所以手法就是,將所有垃圾訊息當作是同一個文件,所有正常訊息當作是另一個文件,所以我們要將所有出現在「Spam」和「Genuine」的「詞」都列出來,並且算出TF-IDF矩陣。

然後將Spam算出的TF-IDF去減上Genuine算出的TF-IDF,這個值越大,代表這個「詞」對判別是不是Spam訊息的重要性越大,所以這個「詞」就越需要被選出來用,當然這邊你可以自己決定你只要選多少「詞」(特徵向量的維度數)出來用。

當重要的「詞」都選出來了,就可以去轉換訓練資料的特徵向量。

Spam分類在python上的實現

這邊我不用Jupyter操作,第一Medium不能直接用「.ipynb」,第二我有用過Jupyter但對Jupyter操作沒那麼熟悉,第三我貼上文字檔你們也比較好copy一步一步去操作,我的範例python code是在Spyder上執行的。

1. 首先我們先將資料匯入python內,我們會用到的pandas,pandas對處理這種文字資料滿好用的。

import pandas as pddef readData_rawSMS(filepath):
data_rawSMS = pd.read_csv(filepath,usecols=[0,1],encoding='latin-1')
data_rawSMS.columns=['label','content']
return data_rawSMS
data_rawSMS = readData_rawSMS(filepath)

我把data_rawSMS開出來,結果如下:

2. 將資料分成Train和Test,如果有看過我介紹Cross-validation的人,這邊切Train和Test的方式請見諒,我很偷懶直接random去切割,完全沒有考慮「Spam」和「Genuine」的分佈。我這邊是讓每筆資料隨機產生0~1的數字,數字大於等於0.5當作training data,其他是testing data。

def Separate_TrainAndTest(data_rawSMS):
n=int(data_rawSMS.shape[0])
tmp_train=(np.random.rand(n)>=0.5)
return data_rawSMS.iloc[np.where(tmp_train==True)[0]],data_rawSMS.iloc[np.where(tmp_train==False)[0]]

結果如下:

3. 從training data去著手算哪些「詞」重要。
這邊有點的function,我加了兩個參數
「size_table」: 要選多少個重要的「詞」出來,等於決定特徵向量的維度數。Default:我設成200。
「ignore」: 英文字,字少於幾個以下就不要算,比如: 「I」就是1個字,「no」是2個字。Default:我設成3個。
這個function(generate_key_list)輸出是keyword_dict

import re
def generate_key_list(data_rawtrain, size_table=200,ignore=3):
dict_spam_raw = dict()
dict_genuine_raw = dict()
dict_IDF = dict()
# ignore all other than letters.
for i in range(data_rawSMS.shape[0]):
finds = re.findall('[A-Za-z]+', data_rawSMS.iloc[i].content)
if data_rawSMS.iloc[i].label == 'spam':
for find in finds:
if len(find)<ignore: continue
find = find.lower() #英文轉成小寫
try:
dict_spam_raw[find] = dict_spam_raw[find] + 1
except:
dict_spam_raw[find] = dict_spam_raw.get(find,1)
dict_genuine_raw[find] = dict_genuine_raw.get(find,0)
else:
for find in finds:
if len(find)<ignore: continue
find = find.lower()
try:
dict_genuine_raw[find] = dict_genuine_raw[find] + 1
except:
dict_genuine_raw[find] = dict_genuine_raw.get(find,1)
dict_spam_raw[find] = dict_spam_raw.get(find,0)

word_set = set()
for find in finds:
if len(find)<ignore: continue
find = find.lower()
if not(find in word_set):
try:
dict_IDF[find] = dict_IDF[find] + 1
except:
dict_IDF[find] = dict_IDF.get(find,1)
word_set.add(find)
word_df = pd.DataFrame(list(zip(dict_genuine_raw.keys(),dict_genuine_raw.values(),dict_spam_raw.values(),dict_IDF.values())))
word_df.columns = ['keyword','genuine','spam','IDF']
word_df['genuine'] = word_df['genuine'].astype('float')/data_rawtrain[data_rawtrain['label']=='genuine'].shape[0]
word_df['spam'] = word_df['spam'].astype('float')/data_rawtrain[data_rawtrain['label']=='spam'].shape[0]
word_df['IDF'] = np.log10(word_df.shape[0]/word_df['IDF'].astype('float'))
word_df['genuine_IDF'] = word_df['genuine']*word_df['IDF']
word_df['spam_IDF'] = word_df['spam']*word_df['IDF']
word_df['diff']=word_df['spam_IDF']-word_df['genuine_IDF']
selected_spam_key = word_df.sort_values('diff',ascending=False)
keyword_dict = dict()
i = 0
for word in selected_spam_key.head(size_table).keyword:
keyword_dict.update({word.strip():i})
i+=1
return keyword_dict
# build a tabu list based on the training data
size_table = 300 # how many features are used to classify spam
word_len_ignored = 3 # ignore those words shorter than this variable
keyword_dict=generate_key_list(data_rawtrain, size_table, word_len_ignored)

結果如下,排序越前面的越重要,所以「call」、「free」是spam訊息最重要的兩個關鍵字,我這邊列出17個spam訊息比較重要的「詞」。

4. 將Train資料和Test資料轉換成特徵向量,因為這邊是訊息,基本上每個SMS文字量不太,我們直接算TF(term frequency),「詞」在「文件」出現的是或否當特徵向量即可,然後將「spam」的label轉成「1」,「Genuine」的label轉成「0」。

def convert_Content(content, keyword_dict):
m = len(keyword_dict)
res = np.int_(np.zeros(m))
finds = re.findall('[A-Za-z]+', content)
for find in finds:
find=find.lower()
try:
i = keyword_dict[find]
res[i]=1
except:
continue
return res
def raw2feature(data_rawtrain,data_rawtest,keyword_dict):
n_train = data_rawtrain.shape[0]
n_test = data_rawtest.shape[0]
m = len(keyword_dict)
X_train = np.zeros((n_train,m));
X_test = np.zeros((n_test,m));
Y_train = np.int_(data_rawtrain.label=='spam')
Y_test = np.int_(data_rawtest.label=='spam')
for i in range(n_train):
X_train[i,:] = convert_Content(data_rawtrain.iloc[i].content, keyword_dict)
for i in range(n_test):
X_test[i,:] = convert_Content(data_rawtest.iloc[i].content, keyword_dict)

return [X_train,Y_train],[X_test,Y_test]

Train,Test=raw2feature(data_rawtrain,data_rawtest,keyword_dict)

結果如下:
訓練資料(Train)為2806(訓練筆數) × 300(維度數)
測試資料(Test)為2766(訓練筆數) × 300(維度數)

5. 依據特徵資料訓練分類器,這邊我用scikit-learn的隨機森林樹(Random Forest) (我設10顆樹)和Bernoulli Naive Bayes (因為特徵資料是0和1兩種)。

from sklearn.ensemble import RandomForestClassifier
from sklearn.naive_bayes import BernoulliNB
def learn(Train):
model_NB = BernoulliNB()
model_NB.fit(Train[0], Train[1])
Y_hat_NB = model_NB.predict(Train[0])
model_RF = RandomForestClassifier(n_estimators=10, max_depth=None,\
min_samples_split=2, random_state=0)
model_RF.fit(Train[0], Train[1])
Y_hat_RF = model_RF.predict(Train[0])

n=np.size(Train[1])
print('Training Accuarcy NBclassifier : {:.2f}%'.format(sum(np.int_(Y_hat_NB==Train[1]))*100./n))
print('Training Accuarcy RF: {:.2f}%'.format(sum(np.int_(Y_hat_RF==Train[1]))*100./n))
return model_NB,model_RF
# train the Random Forest and the Naive Bayes Model using training data
model_NB,model_RF=learn(Train)

結果如下:

6. 依據訓練好的分類器,進行測試。

def test(Test,model):
Y_hat = model.predict(Test[0])
n=np.size(Test[1])
print ('Testing Accuarcy: {:.2f}% ({})'.format(sum(np.int_(Y_hat==Test[1]))*100./n,model.__module__))
# Test Model using testing data
test(Test,model_NB)
test(Test,model_RF)

結果如下:

從訓練結果和測試結果來看,隨機森林樹部份有overfitting,測試結果比較差,但我們這邊沒有要去做比較不同分類器的效果,這邊你也可以用SVM去training做分類,這篇只是實際操作一個範例。

最後補上一個function,如果你有訓練好的模型,你也想自己打一段字判斷是不是垃圾訊息,可以用以下方式:

#######
def predictSMS(SMS,model,keyword_dict):
X = convert_Content(SMS, keyword_dict)
Y_hat = model.predict(X.reshape(1,-1))
if int(Y_hat) == 1:
print ('SPAM: {}'.format(SMS))
else:
print ('GENUINE: {}'.format(SMS))

這邊舉兩個訊息輸入:
1. go to visit www.yahoo.com.tw, Buy one get one free, Hurry!

inputstr='go to visit www.yahoo.com.tw, Buy one get one free, Hurry!'
predictSMS(inputstr,model_NB,keyword_dict)

2. Call back for anytime.

inputstr=('Call back for anytime.')
predictSMS(inputstr,model_NB,keyword_dict)

結果如下:

以下為python範例的完整code

本篇範例版本:
Python version: 3.6.5
scikit-learn version:0.19.1
pandas version: 0.23.0
re version: 2.2.1
numpy version: 1.14.3

--

--

Tommy Huang
Tommy Huang

Written by Tommy Huang

怕老了忘記這些吃飯的知識,開始寫文章記錄機器/深度學習相關內容。Medium現在有打賞功能(每篇文章最後面都有連結),如果覺得寫的文章不錯,也可以Donate給個Tipping吧。黃志勝 Chih-Sheng Huang (Tommy), mail: chih.sheng.huang821@gmail.com

Responses (4)