翻译自官网手册:NLP From Scratch: Generating Names with a Character-Level RNN
Author: Sean Robertson
原文github代码
这是NLP从零开始三个教程的第二个。在第一个教程char_rnn_classification_tutorial中,使用RNN将名字按照其来源的语言进行了分类。本教程将进行名字生成。
> python sample.py Russian RUS
Rovakov
Uantov
Shavakov
> python sample.py German GER
Gerren
Ereng
Rosher
> python sample.py Spanish SPA
Salla
Parer
Allan
> python sample.py Chinese CHI
Chan
Hang
Iun
本教程依然是编写了一个简单的由几个线性层组成的RNN。最大的不同是不再是输入一个名字的所有字母然后预测它的类别,而是输入一个类别并一次输出一个字母,循环的预测字母(也可以用单词或更高级的序列结构)形成语言,这经常被称为“语言模型( language model
)”
推荐阅读
本教程需要你至少安装了PyTorch、了解Python、了解张量(Tensors):
了解RNNs及其原理可以阅读:
前一个教程也可以学习下:char_rnn_classification_tutorial
注意:
从此处下载数据并解压到当前目录。
这个过程的详细信息查看上一个教程。简之,数据包括一些每行是一个名字的纯文本文件 data/names/[Language].txt
。我们将行分割进数组、将Unicode编码转化为ASCII编码、最后创建了一个字典 {language: [names ...]}
。
from __future__ import unicode_literals, print_function, division
from io import open
import glob
import os
import unicodedata
import string
all_letters = string.ascii_letters + " .,;'-"
n_letters = len(all_letters) + 1 # Plus EOS marker
#返回文件夹下文件列表
def findFiles(path): return glob.glob(path)
# 将Unicode字符串转换为简单的ASCII字符,鸣谢 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 all_letters
)
# 读取一个文件并按行分割
def readLines(filename):
lines = open(filename, encoding='utf-8').read().strip().split('\n')
return [unicodeToAscii(line) for line in lines]
#创建分类字典(category_lines), 每种语言对应一个名字列表
category_lines = {}
all_categories = []
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('Data not found. Make sure that you downloaded data '
'from https://download.pytorch.org/tutorial/data.zip and extract it to '
'the current directory.')
print('# categories:', n_categories, all_categories)
print(unicodeToAscii("O'Néàl"))
输出:
# categories: 18 ['Italian', 'Dutch', 'German', 'Arabic', 'Czech', 'Greek', 'Vietnamese', 'French', 'Spanish', 'English', 'Portuguese', 'Scottish', 'Chinese', 'Irish', 'Russian', 'Japanese', 'Korean', 'Polish']
O'Neal
本网络是在上一教程RNN网络上的基础上,增加了额外的类别张量,其与其它输入张量拼接在一起。类别张量与字母输入相同都是独热向量。
网络输出将作为下一个字母的概率。生成时,可能性最大的输出字母将作为下一步的输入字母。
本教程增加了第二个线性层 o2o
(将隐藏状态和输出合并后)使模型更有效。模型还添加了一个dropout
层,该层会根据设定的概率(本文中为0.1)随机将一部分输入置零,模糊输入从而避免过度拟合。这里将dropout
层放在网络最后,从而增加一些混乱并增加生成的多样性。
import torch
import torch.nn as nn
class 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)
self.softmax = nn.LogSoftmax(dim=1)
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)
output = self.softmax(output)
return output, hidden
def initHidden(self):
return torch.zeros(1, self.hidden_size)
首先,创建可以随机获取类别-名字对(category, line)的辅助函数。
import random
# 从列表中随机取一个条目
def randomChoice(l):
return l[random.randint(0, len(l) - 1)]
# 随机选取一个类别并从该类别中随机选取一个名字
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
,创建("A", "B"), ("B", "C"), ("C", "D"), ("D", "EOS")
作为输入。
类别张量是大小为 <1 x n_categories>
的独热张量。训练时将其在每个时间步传递给网络 - 这个是可选设计,也可以将其作为初始状态的一部分或者其他策略。
# 类别独热向量
def categoryTensor(category):
li = all_categories.index(category)
tensor = torch.zeros(1, n_categories)
tensor[0][li] = 1
return tensor
# 作为输入的从第一个到最后一个字母(不包括EOS)的独热One-hot矩阵
def inputTensor(line):
tensor = torch.zeros(len(line), 1, n_letters)
for li in range(len(line)):
letter = line[li]
tensor[li][0][all_letters.find(letter)] = 1
return tensor
# 作为目标的第二个字母到结尾(EOS)的长张量(LongTensor)
def targetTensor(line):
letter_indexes = [all_letters.find(line[li]) for li in range(1, len(line))]
letter_indexes.append(n_letters - 1) # EOS
return torch.LongTensor(letter_indexes)
为了训练方便,创建了一个 randomTrainingExample
函数,其随机提取类别-名字对 (category, line)
并将其转换为要求的类别-输入-目标张量(category, input, target)
。
# 随机选取类别-名字对并将其转换为类别-输入-目标张量
def randomTrainingExample():
category, line = randomTrainingPair()
category_tensor = categoryTensor(category)
input_line_tensor = inputTensor(line)
target_line_tensor = targetTensor(line)
return category_tensor, input_line_tensor, target_line_tensor
与分类只使用最后一个输出不同,生成在每个步骤都做出预测,因此需要在每一步就是你损失。
自动求导方法很神奇的可以让你简单的将每一步的损失累加起来在最后调用后向传导。
criterion = nn.NLLLoss()
learning_rate = 0.0005
def train(category_tensor, input_line_tensor, target_line_tensor):
target_line_tensor.unsqueeze_(-1)
hidden = rnn.initHidden()
rnn.zero_grad()
loss = 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_(-learning_rate, p.grad.data)
return output, loss.item() / input_line_tensor.size(0)
为了记录训练需要多久,这里增加了timeSince(timestamp)
函数,其可以返回可读的字符串。
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)
训练与之前一样,调用多次调用训练函数并等待几分钟,每个print_every
样本会输出当前时间和损失,每个 plot_every
样本在 all_losses
中记录一个平均损失为绘图做准备。
rnn = RNN(n_letters, 128, n_letters)
n_iters = 100000
print_every = 5000
plot_every = 500
all_losses = []
total_loss = 0 # 重置每个 plot_every 迭代
start = time.time()
for iter in range(1, n_iters + 1):
output, loss = train(*randomTrainingExample())
total_loss += loss
if iter % print_every == 0:
print('%s (%d %d%%) %.4f' % (timeSince(start), iter, iter / n_iters * 100, loss))
if iter % plot_every == 0:
all_losses.append(total_loss / plot_every)
total_loss = 0
输出:
0m 28s (5000 5%) 2.6901
0m 56s (10000 10%) 2.4971
1m 24s (15000 15%) 2.4370
1m 52s (20000 20%) 2.6624
2m 20s (25000 25%) 2.9531
2m 48s (30000 30%) 3.6187
3m 16s (35000 35%) 3.6624
3m 45s (40000 40%) 2.4471
4m 12s (45000 45%) 2.5432
4m 40s (50000 50%) 2.0523
5m 8s (55000 55%) 1.9411
5m 36s (60000 60%) 3.3368
6m 4s (65000 65%) 2.2312
6m 32s (70000 70%) 3.4389
7m 0s (75000 75%) 2.8141
7m 28s (80000 80%) 3.5611
7m 56s (85000 85%) 2.2557
8m 24s (90000 90%) 1.9750
8m 52s (95000 95%) 2.3979
9m 19s (100000 100%) 2.2186
绘制all_losses
中历史损失,查看网络学习情况:
%matplotlib inline
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
plt.figure()
plt.plot(all_losses)
为了生成名字,传递给网络一个字母并得到下一个字母,将所得字母再次传递给网络知道出现终止标记EOS
。具体流程如下:
创建输入类别、起始字母、空隐藏状态张量
使用起始字母生成名字 output_name
字符串
到最大输出长度之前:
output_name
继续返回最终名字
注意: 除了给定起始字母,另一种策略是包含训练时的“起始字符”让网络自动选择起始字母。
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):
output, hidden = rnn(category_tensor, input[0], hidden)
topv, topi = output.topk(1)
topi = topi[0][0]
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))
samples('Russian', 'RUS')
samples('German', 'GER')
samples('Spanish', 'SPA')
samples('Chinese', 'CHI')
输出:
Rovako
Uakinov
Sakin
Gerra
Eren
Rangerre
Salla
Para
Allan
Chan
Han
Iun
使用不同的类别-行category -> line
数据集,比如:
使用“起始标记”,这样生成时不需要选择起始字母
使用规模更大或者设计更好的模型优化结果:
nn.LSTM
和 nn.GRU
层