相關說明
這篇文章的大部分內容參考自我的新書《解構大語言模型:從線性回歸到通用人工智能》,歡迎有興趣的讀者多多支持。
本文涉及到的代碼鏈接如下:regression2chatgpt/ch10_rnn/char_rnn_batch.ipynb
《循環神經網絡(RNN)》這篇文章討論了RNN的模型結構,并展示了如何利用該模型來學習語言。然而,文中的代碼實現只是示范性的,運行效率不高,限制了模型在實際應用中的效果。本文將重點討論如何高效地實現循環神經網絡,并在此基礎上構建深度循環神經網絡(Deep RNN)。
在理解本文的基礎上,我們將能夠更好地理解著名的長短期記憶網絡(LSTM)模型,具體內容請參考:
- 利用神經網絡學習語言(五)——長短期記憶網絡(LSTM)
內容大綱
- 相關說明
- 一、循環神經網絡更優雅的代碼實現
- 二、批量序列數據的處理
- 三、從單層走向更復雜的結構
- 四、利用深度循環神經網絡學習語言
一、循環神經網絡更優雅的代碼實現
利用神經網絡學習語言(三)——循環神經網絡(RNN)中實現了一個最簡單的單層循環神經網絡,本文將進一步討論如何構建更復雜的循環神經網絡,并將其應用于自然語言處理。搭建復雜模型的關鍵在于高效且優雅的代碼實現,這也是接下來將要討論的內容。
模型訓練的基礎是梯度下降法及其變種(如果不太了解請參考其他的文獻[TODO])。在這類算法的每個迭代周期內,首先選擇一批數據,然后計算模型在這批數據上的損失并進行反向傳播。在這個過程中,不同數據的計算是相互獨立的,可以并行執行,這一點同樣適用于循環神經網絡。同一序列的模型計算必須串行進行,不同序列的計算仍然可以并行處理。例如,同一文本的文字必須按順序處理,但是不同的文本可以同時并行處理。因此,提高循環神經網絡計算效率的關鍵在于并行處理批量數據的模型計算。具體的代碼實現如程序清單1所示(完整代碼),以下是實現的要點。
- 實現并行計算的首要步驟是將批量的數據整合成一個新的更高維度的張量。假設模型的輸入數據形狀為(B,T,C)1,如第10—13行所示。其中,B代表批量大小(Batch Size),T代表序列數據的長度,C代表每個元素的特征長度。在自然語言處理領域,C通常表示文本嵌入特征的長度。需要注意的是,上述計算中假設所有輸入序列具有相同的長度,然而在實際應用中,序列長度不同的情況時有發生。本文的第二部分將介紹如何處理這一細節,以確保模型能夠靈活適應不同長度的輸入序列。
- 根據前面的討論,模型將為序列數據中的每個元素生成一個對應的隱藏狀態。因此,模型的輸出形狀將為(B,T,H),如第22行和第23行代碼所示。其中,H表示隱藏狀態的長度。
- 因為序列數據內部需要進行循環處理,所以需要對輸入張量進行轉置,將其形狀變為(T,B,C),詳見第14行。然后,將之前在模型外部執行的循環步驟移到模型內部。具體來說,將這篇文章中的程序清單2中的第11行和第12行稍做修改,變成程序清單1中的第17—20行。
1 | class RNN(nn.Module):2 | 3 | def __init__(self, input_size, hidden_size):4 | super().__init__()5 | self.hidden_size = hidden_size6 | self.i2h = nn.Linear(input_size + hidden_size, hidden_size)7 | 8 | def forward(self, x, hidden=None):9 | re = []
10 | # B batch_size,
11 | # T sequence length,
12 | # C number of channels.
13 | B, T, C = x.shape
14 | x = x.transpose(0, 1) # (T, B, C)
15 | if hidden is None:
16 | hidden = self.init_hidden(B, x.device)
17 | for i in range(T):
18 | # x[i]: (B, C); hidden: (B, H)
19 | combined = torch.cat((x[i], hidden), dim=1)
20 | hidden = F.relu(self.i2h(combined)) # ( B, H)
21 | re.append(hidden)
22 | result_tensor = torch.stack(re, dim=0) # (T, B, H)
23 | return result_tensor.transpose(0, 1) # (B, T, H)
24 |
25 | def init_hidden(self, B, device):
26 | return torch.zeros((B, self.hidden_size), device=device)
二、批量序列數據的處理
上述模型實現對輸入數據提出了一項相對嚴格的要求,即輸入序列的長度必須相同。為了使這個模型更具通用性,能夠適應長度不一的批量序列數據,通常有兩種常見的處理方法。
一種是填充數據,即根據批量數據中最長的序列長度,使用特殊字符來填充其他序列的空白部分。這種處理方式存在一些問題。首先,由于需要使用特殊字符進行填充,會導致不必要的計算開銷(填充字符并不需要進行模型計算)。其次,填充的特殊字符可能使模型產生誤解,因為模型無法區分哪部分數據是不需要學習的。為了應對上述兩個問題,PyTorch提供了一系列封裝函數,用于更高效地處理填充數據和模型計算,其中包括pack_padded_sequence和pad_packed_sequence等函數。關于這些函數的詳細信息,請讀者查閱官方文檔。鑒于篇幅有限,本文不再贅述。
另一種是截斷。截斷意味著設定一個文本長度T,然后按照這個長度從文本中截取片段用于訓練。
在大語言模型中,更常用的方法是截斷而不是填充。這樣選擇有兩個主要原因。首先,雖然循環神經網絡能夠處理任意長度的序列數據,但當處理長距離依賴時,它會面臨挑戰。因此,太長的序列數據對模型的優化幫助不大,選擇一個合適的序列長度更有益。其次,采用截斷方法可以非常方便地多次學習同一文本。具體來說,如果文本的總長度為 L L L,那么可以逐步滑動窗口,生成 L ? T + 1 L-T+1 L?T+1個序列片段(相互重疊)。這種方式大幅擴充了訓練數據的數量,有助于提高模型的泛化能力。
圖1所示為截斷方法的一種實現,下面將使用它來準備訓練數據。一些細心的讀者可能會產生疑慮:這個實現似乎不能很好地處理文本長度小于 T T T的情況。的確如此,圖1中提供的實現存在一些不足之處,無法處理這種“特殊”情況。它只是一個比較直觀的示范,而非最佳實踐。
在實際應用中,為了更全面地處理各種情況,通常采取以下方法進行截斷:在每個文本的末尾添加一個特殊字符來表示結束,比如之前提到的“<|e|>”。然后,將所有文本連接成一個長串,截斷操作將在這個長串上進行。這種方式能夠保證即使某個文本的長度小于 T T T,也能正常處理,只是這種情況下截取的數據中包含了表示結束的特殊字符。
三、從單層走向更復雜的結構
前文實現的循環神經網絡是單層的網絡結構。在橫向上,模型可以根據輸入的序列數據自動展開成一個寬度很大的結構,但從縱向上看,神經網絡仍然只有一層。為了提升模型的表達能力,類似于多層感知器,循環神經網絡也可以增加層數。這意味著可以通過疊加多個循環神經網絡層來構建深度循環神經網絡(Deep RNN)2,如圖2所示。深度循環神經網絡的展開形式是一個寬度和高度都很大的網絡結構。從工程的角度來看,模型會首先橫向傳遞信號,然后逐層向上,一層一層地計算。這種方法可以最大限度地實現并行處理,提高模型的效率。
前文所討論的模型不僅是單層的,而且是單向的,即單向循環神經網絡(Unidirectional RNN)——模型按照順序從左到右處理序列數據。這種處理方式的假設是當前數據只依賴左側文字的背景。然而,在實際應用中,這種假設并不總是成立。以英語為例,冠詞“a”和“an”的使用取決于右側文字,比如“a boy”和“an example”。為了提升模型效果,需要引入雙向循環神經網絡(Bidirectional RNN),如圖3所示。這種雙向設計使得模型能夠更全面地捕捉序列中的依賴關系和模式,提高模型對復雜序列數據的建模能力。在自然語言處理領域,自回歸模式只需要單向循環神經網絡。但在自編碼模式下,要預測被遮蔽的內容,需要考慮左右兩側的上下文,通常需要雙向循環神經網絡。
值得注意的是,圖3所示的雙向循環神經網絡仍然只是單層神經結構,也可以疊加多層來提升模型效果。此外,雙向循環神經網絡的輸出使用了張量拼接的處理技巧。這種簡單且直觀的處理方法在后續的許多復雜神經網絡中也經常出現。
按照輸入/輸出的張量形狀進行分類,前面討論的循環神經網絡都屬于不定長輸入、不定長輸出的模型,對應這篇文章圖1中的標記3。這種模型被稱為標準的循環神經網絡,具有廣泛的適用性,簡單調整后可適應各種復雜的應用場景。下面以標準循環神經網絡為基礎舉例,構建一個用于解決翻譯問題的模型,其具體結構如圖4所示。
模型中包含兩個標準循環神經網絡,分別被稱為編碼器(Encoder)和解碼器(Decoder)3。在圖4中,編碼器會逐步讀入中文文本。它為每個輸入的詞元生成相應的隱藏狀態,我們主要關注最后一個隱藏狀態,它包含了整個文本的信息,會被傳遞給解碼器。
解碼器的運作方式與編碼器有所不同,它不需要生成全零的初始隱藏狀態,而是接收來自編碼器的隱藏狀態作為起點。在解碼器中,初始的輸入序列是表示文本開始的特殊字符“<|b|>”。與文本生成時的步驟類似,解碼器會將預測結果添加到輸入序列中,然后再次觸發模型預測(這個過程依賴其他模型組件,例如圖4中的語言建模頭lmh)。如此循環,解碼器逐步生成目標文本,完成翻譯任務。
按照輸入/輸出的張量形狀進行分類,編碼器是不定長輸入、定長輸出的模型,對應這篇文章圖1中的標記2。解碼器將定長輸入映射到不定長輸出,對應這篇文章圖1中的標記4。當將編碼器和解碼器結合在一起時,就構成了這篇文章圖1中的標記5。
四、利用深度循環神經網絡學習語言
根據上述討論,下面將構建一個雙層的單向循環神經網絡,用于語言學習。具體的模型細節可以參考程序清單2。為了防止過擬合,模型對每一層的輸出進行了隨機失活(Dropout)操作,如第16—17行代碼所示。在實際應用中,這一設計幾乎已經成為循環神經網絡的標準配置。
1 | class CharRNNBatch(nn.Module):2 | 3 | def __init__(self, vs):4 | super().__init__()5 | self.emb_size = 2566 | self.hidden_size = 1287 | self.embedding = nn.Embedding(vs, self.emb_size)8 | self.dp = nn.Dropout(0.4)9 | self.rnn1 = RNN(self.emb_size, self.hidden_size)
10 | self.rnn2 = RNN(self.hidden_size, self.hidden_size)
11 | self.h2o = nn.Linear(self.hidden_size, vs)
12 |
13 | def forward(self, x):
14 | # x: (B, T)
15 | emb = self.embedding(x) # (B, T, C)
16 | h = self.dp(self.rnn1(emb)) # (B, T, H)
17 | h = self.dp(self.rnn2(h)) # (B, T, H)
18 | output = self.h2o(h) # (B, T, vs)
19 | return output
與這篇文章的結果相比,新模型的預測效果有了一定改善,但仍然未能達到理想水平。如圖5所示,它的表現與多層感知器旗鼓相當,這主要是由于循環神經網絡在處理長距離依賴關系時表現不佳,本系列的后續文章將深入討論這一問題。
值得注意的是,如果參考圖2,本節使用隨機失活的方式實際上是對神經網絡中的縱向信號進行處理。然而,這不是唯一的方式,也可以對神經網絡中的橫向信號執行相似的操作,也就是在隱藏狀態上引入失活操作。這一方法在學術界被稱為循環失活(Recurrent Dropout)。
雖然PyTorch提供的封裝中并不包含循環失活,但實現這一功能并不困難,只需在程序清單1的基礎上稍做修改即可(鑒于篇幅,具體修改細節不再詳述)。這個例子清楚展示了神經網絡的靈活性,同時印證了我們不僅需要理解神經網絡模型設計的細節,還需要通過代碼實現它。這樣就可以根據實際需求自主增添相應的模型組件,而不必受限于開源工具的開發進度和設計選擇。
本文提供的實現與PyTorch中的nn.RNN相比,雖然核心實現類似,但是具體的接口封裝上有一些不同之處。在輸入數據方面,本節要求數據的第一個維度表示批量大小,而在PyTorch的封裝中,默認情況卻不是這樣的。此外,PyTorch的封裝還支持雙向和多層的循環神經網絡,而本節的實現是單層單向的。 ??
這種模型也可以被稱為多層循環神經網絡(Multi-layer RNN)。 ??
著名的語言模型Transformer,其基本結構也分為編碼器和解碼器兩個主要部分。 ??