作者:chen_h
微信号 & QQ:862251340
微信公众号:coderpai
这篇博客是翻译Denny Britz写的使用卷积神经网络做文本分类并且在Tensorflow上面实现,作者已经授权翻译,这是原文。
在这篇博客中,我们将实现一个类似于 Kim Yoon 论文中用于句子分类的卷积神经网络模型。论文中的模型在一系列文本分类任务(如情感分类)中获得了良好的分类性能,并成为新文本分类架构的标准基准。
在阅读本文之前,我假设你已经学习了基本的卷积神经网络在自然语言处理中的知识。如果还没有,那么我推荐你看这篇博客。
数据获取和准备
在本博客中,我们使用的数据集是 Movie Review data from Rotten Tomatoes ,这也是论文中使用的其中一个数据集。这个数据集包含 10662 个评论样本,其中一半是正向评论,一半是负向评论。这个数据集大约有2万个词。注意,因为这个数据集很小,所以如果我们使用很复杂的模型,那么容易造成过拟合。并且,这个数据没有帮我们分离训练数据集和测试数据集。因此,我们需要自己去预处理。在这里,我们把10%的数据作为交叉验证集。在原始的论文中,作者使用十折交叉验证(10-fold cross validation)。
在博客中,我不在讲数据的预处理过程,但是你可以在这里找到预处理的代码,该代码主要有以下几个功能:
- 从原始数据文件中,导入正样本和负样本数据。
- 数据清理,使用和论文中相同的代码。
- 将每个句子填充到最大句子长度,也就是数据集中最长的那个句子的长度,这里是59。我们填充的特殊标记是
<PAD>
,将句子填充到相同长度是非常有用的,因为它能帮助我们进行有效的批处理,因为在批处理中的每个例子都必须有相同的长度。 - 构建词汇索引表,将每个单词映射到 0 ~ 18765 之间(18765是词汇量大小),那么每个句子就变成了一个整数的向量。
模型
在博客中,我们所要构建的模型如下图:
第一层网络将词向量嵌入到一个低维的向量中。下一层网络就是利用多个卷积核在前一层网络上进行卷积操作。比如,每次滑动3个,4个或者5个单词。第三层网络是一个max-pool层,从而得到一个长向量,并且添加上 dropout 正则项。最后,我们使用softmax函数对进行分类。
因为,这篇博客的目的是为了学习这个架构,所有我们对原论文中的模型进行了一些简化操作,如下:
- 我们不使用 word2vec 作为我们的初始词向量,而是从原始数据开始开始训练我们的词向量。
- 我们不会对权重进行L2范数约束,虽然在论文A Sensitivity Analysis of (and Practiioners’ Guide to) Convolutional Neural Networks for Sentence Classification中发现,L2范数可以提高一点点的准确率。
- 在原始的论文中,实验将输入数据转换到了两个通道(two input channels)上面,一个是确定的词向量,另一个不可以调整的词向量。我们只使用一个通道。
如果要将上面省略的操作添加到代码中也是非常简单的(几十行代码)。你可以看看博客最后的扩展和练习吧。
那么让我们开始吧。
实现
为了允许各种的超参数配置,我们把我们的代码放到一个TextCNN类中,并且在 init
函数中生成模型图。
import tensorflow as tf
import numpy as np
class TextCNN(object):
"""
A CNN for text classification.
Uses an embedding layer, followed by a convolutional, max-pooling and softmax layer.
"""
def __init__(
self, sequence_length, num_classes, vocab_size,
embedding_size, filter_sizes, num_filters, l2_reg_lambda=0.0):
# Implementation ...
为了实例化类,我们需要传递以下参数到类中:
- sequence_length - 句子的长度。请注意,我们通过添加特殊标记,使得所欲的句子都拥有了相同的长度(我们的数据集是59)。
- num_classes - 最后一层分类的数目,在这里我们是进行二分类(正向评论和负向评论)。
- vocab_size - 词汇量的大小。这个参数是为了确定我们词向量嵌入层的大小,最终的总词向量维度是
[vocabulary_size, embedding_size]
。 - embeddign_size - 每个单词的词向量的长度。
- filter_sizes - 这个参数确定我们希望我们的卷积核每次覆盖几个单词。对于每个卷积核,我们都将有
num_filters
个。比如,filter_sizes = [3, 4, 5] , 这就意味着,卷积核一共有三种类型,分别是每次覆盖3个单词的卷积核,每次覆盖4个单词的卷积核和每次覆盖5个单词的卷积核。卷积核一共的数量是3 * num_filters
个。 - num_filters - 每个卷积核的数量(参考 filter_sizes 参数的介绍)。
输入占位符
我们首先定义需要输入到模型中的数据。
# Placeholders for input, output and dropout
self.input_x = tf.placeholder(tf.int32, [None, sequence_length], name="input_x")
self.input_y = tf.placeholder(tf.float32, [None, num_classes], name="input_y")
self.dropout_keep_prob = tf.placeholder(tf.float32, name="dropout_keep_prob")
tf.placeholder
创建了一个占位符变量,当我们在训练阶段或者测试阶段时,都可以使用它向我们的模型输入数据。第二个参数是输入张量的形状。None
的意思是,该维度的长度可以是任何值。在我们的模型中,第一个维度是批处理大小,而使用 None
来表示这个值,说明网络允许处理任意大小的批次。
在 dropout 层中,我们使用 dropout_keep_prob
参数来控制神经元的激活程度。但这个参数,我们只在训练的时候开启,在测试的时候禁止它。(后续文章会深入介绍)
嵌入层
我们定义的第一个网络层是嵌入层,这一层的作用是将词汇索引映射到低维度的词向量进行表示。它本质是一个我们从数据中学习得到的词汇向量表。
with tf.device('/cpu:0'), tf.name_scope("embedding"):
W = tf.Variable(
tf.random_uniform([vocab_size, embedding_size], -1.0, 1.0),
name="W")
self.embedded_chars = tf.nn.embedding_lookup(W, self.input_x)
self.embedded_chars_expanded = tf.expand_dims(self.embedded_chars, -1)
在这里,我们又使用了一些新功能,让我们来学习一下它们:
tf.device("/cpu:0")
强制代码在CPU上面执行操作。因为默认情况下,TensorFlow会尝试将操作放在GPU上面进行运行(如果存在GPU),但是嵌入层的操作目前还不支持GPU运行,所以如果你不指定CPU进行运行,那么程序会报错。tf.name_scope
创建了一个称之为”embedding”的新的名称范围,该范围将所有的操作都添加到这个”embedding”节点下面。以便在TensorBoard中获得良好的层次结构,有利于可视化。
W
是我们的嵌入矩阵,这个矩阵是我们从数据训练过程中得到的。最开始,我们使用一个随机均匀分布来进行初始化。tf.nn.embedding_lookup
创建实际的嵌入读取操作,这个嵌入操作返回的数据维度是三维张量 [None, sequence_length, embedding_size]
。
TensorFlow 的卷积操作 conv2d
需要一个四维的输入数据,对应的维度分别是批处理大小,宽度,高度和通道数。在我们嵌入层得到的数据中不包含通道数,所以我们需要手动添加它,所以最终的数据维度是 [None, sequence_length, embedding_size, 1]
。
卷积层和池化层
现在我们可以构建我们的卷积层和池化层了。请记住,我们使用的卷积核是不同尺寸的。因为每个卷积核经过卷积操作之后产生的张量是不同维度的,所有我们需要为每一个卷积核创建一层网络,最后再把这些卷积之后的觉果合并成一个大的特征向量。
pooled_outputs = []
for i, filter_size in enumerate(filter_sizes):
with tf.name_scope("conv-maxpool-%s" % filter_size):
# Convolution Layer
filter_shape = [filter_size, embedding_size, 1, num_filters]
W = tf.Variable(tf.truncated_normal(filter_shape, stddev=0.1), name="W")
b = tf.Variable(tf.constant(0.1, shape=[num_filters]), name="b")
conv = tf.nn.conv2d(
self.embedded_chars_expanded,
W,
strides=[1, 1, 1, 1],
padding="VALID",
name="conv")
# Apply nonlinearity
h = tf.nn.relu(tf.nn.bias_add(conv, b), name="relu")
# Max-pooling over the outputs
pooled = tf.nn.max_pool(
h,
ksize=[1, sequence_length - filter_size + 1, 1, 1],
strides=[1, 1, 1, 1],
padding='VALID',
name="pool")
pooled_outputs.append(pooled)
# Combine all the pooled features
num_filters_total = num_filters * len(filter_sizes)
self.h_pool = tf.concat(3, pooled_outputs)
self.h_pool_flat = tf.reshape(self.h_pool, [-1, num_filters_total])
代码中,W
表示不同的卷积核,h
表示对经过卷积得到的输出结果进行非线性处理之后的结果。每个卷积核会覆盖整个词向量长度,但是滑动覆盖几个单词就是不同的了。VALID
填充意味着,我们的卷积核只在我们的单词上面滑动,而不填充边缘,是执行窄卷积,所有最后输出的维度是 [1, sequence_length - filter_size + 1, 1, 1]
。对经过特定卷积的输出,我们做最大池化操作,使得我们得到的张量维度是 [batch_size, 1, 1, num_filters]
。这实质上就是一个特征向量,其中最后一个维度就是对应于我们的特征。一旦我们拥有了来自各个卷积核的输出向量,那么我们就可以把它们合并成一个长的特征向量,该向量的维度是 [batch_size, num_filters_total]
。在 tf.reshape
中使用 -1,就是告诉 TensorFlow 在可能的情况下,将维度进行展平。
上面部分最好花点时间看明白,去弄明白每个操作输出的维度是什么。如果你不是很了解,也可以再去参考这篇博客 Understanding Convolutional Neural Networks for NLP,获得一些灵感。下图是TensorBoard可视化的结果,你可以发现三个卷积核组成了三个不同的网络层。
Dropout层
Dropout 也许是最流行的方法来正则化卷积神经网络。Dropout 的思想非常简单,就是按照一定的概率来“禁用”一些神经元的发放。这种方法可以防止神经元共同适应一个特征,而迫使它们单独学习有用的特征。神经元激活的概率,我们从参数 dropout_keep_prob
中得到。我们在训练阶段将其设置为 0.5,在测试阶段将其设置为 1.0(即所有神经元都被激活)。
# Add dropout
with tf.name_scope("dropout"):
self.h_drop = tf.nn.dropout(self.h_pool_flat, self.dropout_keep_prob)
分数和预测
我们使用来自池化层的特征向量(经过Dropout),然后通过全连接层,得到一个分数最高的类别。我们还可以应用softmax函数来将原始分数转换成归一化概率,但这个操作是保护会改变我们的最终预测。
with tf.name_scope("output"):
W = tf.Variable(tf.truncated_normal([num_filters_total, num_classes], stddev=0.1), name="W")
b = tf.Variable(tf.constant(0.1, shape=[num_classes]), name="b")
self.scores = tf.nn.xw_plus_b(self.h_drop, W, b, name="scores")
self.predictions = tf.argmax(self.scores, 1, name="predictions")
上面代码中,tf.nn.xw_plus_b
是一个很方便的函数,实现 Wx + b
操作。
损失函数和正确率
使用我们上面求得的分数,我们可以定义损失函数。损失值是对模型所造成的误差的度量,我们的目标是最小化这个损失值。分类问题的标准损失函数是交叉熵损失函数。
# Calculate mean cross-entropy loss
with tf.name_scope("loss"):
losses = tf.nn.softmax_cross_entropy_with_logits(self.scores, self.input_y)
self.loss = tf.reduce_mean(losses)
这里,tf.nn.softmax_cross_entropy_with_logits
是一个方便的函数,用来计算每个类别的交叉损失熵,对于我们给定的分数和输入的正确标签。然后,我们计算损失值的平均值。当然,我们也可以对它们进行求和,但是这会对不同批大小的损失值衡量非常困难,尤其是在训练阶段和测试阶段。
我们还定义了一个正确率的函数,它的作用就是在训练阶段和测试阶段来跟踪模型的性能。
# Calculate Accuracy
with tf.name_scope("accuracy"):
correct_predictions = tf.equal(self.predictions, tf.argmax(self.input_y, 1))
self.accuracy = tf.reduce_mean(tf.cast(correct_predictions, "float"), name="accuracy")
可视化网络
就这样,我们完成了网络的定义。完整代码可以点击这里。在TensorBoard中我们可以看到以下的大图。
训练过程
在我们编写我们网络的训练过程之前,我们需要先了解一下TensorFlow中的会话(Session)和图(Graph)的概念。如果你已经对这些概念很熟悉了,那么可以跳过这个部分。
在TensorFlow中,会话是一个图执行的环境(也就是说,图必须在会话中被启动),它包含有关的变量和队列状态。每个会话执行一个单一的图。如果你在创建变量和操作时,没有明确地使用一个会话,那么TensorFlow会创建一个当前默认会话。你可以通过在 session.as_default()
中来修改默认会话(如下)。
图(Graph)中包含各种操作和张量。你可以在程序中使用多个图,但是大多数程序都只需要一个图。你可以把一张图在多个会话中使用,但是不能在一个会话中使用多个图。TensorFlow总是会创建一个默认图,但是你也可以自己手动创建一个图,并且把它设置为默认图,就像我们下面所写的一样。显示的创建会话和图可以确保在不需要它们的时候,正确的释放资源。这是一个很好的习惯。
with tf.Graph().as_default():
session_conf = tf.ConfigProto(
allow_soft_placement=FLAGS.allow_soft_placement,
log_device_placement=FLAGS.log_device_placement)
sess = tf.Session(config=session_conf)
with sess.as_default():
# Code that operates on the default graph and session comes here...
allow_soft_placement
参数的设置,允许 TensorFlow 回退到特定操作的设备,如果在优先设备不存在时。比如,如果我们的代码是运行在一个GPU上面的,但是我们的代码在一个没有GPU的机器上运行了。那么,如果不使用 allow_soft_placement
参数,程序就会报错。如果设置了 log_device_placement
参数,TensorFlow 会记录它运行操作的设备(CPU或者GPU)。这对调试程序非常有用,FLAGS 是我们程序的命令行参数。
实现卷积神经网络和损失函数最小化
当我们实例化我们的 TextCNN 模型时,所有定义的变量和操作都将被放入我们创建的默认图和会话中。
cnn = TextCNN(
sequence_length=x_train.shape[1],
num_classes=2,
vocab_size=len(vocabulary),
embedding_size=FLAGS.embedding_dim,
filter_sizes=map(int, FLAGS.filter_sizes.split(",")),
num_filters=FLAGS.num_filters)
接下来,我们定义如何去最优化我们网络的损失函数。TensorFlow有很多内嵌的优化函数。在这里,我们使用Adam优化器。
global_step = tf.Variable(0, name="global_step", trainable=False)
optimizer = tf.train.AdamOptimizer(1e-4)
grads_and_vars = optimizer.compute_gradients(cnn.loss)
train_op = optimizer.apply_gradients(grads_and_vars, global_step=global_step)
在上述代码中,trian_op
是一个新创建的操作,我们可以运行它来对我们的参数进行梯度更新。每次执行 train_op
操作,就是一个训练步骤。TensorFlow 会自动计算出哪些变量是“可训练”的,并计算它们的梯度。通过定义 global_step
变量并将它传递给优化器,我们允许TensorFlow处理我们的训练步骤。我们每次执行 train_op
操作时,global_step
都会自动递增1。
汇总
TensorFlow有一个汇总的概念,它允许你在训练和评估阶段来跟踪和可视化各种参数。比如,你可能想要去跟踪在各个训练和评估阶段,损失值和正确值是如何变化的。当然,你还可以跟踪更加复杂的数据。例如,图层激活的直方图。汇总是一个序列化对象,我们可以使用 SummaryWriter 函数来将它们写入磁盘。
# Output directory for models and summaries
timestamp = str(int(time.time()))
out_dir = os.path.abspath(os.path.join(os.path.curdir, "runs", timestamp))
print("Writing to {}\n".format(out_dir))
# Summaries for loss and accuracy
loss_summary = tf.scalar_summary("loss", cnn.loss)
acc_summary = tf.scalar_summary("accuracy", cnn.accuracy)
# Train Summaries
train_summary_op = tf.merge_summary([loss_summary, acc_summary])
train_summary_dir = os.path.join(out_dir, "summaries", "train")
train_summary_writer = tf.train.SummaryWriter(train_summary_dir, sess.graph_def)
# Dev summaries
dev_summary_op = tf.merge_summary([loss_summary, acc_summary])
dev_summary_dir = os.path.join(out_dir, "summaries", "dev")
dev_summary_writer = tf.train.SummaryWriter(dev_summary_dir, sess.graph_def)
在这里,我们独立的去处理训练阶段和评估阶段的汇总。在我们的例子中,在训练阶段和评估阶段,我们记录的汇总数据都是一样的。但是,你可能会有一些汇总数据是只想在训练阶段进行记录的(比如,参数更新值)。tf.merge_summary
是一个方便的函数,它可以将多个汇总操作合并到一个我们可以执行的单个操作中。
检查点
在TensorFlow中,另一个你通常想要的功能是检查点(checkpointing)——保存模型的参数以备以后恢复。检查点可用于在以后的继续训练,或者提前来终止训练,从而能来选择最佳参数。检查点是使用 Saver
对象来创建的。
# Checkpointing
checkpoint_dir = os.path.abspath(os.path.join(out_dir, "checkpoints"))
checkpoint_prefix = os.path.join(checkpoint_dir, "model")
# Tensorflow assumes this directory already exists so we need to create it
if not os.path.exists(checkpoint_dir):
os.makedirs(checkpoint_dir)
saver = tf.train.Saver(tf.all_variables())
初始化变量
在我们训练我们的模型之前,我们还需要去初始化图中的所有变量。
sess.run(tf.initialize_all_variables())
initialize_all_variables
函数是一个方便的函数,它能帮助我们去初始化所有的变量。当然你也能手动初始化你自己的参数。手动初始化是非常有用的,比如你想要去初始化你的词向量(嵌入层),用与训练好的词向量模型。
定义单个训练步骤
现在我们定义一个训练函数,用于单个训练步骤,在一批数据上进行评估,并且更新模型参数。
def train_step(x_batch, y_batch):
"""
A single training step
"""
feed_dict = {
cnn.input_x: x_batch,
cnn.input_y: y_batch,
cnn.dropout_keep_prob: FLAGS.dropout_keep_prob
}
_, step, summaries, loss, accuracy = sess.run(
[train_op, global_step, train_summary_op, cnn.loss, cnn.accuracy],
feed_dict)
time_str = datetime.datetime.now().isoformat()
print("{}: step {}, loss {:g}, acc {:g}".format(time_str, step, loss, accuracy))
train_summary_writer.add_summary(summaries, step)
feed_dict
包含了我们需要传入到网络中的数据。你必须为所有的占位符节点提供值,否则TensorFlow会报错。另一种输入数据的方式是使用队列,但这种方法超出了本次的范围,所以我们先不讨论这种方法。
接下来,我们使用 session.run
来执行我们的 train_op
,它会返回我们要求它评估的所有操作的值。注意,train_op
不返回任何东西,它只是更新我们的网络参数。最后,我们打印当前训练的损失值和正确值,并且把汇总结果保存到磁盘。请注意,如果批处理规模很小,那么损失值和模型正确值可能在不同批次之间会有很大的不同。因为我们使用了 Dropout ,所以我们的训练真确率可能会比测试正确率低一点。
我们写了一个相似的函数来评估任意数据集的损失值和真确率,比如在交叉验证数据集和整个训练集上面。本质上,这个函数和上面的函数是相同的,但是没有训练操作,它也禁用了 Dropout 。
def dev_step(x_batch, y_batch, writer=None):
"""
Evaluates model on a dev set
"""
feed_dict = {
cnn.input_x: x_batch,
cnn.input_y: y_batch,
cnn.dropout_keep_prob: 1.0
}
step, summaries, loss, accuracy = sess.run(
[global_step, dev_summary_op, cnn.loss, cnn.accuracy],
feed_dict)
time_str = datetime.datetime.now().isoformat()
print("{}: step {}, loss {:g}, acc {:g}".format(time_str, step, loss, accuracy))
if writer:
writer.add_summary(summaries, step)
循环训练
最后,我们准备去写完整的训练过程。我们对数据集进行批次迭代操作,为每个批处理调用一次 train_step
函数,偶尔去评估一下我们的训练模型。
# Generate batches
batches = data_helpers.batch_iter(
zip(x_train, y_train), FLAGS.batch_size, FLAGS.num_epochs)
# Training loop. For each batch...
for batch in batches:
x_batch, y_batch = zip(*batch)
train_step(x_batch, y_batch)
current_step = tf.train.global_step(sess, global_step)
if current_step % FLAGS.evaluate_every == 0:
print("\nEvaluation:")
dev_step(x_dev, y_dev, writer=dev_summary_writer)
print("")
if current_step % FLAGS.checkpoint_every == 0:
path = saver.save(sess, checkpoint_prefix, global_step=current_step)
print("Saved model checkpoint to {}\n".format(path))
这里,batch_iter
是一个我批处理数据的帮助函数,tr.train.global_step
是一个方便函数,它返回 global_step
的值。点击此处可以查看完整代码。
在 TensorBoard 中查看可视化结果
我们的训练脚本将汇总结果写入到输出目录,通过使用 TensorBoard 指向该目录,我们就可以可视化创建的图形和摘要。
tensorboard --logdir /PATH_TO_CODE/runs/1449760558/summaries/
使用默认参数训练我们的模型(128-dimensional embeddings, filter sizes of 3, 4 and 5, dropout of 0.5 and 128 filters per filter size) ,那么我们可以得到如下图(蓝色是训练数据,红色是10%的交叉验证数据):
这里有一些需要指出的点:
- 我们训练的指标不是那么平滑,因为我们使用的批处理太小了。如果我们使用一个比较大的批处理(或者在整个训练集上面进行评估),那么我们将得到一个更加平滑的蓝线。
- 交叉测试集的正确率显著低于训练集的正确率,这可能是因为网络在训练数据集上面过拟合了,也就是说我们需要更多的数据(MR数据集非常小),更强的正则化或者更小的模型参数。比如,我在最后一层的权重上面添加 L2 惩罚项,那么正确率就能达到76%,接近于原始论文中的数据。
- 训练阶段的损失值和正确率显著低于交叉验证时,这是因为我们使用了 Dropout 。
你可以运行调试这个程序,去玩各种参数。点击这里查看完整代码。
扩展和练习
以下是一些可以帮助提高模型性能的方法: