跳转至

09-循环神经网络

学习时间: 约6-8小时 难度级别: ⭐⭐⭐ 中级 前置知识: 神经网络基础、反向传播算法、优化器 学习目标: 理解序列建模思想,掌握RNN/LSTM/GRU的数学原理与实现,了解Seq2Seq与注意力机制的演进


🎯 学习目标

  1. 理解序列数据的特点与建模难题
  2. 掌握RNN的基本结构、前向传播与反向传播(BPTT)
  3. 深入理解梯度消失/爆炸问题及其根本原因
  4. 精通LSTM三门机制(遗忘门、输入门、输出门)的数学推导
  5. 掌握GRU结构,理解其与LSTM的区别和联系
  6. 了解双向RNN、深层RNN、Seq2Seq模型
  7. 理解注意力机制从Seq2Seq到Transformer的演进
  8. 能用PyTorch实现LSTM文本分类任务

目录


1. 序列数据的特点

1.1 什么是序列数据

序列数据是指数据元素之间存在有序依赖关系的数据,元素的位置和顺序包含重要信息。常见例子:

  • 自然语言:一个句子是单词的有序序列,语法和语义依赖于词序
  • 时间序列:股票价格、气温变化、心电图信号
  • 语音信号:音频帧的时序序列
  • DNA序列:碱基的有序排列(A, T, C, G)
  • 视频:图像帧的时序序列
  • 用户行为:点击流、购买历史

1.2 序列建模的挑战

  1. 可变长度:不同序列长度不同(句子有长有短),全连接网络要求固定维度输入
  2. 长距离依赖:序列中可能存在远距离的关联(如"The cat, which ate the fish, was happy"中was与cat的一致)
  3. 位置敏感:同样的元素在不同位置含义不同("dog bites man" vs "man bites dog")
  4. 时序因果:当前步的输出只能依赖于过去和当前的输入(在线/流式场景)

1.3 为什么需要RNN

传统方法处理序列的局限: - n-gram模型:只考虑有限窗口,无法捕获长距离依赖 - 全连接网络:无法处理变长输入,不共享时间步间的参数 - 1D-CNN:可以处理序列,但感受野有限

RNN通过隐状态递推实现:(1)处理任意长度序列;(2)参数在时间步间共享;(3)理论上可捕获任意长距离依赖。


2. RNN基本结构

RNN基本结构(未展开)

图注:RNN基本结构(未展开)

2.1 核心思想

RNN在每个时间步 \(t\) 维护一个隐藏状态 \(\mathbf{h}_t\),它是对过去所有输入的压缩记忆。

2.2 数学公式

\[\mathbf{h}_t = \tanh(\mathbf{W}_{hh} \mathbf{h}_{t-1} + \mathbf{W}_{xh} \mathbf{x}_t + \mathbf{b}_h)\]
\[\mathbf{y}_t = \mathbf{W}_{hy} \mathbf{h}_t + \mathbf{b}_y\]

其中: - \(\mathbf{x}_t \in \mathbb{R}^d\):时间步 \(t\) 的输入 - \(\mathbf{h}_t \in \mathbb{R}^h\):时间步 \(t\) 的隐藏状态 - \(\mathbf{h}_{t-1}\):上一时间步的隐藏状态(\(\mathbf{h}_0\) 通常初始化为零) - \(\mathbf{W}_{xh} \in \mathbb{R}^{h \times d}\):输入到隐藏的权重矩阵 - \(\mathbf{W}_{hh} \in \mathbb{R}^{h \times h}\):隐藏到隐藏的权重矩阵 - \(\mathbf{W}_{hy} \in \mathbb{R}^{o \times h}\):隐藏到输出的权重矩阵

2.3 时间展开图

将RNN沿时间步展开,可以看到它等价于一个非常深的前馈网络,每一层对应一个时间步,且所有层共享相同的参数\(\mathbf{W}_{xh}, \mathbf{W}_{hh}, \mathbf{W}_{hy}\)):

RNN时间展开图

图注:RNN沿时间步展开,所有RNN单元共享相同的参数

2.4 参数共享的意义

参数共享使RNN具有以下优势: - 泛化性:同一规则应用于所有时间步,可处理训练时未见过的序列长度 - 参数效率:参数量与序列长度无关 - 平移不变性:模式在序列中任何位置出现都能被检测到

Python
import torch
import torch.nn as nn

# PyTorch RNN基本用法
rnn = nn.RNN(input_size=10, hidden_size=64, num_layers=1, batch_first=True)

# 输入: (batch_size, seq_len, input_size)
x = torch.randn(32, 20, 10)  # batch=32, 序列长度=20, 输入维度=10
h0 = torch.zeros(1, 32, 64)  # 初始隐藏状态

output, hn = rnn(x, h0)
# output: (32, 20, 64) — 每个时间步的隐藏状态
# hn: (1, 32, 64) — 最后时间步的隐藏状态

3. RNN的训练:BPTT

时间反向传播(BPTT)

图注:时间反向传播(BPTT) - 蓝色箭头表示前向传播,红色虚线箭头表示反向传播

3.1 时间反向传播(Backpropagation Through Time)

BPTT是反向传播算法在RNN上的扩展。将RNN沿时间展开后,按标准反向传播计算梯度。

总损失是所有时间步损失的累加:

\[\mathcal{L} = \sum_{t=1}^{T} \mathcal{L}_t\]

对参数 \(\mathbf{W}_{hh}\) 的梯度需要沿时间回传:

\[\frac{\partial \mathcal{L}}{\partial \mathbf{W}_{hh}} = \sum_{t=1}^{T} \frac{\partial \mathcal{L}_t}{\partial \mathbf{W}_{hh}}\]

其中每一项涉及链式法则沿时间步回传:

\[\frac{\partial \mathcal{L}_t}{\partial \mathbf{W}_{hh}} = \sum_{k=1}^{t} \frac{\partial \mathcal{L}_t}{\partial \mathbf{h}_t} \left(\prod_{j=k+1}^{t} \frac{\partial \mathbf{h}_j}{\partial \mathbf{h}_{j-1}}\right) \frac{\partial \mathbf{h}_k}{\partial \mathbf{W}_{hh}}\]

3.2 截断BPTT(Truncated BPTT)

完整BPTT计算和存储代价大。截断BPTT将长序列分成固定长度的块,每块独立反向传播,只在块之间传递隐藏状态(不传递梯度)。

这是时间和精度的折中——减少了长距离依赖的捕获能力,但显著降低了计算开销。


4. 梯度消失与梯度爆炸

梯度消失与梯度爆炸

图注:梯度消失(左)和梯度爆炸(右)的可视化

4.1 问题根源

从BPTT的梯度公式中,关键项是连续的雅可比矩阵乘积:

\[\prod_{j=k+1}^{t} \frac{\partial \mathbf{h}_j}{\partial \mathbf{h}_{j-1}} = \prod_{j=k+1}^{t} \text{diag}(\tanh'(\mathbf{z}_j)) \cdot \mathbf{W}_{hh}\]

其中 \(\tanh'(\cdot) \in (0, 1]\)

\(t - k\) 很大时: - 如果 \(\|\mathbf{W}_{hh}\|\) 的最大特征值 \(< 1\):梯度指数衰减梯度消失 - 如果 \(\|\mathbf{W}_{hh}\|\) 的最大特征值 \(> 1\):梯度指数增长梯度爆炸

4.2 梯度消失的后果

  • 模型无法学习长距离依赖
  • 靠前时间步的输入对后面的输出几乎没有影响
  • 实际中,标准RNN只能有效处理约10-20步的依赖

4.3 梯度爆炸的解决

梯度裁剪(Gradient Clipping):当梯度范数超过阈值时,按比例缩放。

\[\mathbf{g} \leftarrow \frac{\tau}{\|\mathbf{g}\|} \cdot \mathbf{g} \quad \text{if } \|\mathbf{g}\| > \tau\]
Python
# PyTorch梯度裁剪
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)

4.4 梯度消失的解决

梯度消失是更根本的问题,需要架构层面的创新 → LSTM和GRU。


5. LSTM详解

LSTM单元结构(三门机制)

图注:LSTM单元结构,包含遗忘门、输入门、输出门和细胞状态

5.1 核心思想

长短期记忆网络(Long Short-Term Memory, Hochreiter & Schmidhuber, 1997)通过引入细胞状态(cell state)和门控机制来解决梯度消失问题。

核心直觉:细胞状态像一条"传送带",信息可以无损地在时间步间流动,门控决定何时写入、保留和读取信息。

5.2 三门机制 + 细胞状态

LSTM在每个时间步包含三个门和一个细胞状态更新:

① 遗忘门(Forget Gate) — 决定丢弃什么旧信息

\[\mathbf{f}_t = \sigma(\mathbf{W}_f [\mathbf{h}_{t-1}, \mathbf{x}_t] + \mathbf{b}_f)\]

\(\mathbf{f}_t \in (0, 1)^h\),每个元素控制对应细胞状态的保留程度。\(\sigma\) 是Sigmoid函数。

② 输入门(Input Gate) — 决定写入什么新信息

\[\mathbf{i}_t = \sigma(\mathbf{W}_i [\mathbf{h}_{t-1}, \mathbf{x}_t] + \mathbf{b}_i)\]
\[\tilde{\mathbf{C}}_t = \tanh(\mathbf{W}_C [\mathbf{h}_{t-1}, \mathbf{x}_t] + \mathbf{b}_C)\]

\(\mathbf{i}_t\) 是输入门的激活值,\(\tilde{\mathbf{C}}_t\) 是候选细胞状态。

③ 细胞状态更新

\[\mathbf{C}_t = \mathbf{f}_t \odot \mathbf{C}_{t-1} + \mathbf{i}_t \odot \tilde{\mathbf{C}}_t\]

\(\odot\) 表示逐元素乘法。旧信息经遗忘门过滤,新信息经输入门选择性写入。

④ 输出门(Output Gate) — 决定输出什么

\[\mathbf{o}_t = \sigma(\mathbf{W}_o [\mathbf{h}_{t-1}, \mathbf{x}_t] + \mathbf{b}_o)\]
\[\mathbf{h}_t = \mathbf{o}_t \odot \tanh(\mathbf{C}_t)\]

5.3 为什么LSTM解决梯度消失

关键在于细胞状态的更新方程:

\[\mathbf{C}_t = \mathbf{f}_t \odot \mathbf{C}_{t-1} + \mathbf{i}_t \odot \tilde{\mathbf{C}}_t\]

\(\mathbf{C}_{t-1}\) 的梯度:

\[\frac{\partial \mathbf{C}_t}{\partial \mathbf{C}_{t-1}} = \text{diag}(\mathbf{f}_t)\]

当遗忘门 \(\mathbf{f}_t\) 接近1时,梯度几乎无损传递,类似ResNet的跳跃连接。网络可以学习何时"记住"(\(\mathbf{f}_t \approx 1\))、何时"遗忘"(\(\mathbf{f}_t \approx 0\))。

5.4 LSTM信息流图示

Text Only
                    C_{t-1} ──────×─────────+────────→ C_t
                              │  (forget)  │  (input)    │
x_t ──┐                      │            │             │
      ├─→ [σ] ─── f_t ──────┘            │             │
      ├─→ [σ] ─── i_t ──────┐            │             │
h_{t-1}┤                      ├──×─────────┘             │
      ├─→ [tanh] ─ C̃_t ────┘                          │
      │                                                 │
      └─→ [σ] ─── o_t ──────────────×──── tanh(C_t) → h_t

5.5 LSTM参数量

LSTM每层的参数量为 \(4 \times (d \times h + h \times h + h) = 4h(d + h + 1)\)(四个门共享相同的参数结构),其中 \(d\) 是输入维度,\(h\) 是隐藏维度。

Python
# PyTorch LSTM
lstm = nn.LSTM(
    input_size=128,     # 输入维度
    hidden_size=256,    # 隐藏状态维度
    num_layers=2,       # 层数
    batch_first=True,   # (batch, seq, feature)
    dropout=0.3,        # 层间dropout
    bidirectional=False  # 单向
)

x = torch.randn(32, 50, 128)  # (batch, seq_len, input_size)
output, (hn, cn) = lstm(x)
# output: (32, 50, 256)  每个时间步的隐藏状态
# hn: (2, 32, 256)       每层最后时间步的隐藏状态
# cn: (2, 32, 256)       每层最后时间步的细胞状态

6. GRU详解

GRU单元结构(更新门+重置门)

图注:GRU单元结构,包含更新门和重置门

6.1 核心思想

门控循环单元(Gated Recurrent Unit, Cho et al., 2014)是LSTM的简化版本,合并了遗忘门和输入门,去掉了细胞状态。

6.2 GRU公式

重置门(Reset Gate) — 控制如何将新输入与之前的记忆组合

\[\mathbf{r}_t = \sigma(\mathbf{W}_r [\mathbf{h}_{t-1}, \mathbf{x}_t] + \mathbf{b}_r)\]

更新门(Update Gate) — 决定保留多少旧信息、接受多少新信息

\[\mathbf{z}_t = \sigma(\mathbf{W}_z [\mathbf{h}_{t-1}, \mathbf{x}_t] + \mathbf{b}_z)\]

候选隐藏状态 — 使用重置门选择性遗忘

\[\tilde{\mathbf{h}}_t = \tanh(\mathbf{W}_h [\mathbf{r}_t \odot \mathbf{h}_{t-1}, \mathbf{x}_t] + \mathbf{b}_h)\]

隐藏状态更新 — 更新门在旧状态和候选状态之间插值

\[\mathbf{h}_t = (1 - \mathbf{z}_t) \odot \mathbf{h}_{t-1} + \mathbf{z}_t \odot \tilde{\mathbf{h}}_t\]

6.3 GRU vs LSTM 对比

LSTM vs GRU对比

图注:LSTM与GRU的详细对比

特性 LSTM GRU
门的数量 3个(遗忘、输入、输出) 2个(重置、更新)
状态 隐藏状态 \(\mathbf{h}\) + 细胞状态 \(\mathbf{C}\) 仅隐藏状态 \(\mathbf{h}\)
参数量 \(4h(d+h+1)\) \(3h(d+h+1)\)(少25%)
训练速度 较慢 较快
长序列建模 更好(细胞状态独立传递) 稍差
短序列/小数据 可能过拟合 更适合
实际性能 通常相当 通常相当

经验法则:两者性能通常非常接近。数据充足时用LSTM,数据量小或序列短时用GRU。不确定时直接实验对比。

Python
# PyTorch GRU
gru = nn.GRU(input_size=128, hidden_size=256, num_layers=2,
             batch_first=True, dropout=0.3)

x = torch.randn(32, 50, 128)
output, hn = gru(x)  # 没有cn(无细胞状态)
# output: (32, 50, 256)
# hn: (2, 32, 256)

7. 双向RNN与深层RNN

双向RNN结构

图注:双向RNN(BiRNN)结构,前向和后向RNN分别处理序列

7.1 双向RNN(Bidirectional RNN)

许多序列任务(如NER、情感分析)中,当前位置的判断需要参考前后文。双向RNN在两个方向分别运行一个RNN,并拼接两个方向的隐藏状态:

\[\overrightarrow{\mathbf{h}}_t = \text{RNN}(\mathbf{x}_t, \overrightarrow{\mathbf{h}}_{t-1})\]
\[\overleftarrow{\mathbf{h}}_t = \text{RNN}(\mathbf{x}_t, \overleftarrow{\mathbf{h}}_{t+1})\]
\[\mathbf{h}_t = [\overrightarrow{\mathbf{h}}_t; \overleftarrow{\mathbf{h}}_t]\]

输出维度为 \(2h\)(两个方向拼接)。

Python
bilstm = nn.LSTM(input_size=128, hidden_size=256, num_layers=2,
                 batch_first=True, bidirectional=True)

x = torch.randn(32, 50, 128)
output, (hn, cn) = bilstm(x)
# output: (32, 50, 512)  — 2*256
# hn: (4, 32, 256)       — 2层*2方向

注意:双向RNN不能用于需要因果推理的任务(如语言生成),因为它需要看到完整序列。

7.2 深层RNN(Stacked RNN)

多层RNN结构

图注:多层RNN(Stacked RNN)结构,每层提取不同抽象级别的特征

多层RNN堆叠,下一层的输入是上一层的输出。每层提取不同抽象级别的特征。

实践中RNN很少超过3-4层(相比CNN的数十层),因为每层已经在时间维度上很"深"。


8. 序列到序列模型

Seq2Seq架构(编码器-解码器)

图注:Seq2Seq架构,编码器将输入序列编码为固定长度向量,解码器基于该向量生成输出序列

8.1 Seq2Seq架构(Encoder-Decoder)

Seq2Seq(Sutskever et al., 2014)用于输入输出均为变长序列的任务(如机器翻译)。

  1. 编码器:将输入序列 \((x_1, \ldots, x_T)\) 编码为固定长度的上下文向量 \(\mathbf{c}\)(通常取编码器最终隐藏状态)
  2. 解码器:以 \(\mathbf{c}\) 为初始输入,自回归地生成输出序列 \((y_1, \ldots, y_{T'})\)
Text Only
编码器:  x_1 → x_2 → x_3 → [context vector c]
解码器:              c → y_1 → y_2 → y_3 → <EOS>

8.2 Seq2Seq的瓶颈

所有输入信息被压缩到一个固定长度的向量 \(\mathbf{c}\) 中——这对长序列来说是严重的信息瓶颈。这直接催生了注意力机制。

Python
class Encoder(nn.Module):  # 继承nn.Module定义神经网络层
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers=1):  # __init__构造方法,创建对象时自动调用
        super().__init__()  # super()调用父类方法
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers, batch_first=True)

    def forward(self, x):
        embedded = self.embedding(x)
        outputs, (hidden, cell) = self.lstm(embedded)
        return outputs, hidden, cell

class Decoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers=1):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers, batch_first=True)
        self.fc = nn.Linear(hidden_dim, vocab_size)

    def forward(self, x, hidden, cell):
        embedded = self.embedding(x.unsqueeze(1))  # unsqueeze增加一个维度
        output, (hidden, cell) = self.lstm(embedded, (hidden, cell))
        prediction = self.fc(output.squeeze(1))  # squeeze去除大小为1的维度
        return prediction, hidden, cell

9. 注意力机制简介

注意力机制在Seq2Seq中的应用

图注:注意力机制让解码器在每一步动态关注编码器的不同位置

9.1 动机

注意力机制(Bahdanau et al., 2014)让解码器在每一步都能动态关注编码器的不同位置,而非仅依赖单一上下文向量。

9.2 基本原理

对于解码器在时间步 \(t\) 的输出:

  1. 计算注意力分数:\(e_{t,i} = \text{score}(\mathbf{s}_{t-1}, \mathbf{h}_i)\)
  2. 归一化为权重:\(\alpha_{t,i} = \text{softmax}(e_{t,i})\)
  3. 加权求和得到上下文:\(\mathbf{c}_t = \sum_i \alpha_{t,i} \mathbf{h}_i\)
  4. \(\mathbf{c}_t\)\(\mathbf{s}_{t-1}\) 一起用于生成输出

常见的评分函数: - 加性注意力(Bahdanau)\(\text{score}(\mathbf{s}, \mathbf{h}) = \mathbf{v}^T \tanh(\mathbf{W}_1 \mathbf{s} + \mathbf{W}_2 \mathbf{h})\) - 点积注意力(Luong)\(\text{score}(\mathbf{s}, \mathbf{h}) = \mathbf{s}^T \mathbf{h}\) - 缩放点积注意力\(\text{score}(\mathbf{s}, \mathbf{h}) = \frac{\mathbf{s}^T \mathbf{h}}{\sqrt{d}}\) → 这正是Transformer所使用的

9.3 从Seq2Seq+Attention到Transformer

注意力机制的演进路线:

Text Only
Seq2Seq (2014) → Attention-based Seq2Seq (2014/2015)
    → Self-Attention (2017) → Transformer (2017)

关键突破:"Attention Is All You Need"(Vaswani et al., 2017)去掉了RNN,完全基于注意力构建模型 → 这就是Transformer,将在下一章详细讲解。


10. 应用场景

10.1 典型应用

应用 输入→输出 模型类型
文本生成 序列→序列 语言模型(单向RNN)
情感分析 序列→标签 多对一(取最后隐藏状态)
命名实体识别 序列→序列(等长) 多对多(双向RNN)
机器翻译 序列→序列(不等长) Seq2Seq + Attention
语音识别 序列→序列 Seq2Seq / CTC
时序预测 序列→值 多对一或多对多
音乐生成 序列→序列 语言模型方式
视频描述 帧序列→文本序列 CNN编码器 + RNN解码器

10.2 RNN在时序预测中的应用

Python
# 简单的时序预测LSTM
class TimeSeriesLSTM(nn.Module):
    def __init__(self, input_dim=1, hidden_dim=64, output_dim=1, num_layers=2):
        super().__init__()
        self.lstm = nn.LSTM(input_dim, hidden_dim, num_layers, batch_first=True)
        self.fc = nn.Linear(hidden_dim, output_dim)

    def forward(self, x):
        # x: (batch, seq_len, input_dim)
        lstm_out, _ = self.lstm(x)
        prediction = self.fc(lstm_out[:, -1, :])  # 取最后一步
        return prediction

11. PyTorch实战:LSTM情感分析

11.1 完整代码

Python
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from collections import Counter
import re

# ==================== 1. 数据预处理 ====================
class TextDataset(Dataset):
    """简易文本数据集(以IMDb为例)"""
    def __init__(self, texts, labels, vocab, max_len=256):
        self.texts = texts
        self.labels = labels
        self.vocab = vocab
        self.max_len = max_len

    def __len__(self):  # __len__定义len()的行为
        return len(self.texts)

    def __getitem__(self, idx):  # __getitem__定义索引访问行为
        tokens = tokenize(self.texts[idx])
        # 将token转为索引
        indices = [self.vocab.get(t, self.vocab['<UNK>']) for t in tokens]
        # 截断或填充
        if len(indices) > self.max_len:
            indices = indices[:self.max_len]
        else:
            indices = indices + [self.vocab['<PAD>']] * (self.max_len - len(indices))
        return torch.tensor(indices, dtype=torch.long), torch.tensor(self.labels[idx], dtype=torch.long)

def tokenize(text):
    """简单分词"""
    text = text.lower()
    text = re.sub(r'[^a-zA-Z\s]', '', text)
    return text.split()

def build_vocab(texts, max_vocab=25000):
    """构建词表"""
    counter = Counter()  # Counter统计元素出现次数
    for text in texts:
        counter.update(tokenize(text))
    # 保留高频词
    most_common = counter.most_common(max_vocab - 2)
    vocab = {'<PAD>': 0, '<UNK>': 1}
    for word, _ in most_common:
        vocab[word] = len(vocab)
    return vocab

# ==================== 2. 模型定义 ====================
class LSTMClassifier(nn.Module):
    """LSTM情感分类模型"""
    def __init__(self, vocab_size, embed_dim=128, hidden_dim=256,
                 num_layers=2, num_classes=2, dropout=0.5, bidirectional=True):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.lstm = nn.LSTM(
            embed_dim, hidden_dim, num_layers,
            batch_first=True, dropout=dropout if num_layers > 1 else 0,
            bidirectional=bidirectional
        )
        self.dropout = nn.Dropout(dropout)

        direction_factor = 2 if bidirectional else 1
        self.fc = nn.Sequential(
            nn.Linear(hidden_dim * direction_factor, hidden_dim),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(hidden_dim, num_classes)
        )

    def forward(self, x):
        # x: (batch, seq_len)
        embedded = self.dropout(self.embedding(x))  # (batch, seq_len, embed_dim)
        lstm_out, (hn, cn) = self.lstm(embedded)     # lstm_out: (batch, seq_len, hidden_dim*2)

        # 方式1:取最后时间步
        # 对于双向LSTM,拼接两个方向最后时间步的隐藏状态
        if self.lstm.bidirectional:
            hidden = torch.cat([hn[-2], hn[-1]], dim=1)  # (batch, hidden_dim*2)  # [-1]负索引取最后一个元素
        else:
            hidden = hn[-1]  # (batch, hidden_dim)

        # 方式2(替代):对所有时间步做平均池化
        # hidden = lstm_out.mean(dim=1)

        output = self.fc(self.dropout(hidden))
        return output

# ==================== 3. 训练流程 ====================
def train_sentiment_model():
    """完整的训练流程(需要配合真实数据集使用)"""
    DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    # 假设已有train_texts, train_labels, test_texts, test_labels
    # 实际使用时可加载IMDb数据集:
    # from torchtext.datasets import IMDB
    # 或 from datasets import load_dataset
    # dataset = load_dataset("imdb")

    # 示例数据(实际使用需替换)
    train_texts = ["This movie is great!", "Terrible waste of time."] * 1000
    train_labels = [1, 0] * 1000

    vocab = build_vocab(train_texts)
    VOCAB_SIZE = len(vocab)

    train_dataset = TextDataset(train_texts, train_labels, vocab, max_len=256)
    train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)  # DataLoader批量加载数据,支持shuffle和多进程

    model = LSTMClassifier(
        vocab_size=VOCAB_SIZE,
        embed_dim=128,
        hidden_dim=256,
        num_layers=2,
        num_classes=2,
        dropout=0.5,
        bidirectional=True
    ).to(DEVICE)

    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5)
    scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=3, factor=0.5)

    # 训练循环
    for epoch in range(20):
        model.train()  # train()开启训练模式
        total_loss = 0
        correct = 0
        total = 0

        for inputs, labels in train_loader:
            inputs, labels = inputs.to(DEVICE), labels.to(DEVICE)

            optimizer.zero_grad()  # 清零梯度,防止梯度累积
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            loss.backward()  # 反向传播计算梯度

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

            optimizer.step()  # 根据梯度更新模型参数

            total_loss += loss.item()  # .item()将单元素张量转为Python数值
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()  # 链式调用,连续执行多个方法

        train_acc = 100. * correct / total
        avg_loss = total_loss / len(train_loader)
        scheduler.step(avg_loss)

        print(f"Epoch [{epoch+1}/20] Loss: {avg_loss:.4f} | Acc: {train_acc:.2f}%")

# train_sentiment_model()

11.2 使用预训练词向量

Python
import numpy as np

def load_pretrained_embeddings(vocab, embed_path='glove.6B.100d.txt', embed_dim=100):
    """加载GloVe预训练词向量"""
    embeddings = np.random.normal(0, 0.1, (len(vocab), embed_dim))
    embeddings[0] = 0  # PAD向量为零

    with open(embed_path, 'r', encoding='utf-8') as f:  # with open自动管理文件打开和关闭
        for line in f:
            parts = line.strip().split()
            word = parts[0]
            if word in vocab:
                embeddings[vocab[word]] = np.array(parts[1:], dtype=np.float32)  # 切片操作取子序列  # np.array创建NumPy数组

    return torch.tensor(embeddings, dtype=torch.float32)

# 使用预训练嵌入初始化
# pretrained = load_pretrained_embeddings(vocab)
# model.embedding.weight.data.copy_(pretrained)
# model.embedding.weight.requires_grad = True  # 可选:是否微调

12. RNN vs Transformer对比

特性 RNN (LSTM/GRU) Transformer
并行化 ❌ 时间步必须顺序计算 ✅ 所有位置可并行计算
长距离依赖 理论上可以,实际受限 通过self-attention直接连接所有位置
位置信息 自然编码在递推过程中 需要额外的位置编码
计算复杂度 \(O(T \cdot d^2)\)\(T\)为序列长度) \(O(T^2 \cdot d)\)
内存开销 只需存储当前隐藏状态 需要存储 \(T \times T\) 注意力矩阵
训练速度 慢(无法并行) 快(GPU并行友好)
推理速度 流式推理友好 需要缓存KV(KV-Cache)
实际表现 在小数据/短序列上仍有竞争力 大数据+长序列的首选
适用场景 实时系统、资源受限设备 大部分NLP和CV任务

趋势:Transformer已在大多数任务上取代RNN成为主流。但RNN在以下场景仍有价值: - 流式/实时处理(如语音识别的在线推理) - 极长序列(Transformer的 \(O(T^2)\) 复杂度成为瓶颈) - 资源受限的边缘设备 - 状态空间模型(SSM,如Mamba)融合了RNN和Transformer的优点,是新的发展方向


13. 练习与自我检查

✏️ 练习题

  1. 手动推导:对一个3个时间步的RNN,手动展开BPTT,写出 \(\frac{\partial \mathcal{L}_3}{\partial \mathbf{W}_{hh}}\) 的完整链式法则表达式。

  2. 实现vanilla RNN:不使用 nn.RNN,从零用矩阵运算实现一个RNN单元,处理一个简单序列(如加法问题:输入两个数字序列,输出它们的和)。

  3. LSTM门控分析:训练一个LSTM进行文本分类,可视化遗忘门在处理不同文本时的激活值,分析它学到了什么(哪些词被"记住",哪些被"遗忘")。

  4. LSTM vs GRU:在IMDb情感分析数据集上分别使用LSTM和GRU,对比准确率、训练时间和参数量。

  5. 序列生成:用LSTM训练一个字符级语言模型(character-level LM),输入莎士比亚文本,生成类似风格的文本。

  6. 梯度实验:训练不同层数的vanilla RNN(1层/3层/5层),记录各层梯度的范数变化,验证梯度消失现象。然后换成LSTM,观察梯度行为的变化。

  7. 时序预测:使用LSTM预测正弦波的下一个值,对比不同隐藏维度和层数的效果。

面试要点

Q1: 解释LSTM如何解决梯度消失问题? A: LSTM引入了细胞状态作为"记忆传送带",通过遗忘门控制信息的保留。当遗忘门接近1时,梯度可以无损地通过细胞状态传递,避免了标准RNN中梯度连乘导致的指数衰减。

Q2: LSTM和GRU的主要区别? A: GRU用2个门(重置门+更新门)替代LSTM的3个门和细胞状态,参数量少25%,训练更快。GRU的更新门兼具LSTM遗忘门和输入门的功能。实际效果两者通常相当。

Q3: 为什么Transformer逐渐取代RNN? A: Transformer解决了RNN的两大问题——无法并行(self-attention可以并行计算所有位置)和长距离依赖困难(任意两个位置直接相连)。在大数据和GPU加速下,Transformer训练效率远超RNN。

Q4: RNN还有什么实用价值? A: 流式推理(在线语音识别)、超长序列(内存友好)、边缘设备(参数量小)。此外SSM(如Mamba)在RNN框架下实现了Transformer级别的性能。

自我检查清单

  • 能手写RNN的前向传播公式
  • 理解BPTT的完整推导过程
  • 能解释梯度消失的数学原因(雅可比矩阵连乘)
  • 能默写LSTM四个公式(遗忘门、输入门、细胞更新、输出门)
  • 理解LSTM为什么解决梯度消失(对比\(\frac{\partial C_t}{\partial C_{t-1}}\)
  • 能说清GRU和LSTM的核心差异
  • 理解双向RNN的使用场景和限制
  • 能实现完整的LSTM文本分类流程
  • 了解Seq2Seq的瓶颈以及注意力机制如何解决它

下一章: 10-Transformer架构 — 从注意力机制到改变世界的Transformer