深度学习-循环神经网络详解

循环神经网络

语言模型

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LZMEqxWa-1687830319090)(image/手动深度学习/1685727428918.png)]

语言模型的计算

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PQEMbTyT-1687830319093)(image/手动深度学习/1685727496842.png)]

n元语法

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-koophGRz-1687830319107)(image/手动深度学习/1685727594403.png)]

循环神经网络

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-c08nlEEF-1687830319108)(image/手动深度学习/1685756509492.png)]

不含隐藏状态的神经网络

让我们考虑一个含单隐藏层的多层感知机。给定样本数为 n n n,输入个数(特征数或特征向量维度)为 d d d的小批量数据样本 X ∈ R n × d X \in \mathbb{R}^{n \times d} XRn×d。设隐藏层的激活函数为 ϕ \phi ϕ,那么隐藏层的输出 H ∈ R n × h H \in \mathbb{R}^{n \times h} HRn×h计算为

H = ϕ ( X W x h + b h ) H = \phi(XW_{xh} + b_h) H=ϕ(XWxh+bh)

其中隐藏层权重参数 W x h ∈ R d × h W_{xh} \in \mathbb{R}^{d \times h} WxhRd×h,隐藏层偏差参数 b h ∈ R 1 × h b_h \in \mathbb{R}^{1 \times h} bhR1×h h h h为隐藏单元个数。上式相加的两项形状不同,因此将按照广播机制相加。把隐藏变量 H H H作为输出层的输入,且设输出个数为 q q q(如分类问题中的类别数),输出层的输出为

O = H W h q + b q O = HW_{hq} + b_q O=HWhq+bq

其中输出变量 O ∈ R n × q O \in \mathbb{R}^{n \times q} ORn×q,输出层权重参数 W h q ∈ R h × q W_{hq} \in \mathbb{R}^{h \times q} WhqRh×q,输出层偏差参数 b q ∈ R 1 × q b_q \in \mathbb{R}^{1 \times q} bqR1×q。如果是分类问题,我们可以使用softmax( O O O)来计算输出类别的概率分布。

含隐藏状态的循环神经网络

现在我们考虑输入数据存在时间相关性的情况。假设 Xt 是序列中时间步 t 的小批量输入,Ht 是该时间步的隐藏变量。与多层感知机不同的是,这里我们保存上一时间步的隐藏变量 Ht-1,并引入一个新的权重参数 Whh,该参数用来描述在当前时间步如何使用上一时间步的隐藏变量。具体来说,时间步 t 的隐藏变量的计算由当前时间步的输入和上一时间步的隐藏变量共同决定:

Ht=ϕ(XtWxh+Ht-1Whh+bh).

与多层感知机相比,我们在这里添加了 Ht-1Whh 一项。由上式中相邻时间步的隐藏变量 HtHt-1 之间的关系可知,这里的隐藏变量能够捕捉截至当前时间步的序列的历史信息,就像是神经网络当前时间步的状态或记忆一样。因此,该隐藏变量也称为隐藏状态。由于隐藏状态在当前时间步的定义使用了上一时间步的隐藏状态,上式的计算是循环的。使用循环计算的网络即循环神经网络(recurrent neural network)。

循环神经网络有很多种不同的构造方法。含上式所定义的隐藏状态的循环神经网络是极为常见的一种。若无特别说明,本章中的循环神经网络均基于上式中隐藏状态的循环计算。在时间步 t,输出层的输出和多层感知机中的计算类似:

Ot=HtWhq+bq.

循环神经网络的参数包括隐藏层的权重 WxhWhh 和偏差 bh,以及输出层的权重 Whq 和偏差 bq。值得一提的是,即便在不同时间步,循环神经网络也始终使用这些模型参数。因此,循环神经网络模型参数的数量不随时间步的增加而增长。

图 6.1 展示了循环神经网络在 3 个相邻时间步的计算逻辑。在时间步 t,隐藏状态的计算可以看成是将输入 Xt 和前一时间步隐藏状态 Ht-1 连结后输入一个激活函数为 ϕ 的全连接层。该全连接层的输出就是当前时间步的隐藏状态 Ht,且模型参数为 WxhWhh 的连结,偏差为 bh。当前时间步 t 的隐藏状态 Ht 将参与下一个时间步 t+1 的隐藏状态 Ht+1 的计算,并输入到当前时间步的全连接输出层。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EwsZRYGT-1687830319110)(image/手动深度学习/1685757614915.png)]

我们刚刚提到,隐藏状态中 XtWxh+Ht−1Whh 的计算等价于将 XtHt−1 连结后的矩阵乘以 WxhWhh 连结后的矩阵。接下来,我们用一个具体的例子来验证这一点。首先,我们构造矩阵 XWxhHWhh,它们的形状分别为(3, 1)、(1, 4)、(3, 4) 和 (4, 4)。将 XWxh 相乘,得到形状为(3, 4)的矩阵,记为 XWxh。将 HWhh 相乘,得到形状为(3, 4)的矩阵,记为 HWxh。然后将 XWxhHWxh 相加,得到形状为(3, 4)的矩阵。

import torch

X, W_xh = torch.randn(3, 1), torch.randn(1, 4)
H, W_hh = torch.randn(3, 4), torch.randn(4, 4)
print(torch.matmul(X, W_xh) + torch.matmul(H, W_hh))

#output
tensor([[ 0.7729, -2.0384,  1.3170,  0.4994],
        [ 1.7895,  4.2351,  1.3124,  1.3924],
        [ 1.3251, -1.4093, -2.2350, -0.7038]])

将矩阵 XH按列(维度1)连结,连结后的矩阵形状为(3, 5)。可见,连结后矩阵在维度1的长度为矩阵 XH在维度1的长度之和(1+4)。然后,将矩阵 W_xhW_hh按行(维度0)连结,连结后的矩阵形状为(5, 4)。最后将两个连结后的矩阵相乘,得到与上面代码输出相同的形状为(3, 4)的矩阵

print(torch.matmul(torch.cat((X, H), dim=1), torch.cat((W_xh, W_hh), dim=0)))
#same output
应用:基于字符级循环神经网络的语言模型

最后我们介绍如何应用循环神经网络来构建一个语言模型。设小批量中样本数为1,文本序列为“想”“要”“有”“直”“升”“机”。图6.2演示了如何使用循环神经网络基于当前和过去的字符来预测下一个字符。在训练时,我们对每个时间步的输出层输出使用softmax运算,然后使用交叉熵损失函数来计算它与标签的误差。在图6.2中,由于隐藏层中隐藏状态的循环计算,时间步3的输出O3取决于文本序列“想”“要”“有”。 由于训练数据中该序列的下一个词为“直”,时间步3的损失将取决于该时间步基于序列“想”“要”“有”生成下一个词的概率分布与该时间步的标签“直”。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cQN3Lq07-1687830319112)(image/手动深度学习/1685758168965.png)]

因为每个输入词是一个字符,因此这个模型被称为字符级循环神经网络(character-level recurrent neural network)。因为不同字符的个数远小于不同词的个数(对于英文尤其如此),所以字符级循环神经网络的计算通常更加简单。在接下来的几节里,我们将介绍它的具体实现

语言模型数据集(周杰伦专辑歌词!!)

本节将介绍如何预处理一个语言模型数据集,并将其转换成字符级循环神经网络所需要的输入格式。为此,我们收集了周杰伦从第一张专辑《Jay》到第十张专辑《跨时代》中的歌词,并在后面几节里应用循环神经网络来训练一个语言模型。当模型训练好后,我们就可以用这个模型来创作歌词。

读取数据集

首先读取这个数据集,看看前40个字符是什么样的

import torch
import random
import zipfile

with zipfile.ZipFile('jaychou_lyrics.txt.zip') as zin:
    with zin.open('jaychou_lyrics.txt') as f:
        corpus_chars = f.read().decode('utf-8')
print(corpus_chars[:40])

#output:
想要有直升机
想要和你飞到宇宙去
想要和你融化在一起
融化在宇宙里
我每天每天每

这个数据集有6万多个字符。为了打印方便,我们把换行符替换成空格,然后仅使用前1万个字符来训练模型。

corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
corpus_chars = corpus_chars[0:10000]
建立字符索引

我们将每个字符映射成一个从0开始的连续整数,又称索引,来方便之后的数据处理。为了得到索引,我们将数据集里所有不同字符取出来,然后将其逐一映射到索引来构造词典。接着,打印 vocab_size,即词典中不同字符的个数,又称词典大小。

idx_to_char = list(set(corpus_chars))
char_to_idx = dict([(char, i) for i, char in enumerate(idx_to_char)])
vocab_size = len(char_to_idx)
print(vocab_size) # 1027

之后,将训练数据集中每个字符转化为索引,并打印前20个字符及其对应的索引。

corpus_indices = [char_to_idx[char] for char in corpus_chars]
sample = corpus_indices[:20]
print('chars:', ''.join([idx_to_char[idx] for idx in sample]))
print('indices:', sample)

#output:
chars: 想要有直升机 想要和你飞到宇宙去 想要和
indices: [250, 164, 576, 421, 674, 653, 357, 250, 164, 850, 217, 910, 1012, 261, 275, 366, 357, 250, 164, 850]

我们将以上代码封装在 d2lzh_pytorch包里的 load_data_jay_lyrics函数中,以方便后面章节调用。调用该函数后会依次得到 corpus_indiceschar_to_idxidx_to_charvocab_size这4个变量。

时序数据的采样

在训练中我们需要每次随机读取小批量样本和标签。与之前章节的实验数据不同的是,时序数据的一个样本通常包含连续的字符。假设时间步数为5,样本序列为5个字符,即“想”“要”“有”“直”“升”。该样本的标签序列为这些字符分别在训练集中的下一个字符,即“要”“有”“直”“升”“机”。我们有两种方式对时序数据进行采样,分别是随机采样和相邻采样。

随机采样

下面的代码每次从数据里随机采样一个小批量。其中批量大小 batch_size指每个小批量的样本数,num_steps为每个样本所包含的时间步数。 在随机采样中,每个样本是原始序列上任意截取的一段序列。相邻的两个随机小批量在原始序列上的位置不一定相毗邻。因此,我们无法用一个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态。在训练模型时,每次随机采样前都需要重新初始化隐藏状态。

def data_iter_random(corpus_indices, batch_size, num_steps, device=None):
    # 减1是因为输出的索引x是相应输入的索引y加1
    num_examples = (len(corpus_indices) - 1) // num_steps
    epoch_size = num_examples // batch_size
    example_indices = list(range(num_examples))
    random.shuffle(example_indices)

    # 返回从pos开始的长为num_steps的序列
    def _data(pos):
        return corpus_indices[pos: pos + num_steps]
    if device is None:
        device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    for i in range(epoch_size):
        # 每次读取batch_size个随机样本
        i = i * batch_size
        batch_indices = example_indices[i: i + batch_size]
        X = [_data(j * num_steps) for j in batch_indices]
        Y = [_data(j * num_steps + 1) for j in batch_indices]
        yield torch.tensor(X, dtype=torch.float32, device=device), torch.tensor(Y, dtype=torch.float32, device=device)

让我们输入一个从0到29的连续整数的人工序列。设批量大小和时间步数分别为2和6。打印随机采样每次读取的小批量样本的输入 X和标签 Y。可见,相邻的两个随机小批量在原始序列上的位置不一定相毗邻。

my_seq = list(range(30))
for X, Y in data_iter_random(my_seq, batch_size=2, num_steps=6):
    print('X: ', X, '\nY:', Y, '\n')

#output
X:  tensor([[18., 19., 20., 21., 22., 23.],
        [12., 13., 14., 15., 16., 17.]]) 
Y: tensor([[19., 20., 21., 22., 23., 24.],
        [13., 14., 15., 16., 17., 18.]]) 

X:  tensor([[ 0.,  1.,  2.,  3.,  4.,  5.],
        [ 6.,  7.,  8.,  9., 10., 11.]]) 
Y: tensor([[ 1.,  2.,  3.,  4.,  5.,  6.],
        [ 7.,  8.,  9., 10., 11., 12.]]) 
相邻采样

除对原始序列做随机采样之外,我们还可以令相邻的两个随机小批量在原始序列上的位置相毗邻。这时候,我们就可以用一个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态,从而使下一个小批量的输出也取决于当前小批量的输入,并如此循环下去。这对实现循环神经网络造成了两方面影响:一方面, 在训练模型时,我们只需在每一个迭代周期开始时初始化隐藏状态;另一方面,当多个相邻小批量通过传递隐藏状态串联起来时,模型参数的梯度计算将依赖所有串联起来的小批量序列。同一迭代周期中,随着迭代次数的增加,梯度的计算开销会越来越大。 为了使模型参数的梯度计算只依赖一次迭代读取的小批量序列,我们可以在每次读取小批量前将隐藏状态从计算图中分离出来。我们将在下一节(循环神经网络的从零开始实现)的实现中了解这种处理方式。

def data_iter_consecutive(corpus_indices, batch_size, num_steps, device=None):
    if device is None:
        device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    corpus_indices = torch.tensor(corpus_indices, dtype=torch.float32, device=device)
    data_len = len(corpus_indices)
    batch_len = data_len // batch_size
    indices = corpus_indices[0: batch_size*batch_len].view(batch_size, batch_len)
    epoch_size = (batch_len - 1) // num_steps
    for i in range(epoch_size):
        i = i * num_steps
        X = indices[:, i: i + num_steps]
        Y = indices[:, i + 1: i + num_steps + 1]
        yield X, Y

同样的设置下,打印相邻采样每次读取的小批量样本的输入 X和标签 Y。相邻的两个随机小批量在原始序列上的位置相毗邻。

for X, Y in data_iter_consecutive(my_seq, batch_size=2, num_steps=6):
    print('X: ', X, '\nY:', Y, '\n')

#output
X:  tensor([[ 0.,  1.,  2.,  3.,  4.,  5.],
        [15., 16., 17., 18., 19., 20.]]) 
Y: tensor([[ 1.,  2.,  3.,  4.,  5.,  6.],
        [16., 17., 18., 19., 20., 21.]]) 

X:  tensor([[ 6.,  7.,  8.,  9., 10., 11.],
        [21., 22., 23., 24., 25., 26.]]) 
Y: tensor([[ 7.,  8.,  9., 10., 11., 12.],
        [22., 23., 24., 25., 26., 27.]]) 

循环神经网络的从0开始实现

在本节中,我们将从零开始实现一个基于字符级循环神经网络的语言模型,并在周杰伦专辑歌词数据集上训练一个模型来进行歌词创作。首先,我们读取周杰伦专辑歌词数据集:

import time
import math
import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as F
import sys
import d2lzh_pytorch as d2l
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

(corpus_indices, char_to_idx, idx_to_char, vocab_size) = d2l.load_data_jay_lyrics()
one-hot向量

为了将词表示成向量输入到神经网络,一个简单的办法是使用one-hot向量。假设词典中不同字符的数量为N(即词典大小 vocab_size),每个字符已经同一个从0到N1的连续整数值索引一一对应。如果一个字符的索引是整数i, 那么我们创建一个全0的长为N的向量,并将其位置为i的元素设成1。该向量就是对原字符的one-hot向量。下面分别展示了索引为0和2的one-hot向量,向量长度等于词典大小。

def one_hot(x, n_class, dtype=torch.float32): 
    # X shape: (batch), output shape: (batch, n_class)
    x = x.long()
    res = torch.zeros(x.shape[0], n_class, dtype=dtype, device=x.device)
    res.scatter_(1, x.view(-1, 1), 1)
    return res

x = torch.tensor([0, 2])
one_hot(x, vocab_size)

我们每次采样的小批量的形状是 ( 批量大小 , 时间步数 ) (\text{批量大小}, \text{时间步数}) (批量大小,时间步数)。下面的函数将这样的小批量变换成数个可以输入进网络的形状为 ( 批量大小 , 词典大小 ) (\text{批量大小}, \text{词典大小}) (批量大小,词典大小) 的矩阵,矩阵个数等于时间步数。也就是说,时间步 t t t 的输入为 X t ∈ R n × d X_t \in \mathbb{R}^{n \times d} XtRn×d,其中 n n n 为批量大小, d d d 为输入个数,即one-hot向量长度(词典大小)。

def to_onehot(X, n_class):  
    # X shape: (batch, seq_len), output: seq_len elements of (batch, n_class)
    return [one_hot(X[:, i], n_class) for i in range(X.shape[1])]

X = torch.arange(10).view(2, 5)
inputs = to_onehot(X, vocab_size)
print(len(inputs), inputs[0].shape)
初始化模型参数

接下来,我们初始化模型参数。隐藏单元个数 num_hiddens是一个超参数。

num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
print('will use', device)

def get_params():
    def _one(shape):
        ts = torch.tensor(np.random.normal(0, 0.01, size=shape), device=device, dtype=torch.float32)
        return torch.nn.Parameter(ts, requires_grad=True)

    # 隐藏层参数
    W_xh = _one((num_inputs, num_hiddens))
    W_hh = _one((num_hiddens, num_hiddens))
    b_h = torch.nn.Parameter(torch.zeros(num_hiddens, device=device, requires_grad=True))
    # 输出层参数
    W_hq = _one((num_hiddens, num_outputs))
    b_q = torch.nn.Parameter(torch.zeros(num_outputs, device=device, requires_grad=True))
    return nn.ParameterList([W_xh, W_hh, b_h, W_hq, b_q])
定义模型

我们根据循环神经网络的计算表达式实现该模型。首先定义 init_rnn_state函数来返回初始化的隐藏状态。它返回由一个形状为(批量大小, 隐藏单元个数)的值为0的 NDArray组成的元组。使用元组是为了更便于处理隐藏状态含有多个 NDArray的情况。

def init_rnn_state(batch_size, num_hiddens, device):
    return (torch.zeros((batch_size, num_hiddens), device=device), )

下面的 rnn函数定义了在一个时间步里如何计算隐藏状态和输出。这里的激活函数使用了tanh函数。3.8节(多层感知机)中介绍过,当元素在实数域上均匀分布时,tanh函数值的均值为0。

def rnn(inputs, state, params):
    # inputs和outputs皆为num_steps个形状为(batch_size, vocab_size)的矩阵
    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 outputs, (H,)

做个简单的测试来观察输出结果的个数(时间步数),以及第一个时间步的输出层输出的形状和隐藏状态的形状。

state = init_rnn_state(X.shape[0], num_hiddens, device)
inputs = to_onehot(X.to(device), vocab_size)
params = get_params()
outputs, state_new = rnn(inputs, state, params)
print(len(outputs), outputs[0].shape, state_new[0].shape) 

#output:5 torch.Size([2, 1027]) torch.Size([2, 256])
定义预测函数

以下函数基于前缀 prefix(含有数个字符的字符串)来预测接下来的 num_chars个字符。这个函数稍显复杂,其中我们将循环神经单元 rnn设置成了函数参数,这样在后面小节介绍其他循环神经网络时能重复使用这个函数。

def predict_rnn(prefix, num_chars, rnn, params, init_rnn_state,
                num_hiddens, vocab_size, device, idx_to_char, char_to_idx):
    state = init_rnn_state(1, num_hiddens, device)
    output = [char_to_idx[prefix[0]]]
    for t in range(num_chars + len(prefix) - 1):
        # 将上一时间步的输出作为当前时间步的输入
        X = to_onehot(torch.tensor([[output[-1]]], device=device), vocab_size)
        # 计算输出和更新隐藏状态
        (Y, state) = rnn(X, state, params)
        # 下一个时间步的输入是prefix里的字符或者当前的最佳预测字符
        if t < len(prefix) - 1:
            output.append(char_to_idx[prefix[t + 1]])
        else:
            output.append(int(Y[0].argmax(dim=1).item()))
    return ''.join([idx_to_char[i] for i in output])

我们先测试一下 predict_rnn函数。我们将根据前缀“分开”创作长度为10个字符(不考虑前缀长度)的一段歌词。因为模型参数为随机值,所以预测结果也是随机的。

predict_rnn('分开', 10, rnn, params, init_rnn_state, num_hiddens, vocab_size,
            device, idx_to_char, char_to_idx)

#'分开西圈绪升王凝瓜必客映'
裁剪梯度

循环神经网络中较容易出现梯度衰减或梯度爆炸。我们会在6.6节(通过时间反向传播)中解释原因。为了应对梯度爆炸,我们可以裁剪梯度(clip gradient)。假设我们把所有模型参数梯度的元素拼接成一个向量 (g),并设裁剪的阈值是 (θ)。裁剪后的梯度

$\min\left(\frac{\|g\|}{\theta}, 1\right)g$

的L2范数不超过 θ \theta θ

def grad_clipping(params, theta, device):
    norm = torch.tensor([0.0], device=device)
    for param in params:
        norm += (param.grad.data ** 2).sum()
    norm = norm.sqrt().item()
    if norm > theta:
        for param in params:
            param.grad.data *= (theta / norm)
困惑度

我们通常使用困惑度(perplexity)来评价语言模型的好坏。回忆一下3.4节(softmax回归)中交叉熵损失函数的定义。困惑度是对交叉熵损失函数做指数运算后得到的值。特别地,

  • 最佳情况下,模型总是把标签类别的概率预测为1,此时困惑度为1;
  • 最坏情况下,模型总是把标签类别的概率预测为0,此时困惑度为正无穷;
  • 基线情况下,模型总是预测所有类别的概率都相同,此时困惑度为类别个数。

显然,任何一个有效模型的困惑度必须小于类别个数。在本例中,困惑度必须小于词典大小 vocab_size

定义模型训练函数

跟之前章节的模型训练函数相比,这里的模型训练函数有以下几点不同:

  1. 使用困惑度评价模型。
  2. 在迭代模型参数前裁剪梯度。
  3. 对时序数据采用不同采样方法将导致隐藏状态初始化的不同。相关讨论可参考6.3节(语言模型数据集(周杰伦专辑歌词))。

另外,考虑到后面将介绍的其他循环神经网络,为了更通用,这里的函数实现更长一些。

def train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                          vocab_size, device, corpus_indices, idx_to_char,
                          char_to_idx, is_random_iter, num_epochs, num_steps,
                          lr, clipping_theta, batch_size, pred_period,
                          pred_len, prefixes):
    if is_random_iter:
        data_iter_fn = d2l.data_iter_random
    else:
        data_iter_fn = d2l.data_iter_consecutive
    params = get_params()
    loss = nn.CrossEntropyLoss()

    for epoch in range(num_epochs):
        if not is_random_iter:  # 如使用相邻采样,在epoch开始时初始化隐藏状态
            state = init_rnn_state(batch_size, num_hiddens, device)
        l_sum, n, start = 0.0, 0, time.time()
        data_iter = data_iter_fn(corpus_indices, batch_size, num_steps, device)
        for X, Y in data_iter:
            if is_random_iter:  # 如使用随机采样,在每个小批量更新前初始化隐藏状态
                state = init_rnn_state(batch_size, num_hiddens, device)
            else:  
            # 否则需要使用detach函数从计算图分离隐藏状态, 这是为了
            # 使模型参数的梯度计算只依赖一次迭代读取的小批量序列(防止梯度计算开销太大)
                for s in state:
                    s.detach_()

            inputs = to_onehot(X, vocab_size)
            # outputs有num_steps个形状为(batch_size, vocab_size)的矩阵
            (outputs, state) = rnn(inputs, state, params)
            # 拼接之后形状为(num_steps * batch_size, vocab_size)
            outputs = torch.cat(outputs, dim=0)
            # Y的形状是(batch_size, num_steps),转置后再变成长度为
            # batch * num_steps 的向量,这样跟输出的行一一对应
            y = torch.transpose(Y, 0, 1).contiguous().view(-1)
            # 使用交叉熵损失计算平均分类误差
            l = loss(outputs, y.long())

            # 梯度清0
            if params[0].grad is not None:
                for param in params:
                    param.grad.data.zero_()
            l.backward()
            grad_clipping(params, clipping_theta, device)  # 裁剪梯度
            d2l.sgd(params, lr, 1)  # 因为误差已经取过均值,梯度不用再做平均
            l_sum += l.item() * y.shape[0]
            n += y.shape[0]

        if (epoch + 1) % pred_period == 0:
            print('epoch %d, perplexity %f, time %.2f sec' % (
                epoch + 1, math.exp(l_sum / n), time.time() - start))
            for prefix in prefixes:
                print(' -', predict_rnn(prefix, pred_len, rnn, params, init_rnn_state,
                    num_hiddens, vocab_size, device, idx_to_char, char_to_idx))
训练模型并创作歌词

现在我们可以训练模型了。首先,设置模型超参数。我们将根据前缀“分开”和“不分开”分别创作长度为50个字符(不考虑前缀长度)的一段歌词。我们每过50个迭代周期便根据当前训练的模型创作一段歌词。

num_epochs, num_steps, batch_size, lr, clipping_theta = 250, 35, 32, 1e2, 1e-2
pred_period, pred_len, prefixes = 50, 50, ['分开', '不分开']

下面采用随机采样训练模型并创作歌词。

train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                      vocab_size, device, corpus_indices, idx_to_char,
                      char_to_idx, True, num_epochs, num_steps, lr,
                      clipping_theta, batch_size, pred_period, pred_len,
                      prefixes)

#output
epoch 50, perplexity 59.514416, time 0.11 sec
 - 分开 我想要这 我想了空 我想了空 我想了空 我想了空 我想了空 我想了空 我想了空 我想了空 我想了空
 - 不分开 我不要这 全使了双 我想了这 我想了空 我想了空 我想了空 我想了空 我想了空 我想了空 我想了空
epoch 100, perplexity 6.801417, time 0.11 sec
 - 分开 我说的这样笑 想你都 不着我 我想就这样牵 你你的回不笑多难的  它在云实 有一条事 全你了空  
 - 不分开觉 你已经离开我 不知不觉 我跟好这节活 我该好好生活 不知不觉 你跟了离开我 不知不觉 我跟好这节
epoch 150, perplexity 2.063730, time 0.16 sec
 - 分开 我有到这样牵着你的手不放开 爱可不可以简简单单没有伤  古有你烦 我有多烦恼向 你知带悄 回我的外
 - 不分开觉 你已经很个我 不知不觉 我跟了这节奏 后知后觉 又过了一个秋 后哼哈兮 快使用双截棍 哼哼哈兮 
epoch 200, perplexity 1.300031, time 0.11 sec
 - 分开 我想要这样牵着你的手不放开 爱能不能够永远单甜没有伤害 你 靠着我的肩膀 你 在我胸口睡著 像这样
 - 不分开觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 又过了一个秋 后知后觉 我该好好生活 我该好好生
epoch 250, perplexity 1.164455, time 0.11 sec
 - 分开 我有一这样布 对你依依不舍 连隔壁邻居都猜到我现在的感受 河边的风 在吹着头发飘动 牵着你的手 一
 - 不分开觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 又过了一个秋 后知后觉 我该好好生活 我该好好生

完整代码

import time
import math
import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as F
import sys
import d2lzh_pytorch as d2l
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

(corpus_indices, char_to_idx, idx_to_char, vocab_size) = d2l.load_data_jay_lyrics()

def one_hot(x, n_class, dtype=torch.float32):  # 将输入向量编码为独热向量表示
    # X shape: (batch), output shape: (batch, n_class)
    x = x.long()  # 将输入向量 x 转换为长整型(整数)类型
    res = torch.zeros(x.shape[0], n_class, dtype=dtype, device=x.device)  # 创建一个形状为 (batch, n_class) 的全零张量 res,其中 batch 是 x 的长度,n_class 是类别数量。
                                                                            # 这个张量会被放置在与输入张量 x 相同的设备上,并使用指定的数据类型 dtype。
    res.scatter_(1, x.view(-1, 1), 1)  # 使用输入向量 x 的值进行填充,将对应位置的元素置为 1
    return res

x = torch.tensor([0, 2])
print(one_hot(x, vocab_size))

def to_onehot(X, n_class):  # 用于将输入的序列转换为独热向量表示
    # X shape: (batch, seq_len), output: seq_len elements of (batch, n_class)
    return [one_hot(X[:, i], n_class) for i in range(X.shape[1])]

X = torch.arange(10).view(2, 5)
# inputs = to_onehot(X, vocab_size)
# print(len(inputs), inputs[0].shape)

#初始化模型参数
num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
print('using fu*king ', device)

def get_params():  # 获取模型参数
    def _one(shape):
        ts = torch.tensor(np.random.normal(0, 0.01, size=shape), device=device, dtype=torch.float32)
        return torch.nn.Parameter(ts, requires_grad=True)  # 将张量 ts 转换为一个可学习的参数,使用 torch.nn.Parameter 进行封装,并设置 requires_grad=True,以便在训练过程中计算梯度。

    # 隐藏层参数
    W_xh = _one((num_inputs, num_hiddens))  # 输入到隐藏层的权重矩阵
    W_hh = _one((num_hiddens, num_hiddens))   # 隐藏层到隐藏层自身的权重矩阵
    b_h = torch.nn.Parameter(torch.zeros(num_hiddens, device=device, requires_grad=True))
    # 输出层参数
    W_hq = _one((num_hiddens, num_outputs))
    b_q = torch.nn.Parameter(torch.zeros(num_outputs, device=device, requires_grad=True))   # 隐藏层的偏置向量
    return nn.ParameterList([W_xh, W_hh, b_h, W_hq, b_q])  # 将所有的参数放入一个 nn.ParameterList 中,并返回这个参数列表对象作为函数的结果。这样做可以方便地管理模型的参数,使其可以被优化器更新

def init_rnn_state(batch_size, num_hiddens, device):  # 用于初始化RNN的隐藏状态
    return (torch.zeros((batch_size, num_hiddens), device=device), )

def rnn(inputs, state, params):  # inputs 是一个包含多个输入的列表,state 是一个包含初始隐藏状态的元组,params 是一个包含模型参数的列表
    # inputs和outputs皆为num_steps个形状为(batch_size, vocab_size)的矩阵
    W_xh, W_hh, b_h, W_hq, b_q = params  # 将传入的参数 params 解包赋值给对应的变量
    H, = state
    outputs = []
    for X in inputs:
        H = torch.tanh(torch.matmul(X, W_xh) + torch.matmul(H, W_hh) + b_h)  # 使用 torch.matmul 函数计算当前输入 X 与输入到隐藏层的权重矩阵 W_xh 的乘积,以及当前隐藏状态 H 与隐藏层自身的权重矩阵 W_hh 的乘积。然后,将这两个结果相加,并加上隐藏层的偏置向量 b_h,再通过 torch.tanh 函数进行激活
        Y = torch.matmul(H, W_hq) + b_q  # 根据更新后的隐藏状态 H 计算当前的输出 Y。使用 torch.matmul 函数计算隐藏状态 H 与隐藏层到输出层的权重矩阵 W_hq 的乘积,并加上输出层的偏置向量 b_q
        outputs.append(Y)
    return outputs, (H,)

state = init_rnn_state(X.shape[0], num_hiddens, device)
inputs = to_onehot(X.to(device), vocab_size)
params = get_params()
outputs, state_new =rnn(inputs, state, params)
# print(len(outputs), outputs[0].shape, state_new[0].shape)

def predict_rnn(prefix, num_chars, rnn, params, init_rnn_state,  # 使用训练好的RNN模型生成文本
                num_hiddens, vocab_size, device, idx_to_char, char_to_idx):
        # prefix 表示生成文本的前缀,num_chars 表示要生成的文本字符数量,rnn 表示训练好的 RNN 模型,params 表示模型的参数,
        # init_rnn_state 表示初始化隐藏状态的函数,num_hiddens 表示隐藏单元的数量,vocab_size 表示词汇表的大小,device 表示设备类型,
        # idx_to_char 表示索引到字符的映射字典,char_to_idx 表示字符到索引的映射字典。
    state = init_rnn_state(1, num_hiddens, device)  # 初始化隐藏状态 state,批量大小为 1
    output = [char_to_idx[prefix[0]]]  # 将前缀的第一个字符转换为索引,并作为输出的初始值
    for t in range(num_chars + len(prefix) - 1):  # 迭代生成指定数量的字符,包括前缀的长度
        # 将上一时间步的输出作为当前时间步的输入
        X = to_onehot(torch.tensor([[output[-1]]], device=device), vocab_size)
        # 计算输出和更新隐藏状态
        (Y, state) = rnn(X, state, params)
        # 下一个时间步的输入是prefix里的字符或者当前的最佳预测字符
        if t < len(prefix) - 1:  # 如果仍在生成前缀部分的字符
            output.append(char_to_idx[prefix[t + 1]])
        else:  # 开始根据模型预测生成字符
            output.append(int(Y[0].argmax(dim=1).item()))  # 将预测输出中概率最高的字符索引转换为整数,并添加到输出列表中
    return ''.join([idx_to_char[i] for i in output])

# print(predict_rnn('分开', 10, rnn, params, init_rnn_state, num_hiddens, vocab_size,
#             device, idx_to_char, char_to_idx))

def grad_clipping(params, theta, device):  # 裁剪梯度,防止梯度爆炸
    norm = torch.tensor([0.0], device=device)  # 创建一个初始值为0的张量 norm,用于计算梯度的范数。这个张量会被放置在指定的设备上
    for param in params:  # 遍历模型的参数列表 params
        norm += (param.grad.data ** 2).sum()  # 将当前参数的梯度平方的和累加到 norm 中
    norm = norm.sqrt().item()  # 对 norm 进行开方操作,并将结果转换为标量值
    if norm > theta:
        for param in params:
            param.grad.data *= (theta / norm)  # 将参数的梯度值按照比例缩放,使其范数不超过阈值 theta

def train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                          vocab_size, device, corpus_indices, idx_to_char,
                          char_to_idx, is_random_iter, num_epochs, num_steps,
                          lr, clipping_theta, batch_size, pred_period,
                          pred_len, prefixes):
    if is_random_iter:  # 是否随机采样
        data_iter_fn = d2l.data_iter_random
    else:
        data_iter_fn = d2l.data_iter_consecutive
    params = get_params()
    loss = nn.CrossEntropyLoss()

    for epoch in range(num_epochs):
        if not is_random_iter:  # 如使用相邻采样,在epoch开始时初始化隐藏状态
            state = init_rnn_state(batch_size, num_hiddens, device)
        l_sum, n, start = 0.0, 0, time.time()
        data_iter = data_iter_fn(corpus_indices, batch_size, num_steps, device)
        for X, Y in data_iter:
            if is_random_iter:  # 如使用随机采样,在每个小批量更新前初始化隐藏状态
                state = init_rnn_state(batch_size, num_hiddens, device)
            else:
            # 否则需要使用detach函数从计算图分离隐藏状态, 这是为了
            # 使模型参数的梯度计算只依赖一次迭代读取的小批量序列(防止梯度计算开销太大)
                for s in state:
                    s.detach_()

            inputs = to_onehot(X, vocab_size)
            # outputs有num_steps个形状为(batch_size, vocab_size)的矩阵
            (outputs, state) = rnn(inputs, state, params)
            # 拼接之后形状为(num_steps * batch_size, vocab_size)
            outputs = torch.cat(outputs, dim=0)
            # Y的形状是(batch_size, num_steps),转置后再变成长度为
            # batch * num_steps 的向量,这样跟输出的行一一对应
            y = torch.transpose(Y, 0, 1).contiguous().view(-1)
            # 使用交叉熵损失计算平均分类误差
            l = loss(outputs, y.long())

            # 梯度清0
            if params[0].grad is not None:
                for param in params:
                    param.grad.data.zero_()
            l.backward()
            grad_clipping(params, clipping_theta, device)  # 裁剪梯度
            d2l.sgd(params, lr, 1)  # 因为误差已经取过均值,梯度不用再做平均
            l_sum += l.item() * y.shape[0]
            n += y.shape[0]

        if (epoch + 1) % pred_period == 0:
            print('epoch %d, perplexity %f, time %.2f sec' % (
                epoch + 1, math.exp(l_sum / n), time.time() - start))
            for prefix in prefixes:
                print(' -', predict_rnn(prefix, pred_len, rnn, params, init_rnn_state,
                    num_hiddens, vocab_size, device, idx_to_char, char_to_idx))

num_epochs, num_steps, batch_size, lr, clipping_theta = 250, 35, 32, 1e2, 1e-2
pred_period, pred_len, prefixes = 50, 50, ['泽', '林']

train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                      vocab_size, device, corpus_indices, idx_to_char,
                      char_to_idx, True, num_epochs, num_steps, lr,
                      clipping_theta, batch_size, pred_period, pred_len,
                      prefixes)

train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                      vocab_size, device, corpus_indices, idx_to_char,
                      char_to_idx, False, num_epochs, num_steps, lr,
                      clipping_theta, batch_size, pred_period, pred_len,
                      prefixes)

循环神经网络的简洁实现

本节将使用PyTorch来更简洁地实现基于循环神经网络的语言模型。首先,我们读取周杰伦专辑歌词数据集

import time
import math
import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as F

import sys
import d2lzh_pytorch as d2l
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

(corpus_indices, char_to_idx, idx_to_char, vocab_size) = d2l.load_data_jay_lyrics()
定义模型

PyTorch中的 nn模块提供了循环神经网络的实现。下面构造一个含单隐藏层、隐藏单元个数为256的循环神经网络层 rnn_layer

num_hiddens = 256
# rnn_layer = nn.LSTM(input_size=vocab_size, hidden_size=num_hiddens) # 已测试
rnn_layer = nn.RNN(input_size=vocab_size, hidden_size=num_hiddens)

与上一节中实现的循环神经网络不同,这里 rnn_layer的输入形状为(时间步数, 批量大小, 输入个数)。其中输入个数即one-hot向量长度(词典大小)。此外,rnn_layer作为 nn.RNN实例,在前向计算后会分别返回输出和隐藏状态h,其中输出指的是隐藏层在各个时间步上计算并输出的隐藏状态,它们通常作为后续输出层的输入。需要强调的是,该“输出”本身并不涉及输出层计算,形状为(时间步数, 批量大小, 隐藏单元个数)。而 nn.RNN实例在前向计算返回的隐藏状态指的是隐藏层在最后时间步的隐藏状态:当隐藏层有多层时,每一层的隐藏状态都会记录在该变量中;对于像长短期记忆(LSTM),隐藏状态是一个元组(h, c),即hidden state和cell state。我们会在本章的后面介绍长短期记忆和深度循环神经网络。关于循环神经网络(以LSTM为例)的输出,可以参考下图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AorI3FX7-1687830319114)(image/手动深度学习/1686964914884.png)]

来看看我们的例子,输出形状为(时间步数, 批量大小, 隐藏单元个数),隐藏状态h的形状为(层数, 批量大小, 隐藏单元个数)

num_steps = 35
batch_size = 2
state = None
X = torch.rand(num_steps, batch_size, vocab_size)
Y, state_new = rnn_layer(X, state)
print(Y.shape, len(state_new), state_new[0].shape)# torch.Size([35, 2, 256]) 1 torch.Size([2, 256])

接下来我们继承 Module类来定义一个完整的循环神经网络。它首先将输入数据使用one-hot向量表示后输入到 rnn_layer中,然后使用全连接输出层得到输出。输出个数等于词典大小 vocab_size

class RNNModel(nn.Module):
    def __init__(self, rnn_layer, vocab_size):
        super(RNNModel, self).__init__()
        self.rnn = rnn_layer
        self.hidden_size = rnn_layer.hidden_size * (2 if rnn_layer.bidirectional else 1) 
        self.vocab_size = vocab_size
        self.dense = nn.Linear(self.hidden_size, vocab_size)
        self.state = None

    def forward(self, inputs, state): # inputs: (batch, seq_len)
        # 获取one-hot向量表示
        X = d2l.to_onehot(inputs, self.vocab_size) # X是个list
        Y, self.state = self.rnn(torch.stack(X), state)
        # 全连接层会首先将Y的形状变成(num_steps * batch_size, num_hiddens),它的输出
        # 形状为(num_steps * batch_size, vocab_size)
        output = self.dense(Y.view(-1, Y.shape[-1]))
        return output, self.state
训练模型

同上一节一样,下面定义一个预测函数。这里的实现区别在于前向计算和初始化隐藏状态的函数接口。

def predict_rnn_pytorch(prefix, num_chars, model, vocab_size, device, idx_to_char,
                      char_to_idx):
    state = None
    output = [char_to_idx[prefix[0]]] # output会记录prefix加上输出
    for t in range(num_chars + len(prefix) - 1):
        X = torch.tensor([output[-1]], device=device).view(1, 1)
        if state is not None:
            if isinstance(state, tuple): # LSTM, state:(h, c)  
                state = (state[0].to(device), state[1].to(device))
            else:   
                state = state.to(device)

        (Y, state) = model(X, state)
        if t < len(prefix) - 1:
            output.append(char_to_idx[prefix[t + 1]])
        else:
            output.append(int(Y.argmax(dim=1).item()))
    return ''.join([idx_to_char[i] for i in output])

让我们使用权重为随机值的模型来预测一次

model = RNNModel(rnn_layer, vocab_size).to(device)
predict_rnn_pytorch('分开', 10, model, vocab_size, device, idx_to_char, char_to_idx)

接下来实现训练函数。算法同上一节的一样,但这里只使用了相邻采样来读取数据。

def train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device,
                                corpus_indices, idx_to_char, char_to_idx,
                                num_epochs, num_steps, lr, clipping_theta,
                                batch_size, pred_period, pred_len, prefixes):
    loss = nn.CrossEntropyLoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    model.to(device)
    state = None
    for epoch in range(num_epochs):
        l_sum, n, start = 0.0, 0, time.time()
        data_iter = d2l.data_iter_consecutive(corpus_indices, batch_size, num_steps, device) # 相邻采样
        for X, Y in data_iter:
            if state is not None:
                # 使用detach函数从计算图分离隐藏状态, 这是为了
                # 使模型参数的梯度计算只依赖一次迭代读取的小批量序列(防止梯度计算开销太大)
                if isinstance (state, tuple): # LSTM, state:(h, c)  
                    state = (state[0].detach(), state[1].detach())
                else:   
                    state = state.detach()

            (output, state) = model(X, state) # output: 形状为(num_steps * batch_size, vocab_size)

            # Y的形状是(batch_size, num_steps),转置后再变成长度为
            # batch * num_steps 的向量,这样跟输出的行一一对应
            y = torch.transpose(Y, 0, 1).contiguous().view(-1)
            l = loss(output, y.long())

            optimizer.zero_grad()
            l.backward()
            # 梯度裁剪
            d2l.grad_clipping(model.parameters(), clipping_theta, device)
            optimizer.step()
            l_sum += l.item() * y.shape[0]
            n += y.shape[0]

        try:
            perplexity = math.exp(l_sum / n)
        except OverflowError:
            perplexity = float('inf')
        if (epoch + 1) % pred_period == 0:
            print('epoch %d, perplexity %f, time %.2f sec' % (
                epoch + 1, perplexity, time.time() - start))
            for prefix in prefixes:
                print(' -', predict_rnn_pytorch(
                    prefix, pred_len, model, vocab_size, device, idx_to_char,
                    char_to_idx))

使用和上一节实验中一样的超参数(除了学习率)来训练模型

num_epochs, batch_size, lr, clipping_theta = 250, 32, 1e-3, 1e-2 # 注意这里的学习率设置
pred_period, pred_len, prefixes = 50, 50, ['分开', '不分开']
train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device,
                            corpus_indices, idx_to_char, char_to_idx,
                            num_epochs, num_steps, lr, clipping_theta,
                            batch_size, pred_period, pred_len, prefixes)

完整代码

import time
import math
import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as F

import sys
import d2lzh_pytorch as d2l
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

(corpus_indices, char_to_idx, idx_to_char, vocab_size) = d2l.load_data_jay_lyrics()

num_hiddens = 256
# rnn_layer = nn.LSTM(input_size=vocab_size, hidden_size=num_hiddens) # 已测试
rnn_layer = nn.RNN(input_size=vocab_size, hidden_size=num_hiddens)

num_steps = 35  # 设置序列长度为35
batch_size = 2
state = None  # 初始化隐藏状态为None
X = torch.rand(num_steps, batch_size, vocab_size)  # 生成一个随机的输入张量X
Y, state_new = rnn_layer(X, state)  # 使用RNN层处理输入X,得到输出Y和更新后的隐藏状态state_new
# print(Y.shape, len(state_new), state_new[0].shape)

class RNNModel(nn.Module):
    def __init__(self, rnn_layer, vocab_size):
        super(RNNModel, self).__init__()
        self.rnn = rnn_layer  # 设置模型的RNN层
        self.hidden_size = rnn_layer.hidden_size * (2 if rnn_layer.bidirectional else 1)  # 计算模型的隐藏状态大小
        self.vocab_size = vocab_size
        self.dense = nn.Linear(self.hidden_size, vocab_size)  # 定义一个全连接层,将隐藏状态映射到词汇表大小的输出
        self.state = None

    def forward(self, inputs, state): # inputs: (batch, seq_len)
        # 获取one-hot向量表示
        X = d2l.to_onehot(inputs, self.vocab_size) # X是个list
        Y, self.state = self.rnn(torch.stack(X), state)  # 将输入和隐藏状态传递给RNN层进行处理,得到输出和更新后的隐藏状态
        # 全连接层会首先将Y的形状变成(num_steps * batch_size, num_hiddens),它的输出
        # 形状为(num_steps * batch_size, vocab_size)
        output = self.dense(Y.view(-1, Y.shape[-1]))  # 将RNN层的输出经过全连接层得到最终的输出。
        return output, self.state  # 返回输出和更新后的隐藏状态

def predict_rnn_pytorch(prefix, num_chars, model, vocab_size, device, idx_to_char,
                      char_to_idx):
    state = None
    output = [char_to_idx[prefix[0]]] # output会记录prefix加上输出,将前缀的第一个字符的索引加入到输出列表中。
    for t in range(num_chars + len(prefix) - 1):  # 迭代生成指定数量的字符
        X = torch.tensor([output[-1]], device=device).view(1, 1)  # 将上一个生成的字符的索引转换为张量X,作为当前时间步的输入
        if state is not None:  # 如果隐藏状态不为None,则将其转移到设备上
            if isinstance(state, tuple): # LSTM, state:(h, c)
                state = (state[0].to(device), state[1].to(device))
            else:
                state = state.to(device)

        (Y, state) = model(X, state)  # 使用模型生成下一个字符的概率分布和更新后的隐藏状态
        if t < len(prefix) - 1:  #  如果当前时间步在前缀范围内,则将下一个前缀字符的索引加入到输出列表中
            output.append(char_to_idx[prefix[t + 1]])
        else:
            output.append(int(Y.argmax(dim=1).item()))  # 否则,将模型生成的下一个字符的索引加入到输出列表中
    return ''.join([idx_to_char[i] for i in output])  # 将输出列表中的字符索引转换为字符串并返回

model = RNNModel(rnn_layer, vocab_size).to(device)  # 创建RNN模型对象,并将其移动到指定设备上
# print(predict_rnn_pytorch('分开', 10, model, vocab_size, device, idx_to_char, char_to_idx))

def train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device,  # 定义训练并使用PyTorch实现的RNN模型进行文本生成的函数
                                corpus_indices, idx_to_char, char_to_idx,  # 接受模型、隐藏状态大小、词汇表大小、设备、语料库索引、索引到字符的映射、字符到索引的映射、训练轮数、序列长度、学习率、梯度裁剪阈值、批次大小、生成周期、生成长度和前缀列表作为参数
                                num_epochs, num_steps, lr, clipping_theta,
                                batch_size, pred_period, pred_len, prefixes):
    loss = nn.CrossEntropyLoss()  # 定义交叉熵损失函数
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)  # 定义优化器,使用Adam算法来更新模型参数。
    model.to(device)
    state = None  # 初始化隐藏状态为None
    for epoch in range(num_epochs):  #  迭代指定轮数的训练过程
        l_sum, n, start = 0.0, 0, time.time()
        data_iter = d2l.data_iter_consecutive(corpus_indices, batch_size, num_steps, device) # 相邻采样
        for X, Y in data_iter:  # 迭代训练数据集的每个批次
            if state is not None:
                # 使用detach函数从计算图分离隐藏状态, 这是为了
                # 使模型参数的梯度计算只依赖一次迭代读取的小批量序列(防止梯度计算开销太大)
                if isinstance (state, tuple): # LSTM, state:(h, c)
                    state = (state[0].detach(), state[1].detach())
                else:
                    state = state.detach()

            (output, state) = model(X, state) # output: 形状为(num_steps * batch_size, vocab_size)  # 使用模型生成输出和更新隐藏状态

            # Y的形状是(batch_size, num_steps),转置后再变成长度为
            # batch * num_steps 的向量,这样跟输出的行一一对应
            y = torch.transpose(Y, 0, 1).contiguous().view(-1)  # 将目标序列转置并展平,用于计算损失。
            l = loss(output, y.long())

            optimizer.zero_grad()
            l.backward()
            # 梯度裁剪
            d2l.grad_clipping(model.parameters(), clipping_theta, device)
            optimizer.step()  #  更新模型参数
            l_sum += l.item() * y.shape[0]  # 累加损失
            n += y.shape[0]  # 累加样本数量

        try:
            perplexity = math.exp(l_sum / n)  # 计算困惑度
        except OverflowError:
            perplexity = float('inf')
        if (epoch + 1) % pred_period == 0:  # 如果满足生成周期,则进行文本生成并打印结果
            print('epoch %d, perplexity %f, time %.2f sec' % (
                epoch + 1, perplexity, time.time() - start))
            for prefix in prefixes:  #  针对每个前缀进行文本生成并打印结果
                print(' -', predict_rnn_pytorch(
                    prefix, pred_len, model, vocab_size, device, idx_to_char,
                    char_to_idx))

num_epochs, batch_size, lr, clipping_theta = 250, 32, 1e-3, 1e-2 # 注意这里的学习率设置
pred_period, pred_len, prefixes = 50, 50, ['分开', '不分开']
train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device,
                            corpus_indices, idx_to_char, char_to_idx,
                            num_epochs, num_steps, lr, clipping_theta,
                            batch_size, pred_period, pred_len, prefixes)

通过时间的反向传播*

在前面两节中,如果不裁剪梯度,模型将无法正常训练。为了深刻理解这一现象,本节将介绍循环神经网络中梯度的计算和存储方法,即通过时间反向传播(back-propagation through time)。

我们在3.14节(正向传播、反向传播和计算图)中介绍了神经网络中梯度计算与存储的一般思路,并强调正向传播和反向传播相互依赖。正向传播在循环神经网络中比较直观,而通过时间反向传播其实是反向传播在循环神经网络中的具体应用。我们需要将循环神经网络按时间步展开,从而得到模型变量和参数之间的依赖关系,并依据链式法则应用反向传播计算并存储梯度。

定义模型

简单起见,我们考虑一个无偏差项的循环神经网络,且激活函数为恒等映射( ϕ ( x ) = x \phi(x) = x ϕ(x)=x)。设时间步 t t t 的输入为单样本 x t ∈ R d x_t \in \mathbb{R}^d xtRd,标签为 y t y_t yt,那么隐藏状态 h t ∈ R h h_t \in \mathbb{R}^h htRh 的计算表达式为

h t = W h x x t + W h h h t − 1 h_t = W_{hx} x_t + W_{hh} h_{t-1} ht=Whxxt+Whhht1,

其中 W h x ∈ R h × d W_{hx} \in \mathbb{R}^{h \times d} WhxRh×d W h h ∈ R h × h W_{hh} \in \mathbb{R}^{h \times h} WhhRh×h 是隐藏层权重参数。设输出层权重参数 W q h ∈ R q × h W_{qh} \in \mathbb{R}^{q \times h} WqhRq×h,时间步 t t t 的输出层变量 o t ∈ R q o_t \in \mathbb{R}^q otRq 计算为

o t = W q h h t o_t = W_{qh} h_t ot=Wqhht.

设时间步 t t t 的损失为 ℓ ( o t , y t ) \ell(o_t, y_t) (ot,yt)。时间步数为 T T T 的损失函数 L L L 定义为

L = 1 T ∑ t = 1 T ℓ ( o t , y t ) L = \frac{1}{T} \sum_{t=1}^T \ell(o_t, y_t) L=T1t=1T(ot,yt).

我们将 L L L 称为有关给定时间步的数据样本的目标函数,并在本节后续讨论中简称为目标函数。

模型计算图

为了可视化循环神经网络中模型变量和参数在计算中的依赖关系,我们可以绘制模型计算图。如下所示,图中展示了时间步 3 的隐藏状态 h 3 h_3 h3 的计算依赖于模型参数 W h x W_{hx} Whx W h h W_{hh} Whh、上一时间步隐藏状态 h 2 h_2 h2,以及当前时间步输入 x 3 x_3 x3

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xbvYrQis-1687830319116)(image/手动深度学习/1686998176295.png)]

图6.3 时间步数为3的循环神经网络模型计算中的依赖关系。方框代表变量(无阴影)或参数(有阴影),圆圈代表运算符

方法

刚刚提到,图6.3中的模型的参数是 W h x W_{hx} Whx, W h h W_{hh} Whh W q h W_{qh} Wqh。与3.14节(正向传播、反向传播和计算图)中的类似,训练模型通常需要模型参数的梯度 ∂ L ∂ W h x \frac{\partial L}{\partial W_{hx}} WhxL, ∂ L ∂ W h h \frac{\partial L}{\partial W_{hh}} WhhL ∂ L ∂ W q h \frac{\partial L}{\partial W_{qh}} WqhL。根据图6.3中的依赖关系,我们可以按照其中箭头所指的反方向依次计算并存储梯度。为了表述方便,我们依然采用3.14节中表达链式法则的运算符prod。

首先,目标函数有关各时间步输出层变量的梯度 ∂ L ∂ o t ∈ R q \frac{\partial L}{\partial o_t} \in \mathbb{R}^q otLRq 很容易计算:

∂ L ∂ o t = ∂ ℓ ( o t , y t ) ∂ o t T ⋅ ∂ o t ∂ L \frac{\partial L}{\partial o_t} = \frac{\partial \ell(o_t, y_t)}{\partial o_t}^T \cdot \frac{\partial o_t}{\partial L} otL=ot(ot,yt)TLot.

下面,我们可以计算目标函数有关模型参数 W q h W_{qh} Wqh 的梯度 ∂ L ∂ W q h ∈ R q × h \frac{\partial L}{\partial W_{qh}} \in \mathbb{R}^{q \times h} WqhLRq×h。根据图6.3, L L L 通过 o 1 , … , o T o_1, \ldots, o_T o1,,oT 依赖 W q h W_{qh} Wqh。依据链式法则,

∂ L ∂ W q h = ∑ t = 1 T prod ( ∂ L ∂ o t , ∂ o t ∂ W q h ) = ∑ t = 1 T ∂ L ∂ o t h t T \frac{\partial L}{\partial W_{qh}} = \sum_{t=1}^T \text{prod}(\frac{\partial L}{\partial o_t}, \frac{\partial o_t}{\partial W_{qh}}) = \sum_{t=1}^T \frac{\partial L}{\partial o_t} h_t^T WqhL=t=1Tprod(otL,Wqhot)=t=1TotLhtT.

其次,我们注意到隐藏状态之间也存在依赖关系。在图6.3中, L L L 只通过 o T o_T oT 依赖最终时间步 T T T 的隐藏状态 h T h_T hT。因此,我们先计算目标函数有关最终时间步隐藏状态的梯度 ∂ L ∂ h T ∈ R h \frac{\partial L}{\partial h_T} \in \mathbb{R}^h hTLRh。依据链式法则,我们得到

∂ L ∂ h T = prod ( ∂ L ∂ o T , ∂ o T ∂ h T ) = W q h T ∂ L ∂ o T \frac{\partial L}{\partial h_T} = \text{prod}(\frac{\partial L}{\partial o_T}, \frac{\partial o_T}{\partial h_T}) = W_{qh}^T \frac{\partial L}{\partial o_T} hTL=prod(oTL,hToT)=WqhToTL.

接下来对于时间步 t < T t < T t<T,在图6.3中, L L L 通过 h t + 1 h_{t+1} ht+1 o t o_t ot 依赖 h t h_t ht。依据链式法则,目标函数有关时间步 t < T t < T t<T 的隐藏状态的梯度 ∂ L ∂ h t ∈ R h \frac{\partial L}{\partial h_t} \in \mathbb{R}^h htLRh 需要按照时间步从大到小依次计算:

∂ L ∂ h t = prod ( ∂ L ∂ h t + 1 , ∂ h t + 1 ∂ h t ) + prod ( ∂ L ∂ o t , ∂ o t ∂ h t ) = W h h T ∂ L ∂ h t + 1 + W q h T ∂ L ∂ o t \frac{\partial L}{\partial h_t} = \text{prod}(\frac{\partial L}{\partial h_{t+1}}, \frac{\partial h_{t+1}}{\partial h_t}) + \text{prod}(\frac{\partial L}{\partial o_t}, \frac{\partial o_t}{\partial h_t}) = W_{hh}^T \frac{\partial L}{\partial h_{t+1}} + W_{qh}^T \frac{\partial L}{\partial o_t} htL=prod(ht+1L,htht+1)+prod(otL,htot)=WhhTht+1L+WqhTotL.

将上面的递归公式展开,对任意时间步 1 ≤ t ≤ T 1 \leq t \leq T 1tT,我们可以得到目标函数有关隐藏状态梯度的通项公式

∂ L ∂ h t = ∑ i = t T ( W h h T ) T − i W q h T ∂ L ∂ o T + t − i \frac{\partial L}{\partial h_t} = \sum_{i=t}^T (W_{hh}^T)^{T-i} W_{qh}^T \frac{\partial L}{\partial o_{T+t-i}} htL=i=tT(WhhT)TiWqhToT+tiL.

由上式中的指数项可见,当时间步数 T T T 较大或者时间步 t t t 较小时,目标函数有关隐藏状态的梯度较容易出现衰减和爆炸。这也会影响其他包含 ∂ L ∂ h t \frac{\partial L}{\partial h_t} htL 项的梯度,例如隐藏层中模型参数的梯度 ∂ L ∂ W h x ∈ R h × d \frac{\partial L}{\partial W_{hx}} \in \mathbb{R}^{h \times d} WhxLRh×d ∂ L ∂ W h h ∈ R h × h \frac{\partial L}{\partial W_{hh}} \in \mathbb{R}^{h \times h} WhhLRh×h。在图6.3中, L L L 通过 h 1 , … , h T h_1, \ldots, h_T h1,,hT 依赖这些模型参数。依据链式法则,我们有

∂ L ∂ W h x ∂ L ∂ W h h = ∑ t = 1 T prod ( ∂ L ∂ h t , ∂ h t ∂ W h x ) = ∑ t = 1 T ∂ L ∂ h t x t T , ∂ L ∂ W h h = ∑ t = 1 T prod ( ∂ L ∂ h t , ∂ h t ∂ W h h ) = ∑ t = 1 T ∂ L ∂ h t h t − 1 T \frac{\partial L}{\partial W_{hx}} \frac{\partial L}{\partial W_{hh}} = \sum_{t=1}^T \text{prod}(\frac{\partial L}{\partial h_t}, \frac{\partial h_t}{\partial W_{hx}}) = \sum_{t=1}^T \frac{\partial L}{\partial h_t} x_t^T, \quad \frac{\partial L}{\partial W_{hh}} = \sum_{t=1}^T \text{prod}(\frac{\partial L}{\partial h_t}, \frac{\partial h_t}{\partial W_{hh}}) = \sum_{t=1}^T \frac{\partial L}{\partial h_t} h_{t-1}^T WhxLWhhL=t=1Tprod(htL,Whxht)=t=1ThtLxtT,WhhL=t=1Tprod(htL,Whhht)=t=1ThtLht1T.

我们已在3.14节里解释过,每次迭代中,我们在依次计算完以上各个梯度后,会将它们存储起来,从而避免重复计算。例如,由于隐藏状态梯度 ∂ L ∂ h t \frac{\partial L}{\partial h_t} htL 被计算和存储,之后的模型参数梯度 ∂ L ∂ W h x \frac{\partial L}{\partial W_{hx}} WhxL ∂ L ∂ W h h \frac{\partial L}{\partial W_{hh}} WhhL 的计算可以直接读取 ∂ L ∂ h t \frac{\partial L}{\partial h_t} htL 的值,而无须重复计算它们。此外,反向传播中的梯度计算可能会依赖变量的当前值。它们正是通过正向传播计算出来的。举例来说,参数梯度 ∂ L ∂ W h h \frac{\partial L}{\partial W_{hh}} WhhL 的计算需要依赖隐藏状态在时间步 t = 0 , … , T − 1 t=0, \ldots, T-1 t=0,,T1 的当前值 h t h_t ht h 0 h_0 h0 是初始化得到的)。这些值是通过从输入层到输出层的正向传播计算并存储得到的。

门控循环单元

上一节介绍了循环神经网络中的梯度计算方法。我们发现,当时间步数较大或者时间步较小时,循环神经网络的梯度较容易出现衰减或爆炸。虽然裁剪梯度可以应对梯度爆炸,但无法解决梯度衰减的问题。通常由于这个原因,循环神经网络在实际中较难捕捉时间序列中时间步距离较大的依赖关系。

门控循环神经网络(gated recurrent neural network)的提出,正是为了更好地捕捉时间序列中时间步距离较大的依赖关系。它通过可以学习的门来控制信息的流动。其中,门控循环单元(gated recurrent unit,GRU)是一种常用的门控循环神经网络 [1, 2]。另一种常用的门控循环神经网络则将在下一节中介绍。

下面将介绍门控循环单元的设计。它引入了重置门(reset gate)和更新门(update gate)的概念,从而修改了循环神经网络中隐藏状态的计算方式。

重置门和更新门

根据图6.4所示,门控循环单元中的重置门(reset gate)和更新门(update gate)的输入均为当前时间步输入 X t X_t Xt与上一时间步隐藏状态 H t − 1 H_{t-1} Ht1,输出由激活函数为sigmoid函数的全连接层计算得到。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IJQiJaFQ-1687830319117)(image/手动深度学习/1687140280070.png)]

具体来说,假设隐藏单元个数为 h h h,给定时间步 t t t的小批量输入 X t ∈ R n × d X_t \in \mathbb{R}^{n \times d} XtRn×d(样本数为 n n n,输入个数为 d d d)和上一时间步隐藏状态 H t − 1 ∈ R n × h H_{t-1} \in \mathbb{R}^{n \times h} Ht1Rn×h。重置门 R t ∈ R n × h R_t \in \mathbb{R}^{n \times h} RtRn×h和更新门 Z t ∈ R n × h Z_t \in \mathbb{R}^{n \times h} ZtRn×h的计算如下:

R t = σ ( X t W x r + H t − 1 W h r + b r ) R_t = \sigma(X_t W_{xr} + H_{t-1} W_{hr} + b_r) Rt=σ(XtWxr+Ht1Whr+br),

Z t = σ ( X t W x z + H t − 1 W h z + b z ) Z_t = \sigma(X_t W_{xz} + H_{t-1} W_{hz} + b_z) Zt=σ(XtWxz+Ht1Whz+bz)

其中 W x r , W x z ∈ R d × h W_{xr}, W_{xz} \in \mathbb{R}^{d \times h} Wxr,WxzRd×h W h r , W h z ∈ R h × h W_{hr}, W_{hz} \in \mathbb{R}^{h \times h} Whr,WhzRh×h是权重参数, b r , b z ∈ R 1 × h b_r, b_z \in \mathbb{R}^{1 \times h} br,bzR1×h是偏差参数。在第3.8节(多层感知机)中介绍过,sigmoid函数可以将元素的值变换到0和1之间。因此,重置门 R t R_t Rt和更新门 Z t Z_t Zt中每个元素的值域都是 [ 0 , 1 ] [0, 1] [0,1]

候选隐藏状态

根据图6.5所示,门控循环单元会计算候选隐藏状态,以辅助后续隐藏状态的计算。首先,我们将当前时间步的重置门输出与上一时间步的隐藏状态进行按元素乘法(用符号 ⊙ 表示)。如果重置门中的元素接近0,表示对应隐藏状态元素被重置为0,即丢弃上一时间步的隐藏状态。如果元素接近1,表示保留上一时间步的隐藏状态。然后,将按元素乘法的结果与当前时间步的输入进行连接,接着通过一个包含激活函数tanh的全连接层计算出候选隐藏状态。候选隐藏状态的所有元素的取值范围为[-1, 1]。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3pZqXRRM-1687830319118)(image/手动深度学习/1687141130502.png)]

具体来说,时间步 t t t 的候选隐藏状态 H t ∼ ∈ R n × h H_t^\sim \in \mathbb{R}^{n \times h} HtRn×h 的计算为

H t ∼ = tanh ⁡ ( X t W x h + ( R t ⊙ H t − 1 ) W h h + b h ) H_t^\sim = \tanh(X_t W_{xh} + (R_t \odot H_{t-1}) W_{hh} + b_h) Ht=tanh(XtWxh+(RtHt1)Whh+bh),

其中 W x h ∈ R d × h W_{xh} \in \mathbb{R}^{d \times h} WxhRd×h W h h ∈ R h × h W_{hh} \in \mathbb{R}^{h \times h} WhhRh×h 是权重参数, b h ∈ R 1 × h b_h \in \mathbb{R}^{1 \times h} bhR1×h 是偏差参数。从上面这个公式可以看出,重置门控制了上一时间步的隐藏状态如何流入当前时间步的候选隐藏状态。而上一时间步的隐藏状态可能包含了时间序列截至上一时间步的全部历史信息。因此,重置门可以用来丢弃与预测无关的历史信息。

隐藏状态

最后,时间步 t t t的隐藏状态 H t ∈ R n × h H_t \in \mathbb{R}^{n \times h} HtRn×h的计算使用当前时间步的更新门 Z t Z_t Zt来对上一时间步的隐藏状态 H t − 1 H_{t-1} Ht1和当前时间步的候选隐藏状态 H ~ t \tilde{H}_t H~t做组合:

H t = Z t ⊙ H t − 1 + ( 1 − Z t ) ⊙ H ~ t H_t = Z_t \odot H_{t-1} + (1 - Z_t) \odot \tilde{H}_t Ht=ZtHt1+(1Zt)H~t

值得注意的是,更新门可以控制隐藏状态应该如何被包含当前时间步信息的候选隐藏状态所更新,如图6.6所示。假设更新门在时间步t’到 t t t t ′ < t t' < t t<t)之间一直近似1。那么,在时间步 t ′ t' t t t t之间的输入信息几乎没有流入时间步 t t t的隐藏状态 H t H_t Ht。实际上,这可以看作是较早时刻的隐藏状态 H t ′ − 1 H_{t'-1} Ht1一直通过时间保存并传递至当前时间步 t t t。这个设计可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时间序列中时间步距离较大的依赖关系。

我们对门控循环单元的设计稍作总结:

  • 重置门有助于捕捉时间序列里短期的依赖关系;
  • 更新门有助于捕捉时间序列里长期的依赖关系。
读取数据集

为了实现并展示门控循环单元,下面依然使用周杰伦歌词数据集来训练模型作词。这里除门控循环单元以外的实现已在6.2节(循环神经网络)中介绍过。以下为读取数据集部分。

import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as F

import sys
import d2lzh_pytorch as d2l
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

(corpus_indices, char_to_idx, idx_to_char, vocab_size) = d2l.load_data_jay_lyrics()
从零开始实现
初始化模型参数

下面的代码对模型参数进行初始化。超参数 num_hiddens定义了隐藏单元的个数。

num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
print('will use', device)

def get_params():
    def _one(shape):
        ts = torch.tensor(np.random.normal(0, 0.01, size=shape), device=device, dtype=torch.float32)
        return torch.nn.Parameter(ts, requires_grad=True)
    def _three():
        return (_one((num_inputs, num_hiddens)),
                _one((num_hiddens, num_hiddens)),
                torch.nn.Parameter(torch.zeros(num_hiddens, device=device, dtype=torch.float32), requires_grad=True))

    W_xz, W_hz, b_z = _three()  # 更新门参数
    W_xr, W_hr, b_r = _three()  # 重置门参数
    W_xh, W_hh, b_h = _three()  # 候选隐藏状态参数

    # 输出层参数
    W_hq = _one((num_hiddens, num_outputs))
    b_q = torch.nn.Parameter(torch.zeros(num_outputs, device=device, dtype=torch.float32), requires_grad=True)
    return nn.ParameterList([W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q])
定义模型

下面的代码定义隐藏状态初始化函数 init_gru_state。同6.4节(循环神经网络的从零开始实现)中定义的 init_rnn_state函数一样,它返回由一个形状为(批量大小, 隐藏单元个数)的值为0的 Tensor组成的元组

def init_gru_state(batch_size, num_hiddens, device):
    return (torch.zeros((batch_size, num_hiddens), device=device), )

下面根据门控循环单元的计算表达式定义模型。

def gru(inputs, state, params):
    W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q = params
    H, = state
    outputs = []
    for X in inputs:
        Z = torch.sigmoid(torch.matmul(X, W_xz) + torch.matmul(H, W_hz) + b_z)
        R = torch.sigmoid(torch.matmul(X, W_xr) + torch.matmul(H, W_hr) + b_r)
        H_tilda = torch.tanh(torch.matmul(X, W_xh) + torch.matmul(R * H, W_hh) + b_h)
        H = Z * H + (1 - Z) * H_tilda
        Y = torch.matmul(H, W_hq) + b_q
        outputs.append(Y)
    return outputs, (H,)
训练并创作

我们在训练模型时只使用相邻采样。设置好超参数后,我们将训练模型并根据前缀“分开”和“不分开”分别创作长度为50个字符的一段歌词。

num_epochs, num_steps, batch_size, lr, clipping_theta = 160, 35, 32, 1e2, 1e-2
pred_period, pred_len, prefixes = 40, 50, ['分开', '不分开']

d2l.train_and_predict_rnn(gru, get_params, init_gru_state, num_hiddens,
                          vocab_size, device, corpus_indices, idx_to_char,
                          char_to_idx, False, num_epochs, num_steps, lr,
                          clipping_theta, batch_size, pred_period, pred_len,
                          prefixes)

完整代码

import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as F
import sys
import d2lzh_pytorch as d2l
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

(corpus_indices, char_to_idx, idx_to_char, vocab_size) = d2l.load_data_jay_lyrics()

num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
print('will use', device)

def get_params():  # 获取模型的参数
    def _one(shape):  # 生成一个具有指定形状的张量,并将其转换为torch.nn.Parameter类型
        ts = torch.tensor(np.random.normal(0, 0.01, size=shape), device=device, dtype=torch.float32)
        return torch.nn.Parameter(ts, requires_grad=True)
    def _three():  # 生成三个参数,分别是输入到更新门、重置门和候选隐藏状态的权重矩阵以及对应的偏置向量
        return (_one((num_inputs, num_hiddens)),
                _one((num_hiddens, num_hiddens)),
                torch.nn.Parameter(torch.zeros(num_hiddens, device=device, dtype=torch.float32), requires_grad=True))

    W_xz, W_hz, b_z = _three()  # 更新门参数
    W_xr, W_hr, b_r = _three()  # 重置门参数
    W_xh, W_hh, b_h = _three()  # 候选隐藏状态参数

    # 输出层参数
    W_hq = _one((num_hiddens, num_outputs))  # 权重矩阵
    b_q = torch.nn.Parameter(torch.zeros(num_outputs, device=device, dtype=torch.float32), requires_grad=True)  # 偏置向量
    return nn.ParameterList([W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q])  # 将所有的参数封装到nn.ParameterList对象中,并作为函数的返回值

def init_gru_state(batch_size, num_hiddens, device):
    return (torch.zeros((batch_size, num_hiddens), device=device), )

def gru(inputs, state, params):
    W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q = params
    H, = state
    outputs = []
    for X in inputs:
        Z = torch.sigmoid(torch.matmul(X, W_xz) + torch.matmul(H, W_hz) + b_z)  # 计算更新门
        R = torch.sigmoid(torch.matmul(X, W_xr) + torch.matmul(H, W_hr) + b_r)  # 计算重置门
        H_tilda = torch.tanh(torch.matmul(X, W_xh) + torch.matmul(R * H, W_hh) + b_h)  # 计算候选隐藏状态
        H = Z * H + (1 - Z) * H_tilda  # 通过更新门Z来组合上一时间步的隐藏状态H和当前时间步的候选隐藏状态H_tilda,得到当前时间步的新隐藏状态H
        Y = torch.matmul(H, W_hq) + b_q  # 计算当前时间步的输出Y
        outputs.append(Y)
    return outputs, (H,)  # 返回包含所有时间步输出的列表outputs,以及最后一个时间步的隐藏状态(H,)作为新的模型状态

num_epochs, num_steps, batch_size, lr, clipping_theta = 160, 35, 32, 1e2, 1e-2
pred_period, pred_len, prefixes = 40, 50, ['分开', '不分开']

d2l.train_and_predict_rnn(gru, get_params, init_gru_state, num_hiddens,
                          vocab_size, device, corpus_indices, idx_to_char,
                          char_to_idx, False, num_epochs, num_steps, lr,
                          clipping_theta, batch_size, pred_period, pred_len,
                          prefixes)
简洁实现

在PyTorch中我们直接调用 nn模块中的 GRU类即可

lr = 1e-2 # 注意调整学习率
gru_layer = nn.GRU(input_size=vocab_size, hidden_size=num_hiddens)
model = d2l.RNNModel(gru_layer, vocab_size).to(device)
d2l.train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device,
                                corpus_indices, idx_to_char, char_to_idx,
                                num_epochs, num_steps, lr, clipping_theta,
                                batch_size, pred_period, pred_len, prefixes)
小结
  • 门控循环神经网络可以更好地捕捉时间序列中时间步距离较大的依赖关系。
  • 门控循环单元引入了门的概念,从而修改了循环神经网络中隐藏状态的计算方式。它包括重置门、更新门、候选隐藏状态和隐藏状态。
  • 重置门有助于捕捉时间序列里短期的依赖关系。
  • 更新门有助于捕捉时间序列里长期的依赖关系。

长短期记忆(LSTM)

LSTM 中引入了3个门,即输入门(input gate)、遗忘门(forget gate)和输出门(output gate),以及与隐藏状态形状相同的记忆细胞(某些文献把记忆细胞当成一种特殊的隐藏状态),从而记录额外的信息。

输入门,遗忘门和输出门

与门控循环单元中的重置门和更新门一样,如图6.7所示,长短期记忆(LSTM)的门的输入均为当前时间步输入 X t X_t Xt与上一时间步隐藏状态 H t − 1 H_{t-1} Ht1,输出由激活函数为sigmoid函数的全连接层计算得到。如此一来,这3个门元素的值域均为 [ 0 , 1 ] [0,1] [0,1]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J6g6MauD-1687830319119)(image/手动深度学习/1687222603939.png)]

具体来说,假设隐藏单元个数为 h h h,给定时间步 t t t的小批量输入 X t ∈ R n × d X_t \in \mathbb{R}^{n \times d} XtRn×d(样本数为 n n n,输入个数为 d d d)和上一时间步隐藏状态 H t − 1 ∈ R n × h H_{t-1} \in \mathbb{R}^{n \times h} Ht1Rn×h。时间步 t t t的输入门 I t ∈ R n × h I_t \in \mathbb{R}^{n \times h} ItRn×h、遗忘门 F t ∈ R n × h F_t \in \mathbb{R}^{n \times h} FtRn×h和输出门 O t ∈ R n × h O_t \in \mathbb{R}^{n \times h} OtRn×h分别计算如下:

I t = σ ( X t W x i + H t − 1 W h i + b i ) I_t = σ(X_tW_{xi} + H_{t-1}W_{hi} + b_i) It=σ(XtWxi+Ht1Whi+bi)
F t = σ ( X t W x f + H t − 1 W h f + b f ) F_t = σ(X_tW_{xf} + H_{t-1}W_{hf} + b_f) Ft=σ(XtWxf+Ht1Whf+bf)
O t = σ ( X t W x o + H t − 1 W h o + b o ) O_t = σ(X_tW_{xo} + H_{t-1}W_{ho} + b_o) Ot=σ(XtWxo+Ht1Who+bo)

其中 W x i , W x f , W x o ∈ R d × h W_{xi}, W_{xf}, W_{xo} \in \mathbb{R}^{d \times h} Wxi,Wxf,WxoRd×h W h i , W h f , W h o ∈ R h × h W_{hi}, W_{hf}, W_{ho} \in \mathbb{R}^{h \times h} Whi,Whf,WhoRh×h是权重参数, b i , b f , b o ∈ R 1 × h b_i, b_f, b_o \in \mathbb{R}^{1 \times h} bi,bf,boR1×h是偏差参数。

候选记忆细胞

接下来,长短期记忆(LSTM)需要计算候选记忆细胞C˜t。它的计算方式与之前介绍的3个门类似,但是使用值域在[-1, 1]的双曲正切函数(tanh)作为激活函数。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Nsnbmxn7-1687830319121)(image/手动深度学习/1687223084382.png)]

具体来说,时间步 t t t的候选记忆细胞 C ˜ t ∈ R n × h C˜t \in \mathbb{R}^{n \times h} C˜tRn×h的计算为:

C ˜ t = t a n h ( X t W x c + H t − 1 W h c + b c ) C˜t = tanh(X_tW_xc + H_{t−1}W_hc + b_c) C˜t=tanh(XtWxc+Ht1Whc+bc)

其中, W x c ∈ R d × h W_{xc} \in \mathbb{R}^{d \times h} WxcRd×h W h c ∈ R h × h W_{hc} \in \mathbb{R}^{h \times h} WhcRh×h是权重参数, b c ∈ R 1 × h b_c \in \mathbb{R}^{1 \times h} bcR1×h是偏差参数。

隐藏状态

有了记忆细胞之后,接下来我们可以通过输出门来控制从记忆细胞到隐藏状态 H t ∈ R n × h H_t \in \mathbb{R}^{n \times h} HtRn×h的信息流动:

H t = O t ⊙ t a n h ( C t ) . H_t = O_t ⊙ tanh(C_t). Ht=Ottanh(Ct).

其中,Ht表示隐藏状态,Ot表示输出门的值,Ct表示记忆细胞的值。在这个公式中,tanh函数确保隐藏状态元素的值在-1到1之间。需要注意的是,当输出门接近1时,记忆细胞的信息将传递到隐藏状态供输出层使用;而当输出门接近0时,记忆细胞的信息将保持不变。图6.10展示了长短期记忆中隐藏状态的计算。

读取数据集

下面我们开始实现并展示长短期记忆。和前几节中的实验一样,这里依然使用周杰伦歌词数据集来训练模型作词

import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as F

import sys
import d2lzh_pytorch as d2l
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

(corpus_indices, char_to_idx, idx_to_char, vocab_size) = d2l.load_data_jay_lyrics()
从0开始实现
初始化模型参数
num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
print('will use', device)

def get_params():
    def _one(shape):
        ts = torch.tensor(np.random.normal(0, 0.01, size=shape), device=device, dtype=torch.float32)
        return torch.nn.Parameter(ts, requires_grad=True)
    def _three():
        return (_one((num_inputs, num_hiddens)),
                _one((num_hiddens, num_hiddens)),
                torch.nn.Parameter(torch.zeros(num_hiddens, device=device, dtype=torch.float32), requires_grad=True))

    W_xi, W_hi, b_i = _three()  # 输入门参数
    W_xf, W_hf, b_f = _three()  # 遗忘门参数
    W_xo, W_ho, b_o = _three()  # 输出门参数
    W_xc, W_hc, b_c = _three()  # 候选记忆细胞参数

    # 输出层参数
    W_hq = _one((num_hiddens, num_outputs))
    b_q = torch.nn.Parameter(torch.zeros(num_outputs, device=device, dtype=torch.float32), requires_grad=True)
    return nn.ParameterList([W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c, W_hq, b_q])
定义模型

在初始化函数中,长短期记忆的隐藏状态需要返回额外的形状为(批量大小, 隐藏单元个数)的值为0的记忆细胞

def init_lstm_state(batch_size, num_hiddens, device):
    return (torch.zeros((batch_size, num_hiddens), device=device), 
            torch.zeros((batch_size, num_hiddens), device=device))

下面根据长短期记忆的计算表达式定义模型。需要注意的是,只有隐藏状态会传递到输出层,而记忆细胞不参与输出层的计算

def lstm(inputs, state, params):
    [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c, W_hq, b_q] = params
    (H, C) = state
    outputs = []
    for X in inputs:
        I = torch.sigmoid(torch.matmul(X, W_xi) + torch.matmul(H, W_hi) + b_i)
        F = torch.sigmoid(torch.matmul(X, W_xf) + torch.matmul(H, W_hf) + b_f)
        O = torch.sigmoid(torch.matmul(X, W_xo) + torch.matmul(H, W_ho) + b_o)
        C_tilda = torch.tanh(torch.matmul(X, W_xc) + torch.matmul(H, W_hc) + b_c)
        C = F * C + I * C_tilda
        H = O * C.tanh()
        Y = torch.matmul(H, W_hq) + b_q
        outputs.append(Y)
    return outputs, (H, C)
训练模型并创作歌词

同上一节一样,我们在训练模型时只使用相邻采样。设置好超参数后,我们将训练模型并根据前缀“分开”和“不分开”分别创作长度为50个字符的一段歌词。

num_epochs, num_steps, batch_size, lr, clipping_theta = 160, 35, 32, 1e2, 1e-2
pred_period, pred_len, prefixes = 40, 50, ['分开', '不分开']

d2l.train_and_predict_rnn(lstm, get_params, init_lstm_state, num_hiddens,
                          vocab_size, device, corpus_indices, idx_to_char,
                          char_to_idx, False, num_epochs, num_steps, lr,
                          clipping_theta, batch_size, pred_period, pred_len,
                          prefixes)
简洁实现

在Gluon中我们可以直接调用 rnn模块中的 LSTM

lr = 1e-2 # 注意调整学习率
lstm_layer = nn.LSTM(input_size=vocab_size, hidden_size=num_hiddens)
model = d2l.RNNModel(lstm_layer, vocab_size)
d2l.train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device,
                                corpus_indices, idx_to_char, char_to_idx,
                                num_epochs, num_steps, lr, clipping_theta,
                                batch_size, pred_period, pred_len, prefixes)

完整代码

import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as F

import sys
import d2lzh_pytorch as d2l
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

(corpus_indices, char_to_idx, idx_to_char, vocab_size) = d2l.load_data_jay_lyrics()

num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
print('will use', device)

def get_params():
    def _one(shape):
        ts = torch.tensor(np.random.normal(0, 0.01, size=shape), device=device, dtype=torch.float32)
        return torch.nn.Parameter(ts, requires_grad=True)
    def _three():  # 生成三个参数,分别是输入门、遗忘门和输出门的权重参数
        return (_one((num_inputs, num_hiddens)),
                _one((num_hiddens, num_hiddens)),
                torch.nn.Parameter(torch.zeros(num_hiddens, device=device, dtype=torch.float32), requires_grad=True))

    W_xi, W_hi, b_i = _three()  # 输入门参数
    W_xf, W_hf, b_f = _three()  # 遗忘门参数
    W_xo, W_ho, b_o = _three()  # 输出门参数
    W_xc, W_hc, b_c = _three()  # 候选记忆细胞参数

    # 输出层参数
    W_hq = _one((num_hiddens, num_outputs))
    b_q = torch.nn.Parameter(torch.zeros(num_outputs, device=device, dtype=torch.float32), requires_grad=True)
    return nn.ParameterList([W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c, W_hq, b_q])

def init_lstm_state(batch_size, num_hiddens, device):
    return (torch.zeros((batch_size, num_hiddens), device=device),  # 初始化LSTM的隐藏状态
            torch.zeros((batch_size, num_hiddens), device=device))  # 初始化LSTM的记忆细胞状态

def lstm(inputs, state, params):
    [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c, W_hq, b_q] = params
    (H, C) = state
    outputs = []
    for X in inputs:  # 根据公式计算
        I = torch.sigmoid(torch.matmul(X, W_xi) + torch.matmul(H, W_hi) + b_i)
        F = torch.sigmoid(torch.matmul(X, W_xf) + torch.matmul(H, W_hf) + b_f)
        O = torch.sigmoid(torch.matmul(X, W_xo) + torch.matmul(H, W_ho) + b_o)
        C_tilda = torch.tanh(torch.matmul(X, W_xc) + torch.matmul(H, W_hc) + b_c)
        C = F * C + I * C_tilda
        H = O * C.tanh()
        Y = torch.matmul(H, W_hq) + b_q
        outputs.append(Y)
    return outputs, (H, C)


num_epochs, num_steps, batch_size, lr, clipping_theta = 160, 35, 32, 1e2, 1e-2
pred_period, pred_len, prefixes = 40, 50, ['分开', '不分开']

d2l.train_and_predict_rnn(lstm, get_params, init_lstm_state, num_hiddens,
                          vocab_size, device, corpus_indices, idx_to_char,
                          char_to_idx, False, num_epochs, num_steps, lr,
                          clipping_theta, batch_size, pred_period, pred_len,
                          prefixes)
小结
  • 长短期记忆的隐藏层输出包括隐藏状态和记忆细胞。只有隐藏状态会传递到输出层。
  • 长短期记忆的输入门、遗忘门和输出门可以控制信息的流动。
  • 长短期记忆可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时间序列中时间步距离较大的依赖关系。

深度循环网络

本章到目前为止介绍的循环神经网络只有一个单向的隐藏层,在深度学习应用里,我们通常会用到含有多个隐藏层的循环神经网络,也称作深度循环神经网络。图6.11演示了一个有L个隐藏层的深度循环神经网络,每个隐藏状态不断传递至当前层的下一时间步和当前时间步的下一层。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hUDRgnDn-1687830319122)(image/手动深度学习/1687307967116.png)]

具体来说,在时间步t里,设小批量输入 X t ∈ R n × d X_t \in \mathbb{R}^{n \times d} XtRn×d(样本数为 n n n,输入个数为 d d d),第 ℓ \ell 隐藏层( ℓ = 1 , … , L \ell=1,\ldots,L =1,,L)的隐藏状态为 H t ( ℓ ) ∈ R n × h H^{(\ell)}_t \in \mathbb{R}^{n \times h} Ht()Rn×h(隐藏单元个数为 h h h),输出层变量为 O t ∈ R n × q O_t \in \mathbb{R}^{n \times q} OtRn×q(输出个数为 q q q),且隐藏层的激活函数为 ϕ \phi ϕ。第1隐藏层的隐藏状态和之前的计算一样:

H ( 1 ) ∗ t = ϕ ( X t W ( 1 ) ∗ x h + H ( 1 ) ∗ t − 1 W ( 1 ) ∗ h h + b h ( 1 ) ) H^{(1)} *t = \phi(X_t W^{(1)}* {xh} + H^{(1)} *{t-1} W^{(1)}* {hh} + b^{(1)}_h) H(1)t=ϕ(XtW(1)xh+H(1)t1W(1)hh+bh(1)),

其中权重 W ( 1 ) ∗ x h ∈ R d × h W^{(1)} *{xh} \in \mathbb{R}^{d \times h} W(1)xhRd×h W ( 1 ) ∗ h h ∈ R h × h W^{(1)}* {hh} \in \mathbb{R}^{h \times h} W(1)hhRh×h和偏差 b h ( 1 ) ∈ R 1 × h b^{(1)}_h \in \mathbb{R}^{1 \times h} bh(1)R1×h分别为第1隐藏层的模型参数。

1 < ℓ ≤ L 1<\ell \leq L 1<L时,第 ℓ \ell 隐藏层的隐藏状态的表达式为

H ( ℓ ) ∗ t = ϕ ( H ( ℓ − 1 ) ∗ t W ( ℓ ) ∗ x h + H ( ℓ ) ∗ t − 1 W h h ( ℓ ) + b h ( ℓ ) ) H^{(\ell)} *t = \phi(H^{(\ell-1)} *t W^{(\ell)}* {xh} + H^{(\ell)}* {t-1} W^{(\ell)}_{hh} + b^{(\ell)}_h) H()t=ϕ(H(1)tW()xh+H()t1Whh()+bh()),

其中权重 W ( ℓ ) ∗ x h ∈ R h × h W^{(\ell)} *{xh} \in \mathbb{R}^{h \times h} W()xhRh×h W ( ℓ ) ∗ h h ∈ R h × h W^{(\ell)}* {hh} \in \mathbb{R}^{h \times h} W()hhRh×h和偏差 b h ( ℓ ) ∈ R 1 × h b^{(\ell)}_h \in \mathbb{R}^{1 \times h} bh()R1×h分别为第 ℓ \ell 隐藏层的模型参数。

最终,输出层的输出只需基于第L隐藏层的隐藏状态:

O t = H t ( L ) W h q + b q O_t = H^{(L)}_t W^{hq} + b^q Ot=Ht(L)Whq+bq,

其中权重 W h q ∈ R h × q W^{hq} \in \mathbb{R}^{h \times q} WhqRh×q和偏差 b q ∈ R 1 × q b^q \in \mathbb{R}^{1 \times q} bqR1×q为输出层的模型参数。

同多层感知机一样,隐藏层个数 L L L和隐藏单元个数 h h h都是超参数。此外,如果将隐藏状态的计算换成门控循环单元或者长短期记忆的计算,我们可以得到深度门控循环神经网络。

双向循环神经网络

之前介绍的循环神经网络模型假设当前时间步是由前面较早时间步的序列决定的,因此它们通过隐藏状态从前往后传递信息。然而,有些情况下,当前时间步可能由后面时间步决定,比如在写句子时可能会根据句子后面的词来修改句子前面的用词。为了更灵活地处理这类信息,双向循环神经网络引入了从后往前传递信息的隐藏层。图6.12展示了一个含有单隐藏层的双向循环神经网络的架构。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TjoZQWkN-1687830319123)(image/手动深度学习/1687308461465.png)]

接下来我们来具体定义双向循环神经网络。给定时间步t的小批量输入 X t ∈ R n × d X_t \in \mathbb{R}^{n \times d} XtRn×d(样本数为n,输入维度为d)和隐藏层激活函数 ϕ \phi ϕ。在双向循环神经网络的架构中,设该时间步正向隐藏状态为 H → t ∈ R n × h H_{\rightarrow t} \in \mathbb{R}^{n \times h} HtRn×h(正向隐藏单元个数为h),反向隐藏状态为 H ← t ∈ R n × h H_{\leftarrow t} \in \mathbb{R}^{n \times h} HtRn×h(反向隐藏单元个数为h)。我们可以分别计算正向隐藏状态和反向隐藏状态:

H t = ϕ ( X t W x h ( f ) + H t − 1 W h h ( f ) + b h ( f ) ) H_t = \phi(X_t W_{xh}^{(f)} + H_{t-1} W_{hh}^{(f)} + b_h^{(f)}) Ht=ϕ(XtWxh(f)+Ht1Whh(f)+bh(f)),
H t = ϕ ( X t W x h ( b ) + H t + 1 W h h ( b ) + b h ( b ) ) H_t = \phi(X_t W_{xh}^{(b)} + H_{t+1} W_{hh}^{(b)} + b_h^{(b)}) Ht=ϕ(XtWxh(b)+Ht+1Whh(b)+bh(b)),

其中 W xf ( h ) ∈ R d × h W_{\text{xf}}^{(h)} \in \mathbb{R}^{d \times h} Wxf(h)Rd×h W hf ( h ) ∈ R h × h W_{\text{hf}}^{(h)} \in \mathbb{R}^{h \times h} Whf(h)Rh×h W xb ( h ) ∈ R d × h W_{\text{xb}}^{(h)} \in \mathbb{R}^{d \times h} Wxb(h)Rd×h W hb ( h ) ∈ R h × h W_{\text{hb}}^{(h)} \in \mathbb{R}^{h \times h} Whb(h)Rh×h b hf ∈ R 1 × h b_{\text{hf}} \in \mathbb{R}^{1 \times h} bhfR1×h b hb ∈ R 1 × h b_{\text{hb}} \in \mathbb{R}^{1 \times h} bhbR1×h是模型参数。

然后我们将两个方向的隐藏状态 H → t H_{\rightarrow t} Ht H ← t H_{\leftarrow t} Ht连接起来得到隐藏状态 H t ∈ R n × 2 h H_t \in \mathbb{R}^{n \times 2h} HtRn×2h,并将其输入到输出层。输出层计算输出 O t ∈ R n × q O_t \in \mathbb{R}^{n \times q} OtRn×q(输出个数为q):

O t = H t W h q + b q , O_t = H_t W_{hq} + b_q, Ot=HtWhq+bq,

其中 W hq ∈ R 2 h × q W_{\text{hq}} \in \mathbb{R}^{2h \times q} WhqR2h×q b q ∈ R 1 × q b_q \in \mathbb{R}^{1 \times q} bqR1×q是输出层的模型参数。不同方向上的隐藏单元个数也可以不同。

总结:

双向循环神经网络在每个时间步的隐藏状态同时取决于该时间步之前和之后的子序列(包括当前时间步的输入)。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值