大規模文字分類網路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是卷積核的長度,|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》