視覺化迴圈神經網路的注意力機制
作者:Zafarali Ahmed
編譯:weakish
迴圈神經網路(RNN)在翻譯(谷歌翻譯)、語音識別(Cortana)和語言生成領域取得了巨大的成功。在Datalogue,我們處理大量的文字資料,我們很有興趣幫助社群理解這一技術。
在這篇教程中,我們將基於Keras編寫一個RNN,將“November 5, 2016”、“5th November 2016”這樣的日期表達轉換為標準格式(“2016–11–05”)。具體來說,我們希望獲得一些神經網路是如何做到這些的直覺。我們將利用 注意力 概念生成一份類似下圖的對映,揭示哪些輸入字元在預測輸出字元上起著重要作用。
教程概覽
我們將從一些技術背景材料開始,接著程式設計模型!在教程中,我會提供指向更高階內容的連結。
如果你想要直接檢視程式碼:
請訪問GitHub: ofollow,noindex">datalogue/keras-attention
你需要了解
如果你想直接跳到本教程的程式碼部分,你最好熟悉Python和Keras。你應該熟悉下線性代數,畢竟神經網路不過是應用了非線性的一些權重矩陣。
下面我們將解釋RNN和seq2seq(序列到序列)模型的直覺。
迴圈神經網路(RNN)
RNN是一個應用同一變換(稱為RNN 單元 或 步驟 )至一個序列的每個元素的函式。RNN 層 的輸出是RNN單元應用至序列的每個元素後的輸出。在文字情形下,這些通常是後續的單詞或字元。此外,RNN單元維護內部記憶,總結了目前為止所見序列的歷史。
RNN 層 的輸出是一個編碼序列 h
,可以處理該序列,也可以將它傳給另一個網路。RNN的輸入和輸出極為靈活:
h h
理論上,訓練資料的序列長度不用一樣。在實踐中,我們補齊或截斷序列得到相同長度,以利用TensorFlow的靜態計算圖的優勢。
我們將重點關注第三種RNN,“多對多”,也稱為序列到序列( seq2seq )。
由於 訓練中梯度計算的不穩定性 ,RNN很難學習長序列。為了解決這一問題,可以將RNN單元替換為 門控單元 ,比如門控迴圈單元(GRU)或 長短時記憶網路(LSTM) 。如果你想了解更多LSTM和門控單元,我強烈推薦 Christopher Olah的部落格 (我就是從這篇開始理解RNN單元的)。從現在開始,當我們談論RNN的時候,我們指的是門控單元。
seq2seq一般框架:編碼器-解碼器設定
幾乎所有處理seq2seq問題的神經網路都涉及:
- 編碼 輸入序列為某種抽象表示。
- 處理 這一編碼。
- 解碼 至目標序列。
編碼器和解碼器可以是任意種類的神經網路組合。在實踐中,大多數人編碼器和解碼器都使用RNN。
上圖顯示了一個簡單的編碼器-解碼器設定。編碼步驟通常生成向量序列 h
,對應輸入資料中的字元序列 x
。在一個RNN編碼器中,通過納入之前向量序列的資訊生成每個向量。
在將 h
傳給解碼器之前,我們可以先處理一番。例如,我們也許選擇只使用最後的編碼(如下圖所示),因為理論上它是整個序列的總結。
直觀地說,這類似總結整個輸入資料為單個表示,接著嘗試加以解碼。儘管對於情緒檢測這樣的分類問題(多對一),總結狀態可能已經具備足夠資訊,對於翻譯之類的問題,僅僅使用總結狀態可能不夠,需要考慮隱藏狀態的完整序列。
然而,人類不是這麼翻譯日期的:我們並不讀取整個文字,然後單獨寫下每個字元的翻譯。從直覺上說,一個人會整體理解一組字元“Jan”對應一月,“5”對應日期,“2016”對應年。如前所述,這一想法是RNN可以捕捉的 注意 ,並且成功用於 影象說明生成(Xu等. 2015) , 語音識別(Chan等. 2015) ,還有 機器翻譯(Bahdanau等. 2014) 。最重要的是,它們生成 可解釋的模型 。
上面提到的影象說明生成論文展示了一個注意力機制如何工作的視覺化例子。在女孩和泰迪熊的複雜例子中,我們看到,生成單詞“girl”(女孩)時,注意力機制成功地聚焦女孩,而不是泰迪熊!相當聰明。這不僅可以生成效果很好的視覺化影象,同時便於作者診斷模型中的問題。
SpaCy的創造者寫了一篇編碼器-注意-解碼器正規化的深度概覽: Embed, encode, attend, predict: The new deep learning formula for state-of-the-art NLP models 。如果你想了解其他改動RNN的方式,可以參考Distill上的 Attention and Augmented Recurrent Neural Networks 。
這篇教程將介紹使用單個 雙向 LSTM作為編碼器和 注意 解碼器。更具體地說,我們將實現Bahdanau等在2014年發表的 Neural machine translation by jointly learning to align and translate 論文中提出的模型的簡化版本。我會講解部分數學,但如果你想了解細節,我邀請你閱讀論文的附錄。
現在我們已經瞭解了RNN這一概念,以及注意力機制背後的直覺,讓我們開始學習如何實現這一模型,接著取得一些漂亮的視覺化結果。後續小節所有的程式碼都可以在本文開頭給出的GitHub倉庫( datalogue/keras-attention )中找到, /models/NMT.py
為模型的完整實現。
編碼器
Keras自帶了RNN(LSTM)實現,可以通過以下方式呼叫:
BLSTM = Bidirectional(LSTM(encoder_units, return_sequences=True))
encoder_units
引數是權重矩陣的大小。 return_sequences=True
表示我們需要完整的編碼序列,而不僅僅是最終總結狀態。
我們的BLSTM將接受輸入序列 x=(x1,...,xT)
中的 字元 作為輸入,並輸出編碼序列 h=(h1,...,hT)
,其中 T
為日期的字元數。注意這和Bahdanau等論文有點不一樣,原論文中句子以單詞而不是字元為單位。我們也不像原論文那樣把編碼序列叫做 註釋(annotations) 。
解碼器
下面到了有趣的部分:解碼器。對序列 t
處的任意給定字元,解碼器接受編碼序列 h=(h1,...,hT)
、之前的隱藏狀態st-1(和解碼器單元共享)、字元yt-1。我們的解碼器層將輸出 y=(y1,...,yT)
(標準化日期中的字元)。上圖總結了我們的整體架構。
等式
如前所示,解碼器相當複雜。所以讓我們將它分解為嘗試預測字元 t
的解碼器單元執行的步驟。在下式中,大寫字母變量表示可訓練引數(注意,為了簡明,我省去了偏置項)。
1.根據編碼序列和解碼器單元的內部隱藏狀態st-1,計算注意概率 α=(α1,…,αT)
。
2.計算 上下文 向量,即帶關注概率的編碼序列加權和。直觀地說,這一向量總結了不同編碼字元在預測第t個字元上的作用。
3.我們接著更新隱藏狀態。如果你熟悉LSTM單元的等式,這些也許會喚起你的回憶,重置門 r
,更新門 z
,以及提議狀態。st-1用於建立提議隱藏狀態。更新門控制在新的隱藏狀態st中包括多少提議。(沒有頭緒? 看這篇逐步講解LSTM的文章 )
4.根據上下文向量、隱藏狀態、之前字元,使用一個簡單的單層神經網路計算第 t
個字元。相比原論文,這裡做了一點改動,原論文用了一個maxout層。這一改動是因為我們想要讓模型儘可能地簡單。
上面的這些等式應用於編碼序列中的每個字元,以生成解碼序列 y
,該序列表示每個位置出現某個轉譯字元的概率。
程式碼
models/custom_recurrent.py
實現了我們的定製層。這一部分比較複雜,因為我們需要對整個編碼序列進行處理。多思考一下能幫助你看懂程式碼。我保證,如果你一邊看等式,一邊看程式碼,會容易不少。
最低限度的定製Keras層需要實現這些方法: __init__
, compute_output_shape
, build
, call
。出於完整性考慮,我們也實現了 get_config
,這讓我們可以很容易地重新載入模型到記憶體之中。此外,Keras迴圈層實現了 step
方法,包括單元中的所有計算。
下面我們首先分步講解下樣板 程式碼 :
-
__init__
是在初始化層時呼叫的方法。它設定將逐漸用於初始化權重、正則化、限制的函式。由於我們的層輸出是序列,我們硬編碼了self.return_sequences=True
。 -
build
是在執行Model.compile(…)
時呼叫的方法。由於我們的模型相當複雜,你可以看到這裡初始化了一大堆權重。self.add_weight
呼叫自動處理初始化權重,並將權重設為模型的可訓練引數。下標為a
的權重用於計算上下文向量(第1步和第2步)。下標為r
、z
、p
的權重用於計算第3步的新隱藏狀態。最後,下標為o
的權重將計算層輸出。 - 我們還實現了一些輔助函式:
compute_output_shape
為任意給定輸入計算輸出形狀;get_config
讓我們從儲存檔案中載入模型(完成訓練之後)。
現在讓我們來看單元邏輯:
預設情況下,單元的每次執行只具備上一時步的資訊。由於我們需要訪問單元內的完整編碼序列,我們需要將它儲存在某處。
def call(self, x): # 儲存完整序列 self.x_seq = x # 對序列的時間維度應用一個密集層。 # 由於它不依賴任何之前的步驟, # 我們可以在這裡應用,以節省計算時間: self._uxpb = _time_distributed_dense(self.x_seq, self.U_a, b=self.b_a, input_dim=self.input_dim, timesteps=self.timesteps, output_dim=self.units) return super(AttentionDecoder, self).call(x)
下面我們將講解程式碼最重要的部分,執行單元邏輯的 step
函式。回憶一下, step
應用於輸入序列的每個元素。
def step(self, x, states): # 獲取上一時步的元素 ytm, stm = states ################## # 等式 1 # > 重複隱藏狀態至序列長度 _stm = K.repeat(stm, self.timesteps) # > 權重矩陣乘以 #重複隱藏狀態 _Wxstm = K.dot(_stm, self.W_a) # > 計算未歸一化的概率 et = K.dot(activations.tanh(_Wxstm + self._uxpb), K.expand_dims(self.V_a)) ################## # 等式 2 at = K.exp(et) at_sum = K.sum(at, axis=1) at_sum_repeated = K.repeat(at_sum, self.timesteps) # 向量 (batch大小, 時步, 1) at /= at_sum_repeated ################## # 等式 3 context = K.squeeze( K.batch_dot(at, self.x_seq, axes=1), axis=1) # ~~~> 計算新隱藏狀態 # 等式 4(重置門) rt = activations.sigmoid( K.dot(ytm, self.W_r) + K.dot(stm, self.U_r) + K.dot(context, self.C_r) + self.b_r) # 等式 5 (更新門) zt = activations.sigmoid( K.dot(ytm, self.W_z) + K.dot(stm, self.U_z) + K.dot(context, self.C_z) + self.b_z) # 等式 6 (提議狀態) s_tp = activations.tanh( K.dot(ytm, self.W_p) + K.dot((rt * stm), self.U_p) + K.dot(context, self.C_p) + self.b_p) # 等式 7 (新隱藏狀態) st = (1-zt)*stm + zt * s_tp # 等式 8 # 出現每個字元的概率 yt = activations.softmax( K.dot(ytm, self.W_o) + K.dot(st, self.U_o) + K.dot(context, self.C_o) + self.b_o) # 方便我們返回 # 視覺化注意的開關 if self.return_probabilities: return at, [yt, st] else: return yt, [yt, st]
在這個單元中,我們想要訪問從 states
獲得的之前字元 ytm
和隱藏狀態 stm
(程式碼第4行)。
我們在第11-18行實現了等式1的一個版本,一次性計算序列中的所有字元。
在第24-28行我們以向量形式為整個序列實現了等式2. 使用 repeat
讓我們可以根據各自的總和劃分每個時步。
為了計算上下文向量,我們要記得 self.x_seq
和 at
有一個“batch維度”,因此我們需要使用 batch_dot
以免在那個維度上相乘。 squeeze
操作不過是移除殘留維度。(程式碼第33-37行。)
之後的程式碼是等式4-8的比較直接的實現。
現在我們需要一點先見之明,我們想要計算文章開頭那樣酷炫的注意對映,所以需要一個切換開關。
訓練
資料
Faker庫可以生成虛假日期,我用這個庫生成了日期,並用Babel庫生成不同語言和格式的日期(借鑑了 rasmusbergpalm/normalization 的做法)。如果你想要了解細節,我邀請你直接去看 data/generate.py
中的程式碼(歡迎改進)。
這個指令碼同時生成了轉換字元至整數的詞彙表,以便神經網路理解字元。 data/reader.py
指令碼可以讀取資料,併為神經網路準備資料。
模型
如前所述,我們實現的模型見 models/NMT.py
。你可以通過 python run.py
執行這個模型(我設定了一些預設引數,詳見Readme)。我建議在GPU上訓練模型,因為在CPU上訓練會比較慢。
如果你想要跳過訓練部分,那我在 weights/
中提供了一些權重。
視覺化
visualizer.py
是視覺化部分的程式碼,兩次載入權重:一次用於預測模型,一次用於獲取概率。
from models.NMT import simpleNMT predictive_model = simpleNMT(...) predictive_model.load_weights(..., return_probabilities=False) probability_model = simpleNMT(..., return_probabilities=True) probability_model.load_weights(...)
執行以下命令可以檢視提供的命令列選項:
python visualizer.py -h
視覺化例子
現在讓我們檢視下 probability_model
生成的關注。我們可以在y軸上看到上面的 probability_model
返回的轉換後日期。在x軸上則是我們的輸入日期。下圖顯示了在預測y軸上的輸出字元時用到了哪些x軸上的輸入字元。顏色越淡,字元的權重越高。
下面是一些我覺得相當有趣的例子。
毫不在意星期幾這樣的無關資訊:
下面則是一個轉換錯誤的例子,因為我們提交的樣本的順序不合常規:“January 2016 05”被轉換成“2016–01–02”,而不是“2016–01–05”。
我們可以看到,模型將2016的“20”錯誤地解讀為幾號,不過這一啟用很薄弱,部分甚至和實際日期“5”的啟用相當。這給我們提供瞭如何更好地訓練模型的洞見。
結語
我希望這篇教程能讓你瞭解如何從頭到尾求解一個機器學習問題。此外,我也希望它有助於你嘗試視覺化用於seq2seq問題的迴圈神經網路。如果我遺漏了什麼,或者你發現了什麼可以改進的地方,歡迎在twitter上聯絡我(zafarali),或者在本文的配套程式碼倉庫上 提交工單 。