高级主题与 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训练长度1000的序列:串行1000步
- Transformer训练长度1000的序列:1步(全并行)
从 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仍然适合的场景:
- 实时/流式处理:不需要看到完整序列,逐步产生输出(如实时语音识别)
- 资源受限:RNN的参数量比Transformer少很多,适合嵌入式设备
- 超长序列:Transformer的注意力矩阵是O(T²),T=100K时内存不够;RNN是O(T)
- 物理系统建模: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
下一步学习路线
直接后续(推荐接下来学):
- Transformer与Self-Attention:RNN的继承者,理解了RNN的局限后,Transformer的设计动机会非常清晰
- BERT与GPT:基于Transformer的预训练语言模型,是现代NLP的基础
扩展应用:
- 语音识别(CTC Loss):RNN在语音任务中的经典应用
- 时间序列预测(时序Transformer):RNN与Transformer在时序任务上的对比
- 强化学习中的RNN:在部分可观测环境(POMDP)中,用LSTM记忆历史状态
深入研究:
- Mamba/SSM(2024+):新型状态空间模型,试图结合RNN的线性复杂度和Transformer的并行训练优势
RNN不只是历史,它是理解序列建模的必经之路。