Skip to content

s07 多层网络的矩阵反向传播 — exercise.py 练习指南

Download exercise.py

练习目标

通过亲手实现单隐藏层的反向传播(δ 递推公式)、梯度裁剪和数值梯度检查,将矩阵形式的反向传播从"看懂公式"提升到"能手写实现"的水平。

预备知识

建议先阅读 index.md 并运行 demo.py,确保理解以下核心公式:

公式含义
δ[L]=ALϕ(Z[L])输出层误差信号
δ[l]=(W[l+1])Tδ[l+1]ϕ(Z[l])隐藏层误差递推
LW[l]=δ[l](A[l1])T权重梯度(外积)
Lb[l]=iδi[l]偏置梯度(求和)

任务清单

任务1:实现单隐藏层的反向传播(δ 递推计算)

描述:补全 single_hidden_backward() 函数。网络结构为 输入 → 隐藏层(ReLU) → 输出层(Sigmoid),损失函数使用 MSE。

代码骨架

python
def single_hidden_backward(W1, b1, W2, b2, X, Y):
    m = X.shape[1]

    # ---- 前向传播 ----
    Z1 = W1 @ X + b1                    # 隐藏层线性变换
    A1 = relu(Z1)                        # 隐藏层 ReLU 激活
    Z2 = W2 @ A1 + b2                   # 输出层线性变换
    A2 = sigmoid(Z2)                     # 输出层 Sigmoid 激活 → 预测值

    # ---- 反向传播 ----
    dA2 = (1.0 / m) * (A2 - Y)          # ∂L/∂A2 — MSE 损失的梯度
    dZ2 = dA2 * sigmoid_derivative(Z2)  # δ[2] = ∇_A L ⊙ φ'(Z2)

    dW2 = dZ2 @ A1.T                    # ∂L/∂W2 = δ[2] @ (A1)^T
    db2 = np.sum(dZ2, axis=1, keepdims=True)  # ∂L/∂b2

    dZ1 = (W2.T @ dZ2) * relu_derivative(Z1)  # δ[1] = W2^T @ δ[2] ⊙ ReLU'(Z1)
    dW1 = dZ1 @ X.T                     # ∂L/∂W1 = δ[1] @ X^T
    db1 = np.sum(dZ1, axis=1, keepdims=True)  # ∂L/∂b1

关键提示

  1. 前向顺序:先 Z1=W1X+b1,再 A1=ReLU(Z1),然后 Z2=W2A1+b2,最后 A2=σ(Z2)。不要搞反!
  2. 输出层 δ:MSE 损失对 σ 输出的梯度是 1m(A2Y),再乘以 σ(Z2)
  3. δ₁ 递推:这是整个练习的核心——把输出层的误差通过 W2T 传回隐藏层,再经过 ReLU 导数门控。注意:ReLU(Z)=1[Z>0]
  4. 权重梯度dW2=δ2A1TdW1=δ1XT。这是误差信号与输入的外积(矩阵乘法)。
  5. 偏置梯度:对 δ 按 axis=1 求和,并用 keepdims=True 保持形状。

维度检查表(以 2 输入 → 3 隐藏 → 1 输出,2 个样本为例):

变量形状说明
X(2, 2)2特征 × 2样本
W1(3, 2)3神经元 × 2输入
b1(3, 1)广播到 (3, 2)
Z1, A1(3, 2)3神经元 × 2样本
W2(1, 3)1神经元 × 3输入
dZ2(1, 2)输出层 δ
dZ1(3, 2)隐藏层 δ(W2^T @ dZ2)
dW1(3, 2)必须与 W1 shape 一致

期望输出:所有梯度的形状与对应参数完全一致。


任务2:实现梯度裁剪

描述:补全 clip_gradients() 函数。当梯度的全局 L2 范数超过 max_norm 时,按比例缩小所有梯度。

数学公式

全局 L2 范数:

g2=igi22

缩放因子:

scale=min(1,max_normg2)

裁剪:

g~i=scalegi

提示

  • 计算 total_norm_sq:遍历所有梯度矩阵,累加 np.sum(grad ** 2)
  • 总范数:total_norm = np.sqrt(total_norm_sq)
  • 如果 total_norm > max_norm,缩放因子 scale = max_norm / total_norm;否则 scale = 1.0
  • 每个梯度 grad * scale

核心思想:梯度裁剪不改变梯度的方向,只限制长度——防止某一步的梯度过大导致参数跳到不稳定的区域。这在 RNN 和 Transformer 训练中是标配手段。


任务3:实现数值梯度检查

描述:补全 numerical_gradient_check() 函数。对每个参数的前 N 个元素(如 10 个),用双边有限差分验证解析梯度的正确性。

数学公式

LθL(θ+ϵ)L(θϵ)2ϵ

算法步骤

  1. params 中的每个参数:
    • 将其展平(flatten()
    • 取前 n_check 个元素检查
  2. 对每个元素:
    • 保存原始值
    • 构造 θ + ε:修改该元素 → 调用 forward_fn 计算 loss_plus
    • 构造 θ - ε:修改该元素 → 计算 loss_minus
    • 数值梯度 =(loss_plusloss_minus)/(2ϵ)
    • 恢复原始值
    • 计算相对误差:abs(grad_analytic - grad_numeric) / max(abs(grad_analytic) + abs(grad_numeric), 1e-10)
    • 如果相对误差 > 105,标记失败

为什么只检查前 10 个元素? 梯度检查极其缓慢——每个参数元素需要 2 次额外前向传播。只随机抽查 10 个元素足以发现 bug,又能保证检查在合理时间内完成。

参考判断标准

  • 相对误差 <107:实现大概率正确
  • 相对误差 105:可能有小错误
  • 相对误差 >103:几乎肯定有 bug

关键概念速查

任务核心公式最容易错的地方
TODO 1: δ 递推δ[1]=(W[2])Tδ[2]ReLU(Z[1])忘记 ReLU 导数 (Z1 > 0)
TODO 1: 权重梯度dW[l]=δ[l](A[l1])T/m忘记除以 m 或转置放错位置
TODO 2: 梯度裁剪g~=gmin(1,max_norm/|g|)忘记 min(1,x)——小梯度不应该被放大
TODO 3: 梯度检查L(θ+ϵ)L(θϵ)2ϵ忘记恢复参数原始值

完整代码

py
# -*- coding: utf-8 -*-
"""
s07 多层网络的矩阵反传 — 练习代码
================================
请完成以下 TODO 任务,加深对矩阵形式反向传播的理解。

每个 TODO 都有详细的中文指示和预期输出描述。
建议先阅读 README.md 并运行 demo.py,再尝试独立补全代码。
"""

import numpy as np
from typing import Dict, List, Tuple


# ============================================================================
# 辅助函数:激活函数(与 demo.py 一致)
# ============================================================================

def relu(Z: np.ndarray) -> np.ndarray:
    return np.maximum(0, Z)


def relu_derivative(Z: np.ndarray) -> np.ndarray:
    return (Z > 0).astype(np.float64)


def sigmoid(Z: np.ndarray) -> np.ndarray:
    Z = np.clip(Z, -500, 500)
    return 1.0 / (1.0 + np.exp(-Z))


def sigmoid_derivative(Z: np.ndarray) -> np.ndarray:
    s = sigmoid(Z)
    return s * (1.0 - s)


# ============================================================================
# TODO 1: 实现单隐藏层的反向传播(δ 递推计算)
# ============================================================================

def single_hidden_backward(
    W1: np.ndarray, b1: np.ndarray,
    W2: np.ndarray, b2: np.ndarray,
    X: np.ndarray, Y: np.ndarray
) -> Dict[str, np.ndarray]:
    """
    实现一个单隐藏层(2层)网络的反向传播。

    网络结构: 输入 → 隐藏层(ReLU) → 输出层(Sigmoid)
    损失函数: MSE

    你要实现的关键步骤:
      1. 前向传播并缓存中间值
      2. 计算输出层误差 δ2
      3. 用递推公式计算隐藏层误差 δ1
      4. 计算 dW1, db1, dW2, db2

    参数:
        W1: 第一层权重 (n_hidden, n_input)
        b1: 第一层偏置 (n_hidden, 1)
        W2: 第二层权重 (n_output, n_hidden)
        b2: 第二层偏置 (n_output, 1)
        X: 输入数据 (n_input, m)
        Y: 标签 (n_output, m)

    返回:
        grads: 包含 dW1, db1, dW2, db2 的字典
    """
    m = X.shape[1]  # 样本数

    # ---- 前向传播 ----
    # TODO: 实现第一层的前向传播
    Z1 = None  # ← TODO: W1 @ X + b1
    A1 = None  # ← TODO: relu(Z1)

    # TODO: 实现第二层的前向传播
    Z2 = None  # ← TODO: W2 @ A1 + b2
    A2 = None  # ← TODO: sigmoid(Z2) —— 这是最终预测值

    # ---- 反向传播 ----
    # TODO: 计算输出层误差 δ2(MSE 损失 + sigmoid 激活)
    # 提示: MSE 损失的梯度 ∂L/∂A2 = (1/m) * (A2 - Y)
    #       δ2 = ∂L/∂A2 ⊙ sigmoid'(Z2)
    dA2 = None  # ← TODO: (1.0 / m) * (A2 - Y)
    dZ2 = None  # ← TODO: dA2 * sigmoid_derivative(Z2)

    # TODO: 计算 dW2 和 db2
    dW2 = None  # ← TODO: dZ2 @ A1.T
    db2 = None  # ← TODO: np.sum(dZ2, axis=1, keepdims=True)

    # TODO: 计算隐藏层误差 δ1(递推公式的核心!)
    # 提示: δ1 = W2^T @ δ2 ⊙ relu'(Z1)
    dZ1 = None  # ← TODO: (W2.T @ dZ2) * relu_derivative(Z1)

    # TODO: 计算 dW1 和 db1
    dW1 = None  # ← TODO: dZ1 @ X.T
    db1 = None  # ← TODO: np.sum(dZ1, axis=1, keepdims=True)

    grads = {"dW1": dW1, "db1": db1, "dW2": dW2, "db2": db2}
    return grads


# ---- 测试 TODO 1 ----
def test_single_hidden_backward():
    """测试单隐藏层的反向传播"""
    print("=" * 60)
    print("TODO 1 测试: 单隐藏层反向传播(δ 递推)")
    print("=" * 60)

    np.random.seed(42)
    # 小网络: 2输入 → 3隐藏(ReLU) → 1输出(Sigmoid)
    W1 = np.random.randn(3, 2) * 0.5  # (3, 2)
    b1 = np.zeros((3, 1))              # (3, 1)
    W2 = np.random.randn(1, 3) * 0.5  # (1, 3)
    b2 = np.zeros((1, 1))              # (1, 1)

    # 构造简单的输入和标签
    X = np.array([[0.5, 1.0], [0.3, 0.8]]).T  # (2, 2) — 2个特征,2个样本
    Y = np.array([[0.0, 1.0]])                  # (1, 2) — 2个标签

    grads = single_hidden_backward(W1, b1, W2, b2, X, Y)

    if grads["dW1"] is None:
        print("  TODO 未完成,请补全 single_hidden_backward 函数")
        return

    print(f"\n  梯度计算结果:")
    print(f"    dW1 shape: {grads['dW1'].shape} (预期: (3, 2))")
    print(f"    db1 shape: {grads['db1'].shape} (预期: (3, 1))")
    print(f"    dW2 shape: {grads['dW2'].shape} (预期: (1, 3))")
    print(f"    db2 shape: {grads['db2'].shape} (预期: (1, 1))")

    # 验证形状
    all_correct = True
    expected_shapes = {"dW1": (3, 2), "db1": (3, 1), "dW2": (1, 3), "db2": (1, 1)}
    for key, expected in expected_shapes.items():
        actual = grads[key].shape
        match = actual == expected
        if not match:
            print(f"    ✗ {key} shape 不匹配: got {actual}, expected {expected}")
            all_correct = False

    if all_correct:
        print(f"\n  ✓ 所有梯度形状正确!")

    print()


# ============================================================================
# TODO 2: 实现梯度裁剪
# ============================================================================

def clip_gradients(grads: Dict[str, np.ndarray], max_norm: float) -> Dict[str, np.ndarray]:
    """
    实现梯度裁剪:当梯度的全局 L2 范数超过阈值时,按比例缩放所有梯度。

    全局 L2 范数:
        total_norm = sqrt( Σ_i ||grad_i||^2 )

    缩放因子:
        scale = min(1.0, max_norm / total_norm)

    裁剪后:
        grad_i_clipped = scale * grad_i

    这个技术在 RNN 和 Transformer 训练中广泛使用,可以有效防止梯度爆炸。

    参数:
        grads: 梯度字典 {参数名: 梯度矩阵}
        max_norm: 梯度范数的最大允许值

    返回:
        grads_clipped: 裁剪后的梯度字典

    参考资料:
        Pascanu et al. (2013): "On the difficulty of training recurrent neural networks"
    """
    # TODO: 计算全局梯度范数
    # 提示: 遍历 grads 中的所有梯度,计算每个梯度的 L2 范数的平方和,然后开根号
    total_norm_sq = 0.0
    for grad in grads.values():
        pass  # ← TODO: total_norm_sq += np.sum(grad ** 2)

    # TODO: 计算总范数
    total_norm = None  # ← TODO: np.sqrt(total_norm_sq)

    # TODO: 计算缩放因子
    # 如果 total_norm > max_norm,则缩放;否则不变(scale=1.0)
    scale = None  # ← TODO: min(1.0, max_norm / total_norm) if total_norm > 0 else 1.0

    # TODO: 按比例缩放所有梯度
    grads_clipped = {}
    for key, grad in grads.items():
        pass  # ← TODO: grads_clipped[key] = scale * grad

    return grads_clipped


# ---- 测试 TODO 2 ----
def test_gradient_clipping():
    """测试梯度裁剪"""
    print("=" * 60)
    print("TODO 2 测试: 梯度裁剪")
    print("=" * 60)

    # 构造一些"爆炸"的梯度
    grads = {
        "dW1": np.ones((3, 2)) * 10.0,   # 每一层的梯度都很大
        "db1": np.ones((3, 1)) * 5.0,
        "dW2": np.ones((1, 3)) * 8.0,
        "db2": np.ones((1, 1)) * 3.0,
    }

    # 计算原始总范数
    total_norm_sq = sum(np.sum(g ** 2) for g in grads.values())
    total_norm = np.sqrt(total_norm_sq)
    print(f"\n  原始梯度总范数: {total_norm:.2f}")

    # 应用梯度裁剪,设 max_norm = 5.0
    max_norm = 5.0
    clipped = clip_gradients(grads, max_norm)

    if not clipped:
        print("  TODO 未完成,请补全 clip_gradients 函数")
        return

    # 计算裁剪后的总范数
    clipped_norm_sq = sum(np.sum(g ** 2) for g in clipped.values())
    clipped_norm = np.sqrt(clipped_norm_sq)
    print(f"  裁剪后梯度总范数: {clipped_norm:.2f}")
    print(f"  裁剪倍数: {total_norm / clipped_norm:.2f}x")
    print(f"  裁剪后范数 ≤ max_norm: {clipped_norm <= max_norm + 1e-6}")

    # 测试小梯度的情况(不应该被裁剪)
    small_grads = {
        "dW1": np.ones((3, 2)) * 0.1,
        "db1": np.ones((3, 1)) * 0.1,
    }
    small_clipped = clip_gradients(small_grads, max_norm=5.0)
    small_norm = np.sqrt(sum(np.sum(g ** 2) for g in small_clipped.values()))
    print(f"\n  小梯度测试 (原范数 < max_norm):")
    print(f"    原始相同: {np.allclose(list(small_grads.values())[0], list(small_clipped.values())[0])}")

    print()


# ============================================================================
# TODO 3: 实现数值梯度检查
# ============================================================================

def numerical_gradient_check(
    forward_fn,
    params: Dict[str, np.ndarray],
    grads: Dict[str, np.ndarray],
    X: np.ndarray, Y: np.ndarray,
    epsilon: float = 1e-7
) -> bool:
    """
    用双边有限差分验证解析梯度。

    对每个参数 θ,数值梯度:
        ∂L/∂θ ≈ (L(θ+ε) - L(θ-ε)) / (2ε)

    实现步骤:
      1. 对 params 中的每个参数,遍历它的每个元素
      2. 计算 L(θ+ε) 和 L(θ-ε)(需要调用 forward_fn 做前向传播)
      3. 用双边差分公式估计梯度
      4. 比较解析梯度和数值梯度的相对误差
      5. 如果任何参数的相对误差 > 1e-5,返回 False

    参数:
        forward_fn: 前向传播函数,签名为 forward_fn(params, X, Y) -> loss
        params: 参数字典
        grads: 解析梯度字典(与 params 结构一致)
        X: 输入数据
        Y: 标签
        epsilon: 微小扰动值

    返回:
        passed: 是否通过梯度检查(所有参数相对误差 < 1e-5)
    """
    # TODO: 对参数中的每一个元素逐一检查
    # 提示:
    #   for param_name in params:
    #       遍历 params[param_name] 的每个元素:
    #           保存原始值
    #           θ + ε → 前向 → loss_plus
    #           θ - ε → 前向 → loss_minus
    #           grad_numeric = (loss_plus - loss_minus) / (2ε)
    #           恢复原始值
    #           计算相对误差
    #           如果误差过大,打印警告

    passed = True  # 假设通过,如果发现错误则设为 False

    # TODO: 实现数值梯度检查
    for param_name in params:
        param = params[param_name]  # 参数矩阵
        grad_analytic = grads[param_name]  # 对应的解析梯度

        # 暂时只检查少量元素(速度优化)
        flat_param = param.flatten()
        flat_grad = grad_analytic.flatten()
        n_check = min(10, len(flat_param))  # 最多只检查 10 个元素

        for i in range(n_check):
            # TODO: 对每个检查元素计算数值梯度
            # 1. 保存原始值
            # 2. 计算 loss(theta + epsilon)
            # 3. 计算 loss(theta - epsilon)
            # 4. 计算数值梯度
            # 5. 比较相对误差
            pass  # ← TODO: 实现

    return passed


# ---- 测试 TODO 3 ----
def test_numerical_gradient_check():
    """测试数值梯度检查函数"""
    print("=" * 60)
    print("TODO 3 测试: 数值梯度检查")
    print("=" * 60)

    # 构造一个简单的测试:f(W, b) = mean((Wx + b - y)^2)
    def simple_forward(params, X, Y):
        W, b = params["W"], params["b"]
        pred = W @ X + b
        return np.mean((pred - Y) ** 2) / 2.0

    # 参数和梯度
    params = {
        "W": np.array([[0.5, -0.3]]),  # (1, 2)
        "b": np.array([[0.1]]),         # (1, 1)
    }
    X = np.array([[1.0], [2.0]])        # (2, 1)
    Y = np.array([[3.0]])               # (1, 1)

    # 解析梯度: dL/dW = (Wx+b-y) · x^T, dL/db = (Wx+b-y)
    W, b = params["W"], params["b"]
    pred = W @ X + b                     # 预测值
    error = pred - Y                     # 误差
    grads = {
        "W": error @ X.T,                # (1, 2)
        "b": error,                      # (1, 1)
    }

    passed = numerical_gradient_check(simple_forward, params, grads, X, Y)

    if passed is None:
        print("  TODO 未完成,请补全 numerical_gradient_check 函数")
    else:
        print(f"  梯度检查结果: {'✓ 通过' if passed else '✗ 失败'}")

    print()


# ============================================================================
# 主程序
# ============================================================================
if __name__ == "__main__":
    print("\n╔══════════════════════════════════════════════════════════════╗")
    print("║   s07 多层网络的矩阵反传 — 动手练习                        ║")
    print("║   请依次完成 TODO 1, 2, 3                                   ║")
    print("╚══════════════════════════════════════════════════════════════╝\n")

    test_single_hidden_backward()
    test_gradient_clipping()
    test_numerical_gradient_check()

    print("=" * 60)
    print("所有测试完成!请检查输出结果。")
    print("如有未通过的测试,请回到对应的 TODO 部分补全代码。")
    print()
    print("核心公式速查:")
    print("  δ[L] = ∇_A L ⊙ φ'(Z[L])")
    print("  δ[l] = (W[l+1])^T @ δ[l+1] ⊙ φ'(Z[l])")
    print("  dW[l] = (1/m) · δ[l] @ (A[l-1])^T")
    print("  db[l] = (1/m) · Σ δ[l]")
    print("=" * 60)