新闻文本分类实战(一)

赛题理解及思考

获取数据

我们直接打开天池大赛找到零基础入门NLP比赛,看到的是这样一个页面:

接着就三步走:注册报名下载数据,查看数据前五行可以看到我们获得的数据如下:

其中左边的label是数据集文本对应的标签,而右边的text则是编码后的文本,文本对应的标签列举如下:

1
{'科技': 0, '股票': 1, '体育': 2, '娱乐': 3, '时政': 4, '社会': 5, '教育': 6, '财经': 7, '家居': 8, '游戏': 9, '房产': 10, '时尚': 11, '彩票': 12, '星座': 13}

根据官方描述:赛题以匿名处理后的新闻数据为赛题数据,数据集报名后可见并可下载。赛题数据为新闻文本,并按照字符级别进行匿名处理。整合划分出14个候选分类类别:财经、彩票、房产、股票、家居、教育、科技、社会、时尚、时政、体育、星座、游戏、娱乐的文本数据。

赛题数据由以下几个部分构成:训练集20w条样本,测试集A包括5w条样本,测试集B包括5w条样本。为了预防选手人工标注测试集的情况,我们将比赛数据的文本按照字符级别进行了匿名处理。

同时我们还应该注意到官网有给出结果评价指标,我们也需要根据这个评价指标衡量我们的验证集数据误差:

既然该拿到的我们都拿到了,我们接下来就开始构思我们都应该使用哪些思路来完成我们的预测。

赛题构思

由于赛题给出的数据是匿名化的,因此我们无法使用分词等操作提取关键词来简单预测,我们可以使用的是对文本提取特征的分类器或者是深度学习分类器,综合我们有如下思路:

  • 思路1:TF-IDF + 机器学习分类器:直接使用TF-IDF对文本提取特征,并使用分类器进行分类。在分类器的选择上,可以使用SVM、LR、或者XGBoost。

  • 思路2:FastText:FastText是入门款的词向量,利用Facebook提供的FastText工具,可以快速构建出分类器。

  • 思路3:WordVec + 深度学习分类器:WordVec是进阶款的词向量,并通过构建深度学习分类完成分类。深度学习分类的网络结构可以选择TextCNN、TextRNN或者BiLSTM。

  • 思路4:Bert词向量:Bert是高配款的词向量,具有强大的建模学习能力。

我们后续将一一实现。

基础数据分析

虽然这里的数据都是编码的文本数据,我们很难通过简单的数据分析获得很多有价值的信息,但我们还是希望获得诸如文本长度分布,文本类别分布、文本字符分布等信息以获得一个直观的印象,下面我们完成这些操作:

读取数据(由于数据量较大读取前一百行便于操作):

1
train_df = pd.read_csv("train_set.csv", sep="\t", nrows=100)

统计文章词数

统计每篇文章的词数并绘制直方图:

1
2
3
4
5
6
%matplotlib inline
train_df["text_len"] = train_df["text"].apply(lambda x:len(x.split(" ")))
print(train_df["text_len"].describe())
_ = plt.hist(train_df['text_len'], bins=200)
plt.xlabel('Text char count')
plt.title("Histogram of char count")

charCount

统计不同种类新闻数量

1
2
3
train_df["label"].value_counts().plot(kind="bar")
plt.title('News class count')
plt.xlabel("category")

统计单词频率

1
2
3
4
5
6
7
8
9
from collections import Counter
all_lines = " ".join(list(train_df["text"]))
word_count = Counter(all_lines.split(" "))
word_count = sorted(word_count.items(), key=lambda d:d[1], reverse=True)
print(len(word_count))
print(word_count[-1])
print(word_count[0])
print(word_count[1])
print(word_count[2])

打印结果为编码”3750”,”648”,”900”为出现次数最多的字符且每篇文章中出现率均很高,我们有理由猜测这三个(或其中一两个)字符是标点符号,由此我们可以试着统计文章句子长度(假设这三个字符为标点符号)。

统计文章平均句子长度

1
2
3
4
5
6
7
8
import re
sent_count = Counter(re.split('3750|648|900', all_lines))
sent_count = sorted(sent_count.items(), key=lambda d:d[1], reverse=True)
sent_count = sent_count[1:-1]
count = 0
for i in range(len(sent_count)):
count += sent_count[i][1]
print("Average amount of sentences per article:", count/100)

结果显示平均每篇文章有78.5个句子。当然这三个编码未必都是标点符号,猜测应该是一个逗号一个句号一个常见字(估计是“的”),因此每篇文章文章句子数应该更少(可以查看上面代码中的第一个sent count,每个分割句都比我们平时遇见的短很多),具体分析哪个编码是表示这个常见字可以对其他新闻文本数据做分析,看看排名前三的关键字是否匹配,这个特征感兴趣的可以自行研究。

统计每类新闻最常出现的n个单词

另外我们可以统计每类新闻出现次数最多的字符,只需要将前100个文本中每类新闻的编码文本拼接起来进行如上统计即可,因此我们可以写一个函数用于输出出现次数前n的字符,然后传入对应的文本即可,实现如下:

1
2
3
4
5
6
7
8
def TopWord(type_, n):
all_lines = " ".join([train_df["text"][i] for i in range(len(train_df["text"])) if train_df["label"][i]==type_])
word_count = Counter(all_lines.split(" "))
word_count = sorted(word_count.items(), key=lambda d:d[1], reverse=True)
for i in range(n):
print(word_count[i])

TopWord(2, 3)

其中参数type_的取值范围为[0,13]内的整数,意为对应的标签,参数n为打印出现次数前n的单词对应的编码。当然也可以对其制作不同类别的文本出现次数前n的单词的直方图,由于这里不准备对单词频率等特征做过多分析因此略过不提。

基于机器学习的文本分类

在机器学习算法的训练过程中,假设给定$N$个样本,每个样本有$M$个特征,这样组成了$N×M$的样本矩阵,然后完成算法的训练和预测。同样的在计算机视觉中可以将图片的像素看作特征,每张图片看作hight×width×3的特征图,一个三维的矩阵来进入计算机进行计算。

但是在自然语言领域,上述方法却不可行:文本是不定长度的。文本表示成计算机能够运算的数字或向量的方法一般称为词嵌入(Word Embedding)方法。词嵌入将不定长的文本转换到定长的空间内,是文本分类的第一步。

词袋模型

词嵌入的最简单的方法就是将每一个字转为一个onehot编码,然后对于句子或者文章直接统计每个字出现的次数(词袋模型),例如:

1
2
3
4
5
句子1:我 爱 北 京 天 安 门
转换为 [1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0]

句子2:我 喜 欢 上 海
转换为 [1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1]

在sklearn中可以直接CountVectorizer来实现这一步骤:

1
2
3
4
5
6
7
8
9
from sklearn.feature_extraction.text import CountVectorizer
corpus = [
'This is the first document.',
'This document is the second document.',
'And this is the third one.',
'Is this the first document?',
]
vectorizer = CountVectorizer()
vectorizer.fit_transform(corpus).toarray()

CountVectorizer的使用及参数表如下,具体可见官方文档

1
2
CountVectorizer(input='content', encoding='utf-8',  decode_error='strict', strip_accents=None, lowercase=True, preprocessor=None, tokenizer=None, stop_words=None, 
token_pattern='(?u)\b\w\w+\b', ngram_range=(1, 1), analyzer='word', max_df=1.0, min_df=1, max_features=None, vocabulary=None, binary=False, dtype=<class 'numpy.int64'>)
参数表 作用
input 一般使用默认即可,可以设置为”filename’或’file’
encodeing 使用默认的utf-8即可,分析器将会以utf-8解码raw document
decode_error 默认为strict,遇到不能解码的字符将报UnicodeDecodeError错误,设为ignore将会忽略解码错误,还可以设为replace,作用尚不明确
strip_accents 默认为None,可设为ascii或unicode,将使用ascii或unicode编码在预处理步骤去除raw document中的重音符号
analyzer 一般使用默认,可设置为string类型,如’word’, ‘char’, ‘char_wb’,还可设置为callable类型,比如函数是一个callable类型
preprocessor 设为None或callable类型
tokenizer 设为None或callable类型
ngram_range 词组切分的长度范围,待详解
stop_words 设置停用词,设为english将使用内置的英语停用词,设为一个list可自定义停用词,设为None不使用停用词,设为None且max_df∈[0.7, 1.0)将自动根据当前的语料库建立停用词表
lowercase 将所有字符变成小写
token_pattern 过滤规则,表示token的正则表达式,需要设置analyzer == ‘word’,默认的正则表达式选择2个及以上的字母或数字作为token,标点符号默认当作token分隔符,而不会被当作token
max_df 可以设置为范围在[0.0 1.0]的float,也可以设置为没有范围限制的int,默认为1.0。这个参数的作用是作为一个阈值,当构造语料库的关键词集的时候,如果某个词的document frequence大于max_df,这个词不会被当作关键词。如果这个参数是float,则表示词出现的次数与语料库文档数的百分比,如果是int,则表示词出现的次数。如果参数中已经给定了vocabulary,则这个参数无效
min_df 类似于max_df,不同之处在于如果某个词的document frequence小于min_df,则这个词不会被当作关键词
max_features 默认为None,可设为int,对所有关键词的term frequency进行降序排序,只取前max_features个作为关键词集
vocabulary 默认为None,自动从输入文档中构建关键词集,也可以是一个字典或可迭代对象
binary 默认为False,一个关键词在一篇文档中可能出现n次,如果binary=True,非零的n将全部置为1,这对需要布尔值输入的离散概率模型的有用的
dtype 使用CountVectorizer类的fit_transform()或transform()将得到一个文档词频矩阵,dtype可以设置这个矩阵的数值类型

硬要总结一下词袋模型的话可总结为三部曲:分词(tokenizing),统计修订词特征值(counting)与标准化(normalizing)

tf-idf

转换的结果正是如上面我们人工转换的那样,但正如你现在想到的,有些字比如“的”,“我”等在每篇文章中出现频率很高,但不能体现更多的文章特征,因此我们会对每篇文章中出现次数较高的单词执行降权操作,想要了解的更具体可以参看我的另一篇学习笔记,这篇文章提到了比较多NLP领域的基础概念,这里简单将其中关于tf-idf的介绍复制过来:

tf-idf:核心方法论为单词并不是出现的越多就越重要,并不是出现的越少就越不重要。计算公式为$tfidf(w)=tf(d,w)*idf(w)$,其中$tf(d,w)$为文档$d$中$w$的词频,$idf(w)=\text{log}\frac{N}{N(w)}$,$N$为语料库中的文档总数,$N(w)$为词语$w$ 出现在多少个文档。 也就是一个单词如果每个文档都出现了,tfidf值会很低,相反一个单词只出现少数几个文档中,在这里个文档中这些个单词的重要性一定是比较高的。

比较词袋模型与tf-idf

接下来我们将对比不同文本表示算法的精度,通过本地构建验证集计算F1得分。

首先是词袋模型,部分代码旁边标有注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from sklearn.feature_extraction.text import CountVectorizer						# 构建词袋模型的库
from sklearn.linear_model import RidgeClassifier # 岭回归分类器
from sklearn.metrics import f1_score # 导入计算F1值的库

train_df = pd.read_csv('train_set.csv', sep='\t', nrows=15000) # 读取前15000行数据

vectorizer = CountVectorizer(max_features=3000) # 取前max_features个作为关键词
train_test = vectorizer.fit_transform(train_df['text']) # 转化为one-hot向量

clf = RidgeClassifier()
clf.fit(train_test[:10000], train_df['label'].values[:10000]) # 对前10000个学习,后5000个验证

val_pred = clf.predict(train_test[10000:]) # 对验证集进行预测
print(f1_score(train_df['label'].values[10000:], val_pred, average='macro'))

其F1得分为0.7416952793751392。

然后我们看tf-idf,代码与前面几乎一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import pandas as pd

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import RidgeClassifier
from sklearn.metrics import f1_score

train_df = pd.read_csv('../input/train_set.csv', sep='\t', nrows=15000)

tfidf = TfidfVectorizer(ngram_range=(1,3), max_features=3000)
train_test = tfidf.fit_transform(train_df['text'])

clf = RidgeClassifier()
clf.fit(train_test[:10000], train_df['label'].values[:10000])

val_pred = clf.predict(train_test[10000:])
print(f1_score(train_df['label'].values[10000:], val_pred, average='macro'))

其F1得分为0.8721598830546126,明显优于词袋模型。上面的代码需要注意的是ngram_range=(1,3)表示考虑单个编码单词组成的词语的长度,官方解释为:

ngram_rangetuple (min_n, max_n), default=(1, 1)

The lower and upper boundary of the range of n-values for different n-grams to be extracted. All values of n such that min_n <= n <= max_n will be used. For example an ngram_range of (1, 1) means only unigrams, (1, 2) means unigrams and bigrams, and (2, 2) means only bigrams. Only applies if analyzer is not callable.

我们还可以尝试其他sklearn库中的文本分类方法:

呃好像只剩下一个HashingVectorizer,这正是我们接下来要介绍的。

用哈希技巧向量化大文本向量

以上的向量化情景很简单,但是,事实上这种方式从字符标记到整型特征的目录(vocabulary_属性)的映射都是在内存中进行,在处理大数据集时会出现一些问题:

  • 语料库越大,词表就会越大,因此使用的内存也越大
  • 拟合(fitting)需要根据原始数据集的大小等比例分配中间数据结构的大小,构建词映射需要完整的传递数据集,因此不可能以严格在线的方式拟合文本分类器。
  • pickling和un-pickling vocabulary很大的向量器会非常慢
  • 将向量化任务分隔成并行的子任务很不容易实现,因为vocabulary属性要共享状态有一个细颗粒度的同步障碍:从标记字符串中映射特征索引与每个标记的首次出现顺序是独立的,因此应该被共享,在这点上并行worker的性能收到了损害,使他们比串行更慢。

通过同时使用由sklearn.feature_extraction.FeatureHasher类实施的“哈希技巧”(特征哈希)、文本预处理和CountVectorizer的标记特征有可能克服这些限制。简而言之,Hash Trick可以做特征的降维,具体的做法是:假设哈希函数$h$使第$i$个特征哈希到位置$j$即$h(i)=j$,则第$i$个原始特征的词频数值$\phi(i)$将累加到哈希后的第$j$个特征的词频数值$\bar\phi$上:

其中$\mathbb{J}$是原始特征的维度。但是上面的方法有一个问题,有可能两个原始特征的哈希后位置在一起导致词频累加特征值突然变大,为了解决这个问题,出现了hash Trick的变种signed hash trick,此时除了哈希函数h,我们多了一个一个哈希函数:

这样做的好处是,哈希后的特征仍然是一个无偏的估计,不会导致某些哈希位置的值过大。

当然,大家会有疑惑,这种方法来处理特征,哈希后的特征是否能够很好的代表哈希前的特征呢?从实际应用中说,由于文本特征的高稀疏性,这么做是可行的。如果大家对理论上为何这种方法有效,建议参考论文:Feature hashing for large scale multitask learning.这里就不多说了。

在scikit-learn的HashingVectorizer类中,实现了基于signed hash trick的算法,这里我们就用HashingVectorizer来实践一下Hash Trick,我们直接用前面的文本数据做分类看看结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
import pandas as pd
from sklearn.feature_extraction.text import HashingVectorizer
from sklearn.linear_model import RidgeClassifier
from sklearn.metrics import f1_score

hashVec = HashingVectorizer(ngram_range=(1,3))
train_test = hashVec.fit_transform(train_df['text'])

clf = RidgeClassifier()
clf.fit(train_test[:10000], train_df['label'].values[:10000])

val_pred = clf.predict(train_test[10000:])
print(f1_score(train_df['label'].values[10000:], val_pred, average='macro'))

这里没有传入max_features参数,因为我们知道哈希技巧本身就是一个降维的算法,计算出来的F1值为0.8891893925390113,明显优于另外两个算法,经过一些调参之后算法准确度还可以提高一些。

这里我们看到虽然我们只用了简单地机器学习算法,但已经取得了挺不错的分类率了,接下来我们将尝试基于深度学习的分类,事实上我们的比赛也从这里才正式开始!

基于深度学习的文本分类

FastText

FastText介绍

fastText模型架构

fastText是一个快速文本分类算法,与基于神经网络的分类算法相比有两大优点:

  1. fastText在保持高精度的情况下加快了训练速度和测试速度
  2. fastText不需要预训练好的词向量,fastText会自己训练词向量
  3. fastText两个重要的优化:Hierarchical Softmax、N-gram

fastText模型架构和word2vec中的CBOW很相似, 不同之处是fastText预测标签而CBOW预测的是中间词,即模型架构类似但是模型的任务不同。word2vec将上下文关系转化为多分类任务,进而训练逻辑回归模型,这里的类别数量$|V|$词库大小。通常的文本数据中,词库少则数万,多则百万,在训练中直接训练多分类逻辑回归并不现实。word2vec中提供了两种针对大规模多分类问题的优化手段, negative sampling 和hierarchical softmax。在优化中,negative sampling 只更新少量负面类,从而减轻了计算量。hierarchical softmax 将词库表示成前缀树,从树根到叶子的路径可以表示为一系列二分类器,一次多分类计算的复杂度从$|V|$降低到了树的高度

fastText模型架构:其中$x_1,x_2,…,x_{N−1},x_N$表示一个文本中的n-gram向量,每个特征是词向量的平均值。这和前文中提到的CBOW相似,CBOW用上下文去预测中心词,而此处用全部的n-gram去预测指定类别

Hierarchical Softmax

softmax函数常在神经网络输出层充当激活函数,目的就是将输出层的值归一化到

$0-1$区间,将神经元输出构造成概率分布,主要就是起到将神经元输出值进行归一化的作用。在标准的softmax中,计算一个类别的softmax概率时,我们需要对所有类别概率做归一化,在这类别很大情况下非常耗时,因此提出了分层softmax(Hierarchical Softmax),思想是根据类别的频率构造霍夫曼树来代替标准softmax,通过分层softmax可以将复杂度从$N$降低到$logN$,下图给出分层softmax示例:

在层次softmax模型中,叶子结点的词没有直接输出的向量,而非叶子节点都有响应的输在在模型的训练过程中,通过Huffman编码,构造了一颗庞大的Huffman树,同时会给非叶子结点赋予向量。我们要计算的是目标词w的概率,这个概率的具体含义,是指从root结点开始随机走,走到目标词w的概率。因此在途中路过非叶子结点(包括root)时,需要分别知道往左走和往右走的概率。例如到达非叶子节点n的时候往左边走和往右边走的概率分别是:

以上图中的目标词$w_2$为例:

因此目标词为$w$的概率可以表示为:

其中$θ_{n(w,j)}$是非叶子结点$n(w,j)$的向量表示(即输出向量);$h$是隐藏层的输出值,从输入词的向量中计算得来;$sign(x,j)$是一个特殊函数,定义为:

此外,所有词的概率和为1,即

最终得到参数更新公式为:

N-gram特征

n-gram是基于语言模型的算法,基本思想是将文本内容按照子节顺序进行大小为N的窗口滑动操作,最终形成窗口为N的字节片段序列。而且需要额外注意一点是n-gram可以根据粒度不同有不同的含义,有字粒度的n-gram和词粒度的n-gram,正如前面调参时的2-gram和3-gram。

对于文本句子的n-gram来说,如上面所说可以是字粒度或者是词粒度,同时n-gram也可以在字符级别工作,例如对单个单词matter来说,假设采用3-gram特征,那么matter可以表示成图中五个3-gram特征,这五个特征都有各自的词向量,五个特征的词向量和即为matter这个词的向其中“<”和“>”是作为边界符号被添加,来将一个单词的ngrams与单词本身区分开来:

从上面来看,使用n-gram有如下优点
1、为罕见的单词生成更好的单词向量:根据上面的字符级别的n-gram来说,即是这个单词出现的次数很少,但是组成单词的字符和其他单词有共享的部分,因此这一点可以优化生成的单词向量
2、在词汇单词中,即使单词没有出现在训练语料库中,仍然可以从字符级n-gram中构造单词的词向量
3、n-gram可以让模型学习到局部单词顺序的部分信息, 如果不考虑n-gram则便是取每个单词,这样无法考虑到词序所包含的信息,即也可理解为上下文信息,因此通过n-gram的方式关联相邻的几个词,这样会让模型在训练的时候保持词序信息

其他解决方案

但正如上面提到过,随着语料库的增加,内存需求也会不断增加,严重影响模型构建速度,针对这个有以下几种解决方案:
1、过滤掉出现次数少的单词
2、使用hash存储
3、由采用字粒度变化为采用词粒度

安装

我们先看看gituhub官方文档对FastText的定义 :

fastText is a library for efficient learning of word representations and sentence classification.

我们先使用fastText,随后再对其进行详细解析,第一步我们需要安装 fastText库:

1
pip instal fastText

或者手动从github下载:

1
2
3
4
5
$ git clone https://github.com/facebookresearch/fastText.git
$ cd fastText
$ sudo pip install .
$ # or :
$ sudo python setup.py install

使用

我们直接使用fastText来实现我们的文本分类,代码一共只有寥寥几行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
!pip install fasttext
import fasttext

import pandas as pd
from sklearn.metrics import f1_score

# 转换为FastText需要的格式
train_df = pd.read_csv("train_set.csv", sep="\t", nrows=15000)
train_df["label_ft"] = "__label__" + train_df["label"].astype(str)
train_df[["text", "label_ft"]].iloc[:-5000].to_csv("train.csv", index=None, header=None, sep="\t")

model = fasttext.train_supervised("train.csv", lr=1.0, wordNgrams=2, verbose=2, minCount=1, epoch=25, loss="hs")

val_pred = [model.predict(x)[0][0].split("__")[-1] for x in train_df.iloc[-5000:]["text"]]
print(f1_score(train_df["label"].values[-5000:].astype(str), val_pred, average="macro"))

结果很快就输出出来了(比前面的机器学习模型还快),结果为0.8260812453351833,但人不可貌相,我们可以对这个结果做很多优化,比如增加训练集数量,调整参数等。下面先对上面部分参数做了个简单的循环用于查看粗略的训练效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 粗略调参
import fasttext
import pandas as pd
from sklearn.metrics import f1_score

# 转换为FastText需要的格式
train_df = pd.read_csv("train_set.csv", sep="\t", nrows=15000)
train_df["label_ft"] = "__label__" + train_df["label"].astype(str)

for val_set in [2000, 3000, 4000, 5000]:
print("The validation set is:", val_set)
train_df[["text", "label_ft"]].iloc[:-val_set].to_csv("train.csv", index=None, header=None, sep="\t")
for lr_ in range(1, 10, 2):
print("Learning rate is:", lr_/10)
for wordGram in [1, 2, 3]:
print("wordNgrams is:", wordGram)
model = fasttext.train_supervised("train.csv", lr=lr_/10, wordNgrams=wordGram, verbose=2, minCount=1, epoch=25, loss="hs")
val_pred = [model.predict(x)[0][0].split("__")[-1] for x in train_df.iloc[-val_set:]["text"]]
print("f1_score is:", f1_score(train_df["label"].values[-val_set:].astype(str), val_pred, average="macro"))

为什么说这是粗略的呢,因为验证集与测试集的划分比较随意,就取了前一部分作为训练集后一部分作为验证集,没有多次独立重复实验取平均值之类的操作,因此实验结果带有较大的偶然性,但这样粗略的实验有助于我们对整体参数有个大概的估计,我们后面的实验可以在更精细的参数范围内做调参等工作。根据上面的粗调参结果我们可以看到几个显然的结论(以下对参数的讨论均限制在上述实验所取范围):

  • 验证集越小效果越好
  • 学习率越高越好
  • 学习率较高(约0.7以上)时,wordNgrams参数为3时效果较好,学习率较低时wordNgrams参数为2时效果较好
  • 最最重要的是,训练数据全加进来效果当然会好很多

由于学习率未达到峰值,我们往1的右边继续调高学习率进行实验,最终我们将参数的取值范围大致定在以下范围内,我们后续将对这个范围内的参数做进一步调参:

参数名 learning_rate wordNgrams
取值范围 (1.2, 1.4) 3

在精确的参数估计中,我们使用10折交叉验证,每折使用9/10的数据进行训练,剩余1/10作为验证集检验模型的效果。这里需要注意每折的划分必须保证标签的分布与整个数据集的分布一致,划分代码如下:

1
2
3
4
5
6
7
label2id = {}
for i in range(total):
label = str(all_labels[i])
if label not in label2id:
label2id[label] = [i]
else:
label2id[label].append(i)

通过10折划分,我们一共得到了10份分布一致的数据,索引分别为0到9,每次通过将一份数据作为验证集,剩余数据作为训练集,获得了所有数据的10种分割。不失一般性,我们选择最后一份完成剩余的实验,即索引为9的一份做为验证集,索引为1-8的作为训练集,然后基于验证集的结果调整超参数,使得模型性能更优。嗯想了想前面的HashingVectorizer,不用累死累活去调参就有0.889的准确率(甭跟我扯训练数据的事,两个方法的训练数据都是10000),真是心累阿,不准备接着调参了反正也不是很高,就大概0.85左右这个样,重点还是后面两个方法。

Word2Vec

词向量训练

Word2Vec的理论我们在往期博客中已经有详细介绍了(传送门),这里我们将直接对其进行调用,并介绍整个实验流程:

首先导入必要的包,并过滤警告:

1
2
3
4
5
6
import pandas as pd
import numpy as np
from gensim.models import Word2Vec

import warnings
warnings.filterwarnings('ignore')

然后保险起见我们再瞅一眼我们的数据:

1
2
data_all = pd.read_csv("train.csv", sep="\t", nrows=15000)
data_all.head()

大概长这样,嗯没错:

我们接下来就要构建我们的词表啦,由于训练时通常是所有文档合并起来一起训练,因此这里就直接将所有的编码词合并起来:

1
2
3
word_list = []
for i in range(len(data_all)):
word_list.append(data_all["text"][i].strip().split(" "))

下一步就将词表传入模型进行训练啦,当然也可以将训练结果进行保存:

1
2
word2vec_model = Word2Vec(word_list, size=20, iter=10, min_count=20)
word2vec_model.save('word2vec_model.w2v')

由于我们是要对文章进行分类,而不是对某个词分类,我们最简单的表示文章的方法就是将文章中所有词向量取平均输出(后面会介绍其他方法):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import time
def getVector(text, word2vec_model):
count = 0
article_vector = np.zeros( word2vec_model.layer1_size )
for word in text:
if word in word2vec_model:
article_vector += word2vec_model[word]
count += 1
return article_vector / count

startTime = time.time()
vector_list = []

for i in range(len(data_all)):
text = data_all["text"][i].strip().split(" ")
vector_list.append(getVector(text, word2vec_model))
if i % 1000 == 0:
print("前%d个文本生成词向量花费总时间%.2f秒" %(i, time.time()-startTime))

可以看到生成的结果还是蛮快的:

于是我们的文本向量也就构建完成了,接下来我们可以套用我们熟知的各种机器学习模型或深度学习模型啦!

利用词向量进行分类

首先最简单的当然是试探一下逻辑回归是否可行:

1
2
3
4
5
6
7
8
9
10
11
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

X = np.array(vector_list)
y = np.array(data_all["label"])

train_X, test_X, train_y, test_y = train_test_split(X, y, test_size=0.2, random_state=0)

logistic_model = LogisticRegression()
logistic_model.fit(train_X, train_y)
logistic_model.score(test_X, test_y)

结果是0.7783333333333333,不好不差,至少说明了构建的词向量不算离谱hhh~我们接下来将所有能想到的分类的模型全部试一遍,然后选一个效果比较不错的再进行调参,大致是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from sklearn.neural_network import MLPClassifier  # 多层感知机
from sklearn.neighbors import KNeighborsClassifier # K最近邻
from sklearn.svm import SVC # 支持向量机
from sklearn.gaussian_process import GaussianProcessClassifier # 高斯过程
from sklearn.gaussian_process.kernels import RBF # 高斯核函数
from sklearn.tree import DecisionTreeClassifier # 决策树
from sklearn.ensemble import RandomForestClassifier, AdaBoostClassifier, GradientBoostingClassifier,\
ExtraTreesClassifier, BaggingClassifier # 集成方法
from sklearn.naive_bayes import GaussianNB # 高斯朴素贝叶斯
from sklearn.discriminant_analysis import QuadraticDiscriminantAnalysis, LinearDiscriminantAnalysis # 判别分析
from xgboost import XGBClassifier # 极端梯度提升(eXtreme Gradient Boosting)

classifiers = [
('Logistic Regression', LogisticRegression()), # 逻辑回归
('Nearest Neighbors', KNeighborsClassifier(3)), # K最近邻
('Linear SVM', SVC(kernel='linear', C=0.025)), # 线性的支持向量机
('RBF SVM', SVC(gamma=2, C=1)), # 径向基函数的支持向量机
('Gaussian Process', GaussianProcessClassifier(1.0 * RBF(1.0))), # 基于拉普拉斯近似的高斯过程
('Decision Tree', DecisionTreeClassifier(max_depth=5)), # 决策树
('Random Forest', RandomForestClassifier(max_depth=5, n_estimators=10, max_features=1)), # 随机森林
('AdaBoost', AdaBoostClassifier()), # 通过迭代弱分类器而产生最终的强分类器的算法
('Extra Trees', ExtraTreesClassifier()),
('GradientBoosting', GradientBoostingClassifier()), # 梯度提升树
('Bagging', BaggingClassifier()),
('Naive Bayes', GaussianNB()), # 朴素贝叶斯
('QDA', QuadraticDiscriminantAnalysis()), # 二次判别分析
('LDA', LinearDiscriminantAnalysis()), # 线性判别分析
('MLP', MLPClassifier(alpha=1)), # 多层感知机
('XGB', XGBClassifier()), # 极端梯度提升
]

定义两个函数便于调参(构建词向量与训练模型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def testWord2Vec(data_all, size_=100, min_count_=5):
word_list = []
for i in range(len(data_all)):
word_list.append(data_all["text"][i].strip().split(" "))
word2vec_model = Word2Vec(word_list, size=size_, iter=10, min_count=min_count_)
vector_list = []
startTime = time.time()
for i in range(len(data_all)):
text = data_all["text"][i].strip().split(" ")
vector_list.append(getVector(text, word2vec_model))
if i % 1000 == 0:
print("前%d个文本生成词向量花费总时间%.2f秒" %(i, time.time()-startTime))
X = np.array(vector_list)
y = np.array(data_all["label"])
return X, y

def testModel(X, y, test_size_=0.2, seed=0):
train_X, test_X, train_y, test_y = train_test_split(X, y, test_size=test_size_, random_state=seed)
for name, clf in classifiers:
print(name, end=": ")
clf.fit(train_X, train_y)
print(clf.score(test_X, test_y))
1
2
X, y = testWord2Vec(data_all)
testModel(X, y)

本人训练后结果比较好的是逻辑回归、K最近邻、多层感知机和极端梯度提升,没有进行调参的准确率大约0.85左右,部分结果如下。希望有更高的准确率读者自行调参即可:

其他方式(Doc2Vec)

事实上我们不一定要先构建词向量,再取平均得到句子向量,2013 年 Mikolov 提出了 word2vec 来学习单词的向量表示,主要有两种方法,cbow ( continuous bag of words) 和 skip-gram , 一个是用语境来预测目标单词,另一个是用中心单词来预测语境。

既然可以将 word 表示成向量形式,那么句子/段落/文档是否也可以只用一个向量表示?事实上是可以的,主要有两种方式,第一种方式便是我们 前面所做的,先得到 word 的向量表示,然后用一个简单的平均来代表文档。 第二种方式就是 Mikolov 在 2014 提出的Doc2Vec,也有两种方法来实现:

Distributed Memory Model of Paragraph Vectors(PVDM)

它不是仅是使用一些单词来预测下一个单词,我们还添加了另一个特征向量,即文档Id

因此,当训练单词向量W时,也训练文档向量D,并且在训练结束时,它包含了文档的向量化表示。

调用gensim方式为:

1
model = gensim.models.Doc2Vec(documents,dm = 0, alpha=0.1, size= 20, min_alpha=0.025)

Distributed Bag of Words version of Paragraph Vector(PV-DBOW)

调用gensim方式为:

1
model = gensim.models.Doc2Vec(documents,dm = 0, alpha=0.1, size= 20, min_alpha=0.025)

由上面的调用方式可见,PVDM与PV-DBOW两者在 gensim 实现时的区别是 dm = 0 还是 1。总结一下:

  • Doc2Vec 的目的:获得文档的一个固定长度的向量表达。

  • 数据:多个文档,以及它们的标签,可以用标题作为标签。

  • 影响模型准确率的因素:语料的大小,文档的数量,越多越高;文档的相似性,越相似越好。

下面我们动手实现一下Doc2Vec(具体参考文档)结果可能会让你大吃一惊(哭笑):

首先还是一样导入相关的包:

1
2
3
4
5
6
7
8
9
import pandas as pd
import numpy as np
from gensim.models import Doc2Vec
import gensim
import os
import re

import warnings
warnings.filterwarnings('ignore')

然后读取文件:

1
2
data_all = pd.read_csv("train.csv", sep="\t", nrows=15000)
data_all.head()

用后3000条做验证集(当然也可以搞个N折交叉验证严谨点),这里需要注意的是一定要重置索引,哎说多都是泪:

1
2
3
4
data_all_train = data_all.head(12000)
data_all_val = data_all.tail(3000)
data_all_val = data_all_val.reset_index(drop=True)
data_all_train.head()

然后按格式读进数据,这里定义了一个read_corpus,是根据官方文档进行的修改,读者也可以根据自己的需要对其进行修改:

1
2
3
4
5
6
7
8
9
10
def read_corpus(data_all, tokens_only=False):
for i in range(len(data_all)):
tokens = data_all["text"][i].split(" ")
if tokens_only:
yield tokens
else:
yield gensim.models.doc2vec.TaggedDocument(tokens, [data_all['label'][i]])

train_corpus = list(read_corpus(data_all_train))
val_corpus = list(read_corpus(data_all_val, tokens_only=True))

随后定义模型,可以开始训练啦:

1
2
3
4
5
6
7
model = gensim.models.doc2vec.Doc2Vec(vector_size=50, min_count=2, epochs=40)
model.build_vocab(train_corpus)
model.train(train_corpus, total_examples=model.corpus_count, epochs=model.epochs)
vector_train_1 = model.infer_vector(train_corpus[1].words)
print(vector_train_1)
tag_train_1 = train_corpus[1].tags
print(tag_train_1)

打印出训练得到的编码,看起来挺人模人样的:

然后我们就把模型套进我们的文本中,将训练集和测试集顺便划分好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
train_X = []
train_y = []
for i in range(len(train_corpus)):
train_X.append(list(model.infer_vector(train_corpus[i].words)))
train_y.append(train_corpus[i].tags)
if(i%3000==0):
print("Processing train set", i)
val_X = []
val_y = []
for i in range(len(val_corpus)):
val_X.append(list(model.infer_vector(val_corpus[i])))
val_y.append([data_all["label"][i]])

train_X = np.array(train_X)
train_y = np.array(train_y)
val_X = np.array(val_X)
val_y = np.array(val_y)

好这一切准备就绪了,我们就套一个逻辑回归吧,咱也甭求多高准确度,百分之八九十就好啦再慢慢调参吧:

1
2
3
4
from sklearn.linear_model import LogisticRegression
logistic_model = LogisticRegression()
logistic_model.fit(train_X, train_y)
logistic_model.score(val_X, val_y)

看看结果:0.12666666,雾草???我不信邪试了试其他的回归模型也差不多这个数,emmm网上搜了搜确实有遇到Doc2Vec效果不稳定的情况,但看原理不是和Word2Vec差不多吗,别以为我是埋伏笔,俺也不知道啊大家清楚的下面留言咯,看看是我代码的问题还是确实如此,又或者是数据集的特点决定的~

Bert

参考链接:

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2020-2021 chenk
  • 由 帅气的CK本尊 强力驱动
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信