从零实现卷积


文档摘要

从零实现卷积 本节摘要:卷积,就是一个你在图像上滑动的微型全连接层——在每一个位置复用同一组权重。本节从最原始的四重嵌套循环开始,纯 NumPy 实现 2D 卷积,推导并记住那个你会在每个架构里算几十遍的输出尺寸公式 ,理解填充、步幅、多输入通道如何把它变成一个 的权重张量,再用 把整层卷积折叠成一次大矩阵乘法——而这正是每个生产级卷积内核(GEMM)的核心。我们还会手写边缘、模糊、锐化、Sobel 等经典核,看清一个未经训练的卷积层能表达什么,并把堆叠卷积与感受野联系起来。读完本节,你能在不看任何框架源码的前提下,讲清 在做什么。 对应原课程:Phase 4 · Lesson 02 · (原英文 )。

从零实现卷积

本节摘要:卷积,就是一个你在图像上滑动的微型全连接层——在每一个位置复用同一组权重。本节从最原始的四重嵌套循环开始,纯 NumPy 实现 2D 卷积,推导并记住那个你会在每个架构里算几十遍的输出尺寸公式 (H - K + 2P) / S + 1,理解填充、步幅、多输入通道如何把它变成一个 (C_out, C_in, K, K) 的权重张量,再用 im2col 把整层卷积折叠成一次大矩阵乘法——而这正是每个生产级卷积内核(GEMM)的核心。我们还会手写边缘、模糊、锐化、Sobel 等经典核,看清一个未经训练的卷积层能表达什么,并把堆叠卷积与感受野联系起来。读完本节,你能在不看任何框架源码的前提下,讲清 nn.Conv2d 在做什么。

对应原课程:Phase 4 · Lesson 02 · convolutions-from-scratch(原英文 phases/04-computer-vision/02-convolutions-from-scratch/docs/en.md)。

学习目标

阅读完本节,你应当能够:

  1. 仅用 NumPy 从零实现 2D 卷积,包括嵌套循环版向量化的 im2col
  2. 对任意「输入尺寸、核尺寸、填充、步幅」组合算出输出空间尺寸,并能解释 (H - K + 2P) / S + 1 这个公式。
  3. 手工设计核(边缘、模糊、锐化、Sobel),解释每个核为什么会产生它那种激活模式。
  4. 把卷积堆叠成特征提取器,并把堆栈深度与感受野大小联系起来。

一、问题与直觉

对一张 224×224 的 RGB 图像用一个全连接层,每个神经元需要 224×224×3 = 150,528 个输入权重。一个仅 1,000 个单元的隐藏层就已经有 1.5 亿参数——而你还没学到任何有用的东西。更糟的是,这一层完全不知道左上角的狗和右下角的狗是同一个模式。它把每个像素位置都当作独立变量,而这恰恰是图像场景里最错的一种假设:把一只猫平移三个像素,不应该逼网络重新学一遍「猫」这个概念。

图像模型需要的两个性质是平移等变性(translation equivariance:输入移动则输出移动)和参数共享(parameter sharing:同一个特征检测器在所有位置运行)。全连接层这两者都不给,而卷积白送两者。

卷积不是为深度学习发明的。驱动 JPEG 压缩、Photoshop 高斯模糊、工业视觉里的边缘检测、以及历史上每一个音频滤波器的,都是同一个运算。CNN 能在 2012 到 2020 年间统治 ImageNet,正是因为卷积是「邻近值相关、同一模式可出现在任意位置」这类数据的正确先验

一个核,滑动

2D 卷积取一个小小的权重矩阵——核(kernel,或称滤波器 filter),在输入上滑动,每个位置计算逐元素乘积之和。这个和就是一个输出像素。

在 5×5 输入上的一个具体 3×3 例子(无填充,步幅 1):

输入 X (5 x 5): 核 W (3 x 3): 1 2 0 1 2 1 0 -1 0 1 3 1 0 2 0 -2 2 1 0 2 1 1 0 -1 1 0 2 1 3 2 1 1 0 1 核在每一个有效的 3 x 3 窗口上滑动。输出 Y 为 3 x 3: Y[0,0] = sum( W * X[0:3, 0:3] ) Y[0,1] = sum( W * X[0:3, 1:4] ) Y[0,2] = sum( W * X[0:3, 2:5] ) Y[1,0] = sum( W * X[1:4, 0:3] ) ... 依此类推

共享权重 + 局部性 + 滑动窗口——这一个公式就是全部想法。其余的都是簿记。

输出尺寸公式

给定输入空间尺寸 H、核尺寸 K、填充 P、步幅 S:

H_out = floor( (H - K + 2P) / S ) + 1

把它背下来。你将在每个架构里算它几十遍。

场景 H K P S H_out
有效卷积,无填充 32 3 0 1 30
同卷积(保尺寸) 32 3 1 1 32
下采样 2 倍 32 3 1 2 16
2×2 池化 32 2 0 2 16
大感受野 32 7 3 2 16

「同填充」(same padding)的意思是:在 S == 1 时选 P 让 H_out == H。对于奇数 K,即 P = (K - 1) / 2。这正是 3×3 核能统治一切的原因——它是最小的、仍然带中心点的奇数核。

填充

不填充时,每次卷积都让特征图缩水。堆 20 层,你的 224×224 就变成 184×184,既浪费边缘算力,又让需要形状匹配的残差连接变复杂。

对 5 x 5 输入做零填充 (P = 1): 0 0 0 0 0 0 0 0 1 2 0 1 2 0 0 0 1 3 1 0 0 现在核可以中心对准像素 0 2 1 0 2 1 0 (0, 0),仍然有 3 行 0 1 0 2 1 3 0 3 列的值可以相乘。 0 2 1 1 0 1 0 0 0 0 0 0 0 0

实践里你会遇到的模式:zero(最常见)、reflect(镜像边缘,生成模型里避免硬边界)、replicate(复制边缘)、circular(环绕,用于环面问题)。

步幅

步幅是滑动的步长。stride=1 是默认。stride=2 把空间维度减半,是 CNN 内部下采样的经典手法——不需要单独的池化层。ResNet、ConvNeXt、MobileNet 等所有现代架构,都在某处用带步幅的卷积替代 max-pool。

5 x 5 输入、3 x 3 核、步幅 1: 起点:(0,0) (0,1) (0,2) -> 输出第 0 行 (1,0) (1,1) (1,2) -> 输出第 1 行 (2,0) (2,1) (2,2) -> 输出第 2 行 输出:3 x 3 同一输入、步幅 2: 起点:(0,0) (0,2) -> 输出第 0 行 (2,0) (2,2) -> 输出第 1 行 输出:2 x 2

多输入通道

真实图像有三个通道。在 RGB 输入上的 3×3 卷积其实是一个 3×3×3 的体积:每个输入通道一张 3×3 切片。在每个空间位置,你把三张切片都乘起来、求和,再加偏置。

输入: (C_in, H, W) 3 x 5 x 5 核: (C_in, K, K) 3 x 3 x 3(一个核) 输出: (1, H', W') 2D 特征图 要生成 C_out 个输出通道,就堆叠 C_out 个核: 权重: (C_out, C_in, K, K) 例如 64 x 3 x 3 x 3 输出: (C_out, H', W') 64 x 3 x 3 参数量: C_out * C_in * K * K + C_out (+ C_out 是偏置)

最后一行是你规划模型时要算的那行。在 3 通道输入上的 64 通道 3×3 卷积有 64 * 3 * 3 * 3 + 64 = 1,792 个参数。很便宜。

im2col 技巧

嵌套循环好读但慢。GPU 要的是大矩阵乘法。技巧是:把输入的每一个感受野窗口展平成大矩阵的一列,把核展平成一行,整个卷积就变成一次矩阵乘法

每一个生产级卷积实现都是这个思路的某种变体,外加缓存分块技巧(直接卷积、Winograd、用于大核的 FFT 卷积)。理解 im2col,就理解了它的核心。

感受野

单个 3×3 卷积看 9 个输入像素。堆两层 3×3,第二层的神经元看的就是 5×5 输入像素。三层 3×3 给 7×7。一般地:

L 层堆叠的 K x K 卷积(步幅 1)之后的感受野 RF = 1 + L * (K - 1) 带步幅时:每层的步幅让 RF 沿层倍增。

「全程 3×3」之所以行得通(VGG、ResNet、ConvNeXt),是因为两个 3×3 卷积看到的输入面积等于一个 5×5,但参数更少、中间还多一次非线性。

二、从零实现

步骤 1:给数组做填充

从最小原语开始:一个在 H×W 数组周围填零的函数。

import numpy as np def pad2d(x, p): if p == 0: return x h, w = x.shape[-2:] out = np.zeros(x.shape[:-2] + (h + 2 * p, w + 2 * p), dtype=x.dtype) out[..., p:p + h, p:p + w] = x return out x = np.arange(9).reshape(3, 3) print(x) print() print(pad2d(x, 1))

用末尾轴这个技巧 x.shape[:-2],意味着同一个函数不用改就能作用于 (H, W)(C, H, W)(N, C, H, W)

步骤 2:嵌套循环的 2D 卷积

参考实现——慢,但毫无歧义。这正是 torch.nn.functional.conv2d 在原理上做的事。

def conv2d_naive(x, w, b=None, stride=1, padding=0): c_in, h, w_in = x.shape c_out, c_in_w, kh, kw = w.shape assert c_in == c_in_w x_pad = pad2d(x, padding) h_out = (h + 2 * padding - kh) // stride + 1 w_out = (w_in + 2 * padding - kw) // stride + 1 out = np.zeros((c_out, h_out, w_out), dtype=np.float32) for oc in range(c_out): for i in range(h_out): for j in range(w_out): hs = i * stride ws = j * stride patch = x_pad[:, hs:hs + kh, ws:ws + kw] out[oc, i, j] = np.sum(patch * w[oc]) if b is not None: out[oc] += b[oc] return out

四重嵌套循环(输出通道、行、列,再加上对 C_in、kh、kw 的隐式求和)。这是你接下来校验每个更快实现的「真值」。

步骤 3:用手工设计的核验证

构造一个垂直 Sobel 核,作用在合成的阶跃图上,观察垂直边缘亮起来。

def synthetic_step_image(): img = np.zeros((1, 16, 16), dtype=np.float32) img[:, :, 8:] = 1.0 return img sobel_x = np.array([ [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]] ], dtype=np.float32)[None] x = synthetic_step_image() y = conv2d_naive(x, sobel_x, padding=1) print(y[0].round(1))

预期:第 7 列出现大的正值(从左到右亮度上升),其余处处为零。这一行打印就是「数学对了」的健全性检查。

步骤 4:im2col

把输入里每个核尺寸的窗口转成矩阵的一列。对于 C_in=3, K=3,每列是 27 个数。

def im2col(x, kh, kw, stride=1, padding=0): c_in, h, w = x.shape x_pad = pad2d(x, padding) h_out = (h + 2 * padding - kh) // stride + 1 w_out = (w + 2 * padding - kw) // stride + 1 cols = np.zeros((c_in * kh * kw, h_out * w_out), dtype=x.dtype) col = 0 for i in range(h_out): for j in range(w_out): hs = i * stride ws = j * stride patch = x_pad[:, hs:hs + kh, ws:ws + kw] cols[:, col] = patch.reshape(-1) col += 1 return cols, h_out, w_out

它仍然是 Python 循环,但重活接下来由一次向量化矩阵乘法完成。

步骤 5:用 im2col + matmul 做快速卷积

用一次矩阵乘法替换四重循环。

def conv2d_im2col(x, w, b=None, stride=1, padding=0): c_out, c_in, kh, kw = w.shape cols, h_out, w_out = im2col(x, kh, kw, stride, padding) w_flat = w.reshape(c_out, -1) out = w_flat @ cols if b is not None: out += b[:, None] return out.reshape(c_out, h_out, w_out)

正确性检查:跑两个实现并比较。

rng = np.random.default_rng(0) x = rng.normal(0, 1, (3, 16, 16)).astype(np.float32) w = rng.normal(0, 1, (8, 3, 3, 3)).astype(np.float32) b = rng.normal(0, 1, (8,)).astype(np.float32) y_naive = conv2d_naive(x, w, b, padding=1) y_im2col = conv2d_im2col(x, w, b, padding=1) print(f"max abs diff: {np.max(np.abs(y_naive - y_im2col)):.2e}")

max abs diff 应在 1e-5 量级——差异来自浮点累加顺序,不是 bug。

步骤 6:一整套手工设计的核

五个滤波器,展示一个未训练的卷积层能表达什么。

KERNELS = { "identity": np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.float32), "blur_3x3": np.ones((3, 3), dtype=np.float32) / 9.0, "sharpen": np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float32), "sobel_x": np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32), "sobel_y": np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32), } def apply_kernel(img2d, kernel): x = img2d[None].astype(np.float32) w = kernel[None, None] return conv2d_im2col(x, w, padding=1)[0]

作用于任意灰度图:模糊会柔化,锐化让边缘更清晰,Sobel-x 点亮垂直边缘,Sobel-y 点亮水平边缘。这些正是 AlexNet 和 VGG 里第一层训练后的卷积层最终学到的东西——因为一个好图像模型无论后面接什么任务,都需要边缘和色块检测器。

三、框架对比

PyTorch 的 nn.Conv2d 把同一个运算用 autograd、CUDA kernel 和 cuDNN 优化包装起来。形状语义完全一致。

import torch import torch.nn as nn conv = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, stride=1, padding=1) print(conv) print(f"weight shape: {tuple(conv.weight.shape)} # (C_out, C_in, K, K)") print(f"bias shape: {tuple(conv.bias.shape)}") print(f"param count: {sum(p.numel() for p in conv.parameters())}") x = torch.randn(8, 3, 224, 224) y = conv(x) print(f"\ninput shape: {tuple(x.shape)}") print(f"output shape: {tuple(y.shape)}")

padding=1 换成 padding=0,输出掉到 222×222;把 stride=1 换成 stride=2,掉到 112×112。用的就是你上面背的那条公式。

四、可复用产物

本节产出两个可复用文件(位于原课程 outputs/):

  • prompt-cnn-architect.md:一个提示词——给定输入尺寸、参数预算和目标感受野,设计一串 Conv2d 层,每一步给出正确的 K/S/P。
  • skill-conv-shape-calculator.md:一个技能——逐层走完一个网络规格,返回每一块的输出形状、感受野和参数量。

五、练习

  1. (简单) 给定 128×128 灰度输入和一串 [Conv3x3(s=1,p=1), Conv3x3(s=2,p=1), Conv3x3(s=1,p=1), Conv3x3(s=2,p=1)],手算每一层的输出空间尺寸和感受野。用一个由哑卷积组成的 PyTorch nn.Sequential 验证。
  2. (中等)conv2d_naiveconv2d_im2col 加上 groups 参数。证明 groups=C_in=C_out 重现了一个深度卷积(depthwise conv),且参数量是 C * K * K 而非 C * C * K * K
  3. (困难) 手写 conv2d_im2col 的反向传播:给定输出的梯度,计算 xw 的梯度。用 torch.autograd.grad 在相同输入和权重上验证。窍门:im2col 的梯度是 col2im,必须累加重叠窗口。

本节要点回顾

  1. 卷积 = 共享权重 + 局部性 + 滑动窗口——同一个微小全连接层在每个位置复用,白送平移等变性和参数共享。
  2. 输出尺寸公式——H_out = floor((H - K + 2P) / S) + 1,背下来,每个架构都要算几十遍。
  3. 3×3 统治一切——最小的、带中心点的奇数核;同填充用 P = (K - 1) / 2
  4. 填充四模式——zero(最常见)、reflect(生成模型)、replicate、circular。
  5. 步幅下采样——stride=2 减半空间维度,现代架构普遍用它替代 max-pool。
  6. 多通道卷积——权重张量是 (C_out, C_in, K, K),每个输出通道是一组核;参数量 C_out * C_in * K * K + C_out
  7. im2col 是核心——把每个窗口展平成列,卷积就变成一次大矩阵乘法,这是每个快速卷积内核(GEMM)的灵魂。
  8. 感受野随深度增长——L 层 K×K(步幅 1)感受野为 1 + L*(K-1);两个 3×3 等于一个 5×5,但更省参数且多一次非线性。
  9. 未训练就能表达——模糊、锐化、Sobel 这些经典核,正是训练后第一层卷积学到的东西。
  10. nn.Conv2d 与之等价——形状语义一致,只是多了 autograd 与 cuDNN 加速。

下一节,我们把单层卷积堆叠成真正的网络,沿 LeNet → AlexNet → VGG → ResNet 的脉络,看清「为什么深」与「为什么残差连接让深成为可能」。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U