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展开成一个普通的深度网络,然后从最后一个时间步开始反向传播梯度。
步骤分解:
- 展开:把T步的RNN展开成一个T层的前馈网络
- 计算每步损失:L = Σ_{t=1}^{T} L_t (这步比较关键)
- 从t=T开始,向t=1反向传播
- 累加所有时间步对参数的梯度
关键梯度推导:
设 δ_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:
- 若 λ_max < 1:梯度随距离指数衰减 → 梯度消失
- 若 λ_max > 1:梯度随距离指数爆炸 → 梯度爆炸
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的优势:
有界:tanh输出范围在(-1, 1)。如果隐藏状态无界增长,经过多次时间步后数值会爆炸。tanh天然防止这种"激活值爆炸"。
零中心化:tanh(0)=0,输出均值接近0。相比之下sigmoid输出均值约0.5,会引起梯度更新的"锯齿效应"。
梯度合适: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时饱和(防止爆炸)。