第六章 深度学习 -- 循环神经网络

第六章 循环神经网络

一、RNN 原理

1.1 序列建模

序列数据是一类按时间或位置顺序排列的数据,顺序本身携带信息。典型例子:

  • 时间序列:股价、气温、传感器读数、用户行为日志。
  • 自然语言:句子、文档(词序决定语义)。
  • 语音:音频帧序列(时序前后强相关)。
  • 生物序列:DNA、蛋白质。

序列建模的核心挑战:

  • 变长输入/输出:句子有长有短,音频有长有短。模型必须能处理任意长度。前馈网络要求固定维度输入,序列需 padding 到最长样本长度,但会带来计算浪费(大量 padding 参与运算)与位置信息丢失(不同位置无差别)。
  • 长距离依赖:句中代词与先行词可能相隔数十个词,模型需要捕获这种远距离关系。CNN 的感受野受限于层数,MLP 完全没有时间维度的概念,RNN 在理论上能通过隐藏状态跨任意距离传递信息。
  • 位置信息:同一组词的不同排列,语义不同。「猫吃鱼」和「鱼吃猫」字面一样但语义相反;模型必须感知 token 的位置/顺序,不能把序列当作「集合」。
  • 输入/输出对应关系灵活 :不限于「一对一」(图像分类),典型对应关系有:
    • 一对一:图像分类(单图 → 单标签)。
    • 多对一:句子分类、情感分析(N 个 token → 1 个类别)。
    • 一对多:图像描述、诗歌生成(1 张图 → N 个 token)。
    • 同步多对多:词性标注、命名实体识别(N token → N 标签,每个位置一个输出)。
    • 异步多对多:机器翻译、视频描述(N token → M token,N 与 M 不等长)。
    • 序列到集合/集合到序列:目标检测、点云生成等。

前馈网络(MLP、CNN)要求输入是固定维度的向量,对序列建模天生不友好 ------它们无法自然处理变长输入,也无法直接捕获位置信息(虽然可以用「最近 N 个 token」滑动窗口拼成定长输入,但仍是「局部上下文」)。循环神经网络 (Recurrent Neural Network, RNN) 通过在时间步之间共享参数 + 维护隐藏状态,用同一套参数递归地处理任意长度的序列,是序列建模的经典方案。

1.2 循环结构

基本 RNN 单元 。在每个时间步 ttt,RNN 接收当前输入 xtx_txt 和上一时刻的隐藏状态 ht−1h_{t-1}ht−1,输出当前时刻的隐藏状态 hth_tht:

ht=tanh⁡(Wxhxt+Whhht−1+bh) h_t = \tanh(W_{xh} x_t + W_{hh} h_{t-1} + b_h) ht=tanh(Wxhxt+Whhht−1+bh)

yt=Whyht+by y_t = W_{hy} h_t + b_y yt=Whyht+by

其中 WxhW_{xh}Wxh、WhhW_{hh}Whh、WhyW_{hy}Why 是共享的权重矩阵(对所有时间步相同),hth_tht 是隐藏状态,作为「记忆」传递给下一时刻。

关键性质:

  • 参数共享:所有时间步共用同一组权重,使模型可以处理任意长度的输入。
  • 隐藏状态作为记忆 :hth_tht 理论上包含从 x1x_1x1 到 xtx_txt 的所有信息(实际能力受限于梯度传播,见 1.3)。
  • 图灵完备性(理论上):足够宽的 RNN 可以模拟任意图灵机,前提是能完美训练(实际中做不到)。

PyTorch 实现:

python 复制代码
import torch
import torch.nn as nn

# nn.RNN 封装了整个循环
rnn = nn.RNN(
    input_size=128,     # 输入 x_t 维度
    hidden_size=256,    # 隐藏状态 h_t 维度
    num_layers=2,       # 堆叠层数
    nonlinearity='tanh',# 激活函数
    batch_first=True,   # 输入形状 (B, L, C) 而非 (L, B, C)
    dropout=0.3,        # 层间 Dropout
)

x = torch.randn(32, 100, 128)   # (B, L, C)
output, h_n = rnn(x)
# output: (B, L, hidden_size) - 每个时间步的输出
# h_n:   (num_layers, B, hidden_size) - 最后一个时间步的隐藏状态

输入/输出张量约定 。PyTorch 的 batch_first=True 让最外层维度是 batch;不设 batch_first 时,默认是 (seq_len, batch, features),这是早期学术实现的传统约定。强烈建议显式设置 batch_first=True,代码可读性更高。

手动展开循环。理解原理时,经常需要把 RNN 显式按时间步展开:

python 复制代码
class SimpleRNN(nn.Module):
    def __init__(self, input_size, hidden_size):
        super().__init__()
        self.W_xh = nn.Linear(input_size, hidden_size)
        self.W_hh = nn.Linear(hidden_size, hidden_size)

    def forward(self, x):
        # x: (B, L, C)
        B, L, _ = x.shape
        h = torch.zeros(B, self.W_hh.in_features, device=x.device)
        outputs = []
        for t in range(L):
            h = torch.tanh(self.W_xh(x[:, t, :]) + self.W_hh(h))
            outputs.append(h)
        return torch.stack(outputs, dim=1), h

实际生产中总是用 nn.RNN / nn.LSTM / nn.GRU,内部用 cuDNN 优化,比手写循环快得多。

词嵌入(Embedding)。序列模型的输入通常不是原始 token,而是 token id(整数)经嵌入层映射后的稠密向量:

python 复制代码
emb = nn.Embedding(num_embeddings=50000, embedding_dim=128)
ids = torch.randint(0, 50000, (2, 10))   # (B, L) 整数 token id
x = emb(ids)                              # (B, L, 128)
  • nn.Embedding 本质是可学习的查表 :内部是一个 (V,d)(V, d)(V,d) 矩阵(VVV 为词表大小),前向就是按 id 取行。数学上等价于「one-hot 向量乘以该矩阵」,但免去了显式 one-hot 的巨大稀疏乘法。
  • 梯度只会流到本 batch 被查过的那些行------与 one-hot 路径数学等价,计算量却小几个数量级。
  • 为什么不用 one-hot 直接输入:5 万词表的 one-hot 是 5 万维、几乎全零;嵌入把它压到 128 维稠密向量,且训练后语义相近的词会在嵌入空间相互靠近。第七章 Transformer 同样以 Embedding 层起步。

多层 RNN 。把多个 RNN 堆叠,上一层的输出作为下一层的输入(num_layers > 1)。底层学低层特征(词向量级),高层学抽象语义(句意级)。常见配置 2-4 层,过多容易过拟合与梯度困难。

双向 RNN 。同时维护一个「正向」和一个「反向」RNN,最终隐藏状态是两者拼接。对序列标注(词性标注、命名实体识别)任务非常有效,因为当前位置的语义往往需要看到后文才能确定。PyTorch nn.RNN(bidirectional=True),输出维度翻倍。

1.3 长期依赖问题

什么是长期依赖。在序列中,某个位置的输出可能依赖于很久之前的信息。例如:

  • 「我从小在中国长大,......,我会说流利的中文。」------「中文」这个词依赖于几十个词前的「中国」。
  • 股价预测中,今天的价格可能受到一年前政策的影响。

理论上,RNN 可以通过隐藏状态传递信息到任意远的距离。但实际训练中,这种长距离依赖几乎学不到 ------这就是长期依赖问题。

根本原因:梯度消失/爆炸 。反向传播通过时间(Backpropagation Through Time, BPTT)需要沿时间维度反向求导。考虑损失 LtL_tLt 对早期隐藏状态 hkh_khk (k<tk < tk<t) 的梯度:

∂Lt∂hk=∂Lt∂ht∏i=k+1t∂hi∂hi−1 \frac{\partial L_t}{\partial h_k} = \frac{\partial L_t}{\partial h_t} \prod_{i=k+1}^{t} \frac{\partial h_i}{\partial h_{i-1}} ∂hk∂Lt=∂ht∂Lti=k+1∏t∂hi−1∂hi

其中 ∂hi∂hi−1\frac{\partial h_i}{\partial h_{i-1}}∂hi−1∂hi 是 Jacobian 矩阵,不仅包含权重矩阵 WhhW_{hh}Whh ,还包含激活函数的逐元素导数 σ′(Whhhi−1+Wxhxi−1+b)\sigma'(W_{hh} h_{i-1} + W_{xh} x_{i-1} + b)σ′(Whhhi−1+Wxhxi−1+b):

∂hi∂hi−1=diag ⁣(σ′(zi))⋅Whh,其中 zi=Whhhi−1+Wxhxi−1+b \frac{\partial h_i}{\partial h_{i-1}} = \text{diag}\!\left(\sigma'(\mathbf{z}i)\right) \cdot W{hh}, \quad \text{其中 } \mathbf{z}i = W{hh} h_{i-1} + W_{xh} x_{i-1} + b ∂hi−1∂hi=diag(σ′(zi))⋅Whh,其中 zi=Whhhi−1+Wxhxi−1+b

所以连乘的每一项都被激活函数导数「调制」:Sigmoid/Tanh 的导数在饱和区(σ′→0\sigma' \to 0σ′→0)会把整个项拉向 0,加剧梯度消失。这个连乘的范数行为是梯度消失/爆炸的根源:

  • 若每项最大奇异值 < 1,连乘指数级衰减至 0 → 梯度消失。
  • 若每项最大奇异值 > 1,连乘指数级增长 → 梯度爆炸。

实践中两者都常见,梯度消失更隐蔽 (loss 不下降但不会 NaN),梯度爆炸更显眼(loss 突然变 NaN)。

BPTT 截断(Truncated BPTT) 。为节省计算与缓解梯度问题,实际中不对完整序列反向传播,而是按固定窗口(如 50-200 步)截断:

python 复制代码
# 截断 BPTT 的伪代码
for t in range(0, seq_len, bptt_len):
    chunk = x[:, t:t+bptt_len, :]
    h = rnn(chunk, h)            # 只对 chunk 长度反向传播
    # ...
    h = h.detach()                # 截断计算图,不让梯度穿越 chunk 边界

h.detach() 是关键------它切断 chunk 之间的梯度流,使每段 BPTT 独立。代价是真正长距离的梯度无法传播,但实践中这往往是工程上可接受的折中。

梯度裁剪。处理梯度爆炸的标准做法:限制梯度范数到某个上界。

python 复制代码
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

简单有效,是 RNN 训练循环的标配。

LSTM/GRU 的解决方案(下节详述)。长短期记忆网络(LSTM)与门控循环单元(GRU)通过「门控机制」让信息选择性通过,直接缓解了梯度消失问题。这是 2014 年前 RNN 时代的主流方案,直到 Transformer 在多数任务上取代它们。

二、改进结构

2.1 LSTM

长短期记忆网络 (Long Short-Term Memory, LSTM;Hochreiter & Schmidhuber, 1997)通过引入记忆单元(memory cell) 与三个门控(gate),让信息能够长时间保持在「高速公路」上,从根本上缓解了普通 RNN 的梯度消失问题。

核心思想 :用一个独立的「细胞状态」CtC_tCt 沿时间传递信息,信息通过三个门控(遗忘门、输入门、输出门)有选择地写入、保留、读出。这三个门是 sigmoid 函数(输出 0 到 1),分别控制「忘记多少旧信息」「写入多少新信息」「输出多少当前状态」。

LSTM 单元的数学定义(PyTorch 风格的实现):

ft=σ(Wf⋅ht−1,xt+bf)(遗忘门 forget gate)it=σ(Wi⋅ht−1,xt+bi)(输入门 input gate)C~t=tanh⁡(WC⋅ht−1,xt+bC)(候选细胞状态)Ct=ft⊙Ct−1+it⊙C~t(更新细胞状态)ot=σ(Wo⋅ht−1,xt+bo)(输出门 output gate)ht=ot⊙tanh⁡(Ct)(输出隐藏状态) \begin{aligned} f_t &= \sigma(W_f \cdot h_{t-1}, x_t + b_f) \quad &\text{(遗忘门 forget gate)} \\ i_t &= \sigma(W_i \cdot h_{t-1}, x_t + b_i) \quad &\text{(输入门 input gate)} \\ \tilde{C}t &= \tanh(W_C \cdot h_{t-1}, x_t + b_C) \quad &\text{(候选细胞状态)} \\ C_t &= f_t \odot C{t-1} + i_t \odot \tilde{C}_t \quad &\text{(更新细胞状态)} \\ o_t &= \sigma(W_o \cdot h_{t-1}, x_t + b_o) \quad &\text{(输出门 output gate)} \\ h_t &= o_t \odot \tanh(C_t) \quad &\text{(输出隐藏状态)} \end{aligned} ftitC~tCtotht=σ(Wf⋅ht−1,xt+bf)=σ(Wi⋅ht−1,xt+bi)=tanh(WC⋅ht−1,xt+bC)=ft⊙Ct−1+it⊙C~t=σ(Wo⋅ht−1,xt+bo)=ot⊙tanh(Ct)(遗忘门 forget gate)(输入门 input gate)(候选细胞状态)(更新细胞状态)(输出门 output gate)(输出隐藏状态)

其中 ⊙\odot⊙ 是逐元素乘,ht−1,xth_{t-1}, x_tht−1,xt 是拼接。

为什么缓解梯度消失 。反向传播时,梯度通过细胞状态传递的路径是加法 (⊙\odot⊙ 元素乘后与新值相加),不是矩阵乘法。这条加性路径让梯度可以无损地穿越多个时间步,长期依赖得以保留。

PyTorch:

python 复制代码
lstm = nn.LSTM(
    input_size=128,
    hidden_size=256,
    num_layers=2,
    batch_first=True,
    dropout=0.3,
    bidirectional=True,        # 可选:双向
)

x = torch.randn(32, 100, 128)
output, (h_n, c_n) = lstm(x)
# output: (B, L, hidden_size * num_directions)
# h_n:   (num_layers * num_directions, B, hidden_size) - 最后时刻隐藏状态
# c_n:   同上,最后时刻细胞状态

h_n 与 c_n 都是「最后时间步」的状态,维度都是 (num_layers * num_directions, B, hidden_size)。

输入/输出的复杂约定 。LSTM 相比 RNN 多返回 c_n(细胞状态),这是初学者常踩的坑。务必查阅官方文档确认维度,尤其在双向 + 多层场景下。

初始化 。h_0 与 c_0 默认全零,PyTorch 自动处理。某些场景(如序列到序列的解码器)需要用编码器的最后状态作为解码器的初始状态------此时 c_0 = c_n_encoder,h_0 = h_n_encoder。

LSTM 变体:

  • Peephole LSTM :让门控能直接看到 Ct−1C_{t-1}Ct−1(而不仅是 ht−1h_{t-1}ht−1 与 xtx_txt),某些任务上略好。
  • Coupled LSTM :遗忘门与输入门耦合(ft=1−itf_t = 1 - i_tft=1−it),减少参数。
  • 多层 LSTM + Residual:在层间加残差,支持更深网络(类似 ResNet)。

2.2 GRU

门控循环单元 (Gated Recurrent Unit, GRU;Cho et al., 2014)是 LSTM 的一种简化变体,只有两个门(重置门、更新门),没有单独的细胞状态 CtC_tCt。它合并了「遗忘」与「输入」的概念,直接用「更新门」控制新旧隐藏状态的混合比例。

GRU 的数学定义:

zt=σ(Wz⋅ht−1,xt+bz)(更新门 update gate)rt=σ(Wr⋅ht−1,xt+br)(重置门 reset gate)h~t=tanh⁡(W⋅rt⊙ht−1,xt+b)(候选隐藏状态)ht=(1−zt)⊙ht−1+zt⊙h~t(更新隐藏状态) \begin{aligned} z_t &= \sigma(W_z \cdot h_{t-1}, x_t + b_z) \quad &\text{(更新门 update gate)} \\ r_t &= \sigma(W_r \cdot h_{t-1}, x_t + b_r) \quad &\text{(重置门 reset gate)} \\ \tilde{h}t &= \tanh(W \cdot r_t \\odot h_{t-1}, x_t + b) \quad &\text{(候选隐藏状态)} \\ h_t &= (1 - z_t) \odot h{t-1} + z_t \odot \tilde{h}_t \quad &\text{(更新隐藏状态)} \end{aligned} ztrth~tht=σ(Wz⋅ht−1,xt+bz)=σ(Wr⋅ht−1,xt+br)=tanh(W⋅rt⊙ht−1,xt+b)=(1−zt)⊙ht−1+zt⊙h~t(更新门 update gate)(重置门 reset gate)(候选隐藏状态)(更新隐藏状态)

门控解释:

  • 更新门 ztz_tzt :控制保留多少旧状态 (1−zt)⊙ht−1(1 - z_t) \odot h_{t-1}(1−zt)⊙ht−1,写入多少新候选 zt⊙h~tz_t \odot \tilde{h}_tzt⊙h~t。等价于 LSTM 的「遗忘 + 输入」两个门耦合。
  • 重置门 rtr_trt :控制上一时刻隐藏状态对候选 h~t\tilde{h}_th~t 的影响。rt→0r_t \to 0rt→0 时,候选 h~t\tilde{h}_th~t 忽略旧状态;rt→1r_t \to 1rt→1 时,正常使用旧状态。

PyTorch:

python 复制代码
gru = nn.GRU(
    input_size=128,
    hidden_size=256,
    num_layers=2,
    batch_first=True,
    dropout=0.3,
    bidirectional=True,
)

LSTM vs GRU 对比:

维度 LSTM GRU
门数量 3(遗忘、输入、输出) 2(更新、重置)
状态数 2(hth_tht, CtC_tCt) 1(hth_tht)
参数数 4 × (input + hidden) × hidden 3 × (input + hidden) × hidden
训练速度 较慢 较快(参数少)
长期记忆能力 强(显式 CtC_tCt) 略弱(隐式)
多数任务表现 略好 略差,但差距小

经验选择:

  • LSTM 是默认选择,经过时间检验,适用于大多数序列任务。
  • GRU 在资源受限、追求速度、序列不太长时是合理选择(例如端侧推理)。
  • 两者实际表现往往非常接近,值得对当前任务对比实验再决定。

2.3 双向与堆叠

双向 RNN(Bidirectional RNN)。同时维护两个方向的 RNN:

  • 前向 :htfw=f(xt,ht−1fw)h_t^{fw} = f(x_t, h_{t-1}^{fw})htfw=f(xt,ht−1fw) 从左到右处理。
  • 反向 :htbw=f(xt,ht+1bw)h_t^{bw} = f(x_t, h_{t+1}^{bw})htbw=f(xt,ht+1bw) 从右到左处理。

最终输出通常是拼接:ht=htfw;htbwh_t = h_t\^{fw}; h_t\^{bw}ht=htfw;htbw,维度翻倍。

适用场景:

  • 序列标注(词性、命名实体、chunking)------当前位置的标签可能依赖前后文。
  • 文本分类------句意依赖整个句子,正反两方向都看更好。
  • 不适用于:流式/在线推理(反向需要等完整序列)、自回归生成(GPT 风格)。

PyTorch :在 nn.RNN/LSTM/GRU 中设 bidirectional=True 即可。

堆叠 RNN(Stacked RNN)。多层 RNN 堆叠,上层接收下层的输出作为输入。深度的好处类似前馈网络------更深能学更抽象的语义。常见 2-4 层,过多易过拟合与梯度困难。

实现细节:

  • 各层之间通常加 Dropout (每层输入做 Dropout)防止过拟合。PyTorch nn.RNN(dropout=0.3) 自动在层间加 Dropout(但只在 num_layers > 1 时生效)。
  • 多层 + 双向的输出维度:(num_layers * num_directions, B, hidden),容易混淆,务必确认。

PyTorch 完整示例:双向 + 堆叠 LSTM 文本分类器

python 复制代码
class TextClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes, num_layers=2):
        super().__init__()
        self.embed = nn.Embedding(vocab_size, embed_dim)
        self.lstm = nn.LSTM(
            embed_dim, hidden_dim,
            num_layers=num_layers,
            batch_first=True,
            dropout=0.3,
            bidirectional=True,
        )
        self.fc = nn.Linear(hidden_dim * 2, num_classes)   # 双向 → 维度 ×2

    def forward(self, x):
        # x: (B, L)
        x = self.embed(x)                            # (B, L, embed_dim)
        output, (h_n, c_n) = self.lstm(x)            # output: (B, L, 2H)
        # 取最后时间步(双向拼接后)的隐藏状态
        # h_n 形状 (num_layers * 2, B, H),最后一层前向与反向拼接
        h_fw = h_n[-2, :, :]   # 最后一层前向
        h_bw = h_n[-1, :, :]   # 最后一层反向
        h = torch.cat([h_fw, h_bw], dim=1)           # (B, 2H)
        return self.fc(h)

LSTM/GRU 在 2020 年后的位置。Transformer 在大多数序列任务(翻译、语言建模、QA)上取代了 RNN。但 RNN 仍在以下场景有优势:

  • 流式/在线推理:每个时间步只需 O(1) 计算,Transformer 需 O(序列长²) 的注意力。
  • 极低资源:RNN 参数更少,适合端侧/嵌入式部署。
  • Mamba 等 SSM 出现后:RNN 风格的「线性时间序列建模」正被重新评估。

RNN 训练的高级技巧:

  • 学习率调度:RNN 对学习率敏感,通常用 warmup + 衰减。
  • 梯度裁剪 :max_norm=1.0 或 5.0 是标配。
  • Teacher Forcing (训练时):解码器用真实 token 作为下一步输入,加速收敛;但推理时只能用模型自己的输出,造成 train-test mismatch------可使用 Scheduled Sampling 逐渐切换。
  • Truncated BPTT :长序列训练时按 chunk 截断,每段 detach 隐藏状态。
相关推荐
子非鱼@Itfuture1 小时前
1024这天,我们聊聊AI
1024程序员节
用户14465546834011 小时前
Spring AI Alibaba Agent 长流程栈溢出:从 Reactor 调用链到修复验证
人工智能
夜雪一千1 小时前
Linux 查找文件 & 搜索内容介绍
1024程序员节
小弥儿1 小时前
GitHub今日热榜 | 2026-10-24:智能体安全攻防同日上榜
1024程序员节
John_Hui1 小时前
MySQL 进阶知识
数据库·sql·mysql·1024程序员节
yma161 小时前
从零搭建 P2P 聊天室:yma16.cloud 在线工具实战
前端·1024程序员节
小码哥(^_^)1 小时前
人力资源面试题集1
eclipse·1024程序员节
染指11101 小时前
155.Agent-LangGraph-节点级重试策略
人工智能·langchain·1024程序员节·agents
labview_自动化1 小时前
Labview动态加载类
1024程序员节