循环神经网络 (RNN):处理序列数据的逻辑

📂 所属阶段:第二阶段 — 深度学习与序列模型(进阶篇)
🔗 相关章节:PyTorch 基础 · 长短时记忆网络 LSTM/GRU


1. 为什么需要 RNN?

1.1 传统神经网络的局限

传统的全连接网络(Dense)和卷积网络(CNN),本质上都是独立处理每个输入样本的。无论我们把文本里的词怎么排列,只要词频差不多,模型输出的特征向量就可能非常相似。

举个情感分析的反例

文本序列:“这部电影 难看” vs “这部电影 好看”。
传统网络只会统计「电影」「这」「部」这些词,甚至可能把两个句子的特征向量做得几乎一样,完全没法区分「好看」和「难看」的语义差异!

这种“只看词频、不看顺序”的方式,在面对自然语言、语音信号、股票价格这类带有明显先后顺序的序列数据时,显得力不从心。

1.2 RNN 的核心突破:加入「记忆」

循环神经网络(Recurrent Neural Network,简称 RNN)的名字里就藏着它的秘密——循环复用同一个神经元单元,同时引入了一个叫隐藏状态(Hidden State)的东西,用来保存“之前看到的信息”。简单说,RNN 在读取序列时,每一步都会把当前输入与上一步留下的“记忆”融合,生成新的记忆和输出。

为了更直观理解,可以把 RNN 沿着时间步(序列中的每个元素)展开:

graph LR
    subgraph RNN 展开图
        h0((h₀))
        x0[x₀]
        r0[RNN 单元]
        h1((h₁))
        y0[y₀]
        x1[x₁]
        r1[RNN 单元]
        h2((h₂))
        y1[y₁]
        x2[x₂]
        r2[RNN 单元]
        y2[y₂]
    end

    h0 --> r0
    x0 --> r0
    r0 --> h1
    r0 --> y0
    h1 --> r1
    x1 --> r1
    r1 --> h2
    r1 --> y1
    h2 --> r2
    x2 --> r2
    r2 --> y2

每个时间步的处理逻辑都一样:

  1. 接收当前输入 x_t 和上一步的隐藏状态 h_{t-1}
  2. 经过相同的 RNN 单元,生成新的隐藏状态 h_t 和当前输出 y_t

RNN 单元内部的工作很简洁:先将当前输入和前一步隐藏状态分别乘以各自的权重矩阵,再加一个偏置,最后通过双曲正切(tanh)激活函数将数值压缩到 (-1, 1) 之间,得到新的隐藏状态。整个过程完全一样,无论序列多长都复用同一套参数,所以 RNN 天然能够处理任意长度的序列。


2. RNN 的致命问题

虽然 RNN 解决了“独立处理”的问题,但它有两个天然的硬伤,导致长序列任务里基本不再使用原生 RNN

2.1 梯度消失与爆炸

训练神经网络的核心是反向传播:从输出层逐步往回计算每个参数对最终损失的影响,然后更新参数。RNN 的特殊之处在于,参数在时间步上是被重复使用的,因此反向传播时,梯度会沿着时间步连乘多次(连乘的次数等于序列长度)。

  • 梯度消失:如果连乘的梯度值普遍小于 1,那么经过很多次相乘后,梯度会越来越小,趋近于 0。这意味着模型几乎无法学习到很久以前的信息对当前的影响。
  • 梯度爆炸:如果梯度值大于 1,经过多次相乘后,梯度会指数级增长,趋近于无穷大,导致训练时的损失值(Loss)直接变成 NaN,训练崩溃。
长期依赖失效的直观例子

“我出生在中国……(中间夹杂了 1000 个完全不相关的字)……我会说___”
原生 RNN 大概率会把第一个分句的「中国」忘得一干二净,很难填出「中文」!

梯度爆炸可以通过梯度裁剪(Gradient Clipping)来缓解:设定一个阈值,当梯度超过该阈值时就强行按比例缩小。但梯度消失问题对于原生 RNN 几乎是无法修复的,这也直接催生了 LSTM 和 GRU 这些改进模型。

2.2 其他不足

除了梯度问题,原生 RNN 还有一些小瑕疵:

  • 串行计算,效率受限:每一个时间步的计算都必须等待上一步完成,没法像 CNN 那样大规模并行,训练速度慢。
  • 对初始状态敏感:初始隐藏状态(通常是全零向量)的选择会影响早期时间步的学习效果。

3. PyTorch RNN 快速实现

虽然原生 RNN 不常用,但它是理解 LSTM、GRU 的基础。下面我们用 PyTorch 实现简单的文本分类器,体会一下 RNN 的使用方式。

3.1 单向 RNN 文本分类器

下面的代码构建了一个单向 RNN,用于情感二分类。输入是句子的词索引(token id)序列,输出是正/负类的 logits。

单向
import torch
import torch.nn as nn

class RNNTextClassifier(nn.Module):
    """简单的情感二分类:输入是token id序列,输出是正/负的logits"""
    def __init__(
        self, 
        vocab_size: int,        # 词表大小
        embed_dim: int = 128,   # 词嵌入维度
        hidden_dim: int = 128,  # RNN隐藏层维度
        num_layers: int = 2,     # RNN堆叠层数
        num_classes: int = 2,    # 分类数
        dropout: float = 0.3     # 防止过拟合的dropout率
    ):
        super().__init__()
        # 1. 词嵌入层:把token id转换成稠密向量
        self.embedding = nn.Embedding(
            vocab_size, embed_dim, padding_idx=0  # padding_idx=0表示填充的向量不参与更新
        )
        # 2. 单向 RNN 层
        self.rnn = nn.RNN(
            input_size=embed_dim,
            hidden_size=hidden_dim,
            num_layers=num_layers,
            batch_first=True,  # 关键!输入输出的第一维是batch_size,方便处理
            dropout=dropout if num_layers > 1 else 0.0,  # 只有多层RNN时才加dropout
            bidirectional=False  # 单向,只看前文
        )
        # 3. 全连接分类层
        self.classifier = nn.Linear(hidden_dim, num_classes)

    def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
        """
        input_ids: (batch_size, seq_len) → batch_size个句子,每个句子seq_len个token id
        return: (batch_size, num_classes) → 每个句子的正/负logits
        """
        # 步骤1:词嵌入
        embedded = self.embedding(input_ids)  # (batch_size, seq_len, embed_dim)
        
        # 步骤2:过RNN
        # output: (batch_size, seq_len, hidden_dim) → 每个时间步的隐藏状态
        # hidden: (num_layers, batch_size, hidden_dim) → 每个堆叠层最后时刻的隐藏状态
        output, hidden = self.rnn(embedded)
        
        # 步骤3:取RNN的最后一个时间步的隐藏状态做分类
        # batch_first=True时,-1就是序列的最后一个时刻
        last_hidden = output[:, -1, :]
        logits = self.classifier(last_hidden)
        return logits

# 测试一下
if __name__ == "__main__":
    vocab_size = 10000  # 假设词表有10000个词
    model = RNNTextClassifier(vocab_size=vocab_size)
    # 生成假数据:32个batch,每个句子50个token(id从1到9999,0是填充)
    dummy_input = torch.randint(1, vocab_size, (32, 50))
    dummy_logits = model(dummy_input)
    print(f"输出形状:{dummy_logits.shape}")  # 应该是 torch.Size([32, 2])

3.2 双向 RNN 文本分类器

有时候理解一个词不仅要看前面说了什么,还要结合后面的上下文。比如“我今天很__,因为没吃到火锅”,横线处的情绪显然和后面的“没吃到火锅”负相关。

双向 RNN(Bidirectional RNN)同时训练两个方向的结构:

  • 前向 RNN:从左到右处理序列;
  • 后向 RNN:从右到左处理序列。

最后把两个方向最后时刻的隐藏状态拼接起来,作为对整个序列的表示。

双向
import torch
import torch.nn as nn

class BiRNNTextClassifier(nn.Module):
    """双向情感二分类:同时看前后文的语义"""
    def __init__(
        self, 
        vocab_size: int,
        embed_dim: int = 128,
        hidden_dim: int = 128,
        num_layers: int = 2,
        num_classes: int = 2,
        dropout: float = 0.3
    ):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        # 关键!把bidirectional设为True
        self.rnn = nn.RNN(
            input_size=embed_dim,
            hidden_size=hidden_dim,
            num_layers=num_layers,
            batch_first=True,
            dropout=dropout if num_layers > 1 else 0.0,
            bidirectional=True
        )
        # 因为是双向,隐藏维度要翻倍
        self.classifier = nn.Linear(hidden_dim * 2, num_classes)

    def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
        embedded = self.embedding(input_ids)
        # RNN返回的hidden是(forward_hidden, backward_hidden),每个的形状为(num_layers, batch_size, hidden_dim)
        output, (forward_hidden, backward_hidden) = self.rnn(embedded)
        # 取最后层的前向和后向隐藏状态,拼接
        combined_hidden = torch.cat([forward_hidden[-1], backward_hidden[-1]], dim=-1)
        logits = self.classifier(combined_hidden)
        return logits

# 同样测试一下
if __name__ == "__main__":
    vocab_size = 10000
    model = BiRNNTextClassifier(vocab_size=vocab_size)
    dummy_input = torch.randint(1, vocab_size, (32, 50))
    dummy_logits = model(dummy_input)
    print(f"双向RNN输出形状:{dummy_logits.shape}")  # 仍然是 torch.Size([32, 2])

4. 小结与速查

4.1 核心知识点回顾

  1. RNN 的作用:为序列数据引入“记忆”,解决传统网络“只看词频、忽略位置”的问题。
  2. 展开图理解:按时间步展开后,每个时间步复用同一个单元,输入为当前词和上一步记忆。
  3. 致命问题
    • 梯度消失,导致捕获不到长距离依赖;
    • 梯度爆炸,导致训练不稳定。
  4. 改进方向:LSTM / GRU 专门解决梯度消失;梯度裁剪专门解决梯度爆炸。

4.2 PyTorch RNN 速查

参数 / 输出说明
input_sizeRNN 输入的特征维度(例如词嵌入维度)
hidden_size隐藏层维度
num_layers堆叠的 RNN 层数
batch_first=True输入输出的第一维是 batch_size(推荐使用)
bidirectional=True开启双向 RNN
dropout堆叠层之间的 dropout 率(仅当 num_layers > 1 时生效)
output所有时间步的隐藏状态:(batch_size, seq_len, hidden_size * dirs)
hidden所有层最后时刻的隐藏状态:(num_layers * dirs, batch_size, hidden_size)
实践忠告

原生 RNN 在长序列任务(长文本分类、机器翻译等)中表现很差,实际项目中请直接使用 LSTM 或 GRU。下一篇文章我们就来深入解析 LSTM。


🔗 扩展阅读