一. 前言
由于最近有一个邮件分类的工作需要完成,研究了一下基于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==' '): 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 != ' ' and word != ' ' 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%的准确率,也算是比较实用了。