曾經,為了處理一些序列相關的數據,我稍微了解了一點遞歸網絡 (RNN) 的東西。由於當時只會 tensorflow,就從官網上找了一些 tensorflow 相關的 demo,中間陸陸續續折騰了兩個多星期,才對 squence to sequence,sequence classification 這些常見的模型和代碼有了一些膚淺的認識。雖然只是多了時間這個維度,但 RNN 相關的東西,不僅是模型搭建上,在數據處理方面的繁瑣程度也比 CNN 要高一個 level。另外,我也是從那個時候開始對 tensorflow 產生抵觸心理,在 tf 中,你知道 RNN 有幾種寫法嗎?你知道 dynamic_rnn 和 static_rnn 有什么區別嗎?各種紛繁復雜的概念無疑加大了初學者的門檻。后來我花了一兩天的時間轉向 pytorch 后,感覺整個世界瞬間清凈了 (當然了,學 tf 的好處就是轉其他框架的時候非常快,但從其他框架轉 tf 卻可能生不如死)。pytorch 在模型搭建和數據處理方面都非常好上手,比起 tf 而言,代碼寫起來更加整潔干凈,而且開發人員更容易理解代碼的運作流程。不過,在 RNN 這個問題上,新手還是容易犯嘀咕。趁着這一周剛剛摸清了 pytorch 搭建 RNN 的套路,我准備記錄一下用 pytorch 搭建 RNN 的基本流程,以及數據處理方面要注意的問題,希望后來的同學們少流點血淚...
至於 tf 怎么寫 RNN,之后有閑再補上 (我現在是真的不想回去碰那顆燙手的山芋😩)

什么是 RNN
雖然說我們用的是 API,但對於 RNN 是什么東西還是得了解一下吧。對於從沒接觸過 RNN 的小白來說,karpathy 這篇家喻戶曉的文章是一定要讀一下的,如果想更加形象地了解它的工作機制,可以搜一些李宏毅的深度學習教程。
RNN 其實也是一個普通的神經網絡,只不過多了一個 hidden state 來保存歷史信息。跟一般網絡不同的是,RNN 網絡的輸入數據的維度通常是 \([batch\_size \times seq\_len \times input\_size ]\),它多了一個序列長度 \(seq\_len\)。在前向過程中,我們會把樣本 \(t\) 個時間序列的信息不斷輸入同一個網絡 (見上圖),因為是重復地使用同一個網絡,所以稱為遞歸網絡。
關於 RNN,你只需要記住一個公式:\(h_t = \tanh(w_{ih} x_t + b_{ih} + w_{hh} h_{(t-1)} + b_{hh})\)。這也是 pytorch 官方文檔中給出的最原始的 RNN 公式,其中 \(w_{*}\) 表示 weight,\(b_{*}\) 表示 bias,\(x_t\) 是輸入,\(h_t\) 是隱藏狀態。回憶一下,普通的神經網絡只有 \(w_{ih} x_t + b_{ih}\) 這一部分,而 RNN 無非就是多加了一個隱藏狀態的信息 \(w_{hh} h_{(t-1)} + b_{hh}\) 而已。
普通網絡都是一次前向傳播就得到結果,而 RNN 因為多了 sequence 這個維度,所以需要跑 n 次前向。我們用 numpy 的寫法把 RNN 的工作流程總結一下,就得到了如下代碼 (部分抄自 karpathy 的文章):
# 這里要啰嗦一句,karpathy在RNN的前向中還計算了一個輸出向量output vector,
# 但根據RNN的原始公式,它的輸出只有一個hidden state,至於整個網絡最后的output vector,
# 在hidden state之后再接一個全連接層得到的,所以並不屬於RNN的內容。
# 包括pytorch和tf框架中,RNN的輸出也只有hidden state。理解這一點很重要。
class RNN:
# ...
def step(self, x, hidden):
# update the hidden state
hidden = np.tanh(np.dot(self.W_hh, hidden) + np.dot(self.W_xh, x))
return hidden
rnn = RNN()
# x: [batch_size * seq_len * input_size]
x = get_data()
seq_len = x.shape[1]
# 初始化一個hidden state,RNN中的參數沒有包括hidden state,
# 只包括hidden state對應的權重W和b,
# 所以一般我們會手動初始化一個全零的hidden state
hidden_state = np.zeros()
# 下面這個循環就是RNN的工作流程了,看到沒有,每次輸入的都是一個時間步長的數據,
# 然后同一個hidden_state會在循環中反復輸入到網絡中。
for i in range(seq_len):
hidden_state = rnn(x[:, i, :], hidden_state)
過來人血淚教訓:一定要看懂上面的代碼再往下讀呀。
pytorch 中的 RNN
好了,現在可以進入本文正題了。我們分數據處理和模型搭建兩部分來介紹。
數據處理
pytorch 的數據讀取框架方便易用,比 tf 的 Dataset 更有親和力。另外,tf 的數據隊列底層是用 C++ 的多線程實現的,因此數據讀取和預處理都要使用 tf 內部提供的 API,否則就失去多線程的能力,這一點實在是令人腦殼疼。再者,過來人血淚教訓,tf 1.4 版本的 Dataset api 有線程死鎖的bug,誰用誰知道😈。而 pytorch 基於多進程的數據讀取機制,避免 python GIL 的問題,同時代碼編寫上更加靈活,可以隨意使用 opencv、PIL 進行處理,爽到飛起。
pytorch 的數據讀取隊列主要靠torch.utils.data.Dataset
和torch.utils.data.DataLoader
實現,具體用法這里略過,主要講一下在 RNN 模型中,數據處理有哪些需要注意的地方。
在一般的數據讀取任務中,我們只需要在Dataset
的__getitem__
方法中返回一個樣本即可,pytorch 會自動幫我們把一個 batch 的樣本組裝起來,因此,在 RNN 相關的任務中,__getitem__
通常返回的是一個維度為 \([seq\_len \times input\_size]\) 的數據。這時,我們會遇到第一個問題,那就是不同樣本的 \(seq\_len\) 是否相同。如果相同的話,那之后就省事太多了,但如果不同,這個地方就會成為初學者第一道坎。因此,下面就針對 \(seq\_len\) 不同的情況介紹一下通用的處理方法。
首先需要明確的是,如果 \(seq\_len\) 不同,那么 pytorch 在組裝 batch 的時候會首先報錯,因為一個 batch 必須是一個 n-dimensional 的 tensor,\(seq\_len\) 不同的話,證明有一個維度的長度是不固定的,那就沒法組裝成一個方方正正的 tensor 了。因此,在數據預處理時,需要記錄下每個樣本的 \(seq\_len\),然后統計出一個均值或者最大值,之后,每次取數據的時候,都必須把數據的 \(seq\_len\) 填充 (補0) 或者裁剪到這個固定的長度,而且要記得把該樣本真實的 \(seq\_len\) 也一起取出來 (后面有大用)。例如下面的代碼:
def __getitem__(self, idx):
# data: seq_len * input_size
data, label, seq_len = self.train_data[idx]
# pad_data: max_seq_len * input_size
pad_data = np.zeros(shape=(self.max_seq_len, data.shape[1]))
pad_sketch[0:data.shape[0]] = data
sample = {'data': pad_data, 'label': label, 'seq_len': seq_len}
return sample
這樣,你從外部拿到的 batch 數據就是一個 \([batch\_size \times max\_seq\_len \times input\_size]\) 的 tensor。
模型搭建
RNN
拿到數據后,下面就要正式用 pytorch 的 RNN 了。從我最開始寫的那段 RNN 的代碼也能看出,RNN 其實就是在一個循環中不斷的 forward 而已。但直接循環調用其實是非常低效的,pytoch 內部會用 CUDA 的函數來加速這里的操作,對於直接調 API 的我們來說,只需要知道RNN
返回給我們的是什么即可。讓我們翻開官方文檔:
class torch.nn.RNN(*args, **kwargs)
Parameters: input_size, hidden_size, num_layers, ...
Inputs: input, h_0
- input of shape (seq_len, batch, input_size)
- h_0 of shape (num_layers * num_directions, batch, hidden_size)
Outputs: output, h_n
- output of shape (seq_len, batch, num_directions * hidden_size)
- h_n (num_layers * num_directions, batch, hidden_size)
這里我只摘錄初始化參數以及輸入輸出的 shape,記住這些信息就夠了,下面會講具體怎么用。注意,shape 里面有一個num_directions
,這玩意表示這個 RNN 是單向還是雙向的,簡單起見,我們這里默認都是單向的 (即num_directions=1
)。
現在借用這篇文章中的例子做講解。
首先,我們初始化一個RNN
:
batch_size = 2
max_length = 3
hidden_size = 2
n_layers = 1
# 這個RNN由兩個全連接層組成,對應的兩個hidden state的維度是2,輸入向量維度是1
rnn = nn.RNN(1, hidden_size, n_layers, batch_first=True)
然后,假設我們的輸入數據是這樣子的:
x = torch.FloatTensor([[1, 0, 0], [1, 2, 3]]).resize_(2, 3, 1)
x = Variable(x) # [batch, seq, feature], [2, 3, 1]
seq_lengths = np.array([1, 3]) # list of integers holding information about the batch size at each sequence step
print(x)
>>> tensor([[[ 1.],
[ 0.],
[ 0.]],
[[ 1.],
[ 2.],
[ 3.]]])
可以看到輸入數據的維度是 \([2 \times 3 \times 1]\),也就是 \(batch\_size=2\),\(seq\_len=3\),\(input\_size=1\)。但要注意一點,第一個樣本的 \(seq\_len\) 的有效長度其實是 1,后面兩位都補了 0。那么,在實際計算的時候,第一個樣本其實只要跑 1 遍 forward 即可,而第二個樣本才需要跑 3 遍 forward。
pack_padded_sequence
那如何讓RNN
知道不同樣本的序列長度不一樣呢?幸運的是,pytorch 已經提供了很好的接口來處理這種情況了。如果輸入樣本的 \(seq\_len\) 長度不一樣,我們需要把輸入的每個樣本重新打包 (pack)。具體來講,pytorch 提供了 torch.nn.utils.rnn.pack_padded_sequence
接口,它會幫我們把輸入轉為一個PackedSequence
對象,而后者就包含了每個樣本的\(seq\_len\) 信息。pack_padded_sequence
最主要的輸入是輸入數據以及每個樣本的 \(seq\_len\) 組成的 list。需要注意的是,我們必須把輸入數據按照 \(seq\_len\) 從大到小排列后才能送入pack_padded_sequence
。我們繼續之前的例子:
# 對seq_len進行排序
order_idx = np.argsort(seq_lengths)[::-1]
print('order_idx:', str(order_idx))
order_x = x[order_idx.tolist()]
order_seq = seq_lengths[order_idx]
print(order_x)
>>> order_idx: [1 0]
tensor([[[ 1.],
[ 2.],
[ 3.]],
[[ 1.],
[ 0.],
[ 0.]]])
# 經過以上處理后,長序列的樣本調整到短序列樣本之前了
# pack it
pack = pack_padded_sequence(order_tensor, order_seq, batch_first=True)
print(pack)
>>>PackedSequence(data=tensor([[ 1.],
[ 1.],
[ 2.],
[ 3.]]), batch_sizes=tensor([ 2, 1, 1]))
理解這里的PackedSequence
是關鍵。
前面說到,RNN
其實就是在循環地 forward。在上面這個例子中,它每次 forward 的數據是這樣的:

第一個序列中,由於兩個樣本都有數據,所以可以看作是 \(batch\_size=2\) 的輸入,后面兩個序列只有第一個樣本有數據,所以可以看作是 \(batch\_size=1\) 的輸入。因此,我們其實可以把這三個序列的數據分解為三個 batch 樣本,只不過 batch 的大小分別為 2,1,1。到這里你應該清楚PackedSequence
里的data
和batch_size
是什么東西了吧,其實就是把我們的輸入數據重新整理打包成data
,同時根據我們傳入的 seq list 計算batch_size
,然后,RNN
會根據batch_size
從打包好的data
里面取數據,然后一遍遍的執行 forward 函數。
理解這一步后,主要難點就解決了。
RNN的輸出
從文檔中可以看出,RNN
輸出兩個東西:output
和h_n
。其中,h_n
是跑完整個時間序列后 hidden state 的數值。但output
又是什么呢?之前不是說過原始的RNN
只輸出 hidden state 嗎,為什么這里又會有一個output
?其實,這個output
並不是我們理解的網絡最后的 output vector,而是每次 forward 后計算得到的 hidden state。畢竟h_n
只保留了最后一步的 hidden state,但中間的 hidden state 也有可能會參與計算,所以 pytorch 把中間每一步輸出的 hidden state 都放到output
中,因此,你可以發現這個output
的維度是 (seq_len, batch, num_directions * hidden_size)
。
不過,如果你之前用pack_padded_sequence
打包過數據,那么為了保證輸入輸出的一致性,pytorch 也會把output
打包成一個PackedSequence
對象,我們將上面例子的數據輸入RNN
,看看輸出是什么樣子的:
# initialize
h0 = Variable(torch.randn(n_layers, batch_size, hidden_size))
# forward
out, _ = rnn(pack, h0)
print(out)
>>> PackedSequence(data=tensor([[ -0.3207, -0.4567],
[ 0.6665, 0.0530],
[ 0.4456, 0.1340],
[ 0.3373, -0.3268]]), batch_sizes=tensor([ 2, 1, 1]))
輸出的PackedSequence
中包含兩部分,其中data
才是我們要的output
。但這個output
的 shape 並不是(seq_len, batch, num_directions * hidden_size)
,因為 pytorch 已經把輸入數據中那些填充的 0 去掉了,因此輸出來的數據對應的是真實的序列長度。我們要把它重新填充回一個方方正正的 tensor 才方便處理,這里會用到另一個相反的操作函數torch.nn.utils.pad_packed_sequence
:
# unpack
unpacked = pad_packed_sequence(out)
out, bz = unpacked[0], unpacked[1]
print(out, bz)
>>> tensor([[[ -0.3207, -0.4567],
[ 0.6665, 0.0530]],
[[ 0.4456, 0.1340],
[ 0.0000, 0.0000]],
[[ 0.3373, -0.3268],
[ 0.0000, 0.0000]]]) tensor([ 3, 1])
現在,這個output
的 shape 就是一個標准形式了。
不過我一般更習慣batch_size
作為第一個維度,所以可以稍微調整下:
# seq_len x batch_size x hidden_size --> batch_size x seq_len x hidden_size
out = out.permute((1, 0, 2))
print("output", out)
print("input", order_x)
>>> output tensor([[[-0.1319, -0.8469],
[-0.3781, -0.8940],
[-0.4869, -0.9621]],
[[-0.8569, -0.7509],
[ 0.0000, 0.0000],
[ 0.0000, 0.0000]]])
intput tensor([[[ 1.],
[ 2.],
[ 3.]],
[[ 1.],
[ 0.],
[ 0.]]])
現在,輸入輸出就一一對應了。之后,你可以從output
中取出你需要的 hidden state,然后接個全連接層之類的,得到真正意義上的 output vector。取出 hidden state 一般會用到torch.gather
函數,比如,如果我想取出最后一個時間序列的 hidden state,可以這樣寫 (這段代碼就不多解釋了,請查一下torch.gather
的用法,自行體會):
# bz來自上面的例子, bz=tensor([ 3, 1])
bz = (bz - 1).view(bz.shape[0], 1, -1)
print(bz)
bz = bz.repeat(1, 1, 2)
print(bz)
out = torch.gather(out, 1, bz)
print(out)
>>> tensor([[[2]], [[0]]])
tensor([[[2, 2]], [0, 0]])
tensor([[[-0.4869, -0.9621]],
[[-0.8569, -0.7509]]])
對了,最后要注意一點,因為pack_padded_sequence
把輸入數據按照 \(seq\_len\) 從大到小重新排序了,所以后面在計算 loss 的時候,要么把output
的順序重新調整回去,要么把 target 數據的順序也按照新的 \(seq\_len\) 重新排序。當 target 是 label 時,調整起來還算方便,但如果 target 也是序列類型的數據,可能會多點體力活,可以參考這篇文章進行調整。