从零实现卷积

Build~75 minutesPython先修要求: Phase 3 (Deep Learning Core), Phase 4 Lesson 01 (Image Fundamentals)
卷积是一个在图像上滑动的小密集层,在每个位置共享同一组权重。
学习目标
  • 仅使用 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:

H_{\text{out}} = \left\lfloor \frac{H - K + 2P}{S} \right\rfloor + 1

记住它。你每个架构都要算几十次。

ScenarioHKPSH_out
Valid conv, no padding3230130
Same conv (preserves size)3231132
Downsample by 23231216
Pool 2x23220216
Large receptive field3273216

「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 卷积相同,但参数更少,中间还多了一个非线性。

convolution-kernel静态版本未包含此交互图

构建它

第 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,逐层遍历网络规格,返回每个块的输出形状、感受野和参数数量。

练习

  1. (简单) 给定 128x128 灰度输入和堆叠 [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_naive 和 conv2d_im2col,使其接受 groups 参数。证明 groups=C_in=C_out 复现 depthwise 卷积,且其参数量为 C * K * K 而不是 C * C * K * K。
  3. (困难) 手写 conv2d_im2col 的反向传播:给定输出梯度,计算 x 和 w 的梯度。用 torch.autograd.grad 在相同输入和权重上验证。技巧在于:im2col 的梯度是 col2im,它必须累加重叠的窗口。

关键术语

TermWhat people sayWhat 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

延伸阅读

“Stay hungry, stay foolish.”

— Steve Jobs

esc
↑↓切换↵打开esc关闭