第 71 课 - 卷积神经网络(CNN)基础
所属模块: 深度学习入门
难度: Intermediate
标签: deep-learning, cnn
1. 学习目标
通过本节课的学习,你将能够:
- 理解 为什么需要卷积神经网络(CNN)来处理图像等网格状数据。
- 掌握 卷积层、池化层和全连接层等核心组件的工作原理。
- 使用 PyTorch 构建一个基础的 CNN 模型结构。
- 识别 CNN 模型中各层输出的张量维度变化。
2. 核心概念
为什么需要 CNN?
全连接网络(像上一节课学到的)处理图像时,每个神经元都需要与上一层的所有神经元相连。对于一张 28x28 的灰度图,输入层就有 784 个神经元。如果第一个隐藏层有 1000 个神经元,那么仅这一层的权重参数就有 784,000 个。参数过多不仅计算量大,而且容易过拟合,更无法有效捕捉图像中的局部特征(如边缘、纹理)和空间结构。
CNN 的出现解决了这个问题,它通过 “卷积” 操作,模仿了人类视觉皮层的感受野机制,能够局部连接、参数共享,极大地减少了参数数量,并能自动学习从低级特征(边缘)到高级特征(部件、物体)的层次化表示。
核心组件
-
卷积层
- 作用: 特征提取。使用一个小的滤波器(卷积核,Kernel)在输入图像上滑动,在每个位置计算卷积核与图像局部区域的点积,生成一个特征图。
- 关键概念:
- 卷积核: 一个小的权重矩阵(如 3x3),是网络需要学习的参数。不同的卷积核可以检测不同的特征(如垂直边缘、水平边缘)。
- 步长: 卷积核每次滑动的像素数。步长为1时,输出特征图尺寸较大;步长大于1时,起到降维作用。
- 填充: 在输入图像周围补零。可以保持输出特征图的空间尺寸(当
padding=(kernel_size-1)/2且stride=1时)。
- 输出尺寸计算:
输出尺寸 = (输入尺寸 - 卷积核尺寸 + 2 * 填充) / 步长 + 1
-
激活函数
- 作用: 引入非线性。最常用的是 ReLU,因为它计算简单,能有效缓解梯度消失问题。通常紧接在卷积层之后。
-
池化层
- 作用: 下采样,降低特征图的空间尺寸,减少计算量,并赋予模型一定程度的平移不变性(轻微移动物体,特征仍能被识别)。
- 常见类型: 最大池化(取局部区域的最大值)、平均池化(取局部区域的平均值)。最大池化更常用。
-
展平与全连接层
- 作用: 将经过多次卷积和池化得到的高维、局部的特征图“展平”成一维向量,然后连接到一个或多个全连接层,进行最终的分类或回归。
-
CNN 流水线(前向传播)
输入图像 -> [卷积层 + ReLU] -> [池化层] -> ... -> [展平] -> [全连接层] -> 输出
3. 代码示例
我们将使用 PyTorch 构建一个用于 MNIST 手写数字识别的简单 CNN。
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# 1. 准备数据集
# 定义图像预处理:转为Tensor,并进行标准化(使用MNIST数据集的均值和标准差)
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
# 下载并加载训练集和测试集
train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)
# 2. 定义CNN模型
class SimpleCNN(nn.Module):
def __init__(self):
super(SimpleCNN, self).__init__()
# 第一个卷积层:1个输入通道(灰度图),16个输出通道(16个卷积核),卷积核大小3x3,填充1(保持尺寸)
self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1)
# 第二个卷积层:16个输入通道(来自上一层),32个输出通道,卷积核大小3x3,填充1
self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1)
# 最大池化层:窗口大小2x2,步长2(尺寸减半)
self.pool = nn.MaxPool2d(2, 2)
# 第一个全连接层:输入特征数 = 32个通道 * 7x7特征图(经过两次池化后尺寸变化)
self.fc1 = nn.Linear(32 * 7 * 7, 128)
# 第二个全连接层(输出层):输出10个类别(数字0-9)
self.fc2 = nn.Linear(128, 10)
# ReLU激活函数
self.relu = nn.ReLU()
def forward(self, x):
# 输入x形状:(batch_size, 1, 28, 28)
x = self.pool(self.relu(self.conv1(x))) # 输出形状:(batch_size, 16, 14, 14)
x = self.pool(self.relu(self.conv2(x))) # 输出形状:(batch_size, 32, 7, 7)
# 将多维特征图展平为一维向量
x = x.view(-1, 32 * 7 * 7) # 输出形状:(batch_size, 32*7*7)
x = self.relu(self.fc1(x)) # 输出形状:(batch_size, 128)
x = self.fc2(x) # 输出形状:(batch_size, 10)
return x
# 3. 初始化模型、损失函数和优化器
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = SimpleCNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 4. 训练模型 (简化版,仅展示一个epoch)
def train_one_epoch(model, loader, criterion, optimizer, device):
model.train()
total_loss = 0
for batch_idx, (data, target) in enumerate(loader):
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step()
total_loss += loss.item()
if batch_idx % 200 == 0:
print(f' Batch {batch_idx}, Loss: {loss.item():.4f}')
return total_loss / len(loader)
# 5. 测试模型
def test_model(model, loader, device):
model.eval()
correct = 0
total = 0
with torch.no_grad():
for data, target in loader:
data, target = data.to(device), target.to(device)
output = model(data)
_, predicted = torch.max(output.data, 1)
total += target.size(0)
correct += (predicted == target).sum().item()
accuracy = 100. * correct / total
return accuracy
# 主程序
if __name__ == "__main__":
print("开始训练...")
for epoch in range(1): # 训练1个epoch
train_loss = train_one_epoch(model, train_loader, criterion, optimizer, device)
print(f'Epoch {epoch+1}, Average Loss: {train_loss:.4f}')
print("\n开始测试...")
accuracy = test_model(model, test_loader, device)
print(f'测试集准确率: {accuracy:.2f}%')
# 查看模型结构(可选)
# print(model)
# 查看第一层卷积核(可选)
# print(“卷积核形状:“, model.conv1.weight.shape) # 输出: torch.Size([16, 1, 3, 3])
代码解读:
SimpleCNN类定义了网络结构。__init__方法定义层,forward方法定义数据如何流经这些层。nn.Conv2d(1, 16, 3, padding=1): 第一层卷积,1个输入通道(灰度),16个输出通道,3x3卷积核,填充1。nn.MaxPool2d(2, 2): 使用2x2窗口,步长为2进行最大池化,特征图长宽各减半。x.view(-1, 32 * 7 * 7): 将四维张量(batch, channels, height, width)展平为二维张量(batch, features),以便输入全连接层。- 训练和测试流程与上一课的全连接网络基本一致。
4. 实践练习
练习1:观察卷积核
- 任务: 修改上述代码,在模型初始化后,打印第一个卷积层
conv1的权重形状(model.conv1.weight),并解释[16, 1, 3, 3]这四个数字分别代表什么。 - 预期输出: 一个形状为
[16, 1, 3, 3]的张量。解释:16个卷积核,每个卷积核的输入通道数为1(因为输入是灰度图),每个卷积核的空间尺寸是3x3。
练习2:理解特征图尺寸变化
- 任务: 假设输入是一张 1x32x32 的彩色图片(3通道)。经过一个
nn.Conv2d(3, 8, kernel_size=5, padding=2)和一个nn.MaxPool2d(2, 2)后,输出特征图的形状是什么? - 要求: 写出计算过程。
- 预期输出:
(8, 16, 16)。计算过程:卷积后尺寸(32 - 5 + 2*2)/1 + 1 = 32,通道数变为8,得到8x32x32。池化后尺寸(32 - 2)/2 + 1 = 16,得到8x16x16。
练习3:设计一个更小的CNN
- 任务: 设计一个仅包含一个卷积层和一个全连接层的CNN,用于MNIST分类。要求卷积层输出通道数为8,卷积核大小5x5,使用ReLU激活和一次2x2最大池化。
- 预期输出: 代码能成功运行并输出一个合理的测试准确率(>90%)。
5. 常见错误
- 忘记激活函数: 在卷积层后没有接
ReLU等激活函数,导致网络缺乏非线性表达能力,效果接近于线性模型。 - 维度匹配错误: 在连接卷积层和全连接层时,
view或flatten操作的特征数量计算错误。务必仔细推导或打印中间张量的形状。 - 混淆通道和空间维度: PyTorch 中图像张量的默认格式是
(N, C, H, W)(批次,通道,高度,宽度),而不是(N, H, W, C)。在自定义操作时需要注意。 - 池化层尺寸设置不当: 使用了过大的池化窗口(如
MaxPool2d(4, 4))或在小特征图上重复池化,导致特征图过早地丧失所有空间信息,模型性能下降。 - 在卷积层上使用不恰当的激活函数: 最后一层全连接输出层通常不使用激活函数(分类任务接
CrossEntropyLoss,回归任务可能接线性激活)。中间层避免使用Sigmoid/Tanh,优先使用ReLU。
6. 小结
本节课我们学习了卷积神经网络(CNN)的基础知识,它是处理图像、视频等网格数据的强大工具。
- 核心思想: 通过卷积操作实现局部连接和参数共享,高效地提取图像的层次化特征。
- 核心组件:
- 卷积层:用于特征提取。
- 激活函数(如ReLU):引入非线性。
- 池化层:用于下采样,降低空间维度,增强平移不变性。
- 全连接层:用于综合特征并进行最终预测。
- 构建流程: 通常遵循
卷积 -> 激活 -> 池化的模式重复数次,然后展平并连接全连接层。 - 关键实践: 使用 PyTorch 时,要特别注意
nn.Conv2d和nn.Linear之间维度的正确转换。
下一课预览: 我们将学习循环神经网络(RNN)与 LSTM,了解如何处理序列数据(如文本、时间序列),这与CNN处理空间数据的思想既有联系又有显著区别。
练习编辑器
rust
Loading...