DL · 章节 3
深度学习 3 —— 卷积网络与图像处理 (一)
课程章节 · 中央理工里尔学院 · Pascal Yim · 2026 年
在 DL 1 与 DL 2 中,我们一步一步把单个神经元堆成多层感知器 (MLP),并用它解决了表格型回归与分类问题。那时的输入是一行整齐的特征向量,每一维都有明确的含义——年龄、收入、面积、价格。但当输入从一行
pandas表格变成一张28×28的手写数字图像时,MLP 立刻显得笨拙——它把图像扁平化为 784 维向量,完全无视像素之间的二维邻接关系,也无视"同一个数字写在画面不同位置仍是同一个数字"这一基本事实。本章我们正式跨入卷积神经网络 (Convolutional Neural Network, CNN) 的世界:从 MLP 的不足讲起,引入卷积运算与池化,理解为什么 CNN 在图像上是天然的合适工具,再用 PyTorch 在 MNIST 上训练第一个真正意义上的视觉网络。掌握本章之后,你就拥有了进入计算机视觉、迁移学习、Transformer 视觉变体的入场券。
1. 从 MLP 到 CNN:为什么需要新的归纳偏置
1.1 MLP 在图像上的两个先天缺陷
把一张 灰度图像送进 MLP 之前,我们必须先把它"压扁"为一个 784 维向量。这一步看似无害,但它带走了图像里最珍贵的信息——空间结构。一旦扁平化,左上角像素 1x1 与正下方像素 2x1 的几何近邻关系,在 MLP 的输入里和位于第 500 个分量的某个无关像素已经没什么区别;权重矩阵必须从零开始重新学习"哪些维度其实是邻居"。这就好比把一本书拆成单字,然后让一个不认识汉字结构的读者去重建整本书的语义——理论上可行,但要付出极其惨痛的代价。
更严重的是参数爆炸。一个 的输入接入 32 个神经元的全连接隐层,就会产生 个权重。如果我们换成 的彩色图像,这个数字会膨胀到约一千五百万——而这只是第一层。MLP 没有对图像做任何结构假设,只能用海量参数硬学,既慢又容易过拟合。再考虑到训练集里每张猫的照片其实只是无穷多种"猫的姿态、光照、背景"中的一个瞬间样本,MLP 想要从这些样本里学到一个真正能泛化的"猫"概念,几乎是不可能完成的任务。
1.2 图像的两个基本规律
幸运的是,自然图像满足两条非常有用的先验:
- 局部性 (locality):决定一小块图像是"边缘"还是"角落",只需要看周围十几个像素,不需要瞄一眼对面那个角。视觉皮层 V1 区的简单细胞对此早有验证:每个细胞只对视野中一个很小的区域敏感。
- 平移不变性 (translation invariance):一只猫从画面左上角挪到右下角,它依然是同一只猫,识别它的"特征探测器"也应该完全一样。我们不需要为图像里每一个可能位置都重新学一套权重。
CNN 的核心思想就是把这两条先验直接编进网络结构:局部连接 + 权重共享。这就是所谓的归纳偏置 (inductive bias) ——通过约束模型形式,让它从一开始就只考虑"合理"的解,从而以更少的参数获得更好的泛化。换言之,CNN 不是单纯比 MLP 多了几个新名词,而是把"图像的物理"刻进了网络架构。这是从 Yann LeCun 的 LeNet 一直到今天 ResNet、ConvNeXt 共同的灵魂。
2. 二维卷积运算
2.1 卷积核与特征图
二维卷积的核心是一块小小的卷积核 (kernel,又称 filter),典型尺寸是 或 。它在输入图像上滑动:每到一个位置,就把核里的权重和被覆盖的局部像素逐元素相乘,再求和,得到输出图上对应位置的一个数值。所有这些数值组成一张新的二维图,称为特征图 (feature map)。
形式上,设输入为 ,卷积核为 (尺寸 ),则输出 在位置 处的值为
注意三件事。第一,核里的权重就是参数,它们由梯度下降学出来,不是手工设计的。在传统图像处理里,人们手工设计 Sobel、Prewitt、Laplacian 等滤波器来检测边缘;CNN 的高明之处在于让滤波器自己从数据中浮现出来——而且通常学出来的浅层滤波器,长得跟手工 Sobel 惊人相似。第二,整张图共享同一个核——这正是权重共享的体现:无论物体出现在画面何处,同一个边缘探测器都能找到它。第三,卷积运算只看一个小邻域,这正是局部连接的体现。
2.2 步幅与填充
把核滑过整张图像有两种花样可以玩:
- 步幅 (stride, ):核每次平移多少格。 是标准做法,逐像素滑动; 则跳格滑动,会让特征图边长减半。大步幅本身就具备下采样效果,在某些现代架构中甚至完全代替了池化。
- 填充 (padding, ):在输入图像的四周补 圈零。如果不填充,输出会比输入小一圈;为了让输出和输入"同形"(常称 same padding),我们通常把 选成 。填充另一个隐藏的好处是让边缘像素也能被卷积核"完整覆盖"若干次,而不是只在边角浅尝辄止。
把这些参数串起来,就得到 CNN 章节里你必须熟练于心的形状公式:
卷积输出形状规则
输入空间尺寸为 ,卷积核尺寸 ,填充 ,步幅 ,则输出尺寸为 宽度方向公式完全对称。
代入常用配方 ,得到 ,空间尺寸不变;这就是 PyTorch 里默认推荐 kernel_size=3, padding=1 的原因。如果换成 ,输出每边各掉两格;若再叠加 ,空间尺寸近似减半。这个公式看起来朴素,但它会陪伴你设计 CNN 的整个职业生涯——每加一层,都要在脑子里默念一遍它。
2.3 多通道与多滤波器
实际图像往往不止一个通道——彩色图像有 RGB 三通道。在 PyTorch 里,卷积层默认对所有输入通道同时做卷积,然后把结果加在一起,得到一张特征图。一个卷积层可以学习多个这样的卷积核 (即 out_channels 参数),每个核负责一种特征,产生独立的特征图。所以,一个标准 Conv2d 的运算可以这样理解:
- 输入张量形状:
- 输出张量形状:
- 参数数量: ,加上 个偏置。
注意参数量与图像分辨率 完全无关——这正是权重共享带来的最大红利。一张 的小图和一张 的高清图,经过同一个 卷积层,参数量都是同样的 。MLP 的参数量则会随分辨率平方级别地暴涨。这就是 CNN 能扩展到大图、能做迁移学习的根本物理原因。
3. 池化、感受野与经典 CNN 架构
3.1 池化:受控的空间下采样
卷积层保留了空间分辨率,但深度网络通常希望逐层把"局部细节"压缩成"全局语义"。池化 (pooling) 就是为此设计的下采样操作。最常见的是最大池化 (max pooling):在一个 的窗口里,只保留最大的那个值。它把高度和宽度都除以 2,通道数不变。
池化有三个好处:
- 降维:把 缩成 再缩成 ,后续全连接层的参数量随之锐减,计算和显存压力同步下降。
- 小幅平移鲁棒性:窗口内挪动一两个像素,最大值通常不变。这让网络对手写数字的微小抖动天然免疫。
- 逐层抽象:浅层关心"边缘",深层关心"形状",最深层关心"猫脸",池化为这种语义分级提供物理基础。每一次下采样都在迫使网络"放弃细枝末节,保留更概括的语义"。
除了最大池化,平均池化 (AvgPool2d) 也常被使用,尤其是在网络末端的"全局平均池化"层——它把一张特征图直接压缩成一个数,常见于现代 CNN (如 ResNet) 的 head 部分。
3.2 感受野:每个神经元"看到了什么"
随着卷积与池化层的堆叠,深层神经元在原始图像上覆盖的区域越来越大,我们称这块区域为感受野 (receptive field)。两个 卷积串联,感受野扩大到 ;再加一次池化后再卷积,等效感受野可以轻松达到 甚至更大。这正是 CNN 能从局部边缘逐步组合出全局形状的几何原理:浅层看小局部,深层看大全景。VGG 网络之所以坚持只用 卷积,正是基于这个理由——多个小核串联在感受野上等价于一个大核,但参数更少、非线性更丰富。
3.3 经典 CNN 架构的家族脉络
虽然本章只搭建一个最朴素的小型 CNN,认识几位"老前辈"仍然有用,因为它们奠定了今天所有视觉网络的范式。
- LeCun 1998 — LeNet-5: 第一个端到端用反向传播训练的 CNN,专门用于识别手写支票数字,几乎是 MNIST 的"原住民"。结构是
Conv → Pool → Conv → Pool → FC → FC,与我们这一章要搭的玩具 CNN 几乎一模一样。 - AlexNet (2012): 在 ImageNet 上一鸣惊人,把 ReLU、Dropout、GPU 训练这些今天的标配带进主流,被公认为深度学习时代的开局之作。
- VGG (2014): 把"小卷积核 () + 深堆叠"的极简风格推向极致,16 层、19 层版本至今仍是教学和迁移学习里的常客。
- ResNet (2015): 引入残差连接 (skip connection),让网络深到上百层成为可能,赢得了当年所有视觉竞赛的冠军。
- EfficientNet, ConvNeXt: 现代化的 CNN,在精度、参数量、推理速度的多目标平衡上做到极致,与 Vision Transformer 同台竞技。
我们这一章只复刻 LeNet 级别的 baseline,但所有更深的架构都是同一砖块的堆叠艺术。一旦理解 Conv2d + ReLU + MaxPool2d 这一基本块,你就掌握了打开整个 CNN 家族大门的钥匙。
4. PyTorch 中的 CNN 工具箱
4.1 (B, C, H, W):四维张量约定
在 PyTorch 中,nn.Conv2d 只接受四维张量,不接受二维或三维。这一点务必牢记,它也是初学者最常踩的坑——把 (N, 28, 28) 直接喂进 Conv2d 会立刻收到一个红色的 RuntimeError。
PyTorch 四维形状规则
卷积输入张量的形状必须是 :
- =
batch_size,同时处理的图像数;- =
channels,输入通道数 (灰度 = 1, RGB = 3);- =
height,图像高度;- =
width,图像宽度。
所以,从 CSV 文件加载来的 MNIST 数据,我们必须经过两步重塑:先 reshape 把 784 维行向量复原成 的方阵,再用 unsqueeze(1) 显式塞进一个通道轴。
n = X.shape[0] X = X.reshape(n, 28, 28) # (n, 28, 28) X = X / 255.0 X_train_t = torch.tensor(X_train, dtype=torch.float32).unsqueeze(1) # (n, 1, 28, 28) y_train_t = torch.tensor(y_train, dtype=torch.long)
unsqueeze(1) 不会复制任何数据,它只是告诉 PyTorch:"这里其实是单通道图像。"对应地,如果数据是彩色的,我们一般从 NumPy 习惯的 (N, H, W, C) 转置为 PyTorch 习惯的 (N, C, H, W),代码是 np.transpose(X, (0, 3, 1, 2))。
4.2 nn.Conv2d:卷积层的 PyTorch 接口
nn.Conv2d( in_channels, out_channels, kernel_size, stride=1, padding=0, )
in_channels必须等于上一层输出的通道数 (第一层等于 1 或 3);out_channels是该层学习的滤波器个数,可以自由选择,常取 16, 32, 64;kernel_size、stride、padding直接对应上面的 。
输出张量的通道数等于 out_channels,空间尺寸由形状公式决定。一个常见的工程实践是:浅层用较少的通道 (16, 32),随着深度增加把通道数翻倍 (64, 128, 256),同时通过池化或大步幅卷积让空间尺寸成比例缩小。这样总的张量体积大致保持稳定,既不会浪费算力,也不会过早丢失信息。
4.3 nn.MaxPool2d 与 nn.Flatten
最大池化的 PyTorch 写法是:
nn.MaxPool2d(kernel_size=2, stride=2)
效果是 都除以 2,通道数不变。从卷积块过渡到全连接层之前,要把多维张量铺平成向量:
nn.Flatten()
它把 变成 。理解这一步对后面 nn.Linear 的 in_features 计算至关重要。Flatten 默认从第 1 维开始拉平,保留 batch 维不动——这正是我们想要的行为。如果你坚持手写,可以写 x = torch.flatten(x, start_dim=1),效果一致。
4.4 nn.BatchNorm2d:批归一化
在比较深的 CNN 中,我们常在 Conv2d 与 ReLU 之间插一层 nn.BatchNorm2d(num_features),它在每个通道上对 mini-batch 内的激活做标准化:减去通道均值、除以通道标准差,再施加可学习的缩放和偏移。批归一化能稳定训练、允许更大的学习率、起到一定的正则化作用,自从 2015 年被提出后就几乎成了深度网络的标配。num_features 等于该位置的通道数 C。本章我们的小网络可以先不加,但你将在后续章节频繁见到。
5. 在 MNIST 上训练你的第一个 CNN
5.1 数据准备模式
MNIST 的 CSV 版本把每张图存成一行 784 个像素值。从加载到送进 Conv2d,有一套固定模式:
df = pd.read_csv("mnist_small.csv") X = df.drop(columns="label").to_numpy() y = df["label"].to_numpy() X = X.reshape(X.shape[0], 28, 28) X = X / 255.0 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) X_train_t = torch.tensor(X_train, dtype=torch.float32).unsqueeze(1) y_train_t = torch.tensor(y_train, dtype=torch.long) X_test_t = torch.tensor(X_test, dtype=torch.float32).unsqueeze(1) y_test_t = torch.tensor(y_test, dtype=torch.long)
四个要点请刻进肌肉记忆:reshape(n, 28, 28) 还原二维结构;/255.0 归一化到 ,让数值范围友好;特征用 float32 (浮点权重才能算梯度);标签用 long (CrossEntropyLoss 要求整型类别索引)。unsqueeze(1) 把 升维成 。最后用 TensorDataset 把张量打包,再用 DataLoader 配上 batch_size=64, shuffle=True 即可送进训练循环。
5.2 用 nn.Sequential 搭建一个小型 CNN
下面这个最朴素的 CNN 只有一个卷积块:
model = nn.Sequential( nn.Conv2d(1, 8, kernel_size=3, padding=1), # (n, 8, 28, 28) nn.ReLU(), nn.MaxPool2d(2), # (n, 8, 14, 14) nn.Flatten(), # (n, 8*14*14) nn.Linear(8 * 14 * 14, 10), # logits 10 类 )
每一行右边的注释就是张量当前形状,这是阅读 CNN 代码的标准习惯——逐层标注 (B, C, H, W),可以帮你及时发现尺寸错误。让我们逐层验证形状公式:
- 输入 ,经过
Conv2d(1, 8, 3, padding=1): 由 ,输出 。 - ReLU 不改变形状。
MaxPool2d(2): ,输出 。Flatten把后三维拉平: 。Linear(1568, 10): 输出 10 个类别 logits。
这个网络的卷积层只有 个参数,比 MLP 第一层的 小了三个数量级——这就是权重共享的力量。
5.3 训练循环模板
CNN 的训练循环和上一章 MLP 完全一致——这是 PyTorch 的优雅之处:
criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1) epochs = 100 loss_history = [] for _ in range(epochs): epoch_loss, nb = 0.0, 0 for Xb, yb in train_loader: optimizer.zero_grad() logits = model(Xb) loss = criterion(logits, yb) loss.backward() optimizer.step() epoch_loss += loss.item() nb += 1 loss_history.append(epoch_loss / nb)
评估阶段记得关掉梯度跟踪:
with torch.no_grad(): logits = model(X_test_t) y_hat = torch.argmax(logits, dim=1).cpu().numpy()
在 MNIST 这种简单任务上,即便是这样一个超薄的 CNN,准确率也常常一举冲上 96–98%,显著优于同等规模的 MLP——而且参数更少。如果你有 GPU,只需在脚本开头加 device = torch.device("cuda" if torch.cuda.is_available() else "cpu"),然后在模型和数据上各调用一次 .to(device),训练时间还能缩短到几十秒级别。
5.4 多层堆叠的形状簿记
把网络加深就是不断重复"卷积 + 激活 (+ 池化)"砖块,只需保证一层的 out_channels 等于下一层的 in_channels。一个典型的两层堆叠:
model = nn.Sequential( nn.Conv2d(1, 16, 3, padding=1), # (n, 16, 28, 28) nn.ReLU(), nn.MaxPool2d(2), # (n, 16, 14, 14) nn.Conv2d(16, 32, 3, padding=1), # (n, 32, 14, 14) nn.ReLU(), nn.MaxPool2d(2), # (n, 32, 7, 7) nn.Flatten(), nn.Linear(32 * 7 * 7, 10), )
加深网络时最容易踩的坑只有两个:忘记更新下一层 in_channels,以及**Linear 层的 in_features 算错**。一个万无一失的小习惯是:在网络定义后跑一次 model(torch.randn(1, 1, 28, 28)).shape 做单步前向,让 PyTorch 替你验形状。如果想随时查看中间张量尺寸,在自定义模型的 forward 里加几行 print(x.shape) 也是完全合法的调试技巧。
5.5 nn.Module 类语法:更灵活的写法
当架构需要分支、跳连、共享权重时,nn.Sequential 的线性顺序就不够用了,标准做法是继承 nn.Module:
class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 16, 3, padding=1) self.conv2 = nn.Conv2d(16, 32, 3, padding=1) self.pool = nn.MaxPool2d(2) self.relu = nn.ReLU() self.fc = nn.Linear(32 * 7 * 7, 10) def forward(self, x): x = self.pool(self.relu(self.conv1(x))) x = self.pool(self.relu(self.conv2(x))) x = torch.flatten(x, start_dim=1) x = self.fc(x) return x
__init__ 里声明所有层,forward 里写数据流——所有现代 PyTorch 项目都遵守这一约定。声明为 self.conv1 这类属性的层,会自动被 PyTorch 注册为模型的可训练参数,从而进入 model.parameters()、跟随 .to(device)、保存到 checkpoint。如果你不小心写成局部变量 conv1 = nn.Conv2d(...),这一层就完全游离在网络之外,梯度永远不会更新——这是另一个经典坑。
练习
- 形状验证:对于
Conv2d(in_channels=3, out_channels=16, kernel_size=5, padding=0, stride=1),输入形状 ,手动用 算出输出空间尺寸,并写出完整四维形状。 - 从 MLP 到 CNN:在 MNIST 上分别训练第 1 节的 MLP (架构 ) 和本章第 5.2 节的小型 CNN。比较参数总数 (
sum(p.numel() for p in model.parameters())) 与测试准确率,从局部性与权重共享的角度解释你看到的差异。 - 加深网络:在 5.4 节双卷积模板的基础上,再加一个
Conv2d(32, 64, 3, padding=1) + ReLU + MaxPool2d(2)。重新计算Flatten后的维度,并相应改写Linear的in_features。 - 形状陷阱:把第二个
MaxPool2d(2)不小心改成MaxPool2d(3)。手动算出空间尺寸会变成多少,然后跑一遍代码确认 PyTorch 抛出的错误信息是什么。 nn.Module改写:将 5.4 节的nn.Sequential模型改写为继承nn.Module的类版本,要求forward方法里只调用一次self.pool和一次self.relu的实例 (即两次卷积复用同一个池化和激活模块)。- 批归一化对比:在每个
Conv2d后面插入nn.BatchNorm2d,保持其它超参数不变,观察前 10 个 epoch 的损失曲线是否更平滑、是否能容忍更大的学习率。 - 彩色图像 reshape:CIFAR-10 的 CSV 把每张 彩色图存成 3072 维行向量。请写出从
X到(n, 3, 32, 32)的完整 reshape + transpose 步骤,解释为什么np.transpose(X, (0, 3, 1, 2))是必需的。 - 感受野计算:对于
Conv3 + Pool2 + Conv3 + Pool2 + Conv3的堆叠 (所有卷积 ,所有池化 ),手动计算最深层一个神经元在原始输入上的感受野尺寸。
拓展阅读
- Stanford CS231n — Convolutional Neural Networks for Visual Recognition (https://cs231n.github.io/convolutional-networks/):全网最经典的 CNN 入门讲义,从感受野到参数共享讲得极清楚,推荐配合本章逐节阅读。
- Goodfellow, Bengio, Courville — Deep Learning, Chapter 9 "Convolutional Networks":理论圣经级别的章节,对卷积的数学性质 (等变性 vs. 不变性) 有深入论述。
- He et al., 2015 — Deep Residual Learning for Image Recognition (ResNet) (https://arxiv.org/abs/1512.03385):了解为什么"网络越深越难训练",以及残差连接如何解决这个问题。
- PyTorch 官方教程 — TRAINING A CLASSIFIER (CIFAR-10) (https://pytorch.org/tutorials/beginner/blitz/cifar10_tutorial.html):用与本章一脉相承的 API 在 CIFAR-10 上完整走一遍训练评估循环。
torchvision.models(https://pytorch.org/vision/stable/models.html):内置 ResNet、VGG、EfficientNet 等预训练模型,后续做迁移学习时直接from torchvision.models import resnet18就能加载。- Distill.pub — Feature Visualization (https://distill.pub/2017/feature-visualization/):用可视化方法看看 CNN 各层到底学到了什么样的"特征",看完会对感受野和层级抽象有更直观的体会。