python神经网络编程入门(十)——CNN手撕卷积层(Conv2D)前向传播 —— 用 NumPy 实现滑窗与互相关运算
2026/7/15 22:59:16 网站建设 项目流程

在第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] ], dtype=np.float32) # 卷积核 (3x3) kernel = np.array([ [1, 0, 1], [0, 1, 0], [1, 0, 1] ], dtype=np.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)
= 1+0+0 + 0+1+0 + 0+0+1 =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), dtype=np.float32) for i in range(out_H): for j in range(out_W): # 从图像中切出当前局部窗口 region = image[i:i+kH, j:j+kW] # 逐元素相乘再求和 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 和 padding:

def conv2d_naive(image, kernel, stride=1, padding=0): H, W = image.shape kH, kW = kernel.shape # 对输入图像进行零填充 if padding > 0: image_padded = np.pad(image, pad_width=padding, mode='constant', constant_values=0) 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), dtype=np.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_i+kH, start_j:start_j+kW] output[i, j] = np.sum(region * kernel) return output # 测试:stride=2, padding=1 output_s2p1 = conv2d_naive(image, kernel, stride=2, padding=1) print("stride=2, padding=1 的输出:\n", output_s2p1)

你会发现输出尺寸变成了 3×3(因为 (5+2-3)//2+1 = 4//2+1=3),和步长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]] ], dtype=np.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]]] ], dtype=np.float32) # shape (2, 2, 2, 2) 即 (K, C, kH, kW)

对于第一个卷积核(K=0),要在输入的两个通道上分别做卷积,然后把两个通道的结果相加。
我们直接扩展之前的conv2d_naive来处理多通道:

def conv2d_multi_channel(image, kernels, stride=1, padding=0): # 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)), mode='constant', constant_values=0) 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), dtype=np.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_i+kH, start_j:start_j+kW] # (C, kH, kW) # 逐通道乘积累加 output[k, i, j] = np.sum(region * kernels[k]) return output out_multi = conv2d_multi_channel(input_3d, kernels, stride=1, padding=0) 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, stride=1, padding=0) 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, stride=1, padding=0) print("PyTorch 多通道输出:\n", out_torch3) print("NumPy 多通道输出:\n", out_multi) assert np.allclose(out_torch3.numpy(), out_multi, atol=1e-6), "验证失败!" print("✅ 结果完全一致,手撕卷积成功!")

当你看到屏幕上打印出✅ 结果完全一致,手撕卷积成功!时,那一刻的成就感会告诉你:你对卷积的理解已经到达了框架作者的层面。


7. 本篇总结 & 下篇预告

这一篇,我们从人脑手算 → NumPy 双层循环 → stride/padding → 多通道多核 → PyTorch 对比验证,把卷积层前向传播的每一块砖都拆开看了一遍。

现在你应该可以自信地说出:

  • 卷积运算的本质就是滑动窗口 + 逐元素乘加

  • 步长和填充控制了输出尺寸与感受野的覆盖密度;

  • 多通道输入时,每个卷积核在所有通道上求和得到一层输出;

  • 我写的 NumPy 代码和 PyTorch 的F.conv2d结果分毫不差。

但前向传播只是“使用”卷积核,真正让卷积核“学会”提取特征的关键,在反向传播。
而卷积层的反向传播,可以说是整个 CNN 里最绕、最容易卡住的一关。为什么?因为误差在回传时,竟然要把卷积核旋转 180°再和误差图做卷积——这个操作光听描述就让人一头雾水。

下一篇文章,我们就专门来啃这块最硬的骨头。
我们会用最直观的图解,一步一步拆解梯度是如何从下一层“回流”到卷积核权重和输入上的;然后搞清楚那个经典的灵魂拷问——“为什么误差要旋转 180°?”;最后,用纯 NumPy 手撕出完整的反向传播代码,并再次和 PyTorch 的自动求导硬碰硬验证。

打通这一关,你对 CNN 的理解就真正从“会用”跨入了“会造”的层面。
咱们第 11 篇见。

代码片段1(5、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] ], dtype=np.float32) # 一个 3×3 的卷积核,用来检测某种特征(比如交叉形状)。 kernel_3x3 = np.array([ [1, 0, 1], [0, 1, 0], [1, 0, 1] ], dtype=np.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 # 输出尺寸公式(无填充,步长1):out = H - kH + 1 out_H = H - kH + 1 out_W = W - kW + 1 # 创建一个全零的输出矩阵,用于存放卷积结果 output = np.zeros((out_H, out_W), dtype=np.float32) # 两层循环:遍历输出矩阵的每一个位置 for i in range(out_H): for j in range(out_W): # 从原图中切出当前窗口对应的局部区域 region = image[i:i+kH, j:j+kW] # 大小与卷积核一致 # 卷积运算:对应位置相乘,然后求和(互相关运算) output[i, j] = np.sum(region * kernel) return output def conv2d_naive(image, kernel, stride=1, padding=0): """ 增强版卷积:支持步长(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_width=padding 表示上下左右各补 padding 个零。 image = np.pad(image, pad_width=padding, mode='constant', constant_values=0) # 填充后的新尺寸 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), dtype=np.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_i+kH, start_j:start_j+kW] output[i, j] = np.sum(region * kernel) return output def conv2d_multi_channel(image, kernels, stride=1, padding=0): """ 多通道卷积:支持多通道输入,同时使用多个卷积核。 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)), mode='constant', constant_values=0) _, 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), dtype=np.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_i+kH, start_j:start_j+kW] # 卷积运算: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, stride=1, padding=0) print("PyTorch conv2d 输出:\n", output_torch.squeeze().numpy()) # 对比两者是否一致(允许极小浮点误差) if np.allclose(output_numpy, output_torch.squeeze().numpy(), atol=1e-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]] ], dtype=np.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]]] ], dtype=np.float32) # 形状: (2, 2, 2, 2) 解释: (K=2, C=2, kH=2, kW=2) # ==================== 5. 多通道验证 ==================== print("=" * 50) print("多通道卷积验证") print("=" * 50) # 用自己写的多通道卷积函数计算结果 output_multi_np = conv2d_multi_channel(input_3d, kernels_3d, stride=1, padding=0) 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, stride=1, padding=0) print("PyTorch 多通道输出:\n", output_torch3) if np.allclose(output_multi_np, output_torch3.numpy(), atol=1e-6): print("✅ 多通道验证通过,手撕卷积完全正确!\n") else: print("❌ 多通道验证失败,请检查实现。\n")

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询