主题
字号
CHAPTER 08 ≈ 30 MIN READ

高级主题与 Transformer 过渡

变长序列处理:Pack和Pad

实际中,同一个batch里的序列长度不同,需要padding。但让LSTM处理padding位置是浪费,而且会影响最终隐藏状态。

from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence

class EfficientLSTM(nn.Module):
    def __init__(self, input_size, hidden_size):
        super().__init__()
        self.lstm = nn.LSTM(input_size, hidden_size, batch_first=True)

    def forward(self, x, lengths):
        """
        x: (batch, max_len, input_size) - 已padding的序列
        lengths: (batch,) - 每个序列的真实长度
        """
        # 按长度降序排序(pack_padded_sequence的要求)
        lengths_sorted, sort_idx = lengths.sort(descending=True)
        x_sorted = x[sort_idx]

        # 打包:告诉LSTM每个序列实际多长,不处理padding
        packed = pack_padded_sequence(
            x_sorted, lengths_sorted.cpu(), batch_first=True
        )

        # LSTM只处理有效token
        packed_output, (h_n, c_n) = self.lstm(packed)

        # 解包:还原padding格式
        output, _ = pad_packed_sequence(packed_output, batch_first=True)

        # 还原排序
        _, unsort_idx = sort_idx.sort()
        output = output[unsort_idx]
        h_n = h_n[:, unsort_idx, :]
        c_n = c_n[:, unsort_idx, :]

        return output, (h_n, c_n)

训练技巧

梯度裁剪的正确方式

# ❌ 错误:按元素裁剪(改变梯度方向)
for param in model.parameters():
    if param.grad is not None:
        param.grad.data.clamp_(-1, 1)

# ✅ 正确:按全局范数裁剪(只改变幅度,不改变方向)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

🔍 为什么全局范数裁剪更好

按元素裁剪时,大梯度被截断,小梯度不变,这会改变梯度的方向(各个维度之间的比例变了)。梯度方向决定了参数更新的"走向",改变方向可能导致模型往错误的方向更新。

全局范数裁剪计算所有参数梯度的L2范数,如果超过阈值,等比例缩放所有梯度。这样梯度方向不变,只是走的步子小一点,是更合理的选择。

深层RNN与残差连接

class DeepBiLSTM(nn.Module):
    """多层BiLSTM,层间有残差连接"""
    def __init__(self, input_size, hidden_size, num_layers, dropout=0.3):
        super().__init__()
        self.layers = nn.ModuleList()
        self.layer_norms = nn.ModuleList()
        self.projs = nn.ModuleList()

        for i in range(num_layers):
            in_size = input_size if i == 0 else hidden_size * 2
            self.layers.append(
                nn.LSTM(in_size, hidden_size, batch_first=True, bidirectional=True)
            )
            self.layer_norms.append(nn.LayerNorm(hidden_size * 2))
            # 如果维度不匹配,需要投影
            if in_size != hidden_size * 2:
                self.projs.append(nn.Linear(in_size, hidden_size * 2, bias=False))
            else:
                self.projs.append(None)

        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        for i, (layer, norm, proj) in enumerate(
            zip(self.layers, self.layer_norms, self.projs)
        ):
            residual = x
            output, _ = layer(x)
            output = self.dropout(output)

            # 残差连接(如果维度匹配)
            if proj is not None:
                residual = proj(residual)
            output = norm(output + residual)

            x = output

        return x

Layer Normalization in RNN

标准BN在RNN中效果差(batch统计在短序列上不稳定),LayerNorm是更好的选择:

class LayerNormLSTMCell(nn.Module):
    """带LayerNorm的LSTM单元"""
    def __init__(self, input_size, hidden_size):
        super().__init__()
        self.lstm_cell = nn.LSTMCell(input_size, hidden_size)
        self.ln_h = nn.LayerNorm(hidden_size)  # 对隐藏状态归一化
        self.ln_c = nn.LayerNorm(hidden_size)  # 对细胞状态归一化

    def forward(self, x, hidden):
        h, c = hidden
        h_next, c_next = self.lstm_cell(x, (h, c))
        h_next = self.ln_h(h_next)
        c_next = self.ln_c(c_next)
        return h_next, c_next

学习率调度策略

# 策略1:Warm-up + Cosine Decay(Transformer推荐)
from torch.optim.lr_scheduler import LambdaLR

def get_cosine_schedule_with_warmup(optimizer, num_warmup_steps, num_training_steps):
    def lr_lambda(current_step):
        if current_step < num_warmup_steps:
            return float(current_step) / float(max(1, num_warmup_steps))
        progress = float(current_step - num_warmup_steps) / float(
            max(1, num_training_steps - num_warmup_steps)
        )
        return max(0.0, 0.5 * (1.0 + math.cos(math.pi * progress)))
    return LambdaLR(optimizer, lr_lambda)

# 策略2:ReduceLROnPlateau(验证集不再改善时降低学习率)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
    optimizer, mode='min', factor=0.5, patience=3, verbose=True
)
scheduler.step(val_loss)  # 每个epoch结束时调用

# 策略3:CyclicLR(循环学习率)
scheduler = torch.optim.lr_scheduler.CyclicLR(
    optimizer, base_lr=1e-4, max_lr=1e-2, step_size_up=500
)

Dropout策略

class RNNWithDropout(nn.Module):
    """
    RNN中常见的3种Dropout位置
    """
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes):
        super().__init__()
        # Dropout 1:嵌入层后(防止模型记住特定词的绝对位置)
        self.embed_dropout = nn.Dropout(0.2)

        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)

        self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers=2,
                            batch_first=True,
                            # Dropout 2:层间(PyTorch内置,不作用于最后一层输出)
                            dropout=0.3)

        # Dropout 3:全连接层前(防止过拟合)
        self.fc_dropout = nn.Dropout(0.5)
        self.fc = nn.Linear(hidden_dim, num_classes)

    def forward(self, x):
        embedded = self.embed_dropout(self.embedding(x))
        output, (hidden, _) = self.lstm(embedded)
        return self.fc(self.fc_dropout(hidden[-1]))

RNN不只用于NLP,时间序列预测是它的另一大应用领域:股票价格、天气预报、传感器数据。

时间序列的特点

import numpy as np
import torch
import torch.nn as nn

# 生成一个含噪声的正弦波时间序列(模拟周期性数据)
def generate_sine_wave(length=1000, period=50, noise_level=0.1):
    t = np.linspace(0, length / period * 2 * np.pi, length)
    signal = np.sin(t) + noise_level * np.random.randn(length)
    return signal.astype(np.float32)

data = generate_sine_wave()

# 可视化(如果在Jupyter中)
# import matplotlib.pyplot as plt
# plt.plot(data[:200])
# plt.title("正弦波(含噪声)")
# plt.show()

时间序列预测

滑动窗口构建数据集

class TimeSeriesDataset(torch.utils.data.Dataset):
    """
    滑动窗口法:
    - 输入:过去 seq_len 个时间步的值
    - 标签:未来 pred_len 个时间步的值
    """
    def __init__(self, data, seq_len=50, pred_len=10):
        self.seq_len = seq_len
        self.pred_len = pred_len

        # 归一化
        self.mean = data.mean()
        self.std = data.std()
        data_norm = (data - self.mean) / self.std

        # 构建样本
        self.X, self.y = [], []
        for i in range(len(data_norm) - seq_len - pred_len + 1):
            self.X.append(data_norm[i:i+seq_len])
            self.y.append(data_norm[i+seq_len:i+seq_len+pred_len])

        self.X = torch.tensor(np.array(self.X)).unsqueeze(-1)  # (N, seq_len, 1)
        self.y = torch.tensor(np.array(self.y))               # (N, pred_len)

    def __len__(self): return len(self.X)
    def __getitem__(self, idx): return self.X[idx], self.y[idx]

    def inverse_transform(self, y):
        """还原归一化"""
        return y * self.std + self.mean

LSTM时间序列预测模型

class LSTMForecaster(nn.Module):
    """LSTM时间序列预测"""
    def __init__(self, input_size=1, hidden_size=64, num_layers=2,
                 pred_len=10, dropout=0.2):
        super().__init__()
        self.lstm = nn.LSTM(input_size, hidden_size, num_layers,
                            batch_first=True, dropout=dropout)
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_size, pred_len)

    def forward(self, x):
        # x: (batch, seq_len, input_size)
        output, (h_n, _) = self.lstm(x)
        # 取最后时刻的隐藏状态做预测
        last_hidden = self.dropout(h_n[-1])  # (batch, hidden_size)
        pred = self.fc(last_hidden)          # (batch, pred_len)
        return pred


# 训练
dataset = TimeSeriesDataset(data, seq_len=50, pred_len=10)
train_size = int(0.8 * len(dataset))
train_dataset, val_dataset = torch.utils.data.random_split(
    dataset, [train_size, len(dataset) - train_size]
)

train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=32, shuffle=True)
val_loader = torch.utils.data.DataLoader(val_dataset, batch_size=32)

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = LSTMForecaster(pred_len=10).to(device)
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

for epoch in range(100):
    model.train()
    total_loss = 0
    for x, y in train_loader:
        x, y = x.to(device), y.to(device)
        pred = model(x)
        loss = criterion(pred, y)
        optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        optimizer.step()
        total_loss += loss.item()

    if (epoch + 1) % 20 == 0:
        avg_loss = total_loss / len(train_loader)
        rmse = (avg_loss ** 0.5) * dataset.std  # 还原到原始尺度
        print(f"Epoch {epoch+1}: RMSE={rmse:.4f}")

🔍 时间序列预测的陷阱:数据泄露

处理时间序列时,必须严格保证测试集在时间上在训练集之后,不能随机划分!

如果随机划分,训练集里可能包含未来的数据点,模型可以"作弊"学到未来信息,导致测试准确率虚高。这在实际部署时会失效。

正确做法:按时间顺序划分,比如前80%作为训练集,中间10%作为验证集,后10%作为测试集。


RNN的根本局限

尽管LSTM和GRU很强大,但RNN有一个根本性的架构缺陷:串行计算

处理长度为T的序列,RNN必须执行T步串行计算(第t步必须等第t-1步完成)。这在GPU时代是个大问题——GPU的优势是并行,但RNN无法并行化。

RNN(串行):        x1 → h1 → x2 → h2 → x3 → h3 → ...(必须等待)
Transformer(并行):[x1, x2, x3, ...] → 同时处理所有位置

计算效率对比

从 RNN 到 Transformer

注意力机制取代循环

Transformer(Vaswani et al., 2017, "Attention is All You Need")完全放弃了RNN,改用Self-Attention:

Self-Attention的核心:
"在处理词i时,直接从所有其他位置j加权取信息,权重由i和j的相关性决定"

RNN的信息传递:x1 → h1 → h2 → h3 → ... → hT(间接,要经过多步)
Attention的信息传递:x1 → 直接连接到 xT(O(1)距离)

长距离依赖的处理能力

特性 RNN/LSTM Transformer
任意两点间最短路径 O(T) O(1)
并行计算 不支持 支持
计算复杂度 O(T) O(T²)(注意力矩阵)
长距离依赖 困难(梯度消失) 容易
序列长度 可扩展 受内存限制(O(T²))

RNN仍然有价值吗?

答案:是的,但适用场景在变化。

RNN/LSTM仍然适合的场景:

  1. 实时/流式处理:不需要看到完整序列,逐步产生输出(如实时语音识别)
  2. 资源受限:RNN的参数量比Transformer少很多,适合嵌入式设备
  3. 超长序列:Transformer的注意力矩阵是O(T²),T=100K时内存不够;RNN是O(T)
  4. 物理系统建模:RNN的状态传递结构天然适合物理系统的时序动力学

近期进展

2024年,Mamba(Gu & Dao)和RWKV等架构重新引发了对RNN式架构的兴趣。这些"现代RNN"保留了RNN线性计算复杂度的优势,同时尝试弥补长程依赖的缺陷,被视为Transformer的潜在替代方案。

🔍 深层思考:学RNN有没有价值

有人问:"既然Transformer这么强,为什么还要学RNN?"

原因有几个:

理解历史,才能理解现在。Transformer的注意力机制,很大程度上是对RNN局限性的回应。不理解RNN的问题,就很难真正理解为什么Transformer要这样设计。

RNN是序列建模的基础语言。LSTM中的门控机制、细胞状态、梯度高速公路等概念,在很多现代架构中以不同形式出现(Mamba的选择性状态空间、GRU式门控)。

工程实践中仍然有用。许多生产系统仍在使用LSTM(轻量、推理快、适合流式)。理解它才能维护、优化这些系统。

深度学习的思维方式。RNN教会你如何思考信息的"流动"、梯度的"传播"、架构设计如何影响模型能力。这些思维方式适用于所有深度学习架构。


常见错误与解决方案

错误1:维度混乱

# ❌ 常见错误:输入维度顺序搞错
# batch_first=True时,应该是 (batch, seq, feature)
x = torch.randn(10, 32, 100)  # 错!这是 (seq, batch, feature)
x = torch.randn(32, 10, 100)  # ✓ batch=32, seq=10, feature=100

# ❌ Embedding输入类型错误
x = torch.randn(32, 10)        # 错!Embedding需要Long类型
x = torch.randint(0, 1000, (32, 10))  # ✓ Long类型整数索引

# ❌ 忘记LSTM返回两个值
output = lstm(x)        # 错!lstm返回 (output, (h_n, c_n))
output, (h, c) = lstm(x)  # ✓

错误2:隐藏状态管理不当

# ❌ 跨batch不重置隐藏状态
model.hidden = None
for batch in loader:
    output, hidden = model(batch, model.hidden)
    model.hidden = hidden  # 错!梯度会一直追溯到训练开始,内存泄漏

# ✓ 正确:截断梯度
for batch in loader:
    output, hidden = model(batch, hidden)
    hidden = hidden.detach()  # 截断梯度图,只保留数值

错误3:测试集时忘记torch.no_grad()

# ❌ 评估时没用no_grad,浪费内存,速度慢
for x, y in val_loader:
    output = model(x)  # 仍然在构建计算图

# ✓ 正确
model.eval()
with torch.no_grad():
    for x, y in val_loader:
        output = model(x)

错误4:损失函数包含Padding位置

# ❌ 直接计算损失,padding位置的损失也被计入
loss = nn.CrossEntropyLoss()(logits.view(-1, vocab_size), y.view(-1))

# ✓ 忽略padding位置的损失
loss = nn.CrossEntropyLoss(ignore_index=0)(logits.view(-1, vocab_size), y.view(-1))
# ignore_index=0 表示idx=0(即<pad>)的位置不计入损失

错误5:双向LSTM隐藏状态拼接错误

bilstm = nn.LSTM(10, 20, bidirectional=True, batch_first=True)
output, (h_n, c_n) = bilstm(x)

# ❌ 错误:直接取h_n[-1](只有后向最后层)
last_hidden = h_n[-1]  # 只有后向

# ✓ 正确:拼接前向和后向
h_forward = h_n[-2]   # 最后一层前向
h_backward = h_n[-1]  # 最后一层后向
last_hidden = torch.cat([h_forward, h_backward], dim=1)  # (batch, 40)

错误6:梯度爆炸导致Loss变NaN

# 症状:loss突然变成nan,参数包含inf

# ✓ 解决:在optimizer.step()之前加梯度裁剪
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)  # ← 加在这里
optimizer.step()

# 调试:检查梯度是否有问题
for name, param in model.named_parameters():
    if param.grad is not None:
        print(f"{name}: grad_norm={param.grad.norm():.4f}")

RNN快速查阅表

# ============ 核心组件 ============
import torch
import torch.nn as nn

# RNN(基础,很少直接用)
rnn = nn.RNN(input_size, hidden_size, num_layers, batch_first=True)
output, h_n = rnn(x)
# output: (batch, seq, hidden)  |  h_n: (num_layers, batch, hidden)

# LSTM(最常用)
lstm = nn.LSTM(input_size, hidden_size, num_layers,
               batch_first=True, dropout=0.3, bidirectional=True)
output, (h_n, c_n) = lstm(x)
# output: (batch, seq, hidden*2)  |  h_n,c_n: (num_layers*2, batch, hidden)

# GRU(LSTM的简化版)
gru = nn.GRU(input_size, hidden_size, num_layers, batch_first=True)
output, h_n = gru(x)

# Embedding
embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
embedded = embedding(x_long)  # x_long: LongTensor → embedded: FloatTensor


# ============ 文本分类模板 ============
class TextClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=True)
        self.dropout = nn.Dropout(0.3)
        self.fc = nn.Linear(hidden_dim * 2, num_classes)

    def forward(self, x):
        embedded = self.dropout(self.embedding(x))
        _, (h_n, _) = self.lstm(embedded)
        # 拼接双向最后隐藏状态
        h = torch.cat([h_n[-2], h_n[-1]], dim=1)
        return self.fc(self.dropout(h))


# ============ 训练模板 ============
def train(model, loader, criterion, optimizer, clip=1.0):
    model.train()
    for x, y in loader:
        logits = model(x)
        loss = criterion(logits, y)
        optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), clip)  # 别忘梯度裁剪!
        optimizer.step()


# ============ 变长序列处理 ============
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
packed = pack_padded_sequence(x, lengths.cpu(), batch_first=True)
output, (h, c) = lstm(packed)
output, _ = pad_packed_sequence(output, batch_first=True)


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

# ============ 损失函数忽略padding ============
criterion = nn.CrossEntropyLoss(ignore_index=0)

总结与下一步

本章知识树

RNN体系
├── 基础RNN
│   ├── 前向传播:h_t = tanh(W_ih·x_t + W_hh·h_{t-1})
│   ├── 反向传播:BPTT
│   ├── 梯度消失/爆炸(核心缺陷)
│   └── 参数共享的归纳偏置
│
├── 改进:门控机制
│   ├── LSTM(3门 + 细胞状态)
│   │   ├── 遗忘门:决定丢弃什么
│   │   ├── 输入门:决定记住什么
│   │   ├── 输出门:决定输出什么
│   │   └── 细胞状态:梯度高速公路
│   └── GRU(2门,LSTM简化版)
│       ├── 更新门(遗忘+输入合一)
│       └── 重置门
│
├── 经典应用
│   ├── 字符级语言模型(文本生成)
│   ├── 文本分类/情感分析(多对一)
│   ├── Seq2Seq翻译(多对多异步)
│   └── 时间序列预测
│
├── 关键技术
│   ├── 词嵌入(Embedding层)
│   ├── 注意力机制(Bahdanau)
│   ├── Beam Search解码
│   ├── Teacher Forcing & Exposure Bias
│   └── 梯度裁剪
│
└── 历史位置
    └── RNN → 注意力机制 → Transformer → 现代LLM

下一步学习路线

直接后续(推荐接下来学)

扩展应用

深入研究


RNN不只是历史,它是理解序列建模的必经之路。