Skip to content

案例拆解:一次卷积的逐像素计算 ​

理论篇说「卷积只看 9 个输入」,但 9 个输入怎么变成一个数、9 个位置怎么拼成一张图、3 个通道怎么叠成 2 张输出,只有手算一遍才清楚。

这一篇不做抽象推导,只把一组固定的小数字从头算到尾:5×5 输入、3×3 核、3 个通道、2 个核、padding、stride、dilation、池化。每个结果都能用 assets/case_walkthrough.py 复现。

![这一篇只算一次卷积](/ai-four-kings/第 4 章:CNN 原理/assets/ch4_case_setup.svg)

<!-- 配图:5×5 输入、3×3 核与 3×3 输出并排,箭头标出逐元素乘与求和;底部说明 81 次乘法 -->

输入是:

text
[[0, 1, 5, 1, 0],
 [0, 2, 5, 2, 0],
 [0, 1, 5, 1, 0],
 [0, 3, 5, 3, 0],
 [0, 1, 5, 1, 0]]

核是「左负右正」的垂直边缘检测器:

text
[[−1, 0, 1],
 [−1, 0, 1],
 [−1, 0, 1]]

核在 5×5 上只能滑 3 步、3 步,所以输出是 3×3,一共 9 个位置。一个位置做 9 次乘法,整张输出就是 9×9=81 次乘法。

一、单通道:9 个窗口与 81 次乘法 ​

![9 个窗口,9 个输出](/ai-four-kings/第 4 章:CNN 原理/assets/ch4_case_windows.svg)

<!-- 配图:9 张 5×5 小图,各自高亮一个 3×3 窗口;每个窗口下方给出点积 15、0、−15 -->

9 个窗口按行优先编号,窗口 (i, j) 的左上角在输入的第 i 行、第 j 列:

窗口左上角点积
1(0, 0)15
2(0, 1)0
3(0, 2)−15
4(1, 0)15
5(1, 1)0
6(1, 2)−15
7(2, 0)15
8(2, 1)0
9(2, 2)−15

窗口之间是重叠的。5×5 图上,角点只落在 1 个窗口里,边点落在 2 个,中心点落在全部 9 个。重叠不是浪费:同一个像素被多个输出复用,同一组 9 个权重也被 9 个位置复用。

![9 次点积,按位置填回 3×3](/ai-four-kings/第 4 章:CNN 原理/assets/ch4_case_assembly.svg)

<!-- 配图:9 个点积方块按 3×3 排列,第一个 15 高亮;左侧说明为什么是 9 个位置而不是 25 个 -->

把 9 个点积按位置填回去:

text
[[ 15,   0, −15],
 [ 15,   0, −15],
 [ 15,   0, −15]]

把第一个 15 摊开,就是三行各一次「左负右正」:

text
第 0 行:−1×0 + 0×1 + 1×5 = 5
第 1 行:−1×0 + 0×2 + 1×5 = 5
第 2 行:−1×0 + 0×1 + 1×5 = 5
              合计 = 15

窗口右移一格,三行的左边变成 1、右边也变成 1,每行 −1 + 0 + 1 = 0,合计 0。再右移一格,左边是 5、右边是 0,每行 −5 + 0 + 0 = −5,合计 −15。所以第一行输出是 15, 0, −15。

竖直亮条经过这个核,左边响应 15、右边 −15、中间 0。它检测的是「左暗右亮」的方向,而不是「亮条在第几列」。

核心代码只有四层循环:

python
# 第 4 章:CNN 原理/assets/case_walkthrough.py :: conv2d_valid()
def conv2d_valid(x, w, b=0.0):
    """x: C×H×W, w: O×C×KH×KW, b: O;只做 valid、stride 1。"""
    C, H, W = x.shape
    O, _, KH, KW = w.shape
    out = np.zeros((O, H - KH + 1, W - KW + 1))
    for o in range(O):
        for i in range(out.shape[1]):
            for j in range(out.shape[2]):
                patch = x[:, i:i + KH, j:j + KW]
                out[o, i, j] = np.sum(patch * w[o]) + b[o]
    return out

这里的循环顺序是 输出通道 → 输出行 → 输出列,最内层用 np.sum 做 9 个乘积的求和。第 4.3 篇会把它拆成真正的四重循环(再加上 batch 维),并和 F.conv2d 逐元素对齐。

二、多通道与卷积核个数 ​

单通道时核是 3×3;输入有 3 个通道时,核变成 3×3×3——每个通道一片 3×3,各自做点积,再把 3 个结果相加。

![3 个通道、2 个核:输出是 2 张图](/ai-four-kings/第 4 章:CNN 原理/assets/ch4_case_multichannel.svg)

<!-- 配图:三个 3×3 输入通道,两个 3×3×3 核各自产生一个输出值 5 和 4;底部强调核形状 C_out×C_in×K×K -->

输入 3 个通道:

text
通道 1:[[1, 0, 2], [0, 1, 0], [1, 2, 1]]
通道 2:[[0, 1, 1], [2, 0, 1], [0, 1, 0]]
通道 3:[[2, 0, 1], [1, 1, 0], [0, 2, 1]]

2 个核,每个核 3×3×3;输出空间只有 1×1,所以每个核只产生 1 个数。结果是 [5, 4],输出形状 2×1×1。

![输出的 5 和 4 是怎么加出来的](/ai-four-kings/第 4 章:CNN 原理/assets/ch4_case_channel_sum.svg)

<!-- 配图:表格列出两个核在三个通道上的点积、求和、bias 与输出;下方两张卡片给出 5 和 4 的加法过程 -->

逐通道拆开:

输出通道通道 1通道 2通道 3三通道和bias输出
核 1032505
核 231−1314
text
核 1:0 + 3 + 2 + 0 = 5
核 2:3 + 1 + (−1) + 1 = 4

注意 bias 是「每个输出通道一个」,不是每个位置一个。核 1 的 bias=0,核 2 的 bias=1;同一通道的 28×28 个位置都共用这个 bias。

三个通道的分项相加才是输出。核 1 在三个通道上的点积是 0、3、2,只取任何一个通道都得不到 5;核 2 的 3、1、−1 同理。通道求和不是可选项,它是「同一个输出像素看同一位置的三个通道」的定义。

![核的个数决定输出通道数](/ai-four-kings/第 4 章:CNN 原理/assets/ch4_case_kernels.svg)

<!-- 配图:输入 3×3×3 分给 2 个核,每个核产出一张 1×1 特征图;下方参数量 56、乘加量 54、输出形状 2×1×1 -->

参数量与乘加量:

text
参数量 = (C_in × K × K + 1) × C_out
      = (3 × 3 × 3 + 1) × 2
      = 56

乘加量 = C_out × C_in × K × K × H_out × W_out
      = 2 × 3 × 9 × 1 × 1
      = 54

56 是 2 个核,每个核 27 个权重加 1 个 bias;54 是 2 个输出通道、每个 27 次乘加。输出只有 1×1,所以乘加量很小;若输出是 28×28,乘加量会乘 784。

设计取舍:一个核还是多个核 ​

方案收益代价
A:2 个核输出 2 张特征图,能同时检测两种模式;参数量 56乘加量翻倍到 54;输出通道变多,后面的层输入也变厚
B:1 个核参数量 28,乘加 27,最省只有 1 张特征图,一种核只能表达一种局部模式

这一篇选 A,因为「核的个数 = 输出通道数」是 CNN 的基本设计杠杆:通道数越多,能并存的模式越多,但下一层的 C_in 也越大,乘加量按 C_in×C_out 增长。真实网络里 32、64、128 个核就是这么加上去的。

三、padding、stride、dilation ​

padding:把 5×5 留成 5×5 ​

![补 1 圈零,5×5 才能留住 5×5](/ai-four-kings/第 4 章:CNN 原理/assets/ch4_case_padding.svg)

<!-- 配图:5×5 原图、补零后的 7×7(新增零用红框标出)、5×5 输出;底部统计零的占比 -->

3×3 核在 5×5 上做 valid 卷积,输出只有 3×3,每层缩 2。补 1 圈零后变 7×7:

text
(5 + 2×1 − 3) / 1 + 1 = 5

新增的零有 7×7 − 5×5 = 24 个,占 7×7 的 48.98%。28×28 补一圈是 30×30,新增 30×30 − 28×28 = 116 个零,占 12.89%。

补零的代价是边界被稀释:3×3 valid 时,角点只参与 1 个窗口,边点 2 个,内部点 4 个。padding 让输出尺寸不变,但边界像素的权重天然比内部低。

stride:少取位置 ​

![stride=2:4 个窗口,跳过 5 个位置](/ai-four-kings/第 4 章:CNN 原理/assets/ch4_case_stride.svg)

<!-- 配图:5×5 输入上 4 个彩色 3×3 窗口,输出 2×2 为 15、−15、15、−15;右侧注明位置从 9 降到 4 -->

5×5、k=3、s=2:

text
(5 − 3) / 2 + 1 = 2

输出 2×2,只有 4 个位置;stride=1 时有 9 个位置,少了 55.6%。取的是窗口 1、3、7、9:

text
[[15, −15],
 [15, −15]]

每个位置仍然做 9 次乘法。stride 省的是「位置数」,不是「每位置的计算量」。被跳过的行 1、列 1 上的信息,如果正好是目标所在处,就漏了。

dilation:把核摊开 ​

![dilation=2:核变大,点数不变](/ai-four-kings/第 4 章:CNN 原理/assets/ch4_case_dilation.svg)

<!-- 配图:7×7 输入上 9 个采样点(行/列 0、2、4),输出 3×3;注明有效跨度 5×5、空洞 16 个位置 -->

7×7、k=3、d=2:

text
有效核 = D(K−1) + 1 = 2×2 + 1 = 5
输出 = (7 − 5) / 1 + 1 = 3

3×3 核只取 9 个点,但点在行/列 0、2、4 上,跨度 5×5。5×5 的跨度里有 25 个位置,只采 9 个,剩 16 个是空洞。

dilation 把感受野从 3×3 拉到 5×5,参数量仍是 9 个权重。代价是采样不连续,细小的连续纹理可能被跳过;连续堆 dilation=2 会出现「棋盘格」偏差。

四种配置放在一起:

配置输入输出乘加量(单通道单核)
valid,s=15×53×381
same,p=15×55×5225
stride 25×52×236
dilation 27×73×381

same 的输出位置最多,乘加量是 valid 的 2.78 倍;stride 2 最省,但漏位置;dilation 2 用同样的 81 次乘加换到了 5×5 的感受野。

四、池化丢了什么 ​

![池化丢了什么:位置、梯度、分辨率](/ai-four-kings/第 4 章:CNN 原理/assets/ch4_case_pool.svg)

<!-- 配图:4×4 输入切成 4 个 2×2 窗口,右侧 max 输出 [6,4,4,8] 与 avg 输出 [3.75,2.25,1.75,5.0];底部对比反向梯度 -->

4×4 输入、2×2 max pool:

text
输入:
[[1, 3, 2, 4],
 [5, 6, 1, 2],
 [0, 2, 8, 3],
 [4, 1, 2, 7]]

max:[[6, 4],
      [4, 8]]

argmax 展平下标:[5, 3, 12, 10]

avg:[[3.75, 2.25],
      [1.75, 5.00]]

池化丢的第一样东西是位置。max 只保留最大值,6 原本在 (1,1),4 在 (0,3),4 在 (3,0),8 在 (2,2);这些坐标只有 return_indices=True 时才留下。

第二样是梯度。反向传播时,max pool 只把梯度回传给每个窗口的 argmax:

  • 4 个窗口各回传 1 个梯度,4/16 = 25% 的输入非零;
  • 4 个梯度里只有 2 个不同的值。

avg pool 则把梯度平均分给 4 个输入,100% 的输入非零。

第三样是分辨率。一次 2×2 池化,特征图边长减半,一个格子对应输入 2 个像素,位置误差最大 1 px;两次池化后一个格子对应 4 个像素,误差最大 3 px。池化越深,定位越粗。

平移呢?实测 2×2 max pool:

平移池化图变化格子占比平均绝对变化与原图平移副本的最大差
1 px15.82%0.18522.8243
2 px19.90%0.35390

平移 2 px 正好是一个池化格子,池化图是原图的精确平移副本,最大差 0;平移 1 px 不是格子整数倍,15.82% 的格子会变。avg pool 在 1 px 时变化 22.96%,比 max 还大。

设计取舍:max pool 还是 avg pool ​

方案收益代价
A:max pool保留最强响应,边缘/纹理更突出;平移 1 px 只变 15.82% 的格子梯度稀疏(25% 非零);丢掉较弱响应与精确位置
B:avg pool梯度 100% 回传,输出平滑,位置信息以均分权重保留强响应被平均稀释;平移 1 px 变化 22.96%,反而更不稳定

这一篇选 A。分类任务更关心「有没有这个特征」,不关心它精确在哪;max 的稀疏梯度在 CNN 里也不是问题,因为每个窗口至少还有一条路径。需要密集梯度或做可视化热图时,才换 avg。

踩坑记录

  • 现象:给 max pool 做反向,16 个输入里只有 4 个拿到梯度(25%),而且 4 个值里只有 2 个不同;同一份代码换 avg pool,16 个全有梯度。
  • 原因:max pool 的前向只选了每个窗口的最大值,反向自然只回传给被选中的位置;没被选中的位置梯度为 0,不是实现错误。
  • 修复:要可视化「哪些输入影响了输出」,用 return_indices=True 保存 argmax 坐标;要每个输入都拿梯度,用 avg pool,或在 max 前加 soften(如 max 的 softmax 近似)。别把 25% 当成 bug。

五、这一篇没解决的问题 ​

到这里,一次卷积的每一笔都算清了:9 个窗口、81 次乘法、3 通道求和、2 个核、补零、跳步、膨胀、池化。但真实网络不是 5×5,而是 28×28 甚至 224×224;一层 32 通道的 3×3 卷积就是 225,792 次乘加(理论篇算过),一个 MNIST 小 CNN 每张图 1,031,744 次。

这些乘加不能靠 Python 的四层循环跑。下一篇要回答的是:怎么把卷积写成可运行的代码,怎么让它和 F.conv2d 的每个元素都对上,以及为什么手写版本比 PyTorch 慢 99 倍。

自检 ​

合上笔记,试着回答:

  • 5×5 输入、3×3 核、stride 1,为什么输出是 3×3 而不是 5×5?一共多少次乘法?
  • 9 个窗口里,角点、边点、中心点分别被几个窗口覆盖?
  • 3 通道、2 个 3×3 核,参数量为什么是 56 而不是 18?输出形状是什么?
  • padding 后 7×7 里有多少个零?占多少?28×28 补一圈呢?
  • stride=2 时 5×5 的 9 个位置为什么只剩 4 个?少了百分之多少?
  • dilation=2 的有效核多大?为什么点数还是 9 个?
  • max pool 的反向为什么只有 25% 输入非零?平移 1 px 和 2 px 的差别在哪?