利用TensorFlow实现卷积神经网络做文本分类

来源:互联网 发布:淘宝店主自拍技巧 编辑:程序博客网 时间:2024/06/05 01:20

转载: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)。

在博客中,我不在讲数据的预处理过程,但是你可以在这里找到预处理的代码,该代码主要有以下几个功能:

  1. 从原始数据文件中,导入正样本和负样本数据。
  2. 数据清理,使用和论文中相同的代码。
  3. 将每个句子填充到最大句子长度,也就是数据集中最长的那个句子的长度,这里是59。我们填充的特殊标记是 <PAD> ,将句子填充到相同长度是非常有用的,因为它能帮助我们进行有效的批处理,因为在批处理中的每个例子都必须有相同的长度。
  4. 构建词汇索引表,将每个单词映射到 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 tfimport numpy as npclass 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 dropoutself.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 featuresnum_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 dropoutwith 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 losswith 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 Accuracywith 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 summariestimestamp = 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 accuracyloss_summary = tf.scalar_summary("loss", cnn.loss)acc_summary = tf.scalar_summary("accuracy", cnn.accuracy)# Train Summariestrain_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 summariesdev_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 对象来创建的。

# Checkpointingcheckpoint_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 itif 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 batchesbatches = 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 。

你可以运行调试这个程序,去玩各种参数。点击这里查看完整代码。

扩展和练习

以下是一些可以帮助提高模型性能的方法:

  • 词向量用 word2vec 进行初始化。如果你要让这种方法有效,那么需要使用 300 维的词向量来进行模型的初始化工作。
  • 使用L2范数对最后一层中的权重进行约束,就像原始论文中的一样。你也可以通过定义一个新的操作来更新每次训练之后的权重值。
  • 添加L2正则项到网络中,以防止过拟合,也可以尝试增加 dropout 系数。(我的代码中已经实现了L2正则项,但是默认情况下是禁用的。)
  • 将权重更新和网络层操作的结果都保存起来,然后在 TensorBoard 中进行可视化。


作者:chen_h
链接:http://www.jianshu.com/p/ed3eac3dcb39
來源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

阅读全文
0 0
原创粉丝点击