全连接层可以把一张图像展平后分类,但它不直接利用相邻像素的结构。如果希望同一种局部模式在不同位置使用同一套参数,就会得到卷积层的两个核心条件:只看局部窗口,在不同空间位置共享权重。

本文接上反向传播与优化器,从一次可手算的卷积进入共享参数的梯度累积。实验只实现单样本卷积层,不是完整图像分类器,也不把示意核当成训练后一定会出现的特征。

局部连接和共享权重各省了什么

Stanford CS231n 的卷积网络讲义把局部感受野与参数共享分别讨论:局部连接限制一个输出看见的输入范围,共享则让同一输出通道在不同位置使用相同核与偏置。

设输入 3×3、输出 2×2、一个通道,局部窗口为 2×2。

结构 每个输出看什么 四个输出是否共享参数 参数数
全连接 全部 9 个输入 不共享 4×(9+1)=40
局部连接但不共享 各自 4 个输入 不共享 4×(4+1)=20
卷积层 各自 4 个输入 同一套核与偏置 4+1=5

共享减少的是可学习参数,不是取消不同位置的计算。输入越大,同一核会被使用更多次;输出与中间激活仍然可能占用大量内存。

多通道卷积不是每个颜色独立出一个结果

本文采用输入 C_in×H×W、权重 C_out×C_in×K_h×K_w、输出 C_out×H_out×W_out,没有批次维,groups=1、dilation=1。

每个输出通道有一组跨越所有输入通道的核。在一个空间位置,先把不同输入通道的乘积相加,再加一次该输出通道的偏置。RGB 的三个通道通常参与同一个输出特征,不是默认各自产生互不相干的输出。

对输出位置 i,j,输入坐标是 iS+u−P、jS+v−P。越界位置按零处理。尺寸为:

H_out=⌊(H+2P−K_h)/S⌋+1,W_out=⌊(W+2P−K_w)/S⌋+1。

除不尽时舍弃不完整的尾部窗口。现代 PyTorch Conv2d 文档使用向下取整的尺寸公式;数学意义的卷积通常翻转核,而深度学习卷积层通常执行不翻转核的互相关,本文跟随这一约定。

四个窗口的手工演算

输入 X=[[1,2,3],[4,5,6],[7,8,9]],核 K=[[1,0],[0,−1]],偏置 0,步幅 1、填充 0。这个核只是教学设定。

输出位置 局部窗口 计算 结果
0,0 [[1,2],[4,5]] 1−5 −4
0,1 [[2,3],[5,6]] 2−6 −4
1,0 [[4,5],[7,8]] 4−8 −4
1,1 [[5,6],[8,9]] 5−9 −4
同一2×2核在3×3输入上计算四个输出位置,各输出为负4;共享权重的梯度汇总四个窗口贡献
原创合成矩阵示意。核为手工指定,不是训练实测的滤波器。查看原图

共享权重为什么要累加所有位置的梯度

记上游梯度 G[o,i,j]=∂L/∂Y[o,i,j]。同一个权重参与许多输出,所以链式法则必须汇总所有这些路径:

对象 每个合法窗口贡献
核 K[o,c,u,v] 加上 G[o,i,j]×X[c,iS+u−P,jS+v−P]
输入 X[c,h,w] 加上 G[o,i,j]×K[o,c,u,v]
偏置 b[o] 加上 G[o,i,j]

选择 L 为四个输出之和,就有每个 G=1。左上角核权重的梯度是 1+2+4+5=12,而不是只取一个窗口的 1;完整核梯度为 [[12,16],[24,28]],偏置梯度为 4。输入中心同时收到正负贡献,最终梯度为 0;不能用赋值覆盖前一个窗口的贡献。

纯 Python 前向与反向实现

代码支持多个输入、输出通道,以及正整数步幅和非负零填充。grad_out 是调用方已经算好的上游梯度;不传时只做前向,梯度字段返回 None。它不实现批次、分组卷积、膨胀卷积或 GPU 优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import json
import math


def shape(tensor, rank):
if rank == 0:
if type(tensor) not in (int, float) or not math.isfinite(tensor):
raise ValueError("invalid number")
return ()
if not isinstance(tensor, (list, tuple)) or not tensor:
raise ValueError("empty or invalid tensor")
children = [shape(item, rank - 1) for item in tensor]
if any(item != children[0] for item in children):
raise ValueError("ragged tensor")
return (len(tensor),) + children[0]


def conv2d(x, kernel, bias, *, stride=1, padding=0, grad_out=None):
if type(stride) is not int or stride < 1:
raise ValueError("invalid stride")
if type(padding) is not int or padding < 0:
raise ValueError("invalid padding")
ci, h, w = shape(x, 3)
co, kc, kh, kw = shape(kernel, 4)
if kc != ci or shape(bias, 1) != (co,):
raise ValueError("channel mismatch")
if h + 2 * padding < kh or w + 2 * padding < kw:
raise ValueError("kernel does not fit")
oh = (h + 2 * padding - kh) // stride + 1
ow = (w + 2 * padding - kw) // stride + 1
backward = grad_out is not None
if backward and shape(grad_out, 3) != (co, oh, ow):
raise ValueError("gradient shape mismatch")
y = [[[0.0] * ow for _ in range(oh)] for _ in range(co)]
dx = [[[0.0] * w for _ in range(h)] for _ in range(ci)] if backward else None
dk = [[[[0.0] * kw for _ in range(kh)] for _ in range(ci)]
for _ in range(co)] if backward else None
db = [0.0] * co if backward else None
for o in range(co):
for i in range(oh):
for j in range(ow):
value = bias[o]
g = grad_out[o][i][j] if backward else 0.0
if backward:
db[o] += g
for c in range(ci):
for u in range(kh):
for v in range(kw):
row, col = i * stride + u - padding, j * stride + v - padding
if 0 <= row < h and 0 <= col < w:
value += x[c][row][col] * kernel[o][c][u][v]
if backward:
dx[c][row][col] += g * kernel[o][c][u][v]
dk[o][c][u][v] += g * x[c][row][col]
y[o][i][j] = value
return {"y": y, "dx": dx, "dk": dk, "db": db}


if __name__ == "__main__":
x = [[[1, 2, 3], [4, 5, 6], [7, 8, 9]]]
kernel = [[[[1, 0], [0, -1]]]]
print(json.dumps(conv2d(x, kernel, [0], grad_out=[[[1, 1], [1, 1]]]), indent=2))

卷积计算主体的前向与反向时间均为 O(C_out H_out W_out C_in K_h K_w)。代码还检查全部输入、核、偏置与上游梯度,需额外 O(C_in HW+C_out C_in K_h K_w+C_out H_out W_out) 时间。输出占 O(C_out H_out W_out),反向额外保存输入、核、偏置的梯度;这里只是便于逐项核验的直接实现,不代表框架底层的实际执行速度。

从卷积层到卷积网络还差什么

卷积后常接非线性,再通过更多卷积、下采样和输出头完成任务。没有非线性时,连续线性层的表达会受到限制;卷积层本身不是分类概率。共享局部核也不自动保证整个模型对任意平移完全不变,边界、步幅和后续结构都会影响结果。

自检:同一个核被使用四次,更新时能只取最后一个窗口的梯度吗?

不能。共享参数有四条通向损失的路径,应该按链式法则累加。只有损失定义本身含有平均因子时才应用对应缩放,不能因为“共享”就自行除以四。

边界 检查
多输入通道 所有通道求和后只加一次输出偏置
核翻转 本文采用互相关约定,不能与数学卷积混用
步幅除不尽 输出尺寸向下取整,只计算完整窗口
重叠窗口 输入与权重梯度都需要 +=
填充 零不是可学习输入,不给虚拟像素保存梯度
上游梯度 形状必须与输出一致,不能猜成输入形状
数值差分 验证抽样点,不等于证明整个网络训练正确

项目 tests/verify-convolution-article.cjs 用独立展开矩阵核验前向,用标量损失的中心差分核验输入、核和偏置梯度。继续读 DQN 的神经 Q 网络,可以比较“从图像提取特征”与“输出动作价值”是怎样连接但又不相同的任务。