6 .2 基于K近邻算法的垃圾邮件分类#

在6.1节内容中,我们介绍了2种简单的词袋模型,接下来我们以第2种词袋模型表示方法为例,通过K近邻算法来对垃圾邮件进行分类处理。下面用到的是一个中文的邮件分类数据集,包含垃圾邮件和非垃圾邮件两类,即一个二分类任务。其中ham_5000.utf8spam_5000.utf8这两个文件中分别包含5000封正常邮件和垃圾邮件,文件中每行分别表示一封邮件,示例如下:

“我的意中人是一个盖世英雄,有一天他会踩着七色的云彩来娶我,我猜中了前头,可是我猜不着这结局”世间一切美好都有有效期限吧,坦然面对,接受幸福的彩排。

总地来讲,要完成这一文本分类任务,首先需要载入原始文本并对其中的每个样本进行分词处理,接着通过上面介绍的CountVectorizer类来完成文本的向量化表示,并制作完成每个样本对应的类别以便构成一个完整的数据集,最后根据K近邻算法完成分类任务。不过在正式介绍文本分类任务之前,我们先来介绍如何对训练好的模型进行持久化和复用。

6.2.1 复用模型#

在实际应用场景中,我们不可能每次在对新数据进行预测时都从头开始训练一个模型。通常,模型在第1次训练完成后会被持久化保存下来,并且只要后续不需要再对模型做任何改动,在对新数据进行预测时,只需载入已有的模型进行复用[1]。完整代码参见AllBooKCode/Chapter06/C05_bag_of_word_cla.py 文件。

1. 保存模型

首先需要定义一个函数来对传入的模型进行保存,代码如下:

1 import joblib
2 def save_model(model, dir='MODEL', MODEL_NAME='model.pkl'):
3     if not os.path.exists(dir):
4         os.mkdir(dir)
5     path = os.path.join(dir, MODEL_NAME)
6     joblib.dump(model,path )

在上述代码中,第3~4行用来判断当前是否存在MODEL这个目录。如果不存在则创建。第5行是根据目录名称和模型名称拼接模型的保存路径。第6行用来将传入的模型以MODEL_NAME的名称保存到MODEL目录中。

2. 复用模型

在复用模型之前,需要先定义一个函数来对已有的模型进行载入,代码如下:

1 def load_model(dir='MODEL', MODEL_NAME='model.pkl'):
2     path = os.path.join(dir, MODEL_NAME)
3     if not os.path.exists(path):
4         raise FileNotFoundError(f"{path} 模型不存在,请先训练模型!")
5     model = joblib.load(path)
6     return model

在上述代码中,第2~4行用来判断给定的路径中是否存在一个名为MODEL_NAME的模型文件,如果不存在则进行提示。第5~6行用来返回载入后的模型。

下面,我们将开始完整介绍如何在训练集上训练模型以及在测试数据上复用模型。

6.2.2 载入原始文本#

首先需要完成一个函数来载入本地文本以及构造每个样本对应的标签。同时,为了方便这部分代码在后续其它地方复用,我们将其放到了utils下的dataset模块中,可参见AllBooKCode/utils/dataset.py文件,示例代码如下:

 1 DATA_HOME = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data')
 2 def load_spam():
 3     data_spam_dir = os.path.join(DATA_HOME, 'spam')
 4     def load_spam_data(file_path=None):
 5         texts = []
 6         with open(file_path, encoding='utf-8') as f:
 7             for line in f:
 8                 line = line.strip('\n')
 9                 texts.append(clean_str(line))
10         return texts
11     x_pos = load_spam_data(file_path=os.path.join(data_spam_dir, 'ham_5000.utf8'))
12     x_neg = load_spam_data(file_path=os.path.join(data_spam_dir, 'spam_5000.utf8'))
13     y_pos, y_neg = [1] * len(x_pos), [0] * len(x_neg)
14     x, y = x_pos + x_neg, y_pos + y_neg
15     return x, y

在上述代码中,第1行用于获取当前工程目录下data目录所在的绝对路径(今后本书中所使用到的数据集均会放到该目录下),其中__file__为Python中的环境变量用于得到当前文件所在的绝对路径,而os.path.dirname则是根据当前路径取对应的目录。例如在这里DATA_HOME的结果将形如:

D:/wangcheng/gitR/MachineLearningWithMe/AllBookCode/data

第2行是定义load_spam()函数来载入原始的垃圾邮件数据。第3行是拼接得到垃圾邮件数据集所在的目录,形如

D:/wangcheng/gitR/MachineLearningWithMe/AllBookCode/data/spam

第4~10行是定义一个辅助函数load_spam_data()来按行读取本地文件中的所有文本,并去掉每行末尾的换行符,其中函数clean_str()的作用是去掉一个字符串中的所有非中文字符,最后返回处理好的结果。第11~12行则是分别载入垃圾邮件和非垃圾邮件。第13行则是分别构造正负样本对应的样本标签。第14~15是将最后处理完成的结果进行返回。最终, x为一个列表,每个元素为一个样本(一条文本记录);y也为一个列表,每个元素为样本对应的标签。

在完成原始数据载入后,需要进一步对每个样本进行分词处理,以便后续通过词袋模型进行向量化处理。因此还需要定义一个辅助函数来完成这部分功能(同样保存于dataset模块中),示例代码如下:

1 def load_cut_spam():
2     x, y = load_spam()
3     x_cut = []
4     for text in x:
5         seg_list = jieba.cut(text, cut_all=False)
6         tmp = " ".join(seg_list)
7         x_cut.append(tmp)
8     return x_cut, y

在上述代码中,第2行便是通过上面实现的load_spam()函数来载入原始的文本数据;第4~8行则是对原始数据进行分词处理并返回。最终,x_cut将是一个列表,每个元素为一个分词后的样本,形式如下所示:

1 ['中信 国际 电子科技 有限公司 推出 新 产品 升职 步步高',
2  '搜索 文件 看 是否 不 小心 拖 到 某个 地方 了', ....]

6.2.3 制作数据集#

在完成原始文本的载入后,需要将其划分成训练集和测试集两个部分,以便后续进行向量化处理,示例代码如下:

1 from utils import load_cut_spam
2 def get_dataset():
3     x, y = load_cut_spam()
4     X_train, X_test, y_train, y_test = \
5         train_test_split(x, y, test_size=0.3, random_state=42)
6     return X_train, X_test, y_train, y_test

在上述代码中,第1行是从utils中导入load_cut_spam()来载入分词后的原始数据。第4~6行是以3:7的比例将原始数据划分为测试集和训练集并进行返回。

进一步,对训练集和测试集进行预处理,即文本向量化,示例代码如下:

 1 def preprocessing(x, train=False, top_k_words=1000,
 2                   MODEL_NAME='count_vec.pkl'):
 3     if train:
 4         count_vec = CountVectorizer(max_features=top_k_words)
 5         count_vec.fit(x)
 6         save_model(count_vec, MODEL_NAME=MODEL_NAME)
 7     else:
 8         count_vec = load_model(MODEL_NAME=MODEL_NAME)
 9     x = count_vec.transform(x)
10     return x

在上述代码中,第1行x表述待向量化的分词后的样本,train用来判断当前是处理训练集还是测试集,MODEL_NAME表示模型保存的名称。第3~6行表示如果为训练模式,则实例化类CountVectorizer,然后通过训练集来构造词表,并保存模型。第7~8行表示如果为测试模型,则载入本地已有的模型。第9行则是通过实例化的类对象来将样本进行向量化。

6.2.4 训练模型与测试#

在制作完成数据集后,就可以定义K近邻模型并进行训练与预测。首先,需要分别定义两个函数来完成模型的训练和测试部分,示例代码如下:

1 def train(X_train, y_train):
2     X_train = preprocessing(X_train, train=True)
3     model = KNeighborsClassifier(n_neighbors=3)
4     model.fit(X_train, y_train)
5     save_model(model, MODEL_NAME='KNN.pkl')

在上述代码中,第2行是通过preprocessing()函数来对训练集进行向量化。第3~5行为实例化类KNeighborsClassifier(),同时通过训练集对模型进行拟合并保存模型。

进一步,测试部分的示例代码为:

1 def predict(X, MODEL_NAME='KNN.pkl'):
2     X_test = preprocessing(X, train=False)
3     model = load_model(MODEL_NAME=MODEL_NAME)
4     y_pred = model.predict(X_test)
5     return y_pred

在上述代码中,第2行是通过训练集得到的词表来对测试集进行向量化。第3行是载入已训练完成的模型。第4行是对测试集进行预测并返回预测结果。

最后,可以通过如下方式来调用上述函数:

1 if __name__ == '__main__':
2     X_train, X_test, y_train, y_test = get_dataset()
3     train(X_train, y_train)
4     y_pred = predict(X_test)
5     print(classification_report(y_test, y_pred))

在上述代码中,第2行表示得到分词后的数据集,包括训练和预测两部分。第3行是训练模型并保存。第4~5行则是对测试数据进行预测。这里需要特别注意的地方就是,一定要先划分数据,然后再进行向量化。

上述代码运行结束后便可输出类似如下结果:

              precision    recall  f1-score   support
           0       0.76      1.00      0.86      1464
           1       1.00      0.69      0.82      1537
    accuracy                           0.84      3001
   macro avg       0.88      0.84      0.84      3001
weighted avg       0.88      0.84      0.84      3001

从上述结果可看出,K近邻模型在垃圾邮件分类这个任务上的表现并不好。在第7章中,我们将会介绍一种专门适用于文本分类的朴素贝叶斯算法。

至此,对于文本数据的预处理过程、向量化过程、模型的训练和复用过程就介绍完了。不过这里需要提醒的是,在保存模型的时候不仅要对最后的分类或者回归模型进行保存,还要对数据集预处理模型进行保存,例如这里的CountVectorizer模型。因为新输入的数据一般是原始数据,需要对其进行相应的标准化(这里是向量化)处理,同时还必须使用通过训练集得到的参数(例如这里的指词表)对新数据进行标准化,所以也需要对标准化时的模型进行保存。

6.2.5 小结#

在这节中,我们首先以一个真实的垃圾邮件数据集为例,详细介绍了如何通过sklearn中的K近邻模型来完成文本的分类任务,包括载入原始文本数据、制作数据集、划分数据集等;然后还介绍了如何通过joblib模块来完成模型的持久化和复用;最后还分析了复用模型时不仅需要保存最后的回归或者分类模型,同时还需要保存数据预处理过程中所用到的所有模型。在下节内容中,我们将开始第3种文本向量化算法TFIDF模型。