翻译自:http://www.wildml.com/2015/12/implementing-a-cnn-for-text-classification-in-tensorflow/
代码请见:整个代码都在github中,可用
在这篇文章中,我们将会实现一个与 Kim Yoon的 Convolutional Neural Networks for Sentence Classification这篇论文中相似的模型,论文中的模型在一系列的文本分类任务(像情感分析)中已经实现了很好的分类性能,已经成为未来新的文本分类体系结构的标准基准。
我将假设你已经熟悉了在自然语言处理中应用卷积神经网络的基础知识,如果没有,建议先阅读 Understanding Convolutional Neural Networks for NLP 来了解必要的背景知识。
数据处理
我们使用的数据集为电影评论 Movie Review data from Rotten Tomatoes
,原始论文中也使用了其中一个数据集。数据集中有10662条评论语句,一半positive,一半negative,此数据集的词汇量大约20k。注意,使用一个powerful模型可能会造成过拟合。此外,数据集没有一个官方的train/test分割,因此我们只使用10%的数据作为开发集。原始论文报告的是对数据进行10倍交叉验证的结果。
此处不讲数据预处理,GitHub有代码:
1. 从原始数据文件中加载数据
2. 用与 原文一样的代码清理文本数据
3. 用<pad>填充每个句子使长度一致,长度为59个词,长度一致可以高效地批处理数据。
4. 建立词汇索引,将每个字标记上一个0到18765(词汇量)之间的整数,这样每个句子就成了一个整数向量
模型
我们将在这篇文章中构建的网络大致如下:
第一层将字映射成低维向量,下一层用多种过滤器大小在映射成的字向量上进行卷积运算,例如,一次滑动3,4或5个字。然后我们将卷积层的结果通过max-pool化为一个长特征向量,添加dropout正则化,用softmax层将结果分类。
为方便讲解,此处略微简化下原论文的模型:
- 我们不是用先前训练好的word2vec作为我们的词向量(word embeddings),而是学一下从头开始映射(embed)
- 我们不会在权重向量上用L2常规约束,已有论文A Sensitivity Analysis of (and Practitioners’ Guide to) Convolutional Neural Networks for Sentence Classification
证明此约束对最终结果影响甚小 - 原论文中的实验用了两个数据输入通道——静态和非静态字向量,我们只用一种
在这里向代码添加上述扩展是相对简单的(几十行代码),可以看一下本文结尾的练习。
下面正式开始!
实现
我们把代码放在了TextCNN类中,以便进行不同的超级参数(Hyperparameter)设置,在init函数中生成模型图。
import tensorflow as tf
import numpy as np
classTextCNN(object):
"""
CNN用于文本分类;使用了一个词向量层,后跟卷积层、最大池化层和softmax层。
"""
def__init__(
self, sequence_length, num_classes, vocab_size,
embedding_size, filter_sizes, num_filters):
#implementation
参数介绍
- sequence_length 句子的长度。我们已将所有句子填充至等长(59)
- num_classes 输出层的类个数,此项目中是2——positive和negative
- vocab_size 词汇规模,用来定义嵌入层的大小,形如[vacabulary_size,embedding_size]
- embedding_size 嵌入(embeddings)的维数
- filter_sizes 我们希望的卷积过滤器覆盖的单词数。我们将为每个大小指定过滤器,例如,[3,4,5]表示我们分别设置滑过3,4,5个词的过滤器,即共有3*num_fliters个过滤器
- num_filters 针对每个过滤器大小的过滤器个数
输入占位符
我们从定义传递给我们网络的输入数据开始:
# 用于输入、输出和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
创建了一个占位符变量,在训练或测试时用于反馈给网络。第二个参数是输入张量(input tensor
)的形式,None
表示维度的长度是任意的,在本例中第一维是批处理大小,使用None
允许网络处理任意大小的批处理。
在dropout
层保存一个神经元也是对网络的输入,因为我们只在训练时允许dropout
,评估模型时禁用。
嵌入层(Embedding层)
嵌入层是我们定义的第一个层,它将字索引映射到低维向量表示,本质上是一个从数据中学习的查找表。
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)
#embedding_lookup此函数是根据索引input_x查找W中的元素
self.embedded_chars_expanded = tf.expand_dims(self.embedded_chars, -1)
#拓展最后一维
此处应用了几个新功能:
-
tf.device("/cpu:0")
:必须进行CPU执行的操作,默认情况下如果GPU可用的话,TensorFlow会将操作放在GPU上。但是嵌入层的实现现在没有GPU支持,放在GPU上会抛出错误。 -
tf.name_scope
:创建一个名为embedding
的NameScope
,它将所有操作加至名为embedding
的顶层节点以方便你在TensorBoard
上可视化操作。
W
是在训练时学习得到的嵌入层矩阵。我们用一个随机统一分布(random uniform distribution)来初始化它。 tf.nn.embedding_lookup
创建一个实际的嵌入操作,嵌入操作的结果是产生一个三维tensor [None,sequence_length,embedding_size]
TensorFlow的卷积con2d
操作需要一个四维tensor:[batch,width,height,channel]
。我们的嵌入层结果没有channel
这一维,需要手动添加,所以最终该层为[None,sequence_length,embedding_size,1]
卷积和最大池层
现在我们可以建造卷积层和max-pooling了,注意我们用了不同大小的过滤器(filter
)。由于每个卷积产生不同形状的tensor
,因此我们需要迭代(iterate)他们,为每一个创建一个层,然后将结果合并进一个大的特征向量。
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
是对卷积输出层应用非线性处理(nonlinearity)的结果。每个过滤器滑过整个embedding
,但是覆盖的字数不同。“VALID” padding
表示过滤器滑过了整个句子没有填充边缘,执行一个窄卷积得到输出[1,sequence_length-filter_size+1,1,1]
。对特定大小的过滤器输出执行max-pooling
得到一个tensor[batch_size,1,1,num_filters]
。这本质是一个特征向量,最后一维对应我们的特征。得到所有大小的过滤器的池化输出tensor后将他们合并入一个长特征向量[batch_size,num_filters_total]
。在tf.reshape
中使用-1
告诉TensorFlow
在可能时降维(flatten the dimension)。
可以花点时间理解这些操作的输出形式,回去看《理解自然语言处理中的卷积神经网络》( Understanding Convolutional Neural Networks for NLP
)。TensorBoard可视化操作也可以帮助理解。
Dropout 层
Dropout
也许是正则化卷积神经网络最流行的方法,Dropout
背后的思想很简单。dropout
层随机“禁用”一部分神经元,有效防止神经元共适应(co-adapting),迫使它们学习独立有用的特征。可用的那部分神经元由dropout_keep_prob
对网络的输入定义,在训练时我们将它设为0.5,评估时设为1(禁用dropout)。
# 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
矩阵乘法的容器。
损失和准确度
使用得分可以定义损失函数。损失(loss)是对我们的网络犯错误的衡量,我们的目标是最小化损失。分类问题的标准损失函数是交叉熵损失。
# 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是为每个类计算交叉熵损失的函数,给定我们的分数和正确的输入标签。然后我们可以得到损失的平均值;也可以使用总和,但这样会在 比较不同批量和train/dev数据的损失 的时候加大难度。
也可以定义一个训练和测试期间追踪有用的量的准确度表达式。
# 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")
可视化网络
网络定义工作已经完成。所有代码可以在此The full codes获得。可以在TensorBoard中可视化网络得到大图.
训练过程
为网络定义训练程序之前,我们需要了解TensorFlow如何使用Session
和Graph
的基本知识。如果你已经熟悉了这些概念,可以跳过本节。
在TensorFlow中,会话(Session
)是一个你可以在其中进行图形操作的环境,它包含变量和队列的状态。每个会话都在一个图上进行。如果在创建变量和操作时,没有明确指定会话,将使用TensorFlow创建的默认会话。可以通过在session.as_default()
块内执行命令来更改会话(具体往下看)。
图形包含操作和张量(tensors),可以在程序中使用多个图,但大多数程序只需一个图。可以在多个会话中使用相同的图,但不能在一个会话中使用多个图。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),这对debug很有用。FLAGS是我们程序的命令行参数。
实例化CNN并最小化损失
实例化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)
train_op
是一个新创建的操作,可以用来对参数执行梯度更新。train_op
每次执行都是一个训练步骤,TensorFlow自动算出哪些变量是可训练的,并计算出它们的梯度。通多定义一个名为global_step
的变量,并把它上午值传递给优化器,可以让TensorFlow为我们计数训练步骤,每执行一次train_op
全局步骤(global step
)自动加一。
Summaries
TensorFlow有一个summaries的概念,可以让你在训练和评估时追踪和可视化各种量。比如,你可能想追踪随时间变化的损失和精确度。也可以追踪更复杂的变量,例如图层激活的直方图。Summaries
是序列化的对象,使用 SummaryWriter写入磁盘。
# Output directory for models and summaries
timestamp = 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 accuracy
loss_summary = tf.scalar_summary("loss", cnn.loss)
acc_summary = tf.scalar_summary("accuracy", cnn.accuracy)
# Train Summaries
train_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 summaries
dev_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)
这里,我们分别追踪训练和测试的summaries
。这该案例中他们是相同的量,但是你可能会有只想在训练期间追踪的量,比如变量更新值。tf.merge_summary是一个将多个汇总操作合并成一个可执行操作的函数。
Checkpointing 检查点
另一个常用的TensorFlow功能是检查点checkpointing--保存模型的参数以便稍后恢复。检查点可用于稍后继续训练,或使用提前停止选择最佳参数设置。检查点由 Saver类创建。
# Checkpointing
checkpoint_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 it
if 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
函数可以方便地初始化我们定义的所有变量。你也可以手动初始化你的变量。例如,使用预先训练的值初始化你的嵌入(embeddings)是很有用的。
定义一个训练步骤
在我们来为单个训练步骤定义一个函数,在批量数据上评估模型、更新模型参数
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
什么也不返回,它只是更新网络参数。最后输出当前批次的损失和精度,保存summaries
到磁盘。注意,如果批量较小,训练批次的损失和精度可能因批次而异。由于我们使用dropout
,你的训练指标可能比评估指标更糟。
我们在随机数据集(arbitrary data set
)上写了个类似的函数来评估损失和精度,例如一个有效集或者整个训练集。重要的是,这个函数和上边的函数功能相同,但是没有训练操作。它也禁用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)
Training loop 训练循环
最后,准备写训练循环。我们迭代批量数据,为每个批次调用train_step
函数,间或评估和检查我们的模型:
# Generate batches
batches= data_helpers.batch_iter(
zip(x_train, y_train), FLAGS.batch_size, FLAGS.num_epochs)
# Training loop. For each batch...
forbatch in batches:
x_batch, y_batch = zip(*batch)
train_step(x_batch, y_batch)
current_step= tf.train.global_step(sess, global_step)
ifcurrent_step % FLAGS.evaluate_every ==0:
print("\nEvaluation:")
dev_step(x_dev, y_dev, writer=dev_summary_writer)
print("")
ifcurrent_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
是批处理数据的辅助函数, tf.train.global_step
是返回global_step
的值的函数。完整训练代码见这里: The full code for training
在TensorBoard中将结果可视化
训练脚本把summaries
写入输出目录,通过把TensorBoard指向该目录,我们可以可视化创建的图和摘要。
tensorboard --logdir /PATH_TO_CODE/runs/1449760558/summaries/
使用默认参数(128维嵌入,过滤器大小为3,4,5,dropout为0.5,每个大小的过滤有128个过滤器)运行训练过程会产生以下损失和精度曲线图(蓝色是训练数据,红色是10%dev数据)
这里需要说明几件事:
- 由于使用了小批量,我们的训练指标(training metrics)并不平滑。如果使用大批量(或在整个训练集上进行评估)会得到一条更平滑的蓝线
- 由于开发(dev)精度明显低于训练精度,看上与很像网络过拟合训练数据。说明我们需要更多的数据(MR数据集很小),需要更强的正则化或更少的模型参数,例如 我尝试在最后一层为权重添加额外的L2惩罚可以将精度提高到76%,接近于原论文中的结果。
- 由于使用了dropout训练损失和精度开始时显著低于开发指标(dev metrics)
你可以调试代码,并尝试用不同的参数配置来运行模型,代码和说明可在GitHub获取: Code and instructions are available on Github.
扩展和练习
以下是一些有用的练习,可以提高模型的性能:
- 使用预先训练的word2vec向量初始化嵌入。 要完成这项工作,您需要使用300维嵌入并使用预先训练的值初始化它们。
- 与原始论文中一样,限制最后一层中权重向量的L2范数。 您可以通过定义在每个训练步骤后更新权重值的新操作来完成此操作。
- 将L2正则化添加到网络以对抗过度拟合,同时尝试提高dropout率。 (Github上的代码已包含L2正则化,但默认情况下禁用)
- 添加权重更新和图层操作的直方图摘要,并在TensorBoard中显示它们。