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

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


1. 为什么需要 RNN?

1.1 传统神经网络的局限

传统的全连接网络(Dense)或卷积网络(CNN),本质上都是独立处理每个输入样本的——不管输入文本里的词是怎么排列的,只要词频差不多,输出的特征向量就可能重合。

举个情感分析的反例

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

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

RNN(Recurrent Neural Network)的名字里就藏着秘密——循环复用同一个神经元单元,同时引入了一个叫「隐藏状态(Hidden State)」的东西,用来存储「之前看到的信息」。

我们可以把RNN按时间步展开来看(时间步对应序列里的每个元素,比如文本里的第t个词):

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 函数(双曲正切)来归一化,避免数值过大或过小: 新记忆 = tanh( 输入权重 × 当前输入 + 记忆权重 × 旧记忆 + 偏置 )


2. RNN 的致命问题

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

2.1 梯度消失与爆炸

训练神经网络的核心是「反向传播梯度」——从输出层往回走,计算每个权重对最终损失的影响,然后更新权重。

但RNN是循环复用同一个单元的,反向传播时梯度会沿着时间步连乘N次(N是序列长度):

  • 如果权重矩阵的特征值都小于1:梯度连乘后会越来越小,趋近于0——这就是「梯度消失」,模型学不到很久之前的信息
  • 如果权重矩阵的特征值都大于1:梯度连乘后会越来越大,趋近于无穷——这就是「梯度爆炸」,训练时Loss会直接跳成NaN
长期依赖失效的直观例子

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

2.2 其他小问题

除了这两个核心问题,原生RNN还有训练效率低(因为必须按时间步串行处理,没法像CNN那样并行化大部分层)、对初始记忆敏感等小瑕疵。


3. PyTorch RNN 快速实现

虽然原生RNN不常用,但它是LSTM/GRU的基础,我们还是得先学会用PyTorch写一个简单的文本分类器。

3.1 单向 RNN 文本分类器

单向
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是让填充的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的最后一个时间步(或最后一层)的隐藏状态做分类
        # 这里取output[:, -1, :],因为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就是同时跑两个RNN:

  • 前向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
        )
        # 因为双向,hidden_dim要×2
        self.classifier = nn.Linear(hidden_dim * 2, num_classes)

    def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
        embedded = self.embedding(input_ids)
        output, (forward_hidden, backward_hidden) = self.rnn(embedded)
        # forward_hidden[-1]: 最后一个前向堆叠层的最后隐藏状态
        # backward_hidden[-1]: 最后一个后向堆叠层的最后隐藏状态
        # 拼接起来
        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。


🔗 扩展阅读