Chapter7 循环神经网络-1


第二部分地址
循环神经网络(recurrent neural network, RNN)常用语处理序列数据,如一段文字或者声音、购物或观影的顺序,甚至是图像中的一行或一列像素。因此,循环神经网络有着极为广泛的实际应用,如语言模型、文本分类、机器翻译、语音识别、图像分析和推荐系统等。

如果说卷积神经网络可以有效地处理空间信息,那么循环神经网络则可以更好地处理序列信息。循环神经网络通过引入状态变量存储的信息和当前的输入,从而可以确定当前的输入。

许多使用循环神经网络的例子都是基于文本数据的,因此首先介绍语言模型,然后介绍什么是序列模型以及怎么样对文本数据进行预处理,随后介绍循环神经网络RNN,记忆LSTM,GRU等模型。

1、语言模型

1.1、语言模型的概念

语言模型(language model)是自然语言处理的重要技术。自然语言处理中最常见的数据就是文本数据。我们可以把一段自然语言文本看作一段离散的时间序列。假设一段长度为 T T T的文本中的词依次为 w 1 , w 2 , ⋯   , w T w_1,w_2,\cdots ,w_T w1,w2,,wT,那么在离散的时间序列中, w t ( 1 ≤ t ≤ T ) w_t(1\le t \le T) wt(1tT)可以看作在时间步 t t t的输出或者标签。给定一个长度为 T T T的序列 w 1 , w 2 , ⋯   , w T w_1,w_2,\cdots ,w_T w1,w2,,wT,语言模型将计算该序列的概率:
P ( w 1 , w 2 , ⋯   , w T ) P(w_1,w_2,\cdots ,w_T) P(w1,w2,,wT)
简单来说,语言模型的概念为:语言模型计算一个句子是句子的概率的模型。(文本序列就是句子喽)

例如下面三句话以及它们是句子的概率分别为

  • “博主长得很帅!”:0.8
  • “博主长得很一般!”:0.01
  • “长博主一般得!”:0.000001

第一句话是句子的概率为0.8,因为这句话即合乎语义又合乎语法,因此这句话是句子的概率很高;第二句话为句子的概率为0.01,这是因为虽然这个句子符合语法但是却不合乎语义,因为构建的语言模型中,“博主”和“一般”出现在一起的概率就很小;而第三句话读都读不同,显然不符合语法,因此是句子的概率极低。

语言模型有很多的用处,它可以用于提升语音识别性能或输入法准确度等。例如,当我们输入“zi ran yu yan chu li”,它所对应的中文可能是“自然语言处理”、“子然语言出力”、“紫然玉言储例”,经过语言模型的判断这三个句子的概率为0.9.,001,0.0001,我们就可以得到是句子概率最高得一句话“自然语言处理”,这也是我们正向得到的。

1.2、语言模型的计算

统计语言模型通过概率来刻画语言模型,假设序列 w 1 , w 2 , ⋯   , w T w_1,w_2,\cdots ,w_T w1,w2,,wT中的每个词是依次生成的,则有:
P ( s ) = P ( w 1 , w 2 , ⋯   , w T ) = ∏ t = 1 T P ( w t ∣ w 1 , ⋯   , w t − 1 ) P(s)=P(w_1,w_2,\cdots ,w_T) = \prod_{t=1}^{T}P(w_t|w_1, \cdots ,w_{t-1}) P(s)=P(w1,w2,,wT)=t=1TP(wtw1,,wt1)
例如,一段含有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|w_1)P(w_3|w_1,w_2)P(w_4|w_1,w_2,w_3) P(w1,w2,w3,w4)=P(w1)P(w2w1)P(w3w1,w2)P(w4w1,w2,w3)
为计算语言模型,我们需要计算词的概率,以及一个词在给定前几个词的情况下的条件概率,即语言模型的参数。

词的概率可以通过该词在训练数据集中的相对词频来计算,用语料的频率代替概率(频率学派),即计算词在训练数据集中的出现的次数与训练数据集的总词数之比:
P ( w i ) = c o u n t ( w i ) N P(w_i)=\frac{count(w_i)}{N} P(wi)=Ncount(wi)
一个词在给定前几个词的情况下的条件概率也可以通过训练数据集中的相对词频计算,其计算公式为:
P ( w i ∣ w i − 1 ) = P ( w i − 1 , w i ) P ( w i − 1 ) = c o u n t ( w i − 1 , w i ) / N c o u n t ( w i − 1 ) / N = c o u n t ( w i − 1 , w i ) c o u n t ( w i − 1 ) P(w_i|w_{i-1})=\frac{P(w_{i-1},w_i)}{P(w_{i-1})}=\frac{count(w_{i-1},w_i)/N}{count(w_{i-1})/N}=\frac{count(w_{i-1},w_i)}{count(w_{i-1})} P(wiwi1)=P(wi1)P(wi1,wi)=count(wi1)/Ncount(wi1,wi)/N=count(wi1)count(wi1,wi)
但是只是这样构建语言模型的话,还存在一些问题。例如在下面的例子中,对于一句话 s e n t e n c e = { w 1 , w 2 , ⋯   , w n } sentence=\{w_1,w_2,\cdots,w_n\} sentence={w1,w2,,wn}

当这句话为“张三很帅”的时候,使用语言模型计算它为句子的概率为: P ( 张 三    很    帅 ) = P ( 张 三 ) ∗ P ( 很 ∣ 张 三 ) ∗ P ( 帅 ∣ 张 三 , 很 ) P(张三\; 很\; 帅)=P(张三)*P(很|张三)*P(帅|张三,很) P()=P()P()P(),由于张三是一个常见的名字,因此在预料中很容易出现,因此通过语言模型可以计算出它的一个概率为 P 1 P_1 P1

但对于“张得帅很帅”这句话,它的概率为: P ( 张 得 帅    很    帅 ) = P ( 张 得 帅 ) ∗ P ( 很 ∣ 张 得 帅 ) ∗ P ( 帅 ∣ 张 得 帅 , 很 ) P(张得帅\; 很\; 帅)=P(张得帅)*P(很|张得帅)*P(帅|张得帅,很) P()=P()P()P(),“张得帅”也可能是一个人名,因此“张得帅很帅”也是一句正确的话,但是“张得帅”在语料库中可能就没有出现过,因此 P ( 张 得 帅 ) P(张得帅) P()的概率为0,那么最终的结果也为0。这显然有些不合理,因为“张得帅很帅”明显是一句话。“张得帅很帅”这句话概率为0的原因是因为有一些词在语料库中没有出现过。

而对于“张三很漂亮”这句话,它的概率为: P ( 张 三    很    漂 亮 ) = P ( 张 三 ) ∗ P ( 很 ∣ 张 三 ) ∗ P ( 漂 亮 ∣ 张 三 , 很 ) P(张三\; 很\; 漂亮)=P(张三)*P(很|张三)*P(漂亮|张三,很) P()=P()P()P(),其中“张三”可能是个男生,“张三跟漂亮”在语料库中出现的概率为0,因此总的概率为0。但是这句话中的每一个词的概率都不为0,并且 P ( 张 三 ) P(张三) P() P ( 很 ∣ 张 三 ) P(很|张三) P()也都不为0,但是再加一个“漂亮”概率就为0了,这是因为这一句话太长了。

由上面例子可以看出,当一个句子明明就是句子的时候,它为句子的概率可能为0,怎么处理这种情况那?可以使用统计语言模型中的平滑操作

统计语言模型中的平滑操作

有一些词或者词组在语料中没有出现过,但是这不能代表它不可能存在。**平滑操作就是给那些没有出现过的词或者词组也给一个比较小的概率。**平滑操作有很多种。

拉普拉斯平滑(Laplace Smoothing)也称为加1平滑:每个词在原来出现次数的基础上加1,下面是经过拉普拉斯平滑后的计算公式:
P ( w ) = c o u n t ( w ) N ⇒ P ( w ) = c o u n t ( w ) + 1 N + V P(w) = \frac{count(w)}{N} \Rightarrow P(w) = \frac{count(w)+1}{N+V} P(w)=Ncount(w)P(w)=N+Vcount(w)+1
其中V,加1的词的个数,下面来看一个简单的例子,有三个词A、B、C,每个词的个数分别为0、990、10,在未经过平滑时,每个词的概率为:
A : 0    P ( A ) = 0 / 1000 = 0 B : 990    P ( B ) = 990 / 1000 = 0.99 C : 10   P ( C ) = 10 / 1000 = 0.01 A:0 \; P(A)=0/1000=0 \\ B:990 \; P(B)=990/1000=0.99 \\ C:10 \:P(C)=10/1000=0.01 A:0P(A)=0/1000=0B:990P(B)=990/1000=0.99C:10P(C)=10/1000=0.01
经过平滑之后,每个词的概率为:
A : 1    P ( A ) = 1 / 1003 = 0.001 B : 991    P ( B ) = 991 / 1003 = 0.988 C : 11   P ( C ) = 11 / 1003 = 0.011 A:1 \; P(A)=1/1003=0.001 \\ B:991 \; P(B)=991/1003=0.988 \\ C:11 \:P(C)=11/1003=0.011 A:1P(A)=1/1003=0.001B:991P(B)=991/1003=0.988C:11P(C)=11/1003=0.011
可见之前概率较大的词的概率变小了,概率较小的次的概率变大了。但是平滑操作还存在参数空间过大和数据稀疏严重的问题(空间参数过大导致了数据系数严重的问题),因此可以使用马尔科夫假设来解决此问题。

1.3、马尔科夫假设

当序列长度增加时,计算和存储多个词共同出现的概率的复杂程度会呈指数级增加。因此可以通过马尔科夫假设解决该问题,马尔科夫假设是指下一个词的出现仅依赖于前面的n个词(因为一个词与它间隔很长的词之间的关系可能就不是很大了),即k阶马尔科夫链。如果基于n-1阶马尔科夫链,就可以将语言模型改写为:
P ( s ) = P ( w 1 , w 2 , ⋯   , w n ) ≈ ∏ t = 1 T P ( w t ∣ w t − ( n − 1 ) , ⋯   , w t − 1 ) P(s)=P(w_1,w_2,\cdots ,w_n) \approx \prod_{t=1}^{T}P(w_t|w_{t-(n-1)}, \cdots ,w_{t-1}) P(s)=P(w1,w2,,wn)t=1TP(wtwt(n1),,wt1)
以上也叫作n元语法。它是基于n-1阶马尔科夫链的概率语言模型。当n分别为1、2、3和k时,我们将其分别称作一元语法(unigram)、二元语法(bigram)、三元语法(trigram)和k元语法(k-gram)。长度为T的序列 w 1 , w 2 , ⋯   , w T w_1,w_2,\cdots ,w_T w1,w2,,wT在一元语法、二元语法、三元语法和k元语法中的概率分别是:
u n i g r a m : P ( s ) = P ( w 1 ) P ( w 2 ) P ( w 3 ) ⋯ P ( w T ) b i g r a m : P ( s ) = P ( w 1 ) P ( w 2 ∣ w 1 ) P ( w 3 ∣ w 2 ) ⋯ P ( w T ∣ w T − 1 ) t r i g r a m : P ( s ) = P ( w 1 ) P ( w 2 ∣ w 1 ) P ( w 3 ∣ w 1 w 2 ) ⋯ P ( w T ∣ w T − 2 w T − 1 ) k − g r a m : P ( s ) = P ( w 1 ) P ( w 2 ∣ w 1 ) P ( w 3 ∣ w 1 w 2 ) ⋯ P ( w T ∣ w T − k + 1 w T − 1 ) unigram:P(s)=P(w_1)P(w_2)P(w_3) \cdots P(w_T)\\ bigram:P(s)=P(w_1)P(w_2|w_1)P(w_3|w_2) \cdots P(w_T|w_{T-1})\\ trigram:P(s)=P(w_1)P(w_2|w_1)P(w_3|w_1w_2) \cdots P(w_T|w_{T-2}w_{T-1})\\ k-gram:P(s)=P(w_1)P(w_2|w_1)P(w_3|w_1w_2) \cdots P(w_T|w_{T-k+1}w_{T-1})\\ unigram:P(s)=P(w1)P(w2)P(w3)P(wT)bigram:P(s)=P(w1)P(w2w1)P(w3w2)P(wTwT1)trigram:P(s)=P(w1)P(w2w1)P(w3w1w2)P(wTwT2wT1)kgram:P(s)=P(w1)P(w2w1)P(w3w1w2)P(wTwTk+1wT1)
当n较小时,n元语法往往并不准确。例如,在一元语法中,由3个词组成的句子“你走先”和“你先走”的概率是一样的。然而,当n较大时,n元语法需要计算并存储大量的词频和多词相邻词频。

下面以“我 明天 下午 打 网球”,看一下原始语言模型和经过马尔科夫优化后的语言模型应该怎么计算:
P ( 我 , 明 天 , 下 午 , 打 , 网 球 ) = P ( 我 ) P ( 明 天 ∣ 我 ) P ( 下 午 ∣ 我 , 明 天 ) P ( 打 ∣ 我 , 明 天 , 下 午 ) P ( 网 球 ∣ 我 , 明 天 , 下 午 , 打 ) = P ( 我 ) P ( 明 天 ) P ( 下 午 ) P ( 打 ) P ( 网 球 )        u n i g r a m = P ( 我 ) P ( 明 天 ∣ 我 ) P ( 下 午 ∣ 明 天 ) P ( 打 ∣ 下 午 ) P ( 网 球 ∣ 打 )        b i g r a m = P ( 我 ) P ( 明 天 ∣ 我 ) P ( 下 午 ∣ 我 , 明 天 ) P ( 打 ∣ 明 天 , 下 午 ) P ( 网 球 ∣ 下 午 , 打 )        t r i g r a m P(我,明天,下午,打,网球)\\ =P(我)P(明天|我)P(下午|我,明天)P(打|我,明天,下午)P(网球|我,明天,下午,打)\\ =P(我)P(明天)P(下午)P(打)P(网球)\; \; \;unigram\\ =P(我)P(明天|我)P(下午|明天)P(打|下午)P(网球|打)\; \; \;bigram\\ =P(我)P(明天|我)P(下午|我,明天)P(打|明天,下午)P(网球|下午,打)\; \; \;trigram P(,,,,)=P()P()P(,)P(,,)P(,,,)=P()P()P()P()P()unigram=P()P()P()P()P()bigram=P()P()P(,)P(,)P(,)trigram

1.4、语言模型评价指标:困惑度(Perplexity)

语言模型在实质上是一个多分类问题,语言模型计算一个句子是句子的概率。对于一个句子s,其中有 w 1 , w 2 , ⋯   , w n w_1,w_2,\cdots ,w_n w1,w2,,wn共n个词,它是句子的概率为:
P ( s ) = P ( w 1 , w 2 , ⋯   , w n ) = P ( w 1 ) P ( w 2 ∣ w 1 ) ⋯ P ( w n ∣ w 1 , w 2 ⋯ w n − 1 ) P(s)=P(w_1,w_2, \cdots ,w_n)=P(w_1)P(w_2|w_1) \cdots P(w_n|w_1,w_2 \cdots w_{n-1}) P(s)=P(w1,w2,,wn)=P(w1)P(w2w1)P(wnw1,w2wn1)
下面就需要计算出每一项的概率,就可以计算出句子s为句子的概率了,那么每一项有怎么计算哪?对于第一项 P ( w 1 ) P(w_1) P(w1),计算为对于所有的词 w 1 , w 2 , ⋯ w T w_1,w_2,\cdots w_T w1,w2,wT w 1 w_1 w1出现的概率分布,可以理解为输入为None,标签为 w 1 w_1 w1,词表大小的一个多分类问题;对于第二项 P ( w 2 ∣ w 1 ) P(w_2|w_1) P(w2w1)为当输入为 w 1 w_1 w1,标签为 w 2 w_2 w2;其他项可以依次类推。综上所述,每一项都可以理解为是一个词表大小的多分类问题。

下图为一个基于前馈神经网络的语言模型,它使用前几个词的词向量,并把它们连接在一起,后面接一个隐藏层,然后是一个分类层,分类层就是一个词表大小的分类器。

语言模型的评价指标,就可以使用困惑度来表示,一个句子的困惑度为一个句子的概率开负的n分之一次方,表示为:
P P ( s ) = P ( w 1 , w 2 , ⋯ w n ) − 1 n = 1 P ( w 1 , w 2 , ⋯   , w n ) n PP(s) = P(w_1,w_2,\cdots w_n)^{- \frac{1}{n}}=\sqrt[n]{\frac{1}{P(w_1,w_2,\cdots ,w_n)}} PP(s)=P(w1,w2,wn)n1=nP(w1,w2,,wn)1
句子概率越大,语言模型越好,困惑度越小。

2、文本预处理

序列数据存在许多种形式,文本是最常见例子之一。例如,一篇文章可以简单地看作是一串单词序列,甚至是一串字符序列。下面为解析文本的常见处理步骤,这些步骤包括:

  1. 将文本作为字符串加载到内存中。
  2. 将字符串查分为词元(如单词和字符)。
  3. 建立一个词表,将拆分的词元映射到数字索引。
  4. 将文本转换为数字索引序列,方便模型操作。

导入所使用的包:

import collections
import re
from d2l import torch as d2l

2.1、读取数据集

首先,加载H.G.Well的The Time Machine中的文本,这是一个小的语料库,只有30000多个单词。The Time Machine是一本科幻小说,下图为这本小说的前几行的内容,全部的文本有三千多行。

下面的函数将The Time Machine文本数据集读取到由多条文本行组成的列表中,其中每条文本行都是一个字符串。然后忽略了文本中的标点符号和字母大写。

#传入time_machine数据集的下载地址以及哈希校验码
d2l.DATA_HUB['time_machine'] = (d2l.DATA_URL + 'timemachine.txt',
                                '090b5e7e70c295757f55df93cb0a180b9691891a')
#将time_machine数据集加载到文本行的列表中
def read_time_machine():
    #下载timemachine.txt文件,并打开文件按行读取内容
    with open(d2l.download('time_machine'),'r') as f:
        lines = f.readlines()
    #re.sub('[^A-Za-z]+',' ',line):使用正则表达式匹配多个连续的非字母,将它们替换为空格
    #strip(); 去除字符串两边的空格
    #lower():转换字符串中所有大写字符为小写。
    return [re.sub('[^A-Za-z]+',' ',line).strip().lower() for line in lines]

lines = read_time_machine()
print(f'#文本总行数:{len(lines)}')
print(lines[0])
print(lines[10])
#文本总行数:3221
the time machine by h g wells
twinkled and his usually pale face was flushed and animated the

2.2、词元化

下面将文本序列拆分为词元列表,词元是文本的基本单位。下面的tokenize函数将文本行列表作输入,列表中的每个元素是一个文本序列。函数返回一个由词元列表组成的列表,其中每个词元都是一个字符串。(就是将一句话按照单词或者字母进行拆)

#将文本拆分为单词或者字符词元
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)
for i in range(11):
    print(tokens[i])
['the', 'time', 'machine', 'by', 'h', 'g', 'wells']
[]
[]
[]
[]
['i']
[]
[]
['the', 'time', 'traveller', 'for', 'so', 'it', 'will', 'be', 'convenient', 'to', 'speak', 'of', 'him']
['was', 'expounding', 'a', 'recondite', 'matter', 'to', 'us', 'his', 'grey', 'eyes', 'shone', 'and']
['twinkled', 'and', 'his', 'usually', 'pale', 'face', 'was', 'flushed', 'and', 'animated', 'the']

2.3、词表

词元的类型是字符串,而模型需要的输入是数字,因此这种类型不方便模型使用。下面,构建一个字典,通常也叫作词表,用来将字符串类型的词元映射到从0开始的数字索引中。

首先将训练集中所有的文档合并在一起,对它们的唯一词元进行统计,得到的统计结果称之为语料(corpus)。然后根据每个唯一词元的出现频率,为其分配一个数字索引。为降低复杂性,移除很少出现的词元。将语料库中不存在或者已删除的词元映射为一个特殊的未知词元" ⟨ u n k ⟩ \langle unk \rangle unk"。增加一个列表,用于保存那些被保留的词元,例如:填充词元(“ ⟨ p a d ⟩ \langle pad \rangle pad”);序列开始词元(“ ⟨ b o s ⟩ \langle bos \rangle bos”);序列结束词元(“ ⟨ e o s ⟩ \langle eos \rangle eos”)。

下面的函数将统计词元的频率,即某个词元在训练集中出现的次数,最后以一个列表的形式返回。

#统计词元的频率,返回每个词元及其出现的次数,以一个字典形式返回。
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)
corpus_num=count_corpus(tokens)
print(corpus_num['the'])
2261

下面定义的Vocab类,用于构建词表,并支持查询词表长度、通过索引查询词元、词元查询索引的功能。

#文本词表
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)
        #counter.items():为一个字典
        #lambda x:x[1]:对第二个字段进行排序
        #reverse = True:降序
        self._token_freqs = sorted(counter.items(),key = lambda x:x[1],reverse = True)

        #未知单词的索引为0
        #idx_to_token用于保存所有未重复的词元
        self.idx_to_token = ['<unk>'] + reserved_tokens
        #token_to_idx:是一个字典,保存词元和其对应的索引
        self.token_to_idx = {token:idx for idx,token in enumerate(self.idx_to_token)}

        for token, freq in self._token_freqs:
            #min_freq为最小出现的次数,如果小于这个数,这个单词被抛弃
            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)

    #获取要查询词元的索引,支持list,tuple查询多个词元的索引
    def __getitem__(self, tokens):
        if not isinstance(tokens,(list,tuple)):
            #self.unk:如果查询不到返回0
            return self.token_to_idx.get(tokens,self.unk)
        return [self.__getitem__(token) for token in tokens]

    # 根据索引查询词元,支持list,tuple查询多个索引对应的词元
    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):
        return 0
    @property
    def token_freqs(self):
        return self._token_freqs

下面使用The Time Machine数据集作为语料库来构建词表,然后打印前几个高频词元及其索引。

vocab = Vocab(tokens)
print(list(vocab.token_to_idx.items())[:10])
[('<unk>', 0), ('the', 1), ('i', 2), ('and', 3), ('of', 4), ('a', 5), ('to', 6), ('was', 7), ('in', 8), ('that', 9)]

下面就可以将一条文本行转换成一个数字索引列表了。

for i in [0,10]:
    print('文本',tokens[i])
    print('索引',vocab[tokens[i]])
文本 ['the', 'time', 'machine', 'by', 'h', 'g', 'wells']
索引 [1, 19, 50, 40, 2183, 2184, 400]
文本 ['twinkled', 'and', 'his', 'usually', 'pale', 'face', 'was', 'flushed', 'and', 'animated', 'the']
索引 [2186, 3, 25, 1044, 362, 113, 7, 1421, 3, 1045, 1]

2.4、整合所有的功能

下面将所有的功能打包到load_corpus_time_machine含住中,该函数返回corpus(词元索引列表)和vocab(时光机器语料库的词表)。这列做了一些更改

  1. 为了简化后面章节中的训练,我们使用字符(而不是单词)实现文本词元化;
  2. The Time Machine数据集中的每个文本行不一定是一个句子或一个段落,还可能是一个单词,因此返回的corpus仅处理为单个列表,而不是使用多词元列表构成的一个列表。
#返回The Time Machine数据集的词元索引别表和词表
def load_corpus_time_machine(max_tokens = -1):
    lines = read_time_machine()
    tokens = tokenize(lines,'char')
    vocab = Vocab(tokens)

    #因为The Time Machine数据集中的每个文本行不一定是一个句子或者是一个段落
    #所以将所有文本行展平到一个列表中
    #保存数据集中每个字符的索引
    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()
print(len(corpus), len(vocab))
#然后打印前几个高频词元及其索引,可见是由字符组成的
print(list(vocab.token_to_idx.items())[:10])
170580 28
[('<unk>', 0), (' ', 1), ('e', 2), ('t', 3), ('a', 4), ('i', 5), ('n', 6), ('o', 7), ('s', 8), ('h', 9)]

3、读取时序数据

由于序列数据在本质上是连续的,当序列变得太长而不能被模型一次性处理时,可以通过拆分序列以方便模型的读取。

在训练中需要每次随机读取小批量样本和标签。去卷积神经网络和线性网络的数据集不同的是,时序数据的一个样本通常包含连续的字符。假设时间步数为6,样本序列为6个字符,即’m’,‘a’,‘c’,‘h’,‘i’,‘n’,该样本的标签序列为这些字符分别在训练集中的下一个字符,即’a’,‘c’,‘h’,‘i’,‘n’,‘e’。这里有两种方式对时序数据进行采样,分别为随机采样和相邻采样。

3.1、随机采样

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

import random
import torch
import numpy 
from d2l import torch as d2l

#使用随机抽样生成一个小批量子序列
def seq_data_iter_random(corpus, batch_size, num_steps):
    #随机偏移量开始对序列进行分区,随机范围包括num_steps-1
    corpus = corpus[random.randint(0,num_steps - 1):]
    
    #减去1,是因为需要考虑标签
    #num_subseqs:表示分割的序列的条数
    num_subseqs = (len(corpus) - 1) //num_steps
    
    #长度为num_steps的子序列的起始索引编号
    initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
    
    
    #在随机抽样的迭代过程中,来自两个相邻的、随机的、小批量的子序列不一定在原始序列上相邻
    #因此将起始索引编号打乱
    random.shuffle(initial_indices)
    
    #返回从pos位置开始的长度为num_steps的序列
    def data(pos):
        return corpus[pos : pos+num_steps]
    
    #表示一共有多少个批量
    num_batches = num_subseqs // batch_size
    
    #从0到批量大小×批量的数量遍历,间隔为批量大小,即从循环次数为批量的数量
    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]
        #返回一个可以用来迭代(for循环)的生成器,因为按照批量大小,返回的数据是多条的
        yield torch.tensor(X),torch.tensor(Y)

下面生成一个从0到34的序列。 假设批量大小为2,时间步数为5,这意味着可以生成 ⌊ ( 35 − 1 ) / 5 = 6 ⌋ \lfloor (35-1)/5=6\rfloor (351)/5=6个“特征-标签”子序列对。 小批量大小为6,因此只能得到3个小批量。

my_seq = list(range(35))
for X, Y in seq_data_iter_random(my_seq, batch_size = 2,num_steps=5):
    print('X: ',X,'\nY:',Y)
X:  tensor([[ 7,  8,  9, 10, 11],
        [22, 23, 24, 25, 26]]) 
Y: tensor([[ 8,  9, 10, 11, 12],
        [23, 24, 25, 26, 27]])
X:  tensor([[27, 28, 29, 30, 31],
        [17, 18, 19, 20, 21]]) 
Y: tensor([[28, 29, 30, 31, 32],
        [18, 19, 20, 21, 22]])
X:  tensor([[12, 13, 14, 15, 16],
        [ 2,  3,  4,  5,  6]]) 
Y: tensor([[13, 14, 15, 16, 17],
        [ 3,  4,  5,  6,  7]])

3.2、相邻采样

除了对原始序列可以随机抽样外,还可以令相邻的两个随机小批量在原始序列上的位置相邻。这时候就可以用一个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态,从而使下一个小批量的输出也取决于当前小批量的输入,并如此循环下去。这对实现循环神经网络造成了两方面的影响:

  1. 在训练模型时,我们只需要在一个迭代周期开始时初始化隐藏状态。
  2. 在多个相邻小批量通过传递隐藏状态串联起来时,模型参数的梯度将依赖所有串联起来的小批量序列。

同一迭代周期中,随着迭代次数的增加,梯度的计算开销会越来越大。

#使用相邻采样生成一个小批量子序列
def seq_data_iter_sequential(corpus, batch_size, num_steps):
    #从随机偏移量开始划分序列
    offest = random.randint(0,num_steps)
    #获取用于最终训练的序列,因为有偏移量和不能整除,因此对输入的序列进行处理
    num_tokens = ((len(corpus) - offest -1) // batch_size) * batch_size
    
    #样本序列
    Xs = torch.tensor(corpus[offest:offest + num_tokens])
    #标签序列
    Ys = torch.tensor(corpus[offest + 1: offest + num_tokens +1])
    
    #转为2维数据,行代表不同批次
    Xs,Ys = Xs.reshape(batch_size,-1),Ys.reshape(batch_size,-1)
    
    #为批次的数量
    num_batchs = Xs.shape[1] // num_steps
    #循环输出
    for i in range(0,num_steps * num_batchs,num_steps):
        X = Xs[:,i:i + num_steps]
        Y = Ys[:,i:i + num_steps]
        yield X,Y

基于相同的设置,通过相邻采样读取每个小批量的子序列的特征X和标签Y。 通过将它们打印出来可以发现: 迭代期间来自两个相邻的小批量中的子序列在原始序列中是相邻的。

for X, Y in seq_data_iter_sequential(my_seq, batch_size=2, num_steps=5):
    print('X: ', X, '\nY:', Y)
X:  tensor([[ 1,  2,  3,  4,  5],
        [17, 18, 19, 20, 21]]) 
Y: tensor([[ 2,  3,  4,  5,  6],
        [18, 19, 20, 21, 22]])
X:  tensor([[ 6,  7,  8,  9, 10],
        [22, 23, 24, 25, 26]]) 
Y: tensor([[ 7,  8,  9, 10, 11],
        [23, 24, 25, 26, 27]])
X:  tensor([[11, 12, 13, 14, 15],
        [27, 28, 29, 30, 31]]) 
Y: tensor([[12, 13, 14, 15, 16],
        [28, 29, 30, 31, 32]])

3.3、操作整合

现在,我们将上面的两个采样函数包装到一个类中,以便稍后可以将其用作数据迭代器。

#加载序列数据的迭代器
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)

最后,定义了一个函数load_data_time_machine,它同时返回数据迭代器和词表。

#返回时光机器数据集的迭代器和词表
def load_data_time_machine(batch_size,num_steps,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

下面对The Time Machine数据集的前45个数据进行迭代读取,批量大小为2,时间步长为10,并输出其对应的字符。

import numpy 
    
iter,vocab = load_data_time_machine(2,10,False,45)
for X,Y in iter:
    print('X: ', X, '\nY:', Y)
    print('X char:',[vocab.to_tokens(i.numpy().tolist()) for i in X])
    print('Y char:',[vocab.to_tokens(i.numpy().tolist()) for i in Y])
X:  tensor([[ 1, 13,  4, 15,  9,  5,  6,  2,  1, 21],
        [12, 12,  8,  5,  3,  9,  2,  1,  3,  5]]) 
Y: tensor([[13,  4, 15,  9,  5,  6,  2,  1, 21, 19],
        [12,  8,  5,  3,  9,  2,  1,  3,  5, 13]])
X char: [[' ', 'm', 'a', 'c', 'h', 'i', 'n', 'e', ' ', 'b'], ['l', 'l', 's', 'i', 't', 'h', 'e', ' ', 't', 'i']]
Y char: [['m', 'a', 'c', 'h', 'i', 'n', 'e', ' ', 'b', 'y'], ['l', 's', 'i', 't', 'h', 'e', ' ', 't', 'i', 'm']]

4、循环神经网络

4.1、概念

前面介绍的n元语法中,时间步 t t t的词 w t w_t wt基于前面所有词的条件概率只考虑了最近时间步的 n − 1 n-1 n1个词。如果要考虑比 t − ( n − 1 ) t-(n-1) t(n1)更早时间步的词对 w t w_t wt可能会产生好的影响,这样一来就需要增大n,因此模型的参数的数量将随之呈指数级增长。

下面将介绍循环神经网络(Recurrent Natural Network, RNN)。它并非刚性地记忆所有固定长度的序列,而是通过隐藏状态来存储之前时间步的信息。下面就来看看循环神经网络是什么。

下图为循环神经网络的经典结构,从图中可以看到输入 x x x,隐藏层,输出层等,这些与传统神经网络类似,不过自循环 W W W是循环神经网络的一大特色。这个自循环可以理解为神经元之间的联系。

img

其中 U U U是输入到隐藏层的权重矩阵, W W W是状态到隐藏层的权重矩阵, s s s为状态, V V V是隐藏层到输出层的权重矩阵,下图为上图展开后的样子,从图中可以看出,它的共享参数方式是各个时间节点对应的 W 、 U 、 V W、U、V WUV都是不变的,这个机制就像卷积神经网络的卷积核机制一样,通过这种方式实现参数共享,同时大大降低参数量。

img

下面看看循环神经网络的是如何进行计算的。假设 X t ∈ R n × d X_t \in R^{n \times d} XtRn×d是序列中时间步 t t t的小批量输入, H t ∈ R n × d H_t \in R^{n \times d} HtRn×d是该时间步的隐藏变量。与多层感知机不同的是,这里我们保存上一步的隐藏变量 H t − 1 H_{t-1} Ht1,并引入一个新的权重参数 W ∈ R h × h W \in R^{h \times h} WRh×h,该参数用来描述在当前时间步如何使用上一时间步的隐藏变量。具体说,时间步 t t t的隐藏变量的计算由当前时间步的输入和上一时间步的隐藏变量共同决定:
H t = ϕ ( X t U + H t − 1 W + b h ) H_t=\phi(X_tU+H_{t-1}W+b_h) Ht=ϕ(XtU+Ht1W+bh)
输出层的计算为:
O t = H t V + b q O_t=H_tV+b_q Ot=HtV+bq
循环神经网络的参数包括输入层权重 U U U、隐藏层权重 W W W、输出层权重 V V V和两个偏置参数 b h b_h bh b q b_q bq。在不同的时间步,循环神经网络一直使用这些模型参数。因此循环神经网络模型参数的数量不随时间步的增加而增长。

下图为循环神经网络在3个相邻时间步的计算过程。在时间步 t t t,隐藏状态的计算可以看成是将输入 X t X_t Xt和前一时间步隐藏状态 H t − 1 H_{t-1} Ht1连接后输入一个激活函数为 ϕ \phi ϕ的全连接层,该全连接层的输出就是当前时间步的隐藏状态 H t H_t Ht。当前时间步的隐藏状态 H t H_t Ht将会参与下一个时间步 t + 1 t+1 t+1的隐藏状态 H t + 1 H_{t+1} Ht+1的计算,并输入到当前时间步的全连接输出层。

img

RNN有很多的变形,在上面介绍的网络是Elman Network(下图左边),它是把当前时间节点的隐藏层的值存起来,作为下一个时间节点隐藏层的输入使用;还有一种RNN的结构网络是Jordan Network(下图右边),它是把当前时间节点的输出值作为下一个时间节点隐藏层的输入值。有人说Jordan Network可能会得到更好的性能,因为左边的Elman Network中的隐藏层节点中是没有target的,所以我们不知道隐藏层存的内容是什么,而右图中Jordan Network的输出它是有target的,因此它知道自己应该保存什么信息。但下面的实现中还是使用Elman Network。

img

4.2、通过时间反向传播

下面介绍循环神经网络中梯度计算和存储方法,即通过时间反向传播。

4.2.1、定义模型

为了简单,考虑一个无偏差的循环神经网络,激活函数为恒等映射,设时间步t的输入为单样本 x t ∈ R d x_t \in R^d xtRd,标签为 y t y_t yt,那么隐藏状态的表达式为:
h t = U x t + W h t − 1 h_t=Ux_t + Wh_{t-1} ht=Uxt+Wht1
其中 U ∈ R h × d U \in R^{h \times d} URh×d W ∈ R h × h W \in R^{h\times h} WRh×h是输入层和隐藏层权重参数。设输出层权重参数为 V ∈ R q × h V \in R^{q\times h} VRq×h,时间步 t t t的输出层变量 o t ∈ R q o_t \in R^q otRq的计算为::
o t = V h t o_t = Vh_t ot=Vht
设时间步 t t t的损失函数为 l ( o t , y t ) \mathscr l (o_t,y_t) l(ot,yt)。时间步数为T的损失函数L定义为:
L = 1 T ∑ t = 1 T l ( o t , y t ) L = \frac{1}{T} \sum_{t=1}^T \mathscr l (o_t,y_t) L=T1t=1Tl(ot,yt)

4.2.2、模型计算图

下图为模型计算图,例如时间步3的隐藏状态 h 3 h_3 h3的计算依赖模型参数 U 、 W U、W UW、上一时间步隐藏状态 h 2 h_2 h2以及当前时间步输入 x 3 x_3 x3

img

4.2.2、计算

模型的参数是 U U U W W W V V V。训练模型时需要计算这些参数的梯度 ∂ L / ∂ U 、 ∂ L / ∂ W \partial L /\partial U、\partial L /\partial W L/UL/W ∂ L / ∂ V \partial L /\partial V L/V。可以按照计算图中箭头所指的反方向依次计算并存储梯度,下面看看如何进行计算。

输出层梯度

目标函数与各时间步输出层的梯度 ∂ L / ∂ o t \partial L /\partial {o_t} L/ot为:
∂ L ∂ o t = ∂ l ( o t , y t ) T ⋅ ∂ o t \frac{\partial L}{\partial {o_t}} = \frac{\partial {\mathscr l (o_t,y_t)}}{T \cdot \partial o_t} otL=Totl(ot,yt)

输出层参数的梯度

下面计算输出层的参数 V V V的梯度 ∂ L / ∂ V \partial L /\partial {V} L/V。根据计算图 L L L通过 o 1 , ⋯ o T o_1,\cdots o_T o1,oT依赖 V V V,根据链式法则,计算公式为:
∂ L ∂ V = ∑ t = 1 T ( ∂ L ∂ o t ∂ o t ∂ V ) = ∑ t = 1 T ∂ L ∂ o t h t ⊤ \frac{\partial L}{\partial {V}} = \sum_{t=1}^T(\frac{\partial L}{\partial {o_t}} \frac{\partial {o_t}}{\partial {V}})=\sum_{t=1}^T \frac{\partial L}{\partial {o_t}} h_t^{\top} VL=t=1T(otLVot)=t=1TotLht

隐藏状态的梯度

L只通过 o T o_T oT依赖最终时间步 T T T的隐藏状态 h T h_T hT。因此目标函数对最终时间步隐藏状态的梯度 ∂ L / ∂ h T \partial L /\partial {h_T} L/hT为:
∂ L ∂ h T = ( ∂ L ∂ o t ∂ o t ∂ h T ) = V ⊤ ∂ L ∂ o T \frac{\partial L}{\partial {h_T}} = (\frac{\partial L}{\partial {o_t}} \frac{\partial {o_t}}{\partial {h_T}})=V^{\top}\frac{\partial L}{\partial {o_T}} hTL=(otLhTot)=VoTL
而对于时间步 t < T t \lt T t<T L L L需要通过 h t + 1 h_{t+1} ht+1 o t o_t ot依赖 h t h_t ht。根据链式法则,目标函数关于时间步 t < T t \lt T t<T的隐藏状态的梯度 ∂ L / ∂ h t \partial L /\partial {h_t} L/ht需要按照时间步从大到小依次计算:
∂ L ∂ h t = ( ∂ L ∂ h t + 1 ∂ h t + 1 ∂ h t ) + ( ∂ L ∂ o t ∂ o t ∂ h t ) = W ⊤ ∂ L ∂ h t + 1 + V ⊤ ∂ L ∂ o t \frac{\partial L}{\partial {h_t}} = (\frac{\partial L}{\partial {h_{t+1}}} \frac{\partial {h_{t+1}}}{\partial {h_t}}) + (\frac{\partial L}{\partial {o_t}} \frac{\partial {o_t}}{\partial {h_t}})=W^{\top}\frac{\partial L}{\partial {h_{t+1}}}+V^{\top}\frac{\partial L}{\partial {o_t}} htL=(ht+1Lhtht+1)+(otLhtot)=Wht+1L+VotL
将上面的递归公式展开,对于任意时间步 1 ⩽ t ⩽ T 1 \leqslant t \leqslant T 1tT,可以得到目标函数有关隐藏状态梯度的通项公式为:
∂ L ∂ h t = ∑ i = t T ( W ⊤ ) T − i V ⊤ ∂ L ∂ o T + t − i \frac{\partial L}{\partial {h_t}} = \sum_{i=t}^T (W^{\top})^{T-i} V^{\top} \frac{\partial L}{\partial {o_{T+t-i}}} htL=i=tT(W)TiVoT+tiL

输入层和隐藏层参数的梯度

由上式的指数项可见,当时间步数 T T T较大或者时间步 t t t较小时,目标函数有关隐藏状态的梯度容易出现衰减和爆炸。这也会影响其他包含 ∂ L ∂ h t \frac{\partial L}{\partial {h_t}} htL项的梯度,例如输入层和隐藏层的参数的梯度 ∂ L / ∂ U \partial L /\partial {U} L/U ∂ L / ∂ W \partial L /\partial {W} L/W L L L通过 h 1 , ⋯   , h T h_1,\cdots ,h_T h1,,hT依赖这些模型参数,根据链式法则有:
∂ L ∂ U = ∑ i = 1 T ( ∂ L ∂ h t ∂ h t ∂ U ) = ∑ i = 1 T ∂ L ∂ h t x t ⊤ ∂ L ∂ W = ∑ i = 1 T ( ∂ L ∂ h t ∂ h t ∂ W ) = ∑ i = 1 T ∂ L ∂ h t h t − 1 ⊤ \frac{\partial L}{\partial {U}}= \sum_{i=1}^T(\frac{\partial L}{\partial {h_{t}}} \frac{\partial h_t}{\partial {U}})=\sum_{i=1}^T \frac{\partial L}{\partial {h_{t}}}x_t^{\top}\\ \frac{\partial L}{\partial {W}}= \sum_{i=1}^T(\frac{\partial L}{\partial {h_{t}}} \frac{\partial h_t}{\partial {W}})=\sum_{i=1}^T \frac{\partial L}{\partial {h_{t}}}h_{t-1}^{\top} UL=i=1T(htLUht)=i=1ThtLxtWL=i=1T(htLWht)=i=1ThtLht1

4.3、应用:基于字符级循环神经网络的语言模型

下面介绍如何使循环神经网络来构建语言模型。设小批量大小为1,批量中的那个文本序为“machine”,下图演示了如何通过基于字符级语言建模的循环神经网络,即使用当前的和先前的字符预测下一个字符。

在训练过程中,我们对每个时间步的输出层的输出进行softmax操作, 然后利用交叉熵损失计算模型输出和标签之间的误差。图中时间步3的输出 O 3 O_3 O3取决于文本序列“m”、“a”和“c”,由于训练数据中该序列的下一个字符为“h”,因此时间步3的损失将取决于该时间步基于序列“m”、“a”和“c”生成下一个词为“h”的概率。

img

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

下面将根据对循环神经网络的描述,从头实现循环神经网络实现字符级语言模型。并使用模型在The Time Machine数据集上进行训练,下面使用前面定义的函数读取The Time Machine数据集。

import math
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
%matplotlib inline

#批量大小,时间步长
batch_size, num_steps = 32,35

#返回时光机器数据集的迭代器和词表
#train_iter中已经将样本和标签处理好,并且将数据以索引的形式保存
#vocab为词表,用于将相应的索引转为文本
train_iter,vocab = load_data_time_machine(batch_size, num_steps)

4.4.1、独热(ont-hot)编码

在处理后的The Time Machine数据集中,将每一个词元表示为一个数字索引,将这些索引直接引入神经网络会使得学习变得困难。因此通常将词元表示为更具表现力的特征向量(如ont-hot、词向量等),这里先介绍最简单的表示独热编码(ont-hot)。

这本数据集中,独热编码就是将每个所用映射为不同的单位向量:假设不同词元的数目为 N N N(len(vocab)),词元索引的范围为0到 N − 1 N-1 N1。如果词元的索引为整数 i i i,就创建一个长度为 N N N的全0向量,并将第 i i i处的元素设置为1。下面将使用torch.nn.functional.one_hot(tensor, num_classes=- 1)函数演示如何生成独热向量,其参数的含义为:

  • tensor (LongTensor):类值。
  • num_classes (int):类的总数。如果设置为 -1,则类数将被推断为比输入张量中的最大类值大 1。
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函数如何将这样的一个小批量数据转换成三维张量,张量的最后一个维度等于词表大小。为便于输入模型,可以转换输入的维度,以便获得形状为(时间步数、批量大小、词表大小)的输出。这样使得能够方便地通过最外层的维度,一步一步更新小批量数据的隐状态(循环神经网络是根据时间步进行输入的)。

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

4.4.2、初始化模型参数

下面初始化循环神经网络模型的模型参数。隐藏单元数num_hiddens是一个可调的超参数。当训练语言模型时。输入和输出来自相同的词表,因此他们具有相同的维度,即词表的大小。

因为将索引转为了one-hot编码,因此输入的维度为one-hot编码的长度即词表大小,如果使用其他词向量的方式,就不一定是词表的大小了。隐藏单元数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.3、循环神经网络模型

首先定义一个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激活函数。

一个时间步的输入为: 同一时间步的批量大小长度为此表大小的向量,因此为逐时间更新小批量数据的隐状态。

#rnn核心部分,返回输出和隐藏状态
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)
        #获得初始状态,和rnn前向传播函数
        self.init_state,self.forward_fn = init_state,forward_fn
    def __call__(self, X,state):
        #获取one-hot编码
        X = F.one_hot(X.T,self.vocab_size).type(torch.float32)
        #进行前向传播
        return self.forward_fn(X,state,self.params)
    def begin_state(self,batch_size,device):
        #初始状态
        return self.init_state(batch_size,self.num_hiddens,device)

定义一个使用GPU的函数,如果有GPU设备就使用,如果没有,就使用CPU。

#如果存在,则返回gpu(i),否则返回cpu
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],d2l.try_gpu())
#通过rnn获取新的状态
Y,new_state = net(X.to(d2l.try_gpu()),state)
Y.shape,len(new_state),new_state[0].shape
(torch.Size([10, 28]), 1, torch.Size([2, 512]))

4.4.4、预测

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

#在prefix后面生成新字符
def predict(prefix,num_preds,net,vocab,device):
    #获取初始状态
    state = net.begin_state(batch_size = 1,device=device)
    #保存输出的字符
    outputs = [vocab[prefix[0]]]
    #获得当前时间步的输入,为输出列表的最后一个字符
    get_input = lambda : torch.tensor([outputs[-1]],device=device).reshape((1,1))
    
    #预热期
    for y in prefix[1:]:
        _,state = net(get_input(),state)
        outputs.append(vocab[y])
    #预测num_pred步
    for _ in range(num_preds):
        y,state = net(get_input(),state)
        outputs.append(int(y.argmax(dim=1).reshape(1)))
    return ''.join([vocab.idx_to_token[i] for i in outputs])

下面使用predict函数,指定前缀为time traveller,并基于这个前缀生成10个后续字符。由于网络没有训练,会产生很差的结果。

predict('time traveller ', 10, net, vocab, try_gpu())
'time traveller brw brw br'

4.4.5、训练

在训练模型之前,定义一个函数在一个迭代周期内训练模型,它的训练模式与之前的模型的训练方式有以下不同的地方:

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

下面看看使用随机采样和相邻采样如何初始化隐状态:

相邻采样:

使用相邻采样时,只在每个迭代周期的开始位置初始化隐状态。

原因是下一个小批量数据中的第 i i i个子序列样本与当前第 i i i个子序列样本相邻,因此当前小批量数据最后一个样本的隐状态,将用于初始化下一个小批量数据的第一个隐状态。这样,存储在隐状态中的历史信息可以在一个迭代周期内流入相邻的子序列。

然而,在任何一点隐状态的计算,都依赖于同一迭代周期中前面所有的小批量数据,这使得梯度计算变得复杂。为了降低计算量,在处理任何一个小批量数据之前,先分离梯度,使得隐状态的梯度计算总是限制在一个小批量数据的时间步内。

随机采样

当使用随机采样时,因为每个样本都是在一个随机位置抽样的,因此需要为每个迭代周期重新初始化隐状态。

下面的函数在一个迭代周期内训练模型:

#训练网络一个迭代周期
def train_epoch(net,train_iter,loss,updater,device,use_random_iter):
    state,timer = None,d2l.Timer()
    #记录损失之和,词元数量
    metric = d2l.Accumulator(2)
    for X,Y in train_iter:
        #第一次迭代或者使用随机抽样是初始化state
        if state is None or use_random_iter:
            state = net.begin_state(batch_size = X.shape[0],device=device)
        else:
            if isinstance(net,nn.Module) and not isinstance(state,tuple):
                state.detach_()
            else:
                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()
        #针对优化器是pytotch还是自定义有不同的优化方法
        if isinstance(updater,torch.optim.Optimizer):  
            updater.zero_grad()
            l.backward()
            updater.step()
        else:
            l.backward()
            updater(batch_size = 1)

        metric.add(l * y.numel(),y.numel())
    #返回困惑度和平均用时
    return math.exp(metric[0]/metric[1]),metric[1]/timer.stop()

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

#自定义优化器
def sgd(params,lr,batch_size):
    #小批量随机梯度下降
    with torch.no_grad():
        for param in params:
            param -= lr*param.grad / batch_size
            param.grad.zero_()
            
def train(net,train_iter,vocab,lr,num_epochs,device,use_random_iter = False):
    #交叉熵损失函数
    loss = nn.CrossEntropyLoss()
    animator = d2l.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(prefix,50,net,vocab,device)

    for epoch in range(num_epochs):
        ppl,speed = train_epoch(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'))

对模型进行训练,并使用训练的模型对"time traveller"和"traveller"两个前缀进行预测。

num_epochs, lr = 500, 1
train(net, train_iter, vocab, lr, num_epochs, try_gpu())
困惑度 1311477198769546752.0, 61831.6 词元/秒 cuda:0
time travelleroleeoleeoleooeeoleeoleooieoleroleoleeoleoleeoieole
travellereooleoleeoleeoieoleeoleeoleeoleeoieoleeoleeoleeole

output_59_1

这结果也太差了吧!!!刚开始的时候,训练的损失还是很小的,突然变得很大,又突然变得很小,这也太不稳定了吧,那是什么造成这种原因了呢?

下图为RNN中的损失值随参数变化的图像,从图像中可以看出,变化是非常崎岖的。损失的变化在有些地方是十分的平坦,而有些地方则变得十分陡峭。当在进行梯度下降过程中,如果刚好经过变化比较陡峭的地方,那么损失就会暴增或者暴跌。在比较平坦的地方由于梯度大小比较小,那么学习率较大,然而当经过一个陡峭的地方的时候,来不及调整学习率,一个大的学习率乘以一个大的梯度,那么就会导致参数飞出去了。

img

那么怎么解决这个问题哪?一个比较简单的方法是使用梯度裁剪,就是将梯度固定在某一个范围内。这是以下比较简单的方法,下面介绍一下梯度裁剪。

4.4.6、梯度裁剪

通过我们的分析我们了解到直接训练RNN模型是不可取的,并且通过由前面介绍的反向传播中可知,当T较大时,它可能导致数值不稳定,例如导致梯度爆炸或者梯度消失。因此循环神经网络需要额外的方式来支持稳定训练。

当梯度很大时,优化算法可能无法收敛。因此可以通过降低学习率 η \eta η来解决这个问题。但是如果我们很少得到大的梯度,这种做法就很不好。一种流行的替代方法是将梯度 g g g投影回给定半径(例如 θ \theta θ)的球来裁剪梯度 g g g。如以下公式:
g ⟵ m i n ( 1 , θ ∣ ∣ g ∣ ∣ ) g g \longleftarrow min(1, \frac{\theta}{||g||})g gmin(1,gθ)g

通过这样做,梯度的范数永远不会超过 θ \theta θ,并且更新后的梯度完全与 g g g的原始方向对齐。它还限制了任何给定小批量诗句对参数向量的影响,这使得模型的稳定性更好。

梯度裁剪提供了一个快速修复梯度爆炸的方法,虽然并不能完全解决问题,但它是众多有效的技术之一。下面定义一个函数来裁剪模型的梯度。

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

下面为添加了梯度参加的训练模型一个迭代周期的代码:

#添加了梯度裁剪的训练网络一个迭代周期
def train_epoch(net,train_iter,loss,updater,device,use_random_iter):
    state,timer = None,d2l.Timer()
    #记录损失之和,词元数量
    metric = d2l.Accumulator(2)
    for X,Y in train_iter:
        #第一次迭代或者使用随机抽样是初始化state
        if state is None or use_random_iter:
            state = net.begin_state(batch_size = X.shape[0],device=device)
        else:
            if isinstance(net,nn.Module) and not isinstance(state,tuple):
                state.detach_()
            else:
                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()
        #针对优化器是pytotch还是自定义有不同的优化方法
        if isinstance(updater,torch.optim.Optimizer):  
            updater.zero_grad()
            l.backward()
            grad_clipping(net,1)
            updater.step()
        else:
            l.backward()
            grad_clipping(net,1)
            updater(batch_size = 1)

        metric.add(l * y.numel(),y.numel())
    #返回困惑度和平均用时
    return math.exp(metric[0]/metric[1]),metric[1]/timer.stop()

下面定义一个新的模型,再使用添加了梯度参加的模型训练过程对新的模型进行训练。

#隐藏层特征数量
num_hiddens = 512
#定义模型
net1 = RNNModelScratch(len(vocab),num_hiddens,try_gpu(),get_params,init_rnn_state,rnn)
num_epochs, lr = 500, 1
train(net1, train_iter, vocab, lr, num_epochs, try_gpu())
困惑度 1.0, 57780.6 词元/秒 cuda:0
time travelleryou can show black is white by argument said filby
travelleryou can show black is white by argument said filby

output_61_1

这下训练结果很不错了。。。

下面使用随机抽样方法训练一下模型。

net = RNNModelScratch(len(vocab), num_hiddens, try_gpu(), get_params,
                      init_rnn_state, rnn)
train(net, train_iter, vocab, lr, num_epochs, try_gpu(),
          use_random_iter=True)
困惑度 1.6, 57201.7 词元/秒 cuda:0
time travellerit s against reason said filbywhat very revarthe w
traveller hat nog hat seatl rather a some time brightening 

output_63_1

4.4.7、代码整合

下面对从零实现的循环神经网络从数据处理到模型搭建、训练、预测的代码进行整合。

# -*- coding: utf-8 -*-
# @Time : 2022/4/17 14:50
# @Author : tiancn
import collections
import re
import random
import numpy
import math
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l


#1. 数据处理部分

#传入time_machine数据集的下载地址以及哈希校验码
d2l.DATA_HUB['time_machine'] = (d2l.DATA_URL + 'timemachine.txt',
                                '090b5e7e70c295757f55df93cb0a180b9691891a')
#将time_machine数据集加载到文本行的列表中
def read_time_machine():
    #下载timemachine.txt文件,并打开文件按行读取内容
    with open(d2l.download('time_machine'),'r') as f:
        lines = f.readlines()
    #re.sub('[^A-Za-z]+',' ',line):使用正则表达式匹配多个连续的非字母,将它们替换为空格
    #strip(); 去除字符串两边的空格
    #lower():转换字符串中所有大写字符为小写。
    return [re.sub('[^A-Za-z]+',' ',line).strip().lower() for line in lines]

#将文本拆分为单词或者字符词元
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)

#统计词元的频率,返回每个词元及其出现的次数,以一个字典形式返回。
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)

#文本词表
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)
        #counter.items():为一个字典
        #lambda x:x[1]:对第二个字段进行排序
        #reverse = True:降序
        self._token_freqs = sorted(counter.items(),key = lambda x:x[1],reverse = True)

        #未知单词的索引为0
        #idx_to_token用于保存所有未重复的词元
        self.idx_to_token = ['<unk>'] + reserved_tokens
        #token_to_idx:是一个字典,保存词元和其对应的索引
        self.token_to_idx = {token:idx for idx,token in enumerate(self.idx_to_token)}

        for token, freq in self._token_freqs:
            #min_freq为最小出现的次数,如果小于这个数,这个单词被抛弃
            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)

    #获取要查询词元的索引,支持list,tuple查询多个词元的索引
    def __getitem__(self, tokens):
        if not isinstance(tokens,(list,tuple)):
            #self.unk:如果查询不到返回0
            return self.token_to_idx.get(tokens,self.unk)
        return [self.__getitem__(token) for token in tokens]

    # 根据索引查询词元,支持list,tuple查询多个索引对应的词元
    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):
        return 0
    @property
    def token_freqs(self):
        return self._token_freqs

#返回The Time Machine数据集的词元索引别表和词表
def load_corpus_time_machine(max_tokens = -1):
    lines = read_time_machine()
    tokens = tokenize(lines,'char')
    vocab = Vocab(tokens)

    #因为The Time Machine数据集中的每个文本行不一定是一个句子或者是一个段落
    #所以将所有文本行展平到一个列表中
    #保存数据集中每个字符的索引
    corpus = [vocab[token] for line in tokens for token in line]
    if max_tokens > 0:
        corpus = corpus[:max_tokens]
    return corpus,vocab

#2.读取处理好的数据,以便于模型训练

# 使用随机抽样生成一个小批量子序列
def seq_data_iter_random(corpus, batch_size, num_steps):
    # 随机偏移量开始对序列进行分区,随机范围包括num_steps-1
    corpus = corpus[random.randint(0, num_steps - 1):]

    # 减去1,是因为需要考虑标签
    # num_subseqs:表示分割的序列的条数
    num_subseqs = (len(corpus) - 1) // num_steps

    # 长度为num_steps的子序列的起始索引编号
    initial_indices = list(range(0, num_subseqs * num_steps, num_steps))

    # 在随机抽样的迭代过程中,来自两个相邻的、随机的、小批量的子序列不一定在原始序列上相邻
    # 因此将起始索引编号打乱
    random.shuffle(initial_indices)

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

    # 表示一共有多少个批量
    num_batches = num_subseqs // batch_size

    # 从0到批量大小×批量的数量遍历,间隔为批量大小,即从循环次数为批量的数量
    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]
        # 返回一个可以用来迭代(for循环)的生成器,因为按照批量大小,返回的数据是多条的
        yield torch.tensor(X), torch.tensor(Y)


# 使用相邻采样生成一个小批量子序列
def seq_data_iter_sequential(corpus, batch_size, num_steps):
    # 从随机偏移量开始划分序列
    offest = random.randint(0, num_steps)
    # 获取用于最终训练的序列,因为有偏移量和不能整除,因此对输入的序列进行处理
    num_tokens = ((len(corpus) - offest - 1) // batch_size) * batch_size

    # 样本序列
    Xs = torch.tensor(corpus[offest:offest + num_tokens])
    # 标签序列
    Ys = torch.tensor(corpus[offest + 1: offest + num_tokens + 1])

    # 转为2维数据,行代表不同批次
    Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)

    # 为批次的数量
    num_batchs = Xs.shape[1] // num_steps
    # 循环输出
    for i in range(0, num_steps * num_batchs, 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,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


#3.循环神经网络搭建部分

#初始化模型参数
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

#返回初始化隐藏状态
def init_rnn_state(batch_size,num_hiddens,device):
    return (torch.zeros((batch_size,num_hiddens),device=device),)

#rnn核心部分,返回输出和隐藏状态
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)
        #获得初始状态,和rnn前向传播函数
        self.init_state,self.forward_fn = init_state,forward_fn
    def __call__(self, X,state):
        #获取one-hot编码
        X = F.one_hot(X.T,self.vocab_size).type(torch.float32)
        #进行前向传播
        return self.forward_fn(X,state,self.params)
    def begin_state(self,batch_size,device):
        #初始状态
        return self.init_state(batch_size,self.num_hiddens,device)
#使用GPU
#如果存在,则返回gpu(i),否则返回cpu
def try_gpu(i=0):
    if torch.cuda.device_count()>=i+1:
        return torch.device(f'cuda:{i}')
    return torch.device('cpu')

#4.预测部分


#在prefix后面生成新字符
def predict(prefix, num_preds, net, vocab, device):
    # 获取初始状态
    state = net.begin_state(batch_size=1, device=device)
    # 保存输出的字符
    outputs = [vocab[prefix[0]]]
    # 获得当前时间步的输入,为输出列表的最后一个字符
    get_input = lambda: torch.tensor([outputs[-1]], device=device).reshape((1, 1))

    # 预热期
    for y in prefix[1:]:
        _, state = net(get_input(), state)
        outputs.append(vocab[y])
    # 预测num_pred步
    for _ in range(num_preds):
        y, state = net(get_input(), state)
        outputs.append(int(y.argmax(dim=1).reshape(1)))
    return ''.join([vocab.idx_to_token[i] for i in outputs])



#5.模型训练部分

#裁剪梯度
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


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


def train(net, train_iter, vocab, lr, num_epochs, device, use_random_iter=False):
    # 交叉熵损失函数
    loss = nn.CrossEntropyLoss()
    animator = d2l.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(prefix, 50, net, vocab, device)

    for epoch in range(num_epochs):
        state, timer = None, d2l.Timer()
        # 记录损失之和,词元数量
        metric = d2l.Accumulator(2)
        for X, Y in train_iter:
            # 第一次迭代或者使用随机抽样是初始化state
            if state is None or use_random_iter:
                state = net.begin_state(batch_size=X.shape[0], device=device)
            else:
                if isinstance(net, nn.Module) and not isinstance(state, tuple):
                    state.detach_()
                else:
                    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()
            # 针对优化器是pytotch还是自定义有不同的优化方法
            if isinstance(updater, torch.optim.Optimizer):
                updater.zero_grad()
                l.backward()
                grad_clipping(net, 1)
                updater.step()
            else:
                l.backward()
                grad_clipping(net, 1)
                updater(batch_size=1)

            metric.add(l * y.numel(), y.numel())
        # 返回困惑度和平均用时
        ppl, speed = math.exp(metric[0] / metric[1]), metric[1] / timer.stop()

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

#6. 获取数据,实例化模型,训练模型并预测

#批量大小,时间步长
batch_size, num_steps = 32,35
#训练轮次,学习率
num_epochs, lr = 500, 1
#隐藏层特征数量
num_hiddens = 512

#返回时光机器数据集的迭代器和词表
#train_iter中已经将样本和标签处理好,并且将数据以索引的形式保存
#vocab为词表,用于将相应的索引转为文本
train_iter,vocab = load_data_time_machine(batch_size, num_steps)

#定义模型
net = RNNModelScratch(len(vocab),num_hiddens,try_gpu(),get_params,init_rnn_state,rnn)

#训练模型
train(net, train_iter, vocab, lr, num_epochs, try_gpu())
困惑度 1.0, 57818.5 词元/秒 cuda:0
time travelleryou can show black is white by argument said filby
travelleryou can show black is white by argument said filby

output_65_1

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

4.5.1、torch.nn.RNN()函数

PyTorch中提供了两个实现循环神经网络的方法:torch.nn.RNN()torch.nn.RNNCell()torch.nn.RNN()的输入是一个序列;而torch.nn.RNNCell()的输入是一个时间步,因此需要使用循环才能实现对一个序列进行处理的功能。下面主要介绍torch.nn.RNN()

torch.nn.RNN()的一般格式为:

torch.nn.RNN(*args, **kwargs)

对于输入序列中的每个元素,每层都计算以下函数:
h t = t a n h ( W i h x t + b i h + W h h h t − 1 + b h h ) h_t = tanh(W_{ih}x_t + b_{ih} + W_{hh}h_{t-1}+b_{hh}) ht=tanh(Wihxt+bih+Whhht1+bhh)

下面看看该函数中的参数:

  • input_size:输入x的特征数量
  • hidden_size:隐藏层的特征数量
  • num_layers:RNN层数
  • nonlinearity:指定非线性函数使用tanh还是relu。默认是tanh
  • bias:如果是False,RNN层就不会使用偏置权重,默认是True
  • batch_first:如果为True的话,那么输入Tensor的shape应该是(batch,seq,feature),输出也是这样。默认为False,即网络输入为(seq,batch,feature),即序列长度、批次大小、特征维度
  • dropout:如果值非零(参数的取值范围在0-1之间),那么除了最后一层外,其他层的输出都会加上一个dropout层,默认为0
  • bidirectional:如果True,将编程一个双向的RNN,默认为False。

函数torch.nn.RNN()的输入为特征和隐藏状态,记为 ( x t , h 0 ) (x_t,h_0) (xt,h0),输出包括输出特征和输出隐藏状态,记为 ( o u t p u t t , h n ) (output_t,h_n) (outputt,hn)

下面看看各个的形状是什么样子的:

  • 其中输入特征值 x t x_t xt的形状为(seq_len,batch,input_size),这与CNN的输入不一样,CNN的第一个参数为批量大小,而RNN为序列的长度,因为RNN需要一个时间步一个时间步的进行输入。
  • h 0 h_0 h0的形状为(num_layers * num_directional,batch,hidden_size),其中num_layers为层数,num_directional为方向数,如果取2则表示为双向,取1表示为单向。
  • o u t p u t t output_t outputt的形状为(seq_len,batch,num_directional * hidden_size)
  • h n h_n hn的形状为(num_layers * num_directional,batch,hidden_size)

4.5.2、RNN简洁实现

下面使用torch.nn.RNN()函数实现一个简洁版的循环神经网络。其中数据预处理,数据加载,模型训练以及预测还使用上面定义的,本节重点关注如何使用torch.nn.RNN()搭建循环神经网络。在整合版本中有完整代码。

导入所使用的包并加载The Time Machine数据集:

import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l

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

定义模型

在定义模型之前,先看看如何使用这个函数,我们构造一个隐藏层特征数量为256的单隐藏层的循环神经网络rnn_layer。而多层就可以理解为一层循环神经网络的输出被下一层作为输入就好了,可以通过参数num_layers指定。

num_hiddens = 256
#第一个参数是特征数量,这里为数据集词元的数量。因为使用的是one-hot编码,每一个输入的长度与词元的数量一样
rnn_layrer = 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)))
#计算RNN的输出,Y为所有的隐状态,state_new为当前时间步的隐状态
Y, state_new = rnn_layrer(X, state)
Y.shape, state_new.shape
(torch.Size([35, 32, 256]), torch.Size([1, 32, 256]))

下面为一个完整的循环神经网络模型定义了一个RNNModel类。 使用nn.RNN构造的循环神经网络只包含隐藏的循环层,我们还需要创建一个单独的输出层。

class RNNModel(nn.Module):
    def __init__(self,vocab_size,num_hiddens,**kwargs):
        super(RNNModel, self).__init__(**kwargs)
        #RNN核心
        self.rnn = nn.RNN(vocab_size,num_hiddens)
        self.vocab_size = vocab_size
        self.num_hiddens = num_hiddens
        #如果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(vocab_size=len(vocab),num_hiddens=num_hiddens)
net = net.to(device)
#进行预测
predict('time traveller',10,net,vocab,device=device)
'time travellerlzdrlzdrlz'

显然,没有训练过的模型,不可能输出好的结果,下面对模型进行训练,然后再预测。

num_epochs,lr = 500,1
train(net,train_iter,vocab,lr,num_epochs,device)
困惑度 1.3, 191189.5 词元/秒 cuda:0
time traveller proceeded anyreal body must hove the of the thing
travelleryor sithisald the ithard asteat lo germabous toven

output_82_1

4.5.3、代码整合

这部分把数据处理、数据加载、模型定义、模型训练都集成在一起。

# -*- coding: utf-8 -*-
# @Time : 2022/4/17 14:50
# @Author : tiancn
import collections
import re
import random
import numpy
import math
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l


#1. 数据处理部分

#传入time_machine数据集的下载地址以及哈希校验码
d2l.DATA_HUB['time_machine'] = (d2l.DATA_URL + 'timemachine.txt',
                                '090b5e7e70c295757f55df93cb0a180b9691891a')
#将time_machine数据集加载到文本行的列表中
def read_time_machine():
    #下载timemachine.txt文件,并打开文件按行读取内容
    with open(d2l.download('time_machine'),'r') as f:
        lines = f.readlines()
    #re.sub('[^A-Za-z]+',' ',line):使用正则表达式匹配多个连续的非字母,将它们替换为空格
    #strip(); 去除字符串两边的空格
    #lower():转换字符串中所有大写字符为小写。
    return [re.sub('[^A-Za-z]+',' ',line).strip().lower() for line in lines]

#将文本拆分为单词或者字符词元
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)

#统计词元的频率,返回每个词元及其出现的次数,以一个字典形式返回。
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)

#文本词表
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)
        #counter.items():为一个字典
        #lambda x:x[1]:对第二个字段进行排序
        #reverse = True:降序
        self._token_freqs = sorted(counter.items(),key = lambda x:x[1],reverse = True)

        #未知单词的索引为0
        #idx_to_token用于保存所有未重复的词元
        self.idx_to_token = ['<unk>'] + reserved_tokens
        #token_to_idx:是一个字典,保存词元和其对应的索引
        self.token_to_idx = {token:idx for idx,token in enumerate(self.idx_to_token)}

        for token, freq in self._token_freqs:
            #min_freq为最小出现的次数,如果小于这个数,这个单词被抛弃
            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)

    #获取要查询词元的索引,支持list,tuple查询多个词元的索引
    def __getitem__(self, tokens):
        if not isinstance(tokens,(list,tuple)):
            #self.unk:如果查询不到返回0
            return self.token_to_idx.get(tokens,self.unk)
        return [self.__getitem__(token) for token in tokens]

    # 根据索引查询词元,支持list,tuple查询多个索引对应的词元
    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):
        return 0
    @property
    def token_freqs(self):
        return self._token_freqs

#返回The Time Machine数据集的词元索引别表和词表
def load_corpus_time_machine(max_tokens = -1):
    lines = read_time_machine()
    tokens = tokenize(lines,'char')
    vocab = Vocab(tokens)

    #因为The Time Machine数据集中的每个文本行不一定是一个句子或者是一个段落
    #所以将所有文本行展平到一个列表中
    #保存数据集中每个字符的索引
    corpus = [vocab[token] for line in tokens for token in line]
    if max_tokens > 0:
        corpus = corpus[:max_tokens]
    return corpus,vocab

#2.读取处理好的数据,以便于模型训练

# 使用随机抽样生成一个小批量子序列
def seq_data_iter_random(corpus, batch_size, num_steps):
    # 随机偏移量开始对序列进行分区,随机范围包括num_steps-1
    corpus = corpus[random.randint(0, num_steps - 1):]

    # 减去1,是因为需要考虑标签
    # num_subseqs:表示分割的序列的条数
    num_subseqs = (len(corpus) - 1) // num_steps

    # 长度为num_steps的子序列的起始索引编号
    initial_indices = list(range(0, num_subseqs * num_steps, num_steps))

    # 在随机抽样的迭代过程中,来自两个相邻的、随机的、小批量的子序列不一定在原始序列上相邻
    # 因此将起始索引编号打乱
    random.shuffle(initial_indices)

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

    # 表示一共有多少个批量
    num_batches = num_subseqs // batch_size

    # 从0到批量大小×批量的数量遍历,间隔为批量大小,即从循环次数为批量的数量
    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]
        # 返回一个可以用来迭代(for循环)的生成器,因为按照批量大小,返回的数据是多条的
        yield torch.tensor(X), torch.tensor(Y)


# 使用相邻采样生成一个小批量子序列
def seq_data_iter_sequential(corpus, batch_size, num_steps):
    # 从随机偏移量开始划分序列
    offest = random.randint(0, num_steps)
    # 获取用于最终训练的序列,因为有偏移量和不能整除,因此对输入的序列进行处理
    num_tokens = ((len(corpus) - offest - 1) // batch_size) * batch_size

    # 样本序列
    Xs = torch.tensor(corpus[offest:offest + num_tokens])
    # 标签序列
    Ys = torch.tensor(corpus[offest + 1: offest + num_tokens + 1])

    # 转为2维数据,行代表不同批次
    Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)

    # 为批次的数量
    num_batchs = Xs.shape[1] // num_steps
    # 循环输出
    for i in range(0, num_steps * num_batchs, 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,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


#3.循环神经网络搭建部分
class RNNModel(nn.Module):
    def __init__(self,vocab_size,num_hiddens,**kwargs):
        super(RNNModel, self).__init__(**kwargs)
        #RNN核心
        self.rnn = nn.RNN(vocab_size,num_hiddens)
        self.vocab_size = vocab_size
        self.num_hiddens = num_hiddens
        #如果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))

#使用GPU
#如果存在,则返回gpu(i),否则返回cpu
def try_gpu(i=0):
    if torch.cuda.device_count()>=i+1:
        return torch.device(f'cuda:{i}')
    return torch.device('cpu')

#4.预测部分


#在prefix后面生成新字符
def predict(prefix, num_preds, net, vocab, device):
    # 获取初始状态
    state = net.begin_state(batch_size=1, device=device)
    # 保存输出的字符
    outputs = [vocab[prefix[0]]]
    # 获得当前时间步的输入,为输出列表的最后一个字符
    get_input = lambda: torch.tensor([outputs[-1]], device=device).reshape((1, 1))

    # 预热期
    for y in prefix[1:]:
        _, state = net(get_input(), state)
        outputs.append(vocab[y])
    # 预测num_pred步
    for _ in range(num_preds):
        y, state = net(get_input(), state)
        outputs.append(int(y.argmax(dim=1).reshape(1)))
    return ''.join([vocab.idx_to_token[i] for i in outputs])



#5.模型训练部分

#裁剪梯度
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


def train(net, train_iter, vocab, lr, num_epochs, device, use_random_iter=False):
    # 交叉熵损失函数
    loss = nn.CrossEntropyLoss()
    animator = d2l.Animator(xlabel='epoch', ylabel='perplexity',
                            legend=['train'], xlim=[10, num_epochs])
    updater = torch.optim.SGD(net.parameters(), lr)

    predict_ = lambda prefix: predict(prefix, 50, net, vocab, device)

    for epoch in range(num_epochs):
        state, timer = None, d2l.Timer()
        # 记录损失之和,词元数量
        metric = d2l.Accumulator(2)
        for X, Y in train_iter:
            # 第一次迭代或者使用随机抽样是初始化state
            if state is None or use_random_iter:
                state = net.begin_state(batch_size=X.shape[0], device=device)
            else:
                if isinstance(net, nn.Module) and not isinstance(state, tuple):
                    state.detach_()
                else:
                    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()
            # 针对优化器是pytotch还是自定义有不同的优化方法
            updater.zero_grad()
            l.backward()
            grad_clipping(net, 1)
            updater.step()

            metric.add(l * y.numel(), y.numel())
        # 返回困惑度和平均用时
        ppl, speed = math.exp(metric[0] / metric[1]), metric[1] / timer.stop()

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

下面训练模型

# 批量大小,时间步长
batch_size, num_steps = 32, 35
# 训练轮次,学习率
num_epochs, lr = 500, 1
# 隐藏层特征数量
num_hiddens = 512
# 返回时光机器数据集的迭代器和词表
# train_iter中已经将样本和标签处理好,并且将数据以索引的形式保存
# vocab为词表,用于将相应的索引转为文本
train_iter, vocab = load_data_time_machine(batch_size, num_steps)
# 定义模型
net = RNNModel(vocab_size=len(vocab),num_hiddens=num_hiddens)
net.to(try_gpu())
# 训练模型
train(net,train_iter,vocab,lr,num_epochs,try_gpu())
困惑度 1.0, 92812.7 词元/秒 cuda:0

output_86_1

下面使用训练好的模型预测一下句子。

predict('you', 22, net, vocab, try_gpu())
'you sure we can move free'

结果是一句话哈哈。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值