10.4. 双向循环神经网络¶ 在 SageMaker Studio Lab 中打开 Notebook
到目前为止,我们的序列学习任务的工作示例是语言建模,我们的目标是根据序列中的所有先前词元来预测下一个词元。在这种情况下,我们只希望以后向上下文为条件,因此标准RNN的单向链式结构似乎是合适的。然而,在许多其他序列学习任务中,在每个时间步都根据左向和右向的上下文来预测是完全可以的。例如,考虑词性标注。在评估一个给定单词的词性时,我们为什么不考虑双向的上下文呢?
另一个常见的任务——在对实际感兴趣的任务进行模型微调之前,通常作为预训练练习很有用——是在文本文档中屏蔽掉随机的词元,然后训练一个序列模型来预测缺失词元的值。注意,根据空格后面出现的内容,缺失词元的可能值会发生很大变化。
I am
___
.I am
___
hungry.I am
___
hungry, and I can eat half a pig.
在第一个句子中,“happy”似乎是一个可能的候选项。在第二个句子中,“not”和“very”似乎是合理的,但“not”似乎与第三个句子不符。
幸运的是,一种简单的技术可以将任何单向RNN转换为双向RNN (Schuster and Paliwal, 1997)。我们只需实现两个单向RNN层,以相反的方向连接在一起,并作用于相同的输入 (图 10.4.1)。对于第一个RNN层,第一个输入是 \(\mathbf{x}_1\),最后一个输入是 \(\mathbf{x}_T\);但对于第二个RNN层,第一个输入是 \(\mathbf{x}_T\),最后一个输入是 \(\mathbf{x}_1\)。为了产生这个双向RNN层的输出,我们只需将两个底层单向RNN层的相应输出连接起来即可。
图 10.4.1 双向RNN的架构。¶
形式上,对于任何时间步 \(t\),我们考虑一个小批量输入 \(\mathbf{X}_t \in \mathbb{R}^{n \times d}\) (样本数 \(=n\);每个样本中的输入数 \(=d\)),并让隐藏层激活函数为 \(\phi\)。在双向架构中,该时间步的前向和后向隐藏状态分别为 \(\overrightarrow{\mathbf{H}}_t \in \mathbb{R}^{n \times h}\) 和 \(\overleftarrow{\mathbf{H}}_t \in \mathbb{R}^{n \times h}\),其中 \(h\) 是隐藏单元的数量。前向和后向隐藏状态的更新如下:
其中权重 \(\mathbf{W}_{\textrm{xh}}^{(f)} \in \mathbb{R}^{d \times h}, \mathbf{W}_{\textrm{hh}}^{(f)} \in \mathbb{R}^{h \times h}, \mathbf{W}_{\textrm{xh}}^{(b)} \in \mathbb{R}^{d \times h}, \textrm{ 和 } \mathbf{W}_{\textrm{hh}}^{(b)} \in \mathbb{R}^{h \times h}\),以及偏置 \(\mathbf{b}_\textrm{h}^{(f)} \in \mathbb{R}^{1 \times h}\) 和 \(\mathbf{b}_\textrm{h}^{(b)} \in \mathbb{R}^{1 \times h}\) 都是模型参数。
接下来,我们将前向和后向隐藏状态 \(\overrightarrow{\mathbf{H}}_t\) 和 \(\overleftarrow{\mathbf{H}}_t\) 连接起来,得到用于输入到输出层的隐藏状态 \(\mathbf{H}_t \in \mathbb{R}^{n \times 2h}\)。在具有多个隐藏层的深度双向RNN中,这些信息作为*输入*传递给下一个双向层。最后,输出层计算输出 \(\mathbf{O}_t \in \mathbb{R}^{n \times q}\) (输出数 \(=q\)):
在这里,权重矩阵 \(\mathbf{W}_{\textrm{hq}} \in \mathbb{R}^{2h \times q}\) 和偏置 \(\mathbf{b}_\textrm{q} \in \mathbb{R}^{1 \times q}\) 是输出层的模型参数。尽管技术上两个方向可以有不同数量的隐藏单元,但在实践中很少做出这种设计选择。我们现在演示一个双向RNN的简单实现。
import torch
from torch import nn
from d2l import torch as d2l
from mxnet import np, npx
from mxnet.gluon import rnn
from d2l import mxnet as d2l
npx.set_np()
from jax import numpy as jnp
from d2l import jax as d2l
No GPU/TPU found, falling back to CPU. (Set TF_CPP_MIN_LOG_LEVEL=0 and rerun for more info.)
import tensorflow as tf
from d2l import tensorflow as d2l
10.4.1. 从零开始实现¶
要从零开始实现一个双向RNN,我们可以包含两个具有独立可学习参数的单向 RNNScratch
实例。
class BiRNNScratch(d2l.Module):
def __init__(self, num_inputs, num_hiddens, sigma=0.01):
super().__init__()
self.save_hyperparameters()
self.f_rnn = d2l.RNNScratch(num_inputs, num_hiddens, sigma)
self.b_rnn = d2l.RNNScratch(num_inputs, num_hiddens, sigma)
self.num_hiddens *= 2 # The output dimension will be doubled
class BiRNNScratch(d2l.Module):
def __init__(self, num_inputs, num_hiddens, sigma=0.01):
super().__init__()
self.save_hyperparameters()
self.f_rnn = d2l.RNNScratch(num_inputs, num_hiddens, sigma)
self.b_rnn = d2l.RNNScratch(num_inputs, num_hiddens, sigma)
self.num_hiddens *= 2 # The output dimension will be doubled
class BiRNNScratch(d2l.Module):
num_inputs: int
num_hiddens: int
sigma: float = 0.01
def setup(self):
self.f_rnn = d2l.RNNScratch(num_inputs, num_hiddens, sigma)
self.b_rnn = d2l.RNNScratch(num_inputs, num_hiddens, sigma)
self.num_hiddens *= 2 # The output dimension will be doubled
class BiRNNScratch(d2l.Module):
def __init__(self, num_inputs, num_hiddens, sigma=0.01):
super().__init__()
self.save_hyperparameters()
self.f_rnn = d2l.RNNScratch(num_inputs, num_hiddens, sigma)
self.b_rnn = d2l.RNNScratch(num_inputs, num_hiddens, sigma)
self.num_hiddens *= 2 # The output dimension will be doubled
前向和后向RNN的状态分别更新,而这两个RNN的输出被连接起来。
@d2l.add_to_class(BiRNNScratch)
def forward(self, inputs, Hs=None):
f_H, b_H = Hs if Hs is not None else (None, None)
f_outputs, f_H = self.f_rnn(inputs, f_H)
b_outputs, b_H = self.b_rnn(reversed(inputs), b_H)
outputs = [torch.cat((f, b), -1) for f, b in zip(
f_outputs, reversed(b_outputs))]
return outputs, (f_H, b_H)
@d2l.add_to_class(BiRNNScratch)
def forward(self, inputs, Hs=None):
f_H, b_H = Hs if Hs is not None else (None, None)
f_outputs, f_H = self.f_rnn(inputs, f_H)
b_outputs, b_H = self.b_rnn(reversed(inputs), b_H)
outputs = [np.concatenate((f, b), -1) for f, b in zip(
f_outputs, reversed(b_outputs))]
return outputs, (f_H, b_H)
@d2l.add_to_class(BiRNNScratch)
def forward(self, inputs, Hs=None):
f_H, b_H = Hs if Hs is not None else (None, None)
f_outputs, f_H = self.f_rnn(inputs, f_H)
b_outputs, b_H = self.b_rnn(reversed(inputs), b_H)
outputs = [jnp.concatenate((f, b), -1) for f, b in zip(
f_outputs, reversed(b_outputs))]
return outputs, (f_H, b_H)
@d2l.add_to_class(BiRNNScratch)
def forward(self, inputs, Hs=None):
f_H, b_H = Hs if Hs is not None else (None, None)
f_outputs, f_H = self.f_rnn(inputs, f_H)
b_outputs, b_H = self.b_rnn(reversed(inputs), b_H)
outputs = [tf.concat((f, b), -1) for f, b in zip(
f_outputs, reversed(b_outputs))]
return outputs, (f_H, b_H)
10.4.2. 简洁实现¶
使用高级API,我们可以更简洁地实现双向RNN。这里我们以一个GRU模型为例。
class BiGRU(d2l.RNN):
def __init__(self, num_inputs, num_hiddens):
d2l.Module.__init__(self)
self.save_hyperparameters()
self.rnn = nn.GRU(num_inputs, num_hiddens, bidirectional=True)
self.num_hiddens *= 2
使用高级API,我们可以更简洁地实现双向RNN。这里我们以一个GRU模型为例。
class BiGRU(d2l.RNN):
def __init__(self, num_inputs, num_hiddens):
d2l.Module.__init__(self)
self.save_hyperparameters()
self.rnn = rnn.GRU(num_hiddens, bidirectional=True)
self.num_hiddens *= 2
Flax API不提供RNN层,因此没有任何 bidirectional
参数的概念。如果需要双向层,需要像从零开始实现中那样手动反转输入。
使用高级API,我们可以更简洁地实现双向RNN。这里我们以一个GRU模型为例。
10.4.3. 小结¶
在双向RNN中,每个时间步的隐藏状态同时由当前时间步之前和之后的数据决定。双向RNN主要用于序列编码和给定双向上下文估计观测值。由于梯度链较长,双向RNN的训练成本非常高。