RNN与序列模型-从循环神经网络到LSTM
理解RNN,就理解了为什么Transformer要这样设计。这篇文章从"为什么"出发,带你彻底搞懂序列建模的进化史。
RNN与序列模型:从循环神经网络到LSTM
理解RNN,就理解了为什么Transformer要这样设计。这篇文章从"为什么"出发,带你彻底搞懂序列建模的进化史。
核心类比:
- RNN:有记忆的阅读器——边读文字边更新"理解状态",但记忆会随距离衰减
- LSTM:有选择性记忆的阅读器——通过"门"来控制哪些信息值得长期保留、哪些可以遗忘
- 为什么被Transformer取代:RNN必须串行处理(慢),且远处信息仍然不够可靠;Transformer让每个词直接"看到"所有其他词,完全绕过了信息需要逐步传递的问题
1.1 一、为什么需要RNN?CNN处理不了什么?
1.1.1 图像 vs 文本:两种完全不同的数据结构
上一篇我们学了CNN(卷积神经网络),它非常擅长处理图像。为什么?因为图像是空间数据——"猫的耳朵"出现在图片的左上角还是右下角,都是猫耳朵,不影响识别。CNN用滑动窗口检测这些局部特征,正好适合。
但文本不一样。文本是时序数据(temporal data,数据的含义与其出现顺序强相关):
"我爱你" ≠ "你爱我" ≠ "爱我你"
同样三个字,顺序不同,意思完全不同。更重要的是,理解文本往往需要"记住前面说过什么"。
1.1.2 一个翻译的例子:为什么需要记忆?
考虑将下面这句话翻译成英语:
"小明昨天去了图书馆,借了三本关于人工智能的书,回家读了一整晚。"
翻译到最后的"回家读了一整晚"时,你需要知道:
- 谁去了图书馆?→ 小明(句子开头的信息)
- 读的是什么?→ 那三本书(中间的信息)
一个"没有记忆"的网络,处理到句尾时,早就"忘了"句首的信息。
CNN能做到这个吗?不能。CNN的卷积核是固定大小的滑动窗口,只能看局部。全连接网络呢?它能看整句话,但它的输入长度是固定的——句子长度不同怎么办?而且它没有建模"顺序"这个概念,对网络来说,输入顺序打乱和不打乱没有区别。
这就是**RNN(Recurrent Neural Network,循环神经网络)**登场的原因:专门为处理序列数据(文本、语音、股票价格、传感器数据……)而设计。
1.2 二、RNN的核心思想:带记忆的神经网络
1.2.1 用类比建立直觉:阅读理解
想象你在阅读一段文字。你读每个词时:
- 你用当前词(当前输入)更新你的理解
- 你的理解不仅来自当前词,还来自你之前读过的内容(历史记忆)
- 读完每个词,你的理解状态也随之更新
这就是RNN的核心机制,只不过"你的理解状态"在RNN里叫做隐状态(hidden state)。
1.2.2 RNN的工作流程
时刻 t=1:读"小"
输入: x₁ = "小" (词向量)
前一隐状态: h₀ = 全零向量 (初始,没有历史)
计算: h₁ = f(W·x₁ + U·h₀ + b)
输出: y₁ (可选,比如词性标注)
时刻 t=2:读"明"
输入: x₂ = "明" (词向量)
前一隐状态: h₁ (上一步的记忆,包含了"小"的信息)
计算: h₂ = f(W·x₂ + U·h₁ + b)
输出: y₂
时刻 t=3:读"昨"
输入: x₃ = "昨"
前一隐状态: h₂ (包含了"小明"的信息)
计算: h₃ = f(W·x₃ + U·h₂ + b)
...
用ASCII图表示:
x₁ x₂ x₃ x₄
↓ ↓ ↓ ↓
h₀ →[RNN]→ h₁→[RNN]→ h₂→[RNN]→ h₃→[RNN]→ h₄
↓ ↓ ↓ ↓
y₁ y₂ y₃ y₄
注意:每个[RNN]单元共享同一组参数 W, U, b
这是RNN和普通神经网络的关键区别!
关键洞察:每个时间步用的是同一组权重 W、U、b。这叫参数共享(parameter sharing),好处是无论句子多长,参数量都不变。
1.2.3 数学公式
RNN的核心更新公式只有一行:
hₜ = tanh(W · xₜ + U · hₜ₋₁ + b)
xₜ:当前时刻的输入(比如当前词的词向量)hₜ₋₁:上一时刻的隐状态(历史记忆)W:输入权重矩阵(学习如何利用当前输入)U:循环权重矩阵(学习如何利用历史信息)b:偏置项tanh:双曲正切激活函数(将结果压缩到-1到1之间,防止数值爆炸)
输出层(如果需要的话):
yₜ = softmax(V · hₜ + c)
1.2.4 PyTorch代码:从零实现一个简单RNN
import torch
import torch.nn as nn
import numpy as np
# ============================================================
# 方式1:手动实现RNN,理解底层计算
# ============================================================
class SimpleRNN(nn.Module):
"""手动实现的单步RNN,完全透明"""
def __init__(self, input_size, hidden_size, output_size):
super().__init__()
self.hidden_size = hidden_size
# 三组参数:处理输入的W,处理隐状态的U,输出层的V
self.W = nn.Linear(input_size, hidden_size) # 输入→隐状态
self.U = nn.Linear(hidden_size, hidden_size) # 隐状态→隐状态(循环连接)
self.V = nn.Linear(hidden_size, output_size) # 隐状态→输出
def forward(self, x_sequence, h0=None):
"""
x_sequence: (seq_len, input_size) - 一个序列
h0: (hidden_size,) - 初始隐状态,默认全零
"""
seq_len = x_sequence.size(0)
# 初始隐状态:如果没有提供,就用全零
if h0 is None:
h = torch.zeros(self.hidden_size)
else:
h = h0
outputs = []
hidden_states = []
# 逐时间步处理序列 —— 这就是RNN"必须顺序处理"的根本原因
for t in range(seq_len):
x_t = x_sequence[t] # 当前时刻的输入
# 核心公式:新隐状态 = tanh(W·x_t + U·h_{t-1})
# W·x_t:从当前输入中提取信息
# U·h:从历史记忆中提取信息
# tanh:压缩到(-1, 1),防止隐状态数值无限增大
h = torch.tanh(self.W(x_t) + self.U(h))
y_t = self.V(h) # 当前时刻的输出
outputs.append(y_t)
hidden_states.append(h)
return torch.stack(outputs), torch.stack(hidden_states)
# 测试
input_size = 10 # 输入维度(如词向量维度)
hidden_size = 20 # 隐状态维度(记忆容量)
output_size = 5 # 输出维度(如词汇表大小)
seq_len = 7 # 序列长度(句子有7个词)
model = SimpleRNN(input_size, hidden_size, output_size)
x = torch.randn(seq_len, input_size) # 7个时间步,每步10维输入
outputs, hiddens = model(x)
print(f"输入序列形状: {x.shape}") # torch.Size([7, 10])
print(f"输出序列形状: {outputs.shape}") # torch.Size([7, 5])
print(f"隐状态序列: {hiddens.shape}") # torch.Size([7, 20])
# ============================================================
# 方式2:使用PyTorch内置的nn.RNN(生产实践中使用这个)
# ============================================================
rnn = nn.RNN(
input_size=10, # 输入特征维度
hidden_size=20, # 隐状态维度
num_layers=2, # 堆叠2层RNN(深层RNN)
batch_first=True, # 输入形状是(batch, seq, feature),更符合直觉
nonlinearity='tanh' # 激活函数
)
# batch_first=True 时,输入形状:(batch_size, seq_len, input_size)
x_batch = torch.randn(32, 7, 10) # 32个样本,每个序列7步,每步10维
output, h_n = rnn(x_batch)
# output: (32, 7, 20) — 每个时间步的输出
# h_n: (2, 32, 20) — 最后时刻的隐状态(2层 × 32批次 × 20维)
print(f"\n批处理输入: {x_batch.shape}")
print(f"批处理输出: {output.shape}") # (32, 7, 20)
print(f"最终隐状态: {h_n.shape}") # (2, 32, 20)
1.2.5 一个完整的情感分析示例
import torch
import torch.nn as nn
class SentimentRNN(nn.Module):
"""用RNN做情感分析:判断影评是正面还是负面"""
def __init__(self, vocab_size, embed_dim, hidden_size, num_classes):
super().__init__()
# Embedding层:将词的索引转为密集向量
# 相当于一个查找表:第i行是第i个词的向量表示
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.rnn = nn.RNN(
input_size=embed_dim,
hidden_size=hidden_size,
batch_first=True
)
# 只用最后一个时刻的隐状态来做分类
# 理论上最后的隐状态"总结"了整个序列的信息
self.classifier = nn.Linear(hidden_size, num_classes)
def forward(self, x):
# x: (batch, seq_len) — 词索引序列
# 词索引 → 词向量
embedded = self.embedding(x) # (batch, seq_len, embed_dim)
# RNN处理序列
output, h_n = self.rnn(embedded)
# output: (batch, seq_len, hidden) — 每步输出
# h_n: (1, batch, hidden) — 最后隐状态
# 取最后一个时间步的隐状态作为整句的表示
# squeeze(0) 去掉num_layers那个维度
last_hidden = h_n.squeeze(0) # (batch, hidden)
# 分类
logits = self.classifier(last_hidden) # (batch, num_classes)
return logits
# 创建模型
model = SentimentRNN(
vocab_size=10000, # 词汇表大小
embed_dim=128, # 词向量维度
hidden_size=256, # 隐状态维度
num_classes=2 # 正面/负面
)
# 模拟一批输入:32个样本,每个序列50个词(词的索引)
x = torch.randint(0, 10000, (32, 50))
logits = model(x)
print(f"情感分类输出: {logits.shape}") # (32, 2)
# 统计参数量
total_params = sum(p.numel() for p in model.parameters())
print(f"模型参数量: {total_params:,}")
1.3 三、RNN的致命问题
RNN的思路很优雅,但在实践中遇到了严重障碍。理解这些问题,是理解LSTM和Transformer为什么这样设计的关键。
1.3.1 问题1:梯度消失(Vanishing Gradient)
先回忆反向传播:训练神经网络时,我们从损失函数出发,用链式法则(chain rule)将梯度从输出层一路往回传,更新每一层的参数。
RNN的问题在于:时间轴就是"深度"。一个100词的句子,等于一个100层深的网络。
梯度在时间轴上反向传播时,要经历大量的乘法。每经过一个时间步,梯度都会乘以循环权重矩阵的某个数值。
用数字说明:
import numpy as np
# 假设每个时间步梯度乘以一个小于1的值(这很常见)
gradient_factor = 0.9 # 每步梯度缩小到90%
# 经过不同步数后,梯度还剩多少?
for steps in [10, 20, 50, 100]:
remaining = gradient_factor ** steps
print(f"经过 {steps:3d} 步后,梯度剩余比例: {remaining:.8f}")
# 输出:
# 经过 10 步后,梯度剩余比例: 0.34867844
# 经过 20 步后,梯度剩余比例: 0.12157665
# 经过 50 步后,梯度剩余比例: 0.00515378
# 经过 100 步后,梯度剩余比例: 0.00002656
经过100步,梯度只剩0.002%,实际上变成了0。这意味着句子开头的词对后面词的训练完全没有影响——网络根本学不到长距离的依赖关系。
这就叫梯度消失(Vanishing Gradient)。
与之相反的是梯度爆炸(Exploding Gradient):如果每步的乘法因子大于1,梯度会指数级增长,导致参数更新幅度巨大,训练完全失控。
# 梯度爆炸示例
gradient_factor = 1.1 # 每步梯度增大到110%
for steps in [10, 20, 50, 100]:
remaining = gradient_factor ** steps
print(f"经过 {steps:3d} 步后,梯度增长倍数: {remaining:.2f}")
# 经过 10 步后,梯度增长倍数: 2.59
# 经过 20 步后,梯度增长倍数: 6.73
# 经过 50 步后,梯度增长倍数: 117.39
# 经过 100 步后,梯度增长倍数: 13780.61
梯度爆炸可以用**梯度裁剪(Gradient Clipping)**缓解——当梯度超过阈值时强制截断。梯度消失则更难处理,因为"什么都没有"很难被检测到。
1.3.2 问题2:长距离依赖失效
梯度消失的直接后果:RNN记不住久远的信息。
经典例子:
"我在[北京]出生,从小学中文,后来去了美国,
在纽约工作了10年,每天说英文,偶尔回国,
和同事交流时说的是___语。"
答案是"普通话",需要关联到句子开头的"北京"。对于RNN,这个距离太远了,"北京"的信息早就在层层传递中消失。
信息流动路径:
"北京" → h₁ → h₂ → h₃ → ... → h₅₀ → ... → h₁₀₀ → 预测"普通话"
每经过一步,"北京"的信号就更微弱一点。
经过100步,信号几乎完全淹没在噪声中。
1.3.3 问题3:无法并行,训练慢
RNN有一个结构性缺陷:必须按顺序处理。
计算 h₂ 必须先有 h₁,计算 h₃ 必须先有 h₂……所有时间步必须串行计算,无法利用GPU的并行计算能力。
RNN的计算依赖:
h₁ → h₂ → h₃ → h₄ → h₅
↑ ↑ ↑ ↑ ↑
串行!GPU空转等待
相比之下,现代GPU可以同时进行几千个矩阵计算。RNN的顺序依赖让GPU的并行优势几乎完全浪费。这在句子很长(数百词)或训练数据量极大时,是致命的性能瓶颈。
1.4 四、LSTM:给RNN装上"记忆开关"
RNN vs LSTM 结构对比——LSTM 通过三个门控机制解决了 RNN 的梯度消失问题
1997年,Hochreiter和Schmidhuber提出了LSTM(Long Short-Term Memory,长短期记忆网络),专门解决梯度消失和长距离依赖问题。
1.4.1 核心思想:用"门"控制信息流
RNN的问题是信息在传递时总是被同一套变换处理,既无法选择性保留,也无法选择性遗忘。
LSTM引入了**门(Gate)**机制——本质是一个值域在0到1之间的"开关":
- 输出0:完全关闭,不让任何信息通过
- 输出1:完全打开,让信息原封不动通过
- 输出0.5:半开,让一半信息通过
这些门本身是由sigmoid激活函数控制的神经网络层,可以通过训练学习——网络会自动学到"什么时候该记住,什么时候该遗忘"。
1.4.2 工作笔记本类比
把LSTM想象成一位研究员在做工作笔记:
细胞状态(Cell State):正式的笔记本
- 记录重要的长期信息
- 不随意修改,有章可循
隐状态(Hidden State):工作台上当前关注的内容
- 当前正在处理的信息
- 会频繁更新
三个门:
1. 遗忘门(Forget Gate)——"这条笔记还有用吗?"
看当前输入,决定笔记本里哪些内容可以擦掉
2. 输入门(Input Gate)——"这个新信息值得记下来吗?"
看当前输入,决定哪些新信息值得写入笔记本
3. 输出门(Output Gate)——"现在需要查阅哪些笔记?"
决定笔记本里哪些内容要拿出来当前使用
1.4.3 LSTM的数学公式
LSTM的四个核心计算(每个时间步执行一次):
# 遗忘门:决定遗忘多少旧记忆
fₜ = σ(Wf · [hₜ₋₁, xₜ] + bf)
# 输入门:决定写入多少新信息
iₜ = σ(Wi · [hₜ₋₁, xₜ] + bi)
# 候选记忆:计算新的候选信息
C̃ₜ = tanh(Wc · [hₜ₋₁, xₜ] + bc)
# 更新细胞状态(核心!)
Cₜ = fₜ ⊙ Cₜ₋₁ + iₜ ⊙ C̃ₜ
↑ ↑
遗忘旧的 加入新的
# 输出门:决定输出多少
oₜ = σ(Wo · [hₜ₋₁, xₜ] + bo)
# 最终输出的隐状态
hₜ = oₜ ⊙ tanh(Cₜ)
其中:
σ:sigmoid函数,输出在(0,1)之间,作为"门"的开关程度⊙:逐元素相乘(element-wise multiplication),门控的数学实现[hₜ₋₁, xₜ]:将前一隐状态和当前输入拼接
1.4.4 为什么LSTM能解决梯度消失?
关键在细胞状态的更新公式:
Cₜ = fₜ ⊙ Cₜ₋₁ + iₜ ⊙ C̃ₜ
注意这里有加法!在反向传播时,加法的梯度直接流过去,不会像乘法那样逐步衰减。
如果遗忘门 fₜ ≈ 1(网络学到"要记住这段信息"),那么梯度可以沿着细胞状态直接流回很早的时间步,几乎没有衰减。
RNN梯度路径:乘 → 乘 → 乘 → 乘 → ... (连续乘法,信号指数衰减)
LSTM梯度路径:
细胞状态:加 → 加 → 加 → 加 → ... (加法主路径,信号稳定传递)
↑
这就是LSTM的核心创新!
1.4.5 PyTorch代码:LSTM实现情感分析
import torch
import torch.nn as nn
class SentimentLSTM(nn.Module):
"""用LSTM做情感分析,相比RNN能处理更长的依赖"""
def __init__(self, vocab_size, embed_dim, hidden_size, num_classes,
num_layers=2, dropout=0.3):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(
input_size=embed_dim,
hidden_size=hidden_size,
num_layers=num_layers, # 堆叠多层LSTM,提升表达能力
batch_first=True,
dropout=dropout, # 层间dropout,防止过拟合
bidirectional=True # 双向LSTM:同时从前往后和从后往前读
)
# 双向LSTM输出维度是 hidden_size * 2(正向+反向)
self.dropout = nn.Dropout(dropout)
self.classifier = nn.Linear(hidden_size * 2, num_classes)
def forward(self, x, lengths=None):
"""
x: (batch, seq_len) — 词索引
lengths: 每个样本的实际长度(可选,用于忽略padding)
"""
embedded = self.dropout(self.embedding(x))
# embedded: (batch, seq_len, embed_dim)
# LSTM返回三个值:
# output: (batch, seq_len, hidden*2) — 每步的输出
# (h_n, c_n): 最后时刻的隐状态和细胞状态
output, (h_n, c_n) = self.lstm(embedded)
# 取最后一层的前向和后向隐状态拼接
# h_n形状:(num_layers * num_directions, batch, hidden)
# 最后一层的前向:h_n[-2],后向:h_n[-1]
forward_h = h_n[-2] # (batch, hidden)
backward_h = h_n[-1] # (batch, hidden)
combined = torch.cat([forward_h, backward_h], dim=1) # (batch, hidden*2)
combined = self.dropout(combined)
logits = self.classifier(combined)
return logits
# 实例化模型
model = SentimentLSTM(
vocab_size=10000,
embed_dim=128,
hidden_size=256,
num_classes=2,
num_layers=2,
dropout=0.3
)
# 统计参数量并对比
rnn_model = nn.RNN(128, 256, 2, batch_first=True)
lstm_model = nn.LSTM(128, 256, 2, batch_first=True)
rnn_params = sum(p.numel() for p in rnn_model.parameters())
lstm_params = sum(p.numel() for p in lstm_model.parameters())
print(f"RNN 参数量: {rnn_params:>10,}")
print(f"LSTM 参数量: {lstm_params:>10,}")
print(f"参数比例 LSTM/RNN: {lstm_params/rnn_params:.1f}x")
# LSTM参数量约是RNN的4倍(因为有4组权重矩阵:遗忘门、输入门、候选记忆、输出门)
1.4.6 双向LSTM:同时从两个方向读
单向LSTM(从左往右):
词1 → 词2 → 词3 → 词4 → 词5
↓
只知道前面的
双向LSTM(bidirectional):
词1 → 词2 → 词3 → 词4 → 词5 (前向)
词5 → 词4 → 词3 → 词2 → 词1 (后向)
↓
词3同时知道前面和后面的内容
对于分类任务,双向往往比单向效果好——比如理解"我不是很好"中的"好",既需要知道前面的"不",也需要知道后面的语境。
1.5 五、GRU:更简洁的LSTM
2014年,Cho等人提出了GRU(Gated Recurrent Unit,门控循环单元)。它用两个门代替LSTM的三个门,在很多任务上性能相当,但参数更少、训练更快。
1.5.1 GRU的两个门
LSTM有:遗忘门、输入门、输出门 + 细胞状态、隐状态(共5个量)
GRU有:
重置门(Reset Gate)— 决定"用多少历史信息计算候选状态"
更新门(Update Gate)— 同时扮演LSTM遗忘门和输入门的角色
只有一个状态:隐状态 hₜ(没有独立的细胞状态)
GRU的公式:
# 重置门:决定历史信息的使用比例
rₜ = σ(Wr · [hₜ₋₁, xₜ])
# 更新门:决定保留多少旧状态,写入多少新状态
zₜ = σ(Wz · [hₜ₋₁, xₜ])
# 候选隐状态(重置门控制历史的参与度)
h̃ₜ = tanh(W · [rₜ ⊙ hₜ₋₁, xₜ])
# 最终隐状态(更新门兼顾遗忘和输入)
hₜ = (1 - zₜ) ⊙ hₜ₋₁ + zₜ ⊙ h̃ₜ
↑ ↑
保留旧状态 写入新状态
注意最后一行的优雅设计:(1 - zₜ)和zₜ天然互补——更新门越大,写入新信息越多,保留旧信息越少。LSTM用两个独立的门做同样的事情,GRU只用一个。
1.5.2 PyTorch代码:GRU
import torch
import torch.nn as nn
# GRU用法与LSTM几乎完全相同,只是没有细胞状态
gru = nn.GRU(
input_size=128,
hidden_size=256,
num_layers=2,
batch_first=True,
bidirectional=True
)
x = torch.randn(32, 50, 128) # (batch=32, seq=50, feature=128)
output, h_n = gru(x)
# 注意:GRU只返回 (output, h_n),没有 c_n(没有细胞状态)
# LSTM返回 (output, (h_n, c_n))
print(f"GRU输出: {output.shape}") # (32, 50, 512) — 双向所以是256*2
print(f"GRU隐状态: {h_n.shape}") # (4, 32, 256) — 2层*2方向
# 参数量对比
lstm = nn.LSTM(128, 256, 2, batch_first=True, bidirectional=True)
gru_params = sum(p.numel() for p in gru.parameters())
lstm_params = sum(p.numel() for p in lstm.parameters())
print(f"\nGRU 参数量: {gru_params:>10,}")
print(f"LSTM 参数量: {lstm_params:>10,}")
print(f"GRU比LSTM少 {(1 - gru_params/lstm_params)*100:.1f}% 的参数")
# GRU比LSTM少约25%的参数(3/4,因为GRU只有3组权重,LSTM有4组)
1.5.3 GRU vs LSTM:如何选择?
经验原则:
GRU 适合的场景:
✓ 数据集较小,担心过拟合
✓ 计算资源有限,需要更快的训练
✓ 序列不是特别长(< 500步)
✓ 快速原型验证
LSTM 适合的场景:
✓ 数据量充足,模型有足够的容量
✓ 序列非常长,需要更强的记忆能力
✓ 任务特别复杂,精度要求高
✓ 有充足的算力和时间调参
实践结论:
两者在大多数任务上差异不大(5%以内)。
先用GRU快速验证方案可行性,再根据需要换LSTM调优。
2017年后,两者都逐渐被Transformer取代。
1.6 六、为什么Transformer最终取代了RNN?
2017年,Google发表论文《Attention Is All You Need》,提出了Transformer架构。此后,NLP(Natural Language Processing,自然语言处理,让计算机理解和生成人类语言的技术领域)领域几乎所有重要模型都换成了Transformer。RNN/LSTM为什么输了?
1.6.1 RNN的三个根本限制
限制1:必须顺序处理(无法并行)
RNN的计算依赖链:
h₁ → h₂ → h₃ → ... → h₁₀₀₀
GPU有几千个计算核心可以同时工作,
但RNN只能用1个,其他的在空转等待。
训练一个长序列模型:
RNN需要等待1000步串行计算
Transformer所有位置同时并行计算
在大规模数据上,这是10-100倍的训练速度差距。
限制2:长距离依赖仍然困难
即使有了LSTM的门机制,"记忆"仍然通过隐状态一步步传递。对于特别长的文档(几千词),早期信息依然容易丢失。
LSTM改善了梯度消失,但没有根本解决信息必须通过"中间人"传递的问题:
"北京"的信息要到达词1000:
RNN/LSTM路径:北京 → h₁ → h₂ → ... → h₁₀₀₀(经过999步)
Transformer路径:北京 直接 → 词1000(一步到达!)
限制3:无法充分利用规模
深度学习有一个规律:数据越多、模型越大,效果越好。但RNN的顺序处理限制让它无法高效利用大规模数据集——训练太慢了。
Transformer可以高效并行,随着数据和参数的增加,效果持续提升,这才有了GPT、BERT、Claude、ChatGPT这些大语言模型。
1.6.2 Transformer的核心:注意力机制
RNN的信息传递(间接):
位置1 → 位置2 → 位置3 → ... → 位置N
每个位置只能从前一个位置获取信息
Transformer的注意力(直接):
每个位置同时关注所有其他位置
位置i ←→ 位置j (无论距离多远,一步直达)
注意力机制让每个词都能"直接看到"序列中的任何其他词,彻底解决了长距离依赖问题。关于注意力机制的详细原理,见下一篇Transformer架构详解。
1.6.3 RNN vs LSTM vs Transformer 对比总结
| 特性 | RNN | LSTM/GRU | Transformer |
|---|---|---|---|
| 并行训练 | 不能(顺序依赖) | 不能(顺序依赖) | 可以(全部并行) |
| 长距离依赖 | 差(梯度消失) | 较好(门机制) | 优秀(直接注意力) |
| 参数量 | 少 | 中等 | 多(但规模可大可小) |
| 训练速度 | 慢 | 慢 | 快(GPU利用率高) |
| 规模扩展性 | 弱 | 弱 | 极强(支撑GPT-4等) |
| 实时推理 | 高效(流式处理) | 高效(流式处理) | 较重 |
| 适用现状 | 基本被取代 | 部分场景仍用 | NLP主流 |
RNN/LSTM仍然在一些特殊场景有用:
- 边缘设备实时推理:参数少,可以流式处理每个输入
- 强化学习中的序列决策:LSTM仍是很多RL架构的选择
- 时间序列预测(短序列):金融、传感器等领域
1.7 七、完整代码示例:三种架构对比
以下代码构建三个等价的情感分析模型(RNN、LSTM、GRU),让你直观感受它们的异同:
import torch
import torch.nn as nn
import time
class SequenceClassifier(nn.Module):
"""通用序列分类器,支持RNN/LSTM/GRU三种架构"""
def __init__(self, vocab_size, embed_dim, hidden_size, num_classes,
model_type='lstm', num_layers=2, dropout=0.3):
super().__init__()
self.model_type = model_type
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.dropout = nn.Dropout(dropout)
# 根据model_type选择不同的循环层
if model_type == 'rnn':
self.recurrent = nn.RNN(
embed_dim, hidden_size, num_layers,
batch_first=True, dropout=dropout if num_layers > 1 else 0
)
elif model_type == 'lstm':
self.recurrent = nn.LSTM(
embed_dim, hidden_size, num_layers,
batch_first=True, dropout=dropout if num_layers > 1 else 0
)
elif model_type == 'gru':
self.recurrent = nn.GRU(
embed_dim, hidden_size, num_layers,
batch_first=True, dropout=dropout if num_layers > 1 else 0
)
self.classifier = nn.Linear(hidden_size, num_classes)
def forward(self, x):
embedded = self.dropout(self.embedding(x))
if self.model_type == 'lstm':
output, (h_n, c_n) = self.recurrent(embedded)
else:
output, h_n = self.recurrent(embedded)
# 取最后一层的最后一个时间步
last_hidden = h_n[-1] # (batch, hidden)
logits = self.classifier(self.dropout(last_hidden))
return logits
def benchmark_models():
"""对比三种架构的参数量和推理速度"""
vocab_size = 10000
embed_dim = 128
hidden_size = 256
num_classes = 2
batch_size = 64
seq_len = 100 # 序列长度100
results = {}
for model_type in ['rnn', 'gru', 'lstm']:
model = SequenceClassifier(
vocab_size, embed_dim, hidden_size, num_classes,
model_type=model_type
)
model.eval()
# 计算参数量
params = sum(p.numel() for p in model.parameters())
# 测试推理速度(10次取平均)
x = torch.randint(0, vocab_size, (batch_size, seq_len))
with torch.no_grad():
# 预热
_ = model(x)
start = time.time()
for _ in range(10):
_ = model(x)
elapsed = (time.time() - start) / 10 * 1000 # 毫秒
results[model_type] = {'params': params, 'time_ms': elapsed}
print(f"{model_type.upper():4s} | 参数: {params:>10,} | 推理时间: {elapsed:.2f}ms")
print(f"\n参数比 LSTM/RNN: {results['lstm']['params']/results['rnn']['params']:.1f}x")
print(f"参数比 GRU/RNN: {results['gru']['params']/results['rnn']['params']:.1f}x")
benchmark_models()
# 典型输出(实际数值因硬件和batch大小而异):
# RNN | 参数: 678,402 | 推理时间: X.XXms
# GRU | 参数: 1,970,178 | 推理时间: X.XXms
# LSTM | 参数: 2,625,538 | 推理时间: X.XXms
#
# 参数比 LSTM/RNN: 3.9x
# 参数比 GRU/RNN: 2.9x
1.8 八、小结:三大架构的分工
经过这篇文章,我们完成了序列模型的完整进化历程:
时间轴(深度学习中的序列建模进化史):
1986年 反向传播算法
↓
1990年 简单RNN — 第一次有了"记忆"
↓ 问题:梯度消失,记不住长距离信息
1997年 LSTM — 用"门"控制记忆的读写
↓ 问题:参数多、无法并行
2014年 GRU — 更简洁的LSTM
↓ 问题:仍然无法并行
2015年 注意力机制 — 任意两个位置直接建立联系
↓
2017年 Transformer — 完全基于注意力,不需要RNN
↓ 特点:可并行、长距离依赖完美处理
2018年 BERT、GPT — 预训练Transformer
↓
2020年+ ChatGPT、Claude、Gemini — 大语言模型时代
三大架构的分工(在2024年的视角):
CNN(卷积神经网络)
→ 擅长:空间数据(图像、视频)
→ 核心思想:局部特征,平移不变性
→ 现状:图像领域仍是主流(ResNet、EfficientNet等)
RNN / LSTM / GRU(循环神经网络家族)
→ 擅长:时序数据(语音识别、时间序列预测、边缘实时推理)
→ 核心思想:隐状态传递历史信息
→ 现状:在NLP已基本被Transformer取代,
语音和时间序列领域仍有重要应用
Transformer
→ 擅长:序列数据(文本、代码、蛋白质序列……)
→ 核心思想:注意力机制,任意位置直接交互
→ 现状:NLP的绝对主流,正在扩展到视觉(ViT)、
多模态(GPT-4V)等几乎所有领域
理解RNN对你理解LLM的意义:
尽管现代LLM(GPT、Claude、Gemini)都基于Transformer,理解RNN仍然重要:
理解问题的来由:Transformer的每个设计决策,都是在解决RNN的某个具体缺陷。不理解RNN的问题,就很难理解Transformer为什么要这样设计。
注意力机制的对比:知道"RNN通过隐状态传递信息",才能欣赏"注意力机制直接建立任意位置联系"的优雅与革命性。
工程权衡:在资源受限的实际场景中,LSTM/GRU有时仍是更好的选择。了解各种架构的特点,才能做出合适的工程决策。
后续应用
学完本篇,你已经理解了序列建模的核心思想和LSTM的工作原理。接下来:
05. Transformer架构详解:深入理解注意力机制和Transformer的完整架构,直接对应GPT/BERT/Claude的工作原理
06. 深度学习与LLM的关系:从深度学习基础到实际应用LLM,了解作为应用开发者需要掌握到什么深度
RNN相关的实际应用方向(如果你对时序数据感兴趣):
- 时间序列预测:用LSTM预测股价、销售额、气温等
- 语音识别前端:很多语音系统的特征提取仍用LSTM
- 强化学习:部分RL算法(如PPO with LSTM)用LSTM处理历史状态
一道思考题:
文中提到"Transformer因为可以并行所以训练快",但推理时(生成回答时),GPT每次也是一个词一个词地生成——这时Transformer还能并行吗?(答案在Transformer那篇文章里。)