1. 程式人生 > >用Tensorflow實現CNN文字分類(詳細解釋及TextCNN程式碼解釋)

用Tensorflow實現CNN文字分類(詳細解釋及TextCNN程式碼解釋)

Ox00: Motivation最近在研究Yoon Kim的一篇經典之作Convolutional Neural Networks for Sentence Classification,這篇文章可以說是cnn模型用於文字分類的開山之作(其實第一個用的不是他,但是Kim提出了幾個variants,並有詳細的調參)
wildml對這篇paper有一個tensorflow的實現,具體參見here。其實blog已經寫的很詳細了,但是對於剛入手tensorflow的新人來說程式碼可能仍存在一些細節不太容易理解,我也是初學,就簡單總結下自己的理解,如果對讀者有幫助那將是極好的。
Ox01: Start!我主要對TextCNN這個類進行解讀,具體程式碼在

這裡

研究別人程式碼時,時常問自己幾個問題,由問題切入,在讀的過程中找答案,這種方式我個人認為是最efficient的
1 這個class的主要作用是什麼?TextCNN類搭建了一個最basic的CNN模型,有input layer,convolutional layer,max-pooling layer和最後輸出的softmax layer。
但是又因為整個模型是用於文字的(而非CNN的傳統處理物件:影象),因此在cnn的操作上相對應地做了一些小調整:
  • 對於文字任務,輸入層自然使用了word embedding來做input data representation。
  • 接下來是卷積層,大家在影象處理中經常看到的卷積核都是正方形的,比如4*4,然後在整張image上沿寬和高逐步移動進行卷積操作。但是nlp中輸入的“image”是一個詞矩陣,比如n個words,每個word用200維的vector表示的話,這個”image”就是n*200的矩陣,卷積核只在高度上已經滑動,在寬度上和word vector的維度一致(=200),也就是說每次視窗滑動過的位置都是完整的單詞,不會將幾個單詞的一部分“vector”進行卷積,這也保證了word作為語言中最小粒度的合理性。(當然,如果研究的粒度是character-level而不是word-level,需要另外的方式處理)
  • 由於卷積核和word embedding的寬度一致,一個卷積核對於一個sentence,卷積後得到的結果是一個vector, shape=(sentence_len - filter_window + 1, 1),那麼,在max-pooling後得到的就是一個Scalar。所以,這點也是和影象卷積的不同之處,需要注意一下。
  • 正是由於max-pooling後只是得到一個scalar,在nlp中,會實施多個filter_window_size(比如3,4,5個words的寬度分別作為卷積的視窗大小),每個window_size又有num_filters個(比如64個)卷積核。一個卷積核得到的只是一個scalar太孤單了,智慧的人們就將相同window_size卷積出來的num_filter個scalar組合在一起,組成這個window_size下的feature_vector。
  • 最後再將所有window_size下的feature_vector也組合成一個single vector,作為最後一層softmax的輸入。
重要的事情說三遍:一個卷積核對於一個句子,convolution後得到的是一個vector;max-pooling後,得到的是一個scalar。
如果對上述講解還有什麼不理解的地方,請移步wildml的另一篇blog,包教包會。
說了這麼多,總結一下這個類的作用就是:搭建一個用於文字資料的CNN模型!
2 一些引數既然TextCNN類是基於YoonKim的思路搭建的,那麼我們接下來一個很重要的步驟就是將paper中提到的各種引數設定都整理出來,有一些引數是關於模型的,有一些引數是關於training的,比如epoch等,這類引數就和模型本身無關,以此來確定我們的TextCNN類需要傳遞哪些引數來初始化。
趕緊把paper開啟,來仔細找找引數吧。
3.1節Hyperparameters and Training部分講到一些,還有一部分在Table1中:
關於model
  • filter windows: [3,4,5]
  • filter maps: 100 for each filter window
  • dropout rate: 0.5
  • l2 constraint: 3
  • randomly select 10% of training data as dev set(early stopping)
  • word2vec(google news) as initial input, dim = 300
  • sentence of length: n, padding where necessary
  • number of target classes
  • dataset size
  • vocabulary size
關於training
  • mini batch size: 50
  • shuffuled mini batch
  • Adadelta update rule: similar results to Adagrad but required fewer epochs
  • Test method: standard train/test split ot CV
3 Dropout注意事項正則是解決過擬合的問題,在最後一層softmax的時候是full-connected layer,因此容易產生過擬合。
策略就是在:
訓練階段,對max-pooling layer的輸出實行一些dropout,以概率p啟用,啟用的部分傳遞給softmax層。
測試階段,w已經學好了,但是不能直接用於unseen sentences,要乘以p之後再用,這個階段沒有dropout了全部輸出給softmax層。
4 Embedding Layer
1
2
3
4
5
6
7
# Embedding layer
with tf.device('/cpu:0'), tf.name_scope("embedding"):
    W = tf.Variable(
        tf.random_uniform([vocab_size, embedding_size], -1.0, 1.0),
        name="W")
    self.embedded_chars = tf.nn.embedding_lookup(W, self.input_x)
    self.embedded_chars_expanded = tf.expand_dims(self.embedded_chars, -1)
儲存全部word vector的矩陣<span tabindex="0" class="MathJax" id="MathJax-Element-1-Frame" role="presentation" style="display: inline-block; position: relative;" data-mathml='W'>WW初始化時是隨機random出來的,也就是paper中的第一種模型CNN-rand
訓練過程中並不是每次都會使用全部的vocabulary,而只是產生一個batch(batch中都是sentence,每個sentence標記了出現哪些word(最大長度為sequence_length),因此batch相當於一個二維列表),這個batch就是input_x。
1
self.input_x = tf.placeholder(tf.int32, [None, sequence_length], name="input_x")

tf.nn.embedding_lookup:查詢input_x中所有的ids,獲取它們的word vector。batch中的每個sentence的每個word都要查詢。所以得到的embedded_chars的shape應該是[None, sequence_length, embedding_size](1)
但是,輸入的word vectors得到之後,下一步就是輸入到卷積層,用到tf.nn.conv2d函式,
再看看conv2d的引數列表:
input: [batch, in_height, in_width, in_channels](2)
filter: [filter_height, filter_width, in_channels, out_channels](3)
對比(1)(2)可以發現,就差一個in_channels了,而最simple的版本也就只有1通道(Yoon的第四個模型用到了multichannel)
因此需要expand dim來適應conv2d的input要求,萬能的tensorflow已經提供了這樣的功能:
This operation is useful if you want to add a batch dimension to a single element. For example, if you have a single image of shape [height, width, channels], you can make it a batch of 1 image with expand_dims(image, 0), which will make the shape [1, height, width, channels].
Example:
# ‘t’ is a tensor of shape [2]
shape(expand_dims(t, -1)) ==> [2, 1]
因此只需要
1
tf.expand_dims(self.embedded_chars, -1)

就能在embedded_chars後面加一個in_channels=1
5 Conv and Max-pooling
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Create a convolution + maxpool layer for each filter size
pooled_outputs = []
for i, filter_size in enumerate(filter_sizes):
    with tf.name_scope("conv-maxpool-%s" % filter_size):
        # Convolution Layer
        filter_shape = [filter_size, embedding_size, 1, num_filters]
        W = tf.Variable(tf.truncated_normal(filter_shape, stddev=0.1), name="W")
        b = tf.Variable(tf.constant(0.1, shape=[num_filters]), name="b")
        conv = tf.nn.conv2d(
            self.embedded_chars_expanded,
            W,
            strides=[1, 1, 1, 1],
            padding="VALID",
            name="conv")
        # Apply nonlinearity
        h = tf.nn.relu(tf.nn.bias_add(conv, b), name="relu")
        # Maxpooling over the outputs
        pooled = tf.nn.max_pool(
            h,
            ksize=[1, sequence_length - filter_size + 1, 1, 1],
            strides=[1, 1, 1, 1],
            padding='VALID',
            name="pool")
        pooled_outputs.append(pooled)

# Combine all the pooled features
num_filters_total = num_filters * len(filter_sizes)
self.h_pool = tf.concat(3, pooled_outputs)
self.h_pool_flat = tf.reshape(self.h_pool, [-1, num_filters_total])
首先,對filter_sizes中的每一個filter_window_size都要進行卷積(每一種size都要產生num_filters那麼多個filter maps),所以外層就是一個大的for迴圈。
繼續,看到了一個比較陌生的函式tf.name_scope('xxx')
這個函式的作用參見官方文件
由於在for迴圈內部,filter_size是固定了的,因此可以結合(3):[filter_height, filter_width, in_channels, out_channels]得到,filter_shape = [filter_size, embedding_size, 1, num_filters]
之所以要弄清楚filter shape是因為要對filter的權重矩陣w進行初始化:
1
W = tf.Variable(tf.truncated_normal(filter_shape, stddev=0.1), name="W")

這裡為什麼要用tf.truncated_normal()函式呢?
答:tensorflow中提供了兩個normal函式:
  • tf.random_normal(shape, mean=0.0, stddev=1.0, dtype=tf.float32, seed=None, name=None)
  • tf.truncated_normal(shape, mean=0.0, stddev=1.0, dtype=tf.float32, seed=None, name=None)
對比了一下,這兩個函式的引數列表完全相同,不同之處我就直接引用文件中的說明,講解的很清楚,
Outputs random values from a truncated normal distribution.
The generated values follow a normal distribution with specified mean and standard deviation, except that values whose magnitude is more than 2 standard deviations from the mean are dropped and re-picked.
也就是說random出來的值的範圍都在[mean - 2 standard_deviations, mean + 2 standard_deviations]內。
下圖可以告訴你這個範圍在哪,

conv2d得到的其實是下圖中的<span tabindex="0" class="MathJax" id="MathJax-Element-2-Frame" role="presentation" style="display: inline-block; position: relative;" data-mathml='w⋅x'>w⋅x w⋅x的部分,

還要加上bias項tf.nn.bias_add(conv, b),並且通過relu:tf.nn.relu才最終得到卷積層的輸出<span tabindex="0" class="MathJax" id="MathJax-Element-3-Frame" role="presentation" style="display: inline-block; position: relative;" data-mathml='h'>h h。
那究竟卷積層的輸出的shape是什麼樣呢?
官方文件中有一段話解釋了卷積後得到的輸出結果:

第三部進行了right-multiply之後得到的結果就是[batch, out_height, out_width, output_channels],但是還是不清楚這裡的out_height和out_width到底是什麼。
那就看看wildml中怎麼說的吧
“VALID” padding means that we slide the filter over our sentence without padding the edges, performing a narrow convolution that gives us an output of shape [1, sequence_length - filter_size + 1, 1, 1]. 
哦,這句話的意思是說out_height和out_width其實和padding的方式有關係,這裡選擇了”VALID”的方式,也就是不在邊緣加padding,得到的out_height=sequence_length - filter_size + 1,out_width=1
因此,綜合上面的兩個解釋,我們知道conv2d-加bias-relu之後得到的<span tabindex="0" class="MathJax" id="MathJax-Element-4-Frame" role="presentation" style="display: inline-block; position: relative;" data-mathml='h'>h h的shape=[batch, sequence_length - filter_size + 1, 1, num_filters] 接下來的工作就是max-pooling了,來看一下tensorflow中給出的函式:
tf.nn.max_pool(value, ksize, strides, padding, data_format='NHWC', name=None)

其中最重要的兩個引數是value和ksize。
value相當於是max pooling層的輸入,在整個網路中就是剛才我們得到的<span tabindex="0" class="MathJax" id="MathJax-Element-5-Frame" role="presentation" style="display: inline-block; position: relative;" data-mathml='h'>h h,check了一下它倆的shape是一致的,說明可以直接傳遞到下一層。
另一個引數是ksize,官方解釋說是input tensor每一維度上的window size。仔細想一下,其實就是想定義多大的範圍來進行max-pooling,比如在影象中常見的2*2的小正方形區域對整個h得到feature map進行pooling,但是在nlp中,剛才說到了每一個feature map現在是[batch, sequence_length - filter_size + 1, 1, num_filters]維度的,我們想知道每個output_channels(每個channel是一個vector)的最大值,也就是最重要的feature是哪一個,那麼就是在第二個維度上設定window=sequence_length - filter_size + 1【這裡感覺沒解釋通,待後續探索】
根據ksize的設定,和value的shape,可以得到pooled的shape=[batch, 1, 1, num_filters]
這是一個filter_size的結果(比如filter_size = 3),pooled儲存的是當前filter_size下每個sentence最重要的num_filters個features,結果append到pooled_outputs列表中存起來,再對下一個filter_size進行相同的操作。 等到for迴圈結束時,也就是所有的filter_size全部進行了卷積和max-pooling之後,首先需要把相同filter_size的所有pooled結果concat起來,再將不同的filter_size之間的結果concat起來,最後的到的應該類似於二維陣列,[batch, all_pooled_result]
all_pooled_result一共有num_filters\(100)*len(filter_sizes)(3)個,比如300個
連線的過程需要使用tf.concat,官方給出的例子很容易理解。
最後得到的h_pool_flat也就是[batch, 300]維的tensor。
6 Dropout
1
2
3
# Add dropout
with tf.name_scope("dropout"):
    self.h_drop = tf.nn.dropout(self.h_pool_flat, self.dropout_keep_prob)
前面在“dropout注意事項”中講到了,dropout僅對hiddenlayer的輸出層進行drop,使得有些結點的值不輸出給softmax層。
7 Output
1
2
3
4
5
6
7
8
9
10
11
12

# Final (unnormalized) scores and predictions
with tf.name_scope("output"):
    W = tf.get_variable(
        "W",
        shape=[num_filters_total, num_classes],
        initializer=tf.contrib.layers.xavier_initializer())
    b = tf.Variable(tf.constant(0.1, shape=[num_classes]), name="b")
    l2_loss += tf.nn.l2_loss(W)
    l2_loss += tf.nn.l2_loss(b)
    self.scores = tf.nn.xw_plus_b(self.h_drop, W, b, name="scores")
    self.predictions = tf.argmax(self.scores, 1, name="predictions")
輸出層其實是個softmax分類器,沒什麼可講的,但是要注意l2正則(雖然有paper說l2加不加並沒有什麼區別)
但是我還有一個疑問是為什麼對b也要進行正則約束?
另外,tf.nn.xw_plus_b()在open api中並沒有提供,參考github上的某個issue
因此可以改為tf.matmul(self.h_drop, W) + b但是不好的地方是無法設定name了。。(用xw_plus_b也不會報錯不改也可以)
還有一個奇怪的地方是,這一層按道理說應該是一個softmax layer,但是並沒有使用到softmax函式,在Yoon的文章中也是直接得到輸出的,

因此,我們也按照這種方式寫程式碼,得到所有類別的score,並且選出最大值的那個類別(argmax)
y的shape為[batch, num_classes],因此argmax的時候是選取每行的max,dimention=1
因此,最後scores的shape為[batch, 1]
8 Loss function得到了整個網路的輸出之後,也就是我們得到了y_prediction,但還需要和真實的y label進行比較,以此來確定預測好壞。
1
2
3
4
# CalculateMean cross-entropy loss
with tf.name_scope("loss"):
    losses = tf.nn.softmax_cross_entropy_with_logits(self.scores, self.input_y)
    self.loss = tf.reduce_mean(losses) + l2_reg_lambda * l2_loss

還是使用常規的cross_entropy作為loss function。最後一層是全連線層,為了防止過擬合,最後還要在loss func中加入l2正則項,即l2_loss。l2_reg_lambda來確定懲罰的力度。
9 Accuracy
1
2
3
4
# Accuracy
with tf.name_scope("accuracy"):
    correct_predictions = tf.equal(self.predictions, tf.argmax(self.input_y, 1))
    self.accuracy = tf.reduce_mean(tf.cast(correct_predictions, "float"), name="accuracy")
tf.equal(x, y)返回的是一個bool tensor,如果xy對應位置的值相等就是true,否則false。得到的tensor是[batch, 1]的。
tf.cast(x, dtype)將bool tensor轉化成float型別的tensor,方便計算
tf.reduce_mean()本身輸入的就是一個float型別的vector(元素要麼是0.0,要麼是1.0),直接對這樣的vector計算mean得到的就是accuracy,不需要指定reduction_indices
0x02: Conclusion後續可能還會對其他部分進行解讀,敬請期待。