• 教程 >
  • 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> 的“独热向量”。独热向量填充了 0,除了当前字母索引处的 1,例如 "b" = <0 1 0 0 0 ...>

为了构成一个单词,我们将一堆单词连接成一个 2D 矩阵 <line_length x 1 x n_letters>

额外的 1 维是因为 PyTorch 假设一切都在批处理中 - 我们在这里只是使用批处理大小 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')
使用数据集对象使我们能够轻松地将数据拆分为训练集和测试集。在这里,我们创建了一个 80/20

拆分,但 torch.utils.data 具有更多有用的实用程序。在这里,我们指定一个生成器,因为我们需要使用

与 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 加速的内核。它还简化了 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() 函数来实现这一点,该函数使用小批量在给定数据集上训练模型。RNN 的训练方式与其他网络类似;因此,为了完整起见,我们在此处包含一个批量训练方法。循环 (for i in batch) 计算批次中每个项目的损失,然后再调整权重。此操作重复执行,直到达到 epoch 数。

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

我们现在可以训练一个具有小批量的数据集,指定的 epoch 数。此示例的 epoch 数已减少以加快构建速度。您可以使用不同的参数获得更好的结果。

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() 相同,只是减去了反向传播。

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

您可以挑出主轴上的亮点,这些亮点显示了它错误猜测的语言,例如韩语的中文和意大利语的西班牙语。它在希腊语方面似乎做得很好,而在英语方面做得非常差(可能是因为与其他语言重叠)。

练习

  • 使用更大和/或形状更好的网络获得更好的结果

    • 调整超参数以提高性能,例如更改 epoch 数、批量大小和学习率

    • 尝试 nn.LSTMnn.GRU

    • 修改图层的大小,例如增加或减少隐藏节点的数量或添加额外的线性层

    • 将多个 RNN 组合为更高级别的网络

  • 尝试使用不同的行 -> 标签数据集,例如

    • 任何单词 -> 语言

    • 名字 -> 性别

    • 角色名称 -> 作者

    • 页面标题 -> 博客或 subreddit

脚本的总运行时间: ( 11 分钟 56.672 秒)

由 Sphinx-Gallery 生成的图库


评价本教程

© 版权所有 2024, PyTorch。

使用 Sphinx 构建,主题由 theme 提供,由 Read the Docs 提供。

文档

访问 PyTorch 的全面开发者文档

查看文档

教程

获取面向初学者和高级开发者的深入教程

查看教程

资源

查找开发资源并获得问题解答

查看资源