循环神经网络(RNN)的梯度下降算法,是我做深度学习这么久以来,觉得最容易被低估的一块硬骨头。很多人能叫出BPTT这个名字,也会用LSTM、GRU这些现成封装,但真到训练的时候,loss震荡、梯度爆炸到NaN、或者梯度消失到训练完全停止,能讲清楚背后原因的人其实不多。这篇文章就想把“RNN的梯度下降”这件事从思路到实现完整讲一遍,包含它和普通前馈网络相比到底差在哪里、梯度是怎么顺着时间步一层层传回去的、以及我自己的实现经验和排查套路。无论你是刚入门想弄懂BPTT,还是已经在用框架但训练不顺想找排查方向,这篇都能给你一个可以直接落地的参考。
坦白说,RNN的梯度下降在数学上并不神秘,它照样是链式法则,照样让参数沿着损失函数的负梯度方向更新。但多了一个“时间”维度之后,问题立刻变复杂了:t时刻的损失函数,不仅取决于t时刻的输入,还取决于t-1、t-2时刻的输入状态,因为隐藏状态把历史信息一路带了过来。所以梯度也不得不顺着时间维度的依赖关系反向流动,这就是BPTT(Backpropagation Through Time,按时间反向传播)。这篇文章会花大量篇幅,把BPTT这条链路上的每一步都拆开看。
1. 解决问题的思路:先搞懂前向计算,才能算对反向梯度
1.1 RNN为什么用梯度下降,损失从哪里来
RNN本质上是一个带记忆的映射函数。任务不同,损失函数也不同。比如做语言模型,输入是前一个词,输出是下一个词的概率分布,这时候用交叉熵损失;做时间序列回归,比如预测气温或股价,这时候用均方误差损失。无论哪种,最终都会得到一个标量loss,然后我们用梯度下降去调整网络参数,让这个loss逐步变小。
很多人一开始理解RNN时,喜欢把注意力全放在“循环”两个字上,但对损失函数的结构不够重视。其实梯度下降算法的起点就是损失函数。你必须明确知道:你优化的是“整个序列上的平均损失”,还是“最后一个时间步的损失”,还是“每个时间步损失的加权和”。在RNN训练里,最常用的是把整个序列所有时间步的损失都计算出来,然后求平均,或者求和。这样能够让网络在一个序列内部同时受到多个位置信号的约束,学出来的参数不会只偏向序列末尾。
举个例子,我在自己动手写一个简单字符级语言模型demo时,输入是一个字符序列“a b c d”,输出是每一步预测下一个字符。我会把每一步的交叉熵损失计算出来,最后取平均,当作这一步的优化目标。这样梯度信号在整个时间轴上均匀分布,而不是集中在最后一步,训练效率和稳定性都会好很多。
1.2 前向传播时,必须缓存哪些状态
在做反向传播之前,前向传播阶段不能只算一个最终预测结果就完事。因为BPTT要复用大量中间量。具体来说,你需要把每个时间步的隐状态、以及激活函数的原始输入(也就是线性变换的结果)全部保存下来。这些就是反向传播时的“材料”。
公式可以写得很简单。一个最基础的全连接RNN单元,第t步的隐藏状态是:
h_t = tanh(W_hx * x_t + W_hh * h_{t-1} + b_h)
然后输出层的未归一化结果一般是:
z_t = W_hy * h_t + b_y
再经过softmax得到每一步的预测概率。注意,我在前向过程中会把h_t保存成一个列表,也会把tanh括号里面的内容保存下来。为什么?因为反向传播时要对tanh求导,而tanh的导数存在一个快速公式:1 - h_t^2,其中h_t是已经算出来的激活值。如果你没有保存激活值,反向传播的时候就得重新算一遍前向,浪费时间不说,还容易把代码写得很丑。
当然,如果你用的不是纯tanh,而是类似ReLU的激活,那中间缓存的需求会稍微变化,但核心原则不变:反向传播要用到的中间张量,在前向传播阶段尽量都准备好。
1.3 跨时间步共享权重,改变了梯度的形态
这是RNN和普通前馈网络最本质的区别。在前馈网络里,每一层的权重W是独立的,梯度只沿着层与层之间的一条路径回传。但在RNN里,同一个W_hh在每个时间步都被使用,所以loss对W_hh的梯度,必须把所有时间步上的贡献全部加起来。
打个比方,这就像你在同一家连锁店的不同分店都刷了同一张会员卡,最后统计积分时,要把每一家分店的消费记录全部累积起来。W_hh是共享的,t=1这一步用它做了计算,t=10这一步也用它做了计算,所以损失对它的偏导数是各个时间步偏导数的总和。
也正因为如此,RNN的梯度天然会比普通网络“大一些”,因为它是一个累加的结果。同时,它也是一个“乘积”的结果:梯度要跨时间回传,本质上要把多个时刻的状态转移矩阵连乘起来。一旦矩阵的谱半径小于1,连乘很快就会衰减到零;大于1,又会指数级放大。这就是后面我们要重点说的梯度消失和梯度爆炸的数学根源。
2. 核心细节:BPTT是如何一步步把梯度传回去的
2.1 从输出损失到输出层的梯度
BPTT的后向过程,第一站是输出层。假设我们用softmax加交叉熵作为组合,那么在logits层面的梯度有一个非常简明的结果:预测概率减去真实概率。也就是:
dz_t = y_pred_t - y_onehot_t
这个dz_t就是z_t(W_hy * h_t + b_y的输出)关于损失的梯度。很多框架的CrossEntropyLoss在底层也是这么算的。自己实现的时候,可以直接用这个式子省掉softmax求导。
然后,W_hy的梯度就是dz_t和当前隐藏状态h_t的外积。这里我习惯把整个序列的梯度累加到一起,因为W_hy也是共享的,每个时间步的loss都会对它做一次贡献。
2.2 梯度是如何跨越时间步流动的
这一步是核心中的核心。拿到dz_t之后,我们要把梯度回传到隐藏状态h_t上去。此时h_t的梯度由两个部分叠加:
- 来自当前时刻输出层的梯度:W_hy^T * dz_t
- 来自下一时刻传递回来的梯度:这个在反向循环里就是先前已经累积好的一个变量,我习惯叫它d_h_next
二者相加,就得到了h_t的总梯度。接下来要通过tanh单元。tanh的导数是1 - h_t^2,所以可以算出对线性输入a_t的梯度:
da_t = dh_t * (1 - tanh(a_t)^2)
有了da_t以后,就能更新W_hx和W_hh各自的梯度了。W_hx的梯度是da_t和x_t的外积,W_hh的梯度是da_t和前一隐藏状态h_{t-1}的外积。注意这里用到的是h_{t-1},不是h_t,因为在这个时间步里,W_hh负责把上一步的隐藏状态变换到当前步。
然后还需要继续往前传:把da_t通过W_hh回传给h_{t-1},也就是:
d_h_next = W_hh^T * da_t
循环往复,直到处理完第一个时间步。这个循环,就是完整性极高的BPTT算法。
2.3 全量BPTT和截断BPTT的取舍
理论上BPTT要把整个序列的各个时间步全部反向传播一遍。但是如果序列很长,比如一个文本有几百、上千个token,全量BPTT不仅显存压力大,计算量也会大到不现实。因为每一步反向都要重新沿着整条时间链去计算。
所以业界、包括我自己做实验,更常用的是截断BPTT(Truncated BPTT)。做法是:把长序列切成固定长度的小段,比如每个段包含20个或50个时间步,然后在这个段内做前向和反向。段与段之间的隐藏状态是可以传递的,但是梯度不跨段反向传播。也就是说,当前段计算时会用上一段的最终隐藏状态作为起点,但这一段产生的梯度不会传回上一段。
我在实际使用中,如果序列长度在30以内,全量BPTT没问题;如果序列长度上百,截断长度一般取20到50比较合适。截断太短,模型学不到长期依赖;截断太长,训练速度明显变慢,梯度也可能不稳定。这个参数值得你根据数据量专门调试几轮。
3. 实操过程:从零实现一个带梯度下降的RNN
3.1 参数初始化的经验
很多教程讲RNN都会跳过初始化,但我发现初始化对训练稳定性影响极大。最忌讳的做法是把W_hh和W_hx都初始化成均匀分布的大数值,那样做非常容易梯度爆炸。
我常用的方案是:输出层权重W_hy用均匀小数值初始化,比如上下限是±1/sqrt(H);输入权重W_hx也是类似。关键在于状态转移矩阵W_hh,它的谱半径最好控制在1附近。手动控制谱半径还得做特征值分解,日常做demo不用那么复杂,我会把W_hh初始化成单位矩阵的缩放版本,或者用一个较小标准差的高斯分布,比如0.1。这样至少保证了初始阶段梯度不会瞬间爆炸。
更激进的做法是使用正交初始化。把W_hh初始化为正交矩阵,可以很好地保持梯度范数的稳定性。如果你用PyTorch,直接torch.nn.init.orthogonal_就能做到。偏置项全部初始化为零,或者为了缓解某些问题把输出层的偏置设置为训练集标签的统计先验,那个就是进阶玩法了,先用零也行。
3.2 前向传播代码与缓存设计
下面我用Python加NumPy风格写一套极简的全连接RNN实现。这里主要为了展示BPTT的实现逻辑,不是让你拿去跑大规模训练,但可以当成理解框架源码的一个极其有效的解剖模型。
import numpy as np def softmax(x): e = np.exp(x - np.max(x, axis=-1, keepdims=True)) return e / np.sum(e, axis=-1, keepdims=True) def rnn_forward(xs, W_hx, W_hh, W_hy, b_h, b_y, h0): T = len(xs) H = W_hh.shape[0] hidden_states = [h0] # 缓存所有 h_t,下标 t 对应 time step t raw_acts = [] # 缓存 h_t = tanh(a_t) 中的 a_t logits = [] for t in range(T): x = xs[t] # 当前时间步输入,形状 (D,) a = np.dot(W_hx, x) + np.dot(W_hh, hidden_states[t]) + b_h raw_acts.append(a) h = np.tanh(a) hidden_states.append(h) z = np.dot(W_hy, h) + b_y logits.append(z) return np.array(logits), hidden_states, raw_acts这里有一个细节,hidden_states的下标。我让hidden_states[0]等于初始隐藏状态h0,这样当t=0时,使用的就是h0。循环结束后,hidden_states的长度是T+1,其中hidden_states[t+1]对应真正经过当前时间步计算出来的h_t。写反向传播的时候,下标千万别搞混,这是新手最容易出错的地方之一。
3.3 反向传播代码的写法
继续写反传。为了保留全套关键逻辑,我这里直接给出BPTT的核心循环。注意,我在代码里假设输出层用的是softmax加交叉熵,所以logits层面的梯度可以直接写成预测概率减真实概率。
def rnn_backward(xs, ys, logits, hidden_states, raw_acts, params): W_hx, W_hh, W_hy, b_h, b_y = params T = len(xs) H = W_hh.shape[0] dW_hx = np.zeros_like(W_hx) dW_hh = np.zeros_like(W_hh) dW_hy = np.zeros_like(W_hy) db_h = np.zeros_like(b_h) db_y = np.zeros_like(b_y) d_h_next = np.zeros(H) for t in reversed(range(T)): # 当前时刻输出层的梯度 dz = logits[t] - ys[t] # ys 是 one-hot 向量 dW_hy += np.outer(dz, hidden_states[t+1]) db_y += dz # 当前时刻隐状态梯度 = 输出层回传梯度 + 下一时刻回传梯度 dh = np.dot(W_hy.T, dz) + d_h_next # 通过 tanh 的导数 da = dh * (1 - np.tanh(raw_acts[t]) ** 2) db_h += da dW_hx += np.outer(da, xs[t]) dW_hh += np.outer(da, hidden_states[t]) # 继续往上一时间步传 d_h_next = np.dot(W_hh.T, da) return [dW_hx, dW_hh, dW_hy, db_h, db_y]代码的核心就是那个for t in reversed(range(T))循环。你可能会问,为什么da公式里用的是raw_acts[t]而不是hidden_states[t+1]?这就要回到第1.2节说的缓存问题。tanh的导数既可以用输入a来算,也可以用输出h来计算,只要保持一致就行。用raw_acts[t]和用1 - hidden_states[t+1]**2是等价的,但用raw_acts得先重新求一遍tanh,反而是缓存hidden_states更省事。你可以自己选,把导数写成dh * (1 - hidden_states[t+1] ** 2)也是完全正确的。
3.4 训练循环与参数更新
有了前向和反向,剩下的事就是沿着负梯度方向更新参数了。最简单的SGD更新:
learning_rate = 0.01 for step in range(max_steps): logits, hidden_states, raw_acts = rnn_forward(...) loss = compute_loss(logits, ys) grads = rnn_backward(...) for p, g in zip(params, grads): p -= learning_rate * g不过实际训练很少用裸SGD,我至少会加一个简单的动量。动量项的公式很古老,但在RNN这类容易震荡的任务上,效果比裸SGD稳不少。如果是用框架,Adam是默认选项。但要记住:Adam只是帮你调节参数的更新步长,它并没有解决梯度消失和梯度爆炸本身。真正的问题还是要从梯度流动的层面去解决。
训练循环里还有一个容易被忽略的点:每一步的初始隐藏状态h0从哪来。最简单的方式是每次随机初始化,或者用上一批次最后保存的隐藏状态。如果把h0设为零向量,前几个时间步的梯度会偏小,网络需要花一些时间“暖机”。如果序列之间没有强烈的时间依赖,随机初始化或零初始化都没问题。
4. 常见问题速查与传统参数调试
4.1 梯度消失:症状、成因和对策
梯度消失大概是RNN最著名的毛病。它的特征是:训练loss下降得非常缓慢,往往前几个epoch几乎纹丝不动;或者更明显的是,网络在输入序列靠前位置的信息,完全学不到。比如做文本分类,你给模型一个很长的句子,分类结果只受结尾关键词影响,开头内容无论怎么改,预测都不变。
数学上,这是因为跨时间步回传时,会将多个W_hh和tanh导数的连乘累积起来。tanh导数最大值只有1,而且只有在0附近才接近1;只要时间步一长,连乘结果就会指数级趋近于零。可以粗暴地理解为梯度在往回走的过程中被“稀释”了。
对策分两个层次。第一是激活函数层面,换成ReLU或者Leaky ReLU能在一定程度上减少导数衰减;第二是结构层面,使用LSTM或GRU。LSTM里用“细胞状态”加门控机制,让梯度的传递路径变成一条近似的“高速通道”,允许跨很多时间步都不衰减。这也是为什么工业界几乎只用LSTM和GRU。如果你在实现基础RNN模型,发现它怎么调都学不好长序列,不要死磕,尽快换门控结构才是正经路。
4.2 梯度爆炸:症状、成因和梯度裁剪
梯度爆炸的症状比消失好认得多:loss突然跳到无穷大或者NaN,梯度数值大得离谱。最常见的原因是W_hh的谱半径大于1,导致连乘指数放大。再叠加一个较大的学习率,几乎必炸。
最直接、最有效的手段是梯度裁剪。梯度裁剪不是修改目标函数,而是限制梯度的范数。两种常用做法:一是按值裁剪,把梯度张量的每个元素限制在[-clip_value, clip_value];二是按范数裁剪,算一下所有梯度的全局2范数,如果超过阈值就等比缩放。我强烈推荐按范数裁剪,它在实践中比按值裁剪更稳定。
def clip_grad_norm_(params, max_norm): total_norm = 0.0 for p in params: total_norm += (p.grad * p.grad).sum().item() total_norm = total_norm ** 0.5 scale = max_norm / (total_norm + 1e-6) if scale < 1.0: for p in params: p.grad *= scale这段代码的逻辑很简单:计算总范数,如果超过max_norm,就把所有梯度等比缩小。max_norm我一般取5.0,这个数值在很多开源代码里也很常见。如果模型比较大或者序列比较长,可以适当放宽到10,如果训练特别不稳定,也可以压到1.0。建议你从5.0起步。
需要特别强调的是,梯度裁剪解决的是“更新一瞬间”的问题。它不会让消失的梯度重新出现,也不会把W_hh的谱半径变好。它只是当你累加出极大梯度时,不让这一次更新毁掉已有参数。
4.3 学习率、批大小与序列长度的配合
在RNN训练里,学习率的选择和普通CNN很不一样。因为同一时间步数里,RNN的梯度路径更长、方差更大,能承受的学习率通常比CNN低。拿一个小demo举例,我用基础RNN做字符预测,LSTM的学习率可以用0.001到0.002,但基础RNN最好从0.0005开始试,否则很容易在开头几百步就出现NaN。
批大小也会影响梯度质量。批大小越大,梯度估计越稳定,但RNN模型每个样本的序列长度都不同,Padding逻辑会直接影响梯度质量。我见过不少人把不同长度的序列硬pad到同样长度之后,却忘记在反向传播时屏蔽padding位置。结果那些无效时间步的loss参与梯度计算,相当于给网络灌入大量噪声。正确做法是用mask,让padding位置不参与loss计算和梯度回传。
序列长度这个超参数,常常被忽略。实际序列长度越长,BPTT需要的计算量越大,梯度跨时间衰减也越严重。因此做长文本任务时,不要总想着把完整句子一次输入。很多项目用截断BPTT的窗口训练,比如每64个时间步为一段,效果就很好。窗口大小的选择可以简单粗暴地做几次扫描:2的倍数增长,观察验证集loss变化。
4.4 排查NaN和loss震荡的实操流程
训练RNN遇到NaN,首先别急着改模型。我自己的排查顺序是:
第一步,检查数据。输入里有没有NaN,标签有没有错位。这是最高频原因,而且换模型结构之前应该先排除。
第二步,检查损失函数。softmax加交叉熵时,预测概率如果出现log(0),也会导致NaN。通常要在logits上做数值稳定处理,或者给log加一个极小值eps。
第三步,检查初始化。把W_hh的初始值打印出来,看看范数大小。如果初始化范围过大,网络在第一步就已经处于混乱边缘。
第四步,打开梯度裁剪,并打印梯度范数。观察梯度范数到底在哪个时间步激增。如果加了裁剪后训练成为可能,说明问题基本就是梯度爆炸;如果裁剪无效,看看是否出现vanishing导致的训练停滞,再决定要不要换LSTM。
另外,loss从一开始就不下降,和“下降后又突然跳高”是两个完全不同的现象。前者更像初始化太差或者梯度消失,后者往往是学习率过高或梯度裁剪阈值过松。记录好每个epoch的梯度范数均值,对RNN训练来说比记录loss本身更有诊断价值。
5. 实操心得与进阶建议
5.1 观察梯度范数和梯度分布
调试RNN,一定要养成肉眼观察梯度的习惯。我在每次训练时,会加一个小的钩子,定期打印每一层参数梯度的2范数、最大绝对值元素、以及梯度的均值和标准差。这个习惯在普通前馈网络里是“锦上添花”,在RNN里几乎是“必须”。
为什么?因为RNN的梯度既有整体范数问题,也有分布问题。有时候整体范数正常,但某个时间步的梯度过大或过小,说明时间维度的分布异常。比如你可能发现第一个时间步的梯度最大值是1e-3,最后一个时间步是10,两者差了四个数量级。这种“时间梯度失衡”靠全局梯度裁剪是解决不了的,需要更精细的手段,比如在时间维度上做归一化,或者使用更长的截断窗口。
具体操作上,如果你用PyTorch,可以在torch.autograd中注册hooks;如果你手撕代码,就在每个时间步的反传结束处记录dh的范数。记录下来的曲线能帮你快速定位很多玄学问题。
5.2 结构层面的改进:LSTM、GRU和残差连接
如果基础RNN的梯度问题让人绝望,那就直接上LSTM或GRU。它们不是神奇地解决了梯度,而是把跨时间传递的路径改成线性自连接的细胞状态,梯度可以沿着细胞状态“直通”跨过大量时间步。代价是参数数量增加、计算变慢。
GRU相比LSTM少了一个门,参数更少,在小数据集上往往比LSTM更稳。如果是做机器翻译、语音识别这样的大任务,更依赖长期记忆时,LSTM的表现稳定一些。只考虑收敛速度而不要求极限精度的话,GRU是个非常合适的默认选择。
如果你必须使用简单RNN但需要处理相对长的依赖,可以试试“残连接”思想:把输入和经过变换后的部分相加,即h_t = tanh(x_t的线性变换 + W_hh * h_{t-1} + b_h) + x_t的一个投影。虽然不严格等于残差网络,但确实能让梯度的短程路径更通畅。另一个办法是使用多层RNN时,层与层之间按时间步交错堆叠,不要直接把所有层在时间维度上一步展开完,这种工程技巧也更接近真实框架的实现方式。
5.3 优化器选择和相关经验
最后再说优化器。基础RNN训练,我以前特别喜欢用SGD加一点Nesterov动量,在小数据集上收敛非常稳定。但SGD需要手动调节学习率,而且要配合学习率下降策略,对新手不友好。Adam是稳妥选择,它对学习率的敏感度低,初始化也都推荐默认的1e-3。但Adam也有自己的坑:如果loss突然变差,尝试一下把学习率调低到1e-4,往往就好了。
还有一种现象值得单独提一下:Adam配合梯度裁剪时,如果梯度范数经常到达裁剪阈值,会出现训练步长“阶梯式”变化。这时别一味调大max_norm,反而应该降低学习率。我踩过几次坑之后形成了习惯:梯度裁剪阈值尽量固定,优先调学习率,学率根据梯度范数的均值动态调整。
还有一个小技巧:如果lstm训练过程中验证集loss开始反弹,可能是梯度噪声太大,而Adam的指数平均衰减系数beta1偏大。官方默认beta1=0.9,你在RNN任务里可以试试0.8或者0.7,让优化器对近期梯度变化更敏感,有时候对加速收敛有意想不到的帮助。
另一条经验是:多跑几个随机种子。RNN训练随机性非常强,同样的超参数、不同的随机种子,loss曲线后期可能差出几个点。不要轻易因为一两次结果差就下结论。我每次调参都会至少跑三个种子,取中位数和方差来对比,这样可以过滤掉大量“看似玄学”的影响。
至于要不要上学习率调度器,我的看法是:先不加。等基础训练的loss曲线稳定之后,再用余弦退火或ReduceLROnPlateau做微调。RNN本身梯度路径长,早期就使用动态学习率,很容易把一个本来就正常的训练过程搞得更加看不清。
最后再分享一个我自己的习惯:写RNN的正反向实现时,即使是用框架,也先在一个非常小的玩具序列上检查梯度。拿数据算一遍手推结果,和框架给的结果对比。只要这一步验证对了,后面做大规模训练才不至于一错错到底。这个习惯帮我节省过好几个夜晚的排查时间,也推荐你试试。