- 使用RNN模型构建人名分类器
- 获取所有常用字符包括字母和常用标点
- 获取常用字符数量
- 将字符串(单词粒度)转化为张量表示,如:”ab” —->
- 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., 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.]]])
- 构建训练函数并进行训练
- 构建评估函数并进行预测
- 使用seq2seq模型架构实现英译法任务
python版本使用3.6.x, pytorch版本使用1.3.1
使用RNN模型构建人名分类器
以一个人名为输入, 使用模型帮助我们判断它最有可能是来自哪一个国家的人名, 这在某些国际化公司的业务中具有重要意义, 在用户注册过程中, 会根据用户填写的名字直接给他分配可能的国家或地区选项, 以及该国家或地区的国旗, 限制手机号码位数等等.
人名分类数据
步骤
- 第一步: 导入必备的工具包.
- 第二步: 对data文件中的数据进行处理,满足训练要求.
- 第三步: 构建RNN模型(包括传统RNN, LSTM以及GRU).
- 第四步: 构建训练函数并进行训练.
- 第五步: 构建评估函数并进行预测.
导入必备的工具包
# 从io中导入文件打开方法from io import open# 帮助使用正则表达式进行子目录的查询import globimport os# 用于获得常见字母及字符规范化import stringimport unicodedata# 导入随机工具randomimport random# 导入时间和数学工具包import timeimport math# 导入torch工具import torch# 导入nn准备构建模型import torch.nn as nn# 引入制图工具包import matplotlib.pyplot as plt
数据处理
获取常用字符数量
n_letters = len(all_letters)
print(“n_letter:”, n_letters)
<br />输出效果- 字符规范化之unicode转Ascii函数```python# 关于编码问题我们暂且不去考虑# 我们认为这个函数的作用就是去掉一些语言中的重音标记# 如: Ślusàrski ---> Slusarskidef unicodeToAscii(s):return ''.join(c for c in unicodedata.normalize('NFD', s)if unicodedata.category(c) != 'Mn'and c in all_letters)
测试
输出
- 构建一个从持久化文件中读取内容到内存的函数 ```python data_path = “./data/name/“
def readLines(filename): “””从文件中读取每一行加载到内存中形成列表”””
# 打开指定文件并读取所有内容, 使用strip()去除两侧空白符, 然后以'\n'进行切分lines = open(filename, encoding='utf-8').read().strip().split('\n')# 对应每一个lines列表中的名字进行Ascii转换, 使其规范化.最后返回一个名字列表return [unicodeToAscii(line) for line in lines]
<br />测试- 构建人名类别(所属的语言)列表与人名对应关系字典:```python# 构建的category_lines形如:{"English":["Lily", "Susan", "Kobe"], "Chinese":["Zhang San", "Xiao Ming"]}category_lines = {}# all_categories形如: ["English",...,"Chinese"]all_categories = []# 读取指定路径下的txt文件, 使用glob,path中可以使用正则表达式for filename in glob.glob(data_path + '*.txt'):# 获取每个文件的文件名, 就是对应的名字类别category = os.path.splitext(os.path.basename(filename))[0]# 将其逐一装到all_categories列表中all_categories.append(category)# 然后读取每个文件的内容,形成名字列表lines = readLines(filename)# 按照对应的类别,将名字列表写入到category_lines字典中category_lines[category] = lines# 查看类别总数n_categories = len(all_categories)print("n_categories:", n_categories)# 随便查看其中的一些内容print(category_lines['Italian'][:5])
- 将人名转化为对应onehot张量表示:
```python
将字符串(单词粒度)转化为张量表示,如:”ab” —->
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., 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.]]])
def lineToTensor(line): “””将人名转化为对应onehot张量表示, 参数line是输入的人名”””
# 首先初始化一个0张量, 它的形状(len(line), 1, n_letters)# 代表人名中的每个字母用一个1 x n_letters的张量表示.tensor = torch.zeros(len(line), 1, n_letters)# 遍历这个人名中的每个字符索引和字符for li, letter in enumerate(line):# 使用字符串方法find找到每个字符在all_letters中的索引# 它也是我们生成onehot张量中1的索引位置tensor[li][0][all_letters.find(letter)] = 1# 返回结果return tensor
line = “Bai” line_tensor = lineToTensor(line) print(“line_tensot:”, line_tensor)
<a name="743e90fa"></a>## 构建RNN模型<a name="e8e6fc1f"></a>### 构建传统的RNN模型:```python# 使用nn.RNN构建完成传统RNN使用类class RNN(nn.Module):def __init__(self, input_size, hidden_size, output_size, num_layers=1):"""初始化函数中有4个参数, 分别代表RNN输入最后一维尺寸, RNN的隐层最后一维尺寸, RNN层数"""super(RNN, self).__init__()# 将hidden_size与num_layers传入其中self.hidden_size = hidden_sizeself.num_layers = num_layers# 实例化预定义的nn.RNN, 它的三个参数分别是input_size, hidden_size, num_layersself.rnn = nn.RNN(input_size, hidden_size, num_layers)# 实例化nn.Linear, 这个线性层用于将nn.RNN的输出维度转化为指定的输出维度self.linear = nn.Linear(hidden_size, output_size)# 实例化nn中预定的Softmax层, 用于从输出层获得类别结果self.softmax = nn.LogSoftmax(dim=-1)def forward(self, input, hidden):"""完成传统RNN中的主要逻辑, 输入参数input代表输入张量, 它的形状是1 x n_lettershidden代表RNN的隐层张量, 它的形状是self.num_layers x 1 x self.hidden_size"""# 因为预定义的nn.RNN要求输入维度一定是三维张量, 因此在这里使用unsqueeze(0)扩展一个维度input = input.unsqueeze(0)# 将input和hidden输入到传统RNN的实例化对象中,如果num_layers=1, rr恒等于hnrr, hn = self.rnn(input, hidden)# 将从RNN中获得的结果通过线性变换和softmax返回,同时返回hn作为后续RNN的输入return self.softmax(self.linear(rr)), hndef initHidden(self):"""初始化隐层张量"""# 初始化一个(self.num_layers, 1, self.hidden_size)形状的0张量return torch.zeros(self.num_layers, 1, self.hidden_size)
构建LSTM模型
# 使用nn.LSTM构建完成LSTM使用类class LSTM(nn.Module):def __init__(self, input_size, hidden_size, output_size, num_layers=1):"""初始化函数的参数与传统RNN相同"""super(LSTM, self).__init__()# 将hidden_size与num_layers传入其中self.hidden_size = hidden_sizeself.num_layers = num_layers# 实例化预定义的nn.LSTMself.lstm = nn.LSTM(input_size, hidden_size, num_layers)# 实例化nn.Linear, 这个线性层用于将nn.RNN的输出维度转化为指定的输出维度self.linear = nn.Linear(hidden_size, output_size)# 实例化nn中预定的Softmax层, 用于从输出层获得类别结果self.softmax = nn.LogSoftmax(dim=-1)def forward(self, input, hidden, c):"""在主要逻辑函数中多出一个参数c, 也就是LSTM中的细胞状态张量"""# 使用unsqueeze(0)扩展一个维度input = input.unsqueeze(0)# 将input, hidden以及初始化的c传入lstm中rr, (hn, c) = self.lstm(input, (hidden, c))# 最后返回处理后的rr, hn, creturn self.softmax(self.linear(rr)), hn, cdef initHiddenAndC(self):"""初始化函数不仅初始化hidden还要初始化细胞状态c, 它们形状相同"""c = hidden = torch.zeros(self.num_layers, 1, self.hidden_size)return hidden, c
构建GRU模型
# 使用nn.GRU构建完成传统RNN使用类# GRU与传统RNN的外部形式相同, 都是只传递隐层张量, 因此只需要更改预定义层的名字class GRU(nn.Module):def __init__(self, input_size, hidden_size, output_size, num_layers=1):super(GRU, self).__init__()self.hidden_size = hidden_sizeself.num_layers = num_layers# 实例化预定义的nn.GRU, 它的三个参数分别是input_size, hidden_size, num_layersself.gru = nn.GRU(input_size, hidden_size, num_layers)self.linear = nn.Linear(hidden_size, output_size)self.softmax = nn.LogSoftmax(dim=-1)def forward(self, input, hidden):input = input.unsqueeze(0)rr, hn = self.gru(input, hidden)return self.softmax(self.linear(rr)), hndef initHidden(self):return torch.zeros(self.num_layers, 1, self.hidden_size)
实例化参数:
# 因为是onehot编码, 输入张量最后一维的尺寸就是n_lettersinput_size = n_letters# 定义隐层的最后一维尺寸大小n_hidden = 128# 输出尺寸为语言类别总数n_categoriesoutput_size = n_categories# num_layer使用默认值, num_layers = 1
输入参数:
# 假如我们以一个字母B作为RNN的首次输入, 它通过lineToTensor转为张量# 因为我们的lineToTensor输出是三维张量, 而RNN类需要的二维张量# 因此需要使用squeeze(0)降低一个维度input = lineToTensor('B').squeeze(0)# 初始化一个三维的隐层0张量, 也是初始的细胞状态张量hidden = c = torch.zeros(1, 1, n_hidden)
调用:
rnn = RNN(n_letters, n_hidden, n_categories)lstm = LSTM(n_letters, n_hidden, n_categories)gru = GRU(n_letters, n_hidden, n_categories)rnn_output, next_hidden = rnn(input, hidden)print("rnn:", rnn_output)lstm_output, next_hidden, c = lstm(input, hidden, c)print("lstm:", lstm_output)gru_output, next_hidden = gru(input, hidden)print("gru:", gru_output)
rnn: tensor([[[-2.8822, -2.8615, -2.9488, -2.8898, -2.9205, -2.8113, -2.9328,-2.8239, -2.8678, -2.9474, -2.8724, -2.9703, -2.9019, -2.8871,-2.9340, -2.8436, -2.8442, -2.9047]]], grad_fn=<LogSoftmaxBackward>)lstm: tensor([[[-2.9427, -2.8574, -2.9175, -2.8492, -2.8962, -2.9276, -2.8500,-2.9306, -2.8304, -2.9559, -2.9751, -2.8071, -2.9138, -2.8196,-2.8575, -2.8416, -2.9395, -2.9384]]], grad_fn=<LogSoftmaxBackward>)gru: tensor([[[-2.8042, -2.8894, -2.8355, -2.8951, -2.8682, -2.9502, -2.9056,-2.8963, -2.8671, -2.9109, -2.9425, -2.8390, -2.9229, -2.8081,-2.8800, -2.9561, -2.9205, -2.9546]]], grad_fn=<LogSoftmaxBackward>)
构建训练函数并进行训练
从输出结果中获得指定类别函数
def categoryFromOutput(output):"""从输出结果中获得指定类别, 参数为输出张量output"""# 从输出张量中返回最大的值和索引对象, 我们这里主要需要这个索引top_n, top_i = output.topk(1)# top_i对象中取出索引的值category_i = top_i[0].item()# 根据索引值获得对应语言类别, 返回语言类别和索引值return all_categories[category_i], category_i
输入参数
# 将上一步中gru的输出作为函数的输入output = gru_output# tensor([[[-2.8042, -2.8894, -2.8355, -2.8951, -2.8682, -2.9502, -2.9056,# -2.8963, -2.8671, -2.9109, -2.9425, -2.8390, -2.9229, -2.8081,# -2.8800, -2.9561, -2.9205, -2.9546]]], grad_fn=<LogSoftmaxBackward>)
调用
category, category_i = categoryFromOutput(output)print("category:", category)print("category_i:", category_i)
category: Portuguesecategory_i: 13
随机生成训练数据
def randomTrainingExample():"""该函数用于随机产生训练数据"""# 首先使用random的choice方法从all_categories随机选择一个类别category = random.choice(all_categories)# 然后在通过category_lines字典取category类别对应的名字列表# 之后再从列表中随机取一个名字line = random.choice(category_lines[category])# 接着将这个类别在所有类别列表中的索引封装成tensor, 得到类别张量category_tensorcategory_tensor = torch.tensor([all_categories.index(category)], dtype=torch.long)# 最后, 将随机取到的名字通过函数lineToTensor转化为onehot张量表示line_tensor = lineToTensor(line)return category, line, category_tensor, line_tensor# 我们随机取出十个进行结果查看for i in range(10):category, line, category_tensor, line_tensor = randomTrainingExample()print('category =', category, '/ line =', line, '/ category_tensor =', category_tensor)
category = French / line = Fontaine / category_tensor = tensor([5])category = Italian / line = Grimaldi / category_tensor = tensor([9])category = Chinese / line = Zha / category_tensor = tensor([1])category = Italian / line = Rapallino / category_tensor = tensor([9])category = Czech / line = Sherak / category_tensor = tensor([2])category = Arabic / line = Najjar / category_tensor = tensor([0])category = Scottish / line = Brown / category_tensor = tensor([15])category = Arabic / line = Sarraf / category_tensor = tensor([0])category = Japanese / line = Ibi / category_tensor = tensor([10])category = Chinese / line = Zha / category_tensor = tensor([1])
构建传统RNN训练函数:
# 定义损失函数为nn.NLLLoss,因为RNN的最后一层是nn.LogSoftmax, 两者的内部计算逻辑正好能够吻合.criterion = nn.NLLLoss()# 设置学习率为0.005learning_rate = 0.005def trainRNN(category_tensor, line_tensor):"""定义训练函数, 它的两个参数是category_tensor类别的张量表示, 相当于训练数据的标签,line_tensor名字的张量表示, 相当于对应训练数据"""# 在函数中, 首先通过实例化对象rnn初始化隐层张量hidden = rnn.initHidden()# 然后将模型结构中的梯度归0rnn.zero_grad()# 下面开始进行训练, 将训练数据line_tensor的每个字符逐个传入rnn之中, 得到最终结果for i in range(line_tensor.size()[0]):output, hidden = rnn(line_tensor[i], hidden)# 因为我们的rnn对象由nn.RNN实例化得到, 最终输出形状是三维张量, 为了满足于category_tensor# 进行对比计算损失, 需要减少第一个维度, 这里使用squeeze()方法loss = criterion(output.squeeze(0), category_tensor)# 损失进行反向传播loss.backward()# 更新模型中所有的参数for p in rnn.parameters():# 将参数的张量表示与参数的梯度乘以学习率的结果相加以此来更新参数p.data.add_(-learning_rate, p.grad.data)# 返回结果和损失的值return output, loss.item()
构建LSTM训练函数
# 与传统RNN相比多出细胞状态cdef trainLSTM(category_tensor, line_tensor):hidden, c = lstm.initHiddenAndC()lstm.zero_grad()for i in range(line_tensor.size()[0]):# 返回output, hidden以及细胞状态coutput, hidden, c = lstm(line_tensor[i], hidden, c)loss = criterion(output.squeeze(0), category_tensor)loss.backward()for p in lstm.parameters():p.data.add_(-learning_rate, p.grad.data)return output, loss.item()
构建GRU训练函数:
# 与传统RNN完全相同, 只不过名字改成了GRUdef trainGRU(category_tensor, line_tensor):hidden = gru.initHidden()gru.zero_grad()for i in range(line_tensor.size()[0]):output, hidden= gru(line_tensor[i], hidden)loss = criterion(output.squeeze(0), category_tensor)loss.backward()for p in gru.parameters():p.data.add_(-learning_rate, p.grad.data)return output, loss.item()
构建时间计算函数:
def timeSince(since):"获得每次打印的训练耗时, since是训练开始时间"# 获得当前时间now = time.time()# 获得时间差,就是训练耗时s = now - since# 将秒转化为分钟, 并取整m = math.floor(s / 60)# 计算剩下不够凑成1分钟的秒数s -= m * 60# 返回指定格式的耗时return '%dm %ds' % (m, s)
构建训练过程的日志打印函数
# 设置训练迭代次数n_iters = 1000# 设置结果的打印间隔print_every = 50# 设置绘制损失曲线上的制图间隔plot_every = 10def train(train_type_fn):"""训练过程的日志打印函数, 参数train_type_fn代表选择哪种模型训练函数, 如trainRNN"""# 每个制图间隔损失保存列表all_losses = []# 获得训练开始时间戳start = time.time()# 设置初始间隔损失为0current_loss = 0# 从1开始进行训练迭代, 共n_iters次for iter in range(1, n_iters + 1):# 通过randomTrainingExample函数随机获取一组训练数据和对应的类别category, line, category_tensor, line_tensor = randomTrainingExample()# 将训练数据和对应类别的张量表示传入到train函数中output, loss = train_type_fn(category_tensor, line_tensor)# 计算制图间隔中的总损失current_loss += loss# 如果迭代数能够整除打印间隔if iter % print_every == 0:# 取该迭代步上的output通过categoryFromOutput函数获得对应的类别和类别索引guess, guess_i = categoryFromOutput(output)# 然后和真实的类别category做比较, 如果相同则打对号, 否则打叉号.correct = '✓' if guess == category else '✗ (%s)' % category# 打印迭代步, 迭代步百分比, 当前训练耗时, 损失, 该步预测的名字, 以及是否正确print('%d %d%% (%s) %.4f %s / %s %s' % (iter, iter / n_iters * 100, timeSince(start), loss, line, guess, correct))# 如果迭代数能够整除制图间隔if iter % plot_every == 0:# 将保存该间隔中的平均损失到all_losses列表中all_losses.append(current_loss / plot_every)# 间隔损失重置为0current_loss = 0# 返回对应的总损失列表和训练耗时return all_losses, int(time.time() - start)
开始训练传统RNN, LSTM, GRU模型:
# 调用train函数, 分别进行RNN, LSTM, GRU模型的训练# 并返回各自的全部损失, 以及训练耗时用于制图all_losses1, period1 = train(trainRNN)all_losses2, period2 = train(trainLSTM)all_losses3, period3 = train(trainGRU)# 绘制损失对比曲线, 训练耗时对比柱张图# 创建画布0plt.figure(0)# 绘制损失对比曲线plt.plot(all_losses1, label="RNN")plt.plot(all_losses2, color="red", label="LSTM")plt.plot(all_losses3, color="orange", label="GRU")plt.legend(loc='upper left')# 创建画布1plt.figure(1)x_data=["RNN", "LSTM", "GRU"]y_data = [period1, period2, period3]# 绘制训练耗时对比柱状图plt.bar(range(len(x_data)), y_data, tick_label=x_data)
传统RNN训练日志输出:
5000 5% (0m 16s) 3.2264 Carr / Chinese ✗ (English)10000 10% (0m 30s) 1.2063 Biondi / Italian ✓15000 15% (0m 47s) 1.4010 Palmeiro / Italian ✗ (Portuguese)20000 20% (1m 0s) 3.8165 Konae / French ✗ (Japanese)25000 25% (1m 17s) 0.5420 Koo / Korean ✓30000 30% (1m 31s) 5.6180 Fergus / Portuguese ✗ (Irish)35000 35% (1m 45s) 0.6073 Meeuwessen / Dutch ✓40000 40% (1m 59s) 2.1356 Olan / Irish ✗ (English)45000 45% (2m 13s) 0.3352 Romijnders / Dutch ✓50000 50% (2m 26s) 1.1624 Flanagan / Irish ✓55000 55% (2m 40s) 0.4743 Dubhshlaine / Irish ✓60000 60% (2m 54s) 2.7260 Lee / Chinese ✗ (Korean)65000 65% (3m 8s) 1.2075 Rutherford / English ✓70000 70% (3m 23s) 3.6317 Han / Chinese ✗ (Vietnamese)75000 75% (3m 37s) 0.1779 Accorso / Italian ✓80000 80% (3m 52s) 0.1095 O'Brien / Irish ✓85000 85% (4m 6s) 2.3845 Moran / Irish ✗ (English)90000 90% (4m 21s) 0.3871 Xuan / Chinese ✓95000 95% (4m 36s) 0.1104 Inoguchi / Japanese ✓100000 100% (4m 52s) 4.2142 Simon / French ✓ (Irish)
LSTM训练日志输出:
5000 5% (0m 25s) 2.8640 Fabian / Dutch ✗ (Polish)10000 10% (0m 48s) 2.9079 Login / Russian ✗ (Irish)15000 15% (1m 14s) 2.8223 Fernandes / Greek ✗ (Portuguese)20000 20% (1m 40s) 2.7069 Hudecek / Polish ✗ (Czech)25000 25% (2m 4s) 2.6162 Acciaio / Czech ✗ (Italian)30000 30% (2m 27s) 2.4044 Magalhaes / Greek ✗ (Portuguese)35000 35% (2m 52s) 1.3030 Antoschenko / Russian ✓40000 40% (3m 18s) 0.8912 Xing / Chinese ✓45000 45% (3m 42s) 1.1788 Numata / Japanese ✓50000 50% (4m 7s) 2.2863 Baz / Vietnamese ✗ (Arabic)55000 55% (4m 32s) 3.2549 Close / Dutch ✗ (Greek)60000 60% (4m 54s) 4.5170 Pan / Vietnamese ✗ (French)65000 65% (5m 16s) 1.1503 San / Chinese ✗ (Korean)70000 70% (5m 39s) 1.2357 Pavlik / Polish ✗ (Czech)75000 75% (6m 2s) 2.3275 Alves / Portuguese ✗ (English)80000 80% (6m 28s) 2.3294 Plamondon / Scottish ✗ (French)85000 85% (6m 54s) 2.7794 Water / French ✗ (English)90000 90% (7m 18s) 0.8021 Pereira / Portuguese ✓95000 95% (7m 43s) 1.4374 Kunkel / German ✓100000 100% (8m 5s) 1.2792 Taylor / Scottish ✓
GRU训练日志输出:
5000 5% (0m 22s) 2.8182 Bernard / Irish ✗ (Polish)10000 10% (0m 48s) 2.8966 Macias / Greek ✗ (Spanish)15000 15% (1m 13s) 3.1046 Morcos / Greek ✗ (Arabic)20000 20% (1m 37s) 1.5359 Davlatov / Russian ✓25000 25% (2m 1s) 1.0999 Han / Vietnamese ✓30000 30% (2m 26s) 4.1017 Chepel / German ✗ (Russian)35000 35% (2m 49s) 1.8765 Klein / Scottish ✗ (English)40000 40% (3m 11s) 1.1265 an / Chinese ✗ (Vietnamese)45000 45% (3m 34s) 0.3511 Slusarski / Polish ✓50000 50% (3m 59s) 0.9694 Than / Vietnamese ✓55000 55% (4m 25s) 2.3576 Bokhoven / Russian ✗ (Dutch)60000 60% (4m 51s) 0.1344 Filipowski / Polish ✓65000 65% (5m 15s) 1.4070 Reuter / German ✓70000 70% (5m 37s) 1.8409 Guillory / Irish ✗ (French)75000 75% (6m 0s) 0.6882 Song / Korean ✓80000 80% (6m 22s) 5.0092 Maly / Scottish ✗ (Polish)85000 85% (6m 43s) 2.4570 Sai / Chinese ✗ (Vietnamese)90000 90% (7m 5s) 1.2006 Heel / German ✗ (Dutch)95000 95% (7m 27s) 0.9144 Doan / Vietnamese ✓100000 100% (7m 50s) 1.1320 Crespo / Portuguese ✓
损失对比曲线:

损失对比曲线分析:
模型训练的损失降低快慢代表模型收敛程度, 由图可知, 传统RNN的模型收敛情况最好, 然后是GRU, 最后是LSTM, 这是因为: 我们当前处理的文本数据是人名, 他们的长度有限, 且长距离字母间基本无特定关联, 因此无法发挥改进模型LSTM和GRU的长距离捕捉语义关联的优势. 所以在以后的模型选用时, 要通过对任务的分析以及实验对比, 选择最适合的模型.
训练耗时对比图:

训练耗时对比图分析:
模型训练的耗时长短代表模型的计算复杂度, 由图可知, 也正如我们之前的理论分析, 传统RNN复杂度最低, 耗时几乎只是后两者的一半, 然后是GRU, 最后是复杂度最高的LSTM.
结论:
模型选用一般应通过实验对比, 并非越复杂或越先进的模型表现越好, 而是需要结合自己的特定任务, 从对数据的分析和实验结果中获得最佳答案.
构建评估函数并进行预测
构建传统RNN评估函数
def evaluateRNN(line_tensor):"""评估函数, 和训练函数逻辑相同, 参数是line_tensor代表名字的张量表示"""# 初始化隐层张量hidden = rnn.initHidden()# 将评估数据line_tensor的每个字符逐个传入rnn之中for i in range(line_tensor.size()[0]):output, hidden = rnn(line_tensor[i], hidden)# 获得输出结果return output.squeeze(0)
构建LSTM评估函数:
def evaluateLSTM(line_tensor):# 初始化隐层张量和细胞状态张量hidden, c = lstm.initHiddenAndC()# 将评估数据line_tensor的每个字符逐个传入lstm之中for i in range(line_tensor.size()[0]):output, hidden, c = lstm(line_tensor[i], hidden, c)return output.squeeze(0)
构建GRU评估函数:
def evaluateGRU(line_tensor):hidden = gru.initHidden()# 将评估数据line_tensor的每个字符逐个传入gru之中for i in range(line_tensor.size()[0]):output, hidden = gru(line_tensor[i], hidden)return output.squeeze(0)
输入参数:
line = "Bai"line_tensor = lineToTensor(line)
调用:
rnn_output = evaluateRNN(line_tensor)lstm_output = evaluateLSTM(line_tensor)gru_output = evaluateGRU(line_tensor)print("rnn_output:", rnn_output)print("gru_output:", lstm_output)print("gru_output:", gru_output)
rnn_output: tensor([[-2.8923, -2.7665, -2.8640, -2.7907, -2.9919, -2.9482, -2.8809, -2.9526,-2.9445, -2.8115, -2.9544, -2.9043, -2.8016, -2.8668, -3.0484, -2.9382,-2.9935, -2.7393]], grad_fn=<SqueezeBackward1>)gru_output: tensor([[-2.9498, -2.9455, -2.8981, -2.7791, -2.8915, -2.8534, -2.8637, -2.8902,-2.9537, -2.8834, -2.8973, -2.9711, -2.8622, -2.9001, -2.9149, -2.8762,-2.8286, -2.8866]], grad_fn=<SqueezeBackward1>)gru_output: tensor([[-2.8781, -2.9347, -2.7355, -2.9662, -2.9404, -2.9600, -2.8810, -2.8000,-2.8151, -2.9132, -2.7564, -2.8849, -2.9814, -3.0499, -2.9153, -2.8190,-2.8841, -2.9706]], grad_fn=<SqueezeBackward1>)
构建预测函数
def predict(input_line, evaluate, n_predictions=3):"""预测函数, 输入参数input_line代表输入的名字,n_predictions代表需要取最有可能的top个"""# 首先打印输入print('\n> %s' % input_line)# 以下操作的相关张量不进行求梯度with torch.no_grad():# 使输入的名字转换为张量表示, 并使用evaluate函数获得预测输出output = evaluate(lineToTensor(input_line))# 从预测的输出中取前3个最大的值及其索引topv, topi = output.topk(n_predictions, 1, True)# 创建盛装结果的列表predictions = []# 遍历n_predictionsfor i in range(n_predictions):# 从topv中取出的output值value = topv[0][i].item()# 取出索引并找到对应的类别category_index = topi[0][i].item()# 打印ouput的值, 和对应的类别print('(%.2f) %s' % (value, all_categories[category_index]))# 将结果装进predictions中predictions.append([value, all_categories[category_index]])for evaluate_fn in [evaluateRNN, evaluateLSTM, evaluateGRU]:print("-"*18)predict('Dovesky', evaluate_fn)predict('Jackson', evaluate_fn)predict('Satoshi', evaluate_fn)
------------------> Dovesky(-0.58) Russian(-1.40) Czech(-2.52) Scottish> Jackson(-0.27) Scottish(-1.71) English(-4.14) French> Satoshi(-0.02) Japanese(-5.10) Polish(-5.42) Arabic------------------> Dovesky(-1.03) Russian(-1.12) Czech(-2.22) Polish> Jackson(-0.37) Scottish(-2.17) English(-2.81) Czech> Satoshi(-0.29) Japanese(-1.90) Arabic(-3.20) Polish------------------> Dovesky(-0.44) Russian(-1.55) Czech(-3.06) Polish> Jackson(-0.39) Scottish(-1.91) English(-3.10) Polish> Satoshi(-0.43) Japanese(-1.22) Arabic(-3.85) Italian
使用seq2seq模型架构实现英译法任务
模型架构

从图中可知, seq2seq模型架构, 包括两部分分别是encoder(编码器)和decoder(解码器), 编码器和解码器的内部实现都使用了GRU模型, 这里它要完成的是一个中文到英文的翻译: 欢迎 来 北京 —> welcome to BeiJing. 编码器首先处理中文输入”欢迎 来 北京”, 通过GRU模型获得每个时间步的输出张量,最后将它们拼接成一个中间语义张量c, 接着解码器将使用这个中间语义张量c以及每一个时间步的隐层张量, 逐个生成对应的翻译语言.
下载数据集
下载地址: https://download.pytorch.org/tutorial/data.zip
基于GRU的seq2seq模型架构实现翻译的过程:
- 第一步: 导入必备的工具包.
- 第二步: 对持久化文件中数据进行处理, 以满足模型训练要求.
- 第三步: 构建基于GRU的编码器和解码器.
- 第四步: 构建模型训练函数, 并进行训练.
- 第五步: 构建模型评估函数, 并进行测试以及Attention效果分析.
导入必备的工具包
# 从io工具包导入open方法from io import open# 用于字符规范化import unicodedata# 用于正则表达式import re# 用于随机生成数据import random# 用于构建网络结构和函数的torch工具包import torchimport torch.nn as nnimport torch.nn.functional as F# torch中预定义的优化方法工具包from torch import optim# 设备选择, 我们可以选择在cuda或者cpu上运行你的代码device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
数据处理
将指定语言中的词汇映射成数值
# 起始标志SOS_token = 0# 结束标志EOS_token = 1class Lang:def __init__(self, name):"""初始化函数中参数name代表传入某种语言的名字"""# 将name传入类中self.name = name# 初始化词汇对应自然数值的字典self.word2index = {}# 初始化自然数值对应词汇的字典, 其中0,1对应的SOS和EOS已经在里面了self.index2word = {0: "SOS", 1: "EOS"}# 初始化词汇对应的自然数索引,这里从2开始,因为0,1已经被开始和结束标志占用了self.n_words = 2def addSentence(self, sentence):"""添加句子函数, 即将句子转化为对应的数值序列, 输入参数sentence是一条句子"""# 根据一般国家的语言特性(我们这里研究的语言都是以空格分个单词)# 对句子进行分割,得到对应的词汇列表for word in sentence.split(' '):# 然后调用addWord进行处理self.addWord(word)def addWord(self, word):"""添加词汇函数, 即将词汇转化为对应的数值, 输入参数word是一个单词"""# 首先判断word是否已经在self.word2index字典的key中if word not in self.word2index:# 如果不在, 则将这个词加入其中, 并为它对应一个数值,即self.n_wordsself.word2index[word] = self.n_words# 同时也将它的反转形式加入到self.index2word中self.index2word[self.n_words] = word# self.n_words一旦被占用之后,逐次加1, 变成新的self.n_wordsself.n_words += 1
字符规范化
# 将unicode转为Ascii, 我们可以认为是去掉一些语言中的重音标记:Ślusàrskidef unicodeToAscii(s):return ''.join(c for c in unicodedata.normalize('NFD', s)if unicodedata.category(c) != 'Mn')def normalizeString(s):"""字符串规范化函数, 参数s代表传入的字符串"""# 使字符变为小写并去除两侧空白符, z再使用unicodeToAscii去掉重音标记s = unicodeToAscii(s.lower().strip())# 在.!?前加一个空格s = re.sub(r"([.!?])", r" \1", s)# 使用正则表达式将字符串中不是大小写字母和正常标点的都替换成空格s = re.sub(r"[^a-zA-Z.!?]+", r" ", s)return s
将持久化文件中的数据加载到内存, 并实例化类Lang
data_path = '../Downloads/data/eng-fra.txt'def readLangs(lang1, lang2):"""读取语言函数, 参数lang1是源语言的名字, 参数lang2是目标语言的名字返回对应的class Lang对象, 以及语言对列表"""# 从文件中读取语言对并以/n划分存到列表lines中lines = open(data_path, encoding='utf-8').\read().strip().split('\n')# 对lines列表中的句子进行标准化处理,并以\t进行再次划分, 形成子列表, 也就是语言对pairs = [[normalizeString(s) for s in l.split('\t')] for l in lines]# 然后分别将语言名字传入Lang类中, 获得对应的语言对象, 返回结果input_lang = Lang(lang1)output_lang = Lang(lang2)return input_lang, output_lang, pairs
过滤出符合我们要求的语言对:
# 设置组成句子中单词或标点的最多个数MAX_LENGTH = 10# 选择带有指定前缀的语言特征数据作为训练数据eng_prefixes = ("i am ", "i m ","he is", "he s ","she is", "she s ","you are", "you re ","we are", "we re ","they are", "they re ")def filterPair(p):"""语言对过滤函数, 参数p代表输入的语言对, 如['she is afraid.', 'elle malade.']"""# p[0]代表英语句子,对它进行划分,它的长度应小于最大长度MAX_LENGTH并且要以指定的前缀开头# p[1]代表法文句子, 对它进行划分,它的长度应小于最大长度MAX_LENGTHreturn len(p[0].split(' ')) < MAX_LENGTH and \p[0].startswith(eng_prefixes) and \len(p[1].split(' ')) < MAX_LENGTHdef filterPairs(pairs):"""对多个语言对列表进行过滤, 参数pairs代表语言对组成的列表, 简称语言对列表"""# 函数中直接遍历列表中的每个语言对并调用filterPair即可return [pair for pair in pairs if filterPair(pair)]
对以上数据准备函数进行整合, 并使用类Lang对语言对进行数值映射
def prepareData(lang1, lang2):"""数据准备函数, 完成将所有字符串数据向数值型数据的映射以及过滤语言对参数lang1, lang2分别代表源语言和目标语言的名字"""# 首先通过readLangs函数获得input_lang, output_lang对象,以及字符串类型的语言对列表input_lang, output_lang, pairs = readLangs(lang1, lang2)# 对字符串类型的语言对列表进行过滤操作pairs = filterPairs(pairs)# 对过滤后的语言对列表进行遍历for pair in pairs:# 并使用input_lang和output_lang的addSentence方法对其进行数值映射input_lang.addSentence(pair[0])output_lang.addSentence(pair[1])# 返回数值映射后的对象, 和过滤后语言对return input_lang, output_lang, pairs
将语言对转化为模型输入需要的张量
def tensorFromSentence(lang, sentence):"""将文本句子转换为张量, 参数lang代表传入的Lang的实例化对象, sentence是预转换的句子"""# 对句子进行分割并遍历每一个词汇, 然后使用lang的word2index方法找到它对应的索引# 这样就得到了该句子对应的数值列表indexes = [lang.word2index[word] for word in sentence.split(' ')]# 然后加入句子结束标志indexes.append(EOS_token)# 将其使用torch.tensor封装成张量, 并改变它的形状为nx1, 以方便后续计算return torch.tensor(indexes, dtype=torch.long, device=device).view(-1, 1)def tensorsFromPair(pair):"""将语言对转换为张量对, 参数pair为一个语言对"""# 调用tensorFromSentence分别将源语言和目标语言分别处理,获得对应的张量表示input_tensor = tensorFromSentence(input_lang, pair[0])target_tensor = tensorFromSentence(output_lang, pair[1])# 最后返回它们组成的元组return (input_tensor, target_tensor)
构建基于GRU的编码器和解码器
编码器

class EncoderRNN(nn.Module):def __init__(self, input_size, hidden_size):"""它的初始化参数有两个, input_size代表解码器的输入尺寸即源语言的词表大小,hidden_size代表GRU的隐层节点数, 也代表词嵌入维度, 同时又是GRU的输入尺寸"""super(EncoderRNN, self).__init__()# 将参数hidden_size传入类中self.hidden_size = hidden_size# 实例化nn中预定义的Embedding层, 它的参数分别是input_size, hidden_size# 这里的词嵌入维度即hidden_size# nn.Embedding的演示在该代码下方self.embedding = nn.Embedding(input_size, hidden_size)# 然后实例化nn中预定义的GRU层, 它的参数是hidden_size# nn.GRU的演示在该代码下方self.gru = nn.GRU(hidden_size, hidden_size)def forward(self, input, hidden):"""编码器前向逻辑函数中参数有两个, input代表源语言的Embedding层输入张量hidden代表编码器层gru的初始隐层张量"""# 将输入张量进行embedding操作, 并使其形状变为(1,1,-1),-1代表自动计算维度# 理论上,我们的编码器每次只以一个词作为输入, 因此词汇映射后的尺寸应该是[1, embedding]# 而这里转换成三维的原因是因为torch中预定义gru必须使用三维张量作为输入, 因此我们拓展了一个维度output = self.embedding(input).view(1, 1, -1)# 然后将embedding层的输出和传入的初始hidden作为gru的输入传入其中,# 获得最终gru的输出output和对应的隐层张量hidden, 并返回结果output, hidden = self.gru(output, hidden)return output, hiddendef initHidden(self):"""初始化隐层张量函数"""# 将隐层张量初始化成为1x1xself.hidden_size大小的0张量return torch.zeros(1, 1, self.hidden_size, device=device)
解码器

class DecoderRNN(nn.Module):def __init__(self, hidden_size, output_size):"""初始化函数有两个参数,hidden_size代表解码器中GRU的输入尺寸,也是它的隐层节点数output_size代表整个解码器的输出尺寸, 也是我们希望得到的指定尺寸即目标语言的词表大小"""super(DecoderRNN, self).__init__()# 将hidden_size传入到类中self.hidden_size = hidden_size# 实例化一个nn中的Embedding层对象, 它的参数output这里表示目标语言的词表大小# hidden_size表示目标语言的词嵌入维度self.embedding = nn.Embedding(output_size, hidden_size)# 实例化GRU对象,输入参数都是hidden_size,代表它的输入尺寸和隐层节点数相同self.gru = nn.GRU(hidden_size, hidden_size)# 实例化线性层, 对GRU的输出做线性变化, 获我们希望的输出尺寸output_size# 因此它的两个参数分别是hidden_size, output_sizeself.out = nn.Linear(hidden_size, output_size)# 最后使用softmax进行处理,以便于分类self.softmax = nn.LogSoftmax(dim=1)def forward(self, input, hidden):"""解码器的前向逻辑函数中, 参数有两个, input代表目标语言的Embedding层输入张量hidden代表解码器GRU的初始隐层张量"""# 将输入张量进行embedding操作, 并使其形状变为(1,1,-1),-1代表自动计算维度# 原因和解码器相同,因为torch预定义的GRU层只接受三维张量作为输入output = self.embedding(input).view(1, 1, -1)# 然后使用relu函数对输出进行处理,根据relu函数的特性, 将使Embedding矩阵更稀疏,以防止过拟合output = F.relu(output)# 接下来, 将把embedding的输出以及初始化的hidden张量传入到解码器gru中output, hidden = self.gru(output, hidden)# 因为GRU输出的output也是三维张量,第一维没有意义,因此可以通过output[0]来降维# 再传给线性层做变换, 最后用softmax处理以便于分类output = self.softmax(self.out(output[0]))return output, hiddendef initHidden(self):"""初始化隐层张量函数"""# 将隐层张量初始化成为1x1xself.hidden_size大小的0张量return torch.zeros(1, 1, self.hidden_size, device=device)
构建基于GRU和Attention的解码器

class AttnDecoderRNN(nn.Module):def __init__(self, hidden_size, output_size, dropout_p=0.1, max_length=MAX_LENGTH):"""初始化函数中的参数有4个, hidden_size代表解码器中GRU的输入尺寸,也是它的隐层节点数output_size代表整个解码器的输出尺寸, 也是我们希望得到的指定尺寸即目标语言的词表大小dropout_p代表我们使用dropout层时的置零比率,默认0.1, max_length代表句子的最大长度"""super(AttnDecoderRNN, self).__init__()# 将以下参数传入类中self.hidden_size = hidden_sizeself.output_size = output_sizeself.dropout_p = dropout_pself.max_length = max_length# 实例化一个Embedding层, 输入参数是self.output_size和self.hidden_sizeself.embedding = nn.Embedding(self.output_size, self.hidden_size)# 根据attention的QKV理论,attention的输入参数为三个Q,K,V,# 第一步,使用Q与K进行attention权值计算得到权重矩阵, 再与V做矩阵乘法, 得到V的注意力表示结果.# 这里常见的计算方式有三种:# 1,将Q,K进行纵轴拼接, 做一次线性变化, 再使用softmax处理获得结果最后与V做张量乘法# 2,将Q,K进行纵轴拼接, 做一次线性变化后再使用tanh函数激活, 然后再进行内部求和, 最后使用softmax处理获得结果再与V做张量乘法# 3,将Q与K的转置做点积运算, 然后除以一个缩放系数, 再使用softmax处理获得结果最后与V做张量乘法# 说明:当注意力权重矩阵和V都是三维张量且第一维代表为batch条数时, 则做bmm运算.# 第二步, 根据第一步采用的计算方法, 如果是拼接方法,则需要将Q与第二步的计算结果再进行拼接,# 如果是转置点积, 一般是自注意力, Q与V相同, 则不需要进行与Q的拼接.因此第二步的计算方式与第一步采用的全值计算方法有关.# 第三步,最后为了使整个attention结构按照指定尺寸输出, 使用线性层作用在第二步的结果上做一个线性变换. 得到最终对Q的注意力表示.# 我们这里使用的是第一步中的第一种计算方式, 因此需要一个线性变换的矩阵, 实例化nn.Linear# 因为它的输入是Q,K的拼接, 所以输入的第一个参数是self.hidden_size * 2,第二个参数是self.max_length# 这里的Q是解码器的Embedding层的输出, K是解码器GRU的隐层输出,因为首次隐层还没有任何输出,会使用编码器的隐层输出# 而这里的V是编码器层的输出self.attn = nn.Linear(self.hidden_size * 2, self.max_length)# 接着我们实例化另外一个线性层, 它是attention理论中的第四步的线性层,用于规范输出尺寸# 这里它的输入来自第三步的结果, 因为第三步的结果是将Q与第二步的结果进行拼接, 因此输入维度是self.hidden_size * 2self.attn_combine = nn.Linear(self.hidden_size * 2, self.hidden_size)# 接着实例化一个nn.Dropout层,并传入self.dropout_pself.dropout = nn.Dropout(self.dropout_p)# 之后实例化nn.GRU, 它的输入和隐层尺寸都是self.hidden_sizeself.gru = nn.GRU(self.hidden_size, self.hidden_size)# 最后实例化gru后面的线性层,也就是我们的解码器输出层.self.out = nn.Linear(self.hidden_size, self.output_size)def forward(self, input, hidden, encoder_outputs):"""forward函数的输入参数有三个, 分别是源数据输入张量, 初始的隐层张量, 以及解码器的输出张量"""# 根据结构计算图, 输入张量进行Embedding层并扩展维度embedded = self.embedding(input).view(1, 1, -1)# 使用dropout进行随机丢弃,防止过拟合embedded = self.dropout(embedded)# 进行attention的权重计算, 哦我们呢使用第一种计算方式:# 将Q,K进行纵轴拼接, 做一次线性变化, 最后使用softmax处理获得结果attn_weights = F.softmax(self.attn(torch.cat((embedded[0], hidden[0]), 1)), dim=1)# 然后进行第一步的后半部分, 将得到的权重矩阵与V做矩阵乘法计算, 当二者都是三维张量且第一维代表为batch条数时, 则做bmm运算attn_applied = torch.bmm(attn_weights.unsqueeze(0),encoder_outputs.unsqueeze(0))# 之后进行第二步, 通过取[0]是用来降维, 根据第一步采用的计算方法, 需要将Q与第一步的计算结果再进行拼接output = torch.cat((embedded[0], attn_applied[0]), 1)# 最后是第三步, 使用线性层作用在第三步的结果上做一个线性变换并扩展维度,得到输出output = self.attn_combine(output).unsqueeze(0)# attention结构的结果使用relu激活output = F.relu(output)# 将激活后的结果作为gru的输入和hidden一起传入其中output, hidden = self.gru(output, hidden)# 最后将结果降维并使用softmax处理得到最终的结果output = F.log_softmax(self.out(output[0]), dim=1)# 返回解码器结果,最后的隐层张量以及注意力权重张量return output, hidden, attn_weightsdef initHidden(self):"""初始化隐层张量函数"""# 将隐层张量初始化成为1x1xself.hidden_size大小的0张量return torch.zeros(1, 1, self.hidden_size, device=device)
构建模型训练函数, 并进行训练
teacher_forcing
它是一种用于序列生成任务的训练技巧, 在seq2seq架构中, 根据循环神经网络理论,解码器每次应该使用上一步的结果作为输入的一部分, 但是训练过程中,一旦上一步的结果是错误的,就会导致这种错误被累积,无法达到训练效果, 因此,我们需要一种机制改变上一步出错的情况,因为训练时我们是已知正确的输出应该是什么,因此可以强制将上一步结果设置成正确的输出, 这种方式就叫做teacher_forcing.
作用:
能够在训练的时候矫正模型的预测,避免在序列生成的过程中误差进一步放大.
teacher_forcing能够极大的加快模型的收敛速度,令模型训练过程更快更平稳.
# 设置teacher_forcing比率为0.5teacher_forcing_ratio = 0.5def train(input_tensor, target_tensor, encoder, decoder, encoder_optimizer, decoder_optimizer, criterion, max_length=MAX_LENGTH):"""训练函数, 输入参数有8个, 分别代表input_tensor:源语言输入张量,target_tensor:目标语言输入张量,encoder, decoder:编码器和解码器实例化对象encoder_optimizer, decoder_optimizer:编码器和解码器优化方法,criterion:损失函数计算方法,max_length:句子的最大长度"""# 初始化隐层张量encoder_hidden = encoder.initHidden()# 编码器和解码器优化器梯度归0encoder_optimizer.zero_grad()decoder_optimizer.zero_grad()# 根据源文本和目标文本张量获得对应的长度input_length = input_tensor.size(0)target_length = target_tensor.size(0)# 初始化编码器输出张量,形状是max_lengthxencoder.hidden_size的0张量encoder_outputs = torch.zeros(max_length, encoder.hidden_size, device=device)# 初始设置损失为0loss = 0# 循环遍历输入张量索引for ei in range(input_length):# 根据索引从input_tensor取出对应的单词的张量表示,和初始化隐层张量一同传入encoder对象中encoder_output, encoder_hidden = encoder(input_tensor[ei], encoder_hidden)# 将每次获得的输出encoder_output(三维张量), 使用[0, 0]降两维变成向量依次存入到encoder_outputs# 这样encoder_outputs每一行存的都是对应的句子中每个单词通过编码器的输出结果encoder_outputs[ei] = encoder_output[0, 0]# 初始化解码器的第一个输入,即起始符decoder_input = torch.tensor([[SOS_token]], device=device)# 初始化解码器的隐层张量即编码器的隐层输出decoder_hidden = encoder_hidden# 根据随机数与teacher_forcing_ratio对比判断是否使用teacher_forcinguse_teacher_forcing = True if random.random() < teacher_forcing_ratio else False# 如果使用teacher_forcingif use_teacher_forcing:# 循环遍历目标张量索引for di in range(target_length):# 将decoder_input, decoder_hidden, encoder_outputs即attention中的QKV,# 传入解码器对象, 获得decoder_output, decoder_hidden, decoder_attentiondecoder_output, decoder_hidden, decoder_attention = decoder(decoder_input, decoder_hidden, encoder_outputs)# 因为使用了teacher_forcing, 无论解码器输出的decoder_output是什么, 我们都只# 使用‘正确的答案’,即target_tensor[di]来计算损失loss += criterion(decoder_output, target_tensor[di])# 并强制将下一次的解码器输入设置为‘正确的答案’decoder_input = target_tensor[di]else:# 如果不使用teacher_forcing# 仍然遍历目标张量索引for di in range(target_length):# 将decoder_input, decoder_hidden, encoder_outputs传入解码器对象# 获得decoder_output, decoder_hidden, decoder_attentiondecoder_output, decoder_hidden, decoder_attention = decoder(decoder_input, decoder_hidden, encoder_outputs)# 只不过这里我们将从decoder_output取出答案topv, topi = decoder_output.topk(1)# 损失计算仍然使用decoder_output和target_tensor[di]loss += criterion(decoder_output, target_tensor[di])# 最后如果输出值是终止符,则循环停止if topi.squeeze().item() == EOS_token:break# 否则,并对topi降维并分离赋值给decoder_input以便进行下次运算# 这里的detach的分离作用使得这个decoder_input与模型构建的张量图无关,相当于全新的外界输入decoder_input = topi.squeeze().detach()# 误差进行反向传播loss.backward()# 编码器和解码器进行优化即参数更新encoder_optimizer.step()decoder_optimizer.step()# 最后返回平均损失return loss.item() / target_length
构建时间计算函数
# 导入时间和数学工具包import timeimport mathdef timeSince(since):"获得每次打印的训练耗时, since是训练开始时间"# 获得当前时间now = time.time()# 获得时间差,就是训练耗时s = now - since# 将秒转化为分钟, 并取整m = math.floor(s / 60)# 计算剩下不够凑成1分钟的秒数s -= m * 60# 返回指定格式的耗时return '%dm %ds' % (m, s)
调用训练函数并打印日志和制图
# 导入plt以便绘制损失曲线import matplotlib.pyplot as pltdef trainIters(encoder, decoder, n_iters, print_every=1000, plot_every=100, learning_rate=0.01):"""训练迭代函数, 输入参数有6个,分别是encoder, decoder: 编码器和解码器对象,n_iters: 总迭代步数, print_every:打印日志间隔, plot_every:绘制损失曲线间隔, learning_rate学习率"""# 获得训练开始时间戳start = time.time()# 每个损失间隔的平均损失保存列表,用于绘制损失曲线plot_losses = []# 每个打印日志间隔的总损失,初始为0print_loss_total = 0# 每个绘制损失间隔的总损失,初始为0plot_loss_total = 0# 使用预定义的SGD作为优化器,将参数和学习率传入其中encoder_optimizer = optim.SGD(encoder.parameters(), lr=learning_rate)decoder_optimizer = optim.SGD(decoder.parameters(), lr=learning_rate)# 选择损失函数criterion = nn.NLLLoss()# 根据设置迭代步进行循环for iter in range(1, n_iters + 1):# 每次从语言对列表中随机取出一条作为训练语句training_pair = tensorsFromPair(random.choice(pairs))# 分别从training_pair中取出输入张量和目标张量input_tensor = training_pair[0]target_tensor = training_pair[1]# 通过train函数获得模型运行的损失loss = train(input_tensor, target_tensor, encoder,decoder, encoder_optimizer, decoder_optimizer, criterion)# 将损失进行累和print_loss_total += lossplot_loss_total += loss# 当迭代步达到日志打印间隔时if iter % print_every == 0:# 通过总损失除以间隔得到平均损失print_loss_avg = print_loss_total / print_every# 将总损失归0print_loss_total = 0# 打印日志,日志内容分别是:训练耗时,当前迭代步,当前进度百分比,当前平均损失print('%s (%d %d%%) %.4f' % (timeSince(start),iter, iter / n_iters * 100, print_loss_avg))# 当迭代步达到损失绘制间隔时if iter % plot_every == 0:# 通过总损失除以间隔得到平均损失plot_loss_avg = plot_loss_total / plot_every# 将平均损失装进plot_losses列表plot_losses.append(plot_loss_avg)# 总损失归0plot_loss_total = 0# 绘制损失曲线plt.figure()plt.plot(plot_losses)# 保存到指定路径plt.savefig("./s2s_loss.png")
进行训练
# 设置隐层大小为256 ,也是词嵌入维度hidden_size = 256# 通过input_lang.n_words获取输入词汇总数,与hidden_size一同传入EncoderRNN类中# 得到编码器对象encoder1encoder1 = EncoderRNN(input_lang.n_words, hidden_size).to(device)# 通过output_lang.n_words获取目标词汇总数,与hidden_size和dropout_p一同传入AttnDecoderRNN类中# 得到解码器对象attn_decoder1attn_decoder1 = AttnDecoderRNN(hidden_size, output_lang.n_words, dropout_p=0.1).to(device)# 设置迭代步数n_iters = 75000# 设置日志打印间隔print_every = 5000# 调用trainIters进行模型训练,将编码器对象encoder1,码器对象attn_decoder1,迭代步数,日志打印间隔传入其中trainIters(encoder1, attn_decoder1, n_iters, print_every=print_every)
3m 35s (5000 6%) 3.41597m 12s (10000 13%) 2.780510m 46s (15000 20%) 2.466314m 23s (20000 26%) 2.169318m 6s (25000 33%) 1.930321m 44s (30000 40%) 1.760125m 23s (35000 46%) 1.620729m 8s (40000 53%) 1.497332m 44s (45000 60%) 1.383236m 22s (50000 66%) 1.269440m 6s (55000 73%) 1.181343m 51s (60000 80%) 1.090747m 29s (65000 86%) 1.042551m 10s (70000 93%) 0.995554m 48s (75000 100%) 0.9158
损失下降曲线

损失曲线分析:
- 一直下降的损失曲线, 说明模型正在收敛, 能够从数据中找到一些规律应用于数据.
构建模型评估函数, 并进行测试以及Attention效果分析.
构建模型评估函数
def evaluate(encoder, decoder, sentence, max_length=MAX_LENGTH):"""评估函数,输入参数有4个,分别是encoder, decoder: 编码器和解码器对象,sentence:需要评估的句子,max_length:句子的最大长度"""# 评估阶段不进行梯度计算with torch.no_grad():# 对输入的句子进行张量表示input_tensor = tensorFromSentence(input_lang, sentence)# 获得输入的句子长度input_length = input_tensor.size()[0]# 初始化编码器隐层张量encoder_hidden = encoder.initHidden()# 初始化编码器输出张量,是max_lengthxencoder.hidden_size的0张量encoder_outputs = torch.zeros(max_length, encoder.hidden_size, device=device)# 循环遍历输入张量索引for ei in range(input_length):# 根据索引从input_tensor取出对应的单词的张量表示,和初始化隐层张量一同传入encoder对象中encoder_output, encoder_hidden = encoder(input_tensor[ei],encoder_hidden)#将每次获得的输出encoder_output(三维张量), 使用[0, 0]降两维变成向量依次存入到encoder_outputs# 这样encoder_outputs每一行存的都是对应的句子中每个单词通过编码器的输出结果encoder_outputs[ei] += encoder_output[0, 0]# 初始化解码器的第一个输入,即起始符decoder_input = torch.tensor([[SOS_token]], device=device)# 初始化解码器的隐层张量即编码器的隐层输出decoder_hidden = encoder_hidden# 初始化预测的词汇列表decoded_words = []# 初始化attention张量decoder_attentions = torch.zeros(max_length, max_length)# 开始循环解码for di in range(max_length):# 将decoder_input, decoder_hidden, encoder_outputs传入解码器对象# 获得decoder_output, decoder_hidden, decoder_attentiondecoder_output, decoder_hidden, decoder_attention = decoder(decoder_input, decoder_hidden, encoder_outputs)# 取所有的attention结果存入初始化的attention张量中decoder_attentions[di] = decoder_attention.data# 从解码器输出中获得概率最高的值及其索引对象topv, topi = decoder_output.data.topk(1)# 从索引对象中取出它的值与结束标志值作对比if topi.item() == EOS_token:# 如果是结束标志值,则将结束标志装进decoded_words列表,代表翻译结束decoded_words.append('<EOS>')# 循环退出breakelse:# 否则,根据索引找到它在输出语言的index2word字典中对应的单词装进decoded_wordsdecoded_words.append(output_lang.index2word[topi.item()])# 最后将本次预测的索引降维并分离赋值给decoder_input,以便下次进行预测decoder_input = topi.squeeze().detach()# 返回结果decoded_words, 以及完整注意力张量, 把没有用到的部分切掉return decoded_words, decoder_attentions[:di + 1]
随机选择指定数量的数据进行评估
def evaluateRandomly(encoder, decoder, n=6):"""随机测试函数, 输入参数encoder, decoder代表编码器和解码器对象,n代表测试数"""# 对测试数进行循环for i in range(n):# 从pairs随机选择语言对pair = random.choice(pairs)# > 代表输入print('>', pair[0])# = 代表正确的输出print('=', pair[1])# 调用evaluate进行预测output_words, attentions = evaluate(encoder, decoder, pair[0])# 将结果连成句子output_sentence = ' '.join(output_words)# < 代表模型的输出print('<', output_sentence)print('')# 调用evaluateRandomly进行模型测试,将编码器对象encoder1,码器对象attn_decoder1传入其中evaluateRandomly(encoder1, attn_decoder1)
Attention张量制图:
sentence = "we re both teachers ."# 调用评估函数output_words, attentions = evaluate(encoder1, attn_decoder1, sentence)print(output_words)# 将attention张量转化成numpy, 使用matshow绘制plt.matshow(attentions.numpy())# 保存图像plt.savefig("./s2s_attn.png")
['nous', 'sommes', 'toutes', 'deux', 'enseignantes', '.', '<EOS>']

分析:
Attention图像的纵坐标代表输入的源语言各个词汇对应的索引, 0-6分别对应[“we”, “re”, “both”, “teachers”, “.”, “”], 纵坐标代表生成的目标语言各个词汇对应的索引, 0-7代表[‘nous’, ‘sommes’, ‘toutes’, ‘deux’, ‘enseignantes’, ‘.’, ‘’], 图中浅色小方块(颜色越浅说明影响越大)代表词汇之间的影响关系, 比如源语言的第1个词汇对生成目标语言的第1个词汇影响最大, 源语言的第4,5个词对生成目标语言的第5个词会影响最大, 通过这样的可视化图像, 我们可以知道Attention的效果好坏, 与我们人为去判定到底还有多大的差距. 进而衡量我们训练模型的可用性.
