在第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")
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_65148539/article/details/162913909




