把全连接层换成卷积层,参数量会掉几个数量级,而效果反而更好。原因不在于卷积「更先进」,在于它对图像做了三个假设:特征是局部的、同一个特征在哪个位置都算数、以及像素之间的空间关系有意义。这三条恰好是全连接层完全没有利用的信息。
这篇先算清楚输出尺寸的公式(padding 和 stride 各自在里面起什么作用),再讲感受野——一个输出像素到底「看到」了原图多大一块区域,以及为什么它随层数增长得比直觉慢。最后用 Numpy 手写一遍二维卷积,把前面的公式落到代码上。
一、卷积输出尺寸的数学公式
当您在输入张量上滑动卷积核时,输出特征图的空间尺寸取决于四个因素:输入尺寸 ($W, H$)、卷积核大小 ($K$)、填充/Padding ($P$) 和步长/Stride ($S$)。
计算输出尺寸的通用公式为:
Output_Size = floor((Input_Size + 2 * Padding - Kernel_Size) / Stride) + 1
我们来拆解一个经典场景:输入图像为 5x5,卷积核为 3x3,填充为 0 (Valid 卷积),步长为 1。代入公式:
floor((5 + 2*0 - 3) / 1) + 1 = 3
因此,输出的特征图精确地为 3x3。如果我们希望输出保持 5x5 的尺寸,则需要添加 1 的填充 (Same 卷积),假设步长依然为 1。
二、手算单个输出像素点
卷积的一个输出值,等于输入的一个局部图像块(Patch)与卷积核矩阵之间逐元素相乘后的总和。这本质上就是一个点积操作。
实验包输出的 conv2d-results.csv 显示中间位置 row=1,col=1 的结果是 -1.000000。如果您手动把高亮的 3x3 区域和卷积核对齐相乘并求和,也会得到一模一样的结果。
三、不断扩张的感受野 (Receptive Field)
感受野 (Receptive Field, RF) 是指神经网络中某个特征影响了原始输入空间多大的区域。单层 3x3 卷积只能看到 3x3 的局部区域。然而,深度神经网络堆叠了多个卷积层,网络到底是如何“看懂”整张图片的呢?
当你在第一层上再堆叠一个 3x3 卷积层时,第二层的一个输出神经元连接到第一层隐藏层中 3x3 的区域。但是,这 9 个神经元中的每一个,都分别连接到原始输入中 3x3 的区域。因此,第二层中的单个神经元在原始输入上具有 5x5 的有效感受野。
graph TD
sublayer_2["第 2 层 (1x1 输出)"] --> sublayer_1["第 1 层 (3x3 特征图)"]
sublayer_1 --> input["原始输入 (5x5 感受野)"]
style sublayer_2 fill:#f9f,stroke:#333,stroke-width:2px
style sublayer_1 fill:#bbf,stroke:#333,stroke-width:2px
style input fill:#bfb,stroke:#333,stroke-width:2px
在数学上,第 $l$ 层的感受野尺寸 $RF_l$ 可以用以下公式计算:
RF_l = RF_{l-1} + (Kernel_Size_l - 1) * Stride_Product_{i=1 to l-1}
这就解释了为什么卷积网络可以从检测微小的边缘开始,逐渐组合以识别复杂的纹理、形状,最终识别出人脸或汽车等完整对象。
四、代码实现:im2col 与矩阵乘法
在工程实践中,使用嵌套循环(滑动窗口)遍历图像的速度极其缓慢。现代深度学习框架(如 PyTorch 和 TensorFlow)通过将卷积转换为庞大的矩阵乘法来对该操作进行向量化加速。这种技术被称为 im2col (Image to Column)。
im2col 会从输入图像中提取每个局部块(Patch),将其展平为一维向量,并将它们堆叠成一个大型矩阵。卷积核也会被展平。随后,卷积操作就变成了一次高度优化的矩阵乘法 (GEMM)。
import numpy as np
def conv2d_im2col(image, kernel, stride=1):
"""
一个使用 im2col 实现 2D 卷积的实用 Numpy 代码。
"""
h_in, w_in = image.shape
k_h, k_w = kernel.shape
# 计算输出尺寸
out_h = (h_in - k_h) // stride + 1
out_w = (w_in - k_w) // stride + 1
# 提取图像块 (im2col 步骤)
# cols 矩阵的形状: (out_h * out_w, k_h * k_w)
cols = []
for r in range(0, h_in - k_h + 1, stride):
for c in range(0, w_in - k_w + 1, stride):
patch = image[r:r+k_h, c:c+k_w]
cols.append(patch.reshape(-1))
im_matrix = np.vstack(cols)
# 展平卷积核
weight_matrix = kernel.reshape(-1, 1)
# 执行矩阵乘法
result = im_matrix @ weight_matrix
# 将结果 reshape 回特征图的尺寸
return result.reshape(out_h, out_w)
# 测试实现
test_img = np.arange(25).reshape(5, 5)
test_kernel = np.ones((3, 3))
output = conv2d_im2col(test_img, test_kernel)
print("输出形状:", output.shape)
print(output)
对于 5x5 输入和 3x3 卷积核,共有 9 个有效图像块,每个块包含 9 个值。生成的 im_matrix 形状将是 (9, 9)。
五、卷积维度检查表
卷积代码最常见的问题不是公式不会写,而是张量维度在某一层悄悄错了。下面这张表可以在调试模型时逐层检查。
| 检查项 | 应该确认什么 | 常见错误 |
|---|---|---|
| 输入格式 | NCHW 还是 NHWC | 通道维和高度维写反 |
| 输出尺寸 | 是否符合公式 | padding 或 stride 少写导致 flatten 失败 |
| 感受野 | 深层特征是否覆盖足够输入区域 | 网络太浅,只能看到局部纹理 |
| 参数量 | out_channels * in_channels * k_h * k_w |
通道数暴涨导致显存超限 |
| 边界填充 | zero / reflect / replicate 是否符合任务 | 边缘目标预测明显差 |
六、动画演示核心过程
在观看动画时,请注意两个核心属性:相同的卷积核权重在许多空间位置被重复使用(这就是权重共享),并且每个输出最初只“看到”输入的一个局部区域(这就是局部连接)。
七、个人经验与工程师视角
在真实的业务场景中使用卷积神经网络,会遇到一些单看数学公式很难发现的工程挑战:
显存与算力的博弈:
im2col方法非常巧妙,它通过矩阵乘法充分压榨 GPU 核心的算力,但代价极其昂贵:内存复制冗余。通过提取重叠的图像块,im2col会成倍地膨胀输入张量的显存占用。如果您处理的是大型医疗影像(如 3D CT 扫描),调用im2col很容易直接导致 Out-Of-Memory (OOM) 报错。在生产级的 C++/CUDA 开发中,我们通常采用显存效率更高的隐式 GEMM (Implicit GEMM) 或 Winograd 算法。
- 维度不匹配的噩梦: 初级工程师遇到的最常见的错误,就是从最后一个卷积层过渡到第一个全连接(Dense)层时,突然跳出
RuntimeError: size mismatch。在执行flatten()操作之前,务必养成习惯:打印张量形状,或者使用输出尺寸公式手动验算最后的尺寸。 - 棋盘伪影 (Checkerboard Artifacts): 在生成对抗网络 (GANs) 中使用转置卷积(常被误称为反卷积)进行上采样时,您经常会遇到棋盘状的伪影。当卷积核的大小不能被步长整除时,就会发生这种现象。我常用的一个实用解决方案是:放弃转置卷积,改用“最近邻插值上采样 (Nearest-neighbor upsample) + 标准步长为 1 的普通卷积”组合来替代。
- Padding 对边缘的影响: 补零 (Zero-padding) 是框架的默认选项,但它人为地在特征图中引入了黑色边缘。如果您发现模型在处理图像边缘的对象时性能不佳,可以考虑将其切换为 “Reflect” 或 “Replicate” 填充模式。
下一篇,我们将离开死板、局部连接的卷积,进入 Transformer 中极其灵活、基于全局 Token-to-Token 交互的注意力机制世界。