把递推矩阵的缩放设成 0.5、0.9、1.1,看梯度回传 20 步之后还剩多少: 分别是 1 步时的 1.78e-06、6.64e-02、3.10e-02 倍。 小于 1 就衰减到零,而大于 1 那一列也没涨——tanh 饱和把增长吃掉了。
上一节的卷积处理的是「空间上的相邻」。 这一节处理「时间上的先后」——一句话、一段语音、一支股票的走势,都是序列。
一、多了一个「记住上一步」
普通网络每一帧都是独立的,喂进去什么算什么。 序列数据不行:「银行」这个词的意思取决于前面出现过什么。 循环网络的做法是加一个隐状态 h:h_t 由当前输入和上一步的 h 一起决定, h_t = tanh(W × h 上一步 + 当前输入)。 这个 h 就是「记忆」。它把之前所有步的信息压成一个向量,一路带下去。 参数量和序列长度无关——不管序列多长,都是那几个矩阵。这是它比「把整段序列摊平喂进去」高明的地方。 代价是:训练时必须一步一步算,不能并行。这一点让它在 GPU 时代吃了大亏。
二、梯度要乘 20 次
训练时要算「损失对第 1 步的 h 的导数」。按链式法则,得从第 20 步一路乘回第 1 步。 每乘一次,就多乘一个矩阵 W 和一个 tanh 的导数(1 减 h 平方,最大是 1)。 所以 20 步之后,梯度大约被乘以 W 的 20 次方。特征值小于 1 就指数衰减,大于 1 就指数爆炸。 Demo 把 W 取成对角阵(每个维度独立),缩放分别设成 0.5、0.9、1.1,直接数出来。
三、三列数说了什么
缩放 0.5:20 步后梯度只剩 1 步时的 1.78e-06。等于归零。 网络学不到远处的东西——这叫梯度消失。 缩放 0.9:20 步后剩 6.64e-02,衰减慢一些,但仍然是衰减。 这就是「勉强能用」的区域,也是早期 RNN 只能处理十几个词的步长的原因。 缩放 1.1:20 步后只剩 1 步时的 3.10e-02——注意这里也是变小,不像预期那样爆炸。 因为 tanh 的导数(1 减 h 平方)把增长吃掉了:h 一旦接近正负 1,导数就接近 0。 所以这个 Demo 证明的是「衰减」,不是「爆炸」。 真正的爆炸需要去掉 tanh 的饱和,或者用更长的序列、更大的缩放。 这里我如实说明:爆炸这一列在 20 步内没有出现。
四、为什么「刚好等于 1」不可能维持
理论上只有缩放恰好在 1 附近,梯度才能稳定传下去。但训练本身就在改 W,它不可能一直停在 1。 更麻烦的是:不同维度需要的缩放不一样。有的信息要记 100 步,有的 2 步就该忘。 一个统一的 W 满足不了两边。 所以纯循环网络在长序列上几乎不能用。 解法有两条路:加门控(LSTM,8.2.6 讲),或者换掉循环结构(注意力机制,后来成了主流)。 从 2015 年到 2019 年,LSTM 是序列任务的标准答案;2019 年之后被 Transformer 取代。 但门控那套思路——用加法通道代替乘法——一直有用。
五、动手 Demo:梯度消失有多快:三种缩放,回传 20 步
隐状态取 4 维,递推矩阵取对角阵,缩放分别设成 0.5、0.9、1.1; 从第 20 步往回推,每一步算一次「损失对初始隐状态的梯度」的范数,看它随回传步数怎么变。
for t in range(20):
dh = (1 - h * h) # tanh 的导数
g = (W.T @ g) * dh # 往回传一步
norms.append(float(np.linalg.norm(g)))
h = np.tanh(W @ h)
实跑输出:
--------------------------------------------------------------------
循环网络:梯度随步数衰减得多快
--------------------------------------------------------------------
隐状态维度 4
递推式 h_t = tanh(W h_{t-1} + x_t),W 取对角阵
看的是 损失对 h_0 的梯度范数,随回传步数变化
回传步数 缩放 0.5 缩放 0.9 缩放 1.1
----------------------------------------
1 7.86e-01 1.42e+00 1.73e+00
5 4.58e-02 5.90e-01 9.05e-01
10 1.43e-03 2.91e-01 3.67e-01
15 4.48e-05 1.62e-01 1.42e-01
20 1.40e-06 9.40e-02 5.37e-02
缩放 0.5 走 20 步 衰减到 1 步时的 1.78e-06 倍
缩放 0.9 走 20 步 衰减到 1 步时的 6.64e-02 倍
缩放 1.1 走 20 步 涨到 1 步时的 3.10e-02 倍
三列数字把循环网络的两个病一次说清了:
缩放小于 1,梯度指数衰减,20 步之后基本归零——网络学不到远处的东西,这叫梯度消失。
缩放大于 1,梯度指数爆炸,几步之后就冲到天文数字——这叫梯度爆炸,训练直接崩。
只有刚好等于 1 才稳,而训练过程本身就在改这个缩放,它不可能一直等于 1。所以纯循环网络在长序列上几乎不能用,必须加门控——这就是后面 LSTM 要解决的问题。
缩放 0.5 那一列掉得极快:1 步 7.86e-01、5 步 4.58e-02、10 步 1.43e-03、20 步 1.40e-06。 每 5 步掉将近两个数量级。 缩放 0.9 那一列慢得多,20 步还有 9.40e-02。这是「勉强能用」的区域。 缩放 1.1 那一列反而也降——从 1.73e+00 掉到 5.37e-02。因为 tanh 饱和了,导数接近 0。 所以这个 Demo 没有演示出梯度爆炸,只演示了衰减。 想看爆炸,得把 tanh 换成线性激活,或者把缩放设到 1.5 以上。 我没改,是因为改了就不是「循环网络」了——饱和正是循环网络的一部分。 这条结论对训练师的实际意义:遇到序列任务效果差,先看序列长度。 超过 20 到 30 步还在用普通 RNN,基本上注定学不到远处信息。
30 秒实验
- 把缩放改成 1.5,看还会不会衰减——想想为什么。
- 把回传步数从 20 提到 50,看缩放 0.9 那列掉到多少。
- 想一想:为什么循环网络训练时不能并行,而卷积可以?
常见误区
- 「RNN 的问题是梯度爆炸」——更常见的是消失,爆炸要靠不饱和的激活才明显。
- 「多训几轮就能学到远处信息」——梯度已经归零,训多久都传不过去。
- 「序列越长效果越好」——超过一定长度只会更难训,不会更好。
自测三问(点开对答案)
1. 循环网络的隐状态是什么? —— 由当前输入和上一步隐状态共同决定的向量,起「记忆」的作用。 2. 梯度为什么会消失? —— 反向传播要连乘 20 次矩阵和激活导数,都小于 1 时指数衰减到 0。 3. 为什么「刚好等于 1」维持不住? —— 训练本身在改权重,而且不同维度需要的记忆长度不一样,一个统一矩阵满足不了。到这里讲的都是「判别」——给输入要答案。下一节反过来:让网络自己造数据。
- ☐ (本节完,下一节 8.2.4 生成对抗网络:相互博弈以提升模型性能)