AI-For-Beginners 循环神经网络实战:从词序建模到 LSTM 与双向多层网络的 PyTorch/TensorFlow 实现
AI-For-Beginners 循环神经网络实战:从词序建模到 LSTM 与双向多层网络的 PyTorch/TensorFlow 实现
循环神经网络(RNN)是 NLP 课程中承接「词嵌入 + 线性分类器」的关键架构:它通过逐符号传递状态来建模词序与长距离依赖。本文以 AI-For-Beginners 课程第 16 课(lessons/5-NLP/16-RNN/)为主体,完整讲解 RNN 细胞解剖、LSTM 门控机制、双向/多层扩展,并基于 RNNPyTorch.ipynb 与 RNNTF.ipynb 两套笔记给出可复跑的序列分类实战代码,读完你可以在 AG_NEWS 新闻分类任务上独立实现 RNN/LSTM 分类器并理解其训练细节。
为什么词嵌入不够:序列模型必须显式建模词序
在课程前几课中,我们使用丰富的语义表示(词嵌入)并在其之上叠加一个简单的线性分类器。这种架构能捕获句子中词语的聚合含义,但由于在嵌入之上执行的是聚合操作(如取平均),原始文本中的词序信息在这一步被抹掉了。无法建模词序的模型,也就无法解决更复杂、更具歧义的任务,例如文本生成(text generation)或问答(question answering)。
要捕获文本序列的语义,就需要另一种神经网络架构——循环神经网络(Recurrent Neural Network, RNN)。在 RNN 中,句子被逐符号(token)送入网络,网络每步产生一个状态(state),该状态会与下一个符号一起再次送入网络,从而形成对序列的逐步递归处理。
RNN 的核心思想:共享权重的状态传递与端到端训练
给定输入 token 序列 X₀,...,Xₙ,RNN 会创建一串神经网络块(block),并通过一次反向传播端到端(end-to-end)地训练这条序列。每个网络块接收一对 (Xᵢ, Sᵢ) 作为输入,产出 Sᵢ₊₁ 作为结果;最终状态 Sₙ(或输出 Yₙ)被送入线性分类器得到分类结果。
这里有一个关键特性:所有网络块共享同一组权重。正因为权重共享,图中展开的链条可以等价地画成一个带递归反馈环的单一模块(输出状态绕回输入端)。这也是"循环"一词的由来——网络在时间步上被展开,但参数只有一份。
因为状态向量 S₀,...,Sₙ 会贯穿整个网络传递,网络得以学习单词之间的顺序依赖。例如,当序列中某处出现 not 一词时,网络可以学会对状态向量中的某些元素做"取反"操作,从而在状态中编码出否定语义。
RNN 细胞解剖:W 与 H 两张权重矩阵
一个简单的 RNN 细胞(cell)接收前一个状态 Sᵢ₋₁ 和当前符号 Xᵢ 作为输入,产出输出状态 Sᵢ(在生成式网络中,有时我们还会关心额外输出 Yᵢ)。
细胞内部有两张权重矩阵:
- W:用于变换输入符号;
- H:用于变换输入状态。
于是网络的输出可写为:
σ(W×Xᵢ + H×Sᵢ₋₁ + b)
其中 σ 是激活函数(RNN 常用 tanh),b 是额外的偏置项。课程笔记(英文版 README 与 RNNTF.ipynb)补充指出:若还需要在每一步产出 Yᵢ(如文本生成、机器翻译),则需额外一张输出矩阵 W_O,即 Yᵢ = f(W_O×Sᵢ + b_O)。
在很多实际场景中,输入 token 在进入 RNN 之前会先经过**嵌入层(embedding layer)**以降低维度。此时若输入向量维度为 emb_size、状态向量维度为 hid_size,则 W 的尺寸为 emb_size×hid_size,H 的尺寸为 hid_size×hid_size——这两张矩阵的规模决定了 RNN 层的参数量。
LSTM:用门控机制显式管理状态,对抗梯度消失
经典 RNN 的主要问题之一是所谓的梯度消失(vanishing gradients):由于 RNN 是在一次反向传播中端到端训练整条展开序列,误差很难传导到网络靠前的层,因此网络学不到相距较远 token 之间的关系。避免这一问题的途径之一是引入显式状态管理,也就是所谓的门(gate)。这类架构有两个广为人知的代表:**长短期记忆网络(LSTM)**与 GRU(Gated Recurrent Unit)。
LSTM 的组织方式与 RNN 类似,但层与层之间传递的是两个状态:
- 实际状态 C(cell state);
- 隐藏向量 H(hidden vector)。
在每个单元中,隐藏向量 Hᵢ 与输入 Xᵢ 拼接在一起,并通过门来控制状态 C 的更新。每个门本质上是带 sigmoid 激活(输出范围 [0,1])的小型神经网络,乘以状态向量时可视为一位一位的掩码。从图中从左到右依次有三扇门:
| 门 | 作用 |
|---|---|
| 遗忘门(forget gate) | 接收隐藏向量,决定状态向量 C 中哪些分量需要遗忘、哪些需要保留传递 |
| 输入门(input gate) | 从输入与隐藏向量中提取信息,并将其写入状态 |
| 输出门(output gate) | 先通过带 tanh 激活的线性层变换状态,再借助隐藏向量 Hᵢ 挑选部分分量,产出新状态 Cᵢ₊₁ |
状态 C 的分量可以理解为一系列可开关的标志位(flag)。课程给出了一个非常直观的例子:当序列中出现名字 Alice 时,我们可以假设它指代女性角色,于是在状态中竖起"句子中有阴性名词"的旗标;当后续再出现 and Tom 时,再竖起"句子中有复数名词"的旗标。就这样通过操纵状态,网络可以持续跟踪句子各部分的语法属性——这也是 LSTM 能记住远距离信息的原因。
双向与多层 RNN
前面讨论的循环网络都是单向的——从序列开头处理到结尾,这看起来自然,因为它模仿了我们阅读和听语音的方式。但在许多实际场景中,我们可以对输入序列做随机访问,因此让循环计算同时沿两个方向进行是合理的,这类网络称为双向 RNN(bidirectional RNN)。使用双向网络时需要两个隐藏状态向量,每个方向各一个。
无论是单向还是双向,循环网络都能捕获序列内的特定模式,并将其存入状态向量或作为输出传递。与卷积网络类似,我们可以在第一层之上再叠加一层循环网络,从第一层提取的低层模式中构建更高层的模式——这就是多层 RNN(multi-layer RNN):由两个或更多循环网络堆叠而成,前一层输出作为后一层的输入。
在 PyTorch 中,只需给 RNN/LSTM/GRU 构造器传 num_layers 参数即可自动堆叠多层;隐藏/状态向量的尺寸会按比例增大,处理循环层输出时需相应调整后续全连接层的输入维度。在 Keras 中则直接向模型追加多个循环层,除最后一层外都需要设置 return_sequences=True,以便获得中间各步的输出而非仅最后一步的隐藏状态。
PyTorch 实战:从简单 RNN 分类器到打包序列的 LSTM
本课配套的 PyTorch 笔记 RNNPyTorch.ipynb 在 AG_NEWS 新闻数据集上完成了从简单 RNN 到 LSTM、再到 packed sequence 优化的完整实现,公共训练/数据函数集中在同目录的 torchnlp.py 中。
数据准备与辅助函数
首先加载数据集并构建词表(torchnlp.py 中的 load_dataset 使用 torchtext 内置的 AG_NEWS,类别为 World / Sports / Business / Sci-Tech,并基于计数器构建 min_freq 可控的 vocab)。padify 函数负责把 batch 内变长序列 pad 到同一长度并返回标签张量;train_epoch 则封装了一次完整的训练轮次(Adam 优化器、CrossEntropyLoss、每 200 个 batch 打印一次准确率)。
import torch
import torchtext
from torchnlp import *
train_dataset, test_dataset, classes, vocab = load_dataset()
vocab_size = len(vocab)
简单 RNN 分类器
每个简单 RNN 单元是一个线性网络:拼接输入向量与状态向量,产出新状态向量。PyTorch 用 RNNCell 类表示单个单元,用 RNN 层表示一整个单元序列。先加嵌入层降维,再叠 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))
笔记中的要点:
- RNN 层会返回两个输出:
x是每个时间步的单元输出序列,h是序列最后元素的最终隐藏状态;此处对输出序列取均值后接线性分类器; - 这里使用未训练的嵌入层是出于简洁考虑,更好的做法是换用前一课介绍的 Word2Vec 或 GloVe 预训练嵌入;
- RNN 很难训练:一旦沿序列长度展开,参与反向传播的层数会非常多,因此需要较小的学习率、较大的数据集,且优先使用 GPU。笔记中训练参数为
batch_size=16、embed_dim=64、hidden_dim=32、lr=0.001。
换用 LSTM 层
LSTM 内部结构虽然复杂,但 PyTorch 把它封装在 LSTMCell/LSTM 类里,因此 LSTM 分类器与简单 RNN 几乎一样——只需替换循环层并改用最后一个隐藏状态 h[-1]:
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])
训练 LSTM 同样相当缓慢,训练初期准确率可能看不出明显上升,需要耐心多训练一段时间,并调整 lr 学习率以兼顾训练速度与稳定性。
Packed Sequences:消除填充带来的无效计算
为了让 batch 内序列等长,padify 用零向量把短序列补齐。这会造成内存浪费,对 RNN 来说更关键的问题是:填充项也会创建额外的 RNN 细胞并参与训练,却没有任何有效输入信息。更优的做法是只对真实序列长度进行训练。
PyTorch 为此提供了打包序列(packed sequence)格式。笔记中的例子:假设填充后的 mini-batch 为
[[1,2,3,4,5],
[6,7,8,0,0],
[9,0,0,0,0]]
0 表示填充值,各序列真实长度为 [5,3,1]。我们希望先让第一个 RNN 细胞组用大 batch([1,6,9])训练,随后第三条序列结束,改用更短的 batch([2,7]、[3,8])继续……因此打包序列表示为一个向量 [1,6,9,2,7,3,8,4,5] 加上长度向量 [5,3,1],由两者可完整还原原 batch。
实现方式:用 torch.nn.utils.rnn.pack_padded_sequence 生成打包序列,所有循环层(RNN/LSTM/GRU)都接受打包序列作为输入并输出打包结果,再用 torch.nn.utils.rnn.pad_packed_sequence 解包。由于打包需要长度向量,需要新的 collate 函数把真实长度一并返回:
def pad_length(b):
v = [encode(x[1]) for x in b]
len_seq = list(map(len, v))
l = max(len_seq)
return (
torch.LongTensor([t[0] - 1 for t in b]),
torch.stack([torch.nn.functional.pad(torch.tensor(t), (0, l - len(t)), mode='constant', value=0) for t in v]),
torch.tensor(len_seq)
)
对应的 LSTM 分类器在 forward 中同时接收填充 batch 与长度向量:
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])
笔记特别说明:实际上这里并不使用解包后的 x(后续计算只用隐藏层输出),保留解包步骤是为了方便你在需要继续使用网络输出的场景中修改代码。训练时需给 train_epoch_emb 传 use_pack_sequence=True——因为当前 pack_padded_sequence 要求长度张量位于 CPU 设备,训练函数需要避免把长度序列数据搬到 GPU,具体可见 torchnlp.py 中 train_epoch_emb 的实现。
双向与多层
笔记中对应小节的结论是:在 PyTorch 中创建双向 RNN 只需给 RNN/LSTM/GRU 构造器传 bidirectional=True,两个方向的隐藏状态会被编码为一个尺寸加倍(2×hid_size)的向量,接入全连接层时把维度翻倍即可;堆叠多层则传入 num_layers 参数。
TensorFlow/Keras 实战:从 SimpleRNN 到双向多层 LSTM
TensorFlow 版本 RNNTF.ipynb 使用相同的 AG_NEWS 数据(通过 tensorflow_datasets 加载的 ag_news_subset),展示了 Keras 视角的等价实现,四类标签不变。
简单 RNN 分类器
Keras 用 SimpleRNN 层表示单层循环单元。由于 one-hot 编码 token 维度太高,同样先加嵌入层降维:
vocab_size = 20000
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')
])
model.compile(loss='sparse_categorical_crossentropy', metrics=['acc'], optimizer='adam')
model.fit(ds_train.map(tupelize_title).batch(batch_size),
validation_data=ds_test.map(tupelize_title).batch(batch_size))
笔记中的实践细节:
- 先用
vectorizer.adapt(ds_train.take(2000).map(extract_title))只在新闻标题上训练向量化器,随后仅用标题训练模型以加快速度(此时准确率通常会偏低,因为输入信息更少); - RNN 训练困难、速度慢,同样建议用小学习率、大数据集并优先使用 GPU;
- 当维度不高(例如字符级 token 化)时,也可以直接把 one-hot 编码的 token 送入 RNN 细胞,不一定非要嵌入层。
变长序列与掩码(Masking)
TextVectorization 层会自动用填充 token 把 mini-batch 中的变长序列补齐,而这些填充 token 也会参与训练,可能使模型收敛变复杂。笔记给出两条对策:
- 按序列长度重排数据集并分组,可用
tf.data.experimental.bucket_by_sequence_length; - 使用掩码(masking):要么加独立的
Masking层,要么直接给Embedding层设置mask_zero=True,让填充位置不参与训练:
model = keras.models.Sequential([
vectorizer,
keras.layers.Embedding(vocab_size, embed_size, mask_zero=True),
keras.layers.SimpleRNN(16),
keras.layers.Dense(4, activation='softmax')
])
加入掩码后就可以放心地在标题+描述的全量数据上训练了。笔记提醒:向量化器此前只在标题上训练过,严格来说应重新在全文上训练向量化器,但实际影响很小,为简洁起见沿用原向量化器。
LSTM 与双向多层网络
Keras 同样把 LSTM 内部结构隐藏在 LSTM 层中,替换循环层即可:
model = keras.models.Sequential([
vectorizer,
keras.layers.Embedding(vocab_size, embed_size),
keras.layers.LSTM(8),
keras.layers.Dense(4, activation='softmax')
])
双向与多层实现则用 Bidirectional 层包装循环层(它内部复制两份循环层,并把其中一份的 go_backwards 置为 True 以反向扫描序列),堆叠两层 LSTM 时除最后一层外均需 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')
])
这是本课迄今准确率最高的模型,但训练耗时也最长,笔记建议耐心等待训练完成。
运行环境与依赖安装
在本地运行本课笔记前,需要安装 NLP 相关依赖(见 NLP 模块 README):
# PyTorch 版本
pip install -r requirements-pytorch.txt
# TensorFlow 版本
pip install -r requirements-tf.txt
本模块的大模型训练有明确的 GPU 注意事项:建议使用 GPU 机器;GPU 显存不足时可调小 mini-batch 大小;较老版本的 TensorFlow 在同一内核中连续训练多个模型时可能不释放显存,可加入如下代码让显存按需增长:
physical_devices = tf.config.list_physical_devices('GPU')
if len(physical_devices) > 0:
tf.config.experimental.set_memory_growth(physical_devices[0], True)
TensorFlow 笔记还提醒:某些 NVIDIA 驱动版本在训练完模型后不释放内存,若在训练时遇到诡异错误,可以重启 notebook 内核。
课后作业与延伸
本课作业(assignment.md)建议:基于本课的 PyTorch 或 TensorFlow 笔记,换用你自己的数据集(例如 Kaggle 上的天气推文数据集)重新运行,改写 notebook 以记录你自己的发现,尝试不同类型的数据集并总结结论。
深入学习方面,本课推荐研读 Grid LSTM 与基于视觉注意力机制的图像描述生成(Show, Attend and Tell)等 LSTM 应用文献,并参考 Christopher Olah 对 LSTM 内部机制的经典图解文章。本课的随堂测验与课后测验可通过仓库中的 quiz-app 运行。
小结
通过本课你可以掌握以下能力:
- 理解 RNN 为何能建模词序:逐符号输入、状态传递、权重共享、端到端反向传播;
- 掌握 RNN 细胞的数学形式 σ(W×Xᵢ + H×Sᵢ₋₁ + b) 与维度推导;
- 理解 LSTM 三扇门(遗忘门、输入门、输出门)如何通过显式状态管理缓解梯度消失;
- 理解双向与多层 RNN 的动机与构造方式;
- 在 PyTorch 中实现 RNN/LSTM 分类器,并掌握 packed sequence 优化与
num_layers/bidirectional参数; - 在 Keras 中实现
SimpleRNN/LSTM/Bidirectional分类器,并掌握mask_zero掩码技巧。
RNN 不仅能做序列分类,还能胜任文本生成、机器翻译等任务——这正是下一课(生成式网络)将要讨论的内容。建议在动手运行两套 notebook(PyTorch / TensorFlow)后,用你自己的数据集完成作业,以加深对循环架构与训练技巧的理解。


