
简介这是一套基于Python与卷积神经网络CNN的手写数字识别代码资源面向计算机视觉入门者、机器学习学习者以及课程设计或竞赛项目开发者旨在演示图像分类任务从数据到模型部署的完整流程。压缩包共4个文件包含3个CSV数据文件与1个Python脚本整体大小约13.26MB。其中CSV文件分别承担训练样本、测试样本及预测结果存储Python脚本则完整实现了数据预处理、标签One-Hot编码、CNN模型构建、训练与评估等核心步骤。通过阅读代码可以掌握像素值归一化、图像二维数组转换、卷积层与池化层设计、全连接层分类及反向传播优化等关键操作。该模型在测试集上可达约99%的识别准确率并附有预测结果文件便于与真实标签对比验证模型泛化能力。此外还可根据代码进一步尝试调整学习率、批量大小等超参数或引入旋转、平移等数据增强手段以提升模型鲁棒性。资源结构简洁各模块分工明确方便直接运行或二次改造。目前已有472人学习浏览适合希望快速上手CNN手写识别项目的开发者参考学习。1. 手写数字识别是深度学习入门绕不开的第一道坎这份CNN神经网络代码帮你把坑填平“手写数字识别”几乎是每一个想真正用上深度学习的开发者都会碰到的第一个任务而一份能跑的“CNN神经网络代码”往往比十遍理论讲解更能让人理解卷积到底在做什么。MNIST 数据集里的图片只有 28×28 像素任务只是区分 0 到 9 十个数字看起来简单但很多人在把这张小图喂给网络时依然翻车问题集中在环境配置、数据归一化、网络层参数和训练循环的顺序上。这篇文章不是给你一个黑匣子脚本而是把这个方向从环境准备、CNN 搭建、训练验证一直到真实手写图片推理的完整链路拆开来讲。你要是刚看完深度学习理论想用 Python 跑通第一个 CNN 实验这篇文章正好能照着复现如果你已经跑通过但遇到了 Loss 不降、准确率卡住这一类问题也能直接跳到第 5 章的踩坑记录里找答案。2. 数据与环境准备手写识别项目在写第一行模型代码之前要做的三件事2.1 为什么手写数字识别用 CNN 而不是全连接网络很多人刚入门时有个疑问MNIST 每张图只有 784 个像素直接用全连接网络也能跑出 90% 以上的准确率为什么还要上 CNN原因是全连接网络把每个像素都当作独立特征完全没有利用相邻像素的空间关系。手写数字的笔画是有局部结构的比如横折、竖弯钩、圆弧这些特征只出现在图片的某一块区域。全连接网络看不到这种局部性每张图的每个位置都要用独立的权重去学参数量爆炸不说泛化能力也弱。CNN 解决这个问题的思路很直接用一个小卷积核在整张图上游走同一个卷积核在不同位置提取同一种局部特征这就是权重共享。比如一个 3×3 卷积核可能专门负责检测竖直边缘另一个卷积核负责检测横向笔画。层层叠加之后低层卷积看到的是边缘和线条高层卷积则组合出数字的整体结构。再加上池化操作不断把特征图缩小模型对数字的微小偏移、粗细变化就不那么敏感了。所以在手写数字识别这个任务上CNN 的收敛速度和最终准确率都明显优于全连接这也是为什么几乎所有教科书都会用这个任务来展示“深度学习卷积神经网络”。2.2 安装 Python 环境并准备 PyTorch一个能跑最小例子的环境指令不要一上来就装 GPU 版本先用 CPU 把流程跑通再考虑显卡加速。先把 Python 基础环境准备好。如果你在 Windows 上安装过 Python 但命令行仍然提示“python 不是内部或外部命令”那是环境变量没配好按 python 官网下载安装包时勾选“Add Python to PATH”或者在系统环境变量里手动把 Python 安装目录加进去。我一般会在项目目录下创建虚拟环境避免多个项目依赖互相打架。# macOS / Linux python3 -m venv venv source venv/bin/activate pip install --upgrade pip # Windows 的 cmd 或 PowerShell 环境 # python -m venv venv # venv\Scripts\activate # python -m pip install --upgrade pip接下来安装 PyTorch 和 torchvision。torchvision 里带了 MNIST 数据集的下载接口省去自己找数据的麻烦。CPU 版安装命令很简单pip install torch torchvision如果你的机器有 NVIDIA 显卡并且之前装好过 CUDA可以去 PyTorch 官网主页复制匹配你 CUDA 版本的安装命令。对于入门项目不要卡在 GPU 上CPU 训练这个小数据集同样能跑出 98% 左右的准确率只是耗时多一些。装完后可以在 Python 交互环境里验证一下import torch print(torch.__version__)能正常输出版本号就说明环境没问题。这里有个容易踩的坑Python 版本和 PyTorch 版本有对应关系如果 pip 安装报错或者 import 失败优先检查 Python 版本是否太新或太旧。建议使用 Python 3.8 到 3.11 之间的版本别盲追最新。2.3 加载 MNIST 数据与预处理张量、归一化与 DataLoaderMNIST 官方提供的图片是 PIL 格式需要先转成 PyTorch 的张量同时做归一化才能送进网络。这里有一个新手最容易忽略的点原始图片的像素值是 0 到 255 的整数如果直接拿给模型用数值范围太大会导致梯度更新不稳定。常见做法是先用 ToTensor 把 PIL 图变成 0 到 1 之间的浮点张量再用固定均值和标准差做标准化。from torchvision import datasets, transforms from torch.utils.data import DataLoader # MNIST 灰度图像的统计均值是 0.1307标准差是 0.3081 # 这两个值是由官方训练集计算得到的属于固定前置数据 transform transforms.Compose([ transforms.ToTensor(), # 把 PIL 图像变成 [0, 1] 范围的张量 transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransform ) test_dataset datasets.MNIST( root./data, trainFalse, downloadTrue, transformtransform ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size64, shuffleFalse)这段代码里最值得关注的是 DataLoader 的两个参数。shuffleTrue只用于训练集让每个 epoch 里样本顺序都打乱避免模型记住顺序而不是学特征测试集不需要打乱因为只做一次评估。batch_size是每次喂给模型的样本数量64 是一个比较稳的起点。我在实际项目中见过不少人把 batch_size 调到 256 甚至更大结果模型很快就过拟合或者显存被撑爆其实小批量对梯度估计更准确训练反而更稳。这里的root./data表示数据集会下载到当前目录下的 data 文件夹第一次运行需要联网。如果下载过程中断把 data 目录整个删掉重新下即可。3. CNN 卷积神经网络代码骨架两层卷积加一层全连接3.1 网络结构的参数取舍in_channels、kernel_size 和 padding手写数字识别并不需要很深的网络LeNet 这种诞生于几十年前的结构已经能做得很好。我常用的结构是两层卷积加一层全连接参数适中理解起来也直观。下面这份代码可以直接复制到你的训练脚本里。import torch import torch.nn as nn import torch.nn.functional as F class HandwrittenCNN(nn.Module): def __init__(self): super().__init__() # 第一层卷积输入 1 个灰度通道输出 32 个特征图 # 卷积核大小 3×3padding1 让 28×28 图像保持尺寸不变 self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) # 第二层卷积输入 32输出 64卷积核仍是 3×3 self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) # 最大池化2×2 窗口步长 2特征图长宽各缩半 self.pool nn.MaxPool2d(2, 2) # 经过两次池化后28×28 会变成 7×7 # 所以全连接层输入维度是 64 * 7 * 7 self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) # Dropout 比例 0.25训练时随机丢弃一部分神经元 self.dropout nn.Dropout(0.25) def forward(self, x): # 第一层卷积 ReLU 池化输出 (batch, 32, 14, 14) x self.pool(F.relu(self.conv1(x))) # 第二层卷积 ReLU 池化输出 (batch, 64, 7, 7) x self.pool(F.relu(self.conv2(x))) # 把多维特征图展平成向量-1 表示自动推断 batch 大小 x x.view(-1, 64 * 7 * 7) x F.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) # 输出 10 个类别的 logits return x # 用假数据验证前向传播形状防止层参数算错 model HandwrittenCNN() dummy_input torch.zeros((1, 1, 28, 28)) print(model(dummy_input).shape) # 期望输出 torch.Size([1, 10])代码里的关键参数是padding1。如果不加 padding28×28 的图经过 3×3 卷积后尺寸会变成 26×26两次卷积加池化后很难凑出整数的 7×7 维度。加了 padding 后尺寸不变第二层池化后的特征图正好是 7×7全连接层输入维度就能直接用64 * 7 * 7计算。这是一个很多人自己推网络结构时最容易算错的细节。3.2 激活函数、池化与 Dropout每一层为什么要这样放网络里的每一层都不是随便叠加的。卷积层后面接 ReLU 激活函数是因为卷积操作本质上是线性变换需要引入非线性才能拟合数字笔画的复杂模式。ReLU 的计算形式是max(0, x)在正区间梯度恒为 1不像 sigmoid 那样在两端梯度趋近于 0所以深度稍微大一点也不会出现梯度消失。我用 ReLU 是默认选择基本不会换。MaxPooling 池化层的直观含义是在 2×2 的窗口里取最大值保留最明显的特征同时把图片尺寸缩小。这样做的好处是让网络对数字的轻微位移不那么敏感减少后面全连接层的参数量。除了 max pooling有人也会用 average pooling但手写数字识别里最大值更能保留笔画强度所以 max 是更常见的选择。Dropout 层则是一个玄学工具训练时随机让一部分神经元输出变成 0相当于每次都训练一个不同的子网络。它的直接作用是防止过拟合。MNIST 本身的训练数据有 6 万张不算少但网络参数也不少Dropout 仍然能带来可感知的准确率提升。注意 Dropout 只在训练时生效在验证或推理阶段必须调用model.eval()否则模型预测结果会随机抖动。3.3 损失函数与优化器交叉熵和 Adam 中那些影响准确率的参数模型输出的是 10 个 logits接下来需要定义“怎么判断预测得好不好”。分类任务的标准选择是交叉熵损失。PyTorch 的CrossEntropyLoss已经把 softmax 和损失计算合并在一起所以模型输出层不需要再额外套 softmax。import torch.optim as optim # 交叉熵损失适合多分类内部自带 softmax 操作 criterion nn.CrossEntropyLoss() # Adam 优化器对学习率不敏感收敛比较稳 # learning rate 0.001 是一个覆盖很多分类任务的通用起手值 optimizer optim.Adam(model.parameters(), lr0.001)Adam 优化器之所以比传统 SGD 好上手是因为它为每个参数维护自适应学习率在大多数情况下使用默认的lr0.001就能让模型正常收敛。我在调参时一般先固定这个学习率如果 Loss 下降明显变慢再考虑降到0.0001而不是一开始就手动衰减学习率。需要明白的是Adam 有两个额外参数betas(0.9, 0.999)用来控制一阶矩和二阶矩的指数衰减速率正常情况下不需要调整。如果你更习惯 SGD那么需要搭配较大的学习率0.01 左右并且要手动设置 momentum 才能达到相近效果。对于一个入门项目直接用 Adam 是性价比最高的选择。4. 训练与验证让手写数字识别模型跑起来的完整脚本4.1 训练循环前向、反向、梯度清零与权重更新的顺序训练循环是每个深度学习项目的心脏顺序极其讲究。很多人第一次写时会在一个 batch 的梯度反传之后忘记清零结果梯度不断累加Loss 越调越乱。正确的顺序是先zero_grad()清空上一步的梯度再做前向传播计算输出然后计算损失接着backward()算出当前梯度最后step()更新参数。def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss 0 correct 0 total_sample 0 for images, labels in train_loader: images images.to(device) labels labels.to(device) # 1. 清空上一轮 batch 的梯度 optimizer.zero_grad() # 2. 前向传播 outputs model(images) # 3. 计算损失 loss criterion(outputs, labels) # 4. 反向传播求梯度 loss.backward() # 5. 更新权重 optimizer.step() # 统计当前 epoch 的 loss 和准确率 total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total_sample labels.size(0) return total_loss / total_sample, correct / total_samplemodel.train()这一行也容易被忽略。它会启用 Dropout 和 BatchNorm 的训练模式。如果忘了写Dropout 将不生效模型在训练阶段就成了“全容量”网络过拟合风险很高。optimizer.zero_grad()不是可选项PyTorch 的梯度是默认累积的手动清零是最稳妥的写法。至于为什么用outputs.argmax(dim1)因为模型输出的 10 个数里下标最大的那个可以代表预测类别。4.2 验证循环为什么不能用训练集准确率汇报结果训练集上的准确率只能反应模型的记忆能力不能代表泛化能力。验证循环和训练循环有几处关键差异不需要计算梯度不需要更新权重并且必须把模型切到eval()模式。实际操作时用with torch.no_grad():把整个验证逻辑包起来这样 PyTorch 就不会保存计算图内存占用大幅降低速度也会快一些。def validate(model, test_loader, criterion, device): model.eval() # 关闭 Dropout进入推理模式 total_loss 0 correct 0 total_sample 0 with torch.no_grad(): for images, labels in test_loader: images images.to(device) labels labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total_sample labels.size(0) return total_loss / total_sample, correct / total_sample验证循环里的model.eval()和训练循环里的model.train()是对应的特别是在有 Dropout 的模型里漏了eval()会导致每次验证结果都不一样而且验证集准确率往往偏低。我见过不止一个人因为这个问题反复怀疑自己的模型结构其实只是模式没切换对。4.3 Batch Size、Learning Rate、Epochs训练参数的调整列表下面这组参数是手写数字识别任务中经过大量验证的常用区间你可以直接作为起跑线参数常用区间备注batch_size32 ~ 128小批量更好收敛大批量更快learning_rate0.0001 ~ 0.001Adam 常用 0.001SGD 可尝试 0.01epochs10 ~ 30MNIST 通常 10 个 epoch 能到 97% 以上optimizerAdam / SGD with momentumAdam 更省心SGD 需要调 momentum把这些串成一个完整的训练入口大概长这样device torch.device(cuda if torch.cuda.is_available() else cpu) model HandwrittenCNN().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) epochs 10 for epoch in range(epochs): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, device ) val_loss, val_acc validate(model, test_loader, criterion, device) print(fEpoch {epoch1:02d} | 训练准确率 {train_acc:.4f} | 验证准确率 {val_acc:.4f}) # 保存权重后面推理要用 torch.save(model.state_dict(), handwritten_cnn.pt)通常跑 10 个 epoch 后验证集准确率能到 97% 以上把 epoch 增加到 15 到 20 个配合 Dropout可以超过 98.5%。如果准确率卡在 90% 附近别急着加网络深度先检查是不是预处理环节少了归一化。下一章会集中说这些坑。5. 手写数字识别 CNN 训练避坑5 个典型翻车现场5.1 现象Loss 不降反升或干脆不变化原因最常见的是数据没有归一化。手写数字图片的原始像素值范围是 0 到 255不经过 ToTensor 和 Normalize 直接送进模型数值过大梯度更新一步就可能把参数推到远离最优解的区域导致 Loss 震荡甚至越跑越大。另一个常见原因是学习率设置不当Adam 的默认学习率 0.001 属于比较稳的值如果直接用 0.1 甚至 0.5Loss 会在一开始就冲上天空。解决先确认 transform 里确实加了transforms.ToTensor()和transforms.Normalize((0.1307,), (0.3081,))这两行缺一不可。如果数据没问题把学习率降低到 0.0001 重新跑看 Loss 是否开始缓慢下降。一个小技巧是打印每个 batch 的outputs数值如果输出全部是很大的正数或负数基本就是数值溢出问题优先查归一化。5.2 现象模型把所有输入都预测成同一个数字原因这种情况通常会伴随验证集准确率停留在 10% 左右说明模型完全没有学到有效特征。常见原因包括训练和验证的数据集标签错位、某个类别样本量过少、学习率过大导致模型陷入一种病态状态。在实践中我发现更隐蔽的原因是 shuffle 参数设置错误验证循环里把 shuffle 设成了 True导致每次验证都随机抽样模型看到的样本分布不稳定。解决先打印一小批(images, labels)看看数据本身是否正常标签和图像是否对应。然后检查 DataLoader 设置训练集必须是shuffleTrue测试集必须是shuffleFalse。如果这两处都没问题把学习率降到 0.0001初始化方式换成默认的 kaiming重新训练。这个现象在新手项目里出现频率很高很多时候不是代码逻辑问题而是环境里残留了上一次运行的旧权重建议重新初始化模型再试。5.3 现象验证集准确率卡在 90% 到 92% 之间上不去原因对于手写数字识别来说90% 的准确率等于白做随便一个线性分类器都能达到这个水平。卡住通常意味着网络容量不足或者训练时长不够。如果你只用了单层卷积模型很难区分形状相近的数字比如 4 和 9、7 和 1。另一个原因是全连接层前的特征图维度算错导致展平后的向量没把所有信息都用上。解决把网络换成第 3 章给出的两层卷积结构确保padding1。如果已经是两层卷积试着把第一层输出通道从 32 提升到 64第二层从 64 提升到 128。MNIST 本身是相对简单的数据集不需要动用 ResNet 这种大模型深度过深反而容易过拟合参数量也会让人调试起来更吃力。训练方面验证准确率在 10 个 epoch 后还在爬升就继续加 epoch上限 30 个。5.4 现象训练速度慢到无法忍受原因CPU 训练本来就需要耐心但如果一个 epoch 耗时过长可能是数据加载变成了瓶颈。默认的 DataLoader 使用单进程加载数据num_workers0在 CPU 训练时瓶颈也可能出现在model.to(device)没有生效实际上还在 CPU 上跑。此外test_loader每次训练循环都被调用也会拖慢整体速度。解决训练循环前单独跑一次print(device)确认模型和数据的设备一致。数据加载方面给 DataLoader 加上num_workers2和pin_memoryTrue数据预处理会并行进行能明显提速。如果你的电脑有 CUDA 显卡把 GPU version 的 PyTorch 装上整个训练从几分钟缩短到几十秒省下的时间足够用来调参数。但要注意GPU 训练时test_loader的pin_memoryTrue也需要配合images.to(device)才能真正生效。5.5 现象训练好的模型识别不了真实手写图片原因这是最容易被忽略的一个坑也最容易让人对前面的训练产生怀疑。MNIST 训练集里的图片是 28×28、白字黑底、数字居中、笔画粗细相对均匀而你用手机拍摄或画图生成的图片往往是黑字白底、尺寸不是 28×28、数字可能偏在角落甚至有背景噪声。模型在训练分布上表现优秀一旦遇到分布外数据预测结果自然一塌糊涂。解决真实图片推理前必须做一套和训练一致的预处理。先用IMREAD_GRAYSCALE读成灰度图把白底黑字反转成白字黑底然后通过轮廓检测截取数字区域再resize到 28×28最后用训练时相同的均值标准差做归一化。这套流程写成代码就是第 6 章的推理函数做完之后你会发现模型其实没有坏只是数据形态不对。6. 从 MNIST 走向真实的自己手写数字推理、OpenCV 预处理与一个验证技巧6.1 真实图片预处理与推理代码在模型训练完备之后最激动人心的时刻就是把你自己写一个数字然后喂给模型让它猜一猜。但不要直接拿一张手机拍摄的白纸黑字照片丢给模型那样一定会翻车。这里给出一套完整的预处理和推理函数也是我在本地验证训练效果时最常用的流程。import cv2 import torch import numpy as np # 训练时保存的权重文件 model.load_state_dict(torch.load(handwritten_cnn.pt, map_locationdevice)) model.to(device) model.eval() def preprocess_image(path): # 1. 读取灰度图保留通道数为 1 img cv2.imread(path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(f图片读取失败请检查路径: {path}) # 2. 二值化 反转MNIST 是白字黑底 # THRESH_BINARY_INV 会把大于阈值的白色文字变成黑色背景上的白色前景 _, img cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV) # 3. 查找最外层轮廓裁剪出数字主体区域 # 这一步去掉拍摄时的大片空白背景让数字尽量填满画面 contours, _ cv2.findContours( img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) if contours: x, y, w, h cv2.boundingRect(contours[0]) img img[y:yh, x:xw] # 4. 统一尺寸到 28x28INTER_AREA 适合缩小图片保留更多结构信息 img cv2.resize(img, (28, 28), interpolationcv2.INTER_AREA) # 5. 归一化先转成 [0, 1]再做与训练完全一致的标准缩放 img img.astype(np.float32) / 255.0 img (img - 0.1307) / 0.3081 # 6. 增加 batch 维度 (1, 28, 28) 和通道维度 (1, 1, 28, 28) img torch.from_numpy(img).unsqueeze(0).unsqueeze(0) return img with torch.no_grad(): input_tensor preprocess_image(my_digit.png).to(device) pred model(input_tensor) print(预测结果, pred.argmax(dim1).item())步骤 3 里的轮廓检测是整套流程里最关键的一环。如果你写的数字是横向拉伸的或者比笔画周围留下了大片空白直接 resize 会把数字压扁或缩小模型很容易识别错。先裁剪再 resize能最大程度保留数字的原始比例。另外如果你的样本是多位数需要额外做数字切割这个方向已经超出本项目的核心范围暂时先不展开。6.2 在验证集上可视化混淆矩阵让“准确率”暴露模型的盲区只汇报一个 98% 的验证准确率并不足以说明模型在真实场景里值得信任。我通常会在训练结束后做一张混淆矩阵看看到底哪些数字是模型最容易混淆的。from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt import seaborn as sns y_true [] y_pred [] with torch.no_grad(): for images, labels in test_loader: images images.to(device) labels labels.to(device) outputs model(images) y_true.extend(labels.cpu().numpy()) y_pred.extend(outputs.argmax(dim1).cpu().numpy()) cm confusion_matrix(y_true, y_pred) plt.figure(figsize(8, 6)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues) plt.xlabel(预测值) plt.ylabel(真实值) plt.show()这张图会很清楚地把模型最容易搞混的类别展示出来。比如第 4 行第 9 列出现一片亮色说明手写“4”经常被误认为“9”。看到这种结果后你可以针对性收集更多“4”的训练样本或者让模型在预处理时对齐角度、重心这些都是在准确率数字之外真正提升模型质量的工作。我自己的习惯是训练出一个模型后先画混淆矩阵再用自己手写一组 0 到 9 的白纸黑字图片做一遍真实测试两关都过了才会考虑把这个模型应用到其他项目里。这个方向后续如果要做更复杂的验证还可以引入数据增强、迁移学习甚至用 ONNX 导出加速推理。希望这份代码和踩坑记录能帮到你。本文还有配套的精品资源点击获取