长短时记忆网络 (LSTM/GRU):解决梯度消失,捕捉长距离依赖

📂 所属阶段:第二阶段 — 深度学习与序列模型(进阶篇)
🎯 前置知识:循环神经网络 (RNN) 基础
🔗 相关章节:循环神经网络 (RNN) · 序列到序列模型 (Seq2Seq)


1. LSTM 核心思想:给RNN装个“信息保险箱”

1.1 痛点:普通RNN的“短视”

普通RNN处理长序列时,前面的有用信息很容易被后面的信息覆盖甚至冲掉——这就是“梯度消失”导致的结果:反向传播时,越靠前的参数,权重更新的“信号”越弱,几乎学不到长距离的关联(比如翻译任务中“主语单复数”和“动词结尾”的对应)。

LSTM的核心改进,就是引入了「细胞状态」(Cell State) 这条“几乎不减速的信息传送带”,再搭配三个「可学习的信息门」 来精准控制:

  • 什么时候把新信息存进传送带?
  • 什么时候把旧信息从传送带上扔掉?
  • 什么时候从传送带上取信息输出给下一层/下一个时刻?

1.2 拆解LSTM的计算流程

为了避免数学公式,我们用一个「文本情感分析」的具体场景(比如输入:“这部电影开场有点闷,但结局太治愈太戳泪点了!”),来一步步看每个门和细胞状态的工作:

第一步:遗忘门(Filter Gate)

先“清理”上一时刻细胞状态里的信息——比如开场的“有点闷”,看到后面的“但结局”就应该降低甚至忘掉它的权重
遗忘门的逻辑:把上一时刻的隐藏状态 h_prev(相当于上一时刻的“短期临时记忆”)和当前时刻的输入 x_now(“但结局”的词向量/特征)拼在一起,经过可学习的调节参数后,用「0-1开关函数」处理——输出1代表“全留”,0代表“全删”,中间值代表“留一部分”

第二步:输入门(Write Gate)+ 临时候选状态

接下来决定“往细胞状态里加什么新信息”,分两个小步骤:

  1. 输入选择门:同样拼 h_prevx_now,用开关函数选出哪些临时信息值得存;
  2. 临时候选状态:还是拼 h_prevx_now,用「-1到1的缩放函数」生成当前时刻的“新内容草稿”;
  3. 把“选择门的结果”和“草稿”相乘——只有被选中的内容才会真正写入传送带。

第三步:更新细胞状态(Update Conveyor)

核心步骤!有了前面的“清理结果”和“写入结果”,就能更新传送带了:

  1. 先拿“清理结果”乘以上一时刻的细胞状态(旧传送带内容);
  2. 再加上“写入结果”;
  3. 得到当前时刻的细胞状态——这就是LSTM能保存长距离信息的关键!

第四步:输出门(Output Gate)+ 隐藏状态

最后决定“从传送带里取什么信息,输出给下一个时刻/全连接层”:

  1. 输出选择门:还是拼 h_prevx_now,用开关函数选出哪些传送带内容要输出;
  2. 缩放传送带内容:把当前时刻的细胞状态用缩放函数压缩到-1到1;
  3. 把“选择门的结果”和“压缩后的内容”相乘——得到当前时刻的隐藏状态(短期临时记忆)。

1.3 PyTorch LSTM 实战类实现

我们直接写一个双向LSTM情感分类模型——双向LSTM能同时看“前文”和“后文”,情感分析这类任务效果更好:

import torch
import torch.nn as nn

class BiLSTMTextClassifier(nn.Module):
    """
    双向LSTM文本分类器
    适用于情感分析、新闻分类等短文本任务
    """
    def __init__(
        self,
        vocab_size: int,    # 词表大小
        embed_dim: int = 256,  # 词嵌入维度
        hidden_dim: int = 256, # LSTM隐藏层维度
        num_layers: int = 2,   # LSTM层数
        dropout: float = 0.3,  #  dropout比例,防止过拟合
        num_classes: int = 2    # 分类类别数(二分类:积极/消极)
    ):
        super().__init__()
        
        # 1. 词嵌入层:把词ID转换成低维稠密向量
        self.embedding = nn.Embedding(
            vocab_size, embed_dim, padding_idx=0  # padding_idx=0:忽略词表中的填充词
        )
        
        # 2. 双向LSTM层
        self.lstm = nn.LSTM(
            input_size=embed_dim,
            hidden_size=hidden_dim,
            num_layers=num_layers,
            batch_first=True,  # 输入输出的第一维度是batch_size(更符合习惯)
            bidirectional=True, # 双向:前向看前文,后向看后文
            dropout=dropout if num_layers > 1 else 0  # 只有多层LSTM才加层间dropout
        )
        
        # 3. 全连接分类头:双向拼接后的维度是 hidden_dim*2
        self.classifier = nn.Sequential(
            nn.Dropout(dropout),
            nn.Linear(hidden_dim * 2, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, num_classes)
        )

    def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
        """
        前向传播
        input_ids: (batch_size, seq_len) → 每个样本是词ID序列
        logits: (batch_size, num_classes) → 每个样本对应类别的未归一化分数
        """
        # 词嵌入:(B, L) → (B, L, E)
        embedded = self.embedding(input_ids)
        
        # LSTM计算
        # output: (B, L, 2H) → 每个时刻的双向隐藏状态拼接
        # (h_n, c_n): 最后时刻的隐藏状态和细胞状态,(2*num_layers, B, H)
        output, (h_n, _) = self.lstm(embedded)
        
        # 取最后一层的双向隐藏状态拼接 → (B, 2H)
        last_layer_forward = h_n[-2]  # 前向最后一层的最后一个隐藏状态
        last_layer_backward = h_n[-1] # 后向最后一层的最后一个隐藏状态
        final_hidden = torch.cat([last_layer_forward, last_layer_backward], dim=-1)
        
        # 分类头计算
        logits = self.classifier(final_hidden)
        return logits

2. GRU:LSTM的“轻量精简版”

2.1 GRU的改进思路

2014年Cho等人提出了门控循环单元(GRU),直接把LSTM的三个门合并成两个,还去掉了独立的细胞状态——把“长期记忆”和“短期临时记忆”合并成了一个隐藏状态:

LSTMGRU
遗忘门、输入门、输出门重置门、更新门(两个门)
独立细胞状态(长期)+ 隐藏状态(短期)单一隐藏状态(长短期合并)
参数量大参数量少 ~33%
复杂任务表现稳简单/中等任务效果相近,训练/推理更快

2.2 PyTorch GRU 实战类实现

同样写一个双向GRU情感分类器,代码结构和LSTM几乎完全一致,只是把nn.LSTM换成nn.GRU,少了对细胞状态的处理:

import torch
import torch.nn as nn

class BiGRUTextClassifier(nn.Module):
    """
    双向GRU文本分类器
    轻量高效,适合快速原型验证或简单任务
    """
    def __init__(
        self,
        vocab_size: int,
        embed_dim: int = 256,
        hidden_dim: int = 256,
        num_layers: int = 2,
        dropout: float = 0.3,
        num_classes: int = 2
    ):
        super().__init__()
        
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        
        # 替换成nn.GRU
        self.gru = nn.GRU(
            input_size=embed_dim,
            hidden_size=hidden_dim,
            num_layers=num_layers,
            batch_first=True,
            bidirectional=True,
            dropout=dropout if num_layers > 1 else 0
        )
        
        # 分类头不变
        self.classifier = nn.Sequential(
            nn.Dropout(dropout),
            nn.Linear(hidden_dim * 2, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, num_classes)
        )

    def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
        embedded = self.embedding(input_ids)
        # GRU只返回output和h_n,没有c_n
        _, h_n = self.gru(embedded)
        final_hidden = torch.cat([h_n[-2], h_n[-1]], dim=-1)
        logits = self.classifier(final_hidden)
        return logits

3. 实战片段:快速跑通情感分类训练

3.1 训练与验证单轮函数

我们补充上训练时常用的梯度裁剪(防止梯度爆炸)和准确率计算

import torch
from torch.utils.data import DataLoader

def train_one_epoch(
    model: nn.Module,
    dataloader: DataLoader,
    optimizer: torch.optim.Optimizer,
    criterion: nn.Module,
    device: torch.device,
    clip_max_norm: float = 1.0  # 梯度裁剪的最大范数
) -> tuple[float, float]:
    """
    训练单轮模型
    返回:平均损失、平均准确率
    """
    model.train()
    total_loss = 0.0
    correct_preds = 0
    total_preds = 0

    for batch in dataloader:
        # 把数据移到GPU/CPU
        input_ids = batch["input_ids"].to(device)
        labels = batch["label"].to(device)

        # 前向传播
        optimizer.zero_grad()
        logits = model(input_ids)
        loss = criterion(logits, labels)

        # 反向传播 + 梯度裁剪 + 参数更新
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), clip_max_norm)
        optimizer.step()

        # 统计指标
        total_loss += loss.item()
        preds = logits.argmax(dim=-1)
        correct_preds += (preds == labels).sum().item()
        total_preds += labels.size(0)

    avg_loss = total_loss / len(dataloader)
    avg_acc = correct_preds / total_preds
    return avg_loss, avg_acc

3.2 极简推理函数

最后写一个不带Tokenizer细节的推理函数(实际使用时直接替换成你用的Tokenizer,比如Hugging Face的AutoTokenizer):

import torch

def predict_sentiment(
    model: nn.Module,
    tokenizer,  # 假设已初始化好的Tokenizer
    text: str,
    device: torch.device
) -> dict[str, float]:
    """
    预测单条文本的情感
    返回:积极、消极的概率字典
    """
    model.eval()
    with torch.no_grad():  # 推理时不需要计算梯度
        # 分词+转ID+补填充(这里简化,实际用tokenizer的__call__更方便)
        tokens = tokenizer.tokenize(text)
        ids = tokenizer.convert_tokens_to_ids(tokens)
        # 加batch维度 → (1, seq_len)
        input_ids = torch.tensor([ids]).to(device)
        
        # 前向传播
        logits = model(input_ids)
        # 用softmax把未归一化分数转成概率
        probs = torch.softmax(logits, dim=-1).squeeze(0)  # 去掉batch维度
    
    return {
        "positive": round(probs[1].item(), 4),
        "negative": round(probs[0].item(), 4)
    }

4. 2026年的选择建议

4.1 LSTM vs GRU 简单对比

维度LSTMGRU
参数量少30%左右
训练/推理速度
长距离记忆能力略强(文献中)中等任务足够
适用场景(历史)机器翻译、语音识别情感分析、文本分类

4.2 2026年的实际应用情况

虽然LSTM/GRU是序列模型的经典入门知识,但在当前的NLP、甚至部分语音识别场景中,Transformer架构的预训练模型(如BERT、GPT、Whisper)已经几乎完全替代了它们的位置

  • 预训练模型有海量无标注数据的知识,下游任务微调效果远好于从零训练的LSTM/GRU;
  • 现在的硬件(如A100、H100 GPU)对Transformer的自注意力机制优化得非常好,训练速度甚至不比多层LSTM慢。

4.3 什么时候还会用LSTM/GRU?

目前它们主要用于:

  1. 需要严格 低延迟/低参数量 的边缘设备场景
  2. 严格要求按顺序处理输入输出、且不需要长距离全局依赖的小任务;
  3. 学术研究的对比实验(验证新模型的效果时,常用LSTM/GRU做baseline)。

🔗 扩展阅读