ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

MNIST手写数字识别实战:TensorFlow与PyTorch双框架实现全解析

MNIST手写数字识别实战:TensorFlow与PyTorch双框架实现全解析 手写数字识别应该是我接触深度学习以来做的第一个完整实验也是很多教程里的“Hello World”。这次实验我没有只用一个框架而是把TensorFlow和PyTorch各写了一遍从数据加载、网络搭建、训练调参到测试评估整个流程完整跑通最后整理成一份实验报告。代码和报告一起归档之后我又把整个过程的思路重新梳理了一遍这篇文章就相当于把这次实验的全过程展开来讲模型怎么搭、参数怎么设、训练中遇到哪些问题、实验报告怎么写一次性说明白。这个项目非常适合刚入门神经网络的朋友哪怕你之前只写过一点Python跟着这篇文章的思路也能把一个完整的手写数字识别流程跑起来。文章里的代码都是可以直接复制运行的环境和依赖我也会把版本细节说清楚。文章后面还会分享我这次实验中踩过的几个坑、调参时的对比数据以及实验报告的写法参考这些内容在常规教程里一般不会写。1. 实验设计从数据到模型整个识别流程怎么串联1.1 为什么选择MNIST作为入门实验做深度学习实验第一步就是选数据集。手写数字识别用的是MNIST数据集里面有6万张训练图片和1万张测试图片每张都是28x28的灰度图内容是0到9的手写数字。这个数据集很“干净”图片已经做了居中处理背景和前景对比明显所以即使是简单模型也能达到不错的准确率。但不要因为“简单”就小看它。MNIST虽然只是入门级数据集但在这个数据集上能跑通整套流程你就能把神经网络怎么工作这件事彻底搞明白输入怎么进入网络、权重怎么更新、损失函数在优化什么、准确率是怎么计算出来的。这些基本概念一旦通了后续再接触CIFAR-10、ImageNet或者各种实际项目思路是完全一样的。我这次实验选的不是更花哨的数据集就是因为MNIST足够小、下载快、训练也快。在最开始的时候把精力放在理解原理和流程上比追求复杂数据集更有价值。1.2 两套框架各写一遍的价值在哪现在的深度学习框架主要就是TensorFlow和PyTorch两大阵营。很多人在入门时纠结该学哪个。我的建议是如果你真的想入这一行两个都得会一点起码要能读懂对方的代码。原因不复杂不同公司、不同开源项目用的框架不一样你出去实习或接项目很可能遇到什么就得用什么。但更重要的一点是用两个框架各写一遍同一个任务能帮助你区分“框架的写法”和“模型的本质”。比如在TensorFlow里搭全连接层你可能就是调用一个Dense()在PyTorch里则要写Linear()。表面看是API不同但底层都是做同一个矩阵运算输入向量乘以权重矩阵加上偏置经过激活函数。你只有两个都写过才会真正理解这些层背后的数学运算是一致的。这次实验我在两个框架里用了同一个网络结构、同样的训练轮数和差不多的超参数最终得到的准确率也很接近。这种对比实验本身就很有意思能让你直观看到“框架”只是工具真正决定模型效果的是数据、结构、超参数这些核心因素。1.3 网络结构怎么定先搞清全连接和卷积的区别这次实验我并没有一上来就上卷积神经网络而是先用全连接网络Dense网络跑通流程之后又额外做了一个卷积神经网络版本做对比。很多人喜欢直接跳到CNN跳过了基础版本我觉得这样做其实是吃亏的。全连接网络的理解成本最低。输入是一张28x28的图片你把它“拉平”成784个数值然后这些数值作为第一层神经元的输入。每个神经元都跟上一层的所有神经元连接这就是“全连接”的含义。这种结构看起来直观缺点是参数非常多而且它不考虑图片的空间结构——把一个像素和离它很远的像素一视同仁。卷积神经网络CNN就不同了它利用卷积核在图片上滑动提取局部特征。一开始的卷积层提取的可能是边缘、线条这类低级特征后面层会把低级特征组合成高级特征比如数字的弧线、交叉点。因为卷积核是参数共享的参数量反而比全连接少得多而且更适合处理图片。我在两种网络上做了对比实验结果很典型全连接网络在测试集上准确率大约97.5%CNN能达到99%以上。这个对比不是说全连接没用而是让你切身感受到“网络结构对任务效果的巨大影响”。2. 环境配置与数据准备2.1 依赖安装与版本建议环境这块我踩过不少坑特别是版本不匹配的问题。这里给出一套我实测能跑的版本组合你可以直接参考我用的是Python 3.9环境。TensorFlow方面用的2.10版本PyTorch用的是1.13版本。如果你电脑有NVIDIA显卡并且装好了CUDA那PyTorch版可以启用GPU加速没有显卡也没关系这次实验的数据量和模型规模都不大用CPU训练也完全能跑就是每轮训练可能会多花几分钟。安装方面TensorFlow直接执行pip install tensorflow2.10即可。PyTorch因为涉及CUDA版本的选择建议去它的官网根据你的环境生成安装命令。但要注意如果不需要GPU加速直接pip install torch torchvision也行默认装的CPU版本。建议在虚拟环境里安装不要直接干到系统环境里。我曾经图省事直接在系统环境装了一堆包结果后面其他项目依赖冲突光排查就耗了半天。用virtualenv或者conda管理环境是每个做深度学习的人都应该养成的基本习惯。注意不同版本的框架API会有差异。比如TensorFlow 1.x和2.x的写法完全不同你在网上查资料时一定要先确认版本。如果照着旧教程写新版本代码会经常遇到报错。2.2 手写数字图片怎么就变成了矩阵理解数据格式是理解整个实验的第一步。一张MNIST图片是28x28的灰度图所谓灰度图就是每个像素点只有一个数值表示亮度范围从0到255。0是纯黑255是纯白中间是深浅不同的灰色。所以在计算机眼里一张手写数字图片并不是“图片”而是一个28x28的二维矩阵里面是280多个数值。而神经网络处理输入时通常需要的是一个向量所以全连接网络的第一步就是把28x28的矩阵“拉平”成784个数值的一维向量。我觉得理解这一点特别重要因为很多人在后面遇到维度不对的报错时一头雾水本质上就是没有搞清楚数据的形状。跟数据形状相关的问题占深度学习调试的很大比例。我这次实验在展示数据时做了一步可视化从训练集里随机抽了12张图片把它们画出来并标注对应的标签。你会看到那些手写数字有的歪歪扭扭有的甚至人眼都难辨认但机器学习算法依然能从中找出规律这件事本身就挺奇妙的。2.3 数据归一化这一步不能省图像预处理中有一件非常重要的事归一化。简单说就是把像素值从0到255的范围缩放到0到1之间。方法很简单把所有像素值除以255即可。为什么要做这一步因为神经网络的训练依赖梯度下降而梯度的大小跟输入数值的范围直接相关。如果输入数值都很大而且范围很宽某些权重的梯度会过大或过小导致训练不稳定收敛也慢。把输入缩放到0到1这样的小范围能让梯度保持在一个相对合理的尺度训练过程会更顺畅。除了归一化我还对训练数据做了打乱操作。这个也很重要如果一批数据里全是同一个数字模型在这批数据上算出的梯度就会有偏差长期下来模型泛化能力会变差。打乱数据让每个批次尽可能包含各类数字训练会更稳定。提示我在预处理阶段还额外看了每个数字类别的数量分布确认了MNIST的数据是基本均匀的。如果类别极度不均衡模型的训练策略就需要调整了好在MNIST没有这个问题。3. TensorFlow版实现从搭建模型到输出准确率3.1 用Sequential搭建一个全连接网络TensorFlow里最直观的建模方式是Keras的Sequential API它就像搭积木一样一层一层地往上加。我这次设计的网络结构是输入层784个节点对应28x28的展平图片第一层全连接128个神经元用ReLU激活函数然后接一个Dropout层丢弃率0.2第二层全连接10个神经元用Softmax激活函数做分类输出。代码看起来是这样的import tensorflow as tf from tensorflow.keras import Sequential from tensorflow.keras.layers import Dense, Dropout, Flatten model Sequential([ Flatten(input_shape(28, 28)), Dense(128, activationrelu), Dropout(0.2), Dense(10, activationsoftmax) ]) model.summary()为什么第一层选128个神经元其实没有绝对的标准。128是一个在效果和计算量之间比较平衡的取值。更少的神经元比如64会快一些但可能欠拟合更多的比如256会慢一些但不一定能显著提升准确率。你可以把这个128理解为一个“经验起点”后面通过对比实验再调整。Dropout层是干什么的它是防止过拟合的。简单理解就是训练时随机“关掉”一部分神经元的连接让网络不能过度依赖某几个节点从而逼着更多神经元学会有用的特征。这里设0.2的意思是每批数据训练时有20%的神经元连接被随机丢弃。10个输出节点对应0到9这10个数字类别。Softmax会把10个输出值转换成概率分布加起来等于1比如模型认为这张图是数字5的概率是0.85是数字6的概率是0.1以此类推。最终预测结果取概率最大的那个类别。3.2 训练过程里损失函数和优化器怎么选建模之后就要“训练”了。训练的核心是让模型输出的概率分布尽量接近真实标签。怎么衡量“接近程度”这就是损失函数的作用。这次我用的损失函数是交叉熵CrossEntropyLoss。交叉熵是分类任务最常用的损失函数数值越小说明模型的预测越接近真实标签。在TensorFlow里因为标签是0到9的整数而不是独热编码所以用sparse_categorical_crossentropy这个“sparse”表示标签用整数形式它内部会自动处理成独热编码来计算省了一步手动转换很方便。优化器选的是Adam。Adam可以理解成一个自适应的梯度下降算法它会根据梯度的情况自动调整每一步的更新步长学习率所以整体上很“省心”。如果是新手用Adam做默认选择基本不会错。学习率我设置了0.001这是Adam一个很常用的初始值。训练时我设置验证集比例是10%。也就是说6万张训练图片里5.4万张用于训练6000张用于验证。验证集的作用是在每轮训练后检查模型在没见过的数据上的表现帮助我们判断模型是欠拟合、正常、还是过拟合。model.compile( optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy] ) history model.fit( x_train, y_train, batch_size32, epochs10, validation_split0.1, verbose1 )batch_size32表示每次从训练数据里取32张图片计算一次梯度并更新权重。为什么不一次把所有数据都送进去因为6万张图片全部算一遍梯度内存开销大而且梯度方向可能不够平滑。32这个值是经验选择这也是后面可以调参的点。3.3 评估阶段准确率之外还要看什么训练结束后在测试集的1万张图片上做了评估得到了大约97.5%的准确率。也就是说100张手写数字图片里模型大约能正确识别97到98张。test_loss, test_acc model.evaluate(x_test, y_test) print(f测试集准确率: {test_acc:.4f})但只看准确率其实不够我额外生成了分类报告列出了每一个数字类别的精确率Precision、召回率Recall和F1分数。这一步很有价值因为它能告诉你模型对哪个数字识别得特别好对哪个数字容易混淆。比如我这次实验里模型对数字“0”的识别非常好但对“4”和“9”这一类字形相似的组合会偶尔搞混。如果某些个别类别的表现拉低了整体准确率你就有了明确的改进方向比如针对混淆的数字补充更多样化的训练样本、调整网络结构、改进预处理方式等。这种带着业务视角去分析模型的方法在实际工作中是非常重要的能力。4. PyTorch版实现手写训练循环搞清底层逻辑4.1 数据加载Dataset和DataLoader怎么配合PyTorch的数据流程跟TensorFlow的高层API差别很大。TensorFlow里model.fit()就把数据加载和训练都封装好了PyTorch则更“手动”每个环节都要你自己写清楚。这个过程能帮你更深入地理解训练到底是怎么发生的。先把数据集读进来。PyTorch自带MNIST数据集可以方便地下载但你通常还需要自己定义怎么对数据做变换import torch from torch import nn from torch.utils.data import DataLoader 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, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue) test_loader DataLoader(test_dataset, batch_size32, shuffleFalse)这里的ToTensor()把图片从0到255的像素值转成0到1的浮点张量同时又多了一个维度变成1x28x281是通道数因为是灰度图。Normalize()再做标准化用MNIST数据集的像素均值和标准差把数据变成标准正态分布这比简单的除以255更精细能进一步提升训练稳定性。DataLoader就好比一个自动分发的传送带每次告诉你一批数据。设置shuffleTrue意味着每轮训练时数据都重新打乱这是防止模型“记住”顺序。测试集不用打乱因为测试时顺序无所谓。4.2 定义模型类继承nn.Module要做什么PyTorch里定义模型的标准姿势是写一个类继承nn.Module。这样做的好处是框架会自动管理模型的参数、前向传播过程等。class DigitNet(nn.Module): def __init__(self): super().__init__() self.fc1 nn.Linear(28 * 28, 128) self.dropout nn.Dropout(0.2) self.fc2 nn.Linear(128, 10) def forward(self, x): x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x model DigitNet()在__init__里定义了网络有哪些层在forward里定义了数据怎么在这些层之间流动。注意第一层nn.Linear(28*28, 128)输入是784维但数据从DataLoader出来时形状是[batch_size, 1, 28, 28]所以forward里第一件事就是把[batch_size, 1, 28, 28]展平成[batch_size, 784]用x.view(x.size(0), -1)实现。顺便说一下全连接层不经过Flatten是没法直接处理图片形状的这也是一个新手经常报错的地方。TensorFlow那边可以用Flatten()层帮你处理PyTorch这边则要手动写这个view操作。两相对比你会更清楚框架各自帮你做了什么。这里我注意到一个点nn.Linear默认会做随机初始化通常无需手动指定。但如果你做更复杂的实验了解每个层的初始化方式会有帮助因为初始化不当可能会导致训练初期的梯度消失或爆炸。4.3 训练循环里loss.backward()到底发生了什么PyTorch最让人印象深刻的地方是自动求导。你只需要把损失传进loss.backward()框架就会自动算出所有参数的梯度。但具体每一步在做什么我建议每个人都亲手写一遍训练循环。optimizer torch.optim.Adam(model.parameters(), lr0.001) loss_fn nn.CrossEntropyLoss() def train_one_epoch(model, dataloader, optimizer, loss_fn): model.train() running_loss 0.0 correct 0 total 0 for images, labels in dataloader: optimizer.zero_grad() outputs model(images) loss loss_fn(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() return running_loss / len(dataloader), correct / totaloptimizer.zero_grad()的作用是清空上一步留下的梯度。PyTorch的机制是梯度默认会累积如果不手动清零每一步的梯度会叠加导致更新方向混乱训练不收敛。新手最容易犯这个错。loss.backward()计算梯度。optimizer.step()根据梯度和优化器规则更新权重。这三步合在一起才是一个完整的参数更新过程。在测试时要用torch.no_grad()把梯度计算关掉因为测试只需要前向传播不需要反向传播关掉它可以省内存也加快速度。def evaluate(model, dataloader, loss_fn): model.eval() correct 0 total 0 test_loss 0.0 with torch.no_grad(): for images, labels in dataloader: outputs model(images) loss loss_fn(outputs, labels) test_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() return test_loss / len(dataloader), correct / total注意model.train()和model.eval()的切换。这两个模式会影响Dropout等层的表现训练时Dropout正常随机丢弃神经元测试时要保证所有神经元都用上否则结果会不稳定。忘记切换模式也是一个常见错误。用PyTorch跑完同样的网络结构后测试准确率大约也是97.5%左右。这印证了前面说的两个框架只是API不同底层数学是一样的。5. 实验结果分析与调参记录5.1 训练曲线loss降不下去的时候别急着加层我每次训练都会把损失值和准确率记录下来画出曲线图。这张图能告诉你很多信息。这次实验里训练集和验证集的loss都在下降而且下降速度逐渐变缓这是正常的收敛形态。训练集和验证集的曲线大致同步说明没有严重的过拟合。如果训练loss持续下降但验证loss不降反升就是过拟合的典型征兆这时候可以考虑加Dropout、减小网络规模、或用更多数据做增强。如果loss一开始降得很慢甚至不降怎么办我的建议是先别急着改结构查一下学习率是否过大或过小才是关键。我这次实验对比了学习率0.01和0.001的情况0.01时loss虽然收敛快但验证集准确率波动较大0.001时训练更稳最终准确率反而更高。如果学习率再大比如0.1loss可能会出现抖动甚至直接发散。打开训练曲线就想看“训练跑没跑起来”这一点比盯着训练集准确率更重要。训练准确率高但验证准确率低说明泛化差两个都低可能问题出在数据预处理或学习率设置上。5.2 不同超参数的对比实验调参不能靠猜。我这次实验做了一组小型对比把关键超参数和最终准确率整理成了表格这样看效果比文字描述直观得多。网络结构优化器学习率batch_size训练轮数测试准确率全连接128Adam0.001321097.5%全连接128SGD0.01321096.9%全连接256Adam0.001321097.8%CNNAdam0.001321099.1%看到这张表结论就很清晰了从全连接128到256准确率提升并不明显但从全连接到CNN准确率提升非常显著。这说明在图像任务上“结构选对”比“堆参数”更有效。与其把全连接层从128加到256甚至512不如换一个更适合处理图像的网络结构。批次大小的影响我也测过64的batch_size在训练速度上比32快一些但准确率会出现轻微下降因为每批次计算的梯度更“粗”了。这个影响在全连接网上还不算大但在一些更复杂的模型上会更明显。这些对比实验本身也是实验报告里很加分的内容。导师和同行看到的不只是“我跑出来了”而是“我理解各个参数的影响”这完全是两个层次的体现。5.3 错误样本分析看看模型到底错在哪光给一个97.5%的数字还不够我会把预测错的样本打印出来看看。这个习惯我非常推荐。我发现错误样本主要集中在几类情况有些数字写得太潦草连人眼都很难判断有些数字因为笔画太细或者扫描变形特征不明显还有一类就是确实长得非常像比如“4”和“9”、“7”和“2”人都有可能看错。把这些错例和对应的真实标签、预测标签放在一张图里你能直观地看到模型在哪类问题上容易犯糊涂。如果你要继续优化模型这就是最直接的“改进需求说明书”。提高准确率的思路可以是从数据入手做数据增强旋转、缩放、添加噪声让模型看到更多样的写法也可以尝试更换更大的网络架构、加入注意力机制等。6. 实验报告怎么写 避坑清单6.1 一份合格的实验报告该包含什么实验报告不是把代码粘贴上去凑页数。我写实验报告时通常遵循一个结构背景与目标、方法、实验结果、分析、总结。背景与目标部分清晰说明要解决什么问题识别手写数字、为什么这个问题有代表性它是图像分类的经典任务。方法部分写清楚网络结构、损失函数、优化器、超参数设置最好画出网络结构图配合文字解释每一层的输入输出形状这部分能让读者一眼看懂你的设计思路。实验结果部分放上训练曲线、准确率表格、错误样本举例。分析部分对照实验表格说明哪些因素起了作用哪些没有。总结部分写上这次实验的收获和可改进方向。写分析时最忌讳的就是只写“准确率是97.5%”而不写“为什么是97.5%”“有什么办法能进一步提升到99%”。后者才是实验报告的价值所在。在总结里我写了一句自己的真实体会“通过全连接网络和卷积网络的对比我认识到对于图像任务选择合适的网络结构比单纯增加参数量更关键。”这个结论不是空话而是从对比数据里自然得出来的。6.2 我踩过的5个坑和对应解决方案做实验的过程中肯定会出问题我总结了自己踩过的高频坑写在这里。第一个坑是TensorFlow版本不匹配。有一次我照着网上教程用tf.placeholder写代码结果因为我装的是2.x版本这个API早就没了直接报错。解决方法是先确认版本教程也必须用对应版本的文章。现在我在查资料时第一眼看的就是文章标题或开头标注的版本号。第二个坑是PyTorch训练时忘记调用optimizer.zero_grad()。导致每轮的梯度不断累加loss不降反升我还一度以为模型结构写错了。自查了一阵才发现是这么个小问题。这种问题你自己遇到一次就长记性了。第三个坑是CPU训练太慢。我第一次跑实验时没装GPU版PyTorch用CPU训练全连接网络还行等到跑一个稍微宽一点的网络时每一轮都要好久。后来我发现一个很聪明的办法用一个小一点的样本子集先把整个代码流程跑通确认无误后再用全量数据训练。调试速度和迭代效率提升明显。第四个坑是测试时忘了用model.eval()导致Dropout在预测时仍然生效每次预测出来的结果都不一样。如果你发现同一个样本每次预测结果都不同十有八九是这个原因。第五个坑是数据加载时图片维度的顺序搞混。PyTorch的图片张量是[通道数, 高度, 宽度]而TensorFlow默认是[高度, 宽度, 通道数]这两个框架的差异让人很容易混淆。如果你在写代码时发现维度报错先检查数据形状是否符合当前框架的约定。6.3 这个项目还能怎么扩展跑通MNIST只是一个起点我做完这个实验后一直在想下一步可以怎么走。最直接的扩展是把模型换成真正的CNN网络加几层卷积和池化层你会发现准确率明显提升。进一步的思路是尝试一些现代架构里的技巧比如BatchNorm、残差连接、数据增强等观察它们对性能的影响。再进一步你可以把训练好的模型拿来识别自己手写的数字通过摄像头或画图工具生成图片输入到模型里做预测。这个玩法看起来简单但它会逼着你搞定“真实图像的处理流程”比如背景怎么去除、字母怎么定位、缩放到28x28时保持比例等。这里面门道非常多。如果你对理论更感兴趣还可以做一个“参数可视化”。把训练好的第一层权重画出来你可能会看到一些类似笔画、边缘的特征提取器。把隐藏层的中间输出画出来也能直观感受到网络在逐层提取信息。这些内容会让你的实验报告和项目展示更有深度。最后再说两句做完这次双框架实验我最大的体会就是手写数字识别这个任务看起来简单但把整个流程完整跑通、把细节搞明白收获远大于“我会用API了”。现在再让我去写一个新模型的代码我不需要去网上搜“该怎么开始”因为训练一个模型的基本流程已经刻在脑子里了。这种基本功是任何框架教程都替代不了的。如果你也正在做这个实验我的建议很直接:别只满足于把代码跑通试试改一下网络结构、换个优化器、看看错误样本这个过程才会让你真正进步。实验报告也认真写把每个决定背后的原因写清楚。等你做完再看这不仅仅是一个作业而是你深度学习入门的第一块扎实的垫脚石。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表