机器翻译及实践 初级版:含注意力机制的编码器—解码器模型
前言
本博客为笔者NLP实验课内容。
行文逻辑如下:
1.介绍2个重要的前置知识:Seq2Seq和注意力机制
至于更基础的词嵌入、RNN等前置知识可看前一篇博客或自行学习
2.实践:法英翻译(使用含注意力机制的编码器—解码器)
一、什么是机器翻译?
其实就是我们所说的翻译,也就是把一句话由一种语言翻译成另一种语言。
按理来说最容易理解的方法是构造一个映射关系或字典,把一个语言中的每个词与另一种语言一一对应起来。但实际实行起来难度很大
一是词太多;
二是对于词组,短语这种含多个词,每个词分开分别有各自的意思,合起来又是另一种意思这种情况很难处理;
还有,输入输出的序列长度不一致,例如输入3个词,输出可能4个词。
以上这些难点都需要我们去考虑,故基于此,我们开始我们本次的实验。
二、所需要的前置知识
延续前一篇博客的传统,本部分还是按照what—why—how的逻辑进行介绍。
how部分主要讲解实现原理,具体代码实现会放在第三节即实践部分进行讲解。
(一).Seq2Seq
1.什么是Seq2Seq
简单的描述其功能就是:利用编码器把a变为b,再利用解码器把b变成c。
在我看来,任何的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′tht,
权重
α
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=1Texp(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得分、束搜索等。是一个很好的回顾已学习知识的机会。