10.现代循环神经网络
目录
- 门控循环单元(GRU)
- 门控隐状态
- 重置门和更新门
- 候选隐状态
- 隐状态
- 从零开始实现
- 初始化模型参数
- 定义模型
- 训练与预测
- 简洁实现
- 总结
- 长短期记忆网络(LSTM)
- 门控记忆元
- 输入门、忘记门和输出门
- 候选记忆元
- 记忆元
- 隐状态
- 从零开始实现
- 初始化模型参数
- 定义模型
- 训练和预测
- 简洁实现
- 总结
- 门控记忆元
- GRU和LSTM区别
- 深度循环神经网络
- 函数依赖关系
- 简洁实现
- 训练与预测
- 总结
- 双向循环神经网络
- 隐马尔可夫模型中的动态规划
- 双向模型
- 定义
- 模型的计算代价及其应用
- 双向循环神经网络的错误应用
- 总结
- 机器翻译与数据集
- 下载和预处理数据集
- 词元化
- 词表
- 加载数据集
- 训练模型
- 总结
- 编码器-解码器架构
- 重新看CNN
- 重新看RNN
- 编码器
- 解码器
- 合并编码器和解码器
- 总结
- 序列到序列学习(seq2seq)
- 编码器
- 解码器
- 损失函数
- 训练
- 预测
- 预测序列的评估
- 束搜索
- 贪心搜索
- 束搜索
- 总结
门控循环单元(GRU)
我们讨论了如何在循环神经网络中计算梯度, 以及矩阵连续乘积可以导致梯度消失或梯度爆炸的问题。 下面我们简单思考一下这种梯度异常在实践中的意义:
- 我们可能会遇到这样的情况:早期观测值对预测所有未来观测值具有非常重要的意义。 考虑一个极端情况,其中第一个观测值包含一个校验和, 目标是在序列的末尾辨别校验和是否正确。 在这种情况下,第一个词元的影响至关重要。 我们希望有某些机制能够在一个记忆元里存储重要的早期信息。 如果没有这样的机制,我们将不得不给这个观测值指定一个非常大的梯度, 因为它会影响所有后续的观测值。
- 我们可能会遇到这样的情况:**一些词元没有相关的观测值。 **例如,在对网页内容进行情感分析时, 可能有一些辅助HTML代码与网页传达的情绪无关。 我们希望有一些机制来_跳过_隐状态表示中的此类词元。
- 我们可能会遇到这样的情况:序列的各个部分之间存在逻辑中断。 例如,书的章节之间可能会有过渡存在, 或者证券的熊市和牛市之间可能会有过渡存在。 在这种情况下,最好有一种方法来_重置_我们的内部状态表示。

在学术界已经提出了许多方法来解决这类问题。 其中最早的方法是”长短期记忆”(long-short-term memory,LSTM) (Hochreiter and Schmidhuber, 1997。 门控循环单元(gated recurrent unit,GRU) (Cho et al., 2014) 是一个稍微简化的变体,通常能够提供同等的效果, 并且计算 (Chung et al., 2014)的速度明显更快。 由于门控循环单元更简单,我们从它开始解读。
门控隐状态
门控循环单元与普通的循环神经网络之间的关键区别在于: 前者支持隐状态的门控。 这意味着模型有专门的机制来确定应该何时更新隐状态, 以及应该何时重置隐状态。 这些机制是可学习的,并且能够解决了上面列出的问题。 例如,如果第一个词元非常重要, 模型将学会在第一次观测之后不更新隐状态。 同样,模型也可以学会跳过不相关的临时观测。 最后,模型还将学会在需要的时候重置隐状态。 下面我们将详细讨论各类门控。
重置门和更新门
我们首先介绍_重置门_(reset gate)和_更新门_(update gate)。 我们把它们设计成(0,1)区间中的向量, 这样我们就可以进行凸组合。 重置门允许我们控制“可能还想记住”的过去状态的数量; 更新门将允许我们控制新状态中有多少个是旧状态的副本。
📌更新门是一种权重,用于确定应该保留当前状态的多少,以及应该从新输入的信息中获取多少信息。 更新门的值介于 0 和 1 之间,如果值接近 0,则表示应该保留当前状态;如果值接近 1,则表示应该从新输入的信息中获取信息。
重置门是另一种权重,用于决定是否将记忆状态重置为零。 如果重置门的值接近 1,则表示应该重置记忆状态;如果值接近 0,则表示应该保留当前记忆状态。
因此,更新门和重置门的区别在于:更新门决定了应该保留当前状态的多少以及从新输入的信息中获取多少信息,而重置门决定了是否应该重置记忆状态。
我们从构造这些门控开始。 下图描述了门控循环单元中的重置门和更新门的输入, 输入是由当前时间步的输入和前一时间步的隐状态给出。 两个门的输出是由使用sigmoid激活函数的两个全连接层给出。

我们来看一下门控循环单元的数学表达。 对于给定的时间步 t t 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}$的计算如下所示:
R t = σ ( X t W x r + H t − 1 W h r + b r ) Z t = σ ( X t W x z + H t − 1 W h z + b z ) \begin{aligned} \mathbf{R}_{t} & =\sigma\left(\mathbf{X}_{t} \mathbf{W}_{x r}+\mathbf{H}_{t-1} \mathbf{W}_{h r}+\mathbf{b}_{r}\right) \\ \mathbf{Z}_{t} & =\sigma\left(\mathbf{X}_{t} \mathbf{W}_{x z}+\mathbf{H}_{t-1} \mathbf{W}_{h z}+\mathbf{b}_{z}\right)\end{aligned} RtZt=σ(XtWxr+Ht−1Whr+br)=σ(XtWxz+Ht−1Whz+bz)
其中 W x r , W x z ∈ R d × h \mathbf{W}_{x r}, \mathbf{W}_{x z} \in \mathbb{R}^{d \times h} Wxr,Wxz∈Rd×h 和 W h r , W h z ∈ R h × h \mathbf{W}_{h r}, \mathbf{W}_{h z} \in \mathbb{R}^{h \times h} Whr,Whz∈Rh×h是权重参数, b r , b z ∈ R 1 × h \mathbf{b}_{r}, \mathbf{b}_{z} \in \mathbb{R}^{1 \times h} br,bz∈R1×h是偏置参数。 请注意,在求和过程中会触发广播机制 。 我们使用sigmoid函数将输入值转换到区间(0,1)。
候选隐状态
首先看一下之前的隐状态
H t = ϕ ( X t W x h + H t − 1 W h h + b h ) \mathbf{H}_{t}=\phi\left(\mathbf{X}_{t} \mathbf{W}_{x h}+\mathbf{H}_{t-1} \mathbf{W}_{h h}+\mathbf{b}_{h}\right) Ht=ϕ(XtWxh+Ht−1Whh+bh)
接下来,让我们将重置门 R t \mathbf{R}_t Rt与常规隐状态更新机制集成, 得到在时间步 t t t的_候选隐状态_(candidate hidden state) H ~ t ∈ R n × h \tilde{\mathbf{H}}_t \in \mathbb{R}^{n \times h} H~t∈Rn×h。
H ~ t = tanh ( X t W x h + ( R t ⊙ H t − 1 ) W h h + b h ) , \tilde{\mathbf{H}}_t = \tanh(\mathbf{X}_t \mathbf{W}_{xh} + \left(\mathbf{R}_t \odot \mathbf{H}_{t-1}\right) \mathbf{W}_{hh} + \mathbf{b}_h), H~t=tanh(XtWxh+(Rt⊙Ht−1)Whh+bh),
其中
W
x
h
∈
R
d
×
h
\mathbf{W}_{xh} \in \mathbb{R}^{d \times h}
Wxh∈Rd×h和
W
h
h
∈
R
h
×
h
\mathbf{W}_{hh} \in \mathbb{R}^{h \times h}
Whh∈Rh×h是权重参数,
b
h
∈
R
1
×
h
\mathbf{b}_h \in \mathbb{R}^{1 \times h}
bh∈R1×h是偏置项, 符号
⊙
\odot
⊙是Hadamard积(按元素乘积)运算符。 在这里,我们使用tanh非线性激活函数来确保候选隐状态中的值保持在区间
(
−
1
,
1
)
(-1, 1)
(−1,1)**
中。**
R t \mathbf{R}_t Rt和 H t − 1 \mathbf{H}_{t-1} Ht−1的元素相乘可以减少以往状态的影响。 每当重置门 R t \mathbf{R}_t Rt中的项接近1时, 我们恢复一个如普通的循环神经网络。 对于重置门 R t \mathbf{R}_t Rt中所有接近0的项, 候选隐状态是以 X t \mathbf{X}_t Xt作为输入的多层感知机的结果。 因此,任何预先存在的隐状态都会被_重置_为默认值。
📌候选隐状态 H ~ t \tilde{\mathbf{H}}_t H~t 和 R t \mathbf{R}_t Rt 之间的关系可以理解为 R t \mathbf{R}_t Rt 是对 H ~ t \tilde{\mathbf{H}}_t H~t 的修正。 R t \mathbf{R}_t Rt 是一个形状和 H ~ t \tilde{\mathbf{H}}_t H~t 相同的矩阵,其中每一个元素都在 0 0 0 到 1 1 1 之间,表示了 H ~ t \tilde{\mathbf{H}}_t H~t 对当前时刻隐藏状态 H t \mathbf{H}_t Ht 的影响程度。
如果 R t \mathbf{R}_t Rt 中的元素较大,则说明当前时刻的输入 X t \mathbf{X}_t Xt 对当前隐藏状态 H t \mathbf{H}_t Ht 的影响较大,即 X t \mathbf{X}_t Xt 是一个较重要的特征,而之前的隐藏状态 H t − 1 \mathbf{H}_{t-1} Ht−1 对当前隐藏状态 H t \mathbf{H}_t Ht 的影响较小。
反之,如果 R t \mathbf{R}_t Rt 中的元素较小,则说明当前时刻的输入 X t \mathbf{X}_t Xt 对当前隐藏状态 H t \mathbf{H}_t Ht 的影响较小,即 X t \mathbf{X}_t Xt 不是一个重要的特征,而之前的隐藏状态 H t − 1 \mathbf{H}_{t-1} Ht−1 对当前隐藏状态 H t \mathbf{H}_t Ht 的影响较大。
下图说明了应用重置门之后的计算流程。

隐状态
上述的计算结果只是候选隐状态,我们仍然需要结合更新门 Z t \mathbf{Z}_t Zt的效果。 这一步确定新的隐状态 H t ∈ R n × h \mathbf{H}_t \in \mathbb{R}^{n \times h} Ht∈Rn×h在多大程度上来自旧的状态 H t − 1 \mathbf{H}_{t-1} Ht−1和 新的候选状态 H ~ t \tilde{\mathbf{H}}_t H~t。 更新门 Z t \mathbf{Z}_t Zt仅需要在 H t − 1 \mathbf{H}_{t-1} Ht−1和 H ~ t \tilde{\mathbf{H}}_t H~t之间进行按元素的凸组合就可以实现这个目标。 这就得出了门控循环单元的最终更新公式:
H t = Z t ⊙ H t − 1 + ( 1 − Z t ) ⊙ H ~ t . \mathbf{H}_t = \mathbf{Z}_t \odot \mathbf{H}_{t-1} + (1 - \mathbf{Z}_t) \odot \tilde{\mathbf{H}}_t. Ht=Zt⊙Ht−1+(1−Zt)⊙H~t.
每当更新门 Z t \mathbf{Z}_t Zt接近 1 1 1时,模型就倾向只保留旧状态。 此时,来自 X t \mathbf{X}_t Xt的信息基本上被忽略, 从而有效地跳过了依赖链条中的时间步 t t t。
相反,当 Z t \mathbf{Z}_t Zt接近 0 0 0时, 新的隐状态 H t \mathbf{H}_t Ht就会接近候选隐状态 H ~ t \tilde{\mathbf{H}}_t H~t。
这些设计可以帮助我们处理循环神经网络中的梯度消失问题, 并更好地捕获时间步距离很长的序列的依赖关系。 例如,如果整个子序列的所有时间步的更新门都接近于 1 1 1, 则无论序列的长度如何,在序列起始时间步的旧隐状态都将很容易保留并传递到序列结束。
下图说明了更新门起作用后的计算流。

总之,门控循环单元具有以下两个显著特征:
- 重置门有助于捕获序列中的短期依赖关系;
- 更新门有助于捕获序列中的长期依赖关系。
R t = σ ( X t W x r + H t − 1 W h r + b r ) Z t = σ ( X t W x z + H t − 1 W h z + b z ) H ~ t = tanh ( X t W x h + ( R t ⊙ H t − 1 ) W h h + b h ) H t = Z t ⊙ H t − 1 + ( 1 − Z t ) ⊙ H ~ t \begin{aligned} \boldsymbol{R}_{t} & =\sigma\left(\boldsymbol{X}_{t} \boldsymbol{W}_{x r}+\boldsymbol{H}_{t-1} \boldsymbol{W}_{h r}+\boldsymbol{b}_{r}\right) \\ \boldsymbol{Z}_{t} & =\sigma\left(\boldsymbol{X}_{t} \boldsymbol{W}_{x z}+\boldsymbol{H}_{t-1} \boldsymbol{W}_{h z}+\boldsymbol{b}_{z}\right) \\ \tilde{\boldsymbol{H}}_{t} & =\tanh \left(\boldsymbol{X}_{t} \boldsymbol{W}_{x h}+\left(\boldsymbol{R}_{t} \odot \boldsymbol{H}_{t-1}\right) \boldsymbol{W}_{h h}+\boldsymbol{b}_{h}\right) \\ \boldsymbol{H}_{t} & =\boldsymbol{Z}_{t} \odot \boldsymbol{H}_{t-1}+\left(1-\boldsymbol{Z}_{t}\right) \odot \tilde{\boldsymbol{H}}_{t}\end{aligned} RtZtH~tHt=σ(XtWxr+Ht−1Whr+br)=σ(XtWxz+Ht−1Whz+bz)=tanh(XtWxh+(Rt⊙Ht−1)Whh+bh)=Zt⊙Ht−1+(1−Zt)⊙H~t
从零开始实现
为了更好地理解门控循环单元模型,我们从零开始实现它。 首先,我们读取时间机器数据集:
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**定义隐藏单元的数量, 实例化与更新门、重置门、候选隐状态和输出层相关的所有权重和偏置。
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
上面这段代码是在初始化参数,其中包括了LSTM模型的更新门、重置门和候选隐状态参数。
vocab_size是词典大小,即输入数据的特征维度;
num_hiddens是隐藏层的大小,即隐状态的维度。
device是用于设置随机数生成的设备(CPU或GPU)。
normal函数用于生成按照正态分布初始化的权重参数,其中每个元素都是标准差为0.01的随机数。three函数用于生成三元组,其中第一个元素是权重矩阵,第二个元素是偏置,第三个元素是全0向量。
然后,代码中分别定义了更新门、重置门和候选隐状态参数。最后定义了输出层参数。
最后,所有的参数都要设置为可以计算梯度,即param.requires_grad_(True)。最终,返回所有的参数。
定义模型
现在我们将定义隐状态的初始化函数init_gru_state。 此函数返回一个形状为(批量大小,隐藏单元个数)的张量,张量的值全部为零。
def init_gru_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device), )
现在我们准备定义门控循环单元模型, 模型的架构与基本的循环神经网络单元是相同的, 只是权重更新公式更为复杂。
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,)
实现了 GRU 循环神经网络的前向计算。
其中,gru 函数实现了 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。
然后,对于每个时间步的输入 X,通过公式
Z
t
=
σ
(
X
t
W
x
z
+
H
t
−
1
W
h
z
+
b
z
)
\mathbf{Z}_t = \sigma\left(\mathbf{X}_t \mathbf{W}_{xz} + \mathbf{H}_{t-1} \mathbf{W}_{hz} + \mathbf{b}_z\right)
Zt=σ(XtWxz+Ht−1Whz+bz) 计算更新门
Z
t
\mathbf{Z}_t
Zt。
同理,计算重置门 R t \mathbf{R}_t Rt, R t = σ ( X t W x r + H t − 1 W h r + b r ) \mathbf{R}_t = \sigma\left(\mathbf{X}_t \mathbf{W}_{xr} + \mathbf{H}_{t-1} \mathbf{W}_{hr} + \mathbf{b}_r\right) Rt=σ(XtWxr+Ht−1Whr+br)。
最后,计算候选隐状态 H ~ t = tanh ( X t W x h + ( R t ⊙ H t − 1 ) W h h + b h ) \tilde{\mathbf{H}}_t = \tanh\left(\mathbf{X}_t \mathbf{W}_{xh} + \left(\mathbf{R}_t \odot \mathbf{H}_{t-1}\right) \mathbf{W}_{hh} + \mathbf{b}_h\right) H~t=tanh(XtWxh+(Rt⊙Ht−1)Whh+bh)。
最终,更新隐状态 H t = Z t ⊙ H t − 1 + ( 1 − Z t ) ⊙ H ~ t \mathbf{H}_t = \mathbf{Z}_t \odot \mathbf{H}_{t-1} + \left(1 - \mathbf{Z}_t\right) \odot \tilde{\mathbf{H}}_t Ht=Zt⊙Ht−1+(1−Zt)⊙H~t,并计算输出 Y t = H t W h q + b q \mathbf{Y}_t = \mathbf{H}_t \mathbf{W}_{hq} + \mathbf{b}_q Yt=HtWhq+bq
训练与预测
训练结束后,我们分别打印输出训练集的困惑度, 以及前缀“time traveler”和“traveler”的预测序列上的困惑度。
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)
这段代码是用来训练一个 RNN 模型的。代码中,首先声明了一些超参数,如 vocab_size(字典大小)、num_hiddens(隐藏单元数量)、device(使用的 GPU 设备)、num_epochs(训练次数)、lr(学习率)。然后创建了一个 RNNModelScratch 的实例,并将刚才声明的超参数传入构造函数。最后,调用 train_ch8 函数训练模型。
perplexity 1.3, 28030.1 tokens/sec on cuda:0
time traveller wetheving of my investian of the fromaticalllesp
travellery celaner betareabreart of the three dimensions an

简洁实现
高级API包含了前文介绍的所有配置细节, 所以我们可以直接实例化门控循环单元模型。 这段代码的运行速度要快得多, 因为它使用的是编译好的运算符而不是Python来处理之前阐述的许多细节。
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)
perplexity 1.1, 334788.1 tokens/sec on cuda:0
time traveller with a slight accession ofcheerfulness really thi
travelleryou can show black is white by argument said filby

总结
- 门控循环神经网络可以更好地捕获时间步距离很长的序列上的依赖关系。
- 重置门有助于捕获序列中的短期依赖关系。
- 更新门有助于捕获序列中的长期依赖关系。
- 重置门打开时,门控循环单元包含基本循环神经网络;更新门打开时,门控循环单元可以跳过子序列。
长短期记忆网络(LSTM)
长期以来,隐变量模型存在着长期信息保存和短期输入缺失的问题。 解决这一问题的最早方法之一是长短期存储器(long short-term memory,LSTM) (Hochreiter and Schmidhuber, 1997)。 它有许多与门控循环单元一样的属性。 有趣的是,长短期记忆网络的设计比门控循环单元稍微复杂一些, 却比门控循环单元早诞生了近20年。
LSTM(Long-Short Term Memory)是一种时间循环神经网络,特别适用于处理和预测时间序列数据。LSTM在RNN(循环神经网络)的基础上进行了改进,引入了一个新的机制,允许它保留历史信息和遗忘不相关的信息。
LSTM网络由记忆细胞、输入门、遗忘门和输出门组成。记忆细胞负责保存和更新历史信息,而输入门、遗忘门和输出门则控制对记忆细胞的读写操作。
- 输入门控制当前时刻是否将新信息写入记忆细胞。
- 遗忘门决定从记忆细胞中删除什么信息。
- 输出门决定从记忆细胞中读取什么信息。
在训练LSTM网络时,通过不断更新门的参数以及记忆细胞的状态来学习对历史信息的注意力和保留能力。
LSTM的引入使得循环神经网络能够解决更加复杂的问题,特别是对于带有长期依赖关系的时间序列数据,LSTM的性能明显优于普通的循环神经网络。
门控记忆元
可以说,长短期记忆网络的设计灵感来自于计算机的逻辑门。** 长短期记忆网络引入了记忆元(memory cell),或简称为单元(cell)。 有些文献认为记忆元是隐状态的一种特殊类型, 它们与隐状态具有相同的形状,其设计目的是用于记录附加的信息。 为了控制记忆元,我们需要许多门。 其中一个门用来从单元中输出条目,我们将其称为输出门 **(output gate)。 另外一个门用来决定何时将数据读入单元,我们将其称为输入门(input gate)。 我们还需要一种机制来重置单元的内容,由遗忘门(forget gate)来管理, 这种设计的动机与门控循环单元相同, 能够通过专用机制决定什么时候记忆或忽略隐状态中的输入。 让我们看看这在实践中是如何运作的。

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

我们来细化一下长短期记忆网络的数学表达。 假设有 h h h个隐藏单元,批量大小为 n n n,输入数为 d d d。 因此,输入为 X t ∈ R n × d \mathbf{X}_t \in \mathbb{R}^{n \times d} Xt∈Rn×d, 前一时间步的隐状态为 H t − 1 ∈ R n × h \mathbf{H}_{t-1} \in \mathbb{R}^{n \times h} Ht−1∈Rn×h。 相应地,时间步 t t t的门被定义如下: 输入门是 I t ∈ R n × h \mathbf{I}_t \in \mathbb{R}^{n \times h} It∈Rn×h, 遗忘门是 F t ∈ R n × h \mathbf{F}_t \in \mathbb{R}^{n \times h} Ft∈Rn×h, 输出门是 O t ∈ R n × h \mathbf{O}_t \in \mathbb{R}^{n \times h} Ot∈Rn×h。 它们的计算方法如下
I t = σ ( X t W x i + H t − 1 W h i + b i ) F t = σ ( X t W x f + H t − 1 W h f + b f ) O t = σ ( X t W x o + H t − 1 W h o + b o ) \begin{aligned} \mathbf{I}_{t} & =\sigma\left(\mathbf{X}_{t} \mathbf{W}_{x i}+\mathbf{H}_{t-1} \mathbf{W}_{h i}+\mathbf{b}_{i}\right) \\ \mathbf{F}_{t} & =\sigma\left(\mathbf{X}_{t} \mathbf{W}_{x f}+\mathbf{H}_{t-1} \mathbf{W}_{h f}+\mathbf{b}_{f}\right) \\ \mathbf{O}_{t} & =\sigma\left(\mathbf{X}_{t} \mathbf{W}_{x o}+\mathbf{H}_{t-1} \mathbf{W}_{h o}+\mathbf{b}_{o}\right)\end{aligned} ItFtOt=σ(XtWxi+Ht−1Whi+bi)=σ(XtWxf+Ht−1Whf+bf)=σ(XtWxo+Ht−1Who+bo)
其中 W x i , W x f , W x o ∈ R d × h \mathbf{W}_{x i}, \mathbf{W}_{x f}, \mathbf{W}_{x o} \in \mathbb{R}^{d \times h} Wxi,Wxf,Wxo∈Rd×h和 W h i , W h f , W h o ∈ R h × h \mathbf{W}_{h i}, \mathbf{W}_{h f}, \mathbf{W}_{h o} \in \mathbb{R}^{h \times h} Whi,Whf,Who∈Rh×h是权重参数, b i , b f , b o ∈ R 1 × h \mathbf{b}_{i}, \mathbf{b}_{f}, \mathbf{b}_{o} \in \mathbb{R}^{1 \times h} bi,bf,bo∈R1×h是偏置参数。
候选记忆元
由于还没有指定各种门的操作,所以先介绍候选记忆元(candidate memory cell)$ \tilde{\mathbf{C}}_t \in \mathbb{R}^{n \times h} 。它的计算与上面描述的三个门的计算类似,但是 ∗ ∗ 使用 ∗ ∗ 。 它的计算与上面描述的三个门的计算类似, 但是**使用** 。它的计算与上面描述的三个门的计算类似,但是∗∗使用∗∗tanh ∗ ∗ 函数作为激活函数 ∗ ∗ ,函数的值范围为 **函数作为激活函数**,函数的值范围为 ∗∗函数作为激活函数∗∗,函数的值范围为(-1, 1) 。下面导出在时间步 。 下面导出在时间步 。下面导出在时间步t$处的方程:
C ~ t = tanh ( X t W x c + H t − 1 W h c + b c ) , \tilde{\mathbf{C}}_t = \text{tanh}(\mathbf{X}_t \mathbf{W}_{xc} + \mathbf{H}_{t-1} \mathbf{W}_{hc} + \mathbf{b}_c), C~t=tanh(XtWxc+Ht−1Whc+bc),
其中 W x c ∈ R d × h \mathbf{W}_{xc} \in \mathbb{R}^{d \times h} Wxc∈Rd×h和$\mathbf{W}_{hc} \in \mathbb{R}^{h \times h} 是权重参数, 是权重参数, 是权重参数,\mathbf{b}_c \in \mathbb{R}^{1 \times h} $是偏置参数。
候选记忆元的如下图所示。

记忆元
在门控循环单元中,有一种机制来控制输入和遗忘(或跳过)。 类似地,在长短期记忆网络中,也有两个门用于这样的目的:** 输入门** I t \mathbf{I}_t It控制采用多少来自 C ~ t \tilde{\mathbf{C}}_t C~t的新数据, 而遗忘门 F t \mathbf{F}_t Ft控制保留多少过去的 记忆元 C t − 1 ∈ R n × h \mathbf{C}_{t-1} \in \mathbb{R}^{n \times h} Ct−1∈Rn×h的内容。 使用按元素乘法,得出:
C t = F t ⊙ C t − 1 + I t ⊙ C ~ t . \mathbf{C}_t = \mathbf{F}_t \odot \mathbf{C}_{t-1} + \mathbf{I}_t \odot \tilde{\mathbf{C}}_t. Ct=Ft⊙Ct−1+It⊙C~t.
如果遗忘门始终为1且输入门始终为0, 则过去的记忆元 C t − 1 \mathbf{C}_{t-1} Ct−1** 将随时间被保存并传递到当前时间步**。 引入这种设计是为了缓解梯度消失问题, 并更好地捕获序列中的长距离依赖关系。
这样我们就得到了计算记忆元的流程图,如下图。

隐状态
最后,我们需要定义如何计算隐状态
H
t
∈
R
n
×
h
\mathbf{H}_t \in \mathbb{R}^{n \times h}
Ht∈Rn×h, 这就是输出门发挥作用的地方。 在长短期记忆网络中,它仅仅是记忆元的
t
a
n
h
tanh
tanh
的门控版本。 这就确保了
H
t
\mathbf{H}_t
Ht的值始终在区间
(
−
1
,
1
)
(-1, 1)
(−1,1)内:
H t = O t ⊙ tanh ( C t ) . \mathbf{H}_t = \mathbf{O}_t \odot \tanh(\mathbf{C}_t). Ht=Ot⊙tanh(Ct).
只要输出门接近 1 1 1,我们就能够有效地将所有记忆信息传递给预测部分, 而对于输出门接近 0 0 0,我们只保留记忆元内的所有信息,而不需要更新隐状态。
下图提供了数据流的图形化演示。

完整公式
I t = σ ( X t W x i + H t − 1 W h i + b i ) F t = σ ( X t W x f + H t − 1 W h f + b f ) O t = σ ( X t W x o + H t − 1 W h o + b o ) C ~ t = tanh ( X t W x c + H t − 1 W h c + b c ) C t = F t ⊙ C t − 1 + I t ⊙ C ~ t H t = O t ⊙ tanh ( C t ) \begin{aligned} \boldsymbol{I}_{t} & =\sigma\left(\boldsymbol{X}_{t} \boldsymbol{W}_{x i}+\boldsymbol{H}_{t-1} \boldsymbol{W}_{h i}+\boldsymbol{b}_{i}\right) \\ \boldsymbol{F}_{t} & =\sigma\left(\boldsymbol{X}_{t} \boldsymbol{W}_{x f}+\boldsymbol{H}_{t-1} \boldsymbol{W}_{h f}+\boldsymbol{b}_{f}\right) \\ \boldsymbol{O}_{t} & =\sigma\left(\boldsymbol{X}_{t} \boldsymbol{W}_{x o}+\boldsymbol{H}_{t-1} \boldsymbol{W}_{h o}+\boldsymbol{b}_{o}\right) \\ \tilde{\boldsymbol{C}}_{t} & =\tanh \left(\boldsymbol{X}_{t} \boldsymbol{W}_{x c}+\boldsymbol{H}_{t-1} \boldsymbol{W}_{h c}+\boldsymbol{b}_{c}\right) \\ \boldsymbol{C}_{t} & =\boldsymbol{F}_{t} \odot \boldsymbol{C}_{t-1}+\boldsymbol{I}_{t} \odot \tilde{\boldsymbol{C}}_{t} \\ \boldsymbol{H}_{t} & =\boldsymbol{O}_{t} \odot \tanh \left(\boldsymbol{C}_{t}\right)\end{aligned} ItFtOtC~tCtHt=σ(XtWxi+Ht−1Whi+bi)=σ(XtWxf+Ht−1Whf+bf)=σ(XtWxo+Ht−1Who+bo)=tanh(XtWxc+Ht−1Whc+bc)=Ft⊙Ct−1+It⊙C~t=Ot⊙tanh(Ct)
从零开始实现
现在,我们从零开始实现长短期记忆网络。 我们首先加载时光机器数据集。
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)
初始化模型参数
接下来,我们需要定义和初始化模型参数。 如前所述,超参数num_hiddens定义隐藏单元的数量。 我们按照标准差0.01的高斯分布初始化权重,并将偏置项设为0。
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,形状为(批量大小,隐藏单元数)。 因此,我们得到以下的状态初始化。
# 初始化隐状态
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
\mathbf{C}_t
Ct
不直接参与输出计算。
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)
它实现了一个LSTM网络的单个时间步的前向传播(forward pass)。
在这段代码中,输入是一个序列形式的数据,也就是inputs。该数据包含了在一个时间步内的所有数据。对于每个时间步,从参数(params)中获取所有必要的权重(如W_xi、W_hi等)和偏差项(如b_i、b_f等)。
接下来,对于每个时间步,首先使用输入数据和隐藏状态计算三个门控制量:输入门、遗忘门和输出门。然后,使用输入数据和隐藏状态计算候选的记忆细胞(C_tilda)。然后,使用遗忘门(F)和输入门(I)计算当前的记忆细胞(C)。接下来,使用输出门(O)和记忆细胞(C)计算当前的隐藏状态(H)。最后,使用隐藏状态计算输出。
所有计算的结果(输出)都保存在outputs列表中,最后通过调用torch.cat函数将列表中的所有元素拼接在一起并返回。此外,函数还返回最终的隐藏状态和
训练和预测
让我们通过实例化RNNModelScratch类来训练一个长短期记忆网络。
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.4, 22462.5 tokens/sec on cuda:0
time traveller for the brow henint it aneles a overrecured aback
travellerifilby freenotin s dof nous be and the filing and

简洁实现
使用高级API,我们可以直接实例化LSTM模型。 高级API封装了前文介绍的所有配置细节。 这段代码的运行速度要快得多, 因为它使用的是编译好的运算符而不是Python来处理之前阐述的许多细节。
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
perplexity 1.1, 330289.2 tokens/sec on cuda:0
time travelleryou can show black is white by argument said filby
travelleryou can show black is white by argument said filby

总结
- 长短期记忆网络有三种类型的门:输入门、遗忘门和输出门。
- 长短期记忆网络的隐藏层输出包括“隐状态”和“记忆元”。只有隐状态会传递到输出层,而记忆元完全属于内部信息。
- 长短期记忆网络可以缓解梯度消失和梯度爆炸。
GRU和LSTM区别
GRU (Gated Recurrent Unit) 和 LSTM (Long Short-Term Memory) 都是循环神经网络(RNN)的重要组成部分。他们都是用于处理序列数据(如文本,语音,视频)的深度学习模型。
两者的主要区别在于它们的门控机制。 LSTM 具有输入门,输出门和遗忘门,而 GRU 只具有更新门和重置门。 这些门控机制允许模型更好地控制信息的流动,从而提高模型的效果。
另外,LSTM 结构较为复杂,它具有更多的参数,因此训练较慢,但具有更高的准确性;相反,GRU 结构更加简单,因此训练速度快,但不如 LSTM 准确。
总体而言,两者都是高效的循环神经网络,根据具体的任务和数据情况,可以进行选择。
深度循环神经网络
到目前为止,我们只讨论了具有一个单向隐藏层的循环神经网络。 其中,隐变量和观测值与具体的函数形式的交互方式是相当随意的。 只要交互类型建模具有足够的灵活性,这就不是一个大问题。 然而,对一个单层来说,这可能具有相当的挑战性。 之前在线性模型中,我们通过添加更多的层来解决这个问题。 而在循环神经网络中,我们首先需要确定如何添加更多的层, 以及在哪里添加额外的非线性,因此这个问题有点棘手。
事实上,我们可以将多层循环神经网络堆叠在一起, 通过对几个简单层的组合,产生了一个灵活的机制。 特别是,数据可能与不同层的堆叠有关。 例如,我们可能希望保持有关金融市场状况 (熊市或牛市)的宏观数据可用, 而微观数据只记录较短期的时间动态。
下图描述了一个具有 L L L个隐藏层的深度循环神经网络, 每个隐状态都连续地传递到当前层的下一个时间步和下一层的当前时间步。

函数依赖关系
我们可以将深度架构中的函数依赖关系形式化, 这个架构是由上图中描述了 L L L个隐藏层构成。 后续的讨论主要集中在经典的循环神经网络模型上, 但是这些讨论也适应于其他序列模型。
假设在时间步 t t t有一个小批量的输入数据 X t ∈ R n × d \mathbf{X}_t \in \mathbb{R}^{n \times d} Xt∈Rn×d(样本数: n n n,每个样本中的输入数: d d d)。 同时,将 l t h l^\mathrm{th} lth隐藏层$ l=1,\ldots,L 的隐状态设为 的隐状态设为 的隐状态设为\mathbf{H}_t^{(l)} \in \mathbb{R}^{n \times h} (隐藏单元数: (隐藏单元数: (隐藏单元数:ℎ ),输出层变量设为 ), 输出层变量设为 ),输出层变量设为\mathbf{O}_t \in \mathbb{R}^{n \times q} (输出数: (输出数: (输出数:q )。设置 )。 设置 )。设置\mathbf{H}_t^{(0)} = \mathbf{X}_t , ∗ ∗ 第 ∗ ∗ , **第** ,∗∗第∗∗l ∗ ∗ 个隐藏层的隐状态使用激活函数 ∗ ∗ **个隐藏层的隐状态使用激活函数** ∗∗个隐藏层的隐状态使用激活函数∗∗\phi_l$,则:
H t ( l ) = ϕ l ( H t ( l − 1 ) W x h ( l ) + H t − 1 ( l ) W h h ( l ) + b h ( l ) ) , \mathbf{H}_t^{(l)} = \phi_l(\mathbf{H}_t^{(l-1)} \mathbf{W}_{xh}^{(l)} + \mathbf{H}_{t-1}^{(l)} \mathbf{W}_{hh}^{(l)} + \mathbf{b}_h^{(l)}), Ht(l)=ϕl(Ht(l−1)Wxh(l)+Ht−1(l)Whh(l)+bh(l)),
其中,权重 W x h ( l ) ∈ R h × h \mathbf{W}_{xh}^{(l)} \in \mathbb{R}^{h \times h} Wxh(l)∈Rh×h, W h h ( l ) ∈ R h × h \mathbf{W}_{hh}^{(l)} \in \mathbb{R}^{h \times h} Whh(l)∈Rh×h和 偏置 b h ( l ) ∈ R 1 × h \mathbf{b}_h^{(l)} \in \mathbb{R}^{1 \times h} bh(l)∈R1×h都是第 l l l个隐藏层的模型参数。
最后,输出层的计算仅基于第 l l l个隐藏层最终的隐状态:
O t = H t ( L ) W h q + b q , \mathbf{O}_t = \mathbf{H}_t^{(L)} \mathbf{W}_{hq} + \mathbf{b}_q, Ot=Ht(L)Whq+bq,
其中,权重 W h q ∈ R h × q \mathbf{W}_{hq} \in \mathbb{R}^{h \times q} Whq∈Rh×q和偏置 b q ∈ R 1 × q \mathbf{b}_q \in \mathbb{R}^{1 \times q} bq∈R1×q都是输出层的模型参数。
与多层感知机一样,隐藏层数目 L L L和隐藏单元数目ℎ都是超参数。 也就是说,它们可以由我们调整的。 另外,用门控循环单元或长短期记忆网络的隐状态 来代替隐状态进行计算, 可以很容易地得到深度门控循环神经网络或深度长短期记忆神经网络。
简洁实现
实现多层循环神经网络所需的许多逻辑细节在高级API中都是现成的。 简单起见,我们仅示范使用此类内置函数的实现方式。 以长短期记忆网络模型为例, 该代码与之前在上节中使用的代码非常相似, 实际上唯一的区别是我们指定了层的数量, 而不是使用单一层这个默认值。 像往常一样,我们从加载数据集开始。
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**的值来设定隐藏层数。
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)
训练与预测
由于使用了长短期记忆网络模型来实例化两个层,因此训练速度被大大降低了。
num_epochs, lr = 500, 2
d2l.train_ch8(model, train_iter, vocab, lr*1.0, num_epochs, device)
perplexity 1.0, 224250.2 tokens/sec on cuda:0
time travelleryou can show black is white by argument said filby
travelleryou can show black is white by argument said filby

总结
- 在深度循环神经网络中,隐状态的信息被传递到当前层的下一时间步和下一层的当前时间步。
- 有许多不同风格的深度循环神经网络, 如长短期记忆网络、门控循环单元、或经典循环神经网络。 这些模型在深度学习框架的高级API中都有涵盖。
- 总体而言,深度循环神经网络需要大量的调参(如学习率和修剪) 来确保合适的收敛,模型的初始化也需要谨慎。
双向循环神经网络
在序列学习中,我们以往假设的目标是: 在给定观测的情况下 (例如,在时间序列的上下文中或在语言模型的上下文中), 对下一个输出进行建模。 虽然这是一个典型情景,但不是唯一的。 还可能发生什么其它的情况呢? 我们考虑以下三个在文本序列中填空的任务。
- 我
___。 - 我
___饿了。 - 我
___饿了,我可以吃半头猪。
根据可获得的信息量,我们可以用不同的词填空, 如“很高兴”(”happy”)、“不”(”not”)和“非常”(”very”)。 很明显,每个短语的“下文”传达了重要信息(如果有的话), 而这些信息关乎到选择哪个词来填空, 所以无法利用这一点的序列模型将在相关任务上表现不佳。 例如,如果要做好**命名实体识别 **(例如,识别“Green”指的是“格林先生”还是绿色), **不同长度的上下文范围重要性是相同的。 **为了获得一些解决问题的灵感,让我们先迂回到概率图模型。
- 取决于过去和未来的上下文,可以填还跟不一样的词
- 目前为止RNN只看过去
- 在填空的时候,我们也可以看未来
隐马尔可夫模型中的动态规划
这一小节是用来说明动态规划问题的, 具体的技术细节对于理解深度学习模型并不重要, 但它有助于我们思考为什么要使用深度学习, 以及为什么要选择特定的架构。
如果我们想用概率图模型来解决这个问题, 可以设计一个隐变量模型: 在任意时间步 t t t,假设存在某个隐变量 h t h_t ht, 通过概率 P ( x t ∣ h t ) P(x_t \mid h_t) P(xt∣ht)控制我们观测到的 x t x_t xt。 此外,任何 h t → h t + 1 h_t \to h_{t+1} ht→ht+1转移 都是由一些状态转移概率 P ( h t + 1 ∣ h t ) P(h_{t+1} \mid h_{t}) P(ht+1∣ht)给出。 这个概率图模型就是一个_隐马尔可夫模型_(hidden Markov model,HMM), 如下图所示。

因此,对于有 T T T个观测值的序列, 我们在观测状态和隐状态上具有以下联合概率分布:
P ( x 1 , … , x T , h 1 , … , h T ) = ∏ t = 1 T P ( h t ∣ h t − 1 ) P ( x t ∣ h t ) , where P ( h 1 ∣ h 0 ) = P ( h 1 ) . P(x_1, \ldots, x_T, h_1, \ldots, h_T) = \prod_{t=1}^T P(h_t \mid h_{t-1}) P(x_t \mid h_t), \text{ where } P(h_1 \mid h_0) = P(h_1). P(x1,…,xT,h1,…,hT)=t=1∏TP(ht∣ht−1)P(xt∣ht), where P(h1∣h0)=P(h1).
现在,假设我们观测到所有的 x i x_i xi,除了 x j x_j xj, 并且我们的目标是计算 P ( x j ∣ x − j ) P(x_j \mid x_{-j}) P(xj∣x−j), 其中 x − j = ( x 1 , … , x j − 1 , x j + 1 , … , x T ) x_{-j} = (x_1, \ldots, x_{j-1}, x_{j+1}, \ldots, x_{T}) x−j=(x1,…,xj−1,xj+1,…,xT)。 由于 P ( x j ∣ x − j ) P(x_j \mid x_{-j}) P(xj∣x−j)中没有隐变量, 因此我们考虑对 h 1 , … , h T h_1, \ldots, h_T h1,…,hT选择构成的 所有可能的组合进行求和。 如果任何 h i h_i hi可以接受 K K K个不同的值(有限的状态数), 这意味着我们需要对 k T k^T kT个项求和, 这个任务显然难于登天。 幸运的是,有个巧妙的解决方案:动态规划(dynamic programming)。
要了解动态规划的工作方式, 我们考虑对隐变量 h 1 , … , h T h_1, \ldots, h_T h1,…,hT的依次求和。
P ( x 1 , … , x T ) = ∑ h 1 , … , h T P ( x 1 , … , x T , h 1 , … , h T ) = ∑ h 1 , … , h T ∏ t = 1 T P ( h t ∣ h t − 1 ) P ( x t ∣ h t ) = ∑ h 2 , … , h T [ ∑ h 1 P ( h 1 ) P ( x 1 ∣ h 1 ) P ( h 2 ∣ h 1 ) ] ⏟ π 2 ( h 2 ) = d e f P ( x 2 ∣ h 2 ) ∏ t = 3 T P ( h t ∣ h t − 1 ) P ( x t ∣ h t ) = ∑ h 3 , … , h T [ ∑ h 2 π 2 ( h 2 ) P ( x 2 ∣ h 2 ) P ( h 3 ∣ h 2 ) ] ⏟ π 3 ( h 3 ) = d e f P ( x 3 ∣ h 3 ) ∏ t = 4 T P ( h t ∣ h t − 1 ) P ( x t ∣ h t ) = … = ∑ h T π T ( h T ) P ( x T ∣ h T ) . \begin{split}\begin{aligned} &P(x_1, \ldots, x_T) \\ =& \sum_{h_1, \ldots, h_T} P(x_1, \ldots, x_T, h_1, \ldots, h_T) \\ =& \sum_{h_1, \ldots, h_T} \prod_{t=1}^T P(h_t \mid h_{t-1}) P(x_t \mid h_t) \\ =& \sum_{h_2, \ldots, h_T} \underbrace{\left[\sum_{h_1} P(h_1) P(x_1 \mid h_1) P(h_2 \mid h_1)\right]}_{\pi_2(h_2) \stackrel{\mathrm{def}}{=}} P(x_2 \mid h_2) \prod_{t=3}^T P(h_t \mid h_{t-1}) P(x_t \mid h_t) \\ =& \sum_{h_3, \ldots, h_T} \underbrace{\left[\sum_{h_2} \pi_2(h_2) P(x_2 \mid h_2) P(h_3 \mid h_2)\right]}_{\pi_3(h_3)\stackrel{\mathrm{def}}{=}} P(x_3 \mid h_3) \prod_{t=4}^T P(h_t \mid h_{t-1}) P(x_t \mid h_t)\\ =& \dots \\ =& \sum_{h_T} \pi_T(h_T) P(x_T \mid h_T). \end{aligned}\end{split} ======P(x1,…,xT)h1,…,hT∑P(x1,…,xT,h1,…,hT)h1,…,hT∑t=1∏TP(ht∣ht−1)P(xt∣ht)h2,…,hT∑π2(h2)=def [h1∑P(h1)P(x1∣h1)P(h2∣h1)]P(x2∣h2)t=3∏TP(ht∣ht−1)P(xt∣ht)h3,…,hT∑π3(h3)=def [h2∑π2(h2)P(x2∣h2)P(h3∣h2)]P(x3∣h3)t=4∏TP(ht∣ht−1)P(xt∣ht)…hT∑πT(hT)P(xT∣hT).
通常,我们将前向递归(forward recursion)写为
π t + 1 ( h t + 1 ) = ∑ h t π t ( h t ) P ( x t ∣ h t ) P ( h t + 1 ∣ h t ) . \pi_{t+1}(h_{t+1}) = \sum_{h_t} \pi_t(h_t) P(x_t \mid h_t) P(h_{t+1} \mid h_t). πt+1(ht+1)=ht∑πt(ht)P(xt∣ht)P(ht+1∣ht).
递归被初始化为 π 1 ( h 1 ) = P ( h 1 ) \pi_1(h_1) = P(h_1) π1(h1)=P(h1)。 符号简化,也可以写成 π t + 1 = f ( π t , x t ) \pi_{t+1} = f(\pi_t, x_t) πt+1=f(πt,xt), 其中 f f f是一些可学习的函数。 这看起来就像我们在循环神经网络中讨论的隐变量模型中的更新方程。
与前向递归一样,我们也可以使用后向递归对同一组隐变量求和。这将得到:
P ( x 1 , … , x T ) = ∑ h 1 , … , h T P ( x 1 , … , x T , h 1 , … , h T ) = ∑ h 1 , … , h T ∏ t = 1 T − 1 P ( h t ∣ h t − 1 ) P ( x t ∣ h t ) ⋅ P ( h T ∣ h T − 1 ) P ( x T ∣ h T ) = ∑ h 1 , … , h T − 1 ∏ t = 1 T − 1 P ( h t ∣ h t − 1 ) P ( x t ∣ h t ) ⋅ [ ∑ h T P ( h T ∣ h T − 1 ) P ( x T ∣ h T ) ] ⏟ ρ T − 1 ( h T − 1 ) = d e f = ∑ h 1 , … , h T − 2 ∏ t = 1 T − 2 P ( h t ∣ h t − 1 ) P ( x t ∣ h t ) ⋅ [ ∑ h T − 1 P ( h T − 1 ∣ h T − 2 ) P ( x T − 1 ∣ h T − 1 ) ρ T − 1 ( h T − 1 ) ] ⏟ ρ T − 2 ( h T − 2 ) = d e f = … = ∑ h 1 P ( h 1 ) P ( x 1 ∣ h 1 ) ρ 1 ( h 1 ) . \begin{split}\begin{aligned} & P(x_1, \ldots, x_T) \\ =& \sum_{h_1, \ldots, h_T} P(x_1, \ldots, x_T, h_1, \ldots, h_T) \\ =& \sum_{h_1, \ldots, h_T} \prod_{t=1}^{T-1} P(h_t \mid h_{t-1}) P(x_t \mid h_t) \cdot P(h_T \mid h_{T-1}) P(x_T \mid h_T) \\ =& \sum_{h_1, \ldots, h_{T-1}} \prod_{t=1}^{T-1} P(h_t \mid h_{t-1}) P(x_t \mid h_t) \cdot \underbrace{\left[\sum_{h_T} P(h_T \mid h_{T-1}) P(x_T \mid h_T)\right]}_{\rho_{T-1}(h_{T-1})\stackrel{\mathrm{def}}{=}} \\ =& \sum_{h_1, \ldots, h_{T-2}} \prod_{t=1}^{T-2} P(h_t \mid h_{t-1}) P(x_t \mid h_t) \cdot \underbrace{\left[\sum_{h_{T-1}} P(h_{T-1} \mid h_{T-2}) P(x_{T-1} \mid h_{T-1}) \rho_{T-1}(h_{T-1}) \right]}_{\rho_{T-2}(h_{T-2})\stackrel{\mathrm{def}}{=}} \\ =& \ldots \\ =& \sum_{h_1} P(h_1) P(x_1 \mid h_1)\rho_{1}(h_{1}). \end{aligned}\end{split} ======P(x1,…,xT)h1,…,hT∑P(x1,…,xT,h1,…,hT)h1,…,hT∑t=1∏T−1P(ht∣ht−1)P(xt∣ht)⋅P(hT∣hT−1)P(xT∣hT)h1,…,hT−1∑t=1∏T−1P(ht∣ht−1)P(xt∣ht)⋅ρT−1(hT−1)=def [hT∑P(hT∣hT−1)P(xT∣hT)]h1,…,hT−2∑t=1∏T−2P(ht∣ht−1)P(xt∣ht)⋅ρT−2(hT−2)=def hT−1∑P(hT−1∣hT−2)P(xT−1∣hT−1)ρT−1(hT−1) …h1∑P(h1)P(x1∣h1)ρ1(h1).
因此,我们可以将后向递归(backward recursion)写为:
ρ t − 1 ( h t − 1 ) = ∑ h t P ( h t ∣ h t − 1 ) P ( x t ∣ h t ) ρ t ( h t ) , \rho_{t-1}(h_{t-1})= \sum_{h_{t}} P(h_{t} \mid h_{t-1}) P(x_{t} \mid h_{t}) \rho_{t}(h_{t}), ρt−1(ht−1)=ht∑P(ht∣ht−1)P(xt∣ht)ρt(ht),
初始化 ρ T ( h T ) = 1 \rho_T(h_T) = 1 ρT(hT)=1。 前向和后向递归都允许我们对 T T T个隐变量在 O ( k T ) \mathcal{O}(kT) O(kT)线性而不是指数)时间内对 ( h 1 , … , h T ) (h_1, \ldots, h_T) (h1,…,hT)的所有值求和。 这是使用图模型进行概率推理的巨大好处之一。 它也是通用消息传递算法 (Aji and McEliece, 2000)的一个非常特殊的例子。 结合前向和后向递归,我们能够计算
P ( x j ∣ x − j ) ∝ ∑ h j π j ( h j ) ρ j ( h j ) P ( x j ∣ h j ) . P(x_j \mid x_{-j}) \propto \sum_{h_j} \pi_j(h_j) \rho_j(h_j) P(x_j \mid h_j). P(xj∣x−j)∝hj∑πj(hj)ρj(hj)P(xj∣hj).
因为符号简化的需要,后向递归也可以写为 ρ t − 1 = g ( ρ t , x t ) \rho_{t-1} = g(\rho_t, x_t) ρt−1=g(ρt,xt), 其中 g g g是一个可以学习的函数。 同样,这看起来非常像一个更新方程, 只是不像我们在循环神经网络中看到的那样前向运算,而是后向计算。 事实上,知道未来数据何时可用对隐马尔可夫模型是有益的。 信号处理学家将是否知道未来观测这两种情况区分为内插和外推, 有关更多详细信息,请参阅 (Doucet et al., 2001)。
双向模型
如果我们希望在循环神经网络中拥有一种机制, 使之能够提供与隐马尔可夫模型类似的前瞻能力, 我们就需要修改循环神经网络的设计。 幸运的是,这在概念上很容易, 只需要增加一个“从最后一个词元开始从后向前运行”的循环神经网络, 而不是只有一个在前向模式下“从第一个词元开始运行”的循环神经网络。 双向循环神经网络**(bidirectional RNNs) 添加了反向传递信息的隐藏层**,以便更灵活地处理此类信息。 下图描述了具有单个隐藏层的双向循环神经网络的架构。

事实上,这与隐马尔可夫模型中的动态规划的前向和后向递归没有太大区别。 其主要区别是,在隐马尔可夫模型中的方程具有特定的统计意义。 双向循环神经网络没有这样容易理解的解释, 我们只能把它们当作通用的、可学习的函数。 这种转变集中体现了现代深度网络的设计原则: 首先使用经典统计模型的函数依赖类型,然后将其参数化为通用形式。
定义
双向循环神经网络是由 (Schuster and Paliwal, 1997)提出的, 关于各种架构的详细讨论请参阅 (Graves and Schmidhuber, 2005)。 让我们看看这样一个网络的细节。
对于任意时间步 t t t,给定一个小批量的输入数据 X t ∈ R n × d \mathbf{X}_t \in \mathbb{R}^{n \times d} Xt∈Rn×d(样本数 n n n,每个示例中的输入数 d d d), 并且令隐藏层激活函数为 ϕ \phi ϕ。 在双向架构中,我们设该时间步的前向和反向隐状态分别为$ \overrightarrow{\mathbf{H}}_t \in \mathbb{R}^{n \times h} ∗ ∗ 和 ∗ ∗ **和 ** ∗∗和∗∗\overleftarrow{\mathbf{H}}_t \in \mathbb{R}^{n \times h} ∗ ∗ ,其中 ∗ ∗ **, 其中** ∗∗,其中∗∗ℎ$是隐藏单元的数目。 前向和反向隐状态的更新如下:
H → t = ϕ ( X t W x h ( f ) + H → t − 1 W h h ( f ) + b h ( f ) ) , H ← t = ϕ ( X t W x h ( b ) + H ← t + 1 W h h ( b ) + b h ( b ) ) , \begin{split}\begin{aligned} \overrightarrow{\mathbf{H}}_t &= \phi(\mathbf{X}_t \mathbf{W}_{xh}^{(f)} + \overrightarrow{\mathbf{H}}_{t-1} \mathbf{W}_{hh}^{(f)} + \mathbf{b}_h^{(f)}),\\ \overleftarrow{\mathbf{H}}_t &= \phi(\mathbf{X}_t \mathbf{W}_{xh}^{(b)} + \overleftarrow{\mathbf{H}}_{t+1} \mathbf{W}_{hh}^{(b)} + \mathbf{b}_h^{(b)}), \end{aligned}\end{split} HtHt=ϕ(XtWxh(f)+Ht−1Whh(f)+bh(f)),=ϕ(XtWxh(b)+Ht+1Whh(b)+bh(b)),
其中,权重 W x h ( f ) ∈ R d × h , W h h ( f ) ∈ R h × h , W x h ( b ) ∈ R d × h , W h h ( b ) ∈ R h × h \mathbf{W}_{x h}^{(f)} \in \mathbb{R}^{d \times h}, \mathbf{W}_{h h}^{(f)} \in \mathbb{R}^{h \times h}, \mathbf{W}_{x h}^{(b)} \in \mathbb{R}^{d \times h}, \mathbf{W}_{h h}^{(b)} \in \mathbb{R}^{h \times h} Wxh(f)∈Rd×h,Whh(f)∈Rh×h,Wxh(b)∈Rd×h,Whh(b)∈Rh×h和偏置 b h ( f ) ∈ R 1 × h , b h ( b ) ∈ R 1 × h \mathbf{b}_{h}^{(f)} \in \mathbb{R}^{1 \times h}, \mathbf{b}_{h}^{(b)} \in \mathbb{R}^{1 \times h} bh(f)∈R1×h,bh(b)∈R1×h都是模型参数。
接下来,将前向隐状态 H → t \overrightarrow{\mathbf{H}}_t Ht和反向隐状态 H ← t \overleftarrow{\mathbf{H}}_t Ht连接起来, 获得需要送入输出层的隐状态 H t ∈ R n × 2 h \mathbf{H}_t \in \mathbb{R}^{n \times 2h} Ht∈Rn×2h。 在具有多个隐藏层的深度双向循环神经网络中, 该信息作为输入传递到下一个双向层。 最后,输出层计算得到的输出为 O t ∈ R n × q \mathbf{O}_t \in \mathbb{R}^{n \times q} Ot∈Rn×q( q q q是输出单元的数目):
O t = H t W h q + b q . \mathbf{O}_t = \mathbf{H}_t \mathbf{W}_{hq} + \mathbf{b}_q. Ot=HtWhq+bq.
这里,权重矩阵 W h q ∈ R 2 h × q \mathbf{W}_{hq} \in \mathbb{R}^{2h \times q} Whq∈R2h×q和偏置 b q ∈ R 1 × q \mathbf{b}_q \in \mathbb{R}^{1 \times q} bq∈R1×q是输出层的模型参数。 事实上,这两个方向可以拥有不同数量的隐藏单元。
模型的计算代价及其应用
双向循环神经网络的一个关键特性是:使用来自序列两端的信息来估计输出。 也就是说,我们使用来自过去和未来的观测信息来预测当前的观测。 但是在对下一个词元进行预测的情况中,这样的模型并不是我们所需的。 因为在预测下一个词元时,我们终究无法知道下一个词元的下文是什么, 所以将不会得到很好的精度。 具体地说,在训练期间,我们能够利用过去和未来的数据来估计现在空缺的词; 而在测试期间,我们只有过去的数据,因此精度将会很差。 下面的实验将说明这一点。
另一个严重问题是,双向循环神经网络的计算速度非常慢。 其主要原因是网络的前向传播需要在双向层中进行前向和后向递归, 并且网络的反向传播还依赖于前向传播的结果。 因此,梯度求解将有一个非常长的链。
双向层的使用在实践中非常少,并且仅仅应用于部分场合。 例如,填充缺失的单词、词元注释(例如,用于命名实体识别) 以及作为序列处理流水线中的一个步骤对序列进行编码(例如,用于机器翻译)。
双向循环神经网络的错误应用
由于双向循环神经网络使用了过去的和未来的数据, 所以我们不能盲目地将这一语言模型应用于任何预测任务。 尽管模型产出的困惑度是合理的, 该模型预测未来词元的能力却可能存在严重缺陷。 我们用下面的示例代码引以为戒,以防在错误的环境中使用它们。
import torch
from torch import nn
from d2l import torch as d2l
# 加载数据
batch_size, num_steps, device = 32, 35, d2l.try_gpu()
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
# 通过设置“bidirective=True”来定义双向LSTM模型,通过API直接调用
vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers, bidirectional=True)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)
# 训练模型
num_epochs, lr = 500, 1
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
perplexity 1.1, 109857.9 tokens/sec on cuda:0
time travellerererererererererererererererererererererererererer
travellerererererererererererererererererererererererererer

总结
- 在双向循环神经网络中,每个时间步的隐状态由当前时间步的前后数据同时决定。
- 双向循环神经网络与概率图模型中的“前向-后向”算法具有相似性。
- 双向循环神经网络主要用于序列编码和给定双向上下文的观测估计。
- 由于梯度链更长,因此双向循环神经网络的训练代价非常高。
机器翻译与数据集
语言模型是自然语言处理的关键, 而_机器翻译_是语言模型最成功的基准测试。 因为机器翻译正是将输入序列转换成输出序列的 序列转换模型(sequence transduction)的核心问题。 序列转换模型在各类现代人工智能应用中发挥着至关重要的作用。 为此,本节将介绍机器翻译问题及其后文需要使用的数据集。
机器翻译(machine translation)指的是 将序列从一种语言自动翻译成另一种语言。 事实上,这个研究领域可以追溯到数字计算机发明后不久的20世纪40年代, 特别是在第二次世界大战中使用计算机破解语言编码。 几十年来,在使用神经网络进行端到端学习的兴起之前, 统计学方法在这一领域一直占据主导地位 (Brown et al., 1990, Brown et al., 1988)。 因为_统计机器翻译_(statistical machine translation)涉及了 翻译模型和语言模型等组成部分的统计分析, 因此基于神经网络的方法通常被称为 神经机器翻译(neural machine translation), 用于将两种翻译模型区分开来。
机器翻译的数据集是由源语言和目标语言的文本序列对组成的。 因此,我们需要一种完全不同的方法来预处理机器翻译数据集, 而不是复用语言模型的预处理程序。 下面,我们看一下如何将预处理后的数据加载到小批量中用于训练。
import os
import torch
from d2l import torch as d2l
下载和预处理数据集
首先,下载一个由Tatoeba项目的双语句子对 组成的“英-法”数据集,数据集中的每一行都是制表符分隔的文本序列对, 序列对由英文文本序列和翻译后的法语文本序列组成。 请注意,每个文本序列可以是一个句子, 也可以是包含多个句子的一个段落。 在这个将英语翻译成法语的机器翻译问题中, 英语是_源语言_(source language), 法语是_目标语言_(target language)。
#@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), 使用小写字母替换大写字母,并在单词和标点符号之间插入空格。
#@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])
这段代码是对 NMT 数据进行预处理的代码。
首先,它定义了一个 no_space 函数,该函数用于判断当前字符是否是标点符号(“,”、“.”、“!”、“?”)且前一个字符是否是空格。
然后,它对原始文本进行了一些预处理:
- 使用空格替换不间断空格
- 使用小写字母替换大写字母
- 在单词和标点符号之间插入空格
最后,它返回预处理后的文本。
go . va !
hi . salut !
run ! cours !
run ! courez !
who ? qui ?
wow ! ça alors !
词元化
在机器翻译中,我们更喜欢**单词级词元化 **(最先进的模型可能使用更高级的词元化技术)。
下面的tokenize_nmt函数对前num_examples**个文本序列对进行词元, 其中每个词元要么是一个词,要么是一个标点符号**。 此函数返回两个词元列表:source和target: source[i]是源语言(这里是英语)第
i
i
i个文本序列的词元列表, target[i]是目标语言(这里是法语)第
i
i
i个文本序列的词元列表。
#@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]
这段代码的作用是将文本分割为句子,并对每个句子进行词元化,最后将源语言和目标语言分别存储在两个列表 source 和 target 中。如果给定了 num_examples 参数,则只处理 num_examples 个句子。
每个句子是用 \t 分隔的源语言和目标语言。使用 split 函数以 \n 分割每一行,使用 split 函数以 \t 分割每一行,将每个句子分成两个部分:源语言和目标语言。再使用 split 函数以 ' ' 分割每一个语言,得到词元化后的语言。最后,将源语言和目标语言分别存储在 source 和 target 两个列表中。
最终的结果是源语言和目标语言分别存储在两个列表中,每个列表中的每一个元素都是一个列表,存储了一个句子中的词元。
([['go', '.'],
['hi', '.'],
['run', '!'],
['run', '!'],
['who', '?'],
['wow', '!']],
[['va', '!'],
['salut', '!'],
['cours', '!'],
['courez', '!'],
['qui', '?'],
['ça', 'alors', '!']])
让我们绘制每个文本序列所包含的词元数量的直方图。 在这个简单的“英-法”数据集中,大多数文本序列的词元数量少于20个。
#@save
def show_list_len_pair_hist(legend, xlabel, ylabel, xlist, ylist):
"""绘制列表长度对的直方图"""
d2l.set_figsize()
_, _, patches = d2l.plt.hist(
[[len(l) for l in xlist], [len(l) for l in ylist]])
d2l.plt.xlabel(xlabel)
d2l.plt.ylabel(ylabel)
for patch in patches[1].patches:
patch.set_hatch('/')
d2l.plt.legend(legend)
show_list_len_pair_hist(['source', 'target'], '# tokens per sequence',
'count', source, target);
此代码使用 matplotlib 库绘制了一个列表长度对的直方图。输入的参数为:
- legend:图例
- xlabel:x 轴标签
- ylabel:y 轴标签
- xlist:x 轴数据
- ylist:y 轴数据
它首先使用 hist 函数绘制了两个数据列表的长度直方图,并设置了 x 轴标签和 y 轴标签。然后,它使用 for 循环遍历直方图的图形(即矩形),并为第二个直方图的图形设置了“/”的效果。最后,它使用 legend 函数绘制图例。

词表
由于机器翻译数据集由语言对组成, 因此我们可以分别为源语言和目标语言构建两个词表。 使用单词级词元化时,词表大小将明显大于使用字符级词元化时的词表大小。 为了缓解这一问题,这里我们将出现次数少于2次的低频率词元 视为相同的未知(“<unk>”)词元。 除此之外,我们还指定了额外的特定词元, 例如在小批量时用于将序列填充到相同长度的填充词元(“<pad>”), 以及序列的开始词元(“<bos>”)和结束词元(“<eos>”)。 这些特殊词元在自然语言处理任务中比较常用。
src_vocab = d2l.Vocab(source, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
len(src_vocab)
10012
加载数据集
在机器翻译中,每个样本都是由源和目标组成的文本序列对, 其中的每个文本序列可能具有不同的长度。
为了提高计算效率,我们仍然可以通过_截断_(truncation)和 填充(padding)方式实现一次只处理一个小批量的文本序列。 假设**同一个小批量中的每个序列都应该具有相同的长度num_steps, 那么如果文本序列的词元数目少于num_steps时, 我们将继续在其末尾添加特定的“<pad>”词元, 直到其长度达到num_steps; 反之,我们将截断文本序列时,只取其前num_steps 个词元, 并且丢弃剩余的词元。**这样,每个文本序列将具有相同的长度, 以便以相同形状的小批量进行加载。
如前所述,下面的truncate_pad函数将截断或填充文本序列。
#@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>'])
[47, 4, 1, 1, 1, 1, 1, 1, 1, 1] # 这个句子中只有两个词,其余都是填充
现在我们定义一个函数,可以将文本序列 转换成小批量数据集用于训练。 我们将特定的“<eos>”词元添加到所有序列的末尾, 用于表示序列的结束。 当模型通过一个词元接一个词元地生成序列进行预测时, 生成的“<eos>”词元说明完成了序列输出工作。 此外,我们还记录了每个文本序列的长度, 统计长度时排除了填充词元, 在稍后将要介绍的一些模型会需要这个长度信息。
#@save
def build_array_nmt(lines, vocab, num_steps):
"""将机器翻译的文本序列转换成小批量"""
lines = [vocab[l] for l in lines]
lines = [l + [vocab['<eos>']] for l in lines]
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
build_array_nmt 函数用于将机器翻译的文本序列转换为小批量。它先将文本序列中的每一行通过字典 vocab 转换成数字序列,然后为每一行添加 EOS 标识。接下来,通过函数 truncate_pad 截断或填充数字序列,以便数字序列的长度为 num_steps。最后通过 PyTorch 的 tensor 函数将数字序列转换为 PyTorch 张量,并通过统计每一行中不等于 PAD 标识的数字个数,得到一个有效长度的张量 valid_len。
训练模型
最后,我们定义load_data_nmt函数来返回数据迭代器, 以及源语言和目标语言的两种词表。
#@save
def load_data_nmt(batch_size, num_steps, num_examples=600):
"""返回翻译数据集的迭代器和词表"""
text = preprocess_nmt(read_data_nmt())
source, target = tokenize_nmt(text, num_examples)
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)
data_iter = d2l.load_array(data_arrays, batch_size)
return data_iter, src_vocab, tgt_vocab
下面我们读出“英语-法语”数据集中的第一个小批量数据。
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([[ 6, 143, 4, 3, 1, 1, 1, 1],
[ 54, 5, 3, 1, 1, 1, 1, 1]], dtype=torch.int32)
X的有效长度: tensor([4, 3])
Y: tensor([[ 6, 0, 4, 3, 1, 1, 1, 1],
[93, 5, 3, 1, 1, 1, 1, 1]], dtype=torch.int32)
Y的有效长度: tensor([4, 3])
总结
- 机器翻译指的是将文本序列从一种语言自动翻译成另一种语言。
- 使用单词级词元化时的词表大小,将明显大于使用字符级词元化时的词表大小。为了缓解这一问题,我们可以将低频词元视为相同的未知词元。
- 通过截断和填充文本序列,可以保证所有的文本序列都具有相同的长度,以便以小批量的方式加载。
编码器-解码器架构
机器翻译是序列转换模型的一个核心问题, 其输入和输出都是长度可变的序列。 为了处理这种类型的输入和输出, 我们可以设计一个包含两个主要组件的架构: 第一个组件是一个_编码器_(encoder): 它接受一个长度可变的序列作为输入, 并将其转换为具有固定形状的编码状态。 第二个组件是_解码器_(decoder): 它将固定形状的编码状态映射到长度可变的序列。 这被称为_编码器-解码器_(encoder-decoder)架构, 如 下图所示

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

- 编码器:将输入编程为中间表达形式(特征)
- 解码器:将中间表示解码成输出
重新看RNN

- 编码器:将文本表示成向量
- 解码器:向量表示成输出
编码器
在编码器接口中,我们只指定长度可变的序列作为编码器的输入X。 任何继承这个Encoder基类的模型将完成代码实现。
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)转换为编码后的状态。 注意,此步骤可能需要额外的输入,例如:输入序列的有效长度。 为了逐个地生成长度可变的词元序列, 解码器在每个时间步都会将输入 (例如:在前一时间步生成的词元)和编码后的状态 映射成当前时间步的输出词元。
#@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
合并编码器和解码器
总而言之,“编码器-解码器”架构包含了一个编码器和一个解码器, 并且还拥有可选的额外的参数。 在前向传播中,编码器的输出用于生成编码状态, 这个状态又被解码器作为其输入的一部分。
#@save
class EncoderDecoder(nn.Module):
"""编码器-解码器架构的基类"""
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_outputs = self.encoder(enc_X, *args) # 编码器输入x的得到编码器的输出
dec_state = self.decoder.init_state(enc_outputs, *args) # 用编码器的输出生成解码器的状态
return self.decoder(dec_X, dec_state) # 用解码器的状态和解码器的输入x得到最后的输出
“编码器-解码器”体系架构中的术语状态 会启发人们使用具有状态的神经网络来实现该架构。 在下一节中,我们将学习如何应用循环神经网络, 来设计基于“编码器-解码器”架构的序列转换模型。
总结
- “编码器-解码器”架构可以将长度可变的序列作为输入和输出,因此适用于机器翻译等序列转换问题。
- 编码器将长度可变的序列作为输入,并将其转换为具有固定形状的编码状态。
- 解码器将具有固定形状的编码状态映射为长度可变的序列。
序列到序列学习(seq2seq)
机器翻译中的输入序列和输出序列都是长度可变的。 为了解决这类问题,我们在上节中 设计了一个通用的”编码器-解码器“架构。 本节,我们将使用两个循环神经网络的编码器和解码器, 并将其应用于_序列到序列_(sequence to sequence,seq2seq)类的学习任务 (Cho et al., 2014, Sutskever et al., 2014)。
遵循编码器-解码器架构的设计原则, 循环神经网络编码器使用长度可变的序列作为输入, 将其转换为固定形状的隐状态。 换言之,输入序列的信息被_编码_到循环神经网络编码器的隐状态中。 为了连续生成输出序列的词元, 独立的循环神经网络解码器是基于输入序列的编码信息 和输出序列已经看见的或者生成的词元来预测下一个词元。下图演示了 如何在机器翻译中使用两个循环神经网络进行序列到序列学习。

特定的“<eos>”表示序列结束词元。 一旦输出序列生成此词元,模型就会停止预测。 在循环神经网络解码器的初始化时间步,有两个特定的设计决定: 首先,特定的“<bos>”表示序列开始词元,它是解码器的输入序列的第一个词元。 其次,使用循环神经网络编码器最终的隐状态来初始化解码器的隐状态。 例如,在 (Sutskever et al., 2014)的设计中, 正是基于这种设计将输入序列的编码信息送入到解码器中来生成输出序列的。 在其他一些设计中 (Cho et al., 2014), 如上图所示, 编码器最终的隐状态在每一个时间步都作为解码器的输入序列的一部分。 可以允许标签成为原始的输出序列, 从源序列词元“<bos>”“Ils”“regardent”“.” 到新序列词元 “Ils”“regardent”“.”“<eos>”来移动预测的位置。
- 编码器是一个RNN,读取输入句子,可以是双向
- 解码器使用另外一个RNN来输出
下面,我们动手构建上图的设计, 并将基于“英-法”数据集来训练这个机器翻译模型。
import collections
import math
import torch
from torch import nn
from d2l import torch as d2l
编码器

- 编码器是没有输出的RNN
- 编码器最后时间步的隐状态用作解码器的初始隐状态
从技术上讲,编码器将长度可变的输入序列转换成 形状固定的上下文变量 c c c, 并且将输入序列的信息在该上下文变量中进行编码。 如 上图所示,可以使用循环神经网络来设计编码器。
考虑由一个序列组成的样本(批量大小是1)。 假设输入序列是 x 1 , … , x T x_1, \ldots, x_T x1,…,xT, 其中 x t x_t xt是输入文本序列中的第 t t t个词元。 在时间步 t t t,循环神经网络将词元 x t x_t xt的输入特征向量 x t \mathbf{x}_t xt和 h t − 1 \mathbf{h} _{t-1} ht−1(即上一时间步的隐状态) 转换为 h t \mathbf{h}_t ht(即当前步的隐状态)。 使用一个函数 f f f来描述循环神经网络的循环层所做的变换:
h t = f ( x t , h t − 1 ) . \mathbf{h}_t = f(\mathbf{x}_t, \mathbf{h}_{t-1}). ht=f(xt,ht−1).
总之,编码器通过选定的函数 q q q, 将所有时间步的隐状态转换为上下文变量:
c = q ( h 1 , … , h T ) . \mathbf{c} = q(\mathbf{h}_1, \ldots, \mathbf{h}_T). c=q(h1,…,hT).
比如,当选择 q ( h 1 , … , h T ) = h T q(\mathbf{h}_1, \ldots, \mathbf{h}_T) = \mathbf{h}_T q(h1,…,hT)=hT时 , 上下文变量仅仅是输入序列在最后时间步的隐状态 h T \mathbf{h}_T hT。
到目前为止,我们使用的是一个单向循环神经网络来设计编码器, 其中隐状态只依赖于输入子序列, 这个子序列是由输入序列的开始位置到隐状态所在的时间步的位置 (包括隐状态所在的时间步)组成。 我们也可以使用双向循环神经网络构造编码器, 其中隐状态依赖于两个输入子序列, 两个子序列是由隐状态所在的时间步的位置之前的序列和之后的序列 (包括隐状态所在的时间步), 因此隐状态对整个序列的信息都进行了编码。
现在,让我们实现循环神经网络编码器。 注意,我们使用了**_嵌入层_(embedding layer) 来获得输入序列中每个词元的特征向量**。 嵌入层的权重是一个矩阵, 其行数等于输入词表的大小(vocab_size), 其列数等于特征向量的维度(embed_size)。 对于任意输入词元的索引
i
i
i, 嵌入层获取权重矩阵的第
i
i
i行(从0开始)以返回其特征向量。 另外,本文选择了一个多层门控循环单元来实现编码器。
#@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
这是一个 Seq2SeqEncoder 类的代码。这是一个继承自 d2l.Encoder 的类,是一个用于序列到序列学习的循环神经网络编码器。
- 参数说明:
- vocab_size:词汇表大小,表示词汇量的数量。
- embed_size:嵌入大小,即词向量的维数。
- num_hiddens:隐藏单元的数量,用于存储网络的状态。
- num_layers:隐藏层的数量,用于控制网络的深度。
- dropout:Dropout 的比例,用于防止过拟合。
- 类中的模块说明:
- self.embedding:嵌入层,用于将词语编码成向量。
- self.rnn:GRU 循环神经网络,用于对输入数据进行编码。
- forward 函数:
- 首先,对输入数据进行嵌入,并对数据进行维度变换。
- 然后,调用 self.rnn 对输入数据进行编码,得到输出和状态。
- 最后,将输出和状态作为函数的返回值,返回给调用者。
总的来说,Seq2SeqEncoder 类是一个用于序列到序列学习的循环神经网络编码器,用于将词语编码成向量,并对输入数据进行编码。
下面,我们实例化上述编码器的实现: 我们使用一个两层门控循环单元编码器,其隐藏单元数为16。 给定一小批量的输入序列X(批量大小为4,时间步为7)。 在完成所有时间步后, 最后一层的隐状态的输出是一个张量(output由编码器的循环层返回), 其形状为(时间步数,批量大小,隐藏单元数)。
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
torch.Size([7, 4, 16]) # num_steps,batch_size,num_hiddens
由于这里使用的是门控循环单元, 所以在最后一个时间步的多层隐状态的形状是 (隐藏层的数量,批量大小,隐藏单元的数量)。 如果使用长短期记忆网络,state中还将包含记忆单元信息。
state.shape
torch.Size([2, 4, 16]) # num_layers,batch_size,num_hiddens
解码器
正如上文提到的,编码器输出的上下文变量 c 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, \ldots, y_{t’-1} ∗ ∗ 和上下文变量 ∗ ∗ **和上下文变量** ∗∗和上下文变量∗∗c ∗ ∗ ,即 ∗ ∗ **, 即** ∗∗,即∗∗P(y_{t’} \mid y_1, \ldots, y_{t’-1}, \mathbf{c})$。
为了在序列上模型化这种条件概率, 我们可以使用另一个循环神经网络作为解码器。 在输出序列上的任意时间步 t ′ t^\prime t′, 循环神经网络将来自上一时间步的输出 y t ′ − 1 y_{t^\prime-1} yt′−1 和上下文变量 c c c作为其输入, 然后在当前时间步将它们和上一隐状态 s t ′ − 1 \mathbf{s}_{t^\prime-1} st′−1转换为 隐状态 s t ′ \mathbf{s}_{t^\prime} st′。 因此,可以使用函数 g g g来表示解码器的隐藏层的变换:
s t ′ = g ( y t ′ − 1 , c , s t ′ − 1 ) . \mathbf{s}_{t^\prime} = g(y_{t^\prime-1}, \mathbf{c}, \mathbf{s}_{t^\prime-1}). st′=g(yt′−1,c,st′−1).
在获得解码器的隐状态之后, 我们可以使用输出层和softmax操作 来计算在时间步 t ′ t^\prime t′时输出 y t ′ y_{t^\prime} yt′的条件概率分布$ P(y_{t^\prime} \mid y_1, \ldots, y_{t^\prime-1}, \mathbf{c}) $。
当实现解码器时, 我们直接使用编码器最后一个时间步的隐状态来初始化解码器的隐状态。 这就要求使用循环神经网络实现的编码器和解码器具有相同数量的层和隐藏单元。 为了进一步包含经过编码的输入序列的信息, 上下文变量在所有的时间步与解码器的输入进行拼接(concatenate)。 为了预测输出词元的概率分布, 在循环神经网络解码器的最后一层使用全连接层来变换隐状态。
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)
self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
dropout=dropout)
self.dense = nn.Linear(num_hiddens, vocab_size) #全连接层
def init_state(self, enc_outputs, *args):
return enc_outputs[1] #只把编码器的状态state拿出
def forward(self, X, state):
# 输出'X'的形状:(batch_size,num_steps,embed_size)
X = self.embedding(X).permute(1, 0, 2)
# 广播context,使其具有与X相同的num_steps,state[-1]是最后一个时刻最后一层的输出
context = state[-1].repeat(X.shape[0], 1, 1)
X_and_context = torch.cat((X, context), 2) # 沿着第2维度进行拼接
output, state = self.rnn(X_and_context, state)
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
上面代码是定义一个序列到序列学习模型中的解码器类Seq2SeqDecoder。
首先它继承自一个父类d2l.Decoder。
然后它定义了一些成员变量:
self.embedding:词嵌入层,将单词编号映射到向量。self.rnn:循环神经网络层,该层的输入是单词向量和上下文向量,输出是隐藏状态。self.dense:全连接层,将隐藏状态转换为词的预测分数。
然后它定义了两个函数:
init_state:初始化解码器的状态,其中enc_outputs是编码器的输出。该函数将状态初始化为编码器的最终隐藏状态。forward:前向传播函数,它计算解码器的输出和隐藏状态。首先,它对输入的单词编号进行词嵌入;接着,它将上下文向量复制成与输入相同的形状;然后,它将词嵌入和上下文向量连接在一起并通过循环神经网络计算隐藏状态;最后,它使用全连接层将隐藏状态转换为词的预测分数,并输出解码器的输出。
下面,我们用与前面提到的编码器中相同的超参数来实例化解码器。 如我们所见,解码器的输出形状变为(批量大小,时间步数,词表大小), 其中张量的最后一个维度存储预测的词元分布。
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
(torch.Size([4, 7, 10]), torch.Size([2, 4, 16]))
损失函数
在每个时间步,解码器预测了输出词元的概率分布。 类似于语言模型,可以使用softmax来获得分布, 并通过计算交叉熵损失函数来进行优化。 特定的填充词元被添加到序列的末尾, 因此不同长度的序列可以以相同形状的小批量加载。 但是,我们应该将填充词元的预测排除在损失函数的计算之外。
为此,我们可以使用下面的sequence_mask函数 通过零值化屏蔽不相关的项, 以便后面任何不相关预测的计算都是与零的乘积,结果都等于零。 例如,如果两个序列的有效长度(不包括填充词元)分别为1和2, 则第一个序列的第一项和第二个序列的前两项之后的剩余项将被清除为零。
#@save
def sequence_mask(X, valid_len, value=0):
"""在序列中屏蔽不相关的项"""
maxlen = X.size(1)
mask = torch.arange((maxlen), dtype=torch.float32,
device=X.device)[None, :] < valid_len[:, None]
X[~mask] = value
return X
X = torch.tensor([[1, 2, 3], [4, 5, 6]])
sequence_mask(X, torch.tensor([1, 2]))
这段代码实现了一个名为 “sequence_mask” 的函数,该函数用于在序列中屏蔽不相关的项。
该函数接受三个参数:
- X:形状为 (batch_size, num_timesteps) 的张量,表示批量中每个样本的序列。
- valid_len:形状为 (batch_size,) 的张量,表示批量中每个样本的序列的有效长度。
- value:一个标量,用于填充序列中不相关的项(默认值为 0)。
该函数实现了以下步骤:
- 通过在给定设备上创建一个形状为 (1, num_timesteps) 的从 0 到 num_timesteps-1 的张量,创建了一个掩码。
- 通过将掩码与 valid_len[:, None] 的比较结果进行比较,创建了一个布尔型掩码,该掩码指示了每个时间步是否相关。
- 将 X 中不相关的项替换为 value。
- 返回屏蔽了不相关项的 X。
tensor([[1, 0, 0],
[4, 5, 0]])
我们还可以使用此函数屏蔽最后几个轴上的所有项。如果愿意,也可以使用指定的非零值来替换这些项。
X = torch.ones(2, 3, 4)
sequence_mask(X, torch.tensor([1, 2]), value=-1)
tensor([[[ 1., 1., 1., 1.],
[-1., -1., -1., -1.],
[-1., -1., -1., -1.]],
[[ 1., 1., 1., 1.],
[ 1., 1., 1., 1.],
[-1., -1., -1., -1.]]])
现在,我们可以通过扩展softmax交叉熵损失函数来遮蔽不相关的预测。 最初,所有预测词元的掩码都设置为1。 一旦给定了有效长度,与填充词元对应的掩码将被设置为0。 最后,将所有词元的损失乘以掩码,以过滤掉损失中填充词元产生的不相关预测。
#@save
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
"""带遮蔽的softmax交叉熵损失函数"""
# pred的形状:(batch_size,num_steps,vocab_size)
# label的形状:(batch_size,num_steps)
# valid_len的形状:(batch_size,)
def forward(self, pred, label, valid_len):
weights = torch.ones_like(label)
weights = sequence_mask(weights, valid_len)
self.reduction='none'
unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
pred.permute(0, 2, 1), label)
weighted_loss = (unweighted_loss * weights).mean(dim=1)
return weighted_loss
这段代码定义了一个名为 MaskedSoftmaxCELoss 的类,该类继承自 PyTorch 中的 nn.CrossEntropyLoss,并实现了带遮蔽的 Softmax 交叉熵损失函数。
该类的 forward 方法接受三个参数:
- pred:形状为 (batch_size, num_steps, vocab_size) 的张量,代表了模型的预测结果。
- label:形状为 (batch_size, num_steps) 的张量,代表了标签。
- valid_len:形状为 (batch_size,) 的张量,代表了批量中每个样本的序列的有效长度。
该类的 forward 方法实现了以下步骤:
- 使用 torch.ones_like 创建了一个形状与标签 label 相同的全 1 张量。
- 调用前面定义的 sequence_mask 函数,通过该函数创建了一个掩码张量,该张量指示了序列中的有效项。
- 设置 nn.CrossEntropyLoss 中的 reduction 参数为 ‘none’,以便在计算未加权损失时保留结果的形状。
- 调用 nn.CrossEntropyLoss 的 forward 方法,计算未加权的交叉熵损失。请注意,在调用时,预测结果的形状需要通过 permute 函数转置为 (batch_size, vocab_size, num_steps)。
- 将未加权的损失与掩码张量相乘,然后计算该张量的平均值,从而得到加权的损失
我们可以创建三个相同的序列来进行代码健全性检查, 然后分别指定这些序列的有效长度为4、2和0。 结果就是,第一个序列的损失应为第二个序列的两倍,而第三个序列的损失应为零。
loss = MaskedSoftmaxCELoss()
loss(torch.ones(3, 4, 10), torch.ones((3, 4), dtype=torch.long),
torch.tensor([4, 2, 0]))
tensor([2.3026, 1.1513, 0.0000])
训练
在下面的循环训练过程中 特定的序列开始词元(“<bos>”)和 原始的输出序列(不包括序列结束词元“<eos>”) 拼接在一起作为解码器的输入。 这被称为_强制教学_(teacher forcing), 因为原始的输出序列(词元的标签)被送入解码器。 或者,将来自上一个时间步的_预测_得到的词元作为解码器的当前输入。
#@save
def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
"""训练序列到序列模型"""
def xavier_init_weights(m):
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)
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
loss = MaskedSoftmaxCELoss()
net.train()
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)
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)}')
现在,在机器翻译数据集上,我们可以 创建和训练一个循环神经网络“编码器-解码器”模型用于序列到序列的学习。
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)
loss 0.019, 11451.2 tokens/sec on cuda:0
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GpADbFTl-1676882702013)(image/image_7_qGcJ8lx9.png)]
预测
为了采用一个接着一个词元的方式预测输出序列, 每个解码器当前时间步的输入都将来自于前一时间步的预测词元。 与训练类似,序列开始词元(“<bos>”) 在初始时间步被输入到解码器中。 该预测过程如下图所示, 当输出序列的预测遇到序列结束词元(“<eos>”)时,预测就结束了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iETpFMTn-1676882702013)(image/image_9GKMLK6PSV.png)]
#@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
这是一个序列到序列(seq2seq)模型的预测函数。它接受以下参数:
net:seq2seq模型的实例src_sentence:输入句子的字符串src_vocab:输入句子的词典tgt_vocab:目标句子的词典num_steps:最多生成的词元数device:使用的设备(例如GPU)save_attention_weights:是否保存注意力权重(布尔值)
首先,在预测时,函数将模型设置为评估模式。然后将输入句子的字符串转换为词元的整数索引,并增加<eos>词元以表示句子的结束。
接下来,函数计算源句子的编码器输出,并初始化解码器状态。之后,函数使用解码器多次运行,每次生成一个词元。解码器输出预测概率最高的词元,并使用它作为下一次运行的输入。如果生成的词元是<eos>,则代表目标序列生成完毕。如果选择保存注意力权重,则保存每次解码器运行的注意力权重。最后,函数将生成的词元转换回字符串并返回。
预测序列的评估
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JbX2HiQT-1676882702014)(image/image_xIijyi2Jtu.png)]
BLEU的代码实现如下。
def bleu(pred_seq, label_seq, k): #@save
"""计算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))
for n in range(1, k + 1):
num_matches, label_subs = 0, collections.defaultdict(int)
for i in range(len_label - n + 1):
label_subs[' '.join(label_tokens[i: i + n])] += 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
score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
return score
最后,利用训练好的循环神经网络“编码器-解码器”模型, 将几个英语句子翻译成法语,并计算BLEU的最终结果。
engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
translation, attention_weight_seq = predict_seq2seq(
net, eng, src_vocab, tgt_vocab, num_steps, device)
print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')
go . => va !, bleu 1.000
i lost . => j'ai perdu ., bleu 1.000
he's calm . => il est bon ?, bleu 0.537
i'm home . => je suis chez moi debout ., bleu 0.803
束搜索
本节将首先介绍_贪心搜索 _(greedy search)策略, 并探讨其存在的问题,然后对比其他替代策略: 穷举搜索(exhaustive search)和_束搜索_(beam search)。
在正式介绍贪心搜索之前,我们使用与 上节中 相同的数学符号定义搜索问题。 在任意时间步 t ′ t' t′,解码器输出 y t ′ y_{t'} yt′的概率取决于 时间步 t ′ t' t′之前的输出子序列 y 1 , … , y t ′ − 1 y_1, \ldots, y_{t'-1} y1,…,yt′−1 和对输入序列的信息进行编码得到的上下文变量 c c c。 为了量化计算代价,用 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>”之后的部分(非本句) 将在实际输出中丢弃。
贪心搜索
对于输出序列的每一时间步 t ′ t' t′, 我们都将基于贪心搜索从 Y \mathcal{Y} Y中找到具有最高条件概率的词元,即:
y t ′ = argmax y ∈ Y P ( y ∣ y 1 , … , y t ′ − 1 , c ) y_{t'} = \operatorname*{argmax}_{y \in \mathcal{Y}} P(y \mid y_1, \ldots, y_{t'-1}, \mathbf{c}) yt′=y∈YargmaxP(y∣y1,…,yt′−1,c)
一旦输出序列包含了“<eos>”或者达到其最大长度 T ′ T' T′,则输出完成。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IAymgkf6-1676882702014)(image/image_C6_fYBxgrX.png)]
在每个时间步,贪心搜索选择具有最高条件概率的词元
然而,贪心搜索无法保证得到最优序列。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6X2Hlihr-1676882702014)(image/image_Vbx9VmCQeD.png)]
在时间步2,选择具有第二高条件概率的词元“C”(而非最高条件概率的词元)
束搜索
束搜索(beam search)是贪心搜索的一个改进版本。 它有一个超参数,名为_束宽 _(beam size) k k k。 在时间步1,我们选择具有最高条件概率的 k k k个词元。 这 k k k个词元将分别是 k k k个候选输出序列的第一个词元。 在随后的每个时间步,基于上一时间步的 k k k个候选输出序列, 我们将继续从 k ∣ Y ∣ k\left|\mathcal{Y}\right| k∣Y∣个可能的选择中 挑出具有最高条件概率的 k k k个候选输出序列。



总结
- 序列搜索策略包括贪心搜索、穷举搜索和束搜索。
- 贪心搜索所选取序列的计算量最小,但精度相对较低。
- 穷举搜索所选取序列的精度最高,但计算量最大。
- 束搜索通过灵活选择束宽,在正确率和计算代价之间进行权衡。



















