python神经网络编程入门(十一)——CNN卷积层的反向传播—— 为什么要把卷积核转 180°?

📅 2026/7/16 23:44:09 👁️ 阅读次数 📝 编程学习
python神经网络编程入门(十一)——CNN卷积层的反向传播—— 为什么要把卷积核转 180°?

第 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的计算步骤变成:

  1. 把卷积核W旋转 180°。

  2. dout外围补上两圈 0(因为 3×3 核,补 3-1=2),变成一个 7×7 的矩阵。

  3. 用旋转后的核在这个 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 已近在眼前。