大规模文本分类网络TextCNN介绍

TextCNN网络是2014年提出的用来做文本分类的卷积神经网络,由于其结构简单、效果好,在文本分类、推荐等NLP领域应用广泛,我自己在工作中也有探索其在实际当中的应用,今天总结一下。

TextCNN的网络结构

数据预处理

再将TextCNN网络的具体结构之前,先讲一下TextCNN处理的是什么样的数据以及需要什么样的数据输入格式。假设现在有一个文本分类的任务,我们需要对一段文本进行分类来判断这个文本是是属于哪个类别:体育、经济、娱乐、科技等。训练数据集如下示意图:
大规模文本分类网络TextCNN介绍_第1张图片
第一列是文本的内容,第二列是文本的标签。首先需要对数据集进行处理,步骤如下:
- 分词 中文文本分类需要分词,有很多开源的中文分词工具,例如Jieba等。分词后还会做进一步的处理,去除掉一些高频词汇和低频词汇,去掉一些无意义的符号等。
- 建立词典以及单词索引 建立词典就是统计文本中出现多少了单词,然后为每个单词编码一个唯一的索引号,便于查找。如果对以上词典建立单词索引,结果如下图示意:
大规模文本分类网络TextCNN介绍_第2张图片
上面的词典表明,“谷歌”这个单词,可以用数字 0 来表示,“乐视”这个单词可以用数字 1 来表示。
- 将训练文本用单词索引号表示 在上面的单词-索引表示下,训练示例中的第一个文本样本可以用如下的一串数字表示:
这里写图片描述
到这里文本的预处理工作基本全部完成,将自然语言组成的训练文本表示成离散的数据格式,是处理NLP工作的第一步。

TextCNN结构

TextCNN的结构比较简单,输入数据首先通过一个embedding layer,得到输入语句的embedding表示,然后通过一个convolution layer,提取语句的特征,最后通过一个fully connected layer得到最终的输出,整个模型的结构如下图:
大规模文本分类网络TextCNN介绍_第3张图片
上图是论文中给出的视力图,下面分别介绍每一层。
- 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个卷积核。大规模文本分类网络TextCNN介绍_第4张图片
上图是从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版),附上,有不足之处,望大家指出。

TextCNN的代码实现

写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》

你可能感兴趣的:(机器学习)