【TensorFlow小记】CNN英文文本分类

学习GitHub上一个不错的CNN英文文本分类项目,代码解读。

一、环境

  • 开发环境
    • Windows
  • Python版本
    • Python 3.5.4
  • pip包
    • tensorflow==1.5.0
    • numpy==1.16.3

二、项目介绍

  因为工作需要,要用CNN实现文本分类,但机器学习这块还不能算入门,仅是利用零碎时间做了了解。于是在GitHub上找到了cnn-text-classification-tf项目,一边解读一边学习吧。
  PS:这个项目的作者很酷,注意看关键词“High-shool dropout”,而且在GitHub上有多个不错的机器学习项目,学习能力和知识量可见一斑。当然了,国内也有很多没有学校光环的技术大牛,这些都是题外话了。
github_dennybritz.png

  关于这个项目,其实Implementing a CNN for Text Classification in TensorFlow这篇blog已经写的很详细了,但是它是英文的,而且对于刚入手tensorflow的新人来说代码可能仍存在一些细节不太容易理解,我也是初学,就简单总结下自己的理解,如果对读者有帮助那将是极好的。

  首先看这个项目,共有四个PY文件,分别是:
  • text_cnn.py:网络结构设计
  • train.py:网络训练
  • eval.py:预测&评估
  • data_helpers.py:数据预处理

  下面对最核心的部分,即CNN模型进行解读,具体代码看这里

三、模型原理

  在text_cnn.py中,主要定义了一个类 TextCNN
  这个类搭建了一个最basic的CNN模型,有 input layer,convolutional layer,max-pooling layer 和最后输出的 softmax layer。
  但是又因为整个模型是用于文本的(而非CNN的传统处理对象:图像),因此在CNN的操作上相对应地做了一些小调整:

  • 对于文本任务,输入层自然使用了word embedding来将文本转换成词向量。
  • 接下来是卷积层,在图像处理中经常看到的卷积核都是正方形的,比如4*4矩阵,然后在整张image上沿宽和高逐步移动进行卷积操作。但是在NLP中输入的“image”是一个词矩阵,比如n个words,每个word用200维(embedding_size)的 vector(矢量/一维数组)表示的话,这个“image”就是n*200的矩阵,卷积核只在高度上滑动,在宽度上和 word vector(word embedding) 的维度一致(=200),也就是说每次窗口滑动过的位置都是完整的单词,不会将几个单词的一部分“vector”进行卷积,这也保证了word作为语言中最小粒度的合理性。(当然,如果研究的粒度是character-level而不是word-level,需要另外的方式处理)
  • 由于卷积核和 word embedding 的宽度一致,一个卷积核对于一个sentence,卷积后得到的结果是一个 vector,shape=(sentence_len - filter_size + 1, 1),那么,在 max-pooling 后得到的就是一个 scalar。所以,这点也是和图像卷积的不同之处,需要注意一下。
  • 正是由于 max-pooling 后只是得到一个 scalar,在NLP中,会实施多个 filter_size(比如3,4,5个words的宽度分别作为卷积的窗口大小),每个 filter_size 又有 num_filters 个(比如64个)卷积核。一个卷积核得到的只是一个scalar太孤单了,智慧的人们就将相同 filter_size 卷积出来的 num_filters 个 scalar 组合在一起,组成这个 filter_size 下的 feature_vector。
  • 最后再将所有 filter_size 下的 feature_vector 也组合成一个 single vector,作为最后一层softmax的输入。

  整个过程如下图所示,这个模型在CNN文本分类原理一文中也进行了讲述。
text_cnn_structure.png

重要的事情说三遍:一个卷积核对于一个句子,convolution后得到的是一个vector;max-pooling后,得到的是一个scalar。

  如果对上述讲解还有什么不理解的地方,可以先看下CNN文本分类原理这篇文章。
  说了这么多,总结一下这个类的作用就是:搭建一个用于文本数据的CNN模型。

四、代码解读

  了解了大致原理,进行代码的解读就更容易上手了。下面重点对 TextCNN 这个类进行逐行的理解。

1. 类初始化

    def __init__(
        self, sequence_length, num_classes, vocab_size, 
        embedding_size, filter_sizes, num_filters, l2_reg_lambda=0.0):

  可以看到类TextCNN的初始化中,接收了train.py中传入的若干参数。
  • sequence_length:句子固定长度(不足补全,超过截断)
  • num_classes:输出层中的类数(二分类传入的就是2)
  • vocab_size:词库大小
  • embedding_size:词向量维度
  • filter_sizes:卷积核尺寸(我们想要卷积过滤器覆盖的字数,例如,[3,4,5]意味着我们将有一个过滤器,分别滑过3,4和5个字,总共有3 * num_filters个过滤器)
  • num_filters:每个尺寸的卷积核数量
  • l2_reg_lambda=0.0:L2正则参数


  下面定义网络的输入数据,一句句看。

    # 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")

    # Keeping track of l2 regularization loss (optional)
    l2_loss = tf.constant(0.0)

  变量input_x存储句子矩阵,宽为sequence_length,长度自适应(=句子数量);
  变量input_y存储句子对应的分类结果,宽度为num_classes,长度自适应;
  变量dropout_keep_prob存储dropout参数;
  常量l2_loss为L2正则超参数。

  tf.placeholder创建一个占位符变量,当我们在训练集或测试时间执行它时,我们将其馈送到网络。第二个参数是输入张量的形状:None意味着该维度的长度可以是任何东西。在我们的情况下,第一个维度是批量大小,并且使用“None”允许网络处理任意大小的批次。
  将神经元保留在丢失层中的概率也是网络的输入,因为我们仅在训练期间使用dropout。 我们在评估模型时禁用它(稍后再说)。

  下面开始构建网络,一层层看。

2. Embedding层

  我们定义的第一层是Embedding层。

    with tf.device('/cpu:0'), tf.name_scope("embedding"):
        self.W = tf.Variable(
            tf.random_uniform([vocab_size, embedding_size], -1.0, 1.0),
            name="W")
        self.embedded_chars = tf.nn.embedding_lookup(self.W, self.input_x)
        self.embedded_chars_expanded = tf.expand_dims(self.embedded_chars, -1)

  我们在这里使用了几个功能:
  • tf.device('/cpu:0') 强制在CPU上执行操作。默认情况下,TensorFlow将尝试将操作放在GPU上(如果有的话)可用,但是我这里是笔记本电脑开发调试,所以用CPU支持。
  • tf.name_scope创建一个命名域,名称为“embedding”。它将所有操作添加到名为“embedding”的顶级节点中,以便在TensorBoard中可视化网络时获得良好的层次结构。
  • self.W是我们在训练中学习的embedding矩阵,我们使用随机均匀分布来初始化它。
    ৹ 存储vocab_size个大小为embedding_size的词向量,随机初始化为-1.0~1.0之间的值;

  • self.embedded_chars是输入input_x对应的词向量表示;
    ৹ tf.nn.embedding_lookup创建实际的embedding操作,embedding操作的结果是一个三维的tensor,它的形状是[None,sequence_length,embedding_size]

  • self.embedded_chars_expanded是,将词向量表示扩充一个维度(embedded_chars * 1),维度变为[句子数量, sequence_length, embedding_size, 1],方便进行卷积(tf.nn.conv2d的input参数为四维变量,见后文)
    ৹ 函数tf.expand_dims(input, axis=None, name=None, dim=None):在input第axis位置增加一个维度(dim用法等同于axis,官方文档已弃用);
    ৹ TensorFlow的卷积操作conv2d需要传递一个四维tensor,其维数对应于batch(批次),width(宽度),height(高度)和channel(通道)。我们embedding的结果不包含channel维度,所以我们手动添加它,留下的一层shape为[None,sequence_length,embedding_size,1]

3. 卷积层和池化(Max-Pooling)层

  现在我们开始构建卷积层,然后是池化(Max-Pooling)层。
  注意:我们使用了不同大小的filters(卷积核),因为每个卷积都会产生不同形状的tensor,我们需要对它们进行迭代,为每个tensor创建一个层,然后将结果合并成一个大的特征向量。

    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")
            # Maxpooling 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(pooled_outputs, 3)
    self.h_pool_flat = tf.reshape(self.h_pool, [-1, num_filters_total])

卷积计算
  在conv-maxpool-%s这个命名域下:
  • W:卷积核
  • b:偏移量,num_filters个卷积核,故有这么多个偏移量
  • conv:Wself.embedded_chars_expanded的卷积

  函数tf.nn.conv2d(input, filter, strides, padding, use_cudnn_on_gpu=None, name=None)实现卷积计算,参考tf.nn.conv2d是怎样实现卷积的
  本处调用的参数:
  • input:输入的词向量,[句子数(图片数)batch, 句子定长(对应图高),词向量维度(对应图宽), 1(对应图像通道数)]
  • filter:卷积核,[卷积核的高度,词向量维度(卷积核的宽度),1(图像通道数),卷积核个数(输出通道数)]
  • strides:图像各维步长,一维向量,长度为4,图像通常为[1, x, x, 1]
  • padding:卷积方式,"SAME"为等长卷积, "VALID"为窄卷积
  • 输出feature map:shape是[batch, height, width, channels]这种形式


激活函数
  • h:存储WX+b后非线性激活的结果
  函数tf.nn.bias_add(value, bias, name = None):将偏差项bias加到value上,支持广播的形式,bias必须为1维的,value维度任意,最后一维和bias大小一致;
  函数tf.nn.relu(features, name = None):非线性激活单元relu激活函数


池化(Pooling)
  • pooled:池化后结果
  函数tf.nn.max_pool(value, ksize, strides, padding, name=None):对value池化
  本处调用的参数:
  • value:待池化的四维张量,维度是[batch, height, width, channels]
  • ksize:池化窗口大小,长度(大于)等于4的数组,与value的维度对应,一般为[1,height,width,1],batch和channels上不池化
  • strides:与卷积步长类似
  • padding:与卷积的padding参数类似
  • 返回值shape仍然是[batch, height, width, channels]这种形式

  池化后的结果append到pooled_outputs中。对每个卷积核重复上述操作,故pooled_outputs的数组长度应该为num_filters。

4. Dropout层

  Dropout或许是正则化CNN的最流行的方法。dropout背后的思想是简单的。dropout层随机地屏蔽一部分神经元。这防止神经元一起变化,并且强迫它们单独学习有用的特征。我们激活的部分神经元是通过dropout_keep_prob定义的。在训练期间,我们给它设置一个值,比如0.5,然后在评价的时候设为1.0(不启动dropout)。

    # Add dropout
    with tf.name_scope("dropout"):
        self.h_drop = tf.nn.dropout(self.h_pool_flat, self.dropout_keep_prob)

5. 输出层(+softmax层)

  使用经过max-pooling(with dropout applied)的特征向量,我们可以通过做矩阵乘积,然后选择得分最高的类别进行预测。我们也可以应用softmax函数把原始的分数转化为规范化的概率,但是这不会改变我们最终的预测结果。

    # Final (unnormalized) scores and predictions
    with tf.name_scope("output"):
        W = tf.get_variable(
            "W",
            shape=[num_filters_total, num_classes],
            initializer=tf.contrib.layers.xavier_initializer()
        )
        b = tf.Variable(tf.constant(0.1, shape=[num_classes]), name="b")

        l2_loss += tf.nn.l2_loss(W)
        l2_loss += tf.nn.l2_loss(b)

        self.scores = tf.nn.xw_plus_b(self.h_drop, W, b, name="scores")
        self.predictions = tf.argmax(self.scores, 1, name="predictions")

  W和b均为线性参数,因为加了两个参数所以增加了L2损失,都加到了l2_loss里;
  这里,tf.nn.xw_plus_b是进行Wx+b矩阵乘积的方便形式。

6. 模型评估

  这里不属于模型结构的一部分,只是计算了模型的准确率,用tf.equal判断预测结果和真实值之间是否相等,tf.reduce_mean计算了模型和真实值一致的结果占得比例。

    # Calculate mean cross-entropy loss
    with tf.name_scope("loss"):
        losses = tf.nn.softmax_cross_entropy_with_logits(logits=self.scores, labels=self.input_y)
        self.loss = tf.reduce_mean(losses) + l2_reg_lambda * l2_loss

    # 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")

五、其他代码解读

1. train.py

参数设置
  首先是大量的参数设置,参数的设置函数主要有三个参数,参数的名字参数的默认值,以及参数的解释。这里打印参数的代码被注释了。为什么要这么设置参数呢,因为这样我们可以通过命令行传入我们想要传入的参数,而不需要改动我们的代码。

preprocess
  在预处理函数def preprocess():中,首先是加载数据的代码,没有什么特别的可以讲,就是一个加载数据的函数def load_data_and_labels(positive_data_file, negative_data_file):
  值得一提的就是它返回的值,x_text是一个由每句词的列表组成的列表,y的话是由一个长度为2的列表组成的列表。

  预处理的第二步就是构建词典,把我们的句子序列(由单词列表构成)转换成数据序列(单词在词典里面的索引),这边完全由tensorflow的内置函数完成。

  之后就是打乱数据和划分训练和测试集了。这些代码都是可以直接复用的代码。大部分的深度学习NLP任务都要经过相应的处理。

train
  训练部分的相关分析我都在代码注释中体现了。

六、总结

  以上是第一遍学习并解读 cnn-text-classification-tf 这个项目的笔记。主要还是参考了作者在项目里提到的两篇博客,讲的很好。同时还参考了多篇国人翻译版博客,除去copy来copy去的内容,也在理解上得到了不少帮助。鉴于每篇总有翻译的不是很到位的地方,尤其对于我这种新手,故自己加以修改,重新做了笔记。
  当然了,第一遍解读还是会有很多存疑的地方,后续随着理解加深再修改吧。
  PS:我fork了一份源码并加了代码中文注释:加注释版本


参考
http://www.wildml.com/2015/11/understanding-convolutional-neural-networks-for-nlp/
http://www.wildml.com/2015/12/implementing-a-cnn-for-text-classification-in-tensorflow/



  目录