循环神经网络(RNN)与 LSTM 实战指南:基于 AI-For-Beginners 的 NLP 序列建模教程
循环神经网络(RNN)与 LSTM 实战指南:基于 AI-For-Beginners 的 NLP 序列建模教程
导读
本教程来自开源课程 AI-For-Beginners 的 NLP 模块(lessons/5-NLP/16-RNN),系统讲解循环神经网络(Recurrent Neural Network,RNN)的原理与实战:从"为何嵌入层+线性分类器无法建模词序"这一动机出发,深入 RNN 单元的内部结构、LSTM 三门控机制,再到双向与多层 RNN 的扩展,并配套 PyTorch 与 TensorFlow 两套完整可运行的分类器代码。读完本文,你将理解序列模型为何能捕捉词序依赖、掌握 RNN/LSTM 单元的数学结构,并能基于 RNNPyTorch.ipynb 与 RNNTF.ipynb 独立实现新闻文本序列分类。
为什么需要 RNN:嵌入聚合丢失了词序
在前序课程(如 14-Embeddings)中,我们使用丰富的语义向量表示文本,并在嵌入之上叠加一个简单线性分类器。这种架构的核心局限在于:嵌入之后的聚合操作(如取平均)会从原始文本中抹去词的顺序信息,模型只能捕捉句子中词语的"聚合含义",无法建模词序。
正因如此,这类模型无法解决更复杂或更含糊的任务,例如文本生成、问答系统——这些任务高度依赖词的先后次序。课程 lessons/5-NLP/16-RNN/README.md 明确指出:为了捕捉文本序列的意义,我们需要另一种神经网络架构——循环神经网络(RNN)。
RNN 的核心思想是:一次一个符号(token)地将句子送入网络,网络每步产出一个状态(state),该状态与下一个符号一起再次送入网络。这种"状态传递"使信息得以在序列中流动。
RNN 的工作原理:共享权重的端到端训练
给定输入符号序列 X₀, ..., Xₙ,RNN 会创建一串神经网络单元块,并通过反向传播(backpropagation)一次性端到端训练整条链。每个网络块以 (Xᵢ, Sᵢ) 为输入,产出 Sᵢ₊₁;最终状态 Sₙ(或输出 Yₙ)送入一个线性分类器产生分类结果。
关键点在于:
- 所有网络块共享同一套权重——图中每个展开的块本质上是同一个单元,因此可以把整条链压缩成右侧那个带循环反馈回路的单块表示:输出状态重新作为输入送回网络;
- 由于状态向量 S₀, ..., Sₙ 在链中逐级传递,网络能够学习词与词之间的序列依赖。例如,当序列中某处出现单词 not 时,网络可以学习对状态向量中的某些分量做"取反",从而实现语义上的否定。
课程 lessons/5-NLP/16-RNN/README.md 中的图示正是"展开形式(左)vs. 紧凑循环形式(右)"的对照,帮助读者理解所有 RNN 单元共享权重的本质。
RNN 单元解剖:两矩阵一偏置的线性变换
一个简单的 RNN 单元内部结构如下:它接收前一状态 Sᵢ₋₁ 和当前符号 Xᵢ 作为输入,产出输出状态 Sᵢ(在生成式网络中,我们有时还关心额外的输出 Yᵢ)。
单元内部包含两个权重矩阵:
- W:变换输入符号 Xᵢ;
- H:变换输入状态 Sᵢ₋₁。
网络输出按以下公式计算:
Sᵢ = σ(W × Xᵢ + H × Sᵢ₋₁ + b)
其中 σ 是激活函数(实践中常用 tanh),b 是额外的偏置项。课程 RNNPyTorch.ipynb 补充了等价的表述:Sᵢ₊₁ = f(W_H × Sᵢ + W_I × Xᵢ + b),并在需要逐步输出(如文本生成、机器翻译)时额外引入输出矩阵 W_O 计算 Yᵢ = f(W_O × Sᵢ + b_O)。
维度约定
实际应用中,输入符号通常先经过**嵌入层(embedding layer)**降维后再进入 RNN。此时:
- 输入向量维度记为 emb_size(嵌入维度);
- 状态向量维度记为 hid_size(隐藏维度);
- 矩阵 W 的尺寸为 emb_size × hid_size;
- 矩阵 H 的尺寸为 hid_size × hid_size。
课程中的 LSTM 分类器即采用 embed_dim=64, hidden_dim=32 的组合(见 RNNPyTorch.ipynb)。
LSTM:用门控对抗梯度消失
经典 RNN 面临的核心问题是梯度消失(vanishing gradients):由于 RNN 是在一次反向传播中端到端展开训练的,序列展开后涉及的层数很多,误差难以传播回网络前部,导致网络无法学习相距较远符号之间的关系。
解决方案之一是引入显式状态管理,即所谓的**门控(gates)**机制。两大知名架构分别是:
- 长短期记忆网络(Long Short Term Memory,LSTM);
- 门控循环单元(Gated Recurrent Unit,GRU)。
LSTM 的组织方式与 RNN 类似,但层与层之间传递的是两个状态:
- 实际状态 C(也称细胞状态);
- 隐藏向量 H。
在每个单元中,隐藏向量 Hᵢ 与输入 Xᵢ 拼接,通过门控制状态 C 的更新。每个门都是一个带 sigmoid 激活的神经网络(输出范围 [0,1]),与状态向量相乘时可视为逐位的掩码(bitwise mask)。LSTM 共有三个门(按图中从左到右的顺序):
| 门 | 作用 |
|---|---|
| 遗忘门(forget gate) | 读取隐藏向量,决定状态向量 C 中哪些分量需要被遗忘、哪些需要原样保留; |
| 输入门(input gate) | 从输入与隐藏向量中提取信息,写入状态,决定"记住什么"; |
| 输出门(output gate) | 先经带 tanh 激活的线性层变换状态,再用隐藏向量 Hᵢ 挑选其中部分分量,产出新的状态 Cᵢ₊₁(并决定输出什么)。 |
状态 C 的直观理解:可开关的语义标志
状态 C 的各个分量可以理解为可以打开/关闭的标志位。课程中给出一个生动的例子:当序列中出现人名 Alice 时,模型可能据此假设句子中存在女性角色,于是在状态中竖起"句子中有阴性名词"的标志;当后续出现 and Tom 时,再竖起"复数名词"的标志。通过反复操纵状态,网络得以追踪句子各部分的语法属性——这正是 LSTM 能建模长距离依赖的直觉来源。
理解 LSTM 内部细节的经典读物是 Christopher Olah 的 Understanding LSTM Networks(该文被课程 lessons/5-NLP/16-RNN/README.md 列为"复习与自学"推荐资料)。
双向 RNN 与多层 RNN
双向 RNN(Bidirectional RNN)
此前讨论的循环网络都沿单一方向运行(序列从头到尾),这与人类阅读、聆听的顺序一致。但在很多实际场景中,我们对输入序列具备随机访问能力,此时同时在两个方向上执行循环计算往往更合理,这类网络称为双向 RNN。处理双向网络时,需要两个隐藏状态向量,每个方向各一个。
- 在 PyTorch 中,向 RNN/LSTM/GRU 构造函数传入
bidirectional=True即可;两个方向的隐藏向量会被拼接为一个尺寸翻倍的向量,后续接全连接层时需相应调整输入维度(见 RNNPyTorch.ipynb); - 在 Keras 中,用
Bidirectional层包裹循环层即可:该层在内部复制两份层,并把其中一份的go_backwards置为True,使其沿序列反向运行(见 RNNTF.ipynb)。
多层 RNN(Multi-layer RNN)
无论单向还是双向,循环网络都会在序列中捕捉特定模式,并将其存入状态向量或输出。与卷积网络类似,我们可以在第一层之上再堆叠一层循环网络,以从第一层提取的低层模式中构建更高层的模式——这就是多层 RNN:由两个或更多循环网络组成,前一层的输出作为后一层的输入。
- PyTorch:向 RNN/LSTM/GRU 构造函数传入
num_layers参数即可自动堆叠多层;此时隐藏/状态向量的尺寸会按比例增大,处理循环层输出时需一并考虑(见 RNNPyTorch.ipynb); - Keras:直接向模型追加多个循环层即可,但除最后一层外,其余层需设置
return_sequences=True,因为中间层需要返回全部中间状态而非仅最终状态(见 RNNTF.ipynb)。
实战一:PyTorch 实现 RNN/LSTM 新闻分类
课程配套的 RNNPyTorch.ipynb 以 AG_NEWS 新闻数据集 的四分类(World / Sports / Business / Sci-Tech)为例,完整演示了从数据加载到模型训练的流程。配套工具模块 torchnlp.py 提供了数据加载与训练基础设施。
数据准备与工具函数
torchnlp.py 中的关键函数:
load_dataset(ngrams=1, min_freq=1):加载 AG_NEWS 数据集、构建词表,类别为['World', 'Sports', 'Business', 'Sci/Tech'];encode(x, ...):将文本按 tokenizer 编码为词表索引序列;padify(b, ...):将 mini-batch 内不等长的序列按该 batch 内最大长度填充为等长张量;train_epoch(net, dataloader, lr=0.01, ...):标准训练循环,默认使用 Adam 优化器与交叉熵损失,每 200 步打印累计准确率。
简单 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))
训练(batch_size=16,lr=0.001,embed_dim=64,hidden_dim=32):
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=16, collate_fn=padify, shuffle=True)
net = RNNClassifier(vocab_size, 64, 32, len(classes)).to(device)
train_epoch(net, train_loader, lr=0.001)
笔记中的运行结果表明:简单 RNN 随训练逐步收敛,处理约 11 万样本后准确率提升至约 0.806。这里 x 是每个时间步的单元输出序列,h 是序列最后一个元素的最终隐藏状态;本例对 x 沿时间维求平均后送入分类器。
注意:笔记作者指出,为了简洁此处使用未预训练的嵌入层;如需更好效果,可像前一单元描述的那样换用 Word2Vec 或 GloVe 预训练嵌入。此外,RNN 展开后参与反向传播的层数很多,训练困难,因此需要较小的学习率与更大数据集,并强烈建议使用 GPU。
LSTM 分类器
LSTM 单元的内部结构虽复杂,但 PyTorch 将其封装在 LSTMCell 类中,整个 LSTM 层由 LSTM 对象表示。因此 LSTM 分类器与简单 RNN 极为相似:
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 返回的隐藏状态是 (层数, batch, hidden) 结构),训练配置与简单 RNN 相同。笔记运行结果显示,训练初期准确率提升不明显(符合 LSTM 训练缓慢的特征),约 12 万样本后达到约 0.773。
进阶:Packed Sequence 消除填充开销
由于 mini-batch 内序列需要填充(padding)到等长,填充的零向量会额外创建 RNN 单元参与训练,却不携带有效信息,造成计算与内存浪费。更优做法是让 RNN 只按真实序列长度训练。
PyTorch 提供了 packed sequence 格式。以如下填充 mini-batch 为例:
[[1,2,3,4,5],
[6,7,8,0,0],
[9,0,0,0,0]]
实际长度向量为 [5,3,1]。packed sequence 将其压缩为一个向量 [1,6,9,2,7,3,8,4,5] 加上长度向量 [5,3,1],从而高效还原原 mini-batch,避免为填充位多算循环步。
实现要点:
- 用
torch.nn.utils.rnn.pack_padded_sequence打包,用pad_packed_sequence解包; - 所有循环层(RNN/LSTM/GRU)都支持 packed 序列输入;
- 需要专门的 collate 函数同时返回标签、填充后的特征与长度向量。
对应实现(来自 RNNPyTorch.ipynb):
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)
)
train_loader_len = torch.utils.data.DataLoader(train_dataset, batch_size=16, collate_fn=pad_length, shuffle=True)
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])
训练:train_epoch_emb(net, train_loader_len, lr=0.001, use_pack_sequence=True)。笔记记录约 12 万样本后准确率达到约 0.814,是三个 PyTorch 模型中表现最好的。此处 train_epoch_emb 与 use_pack_sequence 参数的原因在于:pack_padded_sequence 要求长度张量位于 CPU 设备,因此训练函数需避免把长度向量移到 GPU(实现见 torchnlp.py)。
双向与多层扩展
在 PyTorch 中构造双向/多层 RNN 非常直接:
# 双向:hidden 向量尺寸翻倍
self.rnn = torch.nn.LSTM(embed_dim, hidden_dim, bidirectional=True)
# 多层:num_layers 自动堆叠
self.rnn = torch.nn.LSTM(embed_dim, hidden_dim, num_layers=2)
双向网络的两个隐藏状态被 PyTorch 编码为一个尺寸翻倍的向量,接全连接层时只需按此调整输入维度即可(见 RNNPyTorch.ipynb)。
实战二:TensorFlow/Keras 实现 RNN 与 LSTM
课程同时提供 RNNTF.ipynb,使用 Keras 完成同样的新闻分类。环境准备要点:
import sys
!{sys.executable} -m pip install --quiet tensorflow_datasets==4.4.0
!cd ~ && wget -q -O - https://mslearntensorflowlp.blob.core.windows.net/data/tfds-ag-news.tgz | tar xz
并建议在 GPU 环境下启用显存按需增长,避免大模型训练时的显存问题:
physical_devices = tf.config.list_physical_devices('GPU')
if len(physical_devices) > 0:
tf.config.experimental.set_memory_growth(physical_devices[0], True)
SimpleRNN 分类器
Keras 中,简单循环单元对应 SimpleRNN 层。由于 one-hot 编码维度过高,同样采用"嵌入层 + RNN 层 + Dense 分类头"的结构(vocab_size=20000,embed_size=64,RNN 单元数 16):
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.summary()
该模型的参数量构成(笔记中的 summary 输出):Embedding 层约 128 万参数(20000×64),SimpleRNN 层 1296 参数,Dense 层 68 参数。训练时仅用新闻标题(extract_title),配 sparse_categorical_crossentropy 损失与 Adam 优化器;由于只训练标题,准确率相对偏低(验证集约 0.80)。
笔记提示:当 token 维度不高时(如字符级分词),可直接将 one-hot 向量送入 RNN 单元而无需嵌入层。
变长序列的两个优化手段
TextVectorization 会自动用 pad token 填充 batch 内的变长序列,但填充 token 同样参与训练,可能干扰收敛。课程给出两种对策:
- 按长度分桶:使用
tf.data.experimental.bucket_by_sequence_length按序列长度重排并分组数据; - 掩码(masking):在 Keras 中,可在模型中加独立的
Masking层,或给Embedding层设置mask_zero=True。
掩码版本模型(同时训练标题+描述,验证准确率提升至约 0.882):
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')
])
model.compile(loss='sparse_categorical_crossentropy', metrics=['acc'], optimizer='adam')
model.fit(ds_train.map(tupelize).batch(batch_size),
validation_data=ds_test.map(tupelize).batch(batch_size))
注意:此处复用了基于标题训练的 vectorizer,理论上用全文重新训练效果更佳,但影响很小,为简洁起见沿用旧 vectorizer。
LSTM 与双向多层 LSTM
Keras 将 LSTM 内部结构封装在 LSTM 层中,替换循环层即可(单元数 8,验证准确率约 0.887):
model = keras.models.Sequential([
vectorizer,
keras.layers.Embedding(vocab_size, embed_size),
keras.layers.LSTM(8),
keras.layers.Dense(4, activation='softmax')
])
model.compile(loss='sparse_categorical_crossentropy', metrics=['acc'], optimizer='adam')
model.fit(ds_train.map(tupelize).batch(8),
validation_data=ds_test.map(tupelize).batch(8))
最后的进阶示例构建双层双向 LSTM(embed_size=128,每层 64 单元),除最后一层外均需 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')
])
model.compile(loss='sparse_categorical_crossentropy', metrics=['acc'], optimizer='adam')
model.fit(ds_train.map(tupelize).batch(batch_size),
validation_data=ds_test.map(tupelize).batch(batch_size))
提示:LSTM 训练缓慢,训练初期准确率可能上升不明显,需要持续训练若干轮;也可用
.take(...)限制训练数据量加快迭代。
环境与依赖
在本机运行 NLP 相关 notebook 前,需按课程 lessons/5-NLP/README.md 的指引安装依赖:
- PyTorch 版本:
pip install -r requirements-pytorch.txt(对应 requirements-pytorch.txt); - TensorFlow 版本:
pip install -r requirements-tf.txt(对应 requirements-tf.txt,内含 TensorFlow、tensorflow_datasets、gensim、nltk、transformers 等)。
同时注意 GPU 显存约束:大模型训练建议使用 GPU 环境;若遇显存不足,可减小 mini-batch 大小;部分旧版 TensorFlow 在单内核中训练多个模型后可能不释放显存,可通过上面给出的 set_memory_growth 代码让显存按需分配。
课程任务与延伸学习
本单元结尾的作业 assignment.md 要求:选择 PyTorch 或 TensorFlow 版本的 notebook,换上自己的数据集(例如带署名的 Kaggle 数据集,如天气推文数据集)重新运行,改写 notebook 以呈现自己的发现,尝试不同类型的数据集并记录结论。
关于 LSTM 应用的两个延伸阅读方向(课程"挑战"环节):
- Grid LSTM:把 LSTM 门控思想扩展到多维网格结构;
- Show, Attend and Tell:将注意力机制引入图像描述生成,是 LSTM 与视觉交叉应用的经典工作。
小结
通过本单元可以看到,RNN 不仅能做序列分类,还可胜任文本生成、机器翻译等更广泛的任务——这些将在下一单元(17-GenerativeNetworks 等)继续展开。核心要点回顾:
- 嵌入聚合丢失词序,需要 RNN 以状态传递建模序列依赖;
- 简单 RNN 单元 = 两权重矩阵 + 偏置 + 激活函数,所有单元共享权重并端到端反向传播训练;
- 梯度消失催生了 LSTM 的三门控(遗忘门、输入门、输出门)显式状态管理;
- 双向 RNN 同时利用前后文,多层 RNN 逐层提炼更高层模式;
- 工程上,PyTorch 用 packed sequence 消除填充浪费,Keras 用 mask_zero/bucket_by_sequence_length 处理变长序列,均能显著改善收敛与准确率。


