第 13 章 · 经典网络结构

CNN 卷积神经网络

前面两部分搭的都是全连接网络(MLP)——每个输入都连到每个神经元。它处理“一行特征”很好, 可一旦面对图像就水土不服。这一章认识第一位“专才”:CNN(卷积神经网络), 它靠卷积这一招,又省参数又懂空间,是计算机视觉几十年的主力。

读完这一章,你会明白

  • 用全连接网络处理图像的三个硬伤;
  • 卷积到底在算什么:局部感受野 + 一个小滤波器在图上滑动;
  • 权重共享为什么能让参数暴减,还自带平移不变;
  • 池化通道、以及“浅层看边缘、深层看物体”的层级特征;
  • LeNet / AlexNet / ResNet 这些名字大概是怎么回事;
  • 亲手跑通最小 CNN,亲眼看它在小数据平移测试里赢过 MLP;
  • 在 ViT 当道的年代,CNN 还握着哪些地盘、握住的凭什么。
完整 CNN 长什么样? 先在图像上做卷积和池化,空间结构一路保留;最后才 flatten 接分类头。 输入图像 [B, C, H, W] Conv Block 可堆叠: Conv2D → ReLU → MaxPool 卷积: 3×3 核在图上滑 3×3 kernel 特征图 ReLU 激活 池化: 2×2 区域取最大,图变小 下采样后的特征图 特征图堆 [B, C', H', W'] Flatten 摊成一行向量 Linear Head logits [B, 类别数] 训练 softmax + CE 预测 argmax → 类别 本项目的 mini CNN: 1× Conv2D → ReLU → MaxPool → Flatten → Linear。 真实 CNN 会把 Conv Block 重复很多次,最后才接分类头。

CNN 主干 = 输入图像 → 可堆叠的卷积块 → Flatten → 分类头。能堆叠的是中间的 Conv Block,不是最后的 Linear Head。

1. 全连接处理图像,卡在哪?

把一张图片摊平成一个长向量、直接喂给 MLP,会撞上三个问题:

CNN 的所有设计,都是冲着这三点来的。

2. 卷积:一个小滤波器,在图上滑一遍

卷积的核心是一个很小的权重方块,叫卷积核 / 滤波器(kernel),比如 3×3。 它不看全图,只盖住图上的一小块(局部感受野),把这一小块和核点积(第 1 章), 得到一个数;然后滑到下一个位置再算一个数……滑遍全图,就得到一张新的图,叫特征图(feature map)

输入图(5×5) 3×3 核盖住的一小块 ⊛ 核 点积求和 特征图(3×3) 这一格 = 那一小块与核的点积

卷积:核盖住左上 3×3,做一次点积得到特征图的第一格;核向右/向下滑动,算出整张特征图。

卷积一层 · 示意伪代码(帮助理解)
for (r : 输出的每一行)
  for (c : 输出的每一列) {
    sum = 0;
    for (i : 0..K) for (j : 0..K)                 // 核的每个位置  1
      sum += image[r+i][c+j] * kernel[i][j];      // 小块 · 核 (点积)
    feature_map[r][c] = activate(sum + bias);      // 一个输出像素   2
  }
  1. 核在图上滑动,每到一个位置,就把覆盖的小块和核做点积——和一个神经元干的事一模一样,只是只看局部
  2. 加偏置、过激活(第 7 章),得到特征图上的一个像素。同一个核扫遍全图——这就是下一节的“权重共享”。

① 用一条公式把“滑动点积”钉死

上面的伪代码写成一条公式,就是卷积层对每一个输出位置 (i, j) 做的事(先看单通道、先不加激活):

Y[i, j] = b + Σu=0K−1 Σv=0K−1 W[u, v] · X[i·S+u, j·S+v] (i, j) = 输出特征图上的一格;(u, v) 遍历 K×K 核内每个位置;S = stride,b = 偏置。多个输入通道时,外面再套一层“对所有输入通道求和”。

这条式子和配套代码里 conv2d.cpp 的三层内层循环逐字对应:对输入通道求和是通道循环、Σu / Σv 是核内那两层循环,i·S / j·S 就是代码里的 base_row / base_col。换句话说,卷积没有任何玄机——它就是“一个偏置 + 一堆乘加”,和第 2 章那个神经元Σw·x + b 是同一件事,只不过每次只吃局部那 K×K 个像素

拿一个具体的 3×3 小块和 3×3 核,把这条式子从头算到尾(取 S=1、b=0):

图像小块 X (3×3) 卷积核 W (3×3) 123 012 301 101 010 101 = 9 输出一格 Y

卷积单格计算:把小块与核逐位置相乘,再全部加起来(核里为 0 的位置直接被抹掉),加上偏置就是特征图上的一格。

Y = 1×1 + 2×0 + 3×1     (第一行: 小块 · 核, 逐元素相乘)
  + 0×0 + 1×1 + 2×0     (第二行)
  + 3×1 + 0×0 + 1×1     (第三行)
  + 0                    (偏置 b)
  = 1 + 3 + 1 + 3 + 1
  = 9                    ← 特征图上这一格的值

核每滑到一个新位置,就把上面这套“9 次乘、再全加起来”重做一遍,得到特征图上的下一格。整张特征图,就是同一个核(同一组 9 个权重)在图上滑一遍攒出来的——这也预告了下一节的权重共享

② 一层卷积层,到底吃什么、吐什么?

真正的卷积层,输入和输出都不是“一串数”,而更像一摞图。 灰度图可以看成 1 个通道,彩图通常是 R/G/B 3 个通道。卷积层吃进去的是 [输入通道数, 高, 宽],吐出来的是 [输出通道数, 高, 宽]。所谓“输出通道数”,其实就是 你放了多少个卷积核——一个核产出一张特征图,多个核就产出多张特征图。

名字形状怎么记直觉
输入图 [C_in, H, W] 一张灰度图是 1 个通道;一张 RGB 图是 3 个通道
一个卷积核 [C_in, K_h, K_w] 它不只看 2D 小块,而是会把所有输入通道一起看
一层卷积 C_out 组核 + C_out 个偏置 放几组核,就产出几张特征图
输出特征图 [C_out, H_out, W_out] 每个输出通道对应“某一种花纹检测器”的响应图

最小例子:MNIST 是 1 通道 28×28。若你放 8 个 5×5 核,这一层的输出就是 8 张特征图。

这也解释了一个容易忽略的点:如果输入是 RGB 三通道,那一个核不是 3×3,而是 3×3×3。 它要同时看红、绿、蓝三张图在同一位置的小块,再把它们合成一个输出值。也就是说,输出的“1 个通道” 往往是对所有输入通道共同加工的结果,不是只盯某个单独颜色通道。

③ stride 和 padding 在控制什么?

卷积核并不是只能“一格一格慢慢挪”。它每次往右/往下滑几格,由 stride(步幅) 决定; 边缘要不要先补一圈 0,由 padding(填充) 决定。它们直接决定输出特征图的大小:

Hout = H + 2PKhS + 1, Wout = W + 2PKwS + 1 S = stride, P = padding。实际实现里要求整除,否则边缘会对不上格子。
设置效果直觉
stride 小 输出更大,看得更细 像拿放大镜一格一格细扫
stride 大 输出更小,算得更快 像跳着看,更快但更粗
padding = 0 图会越卷越小 边缘没有额外空间,核滑不到最外侧外面去
padding > 0 可以保住边缘信息,也更容易让输出尺寸不变 先给图片外面垫一圈“缓冲带”

比如 28×28 输入,5×5 卷积核,若 stride=1 且 padding=2,输出仍是 28×28;这就是很多 CNN 的常见设定。

3. 权重共享:参数暴减,还自带平移不变

这看似普通的“滑动”,一举解决了第 1 节的三个硬伤:

严格说:卷积是“平移等变”,真正的“平移不变”还要靠池化

精确一点讲,卷积本身给的是平移等变(equivariance):物体往右移几个像素,它在特征图上的响应也跟着往右移,而不是原地不动。 真正让最终判断“猫在哪个角落都算猫”的平移不变(invariance),是靠后面的池化和全局聚合把位置信息“抹平”出来的。 本书为了直觉常把两者统称“平移不变”,但记住这层区别,你读论文时不会被绕晕。

像拿一个“找边缘的印章”盖满全图

你可以把卷积核想成一个专门检测某种花纹的印章(比如“竖边缘”)。拿它在整张图上一处处盖, 哪里有竖边缘,那里就盖出一个高亮。一个核 = 一种花纹检测器,而且全图通用。这就是权重共享的威力。

4. 池化:把特征图“缩一缩”

池化(pooling)是紧跟卷积的“下采样”:把特征图切成小块(如 2×2),每块只留一个代表值—— 最大池化取最大值,平均池化取平均。好处:

很多人第一次学 CNN 会把池化和卷积混在一起。其实它们分工完全不同: 卷积层负责“学花纹检测器”,有可训练权重;而池化层通常没有可训练参数,只是把已经提出来的特征图 缩一缩、留一个代表值。前者更像“发现有什么”,后者更像“把发现过的东西压缩保存”。

有没有可训练权重主要做什么输出尺寸
卷积 检测边缘 / 纹理 / 局部部件 由 kernel / stride / padding 决定
池化 通常没有 下采样,压缩空间尺寸,提高鲁棒性 通常明显变小
src/deeplearning/cnn/max_pool2d.cpp · Forward(精简)
for (int out_row = 0; out_row < out_height; out_row++)
  for (int out_col = 0; out_col < out_width; out_col++) {
    best_value = -∞;                                      1
    for (int kh = 0; kh < kernel_height_; kh++)
      for (int kw = 0; kw < kernel_width_; kw++)
        best_value = max(best_value, input[channel][row][col]); 2
    output[channel][out_row][out_col] = best_value;      3
  }
  1. 先假设这一小块里最好的值还不存在。
  2. 扫完整个 2×2 或 3×3 小窗口,只留下最大的那个。
  3. 输出层拿到的是“这一小块里最强的响应”。所以池化不是在学新特征,而是在保留最显著的旧特征

5. 通道与堆叠:从边缘到物体

一个核只能检测一种花纹,所以每层会用很多个核,产出很多张特征图——这些就是 通道(channel)(彩图输入本身就有 R/G/B 三通道)。把“卷积 + 激活 + 池化”一层层堆起来, 就出现了神奇的层级特征:

浅层边缘、颜色、角点
中层纹理、局部部件(眼睛、轮子)
深层完整物体(猫脸、汽车)
全连接汇总特征 → 分类

越往深层,特征越抽象——从边缘,到部件,到整个物体。这正是第 0 章说的“网络自己逐层提炼特征”。

这里再强调一次“通道”这个词,因为它在 CNN 里会出现两次、容易混: 输入通道说的是“进来有几张图一起看”(灰度 1 张、RGB 3 张); 输出通道说的是“这一层产出了几张特征图”,它等于卷积核的个数。 所以“通道变多”通常不是原图变彩了,而是模型在同一层里学会了更多种花纹检测器。

场景输入通道卷积核个数输出通道
MNIST 灰度图 1 8 8
RGB 彩图 3 32 32

“输出通道 = 核的个数”是最该记住的一条。一个核负责一种花纹,多个核并排工作,就得到多张特征图。

② 最后怎么从特征图变成“这是一只猫 / 这是数字 7”?

卷积、池化一路做下来,拿到的还是特征图,不是最终类别。真正把“看见了哪些边缘 / 部件”翻译成 “这张图属于哪一类”的,通常是最后那几层分类头:先把多通道特征图拉平(flatten), 再接一层或几层全连接,最后在类别数上输出 logits,过 softmax 得到概率。

输入图片28×28×1
卷积 + 激活提局部特征
池化缩小特征图
Flatten把多张特征图摊平
线性层汇总成类别分数

CNN 前半段做的是“提特征”,最后那层线性层做的是“拿这些特征做决策”。

src/deeplearning/cnn/mini_cnn_classifier.cpp · Flatten + 分类头(精简)
pool_.Forward(relu_output, pooled_output);         1
flattened = FlattenTensor(pooled_output);          2

for (int cls = 0; cls < class_num_; cls++) {
  logits[cls] = fc_bias_[cls];
  for (int dim = 0; dim < flattened_dim_; dim++)
    logits[cls] += fc_weight_[cls][dim] * flattened[dim]; 3
}
  1. 先把池化后的特征图准备好。到这一步为止,模型还只是在“看图提特征”。
  2. Flatten 把多通道二维特征图摊成一个长向量,方便交给普通线性层。
  3. 最后那层线性层把“看见了哪些特征”汇总成每个类别的分数(logits)。这一步和MNIST 的 MLP 输出层在本质上是一回事。

③ 这个最小 CNN 分类网络长什么样?

到这里,卷积、池化、分类头这些零件都认识了。把它们按顺序拼起来,一个最小 CNN 分类器其实非常朴素: 图片进来 → 卷积提局部特征 → ReLU 加非线性 → 池化缩图 → Flatten 拉平 → 线性层输出类别分数。 下面拿本仓库的 MNIST 小 CNN 举一个具体尺寸的例子(1 通道 28×28 输入,8 个 5×5 卷积核,padding=2,2×2 池化):

输入图 X1 × 28 × 28
Conv + ReLU8 × 28 × 28
MaxPool 2×28 × 14 × 14
Flatten1568 维
Linear + Softmax10 类概率

这就是本仓库最小 CNN 的完整前向链路:前半段提特征,最后一层把特征翻译成类别概率。

模块吃进去什么吐出来什么主要可训练参数
卷积层 图片张量 [C_in, H, W] 特征图 [C_out, H_out, W_out] 卷积核权重 + 偏置
ReLU 卷积输出 非负特征图
池化层 特征图 尺寸更小的特征图 通常无
Flatten 多通道二维特征图 一条长向量
分类头 Flatten 后向量 10 个 logits / 概率 全连接权重 + 偏置

所以 CNN 并不是“只有卷积”。真正完整的分类网络是“卷积特征提取器 + 最后的分类头”一起组成的。

④ 数一数:这张网到底有多少个可训练参数?

“卷积省参数”不能只当口号,得能数出来。参数只藏在两个地方:卷积核 + 卷积偏置,以及分类头的全连接权重 + 偏置(ReLU、池化、Flatten 都没有可训练参数)。两条计数公式如下:

卷积层 = Cout × (Cin × Kh × Kw + 1) 每个输出通道 = 一组 C_in×K_h×K_w 的核权重 + 1 个偏置;放 C_out 个核就乘 C_out。
分类头 = 类别数 × (Flatten 维度 + 1),其中 Flatten 维度 = Cout × Hpool × Wpool 普通全连接:每个类别一行权重(长度 = 池化后摊平的维度)+ 1 个偏置。

代入本仓库最小 CNN 的默认配置(1×28×28 输入,85×5 核、padding=2 → 卷积输出 8×28×28,2×2 池化 → 8×14×14,Flatten = 8×14×14 = 1568,10 类):

形状 / 配置参数量怎么算可训练参数
卷积层 8 个 5×5 核,输入 1 通道 8 × (1×5×5 + 1) = 8 × 26 208
ReLU / 池化 / Flatten 无可训练参数 0
分类头(全连接) 1568 → 10 10 × (1568 + 1) 15 690
合计 208 + 15 690 15 898

数字可用 ./bin/cnn_mnist 里的默认配置(conv-channels=8、kernel-size=5)对上;换参数时把上面两条公式重代一遍即可。

最反直觉的一点:参数几乎全在最后那层全连接

负责“看图”的卷积层只有 208 个参数,只占全网的 1.3%;而最后那层普普通通的全连接分类头却吃掉了 15 690 个,占 98.7%。这正是权重共享的威力——8 个核不管扫多大的图,都只需 208 个权重;可一旦 Flatten 接上全连接,参数量就立刻被“像素数 × 类别数”顶起来。真实 CNN 之所以拼命用卷积/池化把图缩小、并尽量少用大全连接,原因就在这里。

⑤ 训练和推理各在做什么?

训练和推理用的是同一座 CNN,区别不在网络换了,而在于后面有没有标签、有无反向传播。 训练时,图片和正确类别一起喂进去,前向算出 10 类概率,再用交叉熵算损失,把误差一路反着传回去更新卷积层和分类头; 推理时则只有图片,做完前向后直接挑概率最高的那一类即可。

场景喂给模型什么拿到什么后面还做什么
训练 图片 + 正确标签 10 类概率 算交叉熵,再反向传播更新参数
推理 只有图片 10 类概率 取概率最高的类别作为答案
src/deeplearning/cnn/mini_cnn_classifier.cpp · Train(精简)
ForwardFeature(image, conv_output, relu_output,
               pooled_output, flattened);                 1
logits = fc(flattened);
probs = Softmax(logits);                                 2
loss += -log(max(probs[label], 1e-12));                 3

grad_logits = probs;
grad_logits[label] -= 1.0;                              4
pool_.Backward(grad_pooled, grad_relu);                 5
conv_.Backward(grad_conv, grad_input, learning_rate);   6
fc_weight_[cls][dim] -= learning_rate * grad_fc_weight[cls][dim]; 7
  1. 先做完整前向:卷积、ReLU、池化、Flatten,拿到分类头要吃的特征向量。
  2. 分类头把特征向量变成 10 个类别分数,再经 softmax 变成概率。
  3. 用正确标签算交叉熵损失。分类头越不相信正确类别,损失越大。
  4. softmax + 交叉熵的梯度仍是那条老规律: probs - one-hot(label)
  5. 误差先从分类头传回池化输出,再经池化层把梯度路由回“刚才赢了的那个位置”。
  6. 再经过 ReLU 门控回到卷积层,更新卷积核权重。
  7. 最后把全连接分类头也一起更新。也就是说,训练时卷积层和最后分类头是整网一起学的。

推理时就简单得多:只做前向,拿到 10 类概率后取最大值。你可以把它理解成“训练 = 前向 + 反向 + 更新”, “推理 = 只有前向 + 选最大”。这和前面学过的 MLP 没有本质区别,区别只是前半段从“全连接提特征”换成了“卷积提特征”。

6. 一眼看过经典结构

套路都是“卷积/池化叠很多层做特征,最后接几层全连接做分类”,只是越做越深、越做越巧:

CNN、MLP、Transformer 什么关系?

它们是三种不同的“积木搭法”,共享同一套地基(第 1–11 章的前向、损失、反传、优化)。 MLP 适合一行特征、CNN 专攻图像的局部+平移不变、Transformer(第四部分)靠注意力专攻序列。 如今图像领域也越来越多用 Transformer(ViT),但 CNN 的“局部感受野 + 权重共享”思想依然影响深远。

7. 对照真实代码:最小 CNN 怎么拼起来

上面讲的“卷积 → 激活 → 池化 → 分类头”,在配套项目里就被写成了一个非常小的分类器。 你可以把它理解成:前半段负责在图上找特征,最后一层负责拿这些特征做分类

src/deeplearning/cnn/conv2d.cpp · Forward(精简)
output = MakeTensor3D(output_channels_, out_height, out_width);
for (int oc = 0; oc < output_channels_; oc++)
  for (int out_row = 0; out_row < out_height; out_row++)
    for (int out_col = 0; out_col < out_width; out_col++) {
      double sum = bias_[oc];                                  1
      int base_row = out_row * stride_, base_col = out_col * stride_;
      for (int ic = 0; ic < input_channels_; ic++)
        for (int kh = 0; kh < kernel_height_; kh++)
          for (int kw = 0; kw < kernel_width_; kw++)
            sum += weight_[oc][ic][kh][kw] *                  2
                   last_input_padded_[ic][base_row + kh][base_col + kw];
      output[oc][out_row][out_col] = sum;                     3
    }
  1. 一个输出通道 = 一组卷积核权重 + 一个偏置。你可以把它想成“一个专门找某种花纹的检测器”。
  2. 这正是第 2 节那句“小块和核做点积”:对每个输出位置,把覆盖到的局部区域和核逐项相乘再求和。
  3. 三层循环扫完整张图,同一组权重在所有位置反复复用。这就是权重共享,也是 CNN 参数少、能适应平移的根源。
src/deeplearning/cnn/mini_cnn_classifier.cpp · 特征提取 + 分类头(精简)
conv_.Forward(input, conv_output);                 1
relu_output = conv_output;
ApplyRelu(relu_output);                           2
pool_.Forward(relu_output, pooled_output);       3
flattened = FlattenTensor(pooled_output);        4

for (int cls = 0; cls < class_num_; cls++) {
  logits[cls] = fc_bias_[cls];
  for (int dim = 0; dim < flattened_dim_; dim++)
    logits[cls] += fc_weight_[cls][dim] * flattened[dim];
}                                                 5
  1. 先做卷积:拿一组小核在图上滑,把边缘、角点之类的局部花纹提出来。
  2. 再过 ReLU(第 7 章),让网络有非线性表达能力。
  3. 池化把特征图“缩一缩”,减少后面计算,也让轻微平移更不容易把结果弄乱。
  4. 把多通道特征图拉平成一个长向量,准备交给分类头。
  5. 最后还是一层全连接:把“看见了哪些局部特征”汇总成 10 类分数。也就是说,CNN 不是不要全连接,而是把它放在最后做决策
自己跑一个

配套项目里有个最小 demo: src/demo/cnn_mnist。在 src/ 目录编译后运行 ./bin/cnn_mnist --epochs 3 --train-limit 2000 --test-limit 1000, 你会看到它直接在 MNIST 上训练一个 conv → ReLU → max-pool → linear 的小 CNN。 它和第 23 章做的是同一个“识别手写数字”任务,只是前半段的特征提取器从 MLP 换成了 CNN。

8. 亲手跑一遍:同一批数字,MLP 和 CNN 各认出多少?

上一节的“自己跑一个”给了命令,这一节把结果摆上桌。三场小实验,规则只有一条:两边吃完全相同的训练图,谁也不多占。 口径先交代清楚——MLP 用第 23 章实战的原配方(784→128→64→10,Adam + 热身余弦学习率); CNN 用本章 demo 的默认(8 个 5×5 核,单样本 SGD,lr=0.01)。两者的训练器并不相同,所以请把它读作 “两座各自最顺手的积木对打”,而不是“优化器大赛”。下面每条数字,都可以用同一条命令亲手复现:

cd src/
# CNN 一方(参数即默认值,显式写出来方便对照)
./bin/cnn_mnist --epochs 3 \
  --train-limit 2000 --test-limit 10000 \
  --conv-channels 8 --kernel-size 5
# MLP 一方(第 23 章 demo,相同的数据预算)
./bin/mnist --train-limit 2000 --epochs 3

⑥ 第一局:同样 2000 张,谁学得好?

训练集只取前 2000 张、只训 3 轮,再用完整的 10000 张测试图打分:

选手结构可训练参数测试准确率
MLP(第 23 章配方) 784→128→64→10 全连接 109 386 87.1%
最小 CNN(本章 demo) conv→ReLU→pool→linear 15 898 93.2%

同一份数据预算,CNN 高出约 6 个点,参数却只有对方的 1/7。2000 张连 60000 张的零头都不到——MLP 的天赋是“什么都能学”,代价是“什么都得从头学”,小数据恰恰喂不饱它。

这种先验有个专门名字:归纳偏置

“结构自带的先验假设”在行话里叫归纳偏置(inductive bias)。CNN 的偏置是“图像由局部花纹组成、花纹出现在哪个位置都算数”——恰好押对了图像这道题。 MLP 几乎没有这类偏置,表格数据反而更适合它。数据越少,先验越值钱;第一局那 6 个点,买的就是它。

⑦ 第二局:把数字挪个位置,差距拉开

第 3 节说权重共享“自带平移不变”,是骡子是马现在就遛:训练图原样不动,测试时把每张图整体挪 2~3 像素(空出的边缘补黑), 再让两个模型各认一遍。仍然各训 2000 张 / 3 轮,前 1000 张测试图逐张配对打分:

测试图整体挪一挪,两个模型各掉多少分? 同前 2000 张训练 / 3 轮;前 1000 张测试图配对打分;空出的边缘补黑 MLP(第 23 章) CNN(本章) 0% 25% 50% 75% 100% 原始(不挪) 84.9 92.4 右移 2 像素 61.8 74.7 左移 2 像素 53.6 68.7 下移 2 像素 50.9 66.1 上移 2 像素 43.0 56.8 右移 3 像素 44.0 57.6

每一根条都是同 1000 张测试图的准确率:MLP 对平移几乎不设防,四个方向全崩;CNN 全面领先,但注意它自己也在掉。

测试图MLPCNNCNN 领先
原始(不挪)84.9%92.4%+7.5
整体右移 2 像素61.8%74.7%+12.9
整体左移 2 像素53.6%68.7%+15.1
整体下移 2 像素50.9%66.1%+15.2
整体上移 2 像素43.0%56.8%+13.8
整体右移 3 像素44.0%57.6%+13.6

四个方向、两种幅度,CNN 全都领先十几分。这就是第 1 节第三条硬伤——“猫在左上角和右下角,MLP 得各学一遍”——在数字上长出的样子。

先别吹过头:CNN 自己也掉了 20 多分

第 3 节说过,卷积给的是平移等变,真正消掉位置靠池化;这张网只有一层 2×2 池化,只吞得掉 1~2 像素的小位移,挪 3 像素之后 Flatten 出来的位置依旧错位。 所以“自带平移不变”的准确版本是:对小位移容忍,不是对任意平移免疫。想扛更大的位移,要么把池化叠深,要么训练时就随机挪一挪——后者正是第 10 章的数据增强

⑧ 第三局:换配置、加数据,两点收尾

最后两张小表,回答“那还能怎么调”(同一个 demo,一次只动一个开关):

变体(其余不动)测试准确率一句话读法
默认:8 个 5×5 核,训 3 轮92.4%基准
减到 4 个核92.8%2000 张时,4 种花纹检测器已够用
加到 16 个核92.7%容量此刻不是瓶颈
核换成 3×390.3%感受野小了,小亏一点
默认结构,改训 10 轮93.6%(训练集 99.7%)还能再挤一点,但训练 / 测试裂口已经张开——正是第 10 章的过拟合

2000 张的小局里,加核、加轮数都只能挤出 1 分上下;真正的变量在数据。

训练集规模(8 个 5×5 核 / 训 3 轮)测试准确率(完整 10000 张)
2 000 张93.2%
10 000 张96.8%
60 000 张(全量)98.0%

全量时,CNN 用 15 898 个参数追平并小幅反超第 23 章 MLP 的约 97.8%(109 386 个参数):数据喂饱之后,结构优势变成了白赚的参数效率

一句话收掉这三局:MLP 是题海选手,CNN 是押中了题的专才。专才的偏置押对了(图像题),就是小数据更快、大数据更省参数; 偏置押错了题,就换另一种积木——序列题的偏置长什么样,正是后面 RNN 与注意力要回答的。

9. 大模型时代,CNN 还剩哪些地盘?

AlexNet 在 2012 年点燃这轮浪潮之后,C 位也换过:2020 年起,注意力和 Transformer 跨进视觉,ViT 一脉拿走了大规模图像榜单; 今天的多模态大模型,“眼睛”也大多长着 ViT 的样子。但“被抢走头条”不等于“被抢走饭碗”——把战场拆开看,结论很清楚:

已经让位的:

还得是 CNN 的:

地盘为什么还是它
小数据与私有数据(工业质检、医疗影像……) 归纳偏置第二次登场:数据越少,先验越值钱——就是第 8 节那 6 个点的放大版
端侧实时视觉(手机夜景多帧、安防摄像头、车载感知) 卷积的计算量随分辨率线性增长、延迟稳定,NPU / DSP 对卷积的支持也最成熟
高分辨率逐像素预测(医学分割、实时检测分割) 注意力的开销随像素数平方级膨胀,全分辨率吃不消;而这些任务恰恰丢不起空间精度
音频频谱图(语音前端、声码器) 时频图也是“图”,“局部花纹检测器”这条路原样适用

一句话:带宽贵、数据少、像素多的场合,卷积那套“小核滑全图”的账,到今天也算得过来。

战况更像“通婚”,不是“改朝换代”

ConvNeXt 拿着 Transformer 时代的训练配方,把纯卷积拉回到能打平 ViT;ViT 自己也借卷积做输入 stem 稳住训练。 所以判断框架的还是第 8 节那句:谁的偏置押中你的题。 图像这道题上,“局部 + 平移”这个偏置到今天依然值钱。

小结

  • 全连接处理图像有三个硬伤:参数爆炸、丢空间结构、无平移不变
  • 卷积 = 一个小核在图上滑动,每处和局部小块做点积,产出特征图
  • 权重共享(同核扫全图):参数暴减 + 保留空间 + 平移不变
  • 池化缩小特征图、增强鲁棒;多核 = 多通道,堆叠出“边缘→部件→物体”的层级特征。
  • 经典结构 LeNet/AlexNet/ResNet 都是“卷积池化做特征 + 全连接分类”,ResNet 用残差堆到很深。
  • 配套项目里的最小 CNN 骨架就是 conv → ReLU → max-pool → linear:前半段提特征,最后一层做分类。
  • 同预算实测(各 2000 张 / 3 轮):CNN 93.2% 对 MLP 87.1%,参数只有 1/7;全量 6 万张时 CNN 到 98.0%,反而小幅反超。
  • 把测试图整体平移 2~3 像素:CNN 各方向仍领先十余点——“平移不变”对小位移成立,不是免疫。
  • 大模型时代的地盘划分:ViT 拿走大数据前沿,CNN 守住小数据、端侧实时、高分辨率逐像素——判定标准不变:谁的偏置押中你的题。

动手与思考

问题 1:同样只学 2000 张,CNN 凭什么甩开 MLP 约 6 个点?

靠结构先验(归纳偏置)。卷积核局部看、全图复用,同一组权重在每张图的每个位置都被检验一遍,相当于 2000 张图被“用出”几十万次局部监督;而 MLP 的每个权重只盯死一个绝对位置,数据少了就学不饱。回想第 3 节的权重共享。

问题 2:平移实验里 CNN 明明“自带平移不变”,为什么准确率也掉了 20 多分?

因为卷积只保证平移等变(响应跟着挪),消位置全靠池化;这张网只有一层 2×2 池化,只吞得掉 1~2 像素,挪 3 像素后 Flatten 的位置依旧错位。所以准确说法是“小位移容忍”,不是任意平移免疫。见第 3 节的辨析卡片。

问题 3:这张 CNN 有 98.7% 的参数挤在最后那层全连接。想继续砍参数,下一刀该砍哪?

砍 Flatten→分类头这条边:再叠一层 2×2 池化把 8×14×14 缩到 8×7×7(Flatten 从 1568 掉到 392),或用全局平均池化把每张特征图直接压成一个数(1568→8),分类头立刻从 15690 掉到百级——参数表见第 5 节。真实 CNN 正是这么干的。

问题 4:不改深网络,想让模型扛住更大幅度的平移,最便宜的一招是什么?

数据增强:训练时就随机平移、旋转、抖动,让“挪过位置的数字”直接进训练集,把想让模型不在乎的变化亲手喂给它。这是第 10 章那剂“更多数据”猛药的具体吃法。

问题 5:把核从 5×5 换成 9×9,参数会涨到多少?猜准确率会大涨吗?

卷积参数 = 8×(1×9×9+1) = 656,仍不到全网的零头;但从消融表看,小数据下加核(4→16)只动 0.几分,kernel 3→5 也只值 2 分——瓶颈在数据,不在核。预期持平或微动;把你猜的数和 ./bin/cnn_mnist --kernel-size 9 的真结果对一对。

问题 6:为什么多模态大模型的“眼睛”大多用 ViT,而不是 CNN?

两个原因都绕回归纳偏置:其一,训视觉编码器时数据以亿计,偏置的“省钱价值”被数据量摊薄——把第 8 节“数据越少先验越值钱”反过来读就行;其二,同一块 Transformer 积木能同时吃文本和图像,工程上最能复用。不是 CNN 不行,是“预训练规模”这道题的赢家换人了。二者的地盘划分见第 9 节

CNN 专治图像。可另一类数据——一句话、一段时间序列——需要“记住前文”。 下一章认识第二位专才:RNN 与 LSTM,顺便看清它有哪些先天短板,为第四部分的注意力埋下伏笔。