s15 序列模型 — exercise.py 练习指南
练习目标
通过手写 RNN、LSTM、GRU 核心组件的代码,建立对循环神经网络前向传播和门控机制的深刻直觉。完成三个练习后,你将能够:
- 独立写出 RNN 的时间步循环前向传播
- 理解 LSTM 遗忘门在梯度传播中的作用
- 掌握 GRU 更新门的线性插值机制
预备知识
在开始练习前,请确保你已经理解以下概念(详见 index.md 和 demo.py 详解):
- RNN 公式:
- BPTT:梯度沿时间反向传播,连乘导致梯度消失
- sigmoid 函数:
,输出范围 - LSTM 三个门:遗忘门
、输入门 、输出门 - GRU 双门:重置门
、更新门
任务清单
练习 1:实现 RNN 前向传播(手动循环时间步)
目标:补全 rnn_forward() 函数,实现 RNN 的逐时间步前向传播。
核心公式:
输入张量形状:
x:(batch, seq_len, input_size)— 输入序列W_ih:(hidden_size, input_size)— 输入→隐藏权重W_hh:(hidden_size, hidden_size)— 隐藏→隐藏权重b:(hidden_size,)— 偏置h0:(batch, hidden_size)— 初始隐藏状态,默认全零
TODO 步骤:
python
for t in range(seq_len):
x_t = x[:, t, :] # 取出第 t 步的输入,形状 (batch, input_size)
h = torch.tanh(
x_t @ W_ih.T # (batch, input_size) @ (input_size, hidden_size) → (batch, hidden_size)
+ h @ W_hh.T # (batch, hidden_size) @ (hidden_size, hidden_size) → (batch, hidden_size)
+ b # (hidden_size,) 广播到 (batch, hidden_size)
)
all_h.append(h) # 保存当前隐藏状态关键提示:
@是 PyTorch 矩阵乘法运算符(等价于torch.matmul)W_ih的形状是(hidden_size, input_size),需要用.T转置后与输入相乘b的维度是(hidden_size,),会被 PyTorch 自动广播到(batch, hidden_size)- 最后用
torch.stack(all_h, dim=1)将所有时间步的隐藏状态堆叠起来
预期输出:
[练习1] RNN 前向传播输出形状: (2, 5, 3) (期望: (2, 5, 3))练习 2:实现 LSTM 遗忘门的计算
目标:补全 lstm_forget_gate() 函数,实现遗忘门的计算。
核心公式:
其中
TODO 步骤:
python
# 1. 在 dim=1 上拼接 h_prev 和 x_t
combined = torch.cat([h_prev, x_t], dim=1) # (batch, hidden_size + input_size)
# 2. 线性变换:W_f @ combined^T → (batch, hidden_size)
# 使用 F.linear(combined, W_f, b_f) 或 manual matmul
gate = F.linear(combined, W_f, b_f) # (batch, hidden_size)
# 3. sigmoid 激活
f_t = torch.sigmoid(gate) # (batch, hidden_size),值域 [0,1]关键提示:
torch.cat([h_prev, x_t], dim=1)在特征维度拼接F.linear(input, weight, bias)等价于input @ weight.T + biastorch.sigmoid()将任意实数映射到 (0, 1),值越大表示"越不想遗忘":遗忘该维度的信息; :保留该维度的信息
理解遗忘门的直觉:在读取一句长文本时,读到句号后可能需要"遗忘"前面句子的部分细节;读到新的主语时需要"遗忘"前一个主语的信息。
预期输出:
[练习2] 遗忘门输出形状: torch.Size([4, 8]), 范围在[0,1]: True (期望: True)练习 3:实现 GRU 更新门
目标:补全 gru_update_gate() 函数,实现 GRU 更新门的计算。
核心公式:
更新门的作用是控制
TODO 步骤(与练习 2 的遗忘门实现几乎相同):
python
combined = torch.cat([h_prev, x_t], dim=1)
z_t = torch.sigmoid(F.linear(combined, W_z, b_z))
return z_t关键提示:
- GRU 的更新门
同时做了 LSTM 中遗忘门和输入门的工作 : (保留历史,不更新) : (用新信息替换) - GRU 比 LSTM 少一个门,参数更少,训练更快
预期输出:
[练习3] 更新门输出形状: torch.Size([4, 8]), 范围在[0,1]: True (期望: True)三个模型的参数量对比
完成练习后,可以计算一下三种模型的参数量差异:
| 模型 | 线性变换数 | 参数量公式 | 门控机制 |
|---|---|---|---|
| RNN | 2 | 无(一个 tanh) | |
| LSTM | 4(合并为1个大矩阵) | 遗忘+输入+输出 | |
| GRU | 2(门合并为1,候选独立) | 重置+更新 |
检查要点
完成所有 TODO 后,运行 python exercise.py,确认:
- [ ] 练习 1 输出形状为
(2, 5, 3) - [ ] 练习 2 遗忘门值域在 [0, 1] 内
- [ ] 练习 3 更新门值域在 [0, 1] 内
全部通过后,返回 demo.py 对照参考实现,理解每个细胞完整的 forward 逻辑。
完整代码
py
# -*- coding: utf-8 -*-
"""
s15 序列模型 — 练习题
==============================================
请补全以下 TODO 部分,完成后运行验证。
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
# ============================================================
# 练习 1:实现 RNN 前向传播(手动循环时间步)
# ============================================================
def rnn_forward(
x: torch.Tensor, # (batch, seq_len, input_size)
W_ih: torch.Tensor, # (hidden_size, input_size) — 输入→隐藏权重
W_hh: torch.Tensor, # (hidden_size, hidden_size) — 隐藏→隐藏权重
b: torch.Tensor, # (hidden_size,) — 偏置
h0: torch.Tensor = None, # (batch, hidden_size) — 初始隐藏状态
) -> torch.Tensor:
"""
TODO: 实现 RNN 前向传播
h_t = tanh(W_ih @ x_t + W_hh @ h_{t-1} + b)
参数:
x: 输入序列 (batch, seq_len, input_size)
W_ih: 输入权重矩阵 (hidden_size, input_size)
W_hh: 隐藏权重矩阵 (hidden_size, hidden_size)
b: 偏置向量 (hidden_size,)
h0: 初始隐藏状态 (batch, hidden_size),默认全零
返回:
all_h: 所有时间步的隐藏状态 (batch, seq_len, hidden_size)
"""
batch_size, seq_len, input_size = x.shape
hidden_size = W_hh.shape[0]
if h0 is None:
h = torch.zeros(batch_size, hidden_size)
else:
h = h0
all_h = []
# TODO: 对每个时间步循环
# 1. x_t = x[:, t, :] 取出第 t 步的输入
# 2. h = tanh(x_t @ W_ih.T + h @ W_hh.T + b)
# 3. 将 h 存入 all_h
# ===== 你的代码在这里 =====
pass
# ==========================
return torch.stack(all_h, dim=1) if all_h else torch.zeros(batch_size, seq_len, hidden_size)
# 测试 RNN 前向传播
batch, seq_len, input_size, hidden_size = 2, 5, 4, 3
x_test = torch.randn(batch, seq_len, input_size)
W_ih_test = torch.randn(hidden_size, input_size)
W_hh_test = torch.randn(hidden_size, hidden_size)
b_test = torch.randn(hidden_size)
try:
result = rnn_forward(x_test, W_ih_test, W_hh_test, b_test)
print(f"[练习1] RNN 前向传播输出形状: {result.shape} (期望: ({batch}, {seq_len}, {hidden_size}))")
except Exception as e:
print(f"[练习1] 未完成实现: {e}")
# ============================================================
# 练习 2:实现 LSTM 遗忘门的计算
# ============================================================
def lstm_forget_gate(
h_prev: torch.Tensor, # (batch, hidden_size) — 上一时刻隐藏状态
x_t: torch.Tensor, # (batch, input_size) — 当前输入
W_f: torch.Tensor, # (hidden_size, hidden_size + input_size) — 遗忘门权重
b_f: torch.Tensor, # (hidden_size,) — 遗忘门偏置
) -> torch.Tensor:
"""
TODO: 实现 LSTM 遗忘门
f_t = σ(W_f · [h_{t-1}, x_t] + b_f)
其中 σ 是 sigmoid 函数,[h, x] 表示在 dim=1 上拼接
参数:
h_prev: 上一时刻隐藏状态 (batch, hidden_size)
x_t: 当前输入 (batch, input_size)
W_f: 遗忘门权重 (hidden_size, hidden_size + input_size)
b_f: 遗忘门偏置 (hidden_size,)
返回:
f_t: 遗忘门输出,范围 [0, 1] (batch, hidden_size)
"""
# TODO: 实现
# 1. 在 dim=1 上拼接 h_prev 和 x_t
# 2. 计算 W_f @ combined.T → 需要转置,或使用 nn.functional.linear
# 3. 加上 b_f 并通过 sigmoid
# ===== 你的代码在这里 =====
return torch.tensor([])
# ==========================
# 测试遗忘门
batch_test, hidden_test, input_test = 4, 8, 6
h_test = torch.randn(batch_test, hidden_test)
x_test = torch.randn(batch_test, input_test)
W_f_test = torch.randn(hidden_test, hidden_test + input_test)
b_f_test = torch.randn(hidden_test)
try:
f_t = lstm_forget_gate(h_test, x_test, W_f_test, b_f_test)
is_valid = (f_t.min() >= 0 and f_t.max() <= 1)
print(f"练习2] 遗忘门输出形状: {f_t.shape}, 范围在[0,1]: {is_valid} (期望: True)")
except Exception as e:
print(f"练习2] 未完成实现: {e}")
# ============================================================
# 练习 3:实现 GRU 更新门
# ============================================================
def gru_update_gate(
h_prev: torch.Tensor, # (batch, hidden_size)
x_t: torch.Tensor, # (batch, input_size)
W_z: torch.Tensor, # (hidden_size, hidden_size + input_size)
b_z: torch.Tensor, # (hidden_size,)
) -> torch.Tensor:
"""
TODO: 实现 GRU 更新门
z_t = σ(W_z · [h_{t-1}, x_t] + b_z)
更新门控制保留多少旧状态和写入多少新状态:
h_t = (1 - z_t) ⊙ h_{t-1} + z_t ⊙ h̃_t
参数:
h_prev: 上一时刻隐藏状态
x_t: 当前输入
W_z: 更新门权重 (hidden_size, hidden_size + input_size)
b_z: 更新门偏置 (hidden_size,)
返回:
z_t: 更新门输出,范围 [0, 1] (batch, hidden_size)
"""
# TODO: 实现(与遗忘门类似)
# ===== 你的代码在这里 =====
return torch.tensor([])
# ==========================
# 测试更新门
z_t_test = gru_update_gate(h_test, x_test, W_f_test, b_f_test)
try:
is_valid = (z_t_test.min() >= 0 and z_t_test.max() <= 1)
print(f"练习3] 更新门输出形状: {z_t_test.shape}, 范围在[0,1]: {is_valid} (期望: True)")
except Exception as e:
print(f"练习3] 未完成实现: {e}")
print("\n所有练习测试完成!请对比 demo.py 查看参考实现。")
print("""
提示:
- RNN 前向传播的核心是「循环时间步 + tanh」
- LSTM 遗忘门用 sigmoid 输出 [0,1] 决定遗忘比例
- GRU 更新门用 sigmoid 做历史与新信息的线性插值
""")