05 使用RNN联合注意力机制实现机器翻译

本文主要参考了 Translation with a Sequence to Sequence Network and Attention (作者:Sean Robertson),但为方便读者理解,使用了我个人的行文风格和语言组织。

本文将介绍循环神经网络(RNN)的一个重要应用:机器翻译,具体是实现把一句法语翻译成对应的英语。通过阅读本文,您将了解到用RNN实现机器翻译的技术细节、并初步了解注意力机制。

读者在阅读本文前,应对RNN、PyTorch等已有一定的认识,同时如果您对用神经网络进行序列学习有一定了解则更好。您可以通过阅读此专栏之前的文章来熟悉相关知识。

客观地说,用RNN实现商用级的机器翻译是一个很庞大的工程,基本上不是一个个人电脑能够完成的。本文仅在一个很小的语料库的基础上进行训练,旨在介绍RNN如何应用于机器翻译以及类似的序列学习上。即使要完成这样小规模的演示,其工作量也是不可小觑的。我们要完成下列工作:数据准备、模型的理解、选择和构建、训练模型、评估模型等。

下图展示了一个机器翻译模型的工作机制:

整个模型由一个编码器(Encoder)和一个解码器组成(Decoder),两者通过一个上下文向量(Context)连接在一起。编码器负责接受表示法语句子的时序数据,图中对应的法语句子是:“le chat est noir”,该编码器在每一个时间步均接受该句子中的一个词语(例如第二个词chat)对应的词向量(85),直至句子结束。当编码器接收到表示句子结束(<EOS>)对应的词向量(99)时,它会生成一个表示该句子的特征向量(Context),并把该向量送入解码器(Decoder),解码器同时接受一个原始输入(<SOS>)并开始不停地给出该解码器的输出,每一个时间步解码器给出的输出是翻译后的句子中的一个词语在该语料库中对应的词向量,同时该词向量也被再次作为该解码器的输入参与该解码器下一个时间步输出的运算。例如,在上图中,解码器接受到编码器送过来的句子特征向量和<SOS>对应的词向量(00)后,给出了输出42,在语料库中,该数字42对应的英语词语是the。随后输出42被送回该解码器的输入端,解码器接收到该输入后结合Context向量再一次给出输出82,对应英语单词为cat。如此反复,直至解码器输出对应着句子终止符<EOS>的数字99,或者解码器输出词语的数量到达人为设定的上限,解码器终止输出。

从上面的讲解,我们可以看出,为了训练这样一个模型,我们必须要具有“英语”和“法语”两门语言的语料库,也就是词向量。这就是数据准备的核心工作,它负责从原始数据构建两门语言的词向量。由于我们的例子是一个简化的机器翻译模型,我们这里使用的依旧是局部词向量,也就是从使用的语料库中构建词向量,并且我们使用的仍是one-hot的词向量。

接下来我们将进入代码环节,了解数据准备的相关代码实现。

下面的代码声明了我们要使用的各种库、包:

from __future__ import unicode_literals, print_function, division
from io import open # 处理文件
import unicodedata # 处理unicode字符相关事项
import string 
import re # 正则表达式相关
import random

import torch 
import torch.nn as nn
from torch.autograd import Variable
from torch import optim
import torch.nn.functional as F

use_cuda = torch.cuda.is_available() # 如果您的计算机支持cuda,则优先在cuda下运行

数据准备

这里下载所需的训练数据,并把它解压至当前工作目录中。该数据文件是一个较大的文本文件,文本文件的每一行是由一个tab键分开的两句话:一句英语、一句法语组成,如下格式:

I am cold.    Je suis froid.

这是我们整篇文章所要用到的唯一训练数据。我们的数据准备工作要作如下事情:

  • 把文本文件一次性读入内存
  • 对文本中出现的一些特殊字符进行适当处理
  • 分别建立“英语”、“法语”语料库(词向量),并对两库进行一定的修饰,剔除不常用的词、并剔除两库中使用不常用词的句子。
  • 提供后续程序需要的一些功能接口

首先,声明两个变量,表示句子起始和结束的占位符:

SOS_token = 0
EOS_token = 1

我们构建一个类Lang,来全权处理文本数据相关的操作:

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 # 对于已存在于语料库中的词,仅增加其出现次数。

原作者在其原文中还提供了两个辅助方法,来将unicode字符转化为ascii字符,同时对英语、法语句子中存在一些大小写、缩写、连写、特殊字符等现象进行的规范化处理:

# Turn a Unicode string to plain ASCII, thanks to
# http://stackoverflow.com/a/518232/2809427
def unicodeToAscii(s):
    return ''.join(
        c for c in unicodedata.normalize('NFD', s)
        if unicodedata.category(c) != 'Mn'
    )

# Lowercase, trim, and remove non-letter characters
def normalizeString(s):
    s = unicodeToAscii(s.lower().strip())
    s = re.sub(r"([.!?])", r" \1", s)
    s = re.sub(r"[^a-zA-Z.!?]+", r" ", s)
    return s

读者可以通过输入一些感兴趣的字符串来观察这两个函数具体的功能,这里不再赘述,同样如果要使本文的代码支持中文操作,则需要谨慎使用这两个方法。

下面的方法readLangs将根据传入的参数,寻找对应的文本文件,读取文件中的内容,行程一系列语句对,并构建两个Lang对象,方便进一步处理:

def readLangs(lang1, lang2, reverse=False): # lang1,lang2仅是字符串,代表对应的语言
    print("Reading lines...")
    # Read the file and split into lines
    lines = open('data/%s-%s.txt' % (lang1, lang2), encoding='utf-8').\
        read().strip().split('\n')
    # 把文本文件变为语句对列表
    # Split every line into pairs and normalize
    pairs = [[normalizeString(s) for s in l.split('\t')] for l in lines]

    # 提供一个反向的操作,即原来是英文->法语,使用reverse后则为法语->英语
    # Reverse pairs, make Lang instances
    if reverse:
        pairs = [list(reversed(p)) for p in pairs]
        input_lang = Lang(lang2)
        output_lang = Lang(lang1)
    else:
        input_lang = Lang(lang1)
        output_lang = Lang(lang2)

    return input_lang, output_lang, pairs

原文中,作者还提供了一些辅助方法,用于从总的文本数据中筛选出感兴趣的数据来进行训练,读者可以根据自己的兴趣决定是否使用下面这两个方法:

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): # 作者仅对训练数据中句子长度都小于10,且以一定字符串开头的英文句子感兴趣
    return len(p[0].split(' ')) < MAX_LENGTH and \
        len(p[1].split(' ')) < MAX_LENGTH and \
        p[1].startswith(eng_prefixes)

def filterPairs(pairs): # 从所有pairs中选出作者感兴趣的pair
    return [pair for pair in pairs if filterPair(pair)]

有了之前的一系列铺垫,我们就可以完成数据准备的核心工作了:

def prepareData(lang1, lang2, reverse=False):

    # 构建两个语料库
    input_lang, output_lang, pairs = readLangs(lang1, lang2, reverse)
    print("Read %s sentence pairs" % len(pairs))
    pairs = filterPairs(pairs) # 筛选感兴趣的语句对
    print("Trimmed to %s sentence pairs" % len(pairs))
    print("Counting words...") # 统计词频
    for pair in pairs:
        input_lang.addSentence(pair[0])
        output_lang.addSentence(pair[1])
    print("Counted words:")
    print(input_lang.name, input_lang.n_words)
    print(output_lang.name, output_lang.n_words)
    return input_lang, output_lang, pairs

input_lang, output_lang, pairs = prepareData('eng', 'fra', True)
print(random.choice(pairs))

上面额代码输出如下信息:

Reading lines...
Read 135842 sentence pairs
Trimmed to 10853 sentence pairs
Counting words...
Counted words:
fra 4489
eng 2925
['je suis daltonien .', 'i m color blind .']

至此,数据准备工作已经完成,我们得到了两个语料库,并且有能力获取到训练数据中出现的我们感兴趣的英语句子及其对应的法语翻译(或者反过来),接下来就是构建机器翻译模型了。


序列模型

在本文的一开始,我们已经简要介绍了用于机器翻译等序列学习模型的构成:一个解码器和一个编码器。两者都是一个循环神经网络,两者通过一个上下文特征向量来链接。该模型对应的是我在文章:01 序列模型和基于LSTM的循环神经网络中提到的四类循环神经网络类型中的第三类:延时的多对多模型。也就是编码器不停的接受时序数据输入,对其进行编码,当输入完毕后,将编码结果传递给解码器,同时解码器开始给出时序数据输出。

可以看得出,如果您理解了单个RNN的机构和运作方式,那么理解这个由两个RNN组成的序列模型其实应该是很简单的,重点放在链接这两个模型的Context向量上就可以了。不过这里我们还是简单的过一遍单个编码器和解码器的结构。

  • 编码器

下图是编码器的内部结构:

读者如果想详细了解单个RNN内部的结构可以参考我之前的一片文章:02 用字符级别的循环神经网络来判断一个人的名字是哪个国家的常用名。该编码器的代码如下:

class EncoderRNN(nn.Module):
    def __init__(self, input_size, hidden_size, n_layers=1):
        super(EncoderRNN, self).__init__()
        self.n_layers = n_layers # 可以使用多层gru操作,默认只使用一层
        self.hidden_size = hidden_size # 隐藏层的尺寸,如何设定参考后续代码

        self.embedding = nn.Embedding(input_size, hidden_size) # 词向量
        self.gru = nn.GRU(hidden_size, hidden_size) # 记忆结构

    def forward(self, input, hidden):
        embedded = self.embedding(input).view(1, 1, -1) 
        output = embedded 
        for i in range(self.n_layers):
            output, hidden = self.gru(output, hidden)
        return output, hidden

    def initHidden(self):
        result = Variable(torch.zeros(1, 1, self.hidden_size))
        if use_cuda:
            return result.cuda()
        else:
            return result
  • 解码器一——简单解码器

解码器同样也是一个RNN,它接受来自编码器的Context向量并且输出一些列词语对应的向量序列来生成翻译后的句子。近期在解码器的构造中有一些进展,那就是引入了注意力机制来生成更出色的翻译结果。本文将同时介绍两种解码器:传统的简单解码器,和引入了注意力机制的解码器。本篇将仅描述注意力机制在机器翻译中的应用,我将单独写一篇文章来详细探究注意力机制。在介绍包含注意力机制的解码器之前,我们还是先了解下传统的不包含注意力机制额解码器的结构,如下图:

在每一个解码时间步中,解码器将会得到一个输入数据和一个隐藏层状态数据。其中最初始的输入数据是一个标记句子开始的占位符<SOS>,最初始的隐藏层状态数据是上下文向量,即来自于编码器的最后一次隐藏层状态数据。

上面的这段话明确了在简单解码器中:解码器和编码器之间的联系方式。上图所示的简单解码器代码是这样的:

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)
        self.gru = nn.GRU(hidden_size, hidden_size)
        self.out = nn.Linear(hidden_size, output_size)
        # 由于输出是语料库中词语的概率,选最大概率的索引对应的词,
        # 所以需要一个类softmax操作
        self.softmax = nn.LogSoftmax()

    def forward(self, input, hidden):
        output = self.embedding(input).view(1, 1, -1)
        for i in range(self.n_layers):
            output = F.relu(output)
            output, hidden = self.gru(output, hidden)
        output = self.softmax(self.out(output[0]))
        return output, hidden

    def initHidden(self):
        result = Variable(torch.zeros(1, 1, self.hidden_size))
        if use_cuda:
            return result.cuda()
        else:
            return result
  • 解码器二——注意力机制解码器

相比于简单解码器仅把编码器的最后一次隐藏层状态数据发送给解码器,基于注意力机制的解码器需要的数据要更多一些。我们可以想象一下,如果仅把编码器最后一次的隐藏层状态数据交给解码器,那么这个隐藏层数据承担着整个翻译句子的重任,它能很好的胜任吗?基于这个疑虑,我们尝试着把编码器所有时间步的隐藏状态数据都送入解码器,并且让解码器根据自身的一套机制(注意力机制)决定如何使用这些数据来生成最后的输出。

注意力机制允许解码器网络专注于编码器总体输出的某一个或一些时间步输出状态数据。具体来说,我们维护一张注意力权重矩阵,通过将编码器的输出与该注意力矩阵进行矩阵乘法,来得到一个基于注意力权重的输出(在下文的代码中,该输出的名字为attn_applied),这样的输出可以帮助解码器在不同的时间步里更好的选择给出的翻译词语。

计算注意力权重这个过程是通过另一个前向层(attn)完成的,它使用解码器的输入和隐藏状态作为输入。由于不同的待翻译的句子长度不一样,而为了计算方便,我们需要维护一张静态尺寸的权重矩阵,为此我们设置一个待翻译句子的最大长度上限。对于那些较短的句子,解码器只使用权重矩阵中前面一些数据。

读者可能对于注意力机制还不是很能理解,没有关系,当前您只需对此有大概印象即可,知道该解码器的输入和输出形式即可,我将会专门写一篇文章来详细介绍注意力机制。

基于注意力机制的解码器其内部结构如下图:

此图已经相当复杂了,此图中还有两条反馈线条没有绘出,也就是该网络的输出(output)将作下一时刻网络的输入(input),同样网络当前输出的隐藏层状态(hidden)将作为下一时刻网络隐藏层状态输入的数据。读者在阅读此图时要在脑海中构建这两个反馈线条。

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, self.max_length)
        self.attn_combine = nn.Linear(self.hidden_size * 2, self.hidden_size)
        self.dropout = nn.Dropout(self.dropout_p)
        self.gru = nn.GRU(self.hidden_size, self.hidden_size)
        self.out = nn.Linear(self.hidden_size, self.output_size)

    def forward(self, input, hidden, encoder_output, encoder_outputs):
        embedded = self.embedding(input).view(1, 1, -1)
        embedded = self.dropout(embedded)

        attn_weights = F.softmax(
            self.attn(torch.cat((embedded[0], hidden[0]), 1)))
        attn_applied = torch.bmm(attn_weights.unsqueeze(0),
                                 encoder_outputs.unsqueeze(0))

        output = torch.cat((embedded[0], attn_applied[0]), 1)
        output = self.attn_combine(output).unsqueeze(0)

        for i in range(self.n_layers):
            output = F.relu(output)
            output, hidden = self.gru(output, hidden)

        output = F.log_softmax(self.out(output[0]))
        return output, hidden, attn_weights

    def initHidden(self):
        result = Variable(torch.zeros(1, 1, self.hidden_size))
        if use_cuda:
            return result.cuda()
        else:
            return result

我们暂时不去详细了解该模型内部构成,我们把注意力关注在其初始化参数和随后在训练时如何使用EncoderRNN和AttnDecoderRNN上。我们先看看最终在使用EncoderRNN和AttnDecoderRNN时向其传入的是怎样的参数:

hidden_size = 256
encoder1 = EncoderRNN(input_lang.n_words, hidden_size)
attn_decoder1 = AttnDecoderRNN(hidden_size, output_lang.n_words,
                               1, dropout_p=0.1)

通过上面这三行代码,我们可以看出,在生成编码器和基于注意力机制的解码器对象时,我们是人为指定了两个模块的隐藏层状态数目均为256。对于编码器来说其另一个参数:输入数据的尺寸是待翻译句子所在的语料库中词语的数量,这种设置体现了其使用的是one-hot词向量;对于解码器,其另一个重要的参数对应的是已翻译的句子所在的语料库中词语的数量,同样是基于one-hot词向量的,但请注意这两个语料库中词语的数量通常并不相等。从这里我们也看出我们在设计Lang类时需要统计一个语料库词汇量的大小。

同时观察两类解码器的forward方法,我们发现基于注意力机制的解码器接受参数不仅仅是编码器的最后一次中间状态数据,还包含额外的其他参数,我们将在后续详细解释这些多出来的参数。

训练模型

  • 准备训练数据

准备训练数据比较简单,主要是生成网络需要使用(输入、输出)的词向量序列,把他们编程PyTorch变量即可,这一段就不介绍了。具体代码如下:

def indexesFromSentence(lang, sentence):
    return [lang.word2index[word] for word in sentence.split(' ')]


def variableFromSentence(lang, sentence):
    indexes = indexesFromSentence(lang, sentence)
    indexes.append(EOS_token)
    result = Variable(torch.LongTensor(indexes).view(-1, 1))
    if use_cuda:
        return result.cuda()
    else:
        return result

def variablesFromPair(pair):
    input_variable = variableFromSentence(input_lang, pair[0])
    target_variable = variableFromSentence(output_lang, pair[1])
    return (input_variable, target_variable)
  • 训练模型

训练模型的过程体现了我们如何使用刚才的编码器和解码器对象,这段代码要仔细阅读理解。为便于理解,我把对这段代码的解释全写在代码注释中:

teacher_forcing_ratio = 0.5 # 解释见后

def train(input_variable, target_variable, encoder, decoder, encoder_optimizer, decoder_optimizer, criterion, max_length=MAX_LENGTH):
    # 初始化编码器的隐藏层状态    
    encoder_hidden = encoder.initHidden()
    # 清除编码器、解码器的梯度数据,准备接受下一次的梯度数据
    encoder_optimizer.zero_grad()
    decoder_optimizer.zero_grad()
    # 待翻译句子和已翻译句子的长度(即组成句子的词语的数量)
    input_length = input_variable.size()[0]
    target_length = target_variable.size()[0]
    # 建立一个编码器输出的PyTorch变量,注意命名是-s结尾,表示
    # 该变量保存了Encoder每一次中间状态数据,而不是最后一次中间状态。
    encoder_outputs = Variable(torch.zeros(max_length, encoder.hidden_size))
    # 如果使用cuda,则再包装一下
    encoder_outputs = encoder_outputs.cuda() if use_cuda else encoder_outputs

    loss = 0
    # 编码器的编码过程
    for ei in range(input_length):
        encoder_output, encoder_hidden = encoder(
            input_variable[ei], encoder_hidden)
        encoder_outputs[ei] = encoder_output[0][0]
    # 通过编码过程,得到了编码器的每一次中间状态数据

    # 给解码器准备最初的输入,是一个开始占位符
    decoder_input = Variable(torch.LongTensor([[SOS_token]]))
    decoder_input = decoder_input.cuda() if use_cuda else decoder_input

    # 解码器初始的输入就是编码器最后一次中间层状态数据
    decoder_hidden = encoder_hidden

    # 该变量表明是否在每一次输出时都是用目标正确输出来计算损失
    use_teacher_forcing = True if random.random() < teacher_forcing_ratio else False

    if use_teacher_forcing:
        # 条件为真时,使用正确的输出作为下一时刻解码器的输入来循环计算
        # Teacher forcing: Feed the target as the next input
        for di in range(target_length):
            # decoder解码器具体实施的过程,确定其输出、隐藏层状态、以及注意力数据
            # decoder的forward方法会动态的确定decoder_attention数据
            decoder_output, decoder_hidden, decoder_attention = decoder(
                decoder_input, decoder_hidden, encoder_output, encoder_outputs)
            # 更新损失
            loss += criterion(decoder_output, target_variable[di])
            # 确定下一时间步的解码器输入
            decoder_input = target_variable[di]  # Teacher forcing

    else:
        # 条件不为真时,使用解码器自身预测的输出来作为下一时刻解码器的输入来循环计算
        # Without teacher forcing: use its own predictions as the next input
        for di in range(target_length):
            decoder_output, decoder_hidden, decoder_attention = decoder(
                decoder_input, decoder_hidden, encoder_output, encoder_outputs)
            topv, topi = decoder_output.data.topk(1)
            ni = topi[0][0]

            decoder_input = Variable(torch.LongTensor([[ni]]))
            decoder_input = decoder_input.cuda() if use_cuda else decoder_input

            loss += criterion(decoder_output, target_variable[di])
            if ni == EOS_token:
                break
    # 反向传递损失
    loss.backward()
    # 更新整个网络模型的参数
    encoder_optimizer.step()
    decoder_optimizer.step()
    # 该方法是训练过程,训练过程仅输出了训练的损失,并不提供翻译得到的句子,会有专门
    # 的方法来实施翻译过程。
    return loss.data[0] / target_length

再写两个计时用的辅助函数:

import time
import math

def asMinutes(s):
    m = math.floor(s / 60)
    s -= m * 60
    return '%dm %ds' % (m, s)

def timeSince(since, percent):
    now = time.time()
    s = now - since
    es = s / (percent)
    rs = es - s
    return '%s (- %s)' % (asMinutes(s), asMinutes(rs))

前面的训练过程针对的是在少量数据情况下的一次训练过程,为了迭代在训练集上进行训练,并持续跟踪loss,我们编写下面的方法:

def trainIters(encoder, decoder, n_iters, print_every=1000, plot_every=100, learning_rate=0.01):
    start = time.time() # 启动计时
    plot_losses = []    # 保存需要绘制的loss
    print_loss_total = 0  # Reset every print_every 设置loss采样频率
    plot_loss_total = 0  # Reset every plot_every
    # 声明两个RNN的优化器
    encoder_optimizer = optim.SGD(encoder.parameters(), lr=learning_rate)
    decoder_optimizer = optim.SGD(decoder.parameters(), lr=learning_rate)
    # 得到训练使用的数据
    training_pairs = [variablesFromPair(random.choice(pairs))
                      for i in range(n_iters)]
    # 损失计算方法
    criterion = nn.NLLLoss()

    # 循环训练,迭代的次数
    for iter in range(1, n_iters + 1):
        training_pair = training_pairs[iter - 1]
        input_variable = training_pair[0]
        target_variable = training_pair[1]

        loss = train(input_variable, target_variable, encoder,
                     decoder, encoder_optimizer, decoder_optimizer, criterion)
        print_loss_total += loss
        plot_loss_total += loss

        if iter % print_every == 0:
            print_loss_avg = print_loss_total / print_every
            print_loss_total = 0
            print('%s (%d %d%%) %.4f' % (timeSince(start, iter / n_iters),
                                         iter, iter / n_iters * 100, print_loss_avg))

        if iter % plot_every == 0:
            plot_loss_avg = plot_loss_total / plot_every
            plot_losses.append(plot_loss_avg)
            plot_loss_total = 0

    showPlot(plot_losses)

下面的代码用来绘制loss

import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
import numpy as np

def showPlot(points):
    plt.figure()
    fig, ax = plt.subplots()
    # this locator puts ticks at regular intervals
    loc = ticker.MultipleLocator(base=0.2)
    ax.yaxis.set_major_locator(loc)
    plt.plot(points)

评估

评估的过程就是使用训练过的网络模型,给出一个句子作为模型的输入,观察模型的输出和目标输出之间额差别,代码如下:

def evaluate(encoder, decoder, sentence, max_length=MAX_LENGTH):
    # 把sentence转化为网络可以接受的输入,同时初始化编码器的隐藏状态
    input_variable = variableFromSentence(input_lang, sentence)
    input_length = input_variable.size()[0]
    encoder_hidden = encoder.initHidden()
    # 准备编码器输出变量
    encoder_outputs = Variable(torch.zeros(max_length, encoder.hidden_size))
    encoder_outputs = encoder_outputs.cuda() if use_cuda else encoder_outputs

    # 得到编码的输出
    for ei in range(input_length):
        encoder_output, encoder_hidden = encoder(input_variable[ei],
                                                 encoder_hidden)
        encoder_outputs[ei] = encoder_outputs[ei] + encoder_output[0][0]

    # 准备解码器输出的变量
    decoder_input = Variable(torch.LongTensor([[SOS_token]]))  # SOS
    decoder_input = decoder_input.cuda() if use_cuda else decoder_input

    # 编码器和解码器之间的桥梁:Context
    decoder_hidden = encoder_hidden
    # 准备一个列表来保存网络预测的词语
    decoded_words = []
    # 准备一个变量保存解码过程中产生的注意力数据
    decoder_attentions = torch.zeros(max_length, max_length)

    # 解码过程,有一个最大长度限制
    for di in range(max_length):
        decoder_output, decoder_hidden, decoder_attention = decoder(
            decoder_input, decoder_hidden, encoder_output, encoder_outputs)
        
        decoder_attentions[di] = decoder_attention.data
        topv, topi = decoder_output.data.topk(1)
        ni = topi[0][0]
        if ni == EOS_token:
            decoded_words.append('<EOS>')
            break
        else:
            decoded_words.append(output_lang.index2word[ni])
        # 解码器的输出作为其输入
        decoder_input = Variable(torch.LongTensor([[ni]]))
        decoder_input = decoder_input.cuda() if use_cuda else decoder_input
    # 返回预测的单词,以及注意力机制(供分析注意力机制)
    return decoded_words, decoder_attentions[:di + 1]

我们可以编写一个方法来批量随机地观察网络的预测:

def evaluateRandomly(encoder, decoder, n=10):
    for i in range(n):
        pair = random.choice(pairs)
        print('>', pair[0])
        print('=', pair[1])
        output_words, attentions = evaluate(encoder, decoder, pair[0])
        output_sentence = ' '.join(output_words)
        print('<', output_sentence)
        print('')

最后我们启动网络的训练过程,并在训练结束后评估该网络,代码如下:

# 下面的三行代码在之前介绍过
# hidden_size = 256
# encoder1 = EncoderRNN(input_lang.n_words, hidden_size)
# attn_decoder1 = AttnDecoderRNN(hidden_size, output_lang.n_words,
                               1, dropout_p=0.1)
# 支持cuda计算
if use_cuda:
    encoder1 = encoder1.cuda()
    attn_decoder1 = attn_decoder1.cuda()

# 核心的训练代码仅此一句
trainIters(encoder1, attn_decoder1, 75000, print_every=5000)

训练过程的输出可以是这样:

3m 33s (- 49m 49s) (5000 6%) 2.8480
7m 3s (- 45m 53s) (10000 13%) 2.3235
10m 34s (- 42m 18s) (15000 20%) 1.9944
14m 6s (- 38m 48s) (20000 26%) 1.7494
17m 38s (- 35m 17s) (25000 33%) 1.5772
21m 11s (- 31m 47s) (30000 40%) 1.4095
24m 45s (- 28m 17s) (35000 46%) 1.2621
28m 19s (- 24m 46s) (40000 53%) 1.1148
31m 52s (- 21m 14s) (45000 60%) 1.0271
35m 27s (- 17m 43s) (50000 66%) 0.9479
39m 1s (- 14m 11s) (55000 73%) 0.8658
42m 36s (- 10m 39s) (60000 80%) 0.7852
46m 10s (- 7m 6s) (65000 86%) 0.7437
49m 46s (- 3m 33s) (70000 93%) 0.6534
53m 21s (- 0m 0s) (75000 100%) 0.6378

训练过程的损失绘制成图可以是这样:

评估网络的代码:

evaluateRandomly(encoder1, attn_decoder1)

其输出可以是这样:

> ils n ont pas peur de la mort .
= they aren t afraid of death .
< they aren t afraid of death . <EOS>

> ils sont chretiens .
= they are christians .
< they re christians . <EOS>

> vous etes redevable de la dette .
= you are liable for the debt .
< you are liable about the chair . <EOS>

> tu n es pas suffisament age pour boire .
= you re not old enough to drink .
< you are not old enough to drink . <EOS>

> je vais attraper le bus suivant .
= i m going to catch the next bus .
< i m going to the concert . . <EOS>

> c est une boule de nerfs .
= he s a nervous wreck .
< he s a old guy . <EOS>

> elle disait qu elle avait ete heureuse .
= she said that she had been happy .
< she said she had been been happy . <EOS>

> je ne fais pas le moindre projet .
= i m not making any plans .
< i m not making any plans . <EOS>

> nous sommes en train d ecouter la radio .
= we are listening to the radio .
< we are listening to the door . <EOS>

> je suis benie .
= i m blessed .
< i m stubborn . <EOS>

至此,基于RNN编码器和基于注意力机制的RNN解码器构建的简单的机器翻译模型就介绍完毕了。关于注意力机制的可视化及其背后的原理,我们将在下文介绍。


后记

本文信息量较大,为了更好的理解本文,我建议读者仔细阅读源代码,读者可不必按照本文组织的代码次序来阅读,建议先从生成EncoderRNN,AttnDecoderRNN类的两个对象的语句入手,然后仔细阅读训练模型的核心方法train,再去了解编码器和解码器的具体实现机制forward方法,最后关注那些辅助方法。在理解这些方法过程中,特别要理清网络模型中的参数,特别是张量参数的尺寸大小,这是非常重要的。

上述基于编码器RNN和解码器RNN的序列模型不仅可以用来进行机器翻译,还可以用来进行对话系统、问答系统的开发。值得注意的是,如果你使用的输入和输出是同样的句子,那么训练得到的模型是一个基于时序数据的自编码器模型,这个在开发别的语言翻译系统以及开发问答系统中是非常重要的一环,具体可参考我在文章:浅谈强人工智能的瓶颈和可能的努力 里提到的一个问答系统模型。

同样上述模型仅仅是一个简化的模型,为了能够让其更加实用,我们可以用已经训练好的词向量来代替embedding过程;也可以通过增加一些层来进行复杂的翻译过程的训练。

最后,我对本文出现的代码进行了重新组织,将所有的代码根据用途分为data,model和train三个文件,读者可以在这里下载重新组织后的代码。

编辑于 2017-08-28 08:47