10.4. 双向循环神经网络
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 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层的相应输出连接起来即可。

../_images/birnn.svg

图 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\) 是隐藏单元的数量。前向和后向隐藏状态的更新如下:

(10.4.1)\[\begin{split}\begin{aligned} \overrightarrow{\mathbf{H}}_t &= \phi(\mathbf{X}_t \mathbf{W}_{\textrm{xh}}^{(f)} + \overrightarrow{\mathbf{H}}_{t-1} \mathbf{W}_{\textrm{hh}}^{(f)} + \mathbf{b}_\textrm{h}^{(f)}),\\ \overleftarrow{\mathbf{H}}_t &= \phi(\mathbf{X}_t \mathbf{W}_{\textrm{xh}}^{(b)} + \overleftarrow{\mathbf{H}}_{t+1} \mathbf{W}_{\textrm{hh}}^{(b)} + \mathbf{b}_\textrm{h}^{(b)}), \end{aligned}\end{split}\]

其中权重 \(\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\)):

(10.4.2)\[\mathbf{O}_t = \mathbf{H}_t \mathbf{W}_{\textrm{hq}} + \mathbf{b}_\textrm{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的训练成本非常高。

10.4.4. 练习

  1. 如果不同的方向使用不同数量的隐藏单元,\(\mathbf{H}_t\) 的形状会如何变化?

  2. 设计一个具有多个隐藏层的双向RNN。

  3. 多义词在自然语言中很常见。例如,单词“bank”在上下文“i went to the bank to deposit cash”(我去银行存钱)和“i went to the bank to sit down”(我到河岸坐下)中有不同的含义。我们如何设计一个神经网络模型,使得给定一个上下文序列和一个单词,能够返回该单词在正确上下文中的向量表示?哪种类型的神经架构更适合处理多义词问题?