将神经网络应用于大图像时,输入可能有上百万个维度,如果输入层和隐含层进行“全连接”,需要训练的参数将会非常多。如果构建一个“部分联通”网络,每个隐含单元仅仅只能连接输入单元的一部分,参数数量会显著下降。卷积神经网络就是基于这个原理而构建的。这其中的思想就是,降维或者说是特征选择,通过前面的卷积层或者池化层将重要的特征选取出来,然后全连接进行分类。特征是最重要的。
论文所提出的模型结构如下图所示:
1,这里的输入层显示有两个channel,其实我们可以看作是一个,因为后文中说到这两个channel分别是static和non-static,即使用的词向量是否随着训练发生变化。non-static就是词向量随着模型训练变化(Fine tune),这样的好处是词向量可以根据数据集做适当调整,但是CS224d课程里也说过当数据集较小时不推荐此操作,否则容易产生过拟合现象。static就是直接使用word2vec训练好的词向量即可。此外,由图可知,输入层是将一个句子所有单词(padding)的词向量进行拼接成一个矩阵,每一行代表一个词。每个句子固定20个词,如果不够的补padding。
2,卷积层,不做过多解释。每个卷积核的大小为filter_size*embedding_size。filter_size代表卷积核纵向上包含单词个数,即认为相邻几个词之间有词序关系,代码里使用的是[3,4,5]。embedding_size就是词向量的维数。每个卷积核计算完成之后我们就得到了1个列向量,代表着该卷积核从句子中提取出来的特征。有多少和卷积核就能提取出多少种特征,即图中在纵深方向上channel的数量。
3,池化层。文中提到pooling操作就是将卷积得到的列向量的最大值提取出来。这样pooling操作之后我们会获得一个num_filters维的行向量,即将每个卷积核的最大值连接起来。这样做还有一个好处就是,如果我们之前没有对句子进行padding操作,那么句子的长度是不同的,卷积之后得到的列向量维度也是不同的,可以通过pooling来消除句子之间长度不同的差异。
4,全连接层,为了将pooling层输出的向量转化为我们想要的预测结果,加上一个softmax层即可。针对电影评价的分类任务,就是将其转化为正面、负面两个结果。文中还提到了过拟合的问题,因为实验中所使用的数据集相对较小,很容易就会发生过拟合现象,在实验过程中也会发现当迭代3000多轮的时候准确率就会接近1。所以这里引如dropout来减少过拟合现象。此外还可以考虑L2正则化等方法实现防止过拟合的功能。
到这里其实对论文模型的
数据获取和准备
在本博客中,我们使用的数据集是 Movie Review data from Rotten Tomatoes ,这也是论文中使用的其中一个数据集。这个数据集包含 10662 个评论样本,其中一半是正向评论,一半是负向评论。这个数据集大约有2万个词。注意,因为这个数据集很小,所以如果我们使用很复杂的模型,那么容易造成过拟合。并且,这个数据没有帮我们分离训练数据集和测试数据集。因此,我们需要自己去预处理。在这里,我们把10%的数据作为交叉验证集。在原始的论文中,作者使用十折交叉验证(10-fold cross validation)。
数据预处理从原始数据文件中,导入正样本和负样本数据。数据清理,使用和论文中相同的代码。
将每个句子填充到最大句子长度,也就是数据集中最长的那个句子的长度,这里是20。我们填充的特殊标记是 ,将句子填充到相同长度是非常有用的,因为它能帮助我们进行有效的批处理,因为在批处理中的每个例子都必须有相同的长度。
构建词汇索引表,将每个单词映射到 0 ~ 18765 之间(18765是词汇量大小),那么每个句子就变成了一个整数的向量。
准备单词的embeding向量,这里采用训练好的256的Word2vector向量。
初始化textcnn模型
为了允许各种的超参数配置,我们把我们的代码放到一个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 - 句子的长度。请注意,我们通过添加特殊标记,使得所欲的句子都拥有了相同的长度(我们的数据集是20)。
num_classes - 最后一层分类的数目,在这里我们是进行二分类(正向评论和负向评论)。
vocab_size - 词汇量的大小。这个参数是为了确定我们词向量嵌入层的大小,最终的总词向量维度是 [vocabulary_size, embedding_size] 。
embeddign_size - 每个单词的词向量的长度128或者256。
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:有两种情况可以不用:数据量特别小,或者你用了更好的正则方法,比如bn。实际中我们尝试了不同参数的dropout,最好的还是0.5,所以如果你的计算资源很有限,默认0.5是一个很好的选择。
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")
基于深度学习技术的文本分类技术比起传统的文本分类模型,例如 LR,SVM 等,有什么优势呢?
首先,最明显的优势,深度学习不需要人工手动的提取文本的特征,它可以自动的获取基础特征并组合为高级的特征,训练模型获得文本特征与目标分类之间的关系,省去了使用TF-IDF等提取句子的关键词构建特征工程的过程。
其次,相比传统的N-gram模型而言,深度学习中可以更好的利用词序的特征,CNN的文本分类模型中的filter的size的大小可以当做是一种类似于N-gram的方式,而RNN(LSTM)则可以利用更长的词序,配合Attention机制则可以通过加权体矩阵体现句子中的核心词汇部位,attention最早是用于自动翻译中实现对应词汇对齐及可视化的功能。
作者:李良