
简介面向计算机专业学生课程设计与期末大作业的Python手写数字识别项目基于经典MNIST数据集提供完整源码与全部数据适用于机器学习或深度学习入门实战。项目覆盖数据预处理、模型训练和测试评估全流程可借助逻辑回归、SVM或卷积神经网络CNN等算法完成手写数字分类帮助初学者直观理解模型训练、参数调整与准确率验证等核心概念。压缩包共9个文件包含两个Python源文件、四个gzip格式的数据集、一个说明文档以及build、swo等辅助文件整体约11.07MB结构简洁下载解压后即可运行调试。目前已有64人学习下载。资源内含可执行的卷积神经网络示例代码、标准MNIST训练与测试数据、简明使用说明读者能快速复现识别效果并以此为模板修改网络结构或超参数适用于课程设计报告、期末项目展示及动手实验。1. 手写数字识别项目为什么MNIST值得用完整工程标准做一遍很多人觉得MNIST是深度学习里的“Hello World”跑个官方示例、看几行loss曲线就算入门了。但当你真正想把它做成一个“能训练、能评估、能对单张图片做推理”的完整项目时会发现数据怎么稳定拿到、训练参数怎么调、模型为什么在测试集上翻车每一步都有坑。这个标题里“完整代码及数据”点出的正是这个诉求不只是跑通而是拿到一套能复现、能扩展的手写数字识别方案。本文会从数据获取、模型设计、训练调优到推理落地逐一拆解适合正在学Python深度学习、想把第一个模型做扎实的开发者。MNIST虽然简单但它覆盖了数据工程、模型训练、评估分析和部署的完整闭环做好它后面做任何图像分类项目都能少走弯路。2. 理解手写数字识别的任务本质数据、模型与评估指标2.1 MNIST数据集结构从idx文件到Tensor的完整链路MNIST数据集由LeCun等人整理包含60000张训练图片和10000张测试图片每张是28×28像素的灰度图像素值范围0到255标签为0到9十个数字。原始数据以idx格式存储分为四个文件训练图像、训练标签、测试图像、测试标签。没有额外元数据文件头里写了magic number、样本数量、行数、列数。用PyTorch加载MNIST时torchvision的datasets.MNIST类会帮你完成从idx文件到Tensor的转换。但如果网络不稳定下载经常失败后面第三章会详细讲替代方案。先理解数据本身28×28的灰度图像在内存中就是一个形状为(60000, 28, 28)的数组转换成PyTorch张量后变成(60000, 1, 28, 28)1是通道数灰度图只有一个通道。标签是整数0到9不做one-hot编码也没问题CrossEntropyLoss内部会处理。2.2 模型选型全连接网络与CNN的边界在哪里手写数字识别常见的两种模型路线多层感知机MLP和卷积神经网络CNN。MLP把28×28的图像展平成一维向量输入784个神经元然后经过若干全连接层输出10个类别。CNN则保留图像的二维空间结构用卷积核提取局部特征。对于MNIST两层卷积加池化再加全连接层是经典组合参数量小、效果好。我之前对比过一个三层全连接网络784-128-64-10参数量大约6.6万测试准确率能到97%左右一个两层卷积网络Conv1 32通道Conv2 64通道全连接128参数量约21万测试准确率能到99%以上。如果只是学习用MLP足够如果目标是理解图像特征提取建议直接上CNN。参数量的差距带来的是对图像局部结构的建模能力差异数字的边缘、拐角、交叉这些特征卷积核天然比全连接层更容易捕捉。2.3 评估指标准确率之外还要看混淆矩阵手写数字识别最直观的指标是准确率accuracy即预测正确的样本数除以总样本数。但只盯准确率会掩盖很多问题。比如数字4和9容易混淆7和1容易混淆这些错误模式需要通过混淆矩阵confusion matrix才能看清。混淆矩阵是一个10×10的表格行是真实标签列是预测标签。第i行第j列的值表示真实为i但被预测为j的样本数。对角线越亮说明分类越准非对角线上的亮点就是系统性的混淆。用sklearn的confusion_matrix函数加matplotlib的imshow绘制能直观地看到哪些数字经常互相认错。除了混淆矩阵还可以统计每个类别的精确率precision、召回率recall和F1分数当某个数字的召回率明显低于其他数字时说明模型对该类别的特征学习不充分。3. 数据获取与预处理torchvision下载404后的四个可靠方案3.1 方案一换源下载与手动放置解决最常见的下载失败torchvision的datasets.MNIST默认从https://yann.lecun.com/exdb/mnist/下载数据。这个地址在国外国内网络环境下经常出现超时、连接重置甚至404。最常见的表现是第一次运行代码时卡在Downloading进度条等了几分钟报错。先看默认写法以及它的失败点from torchvision import datasets, transforms # 默认写法会自动下载MNIST到指定目录 train_dataset datasets.MNIST( root./data, # 数据保存目录 trainTrue, # 加载训练集 transformtransforms.ToTensor(), # 转成Tensor downloadTrue # 自动下载这里容易卡住或报错 )上面这段代码如果网络状况不好会在downloadTrue这一步抛出URLError或HTTPError。解决思路不是硬等而是用镜像源手动下载文件再放回torchvision期望的目录结构里。推荐做法是手动下载四个idx文件train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz放到./data/MNIST/raw/目录下不需要解压torchvision会自动识别。完整流程如下# 创建目录结构 mkdir -p ./data/MNIST/raw # 进入目录后用wget或浏览器下载四个.gz文件 cd ./data/MNIST/raw wget https://ossci-datasets.s3.amazonaws.com/mnist/train-images-idx3-ubyte.gz wget https://ossci-datasets.s3.amazonaws.com/mnist/train-labels-idx1-ubyte.gz wget https://ossci-datasets.s3.amazonaws.com/mnist/t10k-images-idx3-ubyte.gz wget https://ossci-datasets.s3.amazonaws.com/mnist/t10k-labels-idx1-ubyte.gz这里用的是AWS S3上的镜像比原站稳定得多是社区里常用的替代源。文件下载完把上面代码里的download参数改成Falsetrain_dataset datasets.MNIST( root./data, trainTrue, transformtransforms.ToTensor(), downloadFalse # 本地已有数据跳过下载 )torchvision检查到./data/MNIST/raw/下有四个gz文件后会自动解压并读取。整个过程不需要改动代码逻辑只换数据来源和download标志位。3.2 方案二用Kaggle或第三方数据集包适合离线环境如果目标机器完全不能访问外网或者公司内网限制了下载源可以考虑从Kaggle的MNIST数据集页面下载压缩包或者找同组同事拷贝一份现成的数据目录。拷贝时要注意目录层级必须匹配torchvision的预期结构root/MNIST/raw/下要有四个idx文件root/MNIST/processed/下是torchvision缓存过的pt文件。这里有一个隐藏坑如果之前下载失败processed目录里可能会残留不完整的processing缓存。torchvision检测到processed文件存在但内容不完整时可能不会重新处理raw数据导致加载时报EOFError或尺寸不匹配错误。解决办法是删掉整个processed目录让torchvision强制从raw重新构建。删除后重新运行代码如果raw下文件齐全torchvision会在几秒内完成预处理。# 删除可能损坏的缓存后重试 rm -rf ./data/MNIST/processed3.3 方案三手写Dataset与DataLoader摆脱对torchvision的依赖torchvision的便捷性有代价版本更新可能导致API变动某些镜像环境下安装torchvision本身就是负担。如果你只装了纯PyTorch不想引入torchvision完全可以用现成的idx解析代码自己实现数据集类。这不是重复造轮子而是理解数据加载原理的最直接路径。import os import torch import struct import numpy as np from torch.utils.data import Dataset, DataLoader class MNISTDataset(Dataset): def __init__(self, images_path, labels_path): self.images, self.labels self._load_data(images_path, labels_path) def _load_data(self, images_path, labels_path): # 读取图片文件idx3-ubyte格式 with open(images_path, rb) as f: magic, num, rows, cols struct.unpack(IIII, f.read(16)) images np.frombuffer(f.read(), dtypenp.uint8) images images.reshape(num, rows, cols) # 读取标签文件idx1-ubyte格式 with open(labels_path, rb) as f: magic, num struct.unpack(II, f.read(8)) labels np.frombuffer(f.read(), dtypenp.uint8) return images, labels def __len__(self): return len(self.images) def __getitem__(self, idx): image self.images[idx] label self.labels[idx] # 转成浮点Tensor像素值缩放到[0,1] image_tensor torch.tensor(image, dtypetorch.float32) / 255.0 image_tensor image_tensor.unsqueeze(0) # 添加通道维形状(1,28,28) label_tensor torch.tensor(label, dtypetorch.long) return image_tensor, label_tensor # 使用示例 root ./data/MNIST/raw train_set MNISTDataset( os.path.join(root, train-images-idx3-ubyte), os.path.join(root, train-labels-idx1-ubyte) ) test_set MNISTDataset( os.path.join(root, t10k-images-idx3-ubyte), os.path.join(root, t10k-labels-idx1-ubyte) ) train_loader DataLoader(train_set, batch_size64, shuffleTrue) test_loader DataLoader(test_set, batch_size64, shuffleFalse)这段代码用struct模块解析idx二进制格式IIII表示按大端序读取4个32位无符号整数分别是magic number、样本数、行数、列数。重点说明三个细节一是np.frombuffer读完后必须reshape成(num, rows, cols)否则后续索引维度不对二是像素转Tensor时要除以255缩放到0到1之间直接送入网络会导致梯度不稳定三是unsqueeze(0)加通道维度没有这一步卷积层会报“expected 3D or 4D tensor”的错误。写成自定义Dataset的好处是可以自由修改预处理逻辑比如加数据增强或归一化不依赖torchvision的内部实现。3.4 数据预处理归一化、数据增强与划分策略MNIST的灰度像素值在0到255之间直接输入神经网络会导致数值过大梯度更新不稳定。最常见的处理是除以255缩放到0到1之间更精细的做法是用均值0.1307和标准差0.3081做标准化这是MNIST官方推荐的两个常数能进一步让数据分布接近标准正态。from torchvision import transforms # 训练集加一点随机扰动增加泛化能力 train_transform transforms.Compose([ transforms.RandomAffine(degrees10, translate(0.1, 0.1)), # 随机旋转10度以内平移10%以内 transforms.ToTensor(), # 转为Tensor并缩放到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # 标准化 ]) # 测试集不做增强只做归一化 test_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])数据增强对MNIST这种小数据集有明显收益。RandomAffine里degrees10表示最多旋转10度translate(0.1, 0.1)表示水平和垂直方向最多平移10%。手写数字本身就有倾斜和偏移这些增强让模型见过更多样的写法测试集准确率通常能提升约0.2到0.5个百分点。但要注意的是增强不能过度如果旋转超过20度数字6变成9、9变成6的情况会增加反而拉低效果。数据划分方面60000张训练图片可以拆成55000训练和5000验证用来监控训练过程中的过拟合。PyTorch里用torch.utils.data.random_split完成设置随机种子保证可复现。4. 手写数字识别完整代码从网络定义到训练评估一次讲透4.1 网络结构定义两层卷积加全连接层的配置细节下面是一个典型的CNN结构参数量约21万测试准确率在99%以上。这里把网络定义单独写成一个类方便后续替换或迁移。import torch import torch.nn as nn class MNISTCNN(nn.Module): def __init__(self): super(MNISTCNN, self).__init__() # 第一层卷积1通道输入32个卷积核卷积核大小3x3 self.conv1 nn.Conv2d(1, 32, kernel_size3, stride1, padding1) # 第二层卷积32通道输入64个卷积核卷积核大小3x3 self.conv2 nn.Conv2d(32, 64, kernel_size3, stride1, padding1) # 池化层2x2最大池化特征图尺寸减半 self.pool nn.MaxPool2d(kernel_size2, stride2) # Dropout随机失活25%的神经元抑制过拟合 self.dropout nn.Dropout(0.25) # 全连接层最终特征图是7x7x64展平后为3136维 self.fc1 nn.Linear(7 * 7 * 64, 128) self.fc2 nn.Linear(128, 10) self.relu nn.ReLU() def forward(self, x): # 输入形状(batch, 1, 28, 28) x self.relu(self.conv1(x)) # 输出(batch, 32, 28, 28) x self.pool(x) # 输出(batch, 32, 14, 14) x self.relu(self.conv2(x)) # 输出(batch, 64, 14, 14) x self.pool(x) # 输出(batch, 64, 7, 7) x x.view(x.size(0), -1) # 展平成(batch, 3136) x self.relu(self.fc1(x)) # 输出(batch, 128) x self.dropout(x) # 训练时随机失活 x self.fc2(x) # 输出(batch, 10) return x几个参数的选型理由值得说清楚卷积核大小选3×3这是目前图像任务里性价比最高的配置两个3×3堆叠的有效感受野相当于一个5×5但参数量更少。padding1保证卷积前后特征图尺寸不变便于计算。第一层用32个卷积核提取边缘、角点这类低级特征第二层用64个卷积核组合出数字的笔画结构。池化用最大池化而不是平均池化因为手写数字的关键特征是笔画是否存在最大值更能保留这种强响应。Dropout放在全连接层前而不是卷积层后因为全连接层的参数量占比高更容易过拟合。forward里的x.view(x.size(0), -1)是展平操作-1表示自动推断剩余维度。假设输入batch是64展平后就是(64, 3136)。两个全连接层中间加ReLU激活最后一层不加激活因为后面要接CrossEntropyLoss它在内部会做softmax。如果把softmax写进网络训练时梯度会不稳定。这是新手常见的错误后面避坑章节会细说。4.2 训练循环损失函数、优化器与学习率的选择训练循环要处理四个关键对象损失函数、优化器、数据迭代和梯度更新。MNIST是10分类任务损失函数固定用交叉熵CrossEntropyLoss优化器最常用Adam学习率默认1e-3起步。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model MNISTCNN().to(device) criterion nn.CrossEntropyLoss() # 内部包含softmax输入不需要额外激活 optimizer optim.Adam(model.parameters(), lr0.001) # 学习率1e-3 scheduler optim.lr_scheduler.StepLR(optimizer, step_size3, gamma0.5) # 每3个epoch学习率乘以0.5后期用小学习率收敛到更优解 epochs 10 for epoch in range(epochs): model.train() # 切换到训练模式启用Dropout running_loss 0.0 correct 0 total 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() * images.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() train_loss running_loss / total train_acc correct / total # 每轮结束后跑一次验证集 val_acc evaluate(model, test_loader, device) print(fEpoch {epoch1}/{epochs}, Train Loss: {train_loss:.4f}, fTrain Acc: {train_acc:.4f}, Val Acc: {val_acc:.4f}) scheduler.step() # 更新学习率训练循环里最容易忽略的点有三个。第一optimizer.zero_grad()必须在每个batch开始前调用否则梯度会累加导致参数更新步长失控。第二model.train()和model.eval()切换很重要训练模式启用Dropout验证模式禁用Dropout如果忘记切换回eval验证集准确率会偏低。第三loss.item()取的是Python标量running_loss loss.item() * images.size(0)是为了计算整个epoch的平均损失直接累加每个batch的loss会受batch大小影响。学习率1e-3对Adam来说是个稳妥的默认值。如果发现训练loss震荡不降可以降到5e-4如果收敛太慢可以先用1e-3跑几个epoch再切小学习率。StepLR里step_size3表示每3个epoch降一次学习率gamma0.5表示乘以0.5到了第10个epoch学习率已经降到0.000125这个递减幅度对MNIST完全够用不必非要使用CosineAnnealing这类复杂调度器。4.3 评估与可视化准确率曲线、混淆矩阵与错误样本分析训练过程中的loss和准确率曲线值得画出来。用matplotlib把每个epoch的训练准确率和验证准确率画在同一张图上如果两条曲线差距越拉越大就是过拟合的典型信号——训练集准确率接近100%验证集准确率停在98%上不去。import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, classification_report import numpy as np def evaluate(model, data_loader, device): model.eval() # 切换到评估模式关闭Dropout correct 0 total 0 all_labels [] all_preds [] with torch.no_grad(): # 推理时不需要计算梯度 for images, labels in data_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() all_labels.extend(labels.cpu().numpy()) all_preds.extend(predicted.cpu().numpy()) accuracy correct / total print(fTest Accuracy: {accuracy:.4f}) # 混淆矩阵与分类报告 cm confusion_matrix(all_labels, all_preds) print(classification_report(all_labels, all_preds, digits3)) # 绘制混淆矩阵 plt.figure(figsize(8, 6)) plt.imshow(cm, interpolationnearest, cmapBlues) plt.colorbar() plt.xlabel(Predicted) plt.ylabel(True) plt.xticks(range(10), range(10)) plt.yticks(range(10), range(10)) for i in range(10): for j in range(10): plt.text(j, i, str(cm[i, j]), hacenter, vacenter) plt.savefig(confusion_matrix.png, dpi150) plt.show() return accuracytorch.no_grad()在这个函数里是关键它告诉PyTorch不需要构建计算图推理时的显存占用大幅下降速度也更快。模型在验证模式下做了一次完整的前向传播得到预测值后把结果从GPU搬回CPU再用numpy处理避免数据类型转换时踩坑。混淆矩阵的读法有学问。比如cm[4][9]的值表示真实标签是4但被预测成9的样本数。如果这个值超过50说明数字4和9的圆形结构确实容易混淆这时最有效的优化手段不是调参而是增加数据增强里的旋转角度或者检查数据集里4和9的字体风格差异。错误样本分析也值得做。把预测错的图片连同真实标签和预测标签一起画成一排你会直观地看到有些数字写得过于潦草人眼都很难辨认有些错误是训练集中类似样本太少导致的。这一步能帮你判断模型的错误是数据问题还是模型容量问题。5. 避坑指南手写数字识别最常见的5个翻车现场5.1 torchvision下载MNIST一直404或超时现象运行代码后卡在Downloading进度条最后报HTTPError 404或URLError下载链接打不开。原因torchvision默认从yann.lecun.com下载这个源在部分网络环境下不稳定有时候还会返回404。新版torchvision尝试切换到其他CDN但切换逻辑未必覆盖所有网络环境。解决下载源质量决定成功率换用AWS S3的镜像手动下载四个gz文件放到./data/MNIST/raw/下再把download参数设为False。镜像地址在第三章里已经给出。如果手动下载也慢可以把gz文件从能联网的机器上拷贝到目标机器目录结构不变即可。5.2 CPU训练一个epoch要等十分钟现象同样的代码别人GPU跑一个epoch只要几十秒自己在CPU上跑要几百秒epoch数设大一点就完全没法等。原因全连接网络或卷积网络在CPU上确实慢尤其当batch内数据在CPU和GPU之间频繁拷贝时IO开销可能超过计算本身。解决把batch_size调大可以减少迭代次数比如从64调到128网络相同的情况下总计算量不变但Python循环和内存拷贝开销减少了。更有效的办法是用torch.utils.data.DataLoader里的num_workers参数多进程并行加载数据能隐藏部分IO延迟train_loader DataLoader(train_set, batch_size128, shuffleTrue, num_workers4)num_workers4表示用4个子进程预取数据主进程训练时数据已经准备好不用空等。Windows系统下如果num_workers不为0报错需要在主脚本加if __name__ __main__:保护代码块。模型本身结构也可以压缩把conv1的通道从32减到16参数量减半准确率只掉零点几个百分点。5.3 Loss不降或准确率卡在10%附近现象每轮epoch的loss一直在2.3左右不动准确率维持在10%上下相当于完全随机猜。原因这是分类任务里典型的“训练完全没生效”信号。常见原因有三个学习率过大导致梯度爆炸、数据没有归一化到合理范围、网络最后一层误加了softmax导致CrossEntropyLoss出现数值问题。最后一个尤其隐蔽因为加了softmax模型也能出结果但loss就是不降。解决先检查数据预处理确认像素值已经被缩放到[0,1]区间再看网络最后一层是否只是nn.Linear(128, 10)而没有接激活函数最后把学习率从1e-3降到1e-4重新试一版。这三步做完如果loss还是不动打印一下outputs的值看是否出现NaN或全是负数NaN通常是学习率过高导致梯度爆炸全是负数说明最后一层的输出分布不合理。按此排查基本都能解决。5.4 准确率很高但单张图片推理翻车现象测试集准确率99%但自己写一个数字存成图片丢给模型预测结果完全不对。原因测试集里的图片都是28×28、黑底白字、数字居中而自己画的图可能是白底黑字、大小不一、位置不居中。模型学到的分布和推理时的输入分布不一致等于跨了数据域。解决推理前做对齐预处理。用PIL把图片转灰度、二值化、缩放、居中再送入模型。完整流程在下一章单张图片推理部分给出。简单说自己画的图需要先反色如果是白底黑字、缩放到28×28、再归一化到和训练集相同的分布一步都不能少。5.5 张量设备不一致Expected all tensors to be on the same device现象报错信息类似“Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu”。原因模型被搬到了GPU但输入数据还在CPU上。常见于model model.to(device)之后写for循环时忘了把images和labels也.to(device)。解决养成一个习惯训练和推理的统一写法是images, labels images.to(device), labels.to(device)如果在CPU上训练把device设置成torch.device(cpu)整段代码不用改因为.to(cpu)不会报错。还有一个隐蔽场景DataLoader的pin_memoryTrue参数在GPU训练时能加速数据传输但配合num_workers在Windows上偶尔会引入奇怪的内存错误新手建议先不用这个参数。6. 进阶把训练好的模型变成能用的推理服务6.1 单张图片推理从读图到输出数字的完整流程训练完模型总要拿它做点实际预测。这里给出一个可复用的单张图片推理函数。核心思路是把任意图片预处理成与MNIST训练集相同的数据分布再喂给模型。from PIL import Image def preprocess_image(image_path): # 打开图片并转灰度 img Image.open(image_path).convert(L) # 反色MNIST是黑底白字如果输入是白底黑字需要反转 import numpy as np arr np.array(img) if arr.mean() 128: # 背景偏亮说明是白底黑字 arr 255 - arr # 缩放并居中到28x28 img Image.fromarray(arr) img img.resize((28, 28), Image.Resampling.LANCZOS) # 转Tensor并标准化 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_single(model, image_tensor, device): model.eval() image_tensor image_tensor.to(device) with torch.no_grad(): output model(image_tensor) probs torch.softmax(output, dim1) # 转成概率便于解读 confidence, predicted torch.max(probs, 1) return predicted.item(), confidence.item() # 使用示例 model MNISTCNN().to(device) model.load_state_dict(torch.load(mnist_cnn.pth, map_locationdevice)) tensor preprocess_image(my_digit.png) digit, conf predict_single(model, tensor, device) print(f预测数字: {digit}, 置信度: {conf:.4f})这个函数里最容易忽略的是反色判断。MNIST训练集是黑底白字你自己用画图工具画数字通常是白底黑字。arr.mean() 128判断图片整体偏亮还是偏暗偏亮就反色。如果不做这一步模型看到的数字就是反的准确率会急剧下降。缩放插值用LANCZOS而不是默认的NEAREST因为手写线条经过抗锯齿缩放到28×28后特征更接近训练集里数字的笔画形态。6.2 模型导出PyTorch转ONNX与跨环境部署训练好的模型不应只活在训练脚本里。把PyTorch模型导出成ONNX格式可以在没有安装PyTorch的机器上推理也可以迁移到TensorRT、ONNX Runtime等推理引擎中获得更快的速度。# 导出ONNX dummy_input torch.randn(1, 1, 28, 28).to(device) model.eval() torch.onnx.export( model, dummy_input, mnist_cnn.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}}, opset_version11 ) print(ONNX导出完成)这里强调dynamic_axes参数它允许推理时传入任意batch大小而不是固定为1。实际部署时用ONNX Runtime加载模型只需要几行代码import onnxruntime as ort import numpy as np sess ort.InferenceSession(mnist_cnn.onnx) input_name sess.get_inputs()[0].name output sess.run(None, {input_name: tensor.numpy()}) predicted np.argmax(output[0])ONNX的好处是摆脱了PyTorch版本绑定。生产环境里用onnxruntime加载模型不需要安装torch部署体积小很多。模型导出成功后一定要用random input做一次前向验证确认导出前后输出一致再交付给推理服务。这一步能帮你避免模型结构里某些PyTorch特有算子无法被ONNX转换的暗坑。6.3 一个可以直接迁移到其他数据集的训练模板MNIST项目最有价值的产出之一是一套可以复用的训练流程。把数据集换成Fashion-MNIST、CIFAR-10或自己的业务数据时需要改动的地方很小。Fashion-MNIST和MNIST的图片尺寸完全一样只是内容从数字换成了衣物图片直接改数据集加载路径即可。CIFAR-10是32×32的三通道彩色图需要把nn.Conv2d(1, 32, ...)改成nn.Conv2d(3, 32, ...)输入图片的通道数从1变成3其他结构完全不用动。我的习惯是把网络定义、训练循环、评估函数三个部分分别放在model.py、train.py、evaluate.py三个文件里每次换数据集时只改数据加载部分和模型输入通道数。这个习惯帮我省了大量重复工作量也让我对每一行的含义理解得更透。你如果跑通了这篇代码建议也试试把模型换成ResNet的浅层版本或者把优化器换成SGD加momentum看看结果有什么变化这种实验带来的理解比只看教程深刻得多。训练MNIST项目多年下来我最大的一个教训是模型结构简单不等于流程简单很多人会在数据加载和预处理上翻车却又去调网络结构最后越调越乱。先把数据链路走通再用小模型快速验证最后才加复杂度这是最稳的顺序。希望这篇笔记能帮你绕开我踩过的那些坑顺利跑通属于你自己的手写数字识别项目。本文还有配套的精品资源点击获取