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

LSTM 在 RNN 上加了一条独立的 cell 通道,用三道门控制「留多少、写多少、拿多少」。 门就是 sigmoid,输出在 0 到 1 之间,等于一个可学的开关。 实测一个 3 维单元:遗忘门 0.71 到 0.73、输入门 0.51、输出门 0.47 到 0.51。 c 的更新只有一次乘法和一次加法,没有 tanh 挤压。 同一个延迟复述任务:普通 RNN 卡在 0.5000,LSTM 第 200 轮就到 0.0092,最后 0.0005, 训练与测试都是 1.0000。 它的遗忘门稳定在 0.773,并没有开到 1。 连乘 8 个 f 剩 0.1276,普通 RNN 的隐状态路径只剩 0.000013,差 10117 倍。

📝 本节和前面几节的关系

6.1 说过 RNN 的毛病是每退一步都要乘一次 Wh 和一个 tanh 导数。 LSTM 的思路不是「让这个乘积变大」,而是「另开一条只乘一个数的路」。 6.3 的 GRU 是它的精简版,把三道门并成两道。 第 4 章的链锁律在这里要看清楚:c 的梯度路径上只有 f,没有矩阵乘,也没有 tanh。

一、思路:另开一条路

RNN 的隐状态每一步都要过 tanh,所以梯度每退一步都要乘一次 tanh 的导数,而这一项永远小于 1。 想让连乘不衰减,就得让这一项接近 1——但它做不到。 LSTM 换了个思路: 与其想办法让这一项变大,不如另开一条通道,让它只做加法和乘法。这条通道叫 cell state,记作 c。 关键的一句:c 没有 tanh。它线性地一路往前传,梯度沿它往回走时,每一步只乘一个数。 还有一层好处:加法让梯度可以「原样分流」。一个加法节点的梯度会不加修改地分给两路, 这是 LSTM 能记住的第二个原因,比门的开关更底层。

二、四道门各自管什么

门 算式 管什么
遗忘门 f σ(Wx 加 Wh 加 b) 旧的 c 留多少
输入门 i σ(同一套线性组合) 新内容写多少
候选 g tanh(同一套线性组合) 新内容写什么
输出门 o σ(同一套线性组合) 这一刻拿多少出来

于是更新只有两行:c 等于 f 乘旧 c 加 i 乘 g;h 等于 o 乘 tanh(c)。 读法要说清:f 接近 1 就是全留,接近 0 就是全忘;i 是「写多少」,g 是「写什么」; o 管的是「这一刻从仓库里拿出多少给外面看」,它不影响仓库里存了什么。 四道门都是同一个线性组合切出来的四段。一次矩阵乘算四份,这就是 LSTM 的 Wh 是 8×32 的原因。

三、手算一遍

门 单元 1 单元 2 单元 3
遗忘门 f 0.7146 0.7326 0.7292
输入门 i 0.5211 0.5120 0.5088
候选 g -0.0451 +0.0043 +0.0155
输出门 o 0.4658 0.5088 0.5099

第一个单元:旧 c 是 0.2,f 是 0.7146,留下 0.1429;i 乘 g 是 0.5211 乘 −0.0451,等于 −0.0235。 加起来 0.1194,就是打印出来的新 c。 这一行手算就把公式验完了。如果不一致,一定是门的顺序切错了——这是实现 LSTM 最常见的 bug, 因为四段都是一样的形状。 再看 h:新 c 是 0.1194,tanh 之后是 0.1188,乘 o 的 0.4658 得 0.0554。和打印的 h 一致。 h 比 c 小很多,这是正常的。c 是「仓库里存了多少」,h 是「这一刻拿出多少」, 两者本来就不该相等。

四、为什么这样就保住了梯度

反向时 c 的梯度路径是「dc 乘 f 传给上一刻」。一路乘下去,8 步就是 8 个 f 连乘。 实测训练后的 f 均值在 0.771 到 0.774 之间,0.773 的 8 次方是 0.1276。 同一个网络里普通 RNN 的隐状态路径,退 8 步只剩 0.000013。两者差 10117 倍。 为什么普通 RNN 差这么多? 因为它每一步乘的是「矩阵乘 Wh 再乘 tanh 导数」,这个乘积的大小由初始化定死,训练很难改。 LSTM 的 f 是每个单元、每个时刻单独算出来的。想留就调到 0.99,想忘就调到 0.1。 一句话概括:它把「记住多久」从超参变成了可学的参数。这就是全部。

五、代价

参数 338 对 98,是 3.45 倍。Wh 从 8×8 变成 8×32,因为四道门要各算一份。 计算量差不多也是这个比例。所以序列很短、依赖很近的任务上,LSTM 不一定比 RNN 划算。 还有一个隐患:f 若长期接近 1,c 会一路累加。tanh 只压住了 h,仓库本身没有界。 梯度爆炸就是这么来的,工程上靠梯度裁剪对付。 另外 LSTM 的状态有两份(h 和 c),初始化和调试都比 RNN 麻烦。多出来的复杂度不是白给的。

六、动手 Demo:四道门手算,再跑同一个任务

先把一个 3 维 LSTM 单元的四道门手算一遍,验证公式; 再用同一个延迟复述任务训练 800 轮,看损失怎么掉; 最后把训练后每个时间步的遗忘门均值打出来,算一算连乘的结果。

import numpy as np

def sig(z):
    return 1.0 / (1.0 + np.exp(-np.clip(z, -30, 30)))

# 一个 3 维 LSTM 单元:四道门共用一次矩阵乘
H = 3
z = x @ m.Wx + h0 @ m.Wh + m.b      # 形状 1×12,切成四段用
i = sig(z[:, :H])                   # 输入门
f = sig(z[:, H:2 * H])              # 遗忘门
o = sig(z[:, 2 * H:3 * H])          # 输出门
g = np.tanh(z[:, 3 * H:])           # 候选

c = f * c0 + i * g                  # 传送带:只乘一次、只加一次
h = o * np.tanh(c)                  # 拿出一部分给外面看

# 训练完,看每个时间步的遗忘门均值
hh, cache = m.forward(Xte)
# cache[t] = (h0, c0, i, f, o, g, tanh(c)),下标 3 是 f
fs = np.stack([cc[3] for cc in cache], 0)
fm = fs.mean((1, 2))
print("遗忘门均值", " ".join("%.3f" % v for v in fm))
print("连乘 8 个", "%.4f" % float(np.prod(fm)))

实跑输出:

--------------------------------------------------------------------
LSTM:三个门决定记什么、忘什么
--------------------------------------------------------------------

  先把一个 LSTM 单元的四道门手算一遍。输入 0.5,上一步的 h 与 c 给定:

  门          算式                    结果
  ------------------------------------------------------------
  遗忘门 f    σ(Wx + Wh + b)          0.7146  0.7326  0.7292
  输入门 i    σ(同一套线性组合)       0.5211  0.5120  0.5088
  候选 g      tanh(同一套线性组合)    -0.0451 +0.0043 +0.0155
  输出门 o    σ(同一套线性组合)       0.4658  0.5088  0.5099

  然后两行就把状态更新完了:**c = f 乘 c_上一 + i 乘 g;h = o 乘 tanh(c)。**
    c 新 = +0.1194  +0.0755  -0.0650
    h 新 = +0.0554  +0.0383  -0.0331

  **c 是那条「传送带」:它只经过一次乘法和一次加法,没有 tanh 挤压。**
  梯度沿它往回传时,每步只乘一个 f,而 f 可以学成接近 1。

  换成同一个任务(记住第 1 位,8 步之后复述):

    第 100 轮  损失 0.687717
    第 300 轮  损失 0.002563
    第 500 轮  损失 0.000992
    第 700 轮  损失 0.000602

  **第 200 轮就掉到 0.0092,最后 0.0005,训练与测试准确率都是 1.0000。**
  同一个任务,普通 RNN 卡在 0.5000。

  再看它到底怎么做到的——把训练后每个时间步的遗忘门平均值打出来:

    时间步           1      2      3      4      5      6      7      8
    遗忘门均值   0.771  0.774  0.774  0.774  0.774  0.773  0.772  0.772

  **它的遗忘门稳定在 0.773 左右,并没有开到 1。**
  这说明 LSTM 不是靠「全开」来做记忆的,而是自己挑了一个衰减率。
  这个数有多要紧,看下面这行对比就够了:

      普通 RNN 的隐状态路径退 8 步,剩 0.000013
      LSTM 的 cell 路径走 8 步(连乘 8 个 f),剩 0.1276

  **差了 10117 倍。**普通 RNN 那个衰减因子由权重初始化的谱半径决定,训练也改不了多少;
  LSTM 的 f 是每个单元每个时刻单独算出来的,想留就留、想忘就忘。
  这就是两者的全部差别:**它把「记住多久」从超参变成了可学的参数。**

手的部分先看那张表:四道门里只有 g 是 tanh,所以只有 g 会出负数,另外三个都在 0 到 1 之间。 这是判断实现有没有写错的第一眼特征。 三列的门值都很接近(f 在 0.71 到 0.73、i 在 0.51、o 在 0.47 到 0.51)。 因为初始化的偏置是零,权重又是小随机数,四道门一开始都在 0.5 附近打转。 训练那四行:0.687717、0.002563、0.000992、0.000602。 第 200 轮就掉到 0.0092。对比上一节的 0.6909 恒不动,差别是一眼能看出来的。 测试 1.0000、训练 1.0000。这个任务上它不但是学会了,而且一次没错。 遗忘门那一行最值得看:0.771、0.774、0.774、0.774、0.774、0.773、0.772、0.772。 它没有开到 1,也没有往 0 掉,而是稳稳挑了一个 0.773。 这说明 LSTM 不是靠「全开」记东西的。 它算出来的是「这批数据里衰减多少最划算」——8 步刚好够用,再多留反而是噪声。 最后两行对比:普通 RNN 退 8 步剩 0.000013,LSTM 连乘 8 个 0.773 剩 0.1276。差 10117 倍。 这个倍数不是 LSTM 的设计目标,是它的副产品。设计目标是「让衰减率可学」, 而可学的结果正好落在一个够用的值上。

30 秒实验

  1. 把遗忘门的偏置初始化从 1 改成 0,重跑同一个任务,看第 200 轮的损失有没有变化。
  2. 手算里把输入从 0.5 改成 −0.5,四道门里哪一个变化最大?先算再验。
  3. 如果把 c 的更新改成「c 等于 tanh(f 乘旧 c 加 i 乘 g)」,梯度会变成什么样?

常见误区

  1. 以为遗忘门要学会「开到 1」才能记住。实测它稳定在 0.773。它挑的是一个衰减率,不是全开。
  2. 以为 LSTM 只是「多了三道门」。Wh 从 8×8 变成 8×32, 是四倍,不是三倍——因为除了三道门还有一个候选 g。
  3. 以为 LSTM 能记住无限长。8 个 f 连乘还是 0.1276,16 步剩 0.016,32 步就剩 0.0003。
自测三问(点开对答案) 1. LSTM 靠什么绕开梯度消失? —— 靠一条只有加法和乘法的 cell 通道。 梯度沿它往回走时每步只乘一个遗忘门,不需要乘矩阵,也不需要乘 tanh 的导数。 2. 遗忘门、输入门、输出门各管什么? —— 遗忘门决定旧 cell 留多少;输入门决定新内容写多少(写什么由候选 g 给); 输出门决定这一刻从 cell 里拿出多少变成 h。 3. 为什么参数量是 RNN 的 3.45 倍而不是 3 倍? —— 隐层权重从 8×8 变成 8×32 正好四倍,但那只是隐层那一块; 输入和输出那两块没有变,所以总体比例落在 4 和 1 之间。

LSTM 好用,但四道门有点重。 6.3 的 GRU 把「遗忘」和「输入」合成一个更新门,参数降到 LSTM 的 76%。

相关笔记