1、本文作者学习了前辈的研究
《Implementing a CNN for Text Classification in TensorFlow》以及github作者的实验,使用THUCNews的一个子集进行训练与测试。THUCNews是根据新浪新闻RSS订阅频道2005~2011年间的历史数据筛选过滤生成,包含74万篇新闻文档(2.19 GB),均为UTF-8纯文本格式。非常感激前辈及学习过程中的伙伴。
github地址:
https://github.com/gaussic/text-classification-cnn-rnn#text-classification-with-cnn-and-rnn
cnew文件夹数据说明:
cnews.train.txt: 训练集(50000条)
cnews.val.txt: 验证集(5000条)
cnews.test.txt: 测试集(10000条)
cnews.vocab.txt:词汇表(5000个)
共有10个类别。
下载链接:https://pan.baidu.com/s/1u4fjHg2B9zdaejvAK_iP1w
提取码:6ubq
其中声明一下:用CNN进行文本分类不用进行分词和词性标注,整个过程最核心的部分是embedding(词嵌入)层的构建,其目的在于把每个句子用向量来表示,又要避免计算量过大,不得不做一些特殊处理。
2、数据预处理过程如下:
(1)获得词汇表:
(在给定的数据集中已经给出词汇表,但其实制作词汇表并不难)
此处构建词汇表是统计了包括训练集、验证集和测试集在内的所有文本数据当中出现频率最高的5000个词,当然也包括了标点:)。关键的地方在于词汇表的表头第0个位置放着<PAD>,目的是用于表征句子当中的词汇万一在词汇表当中没有呢?那就用词汇表的第0个位置的词来统一表示,也可以理解为补零:)。
from collections import Counter
def getVocabularyText(content_list, size):
size = size - 1
allContent = ''.join(content_list)
#将内容列表中的所有文章合并起来变成字符串str形式
counter = Counter(allContent)
#将Counter对象实例化并传入字符串形式的内容
vocabulary = []
vocabulary.append('<PAD>')
for i in counter.most_common(size):
vocabulary.append(i[0])
with open('vocabulary.txt', 'w', encoding='utf8') as file:
for vocab in vocabulary:
file.write(vocab + '\n')
其中补充一下collections包中Counter的用法与效果:
from collections import Counter
a = 'hello大家好,我的..嗯,我的名字是Juanly!我有点害羞。。'
counter = Counter(a)
counter.most_common(2)
most_common()里面接受一个int类型数据,表示输出频率最高的int个字符并且用元组的形式表示,元组左边是该字符,右边是词频。
输出:
[('l', 3), ('我', 3)]
(2)读取数据:
with open('./cnews/cnews.vocab.txt', encoding='utf8') as file:
vocabulary_list = [k.strip() for k in file.readlines()]
#读取词表
with open('./cnews/cnews.train.txt', encoding='utf8') as file:
line_list = [k.strip() for k in file.readlines()]
#读取每行
train_label_list = [k.split()[0] for k in line_list]
#将标签依次取出
train_content_list = [k.split(maxsplit=1)[1] for k in line_list]
#将内容依次取出,此处注意split()选择最大分割次数为1,否则句子被打断.
#同理读取test数据
with open('./cnews/cnews.test.txt', encoding='utf8') as file:
line_list = [k.strip() for k in file.readlines()]
test_label_list = [k.split()[0] for k in line_list]
test_content_list = [k.split(maxsplit=1)[1] for k in line_list]
(3)数据预处理之句子向量化:
由于一个基于整篇文本单词的向量维度十分大,在构造句子向量的时候就不会选择单词的向量拼接,而是选择单词对应词汇表的id(行号)拼接,这样可以有效避开了句子向量所造成的空间开销巨大问题。
word2id_dict = dict(((b, a) for a, b in enumerate(vocabulary_list)))
def content2vector(content_list):
content_vector_list = []
for content in content_list:
content_vector = []
for word in content:
if word in word2id_dict:
content_vector.append(word2id_dict[word])
else:
content_vector.append(word2id_dict['<PAD>'])
content_vector_list.append(content_vector)
return content_vector_list
train_vector_list = content2vector(train_content_list)
test_vector_list = content2vector(test_content_list)
print(len(train_content_list[0]))
print(len(train_vector_list[:1][0]))
print('************************************')
print(len(test_content_list[0]))
print(len(test_vector_list[:1][0]))
输出:
746
746
************************************
1720
1720
此处结果一样则说明了一个句子文本里面的所有词都成功地转化成了词汇表id。
(4)数据预处理-训练集与测试集:
vocab_size = 5000 # 词汇表达小
seq_length = 600 # 句子序列长度
num_classes = 10 # 类别数
一个句子的向量长度就是词的总数*词向量的维度了。这样一乘发现维度就特别大,而且每个句子的长度不一,对于CNN, 输入与输出都是固定的,所以句子有长有短就没法按batch来训练了。所以这里有个规定,就是每个句子长度为seq_length,那么不够这么长的句子就要补0操作,还记得0表示什么词吗?:)没错,就是’’。
补充的0对后面的结果没有影响,因为后面的max-pooling只会输出最大值,补零的项会被过滤掉.
用keras的预处理模块去规范化句子序列长度,训练集与测试集都要这么做。说明一下contrib这个模块,该模块包含了一些不稳定的或者实验性的代码,比如tf.contrib.keras,是一种用于TensorFlow实现keras的高级API。
import tensorflow.contrib.keras as kr
train_X = kr.preprocessing.sequence.pad_sequences(train_vector_list,600)
test_X = kr.preprocessing.sequence.pad_sequences(test_vector_list,600)
以上的pad_sequences函数将id形式数组也即句子向量转化为了固定长度或者维度为600的数组,刚刚也提到如果句子不够长则补0,此处的pad_sequences是从左边补0,非右边,并且句子的截断是从右边开始数起,够600则截断,可以理解为列表切片操作[-600:],只不过这里是批量list处理而已。
接着对label进行one-hot处理,并且要先对字符串的label进行labelEncoder之后变为0-9个数才能进行one-hot,且由于10个类别,则每个label的维度大小为10。
import tensorflow.contrib.keras as kr
from sklearn.preprocessing import LabelEncoder
label = LabelEncoder()
train_Y = kr.utils.to_categorical(label.fit_transform(train_label_list),num_classes=num_classes)
test_Y = kr.utils.to_categorical(label.fit_transform(test_label_list),num_classes=num_classes)
train_Y[:2]
输出如下:
array([[1., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
[1., 0., 0., 0., 0., 0., 0., 0., 0., 0.]])
3、构建CNN
import tensorflow as tf
X_holder = tf.placeholder(tf.int32,[None,seq_length])
#X的占位符由于句子是由id组成的向量,而id为int类型,
#所以定义x的传入数据为tf.int32类型,
#None表示可以传任意组X,seq_length表示词向量维度。
Y_holder = tf.placeholder(tf.float32,[None,num_classes])
#同理Y占位符向量维度为10,也即num_classes。
下面讲解下CNN的分类过程。
我们知道CNN通常用来做图像处理的,而在最开始图像输入的时候会有1到3个通道,1是黑白图像,3是彩色图像。
但与图像的通道不同的是,大多数NLP任务的输入是作为一个矩阵表示的句子或文档。矩阵的每一行对应一个token,通常是一个单词,但它可以是一个字符。也就是说,每一行都是表示一个单词的向量。通常,这些向量是单词嵌入(低维度表示),比如word2vec或GloVe,但它们也可以是一个one-hot的向量,将单词编入一个词汇表,但可想这个维度十分巨大,比如这里5000个词的词汇表每个单词的ont-hot就有5000维,空间要炸了:)。对于一个使用100维嵌入的10个单词的句子,我们将会有一个10*100的矩阵作为输入,这就相当于CNN做图像处理时的图像输入层,此处的100就是CNN中的通道数。
处理图像时,filter在图像的局部补丁上滑动,但是在NLP中,我们使用的filter要滑过矩阵每个单词的全行。因此,我们的filter的“宽度”通常与输入矩阵的宽度相同。高度或区域大小可能有所不同,但每次滑动窗口超过2-5个单词是正常的。把所有这些放在一起,一个用于NLP的卷积神经网络可能是这样的:
这里描述了三个filter区域的大小:2、3和4,每个都有2个filter。每个filter在句子矩阵上执行卷积并生成(变长)map。然后在每个map上执行1-max池。每个功能图中最大的数字被记录下来。因此,从所有6个映射中生成一个单变量特征向量,并将这6个特征连接起来,以形成倒数第二层的特征向量。最后的softmax层接收这个特征向量作为输入,并使用它对句子进行分类;这里我们假设二分类,因此描述了两个可能的输出状态。
这里引用论文做一个内容参插:
图像中相互靠近的像素可能在语义上是相关的(同一对象的一部分),但对于单词来说,情况不总是如此。在许多语言中,部分短语可以用其他几个词分开。在单词的组成方面也不是很明显。显然,单词在某种程度上的组成就像一个形容词修饰一个名词,但它是如何运作的,在更高层次上的表示意义并不像在计算机视觉当中那么明显。
但这不代表CNN不适合NLP,而只有RNN适合。事实证明,CNNs(注意这里的‘s’ :)应用于NLP问题的表现相当不错。
使用大量的词汇表,计算任何超过3-grams的句子都可能很慢。卷积的filter能自动地将句子表示得很好,而不需要代表整个词汇表。拥有大于5的filter是合理的,论文作者认为第一层中许多学习的filter捕获的特性与n-grams非常相似(但不受限制),但以更紧凑的方式表示它们。
注意一点,在CNN中进行池化时,通过执行max操作,可以保存关于该特性是否出现在句子中的信息,但是正在丢失关于它出现在哪里的信息。但是,这些关于地点的信息真的有用吗?是的,它有点类似于n-grams模型所做的事情。你正在失去关于位置的全球信息(在一个句子中发生了什么),但是你保留了由filters捕获的本地信息。
另外NLP中的关于CNN通道的解释:
在NLP中,你可以想象有各种各样的通道:你可以有一个单独的通道来处理不同的单词嵌入(例如,word2vec和FloVe),或者你可以有一个通道用不同的语言表示相同的句子,或者用不同的方式表达。
关于NLP中的CNNs能做什么?
对于CNNs来说,最自然的选择似乎是分类任务,比如情绪分析、垃圾邮件检测或主题分类。卷积和池操作丢失了关于本地单词顺序的信息,因此,纯CNN架构很难做序列标注或者实体抽取。
实现该分类值得注意的地方是:
所有的数据集在文档长度都非常相似,因此相同的指导原则可能不适用于看起来非常不同的数据。
需要说明的是:原论文用的是二维卷积核,一般取 (2,3,4), 而矩形的宽度是定长的, 同 word 的 embedding_size 相同. 每种尺寸都配有 NUM_FILTERS 个数目,而在这里使用的是一维卷积核,高度是1,,一层卷积,一层池化,以及一层全连阶层。所以此处做了改动。
参数初始化:
embedding_dim = 128 # 词向量维度
num_filters = 256 # 卷积核数目
kernel_size = 8 # 卷积核尺寸
hidden_dim = 128 # 全连接层神经元
dropout_keep_prob = 0.5 # dropout保留比例
learning_rate = 1e-3 # 学习率
batch_size = 64 # 每批训练大小
这里定义的embedding层的维度为128,也即表示了每个词的词向量维度为128.
embedding = tf.get_variable('embedding', [vocab_size, embedding_dim])
#embedding字典维度为5000*128,128为词向量维度
embedding_inputs = tf.nn.embedding_lookup(embedding, X_holder)
#embedding_inputs的维度为(batch_size)64*600*128
将embedding层的输出扔进卷积层,其中卷积核数定为256,大小定为8,高度恒为1,那么经过卷积层之后得到的样本维度为[64,593,256]。【(600-8)/1 +1=593】
再接一个池化层,在第一个维度去最大值,所以最后得到的维度为batch_size*256。
最后接一个全连阶层,其中tf.layers.dense()传入一个input,以及输出的维度,其他参数默认即可。此处输出维度为hidden_dim = 128。所以全连阶层之后输出为batch_size*128。
conv1 = tf.layers.conv1d(inputs=embedding_inputs,filters=num_filters,kernel_size=kernel_size)
max_pool = tf.reduce_max(conv1,reduction_indices=[1])
full_connect = tf.layers.dense(max_pool,hidden_dim)
在上面全连阶层输出的基础上再加上一个dropout来增加模型的泛化能力,防止过拟合,此处选择0.8的保留率。
注意dropout有个缺点是训练时间是没有dropout网络的2-3倍。
关于dropout其实就是在内部对每个神经元进行抛硬币选择抛弃或者屏蔽,实际所谓抛硬币就是生成一个和输入相同大小的0,1矩阵,然后让矩阵每个元素分别乘以输入矩阵每个元素,固然0会让神经元屏蔽,系数为1的神经元保留,然后让保留下来的神经元的值再除以keep_prob也即给定的保留概率,让他的值给放大。此做法主要原因是为了让输出结果期望不变。
Dropout可以看做是一种模型平均,把来自不同模型的估计或者预测通过一定的权重平均起来,在一些文献中也称为模型组合,它一般包括组合估计和组合预测。而所谓的不同模型,是由于每批次的训练锁忽略的神经元不同,所以得到的模型也不同。
因为dropout之后神经元里面的值被修改了,再次进入激活层,然后再经过一次全连阶层,让维度变为batch_size*num_classes(也即类别数)。
full_connect_dropout = tf.contrib.layers.dropout(full_connect,keep_prob=0.8)
full_connect_activate = tf.nn.relu(full_connect_dropout)
full_connect_last = tf.layers.dense(full_connect_activate,num_classes)
predict_y = tf.nn.softmax(full_connect_last)
接着定义损失函数,此处选择交叉熵损失函数,然后是用新版tensorflow的最新的tf.nn.softmax_cross_entropy_with_logits_v2(labels=Y_holder,logits=full_connect_last)来表示交叉熵,当然别忘了最后还要求均值才是损失函数,用tf.reduce_mean(cross_entry)表示。优化器选择Adam优化器,然后最小化误差即可。
cross_entry = tf.nn.softmax_cross_entropy_with_logits_v2(labels=Y_holder,logits=full_connect_last)
loss = tf.reduce_mean(cross_entry)
optimizer = tf.train.AdamOptimizer(learning_rate=learning_rate)
train = optimizer.minimize(loss)
接下来说明一下准确率,要计算准确率无非就是让预测结果和原标签值的比较,看预测为1的时候原标签值是否也为1,但因为这是一个one-hot后的值,要计算准确率一定要找到向量对应位置上的标签值是否相同。而由于ont-hot向量只有一个位置元素为1,其余为0,则按照列的维度上找到最大值(就是1)所在的位置并返回下标和原标签值1所在位置下标作比较,如果下标相同,则预测对了,否则为错。要满足这样一个功能,是用tf.argmax与tf.equal两个函数即可,比较他们下标,其中tf.argmax与np.argmax原理一样。
tf.argmax(input, axis=None, name=None, dimension=None) ,input表示输入,一般axis为1或者0表示维度,axis=1的时候,返回沿着横轴的所有x最大值下标,当axis=0的时候,返回沿着纵轴的所有y值的最大下标。
比较后的tf.equal()返回的是布尔值,让布尔值相加在求平均即为准确率。用tf.reduce_mean()。
correct = tf.equal(tf.argmax(Y_holder,1),tf.argmax(predict_y,1))
accuracy = tf.reduce_mean(tf.cast(correct,tf.float32))
变量初始化:
init = tf.global_variables_initializer()
sess = tf.Session()
sess.run(init)
训练开始:
导入random包,使用random.sample()打乱顺序,然后每训练一百次,把测试集扔进去做测试,输出结果。
import random
for i in range(5000):
train_index = random.sample(list(range(len(train_Y))),k=batch_size)
X = train_X[train_index]
Y = train_Y[train_index]
sess.run(train,feed_dict={X_holder:X,Y_holder:Y})
step = i + 1
if step % 100 == 0:
test_index = random.sample(list(range(len(test_Y))), k=200)
x = test_X[test_index]
y = test_Y[test_index]
loss_value, accuracy_value = sess.run([loss, accuracy], {X_holder:x, Y_holder:y})
print('step:%d loss:%.4f accuracy:%.4f' %(step, loss_value, accuracy_value))
输出结果如下:
(截取某10次结果)
step:2500 loss:0.1695 accuracy:0.9550
step:2600 loss:0.1230 accuracy:0.9700
step:2700 loss:0.2092 accuracy:0.9350
step:2800 loss:0.1260 accuracy:0.9550
step:2900 loss:0.0668 accuracy:0.9800
step:3000 loss:0.1783 accuracy:0.9600
step:3100 loss:0.2116 accuracy:0.9450
step:3200 loss:0.1571 accuracy:0.9700
step:3300 loss:0.2238 accuracy:0.9500
step:3400 loss:0.2115 accuracy:0.9550
当然光看准确率无法说明结果,还要靠混淆矩阵来进行分析:
import numpy as np
import pandas as pd
from sklearn.metrics import confusion_matrix
def predictAll(test_X, batch_size=100):
predict_value_list = []
for i in range(0, len(test_X), batch_size):
X = test_X[i: i + batch_size]
predict_value = sess.run(predict_y, {X_holder:X})
predict_value_list.extend(predict_value)
return np.array(predict_value_list)
Y = predictAll(test_X)
y = np.argmax(Y, axis=1)
predict_label_list = label.inverse_transform(y)
pd.DataFrame(confusion_matrix(test_label_list, predict_label_list),
columns=label.classes_,
index=label.classes_ )
结果如下:
import numpy as np
from sklearn.metrics import precision_recall_fscore_support
def eval_model(y_true, y_pred, labels):
# 计算每个分类的Precision, Recall, f1, support
p, r, f1, s = precision_recall_fscore_support(y_true, y_pred)
# 计算总体的平均Precision, Recall, f1, support
tot_p = np.average(p, weights=s)
tot_r = np.average(r, weights=s)
tot_f1 = np.average(f1, weights=s)
tot_s = np.sum(s)
res1 = pd.DataFrame({
u'Label': labels,
u'Precision': p,
u'Recall': r,
u'F1': f1,
u'Support': s
})
res2 = pd.DataFrame({
u'Label': ['总体'],
u'Precision': [tot_p],
u'Recall': [tot_r],
u'F1': [tot_f1],
u'Support': [tot_s]
})
res2.index = [999]
res = pd.concat([res1, res2])
return res[['Label', 'Precision', 'Recall', 'F1', 'Support']]
eval_model(test_label_list, predict_label_list, label.classes_)
结果如下:
可以从图中看出分类结果比较理想,F1值达到95.3%,并且召回率和准确率都达到了95.4%,整体分类效果较好。稍逊于原论文作者的96%,下次尝试完整复现论文。其中可知教育分类较一般,家具和时政分类结果较一般的好。如果需要提高分类准确率,可以通过调节dropout来实现,或者在词嵌入层提前用Word2vec训练好词向量,不用cnn反向传播更新词向量,也许效果会更好,当然也有可能教育类、家具类、时政类文章的长度比较短,而这里基于所有文章同样长度来做训练,导致了预测不佳的情况。所以本分类实现对文章的长度是有要求的。