第 13 章 · 经典网络结构
CNN 卷积神经网络
前面两部分搭的都是全连接网络(MLP)——每个输入都连到每个神经元。它处理“一行特征”很好, 可一旦面对图像就水土不服。这一章认识第一位“专才”:CNN(卷积神经网络), 它靠卷积这一招,又省参数又懂空间,是计算机视觉几十年的主力。
读完这一章,你会明白
- 用全连接网络处理图像的三个硬伤;
- 卷积到底在算什么:局部感受野 + 一个小滤波器在图上滑动;
- 权重共享为什么能让参数暴减,还自带平移不变;
- 池化、通道、以及“浅层看边缘、深层看物体”的层级特征;
- LeNet / AlexNet / ResNet 这些名字大概是怎么回事;
- 亲手跑通最小 CNN,亲眼看它在小数据和平移测试里赢过 MLP;
- 在 ViT 当道的年代,CNN 还握着哪些地盘、握住的凭什么。
CNN 主干 = 输入图像 → 可堆叠的卷积块 → Flatten → 分类头。能堆叠的是中间的 Conv Block,不是最后的 Linear Head。
1. 全连接处理图像,卡在哪?
把一张图片摊平成一个长向量、直接喂给 MLP,会撞上三个问题:
- 参数爆炸:一张 1000×1000 的彩图摊平就是 300 万个输入。第一层哪怕只有 1000 个神经元,权重就有 30 亿个——根本训不动。
- 丢了空间结构:摊平后,原本相邻的像素被拆散到向量各处,“这两个点挨在一起”这种关键信息没了。
- 没有平移不变:猫出现在左上角和出现在右下角,对 MLP 是完全不同的输入,得各学一遍,极其浪费。
CNN 的所有设计,都是冲着这三点来的。
2. 卷积:一个小滤波器,在图上滑一遍
卷积的核心是一个很小的权重方块,叫卷积核 / 滤波器(kernel),比如 3×3。 它不看全图,只盖住图上的一小块(局部感受野),把这一小块和核做点积(第 1 章), 得到一个数;然后滑到下一个位置再算一个数……滑遍全图,就得到一张新的图,叫特征图(feature map)。
卷积:核盖住左上 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 章),得到特征图上的一个像素。同一个核扫遍全图——这就是下一节的“权重共享”。
① 用一条公式把“滑动点积”钉死
上面的伪代码写成一条公式,就是卷积层对每一个输出位置 (i, j) 做的事(先看单通道、先不加激活):
这条式子和配套代码里 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):
卷积单格计算:把小块与核逐位置相乘,再全部加起来(核里为 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(填充) 决定。它们直接决定输出特征图的大小:
| 设置 | 效果 | 直觉 |
|---|---|---|
| stride 小 | 输出更大,看得更细 | 像拿放大镜一格一格细扫 |
| stride 大 | 输出更小,算得更快 | 像跳着看,更快但更粗 |
| padding = 0 | 图会越卷越小 | 边缘没有额外空间,核滑不到最外侧外面去 |
| padding > 0 | 可以保住边缘信息,也更容易让输出尺寸不变 | 先给图片外面垫一圈“缓冲带” |
比如 28×28 输入,5×5 卷积核,若 stride=1 且 padding=2,输出仍是 28×28;这就是很多 CNN 的常见设定。
3. 权重共享:参数暴减,还自带平移不变
这看似普通的“滑动”,一举解决了第 1 节的三个硬伤:
- 参数暴减:一个 3×3 的核只有 9 个权重,却要负责整张图。相比全连接动辄上亿,参数少了好几个数量级。
- 保留空间结构:核只看连在一起的一小块,天然尊重“相邻像素有关系”。
- 平移不变:同一个核扫全图,所以猫在哪个角落都能被同一个“猫特征核”检测到——不用各学一遍。
精确一点讲,卷积本身给的是平移等变(equivariance):物体往右移几个像素,它在特征图上的响应也跟着往右移,而不是原地不动。 真正让最终判断“猫在哪个角落都算猫”的平移不变(invariance),是靠后面的池化和全局聚合把位置信息“抹平”出来的。 本书为了直觉常把两者统称“平移不变”,但记住这层区别,你读论文时不会被绕晕。
你可以把卷积核想成一个专门检测某种花纹的印章(比如“竖边缘”)。拿它在整张图上一处处盖, 哪里有竖边缘,那里就盖出一个高亮。一个核 = 一种花纹检测器,而且全图通用。这就是权重共享的威力。
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 并不是“只有卷积”。真正完整的分类网络是“卷积特征提取器 + 最后的分类头”一起组成的。
④ 数一数:这张网到底有多少个可训练参数?
“卷积省参数”不能只当口号,得能数出来。参数只藏在两个地方:卷积核 + 卷积偏置,以及分类头的全连接权重 + 偏置(ReLU、池化、Flatten 都没有可训练参数)。两条计数公式如下:
代入本仓库最小 CNN 的默认配置(1×28×28 输入,8 个 5×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 类概率 | 取概率最高的类别作为答案 |
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。
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 张测试图逐张配对打分:
每一根条都是同 1000 张测试图的准确率:MLP 对平移几乎不设防,四个方向全崩;CNN 全面领先,但注意它自己也在掉。
| 测试图 | MLP | CNN | CNN 领先 |
|---|---|---|---|
| 原始(不挪) | 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 得各学一遍”——在数字上长出的样子。
第 3 节说过,卷积给的是平移等变,真正消掉位置靠池化;这张网只有一层 2×2 池化,只吞得掉 1~2 像素的小位移,挪 3 像素之后 Flatten 出来的位置依旧错位。 所以“自带平移不变”的准确版本是:对小位移容忍,不是对任意平移免疫。想扛更大的位移,要么把池化叠深,要么训练时就随机挪一挪——后者正是第 10 章的数据增强。
⑧ 第三局:换配置、加数据,两点收尾
最后两张小表,回答“那还能怎么调”(同一个 demo,一次只动一个开关):
| 变体(其余不动) | 测试准确率 | 一句话读法 |
|---|---|---|
| 默认:8 个 5×5 核,训 3 轮 | 92.4% | 基准 |
| 减到 4 个核 | 92.8% | 2000 张时,4 种花纹检测器已够用 |
| 加到 16 个核 | 92.7% | 容量此刻不是瓶颈 |
| 核换成 3×3 | 90.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 的样子。但“被抢走头条”不等于“被抢走饭碗”——把战场拆开看,结论很清楚:
已经让位的:
- 大规模图像分类 / 检测的前沿榜单:ViT、Swin、DINOv 一脉轮流坐庄——它们吃得起海量数据,正好养活注意力“什么偏置都不带”的大胃口;
- 多模态大模型的视觉编码器:CLIP-ViT 一支几乎成了默认的“眼睛”;
- 图像生成的新主干:DiT 把扩散模型从 U-Net 往 Transformer 搬——不过留意,U-Net 里至今也杵着大量卷积。
还得是 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,顺便看清它有哪些先天短板,为第四部分的注意力埋下伏笔。