从零开始的自然语言处理:利用字符级RNN生成名字
作者: Sean Robertson
这是“从零开始的自然语言处理”系列教程中的第二部分。在第一部分中,我们使用 RNN 将名字分类到其起源的语言。这次我们将根据语言生成名字。
>pythonsample.pyRussianRUS Rovakov Uantov Shavakov >pythonsample.pyGermanGER Gerren Ereng Rosher >pythonsample.pySpanishSPA Salla Parer Allan >pythonsample.pyChineseCHI Chan Hang Iun
我们仍在手工制作一个小型RNN,并包含几个线性层。主要的区别在于,不再是读取完名字中的所有字母后再进行类别预测,而是输入一个类别并一次输出一个字母。通过递归地预测字符来生成语言(这也可以用单词或其他更高层次的结构来完成)通常被称为“语言模型”。
推荐阅读:
假设你已经安装了PyTorch,掌握了Python,并且了解张量。
-
https://pytorch.org/ 提供了安装说明
-
使用 PyTorch 进行深度学习:60 分钟速成 来开始学习 PyTorch
-
使用示例学习 PyTorch,以获得全面而深入的了解
-
PyTorch 实用技巧:Former Torch 用户指南(如果你是 Former Lua Torch 用户)
了解循环神经网络(RNN)及其工作原理也会很有用:
-
循环神经网络的不合理有效性展示了大量的现实生活中的实例。
-
理解LSTM网络专门介绍LSTMs,但同时也为一般RNNs提供了有价值的参考信息。
我也建议参考之前的教程:从零开始的NLP:使用字符级RNN对名称进行分类
准备数据
注
从这里下载数据,并将其解压到当前目录。
参见上一个教程以获取此过程的更多详细信息。简而言之,有许多纯文本文件 data/names/[Language].txt
,每个文件中每行包含一个名字。我们将各行拆分为数组,并将Unicode转换为ASCII,最终得到一个字典 {language: [names ...]}
。
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) # 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 all_letters ) # Read a file and split into lines def readLines(filename): with open(filename, encoding='utf-8') as some_file: return [unicodeToAscii(line.strip()) for line in some_file] # Build the category_lines dictionary, a list of lines per category 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 ['Arabic', 'Chinese', 'Czech', 'Dutch', 'English', 'French', 'German', 'Greek', 'Irish', 'Italian', 'Japanese', 'Korean', 'Polish', 'Portuguese', 'Russian', 'Scottish', 'Spanish', 'Vietnamese'] O'Neal
创建网络
此网络在上一教程的 RNN基础上扩展,添加了一个额外的参数用于类别张量,并将其与其他张量拼接在一起。类别张量与字母输入一样,也是一个 one-hot 向量。
我添加了第二个线性层 o2o
(在隐藏层和输出层结合之后),以增强其处理能力。还有一个 dropout 层,它会以一定的概率随机将输入的一部分置零(这里为 0.1),通常用于模糊输入以防止过拟合。在这里,我们将其用在网络的末尾,有意地增加一些混乱,从而提高采样多样性。

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 # Random item from a list def randomChoice(l): return l[random.randint(0, len(l) - 1)] # Get a random category and random line from that category def randomTrainingPair(): category = randomChoice(all_categories) line = randomChoice(category_lines[category]) return category, line
对于每个时间步(即训练词中的每个字母),网络的输入将是(类别, 当前字母, 隐藏状态)
,输出将是(下一个字母, 下一个隐藏状态)
。因此,对于每个训练集,我们需要提供类别、一组输入字母和一组输出/目标字母。
由于我们每个时间步都从当前字母预测下一个字母,因此字母对是由行中连续的字母组成的组。例如,对于"ABCD<EOS>"
,我们会创建(“A”,“B”),(“B”,“C”),(“C”,“D”),(“D”,“EOS”)。

类别张量是一个one-hot 张量,大小为 <1 x n_categories>
。在训练过程中,我们会在每个时间步将其输入到网络中——这是一个设计选择,也可以将其作为初始隐藏状态或其他策略的一部分。
# One-hot vector for category def categoryTensor(category): li = all_categories.index(category) tensor = torch.zeros(1, n_categories) tensor[0][li] = 1 return tensor # One-hot matrix of first to last letters (not including EOS) for input 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 # ``LongTensor`` of second letter to end (EOS) for target 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
函数,该函数获取一个随机的 (类别, 行) 对,并将其转换成所需的 (类别, 输入, 目标) 张量。
# Make category, input, and target tensors from a random category, line pair 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
训练网络
与分类任务不同(分类任务只使用最后一个输出),在这里我们每一步都会进行预测,并且每一步都会计算损失。
autograd 的魔力允许你在每一步简单地计算这些损失的总和,并在最后调用 backward 函数。
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 = torch.Tensor([0]) # you can also just simply use ``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_(p.grad.data, alpha=-learning_rate) 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)
训练过程如常进行 - 多次调用 train 函数并等待几分钟,每处理 print_every
个样本就打印当前时间和损失值,并在 all_losses
中存储每 plot_every
个样本的平均损失值,以便后续绘图。
rnn = RNN(n_letters, 128, n_letters) n_iters = 100000 print_every = 5000 plot_every = 500 all_losses = [] total_loss = 0 # Reset every ``plot_every`` ``iters`` 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 26s (5000 5%) 3.1506 0m 52s (10000 10%) 2.5070 1m 19s (15000 15%) 3.3047 1m 46s (20000 20%) 2.4247 2m 14s (25000 25%) 2.6406 2m 41s (30000 30%) 2.0266 3m 8s (35000 35%) 2.6520 3m 34s (40000 40%) 2.4261 4m 1s (45000 45%) 2.2302 4m 28s (50000 50%) 1.6496 4m 55s (55000 55%) 2.7101 5m 22s (60000 60%) 2.5396 5m 49s (65000 65%) 2.5978 6m 17s (70000 70%) 1.6029 6m 44s (75000 75%) 0.9634 7m 11s (80000 80%) 3.0950 7m 38s (85000 85%) 2.0512 8m 5s (90000 90%) 2.5302 8m 32s (95000 95%) 3.2365 8m 59s (100000 100%) 1.7113
绘制损耗图
绘制所有损失值(all_losses)的历史记录,显示网络正在学习。
import matplotlib.pyplot as plt plt.figure() plt.plot(all_losses)

[<matplotlib.lines.Line2D object at 0x7f706c9b3be0>]
网络采样
为了进行采样,我们将一个字母输入到网络中,并询问下一个字母是什么。然后将得到的字母再次输入到网络中,重复这个过程,直到遇到 EOS 标记为止。
-
创建输入类别、起始字母和空隐藏状态的张量
-
创建一个字符串
output_name
,使其以指定的字母开头 -
不超过最大输出长度的情况下,
-
将当前字母输入到网络中
-
获取下一个字母及其对应的下一个隐藏状态,依据最高的输出。
-
如果字母是EOS,则在此处停止。
-
如果是普通字母,则将其添加到
output_name
并继续处理。
-
-
返回最终的名字
注
与其给它指定一个起始字母,另一种策略是,在训练过程中添加一个“字符串开始”标记,让网络自行决定起始字母。
max_length = 20 # Sample from a category and starting letter def sample(category, start_letter='A'): with torch.no_grad(): # no need to track history in sampling 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 # Get multiple samples from one category and multiple starting letters 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')
Rovaki Uarinovev Shinan Gerter Eeren Roune Santera Paneraz Allan Chin Han Ion
练习
-
尝试使用不同的数据集(类别 -> 线),例如:
-
虚构作品系列 -> 角色名称
-
词性 -> 词语
-
国家 → 城市
-
-
使用一个“句首”标记,以便无需指定起始字母即可进行采样。
-
通过使用更大或更优结构的网络,获得更好的结果
-
尝试使用
nn.LSTM
和nn.GRU
层 -
将多个这样的RNN结合起来形成一个更高层次的网络
-