1. 程式人生 > >利用TensorFlow實現卷積神經網路做文字分類

利用TensorFlow實現卷積神經網路做文字分類

這篇部落格是翻譯Denny Britz寫的使用卷積神經網路做文字分類並且在Tensorflow上面實現,作者已經授權翻譯,這是原文

在這篇部落格中,我們將實現一個類似於 Kim Yoon 論文中用於句子分類的卷積神經網路模型。論文中的模型在一系列文字分類任務(如情感分類)中獲得了良好的分類效能,併成為新文字分類架構的標準基準。

在閱讀本文之前,我假設你已經學習了基本的卷積神經網路在自然語言處理中的知識。如果還沒有,那麼我推薦你看這篇部落格

資料獲取和準備

在本部落格中,我們使用的資料集是 Movie Review data from Rotten Tomatoes ,這也是論文中使用的其中一個數據集。這個資料集包含 10662 個評論樣本,其中一半是正向評論,一半是負向評論。這個資料集大約有2萬個詞。注意,因為這個資料集很小,所以如果我們使用很複雜的模型,那麼容易造成過擬合。並且,這個資料沒有幫我們分離訓練資料集和測試資料集。因此,我們需要自己去預處理。在這裡,我們把10%的資料作為交叉驗證集。在原始的論文中,作者使用十折交叉驗證(10-fold cross validation)。

在部落格中,我不在講資料的預處理過程,但是你可以在這裡找到預處理的程式碼,該程式碼主要有以下幾個功能:

  1. 從原始資料檔案中,匯入正樣本和負樣本資料。
  2. 資料清理,使用和論文中相同的程式碼
  3. 將每個句子填充到最大句子長度,也就是資料集中最長的那個句子的長度,這裡是59。我們填充的特殊標記是 <PAD> ,將句子填充到相同長度是非常有用的,因為它能幫助我們進行有效的批處理,因為在批處理中的每個例子都必須有相同的長度。
  4. 構建詞彙索引表,將每個單詞對映到 0 ~ 18765 之間(18765是詞彙量大小),那麼每個句子就變成了一個整數的向量。

模型

在部落格中,我們所要構建的模型如下圖:

第一層網路將詞向量嵌入到一個低維的向量中。下一層網路就是利用多個卷積核在前一層網路上進行卷積操作。比如,每次滑動3個,4個或者5個單詞。第三層網路是一個max-pool層,從而得到一個長向量,並且新增上 dropout 正則項。最後,我們使用softmax函式對進行分類。

因為,這篇部落格的目的是為了學習這個架構,所有我們對原論文中的模型進行了一些簡化操作,如下:

如果要將上面省略的操作新增到程式碼中也是非常簡單的(幾十行程式碼)。你可以看看部落格最後的擴充套件和練習吧。

那麼讓我們開始吧。

實現

為了允許各種的超引數配置,我們把我們的程式碼放到一個TextCNN類中,並且在 init 函式中生成模型圖。

import tensorflow as tf
import numpy as np

class TextCNN(object):
    """
    A CNN for text classification.
    Uses an embedding layer, followed by a convolutional, max-pooling and softmax layer.
    """
    def __init__(
      self, sequence_length, num_classes, vocab_size,
      embedding_size, filter_sizes, num_filters, l2_reg_lambda=0.0):
      # Implementation ...

為了例項化類,我們需要傳遞以下引數到類中:

  • sequence_length - 句子的長度。請注意,我們通過新增特殊標記,使得所欲的句子都擁有了相同的長度(我們的資料集是59)。
  • num_classes - 最後一層分類的數目,在這裡我們是進行二分類(正向評論和負向評論)。
  • vocab_size - 詞彙量的大小。這個引數是為了確定我們詞向量嵌入層的大小,最終的總詞向量維度是 [vocabulary_size, embedding_size]
  • embeddign_size - 每個單詞的詞向量的長度。
  • filter_sizes - 這個引數確定我們希望我們的卷積核每次覆蓋幾個單詞。對於每個卷積核,我們都將有 num_filters 個。比如,filter_sizes = [3, 4, 5] , 這就意味著,卷積核一共有三種類型,分別是每次覆蓋3個單詞的卷積核,每次覆蓋4個單詞的卷積核和每次覆蓋5個單詞的卷積核。卷積核一共的數量是 3 * num_filters 個。
  • num_filters - 每個卷積核的數量(參考 filter_sizes 引數的介紹)。

輸入佔位符

我們首先定義需要輸入到模型中的資料。

# Placeholders for input, output and dropout
self.input_x = tf.placeholder(tf.int32, [None, sequence_length], name="input_x")
self.input_y = tf.placeholder(tf.float32, [None, num_classes], name="input_y")
self.dropout_keep_prob = tf.placeholder(tf.float32, name="dropout_keep_prob")

tf.placeholder 建立了一個佔位符變數,當我們在訓練階段或者測試階段時,都可以使用它向我們的模型輸入資料。第二個引數是輸入張量的形狀。None 的意思是,該維度的長度可以是任何值。在我們的模型中,第一個維度是批處理大小,而使用 None 來表示這個值,說明網路允許處理任意大小的批次。

在 dropout 層中,我們使用 dropout_keep_prob 引數來控制神經元的啟用程度。但這個引數,我們只在訓練的時候開啟,在測試的時候禁止它。(後續文章會深入介紹)

嵌入層

我們定義的第一個網路層是嵌入層,這一層的作用是將詞彙索引對映到低維度的詞向量進行表示。它本質是一個我們從資料中學習得到的詞彙向量表。

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)

在這裡,我們又使用了一些新功能,讓我們來學習一下它們:

  • tf.device("/cpu:0") 強制程式碼在CPU上面執行操作。因為預設情況下,TensorFlow會嘗試將操作放在GPU上面進行執行(如果存在GPU),但是嵌入層的操作目前還不支援GPU執行,所以如果你不指定CPU進行執行,那麼程式會報錯。
  • tf.name_scope 建立了一個稱之為"embedding"的新的名稱範圍,該範圍將所有的操作都新增到這個"embedding"節點下面。以便在TensorBoard中獲得良好的層次結構,有利於視覺化。

W 是我們的嵌入矩陣,這個矩陣是我們從資料訓練過程中得到的。最開始,我們使用一個隨機均勻分佈來進行初始化。tf.nn.embedding_lookup 建立實際的嵌入讀取操作,這個嵌入操作返回的資料維度是三維張量 [None, sequence_length, embedding_size]

TensorFlow 的卷積操作 conv2d 需要一個四維的輸入資料,對應的維度分別是批處理大小,寬度,高度和通道數。在我們嵌入層得到的資料中不包含通道數,所以我們需要手動新增它,所以最終的資料維度是 [None, sequence_length, embedding_size, 1]

卷積層和池化層

現在我們可以構建我們的卷積層和池化層了。請記住,我們使用的卷積核是不同尺寸的。因為每個卷積核經過卷積操作之後產生的張量是不同維度的,所有我們需要為每一個卷積核建立一層網路,最後再把這些卷積之後的覺果合併成一個大的特徵向量。

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")
        # Max-pooling 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])

程式碼中,W 表示不同的卷積核,h 表示對經過卷積得到的輸出結果進行非線性處理之後的結果。每個卷積核會覆蓋整個詞向量長度,但是滑動覆蓋幾個單詞就是不同的了。VALID 填充意味著,我們的卷積核只在我們的單詞上面滑動,而不填充邊緣,是執行窄卷積,所有最後輸出的維度是 [1, sequence_length - filter_size + 1, 1, 1] 。對經過特定卷積的輸出,我們做最大池化操作,使得我們得到的張量維度是 [batch_size, 1, 1, num_filters]。這實質上就是一個特徵向量,其中最後一個維度就是對應於我們的特徵。一旦我們擁有了來自各個卷積核的輸出向量,那麼我們就可以把它們合併成一個長的特徵向量,該向量的維度是 [batch_size, num_filters_total] 。在 tf.reshape 中使用 -1,就是告訴 TensorFlow 在可能的情況下,將維度進行展平。

上面部分最好花點時間看明白,去弄明白每個操作輸出的維度是什麼。如果你不是很瞭解,也可以再去參考這篇部落格 Understanding Convolutional Neural Networks for NLP,獲得一些靈感。下圖是TensorBoard視覺化的結果,你可以發現三個卷積核組成了三個不同的網路層。



Dropout層

Dropout 也許是最流行的方法來正則化卷積神經網路。Dropout 的思想非常簡單,就是按照一定的概率來“禁用”一些神經元的發放。這種方法可以防止神經元共同適應一個特徵,而迫使它們單獨學習有用的特徵。神經元啟用的概率,我們從引數 dropout_keep_prob 中得到。我們在訓練階段將其設定為 0.5,在測試階段將其設定為 1.0(即所有神經元都被啟用)。

# Add dropout
with tf.name_scope("dropout"):
    self.h_drop = tf.nn.dropout(self.h_pool_flat, self.dropout_keep_prob)

分數和預測

我們使用來自池化層的特徵向量(經過Dropout),然後通過全連線層,得到一個分數最高的類別。我們還可以應用softmax函式來將原始分數轉換成歸一化概率,但這個操作是保護會改變我們的最終預測。

with tf.name_scope("output"):
    W = tf.Variable(tf.truncated_normal([num_filters_total, num_classes], stddev=0.1), name="W")
    b = tf.Variable(tf.constant(0.1, shape=[num_classes]), name="b")
    self.scores = tf.nn.xw_plus_b(self.h_drop, W, b, name="scores")
    self.predictions = tf.argmax(self.scores, 1, name="predictions")

上面程式碼中,tf.nn.xw_plus_b是一個很方便的函式,實現 Wx + b 操作。

損失函式和正確率

使用我們上面求得的分數,我們可以定義損失函式。損失值是對模型所造成的誤差的度量,我們的目標是最小化這個損失值。分類問題的標準損失函式是交叉熵損失函式

# Calculate mean 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)

這裡,tf.nn.softmax_cross_entropy_with_logits 是一個方便的函式,用來計算每個類別的交叉損失熵,對於我們給定的分數和輸入的正確標籤。然後,我們計算損失值的平均值。當然,我們也可以對它們進行求和,但是這會對不同批大小的損失值衡量非常困難,尤其是在訓練階段和測試階段。

我們還定義了一個正確率的函式,它的作用就是在訓練階段和測試階段來跟蹤模型的效能。

# Calculate 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")

視覺化網路

就這樣,我們完成了網路的定義。完整程式碼可以點選這裡。在TensorBoard中我們可以看到以下的大圖。


訓練過程

在我們編寫我們網路的訓練過程之前,我們需要先了解一下TensorFlow中的會話(Session)和圖(Graph)的概念。如果你已經對這些概念很熟悉了,那麼可以跳過這個部分。

在TensorFlow中,會話是一個圖執行的環境(也就是說,圖必須在會話中被啟動),它包含有關的變數和佇列狀態。每個會話執行一個單一的圖。如果你在建立變數和操作時,沒有明確地使用一個會話,那麼TensorFlow會建立一個當前預設會話。你可以通過在 session.as_default() 中來修改預設會話(如下)。

圖(Graph)中包含各種操作和張量。你可以在程式中使用多個圖,但是大多數程式都只需要一個圖。你可以把一張圖在多個會話中使用,但是不能在一個會話中使用多個圖。TensorFlow總是會建立一個預設圖,但是你也可以自己手動建立一個圖,並且把它設定為預設圖,就像我們下面所寫的一樣。顯示的建立會話和圖可以確保在不需要它們的時候,正確的釋放資源。這是一個很好的習慣。

with tf.Graph().as_default():
    session_conf = tf.ConfigProto(
      allow_soft_placement=FLAGS.allow_soft_placement,
      log_device_placement=FLAGS.log_device_placement)
    sess = tf.Session(config=session_conf)
    with sess.as_default():
        # Code that operates on the default graph and session comes here...

allow_soft_placement 引數的設定,允許 TensorFlow 回退到特定操作的裝置,如果在優先裝置不存在時。比如,如果我們的程式碼是執行在一個GPU上面的,但是我們的程式碼在一個沒有GPU的機器上運行了。那麼,如果不使用 allow_soft_placement 引數,程式就會報錯。如果設定了 log_device_placement 引數,TensorFlow 會記錄它執行操作的裝置(CPU或者GPU)。這對除錯程式非常有用,FLAGS 是我們程式的命令列引數。

實現卷積神經網路和損失函式最小化

當我們例項化我們的 TextCNN 模型時,所有定義的變數和操作都將被放入我們建立的預設圖和會話中。

cnn = TextCNN(
    sequence_length=x_train.shape[1],
    num_classes=2,
    vocab_size=len(vocabulary),
    embedding_size=FLAGS.embedding_dim,
    filter_sizes=map(int, FLAGS.filter_sizes.split(",")),
    num_filters=FLAGS.num_filters)

接下來,我們定義如何去最優化我們網路的損失函式。TensorFlow有很多內嵌的優化函式。在這裡,我們使用Adam優化器。

global_step = tf.Variable(0, name="global_step", trainable=False)
optimizer = tf.train.AdamOptimizer(1e-4)
grads_and_vars = optimizer.compute_gradients(cnn.loss)
train_op = optimizer.apply_gradients(grads_and_vars, global_step=global_step)

在上述程式碼中,trian_op 是一個新建立的操作,我們可以執行它來對我們的引數進行梯度更新。每次執行 train_op 操作,就是一個訓練步驟。TensorFlow 會自動計算出哪些變數是“可訓練”的,並計算它們的梯度。通過定義 global_step 變數並將它傳遞給優化器,我們允許TensorFlow處理我們的訓練步驟。我們每次執行 train_op 操作時,global_step 都會自動遞增1。

彙總

TensorFlow有一個彙總的概念,它允許你在訓練和評估階段來跟蹤和視覺化各種引數。比如,你可能想要去跟蹤在各個訓練和評估階段,損失值和正確值是如何變化的。當然,你還可以跟蹤更加複雜的資料。例如,圖層啟用的直方圖。彙總是一個序列化物件,我們可以使用 SummaryWriter 函式來將它們寫入磁碟。

# Output directory for models and summaries
timestamp = str(int(time.time()))
out_dir = os.path.abspath(os.path.join(os.path.curdir, "runs", timestamp))
print("Writing to {}\n".format(out_dir))

# Summaries for loss and accuracy
loss_summary = tf.scalar_summary("loss", cnn.loss)
acc_summary = tf.scalar_summary("accuracy", cnn.accuracy)

# Train Summaries
train_summary_op = tf.merge_summary([loss_summary, acc_summary])
train_summary_dir = os.path.join(out_dir, "summaries", "train")
train_summary_writer = tf.train.SummaryWriter(train_summary_dir, sess.graph_def)

# Dev summaries
dev_summary_op = tf.merge_summary([loss_summary, acc_summary])
dev_summary_dir = os.path.join(out_dir, "summaries", "dev")
dev_summary_writer = tf.train.SummaryWriter(dev_summary_dir, sess.graph_def)

在這裡,我們獨立的去處理訓練階段和評估階段的彙總。在我們的例子中,在訓練階段和評估階段,我們記錄的彙總資料都是一樣的。但是,你可能會有一些彙總資料是隻想在訓練階段進行記錄的(比如,引數更新值)。tf.merge_summary 是一個方便的函式,它可以將多個彙總操作合併到一個我們可以執行的單個操作中。

檢查點

在TensorFlow中,另一個你通常想要的功能是檢查點(checkpointing)——儲存模型的引數以備以後恢復。檢查點可用於在以後的繼續訓練,或者提前來終止訓練,從而能來選擇最佳引數。檢查點是使用 Saver 物件來建立的。

# Checkpointing
checkpoint_dir = os.path.abspath(os.path.join(out_dir, "checkpoints"))
checkpoint_prefix = os.path.join(checkpoint_dir, "model")
# Tensorflow assumes this directory already exists so we need to create it
if not os.path.exists(checkpoint_dir):
    os.makedirs(checkpoint_dir)
saver = tf.train.Saver(tf.all_variables())

初始化變數

在我們訓練我們的模型之前,我們還需要去初始化圖中的所有變數。

sess.run(tf.initialize_all_variables())

initialize_all_variables 函式是一個方便的函式,它能幫助我們去初始化所有的變數。當然你也能手動初始化你自己的引數。手動初始化是非常有用的,比如你想要去初始化你的詞向量(嵌入層),用與訓練好的詞向量模型。

定義單個訓練步驟

現在我們定義一個訓練函式,用於單個訓練步驟,在一批資料上進行評估,並且更新模型引數。

def train_step(x_batch, y_batch):
    """
    A single training step
    """
    feed_dict = {
      cnn.input_x: x_batch,
      cnn.input_y: y_batch,
      cnn.dropout_keep_prob: FLAGS.dropout_keep_prob
    }
    _, step, summaries, loss, accuracy = sess.run(
        [train_op, global_step, train_summary_op, cnn.loss, cnn.accuracy],
        feed_dict)
    time_str = datetime.datetime.now().isoformat()
    print("{}: step {}, loss {:g}, acc {:g}".format(time_str, step, loss, accuracy))
    train_summary_writer.add_summary(summaries, step)

feed_dict 包含了我們需要傳入到網路中的資料。你必須為所有的佔位符節點提供值,否則TensorFlow會報錯。另一種輸入資料的方式是使用佇列,但這種方法超出了本次的範圍,所以我們先不討論這種方法。

接下來,我們使用 session.run 來執行我們的 train_op ,它會返回我們要求它評估的所有操作的值。注意,train_op 不返回任何東西,它只是更新我們的網路引數。最後,我們列印當前訓練的損失值和正確值,並且把彙總結果儲存到磁碟。請注意,如果批處理規模很小,那麼損失值和模型正確值可能在不同批次之間會有很大的不同。因為我們使用了 Dropout ,所以我們的訓練真確率可能會比測試正確率低一點。

我們寫了一個相似的函式來評估任意資料集的損失值和真確率,比如在交叉驗證資料集和整個訓練集上面。本質上,這個函式和上面的函式是相同的,但是沒有訓練操作,它也禁用了 Dropout 。

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,
      cnn.dropout_keep_prob: 1.0
    }
    step, summaries, loss, accuracy = sess.run(
        [global_step, dev_summary_op, cnn.loss, cnn.accuracy],
        feed_dict)
    time_str = datetime.datetime.now().isoformat()
    print("{}: step {}, loss {:g}, acc {:g}".format(time_str, step, loss, accuracy))
    if writer:
        writer.add_summary(summaries, step)

迴圈訓練

最後,我們準備去寫完整的訓練過程。我們對資料集進行批次迭代操作,為每個批處理呼叫一次 train_step 函式,偶爾去評估一下我們的訓練模型。

# Generate batches
batches = data_helpers.batch_iter(
    zip(x_train, y_train), FLAGS.batch_size, FLAGS.num_epochs)
# Training loop. For each batch...
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.evaluate_every == 0:
        print("\nEvaluation:")
        dev_step(x_dev, y_dev, writer=dev_summary_writer)
        print("")
    if current_step % FLAGS.checkpoint_every == 0:
        path = saver.save(sess, checkpoint_prefix, global_step=current_step)
        print("Saved model checkpoint to {}\n".format(path))

這裡,batch_iter 是一個我批處理資料的幫助函式,tr.train.global_step 是一個方便函式,它返回 global_step 的值。點選此處可以檢視完整程式碼。

在 TensorBoard 中檢視視覺化結果

我們的訓練指令碼將彙總結果寫入到輸出目錄,通過使用 TensorBoard 指向該目錄,我們就可以視覺化建立的圖形和摘要。

tensorboard --logdir /PATH_TO_CODE/runs/1449760558/summaries/

使用預設引數訓練我們的模型(128-dimensional embeddings, filter sizes of 3, 4 and 5, dropout of 0.5 and 128 filters per filter size) ,那麼我們可以得到如下圖(藍色是訓練資料,紅色是10%的交叉驗證資料):



這裡有一些需要指出的點:

  • 我們訓練的指標不是那麼平滑,因為我們使用的批處理太小了。如果我們使用一個比較大的批處理(或者在整個訓練集上面進行評估),那麼我們將得到一個更加平滑的藍線。
  • 交叉測試集的正確率顯著低於訓練集的正確率,這可能是因為網路在訓練資料集上面過擬合了,也就是說我們需要更多的資料(MR資料集非常小),更強的正則化或者更小的模型引數。比如,我在最後一層的權重上面新增 L2 懲罰項,那麼正確率就能達到76%,接近於原始論文中的資料。
  • 訓練階段的損失值和正確率顯著低於交叉驗證時,這是因為我們使用了 Dropout 。

你可以執行除錯這個程式,去玩各種引數。點選這裡檢視完整程式碼。

擴充套件和練習

以下是一些可以幫助提高模型效能的方法:

  • 詞向量用 word2vec 進行初始化。如果你要讓這種方法有效,那麼需要使用 300 維的詞向量來進行模型的初始化工作。
  • 使用L2範數對最後一層中的權重進行約束,就像原始論文中的一樣。你也可以通過定義一個新的操作來更新每次訓練之後的權重值。
  • 新增L2正則項到網路中,以防止過擬合,也可以嘗試增加 dropout 係數。(我的程式碼中已經實現了L2正則項,但是預設情況下是禁用的。)
  • 將權重更新和網路層操作的結果都儲存起來,然後在 TensorBoard 中進行視覺化。