Skip to content

s15 序列模型 — exercise.py 练习指南

Download exercise.py

练习目标

通过手写 RNN、LSTM、GRU 核心组件的代码,建立对循环神经网络前向传播和门控机制的深刻直觉。完成三个练习后,你将能够:

  1. 独立写出 RNN 的时间步循环前向传播
  2. 理解 LSTM 遗忘门在梯度传播中的作用
  3. 掌握 GRU 更新门的线性插值机制

预备知识

在开始练习前,请确保你已经理解以下概念(详见 index.md 和 demo.py 详解):

  • RNN 公式ht=tanh(Whht1+Wxxt+b)
  • BPTT:梯度沿时间反向传播,连乘导致梯度消失
  • sigmoid 函数σ(x)=11+ex,输出范围 (0,1)
  • LSTM 三个门:遗忘门 ft、输入门 it、输出门 ot
  • GRU 双门:重置门 rt、更新门 zt

任务清单

练习 1:实现 RNN 前向传播(手动循环时间步)

目标:补全 rnn_forward() 函数,实现 RNN 的逐时间步前向传播。

核心公式

ht=tanh(xtWih+ht1Whh+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) — 初始隐藏状态,默认全零

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() 函数,实现遗忘门的计算。

核心公式

ft=σ(Wf[ht1,xt]+bf)

其中 [ht1,xt] 表示在 dim=1(特征维度)上拼接,σ 是 sigmoid 函数。

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 + bias
  • torch.sigmoid() 将任意实数映射到 (0, 1),值越大表示"越不想遗忘"
  • ft0:遗忘该维度的信息;ft1:保留该维度的信息

理解遗忘门的直觉:在读取一句长文本时,读到句号后可能需要"遗忘"前面句子的部分细节;读到新的主语时需要"遗忘"前一个主语的信息。

预期输出

[练习2] 遗忘门输出形状: torch.Size([4, 8]), 范围在[0,1]: True (期望: True)

练习 3:实现 GRU 更新门

目标:补全 gru_update_gate() 函数,实现 GRU 更新门的计算。

核心公式

zt=σ(Wz[ht1,xt]+bz)

更新门的作用是控制 ht 在多大程度上保留旧状态 ht1、在多大程度上采用新候选 h~t

ht=(1zt)ht1+zth~t

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 的更新门 zt 同时做了 LSTM 中遗忘门和输入门的工作
  • zt0htht1(保留历史,不更新)
  • zt1hth~t(用新信息替换)
  • GRU 比 LSTM 少一个门,参数更少,训练更快

预期输出

[练习3] 更新门输出形状: torch.Size([4, 8]), 范围在[0,1]: True (期望: True)

三个模型的参数量对比

完成练习后,可以计算一下三种模型的参数量差异:

模型线性变换数参数量公式门控机制
RNN2dh(dh+dx)无(一个 tanh)
LSTM4(合并为1个大矩阵)4dh(dh+dx)遗忘+输入+输出
GRU2(门合并为1,候选独立)3dh(dh+dx)重置+更新

检查要点

完成所有 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 做历史与新信息的线性插值
""")