前言
之前写了十几篇blog,但更多都是基础知识的回顾,基础知识尽管再好,也是基础知识,它只能帮助你在工作中更快上手,或者说让你在程序员的道路上走得更远——地基打得越好,楼层才能越高。毕竟最近也开始要考虑找工作的事情,所以也要把专业知识给补上。作为一个日常都是处理文本的少年,文本分类是第一道、甚至可以不夸张地说是最重要一道的坎:尽管现在各种NLP方法都热火朝天的样子,但本质上都存在着落地难的问题,NLG看似高端大气上档次,但是除了机器翻译外,其他都很难做到商用级别,而机器翻译也不是单纯地使用Bert啊,ELMO等模型就可以搞定(甚至可以说那种高大上的模型只占其中很少的部分),至于其他NLU,只能说NLG的情况稍微好一丢丢,但还是以规则匹配作为主体(你试试以机器学习作为主体你的工作会有80%的时间跟业务和客户进行“愉快地”交流)。因此,文本分类看似简单,但有可能是日常接触中用到最多机器学习的方法了,还是非常适合重点讲讲。
本次的文本分类会分为3个blog,分别为:
文本分类(初阶):主要是传统方法对文本处理的运用,什么One-Hot,TF-IDF等等
文本分类(中阶):使用词向量对文本处理的运用,包括FastText,TextCNN,TextRNN
文本分类(高阶):使用Attention机制以及基于上下文的动态词向量的运用,包括BiLSTM-Attention,Self-Attention等等(Bert要不要讲待定)
写完这些估计会再讲讲文本相似度(基于孪生网络)、Seq2Seq-Attention等模型,在这里也算先进行一个预告吧。
传统文本分类的相关理论
先说自己的观点:所谓的文本数据,虽然说是非结构化的数据,但其实本质就是个未经加工的结构化数据。本渣一直没把图像啊,文本啊这些非结构化的数据看得多么特殊,这玩意无论使用深度学习也好,传统学习也罢,第一步,都是要将其进行结构化的处理。在传统的文本分类过程中,整个建模的过程则是:
训练数据->文本预处理->特征提取->文本表示->分类器[1]
文本预处理:
在文本预处理中,传统的机器学习算法和深度学习算法并无差别。文本预处理过程是在文本中提取关键词表示文本的过程,中文文本处理中主要包括文本分词和去停用词两个阶段。当然,这两个步骤其实也是可选的!例如分词,其实也存在一些争议,因为也有一些研究人员更倾向于使用字而不是词作为文本表示方法——例如现在预训练bert模型,训练得出的大多是以字向量作为单位进行,不过,从语言学的角度上讲,词才是语义上的最小颗粒度!所以个人也倾向于在文本分类任务中使用分词。至于去停用词,那就更好玩了,从定义的角度上讲:停用词是指一些在文档集中出现频率很高,明显对分类任务没有贡献或贡献很小的词,去掉词携带的信息量很少,滤去以后可以提高分类器 的效率,而且对分类器的性能不会有什么影响。但是,定义归定义!你贡献少不等于没贡献,例如你把那些“你”,“我”,“的”这些停用词去掉,总会丢失一部分信息,如果你是追求准确率最大化,我给的建议是:其实也不差你那点内存,停用词只去掉一些奇奇怪怪的标点符号就行。这里为了让整个流程通顺一点,我会把分词和去停用词都执行一遍——但在实际工作中,请切勿照本宣科!
特征提取与文本表示:
特征提取和文本表示经常混在一起讲,因为你提取到了所谓的特征,也是为了表示文本的。特征提取可以使用互信息(可以参考https://zhuanlan.zhihu.com/p/94074441)进行特征的选择(然而俺真的没看过有勇士这样做的——没必要把简单的问题搞复杂)。在传统方法中,文本表示则常用词袋模型进行。词袋模型是指假定一个文本,忽略其词序和语法、句法,仅仅将其看做是一些词汇的集合,而文本中的每个词汇都是独立的。即每篇文档都看成一个袋子。然后看这个袋子里装的都是些什么词汇——这时候对应的也是One-hot编码。当然,词袋模型也不是专指一种模型,它也有0-1词袋(有则1,没有就是0,就是One-hot),频次词袋(有n个就是n,没有就是0),还有传说中的tf-idf!
为什么我这里着重强调TF-IDF?还要加上一个"传说"两字?
因为TF-IDF的公式无比简单,但又无比重要!重要到什么样的程度呢?俺引用吴军老师《数学之美》的对TF-IDF的评价:公认为信息检索中最重要的发明之一!
但是,它的公式仅仅是
n(i,j)代表了字符i在文档j出现的次数,即字词的重要性随着它在文件中出现的次数成正比增加,但同时会随着它在语料库中出现的频率成反比下降。
所以后续的传统机器模型,我会无脑使用TF-IDF这一值进行文本表示!
分类器:
分类器的算法在这里我不打算多说了,因为,在文本基于传统表示方法后,使用分类器跟传统的机器学习几乎是一模一样,有兴趣的可以看看我很久很久之前学生时代写的Kaggle实战——Gender Recognition by Voice声音的性别区分(结构化数据)(一)。无非是维度稍微高那么一丢丢(好吧,样本量多的时候会上百万维)。
只要记住,当维度特别大的时候,记得使用稀疏矩阵(放心,sklearn自带)!不然的话内存绝对会爆掉!很多人会在对词向量和词袋模型进行比较时候,都会跟风来一句,节省内存——哥只能呵呵一笑了,too young,boy(girl),这年头难不成还不会用个稀疏矩阵么?其实后续(文本分类中阶)所说的词向量跟词袋模型相比,内存并没有减少多少,词向量(静态词向量)也并非非常非常了不起的技术,最简单的一个直觉,词向量的输入,就是one-hot编码输入,甚至可以说词向量,就是one-hot的检索过程(这个说起来又是一篇blog的事情,有空再说)。
当然,以俺多年的经验(好吧,我搞nlp也就2年),树形的结构,一般是不得行的,什么decision tree,random forest,xgboost请直接退出群聊。svm以及朴素贝叶斯分类器,一般是不错滴。当然,也要看情况。不同的数据集,不同的文本分类任务,都会有所差异,这个不懂的请直接百度 天下没有免费的午餐定理。例如,长文本和短文本就有很大的差异。我在这里先抛出我实验的结论(只能说明一部分数据集):如果进行长文本的文本分类,深度学习,或者说后面的中阶,高阶的内容,对比这里的初阶的方法,几乎没有优势,而且,一个朴素贝叶斯,训练需要1秒,一个rnn,训练需要一天,在准确度差不多的情况下,你选哪个?
实验部分
ok,上面讲了那么多废话,归根结底还是要用实验来说话。本次实验使用复旦大学李荣陆博士提供的文本分类数据集(很老很老很老的数据集了),其中训练集包括 9804 篇文档,测试集包括 9833 篇文档,分为 20 个类别,训练集和测试集文档数接近 1:1。由于数据库各类别的数据量分布极其不均匀。因此,我们只选取其训练集和测试集数据量同时>100 的类别进行分类(低于100的话,在实际工作中就老老实实地让业务人员先收集吧,当然,<100也可以做,例如现在one-shot leaning那些,但效果可以保证绝!对!不!好!)。经过筛选,在本文中删除其中11个类别,最后的训练数据则是得到训练集为 9318 篇文档数,测试集为 9331 篇文 档,共 9 分类。
此外,考虑到有的文本很长很长处理起来太耗时间(这里的数据集是长文本),这里进行一个截取操作,只保留前300个字符(都看了300个字符,计算机如果还识别不出那是啥,短文本岂不是没法混了?)
先进行数据预处理阶段:分词+去停用词
import os
import jieba
import re
import string
from tqdm import tqdm
print("现在开始分词和去停用词")
# 保存文件
def savefile(savepath, content):
fp = open(savepath, "w",encoding='gb2312', errors='ignore')
fp.write(content)
fp.close()
# 读取文件
def readfile(path):
fp = open(path, "r", encoding='gb2312', errors='ignore')
content = fp.read()
fp.close()
return content
# 加载停用词
def stopwordslist(filepath):
stopwords = [line.strip() for line in open(filepath,'r',encoding='gb2312',errors='ignore').readlines()]
return stopwords
# 移除停用词
def movestopwords(sentence,stopwords_list):
outstr = ''
for word in sentence.split():
if word not in stopwords_list:
if word != '\t' and word != '\t''\n':
outstr +=" "
outstr += word
# outstr += " "
return outstr
# 替换数字和符号
def replaceAllSymbols(oldStr):
specialsymbols = "[\s+\.\!\/_,$%^*(+\"\'" + string.punctuation + "]+|[+——!,。?<>《》:;、~@#¥%……&*()]+"
mathsysmbols = '\d+(\.\d+)*([×\+\-\*\/]\d+(\.\d+)*)*[0-9A-Za-z]*'
# 去掉数字
oldStr = re.sub(mathsysmbols, " ", oldStr)
# 再去掉符号
return re.sub(specialsymbols, " ", oldStr)
corpus_path_train = "/train_corpus/" # 训练集未分词分类预料库路径
seg_path_train = "/train_corpus_seg/" # 训练集分词后分类语料库路径
corpus_path_test = "/test_corpus/" # 测试集未分词分类预料库路径
seg_path_test = "/test_corpus_seg/" # 测试集分词后分类语料库路径
corpus_path = (corpus_path_train,corpus_path_test)
seg_path = (seg_path_train,seg_path_test)
catelist = [os.listdir(i) for i in corpus_path]
words_list_train=[]
filename_list_train=[]
words_list_test = []
filename_list_test = []
# 加载停用词表
stopwords = stopwordslist('/hlt2.txt')
print("停用词表长度为:"+str(len(stopwords)))
# 训练集处理
for mydir in catelist[0]:
class_path = corpus_path[0] + mydir + "/" # 拼出分类子目录的路径
seg_dir = seg_path[0] + mydir + "/" # 拼出分词后预料分类目录
if not os.path.exists(seg_dir): # 是否存在,不存在则创建
os.makedirs(seg_dir)
file_list = os.listdir(class_path)
print("当前处理的类别是: ",mydir)
for file_path in tqdm(file_list):
fullname = class_path + file_path
content = readfile(fullname).strip() # 读取文件内容
content = content.replace("\n", "").strip() # 删除换行和多余的空格
content = content.replace('\t',"") # trans Tab to 空格
#一句话轻松简单搞定分词
content_seg = jieba.cut(content)
words=" ".join(content_seg)
#因为jieba分词,遇到空格也会作为一个单词,分完词后,将空格全部过滤掉
words=movestopwords(words,stopwords).strip()
words=replaceAllSymbols(words).strip()
words = re.sub(r'\s+',' ',words) # trans 多空格 to 空格
#因为后面要固定长度,因此我们只需前300个词组即可
words = " ".join(words.split()[:300]).strip()
savefile(seg_dir + file_path,words)
words_list_train.append(words)
filename_list_train.append(mydir)
# 测试集的遍历
words_list_test = []
for mydir in catelist[1]:
class_path = corpus_path[1] + mydir + "/" # 拼出分类子目录的路径
seg_dir = seg_path[1] + mydir + "/" # 拼出分词后预料分类目录
if not os.path.exists(seg_dir): # 是否存在,不存在则创建
os.makedirs(seg_dir)
file_list = os.listdir(class_path)
print("当前处理的类别是: ",mydir)
for file_path in tqdm(file_list):
fullname = class_path + file_path
content = readfile(fullname).strip() # 读取文件内容
content = content.replace("\n", "").strip() # 删除换行和多余的空格
content = content.replace('\t',"") # trans Tab to 空格
#一句话轻松简单搞定分词
content_seg = jieba.cut(content)
words=" ".join(content_seg)
#因为jieba分词,遇到空格也会作为一个单词,分完词后,将空格全部过滤掉
words=movestopwords(words,stopwords).strip()
words=replaceAllSymbols(words).strip()
words = re.sub(r'\s+',' ',words) # trans 多空格 to 空格
#因为后面要固定长度,因此我们只需前300个词组即可
words = " ".join(words.split()[:300]).strip()
savefile(seg_dir + file_path,words)
words_list_test.append(words)
filename_list_test.append(mydir)
# 另一种保存方式,把train_test都保存正在一个大的文件上
fileObject_test = open('test_seg_new.txt', "w",encoding='gb2312', errors='ignore')
fileObject_train = open('train_seg_new.txt', "w",encoding='gb2312', errors='ignore')
for ip in words_list_train:
fileObject_train.write(ip)
fileObject_train.write('\n')
fileObject_train.close()
for ip in words_list_test:
fileObject_test.write(ip)
fileObject_test.write('\n')
fileObject_test.close()
fileObject_test_label = open('test_label_new.txt', "w",encoding='gb2312', errors='ignore')
fileObject_train_lable = open('train_label_new.txt', "w",encoding='gb2312', errors='ignore')
for ip in filename_list_train:
fileObject_train_lable.write(ip)
fileObject_train_lable.write('\n')
fileObject_train_lable.close()
for ip in filename_list_test:
filename_list_test.write(ip)
filename_list_test.write('\n')
fileObject_train_lable.close(
print("分词结束")
特征提取与文本表示:
count_v1=TfidfVectorizer()
train_data=count_v1.fit_transform(train_texts)
print ("the shape of train is "+repr(train_data.shape))
count_v2 = TfidfVectorizer(vocabulary=count_v1.vocabulary_)
test_data = count_v2.fit_transform(test_texts)
print("the shape of test is "+repr(test_data.shape))
x_train = train_data
y_train = train_labels
x_test = test_data
y_test = test_labels
建模:
朴素贝叶斯(秒出结果有木有!)
print('(3) Naive Bayes...')
from sklearn.naive_bayes import MultinomialNB
bys_clf = MultinomialNB(alpha = 0.01)
bys_clf.fit(x_train, y_train)
# 最简单求准确率的方法
scores = bys_clf.score(x_test,y_test)
print("score is"+str(scores))
# 另一种求准确率的方法
preds = bys_clf.predict(x_test)
num = 0
preds = preds.tolist()
for i,pred in enumerate(preds):
if str(pred) == str(y_test[i]):
num += 1
print('bys precision_score:' + str(float(num) / len(preds)))
(3) Naive Bayes... score is0.9165237891127304 bys precision_score:0.9165237891127304
效果还是不错的
SVN——但效果迷之好,不愧是2012年以前吊打神经网络的模型
print ('(4) SVM...')
from sklearn.svm import SVC
svclf = SVC(kernel = 'linear')
svclf.fit(x_train,y_train)
scores = svclf.score(x_test,y_test)
print("score is"+str(scores))
preds = svclf.predict(x_test)
num = 0
preds = preds.tolist()
for i,pred in enumerate(preds):
if str(pred) == str(y_test[i]):
num += 1
print('svm precision_score:' + str(float(num) / len(preds)))
(4) SVM... score is0.9528504072010288 svm precision_score:0.9528504072010288
这里默默插一句话:虽然svn在长文本效果不差,但是,我以前跑过短文本,那个效果是真的一言难尽啊
RandomForest:
print ('(5) RF...')
from sklearn.ensemble import RandomForestClassifier
rfclf = RandomForestClassifier()
rfclf.fit(x_train,y_train)
scores = rfclf.score(x_test,y_test)
print("score is"+str(scores))
preds = rfclf.predict(x_test)
num = 0
preds = preds.tolist()
for i,pred in enumerate(preds):
if str(pred) == str(y_test[i]):
num += 1
print('rf precision_score:' + str(float(num) / len(preds)))
(5) RF... score is0.9162023146163738 rf precision_score:0.9162023146163738
居然被打脸了,俺严重怀疑这一年sklean里面randomForest的代码出现了改动,明明以前测都是80%+
数据集百度云链接:
原始数据文件下述下载:
训练集链接:https://pan.baidu.com/s/1slA3EGH 密码:2uvr
测试集链接:https://pan.baidu.com/s/1eSKZzAM 密码:7uqi
代码集:算了,懒得放github链接了,上面几乎都是所有的代码了
最后说几句:
虽然这里说的是初阶,但不代表技术不好。虽然现在很多不懂技术的媒体都在一天到晚鼓吹什么神经网络,深度学习。但可以看到,用传统方法效果还是相当可以的。正如我在一开头吐槽,目前其实NLP最难的问题是落地和可解释性——例如某个逻辑出了错你要立马能改,你又是一直无脑堆黑箱模型,那就直接gg了。很多所谓的深度学习,其实在应用中反而只是一个辅助的作用,你试试用bert模型弄一个每秒并发量上万甚至十多万的模型看看???
至于为啥面试要一直问深度模型?你要知道,现在算法是真心卷,不多问点最新的问题,你还真没办法看那个人是否勤奋好学.........
参考文献和网站:
[1]用深度学习(CNN RNN Attention)解决大规模文本分类问题 - 综述和实践[Z]https: //zhuanlan.zhihu.com/p/25928551