import dnnlpy
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
dnnlpy.set_matplotlib_format('highdpi')
print('PyTorch version:', torch.__version__)PyTorch version: 2.13.0+cpu
jshn9515
2026-06-30
2026-06-30
在前面的多层感知机中,我们已经学会了如何把一个输入向量送入神经网络,并通过线性层和激活函数逐层提取表示。对于表格数据或已经整理好的特征向量,这种做法非常自然:每个样本都可以写成一个固定长度的向量,网络只需要学习不同输入特征之间的组合关系。
图像似乎也可以用同样的方法处理。一张灰度图像本质上是一组像素值,只要把二维像素矩阵展平成一个长向量,就可以直接送入 MLP。事实上,在 MNIST 这样的小型数据集上,一个普通 MLP 也确实能够完成手写数字分类。
但问题是,图像并不只是一个很长的向量。
图像中的像素排列具有明确的空间含义。相邻像素通常属于同一个局部结构,一条边缘可能出现在图像中的任何位置,而更复杂的纹理和物体又是由这些局部模式逐层组合出来的。如果直接把图像展平,MLP 虽然仍然能看到所有像素值,却没有显式利用这些结构。
卷积神经网络(Convolutional Neural Network, CNN)的出发点,就是把图像的空间结构直接写进网络的连接方式中。它不是简单地给 MLP 换一个名字,而是重新思考:处理图像时,一个神经元究竟应该看哪些输入?相同的局部模式出现在不同位置时,是否真的需要重新学习一套参数?
这一节我们先不急着推导卷积的完整公式,而是从 MLP 的局限出发,理解 CNN 为什么会采用局部连接和权重共享,以及这些设计为什么特别适合图像。
PyTorch version: 2.13.0+cpu
假设我们有一张 \(28 \times 28\) 的灰度图像。由于每个位置只有一个像素值,它可以写成:
\[ X \in \mathbb{R}^{28\times 28} \]
如果想把它交给 MLP,通常需要先把二维图像展平成长度为 784 的向量:
\[ \operatorname{flatten}(X) \in \mathbb{R}^{784} \]
从数据类型上看,这并没有问题。二维矩阵中的所有像素值仍然保留在展平后的向量里,没有任何数值被删除。但是,数据的表示方式发生了一个重要变化:原本明确的二维邻接关系不再直接体现在张量形状中。
例如,在原图像中,位置 \((i,j)\) 附近的像素是 \((i-1,j)\)、\((i+1,j)\)、\((i,j-1)\) 和 \((i,j+1)\)。这些位置在空间上彼此接近,通常也具有较强相关性。图像展平以后,它们只变成向量中的若干索引。MLP 并不知道哪些索引原本彼此相邻,也不知道一组像素可能共同构成边缘、角点或纹理。
我们可以用一个简单图案观察展平前后的区别。
image = torch.zeros(8, 8)
image[1:7, 3:5] = 1.0
flattened = image.view(1, -1)
flattened = flattened.repeat(image.numel(), 1)
fig = plt.figure(1, figsize=(6, 3))
ax1 = fig.add_subplot(1, 2, 1)
ax1.imshow(image, cmap='gray', vmin=0, vmax=1)
ax1.set_xticks([])
ax1.set_yticks([])
ax1.set_title(r'Image: $8 \times 8$')
ax2 = fig.add_subplot(1, 2, 2)
ax2.imshow(flattened, cmap='gray', vmin=0, vmax=1)
ax2.set_aspect('equal')
ax2.set_yticks([])
ax2.set_xlabel('Feature Index')
ax2.set_title('Flattened: 64')
plt.show()
左边的图像中,我们很容易看出中间存在一条竖直结构。右边虽然保留了完全相同的数值,但这种结构已经不再直观。对于 MLP 来说,它需要从训练数据中自己学出哪些输入索引应该组合在一起,而网络结构本身没有提供任何关于二维空间的提示。这就是图像和普通向量之间最重要的区别之一:图像不仅包含像素值,还包含像素之间的空间关系。
展平图像带来的另一个现实问题,是全连接层的参数数量会随着输入分辨率迅速增长。
对于一个输入维度为 \(d_{\text{in}}\)、输出维度为 \(d_{\text{out}}\) 的线性层,其权重矩阵形状为:
\[ W\in\mathbb{R}^{d_{\text{out}}\times d_{\text{in}}} \]
忽略 bias 后,参数数量就是:
\[ d_{\text{in}} \times d_{\text{out}} \]
对于 \(28 \times 28\) 的灰度图像,如果第一层包含 512 个隐藏单元,那么参数数量为:
\[ 28 \times 28 \times 512 = 401,408 \]
这个规模还可以接受。但如果输入换成一张 \(3 \times 224 \times 224\) 的彩色图像,展平后的输入维度变成:
\[ 3 \times 224 \times 224 = 150,528 \]
同样连接到 512 个隐藏单元时,仅第一层的权重参数就达到:
\[ 150,528 \times 512 = 77,070,336 \]
我们可以直接用 PyTorch 验证这个数量。
MNIST input layer: 401,408 parameters.
ImageNet input layer: 77,070,336 parameters.
这里的问题不只是参数多。全连接层中的每个输出神经元都要为每一个输入像素保存独立权重。这意味着输入分辨率稍微增大,参数量就会快速膨胀,训练所需的计算量、显存和数据量也会随之增加。
更重要的是,这些大量参数并没有利用图像中最明显的先验:很多有用模式只存在于局部区域,而不是要求每个神经元从一开始就同时观察整张图像。
观察一张自然图像时,我们通常不会先把所有像素放在一起理解。很多最基础的视觉信息都来自局部区域,例如:
也就是说,视觉特征具有明显的层次结构,但最底层的信息通常来自一个很小的邻域。
假设我们想判断图像中某个位置附近是否存在竖直边缘。这个任务并不需要一开始就查看图像另一端的像素,只需要比较当前位置附近左右两侧的亮度即可。让一个神经元连接整张图像,不仅浪费参数,也没有体现这个任务的局部性。
CNN 因此采用了局部连接(local connectivity)。一个卷积输出位置只连接输入中的一个局部窗口。例如,一个 \(3\times 3\) 的窗口只观察当前区域周围的 9 个位置,而不是整张图像。
Connections used by one fully connected unit: 64
Connections used by one local 3x3 unit: 9
对于 \(8 \times 8\) 的输入,一个全连接神经元需要连接 64 个输入位置,而一个 \(3 \times 3\) 局部窗口只需要连接 9 个位置。当图像变得更大时,局部窗口的大小仍然可以保持不变,因此单个输出位置需要处理的输入数量不会随着整张图像的面积一起增长。
这种设计把一个非常有用的假设写进了网络:
附近的像素通常比相距很远的像素更直接相关。
这个假设称为网络的归纳偏置(inductive bias)。它并不是说远距离关系不重要,而是说视觉建模可以先从局部结构开始,再通过多层网络逐渐扩大能够看到的范围。
仅仅使用局部连接仍然不够。
假设图像左上角和右下角都可能出现一条竖直边缘。如果我们为每个位置分别学习一组局部参数,那么左上角的边缘检测器和右下角的边缘检测器仍然互不相关。即使它们要识别的是同一种模式,网络也需要在不同位置重复学习。但图像中的很多局部模式具有位置无关性。一条竖直边缘无论出现在左边、右边、上方还是下方,本质上仍然是一条竖直边缘。因此,更合理的做法是:让同一个局部检测器在整张图像上重复使用。
这就是卷积中的权重共享(weight sharing)。
卷积层会学习一个小型权重窗口,通常称为卷积核(kernel)或滤波器(filter)。这个卷积核在图像上滑动,并在每个位置执行相同的局部计算。于是,同一组参数可以在不同空间位置检测同一种模式。
为了直观地观察这种效果,我们先手动构造一个简单的竖直边缘检测器。这里暂时使用 nn.Conv2d 完成计算,卷积层的具体公式、张量形状和从零实现会留到后面的章节。
image = torch.zeros(1, 1, 32, 32)
image[..., 16:] = 1.0
kernel = torch.tensor(
[
[-1.0, 0.0, 1.0],
[-1.0, 0.0, 1.0],
[-1.0, 0.0, 1.0],
]
).view(1, 1, 3, 3)
response = F.conv2d(image, kernel)
fig = plt.figure(2, figsize=(6, 3))
ax1 = fig.add_subplot(1, 2, 1)
ax1.imshow(image[0, 0], cmap='gray')
ax1.set_xticks([])
ax1.set_yticks([])
ax1.set_title('Input Image')
ax2 = fig.add_subplot(1, 2, 2)
ax2.imshow(response[0, 0], cmap='gray')
ax2.set_xticks([])
ax2.set_yticks([])
ax2.set_title('Vertical Edge Response')
plt.show()
这个卷积核只包含 9 个权重,但它可以被应用到输入的所有局部位置。如果把竖直边缘移动到图像中的另一个位置,同一个卷积核仍然能够产生响应,不需要为新位置重新定义一组权重。
相比之下,全连接层默认会为每个输入位置学习独立参数。它当然也有可能通过训练学出类似行为,但这种规律并没有被网络结构直接保证,通常需要更多参数和更多数据才能学习出来。
局部连接和权重共享结合起来,构成了 CNN 最核心的设计:
权重共享还带来了一个非常重要的性质:平移等变性(translation equivariance)。
假设输入图像中的某个模式向右移动了一些位置。如果我们使用同一个卷积核扫描整张图像,那么输出中的响应通常也会向右移动相同的位置。可以写成:
\[ f(T(X)) = T(f(X)) \]
其中,\(T\) 表示平移操作,\(f\) 表示卷积计算。
“等变”并不意味着输出完全不变。输入中的边缘移动以后,输出中的边缘响应也会移动。真正保持的是输入变化和输出变化之间的对应关系。
我们可以用同一个卷积核分别处理两个仅位置不同的图像。
image_left = torch.zeros(1, 1, 32, 32)
image_left[..., 10:] = 1.0
image_right = torch.zeros(1, 1, 32, 32)
image_right[..., 20:] = 1.0
response_left = F.conv2d(image_left, kernel)
response_right = F.conv2d(image_right, kernel)
fig = plt.figure(3, figsize=(6, 6))
ax1 = fig.add_subplot(2, 2, 1)
ax1.imshow(image_left[0, 0], cmap='gray')
ax1.set_title('Input: Edge on the Left')
ax2 = fig.add_subplot(2, 2, 2)
ax2.imshow(response_left[0, 0], cmap='gray')
ax2.set_title('Response')
ax3 = fig.add_subplot(2, 2, 3)
ax3.imshow(image_right[0, 0], cmap='gray')
ax3.set_title('Input: Edge on the Right')
ax4 = fig.add_subplot(2, 2, 4)
ax4.imshow(response_right[0, 0], cmap='gray')
ax4.set_title('Shifted Response')
for ax in [ax1, ax2, ax3, ax4]:
ax.set_xticks([])
ax.set_yticks([])
plt.show()
当输入中的边缘向右移动时,卷积输出中的响应也随之向右移动。这种性质非常适合视觉任务,因为物体或局部特征并不会永远出现在固定位置。
需要注意,平移等变性和平移不变性不是同一个概念:
卷积本身主要提供平移等变性。图像分类最终希望模型对物体的小范围位置变化不那么敏感,这种近似平移不变性通常还需要池化、下采样、全局平均池化以及训练数据增强等机制共同实现。
现在可以重新比较全连接层和卷积层的参数数量。
假设输入是一张 RGB 图像,有 3 个输入通道。我们希望得到 64 个输出特征通道,并使用 \(3 \times 3\) 的卷积核。卷积层的权重形状为:
\[ 64 \times 3 \times 3 \times 3 \]
因此忽略 bias 时,参数数量只有:
\[ 64 \times 3 \times 3 \times 3 = 1,728 \]
无论输入图像是 \(32 \times 32\)、\(224 \times 224\),还是更高分辨率,只要输入通道数、输出通道数和卷积核大小不变,卷积层的参数数量就不会改变。
Linear layer: 9,633,792 parameters.
Conv2d layer: 1,728 parameters.
二者的区别来自连接方式:
这并不表示卷积层一定比线性层更强。相反,卷积层主动限制了连接方式。正是这种限制让它更适合图像:网络不需要从零学习“局部像素更相关”和“同一种模式可以出现在不同位置”这些规律,因为它们已经被写进了模型结构。
一个卷积层只能处理有限大小的局部区域,那么 CNN 如何识别覆盖整张图像的复杂物体?
关键在于堆叠多层卷积。
卷积核在输入上滑动后,会生成一个二维输出,每个位置表示该局部区域对某种模式的响应。这样的输出称为特征图(feature map)。一个卷积核通常生成一张特征图,而多个卷积核会生成多个输出通道,每个通道可以学习检测不同的局部模式。例如,第一层卷积可以从像素中提取简单的局部模式,例如不同方向的边缘。第二层不再直接只看原始像素,而是组合第一层得到的边缘特征,从而形成角点、纹理或简单轮廓。随着网络继续加深,后面的层可以把这些低层特征继续组合成物体部件和更高层语义。
可以把这个过程粗略理解为:

这种从局部到全局、从简单模式到复杂模式的逐层组合过程,就是 CNN 的层次化特征学习(hierarchical feature learning)。虽然单个卷积输出只依赖一个局部窗口,但多层卷积堆叠后,后层特征能够间接看到越来越大的输入区域。
一个输出神经元在原始输入上能够受到影响的区域称为感受野(receptive field)。网络越深,感受野通常越大,因此模型既能保留局部结构,又能逐渐整合更广范围的信息。感受野的精确计算会受到卷积核大小、步幅和下采样方式影响,我们会在后面讨论卷积层和池化层时继续看到这个概念。
卷积最经典的应用是二维图像,但局部连接和权重共享并不只适用于图像。
对于时间序列或音频,可以沿时间轴使用一维卷积;对于医学体积数据,可以使用三维卷积。它们的核心思想仍然相同:在局部区域内执行同一种可学习计算,并把同一组权重应用到不同位置。
不过,CNN 对图像尤其成功,是因为它的结构和图像本身的特点非常匹配:
CNN 将这些规律编码为局部连接、权重共享和平移等变性,因此相比完全通用的 MLP,它在视觉任务中通常具有更合适的归纳偏置。
这一节我们从 MLP 处理图像时遇到的问题出发,介绍了卷积神经网络的设计动机。
把图像展平成向量并不会丢失像素数值,但会隐藏图像原有的二维空间结构。全连接层还要求每个输出神经元连接所有输入像素,导致参数数量随着图像分辨率快速增长。更重要的是,它没有显式利用图像中的局部相关性,也不会自动让同一种模式在不同位置共享检测参数。
CNN 通过两个核心设计缓解了这些问题:局部连接让每个输出位置只观察输入中的一个小窗口,权重共享让同一个卷积核可以在整张图像上重复使用。权重共享进一步带来了平移等变性:输入中的模式发生平移时,对应的特征响应也会随之平移。
当多个卷积层堆叠起来以后,网络可以先提取边缘和颜色等低层特征,再逐渐组合出纹理、物体部件和完整物体。CNN 因此不是简单地减少参数,而是把适合图像的空间归纳偏置写进了网络结构。
下一节我们将正式进入卷积层的计算过程,讨论卷积核如何在输入上滑动,以及 kernel size、padding、stride、输入通道和输出通道分别如何影响输出张量的形状。