1. 程式人生 > >大規模文字分類網路TextCNN介紹

大規模文字分類網路TextCNN介紹

TextCNN網路是2014年提出的用來做文字分類的卷積神經網路,由於其結構簡單、效果好,在文字分類、推薦等NLP領域應用廣泛,我自己在工作中也有探索其在實際當中的應用,今天總結一下。

TextCNN的網路結構

資料預處理

再將TextCNN網路的具體結構之前,先講一下TextCNN處理的是什麼樣的資料以及需要什麼樣的資料輸入格式。假設現在有一個文字分類的任務,我們需要對一段文字進行分類來判斷這個文字是是屬於哪個類別:體育、經濟、娛樂、科技等。訓練資料集如下示意圖:
這裡寫圖片描述
第一列是文字的內容,第二列是文字的標籤。首先需要對資料集進行處理,步驟如下:
- 分詞 中文文字分類需要分詞,有很多開源的中文分詞工具,例如Jieba等。分詞後還會做進一步的處理,去除掉一些高頻詞彙和低頻詞彙,去掉一些無意義的符號等。
- 建立詞典以及單詞索引

建立詞典就是統計文字中出現多少了單詞,然後為每個單詞編碼一個唯一的索引號,便於查詢。如果對以上詞典建立單詞索引,結果如下圖示意:
這裡寫圖片描述
上面的詞典表明,“谷歌”這個單詞,可以用數字 0 來表示,“樂視”這個單詞可以用數字 1 來表示。
- 將訓練文字用單詞索引號表示 在上面的單詞-索引表示下,訓練示例中的第一個文字樣本可以用如下的一串數字表示:
這裡寫圖片描述
到這裡文字的預處理工作基本全部完成,將自然語言組成的訓練文字表示成離散的資料格式,是處理NLP工作的第一步。

TextCNN結構

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版),附上,有不足之處,望大家指出。

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》