- 仅使用 NumPy 从零实现 2D 卷积,包括嵌套循环版本和向量化的
im2col版本 - 对任意输入尺寸、核尺寸、填充和步幅组合计算输出空间尺寸,并说明
(H - K + 2P) / S + 1公式的由来 - 手工设计卷积核(边缘、模糊、锐化、Sobel),并解释为什么每个核会产生对应的激活模式
- 把卷积堆叠成特征提取器,并把堆叠深度与感受野大小关联起来
问题
对一张 224x224 RGB 图像,全连接层的每个神经元需要 224 224 3 = 150,528 个输入权重。一个只有 1,000 个单元的隐藏层就已经是 1.5 亿参数——在学到任何有用东西之前。更糟的是,这层完全不知道左上角的狗和右下角的狗是同一个模式。它把每个像素位置当成独立的,这对图像来说是完全错误的:把猫平移三个像素,不应该强迫网络重新学习这个概念。
图像模型需要的两个性质是平移等变性(输入平移时输出也平移)和参数共享(同一个特征检测器到处运行)。全连接层两者都不给。卷积同时免费提供两者。
卷积不是为深度学习发明的。JPEG 压缩、Photoshop 里的高斯模糊、工业视觉里的边缘检测、以及所有已发布的音频滤波器,都靠同一个操作。CNN 从 2012 到 2020 年主导 ImageNet 的原因,就是卷积对「邻近值相关、同一模式可以出现在任何位置」的数据来说是正确先验。
概念
一个核,滑动
2D 卷积取一个叫核(或滤波器)的小权重矩阵,把它滑过输入,在每个位置计算逐元素乘积之和。这个和成为一个输出像素。
flowchart LR
subgraph IN["Input (H x W)"]
direction LR
I1["5 x 5 image"]
end
subgraph K["Kernel (3 x 3)"]
K1["learned<br/>weights"]
end
subgraph OUT["Output (H-2 x W-2)"]
O1["3 x 3 map"]
end
I1 --> |"slide kernel<br/>compute dot product<br/>at each position"| O1
K1 --> O1
style IN fill:#dbeafe,stroke:#2563eb
style K fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a一个具体例子:3x3 核作用在 5x5 输入上(无填充,步幅 1):
Input X (5 x 5): Kernel 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
The kernel slides across every valid 3 x 3 window. Output Y is 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] )
... and so on这一公式——共享权重、局部性、滑动窗口——就是全部思想。其余都是记账。
输出尺寸公式
给定输入空间尺寸 H、核尺寸 K、填充 P、步幅 S:
记住它。你每个架构都要算几十次。
| Scenario | H | K | P | S | H_out |
|---|---|---|---|---|---|
| Valid conv, no padding | 32 | 3 | 0 | 1 | 30 |
| Same conv (preserves size) | 32 | 3 | 1 | 1 | 32 |
| Downsample by 2 | 32 | 3 | 1 | 2 | 16 |
| Pool 2x2 | 32 | 2 | 0 | 2 | 16 |
| Large receptive field | 32 | 7 | 3 | 2 | 16 |
「Same padding」指选择 P 使得当 S = 1 时 H_out = H。对奇数 K,即 P = (K - 1) / 2。这就是 3x3 核占主导的原因——它们是仍然拥有中心的最小奇数核。
填充
没有填充时,每次卷积都会缩小特征图。堆叠 20 层后,224x224 图像变成 184x184,这会浪费边界上的计算,也让需要匹配形状残差连接变复杂。
Zero padding (P = 1) on a 5 x 5 input:
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 Now the kernel can centre on pixel
0 1 0 2 1 3 0 (0, 0) and still have three rows and
0 2 1 1 0 1 0 three columns of values to multiply.
0 0 0 0 0 0 0实践中常见的模式:zero(最常用)、reflect(镜像边缘,避免生成模型中的硬边界)、replicate(复制边缘)、circular(环绕,用于环面问题)。
步幅
步幅是滑动的步长。stride=1 是默认值。stride=2 把空间尺寸减半,是 CNN 里不借助单独池化层而进行下采样的经典方式——现代架构(ResNet、ConvNeXt、MobileNet)都在某处用步幅卷积代替 max-pool。
Stride 1 on a 5 x 5 input, 3 x 3 kernel:
starts: (0,0) (0,1) (0,2) -> output row 0
(1,0) (1,1) (1,2) -> output row 1
(2,0) (2,1) (2,2) -> output row 2
Output: 3 x 3
Stride 2 on the same input:
starts: (0,0) (0,2) -> output row 0
(2,0) (2,2) -> output row 1
Output: 2 x 2多输入通道
真实图像有三个通道。RGB 输入上的 3x3 卷积实际上是一个 3x3x3 的体积:每个输入通道对应一个 3x3 切片。在每个空间位置,你跨三个切片逐元素相乘求和,再加一个偏置。
Input: (C_in, H, W) 3 x 5 x 5
Kernel: (C_in, K, K) 3 x 3 x 3 (one kernel)
Output: (1, H', W') 2D map
For a layer that produces C_out output channels, you stack C_out kernels:
Weight: (C_out, C_in, K, K) e.g. 64 x 3 x 3 x 3
Output: (C_out, H', W') 64 x 3 x 3
Parameter count: C_out * C_in * K * K + C_out (the + C_out is biases)最后一行是你在规划模型时会计算的。一个 3 通道输入上的 64 通道 3x3 卷积有 64 * 3 * 3 * 3 + 64 = 1,792 个参数。很便宜。
im2col 技巧
嵌套循环易读但慢。GPU 喜欢大矩阵乘法。技巧是:把输入的每个感受野窗口摊平成大矩阵的一列,把核摊平成一行,整个卷积就变成一次矩阵乘法。
flowchart LR
X["Input<br/>(C_in, H, W)"] --> IM2COL["im2col<br/>(extract patches)"]
IM2COL --> COLS["Cols matrix<br/>(C_in * K * K, H_out * W_out)"]
W["Weight<br/>(C_out, C_in, K, K)"] --> FLAT["Flatten<br/>(C_out, C_in * K * K)"]
FLAT --> MM["matmul"]
COLS --> MM
MM --> OUT["Output<br/>(C_out, H_out * W_out)<br/>reshape to (C_out, H_out, W_out)"]
style X fill:#dbeafe,stroke:#2563eb
style W fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a每个生产级卷积实现都是它的某种变体,加上缓存分块技巧(direct conv、Winograd、大核 FFT 卷积)。理解 im2col 就理解了核心。
感受野
单个 3x3 卷积看 9 个输入像素。堆叠两个 3x3 卷积,第二层的神经元看 5x5 输入像素。三个 3x3 卷积给出 7x7。一般地:
RF after L stacked K x K convs (stride 1) = 1 + L * (K - 1)
With strides: RF grows multiplicatively with stride along each layer.「全程 3x3」之所以有效(VGG、ResNet、ConvNeXt),是因为两个 3x3 卷积看到的输入区域与一个 5x5 卷积相同,但参数更少,中间还多了一个非线性。
构建它
第 1 步:对数组填充
从最小原语开始:一个对 H x W 数组做零填充的函数。
pythonimport 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 在原理上的做法。
pythondef 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 求和)。这是你会用来检验每个更快实现的 ground truth。
第 3 步:用手工设计的核验证
构造一个垂直 Sobel 核,作用在合成阶梯图像上,观察垂直边缘被点亮。
pythondef 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 列出现较大正值(从左到右亮度增加),其余地方接近零。这一行打印就是数学正确的 sanity check。
第 4 步:im2col
把输入中每个核大小的窗口转换成矩阵的一列。对 C_in=3, K=3,每列是 27 个数。
pythondef 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 实现快速卷积
用一次矩阵乘法取代四重循环。
pythondef 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)正确性检查:运行两种实现并比较。
pythonrng = 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 步:一组手工设计的核
五个滤波器展示单个卷积层在训练之前能表达什么。
pythonKERNELS = {
"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 优化。形状语义完全一致。
pythonimport 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)} TOK0
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,输出降到 222x222。把 stride=1 换成 stride=2,输出降到 112x112。就是你上面记住的同一个公式。
交付它
本节课产出:
outputs/prompt-cnn-architect.md—— 一个 prompt,给定输入尺寸、参数预算和目标感受野,设计出一层Conv2d堆叠,每一步的 K/S/P 都正确。outputs/skill-conv-shape-calculator.md—— 一个 skill,逐层遍历网络规格,返回每个块的输出形状、感受野和参数数量。
练习
- (简单) 给定 128x128 灰度输入和堆叠
[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 卷积,且其参数量为C * K * K而不是C * C * K * K。 - (困难) 手写
conv2d_im2col的反向传播:给定输出梯度,计算x和w的梯度。用torch.autograd.grad在相同输入和权重上验证。技巧在于:im2col 的梯度是col2im,它必须累加重叠的窗口。
关键术语
| Term | What people say | What it actually means |
|---|---|---|
| Convolution | "Sliding a filter" | A learnable dot product applied at every spatial location with shared weights; mathematically a cross-correlation, but everyone calls it convolution |
| Kernel / filter | "The feature detector" | A small weight tensor of shape (C_in, K, K) whose dot product with a window of input produces one output pixel |
| Stride | "How far you jump" | The step size between consecutive kernel placements; stride 2 halves each spatial dimension |
| Padding | "Zeros on the edges" | Extra values added around the input so the kernel can centre on border pixels; same padding keeps output size equal to input size |
| Receptive field | "How much the neuron sees" | The patch of original input that a given output activation depends on, growing with depth and stride |
| im2col | "The GEMM trick" | Rearranging every receptive window into columns so convolution becomes one big matrix multiply — the core of every fast conv kernel |
| Depthwise conv | "One kernel per channel" | A conv with groups == C_in, computing each output channel from only its matching input channel; the backbone of MobileNet and ConvNeXt |
| Translation equivariance | "Shift in, shift out" | Property that shifting the input by k pixels shifts the output by k pixels; comes for free with shared weights |
延伸阅读
- A guide to convolution arithmetic for deep learning (Dumoulin & Visin, 2016) —— 关于填充 / 步幅 / 扩张的权威图示,每个课程都在悄悄复用
- CS231n: Convolutional Neural Networks for Visual Recognition —— 经典讲义,包含原始 im2col 解释
- The Annotated ConvNet (fast.ai) —— 从手工卷积走到训练好的数字分类器的 notebook
- Receptive Field Arithmetic for CNNs (Dang Ha The Hien) —— 感受野计算的论文级交互式讲解