首页 > 其他分享 >机器翻译及实践 初级版:含注意力机制的编码器—解码器模型

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

时间:2024-07-01 15:59:00浏览次数:17  
标签:编码器 seq 机器翻译 tokens state 解码器 size

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


前言

本博客为笔者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} ht−1​变换为当前时间步的隐藏状态 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​,ht−1​).

接下来,编码器通过自定义函数 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​,…,yt′−1​和背景变量 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(yt′​∣y1​,…,yt′−1​,c)。

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

为此,我们可以使用另一个循环神经网络作为解码器。在输出序列的时间步 t ′ t^\prime t′,解码器将上一时间步的输出 y t ′ − 1 y_{t^\prime-1} yt′−1​以及背景变量 c \boldsymbol{c} c作为输入,并将它们与上一时间步的隐藏状态 s t ′ − 1 \boldsymbol{s}_{t^\prime-1} st′−1​变换为当前时间步的隐藏状态 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(yt′−1​,c,st′−1​).
(其实我们就是要更新这个 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(yt′​∣y1​,…,yt′−1​,c),例如,基于当前时间步的解码器隐藏状态 s t ′ \boldsymbol{s}_{t^\prime} st′​、上一时间步的输出 y t ′ − 1 y_{t^\prime-1} yt′−1​以及背景变量 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​,…,yT′​∣x1​,…,xT​)​=t′=1∏T′​P(yt′​∣y1​,…,yt′−1​,x1​,…,xT​)=t′=1∏T′​P(yt′​∣y1​,…,yt′−1​,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​,…,yT′​∣x1​,…,xT​)=−t′=1∑T′​logP(yt′​∣y1​,…,yt′−1​,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} yt′−1​以及背景变量 c \boldsymbol{c} c作为输入,并将它们与上一时间步的隐藏状态 s t ′ − 1 \boldsymbol{s}_{t^\prime-1} st′−1​变换为当前时间步的隐藏状态 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=1∑T​αt′t​ht​,
权重 α t ′ t \alpha_{t' t} αt′t​可通过以下方法计算出:
α 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. αt′t​=∑k=1T​exp(et′k​)exp(et′t​)​,t=1,…,T.
e t ′ t e_{t' t} et′t​同时取决于解码器的时间步 t ′ t' t′和编码器的时间步 t t t,我们不妨以解码器在时间步 t ′ − 1 t'-1 t′−1的隐藏状态 s t ′ − 1 \boldsymbol{s}_{t' - 1} st′−1​与编码器在时间步 t t t的隐藏状态 h t \boldsymbol{h}_t ht​为输入,并通过函数 a a a计算 e t ′ t e_{t' t} et′t​:

e t ′ t = a ( s t ′ − 1 , h t ) . e_{t' t} = a(\boldsymbol{s}_{t' - 1}, \boldsymbol{h}_t). et′t​=a(st′−1​,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)=s⊤h。假设我们希望根据解码器单个隐藏状态 s t ′ − 1 ∈ R h \boldsymbol{s}_{t' - 1} \in \mathbb{R}^{h} st′−1​∈Rh和编码器所有隐藏状态 h t ∈ R h , t = 1 , … , T \boldsymbol{h}_t \in \mathbb{R}^{h}, t = 1,\ldots,T ht​∈Rh,t=1,…,T来计算背景向量 c t ′ ∈ R h \boldsymbol{c}_{t'}\in \mathbb{R}^{h} ct′​∈Rh。
我们可以将查询项矩阵 Q ∈ R 1 × h \boldsymbol{Q} \in \mathbb{R}^{1 \times h} Q∈R1×h设为 s t ′ − 1 ⊤ \boldsymbol{s}_{t' - 1}^\top st′−1⊤​,并令键项矩阵 K ∈ R T × h \boldsymbol{K} \in \mathbb{R}^{T \times h} K∈RT×h和值项矩阵 V ∈ R T × h \boldsymbol{V} \in \mathbb{R}^{T \times h} V∈RT×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得分、束搜索等。是一个很好的回顾已学习知识的机会。

标签:编码器,seq,机器翻译,tokens,state,解码器,size
From: https://blog.csdn.net/wwyhhh/article/details/139975422

相关文章

  • 编码器的稀疏注意力块(ProbSparse Self-Attention Block)
    编码器的稀疏注意力块(ProbSparseSelf-AttentionBlock)详细解释1.概述稀疏注意力块是Informer模型的核心组件之一,旨在高效处理长时间序列数据。它通过稀疏自注意力机制(ProbSparseSelf-Attention)显著降低计算复杂度,同时保持较高的性能。2.主要组件稀疏注意力块由以下......
  • 编码器的蒸馏(Distilling)详细解释
    编码器的蒸馏(Distilling)详细解释概述蒸馏(Distilling)步骤是在稀疏注意力块之后,用于进一步压缩和提炼特征表示。这个步骤的主要目的是减少序列长度,使得模型能够更有效地处理长时间序列数据,同时保持重要的特征信息。主要步骤1x3卷积层(Conv1d)ELU激活函数最大池化(MaxPooli......
  • Transformer细节(五)——详解Transformer解码器的自注意力层和编码器-解码器注意力层数
    一、自注意力层(Self-AttentionLayer)并行处理目标序列        自注意力层的任务是计算输入序列中每个位置之间的关系,并生成每个位置的表示。这一过程可以并行处理,因为它并不依赖于前一个位置的计算结果。自注意力机制的具体步骤1.输入嵌入与位置编码      ......
  • three.js 第八节 - gltf加载器、解码器
    //@ts-nocheck//引入three.jsimport*asTHREEfrom'three'//导入轨道控制器import{OrbitControls}from'three/examples/jsm/controls/OrbitControls'//导入hdr加载器(专门加载hdr的)import{RGBELoader}from'three/examples/jsm/loaders/RGBELoad......
  • 8路编码器脉冲计数器或16路DI高速计数器,Modbus RTU模块 YL69-485/232
    特点:●编码器解码转换成标准ModbusRTU协议●可用作编码器计数器或者转速测量●支持8个编码器同时计数,可识别正反转●也可以设置作为16路独立DI高速计数器● 编码器计数值支持断电自动保存● DI输入和电源之间3000V隔离●通过RS-485/232接口可以清零和设置计数......
  • 基于哔哩哔哩视频库的音频提取播放器,实现下载B站音频到本地,方便把鬼畜下载到手机上,项
    importreimportjsonimportthreadingimporttimeimportosimportshutilimportsubprocessimportrequestsimportPySimpleGUIassgos.environ['PYGAME_HIDE_SUPPORT_PROMPT']="hide"frompygameimportmixersg.theme('SystemDef......
  • 在AMD GPUs上构建解码器Transformer模型
    BuildingadecodertransformermodelonAMDGPU(s)—ROCmBlogs在这篇博客中,我们展示了如何使用PyTorch2.0和ROCm在单个节点上的单个和多个AMDGPU上运行AndrejKarpathy精美的PyTorch重新实现的GPT。我们使用莎士比亚的作品来训练我们的模型,然后进行推理,看看我们的模......
  • 9.2.1 简述图像分割中经常用到的编码器-解码器网络结构的设计理念。
    9.2图像分割场景描述:图像分类图像识别图像分割不同标注出每个目标的类别像素级别的图像识别,标注出图像中每个像素所属的对象类别不同对整张图像进行识别进行稠密的像素级分类应用场景视频软件中的背景替换、避开人物的弹幕模板、自动驾驶以及医疗辅助判断等分类前景分割(f......
  • 杂项——编码器控制小车走固定距离(stm32)
    先算出轮子转一周编码器的数值(假设为1000),再算出轮子一周的周长(假设为10cm)。那么要前进1米只需要转1米/10cm等于10圈,10圈编码器数值为10000。我们只需要在代码里面写出编码器数值如果<10000则让小车前进,如果>10000让小车停止,即可实现控制小车走固定的距离编码器.c函数(编码器......
  • Python深度学习实践:自动编码器在数据降维中的应用
    Python深度学习实践:自动编码器在数据降维中的应用1.背景介绍在现代数据科学和机器学习领域中,高维数据处理是一个常见的挑战。许多真实世界的数据集包含大量的特征,这些特征往往存在高度的冗余和噪声。高维数据不仅增加了计算复杂性,还容易导致维数灾难(curseofdimensio......