历史背景
卷积的概念来自 1980 年福岛邦彦的 Neocognitron(新认知机),灵感来自 Hubel 和 Wiesel 对猫视觉皮层的研究——神经元只对局部区域和特定方向的边缘有响应。
福岛邦彦模仿视觉皮层设计了两种细胞:
- S 细胞(Simple):类似卷积层,用一组权重检测局部特征(比如某个方向的边缘)
- C 细胞(Complex):类似池化层,对 S 细胞的输出做"最大值"操作,提供平移不变性
这和现代 CNN 的 Conv + Pool 结构几乎一致。1989 年 LeCun 第一次将反向传播和卷积结合,用于识别手写邮政编码。1998 年的 LeNet-5 在 MNIST 上达到 99%+ 的准确率,并被美国银行用于识别支票上的手写金额。
2012 年的 AlexNet 以 15.3% 的 top-5 错误率(第二名 26.2%)赢得了 ImageNet 大赛,标志着 CNN 真正成为计算机视觉的主导方法。
LeNet (1998) → 手写数字识别,卷积首次成功应用
AlexNet (2012) → 深层 CNN + GPU 训练,ImageNet 冠军,引爆深度学习革命
VGG (2014) → 全 3×3 小卷积核堆叠的深层 CNN
ResNet (2015) → 残差连接,152 层 CNN
MobileNet (2017) → 深度可分离卷积,移动端部署
EfficientNet → 复合缩放,效率和精度的最优平衡
下面从最基础的"什么是卷积"开始,一步步拆解 CNN 的核心概念。
什么是卷积
卷积就是用一个"小窗口"在图像上滑动扫描,每次窗口覆盖的区域做一次加权求和。这个小窗口就是卷积核(也叫滤波器),它的滑动过程就是滑动窗口操作。
滑动窗口是怎么工作的
想象你拿一个放大镜在图片上从左到右、从上到下移动,放大镜能看到的区域就是"窗口":
输入图像 (4x4): 卷积核 (3x3): 输出 (2x2):
1 2 3 4 1 0 -1 ? ?
5 6 7 8 1 0 -1 ? ?
9 10 11 12 1 0 -1
13 14 15 16
滑动过程:
第1步: 窗口停在左上角 第2步: 向右滑一格
┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐
│ █ │ █ │ █ │ │ │ │ █ │ █ │ █ │
├───┼───┼───┼───┤ ├───┼───┼───┼───┤
│ █ │ █ │ █ │ │ │ │ █ │ █ │ █ │
├───┼───┼───┼───┤ ├───┼───┼───┼───┤
│ █ │ █ │ █ │ │ │ │ █ │ █ │ █ │
├───┼───┼───┼───┤ ├───┼───┼───┼───┤
│ │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┘ └───┴───┴───┴───┘
第3步: 向下滑一格 第4步: 再向右滑一格
┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐
│ │ │ │ │ │ │ │ │ │
├───┼───┼───┼───┤ ├───┼───┼───┼───┤
│ █ │ █ │ █ │ │ │ │ █ │ █ │ █ │
├───┼───┼───┼───┤ ├───┼───┼───┼───┤
│ █ │ █ │ █ │ │ │ │ █ │ █ │ █ │
├───┼───┼───┼───┤ ├───┼───┼───┼───┤
│ █ │ █ │ █ │ │ │ │ █ │ █ │ █ │
└───┴───┴───┴───┘ └───┴───┴───┴───┘
每个位置算一个数,拼起来就是输出。
完整计算过程
第一步:窗口放在左上角
图像区域: 卷积核:
1 2 3 1 0 -1
5 6 7 × 1 0 -1
9 10 11 1 0 -1
对应位置相乘再求和:
= 1×1 + 2×0 + 3×(-1) + 5×1 + 6×0 + 7×(-1) + 9×1 + 10×0 + 11×(-1)
= 1 + 0 - 3 + 5 + 0 - 7 + 9 + 0 - 11
= -6
输出(0,0) = -6
第二步:向右滑一格
图像区域: 卷积核:
2 3 4 1 0 -1
6 7 8 × 1 0 -1
10 11 12 1 0 -1
= 2×1 + 3×0 + 4×(-1) + 6×1 + 7×0 + 8×(-1) + 10×1 + 11×0 + 12×(-1)
= 2 - 4 + 6 - 8 + 10 - 12
= -6
输出(0,1) = -6
依次类推,最终输出:
[-6 -6]
[-6 -6]
用 PyTorch 验证:
import torch
import torch.nn.functional as F
img = torch.tensor([
[ 1, 2, 3, 4],
[ 5, 6, 7, 8],
[ 9, 10, 11, 12],
[13, 14, 15, 16]
]).float().unsqueeze(0).unsqueeze(0) # (1, 1, 4, 4)
kernel = torch.tensor([
[[ 1, 0, -1],
[ 1, 0, -1],
[ 1, 0, -1]]
]).float().unsqueeze(0) # (1, 1, 3, 3)
out = F.conv2d(img, kernel)
print(out[0, 0])
# tensor([[-6., -6.],
# [-6., -6.]])
输出尺寸怎么算
输出高度 = (输入高度 - 卷积核高度) // stride + 1
输出宽度 = (输入宽度 - 卷积核宽度) // stride + 1
默认 stride=1 时简化为:
输出高度 = 输入高度 - 卷积核高度 + 1
输出宽度 = 输入宽度 - 卷积核宽度 + 1
为什么是这个公式?用一个具体例子看。
输入 5×5,卷积核 3×3,stride=1:
核从左到右能停在几个位置?
位置1(列 0-2) 位置2(列 1-3) 位置3(列 2-4)
┌───┬───┬───┬───┬───┐ ┌───┬───┬───┬───┬───┐ ┌───┬───┬───┬───┬───┐
│ █ │ █ │ █ │ │ │ │ │ █ │ █ │ █ │ │ │ │ │ █ │ █ │ █ │
├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤
│ █ │ █ │ █ │ │ │ │ │ █ │ █ │ █ │ │ │ │ │ █ │ █ │ █ │
├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤
│ █ │ █ │ █ │ │ │ │ │ █ │ █ │ █ │ │ │ │ │ █ │ █ │ █ │
├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┘ └───┴───┴───┴───┴───┘ └───┴───┴───┴───┴───┘
能停 3 次 = 5 - 3 + 1
核的宽度是 3。输入宽 5,核占掉 3,还剩 2 个"可移动的空间"。这 2 次移动加上起始的 1 次(不移动),就是 5 - 3 + 1 = 3 次,那个 +1 就是起始位置本身。
行方向同理,核从上到下也只能停 3 次。所以输出是 3×3。
如果 stride=2(每次跳一格),情况就不同了:
输入 5×5,卷积核 3×3,stride=2:
位置1(列 0-2) 位置2(列 2-4)
┌───┬───┬───┬───┬───┐ ┌───┬───┬───┬───┬───┐
│ █ │ █ │ █ │ │ │ │ │ │ █ │ █ │ █ │
├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤
│ █ │ █ │ █ │ │ │ │ │ │ █ │ █ │ █ │
├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤
│ █ │ █ │ █ │ │ │ │ │ │ █ │ █ │ █ │
├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤
│ │ │ │ │ │ │ │ │ │ │ │
├───┼───┼───┼───┼───┤ ├───┼───┼───┼───┼───┤
│ │ │ │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┘ └───┴───┴───┴───┴───┘
能停 2 次 = (5 - 3) // 2 + 1
stride=2 意味着每次滑 2 格。核从列 0-2 直接跳到列 2-4(中间没有"列 1-3"这个位置)。能停的次数是 (5 - 3) // 2 + 1 = 2。
通用公式(含 stride):
输出尺寸 = (输入尺寸 - 核尺寸) // stride + 1
卷积核在"看"什么
不同数值的卷积核能检测不同的特征。
竖直边缘检测
用一张左边白、右边黑的图测试:
import numpy as np
import torch
import torch.nn.functional as F
kernel = [[ 1, 0, -1],
[ 1, 0, -1],
[ 1, 0, -1]]
img = np.array([
[1, 1, 1, 0, 0, 0],
[1, 1, 1, 0, 0, 0],
[1, 1, 1, 0, 0, 0],
[1, 1, 1, 0, 0, 0],
[1, 1, 1, 0, 0, 0],
[1, 1, 1, 0, 0, 0],
], dtype=np.float32)
卷积结果:
[[0. 3. 3. 0.]
[0. 3. 3. 0.]
[0. 3. 3. 0.]
[0. 3. 3. 0.]]
解读:
- 第 0 列:全 0 → 纯白区域,没有边缘
- 第 1、2 列:全是 3 → 白黑交界处,检测到竖边
- 第 3 列:全 0 → 纯黑区域,没有边缘
核的左列是 +1,右列是 -1。当左边亮(值大)、右边暗(值小)时,结果是大正值。纯白或纯黑区域正负抵消,结果是 0。
水平边缘检测
用一张上面白、下面黑的图测试:
kernel = [[ 1, 1, 1],
[ 0, 0, 0],
[-1, -1, -1]]
img = np.array([
[1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
], dtype=np.float32)
卷积结果:
[[0. 0. 0. 0.]
[3. 3. 3. 3.]
[3. 3. 3. 3.]
[0. 0. 0. 0.]]
解读:
- 第 0 行:全 0 → 纯白区域,没有边缘
- 第 1、2 行:全是 3 → 白黑交界处(行 2-3 之间),检测到横边
- 第 3 行:全 0 → 纯黑区域,没有边缘
核的上行是 +1,下行是 -1。当上边亮(值大)、下边暗(值小)时,结果是大正值。纯白或纯黑区域正负抵消,结果是 0。
核心思想
传统图像处理中,这些核是人为设计的。深度学习的关键突破是:核的值不是人定的,是网络自己学出来的。网络通过训练发现什么样的核最有利于完成任务。
为什么引入卷积
全连接的问题:参数量爆炸
一张 224×224×3 的 RGB 图像,展平后有 150,528 个像素。全连接到一个 1000 节点的输出层(1000 是 ImageNet 的类别数,也是 AlexNet、ResNet 等经典网络的输出维度):
150,528 × 1,000 = 1.5 亿个参数
而 AlexNet 第一个卷积层只用了几万个参数,差了数千倍。参数太多意味着:
- 需要极大量的训练数据
- 极易过拟合
- 计算和存储成本不可接受
全连接的问题:破坏空间结构
全连接层把图像展平成一维向量,每个像素对应一个固定的输入节点。展平后,位置信息变成了"这个像素是第几个节点":
4×4 图像: 展平后:
(0,0) (0,1) (0,2) (0,3) 节点0 节点1 节点2 节点3
(1,0) (1,1) (1,2) (1,3) 节点4 节点5 节点6 节点7
(2,0) (2,1) (2,2) (2,3) → 节点8 节点9 节点10 节点11
(3,0) (3,1) (3,2) (3,3) 节点12 节点13 节点14 节点15
假设"猫耳朵"是一个 2x2 的亮块:
- 在左上角时,占据像素 (0,0)(0,1)(1,0)(1,1) → 对应节点 0,1,4,5
- 在右下角时,占据像素 (2,2)(2,3)(3,2)(3,3) → 对应节点 10,11,14,15
"猫耳朵"在左上角时,节点 0,1,4,5 被激活,它们连到隐藏层的权重是 W_左上。
"猫耳朵"在右下角时,节点 10,11,14,15 被激活,它们连到隐藏层的权重是 W_右下。
这是两组完全独立的权重参数。模型不知道这两组模式其实描述的是同一个东西。它必须通过训练数据看到足够多的"左上角有耳朵的猫"和"右下角有耳朵的猫",才能分别学会。
猫耳朵在左上角 → 激活节点 0,1,4,5 → 权重 W_左上 → 识别为"猫"
猫耳朵在右下角 → 激活节点 10,11,14,15 → 权重 W_右下 → 识别为"猫"
卷积就不一样:同一个核在整张图上滑动,不管"猫耳朵"出现在哪个位置,用的都是同一组权重。学一次,处处能用,这就是平移不变性。
nn.Conv2d 函数
PyTorch 中用 nn.Conv2d 创建卷积层:
nn.Conv2d(in_channels, out_channels, kernel_size,
stride=1, padding=0, dilation=1, groups=1, bias=True)
| 参数 | 默认值 | 含义 |
|---|---|---|
in_channels | 必填 | 输入通道数。灰度图是 1,RGB 是 3 |
out_channels | 必填 | 输出通道数,也是卷积核的数量 |
kernel_size | 必填 | 卷积核尺寸,5 表示 5×5,(3,5) 表示 3×5 |
stride | 1 | 滑动步长,每次移动几格 |
padding | 0 | 边缘填充,四周补几圈 0 |
dilation | 1 | 空洞卷积,核内像素间距 |
groups | 1 | 分组卷积,通道分组计算 |
bias | True | 是否加偏置,每个核一个偏置项 |
最常用的就是前三个参数:
# 灰度图输入,10 个 5×5 的核
nn.Conv2d(1, 10, 5)
卷积的三个核心优势
1. 局部性
nn.Conv2d(1, 10, 5)的 5×5 核只关注 25 个像素的局部区域- 识别"左眼"只需要看左眼那块像素,不需要同时看右下角像素
2. 参数共享
- 同一个"竖边检测核"在整张图上都用同一组权重
- 左上角用这组权重,右下角也用这组权重
- 不需要为每个位置学一套参数
3. 平移不变性
- 因为参数共享,特征在哪都能被检测到
- 猫在图的左边或右边,卷积都能认出来
这三点是因果关系:局部性 + 参数共享 → 平移不变性。
卷积层的参数计算
参数量公式:
参数量 = (kernel_h × kernel_w × in_channels) × out_channels + out_channels(偏置)
代入数字:
每个核: 3 × 3 × 3 = 27 个权重 ← 3 是因为 RGB 有 3 个通道
64 个核: 27 × 64 = 1,728
偏置: 64(每个核一个)
总计: 1,728 + 64 = 1,792
验证:
conv.weight.shape # torch.Size([64, 3, 3, 3])
conv.bias.shape # torch.Size([64])
sum(p.numel() for p in conv.parameters()) # 1792
权重张量的形状 [64, 3, 3, 3] 含义:
[卷积核数, 输入通道, 核高度, 核宽度]
↑ ↑ ↑ ↑
64个核 RGB=3 3 × 3
每个核有 3 个通道(分别对应 R、G、B),每个通道一个 3×3 的权重矩阵。计算过程是:
输入 (H, W, 3) → 核1的3个通道分别与R/G/B做卷积 → 3个结果相加 → 输出特征图1 (H', W')
→ 核2的3个通道分别与R/G/B做卷积 → 3个结果相加 → 输出特征图2 (H', W')
→ ...
→ 核64做同样操作 → 输出特征图64 (H', W')
简单说:一个核(含3个通道)扫描一次得到一张特征图,64 个核就得到 64 张特征图。
不同模型的卷积参数量
# ResNet-18 第一层
nn.Conv2d(3, 64, 7, stride=2, padding=3)
# 参数: (7×7×3)×64 + 64 = 9,472
# MNIST CNN 第一层
nn.Conv2d(1, 10, 5)
# 参数: (5×5×1)×10 + 10 = 260
卷积层的串联
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(1, 10, 5) # 输入 1 通道,输出 10 通道
self.conv2 = nn.Conv2d(10, 20, 5) # 输入 10 通道,输出 20 通道
self.fc = nn.Linear(320, 10)
def forward(self, x):
# conv1 卷积 → max_pool2d 池化 → ReLU 激活(引入非线性)
x = F.relu(F.max_pool2d(self.conv1(x), 2))
# conv2 卷积 → max_pool2d 池化 → ReLU 激活
x = F.relu(F.max_pool2d(self.conv2(x), 2))
# 展平后送入全连接层
x = x.view(-1, 320)
return self.fc(x)
关键规则:前一层的输出通道数必须等于后一层的输入通道数。
conv1: nn.Conv2d(1, 10, 5) ← 输出 10 通道
conv2: nn.Conv2d(10, 20, 5) ← 输入必须是 10 通道
# ↑
# 必须匹配 conv1 的 out_channels
最大池化
在卷积层之间,通常会插入池化层来降低空间尺寸。
F.max_pool2d(x, 2, 2) # 2×2 窗口,stride=2
用 2×2 的窗口在特征图上滑动,每个窗口只取最大值。
输入 (4x4):
[[ 1, 3, 2, 4],
[ 5, 6, 1, 2],
[ 3, 2, 8, 7],
[ 4, 1, 5, 3]]
2×2 窗口,stride=2:
左上: ┌───┬───┐ 右上: ┌───┬───┐
│ 1 │ 3 │ │ 2 │ 4 │
│ 5 │ 6 │ → max=6 │ 1 │ 2 │ → max=4
└───┴───┘ └───┴───┘
左下: ┌───┬───┐ 右下: ┌───┬───┐
│ 3 │ 2 │ │ 8 │ 7 │
│ 4 │ 1 │ → max=4 │ 5 │ 3 │ → max=8
└───┴───┘ └───┴───┘
输出 (2x2):
[[6, 4],
[4, 8]]
尺寸变化:
输出尺寸 = (输入尺寸 - kernel_size) // stride + 1 ← 同卷积的输出尺寸公式
以 max_pool2d(x, 2, 2) 为例:
24 → (24 - 2) // 2 + 1 = 12 ← 偶数输入时减半
25 → (25 - 2) // 2 + 1 = 12 ← 奇数输入时向下取整
为什么用最大池化:
- 下采样:
(10, 24, 24) → (10, 12, 12),长宽各减半,数据量从10×24×24=5760降到10×12×12=1440,减少了(5760-1440)/5760 = 75% - 保留最强响应:某区域
[0.1, 0.9, 0.2, 0.3],max = 0.9,只保留最显著的特征 - 一定的平移不变性:特征偏移 1 个像素,max 结果不变
F.max_pool2d(x, 2, 2) # 显式写 kernel_size 和 stride
F.max_pool2d(x, 2) # stride 默认等于 kernel_size,等价
数据流
有了卷积和池化,一个完整的 CNN 数据流是这样的:
输入 (1, 28, 28) ← MNIST 灰度图
↓ conv1
(10, 24, 24) ← 24 = 28 - 5 + 1
↓ max_pool2d(2)
(10, 12, 12) ← 12 = (24 - 2) // 2 + 1
↓ conv2
(20, 8, 8) ← 8 = 12 - 5 + 1
↓ max_pool2d(2)
(20, 4, 4) ← 4 = (8 - 2) // 2 + 1
↓ 展平
(320,) ← 20 × 4 × 4
↓ fc
(10,) ← 10 类分类输出
完整训练流程
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torchvision import datasets, transforms
# 1. 定义模型
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
# 卷积层:输入通道1(灰度图),输出通道10,卷积核5x5
# 输入 (batch, 1, 28, 28) → 输出 (batch, 10, 24, 24)
# 得到 10 张 24x24 的特征图,每张对应一个卷积核的扫描结果
self.conv1 = nn.Conv2d(1, 10, 5)
# 卷积层:输入通道10(匹配conv1的输出),输出通道20,卷积核5x5
# 输入 (batch, 10, 12, 12) → 输出 (batch, 20, 8, 8)
# 得到 20 张 8x8 的特征图,捕捉更复杂的模式
self.conv2 = nn.Conv2d(10, 20, 5)
# 全连接层:输入320(20通道 * 4 * 4),输出10(10分类)
# 输入 (batch, 320) → 输出 (batch, 10)
# 将提取的特征映射到10个类别的得分
self.fc = nn.Linear(320, 10)
def forward(self, x):
# 第一层:卷积 + 池化 + ReLU激活
# 输入 x: (batch, 1, 28, 28)
# conv1后: (batch, 10, 24, 24) ← 24 = 28 - 5 + 1,10张特征图
# pool后: (batch, 10, 12, 12) ← 12 = (24 - 2) // 2 + 1,空间尺寸减半
# ReLU: f(x) = max(0, x),将负值置为0,引入非线性,使网络能学习复杂模式
x = F.relu(F.max_pool2d(self.conv1(x), 2))
# 第二层:卷积 + 池化 + ReLU激活
# conv2后: (batch, 20, 8, 8) ← 8 = 12 - 5 + 1,20张特征图
# pool后: (batch, 20, 4, 4) ← 4 = (8 - 2) // 2 + 1,空间尺寸减半
# ReLU 再次引入非线性,过滤掉负响应,保留有意义的特征
x = F.relu(F.max_pool2d(self.conv2(x), 2))
# 展平:(batch, 20, 4, 4) -> (batch, 320) ← 320 = 20 * 4 * 4
# 将多维特征图展平为一维向量,供全连接层处理
x = x.view(-1, 320)
# 全连接:(batch, 320) -> (batch, 10),得到10分类输出
# 每个输出值是对应类别的得分,后续通过 CrossEntropyLoss 计算损失
return self.fc(x)
# 2. 准备数据
# transforms.Compose 将多个预处理步骤组合成一个流水线
# ToTensor: 将 PIL/NumPy 图像转为 Tensor,并将像素值从 [0,255] 缩放到 [0,1]
# Normalize: 用均值和标准差做标准化,(0.1307, 0.3081) 是 MNIST 数据集的统计值
# 标准化公式: (x - mean) / std,使数据分布更接近标准正态分布,加速收敛
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
# 下载并加载 MNIST 训练集
# train=True 表示使用训练集,download=True 表示本地没有则自动下载
# transform 指定对每张图像应用上述预处理流水线
train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
# DataLoader 负责将数据集分批送入模型
# batch_size=64 表示每次返回64张图像和标签,shuffle=True 表示每轮打乱顺序
# 打乱顺序防止模型记住 batch 的顺序,提高泛化能力
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=64, shuffle=True)
# 加载 MNIST 测试集(用于评估模型泛化能力)
# train=False 表示使用测试集,shuffle=False 表示不需要打乱
test_dataset = datasets.MNIST('./data', train=False, transform=transform)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=64, shuffle=False)
# 3. 训练
# 实例化模型(此时参数已随机初始化)
model = SimpleCNN()
# 优化器:Adam 是一种自适应学习率的优化算法,lr=1e-3 表示学习率为 0.001
# model.parameters() 返回模型所有可训练参数(卷积核权重、全连接层权重、偏置等)
# Adam 会根据每个参数的历史梯度自动调整学习率,比传统 SGD 收敛更快
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# 损失函数:CrossEntropyLoss 用于多分类任务,内部包含 Softmax + 负对数似然
# 它将模型的原始输出(logits)转化为概率分布,并计算与真实标签的差距
criterion = nn.CrossEntropyLoss()
# 训练 3 个 epoch(每个 epoch 表示把整个训练集过一遍)
for epoch in range(3):
# 切换为训练模式,启用 Dropout、BatchNorm 等训练时特有的行为
model.train()
# 每个 epoch 内,按 batch 遍历训练集
# MNIST 训练集有 60000 张图,batch_size=64,所以每个 epoch 约 938 个 batch
for images, labels in train_loader:
# 梯度清零:因为 PyTorch 会累积梯度,每次反向传播前必须清零
# 如果不清零,新梯度会和旧梯度叠加,导致参数更新方向错误
optimizer.zero_grad()
# 前向传播:将一批图像送入模型,得到预测输出
# images 形状: (64, 1, 28, 28),output 形状: (64, 10)
output = model(images)
# 计算损失:模型输出和真实标签之间的差距
# output 是 10 类得分,labels 是每个样本的真实类别索引
loss = criterion(output, labels)
# 反向传播:计算损失对每个参数的梯度
# 梯度表示"这个参数应该往哪个方向调才能让损失变小"
loss.backward()
# 参数更新:根据梯度调整模型参数,使损失减小
# Adam 会自动调整每个参数的更新步长
optimizer.step()
# 评估:每个 epoch 结束后在测试集上计算准确率(衡量泛化能力)
# 切换为评估模式,关闭 Dropout、BatchNorm 的训练行为
model.eval()
correct = 0
# torch.no_grad() 表示不记录梯度,节省内存和计算
# 评估阶段不需要反向传播,所以不需要梯度信息
with torch.no_grad():
for images, labels in test_loader:
# 前向传播得到预测
# model(images) 输出 (batch, 10),argmax(dim=1) 取每行最大值的索引,即预测类别
pred = model(images).argmax(dim=1)
# 统计预测正确的数量
# pred == labels 得到一个布尔张量,sum() 计算 True 的个数
correct += (pred == labels).sum()
# 计算准确率:正确数 / 总样本数
accuracy = correct / len(test_dataset)
print(f"Epoch {epoch+1}: Test Accuracy = {accuracy:.4f}")
Epoch 1: Test Accuracy = 0.9752
Epoch 2: Test Accuracy = 0.9849
Epoch 3: Test Accuracy = 0.9865
CNN 的实际用途
虽然 Transformer 在 NLP 领域占据主导,但 CNN 在视觉领域依然是基石,并且在 2024-2026 年仍有大量实际应用:
1. 工业检测与质量控制
产线上的缺陷检测(划痕、裂纹、异物)几乎都用 CNN。原因是 CNN 对局部特征敏感,且推理速度快、部署成本低。特斯拉、宁德时代、富士康的产线都在用。
2. 医学影像分析
- X 光/CT 肺结节检测:用 3D 卷积(Conv3D)在体积数据上滑动
- 眼底视网膜分割:U-Net(一种编码器-解码器 CNN 架构)是标准方案
- 病理切片分类:ResNet、EfficientNet 作为 backbone
医学影像的特点是数据量小、精度要求高,CNN 的参数效率和局部感知优势在这里非常明显。
3. 自动驾驶
- 车道线检测:实时 CNN 模型处理摄像头视频流
- 交通标志识别:轻量级 MobileNet 部署在车载芯片上
- 目标检测:YOLO 系列(Ultralytics YOLOv8/v10/YOLO11)的核心 backbone 依然是 CNN
4. 手机摄影
计算摄影几乎离不开 CNN:
- 夜景降噪:CNN 从多帧低曝光图像中恢复细节
- 人像模式虚化:语义分割网络识别人物轮廓
- 超分辨率:SRCNN 及其变体将低分辨率图像放大
苹果、华为、小米的手机 ISP(图像信号处理器)都内置了 CNN 加速模块。
5. 安防与人脸识别
- 门禁系统:人脸检测(MTCNN)+ 特征提取(ArcFace)
- 视频分析:行为识别(SlowFast 网络,基于 3D 卷积)
- 车牌识别:轻量 CNN + CTC 解码
6. 科学计算
- 气象预报:ConvLSTM 预测降雨云图移动
- 材料科学:CNN 分析显微镜图像,检测晶体缺陷
- 天文:从望远镜图像中自动分类星系
7. 与大模型结合
CNN 并没有被 Vision Transformer 取代,而是经常作为混合架构的一部分:
- 多模态模型(如 LLaVA、Qwen-VL)的视觉编码器有时用 CNN(CLIP 本身就支持 ResNet 或 ViT 作为视觉编码器,早期版本多用 ResNet)
- SAM(Segment Anything Model) 的图像编码器是 ViT,但大量下游微调方案用 CNN 做轻量替代
- 端侧部署:在手机、IoT 设备上,CNN 依然比 ViT 更高效
为什么 CNN 没有被取代
| 维度 | CNN | ViT |
|---|---|---|
| 归纳偏置 | 局部性 + 平移不变性(先验知识强) | 全局注意力(需要大量数据学习) |
| 数据效率 | 小数据集上表现好 | 需要大规模预训练 |
| 推理速度 | 卷积运算高度优化(cuDNN、TensorRT) | 注意力复杂度 O(n²) |
| 部署成本 | 手机/嵌入式芯片原生支持 | 需要更多内存和算力 |
CNN 的归纳偏置(inductive bias)是它的核心优势——它"天生就知道"图像处理应该关注局部区域,而不是从头学习这个规律。这使得 CNN 在小数据和资源受限场景下依然不可替代。
总结
卷积神经网络的核心就几个概念:
- 卷积:用核在图上滑动扫描,提取局部特征
- 参数共享:同一个核在全图复用,大幅减少参数量
- 池化:保留最强响应,降低空间尺寸
- 串联:多层卷积从简单特征逐步组合到复杂语义
理解了这四点,你就理解了 CNN 的本质。