AI-For-Beginners 循环神经网络(RNN)实战指南:从词序建模到 LSTM 分类器
AI-For-Beginners 循环神经网络(RNN)实战指南:从词序建模到 LSTM 分类器
本指南以 AI-For-Beginners 课程第 16 课《Recurrent Neural Networks》为核心(孟加拉语译文见 translations/bn/lessons/5-NLP/16-RNN/README.md,英文原文见 lessons/5-NLP/16-RNN/README.md),讲解 RNN 如何解决"嵌入聚合丢失词序信息"的问题,并借助本仓库中的 PyTorch / TensorFlow 双版本 Notebook 与训练辅助代码,完整演示从朴素 RNN 到 LSTM、双向与多层循环网络的序列分类实战。读完你将能够:理解 RNN 单元与门控机制的原理、写出可运行的 RNN/LSTM 文本分类器、掌握填充序列与打包序列(packed sequence)等训练细节。
图:RNN 按时间步展开(左侧)与带反馈回路的紧凑表示(右侧),图中所有块共享同一组权重。
为什么需要 RNN:词序即语义
在课程 5-NLP/14-Embeddings 等前置单元中,我们使用"嵌入 + 线性分类器"的架构处理文本:将句子中每个词的嵌入做聚合(如平均/求和)后送入分类器。这种架构能捕捉句子的整体语义,却无法表示词的顺序——聚合操作把位置信息从原始文本中抹除了。
因此,这类模型无法胜任更复杂或存在歧义的任务,例如文本生成和问答系统。要捕捉文本序列的语义,需要一种新架构:循环神经网络(Recurrent Neural Network,RNN)。它的基本思想非常直观:
- 每次把句子中的**一个符号(token)**送入网络;
- 网络输出一个状态向量(state);
- 该状态再与下一个符号一起送回网络,循环往复。
给定输入序列 X0,...,Xn,RNN 会生成一个由神经网络块组成的序列,并用反向传播端到端训练整条链。每个块以 (Xi, Si) 为输入,产出 Si+1;最终状态 Sn(或输出 Yn)送入线性分类器得到结果。关键点在于:所有块共享同一套权重,因此上图右侧的"单块 + 反馈回路"表示与左侧展开形式完全等价。
因为状态向量 S0,...,Sn 贯穿整个网络传递,RNN 能够学习词之间的顺序依赖。例如,当序列中出现 not 一词时,网络可以学会将状态向量中某些分量"取反",从而表达否定语义。
RNN 单元的内部结构
一个最简单的 RNN 单元接收前一时刻状态 Si-1 和当前符号 Xi,输出状态 Si;在生成式网络中,我们有时还关心每个时间步的额外输出 Yi。
单元内部只有两个权重矩阵:
- W:负责变换输入符号;
- H:负责变换输入状态。
输出状态的计算式为:
S_i = σ(W × X_i + H × S_i-1 + b)
其中 σ 是激活函数(在 RNNTF.ipynb 中描述为常用 tanh),b 是偏置项。若进一步需要每个时间步都产出 Yi(如文本生成、机器翻译),则额外引入矩阵 WO,计算 Yi = f(WO×Si+bO)。
图:RNN 单元内部由输入变换矩阵 W 与状态变换矩阵 H 构成。
在实际工程中,输入 token 通常先经过**嵌入层(embedding layer)**降维再进入 RNN。若输入向量维度为 emb_size、状态向量维度为 hid_size,则 W 的形状为 emb_size×hid_size,H 的形状为 hid_size×hid_size。这正是两个 Notebook 中"Embedding → RNN 层 → 全连接分类层"的标准做法(见 RNNPyTorch.ipynb 的 RNNClassifier 定义)。
LSTM:用门控解决梯度消失
经典 RNN 的核心痛点是梯度消失(vanishing gradients):由于 RNN 在单次反向传播中端到端展开训练,误差很难传播到网络前端的层,导致网络学不到相距较远 token 之间的关系。业界给出的主流解法是引入显式状态管理(explicit state management),即用**门(gate)**控制状态,代表性架构有两个:**LSTM(Long Short Term Memory,长短期记忆)**与 GRU(Gated Relay Unit)。
LSTM 的组织方式与 RNN 类似,但在层与层之间传递两个状态:真实状态 C 与隐藏向量 H。在每个单元中,隐藏向量 Hi 与输入 Xi 拼接后,通过三个门控制状态 C 的变化。每个门本质是一个带 sigmoid 激活(输出范围 [0,1])的小神经网络,与状态向量相乘时相当于按位掩码(bitwise mask)。三个门从左到右分别是:
- 遗忘门(forget gate):读取隐藏向量,决定状态 C 中哪些分量需要遗忘、哪些保留;
- 输入门(input gate):从输入与隐藏向量中提取信息,写入状态;
- 输出门(output gate):先用带 tanh 激活的线性层变换状态,再依据隐藏向量 Hi 挑选分量,生成新状态 Ci+1。
可以把状态 C 的各个分量想象成可开可关的标志位(flag):读到 Alice 时,模型可能置位"句中出现女性名词"标志;随后读到 and Tom,再置位"复数名词"标志。通过这种状态操纵,网络得以持续追踪句法/语义属性——这正是"长期记忆"的直觉来源。
图:多层 LSTM 网络,低层循环层的输出作为高层循环层的输入。
双向与多层 RNN
以上讨论的循环网络都只沿单一方向处理序列(从前往后),这符合人类阅读与听说的习惯。但许多实际场景对输入序列具备随机访问能力,此时在两个方向上运行循环计算是合理的,这类网络称为双向 RNN(bidirectional RNN):它维护两个隐藏状态向量,一个对应每个方向。
无论单向还是双向,循环网络都在序列中捕捉特定模式,存入状态向量或输出。与卷积网络类似,我们可以在第一层之上再叠加一层循环层,从第一层提取的低层模式中构建高层模式,这就是多层 RNN(multi-layer RNN):由两个或更多循环网络组成,前一层的输出作为下一层的输入。
课程结论强调:RNN 不只用于序列分类,还能胜任文本生成、机器翻译等任务,这些将在下一单元(5-NLP/17-GenerativeNetworks)展开。
实战一:用 PyTorch 训练 RNN / LSTM 新闻分类器
仓库的 RNNPyTorch.ipynb 与辅助脚本 torchnlp.py 构成了完整的 PyTorch 实战链。torchnlp.py 提供了底层工具:
load_dataset(ngrams=1, min_freq=1):加载 AG_NEWS 四分类新闻数据集(World / Sports / Business / Sci-Tech),基于 n-gram 计数构建词表vocab;padify(b, voc):按批内最长序列对样本做 0 填充,产出"标签 + 填充后特征"元组;offsetify(b, voc):用偏移量(offset)存储拼接后的文本序列,供嵌入聚合类模型使用;train_epoch/train_epoch_emb:通用训练循环(Adam 优化器、交叉熵损失、按report_freq打印进度),后者额外支持use_pack_sequence参数;device = torch.device("cuda" if torch.cuda.is_available() else "cpu"):自动选择 GPU/CPU。
朴素 RNN 分类器
Notebook 中的 RNNClassifier 是"嵌入层 → RNN 层 → 全连接分类"的经典组合:
class RNNClassifier(torch.nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):
super().__init__()
self.hidden_dim = hidden_dim
self.embedding = torch.nn.Embedding(vocab_size, embed_dim)
self.rnn = torch.nn.RNN(embed_dim, hidden_dim, batch_first=True)
self.fc = torch.nn.Linear(hidden_dim, num_class)
def forward(self, x):
batch_size = x.size(0)
x = self.embedding(x)
x, h = self.rnn(x)
return self.fc(x.mean(dim=1))
要点:
- 这里使用未预训练的嵌入层以简化演示;若要更优效果,可换用上一单元介绍的 Word2Vec / GloVe 预训练嵌入(5-NLP/14-Embeddings);
torch.nn.RNN返回两个值:x是每个时间步单元输出的序列,h是序列最后元素对应的最终隐藏状态;本例用x.mean(dim=1)对所有时间步输出做平均后再分类;- 训练配置参考(Notebook 实测):
DataLoader(batch_size=16, collate_fn=padify, shuffle=True),RNNClassifier(vocab_size, 64, 32, 4),lr=0.001; - 训练要点:RNN 按序列长度展开后参与反向传播的层数非常多,必须使用较小的学习率,并在较大数据集上训练;训练耗时较长,优先使用 GPU。
用 LSTM 替代朴素 RNN
把 torch.nn.RNN 换成 torch.nn.LSTM 即得到 LSTM 分类器(LSTMClassifier),内部结构完全由框架封装:
class LSTMClassifier(torch.nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):
super().__init__()
self.hidden_dim = hidden_dim
self.embedding = torch.nn.Embedding(vocab_size, embed_dim)
self.embedding.weight.data = torch.randn_like(self.embedding.weight.data) - 0.5
self.rnn = torch.nn.LSTM(embed_dim, hidden_dim, batch_first=True)
self.fc = torch.nn.Linear(hidden_dim, num_class)
def forward(self, x):
batch_size = x.size(0)
x = self.embedding(x)
x, (h, c) = self.rnn(x)
return self.fc(h[-1])
注意此处 forward 取的是最终隐藏状态 h[-1](而非时间步平均),因为 LSTM 返回的 (h, c) 中 h 已编码整条序列的信息。Notebook 输出显示,LSTM 在训练早期准确率提升缓慢,需要更长的训练时间,也可能需要手动调节 lr 寻找"训练速度与内存消耗"的平衡点。
打包序列(Packed Sequence):只训练真实长度
填充虽然使批内序列等长,但会带来两个问题:内存浪费,以及为填充位额外创建了不承载信息的 RNN 单元参与训练。理想做法是只按真实序列长度训练。
假设一个填充 mini-batch 为:
[[1,2,3,4,5],
[6,7,8,0,0],
[9,0,0,0,0]]
0 为填充值,真实长度向量为 [5,3,1]。打包序列的思想是:先以完整 mini-batch 训练第一批单元([1,6,9]),随后第三序列结束,改用缩短的 mini-batch([2,7]、[3,8])继续。打包结果即一个扁平向量 [1,6,9,2,7,3,8,4,5] 加上长度向量 [5,3,1],可无损还原原始填充批。
PyTorch 通过 torch.nn.utils.rnn.pack_padded_sequence 打包、torch.nn.utils.rnn.pad_packed_sequence 解包;RNN / LSTM / GRU 层均支持打包序列输入。LSTMPackClassifier 的实现如下:
class LSTMPackClassifier(torch.nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):
super().__init__()
self.hidden_dim = hidden_dim
self.embedding = torch.nn.Embedding(vocab_size, embed_dim)
self.embedding.weight.data = torch.randn_like(self.embedding.weight.data) - 0.5
self.rnn = torch.nn.LSTM(embed_dim, hidden_dim, batch_first=True)
self.fc = torch.nn.Linear(hidden_dim, num_class)
def forward(self, x, lengths):
batch_size = x.size(0)
x = self.embedding(x)
pad_x = torch.nn.utils.rnn.pack_padded_sequence(x, lengths, batch_first=True, enforce_sorted=False)
pad_x, (h, c) = self.rnn(pad_x)
x, _ = torch.nn.utils.rnn.pad_packed_sequence(pad_x, batch_first=True)
return self.fc(h[-1])
配套的 pad_length 整理函数会额外返回长度张量,供 DataLoader 的 collate_fn 使用。一个易踩的坑(Notebook 明确提示):pack_padded_sequence 要求长度张量位于 CPU 设备,因此训练函数必须通过 train_epoch_emb(..., use_pack_sequence=True) 避免把长度数据搬到 GPU(实现见 torchnlp.py)。
双向与多层:一行参数搞定
PyTorch 把双向/多层能力收敛为构造函数参数,无需手工堆叠:
- 双向:给 RNN / LSTM / GRU 传
bidirectional=True。此时隐藏状态向量会翻倍(两个方向各一份被编码为一个大一倍的向量),创建后续全连接层时要相应扩大输入维度; - 多层:传
num_layers=N自动构建 N 层循环层,隐藏/状态向量大小随之成比例增加,处理循环层输出时需一并考虑。
实战二:用 TensorFlow / Keras 训练 RNN 分类器
RNNTF.ipynb 提供了 TensorFlow/Keras 版本,两者对比学习可加深理解框架抽象与底层原理的一致。
朴素 SimpleRNN 分类器
Keras 中用 SimpleRNN 层表示朴素循环单元。高维 one-hot 向量直接进 RNN 并不划算,因此同样先加 Embedding 层降维(若维度不高,如字符级 token 化,则可以直接喂 one-hot):
vocab_size = 20000
embed_size = 64
vectorizer = keras.layers.experimental.preprocessing.TextVectorization(
max_tokens=vocab_size, input_shape=(1,))
model = keras.models.Sequential([
vectorizer,
keras.layers.Embedding(vocab_size, embed_size),
keras.layers.SimpleRNN(16),
keras.layers.Dense(4, activation='softmax')
])
Notebook 实测(仅用新闻标题训练):loss: 0.6629, acc: 0.7623, val_acc: 0.7995。由于只训练标题而省略描述,准确率天然偏低。RNN 训练困难的原因与 PyTorch 部分相同——按序列展开后反向传播层级深,需要小学习率与较大数据集,因此 Notebook 专门设置了 GPU 显存按需增长的配置并提醒可调节 mini-batch 大小。
Masking:另一种处理变长序列的方案
TextVectorization 会自动在 mini-batch 内填充变长序列,但填充 token 同样参与训练、干扰收敛。Notebook 给出两个缓解手段:
- 按长度分桶:用
tf.data.experimental.bucket_by_sequence_length重排数据集,把长度相近的序列分到一组; - 掩码(masking):单独加
Masking层,或直接给Embedding层指定mask_zero=True。
使用 mask_zero=True 后即可放心在"标题+描述"全文上训练,Notebook 输出为 acc: 0.8079, val_acc: 0.8822,明显优于纯标题训练。Notebook 还提示:向量化器是在标题上适配(adapt)的,严格讲应重新在全文上训练,但实际影响很小,为简单起见沿用原向量化器。
LSTM 与双向多层 LSTM
Keras 中 LSTM 同样只需替换循环层即可,内部实现完全封装:
model = keras.models.Sequential([
vectorizer,
keras.layers.Embedding(vocab_size, embed_size),
keras.layers.LSTM(8),
keras.layers.Dense(4, activation='softmax')
])
Notebook 输出显示 LSTM 版本(batch=8)为 acc: 0.7916, val_acc: 0.8870;LSTM 训练同样缓慢,初期准确率提升不明显,需要耐心多训若干轮。
双向网络通过 Bidirectional 包装层实现——该层在内部复制两份子层,并将其中一份的 go_backwards 置为 True,使其沿序列反向计算。多层网络则直接叠加循环层,除最后一层外都必须指定 return_sequences=True(因为需要中间层返回全部时间步状态,而非只返回最终状态):
model = keras.models.Sequential([
vectorizer,
keras.layers.Embedding(vocab_size, 128, mask_zero=True),
keras.layers.Bidirectional(keras.layers.LSTM(64, return_sequences=True)),
keras.layers.Bidirectional(keras.layers.LSTM(64)),
keras.layers.Dense(4, activation='softmax')
])
这是本课 Notebook 中准确率最高的模型(训练中途已超过 0.87),当然耗时也最长——这正体现了"模型容量 ↑ 效果 ↑ 成本 ↑"的经典权衡。
练习与作业
- 继续学习官方配套 Notebook:PyTorch 版 RNN、TensorFlow 版 RNN;
- 课后作业(见 assignment.md):选取本课任意一个 Notebook(PyTorch 或 TensorFlow),用自己的数据集(如 Kaggle 上需注明出处来源的数据集,例如气象推文分类数据集)重新运行,改写 Notebook 并记录你的发现,尝试不同类型的任务与数据集,形成自己的实验结论。
总结
本单元回答了"如何让模型感知词序"这一核心问题:从嵌入聚合模型的词序盲区出发,引出 RNN 的"逐符号 + 状态传递"架构;随后剖析 RNN 单元的双矩阵结构(W、H)与 LSTM 的遗忘/输入/输出三门机制,并扩展到双向与多层变体;最后通过 PyTorch 与 TensorFlow 两套实战,覆盖了嵌入降维、填充/打包/掩码处理、GPU 训练调优等真实工程细节。
延伸学习建议:
- 深入理解 LSTM 内部机制,推荐 Christopher Olah 的经典文章 Understanding LSTM Networks;
- 挑战阅读:Grid LSTM(论文 Grid Long Short-Term Memory)与 Show, Attend and Tell(带视觉注意力的图像描述生成);
- 下一步进入 5-NLP/17-GenerativeNetworks,用 RNN 实现文本生成与机器翻译。


