转载:http://www.jianshu.com/p/ed3eac3dcb39?from=singlemessage
这篇博客是翻译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 。
你可以运行调试这个程序,去玩各种参数。点击这里查看完整代码。
扩展和练习
以下是一些可以帮助提高模型性能的方法:
作者:chen_h
链接:http://www.jianshu.com/p/ed3eac3dcb39
來源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。