【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上有多个不错的机器学习项目,学习能力和知识量可见一斑。当然了,国内也有很多没有学校光环的技术大牛,这些都是题外话了。
关于这个项目,其实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文本分类原理一文中也进行了讲述。
重要的事情说三遍:一个卷积核对于一个句子,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:W
与self.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/