Keras中的循環層
上面的NumPy簡單實現對應一個實際的Keras層—SimpleRNN層。不過,二者有一點小區別:SimpleRNN層能夠像其他Keras層一樣處理序列批量,而不是像NumPy示例中的那樣只能處理單個序列。也就是說,它接收形狀為(batch_size,timesteps, input_features)的輸入,而不是(timesteps, input_features)。指定初始Input()的shape參數時,你可以將timesteps設為None,這樣神經網絡就能夠處理任意長度的序列,如代碼清單10-16所示。
代碼清單10-16 能夠處理任意長度序列的RNN層
num_features = 14
inputs = keras.Input(shape=(None, num_features))
outputs = layers.SimpleRNN(16)(inputs)
果你想讓模型處理可變長度的序列,那么這就特別有用。但是,如果所有序列的長度相同,那么我建議指定完整的輸入形狀,因為這樣model.summary()能夠顯示輸出長度信息,這總是很好的,而且還可以解鎖一些性能優化功能。Keras中的所有循環層(SimpleRNN層、LSTM層和GRU層)都可以在兩種模式下運行:一種是返回每個時間步連續輸出的完整序列,即形狀為(batch_size, timesteps,output_features)的3階張量;另一種是只返回每個輸入序列的最終輸出,即形狀為(batch_size, output_features)的2階張量。這兩種模式由return_sequences參數控制。我們來看一個SimpleRNN示例,它只返回最后一個時間步的輸出,如代碼清單10-17所示。
代碼清單10-17 只返回最后一個時間步輸出的RNN層
>>> num_features = 14
>>> steps = 120
>>> inputs = keras.Input(shape=(steps, num_features))
>>> outputs = layers.SimpleRNN(16, return_sequences=False)(inputs) ←----請注意,默認情況下使用return_sequences=False
>>> print(outputs.shape)
(None, 16)
代碼清單10-18給出的示例返回了完整的狀態序列。
代碼清單10-18 返回完整輸出序列的RNN層
>>> num_features = 14
>>> steps = 120
>>> inputs = keras.Input(shape=(steps, num_features))
>>> outputs = layers.SimpleRNN(16, return_sequences=True)(inputs)
>>> print(outputs.shape)
(None, 120, 16)
為了提高神經網絡的表示能力,有時將多個循環層逐個堆疊也是很有用的。在這種情況下,你需要讓所有中間層都返回完整的輸出序列,如代碼清單10-19所示。
代碼清單10-19 RNN層堆疊
inputs = keras.Input(shape=(steps, num_features))
x = layers.SimpleRNN(16, return_sequences=True)(inputs)
x = layers.SimpleRNN(16, return_sequences=True)(x)
outputs = layers.SimpleRNN(16)(x)
我們在實踐中很少會用到SimpleRNN層。它通常過于簡單,沒有實際用途。特別是SimpleRNN層有一個主要問題:在t時刻,雖然理論上來說它應該能夠記住許多時間步之前見過的信息,但事實證明,它在實踐中無法學到這種長期依賴。原因在于梯度消失問題,這一效應類似于在層數較多的非循環網絡(前饋網絡)中觀察到的效應:隨著層數的增加,神經網絡最終變得無法訓練。Yoshua Bengio等人在20世紀90年代初研究了這一效應的理論原因。
值得慶幸的是,SimpleRNN層并不是Keras中唯一可用的循環層,還有另外兩個:LSTM層和GRU層,二者都是為解決這個問題而設計的。我們來看LSTM層,其底層的長短期記憶(LSTM)算法由Sepp Hochreiter和Jürgen Schmidhuber在1997年開發4,是二人研究梯度消失問題的重要成果。
LSTM層是SimpleRNN層的變體,它增加了一種攜帶信息跨越多個時間步的方式。假設有一條傳送帶,其運行方向平行于你所處理的序列。序列中的信息可以在任意位置跳上傳送帶,然后被傳送到更晚的時間步,并在需要時原封不動地跳回來。這其實就是LSTM的原理:保存信息以便后續使用,從而防止較早的信號在處理過程中逐漸消失。這應該會讓你想到殘差連接,二者的思路幾乎相同。為了詳細解釋LSTM,我們先從SimpleRNN單元開始講起,如圖10-8所示。因為有許多個權重矩陣,所以對單元中的W和U兩個矩陣添加下標字母o(Wo和Uo)?,表示輸出(output)?。
我們向圖10-8中添加新的數據流,其中攜帶跨越時間步的信息。這條數據流在不同時間步的值稱為c_t,其中c表示攜帶(carry)?。這些信息會對單元產生以下影響:它將與輸入連接和循環連接進行計算(通過密集變換,即與權重矩陣做點積,然后加上偏置,再應用激活函數)?,從而影響傳遞到下一個時間步的狀態(通過激活函數和乘法運算)?。從概念上來看,攜帶數據流可以調節下一個輸出和下一個狀態,如圖10-9所示。到目前為止,內容都很簡單。
下面來看一下這種方法的精妙之處,即攜帶數據流下一個值的計算方法。它包含3個變換,這3個變換的形式都與SimpleRNN單元相同,如下所示。
y = activation(dot(state_t, U) + dot(input_t, W) + b)
但這3個變換都有各自的權重矩陣,我們分別用字母i、f、k作為下標。目前的模型如代碼清單10-20所示(這可能看起來有些隨意,但請你耐心一點)?。
代碼清單10-20 LSTM架構的詳細偽代碼(1/2)
output_t = activation(dot(state_t, Uo) + dot(input_t, Wo) + dot(c_t, Vo) + bo)
i_t = activation(dot(state_t, Ui) + dot(input_t, Wi) + bi)
f_t = activation(dot(state_t, Uf) + dot(input_t, Wf) + bf)
k_t = activation(dot(state_t, Uk) + dot(input_t, Wk) + bk)
通過對i_t、f_t和k_t進行計算,我們得到了新的攜帶狀態(下一個c_t)?,如代碼清單10-21所示。代碼清單10-21 LSTM架構的詳細偽代碼(2/2)
c_t+1 = i_t * k_t + c_t * f_t
添加上述內容之后的模型如圖10-10所示。這就是LSTM層,不算很復雜,只是稍微有些復雜而已。
你甚至可以解釋每個運算的作用。比如你可以說,將c_t和f_t相乘,是為了故意遺忘攜帶數據流中不相關的信息。同時,i_t和k_t都包含關于當前時間步的信息,可以用新信息來更新攜帶數據流。但歸根結底,這些解釋并沒有多大意義,因為這些運算的實際效果是由權重參數決定的,而權重以端到端的方式進行學習,每次訓練都要從頭開始,因此不可能為某個運算賦予特定的意義。RNN單元的類型(如前所述)決定了假設空間,即在訓練過程中搜索良好模型配置的空間,但它不能決定RNN單元的作用,那是由單元權重來決定的。相同的單元具有不同的權重,可以起到完全不同的作用。因此,RNN單元的運算組合最好被解釋為對搜索的一組約束,而不是工程意義上的設計。這種約束的選擇(如何實現RNN單元)最好留給優化算法來完成(比如遺傳算法或強化學習過程)?,而不是讓人類工程師來完成。那將是未來我們構建模型的方式。總之,你不需要理解LSTM單元的具體架構。作為人類,你不需要理解它,而只需記住LSTM單元的作用:允許過去的信息稍后重新進入,從而解決梯度消失問題。