深度學習筆記(一):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的內部狀態就被更新。在最簡單情況下,該內部裝著僅包含一個內部隱向量 h 。下面是一個普通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)
其中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 npimport jieba# data I/Odata = open('/home/multiangle/download/280.txt', 'rb').read() # should be simple plain text filedata = 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) }# hyperparametershidden_size = 200 # size of hidden layer of neuronsseq_length = 25 # number of steps to unroll the RNN forlearning_rate = 1e-1# model parametersWxh = np.random.randn(hidden_size, vocab_size)*0.01 # input to hiddenWhh = np.random.randn(hidden_size, hidden_size)*0.01 # hidden to hiddenWhy = np.random.randn(vocab_size, hidden_size)*0.01 # hidden to outputbh = np.zeros((hidden_size, 1)) # hidden biasby = np.zeros((vocab_size, 1)) # output biasdef 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 ixesn, p = 0, 0mWxh, 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 Adagradsmooth_loss = -np.log(1.0/vocab_size)*seq_length # loss at iteration 0while 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, 迴圈次數