NLP From Scratch

NLP From Scratch: Classifying Names with a Character-Level RNN

  • 我们将构建并训练一个基础的字符级循环神经网络(RNN)来实现词汇分类

  • 字符级RNN将单词作为字符序列进行读取,在每一步输出预测结果和hidden state,并将其前一步的hidden state输入到下一个步骤。以最终的预测结果作为输出,即判断该词汇属于哪个类别

  • 我们将使用来自18种语源的数千个姓氏进行训练,然后根据拼写来预测名字的来源语言

Preparing Torch

  • 设置 torch 默认使用对应硬件所支持的设备(CPU 或 CUDA)以实现 GPU 加速
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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()}")

"""
out:
Using device = cuda:0
"""

Preparing the Data

  • data/names 目录中包含 18 个名为 [Language].txt 的文本文件。每个文件包含若干姓名,每行一个姓名,大多已罗马化(但我们仍需将 Unicode 转换为 ASCII)

  • 第一步是定义和清理数据。首先需要将 Unicode 转换为纯 ASCII 以限制 RNN 输入层维度。通过将 Unicode 字符串转换为 ASCII 并仅允许保留特定字符集来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import string
import unicodedata

# We can use "_" to represent an out-of-vocabulary character, that is, any character we are not handling in our model
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
)

print (f"converting 'Ślusàrski' to {unicodeToAscii('Ślusàrski')}")

"""
out:converting 'Ślusàrski' to Slusarski
"""

Turning Names into Tensors

  • 现在我们已经整理好所有姓名,需要将其转换为 Tensor 才能进行后续处理

  • 表示单个字母时,我们使用大小为 <1 x n_letters> 的 “one-hot vector”。one-hot vector 除当前字母索引位置为 1 外其余全为 0,例如 “b” = <0 1 0 0 0 …>

  • 对于单词而言,我们将多个 one-hot vector 组合成二维矩阵 <line_length x 1 x n_letters>

  • 额外的维度 1 是因为 PyTorch 默认所有输入都包含 batch 维度——这里我们使用的 batch size 为 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Find letter index from all_letters, e.g. "a" = 0
def letterToIndex(letter):
# return our out-of-vocabulary character if we encounter a letter unknown to our model
if letter not in allowed_characters:
return allowed_characters.find("_")
else:
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

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

"""
out:
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., 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., 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.]]], device='cuda:0')
"""
  • 可以使用类似的方法处理其他基于文本的 RNN 任务

  • 接下来,我们需要将所有样本整合为 dataset 以便训练、测试和验证模型

  • 为此我们将使用 Dataset 和 DataLoader 类来存储数据。每个 Dataset 需要实现三个函数:init、len 和 getitem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
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

alldata = NamesDataset("data/names")
print(f"loaded {len(alldata)} items of data")
print(f"example = {alldata[0]}")

"""
out:
loaded 20074 items of data
example = (tensor([11], 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., 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., 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.,
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., 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.]]], device='cuda:0'), 'Arabic', 'Khoury')
"""
  • 使用 dataset 对象可以轻松将数据划分为 train 和 test sets。这里我们采用 80/20 的比例进行分割,但 torch.utils.data 还提供了更多实用工具

  • 此处我们指定 generator 是因为需要使其与 PyTorch 默认使用的设备保持一致

1
2
3
4
5
6
7
8
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)}")

"""
out:
train examples = 17063, validation examples = 3011
"""
  • 现在我们已构建包含 20074 个样本的基础 dataset,每个样本都是 label 和 name 的配对组合。同时已完成 training 和 testing 的数据集划分,以便后续验证所构建的模型