一. 前言
由於最近有一個郵件分類的工作需要完成,研究了一下基於SVM的垃圾郵件分類模型。參照這位作者的思路(https://blog.csdn.net/qq_40186809/article/details/88354825),使用trec06c這個公開的垃圾郵件語料庫(https://plg.uwaterloo.ca/~gvcormac/treccorpus06/)作為數據進行建模。並對代碼進行優化,提升訓練速度。
工作過程如下:
1,數據預處理,提取每一封郵件的內容,進行分詞,數據清洗。
2,選取特征,將郵件內容轉換為特征向量。
3,使用sklearn建立SVM模型。
4,代碼調整及優化。
二.數據預處理
trec06c這個數據集的數據比較特殊,由215個文件夾組成,每個文件夾下方包含300個編碼為GBK的郵件文件,都為原始郵件數據。共21766個正樣本,42854個負樣本,其樣本的正負性由文件夾下的index文件所標識。下方就是一個垃圾郵件(負樣本)的示例:
首先按照自己的需要,將index文件進行處理,控制正負樣本的數量比例持平,得到新的索引文件CN_index_ham、CN_index_spam,其內容為郵件的相對位置索引:
經過觀察,各郵件文件的前半部分為其收發、通信的基本信息,后半部分才是郵件的具體內容,兩部分之間以一個空行進行間隔。因此郵件預處理的思路為按行讀取整個郵件,搜索其第一個空行,並將該空行之后的每一行內容進行記錄、分詞、篩選停用詞等操作。
在預處理過程中還需解決以下2個小問題:
①編碼問題,雖然說文件確定是GBK編碼格式,但仍有部分奇奇怪怪的字符無法正確解碼,因此使用try操作將readline()操作進行包裹,遇到編碼有問題的內容直接讀取下一行。
②由於問題①使用try操作,在except中直接continue,使得在讀取郵件內容時,如果郵件最后一行的編碼有問題,直接continue進入下一次循環,而下一次循環已到文件末尾,沒有東西可讀,程序會反復進行readline()操作並跳入except,陷入無限循環。為解決這個問題,設置一個tag,在每進行一次except操作時,將此tag += 1,如果連續循環20次仍無新內容讀入,則結束文件讀取。
該部分代碼如下。
1 #coding:utf-8 2 import jieba 3 import pandas as pd 4 import numpy as np 5 6 from sklearn.feature_extraction.text import CountVectorizer 7 from sklearn.svm import SVC 8 from sklearn.model_selection import train_test_split 9 from sklearn.externals import joblib 10 11 path_list_spam = [] 12 with open('./data/CN_index_spam','r',encoding='utf-8') as fin: 13 for line in fin.readlines(): 14 path_list_spam.append(line.strip()) 15 16 path_list_ham = [] 17 with open('./data/CN_index_ham','r',encoding='utf-8') as fin: 18 for line in fin.readlines(): 19 path_list_ham.append(line.strip()) 20 21 stopWords = [] 22 with open('./data/CN_stopWord','r',encoding='utf-8') as fin: 23 for i in fin.readlines(): 24 stopWords.append(i.strip()) 25 26 # 定義一些超參數 27 MAX_EMAIL_LENGTH = 200 #最長郵件長度 28 THRESHOLD = len(path_list_ham)/50 # 超過這么多次的詞匯,入選dict 29 30 path_list_spam = path_list_spam[:5000] # 將正例、負例樣本取子集,先各取5000個做實驗 31 path_list_ham = path_list_ham[:5000] 32 # 下方定義數值類字符串檢驗函數 ,預處理時需要將數值信息清洗掉 33 def is_number(s): 34 try: 35 float(s) 36 return True 37 except ValueError: 38 pass 39 try: 40 import unicodedata 41 unicodedata.numeric(s) 42 return True 43 except (TypeError, ValueError): 44 pass 45 return False
上方代碼完成了讀取正例、負例樣本序列,讀取停用詞-stopword的工作。並且定義了兩個超參數,MAX_EMAIL_LENGTH為郵件讀取詞匯最差長度,這里設為200,避免讀取到2/3千字的超長郵件,占據過大內存;THRESHOLD是特征詞入選閾值,如當THRESHOLD=20時,某個詞匯在超過20封郵件中出現過,則將它列為特征詞之一。定義is_number()函數來判斷某個字符串是否為數字,以便於將其清洗出去。
1 def email_cut(path_list): 2 emali_str_list = [] 3 for i in range(len(path_list)): 4 print('====== ',i,' =======') 5 print(path_list[i]) 6 with open(path_list[i],'r',encoding='gbk') as fin: 7 words = [] 8 begin_tag = 0 9 wrong_tag = 0 10 while(True): 11 if wrong_tag > 20 or len(words)>MAX_EMAIL_LENGTH: 12 break 13 try: 14 line = fin.readline() 15 wrong_tag = 0 16 except: 17 wrong_tag += 1 18 continue 19 if (not line): 20 break 21 if(begin_tag == 0): 22 if(line=='\n'): 23 begin_tag = 1 24 continue 25 else: 26 l = jieba.cut(line.strip()) 27 ll = list(l) 28 for word in ll: 29 if word not in stopWords and word != '\n' and word != '\t' and word != ' ' and not is_number(word): # 30 words.append(word) 31 if len(words)>MAX_EMAIL_LENGTH: # 一封email最大詞匯量設置 32 break 33 wordStr = ' '.join(words) 34 emali_str_list.append(wordStr) 35 return emali_str_list
上方函數email_cut() 的輸入參數為 path_list_ham 以及 path_list_spam,該函數根據這些郵件的path地址,將其信息按行進行讀取,並使用jieba進行分詞,清洗掉轉義字符以及數值類字符串,最終將所有郵件的數據存入 emali_str_list 進行返回。
三.特征選取
在這一步中,使用 sklearn 中的 CountVectorizer 類輔助。統計所有郵件數據中出現的詞匯,並對這些詞匯進行篩選,選出現次數出大於 THRESHOLD 的部分,組成詞匯表,並對郵件文本數據進行轉換,以向量形式表示。
1 def textToMatrix(text): 2 cv = CountVectorizer() 3 cv.fit(text) 4 vocabulary = cv.vocabulary_ 5 vector = cv.transform(text) 6 result = pd.DataFrame(vector.toarray()) 7 del(vector) # 及時刪除以節省內存空間 8 features = []# 儲存特征值 9 for key, value in vocabulary.items(): # key, value 示例 '孔子', 23772 即 詞匯,字符串 的形式 10 if result[value].sum() >= THRESHOLD: 11 features.append(key) # 加入詞匯表 12 result.rename(columns={value:key}, inplace=True) # 本來的列名是索引值value,現在改成key ('孔子'、'后人'、'家鄉' ..等詞匯) 13 return result[features] # 縮減特征矩陣規模,僅將特征詞匯表中的列留下
在上方函數中,使用CountVectorizer()將郵件內容(即包含n條字符串的List,每個字符串代表一封郵件)進行統計,獲取詞匯列表,並將郵件內容進行轉換,轉換成一個稀疏矩陣,該郵件沒有出現過的詞匯索引下方對應的值為0,出現過的詞匯索引下方對應的值為該詞在本郵件中出現過的次數。在for循環中,查看詞匯在所有郵件中出現的次數是否大於THRESHOLD ,如大於,則將該位置的列首索引替換為該詞匯本身(key為詞匯,value為詞語本身),最后對大的郵件特征矩陣進行精簡,僅留下特征詞所屬的列進行返回。最終返回的結果大概是下面這種樣式:
最上方一行漢語詞匯為特征詞匯,下面每一行數據代表一封Email的內容,其數值代表對應詞匯在這個Email中的出現次數。可以看出,SVM不能對語句的順序關系進行學習,不同的Email內容可能對應着同樣的特征向量結果。例如:“我想要吃大蘋果” 與“吃蘋果想要大我” 對應的特征向量是一模一樣的。不過一般來講問題不大,畢竟研表究明,漢字的序順並不能影閱響讀嘛。
四.建立SVM模型
最后,使用sklearn的SVC模塊對所有郵件的特征向量進行建模訓練。
1 ham_str_all = email_cut(path_list_ham) 2 spam_str_all = email_cut(path_list_spam) 3 allWord = [] 4 allWord.extend(ham_str_all) 5 allWord.extend(spam_str_all) 6 labels = []#標簽 7 labels.extend(np.ones(len(path_list_ham))) 8 labels.extend(np.zeros(len(path_list_spam))) 9 vector = textToMatrix(allWord)#獲取特征向量 10 print(vector) 11 feature = list(vector.columns) 12 print("feature length: ",len(feature)) 13 with open('./model/CN_features.txt', 'w', encoding="UTF-8") as f: 14 s = ' '.join(feature) 15 f.write(s) 16 svm = SVC(kernel='linear', C=0.5, random_state=0) # 線性核,C的值較小時可以允許一些錯誤 可選核: 'linear', 'poly', 'rbf', 'sigmoid', 'precomputed' 17 # 將數據分成測試集和訓練集 18 X_train, X_test, y_train, y_test = train_test_split(vector, labels, test_size=0.3, random_state=0) 19 svm.fit(X_train, y_train) 20 print(svm.score(X_test, y_test)) 21 model = joblib.dump(svm,'./model/svm_model.m')
首先是讀取正例郵件和反例郵件,並生成其對應的label序列,將郵件轉化為由特征向量組成的matrix(在本例中,特征詞匯正好有256個,也就是說特征向量的維度為256),保存特征詞匯,使用SVC模塊建立SVM模型,分離訓練集與測試集,擬合訓練,對測試集進行計算評分后保存模型。
五.代碼調整及優化
整個實踐建模的過程其實到上面已經結束了,但在實際使用的過程中,發現有下面2個問題。
①訓練速度極慢,5000個正樣本+5000個負樣本需要訓練2個小時。這完全不是svm的訓練速度,而是神經網絡的訓練速度了。在參考的那篇博客中,作者(Ning_wxh)也提到,他的機器只能各取600個正樣本/反樣本進行訓練,再多機器就受不了了。
②內存消耗太大,我電腦16GB的內存都被占滿,不停的從虛擬內存中進行數據交換。下圖內存占用圖中,周期型的鋸齒狀波動表明了實體內存在與虛擬內存作交換。
先說第②個問題。這個問題通過設置 MAX_EMAIL_LENGTH(郵件最大詞匯數目) 和 增加 THRESHOLD 的值來實現的。設置郵件最大詞匯數目為200,避免將幾千字的Email內容全部讀入;而最開始的THRESHOLD值設置為10,最終的特征向量維度為900+,特征向量過於稀疏,便將THRESHOLD設置為樣本總數的50分之1,即100,將維度降為256。此外在textToMatrix()函數中,將vector變量及時刪除,清空內存開銷。這3個步驟,在正/負樣本數量都為5000時,將內存消耗控制在10GB以下。
再說第①個問題。經過不停的錨點調試,發現時間消耗最大的一步語句是textToMatrix()函數中的: result.rename(columns={value:key}, inplace=True) 語句。這條語句的意思是將pd.DataFrame的某列列名進行替換,由value替換為key。由於我們的原始詞匯較多,導致有40000多列數據,定位value列的過程開銷較大,導致較大的時間開銷。原因已經找到,解決這個問題的思路由兩個:一是對列名構建索引,以便快速定位;二是重新構建一個新的pd.DataFrame數據表,將改名操作批量進行。
這里選擇第二種思路,就是空間換時間嘛,重寫textToMatrix()函數如下:
1 def textToMatrix(text): 2 cv = CountVectorizer() 3 cv.fit(text) 4 vocabulary = cv.vocabulary_ 5 vector = cv.transform(text) 6 result = pd.DataFrame(vector.toarray()) 7 del(vector) 8 features = []# 儲存特征值 9 origin_data = np.zeros((len(result),1)) # 新建的數據表 10 for key, value in vocabulary.items(): 11 if result[value].sum() >= THRESHOLD: 12 features.append(key) 13 origin_data = np.column_stack((origin_data,np.array(result[value]))) # 按列堆疊到新數據表 14 origin_data = origin_data[:,1:] # 刪掉初始化的第一列全0數據 15 print('origin_data shape: ',origin_data.shape) 16 origin_data = pd.DataFrame(origin_data) # 轉換為DataFrame對象 17 origin_data.columns = features # 批量修改列名 18 print('features length: ',len(features)) 19 return origin_data
最終,僅耗時2分鍾便完成SVM模型的訓練,比優化代碼之前速度提高了60倍。在測試集上的預測精度為0.93666,即93.6%的准確率,也算是比較實用了。