动手学深度学习(第六章 循环神经网络)

文本预处理

文本是一类序列数据,一篇文章可以看作是字符或单词的序列,本节将介绍文本数据的常见预处理步骤,预处理通常包括四个步骤:

  1. 读入文本
  2. 分词
  3. 建立字典,将每个词映射到一个唯一的索引(index)
  4. 将文本从词的序列转换为索引的序列,方便输入模型

读入文本

我们用一部英文小说,莎士比亚的罗密哦与朱丽叶作品作为示例,展示文本预处理的具体过程。

import collections
import re


def read_time_machine():
    with open('1513.txt', 'r',encoding='gbk',errors='ignore') as f:
        lines = [re.sub('[^a-z]+', ' ', line.strip().lower()) for line in f]
    return lines


lines = read_time_machine()
print('# sentences %d' % len(lines))

# sentences 5668

分词

我们对每个句子进行分词,也就是将一个句子划分成若干个词(token),转换为一个词的序列。

def tokenize(sentences, token='word'):
    """Split sentences into word or char tokens"""
    if token == 'word':
        return [sentence.split(' ') for sentence in sentences]
    elif token == 'char':
        return [list(sentence) for sentence in sentences]
    else:
        print('ERROR: unkown token type '+token)

tokens = tokenize(lines)
tokens[0:2]
[['', ''],
 ['project',
  'gutenberg',
  'romeo',
  'and',
  'juliet',
  'by',
  'william',
  'shakespeare']]

建立字典

为了方便模型处理,我们需要将字符串转换为数字。因此我们需要先构建一个字典(vocabulary),将每个词映射到一个唯一的索引编号。

class Vocab(object):
    def __init__(self, tokens, min_freq=0, use_special_tokens=False):
        counter = count_corpus(tokens)  #  根据下面定义的函数生成一个字典,记录每个词的出现次数
        self.token_freqs = list(counter.items())
        self.idx_to_token = []
        if use_special_tokens:
            # padding, begin of sentence, end of sentence, unknown
            self.pad, self.bos, self.eos, self.unk = (0, 1, 2, 3)
            self.idx_to_token += ['', '', '', '']
        else:
            self.unk = 0
            self.idx_to_token += ['']
        self.idx_to_token += [token for token, freq in self.token_freqs
                        if freq >= min_freq and token not in self.idx_to_token]
        self.token_to_idx = dict()
        for idx, token in enumerate(self.idx_to_token):
            self.token_to_idx[token] = idx

    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]

def count_corpus(sentences):
    tokens = [tk for st in sentences for tk in st]
    return collections.Counter(tokens)  # 返回一个字典,记录每个词的出现次数

我们看一个例子,这里我们尝试用Time Machine作为语料构建字典

vocab = Vocab(tokens)
print(list(vocab.token_to_idx.items())[0:10])
[('', 0), ('project', 1), ('gutenberg', 2), ('romeo', 3), ('and', 4), ('juliet', 5), ('by', 6), ('william', 7), ('shakespeare', 8), ('this', 9)]

将词转为索引

使用字典,我们可以将原文本中的句子从单词序列转换为索引序列

for i in range(8, 10):
    print('words:', tokens[i])
    print('indices:', vocab[tokens[i]])
words: ['have', 'to', 'check', 'the', 'laws', 'of', 'the', 'country', 'where', 'you', 'are', 'located', 'before', 'using']
indices: [52, 53, 54, 13, 55, 15, 13, 56, 57, 32, 48, 50, 58, 59]
words: ['this', 'ebook', '']
indices: [9, 10, 0]

用现有工具进行分词

我们前面介绍的分词方式非常简单,它至少有以下几个缺点:

  1. 标点符号通常可以提供语义信息,但是我们的方法直接将其丢弃了
  2. 类似“shouldn’t", "doesn’t"这样的词会被错误地处理
  3. 类似"Mr.", "Dr."这样的词会被错误地处理

我们可以通过引入更复杂的规则来解决这些问题,但是事实上,有一些现有的工具可以很好地进行分词,我们在这里简单介绍其中的两个:spaCyNLTK

下面是一个简单的例子:

text = "Mr. Chen doesn't agree with my suggestion."

spaCy:

import spacy
nlp = spacy.load('en_core_web_sm')
doc = nlp(text)
print([token.text for token in doc])
['Mr.', 'Chen', 'does', "n't", 'agree', 'with', 'my', 'suggestion', '.']

NLTK:

from nltk.tokenize import word_tokenize
from nltk import data
data.path.append('/home/kesci/input/nltk_data3784/nltk_data')
print(word_tokenize(text))
['Mr.', 'Chen', 'does', "n't", 'agree', 'with', 'my', 'suggestion', '.']

语言模型

语言模型(language model)是自然语言处理的重要技术。自然语言处理中最常见的数据是文本数据。我们可以把一段自然语言文本看作一段离散的时间序列。假设一段长度为 T T 的文本中的词依次为 w 1 , w 2 , , w T w_1, w_2, \ldots, w_T ,那么在离散的时间序列中, w t w_t 1 t T 1 \leq t \leq T )可看作在时间步(time step) t t 的输出或标签。给定一个长度为 T T 的词的序列 w 1 , w 2 , , w T w_1, w_2, \ldots, w_T ,语言模型将计算该序列的概率:

P ( w 1 , w 2 , , w T ) . P(w_1, w_2, \ldots, w_T).

语言模型可用于提升语音识别和机器翻译的性能。例如,在语音识别中,给定一段“厨房里食油用完了”的语音,有可能会输出“厨房里食油用完了”和“厨房里石油用完了”这两个读音完全一样的文本序列。如果语言模型判断出前者的概率大于后者的概率,我们就可以根据相同读音的语音输出“厨房里食油用完了”的文本序列。在机器翻译中,如果对英文“you go first”逐词翻译成中文的话,可能得到“你走先”“你先走”等排列方式的文本序列。如果语言模型判断出“你先走”的概率大于其他排列方式的文本序列的概率,我们就可以把“you go first”翻译成“你先走”。

既然语言模型很有用,那该如何计算它呢?假设序列 w 1 , w 2 , , w T w_1, w_2, \ldots, w_T 中的每个词是依次生成的,我们有

P ( w 1 , w 2 , , w T ) = t = 1 T P ( w t w 1 , , w t 1 ) . P(w_1, w_2, \ldots, w_T) = \prod_{t=1}^T P(w_t \mid w_1, \ldots, w_{t-1}).

例如,一段含有4个词的文本序列的概率

P ( w 1 , w 2 , w 3 , w 4 ) = P ( w 1 ) P ( w 2 w 1 ) P ( w 3 w 1 , w 2 ) P ( w 4 w 1 , w 2 , w 3 ) . P(w_1, w_2, w_3, w_4) = P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_1, w_2) P(w_4 \mid w_1, w_2, w_3).

为了计算语言模型,我们需要计算词的概率,以及一个词在给定前几个词的情况下的条件概率,即语言模型参数。设训练数据集为一个大型文本语料库,如维基百科的所有条目。词的概率可以通过该词在训练数据集中的相对词频来计算。例如, P ( w 1 ) P(w_1) 可以计算为 w 1 w_1 在训练数据集中的词频(词出现的次数)与训练数据集的总词数之比。因此,根据条件概率定义,一个词在给定前几个词的情况下的条件概率也可以通过训练数据集中的相对词频计算。例如, P ( w 2 w 1 ) P(w_2 \mid w_1) 可以计算为 w 1 , w 2 w_1, w_2 两词相邻的频率与 w 1 w_1 词频的比值,因为该比值即 P ( w 1 , w 2 ) P(w_1, w_2) P ( w 1 ) P(w_1) 之比;而 P ( w 3 w 1 , w 2 ) P(w_3 \mid w_1, w_2) 同理可以计算为 w 1 w_1 w 2 w_2 w 3 w_3 三词相邻的频率与 w 1 w_1 w 2 w_2 两词相邻的频率的比值。以此类推。

当序列长度增加时,计算和存储多个词共同出现的概率的复杂度会呈指数级增加。 n n 元语法通过马尔可夫假设(虽然并不一定成立)简化了语言模型的计算。这里的马尔可夫假设是指一个词的出现只与前面 n n 个词相关,即 n n 阶马尔可夫链(Markov chain of order n n )。如果 n = 1 n=1 ,那么有 P ( w 3 w 1 , w 2 ) = P ( w 3 w 2 ) P(w_3 \mid w_1, w_2) = P(w_3 \mid w_2) 。如果基于 n 1 n-1 阶马尔可夫链,我们可以将语言模型改写为

P ( w 1 , w 2 , , w T ) t = 1 T P ( w t w t ( n 1 ) , , w t 1 ) . P(w_1, w_2, \ldots, w_T) \approx \prod_{t=1}^T P(w_t \mid w_{t-(n-1)}, \ldots, w_{t-1}) .

以上也叫 n n 元语法( n n -grams)。它是基于 n 1 n - 1 阶马尔可夫链的概率语言模型。当 n n 分别为1、2和3时,我们将其分别称作一元语法(unigram)、二元语法(bigram)和三元语法(trigram)。例如,长度为4的序列 w 1 , w 2 , w 3 , w 4 w_1, w_2, w_3, w_4 在一元语法、二元语法和三元语法中的概率分别为

P ( w 1 , w 2 , w 3 , w 4 ) = P ( w 1 ) P ( w 2 ) P ( w 3 ) P ( w 4 ) ,   P ( w 1 , w 2 , w 3 , w 4 ) = P ( w 1 ) P ( w 2 w 1 ) P ( w 3 w 2 ) P ( w 4 w 3 ) ,   P ( w 1 , w 2 , w 3 , w 4 ) = P ( w 1 ) P ( w 2 w 1 ) P ( w 3 w 1 , w 2 ) P ( w 4 w 2 , w 3 ) . \begin{aligned} P(w_1, w_2, w_3, w_4) &= P(w_1) P(w_2) P(w_3) P(w_4) ,\ P(w_1, w_2, w_3, w_4) &= P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_2) P(w_4 \mid w_3) ,\ P(w_1, w_2, w_3, w_4) &= P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_1, w_2) P(w_4 \mid w_2, w_3) . \end{aligned}

n n 较小时, n n 元语法往往并不准确。例如,在一元语法中,由三个词组成的句子“你走先”和“你先走”的概率是一样的。然而,当 n n 较大时, n n 元语法需要计算并存储大量的词频和多词相邻频率。

import torch
import random
import zipfile
with zipfile.ZipFile(
        r'D:\研一课件\深度学习\pytorch\动手学深度学习\Dive-into-DL-PyTorch-master\data\jaychou_lyrics.txt.zip'
) as zin:
    with zin.open('jaychou_lyrics.txt') as f:
        corpus_chars = f.read().decode('utf-8')

# 查看前40个字符是什么
corpus_chars[:40]
'想要有直升机\n想要和你飞到宇宙去\n想要和你融化在一起\n融化在宇宙里\n我每天每天每'

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

corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
# \n是换行,\r回车
corpus_chars = corpus_chars[: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)
vocab_size # 1027
1027

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

corpus_indices = [char_to_idx[char] for char in corpus_chars]  #生成所以文本字符对应的index
sample = corpus_indices[:20]
print('chars:', ''.join([idx_to_char[idx] for idx in sample]))
print('indices:', sample)
chars: 想要有直升机 想要和你飞到宇宙去 想要和
indices: [176, 235, 907, 151, 81, 929, 2, 176, 235, 484, 356, 803, 90, 249, 651, 284, 2, 176, 235, 484]

以上代码封装在d2lzh_pytorch包里的load_data_jay_lyrics函数中,以方便后面章节调用。调用该函数后会依次得到corpus_indices、char_to_idx、idx_to_char和vocab_size这4个变量。

时序数据的采样

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

现在我们考虑序列“想要有直升机,想要和你飞到宇宙去”,如果时间步数为5,有以下可能的样本和标签:

  • X X :“想要有直升”, Y Y :“要有直升机”
  • X X :“要有直升机”, Y Y :“有直升机,”
  • X X :“有直升机,”, Y Y :“直升机,想”
  • X X :“要和你飞到”, Y Y :“和你飞到宇”
  • X X :“和你飞到宇”, Y Y :“你飞到宇宙”
  • X X :“你飞到宇宙”, Y Y :“飞到宇宙去”

可以看到,如果序列的长度为 T T ,时间步数为 n n ,那么一共有 T n T-n 个合法的样本,但是这些样本有大量的重合,我们通常采用更加高效的采样方式。我们有两种方式对时序数据进行采样,分别是随机采样和相邻采样。

随机采样

下面的代码每次从数据里随机采样一个小批量。其中批量大小batch_size是每个小批量的样本数,num_steps是每个样本所包含的时间步数。
在随机采样中,每个样本是原始序列上任意截取的一段序列,相邻的两个随机小批量在原始序列上的位置不一定相毗邻。

# 本函数已保存在d2lzh_pytorch包中方便以后使用
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):  #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')
X:  tensor([[ 0.,  1.,  2.,  3.,  4.,  5.],
        [12., 13., 14., 15., 16., 17.]]) 
Y: tensor([[ 1.,  2.,  3.,  4.,  5.,  6.],
        [13., 14., 15., 16., 17., 18.]]) 

X:  tensor([[18., 19., 20., 21., 22., 23.],
        [ 6.,  7.,  8.,  9., 10., 11.]]) 
Y: tensor([[19., 20., 21., 22., 23., 24.],
        [ 7.,  8.,  9., 10., 11., 12.]]) 

相邻采样

在相邻采样中,相邻的两个随机小批量在原始序列上的位置相毗邻。

# 本函数已保存在d2lzh_pytorch包中方便以后使用
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
for X, Y in data_iter_consecutive(my_seq, batch_size=2, num_steps=6):
    print('X: ', X, '\nY:', Y, '\n')
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.]]) 

循环神经网络

本节介绍循环神经网络,下图展示了如何基于循环神经网络实现语言模型。我们的目的是基于当前的输入与过去的输入序列,预测序列的下一个字符。循环神经网络引入一个隐藏变量 H H ,用 H t H_{t} 表示 H H 在时间步 t t 的值。 H t H_{t} 的计算基于 X t X_{t} H t 1 H_{t-1} ,可以认为 H t H_{t} 记录了到当前字符为止的序列信息,利用 H t H_{t} 对序列的下一个字符进行预测。
Image Name

循环神经网络的构造

我们先看循环神经网络的具体构造。假设 X t R n × d \boldsymbol{X}_t \in \mathbb{R}^{n \times d} 是时间步 t t 的小批量输入, H t R n × h \boldsymbol{H}_t \in \mathbb{R}^{n \times h} 是该时间步的隐藏变量,则:

H t = ϕ ( X t W x h + H t 1 W h h + b h ) . \boldsymbol{H}_t = \phi(\boldsymbol{X}_t \boldsymbol{W}_{xh} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hh} + \boldsymbol{b}_h).

其中, W x h R d × h \boldsymbol{W}_{xh} \in \mathbb{R}^{d \times h} W h h R h × h \boldsymbol{W}_{hh} \in \mathbb{R}^{h \times h} b h R 1 × h \boldsymbol{b}_{h} \in \mathbb{R}^{1 \times h} ϕ \phi 函数是非线性激活函数。由于引入了 H t 1 W h h \boldsymbol{H}_{t-1} \boldsymbol{W}_{hh} H t H_{t} 能够捕捉截至当前时间步的序列的历史信息,就像是神经网络当前时间步的状态或记忆一样。由于 H t H_{t} 的计算基于 H t 1 H_{t-1} ,上式的计算是循环的,使用循环计算的网络即循环神经网络(recurrent neural network)。

在时间步 t t ,输出层的输出为:

O t = H t W h q + b q . \boldsymbol{O}_t = \boldsymbol{H}_t \boldsymbol{W}_{hq} + \boldsymbol{b}_q.

其中 W h q R h × q \boldsymbol{W}_{hq} \in \mathbb{R}^{h \times q} b q R 1 × q \boldsymbol{b}_q \in \mathbb{R}^{1 \times q}

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

我们先尝试从零开始实现一个基于字符级循环神经网络的语言模型,这里我们使用周杰伦的歌词作为语料,首先我们读入数据:

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

with zipfile.ZipFile(
        r'D:\研一课件\深度学习\pytorch\动手学深度学习\Dive-into-DL-PyTorch-master\data\jaychou_lyrics.txt.zip'
) as zin:
    with zin.open('jaychou_lyrics.txt') as f:
        corpus_chars = f.read().decode('utf-8')
corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
corpus_chars = corpus_chars[0:10000]
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)
corpus_indices = [char_to_idx[char] for char in corpus_chars]

one-hot向量

我们需要将字符表示成向量,这里采用one-hot向量。假设词典大小是 N N ,每次字符对应一个从 0 0 N 1 N-1 的唯一的索引,则该字符的向量是一个长度为 N N 的向量,若字符的索引是 i i ,则该向量的第 i i 个位置为 1 1 ,其他位置为 0 0 。下面分别展示了索引为0和2的one-hot向量,向量长度等于词典大小。

def one_hot(x, n_class, dtype=torch.float32):
    result = torch.zeros(x.shape[0], n_class, dtype=dtype, device=x.device)  # shape: (n, n_class)
    result.scatter_(1, x.long().view(-1, 1), 1)  # result[i, x[i, 0]] = 1
    return result
    
x = torch.tensor([0, 2])
x_one_hot = one_hot(x, vocab_size)
print(x_one_hot)
print(x_one_hot.shape)
print(x_one_hot.sum(axis=1))
tensor([[1., 0., 0.,  ..., 0., 0., 0.],
        [0., 0., 1.,  ..., 0., 0., 0.]])
torch.Size([2, 1027])
tensor([1., 1.])

我们每次采样的小批量的形状是(批量大小, 时间步数)。下面的函数将这样的小批量变换成数个形状为(批量大小, 词典大小)的矩阵,矩阵个数等于时间步数。也就是说,时间步 t t 的输入为 X t R n × d \boldsymbol{X}_t \in \mathbb{R}^{n \times d} ,其中 n n 为批量大小, 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)
5 torch.Size([2, 1027])

初始化模型参数

下面我们初始化模型参数。注意隐藏单元个数 num_hiddens是一个超参数。

num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
# num_inputs: d
# num_hiddens: h, 隐藏单元的个数是超参数
# num_outputs: q
def get_params():
    def _one(shape):
        param = torch.zeros(shape, device=device, dtype=torch.float32)
        nn.init.normal_(param, 0, 0.01)
        return torch.nn.Parameter(param)

    # 隐藏层参数
    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))
    # 输出层参数
    W_hq = _one((num_hiddens, num_outputs))
    b_q = torch.nn.Parameter(torch.zeros(num_outputs, device=device))
    return (W_xh, W_hh, b_h, W_hq, b_q)

定义模型

函数rnn用循环的方式依次完成循环神经网络每个时间步的计算。

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

函数init_rnn_state初始化隐藏变量,这里的返回值是一个元组。

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

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

print(X)
print(X.shape)
print(num_hiddens)
print(vocab_size)
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(inputs), inputs[0].shape)
print(len(outputs), outputs[0].shape)
print(len(state), state[0].shape)
print(len(state_new), state_new[0].shape)
tensor([[0, 1, 2, 3, 4],
        [5, 6, 7, 8, 9]])
torch.Size([2, 5])
256
1027
5 torch.Size([2, 1027])
5 torch.Size([2, 1027])
1 torch.Size([2, 256])
1 torch.Size([2, 256])

裁剪梯度

循环神经网络中较容易出现梯度衰减或梯度爆炸,这会导致网络几乎无法训练。裁剪梯度(clip gradient)是一种应对梯度爆炸的方法。假设我们把所有模型参数的梯度拼接成一个向量 g \boldsymbol{g} ,并设裁剪的阈值是 θ \theta 。裁剪后的梯度

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

L 2 L_2 范数不超过 θ \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)

定义预测函数

以下函数基于前缀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]]]   # output记录prefix加上预测的num_chars个字符
    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(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)
'分开安娇虽模决秃转惹活手'

困惑度

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

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

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

定义模型训练函数

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

  1. 使用困惑度评价模型。
  2. 在迭代模型参数前裁剪梯度。
  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是num_steps个形状为(batch_size, vocab_size)的矩阵
            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),转置后再变成形状为
            # (num_steps * batch_size,)的向量,这样跟输出的行一一对应
            y = torch.flatten(Y.T)
            # 使用交叉熵损失计算平均分类误差
            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)
epoch 50, perplexity 72.709166, time 1.95 sec
 - 分开 我想要这 快 用双个人 快知的可爱  哼什么 我 我这 你 你着我的 快 你着我  你 你有我  
 - 不分开  想你你有  一知我有 我想你的可爱 像哼我有多  爱有你有 我 你这我  你的你有 我 你这我 
epoch 100, perplexity 10.803959, time 1.90 sec
 - 分开 一只用双截棍 哼哼哈兮 快使用双截棍 哼哼哈兮 我该好好生活 我知道好 你知我 别不我 你不就这样
 - 不分开永 我想你这辈你 后知我遇见你是一场悲剧 我想我这辈子注定一个人演  我 靠这你很单我 想散 你想很
epoch 150, perplexity 3.071530, time 2.31 sec
 - 分开 一直用 一步两步三步四步望著天 看星星 一颗两颗三颗四颗 连成线背著背默默许下心愿 看远方的星如果
 - 不分开吗 我后你爸 你我我妈 这样看吗 就是没空 不果有  你知没有 我想多烦着我 不知不觉 我跟了这节奏
epoch 200, perplexity 1.613822, time 2.15 sec
 - 分开 一直用老哭在举一 她的让午心比阳光射进教堂的角度 能知道你前世是狼人的漫言 传说就成了永垂不朽的诗
 - 不分开吗 然后将过去 你慢温习 让我爱上你 那场悲剧 是你完美演出的一场戏 宁愿心碎哭泣 再狠狠忘记 你爱
epoch 250, perplexity 1.306236, time 1.54 sec
 - 分开 一愿心老哭 三什躺停 温暖了日出 白色蜡烛 温暖了空屋 白色蜡烛 温暖了空屋 白色蜡烛 温暖了空屋
 - 不分开吗 我叫你爸 你打我妈 这样对吗干嘛这样 何必让酒牵鼻子走 瞎 说底你的肩车 我 想我胸口睡著 像这

接下来采用相邻采样训练模型并创作歌词。

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)
epoch 50, perplexity 60.453539, time 1.67 sec
 - 分开 我想要这不 我不能这不 我不能这不 我不能这不 我不能这不 我不能这不 我不能这不 我不能这不 我
 - 不分开 你不要你 你有了空 我有我的可写 我知在这生 你不的美 在我的外 在果我人 我想我这 你有我有 我
epoch 100, perplexity 7.257783, time 1.69 sec
 - 分开 一子她 一颗四颗三步四颗 连成线背著背 默你 回子再考倒我 说散 你想很久了吧? 我不你的黑色幽西
 - 不分开只 你已经 穿你载睛三着 所有人停留 几学人 有皮箱 装沉却三 回头一碗热粥 配上几斤的牛 一直安老
epoch 150, perplexity 2.055131, time 1.66 sec
 - 分开 我不要 爱怎么没有一 悲伤到看着都鹿的骨头 秃鹰盘旋 装水螂空 藤边再 废你在囱重一 悲我 你是到
 - 不分开觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 又过了一个秋 后知后觉 我该好好生活 我该好好生
epoch 200, perplexity 1.310121, time 1.70 sec
 - 分开 问候的黑旧笑 唱着歌拿我妈出气 我真的看不下去 以为我较细汉 从小到大只有妈妈的温暖  为什么我爸
 - 不分开觉我会想透 平知不一个永慢然 我办女神 整颗心悬在半空在 默必下钟三铁阳衫 他们儿子我习惯 从小杂迷
epoch 250, perplexity 1.211576, time 1.67 sec
 - 分开 问候是 我怎么 说壶么 我给了暴督自我 别发抖 快给我抬起头 有话去对医药箱说 别怪我 别怪我 说
 - 不分开觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 又过了一个秋 后知后觉 我该好好生活 我该好好生

循环神经网络的简介实现

定义模型

我们使用Pytorch中的nn.RNN来构造循环神经网络。在本节中,我们主要关注nn.RNN的以下几个构造函数参数:

  • input_size - The number of expected features in the input x
  • hidden_size – The number of features in the hidden state h
  • nonlinearity – The non-linearity to use. Can be either ‘tanh’ or ‘relu’. Default: ‘tanh’
  • batch_first – If True, then the input and output tensors are provided as (batch_size, num_steps, input_size). Default: False

这里的batch_first决定了输入的形状,我们使用默认的参数False,对应的输入形状是 (num_steps, batch_size, input_size)。

forward函数的参数为:

  • input of shape (num_steps, batch_size, input_size): tensor containing the features of the input sequence.
  • h_0 of shape (num_layers * num_directions, batch_size, hidden_size): tensor containing the initial hidden state for each element in the batch. Defaults to zero if not provided. If the RNN is bidirectional, num_directions should be 2, else it should be 1.

forward函数的返回值是:

  • output of shape (num_steps, batch_size, num_directions * hidden_size): tensor containing the output features (h_t) from the last layer of the RNN, for each t.
  • h_n of shape (num_layers * num_directions, batch_size, hidden_size): tensor containing the hidden state for t = num_steps.

现在我们构造一个nn.RNN实例,并用一个简单的例子来看一下输出的形状。

rnn_layer = nn.RNN(input_size=vocab_size, hidden_size=num_hiddens)
num_steps, batch_size = 35, 2
X = torch.rand(num_steps, batch_size, vocab_size)
state = None

Y, state_new = rnn_layer(X, state)
print(Y.shape, state_new.shape)
torch.Size([35, 2, 256]) torch.Size([1, 2, 256])

我们定义一个完整的基于循环神经网络的语言模型。

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)

    def forward(self, inputs, state):
        # inputs.shape: (batch_size, num_steps)
        X = to_onehot(inputs, vocab_size)
        X = torch.stack(X)  # X.shape: (num_steps, batch_size, vocab_size)
        hiddens, state = self.rnn(X, state)
        hiddens = hiddens.view(-1, hiddens.shape[-1])  # hiddens.shape: (num_steps * batch_size, hidden_size)
        output = self.dense(hiddens)
        return output, 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加上预测的num_chars个字符
    for t in range(num_chars + len(prefix) - 1):
        X = torch.tensor([output[-1]], device=device).view(1, 1)
        (Y, state) = model(X, state)  # 前向计算不需要传入模型参数
        if t < len(prefix) - 1:
            output.append(char_to_idx[prefix[t + 1]])
        else:
            output.append(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)
    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) # 相邻采样
        state = None
        for X, Y in data_iter:
            if state is not None:
                # 使用detach函数从计算图分离隐藏状态
                if isinstance (state, tuple): # LSTM, state:(h, c)  
                    state[0].detach_()
                    state[1].detach_()
                else: 
                    state.detach_()
            (output, state) = model(X, state) # output.shape: (num_steps * batch_size, vocab_size)
            y = torch.flatten(Y.T)
            l = loss(output, y.long())
            
            optimizer.zero_grad()
            l.backward()
            grad_clipping(model.parameters(), clipping_theta, device)
            optimizer.step()
            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_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)
epoch 50, perplexity 15.065489, time 0.87 sec
 - 分开始在人 不过 我不要 我不到 我不你的让我 你的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的
 - 不分开 我想你你不我 我不能不想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 
epoch 100, perplexity 1.366576, time 0.89 sec
 - 分开始想我 不能为回忆对到 干什么 有点去布 满脸过外叹息  像是一只饿昏的老斑鸠 印地安老斑鸠 腿短毛
 - 不分开 爱你 的没有 我不能再想 我不 我不 我不要 想要我的难熬  穿过云层 我试著努力向你奔跑 爱才送
epoch 150, perplexity 1.072260, time 0.89 sec
 - 分开始想在 爸和你怎么每天都能可爱女人 坏坏的让我疯狂的可爱女人 漂亮的让我面红的可爱女人 温柔的让我心
 - 不分开不想太  我不要 不要 一个人 后开 我给你的怒火 我想揍这样的着我 别双截棍 哼哼哈兮 快使用双截
epoch 200, perplexity 1.035550, time 0.88 sec
 - 分开始想在 没有你说我不 不要再 你身我 我的认声语沉  我被你说抽离  什么不妥 有多烦恼多 我不想汉
 - 不分开有多烦我 我的我较细汉 从小到大只有妈妈的温暖  为什么我爸爸 那么凶 如果真的我有一双翅膀 二双翅
epoch 250, perplexity 1.051731, time 0.91 sec
 - 分开 想你 爸不会痛吗不要将要 别爱写听 满了黄金葛在你了中节只属后开中融化在一定中现我的回忆 有有在的
 - 不分开有话你对没有的不会  没没有你在  有什么不妥 有话就想那 妈窝我的笑每原 我不想 你不再不要 你不

6.6 通过时间反向传播

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

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

6.6.1

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

h t = W h x x t + W h h h t 1 , \boldsymbol{h}t = \boldsymbol{W}{hx} \boldsymbol{x}t + \boldsymbol{W}{hh} \boldsymbol{h}_{t-1},

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

o t = W q h h t . \boldsymbol{o}t = \boldsymbol{W}{qh} \boldsymbol{h}_{t}.

设时间步 t t 的损失为 ( o t , y t ) \ell(\boldsymbol{o}_t, y_t) 。时间步数为 T T 的损失函数 L L 定义为

L = 1 T t = 1 T ( o t , y t ) . L = \frac{1}{T} \sum_{t=1}^T \ell (\boldsymbol{o}_t, y_t).

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

6.6.2 模型计算图

为了可视化循环神经网络中模型变量和参数在计算中的依赖关系,我们可以绘制模型计算图,如图6.3所示。例如,时间步3的隐藏状态 h 3 \boldsymbol{h}3 的计算依赖模型参数 W h x \boldsymbol{W}{hx} W h h \boldsymbol{W}_{hh} 、上一时间步隐藏状态 h 2 \boldsymbol{h}_2 以及当前时间步输入 x 3 \boldsymbol{x}_3
Image Name

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

6.6.3 方法

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

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

L o t = ( o t , y t ) T o t . \frac{\partial L}{\partial \boldsymbol{o}_t} = \frac{\partial \ell (\boldsymbol{o}_t, y_t)}{T \cdot \partial \boldsymbol{o}_t}.

下面,我们可以计算目标函数有关模型参数 W q h \boldsymbol{W}{qh} 的梯度 L / W q h R q × h \partial L/\partial \boldsymbol{W}{qh} \in \mathbb{R}^{q \times h} 。根据图6.3, L L 通过 o 1 , , o T \boldsymbol{o}_1, \ldots, \boldsymbol{o}T 依赖 W q h \boldsymbol{W}{qh} 。依据链式法则,

L W q h = t = 1 T prod ( L o t , o t W q h ) = t = 1 T L o t h t . \frac{\partial L}{\partial \boldsymbol{W}{qh}} = \sum{t=1}^T \text{prod}\left(\frac{\partial L}{\partial \boldsymbol{o}t}, \frac{\partial \boldsymbol{o}t}{\partial \boldsymbol{W}{qh}}\right) = \sum{t=1}^T \frac{\partial L}{\partial \boldsymbol{o}_t} \boldsymbol{h}_t^\top.

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

L h T = prod ( L o T , o T h T ) = W q h L o T . \frac{\partial L}{\partial \boldsymbol{h}_T} = \text{prod}\left(\frac{\partial L}{\partial \boldsymbol{o}_T}, \frac{\partial \boldsymbol{o}_T}{\partial \boldsymbol{h}T} \right) = \boldsymbol{W}{qh}^\top \frac{\partial L}{\partial \boldsymbol{o}_T}.

接下来对于时间步 t < T t < T , 在图6.3中, L L 通过 h t + 1 \boldsymbol{h}_{t+1} o t \boldsymbol{o}_t 依赖 h t \boldsymbol{h}_t 。依据链式法则, 目标函数有关时间步 t < T t < T 的隐藏状态的梯度 L / h t R h \partial L/\partial \boldsymbol{h}t \in \mathbb{R}^h 需要按照时间步从大到小依次计算: L h t = prod ( L h t + 1 , h t + 1 h t ) + prod ( L o t , o t h t ) = W h h L h t + 1 + W q h L o t \frac{\partial L}{\partial \boldsymbol{h}t} = \text{prod} (\frac{\partial L}{\partial \boldsymbol{h}{t+1}}, \frac{\partial \boldsymbol{h}{t+1}}{\partial \boldsymbol{h}_t}) + \text{prod} (\frac{\partial L}{\partial \boldsymbol{o}t}, \frac{\partial \boldsymbol{o}t}{\partial \boldsymbol{h}t} ) = \boldsymbol{W}{hh}^\top \frac{\partial L}{\partial \boldsymbol{h}{t+1}} + \boldsymbol{W}{qh}^\top \frac{\partial L}{\partial \boldsymbol{o}_t}

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

L h t = i = t T ( W h h ) T i W q h L o T + t i . \frac{\partial L}{\partial \boldsymbol{h}t} = \sum{i=t}^T {\left(\boldsymbol{W}{hh}^\top\right)}^{T-i} \boldsymbol{W}{qh}^\top \frac{\partial L}{\partial \boldsymbol{o}_{T+t-i}}.

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

L W h x = t = 1 T prod ( L h t , h t W h x ) = t = 1 T L h t x 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 . \begin{aligned} \frac{\partial L}{\partial \boldsymbol{W}{hx}} &= \sum{t=1}^T \text{prod}\left(\frac{\partial L}{\partial \boldsymbol{h}t}, \frac{\partial \boldsymbol{h}t}{\partial \boldsymbol{W}{hx}}\right) = \sum{t=1}^T \frac{\partial L}{\partial \boldsymbol{h}t} \boldsymbol{x}t^\top,\ \frac{\partial L}{\partial \boldsymbol{W}{hh}} &= \sum{t=1}^T \text{prod}\left(\frac{\partial L}{\partial \boldsymbol{h}t}, \frac{\partial \boldsymbol{h}t}{\partial \boldsymbol{W}{hh}}\right) = \sum{t=1}^T \frac{\partial L}{\partial \boldsymbol{h}t} \boldsymbol{h}{t-1}^\top. \end{aligned}

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

小结

小结

  • 通过时间反向传播是反向传播在循环神经网络中的具体应用。
  • 当总的时间步数较大或者当前时间步较小时,循环神经网络的梯度较容易出现衰减或爆炸。

6.7 门控循环单元(GRU)

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

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

6.7.1 门控循环单元

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

6.7.1.1 重置门和更新门

如图6.4所示,门控循环单元中的重置门和更新门的输入均为当前时间步输入 X t \boldsymbol{X}t 与上一时间步隐藏状态 H t 1 \boldsymbol{H}{t-1} ,输出由激活函数为sigmoid函数的全连接层计算得到。
Image Name
图片网址:https://github.com/ShusenTang/Dive-into-DL-PyTorch/blob/master/docs/img/chapter06/6.7_gru_1.svg

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

R t = σ ( X t W x r + H t 1 W h r + b r ) ,   Z t = σ ( X t W x z + H t 1 W h z + b z ) , \begin{aligned} \boldsymbol{R}t = \sigma(\boldsymbol{X}t \boldsymbol{W}{xr} + \boldsymbol{H}{t-1} \boldsymbol{W}_{hr} + \boldsymbol{b}r),\ \boldsymbol{Z}t = \sigma(\boldsymbol{X}t \boldsymbol{W}{xz} + \boldsymbol{H}{t-1} \boldsymbol{W}{hz} + \boldsymbol{b}_z), \end{aligned}

其中 W x r , W x z R d × h \boldsymbol{W}{xr}, \boldsymbol{W}{xz} \in \mathbb{R}^{d \times h} W h r , W h z R h × h \boldsymbol{W}{hr}, \boldsymbol{W}{hz} \in \mathbb{R}^{h \times h} 是权重参数, b r , b z R 1 × h \boldsymbol{b}_r, \boldsymbol{b}_z \in \mathbb{R}^{1 \times h} 是偏差参数。3.8节(多层感知机)节中介绍过,sigmoid函数可以将元素的值变换到0和1之间。因此,重置门 R t \boldsymbol{R}_t 和更新门 Z t \boldsymbol{Z}_t 中每个元素的值域都是 [ 0 , 1 ] [0, 1]

6.7.1.2 候选隐藏状态

接下来,门控循环单元将计算候选隐藏状态来辅助稍后的隐藏状态计算。如图6.5所示,我们将当前时间步重置门的输出与上一时间步隐藏状态做按元素乘法(符号为 \odot )。如果重置门中元素值接近0,那么意味着重置对应隐藏状态元素为0,即丢弃上一时间步的隐藏状态。如果元素值接近1,那么表示保留上一时间步的隐藏状态。然后,将按元素乘法的结果与当前时间步的输入连结,再通过含激活函数tanh的全连接层计算出候选隐藏状态,其所有元素的值域为 [ 1 , 1 ] [-1, 1]
Image Name
图片网址:https://github.com/ShusenTang/Dive-into-DL-PyTorch/blob/master/docs/img/chapter06/6.7_gru_2.svg

具体来说,时间步 t t 的候选隐藏状态 H ~ t R n × h \tilde{\boldsymbol{H}}_t \in \mathbb{R}^{n \times h} 的计算为

H ~ t = tanh ( X t W x h + ( R t H t 1 ) W h h + b h ) , \tilde{\boldsymbol{H}}t = \text{tanh}(\boldsymbol{X}t \boldsymbol{W}{xh} + \left(\boldsymbol{R}t \odot \boldsymbol{H}{t-1}\right) \boldsymbol{W}{hh} + \boldsymbol{b}_h),

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

6.7.1.3 隐藏状态

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

H t = Z t H t 1 + ( 1 Z t ) H ~ t . \boldsymbol{H}_t = \boldsymbol{Z}t \odot \boldsymbol{H}{t-1} + (1 - \boldsymbol{Z}_t) \odot \tilde{\boldsymbol{H}}_t.
Image Name
图片地址:https://github.com/ShusenTang/Dive-into-DL-PyTorch/blob/master/docs/img/chapter06/6.7_gru_3.svg

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

RNN存在的问题:梯度较容易出现衰减或爆炸(BPTT)
⻔控循环神经⽹络:捕捉时间序列中时间步距离较⼤的依赖关系
RNN:

Image Name

H t = ϕ ( X t W x h + H t 1 W h h + b h ) H_{t} = ϕ(X_{t}W_{xh} + H_{t-1}W_{hh} + b_{h})
GRU:

Image Name

R t = σ ( X t W x r + H t 1 W h r + b r ) Z t = σ ( X t W x z + H t 1 W h z + b z ) H ~ t = t a n h ( X t W x h + ( R t H t 1 ) W h h + b h ) H t = Z t H t 1 + ( 1 Z t ) H ~ t R_{t} = σ(X_tW_{xr} + H_{t−1}W_{hr} + b_r)\\ Z_{t} = σ(X_tW_{xz} + H_{t−1}W_{hz} + b_z)\\ \widetilde{H}_t = tanh(X_tW_{xh} + (R_t ⊙H_{t−1})W_{hh} + b_h)\\ H_t = Z_t⊙H_{t−1} + (1−Z_t)⊙\widetilde{H}_t
• 重置⻔有助于捕捉时间序列⾥短期的依赖关系;
• 更新⻔有助于捕捉时间序列⾥⻓期的依赖关系。

6.7.2 读取数据集

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

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

with zipfile.ZipFile('jaychou_lyrics.txt.zip') as zin:
    with zin.open('jaychou_lyrics.txt') as f:
        corpus_chars = f.read().decode('utf-8')
corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
corpus_chars = corpus_chars[0:10000]
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)
corpus_indices = [char_to_idx[char] for char in corpus_chars]

6.7.3 从零开始实现

6.7.3.1 初始化模型参数

下面的代码对模型参数进行初始化。超参数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])
will use cpu

6.7.3.2 定义模型

下面的代码定义隐藏状态初始化函数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,)

6.7.3.3 训练模型并创作歌词

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

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

我们每过40个迭代周期便根据当前训练的模型创作一段歌词。

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)
epoch 40, perplexity 150.820123, time 3.10 sec
 - 分开 我不的让我 我想你的让我 爱爱我 我想你 我不不 我想你 我不不 我不不 我想你 我不不 我不不 
 - 不分开 我想你的让我 我想你的让我 爱爱我 我想你 我不不 我想你 我不不 我不不 我想你 我不不 我不不
epoch 80, perplexity 32.456923, time 3.27 sec
 - 分开 我想要你的微笑在一个人剧 我想要你已经很久 想想你这样 我不要再想 我不要再想 我不要再想 我不要
 - 不分开 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我
epoch 120, perplexity 5.803169, time 3.28 sec
 - 分开 我想就这样牵着你 想想和你已经很久 别想就 说你眼睛看着我 别发抖 快给我抬起头 有话去对医药箱说
 - 不分开 她透我 你已经 是你在一个人 后知开觉 我该好好生活 我该好好生活 我该好好生活 不知不觉 我跟了
epoch 160, perplexity 1.760187, time 3.32 sec
 - 分开 一颗好酒 再来一碗热粥 配上几斤的牛肉 我说店小二 三两银够不够 景色入秋 漫天黄沙凉过 塞北的客
 - 不分开多 我知能没 我不多这想 我不能再想 我不 我不 我不能 爱情走的太快就像龙卷风 不能承受我已无处可

6.7.4 简洁实现

在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)
epoch 40, perplexity 1.021366, time 1.74 sec
 - 分开我那么凶 如果真的我有一双翅膀 二双翅膀 随时出发 偷偷出发 我一定带我妈走  从前的教育别人的家庭
 - 不分开 那已风化千年的誓言 一切又重演 我感到很疲倦离家乡还是很远 害怕再也不能回到你身边 我给你的爱写在
epoch 80, perplexity 1.011115, time 1.69 sec
 - 分开的那画面 经过苏美女神身边 我以女神之名许愿 思念像底格里斯河般的漫延 当古文明只剩下难解的语言 传
 - 不分开始单纯没有悲哀 我 想带你骑单车 我 想和你看棒球 想这样没担忧 唱着歌 一直走 我想就这样牵着你的
epoch 120, perplexity 1.047982, time 1.67 sec
 - 分开的话像 这故事 告诉我 印地安的传说 还真是 瞎透了 什么都有 沙漠之中怎么会有泥鳅 话说完飞过一只
 - 不分开 那已风化千年的誓言 一切又重演 我感到很疲倦离家乡还是很远 害怕再也不能回到你身边 我给你的爱写在
epoch 160, perplexity 1.007723, time 1.69 sec
 - 分开的话像语言暴力 我已无能为力再提起 决定中断熟悉 然后在这里 不限日期 然后将过去 慢慢温习 让我爱
 - 不分开 在一切又重演 我感到很疲倦离家乡还是很远 害怕再也不能回到你身边 我给你的爱写在西元前 深埋在美索

小结

  • 门控循环神经网络可以更好地捕捉时间序列中时间步距离较大的依赖关系。
  • 门控循环单元引入了门的概念,从而修改了循环神经网络中隐藏状态的计算方式。它包括重置门、更新门、候选隐藏状态和隐藏状态。
  • 重置门有助于捕捉时间序列里短期的依赖关系。
  • 更新门有助于捕捉时间序列里长期的依赖关系。

发布了4 篇原创文章 · 获赞 0 · 访问量 139

猜你喜欢

转载自blog.csdn.net/qq_40298825/article/details/104397742