深度學習筆記(四):迴圈神經網路的概念,結構和程式碼註釋

深度學習筆記(一):logistic分類
深度學習筆記(二):簡單神經網路,後向傳播演算法及實現
深度學習筆記(三):啟用函式和損失函式
深度學習筆記:優化方法總結(BGD,SGD,Momentum,AdaGrad,RMSProp,Adam)
深度學習筆記(四):迴圈神經網路的概念,結構和程式碼註釋
深度學習筆記(五):LSTM
深度學習筆記(六):Encoder-Decoder模型和Attention模型


本文的概念和結構部分摘自迴圈神經網路驚人的有效性(上),程式碼部分來自minimal character-level RNN language model in Python/numpy 我對程式碼做了詳細的註釋


迴圈神經網路

序列 普通神經網路和卷積神經網路的一個顯而易見的侷限就是他們的API都過於限制:他們接收一個固定尺寸的向量作為輸入(比如一張影象),並且產生一個固定尺寸的向量作為輸出(比如針對不同分類的概率)。不僅如此,這些模型甚至對於上述對映的演算操作的步驟也是固定的(比如模型中的層數)。RNN之所以如此讓人興奮,其核心原因在於其允許我們對向量的序列進行操作:輸入可以是序列,輸出也可以是序列,在最一般化的情況下輸入輸出都可以是序列。下面是一些直觀的例子:


上圖中每個正方形代表一個向量,箭頭代表函式(比如矩陣乘法)。輸入向量是紅色,輸出向量是藍色,綠色向量裝的是RNN的狀態(馬上具體介紹)。從左至右為:

非RNN的普通過程,從固定尺寸的輸入到固定尺寸的輸出(比如影象分類)。
輸出是序列(例如影象標註:輸入是一張影象,輸出是單詞的序列)。
輸入是序列(例如情緒分析:輸入是一個句子,輸出是對句子屬於正面還是負面情緒的分類)。
輸入輸出都是序列(比如機器翻譯:RNN輸入一個英文句子輸出一個法文句子)。
同步的輸入輸出序列(比如視訊分類中,我們將對視訊的每一幀都打標籤)。
注意在每個案例中都沒有對序列的長度做出預先規定,這是因為迴圈變換(綠色部分)是固定的,我們想用幾次就用幾次。


如你期望的那樣,相較於那些從一開始連計算步驟的都定下的固定網路,序列體制的操作要強大得多。並且對於那些和我們一樣希望構建一個更加智慧的系統的人來說,這樣的網路也更有吸引力。我們後面還會看到,RNN將其輸入向量、狀態向量和一個固定(可學習的)函式結合起來生成一個新的狀態向量。在程式的語境中,這可以理解為執行一個具有某些輸入和內部變數的固定程式。從這個角度看,RNN本質上就是在描述程式。實際上RNN是具備圖靈完備性的,只要有合適的權重,它們可以模擬任意的程式。然而就像神經網路的通用近似理論一樣,你不用過於關注其中細節。實際上,我建議你忘了我剛才說過的話。

如果訓練普通神經網路是對函式做最優化,那麼訓練迴圈網路就是針對程式做最優化。

無序列也能進行序列化處理。你可能會想,將序列作為輸入或輸出的情況是相對少見的,但是需要認識到的重要一點是:即使輸入或輸出是固定尺寸的向量,依然可以使用這個強大的形式體系以序列化的方式對它們進行處理。例如,下圖來自於DeepMind的兩篇非常不錯的論文。左側動圖顯示的是一個演算法學習到了一個迴圈網路的策略,該策略能夠引導它對影象進行觀察;更具體一些,就是它學會了如何從左往右地閱讀建築的門牌號。右邊動圖顯示的是一個迴圈網路通過學習序列化地向畫布上新增顏色,生成了寫有數字的圖片。


左邊:RNN學會如何閱讀建築物門牌號。右邊:RNN學會繪出建築門牌號。


必須理解到的一點就是:即使資料不是序列的形式,仍然可以構建並訓練出能夠進行序列化處理資料的強大模型。換句話說,你是要讓模型學習到一個處理固定尺寸資料的分階段程式。

RNN的計算。那麼RNN到底是如何工作的呢?在其核心,RNN有一個貌似簡單的API:它接收輸入向量x,返回輸出向量y。然而這個輸出向量的內容不僅被輸入資料影響,而且會收到整個歷史輸入的影響。寫成一個類的話,RNN的API只包含了一個step方法:

rnn = RNN()
y = rnn.step(x) # x is an input vector, y is the RNN's output vector

每當step方法被呼叫的時候,RNN的內部狀態就被更新。在最簡單情況下,該內部裝著僅包含一個內部隱向量hh。下面是一個普通RNN的step方法的實現:

class RNN:
# ...
def step(self, x):
# update the hidden state
self.h = np.tanh(np.dot(self.W_hh, self.h)   np.dot(self.W_xh, x))
# compute the output vector
y = np.dot(self.W_hy, self.h)
return y

上面的程式碼詳細說明了普通RNN的前向傳播。該RNN的引數是三個矩陣:W_hh, W_xh, W_hy。隱藏狀態self.h被初始化為零向量。np.tanh函式是一個非線性函式,將啟用資料擠壓到[-1,1]之內。注意程式碼是如何工作的:在tanh內有兩個部分。一個是基於前一個隱藏狀態,另一個是基於當前的輸入。在numpy中,np.dot是進行矩陣乘法。兩個中間變數相加,其結果被tanh處理為一個新的狀態向量。如果你更喜歡用數學公式理解,那麼公式是這樣的:

ht=tanh(Whhht−1 Whxxt)

h_t = tanh(W_{hh} h_{t-1} W_{hx}x_t)
其中tanh是逐元素進行操作的。

我們使用隨機數字來初始化RNN的矩陣,進行大量的訓練工作來尋找那些能夠產生描述行為的矩陣,使用一些損失函式來衡量描述的行為,這些損失函式代表了根據輸入x,你對於某些輸出y的偏好。

更深層網路 RNN屬於神經網路演算法,如果你像疊薄餅一樣開始對模型進行重疊來進行深度學習,那麼演算法的效能會單調上升(如果沒出岔子的話)。例如,我們可以像下面程式碼一樣構建一個2層的迴圈網路:

y1 = rnn1.step(x)
y = rnn2.step(y1)

換句話說,我們分別有兩個RNN:一個RNN接受輸入向量,第二個RNN以第一個RNN的輸出作為其輸入。其實就RNN本身來說,它們並不在乎誰是誰的輸入:都是向量的進進出出,都是在反向傳播時梯度通過每個模型。

更好的網路。需要簡要指明的是在實踐中通常使用的是一個稍有不同的演算法,這就是我在前面提到過的長短基記憶網路,簡稱LSTM。LSTM是迴圈網路的一種特別型別。由於其更加強大的更新方程和更好的動態反向傳播機制,它在實踐中效果要更好一些。本文不會進行細節介紹,但是在該演算法中,所有本文介紹的關於RNN的內容都不會改變,唯一改變的是狀態更新(就是self.h=…那行程式碼)變得更加複雜。從這裡開始,我會將術語RNN和LSTM混合使用,但是在本文中的所有實驗都是用LSTM完成的。


字母級別的語言模型

現在我們已經理解了RNN是什麼,它們何以令人興奮,以及它們是如何工作的。現在通過一個有趣的應用來更深入地加以體會:我們將利用RNN訓練一個字母級別的語言模型。也就是說,給RNN輸入巨量的文字,然後讓其建模並根據一個序列中的前一個字母,給出下一個字母的概率分佈。這樣就使得我們能夠一個字母一個字母地生成新文字了。

在下面的例子中,假設我們的字母表只由4個字母組成“helo”,然後利用訓練序列“hello”訓練RNN。該訓練序列實際上是由4個訓練樣本組成:1.當h為上文時,下文字母選擇的概率應該是e最高。2.l應該是he的下文。3.l應該是hel文字的下文。4.o應該是hell文字的下文。

具體來說,我們將會把每個字母編碼進一個1到k的向量(除對應字母為1外其餘為0),然後利用step方法一次一個地將其輸入給RNN。隨後將觀察到4維向量的序列(一個字母一個維度)。我們將這些輸出向量理解為RNN關於序列下一個字母預測的信心程度。下面是流程圖:


這裡寫圖片描述
一個RNN的例子:輸入輸出是4維的層,隱層神經元數量是3個。該流程圖展示了使用hell作為輸入時,RNN中啟用資料前向傳播的過程。輸出層包含的是RNN關於下一個字母選擇的置信度(字母表是helo)。我們希望綠色數字大,紅色數字小。


舉例如下:在第一步,RNN看到了字母h後,給出下一個字母的置信度分別是h為1,e為2.2,l為-3.0,o為4.1。因為在訓練資料(字串hello)中下一個正確的字母是e,所以我們希望提高它的置信度(綠色)並降低其他字母的置信度(紅色)。類似的,在每一步都有一個目標字母,我們希望演算法分配給該字母的置信度應該更大。因為RNN包含的整個操作都是可微分的,所以我們可以通過對演算法進行反向傳播(微積分中鏈式法則的遞迴使用)來求得權重調整的正確方向,在正確方向上可以提升正確目標字母的得分(綠色粗體數字)。然後進行引數更新,即在該方向上輕微移動權重。如果我們將同樣的資料輸入給RNN,在引數更新後將會發現正確字母的得分(比如第一步中的e)將會變高(例如從2.2變成2.3),不正確字母的得分將會降低。重複進行一個過程很多次直到網路收斂,其預測與訓練資料連貫一致,總是能正確預測下一個字母。

更技術派的解釋是我們對輸出向量同步使用標準的Softmax分類器(也叫作交叉熵損失)。使用小批量的隨機梯度下降來訓練RNN,使用RMSProp或Adam來讓引數穩定更新。

注意當字母l第一次輸入時,目標字母是l,但第二次的目標是o。因此RNN不能只靠輸入資料,必須使用它的迴圈連線來保持對上下文的跟蹤,以此來完成任務。

在測試時,我們向RNN輸入一個字母,得到其預測下一個字母的得分分佈。我們根據這個分佈取出得分最大的字母,然後將其輸入給RNN以得到下一個字母。重複這個過程,我們就得到了文字!現在使用不同的資料集訓練RNN,看看將會發生什麼。

為了更好的進行介紹,我基於教學目的寫了程式碼, 只有100多行

"""
Minimal character-level Vanilla RNN model. Written by Andrej Karpathy (@karpathy)
BSD License
"""
import numpy as np
import jieba
# data I/O
data = open('/home/multiangle/download/280.txt', 'rb').read() # should be simple plain text file
data = data.decode('gbk')
data = list(jieba.cut(data,cut_all=False))
chars = list(set(data))
data_size, vocab_size = len(data), len(chars)
print ('data has %d characters, %d unique.' % (data_size, vocab_size))
char_to_ix = { ch:i for i,ch in enumerate(chars) }
ix_to_char = { i:ch for i,ch in enumerate(chars) }
# hyperparameters
hidden_size = 200   # size of hidden layer of neurons
seq_length = 25 # number of steps to unroll the RNN for
learning_rate = 1e-1
# model parameters
Wxh = np.random.randn(hidden_size, vocab_size)*0.01 # input to hidden
Whh = np.random.randn(hidden_size, hidden_size)*0.01 # hidden to hidden
Why = np.random.randn(vocab_size, hidden_size)*0.01 # hidden to output
bh = np.zeros((hidden_size, 1)) # hidden bias
by = np.zeros((vocab_size, 1)) # output bias
def lossFun(inputs, targets, hprev):
"""
inputs,targets are both list of integers.
hprev is Hx1 array of initial hidden state
returns the loss, gradients on model parameters, and last hidden state
"""
xs, hs, ys, ps = {}, {}, {}, {}
hs[-1] = np.copy(hprev)  # hprev 中間層的值, 存作-1,為第一個做準備
loss = 0
# forward pass
for t in range(len(inputs)):
xs[t] = np.zeros((vocab_size,1)) # encode in 1-of-k representation
xs[t][inputs[t]] = 1    # x[t] 是一個第t個輸入單詞的向量
# 雙曲正切, 啟用函式, 作用跟sigmoid類似
# h(t) = tanh(Wxh*X   Whh*h(t-1)   bh) 生成新的中間層
hs[t] = np.tanh(np.dot(Wxh, xs[t])   np.dot(Whh, hs[t-1])   bh) # hidden state  tanh
# y(t) = Why*h(t)   by
ys[t] = np.dot(Why, hs[t])   by # unnormalized log probabilities for next chars
# softmax regularization
# p(t) = softmax(y(t))
ps[t] = np.exp(ys[t]) / np.sum(np.exp(ys[t])) # probabilities for next chars, 對輸出作softmax
# loss  = -log(value) 預期輸出是1,因此這裡的value值就是此次的代價函式,使用 -log(*) 使得離正確輸出越遠,代價函式就越高
loss  = -np.log(ps[t][targets[t],0]) # softmax (cross-entropy loss) 代價函式是交叉熵
# 將輸入迴圈一遍以後,得到各個時間段的h, y 和 p
# 得到此時累積的loss, 準備進行更新矩陣
# backward pass: compute gradients going backwards
dWxh, dWhh, dWhy = np.zeros_like(Wxh), np.zeros_like(Whh), np.zeros_like(Why) # 各矩陣的引數進行
dbh, dby = np.zeros_like(bh), np.zeros_like(by)
dhnext = np.zeros_like(hs[0])   # 下一個時間段的潛在層,初始化為零向量
for t in reversed(range(len(inputs))): # 把時間作為維度,則梯度的計算應該沿著時間回溯
dy = np.copy(ps[t])  # 設dy為實際輸出,而期望輸出(單位向量)為y, 代價函式為交叉熵函式
dy[targets[t]] -= 1 # backprop into y. see http://cs231n.github.io/neural-networks-case-study/#grad if confused here
dWhy  = np.dot(dy, hs[t].T)  # dy * h(t).T h層值越大的項,如果錯誤,則懲罰越嚴重。反之,獎勵越多(這邊似乎沒有考慮softmax的求導?)
dby  = dy # 這個沒什麼可說的,與dWhy一樣,只不過h項=1, 所以直接等於dy
dh = np.dot(Why.T, dy)   dhnext # backprop into h  z_t = Why*H_t   b_y H_t = tanh(Whh*H_t-1   Whx*X_t), 第一階段求導
dhraw = (1 - hs[t] * hs[t]) * dh # backprop through tanh nonlinearity  第二階段求導,注意tanh的求導
dbh  = dhraw   # dbh表示傳遞 到h層的誤差
dWxh  = np.dot(dhraw, xs[t].T)    # 對Wxh的修正,同Why
dWhh  = np.dot(dhraw, hs[t-1].T)  # 對Whh的修正
dhnext = np.dot(Whh.T, dhraw)     # h層的誤差通過Whh不停地累積
for dparam in [dWxh, dWhh, dWhy, dbh, dby]:
np.clip(dparam, -5, 5, out=dparam) # clip to mitigate exploding gradients
return loss, dWxh, dWhh, dWhy, dbh, dby, hs[len(inputs)-1]
def sample(h, seed_ix, n):
"""
sample a sequence of integers from the model
h is memory state, seed_ix is seed letter for first time step
"""
x = np.zeros((vocab_size, 1))
x[seed_ix] = 1
ixes = []
for t in range(n):
h = np.tanh(np.dot(Wxh, x)   np.dot(Whh, h)   bh)    # 更新中間層
y = np.dot(Why, h)   by             # 得到輸出
p = np.exp(y) / np.sum(np.exp(y))   # softmax
ix = np.random.choice(range(vocab_size), p=p.ravel())   # 根據softmax得到的結果,按概率產生下一個字元
x = np.zeros((vocab_size, 1))       # 產生下一輪的輸入
x[ix] = 1
ixes.append(ix)
return ixes
n, p = 0, 0
mWxh, mWhh, mWhy = np.zeros_like(Wxh), np.zeros_like(Whh), np.zeros_like(Why)
mbh, mby = np.zeros_like(bh), np.zeros_like(by) # memory variables for Adagrad
smooth_loss = -np.log(1.0/vocab_size)*seq_length # loss at iteration 0
while True:
# prepare inputs (we're sweeping from left to right in steps seq_length long)
if p seq_length 1 >= len(data) or n == 0:   # 如果 n=0 或者 p過大
hprev = np.zeros((hidden_size,1)) # reset RNN memory 中間層內容初始化,零初始化
p = 0 # go from start of data           # p 重置
inputs = [char_to_ix[ch] for ch in data[p:p seq_length]] # 一批輸入seq_length個字元
targets = [char_to_ix[ch] for ch in data[p 1:p seq_length 1]]  # targets是對應的inputs的期望輸出。
# sample from the model now and then
if n % 100 == 0:      # 每迴圈100詞, sample一次,顯示結果
sample_ix = sample(hprev, inputs[0], 200)
txt = ''.join(ix_to_char[ix] for ix in sample_ix)
print ('----\n %s \n----' % (txt, ))
# forward seq_length characters through the net and fetch gradient
loss, dWxh, dWhh, dWhy, dbh, dby, hprev = lossFun(inputs, targets, hprev)
smooth_loss = smooth_loss * 0.999   loss * 0.001   # 將原有的Loss與新loss結合起來
if n % 100 == 0: print ('iter %d, loss: %f' % (n, smooth_loss)) # print progress
# perform parameter update with Adagrad
for param, dparam, mem in zip([Wxh, Whh, Why, bh, by],
[dWxh, dWhh, dWhy, dbh, dby],
[mWxh, mWhh, mWhy, mbh, mby]):
mem  = dparam * dparam  # 梯度的累加
param  = -learning_rate * dparam / np.sqrt(mem   1e-8) # adagrad update 隨著迭代次數增加,引數的變更量會越來越小
p  = seq_length # move data pointer
n  = 1 # iteration counter, 迴圈次數