[譯] RNN 迴圈神經網路系列 2:文字分類

歐長坤發表於2019-03-01

本系列文章彙總

  1. RNN 迴圈神經網路系列 1:基本 RNN 與 CHAR-RNN
  2. RNN 迴圈神經網路系列 2:文字分類
  3. RNN 迴圈神經網路系列 3:編碼、解碼器
  4. RNN 迴圈神經網路系列 4:注意力機制
  5. RNN 迴圈神經網路系列 5:自定義單元

RNN 迴圈神經網路系列 2:文字分類

在第一篇文章中,我們看到了如何使用 TensorFlow 實現一個簡單的 RNN 架構。現在我們將使用這些元件並將其應用到文字分類中去。主要的區別在於,我們不會像 CHAR-RNN 模型那樣輸入固定長度的序列,而是使用長度不同的序列。

文字分類

這個任務的資料集選用了來自 Cornell 大學的語句情緒極性資料集 v1.0,它包含了 5331 個正面和負面情緒的句子。這是一個非常小的資料集,但足夠用來演示如何使用迴圈神經網路進行文字分類了。

我們需要進行一些預處理,主要包括標註輸入、附加標記(填充等)。請參考完整程式碼瞭解更多。

預處理步驟

  1. 清洗句子並切分成一個個 token;
  2. 將句子轉換為數值 token;
  3. 儲存每個句子的序列長。

Screen Shot 2016-10-05 at 7.32.36 PM.png
Screen Shot 2016-10-05 at 7.32.36 PM.png

如上圖所示,我們希望在計算完成時立即對句子的情緒做出預測。引入額外的填充符會帶來過多噪聲,這樣的話你模型的效能就會不太好。注意:我們填充序列的唯一原因是因為需要以固定大小的批量輸入進 RNN。下面你會看到,使用動態 RNN 還能避免在序列完成後的不必要計算。

模型

程式碼:

class model(object):

    def __init__(self, FLAGS):

        # 佔位符
        self.inputs_X = tf.placeholder(tf.int32,
            shape=[None, None], name='inputs_X')
        self.targets_y = tf.placeholder(tf.float32,
            shape=[None, None], name='targets_y')
        self.dropout = tf.placeholder(tf.float32)

        # RNN 單元
        stacked_cell = rnn_cell(FLAGS, self.dropout)

        # RNN 輸入
        with tf.variable_scope('rnn_inputs'):
            W_input = tf.get_variable("W_input",
                [FLAGS.en_vocab_size, FLAGS.num_hidden_units])

        inputs = rnn_inputs(FLAGS, self.inputs_X)
        #initial_state = stacked_cell.zero_state(FLAGS.batch_size, tf.float32)

        # RNN 輸出
        seq_lens = length(self.inputs_X)
        all_outputs, state = tf.nn.dynamic_rnn(cell=stacked_cell, inputs=inputs,
            sequence_length=seq_lens, dtype=tf.float32)

        # 由於使用了 seq_len[0],state 自動包含了上一次的對應輸出
        # 因為 state 是一個帶有張量的元組
        outputs = state[0]

        # 處理 RNN 輸出
        with tf.variable_scope('rnn_softmax'):
            W_softmax = tf.get_variable("W_softmax",
                [FLAGS.num_hidden_units, FLAGS.num_classes])
            b_softmax = tf.get_variable("b_softmax", [FLAGS.num_classes])

        # Logits
        logits = rnn_softmax(FLAGS, outputs)
        probabilities = tf.nn.softmax(logits)
        self.accuracy = tf.equal(tf.argmax(
            self.targets_y,1), tf.argmax(logits,1))

        # 損失函式
        self.loss = tf.reduce_mean(
            tf.nn.sigmoid_cross_entropy_with_logits(logits, self.targets_y))

        # 優化
        self.lr = tf.Variable(0.0, trainable=False)
        trainable_vars = tf.trainable_variables()
        # 使用梯度截斷來避免梯度消失和梯度爆炸
        grads, _ = tf.clip_by_global_norm(
            tf.gradients(self.loss, trainable_vars), FLAGS.max_gradient_norm)
        optimizer = tf.train.AdamOptimizer(self.lr)
        self.train_optimizer = optimizer.apply_gradients(
            zip(grads, trainable_vars))

        # 下面是用於取樣的值
        # (在每個單詞後生成情緒)

        # 取所有輸出作為第一個輸入序列
        # (由於取樣,只需一個輸入序列)
        sampling_outputs = all_outputs[0]

        # Logits
        sampling_logits = rnn_softmax(FLAGS, sampling_outputs)
        self.sampling_probabilities = tf.nn.softmax(sampling_logits)

        # 儲存模型的元件
        self.global_step = tf.Variable(0, trainable=False)
        self.saver = tf.train.Saver(tf.all_variables())

    def step(self, sess, batch_X, batch_y=None, dropout=0.0,
        forward_only=True, sampling=False):

        input_feed = {self.inputs_X: batch_X,
                      self.targets_y: batch_y,
                      self.dropout: dropout}

        if forward_only:
            if not sampling:
                output_feed = [self.loss,
                               self.accuracy]
            elif sampling:
                input_feed = {self.inputs_X: batch_X,
                              self.dropout: dropout}
                output_feed = [self.sampling_probabilities]
        else: # 訓練
            output_feed = [self.train_optimizer,
                           self.loss,
                           self.accuracy]

        outputs = sess.run(output_feed, input_feed)

        if forward_only:
            if not sampling:
                return outputs[0], outputs[1]
            elif sampling:
                return outputs[0]
        else: # 訓練
            return outputs[0], outputs[1], outputs[2]複製程式碼

上面的程式碼就是我們的模型程式碼,它在訓練的過程中使用了輸入的文字。注意:為了清楚起見,我們決定將批量資料的大小儲存在我們的輸入和目標占位符中,但是我們應該讓它們獨立於一個特定的批量大小之外。由於這個特定的批量大小依賴於 batch_size,如果我們這麼做,那麼我們就還得輸入一個 initial_state。我們通過嵌入他們來為每個資料序列來輸入 token。實踐策略表明,我們在輸入文字上使用 skip-gram 模型預訓練嵌入權重能夠取得更好的效能。

在此模型中,我們再次使用 dynamic_rnn,但是這次我們提供了sequence_length 引數的值,它是一個包含每個序列長度的列表。這樣,我們就可以避免在輸入序列的最後一個詞之後進行的不必要的計算。length 函式就用來獲取這個列表的長度,如下所示。當然,我們也可以在外面計算seq_len,再通過佔位符進行傳遞。

def length(data):
    relevant = tf.sign(tf.abs(data))
    length = tf.reduce_sum(relevant, reduction_indices=1)
    length = tf.cast(length, tf.int32)
    return length複製程式碼

由於我們填充符 token 為 0,因此可以使用每個 token 的 sign 性質來確定它是否是一個填充符 token。如果輸入大於 0,則 tf.sign 為 1;如果輸入為 0,則為 tf.sign 為 0。這樣,我們可以逐步通過列索引來獲得 sign 值為正的 token 數量。至此,我們可以將這個長度提供給 dynamic_rnn 了。

注意:我們可以很容易地在外部計算 seq_lens,並將其作為佔位符進行傳參。這樣我們就不用依賴於 PAD_ID = 0 這個性質了。

一旦我們從 RNN 拿到了所有的輸出和最終狀態,我們就會希望分離對應輸出。對於每個輸入來說,將具有不同的對應輸出,因為每個輸入長度不一定不相同。由於我們將 seq_len 傳給了 dynamic_rnn,而 state 又是最後一個對應輸出,我們可以通過檢視 state 來找到對應輸出。注意,我們必須取 state[0],因為返回的 state 是一個張量的元組。

其他需要注意的事情:我並沒有使用 initial_state,而是直接給 dynamic_rnn 設定 dtype。此外,dropout 將根據 forward_only 與否,作為引數傳遞給 step()

推斷

總的來說,除了單個句子的預測外,我還想為具有一堆樣本句子整體情緒進行預測。我希望看到的是,每個單詞都被 RNN 讀取後,將之前的單詞分值儲存在記憶體中,從而檢視預測分值是怎樣變化的。舉例如下(值越接近 0 表明越靠近負面情緒):

Screen Shot 2016-10-05 at 8.34.51 PM.png
Screen Shot 2016-10-05 at 8.34.51 PM.png

注意:這是一個非常簡單的模型,其資料集非常有限。主要目的只是為了闡明它是如何搭建以及如何執行的。為了獲得更好的效能,請嘗試使用資料量更大的資料集,並考慮具體的網路架構,比如 Attention 模型、Concept-Aware 詞嵌入以及隱喻(symbolization to name)等等。

損失遮蔽(這裡不需要)

最後,我們來計算 cost。你可能會注意到我們沒有做任何損失遮蔽(loss masking)處理,因為我們分離了對應輸出,僅用於計算損失函式。然而,對於其他諸如機器翻譯的任務來說,我們的輸出很有可能還來自填充符 token。我們不想考慮這些輸出,因為傳遞了 seq_lens 引數的 dynamic_rnn 將返回 0。下面這個例子比較簡單,只用來說明這個實現大概是怎麼回事;我們這裡再一次使用了填充符 token 為 0 的性質:

# 向量化 logits 和目標
targets = tf.reshape(targets, [-1]) # 將張量 targets 轉為向量
losses = tf.nn.sparse_softmax_cross_entropy_with_logits(logits, targets)
mask = tf.sign.(tf.to_float(targets)) # targets 為 0 則輸出為 0, target < 0 則輸出為 -1, 否則 為 1
masked_losses = mask*losses # 填充符所在位置的貢獻為 0複製程式碼

首先我們要將 logits 和 targets 向量化。為了使 logits 向量化,一個比較好的辦法是將 dynamic_rnn 的輸出向量化為 [-1,num_hidden_units] 的形狀,然後乘以 softmax 權重 [num_hidden_units,num_classes]。通過損失遮蔽操作,就可以消除填充符所在位置貢獻的損失。

程式碼

GitHub 倉庫 (正在更新,敬請期待!)

張量形狀變化的參考

原始未處理過的文字 X 形狀為 [N,]y 的形狀為 [N, C],其中 C 是輸出類別的數量(這些是手動完成的,但我們需要使用獨熱編碼來處理多類情況)。

然後 X 被轉化為 token 並進行填充,變成了 [N, <max_len>]。我們還需要傳遞形狀為 [N,]seq_len 引數,包含每個句子的長度。

現在 Xseq_leny 通過這個模型首先嵌入為 [NXD],其中 D 是嵌入維度。X 便從 [N, <max_len>] 轉換為了 [N, <max_len>, D]。回想一下,X 在這裡有一箇中間表示,它被獨熱編碼為了 [N, <max_len>, <num_words>]。但我們並不需要這麼做,因為我們只需要使用對應詞的索引,然後從詞嵌入權重中取值就可以了。

我們需要將這個嵌入後的 X 傳遞給 dynamic_rnn 並返回 all_outputs[N, <max_len>, D])以及 state[1, N, D])。由於我們輸入了 seq_lens,對於我們而言它就是最後一個對應的狀態。從維度的角度來說,你可以看到, all_outputs 就是來自 RNN 的對於每個句子中的每個詞的全部輸出結果。然而,state 僅僅只是每個句子的最後一個對應輸出。

現在我們要輸入 softmax 權重,但在此之前,我們需要通過取第一個索引(state[0])來把狀態從 [1,N,D] 轉換為[N,D]。如此便可以通過與 softmax 權重 [D,C] 的點積,來得到形狀為 [N,C] 的輸出。其中,我們做指數級 softmax 運算,然後進行正則化,最終結合形狀為 [N,C]target_y 來計算損失函式。

注意:如果你使用了基本的 RNN 或者 GRU,從 dynamic_rnn 返回的 all_outputsstate 的形狀是一樣的。但是如果使用 LSTM 的話,all_outputs 的形狀就是 [N, <max_len>, D]state 的形狀為 [1, 2, N, D]


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章