最近重新复习经典序列模型时,我发现不少资料把重点放在“门(gate)”的数量和复杂公式的罗列上,反而容易忽略这些设计背后的 trade-off。复习的时候,我更想把它们放回 architecture design 的角度看一遍。
比起数每个模型有几个门,我更想搞清楚一个问题:vanilla RNN 的 state update 有什么问题,LSTM 和 GRU 分别是怎么重新设计信息流的?
Vanilla RNN:问题出在哪里#
最朴素的 Vanilla RNN 的状态更新方程非常直观:
$$ h_t = \tanh(W_x x_t + W_h h_{t-1} + b) $$从公式里能看到几个关键特性:
- 历史信息全部被压缩在 \( h_t \) 中:无论是五步前还是五十步前的信息,都必须挤在这一个 hidden state 向量里。
- 每一步经历完全相同的状态转换(state transition):无论当前 timestep 遇到了什么输入,历史状态 \( h_{t-1} \) 都会无条件地与 \( W_h \) 相乘。
- 参数 \( W_h \) 跨 timestep 共享:这是循环神经网络的本质,但这也意味着网络对历史的“处理方式”是静态的。
- 缺乏显式的控制机制:vanilla RNN 没有任何机制来显式决定哪些历史应该保留、哪些应该忘记、或者当前时刻应该写入什么新信息。
这种机制在长序列反向传播(BPTT)时很容易导致梯度消失(gradient vanishing)或梯度爆炸(gradient exploding)。我们可以通过 Jacobian 矩阵简单感受一下:
$$ \frac{\partial h_t}{\partial h_{t-k}} = \prod_j \frac{\partial h_j}{\partial h_{j-1}} $$每一次状态传递都会连乘一次 \( W_h \) 和激活函数的导数。如果连乘的项大多小于 1,梯度很快就会衰减到零,导致模型根本无法学习到远距离的依赖关系。
LSTM:多出来的 cell state#
为了解决这个问题,LSTM 被提了出来。先看它维护的状态。Vanilla RNN 主要维护一个状态:
$$ h_t $$而 LSTM 维护了两个状态:
$$ c_t,\quad h_t $$我们可以暂时把 \( c_t \)(cell state)理解为更适合长期传播的 memory path,而把 \( h_t \) 理解为当前的 hidden 或 output state。需要提醒的是,“长期记忆”和“短期记忆”只是为了方便理解的比喻,并不是严谨的数学语义。
LSTM 核心的状态更新公式如下:
$$ c_t = f_t \odot c_{t-1} + i_t \odot \tilde c_t $$如果用人话来解释这个公式,其实非常符合直觉:
新的 memory = 留下来的旧信息 + 真正写进去的新信息
其中:
- \( f_t \)(forget gate):决定旧信息 \( c_{t-1} \) 保留多少;
- \( i_t \)(input gate):决定新信息写多少;
- \( \tilde c_t \)(candidate memory):准备写进去的新内容。
在计算出当前时刻的 cell state \( c_t \) 后,LSTM 还需要对外输出当前的 hidden state:
$$ h_t = o_t \odot \tanh(c_t) $$这里的 \( o_t \)(output gate)控制着内部的 \( c_t \) 有多少会被暴露为当前的 \( h_t \)。
重点解释:为什么“写什么”和“写多少”要拆开#
先仔细看输入阶段的两个变量是怎么生成的。
一个是真正的 gate:
$$ i_t = \sigma(W_i x_t + U_i h_{t-1} + b_i) $$另一个是 candidate memory,它并不是一个 gate,而是包含了实际内容:
$$ \tilde c_t = \tanh(W_c x_t + U_c h_{t-1} + b_c) $$我们可以这样理解它们的分工:
- \( \tilde c_t \) 决定了写什么。
- \( i_t \) 决定了写多少。
既然最后都是要把这两项做 element-wise 乘法(\( i_t \odot \tilde c_t \)),为什么不能直接用一个变量表示最终的 update,而非要拆开呢?
原理上当然可以合并。但 LSTM 选择拆开,这是一种有意的 parameterization 和 inductive bias:
content \(\times\) control
它把“候选内容生成”和“是否写入、写多少”显式解耦了。网络可以独立学习如何产生有用的特征(content),以及在当前上下文中这些特征是否有用(control)。
LSTM 为什么有利于梯度传播#
结合前面的 Jacobian 和 BPTT,我们可以很简洁地解释 LSTM 的优势。沿着 cell state 的主路径来看:
$$ c_t = f_t \odot c_{t-1} + \cdots $$在求导时,我们可以近似认为:
$$ \frac{\partial c_t}{\partial c_{t-1}} \approx f_t $$当跨越多个 timestep 时:
$$ \frac{\partial c_t}{\partial c_{t-k}} \approx \prod_j f_j $$当模型学到了 \( f_j \approx 1 \)(即网络决定保留历史记忆)时,梯度在反向传播时就相当于乘上了一连串的 1。这意味着,cell state 提供了一条比 vanilla RNN 更容易保持梯度的路径。
当然,这不能声称 LSTM “彻底解决”了梯度消失问题。更准确的说法是,它通过引入线性相加的 memory path 和显式的保留机制,显著改善了长期信息和梯度的传播条件。
GRU:不要只说“简化版 LSTM”#
到了 GRU,网络不再维护独立的 cell state:
$$ c_t $$它退回到了像 vanilla RNN 那样,只有:
$$ h_t $$因此,在 GRU 中不存在 \( c_t \to h_t \) 的这一层转换,自然也就没有了 LSTM 中用来控制这层暴露的 output gate。但这绝不仅仅是“做减法”那么简单。
Update gate:把 forget 和 input 做了耦合#
GRU 引入了 update gate \( z_t \),它的状态更新方程长这样:
$$ h_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde h_t $$仔细看这个公式,它可以被理解为在做 interpolation(插值):
旧状态保留多少 vs 新状态采用多少
在 LSTM 中,\( f_t \) 和 \( i_t \) 是独立的,你可以既保留旧信息又写入新信息。而在 GRU 中,\( 1-z_t \) 和 \( z_t \) 天然满足相加等于 1:
$$ (1 - z_t) + z_t = 1 $$这意味着,GRU 用更强的结构约束换来了更简单的 state update。当更新门倾向于保留历史时,新信息的写入就会被相应抑制。需要提醒的是,不同资料对 \( z_t \) 的定义可能反过来(例如用 \( z_t \) 乘历史,\( 1-z_t \) 乘候选),因此不要死记符号,看它到底乘的是 old state 还是 candidate 即可。
Reset gate:重点和 vanilla RNN 对比#
先回到 Vanilla RNN 生成新状态的过程:
$$ \tilde h_t = \tanh(W_x x_t + W_h h_{t-1} + b) $$每一步生成新状态时,历史状态 \( h_{t-1} \) 都是无条件、完整地参与计算的。
而 GRU 引入了 reset gate \( r_t \):
$$ r_t = \sigma(W_r [x_t, h_{t-1}] + b_r) $$然后在生成 candidate 时:
$$ \tilde h_t = \tanh(W_x x_t + W_h (r_t \odot h_{t-1})) $$显然,reset gate 控制的是:生成 candidate 时,要参考多少历史。
这里有个容易卡住的问题:\( h_{t-1} \) 参与多少,难道不能直接由 \( W_h \) 的权重来决定吗?
解释这个问题的关键在于区分参数的静态与动态。\( W_h \) 是跨 timestep 共享的静态模型参数:
$$ W_h^{(1)} = W_h^{(2)} = \cdots = W_h^{(T)} $$它确实能学习到“历史通常应该如何被处理”,但它无法针对当前 timestep 的具体输入,动态决定“这一次我要不要依赖历史”。
而 \( r_t = \sigma(W_r [x_t, h_{t-1}]) \) 中,虽然 \( W_r \) 也是共享参数,但输入 \( x_t \) 和状态 \( h_{t-1} \) 是每一步都在变的。因此:
$$ r_1, r_2, \ldots, r_t $$是可以动态变化的。这里的设计哲学是:
parameters 是 shared 的,但 gate value 是 input-dependent / state-dependent 的。
并且,这些 gate 的输出并不是一个 scalar,而是 hidden_size 维度的向量。这意味着模型实际上可以在**每一维(per-dimension)**上,动态地控制信息流的开与关。
清楚区分 GRU 的两个 gate#
GRU 的两个门可以这样区分:
- reset gate (\( r_t \)):新东西怎么生成,要参考多少历史?
- update gate (\( z_t \)):新东西已经生成了,最终采用多少?
补一个 ML Infra 视角的小细节#
最后补一个工程实现上的细节。LSTM 要计算 4 个不同的项(3 个门加上 1 个 candidate),数学上可以写成四个独立的线性变换:
14 × Linear(input_size + hidden_size, hidden_size)但在工程实现(如 PyTorch 或 CUDA 算子)中,为了最大化 GPU 上 GEMM(通用矩阵乘法)的效率,通常会把这四组完全独立的参数拼成一个大矩阵一次性计算,算完之后再通过 chunk 沿 feature 维度拆开。
PyTorch 里就是这种写法:
1import torch
2import torch.nn as nn
3
4class LSTMCellDemo(nn.Module):
5 def __init__(self, input_size, hidden_size):
6 super().__init__()
7 self.hidden_size = hidden_size
8
9 # 工程优化:将 4 个 gate 的权重合并成一个大矩阵,提升 GPU GEMM 效率
10 self.weight_ih = nn.Linear(input_size, 4 * hidden_size)
11 self.weight_hh = nn.Linear(hidden_size, 4 * hidden_size)
12
13 def forward(self, x, state):
14 h_prev, c_prev = state
15
16 # 1. 大矩阵乘法:一次性计算所有 gate 的 pre-activation
17 gates = self.weight_ih(x) + self.weight_hh(h_prev)
18
19 # 2. 沿 feature 维度拆分为 4 份
20 ingate, forgetgate, cellgate, outgate = gates.chunk(4, dim=1)
21
22 # 3. 分别应用激活函数 (显式的 Control 与 Content 生成)
23 i_t = torch.sigmoid(ingate) # input gate: 写多少
24 f_t = torch.sigmoid(forgetgate) # forget gate: 留多少
25 c_tilde = torch.tanh(cellgate) # candidate: 写什么
26 o_t = torch.sigmoid(outgate) # output gate: 暴露多少
27
28 # 4. 核心状态更新路径
29 c_t = (f_t * c_prev) + (i_t * c_tilde)
30 h_t = o_t * torch.tanh(c_t)
31
32 return h_t, c_t四个线性变换能合并成一次矩阵乘,是因为它们在计算前彼此毫无依赖。这也从工程侧印证了前面的 content / control 解耦。
总结#
回头看这三个模型,演进的主线其实很清楚:
- Vanilla RNN 的问题不止是梯度消失。它的 state 每一步都会被无条件、静态地重新计算。
- LSTM 增加了独立的 memory path,并开始显式控制信息的保留、写入和暴露。
candidate × gate的设计体现了 content 与 control 的解耦。- GRU 的 update gate 通过插值(interpolation)的方式,把 old state 和 new state 的控制耦合到了一起。
- GRU reset gate 提供了 per-step、per-sample、per-dimension 的动态历史依赖控制。
- 从 RNN 到 LSTM 再到 GRU,演进路径是:
1固定的 state transition
2 ↓
3显式的信息流控制
4 ↓
5更紧凑的 gated state updategate 的本质不是几个 sigmoid 激活函数,而是把 recurrent state 的信息流变成了可学习的动态控制机制。