前言
古语有云:“光说不练假把式。”马克思主义也教导我们,要坚持理论与实践相结合,实事求是,在实践中检验和发展真理。因此,笔者也需要通过更多实践来真正掌握所学内容。本推送主要使用 PyTorch 实现 CNN 的手写数字识别功能。
本推送以 Windows 笔记本为运行环境,覆盖大多数读者常用的系统;对性能要求很低,步骤详细,非常适合练手。
目录
1 CNN是什么
2 环境准备
3 导入工具
4 认识手写数字数据集
5 划分训练集和测试集
6 搭建CNN模型
7 训练模型
8 测试模型的识别效果
9 预测结果
10 练习
1 CNN是什么
CNN 的英文全称是 Convolutional Neural Network,即卷积神经网络。
简单来说,CNN 是一种特别适合处理图像的神经网络。它可以从图片中自动学习特征,再根据这些特征完成分类。
例如,当我们识别手写数字 8 时,人眼会观察它的线条、弯曲程度和形状。CNN 也会学习类似的信息,只不过这些特征是通过训练自动学出来的。
CNN 主要包含以下几个部分。
(1) 卷积(Convolution)
可以把卷积理解成一个小窗口在图片上滑动,寻找边缘、线条、转角等特征。
(2)激活函数(ReLU)
ReLU 会将负数变成 0,为网络提供学习复杂模式所需的非线性能力。
(3)池化(Pooling)
池化会缩小特征图的尺寸,同时保留较重要的信息,减少后续计算量。
(4)全连接层(Linear)
将前面提取到的特征汇总起来,最终判断图片属于哪个数字类别。
因此,一个简单的 CNN 可以按照下面的流程工作:
图片 → 卷积 → ReLU → 池化 → 卷积 → ReLU → 池化 → 全连接层 → 分类结果
接下来,我们就用代码实现这个过程。
2 环境准备
(1)python安装
python 3.11版本是目前兼容性比较好的版本,windows电脑可以通过以下链接下载:
https://www.python.org/ftp/python/3.13.11/python-3.13.11-amd64.exe
安装可以按照这样的步骤,对python比较熟悉的朋友可以按照自己的需求自定义安装:
如果出现 “ 你要允许此应用对你的设备进行更改吗? ” ,都点确认即可



(2)jupyter notebook安装
在搜索栏输入cmd,打开命令提示符,输入:
python -m pip install notebook

(3)安装运行CNN所需包
同样地,在命令提示符中输入:
python -m pip install torch torchvision scikit-learn matplotlib numpy
(4)启动jupyter notebook
在命令提示符中输入:
jupyter notebook
双击打开Desktop文件夹,即进入桌面,然后打开我们的ipynb文件:
下载链接:
通过网盘分享的文件:CNN_learning.ipynb
链接: https://pan.baidu.com/s/1ibVstR4qsGE0KJHVwP0PGg?pwd=p5ck 提取码: p5ck
--来自百度网盘超级会员v5的分享

3 导入工具
首先导入后面需要使用的 Python 库。
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import torch
from torch import nn
from torch.utils.data import TensorDataset, DataLoader
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
# Windows 常用中文字体:SimHei(黑体)、Microsoft YaHei(微软雅黑)、SimSun(宋体)
matplotlib.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'SimSun']
matplotlib.rcParams['axes.unicode_minus'] = False # 正常显示负号
torch.manual_seed(42)
np.random.seed(42)
这段代码主要完成三件事:
1.导入数据处理、绘图、神经网络和数据集划分工具。
2.设置绘图字体,尽量避免中文标题显示成方框。
3.设置随机种子,让相同环境下的实验结果尽可能具有可重复性。
4 认识手写数字数据集
本教程使用 scikit-learn 自带的 digits 数据集这个数据已经包含在 scikit-learn 中,不需要另外下载图片文件,每张图片只有 8×8 个像素,标签为 0 到 9。
运行下面的代码:
digits = load_digits()
X = (digits.images / 16.0).astype('float32') # 像素缩放到 [0, 1]
y = digits.target.astype('int64')
print('图片形状:', X.shape, '标签形状:', y.shape)
fig, axes = plt.subplots(1, 5, figsize=(10, 2))
for i, ax in enumerate(axes):
ax.imshow(X[i], cmap='gray_r')
ax.set_title(f'标签:{y[i]}')
ax.axis('off')
plt.tight_layout()
plt.show()

(1)X 和 y 分别是什么?
这里的 X 表示输入数据,也就是图片;y 表示每张图片对应的正确答案,运行后会看到类似这样的形状:
图片形状: (1797, 8, 8)
标签形状: (1797,)
这意味着:一共有 1797 张图片,每张图片的大小为 8×8,每张图片对应一个数字标签。
(2)为什么要除以 16?
原始像素值的范围是 0 到 16。
X = (digits.images / 16.0).astype('float32')
就是将像素缩放到 0 到 1 之间。这样处理可以让神经网络更方便地学习数据中的规律。需要注意的是,本教程使用的是 8×8 的 digits 数据集,并不是常见的 28×28 的 MNIST 数据集。
5 划分训练集和测试集
在训练模型之前,需要把数据分成两部分:
-
训练集:让模型学习图片和数字之间的关系。
-
测试集:检查模型对没有参与训练的图片识别得怎么样。
代码如下:
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y)
train_ds = TensorDataset(torch.from_numpy(X_train[:, None, :, :]), torch.from_numpy(y_train))
test_ds = TensorDataset(torch.from_numpy(X_test[:, None, :, :]), torch.from_numpy(y_test))
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
test_loader = DataLoader(test_ds, batch_size=128)
其中一些关键参数的意义如下:
test_size=0.2
表示 20% 的数据用于测试,剩余 80% 用于训练。
stratify=y
stratify是 “ 分层、分组 ” 的意思,stratify=y 表示尽量保证训练集和测试集中,各个数字类别的比例与原始数据接近。
batch_size=64
表示训练时,每次取 64 张图片进行计算。
shuffle=True
表示每轮训练时打乱训练数据的顺序。
X_train[:, None, :, :]
为什么中间是 None?
因为 CNN 通常要求输入的形状为:
[样本数, 通道数, 高, 宽]
本例是灰度图,每张图片只有一个通道,所以需要把原来的图片数据转换成类似下面的形状:
[样本数, 1, 8, 8]
这个额外的维度,就是图片的通道维度。
6 搭建CNN模型
接下来进入整个教程的核心:构建神经网络。
class SmallCNN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 8, kernel_size=3, padding=1), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(8, 16, kernel_size=3, padding=1), nn.ReLU(),
nn.MaxPool2d(2),
nn.Flatten(),
nn.Linear(16 * 2 * 2, 10)
)
def forward(self, x):
return self.net(x)
model = SmallCNN()
print(model)
被这些代码吓到,我们逐层理解:
(1)第一层卷积
nn.Conv2d(1, 8, kernel_size=3, padding=1)
这行代码表示:
-
输入通道数为 1,因为输入是灰度图。
-
输出通道数为 8,表示学习 8 组特征。
-
卷积窗口大小为 3×3。
-
padding=1用于在边缘补充像素,使卷积后图片的高和宽保持不变。
可以把它理解成:让网络从原始图片中寻找不同类型的局部特征。
(2)ReLU 激活函数
nn.ReLU()
它会将负数变成 0,让网络能够学习更复杂的关系。
(3)最大池化
nn.MaxPool2d(2)
这一步会将特征图的高和宽各缩小一半,即图片像素数从8×8 变为4×4,这样可以减少后续的计算量
(4)第二层卷积
nn.Conv2d(8, 16, kernel_size=3, padding=1)
第二层接收前一层输出的 8 个特征通道,并进一步提取 16 个特征通道。在直观上,可以把它理解为:第一层先学习比较基础的图像特征,后续层再对这些特征进行组合和加工。
(5)展平特征
nn.Flatten()
经过第二次池化后,特征图的尺寸为:16×2×2,展平后就变成一个长度为 64 的向量。
(6)输出分类结果
nn.Linear(16 * 2 * 2, 10)
最后的全连接层输出 10 个分数,分别对应数字 0 到 9。模型会选择分数最高的数字作为预测结果。
因此,本例的尺寸变化可以总结为:
1×8×8 ↓ 第一层卷积8×8×8 ↓ 最大池化8×4×4 ↓ 第二层卷积16×4×4 ↓ 最大池化16×2×2 ↓ 展平64 ↓ 全连接层10 个数字类别的分数
到这里,CNN 模型就搭建完成了。
7 训练模型
刚搭建好的模型还没有学会识别数字,需要通过训练不断调整参数。
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
for epoch in range(8):
model.train()
loss_sum = 0.0
for images, labels in train_loader:
optimizer.zero_grad()
logits = model(images)
loss = loss_fn(logits, labels)
loss.backward()
optimizer.step()
loss_sum += loss.item() * len(labels)
print(f'Epoch {epoch + 1:02d} | 平均损失: {loss_sum / len(train_ds):.4f}')
这一段代码可以分成四个重要步骤:
(1) 定义损失函数
loss_fn = nn.CrossEntropyLoss()
损失函数(loss_fn)用于衡量模型的预测与真实答案之间的差距,损失越小,通常表示模型在当前训练数据上的预测与真实标签越接近。
这里使用的是交叉熵损失函数(CrossEntropyLoss)。

交叉熵来自信息论,用于衡量两个概率分布p(真实)与q(预测)之间的差异,当预测分布与真实分布一致时,交叉熵最小。

(2)定义优化器
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
优化器负责根据梯度调整模型参数。
其中:
-
model.parameters():需要训练的模型参数。 -
lr=0.01:学习率,控制每次参数更新的步幅。
(3)前向传播和反向传播
logits = model(images)
loss = loss_fn(logits, labels)
loss.backward()
optimizer.step()
这是神经网络训练中最核心的过程:首先,模型接收图片,输出 10 个类别的分数,然后,损失函数计算预测与真实标签之间的差距,接着, loss.backward() 计算各个参数的梯度,最后, optimizer.step() 根据梯度更新参数,让模型逐步改进预测能力。 在此之前:
在此之前:
optimizer.zero_grad()
会清空上一轮迭代留下的梯度。
(4)什么是 Epoch?
for epoch in range(8):
这里的 8 表示训练 8 轮,一个 Epoch 指模型完整遍历一次训练集。每轮结束后,代码都会打印平均损失,方便观察模型的学习过程。
在 Notebook 的一次运行中,训练损失从第一轮约 2.08 下降到了第八轮约 0.06,这说明模型在训练过程中逐步学到了数据中的规律。
不过,训练损失下降并不代表模型一定具有良好的泛化能力,还需要通过测试集进一步验证。
8 测试模型的识别效果
训练结束后,我们要检查模型对未参与训练的数据识别得怎么样。
model.eval()
correct = total = 0
with torch.no_grad():
for images, labels in test_loader:
preds = model(images).argmax(dim=1)
correct += (preds == labels).sum().item()
total += labels.size(0)
print(f'测试准确率:{100 * correct / total:.2f}%')
![]()
这段代码中有三个重要概念。
model.eval()
将模型切换到评估模式。
torch.no_grad()
告诉 PyTorch,当前不需要计算梯度,可以减少不必要的计算和内存开销。
argmax(dim=1)
在每张图片对应的 10 个类别分数中,选择分数最高的类别作为预测结果。
最终,测试准确率的计算方式为:
准确率 = 预测正确的图片数量 ÷ 测试图片总数 × 100%
运行结果为:
测试准确率:96.94%
也就是说,在这次测试中,大约 97% 的测试图片被正确识别。
需要说明的是,这是一个小型教学数据集上的结果,不能直接代表模型在真实世界手写图片上的识别效果。
9 预测结果
除了看准确率,我们还可以直接观察模型预测对不对。
with torch.no_grad():
images, labels = next(iter(test_loader))
preds = model(images).argmax(dim=1)
fig, axes = plt.subplots(2, 5, figsize=(10, 4))
for i, ax in enumerate(axes.flat):
ax.imshow(images[i, 0], cmap='gray_r')
ax.set_title(f'预测 {preds[i].item()} / 真实 {labels[i].item()}')
ax.axis('off')
plt.tight_layout()
plt.show()

运行后会得到 10 张手写数字图片,每张图片上方都有两个数字:
-
预测:模型认为这张图片是什么数字。
-
真实:数据集给出的正确答案。
如果两者相同,说明这张图片识别正确;如果不同,就说明模型在这个样本上判断错误。
通过这种方式,我们不仅能看到一个总体准确率,还能直观地了解模型的预测结果。
10 练习
练习 1:增加训练轮数
把:
for epoch in range(15):
改成:
for epoch in range(8):
观察训练损失和测试准确率是否发生变化。
答:训练轮数增加后,训练损失进一步下降,测试准确率也略有提高。
但要注意:增加训练轮数不一定会提高测试准确率。如果训练过度,模型可能会过拟合,也就是越来越熟悉训练数据,却不一定更擅长识别新图片。
练习 2:修改卷积通道数
把第一层卷积的输出通道数从 8 改成 16。思考一下:第二层卷积的输入通道数需要怎样调整?
答:输入通道也要从8改为16
练习 3:思考 CNN 为什么适合图像
如果直接把所有像素输入全连接层,模型还能像 CNN 一样利用像素之间的局部空间关系吗?
试着从卷积窗口、局部特征和参数共享三个角度思考这个问题。
答:普通全连接神经网络也能学习图像特征,但它不会像 CNN 一样,天然地利用图像像素之间的局部空间关系。CNN 的优势主要体现在卷积窗口、局部特征和参数共享三个方面:
普通全连接神经网络将图像像素展平后进行连接,虽然也能学习像素之间的关系,但没有利用局部空间结构。
CNN 通过卷积窗口提取局部特征,通过多层卷积组合复杂特征,并利用参数共享机制在不同位置识别相同模式。
因此,CNN 通常更适合图像识别任务,且在具有局部空间规律的图像任务中往往具有更高的参数利用效率。
结语
通过这个小项目,我们已经走完了一个典型深度学习任务的主要流程:
准备数据 → 划分训练集和测试集 → 搭建模型 → 定义损失函数和优化器 → 训练 → 测试 → 查看预测结果。
虽然我们只识别了 8×8 的手写数字,但这里的基本思想同样可以帮助你理解更复杂的图像分类模型。
作者:bioinforiver(CSDN同名)
2026年10月24日
本系列内容来源于网络,仅用于分享学习,不得用于任何商业用途。
参考文献:
1.交叉熵损失CrossEntropyLoss,湾仔码农,2023年2月20日,https://www.cnblogs.com/Fish0403/p/17073047.html
本文基于PyTorch框架,详细实践卷积神经网络(CNN)在手写数字识别任务中的完整流程:从环境配置、digits数据集加载与预处理,到CNN模型搭建(含卷积、ReLU、池化、全连接层)、损失函数与优化器定义、模型训练与测试,最终达到96.94%测试准确率。重点涵盖8×8灰度图像的通道处理、特征图尺寸变化、梯度更新机制及过拟合分析。
&spm=1001.2101.3001.5002&articleId=168768258&d=1&t=3&u=f832f747037b4983a924f7c58cb3020c)
1万+

被折叠的 条评论
为什么被折叠?



