卷积神经网络(CNN)讲解:从原理到 MNIST 实战
在深度学习领域,卷积神经网络(Convolutional Neural Network, CNN)是专门为图像任务设计的神经网络架构。它通过模拟人类视觉系统的 "局部感知" 特性,解决了传统全连接网络处理图像时 "参数爆炸" 和 "忽略空间信息" 的痛点。本文将结合之前的 MNIST 手写数字识别代码,从核心原理、组件拆解、数据流动到训练逻辑,全面讲解 CNN 的工作机制。
一、为什么需要 CNN?------ 图像任务的特殊性
以 MNIST 数据集为例,每张图像是28×28 的灰度图 (1 个通道)。若用全连接网络,输入层需要 28×28=784
个神经元;若第一层隐藏层设为 1000 个神经元,仅这一层的参数就有 784×1000=78.4万
个。而 CNN 通过两个核心机制大幅减少参数,同时保留图像的空间特征:
- 局部感知:人类看图像时先关注局部(如边缘、纹理),再组合成整体。CNN 的卷积层仅用 "卷积核"(小窗口)提取局部特征,而非关注整个图像。
- 参数共享:一个卷积核在整个图像上滑动时,使用同一组参数(权重),避免为每个像素位置单独设置参数。
二、CNN 的核心组件:拆解代码中的网络结构

python
# 导入PyTorch核心库
import torch
# 从torchvision导入数据集模块,用于加载MNIST等标准数据集
from torchvision import datasets
# 从torch导入神经网络模块(nn)、优化器模块(optim)
from torch import nn, optim
# 导入数据加载器,用于批量加载数据
from torch.utils.data import DataLoader
# 导入ToTensor转换工具,用于将图像转换为PyTorch张量
from torchvision.transforms import ToTensor
# 加载MNIST手写数字数据集(训练集)
training_data = datasets.MNIST(
root='data', # 数据存储路径
train=True, # 加载训练集
download=True, # 如果本地没有数据则自动下载
transform=ToTensor() # 数据转换:将PIL图像转为张量,并归一化到[0,1]范围
)
# 加载MNIST手写数字数据集(测试集)
test_data = datasets.MNIST(
root='data', # 数据存储路径
train=False, # 加载测试集
download=True, # 如果本地没有数据则自动下载
transform=ToTensor() # 同样进行张量转换
)
# 打印数据集大小,确认数据加载成功
print(f"训练集大小: {len(training_data)}, 测试集大小: {len(test_data)}")
# 创建训练集数据加载器
train_dataloader = DataLoader(
training_data,
batch_size=64, # 每次迭代加载64个样本
shuffle=True # 训练时打乱数据顺序,增强模型泛化能力
)
# 创建测试集数据加载器
test_dataloader = DataLoader(
test_data,
batch_size=64, # 测试时同样使用64的批次大小
shuffle=True # 测试时打乱数据(非必须,这里仅为演示)
)
# 查看数据集中样本的形状,确认数据格式正确
for x, y in test_dataloader:
# x: 输入图像张量,形状为[batch_size, 通道数, 高度, 宽度]
# y: 标签张量,形状为[batch_size]
print(f"输入形状: {x.shape}, 标签形状: {y.shape}")
break # 只查看第一个批次
# 自动判断并选择可用的计算设备,优先使用GPU加速
device = torch.device(
'cuda' if torch.cuda.is_available() else # 检查NVIDIA GPU
'mps' if torch.backends.mps.is_available() else # 检查Apple M系列芯片
'cpu' # 都不支持则使用CPU
)
print(f"使用设备: {device}")
# 定义卷积神经网络(CNN)模型,继承自nn.Module
class CNN(nn.Module):
def __init__(self):
super(CNN, self).__init__() # 调用父类构造函数
# 第一个卷积块:卷积层 + ReLU激活 + 最大池化
self.conv1 = nn.Sequential( #Sequential创建一个容器,将多个层组合在一起
nn.Conv2d( #2d用于处理图像
in_channels=1, # 输入通道数:MNIST是灰度图,所以为1
out_channels=32, # 输出通道数:32个卷积核,提取32种特征
kernel_size=5, # 卷积核大小:5x5
stride=1, # 步长:1,每次移动1个像素
padding=2 # 填充:2,保持卷积后特征图大小不变
),
nn.ReLU(), # 激活函数:将数据进行非线性映射
nn.MaxPool2d(2) # 最大池化:2x2窗口,将特征图尺寸缩小一半
)
# 第二个卷积块:两个卷积层 + ReLU激活 + 最大池化
self.conv2 = nn.Sequential(
# 第一个卷积:32->32通道
nn.Conv2d(in_channels=32, out_channels=32, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
# 第二个卷积:32->64通道
nn.Conv2d(in_channels=32, out_channels=64, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2d(2) # 再次池化,特征图尺寸再缩小一半
)
# 第三个卷积块:单个卷积层 + ReLU激活(无池化,保留特征图尺寸)
self.conv3 = nn.Sequential(
nn.Conv2d(in_channels=64, out_channels=128, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
)
# 全连接层:将卷积提取的特征映射到10个类别(0-9)
self.out = nn.Linear(in_features=128 * 7 * 7, out_features=10)
# 输入特征数计算:128通道,每个特征图7x7(原始28x28经两次池化后变为7x7)
# 前向传播函数:定义数据在网络中的流动路径
def forward(self, x):
x = self.conv1(x) # 经过第一个卷积块
x = self.conv2(x) # 经过第二个卷积块
x = self.conv3(x) # 经过第三个卷积块
x = x.view(x.size(0), -1) # 展平操作:将四维张量[batch, 128, 7, 7]转为二维[batch, 128*7*7]
output = self.out(x) # 经过全连接层得到最终输出
return output
# 初始化模型并将其移动到之前选择的计算设备上
model = CNN().to(device)
# 训练函数:负责模型的训练过程
def train(dataloader, model, loss_fn, optimizer, epoch, device):
model.train() # 设置模型为训练模式(启用 dropout、批量归一化等训练特有的层)
total_loss = 0.0 # 记录总损失
batch_size_num = 1 # 批次计数器
# 遍历数据加载器中的每个批次
for x, y in dataloader:
# 将输入和标签移动到计算设备
x = x.to(device)
y = y.to(device)
# 前向传播:计算模型预测值
pred = model(x)
# 计算损失:预测值与真实标签的差距
loss = loss_fn(pred, y)
# 反向传播与参数优化
optimizer.zero_grad() # 清零梯度,避免梯度累积
loss.backward() # 反向传播计算梯度
optimizer.step() # 更新模型参数
# 累加损失
total_loss += loss.item()
# 每100个批次打印一次损失,监控训练过程
if batch_size_num % 100 == 0:
print(f"Epoch {epoch}, Batch {batch_size_num}, Loss: {loss.item():.7f}")
batch_size_num += 1
# 计算并打印该轮的平均损失
avg_loss = total_loss / len(dataloader)
print(f"Epoch {epoch} 训练平均损失: {avg_loss:.7f}")
return avg_loss # 返回平均损失用于后续分析
# 测试函数:评估模型在测试集上的性能
def test(dataloader, model, loss_fn, epoch, device):
size = len(dataloader.dataset) # 测试集总样本数
num_batches = len(dataloader) # 测试集批次数
model.eval() # 设置模型为评估模式(关闭 dropout 等)
total_loss = 0.0 # 总测试损失
correct = 0 # 正确预测的样本数
# 关闭梯度计算(测试时不需要更新参数,节省内存和计算资源)
with torch.no_grad():
# 遍历测试集中的每个批次
for x, y in dataloader:
x = x.to(device)
y = y.to(device)
pred = model(x) # 前向传播获取预测值
# 累加测试损失
total_loss += loss_fn(pred, y).item()
# 计算正确预测数:取预测概率最大的类别与真实标签比较
correct += (pred.argmax(1) == y).type(torch.float).sum().item()
# 计算平均损失和准确率
avg_loss = total_loss / num_batches
accuracy = correct / size
print(f"Epoch {epoch} 测试平均损失: {avg_loss:.7f}, 准确率: {accuracy * 100:.2f}%\n")
return avg_loss, accuracy # 返回测试损失和准确率用于后续分析
# 定义损失函数:交叉熵损失,适用于分类任务
loss_fn = nn.CrossEntropyLoss()
# 定义优化器:Adam优化器,学习率为0.001
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
# 训练参数设置
epochs = 10 # 训练轮次
# 用于记录训练过程中的指标,便于后续可视化
train_losses = [] # 训练损失
test_losses = [] # 测试损失
test_accuracies = [] # 测试准确率
# 开始训练循环
for epoch in range(1, epochs + 1):
print(f"\nEpoch {epoch}/{epochs}")
print("-" * 50) # 分隔线,美化输出
# 训练模型并记录训练损失
train_loss = train(train_dataloader, model, loss_fn, optimizer, epoch, device)
train_losses.append(train_loss)
# 测试模型并记录测试损失和准确率
test_loss, test_acc = test(test_dataloader, model, loss_fn, epoch, device)
test_losses.append(test_loss)
test_accuracies.append(test_acc)
print("训练完成!")
# 打印模型结构,确认网络配置
print(model)
以上的代码定义了一个 3 个卷积块 + 1 个全连接层的 CNN 模型(class CNN(nn.Module)
),我们逐一拆解每个核心组件的作用,以及代码中的具体实现。
1. 卷积层(Conv2d):提取图像局部特征
卷积层是 CNN 的 "眼睛",负责从图像中提取低级特征(如边缘、线条)和高级特征(如轮廓、形状)。
代码中的卷积层示例(以conv1
为例):
python
nn.Conv2d(
in_channels=1, # 输入通道数:MNIST是灰度图,仅1个通道(彩色图为3)
out_channels=32, # 输出通道数:32个卷积核,提取32种不同特征
kernel_size=5, # 卷积核大小:5×5的正方形窗口(局部感知范围)
stride=1, # 步长:卷积核每次滑动1个像素
padding=2 # 填充:在图像边缘补2个像素,确保卷积后尺寸不变
)
关键计算:卷积后特征图的尺寸
卷积层输出的 "特征图" 尺寸是核心指标,公式为:
输出尺寸 = (输入尺寸 - 卷积核尺寸 + 2×padding) / stride + 1
以conv1
为例:
输入尺寸 = 28,卷积核 = 5,padding=2,stride=1
输出尺寸 = (28 - 5 + 2×2)/1 + 1 = 28 → 特征图仍为 28×28,保证空间信息不丢失。
2. 激活函数(ReLU):引入非线性特征
卷积层的输出是 "线性组合"(类似y=wx+b
),无法捕捉图像中的复杂非线性关系(如曲线、不规则边缘)。激活函数的作用是给特征图加入非线性,让 CNN 能学习更复杂的特征。
代码中的实现:
python
nn.ReLU() # 最常用的激活函数,公式:ReLU(x) = max(0, x)
ReLU 的优势:计算简单(避免梯度消失)、能快速收敛,是图像任务的首选激活函数。
3. 池化层(MaxPool2d):降维与特征浓缩
池化层(又称下采样层)的核心作用是减少特征图尺寸,从而降低计算量和过拟合风险,同时保留关键特征(如 "最大值" 对应最显著的局部特征)。
代码中的实现(以conv1
后的池化为例):
python
nn.MaxPool2d(2) # 2×2的最大池化窗口,步长默认等于窗口大小
关键计算:池化后尺寸
最大池化会将2×2
的窗口压缩为 1 个像素(取窗口内最大值),因此尺寸直接减半:
conv1
输出的 28×28 特征图,经过MaxPool2d(2)
后,尺寸变为 14×14。
4. 卷积块:层的 "组合拳"
代码中没有单独使用卷积层,而是将 "卷积层 + 激活函数 + 池化层"(或多卷积层 + 激活函数)组合成卷积块 (如conv1
、conv2
、conv3
),这是 CNN 的经典设计范式:
conv1
:1 个卷积层 + ReLU + 最大池化 → 提取最基础的边缘特征conv2
:2 个卷积层 + ReLU + 最大池化 → 组合基础特征,提取轮廓特征conv3
:1 个卷积层 + ReLU → 进一步细化高级特征(无池化,避免过度降维)
5. 全连接层(Linear):从特征到分类
经过多轮卷积和池化后,特征图已经浓缩了图像的关键信息,但仍需通过全连接层将 "空间特征" 转化为 "类别概率"。
代码中的实现:
python
self.out = nn.Linear(in_features=128 * 7 * 7, out_features=10)
in_features=128×7×7
:输入特征数。conv3
输出的特征图是128通道×7×7尺寸
(怎么来的?下文数据流动会讲),需要通过x.view(x.size(0), -1)
展平为 1 维向量(batch_size × 128×7×7
)。out_features=10
:输出特征数 = MNIST 的类别数(0-9),最终输出每个类别的 "logits 分数"(通过 Softmax 可转化为概率)。
三、CNN 的数据流动:跟着代码走一遍
理解 CNN 的关键是跟踪数据在网络中的尺寸变化 。我们以代码中的forward
函数为线索,从输入(28×28 的 MNIST 图像)到输出(10 个类别分数),完整梳理数据流动过程:
输入:MNIST 图像张量
输入数据x
的形状为 [batch_size, 1, 28, 28]
(批量大小 × 通道数 × 高度 × 宽度),对应代码中test_dataloader
打印的 "输入形状: torch.Size ([64, 1, 28, 28])"(batch_size=64)。
1. 经过conv1
:基础特征提取
python
x = self.conv1(x) # conv1 = 卷积层(1→32) + ReLU + MaxPool2d(2)
- 卷积层后:
[64, 32, 28, 28]
(通道从 1→32,尺寸保持 28×28) - ReLU 后:形状不变,仅值做非线性变换 →
[64, 32, 28, 28]
- 最大池化后:尺寸减半 →
[64, 32, 14, 14]
2. 经过conv2
:轮廓特征提取
python
x = self.conv2(x) # conv2 = 卷积层(32→32) + ReLU + 卷积层(32→64) + ReLU + MaxPool2d(2)
- 第一个卷积层(32→32):尺寸不变 →
[64, 32, 14, 14]
- ReLU 后:形状不变 →
[64, 32, 14, 14]
- 第二个卷积层(32→64):尺寸不变 →
[64, 64, 14, 14]
- ReLU 后:形状不变 →
[64, 64, 14, 14]
- 最大池化后:尺寸减半 →
[64, 64, 7, 7]
3. 经过conv3
:高级特征细化
python
x = self.conv3(x) # conv3 = 卷积层(64→128) + ReLU
- 卷积层(64→128):尺寸不变(padding=2) →
[64, 128, 7, 7]
- ReLU 后:形状不变 →
[64, 128, 7, 7]
4. 展平(view):适配全连接层
python
x = x.view(x.size(0), -1) # 将4维张量展平为2维
x.size(0)
:批量大小(64)-1
:自动计算剩余维度 → 128×7×7=6272- 展平后形状:
[64, 6272]
5. 经过全连接层:输出类别分数
python
output = self.out(x) # 全连接层(6272→10)
- 输出形状:
[64, 10]
→ 每个样本对应 10 个类别(0-9)的分数。
四、CNN 的训练与评估:代码中的核心逻辑
CNN 的训练流程与其他神经网络一致,但需注意 "训练模式" 和 "评估模式" 的区别,以及梯度计算的开关。我们结合代码中的train
和test
函数讲解:
1. 训练函数(train):更新模型参数
训练的核心是 "梯度下降"------ 通过反向传播计算参数梯度,再用优化器更新参数,最小化损失。
关键步骤:
python
model.train() # 设为训练模式:启用Dropout、BatchNorm的训练逻辑
for x, y in dataloader:
x, y = x.to(device), y.to(device) # 数据移到GPU/CPU
pred = model(x) # 前向传播:计算预测值
loss = loss_fn(pred, y) # 计算损失(CrossEntropyLoss适配分类任务)
# 反向传播与优化
optimizer.zero_grad() # 清零梯度(避免累积)
loss.backward() # 反向传播:计算各层梯度
optimizer.step() # 优化器更新参数(如Adam)
- 损失函数:
nn.CrossEntropyLoss()
→ 同时包含 "Softmax" 和 "交叉熵",直接输入 logits 即可。 - 优化器:
torch.optim.Adam()
→ 自适应学习率,收敛快,适合 CNN 训练。
2. 评估函数(test):验证模型性能
评估时不需要更新参数,因此需关闭梯度计算,避免内存浪费;同时需切换到 "评估模式"。
关键步骤:
python
model.eval() # 设为评估模式:关闭Dropout、固定BatchNorm参数
with torch.no_grad(): # 关闭梯度计算
for x, y in dataloader:
pred = model(x)
total_loss += loss_fn(pred, y).item() # 累加测试损失
# 计算准确率:取预测概率最大的类别(pred.argmax(1))与真实标签比较
correct += (pred.argmax(1) == y).type(torch.float).sum().item()
- 准确率计算:
correct / len(dataloader.dataset)
→ 正确预测数 / 总样本数。
3. 训练效果:预期结果
在 MNIST 数据集上,该 CNN 模型训练 10 轮后:

- 训练损失会从初始的~2.3(随机猜测水平)下降到~0.0001
- 测试准确率会达到 99% 以上 → 充分说明 CNN 对图像任务的有效性。
五、总结:CNN 为什么能搞定图像任务?
从原理到代码,我们可以总结出 CNN 的核心优势:
- 局部感知 + 参数共享:大幅减少参数数量,避免过拟合,降低计算成本。
- 特征层级提取:从低级特征(边缘)到高级特征(形状),逐步抽象,符合人类视觉认知。
- 空间信息保留:池化层在降维的同时保留关键空间特征,而全连接网络会丢失空间关系。