从零实现卷积 本节摘要:卷积,就是一个你在图像上滑动的微型全连接层——在每一个位置复用同一组权重。本节从最原始的四重嵌套循环开始,纯 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)。
阅读完本节,你应当能够:
im2col 版。(H - K + 2P) / S + 1 这个公式。对一张 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 个参数。很便宜。
嵌套循环好读但慢。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,但参数更少、中间还多一次非线性。
从最小原语开始:一个在 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)。
参考实现——慢,但毫无歧义。这正是 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 的隐式求和)。这是你接下来校验每个更快实现的「真值」。
构造一个垂直 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 列出现大的正值(从左到右亮度上升),其余处处为零。这一行打印就是「数学对了」的健全性检查。
把输入里每个核尺寸的窗口转成矩阵的一列。对于 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 循环,但重活接下来由一次向量化矩阵乘法完成。
用一次矩阵乘法替换四重循环。
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。
五个滤波器,展示一个未训练的卷积层能表达什么。
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:一个技能——逐层走完一个网络规格,返回每一块的输出形状、感受野和参数量。[Conv3x3(s=1,p=1), Conv3x3(s=2,p=1), Conv3x3(s=1,p=1), Conv3x3(s=2,p=1)],手算每一层的输出空间尺寸和感受野。用一个由哑卷积组成的 PyTorch nn.Sequential 验证。conv2d_naive 和 conv2d_im2col 加上 groups 参数。证明 groups=C_in=C_out 重现了一个深度卷积(depthwise conv),且参数量是 C * K * K 而非 C * C * K * K。conv2d_im2col 的反向传播:给定输出的梯度,计算 x 和 w 的梯度。用 torch.autograd.grad 在相同输入和权重上验证。窍门:im2col 的梯度是 col2im,必须累加重叠窗口。H_out = floor((H - K + 2P) / S) + 1,背下来,每个架构都要算几十遍。P = (K - 1) / 2。stride=2 减半空间维度,现代架构普遍用它替代 max-pool。(C_out, C_in, K, K),每个输出通道是一组核;参数量 C_out * C_in * K * K + C_out。1 + L*(K-1);两个 3×3 等于一个 5×5,但更省参数且多一次非线性。nn.Conv2d 与之等价——形状语义一致,只是多了 autograd 与 cuDNN 加速。下一节,我们把单层卷积堆叠成真正的网络,沿 LeNet → AlexNet → VGG → ResNet 的脉络,看清「为什么深」与「为什么残差连接让深成为可能」。