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的计算步骤变成:
把卷积核
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 已近在眼前。