TextCNN网络是2014年提出的用来做文本分类的卷积神经网络,由于其结构简单、效果好,在文本分类、推荐等NLP领域应用广泛,我自己在工作中也有探索其在实际当中的应用,今天总结一下。
再将TextCNN网络的具体结构之前,先讲一下TextCNN处理的是什么样的数据以及需要什么样的数据输入格式。假设现在有一个文本分类的任务,我们需要对一段文本进行分类来判断这个文本是是属于哪个类别:体育、经济、娱乐、科技等。训练数据集如下示意图:
第一列是文本的内容,第二列是文本的标签。首先需要对数据集进行处理,步骤如下:
- 分词 中文文本分类需要分词,有很多开源的中文分词工具,例如Jieba等。分词后还会做进一步的处理,去除掉一些高频词汇和低频词汇,去掉一些无意义的符号等。
- 建立词典以及单词索引 建立词典就是统计文本中出现多少了单词,然后为每个单词编码一个唯一的索引号,便于查找。如果对以上词典建立单词索引,结果如下图示意:
上面的词典表明,“谷歌”这个单词,可以用数字 0 来表示,“乐视”这个单词可以用数字 1 来表示。
- 将训练文本用单词索引号表示 在上面的单词-索引表示下,训练示例中的第一个文本样本可以用如下的一串数字表示:
到这里文本的预处理工作基本全部完成,将自然语言组成的训练文本表示成离散的数据格式,是处理NLP工作的第一步。
TextCNN的结构比较简单,输入数据首先通过一个embedding layer,得到输入语句的embedding表示,然后通过一个convolution layer,提取语句的特征,最后通过一个fully connected layer得到最终的输出,整个模型的结构如下图:
上图是论文中给出的视力图,下面分别介绍每一层。
- embedding layer:即嵌入层,这一层的主要作用是将输入的自然语言编码成distributed representation,具体的实现方法可以参考word2vec相关论文,这里不再赘述。可以使用预训练好的词向量,也可以直接在训练textcnn的过程中训练出一套词向量,不过前者比或者快100倍不止。如果使用预训练好的词向量,又分为static方法和no-static方法,前者是指在训练textcnn过程中不再调节词向量的参数,后者在训练过程中调节词向量的参数,所以,后者的结果比前者要好。更为一般的做法是:不要在每一个batch中都调节emdbedding层,而是每个100个batch调节一次,这样可以减少训练的时间,又可以微调词向量。
- convolution layer:这一层主要是通过卷积,提取不同的n-gram特征。输入的语句或者文本,通过embedding layer后,会转变成一个二维矩阵,假设文本的长度为|T|,词向量的大小为|d|,则该二维矩阵的大小为|T|x|d|,接下的卷积工作就是对这一个|T|x|d|的二维矩阵进行的。卷积核的大小一般设定为
n是卷积核的长度,|d|是卷积核的宽度,这个宽度和词向量的维度是相同的,也就是卷积只是沿着文本序列进行的,n可以有多种选择,比如2、3、4、5等。对于一个|T|x|d|的文本,如果选择卷积核kernel的大小为2x|d|,则卷积后得到的结果是|T-2+1|x1的一个向量。在TextCNN网络中,需要同时使用多个不同类型的kernel,同时每个size的kernel又可以有多个。如果我们使用的kernel size大小为2、3、4、5x|d|,每个种类的size又有128个kernel,则卷积网络一共有4x128个卷积核。
上图是从google上找到的一个不太理想的卷积示意图,我们看到红色的横框就是所谓的卷积核,红色的竖框是卷积后的结果。从图中看到卷积核的size=1、2、3, 图中上下方向是文本的序列方向,卷积核只能沿着“上下”方向移动。卷积层本质上是一个n-gram特征提取器,不同的卷积核提取的特征不同,以文本分类为例,有的卷积核可能提取到娱乐类的n-gram,比如范冰冰、电影等n-gram;有的卷积核可能提取到经济类的n-gram,比如去产能、调结构等。分类的时候,不同领域的文本包含的n-gram是不同的,激活对应的卷积核,就会被分到对应的类。
- max-pooling layer:最大池化层,对卷积后得到的若干个一维向量取最大值,然后拼接在一块,作为本层的输出值。如果卷积核的size=2,3,4,5,每个size有128个kernel,则经过卷积层后会得到4x128个一维的向量(注意这4x128个一维向量的大小不同,但是不妨碍取最大值),再经过max-pooling之后,会得到4x128个scalar值,拼接在一块,得到最终的结构—512x1的向量。max-pooling层的意义在于对卷积提取的n-gram特征,提取激活程度最大的特征。
- fully-connected layer:这一层没有特别的地方,将max-pooling layer后再拼接一层,作为输出结果。实际中为了提高网络的学习能力,可以拼接多个全连接层。
以上就是TextCNN的网络结构,接下来是我自己写的代码(tensorflow版),附上,有不足之处,望大家指出。
写tensorflow代码,其实有模式可寻的,一般情况下就是三个文件:train.py、model.py、predict.py。除此之外,一般还有一个data_helper.py的文件,用来处理训练数据等。
model.py:定义模型的结构。
train.py:构建训练程序,这里包括训练主循环、记录必要的变量值、保存模型等。
predict.py:用来做预测的。
这里主要附上model.py文件和train.py文件。
model.py
# -*- coding:utf-8 -*-
import tensorflow as tf
import numpy as np
class Settings(object):
"""
configuration class
"""
def __init__(self, vocab_size=100000, embedding_size=128):
self.model_name = "CNN"
self.embedding_size = embedding_size
self.filter_size = [2, 3, 4, 5]
self.n_filters = 128
self.fc_hidden_size = 1024
self.n_class = 2
self.vocab_size = vocab_size
self.max_words_in_doc = 20
class TextCNN(object):
"""
Text CNN
"""
def __init__(self, settings, pre_trained_word_vectors=None):
self.model_name = settings.model_name
self.embedding_size = settings.embedding_size
self.filter_size = settings.filter_size
self.n_filter = settings.n_filters
self.fc_hidden_size = settings.fc_hidden_size
self.n_filter_total = self.n_filter*(len(self.filter_size))
self.n_class = settings.n_class
self.max_words_in_doc = settings.max_words_in_doc
self.vocab_size = settings.vocab_size
""" 定义网络的结构 """
# 输入样本
with tf.name_scope("inputs"):
self._inputs_x = tf.placeholder(tf.int64, [None, self.max_words_in_doc], name="_inputs_x")
self._inputs_y = tf.placeholder(tf.float16, [None, self.n_class], name="_inputs_y")
self._keep_dropout_prob = tf.placeholder(tf.float32, name="_keep_dropout_prob")
# 嵌入层
with tf.variable_scope("embedding"):
if isinstance( pre_trained_word_vectors, np.ndarray): # 使用预训练的词向量
assert isinstance(pre_trained_word_vectors, np.ndarray), "pre_trained_word_vectors must be a numpy's ndarray"
assert pre_trained_word_vectors.shape[1] == self.embedding_size, "number of col of pre_trained_word_vectors must euqals embedding size"
self.embedding = tf.get_variable(name='embedding',
shape=pre_trained_word_vectors.shape,
initializer=tf.constant_initializer(pre_trained_word_vectors),
trainable=True)
else:
self.embedding = tf.Variable(tf.truncated_normal((self.vocab_size, self.embedding_size)))
# conv-pool
inputs = tf.nn.embedding_lookup(self.embedding, self._inputs_x) #[batch_size, words, embedding] # look up layer
inputs = tf.expand_dims(inputs, -1) # [batch_size, words, embedding, 1]
pooled_output = []
for i, filter_size in enumerate(self.filter_size): # filter_size = [2, 3, 4, 5]
with tf.variable_scope("conv-maxpool-%s" % filter_size):
# conv layer
filter_shape = [filter_size, self.embedding_size, 1, self.n_filter]
W = self.weight_variable(shape=filter_shape, name="W_filter")
b = self.bias_variable(shape=[self.n_filter], name="b_filter")
conv = tf.nn.conv2d(inputs, W, strides=[1, 1, 1, 1], padding="VALID", name='text_conv') # [batch, words-filter_size+1, 1, channel]
# apply activation
h = tf.nn.relu(tf.nn.bias_add(conv, b), name="relu")
# max pooling
pooled = tf.nn.max_pool(h, ksize=[1, self.max_words_in_doc - filter_size + 1, 1, 1], strides=[1, 1, 1, 1], padding="VALID", name='max_pool') # [batch, 1, 1, channel]
pooled_output.append(pooled)
h_pool = tf.concat(pooled_output, 3) # concat on 4th dimension
self.h_pool_flat = tf.reshape(h_pool, [-1, self.n_filter_total], name="h_pool_flat")
# add dropout
with tf.name_scope("dropout"):
self.h_dropout = tf.nn.dropout(self.h_pool_flat, self._keep_dropout_prob, name="dropout")
# output layer
with tf.name_scope("output"):
W = self.weight_variable(shape=[self.n_filter_total, self.n_class], name="W_out")
b = self.bias_variable(shape=[self.n_class], name="bias_out")
self.scores = tf.nn.xw_plus_b(self.h_dropout, W, b, name="scores") # class socre
print "self.scores : " , self.scores.get_shape()
self.predictions = tf.argmax(self.scores, 1, name="predictions") # predict label , the output
print "self.predictions : " , self.predictions.get_shape()
# 辅助函数
def weight_variable(self, shape, name):
initial = tf.truncated_normal(shape, stddev=0.1)
return tf.Variable(initial, name=name)
def bias_variable(self, shape, name):
initial = tf.constant(0.1, shape=shape)
return tf.Variable(initial, name=name)
train.py
#coding=utf-8
import tensorflow as tf
from datetime import datetime
import os
from load_data import load_dataset, load_dataset_from_pickle
from cnn_model import TextCNN
from cnn_model import Settings
# Data loading params
tf.flags.DEFINE_string("train_data_path", 'data/train_query_pair_test_data.pickle', "data directory")
tf.flags.DEFINE_string("embedding_W_path", "./data/embedding_matrix.pickle", "pre-trained embedding matrix")
tf.flags.DEFINE_integer("vocab_size", 3627705, "vocabulary size") # **这里需要根据词典的大小设置**
tf.flags.DEFINE_integer("num_classes", 2, "number of classes")
tf.flags.DEFINE_integer("embedding_size", 100, "Dimensionality of character embedding (default: 200)")
tf.flags.DEFINE_integer("batch_size", 256, "Batch Size (default: 64)")
tf.flags.DEFINE_integer("num_epochs", 1, "Number of training epochs (default: 50)")
tf.flags.DEFINE_integer("checkpoint_every", 100, "Save model after this many steps (default: 100)")
tf.flags.DEFINE_integer("num_checkpoints", 5, "Number of checkpoints to store (default: 5)")
tf.flags.DEFINE_integer("max_words_in_doc", 30, "Number of checkpoints to store (default: 5)")
tf.flags.DEFINE_integer("evaluate_every", 100, "evaluate every this many batches")
tf.flags.DEFINE_float("learning_rate", 0.001, "learning rate")
tf.flags.DEFINE_float("keep_prob", 0.5, "dropout rate")
FLAGS = tf.flags.FLAGS
train_x, train_y, dev_x, dev_y, W_embedding = load_dataset_from_pickle(FLAGS.train_data_path, FLAGS.embedding_W_path)
train_sample_n = len(train_y)
print len(train_y)
print len(dev_y)
print "data load finished"
print "W_embedding : ", W_embedding.shape[0], W_embedding.shape[1]
# 模型的参数配置
settings = Settings()
"""
可以配置不同的参数,需要根据训练数据集设置 vocab_size embedding_size
"""
settings.embedding_size = FLAGS.embedding_size
settings.vocab_size = FLAGS.vocab_size
# 设置GPU的使用率
os.environ["CUDA_VISIBLE_DEVICES"] = "1"
gpu_options = tf.GPUOptions(per_process_gpu_memory_fraction=1.0)
sess = tf.Session(config=tf.ConfigProto(gpu_options=gpu_options))
with tf.Session() as sess:
# 在session中, 首先初始化定义好的model
textcnn = TextCNN(settings=settings, pre_trained_word_vectors=W_embedding)
# 在train.py 文件中定义loss和accuracy, 这两个指标不要再model中定义
with tf.name_scope('loss'):
#print textcnn._inputs_y
#print textcnn.predictions
loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=textcnn.scores,
labels=textcnn._inputs_y,
name='loss'))
with tf.name_scope('accuracy'):
#predict = tf.argmax(textcnn.predictions, axis=0, name='predict')
predict = textcnn.predictions # 在模型的定义中, textcnn.predictions 已经是经过argmax后的结果, 在训练.py文件中不能再做一次argmax
label = tf.argmax(textcnn._inputs_y, axis=1, name='label')
#print predict.get_shape()
#print label.get_shape()
acc = tf.reduce_mean(tf.cast(tf.equal(predict, label), tf.float32))
# make一个文件夹, 存放模型训练的中间结果
timestamp = datetime.now().strftime( '%Y-%m-%d %H:%M:%S')
timestamp = "textcnn" + timestamp
out_dir = os.path.abspath(os.path.join(os.path.curdir, "runs", timestamp))
print("Writing to {}\n".format(out_dir))
# 定义一个全局变量, 存放到目前为止,模型优化迭代的次数
global_step = tf.Variable(0, trainable=False)
# 定义优化器, 找出需要优化的变量以及求出这些变量的梯度
optimizer = tf.train.AdamOptimizer(FLAGS.learning_rate)
tvars = tf.trainable_variables()
grads = tf.gradients(loss, tvars)
grads_and_vars = tuple(zip(grads, tvars))
train_op = optimizer.apply_gradients(grads_and_vars, global_step=global_step) # 我理解, global_step应该会在这个函数中自动+1
# 不优化预训练好的词向量
tvars_no_embedding = [tvar for tvar in tvars if 'embedding' not in tvar.name]
grads_no_embedding = tf.gradients(loss, tvars_no_embedding)
grads_and_vars_no_embedding = tuple(zip(grads_no_embedding, tvars_no_embedding))
trian_op_no_embedding = optimizer.apply_gradients(grads_and_vars_no_embedding, global_step=global_step)
# Keep track of gradient values and sparsity (optional)
grad_summaries = []
for g, v in grads_and_vars:
if g is not None:
grad_hist_summary = tf.summary.histogram("{}/grad/hist".format(v.name), g)
grad_summaries.append(grad_hist_summary)
grad_summaries_merged = tf.summary.merge(grad_summaries)
loss_summary = tf.summary.scalar('loss', loss)
acc_summary = tf.summary.scalar('accuracy', acc)
train_summary_op = tf.summary.merge([loss_summary, acc_summary, grad_summaries_merged])
train_summary_dir = os.path.join(out_dir, "summaries", "train")
train_summary_writer = tf.summary.FileWriter(train_summary_dir, sess.graph)
dev_summary_op = tf.summary.merge([loss_summary, acc_summary])
dev_summary_dir = os.path.join(out_dir, "summaries", "dev")
dev_summary_writer = tf.summary.FileWriter(dev_summary_dir, sess.graph)
# save model
checkpoint_dir = os.path.abspath(os.path.join(out_dir, "checkpoints"))
checkpoint_prefix = os.path.join(checkpoint_dir, "model")
if not os.path.exists(checkpoint_dir):
os.makedirs(checkpoint_dir)
#saver = tf.train.Saver(tf.global_variables(), max_to_keep=FLAGS.num_checkpoints)
saver = tf.train.Saver(tf.global_variables(), max_to_keep=2)
#saver.save(sess, checkpoint_prefix, global_step=FLAGS.num_checkpoints)
# 初始化多有的变量
sess.run(tf.global_variables_initializer())
def train_step(x_batch, y_batch):
feed_dict = {
textcnn._inputs_x: x_batch,
textcnn._inputs_y: y_batch,
textcnn._keep_dropout_prob: 0.5
}
_, step, summaries, cost, accuracy = sess.run([train_op, global_step, train_summary_op, loss, acc], feed_dict)
#print tf.shape(y_batch)
#print textcnn.predictions.get_shape()
#time_str = str(int(time.time()))
time_str = datetime.now().strftime( '%Y-%m-%d %H:%M:%S')
print("{}: step {}, loss {:g}, acc {:g}".format(time_str, step, cost, accuracy))
train_summary_writer.add_summary(summaries, step)
return step
def train_step_no_embedding(x_batch, y_batch):
feed_dict = {
textcnn._inputs_x: x_batch,
textcnn._inputs_y: y_batch,
textcnn._keep_dropout_prob: 0.5
}
_, step, summaries, cost, accuracy = sess.run([train_op_no_embedding, global_step, train_summary_op, loss, acc], feed_dict)
time_str = datetime.now().strftime( '%Y-%m-%d %H:%M:%S')
print("{}: step {}, loss {:g}, acc {:g}".format(time_str, step, cost, accuracy))
train_summary_writer.add_summary(summaries, step)
return step
def dev_step(x_batch, y_batch, writer=None):
feed_dict = {
textcnn._inputs_x: x_batch,
textcnn._inputs_y: y_batch,
textcnn._keep_dropout_prob: 1.0
}
step, summaries, cost, accuracy = sess.run([global_step, dev_summary_op, loss, acc], feed_dict)
#time_str = str(int(time.time()))
time_str = datetime.now().strftime( '%Y-%m-%d %H:%M:%S')
print("++++++++++++++++++dev++++++++++++++{}: step {}, loss {:g}, acc {:g}".format(time_str, step, cost, accuracy))
if writer:
writer.add_summary(summaries, step)
for epoch in range(FLAGS.num_epochs):
print('current epoch %s' % (epoch + 1))
for i in range(0, train_sample_n, FLAGS.batch_size):
x = train_x[i:i + FLAGS.batch_size]
y = train_y[i:i + FLAGS.batch_size]
step = train_step(x, y)
if step % FLAGS.evaluate_every == 0:
dev_step(dev_x, dev_y, dev_summary_writer)
if step % FLAGS.checkpoint_every == 0:
path = saver.save(sess, checkpoint_prefix, global_step=FLAGS.num_checkpoints)
print "Saved model checkpoint to {}\n".format(path)
写tensorflow代码的关键在于定义网络结构,多看好代码,仔细揣摩其中定义网络结构的代码模式很重要。另外,对tensorflow中每一个API输入、输出tensor也要了解,特别是tensor的shape,这个在实际中最容易出错。
在工作用到TextCNN做query推荐,并结合先关的文献,谈几点经验:
1、TextCNN是一个n-gram特征提取器,对于训练集中没有的n-gram不能很好的提取。对于有些n-gram,可能过于强烈,反而会干扰模型,造成误分类。
2、TextCNN对词语的顺序不敏感,在query推荐中,我把正样本分词后得到的term做随机排序,正确率并没有降低太多,当然,其中一方面的原因短query本身对term的顺序要求不敏感。隔壁组有用textcnn做博彩网页识别,正确率接近95%,在对网页内容(长文本)做随机排序后,正确率大概是85%。
3、TextCNN擅长长本文分类,在这一方面可以做到很高正确率。
4、TextCNN在模型结构方面有很多参数可调,具体参看文末的文献。
《Convolutional Neural Networks for Sentence Classification》
《A Sensitivity Analysis of (and Practitioners’ Guide to) Convolutional Neural Networks for Sentence Classification》