一.实验介绍
近年来,随着深度学习技术的不断发展,自然语言处理(NLP)领域取得了显著进展。其中,注意力(Attention)机制在机器翻译领域的应用尤为突出。本文将介绍如何使用注意力机制实现机器翻译。
机器翻译是指将一段文本从一种语言自动翻译到另一种语言。因为一段文本序列在不同语言中的长度不一定相同,所以我们使用机器翻译为例来介绍编码器—解码器和注意力机制的应用。
二.实验基本原理模型介绍
2.1 编码器—解码器(seq2seq)
所谓的seq2seq模型就是我们常说的编码器-解码器架构,Encoder和Decoder层一般采用循环神经网络。
模型结构图:
编码器用来分析输入序列,解码器用来生成输出序列。在训练数据集中,我们可以在每个句子后附上特殊符号“”(end of sequence)以表示序列的终止。编码器每个时间步的输入依次为英语句子中的单词、标点和特殊符号“”。下面的图中使用了编码器在最终时间步的隐藏状态作为输入句子的表征或编码信息。解码器在各个时间步中使用输入句子的编码信息和上个时间步的输出以及隐藏状态作为输入。我们希望解码器在各个时间步能正确依次输出翻译后的法语单词、标点和特殊符号"“。需要注意的是,解码器在最初时间步的输入用到了一个表示序列开始的特殊符号”"(beginning of sequence)。
2.1.1 编码器(Encoder)
编码器的作用是把一个不定长的输入序列变换成一个定长的背景变量 c \boldsymbol{c} c,并在该背景变量中编码输入序列信息。常用的编码器是循环神经网络。
让我们考虑批量大小为1的时序数据样本。假设输入序列是 x 1 , … , x T x_1,\ldots,x_T x1,…,xT,例如 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).
例如,当选择 q ( h 1 , … , h T ) = h T q(\boldsymbol{h}_1, \ldots, \boldsymbol{h}_T) = \boldsymbol{h}_T q(h1,…,hT)=hT时,背景变量是输入序列最终时间步的隐藏状态 h T \boldsymbol{h}_T hT。
以上描述的编码器是一个单向的循环神经网络,每个时间步的隐藏状态只取决于该时间步及之前的输入子序列。我们也可以使用双向循环神经网络构造编码器。在这种情况下,编码器每个时间步的隐藏状态同时取决于该时间步之前和之后的子序列(包括当前时间步的输入),并编码了整个序列的信息。
2.2.2 解码器(Decoder)
刚刚已经介绍,编码器输出的背景变量 c \boldsymbol{c} c编码了整个输入序列 x 1 , … , x T x_1, \ldots, x_T x1,…,xT的信息。给定训练样本中的输出序列 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)。
为此,我们可以使用另一个循环神经网络作为解码器。在输出序列的时间步 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).
有了解码器的隐藏状态后,我们可以使用自定义的输出层和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′的概率分布。
2.2.3 Encoder-Decoder模型的训练
根据最大似然估计,我们可以最大化输出序列基于输入序列的条件概率
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),
在模型训练中,所有输出序列损失的均值通常作为需要最小化的损失函数。在图10.8所描述的模型预测中,我们需要将解码器在上一个时间步的输出作为当前时间步的输入。与此不同,在训练中我们也可以将标签序列(训练集的真实输出序列)在上一个时间步的标签作为解码器在当前时间步的输入。这叫作强制教学(teacher forcing)。
2.2 束搜索
那么我们该如何使用编码器—解码器来预测不定长的序列?
上面的内容提到,在准备训练数据集时,我们通常会在样本的输入序列和输出序列后面分别附上一个特殊符号"<eos>“表示序列的终止。我们在接下来的讨论中也将沿用上面全部数学符号。为了便于讨论,假设解码器的输出是一段文本序列。设输出文本词典
Y
\mathcal{Y}
Y(包含特殊符号”<eos>“)的大小为
∣
Y
∣
\left|\mathcal{Y}\right|
∣Y∣,输出序列的最大长度为
T
′
T'
T′。所有可能的输出序列一共有
O
(
∣
Y
∣
T
′
)
\mathcal{O}(\left|\mathcal{Y}\right|^{T'})
O(∣Y∣T′)种。这些输出序列中所有特殊符号”<eos>"后面的子序列将被舍弃。
2.2.1 贪婪搜索
让我们先来看一个简单的解决方案:贪婪搜索(greedy search)。对于输出序列任一时间步 t ′ t' t′,我们从 ∣ Y ∣ |\mathcal{Y}| ∣Y∣个词中搜索出条件概率最大的词
y t ′ = argmax y ∈ Y P ( y ∣ y 1 , … , y t ′ − 1 , c ) y _ { t ^ { \prime } } = \underset { y \in \mathcal { Y } } { \operatorname { argmax } } P \left( y | y _ { 1 } , \ldots , y _ { t ^ { \prime } - 1 } , c \right) yt′=y∈YargmaxP(y∣y1,…,yt′−1,c)
作为输出。一旦搜索出"<eos>"符号,或者输出序列长度已经达到了最大长度 T ′ T' T′,便完成输出。
优缺点
- 优点:
实现简单,计算效率高,因为它不需要探索所有可能的输出序列路径。 - 缺点:
可能陷入局部最优解,因为它每一步都只考虑当前最可能的词,忽略了全局最优序列的可能性。这意味着,即使后续有更优的选择,一旦某一步做出了非全局最佳的选择,就无法回头修正。
对于具有多个合理解的情况,贪婪搜索可能无法找到最自然或最准确的翻译或回复。
因此,尽管贪婪搜索因其高效性而常用作基准或初步探索,但在追求高质量输出时,研究者往往会采用更复杂的解码策略,如束搜索(Beam Search),它保留了多个可能的解并从中选择整体最优解。
2.2.2 穷举搜索
如果目标是得到最优输出序列,我们可以考虑穷举搜索(exhaustive search):穷举所有可能的输出序列,输出条件概率最大的序列。
虽然穷举搜索可以得到最优输出序列,但它的计算开销 O ( ∣ Y ∣ T ′ ) \mathcal{O}(\left|\mathcal{Y}\right|^{T'}) O(∣Y∣T′)很容易过大。例如,当 ∣ Y ∣ = 10000 |\mathcal{Y}|=10000 ∣Y∣=10000且 T ′ = 10 T'=10 T′=10时,我们将评估 1000 0 10 = 1 0 40 10000^{10} = 10^{40} 1000010=1040个序列:这几乎不可能完成。而贪婪搜索的计算开销是 O ( ∣ Y ∣ T ′ ) \mathcal{O}(\left|\mathcal{Y}\right|T') O(∣Y∣T′),通常显著小于穷举搜索的计算开销。例如,当 ∣ Y ∣ = 10000 |\mathcal{Y}|=10000 ∣Y∣=10000且 T ′ = 10 T'=10 T′=10时,我们只需评估 10000 × 10 = 1 0 5 10000\times10=10^5 10000×10=105个序列。
2.2.3 束搜索
束搜索(Beam Search)是序列生成任务中一种改进的搜索策略,是对贪婪搜索的一个改进算法。尤其适用于Seq2Seq模型的解码阶段,以提高预测序列的质量。相比贪婪搜索每次只选择最可能的词,束搜索考虑了更多可能的路径,从而提高了找到全局最优解的机会。
工作流程
-
初始化:与贪婪搜索相似,束搜索开始时也使用特殊标记(如)和编码器的最终状态初始化解码器。
-
扩展与评分:在每一步,解码器为当前状态下的每一个可能的下一个词生成概率分布。接着,基于当前的束内序列,对所有可能的扩展(当前序列加上每个可能的下一个词)进行评分。评分通常依据累积概率,即序列中每个词的生成概率乘积。
-
修剪与保留:根据累积概率对所有扩展序列排序,并保留前k个最佳序列作为下一时间步的候选,这里的k就是束宽。束宽决定了搜索的广度和计算成本之间的平衡,较大的k可以增加找到最优解的概率,但同时也增加了计算负担。
-
终止条件:搜索过程持续进行,直到所有保留的序列生成结束标记(如)或达到预设的最大输出长度。
-
选择最优序列:最后,从所有完成的序列中选择累积概率最高的序列作为最终输出。
优缺点
- 优点:
提高了解码质量,因为考虑了多个可能的路径,减少了因局部最优选择导致的错误。
可以通过调整束宽来平衡解码质量和计算资源消耗。 - 缺点:
** 计算成本高于贪婪搜索,尤其是在大束宽或长序列情况下。
** 仍然不能保证找到全局最优解,只是在有限的搜索空间内找到相对较好的解。
** 需要设置合适的束宽,过小可能错过最优解,过大则增加计算复杂度。
束搜索在许多自然语言处理任务中,如机器翻译、语音识别、文本摘要等,都展现出了优于贪婪搜索的性能,特别是在要求较高生成质量的应用场景中。
2.3 注意力机制
注意力机制(Attention Mechanism)是机器学习中的一种数据处理方法,广泛应用在自然语言处理、图像识别以及语音识别等各种不同类型的机器学习任务中。注意力机制(Attention Mechanism)是深度学习领域中,特别是在序列到序列(Seq2Seq)模型、图像描述生成等任务中引入的一种重要技术。它的核心思想是让模型在处理输入序列时,能够动态地、有选择性地关注输入的不同部分,而不是均匀对待所有输入。这对于处理长序列或者需要理解输入序列中某些关键信息的任务尤为重要。下面是注意力机制的基本概念和工作原理:
2.3.1基本概念
在传统的Seq2Seq模型中,编码器将输入序列编码成一个固定长度的向量,这个向量被解码器用来生成输出序列。然而,当输入序列很长时,单一的固定向量难以捕获所有必要的细节信息。注意力机制的引入解决了这一问题,它允许解码器在生成每一个输出词时,都能直接“查看”输入序列的不同部分,并赋予它们不同的权重(注意力权重),从而聚焦于与当前输出最相关的输入部分。
注意注意力与普通解码器的区别:注意力机制通过对编码器所有时间步的隐藏状态做加权平均来得到背景变量
注意力机制对不同信息的关注程度(重要程度)由权值来体现,注意力机制可以视为查询矩阵(Query)、键(key)以及加权平均值构成了多层感知机(Multilayer Perceptron, MLP)。
注意力的思想,类似于寻址。给定Target中的某个元素Query,通过计算Query和各个Key的相似性或相关性,得到每个Key对应Value的权重系数,然后对Value进行加权求和,即得到最终的Attention数值。所以,本质上Attention机制是Source中元素的Value值进行加权求和,而Query和Key用来计算对应Value的权重系数。
Source:由一系列的<Key, Value>键值对构成。
Query:给定的Target元素;
Key:Source中元素的Key值;
Value:Source中元素的Value值;
权重系数:Query与key的相似性或相关性,权重系数: S i m i l a r i t y ( Q u e r y , K e y i ) S i m i l a r i t y ( Q u e r y , K e y i ) Similarity(Query,Keyi)Similarity(Query, Key_i)Similarity(Query,Keyi );
Attention Value:对Value值进行加权求和;
根据注意力机制的原理可知,其计算公式如下:
A
t
t
e
n
t
i
o
n
(
Q
u
e
r
y
,
s
o
u
r
c
e
)
=
∑
S
i
m
i
l
a
r
i
t
y
(
Q
u
e
r
y
,
K
e
y
i
)
∗
V
a
l
u
e
i
Attention(Query,source)= ∑Similarity(Query,Keyi)∗Valuei
Attention(Query,source)=∑Similarity(Query,Keyi)∗Valuei
Attention从大量信息中有选择地筛选出少量重要信息并聚焦到这些重要信息上,忽略大多不重要的信息。聚焦的过程体现在权重系数的计算上,权重越大,越聚焦在对应的Value值上,即权重代表了信息的重要性,而Value是其对应的信息。
2.3.2 注意力机制的计算
大多数方法采用的注意力机制计算过程可以细化为如下三个阶段。
三阶段的注意力机制计算流程:
-
第一阶段,计算 Query和不同 Key 的相关性,即计算不同 Value 值的权重系数;
常用的方法就是点积或缩放点积。
-
第二阶段,对上一阶段的输出进行归一化处理,将数值的范围映射到 0 和 1 之间。
根据产生方法的不同,第一阶段产生的分值的取值范围也不一样,第二阶段引入类似SoftMax的计算方式对第一阶段的得分就行数值转换。一方面,可以进行归一化,将原始计算分值整理成所有元素权重之和为1的概率分布;另一方面,通过SoftMax的内在机制更加突出重要元素的权重。即一般采用如下公式:
-
第三阶段,根据权重系数对Value进行加权求和,从而得到最终的注意力数值。
2.3.3 注意力的种类
- 硬注意力机制(Hard-Attention):硬注意力更加关注点,也就是图像中的每个点都可能延伸出注意力。同时,硬注意力是一个随机的预测过程,更强调动态变化。硬注意力是一个不可微的注意力,训练过程往往是通过增强学习(reinforcement learning) 来完成。
- 软注意力机制(Soft-Attention): 软注意力更加关注区域或者通道,软注意力是确定性的注意力,学习完成后可以直接通过网络生成,最关键的地方是软注意力是可微的。可微的注意力可以通过神经网络计算梯度,通过前向传播和后向反馈来学习得到注意力的权重。软注意力是[0,1]间连续分布问题,用0到1的不同分值表示每个区域被关注的程度高低。
本质上,注意力机制能够为表征中较有价值的部分分配较多的计算资源。这个有趣的想法自提出后得到了快速发展,特别是启发了依靠注意力机制来编码输入序列并解码出输出序列的变换器(Transformer)模型的设计 。变换器抛弃了卷积神经网络和循环神经网络的架构。它在计算效率上比基于循环神经网络的编码器—解码器模型通常更具明显优势。含注意力机制的变换器的编码结构在后来的BERT预训练模型中得以应用并令后者大放异彩:微调后的模型在多达11项自然语言处理任务中取得了当时最先进的结果。不久后,同样是基于变换器设计的GPT-2模型于新收集的语料数据集预训练后,在7个未参与训练的语言模型数据集上均取得了当时最先进的结果。除了自然语言处理领域,注意力机制还被广泛用于图像分类、自动图像描述、唇语解读以及语音识别。
三.机器翻译实验过程
3.1 读取和预处理数据
我们要定义一些特殊符号。其中“”(padding)符号用来添加在较短序列后,直到每个序列等长,而“”和“”符号分别表示序列的开始和结束。
定义辅助函数来实现对数据集的读取和处理:
# 将一个序列中所有的词记录在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)
all_seqs.append(seq_tokens)
# 使用所有的词来构造词典。并将所有序列中的词变换为词索引后构造Tensor
def build_data(all_tokens, all_seqs):
vocab = Vocab.Vocab(collections.Counter(all_tokens),
specials=[PAD, BOS, EOS])
indices = [[vocab.stoi[w] for w in seq] for seq in all_seqs]
return vocab, torch.tensor(indices)
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)
process_one_seq(out_seq_tokens, out_tokens, out_seqs, max_seq_len)
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)
为了演示方便,我们在这里使用一个很小的法语—英语数据集。在这个数据集里,每一行是一对法语句子和它对应的英语句子,中间使用'\t'
隔开。在读取数据时,我们在句末附上“<eos>”符号,并可能通过添加“<pad>”符号使每个序列的长度均为max_seq_len
。我们为法语词和英语词分别创建词典。法语词的索引和英语词的索引相互独立。
3.2 定义编码器和解码器架构
3.2.1 编码器(Encoder)
在编码器中,我们将输入语言的词索引通过词嵌入层得到词的表征,然后输入到一个多层门控循环单元中。下面我们来创建一个批量大小为4、时间步数为7的小批量序列输入,门控循环单元的隐藏层个数为2,隐藏单元个数为16的编码器。
class Encoder(nn.Module):
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
drop_prob=0, **kwargs):
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)
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):
return None
encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
3.2.2 实现注意力机制
注意力机制的输入包括查询项、键项和值项。设编码器和解码器的隐藏单元个数相同。这里的查询项为解码器在上一时间步的隐藏状态,形状为(批量大小, 隐藏单元个数);键项和值项均为编码器在所有时间步的隐藏状态,形状为(时间步数, 批量大小, 隐藏单元个数)。注意力机制返回当前时间步的背景变量,形状为(批量大小, 隐藏单元个数)。
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) # 返回背景变量
3.2.3 解码器(Decoder)
在解码器的前向计算中,我们先通过刚刚介绍的注意力机制计算得到当前时间步的背景向量。由于解码器的输入来自输出语言的词索引,我们将输入通过词嵌入层得到表征,然后和背景向量在特征维连结。我们将连结后的结果与上一时间步的隐藏状态通过门控循环单元计算出当前时间步的输出与隐藏状态。最后,我们将输出通过全连接层变换为有关各个输出词的预测,形状为(批量大小, 输出词典大小)。
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,
num_layers, dropout=drop_prob)
self.out = nn.Linear(num_hiddens, vocab_size)
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
3.3 模型迭代训练
3.3.1 实现批量损失的计算函数
我们先实现batch_loss
函数计算一个小批量的损失。
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])
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
3.3.2 定义训练函数创建模型示例开始训练
在训练函数中,我们需要同时迭代编码器和解码器的模型参数。
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)
训练过程如下:
3.4 模型测试并评价结果
之前我们介绍了3种方法来生成解码器在每个时间步的输出。这里我们实现最简单的贪婪搜索。
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)
enc_input = torch.tensor([[in_vocab.stoi[tk] for tk in in_tokens]]) # batch=1
enc_state = encoder.begin_state()
enc_output, enc_state = encoder(enc_input, enc_state)
dec_input = torch.tensor([out_vocab.stoi[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)
pred_token = out_vocab.itos[int(pred.item())]
if pred_token == EOS: # 当任一时间步搜索出EOS时,输出序列即完成
break
else:
output_tokens.append(pred_token)
dec_input = pred
return output_tokens
input_seq = 'ils regardent .'
translate(encoder, decoder, input_seq, max_seq_len)
简单测试一下模型。输入法语句子“ils regardent.”,翻译后的英语句子应该是“they are watching.”。
测试结果:
3.5 基于Transformer实现机器翻译(日译中)
3.5.1 引入必要的工具包以及数据集获取
首先,我们要确保系统中已安装以下包。如果发现缺少某些包,请务必安装它们。
代码如下:
import math
import torchtext
import torch
import torch.nn as nn
from torch import Tensor
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader
from collections import Counter
from torchtext.vocab import Vocab
from torch.nn import TransformerEncoder, TransformerDecoder, TransformerEncoderLayer, TransformerDecoderLayer
import io
import time
import pandas as pd
import numpy as np
import pickle
import tqdm
import sentencepiece as spm
torch.manual_seed(0) # 定义随机种子
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# print(torch.cuda.get_device_name(0)) ## 如果你有GPU,请在你自己的电脑上尝试运行这一套代码
在本教程中,我们将使用的日英平行数据集来源于JParaCrawl,数据集该数据集被描述为“由NTT创建的最大的公开可用的日英平行语料库。它是通过大规模网络爬取并自动对齐平行句子的方式构建的。” 相关论文可在此处查阅。
数据集获取:
df = pd.read_csv('./zh-ja/zh-ja.bicleaner05.txt', sep='\\t', engine='python', header=None)
trainen = df[2].values.tolist()#[:10000]
trainja = df[3].values.tolist()#[:10000]
# trainen.pop(5972)
# trainja.pop(5972)
在导入所有日语及其对应的英语句子后,我删除了数据集中最后一项含有缺失值的记录。目前,trainen(英语训练集)和trainja(日语训练集)中的句子总数合计为5,973,071句。然而,为了确保学习过程顺畅且节约时间,通常建议先对数据进行抽样,检验一切是否按预期工作,之后再使用全部数据进行训练。这样做的目的是在大规模投入资源前,通过小规模数据验证模型的配置、代码逻辑以及整个流程是否正确无误。
打印数据示例如下:
print(trainen[500])
print(trainja[500])
输出结果:
3.5.2 构建分词器和词汇表
与英语或其他字母语言不同,日语句子中并没有空格来分隔单词。我们可以使用JParaCrawl提供的分词器,这些分词器针对日语和英语都采用了SentencePiece技术进行创建。你可以访问JParaCrawl的官方网站下载这些分词器,或者直接点击此处进行获取。
SentencePiece是一种流行的文本处理工具,尤其适合处理像日语这样的没有自然空格分隔的语言。它通过统计方法将文本切分成子词单元(tokens),这些单元可以是字符、子词或整个词,具体取决于训练数据和设置。使用JParaCrawl提供的基于SentencePiece的分词模型,可以帮助我们高效且准确地对日英双语文本进行分词,为后续的自然语言处理任务,比如机器翻译,打下基础。
en_tokenizer = spm.SentencePieceProcessor(model_file='enja_spm_models/spm.en.nopretok.model')
ja_tokenizer = spm.SentencePieceProcessor(model_file='enja_spm_models/spm.ja.nopretok.model')
在加载分词器之后,你可以通过运行以下类似的代码来测试它们的功能。
en_tokenizer.encode("All residents aged 20 to 59 years who live in Japan must enroll in public pension system.", out_type='str')
ja_tokenizer.encode("年金 日本に住んでいる20歳~60歳の全ての人は、公的年金制度に加入しなければなりません。", out_type='str')
测试结果如下:
待补充
- 构建TorchText词汇表及将句子转换为Torch张量
使用分词器和原始句子,接下来我们构建来自TorchText的词汇表(Vocab)对象。此过程可能需要几秒钟到几分钟的时间,具体取决于数据集的大小和计算能力。不同的分词器也会影响构建词汇表所需的时间,我尝试了几种其他适用于日语的分词器,但SentencePiece似乎工作得既好又快速,对我来说足够了。于是,我们将句子转换为Torch张量,以便神经网络模型能够直接处理这些数据。
构建词汇表之后,我们需要将原始的句子数据转换为Torch张量(Tensors)。这是深度学习模型训练所必需的,因为模型不能直接理解或操作文本字符串,而需要数值型输入。转换过程包括将每个句子中每个单词的索引(从词汇表中获得)编码为整数序列,然后这些整数序列可以被转换成PyTorch的张量形式,用于后续的模型输入。
# 定义一个构建词汇表的函数
def build_vocab(sentences, tokenizer):
# 使用Counter来计数句子中各个token的出现频率
counter = Counter()
# 遍历所有句子
for sentence in sentences:
# 使用tokenizer对句子进行编码,并更新到counter中,out_type=str确保计数的是字符串形式的token
counter.update(tokenizer.encode(sentence, out_type=str))
# 根据counter创建词汇表实例,同时包含特殊符号:未知词('<unk>')、填充符('<pad>')、句首发('<bos>')、句尾符('<eos>')
return Vocab(counter, specials=['<unk>', '<pad>', '<bos>', '<eos>'])
# 利用定义的函数为日语文本数据(trainja)构建词汇表
ja_vocab = build_vocab(trainja, ja_tokenizer)
# 同样,为英语文本数据(trainen)构建词汇表
en_vocab = build_vocab(trainen, en_tokenizer)
在我们拥有词汇表对象之后,就可以利用这些词汇表和分词器对象来为训练数据构建张量了。具体做法是,首先使用分词器将每条原始文本分割成单词或子词的序列,然后依据词汇表中每个单词或子词的索引,将这些序列转换成整数序列。最后,将这些整数序列封装成PyTorch的张量格式,这样我们的深度学习模型就能直接处理这些数值化的文本数据,进行诸如机器翻译、文本分类或情感分析等自然语言处理任务的训练。这个过程确保了数据的高效表示,同时便于模型理解和运算。
3.5.3 创建数据加载器对象
在这里,我将BATCH_SIZE设置为16,以防“CUDA内存不足”的情况发生,但这一设置取决于多种因素,比如你的机器内存容量、数据大小等,因此请根据实际情况自由调整批处理大小(注:PyTorch的官方教程在使用Multi30k德英数据集时将批处理大小设置为了128。)
在进行深度学习模型训练时,创建一个数据加载器(DataLoader)是非常关键的步骤。数据加载器的作用是从数据集中按批次取出数据,并提供给模型进行训练,同时它还可以实现数据的随机打乱、数据加载的多线程加速等功能,从而提高训练效率。通过调整BATCH_SIZE,我们可以控制每次送入模型进行训练的样本数量,较小的批尺寸有助于减少内存消耗,但可能增加训练时间;较大的批尺寸则能加速训练过程,但需更多内存支持。因此,选择合适的批尺寸是一个权衡内存使用和训练效率的过程。在实际应用中,确实需要根据个人电脑或服务器的具体硬件配置以及数据集的特性来灵活设定。
# 设置批次大小
BATCH_SIZE = 8
# 获取Padding、开始(BOS)和结束(EOS)符号在词汇表中的索引
PAD_IDX = ja_vocab['<pad>'] # Padding符号的索引
BOS_IDX = ja_vocab['<bos>'] # 开始符号的索引
EOS_IDX = ja_vocab['<eos>'] # 结束符号的索引
# 定义生成批次数据的函数
def generate_batch(data_batch):
# 初始化两个空列表,分别用于存放本批次的日语和英语数据
ja_batch, en_batch = [], []
# 遍历当前批次中的每一对日英句子数据
for japanese_item, english_item in data_batch:
# 对每条日语文本,在句首添加开始符号(BOS),句尾添加结束符号(EOS),然后使用torch.cat拼接起来
ja_batch.append(torch.cat([torch.tensor([BOS_IDX]), japanese_item, torch.tensor([EOS_IDX])], dim=0))
# 对应地,对每条英语文本也执行相同的处理
en_batch.append(torch.cat([torch.tensor([BOS_IDX]), english_item, torch.tensor([EOS_IDX])], dim=0))
# 使用pad_sequence函数对处理后的日语和英语序列进行填充,确保每个batch内的序列长度一致,填充的值为PAD_IDX
ja_batch = pad_sequence(ja_batch, padding_value=PAD_IDX)
en_batch = pad_sequence(en_batch, padding_value=PAD_IDX)
# 返回填充后的日语和英语批次数据
return ja_batch, en_batch
# 使用DataLoader创建迭代器,用于训练数据的加载
# 指定批次大小、是否随机打乱数据、以及自定义的生成批次数据函数
train_iter = DataLoader(train_data, batch_size=BATCH_SIZE,
shuffle=True, collate_fn=generate_batch)
3.5.4 构建序列到序列的Transformer模型
Transformer是一种序列到序列(Seq2Seq)模型,它是在《注意力就是你所需要的》这篇论文中首次提出的,旨在解决机器翻译任务。Transformer模型由编码器(Encoder)和解码器(Decoder)两部分组成,每一部分都含有固定数量的层。
编码器通过一系列的多头注意力(Multi-head Attention)和前馈网络(Feed Forward network)层来处理输入序列。编码器的输出,即所谓的记忆(memory),会被传递给解码器,同时解码器也会接收到目标序列的信息。编码器和解码器采用端到端的方式进行联合训练,其中使用了教师强迫(teacher forcing)技术,即在训练过程中,解码器的每一步都直接使用真实的前一时刻输出作为输入,而非模型预测的输出,以此来加速并稳定训练过程。
import torch.nn as nn
class Seq2SeqTransformer(nn.Module):
def __init__(self, num_encoder_layers: int, num_decoder_layers: int,
emb_size: int, src_vocab_size: int, tgt_vocab_size: int,
dim_feedforward:int = 512, dropout:float = 0.1):
"""
序列到序列(Seq2Seq)的Transformer模型初始化方法。
参数:
- num_encoder_layers: 编码器中的层数
- num_decoder_layers: 解码器中的层数
- emb_size: 词嵌入的维度
- src_vocab_size: 源语言词汇表大小
- tgt_vocab_size: 目标语言词汇表大小
- dim_feedforward: 前馈网络的维度,默认为512
- dropout: Dropout比例,默认为0.1
"""
super(Seq2SeqTransformer, self).__init__()
# 定义编码器层
encoder_layer = TransformerEncoderLayer(d_model=emb_size, nhead=NHEAD, dim_feedforward=dim_feedforward)
# 使用定义的层构建编码器
self.transformer_encoder = TransformerEncoder(encoder_layer, num_layers=num_encoder_layers)
# 同样定义解码器层
decoder_layer = TransformerDecoderLayer(d_model=emb_size, nhead=NHEAD, dim_feedforward=dim_feedforward)
# 构建解码器
self.transformer_decoder = TransformerDecoder(decoder_layer, num_layers=num_decoder_layers)
# 生成器用于从模型输出映射到目标词汇表
self.generator = nn.Linear(emb_size, tgt_vocab_size)
# 源语言和目标语言的词嵌入层
self.src_tok_emb = TokenEmbedding(src_vocab_size, emb_size)
self.tgt_tok_emb = TokenEmbedding(tgt_vocab_size, emb_size)
# 位置编码层,为序列中的每个位置提供一个固定的向量
self.positional_encoding = PositionalEncoding(emb_size, dropout=dropout)
def forward(self, src: Tensor, trg: Tensor, src_mask: Tensor,
tgt_mask: Tensor, src_padding_mask: Tensor,
tgt_padding_mask: Tensor, memory_key_padding_mask: Tensor):
"""
模型前向传播过程。
参数:
- src: 源语言输入序列
- trg: 目标语言输入序列
- src_mask: 源语言自注意力的掩码
- tgt_mask: 目标语言自注意力的掩码
- src_padding_mask: 源语言序列的Padding掩码
- tgt_padding_mask: 目标语言序列的Padding掩码
- memory_key_padding_mask: 编码器输出用于解码器的Padding掩码
"""
# 对源语言和目标语言序列应用词嵌入和位置编码
src_emb = self.positional_encoding(self.src_tok_emb(src))
tgt_emb = self.positional_encoding(self.tgt_tok_emb(trg))
# 通过编码器生成记忆向量
memory = self.transformer_encoder(src_emb, src_mask, src_padding_mask)
# 通过解码器生成输出序列
outs = self.transformer_decoder(tgt_emb, memory, tgt_mask, None,
tgt_padding_mask, memory_key_padding_mask)
# 通过线性层映射到目标词汇表大小,得到预测输出
return self.generator(outs)
def encode(self, src: Tensor, src_mask: Tensor):
"""
单独执行编码器部分。
"""
# 接受源语言序列和掩码,返回编码器的输出
return self.transformer_encoder(self.positional_encoding(self.src_tok_emb(src)), src_mask)
def decode(self, tgt: Tensor, memory: Tensor, tgt_mask: Tensor):
"""
给定目标语言序列、编码器的输出记忆以及目标语言掩码,执行解码器部分。
"""
# 生成解码器的输出
return self.transformer_decoder(self.positional_encoding(self.tgt_tok_emb(tgt)), memory, tgt_mask)
文本中的词元通过词嵌入(token embeddings)来表示。为了引入词序的概念,会在词嵌入的基础上加入位置编码(positional encoding)。
import torch
import torch.nn as nn
import math
class PositionalEncoding(nn.Module):
"""
位置编码类,为输入序列中的每个位置生成一个固定长度的向量,以帮助模型理解序列中不同位置的信息。
参数:
- emb_size: 词嵌入维度
- dropout: Dropout比率,用于防止过拟合
- maxlen: 最大序列长度,默认为5000
"""
def __init__(self, emb_size: int, dropout, maxlen: int = 5000):
super(PositionalEncoding, self).__init__()
# 计算位置编码中sin和cos使用的denominator
den = torch.exp(- torch.arange(0, emb_size, 2) * math.log(10000) / emb_size)
# 初始化位置序列(从0到maxlen-1)
pos = torch.arange(0, maxlen).reshape(maxlen, 1)
# 初始化位置嵌入矩阵
pos_embedding = torch.zeros((maxlen, emb_size))
# 奇数索引对应sin函数值,偶数索引对应cos函数值
pos_embedding[:, 0::2] = torch.sin(pos * den)
pos_embedding[:, 1::2] = torch.cos(pos * den)
# 为后续广播加一维
pos_embedding = pos_embedding.unsqueeze(-2)
# 定义Dropout层
self.dropout = nn.Dropout(dropout)
# 注册缓冲区,用于存储位置嵌入矩阵(不会被优化)
self.register_buffer('pos_embedding', pos_embedding)
def forward(self, token_embedding: Tensor):
"""
前向传播方法,将位置编码添加到词嵌入上。
参数:
- token_embedding: 输入的词嵌入张量
"""
# 将位置编码添加到词嵌入上,并应用Dropout
return self.dropout(token_embedding + self.pos_embedding[:token_embedding.size(0),:])
class TokenEmbedding(nn.Module):
"""
词嵌入层,将词汇表中的索引转换为固定长度的向量表示。
参数:
- vocab_size: 词汇表大小
- emb_size: 词嵌入维度
"""
def __init__(self, vocab_size: int, emb_size):
super(TokenEmbedding, self).__init__()
# 初始化一个词嵌入层
self.embedding = nn.Embedding(vocab_size, emb_size)
# 记录词嵌入维度,用于后续计算
self.emb_size = emb_size
def forward(self, tokens: Tensor):
"""
前向传播方法,根据输入的词索引返回词嵌入,并乘以词嵌入维度的平方根进行缩放。
参数:
- tokens: 输入的词索引张量
"""
# 获取词嵌入并按emb_size的平方根进行缩放,这是一种常见的缩放策略
return self.embedding(tokens.long()) * math.sqrt(self.emb_size)
我们还创建了一个后续词掩码(subsequent word mask),用以阻止目标词关注其后面的词,这是为了避免模型在预测时“看到未来”的信息。同时,我们还创建了源语言和目标语言的填充标记掩码,用来在计算注意力权重时忽略掉那些用于填充的标记,确保模型专注于实际有效的输入信息。
import torch
def generate_square_subsequent_mask(sz):
"""
生成一个上三角的掩码矩阵,用于屏蔽后续时间步的值,常用于自回归模型中。
参数:
- sz: 掩码矩阵的尺寸,即序列的最大长度
返回:
- 一个上三角掩码矩阵,其中上三角为0(或-inf),对角线及以下为1(或0)。
"""
# 创建一个上三角矩阵,其中1位于上三角,0位于下三角
mask = (torch.triu(torch.ones((sz, sz), device=device)) == 1).transpose(0, 1)
# 将0变为-inf,1保持不变,以便在softmax操作中将后续时间步的注意力得分设为极小值
mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, float(0.0))
return mask
def create_mask(src, tgt):
"""
创建源序列(src)和目标序列(tgt)所需的掩码,包括:
- 自回归掩码(tgt_mask):确保解码器只关注过去的输出。
- 源序列掩码(src_mask):通常全为False,除非有特定的序列间遮挡需求。
- 源序列填充掩码(src_padding_mask):标识src中padding位置的掩码。
- 目标序列填充掩码(tgt_padding_mask):标识tgt中padding位置的掩码。
参数:
- src: 源序列的Tensor,形状为(seq_length, batch_size)
- tgt: 目标序列的Tensor,形状为(seq_length, batch_size)
返回:
- src_mask, tgt_mask, src_padding_mask, tgt_padding_mask
"""
src_seq_len = src.shape[0]
tgt_seq_len = tgt.shape[0]
# 为目标序列创建自回归掩码
tgt_mask = generate_square_subsequent_mask(tgt_seq_len)
# 源序列掩码默认情况下全为False,这里直接创建一个全False的布尔掩码
src_mask = torch.zeros((src_seq_len, src_seq_len), device=device).type(torch.bool)
# 创建源序列和目标序列的填充掩码,PAD_IDX是预定义的填充标记的索引
src_padding_mask = (src == PAD_IDX).transpose(0, 1)
tgt_padding_mask = (tgt == PAD_IDX).transpose(0, 1)
# 返回所有类型的掩码
return src_mask, tgt_mask, src_padding_mask, tgt_padding_mask
Define model parameters and instantiate model. 这里我们服务器实在是计算能力有限,按照以下配置可以训练但是效果应该是不行的。如果想要看到训练的效果请使用你自己的带GPU的电脑运行这一套代码。
当你使用自己的GPU的时候,NUM_ENCODER_LAYERS 和 NUM_DECODER_LAYERS 设置为3或者更高,NHEAD设置8,EMB_SIZE设置为512。
3.5.5 设计超参数及模型评估函数
# 设计超参数及模型评估函数
SRC_VOCAB_SIZE = len(ja_vocab) # 日语词汇表的大小
TGT_VOCAB_SIZE = len(en_vocab) # 英语词汇表的大小
EMB_SIZE = 512 # 嵌入维度大小
NHEAD = 8 # 多头注意力机制中的头数
FFN_HID_DIM = 512 # 前馈神经网络隐藏层的维度
BATCH_SIZE = 16 # 批量大小
NUM_ENCODER_LAYERS = 3 # 编码器层数量
NUM_DECODER_LAYERS = 3 # 解码器层数量
NUM_EPOCHS = 16 # 训练轮数
# 初始化Seq2SeqTransformer模型
transformer = Seq2SeqTransformer(NUM_ENCODER_LAYERS, NUM_DECODER_LAYERS,
EMB_SIZE, SRC_VOCAB_SIZE, TGT_VOCAB_SIZE,
FFN_HID_DIM)
# 使用Xavier初始化方法初始化模型参数
for p in transformer.parameters():
if p.dim() > 1: # 只对多维参数(通常是权重矩阵)应用初始化
nn.init.xavier_uniform_(p)
# 将模型移至指定设备(如GPU)
transformer = transformer.to(device)
# 定义损失函数,忽略PAD_IDX位置的损失
loss_fn = torch.nn.CrossEntropyLoss(ignore_index=PAD_IDX)
# 设置Adam优化器
optimizer = torch.optim.Adam(
transformer.parameters(), lr=0.0001, betas=(0.9, 0.98), eps=1e-9
)
def train_epoch(model, train_iter, optimizer):
"""训练模型一个epoch"""
model.train() # 设置模型为训练模式
losses = 0 # 初始化损失总和
for idx, (src, tgt) in enumerate(train_iter): # 遍历训练数据迭代器
src, tgt = src.to(device), tgt.to(device) # 将数据移至设备
tgt_input = tgt[:-1, :] # 移除最后一个目标词作为预测的起始
# 为当前批次创建掩码
src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)
# 前向传播获取logits
logits = model(src, tgt_input, src_mask, tgt_mask,
src_padding_mask, tgt_padding_mask, src_padding_mask)
# 清零梯度
optimizer.zero_grad()
# 计算损失,仅对除了PAD之外的位置
tgt_out = tgt[1:,:] # 移除第一个词,与logits对应
loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
# 反向传播并更新权重
loss.backward()
optimizer.step()
losses += loss.item() # 累加损失
return losses / len(train_iter) # 返回平均损失
def evaluate(model, val_iter):
"""评估模型在一个验证集上的性能"""
model.eval() # 设置模型为评估模式
losses = 0 # 初始化损失总和
for idx, (src, tgt) in enumerate(valid_iter): # 遍历验证数据迭代器
src, tgt = src.to(device), tgt.to(device) # 移动数据到设备
tgt_input = tgt[:-1, :] # 截取用于预测的部分
# 创建掩码
src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)
# 获取模型的预测logits
logits = model(src, tgt_input, src_mask, tgt_mask,
src_padding_mask, tgt_padding_mask, src_padding_mask)
# 计算损失
tgt_out = tgt[1:,:] # 移除第一个词,与logits对齐
loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
losses += loss.item() # 累加损失
return losses / len(val_iter) # 返回平均损失
3.5.6 开始训练模型及使用模型完成翻译任务
在准备好所有必需的类和函数后,我们就能够开始训练模型了。不言而喻,完成训练所需的时间会根据多种因素大幅波动,比如计算能力、模型参数以及数据集的大小。
当我使用JParaCrawl完整语料库训练模型时,每种语言大约有590万条句子,仅依靠一块NVIDIA GeForce RTX 3070 GPU进行训练,每个epoch大约需要5个小时左右。
for epoch in tqdm.tqdm(range(1, NUM_EPOCHS+1)):
start_time = time.time()
train_loss = train_epoch(transformer, train_iter, optimizer)
end_time = time.time() # 记录训练过程所花费的时间
print((f"Epoch: {epoch}, Train loss: {train_loss:.3f}, "
f"Epoch time = {(end_time - start_time):.3f}s"))
首先,我们需要创建一系列函数来翻译一个新的日语句子,这一过程包括几个步骤:获取日语句子、进行分词处理、将分词结果转换为张量形式、利用训练好的模型进行推断,最后将模型输出解码回英语句子。
def greedy_decode(model, src, src_mask, max_len, start_symbol):
"""
贪婪解码方法,基于已训练好的模型生成翻译序列。
参数:
- model: 训练好的Seq2SeqTransformer模型
- src: 源语言句子的Tensor表示
- src_mask: 源语言句子的掩码
- max_len: 生成序列的最大长度
- start_symbol: 目标语言序列开始的符号索引
返回:
- 生成的目标语言句子的token索引序列
"""
src = src.to(device) # 确保源序列在指定设备上
src_mask = src_mask.to(device) # 确保源序列掩码也在指定设备上
# 编码源序列
memory = model.encode(src, src_mask)
# 初始化目标序列,以开始符号<BOS>
ys = torch.ones(1, 1).fill_(start_symbol).type(torch.long).to(device)
for i in range(max_len-1):
memory = memory.to(device) # 确保内存(编码输出)在设备上
# 为解码器创建一个掩码,初始阶段目标序列只有一个元素
memory_mask = torch.zeros(ys.shape[0], memory.shape[0]).to(device).type(torch.bool)
# 创建解码器自注意力的掩码
tgt_mask = (generate_square_subsequent_mask(ys.size(0))
.type(torch.bool)).to(device)
# 解码一步
out = model.decode(ys, memory, tgt_mask)
# 调整输出形状以匹配后续操作
out = out.transpose(0, 1)
# 通过生成器获取下一个词的概率分布
prob = model.generator(out[:, -1])
# 选择概率最高的词作为下一个词
_, next_word = torch.max(prob, dim = 1)
next_word = next_word.item() # 转换为Python数值
# 将选择的词添加到目标序列中
ys = torch.cat([ys,
torch.ones(1, 1).type_as(src.data).fill_(next_word)], dim=0)
# 如果遇到<EOS>符号则停止生成
if next_word == EOS_IDX:
break
return ys # 返回生成的序列
def translate(model, src, src_vocab, tgt_vocab, src_tokenizer):
"""
使用贪婪解码方法对单个源语言句子进行翻译。
参数:
- model: 训练好的Seq2SeqTransformer模型
- src: 源语言句子的文本
- src_vocab: 源语言词汇表
- tgt_vocab: 目标语言词汇表
- src_tokenizer: 源语言的分词器
返回:
- 翻译后的目标语言句子文本
"""
model.eval() # 设置模型为评估模式
# 对源句子进行编码,添加开始和结束符号
tokens = [BOS_IDX] + [src_vocab.stoi[tok] for tok in src_tokenizer.encode(src, out_type=str)]+ [EOS_IDX]
num_tokens = len(tokens) # 计算序列长度
src = (torch.LongTensor(tokens).reshape(num_tokens, 1) ) # 转换为Tensor
src_mask = (torch.zeros(num_tokens, num_tokens)).type(torch.bool) # 创建掩码
# 使用贪婪解码生成目标序列
tgt_tokens = greedy_decode(model, src, src_mask, max_len=num_tokens + 5, start_symbol=BOS_IDX).flatten()
# 将生成的token索引序列转换为目标语言的文本
return " ".join([tgt_vocab.itos[tok] for tok in tgt_tokens]).replace("<bos>", "").replace("<eos>", "")
然后,我们只需调用translate函数并传入所需的参数即可。
translate(transformer, "HSコード 8515 はんだ付け用、ろう付け用又は溶接用の機器(電気式(電気加熱ガス式を含む。)", ja_vocab, en_vocab, ja_tokenizer)
trainen.pop(5)
trainja.pop(5)
翻译结果:
3.5.7 保存训练好的模型及词汇表对象
在训练完成后,我们首先需要保存词汇表对象(en_vocab和ja_vocab)以及训练好的模型,以备后续使用。这里我们使用Pickle库来完成这一操作。Pickle是一个Python标准库,能够将Python对象转化为字节流(即序列化过程),方便存储至文件或在网络间传输。
import pickle
# 打开存储数据的文件
file = open('en_vocab.pkl', 'wb')
# dump information to that file
pickle.dump(en_vocab, file)
file.close()
file = open('ja_vocab.pkl', 'wb')
pickle.dump(ja_vocab, file)
file.close()
最后,我们还能使用PyTorch的保存和加载功能来保存模型以备后续使用。通常,根据后续使用的不同需求,有两种保存模型的方法。第一种是仅用于推理,即之后我们可以加载模型,直接使用它来进行日语到英语的翻译。
# 保存模型
torch.save(transformer.state_dict(), 'inference_model')
第二种情况也是为了推理,但同时适用于我们希望在之后加载模型并继续训练的情况。在这种情形下,除了保存模型的参数状态之外,通常还需要保存额外的训练状态信息,如优化器的状态、训练的轮次(epoch)以及其他可能影响训练连续性的参数。
# 保存模型及检查点以便后续继续训练
torch.save({
'epoch': NUM_EPOCHS, # 当前训练的轮数
'model_state_dict': transformer.state_dict(), # 模型的参数状态字典
'optimizer_state_dict': optimizer.state_dict(), # 优化器的状态字典,包括学习率等超参数
'loss': train_loss, # 训练损失值,记录训练时的损失情况
}, 'model_checkpoint.tar') # 将上述所有信息保存至'model_checkpoint.tar'文件中
实验14结束!
四.实验总结与思考
4.1 实验结果
- 训练表现:模型训练损失逐渐下降,表明模型在不断学习输入与输出序列之间的映射关系。
- 测试表现:在一些简单的句子上,模型能够生成较为准确的翻译结果。但在复杂句子上,模型有时会生成不完整或不准确的翻译。
4.2 思考与改进
4.2.1 模型改进:
- 注意力机制:可以尝试更复杂的注意力机制,如多头注意力(Multi-Head Attention),以提升模型对长序列的处理能力。
- 预训练模型:使用预训练的Transformer模型(如BERT、GPT)作为编码器和解码器,可以显著提高翻译效果。
4.2.2 数据处理:
- 数据增强:通过数据增强技术(如同义词替换、随机插入、随机删除等),增加训练数据的多样性,有助于提升模型的泛化能力。
- 更多语料:使用更多的平行语料进行训练,特别是包含复杂句子的语料,可以提高模型在复杂句子上的表现。
4.2.3 训练策略:
- 混合策略:在训练初期使用逐步解码策略(Teacher Forcing),在训练后期逐步减少Teacher Forcing的比例,让模型更多地依赖自身生成的输出,提升推理时的鲁棒性。
- 调参实验:调整模型的超参数(如LSTM单元数、学习率、批量大小等),通过实验找到最优配置。
评估与测试: - 多维度评估:不仅评估模型在单个句子上的翻译效果,还可以使用BLEU、ROUGE等指标进行全面评估。
错误分析:分析模型翻译错误的类型(如词汇错误、语法错误、遗漏等),针对性地改进模型。
结论
通过实验,我们验证了基于注意力机制的编码器-解码器模型在机器翻译任务上的有效性。尽管模型在简单句子上的表现较好,但在复杂句子上仍有改进空间。通过引入更先进的注意力机制、预训练模型以及更丰富的数据和训练策略,可以进一步提升模型的翻译效果。总之,机器翻译是一个复杂的任务,需要综合考虑模型架构、数据处理、训练策略等多方面因素,才能不断逼近理想的翻译效果。