一. 前言

由於最近有一個郵件分類的工作需要完成,研究了一下基於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. 1 #coding:utf-8
  2. 2 import jieba
  3. 3 import pandas as pd
  4. 4 import numpy as np
  5. 5
  6. 6 from sklearn.feature_extraction.text import CountVectorizer
  7. 7 from sklearn.svm import SVC
  8. 8 from sklearn.model_selection import train_test_split
  9. 9 from sklearn.externals import joblib
  10. 10
  11. 11 path_list_spam = []
  12. 12 with open('./data/CN_index_spam','r',encoding='utf-8') as fin:
  13. 13 for line in fin.readlines():
  14. 14 path_list_spam.append(line.strip())
  15. 15
  16. 16 path_list_ham = []
  17. 17 with open('./data/CN_index_ham','r',encoding='utf-8') as fin:
  18. 18 for line in fin.readlines():
  19. 19 path_list_ham.append(line.strip())
  20. 20
  21. 21 stopWords = []
  22. 22 with open('./data/CN_stopWord','r',encoding='utf-8') as fin:
  23. 23 for i in fin.readlines():
  24. 24 stopWords.append(i.strip())
  25. 25
  26. 26 # 定義一些超引數
  27. 27 MAX_EMAIL_LENGTH = 200 #最長郵件長度
  28. 28 THRESHOLD = len(path_list_ham)/50 # 超過這麼多次的詞彙,入選dict
  29. 29
  30. 30 path_list_spam = path_list_spam[:5000] # 將正例、負例樣本取子集,先各取5000個做實驗
  31. 31 path_list_ham = path_list_ham[:5000]
  32. 32 # 下方定義數值類字串檢驗函式 ,預處理時需要將數值資訊清洗掉
  33. 33 def is_number(s):
  34. 34 try:
  35. 35 float(s)
  36. 36 return True
  37. 37 except ValueError:
  38. 38 pass
  39. 39 try:
  40. 40 import unicodedata
  41. 41 unicodedata.numeric(s)
  42. 42 return True
  43. 43 except (TypeError, ValueError):
  44. 44 pass
  45. 45 return False

上方程式碼完成了讀取正例、負例樣本序列,讀取停用詞-stopword的工作。並且定義了兩個超引數,MAX_EMAIL_LENGTH為郵件讀取詞彙最差長度,這裡設為200,避免讀取到2/3千字的超長郵件,佔據過大記憶體;THRESHOLD是特徵詞入選閾值,如當THRESHOLD=20時,某個詞彙在超過20封郵件中出現過,則將它列為特徵詞之一。定義is_number()函式來判斷某個字串是否為數字,以便於將其清洗出去。

  1. 1 def email_cut(path_list):
  2. 2 emali_str_list = []
  3. 3 for i in range(len(path_list)):
  4. 4 print('====== ',i,' =======')
  5. 5 print(path_list[i])
  6. 6 with open(path_list[i],'r',encoding='gbk') as fin:
  7. 7 words = []
  8. 8 begin_tag = 0
  9. 9 wrong_tag = 0
  10. 10 while(True):
  11. 11 if wrong_tag > 20 or len(words)>MAX_EMAIL_LENGTH:
  12. 12 break
  13. 13 try:
  14. 14 line = fin.readline()
  15. 15 wrong_tag = 0
  16. 16 except:
  17. 17 wrong_tag += 1
  18. 18 continue
  19. 19 if (not line):
  20. 20 break
  21. 21 if(begin_tag == 0):
  22. 22 if(line=='\n'):
  23. 23 begin_tag = 1
  24. 24 continue
  25. 25 else:
  26. 26 l = jieba.cut(line.strip())
  27. 27 ll = list(l)
  28. 28 for word in ll:
  29. 29 if word not in stopWords and word != '\n' and word != '\t' and word != ' ' and not is_number(word): #
  30. 30 words.append(word)
  31. 31 if len(words)>MAX_EMAIL_LENGTH: # 一封email最大詞彙量設定
  32. 32 break
  33. 33 wordStr = ' '.join(words)
  34. 34 emali_str_list.append(wordStr)
  35. 35 return emali_str_list

上方函式email_cut() 的輸入引數為 path_list_ham 以及 path_list_spam,該函式根據這些郵件的path地址,將其資訊按行進行讀取,並使用jieba進行分詞,清洗掉轉義字元以及數值類字串,最終將所有郵件的資料存入 emali_str_list 進行返回。

三.特徵選取

在這一步中,使用 sklearn 中的 CountVectorizer 類輔助。統計所有郵件資料中出現的詞彙,並對這些詞彙進行篩選,選出現次數出大於 THRESHOLD 的部分,組成詞彙表,並對郵件文字資料進行轉換,以向量形式表示。

  1. 1 def textToMatrix(text):
  2. 2 cv = CountVectorizer()
  3. 3 cv.fit(text)
  4. 4 vocabulary = cv.vocabulary_
  5. 5 vector = cv.transform(text)
  6. 6 result = pd.DataFrame(vector.toarray())
  7. 7 del(vector) # 及時刪除以節省記憶體空間
  8. 8 features = []# 儲存特徵值
  9. 9 for key, value in vocabulary.items(): # key, value 示例 '孔子', 23772 即 詞彙,字串 的形式
  10. 10 if result[value].sum() >= THRESHOLD:
  11. 11 features.append(key) # 加入詞彙表
  12. 12 result.rename(columns={value:key}, inplace=True) # 本來的列名是索引值value,現在改成key ('孔子'、'後人'、'家鄉' ..等詞彙)
  13. 13 return result[features] # 縮減特徵矩陣規模,僅將特徵詞彙表中的列留下

在上方函式中,使用CountVectorizer()將郵件內容(即包含n條字串的List,每個字串代表一封郵件)進行統計,獲取詞彙列表,並將郵件內容進行轉換,轉換成一個稀疏矩陣,該郵件沒有出現過的詞彙索引下方對應的值為0,出現過的詞彙索引下方對應的值為該詞在本郵件中出現過的次數。在for迴圈中,檢視詞彙在所有郵件中出現的次數是否大於THRESHOLD ,如大於,則將該位置的列首索引替換為該詞彙本身(key為詞彙,value為詞語本身),最後對大的郵件特徵矩陣進行精簡,僅留下特徵詞所屬的列進行返回。最終返回的結果大概是下面這種樣式:

最上方一行漢語詞彙為特徵詞彙,下面每一行資料代表一封Email的內容,其數值代表對應詞彙在這個Email中的出現次數。可以看出,SVM不能對語句的順序關係進行學習,不同的Email內容可能對應著同樣的特徵向量結果。例如:“我想要吃大蘋果” 與“吃蘋果想要大我” 對應的特徵向量是一模一樣的。不過一般來講問題不大,畢竟研表究明,漢字的序順並不能影閱響讀嘛。

四.建立SVM模型

最後,使用sklearn的SVC模組對所有郵件的特徵向量進行建模訓練。

  1. 1 ham_str_all = email_cut(path_list_ham)
  2. 2 spam_str_all = email_cut(path_list_spam)
  3. 3 allWord = []
  4. 4 allWord.extend(ham_str_all)
  5. 5 allWord.extend(spam_str_all)
  6. 6 labels = []#標籤
  7. 7 labels.extend(np.ones(len(path_list_ham)))
  8. 8 labels.extend(np.zeros(len(path_list_spam)))
  9. 9 vector = textToMatrix(allWord)#獲取特徵向量
  10. 10 print(vector)
  11. 11 feature = list(vector.columns)
  12. 12 print("feature length: ",len(feature))
  13. 13 with open('./model/CN_features.txt', 'w', encoding="UTF-8") as f:
  14. 14 s = ' '.join(feature)
  15. 15 f.write(s)
  16. 16 svm = SVC(kernel='linear', C=0.5, random_state=0) # 線性核,C的值較小時可以允許一些錯誤 可選核: 'linear', 'poly', 'rbf', 'sigmoid', 'precomputed'
  17. 17 # 將資料分成測試集和訓練集
  18. 18 X_train, X_test, y_train, y_test = train_test_split(vector, labels, test_size=0.3, random_state=0)
  19. 19 svm.fit(X_train, y_train)
  20. 20 print(svm.score(X_test, y_test))
  21. 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. 1 def textToMatrix(text):
  2. 2 cv = CountVectorizer()
  3. 3 cv.fit(text)
  4. 4 vocabulary = cv.vocabulary_
  5. 5 vector = cv.transform(text)
  6. 6 result = pd.DataFrame(vector.toarray())
  7. 7 del(vector)
  8. 8 features = []# 儲存特徵值
  9. 9 origin_data = np.zeros((len(result),1)) # 新建的資料表
  10. 10 for key, value in vocabulary.items():
  11. 11 if result[value].sum() >= THRESHOLD:
  12. 12 features.append(key)
  13. 13 origin_data = np.column_stack((origin_data,np.array(result[value]))) # 按列堆疊到新資料表
  14. 14 origin_data = origin_data[:,1:] # 刪掉初始化的第一列全0資料
  15. 15 print('origin_data shape: ',origin_data.shape)
  16. 16 origin_data = pd.DataFrame(origin_data) # 轉換為DataFrame物件
  17. 17 origin_data.columns = features # 批量修改列名
  18. 18 print('features length: ',len(features))
  19. 19 return origin_data

最終,僅耗時2分鐘便完成SVM模型的訓練,比優化程式碼之前速度提高了60倍。在測試集上的預測精度為0.93666,即93.6%的準確率,也算是比較實用了。