Pytorch 实现循环神经网络
最近在看李沐老师的《动手学深度学习》,这是一本很好的深度学习入门教材,对于基础的神经网络知识的讲解相当到位。尤其是其中介绍循环神经网络的地方,老师的讲解和代码都很详细了。但是作为学生,在学习的过程中还是难免有不懂的地方,所以我想写这一篇博客来对这一章的内容进行一次小小的总结。

循环神经网络原理
循环神经网络是一种处理时序数据的方法,在 8.1 节中提到过两种方式:一是将前面一段时刻的数据全部作为输入,送给模型得到输出;二是定义一个隐状态,该隐状态汇集了前一段时间的信息,然后结合第 t 时刻的数据作为输入得到输出。前者为自回归模型(AR),后者为隐变量自回归模型(LVAR),显然循环神经网络属于后者。
然而似乎 RNN 并不严格属于隐变量自回归模型()因为 RNN 的隐变量是确定的,一般的隐变量自回归模型都是需要从一个特定的分布中进行采样、积分或变分近似获得隐状态的。
- AR vs LVAR
比较维度 | AR(AutoRegressive)模型 | LVAR(Latent Variable AutoRegressive)模型 |
---|---|---|
模型类型 | 显变量模型(观测变量自回归) | 隐变量模型(观测变量由隐变量驱动) |
建模对象 | 直接建模观测变量的时间依赖关系 | 建模潜在(隐含)因子的时间依赖及其对观测变量的生成 |
结构表达 | \(x_t = \sum_{i=1}^p \phi_i x_{t-i} + \epsilon_t\) | \(z_t = \sum_{i=1}^p A_i z_{t-i} + \eta_t\),\(x_t = C z_t + \epsilon_t\) |
是否引入隐变量 | ❌ 否 | ✅ 是(引入不可观测的潜变量 $z_t$) |
可建模关系 | 只能建模观测变量自身的动态 | 可以建模观测变量间的共变结构与驱动因子动态 |
参数数量 | 少,主要为 AR 系数 | 较多,包含潜变量转移矩阵、投影矩阵等 |
表达能力 | 较弱,适合单变量时间序列建模 | 强大,适合高维时间序列、存在共因子的数据建模 |
估计方法 | OLS、Yule-Walker | EM 算法、变分推断、贝叶斯方法等 |
计算复杂度 | 低 | 高,需对隐变量进行估计或推断 |
应用场景 | 单变量或少量观测变量的序列预测 | 经济学、金融等高维数据建模(如因子模型、脑信号建模等) |
可解释性 | 强,直接解释观测变量之间的因果关系 | 较弱,依赖隐变量建模假设 |
关于 RNN 详细的原理就不多说了,还是比较简单的,可以直接参见李沐的教程
代码
写代码的时候,踩了不少的坑,在这里就做个总结吧
由于 RNN 具有状态(state),这造成了其结构和一般的神经网络还是有挺大区别的。
torch.nn.RNN
是 PyTorch 提供的一个用于循环神经网络(Recurrent Neural Network, RNN)的模块。它实现了最基本的 RNN 单元(非 LSTM 或 GRU),用于处理序列数据。
下面我们详细说明 torch.nn.RNN
的前向传播(forward propagation)过程。
一、输入与输出格式
rnn = nn.RNN(input_size, hidden_size, num_layers=1, batch_first=False)
output, hn = rnn(input, h0)
参数解释:
- input_size:每个时间步输入的特征维度。
- hidden_size:隐藏状态的维度。
- num_layers:堆叠的 RNN 层数。
- batch_first:若为
True
,输入输出的 shape 为(batch, seq_len, input_size)
,否则为(seq_len, batch, input_size)
。
输入张量:
- input:形状为
(seq_len, batch, input_size)
或(batch, seq_len, input_size)
。 - h0(可选):初始隐藏状态,形状为
(num_layers, batch, hidden_size)
。
输出张量:
- output:包含每个时间步的输出,形状为
(seq_len, batch, hidden_size)
,其实是由状态 ht 组成的张量。 - hn:最终时间步的隐藏状态,形状为
(num_layers, batch, hidden_size)
。
所以如果我们是一个 many to one 的模型,在输出时只需要取 output[-1]
传递给全连接层即可;而如果是 many to many 就看情况取相应数量的状态。
二、单层 RNN 的前向传播原理
对于一个序列 $(x_1, x_2, …, x_T)$,RNN 的每个时间步 $t$ 会执行如下操作:
数学表达:
\[h_t = \tanh(W_{ih} x_t + b_{ih} + W_{hh} h_{t-1} + b_{hh})\]其中:
-
\(x_t\):当前时间步的输入,形状为
(input_size,)
-
\(h_{t-1}\):上一时间步的隐藏状态,形状为
(hidden_size,)
-
\(W_{ih}\):输入到隐藏层的权重矩阵,形状为
(hidden_size, input_size)
-
\(W_{hh}\):隐藏状态到隐藏层的权重矩阵,形状为
(hidden_size, hidden_size)
-
\(b_{ih}, b_{hh}\):偏置项,形状为
(hidden_size,)
-
\(\tanh\):激活函数
整个序列依次迭代执行该更新,产生每个时间步的隐藏状态
\[h_t\]。
三、状态初始化
在定义 RNN 类的时候,需要手动写一个状态初始化的方法,该方法返回一个维度为 (num_layers, batch_size, hidden_size)
的张量。实例代码如下:
class RNNRegression(nn.Module):
def __init__(self, input_nums, hidden_nums, output_nums):
super().__init__()
self.rnn = nn.RNN(1, hidden_nums)
self.linear = nn.Linear(hidden_nums, output_nums)
self.hidden_nums = hidden_nums
def forward(self, x, state):
x = x.transpose(0, 1).unsqueeze(-1) # (seq_len, batch_size, input_size) 4,32,1
output, hn = self.rnn(x, state)
y = self.linear(output[-1].reshape(-1, output.shape[-1]))
return y.squeeze(), state
def begin_state(self, device, batch_size):
return torch.zeros((self.rnn.num_layers, batch_size, self.hidden_nums), device=device)
四、梯度裁剪
由于 RNN 的结构,过多的层会导致梯度消失或梯度爆炸,为了避免发生梯度爆炸,我们可以使用梯度裁剪的方法。
其具体操作也并不复杂,只需要加一行代码就可以了:torch.nn.utils.clip_grad_norm_(rnn.parameters(), max_norm=5.0, norm_type=2.0)
,norm_type
指定范数类型,默认是 L2 范数,其可以是任意的 p 范数。
当然除了裁剪之外,还有更加粗暴的方式——梯度截断,即直接把前面层的梯度舍弃掉。
五、预热
在 RNN 中,“预热(warm-up)” 是一个重要但容易被忽视的概念,尤其在处理长序列或状态传递模型时。它并不是 PyTorch 的内置功能,而是一种训练或推理时的策略。
RNN 的预热(warm-up) 指的是在开始对模型输出进行监督训练或评估之前,先输入一段“序列上下文”,仅用于初始化隐状态,但不参与损失计算或梯度反传。
- 为什么需要 warm-up?
RNN 模型的预测依赖于其内部的隐状态(hidden state)。在没有足够上下文时,隐状态往往是随机的或者全零的,这样会导致模型在序列初始阶段预测不准确,尤其在以下场景中尤为明显:
- 状态非常依赖历史输入(如轨迹预测、物理建模、金融时序);
- 训练使用截断反向传播(truncated BPTT),模型看不到完整历史;
- 使用非零初始状态会提升收敛速度或稳定性。
一些坑
这部分是我在写代码时遇到的一些问题。
最开始写好模型,进行训练时发现模型无法收敛,loss 很大,一直降不下去,后来检查了半天发现是损失函数输入的维度不匹配,两个输入的维度好像一个是 (32,1),一个是 (32),导致模型在计算损失的时候自动进行广播。
后来在一份文件中想要再写一个相似的模型处理不同的任务时,用了同一个优化器(optimizer),导致训练不收敛,也是 loss 下不去。后面检查了才发现问题所在。记住一个模型需要一个优化器,其和模型的参数是绑定的,不能用一个优化器优化多个模型的参数!(笨死了
Leave a comment