深度学习笔记 | 漫游RNN(循环神经网络)

发布于:2025-04-05 ⋅ 阅读:(43) ⋅ 点赞:(0)

前言

本文将从三个方面展开介绍RNN(循环神经网络)。首先,会给出RNN的定义,初步了解什么是RNN。然后引入几个例子,分析一下RNN的设计逻辑。最后从0开始实现RNN,以加深理解。
博主也是初学者,这篇博文也是学习总结,如果有错误,还请大佬指正!

授人以鱼不如授人以渔,以下资料中均有对RNN的更详细介绍:

什么是RNN

书上文字定义为:

基于循环计算的隐状态神经网络

直观对比普通神经网络与循环神经网络,唯一的区别是循环神经网络多了循环的结构,也正是该结构使循环神经网络有了“记忆”。

普通神经网络与RNN
隐状态是指循环神经网络中保留了序列直到当前时间步的历史信息的隐藏变量。如图为循环神经网络展开之后的样子,我们可以直观地看到隐藏变量h的传递过程。
展开的RNN

结合数学计算式 h t = f W ( h t − 1 , x t ) h_t = f_W(h_{t-1}, x_t) ht=fW(ht1,xt) ,我们可以发现, t t t时刻的隐藏状态总由当前时刻的输入 x t x_t xt与上一时刻的隐状态 h t − 1 h_{t-1} ht1计算得到。因此可以说明隐状态 h t h_t ht记录了所有 x i ( i = 0 , 1 , … , t ) x_i(i = 0, 1, \ldots, t) xi(i=0,1,,t)时刻输入的信息。

主要应用场景

RNN的记忆性,以及对输入、输出的灵活性,使其擅长于应对序列问题:

  • 自然语言处理(NLP):如文本生成、机器翻译、情感分析等。
  • 时间序列预测:如股票价格预测、天气预报等。
  • 语音识别:处理语音信号中的序列信息

为什么RNN这样设计

传统的前馈神经网络(比如多层感知器)理论上也可以用于序列预测,但它们在处理时序数据时存在明显不足,所以才诞生了RNN。RNN需要解决的主要问题有两点,第一,神经网络需要具有记忆性,以捕获数据的上下文关系及处理序列的特定顺序。第二,能够变长的处理输入、输出,以更好的胜任处理序列问题,同时避免处理序列问题时需要巨大的计算资源。

记忆性

序列数据(如文本、语音或时间序列)中,前后数据之间往往存在重要的依赖关系,以语言预测为例。

有这么一个句子:我要去… ,请你预测后面的是什么?

在此状态下做预测,对人来说是一件很困难的事,对于神经网络来说更是一个几乎不可能的任务。因为缺少“语境”,缺少限制,所以答案有很多种可能。

如果将句子扩长:12点了,我要去…

增加时间状语后,你可能会有一些猜测,后面的词可能是:睡觉?吃饭?

再扩长一些:有些饿,12点了,我要去…

这个时候,你几乎可以确定答案内容一定与“吃饭”相关。所以要让RNN也能完成同样的任务,就必须具有记忆性。隐状态使RNN通过将前一时刻的隐藏状态传递给下一时刻,从而做到捕获时间上的动态关系。

输入、输出的变长能力

在上文的例子中,我们将句子不断扩长,当满足一些条件时,我们也才能猜测后续的内容。前馈网络需要固定长度的输入,而很多序列任务涉及可变长度的数据,所以它们天生就不适合应用于处理序列问题。由于RNN的特殊结构,使网络的灵活性大大提升,它不像之前的神经网络那样,输入、输出的数量是固定的,RNN可以根据需要灵活地应对不同的输入模式,定制所需要的输出模式。换句话说,RNN可以是单输入多输出、多输入单输出,也可以是多输入多输出。

不同结构的RNN

如何实现一个RNN

训练一个神经网络的步骤可以概括为三步:

  1. 载入数据 :包括数据的预处理、归一化、划分训练集与验证集等。
  2. 前向传播计算 :将输入数据通过网络计算得到输出,并计算损失值。
  3. 反向传播更新参数 :根据损失值通过反向传播算法计算梯度,并用优化器(如SGD或Adam)更新网络参数。
    我们将遵循上述三个步骤,分析需求并实现RNN_DIY类:使用一个采样自正弦波的数据作为数据集训练网络,并测试其预测能力。

数据处理

在序列问题中,我们也会遇到一个问题,序列过长时怎么办?比如要让神经网络处理一篇数万字的小说,一次性全部处理必然是不可能的。实践中,往往会拆分序列,让任意长的序列拆分为具有相同时间步数的子序列,然后再输入其中。拆分序列有两种策略:随机抽样(random sampling)和顺序分区(sequential partitioning)。

  • 随机抽样:每个样本都是在原始序列中随机取出的子序列。
  • 顺序抽样:类似滑动窗口,相邻样本中的子序列在原始序列中一定是相邻的。

前向传播计算

手动定义输入到隐藏层权重( W x h W_{xh} Wxh)、隐藏层到隐藏层权重( W h h W_{hh} Whh)、隐藏层偏置( b h b_h bh)以及隐藏层到输出层权重( W h y W_{hy} Why)、输出偏置( b y b_y by)。
隐状态由下式计算得到:
h t = f W ( W x h   x t + W h h   h t − 1 + b h ) h_t = f_W\bigl(W_{xh} \, x_t + W_{hh} \, h_{t-1} + b_h\bigr) ht=fW(Wxhxt+Whhht1+bh)
预测值由下式计算得到:
y ^ = g ( W h y h t + b y ) \hat{y} = g\bigl(W_{hy} h_t + b_y) y^=g(Whyht+by)
其中 g ( ⋅ ) g(\cdot) g() 为可选的激活函数。
如果为语言模型,一般会采用交叉熵损失函数,但是这里是对数值进行简单预测,所以用均方差损失函数。

反向传播

梯度爆炸/梯度消失

循环神经网络在具体实践中很容易碰到梯度消失或梯度爆炸的问题。我们现在忽略输入层到隐藏层的维度转换,只考虑循环计算的过程,分析一下是如何造成梯度消失或梯度爆炸的问题的。在循环计算的过程中,权重是共享的,因此我们设权重为 W h W_h Wh.
梯度消失

循环n次后,权重就会变为其n次方。
W h W_h Wh大于1,那么必然会导致最终的输出值很大,计算梯度时,也必然会有极大的数值,导致梯度爆炸;若 W h W_h Wh小于1,那么可能导致在几次循环后,权重变为0,导致梯度消失问题。
所以需要采取梯度截断策略,将梯度 g \textbf{g} g投影回给定半径的球来截断梯度 g \textbf{g} g,进而保证梯度的范数不超过球的半径,并且保证阶段后的梯度与原梯度的方向保持一致。
g ← min ⁡ ( 1 , θ ∥ g ∥ ) g \mathbf{g} \leftarrow \min\left(1, \frac{\theta}{\|\mathbf{g}\|}\right) \mathbf{g} gmin(1,gθ)g
虽然听着复杂,但是在具体实践中,一般使用Pytorch提供的函数就能轻松实现。

沿时间的反向传播

隐状态的计算是一个递推关系式,这意味着在梯度计算的时候也需要不断的展开,一直求导到刚开始的 h 0 h_0 h0才可以,这将导致极大的计算量,整个计算过程也会很漫长,所以实践中几乎不进行完整的计算。

  • 截断时间步:向前计算 τ \tau τ步即停止计算,这样便能得到近似梯度。也是常用的阶段方法,被称为沿时间反向传播
  • 随机截断:每次计算的展开链长均不同,即覆盖了不同长度序列的梯度的链式求导。

大佬的字符级语言模型

我们在实现自己的RNN前,可以参考大佬的代码,学习大佬是如何处理上述问题的。
Minimal character-level language model with a Vanilla Recurrent Neural Network, in Python/numpy

# backward pass: compute gradients going backwards
  dWxh, dWhh, dWhy = np.zeros_like(Wxh), np.zeros_like(Whh), np.zeros_like(Why)
  dbh, dby = np.zeros_like(bh), np.zeros_like(by)
  dhnext = np.zeros_like(hs[0])
  for t in reversed(xrange(len(inputs))):
    dy = np.copy(ps[t])
    dy[targets[t]] -= 1 
    dWhy += np.dot(dy, hs[t].T)
    dby += dy
    dh = np.dot(Why.T, dy) + dhnext # backprop into h
    dhraw = (1 - hs[t] * hs[t]) * dh # backprop through tanh nonlinearity
    dbh += dhraw
    dWxh += np.dot(dhraw, xs[t].T)
    dWhh += np.dot(dhraw, hs[t-1].T)
    dhnext = np.dot(Whh.T, dhraw)
  for dparam in [dWxh, dWhh, dWhy, dbh, dby]:
    np.clip(dparam, -5, 5, out=dparam) # clip to mitigate exploding gradients
  return loss, dWxh, dWhh, dWhy, dbh, dby, hs[len(inputs)-1]

不同参数的求导均根据下式进行计算:
∂ L ∂ h t = ∑ i = t T ( W h h ⊤ ) T − i W q h ⊤ ∂ L ∂ o T + t − i , ∂ 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{split}\begin{aligned} \frac{\partial L}{\partial \mathbf{h}_t} &= \sum_{i=t}^T {\left(\mathbf{W}_{hh}^\top\right)}^{T-i} \mathbf{W}_{qh}^\top \frac{\partial L}{\partial \mathbf{o}_{T+t-i}},\\ \frac{\partial L}{\partial \mathbf{W}_{hx}} &= \sum_{t=1}^T \text{prod}\left(\frac{\partial L}{\partial \mathbf{h}_t}, \frac{\partial \mathbf{h}_t}{\partial \mathbf{W}_{hx}}\right) = \sum_{t=1}^T \frac{\partial L}{\partial \mathbf{h}_t} \mathbf{x}_t^\top,\\ \frac{\partial L}{\partial \mathbf{W}_{hh}} &= \sum_{t=1}^T \text{prod}\left(\frac{\partial L}{\partial \mathbf{h}_t}, \frac{\partial \mathbf{h}_t}{\partial \mathbf{W}_{hh}}\right) = \sum_{t=1}^T \frac{\partial L}{\partial \mathbf{h}_t} \mathbf{h}_{t-1}^\top \end{aligned}\end{split} htLWhxLWhhL=i=tT(Whh)TiWqhoT+tiL,=t=1Tprod(htL,Whxht)=t=1ThtLxt,=t=1Tprod(htL,Whhht)=t=1ThtLht1

具体my_RNN类的实现代码与逻辑分析,请见我的另一篇博客从0开始,搭建你自己的循环神经网络 | MAKE RNN BY YOURSELF
感谢你的阅读!!