引言

相信很多刚入门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

这里加清华源是为了下载更快,不然默认源很慢容易断。说两个常见坑:

  1. matplotlib装完之后中文显示乱码,解决方法很简单:下载一个微软雅黑字体,放到matplotlib的字体目录,然后修改matplotlib的配置文件,把默认字体改成微软雅黑,具体步骤百度一下就能找到,我折腾了二十分钟才搞定,这里就不展开了。
  2. 如果装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,对入门者真的不友好,你连最基础的模型训练流程、过拟合、梯度下降这些基础概念都没弄明白,去微调大模型也只是跑通别人的代码,根本不知道为什么这么做,先把这个手写数字识别吃透,把基础打牢,再慢慢往大项目走,才是正确的入门路线。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐