s10 CNN核心原理 — exercise.py 练习指南
练习目标
通过亲手实现 Im2Col 转换、最大池化前向传播、感受野递推计算和手动单通道卷积,深度理解卷积神经网络的核心底层机制——从"滑动窗口"到"矩阵乘法",从"逐元素运算"到"感受野增长"。
预备知识
建议先阅读 index.md(熟悉卷积、池化、感受野的定义)并运行 demo.py(看完整效果),确保理解:
| 概念 | 核心思想 |
|---|---|
| 卷积操作 | 卷积核在输入上滑动,每次逐元素乘加 |
| Im2Col | 将卷积窗口展开为矩阵列,转化为矩阵乘法 |
| 最大池化 | 在 |
| 感受野 | 深层神经元在原图上对应的区域大小 |
| 输出尺寸公式 |
任务清单
任务1:实现 Im2Col 转换(显式循环版)
描述:补全 im2col() 函数——用显式双重循环实现图像到列的转换。输入形状
为什么要用显式循环? demo.py 使用了 as_strided(内存视图技巧)实现高效 Im2Col,但对于初学者来说不够直观。显式循环版本让你看到 Im2Col 的真正含义:在输入的每个位置上提取一个 patch(小块),将其展平为一列。
算法步骤:
计算输出尺寸:
零填充:
x_padded = np.pad(x, ((0,0), (0,0), (pad,pad), (pad,pad)))初始化
cols数组,形状双重循环填充:对于每个输出位置
: - 提取
x_padded[n, :, h*s:h*s+k_h, w*s:w*s+k_w]—— 一个形状为的 patch - 将该 patch 展平(
reshape(-1)),填入cols的对应列
- 提取
提示:
cols的列索引可以用col_idx = h * W_out + w- 提取的 patch 展平后长度是
C * k_h * k_w
任务2:实现最大池化的前向传播
描述:补全 max_pool2d_forward() 函数——用显式四重循环实现最大池化。输入形状
算法步骤:
计算输出尺寸:
初始化
out(全零)和argmax(全零,dtype=int),形状均为四重循环:对每个
: - 提取窗口
window = x[n, c, h*s:h*s+k, w*s:w*s+k]——形状 - 最大值
out[n, c, h, w] = window.max() - 最大值在展平窗口内的位置
argmax[n, c, h, w] = window.flatten().argmax()(值为)
- 提取窗口
argmax 为什么重要?
在反向传播时,池化层需要将上游梯度传回下游。由于池化是"取最大值"操作——只有最大值位置的局部梯度为 1,其他位置为 0。因此,argmax 记录了"梯度应该往哪个位置传":
提示:
window.max()取最大值,.flatten().argmax()取最大值在展开后一维数组中的索引- 索引值在 0 到
之间(如 时, [0,0]索引为 0,[0,1]为 1,[1,0]为 2,[1,1]为 3)
任务3:计算 CNN 架构的感受野
描述:补全 compute_receptive_field() 函数。给定一组层配置(每层一个 (kernel_size, stride) 元组),计算每层的感受野和最终感受野。
感受野递推公式:
等价于代码中的:
rf = rf + (k - 1) * cum_stride
cum_stride *= s提示:
- 初始感受野
(输入层每个像素"看到"自己) - 初始累积步长
cum_stride = 1 - 对每个
(k, s):先更新rf,再更新cum_stride(顺序不能反!) - 记录每层后的
rf到history列表中
验证用例:
[(3,1), (3,1)](两个 3×3 卷积):感受野(因为 ) [(3,1)] \times 5(五个 3×3 卷积):感受野(线性增长) [(7,1), (2,2), (3,1)]:感受野(池化加速感受野扩张)
为什么小卷积核堆叠优于大卷积核? 两个
任务4:手动实现单通道 2D 卷积
描述:补全 conv2d_single() 函数——最简单的卷积实现:输入和核都是 2D 矩阵,用双重循环完成卷积。这是理解卷积操作的最佳起点。
算法步骤:
计算输出尺寸:
零填充输入:
input_padded = np.pad(input_2d, pad, mode='constant')初始化
output为全零矩阵,形状双重循环:对每个
输出位置: - 提取
input_padded中的区域: 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~ |
| TODO 3: 感受野 | rf += (k-1) * cum_stride | 先更新 rf 再更新 cum_stride |
| TODO 4: 手动卷积 | 提取 patch,与核逐元素乘加 | padding 后的索引计算 |
完整代码
# -*- 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)