第 4 章 1990—2014:序列建模——RNN、LSTM 与 GRU
学习目标
- 理解循环神经网络(RNN)如何处理序列
- 理解梯度消失问题,以及 LSTM(1997)、GRU(2014)为什么能缓解它
- 用 PyTorch 实现并对比 RNN / LSTM / GRU 三种循环单元
- 实现一个字符级语言模型
4.1 时代背景:从图像到序列
卷积网络解决了图像,但语言、语音、时间序列、DNA 都是序列:长度可变,顺序决定含义(「猫咬狗」和「狗咬猫」完全不同)。MLP 和 CNN 假设输入是定长的向量,天然不擅长序列。
1990 年,Jeffrey Elman 提出 简单循环网络(Simple RNN / Elman network);1997 年,Sepp Hochreiter 和 Jürgen Schmidhuber 提出 LSTM;2014 年,Cho 等人提出更精简的 GRU。这条线索一直铺到 2017 年 Transformer 接管序列建模(第 9 章)。
4.2 RNN:带记忆的前馈网络
循环神经网络(RNN)在每一个时间步做同一件事:把当前输入 xₜ 和上一步的隐藏状态 hₜ₋₁ 一起变换,得到新的隐藏状态 hₜ:
hₜ = tanh(Wₓₕ·xₜ + Wₕₕ·hₜ₋₁ + b)hₜ 就是这个网络的「记忆」:它携带了到目前为止看到的信息。PyTorch 里一个 RNN 层就是 nn.RNN(input_size, hidden_size, batch_first=True)。
用 RNN 做字符级语言模型:输入当前字符,预测下一个字符。给定「学而时习之」,模型应预测「不亦说乎」的「不」。训练数据是字符序列,损失用交叉熵。
examples/ch04_rnn_lstm_gru.py 用相同配置(词嵌入 32、隐藏 64)在《论语》语料上分别训练 RNN、LSTM、GRU:
RNN 参数量 10,734 最终 loss = 0.0047
LSTM 参数量 29,550 最终 loss = 0.0062
GRU 参数量 23,278 最终 loss = 0.0042在这个极短语料上三者都能学会。真正的差异在长序列上(见 4.3)。
4.3 梯度消失:RNN 的阿喀琉斯之踵
RNN 的梯度要沿时间反向传播(BPTT):误差从第 100 步传回第 1 步,要连续乘 99 次 Wₕₕ 的导数。如果 Wₕₕ 的特征值小于 1,梯度指数级衰减,几步之后梯度就是 0——梯度消失;大于 1 则爆炸——梯度爆炸。梯度消失意味着 RNN「记不住」远处信息,长句子的语义传不过来。
这与第 2 章的多层网络深层梯度消失是同一件事,只是 RNN 把「深」放在了时间维度上。
4.4 LSTM:给网络装上门
长短期记忆网络(LSTM)的核心是细胞状态 cₜ——一条贯穿时间的传送带,信息可以原样流过,不受逐时间步的连乘影响。围绕它有三个「门」:
- 遗忘门
fₜ:决定忘记多少旧细胞状态; - 输入门
iₜ:决定写入多少新信息; - 输出门
oₜ:决定输出多少细胞状态给隐藏状态。
更新公式(省略下标细节):
f = σ(Wf·[hₜ₋₁, xₜ])
i = σ(Wi·[hₜ₋₁, xₜ])
o = σ(Wo·[hₜ₋₁, xₜ])
c̃ = tanh(Wc·[hₜ₋₁, xₜ])
cₜ = f⊙cₜ₋₁ + i⊙c̃
hₜ = o⊙tanh(cₜ)关键在 cₜ = f⊙cₜ₋₁ + i⊙c̃:遗忘门接近 1 时,细胞状态几乎原样保留,梯度可以长距离传播。LSTM 因此能记住数百步前的信息,成为 1997 到 2017 年间序列建模的事实标准(机器翻译、语音识别、手写生成都靠它)。
4.5 GRU:精简版 LSTM
2014 年,Kyunghyun Cho 等人在机器翻译的序列到序列模型中提出 GRU(Gated Recurrent Unit),把三个门精简为两个:
- 更新门
z:合并遗忘与输入(决定保留多少旧状态、吸收多少新信息); - 重置门
r:决定忽略多少旧状态。
GRU 参数更少(约为 LSTM 的 3/4),训练更快,在很多任务上效果与 LSTM 相当。这使它成为资源受限场景的常见选择。
4.6 字符级语言模型:RNN 家族的实战
examples/ch04_rnn_lstm_gru.py 里的模型结构是语言模型的通用骨架:
def make_model(kind):
rnn_cls = {"rnn": nn.RNN, "lstm": nn.LSTM, "gru": nn.GRU}[kind]
return nn.Sequential(
nn.Embedding(len(chars), 32),
rnn_cls(32, 64, batch_first=True),
nn.Linear(64, len(chars)),
)Embedding 把字符 id 变成向量,循环单元逐字符处理,最后一层输出每个字符成为下一个字符的概率。训练时输入 xₜ、目标 xₜ₊₁,用交叉熵;生成时从任意字符开始,循环采样,把输出喂回输入(自回归生成)。
把语料换成更长文本、把训练步数加多,这个骨架就能「续写」出像模像样的文字。2010 年代的手机输入法、语音识别,底层就是这类模型。
完整代码
本章用到的完整示例代码:
examples/ch04_rnn_lstm_gru.py
"""第 4 章示例(1990—2014):RNN、LSTM 与 GRU 三种循环架构对比。
同一个字符级语言模型任务(预测《论语》下一个字),三种循环单元
用相同词嵌入、相同隐藏维度训练,比较收敛情况。
运行方式:
uv run python examples/ch04_rnn_lstm_gru.py
"""
import torch
from torch import nn
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
text = (
"学而时习之不亦说乎有朋自远方来不亦乐乎人不知而不愠不亦君子乎"
"学而不思则罔思而不学则殆温故而知新可以为师矣知之为知之不知为不知是知也"
"三人行必有我师焉择其善者而从之其不善者而改之"
)
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
data = torch.tensor([stoi[c] for c in text], device=device)
BLOCK = 16
def make_batches():
xs = [data[i:i + BLOCK] for i in range(0, len(data) - BLOCK, BLOCK)]
ys = [data[i + 1:i + BLOCK + 1] for i in range(0, len(data) - BLOCK, BLOCK)]
return [(x, y) for x, y in zip(xs, ys) if len(x) == BLOCK]
def make_model(kind):
"""按类型构造:rnn / lstm / gru 三种循环单元。"""
rnn_cls = {"rnn": nn.RNN, "lstm": nn.LSTM, "gru": nn.GRU}[kind]
return nn.Sequential(
nn.Embedding(len(chars), 32),
rnn_cls(32, 64, batch_first=True),
nn.Linear(64, len(chars)),
)
def train(model, steps=200):
opt = torch.optim.Adam(model.parameters(), lr=1e-2)
batches = make_batches()
for step in range(steps):
x, y = batches[step % len(batches)]
x = x.unsqueeze(0).to(device)
y = y.unsqueeze(0).to(device)
opt.zero_grad()
h, _ = model[1](model[0](x)) # 前向:embed → 循环单元
loss = nn.functional.cross_entropy(model[2](h).transpose(1, 2), y)
loss.backward()
opt.step()
return loss.item()
for kind in ["rnn", "lstm", "gru"]:
model = make_model(kind).to(device)
loss = train(model)
params = sum(p.numel() for p in model.parameters())
print(f"{kind.upper():4} 参数量 {params:>6,} 最终 loss = {loss:.4f}")动手实践
- 运行
examples/ch04_rnn_lstm_gru.py,把BLOCK = 16改为 64(更长的截断序列),观察三种循环单元 loss 差距是否变大。 - 把语料换成《道德经》开头 100 字,重新训练,比较三种模型的生成效果。
- 在字符级模型里把隐藏维度从 64 改成 16,观察参数量与 loss。
- 用
nn.RNN实现一个「求和」任务:输入一串 0/1,输出是否为 1 的个数 ≥ 3,观察 RNN 是否学得动。
常见错误
| 错误写法 | 现象 | 原因 |
|---|---|---|
nn.RNN 输出形状搞错 | Expected input batch_size 报错 | 输出是 (B, L, H),batch_first=True 时顺序为 (batch, seq, hidden) |
| 循环单元返回两个值只取一个 | TypeError: cannot unpack | RNN/LSTM/GRU 前向返回 (output, h_n),要写 h, _ = ... |
| LSTM 忘记初始化隐藏状态 | 结果不稳定 | 默认从 0 开始,通常可以;但要多层/定长时注意 |
| 字符级模型的目标错位 | loss 不降 | 输入是 xₜ,目标是 xₜ₊₁,必须错开一个位置 |
| 生成时把 logits 直接当概率 | 输出全是最大概率字符 | 用 softmax 后 multinomial 采样,或 argmax 做贪心 |
| 梯度爆炸 | loss 突然变成 inf/nan | 用梯度裁剪 nn.utils.clip_grad_norm_ |
章末练习
基础
- 写出 Elman RNN 的更新公式,并说明
hₜ为什么是记忆。 - LSTM 的三个门分别控制什么?
- GRU 相比 LSTM 少了什么、多了什么(参数层面)?
提高
- 解释为什么 BPTT 会产生梯度消失;用数值实验(计算 50 步后的梯度范数)验证 RNN vs LSTM 的差异。
- 用 LSTM 在「倒序数字」任务上训练(见第 8 章),比较它与第 8 章带注意力模型的效果。
- 在字符级模型上加入
nn.utils.clip_grad_norm_(model.parameters(), 1.0),对比梯度范数与 loss 曲线。
挑战
- 实现 LSTM 的数学公式(不用
nn.LSTM,用nn.Linear自己组合三个门),与官方实现比较输出是否一致。 - 用 GRU 做「奇偶计数」任务:输入二进制串,输出 1 的个数奇偶,改变序列长度测试泛化能力。
章末自测
- RNN 的隐藏状态
hₜ依赖哪些输入?- A. 只看
xₜ - B.
xₜ和hₜ₋₁ - C. 所有未来输入
- D. 只看
xₜ₋₁
- A. 只看
- LSTM 提出于哪一年?
- A. 1986
- B. 1997
- C. 2014
- D. 2017
- 梯度消失的后果是?
- A. 梯度爆炸
- B. 模型无法记住长距离信息
- C. 参数过多
- D. 训练变快
- LSTM 的细胞状态
cₜ相比隐藏状态的特点?- A. 维度更大
- B. 信息可以几乎无损地跨时间传递
- C. 不可训练
- D. 等于输入
- GRU 的门是?
- A. 遗忘门和输出门
- B. 更新门和重置门
- C. 输入门和输出门
- D. 只有遗忘门
- 字符级语言模型的训练目标(输入 x,目标 y)是?
- A. x→x
- B. xₜ→xₜ₊₁
- C. xₜ→yₜ(标签)
- D. 无监督
nn.LSTM(32, 64, batch_first=True)中 64 是?- A. 输入维度
- B. 隐藏维度
- C. 层数
- D. 词表大小
- BPTT 指?
- A. 反向传播的一次前向
- B. 沿时间维度的反向传播
- C. 批量训练
- D. 贝叶斯后验
- 缓解梯度爆炸的常用手段?
- A. 增加学习率
- B. 梯度裁剪
- C. 减少数据
- D. 使用 ReLU 到无穷
- 2014 年提出 GRU 的动机是?
- A. 比 RNN 更简单更稳定
- B. 处理图像
- C. 替代卷积
- D. 减少词表
