注意
點擊此處下載完整的範例程式碼
從頭開始的 NLP:使用字元級 RNN 對名稱進行分類¶
建立於:2017 年 3 月 24 日 | 最後更新:2024 年 12 月 11 日 | 最後驗證:2024 年 11 月 05 日
作者: Sean Robertson
本教學是一個三部分系列的其中一部分
我們將建立和訓練一個基本的字元級循環神經網路 (RNN) 來對單字進行分類。 本教學與其他兩個自然語言處理 (NLP)「從頭開始」的教學從頭開始的 NLP:使用字元級 RNN 產生名稱 和 從頭開始的 NLP:使用序列到序列網路和注意力進行翻譯,展示如何預處理資料以對 NLP 建模。 特別是,這些教學展示了如何在低層級對 NLP 建模進行預處理。
字元級 RNN 將單字讀取為一系列字元 - 在每個步驟輸出預測和「隱藏狀態」,將其先前的隱藏狀態饋送到每個後續步驟。 我們將最終預測作為輸出,即該單字屬於哪個類別。
具體來說,我們將訓練來自 18 種起源語言的數千個姓氏,並根據拼寫預測名稱來自哪種語言。
建議準備¶
在開始本教學之前,建議您已安裝 PyTorch,並且對 Python 程式語言和張量有基本的了解
https://pytorch.dev.org.tw/ 有關安裝說明
使用 PyTorch 進行深度學習:60 分鐘速成 開始使用 PyTorch,並學習張量的基礎知識
使用範例學習 PyTorch 進行廣泛而深入的概述
PyTorch 給以前的 Torch 使用者 如果您是以前的 Lua Torch 使用者
了解 RNN 及其工作原理也很有用
循環神經網路的驚人有效性 顯示了一堆真實生活中的範例
了解 LSTM 網路 專門介紹 LSTM,但也提供了有關 RNN 的資訊
準備 Torch¶
設定 torch 以根據您的硬體(CPU 或 CUDA)預設為正確的裝置使用 GPU 加速。
import torch
# Check if CUDA is available
device = torch.device('cpu')
if torch.cuda.is_available():
device = torch.device('cuda')
torch.set_default_device(device)
print(f"Using device = {torch.get_default_device()}")
Using device = cuda:0
準備資料¶
從 此處 下載資料並將其解壓縮到目前的目錄。
data/names
目錄中包含 18 個文字檔案,命名為 [語言].txt
。 每個檔案都包含一堆名稱,每行一個名稱,主要已羅馬化(但我們仍然需要將 Unicode 轉換為 ASCII)。
第一步是定義和清理我們的資料。 最初,我們需要將 Unicode 轉換為純 ASCII,以限制 RNN 輸入層。 這是通過將 Unicode 字串轉換為 ASCII 並僅允許一小組允許的字元來完成的。
import string
import unicodedata
allowed_characters = string.ascii_letters + " .,;'"
n_letters = len(allowed_characters)
# Turn a Unicode string to plain ASCII, thanks to https://stackoverflow.com/a/518232/2809427
def unicodeToAscii(s):
return ''.join(
c for c in unicodedata.normalize('NFD', s)
if unicodedata.category(c) != 'Mn'
and c in allowed_characters
)
這是一個將 unicode 字母名稱轉換為純 ASCII 的範例。 這簡化了輸入層
print (f"converting 'Ślusàrski' to {unicodeToAscii('Ślusàrski')}")
converting 'Ślusàrski' to Slusarski
將名稱轉換為張量¶
現在我們已經組織了所有名稱,我們需要將它們轉換為張量才能使用它們。
為了表示單個字母,我們使用大小為 <1 x n_letters>
的 “one-hot 向量”。一個 one-hot 向量除了目前字母索引的位置為 1 之外,其餘皆為 0,例如 "b" = <0 1 0 0 0 ...>
。
為了構成一個單字,我們將這些向量組合成一個 2D 矩陣 <line_length x 1 x n_letters>
。
多出來的 1 個維度是因為 PyTorch 假設所有東西都是以批次 (batch) 的方式處理 - 我們在這裡只是使用大小為 1 的批次。
# Find letter index from all_letters, e.g. "a" = 0
def letterToIndex(letter):
return allowed_characters.find(letter)
# Turn a line into a <line_length x 1 x n_letters>,
# or an array of one-hot letter vectors
def lineToTensor(line):
tensor = torch.zeros(len(line), 1, n_letters)
for li, letter in enumerate(line):
tensor[li][0][letterToIndex(letter)] = 1
return tensor
以下是一些如何使用 lineToTensor()
處理單個和多個字元字串的範例。
print (f"The letter 'a' becomes {lineToTensor('a')}") #notice that the first position in the tensor = 1
print (f"The name 'Ahn' becomes {lineToTensor('Ahn')}") #notice 'A' sets the 27th index to 1
The letter 'a' becomes 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.]]], device='cuda:0')
The name 'Ahn' becomes tensor([[[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., 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., 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.]]], device='cuda:0')
恭喜!您已經為這個學習任務建立了基礎的張量物件!您可以使用類似的方法處理其他基於文字的 RNN 任務。
接下來,我們需要將所有範例組合成一個資料集,以便訓練、測試和驗證我們的模型。為此,我們將使用 Dataset 和 DataLoader 類別來保存我們的資料集。每個 Dataset 都需要實作三個函式:__init__
、__len__
和 __getitem__
。
from io import open
import glob
import os
import time
import torch
from torch.utils.data import Dataset
class NamesDataset(Dataset):
def __init__(self, data_dir):
self.data_dir = data_dir #for provenance of the dataset
self.load_time = time.localtime #for provenance of the dataset
labels_set = set() #set of all classes
self.data = []
self.data_tensors = []
self.labels = []
self.labels_tensors = []
#read all the ``.txt`` files in the specified directory
text_files = glob.glob(os.path.join(data_dir, '*.txt'))
for filename in text_files:
label = os.path.splitext(os.path.basename(filename))[0]
labels_set.add(label)
lines = open(filename, encoding='utf-8').read().strip().split('\n')
for name in lines:
self.data.append(name)
self.data_tensors.append(lineToTensor(name))
self.labels.append(label)
#Cache the tensor representation of the labels
self.labels_uniq = list(labels_set)
for idx in range(len(self.labels)):
temp_tensor = torch.tensor([self.labels_uniq.index(self.labels[idx])], dtype=torch.long)
self.labels_tensors.append(temp_tensor)
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
data_item = self.data[idx]
data_label = self.labels[idx]
data_tensor = self.data_tensors[idx]
label_tensor = self.labels_tensors[idx]
return label_tensor, data_tensor, data_label, data_item
在這裡,我們可以將我們的範例資料載入到 NamesDataset
中。
alldata = NamesDataset("data/names")
print(f"loaded {len(alldata)} items of data")
print(f"example = {alldata[0]}")
loaded 20074 items of data
example = (tensor([13], device='cuda:0'), tensor([[[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., 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., 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.,
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.,
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., 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.]]], device='cuda:0'), 'Arabic', 'Khoury')
- 使用 dataset 物件可以讓我們輕鬆地將資料分割成訓練集和測試集。這裡我們建立一個 80/20 的分割比例
分割,但是
torch.utils.data
具有更多有用的工具。這裡我們指定一個 generator,因為我們需要使用
與 PyTorch 預設的設備相同的設備。
train_set, test_set = torch.utils.data.random_split(alldata, [.85, .15], generator=torch.Generator(device=device).manual_seed(2024))
print(f"train examples = {len(train_set)}, validation examples = {len(test_set)}")
train examples = 17063, validation examples = 3011
現在我們有一個包含 20074 個範例的基本資料集,其中每個範例都是標籤和名稱的配對。我們還將資料集分割成訓練集和測試集,以便我們可以驗證我們建立的模型。
建立網路¶
在 autograd 之前,在 Torch 中建立遞迴神經網路需要跨多個時間步複製層的參數。這些層保存了隱藏狀態和梯度,而現在這些都完全由圖形本身處理。這意味著您可以用非常 “純粹” 的方式實現 RNN,就像常規的前饋層一樣。
這個 CharRNN 類別實作了一個具有三個元件的 RNN。首先,我們使用 nn.RNN 實作。接下來,我們定義一個將 RNN 隱藏層對應到輸出的層。最後,我們套用一個 softmax
函式。與將每一層實作為 nn.Linear
相比,使用 nn.RNN
可以顯著提高效能,例如 cuDNN 加速的 kernels。它也簡化了 forward()
中的實作。
import torch.nn as nn
import torch.nn.functional as F
class CharRNN(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super(CharRNN, self).__init__()
self.rnn = nn.RNN(input_size, hidden_size)
self.h2o = nn.Linear(hidden_size, output_size)
self.softmax = nn.LogSoftmax(dim=1)
def forward(self, line_tensor):
rnn_out, hidden = self.rnn(line_tensor)
output = self.h2o(hidden[0])
output = self.softmax(output)
return output
然後,我們可以建立一個具有 57 個輸入節點、128 個隱藏節點和 18 個輸出的 RNN
n_hidden = 128
rnn = CharRNN(n_letters, n_hidden, len(alldata.labels_uniq))
print(rnn)
CharRNN(
(rnn): RNN(57, 128)
(h2o): Linear(in_features=128, out_features=18, bias=True)
(softmax): LogSoftmax(dim=1)
)
之後,我們可以將我們的張量傳遞給 RNN 以獲得預測的輸出。隨後,我們使用一個輔助函式 label_from_output
來為類別導出文字標籤。
def label_from_output(output, output_labels):
top_n, top_i = output.topk(1)
label_i = top_i[0].item()
return output_labels[label_i], label_i
input = lineToTensor('Albert')
output = rnn(input) #this is equivalent to ``output = rnn.forward(input)``
print(output)
print(label_from_output(output, alldata.labels_uniq))
tensor([[-2.9390, -2.8275, -2.8058, -3.0319, -2.8306, -2.9352, -2.9732, -2.9896,
-2.8615, -2.9002, -2.8282, -2.8838, -2.9461, -2.9186, -2.8518, -2.8175,
-2.8220, -2.9018]], device='cuda:0', grad_fn=<LogSoftmaxBackward0>)
('French', 2)
訓練¶
訓練網路¶
現在,訓練這個網路所需要做的就是向它展示一堆範例,讓它做出猜測,並告訴它是否錯誤。
我們透過定義一個 train()
函式來做到這一點,該函式使用 minibatches 在給定的資料集上訓練模型。 RNN 的訓練方式與其他網路類似;因此,為了完整起見,我們在這裡包含一個批次訓練方法。迴圈 (for i in batch
) 在調整權重之前計算批次中每個項目的損失。這個操作會重複執行,直到達到 epochs 的數量。
import random
import numpy as np
def train(rnn, training_data, n_epoch = 10, n_batch_size = 64, report_every = 50, learning_rate = 0.2, criterion = nn.NLLLoss()):
"""
Learn on a batch of training_data for a specified number of iterations and reporting thresholds
"""
# Keep track of losses for plotting
current_loss = 0
all_losses = []
rnn.train()
optimizer = torch.optim.SGD(rnn.parameters(), lr=learning_rate)
start = time.time()
print(f"training on data set with n = {len(training_data)}")
for iter in range(1, n_epoch + 1):
rnn.zero_grad() # clear the gradients
# create some minibatches
# we cannot use dataloaders because each of our names is a different length
batches = list(range(len(training_data)))
random.shuffle(batches)
batches = np.array_split(batches, len(batches) //n_batch_size )
for idx, batch in enumerate(batches):
batch_loss = 0
for i in batch: #for each example in this batch
(label_tensor, text_tensor, label, text) = training_data[i]
output = rnn.forward(text_tensor)
loss = criterion(output, label_tensor)
batch_loss += loss
# optimize parameters
batch_loss.backward()
nn.utils.clip_grad_norm_(rnn.parameters(), 3)
optimizer.step()
optimizer.zero_grad()
current_loss += batch_loss.item() / len(batch)
all_losses.append(current_loss / len(batches) )
if iter % report_every == 0:
print(f"{iter} ({iter / n_epoch:.0%}): \t average batch loss = {all_losses[-1]}")
current_loss = 0
return all_losses
我們現在可以使用 minibatches 訓練資料集指定數量的 epochs。這個範例的 epochs 數量減少了,以加快建置速度。您可以使用不同的參數獲得更好的結果。
start = time.time()
all_losses = train(rnn, train_set, n_epoch=27, learning_rate=0.15, report_every=5)
end = time.time()
print(f"training took {end-start}s")
training on data set with n = 17063
5 (19%): average batch loss = 0.8832110071575249
10 (37%): average batch loss = 0.6889914635618413
15 (56%): average batch loss = 0.576440147925416
20 (74%): average batch loss = 0.4945095779330073
25 (93%): average batch loss = 0.4314638929655953
training took 705.1379203796387s
繪製結果¶
從 all_losses
繪製歷史損失可以顯示網路的學習情況
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
plt.figure()
plt.plot(all_losses)
plt.show()

評估結果¶
為了了解網路在不同類別上的表現如何,我們將建立一個混淆矩陣,指示對於每個實際語言(行),網路猜測的語言(列)。為了計算混淆矩陣,一堆樣本會透過網路執行 evaluate()
,這與 train()
減去反向傳播 (backprop) 相同。
def evaluate(rnn, testing_data, classes):
confusion = torch.zeros(len(classes), len(classes))
rnn.eval() #set to eval mode
with torch.no_grad(): # do not record the gradients during eval phase
for i in range(len(testing_data)):
(label_tensor, text_tensor, label, text) = testing_data[i]
output = rnn(text_tensor)
guess, guess_i = label_from_output(output, classes)
label_i = classes.index(label)
confusion[label_i][guess_i] += 1
# Normalize by dividing every row by its sum
for i in range(len(classes)):
denom = confusion[i].sum()
if denom > 0:
confusion[i] = confusion[i] / denom
# Set up plot
fig = plt.figure()
ax = fig.add_subplot(111)
cax = ax.matshow(confusion.cpu().numpy()) #numpy uses cpu here so we need to use a cpu version
fig.colorbar(cax)
# Set up axes
ax.set_xticks(np.arange(len(classes)), labels=classes, rotation=90)
ax.set_yticks(np.arange(len(classes)), labels=classes)
# Force label at every tick
ax.xaxis.set_major_locator(ticker.MultipleLocator(1))
ax.yaxis.set_major_locator(ticker.MultipleLocator(1))
# sphinx_gallery_thumbnail_number = 2
plt.show()
evaluate(rnn, test_set, classes=alldata.labels_uniq)

您可以挑選出主軸上的亮點,這些亮點顯示了它錯誤猜測的語言,例如中文對韓文,以及西班牙文對義大利文。它似乎在希臘文方面做得非常好,但在英文方面做得非常差(可能是因為與其他語言重疊)。
練習¶
使用更大和/或形狀更好的網路獲得更好的結果
調整超參數以提高效能,例如更改 epochs 數量、批次大小和學習率
嘗試
nn.LSTM
和nn.GRU
層修改層的大小,例如增加或減少隱藏節點的數量或新增其他線性層
將多個這些 RNN 組合為更高層級的網路
嘗試使用不同的 line -> label 資料集,例如
任何單字 -> 語言
名字 -> 性別
角色名稱 -> 作者
頁面標題 -> 部落格或 subreddit
腳本的總執行時間:(11 分鐘 56.672 秒)