
简介基于Python实现的手写数字识别系统适合作高校计算机、人工智能相关课程的课程设计或期末大作业参考。项目已通过导师指导并获97分高分源码、数据集与说明文档一应俱全下载后可直接运行无需修改配置。压缩包内共有3个文件包括rar格式的手写数字数据集、py格式的识别系统主程序以及md格式的工程说明整体大小约11.06MB文件组织清晰便于快速定位源码与数据目前已有260人学习浏览。通过该项目读者可完整了解从数据集加载、图像处理到模型训练与预测的流水线实现掌握基于Python的机器学习项目组织方式尤其适合希望以实战方式巩固数字图像识别知识的初中级Python开发者项目代码规范、注释明确可作为二次开发或算法替换的基础框架。1. 手写数字识别项目从 MNIST 到可复用的 Python 工程很多初学者第一次接触深度学习就是从手写数字识别入手的。这个任务看似简单——把 28×28 像素的灰度图分类成 0 到 9但它几乎涵盖了图像分类项目的全部标准流程数据加载、预处理、模型设计、训练调参、评估推理。拿到这套源码加数据集你其实拿到的是一个微缩版的工业视觉项目骨架换掉数据集就能迁移到其他分类任务上。这篇笔记的价值在于帮你把代码每一块的作用看清楚不只是跑通而是跑明白。这套方案适合三类人刚学完 Python 基础、想找第一个深度学习项目的入门者需要快速完成课程设计或毕业设计的在校生以及想了解图像分类工程化流程、准备转向 CV 方向的开发者。接下来我按落地顺序拆解数据集怎么处理、模型怎么搭、训练参数怎么调、推理怎么写、以及最容易翻车的地方都在哪里。2. 数据集与预处理MNIST 的格式陷阱和归一化细节2.1 MNIST 数据集为什么是事实标准手写数字识别绕不开 MNIST 数据集它由 60000 张训练图和 10000 张测试图组成每张都是 28×28 像素的灰度图。这个数据集之所以成为行业基准是因为它足够干净、大小适中单张图只有 784 个像素值用普通 CPU 训练一个简单的全连接网络只要几分钟。同时它又是一个真实的分类问题——手写数字的笔迹差异很大并不像有些人想象的那样“太简单没挑战性”。在源码包里数据集一般以两种形式出现一种是原始的 IDX 文件格式另一种是已经被处理成 NumPy 数组或图片文件夹的形式。这两种格式各有各的坑。IDX 格式是 MNIST 官方的二进制存储方式需要按特定字节偏移量来解析新手在这里第一次接触“二进制文件解析”这个概念而 NumPy 格式虽然加载方便但需要注意数组的维度顺序和数据类型。我拿到源码的第一件事永远是先看数据加载部分的代码而不是直接跑训练脚本。因为数据格式一旦搞错后面训练出来的模型性能会莫名其妙地差而且极难排查。2.2 IDX 二进制格式的解析方法与代码解读最常见的加载方式是用 PyTorch 的torchvision.datasets.MNIST或 TensorFlow 的keras.datasets.mnist.load_data()它们会自动处理下载和解析。但有些源码包为了减少依赖会自己写解析函数这时候你就需要理解 IDX 格式的内部结构。import struct import numpy as np def load_mnist_images(filename): 解析 MNIST 图像文件IDX3 格式 with open(filename, rb) as f: # 前 4 字节是魔数用于校验文件类型 magic struct.unpack(I, f.read(4))[0] # 接下来依次是图片数量、行数、列数 num_images struct.unpack(I, f.read(4))[0] rows struct.unpack(I, f.read(4))[0] cols struct.unpack(I, f.read(4))[0] # 剩余字节全是像素值范围 0-255 raw_data np.frombuffer(f.read(), dtypenp.uint8) images raw_data.reshape(num_images, rows, cols) return images def load_mnist_labels(filename): 解析 MNIST 标签文件IDX1 格式 with open(filename, rb) as f: magic struct.unpack(I, f.read(4))[0] num_labels struct.unpack(I, f.read(4))[0] labels np.frombuffer(f.read(), dtypenp.uint8) return labels这段代码的关键点有两个。第一struct.unpack(I, ...)中的I表示按大端序读取无符号 32 位整数MNIST 官方格式用的是大端序如果你用默认的小端序去读得到的数据全是错的。第二像素数据是uint8类型范围在 0 到 255 之间直接喂给神经网络通常效果不好需要后续归一化。2.3 归一化为什么必须做以及常见误用把像素值从 [0, 255] 缩放到 [0, 1] 或 [-1, 1] 是深度学习的标准操作。如果不做归一化较大的像素值会主导权重更新导致训练过程不稳定、收敛缓慢甚至发散。最常见的做法是直接除以 255.0但这只是最简单的线性缩放。实际项目中我更推荐用均值-标准差归一化。MNIST 数据集的全局像素均值约为 0.1307标准差约为 0.3081使用这两个统计量做标准化能让数据分布更接近标准正态分布加速模型收敛。在 PyTorch 中这通常写在transforms.Normalize()里from torchvision import transforms # 训练阶段的数据增强 归一化 train_transform transforms.Compose([ transforms.RandomAffine(degrees10, translate(0.1, 0.1)), transforms.ToTensor(), # 将 PIL 图像或 numpy 数组转为 Tensor并自动缩放到 [0, 1] transforms.Normalize((0.1307,), (0.3081,)), # 用 MNIST 全局统计量标准化 ]) # 测试阶段不做增强只做转换和归一化 test_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ])注意这里的ToTensor()已经帮你把像素值除以了 255所以Normalize里的均值和标准差也是在 [0, 1] 尺度下计算的。很多人会犯一个错误直接用原始 0-255 尺度上的均值去归一化这会导致数据分布扭曲。如果你自己写数据加载函数记得手动除以 255.0 后再做标准化。2.4 数据增强数据少的时候的后悔药原始 MNIST 有 60000 张训练图对这个小任务来说已经够用但这不意味着数据增强没必要做。在很多改版的“手写数字识别系统”中训练集可能只有几千张或者你需要模型对倾斜、平移、粗细变化有更强的鲁棒性。这时候数据增强就是你的后悔药。PyTorch 的torchvision.transforms提供了丰富的增强操作。对于 MNIST最有效的增强是随机仿射变换旋转 平移 缩放和随机擦除。但要注意数字识别对语义完整性要求很高过度的增强比如大幅旋转 45 度以上会让 “6” 变成 “9” 的模样反而损害模型性能。3. 模型搭建与训练从全连接到 CNN 的选型逻辑3.1 全连接网络为什么在 MNIST 上效果也不错对于 28×28 的灰度图一个两到三层的全连接网络就能达到 97% 左右的准确率。这在很多入门教程和源码包里是最常见的结构因为它简单、容易理解、训练快速。全连接网络的原理是把 784 个像素值展平成向量然后通过线性变换加激活函数层层映射到 10 个类别得分上。import torch.nn as nn class FullyConnectedNet(nn.Module): 两隐藏层全连接网络适合快速验证流程 def __init__(self): super().__init__() self.fc1 nn.Linear(28 * 28, 128) # 输入 784 维第一隐藏层 128 维 self.fc2 nn.Linear(128, 64) # 第二隐藏层 64 维 self.fc3 nn.Linear(64, 10) # 输出 10 个类别得分 self.relu nn.ReLU() self.dropout nn.Dropout(0.2) # 防止过拟合 def forward(self, x): x x.view(x.size(0), -1) # 把 (N, 1, 28, 28) 展平为 (N, 784) x self.relu(self.fc1(x)) x self.dropout(x) x self.relu(self.fc2(x)) x self.fc3(x) return x这段代码里最容易忽略的是x.view(x.size(0), -1)这一步。输入图像的形状是(batch_size, 1, 28, 28)包含一个颜色通道维度直接送入nn.Linear会报维度错误必须先展平。这里的-1是让 PyTorch 自动推断该维度大小即 784。Dropout 层加在第一个隐藏层后训练时随机丢弃 20% 的神经元测试时自动关闭这是防止过拟合的常用手段。全连接网络适合作为基线和流程验证工具但它有两个先天缺陷一是参数量随输入尺寸平方级增长图像稍大就扛不住二是它完全忽略了像素之间的空间结构关系建模能力有限。所以源码包里如果只有全连接网络你可以跑通流程但如果追求更高准确率CNN 几乎是必须引入的。3.2 卷积神经网络结构设计与参数量估算CNN 通过卷积核在图像上滑动提取局部特征保留了像素的空间邻域关系参数量又远小于全连接网络。一个适用于 MNIST 的经典 CNN 结构是“两层卷积 一层全连接”在测试集上可以达到 99% 以上的准确率。class ConvNet(nn.Module): 两层卷积 全连接MNIST 上的经典高精度结构 def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) # 输入 1 通道输出 32 通道 self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) # 32 - 64 通道 self.pool nn.MaxPool2d(2, 2) # 2x2 最大池化尺寸减半 self.fc1 nn.Linear(64 * 7 * 7, 128) # 池化后特征图 7x7 self.fc2 nn.Linear(128, 10) self.relu nn.ReLU() def forward(self, x): x self.pool(self.relu(self.conv1(x))) # 28x28 - 14x14 x self.pool(self.relu(self.conv2(x))) # 14x14 - 7x7 x x.view(x.size(0), -1) # 展平为 (N, 64*7*7) x self.relu(self.fc1(x)) x self.fc2(x) return x这里最需要讲清楚的是fc1输入维度 64×7×7 是怎么算出来的。输入是 28×28经过第一次conv1padding1 保持尺寸不变后仍是 28×28再经过pool变成 14×14第二次卷积保持 14×14再池化变成 7×7。此时通道数为 64所以展平后的特征向量长度是 64×7×73136。如果你改了卷积核数量或池化次数这个数字必须跟着算否则会报维度不匹配的错误。相比全连接网络这个 CNN 的参数量大约只有十几万个但准确率高出 2 个百分点左右。这就是结构先验带来的收益——卷积核天然适合处理图像这种具有局部相关性的数据。3.3 训练循环的完整框架损失、优化器、学习率与 epoch 的设定训练脚本是整个项目的发动机。对于 MNIST 分类任务损失函数用交叉熵优化器用 Adam 或 SGD学习率一般从 0.001 到 0.01 起步epoch 数在 10 到 20 之间。下面是训练循环的核心骨架这套代码稍加修改就能迁移到其他分类任务上import torch import torch.nn as nn from torch.utils.data import DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) model ConvNet().to(device) criterion nn.CrossEntropyLoss() # 交叉熵损失内部已包含 softmax optimizer torch.optim.Adam(model.parameters(), lr0.001) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse) for epoch in range(15): model.train() # 切换为训练模式启用 dropout 和 BN running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() # 梯度清零否则会累加 outputs model(images) # 前向传播 loss criterion(outputs, labels) # 计算损失 loss.backward() # 反向传播 optimizer.step() # 更新权重 running_loss loss.item() # 每个 epoch 结束后评估一次验证集 model.eval() # 切换为评估模式 correct 0 total 0 with torch.no_grad(): # 推理阶段不计算梯度 for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs.data, 1) total labels.size(0) correct (predicted labels).sum().item() print(fEpoch {epoch1:2d} | Loss: {running_loss/len(train_loader):.4f} | Acc: {100*correct/total:.2f}%)这段代码里有三个容易被忽视的细节。第一个是optimizer.zero_grad()PyTorch 的梯度默认是累加的不清零的话会把上一个 batch 的梯度叠加上去导致权重更新方向错误。第二个是model.train()和model.eval()的切换如果你的网络里有 Dropout 或 BatchNorm 层忘记切换会造成训练和测试行为不一致典型表现是训练损失正常下降但测试准确率忽高忽低。第三个是torch.no_grad()推理时不需要保存梯度加上它能显著减少显存占用并加速计算。3.4 学习率调整策略什么时候用 Adam什么时候换 SGD源码包里优化器最常见的选型是 Adam因为它自适应调整每个参数的学习率对新手友好默认参数就能在大多数任务上表现不错。但如果你追求更高的收敛精度SGD 加动量配合余弦退火学习率调度往往能超过 Adam。我的实际经验是先用 Adam 以 0.001 的学习率跑 5 个 epoch 做快速验证确认模型结构无误后再换成 SGD 加 Momentum 0.9、学习率 0.01配合余弦退火调度器跑完整轮次。这种方式兼顾了调试效率和最终精度。from torch.optim import SGD from torch.optim.lr_scheduler import CosineAnnealingLR optimizer SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max15) # T_max 为总 epoch 数 # 每个 epoch 结束时调用 scheduler.step() 更新学习率 # 学习率会沿着余弦曲线从 0.01 平滑降到接近 0调学习率是这个项目里最值得投入时间的部分它对最终准确率的影响远超模型结构微调。如果训练损失震荡不降首选把学习率降到原来的十分之一试试如果模型收敛到 99% 附近上不去试试学习率退火或者权重衰减。4. 模型推理与可视化从模型文件到可用系统4.1 保存与加载模型torch.save 的三种用法和适用场景训练完成后的模型保存方式直接影响后续的推理部署。PyTorch 提供了多种保存方案源码包里常见的有保存整个模型、保存state_dict和保存带元数据的 checkpoint 三种。我强烈推荐只保存state_dict因为它体积最小且不会因 PyTorch 版本升级导致反序列化失败。# 训练结束后保存模型权重到文件 checkpoint { state_dict: model.state_dict(), optimizer_state: optimizer.state_dict(), epoch: 15, val_acc: 0.992, } torch.save(checkpoint, mnist_cnn_checkpoint.pth) # 推理时加载模型 model ConvNet() # 必须先实例化模型结构 checkpoint torch.load(mnist_cnn_checkpoint.pth, map_locationcpu) model.load_state_dict(checkpoint[state_dict]) model.eval() # 切到推理模式关闭 dropout关于这段代码有一个高频翻车点保存时用的模型类定义和加载时的必须完全一致包括类名、层定义和初始化参数。很多人训练时用的模型类保存在 notebook 里重新开脚本加载的时候类定义变了就会报unexpected keys之类的错误。解决办法是把模型类单独写在一个.py文件里训练和推理共用同一定义。另一个细节是加载后的model.eval()绝对不能省。如果模型里有 BatchNorm 层加载后不切到 eval 模式推理结果会因为 BatchNorm 计算的是 batch 统计量而不是全局统计量而完全不同。4.2 单张图片推理自定义图片如何对齐 MNIST 预处理流程实战中你拿到的待识别图片往往不是标准的 MNIST 格式可能是手机拍的照片、扫描件或者截图。要让模型正确处理这些图片必须完整复现训练时的预处理流程灰度化、缩放到 28×28、归一化。from PIL import Image import torchvision.transforms as transforms def preprocess_custom_image(image_path): 把任意图片转为 MNIST 风格输入张量 # 1. 打开图片并转换为灰度图 img Image.open(image_path).convert(L) # 2. 缩放并居中到 28x28 img img.resize((28, 28), Image.Resampling.LANCZOS) # 3. 与训练一致的转换流程 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ]) tensor transform(img).unsqueeze(0) # 增加 batch 维度形状变为 (1, 1, 28, 28) return tensor def predict_digit(image_path, model, device): 返回预测数字和各类别概率 tensor preprocess_custom_image(image_path).to(device) with torch.no_grad(): output model(tensor) prob torch.softmax(output, dim1) # 转为概率分布 predicted torch.argmax(prob, dim1).item() confidence prob[0][predicted].item() return predicted, confidence这里最常被忽略的是图片缩放算法。直接resize((28, 28))默认使用双线性插值但如果原图比例与 28×28 差距过大数字会发生明显畸变。我的做法是先把图的短边等比缩放到 20 像素左右然后贴到 28×28 的黑色画布中央这样能保留数字的原始比例。对倾斜或偏移的输入这个细节往往决定了识别是成功还是失败。4.3 识别界面的简单实现命令行与可视化窗口两条路源码包里如果包含界面代码通常是两个走向命令行交互或图形界面。命令行方式简单直接适合自动化批处理和管道集成图形界面适合演示和课程设计答辩场景。对于课程设计我建议用 Tkinter 实现一个可画可识别的小工具它不需要额外安装第三方 GUI 库Python 自带且跨平台。import tkinter as tk from PIL import Image, ImageDraw, ImageOps class DigitRecognizerApp: 基于 Tkinter 的手写数字画板支持鼠标书写和实时识别 def __init__(self, model, device): self.model model self.device device self.window tk.Tk() self.window.title(手写数字识别) # 画布白色背景用于鼠标书写 self.canvas tk.Canvas(self.window, width280, height280, bgwhite) self.canvas.pack(pady10) self.canvas.bind(B1-Motion, self.paint) # 绘图临时图像用于后续保存 self.image Image.new(L, (280, 280), white) self.draw ImageDraw.Draw(self.image) # 结果标签 self.result_label tk.Label(self.window, textWrite a digit, font(Arial, 16)) self.result_label.pack() # 按钮 tk.Button(self.window, text识别, commandself.recognize).pack(pady5) tk.Button(self.window, text清空, commandself.clear).pack(pady5) def paint(self, event): 画布绘画事件同时同步到 PIL 图像 x, y event.x, event.y r 8 # 笔画半径 self.canvas.create_oval(x-r, y-r, xr, yr, fillblack) self.draw.ellipse([x-r, y-r, xr, yr], fillblack) def recognize(self): 将画布内容送入模型预测 # 反色并缩放为 28x28 img self.image.resize((28, 28), Image.Resampling.LANCZOS) img ImageOps.invert(img) # 白底黑字转黑底白字与 MNIST 一致 # 后续处理和 4.2 节 predict_digit 相同这段界面代码里需要注意一个细节画布背景是白色、笔画是黑色但 MNIST 训练集是黑底白字所以必须做反色处理。如果不做这步模型会把空白区域当成数字边缘来提取特征准确率会大幅下降。这也是很多人做完界面后发现“手写识别怎么这么差”的头号原因。5. 训练与推理中的常见问题和避坑指南5.1 训练损失不下降或下降缓慢的排查路径模型训练中损失不降通常不是模型结构的问题而是数据或超参数的问题。最先检查的是归一化是否正确。如果像素值范围还在 0 到 255 之间或者均值不为零梯度更新会非常缓慢甚至震荡。其次检查学习率设置。学习率太大损失曲线会出现震荡甚至发散学习率太小比如低于 1e-5损失下降会慢得像停滞。我的排查路径是固定的先打印一批输入数据的min、max、mean确认数据分布再把学习率调到 0.001 用 Adam 跑 5 个 epoch每 100 步打印一次损失。如果损失从 2.3 附近往下降到 0.5 以下说明链路是通的。还有一个不常见但确实存在的坑标签错位。MNIST 数据集的标签文件必须和图像文件一一对应如果你用自定义数据集替换了原始 MNIST务必先可视化一批(图像, 标签)对确认对应关系正确。我的习惯是训练前保存一个check_samples.png把前 64 张图拼成网格并标注正确标签一眼就能看出数据是否错乱。5.2 过拟合的典型表现与 Dropout、早停的具体用法训练集准确率接近 100%但测试集准确率明显偏低这是过拟合的典型信号。MNIST 任务因为数据量充足、任务简单过拟合通常不严重但如果你用了全连接网络或数据量被削减过拟合就会出现。除了 Dropout还有两个实用手段。第一个是早停在验证集上记录最佳准确率如果连续 5 个 epoch 没有提升就停止训练并回滚到最佳权重。第二个是 weight decayL2 正则化它在优化器中直接设置weight_decay1e-4就是一个常用值。我见过不少入门者把 Dropout 比例设到 0.5 以上结果模型欠拟合——记住 Dropout 比例越高网络表达能力越弱不是越高越好。5.3 类别不平衡问题测试准确率 99%但某几个数字总是识别错虽然 MNIST 整体类别均衡但在实际改造中比如你用自己的手写样本扩充数据集很容易出现某些数字样本偏多、某些偏少的情况。类别不平衡的典型表现是整体准确率看起来不错但混淆矩阵里某几个类别互相混叠严重。这时候准确率这个指标会骗人。你需要打印混淆矩阵逐类查看召回率。处理方式有两种一是数据层面做少数类过采样二是损失函数层面给少数类更高权重比如把CrossEntropyLoss的参数weight设置成类别样本数的反比。import torch.nn as nn # 假设 class_counts 是一个长度为 10 的张量记录每类的样本数 # 权重 总样本数 / (类别数 * 每类样本数)让少样本类别获得更大梯度 class_counts torch.tensor([5923, 6742, 5958, 6131, 5842, 5421, 5918, 6265, 5851, 5949]) total class_counts.sum() weights total / (10 * class_counts.float()) criterion nn.CrossEntropyLoss(weightweights.to(device))这段代码里weights的计算逻辑是各类权重与其样本数成反比数值越大表示该类别越“稀缺”模型会对它的误分类给予更高惩罚。注意weights需要和device一致否则会报设备不匹配的错误。这个处理在原始 MNIST 上几乎没有收益但在数据倾斜时往往能把少数类准确率拉高 10 个百分点以上。5.4 常见的维度不匹配、数据类型错误和内存溢出处理维度错误几乎每个初学 PyTorch 的人都会遇到。最常见的报错是Expected input batch_size (64) to match target batch_size (32)这类问题本质是输入张量和标签张量在 batch 维度上不一致。排查方法是打印images.shape和labels.shape逐一核对。还有一个隐蔽问题发生在把图像喂给全连接网络时。如果忘了展平会报mat1 and mat2 shapes cannot be multiplied。这类错误信息里会明确告诉你当前维度是多少期望维度是多少照着改就行。内存溢出在 MNIST 这个规模上很少出现但如果你把batch_size调得过大CPU 训练时确实会内存不足。一个 batch 建议不要超过 256如果内存吃紧就把DataLoader的num_workers调低。6. 进阶技巧把准确率从 99% 推到 99.5% 以上的四条路6.1 模型结构改进加 BatchNorm、换激活函数、加深网络如果你不满足于 99% 的准确率结构上有三条成熟路径。第一在卷积层后加BatchNorm2d它能加速收敛并起到正则化作用在 MNIST 上通常能带来 0.2 到 0.5 个百分点的稳定提升。第二把 ReLU 换成 LeakyReLU 或 GELU减少死神经元的影响尤其在网络较深时效果更明显。第三把 MaxPool 换成步长为 2 的卷积保留更多空间信息。class ImprovedConvNet(nn.Module): 改进版 CNNBatchNorm LeakyReLU def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(32) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(64) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x self.pool(torch.relu(self.bn1(self.conv1(x)))) x self.pool(torch.relu(self.bn2(self.conv2(x)))) x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) return self.fc2(x)BatchNorm 有一个容易被忽略的训练/推理差异它维护的是训练阶段计算的移动平均统计量推理阶段用这些全局统计量而不是当前 batch 的统计量。这就是为什么前文中反复强调model.eval()的重要性。如果你在推理阶段忘记切换模式BatchNorm 会直接导致输出完全错误。6.2 集成学习三个模型的投票机制当单模型达到瓶颈时集成是一个性价比极高的手段。你不需要重新设计网络只需要训练三个结构略有差异的模型比如一个全连接、一个简单 CNN、一个改进版 CNN然后对它们的预测做加权投票。def ensemble_predict(models, weights, image_tensor, device): 加权投票集成models 为模型列表weights 为对应权重 probabilities [] for model, weight in zip(models, weights): model.eval() with torch.no_grad(): output model(image_tensor.to(device)) prob torch.softmax(output, dim1) probabilities.append(prob * weight) final_prob torch.sum(torch.stack(probabilities), dim0) predicted torch.argmax(final_prob, dim1).item() confidence final_prob[0][predicted].item() return predicted, confidence集成的关键在于模型之间要有“差异性”。如果你用完全相同的结构和相同的随机种子训练三个模型投票结果和一个模型几乎没差别。要制造差异性可以从数据增强方式、初始化种子、优化器类型或网络深度上分开。集成的收益在准确率接近 99.5% 时仍然可见大约能再提升 0.1 到 0.3 个百分点且无需调参。6.3 最终验证方法混淆矩阵、错误样本可视化与置信度校准准确率只能告诉你整体水平混淆矩阵和错误样本可视化才能告诉你系统哪里会翻车。这是我在交付任何分类系统前必做的最后一道工序。import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay # 收集所有测试集预测结果 all_preds [] all_labels [] model.eval() with torch.no_grad(): for images, labels in test_loader: outputs model(images.to(device)) preds torch.argmax(outputs, dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) # 绘制混淆矩阵直观看出哪些数字对容易混淆 cm confusion_matrix(all_labels, all_preds) ConfusionMatrixDisplay(cm).plot(cmapBlues) plt.title(Test Set Confusion Matrix) plt.show()只看这条信息就能发现典型问题。如果 “4” 和 “9” 之间混叠较多那说明训练集中这两个数字的笔画差异没有被充分学到如果 “7” 和 “1” 经常混淆可能是数据预处理中的缩放或反色有问题。这一步不是锦上添花而是让你在交付前知道系统在哪些输入上不可靠。6.4 实践者的最后一课从头实现和从零调参的心得用别人的源码跑通很容易真正积累经验的是重新实现一遍。这套结构清晰的源码就是最好的起点教材。拿到手先别急着跑训练逐行读懂数据加载和模型定义然后按自己的理解重写一次推理脚本——这种主动重构带来的理解深度远比把训练跑一百遍更有价值。最后分享一下我的个人习惯每次训练我都会把超参数、数据集版本、最终准确率记录在一个文本文件里和模型权重放在同一个目录。这个习惯救过我很多次——三个月后回头查问题能快速定位是哪个版本的数据、哪组参数产生了当前结果。训练模型的记录文档就是你的后悔药没有它你连回滚的方向都没有。希望这篇笔记能帮你真正吃透手写数字识别这个项目也祝你在迁移到其他图像分类任务时少踩几个坑。本文还有配套的精品资源点击获取