关注

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]
], 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

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--