在這篇文章中,我們將了解 RNN(即循環神經網絡),并嘗試通過 PyTorch 從頭開始??實現其中的部分內容。是的,這并不完全是從頭開始,因為我們仍然依賴 PyTorch autograd 來計算梯度并實現反向傳播,但我仍然認為我們也可以從這個實現中收集到有價值的見解。
有關 RNN 的簡要介紹性概述,我建議您查看上一篇文章,其中我們不僅探討了 RNN 是什么及其工作原理,還探討了如何使用 Keras 實現 RNN 模型。這次,我們將使用 PyTorch,但采取更實際的方法從頭開始構建一個簡單的 RNN。
完全免責聲明,這篇文章很大程度上改編自PyTorch 教程這個 PyTorch 教程。我修改并改變了預處理和訓練中涉及的一些步驟。我仍然建議您將其作為補充材料查看。考慮到這一點,讓我們開始吧。
數據準備
任務是建立一個簡單的分類模型,可以根據名字正確確定一個人的國籍。更簡單地說,我們希望能夠分辨出特定名稱的來源。
下載
我們將使用 PyTorch 教程中的一些標記數據。我們只需輸入即可下載
!curl -O https://download.pytorch.org/tutorial/data.zip; unzip data.zip
此命令將下載文件并將其解壓到當前目錄中,文件夾名稱為data.
現在我們已經下載了所需的數據,讓我們更詳細地看一下數據。首先,這是我們需要的依賴項。
import os
import random
from string import ascii_lettersimport torch
from torch import nn
import torch.nn.functional as F
from unidecode import unidecode_ = torch.manual_seed(42)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
我們首先指定一個目錄,然后嘗試打印出其中的所有標簽。然后我們可以構建一個字典,將語言映射到數字標簽。
data_dir = "./data/names"lang2label = {file_name.split(".")[0]: torch.tensor([i], dtype=torch.long)for i, file_name in enumerate(os.listdir(data_dir))
}
我們看到一共有18種語言。我將每個標簽包裝為張量,以便我們可以在訓練期間直接使用它們。
lang2label
{'Czech': tensor([0]),'German': tensor([1]),'Arabic': tensor([2]),'Japanese': tensor([3]),'Chinese': tensor([4]),'Vietnamese': tensor([5]),'Russian': tensor([6]),'French': tensor([7]),'Irish': tensor([8]),'English': tensor([9]),'Spanish': tensor([10]),'Greek': tensor([11]),'Italian': tensor([12]),'Portuguese': tensor([13]),'Scottish': tensor([14]),'Dutch': tensor([15]),'Korean': tensor([16]),'Polish': tensor([17])}
讓我們將語言數量存儲在某個變量中,以便稍后在模型聲明中使用它,特別是當我們指定最終輸出層的大小時。
num_langs = len(lang2label)
預處理
現在,讓我們對名稱進行預處理。我們首先要用來unidecode標準化所有名稱并刪除任何銳利符號或類似符號。例如,
unidecode("?lusàrski")
'Slusarski'
一旦我們有了解碼后的字符串,我們就需要將其轉換為張量,以便模型可以處理它。這可以首先通過構建映射來完成char2idx,如下所示。
char2idx = {letter: i for i, letter in enumerate(ascii_letters + " .,:;-'")}
num_letters = len(char2idx); num_letters
59
我們看到我們的字符詞匯表中共有 59 個標記。這包括空格和標點符號,例如 .,:;-' . This also means that each name will now be expressed as a tensor of size (num_char, 59) ; in other words, each character will be a tensor of size (59,)
。我們現在可以構建一個完成此任務的函數,如下所示:
def name2tensor(name):tensor = torch.zeros(len(name), 1, num_letters)for i, char in enumerate(name):tensor[i][0][char2idx[char]] = 1return tensor
如果你仔細閱讀代碼,你會發現輸出張量的大小是(num_char, 1, 59),這與上面的解釋不同。嗯,這個額外維度的原因是我們在本例中使用的批量大小為 1。在 PyTorch 中,RNN 層期望輸入張量的大小為(seq_len, batch_size, input_size)。由于每個名稱都有不同的長度,因此為了簡單起見,我們不會對輸入進行批處理,而只是將每個輸入用作單個批處理。有關更詳細的討論,請查看此論壇討論。
name2tensor()讓我們使用虛擬輸入快速驗證函數的輸出。
name2tensor("abc")
tensor([[[1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,0., 0., 0., 0., 0., 0., 0., 0.]],[[0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,0., 0., 0., 0., 0., 0., 0., 0.]],[[0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,0., 0., 0., 0., 0., 0., 0., 0.]]])
數據集創建
現在我們需要構建包含所有預處理步驟的數據集。讓我們將所有解碼和轉換的張量收集在一個列表中,并附上標簽。可以從文件名輕松獲取標簽,例如german.txt.
tensor_names = []
target_langs = []for file in os.listdir(data_dir):with open(os.path.join(data_dir, file)) as f:lang = file.split(".")[0]names = [unidecode(line.rstrip()) for line in f]for name in names:try:tensor_names.append(name2tensor(name))target_langs.append(lang2label[lang])except KeyError:pass
我們可以將其包裝在 PyTorchDataset類中,但為了簡單起見,我們只使用一個好的舊for循環將這些數據輸入到我們的模型中。由于我們處理的是普通列表,因此我們可以輕松地使用sklearn’strain_test_split()將訓練數據與測試數據分開。
from sklearn.model_selection import train_test_splittrain_idx, test_idx = train_test_split(range(len(target_langs)), test_size=0.1, shuffle=True, stratify=target_langs
)train_dataset = [(tensor_names[i], target_langs[i])for i in train_idx
]test_dataset = [(tensor_names[i], target_langs[i])for i in test_idx
]
讓我們看看我們有多少訓練和測試數據。請注意,我們使用的 test_size為 0.1。
print(f"Train: {len(train_dataset)}")
print(f"Test: {len(test_dataset)}")
Train: 18063
Test: 2007
模型
我們將構建兩個模型:一個簡單的 RNN(將從頭開始構建)和一個使用 PyTorch 層的基于 GRU 的模型。
簡單循環神經網絡
現在我們可以構建我們的模型了。這是一個非常簡單的 RNN,它采用單個字符張量表示作為輸入,并產生一些預測和隱藏狀態,可在下一次迭代中使用。請注意,它只是一些在隱藏狀態計算期間應用了 sigmoid 非線性的全連接層。
class MyRNN(nn.Module):def __init__(self, input_size, hidden_size, output_size):super(MyRNN, self).__init__()self.hidden_size = hidden_sizeself.in2hidden = nn.Linear(input_size + hidden_size, hidden_size)self.in2output = nn.Linear(input_size + hidden_size, output_size)def forward(self, x, hidden_state):combined = torch.cat((x, hidden_state), 1)hidden = torch.sigmoid(self.in2hidden(combined))output = self.in2output(combined)return output, hiddendef init_hidden(self):return nn.init.kaiming_uniform_(torch.empty(1, self.hidden_size))
我們init_hidden()在每個新批次開始時都會打電話。為了更容易訓練和學習,我決定使用kaiming_uniform_()來初始化這些隱藏狀態。
我們現在可以構建模型并開始訓練它。
hidden_size = 256
learning_rate = 0.001model = MyRNN(num_letters, hidden_size, num_langs)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
我意識到訓練這個模型非常不穩定,正如你所看到的,損失上下跳躍了很多。盡管如此,我不想為難我的 13 英寸 MacBook Pro,所以我決定在兩個epochs停止。
num_epochs = 2
print_interval = 3000for epoch in range(num_epochs):random.shuffle(train_dataset)for i, (name, label) in enumerate(train_dataset):hidden_state = model.init_hidden()for char in name:output, hidden_state = model(char, hidden_state)loss = criterion(output, label)optimizer.zero_grad()loss.backward()nn.utils.clip_grad_norm_(model.parameters(), 1)optimizer.step()if (i + 1) % print_interval == 0:print(f"Epoch [{epoch + 1}/{num_epochs}], "f"Step [{i + 1}/{len(train_dataset)}], "f"Loss: {loss.item():.4f}")
Epoch [1/2], Step [3000/18063], Loss: 0.0390
Epoch [1/2], Step [6000/18063], Loss: 1.0368
Epoch [1/2], Step [9000/18063], Loss: 0.6718
Epoch [1/2], Step [12000/18063], Loss: 0.0003
Epoch [1/2], Step [15000/18063], Loss: 1.0658
Epoch [1/2], Step [18000/18063], Loss: 1.0021
Epoch [2/2], Step [3000/18063], Loss: 0.0021
Epoch [2/2], Step [6000/18063], Loss: 0.0131
Epoch [2/2], Step [9000/18063], Loss: 0.3842
Epoch [2/2], Step [12000/18063], Loss: 0.0002
Epoch [2/2], Step [15000/18063], Loss: 2.5420
Epoch [2/2], Step [18000/18063], Loss: 0.0172
現在我們可以測試我們的模型。我們可以查看其他指標,但準確性是迄今為止最簡單的,所以我們就這樣吧。
num_correct = 0
num_samples = len(test_dataset)model.eval()with torch.no_grad():for name, label in test_dataset:hidden_state = model.init_hidden()for char in name:output, hidden_state = model(char, hidden_state)_, pred = torch.max(output, dim=1)num_correct += bool(pred == label)print(f"Accuracy: {num_correct / num_samples * 100:.4f}%")
Accuracy: 72.2471%
該模型的準確率高達 72%。這非常糟糕,但考慮到模型非常簡單,而且我們只訓練了兩個 epoch 的模型,我們可以放松下來,享受短暫的快樂,因為知道簡單的 RNN 模型至少能夠學到一些東西。
讓我們通過一些具體示例來看看我們的模型表現如何。下面是一個接受字符串作為輸入并輸出解碼預測的函數。
label2lang = {label.item(): lang for lang, label in lang2label.items()}def myrnn_predict(name):model.eval()tensor_name = name2tensor(name)with torch.no_grad():hidden_state = model.init_hidden()for char in tensor_name:output, hidden_state = model(char, hidden_state)_, pred = torch.max(output, dim=1)model.train() return label2lang[pred.item()]
我不知道這些名字是否真的在訓練或測試集中;這些只是我想出的一些隨機名稱,我認為這些名稱相當合理。瞧,結果是有希望的。
myrnn_predict("Mike")
'English'
myrnn_predict("Qin")
'Chinese'
myrnn_predict("Slaveya")
'Russian'
該模型似乎已將所有名稱分類為正確的類別!
PyTorch GRU
這很酷,我可能可以停在這里,但我想看看這個自定義模型與使用 PyTorch 層的模型相比如何。對于我們簡單的 RNN 來說,GRU 可能不太公平,但讓我們看看它的表現如何。
class GRUModel(nn.Module):def __init__(self, num_layers, hidden_size):super(GRUModel, self).__init__()self.num_layers = num_layersself.hidden_size = hidden_sizeself.gru = nn.GRU(input_size=num_letters, hidden_size=hidden_size, num_layers=num_layers,)self.fc = nn.Linear(hidden_size, num_langs)def forward(self, x):hidden_state = self.init_hidden()output, hidden_state = self.gru(x, hidden_state)output = self.fc(output[-1])return outputdef init_hidden(self):return torch.zeros(self.num_layers, 1, self.hidden_size).to(device)
讓我們聲明模型和與之配套的優化器。請注意,我們使用的是兩層 GRU,它已經比我們當前的 RNN 實現多了一層。
model = GRUModel(num_layers=2, hidden_size=hidden_size)
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
for epoch in range(num_epochs):random.shuffle(train_dataset)for i, (name, label) in enumerate(train_dataset):output = model(name)loss = criterion(output, label)optimizer.zero_grad()loss.backward()optimizer.step()if (i + 1) % print_interval == 0:print(f"Epoch [{epoch + 1}/{num_epochs}], "f"Step [{i + 1}/{len(train_dataset)}], "f"Loss: {loss.item():.4f}")
Epoch [1/2], Step [3000/18063], Loss: 1.8497
Epoch [1/2], Step [6000/18063], Loss: 0.4908
Epoch [1/2], Step [9000/18063], Loss: 1.0299
Epoch [1/2], Step [12000/18063], Loss: 0.0855
Epoch [1/2], Step [15000/18063], Loss: 0.0053
Epoch [1/2], Step [18000/18063], Loss: 2.6417
Epoch [2/2], Step [3000/18063], Loss: 0.0004
Epoch [2/2], Step [6000/18063], Loss: 0.0008
Epoch [2/2], Step [9000/18063], Loss: 0.1446
Epoch [2/2], Step [12000/18063], Loss: 0.2125
Epoch [2/2], Step [15000/18063], Loss: 3.7883
Epoch [2/2], Step [18000/18063], Loss: 0.4862
訓練一開始看起來比較穩定,但我們確實在第二個時期結束時看到了奇怪的跳躍。部分原因是我沒有對此 GRU 模型使用梯度裁剪,并且應用裁剪后我們可能會看到更好的結果。
讓我們看看這個模型的準確性。
num_correct = 0model.eval()with torch.no_grad():for name, label in test_dataset:output = model(name)_, pred = torch.max(output, dim=1)num_correct += bool(pred == label)print(f"Accuracy: {num_correct / num_samples * 100:.4f}%")
Accuracy: 81.4150%
我們得到的這個模型的準確率約為 80%。這比我們的簡單 RNN 模型要好,這在某種程度上是預料之中的,因為它有一個附加層并且使用了更復雜的 RNN 單元模型。
讓我們看看這個模型如何預測給定的一些原始名稱字符串。
def pytorch_predict(name):model.eval()tensor_name = name2tensor(name)with torch.no_grad():output = model(tensor_name)_, pred = torch.max(output, dim=1)model.train()return label2lang[pred.item()]
pytorch_predict("Jake")
'English'
pytorch_predict("Qin")
'Chinese'
pytorch_predict("Fernando")
'Spanish'
pytorch_predict("Demirkan")
'Russian'
最后一個很有趣,因為這是我一位土耳其好朋友的名字。該模型顯然無法告訴我們這個名字是土耳其語,因為它沒有看到任何標記為土耳其語的數據點,但它告訴我們這個名字可能屬于它所訓練的 18 個標簽中的哪個國籍。這顯然是錯誤的,但在某些方面也許相差并不遠。例如,至少它沒有說日語。對于該模型來說,這也不是完全公平的游戲,因為有許多名字可能被描述為跨國的:也許有一個俄羅斯人的名字叫 Demirkan。
結論
通過實現這個 RNN,我學到了很多關于 RNN 的知識。誠然,它很簡單,并且與 PyTorch 基于層的方法有所不同,因為它需要我們手動循環每個字符,但它的低級性質迫使我更多地思考張量維度以及具有張量維度的目的。隱藏狀態和輸出之間的劃分。這也很好地提醒了我們 RNN 是如何難以訓練的。
在接下來的文章中,我們將研究序列到序列模型,簡稱 seq2seq。自從聽說 seq2seq 以來,我就對將一種數據形式轉換為另一種數據形式的力量著迷。盡管由于本地機器的限制,這些模型無法在 CPU 上進行實際訓練,但我認為實現它們本身將是一個令人興奮的挑戰。
本博文譯自Jake Tae的博文。