课程0基础Agent开发课 / 深度学习基础 / RNN与序列模型-从循环神经网络到LSTM
— 32 min read

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,数据的含义与其出现顺序强相关):

code
"我爱你"  ≠  "你爱我"  ≠  "爱我你"

同样三个字,顺序不同,意思完全不同。更重要的是,理解文本往往需要"记住前面说过什么"。

1.1.2 一个翻译的例子:为什么需要记忆?

考虑将下面这句话翻译成英语:

code
"小明昨天去了图书馆,借了三本关于人工智能的书,回家读了一整晚。"

翻译到最后的"回家读了一整晚"时,你需要知道:

  • 谁去了图书馆?→ 小明(句子开头的信息)
  • 读的是什么?→ 那三本书(中间的信息)

一个"没有记忆"的网络,处理到句尾时,早就"忘了"句首的信息。

CNN能做到这个吗?不能。CNN的卷积核是固定大小的滑动窗口,只能看局部。全连接网络呢?它能看整句话,但它的输入长度是固定的——句子长度不同怎么办?而且它没有建模"顺序"这个概念,对网络来说,输入顺序打乱和不打乱没有区别。

这就是**RNN(Recurrent Neural Network,循环神经网络)**登场的原因:专门为处理序列数据(文本、语音、股票价格、传感器数据……)而设计。


1.2 二、RNN的核心思想:带记忆的神经网络

1.2.1 用类比建立直觉:阅读理解

想象你在阅读一段文字。你读每个词时:

  1. 你用当前词(当前输入)更新你的理解
  2. 你的理解不仅来自当前词,还来自你之前读过的内容(历史记忆)
  3. 读完每个词,你的理解状态也随之更新

这就是RNN的核心机制,只不过"你的理解状态"在RNN里叫做隐状态(hidden state)

1.2.2 RNN的工作流程

code
时刻 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图表示:

code
      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的核心更新公式只有一行:

code
hₜ = tanh(W · xₜ + U · hₜ₋₁ + b)
  • xₜ:当前时刻的输入(比如当前词的词向量)
  • hₜ₋₁:上一时刻的隐状态(历史记忆)
  • W:输入权重矩阵(学习如何利用当前输入)
  • U:循环权重矩阵(学习如何利用历史信息)
  • b:偏置项
  • tanh:双曲正切激活函数(将结果压缩到-1到1之间,防止数值爆炸)

输出层(如果需要的话):

code
yₜ = softmax(V · hₜ + c)

1.2.4 PyTorch代码:从零实现一个简单RNN

python
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 一个完整的情感分析示例

python
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层深的网络。

梯度在时间轴上反向传播时,要经历大量的乘法。每经过一个时间步,梯度都会乘以循环权重矩阵的某个数值。

用数字说明

python
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,梯度会指数级增长,导致参数更新幅度巨大,训练完全失控。

python
# 梯度爆炸示例
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记不住久远的信息

经典例子:

code
"我在[北京]出生,从小学中文,后来去了美国,
 在纽约工作了10年,每天说英文,偶尔回国,
 和同事交流时说的是___语。"

答案是"普通话",需要关联到句子开头的"北京"。对于RNN,这个距离太远了,"北京"的信息早就在层层传递中消失。

code
信息流动路径:
"北京" → h₁ → h₂ → h₃ → ... → h₅₀ → ... → h₁₀₀ → 预测"普通话"

每经过一步,"北京"的信号就更微弱一点。
经过100步,信号几乎完全淹没在噪声中。

1.3.3 问题3:无法并行,训练慢

RNN有一个结构性缺陷:必须按顺序处理

计算 h₂ 必须先有 h₁,计算 h₃ 必须先有 h₂……所有时间步必须串行计算,无法利用GPU的并行计算能力。

code
RNN的计算依赖:
h₁ → h₂ → h₃ → h₄ → h₅
↑    ↑    ↑    ↑    ↑
串行!GPU空转等待

相比之下,现代GPU可以同时进行几千个矩阵计算。RNN的顺序依赖让GPU的并行优势几乎完全浪费。这在句子很长(数百词)或训练数据量极大时,是致命的性能瓶颈。


1.4 四、LSTM:给RNN装上"记忆开关"

RNN vs LSTM结构对比图
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想象成一位研究员在做工作笔记:

code
细胞状态(Cell State):正式的笔记本
  - 记录重要的长期信息
  - 不随意修改,有章可循

隐状态(Hidden State):工作台上当前关注的内容
  - 当前正在处理的信息
  - 会频繁更新

三个门:

1. 遗忘门(Forget Gate)——"这条笔记还有用吗?"
   看当前输入,决定笔记本里哪些内容可以擦掉

2. 输入门(Input Gate)——"这个新信息值得记下来吗?"
   看当前输入,决定哪些新信息值得写入笔记本

3. 输出门(Output Gate)——"现在需要查阅哪些笔记?"
   决定笔记本里哪些内容要拿出来当前使用

1.4.3 LSTM的数学公式

LSTM的四个核心计算(每个时间步执行一次):

code
# 遗忘门:决定遗忘多少旧记忆
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能解决梯度消失?

关键在细胞状态的更新公式:

code
Cₜ = fₜ ⊙ Cₜ₋₁ + iₜ ⊙ C̃ₜ

注意这里有加法!在反向传播时,加法的梯度直接流过去,不会像乘法那样逐步衰减。

如果遗忘门 fₜ ≈ 1(网络学到"要记住这段信息"),那么梯度可以沿着细胞状态直接流回很早的时间步,几乎没有衰减。

code
RNN梯度路径:乘 → 乘 → 乘 → 乘 → ... (连续乘法,信号指数衰减)

LSTM梯度路径:
细胞状态:加 → 加 → 加 → 加 → ...  (加法主路径,信号稳定传递)
                                        ↑
                                  这就是LSTM的核心创新!

1.4.5 PyTorch代码:LSTM实现情感分析

python
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:同时从两个方向读

code
单向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的两个门

code
LSTM有:遗忘门、输入门、输出门 + 细胞状态、隐状态(共5个量)

GRU有:
  重置门(Reset Gate)— 决定"用多少历史信息计算候选状态"
  更新门(Update Gate)— 同时扮演LSTM遗忘门和输入门的角色

只有一个状态:隐状态 hₜ(没有独立的细胞状态)

GRU的公式:

code
# 重置门:决定历史信息的使用比例
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

python
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:如何选择?

code
经验原则:

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:必须顺序处理(无法并行)

code
RNN的计算依赖链:
h₁ → h₂ → h₃ → ... → h₁₀₀₀

GPU有几千个计算核心可以同时工作,
但RNN只能用1个,其他的在空转等待。

训练一个长序列模型:
  RNN需要等待1000步串行计算
  Transformer所有位置同时并行计算

在大规模数据上,这是10-100倍的训练速度差距。

限制2:长距离依赖仍然困难

即使有了LSTM的门机制,"记忆"仍然通过隐状态一步步传递。对于特别长的文档(几千词),早期信息依然容易丢失。

LSTM改善了梯度消失,但没有根本解决信息必须通过"中间人"传递的问题:

code
"北京"的信息要到达词1000:
RNN/LSTM路径:北京 → h₁ → h₂ → ... → h₁₀₀₀(经过999步)
Transformer路径:北京 直接 → 词1000(一步到达!)

限制3:无法充分利用规模

深度学习有一个规律:数据越多、模型越大,效果越好。但RNN的顺序处理限制让它无法高效利用大规模数据集——训练太慢了。

Transformer可以高效并行,随着数据和参数的增加,效果持续提升,这才有了GPT、BERT、Claude、ChatGPT这些大语言模型。

1.6.2 Transformer的核心:注意力机制

code
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),让你直观感受它们的异同:

python
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 八、小结:三大架构的分工

经过这篇文章,我们完成了序列模型的完整进化历程:

code
时间轴(深度学习中的序列建模进化史):

1986年 反向传播算法
  ↓
1990年 简单RNN — 第一次有了"记忆"
  ↓  问题:梯度消失,记不住长距离信息
1997年 LSTM — 用"门"控制记忆的读写
  ↓  问题:参数多、无法并行
2014年 GRU — 更简洁的LSTM
  ↓  问题:仍然无法并行
2015年 注意力机制 — 任意两个位置直接建立联系
  ↓
2017年 Transformer — 完全基于注意力,不需要RNN
  ↓  特点:可并行、长距离依赖完美处理
2018年 BERT、GPT — 预训练Transformer
  ↓
2020年+ ChatGPT、Claude、Gemini — 大语言模型时代

三大架构的分工(在2024年的视角):

code
CNN(卷积神经网络)
  → 擅长:空间数据(图像、视频)
  → 核心思想:局部特征,平移不变性
  → 现状:图像领域仍是主流(ResNet、EfficientNet等)

RNN / LSTM / GRU(循环神经网络家族)
  → 擅长:时序数据(语音识别、时间序列预测、边缘实时推理)
  → 核心思想:隐状态传递历史信息
  → 现状:在NLP已基本被Transformer取代,
           语音和时间序列领域仍有重要应用

Transformer
  → 擅长:序列数据(文本、代码、蛋白质序列……)
  → 核心思想:注意力机制,任意位置直接交互
  → 现状:NLP的绝对主流,正在扩展到视觉(ViT)、
           多模态(GPT-4V)等几乎所有领域

理解RNN对你理解LLM的意义

尽管现代LLM(GPT、Claude、Gemini)都基于Transformer,理解RNN仍然重要:

  1. 理解问题的来由:Transformer的每个设计决策,都是在解决RNN的某个具体缺陷。不理解RNN的问题,就很难理解Transformer为什么要这样设计。

  2. 注意力机制的对比:知道"RNN通过隐状态传递信息",才能欣赏"注意力机制直接建立任意位置联系"的优雅与革命性。

  3. 工程权衡:在资源受限的实际场景中,LSTM/GRU有时仍是更好的选择。了解各种架构的特点,才能做出合适的工程决策。


后续应用

学完本篇,你已经理解了序列建模的核心思想和LSTM的工作原理。接下来:

RNN相关的实际应用方向(如果你对时序数据感兴趣):

  • 时间序列预测:用LSTM预测股价、销售额、气温等
  • 语音识别前端:很多语音系统的特征提取仍用LSTM
  • 强化学习:部分RL算法(如PPO with LSTM)用LSTM处理历史状态

一道思考题
文中提到"Transformer因为可以并行所以训练快",但推理时(生成回答时),GPT每次也是一个词一个词地生成——这时Transformer还能并行吗?(答案在Transformer那篇文章里。)

本页目录