📕 第6章 深度学习的应用
📄 一句话概括

卷积网络假设输入是固定大小的图。序列不一样:长度可变,而且顺序有意义。 RNN 的全部机关就一个式子:这一步的隐状态等于 tanh(输入乘 Wx 加上一步隐状态乘 Wh 加 b)。 Wh 就是记忆。 实测 8 维隐状态的网络只有 98 个参数,但梯度从第 12 步往回传,退到第 1 步剩 0.000000。 延迟复述任务上 800 轮训练损失一动没动(0.6909),测试准确率 0.5000——等于瞎猜。

📝 本节和前面几节的关系

第 4 章的反向传播在这里变成「沿时间反向传播」,名字叫 BPTT,本质还是链锁律。 只是乘的项从一层变成了一步。 第 5 章的卷积靠「局部连接加权重共享」省参数, RNN 靠「所有时间步共享同一组权重」省参数——出发点一样,省的方向不同。 6.2 和 6.3 的两个门控结构,都是来修这一节末尾那个梯度消失的。

一、序列不是图

一张猫的图片左右翻转,还是只猫。一句话里的 20 个词随便换个顺序,意思立刻变了。 这就是序列和图的根本差别:位置本身携带信息。 要把顺序吃进来,模型必须在第 t 步还能看见第 t 减 1 步处理过的东西。 那个「处理过的东西」就是隐状态。 换成大白话说:它每读一个输入,就把「到目前为止读到什么」更新一遍,存成一个小向量。 读完整个序列,这个小向量就是整句话的摘要。

二、一个式子

参数 形状 个数 作用
Wx 1×8 8 输入到隐状态
Wh 8×8 64 上一步隐状态到这一步
b 8 8 隐状态偏置
Wo 8×2 16 隐状态到输出
bo 2 2 输出偏置

合计 98 个。前四行是隐状态那一层,后两行是输出层——输出层和普通全连接一模一样。 唯一值得注意的是 Wh: 它的左边和右边都是 8 维隐状态,乘的是「上一步的自己」。这一步就是「记忆」的全部实现, 没有别的机关。 换个角度看,把隐状态当成一本 8 维的笔记本, Wx 决定「这一步看到的怎么写进去」,Wh 决定「旧内容怎么和新内容混」。

三、展开之后就是很深的前馈网络

把时间步一层层摊开看:它长得和一个 8 层的全连接网络一模一样,只不过每一层共用同一组权重。 参数只有 98 个,而这个摊开的网络有 8 层。 这就是权重共享的威力,也是它的代价——同一组权重必须在所有位置都合适,位置之间不能有各自的偏好。 反向传播在这张摊开的图上跑,就叫 BPTT。第 4 章那四步一个字都没变,只是层数变成了时间步数。

四、梯度消失:实测

从第 12 步退到第 1 步,梯度范数从 1.000000 掉到 0.000000。只用了 11 步。 每退一步要乘两样东西: 一个是 Wh,一个是 tanh 的导数(1 减 h 的平方)。tanh 的输出在 −1 到 1 之间,导数最大是 1, 绝大多数时候只有 0.1 到 0.5。 连乘 11 次,0.3 的 11 次方是 0.0000018。 和实测的 0.000013 同一个量级。这不是 bug,是连乘的必然结果。 直接后果:第一位的输入对最终损失几乎没有影响。网络学不会「记住开头」。

五、延迟复述任务:800 轮不动

任务设计得很干净:第 1 位是 0 或 1,中间七位全是 0,第 8 步要求复述第 1 位。要答对, 网络必须把第 1 位的信息带过 7 个空步。 损失从头到尾停在 0.6909。 这个数不是随便来的:二分类完全瞎猜的交叉熵是 ln2,约 0.6931, 0.6909 说明输出基本锁在 0.5 对 0.5。 测试准确率 0.5000。它不是学得慢,是压根没开始学。 原因还是那个: 梯度传不回第 1 步,第 1 位那个输入对应的权重就永远收不到「你猜错了」这个信号。 而它恰恰是唯一有用的信息。

六、RNN 还能干什么

6.4 的字符级语言模型:一次猜一个字符。那是它最直接的应用。 6.5 的 Seq2Seq:读一整句、写一整句。编码器和解码器都是 RNN。 此外还有时间序列预测、语音识别、手写识别。凡是前后有顺序的东西,都是它的地盘。 它的地位后来被注意力取代了(6.6),但「用隐状态携带历史」这个思想留了下来。

七、动手 Demo:8 维隐状态与一个延迟复述任务

先建一个输入 1 维、隐状态 8 维、输出 2 类的 RNN,数清参数;喂一个 5 步的 0/1 序列看隐状态怎么变; 再把梯度从第 12 步往回退,看它剩多少。最后跑那个延迟复述任务。

import numpy as np

class RNN:
    def __init__(self, d, h, k, rg, alpha=0.3):
        self.d, self.h, self.k = d, h, k
        s = alpha / np.sqrt(h)
        self.Wx = rg.normal(0, s, (d, h))    # 输入 -> 隐状态
        self.Wh = rg.normal(0, s, (h, h))    # 上一步隐状态 -> 这一步
        self.b = np.zeros(h)
        self.Wo = rg.normal(0, s, (h, k))    # 隐状态 -> 输出
        self.bo = np.zeros(k)

    def forward(self, X):
        B, T, _ = X.shape
        hs = [np.zeros((B, self.h))]         # 初始隐状态是 0
        for t in range(T):
            hs.append(np.tanh(X[:, t, :] @ self.Wx
                              + hs[-1] @ self.Wh + self.b))
        return hs

rg = np.random.default_rng(1)
m = RNN(1, 8, 2, rg)                         # 98 个参数

# 梯度从第 12 步往回传:每退一步乘一次 Wh 和 tanh 的导数
hl = m.forward((rg.random((1, 12, 1)) > 0.5).astype(float))
v = np.ones((1, 8)) / np.sqrt(8.0)
for t in range(12, 0, -1):
    print(t, "%.6f" % float(np.linalg.norm(v)))
    v = (v * (1.0 - hl[t] * hl[t])) @ m.Wh.T

# 延迟复述任务:第 1 位是信号,中间七位全是 0
X = np.zeros((300, 8, 1))
bit = (rg.random((300, 1)) > 0.5).astype(float)
X[:, 0, 0] = bit[:, 0]

实跑输出:

--------------------------------------------------------------------
递回神经网络:把上一步的记忆带进来
--------------------------------------------------------------------

  一个 8 维隐状态、输入输出各 1 维的循环网络:

  参数    形状        个数
  --------------------------
  Wx      1×8         8
  Wh      8×8         64
  b       8           8
  Wo      8×2         16
  bo      2           2

  合计 98 个。
  **注意 Wh 是「这一步的隐状态乘上一步的隐状态」,这一步就是「记忆」的全部实现——没有别的机关。**

  喂一个 5 步的 0/1 序列,看隐状态怎么一步步变(打印范数):
    第 1 步  输入 0  隐状态范数 0.0000
    第 2 步  输入 1  隐状态范数 0.2178
    第 3 步  输入 0  隐状态范数 0.0644
    第 4 步  输入 0  隐状态范数 0.0173
    第 5 步  输入 0  隐状态范数 0.0029

  再看一件更要紧的事:梯度从第 12 步传回第 1 步,还剩多少。

    从第 12 步退到第  1 步  梯度范数 0.000000
    从第 12 步退到第  2 步  梯度范数 0.000001
    从第 12 步退到第  3 步  梯度范数 0.000003
    从第 12 步退到第  4 步  梯度范数 0.000012
    从第 12 步退到第  5 步  梯度范数 0.000050
    从第 12 步退到第  6 步  梯度范数 0.000149
    从第 12 步退到第  7 步  梯度范数 0.000731
    从第 12 步退到第  8 步  梯度范数 0.002939
    从第 12 步退到第  9 步  梯度范数 0.009932
    从第 12 步退到第 10 步  梯度范数 0.054830
    从第 12 步退到第 11 步  梯度范数 0.220670
    从第 12 步退到第 12 步  梯度范数 1.000000

  **从 1 掉到 0.0000,只用了 11 步。**
  这就是梯度消失:每退一步都乘一次 Wh 和一个小于 1 的导数因子,连乘下来指数衰减。
  它的直接后果是:**第 1 步的输入对最终损失几乎没有影响,网络学不会「记住开头」。**
  下面这个任务就是专门考这件事的。

  任务:序列第 1 位是信号,中间七位全是 0,要求最后输出第 1 位是什么。

    第 100 轮  损失 0.690925
    第 300 轮  损失 0.690924
    第 500 轮  损失 0.690924
    第 700 轮  损失 0.690924

  **800 轮下来损失一动没动,就在 0.6909 附近。**测试准确率 0.5000——等于瞎猜。
  它不是「训得不够」,是**梯度根本传不回第 1 步**。这一步一卡住,后面的门控结构才有意义。

参数表最后一行合计 98。其中 64 个都在 Wh 上——这是唯一一处「自己乘自己」。 隐状态范数那五行:0.0000、0.2178、0.0644、0.0173、0.0029。 输入是 0 的步,隐状态也在衰减,因为它只乘 Wh 再过 tanh。 这说明一件要紧的事:这个网络的「记忆」是会自己衰减的,不是一块永久存储。能记多久, 取决于 Wh 把范数放大还是缩小。 梯度那一列最能说明问题:1.000000 一路掉到 0.000000,中间没有任何一处停顿。它是平滑的指数衰减, 所以调学习率、加训练轮数都没用。 延迟复述任务的四行损失 0.690925、0.690924、0.690924、0.690924。小数点后四位一个没变。 这不是收敛,这是脊柱被掐断。损失不动、准确率 0.5000,两者是同一件事的两个侧面。 要修它,只能改结构——让梯度有一条不必连乘的路。那就是下一节 LSTM 的全部动机。

30 秒实验

  1. 把隐层从 8 加到 32,梯度退 8 步能剩多少?先猜,再改 RNN(1, 32, 2, rg) 跑一遍。
  2. 把任务改成「第 1 位是信号,但要求在第 3 步就复述」,普通 RNN 能学会吗?为什么?
  3. 如果把初始化尺度 alpha 从 0.3 改成 1.0,梯度消失会变慢还是更快?

常见误区

  1. 以为 RNN 的「记忆」是一份额外的存储。没有。 记忆就是那个 8 维向量本身,它每一步都被整个覆写。
  2. 以为训练久一点就能学会长距离依赖。梯度消失是结构性的,800 轮和 8000 轮的结果一样。
  3. 以为隐状态越大就越能记住。加宽确实能装更多, 但能不能学到,取决于梯度传不传得回去,光加宽不解决问题。
自测三问(点开对答案) 1. RNN 里哪一组参数承担「记忆」? —— Wh,也就是「上一步隐状态到这一步隐状态」那一块。 它每个时间步都被重复使用,所以模型在第 t 步还能看到前面处理过的信息。 2. 梯度消失的直接后果是什么? —— 早期时间步的输入对损失几乎没有影响,网络学不会「记住开头」。 实测从第 12 步退到第 1 步,梯度范数只剩 0.000000。 3. 为什么说 RNN 摊开就是一个很深的前馈网络? —— 每个时间步相当于一层,层与层之间靠隐状态连接。 区别只在于所有层共用同一组权重,所以参数不随序列变长而增加。

普通 RNN 卡在「记不住开头」。 6.2 的 LSTM 用一条只有乘加的传送带绕开了这个连乘,同一个任务上它能到 1.0000。

相关笔记