机器翻译及实践 初级版:含注意力机制的编码器—解码器模型


前言

本博客为笔者NLP实验课内容。
行文逻辑如下:
1.介绍2个重要的前置知识:Seq2Seq和注意力机制
至于更基础的词嵌入、RNN等前置知识可看前一篇博客或自行学习
2.实践:法英翻译(使用含注意力机制的编码器—解码器)


一、什么是机器翻译?

其实就是我们所说的翻译,也就是把一句话由一种语言翻译成另一种语言。

按理来说最容易理解的方法是构造一个映射关系或字典,把一个语言中的每个词与另一种语言一一对应起来。但实际实行起来难度很大

一是词太多;
二是对于词组,短语这种含多个词,每个词分开分别有各自的意思,合起来又是另一种意思这种情况很难处理;
还有,输入输出的序列长度不一致,例如输入3个词,输出可能4个词。

以上这些难点都需要我们去考虑,故基于此,我们开始我们本次的实验。

二、所需要的前置知识

延续前一篇博客的传统,本部分还是按照what—why—how的逻辑进行介绍。
how部分主要讲解实现原理,具体代码实现会放在第三节即实践部分进行讲解。

(一).Seq2Seq

1.什么是Seq2Seq

简单的描述其功能就是:利用编码器把a变为b,再利用解码器把b变成c。

输入序列->Seq2Seq->输出序列

在我看来,任何的Seq2Seq都是在做”翻译“,只不过翻译出的“语言”不一样。
比如,输入1,1,1,输出2,2。这也是一种翻译啊!

下面是一个简单的基于Seq2Seq的翻译模型。
在这里插入图片描述
可以看到,一个Seq2Seq模型由两部分组成,编码器和解码器。
输入语句通过编码器生成C(背景变量),需要注意的是,这个C是定长的。然后解码器用C生成翻译。
是不是很想知道他们到底内部的运行机理?别急,咱按规矩,what后得将why,哈哈。

2.机器翻译为什么要用Seq2Seq

1.最直观的理由是他能处理变长数据:在翻译时,输入语言的长度和输出语言的长度是不一样的。
可是简单的RNN也能处理变长数据,为什么不用它?
2.因为Seq2Seq模型相较于RNN在翻译任务中有自己的优势
(1)处理变长序列:Seq2Seq模型专门设计用于解决序列到序列的转换问题,自然地处理变长输入和输出。编码器将输入序列压缩成一个上下文向量,而解码器则从这个向量生成可变长度的输出序列。
(2)捕捉依赖关系:在自然语言中,单词间存在复杂的依赖关系,这些关系对正确翻译至关重要。Seq2Seq模型通过循环神经网络(RNN)或更先进的长短时记忆网络(LSTM)来捕捉长距离的依赖关系。这些网络结构能够存储和访问前文信息,有助于生成准确和连贯的翻译。
(3)端到端的学习:Seq2Seq模型采用端到端的训练方式,直接从输入和输出对中学习翻译函数。这种方法简化了模型的设计和训练过程,因为不需要人工设计特征或规则。通过大量的训练数据,Seq2Seq模型可以自动学习词汇、语法和语义层面的复杂映射关系。

3.如何使用Seq2Seq

还是结合此图进行讲解:
在这里插入图片描述

3.1编码器的实现

我们的目的是让编码器生成一个定长的背景变量 c \boldsymbol{c} c

我们可以看到,如果我们让编码器每个步骤都有一个输出,类似这样:在这里插入图片描述

是不是跟RNN一模一样了!!!那他具体的参数更新等等是不是都明白了?
或者你把他换成双向循环神经网络,LSTM,GRU都行(在我的眼中他们是属于加强版的RNN,本质上没啥大的区别)。

虽然说到这基本上可以结束了,但咱们还是得用数学公式把他描述的严谨点。

假设我们使用的是循环神经网络

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

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

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

c = q ( h 1 , … , h T ) . \boldsymbol{c} = q(\boldsymbol{h}_1, \ldots, \boldsymbol{h}_T). c=q(h1,,hT).

3.2解码器的实现

还是来看这张图:在这里插入图片描述

我们现在已经得到了编码器输出的背景变量 c \boldsymbol{c} c.
其实我们观察解码器,是不是本质上还是一个RNN模型,只不过把输入改成了前一个状态的输出和背景变量 c \boldsymbol{c} c,那么参数的更新啥的,也是一样的。

还是跟上面一样,为了严谨,结合数学公式讲解:

给定训练样本中的输出序列 y 1 , y 2 , … , y T ′ y_1, y_2, \ldots, y_{T'} y1,y2,,yT,对每个时间步 t ′ t' t(符号与输入序列或编码器的时间步 t t t有区别),解码器输出 y t ′ y_{t'} yt的条件概率将基于之前的输出序列 y 1 , … , y t ′ − 1 y_1,\ldots,y_{t'-1} y1,,yt1和背景变量 c \boldsymbol{c} c,即 P ( y t ′ ∣ y 1 , … , y t ′ − 1 , c ) P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \boldsymbol{c}) P(yty1,,yt1,c)

这个P其实就是当前词预测正确的概率,我们要最大化这个P。

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

s t ′ = g ( y t ′ − 1 , c , s t ′ − 1 ) . \boldsymbol{s}_{t^\prime} = g(y_{t^\prime-1}, \boldsymbol{c}, \boldsymbol{s}_{t^\prime-1}). st=g(yt1,c,st1).
(其实我们就是要更新这个 g ,从而使得 P 变大) (其实我们就是要更新这个g,从而使得P变大) (其实我们就是要更新这个g,从而使得P变大)
有了解码器的隐藏状态后,我们可以使用自定义的输出层和softmax运算来计算 P ( y t ′ ∣ y 1 , … , y t ′ − 1 , c ) P(y_{t^\prime} \mid y_1, \ldots, y_{t^\prime-1}, \boldsymbol{c}) P(yty1,,yt1,c),例如,基于当前时间步的解码器隐藏状态 s t ′ \boldsymbol{s}_{t^\prime} st、上一时间步的输出 y t ′ − 1 y_{t^\prime-1} yt1以及背景变量 c \boldsymbol{c} c来计算当前时间步输出 y t ′ y_{t^\prime} yt的概率分布。

3.3训练模型

根据最大似然估计,我们可以最大化输出序列基于输入序列的条件概率

P ( y 1 , … , y T ′ ∣ x 1 , … , x T ) = ∏ t ′ = 1 T ′ P ( y t ′ ∣ y 1 , … , y t ′ − 1 , x 1 , … , x T ) = ∏ t ′ = 1 T ′ P ( y t ′ ∣ y 1 , … , y t ′ − 1 , c ) , \begin{aligned} P(y_1, \ldots, y_{T'} \mid x_1, \ldots, x_T) &= \prod_{t'=1}^{T'} P(y_{t'} \mid y_1, \ldots, y_{t'-1}, x_1, \ldots, x_T)\\ &= \prod_{t'=1}^{T'} P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \boldsymbol{c}), \end{aligned} P(y1,,yTx1,,xT)=t=1TP(yty1,,yt1,x1,,xT)=t=1TP(yty1,,yt1,c),

并得到该输出序列的损失

− log ⁡ P ( y 1 , … , y T ′ ∣ x 1 , … , x T ) = − ∑ t ′ = 1 T ′ log ⁡ P ( y t ′ ∣ y 1 , … , y t ′ − 1 , c ) , -\log P(y_1, \ldots, y_{T'} \mid x_1, \ldots, x_T) = -\sum_{t'=1}^{T'} \log P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \boldsymbol{c}), logP(y1,,yTx1,,xT)=t=1TlogP(yty1,,yt1,c),

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

(二).注意力机制

在我看来,Seq2Seq是大框架,里面可以放很多”函数“实现机器翻译,类似RNN,LSTM等,为了优化这个函数,我们有很多技巧,例如BatchNorm、更好的初始化操作等,在这些优化技巧中,有一个在翻译任务中表现的最出色,那就是注意力机制

1.什么是注意力机制

想象一下这样一个画面,在一张白色的A4纸上有一滴墨汁,你一眼望过去是不是优先注意到了墨汁,而非A4纸的其他部位?
其实这就是注意力机制,简单的说就是对不同内容的关心程度不一样。

2.机器翻译为什么要引入注意力机制

假设我们在翻译在机器翻译中,比如输入source为Tom chase Jerry。输出想得到中文:汤姆 追逐 杰瑞。在翻译Jerry这个单词时,在普通Encoder-Decoder模型中,source里的每个单词对“杰瑞”贡献是相同的,但这样明显和实际不是很相符,在翻译“杰瑞”的时候,我们更关注的应该是"Jerry",对于另外两个单词,关注的会少一些。
这么一想注意力机制是不是还挺合理?其实还有一个重要理由!!
避免信息损失 避免信息损失 避免信息损失
假设我们有很长的一句话,若我们对每个词的关心程度都一样,大脑根本忙不过来,肯定会造成记住了后面又忘了前面,这样是不是造成了信息损失?而注意力机制通过对输入序列的不同部分进行加权处理,有效解决了这一问题。

3.如何实现注意力机制

首先我们要思考一个问题,注意力机制加在哪?编码器部分还是解码器部分
我认为加在解码器中更合理。

结合我们在Seq2Seq中介绍的的模型,我们通过编码器得到了背景变量 c \boldsymbol{c} c,然后在每个时间步内,我们将上一时间步的输出 y t ′ − 1 y_{t^\prime-1} yt1以及背景变量 c \boldsymbol{c} c作为输入,并将它们与上一时间步的隐藏状态 s t ′ − 1 \boldsymbol{s}_{t^\prime-1} st1变换为当前时间步的隐藏状态 s t ′ \boldsymbol{s}_{t^\prime} st.

想一想,如果我们将这个背景变量 c \boldsymbol{c} c进行处理,使得在翻译每个单词时,与当前单词有关的信息占比更大,这种操作是不是能提高翻译的效率?

那么如何实现,简单的来说不就是加权平均:
令编码器在时间步 t t t的隐藏状态为 h t \boldsymbol{h}_t ht,且总时间步数为 T T T。那么解码器在时间步 t ′ t' t的背景变量为所有编码器隐藏状态的加权平均:

c t ′ = ∑ t = 1 T α t ′ t h t , \boldsymbol{c}_{t'} = \sum_{t=1}^T \alpha_{t' t} \boldsymbol{h}_t, ct=t=1Tαttht,
权重 α t ′ t \alpha_{t' t} αtt可通过以下方法计算出:
α t ′ t = exp ⁡ ( e t ′ t ) ∑ k = 1 T exp ⁡ ( e t ′ k ) , t = 1 , … , T . \alpha_{t' t} = \frac{\exp(e_{t' t})}{ \sum_{k=1}^T \exp(e_{t' k}) },\quad t=1,\ldots,T. αtt=k=1Texp(etk)exp(ett),t=1,,T.
e t ′ t e_{t' t} ett同时取决于解码器的时间步 t ′ t' t和编码器的时间步 t t t,我们不妨以解码器在时间步 t ′ − 1 t'-1 t1的隐藏状态 s t ′ − 1 \boldsymbol{s}_{t' - 1} st1与编码器在时间步 t t t的隐藏状态 h t \boldsymbol{h}_t ht为输入,并通过函数 a a a计算 e t ′ t e_{t' t} ett

e t ′ t = a ( s t ′ − 1 , h t ) . e_{t' t} = a(\boldsymbol{s}_{t' - 1}, \boldsymbol{h}_t). ett=a(st1,ht).
函数 a a a中的参数可学习。

其实我们在使用注意力机制时,更常使用查询项和对应的键值对。值项是需要加权平均的一组项。在加权平均中,值项的权重来自查询项以及与该值项对应的键项的计算。

结合之前介绍的模型查询项为解码器的隐藏状态,键项和值项均为编码器的隐藏状态。
让我们考虑一个常见的简单情形,即编码器和解码器的隐藏单元个数均为 h h h,且函数 a ( s , h ) = s ⊤ h a(\boldsymbol{s}, \boldsymbol{h})=\boldsymbol{s}^\top \boldsymbol{h} a(s,h)=sh。假设我们希望根据解码器单个隐藏状态 s t ′ − 1 ∈ R h \boldsymbol{s}_{t' - 1} \in \mathbb{R}^{h} st1Rh和编码器所有隐藏状态 h t ∈ R h , t = 1 , … , T \boldsymbol{h}_t \in \mathbb{R}^{h}, t = 1,\ldots,T htRh,t=1,,T来计算背景向量 c t ′ ∈ R h \boldsymbol{c}_{t'}\in \mathbb{R}^{h} ctRh
我们可以将查询项矩阵 Q ∈ R 1 × h \boldsymbol{Q} \in \mathbb{R}^{1 \times h} QR1×h设为 s t ′ − 1 ⊤ \boldsymbol{s}_{t' - 1}^\top st1,并令键项矩阵 K ∈ R T × h \boldsymbol{K} \in \mathbb{R}^{T \times h} KRT×h和值项矩阵 V ∈ R T × h \boldsymbol{V} \in \mathbb{R}^{T \times h} VRT×h相同且第 t t t行均为 h t ⊤ \boldsymbol{h}_t^\top ht。此时,我们只需要通过矢量化计算

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

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

三、实践(一个简单的基于注意力机制的编码器—解码器机器翻译模型)

本实验所用到的一些库的版本:
numpy 1.23.5
torch 1.8.1+cu111(torch版本貌似影响不大2.1.2也行)
torchaudio 0.8.1
torchtext 0.6.0
torchvision 0.9.1+cu111
此外,本实验直接用cpu运行即可

我们编码的大致思路如下:

在这里插入图片描述

(一).定义模型基础要素

1.读入数据的工具

需要注意torchtext版本,不能太高,否则会报错,没specials这个参数。
我的版本为:0.6.0

你可以简单的把此步骤理解为:把文本转换为数字。在我看来,这部分是最重要的,因为我们后面用到的所有数据,都是通过这一步骤产生的。
这一步需要注意的我认为就是词标注
即由于我们输入的为定长序列,假设句子长度过短,则我们需要进行零填充,而零填充有需要涉及到零填充标记位(句子何时结束)用什么进行零填充

# 设置特殊标记符号
PAD, BOS, EOS = '<pad>', '<bos>', '<eos>'

#将一个序列(seq_tokens)中的所有单词(词tokens)添加到all_tokens列表中,用于后续构建词典。
#然后,为了确保所有序列的长度都等于max_seq_len,我们在序列末尾添加PAD字符(EOS表示句子结束,PAD表示填充)直到达到指定长度。
#最后,将处理后的序列(seq_tokens)添加到all_seqs列表中。

def process_one_seq(seq_tokens, all_tokens, all_seqs, max_seq_len):
    # 将当前序列的词添加到all_tokens中
    all_tokens.extend(seq_tokens)

    # 在序列末尾添加EOS和足够数量的PAD,直到达到max_seq_len
    seq_tokens += [EOS] + [PAD] * (max_seq_len - len(seq_tokens) - 1)
    # 将处理后的序列添加到all_seqs列表中
    all_seqs.append(seq_tokens)


# 使用all_tokens中的所有单词创建一个词典(Vocab),并根据词典将所有序列中的单词转换为对应的词索引。返回词典对象和转换后的词索引Tensor。
def build_data(all_tokens, all_seqs):
    # 使用Counter计算词频并创建词典,包括特殊标记(PAD, BOS, EOS)
    vocab = Vocab.Vocab(collections.Counter(all_tokens), specials=[PAD, BOS, EOS])

    # 对于all_seqs中的每个序列,使用vocab的stoi方法(词到索引)转换为词索引列表
    indices = [[vocab.stoi[w] for w in seq] for seq in all_seqs]

    # 返回词典对象和词索引Tensor
    return vocab, torch.tensor(indices)  # torch.tensor将词索引列表转换为张量
   
# 读取数据,处理并构建输入(in)和输出(out)的数据集,最大序列长度为max_seq_len
def read_data(max_seq_len):
    # 初始化输入和输出的词、序列列表
    in_tokens, out_tokens, in_seqs, out_seqs = [], [], [], []
    # 以文本文件方式打开'fr-en-small.txt',并逐行读取
    with io.open('fr-en-small.txt') as f:
        lines = f.readlines()
    # 遍历文件中的每一行
    for line in lines:
        # 分割输入和输出序列,以'\t'为分隔符
        in_seq, out_seq = line.rstrip().split('\t')
        # 分割输入和输出序列的单词
        in_seq_tokens, out_seq_tokens = in_seq.split(' '), out_seq.split(' ')
        # 如果任何序列加上EOS后长度超过max_seq_len,跳过此样本
        if max(len(in_seq_tokens), len(out_seq_tokens)) > max_seq_len - 1:
            continue
        # 对输入和输出序列分别进行处理
        process_one_seq(in_seq_tokens, in_tokens, in_seqs, max_seq_len)
        process_one_seq(out_seq_tokens, out_tokens, out_seqs, max_seq_len)
    # 使用in_tokens和in_seqs构建输入词典和数据集
    in_vocab, in_data = build_data(in_tokens, in_seqs)
    # 使用out_tokens和out_seqs构建输出词典和数据集
    out_vocab, out_data = build_data(out_tokens, out_seqs)
    # 返回输入和输出的词典,以及合并后的TensorDataset(包含输入和输出数据)
    return in_vocab, out_vocab, Data.TensorDataset(in_data, out_data)  # Data.TensorDataset是PyTorch中的数据集类,包含输入和输出数据

我们来看一下我们处理后的数据:

max_seq_len = 10
in_vocab, out_vocab, dataset = read_data(max_seq_len)
print("输入词典(in_vocab):")
for key, value in in_vocab.stoi.items():
    print(f"{key}: {value}")

print("输出词典(out_vocab):")
for key, value in out_vocab.stoi.items():
    print(f"{key}: {value}")
dataset[0]

构造的两个字典如下(太长了,没放全):
在这里插入图片描述
在这里插入图片描述
然后我们看一下我们要翻译的第一句话:
原文:elle est vieille .
译文:she is old .

对照输入字典:elle-5,est-4,vieille-45,.-3,后面就是句尾标志和零填充。
对照输入字典:she-8,is-4,old-27,.-3,后面就是句尾标志和零填充。

那再看看代码输出的是:
在这里插入图片描述
正确,此部分结束。

2.编码器

编码器部分跟我们之前定义的RNN等模型基本上可谓是一模一样,大概流程如下所示:
在这里插入图片描述

# 定义一个Encoder类,继承自nn.Module,用于处理文本序列的编码
class Encoder(nn.Module):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, drop_prob=0, **kwargs):  # 初始化函数,接收参数如词汇表大小、嵌入维度、隐藏层神经元数量、层数和可能的dropout概率
        super(Encoder, self).__init__(**kwargs)  # 调用父类(nn.Module)的初始化方法

        # 创建嵌入层,将词汇表中的每个单词映射到一个嵌入向量
        self.embedding = nn.Embedding(vocab_size, embed_size)  # vocab_size: 词汇表大小,embed_size: 嵌入维度

        # 创建一个GRU(门控循环单元)网络,用于处理序列数据
        self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=drop_prob)  # num_hiddens: 隐藏层神经元数量,dropout: 防止过拟合的策略

    # 前向传播函数,输入是输入序列和初始状态
    def forward(self, inputs, state):  # inputs: 形状为(batch_size, seq_len),state: 初始化的隐藏状态
        # 将输入序列转换为嵌入向量,并将样本维度和时间步维度互换,以便GRU网络处理
        embedding = self.embedding(inputs.long()).permute(1, 0, 2)  # (seq_len, batch, input_size)

        # 通过GRU网络处理嵌入向量序列
        return self.rnn(embedding, state)  # 返回处理后的输出和更新后的隐藏状态

    # 开始状态函数,返回None,因为GRU的开始状态通常由外部提供
    def begin_state(self):
        return None
# 初始化一个Encoder模型,参数如下:
# vocab_size: 词汇表大小为10
# embed_size: 嵌入维度为8
# num_hiddens: 隐藏层神经元数量为16
# num_layers: 网络层数为2
encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
# 使用输入和初始状态调用Encoder的forward函数进行编码
output, state = encoder(torch.zeros((4, 7)), encoder.begin_state())
# 输出和状态的形状说明了GRU的隐藏状态结构,与LSTM不同,LSTM的state是一个元组(h, c),其中h是隐藏状态,c是细胞状态
output.shape, state.shape # GRU的state是h, 而LSTM的是一个元组(h, c)
# state[0].shape, state[1].shape(LSTM)

用GRU的输出结果:
在这里插入图片描述

用LSTM的输出结果:
在这里插入图片描述

3.注意力机制

其实就两步:
在这里插入图片描述

# 定义一个函数attention_model,用于创建一个简单的注意力模型,输入参数为输入维度(input_size)和注意力维度(attention_size)
def attention_model(input_size, attention_size):
    # 使用nn.Sequential创建一个线性模型,它将输入层、激活函数和输出层串联起来
    # nn.Linear(input_size, attention_size, bias=False):创建一个线性层,输入维度为input_size,输出维度为attention_size,不使用偏置(因为注意力机制通常不需要偏置)
    model = nn.Sequential(
        nn.Linear(input_size, attention_size, bias=False),  # 输入层
        nn.Tanh(),  # 激活函数,这里使用tanh,用于非线性变换
        nn.Linear(attention_size, 1, bias=False)  # 输出层,输出维度为1,因为我们要计算注意力权重,通常是一个标量
    )

    # 返回创建的注意力模型
    return model

def attention_forward(model, enc_states, dec_state):
    """
    此函数计算注意力权重,输入参数为:
    enc_states: 时间步数维度的编码器隐藏状态张量,形状为(时间步数, 批量大小, 隐藏单元个数)
    dec_state: 解码器的隐藏状态张量,形状为(批量大小, 隐藏单元个数)

    注意:dec_state需要被广播到与enc_states相同的形状,以便进行注意力计算
    """
    # 使用unsqueeze将解码器隐藏状态的批量维度提升为时间步数维度,然后使用expand_as方法将其扩展到与enc_states相同的形状
    dec_states = dec_state.unsqueeze(dim=0).expand_as(enc_states)  # (1, 批量大小, 隐藏单元个数)

    # 将编码器和解码器的隐藏状态沿着第三个维度(隐藏单元个数)连接起来
    enc_and_dec_states = torch.cat((enc_states, dec_states), dim=2)  # (时间步数, 批量大小, 隐藏单元个数 * 2)

    # 使用注意力模型(model)计算注意力得分,形状为(时间步数, 批量大小, 1)
    e = model(enc_and_dec_states)  # 注意力得分

    # 对时间步维度应用softmax函数,得到注意力权重,形状为(时间步数, 批量大小, 1)
    alpha = F.softmax(e, dim=0)  # softmax函数确保权重之和为1

    # 将注意力权重与编码器隐藏状态相乘,然后在时间步维度上求和,得到背景变量(context vector)
    context = (alpha * enc_states).sum(dim=0)  # context: (批量大小, 隐藏单元个数)

    # 返回背景变量
    return context

# 定义序列长度(seq_len),批量大小(batch_size),和隐藏单元个数(num_hiddens)
seq_len = 10
batch_size = 4
num_hiddens = 8

# 使用attention_model函数创建一个注意力模型,输入参数为编码器和解码器隐藏状态的两倍大小(2*num_hiddens)和10(假设是注意力维度)
# 注意:这里的2*num_hiddens是假设注意力模型的输入是编码器和解码器隐藏状态的组合
model = attention_model(2*num_hiddens, 10)

# 初始化编码器隐藏状态张量,形状为(序列长度, 批量大小, 隐藏单元个数)
enc_states = torch.zeros((seq_len, batch_size, num_hiddens))

# 初始化解码器隐藏状态张量,形状为(批量大小, 隐藏单元个数)
dec_state = torch.zeros((batch_size, num_hiddens))

# 调用attention_forward函数计算注意力权重,输入为模型、编码器隐藏状态和解码器隐藏状态
context = attention_forward(model, enc_states, dec_state)

# 注意力权重计算后,context的形状会是(批量大小, 隐藏单元个数),因为我们在时间步维度上求和
context_shape = context.shape  # 输出形状为(4, 8)

# 打印context的形状
print(context_shape)  # 输出:(4, 8)

简单看一下计算结果,由于我们假设批量大小为4,隐藏单元个数为8,在计算注意力权重后,我们将编码器隐藏状态(enc_states)与注意力权重相乘,并在时间步维度上求和。所以结果的形状将是(批量大小, 隐藏单元个数),即(4, 8)。这里的4代表批量中的样本数量,而8代表每个样本的隐藏单元个数。
在这里插入图片描述

4.含注意力机制的解码器

这部分跟解码器又是基本上很类似,只不过加了个注意力机制,大致步骤如下:
在这里插入图片描述

class Decoder(nn.Module):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, attention_size, drop_prob=0):
        super(Decoder, self).__init__()  # 初始化父类(nn.Module)

        # 创建嵌入层,将词汇表中的每个单词映射到一个嵌入向量
        self.embedding = nn.Embedding(vocab_size, embed_size)

        # 创建注意力模型,输入是编码器和解码器隐藏状态的两倍大小
        self.attention = attention_model(2*num_hiddens, attention_size)  # 注意力机制,用于计算注意力权重

        # GRU的输入包含注意力输出的c和实际输入,所以输入维度是num_hiddens(隐藏层)+ embed_size(嵌入维度)
        self.rnn = nn.GRU(num_hiddens + embed_size, num_hiddens, num_layers, dropout=drop_prob)  # GRU网络

        # 创建一个线性层,用于将GRU的输出映射到词汇表大小
        self.out = nn.Linear(num_hiddens, vocab_size)  # 输出层

    # 前向传播函数,输入是当前输入、初始状态和编码器隐藏状态
    def forward(self, cur_input, state, enc_states):
        """
        cur_input: 当前时间步的输入,形状为(batch, )
        state: 解码器的隐藏状态,形状为(num_layers, batch, num_hiddens)
        enc_states: 编码器的隐藏状态,形状为(时间步数, 批量大小, num_hiddens)
        """
        # 使用注意力模型计算当前时间步的背景向量c
        c = attention_forward(self.attention, enc_states, state[-1])  # 注意力权重计算

        # 将当前输入的嵌入向量和背景向量连接,形成GRU的输入,维度为(num_hiddens + embed_size)
        input_and_c = torch.cat((self.embedding(cur_input), c), dim=1)  # (batch, num_hiddens+embed_size)

        # 将输入和背景向量扩展到时间步数为1,以便输入到GRU
        output, state = self.rnn(input_and_c.unsqueeze(0), state)  # (1, batch, num_hiddens)

        # 移除时间步维度,将GRU的输出转换为形状为(batch, vocab_size)
        output = self.out(output).squeeze(dim=0)  # (batch, vocab_size)

        # 返回输出和更新后的隐藏状态
        return output, state

    # 开始状态函数,直接将编码器的最终时间步的隐藏状态作为解码器的初始隐藏状态
    def begin_state(self, enc_state):
        return enc_state  # 返回编码器的隐藏状态,形状为(batch, num_hiddens)

(二).组装并训练模型

还是很类似的过程。

1.损失函数

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

# 定义一个函数,用于计算批次损失,适用于编码器(encoder)和解码器(decoder)的联合训练
def batch_loss(encoder, decoder, X, Y, loss_function):
    # 获取批次大小
    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, dtype=torch.long)

    # 初始化掩码,所有元素为1,用于忽略填充项(PAD)的损失
    mask = torch.ones(batch_size, dtype=torch.float)
    num_not_pad_tokens = 0  # 记录非填充项的数量

    # 初始化损失值
    l = torch.tensor([0.0])

    # 遍历标签Y的序列(按时间步顺序)
    for y in Y.permute(1, 0):  # Y的形状是(batch_size, seq_len)
        # 运行解码器并获取输出和新的状态
        dec_output, dec_state = decoder(dec_input, dec_state, enc_outputs)

        # 计算损失并累加到总损失
        l += (mask * loss_function(dec_output, y)).sum()

        # 更新解码器输入为当前标签(强制教学)
        dec_input = y

        # 更新非填充项数量
        num_not_pad_tokens += mask.sum().item()

        # 如果遇到EOS,将后续的mask设置为0,因为EOS之后都是填充
        mask = mask * (y != out_vocab.stoi[EOS]).float()

    # 返回平均损失,除以非填充项的数量
    return l / num_not_pad_tokens

2.训练函数

更新参数的方法选的是Adam

# 定义一个训练函数,用于训练编码器(encoder)和解码器(decoder)
def train(encoder, decoder, dataset, learning_rate, batch_size, num_epochs):
    # 初始化编码器和解码器的优化器,使用Adam优化器
    enc_optimizer = torch.optim.Adam(encoder.parameters(), lr=learning_rate)
    dec_optimizer = torch.optim.Adam(decoder.parameters(), lr=learning_rate)

    # 使用交叉熵损失函数,设置reduction参数为'none',以便后续处理每个样本的损失
    loss_function = nn.CrossEntropyLoss(reduction='none')

    # 创建数据迭代器,从dataset中按批次读取数据并随机打乱
    data_loader = Data.DataLoader(dataset, batch_size, shuffle=True)

    # 进行指定数量的训练轮次
    for epoch in range(num_epochs):
        # 初始化总损失值
        l_sum = 0.0

        # 遍历数据迭代器中的批次
        for X, Y in data_loader:
            # 每个批次开始时,清零优化器的梯度
            enc_optimizer.zero_grad()
            dec_optimizer.zero_grad()

            # 计算批次损失
            l = batch_loss(encoder, decoder, X, Y, loss_function)

            # 反向传播并更新梯度
            l.backward()

            # 更新编码器和解码器的参数
            enc_optimizer.step()
            dec_optimizer.step()

            # 累加当前批次的损失
            l_sum += l.item()

        # 每10个训练轮次输出一次训练进度
        if (epoch + 1) % 10 == 0:
            # 计算并打印当前epoch的平均损失
            print(f"epoch {epoch + 1}, loss: {l_sum / len(data_loader):.3f}")

    # 完成训练
    return encoder, decoder

3.超参数+训练

# 定义模型参数
embed_size = 64  # 字符嵌入维度
num_hiddens = 64  # 隐藏层神经元数量
num_layers = 2  # RNN层的层数

attention_size = 10  # 注意力机制的大小
drop_prob = 0.5  # 随机失活的概率
learning_rate = 0.01  # 学习率
batch_size = 2  # 批处理大小
num_epochs = 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, learning_rate, batch_size, num_epochs)

一些截图(以证明我做了+代码没错误)
在这里插入图片描述

(三).模型评价、预测

1.束搜索

解码器得到的结果,其实还是数字,我们要将其翻译为对应的文字,就需要到字典中进行搜索——故而引出了束搜索。
原文给的搜索方法为贪婪搜索,我写了个束搜索的代码:0.6.0
贪婪搜索

# 定义翻译函数,输入编码器和解码器模型,以及输入序列和最大序列长度
def translate(encoder, decoder, input_seq, max_seq_len):
    # 将输入序列分割成单词,并添加EOS和PAD以填充到最大长度
    in_tokens = input_seq.split(' ')
    in_tokens += [EOS] + [PAD] * (max_seq_len - len(in_tokens) - 1)

    # 将单词转换为整数并创建张量(batch size = 1)
    enc_input = torch.tensor([[in_vocab.stoi[tk] for tk in in_tokens]], dtype=torch.long)

    # 初始化编码器状态
    enc_state = encoder.begin_state()

    # 进行编码
    enc_output, enc_state = encoder(enc_input, enc_state)

    # 初始化解码器输入(开始词)
    dec_input = torch.tensor([out_vocab.stoi[BOS]], dtype=torch.long)

    # 初始化解码器状态,使用编码器的最终状态
    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)

        # 将预测的整数转换回单词
        pred_token = out_vocab.itos[int(pred.item())]

        # 如果预测到EOS,结束翻译
        if pred_token == EOS:
            break
        else:
            # 将预测的单词添加到输出序列
            output_tokens.append(pred_token)
            # 更新解码器输入为当前预测的单词
            dec_input = pred

    # 返回翻译后的单词序列
    return output_tokens
 

翻译结果:
在这里插入图片描述

束搜索

import torch
from collections import defaultdict

def translate(encoder, decoder, input_seq, max_seq_len, beam_width):
    # 将输入序列分割成单词,并添加EOS和PAD以填充到最大长度
    in_tokens = input_seq.split(' ')
    in_tokens += [EOS] + [PAD] * (max_seq_len - len(in_tokens) - 1)

    # 将单词转换为整数并创建张量(batch size = 1)
    enc_input = torch.tensor([[in_vocab.stoi[tk] for tk in in_tokens]], dtype=torch.long)

    # 初始化编码器状态
    enc_state = encoder.begin_state()

    # 进行编码
    enc_output, enc_state = encoder(enc_input, enc_state)

    # 初始化解码器输入(开始词)
    dec_input = torch.tensor([out_vocab.stoi[BOS]], dtype=torch.long)

    # 初始化解码器状态,使用编码器的最终状态
    dec_state = decoder.begin_state(enc_state)

    # 初始化束搜索的数据结构
    sequences = [[list(), 0.0, dec_state]]

    # 遍历最大序列长度
    for _ in range(max_seq_len):
        all_candidates = list()
        # 对于每个当前序列,扩展所有可能的下一个单词
        for i in range(len(sequences)):
            seq, score, state = sequences[i]
            dec_input = torch.tensor([out_vocab.stoi[seq[-1]]], dtype=torch.long) if seq else dec_input
            dec_output, dec_state = decoder(dec_input, state, enc_output)
            log_probs = F.log_softmax(dec_output, dim=1)
            top_log_probs, top_indices = log_probs.topk(beam_width)
            for j in range(beam_width):
                candidate = [seq + [out_vocab.itos[top_indices[0][j].item()]], score + top_log_probs[0][j].item(), dec_state]
                all_candidates.append(candidate)
        # 选择得分最高的束宽度个候选序列
        ordered = sorted(all_candidates, key=lambda tup: tup[1], reverse=True)
        sequences = ordered[:beam_width]

    # 返回得分最高的序列
    return sequences[0][0]
    
input_seq = 'ils regardent .'
for  beam_width in range(3, 11):
    print('beam_width=',beam_width,translate(encoder, decoder, input_seq, max_seq_len, beam_width))

运行结果:
在这里插入图片描述

2.Blue得分

评价机器翻译结果通常使用BLEU(Bilingual Evaluation Understudy)。
具体实现代码为:

# 定义BLEU(Bilingual Evaluation Understudy)得分函数,输入预测单词序列(pred_tokens)和参考标签序列(label_tokens),以及n-gram参数k
def bleu(pred_tokens, label_tokens, k):
    # 获取预测序列和标签序列的长度
    len_pred = len(pred_tokens)
    len_label = len(label_tokens)

    # 初始化得分,根据公式:score = exp(min(0, 1 - len_label / len_pred))
    score = math.exp(min(0, 1 - len_label / len_pred))

    # 遍历1到k(包括k)
    for n in range(1, k + 1):
        # 初始化匹配计数和标签子串计数
        num_matches = 0
        label_subs = collections.defaultdict(int)  # 使用defaultdict存储每个n-gram在标签中的出现次数

        # 遍历标签序列的n-gram
        for i in range(len_label - n + 1):
            label_subs[''.join(label_tokens[i: i + n])] += 1

        # 遍历预测序列的n-gram
        for i in range(len_pred - n + 1):
            # 如果预测的n-gram在标签中出现过,增加匹配计数,并从标签子串计数中减去1
            if label_subs[''.join(pred_tokens[i: i + n])] > 0:
                num_matches += 1
                label_subs[''.join(pred_tokens[i: i + n])] -= 1

        # 根据n-gram匹配的计算公式更新得分:score *= (num_matches / (len_pred - n + 1))^(1/2^n)
        score *= math.pow(num_matches / (len_pred - n + 1), 1 / math.pow(2, n))

    # 返回最终的BLEU得分
    return score
# 定义评估函数,输入一个输入序列(input_seq)和其对应的标签序列(label_seq),以及n-gram参数k
def score(input_seq, label_seq, k):
    # 使用编码器和解码器模型将输入序列翻译成预测单词序列
    pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)

    # 将标签序列分割成单词
    label_tokens = label_seq.split(' ')

    # 计算并打印BLEU得分,保留3位小数
    bleu_score = bleu(pred_tokens, label_tokens, k)
    print('bleu %.3f, predict: %s' % (bleu_score, ' '.join(pred_tokens)))

    # 返回BLEU得分,用于后续的评估或分析
    return bleu_score

展示一下结果:
在这里插入图片描述

至此,本文已完。

总结

本实验包含了NLP中的非常多基础但很重要的内容,例如循环神经网络、Seq2Seq、注意力机制、还有词嵌入、Blue得分、束搜索等。是一个很好的回顾已学习知识的机会。

  • 53
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值