文章目录
一、任务介绍
当你掌握了机器学习和深度学习一些基本知识后,实现一些相关代码是非常必要的。本文提供了一个实战任务和代码实现:在给定的20类新闻文本数据集下,分别使用朴素贝叶斯、K近邻、神经网络进行文本分类,使用5次交叉验证,并对分类结果进行分析讨论。如果你能跟着实现文章中的代码,相信你的代码能力一定能有很大提高。(有些模块使用sklearn可以更简洁地解决,但是我尽量不使用sklearn, 因为那样对代码能力帮助较小)
下面的介绍包含文字叙述和部分代码,完整代码先从这里下载。代码地址
二、数据集:
数据集包含了 20个 新闻群组, 总数 19997 个文档。下载网页
三、数据预处理:
1. 文档清洗:
数据集中共有19997篇文档,对每一篇文档,依次进行如下步骤:
- 使用正则表达式匹配出字母,得到分词列表。
- 去除长度为1的分词(除了i),因为其他单个字符没有意义。
- 使用nltk的PorterStemmer工具提取词干。
- 删除停用词。使用网上下载的一份长度为891的英文停用词表。
- 使用nltk的pos_tag工具,提取出名词和动词。
# 完整代码参考DataSet.py
bigString = f.read()
# 使用正则表达式匹配出非字母、非数字
token_list = re.compile(r'\b[a-zA-Z]+\b', re.I).findall(bigString)
# 去除一些太短的字符
token_list = [tok.lower() for tok in token_list if len(tok) > 1]
# 提取词干
porter = nltk.PorterStemmer()
token_list = [porter.stem(t) for t in token_list]
# 去除停用词
stop_words = set(stop_words1)
token_list = [tok for tok in token_list if tok not in stop_words]
# 提取名词等
pos_tags = nltk.pos_tag(token_list)
token_list = [word for word, pos in pos_tags if pos in tags]
Doclist.append(token_list)
ClassVec.append(i)
2. 词表制作:
如果将上述19997篇文档的词表取交集,将得到长度为8w+的词汇表,这对后续的处理带来困难。因此,我还进行以下两个步骤:
- 删除所有类共同的高频词。对每一个类进行统计,插入集合,最后对20个集合取交集,得到所有类公共的高频词,最后得到44个词,删除之。(如cmu,write,news等,认为这些词对分类没有影响)
- 删除每个类的超低频词。统计每个类中频次小于3的词汇,插入集合,最后对20个集合取并集,得到所有类各自的超低频词,最后得到7w左右个词,删除之。(其中有一些低频词是个例,还有一些是错别字,对分类没有影响,可以放心删除)
删除后的词汇表维度为1w+。
# 完整代码参考DataSet.py
def find_common_highFreq(DocList, ClassVec):
n_class = len(set(ClassVec))
HighFreq_set = set()
for i in tqdm(range(n_class)):
p_vec = []
class_index = list(np.where(ClassVec == i))[0]
for id in class_index:
p_vec += DocList[id]
freq = Counter(p_vec)
# f = open(f'HIgh_Freq.txt', "a")
# 按值从大到小排序
freq = sorted(freq.items(), key=lambda x: x[1], reverse=True)
hign_freq_i = []
for idx in range(len(freq[:200])):
key, value = freq[idx]
hign_freq_i.append(key)
if i == 0:
HighFreq_set = set(hign_freq_i)
else:
HighFreq_set = HighFreq_set & set(hign_freq_i)
with open('Data/High_Freq.txt', 'w') as f:
for word in HighFreq_set:
f.write(f'{word} ')
f.write('\n')
return HighFreq_set
def find_low_freq(DocList, ClassVec):
n_class = len(set(ClassVec))
LowFreq_set = set()
for i in tqdm(range(n_class)):
p_vec = []
class_index = list(np.where(ClassVec == i))[0]
for id in class_index:
p_vec += DocList[id]
freq = Counter(p_vec)
freq = sorted(freq.items(), key=lambda x: x[1], reverse=False) # 按值从小到大排序
low_freq_i = []
for idx in range(len(freq)):
key, value = freq[idx]
if value < 3:
low_freq_i.append(key)
if i == 0:
LowFreq_set = set(low_freq_i)
else:
LowFreq_set = LowFreq_set | set(low_freq_i)
# print(LowFreq_set)
with open('Data/LowFreq_words.txt', 'w', encoding='utf-8') as f:
for line in list(LowFreq_set):
f.write(f'{line}\n')
return LowFreq_set
3. 特征向量:
对每一篇新闻处理后得到的分词列表,使用python的Counter工具统计其每个词的词频,使用词频权重转化成维度为15306的特征向量,特征向量的每一维是该词出现的频次。最后得到19997个特征向量
4. 保存矩阵:
将19997个15306维特征向量拼接成特征矩阵(19997*15306, 并保存
# 完整代码参考DataSet.py
def Doc2Mat(VocabList, DocList):
M = len(DocList)
D = len(VocabList)
P_mat = np.zeros((M, D))
for i in tqdm(range(M)):
freq = Counter(DocList[i])
for key, value in freq.items():
if key in VocabList:
P_mat[i][VocabList.index(key)] += value
return P_mat
np.save('Data/DataMat.npy', DataMat)
np.save('Data/ClassVec.npy', ClassVec)
np.save('Data/VocabList.npy', VocabList)
5. 加载矩阵
tips:注意不要每一次跑模型的时候都运行一遍上面的代码,这是没有必要的,上述代码运行保存成功后以后只需要加载处理后的数据就行。
# 完整代码参考main.py
X = np.load('Data/DataMat.npy', allow_pickle=True)
y = np.load('Data/ClassVec.npy', allow_pickle=True)
VocabList = list(np.load('Data/VocabList.npy', allow_pickle=True))
四、模型算法
1. 朴素贝叶斯
- 原理:
根据贝叶斯公式,要想求出 P ( c │ x ) P(c│x) P(c│x),可以转化成求 P ( x │ c ) P ( c ) P(x│c)P(c) P(x│c)P(c)。同时朴素贝叶斯假设词之间独立,故文档的概率转化成词的概率:
P ( x │ c ) = P ( x 1 │ c ) P ( x 2 │ c ) … … P ( x n ∣ c ) P(x│c)=P(x_1│c)P(x_2│c)……P(x_n |c) P(x│c)=P(x1│c)P(x2│c)……P(xn∣c)。 - 训练:
P ( x │ c ) : P(x│c): P(x│c):将每一类别内部所有特征向量求和得到该类中每个词出现的频次,使用加1平滑防止0概率,每个词汇除以所有词汇频次之和得到 P ( x i │ c ) P(x_i│c) P(xi│c). 防止概率相乘出现下溢出,再使用对数化转化成概率相加。因为有20个类别,所以最后得到 P_mat 维度为[20, 15306]。
P ( c ) P(c) P(c): 统计每一个类别数据集的比例,得到 P_class,维度[20, 1].
# 完整代码参考models/Naive_Bayes.py
def train(self, TrainMat, Label):
"""
:param TrainMat:[M, D]
:param Label:[M,1]
:return: P_mat:[n_class,D], P_class:[n_class,1]
"""
M, D = TrainMat.shape
n_class = len(set(list(Label)))
P_class = np.zeros(n_class)
for i in range(n_class):
idx = list(np.where(Label == i))[0]
P_class[i] = len(idx) / M
P_mat = np.zeros((n_class, D))
for i in range(n_class):
idx = list(np.where(Label == i))[0]
freq_vec = TrainMat[idx].sum(axis=0) # 该类的词汇频次求和
freq_vec += 1 # Laplace smooth
num_word = np.sum(freq_vec)
P_mat[i] = np.log(freq_vec / num_word)
return P_mat, P_class
- 测试:
利用训练好的 P_mat 和 P_class 就可以计算出 P ( c ∣ x ) P(c|x) P(c∣x),取最大概率即预测的类别。
2. K近邻模型:
- 原理: 特征空间中类内的特征向量离得近。
- 训练: 对训练数据(特征矩阵)建一颗kd树
- 测试:对每一个测试数据(特征向量),在kd树中,找到离自己最近的K个点,数量优先的原则投票出v所属的类别。
# 完整代码参考model/Knn.py
def __init__(self, K):
self.K = K
self.model = KNeighborsClassifier(n_neighbors=K, metric='euclidean')
def train(self, Train_mat, Train_ClassVec):
self.model.fit(Train_mat, Train_ClassVec) # 训练
return Train_mat, Train_ClassVec
def test(self, Test_mat, gt_label, train_mat, train_label):
# pred_label = self.model.predict(Test_mat) # 预测
_, indices = self.model.kneighbors(Test_mat) # [M, K]
pred_label = self.vote(indices, train_label)
precision, recall, _, _ = precision_recall_fscore_support(gt_label,
pred_label)
diff = pred_label - gt_label
idx = list(np.where(diff == 0))[0]
accuracy = len(idx) / len(gt_label)
return accuracy, recall
3. 神经网络:
网络结构: 使用最简单的MLP
{fc(15306,256), sigmoid, fc(256,20), softmax}
其它参数: 使用指数衰减学习率,初值5,gamma=0.98, 每20个epoch更新,训练1000个epoch,使用SGD优化器。
(注:如果没有配cuda,可以将代码中出现.cuda(), to(‘cuda’) 删去 )
# 完整代码参考model/MLPs.py
def train(self, X_train, gt_label):
self.net.train()
self.net.cuda()
X_mean = np.mean(X_train, axis=1, keepdims=True)
X_std = np.std(X_train, axis=1, keepdims=True)
X_train = (X_train - X_mean) / X_std
input = torch.from_numpy(X_train)[None, :, :].permute(0, 2, 1).to(torch.float32)
input = input.to('cuda')
for epoch in tqdm(range(1000)):
# 归一化
# in your training loop:
self.optimizer.zero_grad() # zero the gradient buffers
pred = self.net(input).squeeze(0).permute(1, 0)
gt = torch.from_numpy(gt_label).view(-1).long()
gt = gt.to('cuda')
criterion = nn.CrossEntropyLoss()
loss = criterion(pred, gt)
pred = torch.max(pred, 1)[1]
pred = pred.squeeze()
precision, recall, _, _ = precision_recall_fscore_support(gt.detach().cpu().numpy(), pred.detach().cpu().numpy())
loss.backward() # 计算梯度
self.optimizer.step() # 更新参数
if epoch % 20 == 0:
if self.scheduler.get_last_lr()[0] > 0.001:
self.scheduler.step()
logging.info(f"epoch:{epoch}\t"
f"loss: {loss.item():.4f}\t"
f"recall: {recall.mean():.4f}\t"
f"precision:{precision.mean():.4f}")
stat = {'state_dict': self.net.state_dict()}
torch.save(stat, 'snapshot/MLPs_10_08_3.pth')
五、实验结果与分析
评测指标采用总的分类准确率(Acc), 还将细致讨论各类的召回率
(
R
)
(R)
(R),最小召回率的类别(R_cmin)及其召回率(R_min), 最大召回率的类别 (R_cmax) 及其召回率 (R_max), 所有类的召回率的 均值 (R_mean) 和 标准差 (R_std). 分别对三种方法进行讨论,如下表,其中准确率上神经网络高于朴素贝叶斯,高于KNN,可见神经网络的强大学习能力,而KNN受限于模型过于简单,特征距离表征能力不够,朴素贝叶斯则受限于需要大量的训练数据以及词之间相互独立的假设。
还可以注意到,分的最差的类别中,Bayes的召回率高于神经网络,这从原理上可以理解,因为神经网络计算的是总体的loss,不去考虑每一个loss,而朴素贝叶斯对于每一类的计算方法都相同,所以各类之间不会差别太大。
另外,86.67%并不是神经网络的极限,当我试图加宽第二层网络时,取得了6%的突破,如果以后继续加深加宽这个网络,应该能取得更大的突破。
Acc(%) | R_mean(%) | R_std(%) | R_min(%)/R_cmin | R_max(%)/R_cmax | |
---|---|---|---|---|---|
Bayes | 82.27 | 82.30 | 7.71 | 63.90 /religion.misc | 98.89 /Religion.christian |
Knn | 63.83 | 63.85 | 10.46 | 45.64 /religion.misc | 90.89 /Religion.christian |
MLP | 86.67 | 87.02 | 9.69 | 54.38 /religion.misc | 98.96 /Religion.christian |
我还测试了不同K值对KNN性能的影响,如下图,可见准确率随着K值的上升而下降,这说明各类之间的距离比较小,K值扩大容易引入噪声(其他类的点)。
六、遇到问题及解决思路
- 特征向量维度过高,加载缓慢,内存占用大。解决:发现每一类中的超低频次词数目巨大,将其删除就可以得到一个比较舒服的向量维度
- Knn自己编写速度缓慢。解决:调用sklearn的kd树。
- 神经网络:
a. loss不收敛。 解决:在网上看到一篇总结loss不收敛的文章中,发现我的归一化编写错误,对该网络输入归一化,不应该是将向量尺度变为1,而是将其减均值,除以标准差,得到类似0-1正态分布的输入,这样与网络结构更适应。
b. loss下降缓慢。 解决:在之前科研实习过程中使用的学习率都是0.0几的级别,但是这似乎对这个简单文本分类问题来说太小了,经过一番调试,我取了学习率为5.
c. 准确率上不去。解决:原本使用单层的神经网络,准确率只有70%,后面加了一层宽度为20的隐藏层,提升到80%,后面换成宽度为256的隐藏层,提升到87%。