主题
字号
CHAPTER 01 ≈ 25 MIN READ

RNN 基础概念与数学原理

日常生活中的序列数据

序列数据在我们的生活中无处不在:

文本数据是最常见的序列。一句话中每个单词都不是独立的,"我今天去图书馆看书",顺序决定语义。把顺序打乱变成"图书馆看我去书",所有词都在,但完全无法理解——顺序是序列数据的本质特征

时间序列数据同样无处不在。股票价格、气温变化、心电图信号,都是随时间变化的序列。某个时间点的值与之前的值有关联。

音频数据是时间轴上连续变化的信号。语音识别需要理解音频片段之间的时序关系。

视频数据是帧的序列,每一帧与前后帧紧密相关,动作识别依赖这种时序信息。

序列任务的5种类型

常规任务可以分成以下几种:

一对一 (one-to-one):普通分类
输入:[x] → 输出:[y]
例子:图片分类(不是序列任务,CNN或MLP做)

一对多 (one-to-many):从一个输入生成序列
输入:[x] → 输出:[y1, y2, y3, ...]
例子:图片描述(给一张图片,生成一段话)

多对一 (many-to-one):从序列得到一个输出
输入:[x1, x2, x3] → 输出:[y]
例子:情感分析(一段话 → 正面/负面)

多对多(同步):每个时刻都有输出
输入:[x1, x2, x3] → 输出:[y1, y2, y3]
例子:词性标注(每个词 → 名词/动词/形容词...)

多对多(异步):输入完毕后才开始输出
输入:[x1, x2, ..., xn] → 输出:[y1, y2, ..., ym]
例子:机器翻译(读完英文句子,输出中文)

后4种类型对应了RNN的大部分实际应用场景。

序列数据 vs 图像数据

特征 图像数据 序列数据
数据结构 网格(像素) 链式(时间步)
关键假设 空间局部性 时序依赖性
关键挑战 平移不变性 变长、长程依赖
主要模型 CNN RNN/Transformer
顺序重要吗 否(可以小幅度旋转图片) 是(不能打乱句子)

传统方法的局限

在RNN出现之前,处理序列数据主要靠手工提取特征(词频、TF-IDF、n-gram),然后输入传统机器学习模型。核心问题有两个:

首先,特征工程耗时耗力,不同任务需要完全不同的特征设计。

其次,无法捕捉长距离依赖。"虽然他昨天很晚才睡,但是今天他还是很早起床"——要预测"起床",需要记住很久之前的"很晚才睡"。n-gram只能捕捉局部信息,无法处理这类长距离关联。

RNN的核心思想

RNN的核心思想是引入循环机制——网络有一个"记忆"(隐藏状态),每处理一个新输入,就将当前输入与之前的记忆结合,生成新的记忆和输出。


为什么需要RNN

要理解RNN的必要性,我们先看看用全连接网络(FCN)处理序列会遇到什么问题。

问题1:输入长度固定。FCN要求固定大小的输入。但句子长度不固定——有的3个词,有的50个词。如果用最大长度padding,短句子会有大量无效信息。

问题2:无法利用顺序信息。FCN把所有输入一起处理,"我爱你"和"你爱我"会产生完全不同的语义,但如果我们只是把词向量加起来,这两者的结果是一样的。

问题3:参数数量随长度爆炸。如果序列长100,每个词100维,输入就是10000维,第一层全连接的参数量极大。

RNN通过共享参数解决了这些问题。

🔍 深层思考:参数共享的深意

RNN在所有时间步使用同样的权重矩阵(W_ih, W_hh)。这不只是节省参数的技巧,背后有深刻的含义:

它是一种归纳偏置(Inductive Bias)。参数共享隐含地假设"处理第t个词的规则与处理第t+5个词的规则相同"。这就像我们阅读时,理解"爱"这个字的方法,不会因为它是句子的第2个字还是第8个字而不同。

它使RNN能泛化到任意长度序列。训练时序列长度可以是50,推理时可以是100,因为网络的"规则"是与位置无关的。

类比:这就像用同一个for循环处理不同长度的数组,而不是把每次迭代写成不同的代码块。RNN就是序列处理的"for循环"。

RNN的工作原理与结构

基本结构

                     输出 y_1    输出 y_2    输出 y_3
                       ↑           ↑           ↑
隐藏状态: h_0 ——→ [ RNN ] ——→ [ RNN ] ——→ [ RNN ] ——→ h_3
                       ↑           ↑           ↑
                    输入 x_1    输入 x_2    输入 x_3

每个 [ RNN ] 单元内部做同样的事情:
输入: x_t(当前词)+ h_{t-1}(之前的记忆)
输出: h_t(新记忆)+ y_t(当前时刻的输出,可选)

注意:所有时间步的 [ RNN ] 用的是完全相同的权重,只是输入不同。

循环与展开

RNN通常画成一个有自环的单元,这个"自环"就是隐藏状态传递给自己。当我们把它在时间维度上"展开"时,就变成了上面那个线性结构,看起来像一个很深的前馈网络——只不过每层用相同的权重。

前向传播数学推导

符号定义

符号 含义 形状
x_t 第t步输入向量 (input_size,)
h_t 第t步隐藏状态 (hidden_size,)
y_t 第t步输出 (output_size,)
W_ih 输入→隐藏权重 (hidden_size, input_size)
W_hh 隐藏→隐藏权重 (hidden_size, hidden_size)
W_hy 隐藏→输出权重 (output_size, hidden_size)
b_h 隐藏层偏置 (hidden_size,)

前向传播公式

h_t = tanh(W_ih @ x_t + W_hh @ h_{t-1} + b_h)   ← 隐藏状态更新
y_t = W_hy @ h_t + b_y                             ← 输出计算(可选)

其中 @ 表示矩阵乘法,tanh 是双曲正切激活函数。

维度验证(以批量处理为例)

设 batch_size=B, hidden_size=H, input_size=D:

X_t @ W_ih.T:  (B, D)  @  (D, H)  = (B, H)  ✓
h_{t-1} @ W_hh.T: (B, H) @ (H, H) = (B, H)  ✓
相加后 tanh: (B, H) → (B, H)         ✓

手动实现形状的验证

import torch
import torch.nn as nn

# 参数设置
input_size = 10
hidden_size = 20
batch_size = 3

# 手动创建权重(模拟nn.RNN的内部实现)
W_ih = torch.randn(hidden_size, input_size)
W_hh = torch.randn(hidden_size, hidden_size)
b_h = torch.zeros(hidden_size)

# 单个时间步的手动前向传播
x_t = torch.randn(batch_size, input_size)
h_prev = torch.zeros(batch_size, hidden_size)

# 公式:h_t = tanh(x_t @ W_ih.T + h_{t-1} @ W_hh.T + b_h)
h_next = torch.tanh(x_t @ W_ih.T + h_prev @ W_hh.T + b_h)
print("手动计算的隐藏状态形状:", h_next.shape)  # (3, 20)

反向传播(BPTT)数学推导

RNN的反向传播称为"通过时间反向传播"(Backpropagation Through Time, BPTT)。

核心思想:将RNN展开成一个普通的深度网络,然后从最后一个时间步开始反向传播梯度。

步骤分解

  1. 展开:把T步的RNN展开成一个T层的前馈网络
  2. 计算每步损失:L = Σ_{t=1}^{T} L_t (这步比较关键)
  3. 从t=T开始,向t=1反向传播
  4. 累加所有时间步对参数的梯度

关键梯度推导

设 δ_t = ∂L/∂a_t(t时刻隐藏状态的梯度)

对于最后一步T:

δ_T = (∂L_T/∂y_T) @ W_hy.T ⊙ tanh'(a_T)

其中 a_T = W_ih @ x_T + W_hh @ h_{T-1} + b_h,tanh'(x) = 1 - tanh²(x)

对于中间步骤 t(从T-1到1往回算):

δ_t = (δ_{t+1} @ W_hh.T + ∂L_t/∂y_t @ W_hy.T) ⊙ tanh'(a_t)
       ↑                    ↑
    来自下一时刻的梯度    来自当前时刻输出损失的梯度

参数梯度(对权重求梯度,需要对所有时间步求和):

∂L/∂W_hh = Σ_{t=1}^{T} δ_t @ h_{t-1}.T   ← 关键:所有时间步梯度累加
∂L/∂W_ih = Σ_{t=1}^{T} δ_t @ x_t.T
∂L/∂b_h  = Σ_{t=1}^{T} δ_t

💡 直觉理解BPTT

想象你在看一篇侦探小说,发现结尾写错了。现在你要找出是哪里的伏笔埋错了(从结尾往前找)。BPTT就是这个过程:从最终的"写错了"(损失函数)出发,一步步往前追溯"责任"(梯度),找到每个时间步、每个权重"应该负多少责任"(参数梯度)。

关键点:由于RNN所有时间步共享权重,每个权重的最终梯度是它在所有时间步贡献的梯度之

梯度消失与梯度爆炸——RNN的阿喀琉斯之踵

这是RNN面临的核心问题,也是LSTM和GRU被发明的直接原因。

为什么会发生

梯度从时间步T传播到时间步k,需要经过T-k次矩阵乘法:

δ_k ≈ δ_T × (W_hh)^(T-k)

设 W_hh 的最大特征值(谱范数)为 λ_max:

import numpy as np

# 演示梯度消失
W = np.random.randn(5, 5) * 0.5  # 谱范数 < 1
grad = np.ones(5)

print("梯度随时间步传播的变化(梯度消失示例):")
for step in range(10):
    grad = W.T @ grad
    print(f"  步骤 {step+1}: 梯度范数 = {np.linalg.norm(grad):.6f}")
# 你会看到梯度迅速趋近于0

print("\n梯度随时间步传播的变化(梯度爆炸示例):")
W2 = np.random.randn(5, 5) * 2  # 谱范数 > 1
grad2 = np.ones(5)
for step in range(10):
    grad2 = W2.T @ grad2
    print(f"  步骤 {step+1}: 梯度范数 = {np.linalg.norm(grad2):.2f}")
# 你会看到梯度迅速爆炸

梯度消失的实际影响

网络"记不住"很久之前的信息。在"虽然这部电影剧情不太好,但演员表演非常出色,导演的镜头语言也很精彩,所以我还是喜欢这部电影"这句话中,"喜欢"要依赖很久之前的"演员表演非常出色",梯度消失的RNN很难学到这种关联。

梯度爆炸的实际影响

权重更新幅度极大,Loss突然变成NaN,训练完全失控。

当时是采用了crossEntropy代替sigmoid。

深层思考:为什么用tanh而不是ReLU?

这是个很好的问题,很多初学者会问。

tanh的优势

  1. 有界:tanh输出范围在(-1, 1)。如果隐藏状态无界增长,经过多次时间步后数值会爆炸。tanh天然防止这种"激活值爆炸"。

  2. 零中心化:tanh(0)=0,输出均值接近0。相比之下sigmoid输出均值约0.5,会引起梯度更新的"锯齿效应"。

  3. 梯度合适:tanh'(0)=1,在0附近梯度较大,有利于训练。

ReLU在RNN中为什么不好用

# 设想用ReLU的RNN
# h_t = ReLU(W_hh @ h_{t-1} + W_ih @ x_t)
# 若W_hh的某个特征值 > 1,则激活值会随时间步指数爆炸
# tanh的饱和特性虽然会导致梯度消失,但至少激活值不会爆炸

但也有ReLU RNN的研究

2015年,Le等人提出了IRNN(Identity RNN),将W_hh初始化为单位矩阵、使用ReLU激活,在某些任务上效果不错。核心思想是:单位矩阵初始化使W_hh的特征值都为1,梯度既不消失也不爆炸。

🔍 深层思考:激活函数选择的本质权衡

在RNN中,我们面临一个两难困境:

  • 非线性太弱(如恒等激活):RNN退化成线性系统,表达能力有限
  • 非线性太强(如激活函数饱和区):梯度消失,难以学习长距离依赖

tanh是个折中:在0附近近似线性(利于梯度传播),远离0时饱和(防止爆炸)。