循环神经网络(RNN),附代码
参考:(代码目前版本基本是参考沐神的)
[1] https://www.bilibili.com/video/BV1BQ4y1R7V7?p=3
[2] https://www.bilibili.com/video/BV1kq4y1H7sw?spm_id_from=333.999.0.0
在小规模问题上,RNN可能效果还是不错。
特点:输入和输出长度无需固定,适合语音,文本等时序序列数据。
RNN架构
上图为RNN的整体架构,RNN每次看到一个词,通过状态hi来积累看到的信息。例如,h0包含x0的信息,h1包含x0和x1的信息,最后一个状态ht包含了整句话的信息,从而可以把它作为整个句子的特征,用来做其他任务,比如分类。这是从架构图中直观看到的信息。注意,无论RNN的链条有多长,都只有一个参数矩阵A,A可以随机初始化,然后再通过训练来学习。
RNN具体状态更新过程
这个公式是从沐神[2]视频中截取的,用架构图中的形式描述可以写为下式:
这个b(bias)加不加其实都是可以的,不加比较简单,就像刚上手神经网络的时候,y=Wx+b,就干脆省掉b,方便学习。
(h_t-1 x_t)^T这个表示h_t-1和x_t拼接起来的转置,A就是第一个公式中Whh和Whx的拼接,所以两个是一模一样的。
计算可视化
RNN的激活函数用的是tanh,非sigmoid和relu
计算过程上面也有说明了,就是将上一个隐藏状态h_t-1和当前的输入x_t拼接在一起,和参数矩阵A相乘,然后在外面套一个激活函数tanh
tanh激活函数是个什么
这个就是可以将自变量映射到-1到1之间的一个函数,叫双曲正切函数,定义式长这样。
那么为什么要用tanh作为激活函数?
设想一下,如果没有这个激活函数,输入的xi为全0向量,那么状态更新就变成了如下所示:
由此可以推导h_100 = Ah_99 = … = A^100h_0
若A的特征值是0.9,0.9的100次方是非常接近0的,h_100就几乎是一个0向量;同样地,若A的特征值是1.1,1.1的100次方是非常大的,h_100可能就溢出了。
用了tanh激活函数就会让每个数字映射到-1到1的这个合理的区间中。
RNN的用法
RNN的用法有两种,一种是直接将最后一个状态h_t拿出来作为最终的输出,也可以将所有状态一起拿出来作为最终的输出。
第二种用法的具体操作可以为:将h1,h2……ht拼接成一个大矩阵,然后用flatten将这个矩阵展开成一个向量,然后乘上一个参数矩阵套一层激活函数作为输出。
Simple RNN的缺陷
上述介绍的是Simple RNN,缺陷也是比较容易看出来的。
短期性:距离某个输入较远的隐状态h几乎不会受到这个输入的影响。
例如,h_100很可能和x_0已经无关了。
到这里可能就疑惑了,从架构图中不是看得出来h_100包含x0到x100的所有信息吗?但是h_100毕竟只是一个隐状态,而且离x0是非常远的,离x100是很近的,那它很明显要更关心和它更亲近的人,即h_100是和x100最相关的,这就是Simple RNN的短期性或者遗忘性的问题。
提升效果的办法:多层RNN,双向RNN
多层RNN(Stacked RNN)
这个比较简单,就是将一层一层的RNN拼接起来,下层的输出作为上层的输入,注意这里需要下层的h1到ht的所有状态,而不是只要ht一个状态。
每一层都是自己的参数矩阵A
双向RNN(Bidirectional RNN)
就是两条独立的RNN,一条从右往左读数据,一条从左往右读数据。
不共享参数,不共享状态,两条RNN各自输出自己的状态向量h,然后拼接起来成y,如果有多层,就把y作为上层的输入;如果没有多层,y可以都丢掉,只保留两条RNN最后的状态向量,ht和ht’,把他们拼接起来作为最后的特征来完成任务。
双向的RNN总是比Simple RNN效果好,这也是比较好理解的,Simple RNN容易忘掉早些的输入,双向的恰恰把早些的输入记的更深了。
还有个提升效果的办法的预训练,这个就不多说了,懂得都懂,不懂的可以搜一下,火的不得了。
代码实现:
先放沐神的版本,后面我自己写的版本。
import 模块
%matplotlib inline
import math
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
import os
os.environ['KMP_DUPLICATE_LIB_OK'] = 'TRUE' #不加这个我的程序在训练时候会报一个dll的错误
定义batch_size、num_steps,导入数据
batch_size, num_steps = 32, 35 #num_steps是时间T的大小,一次看多长的序列 输入是batch_size*vocab_size (因为输入的编码的char级别的onehot向量)
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
vocab可以看成是一个dict,keys是包含26个字母和’’,’ '(空格)两个元素
train_iter是包含训练数据的内容,已经封装好了,由于不是RNN的重点,就不细说了。里面的内容如下
从X和Y中的内容和vocab可以看出,Xi表示一个字母,是char级别的,而非word级别的
所需求解的RNN层参数W_xh、W_hh、b_h、输出层参数W_hq、b_q
因为是手撸RNN,所以需要先将参数矩阵都显式写出来,后面需要用矩阵乘法来实现
至于为什么写成一个函数的形式,这是为了方便封装,实现模型时显得简洁
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
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:
#W_xh:num_inputs, num_hiddens;W_hh:num_hiddens, num_hiddens
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,)
为什么inputs的形状是(num_steps,batch_size,vocab_size)而不是(batch_size,num_steps,vocab_size)
这是因为每次送入模型运算的是当前时间步Xi下的字符,而不能将所有时间步都送入,所以要先将inputs转置成(num_steps,batch_size,vocab_size),每次取一个时间步作为输入
从零开始实现的循环神经⽹络模型
class RNNModelScratch:
def