循环神经网络5-从零开始实现循环神经网络

105 阅读12分钟

在本节中,我们将从头开始实现一个基于循环神经网络(RNN)的字符级语言模型,使用H.G. Wells的《时光机器》数据集进行训练。我们将会逐步建立模型、初始化参数并进行训练。

import torch
from torch.nn import functional as F

import d2l

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

1. 独热编码

独热编码(One-Hot Encoding)是一种常用的词元表示方式。它将每个词元(单词或字符)表示为一个向量,该向量的维度与词表大小相同,其中只有一个位置为1,其余位置为0。例如,如果词表包含4个词元(['a', 'b', 'c', 'd']),那么字符'a'可以表示为[1, 0, 0, 0],字符'b'表示为[0, 1, 0, 0]

# 词表大小假设为28
X = torch.arange(10).reshape((2, 5))
print(X.T)
"""
tensor([[0, 5],
        [1, 6],
        [2, 7],
        [3, 8],
        [4, 9]])
"""
print(F.one_hot(X.T, 28))

输出结果是一个形状为(5, 2, 28)的张量。

tensor([[[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0]],

        [[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0]],

        [[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0]],

        [[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0]],

        [[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0]]])

2. 初始化模型参数

我们需要初始化循环神经网络(RNN)模型的参数。在字符级语言模型中,输入和输出的维度相同,都来自于同一个词表,因此它们的维度一致。

def get_params(vocab_size, num_hiddens, device):
    """
    初始化循环神经网络(RNN)模型的参数。

    参数:
    vocab_size (int): 词汇表大小(即输入和输出的维度)。
    num_hiddens (int): 隐藏层的维度。
    device (torch.device): 设备(如CPU或GPU)。

    返回:
    list: 包含所有模型参数的列表,包含权重矩阵和偏置。
    """
    num_inputs = num_outputs = vocab_size  # 输入和输出的维度等于词汇表大小

    def normal(shape):
        """生成正态分布随机数,初始化参数"""
        return torch.randn(size=shape, device=device) * 0.01  # 标准差为0.01

    # 初始化参数
    W_xh = normal((num_inputs, num_hiddens))  # 输入到隐藏层的权重
    W_hh = normal((num_hiddens, num_hiddens))  # 隐藏层到隐藏层的权重
    b_h = torch.zeros(num_hiddens, device=device)  # 隐藏层的偏置
    W_hq = normal((num_hiddens, num_outputs))  # 隐藏层到输出层的权重
    b_q = torch.zeros(num_outputs, device=device)  # 输出层的偏置

    # 将所有参数放入一个列表
    params = [W_xh, W_hh, b_h, W_hq, b_q]

    # 设置所有参数为可训练
    for param in params:
        param.requires_grad_(True)

    return params

3. 循环神经网络模型

接下来,我们定义一个简单的RNN模型。首先需要一个初始化隐状态的函数,隐状态用于存储模型在每个时间步的“记忆”。然后,我们定义rnn函数来计算每个时间步的隐状态和输出。

def init_rnn_state(batch_size, num_hiddens, device):
    """
    初始化RNN的隐状态,返回初始的隐藏状态和细胞状态(如果是LSTM的话)。

    参数:
    batch_size (int): 输入数据的批量大小。
    num_hiddens (int): 隐藏层的维度。
    device (torch.device): 设备(如CPU或GPU)。

    返回:
    tuple: 包含隐状态和细胞状态(LSTM)的元组。
    """
    # 对于RNN,初始化隐藏状态为零
    h = torch.zeros(batch_size, num_hiddens, device=device)
    return h,


def rnn(inputs, state, params):
    """
    实现RNN的前向计算过程。

    参数:
    inputs (Tensor): 输入序列的张量,形状为(时间步数, 批量大小, 输入维度)。
    state (tuple): 初始隐状态(对于LSTM包含隐状态和细胞状态)。
    params (list): 模型的所有参数,包含权重和偏置。

    返回:
    tuple: 包含输出和最终隐状态的元组。
    """
    W_xh, W_hh, b_h, W_hq, b_q = params
    H, = state
    outputs = []  # 用来存储每个时间步的输出
    for X in inputs:
        # 当前时间步的隐状态更新公式
        H = torch.tanh(torch.matmul(X, W_xh) + torch.matmul(H, W_hh) + b_h)  # 隐状态的计算
        Y = torch.matmul(H, W_hq) + b_q  # 输出的计算
        outputs.append(Y)  # 保存当前时间步的输出

    return torch.cat(outputs, dim=0), (H,)  # 拼接所有时间步的输出并返回,更新的隐状态

在这个函数中,X是输入的词元,H是隐状态,Y是模型的输出。每个时间步都会更新隐状态并生成新的输出。

定义了所有需要的函数之后,接下来我们创建一个类来包装这些函数,并存储从零开始实现的循环神经网络模型的参数。

# d2l.py

class RNNModelScratch:
    """从零开始实现的循环神经网络模型,用于字符级文本生成。"""

    def __init__(self, vocab_size, num_hiddens, device,
                 get_params, init_state, forward_fn):
        """
        初始化 RNN 模型。

        参数:
        vocab_size (int): 词汇表大小。
        num_hiddens (int): 隐藏层单元数。
        device (torch.device): 设备。
        get_params (function): 获取模型参数的函数。
        init_state (function): 初始化隐藏状态的函数。
        forward_fn (function): 前向传播函数。
        """
        self.vocab_size, self.num_hiddens = vocab_size, num_hiddens
        # 获取模型的所有参数
        self.params = get_params(vocab_size, num_hiddens, device)
        # 设置初始化状态函数和前向传播函数
        self.init_state, self.forward_fn = init_state, forward_fn

    def __call__(self, X, state):
        """
        调用模型进行前向传播。

        参数:
        X (Tensor): 输入数据,形状为 (batch_size, seq_len)。
        state (Tensor): 上一时刻的隐藏状态,形状为 (batch_size, num_hiddens)。

        返回:
        输出结果和更新后的隐藏状态。
        """
        # 将输入转换为one-hot编码,并进行类型转换
        X = F.one_hot(X.T, self.vocab_size).type(torch.float32)
        return self.forward_fn(X, state, self.params)  # 调用前向传播函数进行计算

    def begin_state(self, batch_size, device):
        """
        初始化隐藏状态。

        参数:
        batch_size (int): 输入数据的批量大小。
        device (torch.device): 设备类型('cpu' 或 'gpu')。

        返回:
        Tensor: 初始化的隐藏状态,形状为 (batch_size, num_hiddens)。
        """
        return self.init_state(batch_size, self.num_hiddens, device)

F.one_hot(X.T, self.vocab_size) 中为什么是X.T而不是X

假设 X 是输入的批量数据,它的形状是 (batch_size, seq_len),其中 batch_size 是每次输入的样本数量,seq_len 是每个样本的序列长度(也就是文本的字符数)。

而在大多数 RNN 模型中,输入数据的维度通常是 (seq_len, batch_size, input_size),其中:

  • seq_len 是序列的长度。
  • batch_size 是每个批次的样本数。
  • input_size 是每个输入特征的大小,在字符级任务中通常是词汇表大小(即 vocab_size)。

为了让 F.one_hot() 正确地处理数据,需要对 X 进行转置,变成 (seq_len, batch_size),这样才能正确地按照序列的每一个时间步来做 one-hot 编码。


让我们检查输出是否具有正确的形状。 例如,隐状态的维数是否保持不变。

num_hiddens = 512
net = d2l.RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(),
                          get_params, init_rnn_state, rnn)
state = net.begin_state(X.shape[0], d2l.try_gpu())
Y, new_state = net(X.to(d2l.try_gpu()), state)
print(Y.shape, len(new_state), new_state[0].shape)

输出:

torch.Size([10, 28]) 1 torch.Size([2, 512])

4. 预测

为了根据模型的预测生成字符,我们定义了一个预测函数。在模型训练完成后,我们可以通过给定一个前缀字符串,预测后续的字符。

d2l.py

def predict_ch8(prefix, num_preds, net, vocab, device):
    """
    使用训练好的RNN模型生成文本。

    参数:
    prefix (str): 输入的文本前缀,模型将基于这个前缀生成后续文本。
    num_preds (int): 要生成的字符数。
    net (nn.Module): 训练好的RNN模型。
    vocab (Vocab): 词汇表,用于处理输入和输出的字符。
    device (torch.device): 设备(CPU或GPU),决定模型和数据存放的位置。

    返回:
    str: 生成的文本序列。
    """
    state = net.begin_state(batch_size=1, device=device)  # 初始状态为空
    outputs = [vocab[prefix[0]]]
    get_input = lambda: torch.tensor([outputs[-1]], device=device).reshape((1, 1))
    # 将前缀转换为模型的输入,获取对应的字符索引
    for y in prefix[1:]:
        # 使用模型预测下一个字符,更新隐状态
        _, state = net(get_input(), state)
        outputs.append(vocab[y])
    # 生成num_preds个字符
    for _ in range(num_preds):
        # 获取当前模型的输出(预测的下一个字符)
        y, state = net(get_input(), state)
        outputs.append(int(y.argmax(dim=1).reshape(1)))

    return ''.join([vocab.idx_to_token[i] for i in outputs])

现在我们可以测试predict_ch8函数。 我们将前缀指定为time traveller, 并基于这个前缀生成10个后续字符。 鉴于我们还没有训练网络,它会生成荒谬的预测结果。

print(d2l.predict_ch8('time traveller ', 10, net, vocab, d2l.try_gpu()))

输出:

time traveller vohpiyy vo

5. 梯度裁剪

在训练RNN模型时,可能会出现梯度爆炸的问题,导致模型无法收敛。为了防止这种情况发生,我们可以使用梯度裁剪。通过限制梯度的最大值,我们可以确保模型训练的稳定性。

def grad_clipping(net, theta):
    """裁剪梯度
    对模型的梯度进行裁剪,防止梯度爆炸。
    参数:
        net: 神经网络模型,可以是 `nn.Module` 的实例或包含参数的自定义对象。
        theta: 裁剪阈值。如果梯度的范数超过该阈值,进行裁剪。
    """
    # 如果 net 是 nn.Module 类的实例,获取其所有可训练的参数
    if isinstance(net, nn.Module):
        # 获取所有需要梯度更新的参数
        params = [param for param in net.parameters() if param.requires_grad]
    else:
        # 如果 net 不是 nn.Module 实例,假设它有一个 `params` 属性
        params = net.params
    # 计算所有参数的梯度范数(L2范数):对所有参数的梯度进行平方和,再取平方根
    norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
    # 如果梯度的范数超过了阈值 theta,就进行裁剪
    if norm > theta:
        for param in params:
            # 通过缩放梯度,确保范数不超过 theta
            param.grad[:] *= theta / norm

6. 训练

在训练模型时,我们可以使用train_epoch_ch8函数来进行一个迭代周期的训练。该函数会自动进行梯度裁剪并更新模型的参数。

# d2l.py

def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
    """训练网络一个迭代周期
    参数:
        net: 待训练的网络模型
        train_iter: 训练数据迭代器
        loss: 损失函数
        updater: 更新器,可以是优化器或自定义的更新方法
        device: 计算设备(如 'cpu' 或 'cuda')
        use_random_iter: 是否使用随机抽样
    返回:
        返回训练损失的指数损失(即对数损失的指数)和每秒处理的样本数量
    """
    state, timer = None, Timer()  # 初始化模型状态和计时器
    metric = Accumulator(2)  # 用于累积训练损失和词元数量的辅助器
    for X, Y in train_iter:
        if state is None or use_random_iter:
            # 如果是第一次迭代或者需要随机抽样时,初始化模型的隐状态
            state = net.begin_state(batch_size=X.shape[0], device=device)
        else:
            if isinstance(net, nn.Module) and not isinstance(state, tuple):
                # 如果模型是nn.Module且隐状态是一个张量(如GRU)
                state.detach_()  # 将隐状态从计算图中分离,避免梯度传播
            else:
                # 如果模型是LSTM或自定义模型,隐状态可能是一个元组
                for s in state:
                    s.detach_()  # 将每个隐状态分离
        y = Y.T.reshape(-1)  # 转置并拉直标签张量
        X, y = X.to(device), y.to(device)  # 将输入和标签移动到指定设备
        # 前向传播:计算预测结果和更新隐状态
        y_hat, state = net(X, state)
        # 计算当前批次的损失,使用long类型的标签
        l = loss(y_hat, y.long()).mean()

        if isinstance(updater, torch.optim.Optimizer):
            # 如果updater是优化器对象(如torch.optim.Optimizer)
            updater.zero_grad()
            l.backward()
            grad_clipping(net, 1)  # 梯度裁剪,避免梯度爆炸
            updater.step()
        else:
            # 如果updater是自定义的更新方法
            l.backward()
            grad_clipping(net, 1)
            updater(batch_size=1)
        # 累积当前批次的损失和词元数量
        metric.add(l * y.numel(), y.numel())
    return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()

循环神经网络模型的训练函数既支持从零开始实现, 也可以使用高级API来实现。

def train_ch8(net, train_iter, vocab, lr, num_epochs, device,
              use_random_iter=False):
    """训练模型
    参数:
        net: 待训练的网络模型
        train_iter: 训练数据迭代器
        vocab: 词汇表
        lr: 学习率
        num_epochs: 训练的周期数
        device: 计算设备(如 'cpu' 或 'cuda')
        use_random_iter: 是否使用随机抽样(默认为 False)
    """
    loss = nn.CrossEntropyLoss()  # 定义交叉熵损失函数
    animator = Animator(xlabel='epoch', ylabel='perplexity',
                        legend=['train'], xlim=[10, num_epochs],
                        figsize=(7, 5.5))
    # 初始化更新器
    if isinstance(net, nn.Module):
        updater = torch.optim.SGD(net.parameters(), lr=lr)
    else:
        updater = lambda batch_size: sgd(net.params, lr, batch_size)

    # 定义预测函数,用于根据给定前缀生成文本
    predict = lambda prefix: predict_ch8(prefix, 50, net, vocab, device)
    # 训练和预测
    for epoch in range(num_epochs):
        # 在每个周期中训练并计算困惑度和每秒处理的词元数
        ppl, speed = train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter)
        # 每经过10个周期打印一次预测结果
        if (epoch + 1) % 10 == 0:
            print(predict('time traveller'))  # 以'time traveller'为前缀进行预测
            animator.add(epoch + 1, [ppl])

    # 打印最终的困惑度和处理速度
    print(f'困惑度 {ppl:.1f}, {speed:.1f} 词元/秒 {str(device)}')

    # 打印以'time traveller'和'traveller'为前缀的预测文本
    print(predict('time traveller'))
    print(predict('traveller'))

现在,我们训练循环神经网络模型。 因为我们在数据集中只使用了10000个词元, 所以模型需要更多的迭代周期来更好地收敛。

num_epochs, lr = 500, 1
d2l.train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu())

输出:

困惑度 1.0, 80566.5 词元/秒 cuda:0
time travelleryou can show black is white by argument said filby
travelleryou can show black is white by argument said filby

屏幕截图 2025-03-04 163854.png

最后,让我们检查一下使用随机抽样方法的结果。

net = d2l.RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(), get_params,
                          init_rnn_state, rnn)
d2l.train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu(),
              use_random_iter=True)

输出:

困惑度 1.5, 63868.8 词元/秒 cuda:0
time traveller proceeded the time traveller proceeded the time t
traveller hrle ghthe but loming a aintton sald atlyune s it

屏幕截图 2025-03-04 164325.png

7. 小结

通过本节的内容,我们实现了一个基于循环神经网络的字符级语言模型。该模型能够根据给定的前缀生成后续文本。我们学习了如何:

  • 使用独热编码表示词元;
  • 初始化模型参数;
  • 实现循环神经网络的正向传播;
  • 使用梯度裁剪防止梯度爆炸;
  • 训练和评估模型。

尽管从零开始实现模型是一个很好的学习过程,但在实际应用中,通常我们会使用更高效的API来实现RNN模型。在下一篇文章中,我们将学习如何优化循环神经网络的实现。