【NLP】TensorFlow實現CNN用於文字分類(譯)

widiot1發表於2018-01-27

原文:Implementing a CNN for Text Classification in TensorFlow
作者:DENNY BRITZ
時間:2015/12/11

完整的程式碼在 Github 上可用

在這篇文章中,我們將實現一個類似 Kim Yoon 的卷積神經網路的句子分類模型。該論文提出的模型在一系列文字分類任務(如情感分析)中實現了良好的分類效能,並且自此成為新的文字分類體系結構的標準基線。

我假設你已經熟悉了應用於 NLP 的卷積神經網路的基礎知識。如果不是的話,我建議先閱讀瞭解用於NLP的卷積神經網路來獲得必要的背景知識。

資料和預處理


我們將在這篇文章中使用的資料集是Rotten Tomatoes的電影評論資料,也是原論文中使用的資料集之一。該資料集包含 10,662 個示例評論句子,一半正面另一半負面。資料集有一個大約 20k 的詞彙量。請注意,由於這個資料集相當小,我們可能會使這個強健的模型過擬合。此外,該資料集不帶有正式的訓練/測試分割,所以我們只使用 10% 的資料作為開發集。原論文對資料進行了 10 次交叉驗證。

我不會在這篇文章中交待資料預處理程式碼,但它在Github上可用,並執行以下操作:

  1. 從原始資料檔案載入正面和負面的句子。
  2. 使用與原論文相同的程式碼清洗文字資料。
  3. 將每個句子填充到最大句子長度,應該是 59。我們在所有其他句子中新增 <PAD> 標記,使它們成為 59 個單詞。將句子填充到相同的長度是有用的,因為它允許我們高效地批量處理資料,因為批處理中的每個示例必須具有相同的長度。
  4. 建立詞彙索引並將每個單詞對映到 0 到 18,765 之間的整數(詞彙大小)。每個句子成為一個整數的向量。

模型


我們將在這篇文章中構建的網路大致如下:


Kim,Y.(2014)。用於句子分類的卷積神經網路

第一層將單詞嵌入到低維向量中。下一層使用多個濾波器大小對嵌入的單詞向量執行卷積。例如,一次滑動3,4或5個單詞。接下來,我們將卷積層的結果最大池化為一個長特徵向量,新增 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):
        # Implementation...

為了例項化這個類,我們傳遞下面的引數:

  • sequence_length - 我們的句子的長度。請記住,我們填充了所有句子的長度(對於我們的資料集為 59)。
  • num_classes - 輸出層中的類數,在我們的例子中是兩個(正面和負面)。
  • vocab_size - 我們詞彙的大小。這是需要定義我們的嵌入層的大小,將有形狀 [vocabulary_size, embedding_size]。
  • embedding_size - 我們嵌入的維度大小。
  • filter_sizes - 我們希望過濾器覆蓋的字數。用 num_filters 指定每個尺寸有多少個過濾器。例如,[3, 4, 5] 意味著我們將有過濾器分別滑過 3,4 和 5 個字,總共 3 * num_filters 個過濾器。
  • num_filters - 每個過濾器大小的過濾器數量(見上)。

輸入佔位符

我們首先定義傳遞給我們網路的輸入資料:

# 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。我們在評估模型時會禁用它(稍後會詳細介紹)。

嵌入層

我們定義的第一層是嵌入層,它將詞彙的詞索引對映到低維向量表示。它本質上是一個從資料中學習的查詢表。

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 上則會引發錯誤。
  • tf.name_scope 用名稱“embedding” 建立一個新的名稱作用域。該作用域將所有操作新增到名為“enbedding”的頂級節點中,以便在 TensorBoard 中視覺化網路時獲得良好的層次結構。

W 是我們在訓練中學習的嵌入矩陣。我們使用隨機均勻分佈來初始化它。tf.nn.embedding_lookup 建立實際的嵌入操作。嵌入操作的結果是形狀為[None, sequence_length, embedding_size]的三維張量 。

TensorFlow 的卷積 conv2d 操作需要有對應於分批,寬度,高度和通道的4維張量。我們嵌入的結果不包含通道維度,所以我們手動新增它,給我們留下形狀為[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] 的長特徵向量。-1 在 tf.reshape 可能的情況下使用告訴 TensorFlow 使尺寸變平。

花一些時間,並嘗試瞭解每個操作的輸出形狀。你也可以參考瞭解用於NLP的卷積神經網路來得到一些直覺。在 TensorBoard 中視覺化操作也可能有幫助(對於特定的過濾器尺寸3,4和5):


具有多個濾波器大小的卷積層


卷積和最大池化為一個單一的過濾器大小

Dropout層

dropout 是使卷積神經網路正則化的最普遍的方法。dropout 背後的想法很簡單。dropout 層隨機“禁用”其神經元的一部分。這可以防止神經元共同適應,並迫使他們學習單獨有用的特徵。我們保持啟用的神經元部分是由 dropout_keep_prob 定義的。我們在訓練期間將其設定為 0.5,在評估期間設為 1(禁用dropout)。

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

分數和預測

使用來自 max-pooling 的特徵向量(應用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 中視覺化網路:


CNN的文字分類

訓練過程


之前我們為定義我們的網路訓練過程中,我們需要了解 TensorFlow 如何使用一些基本的 Sessions 和 Graphs。如果你已經熟悉這些概念,可以跳過本節。

在 TensorFlow 中,Session 是執行圖操作的環境,它包含有關變數和佇列的狀態。每個會話都在一個圖上進行。如果在建立變數和操作時沒有明確使用會話,則使用由 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 是我們的程式的命令列引數。

例項化CNN並使損失最小化

當我們例項化我們的 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)

在 train_op 這裡,我們可以執行一個新建立的操作來對引數執行梯度更新。每一次執行 train_op 都是一個訓練步驟。TensorFlow 會自動計算出哪些變數是“可訓練的”並計算出它們的梯度。通過定義一個 global_step 變數並將其傳遞給優化器,我們允許 TensorFlow 為我們處理訓練步驟的計數。每執行一次 train_op,全域性步數將自動遞增 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 功能是檢查點 - 儲存模型的引數以便以後恢復。可以使用檢查點在以後繼續進行訓練,或者使用提早停止來選擇最佳引數設定。檢查點是使用 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 是我編寫批量資料的幫助函式,tf.train.global_step 是返回 global_step 值的輔助函式。完整的訓練程式碼也可以在這裡找到

在TensorBoard中顯示結果


我們的訓練指令碼將摘要寫入輸出目錄,並將 TensorBoard 指向該目錄,我們可以看到圖和我們建立的摘要。

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

使用預設引數(128維嵌入,3,4和5的過濾器尺寸,dropout每個過濾器尺寸的0.5和128過濾器)執行訓練過程得出以下損失和準確度曲線圖(藍色是訓練資料,紅色是10%開發資料)。


文字分類CNN的損失部分


文字分類CNN精度圖

有幾件事情是突出的:

  • 我們的訓練指標並不平坦,因為我們使用小批量。如果我們使用較大的批次(或在整個訓練集上進行評估),我們會得到一條更平滑的藍線。
  • 由於開發精度明顯低於訓練精度,因此似乎我們的網路過度訓練資料,表明我們需要更多的資料(MR資料集非常小),更強的正則化或更少的模型引數。例如,我嘗試在最後一層為權重新增額外的 L2 規約,並且能夠將精度提高到 76%,接近於原始論文中的資料。
  • 訓練損失和準確性開始顯著低於開發指標由於應用到它的 dropout。

你可以隨意使用程式碼,並嘗試使用各種引數配置來執行模型。程式碼和說明在Github上可用

擴充套件和練習

這裡有幾個有用的練習可以提高模型的效能:

  • 使用預先訓練的 word2vec 向量初始化嵌入。為了讓這個有效,你需要使用 300 維嵌入,並使用預先訓練的值進行初始化。
  • 像原論文一樣限制最後一層中權重向量的 L2 範數。你可以通過定義一個新操作在每個訓練步驟後更新權重值來完成此操作。
  • 增加 L2 正則化到網路來防止過擬合,也可以試驗增加 dropout 率。(Github上的程式碼已經包含 L2 正則化,但預設情況下是禁用的)
  • 為權重更新和圖層操作新增直方圖摘要,並在 TensorBoard 中顯示。

請在評論中留下反饋和問題!

相關文章