一、引言:当图像遇到卷积
在上一篇博客中,我们用全连接神经网络实现了手写数字识别,达到了不错的准确率。但如果你仔细观察那个网络结构,会发现一个"隐患":我们将28×28的图像直接展平成784维向量,然后连接到全连接层。
这种做法存在两个严重问题:
问题一:参数爆炸。假设第一层有1000个神经元,那么仅这一层就有784×1000 = 784,000个参数。如果输入是224×224的彩色图像(3通道),参数量会达到150,528×1000 = 1.5亿!这显然不可接受。
问题二:丢失空间信息。图像展平后,相邻像素之间的空间关系被彻底打乱。对于图像识别来说,"某个像素的上下左右是什么"恰恰是最重要的信息。
卷积神经网络(Convolutional Neural Network, CNN)正是为了解决这些问题而生的。它通过卷积操作提取图像的局部特征,通过权值共享大幅减少参数量,通过池化操作降低特征维度,是计算机视觉领域最重要的深度学习模型。
本篇博客将基于PyTorch框架,带领大家从零构建一个CNN来识别MNIST手写数字,深入理解卷积、池化、通道等核心概念。
二、CNN的核心概念
2.1 什么是卷积
卷积是CNN的核心操作。简单来说,它用一个小的窗口(称为卷积核或滤波器)在图像上滑动,每次滑动时计算窗口内像素与卷积核的加权和,得到一个新的值。
输入图像(5×5) 卷积核(3×3) 输出特征图(3×3) 1 0 1 0 1 1 0 1 ? ? ? 0 1 0 1 0 0 1 0 ? ? ? 1 0 1 0 1 1 0 1 ? ? ? 0 1 0 1 0 1 0 1 0 1卷积的意义:
卷积核可以看作一个"特征检测器"——不同的卷积核检测不同的特征(边缘、纹理、角点等)
通过训练,网络会自动学习到最有用的卷积核
权值共享:同一个卷积核在整个图像上滑动,参数只有9个,而不是784×1000个
2.2 关键参数
在PyTorch中,卷积层使用nn.Conv2d()创建,核心参数包括:
| 参数 | 含义 | 说明 |
|---|---|---|
in_channels | 输入通道数 | 灰度图为1,RGB彩色图为3 |
out_channels | 输出通道数 | 等于卷积核的个数 |
kernel_size | 卷积核大小 | 常用3、5、7 |
stride | 步长 | 卷积核每次滑动的距离,默认1 |
padding | 填充 | 在图像边缘补零的圈数 |
2.3 输出尺寸计算公式
卷积后的特征图尺寸可以通过以下公式计算:
示例:输入28×28,kernel_size=5,stride=1,padding=2
这说明:当stride=1,padding=(kernel_size-1)/2时,输出尺寸与输入尺寸相同。这是一种常用的设计策略,使得网络可以加深而不改变空间维度。
2.4 池化操作
池化(Pooling)用于降低特征图的空间维度,减少参数量和计算量,同时增强特征的平移不变性。
最常用的池化是最大池化(Max Pooling):在窗口内取最大值。
特征图(4×4) 最大池化(2×2) 输出(2×2) 1 3 2 4 3 4 5 6 7 8 → 7 8 9 2 1 3 4 5 6 7池化不改变通道数,只改变空间尺寸。
2.5 激活函数——ReLU
在CNN中,ReLU(Rectified Linear Unit)是最常用的激活函数:
ReLU(x)=max(0,x)ReLU(x)=max(0,x)
ReLU的优势:
计算简单(只需比较大小)
缓解梯度消失问题
稀疏激活(负值被置零),提高计算效率
相比上一篇博客中使用的Sigmoid函数,ReLU在深层网络中表现更好。
三、CNN网络架构设计
3.1 整体结构
本篇示例代码设计的CNN由3个卷积块和一个全连接层组成:
输入(1×28×28) ↓ Conv1: Conv2d(1→16) + ReLU + MaxPool2d(2) → (16×14×14) ↓ Conv2: Conv2d(16→32) + ReLU + Conv2d(32→32) + ReLU + MaxPool2d(2) → (32×7×7) ↓ Conv3: Conv2d(32→64) + ReLU → (64×7×7) ↓ Flatten: (64×7×7 = 3136) ↓ 全连接: Linear(3136→10) ↓ 输出(10个类别得分)尺寸变化分析:
输入:1×28×28
Conv2d(1→16, k=5, s=1, p=2):16×28×28(尺寸不变)
ReLU:16×28×28(尺寸不变)
MaxPool2d(2):16×14×14(尺寸减半)
第二个卷积块包含两个卷积层:
self.conv2 = nn.Sequential( nn.Conv2d(16, 32, 5, 1, 2), # 32×14×14 nn.ReLU(), # 32×14×14 nn.Conv2d(32, 32, 5, 1, 2), # 32×14×14 nn.ReLU(), # 32×14×14 nn.MaxPool2d(2), # 32×7×7 )第三个卷积块只有一个卷积层:
self.conv3 = nn.Sequential( nn.Conv2d(32, 64, 5, 1, 2), # 64×7×7 nn.ReLU(), # 64×7×7 )3.3 全连接层与展平操作
self.out = nn.Linear(64 * 7 * 7, 10)在进入全连接层之前,需要将三维特征图展平为一维向量:
x = x.view(x.size(0), -1) # (batch_size, 64*7*7)x.size(0):batch_size(本例中为64)-1:自动推断剩余维度的大小(64×7×7 = 3136)展平后形状:(64, 3136)
3.4 参数数量统计
让我们计算每一层的参数量:
| 层 | 计算 | 参数量 |
|---|---|---|
| conv1 | 16×(5×5×1+1) | 416 |
| conv2-1 | 32×(5×5×16+1) | 12,832 |
| conv2-2 | 32×(5×5×32+1) | 25,632 |
| conv3 | 64×(5×5×32+1) | 51,264 |
| out | 3136×10+10 | 31,370 |
| 总计 | 约12万 |
相比全连接网络的约16万参数,CNN参数量更少,且随着图像尺寸增大,CNN的优势会更加明显。
四、前向传播
def forward(self, x): x = self.conv1(x) # (64,1,28,28) → (64,16,14,14) x = self.conv2(x) # (64,16,14,14) → (64,32,7,7) x = self.conv3(x) # (64,32,7,7) → (64,64,7,7) x = x.view(x.size(0), -1) # 展平 → (64, 3136) output = self.out(x) # 全连接 → (64, 10) return output数据流动过程:
| 步骤 | 操作 | 输出形状 |
|---|---|---|
| 输入 | - | (64, 1, 28, 28) |
| conv1 | 卷积+ReLU+池化 | (64, 16, 14, 14) |
| conv2 | 双层卷积+池化 | (64, 32, 7, 7) |
| conv3 | 单层卷积 | (64, 64, 7, 7) |
| view | 展平 | (64, 3136) |
| out | 全连接 | (64, 10) |
五、训练与测试
5.1 训练函数
训练函数与上一篇博客基本相同,核心五步:
def train(dataloader, model, loss_fn, optimizer): model.train() for x, y in dataloader: x, y = x.to(device), y.to(device) pred = model(x) loss = loss_fn(pred, y) optimizer.zero_grad() loss.backward() optimizer.step()5.2 测试函数
def test(dataloader, model, loss_fn): model.eval() size = len(dataloader.dataset) correct = 0 with torch.no_grad(): for x, y in dataloader: x, y = x.to(device), y.to(device) pred = model(x) correct += (pred.argmax(1) == y).type(torch.float).sum().item() print(f"Accuracy: {100*correct/size}%")5.3 训练配置
loss_fn = nn.CrossEntropyLoss() # 交叉熵损失 optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # Adam优化器 epochs = 10六、全连接网络 vs 卷积神经网络
| 对比维度 | 全连接网络(NN) | 卷积神经网络(CNN) |
|---|---|---|
| 输入处理 | 直接展平 | 保持二维结构 |
| 空间信息 | 完全丢失 | 保留局部关系 |
| 参数量 | 较多 | 较少(权值共享) |
| 特征提取 | 无 | 自动提取局部特征 |
| 平移不变性 | 无 | 池化提供 |
| 适合任务 | 结构化数据 | 图像、视频 |
为什么CNN更适合图像?
局部连接:每个神经元只连接输入的局部区域,符合图像的局部相关性
权值共享:同一个卷积核在整张图上滑动,大大减少参数
平移不变性:无论物体出现在图像哪个位置,都能被同一卷积核检测到
层次化特征:浅层学习边缘、纹理,深层学习语义特征
七、CNN的核心设计思想
7.1 感受野
感受野是指输出特征图上一个像素对应输入图像上的区域大小。
第一层卷积(5×5)的感受野为5×5
经过两层卷积后,感受野会叠加扩大
更深的网络,感受野更大,能捕捉更全局的特征
7.2 通道的变化规律
| 阶段 | 通道数 | 空间尺寸 | 说明 |
|---|---|---|---|
| 输入 | 1 | 28×28 | 原始灰度图 |
| conv1后 | 16 | 14×14 | 提取16种低级特征 |
| conv2后 | 32 | 7×7 | 提取32种中级特征 |
| conv3后 | 64 | 7×7 | 提取64种高级特征 |
规律:随着网络加深,通道数增加,空间尺寸减小。这是一种常见的设计模式——用更多的通道来编码更丰富的语义信息。
7.3 为什么用多个卷积层堆叠?
在conv2中,我们使用了两个连续的3×3卷积层,而不是一个5×5卷积层。这样做有两个好处:
参数更少:两个3×3卷积的参数量为 2×3×3 = 18,而一个5×5卷积为 25
非线性更强:两个ReLU比一个ReLU引入更多非线性
这个思想来自VGG网络,是深度学习中的重要设计原则。
八、完整代码总结
import torch from torch import nn from torch.utils.data import DataLoader from torchvision import datasets from torchvision.transforms import ToTensor # 1. 加载数据 training_data = datasets.MNIST(root="data", train=True, download=True, transform=ToTensor()) test_data = datasets.MNIST(root="data", train=False, download=True, transform=ToTensor()) # 2. 创建DataLoader train_dataloader = DataLoader(training_data, batch_size=64) test_dataloader = DataLoader(test_data, batch_size=64) # 3. 选择设备 device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu" # 4. 构建CNN模型 class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.conv1 = nn.Sequential( nn.Conv2d(1, 16, 5, 1, 2), nn.ReLU(), nn.MaxPool2d(kernel_size=2), ) self.conv2 = nn.Sequential( nn.Conv2d(16, 32, 5, 1, 2), nn.ReLU(), nn.Conv2d(32, 32, 5, 1, 2), nn.ReLU(), nn.MaxPool2d(2), ) self.conv3 = nn.Sequential( nn.Conv2d(32, 64, 5, 1, 2), nn.ReLU(), ) self.out = nn.Linear(64 * 7 * 7, 10) def forward(self, x): x = self.conv1(x) # (64,1,28,28) → (64,16,14,14) x = self.conv2(x) # (64,16,14,14) → (64,32,7,7) x = self.conv3(x) # (64,32,7,7) → (64,64,7,7) x = x.view(x.size(0), -1) # 展平 output = self.out(x) return output model = CNN().to(device) # 5. 定义损失函数和优化器 loss_fn = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # 6. 训练函数 def train(dataloader, model, loss_fn, optimizer): model.train() for batch, (x, y) in enumerate(dataloader): x, y = x.to(device), y.to(device) pred = model(x) loss = loss_fn(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() # 7. 测试函数 def test(dataloader, model, loss_fn): model.eval() size = len(dataloader.dataset) correct = 0 with torch.no_grad(): for x, y in dataloader: x, y = x.to(device), y.to(device) pred = model(x) correct += (pred.argmax(1) == y).type(torch.float).sum().item() print(f"Accuracy: {100*correct/size}%") # 8. 执行训练和测试 epochs = 10 for t in range(epochs): print(f"Epoch {t+1}") train(train_dataloader, model, loss_fn, optimizer) test(test_dataloader, model, loss_fn)九、总结
本篇博客通过构建一个完整的CNN,系统学习了卷积神经网络的核心知识:
| 知识点 | 核心内容 |
|---|---|
| 卷积层 | nn.Conv2d(),提取局部特征,权值共享 |
| 池化层 | nn.MaxPool2d(),降低空间维度 |
| 激活函数 | ReLU,缓解梯度消失 |
| Sequential | 组合多个层为容器 |
| 展平操作 | view(),将三维特征图转为一维 |
| 全连接层 | nn.Linear(),输出分类结果 |
| 通道变化 | 通道递增,空间递减 |
CNN的核心设计原则:
局部连接:每个神经元只关注局部区域
权值共享:同一卷积核在整张图上复用
层次化特征:浅层低级→深层高级
空间下采样:通过池化逐步减小空间尺寸