第 13 章 · 经典网络结构
CNN 卷积神经网络
前面两部分搭的都是全连接网络(MLP)——每个输入都连到每个神经元。它处理“一行特征”很好, 可一旦面对图像就水土不服。这一章认识第一位“专才”:CNN(卷积神经网络), 它靠卷积这一招,又省参数又懂空间,是计算机视觉几十年的主力。
读完这一章,你会明白
- 用全连接网络处理图像的三个硬伤;
- 卷积到底在算什么:局部感受野 + 一个小滤波器在图上滑动;
- 权重共享为什么能让参数暴减,还自带平移不变;
- 池化、通道、以及“浅层看边缘、深层看物体”的层级特征;
- LeNet / AlexNet / ResNet 这些名字大概是怎么回事。
1. 全连接处理图像,卡在哪?
把一张图片摊平成一个长向量、直接喂给 MLP,会撞上三个问题:
- 参数爆炸:一张 1000×1000 的彩图摊平就是 300 万个输入。第一层哪怕只有 1000 个神经元,权重就有 30 亿个——根本训不动。
- 丢了空间结构:摊平后,原本相邻的像素被拆散到向量各处,“这两个点挨在一起”这种关键信息没了。
- 没有平移不变:猫出现在左上角和出现在右下角,对 MLP 是完全不同的输入,得各学一遍,极其浪费。
CNN 的所有设计,都是冲着这三点来的。
2. 卷积:一个小滤波器,在图上滑一遍
卷积的核心是一个很小的权重方块,叫卷积核 / 滤波器(kernel),比如 3×3。 它不看全图,只盖住图上的一小块(局部感受野),把这一小块和核做点积(第 1 章), 得到一个数;然后滑到下一个位置再算一个数……滑遍全图,就得到一张新的图,叫特征图(feature map)。
卷积:核盖住左上 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
}
- 核在图上滑动,每到一个位置,就把覆盖的小块和核做点积——和一个神经元干的事一模一样,只是只看局部。
- 加偏置、过激活(第 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(填充) 决定。它们直接决定输出特征图的大小:
| 设置 | 效果 | 直觉 |
|---|---|---|
| stride 小 | 输出更大,看得更细 | 像拿放大镜一格一格细扫 |
| stride 大 | 输出更小,算得更快 | 像跳着看,更快但更粗 |
| padding = 0 | 图会越卷越小 | 边缘没有额外空间,核滑不到最外侧外面去 |
| padding > 0 | 可以保住边缘信息,也更容易让输出尺寸不变 | 先给图片外面垫一圈“缓冲带” |
比如 28×28 输入,5×5 卷积核,若 stride=1 且 padding=2,输出仍是 28×28;这就是很多 CNN 的常见设定。
3. 权重共享:参数暴减,还自带平移不变
这看似普通的“滑动”,一举解决了第 1 节的三个硬伤:
- 参数暴减:一个 3×3 的核只有 9 个权重,却要负责整张图。相比全连接动辄上亿,参数少了好几个数量级。
- 保留空间结构:核只看连在一起的一小块,天然尊重“相邻像素有关系”。
- 平移不变:同一个核扫全图,所以猫在哪个角落都能被同一个“猫特征核”检测到——不用各学一遍。
你可以把卷积核想成一个专门检测某种花纹的印章(比如“竖边缘”)。拿它在整张图上一处处盖, 哪里有竖边缘,那里就盖出一个高亮。一个核 = 一种花纹检测器,而且全图通用。这就是权重共享的威力。
4. 池化:把特征图“缩一缩”
池化(pooling)是紧跟卷积的“下采样”:把特征图切成小块(如 2×2),每块只留一个代表值—— 最大池化取最大值,平均池化取平均。好处:
- 缩小尺寸、减少后续计算;
- 增强鲁棒性:物体轻微移动几个像素,最大值往往不变,识别更稳。
很多人第一次学 CNN 会把池化和卷积混在一起。其实它们分工完全不同: 卷积层负责“学花纹检测器”,有可训练权重;而池化层通常没有可训练参数,只是把已经提出来的特征图 缩一缩、留一个代表值。前者更像“发现有什么”,后者更像“把发现过的东西压缩保存”。
| 层 | 有没有可训练权重 | 主要做什么 | 输出尺寸 |
|---|---|---|---|
| 卷积 | 有 | 检测边缘 / 纹理 / 局部部件 | 由 kernel / stride / padding 决定 |
| 池化 | 通常没有 | 下采样,压缩空间尺寸,提高鲁棒性 | 通常明显变小 |
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
}
- 先假设这一小块里最好的值还不存在。
- 扫完整个 2×2 或 3×3 小窗口,只留下最大的那个。
- 输出层拿到的是“这一小块里最强的响应”。所以池化不是在学新特征,而是在保留最显著的旧特征。
5. 通道与堆叠:从边缘到物体
一个核只能检测一种花纹,所以每层会用很多个核,产出很多张特征图——这些就是 通道(channel)(彩图输入本身就有 R/G/B 三通道)。把“卷积 + 激活 + 池化”一层层堆起来, 就出现了神奇的层级特征:
越往深层,特征越抽象——从边缘,到部件,到整个物体。这正是第 0 章说的“网络自己逐层提炼特征”。
这里再强调一次“通道”这个词,因为它在 CNN 里会出现两次、容易混: 输入通道说的是“进来有几张图一起看”(灰度 1 张、RGB 3 张); 输出通道说的是“这一层产出了几张特征图”,它等于卷积核的个数。 所以“通道变多”通常不是原图变彩了,而是模型在同一层里学会了更多种花纹检测器。
| 场景 | 输入通道 | 卷积核个数 | 输出通道 |
|---|---|---|---|
| MNIST 灰度图 | 1 | 8 | 8 |
| RGB 彩图 | 3 | 32 | 32 |
“输出通道 = 核的个数”是最该记住的一条。一个核负责一种花纹,多个核并排工作,就得到多张特征图。
② 最后怎么从特征图变成“这是一只猫 / 这是数字 7”?
卷积、池化一路做下来,拿到的还是特征图,不是最终类别。真正把“看见了哪些边缘 / 部件”翻译成 “这张图属于哪一类”的,通常是最后那几层分类头:先把多通道特征图拉平(flatten), 再接一层或几层全连接,最后在类别数上输出 logits,过 softmax 得到概率。
CNN 前半段做的是“提特征”,最后那层线性层做的是“拿这些特征做决策”。
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
}
- 先把池化后的特征图准备好。到这一步为止,模型还只是在“看图提特征”。
Flatten把多通道二维特征图摊成一个长向量,方便交给普通线性层。- 最后那层线性层把“看见了哪些特征”汇总成每个类别的分数(logits)。这一步和MNIST 的 MLP 输出层在本质上是一回事。
③ 这个最小 CNN 分类网络长什么样?
到这里,卷积、池化、分类头这些零件都认识了。把它们按顺序拼起来,一个最小 CNN 分类器其实非常朴素: 图片进来 → 卷积提局部特征 → ReLU 加非线性 → 池化缩图 → Flatten 拉平 → 线性层输出类别分数。 下面拿本仓库的 MNIST 小 CNN 举一个具体尺寸的例子(1 通道 28×28 输入,8 个 5×5 卷积核,padding=2,2×2 池化):
这就是本仓库最小 CNN 的完整前向链路:前半段提特征,最后一层把特征翻译成类别概率。
| 模块 | 吃进去什么 | 吐出来什么 | 主要可训练参数 |
|---|---|---|---|
| 卷积层 | 图片张量 [C_in, H, W] |
特征图 [C_out, H_out, W_out] |
卷积核权重 + 偏置 |
| ReLU | 卷积输出 | 非负特征图 | 无 |
| 池化层 | 特征图 | 尺寸更小的特征图 | 通常无 |
| Flatten | 多通道二维特征图 | 一条长向量 | 无 |
| 分类头 | Flatten 后向量 | 10 个 logits / 概率 | 全连接权重 + 偏置 |
所以 CNN 并不是“只有卷积”。真正完整的分类网络是“卷积特征提取器 + 最后的分类头”一起组成的。
④ 训练和推理各在做什么?
训练和推理用的是同一座 CNN,区别不在网络换了,而在于后面有没有标签、有无反向传播。 训练时,图片和正确类别一起喂进去,前向算出 10 类概率,再用交叉熵算损失,把误差一路反着传回去更新卷积层和分类头; 推理时则只有图片,做完前向后直接挑概率最高的那一类即可。
| 场景 | 喂给模型什么 | 拿到什么 | 后面还做什么 |
|---|---|---|---|
| 训练 | 图片 + 正确标签 | 10 类概率 | 算交叉熵,再反向传播更新参数 |
| 推理 | 只有图片 | 10 类概率 | 取概率最高的类别作为答案 |
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
- 先做完整前向:卷积、ReLU、池化、Flatten,拿到分类头要吃的特征向量。
- 分类头把特征向量变成 10 个类别分数,再经 softmax 变成概率。
- 用正确标签算交叉熵损失。分类头越不相信正确类别,损失越大。
- softmax + 交叉熵的梯度仍是那条老规律:
probs - one-hot(label)。 - 误差先从分类头传回池化输出,再经池化层把梯度路由回“刚才赢了的那个位置”。
- 再经过 ReLU 门控回到卷积层,更新卷积核权重。
- 最后把全连接分类头也一起更新。也就是说,训练时卷积层和最后分类头是整网一起学的。
推理时就简单得多:只做前向,拿到 10 类概率后取最大值。你可以把它理解成“训练 = 前向 + 反向 + 更新”, “推理 = 只有前向 + 选最大”。这和前面学过的 MLP 没有本质区别,区别只是前半段从“全连接提特征”换成了“卷积提特征”。
6. 一眼看过经典结构
套路都是“卷积/池化叠很多层做特征,最后接几层全连接做分类”,只是越做越深、越做越巧:
- LeNet(1998):CNN 的鼻祖,就是用来识别手写数字(和本书的 MNIST 同一个任务)。
- AlexNet(2012):在 ImageNet 大赛上一鸣惊人,点燃了这一轮深度学习浪潮。
- ResNet(2015):靠残差连接(还记得吗?第 18 章 Transformer 也用它)把网络堆到上百层还训得动。
它们是三种不同的“积木搭法”,共享同一套地基(第 1–11 章的前向、损失、反传、优化)。 MLP 适合一行特征、CNN 专攻图像的局部+平移不变、Transformer(第四部分)靠注意力专攻序列。 如今图像领域也越来越多用 Transformer(ViT),但 CNN 的“局部感受野 + 权重共享”思想依然影响深远。
7. 对照真实代码:最小 CNN 怎么拼起来
上面讲的“卷积 → 激活 → 池化 → 分类头”,在配套项目里就被写成了一个非常小的分类器。 你可以把它理解成:前半段负责在图上找特征,最后一层负责拿这些特征做分类。
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
}
- 一个输出通道 = 一组卷积核权重 + 一个偏置。你可以把它想成“一个专门找某种花纹的检测器”。
- 这正是第 2 节那句“小块和核做点积”:对每个输出位置,把覆盖到的局部区域和核逐项相乘再求和。
- 三层循环扫完整张图,同一组权重在所有位置反复复用。这就是权重共享,也是 CNN 参数少、能适应平移的根源。
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
- 先做卷积:拿一组小核在图上滑,把边缘、角点之类的局部花纹提出来。
- 再过 ReLU(第 7 章),让网络有非线性表达能力。
- 池化把特征图“缩一缩”,减少后面计算,也让轻微平移更不容易把结果弄乱。
- 把多通道特征图拉平成一个长向量,准备交给分类头。
- 最后还是一层全连接:把“看见了哪些局部特征”汇总成 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,顺便看清它有哪些先天短板,为第四部分的注意力埋下伏笔。