机器学习基础(十三):循环神经网络RNN与LSTM

一、引言

前面我们学习的 CNN 擅长处理空间数据(图像),但现实世界还有大量序列数据

  • 一句话里的词是有顺序的;
  • 股票价格随时间变化;
  • 音乐是一段连续的音符序列;
  • 传感器数据是时间序列。

这些数据有一个共同特点:当前状态与之前的状态有关。CNN 无法建模这种时序依赖,我们需要一种新的网络结构——循环神经网络(RNN)

这篇文章从 RNN 的基本结构出发,逐步深入到长短期记忆网络(LSTM),最后用一个文本情感分类的实战案例收尾。


二、为什么需要 RNN?

2.1 序列数据的挑战

假设我们要预测句子"我今天很开心"的情感。如果用普通神经网络:

  1. 把每个词编码成 one-hot 向量;
  2. 拼接成一个大向量输入全连接层;
  3. 输出情感类别。

问题很明显:

  • 丢失了顺序信息:"不 是 很 好" 和 "很 好 不 是" 被当成一样的输入;
  • 输入长度固定:不同长度的句子需要填充或截断;
  • 没有记忆:不知道前面的词是什么。

2.2 RNN 的核心思想

RNN 的关键设计:引入隐藏状态(Hidden State),让网络有"记忆"

在每个时间步,RNN 接收两个输入:

  • 当前输入 \(x_t\)(当前词/当前时刻的数据)
  • 上一时刻的隐藏状态 \(h_{t-1}\)(之前的记忆)

然后输出:

  • 当前时刻的隐藏状态 \(h_t\)(更新后的记忆)
  • 当前时刻的预测 \(y_t\)(可选)

三、RNN 的结构与公式

3.1 基本结构

RNN 的核心计算只有两步:

\[h_t = \tanh(W_{hh} h_{t-1} + W_{xh} x_t + b_h) \]

\[y_t = W_{hy} h_t + b_y \]

其中:

  • \(x_t\):时刻 \(t\) 的输入(维度 \(d_{in}\)
  • \(h_t\):时刻 \(t\) 的隐藏状态(维度 \(d_{hidden}\)
  • \(W_{xh}\):输入到隐藏的权重(\(d_{hidden} \times d_{in}\)
  • \(W_{hh}\):隐藏到隐藏的权重(\(d_{hidden} \times d_{hidden}\)
  • \(W_{hy}\):隐藏到输出的权重(\(d_{out} \times d_{hidden}\)
  • \(\tanh\):激活函数,输出范围 \((-1, 1)\)

3.2 展开视角

如果把 RNN 按时间展开,它就是一个很深的网络

        x1 → [RNN Cell] → h1 → [RNN Cell] → h2 → [RNN Cell] → h3
                ↑                ↑                ↑
               h0               h1               h2
              (初始)          (记忆1)          (记忆2)

关键洞察:所有时间步共享同一套参数 \((W_{hh}, W_{xh}, b_h)\),这大大减少了参数量。

3.3 PyTorch 实现

import torch
import torch.nn as nn

# 定义 RNN
rnn = nn.RNN(
    input_size=10,    # 输入特征维度(如词向量维度)
    hidden_size=20,   # 隐藏状态维度
    num_layers=1,     # RNN 层数
    batch_first=True  # 输入格式为 (batch, seq, feature)
)

# 输入: (batch_size, seq_len, input_size)
x = torch.randn(2, 5, 10)  # batch=2, 序列长度=5, 特征维度=10

# 前向传播
output, hidden = rnn(x)
# output: (2, 5, 20) - 每个时间步的输出
# hidden: (1, 2, 20) - 最后时刻的隐藏状态

四、RNN 的变体

根据输入输出的不同,RNN 有几种常见结构:

结构 输入 输出 典型应用
一对一 单输入 单输出 普通神经网络
一对多 单输入 序列输出 图像描述生成
多对一 序列输入 单输出 文本情感分类
多对多 序列输入 序列输出 机器翻译、语音识别
多对多(同步) 序列输入 每个输入对应输出 视频逐帧分类、NER

4.1 多对一:情感分类

class SentimentRNN(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, output_dim):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.rnn = nn.RNN(embed_dim, hidden_dim, batch_first=True)
        self.fc = nn.Linear(hidden_dim, output_dim)

    def forward(self, x):
        # x: (batch, seq_len)
        embedded = self.embedding(x)  # (batch, seq_len, embed_dim)
        _, hidden = self.rnn(embedded)  # hidden: (1, batch, hidden_dim)
        return self.fc(hidden.squeeze(0))  # (batch, output_dim)

4.2 多对多:序列生成

# 每个时间步都输出一个预测
class Seq2SeqRNN(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.rnn = nn.RNN(input_dim, hidden_dim, batch_first=True)
        self.fc = nn.Linear(hidden_dim, output_dim)

    def forward(self, x):
        output, _ = self.rnn(x)  # output: (batch, seq_len, hidden_dim)
        return self.fc(output)   # (batch, seq_len, output_dim)

五、RNN 的致命缺陷:梯度消失与梯度爆炸

5.1 问题现象

RNN 展开后是一个很深的网络(深度 = 序列长度)。当序列很长时:

  • 梯度消失:梯度在反向传播时不断乘以小于1的数,前面层的梯度趋近于0,模型"记不住"久远的信息;
  • 梯度爆炸:梯度不断乘以大于1的数,前面层的梯度指数级增长,参数更新失控。

5.2 数学解释

RNN 的反向传播涉及对 \(W_{hh}\) 的多次连乘:

\[\frac{\partial L}{\partial h_1} = \frac{\partial L}{\partial h_T} \cdot \prod_{t=2}^{T} \frac{\partial h_t}{\partial h_{t-1}} = \frac{\partial L}{\partial h_T} \cdot \prod_{t=2}^{T} W_{hh}^T \cdot \text{diag}(1 - \tanh^2(\cdot)) \]

如果 \(W_{hh}\) 的特征值大于1,梯度爆炸;小于1,梯度消失。

5.3 缓解方法

方法 原理 效果
梯度裁剪(Gradient Clipping) 设定梯度范数上限 防止梯度爆炸
使用 ReLU 替代 tanh 导数为0或1,缓解梯度消失 效果有限
更好的初始化 正交初始化 \(W_{hh}\) 部分缓解
LSTM/GRU 引入门控机制 根本解决

梯度裁剪代码:

loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5)  # 裁剪到范数5
optimizer.step()

六、LSTM:长短期记忆网络

6.1 核心思想

LSTM(Long Short-Term Memory)通过门控机制控制信息的流动:

  • 遗忘门:决定丢弃哪些旧信息;
  • 输入门:决定存储哪些新信息;
  • 输出门:决定输出哪些信息。

关键是引入细胞状态(Cell State) \(C_t\),它像一条"传送带",信息可以不变地流过,解决了梯度消失问题。

6.2 LSTM 的完整公式

遗忘门(forget gate):

\[f_t = \sigma(W_f \cdot [h_{t-1}, x_t] + b_f) \]

输入门(input gate):

\[i_t = \sigma(W_i \cdot [h_{t-1}, x_t] + b_i) \]

\[\tilde{C}_t = \tanh(W_C \cdot [h_{t-1}, x_t] + b_C) \]

更新细胞状态

\[C_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}_t \]

输出门(output gate):

\[o_t = \sigma(W_o \cdot [h_{t-1}, x_t] + b_o) \]

\[h_t = o_t \odot \tanh(C_t) \]

其中 \(\odot\) 表示逐元素乘法(Hadamard积),\(\sigma\) 是 sigmoid 函数(输出0~1,作为门的开关)。

6.3 LSTM 的直观理解

细胞状态 C_t-1  ───────────────────────────────→  C_t
                    ↑           ↑
               遗忘门 f_t    输入门 i_t
                    ↓           ↓
               丢弃旧信息    添加新信息
                    
隐藏状态 h_t-1 ──→ [LSTM Cell] ──→ h_t
        x_t    ──→           ──→ (输出)
  • 遗忘门 = 0:完全丢弃旧记忆;
  • 遗忘门 = 1:完全保留旧记忆;
  • 输入门 = 0:不添加新信息;
  • 输入门 = 1:完全接受新候选。

6.4 PyTorch 实现 LSTM

lstm = nn.LSTM(
    input_size=10,
    hidden_size=20,
    num_layers=2,      # 堆叠2层LSTM
    batch_first=True,
    dropout=0.3        # 层间dropout(仅num_layers>1时有效)
)

x = torch.randn(2, 5, 10)  # (batch, seq_len, input_size)

# LSTM 返回 output 和 (hidden, cell)
output, (hidden, cell) = lstm(x)
# output: (2, 5, 20) - 每个时间步的输出
# hidden: (2, 2, 20) - 每层最后时刻的隐藏状态
# cell:   (2, 2, 20) - 每层最后时刻的细胞状态

6.5 GRU:LSTM 的简化版

GRU(Gated Recurrent Unit)将遗忘门和输入门合并为更新门,结构更简单:

\[z_t = \sigma(W_z \cdot [h_{t-1}, x_t]) \quad \text{(更新门)} \]

\[r_t = \sigma(W_r \cdot [h_{t-1}, x_t]) \quad \text{(重置门)} \]

\[\tilde{h}_t = \tanh(W \cdot [r_t \odot h_{t-1}, x_t]) \]

\[h_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t \]

GRU vs LSTM

  • GRU 参数量更少,训练更快;
  • 效果上两者相当,GRU 在数据量小时可能更好;
  • LSTM 表达能力更强,在复杂任务上略优。
# PyTorch GRU
gru = nn.GRU(input_size=10, hidden_size=20, num_layers=1, batch_first=True)
output, hidden = gru(x)

七、双向 RNN 与多层堆叠

7.1 双向 RNN(Bi-RNN)

有些任务需要同时考虑过去和未来的信息,比如命名实体识别(NER):

"我在北京工作" —— 知道"工作"在后面,才能确定"北京"是地点而非人名。

双向 RNN 同时运行两个 RNN:

  • 一个正向处理序列(从左到右);
  • 一个反向处理序列(从右到左);
  • 最后拼接两个方向的隐藏状态。
# 双向 LSTM
bi_lstm = nn.LSTM(
    input_size=10,
    hidden_size=20,
    num_layers=1,
    batch_first=True,
    bidirectional=True  # 启用双向
)

output, _ = bi_lstm(x)
# output: (batch, seq_len, 40) - 20*2,两个方向拼接

7.2 多层堆叠

像 CNN 一样,RNN 也可以堆叠多层,提取更高层次的特征:

# 3层双向 LSTM
stacked_lstm = nn.LSTM(
    input_size=10,
    hidden_size=20,
    num_layers=3,
    batch_first=True,
    bidirectional=True,
    dropout=0.3  # 层间dropout
)

八、综合实战:IMDB 情感分类

下面用 LSTM 实现一个完整的电影评论情感分类模型:

import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import datasets
import torchvision.transforms as transforms

# 加载 IMDB 数据集(使用 torchtext 或手动处理)
# 这里简化展示核心模型结构

class LSTMSentiment(nn.Module):
    def __init__(self, vocab_size, embed_dim=128, hidden_dim=256, 
                 num_layers=2, output_dim=1, dropout=0.5):
        super().__init__()
        
        # 词嵌入层
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        
        # 双向 LSTM
        self.lstm = nn.LSTM(
            embed_dim, 
            hidden_dim, 
            num_layers=num_layers,
            bidirectional=True,
            dropout=dropout if num_layers > 1 else 0,
            batch_first=True
        )
        
        # 分类层
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_dim * 2, output_dim)  # *2 for bidirectional
        
    def forward(self, text, lengths):
        # text: (batch, seq_len)
        embedded = self.dropout(self.embedding(text))  # (batch, seq_len, embed_dim)
        
        # Pack sequence for efficient processing
        packed = nn.utils.rnn.pack_padded_sequence(
            embedded, lengths.cpu(), batch_first=True, enforce_sorted=False
        )
        
        packed_output, (hidden, cell) = self.lstm(packed)
        
        # hidden: (num_layers * 2, batch, hidden_dim)
        # 取最后一层的正向和反向隐藏状态
        hidden = torch.cat((hidden[-2], hidden[-1]), dim=1)  # (batch, hidden_dim * 2)
        
        hidden = self.dropout(hidden)
        return self.fc(hidden)  # (batch, 1)

# 训练循环
def train_epoch(model, iterator, optimizer, criterion):
    model.train()
    epoch_loss = 0
    
    for batch in iterator:
        text, text_lengths = batch.text
        labels = batch.label.float()
        
        optimizer.zero_grad()
        predictions = model(text, text_lengths).squeeze(1)
        loss = criterion(predictions, labels)
        
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1)  # 梯度裁剪
        optimizer.step()
        
        epoch_loss += loss.item()
    
    return epoch_loss / len(iterator)

# 使用示例
VOCAB_SIZE = 10000
model = LSTMSentiment(VOCAB_SIZE)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.BCEWithLogitsLoss()

# 假设有数据迭代器 train_iterator
# for epoch in range(10):
#     loss = train_epoch(model, train_iterator, optimizer, criterion)
#     print(f"Epoch {epoch+1}, Loss: {loss:.4f}")

九、RNN 的局限与 Transformer 的崛起

9.1 RNN/LSTM 的问题

尽管 LSTM 缓解了梯度消失,但仍有一些根本局限:

问题 说明
串行计算 必须按顺序处理,无法并行,训练慢;
长程依赖仍有限 超过100步的依赖仍难以捕捉;
难以捕捉全局关系 "它"指代句子开头的哪个词?需要全局注意力。

9.2 Transformer 的解决方案

2017年,Transformer 架构提出自注意力机制(Self-Attention)

  • 完全并行:一次性看到整个序列;
  • 任意距离依赖:任意两个位置的关联直接计算;
  • 成为现代 NLP/CV 的基石:BERT、GPT、ViT 都基于 Transformer。

RNN 并未完全淘汰

  • 资源受限场景(移动端);
  • 实时流式处理(必须按顺序);
  • 小数据集(RNN 参数少,不易过拟合)。

十、总结

知识点 要点
RNN 核心 隐藏状态传递记忆,参数共享;
梯度问题 长序列导致梯度消失/爆炸;
LSTM 解决 门控机制 + 细胞状态,保留长期记忆;
GRU LSTM 的简化版,效果相当;
双向/多层 捕捉双向信息,提取层次特征;
实战技巧 梯度裁剪、pack_padded_sequence、取最后时刻隐藏状态;
演进路线 RNN → LSTM/GRU → Attention → Transformer。

关键洞察:RNN 教会了我们如何建模序列,LSTM 教会了我们如何用门控保护梯度。这些思想在 Transformer 中依然延续——只是换了一种实现方式。


附录:RNN/LSTM 速查表

# RNN
nn.RNN(input_size, hidden_size, num_layers, batch_first=True)

# LSTM
nn.LSTM(input_size, hidden_size, num_layers, 
        batch_first=True, bidirectional=True, dropout=0.5)

# GRU
nn.GRU(input_size, hidden_size, num_layers, batch_first=True)

# 梯度裁剪
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

# 序列填充(处理变长序列)
from torch.nn.utils.rnn import pad_sequence, pack_padded_sequence
padded = pad_sequence(sequences, batch_first=True, padding_value=0)
packed = pack_padded_sequence(padded, lengths, batch_first=True, enforce_sorted=False)
posted @ 2026-04-07 21:00  YZG5N  阅读(96)  评论(0)    收藏  举报