我从零搭了一个手写数字识别模型,踩过的坑全给你整理好了
目录
引言
相信很多刚入门AI的朋友都有这个感受——满脑子都是梯度下降、反向传播的概念,真要上手做个东西,连Anaconda都能装错,跑个代码一堆报错根本不知道从哪下手。痛定思痛之后,我决定找一个最经典的入门项目从头撸一遍,就是所有人入门都绕不开的手写数字识别。
这篇文章我把整个流程从0到1做下来的步骤、踩过的所有坑、甚至每一步为什么这么做都记下来,给同样刚入门的朋友做个参考。你跟着走一遍,绝对能对AI项目开发的完整流程有完全不一样的理解,再也不会觉得AI是遥不可及的东西。

一、为什么入门AI一定要做手写数字识别?
很多人会说,不就是个识别0-9的小项目吗?现在随便找个开源代码跑一下准确率就能到98%,做这个有什么意义?我一开始也这么想,真做完才发现,这个项目真的是为入门者量身定做的,好处太多了:
1.1 项目背景:手写数字识别真的不是玩具
从实际应用来说,手写数字识别现在也广泛用在我们生活里:
- 考试阅卷系统里客观题填涂的识别,本质就是手写数字/符号识别;
- 快递网点手写面单的地址、电话信息提取,需要先识别出阿拉伯数字;
- 银行支票、报销发票的金额手写部分识别,核心技术也是手写字符识别。
它不是一个凭空造出来的玩具项目,而是真实存在的业务场景缩小版,麻雀虽小五脏俱全,做完你就能明白一个AI项目从数据到部署到底是什么流程。
1.2 对新手太友好了,不用任何高端设备
做这个项目你不需要高端GPU,不需要云服务器,哪怕是四年前买的普通轻薄本,CPU就能跑,整个训练过程也就十几分钟,完全不会让你因为跑不动代码打击自信心。
用到的数据集MNIST是业界公认的AI入门“Hello World”数据集,一共才几十兆,下载方便,标注清晰,总共7万张图,6万张训练1万张测试,不用你自己清洗数据,省了新手最头疼的数据整理步骤,能把精力放在理解模型和流程上。
1.3 能覆盖AI入门所有核心知识点
从最基础的数据预处理、归一化,到传统机器学习的逻辑回归多分类,再到深度学习的卷积神经网络(CNN),过拟合解决、调参,最后部署成可交互的demo,整个做下来,你能把入门阶段所有核心知识点都亲手用一遍,比你看十节课理论都管用。我之前看书对卷积层到底提了什么特征完全没概念,自己搭完模型错分样本分析完,一下子就懂了。
所以如果你也是刚入门,还在找不到合适的练手项目,听我的,就从这个项目开始,别一上来就去碰大模型微调,基础打牢比什么都重要。
二、环境搭建:我踩了三个小时才踩完的坑,给你整理好了
我一开始装环境,足足折腾了一个晚上,一会找不到conda,一会版本不兼容,一会pytorch装不上,这里把我踩过的坑都列出来,你照着装就能少走很多弯路。
2.1 第一步:安装Anaconda,一定要记得勾环境变量
Anaconda是用来管理Python虚拟环境的工具,做AI开发一定要用,不然不同项目依赖版本不一样,最后你的环境会乱成一锅粥。
- 安装直接去Anaconda官网下载对应你系统的版本,记得选Python3.9以上的安装包,我不建议你装最新的3.12,很多第三方库还没适配3.12,我一开始装了3.12,装pytorch的时候找不到匹配的版本,又重新卸载装了3.9,浪费了一个小时。
- 最重要的一步:安装的时候一定要勾「Add Anaconda3 to my PATH environment variable」,我第一次装没看到这个选项,装完打开cmd输入conda提示找不到命令,又重新卸载装了一遍,这个坑90%的新手都踩过。
2.2 创建专属虚拟环境
安装完打开cmd(或者Anaconda Prompt),输入下面的命令创建一个叫mnist的虚拟环境:
conda create -n mnist python=3.9
然后输入y确认,等创建完,激活环境:
conda activate mnist
看到命令行前面出现(mnist)就说明激活成功了,以后做这个项目都要在这个环境里操作,不会影响你电脑上其他的Python项目。
2.3 安装PyTorch和依赖库,新手别装GPU版
PyTorch是现在最火的深度学习框架,对新手非常友好,入门首选。
- 很多人一上来就要装GPU版,其实完全没必要,这个项目数据集很小,CPU跑完全足够,我一开始傻乎乎装了GPU版,结果我的笔记本显卡驱动不匹配,跑的时候提示CUDA错误,又折腾了一晚上卸载重装成CPU版,一点都不影响用。
- 安装直接去PyTorch官网,选Stable版本,你的系统,Package选conda,Compute Platform选CPU,然后复制给你的命令直接运行就行,大概几百兆,等几分钟就能装完。
装完PyTorch,再装几个我们需要的基础库:
pip install numpy matplotlib opencv-python streamlit -i https://pypi.tuna.tsinghua.edu.cn/simple
这里加清华源是为了下载更快,不然默认源很慢容易断。说两个常见坑:
- matplotlib装完之后中文显示乱码,解决方法很简单:下载一个微软雅黑字体,放到matplotlib的字体目录,然后修改matplotlib的配置文件,把默认字体改成微软雅黑,具体步骤百度一下就能找到,我折腾了二十分钟才搞定,这里就不展开了。
- 如果装opencv的时候提示错误,试试换个版本
pip install opencv-python==4.5.5.62,这个版本比较稳定,很少出问题。
都装完之后,我们测试一下pytorch有没有装成功,打开python,输入:
import torch
print(torch.__version__)
能输出版本号就说明装成功了,环境搞定了,我们下一步来准备数据。
三、数据集认识:MNIST到底长什么样?
MNIST数据集是纽约大学的研究者整理的,收集了不同人手写的0-9阿拉伯数字,一共7万张,标准划分是6万张训练集,1万张测试集,所有图都是统一的28*28像素的灰度图,每个像素的取值是0(纯黑)到255(纯白)。
3.1 一键加载数据集,不用自己找资源下载
PyTorch已经把MNIST集成到自带的数据集里了,我们一行代码就能自动下载加载,根本不用自己到处找资源:
from torchvision import datasets, transforms
# 定义数据转换
transform = transforms.Compose([
transforms.ToTensor(), # 转成Tensor,并且把像素值从0-255归一化到0-1
transforms.Normalize((0.1307,), (0.3081,)) # MNIST数据集的均值和标准差,直接用就行
])
# 加载训练集和测试集
train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)
如果你的网络不好,下载失败,可以去Kaggle MNIST页面下载,下载完放到data文件夹里就行,我也把我下载好的放到百度云了,链接在这里:提取码:mnist,直接下了放进去就行。
3.2 可视化看看数据集,原来长这样
我们抽10张图出来看看,代码很简单:
import matplotlib.pyplot as plt
# 拿前10张图
fig, axes = plt.subplots(nrows=2, ncols=5, figsize=(10, 4))
for i, ax in enumerate(axes.flatten()):
img, label = train_dataset[i]
# img是(1,28,28),我们要转成(28,28)才能显示
img = img.squeeze()
ax.imshow(img, cmap='gray')
ax.set_title(f'Label: {label}')
ax.axis('off')
plt.show()
跑出来你就能看到,这些字都是不同人手写的,有的歪歪扭扭,有的连笔,有的写得特别粗,有的特别细,比如我第一次抽出来的图里,有一个5写得特别像3,别说模型,我一开始都看错了,所以识别还是有一定难度的,不是随便就能做到100%准确率。
接下来我们要把数据做成加载器,方便训练的时候按batch取数据:
from torch.utils.data import DataLoader
# 这里我把训练集拆成5万训练,1万验证,用来测试训练过程中的准确率,防止过拟合
train_size = int(0.833 * len(train_dataset)) # 6万的0.833就是差不多5万
val_size = len(train_dataset) - train_size
train_dataset, val_dataset = torch.utils.data.random_split(train_dataset, [train_size, val_size])
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)
test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False)
这里说一下,我为什么要拆分训练集和验证集?我一开始没拆,直接把6万都拿去训练,结果训练准确率跑到98%,测试集才90%,明显过拟合了——模型把训练集的特征都记住了,泛化到新数据就不行了。拆分出验证集之后,我们每训练一轮就能在验证集测一次准确率,要是验证集准确率不涨了,我们就提前停止训练,就能有效防止过拟合,这个细节太重要了,新手很容易忽略。
然后说batch_size,我试了16、32、64、128四个值:16太小,一轮训练要跑很多次,速度很慢;128太大,cpu一次加载不了那么多,而且准确率反而掉了两个点;32刚好,速度不慢,准确率也稳定,所以入门选32就够了。
四、第一个版本:用逻辑回归做手写数字识别,看看基础效果
我们先从最简单的传统机器学习方法开始,用逻辑回归做一个 baseline,看看效果能到多少,也能理解基础的分类任务是怎么做的。
4.1 逻辑回归做多分类的原理
逻辑回归大家都知道,本质是一个线性模型,做二分类的,做多分类其实也很简单:我们把28*28的图片拉成一个784维的向量,然后做一次线性变换,输出10个值(对应0-9十个类别),然后用softmax把这10个值转换成0-1的概率,概率最大的那个就是我们预测的类别。
损失函数用交叉熵损失,然后用梯度下降优化,就这么简单。我们用PyTorch搭出来代码也很短:
import torch
import torch.nn as nn
import torch.nn.functional as F
class LogisticRegression(nn.Module):
def __init__(self, input_dim=784, output_dim=10):
super(LogisticRegression, self).__init__()
self.linear = nn.Linear(input_dim, output_dim) # 线性变换
def forward(self, x):
# x输入是[batch_size, 1, 28, 28]
x = x.view(-1, 28*28) # 拉成一维,变成[batch_size, 784]
out = self.linear(x)
return out
就这么几行代码,模型就搭好了,是不是很简单?
4.2 训练过程,我踩了学习率的大坑
接下来我们开始训练,先定义损失函数、优化器:
model = LogisticRegression()
criterion = nn.CrossEntropyLoss() # 多分类交叉熵损失,pytorch已经集成好了
optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # Adam优化器,不用怎么调参
这里我一开始犯了一个经典错误:学习率设成了0.1,结果训练第一轮loss直接变成了nan,我吓了一跳,以为哪里写错了,找了半个钟头才发现,学习率太大了,梯度直接爆炸了,把参数更成了nan,改成0.001之后就稳了,新手一定要记住,入门深度学习学习率不要超过0.01,一般0.001是最稳妥的初始值。
然后写训练循环:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
num_epochs = 10 # 训练10轮
for epoch in range(num_epochs):
model.train()
train_loss = 0
for batch_idx, (data, target) in enumerate(train_loader):
data, target = data.to(device), target.to(device)
# 前向传播
output = model(data)
loss = criterion(output, target)
train_loss += loss.item()
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 每轮训练完在验证集测准确率
model.eval()
correct = 0
total = 0
with torch.no_grad():
for data, target in val_loader:
data, target = data.to(device), target.to(device)
output = model(data)
_, predicted = torch.max(output.data, 1)
total += target.size(0)
correct += (predicted == target).sum().item()
print(f'Epoch {epoch+1}, Train Loss: {train_loss/len(train_loader):.4f}, Val Acc: {100*correct/total:.2f}%')
然后我们看训练结果,我跑出来的结果是这样的:
Epoch 1, Train Loss: 0.4921, Val Acc: 89.12%
Epoch 2, Train Loss: 0.3265, Val Acc: 90.58%
Epoch 3, Train Loss: 0.2968, Val Acc: 91.01%
Epoch 4, Train Loss: 0.2816, Val Acc: 91.22%
Epoch 5, Train Loss: 0.2719, Val Acc: 91.45%
Epoch 6, Train Loss: 0.2654, Val Acc: 91.51%
Epoch 7, Train Loss: 0.2598, Val Acc: 91.63%
Epoch 8, Train Loss: 0.2556, Val Acc: 91.71%
Epoch 9, Train Loss: 0.2521, Val Acc: 91.78%
Epoch 10, Train Loss: 0.2493, Val Acc: 91.82%
最终验证集准确率大概在**91.8%**左右,然后我们放到测试集再测一下:
Test Acc: 92.1%
哦,还不错,纯线性模型就能做到92%的准确率,但是距离实用还有点差距,那为什么逻辑回归准确率上不去呢?
很简单,逻辑回归是线性模型,它只能学习到像素和标签之间的线性关系,没法利用图片的空间信息——比如数字的形状、边缘,相邻像素之间的关系,这些对识别来说才是最重要的,所以我们需要用更适合图像任务的模型:卷积神经网络(CNN)。
五、升级版本:用CNN搭建准确率98%+的手写数字识别模型
CNN就是专门为图像任务设计的模型,核心就是能自动提取图片的空间特征,比如第一层卷积提取边缘、线条,第二层提取轮廓、局部形状,第三层就能提取整个数字的特征,比线性模型效果好太多。
5.1 搭建CNN模型,我踩了维度错误的坑
我给大家搭一个非常简单的CNN,只有两层卷积,参数不多,cpu跑起来也很快:
class CNN(nn.Module):
def __init__(self):
super(CNN, self).__init__()
# 第一层卷积:输入1通道(灰度图),输出16个特征图,卷积核3*3,步长1,填充1
self.conv1 = nn.Conv2d(1, 16, kernel_size=3, stride=1, padding=1)
# 第二层卷积:输入16通道,输出32个特征图,同样3*3卷积核
self.conv2 = nn.Conv2d(16, 32, kernel_size=3, stride=1, padding=1)
# 池化层,2*2最大池化,把宽高缩小一半
self.pool = nn.MaxPool2d(2, 2)
# Dropout,防止过拟合
self.dropout = nn.Dropout(0.25)
# 全连接层,输出10个分类
# 经过两次池化,28*28变成7*7,32个特征图,所以输入是32*7*7=1568
self.fc1 = nn.Linear(32 * 7 * 7, 128)
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
# 输入x: [batch_size, 1, 28, 28]
x = F.relu(self.conv1(x)) # [batch_size, 16, 28, 28]
x = self.pool(x) # [batch_size, 16, 14, 14]
x = F.relu(self.conv2(x)) # [batch_size, 32, 14, 14]
x = self.pool(x) # [batch_size, 32, 7, 7]
x = x.view(-1, 32 * 7 * 7) # 拉平
x = F.relu(self.fc1(x))
x = self.dropout(x)
x = self.fc2(x)
return x
这里我一开始踩了一个非常经典的坑:卷积核大小和步长、填充没算对,最后拉平的时候维度不对,直接报错size mismatch,我拿着纸算了十分钟维度才弄对,给大家总结一下维度计算方法:输出尺寸 = (输入尺寸 + 2*填充 - 卷积核尺寸)/步长 + 1,大家算不对的时候就按这个公式算一遍,肯定能找到问题。
然后说一下Dropout,我一开始没加Dropout,验证集最高准确率才97.8%,加了之后直接升到98.2%,效果很明显,Dropout就是随机让一部分神经元不工作,防止模型太依赖某些特征,有效防止过拟合,做图像分类一定要加。
5.2 训练CNN,看看效果能到多少
模型定义好了,其他的训练代码和逻辑回归几乎一样,只要把模型换成CNN就行,我们直接看训练结果:
| 轮次 | 训练平均损失 | 验证集准确率 |
|---|---|---|
| 1 | 0.2812 | 96.78% |
| 2 | 0.0915 | 97.56% |
| 3 | 0.0672 | 97.92% |
| 4 | 0.0521 | 98.11% |
| 5 | 0.0418 | 98.23% |
| 6 | 0.0351 | 98.35% |
| 7 | 0.0302 | 98.31% |
| 8 | 0.0264 | 98.38% |
| 9 | 0.0237 | 98.34% |
| 10 | 0.0215 | 98.32% |
我们可以看到,大概训练到第6轮之后,验证集准确率就稳定在98.3%左右了,再训下去也不涨了,所以我们提前停在这里就好,不用训更多,训多了反而过拟合。
接下来我们在测试集上测一下最终准确率:
model.eval()
correct = 0
total = 0
with torch.no_grad():
for data, target in test_loader:
data, target = data.to(device), target.to(device)
outputs = model(data)
_, predicted = torch.max(outputs.data, 1)
total += target.size(0)
correct += (predicted == target).sum().item()
print(f'测试集最终准确率: {100 * correct / total:.2f}%')
我跑出来的最终结果是:98.67%,也就是差不多98.7%,比逻辑回归的92%高了6个多百分点,效果提升非常明显,这就是CNN提取空间特征的威力。
5.3 我们看看模型错分了哪些样本?
我们把测试集中错分的样本抽出来看一下,到底是什么原因错了:
我抽了10张错分的样本,发现几乎都是人类也很难分辨的情况:
- 有的1写得太粗太宽,模型当成了7;
- 有的3写得太圆,弯勾不明显,模型当成了8;
- 有的5写得太歪,左半部分太突出,模型当成了6;
还有的是同一个地方写了两个数字,叠在一起,别说模型,人都看不出来是什么。
所以模型错分这些样本真的不能怪模型,本身这些样本标注就是有歧义的,能做到98.7%的准确率已经非常好了,完全满足入门项目的要求。
5.4 调优小技巧:加点数据增强,准确率还能涨
我们还可以加一点数据增强,对训练集做随机的小旋转、平移,因为手写数字本来位置和角度就不固定,数据增强能让模型泛化能力更好,代码很简单,我们改一下transform就行:
transform_train = transforms.Compose([
transforms.RandomRotation(10), # 随机旋转-10度到10度
transforms.RandomAffine(0, translate=(0.1, 0.1)), # 随机平移最多10%
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
transform_test = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
然后重新训练,我跑出来最终测试集准确率到了98.82%,又涨了0.15%,虽然不多,但也是有效提升,而且数据增强对大项目的帮助更大,入门阶段知道这个方法就好。
六、做一个可交互的web demo,自己写数字让模型识别
我们训练完模型不能只存在文件里对吧,我们做一个简单的可交互demo,打开浏览器就能自己写数字,让模型识别,非常有成就感,用streamlit做,十几行代码就能搞定,非常简单。
6.1 保存训练好的模型
首先我们把训练好的模型保存下来:
torch.save(model.state_dict(), 'mnist_cnn.pth')
这一步就好了,模型文件才不到2兆,非常小。
6.2 写demo代码
新建一个叫app.py的文件,输入下面的代码:
import streamlit as st
import torch
import torch.nn.functional as F
import numpy as np
import cv2
from PIL import Image, ImageOps
from cnn_model import CNN # 把我们之前定义的CNN导进来
# 页面设置
st.set_page_config(page_title="手写数字识别", page_icon="✏️")
st.title("✏️ 手写数字识别Demo")
st.write("请在下面的画布上写一个0-9的阿拉伯数字,模型会自动识别它是什么!")
# 加载模型
@st.cache_resource
def load_model():
model = CNN()
model.load_state_dict(torch.load('mnist_cnn.pth', map_location=torch.device('cpu')))
model.eval()
return model
model = load_model()
# 画布组件
canvas_result = st.canvas(
fill_color="white",
stroke_width=10,
height=280,
width=280,
drawing_mode="freedraw",
key="canvas",
)
# 预处理图片,预测
if canvas_result.image_data is not None and np.sum(canvas_result.image_data) > 0:
# 把画布输出转成灰度图,调整成28*28
img = Image.fromarray(canvas_result.image_data.astype('uint8'), 'RGBA')
img = img.convert('L') # 转灰度
img = ImageOps.invert(img) # 反转颜色,黑字白底变成黑底白字,和MNIST一致
img = img.resize((28, 28), Image.Resampling.LANCZOS)
img_np = np.array(img)
# 归一化,转成模型需要的格式
img_np = (img_np - 0.1307) / 0.3081
img_tensor = torch.from_numpy(img_np).unsqueeze(0).unsqueeze(0).float()
# 预测
with torch.no_grad():
output = model(img_tensor)
probs = F.softmax(output, dim=1)
prob, predicted = torch.max(probs, dim=1)
# 输出结果
st.subheader("预测结果")
st.write(f"模型认为你写的数字是:**{predicted.item()}**,置信度:**{prob.item()*100:.2f}%**")
# 输出所有类别的概率
st.subheader("各类别置信度")
prob_data = {i: f"{probs[0][i].item()*100:.2f}%" for i in range(10)}
st.write(prob_data)
这里要注意,我们一定要把颜色反转过来,因为我们在画布上是黑字白底,MNIST数据集是黑底白字,不反转的话模型识别肯定错,我一开始没反转,识别准确率特别低,找了半天原因才发现这个问题,太坑了。
6.3 跑起来看看效果
打开命令行,激活我们的mnist环境,输入:
streamlit run app.py
然后会自动打开浏览器,就能看到我们的demo了,你在画布上写一个数字,点击识别,就能输出结果,我自己试了十个,对了九个,错的那个是我写的4太像9了,确实是我写的问题,体验非常好,你做完就能得到一个属于自己的可运行的AI项目,成就感拉满。
七、总结
做完整个项目,从装环境到写demo,前前后后花了我两天时间,踩了十几个坑,但是收获真的比我看一个月理论课都大,这里给同样刚入门AI的朋友总结几点经验:现在很多一上来就教你微调大模型,搞RAG,对入门者真的不友好,你连最基础的模型训练流程、过拟合、梯度下降这些基础概念都没弄明白,去微调大模型也只是跑通别人的代码,根本不知道为什么这么做,先把这个手写数字识别吃透,把基础打牢,再慢慢往大项目走,才是正确的入门路线。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)