8深度学习

循环网络

序列建模

RNNLSTMGRU

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第8章:循环网络(Recurrent Networks)

一、导读

1.1 本章学习目标

第8章讲解循环神经网络(RNN),这是处理序列数据的核心架构。与CNN处理固定大小的输入不同,RNN可以处理任意长度的序列,并通过隐藏状态维护对历史信息的记忆。

本章的核心学习目标包括:

  • 理解RNN的基本原理:隐藏状态、时间展开、参数共享
  • 掌握BPTT算法:随时间反向传播的原理和实现
  • 理解梯度消失/爆炸问题:为什么标准RNN难以学习长距离依赖
  • 掌握LSTM和GRU:门控机制如何解决长期依赖问题
  • 理解双向RNN和深度RNN
  • 了解RNN的应用:语言模型、序列标注、序列到序列

1.2 为什么本章如此重要

序列数据无处不在:文本、语音、时间序列、视频帧。RNN是深度学习处理序列数据的基础架构。

虽然Transformer在许多任务上已经取代了RNN,但理解RNN仍然重要:

  • 历史意义:RNN是序列建模的开创性工作
  • 理论基础:LSTM/GRU的门控思想影响深远
  • 特定场景:在某些流式处理、低延迟场景中仍有优势
  • 理解Transformer:RNN的局限性推动了Transformer的发明

1.3 核心问题引导

为什么全连接网络不适合处理变长序列?

RNN的隐藏状态如何编码历史信息?

为什么标准RNN存在梯度消失问题?

LSTM的门控机制如何解决长期依赖?

双向RNN如何同时利用过去和未来的信息?

RNN在哪些场景下仍然优于Transformer?


二、核心概念详解

2.1 基本RNN

2.1.1 原理

RNN在每个时间步接收输入 x_t,并更新隐藏状态 h_t:

h_t = tanh(W_hh · h_{t-1} + W_xh · x_t + b_h)
y_t = W_hy · h_t + b_y
python
import torch
import torch.nn as nn

# 基本RNN
rnn = nn.RNN(input_size=10, hidden_size=20, num_layers=1, batch_first=True)

# 输入:(batch, seq_len, input_size)
x = torch.randn(32, 50, 10)  # 批量32,序列长度50,输入维度10

output, h_n = rnn(x)
print(f"输出形状: {output.shape}")  # (32, 50, 20)
print(f"最终隐藏状态: {h_n.shape}")  # (1, 32, 20)

2.1.2 手动实现RNN

python
import numpy as np

class SimpleRNN:
    def __init__(self, input_size, hidden_size):
        self.W_xh = np.random.randn(input_size, hidden_size) * 0.01
        self.W_hh = np.random.randn(hidden_size, hidden_size) * 0.01
        self.b_h = np.zeros(hidden_size)
        self.W_hy = np.random.randn(hidden_size, input_size) * 0.01
        self.b_y = np.zeros(input_size)
    
    def forward(self, x_seq):
        """x_seq: (seq_len, input_size)"""
        seq_len = x_seq.shape[0]
        h = np.zeros(self.W_hh.shape[0])
        
        h_states = [h]
        y_seq = []
        
        for t in range(seq_len):
            x_t = x_seq[t]
            h = np.tanh(x_t @ self.W_xh + h @ self.W_hh + self.b_h)
            y = h @ self.W_hy + self.b_y
            h_states.append(h)
            y_seq.append(y)
        
        return np.array(y_seq), h_states

# 测试
rnn = SimpleRNN(input_size=10, hidden_size=20)
x = np.random.randn(50, 10)
y, h_states = rnn.forward(x)
print(f"输出形状: {y.shape}")
print(f"隐藏状态数量: {len(h_states)}")

2.2 BPTT(随时间反向传播)

python
# BPTT将RNN在时间上展开,转化为一个很深的前馈网络
# 然后应用标准的反向传播

# 梯度问题:
# ∂L/∂W_hh 涉及连乘 ∏(t=i to j) ∂h_t/∂h_{t-1}
# 每个 ∂h_t/∂h_{t-1} ≈ W_hh * diag(1 - h_t²)
# 连乘导致梯度指数级增长(爆炸)或衰减(消失)

# 梯度裁剪解决梯度爆炸
def clip_gradient_norm(parameters, max_norm):
    total_norm = 0
    for p in parameters:
        if p.grad is not None:
            total_norm += p.grad.data.norm() ** 2
    total_norm = total_norm ** 0.5
    clip_coef = max_norm / (total_norm + 1e-6)
    if clip_coef < 1:
        for p in parameters:
            if p.grad is not None:
                p.grad.data.mul_(clip_coef)

2.3 LSTM(长短期记忆网络)

2.3.1 门控机制

LSTM通过三个门控机制解决长期依赖问题:

遗忘门:f_t = σ(W_f · [h_{t-1}, x_t] + b_f)
输入门:i_t = σ(W_i · [h_{t-1}, x_t] + b_i)
候选值:C̃_t = tanh(W_C · [h_{t-1}, x_t] + b_C)
细胞状态:C_t = f_t * C_{t-1} + i_t * C̃_t
输出门:o_t = σ(W_o · [h_{t-1}, x_t] + b_o)
隐藏状态:h_t = o_t * tanh(C_t)
python
import torch
import torch.nn as nn

# LSTM
lstm = nn.LSTM(input_size=10, hidden_size=20, num_layers=1, batch_first=True)

x = torch.randn(32, 50, 10)
output, (h_n, c_n) = lstm(x)

print(f"输出形状: {output.shape}")      # (32, 50, 20)
print(f"隐藏状态: {h_n.shape}")         # (1, 32, 20)
print(f"细胞状态: {c_n.shape}")         # (1, 32, 20)

# 手动实现LSTM单元
class LSTMCell:
    def __init__(self, input_size, hidden_size):
        self.hidden_size = hidden_size
        combined_size = input_size + hidden_size
        
        # 合并所有门的权重
        self.W = np.random.randn(combined_size, 4 * hidden_size) * 0.01
        self.b = np.zeros(4 * hidden_size)
    
    def forward(self, x, h_prev, c_prev):
        combined = np.concatenate([x, h_prev])
        gates = combined @ self.W + self.b
        
        # 分割四个门
        f = self.sigmoid(gates[:self.hidden_size])           # 遗忘门
        i = self.sigmoid(gates[self.hidden_size:2*self.hidden_size])  # 输入门
        g = np.tanh(gates[2*self.hidden_size:3*self.hidden_size])     # 候选值
        o = self.sigmoid(gates[3*self.hidden_size:])         # 输出门
        
        c_new = f * c_prev + i * g
        h_new = o * np.tanh(c_new)
        
        return h_new, c_new
    
    def sigmoid(self, x):
        return 1 / (1 + np.exp(-np.clip(x, -500, 500)))

2.3.2 LSTM如何解决梯度消失

python
# LSTM的细胞状态更新:C_t = f_t * C_{t-1} + i_t * C̃_t
# 梯度流:∂C_t/∂C_{t-1} = f_t
# 遗忘门f_t可以学习保持为1,梯度不会消失

# 对比标准RNN:h_t = tanh(W_hh * h_{t-1} + ...)
# 梯度流:∂h_t/∂h_{t-1} = W_hh * diag(1 - h_t²)
# 连乘导致梯度消失

# 可视化门控值
lstm = nn.LSTM(10, 20, batch_first=True)
x = torch.randn(1, 100, 10)
output, (h, c) = lstm(x)

# 提取门控值需要修改内部实现
# 这里展示细胞状态的变化
print(f"细胞状态范围: [{c.min().item():.3f}, {c.max().item():.3f}]")
print(f"隐藏状态范围: [{h.min().item():.3f}, {h.max().item():.3f}]")

2.4 GRU(门控循环单元)

python
# GRU是LSTM的简化版本,只有两个门
# 重置门:r_t = σ(W_r · [h_{t-1}, x_t])
# 更新门:z_t = σ(W_z · [h_{t-1}, x_t])
# 候选值:h̃_t = tanh(W · [r_t * h_{t-1}, x_t])
# 隐藏状态:h_t = (1 - z_t) * h_{t-1} + z_t * h̃_t

gru = nn.GRU(input_size=10, hidden_size=20, batch_first=True)
x = torch.randn(32, 50, 10)
output, h_n = gru(x)

print(f"GRU输出: {output.shape}")
print(f"GRU隐藏状态: {h_n.shape}")

2.5 双向RNN与深度RNN

python
# 双向RNN:同时从前往后和从后往前处理序列
birnn = nn.RNN(input_size=10, hidden_size=20, bidirectional=True, batch_first=True)
x = torch.randn(32, 50, 10)
output, h_n = birnn(x)
print(f"双向RNN输出: {output.shape}")  # (32, 50, 40) - 前向20 + 后向20

# 深度RNN:堆叠多层
deep_rnn = nn.LSTM(input_size=10, hidden_size=20, num_layers=3, batch_first=True)
output, (h_n, c_n) = deep_rnn(x)
print(f"3层LSTM隐藏状态: {h_n.shape}")  # (3, 32, 20)

三、实战案例

3.1 案例一:字符级语言模型

python
import torch
import torch.nn as nn
import torch.optim as optim

# 简单的字符级语言模型
class CharRNN(nn.Module):
    def __init__(self, vocab_size, embed_size, hidden_size, num_layers):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.lstm = nn.LSTM(embed_size, hidden_size, num_layers, batch_first=True)
        self.fc = nn.Linear(hidden_size, vocab_size)
    
    def forward(self, x, hidden=None):
        x = self.embedding(x)
        output, hidden = self.lstm(x, hidden)
        output = self.fc(output)
        return output, hidden

# 训练数据(简单的文本)
text = "hello world, this is a simple character level language model example"
chars = list(set(text))
char2idx = {c: i for i, c in enumerate(chars)}
idx2char = {i: c for i, c in enumerate(chars)}
vocab_size = len(chars)

# 准备数据
seq_length = 20
inputs, targets = [], []
for i in range(len(text) - seq_length):
    inputs.append([char2idx[c] for c in text[i:i+seq_length]])
    targets.append([char2idx[c] for c in text[i+1:i+seq_length+1]])

inputs = torch.tensor(inputs)
targets = torch.tensor(targets)

# 训练
model = CharRNN(vocab_size, embed_size=32, hidden_size=64, num_layers=2)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.005)

for epoch in range(100):
    total_loss = 0
    for i, (inp, tgt) in enumerate(zip(inputs, targets)):
        optimizer.zero_grad()
        output, _ = model(inp.unsqueeze(0))
        loss = criterion(output.view(-1, vocab_size), tgt)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
        optimizer.step()
        total_loss += loss.item()
    
    if (epoch + 1) % 20 == 0:
        print(f"Epoch {epoch+1}, Loss: {total_loss/len(inputs):.4f}")

# 生成文本
def generate(model, start_char, length=100):
    model.eval()
    x = torch.tensor([[char2idx[start_char]]])
    generated = [start_char]
    hidden = None
    
    for _ in range(length):
        output, hidden = model(x, hidden)
        probs = torch.softmax(output[0, -1], dim=0)
        next_idx = torch.multinomial(probs, 1).item()
        generated.append(idx2char[next_idx])
        x = torch.tensor([[next_idx]])
    
    return ''.join(generated)

print("\n生成文本:")
print(generate(model, 'h', 200))

3.2 案例二:序列分类

python
import torch
import torch.nn as nn

class SequenceClassifier(nn.Module):
    def __init__(self, input_size, hidden_size, num_classes, num_layers=2):
        super().__init__()
        self.lstm = nn.LSTM(input_size, hidden_size, num_layers, 
                           batch_first=True, bidirectional=True, dropout=0.2)
        self.fc = nn.Linear(hidden_size * 2, num_classes)  # 双向
    
    def forward(self, x):
        output, (h_n, _) = self.lstm(x)
        # 拼接前向和后向的最终隐藏状态
        h_forward = h_n[-2]   # 最后一层的前向
        h_backward = h_n[-1]  # 最后一层的后向
        h_combined = torch.cat([h_forward, h_backward], dim=1)
        return self.fc(h_combined)

# 使用示例
model = SequenceClassifier(input_size=10, hidden_size=32, num_classes=5)
x = torch.randn(32, 50, 10)  # 批量32,序列长度50,特征10
output = model(x)
print(f"分类输出: {output.shape}")  # (32, 5)

四、本章小结

本章系统介绍了循环神经网络:

基本RNN:通过隐藏状态维护序列历史信息,参数在时间步间共享。

BPTT:将RNN展开为深层前馈网络进行训练,但存在梯度消失/爆炸问题。

LSTM:通过遗忘门、输入门、输出门的门控机制,有效解决长期依赖问题。

GRU:LSTM的简化版本,只有重置门和更新门,参数更少但效果相当。

双向和深度RNN:双向RNN同时利用过去和未来信息,深度RNN学习层次化表示。

虽然Transformer在许多任务上已取代RNN,但RNN的门控思想和序列建模原理仍然重要。


五、思考与练习

思考题

LSTM的遗忘门初始偏置为什么通常设为1?

为什么RNN难以并行化?这与Transformer的对比是什么?

Teacher Forcing是什么?它解决了什么问题,引入了什么问题?

为什么Transformer在长序列上优于RNN?自注意力机制的优势是什么?

在什么场景下RNN仍然优于Transformer?

编程练习

手动实现LSTM:用NumPy实现LSTM的前向和反向传播。

梯度可视化:比较标准RNN和LSTM的梯度流,验证梯度消失问题。

字符级生成:在莎士比亚文本上训练字符级LSTM语言模型。

序列分类:在IMDB情感分析数据集上比较RNN、LSTM、GRU的性能。

注意力机制:为RNN添加注意力机制,观察效果提升。

8/10循环网络
0%