从零开始自己搭建RNN【Pytorch文档】1
先贴官方教程:https://pytorch.org/tutorials/intermediate/char_rnn_classification_tutorial.html
参考Blog:https://blog.csdn.net/iteapoy/article/details/106478462
字母级RNN的分类任务
这次我们只用到 /name
这个文件夹下的18个文件,每个文件以语言命名,格式为 [Language].txt
打开后,里面是该语言中常用的姓/名。
任务说明
输入一个姓名,根据它的拼写,用循环神经网络对它分类,判断它属于哪个语言里的姓名
理论基础
RNN
循环神经网络(Recurrent Neural Network)里面引入了循环体结构
x
t
x_t
xt是第 t 步循环时的输入,
h
t
h_t
ht是第 t 步循环时的输出,他们都是向量,把它按时序展平变成一般的神经网络那样的单向传播结构。展开后就是一个链状结构:
每一个A
块里的结构都是一样的,现在的问题是:变量到底应该怎么更新?输入的
x
t
x_t
xt应该如何处理,才能输出
h
t
h_t
ht,图里的 A 内部具体的更新结构如下:
流程为:
- 把上一步输出的 h t − 1 h_{t-1} ht−1乘上权重矩阵 W h W_h Wh,变成 W h h t − 1 W_hh_{t-1} Whht−1,h是hidden layer
- 把这一步输入的 x t x_t xt也乘上权重矩阵 W i W_i Wi,变成 W i i t W_ii_{t} Wiit,i是input
- 把两者相加,得到 W h h t − 1 + W i i t W_hh_{t-1}+W_ii_{t} Whht−1+Wiit
- 经过一个 t a n h tanh tanh函数,就得到这一步的输出: h t = t a n h ( W h h t − 1 + W i i t ) h_t=tanh(W_hh_{t-1}+W_ii_t) ht=tanh(Whht−1+Wiit)
反复循环流程1-4,就是一个最简单的RNN
在这个简单RNN中, W h , W i W_h,W_i Wh,Wi并没有与时间步相关的下标,在训练过程中,这两个矩阵中的数字会发生变化,因为它们是模型要学习的“参数”始终只有这两个矩阵。
另外,你可能会看到一种带有偏置向量 b 的更新方式:
h t = t a n h ( ( W h h t − 1 + b h ) + ( W i i t + b i ) ) h_t=tanh((W_hh_{t-1}+b_h)+(W_ii_t+b_i)) ht=tanh((Whht−1+bh)+(Wiit+bi))
这里进行了简化,即令所有的向量 b 都为0。另外,我们在初始化向量 h 0 h_0 h0 的时候,也会把它初始化成全为0的向量
RNN这样一个结构用来处理有前后关联的序列非常有效,因此在自然语言处理里也取得了不错的成绩。因为一句话可以看成是许多词组成的序列,这些词之前有前后文/上下文关系
LSTM
LSTM是长短期记忆网络(LSTM,Long Short-Term Memory),通过三个门(遗忘门、输入门、输出门)的控制,存储短期记忆或长期记忆。它的整体流程是这样:
LSTM里的一个A
内部的结构变成了这样子:
李宏毅老师的简化版容易入门:
对同一个输入
x
t
x_t
xt,乘上不同的权重
W
f
,
W
i
,
W
,
W
o
W_f,W_i,W,W_o
Wf,Wi,W,Wo,得到四个不同的值:
- z f = W f x t z_f=W_fx_t zf=Wfxt,遗忘门(Forget Gate)的输入
- z i = W i x t z_i=W_ix_t zi=Wixt,输入门(Input Gate)的输入
- z = W x t z_=Wx_t z=Wxt,真正的输入
- z o = W o x t z_o=W_ox_t zo=Woxt,输出门(Output Gate)的输入
先来看红色方框圈出来的部分,输入和输入门的更新,它负责判断是否要接受新的输入
这一步操作流程:
- z i z_i zi乘权重 W i W_i Wi,加上偏置向量 b i b_i bi,经过输入门,得到 i t = σ ( W i z i + b i ) i_t=\sigma(W_iz_i+b_i) it=σ(Wizi+bi)
- z z z乘权重 W c W_c Wc,加上偏置向量 b c b_c bc,经过 t a n h tanh tanh,得到 c t = σ ( W c z + b c ) c_t=\sigma(W_cz+b_c) ct=σ(Wcz+bc)
- 把 i t i_t it和 c t c_t ct按元素相乘(Hadamard乘积,运算符 ⊙ \odot ⊙或 ∗ \ast ∗),得到 i t ∗ c t i_t\ast c_t it∗ct,输入cell
再看蓝色圈出来的部分,是遗忘门的更新,它负责判断是否要更新cell中的值,如果更新了,就要忘记之前的值,写入新的值:
- cell中存放了上一步的存储向量 c t − 1 c_{t-1} ct−1
- z f z_f zf乘以权重 W f W_f Wf,加上偏置向量 b f b_f bf,经过遗忘门,得到 f t = σ ( W f z f + b f ) f_t=\sigma(W_fz_f+b_f) ft=σ(Wfzf+bf)
- 在cell中把
c
t
−
1
c_{t-1}
ct−1与
f
t
f_t
ft按元素相乘,然后和
i
t
∗
c
t
i_t\ast c_t
it∗ct相加,就变成了新的存储向量
c t = c t − 1 ∗ f t + i t ∗ c t c_t=c_{t-1}\ast f_t+i_t\ast c_t ct=ct−1∗ft+it∗ct
最后看橙色圈出来的部分,输出门的更新,它负责判断是否要输出最后的值:
- 新的 c t c_t ct通过 t a n h tanh tanh函数,得到 t a n h ( c t ) tanh(c_t) tanh(ct)
- z o z_o zo乘以权重 W o W_o Wo,加上偏置向量 b o b_o bo,经过输出门,变成 o t = σ ( W o z o + b o ) o_t=\sigma(W_oz_o+b_o) ot=σ(Wozo+bo)
- 将 t a n h ( c t ) tanh(c_t) tanh(ct)与 o t o_t ot按元素相乘,得到新的隐藏层状态 h t = o t ∗ t a n h ( c t ) h_t=o_t\ast tanh(c_t) ht=ot∗tanh(ct)
- 隐藏层状态 h t h_t ht通过softmax函数,得到最后的输出 y t = s o f t m a x ( h t ) y_t=softmax(h_t) yt=softmax(ht)
以上,就是LSTM中某一步的状态更新情况。再回头看这张图:
之前,
z
f
,
z
i
,
z
,
z
o
z_f,z_i,z,z_o
zf,zi,z,zo都是用
x
t
x_t
xt乘以不同的权重得到的,但是光凭
x
t
x_t
xt 还不能传递足够多的信息,于是把
x
t
x_t
xt和上一步输出的隐藏层状态
h
t
−
1
h_{t-1}
ht−1拼在一起,变成一个新向量
[
x
t
,
h
t
−
1
]
[x_t,h_{t-1}]
[xt,ht−1],更新公式为:
一般称循环神经网络的时候,其实都是在说LSTM
GRU
因为LSTM太复杂,而且容易过拟合,有一个简化版的LSTM,叫做GRU,它把遗忘门和输入门合并成了一个更新门,从三个门减少到了两个门,更新公式如下(省略偏置向量b):
one-hot 编码
需要将输入 x t x_t xt转化为一个特征向量, x t x_t xt可以是字母的特征向量,或者单词的特征向量,或者句子的特征向量
one-hot 编码是一个长度为 n 的向量,只有 1 个数字是1,其它的 n − 1 个数字都是0。one-hot 编码使得每个字母在它们各自的维度上,与其它字母是独立的
比如一个单词 “apple”,就分别对a、p、p、l、e 编码,作为输入,LSTM需要循环5次
而单词级(word-level)RNN就是把整个单词 “apple” 编码成一个向量。通常,对单词的编码用于Seq2Seq模型,即处理的是一个序列 “An apple a day keeps the doctor away”
代码实现
数据预处理
首先把所有的/name/[language].txt
文件读取出来
n_letters
表示所有字母的数量
unicodeToAscii()
函数将输入转换为英文字母
from __future__ import unicode_literals, print_function, division
from io import open
import glob
import os
import unicodedata
import string
def findFiles(path): return glob.glob(path) #glob(path)查询目录下文件
def unicodeToAscii(str): #记下即可
return ''.join(
c for c in unicodedata.normalize('NFD', str)
if unicodedata.category(c) != 'Mn'
and c in all_letters
)
print(findFiles('MLdata/data/names/*.txt')) #查找以.txt结尾的文件
all_letters = string.ascii_letters + '.,;' #ascii_letters生成所有字母
n_letters = len(all_letters)
print(unicodeToAscii('Ślusàrski'))
out
[‘MLdata/data/names\Arabic.txt’, ‘MLdata/data/names\Chinese.txt’, ‘MLdata/data/names\Czech.txt’, ‘MLdata/data/names\Dutch.txt’, ‘MLdata/data/names\English.txt’, ‘MLdata/data/names\French.txt’, ‘MLdata/data/names\German.txt’, ‘MLdata/data/names\Greek.txt’, ‘MLdata/data/names\Irish.txt’, ‘MLdata/data/names\Italian.txt’, ‘MLdata/data/names\Japanese.txt’, ‘MLdata/data/names\Korean.txt’, ‘MLdata/data/names\Polish.txt’, ‘MLdata/data/names\Portuguese.txt’, ‘MLdata/data/names\Russian.txt’, ‘MLdata/data/names\Scottish.txt’, ‘MLdata/data/names\Spanish.txt’, ‘MLdata/data/names\Vietnamese.txt’]
Slusarski
文件命名[language].txt
中,language
是类别category,把每个文件打开,存入一个数组lines = [name1,...]
,建立一个词典category_lines = {language: lines}
#文件命名`[language].txt`中,`language`是类别category,把每个文件打开,
#存入一个数组`lines = [name1,...]`,建立一个词典`category_lines = {language: lines}`
category_lines = {}
all_categories = []
def readLines(filename):
lines = open(filename, encoding = 'utf-8').read().strip().split('\n') #先read读取,strip去除首尾空格,split以'\n'分隔
return [unicodeToAscii(line) for line in lines] #将lines中的name读出放入字母标准化函数并输出标准的name
for filename in findFiles('MLdata/data/names/*.txt'):
category = os.path.splitext(os.path.basename(filename))[0] #os.path.basename(path)返回path最后的文件名,os.path.splitext() 将文件名和扩展名分开
all_categories.append(category)
lines = readLines(filename) #readLines一次性读取整个文件;自动将文件内容分析成一个行的列表
category_lines[category] = lines #把language与名字组合放在词典里
n_categories = len(all_categories)
print(all_categories)
print(category_lines['Italian'])
out
[‘Arabic’, ‘Chinese’, ‘Czech’, ‘Dutch’, ‘English’, ‘French’, ‘German’, ‘Greek’, ‘Irish’, ‘Italian’, ‘Japanese’, ‘Korean’, ‘Polish’, ‘Portuguese’, ‘Russian’, ‘Scottish’, ‘Spanish’, ‘Vietnamese’]
[‘Abandonato’, ‘Abatangelo’, ‘Abatantuono’, ‘Abate’, ‘Abategiovanni’, ‘Abatescianni’, ‘Abba’, ‘Abbadelli’, ‘Abbascia’, ‘Abbatangelo’, ‘Abbatantuono’, ‘Abbate’, ‘Abbatelli’, ‘Abbaticchio’, ‘Abbiati’, ‘Abbracciabene’, ‘Abbracciabeni’, ‘Abelli’, ‘Abello’, ‘Abrami’, ‘Abramo’, ‘Acardi’, ‘Accardi’, ‘Accardo’, ‘Acciai’, ‘Acciaio’, ‘Acciaioli’, ‘Acconci’, ‘Acconcio’, ‘Accorsi’, ‘Accorso’, ‘Accosi’, ‘Accursio’, ‘Acerbi’, …
接下来,就是要对字母进行one-hot编码,转成 tensor
假设字母表中的字母数量为n_letters
,一个字母的向量就是<1 x n_letters>
维,只有其中1维是1,其余都是0
一个长度为line_length
的单词,它的向量维度为<line_length x n_letters>
维
按照batch来训练,设定一个单词为一组batch的话,单词的向量维度就是<line_length x 1 x n_letters>
import torch
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
#返回字母letter的索引 index
def letterToIndex(letter):
return all_letters.find(letter)
#把一个字母编码成tensor
def letterToTensor(letter):
tensor = torch.zeros(1, n_letters) #创建 1 x n_letters 的张量
#把字母letter 的索引定为1,其他为0
tensor[0][letterToIndex(letter)] = 1
return tensor.to(device)
#把一个单词编码为tensor
def lineToTensor(line):
tensor = torch.zeros(len(line), 1, n_letters)
#遍历单词中所有字母,对每个字母letter的索引设为1,其他为0
for li, letter in enumerate(line):
tensor[li][0][letterToIndex(letter)] = 1
return tensor.to(device)
print(letterToTensor('J'))
print(lineToTensor('Jones').size())
out
tensor([[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.]], device=‘cuda:0’)
torch.Size([5, 1, 55])
搭建模型
一个简单的RNN,设计为两层的结构,i2h
(
i
n
p
u
t
t
o
h
i
d
d
e
n
input to hidden
input to hidden)为输入
x
t
x_t
xt到隐藏层
h
t
h_t
ht,i2o
为输入
x
t
x_t
xt到输出
o
t
o_t
ot,softmax
把输出
o
t
o_t
ot变成预测值
y
t
y_t
yt,在此用的是LogSoftmax
函数,对应的损失函数为NLLLoss()
,若是一般的Softmax
函数,对应的损失函数就是交叉熵损失CrossEntropy()=Log(NLLLoss())
在此设定隐藏层的向量维度为128维
模型真正运行步骤在forward()
函数中,它的输入为
x
t
x_t
xt,隐藏层为
h
t
h_t
ht:
combined = torch.cat((input, hidden), 1)
:把 x t x_t xt和上一步的 h t − 1 h_{t-1} ht−1拼接在一起变成[ x t x_t xt, h t − 1 h_{t-1} ht−1]hidden = self.i2h(combined)
:之前基础理论里,把输入[ x t x_t xt, h t − 1 h_{t-1} ht−1]乘上权重 W h W_h Wh变成新的隐藏层 h t = W h [ x t , h t − 1 ] h_t=W_h[x_t,h_{t-1}] ht=Wh[xt,ht−1],在此实际上是通过一个线性的全连接层i2h
,它的输入大小为input_size + hidden_size
,输出是hidden_size
output = self.i2o(combined)
:把输入的[ x t x_t xt, h t − 1 h_{t-1} ht−1]乘上权重 W o W_o Wo,得到输出 o t = W o [ x t , h t − 1 ] o_t=W_o[x_t,h_{t-1}] ot=Wo[xt,ht−1],通过一个线性的全连接层i2o
,它的输入是input_size + hidden_size
,输出是output_size
output = self.softmax(output)
:通过softmax函数,把 o t o_t ot转换为预测值 y t y_t yt
import torch.nn as nn
class RNN(nn.Module):
#初始化定义每一层的输入大小和输出大小
def __init__(self, input_size, hidden_size, output_size):
super(RNN, self).__init__() #继承父类的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(dim = 1)
#前向传播过程
def forward(self, input, hidden):
combined = torch.cat((input, hidden), 1) #按维数1拼接(横着拼)
hidden = self.i2h(combined)
output = self.i2o(combined)
output = self.softmax(output)
return output, hidden
#初始化隐藏层状态 h0
def initHidden(self):
return torch.zeros(1, self.hidden_size).to(device)
n_hidden = 128
rnn = RNN(n_letters, n_hidden, n_categories)
rnn = rnn.to(device)
#输入字母A测试
input = letterToTensor('A')
hidden = torch.zeros(1, n_hidden).to(device)
output, next_hidden = rnn(input, hidden)
print(output)
#输入名字Albert的第一个字母A测试
input = lineToTensor('Albert')
hidden = torch.zeros(1, n_hidden).to(device)
output, next_hidden = rnn(input[0], hidden)
print(output)
定义一个函数categoryFromOutput()
可以把
y
t
y_t
yt转换为对应的类别,用Tensor.topk
选出概率最大的那个概率的下标category_i
,就是
y
t
y_t
yt的类别
def categoryFromOutput(output):
top_n, top_i = output.topk(1)
category_i = top_i[0].item()
return all_categories[category_i], category_i
print(categoryFromOutput(output))
训练
接下来训练模型,随机采样一部分数据进行训练
用randomChoice()
从全部数据中随机采样,先采样得到类别category
,再从类别中随机采样,得到姓名line
randomTrainingExample()
将采样得到的category-line
对变成tensor
#训练
import random
def randomChoice(l):
return l[random.randint(0, len(l)-1)]
def randomTrainingExample():
category = randomChoice(all_categories) #采样得到category
line = randomChoice(category_lines[category]) #从category中采样得到line
category_tensor = torch.tensor([all_categories.index(category)], dtype = torch.long).to(device)
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)
定义损失函数为NLLLoss()
,学习率0.005
在训练的每个循环中会执行以下过程:
- 创建输入tensor和目标tensor
- 初始化隐藏层状态 h 0 h_0 h0
- 输入每个字母 x t x_t xt
- 保存下一个字母需要的隐藏层状态 h t h_t ht
- 将模型预测的输出 y t y_t yt与目标 y t ∗ y_t^* yt∗进行对比
- 梯度反向传播
- 返回输出和损失函数
#定义损失函数 NLLLoss
criterion = nn.NLLLoss()
learning_rate = 0.005
def train(category_tensor, line_tensor):
hidden = rnn.initHidden()
rnn.zero_grad()
#RNN的循环
for i in range(line_tensor.size()[0]):
output, hidden = rnn(line_tensor[i], hidden)
loss = criterion(output, category_tensor)
loss.backward()
#更新参数
for p in rnn.parameter():
p.data.add_(p.grad.data, alpha =- learning_rate)
return output, loss.item()
下面正式开始训练模型
timeSince()
计算出训练时间, 总共训练n_iters
次,每次用1个样本进行训练,每print_every
次打印当前的训练损失,每plot_every
次把损失保存到all_losses
数值中,方便画图
import time
import math
n_iters = 100000
print_every = 5000
plot_every = 1000
current_loss = 0
all_loss = []
def timeSince(since):
now = time.time()
s = now - since
return '%dm %ds' % (s//60, s%60)
start = time.time()
for iter in range(1, n_iters + 1):
category, line, category_tensor, line_tesor = randomTrainingExample()
output, loss = train(category_tensor, line_tensor)
current_loss += loss
if iter % print_every == 0:
guess, guess_i = categoryFromOutput(output)
current = '√' if guess == category else 'x(%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.append(current_loss/plot_every)
current_loss = 0
画图
#画图
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
plt.figure()
plt.plot(all_losses)
为了看模型在各个分类上的预测情况,画出18国语言的混淆矩阵,每一行都是真实语言,每一列都是预测语言,用函数evaluate()
来计算混淆矩阵,evaluate()
和train()
非常相似,但是不需要反向传播
#绘制混淆矩阵
confusion = torch.zeros(n_categories, n_categories)
n_confusion = 10000
def evaluate(line_tensor):
hidden = rnn.initHidden()
for i in range(line_tensor.size()[0]):
output, hidden = rnn(line_tensor[i], hidden)
return output
for i in range(n_confusion):
category, line, category_tensor, line_tensor = randomTrainExample()
output = evaluate(line_tensor)
guess, guess_i = categoryFromOutput(output)
category_i = all_categories.index(category)
confusion[category_i][guess_i] += 1
for i in range(n_categories):
confusion[i] = confusion[i] / confusion[i].sum()
fig = plt.figure()
ax = fig.add_subplot(111)
cax = ax.matshow(confusion.numpy())
fig.colorbar(cax)
ax.set_xticklabels([''] + all_categories, rotation = 90)
ax.set_yticklabels([''] + all_categories)
ax.xaxis.set_major_locator(ticker.MultipleLocator(1))
ax.yaxis.set_major_locator(ticker.MultipleLocator(1))
plt.show
两种语言连线处的正方形颜色越偏暖色,表示两种语言的姓名越相似
预测
对于每个名字input_line
,每次预测n_predictions = 3
种最有可能的类别,并输出他们对应的概率
#预测
def predict(input_line, n_predictions = 3):
print('\n > %s' % input_line)
with torch.no_grad():
output = evaluate(lineToTensor(input_line))
topv, topi = output.topk(n_predictions, 1, True)
predictions = []
for i in range(n_predictions):
value = topv[0][i].item()
category_index = topi[0][i].item()
print('(%.2f) %s' % (value, all_catrgories[category_index]))
predictions.append([value, all_categories[category_index]])
print('Dovesky')
print('Jackson')
print('Satoshi')