搭建深度学习框架(三) 循环神经网络, BPTT的梯度计算

为什么需要RNN

RNN是一种拥有记忆的网络, 一旦网络接收到了输入, 就会改变它的隐藏变量. 这个隐藏变量会参与RNN的前向运算, 从而让之前的输入x, 能影响现在的输出o. 具有这种性质的它通常用于处理序列信息. 序列信息比起之前的传统模式分类有着一些不太好的性质, 语音和文本信号, 都是变长的. 而且文本信息常常是每个词对应一个one-hot编码或者一个word-embedding词向量, 而语音信号的采样率又非常高, 一段简单的声音有可能对应着几千长度的序列. 变长的数据还比较容易处理, 毕竟我们在数据科学中也会遇到缺失值, 直接补0即可. 但是考虑到数据可能很长也可能很短, 我们想把它们一同处理就必须把所有数据对齐最长的那个.这样就造成了不必要的算力浪费. 最长的数据的维度可能很高(几k甚至几百k), 也就是至少我们输入层的input_size就很大, 即至少输入层的参数会非常非常多. 如果设计一个巨型的网络来处理序列数据显然是浪费的.
我们需要更好的架构来处理序列数据, 这时RNN就很有用了. RNN可以自由处理变长序列, 但是每次运算的输入只有一个词向量那么大. 这样就大大节约了参数. 同时也减少了计算.

结构与前向传播

在这里插入图片描述
RNN的计算和前馈网络相似, 每次RNN前向传播会同时接收两个向量, 一个是我们当前时刻的输入x, 另一个是保存在存储单元中的向量h. 我们会同时用这两个向量, 经过两个线性层, 得到RNN的隐层输出. 这个隐层输出会成为新的存储单元中的向量h, 参与下一次运算. 而当前时刻我们还会把这个h经过输出线性层, 再经过一个激活函数(softmax)得到当前时刻的输出.这个过程如果进行计算图展开就可以写成
在这里插入图片描述
我们的参数一共有三个线性层, 三个权重矩阵和三个偏置. 我们在实践时一般会把W和U对应的偏置合二为一, 也就是这样的RNN架构需要5种参数.
从0时刻开始, 我们的h一开始会被初始化为0. 然后, 我们用两个线性层和一个激活函数计算新的h.
h1=σ(x1U+h0W+b) h_1 = \sigma(x_1U+h_0W+b)
当前时刻的输出就由h1继续运算得到
o1=h1V+c o_1 = h_1V+c
然后, 我们会接受新的x输入, 它和新的h一共继续这样运算下去
ht=σ(xtU+ht1W+b) h_t = \sigma(x_tU+h_{t-1}W+b)
ot=htV+c o_t = h_tV+c
这就是最简单的RNN架构, 如果让它接收完一整个序列信息, 他就可以输出一个和整个序列都有相关性的输出, 然后根据我们想要什么, 就可以设置合适的损失函数, 并用梯度方法训练它.

反向传播(BPTT)

RNN的参数梯度该如何计算呢? 如果你使用Pytorch的计算图模型来计算梯度, 就会发现这其实并不需要任何其他的backward_fn的设计, 因为我们只是用了一些激活函数和线性层, 我们之前的推导已经完全够用. 唯一需要注意的点是, 我们在计算图中进行了权值共享, 把同一个V,W,U使用了好多遍, 这时要计算导数时, 就需要把每个V,W,U的导数都计算一次, 然后把它们加起来.
在这里插入图片描述
这里我们先计算出图中每个部分导, 然后再给出参数的导数到底该怎么计算的公式.
首先t时刻的输出损失LtL_t和t时刻的h是有直接相关性的, 因为ot=htV+co_t = h_tV+c, 我们这里可以直接计算V和c的偏导, 并计算出h关于LtL_t的偏导. 注意这并不是h的全部偏导, 我们还要考虑来自t+x时间的损失Lt+xL_{t+x}的导数.设序列的总长度为K.
LtV=htTLtot \frac{\partial L_t}{\partial V} = h_t^T\frac{\partial L_t}{\partial o_t}
Ltc=SUMROW Ltot \frac{\partial L_t}{\partial c} = SUMROW\ \frac{\partial L_t}{\partial o_t}
Ltht=LtotVT \frac{\partial L_t}{\partial h_t} = \frac{\partial L_t}{\partial o_t}V^T
LV=k=1KhkTLkyk \frac{\partial L}{\partial V} = \sum_{k=1}^K h_k^T\frac{\partial L_k}{\partial y_k}
Lc=k=1KSUMROW Lkyk \frac{\partial L}{\partial c} = \sum_{k=1}^K SUMROW\ \frac{\partial L_k}{\partial y_k}
从上图我们知道, 任意hth_t关于损失的导数要同时考虑LtL_tLkL_k所有这些的损失. 对一个Lk,k>tL_k, k>t, 我们要计算它对hth_t的导数如下
Lkht=Lkhki=tk1hi+1hi \frac{\partial L_k}{\partial h_t} = \frac{\partial L_k}{\partial h_k}\prod_{i=t}^{k-1}\frac{\partial h_{i+1}}{\partial h_{i}}
hi+1hi=σWT \frac{\partial h_{i+1}}{\partial h_{i}} = \sigma'W^T
如果我们使用tanh激活函数, 设vt+1=xt+1U+htW+bv_{t+1} = x_{t+1}U+h_tW+b, ht+1=tanh(vt+1)h_{t+1} = tanh(v_{t+1}), 则能写出hi+1hi\frac{\partial h_{i+1}}{\partial h_{i}}更精确的形式.
hi+1hi=(1hi+12)WT \frac{\partial h_{i+1}}{\partial h_{i}} = (1-h_{i+1}^2)\cdot W^T
这样我们就能给出任意hth_t关于总损失L的导数完整的形式
Lht=k=tKLkht=k=tKLkhki=tk1(1hi+12)WT \frac{\partial L}{\partial h_t} = \sum_{k = t}^K\frac{\partial L_k}{\partial h_t} = \sum_{k = t}^K\frac{\partial L_k}{\partial h_k}\prod_{i=t}^{k-1}(1-h_{i+1}^2)\cdot W^T
然后任务就是根据Lht\frac{\partial L}{\partial h_{t}}计算W,U和b的导数. 虽然W,U,b是权值共享, 我们还是把不同时刻的它们写成Wt,Ut,btW_t,U_t,b_t方便描述
LWt=ht1TσLht=ht1T((1ht2)Lht) \frac{\partial L}{\partial W_t} = h_{t-1}^T\sigma'\frac{\partial L}{\partial h_{t}} = h_{t-1}^T ((1-h_{t}^2)\cdot \frac{\partial L}{\partial h_{t}})
LUt=xtTσLht=xtT((1ht2)Lht) \frac{\partial L}{\partial U_t} = x_{t}^T\sigma'\frac{\partial L}{\partial h_{t}} = x_{t}^T ((1-h_{t}^2)\cdot \frac{\partial L}{\partial h_{t}})
Lbt=SUMROW σLht=SUMROW ((1ht2)Lht) \frac{\partial L}{\partial b_t} = SUMROW\ \sigma'\frac{\partial L}{\partial h_{t}} = SUMROW\ ((1-h_{t}^2)\cdot \frac{\partial L}{\partial h_{t}})
权值共享的参数, 最后更新时要把这些不同时刻t得到的导数加起来, 才是最终的损失函数关于参数的导数
LW=k=1KLWk \frac{\partial L}{\partial W} = \sum_{k=1}^K \frac{\partial L}{\partial W_k}
LU=k=1KLUk \frac{\partial L}{\partial U} = \sum_{k=1}^K \frac{\partial L}{\partial U_k}
Lb=k=1KLbk \frac{\partial L}{\partial b} = \sum_{k=1}^K \frac{\partial L}{\partial b_k}

训练技巧

很显然, RNN无法像CNN一样, 实现超高度的并行化. CNN中, 我们可以在图像与图像间并行计算, 也能在卷积核与卷积核间并行计算. 但是RNN不行, RNN任意时刻的输入都取决于前面时刻的运算, 直到t-1时刻的运算完成, t时刻的运算才能开始. 这就使得RNN的运算很缓慢, 尽管如此, 我们还是希望尽可能并行化计算. 之前使用的mini-batch实际上仍然能在RNN中使用. 虽然一个batch中的数据有长有短, 我们只需要按照batch中最长的那个把所有数据在时间上对齐, 不足的补零. 这样时刻t就能一次输入n个m维的向量x, 即输入是n行m列矩阵{x1t;x2t...,xnt}\{x_{1t};x_{2t}...,x_{nt}\}.
另外RNN的训练有一些很不好的性质, 我们看Lht\frac{\partial L}{\partial h_{t}}的公式, 它是把很多的ht+1ht=11vi+12WT\frac{\partial h_{t+1}}{\partial h_{t}}=\frac{1}{1-v_{i+1}^2}\cdot W^T做连乘, 而因为WW权值共享, 每次的ht+1ht\frac{\partial h_{t+1}}{\partial h_{t}}相差并不会很大. 设想, 如果所有ht+1ht1.01\frac{\partial h_{t+1}}{\partial h_{t}}\simeq 1.01, 序列长度为1000, 对Lh1\frac{\partial L}{\partial h_{1}}计算导数将会有1.01^{100} = 20959那么大, 这就是RNN的短板, 梯度爆炸. 在优化的目标函数中会非常常见这样的"悬崖", 如果我们用这个梯度更新参数, 将会让参数直接废掉. 为此我们会用一些简单的处理方式来缓解这种影响, 比如每隔T个时间单位就清零前面传来的梯度, 或是更新参数时设计clip截断梯度.

RNN实现

import torch
import math
import numpy as np
import matplotlib.pyplot as plt
import torch.nn.functional as F

class Tanh:
    def __init__(self):
        self.out = None
        
    def forward(self, x):
        self.out = torch.tanh(x)
        return self.out
    
    def backward(self, dz):
        return dz*(1-self.out**2)
    
    def __call__(self, X):
        return self.forward(X)
    
    
class RNN:
    def __init__(self, input_sz, hidden_sz, output_sz,
                LEARNING_RATE=0.01):
        '''
        单隐层RNN, 接收输入向量x, 长度为input_sz
        隐层输出向量h, 长度hidden_sz, 输出层输出向量o, 长度output_sz
        参数有V,c,W,U,b
        '''
        self.hidden_sz = hidden_sz
        self.lr = LEARNING_RATE
        
        self.W = torch.randn(hidden_sz,hidden_sz)*math.sqrt(1/hidden_sz)
        # 从ht-1到ht的连接权
        self.U = torch.randn(input_sz,hidden_sz)*math.sqrt(2/(input_sz+hidden_sz)) 
        # 从X到ht的连接权
        self.b  = torch.randn(hidden_sz)*math.sqrt(2/hidden_sz)       
        # 在隐层激活前增加的偏置
        self.V = torch.randn(hidden_sz,output_sz)*math.sqrt(2/(output_sz+hidden_sz)) 
        # 从h到output的连接权
        self.c = torch.randn(output_sz)*math.sqrt(2/output_sz)     
        # 在输出前增加的偏置
        
        self.dW,self.dU,self.db,self.dV,self.dc = torch.zeros_like(self.W),\
        torch.zeros_like(self.U),torch.zeros_like(self.b),torch.zeros_like(self.V),\
        torch.zeros_like(self.c)
        
        # 输入
        self.h = None
        # hidden 的值
        
        self.input_x_list = []
        self.h_list = []
        self.dtanh_list = []
        
        self.tanh = Tanh()
        
    def forward(self, x):
        '''
        输入x, size(n,m), 表示n条m维的输入
        输出o, 结合h和x运算得到的输出值
        '''
        
        # forward计算
        n,m = x.shape
        if type(self.h)==type(None):
            self.h = torch.zeros(n,self.hidden_sz)
        y = x.mm(self.U)+self.h.mm(self.W)+self.b
        self.input_x_list.append(x)
        # 记录一下旧的h的值
        self.h_list.append(self.h.clone())
        self.h = self.tanh(y)
        o = self.h.mm(self.V)+self.c
        
        # 计算部分梯度
        dtan = self.tanh.backward(1)
        self.dtanh_list.append(dtan)
        
        return o
    
    def backward(self, dout):
        # 计算dL/dV
        self.dV += self.h.T.mm(dout)
        # 计算dL/dc
        self.dc += torch.sum(dout, axis = 0)
        # 计算dL/dh_i
        dh = dout.mm(self.V.T)
        for i in range(len(self.dtanh_list)-1,-1,-1):
            dv = self.dtanh_list[i]*dh
            self.dW += self.h_list[i].T.mm(dv)
            self.dU += self.input_x_list[i].T.mm(dv)
            self.db += torch.sum(dv, axis = 0)
            dh = dv.mm(self.W.T)
            
            
    
    def clear(self):
        # 清空所有记忆体
        self.dW,self.dU,self.db,self.dV,self.dc = torch.zeros_like(self.W),\
        torch.zeros_like(self.U),torch.zeros_like(self.b),\
        torch.zeros_like(self.V),torch.zeros_like(self.c)
        
        self.input_x_list.clear()
        self.h_list.clear()
        self.dtanh_list.clear()
        
        self.h = None
    
    def clip(self):
        # 裁剪dW等参数, 如果绝对值过大就裁剪掉
        self.dW = torch.tensor(np.clip(self.dW.numpy(),-5,5))
        self.dU = torch.tensor(np.clip(self.dU.numpy(),-5,5))
        self.db = torch.tensor(np.clip(self.db.numpy(),-5,5))
        self.dV = torch.tensor(np.clip(self.dV.numpy(),-5,5))
        self.dc = torch.tensor(np.clip(self.dc.numpy(),-5,5))
        
    
    
    def update(self):
        self.clip()
        
        self.W -= self.lr*self.dW
        self.U -= self.lr*self.dU
        self.b -= self.lr*self.db
        self.V -= self.lr*self.dV
        self.c -= self.lr*self.dc
        
        self.clear()
        
    def __call__(self, X):
        return self.forward(X)

梯度验证

和我们实现卷积神经网络时一样, 使用Pytorch和我们自己写的RNN做同样的事情, 并比较两者反向传播计算得到的梯度.

L = 5
n = 2

W = torch.randn(2,2)
U = torch.randn(2,2)
b = torch.randn(2)
V = torch.randn(2,1)
c = torch.randn(1)
h = torch.zeros(2,2)

W.requires_grad = True
U.requires_grad = True
b.requires_grad = True
V.requires_grad = True
c.requires_grad = True
h.requires_grad = True

input_X = torch.rand(L,2,2)
target = torch.rand(L,2,1)
loss = 0.


for i in range(L):
    v = input_X[i].mm(U)+h.mm(W)+b
    h = torch.tanh(v)
    o = h.mm(V)+c
    loss += F.mse_loss(o,target[i])/L
loss.backward()
print(W.grad)
print(U.grad)
print(b.grad)
print(V.grad)
print(c.grad)


my_rnn = RNN(2,2,1,0)
my_rnn.W = W.detach()
my_rnn.U = U.detach()
my_rnn.b = b.detach()
my_rnn.V = V.detach()
my_rnn.c = c.detach()

for i in range(L):
    o = my_rnn(input_X[i])
    my_rnn.backward(2*(o-target[i])/(L*n))

print(my_rnn.dW)
print(my_rnn.dU)
print(my_rnn.db)
print(my_rnn.dV)
print(my_rnn.dc)

实践:时序相关序列预测

使用RNN处理时序数据有着非常好的优势, 这里我们用RNN预测正弦波信号. 我们在每个2π\pi周期中采样8个点, 用多个这样的周期数据让RNN学习, 这样RNN就会知道不论何时遇到序列信号, 都应该输出正弦波.

model = RNN(1,20,1,LEARNING_RATE = 0.003)

X = torch.linspace(0,40*math.pi,160)
y = torch.sin(X)
X = X.reshape(20,-1,1)
X = X.permute(1,0,2)
y = y.reshape(20,-1,1)
y = y.permute(1,0,2)

L,n,m = X.shape

for epoch in range(10000):
    loss = 0.
    for i in range(L):
        out = model(X[i])
        dout = (out-y[i])  # mse loss
        loss += (dout**2).sum()/n
        model.backward(dout/L)
    loss /= L
    model.update()
    if (epoch+1)%500==0:
        print("epoch %d, loss %.4f"%(epoch+1,loss))

model.clear()

X_test = torch.linspace(40*math.pi,50*math.pi,40)
X_test = X_test.reshape(-1,1,1)

out_list = []
for i in range(len(X_test)):
    out = model(X_test[i])
    out_list.append(out.flatten().item())
    if (i+1)%8==0:
        model.clear()
    
xx = np.linspace(40*math.pi,50*math.pi,40)
plt.plot(xx,out_list)
plt.plot(xx,np.sin(xx))

我们用RNN预测后几个周期的信号, 可以看见
在这里插入图片描述
RNN在3个周期以内都可以很好地fit真实正弦波, 在后面的周期逐渐发生偏移. 因为与我们输入x相乘的矩阵U并没有完全被学习成0矩阵, 这应该是偏移的来源. 但是我们已经能看出RNN的时序预测能力.

双向RNN

在这里插入图片描述
上面的RNN保证了, t时刻的输出和1~t时刻的所有输入都有关. 但是在做一些更复杂的问题时, 我们会希望RNN在任意时刻的输出和整个序列都相关. 比如我们会希望预测词性, 那么一个词的词性不但要看上文, 还要看下文. 这时我们就会用这样的双向RNN架构. 它实际上是把两个上面的单向RNN拼起来, 但是输出会有一些变换, 我们的输出计算同时考虑两个RNN的当前隐层.
o=h1tV1+h2tV2+c o = h_{1t}V_1+h_{2t}V_2+c
反向传播和上面大同小异, 几乎没有区别, 但是这样的架构就可以帮我们看一整个序列.

LSTM

在这里插入图片描述
上图是一个LSTM长短期记忆的结构体,它的引入是为了解决simple RNN的梯度问题, rnn的最大缺点是梯度不平滑,因为存储单元在每一时刻,都会被另一个新的值完全覆盖(赋值操作),虽然新的h和过去的h也有相关性,但随着时间的推移,h将产生非常大的变化造成进行BPTT传播时,很容易出现梯度消失和梯度爆炸。
LSTM的h单元(图中的c单元)也会变化,但是每次变化要经过forget gate,这是一个相对更为平滑的过程,而且实践中forget的值会被设计的“不经常忘记”,也就使得记忆很容易长期保存下来,而新的记忆是以相加的形式得到的,这种形式的rnn更容易训练。
在这里插入图片描述
简单介绍下lstm的计算过程。每次前向传播,我们把上一次的输出h和本次输入x拼起来,得到新的输入向量x。然后x通过线性组合,成为每一个gate的开关信号. 第一个gate是input gate,第二个gate是forget gate,第三个gate是output gate. 输入向量被线性变化并加在几个门上,首先得到输入z=sigmoid(Winx)Wxz = sigmoid(W_{in}x)* Wx 然后遗忘门和输入1共同决定现在的c存储c=z+sigmoid(Wfgx)cc = z+sigmoid(W_{fg}x) * c 再然后,输出门限制真正的输出。o=sigmoid(Woutx)tanh(c)o = sigmoid(W_{out}x) * tanh(c)再到输出层即可,输出层的一个线性变换加softmaxy=softmax(Uo)y = softmax(Uo)
看起来运算好像很复杂, 但是其实是可以做的, 而且不算困难. 使用LSTM就能让RNN更有效的在大部分任务上训练, 缺点只是多了3倍的参数.

常用架构

因为RNN允许多输入多输出, 它有着极为多样化的架构. 在这里插入图片描述
我们可以只用一个输入, 后面的输入都为0, 也就是RNN输出只取决于第一个输入和隐层(one to many), 这样的架构常用在语言模型. 我们可以一直接受输入, 只在最后输出一次,(many to one), 这种架构常用在序列分类模型. 还有更多样的many to many, 那就能做更多的事情了.

小结

这次我们实现了RNN, 到此, 最基础的深度模型框架就算开发完毕了. 我们已经学习了DNN全连接架构, 它能处理很多传统模式识别问题, 而且在数据量较大时表现超过大多数传统机器学习算法. 我们学习了CNN卷积神经网络, CNN在图像数据之类的局部特征数据表现比DNN更好. 我们还学习了RNN, RNN广泛应用于语言处理, 并且能帮助我们处理很长的序列数据.
后面我计划至少要再讲一下注意力机制的框架开发, 还有一些比较重要的深度学习架构. 比如生成模型GAN, 基于RNN的Seq2Seq, 目标检测RCNN等等. 来日方长, 喜欢的朋友点个赞吧.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章