引言 上一篇文章我们用全连接网络实现了手写数字识别——但你知道吗?如果换成人脸识别、自动驾驶场景,全连接网络基本不 work。
原因很简单:全连接把 28×28 的图片展平成 784 个独立的像素,完全丢掉了空间结构信息。 一张猫的图片,把像素随机打乱,全连接网络认不出来了。
CNN(卷积神经网络) 就是来解决这个问题的——它用卷积核在图片上滑动,保留空间结构,捕捉局部特征。这也是为什么 CNN 统治了计算机视觉领域近十年。
前置知识
需要已经会用 PyTorch 搭网络、跑训练循环。
一、CNN 的核心思想 为什么全连接不行? 全连接层的参数量 = 输入维度 × 输出维度。对于一张 256×256 的彩色图片:
1 2 输入: 256 × 256 × 3 = 196,608 全连接层(128个神经元): 196,608 × 128 ≈ 2500 万参数
2500 万个参数就一层! 三层下去直接上亿,根本训不动。
CNN 的三大优势
全连接
CNN
每个像素独立
局部连接 — 只关注局部区域
不同位置要重复学习
权值共享 — 同一个卷积核扫过整张图
参数量爆炸
参数量可控 — 取决于卷积核大小而非图片大小
二、卷积层详解 2.1 卷积操作 想象你在图片上放了一个小窗口(3×3 像素),窗口在每个位置都和图片做点积运算 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 输入图片 (5×5) 卷积核 (3×3) 输出特征图 (3×3) ┌──┬──┬──┬──┬──┐ ┌──┬──┬──┐ ┌───┬───┬───┐ │1 │2 │3 │4 │5 │ │1 │0 │-1│ │ ? │ ? │ ? │ ├──┼──┼──┼──┼──┤ ├──┼──┼──┤ ├───┼───┼───┤ │6 │7 │8 │9 │10│ │2 │0 │-2│ │ ? │ ? │ ? │ ├──┼──┼──┼──┼──┤ ├──┼──┼──┤ ├───┼───┼───┤ │11│12│13│14│15│ │1 │0 │-1│ │ ? │ ? │ ? │ ├──┼──┼──┼──┼──┤ └──┴──┴──┘ └───┴───┴───┘ │16│17│18│19│20│ ├──┼──┼──┼──┼──┤ │21│22│23│24│25│ └──┴──┴──┴──┴──┘ 第一个位置计算: 1×1 + 2×0 + 3×(-1) + 6×2 + 7×0 + 8×(-2) + 11×1 + 12×0 + 13×(-1) = 1 + 0 - 3 + 12 + 0 - 16 + 11 + 0 - 13 = -8
这个卷积核是一个垂直边缘检测器 — 它能捕捉到图像中的垂直纹理。
2.2 关键参数 1 2 3 4 5 6 7 8 9 10 import torch.nn as nnconv = nn.Conv2d( in_channels=3 , out_channels=16 , kernel_size=3 , stride=1 , padding=1 )
参数对输出的影响:
1 2 3 4 5 6 7 输出尺寸 = (输入尺寸 + 2×padding - kernel_size) / stride + 1 例:输入 32×32, kernel=3, padding=1, stride=1 输出 = (32 + 2 - 3) / 1 + 1 = 32 # 尺寸不变 例:输入 32×32, kernel=3, padding=0, stride=2 输出 = (32 + 0 - 3) / 2 + 1 = 15.5 → 15 # 下采样
2.3 感受野(Receptive Field) 卷积网络中,越深的层能看到输入图像上越大的区域:
层
卷积核
感受野
第1层卷积
3×3
3×3
第2层卷积
3×3
5×5
第3层卷积
3×3
7×7
两层 3×3 卷积 = 一层 5×5 卷积 ,但参数量更少(2×9 vs 25)。所以现代 CNN 倾向用多层小卷积核堆叠。
三、池化层 池化层对特征图做下采样 ,减少参数量,增加平移不变性。
1 2 3 pool = nn.MaxPool2d(kernel_size=2 , stride=2 )
1 2 3 4 5 6 最大池化 (2×2, stride=2): ┌──┬──┐ ┌──┐ │1 │5 │ │5 │ ├──┼──┤ → ├──┤ │2 │8 │ │8 │ └──┴──┘ └──┘
平均池化 也常用,但在计算机视觉中最大池化更主流(保留最强的特征响应)。
四、经典 CNN 架构实战:CIFAR-10 分类 我们来搭建一个类似 VGG 风格 的 CNN——堆叠卷积层 + 池化层,最后接全连接分类。
4.1 模型定义 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 import torchimport torch.nn as nnimport torch.nn.functional as Fimport torch.optim as optimfrom torch.utils.data import DataLoaderfrom torchvision import datasets, transformsimport matplotlib.pyplot as pltdevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu' ) print (f"设备: {device} " )class SimpleCNN (nn.Module): """简版 VGG 风格 CNN""" def __init__ (self, num_classes=10 ): super ().__init__() self .features = nn.Sequential( nn.Conv2d(3 , 32 , kernel_size=3 , padding=1 ), nn.ReLU(inplace=True ), nn.Conv2d(32 , 32 , kernel_size=3 , padding=1 ), nn.ReLU(inplace=True ), nn.MaxPool2d(kernel_size=2 , stride=2 ), nn.Conv2d(32 , 64 , kernel_size=3 , padding=1 ), nn.ReLU(inplace=True ), nn.Conv2d(64 , 64 , kernel_size=3 , padding=1 ), nn.ReLU(inplace=True ), nn.MaxPool2d(kernel_size=2 , stride=2 ), nn.Conv2d(64 , 128 , kernel_size=3 , padding=1 ), nn.ReLU(inplace=True ), nn.Conv2d(128 , 128 , kernel_size=3 , padding=1 ), nn.ReLU(inplace=True ), nn.MaxPool2d(kernel_size=2 , stride=2 ), ) self .classifier = nn.Sequential( nn.Dropout(0.5 ), nn.Linear(128 * 4 * 4 , 256 ), nn.ReLU(inplace=True ), nn.Dropout(0.5 ), nn.Linear(256 , num_classes), ) def forward (self, x ): x = self .features(x) x = x.view(x.size(0 ), -1 ) x = self .classifier(x) return x model = SimpleCNN().to(device) print (model)
参数量对比:
1 2 3 SimpleCNN: 约 85 万参数 等效全连接网络: 图片 32×32×3=3072,一层 256 个神经元 → 78 万参数 但 CNN 有 6 层卷积,同样深度的全连接网络参数量会爆炸
4.2 数据加载(CIFAR-10) CIFAR-10 是 32×32 的彩色图片(10 个类别:飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 train_transform = transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.RandomCrop(32 , padding=4 ), transforms.ColorJitter(0.1 , 0.1 ), transforms.ToTensor(), transforms.Normalize( (0.4914 , 0.4822 , 0.4465 ), (0.2470 , 0.2435 , 0.2616 ) ) ]) test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize( (0.4914 , 0.4822 , 0.4465 ), (0.2470 , 0.2435 , 0.2616 ) ) ]) train_dataset = datasets.CIFAR10( root='./data' , train=True , download=True , transform=train_transform ) test_dataset = datasets.CIFAR10( root='./data' , train=False , download=True , transform=test_transform ) train_loader = DataLoader(train_dataset, batch_size=128 , shuffle=True , num_workers=2 ) test_loader = DataLoader(test_dataset, batch_size=128 , shuffle=False , num_workers=2 ) print (f"训练集: {len (train_dataset)} 张" )print (f"测试集: {len (test_dataset)} 张" )print (f"类别: {train_dataset.classes} " )
4.3 训练 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001 ) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30 ) EPOCHS = 30 for epoch in range (EPOCHS): model.train() running_loss = 0.0 correct = 0 total = 0 for data, target in train_loader: data, target = data.to(device), target.to(device) optimizer.zero_grad() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() running_loss += loss.item() _, preds = output.max (1 ) total += target.size(0 ) correct += preds.eq(target).sum ().item() scheduler.step() train_acc = 100. * correct / total print (f'Epoch {epoch+1 :2d} /{EPOCHS} | Loss: {running_loss/len (train_loader):.4 f} | ' f'Train Acc: {train_acc:.2 f} %' ) model.eval () test_correct = 0 test_total = 0 with torch.no_grad(): for data, target in test_loader: data, target = data.to(device), target.to(device) _, preds = model(data).max (1 ) test_total += target.size(0 ) test_correct += preds.eq(target).sum ().item() test_acc = 100. * test_correct / test_total print (f' Test Acc: {test_acc:.2 f} %' ) print ("训练完成 ✅" )
用 GPU 训练 30 个 epoch 大约 5-10 分钟,最终测试准确率应达到 80-85% (对于这个简单的网络来说已经不错了)。
4.4 可视化卷积核 1 2 3 4 5 6 7 8 9 10 11 12 13 first_conv = model.features[0 ] weights = first_conv.weight.data.cpu() fig, axes = plt.subplots(4 , 8 , figsize=(12 , 6 )) for i, ax in enumerate (axes.flat): if i < weights.size(0 ): kernel = weights[i].mean(dim=0 ) ax.imshow(kernel, cmap='viridis' ) ax.axis('off' ) plt.suptitle('第一层 32 个卷积核可视化' ) plt.show()
你会看到:有的卷积核学会检测边缘(黑白条纹),有的学会检测颜色块,有的是纹理模式——这就是 CNN 的”神经元”。
五、CNN 设计原则 5.1 经典设计模式 1 2 3 4 输入 → [Conv → ReLU]×N → Pool → [Conv → ReLU]×M → Pool → ... → FC → FC → Softmax ↓ ↓ 逐步加深通道 逐步减少神经元 逐步缩小尺寸
层类型
通道数变化
尺寸变化
浅层卷积
3 → 32 → 64
32×32 → 16×16
中层卷积
64 → 128 → 256
16×16 → 8×8
深层卷积
256 → 512
8×8 → 4×4
全连接
512×4×4 → 256 → 10
展平
5.2 经典 CNN 演进
模型
年份
特点
参数量
LeNet-5
1998
第一个 CNN,手写数字识别
6 万
AlexNet
2012
ImageNet 冠军,深度学习元年
6000 万
VGG-16
2014
堆叠 3×3 小卷积核,简洁优雅
1.38 亿
ResNet-50
2015
残差连接,解决了深层网络梯度消失
2500 万
EfficientNet
2019
神经架构搜索,效率最高
400 万-3000 万
ResNet 的残差连接 是近年来最重要的创新之一:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class ResidualBlock (nn.Module): """残差块""" def __init__ (self, channels ): super ().__init__() self .conv1 = nn.Conv2d(channels, channels, 3 , padding=1 ) self .bn1 = nn.BatchNorm2d(channels) self .conv2 = nn.Conv2d(channels, channels, 3 , padding=1 ) self .bn2 = nn.BatchNorm2d(channels) def forward (self, x ): residual = x out = F.relu(self .bn1(self .conv1(x))) out = self .bn2(self .conv2(out)) out += residual return F.relu(out)
残差连接让梯度可以直接通过跳跃连接反向传播,解决了层数太深梯度消失的问题——这也是现在所有大模型(包括 Transformer)都在用残差连接的原因。
六、三张图看懂 CNN 流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 输入图片 (3×32×32) │ ▼ ┌─────────────────────────────────────────────────────┐ │ 特征提取器(卷积部分) │ ├─────────────────────────────────────────────────────┤ │ Conv 3×32×32 → 32×32×32 (32个卷积核,提取边缘纹理) │ │ Conv 32×32×32 → 32×32×32 (第二层,提取简单形状) │ │ Pool 32×32×32 → 32×16×16 (下采样,保留最强特征) │ │ Conv 32×16×16 → 64×16×16 (更深,提取图案模式) │ │ Conv 64×16×16 → 64×16×16 │ │ Pool 64×16×16 → 64×8×8 │ │ Conv 64×8×8 → 128×8×8 (最深,提取语义概念) │ │ Conv 128×8×8 → 128×8×8 │ │ Pool 128×8×8 → 128×4×4 │ └──────────────────────┬──────────────────────────────┘ │ 展平: 128×4×4 = 2048 ▼ ┌─────────────────────────────────────────────────────┐ │ 分类器(全连接部分) │ ├─────────────────────────────────────────────────────┤ │ FC 2048 → 256 (特征 → 类别置信度) │ │ FC 256 → 10 │ └──────────────────────┬──────────────────────────────┘ │ ▼ [0.01, 0.02, 0.92, ...] ← 输出概率
七、总结 本文你掌握了:
知识点
掌握
CNN 为什么比全连接更适合图片
✅
卷积操作原理(卷积核、步长、填充)
✅
池化层的作用
✅
感受野与多层小卷积核
✅
用 PyTorch 搭建 CNN 完成 CIFAR-10 分类
✅ 实战
经典 CNN 架构演进(VGG/ResNet)
✅
残差连接原理
✅
下一步推荐: