我们知道,卷积神经网络(CNN)主要是在计算机视觉方面已经取得了很多很好的成就,但是,CNN在自然语言处理方面同样也可以拥有很好的应用。本文将介绍一个有关CNN的模型,用来对文本进行分类,并将它应用在文本分类的热门任务——情感分析上,模型的名称叫TextCNN,模型的论文地址如下:
下面将对该模型的原理进行具体介绍,并用tensorflow来实现它。
假设对于每一个句子都进行padding,使得句子的长度都为,对于太长的句子则进行截断,则每个句子可以表示为:
其中,表示句子中的第个词汇,其词向量的维度为,表示将每个词向量进行拼接,表示词汇串的拼接,如图1中左侧所示,这样一来,每个句子都可以表示成一个的二维矩阵。
接着,对于卷积操作,TextCNN的每一个卷积核的宽度都选择与词向量的维度一样大小,而高度则可以是变动的,如卷积核,其高度为,宽度为,该卷积核的每一次卷积操作将对个词汇的词向量进行特征提取,记第步提取后的特征值为,则的计算公式如下:
其中,表示偏置项,则表示一个非线性函数,比如等。因此,当卷积核的从上到下对句子的特征矩阵进行滑动时,如果时间步为1,则每一步卷积对应的词汇串分别为,最终卷积结束后将得到一个长度为的特征向量:
接着,对卷积核得到的特征向量进行池化操作,作者选择的是最大池化操作max-pooling,因为,max-pooling从卷积后得到的特征向量中提取最大的值,其实也起点一个类似注意力的机制,使得每个卷积核可以关注于句子中一些比较重要的词汇。记池化操作得到的值为:
通过前面的介绍我们可以发现,对于一个卷积核,不管卷积核的高度选取多少,最终对每个句子的卷积操作将得到一个特征值, 因此,TextCNN会设置多个不同高度的卷积核,每个卷积核都会对句子的特征矩阵进行卷积,最终经过池化操作得到一个特征值,然后将每个卷积核得到的特征值进行拼接,这样就可以对每个句子得到一个统一维度的向量表示,向量的长度大小即为卷积核的数量大小,如图1中第三部分所示,此时句子向量可以表示为:
接着,将卷积后得到的句子向量传入一个全连接层,在该层,作者引入了dropout操作来防止过拟合,其计算公式如下:
其中,表示一个元素乘积操作,即内积,为一个长度为的mask向量,其中,每个元素为1的概率为,简单来讲就是一个伯努利分布。另外,作者对也添加了一个的正则化操作,最后,将全连接层计算的结果再接一个softmax层,即可得到句子在每个类别中的概率分布。
以上就是TextCNN的模型结构,我们可以发现其实模型并没有什么新奇之处,主要是对卷积核的宽度进行了限制,保证与词向量的维度一致,另外,采用多种高度的卷积核同时进行卷积操作,获得多通道的输出,其他的基本没什么变动。CNN由于通过调整卷积核的高度,可以使得每个特征是对个词汇的抽象,就类似与ngram一样,因此,在一定程度上保留了词汇之间的时序信息。
接下来,本文通过tensorflow框架来实现TextCNN模型,并将其应用在情感分析任务上,有关实验的数据集可以参考前面的文章《FastText文本分类与tensorflow实现》,TextCNN的模型代码如下:
import os
import numpy as np
import tensorflow as tf
from eval.evaluate import accuracy
from tensorflow.contrib import slim
from loss.loss import cross_entropy_loss
class TextCNN(object):
def __init__(self,
num_classes,
seq_length,
vocab_size,
embedding_dim,
learning_rate,
learning_decay_rate,
learning_decay_steps,
epoch,
filter_sizes,
num_filters,
dropout_keep_prob,
l2_lambda
):
self.num_classes = num_classes
self.seq_length = seq_length
self.vocab_size = vocab_size
self.embedding_dim = embedding_dim
self.learning_rate = learning_rate
self.learning_decay_rate = learning_decay_rate
self.learning_decay_steps = learning_decay_steps
self.epoch = epoch
self.filter_sizes = filter_sizes
self.num_filters = num_filters
self.dropout_keep_prob = dropout_keep_prob
self.l2_lambda = l2_lambda
self.input_x = tf.placeholder(tf.int32, [None, self.seq_length], name='input_x')
self.input_y = tf.placeholder(tf.float32, [None, self.num_classes], name='input_y')
self.l2_loss = tf.constant(0.0)
self.model()
def model(self):
# embedding层
with tf.name_scope("embedding"):
self.embedding= tf.Variable(tf.random_uniform([self.vocab_size, self.embedding_dim], -1.0, 1.0),
name="embedding")
self.embedding_inputs = tf.nn.embedding_lookup(self.embedding,self.input_x)
self.embedding_inputs = tf.expand_dims(self.embedding_inputs,-1)
# 卷积层 + 池化层
pooled_outputs = []
for i, filter_size in enumerate(self.filter_sizes):
with tf.name_scope("conv_{0}".format(filter_size)):
filter_shape = [filter_size, self.embedding_dim, 1, self.num_filters]
W = tf.Variable(tf.truncated_normal(filter_shape, stddev=0.1), name="W")
b = tf.Variable(tf.constant(0.1, shape=[self.num_filters]), name="b")
conv = tf.nn.conv2d(
self.embedding_inputs,
W,
strides=[1, 1, 1, 1],
padding="VALID",
name="conv"
)
h = tf.nn.relu(tf.nn.bias_add(conv, b), name="relu")
pooled = tf.nn.max_pool(
h,
ksize=[1, self.seq_length - filter_size + 1, 1, 1],
strides=[1, 1, 1, 1],
padding='VALID',
name="pool"
)
pooled_outputs.append(pooled)
# 将每种尺寸的卷积核得到的特征向量进行拼接
num_filters_total = self.num_filters * len(self.filter_sizes)
h_pool = tf.concat(pooled_outputs, 3)
h_pool_flat = tf.reshape(h_pool, [-1, num_filters_total])
# 对最终得到的句子向量进行dropout
with tf.name_scope("dropout"):
h_drop = tf.nn.dropout(h_pool_flat, self.dropout_keep_prob)
# 全连接层
with tf.name_scope("output"):
W = tf.get_variable("W",shape=[num_filters_total, self.num_classes],
initializer=tf.contrib.layers.xavier_initializer())
b = tf.Variable(tf.constant(0.1, shape=[self.num_classes]), name="b")
self.l2_loss += tf.nn.l2_loss(W)
self.l2_loss += tf.nn.l2_loss(b)
self.logits = tf.nn.xw_plus_b(h_drop, W, b, name="scores")
self.pred = tf.argmax(self.logits, 1, name="predictions")
# 损失函数
self.loss = cross_entropy_loss(logits=self.logits, labels=self.input_y) + self.l2_lambda*self.l2_loss
# 优化函数
self.global_step = tf.train.get_or_create_global_step()
learning_rate = tf.train.exponential_decay(self.learning_rate, self.global_step,
self.learning_decay_steps, self.learning_decay_rate,
staircase=True)
optimizer = tf.train.AdamOptimizer(learning_rate)
update_ops = tf.get_collection(tf.GraphKeys.UPDATE_OPS)
self.optim = slim.learning.create_train_op(total_loss=self.loss, optimizer=optimizer, update_ops=update_ops)
# 准确率
self.acc = accuracy(logits=self.logits, labels=self.input_y)
def fit(self,train_x,train_y,val_x,val_y,batch_size):
# 创建模型保存路径
if not os.path.exists('./saves/textcnn'): os.makedirs('./saves/textcnn')
if not os.path.exists('./train_logs/textcnn'): os.makedirs('./train_logs/textcnn')
# 开始训练
train_steps = 0
best_val_acc = 0
# summary
tf.summary.scalar('val_loss', self.loss)
tf.summary.scalar('val_acc', self.acc)
merged = tf.summary.merge_all()
# 初始化变量
sess = tf.Session()
writer = tf.summary.FileWriter('./train_logs/textcnn', sess.graph)
saver = tf.train.Saver(max_to_keep=10)
sess.run(tf.global_variables_initializer())
for i in range(self.epoch):
batch_train = self.batch_iter(train_x, train_y, batch_size)
for batch_x,batch_y in batch_train:
train_steps += 1
feed_dict = {self.input_x:batch_x,self.input_y:batch_y}
_, train_loss, train_acc = sess.run([self.optim,self.loss,self.acc],feed_dict=feed_dict)
if train_steps % 1000 == 0:
feed_dict = {self.input_x:val_x,self.input_y:val_y}
val_loss,val_acc = sess.run([self.loss,self.acc],feed_dict=feed_dict)
summary = sess.run(merged,feed_dict=feed_dict)
writer.add_summary(summary, global_step=train_steps)
if val_acc>=best_val_acc:
best_val_acc = val_acc
saver.save(sess, "./saves/textcnn/", global_step=train_steps)
msg = 'epoch:%d/%d,train_steps:%d,train_loss:%.4f,train_acc:%.4f,val_loss:%.4f,val_acc:%.4f'
print(msg % (i,self.epoch,train_steps,train_loss,train_acc,val_loss,val_acc))
sess.close()
def batch_iter(self, x, y, batch_size=32, shuffle=True):
"""
生成batch数据
:param x: 训练集特征变量
:param y: 训练集标签
:param batch_size: 每个batch的大小
:param shuffle: 是否在每个epoch时打乱数据
:return:
"""
data_len = len(x)
num_batch = int((data_len - 1) / batch_size) + 1
if shuffle:
shuffle_indices = np.random.permutation(np.arange(data_len))
x_shuffle = x[shuffle_indices]
y_shuffle = y[shuffle_indices]
else:
x_shuffle = x
y_shuffle = y
for i in range(num_batch):
start_index = i * batch_size
end_index = min((i + 1) * batch_size, data_len)
yield (x_shuffle[start_index:end_index], y_shuffle[start_index:end_index])
def predict(self,x):
sess = tf.Session()
sess.run(tf.global_variables_initializer())
saver = tf.train.Saver(tf.global_variables())
ckpt = tf.train.get_checkpoint_state('./saves/textcnn/')
saver.restore(sess, ckpt.model_checkpoint_path)
feed_dict = {self.input_x: x}
logits = sess.run(self.logits, feed_dict=feed_dict)
y_pred = np.argmax(logits, 1)
return y_pred
数据集方面的参数设置与 《FastText文本分类与tensorflow实现》中的一样,然后TextCNN的卷积核高度设置三种尺度,分别为3,4,5,每种尺度的卷积核数量均为100,另外,L2惩罚系数设置为3,最终模型的训练效果如图2所示,在3000个测试集上的预测准确率达到了97.56%,比之前的FastText大约高了2个百分点。
最后,大致总结一下TextCNN的优缺点: