第 13 章 · 经典网络结构

CNN 卷积神经网络

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

读完这一章,你会明白

  • 用全连接网络处理图像的三个硬伤;
  • 卷积到底在算什么:局部感受野 + 一个小滤波器在图上滑动;
  • 权重共享为什么能让参数暴减,还自带平移不变;
  • 池化通道、以及“浅层看边缘、深层看物体”的层级特征;
  • LeNet / AlexNet / ResNet 这些名字大概是怎么回事。

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

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

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

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

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

输入图(5×5) 3×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 章),得到特征图上的一个像素。同一个核扫遍全图——这就是下一节的“权重共享”。

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

真正的卷积层,输入和输出都不是“一串数”,而更像一摞图。 灰度图可以看成 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 节的三个硬伤:

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

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

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 并不是“只有卷积”。真正完整的分类网络是“卷积特征提取器 + 最后的分类头”一起组成的。

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

训练和推理用的是同一座 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。

小结

  • 全连接处理图像有三个硬伤:参数爆炸、丢空间结构、无平移不变
  • 卷积 = 一个小核在图上滑动,每处和局部小块做点积,产出特征图
  • 权重共享(同核扫全图):参数暴减 + 保留空间 + 平移不变
  • 池化缩小特征图、增强鲁棒;多核 = 多通道,堆叠出“边缘→部件→物体”的层级特征。
  • 经典结构 LeNet/AlexNet/ResNet 都是“卷积池化做特征 + 全连接分类”,ResNet 用残差堆到很深。
  • 配套项目里的最小 CNN 骨架就是 conv → ReLU → max-pool → linear:前半段提特征,最后一层做分类。

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