项目介绍
文本分类是自然语言处理的应用领域之一,文本分类是很多其他任务的基本型。本项目是一个最简单的二分类问题。
本项目会介绍如何将文本数据转化为数值型的特征数据(提取文本特质)。然后,使用机器学习当中的支持向量机算法,用 Python 实现对 10001 个邮件样本进行分类的任务。
知识点
- 自然语言处理基本概念
- 支持向量机算法
- TF-IDF
文本分类简介
文本分类技术在自然语言处理领域当中,有着十分重要的地位。一般而言,文本分类是指在一定的规则下,根据内容自动确定文本类别这一过程。文本分类在实际场景中有诸多方面的应用,比如常见的有垃圾邮件分类,情感分析等,新闻分类等等。
文本分类问题的种类
按照分类要求的不同,文本分类主要可以分为二分类,多分类,多标签分类三大类。
- 二分类问题:也是最基础的分类,顾名思义是将文本归为两种类别,比如将正常邮件邮件划分问题,垃圾邮件或者正常邮件。一段影评,判断是好评还是差评的问题。
- 多分类问题:是将文本划分为多个类别,比如将新闻归为政治类,娱乐类,生活类等等。
- 多标签分类:是给文本贴上多个不同的标签,比如一部小说可以同时被划分为多个主题,可能既是修仙小说,又是玄幻小说。
文本分类问题解决方法
文本分类主要有两种方法:传统机器学习文本分类算法、深度学习文本分类算法。
- 传统方法:特征提取 + 分类器。就是将文本转换成固定维度的向量,然后送到分类器中进行分类。
- 深度学习方法:可以自动提取特征,实现端到端的训练,有较强的特征表征能力,所以深度学习进行文本分类的效果往往要好于传统的方法。
支持向量机算法简介
简单说下支持向量机的原理以及使用方法,支持向量机也简称 SVM (Support Vector Machine)。
SVM 是传统机器学习的一个非常重要的分类算法。通过给定训练样本,支持向量机找到一个「最佳的」划分超平面,将不同的类别划分开来。
通俗来讲,这样的超平面有很多,支持向量机就是要找到位于两类训练样本「正中间」的划分超平面。为了便于理解,我们用了下面图中这个简单的二维空间例子来讲解支持向量机的基本原理,实际应用中常常是复杂的高维空间。
可以看到,图中有很多的线都可以正确将样本划分为两个类别,但是红色的线位于两个样本的「正中间」位置,是最佳的一条线。
为什么不选用其它的线呢?因为受噪声和训练集局限性的因素呢,训练集外的其它样本可能比训练集的样本更接近两个类别的分界,这样就会导致分类错误。恰恰红色这条线受影响最小,可以保证最大的容错率,所以支持向量机的目的就是要找到这条红线。
图中红线为最大分类间隔超平面,虚线上的点是距离分界面最近的点集,这类点就称为 「支持向量」。
我们现在延伸到多维空间,超平面用线性方程式表示为:
其中 是权重值,决定了超平面所处的方向位置;是位移项,决定了超平面与原点之间的距离,和都是向量。
在空间中,任意一点 到超平面的距离
其中, 是
➡️L2范数:比如 , 那么 。
tips:不太理解这个公式可以看一下高中学过的点到直线的距离公式
假设超平面 H 能够将训练样本正确分类,给定一个训练样本集:
其中 【二分类问题】, 是 维向量,即有若 ,则有 ,若 ,则有 ,即:
当且仅当,点在两个异类支持向量集合上,分别使等号成立。此时两个支持向量之间的「间隔」为:
显然为了最大化间隔,我们需要找到最小的
文本特征提取简介
nlp 任务非常重要的一步就是特征提取,在向量空间模型中,文本的特征包括字、词组、短语等多种元素表示。在文本数据集上一般含有数万甚至数十万个不同的词组,如此庞大的词组构成的向量规模惊人,计算机运算非常困难。
进行特征选择,对文本分类具有重要的意义。特征选择就是要想办法选出那些最能表征文本含义的词组元素。特征选择不仅可以降低问题的规模,还有助于分类性能的改善,选取不同的特征对文本分类系统的性能有非常重要的影响。已提出的文本分类特征选择方法比较多,常用的方法有TF-IDF以及词袋模型。
词袋模型
词袋模型是最原始的一类特征集,忽略掉了文本的语法和语序,用一组无序的单词序列来表达一段文字或者一个文档。可以这样理解,把整个文档集的所有出现的词都丢进袋子里面,然后无序去重地排出来(去掉重复的)。对每一个文档,按照词语出现的次数来表示文档。例如:
句子1:我/有/一个/苹果
句子2:我/明天/去/一个/地方
句子3:你/到/一个/地方
句子4:我/有/我/最爱的/你
把所有词丢进一个袋子:我,有,一个,苹果,明天,去,地方,你,到,最爱的
。这 4 句话中总共出现了这 10 个词。
现在我们建立一个无序列表:我,有,一个,苹果,明天,去,地方,你,到,最爱的
。并根据每个句子中词语出现的次数来表示每个句子。
总结一下特征:
- 句子 1 特征: ( 1 , 1 , 1 , 1 , 0 , 0 , 0 , 0 , 0 , 0 )
- 句子 2 特征: ( 1 , 0 , 1 , 0 , 1 , 1 , 1 , 0 , 0 , 0 )
- 句子 3 特征: ( 0 , 0 , 1 , 0 , 0 , 0 , 1 , 1 , 1 , 0 )
- 句子 4 特征: ( 2 , 1 , 0 , 0 , 0 , 0 , 0 , 1 , 0 , 1 )
这样的一种特征表示就称之为词袋模型的特征。
TF-IDF 模型
这种模型主要是用词汇的统计特征来作为特征集。TF-IDF 由两部分组成:TF(Term frequency,词频),IDF(Inverse document frequency,逆文档频率)两部分组成。
TF 和 IDF 都很好理解,我们直接来说一下他们的计算公式:
- TF:
其中分子 表示词 在文档 中出现的频次。分母则是文档 中所有词频次的总和,也就是文档
举个例子:
句子1:上帝/是/一个/女孩
句子2:桌子/上/有/一个/苹果
句子3:小明/是/老师
句子4:我/有/我/最喜欢/的/
每个句子中词语的 TF :
IDF:
其中 代表文档的总数,分母部分 则是代表文档集中含有 词的文档数。原始公式是分母没有 的,这里
用 IDF 计算公式计算上面句子中每个词的 IDF 值:
最后,把 TF 和 IDF 两个值相乘就可以得到 TF-IDF 的值。即:
上面每个句子中,词语的 TF-IDF 值:
把每个句子中每个词的 TF-IDF 值 添加到向量表示出来就是每个句子的 TF-IDF 特征。
例如句子 1 的特征:
中文邮件分类
上面我们主要讲解了支持向量机的基本思想,现在将其使用在垃圾邮件分类任务中。整个实验步骤大致如下:
- 导入数据,并进行分词和剔除停用词。
- 划分训练集和测试集。
- 将文本数据转化为数字特征数据。
- 构建分类器。
- 训练分类器。
- 测试分类器。
1. 加载数据集
本次用到的数据包含 3 个文件, ham_data.txt 文件里面包含 5000 条正常邮件样本,spam_data.txt 文件里面包含 5001 个垃圾邮件样本,stopwords 是停用词表。整个数据集放到了云上。
先调用Linux的wget
命令来下载文件,如果是windows则使用其他命令:
!wget - nc "http://labfile.oss.aliyuncs.com/courses/1208/ham_data.txt"
!wget - nc "http://labfile.oss.aliyuncs.com/courses/1208/spam_data.txt"
!wget - nc "http://labfile.oss.aliyuncs.com/courses/1208/stop_word.txt"
--2021-09-28 20:08:48-- http://-/
正在解析主机 - (-)... 失败:nodename nor servname provided, or not known。
wget: 无法解析主机地址 “-”
--2021-09-28 20:08:48-- http://nc/
正在解析主机 nc (nc)... 失败:nodename nor servname provided, or not known。
wget: 无法解析主机地址 “nc”
--2021-09-28 20:08:48-- http://labfile.oss.aliyuncs.com/courses/1208/ham_data.txt
正在解析主机 labfile.oss.aliyuncs.com (labfile.oss.aliyuncs.com)... 47.110.177.159
正在连接 labfile.oss.aliyuncs.com (labfile.oss.aliyuncs.com)|47.110.177.159|:80... 已连接。
已发出 HTTP 请求,正在等待回应... 200 OK
长度:2481321 (2.4M) [text/plain]
正在保存至: “ham_data.txt.1”
ham_data.txt.1 100%[===================>] 2.37M 6.92MB/s 用时 0.3s
2021-09-28 20:08:48 (6.92 MB/s) - 已保存 “ham_data.txt.1” [2481321/2481321])
下载完毕 --2021-09-28 20:08:48--
总用时:0.5s
下载了:1 个文件,0.3s (6.92 MB/s) 中的 2.4M
--2021-09-28 20:08:49-- http://-/
正在解析主机 - (-)... 失败:nodename nor servname provided, or not known。
wget: 无法解析主机地址 “-”
--2021-09-28 20:08:49-- http://nc/
正在解析主机 nc (nc)... 失败:nodename nor servname provided, or not known。
wget: 无法解析主机地址 “nc”
--2021-09-28 20:08:49-- http://labfile.oss.aliyuncs.com/courses/1208/spam_data.txt
正在解析主机 labfile.oss.aliyuncs.com (labfile.oss.aliyuncs.com)... 47.110.177.159
正在连接 labfile.oss.aliyuncs.com (labfile.oss.aliyuncs.com)|47.110.177.159|:80... 已连接。
已发出 HTTP 请求,正在等待回应... 200 OK
长度:1304315 (1.2M) [text/plain]
正在保存至: “spam_data.txt.1”
spam_data.txt.1 100%[===================>] 1.24M 4.52MB/s 用时 0.3s
2021-09-28 20:08:49 (4.52 MB/s) - 已保存 “spam_data.txt.1” [1304315/1304315])
下载完毕 --2021-09-28 20:08:49--
总用时:0.5s
下载了:1 个文件,0.3s (4.52 MB/s) 中的 1.2M
--2021-09-28 20:08:49-- http://-/
正在解析主机 - (-)... 失败:nodename nor servname provided, or not known。
wget: 无法解析主机地址 “-”
--2021-09-28 20:08:49-- http://nc/
正在解析主机 nc (nc)... 失败:nodename nor servname provided, or not known。
wget: 无法解析主机地址 “nc”
--2021-09-28 20:08:49-- http://labfile.oss.aliyuncs.com/courses/1208/stop_word.txt
正在解析主机 labfile.oss.aliyuncs.com (labfile.oss.aliyuncs.com)... 47.110.177.159
正在连接 labfile.oss.aliyuncs.com (labfile.oss.aliyuncs.com)|47.110.177.159|:80... 已连接。
已发出 HTTP 请求,正在等待回应... 200 OK
长度:15185 (15K) [text/plain]
正在保存至: “stop_word.txt.1”
stop_word.txt.1 100%[===================>] 14.83K --.-KB/s 用时 0.04s
2021-09-28 20:08:49 (412 KB/s) - 已保存 “stop_word.txt.1” [15185/15185])
下载完毕 --2021-09-28 20:08:49--
总用时:0.2s
下载了:1 个文件,0.04s (412 KB/s) 中的 15K
h = open('ham_data.txt', encoding='utf-8') # 正常邮件
s = open('spam_data.txt', encoding='utf-8') # 垃圾邮件
h, s
(<_io.TextIOWrapper name='ham_data.txt' mode='r' encoding='utf-8'>,
<_io.TextIOWrapper name='spam_data.txt' mode='r' encoding='utf-8'>)
上面的h
和s
只不过是数据流,我们准备的数据对应的是一行一封邮件,所以这里我们要用readlines()
来按行读取文本的内容。
h_data = h.readlines()
s_data = s.readlines()
h_data[0:3], s_data[0:3] # 查看一下前 3 封邮件为例
(['讲的是孔子后人的故事。一个老领导回到家乡,跟儿子感情不和,跟贪财的孙子孔为本和睦。 老领导的弟弟魏宗万是赶马车的。 有个洋妞大概是考察民俗的,在他们家过年。 孔为本总想出国,被爷爷教育了。 最后,一家人基本和解。 顺便问另一类电影,北京青年电影制片厂的。中越战背景。一军人被介绍了一个对象,去相亲。女方是军队医院的护士,犹豫不决,总是在回忆战场上负伤的男友,好像还没死。最后 男方表示理解,归队了。\n',
'不至于吧,离开这个破公司就没有课题可以做了? 谢谢大家的关心,她昨天晚上睡的很好。MM她自己已经想好了。见机行事吧,拿到相关的能出来做论文的材料,就马上辞职。 唉!看看吧,说不定还要各为XDJM帮出出找工作的主意呢。MM学通信的,哈尔滨工程大学的研究生,不想在哈碌碌无为的做设计,因此才出来的。先谢谢了啊。!!! 本人语文不好,没加标点。辛苦那些看不懂的XDJM么了。\n',
'生一个玩玩,不好玩了就送人 第一,你要知道,你们恋爱前,你爹妈对她是毫无意义的。没道理你爹妈就要求她生孩子,她就得听话。换句话说,你岳父母要未来孩子跟妈姓,你做的到吗?夫妻是平等的。如果你没办法答应岳父母,她干吗答应你爹妈呢? 第二,有了孩子你养不养的起?不是说想生就生,图你爹妈一个高兴,如果没有房子,没有充足的财力,生孩子只会带给你们更多的困难,生小孩容易,养小孩难啊。\n'],
['有情之人,天天是节。一句寒暖,一线相喧;一句叮咛,一笺相传;一份相思, 一心相盼;一份爱意,一生相恋。 搜寻201:::http://201.855.com 在此祝大家七夕情人快乐! 搜寻201友情提示::: 2005年七夕情人节:8月11日――别忘了给她(他)送祝福哦!\n',
'我司是一家实业贸易定税企业;有余额票向外开 费用相对较低,此操作方式可以为贵公司(工厂) 节约部分税金。 公司本着互利互惠的原则,真诚期待你的来电!!! 联系: 王生 TEL: --13528886061\n',
'本公司有部分普通发票(商品销售发票)增值税发票及海关代征增值税专用缴款书及其它服务行业发票, 公路、内河运输发票。可以以低税率为贵公司代开,本公司具有内、外贸生意实力,保证我司开具的票据的真实性。 希望可以合作!共同发展!敬侯您的来电洽谈、咨询! 联系人:李先生 联系电话:13632588281 如有打扰望谅解,祝商琪。\n'])
2. 数据预处理
因为我们加载的数据无法直接被模型处理,所以我们需要对数据进行一系列预处理准备工作。
2.1 生成样本和标签
读取数据及之后之后, h_data 是由 5000 条邮件字符串组成的正常邮件样本集, s_data 是由 5001 条邮件字符串组成的垃圾邮件样本集,我们需要:手动给样本集生成对应标签,下面我们为将两个样本组合起来,并贴上标签,将正常邮件的标签设置为 1,垃圾邮件的标签设置为 0。
import numpy as np
h_labels = np.ones(len(h_data)).tolist() # 生成全 1 的正标签list
s_labels = np.zeros(len(s_data)).tolist() # 生成全 0 的负标签list
# 拼接正负样本集和标签集合到一起
datas = h_data + s_data
labels = h_labels + s_labels
2.2 划分训练集和测试集
使用 scikit-learn 工具里面的 train_test_split 类在 10001 个样本当中,随机划出 25% 个样本和标签来作为测试集,剩下的 75% 作为训练集来进行训练我们的分类器。
from sklearn.model_selection import train_test_split
train_d, test_d, train_y, test_y = train_test_split(datas, labels, test_size=0.25, random_state=5)
参数的含义:
-
datas
:样本集 -
labels
:标签集 -
train_test_split
:划分到测试集的比例 -
random_state
:随机种子,取同一个的随机种子那么每次划分出的测试集是一样的。
返回值的含义:
-
train_d
:训练集 -
test_d
:测试集 -
train_y
:训练标签 -
test_y
:测试标签
查看一下前10个标签试试:
train_y[0:10]
[0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0]
2.3 分词
nlp 工作往往是以词语作为基本特征进行分析,所以需要对文本进行分词。这里为了代码简洁方便理解,将分词设计成 tokenize_words 函数,供后续直接调用。我们使用 jieba
分词库。
import jieba
def tokenize_words(corpus):
tokenized_words = jieba.cut(corpus) # 调用 jieba 分词
tokenized_words = [token.strip() for token in tokenized_words] # 去掉回车符,转为list类型
return tokenized_words
# 随便输入一句话调用函数验证一下
string = '我爱自然语言处理'
b = tokenize_words(string)
b
Building prefix dict from the default dictionary ...
Loading model from cache /var/folders/n6/fr8x2twx7v9b5c7qqz1d_d640000gn/T/jieba.cache
Loading model cost 0.644 seconds.
Prefix dict has been built successfully.
['我', '爱', '自然语言', '处理']
2.4 去除停用词
在自然语言中,很多字词是没有实际意义的,比如:【的】【了】【得】等,因此要将其剔除。首先加载我们刚刚下载好的停用词表。这里也可以自行在网上下载,编码格式为 utf-8,每行一个停用词。为了方便调用,我们将去除停用词的操作放到 remove_stopwords 函数当中。
我们可以简单查看一下停用词表:
def remove_stopwords(corpus): # 函数输入为全部样本集(包括训练和测试)
sw = open('stop_word.txt', encoding='utf-8') # 加载停用词表
sw_list = [l.strip for l in sw] # 去掉回车符存放至list中
# 调用分词函数
tokenized_data = tokenize_words(corpus)
# 使用list生成式对停用词进行过滤
filtered_data = [data for data in tokenized_data if data not in sw_list]
# 用' '将 filtered_data 串起来赋值给 filtered_datas(不太好介绍,可以看一下下面处理前后的截图对比)
filtered_datas = ' '.join(filtered_data)
# 返回是去除停用词后的字符串
return filtered_datas
tips:
接下来,构建一个函数整合分词和剔除停用词的预处理工作,调用 tqdm 模块显示进度。
from tqdm.notebook import tqdm
def preprocessing_datas(datas):
preprocessing_datas = []
# 对 datas 当中的每一个 data 进行去停用词操作
# 并添加到上面刚刚建立的 preprocessed_datas 当中
for data in tqdm(datas):
data = remove_stopwords(data)
preprocessing_datas.append(data)
# 返回预处理后的样本集
return preprocessing_datas
最后直接调用上面的与处理函数对训练集和测试集进行预处理,并查看一下我们处理后的文本:
pred_train_d = preprocessing_datas(train_d)
print(pred_train_d[0])
pred_test_d = preprocessing_datas(test_d)
print(pred_test_d[0])
0%| | 0/7500 [00:00<?, ?it/s]
敬 的 用户 你好 ! 1798 国际 贸易网 < http : / / www.1798 . cn / > , 是 全球排名 1.2 万名 的 商业 门户网站 , 现 免费 开放 发布 供求信息 , 人才 招聘 , 互联网 址 注册 。 请 马上 点击 注册 < http : / / www.1798 . cn / index . asp >
0%| | 0/2501 [00:00<?, ?it/s]
全球 最大 的 中文 创业 门户 , 一个 令 全球 震惊 的 成功 秘密 ! www . zhaozhao360 . com 上班族 另类 火爆 发财 法 将 兼职 变成 创业 你 准备 好了吗 ? 美女 老板 掘 第一桶金 19 岁 小女子 5 年 净赚 30 万 内衣 试穿 妹 试成 白金 丽 中国 新 九大 暴利行业 ! 比尔 ・ 盖茨 怎样 花钱 ? 赚到 500 万 只 因 一条 狗 怎么 把 工资 从 400 变成 40000
通过前面的预处理工作,我们得到了分词过后并且去除停用词了的样本集 pred_train_d 和 测试集 pred_test_d。
3. 特征提取
在进行分词及去停用词处理过后,得到的是一个分词后的文本。现在我们的分类器是 SVM,而 SVM 的输入要求是数值型的特征。这意味着我们要将前面所进行预处理的文本数据进一步处理,将其转换为数值型数据。
转换的方法有很多种,这里使用最经典的 TF-IDF 方法。
在 Python 当中,我们可以通过 scikit-learn 来实现 TF-IDF 模型。并且,使用 scikit-learn 库将会非常简单。这里主要用到了 TfidfVectorizer()
类。
接下来我们开始使用这个类将文本特征转换为相应的 TF-IDF 值。
首先加载 TfidfVectorizer
类,并定义 TF-IDF 模型训练器 vectorizer
。
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer(min_df=1, norm='l2', smooth_idf=True, use_idf=True, ngram_range=(1, 1))
参数列表:
-
min_df
: 忽略掉词频严格低于定阈值的词。 -
norm
:标准化词条向量所用的规范。 -
smooth_idf
:添加一个平滑 IDF 权重,即 IDF 的分母是否使用平滑,防止 0 权重的出现。 -
use_idf
: 启用 IDF 逆文档频率重新加权。 -
ngram_range
:同词袋模型
对预处理过后的 pred_train_d
进行特征提取:
tfidf_train_features = vectorizer.fit_transform(pred_train_d)
tfidf_train_features
<7500x29195 sparse matrix of type '<class 'numpy.float64'>'
with 262786 stored elements in Compressed Sparse Row format>
通过这一步,我们得到了 7500 个 28335 维数左右的向量作为我们的训练特征集。我们可以查看转换结果:
np.array(tfidf_train_features.toarray()).shape
(7500, 29195)
用训练集训练好特征后的 vectorizer
来提取测试集的特征:
⚠️注意这里不能用 vectorizer.fit_transform()
要用 vectorizer.transform()
,否则,将会对测试集单独训练 TF-IDF 模型,而不是在训练集的词数量基础上做训练。这样词总量跟训练集不一样多,排序也不一样,将会导致维数不同,最终无法完成测试。
tfidf_test_features = vectorizer.transform(pred_test_d)
tfidf_test_features
<2501x29195 sparse matrix of type '<class 'numpy.float64'>'
with 81964 stored elements in Compressed Sparse Row format>
完成之后,我们得到 2501 个 28335 维数的向量作为我们的测试特征集。
3. 分类
在获得 TF-IDF 特征之后,我们才能真正执行分类任务。我们不需要自己手动构建算法模型,可以调用 sklearn
中的 SGDClassifier()
类来训练 SVM 分类器。
SGDClassifier
是一个多个分类器的组合,当参数 loss='hinge'
时是一个支持向量机分类器。
from sklearn.linear_model import SGDClassifier
svm = SGDClassifier(loss='hinge')
然后我们将之前准备好的样本集和样本标签送进 SVM 分类器进行训练。
svm.fit(tfidf_train_features, train_y)
SGDClassifier()
4. 查看分类结果
调包实现算法非常简单,可以看到最复杂的步骤就在于预处理工作,接下来我们查看一下分类结果如何,先用测试集来看一下分类器的效果。
predictions = svm.predict(tfidf_test_features)
predictions
array([0., 1., 1., ..., 0., 1., 0.])
为了直观显示分类的结果,我们用 scikit-learn 库中的 accuracy_score
函数来计算一下分类器的准确率(准确率即 test_l
中与 prediction
相同的比例)。
from sklearn import metrics
accuracy_score = np.round(metrics.accuracy_score(test_y, predictions), 2)
accuracy_score
0.99
用测试标签和预测结果计算分类准确率,np.round(X,2)
的作用是四舍五入后保留小数点后 2 位数字。
可以看到我们的准确率达到了99%,可以说是非常高了。我们可以随机输入一个样本查看其预测结果是否正确:
id = int(input('请输入样本编号(0~250):'))
print('邮件类型:', '垃圾邮件' if test_y[id]==1 else'正常邮件')
print('预测邮件类型:', '垃圾邮件' if predictions[id]==1 else'正常邮件')
print('文本:', test_d[id])
邮件类型: 正常邮件
预测邮件类型: 正常邮件