• 教學課程 >
  • 從頭開始的 NLP:使用字元級 RNN 對名稱進行分類
捷徑

從頭開始的 NLP:使用字元級 RNN 對名稱進行分類

建立於:2017 年 3 月 24 日 | 最後更新:2024 年 12 月 11 日 | 最後驗證:2024 年 11 月 05 日

作者: Sean Robertson

本教學是一個三部分系列的其中一部分

我們將建立和訓練一個基本的字元級循環神經網路 (RNN) 來對單字進行分類。 本教學與其他兩個自然語言處理 (NLP)「從頭開始」的教學從頭開始的 NLP:使用字元級 RNN 產生名稱從頭開始的 NLP:使用序列到序列網路和注意力進行翻譯,展示如何預處理資料以對 NLP 建模。 特別是,這些教學展示了如何在低層級對 NLP 建模進行預處理。

字元級 RNN 將單字讀取為一系列字元 - 在每個步驟輸出預測和「隱藏狀態」,將其先前的隱藏狀態饋送到每個後續步驟。 我們將最終預測作為輸出,即該單字屬於哪個類別。

具體來說,我們將訓練來自 18 種起源語言的數千個姓氏,並根據拼寫預測名稱來自哪種語言。

準備 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()
char rnn classification tutorial

評估結果

為了了解網路在不同類別上的表現如何,我們將建立一個混淆矩陣,指示對於每個實際語言(行),網路猜測的語言(列)。為了計算混淆矩陣,一堆樣本會透過網路執行 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)
char rnn classification tutorial

您可以挑選出主軸上的亮點,這些亮點顯示了它錯誤猜測的語言,例如中文對韓文,以及西班牙文對義大利文。它似乎在希臘文方面做得非常好,但在英文方面做得非常差(可能是因為與其他語言重疊)。

練習

  • 使用更大和/或形狀更好的網路獲得更好的結果

    • 調整超參數以提高效能,例如更改 epochs 數量、批次大小和學習率

    • 嘗試 nn.LSTMnn.GRU

    • 修改層的大小,例如增加或減少隱藏節點的數量或新增其他線性層

    • 將多個這些 RNN 組合為更高層級的網路

  • 嘗試使用不同的 line -> label 資料集,例如

    • 任何單字 -> 語言

    • 名字 -> 性別

    • 角色名稱 -> 作者

    • 頁面標題 -> 部落格或 subreddit

腳本的總執行時間:(11 分鐘 56.672 秒)

由 Sphinx-Gallery 產生圖庫

文件

存取 PyTorch 的完整開發者文件

查看文件

教學課程

取得針對初學者和進階開發者的深度教學

查看教學

資源

尋找開發資源並獲得解答

查看資源