本文轉載自「ModifyAI」
作者:容順林,日本早稻田大學碩士畢業,資深算法工程師,多年自然語言處理相關經驗
前言:最近看到關於TextCNN的幾篇文章,寫的不錯,故轉載過來分享給大家。原作者準備寫一個系列,每周更新一篇,現在更新了兩篇,後續還有幾篇更新,對TextCNN文本分類和自然語言處理感興趣的可以後續關注公眾號ModifyAI
正文:
今天主要講TextCNN的基本原理和優劣勢,包括網絡結構、如何更新參數以及應用場景等。
TextCNN是什麼我們之前提前CNN時,通常會認為是屬於CV領域,用於計算機視覺方向的工作,但是在2014年,Yoon Kim針對CNN的輸入層做了一些變形,提出了文本分類模型TextCNN。與傳統圖像的CNN網絡相比, TextCNN 在網絡結構上沒有任何變化(甚至更加簡單了), 從圖一可以看出TextCNN 其實只有一層卷積,一層max-pooling, 最後將輸出外接softmax 來進行多分類。
圖一 TextCNN 網絡結構
與圖像領域的CNN的網絡相比,TextCNN 最大的不同是在輸入數據不同:圖像是二維數據, 圖像的卷積核是從左到右, 從上到下進行滑動來進行特徵抽取。
自然語言是一維數據, 雖然經過word-embedding 生成了二維向量,但是對詞向量做從左到右滑動來進行卷積沒有意義. 比如 "今天" 對應的向量[0, 0, 0, 0, 1], 按窗口大小為 1* 2 從左到右滑動得到[0,0], [0,0], [0,0], [0, 1]這四個向量, 對應的都是"今天"這個詞彙, 這種滑動沒有幫助.
TextCNN的成功, 不是網絡結構的成功, 而是通過引入已經訓練好的詞向量來在多個數據集上達到了超越benchmark 的表現,進一步證明了構造更好的embedding, 是提升nlp 各項任務的關鍵能力。
TextCNN的優勢textCNN最大優勢網絡結構簡單 ,在模型網絡結構如此簡單的情況下,通過引入已經訓練好的詞向量依舊有很不錯的效果,在多項數據數據集上超越benchmark。
網絡結構簡單導致參數數目少, 計算量少, 訓練速度快,在單機單卡的v100機器上,訓練165萬數據, 迭代26萬步,半個小時左右可以收斂。
TextCNN的具體流程1.Word Embedding 分詞構建詞向量
如圖二 所示, textCNN 首先將 "今天天氣很好,出來玩" 分詞成"今天/天氣/很好/,/出來/玩, 通過word2vec或者GLOV 等embedding 方式將每個詞成映射成一個5維(維數可以自己指定)詞向量, 如 "今天" -> [0,0,0,0,1], "天氣" ->[0,0,0,1,0], "很好" ->[0,0,1,0,0]等等。圖二 Word Embedding
這樣做的好處主要是將自然語言數值化,方便後續的處理。從這裡也可以看出不同的映射方式對最後的結果是會產生巨大的影響, nlp 當中目前最火熱的研究方向便是如何將自然語言映射成更好的詞向量。我們構建完詞向量後,將所有的詞向量拼接起來構成一個6*5的二維矩陣,作為最初的輸入。2. Convolution 卷積
圖三 卷積示意圖
卷積是一種數學算子。我們用一個簡單的例子來說明一下
step.1 將 "今天"/"天氣"/"很好"/"," 對應的4*5 矩陣 與卷積核做一個point wise 的乘法然後求和, 便是卷積操作:
feature_map[0] =0*1 + 0*0 + 0*1 + 0*0 + 1*0 + //(第一行)
0*0 + 0*0 + 0*0 + 1*0 + 0*0 + //(第二行)
0*1 + 0*0 + 1*1 + 0*0 + 0*0 + //(第三行)
0*1 + 1*0 + 0*1 + 0*0 + 0*0 //(第四行)
= 1
step.2 將窗口向下滑動一格(滑動的距離可以自己設置),"天氣"/"很好"/","/"出來" 對應的4*5 矩陣 與卷積核(權值不變) 繼續做point wise 乘法後求和
feature_map[1] = 0*1 + 0*0 + 0*1 + 1*0 + 0*0 + //(第一行)
0*0 + 0*0 + 1*0 + 0*0 + 0*0 + //(第二行)
0*1 + 1*0 + 0*1 + 0*0 + 0*0 +//(第三行)
1*1 + 0*0 + 0*1 + 0*0 + 0*0 //(第四行)
= 1
step.3 將窗口向下滑動一格(滑動的距離可以自己設置) "很好"/","/"出來"/"玩" 對應的4*5 矩陣 與卷積核(權值不變) 繼續做point wise 乘法後求和
feature_map[2] = 0*1 + 0*0 + 1*1 + 1*0 + 0*0 +//(第一行)
0*0 + 1*0 + 0*0 + 0*0 + 0*0 + //(第二行)
1*1 + 0*0 + 0*1 + 0*0 + 0*0 +//(第三行)
0*1 + 0*0 + 0*1 + 1*0 + 1*0 //(第四行)
= 2
feature_map 便是卷積之後的輸出, 通過卷積操作 將輸入的6*5 矩陣映射成一個 3*1 的矩陣,這個映射過程和特徵抽取的結果很像,於是便將最後的輸出稱作feature map。一般來說在卷積之後會跟一個激活函數,在這裡為了簡化說明需要,我們將激活函數設置為f(x) = x3. 關於channel 的說明
圖四 channel 說明
CNN 中常常會提到一個詞channel, 圖三 中 深紅矩陣與 淺紅矩陣 便構成了兩個channel 統稱一個卷積核, 從這個圖中也可以看出每個channel 不必嚴格一樣, 每個4*5 矩陣與輸入矩陣做一次卷積操作得到一個feature map. 在計算機視覺中,由於彩色圖像存在 R, G, B 三種顏色, 每個顏色便代表一種channel。根據原論文作者的描述, 一開始引入channel 是希望防止過擬合(通過保證學習到的vectors 不要偏離輸入太多)來在小數據集合獲得比單channel更好的表現,後來發現其實直接使用正則化效果更好。
不過使用多channel 相比與單channel, 每個channel 可以使用不同的word embedding, 比如可以在no-static(梯度可以反向傳播) 的channel 來fine tune 詞向量,讓詞向量更加適用於當前的訓練。
對於channel在textCNN 是否有用, 從論文的實驗結果來看多channels並沒有明顯提升模型的分類能力, 七個數據集上的五個數據集 單channel 的textCNN 表現都要優於 多channels的textCNN。
圖五 textCNN 實驗
我們在這裡也介紹一下論文中四個model 的不同
CNN-rand (單channel), 設計好 embedding_size 這個 Hyperparameter 後, 對不同單詞的向量作隨機初始化, 後續BP的時候作調整.
CNN-static(單channel), 拿 pre-trained vectors from word2vec, FastText or GloVe 直接用, 訓練過程中不再調整詞向量.
CNN-non-static(單channel), pre-trained vectors + fine tuning , 即拿word2vec訓練好的詞向量初始化, 訓練過程中再對它們微調.
4.max-pooling
圖六: max-pooling 說明
得到feamap = [1,1,2] 後, 從中選取一個最大值[2] 作為輸出, 便是max-pooling。max-pooling 在保持主要特徵的情況下, 大大降低了參數的數目, 從圖五中可以看出 feature map 從 三維變成了一維, 好處有如下兩點:降低了過擬合的風險, feature map = [1, 1, 2] 或者[1, 0, 2] 最後的輸出都是[2], 表明開始的輸入即使有輕微變形, 也不影響最後的識別。
參數減少, 進一步加速計算。
pooling 本身無法帶來平移不變性(圖片有個字母A, 這個字母A 無論出現在圖片的哪個位置, 在CNN的網絡中都可以識別出來),卷積核的權值共享才能. max-pooling的原理主要是從多個值中取一個最大值,做不到這一點。cnn 能夠做到平移不變性,是因為在滑動卷積核的時候,使用的卷積核權值是保持固定的(權值共享), 假設這個卷積核被訓練的就能識別字母A, 當這個卷積核在整張圖片上滑動的時候,當然可以把整張圖片的A都識別出來。5.使用softmax k分類
圖六:softmax 示意圖
如圖六所示, 我們將 max-pooling的結果拼接起來, 送入到softmax當中, 得到各個類別比如 label 為1 的概率以及label 為-1的概率。如果是預測的話,到這裡整個textCNN的流程遍結束了。如果是訓練的話,此時便會根據預測label以及實際label來計算損失函數, 計算出softmax 函數,max-pooling 函數, 激活函數以及卷積核函數 四個函數當中參數需要更新的梯度, 來依次更新這四個函數中的參數,完成一輪訓練 。TextCNN的總結本次我們介紹的textCNN是一個應用了CNN網絡的文本分類模型。textCNN的流程是先將文本分詞做embeeding得到詞向量, 將詞向量經過一層卷積,一層max-pooling, 最後將輸出外接softmax 來做n分類。
textCNN 的優勢在於模型簡單, 訓練速度快,效果不錯。
textCNN的缺點在於模型可解釋型不強,在調優模型的時候,很難根據訓練的結果去針對性的調整具體的特徵,因為在textCNN中沒有類似gbdt模型中特徵重要度(feature importance)的概念, 所以很難去評估每個特徵的重要度。
關於如何調優textCNN的經驗總結,會在第三篇來總結,在下一篇,我們會來聊聊textCNN的具體代碼實現。
textCNN 整體框架1. 模型架構
圖一:textCNN 模型結構示意
2. 代碼架構
圖二: 代碼架構說明
3.代碼地址
項目地址 https://github.com/rongshunlin/ModifyAI.git
部分代碼參考了
https://github.com/dennybritz/cnn-text-classification-tf
4.訓練效果說明:
圖三:訓練效果展示
textCNN model 代碼介紹
2.1 wordEmbedding
圖四:WordEmbedding 例子說明
# embedding layer
with tf.name_scope("embedding"):
self.W = tf.Variable(tf.random_uniform([self._config.vocab_size, self._config.embedding_dim], -1.0, 1.0),
name="W")
self.char_emb = tf.nn.embedding_lookup(self.W, self.input_x)
self.char_emb_expanded = tf.expand_dims(self.char_emb, -1)
tf.logging.info("Shape of embedding_chars:{}".format(str(self.char_emb_expanded.shape)))
簡要說明:
vocab_size: 詞典大小18758
embedding_dim: 詞向量大小 為128
seq_length: 句子長度,設定最長為56
embedding_look: 查表操作 根據每個詞的位置id 去初始化的w中尋找對應id的向量. 得到一個tensor :[batch_size, seq_length, embedding_size] 既 [?, 56, 128], 此處? 表示batch, 即不知道會有多少輸入。
舉例說明:我們有一個詞典大小為3的詞典,一共對應三個詞 「今天」,「天氣」 「很好「,w =[[0,0,0,1],[0,0,1,0],[0,1,0,0]]。
我們有兩個句子,」今天天氣「,經過預處理後輸入是[0,1]. 經過embedding_lookup 後,根據0 去查找 w 中第一個位置的向量[0,0,0,1], 根據1去查找 w 中第二個位置的向量[0,0,1,0] 得到我們的char_emb [[0,0,0,1],[0,0,1,0]]
同理,「天氣很好」,預處理後是[1,2]. 經過經過embedding_lookup 後, 得到 char_emb 為[[0,0,1,0],[0,1,0,0]]
因為, 卷積神經網conv2d是需要接受四維向量的,故將char_embdding 增廣一維,從 [?, 56, 128] 增廣到[?, 56, 128, 1]
2.2 Convolution 卷積 + Max-Pooling
圖五:卷積例子說明
pooled_outputs = []
for i, filter_size in enumerate(self._config.filter_sizes):
with tf.variable_scope("conv-maxpool-%s" % filter_size):
filter_width = self._config.embedding_dim
input_channel_num = 1
output_channel_num = self._config.num_filters
filter_shape = [filter_size, filter_width, input_channel_num, output_channel_num]
n = filter_size * filter_width * input_channel_num
kernal = tf.get_variable(name="kernal",
shape=filter_shape,
dtype=tf.float32,
initializer=tf.random_normal_initializer(stddev=np.sqrt(2.0 / n)))
bias = tf.get_variable(name="bias",
shape=[output_channel_num],
dtype=tf.float32,
initializer=tf.zeros_initializer)
conv = tf.nn.conv2d(
input=self.char_emb_expanded,
filter=kernal,
strides=[1, 1, 1, 1],
padding="VALID",
name="cov")
tf.logging.info("Shape of Conv:{}".format(str(conv.shape)))
h = tf.nn.relu(tf.nn.bias_add(conv, bias), name="relu")
tf.logging.info("Shape of h:{}".format(str(h)))
pooled = tf.nn.max_pool(
value=h,
ksize=[1, self._config.max_seq_length - filter_size + 1, 1, 1],
strides=[1, 1, 1, 1],
padding="VALID",
name="pool"
)
tf.logging.info("Shape of pooled:{}".format(str(pooled.shape)))
pooled_outputs.append(pooled)
tf.logging.info("Shape of pooled_outputs:{}".format(str(np.array(pooled_outputs).shape)))
total_filter_num = self._config.num_filters * len(self._config.filter_sizes)
all_features = tf.reshape(tf.concat(pooled_outputs, axis=-1), [-1, total_filter_num])
tf.logging.info("Shape of all_features:{}".format(str(all_features.shape)))
簡要說明:
filter_size= 3,4,5. 每個filter 的寬度與詞向量等寬,這樣只能進行一維滑動。
每一種filter卷積後,結果輸出為[batch_size, seq_length - filter_size +1,1,num_filter]的tensor。
由於我們有三種filter_size, 故會得到三種tensor
第一種 tensor, filter_size 為 3處理後的,[?,56-3+1,1, 128] -> [?,54,1, 128]
第二種 tensor, filter_size 為 4處理後的,[?,56-4+1,1, 128] -> [?,53,1, 128]
第三種 tensor, filter_size 為 5處理後的,[?,56-5+1,1, 128] -> [?,52,1, 128]
再用ksize=[?,seq_length - filter_size + 1,1,1]進行max_pooling,得到[?,1,1,num_filter]這樣的tensor. 經過max_pooling 後
第一種 tensor, [?,54,1, 128] –> [?,1,1, 128]
第二種 tensor, [?,53,1, 128] -> [?,1,1, 128]
第三種 tensor, [?,52,1, 128] -> [?,1,1, 128]
將得到的三種結果進行組合,得到[?,1,1,num_filter*3]的tensor.最後將結果變形一下[-1,num_filter*3],目的是為了下面的全連接
[?,1,1, 128], [?,1,1, 128], [?,1,1, 128] –> [?, 384]
2.3 使用softmax k分類
圖六:softmax 示意
簡要說明:
label_size 為 文本分類類別數目,這裡是二分類,然後得到輸出的結果scores,以及得到預測類別在標籤詞典中對應的數值predicitons。使用交叉墒求loss.
with tf.name_scope("output"):
W = tf.get_variable(
name="W",
shape=[total_filter_num, self._config.label_size],
initializer=tf.contrib.layers.xavier_initializer())
b = tf.Variable(tf.constant(0.1, shape=[self._config.label_size]), name="b")
l2_loss += tf.nn.l2_loss(W)
l2_loss += tf.nn.l2_loss(b)
self.scores = tf.nn.xw_plus_b(all_features, W, b, name="scores")
self.predictions = tf.argmax(self.scores, 1, name="predictions")
with tf.name_scope("loss"):
losses = tf.nn.softmax_cross_entropy_with_logits(logits=self.scores, labels=self.input_y)
self.loss = tf.reduce_mean(losses) + self._config.l2_reg_lambda * l2_loss
簡要說明:利用數據預處理模塊加載數據,優化函數選擇adam, 每個batch為64. 進行處理
def train(x_train, y_train, vocab_processor, x_dev, y_dev, model_config):
with tf.Graph().as_default():
sess = tf.Session()
with sess.as_default():
cnn = TextCNNModel(
config=model_config,
is_training=FLAGS.is_train
)
global_step = tf.Variable(0, name="global_step", trainable=False)
optimizer = tf.train.AdamOptimizer(1e-3)
grads_and_vars = optimizer.compute_gradients(cnn.loss)
train_op = optimizer.apply_gradients(grads_and_vars, global_step=global_step)
checkpoint_dir = os.path.abspath(os.path.join(FLAGS.output_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.keep_checkpoint_max)
vocab_processor.save(os.path.join(FLAGS.output_dir, "vocab"))
sess.run(tf.global_variables_initializer())
def train_step(x_batch, y_batch):
"""
A singel training step
:param x_batch:
:param y_batch:
:return:
"""
feed_dict = {
cnn.input_x: x_batch,
cnn.input_y: y_batch
}
_, step, loss, accuracy = sess.run(
[train_op, global_step, cnn.loss, cnn.accuracy],
feed_dict)
time_str = datetime.datetime.now().isoformat()
tf.logging.info("{}: step {}, loss {:g}, acc {:g}".format(time_str, step, loss, accuracy))
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
}
step, loss, accuracy = sess.run(
[global_step, cnn.loss, cnn.accuracy],
feed_dict)
time_str = datetime.datetime.now().isoformat()
tf.logging.info("{}: step {}, loss {:g}, acc {:g}".format(time_str, step, loss, accuracy))
batches = data.DataSet.batch_iter(list(zip(x_train, y_train)), FLAGS.batch_size, FLAGS.num_epochs)
for batch in batches:
x_batch, y_batch = zip(*batch)
train_step(x_batch, y_batch)
current_step = tf.train.global_step(sess, global_step)
if current_step % FLAGS.save_checkpoints_steps == 0:
tf.logging.info("\nEvaluation:")
dev_step(x_dev, y_dev)
if current_step % FLAGS.save_checkpoints_steps == 0:
path = saver.save(sess, checkpoint_prefix, global_step=current_step)
tf.logging.info("Saved model checkpoint to {}\n".format(path))
簡要說明:處理輸入數據,包括導入數據data和標籤label,採用正則化的一些方法清除格式等,最後再寫了一個函數batch_iter來分批導入數據。
class DataSet(object):
def __init__(self, positive_data_file, negative_data_file):
self.x_text, self.y = self.load_data_and_labels(positive_data_file, negative_data_file)
def load_data_and_labels(self, positive_data_file, negative_data_file):
positive_data = list(open(positive_data_file, "r", encoding='utf-8').readlines())
positive_data = [s.strip() for s in positive_data]
negative_data = list(open(negative_data_file, "r", encoding='utf-8').readlines())
negative_data = [s.strip() for s in negative_data]
x_text = positive_data + negative_data
x_text = [self.clean_str(sent) for sent in x_text]
positive_labels = [[0, 1] for _ in positive_data]
negative_labels = [[1, 0] for _ in negative_data]
y = np.concatenate([positive_labels, negative_labels], 0)
return [x_text, y]
def clean_str(self, string):
"""
Tokenization/string cleaning for all datasets except for SST.
Original taken from https://github.com/yoonkim/CNN_sentence/blob/master/process_data.py
"""
string = re.sub(r"[^A-Za-z0-9(),!?\'\`]", " ", string)
string = re.sub(r"\'s", " \'s", string)
string = re.sub(r"\'ve", " \'ve", string)
string = re.sub(r"n\'t", " n\'t", string)
string = re.sub(r"\'re", " \'re", string)
string = re.sub(r"\'d", " \'d", string)
string = re.sub(r"\'ll", " \'ll", string)
string = re.sub(r",", " , ", string)
string = re.sub(r"!", " ! ", string)
string = re.sub(r"\(", " \( ", string)
string = re.sub(r"\)", " \) ", string)
string = re.sub(r"\?", " \? ", string)
string = re.sub(r"\s{2,}", " ", string)
return string.strip().lower()
def batch_iter(data, batch_size, num_epochs, shuffle=True):
"""
Generates a batch iterator for a dataset.
"""
data = np.array(data)
data_size = len(data)
num_batches_per_epoch = int((len(data) - 1) / batch_size) + 1
for epoch in range(num_epochs):
if shuffle:
shuffle_indices = np.random.permutation(np.arange(data_size))
shuffled_data = data[shuffle_indices]
else:
shuffled_data = data
for batch_num in range(num_batches_per_epoch):
start_index = batch_num * batch_size
end_index = min((batch_num + 1) * batch_size, data_size)
yield shuffled_data[start_index:end_index]
簡要說明:修改code_dir , 執行train-eval.sh 即可執行。在腳本中執行可以
#!/bin/bash
export CUDA_VISIBLE_DEVICES=0
CODE_DIR="/home/work/work/modifyAI/textCNN"
MODEL_DIR=$CODE_DIR/model
TRAIN_DATA_DIR=$CODE_DIR/data_set
nohup python3 $CODE_DIR/model.py \
--is_train=true \
--num_epochs=200 \
--save_checkpoints_steps=100 \
--keep_checkpoint_max=50 \
--batch_size=64 \
--positive_data_file=$TRAIN_DATA_DIR/polarity.pos \
--negative_data_file=$TRAIN_DATA_DIR/polarity.neg \
--model_dir=$MODEL_DIR > $CODE_DIR/train_log.txt 2>&1 &