一般來說,提到自然語言處理,我們都會涉及到循環神經網絡(RNN),這是因為自然語言可以被看作是一個時間序列,這個時間序列中的元素是一個個的token。傳統的前饋神經網絡結構簡單,但是不能很好的處理時間序列數據,RNN便應運而生。
一、RNN概述
語言模型給出了單詞序列發生的概率,具體來說就是評估一個單詞發生的可能性。我們之前的CBOW模型就是這種(可以參考我的前兩篇文章《自然語言處理入門2》《自然語言處理入門3》),但是CBOW模型有個問題,就是長度限制,因為CBOW需要選擇一個窗口window來作為上下文,也就是說它只能根據前后一定窗口的大小來預測中間缺失的單詞。如果不斷加大窗口大小也會造成速度和性能的困擾,并且CBOW這種模型的目的其實主要是為了獲取單詞的分布式表示方法,基于此所以語言模型一般都使用RNN。
RNN有一個特點,就是不受上下文長度限制(其實也不是不受限制,只是很大程度上可以不受限制,如果長度過長,一樣會引起梯度消失等等無法訓練的問題,所以才有后面的截斷時序訓練和門控循環神經網絡LSTM等等)。RNN最大的特征就是模型中存在回路,所以才被叫做循環神經網絡,結構圖如下:
跟一般的前饋神經網絡的區別就是,RNN的輸出除了流向下一個節點的結果之外,還要把這個結果重新作為輸入傳到模型中,我們稱之為隱藏信息h。把循環網絡展開來后,就是上圖右邊的樣子了。每個節點的輸入都是原始輸入數據本身以及上一個節點傳過來的隱藏信息。
舉個實際的例子:對于"you say goodbye and i say hello."這句話,傳遞流程如下圖所示:
1. 第一個單詞you,先進行向量化也就是embedding操作,序列沒有上一個節點傳過來的信息,因此輸入向量直接經過RNN得到一個輸出,轉變為概率,可以看到下一個輸出概率最高的是say,同時,RNN的輸出傳遞到下一個節點;
2.第二個單詞say,先進行embedding得到向量化表示,這次RNN有上一個節點傳過來的隱藏信息了,所以要把這個隱藏信息傳遞到RNN,并且將say的embedding表示數據也一并傳入RNN,得到一個輸出,轉變為概率,得到概率最高的goodbye,同時再把RNN的輸出傳遞到下一個節點;
3.第三個單詞goodbye,先進行embedding得到向量化表示,再把上一個的節點的隱藏信息一起傳遞到RNN中得到輸出,轉變為概率得到概率最高的輸出and,同時再把RNN的輸出傳遞掉下一個節點,這里輸出的隱藏信息已經包括了前面的you say goodbye三個單詞的信息了,然后以此類推;
... ...
4.最終遍歷完時間序列中的所有單詞。
不過這里要注意,其實真正的RNN并不存在這樣的鋪開式結構,這里的示意圖只是為了便于理解把RNN在時間方向上展開了而已。
二、RNN的實現
下面我們來實現一下RNN。
有順序的序列數據訓練一般采用“按時間順序展開的神經網絡的誤差反向傳播法”,簡稱BPTT。處理長時序數據時,通常的做法是將網絡連接截成適當的長度。具體來說,就是將時間軸方向上過長的網絡在合適的位置進行截斷,從而創建多個小型網絡,然后對截出來的小型網絡執行誤差反向傳播法,稱為Truncated BPTT。
這是書中提供的方法,因為當序列長度太長的時候,梯度會變得不穩定,容易出現梯度消失問題,并且消耗的計算資源也會很大,所以對網絡進行了截斷訓練,后面的LSTM,GRU等等模型也是為了解決RNN時序過長等引起的問題的方法。
Truncated BPTT的特點就是前饋網絡中正常傳遞,但是反向傳播進行截斷,以“塊”為單位進行誤差反向傳播,其實也就是等于分塊訓練,結構如下:
RNN的正向傳播用公式表示就是:
ht-1是上一個節點傳入的隱藏信息,Wh是隱藏信息的權重矩陣,xt是當前節點輸入的數據,Wx是輸入數據的權重矩陣,b是偏置。這幾個元素進行線性組合后,再應用一次非線性的正切變換,得到的就是輸出ht。
反向傳播示意圖:
反向傳播流程:
上一個節點傳入的dhnext代表傳遞過來的梯度信息,要對ht-1(也就是hprev),Wh,xt,Wx和b分別求偏導數,以得到他們各自的梯度(反向傳播算法里面有說明,可以參考深度學習教科書)。
假設ht-1Wh + xtWx + b為Z,正向傳播公式:
那么他們各自的梯度如下(由于tanh(z)對z的導數是1-(tanh(z))^2):
基于以上的推斷,我們可以寫出RNN的實現程序:
import numpy as npclass RNN:def __init__(self, Wx, Wh, b):self.params = [Wx, Wh, b]self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]self.cache = Nonedef forward(self, x, h_prev):Wx, Wh, b = self.paramst = np.dot(h_prev, Wh) + np.dot(x, Wx) + bh_next = np.tanh(t)# 暫存self.cache = (x, h_prev, h_next) return h_next # 返回正向隱藏信息def backward(self, dh_next):Wx, Wh, b = self.paramsx, h_prev, h_next = self.cachedt = dh_next*(1-h_next**2)db = np.sum(dt, axis=0)dWh = np.dot(h_prev.T, dt)dh_prev = np.dot(dt, Wh.T)dWx = np.dot(x.T, dt)dx = np.dot(dt, Wx.T)self.grads[0][...] = dWxself.grads[1][...] = dWhself.grads[2][...] = dbreturn dx, dh_prev # 返回上一個隱藏信息的梯度和輸入的梯度
根據之前說的截斷網絡訓練方法,我們在RNN的基礎上構建TimeRNN。Time RNN是T個RNN層連起來的網絡。其中有一個成員變量layers,保存T個RNN層,另一個成員變量h保存調用forward方法時的最后一個RNN層隱藏狀態。stateful=True表示繼承Time RNN層的狀態,stateful=False表示不繼承Time RNN層的狀態。
class TimeRNN:def __init__(self, Wx, Wh, b, stateful=False):self.params = [Wx, Wh, b]self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]self.layers = Noneself.h, self.dh = None, Noneself.stateful = statefuldef set_state(self, h):self.h = hdef reset_state(self):self.h = Nonedef forward(self, xs):Wx, Wh, b = self.params # 傳入權重矩陣和偏置# N表示batch_size,T是xs包括的時序數據個數,D是輸入向量的維度N, T, D = xs.shape # H表示隱藏狀態的向量維數D, H = Wx.shapeself.layers = []# 定義輸出隱藏信息hs = np.empty((N,T,H), dtype='f')# 如果是第一個節點或者不繼承隱藏狀態if not self.stateful or self.h is None:# 則輸入的隱藏信息設置為0self.h = np.zeros((N,H), dtype='f')for t in range(T):# 循環遍歷T個節點,記錄隱藏信息layer = RNN(*self.params)self.h = layer.forward(xs[:,t,:],self.h)hs[:,t,:] = self.hself.layers.append(layer)return hsdef backward(self, dhs):Wx, Wh, b = self.paramsN, T, D = dhs.shapeD, H = Wx.shapedxs = np.empty((N,T,D), dtype='f')dh = 0grads = [0,0,0]# 這里是反序遍歷T個節點for t in reversed(range(T)):layer = self.layers[t]# 每個節點內部做反向傳播dx,dh = layer.backward(dhs[:,t,:]+dh) # 求和后的梯度dxs[:,t,:] = dx# 獲取Wx,Wh和b的梯度,并進行累加for i, grad in enumerate(layer.grads):grads[i] += gradfor i,grad in enumerate(grads):self.grads[i][...] = gradself.dh = dhreturn dxs
在TimeRNN的基礎上構建了RNNLM,RNNLM是指基于RNN的語言模型(language model)。這個模型就是把我們之前的信息都合并在一起,輸入的一串時序數據,每串時序數據都要先進行Embedding操作(每串時序數據包含T個單獨的時序數據,或者叫token),然后傳入TimeRNN層,最后把輸出的隱藏信息傳入到Affine層,Affine就是一個簡單的神經網絡,進行計算得到輸出結果。(《深度學習入門:基于Python的理論與實現》有詳細論述),把這三個操作串聯起來形成一個layer。輸出的分類結果進行softmax操作,并計算出損失值,傳遞到Affine層,得到輸入的梯度dx,再輸入到TimeRNN,這里進行分塊,塊內反向傳播,得到的梯度相加,得到總的梯度,再傳遞到Emebedding,進行反向傳播,最后用優化器optimizer進行梯度更新,完成訓練。
class SimpleRnnlm:def __init__(self, vocab_size, wordvec_size, hidden_size):V,D,H = vocab_size, wordvec_size, hidden_sizern = np.random.randn# 初始化權重embed_W = (rn(V,D)/100).astype('f')rnn_Wx = (rn(D,H)/np.sqrt(D)).astype('f')rnn_Wh = (rn(H,H)/np.sqrt(H)).astype('f')rnn_b = np.zeros(H).astype('f')affine_W = (rn(H,V)/np.sqrt(H)).astype('f')affine_b = np.zeros(V).astype('f')# 生成層self.layers = [TimeEmbedding(embed_W),TimeRNN(rnn_Wx, rnn_Wh, rnn_b, stateful=True),TimeAffine(affine_W, affine_b)]self.loss_layer = TimeSoftmaxWithLoss()self.rnn_layer = self.layers[1]# 將所有的權重和梯度整理到列表中self.params, self.grads = [],[]for layer in self.layers:self.params += layer.paramsself.grads += layer.gradsdef forward(self, xs, ts):for layer in self.layers:xs = layer.forward(xs)loss = self.loss_layer.forward(xs, ts)return lossdef backward(self, dout=1):dout = self.loss_layer.backward(dout)for layer in reversed(self.layers):dout = layer.backward(dout)return doutdef reset_state(self):self.rnn_layer.reset_state()
我們可以用之前用到的一個英文語料庫ptb進行訓練。語言模型是基于已經出現的單詞預測將要出現的單詞的概率分布。困惑度(perplexity)是一個比較常用的指標,它是概率的倒數,如果預測一個單詞出現的概率是0.2,則它的困惑度是5,如果預測一個單詞出現的概率是0.8,則它的困惑度是1.25,我們的模型訓練過程可視化指標選擇用困惑度來表示。困惑度自然是越低越好。
import numpy as np# 設定超參數
batch_size = 10
wordvec_size = 100
hidden_size = 100 # RNN的隱藏狀態向量的元素個數
time_size = 5 # RNN的展開大小
lr = 0.1
max_epoch = 100# 讀入訓練數據
corpus, word_to_id, id_to_word = load_data('train')
corpus_size = 1000 # 縮小測試用的數據集
corpus = corpus[:corpus_size]
vocab_size = int(max(corpus) + 1)
xs = corpus[:-1] # 輸入
#print(xs.shape)
ts = corpus[1:] # 輸出(監督標簽)# 生成模型
model = SimpleRnnlm(vocab_size, wordvec_size, hidden_size)
optimizer = SGD(lr)
trainer = RnnlmTrainer(model, optimizer)trainer.fit(xs, ts, max_epoch, batch_size, time_size)
trainer.plot()
# 輸出
| epoch 1 | iter 1 / 19 | time 0[s] | perplexity 417.35
| epoch 2 | iter 1 / 19 | time 0[s] | perplexity 387.39
| epoch 3 | iter 1 / 19 | time 0[s] | perplexity 272.27
| epoch 4 | iter 1 / 19 | time 0[s] | perplexity 224.16
| epoch 5 | iter 1 / 19 | time 0[s] | perplexity 210.55
| epoch 6 | iter 1 / 19 | time 0[s] | perplexity 208.73
| epoch 7 | iter 1 / 19 | time 0[s] | perplexity 200.27
| epoch 8 | iter 1 / 19 | time 0[s] | perplexity 200.94
| epoch 9 | iter 1 / 19 | time 0[s] | perplexity 194.13
| epoch 10 | iter 1 / 19 | time 0[s] | perplexity 189.88
... ...
| epoch 95 | iter 1 / 19 | time 2[s] | perplexity 6.94
| epoch 96 | iter 1 / 19 | time 2[s] | perplexity 6.83
| epoch 97 | iter 1 / 19 | time 2[s] | perplexity 6.54
| epoch 98 | iter 1 / 19 | time 2[s] | perplexity 6.41
| epoch 99 | iter 1 / 19 | time 2[s] | perplexity 5.99
| epoch 100 | iter 1 / 19 | time 2[s] | perplexity 5.89
經過100個世代的訓練,可以看到困惑度從訓練一開始的417.35多下降到5.89,說明預測的概率也在不斷提升,效果還是不錯的,當然5.89的困惑度還是比較高的,繼續訓練有望進一步降低困惑度。我測試了一下,當訓練200個世代后,困惑度下降到了1.15,這就是一個比較好的結果了。