现代循环神经网络

门控循环单元GRU

并非所有的观测值都有意义,也不是所有的观测值都一样重要

  • 可能会遇到这样的情况:早期观测值对预测所有未来观测值具有非常重要的意义。 考虑一个极端情况,其中第一个观测值包含一个校验和, 目标是在序列的末尾辨别校验和是否正确。 在这种情况下,第一个词元的影响至关重要。 我们希望有某些机制能够在一个记忆元里存储重要的早期信息。 如果没有这样的机制,我们将不得不给这个观测值指定一个非常大的梯度, 因为它会影响所有后续的观测值。
  • 我们可能会遇到这样的情况:一些词元没有相关的观测值。 例如,在对网页内容进行情感分析时, 可能有一些辅助HTML代码与网页传达的情绪无关。 我们希望有一些机制来跳过隐状态表示中的此类词元。
  • 我们可能会遇到这样的情况:序列的各个部分之间存在逻辑中断。 例如,书的章节之间可能会有过渡存在, 或者证券的熊市和牛市之间可能会有过渡存在。 在这种情况下,最好有一种方法来重置我们的内部状态表示。

门控隐状态

门控循环单元与普通的循环神经网络之间的关键区别在于: 前者支持隐状态的门控。 这意味着模型有专门的机制来确定应该何时更新隐状态, 以及应该何时重置隐状态

这些机制是可学习的,并且能够解决了上面列出的问题。 例如,如果第一个词元非常重要, 模型将学会在第一次观测之后不更新隐状态。 同样,模型也可以学会跳过不相关的临时观测。 最后,模型还将学会在需要的时候重置隐状态。 下面我们将详细讨论各类门控。

重置门和更新门

首先介绍重置门(reset gate)和更新门(update gate)。 我们把它们设计成(0,1)区间中的向量, 这样我们就可以进行凸组合。 重置门允许我们控制“可能还想记住”的过去状态的数量; 更新门将允许我们控制新状态中有多少个是旧状态的副本。

我们从构造这些门控开始。 图描述了门控循环单元中的重置门和更新门的输入, 输入是由当前时间步的输入和前一时间步的隐状态给出。 两个门的输出是由使用sigmoid激活函数的两个全连接层给出。

../_images/gru-1.svg

对于给定的时间步$t$,假设输入是一个小批量$\mathbf{X}_t \in \mathbb{R}^{n \times d}$(样本个数$n$,输入个数$d$),上一个时间步的隐状态是$\mathbf{H}_{t-1} \in \mathbb{R}^{n \times h}$(隐藏单元个数$h$)。那么,重置门$\mathbf{R}_t \in \mathbb{R}^{n \times h}$和更新门$\mathbf{Z}_t \in \mathbb{R}^{n \times h}$的计算如下所示:

其中$\mathbf{W}_{xr}, \mathbf{W}_{xz} \in \mathbb{R}^{d \times h}$和$\mathbf{W}_{hr}, \mathbf{W}_{hz} \in \mathbb{R}^{h \times h}$是权重参数,$\mathbf{b}_r, \mathbf{b}_z \in \mathbb{R}^{1 \times h}$是偏置参数。请注意,在求和过程中会触发广播机制,我们使用sigmoid函数将输入值转换到区间$(0, 1)$。


候选隐状态

接下来,让我们将重置门$\mathbf{R}_t$与RNN中的常规隐状态更新机制集成,得到在时间步$t$的候选隐状态$\tilde{\mathbf{H}}_t \in \mathbb{R}^{n \times h}$

其中$\mathbf{W}_{xh} \in \mathbb{R}^{d \times h}$和$\mathbf{W}_{hh} \in \mathbb{R}^{h \times h}$是权重参数,$\mathbf{b}_h \in \mathbb{R}^{1 \times h}$是偏置项,符号$\odot$是Hadamard积(按元素乘积)运算符。在这里,我们使用$tanh$非线性激活函数来确保候选隐状态中的值保持在区间$(-1, 1)$中。

与常规公式相比,候选隐状态中的$\mathbf{R}_t$和$\mathbf{H}_{t-1}$的元素相乘可以减少以往状态的影响。每当重置门$\mathbf{R}_t$中的项接近$1$时,我们恢复一个普通的循环神经网络。当重置门中的项接近0时,意味着忽略之前的隐状态,让模型“忘记”之前的信息。

../_images/gru-2.svg


隐状态

上述的计算结果只是候选隐状态,我们仍然需要结合更新门$\mathbf{Z}_t$的效果。这一步确定新的隐状态$\mathbf{H}_t \in \mathbb{R}^{n \times h}$在多大程度上来自旧的状态$\mathbf{H}_{t-1}$和新的候选状态$\tilde{\mathbf{H}}_t$。更新门$\mathbf{Z}_t$仅需要在$\mathbf{H}_{t-1}$和$\tilde{\mathbf{H}}_t$之间进行按元素的凸组合就可以实现这个目标。

这就得出了门控循环单元的最终更新公式:

每当更新门$\mathbf{Z}_t$接近$1$时,模型就倾向只保留旧状态。此时,来自$\mathbf{X}_t$的信息基本上被忽略,从而有效地跳过了依赖链条中的时间步$t$。相反,当$\mathbf{Z}_t$接近$0$时,新的隐状态$\mathbf{H}_t$就会接近候选隐状态$\tilde{\mathbf{H}}_t$。

这些设计可以帮助我们处理循环神经网络中的梯度消失问题,并更好地捕获时间步距离很长的序列的依赖关系。例如,如果整个子序列的所有时间步的更新门都接近于$1$,则无论序列的长度如何,在序列起始时间步的旧隐状态都将很容易保留并传递到序列结束。

../_images/gru-3.svg

总之,门控循环单元具有以下两个显著特征:

  • 重置门有助于捕获序列中的短期依赖关系;
  • 更新门有助于捕获序列中的长期依赖关系。

代码

1
2
3
4
5
6
import torch
from torch import nn
from d2l import torch as d2l

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

下一步是初始化模型参数。 我们从标准差为0.01的高斯分布中提取权重, 并将偏置项设为0,超参数num_hiddens定义隐藏单元的数量, 实例化与更新门、重置门、候选隐状态和输出层相关的所有权重和偏置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def get_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size

def normal(shape):
return torch.randn(size=shape, device=device)*0.01

def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
torch.zeros(num_hiddens, device=device))

W_xz, W_hz, b_z = three() # 更新门参数
W_xr, W_hr, b_r = three() # 重置门参数
W_xh, W_hh, b_h = three() # 候选隐状态参数
# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 附加梯度
params = [W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params

现在我们将定义隐状态的初始化函数init_gru_state。 与在循环网络RNN中定义的init_rnn_state函数一样, 此函数返回一个形状为(批量大小,隐藏单元个数)的张量,张量的值全部为零。

1
2
def init_gru_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device), )

现在我们准备定义门控循环单元模型, 模型的架构与基本的循环神经网络单元是相同的, 只是权重更新公式更为复杂。

1
2
3
4
5
6
7
8
9
10
11
12
def gru(inputs, state, params):
W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q = params
H, = state
outputs = []
for X in inputs:
Z = torch.sigmoid((X @ W_xz) + (H @ W_hz) + b_z)
R = torch.sigmoid((X @ W_xr) + (H @ W_hr) + b_r)
H_tilda = torch.tanh((X @ W_xh) + ((R * H) @ W_hh) + b_h)
H = Z * H + (1 - Z) * H_tilda
Y = H @ W_hq + b_q
outputs.append(Y)
return torch.cat(outputs, dim=0), (H,)

训练和预测的工作方式与循环神经网络RNN完全相同。 训练结束后,我们分别打印输出训练集的困惑度, 以及前缀“time traveler”和“traveler”的预测序列上的困惑度。

1
2
3
4
5
6
7
8
9
10
11
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_params,
init_gru_state, gru)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

"""
perplexity 1.1, 19911.5 tokens/sec on cuda:0
time traveller firenis i heidfile sook at i jomer and sugard are
travelleryou can show black is white by argument said filby
"""

../_images/output_gru_b77a34_66_1.svg


简洁实现

高级API包含了前文介绍的所有配置细节, 所以我们可以直接实例化门控循环单元模型。 这段代码的运行速度要快得多, 因为它使用的是编译好的运算符而不是Python来处理之前阐述的许多细节。

1
2
3
4
5
num_inputs = vocab_size
gru_layer = nn.GRU(num_inputs, num_hiddens)
model = d2l.RNNModel(gru_layer, len(vocab))
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

长短期记忆网络LSTM

门控记忆元

可以说,长短期记忆网络的设计灵感来自于计算机的逻辑门。 长短期记忆网络引入了记忆元(memory cell),或简称为单元(cell)。 有些文献认为记忆元是隐状态的一种特殊类型, 它们与隐状态具有相同的形状,其设计目的是用于记录附加的信息。 为了控制记忆元,我们需要许多门。 其中一个门用来从单元中输出条目,我们将其称为输出门(output gate)。 另外一个门用来决定何时将数据读入单元,我们将其称为输入门(input gate)。 我们还需要一种机制来重置单元的内容,由遗忘门(forget gate)来管理, 这种设计的动机与门控循环单元相同, 能够通过专用机制决定什么时候记忆或忽略隐状态中的输入。

输入门,遗忘门,输出门

就如在门控循环单元中一样, 当前时间步的输入和前一个时间步的隐状态 作为数据送入长短期记忆网络的门中, 如图所示。 它们由三个具有sigmoid激活函数的全连接层处理, 以计算输入门、遗忘门和输出门的值。 因此,这三个门的值都在(0,1)的范围内。../_images/lstm-0.svg

我们来细化一下长短期记忆网络的数学表达。假设有$h$个隐藏单元,批量大小为$n$,输入数为$d$。

因此,输入为$\mathbf{X}_t \in \mathbb{R}^{n \times d}$,前一时间步的隐状态为$\mathbf{H}_{t-1} \in \mathbb{R}^{n \times h}$。

相应地,时间步$t$的门被定义如下:

输入门是$\mathbf{I}_t \in \mathbb{R}^{n \times h}$,

遗忘门是$\mathbf{F}_t \in \mathbb{R}^{n \times h}$,

输出门是$\mathbf{O}_t \in \mathbb{R}^{n \times h}$。

它们的计算方法如下:

其中$\mathbf{W}_{xi}, \mathbf{W}_{xf}, \mathbf{W}_{xo} \in \mathbb{R}^{d \times h}$和$\mathbf{W}_{hi}, \mathbf{W}_{hf}, \mathbf{W}_{ho} \in \mathbb{R}^{h \times h}$是权重参数,$\mathbf{b}_i, \mathbf{b}_f, \mathbf{b}_o \in \mathbb{R}^{1 \times h}$是偏置参数。


候选记忆元

由于还没有指定各种门的操作,所以先介绍候选记忆元(candidate memory cell)$\tilde{\mathbf{C}}_t \in \mathbb{R}^{n \times h}$。

它的计算与上面描述的三个门的计算类似,但是使用$\tanh$函数作为激活函数,函数的值范围为$(-1, 1)$。

下面导出在时间步$t$处的方程:

其中$\mathbf{W}_{xc} \in \mathbb{R}^{d \times h}$和$\mathbf{W}_{hc} \in \mathbb{R}^{h \times h}$是权重参数,$\mathbf{b}_c \in \mathbb{R}^{1 \times h}$是偏置参数。

../_images/lstm-1.svg


记忆元

在门控循环单元中,有一种机制来控制输入和遗忘(或跳过)。类似地,在长短期记忆网络中,也有两个门用于这样的目的:输入门$\mathbf{I}_t$控制采用多少来自$\tilde{\mathbf{C}}_t$的新数据,而遗忘门$\mathbf{F}_t$控制保留多少过去的记忆元$\mathbf{C}_{t-1} \in \mathbb{R}^{n \times h}$的内容。

使用按元素乘法,得出:

如果遗忘门始终为$1$且输入门始终为$0$,则过去的记忆元$\mathbf{C}_{t-1}$将随时间被保存并传递到当前时间步。引入这种设计是为了缓解梯度消失问题,并更好地捕获序列中的长距离依赖关系。

这样我们就得到了计算记忆元的流程图

../_images/lstm-2.svg


隐状态

最后,我们需要定义如何计算隐状态$\mathbf{H}_t \in \mathbb{R}^{n \times h}$,这就是输出门发挥作用的地方。在长短期记忆网络中,它仅仅是记忆元的$\tanh$的门控版本。这就确保了$\mathbf{H}_t$的值始终在区间$(-1, 1)$内:

只要输出门接近$1$,我们就能够有效地将所有记忆信息传递给预测部分,

而对于输出门接近$0$,我们只保留记忆元内的所有信息,而不需要更新隐状态。

../_images/lstm-3.svg

尽管记忆元和隐状态在功能上有重叠,它们的存在并不是重复的,而是相互补充的:

  • 记忆元专注于长期信息的存储和传递。它能够在很长的序列中维持重要的状态信息,因为它通过门控机制精细地控制信息的保留和遗忘。
  • 隐状态则提供了一种机制来输出短期的信息。它反映了当前时间点的网络状态,是基于最近的输入和过去的长期信息(通过记忆元传递)计算得出的。

代码

1
2
3
4
5
6
7
8
from mxnet import np, npx
from mxnet.gluon import rnn
from d2l import mxnet as d2l

npx.set_np()

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

接下来,我们需要定义和初始化模型参数。 如前所述,超参数num_hiddens定义隐藏单元的数量。 我们按照标准差0.01的高斯分布初始化权重,并将偏置项设为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def get_lstm_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size

def normal(shape):
return torch.randn(size=shape, device=device)*0.01

def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
torch.zeros(num_hiddens, device=device))

W_xi, W_hi, b_i = three() # 输入门参数
W_xf, W_hf, b_f = three() # 遗忘门参数
W_xo, W_ho, b_o = three() # 输出门参数
W_xc, W_hc, b_c = three() # 候选记忆元参数
# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 附加梯度
params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc,
b_c, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params

在初始化函数中, 长短期记忆网络的隐状态需要返回一个额外的记忆元, 单元的值为0,形状为(批量大小,隐藏单元数)。 因此,我们得到以下的状态初始化。

1
2
3
def init_lstm_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device),
torch.zeros((batch_size, num_hiddens), device=device))

实际模型的定义与我们前面讨论的一样: 提供三个门和一个额外的记忆元。 请注意,只有隐状态才会传递到输出层, 而记忆元$C_t$不直接参与输出计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def lstm(inputs, state, params):
[W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c,
W_hq, b_q] = params
(H, C) = state
outputs = []
for X in inputs:
I = torch.sigmoid((X @ W_xi) + (H @ W_hi) + b_i)
F = torch.sigmoid((X @ W_xf) + (H @ W_hf) + b_f)
O = torch.sigmoid((X @ W_xo) + (H @ W_ho) + b_o)
C_tilda = torch.tanh((X @ W_xc) + (H @ W_hc) + b_c)
C = F * C + I * C_tilda
H = O * torch.tanh(C)
Y = (H @ W_hq) + b_q
outputs.append(Y)
return torch.cat(outputs, dim=0), (H, C)

1
2
3
4
5
6
7
8
9
10
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_lstm_params,
init_lstm_state, lstm)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
"""
perplexity 1.3, 17736.0 tokens/sec on cuda:0
time traveller for so it will leong go it we melenot ir cove i s
traveller care be can so i ngrecpely as along the time dime
"""

../_images/output_lstm_86eb9f_66_1.svg

长短期记忆网络是典型的具有重要状态控制的隐变量自回归模型。 多年来已经提出了其许多变体,例如,多层、残差连接、不同类型的正则化。 然而,由于序列的长距离依赖性,训练长短期记忆网络 和其他序列模型(例如门控循环单元)的成本是相当高的。 在后面的内容中,我们将讲述更高级的替代模型,如Transformer


深度循环神经网络

事实上,我们可以将多层循环神经网络堆叠在一起, 通过对几个简单层的组合,产生了一个灵活的机制。 特别是,数据可能与不同层的堆叠有关。 例如,我们可能希望保持有关金融市场状况 (熊市或牛市)的宏观数据可用, 而微观数据只记录较短期的时间动态。

描述了一个具有$L$个隐藏层的深度循环神经网络, 每个隐状态都连续地传递到当前层的下一个时间步和下一层的当前时间步

../_images/deep-rnn.svg


函数依赖关系

我们可以将深度架构中的函数依赖关系形式化,这个架构是由图中描述了$L$个隐藏层构成。后续的讨论主要集中在经典的循环神经网络模型上,但是这些讨论也适应于其他序列模型。

假设在时间步$t$有一个小批量的输入数据$\mathbf{X}_t \in \mathbb{R}^{n \times d}$(样本数:$n$,每个样本中的输入数:$d$)。同时,将$l^\mathrm{th}$隐藏层($l=1,\ldots,L$)的隐状态设为$\mathbf{H}_t^{(l)} \in \mathbb{R}^{n \times h}$(隐藏单元数:$h$),输出层变量设为$\mathbf{O}_t \in \mathbb{R}^{n \times q}$(输出数:$q$)。设置$\mathbf{H}_t^{(0)} = \mathbf{X}_t$,第$l$个隐藏层的隐状态使用激活函数$\phi_l$,则:

其中,权重$\mathbf{W}_{xh}^{(l)} \in \mathbb{R}^{h \times h}$,$\mathbf{W}_{hh}^{(l)} \in \mathbb{R}^{h \times h}$和偏置$\mathbf{b}_h^{(l)} \in \mathbb{R}^{1 \times h}$都是第$l$个隐藏层的模型参数。

最后,输出层的计算仅基于第$l$个隐藏层最终的隐状态:

其中,权重$\mathbf{W}_{hq} \in \mathbb{R}^{h \times q}$和偏置$\mathbf{b}_q \in \mathbb{R}^{1 \times q}$都是输出层的模型参数。

与多层感知机一样,隐藏层数目$L$和隐藏单元数目$h$都是超参数。也就是说,它们可以由我们调整的。另外,用门控循环单元或长短期记忆网络的隐状态来代替图中的隐状态进行计算,可以很容易地得到深度门控循环神经网络或深度长短期记忆神经网络。


代码

实现多层循环神经网络所需的许多逻辑细节在高级API中都是现成的。 简单起见,我们仅示范使用此类内置函数的实现方式。 以长短期记忆网络模型为例, 该代码与之前在上一节中使用的代码非常相似, 实际上唯一的区别是我们指定了层的数量, 而不是使用单一层这个默认值。 像往常一样,我们从加载数据集开始。

1
2
3
4
5
6
import torch
from torch import nn
from d2l import torch as d2l

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

因为我们有不同的词元,所以输入和输出都选择相同数量,即vocab_size。 隐藏单元的数量仍然是256。 唯一的区别是,我们现在通过num_layers的值来设定隐藏层数。

1
2
3
4
5
6
vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
device = d2l.try_gpu()
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)

由于使用了长短期记忆网络模型来实例化两个层,因此训练速度被大大降低了。

1
2
3
4
5
6
7
8
num_epochs, lr = 500, 2
d2l.train_ch8(model, train_iter, vocab, lr*1.0, num_epochs, device)

"""
perplexity 1.0, 186005.7 tokens/sec on cuda:0
time traveller for so it will be convenient to speak of himwas e
travelleryou can show black is white by argument said filby
"""

../_images/output_deep-rnn_d70a11_30_1.svg


机器翻译与数据集

语言模型是自然语言处理的关键, 而机器翻译是语言模型最成功的基准测试。 因为机器翻译正是将输入序列转换成输出序列的 序列转换模型 的核心问题。 序列转换模型在各类现代人工智能应用中发挥着至关重要的作用, 因此我们将其做为本章剩余部分和注意力机制的重点。 为此,本节将介绍机器翻译问题及其后文需要使用的数据集

机器翻译指的是 将序列从一种语言自动翻译成另一种语言。基于神经网络的方法通常被称为 神经机器翻译, 用于将两种翻译模型区分开来

与之前的语料库是单一语言的语言模型问题存在不同, 机器翻译的数据集是由源语言和目标语言的文本序列对组成的。 因此,我们需要一种完全不同的方法来预处理机器翻译数据集, 而不是复用语言模型的预处理程序。 下面,我们看一下如何将预处理后的数据加载到小批量中用于训练。

1
2
3
import os
import torch
from d2l import torch as d2l

下载和预处理数据集

首先,下载一个由Tatoeba项目的双语句子对 组成的“英-法”数据集,数据集中的每一行都是制表符分隔的文本序列对, 序列对由英文文本序列和翻译后的法语文本序列组成。 请注意,每个文本序列可以是一个句子, 也可以是包含多个句子的一个段落。 在这个将英语翻译成法语的机器翻译问题中, 英语是源语言, 法语是目标语言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#@save
d2l.DATA_HUB['fra-eng'] = (d2l.DATA_URL + 'fra-eng.zip',
'94646ad1522d915e7b0f9296181140edcf86a4f5')

#@save
def read_data_nmt():
"""载入“英语-法语”数据集"""
data_dir = d2l.download_extract('fra-eng')
with open(os.path.join(data_dir, 'fra.txt'), 'r',
encoding='utf-8') as f:
return f.read()

raw_text = read_data_nmt()
print(raw_text[:75])

"""
Go. Va !
Hi. Salut !
Run! Cours !
Run! Courez !
Who? Qui ?
Wow! Ça alors !
"""

下载数据集后,原始文本数据需要经过几个预处理步骤。 例如,我们用空格代替不间断空格(non-breaking space), 使用小写字母替换大写字母,并在单词和标点符号之间插入空格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#@save
def preprocess_nmt(text):
"""预处理“英语-法语”数据集"""
def no_space(char, prev_char):
return char in set(',.!?') and prev_char != ' '

# 使用空格替换不间断空格
# 使用小写字母替换大写字母
text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()
# 在单词和标点符号之间插入空格
out = [' ' + char if i > 0 and no_space(char, text[i - 1]) else char
for i, char in enumerate(text)]
return ''.join(out)

text = preprocess_nmt(raw_text)
print(text[:80])

"""
go . va !
hi . salut !
run ! cours !
run ! courez !
who ? qui ?
wow ! ça alors !
"""

词元化

在机器翻译中,我们更喜欢单词级词元化 (最先进的模型可能使用更高级的词元化技术)。 下面的tokenize_nmt函数对前num_examples个文本序列对进行词元, 其中每个词元要么是一个词,要么是一个标点符号。 此函数返回两个词元列表:sourcetargetsource[i]是源语言(这里是英语)第$i$个文本序列的词元列表, target[i]是目标语言(这里是法语)第$i$个文本序列的词元列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#@save
def tokenize_nmt(text, num_examples=None):
"""词元化“英语-法语”数据数据集"""
source, target = [], []
for i, line in enumerate(text.split('\n')):
if num_examples and i > num_examples:
break
parts = line.split('\t')
if len(parts) == 2:
source.append(parts[0].split(' '))
target.append(parts[1].split(' '))
return source, target

source, target = tokenize_nmt(text)
source[:6], target[:6]

"""
([['go', '.'],
['hi', '.'],
['run', '!'],
['run', '!'],
['who', '?'],
['wow', '!']],
[['va', '!'],
['salut', '!'],
['cours', '!'],
['courez', '!'],
['qui', '?'],
['ça', 'alors', '!']])
"""

词表

由于机器翻译数据集由语言对组成, 因此我们可以分别为源语言和目标语言构建两个词表。 使用单词级词元化时,词表大小将明显大于使用字符级词元化时的词表大小。 为了缓解这一问题,这里我们将出现次数少于2次的低频率词元 视为相同的未知(<unk>)词元。 除此之外,我们还指定了额外的特定词元, 例如在小批量时用于将序列填充到相同长度的填充词元(<pad>), 以及序列的开始词元(<bos>)和结束词元(<eos>)。 这些特殊词元在自然语言处理任务中比较常用。

1
2
3
4
5
6
7
8
9
10
11
12
13
# source: 包含文本数据的变量,通常是一个列表,其中每个元素是一个句子或单词。
# min_freq=2: 表示只有在数据集中出现至少两次的单词才会被加入到词汇表中。
# 这有助于去除一些罕见词,减少模型的复杂度。
# reserved_tokens=['<pad>', '<bos>', '<eos>']: 指定了一些保留的标记(tokens),
# 分别是填充标记(<pad>)、句子开始标记(<bos>)和句子结束标记(<eos>)。
# 这些标记在训练循环神经网络时非常有用,例如在自然语言处理任务中。
src_vocab = d2l.Vocab(source, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])

# 获取词汇表的长度,即词汇表中不同单词的总数。
# 这个长度包括了因min_freq条件而筛选的单词,以及添加的保留标记。
len(src_vocab)


加载数据集

回想一下,语言模型中的序列样本都有一个固定的长度, 无论这个样本是一个句子的一部分还是跨越了多个句子的一个片断。 这个固定长度是由语言模型这节中的 num_steps(时间步数或词元数量)参数指定的。 在机器翻译中,每个样本都是由源和目标组成的文本序列对, 其中的每个文本序列可能具有不同的长度。

为了提高计算效率,我们仍然可以通过截断(truncation)和 填充(padding)方式实现一次只处理一个小批量的文本序列。 假设同一个小批量中的每个序列都应该具有相同的长度num_steps, 那么如果文本序列的词元数目少于num_steps时, 我们将继续在其末尾添加特定<pad>词元, 直到其长度达到num_steps; 反之,我们将截断文本序列时,只取其前num_steps 个词元, 并且丢弃剩余的词元。这样,每个文本序列将具有相同的长度, 以便以相同形状的小批量进行加载。

如前所述,下面的truncate_pad函数将截断或填充文本序列。

1
2
3
4
5
6
7
8
#@save
def truncate_pad(line, num_steps, padding_token):
"""截断或填充文本序列"""
if len(line) > num_steps:
return line[:num_steps] # 截断
return line + [padding_token] * (num_steps - len(line)) # 填充

truncate_pad(src_vocab[source[0]], 10, src_vocab['<pad>'])

现在我们定义一个函数,可以将文本序列 转换成小批量数据集用于训练。 我们将特定的<eos>词元添加到所有序列的末尾, 用于表示序列的结束。 当模型通过一个词元接一个词元地生成序列进行预测时, 生成的<eos>词元说明完成了序列输出工作。 此外,我们还记录了每个文本序列的长度, 统计长度时排除了填充词元, 在稍后将要介绍的一些模型会需要这个长度信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#@save
def build_array_nmt(lines, vocab, num_steps):
"""
将机器翻译的文本序列转换成小批量。
lines: 包含多个句子的列表,其中每个句子是由单词组成的列表。
vocab: 一个Vocab类的实例,用于将文本数据转换为数字索引。
num_steps: 指定每个句子在转换后的最大长度,如果句子长度不足会进行填充,过长则会被截断。
"""

# 将每个句子中的单词转换为对应的索引。
lines = [vocab[l] for l in lines]

# 在每个索引列表的末尾添加'<eos>'标记的索引。
lines = [l + [vocab['<eos>']] for l in lines]

# 对每个索引列表进行填充或截断,使它们的长度统一为num_steps。
# 使用vocab['<pad>']的索引值作为填充值。
array = torch.tensor([truncate_pad(
l, num_steps, vocab['<pad>']) for l in lines])

# 计算每个序列的有效长度(不包括填充部分)。
valid_len = (array != vocab['<pad>']).type(torch.int32).sum(1)

# 返回处理后的索引列表和每个列表的有效长度。
return array, valid_len


训练

最后,我们定义load_data_nmt函数来返回数据迭代器, 以及源语言和目标语言的两种词表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#@save
def load_data_nmt(batch_size, num_steps, num_examples=600):
"""
返回翻译数据集的迭代器和词表。
batch_size: 指定每个小批量的样本数量。
num_steps: 指定每个样本的最大长度(超过这个长度的部分将被截断,不足的部分将被填充)。
num_examples: 从数据集中读取的样本数量,默认为600。
"""

# 预处理数据集,包括读取数据和初步处理。
text = preprocess_nmt(read_data_nmt())

# 将文本数据分割成源语言数据和目标语言数据,并根据num_examples限制样本数量。
source, target = tokenize_nmt(text, num_examples)

# 为源语言和目标语言各自创建词汇表。
# min_freq=2表示只包括至少出现两次的单词。
# reserved_tokens包括特殊标记:<pad>填充标记,<bos>句子开始标记,<eos>句子结束标记。
src_vocab = d2l.Vocab(source, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
tgt_vocab = d2l.Vocab(target, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])

# 将源语言和目标语言的文本数据转换为小批量的数值数据(包括填充或截断处理)。
src_array, src_valid_len = build_array_nmt(source, src_vocab, num_steps)
tgt_array, tgt_valid_len = build_array_nmt(target, tgt_vocab, num_steps)

# 将处理后的数据组合成一个元组,准备进行批量读取。
data_arrays = (src_array, src_valid_len, tgt_array, tgt_valid_len)

# 使用d2l.load_array创建数据迭代器,用于批量读取数据。
data_iter = d2l.load_array(data_arrays, batch_size)

# 返回数据迭代器和两个语言的词汇表。
return data_iter, src_vocab, tgt_vocab

下面我们读出“英语-法语”数据集中的第一个小批量数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size=2, num_steps=8)
for X, X_valid_len, Y, Y_valid_len in train_iter:
print('X:', X.type(torch.int32))
print('X的有效长度:', X_valid_len)
print('Y:', Y.type(torch.int32))
print('Y的有效长度:', Y_valid_len)
break
"""
X: tensor([[ 7, 43, 4, 3, 1, 1, 1, 1],
[44, 23, 4, 3, 1, 1, 1, 1]], dtype=torch.int32)
X的有效长度: tensor([4, 4])
Y: tensor([[ 6, 7, 40, 4, 3, 1, 1, 1],
[ 0, 5, 3, 1, 1, 1, 1, 1]], dtype=torch.int32)
Y的有效长度: tensor([5, 3])

"""

编码器和解码器

机器翻译是序列转换模型的一个核心问题, 其输入和输出都是长度可变的序列。 为了处理这种类型的输入和输出, 我们可以设计一个包含两个主要组件的架构: 第一个组件是一个编码器(encoder): 它接受一个长度可变的序列作为输入, 并将其转换为具有固定形状的编码状态。 第二个组件是解码器(decoder): 它将固定形状的编码状态映射到长度可变的序列。 这被称为编码器-解码器(encoder-decoder)架构

../_images/encoder-decoder.svg

以英语到法语的机器翻译为例: 给定一个英文的输入序列:“They”“are”“watching”“.”。 首先,这种“编码器-解码器”架构将长度可变的输入序列编码成一个“状态”, 然后对该状态进行解码, 一个词元接着一个词元地生成翻译后的序列作为输出: “Ils”“regordent”“.”。 由于“编码器-解码器”架构是形成后续章节中不同序列转换模型的基础, 因此本节将把这个架构转换为接口方便后面的代码实现。


编码器

在编码器接口中,我们只指定长度可变的序列作为编码器的输入X。 任何继承这个Encoder基类的模型将完成代码实现。

1
2
3
4
5
6
7
8
9
10
11
from torch import nn


#@save
class Encoder(nn.Module):
"""编码器-解码器架构的基本编码器接口"""
def __init__(self, **kwargs):
super(Encoder, self).__init__(**kwargs)

def forward(self, X, *args):
raise NotImplementedError

解码器

在下面的解码器接口中,我们新增一个init_state函数, 用于将编码器的输出(enc_outputs)转换为编码后的状态。 注意,此步骤可能需要额外的输入,例如:输入序列的有效长度, 为了逐个地生成长度可变的词元序列, 解码器在每个时间步都会将输入 (例如:在前一时间步生成的词元)和编码后的状态 映射成当前时间步的输出词元。

1
2
3
4
5
6
7
8
9
10
11
12
from torch import nn
#@save
class Decoder(nn.Module):
"""编码器-解码器架构的基本解码器接口"""
def __init__(self, **kwargs):
super(Decoder, self).__init__(**kwargs)

def init_state(self, enc_outputs, *args):
raise NotImplementedError

def forward(self, X, state):
raise NotImplementedError

合并

总而言之,“编码器-解码器”架构包含了一个编码器和一个解码器, 并且还拥有可选的额外的参数。 在前向传播中,编码器的输出用于生成编码状态, 这个状态又被解码器作为其输入的一部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 引入必要的库
import nn # 假设这是从某个深度学习框架中导入的神经网络库

#@save
class EncoderDecoder(nn.Block):
"""编码器-解码器架构的基类。

这个类是编码器-解码器架构的一个通用实现,可以用于多种不同的任务,如机器翻译、
自动摘要等。它由两部分组成:一个编码器和一个解码器。

参数:
encoder: 编码器部分,负责处理输入数据,将其转换成一个内部表示。
decoder: 解码器部分,负责将编码器的输出转换成最终的输出序列。
"""
def __init__(self, encoder, decoder, **kwargs):
super(EncoderDecoder, self).__init__(**kwargs)
# 初始化编码器和解码器
self.encoder = encoder
self.decoder = decoder

def forward(self, enc_X, dec_X, *args):
"""
前向传播方法。

参数:
enc_X: 编码器的输入数据。
dec_X: 解码器的输入数据。
*args: 可能会传递给编码器和解码器的额外参数。

返回:
解码器的输出,这通常是一个序列,比如翻译的文本或者生成的文本摘要。
"""
# 通过编码器处理输入
enc_outputs = self.encoder(enc_X, *args)
# 使用编码器的输出来初始化解码器的状态
dec_state = self.decoder.init_state(enc_outputs, *args)
# 使用解码器生成最终的输出序列
return self.decoder(dec_X, dec_state)


序列到序列学习seq2seq

本节,我们将使用两个循环神经网络的编码器和解码器, 并将其应用于序列到序列(sequence to sequence,seq2seq)类的学习任务

遵循编码器-解码器架构的设计原则, 循环神经网络编码器使用长度可变的序列作为输入, 将其转换为固定形状的隐状态。 换言之,输入序列的信息被编码到循环神经网络编码器的隐状态中。 为了连续生成输出序列的词元, 独立的循环神经网络解码器是基于输入序列的编码信息 和输出序列已经看见的或者生成的词元来预测下一个词元。图中演示了 如何在机器翻译中使用两个循环神经网络进行序列到序列学习

../_images/seq2seq.svg

特定的<eos>表示序列结束词元。 一旦输出序列生成此词元,模型就会停止预测。 在循环神经网络解码器的初始化时间步,有两个特定的设计决定: 首先,特定的<bos>表示序列开始词元,它是解码器的输入序列的第一个词元。 其次,使用循环神经网络编码器最终的隐状态来初始化解码器的隐状态。

1
2
3
4
5
import collections
import math
import torch
from torch import nn
from d2l import torch as d2l

编码器

从技术上讲,编码器将长度可变的输入序列转换成 形状固定的上下文变量$c$, 并且将输入序列的信息在该上下文变量中进行编码

考虑由一个序列组成的样本(批量大小是$1$)。假设输入序列是$x_1, \ldots, x_T$,其中$x_t$是输入文本序列中的第$t$个词元。在时间步$t$,循环神经网络将词元$x_t$的输入特征向量$\mathbf{x}_t$和$\mathbf{h} _{t-1}$(即上一时间步的隐状态)转换为$\mathbf{h}_t$(即当前步的隐状态)。使用一个函数$f$来描述循环神经网络的循环层所做的变换:

总之,编码器通过选定的函数$q$,将所有时间步的隐状态转换为上下文变量:

比如,当选择$q(\mathbf{h}_1, \ldots, \mathbf{h}_T) = \mathbf{h}_T$时,上下文变量仅仅是输入序列在最后时间步的隐状态$\mathbf{h}_T$。

现在,让我们实现循环神经网络编码器。 注意,我们使用了嵌入层(embedding layer) 来获得输入序列中每个词元的特征向量。 嵌入层的权重是一个矩阵, 其行数等于输入词表的大小(vocab_size), 其列数等于特征向量的维度(embed_size)。 对于任意输入词元的索引$i$, 嵌入层获取权重矩阵的第$i$行(从0开始)以返回其特征向量。 另外,本文选择了一个多层门控循环单元来实现编码器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#@save
class Seq2SeqEncoder(d2l.Encoder):
"""用于序列到序列学习的循环神经网络编码器"""
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
dropout=0, **kwargs):
super(Seq2SeqEncoder, self).__init__(**kwargs)
# 嵌入层
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.GRU(embed_size, num_hiddens, num_layers,
dropout=dropout)

def forward(self, X, *args):
# 输出'X'的形状:(batch_size,num_steps,embed_size)
X = self.embedding(X)
# 在循环神经网络模型中,第一个轴对应于时间步
X = X.permute(1, 0, 2)
# 如果未提及状态,则默认为0
output, state = self.rnn(X)
# output的形状:(num_steps,batch_size,num_hiddens)
# state的形状:(num_layers,batch_size,num_hiddens)
return output, state

面,我们实例化上述编码器的实现: 我们使用一个两层门控循环单元编码器,其隐藏单元数为16。 给定一小批量的输入序列X(批量大小为4,时间步为7)。 在完成所有时间步后, 最后一层的隐状态的输出是一个张量(output由编码器的循环层返回), 其形状为(时间步数,批量大小,隐藏单元数)。

1
2
3
4
5
6
encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,
num_layers=2)
encoder.eval()
X = torch.zeros((4, 7), dtype=torch.long)
output, state = encoder(X)
output.shape

由于这里使用的是门控循环单元, 所以在最后一个时间步的多层隐状态的形状是 (隐藏层的数量,批量大小,隐藏单元的数量)。 如果使用长短期记忆网络,state中还将包含记忆单元信息。


解码器

正如上文提到的,编码器输出的上下文变量$\mathbf{c}$对整个输入序列$x_1, \ldots, x_T$进行编码。来自训练数据集的输出序列$y_1, y_2, \ldots, y_{T’}$,对于每个时间步$t’$(与输入序列或编码器的时间步$t$不同),解码器输出$y_{t’}$的概率取决于先前的输出子序列$y_1, \ldots, y_{t’-1}$和上下文变量$\mathbf{c}$,即$P(y_{t’} \mid y_1, \ldots, y_{t’-1}, \mathbf{c})$。

为了在序列上模型化这种条件概率,我们可以使用另一个循环神经网络作为解码器。在输出序列上的任意时间步$t^\prime$,循环神经网络将来自上一时间步的输出$y_{t^\prime-1}$和上下文变量$\mathbf{c}$作为其输入,然后在当前时间步将它们和上一隐状态$\mathbf{s}_{t^\prime-1}$转换为隐状态$\mathbf{s}_{t^\prime}$。因此,可以使用函数$g$来表示解码器的隐藏层的变换:

当实现解码器时, 我们直接使用编码器最后一个时间步的隐状态来初始化解码器的隐状态。 这就要求使用循环神经网络实现的编码器和解码器具有相同数量的层和隐藏单元。 为了进一步包含经过编码的输入序列的信息, 上下文变量在所有的时间步与解码器的输入进行拼接。 为了预测输出词元的概率分布, 在循环神经网络解码器的最后一层使用全连接层来变换隐状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import torch
import torch.nn as nn
import d2l # 假设d2l是已经导入的深度学习库

class Seq2SeqDecoder(d2l.Decoder):
"""用于序列到序列学习的循环神经网络解码器。"""
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
dropout=0, **kwargs):
super(Seq2SeqDecoder, self).__init__(**kwargs)
# 词嵌入层:将词索引转换为词向量。
self.embedding = nn.Embedding(vocab_size, embed_size)
# GRU层:循环神经网络核心,处理输入序列和上下文向量。
self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
dropout=dropout)
# 全连接层:将RNN的输出转换为最终的词汇表预测。
self.dense = nn.Linear(num_hiddens, vocab_size)

def init_state(self, enc_outputs, *args):
# 初始化解码器的状态,这里简单地使用编码器的最终隐藏状态。
return enc_outputs[1]

def forward(self, X, state):
# 将输入序列X通过嵌入层转换为词向量。
X = self.embedding(X).permute(1, 0, 2) # 改变X的形状以匹配RNN的输入要求。
# 获取上下文向量,并扩展其维度以匹配输入序列的时间步。
context = state[-1].repeat(X.shape[0], 1, 1)
# 将词向量和上下文向量在特征维度上连接。
X_and_context = torch.cat((X, context), 2)
# 通过GRU处理输入序列。
output, state = self.rnn(X_and_context, state)
# 将RNN的输出通过全连接层转换为词汇预测。
output = self.dense(output).permute(1, 0, 2)
# 返回输出和新的隐藏状态。
# output的形状:(batch_size, num_steps, vocab_size)
# state的形状:(num_layers, batch_size, num_hiddens)
return output, state

用与前面提到的编码器中相同的超参数来实例化解码器。 如我们所见,解码器的输出形状变为(批量大小,时间步数,词表大小), 其中张量的最后一个维度存储预测的词元分布。

1
2
3
4
5
6
decoder = Seq2SeqDecoder(vocab_size=10, embed_size=8, num_hiddens=16,
num_layers=2)
decoder.eval()
state = decoder.init_state(encoder(X))
output, state = decoder(X, state)
output.shape, state.shape

../_images/seq2seq-details.svg


损失函数

在每个时间步,解码器预测了输出词元的概率分布。 类似于语言模型,可以使用softmax来获得分布, 并通过计算交叉熵损失函数来进行优化,但是,我们应该将填充词元的预测排除在损失函数的计算之外。

为此,我们可以使用下面的sequence_mask函数 通过零值化屏蔽不相关的项, 以便后面任何不相关预测的计算都是与零的乘积,结果都等于零。 例如,如果两个序列的有效长度(不包括填充词元)分别为1和2, 则第一个序列的第一项和第二个序列的前两项之后的剩余项将被清除为零。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import torch

def sequence_mask(X, valid_len, value=0):
"""
在序列中屏蔽不相关的项。

参数:
X: 输入的张量,形状为(batch_size, sequence_length),包含了需要被屏蔽处理的序列。
valid_len: 一个张量,形状为(batch_size,),包含了每个序列中有效元素的数量。
value: 屏蔽后的填充值,默认为0。

返回:
经过屏蔽处理的输入张量X。
"""
# 获取输入序列的最大长度。
maxlen = X.size(1)
# 生成一个与序列长度相同的掩码,掩码中每个位置的值表示该位置是否应该被保留。
mask = torch.arange((maxlen), dtype=torch.float32,
device=X.device)[None, :] < valid_len[:, None]
# 使用生成的掩码对输入X进行屏蔽处理,不需要的元素被设置为指定的value。
X[~mask] = value
return X

# 示例输入序列
X = torch.tensor([[1, 2, 3], [4, 5, 6]])
# 应用序列掩码,指定每个序列的有效长度。
sequence_mask(X, torch.tensor([1, 2]))

现在,我们可以通过扩展softmax交叉熵损失函数来遮蔽不相关的预测。 最初,所有预测词元的掩码都设置为1。 一旦给定了有效长度,与填充词元对应的掩码将被设置为0。 最后,将所有词元的损失乘以掩码,以过滤掉损失中填充词元产生的不相关预测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
"""带遮蔽的softmax交叉熵损失函数。"""
def forward(self, pred, label, valid_len):
"""
前向传播方法,计算损失。

参数:
pred: 预测值,形状为(batch_size, num_steps, vocab_size),其中包含了对每个时间步的词汇预测。
label: 真实标签,形状为(batch_size, num_steps),包含了每个时间步的真实词汇索引。
valid_len: 有效长度,形状为(batch_size,),指示了每个序列中有效词汇的数量。

返回:
加权的损失值,其中不应计算损失的部分已被遮蔽。
"""
# 创建一个与label形状相同的权重张量,初始全为1。
weights = torch.ones_like(label)
# 使用sequence_mask函数根据valid_len遮蔽weights中的不相关部分。
weights = sequence_mask(weights, valid_len)
# 设置损失计算模式为'none',以获取逐个元素的损失而不是平均损失。
self.reduction = 'none'
# 计算未加权的损失,需要调整pred的维度以符合CrossEntropyLoss的输入要求。
unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
pred.permute(0, 2, 1), label)
# 将未加权的损失与权重相乘,然后沿着时间步的维度取平均,得到加权损失。
weighted_loss = (unweighted_loss * weights).mean(dim=1)
# 返回加权损失的平均值。
return weighted_loss


训练

在下面的循环训练过程中,如图所示, 特定的序列开始词元(<bos)和 原始的输出序列(不包括序列结束词元<eos>) 拼接在一起作为解码器的输入。 这被称为强制教学(teacher forcing), 因为原始的输出序列(词元的标签)被送入解码器。 或者,将来自上一个时间步的预测 得到的词元作为解码器的当前输入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import torch
import torch.nn as nn
import d2l # 假设d2l是已经导入的深度学习库

def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
"""训练序列到序列模型"""
def xavier_init_weights(m):
"""使用Xavier初始化网络权重"""
if type(m) == nn.Linear:
nn.init.xavier_uniform_(m.weight)
if type(m) == nn.GRU:
for param in m._flat_weights_names:
if "weight" in param:
nn.init.xavier_uniform_(m._parameters[param])

# 应用权重初始化,并将模型移动到指定的设备上
net.apply(xavier_init_weights)
net.to(device)

# 使用Adam优化器
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
# 使用自定义的MaskedSoftmaxCELoss计算损失
loss = MaskedSoftmaxCELoss()
net.train()

# 使用d2l库的Animator绘制训练过程中的损失变化
animator = d2l.Animator(xlabel='epoch', ylabel='loss', xlim=[10, num_epochs])

for epoch in range(num_epochs):
timer = d2l.Timer()
metric = d2l.Accumulator(2) # 用于累积损失和词元数量

for batch in data_iter:
optimizer.zero_grad()
X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
# 准备解码器的输入,包括开始符号
bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0], device=device).reshape(-1, 1)
dec_input = torch.cat([bos, Y[:, :-1]], 1) # 使用强制教学
Y_hat, _ = net(X, dec_input, X_valid_len)
l = loss(Y_hat, Y, Y_valid_len)
l.sum().backward() # 反向传播
d2l.grad_clipping(net, 1) # 梯度裁剪
num_tokens = Y_valid_len.sum()
optimizer.step()
with torch.no_grad():
metric.add(l.sum(), num_tokens) # 更新累积的损失和词元数量

# 每隔一定epoch更新动画
if (epoch + 1) % 10 == 0:
animator.add(epoch + 1, (metric[0] / metric[1],))

# 打印训练损失和速度
print(f'loss {metric[0] / metric[1]:.3f}, {metric[1] / timer.stop():.1f} '
f'tokens/sec on {str(device)}')

现在,在机器翻译数据集上,我们可以 创建和训练一个循环神经网络“编码器-解码器”模型用于序列到序列的学习。

1
2
3
4
5
6
7
8
9
10
11
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 300, d2l.try_gpu()

train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
encoder = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers,
dropout)
decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers,
dropout)
net = d2l.EncoderDecoder(encoder, decoder)
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

../_images/output_seq2seq_13725e_171_1.svg


预测

../_images/seq2seq-predict.svg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#@save
def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,
device, save_attention_weights=False):
"""序列到序列模型的预测"""
# 在预测时将net设置为评估模式
net.eval()
src_tokens = src_vocab[src_sentence.lower().split(' ')] + [
src_vocab['<eos>']]
enc_valid_len = torch.tensor([len(src_tokens)], device=device)
src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab['<pad>'])
# 添加批量轴
enc_X = torch.unsqueeze(
torch.tensor(src_tokens, dtype=torch.long, device=device), dim=0)
enc_outputs = net.encoder(enc_X, enc_valid_len)
dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)
# 添加批量轴
dec_X = torch.unsqueeze(torch.tensor(
[tgt_vocab['<bos>']], dtype=torch.long, device=device), dim=0)
output_seq, attention_weight_seq = [], []
for _ in range(num_steps):
Y, dec_state = net.decoder(dec_X, dec_state)
# 我们使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
dec_X = Y.argmax(dim=2)
pred = dec_X.squeeze(dim=0).type(torch.int32).item()
# 保存注意力权重(稍后讨论)
if save_attention_weights:
attention_weight_seq.append(net.decoder.attention_weights)
# 一旦序列结束词元被预测,输出序列的生成就完成了
if pred == tgt_vocab['<eos>']:
break
output_seq.append(pred)
return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq

评估

我们可以通过与真实的标签序列进行比较来评估预测序列。 BLEU(bilingual evaluation understudy) 最先是用于评估机器翻译的结果, 但现在它已经被广泛用于测量许多应用的输出序列的质量。 原则上说,对于预测序列中的任意$n$元语法(n-grams), BLEU的评估都是这个$n$元语法是否出现在标签序列中。

其中$\mathrm{len}_{\text{label}}$表示标签序列中的词元数和$\mathrm{len}_{\text{pred}}$表示预测序列中的词元数,$k$是用于匹配的最长的$n$元语法。

另外,用$p_n$表示$n$元语法的精确度,它是两个数量的比值:第一个是预测序列与标签序列中匹配的$n$元语法的数量,第二个是预测序列中$n$元语法的数量的比率。具体地说,给定标签序列$A$、$B$、$C$、$D$、$E$、$F$和预测序列$A$、$B$、$B$、$C$、$D$,我们有$p_1 = 4/5$、$p_2 = 3/4$、$p_3 = 1/3$和$p_4 = 0$。

当预测序列与标签序列完全相同时,BLEU为$1$。此外,由于$n$元语法越长则匹配难度越大,所以BLEU为更长的$n$元语法的精确度分配更大的权重。具体来说,当$p_n$固定时,$p_n^{1/2^n}$会随着$n$的增长而增加(原始论文使用$p_n^{1/n}$)。而且,由于预测的序列越短获得的$p_n$值越高,

所以公式中中乘法项之前的系数用于惩罚较短的预测序列。例如,当$k=2$时,给定标签序列$A$、$B$、$C$、$D$、$E$、$F$和预测序列$A$、$B$,尽管$p_1 = p_2 = 1$,惩罚因子$\exp(1-6/2) \approx 0.14$会降低BLEU。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import math
import collections

def bleu(pred_seq, label_seq, k): #@save
"""计算BLEU分数。

参数:
pred_seq: 预测序列,即机器翻译的输出文本。
label_seq: 标签序列,即参考翻译的文本。
k: 最高考虑的n-gram的阶数。

返回:
BLEU分数。
"""
# 将预测序列和标签序列分割成单词列表。
pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
len_pred, len_label = len(pred_tokens), len(label_tokens)

# 计算长度惩罚项。
score = math.exp(min(0, 1 - len_label / len_pred))

# 对于从1到k的每个n,计算n-gram精确度。
for n in range(1, k + 1):
num_matches = 0
label_subs = collections.defaultdict(int)

# 为标签序列中的所有n-gram建立字典。
for i in range(len_label - n + 1):
label_subs[' '.join(label_tokens[i: i + n])] += 1

# 对于预测序列中的每个n-gram,如果在标签序列的字典中,则匹配数加1。
for i in range(len_pred - n + 1):
if label_subs[' '.join(pred_tokens[i: i + n])] > 0:
num_matches += 1
label_subs[' '.join(pred_tokens[i: i + n])] -= 1

# 计算n-gram精确度并累乘到分数中。
score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))

# 返回最终的BLEU分数。
return score


总结

特点/模型 GRU LSTM 深度循环神经网络 Seq2Seq
基本结构 2个门(重置门和更新门) 3个门(遗忘门、输入门、输出门)加上一个细胞状态 多层RNN堆叠而成 编码器-解码器结构,可基于RNN、GRU或LSTM
参数数量 比LSTM少,因为只有两个门 相对较多,因为有三个门和一个细胞状态 随层数增加而大幅增加 取决于编码器和解码器的具体实现
记忆能力 较好,尤其是在较短的序列中 非常好,能处理长期依赖问题 通过增加层数增强记忆能力 优于单一RNN,因为编码器将所有信息压缩成一个上下文向量
处理长序列能力 优于基本RNN,但可能不如LSTM 优于GRU和基本RNN 通过深层结构提高处理能力,但容易遇到梯度问题 通过注意力机制(在高级实现中)可以更好地处理长序列
训练难度 相对容易,参数少 相对困难,参数多 更难,因为层数增多导致的梯度消失或爆炸问题 取决于序列的长度和复杂性,以及是否使用注意力机制
应用场景 序列数据处理,如语言模型、文本生成 同GRU,但尤其适合需要处理更长依赖的任务 同GRU和LSTM,但适用于更复杂的序列处理任务 机器翻译、文本摘要、语音识别等需要序列转换的任务