从图中可知, Seq2Seq模型架构, 包括两部分分别是encoder(编码器)和decoder(解码器), 编码器和解码器的内部实现都使用了GRU模型,
这里它要完成的是一个中文到英文的翻译: 欢迎 来 北京 --> welcome to BeiJing.
编码器首先处理中文输入"欢迎 来 北京", 通过GRU模型获得每个时间步的输出张量,最后将它们拼接成一个中间语义张量c, 张量c包含了 "欢迎 来 北京"完整的语义。
接着解码器将使用这个中间语义张量c以及每一个时间步的隐层张量, 逐个生成对应的翻译语言。
翻译数据集下载地址: https://download.pytorch.org/tutorial/data.zip
数据文件预览:
- data/
- eng-fra.txt
She feeds her dog a meat-free diet. Elle fait suivre à son chien un régime sans viande.
She feeds her dog a meat-free diet. Elle fait suivre à son chien un régime non carné.
She folded her handkerchief neatly. Elle plia soigneusement son mouchoir.
She folded her handkerchief neatly. Elle a soigneusement plié son mouchoir.
She found a need and she filled it. Elle trouva un besoin et le remplit.
She gave birth to twins a week ago. Elle a donné naissance à des jumeaux il y a une semaine.
She gave him money as well as food. Elle lui donna de l'argent aussi bien que de la nourriture.
She gave it her personal attention. Elle y a prêté son attention personnelle.
She gave me a smile of recognition. Elle m'adressa un sourire indiquant qu'elle me reconnaissait.
She glanced shyly at the young man. Elle a timidement jeté un regard au jeune homme.
She goes to the movies once a week. Elle va au cinéma une fois par semaine.
She got into the car and drove off. Elle s'introduisit dans la voiture et partit.
整个案例的实现可分为以下五个步骤:
- 第一步: 导入必备的工具包
- python版本使用3.6.x, pytorch版本使用1.3.1
- 第二步: 对持久化文件中数据进行处理, 以满足模型训练要求
- 将指定语言中的词汇映射成数值
- 字符规范化
- 将持久化文件中的数据加载到内存, 并实例化类Lang
- 过滤出符合我们要求的语言对
- 对以上数据准备函数进行整合, 并使用类Lang对语言对进行数值映射
- 将语言对转化为模型输入需要的张量
- 第三步: 构建基于GRU的编码器和解码器
- 构建基于GRU的编码器
- 构建基于GRU的解码器
- 构建基于GRU和Attention的解码器
- 第四步: 构建模型训练函数, 并进行训练
- 什么是teacher_forcing: 它是一种用于序列生成任务的训练技巧, 在seq2seq架构中, 根据循环神经网络理论,解码器每次应该使用上一步的结果作为输入的一部分, 但是训练过程中,一旦上一步的结果是错误的,就会导致这种错误被累积,无法达到训练效果, 因此,我们需要一种机制改变上一步出错的情况,因为训练时我们是已知正确的输出应该是什么,因此可以强制将上一步结果设置成正确的输出, 这种方式就叫做teacher_forcing.
- teacher_forcing的作用: 能够在训练的时候矫正模型的预测,避免在序列生成的过程中误差进一步放大. 另外, teacher_forcing能够极大的加快模型的收敛速度,令模型训练过程更快更平稳.
- 构建训练函数train
- 构建时间计算函数timeSince
- 调用训练函数并打印日志和制图
- 损失曲线分析: 一直下降的损失曲线, 说明模型正在收敛, 能够从数据中找到一些规律应用于数据
- 第五步: 构建模型评估函数, 并进行测试以及Attention效果分析
- 构建模型评估函数evaluate
- 随机选择指定数量的数据进行评估
- 进行了Attention可视化分析
编码器结构图:
解码器结构图:
基于GRU和Attention的解码器结构图:
from io import open # 从io工具包导入open方法
import unicodedata # 用于字符规范化
import re # 用于正则表达式
import random # 用于随机生成数据
import torch # 用于构建网络结构和函数的torch工具包
import torch.nn as nn
import torch.nn.functional as F
import time # 导入时间工具包
import math # 导入数学工具包
from torch import optim # torch中预定义的优化方法工具包
import matplotlib.pyplot as plt # 导入plt以便绘制损失曲线
# 注意:torch.cat((X, Y), 1)表示沿横轴方向数据拼接合并
device = torch.device("cpu") # 设备选择, 我们可以选择在cuda或者cpu上运行你的代码
# device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 设备选择, 我们可以选择在cuda或者cpu上运行你的代码
data_path = './data/eng-fra.txt'
SOS_token = 0 # 起始标志
EOS_token = 1 # 结束标志
teacher_forcing_ratio = 0.5 # 设置teacher_forcing比率为0.5
# 一、类Lang
class Lang:
def __init__(self, name): # 初始化函数中参数name代表传入某种语言的名字
self.name = name # 将name传入类中
self.word2index = {
"SOS": 0, "EOS": 1} # 初始化词汇对应自然数值的字典
self.index2word = {
0: "SOS", 1: "EOS"} # 初始化自然数值对应词汇的字典, 其中0,1对应的SOS和EOS已经在里面了
self.n_words = 2 # 初始化词汇对应的自然数索引,这里从2开始,因为0,1已经被开始和结束标志占用了
# 添加词汇函数, 即将词汇转化为对应的数值, 输入参数word是一个单词
def addWord(self, word):
# 首先判断word是否已经在self.word2index字典的key中
if word not in self.word2index:
self.word2index[word] = self.n_words # 如果不在, 则将这个词加入其中, 并为它对应一个数值,即self.n_words
self.index2word[self.n_words] = word # 同时也将它的反转形式加入到self.index2word中
self.n_words += 1 # self.n_words一旦被占用之后,逐次加1, 变成新的self.n_words
# 添加句子函数, 即将句子转化为对应的数值序列, 输入参数sentence是一条句子
def addSentence(self, sentence):
for word in sentence.split(' '): # 根据一般国家的语言特性(我们这里研究的语言都是以空格分个单词)对句子进行分割,得到对应的词汇列表
self.addWord(word) # 然后调用addWord进行处理
# 测试
name_sample = "eng"
sentence_sample = "hello I am Jay"
eng01 = Lang(name_sample)
eng01.addSentence(sentence_sample)
print("word2index:", eng01.word2index)
print("index2word:", eng01.index2word)
print("n_words:", eng01.n_words)
print("=" * 200)
# 二、工具函数
# 2.1 字符规范化之unicode转Ascii函数【关于编码问题我们暂且不去考虑,我们认为这个函数的作用就是去掉一些语言中的重音标记。如: Ślusàrski ---> Slusarski】
def unicodeToAscii(s):
return ''.join(
c for c in unicodedata.normalize('NFD', s)
if unicodedata.category(c) != 'Mn'
)
# 2.2 字符串规范化函数, 参数str代表传入的字符串
def normalizeString(str):
str = unicodeToAscii(str.lower().strip()) # 使字符变为小写并去除两侧空白符, z再使用unicodeToAscii去掉重音标记
str = re.sub(r"([.!?])", r" \1", str) # 在.!?前加一个空格
str = re.sub(r"[^a-zA-Z.!?]+", r" ", str) # 使用正则表达式将字符串中不是大小写字母和正常标点的都替换成空格
return str
s = "Are you kidding me?"
nsr = normalizeString(s)
print("nsr = {0}".format(nsr))
# 2.3 将持久化文件中的数据加载到内存, 并实例化类Lang
def readLangs(lang1, lang2): # 读取语言函数, 参数lang1是源语言的名字, 参数lang2是目标语言的名字,返回对应的class Lang对象, 以及语言对列表
lines = open(data_path, encoding='utf-8').read().strip().split('\n') # 从文件中读取语言对并以/n划分存到列表lines中
sentence_pairs = [[normalizeString(s) for s in l.split('\t')] for l in lines] # 对lines列表中的句子进行标准化处理,并以\t进行再次划分, 形成子列表, 也就是语言对
# 然后分别将语言名字传入Lang类中, 获得对应的语言对象, 返回结果
input_lang = Lang(lang1)
output_lang = Lang(lang2)
return input_lang, output_lang, sentence_pairs
lang1 = "eng"
lang2 = "fra"
input_lang, output_lang, sentence_pairs = readLangs(lang1, lang2)
print("input_lang:", input_lang)
print("output_lang:", output_lang)
print("sentence_pairs中的前五个:", sentence_pairs[:5])
# 2.4 过滤出符合我们要求的语言对
MAX_LENGTH = 10 # 设置组成句子中单词或标点的最多个数
eng_prefixes = ( # 选择带有指定前缀的语言特征数据作为训练数据
"i am ", "i m ",
"he is", "he s ",
"she is", "she s ",
"you are", "you re ",
"we are", "we re ",
"they are", "they re "
)
def filterPair(p): # 语言对过滤函数, 参数p代表输入的语言对, 如['she is afraid.', 'elle malade.']
# p[0]代表英语句子,对它进行划分,它的长度应小于最大长度MAX_LENGTH并且要以指定的前缀开头
# p[1]代表法文句子, 对它进行划分,它的长度应小于最大长度MAX_LENGTH
return len(p[0].split(' ')) < MAX_LENGTH and p[0].startswith(eng_prefixes) and len(p[1].split(' ')) < MAX_LENGTH
def filterPairs(sentence_pairs): # 对多个语言对列表进行过滤, 参数sentence_pairs代表语言对组成的列表, 简称语言对列表
return [sentence_pair for sentence_pair in sentence_pairs if filterPair(sentence_pair)] # 函数中直接遍历列表中的每个语言对并调用filterPair()即可
fsentence_pairs = filterPairs(sentence_pairs)
print("过滤后的sentence_pairs前五个:", fsentence_pairs[:5])
print("=" * 200)
# 2.5 时间计算函数
def timeSince(since): # 获得每次打印的训练耗时, since是训练开始时间
now = time.time() # 获得当前时间
s = now - since # 获得时间差,就是训练耗时
m = math.floor(s / 60) # 将秒转化为分钟, 并取整
s -= m * 60 # 计算剩下不够凑成1分钟的秒数
return '%dm %ds' % (m, s) # 返回指定格式的耗时
period = timeSince(time.time() - 10 * 60) # 假定模型训练开始时间是10min之前
print("测试:period = {0}".format(period))
# 三、对以上数据准备函数进行整合, 并使用类Lang对语言对进行数值映射【数据准备函数, 完成将所有字符串数据向数值型数据的映射以及过滤语言对,参数lang1, lang2分别代表源语言和目标语言的名字】
def prepareData(lang1, lang2):
input_lang, output_lang, sentence_pairs = readLangs(lang1, lang2) # 首先通过readLangs函数获得input_lang, output_lang对象,以及字符串类型的语言对列表
sentence_pairs = filterPairs(sentence_pairs) # 对字符串类型的语言对列表进行过滤操作
for sentence_pair in sentence_pairs: # 对过滤后的语言对列表进行遍历
# 使用input_lang和output_lang的addSentence方法对其进行数值映射
input_lang.addSentence(sentence_pair[0])
output_lang.addSentence(sentence_pair[1])
print("input_lang = {0}\noutput_lang = {1}\nsentence_pairs = {2}".format(input_lang, output_lang, sentence_pairs))
return input_lang, output_lang, sentence_pairs # 返回数值映射后的对象, 和过滤后语言对
input_lang, output_lang, sentence_pairs = prepareData('eng', 'fra')
print("input_n_words:", input_lang.n_words)
print("output_n_words:", output_lang.n_words)
print("sentence_pairs随机选择一条: {0}".format(random.choice(sentence_pairs)))
print("=" * 200)
# 四、将语言对转化为模型输入需要的张量【将文本句子转换为张量, 参数lang代表传入的Lang的实例化对象, sentence是预转换的句子】
def tensorFromSentence(lang, sentence):
indexes = [lang.word2index[word] for word in sentence.split(' ')] # 对句子进行分割并遍历每一个词汇, 然后使用lang的word2index方法找到它对应的索引,这样就得到了该句子对应的数值列表
indexes.append(EOS_token) # 然后加入句子结束标志
return torch.tensor(indexes, dtype=torch.long, device=device).view(-1, 1) # 将其使用torch.tensor封装成张量, 并改变它的形状为(n*1), 以方便后续计算
def tensorsFromPair(sentence_pair): # 将语言对转换为张量对, 参数pair为一个语言对
source_sentence_tensor = tensorFromSentence(input_lang, sentence_pair[0]) # 调用tensorFromSentence处理源语言获得对应的张量表示
target_sentence_tensor = tensorFromSentence(output_lang, sentence_pair[1]) # 调用tensorFromSentence处理目标语言获得对应的张量表示
return (source_sentence_tensor, target_sentence_tensor) # 最后返回它们组成的元组
sentence_pair = sentence_pairs[0] # 取sentence_pairs的第一条
sentence_tensor_pair = tensorsFromPair(sentence_pair)
print("sentence_tensor_pair = {0}".format(sentence_tensor_pair))
print("=" * 200)
# 五、基于GRU的编码器
# 5.1 构建基于GRU的编码器【以源语言的每一个单词为单位进行编码】
class EncoderRNN(nn.Module):
def __init__(self, encoder_input_size, encoder_word_embedding_size, encoder_hidden_size, num_layers=1): # 初始化参数:【vocab_size_en:代表编码器的输入尺寸即源语言(英文)的词表大小【词表单词总数】;encoder_word_embedding_size:代表词向量维度;hidden_size:代表GRU的隐藏层神经元数量;num_layers:代表隐藏层的层数】
super(EncoderRNN, self).__init__()
self.encoder_input_size = encoder_input_size
self.encoder_word_embedding_size = encoder_word_embedding_size
self.hidden_size = hidden_size
self.num_layers = num_layers
self.embedding = nn.Embedding(num_embeddings=encoder_input_size, embedding_dim=encoder_word_embedding_size) # 实例化nn中预定义的Embedding层, 它的输入encoder_input_size参数代表【英文词表单词总数】, 输出参数是encoder_word_embedding_size【词向量(WordEmbedding)维度】
self.gru = nn.GRU(input_size=encoder_word_embedding_size, hidden_size=encoder_hidden_size, num_layers=num_layers) # 实例化nn中预定义的GRU层
def forward(self, encoder_input, encoder_hidden): # 编码器前向逻辑函数【encoder_input代表将要输入Embedding层的源语言(英文)每个单词的输入张量;encoder_hidden代表编码器层gru的上一个时间步的隐层张量/初始化的隐层张量】
# 参数encoder_input: 代表单词字符串文本通过词汇映射(word2index)后的张量,encoder_input里的每一个数字必须为0~encoder_input_size(英文词表单词总数)间的数来代表词汇表里的一个英文单词
word_embedding = self.embedding(encoder_input).view(1, 1, -1) # 对英文输入张量进行embedding操作, 并使其形状变为(1,1,-1),-1代表自动计算维度【理论上,我们的编码器每次只以一个词作为输入, 因此词汇映射后的尺寸应该是[1, embedding],而这里转换成三维的原因是因为torch中预定义GRU必须使用三维张量作为输入, 因此我们拓展了一个维度】
encoder_output, encoder_hidden = self.gru(word_embedding, encoder_hidden) # 将embedding层的输出张量和上一个时间步的encoder_hidden张量作为gru的输入传入其中,获得最终gru的输出output和对应的隐层张量hidden
return encoder_output, encoder_hidden
def initHidden(self): # 初始化隐层张量函数
return torch.zeros(1, 1, self.hidden_size, device=device) # 将隐层张量初始化成为(1*1*self.hidden_size)大小的0张量
# 5.2 随机初始化实例化参数(测试用)
vocab_size_en = 20
encoder_word_embedding_size = 15
hidden_size = 25
num_layers = 1
# 5.3 随机初始化输入参数(测试用)
encoder_input = sentence_tensor_pair[0][0] # sentence_tensor_pair[0]代表源语言(英文)的句子,sentence_tensor_pair[0][0]代表句子中的第一个词
encoder_hidden = torch.zeros(1, 1, hidden_size) # 初始化第一个隐层张量,(1*1*hidden_size)大小的0张量
print("EncoderRNN--测试:encoder_input = {0}".format(encoder_input))
print("EncoderRNN--测试:encoder_hidden = {0}".format(encoder_hidden))
# 5.4 调用(测试用)
encoder = EncoderRNN(vocab_size_en, encoder_word_embedding_size, hidden_size, num_layers)
encoder_output, encoder_hidden = encoder(encoder_input, encoder_hidden) # 自动调用forward()函数
print("EncoderRNN--测试:encoder_output.shape = {0}\n encoder_output = {1}".format(encoder_output.shape, encoder_output))
print("EncoderRNN--测试:encoder_hidden.shape = {0}\n encoder_hidden = {1}".format(encoder_hidden.shape, encoder_hidden))
print("=" * 200)
# 六、基于GRU的解码器
# 6.1 构建基于GRU的解码器【对照AttnDecoderRNN】
class DecoderRNN(nn.Module):
# 初始化参数:【decoder_input_size=target_vocab_size: 代表整个解码器的输入尺寸, 也是目标语言(法文)的词表大小; decoder_word_embedding_size:是GRU解码器的输入尺寸;decoder_hidden_size:代表解码器中GRU的隐藏层神经元数量;decoder_output_size=target_vocab_size:代表整个解码器的输出尺寸, 也是目标语言(法文)的词表大小】
def __init__(self, decoder_input_size, decoder_word_embedding_size, decoder_hidden_size, decoder_output_size, num_layers=1):
super(DecoderRNN, self).__init__()
self.decoder_input_size = decoder_input_size
self.decoder_word_embedding_size = decoder_word_embedding_size
self.decoder_hidden_size = decoder_hidden_size
self.decoder_output_size = decoder_output_size
self.num_layers = num_layers
# 实例化组件
self.embedding = nn.Embedding(num_embeddings=decoder_input_size, embedding_dim=decoder_word_embedding_size) # 实例化一个nn中的Embedding层对象, 它的参数target_vocab_size这里表示目标语言【法文】的【词表大小】,word_embedding_size表示目标语言【法文】的词向量维度
self.gru = nn.GRU(input_size=decoder_word_embedding_size, hidden_size=decoder_hidden_size, num_layers=num_layers) # 实例化nn中预定义的GRU层对象
self.linear = nn.Linear(in_features=hidden_size, out_features=decoder_output_size) # 实例化线性层对象, 对GRU的输出做线性变化, 获我们希望的输出尺寸target_vocab_size【目标语言的词表大小】
self.softmax = nn.LogSoftmax(dim=1) # 实例化Softmax层对象,使用softmax进行处理,以便于分类
def forward(self, decoder_input, decoder_hidden): # 解码器的前向逻辑函数【decoder_input:代表目标语言(法文)的Embedding层输入张量;decoder_hidden:代表解码器GRU的上一个时间步的隐藏层张量/初始化的隐层张量】
# 参数decoder_input: 代表单词字符串文本通过词汇映射(word2index)后的张量,decoder_input里的每一个数字必须为0~decoder_input_size(法文词表单词总数)间的数来代表法文词汇表里的一个法文单词
embedded = F.relu(self.embedding(decoder_input).view(1, 1, -1)) # 将【法文】输入张量进行embedding操作, 并使其形状变为(1,1,-1),-1代表自动计算维度【原因和解码器相同,因为torch预定义的GRU层只接受三维张量作为输入】,使用relu函数对word_embedding进行处理,根据relu函数的特性, 将使Embedding矩阵更稀疏,以防止过拟合
decoder_output, decoder_hidden = self.gru(embedded, decoder_hidden) # 将embedded及上一个时间步的decoder_hidden传入到解码器gru中
decoder_output = self.softmax(self.linear(decoder_output[0])) # 因为GRU输出的output也是三维张量,第一维没有意义,因此可以通过output[0]来降维,再传给linear线性层做变换, 最后用softmax处理以便于分类
return decoder_output, decoder_hidden
def initHidden(self): # 初始化隐层张量函数
return torch.zeros(1, 1, self.hidden_size, device=device) # 将隐层张量初始化成为(1*1*self.hidden_size)大小的0张量
# 6.2 随机初始化实例化参数(测试用)
vocab_size_fr = 10 # 设目标语言(法文)的词汇表大小为 10
decoder_input_size = vocab_size_fr
decoder_word_embedding_size = 15
decoder_hidden_size = 25 # hidden_size
decoder_output_size = vocab_size_fr
# 6.3 随机初始化输入参数(测试用)
decoder_input = sentence_tensor_pair[1][0] # sentence_tensor_pair[1]代表目标语言【法文】的句子,sentence_tensor_pair[1][0]代表句子中的第一个词
decoder_hidden = torch.zeros(1, 1, decoder_hidden_size) # 初始化第一个隐层张量,(1*1*hidden_size)的0张量
print("DecoderRNN--测试:decoder_input = {0}".format(decoder_input))
print("DecoderRNN--测试:decoder_hidden = {0}".format(decoder_hidden))
# 6.4 调用(测试用)
decoder = DecoderRNN(decoder_input_size, decoder_word_embedding_size, decoder_hidden_size, decoder_output_size)
decoder_output, decoder_hidden = decoder(decoder_input, decoder_hidden)
print("DecoderRNN--测试:decoder_output.shape = {0}\n decoder_output = {1}".format(decoder_output.shape, decoder_output))
print("DecoderRNN--测试:decoder_hidden.shape = {0}\n decoder_hidden = {1}".format(decoder_hidden.shape, decoder_hidden))
print("=" * 200)
# 七、基于GRU和Attention的解码器
# 7.1 构建基于GRU和Attention的解码器
class AttnDecoderRNN(nn.Module):
# 初始化函数中的参数有4个【decoder_input_size=target_vocab_size: 代表整个解码器的输入维度, 也是目标语言(法文)的词表大小; decoder_word_embedding_size: 代表词向量维度; decoder_hidden_size:代表解码器中GRU的输出尺寸【词向量维度】,也是隐藏层的神经元个数; decoder_output_size=target_vocab_size代表整个解码器的输出尺寸, 也是目标语言(法文)的词表大小; dropout_p代表我们使用dropout层时的置零比率,默认0.1; max_length代表句子的最大长度】
def __init__(self, decoder_input_size, decoder_word_embedding_size, decoder_hidden_size, decoder_output_size, dropout_p=0.1, max_length=MAX_LENGTH):
super(AttnDecoderRNN, self).__init__()
self.decoder_input_size = decoder_input_size
self.decoder_word_embedding_size = decoder_word_embedding_size
self.decoder_hidden_size = decoder_hidden_size
self.decoder_output_size = decoder_output_size
self.dropout_p = dropout_p
self.max_length = max_length
# 根据attention的QKV理论,attention的输入参数为三个Q,K,V,【Q是解码器的Embedding层的输出, K是解码器GRU的隐层输出(因为首次隐层还没有任何输出,会使用编码器的隐层输出);V是编码器层的输出】
# 第一步,将Q,K进行纵轴拼接, 做一次线性变化, 再使用softmax处理获得结果最后与V做张量乘法, 得到V的注意力表示结果.【说明:当注意力权重矩阵和V都是三维张量且第一维代表为batch条数时, 则做bmm运算】
# 第二步, 将Q与第二步的计算结果再进行拼接
# 第三步,最后为了使整个attention结构按照指定尺寸输出, 使用线性层作用在第二步的结果上做一个线性变换. 得到最终对Q的注意力表示.
self.embedding = nn.Embedding(self.decoder_input_size, self.decoder_word_embedding_size) # 实例化一个Embedding层, 输入参数是self.output_size【目标语言的词表大小】和self.hidden_size【词向量维度】
self.attn_linear = nn.Linear(self.decoder_word_embedding_size + self.decoder_hidden_size, self.max_length) # 实例化第一个注意力层【 因为它的输入是Q,K的拼接, 所以输入维度:self.decoder_word_embedding_size + self.hidden_size,输出维度:self.max_length,用于与encoder_outputs做bmm】
self.attn_combine = nn.Linear(self.decoder_word_embedding_size + self.decoder_hidden_size, self.decoder_hidden_size) # 实例化第二个注意力层【输入来自第三步的结果, 因为第三步的结果是将Q与第二步的结果进行拼接, 因此输入维度是self.decoder_word_embedding_size + self.hidden_size,输出结果要进入GRU,所以输出维度为self.hidden_size】
self.dropout = nn.Dropout(self.dropout_p) # 实例化一个nn.Dropout层,并传入self.dropout_p
self.gru = nn.GRU(self.decoder_hidden_size, self.decoder_hidden_size) # 实例化nn.GRU, 它的输入和输出隐层尺寸都设为self.hidden_size
self.linear = nn.Linear(self.decoder_hidden_size, self.decoder_output_size) # 实例化gru后面的线性层,也就是我们的解码器输出层.
def forward(self, decoder_input, decoder_hidden, encoder_outputs): # forward函数的输入参数有三个【decoder_input:目标语言(法文)每个单词的输入张量, decoder_hidden:上一个时间步的隐层张量/初始化的隐层张量, encoder_outputs:解码器的输出张量】
# 参数decoder_input: 代表单词字符串文本通过词汇映射(word2index)后的张量,decoder_input里的每一个数字必须为0~decoder_input_size(法文词表单词总数)间的数来代表法文词汇表里的一个法文单词
embedded = self.dropout(self.embedding(decoder_input).view(1, 1, -1)) # 将元数据(英文)每个单词的输入张量进行embedding操作, 并使其形状变为(1,1,-1),-1代表自动计算维度【原因和解码器相同,因为torch预定义的GRU层只接受三维张量作为输入】; 使用dropout进行随机丢弃,防止过拟合【最终形状:torch.Size([1, 1, 25])】
decoder_attention_weights = F.softmax(self.attn_linear(torch.cat((embedded[0], decoder_hidden[0]), 1)), dim=1) # 最终形状:torch.Size([1, 10])。进行第一个注意力层处理【Softmax(Linear([Q,K]))】:将Q,K进行纵轴拼接, 利用attn_linear线性层做一次线性变化使维度调整为与encoder_outputs维度一致, 最后使用softmax处理获得结果。
encoder_outputs_with_attention = torch.bmm(decoder_attention_weights.unsqueeze(0), encoder_outputs.unsqueeze(0)) # 进行bmm操作【Attention(Q,K,V) = Softmax(Linear([Q,K]))·V】:将得到的权重矩阵与V做矩阵乘法计算, 当二者都是三维张量且第一维代表为batch条数时, 则做bmm运算
attn_combine_output = F.relu(self.attn_combine(torch.cat((embedded[0], encoder_outputs_with_attention[0]), 1)).unsqueeze(0)) # 将Q与上一步的计算结果再进行拼接,将拼接结果输入第二个注意力层,最后使用relu激活函数处理获得结果
decoder_output, decoder_hidden = self.gru(attn_combine_output, decoder_hidden) # 将激活后的结果作为gru的输入和hidden一起传入其中
decoder_output = F.log_softmax(self.linear(decoder_output[0]), dim=1) # 最后将结果先降维,然后使用线性层处理成指定的输出维度,最后经过softmax处理
return decoder_output, decoder_hidden, decoder_attention_weights # 返回解码器的最终输出结果,最后的隐藏层张量、注意力权重张量
def initHidden(self): # 初始化隐层张量函数
return torch.zeros(1, 1, self.hidden_size, device=device) # 将隐层张量初始化成为(1*1*self.hidden_size)大小的0张量
# 7.2 随机初始化实例化参数(测试用)
vocab_size_fr = 10 # 设目标语言(法文)的词汇表大小为 10
decoder_input_size = vocab_size_fr
decoder_word_embedding_size = 20
decoder_hidden_size = 25
decoder_output_size = vocab_size_fr
# 7.3 随机初始化输入参数(测试用)
decoder_input = sentence_tensor_pair[1][0] # 目标语言(法文)的某个单词的输入张量
decoder_hidden = torch.zeros(1, 1, hidden_size) # 上一个时间步的隐层张量
encoder_outputs = torch.randn(10, 25) # encoder_outputs需要是encoder中每一个时间步的输出堆叠而成, 它的形状应该是(10*25), 我们这里直接随机初始化一个张量
print("AttnDecoderRNN--测试:decoder_input = {0}".format(decoder_input))
print("AttnDecoderRNN--测试:decoder_hidden.shape = {0}----decoder_hidden = {1}".format(decoder_hidden.shape, decoder_hidden))
# 7.4 调用(测试用)
attnDecoder = AttnDecoderRNN(decoder_input_size, decoder_word_embedding_size, decoder_hidden_size, decoder_output_size)
decoder_output, decoder_hidden, decoder_attention_weights = attnDecoder(decoder_input, decoder_hidden, encoder_outputs)
print("AttnDecoderRNN--测试:decoder_output.shape = {0}----decoder_output = {1}".format(decoder_output.shape, decoder_output))
print("AttnDecoderRNN--测试:decoder_hidden.shape = {0}-----decoder_hidden = {1}".format(decoder_hidden.shape, decoder_hidden))
print("AttnDecoderRNN--测试:decoder_attention_weights.shape = {0}----decoder_attention_weights = {1}".format(decoder_attention_weights.shape, decoder_attention_weights))
print("=" * 200)
# 八、构建训练函数, 一个翻译句子对进行一次梯度下降来更新模型参数,一个翻译句子对数据集迭代一次为一个epoch。【输入参数有8个: source_sentence_tensor: 源语言每句话的输入张量,target_sentence_tensor: 目标语言每句话的输入张量; encoder: 编码器实例化对象; decoder: 解码器实例化对象; encoder_optimizer 编码器优化器; decoder_optimizer: 解码器优化器; criterion: 损失函数计算方法; max_length: 句子的最大长度】
def train_epoch(source_sentence_tensor, target_sentence_tensor, encoder, decoder, encoder_optimizer, decoder_optimizer, criterion, max_length=MAX_LENGTH):
encoder_optimizer.zero_grad() # 编码器优化器梯度归0
decoder_optimizer.zero_grad() # 解码器优化器梯度归0
source_sentence_length = source_sentence_tensor.size(0) # 获得源文本(英文)张量的长度【每句文本的长度】
target_sentence_length = target_sentence_tensor.size(0) # 获得目标文本(法文)张量的长度【每句文本的长度】
loss = 0 # 设置初始损失为0
# 编码阶段
encoder_hidden = encoder.initHidden() # 初始化编码器的隐含层张量(全零张量)
encoder_outputs = torch.zeros(max_length, encoder.hidden_size, device=device) # 初始化编码器输出张量,形状是(max_length*encoder.hidden_size)的0张量,【用于保存每句英文通过编码器编码后的结果,然后传入到解码器应用于解码器的每一步】
for ei in range(source_sentence_length): # 循环遍历输入句子张量索引
encoder_output, encoder_hidden = encoder(source_sentence_tensor[ei], encoder_hidden) # 根据索引从input_tensor句子中取出对应的单词的张量表示,和初始化隐层张量一同传入encoder对象中
encoder_outputs[ei] = encoder_output[0, 0] # 将每次获得的输出encoder_output(三维张量), 使用[0, 0]降两维变成向量依次存入到encoder_outputs,这样encoder_outputs每一行存的都是对应的句子中每个单词通过编码器的输出结果
# 解码阶段
decoder_hidden = encoder_hidden # 初始化解码器的隐层张量为编码器最后一轮的隐层输出
decoder_input = torch.tensor([[SOS_token]], device=device) # 初始化解码器的第一个输入,即起始符
use_teacher_forcing = True if random.random() < teacher_forcing_ratio else False # 根据随机数与teacher_forcing_ratio对比判断是否使用teacher_forcing【use_teacher_forcing为True的概率为0.5】
for di in range(target_sentence_length): # 循环遍历目标句子张量索引
decoder_output, decoder_hidden, decoder_attention_weights = decoder(decoder_input, decoder_hidden, encoder_outputs) # 将decoder_input, decoder_hidden, encoder_outputs即attention中的QKV, 传入解码器对象, 获得decoder_output, decoder_hidden, decoder_attention
loss += criterion(decoder_output, target_sentence_tensor[di]) # 因为使用了teacher_forcing, 无论解码器输出的decoder_output是什么, 都只使用target_sentence_tensor[di](正确的答案)来计算损失
if use_teacher_forcing: # 如果使用teacher_forcing
decoder_input = target_sentence_tensor[di] # 强制将下一次的解码器输入设置为本次的‘正确的答案’
else: # 如果不使用teacher_forcing
top_value, top_index = decoder_output.topk(1) # 只不过这里我们将从decoder_output取出答案【取出decoder_output中所有概率中概率最大的值及其索引】
if top_index.squeeze().item() == EOS_token: # 如果某一步的解码结果是句子终止符号,则解码直接结束,跳出循环
break
decoder_input = top_index.squeeze().detach() # 否则,将下一步解码器的输入decoder_input设置为当前步最大概率值的索引以便进行下次运算【这里的detach的分离作用使得这个decoder_input与模型构建的张量图无关,相当于全新的外界输入】
loss.backward() # 误差进行反向传播【应用反向传播进行梯度计算】
encoder_optimizer.step() # 利用编码器的优化器进行参数更新
decoder_optimizer.step() # 利用解码器的优化器进行参数更新
return loss.item() / target_sentence_length # 最后返回平均损失
# 九、构建迭代训练函数, 输入参数有6个【n_epoches:总迭代次数(每次训练一个随机句子对); encoder:编码器对象; decoder:解码器对象; print_every:每个多少轮次打印一次训练日志; plot_every:每个多少轮次记录一次损失值,为了后续绘制损失曲线; learning_rate学习率】
def train(n_epoches, encoder, decoder, print_every=1000, plot_every=100, learning_rate=0.01):
start = time.time() # 获得训练开始时间戳
plot_losses = [] # 初始化存放平均损失值的列表【每个损失间隔的平均损失保存列表,用于绘制损失曲线】
print_loss_total = 0 # 每个打印日志间隔的总损失值,初始为0
plot_loss_total = 0 # 每个绘制损失值间隔的总损失值,初始为0
# 设置梯度下降优化器
encoder_optimizer = optim.SGD(encoder.parameters(), lr=learning_rate) # 编码器使用预定义的SGD作为梯度下降优化器,将参数和学习率传入其中
decoder_optimizer = optim.Adam(decoder.parameters(), lr=learning_rate) # 解码器使用预定义的Adam作为梯度下降优化器,将参数和学习率传入其中
# 实例化损失函数
criterion = nn.NLLLoss() # 定义损失函数为nn.NLLLoss,因为解码器模型的最后一层是nn.LogSoftmax, 两者的内部计算逻辑正好能够吻合.
# 训练n_epoches个翻译句子对
for epoch_no in range(1, n_epoches + 1): # 根据设置迭代步进行循环
sentence_pair_random = random.choice(sentence_pairs) # 每次从语言对列表中随机取出一对(eng~fra)作为训练语句
training_sentence_tensor_pair = tensorsFromPair(sentence_pair_random) # 将随机取出一对(eng~fra)转换为tonsor
source_sentence_tensor = training_sentence_tensor_pair[0] # 从training_tensor_pair中取出英文句子输入张量【一个英文句子对应的张量】
target_sentence_tensor = training_sentence_tensor_pair[1] # 从training_tensor_pair中取出法文句子目标张量【一个法文句子对应的张量】
loss = train_epoch(source_sentence_tensor, target_sentence_tensor, encoder, decoder, encoder_optimizer, decoder_optimizer, criterion) # 通过train_epoch函数获得模型运行一个epoch后的损失
print_loss_total += loss # 将本轮迭代的损失值进行累加
plot_loss_total += loss # 将本轮迭代的损失值进行累加
if epoch_no % print_every == 0: # 当迭代步达到日志打印间隔时
print_loss_avg = print_loss_total / print_every # 通过总损失除以间隔得到本次间隔的平均损失
print_loss_total = 0 # 将总损失归0
print('epoch_no = (%d %d%%)----timeSince(start)=(%s)----print_loss_avg = %.4f----sentence_pair_random = %s' % (epoch_no, epoch_no / n_epoches * 100, timeSince(start), print_loss_avg, sentence_pair_random)) # 打印日志,日志内容分别是:训练耗时,当前迭代步,当前进度百分比,当前平均损失
if epoch_no % plot_every == 0: # 当迭代步达到损失绘制间隔时
plot_loss_avg = plot_loss_total / plot_every # 通过总损失除以间隔得到本次间隔的平均损失
plot_losses.append(plot_loss_avg) # 将平均损失装进plot_losses列表
plot_loss_total = 0 # 总损失归0
plt.figure() # 绘制损失曲线
plt.plot(plot_losses)
plt.savefig("./s2s_loss.png") # 保存到指定路径
# 十、构建模型测试函数【输入参数有4个,分别是encoder, decoder: 编码器和解码器对象,sentence:需要评估的句子,max_length:句子的最大长度】
def evaluate(encoder, decoder, sentence, max_length=MAX_LENGTH):
with torch.no_grad(): # 评估/测试阶段不进行梯度计算
input_tensor = tensorFromSentence(input_lang, sentence) # 对输入的句子进行张量表示
input_length = input_tensor.size()[0] # 获得输入的句子长度
# 编码阶段
encoder_hidden = encoder.initHidden() # 初始化编码器的隐含层张量(全零张量)
encoder_outputs = torch.zeros(max_length, encoder.hidden_size, device=device) # 初始化编码器输出张量,形状是(max_length*encoder.hidden_size)的0张量,【用于保存每句英文通过编码器编码后的结果,然后传入到解码器应用于解码器的每一步】
for ei in range(input_length): # 循环遍历输入张量索引
encoder_output, encoder_hidden = encoder(input_tensor[ei], encoder_hidden) # 根据索引从input_tensor取出对应的单词的张量表示,和初始化隐层张量一同传入encoder对象中
encoder_outputs[ei] += encoder_output[0, 0] # 将每次获得的输出encoder_output(三维张量), 使用[0, 0]降两维变成向量依次存入到encoder_outputs,这样encoder_outputs每一行存的都是对应的句子中每个单词通过编码器的输出结果
# 解码阶段
decoder_hidden = encoder_hidden # 初始化解码器的隐层张量为编码器最后一轮的隐层输出
decoder_input = torch.tensor([[SOS_token]], device=device) # 初始化解码器的第一个输入,即起始符
decoded_words = [] # 初始化预测的词汇列表,最终合并此词汇列表即得到翻译后的法文句子
decoder_attentions = torch.zeros(max_length, max_length) # 初始化attention张量【decoder_attentions 的形状为torch.Size([10, 10])】
for di in range(max_length): # 开始循环解码
decoder_output, decoder_hidden, decoder_attention_weights = decoder(decoder_input, decoder_hidden, encoder_outputs) # 将decoder_input, decoder_hidden, encoder_outputs传入解码器对象, 获得decoder_output, decoder_hidden, decoder_attention
decoder_attentions[di] = decoder_attention_weights.data # 取所有的attention结果存入初始化的attention张量中【decoder_attention_weights的形状为torch.Size([1, 10])】
top_value, top_index = decoder_output.data.topk(1) # 从解码器输出中获得概率最高的值及其索引对象
if top_index.item() == EOS_token: # 从索引对象中取出它的值与结束标志值作对比
decoded_words.append('<EOS>') # 如果是结束标志值,则将结束标志装进decoded_words列表,代表翻译结束
break # 循环退出
else: # 否则,根据索引找到它在输出语言的index2word字典中对应的单词装进decoded_words
decoded_words.append(output_lang.index2word[top_index.item()])
decoder_input = top_index.squeeze().detach() # 最后将本次预测的索引降维并分离赋值给decoder_input,以便下次进行预测
return decoded_words, decoder_attentions[:di + 1] # 返回结果decoded_words, 以及完整注意力张量, 把没有用到的部分切掉
# 随机选择指定数量的数据进行评估【随机测试函数, 输入参数encoder, decoder代表编码器和解码器对象,n代表测试数】
def evaluateRandomly(encoder, decoder, n=6):
for i in range(n): # 对测试数进行循环
# 从pairs随机选择语言对
sentence_pair_random = random.choice(sentence_pairs)
# > 代表输入
print('>', sentence_pair_random[0])
# = 代表正确的输出
print('=', sentence_pair_random[1])
# 调用evaluate进行预测
output_words, attentions = evaluate(encoder, decoder, sentence_pair_random[0])
# 将结果连成句子
output_sentence = ' '.join(output_words)
# < 代表模型的输出
print('<', output_sentence)
print('')
print("=" * 200)
if __name__ == "__main__":
# 设置输入参数
word_embedding_size = 256 # 设置词向量维度为256
hidden_size = 256 # 设置隐藏层维度为256
n_epoches = 150 # 设置迭代次数
print_every = 10 # 设置日志打印间隔
plot_every = 10 # 设置画图间隔
learning_rate = 0.01 # 设置学习率
# 实例化一个编码器对象
encoder1 = EncoderRNN(encoder_input_size=input_lang.n_words, encoder_word_embedding_size=word_embedding_size, encoder_hidden_size=hidden_size).to(device) # 通过input_lang.n_words获取输入词汇总数,与word_embedding_size、hidden_size一同传入EncoderRNN类中,得到编码器对象encoder1
# 实例化一个带Attention的解码器对象
attn_decoder1 = AttnDecoderRNN(decoder_input_size=output_lang.n_words, decoder_word_embedding_size=word_embedding_size, decoder_hidden_size=hidden_size, decoder_output_size=output_lang.n_words, dropout_p=0.1).to(device) # 通过output_lang.n_words获取目标词汇总数,与word_embedding_size、hidden_size、dropout_p一同传入AttnDecoderRNN类中,得到解码器对象attn_decoder1
# 1、训练模型
train(n_epoches=n_epoches, encoder=encoder1, decoder=attn_decoder1, print_every=print_every, plot_every=plot_every, learning_rate=learning_rate) # 调用trainIters进行模型训练,将编码器对象encoder1,码器对象attn_decoder1,迭代步数,日志打印间隔传入其中
# 2、模型测试
evaluateRandomly(encoder=encoder1, decoder=attn_decoder1, n=8) # 调用evaluateRandomly进行模型测试,将编码器对象encoder1,码器对象attn_decoder1传入其中
# 3、Attention张量制图
sentence = "we re both teachers ."
output_words, attentions = evaluate(encoder1, attn_decoder1, sentence) # 调用评估函数
print("output_words = {0}".format(output_words))
plt.matshow(attentions.numpy()) # 将attention张量转化成numpy, 使用matshow绘制
plt.savefig("./s2s_attn.png") # 保存图像
设置参数如下
# 设置迭代步数
n_iters = 75000
# 设置日志打印间隔
print_every = 5000
训练进度:
3m 35s (5000 6%) 3.4159
7m 12s (10000 13%) 2.7805
10m 46s (15000 20%) 2.4663
14m 23s (20000 26%) 2.1693
18m 6s (25000 33%) 1.9303
21m 44s (30000 40%) 1.7601
25m 23s (35000 46%) 1.6207
29m 8s (40000 53%) 1.4973
32m 44s (45000 60%) 1.3832
36m 22s (50000 66%) 1.2694
40m 6s (55000 73%) 1.1813
43m 51s (60000 80%) 1.0907
47m 29s (65000 86%) 1.0425
51m 10s (70000 93%) 0.9955
54m 48s (75000 100%) 0.9158
损失下降曲线:
损失曲线分析:一直下降的损失曲线, 说明模型正在收敛, 能够从数据中找到一些规律应用于数据。
Attention可视化:
- “纵坐标” 代表输入的源语言各个词汇对应的索引, 0-6分别对应[“we”, “re”, “both”, “teachers”, “.”, “”],
- “横坐标”代表生成的目标语言各个词汇对应的索引, 0-7代表[‘nous’, ‘sommes’, ‘toutes’, ‘deux’, ‘enseignantes’, ‘.’, ‘’],
- 图中浅色小方块(颜色越浅说明影响越大)代表词汇之间的影响关系, 比如源语言的第1个词汇对生成目标语言的第1个词汇影响最大, 源语言的第4,5个词对生成目标语言的第5个词会影响最大,
- 通过这样的可视化图像, 我们可以知道Attention的效果好坏, 与我们人为去判定到底还有多大的差距. 进而衡量我们训练模型的可用性.