在本文中,我们将构建并训练一个字符级别的循环神经网络来对单词进行分类。在基于字符级别的循环神经网络里,单词将是一个个字符(字母)的序列,利用循环神经网络具备一定的时序记忆功能来实现判断一个单词的类别。具体在本文的示例中,我们将对来自18种语言的上千个人名进行训练,并让网络根据给定名字的字母拼写来预测这个名字最可能是哪个国家常用的名字。
推荐先期阅读
在阅读本文前,读者最好具备Python,Pytorch的基本知识,并能理解“张量”这个概念。同时有一定的循环神经网络和LSTM知识对于理解本文也是很有帮助的。
本文从“准备数据”、“创建网络模型”、“训练网络”、“评估网络”、“自我练习”等五各方面来展开,其中最后一部分“自我练习”是留给读者自己实践的内容。
准备数据
从此处下载数据并把它解压到当前目录。下载的压缩文件中data/names目录下包含18个以语言名命名的txt文件,每一个文件里包含大量的人名字符串,每一行表示一个人名。大多数是常用字符,不过我们仍然需要把这些人名字符串从unicode字符转化为ASCII字符。
我们要把所有这些数据加载到一个字典中,字典的键是语言类别字符串,对应的值是一个包含了该语言人名的列表,类似于{language:[names …]}。同时我们也维护一个类别列表,保存了所有语言类别,类似于[language …]。下面的代码做了一些准备工作:
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 | from __future__ import unicode_literals, print_function, division from io import open import glob def findFiles(path): return glob.glob(path) print(findFiles(\\'data/names/*.txt\\')) import unicodedata import string all_letters = string.ascii_letters " .,;\\'" # 所有使用到的字符 n_letters = len(all_letters) # 字符数量(57个) # Turn a Unicode string to plain ASCII, thanks to http://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 ) print(unicodeToAscii(\\'Ślusàrski\\')) # Build the category_lines dictionary, a list of names per language category_lines = {} all_categories = [] # Read a file and split into lines def readLines(filename): lines = open(filename, encoding=\\'utf-8\\').read().strip().split(\\'\n\\') return [unicodeToAscii(line) for line in lines] for filename in findFiles(\\'data/names/*.txt\\'): category = filename.split(\\'/\\')[-1].split(\\'.\\')[0] all_categories.append(category) lines = readLines(filename) category_lines[category] = lines n_categories = len(all_categories) # 语言类别数量 |
执行上面的代码会输出:
1 2 | [\\'data/names/English.txt\\', \\'data/names/Vietnamese.txt\\', \\'data/names/Russian.txt\\', \\'data/names/Czech.txt\\', \\'data/names/Portuguese.txt\\', \\'data/names/Irish.txt\\', \\'data/names/Korean.txt\\', \\'data/names/Japanese.txt\\', \\'data/names/Spanish.txt\\', \\'data/names/Arabic.txt\\', \\'data/names/Chinese.txt\\', \\'data/names/Italian.txt\\', \\'data/names/Scottish.txt\\', \\'data/names/German.txt\\', \\'data/names/Greek.txt\\', \\'data/names/Dutch.txt\\', \\'data/names/Polish.txt\\', \\'data/names/French.txt\\'] Slusarski |
category_lines字典和all_categories列表保存了我们要训练的数据,后文要用到。用下面的代码简要了解下字典保存的数据样式:
1 | print(category_lines[\\'Italian\\'][:5]) |
输出:
1 | [\\'Abandonato\\', \\'Abatangelo\\', \\'Abatantuono\\', \\'Abate\\', \\'Abategiovanni\\'] |
将人名转化为张量
为了能对人名进行分析,我们需要把以字符串表示的人名转化为以张量形式表示的人名。由于本文介绍的是基于字符级别的循环网络,我们将使用独热向量(one-hot vector)技术来表示人人名中出现的单个字符,而用一个2D的矩阵来表示一个人名字符串。单个字符的独热向量的尺寸是<1 × n_letters>。其中该向量里大部分维度的值均为0,只有对应该字符在所有字符列表(all_letters)中的索引位置的值为1。例如字符”b”=<0 1 0 0 0 …>。
把组成人名的字符串的每一个字符的向量连起来,就是表示人名的2维矩阵。中间多出来的长度为1的维度是因为PyTorch默认所有的张量都是基于块(batch)的,这里我们不使用块,因此将其设为1。
我们使用下面的代码来完成上述工作,并用字符”J”和人名字符串”Jones”来看看效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | import torch # Find letter index from all_letters, e.g. "a" = 0 def letterToIndex(letter): return all_letters.find(letter) # Just for demonstration, turn a letter into a <1 x n_letters> Tensor def letterToTensor(letter): tensor = torch.zeros(1, n_letters) tensor[0][letterToIndex(letter)] = 1 return tensor # Turn a line into a , # 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(letterToTensor(\\'J\\')) print(lineToTensor(\\'Jones\\').size()) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | Columns 0 to 12 0 0 0 0 0 0 0 0 0 0 0 0 0 Columns 13 to 25 0 0 0 0 0 0 0 0 0 0 0 0 0 Columns 26 to 38 0 0 0 0 0 0 0 0 0 1 0 0 0 Columns 39 to 51 0 0 0 0 0 0 0 0 0 0 0 0 0 Columns 52 to 56 0 0 0 0 0 [torch.FloatTensor of size 1x57] torch.Size([5, 1, 57]) |
创建网络模型
基于PyTorch可以自动计算梯度,构建反向传播算法这一优势,我们只需要按照传统的前向运算的步骤来创建网络。这里我们使用的RNN网络仅包括2个分别针对输入数据和隐藏状态的线性变换层、以及一个对输出层的LogSoftmax变换。网络结构如下:
具体的代码如下:
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 | import torch.nn as nn from torch.autograd import Variable 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(input_size hidden_size, hidden_size) self.i2o = nn.Linear(input_size hidden_size, output_size) self.softmax = nn.LogSoftmax() def forward(self, input, hidden): combined = torch.cat((input, hidden), 1) hidden = self.i2h(combined) output = self.i2o(combined) output = self.softmax(output) return output, hidden def initHidden(self): return Variable(torch.zeros(1, self.hidden_size)) n_hidden = 128 rnn = RNN(n_letters, n_hidden, n_categories) |
实际运行一个循环网络,我们需要送给网络输入数据,同时网络自身隐藏层的输出也作为下一时刻的输入的一个组成部分。同时在每一个时间步,我们针对网络隐藏层的输出做一个Logsoftmax变换,得到整个网络的输出。可以认为,每一个时刻网络的输入由人名字符串中的某一个字符所对应的张量与网络隐藏层的输出联合组成。整个网络除了确定数据之间的变换类型外,最重要的参数就是输入数据、隐藏层输出以及网络整体输出的尺寸了。本例中,输入数据的尺寸对应于单个字符独热向量的大小,也就是所有字符数量;隐藏层尺寸可以根据需要适当调整,这里我们使用128;输出层尺寸即是所有语言的数量。
由于PyTorch网络模型接受的数据都必须是Variable对象,因此在把数据送入网络前都要把张量变为对应的Variable,下面的代码展示了把字符\\’A\\’送入网络运行得到的输出。
1 2 3 4 | input = Variable(letterToTensor(\\'A\\')) hidden = Variable(torch.zeros(1, n_hidden)) output, next_hidden = rnn(input, hidden) |
为了提高效率,我们以人名字符串而不是单个字符为单位来创建张量和对应的变量,在实际送入网络运行时,我们会从变量中按照索引来送入对应的字符变量。同样模型在运行前需要对隐藏层进行初始化。下面的代码展示了我们如何创建一个基于人名字符串的变量、隐藏层的初始化以及将一个字符串送入模型计算得到的输出结果:
1 2 3 4 5 | input = Variable(lineToTensor(\\'Albert\\')) hidden = Variable(torch.zeros(1, n_hidden)) output, next_hidden = rnn(input[0], hidden) print(output) |
输出:
1 2 3 4 5 6 7 8 | Variable containing: Columns 0 to 9 -2.9427 -2.8834 -2.8105 -2.9505 -2.9144 -3.0235 -2.8622 -2.9749 -2.9168 -2.8478 Columns 10 to 17 -2.8252 -2.9027 -2.8036 -2.8963 -2.8294 -2.9595 -2.8678 -2.8471 [torch.FloatTensor of size 1x18] |
你可以看到,输出是一个<1 × n_categories>的张量,其中的每一个数据代表着该字符串对应语言的几率,数值越大,人名字符串属于该语言的可能性越高。
训练网络
准备训练
在训练网络前,我们还需要做一些准备工作,我们需要写一个方法来直观地查看网络的输出:
1 2 3 4 5 6 | def categoryFromOutput(output): top_n, top_i = output.data.topk(1) # Tensor out of Variable with .data category_i = top_i[0][0] return all_categories[category_i], category_i print(categoryFromOutput(output)) |
代码输出:
1 | (\\'Scottish\\', 12) |
我们也需要一个办法来快速的获取训练数据(输入数据和标签数据):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | import random def randomChoice(l): return l[random.randint(0, len(l) - 1)] def randomTrainingExample(): category = randomChoice(all_categories) line = randomChoice(category_lines[category]) category_tensor = Variable(torch.LongTensor([all_categories.index(category)])) line_tensor = Variable(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) |
上段代码输出:
1 2 3 4 5 6 7 8 9 10 | category = Polish / line = Slazak category = Korean / line = Suk category = Greek / line = Kokoris category = Greek / line = Giannakopoulos category = Czech / line = Camfrlova category = German / line = Kiefer category = Korean / line = Choi category = Greek / line = Glynatsis category = German / line = Foerstner category = Greek / line = Vassilopulos |
训练过程
有了之前的准备工作,训练一个网络就变得很简单了,我们只需要声明一下我们使用的损失计算方法和参数更新方法,就可以把数据送入网络训练了:
1 2 3 | learning_rate = 0.005 optimizer = torch.optim.SGD(rnn.parameters(), lr=learning_rate) criterion = nn.NLLLoss() |
在每一个训练循环里:
1) 生成一个输入数据张量和输出标签张量
2) 生成一个初始化为0的隐藏层状态
3) 把每一个字符的张量数据、隐藏层状态数据送入网络输入层
4) 计算网络的输出,比较其与标签张量的差别,得到损失。
5) 反向传播更新参数
6) 给出网络的输出和损失
1 2 3 4 5 6 7 8 9 10 11 | def train(category_tensor, line_tensor): hidden = rnn.initHidden() rnn.zero_grad() for i in range(line_tensor.size()[0]): output, hidden = rnn(line_tensor[i], hidden) loss = criterion(output, category_tensor) loss.backward() optimizer.step() return output, loss.data[0] |
我们可以使用上面的方法训练大量的样本数据,同时由于训练方法同时给出了损失值,我们还可以将损失值每隔一定迭代次数就读取出来,最后绘制成损失随迭代次数曲线图,观察网络训练进度:
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 | import time import math n_iters = 100000 print_every = 5000 plot_every = 1000 # Keep track of losses for plotting current_loss = 0 all_losses = [] def timeSince(since): now = time.time() s = now - since m = math.floor(s / 60) s -= m * 60 return \\'%dm %ds\\' % (m, s) start = time.time() for iter in range(1, n_iters 1): category, line, category_tensor, line_tensor = randomTrainingExample() output, loss = train(category_tensor, line_tensor) current_loss = loss # Print iter number, loss, name and guess if iter % print_every == 0: guess, guess_i = categoryFromOutput(output) 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)) # Add current loss avg to list of losses if iter % plot_every == 0: all_losses.append(current_loss / plot_every) current_loss = 0 |
上面的代码给出的输出类似如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | 5000 5% (0m 20s) 2.5814 Lee / Chinese ✗ (Korean) 10000 10% (0m 40s) 2.3409 Kouri / Japanese ✗ (Arabic) 15000 15% (0m 59s) 1.3005 Kerper / German ✓ 20000 20% (1m 18s) 1.8712 Campos / Greek ✗ (Portuguese) 25000 25% (1m 39s) 0.1825 Niijima / Japanese ✓ 30000 30% (1m 59s) 3.5167 Rooiakkers / Greek ✗ (Dutch) 35000 35% (2m 20s) 2.2833 Rizzo / Portuguese ✗ (Italian) 40000 40% (2m 41s) 0.4189 Alamilla / Spanish ✓ 45000 45% (3m 1s) 2.4083 Greco / Scottish ✗ (Italian) 50000 50% (3m 23s) 1.5011 Smith / Scottish ✓ 55000 55% (3m 45s) 2.7379 Roy / Korean ✗ (French) 60000 60% (4m 6s) 0.6007 Lew / Chinese ✓ 65000 65% (4m 29s) 0.1083 Luong / Vietnamese ✓ 70000 70% (4m 51s) 1.9831 Mendel / English ✗ (German) 75000 75% (5m 13s) 1.2821 Geroux / French ✓ 80000 80% (5m 35s) 0.0485 Fotopoulos / Greek ✓ 85000 85% (5m 58s) 0.3745 Seelen / Dutch ✓ 90000 90% (6m 20s) 2.6798 Richard / Scottish ✗ (French) 95000 95% (6m 44s) 0.2958 Kijek / Polish ✓ 100000 100% (7m 7s) 2.2774 Klerks / Polish ✗ (Dutch) |
绘制结果
下面的代码将训练进度绘制来:
1 2 3 4 5 | import matplotlib.pyplot as plt import matplotlib.ticker as ticker plt.figure() plt.plot(all_losses) |
评估网络
我们可以设计一个矩阵来反映模型分辨不同语言的能力,暂称这个矩阵为“混淆矩阵”,该矩阵的数据提示了模型对于每一个实际的语言类型(行)的预测语言类型(列)。我们编写一个evaluate方法来完成这个工作,该方法类似于上面提到的train方法,只不过不需要反向传播更新参数:
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 | # Keep track of correct guesses in a confusion matrix confusion = torch.zeros(n_categories, n_categories) n_confusion = 10000 # Just return an output given a line def evaluate(line_tensor): hidden = rnn.initHidden() for i in range(line_tensor.size()[0]): output, hidden = rnn(line_tensor[i], hidden) return output # Go through a bunch of examples and record which are correctly guessed for i in range(n_confusion): category, line, category_tensor, line_tensor = randomTrainingExample() output = evaluate(line_tensor) guess, guess_i = categoryFromOutput(output) category_i = all_categories.index(category) confusion[category_i][guess_i] = 1 # Normalize by dividing every row by its sum for i in range(n_categories): confusion[i] = confusion[i] / confusion[i].sum() # Set up plot fig = plt.figure() ax = fig.add_subplot(111) cax = ax.matshow(confusion.numpy()) fig.colorbar(cax) # Set up axes ax.set_xticklabels([\\'\\'] all_categories, rotation=90) ax.set_yticklabels([\\'\\'] all_categories) # Force label at every tick ax.xaxis.set_major_locator(ticker.MultipleLocator(1)) ax.yaxis.set_major_locator(ticker.MultipleLocator(1)) # sphinx_gallery_thumbnail_number = 2 plt.show() |
代码输出混淆矩阵图:
上图中,用不同颜色的小方格表示了模型把实际语言类型(行)的人名预测为另一种语言(列)的相对次数,对角线的次数较多,说明正确预测的次数也较多,对于非对角线颜色较亮的区域则是误判较多的例子。比如经常把“汉语”人名解读为“越南语”或“韩语”等。
我们也可以编写代码在终端接受输入一个人名,给出该人名字符串最可能的三种语言类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | def predict(input_line, n_predictions=3): print(\'\n> %s\' % input_line) output = evaluate(Variable(lineToTensor(input_line))) # Get top N categories topv, topi = output.data.topk(n_predictions, 1, True) predictions = [] for i in range(n_predictions): value = topv[0][i] category_index = topi[0][i] print(\'(%.2f) %s\' % (value, all_categories[category_index])) predictions.append([value, all_categories[category_index]]) predict(\'Dovesky\') predict(\'Jackson\') predict(\'Satoshi\') |
输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | > Dovesky (-0.76) Russian (-0.93) Czech (-2.31) English > Jackson (-0.84) Scottish (-1.66) English (-1.72) Russian > Satoshi (-1.43) Italian (-1.80) Arabic (-1.85) Japanese |
以上就是示例的所有内容。上述代码被组织成多个文件,读者可以在这里下载:
- data.py 加载文件
- model.py 定义RNN网络
- train.py 训练模型
- predict.py 通过命令行参数运行predict方法
- server.py 提供服务端预测,提供预测的json接口
你可以运行train.py文件来训练和保存网络模型;联合一个人名参数运行predict.py观察网络预测结果:
1 2 3 4 5 | $ python predict.py Qiang <span class="o">(</span>-0.16<span class="o">)</span> Vietnamese <span class="o">(</span>-2.36<span class="o">)</span> Chinese <span class="o">(</span>-3.20<span class="o">)</span> Korean |
运行server.py后访问:http://localhost:5533/Yourname 来获取json格式表示的预测结果
自我练习
- 尝试不同的数据集映射:一行 –> 类别。例如:输入一个单词训练并判断其属于哪一种语言;根据姓名中的姓训练并判断其性别;根据作品中的角色名称训练并预测该作品的作者;根据网页的标题训练并判断是属于博客还是Reddit网站的某一子版块。
- 你也可以针对RNN网络做一些完善工作,比如在网络里增加更多的线性变换层;尝试使用基于LSTM或GRU的RNN;或者把多个RNNs组合起来形成一个更高级的网络等等。
文章来源:知乎专栏
本站微信群、QQ群(三群号 726282629):
