NLP文本分类实战: 传统方法与深度学习
问题描述
文档分类是指给定文档p(可能含有标题t),将文档分类为n个类别中的一个或多个,本文以人机写作为例子,针对有监督学习简单介绍传统机器学习方法和深度学习方法。 文档分类的常见应用:
- 新闻的分类,也就是给新闻打标签,一般标签有几千个,然后要选取k个标签,多分类问题,可见2017知乎看山杯比赛,该比赛是对知乎的问题打标签;
- 人机写作判断,判断文章是人写的还是机器写的,二分类问题,可见CCF2017的360人机大战题目;
- 情感识别,例如判断豆瓣影评中的情感是正向、负向、中立,这个问题很常见而且应用场景很广泛;
传统机器学习模型
使用传统机器学习方法解决文档分类问题一般分为:文档预处理、特征提取、分类器选取、Adaboost多次训练的过程。
文档预处理
- 分词:中文任务分词必不可少,一般使用jieba分词,工业界的翘楚。
- 词性标注:在分词后判断词性(动词、名词、形容词、副词...),在使用jieba分词的时候设置参数就能获取。
- WORD—EMBEDDING:通过词与上下文、上下文与词的关系,有效地将词映射为低维稠密的向量,可以很好的表示词,一般是把训练和测试的语料都用来做word-embedding。可以把word-embedding作为传统机器学习算法的特征,同时也是深度学习方法必不可少的步骤(深度学习中单字和词的embedding都需要)。 本文使用Word2Vector实现Word Embedding,参数设置情况如下
- size=256 <Word Embedding的维度,如果是词的话一般设置为256,字的话设置为100就差不多,毕竟汉字数量为9w左右常用字7000左右>
- window=5, <滑动窗口的大小,词一般设置为5左右,表示当前词加上前后词数量为5,如果为字的话可以设置大一点>
- min_count=5, <最小词频,超过该词频的才纳入统计,字的话词频可以设置高一点>
- workers=15, <线程数量,加速处理>
- 分词、Word Embedding训练的代码如下,推荐使用pickle进行中间数据存储:
import pickle
import codecs
import jieba
import multiprocessing
import codecs
import pandas as pd
from gensim.models.word2vec import Word2Vec
train_file = "train.csv"
test_file = "test.csv"
train_file = codecs.open(train_file, 'r', 'utf-8')
train_lines = train_file.readlines()
test_file = codecs.open(test_file, 'r', 'utf-8')
test_lines = test_file.readlines()
label = []
train_title = []
train_content = []
train_title_cut = []
train_content_cut = []
test_id = []
test_title = []
test_content = []
test_title_cut = []
test_content_cut = []
print("Segment train title/content...")
for i in range(len(train_lines)):
if i % 10000 == 0:
print(i)
if len(train_lines[i].split('\t')) != 4:
continue
article_id, title, content, l = train_lines[i].split('\t')
if 'NEGATIVE' in l:
label.append(0)
else:
label.append(1)
train_title.append(title)
train_content.append(content)
train_title_cut.append(' '.join(jieba.cut(title.strip('\n'), cut_all=False)))
train_content_cut.append(' '.join(jieba.cut(content.strip('\n'), cut_all=False)))
print("Segment train completed.")
print("Segment test title/content...")
for i in range(len(test_lines)):
if i % 10000 == 0:
print(i)
if len(test_lines[i].split('\t')) != 3:
continue
article_id, title, content = test_lines[i].split('\t')
test_id.append(article_id)
test_title.append(title)
test_content.append(content)
test_title_cut.append(' '.join(jieba.cut(title.strip('\n'), cut_all=False)))
test_content_cut.append(' '.join(jieba.cut(content.strip('\n'), cut_all=False)))
print("Segment test completed.")
pickle.dump(label, open('train_label.p', 'wb'))
pickle.dump(train_title, open('train_title.p', 'wb'))
pickle.dump(train_content, open('train_content.p', 'wb'))
pickle.dump(train_title_cut, open('train_title_cut.p', 'wb'))
pickle.dump(train_content_cut, open('train_content_cut.p', 'wb'))
pickle.dump(test_id, open('test_id.p', 'wb'))
pickle.dump(test_title, open('test_title.p', 'wb'))
pickle.dump(test_content, open('test_content.p', 'wb'))
pickle.dump(test_title_cut, open('test_title_cut.p', 'wb'))
pickle.dump(test_content_cut, open('test_content_cut.p', 'wb'))
corpus = train_title_cut + train_content_cut + test_title_cut + test_content_cut
class CorpusData:
def __init__(self, corpus):
self.corpus = corpus
def __iter__(self):
for doc in corpus:
origin_words = doc.split(' ')
yield origin_words
print("Train word to vector...")
corpus_data = CorpusData(corpus)
model = Word2Vec(corpus_data, size=256, window=5, min_count=5, workers=15)
model.save('w2v_model_s256_w5_m5.save')
print("Train w2v completed.")
特征提取
可以说提取的特征决定了整个任务分数的上限,强的或者说敏感的特征对文档分类有及其大的影响,而弱特征的组合有时候也能发挥意向不到的效果,提取过程一般是选取文档的常规特征、针对具体任务设计的特征、对特征的强度计算和筛选。
常规特征
- TF-IDF:词频-逆文档频率,用以评估词对于一个文档集或一个语料库中的其中一个文档的重要程度,计算出来是一个DxN维的矩阵,其中D为文档的数量,N为词的个数,通常会加入N-gram,也就是计算文档中N个相连词的的TF-IDF。一般用sklearn的库函数计算,具体用法详见sklearn.feature_extraction.text.TfidfVectorizer。在人机写作判断的问题来看,TF-IDF是很强的一个特征。
- LDA(文档的话题):可以假设文档集有T个话题,一篇文档可能属于一个或多个话题,通过LDA模型可以计算出文档属于某个话题的概率,这样可以计算出一个DxT的矩阵。LDA特征在文档打标签等任务上表现很好。
- LSI(文档的潜在语义):通过分解文档-词频矩阵来计算文档的潜在语义,和LDA有一点相似,都是文档的潜在特征。
- 词性的TD—IDF:以词的词性表示词,再次计算其tf-idf,由于词性种类很有限,矩阵比较小。
针对具体任务设计特征
本文是以人机写作判断为例子,为此设计了以下特征,其中每种特征都选取最大值、最小值、平均值、中位数、方差:
- 句子长度:文档短句之后,统计句子长度;
- 标点数:文档断句之后,每个句子中的标点个数
- jaccard相似度:分词后的每个句子与分词后的标题的jaccard距离;
- 重复句子:文档中是否有重复句子
- 英文、数字个数:断句后句子中的英文、数字个数
特征的强度计算和筛选
我们要尽可能选取任务敏感的特征,也就是特征足够强可以影响分类的结果,一般用树模型判断特征的重要程度,xgboost的get_fscore就可以实现这一功能。计算特征强度之后,选取较强的特征,摒弃弱特征。可以尝试组合不同的特征来构造新的特征,然后测试新特征的强弱,反复如此获取更多的强特征。
分类器选取
特征提取相当于构造了一个DxF的矩阵,其中D为文档数量,F为特征数量,一篇文档用N维空间上的一个点表示,成为一个数学问题:如何将点分类。机器学习中的一个重要问题就是分类,相对回归问题来说分类问题更加简化、模糊、不确定,往往因为不能设计定量的回归问题而设计成分类问题,例如以前天气预报给的结果是60%的概率降雨,现在可以给出70%的概率降雨100ml的结果,就是随着计算能力的提高的计算技术的发展讲分类问题转换为回归问题。前人给我留下了诸如朴素贝叶斯、逻辑回归、SVM、决策树、神经网络等分类算法,sklearn等机器学习库将其封装为简单容易上手的API,供我们选择。
- 朴素贝叶斯:以前很多人拿到数据就会马上用朴素贝叶斯验证一下数据集,但是往往由于各个特征不是独立同分布的所以朴素贝叶斯的效果一般不好,但是也可以尝试一下,如果效果不错作为最后模型融合的一个模型。针对人机写作判断的任务来说效果太差了,就没有记录。
- 逻辑回归:用来解决回归问题的方法,分类问题可以看做回归问题的特例,也能尝试用此方法。但是结果和朴素贝叶斯差不多的样子。
- SVM:在神经网络火起来之前,可以说SVM撑起了一半分类问题的解决方案,SVM的想法很质朴:找出两类点之间的最大间隔(也包括软间隔,即间隔中有样本点)把样本点分类。但是在人机写作判断的问题上,SVM的表现也不好。
- 树模型:树结构很适合文档分类,我主要使用LightGBM模型对文档进行分类,其中调参的经验是树的深度不要太深、让树尽可能矮宽,这样分类比较充分,一般深度为4~6就行。树模型的调参是一件很痛苦的事情,推荐基于贝叶斯优化的调参方法。有熟悉XGBoost的同学也可以尝试,但是总体来说LGB比XGB速度要快好几倍更适合验证特征以及调参。人机写作任务中,第一批数据是10w训练+5w测试数据,LGB在这个数据集上表现很优秀,单模型就接近0.9了,但是由于后期60w训练+40w测试数据,LGB的性能明显下降,而且怎么调参优化都不起作用。
Adaboost多次训练
这是我和队友针对人机写作判断问题,根据Adaboost算法设计的一个小Trick。如果每次分类结果中,把大量的负样本分为正样本,或者大量负样本分为正样本,就根据正样本和负样本的错误率调整正负样本的权重,例如正负样本的错误率分别为P(正)、P(负),当前权重分别为W(正)、W(负),则根据以下方式调整:
W(正) = P(正)+ [1 - abs(P(正) + P(负)) / 2]
W(负) = P(负)+ [1 - abs(P(正) + P(负)) / 2]
根据这种动态调整权重的方法,可以充分发挥树模型在分类问题上的优势。
小结
新数据发放之后,树模型的能力明显降低,在训练集都达不到0.7的F1值,我在费力捣鼓树模型时候,队友已经开始尝试深度学习的模型,明显优于传统机器学习模型,分数在0.84以上。而且传统机器学习方法中的特征提取环节太过费时费力,而且经常很不讨好。建议刚接触文档分类或者其他机器学习任务,可以尝试传统的机器学习方法(主要是统计学习方法),可以体验一下这几个过程,但是如果想取得好成绩最好还是尝试深度学习。
深度学习模型
深度学习模型的重点是模型的构建和调参,相对来说任务量能小不少。RNN、LSTM等模型由于拥有记忆能力,因而在文本处理上表现优异,但是缺点很明显就是计算量很大,在没有GPU加速情况下,不适合处理大批的数据,CNN在FaceBook的翻译项目上大放异彩也表明CNN在文本处理领域上的重要性,而且相对RNN来说,速度明显提升。本文尝试了多层CNN、并行CNN、RNN与CNN的结合、基于Hierarchical Attention的RNN、迁移学习、多任务学习、联合模型学习。在单模型和联合模型学习上,我们复现、借鉴了2017知乎看山杯比赛第一名的方案,在此表示感谢。深度学习部分代码都是使用Keras框架实现的,Keras搭建模型非常方便适合快速验证自己的想法和模型。
文本预处理
分词、Word Embedding已经介绍过,一般文本内容输入到神经网络作为Input,要先进行Tokenizer,然后对空白部分做padding,并且获得Word Embedding的emnedding_matrix其中Tokenizer、padding都是使用Keras自带的API,因为我刚开始使用深度学习处理文本时候这个过程不是很明白,就分享一下代码,具体过程如下:
from keras.preprocessing.text import Tokenizer
from gensim.models.word2vec import Word2Vec
max_nb_words = 100000 #常用词设置为10w
tokenizer = Tokenizer(num_words=max_nb_words, filters='')
tokenizer.fit_on_texts(train_para_cut) #使用已经切分的训练语料进行fit
word_index = tokenizer.word_index
vocab_size = len(word_index)
model = Word2Vec.load(w2v_file) #Load之前训练好的Word Embedding模型
word_vectors = model.wv
embeddings_index = dict()
for word, vocab_obj in model.wv.vocab.items():
if int(vocab_obj.index) < max_nb_words:
embeddings_index[word] = word_vectors[word]
del model, word_vectors
print("word2vec size: {}".format(len(embeddings_index)))
num_words = min(max_nb_words, vocab_size)
not_found = 0
embedding_matrix = np.zeros((num_words+1, w2v_dim)) # 与训练好的,神经网络Embedding层需要用到
for word, i in word_index.items():
if i > num_words:
continue
embedding_vector = embeddings_index.get(word)
if embedding_vector is not None:
embedding_matrix[i] = embedding_vector
else:
not_found += 1
print("not found word in w2v: {}".format(not_found))
print("input layer size: {}".format(num_words))
print("GET Embedding Matrix Completed")
train_x = tokenizer.texts_to_sequences(train_content_cut)
train_x = pad_sequences(train_x, maxlen=max_len, padding="post", truncating="post")
test_x = tokenizer.texts_to_sequences(test_content_cut)
test_x = pad_sequences(test_x, maxlen=max_len, padding="post", truncating="post")
必要的数据统计
一篇文档及其标签作为神经网络的一个输入,经Tokenizer之后,需要设置定长的输入,必须统计文档长度、句子数、句子长度、标题长度,推荐使用pandas进行统计,方便简洁。就人机写作判断任务的数据统计情况如下:
Label on train:
NEGATIVE 359631
POSITIVE 240369
Content length on train:
POSITVE:
count 240369.000000
mean 1030.239369
std 606.937210
min 2.000000
25% 554.000000
50% 866.000000
75% 1350.000000
max 3001.000000
NEGATIVE:
count 359631.000000
mean 1048.659999
std 607.034089
min 186.000000
25% 574.000000
50% 882.000000
75% 1369.000000
max 3385.000000
Content length on test:
count 400000.000000
mean 1042.695075
std 608.866342
min 136.000000
25% 567.000000
50% 877.000000
75% 1362.000000
max 4042.000000
Sentence number on train
count 600000.000000
mean 64.429440
std 43.248348
min 2.000000
25% 33.000000
50% 53.000000
75% 85.000000
max 447.000000
设想差不多2个字1个词,分完词后句子词数最大不超过2000,神经网络的Input length可以设置为2000,文档句子数设为100,句子长度设为50,最够覆盖绝大部分文档。
正文与标题
文档分为正文和标题两部分,一般两部分分开处理,可以共享Embedding层也可以不共享,人机写作分类问题中我们没有共享Embedding。
正文多层CNN,未使用标题
CNN需要设置不同大小的卷积核,并且多层卷积才能较好的捕获文本的特征,具体网络结构如下:
正文 CNN Inception,未使用标题
RCNN处理正文,多层CNN处理标题
基于Hierarchical Attention的RNN处理正文
模型是根据论文《Hierarchical Attention Networks for Document Classification》实现的,论文中的模型如下
具体实现过程中的网络结构如下:
迁移学习
设计模型M,M在数据集A上训练到最佳效果保存模型的权重,然后再使用训练好的M在数据集B上训练,这个过程可以看做简单的迁移学习。因为人机写作判断任务中,先后有两个数据集,早期数据可以看做A,后期也就是最终的数据看做B,而模型在A上的表现比B上要好很多,这样就可以使用迁移学习来使模型在B上表现也好。 我们是使用RCNN处理正文,多层CNN处理标题这个模型来实现迁移学习的,具体过程如下:
多任务学习
相同模型同时在数据集A和B上训练,可以称为多任务学习,我们也是使用RCNN处理正文,多层CNN处理标题这个模型来实现多任务学习的,具体过程如下:
联合模型学习
选取表现最好的2个单模型,在数据集上预训练到最优,然后联合在一起训练,可以共享Embedding层,也可以不共享,由于我们表现最好的单模型是RCNN处理正文,多层CNN处理标题的Model A和基于Hierarchical Attention的RNN的Model B,Embedding的形式不同所以不能共享,具体形式如下所示,也可以联合多个单模型一起训练,但缺点就是训练时间过长:
小结
深度学习在文档分类问题上比传统机器学习方法有太大的优势,仔细分析就知道文本的特征很难提取,而且这些特征不能很好的表示文档的语义、语法,丢失了很大一部分的有用信息,而深度学习就是将特征提取这个环节交给深度网络去自动完成,通过更高的计算成本换取更全面更优良的文本特征。
模型Stacking
一般是针对评测或者比赛,融合多个模型的结果,不同的模型会导致其预测结果的多样性,Stacking可以有效的融合其多养性达到提高分数的目的。关于Stacking的介绍,可以看这篇文章。 传统Stacking方法一般使用树模型,例如LightGBM、XGBoost等,我们使用神经网络的方式实现,Model就是两个全连接层。
总结
传统机器学习方法可以作为任务的Baseline,而且通过特征的设计和提取能够感受数据,不能把数据也就是文档看做黑盒子,对数据了解足够设计模型肯定事半功倍。至于深度学习的方法,我们只是借鉴、改进经典论文提出的模型,也使用了前人比赛的Trick,如果想深入了解还是需要阅读更多的论文,ACL是计算语言学年会汇集了全球顶尖NLP领域学者的思想,可以关注这个会议阅读其收录的论文。
引用
- Kim Y. Convolutional Neural Networks for Sentence Classification[J]. Eprint Arxiv, 2014.
- Yang Z, Yang D, Dyer C, et al. Hierarchical Attention Networks for Document Classification[C]// Conference of the North American Chapter of the Association for Computational Linguistics: Human Language Technologies. 2017:1480-1489.
- https://zhuanlan.zhihu.com/p/28923961
- https://zhuanlan.zhihu.com/p/25928551