【深度学习】循环神经网络(二)

4.循环神经网络(RNN)

在上一节的n元语法模型中,其中单词xtx_txt在时间步ttt的条件概率仅取决于前面n−1n-1n1个单词。对于时间步t−(n−1)t-(n-1)t(n1)之前的单词,如果想要将其可能产生的影响合并到xtx_txt上,需要增加nnn,然而模型参数的数量也会随之呈指数增长,因为词表$\mathcal{V} 需要存储需要存储需要存储|\mathcal{V} |^n$个数字,因此此时使用隐变量模型:
P(xt∣xt−1,,˙x1)≈P(xt∣ht−1)P(x_t|x_{t-1}, \dot , x_1)\approx P(x_t|h_{t-1})P(xtxt1,,˙x1)P(xtht1)
其中ht−1h_{t-1}ht1是隐状态(hidden state),也称为隐藏变量(hidden variable),它存储了到时间步t−1t-1t1的序列信息。通常可以基于当前输入xtx_txt和先前隐状态ht−1h_{t-1}ht1来计算时间步ttt处的任何时间的隐状态:
ht=f(xt,ht−1)h_t=f(x_t,h_{t-1})ht=f(xt,ht1)
对于上式中的函数f,隐变量模型不是近似值,毕竟hth_tht是可以仅仅存储到目前为止观察到的所有数据。
需要注意的是,隐藏层和隐状态是两个截然不同的概念。隐藏层是在从输入到输出的路径上的隐藏的层(以观测角度来理解),而隐状态则是在给定步骤所做的任何事情的输入(以技术角度来定义),并且这些状态只能通过先前时间步的数据来计算。

4.1 隐状态

假设我们在时间步ttt有小批量输入$X_t \in \mathbb{R} ^{n\times d} ,即对于n个序列样本的小批量,,即对于n个序列样本的小批量,,即对于n个序列样本的小批量,X_t的每一行对应于来自该序列的时间步的每一行对应于来自该序列的时间步的每一行对应于来自该序列的时间步t$处的一个样本,接下来用 $H_t\in \mathbb{R} ^{n\times h} $ 表示时间步ttt的隐藏变量。在这里,我们保存了前一个时间步的隐藏变量Ht−1H_{t-1}Ht1。具体而言,当时间步隐藏变量由当前时间步的输入与前一个时间步的隐藏变量一起计算得出:
Ht=ϕ(XtWxh+Ht−1Whh+bh)H_t=\phi (X_t W_{xh}+H_{t-1}W_{hh}+b_h)Ht=ϕ(XtWxh+Ht1Whh+bh)
由此实例化了隐状态。从相邻时间步的隐藏变量HtH_tHtHt−1H_{t-1}Ht1之间的关系可知,这些变量捕获并保留了序列直到其当前时间步的历史信息,就如当前时间步下神经网络的状态或记忆,因此这样的隐藏变量被称为隐状态。由于在当前的时间步中,隐状态使用的定义与前一个时间步中使用的定义相同,因此此时上式的计算是循环的,于是基于循环计算的隐状态神经网络则称为循环神经网络(recurrent neural network)。在循环神经网络中执行上式计算的层被称为循环层(recurrent layer)。
有许多不同的方法可以构建循环神经网络,由上式定义的隐状态的循环神经网络是非常常见的一种,对于时间步ttt,输出层的输出类似于多层感知机中的计算:
Ot=HtWhq+bqO_t=H_tW_{hq}+b_qOt=HtWhq+bq
其中,O∈Rn×qO\in \mathbb{R} ^{n\times q}ORn×q是输出变量,Whq∈Rh×qW_{hq}\in \mathbb{R} ^{h\times q}WhqRh×q 是权重参数,bq∈R1×qb_q\in \mathbb{R} ^{1\times q}bqR1×q 是输出层的偏置参数。循环神经网络的参数还包括隐藏层的权重Wxh∈Rd×hW_{xh}\in \mathbb{R} ^{d\times h}WxhRd×hWhh∈Rh×hW_{hh}\in \mathbb{R} ^{h\times h}WhhRh×h 和偏置bh∈R1×hb_h\in \mathbb{R} ^{1\times h}bhR1×h。即使在不同的时间步中,循环神经网络也总是使用这些模型参数,因此循环神经网络的参数开销不会随着时间步的增加而增加。
在这里插入图片描述

上图展示了循环神经网络在三个相邻时间步的计算逻辑。在任何时间步ttt,隐状态的计算可以被视为:

  • 拼接当前时间步ttt的输入XtX_tXt和前一时间步t−1t-1t1的隐状态Ht−1H_{t-1}Ht1
  • 将拼接结果送入带有激活函数$\phi 的全连接层。全连接层的输出是当前时间步的全连接层。全连接层的输出是当前时间步的全连接层。全连接层的输出是当前时间步t的隐状态的隐状态的隐状态H_t$

其中隐状态中XtWxh+Ht−1WhhX_t W_{xh}+H_{t-1}W_{hh}XtWxh+Ht1Whh的计算相当于XtX_tXtHt−1H_{t-1}Ht1的拼接 与 WxhW_{xh}WxhWhhW_{hh}Whh的拼接的矩阵乘法。下面我们使用一个简单的代码来说明。 首先,我们定义矩阵X、W_xh、H和W_hh, 它们的形状分别为(3,1)、(1,4)、(3,4)和(4,4)。 分别将X乘以W_xh,将H乘以W_hh, 然后将这两个乘法相加,我们得到一个形状为(3,4)的矩阵。

#隐状态等式解释
import torch 
X, W_xh = torch.normal(0, 1, (3,1)), torch.normal(0, 1, (1,4))
H, W_hh = torch.normal(0, 1, (3,4)), torch.normal(0, 1, (4,4))
torch.matmul(X, W_xh) + torch.matmul(H, W_hh)

输出得到:

tensor([[ 3.3934, -0.9519, -0.7254, -0.8206],
        [ 1.5418,  1.4385,  0.6709, -0.7588],
        [-0.2794, -0.9233,  1.8042, -0.4146]])

现在,我们沿列(轴1)拼接矩阵X和H, 沿行(轴0)拼接矩阵W_xh和W_hh。 这两个拼接分别产生形状(3,5)和形状(5,4)的矩阵。 再将这两个拼接的矩阵相乘, 我们得到与上面相同形状(3.4)的输出矩阵。

torch.matmul(torch.cat((X, H), 1), torch.cat((W_xh, W_hh), 0))

得到:

tensor([[ 3.3934, -0.9519, -0.7254, -0.8206],
        [ 1.5418,  1.4385,  0.6709, -0.7588],
        [-0.2794, -0.9233,  1.8042, -0.4146]])

4.2 基于循环神经网络的字符级语言模型

在上一节的语言模型中,我们的目标是根据过去的和当前的词元预测下一个词元,因此我们将原始序列移位一个词元作为标签。Bengio等人首先提出使用神经网络进行语言建模。接下来,我们看一下如何使用循环神经网络来构建语言模型。 设小批量大小为1,批量中的文本序列为“machine”。 为了简化后续部分的训练,我们考虑使用 字符级语言模型(character-level language model), 将文本词元化为字符而不是单词。下图演示了如何通过基于字符级语言建模的循环神经网络, 使用当前的和先前的字符预测下一个字符。
在这里插入图片描述

在训练过程中,我们对每个时间步的输出层的输出进行softmax操作, 然后利用交叉熵损失计算模型输出和标签之间的误差。 由于隐藏层中隐状态的循环计算,上图中第三个时间步的输出O3O_3O3由文本序列“m”“a”和“c”确定。 由于训练数据中这个文本序列的下一个字符是“h”, 因此第3个时间步的损失将取决于下一个字符的概率分布, 而下一个字符是基于特征序列“m”“a”“c”和这个时间步的标签“h”生成的。实践中,我们使用的批量大小为n>1n>1n>1, 每个词元都由一个d维向量表示。 因此,在时间步t输入XtX_tXt将是一个n×dn \times dn×d矩阵.

4.3 困惑度(Perplexity)

困惑度(Perplexity)用以度量语言模型的质量,这将在后续部分中用于评估基于循环神经网络的模型。 一个好的语言模型能够用高度准确的词元来预测我们接下来会看到什么。 考虑一下由不同的语言模型给出的对“It is raining …”(“…下雨了”)的续写:

  • “It is raining outside”(外面下雨了);
  • “It is raining banana tree”(香蕉树下雨了);
  • “It is raining piouw;kcj pwepoiut”(piouw;kcj pwepoiut下雨了)。

就质量而言,例1显然是最合乎情理、在逻辑上最连贯的。我们可以通过计算序列的似然概率来度量模型的质量。 然而这是一个难以理解、难以比较的数字。 毕竟,较短的序列比较长的序列更有可能出现, 因此评估模型产生托尔斯泰的巨著《战争与和平》的可能性 不可避免地会比产生圣埃克苏佩里的中篇小说《小王子》可能性要小得多。 而缺少的可能性值相当于平均数。因此,我们常用困惑度来衡量自然语言处理的质量,它是一个序列中所有的n个词元的交叉熵损失的平均值的指数:
exp⁡(−1n∑t=1nlog⁡P(xt∣xt−1,…,x1))\exp \left(-\frac{1}{n} \sum_{t=1}^{n} \log P\left(x_{t} \mid x_{t-1}, \ldots, x_{1}\right)\right)exp(n1t=1nlogP(xtxt1,,x1))
困惑度的最好的理解是“下一个词元的实际选择数的调和平均数”。 在最好的情况下,模型总是完美地估计标签词元的概率为1。 在这种情况下,模型的困惑度为1。在最坏的情况下,模型总是预测标签词元的概率为0。 在这种情况下,困惑度是正无穷大。在基线上,该模型的预测是词表的所有可用词元上的均匀分布。 在这种情况下,困惑度等于词表中唯一词元的数量。 事实上,如果我们在没有任何压缩的情况下存储序列, 这将是我们能做的最好的编码方式。 因此,这种方式提供了一个重要的上限, 而任何实际模型都必须超越这个上限。

4.4 循环神经网络的从零实现

首先读取数据集。

#读取数据集
import math
import torch
from torch import nn
from torch.nn import functional as F
import collections
import re
import os
import hashlib
import requests
import random
#读取数据集
DATA_HUB = dict()
DATA_URL = 'http://d2l-data.s3-accelerate.amazonaws.com/'

DATA_HUB['time_machine'] = (DATA_URL + 'timemachine.txt','090b5e7e70c295757f55df93cb0a180b9691891a')

#下载数据集
def download(name, cache_dir=os.path.join('..','data')):
    assert name in DATA_HUB, f'{name} 不存在 {DATA_HUB}'
    url, sha1_hash = DATA_HUB[name]
    os.makedirs(cache_dir, exist_ok=True)
    fname = os.path.join(cache_dir, url.split('/')[-1])
    if os.path.exists(fname):
        sha1 = hashlib.sha1()
        with open(fname, 'rb') as f:
            while True:
                data = f.read(1048576)
                if not data:
                    break
                sha1.update(data)
        if sha1.hexdigest() == sha1_hash:
            return fname
    print(f'正在从{url}下载{fname}...')
    r = requests.get(url, stream=True, verify=True)
    with open(fname, 'wb') as f:
        f.write(r.content)
    return fname

def read_time_machine():
    """将时间机器数据集加载到文本行的列表中"""
    with open(download('time_machine'), 'r') as f:
        lines = f.readlines()
    return [re.sub('[^A-Za-z]+', ' ', line).strip().lower() for line in lines]

lines = read_time_machine()

#词元化
def tokenize(lines, token='word'):
    """将文本行拆分为单词或字符词元"""
    if token == 'word':
        return [line.split() for line in lines]
    elif token == 'char':
        return [list(line) for line in lines]
    else:
        print('错误:未知词元类型:' + token)

tokens = tokenize(lines)

#搭建词表
class Vocab:
    """文本词表"""
    def __init__(self, tokens=None, min_freq=0, reserved_tokens=None):
        if tokens is None:
            tokens = []
        if reserved_tokens is None:
            reserved_tokens = []
        #按出现频率排序
        counter = count_corpus(tokens)
        self._token_freqs = sorted(counter.items(), key=lambda x: x[1], reverse=True)
        #未知词元的索引维0
        self.idx_to_token = ['<unk>'] + reserved_tokens
        self.token_to_idx = {token: idx
                             for idx, token in enumerate(self.idx_to_token)}
        for token, freq in self._token_freqs:
            if freq < min_freq:
                break
            if token not in self.token_to_idx:
                self.idx_to_token.append(token)
                self.token_to_idx[token] = len(self.idx_to_token) - 1

    def __len__(self):
        return len(self.idx_to_token)
    
    def __getitem__(self, tokens):
        if not isinstance(tokens, (list, tuple)):
            return self.token_to_idx.get(tokens, self.unk)
        return [self.__getitem__(token) for token in tokens]

    def to_tokens(self, indices):
        if not isinstance(indices, (list, tuple)):
            return self.idx_to_token[indices]
        return [self.idx_to_token[index] for index in indices]
    
    @property
    def unk(self):#未知词元的索引为0
        return 0
    
    @property
    def token_freqs(self):
        return self._token_freqs
    
def count_corpus(tokens):
    """统计词元的频率"""
    #这里的tokens是1D列表或2D列表
    if len(tokens) == 0 or isinstance(tokens[0], list):
        #将词元列表展平成一个列表
        tokens = [token for line in tokens for token in line]
    return collections.Counter(tokens)

vocab = Vocab(tokens)

def load_corpus_time_machine(max_tokens=-1):
    """返回时光机器数据集的词元索引列表和词表"""
    lines = read_time_machine()
    tokens = tokenize(lines, 'char')
    vocab = Vocab(tokens)
    # 因为时光机器数据集中的每个文本行不一定是一个句子或一个段落,所以将所有文本行展平到一个列表中
    corpus = [vocab[token] for line in tokens for token in line]
    if max_tokens > 0:
        corpus = corpus[: max_tokens]
    return corpus, vocab

corpus, vocab = load_corpus_time_machine()
len(corpus), len(vocab)

def seq_data_iter_random(corpus, batch_size, num_steps):
    """使用随机抽样生成一个小批量子序列"""
    # 从随机偏移量开始对序列进行分区,随机范围包括num_steps-1
    corpus = corpus[random.randint(0, num_steps - 1):]
    # 减去1,是因为我们需要考虑标签
    num_subseqs = (len(corpus) - 1) // num_steps
    # 长度为num_steps的子序列的起始索引
    initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
    # 在随机抽样的迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻
    random.shuffle(initial_indices)

    def data(pos):
        # 返回从pos位置开始的长度为num_steps的序列
        return corpus[pos: pos + num_steps]

    num_batches = num_subseqs // batch_size
    for i in range(0, batch_size * num_batches, batch_size):
        # 在这里,initial_indices包含子序列的随机起始索引
        initial_indices_per_batch = initial_indices[i: i + batch_size]
        X = [data(j) for j in initial_indices_per_batch]
        Y = [data(j + 1) for j in initial_indices_per_batch]
        yield torch.tensor(X), torch.tensor(Y)

def seq_data_iter_sequential(corpus, batch_size, num_steps):
    """使用顺序分区生成一个小批量子序列"""
    # 从随机偏移量开始划分序列
    offset = random.randint(0, num_steps)
    num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
    Xs = torch.tensor(corpus[offset: offset + num_tokens])
    Ys = torch.tensor(corpus[offset + 1: offset + 1 + num_tokens])
    Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
    num_batches = Xs.shape[1] // num_steps
    for i in range(0, num_steps * num_batches, num_steps):
        X = Xs[:, i: i + num_steps]
        Y = Ys[:, i: i + num_steps]
        yield X, Y

#封装数据迭代器
class SeqDataLoader:
    """加载序列数据的迭代器"""
    def __init__(self, batch_size, num_steps, use_random_iter, max_tokens):
        if use_random_iter:
            self.data_iter_fn = seq_data_iter_random
        else:
            self.data_iter_fn = seq_data_iter_sequential
        self.corpus, self.vocab = load_corpus_time_machine(max_tokens)
        self.batch_size, self.num_steps = batch_size, num_steps

    def __iter__(self):
        return self.data_iter_fn(self.corpus, self.batch_size, self.num_steps)

def load_data_time_machine(batch_size, num_steps,  #@save
                           use_random_iter=False, max_tokens=10000):
    """返回时光机器数据集的迭代器和词表"""
    data_iter = SeqDataLoader(
        batch_size, num_steps, use_random_iter, max_tokens)
    return data_iter, data_iter.vocab

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

在train_iter中,每个词元都表示为一个数字索引, 将这些索引直接输入神经网络可能会使学习变得困难。 我们通常将每个词元表示为更具表现力的特征向量。 最简单的表示称为独热编码(one-hot encoding)。简言之,将每个索引映射为相互不同的单位向量: 假设词表中不同词元的数目为N(即len(vocab)), 词元索引的范围为0到N-1。 如果词元的索引是整数i, 那么我们将创建一个长度为N的全0向量, 并将第i处的元素设置为1。 此向量是原始词元的一个独热向量。 索引为0和2的独热向量如下所示:

F.one_hot(torch.tensor([0, 2]), len(vocab))

得到:

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, 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]])

我们每次采样的小批量数据形状是二维张量: (批量大小,时间步数)。 one_hot函数将这样一个小批量数据转换成三维张量, 张量的最后一个维度等于词表大小(len(vocab))。 我们经常转换输入的维度,以便获得形状为 **(时间步数,批量大小,词表大小)**的输出。 这将使我们能够更方便地通过最外层的维度, 一步一步地更新小批量数据的隐状态。

X = torch.arange(10).reshape((2, 5))
F.one_hot(X.T, 28).shape

得到:

torch.Size([5, 2, 28])

4.4.1 初始化模型参数

隐藏单元数num_hiddens是一个可调的超参数。 当训练语言模型时,输入和输出来自相同的词表。 因此,它们具有相同的维度,即词表的大小。

#初始化模型参数
def get_params(vocab_size, num_hiddens, device):
    num_inputs = num_outputs = vocab_size

    def normal(shape):
        return torch.randn(size=shape, device=device) * 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

4.4.2 模型框架

为了定义循环神经网络模型, 我们首先需要一个init_rnn_state函数在初始化时返回隐状态。 这个函数的返回是一个张量,张量全用0填充, 形状为 (批量大小,隐藏单元数)。 在后面的章节中我们将会遇到隐状态包含多个变量的情况, 而使用元组可以更容易地处理些。

#返回隐状态函数
def init_rnn_state(batch_size, num_hiddens, device):
    return (torch.zeros((batch_size, num_hiddens), device=device), )

下面的rnn函数定义了如何在一个时间步内计算隐状态和输出。 循环神经网络模型通过inputs最外层的维度实现循环, 以便逐时间步更新小批量数据的隐状态H。 此外,这里使用tanh函数作为激活函数。当元素在实数上满足均匀分布时,tanh函数的平均值为0。

def rnn(inputs, state, params):
    #inputs的形状:(时间步数量,批量大小,词表大小)
    W_xh, W_hh, b_h, W_hq, b_q = params
    H, = state
    outputs = []
    #X的形状:(批量大小,词表大小)
    for X in inputs:
        H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)
        Y = torch.mm(H, W_hq) + b_q
        outputs.append(Y)
    return torch.cat(outputs, dim=0), (H,)

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

#封装函数
class RNNModelScratch: 
    """从零开始实现的循环神经网络模型"""
    def __init__(self, vocab_size, num_hiddens, device,
                 get_params, init_state, forward_fn):
        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 = 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):
        return self.init_state(batch_size, self.num_hiddens, device)

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

def try_gpu(i=0):
    if torch.cuda.device_count() >= i + 1:
        return torch.device(f'cuda:{i}')
    return torch.device('cpu')

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

输出得到:

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

可以看到输出形状是(时间步数*批量大小,词表大小), 而隐状态形状保持不变,即(批量大小,隐藏单元数)。

4.4.3 模型预测

首先定义预测函数来生成prefix之后的新字符, 其中的prefix是一个用户提供的包含多个字符的字符串。 在循环遍历prefix中的开始字符时, 我们不断地将隐状态传递到下一个时间步,但是不生成任何输出。 这被称为预热(warm-up)期, 因为在此期间模型会自我更新(例如,更新隐状态), 但不会进行预测。 预热期结束后,隐状态的值通常比刚开始的初始值更适合预测, 从而预测字符并输出它们。

#模型预测
def predict_ch8(prefix, num_preds, net, vocab, device):
    """在prefix后面生成新字符"""
    state = net.begin_state(batch_size=1, device=device) # 初始化模型的隐藏状态,设定在指定的设备上
    outputs = [vocab[prefix[0]]] # 将prefix的第一个字符转换为对应的索引并存储在outputs中
    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])  # 将当前字符索引加入到输出列表中
    for _ in range(num_preds): #预测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]) # 将输出的字符索引转换为实际字符并拼接成字符串

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

#生成预测函数
predict_ch8('time traveller', 10, net, vocab, try_gpu())

输出为:

'time traveller ycotmlkxfo'

4.4.4 梯度裁剪

对于长度为T的序列,在迭代中计算这T个时间步上的梯度时将会在反向传播过程中产生长度为$\mathcal{O}(\mathrm {T} ) 的矩阵乘法链。当T较大时,它可能导致数值不稳定,可能产生梯度爆炸或者梯度消失。因此,循环神经网络模型往往需要额外的方式来支持稳定训练。一般来说,当解决优化问题时,我们对模型参数采用更新步骤。假定在向量形式的x中,或者在小批量数据的负梯度g方向上。例如,使用的矩阵乘法链。当T较大时,它可能导致数值不稳定,可能产生梯度爆炸或者梯度消失。因此,循环神经网络模型往往需要额外的方式来支持稳定训练。 一般来说,当解决优化问题时,我们对模型参数采用更新步骤。假定在向量形式的x中,或者在小批量数据的负梯度g方向上。例如,使用的矩阵乘法链。当T较大时,它可能导致数值不稳定,可能产生梯度爆炸或者梯度消失。因此,循环神经网络模型往往需要额外的方式来支持稳定训练。一般来说,当解决优化问题时,我们对模型参数采用更新步骤。假定在向量形式的x中,或者在小批量数据的负梯度g方向上。例如,使用\eta > 0作为学习率时,在一次迭代中,我们将x更新为作为学习率时,在一次迭代中,我们将x更新为作为学习率时,在一次迭代中,我们将x更新为x- \eta g$。如果我们进一步假设目标函数f表现良好,即函数f在常数L下是利普西茨连续的。也就是说,对于任意x和y有:
∣f(x)−f(y)∣≤L∣∣x−y∣∣|f(x)-f(y)|\le L||x-y||f(x)f(y)L∣∣xy∣∣
在这里我们假设:如果我们通过ηg\eta gηg更新参数向量,则有:
∣f(x)−f(x−ηg)∣≤Lη∣∣g∣∣|f(x)-f(x- \eta g)| \le L\eta ||g||f(x)f(xηg)Lη∣∣g∣∣
这意味着我们不会观察到超过Lη∣∣g∣∣L\eta ||g||Lη∣∣g∣∣的变化。这既是坏事也是好事。 坏的方面,它限制了取得进展的速度; 好的方面,它限制了事情变糟的程度,尤其当我们朝着错误的方向前进时。有时梯度可能很大,从而优化算法可能无法收敛。 我们可以通过降低$\eta 的学习率来解决这个问题。但是如果我们很少得到大的梯度呢?在这种情况下,这种做法似乎毫无道理。一个流行的替代方案是通过将梯度g投影回给定半径(例如的学习率来解决这个问题。 但是如果我们很少得到大的梯度呢? 在这种情况下,这种做法似乎毫无道理。 一个流行的替代方案是通过将梯度g投影回给定半径 (例如的学习率来解决这个问题。但是如果我们很少得到大的梯度呢?在这种情况下,这种做法似乎毫无道理。一个流行的替代方案是通过将梯度g投影回给定半径(例如\theta$)的球来裁剪梯度g。 如下式:
g←min⁡(1,θ∥g∥)g \mathbf{g} \leftarrow \min \left(1, \frac{\theta}{\|\mathbf{g}\|}\right) \mathbf{g}gmin(1,gθ)g
通过这样做,我们知道梯度范数永远不会超过θ\thetaθ, 并且更新后的梯度完全与g的原始方向对齐。 它还有一个值得拥有的副作用, 即限制任何给定的小批量数据(以及其中任何给定的样本)对参数向量的影响, 这赋予了模型一定程度的稳定性。 梯度裁剪提供了一个快速修复梯度爆炸的方法, 虽然它并不能完全解决问题,但它是众多有效的技术之一。

下面我们定义一个函数来裁剪模型的梯度, 模型是从零开始实现的模型或由高级API构建的模型。 我们在此计算了所有模型参数的梯度的范数。

#裁剪梯度
def grad_clipping(net, theta):
    """裁剪梯度"""
    if isinstance(net, nn.Module):
        params = [p for p in net.parameters() if p.requires_grad]
    else:
        params = net.params
    norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
    if norm > theta:
        for param in params:
            param.grad[:] *= theta / norm

4.4.5 模型训练

在训练模型之前,让我们定义一个函数在一个迭代周期内训练模型。 与之前的训练模型有三个不同之处。

  • 序列数据的不同采样方法(随机采样和顺序分区)将导致隐状态初始化的差异;
  • 在更新模型参数之前裁剪梯度。 这样的操作的目的是,即使训练过程中某个点上发生了梯度爆炸,也能保证模型不会发散;
  • 用困惑度来评价模型。这样的度量确保了不同长度的序列具有可比性

具体来说,当使用顺序分区时,我们只在每个迭代周期的开始位置初始化隐状态。由于下一个小批量数据中给的第i个子序列样本与当前第i个子序列样本相邻,因此当前小批量数据最后一个样本的隐状态,将用于初始化下一个小批量数据第一个样本的隐状态。这样,存储在隐状态中的序列的历史信息可以在一个迭代周期内流经相邻的子序列。然而,在任何一点隐状态的计算,都依赖于同一迭代周期中前面所有的小批量数据,这使得梯度计算变得复杂。为了降低计算量,在处理任何一个小批量数据之前,我们先分离梯度,使得隐状态的梯度计算总是限制在一个小批量数据的时间步内。
当使用随机抽样时,因为每个样本都是在一个随机位置抽样的, 因此需要为每个迭代周期重新初始化隐状态。 updater是更新模型参数的常用函数。 它既可以是从头开始实现的sgd函数, 也可以是深度学习框架中内置的优化函数。

#定义计时器
class Timer:
    def __init__(self):
        self.times = []
        self.start()

    def start(self):
        self.tik = time.time()

    def stop(self):
        self.times.append(time.time() - self.tik)
        return self.times[-1]
    
    def avg(self):
        return sum(self.times) / len(self.times)
    
    def sum(self):
        return sum(self.times)
    
    def cumsum(self):
        return np.array(self.times).cumsum().tolist()
    
#定义程序Accumulator
class Accumulator:  #@save
    """在n个变量上累加"""
    def __init__(self, n):
        self.data = [0.0] * n

    def add(self, *args):
        self.data = [a + float(b) for a, b in zip(self.data, args)]

    def reset(self):
        self.data = [0.0] * len(self.data)

    def __getitem__(self, idx):
        return self.data[idx]

#模型训练
def train_epoch_ch8(net, train_iter, loss, updater, device, 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
            state = net.begin_state(batch_size=X.shape[0], device=device)
        else:
            if isinstance(net, nn.Module) and not isinstance(state, tuple):
                # state对于nn.GRU是个张量
                state.detach_()
            else:
                # state对于nn.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)
        l = loss(y_hat, y.long()).mean()
        if isinstance(updater, torch.optim.Optimizer):
            updater.zero_grad()
            l.backwawrd()
            grad_clipping(net, 1)
            updater.step()
        else:
            l.backward()
            grad_clipping(net, 1)
            # 因为已经调用了mean函数
            updater(batch_size=1)
        metric.add(1 * y.numel(), y.numel())
    return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()

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

class Animator:
    def __init__(self, xlabel=None, ylabel=None, legend=None, xlim=None, ylim=None, xscale='linear', yscale='linear',
                fmts=('-', 'm--', 'g-', 'r:'), nrows=1, ncols=1, figsize=(3.5, 2.5)):
        if legend is None:
            legend = []
            
        self.fig, self.axes = plt.subplots(nrows, ncols, figsize=figsize)
        if nrows * ncols == 1:
            self.axes = [self.axes,]

        # self.config_axes = lambda:self.set_axes(
        #     self.axes[0], xlabel, ylabel, xlim, ylim, xscale, yscale, legend)
        self.X, self.Y, self.fmts = None, None, fmts
        self.set_axes(xlabel, ylabel, xlim, ylim, xscale, yscale, legend)

    def set_axes(self, xlabel, ylabel, xlim, ylim, xscale, yscale, legend):
        """设置轴"""
        for ax in self.axes:
            ax.set_xlabel(xlabel)
            ax.set_ylabel(ylabel)
            ax.set_xscale(xscale)
            ax.set_yscale(yscale)
            ax.set_xlim(xlim)
            ax.set_ylim(ylim)
            if legend:
                ax.legend(legend)
            ax.grid()
            
    def add(self, x, y):
        if not hasattr(y, '__len__'):
            y = [y]
        n = len(y)
        if not hasattr(x, "__len__"):
            x = [x] * n
        if not self.X:
            self.X = [[] for _ in range(n)]
        if not self.Y:
            self.Y = [[] for _ in range(n)]
        for i, (a, b) in enumerate(zip(x, y)):
            if a is not None and b is not None:
                self.X[i].append(a)
                self.Y[i].append(b)

        for ax in self.axes:
            ax.cla()
            for x, y, fmt in zip(self.X, self.Y, self.fmts):
                ax.plot(x, y, fmt)
        display.display(self.fig)
        display.clear_output(wait=True)

#定义优化算法
#小批量随机梯度下降
def sgd(params, lr, batch_size):
    with torch.no_grad():
        for param in params:
            param -= lr * param.grad / batch_size
            param.grad.zero_()

#API实现RNN
def train_ch8(net, train_iter, vocab, lr, num_epochs, device, use_random_iter=False):
    """训练模型"""
    loss = nn.CrossEntropyLoss()
    animator = Animator(xlabel='epoch', ylabel='perplexity', legend=['train'], xlim=[10, num_epochs])
    #初始化
    if isinstance(net, nn.Module):
        updater = torch.optim.SGD(net.parameters(), 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)
        if (epoch + 1) % 10 == 0:
            print(predict('time traveller'))
            animator.add(epoch + 1, [ppl])
    print(f'困惑度 {ppl:.1f}, {speed:.1f} 词元/秒 {str(device)}')
    print(predict('time traveller'))
    print(predict('traveller'))

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

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

输出得到:

困惑度 1.0, 67212.6 词元/秒 cuda:0
time traveller for so it will be convenient to speak of himwas e
travelleryou can show black is white by argument said filby

在这里插入图片描述

使用随机抽样方法的结果:

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

输出得到:

困惑度 1.5, 65222.3 词元/秒 cuda:0
time traveller held in his hand was a glitteringmetallic framewo
traveller but now you begin to seethe object of my investig

在这里插入图片描述

4.5 循环神经网络的简洁实现

虽然上节对循环神经网络的实现方式具有指导意义,但并不方便。本节将展示如何使用深度学习框架的高级API提供的函数更有效地实现相同的语言模型。 我们仍然从读取时光机器数据集开始。

#读取数据集
import math
import torch
from torch import nn
from torch.nn import functional as F
import collections
import re
import os
import hashlib
import requests
import random

#读取数据集
DATA_HUB = dict()
DATA_URL = 'http://d2l-data.s3-accelerate.amazonaws.com/'

DATA_HUB['time_machine'] = (DATA_URL + 'timemachine.txt','090b5e7e70c295757f55df93cb0a180b9691891a')

#下载数据集
def download(name, cache_dir=os.path.join('..','data')):
    assert name in DATA_HUB, f'{name} 不存在 {DATA_HUB}'
    url, sha1_hash = DATA_HUB[name]
    os.makedirs(cache_dir, exist_ok=True)
    fname = os.path.join(cache_dir, url.split('/')[-1])
    if os.path.exists(fname):
        sha1 = hashlib.sha1()
        with open(fname, 'rb') as f:
            while True:
                data = f.read(1048576)
                if not data:
                    break
                sha1.update(data)
        if sha1.hexdigest() == sha1_hash:
            return fname
    print(f'正在从{url}下载{fname}...')
    r = requests.get(url, stream=True, verify=True)
    with open(fname, 'wb') as f:
        f.write(r.content)
    return fname

def read_time_machine():
    """将时间机器数据集加载到文本行的列表中"""
    with open(download('time_machine'), 'r') as f:
        lines = f.readlines()
    return [re.sub('[^A-Za-z]+', ' ', line).strip().lower() for line in lines]

lines = read_time_machine()

#词元化
def tokenize(lines, token='word'):
    """将文本行拆分为单词或字符词元"""
    if token == 'word':
        return [line.split() for line in lines]
    elif token == 'char':
        return [list(line) for line in lines]
    else:
        print('错误:未知词元类型:' + token)

tokens = tokenize(lines)

#搭建词表
class Vocab:
    """文本词表"""
    def __init__(self, tokens=None, min_freq=0, reserved_tokens=None):
        if tokens is None:
            tokens = []
        if reserved_tokens is None:
            reserved_tokens = []
        #按出现频率排序
        counter = count_corpus(tokens)
        self._token_freqs = sorted(counter.items(), key=lambda x: x[1], reverse=True)
        #未知词元的索引维0
        self.idx_to_token = ['<unk>'] + reserved_tokens
        self.token_to_idx = {token: idx
                             for idx, token in enumerate(self.idx_to_token)}
        for token, freq in self._token_freqs:
            if freq < min_freq:
                break
            if token not in self.token_to_idx:
                self.idx_to_token.append(token)
                self.token_to_idx[token] = len(self.idx_to_token) - 1

    def __len__(self):
        return len(self.idx_to_token)
    
    def __getitem__(self, tokens):
        if not isinstance(tokens, (list, tuple)):
            return self.token_to_idx.get(tokens, self.unk)
        return [self.__getitem__(token) for token in tokens]

    def to_tokens(self, indices):
        if not isinstance(indices, (list, tuple)):
            return self.idx_to_token[indices]
        return [self.idx_to_token[index] for index in indices]
    
    @property
    def unk(self):#未知词元的索引为0
        return 0
    
    @property
    def token_freqs(self):
        return self._token_freqs
    
def count_corpus(tokens):
    """统计词元的频率"""
    #这里的tokens是1D列表或2D列表
    if len(tokens) == 0 or isinstance(tokens[0], list):
        #将词元列表展平成一个列表
        tokens = [token for line in tokens for token in line]
    return collections.Counter(tokens)

vocab = Vocab(tokens)

def load_corpus_time_machine(max_tokens=-1):
    """返回时光机器数据集的词元索引列表和词表"""
    lines = read_time_machine()
    tokens = tokenize(lines, 'char')
    vocab = Vocab(tokens)
    # 因为时光机器数据集中的每个文本行不一定是一个句子或一个段落,所以将所有文本行展平到一个列表中
    corpus = [vocab[token] for line in tokens for token in line]
    if max_tokens > 0:
        corpus = corpus[: max_tokens]
    return corpus, vocab

corpus, vocab = load_corpus_time_machine()
len(corpus), len(vocab)

def seq_data_iter_random(corpus, batch_size, num_steps):
    """使用随机抽样生成一个小批量子序列"""
    # 从随机偏移量开始对序列进行分区,随机范围包括num_steps-1
    corpus = corpus[random.randint(0, num_steps - 1):]
    # 减去1,是因为我们需要考虑标签
    num_subseqs = (len(corpus) - 1) // num_steps
    # 长度为num_steps的子序列的起始索引
    initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
    # 在随机抽样的迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻
    random.shuffle(initial_indices)

    def data(pos):
        # 返回从pos位置开始的长度为num_steps的序列
        return corpus[pos: pos + num_steps]

    num_batches = num_subseqs // batch_size
    for i in range(0, batch_size * num_batches, batch_size):
        # 在这里,initial_indices包含子序列的随机起始索引
        initial_indices_per_batch = initial_indices[i: i + batch_size]
        X = [data(j) for j in initial_indices_per_batch]
        Y = [data(j + 1) for j in initial_indices_per_batch]
        yield torch.tensor(X), torch.tensor(Y)

def seq_data_iter_sequential(corpus, batch_size, num_steps):
    """使用顺序分区生成一个小批量子序列"""
    # 从随机偏移量开始划分序列
    offset = random.randint(0, num_steps)
    num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
    Xs = torch.tensor(corpus[offset: offset + num_tokens])
    Ys = torch.tensor(corpus[offset + 1: offset + 1 + num_tokens])
    Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
    num_batches = Xs.shape[1] // num_steps
    for i in range(0, num_steps * num_batches, num_steps):
        X = Xs[:, i: i + num_steps]
        Y = Ys[:, i: i + num_steps]
        yield X, Y

#封装数据迭代器
class SeqDataLoader:
    """加载序列数据的迭代器"""
    def __init__(self, batch_size, num_steps, use_random_iter, max_tokens):
        if use_random_iter:
            self.data_iter_fn = seq_data_iter_random
        else:
            self.data_iter_fn = seq_data_iter_sequential
        self.corpus, self.vocab = load_corpus_time_machine(max_tokens)
        self.batch_size, self.num_steps = batch_size, num_steps

    def __iter__(self):
        return self.data_iter_fn(self.corpus, self.batch_size, self.num_steps)

def load_data_time_machine(batch_size, num_steps,  #@save
                           use_random_iter=False, max_tokens=10000):
    """返回时光机器数据集的迭代器和词表"""
    data_iter = SeqDataLoader(
        batch_size, num_steps, use_random_iter, max_tokens)
    return data_iter, data_iter.vocab

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

高级API提供了循环神经网络的实现。 我们构造一个具有256个隐藏单元的单隐藏层的循环神经网络层rnn_layer。 事实上,我们还没有讨论多层循环神经网络的意义。 现在仅需要将多层理解为一层循环神经网络的输出被用作下一层循环神经网络的输入就足够了。

#模型定义
num_hiddens = 256
rnn_layer = nn.RNN(len(vocab), num_hiddens)

我们使用张量来初始化隐状态,它的形状是 (隐藏层数,批量大小,隐藏单元数)

#初始化隐状态
state = torch.zeros((1, batch_size, num_hiddens))
state.shape

得到结果:

torch.Size([1, 32, 256])

通过一个隐状态和一个输入,我们就可以用更新后的隐状态计算输出。需要强调的是,rnn_layer的输出(Y)不涉及输出层的计算:它是指每个时间步的隐状态,这些隐状态可以用作后续输出层的输入。

X = torch.rand(size=(num_steps, batch_size, len(vocab)))
Y, state_new = rnn_layer(X, state)
Y.shape, state_new.shape

输出得到:

(torch.Size([35, 32, 256]), torch.Size([1, 32, 256]))

在rnn_layer中只包含隐藏的循环层,现在我们创建一个单独的输出层,定义一个完整的RNNModule类。

#RNNModel
class RNNModel(nn.Module):
    """循环神经网络模型"""
    def __init__(self, rnn_layer, vocab_size, **kwargs):
        super(RNNModel, self).__init__(**kwargs)
        self.rnn = rnn_layer
        self.vocab_size = vocab_size
        self.num_hiddens = self.rnn.hidden_size
        # 如果RNN是双向的(之后将介绍),num_directions应该是2,否则应该是1
        if not self.rnn.bidirectional:
            self.num_directions = 1
            self.linear = nn.Linear(self.num_hiddens, self.vocab_size)
        else:
            self.num_directions = 2
            self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)

    def forward(self, inputs, state):
        X = F.one_hot(inputs.T.long(), self.vocab_size)
        X = X.to(torch.float32)
        Y, state = self.rnn(X, state)
        # 全连接层首先将Y的形状改为(时间步数*批量大小,隐藏单元数)
        # 它的输出形状是(时间步数*批量大小,词表大小)。
        output = self.linear(Y.reshape((-1, Y.shape[-1])))
        return output, state

    def begin_state(self, device, batch_size=1):
        if not isinstance(self.rnn, nn.LSTM):
            # nn.GRU以张量作为隐状态
            return  torch.zeros((self.num_directions * self.rnn.num_layers,
                                 batch_size, self.num_hiddens),
                                device=device)
        else:
            # nn.LSTM以元组作为隐状态
            return (torch.zeros((
                self.num_directions * self.rnn.num_layers,
                batch_size, self.num_hiddens), device=device),
                    torch.zeros((
                        self.num_directions * self.rnn.num_layers,
                        batch_size, self.num_hiddens), device=device))

在训练模型之前,让我们基于一个具有随机权重的模型进行预测。

#基于随机权重的模型训练
device = try_gpu()
net = RNNModel(rnn_layer, vocab_size=len(vocab))
net = net.to(device)
predict_ch8('time traveller', 10, net, vocab, device)

很明显,这种模型根本不能输出好的结果。 接下来,我们使用之前定义的超参数调用train_ch8,并且使用高级API训练模型。

num_epochs, lr = 500, 1
train_ch8(net, train_iter, vocab, lr, num_epochs, device)

输出得到:

perplexity 1.3, 404413.8 tokens/sec on cuda:0
time travellerit would be remarkably convenient for the historia
travellery of il the hise fupt might and st was it loflers

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Faxxtty

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值