文章目录
1.预备知识:深度神经网络(DNN)
DNN输入固定数量(如下图中为3个)的数据,前向传播输出output,与真实值求loss后反向传播更新参数,进行模型训练。如下图所示,我们每次输入固定数量的X1、X2、X3数据,训练得到正确的output输出。
2.RNN出现的意义与基本结构
假定我们正在做一个温度预测的时间序列任务,模型根据n-1天的历史温度数据,预测第n天的温度。
任务如下:
- 第一天温度为:26,此时通过该数据预测第二天温度
- 第二天温度为:27,此时用第一天和第二天的温度数据预测第三天温度,即通过数据序列[26,27]预测第三天温度
- 第三天温度为:29,参考上一条,此时通过数据序列[26,27,29]预测第四天温度。
- 第四天温度为:30,通过数据序列[26,27,29,30]预测第五天温度。
- 第五天温度为:32,通过数据序列[26,27,29,30,32]预测第六天温度。
…
我们只取前5天的数据作为训练样本进行解析,即我们拥有的训练数据为[26,27,29,30,32]。
传统的深度神经网络(DNN),输入尺寸大小固定。我们统一使用2天的温度数据预测第3天的温度,得到的训练数据inputs和labels如下(暂时不考虑测试集)。
input | label |
---|---|
[26,27] | 29 |
[27,29] | 30 |
[29,30] | 32 |
按照传统的DNN,我们已经完成训练数据的制作和神经网络大致模型的构建。但是这里要提出一个疑问,某一天的温度是否和两天前的环境温度不存在联系。
我们都知道温度是渐变的,每一天的温度都与该天温度的前m天温度息息相关(m为非固定值,需要我们去学习,所以更加不可以使用DNN这种输入数据量固定的模型)。
按照直观感受:
- 相较于使用数据[27,29]去拟合数据30,用前三天数据[26,27,29]去拟合第四天的温度(数据30)效果会更好。
- 相较于使用数据[29,30]去拟合数据32,用前四天数据[26,27,29,30]去拟合第五天的温度(数据32)效果会更好。
此时输入数据量不再固定,而是递增的,DNN不适应这种情况。我们提出一种新的模型,模型每次都只输入一个数据,数据量为n时输入n次,输入过程中进行信息积累,这样我们就解决了输入量不固定且数据间有序列关系的问题。
rnn网络结构:
一般单层神经网络架构:
rnn单层神经网络结构:
时间步展开的rnn网络结构:
3.根据输入和输出数量的网络结构分类
3.1 N vs N(输入和输出序列等长)
如下图所示,这个结构是rnn最基础的,每输入一个数据,输出一个对应的output。因为输入序列与输出序列等长,所以用途较为狭小,可用作生成等长度的诗句。
以“落木千山天远大,澄江一道月分明”诗句为例,进行解析。
首先进行分词,这里我们可以使用结巴分词。
import jieba
verse1=jieba.lcut("落木千山天远大", cut_all=False)
verse2=jieba.lcut("澄江一道月分明", cut_all=False)
分割后的内容为:
verse1=['落木', '千山', '天', '远大']
verse2=['澄江', '一道', '月', '分明']
编码后:
{'落木': 0, '千山': 1, '天': 2, '远大': 3}
{'澄江': 0, '一道': 1, '月': 2, '分明': 3}
我们使用pytorch的Embedding模块对verse1进行编码(verse2作为label,无需编码):
nn.Embedding(voc_size, embedding_size, sparse=True)
Embedding后生成的verse1的词向量为(pytorch的Embedding词向量是随机生成的,如果想要产生更有内联性的词向量,需要自己训练参数,再将其导入):
'落木':[-0.0482, -0.9568, 0.7512]
'一道':[ 0.0399, 0.1087, -2.0304]
'月':[0.1200, 0.2021, 1.7677]
'分明':[-0.4733, -0.7433, 2.7065]
此时我们已经得到输入数据和labels:
input | labels |
---|---|
[-0.0482, -0.9568, 0.7512] | 0(‘落木’对应’澄江’,'澄江’编码为0) |
[ 0.0399, 0.1087, -2.0304] | 1(‘千山’对应’一道’,'一道’编码为0) |
[ 0.0399, 0.1087, -2.0304] | 2(’ 天 ‘对应’月’ , '月’编码为0) |
[-0.4733, -0.7433, 2.7065] | 3(‘远大’对应’分明’,'分明’编码为0) |
构建神经网络,此处关键点在于每次输入hidden后,我们输出新的一个hidden用于下一次的输入:
class RNN(nn.Module):
#模型初始化
def __init__(self,hidden_size,embedding_size,output_size):
super(RNN,self).__init__()
self.hidden = nn.Linear(hidden_size+embedding_size, hidden_size)
self.out=nn.Linear(hidden_size+embedding_size, output_size)
self.softmax = nn.Softmax(dim=-1)
#前向传播层
def forward(self,inputs,hidden):
middle=torch.cat((inputs,hidden),-1)#拼接输入诗词和hidden
hidden=self.hidden(middle)
output=self.softmax(self.out(middle))
return output,hidden
进行模型训练并检测结果:
#设置300个epoch训练模型
for epoch in range(300):
for j in range(voc_size):
output,hidden=rnn(inputs[j],hidden)
optimizer.zero_grad()
loss = criterion(output.unsqueeze(0), labels[j].unsqueeze(0))
loss.backward(retain_graph=True)
optimizer.step()
#效果检测
result=[]
for i in range(voc_size):
output,hidden=rnn(inputs[i],hidden)
_,idx=output.max(0)
result.append(verse2[idx])
print(result)
得出结果如下,可以看出模型预测结果正确(这里训练集和测试集统一,只是作为一个测试例子):
['澄江', '一道', '月', '分明']
完整代码如下:
import torch.nn as nn
import torch
import jieba
import torch.optim as optim#优化器
from torch.autograd import Variable
class RNN(nn.Module):
#模型初始化
def __init__(self,hidden_size,embedding_size,output_size):
super(RNN,self).__init__()
self.hidden = nn.Linear(hidden_size+embedding_size, hidden_size)
self.out=nn.Linear(hidden_size+embedding_size, output_size)
self.softmax = nn.Softmax(dim=-1)
#前向传播层
def forward(self,inputs,hidden):
middle=torch.cat((inputs,hidden),-1)#拼接输入诗词和hidden
hidden=self.hidden(middle)
output=self.softmax(self.out(middle))
return output,hidden
#产生数据单元
def make_data(voc_size,embedding_size):
embedding = nn.Embedding(voc_size, embedding_size, sparse=True)
inputs=[]
labels=[]
for i in range(voc_size):
inputs.append(embedding(torch.tensor(i)))
labels.append(i)
return inputs,Variable(torch.LongTensor(labels))
if __name__=="__main__":
verse1=jieba.lcut("落木千山天远大", cut_all=False)
verse2=jieba.lcut("澄江一道月分明", cut_all=False)
voc_size=len(verse1)#诗句长度
embedding_size=2#embedding后的词向量长度
inputs,labels=make_data(voc_size,embedding_size)#产生数据
hidden_size=3#模型中haddensize的长度
hidden=torch.rand(hidden_size)
output_size=len(verse2)
criterion = nn.CrossEntropyLoss()#使用交叉熵
rnn=RNN(hidden_size,embedding_size,output_size)
optimizer = optim.Adam(rnn.parameters(),lr=0.001)#使用Adam优化算法更新参数
#设置300个epoch训练模型
for epoch in range(300):
for j in range(voc_size):
output,hidden=rnn(inputs[j],hidden)
optimizer.zero_grad()
loss = criterion(output.unsqueeze(0), labels[j].unsqueeze(0))
loss.backward(retain_graph=True)
optimizer.step()
#效果检测
result=[]
for i in range(voc_size):
output,hidden=rnn(inputs[i],hidden)
_,idx=output.max(0)
result.append(verse2[idx])
print(result)
3.2 N vs 1(多输入单输出)
结构如下图所示,此时输入序列与输出序列不等长,输出为一。相比于NvsN,我们将网络改造成只在最后一层进行输出即可。此模型常用于分类器。
以人名分类器为例,我们输入名字中的一个个字母,输入完成后对结果进行输出预测,并反向传播训练参数。
数据如下:
{'Abrahams':'英国','Maksimov':'尔罗斯','Sam':'中国','Rushbrooke':'英国','Shen':'中国'}
生成国家字典,以备生成labels。
代码如下:
country_dict={x:i for i,x in enumerate(set(country))}
生成的字典如下:
{'中国': 0, '尔罗斯': 1, '英国': 2}
此时可将26个字母(大写可转为小写)embedding成长度为3的词向量,而人名中包含的字母序列刚好作为N输入,国家名字典映射的数字作为label。
代码如下:
name=[]
country=[]
for dic in name_and_country:
name.append(dic)
country.append(name_and_country[dic])
country_dict={x:i for i,x in enumerate(set(country))}
embedding = nn.Embedding(26, 3, sparse=True)#26表示英文字母,3表示embedding后的词向量尺寸
inputs=[]
labels=[]
for name_str in name:
name_word=[]
for word in name_str:
name_word.append(embedding(torch.tensor(ord(word.lower())-ord('a'))))
inputs.append(name_word)
labels.append(country_dict[name_and_country[name_str]])
生成的部分inputs如图所示:
label如下:
此时数据集已经制作完成,相对于NvsN,Nvs1的变化是不是每一步输入都进行输出,而是最后一个输出作为关键点进行分类和模型反向传播训练。我们的模型并没有变更,只是改变了其训练方法,故训练和检测代码如下:
#设置30个epoch训练模型
for epoch in range(30):
for i,name_voc in enumerate(inputs):#name_voc:某一人名的全部词向量
for word_vector in name_voc:#word_vector:某一人名的某一词向量
output,hidden=rnn(word_vector,hidden)
optimizer.zero_grad()
loss = criterion(output.unsqueeze(0), labels[i].unsqueeze(0))
loss.backward(retain_graph=True)
optimizer.step()
#效果检测
result=[]
for i,name_voc in enumerate(inputs):
for word_vector in name_voc:
output,hidden=rnn(word_vector,hidden)
_,idx=output.max(0)
result.append(list(set(country))[idx])
print(result)
输出如下,可以看出与原国家序列[‘英国’, ‘尔罗斯’, ‘中国’, ‘英国’, ‘中国’]相比,只有第四个的’英国’和’尔罗斯’不同。尽管这里训练集和测试集相同,仍然可部分证明我们模型的可行性。
['英国', '尔罗斯', '中国', '尔罗斯', '中国']
完整代码:
import torch.nn as nn
import torch
import jieba
import torch.optim as optim#优化器
from torch.autograd import Variable
class RNN(nn.Module):
#模型初始化
def __init__(self,hidden_size,embedding_size,output_size):
super(RNN,self).__init__()
self.hidden = nn.Linear(hidden_size+embedding_size, hidden_size)
self.out=nn.Linear(hidden_size+embedding_size, output_size)
self.softmax = nn.Softmax(dim=-1)
#前向传播层
def forward(self,inputs,hidden):
middle=torch.cat((inputs,hidden),-1)#拼接输入诗词和hidden
hidden=self.hidden(middle)
output=self.softmax(self.out(middle))
return output,hidden
#产生数据单元
def make_data(name_and_country,voc_size,embedding_size):
name=[]
country=[]
for dic in name_and_country:
name.append(dic)
country.append(name_and_country[dic])
country_dict={x:i for i,x in enumerate(set(country))}
embedding = nn.Embedding(voc_size, embedding_size, sparse=True)#voc_size:26,表示26个字母。embedding_size:缩放后的词向量,此处为3
inputs=[]
labels=[]
for name_str in name:
name_word=[]
for word in name_str:
name_word.append(embedding(torch.tensor(ord(word.lower())-ord('a'))))
inputs.append(name_word)
labels.append(country_dict[name_and_country[name_str]])
return inputs,Variable(torch.LongTensor(labels))
if __name__=="__main__":
name_and_country={'Abrahams':'英国','Maksimov':'尔罗斯','Sam':'中国','Rushbrooke':'英国','Shen':'中国'}
voc_size=26#字母个数
embedding_size=3#embedding后的词向量长度
inputs,labels=make_data(name_and_country,voc_size,embedding_size)#产生数据
hidden_size=3#模型中haddensize的长度
hidden=torch.rand(hidden_size)
country_dict={x:i for i,x in enumerate(set(country))}#国家列表
output_size=len(set(country_dict))#国家数量
criterion = nn.CrossEntropyLoss()#使用交叉熵
rnn=RNN(hidden_size,embedding_size,output_size)
optimizer = optim.Adam(rnn.parameters(),lr=0.001)#使用Adam优化算法更新参数
#设置30个epoch训练模型
for epoch in range(30):
for i,name_voc in enumerate(inputs):#name_voc:某一人名的全部词向量
for word_vector in name_voc:#word_vector:某一人名的某一词向量
output,hidden=rnn(word_vector,hidden)
optimizer.zero_grad()
loss = criterion(output.unsqueeze(0), labels[i].unsqueeze(0))
loss.backward(retain_graph=True)
optimizer.step()
#效果检测
result=[]
for i,name_voc in enumerate(inputs):
for word_vector in name_voc:
output,hidden=rnn(word_vector,hidden)
_,idx=output.max(0)
result.append(list(set(country))[idx])
print(result)
3.3 1 vs N(单输入多输出)
与NvsN的区别在于,其输入统一且需要一个开始信号量(BOS)、一个结束信号量(EOS)。模型常用于由图像生成相应的场景文字,即"看图说话"。机理介于1vsN和NvsM之间,这里的图片需要attention机制才能表现出更好的效果,不再进行代码演示。
3.4 N vs M(多输入多输出)
网络结构如下图所示,这是一种不限制输入输出长度的RNN结构,首先进行编码生成信息压缩量C,再由C解码生成相应的输出,也被称为seq2seq架构。因为不限制输入输出长度的优点,此模型常用于文本生成和机器翻译。
上述模型架构有一个缺点,编码后数据压缩量C对于所要进行生成的文本贡献量统一,不能体现原始文本和生成文本两者并行生成的特点。
我们以机器翻译的某个句子为例,原文本为"我爱中国",翻译成的文本为"I love china"。“我爱中国"分割四个输入"我”、“爱”、“中”、“国”,对应图片中的x1、x2、x3、x4;“I love china"分割成三个输出"I”、“love”、“china”,对应上图y1、y2、y3。根据翻译特点,此时"我"对应"I",“爱"对应"love”,“中"和"国"一起对应"china”,但这个重要的特征在网络结构中没有体现。网络编码生成C,由C统一解码生成翻译语句,“我”、“爱”、“中”、"国"对于"I"的贡献值相同,“我”、“爱”、“中”、"国"对于"love"的贡献值相同,“我”、“爱”、“中”、"国"对于"china"的贡献值相同,模型是不符合常规认知的,实际效果也比较差。
网络结构不合理,我们需要进行改造,使原始文本和翻译文本生成顺序关联。我们观察编码器hidden的h1、h2、h3、h4,发现可以利用该序列与翻译文本相嵌。使用h1、h2、h3、h4生成c的过程如下:
a1j的计算:
a2j的计算:
a3j的计算:
h和h’的合成可使用余弦相似度计算、向量拼接后使用全连接层拟合等方法,加入h进行运算的巧妙过程也被称为Attention机制。
我们进行演示代码编写。
首先我们进行编码:
source_word=["我","爱","中","国"]
target_word=["I","love","china"]
target_word_dict={x:i for i,x in enumerate(target_word)}
target_word_dict['EOS']=len(target_word_dict)#加入结束标志符
编码后的目标语句词典:
{'I': 0, 'love': 1, 'china': 2, 'EOS': 3}
接下来我们生成数据集:
#产生数据
def make_data(source_word,target_word,voc_size,embedding_size):
embedding = nn.Embedding(voc_size, embedding_size, sparse=True)
inputs=[]
labels=[]
for i in range(len(source_word)):#我们的输入只有一个句子,可以按照句子单词序列进行编码。这里只是作为演示,后续大规模翻译需要修改
inputs.append(embedding(torch.tensor(i)))
for i in range(len(target_word)):#我们的输出也只有一个句子,可以按照句子序列进行编码
labels.append(target_word_dict[target_word[i]])
labels.append(target_word_dict["EOS"])
return inputs,Variable(torch.LongTensor(labels))
数据集样式如下:
inputs | labels |
---|---|
[[-0.9799, 2.0680],[ 0.7484, -0.6000],[1.5033, 1.0036], [-1.3108, 1.1188]] | 0(对应"I") |
[[-0.9799, 2.0680],[ 0.7484, -0.6000],[1.5033, 1.0036], [-1.3108, 1.1188]] | 1(对应"love") |
[[-0.9799, 2.0680],[ 0.7484, -0.6000],[1.5033, 1.0036], [-1.3108, 1.1188]] | 2(对应"China") |
[[-0.9799, 2.0680],[ 0.7484, -0.6000],[1.5033, 1.0036], [-1.3108, 1.1188]] | 3(对应"EOS") |
我们输入inputs产生c,再由c产生output(此处的c有编码器的hidden和解码器的hidden求余弦相似度求得)。
编码器用于生成并收集h1、h2、h3、h4,用于后续c的计算:
#编码器,用于生成编码器的hidden列表
def encoder(self,inputs):
self.hidden_list=[]#用于h1*a1+h2*a2+h3*a3+h4*a4的计算
for i in range(len(inputs)):
middle=torch.cat((inputs[i],self.hidden_encoder_value),-1)
self.hidden_encoder_value=self.hidden_encoder(middle)
self.hidden_list.append(self.hidden_encoder_value)
解码器部分直接生成翻译词,此处和传统rnn模块的输出几乎相同:
#解码器,用于生成翻译词
def decoder(self,hidden):
c=0
for i in range(len(self.hidden_list)):
similarity=torch.cosine_similarity(self.hidden_list[i],torch.tensor(hidden),dim=0)
c+=similarity*self.hidden_list[i]
middle=torch.cat((c,hidden),-1)
self.hidden_decoder_value=self.hidden_decoder(middle)
out=self.softmax(self.out(middle))
return out
训练过程,我们先计算编码器的h1、h2、h3、h4值,再由h1、h2、h3、h4与译码器的h’值计算余弦相似度得出c,此后由c产生output(每生成一个output,c都要重新计算)。初始编译器的hidden为“BOS”(不以BOS开始,机器不知道应该从哪个词开始翻译),训练代码如下:
#训练模型
def train(self,inputs,labels,epochs):
criterion = nn.CrossEntropyLoss()#使用交叉熵
optimizer = optim.Adam(model.parameters(), lr=0.001)#使用Adam优化算法更新参数
for epoch in range(epochs):
self.encoder(inputs)#encoder生成hidden列表,用于h1*a1+h2*a2+h3*a3+h4*a4的计算
for i in range(len(labels)):
if i==0:#刚开始翻译,此时输入序列为bos而不是hidden
output=self.decoder(self.bos)
else:
output=self.decoder(self.hidden_decoder_value)
optimizer.zero_grad()
loss = criterion(output.unsqueeze(0), labels[i].unsqueeze(0))
loss.backward(retain_graph=True)
optimizer.step()
训练完成后进行效果检测:
model=RNN(hidden_size,embedding_size,output_size)
#设置150个epoch训练模型
model.train(inputs,labels,150)
#效果检测
result_list=model(inputs)
result=[]
print()
target_word.append("EOS")#为了简便,直接在原句中加入“EOS”作为结束标志
for i in range(len(result_list)):
result.append(target_word[result_list[i]])
print("原始句:",source_word)
print("翻译句:",result)
输出结果如下,翻译正确。当然这里的训练集和测试集统一,已经过拟合。如果需要真正制作翻译器,需要自己输入大量的数据进行训练。
完整代码
import torch.nn as nn
import torch
import jieba
import torch.optim as optim#优化器
from torch.autograd import Variable
class RNN(nn.Module):
#模型初始化
def __init__(self,hidden_size,embedding_size,output_size):
super(RNN,self).__init__()
self.hidden_encoder_value=torch.rand(hidden_size)#编码器hidden值
self.hidden_decoder_value=torch.rand(hidden_size)#解码器hidden值
self.bos=torch.rand(hidden_size)#BOS:开始翻译标志
self.hidden_encoder = nn.Linear(hidden_size+embedding_size, hidden_size)#编码器
self.hidden_decoder = nn.Linear(hidden_size*2, hidden_size)#解码器
self.out=nn.Linear(hidden_size*2, output_size)#用于输出翻译的词概率
self.softmax = nn.Softmax(dim=-1)
#编码器,用于生成编码器的hidden列表
def encoder(self,inputs):
self.hidden_list=[]#用于h1*a1+h2*a2+h3*a3+h4*a4的计算
for i in range(len(inputs)):
middle=torch.cat((inputs[i],self.hidden_encoder_value),-1)
self.hidden_encoder_value=self.hidden_encoder(middle)
self.hidden_list.append(self.hidden_encoder_value)
#解码器,用于生成翻译词
def decoder(self,hidden):
c=0
for i in range(len(self.hidden_list)):
similarity=torch.cosine_similarity(self.hidden_list[i],torch.tensor(hidden),dim=0)
c+=similarity*self.hidden_list[i]
middle=torch.cat((c,hidden),-1)
self.hidden_decoder_value=self.hidden_decoder(middle)
out=self.softmax(self.out(middle))
return out
#前向传播层
def forward(self,inputs):
self.encoder(inputs)#encoder生成hidden列表,用于h1*a1+h2*a2+h3*a3+h4*a4的计算
out_word_lst=[]
for i in range(len(labels)):#设置最大翻译长度,防止翻译迟迟不结束
if i==0:#刚开始翻译,此时输入序列为bos而不是hidden
output=self.decoder(self.bos)
else:
output=self.decoder(self.hidden_decoder_value)
_,idx=output.max(0)
out_word_lst.append(idx)
if idx==len(labels)-1:#最后一个词对应EOS,即碰到翻译结束符马上停止翻译
break
return out_word_lst
#训练模型
def train(self,inputs,labels,epochs):
criterion = nn.CrossEntropyLoss()#使用交叉熵
optimizer = optim.Adam(model.parameters(), lr=0.001)#使用Adam优化算法更新参数
for epoch in range(epochs):
self.encoder(inputs)#encoder生成hidden列表,用于h1*a1+h2*a2+h3*a3+h4*a4的计算
for i in range(len(labels)):
if i==0:#刚开始翻译,此时输入序列为bos而不是hidden
output=self.decoder(self.bos)
else:
output=self.decoder(self.hidden_decoder_value)
optimizer.zero_grad()
loss = criterion(output.unsqueeze(0), labels[i].unsqueeze(0))
loss.backward(retain_graph=True)
optimizer.step()
#产生数据
def make_data(source_word,target_word,voc_size,embedding_size):
embedding = nn.Embedding(voc_size, embedding_size, sparse=True)
inputs=[]
labels=[]
for i in range(len(source_word)):#我们的输入只有一个句子,可以按照句子单词序列进行编码。这里只是作为演示,后续大规模翻译需要修改
inputs.append(embedding(torch.tensor(i)))
for i in range(len(target_word)):#我们的输出也只有一个句子,可以按照句子序列进行编码
labels.append(target_word_dict[target_word[i]])
labels.append(target_word_dict["EOS"])
return inputs,Variable(torch.LongTensor(labels))
if __name__=="__main__":
source_word=["我","爱","中","国"]
target_word=["I","love","china"]
target_word_dict={x:i for i,x in enumerate(target_word)}
target_word_dict['EOS']=len(target_word_dict)#加入结束标志符
voc_size=len(source_word)#输入文本词汇总数
embedding_size=2#embedding后的词向量长度
inputs,labels=make_data(source_word,target_word,voc_size,embedding_size)#产生数据
hidden_size=3#模型中hidden的长度
output_size=len(labels)#翻译词可能输出,此处为4,分别包含"I"、"love"、"china"、"EOS"的概率
model=RNN(hidden_size,embedding_size,output_size)
#设置150个epoch训练模型
model.train(inputs,labels,150)
#效果检测
result_list=model(inputs)
result=[]
print()
target_word.append("EOS")#为了简便,直接在原句中加入“EOS”作为结束标志
for i in range(len(result_list)):
result.append(target_word[result_list[i]])
print("原始句:",source_word)
print("翻译句:",result)
4.总结
本篇博客主要根据输入输出类型的区别,对基本模型进行了构建和演示代码编写。RNN由一开始的多输入单输出,到现在的多种输入输出结构,离不开基本模型的变型。我们在处理现实任务时,要因地制宜,选择或搭建属于我们自己的模型。
5.参考资料
1、https://www.bilibili.com/video/BV17y4y1m737?from=search&seid=16275505917373064546(黑马程序员视频,对自然语言处理的基础知识进行了讲解)
2、https://zhuanlan.zhihu.com/p/28054589(原理参考)
3、https://blog.csdn.net/sinat_28015305/article/details/109355828(原理参考)