欢迎光临
我们一直在努力

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 已近在眼前。

    赞(0)
    未经允许不得转载:171主机测评 » python神经网络编程入门(十一)——CNN卷积层的反向传播—— 为什么要把卷积核转 180°?
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址