全书导航
大模型之路:从图灵、感知机到 ChatGPT · 卷 3

第 19 章:卷积网络:计算机如何看见图像

本章问题:CNN 为什么特别适合图像?


全连接层能算任意函数,但用在图像上参数会爆炸,而且完全忽略像素之间的空间邻近关系。这一章引入卷积,把"邻近像素有关联"这个先验直接内置进层结构。

19.1 全连接看图像的问题

第 11 章讲了手工视觉特征的辉煌——SIFT、HOG——和它们的局限。第 17 章讲了全连接层:每个输入连接每个输出,一个巨大的矩阵乘法。

如果把全连接层直接用到图像上呢?

一张 200×200 的 RGB 图片有 200×200×3 = 120,000 个像素。如果第一个隐藏层有 64 个神经元,那第一个权重矩阵就是 120,000 × 64 = 7,680,000 个参数——仅仅一层就超过 7 百万个参数。

这不仅是浪费,更深层的问题在于——它完全忽略了图像的空间结构

在全连接层眼里,输入只是一个被打平的一维向量。(0,0) 位置的像素和 (199,199) 位置的像素同等"近"——它们都只是向量里不同的下标。但实际上一张图片是有邻域结构的:猫的眼睛在鼻子上方,鼻子在嘴巴上方。相邻的像素之间有信息——远处的像素之间基本没有直接结构联系。

全连接层不利用任何关于"邻近"的知识。它得花费大量数据去"学习"这个空间的天然属性。而卷积层把这个属性内置进了层的结构本身——不需要额外学习。


19.2 卷积在做什么:滑动窗口

卷积的直觉来自人眼看世界的方式:我们不在同一时刻看整张图片的所有区域。我们的注意力在一个小窗口内来回移动,把局部信息逐步聚集成整体理解。

卷积操作就是把这个过程做成一个数学运算。

你有一个输入图像(比如 5×5 像素)。你有一个小矩阵叫卷积核(也叫滤波器,比如 3×3)。你把卷积核叠在图像左上角,做逐元素乘积再求和,得到一个数。然后你把卷积核往右滑一格,再算一次。再滑,再算……一直到覆盖整张图片。你得到的输出叫特征图——一张比原图稍小的、每个位置上的值代表这个位置"有多符合卷积核寻找的模式"。

用数字举个例子。3×3 的卷积核:

 0  -1   0-1   5  -1 0  -1   0

这是一个锐化滤波器。中间是正中心像素,周围是负邻域——这算出的是"这个像素比邻居亮多少"。滑过猫的毛色边界时,输出会在边缘处出现极大的正值(暗的背景紧邻亮的皮毛),把边缘突显出来。

不同的卷积核检测不同的模式。一个核可能对水平线有反应,另一个对竖线。一个核对红色到绿色的过渡有反应,另一个对亮度变化。每个卷积层都不是只有一个核——它有一组核。每个核在整张图上扫一遍,产生一张特征图。一组核产生一组特征图——它们堆叠起来成为多通道输出,作为下一层的输入。


19.3 为什么卷积很"省"

和全连接层相比,卷积层有两个关键属性让它的参数数量剧减。

局部连接。 卷积核只看到当前位置周围的一个小窗口(比如 3×3 或 5×5),而不是整个输入的所有位置。一个输出值只依赖于局部的小块输入——这直接利用了"图像中的信息是空间邻域相关的"这个事实。

权重共享。 同一个卷积核在整张图片上滑动——左上角用的是同一组权重,右下角用的也是同一组。这意味着:如果你的核学会了在图像的某处检测竖直边缘,它自动会在任何位置检测竖直边缘。

在全连接层中,一个 200×200 的图像打平后连接到一个 64 神经元的隐藏层就是 120,000×64 个独立的一维向量权重。在卷积层中,64 个 3×3 核——每个核有 3×3×(输入通道数)个参数,可能总共只有几百到几千个权重。在遍历这些通道的整个滑动过程中,这些权重被共享。节省了两到三个数量级的参数。


19.4 池化:让网络对微小偏移不敏感

卷积层输出的特征图尺寸和原图差不多大(稍小一些)。在网络的浅层阶段,逐像素位置的信息还在——边缘在第 13 行第 27 列。但你不需要保持这么高的位置精度一直到网络顶层——对于"这是一只猫",猫在图片的偏上方还是正中央并不重要。

池化层做的是降采样——削减特征图的分辨率,同时保留主要信息。

最常见的是最大池化:用 2×2 的窗口在特征图上滑动,每个窗口只保留四个值中的最大值(代表"最明显的存在"),丢掉剩下三个。步长设为 2——完全不重叠。一张 24×24 的特征图经过 2×2 最大池化变成 12×12。

池化的效果:

  1. 计算量降低——特征图面积减半又减半,后续层的参数也跟着减。
  2. 平移不变性——哪怕猫在图片中稍微向左或向右偏移了几个像素,池化层选取的最大值仍然代表"这里有边/有纹理",这个统计信息在局部窗口下保持稳定。
  3. 增大感受野——池化后每个特征图像素对应的原始图像区域增大了一倍(stride 2 的累积效应意味着下一层每个神经元能"看到"上一层两个步长范围的输入区),深度加深时,顶层神经元能"看到"原图的更大范围区域。

一个典型的 CNN 结构模式是:

卷积 → ReLU → 池化 → 卷积 → ReLU → 池化 → ... → 展平 → 全连接 → 输出

浅层卷积抓到边缘和纹理,池化降低分辨率;深层卷积抓到物体部件和语义特征,池化继续降低分辨率;最后展平(把二维特征图拉伸成一维),输入全连接层做最终分类。


19.5 通道:网络的第三个维度

到此为止,输入图像有三个通道(RGB)。当一个 3×3 的卷积核作用在一个三通道图像上时,这个核本身也有三个通道——它在图像的每一个通道上做 3×3 乘积,然后三个通道的结果相加。所以一个"3×3 的卷积核"在 RGB 图像上实际是 3×3×3 = 27 个权重。

卷积层的输出通道数(特征图的层数)等于这一层有多少个独立的卷积核。每个核自己独立地在所有输入通道上做卷积,产生一张输出特征图。所以卷积层的参数数是:K × C_in × kH × kW(K 个独立核 × 每个核的输入通道连接 × 核高度 × 核宽度)。

更深的层的输入通道数可能是 64、128、256(被前一层的一堆核各自产生的特征图堆叠出来的)。每个特征图变成了单个"通道"的信息——早期这些通道分别可能对应不同方向的边缘和颜色块,但到了网络的后期层,每一个通道都不再对应一个人眼能解释的"特征",而是对更高阶统计量的一个高维投影。


19.6 最小代码:10 层 CNN 训练 MNIST

下面的代码用 PyTorch 训练一个小型 CNN 识别手写数字 MNIST(28×28 灰度图,10 个数字类别)。约 40 行。

python
import torchimport torch.nn as nnimport torch.nn.functional as Ffrom torch.utils.data import DataLoaderfrom torchvision import datasets, transforms# 1. 加载 MNISTtransform = transforms.ToTensor()train_ds = datasets.MNIST("./data", train=True, download=True, transform=transform)test_ds = datasets.MNIST("./data", train=False, transform=transform)train_dl = DataLoader(train_ds, batch_size=64, shuffle=True)test_dl = DataLoader(test_ds, batch_size=1000)# 2. 构建 CNNclass Net(nn.Module):    def __init__(self):        super().__init__()        self.conv1 = nn.Conv2d(1, 16, 3, 1)   # 1→16 通道, 3x3 核        self.conv2 = nn.Conv2d(16, 32, 3, 1)  # 16→32 通道        self.fc1 = nn.Linear(32 * 5 * 5, 128)  # 展平后全连接        self.fc2 = nn.Linear(128, 10)    def forward(self, x):        x = F.relu(self.conv1(x))       # (B,16,26,26)        x = F.max_pool2d(x, 2)          # (B,16,13,13)        x = F.relu(self.conv2(x))       # (B,32,11,11)        x = F.max_pool2d(x, 2)          # (B,32,5,5)        x = x.view(x.size(0), -1)       # 展平        x = F.relu(self.fc1(x))        x = self.fc2(x)                 # 输出 logits        return xmodel = Net()# 3. 训练optimizer = torch.optim.Adam(model.parameters(), lr=0.001)for epoch in range(3):    model.train()    for data, target in train_dl:        output = model(data)        loss = F.cross_entropy(output, target)        optimizer.zero_grad()        loss.backward()        optimizer.step()    # 评估    model.eval()    correct = 0    with torch.no_grad():        for data, target in test_dl:            output = model(data)            correct += output.argmax(dim=1).eq(target).sum().item()    print(f"epoch {epoch+1} | test acc {correct/len(test_ds):.2%}")

三个 epoch 后测试准确率可达 ~98%。核心结构就在 CNN 的参数里——两个卷积层、两次池化、最后全连接分类。整个流程和前两章的"训练循环"完全一样,唯一变化是网络层采用了更有结构的卷积设计。


19.7 本章小实验:手算一个卷积

取一张 5×5 的"假图片"(全 0,中间几个位置是 1)。取一个 3×3 的核 [[1,0,-1],[1,0,-1],[1,0,-1]](它是竖边检测器——左边全 +1,右边全 -1)。

用手一步一步把核在图片上滑动,计算输出。

算到中间你会发现:每当核窗从亮区域扫过到暗区域——也就是竖列跨越一个亮到暗的过渡区——卷积的输出就会出现极大的正或负(白色区域(1)乘以核正侧(+1)给正贡献,黑色区域(0)不贡献)。

这个核在整张图上能找到"亮暗竖直边界"的位置。CNN 中的卷积核和这个完全一样,只不过它们检测的模式不是人手设计的——它们是从数据中学来的。


19.8 本章地图

text
问题:CNN 为什么特别适合图像?方法:卷积层利用局部连接和权重共享,在每个滑动窗口上做相同的模式检测,大幅降低参数数量。池化层提供平移不变性和降采样。核心操作:卷积(3×3/5×5 小核,局部内积)→ ReLU → 最大池化 → 重复 → 展平 → 全连接输出。代码:40 行 PyTorch CNN 在 MNIST 上 3 epoch 达到 98% 准确率。今天:Convolution 的设计精神——局部、重复、平移不变——仍然是 Transformer 之前在视觉领域统治级的范式。即使是 Vision Transformer 也被看作等效于学习局部模式块的退化版。

19.9 本章结语:看图不是看像素——是看模式

全连接层在所有像素之间建立全量连接——这个自由度过大。CNN 把"看世界的方式"设为一组可滑动的局部模式检测器——这是一种更接近生物视觉系统的归纳偏置(inductive bias)。这个偏置在 2012 到 2020 年代初驱动了整个视觉 AI 的爆发。

但当任务从"看图"变到"读句子"时,局部滑动窗口不再管用——文本的语义和远距离词序关联更紧密。卷积的局部特性在序列处理上开始失效。

这就引入了序列模型——RNN、LSTM,以及它们没能完全解决的问题。

下一章,循环网络:机器如何记住一句话。

SECTION §02 · ENGAGE

Discussion

留言区 · GitHub-powered comments via Giscus