第 10 篇我们亲手写出了卷积的前向传播,学会了用滑动窗口做“互相关”运算。现在,神经网络要想学习,必须让梯度从后面传回来,更新卷积核的权重,也把误差继续往前传。这件事就叫反向传播。
卷积层的反向传播,几乎每个人都卡在同一个地方:“为什么要把卷积核转 180 度再去卷积?” 很多文章直接给一个公式,看得人更晕。今天我们不用任何花哨的符号,就用第 10 篇那个 5×5 的图像和 3×3 的卷积核,再假定一个具体的“误差矩阵”,一步一步手算,用数字说话,让你亲眼看见“旋转 180 度”是怎么被逼出来的。
看完你会发现,这不过就是把同一个滑窗游戏反过来玩了一遍。
1. 固定数据,先明确我们在算什么
我们要处理的数据和第十篇完全相同,不引入任何新面孔。
输入图像(5×5):
[[1, 1, 1, 0, 0],
[0, 1, 1, 1, 0],
[0, 0, 1, 1, 1],
[0, 0, 1, 1, 0],
[0, 1, 1, 0, 0]]
卷积核(3×3):
[[1, 0, 1],
[0, 1, 0],
[1, 0, 1]]
前向传播时步长为 1,不补零,得到 3×3 的输出特征图,我们之前已经算出具体数字是:
[[4, 3, 4],
[2, 4, 3],
[2, 3, 4]]
现在,假设损失函数已经计算出了对输出特征图的梯度,记作 dout。为了好算,我们不随机生成,而是指定一个简单的数字矩阵:
dout = [[1, 2, 3],
[4, 5, 6],
[7, 8, 9]]
你可以把它理解为:输出层的每个位置对最终损失负有多大的“责任”,这个矩阵就是具体数值。
反向传播要算出两个东西:
-
dW:损失对卷积核里每个权重的梯度(3×3矩阵),用来告诉卷积核该怎样调整。
-
dX:损失对输入图像每个像素的梯度(5×5矩阵),用来继续往网络的前面传。
2. 先求 dW:用误差矩阵去“滑”原始图像
我们先回忆一下前向传播里一个卷积核权重是干什么的。比如卷积核左上角那个权重 W[0,0]=1,它在前向时,会和图像上每一个滑窗的左上角像素相乘。也就是说,W[0,0] 一共参与了 9 次计算(因为输出是 3×3,每个输出点用一次)。每次参与时,乘以的那个输入像素值是多少?我们把这些像素找出来,再乘上对应的误差 dout 值,全加起来,就是 dW[0,0] 的梯度。
2.1 用手工算 dW[0,0]
-
输出位置 (0,0):滑窗的左上角像素是 image[0,0] = 1,误差是 dout[0,0]=1,贡献 1×1=1。
-
输出位置 (0,1):左上角是 image[0,1]=1,误差 dout[0,1]=2,贡献 1×2=2。
-
输出位置 (0,2):左上角 image[0,2]=1,误差 dout[0,2]=3,贡献 1×3=3。
-
输出位置 (1,0):左上角 image[1,0]=0,误差 4,贡献 0。
-
输出位置 (1,1):左上角 image[1,1]=1,误差 5,贡献 5。
-
输出位置 (1,2):左上角 image[1,2]=1,误差 6,贡献 6。
-
输出位置 (2,0):左上角 image[2,0]=0,误差 7,贡献 0。
-
输出位置 (2,1):左上角 image[2,1]=0,误差 8,贡献 0。
-
输出位置 (2,2):左上角 image[2,2]=1,误差 9,贡献 9。
全部加起来:1+2+3+0+5+6+0+0+9 = 26。所以 dW[0,0] = 26。
2.2 再算 dW[0,1],观察规律
W[0,1] 在前向时总是乘滑窗的第一行第二列像素。找出这些像素:
-
(0,0) 窗口:image[0,1]=1 → 误差 1 → 1
-
(0,1) 窗口:image[0,2]=1 → 误差 2 → 2
-
(0,2) 窗口:image[0,3]=0 → 误差 3 → 0
-
(1,0) 窗口:image[1,1]=1 → 误差 4 → 4
-
(1,1) 窗口:image[1,2]=1 → 误差 5 → 5
-
(1,2) 窗口:image[1,3]=1 → 误差 6 → 6
-
(2,0) 窗口:image[2,1]=0 → 误差 7 → 0
-
(2,1) 窗口:image[2,2]=1 → 误差 8 → 8
-
(2,2) 窗口:image[2,3]=1 → 误差 9 → 9
总和:1+2+0+4+5+6+0+8+9 = 35。
你看,这个计算过程,其实就是把 dout 这个 3×3 矩阵当作卷积核,在原始图像 X 上从左到右、从上到下滑动,每盖住一个 3×3 的区域,就把对应的数值相乘再相加。这不是和前向传播完全一样的操作吗?只是角色换了一下:原来卷积核是 W,现在“卷积核”变成了 dout,输入仍然是 X。
于是,求整个 dW 的方法就很简单了:用 dout 做核,对原图 X 做一次互相关(步长 1,不填充)。写出来的代码就是:
import numpy as np
image = np.array([[1,1,1,0,0],
[0,1,1,1,0],
[0,0,1,1,1],
[0,0,1,1,0],
[0,1,1,0,0]], dtype=np.float32)
kernel = np.array([[1,0,1],
[0,1,0],
[1,0,1]], dtype=np.float32)
dout = np.array([[1,2,3],
[4,5,6],
[7,8,9]], dtype=np.float32)
def compute_dW(dout, X, kH, kW):
dW = np.zeros((kH, kW), dtype=np.float32)
for i in range(kH): # 遍历卷积核的行
for j in range(kW): # 遍历卷积核的列
for m in range(dout.shape[0]): # 遍历 dout 的行
for n in range(dout.shape[1]): # 遍历 dout 的列
dW[i,j] += dout[m,n] * X[m+i, n+j]
return dW
dW = compute_dW(dout, image, 3, 3)
print("dW:\\n", dW)
运行后得到的结果是:

这就是正确的 dW,与我们手工算的前两个数字完全吻合。这个矩阵就是损失对卷积核每个权重的梯度。
小结一下:dW 就是用误差矩阵 dout 当“临时卷积核”,在原图上滑动一遍。 你可以这样记:前向是“核滑图”,反向求 dW 是“误差滑图”。

3. 再求 dX:误差怎样传回输入?
接下来是重头戏:我们要知道输入图像上每一个像素该承担多少责任,也就是 dX。
为什么 dX 更绕?因为输入上的一个像素可能被多个滑窗覆盖过,参与了多个输出位置的计算。举个例子,图像正中间那个像素 image[2,2]=1,它同时被 9 个滑窗(3×3 的输出,每个输出对应的窗口都包含它)使用过。所以,9 个误差信号都会和它扯上关系。我们要把这 9 条路径的贡献全部累加起来。
3.1 手算 dX[2,2]
找出 image[2,2] 在每次前向滑窗中乘的是卷积核的哪个权重:
-
输出 (0,0) 的窗口(行0-2,列0-2),image[2,2] 在窗口中的位置是 (2,2),乘的权重是 W[2,2]=1。误差 dout[0,0]=1,贡献 1*1=1。
-
输出 (0,1)(行0-2,列1-3),它的位置是 (2,1),乘权重 W[2,1]=0,贡献 2*0=0。
-
输出 (0,2)(行0-2,列2-4),位置 (2,0),乘权重 W[2,0]=1,贡献 3*1=3。
-
输出 (1,0)(行1-3,列0-2),位置 (1,2),乘权重 W[1,2]=0,贡献 4*0=0。
-
输出 (1,1)(行1-3,列1-3),位置 (1,1),乘权重 W[1,1]=1,贡献 5*1=5。
-
输出 (1,2)(行1-3,列2-4),位置 (1,0),乘权重 W[1,0]=0,贡献 6*0=0。
-
输出 (2,0)(行2-4,列0-2),位置 (0,2),乘权重 W[0,2]=1,贡献 7*1=7。
-
输出 (2,1)(行2-4,列1-3),位置 (0,1),乘权重 W[0,1]=0,贡献 8*0=0。
-
输出 (2,2)(行2-4,列2-4),位置 (0,0),乘权重 W[0,0]=1,贡献 9*1=9。
总和:1+0+3+0+5+0+7+0+9 = 25。所以 dX[2,2] = 25。
3.2 发现规律:旋转 180° 就来自这里
请仔细观察上面的加和顺序:dout[0,0] 乘的是 W[2,2],dout[0,1] 乘的是 W[2,1],dout[2,2] 乘的是 W[0,0]。原本卷积核的左上角权重 W[0,0],在这里被放到了 dout 的右下角去相乘;而右下角的 W[2,2] 又被放到了 dout 的左上角。这恰好等价于:先把卷积核旋转 180°(上下翻转 + 左右翻转),再和 dout 做正常的滑窗乘加。
用我们这个对称的卷积核不明显,但如果换一个不对称的,比如 [[1,2],[3,4]],旋转后就变成 [[4,3],[2,1]],效果一目了然。
更重要的是,dout 是 3×3,而我们想要的是 5×5 的 dX,尺寸不够。怎么办?自然想到:在 dout 外面补零。补多少圈?因为卷积核是 3×3,我们需要让滑窗能覆盖到 dout 的每个边缘,且最终输出是 5×5,所以补 3-1=2 圈零刚刚好。
于是 dX 的计算步骤变成:
把卷积核 W 旋转 180°。
把 dout 外围补上两圈 0(因为 3×3 核,补 3-1=2),变成一个 7×7 的矩阵。
用旋转后的核在这个 7×7 上做一次正常的互相关(步长 1),得到的就是 5×5 的 dX。
这就是那个反直觉的“旋转 180° 再做卷积”的全部真相。
4. 用图片把“旋转”和“补零”具象化

5. 完整的反向传播代码(并验证 dX[2,2])
我们把 dW 和 dX 的计算写成一个统一的函数。
def conv2d_backward(dout, X, W, stride=1, padding=0):
kH, kW = W.shape
H_out, W_out = dout.shape
# 1. 计算 dW:dout 当核,在 X 上滑动
dW = np.zeros_like(W)
for i in range(kH):
for j in range(kW):
for m in range(H_out):
for n in range(W_out):
dW[i,j] += dout[m,n] * X[m*stride + i, n*stride + j]
# 2. 计算 dX:旋转核 + 补零 + 互相关
W_rot = np.flip(W, axis=(0,1)) # 旋转 180°
pad_total = kH – 1 # 需要补的圈数
dout_padded = np.pad(dout, pad_total, mode='constant')
dX = np.zeros_like(X)
for i in range(X.shape[0]):
for j in range(X.shape[1]):
region = dout_padded[i:i+kH, j:j+kW]
dX[i,j] = np.sum(region * W_rot)
return dX, dW
dX, dW = conv2d_backward(dout, image, kernel)
print("dX[2,2] =", dX[2,2]) # 应该是 25
print("dW:\\n", dW)
运行后,dX[2,2] 会输出 25,dW 的值就是刚才我们手工核对的矩阵。一切都对上了。
6. 用 PyTorch 做最终的对照
自己写的东西必须和成熟框架的结果一致,心里才踏实。我们用 PyTorch 的自动求导验证:
import torch
import torch.nn.functional as F
X_t = torch.tensor(image, requires_grad=True)
W_t = torch.tensor(kernel, requires_grad=True)
Y_t = F.conv2d(X_t.view(1,1,5,5), W_t.view(1,1,3,3), stride=1, padding=0)
dout_t = torch.tensor(dout).view(1,1,3,3)
Y_t.backward(dout_t)
dX_torch = X_t.grad.squeeze().numpy()
dW_torch = W_t.grad.squeeze().numpy()
print("dX 一致:", np.allclose(dX, dX_torch, atol=1e-6))
print("dW 一致:", np.allclose(dW, dW_torch, atol=1e-6))
7. 总结
现在,你可以完全扔掉对“旋转 180°”的恐惧了。卷积反向传播说白了就是两件事:
-
求 dW:把误差矩阵 dout 当成一个卷积核,在原始输入图像上做一次互相关。
-
求 dX:先把卷积核旋转 180°,再把 dout 外面补上(核大小−1)圈零,然后做互相关。
这两步都是对前向滑窗逻辑的逆向运用,没有任何神秘公式。
8. 下篇预告
到这一篇为止,卷积层的全部正向和反向你已经亲手实现并验证。下一篇文章,我们将攻下 CNN 另一个基础组件:池化层。池化层没有权重,但它的反向传播有一条非常直观的规则——最大值独享梯度,平均值均分梯度。我们将继续用 NumPy 从零实现 MaxPooling 的前向和反向,并且再次和 PyTorch 硬核对账。
卷积 + 池化 + 全连接,这三件兵器即将凑齐,LeNet-5 已近在眼前。


