ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PyTorch实战:从零搭建DNN识别MNIST手写数字

PyTorch实战:从零搭建DNN识别MNIST手写数字 1. 环境准备与工具链选择先搭好能跑通的PyTorch距离上一次更新已经有一阵子了后台也有不少朋友在催这一篇。老规矩咱们先把整套环境从头过一遍——倒不是重复啰嗦而是PyTorch的安装环节确实有几个容易被忽略的细节尤其对刚接触深度学习的朋友来说一旦环境装错了后面所有代码都会变得非常难排查。所以这一篇在正式开始写DNN代码之前我会用一个完整的章节来聊环境准备这本身也是实战里非常关键的一步。如果你是跟着系列文章一路走过来的应该知道我一直推荐的是Anaconda作为基础Python环境管理工具。原因很简单Anaconda可以帮你把Python版本、依赖库、CUDA相关的组件都打包隔离在独立环境里避免和系统自带的Python冲突也方便以后同时维护多个不同的深度学习项目。深度学习本来就是一个依赖关系比较复杂的领域今天要装这个库、明天要升那个包没有虚拟环境隔离的话光依赖问题就能让人崩溃。1.1 用Anaconda创建独立环境安装Anaconda的过程我就不赘述了官网下一个安装包一路默认安装就行。装完之后打开终端或者Anaconda Prompt执行下面的命令创建环境conda create -n dnn_mnist python3.9 conda activate dnn_mnist这里我选择了Python 3.9而不是更新的3.10或者3.12一个很实际的原因是目前大部分深度学习库对3.9的兼容性打磨得最成熟后面如果做更复杂的项目、装一些编译依赖比较重的库时踩坑的概率会小很多。当然如果你已经是老手想直接用更高版本也完全可以关键是保证PyTorch适配。1.2 安装PyTorchCPU版还是GPU版进入环境之后就是安装PyTorch本体。这里需要先确认一个方向性问题你是在CPU上跑还是用NVIDIA显卡跑GPU加速如果是纯学习、入门跑通代码CPU版本完全够用MNIST这种小数据集用CPU训练一个DNN每个epoch也就是几秒钟的事体验并不会差太多。而且CPU版本安装最省心不需要考虑CUDA版本、显卡驱动这些乱七八糟的事情pip install torch torchvision如果你有NVIDIA独立显卡那肯定推荐装GPU版。但这里就有一个需要特别小心的点PyTorch的CUDA版本要和你的显卡驱动匹配。不要盲目装最新的CUDA正确的做法是先看自己机器的驱动支持什么版本。最简单的确认方式是打开终端输入nvidia-smi看右上角的CUDA Version那个数字就是你的驱动支持的最高CUDA版本比如显示CUDA 12.1那么你安装PyTorch的CUDA版本就不能高于12.1。然后去PyTorch官网的get started页面选择对应的CUDA版本复制生成的命令安装即可。比如pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121注意不要在同一个环境里混着装CPU版和GPU版PyTorch很容易把依赖弄乱。如果装错了最干净的做法是直接删掉环境重建比花时间定位问题更高效。1.3 验证安装是否成功装完之后千万别急着写代码先验证一下能不能正常导入并检测到设备import torch print(torch.__version__) print(torch.cuda.is_available())如果能正常输出版本号GPU版会打印True那就说明环境彻底OK了。如果你这里打印出False而你的机器明确有NVIDIA显卡那大概率就是CUDA版本不匹配回到上一步重新选版本安装即可。环境准备这个环节说到底就是一句话不要急一次装对后面能省下几十倍的时间。我见过太多新手在装环境上花了几天其实核心问题也就是版本匹配那一两行命令的事。2. MNIST数据集与DNN神经网络的数学原理环境搞定接下来进入正题。MNIST是一个入门深度学习绕不开的数据集它的全称是Modified National Institute of Standards and Technology里面包含6万张训练图片和1万张测试图片每张都是28像素乘以28像素的手写数字灰度图内容对应0到9这十个数字。说白了这个任务就是让机器看图片然后告诉我们图片里写的是哪个数字。为什么大家入门都用MNIST因为它足够简单但又不失代表性。图片尺寸小、数据量适中、类别清晰同时它又是标准的图像识别任务该有的要素一个不少。今天我们要做的DNNDeep Neural Network深度神经网络就是在这个数据集上构建一个多分类模型。2.1 一张图片如何进入神经网络先聊一个基础概念计算机是不认识图片的它只能处理数字。一张28x28的灰度图在计算机眼里就是一个28行28列的二维数组每个位置的值代表该像素点的亮度范围从0到2550是纯黑255是纯白。那么神经网络怎么处理这个二维数组最直接的办法就是把它拉直成一个一维向量。28乘以28等于784也就是说一张图变成784个数字排成一排。这就是DNN处理图像的基本方式也是DNN和CNN卷积神经网络最大的区别DNN不考虑图像的空间结构只是把所有像素当作一个长向量喂进网络而CNN会利用像素之间的空间位置关系。2.2 DNN的核心结构从输入到输出DNN的本质就是一系列矩阵乘法和非线性激活函数的叠加。我们的输入是一个784维的向量它不是直接映射到10个类别上而是经过多个隐藏层逐层变换每一层都在提取不同抽象程度的信息。第一层可能提取出的是笔画边缘特征中间层可能组合出数字的部分轮廓最后层再把所有这些特征综合起来做出分类判断。每一层的计算可以写成这个形式h activation(W * x b)其中x是上一层的输出W是这一层的权重矩阵b是偏置向量activation是激活函数。权重W就是神经网络需要学习的东西训练的过程就是不断调整W和b让最终的输出越来越准确。以我们后面要构建的模型为例输入784个像素值第一个全连接层把它映射到512个神经元再传到下一层映射到256个神经元最后一层输出10个数字。最后一层输出的10个值代表模型认为这张图属于0到9每个类别的置信度哪个数字最大就预测为哪个数字。2.3 激活函数的作用ReLU为什么是默认选择如果每一层只是做线性变换那不管堆多少层本质上仍然等价于一个线性模型。激活函数的意义就在于引入非线性让网络有能力拟合真正复杂的函数关系。实践中最常用的激活函数是ReLU公式非常简单max(0, x)。负数输入一律截断为0正数原样保留。ReLU之所以成为默认首选有几个原因一是计算成本极低二是能有效缓解梯度消失问题——Sigmoid函数在输入很大或很小时梯度趋近于0反向传播时梯度不断缩小网络就学不动了而ReLU在正区间的梯度恒定是1传播得很稳定。当然ReLU也有一个著名的毛病神经元死亡。如果某个神经元输出的值一直是负数它会被ReLU永久置为0梯度也永远是0这个神经元就再也无法更新了。缓解办法包括使用Leaky ReLU变体或者在初始化权重时多加注意。对我们的MNIST任务来说常规ReLU已经足够好使先不必过度担心这个问题。2.4 损失函数与反向传播的循环逻辑有前向传播就一定有反向传播。前向传播是数据从输入到输出走一遍得到预测结果损失函数算出预测和真实标签之间的差距然后反向传播把每个参数对损失的影响程度算出来用梯度下降去更新参数。分类任务最常用的损失函数是交叉熵Cross Entropy。为什么不用均方误差因为分类输出的是概率分布交叉熵直接衡量两个概率分布的差异在梯度上表现更好、收敛也更快。PyTorch里有一个非常贴心的设计torch.nn.CrossEntropyLoss()这个函数内部自动组合了LogSoftmax和NLLLoss也就是说你只需要把模型的原始输出和真实标签喂给它它会自动帮你做softmax处理不需要在模型里额外加softmax层。这是新手经常出错的一个点后面写代码时要注意。概念有了接下来就是把这些数学原理翻译成PyTorch代码。3. 从零搭建DNN识别MNIST核心代码逐段拆解下面进入动手环节。我会把整个实现拆成几段代码每一段都会说明它做了什么、为什么这样做方便你跟着逐步构建出完整的代码。3.1 数据加载torchvision的Dataset与DataLoader训练模型的第一步是拿到数据。PyTorch生态里有一个专门处理视觉数据的库叫torchvision它提供了MNIST数据集的下载和读取接口几行代码就能把数据准备好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 torch.utils.data.DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader torch.utils.data.DataLoader(test_dataset, batch_size64, shuffleFalse)这里有两个容易被忽视但很重要的细节。第一个是ToTensor()它把PIL图像从HWC格式转成CHW格式同时做了归一化像素值从0到255变成0到1。第二个是Normalize((0.1307,), (0.3081,))这两个数字是MNIST数据集全部像素的均值和标准差。标准化之后数据变成均值为0方差为1的分布这对神经网络的收敛非常有帮助。注意Normalize的两个参数必须用括号括起来即使只有均值、标准差两个数也要写元组形式。后面的逗号很容易漏漏了会直接报类型错误。DataLoader的作用是自动把数据集分批打包并打乱顺序。batch_size64表示每次取出64张图作为一个小批量送进网络。为什么要用小批量而不是一次把所有6万张图都送进去一方面是内存放不下另一方面小批量能带来一定的随机性梯度的波动反而有助于跳出局部最优。3.2 定义DNN模型结构模型结构是所有代码中最核心的部分我们定义一个包含两个隐藏层的全连接网络。按照惯例输入层和隐藏层之间的线性变换名字里带fc前缀最后一层通常叫outputimport torch.nn as nn import torch.nn.functional as F class DNN(nn.Module): def __init__(self): super(DNN, self).__init__() self.fc1 nn.Linear(784, 512) self.fc2 nn.Linear(512, 256) self.fc3 nn.Linear(256, 10) def forward(self, x): x x.view(-1, 784) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) x self.fc3(x) return x重点解释几个维度关系nn.Linear(784, 512)接收784个输入输出512个神经元nn.Linear(512, 256)再把这512个压缩成256个最后的nn.Linear(256, 10)把256个特征综合成10个类别的打分。在forward函数里第一个动作是x.view(-1, 784)这行的作用是把不同形状的输入拉成统一形状。view是PyTorch专门用来变换张量形状的方法-1表示让PyTorch自动推断这个维度的大小。假设输入是64张28x28的图形状是(64, 1, 28, 28)view之后变成(64, 784)这样就能喂给全连接层了。注意激活函数的位置前两个全连接层之后都接了ReLU最后一层没有接ReLU也没有接Softmax。原因前面强调过CrossEntropyLoss内部会自己处理softmax如果你在模型里先softmax了就会得到一份错误的梯度。3.3 训练循环epoch、batch和梯度更新有了模型和数据接下来就是训练的核心逻辑。先选定优化器和损失函数import torch.optim as optim model DNN() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001)优化器选的是Adam这是入门阶段最稳妥的选择。它的核心思想是把梯度下降和一阶动量、二阶动量结合起来能自适应地为每个参数调整学习率。相比最原始的SGD随机梯度下降Adam对学习率不那么敏感收敛更快对新手来说容错空间更大。下面写训练的主循环def train(model, device, train_loader, optimizer, epoch): model.train() for batch_idx, (data, target) in enumerate(train_loader): data, target data.to(device), target.to(device) optimizer.zero_grad() output model(data) loss criterion(output, target) loss.backward() optimizer.step() if batch_idx % 100 0: print(fTrain Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}] Loss: {loss.item():.6f})这段代码里的每个函数顺序都有讲究尤其是下面三行optimizer.zero_grad() loss.backward() optimizer.step()zero_grad()清空上一次迭代累积的梯度backward()计算当前batch的梯度step()用计算出来的梯度更新模型参数。千万不要把顺序搞反也别忘了每次迭代都要清空梯度否则梯度会一直累加下去参数更新就乱套了。3.4 测试评估验证模型真正的识别能力训练完之后必须用没见过的测试集来评估。MNIST的测试集有一万张图模型从来没在训练中见过它们用这个数据测才真正反映泛化能力def test(model, device, test_loader): model.eval() test_loss 0 correct 0 with torch.no_grad(): for data, target in test_loader: data, target data.to(device), target.to(device) output model(data) test_loss criterion(output, target).item() pred output.argmax(dim1, keepdimTrue) correct pred.eq(target.view_as(pred)).sum().item() test_loss / len(test_loader.dataset) accuracy 100. * correct / len(test_loader.dataset) print(fTest set: Average loss: {test_loss:.4f}, Accuracy: {correct}/{len(test_loader.dataset)} ({accuracy:.2f}%))关键点在于model.eval()和torch.no_grad()这两行。eval()是把模型切换到评估模式这会影响到后面会讲到的dropout和batch normalization等层的运行方式。no_grad()告诉PyTorch这个代码块里不需要计算梯度所以不会保存任何中间变量内存占用更小推理速度更快。如果做推理时不加这两个模型照样能跑但既慢又耗内存养成加上它们的好习惯很重要。argmax(dim1)表示沿着类别方向找出最大值对应的索引也就是找出模型认为最可能的数字。3.5 主函数把整个流程串起来最后写一个主函数把训练和测试串起来import torch def main(): device torch.device(cuda if torch.cuda.is_available() else cpu) print(fUsing device: {device}) model DNN().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) num_epochs 10 for epoch in range(1, num_epochs 1): train(model, device, train_loader, optimizer, epoch) test(model, device, test_loader) if __name__ __main__: main()torch.device(cuda if torch.cuda.is_available() else cpu)这一句是自动判断能用GPU就用GPU不能用就退回CPU这样同一套代码既能跑在带显卡的机器上也能在普通笔记本上调试。典型的运行输出会是这样Train Epoch: 1 [0/60000] Loss: 2.305026 Train Epoch: 1 [6400/60000] Loss: 0.367143 ... Test set: Average loss: 0.0012, Accuracy: 9756/10000 (97.56%)到这里一个完整的DNN识别MNIST的流程就跑通了。4. 训练过程中的常见陷阱与调参实测能跑通只是第一步。我在带新手做这个项目的过程中几乎每一次都会遇到下面这些问题这里集中梳理一遍帮你少走弯路。4.1 损失函数下降缓慢甚至不下降学习率的问题如果你发现训练了十几个epoch损失值还在2.3附近波动几乎没有下降那大概率是学习率设置不合适。交叉熵损失在随机初始化时大约等于ln(10)≈2.302接近这个值说明模型基本在瞎猜。学习率太小比如0.00001参数每次只挪动一点点网络学得非常慢epoch少的话根本看不出变化。学习率太大比如0.1参数剧烈晃动损失值会在不同epoch之间大幅震荡甚至直接爆掉变成NaN。我的建议是先用lr0.001作为基准如果发现损失下降太慢尝试0.005或0.01如果损失发散、剧烈震荡就调回0.0005。另外可以观察同一epoch内不同batch的损失变化趋势如果整体波动小且在缓慢下降说明学习率基本合适。4.2 训练准确率高但测试准确率低这是过拟合如果训练集上的准确率已经到99%以上而测试集只有90%左右说明模型过拟合了——它把训练集里的一些细节信息都背下来了包括噪声和无关的细节而不是学到真正的规律。DNN从原理上说很容易过拟合因为它的参数量非常大完全有能力记住所有训练样本。缓解办法有几个方向第一是增加训练数据量做数据增强比如对图像做随机旋转、平移第二是添加Dropout层在训练时随机丢弃一部分神经元强迫网络不要过度依赖某条特定路径第三是降低模型复杂度比如把512-256隐藏层改成256-128。在DNN里加Dropout非常方便在forward函数的两个全连接层之间插入F.dropout(x, trainingTrue)或者直接定义一个nn.Dropout(p0.5)层即可。注意推理时一定要保持在model.eval()模式否则Dropout还是会随机丢弃神经元导致预测结果不稳定。4.3 训练速度异常慢八成是设备选择出错跑一个MNIST DNN每个epoch应该只在几秒时间内完成。如果你发现训练非常慢先检查代码里有没有这一句device torch.device(cuda if torch.cuda.is_available() else cpu)问题往往出在这里你只定义了device变量但没有把模型和数据真正移到GPU上。模型需要model.to(device)每个batch的数据也需要data.to(device)。如果把模型放在GPU而数据还在CPU会频繁触发设备间的数据拷贝比纯CPU训练还慢反过来也一样。一个小技巧是在每个epoch开头加一行print(device)确认模型和数据确实在同一个设备上。4.4 梯度累积导致的显存爆炸没有人会因为MNIST这个规模的项目遇到显存爆炸但这个机制值得理解。Pytorch的反向传播是梯度累积的每调用一次loss.backward()梯度值就会累加到各个参数的.grad属性上和已有值相加。所以每个batch训练之前必须调用optimizer.zero_grad()把梯度清零。忘掉这行代码的话梯度会越来越大参数更新幅度失控。如果你的损失值在每个epoch结束后比开始还高先检查是不是少了这行。5. DNN模型的可视化与识别效果检验训练结束后光看准确率还不够直观我一直建议初学者做一些可视化来感受模型到底看到了什么、怎么做的判断。这能让很多黑盒感觉变得具体起来。5.1 展示模型预测的数字写一个小的可视化脚本从测试集中随机选几张图片显示图片内容、真实标签和模型预测import matplotlib.pyplot as plt def visualize_prediction(model, test_loader, device, num_images8): model.eval() data_iter iter(test_loader) data, target next(data_iter) data, target data.to(device), target.to(device) with torch.no_grad(): output model(data) pred output.argmax(dim1, keepdimTrue) fig plt.figure(figsize(12, 6)) for idx in range(num_images): ax plt.subplot(2, 4, idx 1) img data[idx].cpu().numpy().squeeze() ax.imshow(img, cmapgray) ax.set_title(fLabel: {target[idx].item()}, Pred: {pred[idx].item()}) ax.axis(off) plt.show()data[idx].cpu().numpy().squeeze()这行的作用是把GPU上的张量先移到CPU再转成numpy数组squeeze()把(1, 28, 28)形状压缩成(28, 28)这样matplotlib才能正常显示。运行之后你会看到一个非常有意思的现象模型在大部分图上都预测正确偶尔会错几张。如果你把预测错误的图打印出来看通常会发现它的字体比较潦草或者形状介于两个数字之间比如一个7带了个横杠模型可能把它误判为2。这些细节正是模型学到的边界所在。5.2 画出损失和准确率的变化曲线更正规的做法是记录每个epoch的损失和准确率画成曲线判断训练状态train_losses [] test_accuracies [] for epoch in range(1, num_epochs 1): train_loss train(model, device, train_loader, optimizer, epoch) acc test(model, device, test_loader) train_losses.append(train_loss) test_accuracies.append(acc) plt.plot(train_losses, labelTrain Loss) plt.plot(test_accuracies, labelTest Accuracy)正常情况下训练损失是平滑下降的曲线测试准确率一路爬升后趋于平稳。如果测出的准确率曲线出现明显的先升后降转折点那个位置就是过拟合开始的地方你可以根据这个观察决定要不要减少训练轮数或者加强正则化。5.3 从DNN到CNN下一步的演进思路跑通DNN之后你已经掌握了深度学习的最基本范式数据加载、模型定义、前向传播、损失计算、反向传播、参数更新、评估。这套流程在之后学习任何更复杂的模型时都是一样的骨架只是中间的网络结构会变化。DNN处理MNIST能达到97.5%以上的准确率看起来已经不错了但它对图像处理方式本质上是有缺陷的。把一张28x28的图片拉直成784的一维向量等于完全丢失了像素之间的空间关系——相邻像素的相关性、局部的形状特征、物体的平移不变性全被无视了。这也是为什么后面一定会引入卷积神经网络CNN卷积能够保留空间结构用卷积核在图像上滑动提取局部特征真正学会识别而不仅仅是记住。所以接下来的系列文章方向已经明确还是在MNIST数据集上把这些DNN换成CNN并对比两者的性能和差异。届时你会直观地看到CNN只需要更短的训练时间就能把准确率推到99%以上。这也是为什么MNIST被叫作深度学习界的Hello World——麻雀虽小五脏俱全从入门到进阶的路径都藏在里面。最后分享一个我个人的实操体会第一次跑通这个项目时看到屏幕上跳出97%的准确率感觉挺神奇的但真正让我理解神经网络的不是那行准确率数字而是后面把预测错误的图逐个打印出来看的那几分钟。建议你把每一步都跑过一遍包括故意做错几步——比如去掉标准化、设错学习率、忘了清梯度——然后观察损失和准确率的变化。亲手制造问题再亲手解决比任何教程都管用得多。
返回列表