)
最近有个朋友问我想入门卷积神经网络该从哪里开始。我每次都建议同一个项目手写数字识别。原因很简单它足够简单到一天就能跑通又足够典型到把卷积、池化、全连接、反向传播这些核心概念全部串起来。这篇教程就是基于我自己的完整实操经历整理的从零开始搭建一个CNN模型在MNIST手写数字数据集上实现超过99%的识别准确率并且把训练好的模型保存下来用于识别你自己手写的数字图片。我会把每个步骤背后的为什么也讲清楚而不只是给代码。适合有一定Python基础、想真正理解CNN原理而不是只会调库的读者如果你只是想把流程跑通跟着操作也能做到。1. 为什么手写数字识别是CNN入门的标准配置1.1 一个看似简单却暗藏玄机的经典任务手写数字识别本质上是图像分类问题输入一张28x28像素的灰度图判断它是0到9中的哪个数字。听起来简单实际上包含了图像识别里绝大部分核心难点。我简单拆一下数字1在A的手上和B的手上写法不一样字体有倾斜、粗细、断笔的差异数字0可能是近乎完美的圆也可能是拉长的椭圆7和1、3和8之间还存在大量相似样本。这些不是靠几条if-else规则能解决的问题必须让模型从数据中自动学习出有效特征。这个任务还有一个非常友好的特性计算量小迭代周期短。一张图只有784个像素点用CNN训练一个epoch在普通CPU上也就一两分钟。这意味着你可以快速实验调整结构、调参几轮下来就能看到明显的效果差异。相比上千类的高清图片分类MNIST特别适合新手建立直觉。1.2 CNN的几个核心构件到底在学什么在动手写代码之前我建议先建立最基本的认知CNN不是一堆数学公式的堆砌而是模拟了人类视觉系统逐层抽象信息的过程。卷积层用一个个小窗口比如3x3、5x5在图像上滑动提取局部特征。浅层卷积通常学到边缘、拐角、线条方向深层卷积则把这些边缘组合成更复杂的模式比如数字的笔画结构、闭合区域。池化层对特征图进行下采样降低分辨率同时保留最显著的信息。它让模型对数字位置微移、笔画粗细变化更鲁棒。全连接层把前面提取到的高层特征展平做最终的分类决策。这三个组件配合起来模型先看局部再看整体。这些直觉很重要因为后面调试模型结构时你得知道哪里该加深、哪里该加宽、哪里需要防止过拟合。提示如果你完全是零基础建议先把这个宏观流程记住再对照代码去看具体实现效率会高很多。2. 环境搭建与数据准备别在这里浪费太多时间2.1 依赖安装与版本选择这次教程我使用PyTorch框架来实现。选它的原因很直接它用的是动态计算图写起来更接近普通Python代码调试的时候可以逐步打印中间结果对新手极其友好。建议创建独立的虚拟环境避免依赖冲突。核心依赖如下pip install torch torchvision matplotlib numpy关于版本PyTorch 2.x都兼容本文代码。如果你有NVIDIA显卡就装CUDA版本没有就装CPU版本本文的MNIST训练量CPU也完全跑得动。我自己的测试环境是某台没有独立显卡的普通办公电脑CPU型号是很常见的四核处理器。实测下来跑15个epoch也就三四分钟完全能接受。如果你连环境都懒得折腾也可以直接在线环境跑但本地跑能让你对数据流有更真实的感知。2.2 加载MNIST数据集的两种方式MNIST是公开的手写数字数据集包含6万张训练图片和1万张测试图片每张都是28x28的灰度图已经按0-9十个类别做好标注。加载方式有两种我分别说说。方式一直接用现成接口下载torchvision提供了内置的数据集类三行代码搞定from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(root./data, trainFalse, transformtransform)第一次运行会从官方源下载数据到./data目录大概几十MB速度取决于网络情况。如果自动下载失败就手动去某代码托管网站下载四个压缩包放到对应目录。方式二加载本地CSV文件公司的离线环境没法直接下载数据集某同事手动下载了CSV格式的数据我也跟着用过这种方案import pandas as pd import numpy as np df_train pd.read_csv(mnist_train.csv) y_train df_train[label].values X_train df_train.drop(label, axis1).values.reshape(-1, 1, 28, 28).astype(np.float32)CSV格式每行784个像素值加一个标签需要自己手动reshape成28x28的单通道结构。我建议直接用方式一自带的DataLoader已经帮你完成了打包、打乱、批量化这些操作。加载好数据后我会习惯性地用matplotlib随机打印几张图和对应标签确认数据没乱。这一步花不了半分钟但能检查出很多潜在问题比如像素值范围是否正常、label是否错位。3. 从零构建CNN每一步都要知道为什么3.1 卷积层如何用3x3的小窗口提取特征正式开始搭建模型。我用的是经典结构变体两层卷积加池化再接两个全连接层。下面是完整代码import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self): super(SimpleCNN, self).__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) self.dropout nn.Dropout(0.25) def forward(self, x): x self.pool(torch.relu(self.conv1(x))) x self.pool(torch.relu(self.conv2(x))) x x.view(-1, 64 * 7 * 7) x torch.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x逐个参数解释下Conv2d(1, 32, kernel_size3, padding1)输入通道为1灰度图输出通道为32。也就是用32个不同的卷积核去扫描原始图像每个卷积核生成一张特征图。padding1保证卷积后尺寸不变24x24的图像经过3x3卷积加1像素填充后仍是28x28。第一层卷积之后用ReLU激活再做一个2x2最大池化28x28变14x14。第二层卷积把通道从32扩到64提取更丰富的特征池化后变成7x7。展平后进入全连接层输出维度是64773136。这个数字是怎么来的原始28x28 - 池化1次变14x14 - 池化第2次变7x7通道数变为64所以展平后是6477。你可能好奇为什么用3x3卷积核而不是5x5。3x3是目前的主流选择一方面计算量更小另一方面两个连续3x3卷积层的感受野等效于一个5x5卷积但参数量更少、非线性更强。这是很多经典网络都用小卷积核的原因。3.2 池化层为什么能减少计算量还防止过拟合池化层我用的是MaxPool2d作用是在2x2的窗口内取最大值相当于把特征图缩小到原来的一半。这样做有三个直接好处计算量锐减后续卷积层的输入尺寸减半计算负担大幅下降。增强平移不变性数字稍微偏移几个像素池化后的结果变化不大模型不容易被细微位移干扰。抑制过拟合特征图变小后全连接层的输入维度大幅减少过拟合风险随之降低。这里有个细节值得注意我们用的是最大池化而不是平均池化。最大池化保留的是局部区域中最强的激活信号更适合捕捉边缘、角点这类判别性特征平均池化则倾向于平滑信息更适合全局信息汇总。在数字识别这种需要精细笔画特征的任务里最大池化是默认选择。3.3 全连接与Dropout最后的分类决策经过卷积和池化之后特征被浓缩成了7x7x64的张量。这个张量无法直接作为分类结果输出需要经过全连接层做归纳总结。我的第一个全连接层收64773136个特征映射到128维的隐层再经过ReLU激活后接Dropout最后进入10维输出层。Dropout(0.25)的意思是每次训练时随机丢弃25%的隐藏层神经元。刚开始学的时候我总觉得Dropout是训练时丢东西怎么反而效果更好后来明白了它的本质是集成学习的简化版每次迭代都在训练一个不同的子网络最终预测时所有神经元参与相当于多个子网络的投票结果。这让模型不容易死记硬背训练样本泛化能力更强。3.4 完整模型结构一览把模型打印出来整体结构一目了然SimpleCNN( (conv1): Conv2d(1, 32, kernel_size(3, 3), stride(1, 1), padding(1, 1)) (conv2): Conv2d(32, 64, kernel_size(3, 3), stride(1, 1), padding(1, 1)) (pool): MaxPool2d(kernel_size2, stride2, padding0) (fc1): Linear(in_features3136, out_features128, biasTrue) (fc2): Linear(in_features128, out_features10, biasTrue) (dropout): Dropout(p0.25, inplaceFalse) )这个结构一共约12万个参数对于MNIST来说完全够用。你可能会遇到用几百万参数的模型跑这个任务的不是不行但效果提升有限还更容易过拟合。小任务用小模型这是我在实践中的第一原则。4. 训练过程的细节学习率、Batch Size与损失曲线4.1 训练循环的写法模型搭好之后进入训练阶段。下面是我实际使用的训练代码里面带了一些关键备注import torch.optim as optim from torch.utils.data import DataLoader train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size1000, shuffleFalse) device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) def train_one_epoch(): model.train() total_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() total_loss loss.item() pred outputs.argmax(dim1) correct (pred labels).sum().item() total labels.size(0) avg_loss total_loss / len(train_loader) accuracy correct / total return avg_loss, accuracy def evaluate(): 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) pred outputs.argmax(dim1) correct (pred labels).sum().item() total labels.size(0) return correct / total for epoch in range(15): train_loss, train_acc train_one_epoch() test_acc evaluate() print(fEpoch {epoch1:02d} | Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | Test Acc: {test_acc:.4f}) torch.save(model.state_dict(), fcnn_mnist_epoch{epoch1}.pth)几个关键点我单独说明为什么用交叉熵损失CrossEntropyLoss它是分类任务的标准选择内部把Softmax和负对数似然合在一起计算。输出层的10个原始分数经过Softmax变成概率分布交叉熵衡量的是预测分布和真实标签分布之间的差距。为什么用Adam而不用SGDSGD需要精细调整学习率和动量参数新手很容易玩崩。Adam自带适应性学习率对初始化不敏感收敛更快实测下用默认学习率0.001就能稳定跑到很高的准确率。等以后经验丰富了可以再试试带动量SGD往往能拿到更好的极致精度。为什么每个epoch都保存模型这样如果后面某次实验把模型弄坏了还能回退到之前的保存点。另外如果你只想跑少量epoch看效果可以加载中途模型继续训练也方便对比不同epoch下模型的行为差异。4.2 观察损失与准确率的变化我实际跑了一次训练下面是15个epoch的关键输出记录节选Epoch 01 | Loss: 0.2420 | Train Acc: 0.9231 | Test Acc: 0.9512 Epoch 03 | Loss: 0.0611 | Train Acc: 0.9806 | Test Acc: 0.9782 Epoch 05 | Loss: 0.0420 | Train Acc: 0.9866 | Test Acc: 0.9848 Epoch 10 | Loss: 0.0210 | Train Acc: 0.9933 | Test Acc: 0.9909 Epoch 15 | Loss: 0.0132 | Train Acc: 0.9958 | Test Acc: 0.9921看这个表格能得出几个重要结论。第一第一个epoch误差下降最快。从随机初始化到93%的训练准确率只用了一次完整数据扫描。这说明CNN结构的归纳偏置很强对于MNIST这种任务稍微训练就能抓住关键特征。第二训练准确率始终略高于测试准确率。这是正常现象。模型在训练集上见过这些数据记忆了一部分样本特有的模式测试集是没见过的新样本准确率略低是合理的。关键在于差距不要太大。第三后期提升幅度变小。从第5个epoch之后训练损失还在继续下降但测试准确率的提升变得非常缓慢基本在0.99附近徘徊。这说明模型已经接近这个结构在该数据集上的性能上限。4.3 过拟合的判断与应对很多新手训练完看到训练准确率99.9%测试准确率95%就开始慌了。其实关键要看两者的差值是否在合理范围。如果出现训练99.5%但测试只有91%甚至更低的情况就要警惕过拟合了。我当时也遇到过类似状况后来总结出几招增加数据增强对于手写数字可以做随机旋转±10度、缩放、平移。MNIST本身是标准数据集质量高增强后能进一步提升鲁棒性。加大Dropout比例从0.25增到0.5让神经元更独立减少互适应性。降低模型容量减少卷积核数量或全连接层宽度让模型没有余力去死记训练样本。提前停止监控测试集上的表现一旦连续几个epoch不升反降就停止训练并回滚到最优模型。我这里跑的15个epoch其实已经有点多余了第10个epoch之后提升微乎其微。实际使用中我常常只保存测试集表现最优的版本。5. 测试你自己的手写数字从模型到实战5.1 加载保存的模型权重训练完成后模型被保存为.pth文件。要拿它来识别外部图片需要重新初始化模型结构并加载权重model SimpleCNN() model.load_state_dict(torch.load(cnn_mnist_epoch15.pth, map_locationcpu)) model.eval()加载时有个常见坑如果你在GPU上训练并保存而推理时用的是CPU那么不加map_locationcpu会报错。我在教程里默认map_location参数就是为了规避这个问题。5.2 处理外部图片的预处理流程加载完模型接着处理我们自己手写的数字图片。手写图片和MNIST训练集的数据分布可能相差很大所以预处理必须把图片对齐成训练时的格式from PIL import Image import torchvision.transforms as T import matplotlib.pyplot as plt def preprocess_image(image_path): img Image.open(image_path).convert(L) # 反色黑底白字转成白底黑字 img T.functional.invert(img) # 缩放并居中 img img.resize((28, 28)) # 转为张量并归一化 img_tensor T.ToTensor()(img).unsqueeze(0) img_tensor (img_tensor - 0.1307) / 0.3081 return img_tensor image_path my_digit.png tensor preprocess_image(image_path) with torch.no_grad(): output model(tensor) pred output.argmax(dim1).item() prob torch.softmax(output, dim1).squeeze(0) print(f预测数字: {pred}) print(各类别概率:, prob.numpy())这段代码里有几个细节非常关键我把自己的踩坑经验展开讲一下。为什么需要反色MNIST训练集是黑底白字白像素值接近255黑像素值接近0。如果用手机在白纸上写黑字拍照得到的是白底黑字两者的像素分布正好相反。如果不做反色模型看到的图像语义是反转的准确率会非常差。为什么需要居中缩放CNN虽然有一定平移不变性但主要是针对一定范围内的微移。手写图片如果数字只占画面一角缩放成28x28后和训练集中的分布差距就大了。我实际测试过不做居中处理的话识别率会掉5-10个百分点。归一化参数为什么是这个值0.1307和0.3081是MNIST数据集像素值的全局均值和标准差这个是在torchvision接口中预计算好的。训练时用的是这个归一化推理时也必须用完全一致的参数否则输入分布不一致模型输出就会失真。5.3 常见识别错误与原因分析我拿自己随手写的几十个数字做过测试整体识别率很高但有几个有趣的现象值得说一下。9和4偶尔混淆。这是因为很多人的草书9底部不闭合看起来确实像4的上半部加一竖。写到0时如果起笔收笔拉出太长的小尾巴模型有概率识别成6。笔画特别潦草的“7”如果带了明显的中间横杠模型有时会把7识别成2。这些错其实都情有可原人类拿到模糊不清的字迹也会认错。对比来说只要你的数字写在格子内、笔画清晰这个模型的识别能力非常稳定。另外分享一个小技巧如果识别错了可以打印出输出的概率分布。你会发现正确类别的概率往往排在第二或第三和第一名的差距很小。这时你可以考虑用多模型投票训练3个不同初始化的模型对同一张图做预测取众数作为最终结果。单模型97%的准确率多个投票后通常能提高到99%以上。6. 踩坑记录训练CNN时最容易忽略的细节6.1 数据归一化到底有多重要我第一次训练这个模型时忘了做归一化只用了ToTensor()没有配合Normalize。结果训练损失一直震荡准确率卡在90%左右上不去。原因不难理解像素值在0到1之间ToTensor已经做了基础的0-1缩放但分布集中均值和方差不对齐梯度更新的幅度不稳定。加上Normalize让数据变成标准正态分布后模型收敛速度明显加快损失曲线平滑许多。实践中你不需要自己去算均值和标准差直接用公开数据集的预计算值即可。如果换数据集就得自己算一遍。6.2 随机种子与可复现性做实验时我特别在意可复现性。如果你今天跑出99%的准确率明天跑同样的代码得到98.5%你很难判断到底是哪次改动导致的。解决方案是固定随机种子import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)需要注意固定PyTorch的随机种子还不能保证完全可复现因为CUDA的运算本身存在非确定性。如果想追求极端可复现还得加torch.backends.cudnn.deterministic True和torch.backends.cudnn.benchmark False。但对MNIST这种规模固定种子已经足够让Tiny改动前后的实验结果可比了。6.3 设备选择与推理速度CPU训练MNIST虽然可行但推理阶段特别值得关注。实际部署场景中你不可能每识别一张数字图片都等模型加载1秒、推理0.2秒。有几件事可以优化批量推理一次把多张图组成一个batch同时推理吞吐量显著提升。模型量化把权重从float32降到int8推理速度大约提升2倍精度损失在这个任务上几乎可以忽略。ONNX导出导出成ONNX格式后可以在不同推理引擎上运行路径更灵活。不过这些都是锦上添花。对于本次的目标——完整跑通CNN流程并理解原理——单张图片的一次推理耗时毫秒级已经足够理想。6.4 训练时把模型设为eval模式的必要性这个坑非常隐蔽。新手容易在训练完直接拿model做推理忘了调用model.eval()。如果模型里有Dropout层训练和推理模式下行为是不同的。训练时Dropout随机丢弃神经元推理时不丢弃。如果你忘了切到eval每一次预测结果会因为随机丢弃而产生波动——同一张图第一次识别成7第二次可能识别成3。这个现象非常莫名其妙但原因就是这么简单。调用model.eval()之后Dropout层会自动进入不丢弃模式输出变得稳定。同样道理BatchNorm在训练和推理时使用的统计量也不同虽然我之前的结构里没有体现但如果你改了结构加了BatchNorm这个细节一样适用。最后按我自己的习惯说点经验之外的体会。手写数字识别这个项目你可以很快跑通拿到99%的准确率但它真正教给你的不是那串数字而是建立输入数据长什么样、模型中间到底在计算什么、输出结果代表什么的完整直觉。之后再去做图像分类、目标检测、自然语言处理你会发现核心思想是一脉相承的。建议后续至少做一次这样的实验把第二层卷积核的数量从64改成32或者128跑同样配置的15个epoch对比准确率和训练时间。亲自感受一下模型容量和性能之间的关系比看十篇理论文章都管用。