用Tensorflow实现CNN文本分类(详细解释及TextCNN代码解释)

  • Post author:
  • Post category:其他



本文转载自:

http://www.dataguru.cn/forum.php?mod=viewthread&tid=637971&extra=page=1&page=1


Ox00: Motivation


最近在研究



Yoon Kim



的一篇经典之作



Convolutional Neural Networks for Sentence Classification



,这篇文章可以说是cnn模型用于文本分类的开山之作(其实第一个用的不是他,但是Kim提出了几个variants,并有详细的调参)





wildml



对这篇paper有一个tensorflow的实现,具体参见



here



。其实blog已经写的很详细了,但是对于刚入手tensorflow的新人来说代码可能仍存在一些细节不太容易理解,我也是初学,就简单总结下自己的理解,如果对读者有帮助那将是极好的。




Ox01: Start!


我主要对TextCNN这个类进行解读,具体代码在



这里






研究别人代码时,时常问自己几个问题,由问题切入,在读的过程中找答案,这种方式我个人认为是最efficient的


1 这个class的主要作用是什么?


TextCNN类搭建了一个最basic的CNN模型,有input layer,convolutional layer,max-pooling layer和最后输出的softmax layer。




但是又因为整个模型是用于


文本


的(而非CNN的传统处理对象:图像),因此在cnn的操作上相对应地做了一些小调整:


  • 对于文本任务,输入层自然使用了word embedding来做input data representation。
  • 接下来是卷积层,大家在图像处理中经常看到的卷积核都是正方形的,比如4*4,然后在整张image上沿宽和高逐步移动进行卷积操作。但是nlp中输入的“image”是一个词矩阵,比如n个words,每个word用200维的vector表示的话,这个”image”就是n*200的矩阵,卷积核只在高度上已经滑动,在宽度上和word vector的维度一致(=200),也就是说每次窗口滑动过的位置都是完整的单词,不会将几个单词的一部分“vector”进行卷积,这也保证了word作为语言中最小粒度的合理性。(当然,如果研究的粒度是character-level而不是word-level,需要另外的方式处理)
  • 由于卷积核和word embedding的宽度一致,一个卷积核对于一个sentence,卷积后得到的结果是一个vector, shape=(sentence_len – filter_window + 1, 1),那么,在max-pooling后得到的就是一个

    Scala

    r。所以,这点也是和图像卷积的不同之处,需要注意一下。
  • 正是由于max-pooling后只是得到一个scalar,在nlp中,会实施多个filter_window_size(比如3,4,5个words的宽度分别作为卷积的窗口大小),每个window_size又有num_filters个(比如64个)卷积核。一个卷积核得到的只是一个scalar太孤单了,智慧的人们就将相同window_size卷积出来的num_filter个scalar组合在一起,组成这个window_size下的feature_vector。
  • 最后再将所有window_size下的feature_vector也组合成一个single vector,作为最后一层softmax的输入。

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


如果对上述讲解还有什么不理解的地方,请移步wildml的另一篇



blog



,包教包会。




说了这么多,总结一下这个类的作用就是:搭建一个用于文本数据的CNN模型!




2 一些参数


既然TextCNN类是基于YoonKim的思路搭建的,那么我们接下来一个很重要的步骤就是将paper中提到的各种参数设置都整理出来,有一些参数是关于模型的,有一些参数是关于training的,比如epoch等,这类参数就和模型本身无关,以此来确定我们的TextCNN类需要传递哪些参数来初始化。




赶紧把



paper



打开,来仔细找找参数吧。




3.1节Hyperparameters and Training部分讲到一些,还有一部分在Table1中:




关于model

  • filter windows:

    [3,4,5]
  • filter maps:

    100

    for each filter window
  • dropout rate:

    0.5
  • l2 constraint:

    3
  • randomly select

    10%

    of training data as dev set(early stopping)
  • word2vec(google news) as initial input, dim =

    300
  • sentence of length: n, padding where necessary
  • number of target classes
  • dataset size
  • vocabulary size


关于training

  • mini batch size:

    50

  • shuffuled

    mini batch

  • Adadelta

    update rule: similar results to Adagrad but required fewer epochs
  • Test method: standard train/test split ot CV


3 Dropout注意事项


正则是解决过拟合的问题,在最后一层softmax的时候是full-connected layer,因此容易产生过拟合。




策略就是在:







训练


阶段,对max-pooling layer的输出实行一些dropout,以概率p激活,激活的部分传递给softmax层。







测试


阶段,w已经学好了,但是不能直接用于unseen sentences,要乘以p之后再用,这个阶段没有dropout了全部输出给softmax层。




4 Embedding Layer

1


2


3


4


5


6


7

# Embedding layer


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)


存储全部word vector的矩阵<span tabindex=”0″ class=”MathJax” id=”MathJax-Element-1-Frame” role=”presentation” style=”display: inline-block; position: relative;” data-mathml=’W’>W




W初始化时是随机random出来的,也就是paper中的第一种模型CNN-rand




训练过程中并不是每次都会使用全部的vocabulary,而只是产生一个batch(batch中都是sentence,每个sentence标记了出现哪些word(最大长度为sequence_length),因此batch相当于一个二维列表),这个batch就是input_x。


1

self.input_x = tf.placeholder(tf.int32, [None, sequence_length], name=”input_x”)




tf.nn.embedding_lookup:查找input_x中所有的ids,获取它们的word vector。batch中的每个sentence的每个word都要查找。所以得到的embedded_chars的shape应该是[None, sequence_length, embedding_size](1)




但是,输入的word vectors得到之后,下一步就是输入到卷积层,用到tf.nn.conv2d函数,




再看看conv2d的参数列表:




input: [batch, in_height, in_width, in_channels](2)




filter: [filter_height, filter_width, in_channels, out_channels](3)




对比(1)(2)可以发现,就差一个in_channels了,而最simple的版本也就只有1通道(Yoon的第四个模型用到了multichannel)




因此需要expand dim来适应conv2d的input要求,万能的tensorflow已经提供了这样的功能:


This operation is useful if you want to add a batch dimension to a single element. For example, if you have a single image of shape [height, width, channels], you can make it a batch of 1 image with expand_dims(image, 0), which will make the shape [1, height, width, channels].


Example:


# ‘t’ is a tensor of shape [2]


shape(expand_dims(t, -1)) ==> [2, 1]


因此只需要


1

tf.expand_dims(self.embedded_chars, -1)




就能在embedded_chars后面加一个in_channels=1




5 Conv and Max-pooling

1


2


3


4


5


6


7


8


9


10


11


12


13


14


15


16


17


18


19


20


21


22


23


24


25


26


27


28


29

# Create a convolution + maxpool layer for each filter size


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(3, pooled_outputs)


self.h_pool_flat = tf.reshape(self.h_pool, [-1, num_filters_total])


首先,对filter_sizes中的每一个filter_window_size都要进行卷积(每一种size都要产生num_filters那么多个filter maps),所以外层就是一个大的for循环。




继续,看到了一个比较陌生的函数tf.name_scope(‘xxx’)




这个函数的作用参见



官方文档





由于在for循环内部,filter_size是固定了的,因此可以结合(3):[filter_height, filter_width, in_channels, out_channels]得到,filter_shape = [filter_size, embedding_size, 1, num_filters]




之所以要弄清楚filter shape是因为要对filter的权重矩阵w进行初始化:


1

W = tf.Variable(tf.truncated_normal(filter_shape, stddev=0.1), name=”W”)




这里为什么要用tf.truncated_normal()函数呢?




答:tensorflow中提供了两个normal函数:


  • tf.random_normal(shape, mean=0.0, stddev=1.0, dtype=tf.float32, seed=None, name=None)
  • tf.truncated_normal(shape, mean=0.0, stddev=1.0, dtype=tf.float32, seed=None, name=None)


对比了一下,这两个函数的参数列表完全相同,不同之处我就直接引用文档中的说明,讲解的很清楚,


Outputs random values from a truncated normal distribution.


The generated values follow a normal distribution with specified mean and standard deviation, except that values whose magnitude is more than 2 standard deviations from the mean are dropped and re-picked.


也就是说random出来的值的范围都在[mean – 2


standard_deviations, mean + 2


standard_deviations]内。




下图可以告诉你这个范围在哪,







conv2d得到的其实是下图中的<span tabindex=”0″ class=”MathJax” id=”MathJax-Element-2-Frame” role=”presentation” style=”display: inline-block; position: relative;” data-mathml=’w⋅x’>w⋅x w⋅x的部分,







还要加上bias项tf.nn.bias_add(conv, b),并且通过relu:tf.nn.relu才最终得到卷积层的输出<span tabindex=”0″ class=”MathJax” id=”MathJax-Element-3-Frame” role=”presentation” style=”display: inline-block; position: relative;” data-mathml=’h’>h h。


那究竟卷积层的输出的shape是什么样呢?


官方文档中有一段话解释了卷积后得到的输出结果:







第三部进行了right-multiply之后得到的结果就是

[batch, out_height, out_width, output_channels]

,但是还是不清楚这里的out_height和out_width到底是什么。


那就看看wildml中怎么说的吧

“VALID” padding means that we slide the filter over our sentence without padding the edges, performing a narrow convolution that gives us an output of shape [1, sequence_length – filter_size + 1, 1, 1].

哦,这句话的意思是说out_height和out_width其实和padding的方式有关系,这里选择了”VALID”的方式,也就是不在边缘加padding,得到的out_height=sequence_length – filter_size + 1,out_width=1


因此,综合上面的两个解释,我们知道conv2d-加bias-relu之后得到的<span tabindex=”0″ class=”MathJax” id=”MathJax-Element-4-Frame” role=”presentation” style=”display: inline-block; position: relative;” data-mathml=’h’>h h的shape=

[batch, sequence_length – filter_size + 1, 1, num_filters]
接下来的工作就是max-pooling了,来看一下tensorflow中给出的函数:


tf.nn.max_pool(value, ksize, strides, padding, data_format=’NHWC’, name=None)







其中最重要的两个参数是value和ksize。


value相当于是max pooling层的输入,在整个网络中就是刚才我们得到的<span tabindex=”0″ class=”MathJax” id=”MathJax-Element-5-Frame” role=”presentation” style=”display: inline-block; position: relative;” data-mathml=’h’>h h,check了一下它俩的shape是一致的,说明可以直接传递到下一层。


另一个参数是ksize,官方解释说是input tensor每一维度上的window size。仔细想一下,其实就是想定义多大的范围来进行max-pooling,比如在图像中常见的2*2的小正方形区域对整个h得到feature map进行pooling,但是在nlp中,刚才说到了每一个feature map现在是

[batch, sequence_length – filter_size + 1, 1, num_filters]

维度的,我们想知道每个output_channels(每个channel是一个vector)的最大值,也就是最重要的feature是哪一个,那么就是在第二个维度上设定window=sequence_length – filter_size + 1【这里感觉没解释通,待后续探索】


根据ksize的设置,和value的shape,可以得到pooled的shape=

[batch, 1, 1, num_filters]




这是一个filter_size的结果(比如filter_size = 3),pooled存储的是当前filter_size下每个sentence最重要的num_filters个features,结果append到pooled_outputs列表中存起来,再对下一个filter_size进行相同的操作。


等到for循环结束时,也就是所有的filter_size全部进行了卷积和max-pooling之后,首先需要把相同filter_size的所有pooled结果concat起来,再将不同的filter_size之间的结果concat起来,最后的到的应该类似于二维数组,[batch, all_pooled_result]




all_pooled_result一共有num_filters\(100)*len(filter_sizes)(3)个,比如300个




连接的过程需要使用



tf.concat



,官方给出的例子很容易理解。




最后得到的h_pool_flat也就是[batch, 300]维的tensor。




6 Dropout

1


2


3

# Add dropout


with tf.name_scope(“dropout”):


self.h_drop = tf.nn.dropout(self.h_pool_flat, self.dropout_keep_prob)


前面在“dropout注意事项”中讲到了,dropout仅对hiddenlayer的输出层进行drop,使得有些结点的值不输出给softmax层。




7 Output

1


2


3


4


5


6


7


8


9


10


11


12



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


输出层其实是个softmax分类器,没什么可讲的,但是要注意l2正则(虽然有paper说l2加不加并没有什么区别)




但是我还有一个疑问是为什么对b也要进行正则约束?




另外,tf.nn.xw_plus_b()在open api中并没有提供,参考github上的某个



issue





因此可以改为tf.matmul(self.h_drop, W) + b但是不好的地方是无法设置name了。。(用xw_plus_b也不会报错不改也可以)




还有一个奇怪的地方是,这一层按道理说应该是一个softmax layer,但是并没有使用到softmax函数,在Yoon的文章中也是直接得到输出的,









因此,我们也按照这种方式写代码,得到所有类别的score,并且选出最大值的那个类别(argmax)




y的shape为[batch, num_classes],因此argmax的时候是选取每行的max,dimention=1




因此,最后scores的shape为[batch, 1]




8 Loss function


得到了整个网络的输出之后,也就是我们得到了y_prediction,但还需要和真实的y label进行比较,以此来确定预测好坏。


1


2


3


4

# CalculateMean 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) + l2_reg_lambda * l2_loss




还是使用常规的cross_entropy作为loss function。最后一层是全连接层,为了防止过拟合,最后还要在loss func中加入l2正则项,即l2_loss。l2_reg_lambda来确定惩罚的力度。




9 Accuracy

1


2


3


4

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


tf.equal(x, y)返回的是一个bool tensor,如果xy对应位置的值相等就是true,否则false。得到的tensor是[batch, 1]的。




tf.cast(x, dtype)将bool tensor转化成float类型的tensor,方便计算




tf.reduce_mean()本身输入的就是一个float类型的vector(元素要么是0.0,要么是1.0),直接对这样的vector计算mean得到的就是accuracy,不需要指定reduction_indices




0x02: Conclusion


后续可能还会对其他部分进行解读,敬请期待。