,

2. NLP From Scratch: Generating Names with Character-Level RNN

在这个部分,我们的目标是给一些提示,最后生成一个名字

Preparing Data
与第一个 部分一样,我们先要对数据进行处理,数据处理的流程如下:(个人认为数据处理是很重要的一个部分,而且数据的多种多样也给数据处理带来了很大的困难,还是要努力提升自己的能力才能够得心应手)

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
# 导入必要的库
from io import import open # 用于文件输入输出操作
import glob # 用于使用通配符查找文件
import os
import unicodedata # 用于处理Unicode字符
import string # 用于字符串操作

# 定义所有允许的字符(ASCII字母加一些标点符号)
all_letters = string.ascii_letters + " .,;'-"
# 计算允许字符总数 +1(用于EOS结束标记)
n_letters = len(all_letters) + 1

# 查找匹配路径模式的所有文件
def findFiles(path): return glob.glob(path)

# 将Unicode字符串转换为纯ASCII
def unicodeToAscii(s):
return ''.join(
c for c in unicodedata.normalize('NFD', s) # Unicode规范化处理
if unicodedata.category(c) != 'Mn' # 过滤掉组合标记
and c in all_letters # 只保留允许的字符集
)

# 读取文件并返回行列表(转换为ASCII)
def readLines(filename):
with open(filename, encoding='utf-8') as some_file: # 以UTF-8编码打开文件
return [unicodeToAscii(line.strip()) for line in some_file] # 处理每一行

# 字典:存储每个类别(语言)的行
category_lines = {}
# 列表:存储所有类别名称
all_categories = []

# 处理data/names目录下的每个.txt文件
for filename in findFiles('data/names/*.txt'):
# 从文件名提取类别名(不带扩展名)
category = os.path.splitext(os.path.basename(filename))[0]
# 将类别添加到列表
all_categories.append(category)
# 从文件读取所有行并存入字典
lines = readLines(filename)
category_lines[category] = lines

# 获取类别总数
n_categories = len(all_categories)

# 如果没有找到数据文件则报错
if n_categories == 0:
raise RuntimeError('未找到数据。请确保已从 '
'https://download.pytorch.org/tutorial/data.zip 下载数据 '
'并解压到当前目录。')

# 打印加载的数据信息
print('类别数量:', n_categories, all_categories)
# 演示Unicode到ASCII的转换
print(unicodeToAscii("O'Néàl")) # 应输出"O'Neal"

Creating the Network
这个网络用category张量的额外参数扩展了上一个教程的RNN,该参数与其他张量一起连接在一起。category张量是一个one-hot vector,就像字母输入一样。

我们将把输出解释为下一个字母的概率。抽样时,最有可能的输出字母被用作下一个输入字母。

添加了第二个线性层o2o(在结合隐藏和输出后),使其有更多的力量来工作。还有一个dropout层,它以给定的概率(这里为0.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
import torch  # 导入PyTorch库
import torch.nn as nn # 导入PyTorch的神经网络模块

class RNN(nn.Module): # 定义RNN类,继承自nn.Module
def __init__(self, input_size, hidden_size, output_size): # 初始化函数
super(RNN, self).__init__() # 调用父类构造函数
self.hidden_size = hidden_size # 设置隐藏层大小

self.i2h = nn.Linear(n_categories + input_size + hidden_size, hidden_size) # 输入到隐藏层的线性变换
self.i2o = nn.Linear(n_categories + input_size + hidden_size, output_size) # 输入到输出层的线性变换
self.o2o = nn.Linear(hidden_size + output_size, output_size) # 隐藏层和输出层组合后的线性变换
self.dropout = nn.Dropout(0.1) # Dropout层,防止过拟合
self.softmax = nn.LogSoftmax(dim=1) # LogSoftmax层,用于多分类

def forward(self, category, input, hidden): # 前向传播函数
input_combined = torch.cat((category, input, hidden), 1) # 拼接类别、输入和隐藏状态
hidden = self.i2h(input_combined) # 计算新的隐藏状态
output = self.i2o(input_combined) # 计算初步输出
output_combined = torch.cat((hidden, output), 1) # 拼接隐藏状态和初步输出
output = self.o2o(output_combined) # 计算最终输出
output = self.dropout(output) # 应用Dropout
output = self.softmax(output) # 应用LogSoftmax
return output, hidden # 返回输出和新的隐藏状态

def initHidden(self): # 初始化隐藏状态
return torch.zeros(1, self.hidden_size) # 返回全零的隐藏状态张量

Training

首先我们创建一个随机生成的输入对(category, line)

1
2
3
4
5
6
7
8
9
10
11
import random  # 导入random模块用于生成随机数

# 从一个列表中随机选择一项
def randomChoice(l):
return l[random.randint(0, len(l) - 1)] # 使用randint生成随机索引并返回对应元素

# 获取随机的类别和该类别下的随机一行数据
def randomTrainingPair():
category = randomChoice(all_categories) # 随机选择一个类别
line = randomChoice(category_lines[category]) # 从该类别中随机选择一行数据
return category, line # 返回类别和行数据的元组

对于每个时间步骤(即训练单词中的每个字母),网络的输入将是(category、current letter、hidden state),输出将是(next letter,next hidden state)。因此,对于每个训练集,我们需要类别、一组输入字母和一组输出/目标字母。

由于我们正在预测每个时间步骤的当前字母的下一个字母,字母对是行中的连续字母组——例如,对于“ABCD<EOS>”,我们将创建(“A”、“B”)、(“B”、“C”)、(“C”、“D”)、(“D”、“EOS”)。

category tensor是大小为<1 x n_categories>的 one-hot vector。在训练时,我们在每个时间步骤中将其输入网络,它本可以作为初始隐藏状态或其他策略的一部分。

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
# 生成类别的one-hot向量
def categoryTensor(category):
li = all_categories.index(category) # 获取类别在all_categories列表中的索引位置
tensor = torch.zeros(1, n_categories) # 创建全零张量(形状:1×类别总数)
tensor[0][li] = 1 # 将对应类别位置设为1
return tensor # 返回one-hot编码的类别张量

# 可视化理解:
# 假设 all_categories = ['Chinese', 'English', 'French']
# category = 'English' → li = 1
# 生成的tensor = [[0, 1, 0]] (1×3的张量)

# 生成输入字母的one-hot矩阵(不包括EOS结束符)
def inputTensor(line):
tensor = torch.zeros(len(line), 1, n_letters) # 创建三维零张量(序列长度×1×字母总数)
for li in range(len(line)):
letter = line[li] # 获取当前位置的字母
tensor[li][0][all_letters.find(letter)] = 1 # 在字母位置设为1
return tensor # 返回形状为(序列长度,1,字母总数)的张量

# 可视化理解:
# 假设 line = "AB", all_letters = "ABC...", n_letters=28
# 输出形状为(2,1,28):
# 位置0: [[[1,0,0,...]]] (A的编码)
# 位置1: [[[0,1,0,...]]] (B的编码)

# 生成目标字母的索引张量(从第二个字母到结尾,加上EOS)
def targetTensor(line):
# 获取从第二个字母开始的所有字母索引
letter_indexes = [all_letters.find(line[li]) for li in range(1, len(line))]
letter_indexes.append(n_letters - 1) # 添加EOS(字母总数-1)作为结束标记
return torch.LongTensor(letter_indexes) # 转换为LongTensor类型

# 可视化理解:
# 假设 line = "AB", all_letters = "ABC...", n_letters=28
# letter_indexes = [B的索引(1), EOS索引(27)]
# 返回张量为[1, 27]

def randomTrainingExample():
category, line = randomTrainingPair() # 随机获取类别和名字
category_tensor = categoryTensor(category) # 类别转one-hot向量 [1, n_categories]
input_line_tensor = inputTensor(line) # 名字转one-hot序列 [len(line),1,n_letters]
target_line_tensor = targetTensor(line) # 目标序列 [len(line)] (从第二个字符开始+EOS)

return category_tensor, input_line_tensor, target_line_tensor # 返回训练所需张量

Training the Network

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
# 定义负对数似然损失函数(用于分类任务)
criterion = nn.NLLLoss()

# 设置学习率(参数更新的步长)
learning_rate = 0.0005

def train(category_tensor, input_line_tensor, target_line_tensor):
# 在目标张量的最后维度上增加一维(用于匹配输出形状)
target_line_tensor.unsqueeze_(-1)

# 初始化RNN的隐藏状态
hidden = rnn.initHidden()

# 清空模型参数的梯度
rnn.zero_grad()

# 初始化损失值(也可以直接使用 loss = 0)
loss = torch.Tensor([0])

# 遍历输入序列的每个时间步
for i in range(input_line_tensor.size(0)):
# 前向传播:获取输出和新的隐藏状态
output, hidden = rnn(category_tensor, input_line_tensor[i], hidden)
# 计算当前时间步的损失
l = criterion(output, target_line_tensor[i])
# 累加所有时间步的损失
loss += l

# 反向传播计算梯度
loss.backward()

# 手动更新模型参数(使用梯度下降)
for p in rnn.parameters():
p.data.add_(p.grad.data, alpha=-learning_rate)

# 返回最后的输出和平均每个时间步的损失
return output, loss.item() / input_line_tensor.size(0)

添加一个时间记录的函数来统计模型训练的时间:

1
2
3
4
5
6
7
8
9
import time
import math

def timeSince(since):
now = time.time()
s = now - since
m = math.floor(s / 60)
s -= m * 60
return '%dm %ds' % (m, s)
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
# 初始化RNN模型(输入维度n_letters,隐藏层128,输出维度n_letters)
rnn = RNN(n_letters, 128, n_letters)

# 训练总迭代次数
n_iters = 100000
# 每隔多少轮打印一次信息
print_every = 5000
# 每隔多少轮记录一次损失用于绘图
plot_every = 500
# 存储所有平均损失值的列表
all_losses = []
# 累计损失(每plot_every轮重置)
total_loss = 0

# 记录训练开始时间
start = time.time()

# 开始训练循环
for iter in range(1, n_iters + 1):
# 获取随机训练样本并训练,返回输出和损失
output, loss = train(*randomTrainingExample())
# 累计损失值
total_loss += loss

# 每隔print_every轮打印训练信息
if iter % print_every == 0:
# 打印:已用时间,当前迭代次数,完成百分比,当前损失
print('%s (%d %d%%) %.4f' % (timeSince(start), iter, iter / n_iters * 100, loss))

# 每隔plot_every轮记录平均损失
if iter % plot_every == 0:
# 计算plot_every轮的平均损失并存入列表
all_losses.append(total_loss / plot_every)
# 重置累计损失
total_loss = 0

绘制损失函数:

1
2
3
4
import matplotlib.pyplot as plt

plt.figure()
plt.plot(all_losses)

结果测试:

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
# 设置生成名字的最大长度
max_length = 20

# 从指定类别和起始字母生成一个名字
def sample(category, start_letter='A'):
with torch.no_grad(): # 禁用梯度计算,节省内存
# 将类别转换为张量
category_tensor = categoryTensor(category)
# 将起始字母转换为输入张量
input = inputTensor(start_letter)
# 初始化隐藏状态
hidden = rnn.initHidden()

# 初始化输出名字(以起始字母开头)
output_name = start_letter

# 循环生成字符直到达到最大长度
for i in range(max_length):
# RNN前向传播
output, hidden = rnn(category_tensor, input[0], hidden)
# 获取最可能的输出字符
topv, topi = output.topk(1)
topi = topi[0][0]

# 如果遇到结束符(EOS)则停止生成
if topi == n_letters - 1:
break
else:
# 将索引转换为字母
letter = all_letters[topi]
# 添加到输出名字中
output_name += letter
# 准备下一个输入
input = inputTensor(letter)

return output_name

# 从同一类别和多个起始字母生成多个名字
def samples(category, start_letters='ABC'):
for start_letter in start_letters:
print(sample(category, start_letter))

# 生成俄罗斯名字(以R、U、S开头)
samples('Russian', 'RUS')

# 生成德国名字(以G、E、R开头)
samples('German', 'GER')

# 生成西班牙名字(以S、P、A开头)
samples('Spanish', 'SPA')

# 生成中文名字(以C、H、I开头)
samples('Chinese', 'CHI')