NLP-基于机器翻译介绍编码解码器和注意力机制

目录

相关知识点定义

机器翻译

编码器

解码器

贪婪搜索

 穷举搜索

束搜索

强制教学

注意力机制

计算背景变量

矢量化计算

更新状态

机器翻译

预处理

 编码器

注意力机制

解码器

训练模型

损失函数

训练

预测不定长序列

评价翻译结果 

总结


因为一段文本序列在不同语言中的长度不一定相同,所以我们使用机器翻译为例来介绍编码器—解码器和注意力机制的应用。

相关知识点定义

机器翻译

是指将一段文本从一种语言自动翻译到另一种语言

编码器

编码器的作用是把一个不定长的输入序列变换成一个定长的背景变量\boldsymbol{c},并在该背景变量中编码输入序列信息。

让我们考虑批量大小为1的时序数据样本。假设输入序列是x_1,\ldots,x_T,例如x_i是输入句子中的第i个词。在时间步t,循环神经网络将输入$x_t$的特征向量\boldsymbol{x}_t和上个时间步的隐藏状态\boldsymbol{h}_{t-1}变换为当前时间步的隐藏状态\boldsymbol{h}_t。我们可以用函数$f$表达循环神经网络隐藏层的变换:

\boldsymbol{h}_t = f(\boldsymbol{x}_t, \boldsymbol{h}_{t-1}).

接下来,编码器通过自定义函数$q$将各个时间步的隐藏状态变换为背景变量

\boldsymbol{c} = q(\boldsymbol{h}_1, \ldots, \boldsymbol{h}_T).

例如,当选择q(\boldsymbol{h}_1, \ldots, \boldsymbol{h}_T) = \boldsymbol{h}_T时,背景变量是输入序列最终时间步的隐藏状态\boldsymbol{h}_T

以上描述的编码器是一个单向的循环神经网络,每个时间步的隐藏状态只取决于该时间步及之前的输入子序列。我们也可以使用双向循环神经网络构造编码器。在这种情况下,编码器每个时间步的隐藏状态同时取决于该时间步之前和之后的子序列(包括当前时间步的输入),并编码了整个序列的信息。

解码器

刚刚已经介绍,编码器输出的背景变量\boldsymbol{c}编码了整个输入序列x_1, \ldots, x_T的信息。给定训练样本中的输出序列y_1, y_2, \ldots, y_{T'},对每个时间步t'(符号与输入序列或编码器的时间步$t$有区别),解码器输出$y_{t'}$的条件概率将基于之前的输出序列y_1,\ldots,y_{t'-1}和背景变量$\boldsymbol{c}$,即P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \boldsymbol{c})

为此,我们可以使用另一个循环神经网络作为解码器。在输出序列的时间步t^\prime,解码器将上一时间步的输出y_{t^\prime-1}以及背景变量\boldsymbol{c}作为输入,并将它们与上一时间步的隐藏状态\boldsymbol{s}_{t^\prime-1}变换为当前时间步的隐藏状态\boldsymbol{s}_{t^\prime}。因此,我们可以用函数$g$表达解码器隐藏层的变换:

\boldsymbol{s}_{t^\prime} = g(y_{t^\prime-1}, \boldsymbol{c}, \boldsymbol{s}_{t^\prime-1}).

有了解码器的隐藏状态后,我们可以使用自定义的输出层和softmax运算来计算P(y_{t^\prime} \mid y_1, \ldots, y_{t^\prime-1}, \boldsymbol{c}),例如,基于当前时间步的解码器隐藏状态 \boldsymbol{s}_{t^\prime}、上一时间步的输出y_{t^\prime-1}以及背景变量\boldsymbol{c}来计算当前时间步输出y_{t^\prime}的概率分布。

贪婪搜索

为了便于讨论,假设解码器的输出是一段文本序列。设输出文本词典\mathcal{Y}(包含特殊符号"<eos>")的大小为\left|\mathcal{Y}\right|,输出序列的最大长度为T'。所有可能的输出序列一共有\mathcal{O}(\left|\mathcal{Y}\right|^{T'})种。这些输出序列中所有特殊符号"<eos>"后面的子序列将被舍弃。

对于输出序列任一时间步$t'$,我们从$|\mathcal{Y}|$个词中搜索出条件概率最大的词

y _ { t ^ { \prime } } = \underset { y \in \mathcal { Y } } { \operatorname { argmax } } P \left( y | y _ { 1 } , \ldots , y _ { t ^ { \prime } - 1 } , c \right)

作为输出。一旦搜索出"<eos>"符号,或者输出序列长度已经达到了最大长度T',便完成输出。

我们在描述解码器时提到,基于输入序列生成输出序列的条件概率是\prod_{t'=1}^{T'} P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \boldsymbol{c})。我们将该条件概率最大的输出序列称为最优输出序列。而贪婪搜索的主要问题是不能保证得到最优输出序列。

下面来看一个例子。假设输出词典里面有“A”“B”“C”和“<eos>”这4个词。图10.9中每个时间步下的4个数字分别代表了该时间步生成“A”“B”“C”和“<eos>”这4个词的条件概率。在每个时间步,贪婪搜索选取条件概率最大的词。因此,图10.9中将生成输出序列“A”“B”“C”“<eos>”。该输出序列的条件概率是0.5\times0.4\times0.4\times0.6 = 0.048

 接下来,观察图10.10演示的例子。与图10.9中不同,图10.10在时间步2中选取了条件概率第二大的词“C”。由于时间步3所基于的时间步1和2的输出子序列由图10.9中的“A”“B”变为了图10.10中的“A”“C”,图10.10中时间步3生成各个词的条件概率发生了变化。我们选取条件概率最大的词“B”。此时时间步4所基于的前3个时间步的输出子序列为“A”“C”“B”,与图10.9中的“A”“B”“C”不同。因此,图10.10中时间步4生成各个词的条件概率也与图10.9中的不同。我们发现,此时的输出序列“A”“C”“B”“<eos>”的条件概率是0.5\times0.3\times0.6\times0.6=0.054,大于贪婪搜索得到的输出序列的条件概率。因此,贪婪搜索得到的输出序列“A”“B”“C”“<eos>”并非最优输出序列。

 穷举搜索

如果目标是得到最优输出序列,我们可以考虑穷举搜索(exhaustive search):穷举所有可能的输出序列,输出条件概率最大的序列。

虽然穷举搜索可以得到最优输出序列,但它的计算开销\mathcal{O}(\left|\mathcal{Y}\right|^{T'})很容易过大。例如,当|\mathcal{Y}|=10000T'=10时,我们将评估10000^{10} = 10^{40}个序列:这几乎不可能完成。而贪婪搜索的计算开销是\mathcal{O}(\left|\mathcal{Y}\right|T'),通常显著小于穷举搜索的计算开销。例如,当|\mathcal{Y}|=10000T'=10时,我们只需评估10000\times10=10^5个序列。

束搜索

束搜索是对贪婪搜索的一个改进算法。它有一个束宽超参数。我们将它设为k。在时间步1时,选取当前时间步条件概率最大的$k$个词,分别组成$k$个候选输出序列的首词。在之后的每个时间步,基于上个时间步的$k$个候选输出序列,从k\left|\mathcal{Y}\right|个可能的输出序列中选取条件概率最大的k个,作为该时间步的候选输出序列。最终,我们从各个时间步的候选输出序列中筛选出包含特殊符号“<eos>”的序列,并将它们中所有特殊符号“<eos>”后面的子序列舍弃,得到最终候选输出序列的集合。

束宽为2,输出序列最大长度为3。候选输出序列有A、C、AB、CE、ABD和CED

上图通过一个例子演示了束搜索的过程。假设输出序列的词典中只包含5个元素,即\mathcal{Y} = \{A, B, C, D, E\},且其中一个为特殊符号“<eos>”。设束搜索的束宽等于2,输出序列最大长度为3。在输出序列的时间步1时,假设条件概率P(y_1 \mid \boldsymbol{c})最大的2个词为AC。我们在时间步2时将对所有的y_2 \in \mathcal{Y}都分别计算$P(y_2 \mid A, \boldsymbol{c})$P(y_2 \mid C, \boldsymbol{c}),并从计算出的10个条件概率中取最大的2个,假设为P(B \mid A, \boldsymbol{c})P(E \mid C, \boldsymbol{c})。那么,我们在时间步3时将对所有的$y_3 \in \mathcal{Y}$都分别计算P(y_3 \mid A, B, \boldsymbol{c})P(y_3 \mid C, E, \boldsymbol{c}),并从计算出的10个条件概率中取最大的2个,假设为P(D \mid A, B, \boldsymbol{c})P(D \mid C, E, \boldsymbol{c})。如此一来,我们得到6个候选输出序列:(1)A;(2)C;(3)AB;(4)CE;(5)ABD和(6)CED。接下来,我们将根据这6个序列得出最终候选输出序列的集合。

在最终候选输出序列的集合中,我们取以下分数最高的序列作为输出序列:

\frac{1}{L^\alpha} \log P(y_1, \ldots, y_{L}) = \frac{1}{L^\alpha} \sum_{t'=1}^L \log P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \boldsymbol{c})

其中L为最终候选序列长度,\alpha一般可选为0.75。分母上的L^\alpha是为了惩罚较长序列在以上分数中较多的对数相加项。分析可知,束搜索的计算开销为\mathcal{O}(k\left|\mathcal{Y}\right|T')。这介于贪婪搜索和穷举搜索的计算开销之间。此外,贪婪搜索可看作是束宽为1的束搜索。束搜索通过灵活的束宽$k$来权衡计算开销和搜索质量。

强制教学

在模型训练中,所有输出序列损失的均值通常作为需要最小化的损失函数。在模型预测中,我们需要将解码器在上一个时间步的输出作为当前时间步的输入。与此不同,在训练中我们也可以将标签序列(训练集的真实输出序列)在上一个时间步的标签作为解码器在当前时间步的输入。这叫作强制教学。

注意力机制

注意力机制通过对编码器所有时间步的隐藏状态做加权平均来得到背景变量。解码器在每一时间步调整这些权重,即注意力权重,从而能够在不同时间步分别关注输入序列中的不同部分并编码进相应时间步的背景变量。

编码器—解码器里我们区分了输入序列或编码器的索引$t$与输出序列或解码器的索引t'。该节中,解码器在时间步$t'$的隐藏状态\boldsymbol{s}_{t'} = g(\boldsymbol{y}_{t'-1}, \boldsymbol{c}, \boldsymbol{s}_{t'-1}),其中\boldsymbol{y}_{t'-1}是上一时间步t'-1的输出y_{t'-1}的表征,且任一时间步t'使用相同的背景变量\boldsymbol{c}。但在注意力机制中,解码器的每一时间步将使用可变的背景变量。记\boldsymbol{c}_{t'}是解码器在时间步t'的背景变量,那么解码器在该时间步的隐藏状态可以改写为

\boldsymbol{s}_{t'} = g(\boldsymbol{y}_{t'-1}, \boldsymbol{c}_{t'}, \boldsymbol{s}_{t'-1}).

这里的关键是如何计算背景变量\boldsymbol{c}_{t'}和如何利用它来更新隐藏状态\boldsymbol{s}_{t'}。下面将分别描述这两个关键点。

计算背景变量

我们先描述第一个关键点,即计算背景变量。下图描绘了注意力机制如何为解码器在时间步2计算背景变量。首先,函数$a$根据解码器在时间步1的隐藏状态和编码器在各个时间步的隐藏状态计算softmax运算的输入。softmax运算输出概率分布并对编码器各个时间步的隐藏状态做加权平均,从而得到背景变量。


具体来说,令编码器在时间步$t$的隐藏状态为\boldsymbol{h}_t,且总时间步数为T。那么解码器在时间步t'的背景变量为所有编码器隐藏状态的加权平均:

\boldsymbol{c}_{t'} = \sum_{t=1}^T \alpha_{t' t} \boldsymbol{h}_t,

其中给定t'时,权重\alpha_{t' t}t=1,\ldots,T的值是一个概率分布。为了得到概率分布,我们可以使用softmax运算:

\alpha_{t' t} = \frac{\exp(e_{t' t})}{ \sum_{k=1}^T \exp(e_{t' k}) },\quad t=1,\ldots,T.

现在,我们需要定义如何计算上式中softmax运算的输入e_{t' t}。由于e_{t' t}同时取决于解码器的时间步t'和编码器的时间步t,我们不妨以解码器在时间步$t'-1$的隐藏状态\boldsymbol{s}_{t' - 1}与编码器在时间步$t$的隐藏状态\boldsymbol{h}_t为输入,并通过函数a计算e_{t' t}

e_{t' t} = a(\boldsymbol{s}_{t' - 1}, \boldsymbol{h}_t).


这里函数$a$有多种选择,如果两个输入向量长度相同,一个简单的选择是计算它们的内积a(\boldsymbol{s}, \boldsymbol{h})=\boldsymbol{s}^\top \boldsymbol{h}。而最早提出注意力机制的论文则将输入连结后通过含单隐藏层的多层感知机变换 :

a(\boldsymbol{s}, \boldsymbol{h}) = \boldsymbol{v}^\top \tanh(\boldsymbol{W}_s \boldsymbol{s} + \boldsymbol{W}_h \boldsymbol{h}),

其中\boldsymbol{v}\boldsymbol{W}_s\boldsymbol{W}_h都是可以学习的模型参数。

矢量化计算

我们还可以对注意力机制采用更高效的矢量化计算。广义上,注意力机制的输入包括查询项以及一一对应的键项和值项,其中值项是需要加权平均的一组项。在加权平均中,值项的权重来自查询项以及与该值项对应的键项的计算。

在上面的例子中,查询项为解码器的隐藏状态,键项和值项均为编码器的隐藏状态。
让我们考虑一个常见的简单情形,即编码器和解码器的隐藏单元个数均为$h$,且函数a(\boldsymbol{s}, \boldsymbol{h})=\boldsymbol{s}^\top \boldsymbol{h}。假设我们希望根据解码器单个隐藏状态\boldsymbol{s}_{t' - 1} \in \mathbb{R}^{h}和编码器所有隐藏状态\boldsymbol{h}_t \in \mathbb{R}^{h}, t = 1,\ldots,T来计算背景向量\boldsymbol{c}_{t'}\in \mathbb{R}^{h}
我们可以将查询项矩阵\boldsymbol{Q} \in \mathbb{R}^{1 \times h}设为\boldsymbol{s}_{t' - 1}^\top,并令键项矩阵\boldsymbol{K} \in \mathbb{R}^{T \times h}和值项矩阵\boldsymbol{V} \in \mathbb{R}^{T \times h}相同且第t行均为\boldsymbol{h}_t^\top。此时,我们只需要通过矢量化计算

\text{softmax}(\boldsymbol{Q}\boldsymbol{K}^\top)\boldsymbol{V}

即可算出转置后的背景向量\boldsymbol{c}_{t'}^\top。当查询项矩阵\boldsymbol{Q}的行数为$n$时,上式将得到$n$行的输出矩阵。输出矩阵与查询项矩阵在相同行上一一对应。

更新状态

现在我们描述第二个关键点,即更新隐藏状态。以门控循环单元为例,在解码器中我们可以对门控循环单元(GRU)中门控循环单元的设计稍作修改,从而变换上一时间步t'-1的输出\boldsymbol{y}_{t'-1}、隐藏状态\boldsymbol{s}_{t' - 1}和当前时间步$t'$的含注意力机制的背景变量\boldsymbol{c}_{t'}。解码器在时间步t'的隐藏状态为

\boldsymbol{s}_{t'} = \boldsymbol{z}_{t'} \odot \boldsymbol{s}_{t'-1} + (1 - \boldsymbol{z}_{t'}) \odot \tilde{\boldsymbol{s}}_{t'}

其中的重置门、更新门和候选隐藏状态分别为

\begin{aligned} \boldsymbol{r}_{t'} &= \sigma(\boldsymbol{W}_{yr} \boldsymbol{y}_{t'-1} + \boldsymbol{W}_{sr} \boldsymbol{s}_{t' - 1} + \boldsymbol{W}_{cr} \boldsymbol{c}_{t'} + \boldsymbol{b}_r),\\ \boldsymbol{z}_{t'} &= \sigma(\boldsymbol{W}_{yz} \boldsymbol{y}_{t'-1} + \boldsymbol{W}_{sz} \boldsymbol{s}_{t' - 1} + \boldsymbol{W}_{cz} \boldsymbol{c}_{t'} + \boldsymbol{b}_z),\\ \tilde{\boldsymbol{s}}_{t'} &= \text{tanh}(\boldsymbol{W}_{ys} \boldsymbol{y}_{t'-1} + \boldsymbol{W}_{ss} (\boldsymbol{s}_{t' - 1} \odot \boldsymbol{r}_{t'}) + \boldsymbol{W}_{cs} \boldsymbol{c}_{t'} + \boldsymbol{b}_s), \end{aligned}

其中含下标的\boldsymbol{W}\boldsymbol{b}分别为门控循环单元的权重参数和偏差参数。

机器翻译

预处理

首先,我们需要导入相关的库和相关的特殊符号

!tar -xf d2lzh_pytorch.tar
import collections
import os
import io
import math
import torch
from torch import nn
import torch.nn.functional as F
import torchtext.vocab as Vocab
import torch.utils.data as Data

import sys
# sys.path.append("..") 
import d2lzh_pytorch as d2l

PAD, BOS, EOS = '<pad>', '<bos>', '<eos>'#定义特殊符号
os.environ["CUDA_VISIBLE_DEVICES"] = "0"#设置CUDA可见设备为0
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')#检查是否有GPU可用,并设置运行设备为cuda或cpu

print(torch.__version__, device)

接着我们定义两个辅助函数去构造字典

# 将一个序列中所有的词记录在all_tokens中以便之后构造词典,然后在该序列后面添加PAD直到序列
# 长度变为max_seq_len,然后将序列保存在all_seqs中
def process_one_seq(seq_tokens, all_tokens, all_seqs, max_seq_len):
    all_tokens.extend(seq_tokens)#用于记录所有序列的所有词,构造词典
    seq_tokens += [EOS] + [PAD] * (max_seq_len - len(seq_tokens) - 1)#再减去一个EOS长度
    all_seqs.append(seq_tokens)#将处理后的序列添加到all_seqs列表中

# 使用所有的词来构造词典。并将所有序列中的词变换为词索引后构造Tensor
def build_data(all_tokens, all_seqs):
    vocab = Vocab.Vocab(collections.Counter(all_tokens),
                        specials=[PAD, BOS, EOS]) #使用所有的词来构造词典,其中包括特殊标记符号PAD, BOS, EOS
    indices = [[vocab.stoi[w] for w in seq] for seq in all_seqs]#构造张量
    return vocab, torch.tensor(indices)

为了演示方便,我们在这里使用一个很小的法语—英语数据集。在这个数据集里,每一行是一对法语句子和它对应的英语句子,中间使用'\t'隔开。在读取数据时,我们在句末附上“<eos>”符号,并可能通过添加“<pad>”符号使每个序列的长度均为max_seq_len

接着我们要构建张量数据结构

def read_data(max_seq_len):
    # in和out分别是input和output的缩写
    in_tokens, out_tokens, in_seqs, out_seqs = [], [], [], []#输入,输出,处理后的输入序列和输出序列
    with io.open('fr-en-small.txt') as f:
        lines = f.readlines()
    for line in lines:
        in_seq, out_seq = line.rstrip().split('\t')#将每行拆分成输入序列和输出序列
        in_seq_tokens, out_seq_tokens = in_seq.split(' '), out_seq.split(' ')#将序列拆分成词
        if max(len(in_seq_tokens), len(out_seq_tokens)) > max_seq_len - 1:
            continue  # 如果加上EOS后长于max_seq_len,则忽略掉此样本
        process_one_seq(in_seq_tokens, in_tokens, in_seqs, max_seq_len) #处理输入序列,将其添加到in_tokens和in_seqs中
        process_one_seq(out_seq_tokens, out_tokens, out_seqs, max_seq_len)#处理输出序列,将其添加到out_tokens和out_seqs中
    in_vocab, in_data = build_data(in_tokens, in_seqs)#构造输入序列的词典和张量
    out_vocab, out_data = build_data(out_tokens, out_seqs)#构造输出序列的词典和张量
    return in_vocab, out_vocab, Data.TensorDataset(in_data, out_data)#返回两个词典和总的张量数据结构

尝试输出当前前面的函数,将序列的最大长度设成7,然后查看读取到的第一个样本。该样本分别包含法语词索引序列和英语词索引序列

max_seq_len = 7
in_vocab, out_vocab, dataset = read_data(max_seq_len)
dataset[0]

输出如下:

 编码器

我们用GRU层去构建编码器,在其前向传播后也会返回输出和最终时间步的多层隐藏层状态,其中的输出指的是最后一层的隐藏层在各个时间步的隐藏状态,并不涉及输出层计算。注意力机制将这些输出作为键项和值项。

class Encoder(nn.Module):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 drop_prob=0, **kwargs):
        """
        初始化Encoder类,继承自nn.Module。
        
        参数:
        - vocab_size (int): 词汇表大小,即输入序列中不同词汇的数量。
        - embed_size (int): 嵌入层的维度,即每个词向量的维度。
        - num_hiddens (int): 隐藏层的神经元数量。
        - num_layers (int): RNN的层数。
        - drop_prob (float): Dropout的概率,默认为0。
        """
        super(Encoder, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)#定义嵌入层,将输入词汇表中的词转变为向量空间
        self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=drop_prob)#定义GRU层

    def forward(self, inputs, state):#前向传播
        # 输入形状是(批量大小, 时间步数)。将输出互换样本维和时间步维
        embedding = self.embedding(inputs.long()).permute(1, 0, 2) # (seq_len, batch, input_size)#将输入序列中的词转换为嵌入向量,并改变张量维度顺序
        return self.rnn(embedding, state)

    def begin_state(self):#返回RNN初始状态,这里为none
        return None

验证函数是否正确,创建一个批量大小为4、时间步数为7的小批量序列输入。设门控循环单元的隐藏层个数为2,隐藏单元个数为16。编码器对该输入执行前向计算后返回的输出形状为(时间步数, 批量大小, 隐藏单元个数)。门控循环单元在最终时间步的多层隐藏状态的形状为(隐藏层个数, 批量大小, 隐藏单元个数)。对于门控循环单元来说,state就是一个元素,即隐藏状态;如果使用长短期记忆,state是一个元组,包含两个元素即隐藏状态和记忆细胞。

encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
output, state = encoder(torch.zeros((4, 7)), encoder.begin_state())
output.shape, state.shape # GRU的state是h, 而LSTM的是一个元组(h, c)

输出:

注意力机制

构建注意力机制模型

def attention_model(input_size, attention_size):
    model = nn.Sequential(nn.Linear(input_size, attention_size, bias=False),#线性变换层,输入的特征维度转变为注意力
                          nn.Tanh(),#激活函数,非线性变换
                          nn.Linear(attention_size, 1, bias=False))#线性变换层,注意力变为标量
    return model

注意力机制的输入包括查询项、键项和值项。设编码器和解码器的隐藏单元个数相同。这里的查询项为解码器在上一时间步的隐藏状态,形状为(批量大小, 隐藏单元个数);键项和值项均为编码器在所有时间步的隐藏状态,形状为(时间步数, 批量大小, 隐藏单元个数)。注意力机制返回当前时间步的背景变量,形状为(批量大小, 隐藏单元个数)。

def attention_forward(model, enc_states, dec_state):
    """
    enc_states: (时间步数, 批量大小, 隐藏单元个数)
    dec_state: (批量大小, 隐藏单元个数)
    """
    #将解码器隐藏状态广播到和编码器隐藏状态形状相同后进行连结
    dec_states = dec_state.unsqueeze(dim=0).expand_as(enc_states)
    enc_and_dec_states = torch.cat((enc_states, dec_states), dim=2)
    
    #使用注意力模型计算注意力权重
    e = model(enc_and_dec_states)  # 形状为(时间步数, 批量大小, 1) 
    alpha = F.softmax(e, dim=0)  # 在时间步维度做softmax运算
    
    #根据注意力权重对编码器隐藏状态进行加权平均得到背景变量
    return (alpha * enc_states).sum(dim=0)  # 返回背景变量

测试:在下面的例子中,编码器的时间步数为10,批量大小为4,编码器和解码器的隐藏单元个数均为8。注意力机制返回一个小批量的背景向量,每个背景向量的长度等于编码器的隐藏单元个数。因此输出的形状为(4, 8)。

seq_len, batch_size, num_hiddens = 10, 4, 8
model = attention_model(2*num_hiddens, 10) #返回注意力模型,输入维度2,输出为10
enc_states = torch.zeros((seq_len, batch_size, num_hiddens))#创建全零的编码器隐藏状态张量
dec_state = torch.zeros((batch_size, num_hiddens))#创建全零的解码器隐藏状态张量
attention_forward(model, enc_states, dec_state).shape#调用注意力权重函数,并打印结果,返回为batch_size,num_hiddens

输出:

解码器

我们直接将编码器在最终时间步的隐藏状态作为解码器的初始隐藏状态。这要求编码器和解码器的循环神经网络使用相同的隐藏层个数和隐藏单元个数。

在解码器的前向计算中,我们先通过刚刚介绍的注意力机制计算得到当前时间步的背景向量。由于解码器的输入来自输出语言的词索引,我们将输入通过词嵌入层得到表征,然后和背景向量在特征维连结。我们将连结后的结果与上一时间步的隐藏状态通过门控循环单元计算出当前时间步的输出与隐藏状态。最后,我们将输出通过全连接层变换为有关各个输出词的预测,形状为(批量大小, 输出词典大小)。

class Decoder(nn.Module):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 attention_size, drop_prob=0):
        super(Decoder, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embed_size)#定义嵌入层,将词汇表大小映射到嵌入维度
        self.attention = attention_model(2*num_hiddens, attention_size)#定义注意力机制
        # GRU的输入包含attention输出的c和实际输入, 所以尺寸是 num_hiddens+embed_size
        self.rnn = nn.GRU(num_hiddens + embed_size, num_hiddens, #GRU层
                          num_layers, dropout=drop_prob)
        self.out = nn.Linear(num_hiddens, vocab_size)# 义全连接层,将GRU输出映射到词汇表大小

    def forward(self, cur_input, state, enc_states):
        """
        cur_input shape: (batch, )
        state shape: (num_layers, batch, num_hiddens)
        """
        # 使用注意力机制计算背景向量
        c = attention_forward(self.attention, enc_states, state[-1])
        # 将嵌入后的输入和背景向量在特征维连结, (批量大小, num_hiddens+embed_size)
        input_and_c = torch.cat((self.embedding(cur_input), c), dim=1) 
        # 为输入和背景向量的连结增加时间步维,时间步个数为1
        output, state = self.rnn(input_and_c.unsqueeze(0), state)
        # 移除时间步维,输出形状为(批量大小, 输出词典大小)
        output = self.out(output).squeeze(dim=0)
        return output, state

    def begin_state(self, enc_state):
        # 直接将编码器最终时间步的隐藏状态作为解码器的初始隐藏状态
        return enc_state

训练模型

损失函数

我们先实现batch_loss函数计算一个小批量的损失。解码器在最初时间步的输入是特殊字符BOS。之后,解码器在某时间步的输入为样本输出序列在上一时间步的词,即强制教学。

def batch_loss(encoder, decoder, X, Y, loss):
    batch_size = X.shape[0]
    enc_state = encoder.begin_state()#初始化编码器状态
    enc_outputs, enc_state = encoder(X, enc_state)#获取编码器输出和最后状态
    # 初始化解码器的隐藏状态
    dec_state = decoder.begin_state(enc_state)
    # 解码器在最初时间步的输入是BOS
    dec_input = torch.tensor([out_vocab.stoi[BOS]] * batch_size)
    # 我们将使用掩码变量mask来忽略掉标签为填充项PAD的损失, 初始全1
    mask, num_not_pad_tokens = torch.ones(batch_size,), 0
    l = torch.tensor([0.0])#初始化损失为0
    
    #逐时间步计算解码器的输出和损失
    for y in Y.permute(1,0): # Y shape: (batch, seq_len)
        dec_output, dec_state = decoder(dec_input, dec_state, enc_outputs)#计算解码器的输出和更新解码器的隐藏状态
        l = l + (mask * loss(dec_output, y)).sum() #计算当前时间步的损失,并累加到总损失中
        dec_input = y  # 使用强制教学
        num_not_pad_tokens += mask.sum().item()#累加非填充项的数量
        # EOS后面全是PAD. 下面一行保证一旦遇到EOS接下来的循环中mask就一直是0
        mask = mask * (y != out_vocab.stoi[EOS]).float()
    return l / num_not_pad_tokens

训练

def train(encoder, decoder, dataset, lr, batch_size, num_epochs):
    enc_optimizer = torch.optim.Adam(encoder.parameters(), lr=lr)#初始化优化器
    dec_optimizer = torch.optim.Adam(decoder.parameters(), lr=lr)

    loss = nn.CrossEntropyLoss(reduction='none')#定义交叉熵损失函数
    data_iter = Data.DataLoader(dataset, batch_size, shuffle=True)#获取数据迭代器
    for epoch in range(num_epochs):#训练
        l_sum = 0.0
        for X, Y in data_iter:
            enc_optimizer.zero_grad()#初始化
            dec_optimizer.zero_grad()
            l = batch_loss(encoder, decoder, X, Y, loss)#计算损失
            l.backward()#反向传播
            enc_optimizer.step()#更新模型参数
            dec_optimizer.step()
            l_sum += l.item()#累加损失
        if (epoch + 1) % 10 == 0:
            print("epoch %d, loss %.3f" % (epoch + 1, l_sum / len(data_iter)))
embed_size, num_hiddens, num_layers = 64, 64, 2     #定义模型参数
attention_size, drop_prob, lr, batch_size, num_epochs = 10, 0.5, 0.01, 2, 50
encoder = Encoder(len(in_vocab), embed_size, num_hiddens, num_layers,#编码器
                  drop_prob)
decoder = Decoder(len(out_vocab), embed_size, num_hiddens, num_layers,#解码器
                  attention_size, drop_prob)
train(encoder, decoder, dataset, lr, batch_size, num_epochs)#训练

预测不定长序列

在这用最简单的贪婪搜索实现翻译

def translate(encoder, decoder, input_seq, max_seq_len):
    in_tokens = input_seq.split(' ')
    in_tokens += [EOS] + [PAD] * (max_seq_len - len(in_tokens) - 1)
    #将输入序列分割成单词,并添加结束符EOS和填充符PAD,确保长度不超过max_seq_len
    
    enc_input = torch.tensor([[in_vocab.stoi[tk] for tk in in_tokens]]) # batch=1
    #将输入序列转换为对应的索引序列,并增加一个维度作为批量维度,这里假设in_vocab和out_vocab是词汇表对象
    
    enc_state = encoder.begin_state()#初始化编码器的初始状态
    enc_output, enc_state = encoder(enc_input, enc_state)
    dec_input = torch.tensor([out_vocab.stoi[BOS]])#初始输入为BOS
    dec_state = decoder.begin_state(enc_state)#编码器最终状态为解码器初状态
    output_tokens = []#输出
    for _ in range(max_seq_len):
        dec_output, dec_state = decoder(dec_input, dec_state, enc_output)
        pred = dec_output.argmax(dim=1)#预测输出的token为概率最大的值
        pred_token = out_vocab.itos[int(pred.item())]#查找最大概率的词汇
        if pred_token == EOS:  # 当任一时间步搜索出EOS时,输出序列即完成
            break
        else:
            output_tokens.append(pred_token)#将预测的token添加到输出序列中
            dec_input = pred#将当前预测作为下一时刻的输入
    return output_tokens

测试代码:(法语转英语)

input_seq = 'ils regardent .'#输入
translate(encoder, decoder, input_seq, max_seq_len)#翻译

输出:

评价翻译结果 

评价机器翻译结果通常使用BLEU(Bilingual Evaluation Understudy)

def bleu(pred_tokens, label_tokens, k):
    len_pred, len_label = len(pred_tokens), len(label_tokens)#预测序列和标签序列的长度
    score = math.exp(min(0, 1 - len_label / len_pred))#计算系数,惩罚较短输出
    for n in range(1, k + 1):#计算每个n的匹配数量与标签序列的子序列的计数
        num_matches, label_subs = 0, collections.defaultdict(int)
        for i in range(len_label - n + 1):#统计长度为n的子序列出现次数
            label_subs[''.join(label_tokens[i: i + n])] += 1
        for i in range(len_pred - n + 1):#统计匹配数量
            if label_subs[''.join(pred_tokens[i: i + n])] > 0:
                num_matches += 1
                label_subs[''.join(pred_tokens[i: i + n])] -= 1
        score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))#按公式计算得分
    return score

接下来,定义一个辅助打印函数。

def score(input_seq, label_seq, k):
    pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)#预测序列
    label_tokens = label_seq.split(' ')#拆分
    print('bleu %.3f, predict: %s' % (bleu(pred_tokens, label_tokens, k),#计算得分并打印
                                      ' '.join(pred_tokens)))

测试代码:

score('ils regardent .', 'they are watching .', k=2)

输出:

bleu 1.000, predict: they are watching .

测试代码:

score('ils sont canadienne .', 'they are canadian .', k=2)

输出:

bleu 0.658, predict: they are actors .

总结

本次机器翻译大致流程为:

1.数据预处理:构建字典,序列化,张量化等函数。

2.构建编码器:构建一个编码器模型,用于将输入的源语言句子编码成一个语义向量或上下文表示。

3.构建解码器:构建一个解码器模型,用于从编码的语义向量中生成目标语言的句子。可以进入注意力机制。

4.定义损失函数及优化函数:训练过程中通常使用一个损失函数(如交叉熵损失)来衡量预测结果与实际目标的差距,并通过优化算法优化模型参数。

5.训练:使用准备好的训练数据对编码器-解码器模型进行训练。

6.预测和生成:训练完成后,利用训练好的模型进行预测和生成。

7.评优:使用BLEU等指标对生成的目标语言句子与参考翻译进行比较,评估翻译质量的好坏

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值