D2L 循环神经网络:从序列数据与 BPTT 到 GRU、LSTM、深层和双向建模
Table of contents
- 本篇逻辑主线
- 1. 序列建模:一步预测容易,多步外推困难
- 2. 从文本到训练语料:词元、词表和 n 元语法
- 3. 长序列怎样组成 batch
- 4. 普通 RNN:同一状态转移在时间上重复使用
- 5. 输出、标签、交叉熵与困惑度
- 6. BPTT:状态值可以跨 batch,计算图不能无限跨 batch
- 7. GRU:候选状态怎样读历史,最终状态怎样写更新
- 8. LSTM:长期记忆的保留、准备、写入和读取
- 9. 一套统一的 PyTorch 语言模型
- 10. 深层循环网络:同时沿时间和层传播
- 11. 双向循环网络:未来可见时才成立
- 12. 怎样选择 RNN、GRU、LSTM、深层和双向结构
- 13. 常见误区与调试顺序
- 本篇知识链总结
- 系列导航
- 对应章节与参考资料
本篇逻辑主线
图像模型通常把一个样本一次性送入网络;序列模型却必须回答两个额外问题:当前输入之前发生了什么,以及这段历史应该保留多久。语言模型把联合概率按时间拆成一连串“预测下一个词元”的条件概率,RNN 再用隐状态压缩此前信息。这样做带来了贯穿本篇的三组矛盾:序列很长但计算图不能无限增长,参数需要跨时间共享但数据规律未必完全平稳,历史既要长期保留又要在边界处及时覆盖。
本文沿着下面的因果链整理 D2L 的循环神经网络基础和现代循环网络:
序列预测与因果方向
↓
文本 → 词元 → 词表 → 语料索引
↓
语言模型、n 元语法、稀疏计数与平滑
↓
随机采样 / 顺序分区,X 与 Y 错开一个时间步
↓
RNN 隐状态、时间参数共享、字符级语言模型
↓
BPTT、detach、梯度裁剪、困惑度
↓
GRU:选择性读取历史 + 选择性更新状态
↓
LSTM:长期记忆的保留、准备、写入与读取
↓
深层 RNN 增加表示深度,双向 RNN 引入未来上下文
真正需要贯穿始终检查的是:当前 tensor 的每一维是什么,当前输出允许看见哪些时间位置,状态值与状态的梯度历史是否都要跨 batch 传递。
对应 Notebook
| 主题 | Notebook | 本文位置 |
|---|---|---|
| 序列模型与多步预测 | sequence.ipynb |
第 1 节 |
| 文本读取、词元化与词表 | text-preprocessing.ipynb |
第 2 节 |
| 语言模型、n 元语法与序列采样 | language-models-and-dataset.ipynb |
第 2~3 节 |
| RNN 原理 | rnn.ipynb |
第 4 节 |
| 从零实现与训练 | rnn-scratch.ipynb |
第 4~6 节 |
| PyTorch 简洁实现 | rnn-concise.ipynb |
第 6 节 |
| 通过时间反向传播 | bptt.ipynb |
第 6 节 |
| GRU | gru.ipynb |
第 7 节 |
| LSTM | lstm.ipynb |
第 8 节 |
| 深层循环网络 | deep-rnn.ipynb |
第 10 节 |
| 双向循环网络 | bi-rnn.ipynb |
第 11 节 |
1. 序列建模:一步预测容易,多步外推困难
给定序列 $x_1,\ldots,x_T$,联合概率总能按时间方向分解:
\[P(x_1,\ldots,x_T)=\prod_{t=1}^{T}P(x_t\mid x_1,\ldots,x_{t-1}).\]自回归模型直接用过去的观测预测未来。若只保留最近 $\tau$ 个值,可以写成
\[\hat x_t=f(x_{t-\tau},\ldots,x_{t-1}).\]训练时的单步预测总是使用真实历史,误差不会进入下一次输入;多步预测则必须把自己的输出重新作为输入:
\[\hat x_{t+2}=f(x_{t-\tau+2},\ldots,x_t,\hat x_{t+1}).\]因此预测步数越远,分布偏移和误差累积越严重。训练误差低只说明模型会在“真实历史附近”做局部预测,并不保证它在自己的预测轨迹上仍然稳定。时间序列划分也必须尊重时间方向:未来数据不能进入训练当前预测的特征、归一化统计或超参数选择。
固定窗口把历史长度写死在输入 shape 中。隐状态模型则递归更新
\[h_t=f(x_t,h_{t-1}),\]用固定长度的 $h_t$ 概括可变长度历史。这是 RNN 的出发点,也是它的限制:历史不会消失在依赖关系上,却可能在有限容量和反复非线性变换中逐渐丢失。
2. 从文本到训练语料:词元、词表和 n 元语法
2.1 词元化与词频
文本首先被拆成字符、单词或子词。collections.Counter(tokens) 统计一维词元列表中每个词元的出现次数:
import collections
tokens = ["the", "time", "the", "machine"]
counter = collections.Counter(tokens)
print(counter) # Counter({'the': 2, 'time': 1, 'machine': 1})
print(counter.most_common()) # 按频率从高到低排列
如果输入是“句子列表的列表”,需要先展平;内部列表不可哈希,不能直接作为 Counter 的键:
lines = [["the", "time"], ["the", "machine"]]
tokens = [token for line in lines for token in line]
counter = collections.Counter(tokens)
词表维护两个方向的映射:
token_to_idx:词元 → 整数编号
idx_to_token:整数编号 → 词元
常见的 __getitem__ 写法同时支持单个词元和词元列表:
def __getitem__(self, tokens):
if not isinstance(tokens, (list, tuple)):
return self.token_to_idx.get(tokens, self.unk)
return [self[token] for token in tokens]
于是 vocab['time'] 返回一个编号,vocab[['time', 'machine']] 则递归返回编号列表。方括号访问会自动调用 __getitem__,未知词通过字典 get 的默认值映射到 <unk>。
2.2 n 元语法只是相邻切片的配对
二元词组可以由两个错位切片组成:
corpus = ["i", "love", "deep", "learning"]
bigram_tokens = list(zip(corpus[:-1], corpus[1:]))
# [('i', 'love'), ('love', 'deep'), ('deep', 'learning')]
三元词组同理:
trigram_tokens = list(zip(
corpus[:-2], corpus[1:-1], corpus[2:]
))
n 元语言模型用最近 $n-1$ 个词元近似完整历史,例如
\[P(x_t\mid x_1,\ldots,x_{t-1}) \approx P(x_t\mid x_{t-n+1},\ldots,x_{t-1}).\]| 窗口变长能表达更多局部结构,但可能组合数按 $ | V | ^n$ 增长,而真实语料中的绝大多数组合很少出现。齐普夫定律又使词频呈明显长尾:少数词极常见,大量词和词组只出现一两次。 |
2.3 拉普拉斯平滑解决的是零频,不是语义
若直接按计数估计
\[\hat P(w\mid h)=\frac{n(h,w)}{n(h)},\]训练集中未出现的合理词组会得到零概率,整条测试序列的乘积也随之变成零。加一平滑改为
\[\hat P(w\mid h)=\frac{n(h,w)+1}{n(h)+|V|},\]从高频事件拿出少量概率质量,分给低频和未观察事件。“处理结构丰富而频率不足的低频词组”指的是缓解这种零频和数据稀疏,而不是理解了词义。可能的 n 元组极多,统一加常数会给大量不合理组合分配过多概率,所以真实语言模型更常使用回退、插值、Kneser–Ney 或神经表示。
3. 长序列怎样组成 batch
3.1 为什么标签 Y 也是一个序列
语言模型在每个时间步都预测下一个词元:
X = [1, 2, 3, 4, 5]
Y = [2, 3, 4, 5, 6]
这不是只做五次互不相关的“单值到单值”预测。RNN 的隐藏状态逐步累积历史,所以五个监督信号分别表示:
[1] → 2
[1, 2] → 3
[1, 2, 3] → 4
[1, 2, 3, 4] → 5
[1, 2, 3, 4, 5] → 6
一次前向传播因此能利用所有位置,而不是读完整段后只得到一个训练目标。若下一段从 [6,7,8,...] 开始,顺序采样会把上一段末尾的隐藏状态继续传入;此时用 6 预测 7 仍可间接利用更早的 5。随机采样的相邻 batch 没有原序列连续关系,所以必须重置状态。
3.2 顺序分区并不会枚举所有滑动窗口
load_data_time_machine(batch_size, num_steps) 默认使用顺序分区:选择一个随机偏移,把保留下来的语料分成 batch_size 条长行,再沿列方向每次截取 num_steps。一个 epoch 会处理所保留的词元,却不会枚举所有可能的重叠窗口;开头、结尾和不足完整 batch 的部分还会被丢弃。
下面给出核心实现,返回的 X,Y shape 都是 [B,T]:
import random
import torch
def seq_data_iter_sequential(corpus, batch_size, num_steps):
offset = random.randint(0, num_steps)
num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
Xs = torch.tensor(corpus[offset:offset + num_tokens])
Ys = torch.tensor(corpus[offset + 1:offset + 1 + num_tokens])
Xs = Xs.reshape(batch_size, -1)
Ys = Ys.reshape(batch_size, -1)
num_batches = Xs.shape[1] // num_steps
for start in range(0, num_batches * num_steps, num_steps):
yield (Xs[:, start:start + num_steps],
Ys[:, start:start + num_steps])
随机偏移改变固定切分边界,提高跨 epoch 的覆盖率,但并不产生完美均匀的所有窗口分布。若确实需要所有合法窗口,可以使用步幅为 1 的重叠滑动窗口:
def all_subsequences(corpus, num_steps):
for start in range(len(corpus) - num_steps):
X = corpus[start:start + num_steps]
Y = corpus[start + 1:start + num_steps + 1]
yield X, Y
步幅小于 num_steps 时,相邻窗口会重复计算相同词元。它增加的是窗口起点和上下文组合的覆盖,不是单个样本的上下文长度。若步幅为 stride>1,可以让偏移量在 0,...,stride-1 间轮换;步幅为 1 时所有起点已被覆盖,无需额外随机偏移。
4. 普通 RNN:同一状态转移在时间上重复使用
设当前输入、上一隐状态分别为 $X_t\in\mathbb R^{B\times d}$、$H_{t-1}\in\mathbb R^{B\times h}$,普通 RNN 为
\[H_t=\tanh(X_tW_{xh}+H_{t-1}W_{hh}+b_h),\] \[O_t=H_tW_{hq}+b_q.\]每个时间步都使用同一组 $W_{xh},W_{hh},W_{hq}$。沿时间展开看起来像很多层,但这些层共享参数,因此参数量不随时间步数 $T$ 增加;计算量和训练时保存的激活却会随 $T$ 增加。
参数共享表达了“不同时间位置大体遵循同一种转移规律”的归纳偏置。它不意味着每个位置输出相同,因为 $X_t$ 和 $H_{t-1}$ 不同。同一词元在不同上下文会产生不同状态。若市场阶段、季节或绝对位置真的改变了生成机制,共享参数可能在不同片段间折中;可以加入时间或阶段特征,使用更强门控结构、混合专家或分段模型。
4.1 独热编码与嵌入查询
嵌入表示用固定长度实数向量表示离散词元。若嵌入表 $E\in\mathbb R^{V\times d_e}$,词元 $i$ 的表示就是第 $i$ 行 $E[i,:]$。独热向量 $e_i$ 与矩阵相乘恰好选择对应行:
\[e_i^TW_{xh}=W_{xh}[i,:].\]因此 one_hot(token) @ W_xh 在计算效果上就是嵌入查表;显式 nn.Embedding 避免构造稀疏的 $V$ 维张量,并允许嵌入维度与隐藏维度分离,但不会仅因换了 API 就自动提高准确率。
4.2 从零实现时每个 X 是一个时间步的整个 batch
原始 X 为 [B,T],转置并独热编码后是 [T,B,V]。因此下面循环中的 X_t 不是整条序列,而是当前时间步上所有 $B$ 个样本:
import torch.nn.functional as F
def rnn_forward(inputs, state, params):
# inputs: [T, B, V];H: [B, H]
W_xh, W_hh, b_h, W_hq, b_q = params
H, = state
outputs = []
for X_t in inputs: # X_t: [B, V]
H = torch.tanh(X_t @ W_xh + H @ W_hh + b_h)
Y_t = H @ W_hq + b_q # [B, V]
outputs.append(Y_t)
# 时间优先地拼成 [T*B, V],只返回最后的H供下一段使用
return torch.cat(outputs, dim=0), (H,)
inputs = F.one_hot(X.T, num_classes=vocab_size).float()
logits, state = rnn_forward(inputs, state, params)
H 的每一行是一个样本的状态,矩阵乘法只是并行处理 batch,不会混合不同行。每个时间步都要预测下一个词元,所以所有 Y_t 都放入 outputs;隐藏状态本身则不断覆盖,最终状态传给下一段。
5. 输出、标签、交叉熵与困惑度
设 B=2,T=3:
X = [[a,b,c], [u,v,w]]
Y = [[b,c,d], [v,w,x]]
RNN 按时间步拼接输出,顺序是 [b,v,c,w,d,x] 对应的六行 logits,所以:
y = Y.T.reshape(-1) # [b,v,c,w,d,x]
标签必须先从 [B,T] 转为 [T,B] 再展平,才能与 torch.cat(outputs, dim=0) 的时间优先顺序一致。最终 shape 为:
logits: [T*B, V]
targets: [T*B]
它们故意不完全相同。多分类交叉熵要求每个样本提供 $V$ 个类别分数,而标签只保存一个正确类别索引:
\[\ell_i=-\log\frac{\exp(\text{logits}_{i,y_i})} {\sum_{j=1}^{V}\exp(\text{logits}_{i,j})}.\]PyTorch 的 CrossEntropyLoss 内部组合了 log_softmax 和正确类别索引选择,不需要把标签显式转成 [T*B,V] 的独热矩阵。
平均交叉熵为
\[L=-\frac1N\sum_{i=1}^{N}\log p_i,\]困惑度定义为
\[\operatorname{PPL}=\exp(L) =\left(\prod_{i=1}^{N}\frac1{p_i}\right)^{1/N}.\]指数来自对数概率的反变换,不是因为标签只有 0 和 1。若模型每次都给正确字符概率 $1/V$,困惑度就是 $V$;完美预测时困惑度为 1。训练困惑度接近 1 也可能只是记住了很小的语料,模型选择仍应查看按时间划分的验证集。
6. BPTT:状态值可以跨 batch,计算图不能无限跨 batch
6.1 为什么只需要 detach(state)
顺序分区中,上一批的最终状态是下一批唯一复用的中间结果:
batch 1:X1 → H1 → loss1
↓ 保留数值
batch 2:X2 + H1 → H2 → loss2
若不分离,loss2.backward() 会继续穿过 H1 回到 batch 1,计算图随 batch 不断增长;普通代码还会遇到“再次通过已释放计算图反向传播”或参数原地更新的版本错误。detach 保留状态值但切断此前的梯度历史,这正是截断的通过时间反向传播:
def detach_state(state):
if isinstance(state, tuple): # LSTM: (H, C)
return tuple(s.detach() for s in state)
return state.detach() # RNN / GRU: H
其他量不需要分离:X,Y 默认不求梯度;当前 logits,loss 不传给下一批;模型参数必须保留梯度,否则不能训练,只需要在每轮更新前清空 .grad。
从零实现为了统一接口,常把单个状态写成 (H,),其外层是 tuple、内部才是二维 Tensor。PyTorch 内置单层单向 nn.RNN/nn.GRU 返回三维状态 [1,B,H],nn.LSTM 返回两个三维张量 (H,C)。第一维统一表示 num_layers * num_directions;detach 对二维或三维都一样有效。
6.2 梯度为什么消失或爆炸
跨越多个时间步的梯度含有状态雅可比的连乘:
\[\frac{\partial H_t}{\partial H_k} =\prod_{j=k+1}^{t}\frac{\partial H_j}{\partial H_{j-1}}.\]若这些变换持续缩小向量,梯度指数趋近于 0,早期词元难以学习;若持续放大,梯度会突然爆炸。完整 BPTT 还需要保存整条序列的激活,时间和内存代价都很高。固定长度截断带来有偏但更稳定的梯度估计,实践中通常比无限反向传播更可控。
梯度裁剪限制整体范数:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
它能阻止一次巨大更新破坏参数,却不能恢复已经接近零的梯度。GRU、LSTM 的门控和近似恒等通路才是缓解长期梯度消失的结构性方法。
6.3 一个统一的训练 epoch
下面的训练函数同时适用于内置 RNN、GRU 和 LSTM 包装模型:
import math
from torch import nn
def train_epoch(model, train_iter, optimizer, device,
use_random_iter=False):
model.train()
criterion = nn.CrossEntropyLoss()
state = None
loss_sum = token_count = 0
for X, Y in train_iter: # X,Y: [B,T]
X, Y = X.to(device), Y.to(device)
if state is None or use_random_iter:
state = None # 内置循环层会创建零状态
else:
state = detach_state(state) # 保留值,截断跨batch梯度
targets = Y.T.reshape(-1) # [T*B]
logits, state = model(X, state) # [T*B,V]
loss = criterion(logits, targets)
optimizer.zero_grad(set_to_none=True)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
n = targets.numel()
loss_sum += loss.item() * n
token_count += n
return math.exp(loss_sum / token_count)
随机采样的相邻 batch 不连续,所以每批都把 state=None;顺序分区才保留状态值并 detach。
7. GRU:候选状态怎样读历史,最终状态怎样写更新
GRU 先计算重置门与更新门:
\[R_t=\sigma(X_tW_{xr}+H_{t-1}W_{hr}+b_r),\] \[Z_t=\sigma(X_tW_{xz}+H_{t-1}W_{hz}+b_z).\]候选状态和最终状态为
\[\tilde H_t=\tanh\left(X_tW_{xh}+(R_t\odot H_{t-1})W_{hh}+b_h\right),\] \[H_t=Z_t\odot H_{t-1}+(1-Z_t)\odot\tilde H_t.\]两个门都涉及过去,但作用位置不同:
| 门 | 控制的问题 | 接近 0 | 接近 1 |
|---|---|---|---|
| 重置门 $R_t$ | 构造候选内容时,读取多少旧记忆? | 候选主要依赖当前输入 | 候选结合完整旧状态 |
| 更新门 $Z_t$ | 候选算好后,最终写入多少新内容? | 使用候选状态 | 直接保留旧状态 |
重置门不是旧状态在最终结果中的混合比例。即使 $R_t=0$,只要 $Z_t=1$,仍有 $H_t=H_{t-1}$:候选内容被重建了,但根本没有写入。只有 $R_t=0,Z_t=0$ 时,模型才根据当前输入重建最终状态。
更新门还提供从 $H_{t-1}$ 到 $H_t$ 的加法直通路径。其导数包含
\[\frac{\partial L}{\partial H_{t-1}} =Z_t\odot\frac{\partial L}{\partial H_t}+\text{其他路径的梯度项}.\]当需要长期记忆的维度上 $Z_t\approx1$ 时,梯度近似原样传回,而不必在每步都乘容易收缩的 tanh 导数和循环矩阵。跨多步的直接路径系数约为 $\prod_j Z_j$。这只能缓解而不能保证消除梯度消失或爆炸。
PyTorch 简洁实现只需替换循环层:
gru = nn.GRU(
input_size=vocab_size,
hidden_size=256,
num_layers=1,
)
输入是 [T,B,V],输出是每个时间步的 [T,B,H],最终状态为 [num_layers,B,H]。GRU 比普通 RNN 多计算两个门,参数与主要矩阵乘法约为普通循环部分的三倍,但通常更容易学习长期依赖。
8. LSTM:长期记忆的保留、准备、写入和读取
LSTM 把内部长期记忆 $C_t$ 与对外可见状态 $H_t$ 分开。三个 sigmoid 门并行地由 $X_t,H_{t-1}$ 计算:
\[\begin{aligned} I_t&=\sigma(X_tW_{xi}+H_{t-1}W_{hi}+b_i),\\ F_t&=\sigma(X_tW_{xf}+H_{t-1}W_{hf}+b_f),\\ O_t&=\sigma(X_tW_{xo}+H_{t-1}W_{ho}+b_o). \end{aligned}\]候选记忆也是并行分支:
\[\tilde C_t=\tanh(X_tW_{xc}+H_{t-1}W_{hc}+b_c).\]输入门并不先参与候选记忆计算。“输入门”指它控制候选内容进入记忆元,而不是控制 $X_t$ 是否进入候选分支。二者计算完成后才组合:
\[C_t=F_t\odot C_{t-1}+I_t\odot\tilde C_t,\] \[H_t=O_t\odot\tanh(C_t).\]| 量 | 理论意义 |
|---|---|
| 遗忘门 $F_t$ | 旧记忆保留多少 |
| 候选记忆 $\tilde C_t$ | 准备写入什么具体内容;它不是比例门 |
| 输入门 $I_t$ | 候选内容实际写入多少 |
| 记忆元 $C_t$ | 更新后的内部长期存储 |
| 输出门 $O_t$ | 当前记忆对外读取多少 |
| 隐状态 $H_t$ | 当前对输出层和下一步门控可见的工作状态 |
典型组合很直观:$F=1,I=0$ 保持旧记忆;$F=0,I=1$ 用候选内容替换旧记忆;$O=0$ 表示“记住但暂不输出”,并不删除 $C_t$。
候选 $\tilde C_t$ 虽被限制在 $(-1,1)$,记忆元 $C_t$ 却是跨时间加法累积的结果,并不保证仍在该区间。输出前再次使用 tanh(C_t),一方面把对外状态限制在稳定范围,另一方面保留 $C_t$ 内部较宽的累积动态;输出门再选择暴露哪些维度。
lstm = nn.LSTM(
input_size=vocab_size,
hidden_size=256,
num_layers=1,
)
outputs, state = lstm(inputs)
H, C = state
# outputs: [T,B,H]
# H, C: [num_layers,B,H]
LSTM 有输入、遗忘、输出和候选四组变换,循环部分的计算和参数量约为普通 RNN 的四倍。它提供更明确的长期存储和读取控制,但不意味着在所有数据集上必然优于更简单的 GRU。
9. 一套统一的 PyTorch 语言模型
RNN、GRU 和 LSTM 可以共用同一包装层。输入仍使用字符独热编码,以便与 D2L 从零实现保持一致:
import torch
from torch import nn
import torch.nn.functional as F
class RecurrentLanguageModel(nn.Module):
def __init__(self, vocab_size, hidden_size=256,
cell="gru", num_layers=1):
super().__init__()
cells = {
"rnn": nn.RNN,
"gru": nn.GRU,
"lstm": nn.LSTM,
}
if cell not in cells:
raise ValueError(f"unknown cell: {cell}")
self.vocab_size = vocab_size
self.rnn = cells[cell](
input_size=vocab_size,
hidden_size=hidden_size,
num_layers=num_layers,
)
self.proj = nn.Linear(hidden_size, vocab_size)
def forward(self, X, state=None):
# X: [B,T] → one_hot: [T,B,V]
inputs = F.one_hot(
X.T, num_classes=self.vocab_size
).float()
outputs, state = self.rnn(inputs, state) # [T,B,H]
logits = self.proj(outputs.reshape(-1, outputs.shape[-1]))
return logits, state # [T*B,V]
训练时用第 6 节的 train_epoch。生成时先用真实前缀预热状态,再把每次采样结果反馈给模型:
@torch.no_grad()
def generate(prefix, num_preds, model, vocab, device, alpha=1.0):
if not prefix or alpha <= 0:
raise ValueError("prefix不能为空且alpha必须为正")
model.eval()
state = None
outputs = [vocab[prefix[0]]]
def last_input():
return torch.tensor(
[[outputs[-1]]], device=device
)
# 输入真实前缀,但不把模型预测当作前缀内容
for token in prefix[1:]:
_, state = model(last_input(), state)
outputs.append(vocab[token])
for _ in range(num_preds):
logits, state = model(last_input(), state)
# q_i ∝ p_i**alpha,等价于softmax(alpha * logits)
probs = torch.softmax(alpha * logits[-1], dim=0)
next_token = torch.multinomial(probs, 1).item()
outputs.append(next_token)
return "".join(vocab.idx_to_token[i] for i in outputs)
alpha=1 按模型原分布采样;alpha>1 使分布更尖锐、更保守;0<alpha<1 增加多样性和错误概率;alpha→∞ 接近 argmax。训练时输入真实上一词元,生成时输入模型自己的结果,所以自回归生成仍会累积错误。
10. 深层循环网络:同时沿时间和层传播
多层 RNN 在每个时间步把第 $l-1$ 层的输出送入第 $l$ 层,同时每一层都把自己的状态送到下一时间步:
\[H_t^{(l)}=\phi_l\left( H_t^{(l-1)}W_{xh}^{(l)} +H_{t-1}^{(l)}W_{hh}^{(l)}+b_h^{(l)} \right),\]其中 $H_t^{(0)}=X_t$。依赖方向可以写成:
时间方向:H[t-1,l] → H[t,l]
层间方向:H[t,l-1] → H[t,l]
增加层数提升的是每个时间步的表示深度,并不自动延长模型能够稳定记忆的时间距离。更深网络还增加参数、激活内存和梯度路径,通常需要 dropout、谨慎初始化和更多调参。
deep_lstm = nn.LSTM(
input_size=vocab_size,
hidden_size=256,
num_layers=2,
dropout=0.1, # 作用于层与层之间;只有num_layers>1时有效
)
两层单向 LSTM 的 H,C shape 都是 [2,B,H]。从零实现时应为每一层维护独立状态:先在时间步 $t$ 更新第一层,再把其输出送入第二层;不能让两层错误共享同一个 $W_{hh}$ 或状态张量。
11. 双向循环网络:未来可见时才成立
双向 RNN 在同一序列上运行两条递归:
\[\overrightarrow H_t=f(X_t,\overrightarrow H_{t-1}), \qquad \overleftarrow H_t=g(X_t,\overleftarrow H_{t+1}).\]第 $t$ 个位置的表示拼接两个方向:
\[H_t=[\overrightarrow H_t;\overleftarrow H_t]\in\mathbb R^{B\times2h}.\]encoder = nn.GRU(
input_size=embedding_size,
hidden_size=hidden_size,
num_layers=2,
bidirectional=True,
)
outputs, state = encoder(embedded_tokens)
# outputs: [T,B,2H]
# state: [2 * num_layers,B,H]
它适合整段输入已经可用的编码任务,例如文本分类、命名实体识别、缺词填充或机器翻译编码器。它不适合自回归“预测下一个词元”:训练第 $t$ 个输出时,反向状态已经看见 $x_{t+1}$ 及更后面的真实词元,标签发生泄漏;实际生成时未来不存在,训练与推理条件不一致。合理的训练困惑度也不能证明这种错误用法有效。
双向与深层是两个独立轴:num_layers=2,bidirectional=True 表示每层都有前后两个方向,状态第一维为 $2\times2=4$。方向数翻倍会增加循环计算、状态和输出通道;下一层的 input_size 也必须接收上一层拼接后的 $2H$ 特征,内置 API 会自动处理。
12. 怎样选择 RNN、GRU、LSTM、深层和双向结构
| 需求 | 合适起点 | 主要理由 | 主要代价或风险 |
|---|---|---|---|
| 教学、小数据、短期依赖 | 单层 RNN | 结构最简单、计算最少 | 长期梯度与记忆较弱 |
| 需要门控但重视简洁和速度 | GRU | 更新门提供长期直通路径,状态只有 $H$ | 循环计算约为 RNN 三倍 |
| 需要显式区分内部记忆与对外状态 | LSTM | $C$ 与 $H$ 分离,读写控制最完整 | 四组变换,参数和计算最多 |
| 单层容量不足 | 深层 GRU/LSTM | 增加每个时间步的表示深度 | 更慢、更耗内存、更难调参 |
| 当前位置允许使用完整左右上下文 | 双向 GRU/LSTM | 同时编码过去和未来 | 不能做在线因果生成,输出维翻倍 |
| 在线预测或自回归生成 | 单向 RNN/GRU/LSTM | 不使用未来信息,训练推理条件一致 | 时间方向难以完全并行 |
模型复杂度应服从任务的信息边界。门越多、层越深并不自动更好;先确认目标是否需要长期依赖、未来上下文是否真实可用,再比较验证集困惑度、吞吐量、参数量和生成质量。
13. 常见误区与调试顺序
- 把
Counter(tokens)当成词表映射;它只统计频数,编号仍由Vocab决定。 - 把
zip(corpus[:-1], corpus[1:])看成神秘语法;它只是把相邻切片配成二元组。 - 认为
[1,2,3,4,5]的标签只应是6;语言模型在每个时间步都提供下一词元监督。 - 认为顺序数据迭代器会返回所有可能滑动窗口;它只返回某个随机偏移下的非重叠分区。
- 看到循环内
X_t:[B,V]就误以为把整段序列一起更新状态;它只是当前时间步的整个 batch。 - 认为
logits:[T*B,V]和targets:[T*B]shape 不同就不能算 loss;交叉熵的后一维是类别分数,标签只保存类别索引。 - 把困惑度取指数归因于独热标签;指数是平均负对数似然的反变换。
- 认为共享参数会让所有时间步产生相同结果;参数相同,但输入与历史状态不同。
- 认为
detach(state)会清空记忆;它只切断梯度,状态数值仍传到下一批。 - 认为三维内置状态与二维从零状态冲突;前者只是显式增加了层数/方向维。
- 认为梯度裁剪也能解决梯度消失;它只限制过大的梯度范数。
- 把 GRU 重置门和更新门都理解成“旧内容占比”;前者控制候选如何读取历史,后者控制最终是否写入候选。
- 认为 LSTM 输入门先计算候选记忆;两者由同一输入并行计算,输入门控制候选写入 $C_t$ 的比例。
- 把双向网络直接用于下一词生成;这会在训练时泄漏未来标签。
遇到 shape 问题时,按下面顺序打印通常最快:
print("tokens", X.shape) # [B,T]
print("time major", X.T.shape) # [T,B]
print("outputs", outputs.shape) # [T,B,D*H]
print("state", state[0].shape if isinstance(state, tuple)
else state.shape) # [L*D,B,H]
print("logits", logits.shape) # [T*B,V]
print("targets", Y.T.reshape(-1).shape) # [T*B]
本篇知识链总结
循环神经网络的核心不是“把前一个输出再输入一次”,而是建立一条受任务信息边界约束的状态通路:文本被编号并切成错位的特征与标签,RNN 用共享转移函数压缩历史,交叉熵在每个时间步提供监督;顺序分区让状态值跨 batch 延续,detach 则把梯度限制在可计算的时间范围内。
普通 RNN 的长梯度链容易消失或爆炸。GRU 将“构造候选时读多少历史”和“最终写多少候选”分开,更新门提供直接保留状态的路径;LSTM 再把内部记忆与对外状态分离,用遗忘、输入、输出三道门管理保留、写入和读取。深层结构沿层方向增加表示能力,双向结构沿时间反方向增加未来上下文,但只有在完整序列真实可用时才合法。
最终可以把整章压缩成三个问题:
数据问题:X、Y、时间、batch 和词表维怎样对齐?
优化问题:状态值需要保留多久,梯度需要反传多久?
建模问题:过去、未来和内部记忆中的哪些信息在任务中真实可用?