python神经网络编程入门(十)——CNN手撕卷积层(Conv2D)前向传播 —— 用 NumPy 实现滑窗与互相关运算

发布时间:2026/7/15 22:59:19
python神经网络编程入门(十)——CNN手撕卷积层(Conv2D)前向传播 —— 用 NumPy 实现滑窗与互相关运算 在第9篇里我们反复念叨“卷积核在图像上滑动”、“权值共享”、“局部感受野”。这些词你肯定已经听懂了但有没有一种隔靴搔痒的感觉——道理我都懂可卷积核到底是怎样“滑”过去的每滑动一格那个输出数字是怎么从图像和卷积核里蹦出来的代码到底怎么写今天我们就直接用 NumPy 手撕卷积层的前向传播。不调包、不偷懒所有滑动窗口全部用 for 循环展开。写完你会发现原来 PyTorch 里那个一行搞定的F.conv2d底层逻辑不过是一个精心设计的“滑窗累加器”。1. 先回忆一下我们手头有哪些数据为了保证从第9篇到第10篇的连贯性我们继续使用第9篇里出现过的那张 5×5 的单通道图像以及那个 3×3 的卷积核。老朋友了别嫌它小越小越能把每一根毛细血管都看清楚。import numpy as np # 输入图像 (5x5, 单通道) 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] ], dtypenp.float32) # 卷积核 (3x3) kernel np.array([ [1, 0, 1], [0, 1, 0], [1, 0, 1] ], dtypenp.float32) print(输入图像:\n, image) print(卷积核:\n, kernel)2. 先用人脑“跑”一次前向传播在写代码之前你必须要用人脑完整走通一次卷积计算。这步不可跳过否则后面代码会看得云里雾里。2.1 第一步确定输出尺寸我们暂时采用最朴素设定步长stride 1填充padding 0不补零输入尺寸 5×5卷积核 3×3输出尺寸公式输出高 (输入高 - 卷积核高) / 步长 1也就是 (5-3)/1 1 3输出是一个 3×3 的矩阵。2.2 第二步把卷积核盖上去逐位置计算想象你现在把卷积核贴在图像的左上角盖住一个 3×3 的区域图像左上角区域 1 1 1 0 1 1 0 0 1 卷积核 1 0 1 0 1 0 1 0 1对应位置相乘再全部相加(1×1) (1×0) (1×1) (0×0) (1×1) (1×0) (0×1) (0×0) (1×1) 1 0 1 0 1 0 0 0 1 4所以输出矩阵的左上角 (0,0) 位置的数值就是4。接下来把卷积核向右移动一格步长1盖住图像的第0~2行、第1~3列的区域1 1 0 1 1 1 0 1 1与卷积核对位相乘求和(1×1)(1×0)(0×1) (1×0)(1×1)(1×0) (0×1)(1×0)(1×1) 100 010 001 3输出 (0,1) 3。如果你能手动算出整个 3×3 的输出矩阵你就是一台人肉 CNN 了。我们来对一下答案完整输出矩阵应该是[[4, 3, 4], [2, 4, 3], [2, 3, 4]]用 Python 验证一下马上就会写代码你可以先信我。3. 把手工过程翻译成 NumPy 代码现在我们已经完全搞清楚了逻辑两层循环遍历输出位置 → 从图像中切出对应区域 → 与卷积核点乘并求和 → 填入输出矩阵。先写出最朴素的版本只支持单通道输入、单卷积核、步长1、无填充def conv2d_naive_single(image, kernel): # image: (H, W) # kernel: (kH, kW) H, W image.shape kH, kW kernel.shape out_H H - kH 1 out_W W - kW 1 output np.zeros((out_H, out_W), dtypenp.float32) for i in range(out_H): for j in range(out_W): # 从图像中切出当前局部窗口 region image[i:ikH, j:jkW] # 逐元素相乘再求和 output[i, j] np.sum(region * kernel) return output # 用我们的老朋友测试 output_naive conv2d_naive_single(image, kernel) print(手写卷积输出:\n, output_naive)和人脑算的完全一致。至此你已经用 NumPy 从零写出了一个最简卷积层的前向传播。它没有一行魔法唯一依赖的np.sum(region*kernel)也只是帮你省掉了手写内层循环而已。4. 补上两个重要设定步长和填充真实世界里的卷积不会总是老老实实一步一步滑动也不会永远不要填充。步长stride窗口每次跨越的像素数。步长越大输出尺寸越小计算量越低但可能丢失细节。填充padding在输入图像四周补零。最常用的是“same”填充保证输入输出尺寸相同。输出尺寸的通用公式输出高 floor((H 2×padding - kH) / stride) 1宽同理。我们现在直接改造conv2d_naive让它支持 stride 和 paddingdef conv2d_naive(image, kernel, stride1, padding0): H, W image.shape kH, kW kernel.shape # 对输入图像进行零填充 if padding 0: image_padded np.pad(image, pad_widthpadding, modeconstant, constant_values0) else: image_padded image H_pad, W_pad image_padded.shape out_H (H_pad - kH) // stride 1 out_W (W_pad - kW) // stride 1 output np.zeros((out_H, out_W), dtypenp.float32) for i in range(out_H): for j in range(out_W): start_i i * stride start_j j * stride region image_padded[start_i:start_ikH, start_j:start_jkW] output[i, j] np.sum(region * kernel) return output # 测试stride2, padding1 output_s2p1 conv2d_naive(image, kernel, stride2, padding1) print(stride2, padding1 的输出:\n, output_s2p1)你会发现输出尺寸变成了 3×3因为 (52-3)//21 4//213和步长1无填充时的尺寸一样但数值却不同——这就是填充和步长改变特征抽取方式的效果。5. 从二维到三维多通道输入与多卷积核真正的图像通常是 RGB 三通道。每个卷积核的通道数必须和输入通道数相同。比如输入是 (C, H, W)一个卷积核的形状就是 (C, kH, kW)。卷积运算时这个核会同时在所有 C 个通道上滑动把所有通道的乘加结果求和得到一个单层的输出。如果有 K 个不同的卷积核每个都产生一层输出最终输出就是 (K, out_H, out_W)。我们继续用直观的数组来演示。为了不混淆这里不再用 5×5 的灰度图而是构造一个简单的 2 通道 3×3 图像以及 2 个卷积核每个核也是 2 通道 2×2走一遍计算。5.1 多通道手算示例# 2通道输入每个通道 3x3 input_3d np.array([ [[1, 2, 0], [0, 1, 1], [2, 0, 1]], [[0, 1, 1], [1, 0, 2], [0, 2, 0]] ], dtypenp.float32) # shape (2, 3, 3) # 2个卷积核每个核的通道数2尺寸2x2 kernels np.array([ [[[1, 0], [0, -1]], [[0, 1], [1, 0]]], [[[1, 1], [1, 0]], [[0, 1], [1, -1]]] ], dtypenp.float32) # shape (2, 2, 2, 2) 即 (K, C, kH, kW)对于第一个卷积核K0要在输入的两个通道上分别做卷积然后把两个通道的结果相加。我们直接扩展之前的conv2d_naive来处理多通道def conv2d_multi_channel(image, kernels, stride1, padding0): # image: (C, H, W) # kernels: (K, C, kH, kW) C, H, W image.shape K, C_k, kH, kW kernels.shape assert C C_k, 输入通道数必须与卷积核通道数相同 if padding 0: image_padded np.pad(image, pad_width((0,0),(padding,padding),(padding,padding)), modeconstant, constant_values0) else: image_padded image _, H_pad, W_pad image_padded.shape out_H (H_pad - kH) // stride 1 out_W (W_pad - kW) // stride 1 output np.zeros((K, out_H, out_W), dtypenp.float32) for k in range(K): for i in range(out_H): for j in range(out_W): start_i i * stride start_j j * stride region image_padded[:, start_i:start_ikH, start_j:start_jkW] # (C, kH, kW) # 逐通道乘积累加 output[k, i, j] np.sum(region * kernels[k]) return output out_multi conv2d_multi_channel(input_3d, kernels, stride1, padding0) print(多通道输出 shape:, out_multi.shape) print(out_multi)这样就完成了多通道、多卷积核的前向传播。代码的逻辑依然非常直白最外层多了一个for k循环内层对多通道区域和卷积核做 element-wise 乘法再全加起来。6. 用 PyTorch 的F.conv2d验证正确性自己写的代码对不对必须拿工业级实现来比对。PyTorch 的torch.nn.functional.conv2d就是最好的裁判。不过 PyTorch 的输入形状是(batch_size, channels, height, width)而且权重形状是(out_channels, in_channels, kH, kW)。我们只需要把 numpy 数组转为 tensor再调整一下即可。6.1 单通道验证import torch import torch.nn.functional as F # 单通道例子image (1,1,5,5), kernel (1,1,3,3) img_t torch.tensor(image).unsqueeze(0).unsqueeze(0) # (1,1,5,5) k_t torch.tensor(kernel).unsqueeze(0).unsqueeze(0) # (1,1,3,3) out_torch F.conv2d(img_t, k_t, stride1, padding0) print(PyTorch 输出:\n, out_torch.squeeze().numpy()) print(NumPy 输出:\n, output_naive)6.2 多通道验证img_t3 torch.tensor(input_3d).unsqueeze(0) # (1,2,3,3) k_t3 torch.tensor(kernels) # (2,2,2,2) out_torch3 F.conv2d(img_t3, k_t3, stride1, padding0) print(PyTorch 多通道输出:\n, out_torch3) print(NumPy 多通道输出:\n, out_multi) assert np.allclose(out_torch3.numpy(), out_multi, atol1e-6), 验证失败 print(✅ 结果完全一致手撕卷积成功)当你看到屏幕上打印出✅ 结果完全一致手撕卷积成功时那一刻的成就感会告诉你你对卷积的理解已经到达了框架作者的层面。7. 本篇总结 下篇预告这一篇我们从人脑手算 → NumPy 双层循环 → stride/padding → 多通道多核 → PyTorch 对比验证把卷积层前向传播的每一块砖都拆开看了一遍。现在你应该可以自信地说出卷积运算的本质就是滑动窗口 逐元素乘加步长和填充控制了输出尺寸与感受野的覆盖密度多通道输入时每个卷积核在所有通道上求和得到一层输出我写的 NumPy 代码和 PyTorch 的F.conv2d结果分毫不差。但前向传播只是“使用”卷积核真正让卷积核“学会”提取特征的关键在反向传播。而卷积层的反向传播可以说是整个 CNN 里最绕、最容易卡住的一关。为什么因为误差在回传时竟然要把卷积核旋转 180°再和误差图做卷积——这个操作光听描述就让人一头雾水。下一篇文章我们就专门来啃这块最硬的骨头。我们会用最直观的图解一步一步拆解梯度是如何从下一层“回流”到卷积核权重和输入上的然后搞清楚那个经典的灵魂拷问——“为什么误差要旋转 180°”最后用纯 NumPy 手撕出完整的反向传播代码并再次和 PyTorch 的自动求导硬碰硬验证。打通这一关你对 CNN 的理解就真正从“会用”跨入了“会造”的层面。咱们第 11 篇见。代码片段15、6步骤numpy与torch运行对比的完整代码import numpy as np import torch import torch.nn.functional as F # 1. 单通道数据与第9篇、第10篇完全一致 # 我们沿用前两篇教程中的示例一张 5×5 的灰度图像像素值为浮点数。 image_5x5 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] ], dtypenp.float32) # 一个 3×3 的卷积核用来检测某种特征比如交叉形状。 kernel_3x3 np.array([ [1, 0, 1], [0, 1, 0], [1, 0, 1] ], dtypenp.float32) # 2. 手写卷积函数 # 下面三个函数由浅入深逐步实现卷积的前向传播。 def conv2d_naive_single(image, kernel): 最简版卷积单通道输入单卷积核步长1填充0。 image: (H, W) 输入图像 kernel: (kH, kW) 卷积核 return: (out_H, out_W) 输出特征图 H, W image.shape kH, kW kernel.shape # 输出尺寸公式无填充步长1out H - kH 1 out_H H - kH 1 out_W W - kW 1 # 创建一个全零的输出矩阵用于存放卷积结果 output np.zeros((out_H, out_W), dtypenp.float32) # 两层循环遍历输出矩阵的每一个位置 for i in range(out_H): for j in range(out_W): # 从原图中切出当前窗口对应的局部区域 region image[i:ikH, j:jkW] # 大小与卷积核一致 # 卷积运算对应位置相乘然后求和互相关运算 output[i, j] np.sum(region * kernel) return output def conv2d_naive(image, kernel, stride1, padding0): 增强版卷积支持步长(stride)和填充(padding)。 image: (H, W) kernel: (kH, kW) stride: 滑动步长默认为1 padding: 在图像周围补零的圈数默认为0 H, W image.shape kH, kW kernel.shape # ----- 处理填充 ----- if padding 0: # np.pad 在二维数组周围补零。 # pad_widthpadding 表示上下左右各补 padding 个零。 image np.pad(image, pad_widthpadding, modeconstant, constant_values0) # 填充后的新尺寸 H_pad, W_pad image.shape # ----- 计算输出尺寸 ----- # 输出尺寸 (填充后尺寸 - 卷积核尺寸) // 步长 1 out_H (H_pad - kH) // stride 1 out_W (W_pad - kW) // stride 1 output np.zeros((out_H, out_W), dtypenp.float32) for i in range(out_H): for j in range(out_W): # 当前窗口的起始坐标考虑步长 start_i i * stride start_j j * stride # 切出局部区域 region image[start_i:start_ikH, start_j:start_jkW] output[i, j] np.sum(region * kernel) return output def conv2d_multi_channel(image, kernels, stride1, padding0): 多通道卷积支持多通道输入同时使用多个卷积核。 image: (C, H, W) C个通道每个通道是一个二维矩阵 kernels: (K, C, kH, kW) K个卷积核每个核有C个通道 C, H, W image.shape K, C_k, kH, kW kernels.shape # 安全检查输入通道数必须与卷积核的通道数一致 if C ! C_k: raise ValueError(输入通道数必须与卷积核通道数相同) # ----- 处理填充对每个通道都要填充----- if padding 0: # pad_width ((0,0), (p,p), (p,p)) 表示通道维度不填充高度和宽度各补 p 个零 image np.pad(image, pad_width((0,0),(padding,padding),(padding,padding)), modeconstant, constant_values0) _, H_pad, W_pad image.shape out_H (H_pad - kH) // stride 1 out_W (W_pad - kW) // stride 1 # 输出形状(K, out_H, out_W)每个卷积核产生一层特征图 output np.zeros((K, out_H, out_W), dtypenp.float32) # 最外层循环遍历每一个卷积核 for k in range(K): for i in range(out_H): for j in range(out_W): start_i i * stride start_j j * stride # 切出局部区域形状为 (C, kH, kW) —— 包含所有通道 region image[:, start_i:start_ikH, start_j:start_jkW] # 卷积运算region 与 kernels[k] 形状一致对应相乘后全部加起来 output[k, i, j] np.sum(region * kernels[k]) return output # 3. 单通道验证 print( * 50) print(单通道卷积验证) print( * 50) # 用我们自己写的函数计算结果 output_numpy conv2d_naive_single(image_5x5, kernel_3x3) print(NumPy 手写卷积输出:\n, output_numpy) # PyTorch 官方实现作为标准答案 # unsqueeze(0) 两次给图像和卷积核增加 batch 维度和 in_channel 维度 img_t torch.from_numpy(image_5x5).unsqueeze(0).unsqueeze(0) # 形状: (1, 1, 5, 5) k_t torch.from_numpy(kernel_3x3).unsqueeze(0).unsqueeze(0) # 形状: (1, 1, 3, 3) output_torch F.conv2d(img_t, k_t, stride1, padding0) print(PyTorch conv2d 输出:\n, output_torch.squeeze().numpy()) # 对比两者是否一致允许极小浮点误差 if np.allclose(output_numpy, output_torch.squeeze().numpy(), atol1e-6): print(✅ 单通道验证通过结果完全一致\n) else: print(❌ 单通道验证失败请检查实现。\n) # 4. 多通道数据构建 # 下面构建一个简单的2通道输入每个通道是 3×3 的小矩阵。 # 这组数据虽然是人造的但能清楚地展示多通道融合的过程。 input_3d np.array([ # 通道 0 [[1, 2, 0], [0, 1, 1], [2, 0, 1]], # 通道 1 [[0, 1, 1], [1, 0, 2], [0, 2, 0]] ], dtypenp.float32) # 形状: (2, 3, 3) # 准备 2 个卷积核每个核的尺寸是 2×2因为输入是 2 通道所以每个核也有 2 个通道。 kernels_3d np.array([ # 第 0 个卷积核2个通道每个通道2×2 [[[1, 0], [0, -1]], [[0, 1], [1, 0]]], # 第 1 个卷积核 [[[1, 1], [1, 0]], [[0, 1], [1, -1]]] ], dtypenp.float32) # 形状: (2, 2, 2, 2) 解释 (K2, C2, kH2, kW2) # 5. 多通道验证 print( * 50) print(多通道卷积验证) print( * 50) # 用自己写的多通道卷积函数计算结果 output_multi_np conv2d_multi_channel(input_3d, kernels_3d, stride1, padding0) print(NumPy 多通道输出 shape:, output_multi_np.shape) # 预期: (2, 2, 2) print(NumPy 多通道输出:\n, output_multi_np) # PyTorch 验证 img_t3 torch.from_numpy(input_3d).unsqueeze(0) # 加 batch 维度 - (1, 2, 3, 3) k_t3 torch.from_numpy(kernels_3d) # (2, 2, 2, 2) 已符合权重格式 output_torch3 F.conv2d(img_t3, k_t3, stride1, padding0) print(PyTorch 多通道输出:\n, output_torch3) if np.allclose(output_multi_np, output_torch3.numpy(), atol1e-6): print(✅ 多通道验证通过手撕卷积完全正确\n) else: print(❌ 多通道验证失败请检查实现。\n)