AI-For-Beginners/translations/mo/lessons/5-NLP/16-RNN/RNNPyTorch.ipynb

479 lines
22 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 循環神經網絡\n",
"\n",
"在上一個模組中,我們使用了豐富的文本語義表示,以及嵌入層上的簡單線性分類器。這種架構能夠捕捉句子中詞語的聚合意義,但它並未考慮詞語的**順序**,因為嵌入層上的聚合操作已經移除了原始文本中的這些信息。由於這些模型無法建模詞語的排列順序,因此它們無法解決更複雜或更具歧義的任務,例如文本生成或問題回答。\n",
"\n",
"為了捕捉文本序列的意義,我們需要使用另一種神經網絡架構,稱為**循環神經網絡**Recurrent Neural NetworkRNN。在 RNN 中,我們將句子逐個符號地傳遞給網絡,網絡會生成某種**狀態**,然後我們將該狀態與下一個符號一起再次傳遞給網絡。\n",
"\n",
"給定輸入序列的標記 $X_0,\\dots,X_n$RNN 會生成一系列神經網絡模塊,並通過反向傳播對這個序列進行端到端的訓練。每個網絡模塊接收一對 $(X_i,S_i)$ 作為輸入,並生成 $S_{i+1}$ 作為結果。最終狀態 $S_n$ 或輸出 $X_n$ 會進入線性分類器以生成結果。所有網絡模塊共享相同的權重,並通過一次反向傳播進行端到端訓練。\n",
"\n",
"由於狀態向量 $S_0,\\dots,S_n$ 是通過網絡傳遞的RNN 能夠學習詞語之間的序列依賴性。例如,當某個序列中出現詞語 *not* 時,網絡可以學習在狀態向量中否定某些元素,從而實現否定的效果。\n",
"\n",
"> 由於圖片中所有 RNN 模塊的權重是共享的,因此同一張圖片可以表示為一個模塊(右側)帶有循環反饋回路,該回路將網絡的輸出狀態回傳到輸入。\n",
"\n",
"接下來,我們來看看循環神經網絡如何幫助我們對新聞數據集進行分類。\n"
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Loading dataset...\n",
"Building vocab...\n"
]
}
],
"source": [
"import torch\n",
"import torchtext\n",
"from torchnlp import *\n",
"train_dataset, test_dataset, classes, vocab = load_dataset()\n",
"vocab_size = len(vocab)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 簡單的 RNN 分類器\n",
"\n",
"在簡單的 RNN 中每個循環單元是一個簡單的線性網絡它接收連接的輸入向量和狀態向量並生成一個新的狀態向量。PyTorch 使用 `RNNCell` 類來表示這種單元,而由這些單元組成的網絡則表示為 `RNN` 層。\n",
"\n",
"為了定義一個 RNN 分類器,我們將首先應用一個嵌入層來降低輸入詞彙的維度,然後在其上添加一個 RNN 層:\n"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [],
"source": [
"class RNNClassifier(torch.nn.Module):\n",
" def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):\n",
" super().__init__()\n",
" self.hidden_dim = hidden_dim\n",
" self.embedding = torch.nn.Embedding(vocab_size, embed_dim)\n",
" self.rnn = torch.nn.RNN(embed_dim,hidden_dim,batch_first=True)\n",
" self.fc = torch.nn.Linear(hidden_dim, num_class)\n",
"\n",
" def forward(self, x):\n",
" batch_size = x.size(0)\n",
" x = self.embedding(x)\n",
" x,h = self.rnn(x)\n",
" return self.fc(x.mean(dim=1))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"> **注意:** 為了簡化,我們在這裡使用未經訓練的嵌入層,但如果想要更好的結果,可以使用預訓練的嵌入層,例如 Word2Vec 或 GloVe 嵌入,這在前一單元中已經描述過。為了更好地理解,您可能需要將此程式碼改寫為使用預訓練的嵌入。\n",
"\n",
"在我們的情況下我們將使用填充的資料加載器因此每個批次都會包含一些相同長度的填充序列。RNN 層將接收嵌入張量的序列,並產生兩個輸出:\n",
"* $x$ 是每一步驟中 RNN 單元輸出的序列\n",
"* $h$ 是序列最後一個元素的最終隱藏狀態\n",
"\n",
"接著,我們應用一個全連接的線性分類器來獲得類別數。\n",
"\n",
"> **注意:** RNN 的訓練相當困難,因為一旦 RNN 單元沿著序列長度展開,反向傳播所涉及的層數就會非常多。因此,我們需要選擇較小的學習率,並在更大的資料集上訓練網路以獲得良好的結果。這可能需要相當長的時間,因此建議使用 GPU。\n"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {
"scrolled": true
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"3200: acc=0.3090625\n",
"6400: acc=0.38921875\n",
"9600: acc=0.4590625\n",
"12800: acc=0.511953125\n",
"16000: acc=0.5506875\n",
"19200: acc=0.57921875\n",
"22400: acc=0.6070089285714285\n",
"25600: acc=0.6304296875\n",
"28800: acc=0.6484027777777778\n",
"32000: acc=0.66509375\n",
"35200: acc=0.6790056818181818\n",
"38400: acc=0.6929166666666666\n",
"41600: acc=0.7035817307692308\n",
"44800: acc=0.7137276785714286\n",
"48000: acc=0.72225\n",
"51200: acc=0.73001953125\n",
"54400: acc=0.7372794117647059\n",
"57600: acc=0.7436631944444444\n",
"60800: acc=0.7503947368421052\n",
"64000: acc=0.75634375\n",
"67200: acc=0.7615773809523809\n",
"70400: acc=0.7662642045454545\n",
"73600: acc=0.7708423913043478\n",
"76800: acc=0.7751822916666666\n",
"80000: acc=0.7790625\n",
"83200: acc=0.7825\n",
"86400: acc=0.7858564814814815\n",
"89600: acc=0.7890513392857142\n",
"92800: acc=0.7920474137931034\n",
"96000: acc=0.7952708333333334\n",
"99200: acc=0.7982258064516129\n",
"102400: acc=0.80099609375\n",
"105600: acc=0.8037594696969697\n",
"108800: acc=0.8060569852941176\n"
]
}
],
"source": [
"train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=16, collate_fn=padify, shuffle=True)\n",
"net = RNNClassifier(vocab_size,64,32,len(classes)).to(device)\n",
"train_epoch(net,train_loader, lr=0.001)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 長短期記憶網路 (LSTM)\n",
"\n",
"傳統 RNN 的主要問題之一是所謂的 **梯度消失** 問題。由於 RNN 是在一次反向傳播中端到端訓練的,因此在將誤差傳遞到網路的第一層時會遇到困難,導致網路無法學習到遠距詞彙之間的關係。為了解決這個問題,可以通過使用所謂的 **閘門** 引入 **顯式狀態管理**。這類架構中最知名的兩種是 **長短期記憶網路** (LSTM) 和 **門控循環單元** (GRU)。\n",
"\n",
"![顯示長短期記憶單元範例的圖片](../../../../../lessons/5-NLP/16-RNN/images/long-short-term-memory-cell.svg)\n",
"\n",
"LSTM 網路的組織方式與 RNN 類似,但有兩個狀態會在層與層之間傳遞:實際狀態 $c$ 和隱藏向量 $h$。在每個單元中,隱藏向量 $h_i$ 與輸入 $x_i$ 連接在一起,並通過 **閘門** 控制狀態 $c$ 的變化。每個閘門都是一個帶有 sigmoid 激活函數(輸出範圍為 $[0,1]$)的神經網路,可以被視為在與狀態向量相乘時的位掩碼。以下是這些閘門(如上圖從左到右):\n",
"* **遺忘閘門** 接收隱藏向量並決定向量 $c$ 的哪些部分需要遺忘,哪些需要保留。\n",
"* **輸入閘門** 從輸入和隱藏向量中提取一些信息,並插入到狀態中。\n",
"* **輸出閘門** 通過帶有 $\\tanh$ 激活函數的線性層轉換狀態,然後使用隱藏向量 $h_i$ 選擇其部分組件以生成新的狀態 $c_{i+1}$。\n",
"\n",
"狀態 $c$ 的組件可以被視為一些可以開啟或關閉的標誌。例如,當我們在序列中遇到名字 *Alice* 時,我們可能會假設它指的是女性角色,並在狀態中設置一個標誌,表示句子中有一個女性名詞。當我們進一步遇到短語 *and Tom* 時,我們會設置一個標誌,表示句子中有複數名詞。因此,通過操作狀態,我們可以假設性地跟蹤句子部分的語法屬性。\n",
"\n",
"> **注意**: 一篇非常棒的文章可以幫助理解 LSTM 的內部結構,請參考 Christopher Olah 的這篇文章 [Understanding LSTM Networks](https://colah.github.io/posts/2015-08-Understanding-LSTMs/)。\n",
"\n",
"雖然 LSTM 單元的內部結構看起來很複雜,但 PyTorch 將這些實現隱藏在 `LSTMCell` 類中,並提供了 `LSTM` 對象來表示整個 LSTM 層。因此LSTM 分類器的實現將與我們之前看到的簡單 RNN 非常相似:\n"
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [],
"source": [
"class LSTMClassifier(torch.nn.Module):\n",
" def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):\n",
" super().__init__()\n",
" self.hidden_dim = hidden_dim\n",
" self.embedding = torch.nn.Embedding(vocab_size, embed_dim)\n",
" self.embedding.weight.data = torch.randn_like(self.embedding.weight.data)-0.5\n",
" self.rnn = torch.nn.LSTM(embed_dim,hidden_dim,batch_first=True)\n",
" self.fc = torch.nn.Linear(hidden_dim, num_class)\n",
"\n",
" def forward(self, x):\n",
" batch_size = x.size(0)\n",
" x = self.embedding(x)\n",
" x,(h,c) = self.rnn(x)\n",
" return self.fc(h[-1])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": []
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"3200: acc=0.259375\n",
"6400: acc=0.25859375\n",
"9600: acc=0.26177083333333334\n",
"12800: acc=0.2784375\n",
"16000: acc=0.313\n",
"19200: acc=0.3528645833333333\n",
"22400: acc=0.3965625\n",
"25600: acc=0.4385546875\n",
"28800: acc=0.4752777777777778\n",
"32000: acc=0.505375\n",
"35200: acc=0.5326704545454546\n",
"38400: acc=0.5557552083333334\n",
"41600: acc=0.5760817307692307\n",
"44800: acc=0.5954910714285714\n",
"48000: acc=0.6118333333333333\n",
"51200: acc=0.62681640625\n",
"54400: acc=0.6404779411764706\n",
"57600: acc=0.6520138888888889\n",
"60800: acc=0.662828947368421\n",
"64000: acc=0.673546875\n",
"67200: acc=0.6831547619047619\n",
"70400: acc=0.6917897727272727\n",
"73600: acc=0.6997146739130434\n",
"76800: acc=0.707109375\n",
"80000: acc=0.714075\n",
"83200: acc=0.7209134615384616\n",
"86400: acc=0.727037037037037\n",
"89600: acc=0.7326674107142858\n",
"92800: acc=0.7379633620689655\n",
"96000: acc=0.7433645833333333\n",
"99200: acc=0.7479032258064516\n",
"102400: acc=0.752119140625\n",
"105600: acc=0.7562405303030303\n",
"108800: acc=0.76015625\n",
"112000: acc=0.7641339285714286\n",
"115200: acc=0.7677777777777778\n",
"118400: acc=0.7711233108108108\n"
]
},
{
"data": {
"text/plain": [
"(0.03487814127604167, 0.7728)"
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"net = LSTMClassifier(vocab_size,64,32,len(classes)).to(device)\n",
"train_epoch(net,train_loader, lr=0.001)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 打包序列\n",
"\n",
"在我們的範例中,我們必須用零向量填充小批次中的所有序列。雖然這會導致一些記憶體的浪費,但對於 RNN 而言,更關鍵的是為這些填充的輸入項目創建了額外的 RNN 單元,這些單元參與了訓練,但並未攜帶任何重要的輸入資訊。如果能讓 RNN 僅針對實際的序列長度進行訓練,效果會更好。\n",
"\n",
"為了實現這一點PyTorch 引入了一種特殊的填充序列存儲格式。假設我們有一個填充過的小批次輸入,看起來像這樣:\n",
"```\n",
"[[1,2,3,4,5],\n",
" [6,7,8,0,0],\n",
" [9,0,0,0,0]]\n",
"```\n",
"這裡的 0 代表填充的值,而輸入序列的實際長度向量是 `[5,3,1]`。\n",
"\n",
"為了有效地用填充序列訓練 RNN我們希望先用較大的小批次`[1,6,9]`)開始訓練第一組 RNN 單元,然後結束第三個序列的處理,接著用較小的小批次(`[2,7]`、`[3,8]`)繼續訓練,依此類推。因此,打包序列被表示為一個向量——在我們的例子中是 `[1,6,9,2,7,3,8,4,5]`,以及長度向量(`[5,3,1]`),我們可以根據這些資訊輕鬆還原原始的填充小批次。\n",
"\n",
"要生成打包序列,我們可以使用 `torch.nn.utils.rnn.pack_padded_sequence` 函數。所有的循環層,包括 RNN、LSTM 和 GRU都支持將打包序列作為輸入並生成打包輸出這些輸出可以使用 `torch.nn.utils.rnn.pad_packed_sequence` 進行解碼。\n",
"\n",
"為了能夠生成打包序列,我們需要將長度向量傳遞給網路,因此我們需要一個不同的函數來準備小批次:\n"
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {},
"outputs": [],
"source": [
"def pad_length(b):\n",
" # build vectorized sequence\n",
" v = [encode(x[1]) for x in b]\n",
" # compute max length of a sequence in this minibatch and length sequence itself\n",
" len_seq = list(map(len,v))\n",
" l = max(len_seq)\n",
" return ( # tuple of three tensors - labels, padded features, length sequence\n",
" torch.LongTensor([t[0]-1 for t in b]),\n",
" torch.stack([torch.nn.functional.pad(torch.tensor(t),(0,l-len(t)),mode='constant',value=0) for t in v]),\n",
" torch.tensor(len_seq)\n",
" )\n",
"\n",
"train_loader_len = torch.utils.data.DataLoader(train_dataset, batch_size=16, collate_fn=pad_length, shuffle=True)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"實際的網路結構會與上面的 `LSTMClassifier` 非常相似,但 `forward` 傳遞過程會同時接收填充過的迷你批次和序列長度的向量。在計算嵌入後,我們會計算打包序列,將其傳遞給 LSTM 層,然後再將結果解包回來。\n",
"\n",
"> **注意**:我們實際上並未使用解包後的結果 `x`,因為我們在後續的計算中使用的是隱藏層的輸出。因此,我們可以完全從這段程式碼中移除解包的部分。我們之所以將其保留在這裡,是為了讓您在需要在後續計算中使用網路輸出時,能夠更輕鬆地修改這段程式碼。\n"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {},
"outputs": [],
"source": [
"class LSTMPackClassifier(torch.nn.Module):\n",
" def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):\n",
" super().__init__()\n",
" self.hidden_dim = hidden_dim\n",
" self.embedding = torch.nn.Embedding(vocab_size, embed_dim)\n",
" self.embedding.weight.data = torch.randn_like(self.embedding.weight.data)-0.5\n",
" self.rnn = torch.nn.LSTM(embed_dim,hidden_dim,batch_first=True)\n",
" self.fc = torch.nn.Linear(hidden_dim, num_class)\n",
"\n",
" def forward(self, x, lengths):\n",
" batch_size = x.size(0)\n",
" x = self.embedding(x)\n",
" pad_x = torch.nn.utils.rnn.pack_padded_sequence(x,lengths,batch_first=True,enforce_sorted=False)\n",
" pad_x,(h,c) = self.rnn(pad_x)\n",
" x, _ = torch.nn.utils.rnn.pad_packed_sequence(pad_x,batch_first=True)\n",
" return self.fc(h[-1])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": []
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {
"scrolled": true
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"3200: acc=0.285625\n",
"6400: acc=0.33359375\n",
"9600: acc=0.3876041666666667\n",
"12800: acc=0.44078125\n",
"16000: acc=0.4825\n",
"19200: acc=0.5235416666666667\n",
"22400: acc=0.5559821428571429\n",
"25600: acc=0.58609375\n",
"28800: acc=0.6116666666666667\n",
"32000: acc=0.63340625\n",
"35200: acc=0.6525284090909091\n",
"38400: acc=0.668515625\n",
"41600: acc=0.6822596153846154\n",
"44800: acc=0.6948214285714286\n",
"48000: acc=0.7052708333333333\n",
"51200: acc=0.71521484375\n",
"54400: acc=0.7239889705882353\n",
"57600: acc=0.7315277777777778\n",
"60800: acc=0.7388486842105263\n",
"64000: acc=0.74571875\n",
"67200: acc=0.7518303571428572\n",
"70400: acc=0.7576988636363636\n",
"73600: acc=0.7628940217391305\n",
"76800: acc=0.7681510416666667\n",
"80000: acc=0.7728125\n",
"83200: acc=0.7772235576923077\n",
"86400: acc=0.7815393518518519\n",
"89600: acc=0.7857700892857142\n",
"92800: acc=0.7895043103448276\n",
"96000: acc=0.7930520833333333\n",
"99200: acc=0.7959072580645161\n",
"102400: acc=0.798994140625\n",
"105600: acc=0.802064393939394\n",
"108800: acc=0.8051378676470589\n",
"112000: acc=0.8077857142857143\n",
"115200: acc=0.8104600694444445\n",
"118400: acc=0.8128293918918919\n"
]
},
{
"data": {
"text/plain": [
"(0.029785829671223958, 0.8138166666666666)"
]
},
"execution_count": 8,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"net = LSTMPackClassifier(vocab_size,64,32,len(classes)).to(device)\n",
"train_epoch_emb(net,train_loader_len, lr=0.001,use_pack_sequence=True)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"> **注意:** 您可能已注意到我們傳遞給訓練函數的參數 `use_pack_sequence`。目前,`pack_padded_sequence` 函數要求長度序列張量位於 CPU 設備上,因此訓練函數需要避免在訓練時將長度序列數據移動到 GPU。您可以查看 [`torchnlp.py`](../../../../../lessons/5-NLP/16-RNN/torchnlp.py) 文件中 `train_emb` 函數的實現。\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 雙向與多層 RNN\n",
"\n",
"在我們的範例中,所有的循環神經網路都是單向運作的,從序列的開頭到結尾。這看起來很自然,因為它類似於我們閱讀或聆聽語音的方式。然而,在許多實際情況下,我們可以隨機存取輸入序列,因此在兩個方向上進行循環計算可能更有意義。這類網路稱為 **雙向** RNN可以透過在 RNN/LSTM/GRU 建構函數中傳入 `bidirectional=True` 參數來建立。\n",
"\n",
"在處理雙向網路時我們需要兩個隱藏狀態向量分別對應每個方向。PyTorch 將這些向量編碼為一個大小加倍的向量,這非常方便,因為通常我們會將結果隱藏狀態傳遞給全連接線性層,只需在建立該層時考慮到這個大小的增加即可。\n",
"\n",
"無論是單向還是雙向的循環網路,都能捕捉序列中的某些模式,並將其存儲到狀態向量中或傳遞到輸出中。與卷積網路類似,我們可以在第一層之上構建另一個循環層,以捕捉更高層次的模式,這些模式是由第一層提取的低層次模式構成的。這引出了 **多層 RNN** 的概念,它由兩層或更多的循環網路組成,前一層的輸出作為下一層的輸入。\n",
"\n",
"![顯示多層長短期記憶 RNN 的圖片](../../../../../translated_images/multi-layer-lstm.dd975e29bb2a59fe58b429db833932d734c81f211cad2783797a9608984acb8c.mo.jpg)\n",
"\n",
"*圖片來源:[這篇精彩的文章](https://towardsdatascience.com/from-a-lstm-cell-to-a-multilayer-lstm-network-with-pytorch-2899eb5696f3) 作者 Fernando López*\n",
"\n",
"PyTorch 讓構建這類網路變得非常簡單,因為只需在 RNN/LSTM/GRU 建構函數中傳入 `num_layers` 參數,即可自動建立多層循環網路。這也意味著隱藏/狀態向量的大小會按比例增加,處理循環層的輸出時需要考慮到這一點。\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## RNN 用於其他任務\n",
"\n",
"在本單元中,我們已經看到 RNN 可以用於序列分類,但事實上,它們還能處理更多任務,例如文本生成、機器翻譯等等。我們將在下一單元中探討這些任務。\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"\n---\n\n**免責聲明** \n本文件已使用 AI 翻譯服務 [Co-op Translator](https://github.com/Azure/co-op-translator) 進行翻譯。儘管我們努力確保翻譯的準確性,但請注意,自動翻譯可能包含錯誤或不準確之處。原始文件的母語版本應被視為權威來源。對於關鍵信息,建議使用專業人工翻譯。我們對因使用此翻譯而引起的任何誤解或誤釋不承擔責任。\n"
]
}
],
"metadata": {
"interpreter": {
"hash": "16af2a8bbb083ea23e5e41c7f5787656b2ce26968575d8763f2c4b17f9cd711f"
},
"kernelspec": {
"display_name": "Python 3.8.12 ('py38')",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.12"
},
"coopTranslator": {
"original_hash": "522ee52ae3d5ae933e283286254e9a55",
"translation_date": "2025-08-28T12:22:38+00:00",
"source_file": "lessons/5-NLP/16-RNN/RNNPyTorch.ipynb",
"language_code": "mo"
}
},
"nbformat": 4,
"nbformat_minor": 2
}