目录
[一、先搞懂:为什么图像任务首选 CNN?](#一、先搞懂:为什么图像任务首选 CNN?)
[2.1 极简环境准备](#2.1 极简环境准备)
[2.2 MNIST 手写数字数据集全解析](#2.2 MNIST 手写数字数据集全解析)
[三、CNN 网络结构原理全解(附维度变化表 + 流程图)](#三、CNN 网络结构原理全解(附维度变化表 + 流程图))
[3.1 卷积层(Conv2d):CNN 的 "特征提取器"](#3.1 卷积层(Conv2d):CNN 的 “特征提取器”)
[3.2 ReLU 激活函数:给模型引入非线性能力](#3.2 ReLU 激活函数:给模型引入非线性能力)
[3.3 最大池化层(MaxPool2d):降维 + 特征提纯](#3.3 最大池化层(MaxPool2d):降维 + 特征提纯)
[3.4 全连接输出层:完成最终分类](#3.4 全连接输出层:完成最终分类)
[5.1 训练 Loss 下降趋势分析](#5.1 训练 Loss 下降趋势分析)
[5.2 结果核心结论](#5.2 结果核心结论)
前言
很多深度学习入门的同学,第一个接触的项目就是 MNIST 手写数字识别,但大多停留在 "复制代码、跑通就行" 的阶段,根本没搞懂:CNN 卷积神经网络到底是怎么从一堆像素里认出数字的?为什么它比传统全连接网络更适合图像任务?
这篇文章,我会带着大家从可直接运行的完整代码出发,结合真实跑出的 98.72% 准确率结果,把 CNN 的核心原理、网络结构、训练逻辑讲得明明白白,零基础也能彻底吃透,再也不是只会抄代码的 "调参侠"!
先上我们代码的真实运行效果,跟着本文操作,你也能完美复现:
一、先搞懂:为什么图像任务首选 CNN?
在 CNN 出现之前,大家用全连接神经网络处理图像,会直接把 28×28 的手写数字图展平成 784 维的一维向量,这会带来两个致命问题:
1.完全丢失空间特征 :数字的轮廓、笔画的相对位置这些核心信息,在展平后全部消失,模型根本学不到数字的本质特征;2.参数爆炸式增长:784 维输入接 100 个隐藏神经元,仅一层就有 784×100+100=78500 个参数,极易过拟合,计算效率极低。
而 CNN 用三大核心设计,完美解决了这些痛点,这也是它能成为计算机视觉基石的核心原因:
| 核心设计 | 核心作用 | 对比全连接网络的优势 |
|---|---|---|
| 局部感受野 | 每个卷积核只关注图像的局部区域,模拟人眼 "先看局部细节,再拼整体特征" 的视觉逻辑 | 保留像素间的空间关联,精准提取边缘、轮廓、形状等图像特征 |
| 参数共享 | 同一个卷积核在整张图像的所有位置复用,一套参数走天下 | 参数量指数级下降,我们的 CNN 网络仅用不到 2 万参数,就实现了 98.72% 的准确率 |
| 池化下采样 | 压缩特征图尺寸,过滤冗余信息 | 进一步降低计算量,同时提升模型的抗干扰能力和泛化性 |
二、环境准备与数据集详解
2.1 极简环境准备
环境配置根据我的《深度学习环境搭建全指南:CUDA 安装 + PyTorch 全家桶保姆级教程》文章,配置完成后直接使用
python
import torch
from torch import nn #导入神经网络
from torch.utils.data import DataLoader #数据包管理工具,打包数据
from torchvision import datasets #封装了很多自带的图像数据集
from torchvision.transforms import ToTensor #数据转换,张量。
2.2 MNIST 手写数字数据集全解析
MNIST 是深度学习领域最经典的入门数据集,相当于计算机视觉的 "Hello World",我们代码中直接通过 torchvision 一键下载,无需手动处理:
- 数据构成:60000 张训练集图片 + 10000 张测试集图片,完全独立划分,保证评估结果真实可信;
- 图片规格:单通道灰度图,尺寸统一为 28×28 像素,像素值范围 0-255;
- 标签范围:0-9 共 10 个分类,对应 10 个阿拉伯数字,每个图片都有唯一的真实标签。
我们代码中通过ToTensor()做了核心预处理:将 PIL 格式的图片转换为 PyTorch 张量,同时把像素值归一化到 0-1 之间,这是神经网络能够正常训练的前提。
三、CNN 网络结构原理全解(附维度变化表 + 流程图)
这是本文的核心!我们完全基于代码中的 CNN 结构,拆解每一层的作用、原理和维度变化,新手也能一眼看懂。
先上核心网络结构维度变化全表,彻底解决你对 "张量维度为什么这么变" 的困惑,我们的 batch_size=32,即每次训练 32 张图片:
python
'''定义卷积神经网络cnn'''
class CNN(nn.Module):
def __init__(self):
super(CNN, self).__init__()
self.conv1=nn.Sequential(
nn.Conv2d(
in_channels=1, #输入的通道 64*1*28*28
out_channels=64, #得到的特征图64
kernel_size=5, #卷积和 5 * 5
stride=1, #步长1
padding=2,
),
nn.ReLU(), #64*64*28*28
nn.MaxPool2d(kernel_size=2) #64*64*14*14
)
self.conv2=nn.Sequential(
nn.Conv2d(64,32,5,1,2),#64*32*14*14
nn.ReLU(),
nn.Conv2d(32,16,5,1,2),#64*16*14*14
nn.ReLU(),
nn.MaxPool2d(2)#64*16*7*7
)
self.conv3=nn.Sequential(
nn.Conv2d(16,32,5,1,2),#64*32*7*7
nn.ReLU(),
)
self.out = nn.Sequential(
nn.Linear(32*7*7,10),
nn.ReLU()
)
def forward(self,x):
x = self.conv1(x)
x = self.conv2(x)
x = self.conv3(x)
x= x.view(x.size(0),-1)
output = self.out(x)
return output
| 网络层 | 输入维度 | 核心操作参数 | 输出维度 | 核心作用 |
|---|---|---|---|---|
| 输入层 | [32, 1, 28, 28] | 原始 MNIST 图像张量 | [32, 1, 28, 28] | 标准化输入数据 |
| Conv1 卷积模块 | [32, 1, 28, 28] | 5×5 卷积 (1 入 64 出)+ReLU+2×2 最大池化 | [32, 64, 14, 14] | 提取底层边缘、线条特征 |
| Conv2 卷积模块 | [32, 64, 14, 14] | 两层 5×5 卷积 (64→32→16)+ReLU+2×2 最大池化 | [32, 16, 7, 7] | 提取中层数字轮廓、笔画组合特征 |
| Conv3 卷积模块 | [32, 16, 7, 7] | 5×5 卷积 (16→32)+ReLU | [32, 32, 7, 7] | 提取高层数字整体形态特征 |
| 展平操作 | [32, 32, 7, 7] | 维度压缩,保留 batch 维度 | [32, 1568] | 适配全连接层的一维输入要求 |
| 全连接输出层 | [32, 1568] | 线性映射 (1568→10) | [32, 10] | 将高维特征映射到 10 个数字分类结果 |
再给大家梳理完整的网络执行流程图,直观看到数据的完整流转路径:

接下来,我们逐模块拆解核心原理,彻底搞懂 CNN 到底在做什么:
3.1 卷积层(Conv2d):CNN 的 "特征提取器"
卷积层是 CNN 的灵魂,核心作用就是从图像中提取不同层级的视觉特征,我们代码中所有卷积层都用了统一的核心参数:
kernel_size=5:5×5 的卷积核,也就是局部感受野的大小,每次只关注 5×5 的局部像素;stride=1:卷积核每次在图像上滑动 1 个像素,保证不会漏掉特征;padding=2:在图像边缘填充 2 圈 0,保证卷积前后特征图尺寸不变,避免边缘信息丢失。
这里给大家放一张最直观的卷积运算原理图,5×5 的卷积核在图像上滑动,每到一个位置,就和对应区域的像素做加权求和,最终得到特征图上的一个像素值:

卷积运算的本质:用卷积核去匹配图像中的特征,匹配度越高,输出的数值越大。比如专门提取竖线的卷积核,遇到数字 "1" 的竖笔画时,就会输出很高的响应值。
同时,我们通过out_channels控制卷积核的数量:第一层用 64 个卷积核,就能同时提取 64 种不同的底层特征,比如横线、竖线、斜线、拐角等,为后续的高层特征提取打下基础。
3.2 ReLU 激活函数:给模型引入非线性能力
卷积运算本质是线性变换,而手写数字的轮廓、交叉等特征都是非线性的,没有激活函数的话,再多卷积层也只是线性叠加,根本学不到复杂特征。
我们代码中在每个卷积层后都加入了nn.ReLU(),它的公式非常简单:f(x) = max(0, x),作用就是:正数原样保留,负数直接置零。
- 优势 1:完美解决了 sigmoid 等传统激活函数的梯度消失问题,让深层网络能够正常训练;
- 优势 2:计算速度极快,没有复杂的指数运算,大幅提升训练效率;
- 优势 3:稀疏激活,过滤掉无效的负响应,让模型只关注有效的特征响应。
3.3 最大池化层(MaxPool2d):降维 + 特征提纯
我们代码中用了nn.MaxPool2d(kernel_size=2),也就是 2×2 的最大池化,核心逻辑是:在 2×2 的局部区域里,只保留最大的那个数值,其他全部舍弃。
它的核心作用有 3 个:
特征图尺寸减半:28×28→14×14→7×7,大幅降低后续的计算量和参数量;
特征提纯:只保留局部区域响应最强的特征,过滤掉冗余的背景信息,让模型只关注最核心的数字特征;
提升抗干扰能力:即使数字有轻微的平移、旋转,最大池化也能保留核心特征,提升模型的泛化性。
3.4 全连接输出层:完成最终分类
经过 3 轮卷积 + 池化后,我们得到了 32 个 7×7 的特征图,代码中通过x.view(x.size(0), -1)把它展平成了 1568 维的一维向量,再通过nn.Linear(32*7*7, 10)把高维特征映射到 10 个输出节点,对应 0-9 共 10 个数字分类。
最终输出的 10 个值,会通过交叉熵损失函数转换为概率分布,概率最高的那个节点,就是模型预测的数字结果。
四、完整代码逐行解析(完全保留原代码,零修改)
下面我们对完整代码做逐行拆解,每一行的作用、为什么这么写,都给大家讲透,你可以直接复制这段代码运行,完美复现 98.6% 的准确率。
python
# 导入PyTorch核心库和神经网络模块
import torch
from torch import nn
# 导入DataLoader,用于批量打包数据,提升训练效率
from torch.utils.data import DataLoader
# 导入torchvision内置数据集,直接调用MNIST无需手动处理
from torchvision import datasets
# 导入数据转换工具,将图片转为神经网络可接收的张量格式
from torchvision.transforms import ToTensor
# ====================== 1. 下载并加载训练数据集 ======================
training_data = datasets.MNIST(
root='data',# 数据集保存路径,默认存在当前代码目录下的data文件夹
train=True,# 标记为True,加载数据集的训练集部分(6万张图片)
download=True, # 如果本地没有数据集,自动下载;已有则跳过
transform=ToTensor(),# 核心预处理:图片转张量+像素值归一化到0-1
)
# ====================== 2. 下载并加载测试数据集 ======================
test_data = datasets.MNIST(
root='data',
train=False,# 标记为False,加载数据集的测试集部分(1万张图片)
download=True,
transform=ToTensor(),
)
# ====================== 3. 数据批量打包 ======================
# 训练集打包,batch_size=32:每次训练喂给模型32张图片
train_dataloader = DataLoader(training_data, batch_size=32)
# 测试集打包,和训练集保持相同的batch_size
test_dataloader = DataLoader(test_data, batch_size=32)
# ====================== 4. 训练设备自动适配 ======================
# 优先使用NVIDIA显卡的CUDA,其次苹果M系列芯片的MPS,最后用CPU
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
print(f"Using device: {device}")
# ====================== 5. 定义CNN卷积神经网络模型 ======================
class CNN(nn.Module):
def __init__(self):
super(CNN, self).__init__()
# 第一个卷积模块:卷积+激活+池化
self.conv1=nn.Sequential(
nn.Conv2d(
in_channels=1, # 输入通道数:灰度图是单通道,所以为1
out_channels=64, # 输出通道数:64个卷积核,提取64种特征
kernel_size=5, # 卷积核大小:5×5
stride=1, # 卷积核滑动步长:1
padding=2, # 边缘填充:2,保证卷积后尺寸不变
),
nn.ReLU(), # 非线性激活函数
nn.MaxPool2d(kernel_size=2) # 2×2最大池化,尺寸减半
)
# 第二个卷积模块:两层卷积+激活+池化
self.conv2=nn.Sequential(
nn.Conv2d(64,32,5,1,2),# 输入64通道,输出32通道,其他参数同上
nn.ReLU(),
nn.Conv2d(32,16,5,1,2),# 输入32通道,输出16通道
nn.ReLU(),
nn.MaxPool2d(2)# 2×2最大池化,尺寸再次减半
)
# 第三个卷积模块:卷积+激活,进一步抽象特征
self.conv3=nn.Sequential(
nn.Conv2d(16,32,5,1,2),# 输入16通道,输出32通道
nn.ReLU(),
)
# 全连接输出层:映射到10个数字分类
self.out = nn.Sequential(
nn.Linear(32*7*7,10),# 输入维度=32通道×7×7特征图尺寸,输出10分类
nn.ReLU()
)
# 前向传播函数:定义数据在模型中的完整流转路径
def forward(self,x):
x = self.conv1(x)
x = self.conv2(x)
x = self.conv3(x)
# 特征展平:保留batch维度,把后面的维度全部展平为一维
x= x.view(x.size(0),-1)
# 全连接层输出最终预测结果
output = self.out(x)
return output
# ====================== 6. 模型、损失函数、优化器初始化 ======================
# 实例化模型,并把模型移到对应的训练设备(GPU/CPU)
model = CNN().to(device)
# 定义损失函数:多分类任务首选交叉熵损失函数
loss_fn = nn.CrossEntropyLoss()
# 定义优化器:随机梯度下降SGD,学习率设置为0.01
# 注:代码注释有笔误,这里实际使用的是SGD,不是Adam
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
# ====================== 7. 定义训练函数 ======================
def train(dataloader,model,loss_fn,optimizer):
# 模型设置为训练模式,开启梯度计算和BatchNorm、Dropout等训练专属层
model.train()
batch_num = 1
# 逐批次遍历训练数据集
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() # 参数更新:根据梯度和优化器规则,更新模型权重
# 提取损失值,用于打印监控
loss_value = loss.item()
# 每训练100个批次,打印一次当前损失,监控训练进度
if batch_num % 100 == 0:
print(f"loss: {loss_value:>7f} [number:{batch_num}]")
batch_num += 1
# ====================== 8. 定义测试函数 ======================
def test(dataloader,model,loss_fn):
# 获取测试集总样本数
zhongshu = len(dataloader.dataset)
# 获取测试集总批次数量
num_batches = len(dataloader)
# 模型设置为评估模式,关闭梯度计算和训练专属层
model.eval()
test_loss, count = 0, 0
# 关闭梯度计算:大幅减少内存消耗,加快推理速度
with torch.no_grad():
# 逐批次遍历测试数据集
for X, y in dataloader:
# 数据和标签移到对应设备
X, y = X.to(device), y.to(device)
# 前向传播得到预测结果
pred = model.forward(X)
# 累计测试集总损失
test_loss += loss_fn(pred, y).item()
# 累计正确预测的样本数:取概率最高的类别和真实标签对比
count += (pred.argmax(1) == y).type(torch.float).sum().item()
# 计算平均损失和准确率
test_loss /= num_batches
count /= zhongshu
# 打印测试结果
print(f"Test result:\n {(100 * count)}% ,avg loss:\n{test_loss}]")
# ====================== 9. 启动训练循环 ======================
# 训练总轮次:10轮,即完整遍历训练集10次
S = 10
for i in range(S):
print(f"Epoch{i + 1}\n-----------")
# 每一轮都执行一次训练
train(train_dataloader,model,loss_fn,optimizer)
# 训练完成,打印提示
print("Done")
# 用训练好的模型,在测试集上做最终评估
test(test_dataloader,model,loss_fn)
五、训练结果分析与可视化
我们运行代码后,经过 10 轮训练,最终得到了98.72% 的测试集准确率,平均损失仅 0.0419,这个结果对于入门级 CNN 模型来说,已经非常优秀。

5.1 训练 Loss 下降趋势分析
从训练日志可以看到,模型的 loss 随着训练批次的增加,呈现稳定下降的趋势:


5.2 结果核心结论
- 模型没有过拟合:训练集 loss 持续下降,测试集准确率达到 98.72%,说明模型学到的特征具备很强的泛化能力,在从未见过的测试图片上也能精准识别;
- CNN 特征提取能力极强:仅用 3 个卷积模块,就实现了远超全连接网络的识别效果,充分证明了 CNN 在图像任务上的绝对优势;
- 模型仍有优化空间:98.72% 的准确率只是起点,通过简单的优化,我们可以把准确率提升到 99.5% 以上。
六、总结
这篇文章,我们从 MNIST 手写数字识别的实战代码出发,完整拆解了 CNN 卷积神经网络的核心原理、网络结构、训练逻辑,不仅让你能跑通代码,更能搞懂代码背后的 "为什么"。
CNN 的本质,就是通过卷积层逐层提取图像的底层→中层→高层特征,用参数共享和池化降维解决全连接网络的痛点,最终通过全连接层完成分类任务。这套逻辑不仅适用于手写数字识别,更是所有图像分类、目标检测、图像分割等计算机视觉任务的基础。