深度學習筆記(四):循環神經網絡的概念,結構和代碼註釋

深度學習筆記(一):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(Whhht1+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 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, 循環次數
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章