PyTorch基础——机器翻译的神经网络实现

一、介绍

内容

“基于神经网络的机器翻译”出现了“编码器+解码器+注意力”的构架,让机器翻译的准确度达到了一个新的高度。所以本次主题就是“基于深度神经网络的机器翻译技术”。

我们首先会尝试使用“编码器+简单解码器”的构架,来观察普通编码器-解码器构架能够取得的效果。然后会尝试“编码器+带有注意力机制的解码器”构架,看看加上注意力能让模型获得怎样的提高。

实验知识点

  • 机器翻译“平行语料”的准备
  • 编码器-解码器框架
  • 注意力机制

二、数据预处理-准备平行语料库

2.1 引入相关包

# 用到的包
# 进行系统操作,如io、正则表达式的包
from io import open
import unicodedata
import string
import re
import random


#Pytorch必备的包
import torch
import torch.nn as nn
from torch.autograd import Variable
from torch import optim
import torch.nn.functional as F
import torch.utils.data as DataSet


# 绘图所用的包
import matplotlib.pyplot as plt
import numpy as np


# 判断本机是否有支持的GPU
use_cuda = torch.cuda.is_available()
# 即时绘图
%matplotlib inline

2.2 准备平行语料

一个包含 135842 条平行语料的法语-英语语料库,并在实验中做了裁剪。

之所以没有用中-英语料,一是很难找到特别合适的平行语料库,二是相比于法英翻译,中英翻译更难训练,难以看到训练效果。

下载数据 网盘链接:https://pan.baidu.com/s/1BPmcPk_-hKPaYbb92PH4TA 提取码:2y3k

# 定义两个特殊符号,分别对应句子头和句子尾
SOS_token = 0
EOS_token = 1

# 读取平行语料库
# 英=法
lines = open('fra.txt', encoding = 'utf-8')
french = lines.read().strip().split('\n')
lines = open('eng.txt', encoding = 'utf-8')
english = lines.read().strip().split('\n')
print(len(french))
print(len(english))

然后再定义一个语言类 Lang,用以实现对英文和法语两种语言的共同处理功能,包括建立词典,编码以及单词到索引的转换等等。另外,我们在上一步定义了两个特殊的单词:SOSEOS,用以分别表示句子的起始与句子的终结。

# 在这个对象中,最重要的是两个字典:word2index,index2word
# 故名思议,第一个字典是将word映射到索引,第二个是将索引映射到word
class Lang:
    def __init__(self, name):
        self.name = name
        self.word2index = {}
        self.word2count = {}
        self.index2word = {0: "SOS", 1: "EOS"}
        self.n_words = 2  # Count SOS and EOS

    def addSentence(self, sentence):
        # 在语言中添加一个新句子,句子是用空格隔开的一组单词
        # 将单词切分出来,并分别进行处理
        for word in sentence.split(' '):
            self.addWord(word)

    def addWord(self, word):
        # 插入一个单词,如果单词已经在字典中,则更新字典中对应单词的频率
        # 同时建立反向索引,可以从单词编号找到单词
        if word not in self.word2index:
            self.word2index[word] = self.n_words
            self.word2count[word] = 1
            self.index2word[self.n_words] = word
            self.n_words += 1
        else:
            self.word2count[word] += 1

然后是几个辅助工具函数,用于辅助处理训练数据,将训练数据简单化到我们想要的程度。

# Turn a Unicode string to plain ASCII, thanks to
# http://*.com/a/518232/2809427
# 将unicode编码转变为ascii编码
def unicodeToAscii(s):
    return ''.join(
        c for c in unicodedata.normalize('NFD', s)
        if unicodedata.category(c) != 'Mn'
    )

# 把输入的英文字符串转成小写
def normalizeEngString(s):
    s = unicodeToAscii(s.lower().strip())
    s = re.sub(r"([.!?])", r" \1", s)
    s = re.sub(r"[^a-zA-Z.!?]+", r" ", s)
    return s

# 对输入的单词对做过滤,保证每句话的单词数不能超过MAX_LENGTH
def filterPair(p):
    return len(p[0].split(' ')) < MAX_LENGTH and \
        len(p[1].split(' ')) < MAX_LENGTH

# 输入一个句子,输出一个单词对应的编码序列
def indexesFromSentence(lang, sentence):
    return [lang.word2index[word] for word in sentence.split(' ')]


# 和上面的函数功能类似,不同在于输出的序列等长=MAX_LENGTH
def indexFromSentence(lang, sentence):
    indexes = indexesFromSentence(lang, sentence)
    indexes.append(EOS_token)
    for i in range(MAX_LENGTH - len(indexes)):
        indexes.append(EOS_token)
    return(indexes)

# 从一个词对到下标
def indexFromPair(pair):
    input_variable = indexFromSentence(input_lang, pair[0])
    target_variable = indexFromSentence(output_lang, pair[1])
    return (input_variable, target_variable)

# 从一个列表到句子
def SentenceFromList(lang, lst):
    result = [lang.index2word[i] for i in lst if i != EOS_token]
    if lang.name == 'French':
        result = ' '.join(result)
    else:
        result = ' '.join(result)
    return(result)

最后是计算准确率的函数,这个函数我们在之前的实验中接触过。在函数中,predictions 是模型给出的一组预测结果,batch_sizenum_classes 列的矩阵,labels 是数据之中的正确答案。在函数中,predictions 是模型给出的一组预测结果,batch_sizenum_classes 列的矩阵,labels 是数据之中的正确答案。

# 计算准确度的函数
def rightness(predictions, labels):
    # 对于任意一行(一个样本)的输出值的第1个维度,求最大,得到每一行的最大元素的下标
    pred = torch.max(predictions.data, 1)[1] 
    #将下标与labels中包含的类别进行比较,并累计得到比较正确的数量
    rights = pred.eq(labels.data).sum() 
    #返回正确的数量和这一次一共比较了多少元素
    return rights, len(labels)

2.3 切分数据集

加载后的平行语料还不能够直接用于普通的编码器-解码器模型,原因是其中可能存在过长的句子。实际上过长的句子会影响普通的编码器-解码器模型的训练效果,所以需要将过长的句子过滤掉。那么对于较长的句子就没有办法了吗?先别着急,后面还会讲到有注意力机制加持的编码器-解码器模型。

# 处理数据形成训练数据
# 设置句子的最大长度
MAX_LENGTH = 5

#对英文做标准化处理
pairs = [[normalizeEngString(fra), normalizeEngString(eng)] for fra, eng in zip(french, english)]

# 对句子对做过滤,处理掉那些超过MAX_LENGTH长度的句子
input_lang = Lang('French')
output_lang = Lang('English')
pairs = [pair for pair in pairs if filterPair(pair)]
print('有效句子对:', len(pairs))

然后对法文和英文建立两个词典:

# 建立两个词典(法文和英文的)
for pair in pairs:
    input_lang.addSentence(pair[0])
    output_lang.addSentence(pair[1])
print("总单词数:")
print(input_lang.name, input_lang.n_words)
print(output_lang.name, output_lang.n_words)

最后将数据集进行切割,形成训练、校验和测试三个数据集。

# 形成训练集,首先,打乱所有句子的顺序
random_idx = np.random.permutation(range(len(pairs)))
pairs = [pairs[i] for i in random_idx]

# 将语言转变为单词的编码构成的序列
pairs = [indexFromPair(pair) for pair in pairs]

# 形成训练集、校验集和测试集
valid_size = len(pairs) // 10
if valid_size > 10000:
    valid_size = 10000
pp = pairs
pairs = pairs[ : - valid_size]
valid_pairs = pp[-valid_size : -valid_size // 2]
test_pairs = pp[- valid_size // 2 :]

# 利用PyTorch的dataset和dataloader对象,将数据加载到加载器里面,并且自动分批

batch_size = 32 #一批包含32个数据记录,这个数字越大,系统在训练的时候,每一个周期处理的数据就越多,这样处理越快,但总的数据量会减少

print('训练记录:', len(pairs))
print('校验记录:', len(valid_pairs))
print('测试记录:', len(test_pairs))

# 形成训练对列表,用于喂给train_dataset
pairs_X = [pair[0] for pair in pairs]
pairs_Y = [pair[1] for pair in pairs]
valid_X = [pair[0] for pair in valid_pairs]
valid_Y = [pair[1] for pair in valid_pairs]
test_X = [pair[0] for pair in test_pairs]
test_Y = [pair[1] for pair in test_pairs]

最后为各个数据集建立加载器和采样器。

# 形成训练集
train_dataset = DataSet.TensorDataset(torch.LongTensor(pairs_X), torch.LongTensor(pairs_Y))
# 形成数据加载器
train_loader = DataSet.DataLoader(train_dataset, batch_size = batch_size, shuffle = True, num_workers=8)


# 校验数据
valid_dataset = DataSet.TensorDataset(torch.LongTensor(valid_X), torch.LongTensor(valid_Y))
valid_loader = DataSet.DataLoader(valid_dataset, batch_size = batch_size, shuffle = True, num_workers=8)

# 测试数据
test_dataset = DataSet.TensorDataset(torch.LongTensor(test_X), torch.LongTensor(test_Y))
test_loader = DataSet.DataLoader(test_dataset, batch_size = batch_size, shuffle = True, num_workers = 8)

到这里,训练数据的准备工作就全部完成了。

三、编码器-解码器模型构架

下面我们要尝试的编码器-解码器模型的构架如下图所示:

以法文翻译成英文为例,编码—解码模型的工作方式是先由编码器将法文编码为内部状态,再由解码器对内部状态进行解码。这个中间的内部状态就体现为一个很大的向量。

而由解码器对内部状态进行解码的过程,则是将代表内部状态的向量作为解码器神经网络的输入,得到解码器输出对应的英文句子。

3.1 编码器的实现

接下来分别介绍编码器和解码器两个神经网络模型的代码实现。

首先是编码器,编码器网络是采用双向 GRU 单元构造的一个两层 RNN。我们只需要设置 bidirectional = True 就可以轻松实现双向 GRU 了。其它的操作则跟一般的 RNN 没有太大区别。

在编码器中,输入层为一个嵌入(embedding)层,它将每个输入的法文词的 one-hot 编码转化为一个词向量。而 RNN 网络则对句子的词语进行循环编码。我们之前接触到,RNN 具有独特的记忆性质,它在编码一句话的时候,既可以关注到这句话中每一个词语的含义,也可以关注到词语之间的联系。

因此,这个过程也可以被抽象为一个对源语言进行理解的过程。编码器没有输出层,但是在对一句话进行处理过后,我们可以将 RNN 全部隐含单元的输出状态作为对源语言编码的大向量,提供给解码器使用。

在有些情况下,也可以将多层循环神经网络最后一层的状态作为编码器的输出。

# 构建编码器RNN
class EncoderRNN(nn.Module):
    def __init__(self, input_size, hidden_size, n_layers=1):
        super(EncoderRNN, self).__init__()
        self.n_layers = n_layers
        self.hidden_size = hidden_size
        # 第一层Embeddeing
        self.embedding = nn.Embedding(input_size, hidden_size)
        # 第二层GRU,注意GRU中可以定义很多层,主要靠num_layers控制
        self.gru = nn.GRU(hidden_size, hidden_size, batch_first = True, 
                          num_layers = self.n_layers, bidirectional = True)

    def forward(self, input, hidden):
        #前馈过程
        #input尺寸: batch_size, length_seq
        embedded = self.embedding(input)
        #embedded尺寸:batch_size, length_seq, hidden_size
        output = embedded
        output, hidden = self.gru(output, hidden)
        # output尺寸:batch_size, length_seq, hidden_size
        # hidden尺寸:num_layers * directions, batch_size, hidden_size
        return output, hidden

    def initHidden(self, batch_size):
        # 对隐含单元变量全部进行初始化
        #num_layers * num_directions, batch, hidden_size
        result = Variable(torch.zeros(self.n_layers * 2, batch_size, self.hidden_size))
        if use_cuda:
            return result.cuda()
        else:
            return result

3.2 解码器的实现

解码器的架构和编码器是整体类似的,但也有部分差别,比如解码器需要输出层,另外,解码器需要设计损失函数以完成网络整体的反向传播算法。

解码器的架构和编码器大体类似:一个嵌入(embedding)层用于将输入的 one-hot 向量转化为词向量,与编码器架构相同的循环神经网络(RNN或LSTM)。与编码器不同的是,由于解码器的输出需要对应到目标语言的单词,因此解码器的输出层的神经元数量为目标语言单词表中的单词数。

其目的是进行 softmax 运算来确定所对应的目标语言单词,和计算模型的损失(Loss)以完成反向传播的过程。

# 解码器网络
class DecoderRNN(nn.Module):
    def __init__(self, hidden_size, output_size, n_layers=1):
        super(DecoderRNN, self).__init__()
        self.n_layers = n_layers
        self.hidden_size = hidden_size
        # 嵌入层
        self.embedding = nn.Embedding(output_size, hidden_size)
        # GRU单元
        # 设置batch_first为True的作用就是为了让GRU接受的张量可以和其它单元类似,第一个维度为batch_size
        self.gru = nn.GRU(hidden_size, hidden_size, batch_first = True,
                        num_layers = self.n_layers, bidirectional = True)
        # dropout操作层
        self.dropout = nn.Dropout(0.1)

        # 最后的全链接层
        self.out = nn.Linear(hidden_size * 2, output_size)
        self.softmax = nn.LogSoftmax(dim = 1)

    def forward(self, input, hidden):
        # input大小:batch_size, length_seq
        output = self.embedding(input)
        # embedded大小:batch_size, length_seq, hidden_size
        output = F.relu(output)
        output, hidden = self.gru(output, hidden)
        # output的结果再dropout
        output = self.dropout(output)
        # output大小:batch_size, length_seq, hidden_size * directions
        # hidden大小:n_layers * directions, batch_size, hidden_size
        output = self.softmax(self.out(output[:, -1, :]))
        # output大小:batch_size * output_size
        # 从output中取时间步重新开始

        return output, hidden

    def initHidden(self):
        # 初始化隐含单元的状态,输入变量的尺寸:num_layers * directions, batch_size, hidden_size
        result = Variable(torch.zeros(self.n_layers * 2, batch_size, self.hidden_size))
        if use_cuda:
            return result.cuda()
        else:
            return result

3.3 开始训练

在一切准备妥当之后,便可以开始训练神经网络模型了。首先,实例化编码器和解码器,定义优化器、损失函数等部件。

# 开始训练过程
# 定义网络结构
hidden_size = 32
max_length = MAX_LENGTH
n_layers = 1

encoder = EncoderRNN(input_lang.n_words, hidden_size, n_layers = n_layers)
decoder = DecoderRNN(hidden_size, output_lang.n_words, n_layers = n_layers)

if use_cuda:
    # 如果本机有GPU可用,则将模型加载到GPU上
    encoder = encoder.cuda()
    decoder = decoder.cuda()

learning_rate = 0.0001
# 为两个网络分别定义优化器
encoder_optimizer = optim.Adam(encoder.parameters(), lr=learning_rate)
decoder_optimizer = optim.Adam(decoder.parameters(), lr=learning_rate)

# 定义损失函数
criterion = nn.NLLLoss()
teacher_forcing_ratio = 0.5

在定义损失函数之后,需要注意到我们还定义了 teacher_forcing_ratio 这一个变量。

字面上理解,这个变量的意义是“老师强制管教概率”;实际上它的意义是,解码器在生成下一个词的时候,输入的不是自己预测的词,而是真实数据。

为什么要这样做?

在一般情况下,解码器在生成下一个词的时候,输入的是自己预测的上一个词实际上,在刚开始训练的时候,解码器网络还没来得及学会针对目标语言的字典进行词语定位、预测的功能。如果直接将解码器网络的输出作为下一个时间步的输入,那么解码器网络非常有可能生成驴唇不对马嘴的句子。这就如同让网络在浩如烟海的目标语言中不断随机试错,独自摸索正确的翻译结果。

为了加快网络的学习速度,我们往往在训练过程中,以一定的概率选择另一种操作方式:

即无论解码器网络有怎样的输出,我们都将正确的翻译结果按照时间步逐词输入到解码器网络中,如下图所示。而设置 teacher_forcing_ratio = 0.5 即代表着将有 50% 的概率选用这种直接使用正确答案的监督学习方式。

那么下面开始定义训练函数。可以感到现在模型的训练流程是要比之前实验接触到的训练流程复杂一些的。

# 用于记录训练中的损失信息,后面绘制图像用
plot_losses = []

print_loss_total = 0

print_loss_avg = 0

def train_simple_model():
    global plot_losses
    global print_loss_total
    global print_loss_avg
    print_loss_total = 0

    # 对训练数据循环
    for data in train_loader:
        input_variable = Variable(data[0]).cuda() if use_cuda else Variable(data[0])
        # input_variable的大小:batch_size, length_seq
        target_variable = Variable(data[1]).cuda() if use_cuda else Variable(data[1])
        # target_variable的大小:batch_size, length_seq

        # 初始化编码器状态
        encoder_hidden = encoder.initHidden(data[0].size()[0])
        # 清空梯度
        encoder_optimizer.zero_grad()
        decoder_optimizer.zero_grad()

        loss = 0

        # 开始编码器的计算,对时间步的循环由系统自动完成
        encoder_outputs, encoder_hidden = encoder(input_variable, encoder_hidden)
        # encoder_outputs的大小:batch_size, length_seq, hidden_size*direction
        # encoder_hidden的大小:direction*n_layer, batch_size, hidden_size

        # 开始解码器的工作
        # 输入给解码器的第一个字符
        decoder_input = Variable(torch.LongTensor([[SOS_token]] * target_variable.size()[0]))
        # decoder_input大小:batch_size, length_seq
        decoder_input = decoder_input.cuda() if use_cuda else decoder_input

        # 让解码器的隐藏层状态等于编码器的隐藏层状态
        decoder_hidden = encoder_hidden
        # decoder_hidden大小:direction*n_layer, batch_size, hidden_size

        # 以teacher_forcing_ratio的比例用target中的翻译结果作为监督信息
        use_teacher_forcing = True if random.random() < teacher_forcing_ratio else False
        base = torch.zeros(target_variable.size()[0])
        if use_teacher_forcing:
            # 教师监督: 将下一个时间步的监督信息输入给解码器
            # 对时间步循环
            for di in range(MAX_LENGTH):
                # 开始一步解码
                decoder_output, decoder_hidden = decoder(decoder_input, decoder_hidden)
                # decoder_ouput大小:batch_size, output_size
                # 计算损失函数
                loss += criterion(decoder_output, target_variable[:, di])
                # 将训练数据当做下一时间步的输入
                decoder_input = target_variable[:, di].unsqueeze(1)  # Teacher forcing
                # decoder_input大小:batch_size, length_seq

        else:
            # 没有教师训练: 使用解码器自己的预测作为下一时间步的输入
            # 开始对时间步进行循环
            for di in range(MAX_LENGTH):
                # 进行一步解码
                decoder_output, decoder_hidden = decoder(decoder_input, decoder_hidden)
                #decoder_ouput大小:batch_size, output_size(vocab_size)

                #从输出结果(概率的对数值)中选择出一个数值最大的单词作为输出放到了topi中
                topv, topi = decoder_output.data.topk(1, dim = 1)
                #topi 尺寸:batch_size, k
                ni = topi[:, 0]

                # 将输出结果ni包裹成Variable作为解码器的输入
                decoder_input = Variable(ni.unsqueeze(1))
                # decoder_input大小:batch_size, length_seq
                decoder_input = decoder_input.cuda() if use_cuda else decoder_input

                #计算损失函数
                loss += criterion(decoder_output, target_variable[:, di])



        # 开始反向传播
        loss.backward()
        loss = loss.cpu() if use_cuda else loss
        # 开始梯度下降
        encoder_optimizer.step()
        decoder_optimizer.step()
        # 累加总误差
        print_loss_total += loss.data.numpy()[0]

    # 计算训练时候的平均误差
    print_loss_avg = print_loss_total / len(train_loader)

然后是模型验证函数:

valid_loss = 0
rights = []

def evaluation_simple_model():
    global valid_loss
    global rights
    valid_loss = 0
    rights = []

    # 对校验数据集循环
    for data in valid_loader:
        input_variable = Variable(data[0]).cuda() if use_cuda else Variable(data[0])
        # input_variable的大小:batch_size, length_seq
        target_variable = Variable(data[1]).cuda() if use_cuda else Variable(data[1])
        # target_variable的大小:batch_size, length_seq

        encoder_hidden = encoder.initHidden(data[0].size()[0])

        loss = 0
        encoder_outputs, encoder_hidden = encoder(input_variable, encoder_hidden)
        # encoder_outputs的大小:batch_size, length_seq, hidden_size*direction
        # encoder_hidden的大小:direction*n_layer, batch_size, hidden_size

        decoder_input = Variable(torch.LongTensor([[SOS_token]] * target_variable.size()[0]))
        # decoder_input大小:batch_size, length_seq
        decoder_input = decoder_input.cuda() if use_cuda else decoder_input

        decoder_hidden = encoder_hidden
        # decoder_hidden大小:direction*n_layer, batch_size, hidden_size

        # 没有教师监督: 使用解码器自己的预测作为下一时间步解码器的输入
        for di in range(MAX_LENGTH):
            # 一步解码器运算
            decoder_output, decoder_hidden = decoder(decoder_input, decoder_hidden)
            #decoder_ouput大小:batch_size, output_size(vocab_size)

            # 选择输出最大的项作为解码器的预测答案
            topv, topi = decoder_output.data.topk(1, dim = 1)
            #topi 尺寸:batch_size, k
            ni = topi[:, 0]
            decoder_input = Variable(ni.unsqueeze(1))
            # decoder_input大小:batch_size, length_seq
            decoder_input = decoder_input.cuda() if use_cuda else decoder_input

            # 计算预测的准确率,记录在right中,right为一个二元组,分别存储猜对的个数和总数
            right = rightness(decoder_output, target_variable[:, di])
            rights.append(right)

            # 计算损失函数
            loss += criterion(decoder_output, target_variable[:, di])
        loss = loss.cpu() if use_cuda else loss
        # 累加校验时期的损失函数
        valid_loss += loss.data.numpy()[0] 

那么下面正式开始模型的训练。

plot_losses = []

# 开始200轮的循环
num_epoch = 100
for epoch in range(num_epoch):
    train_simple_model()

    # 开始跑校验数据集
    evaluation_simple_model()

    # 打印每一个Epoch的输出结果
    right_ratio = 1.0 * np.sum([i[0] for i in rights]) / np.sum([i[1] for i in rights])
    print('进程:%d%% 训练损失:%.4f,校验损失:%.4f,词正确率:%.2f%%' % (epoch * 1.0 / num_epoch * 100, 
                                                    print_loss_avg,
                                                    valid_loss / len(valid_loader),
                                                    100.0 * right_ratio))
    # 记录基本统计指标
    plot_losses.append([print_loss_avg, valid_loss / len(valid_loader), right_ratio])

接下来,绘制模型的训练误差曲线,观察模型的训练过程:由于在线环境我们没有执行完整训练,下面也提供了完整训练的误差曲线

# 将统计指标绘图
a = [i[0] for i in plot_losses]
b = [i[1] for i in plot_losses]
c = [i[2] * 100 for i in plot_losses]
plt.plot(a, '-', label = 'Training Loss')
plt.plot(b, ':', label = 'Validation Loss')
plt.plot(c, '.', label = 'Accuracy')
plt.xlabel('Epoch')
plt.ylabel('Loss & Accuracy')
plt.legend()

可以看到准确率大约在 60% 左右。

3.4 测试模型

如果你没有完成整个训练流程,那么请运行以下代码加载训练过的模型。

encoder = torch.load("Encoder_normal_cpu.mdl")
decoder = torch.load("Decoder_normal_cpu.mdl")

if use_cuda:
    encoder = encoder.cuda()
    decoder = decoder.cuda()

在经过大规模的训练之后,就可以测试神经翻译机了。我们在测试集上运行以下代码:

# 在测试集上测试模型运行的效果

# 首先,在测试集中随机选择20个句子作为测试
indices = np.random.choice(range(len(test_X)), 20)

# 对每个句子进行循环
for ind in indices:
    data = [test_X[ind]]
    target = [test_Y[ind]]
    # 把源语言的句子打印出来
    print(SentenceFromList(input_lang, data[0]))
    input_variable = Variable(torch.LongTensor(data)).cuda() if use_cuda else Variable(torch.LongTensor(data))
    # input_variable的大小:batch_size, length_seq
    target_variable = Variable(torch.LongTensor(target)).cuda() if use_cuda else Variable(torch.LongTensor(target))
    # target_variable的大小:batch_size, length_seq

    # 初始化编码器
    encoder_hidden = encoder.initHidden(input_variable.size()[0])

    loss = 0

    # 编码器开始编码,结果存储到了encoder_hidden中
    encoder_outputs, encoder_hidden = encoder(input_variable, encoder_hidden)
    # encoder_outputs的大小:batch_size, length_seq, hidden_size*direction
    # encoder_hidden的大小:direction*n_layer, batch_size, hidden_size

    # 将SOS作为解码器的第一个输入
    decoder_input = Variable(torch.LongTensor([[SOS_token]] * target_variable.size()[0]))
    # decoder_input大小:batch_size, length_seq
    decoder_input = decoder_input.cuda() if use_cuda else decoder_input

    # 将编码器的隐含层单元数值拷贝给解码器的隐含层单元
    decoder_hidden = encoder_hidden
    # decoder_hidden大小:direction*n_layer, batch_size, hidden_size

    # 没有教师指导下的预测: 使用解码器自己的预测作为解码器下一时刻的输入
    output_sentence = []
    decoder_attentions = torch.zeros(max_length, max_length)
    rights = []
    # 按照输出字符进行时间步循环
    for di in range(MAX_LENGTH):
        # 解码器一个时间步的计算
        decoder_output, decoder_hidden = decoder(decoder_input, decoder_hidden)
        #decoder_ouput大小:batch_size, output_size(vocab_size)

        # 解码器的输出
        topv, topi = decoder_output.data.topk(1, dim = 1)
        #topi 尺寸:batch_size, k
        ni = topi[:, 0]
        decoder_input = Variable(ni.unsqueeze(1))
        ni = ni.numpy()[0]

        # 将本时间步输出的单词编码加到output_sentence里面
        output_sentence.append(ni)
        # decoder_input大小:batch_size, length_seq
        decoder_input = decoder_input.cuda() if use_cuda else decoder_input

        # 计算输出字符的准确度
        right = rightness(decoder_output, target_variable[:, di])
        rights.append(right)
    # 解析出编码器给出的翻译结果
    sentence = SentenceFromList(output_lang, output_sentence)
    # 解析出标准答案
    standard = SentenceFromList(output_lang, target[0])

    # 将句子打印出来
    print('机器翻译:', sentence)
    print('标准翻译:', standard)
    # 输出本句话的准确率
    right_ratio = 1.0 * np.sum([i[0] for i in rights]) / np.sum([i[1] for i in rights])
    print('词准确率:', 100.0 * right_ratio)
    print('\n')

可以看到模型的翻译效果虽然不算好,但也不是毫无章法。但是有一点需要特别注意,我们在训练简单编码器-解码器模型前控制了训练语句的长度,将句子长度限制在了 5 个词以内。在短句子中,编码—解码模型表现非常好。但是如果尝试过你就会知道,一旦翻译句子过长,如 20 个字左右的句子,模型的表现就会变得非常差。原因是当句子长度增加的时候,做翻译时语序的前后置换现象会变得非常严重,神经网络难以把握其中的规律,所以无法进行准确的序列生成。那么下面就使用带有注意力机制的解码器模型,看看模型的效果究竟能提高多少。

四、编码器-有注意力机制的解码器模型构架

4.1 调整训练语料的最大长度

一般来说,带有注意力机制的解码器模型可以提高模型整体的性能,特别是对长句子翻译的效果会更好。

为了验证模型对长句子的翻译效果,下面将句子的最大长度设置为 10, 并重新处理数据形成训练数据、校验数据与测试数据。因为这段生成训练语料的代码与之前几乎相同,所以在这里就不再详细的进行说明了。

# 设置句子的最大长度
MAX_LENGTH = 10

#对英文做标准化处理
pairs = [[normalizeEngString(fra), normalizeEngString(eng)] for fra, eng in zip(french, english)]

# 对句子对做过滤,处理掉那些超过MAX_LENGTH长度的句子
input_lang = Lang('French')
output_lang = Lang('English')
pairs = [pair for pair in pairs if filterPair(pair)]
print('有效句子对:', len(pairs))

# 建立两个字典(中文的和英文的)
for pair in pairs:
    input_lang.addSentence(pair[0])
    output_lang.addSentence(pair[1])
print("总单词数:")
print(input_lang.name, input_lang.n_words)
print(output_lang.name, output_lang.n_words)


# 形成训练集,首先,打乱所有句子的顺序
random_idx = np.random.permutation(range(len(pairs)))
pairs = [pairs[i] for i in random_idx]

# 将语言转变为单词的编码构成的序列
pairs = [indexFromPair(pair) for pair in pairs]

# 形成训练集、校验集和测试集
valid_size = len(pairs) // 10
if valid_size > 10000:
    valid_size = 10000

valid_pairs = pairs[-valid_size : -valid_size // 2]
test_pairs = pairs[- valid_size // 2 :]
pairs = pairs[ : - valid_size]

# 利用PyTorch的dataset和dataloader对象,将数据加载到加载器里面,并且自动分批

batch_size = 32 #一撮包含30个数据记录,这个数字越大,系统在训练的时候,每一个周期处理的数据就越多,这样处理越快,但总的数据量会减少

print('训练记录:', len(pairs))
print('校验记录:', len(valid_pairs))
print('测试记录:', len(test_pairs))

# 形成训练对列表,用于喂给train_dataset
pairs_X = [pair[0] for pair in pairs]
pairs_Y = [pair[1] for pair in pairs]
valid_X = [pair[0] for pair in valid_pairs]
valid_Y = [pair[1] for pair in valid_pairs]
test_X = [pair[0] for pair in test_pairs]
test_Y = [pair[1] for pair in test_pairs]


# 形成训练集
train_dataset = DataSet.TensorDataset(torch.LongTensor(pairs_X), torch.LongTensor(pairs_Y))
# 形成数据加载器
train_loader = DataSet.DataLoader(train_dataset, batch_size = batch_size, shuffle = True, num_workers=8)


# 校验数据
valid_dataset = DataSet.TensorDataset(torch.LongTensor(valid_X), torch.LongTensor(valid_Y))
valid_loader = DataSet.DataLoader(valid_dataset, batch_size = batch_size, shuffle = True, num_workers=8)

# 测试数据
test_dataset = DataSet.TensorDataset(torch.LongTensor(test_X), torch.LongTensor(test_Y))
test_loader = DataSet.DataLoader(test_dataset, batch_size = batch_size, shuffle = True, num_workers = 8)

4.2 带有注意力机制的解码器模型

带有注意力机制的解码器模型结构

结构看起来挺复杂,但是实际上它与之前实验中的“序列生成模型”是类似的,只不过是多出了一个注意力网络的结构。

# 定义基于注意力的解码器RNN
class AttnDecoderRNN(nn.Module):
    def __init__(self, hidden_size, output_size, n_layers=1, dropout_p=0.1, max_length=MAX_LENGTH):
        super(AttnDecoderRNN, self).__init__()
        self.hidden_size = hidden_size
        self.output_size = output_size
        self.n_layers = n_layers
        self.dropout_p = dropout_p
        self.max_length = max_length

        # 词嵌入层
        self.embedding = nn.Embedding(self.output_size, self.hidden_size)

        # 注意力网络(一个前馈神经网络)
        self.attn = nn.Linear(self.hidden_size * (2 * n_layers + 1), self.max_length)

        # 注意力机制作用完后的结果映射到后面的层
        self.attn_combine = nn.Linear(self.hidden_size * 3, self.hidden_size)

        # dropout操作层
        self.dropout = nn.Dropout(self.dropout_p)


        # 定义一个双向GRU,并设置batch_first为True以方便操作
        self.gru = nn.GRU(self.hidden_size, self.hidden_size, bidirectional = True,
                         num_layers = self.n_layers, batch_first = True)
        self.out = nn.Linear(self.hidden_size * 2, self.output_size)

    def forward(self, input, hidden, encoder_outputs):
        # 解码器的一步操作
        # input大小:batch_size, length_seq
        embedded = self.embedding(input)
        # embedded大小:batch_size, length_seq, hidden_size
        embedded = embedded[:, 0, :]
        # embedded大小:batch_size, hidden_size
        embedded = self.dropout(embedded)

        # 将hidden张量数据转化成batch_size排在第0维的形状
        # hidden大小:direction*n_layer, batch_size, hidden_size
        temp_for_transpose = torch.transpose(hidden, 0, 1).contiguous()
        temp_for_transpose = temp_for_transpose.view(temp_for_transpose.size()[0], -1)
        hidden_attn = temp_for_transpose

        # 注意力层的输入
        # hidden_attn大小:batch_size, direction*n_layers*hidden_size
        input_to_attention = torch.cat((embedded, hidden_attn), 1)
        # input_to_attention大小:batch_size, hidden_size * (1 + direction * n_layers)

        # 注意力层输出的权重
        attn_weights = F.softmax(self.attn(input_to_attention))
        # attn_weights大小:batch_size, max_length

        # 当输入数据不标准的时候,对weights截取必要的一段
        attn_weights = attn_weights[:, : encoder_outputs.size()[1]]
        # attn_weights大小:batch_size, length_seq_of_encoder
        attn_weights = attn_weights.unsqueeze(1)
        # attn_weights大小:batch_size, 1, length_seq 中间的1是为了bmm乘法用的

        # 将attention的weights矩阵乘encoder_outputs以计算注意力完的结果
        # encoder_outputs大小:batch_size, seq_length, hidden_size*direction
        attn_applied = torch.bmm(attn_weights, encoder_outputs) 
        # attn_applied大小:batch_size, 1, hidden_size*direction
        # bmm: 两个矩阵相乘。忽略第一个batch纬度,缩并时间维度

        # 将输入的词向量与注意力机制作用后的结果拼接成一个大的输入向量
        output = torch.cat((embedded, attn_applied[:,0,:]), 1)
        # output大小:batch_size, hidden_size * (direction + 1)

        # 将大输入向量映射为GRU的隐含层
        output = self.attn_combine(output).unsqueeze(1)
        # output大小:batch_size, length_seq, hidden_size
        output = F.relu(output)

        # output的结果再dropout
        output = self.dropout(output)

        # 开始解码器GRU的运算
        output, hidden = self.gru(output, hidden)


        # output大小:batch_size, length_seq, hidden_size * directions
        # hidden大小:n_layers * directions, batch_size, hidden_size

        #取出GRU运算最后一步的结果喂给最后一层全链接层
        output = self.out(output[:, -1, :])
        # output大小:batch_size * output_size

        # 取logsoftmax,计算输出结果
        output = F.log_softmax(output, dim = 1)
        # output大小:batch_size * output_size
        return output, hidden, attn_weights

    def initHidden(self, batch_size):
        # 初始化解码器隐单元,尺寸为n_layers * directions, batch_size, hidden_size
        result = Variable(torch.zeros(self.n_layers * 2, batch_size, self.hidden_size))
        if use_cuda:
            return result.cuda()
        else:
            return result

4.3 训练注意力模型

下面实例化新的模型,注意这里使用的编码器结构与之前是一样的,它会与带注意力的解码器重新训练。

#定义网络架构
hidden_size = 32
max_length = MAX_LENGTH
n_layers = 1
encoder = EncoderRNN(input_lang.n_words, hidden_size, n_layers = n_layers)
decoder = AttnDecoderRNN(hidden_size, output_lang.n_words, dropout_p=0.5,
                         max_length = max_length, n_layers = n_layers)

if use_cuda:
    encoder = encoder.cuda()
    decoder = decoder.cuda()

learning_rate = 0.0001
encoder_optimizer = optim.Adam(encoder.parameters(), lr=learning_rate)
decoder_optimizer = optim.Adam(decoder.parameters(), lr=learning_rate)

criterion = nn.NLLLoss()
teacher_forcing_ratio = 0.5

定义训练函数与验证函数,因为训练的流程没有发生本质的变化,所以这两个函数与之前几乎是相同的。

首先是训练函数:

# 用于记录训练中的损失信息,后面绘制图像用
plot_losses = []

print_loss_total = 0

print_loss_avg = 0

def train_attention_model():
    global plot_losses
    global print_loss_total
    global print_loss_avg
    print_loss_total = 0

    # 对训练数据进行循环
    for data in train_loader:
        input_variable = Variable(data[0]).cuda() if use_cuda else Variable(data[0])
        # input_variable的大小:batch_size, length_seq
        target_variable = Variable(data[1]).cuda() if use_cuda else Variable(data[1])
        # target_variable的大小:batch_size, length_seq

        #清空梯度
        encoder_optimizer.zero_grad()
        decoder_optimizer.zero_grad()

        encoder_hidden = encoder.initHidden(data[0].size()[0])

        loss = 0

        #编码器开始工作
        encoder_outputs, encoder_hidden = encoder(input_variable, encoder_hidden)
        # encoder_outputs的大小:batch_size, length_seq, hidden_size*direction
        # encoder_hidden的大小:direction*n_layer, batch_size, hidden_size

        # 解码器开始工作
        decoder_input = Variable(torch.LongTensor([[SOS_token]] * target_variable.size()[0]))
        # decoder_input大小:batch_size, length_seq
        decoder_input = decoder_input.cuda() if use_cuda else decoder_input

        # 将编码器的隐含层单元取值作为编码的结果传递给解码器
        decoder_hidden = encoder_hidden
        # decoder_hidden大小:direction*n_layer, batch_size, hidden_size

        # 同时按照两种方式训练解码器:用教师监督的信息作为下一时刻的输入和不用监督的信息,用自己预测结果作为下一时刻的输入
        use_teacher_forcing = True if random.random() < teacher_forcing_ratio else False
        if use_teacher_forcing:
            # 用监督信息作为下一时刻解码器的输入
            # 开始时间不得循环
            for di in range(MAX_LENGTH):
                # 输入给解码器的信息包括输入的单词decoder_input, 解码器上一时刻预测的单元状态,
                # 编码器各个时间步的输出结果
                decoder_output, decoder_hidden, decoder_attention = decoder(
                    decoder_input, decoder_hidden, encoder_outputs)
                #decoder_ouput大小:batch_size, output_size
                #计算损失函数,得到下一时刻的解码器的输入
                loss += criterion(decoder_output, target_variable[:, di])
                decoder_input = target_variable[:, di].unsqueeze(1)  # Teacher forcing
                # decoder_input大小:batch_size, length_seq
        else:
            # 没有教师监督,用解码器自己的预测作为下一时刻的输入

            # 对时间步进行循环
            for di in range(MAX_LENGTH):
                decoder_output, decoder_hidden, decoder_attention = decoder(
                    decoder_input, decoder_hidden, encoder_outputs)
                #decoder_ouput大小:batch_size, output_size(vocab_size)
                # 获取解码器的预测结果,并用它来作为下一时刻的输入
                topv, topi = decoder_output.data.topk(1, dim = 1)
                #topi 尺寸:batch_size, k
                ni = topi[:, 0]

                decoder_input = Variable(ni.unsqueeze(1))
                # decoder_input大小:batch_size, length_seq
                decoder_input = decoder_input.cuda() if use_cuda else decoder_input

                # 计算损失函数
                loss += criterion(decoder_output, target_variable[:, di])



        # 反向传播开始
        loss.backward()
        loss = loss.cpu() if use_cuda else loss
        # 开始梯度下降
        encoder_optimizer.step()
        decoder_optimizer.step()
        print_loss_total += loss.data.numpy()[0]

    print_loss_avg = print_loss_total / len(train_loader)

然后是验证函数:

valid_loss = 0
rights = []

def evaluation_attention_model():
    global valid_loss
    global rights
    valid_loss = 0
    rights = []

    #对所有的校验数据做循环
    for data in valid_loader:
        input_variable = Variable(data[0]).cuda() if use_cuda else Variable(data[0])
        # input_variable的大小:batch_size, length_seq
        target_variable = Variable(data[1]).cuda() if use_cuda else Variable(data[1])
        # target_variable的大小:batch_size, length_seq

        encoder_hidden = encoder.initHidden(data[0].size()[0])

        loss = 0
        encoder_outputs, encoder_hidden = encoder(input_variable, encoder_hidden)
        # encoder_outputs的大小:batch_size, length_seq, hidden_size*direction
        # encoder_hidden的大小:direction*n_layer, batch_size, hidden_size

        decoder_input = Variable(torch.LongTensor([[SOS_token]] * target_variable.size()[0]))
        # decoder_input大小:batch_size, length_seq
        decoder_input = decoder_input.cuda() if use_cuda else decoder_input

        decoder_hidden = encoder_hidden
        # decoder_hidden大小:direction*n_layer, batch_size, hidden_size

        # 开始每一步的预测
        for di in range(MAX_LENGTH):
            decoder_output, decoder_hidden, decoder_attention = decoder(
                decoder_input, decoder_hidden, encoder_outputs)
            #decoder_ouput大小:batch_size, output_size(vocab_size)
            topv, topi = decoder_output.data.topk(1, dim = 1)
            #topi 尺寸:batch_size, k
            ni = topi[:, 0]

            decoder_input = Variable(ni.unsqueeze(1))
            # decoder_input大小:batch_size, length_seq
            decoder_input = decoder_input.cuda() if use_cuda else decoder_input
            right = rightness(decoder_output, target_variable[:, di])
            rights.append(right)
            loss += criterion(decoder_output, target_variable[:, di])
        loss = loss.cpu() if use_cuda else loss
        valid_loss += loss.data.numpy()[0]

下面开始训练模型。

注意这个带注意力机制的模型训练速度是真的非常慢,100 个训练周期即使是在 GPU 上训练,也需要 14 个小时左右。所以大家完全不用执行下面的训练代码,网盘中提供了训练完毕的模型可以加载使用。

# 开始带有注意力机制的RNN训练

num_epoch = 100

# 开始训练周期循环
plot_losses = []
for epoch in range(num_epoch):
    # 将解码器置于训练状态,让dropout工作
    decoder.train()

    print_loss_total = 0
    # 调用训练函数
    train_attention_model()

    # 将解码器置于测试状态,关闭dropout
    decoder.eval()
    # 调用模型验证函数
    evaluation_attention_model()

    # 计算平均损失、准确率等指标并打印输出
    right_ratio = 1.0 * np.sum([i[0] for i in rights]) / np.sum([i[1] for i in rights])
    print('进程:%d%% 训练损失:%.4f,校验损失:%.4f,词正确率:%.2f%%' % (epoch * 1.0 / num_epoch * 100, 
                                                    print_loss_avg,
                                                    valid_loss / len(valid_loader),
                                                    100.0 * right_ratio))
    plot_losses.append([print_loss_avg, valid_loss / len(valid_loader), right_ratio])

请执行以下代码以加载训练过的模型:

encoder = torch.load('Encoder_final_cpu.mdl')
decoder = torch.load('Decoder_final_cpu.mdl')

下面绘制出训练曲线,以观察模型在训练过程中的准确率变化。

a = [i[0] for i in plot_losses]
b = [i[1] for i in plot_losses]
c = [i[2] * 100 for i in plot_losses]
plt.plot(a, '-', label = 'Training Loss')
plt.plot(b, ':', label = 'Validation Loss')
plt.plot(c, '.', label = 'Accuracy')
plt.xlabel('Epoch')
plt.ylabel('Loss & Accuracy')
plt.legend()

经过 100 轮的训练后,神经翻译模型就已经可以达到了 62% 的词准确率。可以注意到,目前的训练曲线已经展示了过拟合的迹象,即校验集的 Loss 已经大于了训练集,这说明我们的数据量是远远不够的。

接下来在测试集上选择几个例子检验一下这个神经翻译模型的效果:

# 从测试集中随机挑选20个句子来测试翻译的结果
indices = np.random.choice(range(len(test_X)), 20)
for ind in indices:
    data = [test_X[ind]]
    target = [test_Y[ind]]
    print(data[0])
    print(SentenceFromList(input_lang, data[0]))
    input_variable = Variable(torch.LongTensor(data)).cuda() if use_cuda else Variable(torch.LongTensor(data))
    # input_variable的大小:batch_size, length_seq
    target_variable = Variable(torch.LongTensor(target)).cuda() if use_cuda else Variable(torch.LongTensor(target))
    # target_variable的大小:batch_size, length_seq

    encoder_hidden = encoder.initHidden(input_variable.size()[0])

    loss = 0
    encoder_outputs, encoder_hidden = encoder(input_variable, encoder_hidden)
    # encoder_outputs的大小:batch_size, length_seq, hidden_size*direction
    # encoder_hidden的大小:direction*n_layer, batch_size, hidden_size

    decoder_input = Variable(torch.LongTensor([[SOS_token]] * target_variable.size()[0]))
    # decoder_input大小:batch_size, length_seq
    decoder_input = decoder_input.cuda() if use_cuda else decoder_input

    decoder_hidden = encoder_hidden
    # decoder_hidden大小:direction*n_layer, batch_size, hidden_size

    # Without teacher forcing: use its own predictions as the next input
    output_sentence = []
    decoder_attentions = torch.zeros(max_length, max_length)
    rights = []
    for di in range(MAX_LENGTH):
        decoder_output, decoder_hidden, decoder_attention = decoder(
            decoder_input, decoder_hidden, encoder_outputs)
        #decoder_ouput大小:batch_size, output_size(vocab_size)
        topv, topi = decoder_output.data.topk(1, dim = 1)
        decoder_attentions[di] = decoder_attention.data
        #topi 尺寸:batch_size, k
        ni = topi[:, 0]
        decoder_input = Variable(ni.unsqueeze(1))
        ni = ni.numpy()[0]
        output_sentence.append(ni)
        # decoder_input大小:batch_size, length_seq
        decoder_input = decoder_input.cuda() if use_cuda else decoder_input
        right = rightness(decoder_output, target_variable[:, di])
        rights.append(right)
    sentence = SentenceFromList(output_lang, output_sentence)
    standard = SentenceFromList(output_lang, target[0])
    print('机器翻译:', sentence)
    print('标准翻译:', standard)
    # 输出本句话的准确率
    right_ratio = 1.0 * np.sum([i[0] for i in rights]) / np.sum([i[1] for i in rights])
    print('词准确率:', 100.0 * right_ratio)
    print('\n')

观察上面的翻译语句可以发现,有一些翻译错误的语句是连着出现了多个 the 或者 was 或者其它助词。事实上,这是未训练好的神经网络经常表现出来的一种错误方式,即将高频词直接输出出来。它这样做的好处就是可以以较小的概率猜错下一个单词,因为 the、was 等词在英语中出现的频率很高,这样它猜中的准确度也很高。目前的模型还是不能达到理想的翻译效果,其中一个原因上面已经说过了,是训练数据太小了。

另外一个原因在翻译序列的生成这部分。在现有方案中,我们是逐个单词地生成翻译序列的,但是这种做法生成的序列并不够好。于是人们提出了集束搜索(BeamSearch)这种更专业、更好的序列生成方案。关于 BeamSearch 的相关原理可以参考配套书籍中的相关讲解,在这里就不赘述了。

五、总结

接触了目前应用非常广泛的编码器-解码器模型,还了解了注意力机制。机器翻译是一个庞大的工程,研究者们源源不断的提出了各种各样的改进方案,但是这些方案从宏观来讲都没有动摇编码-解码模型的本质要素。并且除了机器翻译,编码器-解码器模型还被应用在其它领域,比如聊天机器人领域。基本上在自然语言处理领域,会经常见到编码器-解码器模型的身影,所以掌握这种模型对我们来说至关重要。

上一篇:How I Wrote a Modern C++ Library in Rust


下一篇:Foobar 2000增加APE播放支持的方法