9.5. 循环神经网络的从零开始实现
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

现在我们准备好从零开始实现循环神经网络。 具体来说,我们将训练这个循环神经网络作为一个字符级语言模型(参见 9.4节),并遵循 9.2节 中概述的数据处理步骤,在一个由 H. G. Wells 的《时间机器》的全部文本组成的语料库上进行训练。我们从加载数据集开始。

%matplotlib inline
import math
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
%matplotlib inline
import math
from mxnet import autograd, gluon, np, npx
from d2l import mxnet as d2l

npx.set_np()
%matplotlib inline
import math
import jax
from flax import linen as nn
from jax import numpy as jnp
from d2l import jax as d2l
%matplotlib inline
import math
import tensorflow as tf
from d2l import tensorflow as d2l

9.5.1. 循环神经网络模型

我们首先定义一个类来实现循环神经网络模型(9.4.2节)。请注意,隐藏单元的数量 num_hiddens 是一个可调的超参数。

class RNNScratch(d2l.Module):  #@save
    """The RNN model implemented from scratch."""
    def __init__(self, num_inputs, num_hiddens, sigma=0.01):
        super().__init__()
        self.save_hyperparameters()
        self.W_xh = nn.Parameter(
            torch.randn(num_inputs, num_hiddens) * sigma)
        self.W_hh = nn.Parameter(
            torch.randn(num_hiddens, num_hiddens) * sigma)
        self.b_h = nn.Parameter(torch.zeros(num_hiddens))
class RNNScratch(d2l.Module):  #@save
    """The RNN model implemented from scratch."""
    def __init__(self, num_inputs, num_hiddens, sigma=0.01):
        super().__init__()
        self.save_hyperparameters()
        self.W_xh = np.random.randn(num_inputs, num_hiddens) * sigma
        self.W_hh = np.random.randn(
            num_hiddens, num_hiddens) * sigma
        self.b_h = np.zeros(num_hiddens)
class RNNScratch(nn.Module):  #@save
    """The RNN model implemented from scratch."""
    num_inputs: int
    num_hiddens: int
    sigma: float = 0.01

    def setup(self):
        self.W_xh = self.param('W_xh', nn.initializers.normal(self.sigma),
                               (self.num_inputs, self.num_hiddens))
        self.W_hh = self.param('W_hh', nn.initializers.normal(self.sigma),
                               (self.num_hiddens, self.num_hiddens))
        self.b_h = self.param('b_h', nn.initializers.zeros, (self.num_hiddens))
class RNNScratch(d2l.Module):  #@save
    """The RNN model implemented from scratch."""
    def __init__(self, num_inputs, num_hiddens, sigma=0.01):
        super().__init__()
        self.save_hyperparameters()
        self.W_xh = tf.Variable(tf.random.normal(
            (num_inputs, num_hiddens)) * sigma)
        self.W_hh = tf.Variable(tf.random.normal(
            (num_hiddens, num_hiddens)) * sigma)
        self.b_h = tf.Variable(tf.zeros(num_hiddens))

下面的 forward 方法定义了如何根据当前输入和模型在前一个时间步的状态,来计算任何时间步的输出和隐藏状态。请注意,循环神经网络模型通过循环遍历 inputs 的最外层维度,一次一个时间步地更新隐藏状态。这里的模型使用 \(\tanh\) 激活函数(5.1.2.3节)。

@d2l.add_to_class(RNNScratch)  #@save
def forward(self, inputs, state=None):
    if state is None:
        # Initial state with shape: (batch_size, num_hiddens)
        state = torch.zeros((inputs.shape[1], self.num_hiddens),
                          device=inputs.device)
    else:
        state, = state
    outputs = []
    for X in inputs:  # Shape of inputs: (num_steps, batch_size, num_inputs)
        state = torch.tanh(torch.matmul(X, self.W_xh) +
                         torch.matmul(state, self.W_hh) + self.b_h)
        outputs.append(state)
    return outputs, state
@d2l.add_to_class(RNNScratch)  #@save
def forward(self, inputs, state=None):
    if state is None:
        # Initial state with shape: (batch_size, num_hiddens)
        state = np.zeros((inputs.shape[1], self.num_hiddens),
                          ctx=inputs.ctx)
    else:
        state, = state
    outputs = []
    for X in inputs:  # Shape of inputs: (num_steps, batch_size, num_inputs)
        state = np.tanh(np.dot(X, self.W_xh) +
                         np.dot(state, self.W_hh) + self.b_h)
        outputs.append(state)
    return outputs, state
@d2l.add_to_class(RNNScratch)  #@save
def __call__(self, inputs, state=None):
    if state is not None:
        state, = state
    outputs = []
    for X in inputs:  # Shape of inputs: (num_steps, batch_size, num_inputs)
        state = jnp.tanh(jnp.matmul(X, self.W_xh) + (
            jnp.matmul(state, self.W_hh) if state is not None else 0)
                         + self.b_h)
        outputs.append(state)
    return outputs, state
@d2l.add_to_class(RNNScratch)  #@save
def forward(self, inputs, state=None):
    if state is None:
        # Initial state with shape: (batch_size, num_hiddens)
        state = tf.zeros((inputs.shape[1], self.num_hiddens))
    else:
        state, = state
        state = tf.reshape(state, (-1, self.num_hiddens))
    outputs = []
    for X in inputs:  # Shape of inputs: (num_steps, batch_size, num_inputs)
        state = tf.tanh(tf.matmul(X, self.W_xh) +
                         tf.matmul(state, self.W_hh) + self.b_h)
        outputs.append(state)
    return outputs, state

我们可以将一个小批量的输入序列馈送到一个循环神经网络模型中,如下所示。

batch_size, num_inputs, num_hiddens, num_steps = 2, 16, 32, 100
rnn = RNNScratch(num_inputs, num_hiddens)
X = torch.ones((num_steps, batch_size, num_inputs))
outputs, state = rnn(X)
batch_size, num_inputs, num_hiddens, num_steps = 2, 16, 32, 100
rnn = RNNScratch(num_inputs, num_hiddens)
X = np.ones((num_steps, batch_size, num_inputs))
outputs, state = rnn(X)
[22:31:16] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
batch_size, num_inputs, num_hiddens, num_steps = 2, 16, 32, 100
rnn = RNNScratch(num_inputs, num_hiddens)
X = jnp.ones((num_steps, batch_size, num_inputs))
(outputs, state), _ = rnn.init_with_output(d2l.get_key(), X)
batch_size, num_inputs, num_hiddens, num_steps = 2, 16, 32, 100
rnn = RNNScratch(num_inputs, num_hiddens)
X = tf.ones((num_steps, batch_size, num_inputs))
outputs, state = rnn(X)

让我们检查循环神经网络模型是否产生正确形状的结果,以确保隐藏状态的维度保持不变。

def check_len(a, n):  #@save
    """Check the length of a list."""
    assert len(a) == n, f'list\'s length {len(a)} != expected length {n}'

def check_shape(a, shape):  #@save
    """Check the shape of a tensor."""
    assert a.shape == shape, \
            f'tensor\'s shape {a.shape} != expected shape {shape}'

check_len(outputs, num_steps)
check_shape(outputs[0], (batch_size, num_hiddens))
check_shape(state, (batch_size, num_hiddens))

9.5.2. 基于循环神经网络的语言模型

下面的 RNNLMScratch 类定义了一个基于循环神经网络的语言模型,我们通过 __init__ 方法的 rnn 参数传入我们的循环神经网络。在训练语言模型时,输入和输出来自同一个词汇表。因此,它们具有相同的维度,即词汇表的大小。请注意,我们使用困惑度来评估模型。如 9.3.2节 所讨论的,这确保了不同长度的序列是可比较的。

class RNNLMScratch(d2l.Classifier):  #@save
    """The RNN-based language model implemented from scratch."""
    def __init__(self, rnn, vocab_size, lr=0.01):
        super().__init__()
        self.save_hyperparameters()
        self.init_params()

    def init_params(self):
        self.W_hq = nn.Parameter(
            torch.randn(
                self.rnn.num_hiddens, self.vocab_size) * self.rnn.sigma)
        self.b_q = nn.Parameter(torch.zeros(self.vocab_size))

    def training_step(self, batch):
        l = self.loss(self(*batch[:-1]), batch[-1])
        self.plot('ppl', torch.exp(l), train=True)
        return l

    def validation_step(self, batch):
        l = self.loss(self(*batch[:-1]), batch[-1])
        self.plot('ppl', torch.exp(l), train=False)
class RNNLMScratch(d2l.Classifier):  #@save
    """The RNN-based language model implemented from scratch."""
    def __init__(self, rnn, vocab_size, lr=0.01):
        super().__init__()
        self.save_hyperparameters()
        self.init_params()

    def init_params(self):
        self.W_hq = np.random.randn(
            self.rnn.num_hiddens, self.vocab_size) * self.rnn.sigma
        self.b_q = np.zeros(self.vocab_size)
        for param in self.get_scratch_params():
            param.attach_grad()
    def training_step(self, batch):
        l = self.loss(self(*batch[:-1]), batch[-1])
        self.plot('ppl', np.exp(l), train=True)
        return l

    def validation_step(self, batch):
        l = self.loss(self(*batch[:-1]), batch[-1])
        self.plot('ppl', np.exp(l), train=False)
class RNNLMScratch(d2l.Classifier):  #@save
    """The RNN-based language model implemented from scratch."""
    rnn: nn.Module
    vocab_size: int
    lr: float = 0.01

    def setup(self):
        self.W_hq = self.param('W_hq', nn.initializers.normal(self.rnn.sigma),
                               (self.rnn.num_hiddens, self.vocab_size))
        self.b_q = self.param('b_q', nn.initializers.zeros, (self.vocab_size))

    def training_step(self, params, batch, state):
        value, grads = jax.value_and_grad(
            self.loss, has_aux=True)(params, batch[:-1], batch[-1], state)
        l, _ = value
        self.plot('ppl', jnp.exp(l), train=True)
        return value, grads

    def validation_step(self, params, batch, state):
        l, _ = self.loss(params, batch[:-1], batch[-1], state)
        self.plot('ppl', jnp.exp(l), train=False)
class RNNLMScratch(d2l.Classifier):  #@save
    """The RNN-based language model implemented from scratch."""
    def __init__(self, rnn, vocab_size, lr=0.01):
        super().__init__()
        self.save_hyperparameters()
        self.init_params()

    def init_params(self):
        self.W_hq = tf.Variable(tf.random.normal(
            (self.rnn.num_hiddens, self.vocab_size)) * self.rnn.sigma)
        self.b_q = tf.Variable(tf.zeros(self.vocab_size))

    def training_step(self, batch):
        l = self.loss(self(*batch[:-1]), batch[-1])
        self.plot('ppl', tf.exp(l), train=True)
        return l

    def validation_step(self, batch):
        l = self.loss(self(*batch[:-1]), batch[-1])
        self.plot('ppl', tf.exp(l), train=False)

9.5.2.1. 独热编码

回想一下,每个词元都由一个数值索引表示,该索引指示了相应单词/字符/词片在词汇表中的位置。您可能会想构建一个只有一个输入节点(在每个时间步)的神经网络,其中索引可以作为标量值输入。当我们处理像价格或温度这样的数值输入时,这很有效,因为任何两个足够接近的值都应该被类似地对待。但这在这里不太合理。我们词汇表中的第 \(45^{\textrm{th}}\)\(46^{\textrm{th}}\) 个词恰好是“their”和“said”,它们的含义相去甚远。

在处理这类分类数据时,最常见的策略是用独热编码来表示每个项目(回想一下 4.1.1节)。独热编码是一个向量,其长度由词汇表大小 \(N\) 给出,其中所有条目都设置为 \(0\),除了与我们的词元对应的条目设置为 \(1\)。例如,如果词汇表有五个元素,那么对应于索引 0 和 2 的独热向量将如下所示。

F.one_hot(torch.tensor([0, 2]), 5)
tensor([[1, 0, 0, 0, 0],
        [0, 0, 1, 0, 0]])
npx.one_hot(np.array([0, 2]), 5)
array([[1., 0., 0., 0., 0.],
       [0., 0., 1., 0., 0.]])
jax.nn.one_hot(jnp.array([0, 2]), 5)
Array([[1., 0., 0., 0., 0.],
       [0., 0., 1., 0., 0.]], dtype=float32)
tf.one_hot(tf.constant([0, 2]), 5)
<tf.Tensor: shape=(2, 5), dtype=float32, numpy=
array([[1., 0., 0., 0., 0.],
       [0., 0., 1., 0., 0.]], dtype=float32)>

我们在每次迭代中采样的小批量数据将采用形状(批量大小,时间步数)。一旦将每个输入表示为独热向量,我们可以将每个小批量看作一个三维张量,其中第三个轴的长度由词汇表大小(len(vocab))给出。我们通常会对输入进行转置,这样我们将获得形状为(时间步数,批量大小,词汇表大小)的输出。这将使我们能够更方便地循环遍历最外层的维度,以便一步一步地更新一个小批量数据的隐藏状态(例如,在上面的 forward 方法中)。

@d2l.add_to_class(RNNLMScratch)  #@save
def one_hot(self, X):
    # Output shape: (num_steps, batch_size, vocab_size)
    return F.one_hot(X.T, self.vocab_size).type(torch.float32)
@d2l.add_to_class(RNNLMScratch)  #@save
def one_hot(self, X):
    # Output shape: (num_steps, batch_size, vocab_size)
    return npx.one_hot(X.T, self.vocab_size)
@d2l.add_to_class(RNNLMScratch)  #@save
def one_hot(self, X):
    # Output shape: (num_steps, batch_size, vocab_size)
    return jax.nn.one_hot(X.T, self.vocab_size)
@d2l.add_to_class(RNNLMScratch)  #@save
def one_hot(self, X):
    # Output shape: (num_steps, batch_size, vocab_size)
    return tf.one_hot(tf.transpose(X), self.vocab_size)

9.5.2.2. 转换循环神经网络输出

语言模型使用一个全连接输出层,将每个时间步的循环神经网络输出转换为词元预测。

@d2l.add_to_class(RNNLMScratch)  #@save
def output_layer(self, rnn_outputs):
    outputs = [torch.matmul(H, self.W_hq) + self.b_q for H in rnn_outputs]
    return torch.stack(outputs, 1)

@d2l.add_to_class(RNNLMScratch)  #@save
def forward(self, X, state=None):
    embs = self.one_hot(X)
    rnn_outputs, _ = self.rnn(embs, state)
    return self.output_layer(rnn_outputs)
@d2l.add_to_class(RNNLMScratch)  #@save
def output_layer(self, rnn_outputs):
    outputs = [np.dot(H, self.W_hq) + self.b_q for H in rnn_outputs]
    return np.stack(outputs, 1)

@d2l.add_to_class(RNNLMScratch)  #@save
def forward(self, X, state=None):
    embs = self.one_hot(X)
    rnn_outputs, _ = self.rnn(embs, state)
    return self.output_layer(rnn_outputs)
@d2l.add_to_class(RNNLMScratch)  #@save
def output_layer(self, rnn_outputs):
    outputs = [jnp.matmul(H, self.W_hq) + self.b_q for H in rnn_outputs]
    return jnp.stack(outputs, 1)

@d2l.add_to_class(RNNLMScratch)  #@save
def forward(self, X, state=None):
    embs = self.one_hot(X)
    rnn_outputs, _ = self.rnn(embs, state)
    return self.output_layer(rnn_outputs)
@d2l.add_to_class(RNNLMScratch)  #@save
def output_layer(self, rnn_outputs):
    outputs = [tf.matmul(H, self.W_hq) + self.b_q for H in rnn_outputs]
    return tf.stack(outputs, 1)

@d2l.add_to_class(RNNLMScratch)  #@save
def forward(self, X, state=None):
    embs = self.one_hot(X)
    rnn_outputs, _ = self.rnn(embs, state)
    return self.output_layer(rnn_outputs)

我们来检查一下前向计算是否能产生正确形状的输出。

model = RNNLMScratch(rnn, num_inputs)
outputs = model(torch.ones((batch_size, num_steps), dtype=torch.int64))
check_shape(outputs, (batch_size, num_steps, num_inputs))
model = RNNLMScratch(rnn, num_inputs)
outputs = model(np.ones((batch_size, num_steps), dtype=np.int64))
check_shape(outputs, (batch_size, num_steps, num_inputs))
model = RNNLMScratch(rnn, num_inputs)
outputs, _ = model.init_with_output(d2l.get_key(),
                                    jnp.ones((batch_size, num_steps),
                                             dtype=jnp.int32))
check_shape(outputs, (batch_size, num_steps, num_inputs))
model = RNNLMScratch(rnn, num_inputs)
outputs = model(tf.ones((batch_size, num_steps), dtype=tf.int64))
check_shape(outputs, (batch_size, num_steps, num_inputs))

9.5.3. 梯度裁剪

虽然你已经习惯于认为神经网络是“深”的,即在单个时间步内,许多层将输入和输出分开,但序列的长度引入了一个新的深度概念。除了在输入到输出方向上通过网络,第一个时间步的输入必须通过一个长度为 \(T\) 层的链条沿着时间步传递,才能影响模型在最后一个时间步的输出。从反向来看,在每次迭代中,我们通过时间反向传播梯度,导致一个长度为 \(\mathcal{O}(T)\) 的矩阵乘积链。正如 5.4节 中提到的,这可能会导致数值不稳定,根据权重矩阵的特性,导致梯度爆炸或消失。

处理梯度消失和梯度爆炸是设计循环神经网络时的一个基本问题,并激发了现代神经网络架构中一些最大的进步。在下一章中,我们将讨论为减轻梯度消失问题而设计的专门架构。然而,即使是现代循环神经网络也常常遭受梯度爆炸的困扰。一个不优雅但普遍存在的解决方案是简单地裁剪梯度,强制使产生的“裁剪后”梯度取较小的值。

一般来说,当通过梯度下降优化某个目标时,我们迭代地更新感兴趣的参数,比如说一个向量 \(\mathbf{x}\),通过将其推向负梯度 \(\mathbf{g}\) 的方向(在随机梯度下降中,我们在一个随机采样的小批量上计算这个梯度)。例如,对于学习率 \(\eta > 0\),每次更新的形式为 \(\mathbf{x} \gets \mathbf{x} - \eta \mathbf{g}\)。我们进一步假设目标函数 \(f\) 足够平滑。形式上,我们说目标函数是具有常数 \(L\)利普希茨连续,意味着对于任何 \(\mathbf{x}\)\(\mathbf{y}\),我们有

(9.5.1)\[|f(\mathbf{x}) - f(\mathbf{y})| \leq L \|\mathbf{x} - \mathbf{y}\|.\]

正如你所看到的,当我们通过减去 \(\eta \mathbf{g}\) 来更新参数向量时,目标值的变化取决于学习率、梯度的范数和 \(L\),如下所示

(9.5.2)\[|f(\mathbf{x}) - f(\mathbf{x} - \eta\mathbf{g})| \leq L \eta\|\mathbf{g}\|.\]

换句话说,目标的变化不能超过 \(L \eta \|\mathbf{g}\|\)。这个上界值小可能被看作是好事也可能是坏事。不利的一面是,我们限制了降低目标值的速度。好的一面是,这限制了我们在任何一个梯度步骤中可能犯错的程度。

当我们说梯度爆炸时,我们指的是 \(\|\mathbf{g}\|\) 变得过大。在这种最坏的情况下,我们可能在单个梯度步骤中造成如此大的损害,以至于我们可能会抵消掉数千次训练迭代中取得的所有进展。当梯度可能如此之大时,神经网络训练常常会发散,无法降低目标值。在其他时候,训练最终会收敛,但由于损失出现巨大的尖峰而变得不稳定。

限制 \(L \eta \|\mathbf{g}\|\) 大小的一种方法是将学习率 \(\eta\) 缩小到很小的值。这样做的好处是我们不会使更新产生偏差。但如果我们只是偶尔得到大梯度呢?这种激进的做法会减慢我们在所有步骤中的进展,只是为了处理罕见的梯度爆炸事件。一个流行的替代方案是采用一种梯度裁剪的启发式方法,将梯度 \(\mathbf{g}\) 投影到某个给定半径 \(\theta\) 的球上,如下所示

(9.5.3)\[\mathbf{g} \leftarrow \min\left(1, \frac{\theta}{\|\mathbf{g}\|}\right) \mathbf{g}.\]

这确保了梯度范数永远不会超过 \(\theta\),并且更新后的梯度完全与 \(\mathbf{g}\) 的原始方向一致。它还有一个理想的副作用,即限制了任何给定的小批量(以及其中的任何给定样本)对参数向量的影响。这赋予了模型一定程度的鲁棒性。需要明确的是,这是一种取巧的方法。梯度裁剪意味着我们并不总是遵循真实的梯度,并且很难从分析上推断可能产生的副作用。然而,这是一种非常有用的取巧方法,在大多数深度学习框架的循环神经网络实现中被广泛采用。

下面我们定义一个裁剪梯度的方法,它被 d2l.Trainer 类的 fit_epoch 方法调用(参见 3.4节)。请注意,在计算梯度范数时,我们将所有模型参数连接起来,将它们视为一个巨大的参数向量。

@d2l.add_to_class(d2l.Trainer)  #@save
def clip_gradients(self, grad_clip_val, model):
    params = [p for p in model.parameters() if p.requires_grad]
    norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
    if norm > grad_clip_val:
        for param in params:
            param.grad[:] *= grad_clip_val / norm
@d2l.add_to_class(d2l.Trainer)  #@save
def clip_gradients(self, grad_clip_val, model):
    params = model.parameters()
    if not isinstance(params, list):
        params = [p.data() for p in params.values()]
    norm = math.sqrt(sum((p.grad ** 2).sum() for p in params))
    if norm > grad_clip_val:
        for param in params:
            param.grad[:] *= grad_clip_val / norm
@d2l.add_to_class(d2l.Trainer)  #@save
def clip_gradients(self, grad_clip_val, grads):
    grad_leaves, _ = jax.tree_util.tree_flatten(grads)
    norm = jnp.sqrt(sum(jnp.vdot(x, x) for x in grad_leaves))
    clip = lambda grad: jnp.where(norm < grad_clip_val,
                                  grad, grad * (grad_clip_val / norm))
    return jax.tree_util.tree_map(clip, grads)
@d2l.add_to_class(d2l.Trainer)  #@save
def clip_gradients(self, grad_clip_val, grads):
    grad_clip_val = tf.constant(grad_clip_val, dtype=tf.float32)
    new_grads = [tf.convert_to_tensor(grad) if isinstance(
        grad, tf.IndexedSlices) else grad for grad in grads]
    norm = tf.math.sqrt(sum((tf.reduce_sum(grad ** 2)) for grad in new_grads))
    if tf.greater(norm, grad_clip_val):
        for i, grad in enumerate(new_grads):
            new_grads[i] = grad * grad_clip_val / norm
        return new_grads
    return grads

9.5.4. 训练

我们使用《时间机器》数据集(data),训练一个基于从零开始实现的循环神经网络(rnn)的字符级语言模型(model)。请注意,我们首先计算梯度,然后对其进行裁剪,最后使用裁剪后的梯度更新模型参数。

data = d2l.TimeMachine(batch_size=1024, num_steps=32)
rnn = RNNScratch(num_inputs=len(data.vocab), num_hiddens=32)
model = RNNLMScratch(rnn, vocab_size=len(data.vocab), lr=1)
trainer = d2l.Trainer(max_epochs=100, gradient_clip_val=1, num_gpus=1)
trainer.fit(model, data)
../_images/output_rnn-scratch_546c4d_155_0.svg
data = d2l.TimeMachine(batch_size=1024, num_steps=32)
rnn = RNNScratch(num_inputs=len(data.vocab), num_hiddens=32)
model = RNNLMScratch(rnn, vocab_size=len(data.vocab), lr=1)
trainer = d2l.Trainer(max_epochs=100, gradient_clip_val=1, num_gpus=1)
trainer.fit(model, data)
../_images/output_rnn-scratch_546c4d_158_0.svg
data = d2l.TimeMachine(batch_size=1024, num_steps=32)
rnn = RNNScratch(num_inputs=len(data.vocab), num_hiddens=32)
model = RNNLMScratch(rnn, vocab_size=len(data.vocab), lr=1)
trainer = d2l.Trainer(max_epochs=100, gradient_clip_val=1, num_gpus=1)
trainer.fit(model, data)
../_images/output_rnn-scratch_546c4d_161_0.svg
data = d2l.TimeMachine(batch_size=1024, num_steps=32)
with d2l.try_gpu():
    rnn = RNNScratch(num_inputs=len(data.vocab), num_hiddens=32)
    model = RNNLMScratch(rnn, vocab_size=len(data.vocab), lr=1)
trainer = d2l.Trainer(max_epochs=100, gradient_clip_val=1)
trainer.fit(model, data)
../_images/output_rnn-scratch_546c4d_164_0.svg

9.5.5. 解码

一旦语言模型被学习,我们不仅可以用它来预测下一个词元,还可以继续预测随后的每一个词元,将先前预测的词元视为输入中的下一个词元。有时我们只想生成文本,就像我们从文档的开头开始一样。然而,通常情况下,根据用户提供的前缀来对语言模型进行条件化会很有用。例如,如果我们正在为搜索引擎开发自动补全功能,或者帮助用户撰写电子邮件,我们会希望输入他们已经写下的内容(前缀),然后生成一个可能的续写。

下面的 predict 方法在接收了用户提供的 prefix 后,一次生成一个字符的续写。在循环遍历 prefix 中的字符时,我们不断将隐藏状态传递到下一个时间步,但不生成任何输出。这被称为预热期。在接收前缀之后,我们现在准备开始输出后续的字符,每个字符都将作为下一个时间步的输入反馈给模型。

@d2l.add_to_class(RNNLMScratch)  #@save
def predict(self, prefix, num_preds, vocab, device=None):
    state, outputs = None, [vocab[prefix[0]]]
    for i in range(len(prefix) + num_preds - 1):
        X = torch.tensor([[outputs[-1]]], device=device)
        embs = self.one_hot(X)
        rnn_outputs, state = self.rnn(embs, state)
        if i < len(prefix) - 1:  # Warm-up period
            outputs.append(vocab[prefix[i + 1]])
        else:  # Predict num_preds steps
            Y = self.output_layer(rnn_outputs)
            outputs.append(int(Y.argmax(axis=2).reshape(1)))
    return ''.join([vocab.idx_to_token[i] for i in outputs])
@d2l.add_to_class(RNNLMScratch)  #@save
def predict(self, prefix, num_preds, vocab, device=None):
    state, outputs = None, [vocab[prefix[0]]]
    for i in range(len(prefix) + num_preds - 1):
        X = np.array([[outputs[-1]]], ctx=device)
        embs = self.one_hot(X)
        rnn_outputs, state = self.rnn(embs, state)
        if i < len(prefix) - 1:  # Warm-up period
            outputs.append(vocab[prefix[i + 1]])
        else:  # Predict num_preds steps
            Y = self.output_layer(rnn_outputs)
            outputs.append(int(Y.argmax(axis=2).reshape(1)))
    return ''.join([vocab.idx_to_token[i] for i in outputs])
@d2l.add_to_class(RNNLMScratch)  #@save
def predict(self, prefix, num_preds, vocab, params):
    state, outputs = None, [vocab[prefix[0]]]
    for i in range(len(prefix) + num_preds - 1):
        X = jnp.array([[outputs[-1]]])
        embs = self.one_hot(X)
        rnn_outputs, state = self.rnn.apply({'params': params['rnn']},
                                            embs, state)
        if i < len(prefix) - 1:  # Warm-up period
            outputs.append(vocab[prefix[i + 1]])
        else:  # Predict num_preds steps
            Y = self.apply({'params': params}, rnn_outputs,
                           method=self.output_layer)
            outputs.append(int(Y.argmax(axis=2).reshape(1)))
    return ''.join([vocab.idx_to_token[i] for i in outputs])
@d2l.add_to_class(RNNLMScratch)  #@save
def predict(self, prefix, num_preds, vocab, device=None):
    state, outputs = None, [vocab[prefix[0]]]
    for i in range(len(prefix) + num_preds - 1):
        X = tf.constant([[outputs[-1]]])
        embs = self.one_hot(X)
        rnn_outputs, state = self.rnn(embs, state)
        if i < len(prefix) - 1:  # Warm-up period
            outputs.append(vocab[prefix[i + 1]])
        else:  # Predict num_preds steps
            Y = self.output_layer(rnn_outputs)
            outputs.append(int(tf.reshape(tf.argmax(Y, axis=2), 1)))
    return ''.join([vocab.idx_to_token[i] for i in outputs])

在下面,我们指定前缀并让它生成20个额外的字符。

model.predict('it has', 20, data.vocab, d2l.try_gpu())
'it has in the the the the '
model.predict('it has', 20, data.vocab, d2l.try_gpu())
'it has in the the prace th'
model.predict('it has', 20, data.vocab, trainer.state.params)
'it has and the the the the'
model.predict('it has', 20, data.vocab)
'it has it and the the the '

虽然从零开始实现上述循环神经网络模型具有指导意义,但并不方便。在下一节中,我们将看到如何利用深度学习框架,使用标准架构快速构建循环神经网络,并依靠高度优化的库函数来获得性能提升。

9.5.6. 总结

我们可以训练基于循环神经网络的语言模型,以生成跟随用户提供的文本前缀的文本。一个简单的循环神经网络语言模型由输入编码、循环神经网络建模和输出生成组成。在训练过程中,梯度裁剪可以缓解梯度爆炸问题,但不能解决梯度消失问题。在实验中,我们实现了一个简单的循环神经网络语言模型,并使用梯度裁剪在字符级分词的文本序列上对其进行训练。通过以前缀为条件,我们可以使用语言模型来生成可能的续写,这在许多应用中都很有用,例如自动补全功能。

9.5.7. 练习

  1. 所实现的语言模型是否基于《时间机器》中从第一个词元开始的所有过去词元来预测下一个词元?

  2. 哪个超参数控制用于预测的历史长度?

  3. 证明独热编码等同于为每个对象选择一个不同的嵌入。

  4. 调整超参数(例如,训练周期数、隐藏单元数、小批量中的时间步数和学习率)以提高困惑度。在坚持使用这种简单架构的情况下,你能将困惑度降到多低?

  5. 用可学习的嵌入替换独热编码。这是否会带来更好的性能?

  6. 进行一项实验,以确定这个在《时间机器》上训练的语言模型在H. G. Wells的其他书籍上表现如何,例如《世界大战》。

  7. 进行另一项实验,评估该模型在其他作者撰写的书籍上的困惑度。

  8. 修改预测方法,以便使用采样而不是选择最可能的下一个字符。

    • 会发生什么?

    • 使模型偏向于更可能的输出,例如,从 \(q(x_t \mid x_{t-1}, \ldots, x_1) \propto P(x_t \mid x_{t-1}, \ldots, x_1)^\alpha\) 进行采样,其中 \(\alpha > 1\)

  9. 在本节中运行代码时不裁剪梯度。会发生什么?

  10. 将本节中使用的激活函数替换为ReLU,并重复本节中的实验。我们还需要梯度裁剪吗?为什么?