Skip to content

s10 CNN核心原理 — exercise.py 练习指南

Download exercise.py

练习目标

通过亲手实现 Im2Col 转换、最大池化前向传播、感受野递推计算和手动单通道卷积,深度理解卷积神经网络的核心底层机制——从"滑动窗口"到"矩阵乘法",从"逐元素运算"到"感受野增长"。

预备知识

建议先阅读 index.md(熟悉卷积、池化、感受野的定义)并运行 demo.py(看完整效果),确保理解:

概念核心思想
卷积操作卷积核在输入上滑动,每次逐元素乘加
Im2Col将卷积窗口展开为矩阵列,转化为矩阵乘法
最大池化k×k 窗口内取最大值 + 记录位置
感受野深层神经元在原图上对应的区域大小
输出尺寸公式Hout=H+2PkS+1

任务清单

任务1:实现 Im2Col 转换(显式循环版)

描述:补全 im2col() 函数——用显式双重循环实现图像到列的转换。输入形状 (N,C,H,W),输出形状 (N,Ckhkw,HoutWout)

为什么要用显式循环? demo.py 使用了 as_strided(内存视图技巧)实现高效 Im2Col,但对于初学者来说不够直观。显式循环版本让你看到 Im2Col 的真正含义:在输入的每个位置上提取一个 patch(小块),将其展平为一列。

算法步骤

  1. 计算输出尺寸:

    Hout=H+2PkhS+1,Wout=W+2PkwS+1
  2. 零填充:x_padded = np.pad(x, ((0,0), (0,0), (pad,pad), (pad,pad)))

  3. 初始化 cols 数组,形状 (N,Ckhkw,HoutWout)

  4. 双重循环填充:对于每个输出位置 (h,w)

    • 提取 x_padded[n, :, h*s:h*s+k_h, w*s:w*s+k_w] —— 一个形状为 (C,kh,kw) 的 patch
    • 将该 patch 展平(reshape(-1)),填入 cols 的对应列

提示

  • cols 的列索引可以用 col_idx = h * W_out + w
  • 提取的 patch 展平后长度是 C * k_h * k_w

任务2:实现最大池化的前向传播

描述:补全 max_pool2d_forward() 函数——用显式四重循环实现最大池化。输入形状 (N,C,H,W),输出形状 (N,C,Hout,Wout)

算法步骤

  1. 计算输出尺寸:Hout=(Hk)//s+1

  2. 初始化 out(全零)和 argmax(全零,dtype=int),形状均为 (N,C,Hout,Wout)

  3. 四重循环:对每个 (n,c,h,w)

    • 提取窗口 window = x[n, c, h*s:h*s+k, w*s:w*s+k]——形状 (k,k)
    • 最大值 out[n, c, h, w] = window.max()
    • 最大值在展平窗口内的位置 argmax[n, c, h, w] = window.flatten().argmax()(值为 0k21

argmax 为什么重要?

在反向传播时,池化层需要将上游梯度传回下游。由于池化是"取最大值"操作——只有最大值位置的局部梯度为 1,其他位置为 0。因此,argmax 记录了"梯度应该往哪个位置传":

outx[n,c,i,j]={1if (i,j) 是窗口内的最大值位置0otherwise

提示

  • window.max() 取最大值,.flatten().argmax() 取最大值在展开后一维数组中的索引
  • 索引值在 0 到 k21 之间(如 k=2 时,[0,0] 索引为 0,[0,1] 为 1,[1,0] 为 2,[1,1] 为 3)

任务3:计算 CNN 架构的感受野

描述:补全 compute_receptive_field() 函数。给定一组层配置(每层一个 (kernel_size, stride) 元组),计算每层的感受野和最终感受野。

感受野递推公式

RFl=RFl1+(kl1)×j=1l1sj

等价于代码中的:

rf = rf + (k - 1) * cum_stride
cum_stride *= s

提示

  • 初始感受野 RF0=1(输入层每个像素"看到"自己)
  • 初始累积步长 cum_stride = 1
  • 对每个 (k, s):先更新 rf,再更新 cum_stride(顺序不能反!)
  • 记录每层后的 rfhistory 列表中

验证用例

  • [(3,1), (3,1)](两个 3×3 卷积):感受野 =5(因为 1+(31)×1+(31)×1=5
  • [(3,1)] \times 5(五个 3×3 卷积):感受野 =11(线性增长)
  • [(7,1), (2,2), (3,1)]:感受野 =7+1+2×2=12(池化加速感受野扩张)

为什么小卷积核堆叠优于大卷积核? 两个 3×3 卷积(参数量 2×9=18)的感受野 = 5×5,等价于一个 5×5 卷积(参数量 25)。但两个 3×3 卷积中间夹了一个 ReLU,比单个 5×5 卷积有更强的非线性表达能力。这就是 VGG 的设计哲学——"小而深"比"大而浅"更好。


任务4:手动实现单通道 2D 卷积

描述:补全 conv2d_single() 函数——最简单的卷积实现:输入和核都是 2D 矩阵,用双重循环完成卷积。这是理解卷积操作的最佳起点。

算法步骤

  1. 计算输出尺寸:Hout=(H+2Pk)/S+1

  2. 零填充输入:input_padded = np.pad(input_2d, pad, mode='constant')

  3. 初始化 output 为全零矩阵,形状 (Hout,Wout)

  4. 双重循环:对每个 (i,j) 输出位置:

    • 提取 input_padded 中的 k×k 区域:patch = input_padded[i*S : i*S+k, j*S : j*S+k]
    • 逐元素乘加:output[i, j] = np.sum(patch * kernel_2d)

测试用例(垂直边缘检测):

输入 5×5         核 3×3 (Sobel 垂直)       输出 3×3
[1 2 3 0 1]     [-1  0  1]                 [6  0 -3]
[4 5 6 1 2]     [-1  0  1]                 [9  0 -3]
[7 8 9 2 3]     [-1  0  1]                 [6 -3 -6]
[0 1 2 3 4]
[1 2 3 4 5]

垂直边缘检测核的特点是:左列全 -1,右列全 +1,中间列全 0。这意味着该核对"左暗右亮"的垂直边缘响应最强(左列乘 -1 抵消暗区,右列乘 +1 增强亮区),对均匀区域响应为 0。


关键概念速查

任务核心操作最容易错的地方
TODO 1: Im2Col提取每个 patch → 展平 → 放入列忘记 padding;索引越界
TODO 2: MaxPool取窗口最大值 + 记录索引argmax 是展平索引(0~k21),不是二维坐标
TODO 3: 感受野rf += (k-1) * cum_stride先更新 rf 再更新 cum_stride
TODO 4: 手动卷积提取 patch,与核逐元素乘加padding 后的索引计算

完整代码

py
# -*- coding: utf-8 -*-
"""
s10 CNN 核心原理 练习
=====================
完成以下 TODO 练习来加深对卷积操作的理解。
"""

import numpy as np
from typing import Tuple

# ============================================================
# 练习 1:实现 Im2Col 转换
# ============================================================

def im2col(x: np.ndarray, kernel_h: int, kernel_w: int,
           stride: int = 1, pad: int = 0) -> np.ndarray:
    """
    TODO: 实现 im2col 转换(不使用 as_strided,用显式循环)

    将输入张量的每个卷积窗口展开为列向量。

    输入: x 形状 (N, C, H, W)
    输出: cols 形状 (N, C*kernel_h*kernel_w, H_out*W_out)

    提示:
    1. 先计算输出尺寸 H_out = (H + 2*pad - k_h) // stride + 1
    2. 对输入做零填充(np.pad)
    3. 对于每个 batch、每个输出位置 (h_out, w_out):
       - 提取大小为 (C, k_h, k_w) 的 patch
       - 将其展平放入 cols 的对应位置
    """
    N, C, H, W = x.shape

    # TODO: 计算输出尺寸
    H_out = None  # 替换为正确的公式
    W_out = None  # 替换为正确的公式

    # TODO: 对 x 做零填充(使用 np.pad,在 H 和 W 维度前后各 pad 个 0)
    x_padded = x  # 替换为 np.pad(...)

    # TODO: 初始化 cols 数组,形状 (N, C * kernel_h * kernel_w, H_out * W_out)
    cols = None

    # TODO: 双重循环填充 cols
    # for h in range(H_out):
    #     for w in range(W_out):
    #         提取 patch 并填充到 cols 的对应列

    return cols


# ============================================================
# 练习 2:实现最大池化的前向传播
# ============================================================

def max_pool2d_forward(x: np.ndarray, kernel_size: int = 2,
                       stride: int = 2) -> Tuple[np.ndarray, np.ndarray]:
    """
    TODO: 实现最大池化前向传播(用显式循环,不用 as_strided)

    参数:
        x: 输入特征图,形状 (N, C, H, W)
        kernel_size: 池化窗口大小
        stride: 步长

    返回:
        out: 池化后的特征图,形状 (N, C, H_out, W_out)
        argmax: 每个窗口中最大值的位置索引,形状 (N, C, H_out, W_out)
                索引是 0 ~ kernel_size*kernel_size-1 的整数

    提示:
    1. 对每个 batch、每个通道、每个输出位置:
       - 提取 kernel_size × kernel_size 的窗口
       - 找到最大值和它在窗口中的位置
    2. argmax 用于反向传播时将梯度传回最大值位置
    """
    N, C, H, W = x.shape
    k, s = kernel_size, stride

    # TODO: 计算输出尺寸
    H_out = None
    W_out = None

    # TODO: 初始化输出和 argmax
    out = None   # 形状 (N, C, H_out, W_out)
    argmax = None  # 形状 (N, C, H_out, W_out), dtype=int

    # TODO: 四重循环实现池化
    # for n in range(N):
    #     for c in range(C):
    #         for h in range(H_out):
    #             for w in range(W_out):
    #                 提取窗口 x[n, c, h*s:h*s+k, w*s:w*s+k]
    #                 取最大值和位置
    #                 写入 out 和 argmax

    return out, argmax


# ============================================================
# 练习 3:计算 CNN 架构的感受野
# ============================================================

def compute_receptive_field(layer_configs: list) -> Tuple[int, list]:
    """
    TODO: 计算给定 CNN 架构的逐层感受野

    感受野递推公式:
        RF_l = RF_{l-1} + (k_l - 1) * (s_1 * s_2 * ... * s_{l-1})
    即: RF_l = RF_{l-1} + (k_l - 1) * cum_stride

    参数:
        layer_configs: 列表,每个元素是 (kernel_size, stride) 的元组
                       例如 [(3,1), (3,1), (2,2)] 表示 Conv3→Conv3→Pool2

    返回:
        final_rf: 最终层在原图上的感受野(整数)
        history: 每层后的感受野列表 [RF_1, RF_2, ..., RF_L]

    示例:
        >>> rf, hist = compute_receptive_field([(3,1), (3,1)])
        >>> rf, hist
        (5, [3, 5])  # 两个 3×3 conv → 感受野 5×5
    """
    rf = 1                # 初始感受野(输入层每个像素"看到"自己)
    cum_stride = 1        # 累积步长:前面所有层步长的乘积
    history = []

    # TODO: 遍历 layer_configs,按递推公式计算
    # for k, s in layer_configs:
    #     rf = rf + (k - 1) * cum_stride
    #     cum_stride *= s
    #     history.append(rf)

    return rf, history


# ============================================================
# 练习 4:手动计算卷积输出(不使用任何库)
# ============================================================

def conv2d_single(input_2d: np.ndarray, kernel_2d: np.ndarray,
                  stride: int = 1, pad: int = 0) -> np.ndarray:
    """
    TODO: 实现单通道 2D 卷积(手动双重循环,输入和核都是 2D 矩阵)

    参数:
        input_2d: 输入 2D 矩阵,形状 (H, W)
        kernel_2d: 卷积核 2D 矩阵,形状 (k, k)
        stride: 步长
        pad: 零填充大小

    返回:
        output: 输出 2D 矩阵,形状 (H_out, W_out)

    这是最直观的卷积实现方式——显式双重循环,帮助理解卷积的"滑动窗口"本质。
    """
    H, W = input_2d.shape
    k = kernel_2d.shape[0]

    # TODO: 计算输出尺寸
    H_out = None
    W_out = None

    # TODO: 零填充(使用 np.pad,只需填充 input_2d)
    input_padded = None

    # TODO: 初始化输出
    output = None  # 形状 (H_out, W_out)

    # TODO: 双重循环完成卷积
    # 对于每个输出位置 (i, j),提取 input_padded 中的对应区域,
    # 与 kernel 做逐元素乘法再求和,填入 output[i, j]

    return output


# ============================================================
# 测试代码
# ============================================================

if __name__ == "__main__":
    print("=" * 50)
    print("s10 CNN 核心原理 — 练习测试")
    print("=" * 50)

    # ---- 测试练习 3:感受野计算 ----
    print("\n[练习 3] 感受野计算测试:")

    # 测试架构 1: Conv3→Conv3→Conv3 (全 3×3 卷积)
    rf1, hist1 = compute_receptive_field([(3, 1), (3, 1), (3, 1)])
    expected_rf1 = 7
    print(f"  Conv3→Conv3→Conv3: RF = {rf1} (期望 {expected_rf1})")

    # 测试架构 2: Conv7→Pool2→Conv3 (经典架构的一小段)
    rf2, hist2 = compute_receptive_field([(7, 1), (2, 2), (3, 1)])
    expected_rf2 = 7 + (2-1)*1 + (3-1)*2
    print(f"  Conv7→Pool2→Conv3: RF = {rf2} (期望 {expected_rf2})")
    print(f"  历史: {hist2}")

    # 测试架构 3: 5 个 Conv3
    rf3, hist3 = compute_receptive_field([(3, 1)] * 5)
    expected_rf3 = 11
    print(f"  5×Conv3: RF = {rf3} (期望 {expected_rf3})")

    # ---- 测试练习 4:手动卷积 ----
    print("\n[练习 4] 手动卷积测试:")

    # 创建一个简单的测试用例
    test_input = np.array([
        [1, 2, 3, 0, 1],
        [4, 5, 6, 1, 2],
        [7, 8, 9, 2, 3],
        [0, 1, 2, 3, 4],
        [1, 2, 3, 4, 5]
    ], dtype=np.float32)

    test_kernel = np.array([
        [-1, 0, 1],
        [-1, 0, 1],
        [-1, 0, 1]
    ], dtype=np.float32)  # 垂直边缘检测器

    # 期望输出(手动计算)
    expected_output = np.array([
        [6,  0, -3],
        [9,  0, -3],
        [6, -3, -6]
    ], dtype=np.float32)

    output = conv2d_single(test_input, test_kernel, stride=1, pad=0)
    if output is not None:
        print(f"  输入 {test_input.shape},核 {test_kernel.shape} → 输出 {output.shape}")
        print(f"  输出:\n{output}")
        # 检查是否与期望值一致
        if np.allclose(output, expected_output):
            print("  ✓ 结果正确!")
        else:
            print(f"  期望输出:\n{expected_output}")
            print("  ✗ 结果与期望不符,请检查实现")

    print("\n" + "=" * 50)
    print("完成所有练习后,运行 demo.py 查看完整演示。")
    print("=" * 50)