9.4. 循环神经网络
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

第 9.3 节中,我们介绍了用于语言建模的马尔可夫模型和\(n\)元语法,其中时间步\(t\)的词元\(x_t\)的条件概率仅取决于前面的\(n-1\)个词元。如果我们希望将时间步\(t-(n-1)\)之前的词元可能产生的影响合并到\(x_t\)上,我们需要增加\(n\)。然而,模型的参数数量也会随之呈指数增长,因为对于词汇表\(\mathcal{V}\),我们需要存储\(|\mathcal{V}|^n\)个数字。因此,与其对\(P(x_t \mid x_{t-1}, \ldots, x_{t-n+1})\)进行建模,不如使用隐变量模型,

(9.4.1)\[P(x_t \mid x_{t-1}, \ldots, x_1) \approx P(x_t \mid h_{t-1}),\]

其中\(h_{t-1}\)是一个*隐藏状态*,它存储了到时间步\(t-1\)为止的序列信息。通常,任何时间步\(t\)的隐藏状态都可以根据当前输入\(x_{t}\)和前一个隐藏状态\(h_{t-1}\)计算得出

(9.4.2)\[h_t = f(x_{t}, h_{t-1}).\]

对于 (9.4.2) 中一个足够强大的函数 \(f\),隐变量模型并非一个近似。毕竟,\(h_t\) 可以简单地存储它到目前为止所观察到的所有数据。然而,这可能会使计算和存储都变得代价高昂。

回想一下,我们在 第 5 节 中讨论了带有隐藏单元的隐藏层。值得注意的是,隐藏层和隐藏状态指的是两个截然不同的概念。如前所述,隐藏层是在从输入到输出的路径上隐藏起来的层。隐藏状态严格来说是我们给定步骤中任何操作的*输入*,它们只能通过查看先前时间步的数据来计算。

循环神经网络(RNNs)是具有隐藏状态的神经网络。在介绍RNN模型之前,我们首先回顾一下在 第 5.1 节 中介绍的多层感知机模型。

import torch
from d2l import torch as d2l
from mxnet import np, npx
from d2l import mxnet as d2l

npx.set_np()
import jax
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

9.4.1. 没有隐藏状态的神经网络

让我们看一个具有单隐藏层的多层感知机。设隐藏层的激活函数为\(\phi\)。给定一个大小为\(n\)、输入维度为\(d\)的小批量样本\(\mathbf{X} \in \mathbb{R}^{n \times d}\),隐藏层的输出\(\mathbf{H} \in \mathbb{R}^{n \times h}\)计算如下

(9.4.3)\[\mathbf{H} = \phi(\mathbf{X} \mathbf{W}_{\textrm{xh}} + \mathbf{b}_\textrm{h}).\]

(9.4.3) 中,我们有隐藏层的权重参数 \(\mathbf{W}_{\textrm{xh}} \in \mathbb{R}^{d \times h}\)、偏置参数 \(\mathbf{b}_\textrm{h} \in \mathbb{R}^{1 \times h}\),以及隐藏单元的数量 \(h\)。这样,我们在求和过程中应用广播机制(参见 第 2.1.4 节)。接下来,隐藏层输出 \(\mathbf{H}\) 作为输出层的输入,其由以下公式给出

(9.4.4)\[\mathbf{O} = \mathbf{H} \mathbf{W}_{\textrm{hq}} + \mathbf{b}_\textrm{q},\]

其中\(\mathbf{O} \in \mathbb{R}^{n \times q}\)是输出变量,\(\mathbf{W}_{\textrm{hq}} \in \mathbb{R}^{h \times q}\)是权重参数,\(\mathbf{b}_\textrm{q} \in \mathbb{R}^{1 \times q}\)是输出层的偏置参数。如果这是一个分类问题,我们可以使用\(\mathrm{softmax}(\mathbf{O})\)来计算输出类别的概率分布。

这完全类似于我们之前在 第 9.1 节 中解决的回归问题,因此我们省略了细节。只需说我们可以随机选取特征-标签对,并通过自动微分和随机梯度下降来学习我们网络的参数。

9.4.2. 具有隐藏状态的循环神经网络

当我们有隐藏状态时,情况就完全不同了。让我们更详细地看看这个结构。

假设我们在时间步\(t\)有一个小批量的输入\(\mathbf{X}_t \in \mathbb{R}^{n \times d}\)。换句话说,对于一个包含\(n\)个序列样本的小批量,\(\mathbf{X}_t\)的每一行对应于序列中时间步\(t\)的一个样本。接下来,用\(\mathbf{H}_t \in \mathbb{R}^{n \times h}\)表示时间步\(t\)的隐藏层输出。与多层感知机不同,这里我们保存前一个时间步的隐藏层输出\(\mathbf{H}_{t-1}\),并引入一个新的权重参数\(\mathbf{W}_{\textrm{hh}} \in \mathbb{R}^{h \times h}\)来描述如何在当前时间步使用前一个时间步的隐藏层输出。具体来说,当前时间步的隐藏层输出的计算是由当前时间步的输入和前一个时间步的隐藏层输出共同决定的

(9.4.5)\[\mathbf{H}_t = \phi(\mathbf{X}_t \mathbf{W}_{\textrm{xh}} + \mathbf{H}_{t-1} \mathbf{W}_{\textrm{hh}} + \mathbf{b}_\textrm{h}).\]

(9.4.3) 相比,(9.4.5) 增加了一项 \(\mathbf{H}_{t-1} \mathbf{W}_{\textrm{hh}}\),从而实例化了 (9.4.2)。从相邻时间步的隐藏层输出 \(\mathbf{H}_t\)\(\mathbf{H}_{t-1}\) 之间的关系可知,这些变量捕获并保留了序列截至当前时间步的历史信息,就像神经网络当前时间步的状态或记忆一样。因此,这样的隐藏层输出被称为*隐藏状态*。由于隐藏状态在当前时间步使用了与前一时间步相同的定义,(9.4.5) 的计算是*循环*的。因此,正如我们所说,基于循环计算的带有隐藏状态的神经网络被称为*循环神经网络*。在RNNs中执行 (9.4.5) 计算的层被称为*循环层*。

构建 RNNs 有许多不同的方法。由 (9.4.5) 定义的隐藏状态的那些非常常见。对于时间步 \(t\),输出层的输出类似于 MLP 中的计算

(9.4.6)\[\mathbf{O}_t = \mathbf{H}_t \mathbf{W}_{\textrm{hq}} + \mathbf{b}_\textrm{q}.\]

RNN的参数包括隐藏层的权重\(\mathbf{W}_{\textrm{xh}} \in \mathbb{R}^{d \times h}, \mathbf{W}_{\textrm{hh}} \in \mathbb{R}^{h \times h}\)和偏置\(\mathbf{b}_\textrm{h} \in \mathbb{R}^{1 \times h}\),以及输出层的权重\(\mathbf{W}_{\textrm{hq}} \in \mathbb{R}^{h \times q}\)和偏置\(\mathbf{b}_\textrm{q} \in \mathbb{R}^{1 \times q}\)。值得一提的是,即使在不同的时间步,RNNs也总是使用这些模型参数。因此,RNN的参数化成本不会随着时间步数的增加而增长。

图 9.4.1阐释了RNN在三个相邻时间步的计算逻辑。在任何时间步\(t\),隐藏状态的计算可以被视为:(i) 将当前时间步\(t\)的输入\(\mathbf{X}_t\)和前一个时间步\(t-1\)的隐藏状态\(\mathbf{H}_{t-1}\)拼接起来;(ii) 将拼接结果送入一个激活函数为\(\phi\)的全连接层。这样一个全连接层的输出就是当前时间步\(t\)的隐藏状态\(\mathbf{H}_t\)。在这种情况下,模型参数是\(\mathbf{W}_{\textrm{xh}}\)\(\mathbf{W}_{\textrm{hh}}\)的拼接,以及来自(9.4.5)的偏置\(\mathbf{b}_\textrm{h}\)。当前时间步\(t\)的隐藏状态\(\mathbf{H}_t\)将参与计算下一个时间步\(t+1\)的隐藏状态\(\mathbf{H}_{t+1}\)。更重要的是,\(\mathbf{H}_t\)也将被送入全连接输出层以计算当前时间步\(t\)的输出\(\mathbf{O}_t\)

../_images/rnn.svg

图 9.4.1 一个带有隐藏状态的RNN。

我们刚才提到,计算隐藏状态的 \(\mathbf{X}_t \mathbf{W}_{\textrm{xh}} + \mathbf{H}_{t-1} \mathbf{W}_{\textrm{hh}}\) 等价于将 \(\mathbf{X}_t\)\(\mathbf{H}_{t-1}\) 进行拼接,再与 \(\mathbf{W}_{\textrm{xh}}\)\(\mathbf{W}_{\textrm{hh}}\) 的拼接进行矩阵乘法。虽然这可以用数学方法证明,但在下文中我们仅用一个简单的代码片段作为演示。首先,我们定义矩阵 XW_xhHW_hh,它们的形状分别为 (3, 1)、(1, 4)、(3, 4) 和 (4, 4)。将 XW_xh 相乘,将 HW_hh 相乘,然后将这两个乘积相加,我们得到一个形状为 (3, 4) 的矩阵。

X, W_xh = torch.randn(3, 1), torch.randn(1, 4)
H, W_hh = torch.randn(3, 4), torch.randn(4, 4)
torch.matmul(X, W_xh) + torch.matmul(H, W_hh)
tensor([[ 1.2526,  0.0580, -3.3460, -0.2519],
        [-1.3064,  1.4132, -0.1435,  0.3482],
        [ 3.1495,  0.8172,  1.5167, -0.9038]])
X, W_xh = np.random.randn(3, 1), np.random.randn(1, 4)
H, W_hh = np.random.randn(3, 4), np.random.randn(4, 4)
np.dot(X, W_xh) + np.dot(H, W_hh)
[22:07:37] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
array([[-0.21952915,  4.256434  ,  4.5812645 , -5.344988  ],
       [ 3.447858  , -3.0177274 , -1.6777471 ,  7.535347  ],
       [ 2.2390068 ,  1.4199957 ,  4.744728  , -8.421293  ]])
X, W_xh = jax.random.normal(d2l.get_key(), (3, 1)), jax.random.normal(
                                                        d2l.get_key(), (1, 4))
H, W_hh = jax.random.normal(d2l.get_key(), (3, 4)), jax.random.normal(
                                                        d2l.get_key(), (4, 4))
jnp.matmul(X, W_xh) + jnp.matmul(H, W_hh)
Array([[-4.270068  ,  2.114032  ,  1.7267488 , -0.34792683],
       [ 0.90870035,  1.4999211 ,  1.0609093 ,  1.4550787 ],
       [-0.08191824, -4.367585  ,  0.48806643,  2.5333326 ]],      dtype=float32)
X, W_xh = tf.random.normal((3, 1)), tf.random.normal((1, 4))
H, W_hh = tf.random.normal((3, 4)), tf.random.normal((4, 4))
tf.matmul(X, W_xh) + tf.matmul(H, W_hh)
<tf.Tensor: shape=(3, 4), dtype=float32, numpy=
array([[ 2.102388  , -1.7009956 ,  1.3737775 ,  2.1133103 ],
       [-4.338441  ,  0.02909756, -1.1000038 , -3.9508421 ],
       [-1.308251  ,  0.6822268 , -0.52479994,  0.9554144 ]],
      dtype=float32)>

现在我们将矩阵XH按列(轴1)拼接,并将矩阵W_xhW_hh按行(轴0)拼接。这两个拼接操作分别得到形状为(3, 5)和(5, 4)的矩阵。将这两个拼接后的矩阵相乘,我们得到与上面相同的形状为(3, 4)的输出矩阵。

torch.matmul(torch.cat((X, H), 1), torch.cat((W_xh, W_hh), 0))
tensor([[ 1.2526,  0.0580, -3.3460, -0.2519],
        [-1.3064,  1.4132, -0.1435,  0.3482],
        [ 3.1495,  0.8172,  1.5167, -0.9038]])
np.dot(np.concatenate((X, H), 1), np.concatenate((W_xh, W_hh), 0))
array([[-0.21952918,  4.256434  ,  4.5812645 , -5.344988  ],
       [ 3.4478583 , -3.0177271 , -1.677747  ,  7.535347  ],
       [ 2.2390068 ,  1.4199957 ,  4.744728  , -8.421294  ]])
jnp.matmul(jnp.concatenate((X, H), 1), jnp.concatenate((W_xh, W_hh), 0))
Array([[-4.270068  ,  2.114032  ,  1.7267488 , -0.3479268 ],
       [ 0.9087004 ,  1.4999211 ,  1.0609093 ,  1.4550787 ],
       [-0.08191825, -4.3675847 ,  0.48806655,  2.5333326 ]],      dtype=float32)
tf.matmul(tf.concat((X, H), 1), tf.concat((W_xh, W_hh), 0))
<tf.Tensor: shape=(3, 4), dtype=float32, numpy=
array([[ 2.102388  , -1.7009954 ,  1.3737776 ,  2.1133103 ],
       [-4.338441  ,  0.02909758, -1.1000041 , -3.9508421 ],
       [-1.308251  ,  0.6822268 , -0.52479994,  0.9554144 ]],
      dtype=float32)>

9.4.3. 基于RNN的字符级语言模型

回想一下,在第 9.3 节中进行语言建模时,我们的目标是根据当前和过去的词元预测下一个词元;因此我们将原始序列向后移动一个词元作为目标(标签)。Bengio 等人(2003首次提出使用神经网络进行语言建模。接下来,我们演示如何使用RNN构建语言模型。设小批量大小为1,文本序列为“machine”。为了简化后续章节的训练,我们将文本分词为字符而不是单词,并考虑一个*字符级语言模型*。图 9.4.2演示了如何通过RNN进行字符级语言建模,以根据当前和之前的字符预测下一个字符。

../_images/rnn-train.svg

图 9.4.2 基于RNN的字符级语言模型。输入和目标序列分别是“machin”和“achine”。

在训练过程中,我们对每个时间步的输出层输出运行softmax操作,然后使用交叉熵损失来计算模型输出与目标之间的误差。由于隐藏层中隐藏状态的循环计算,图 9.4.2中时间步3的输出\(\mathbf{O}_3\)由文本序列“m”、“a”和“c”决定。由于训练数据中序列的下一个字符是“h”,因此时间步3的损失将取决于基于特征序列“m”、“a”、“c”生成的下一个字符的概率分布以及该时间步的目标“h”。

在实践中,每个词元都由一个\(d\)维向量表示,我们使用批量大小\(n>1\)。因此,时间步\(t\)的输入\(\mathbf X_t\)将是一个\(n\times d\)矩阵,这与我们在第 9.4.2 节中讨论的完全相同。

在接下来的章节中,我们将为字符级语言模型实现RNNs。

9.4.4. 小结

使用循环计算来处理隐藏状态的神经网络称为循环神经网络(RNN)。RNN的隐藏状态可以捕获截至当前时间步的序列历史信息。通过循环计算,RNN模型参数的数量不会随着时间步数的增加而增长。至于应用方面,RNN可以用来创建字符级语言模型。

9.4.5. 练习

  1. 如果我们使用RNN来预测文本序列中的下一个字符,任何输出所需的维度是多少?

  2. 为什么RNN可以表达在某个时间步的词元基于文本序列中所有先前词元的条件概率?

  3. 如果你通过一个长序列进行反向传播,梯度会发生什么?

  4. 本节中描述的语言模型存在哪些问题?