ARTICLE DETAIL

资讯详情

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

PyTorch实战:从零构建全连接与卷积网络识别MNIST手写数字

PyTorch实战:从零构建全连接与卷积网络识别MNIST手写数字 我先把话说在前头这篇不是那种“复制粘贴就能跑”的仓库式教程也不是把几十行代码堆出来就完事。我会从零开始把“全连接网络”和“卷积网络”各自的原理、为什么这么设计、每一步代码在做什么全部拆开揉碎附上可以直接跑的完整代码和逐行注释。如果你是一点基础都没有的纯新手跟着敲一遍理解每一行在干什么比你去刷十遍理论课都管用。我先交代一下这篇的核心内容让你有个预期用PyTorch实现一个全连接网络在MNIST手写数字数据集上做分类再实现一个卷积网络在同一数据集上做分类。两个任务目标相同但网络结构完全不同正好用来对比“全连接”和“卷积”的本质差异。MNIST大家都很熟了是60年前的经典数据集但正因为经典、简单它才适合当作第一个自己动手写的神经网络项目。我会把代码按照“数据准备 → 网络定义 → 训练循环 → 评估测试”这条固定链路来组织。因为不管以后你写多复杂的模型也不管是CV、NLP还是推荐系统只要是用PyTorch训练模型这条链路都是通用的。学会一条链路你能迁移到百分之八九十的场景里。1. 动手之前环境准备与三个核心概念速览1.1 环境配置半小时内搞定先检查环境。我没有用太新的版本都是目前稳定且兼容性好的组合Python 3.9PyTorch 2.0CPU版即可新手不用急着上GPUtorchvision用来加载MNIST数据集matplotlib画损失曲线和查看预测结果Jupyter Notebook 或者 VS Code Python插件CPU版本足够跑MNIST全连接网络和这个规模的卷积网络在CPU上训练几分钟到十几分钟就完事。不要一开始就折腾CUDA版本纯新手在GPU环境上花两小时配环境还没写代码就先被劝退了。安装命令pip install torch torchvision matplotlib如果你有conda环境用conda安装更省心conda install pytorch torchvision matplotlib -c pytorch验证是否装好import torch print(torch.__version__) print(torch.cuda.is_available()) # CPU版会输出False正常能打印出版本号就说明环境OK。1.2 三个你绕不开的概念张量、自动求导、损失函数在动手写代码之前有三个基础概念必须先讲明白不然你看代码会一脸懵。第一个是张量Tensor。你可以简单粗暴地把它理解成“可以放到GPU上做并行计算的多维数组”。标量是0维张量向量是1维张量矩阵是2维张量而图像这种有宽、高、颜色通道的就是3维或者4维张量。PyTorch里的Tensor和NumPy的ndarray长得几乎一样但Tensor多了两个关键能力一是自动记录计算图二是可以通过.cuda()方法一键搬上GPU。第二个是自动求导Autograd。这是PyTorch最核心的机制。你定义好网络结构把数据传进去算出损失值然后只要调用loss.backward()PyTorch就会自动从损失值出发沿着计算图反向传播把每个参数要更新的梯度算出来。你不用手动推导链式法则也不用自己实现反向传播代码框架全给你干完了。这是现代深度学习框架能普及的根本原因因为反向传播的数学推导和实现非常繁琐手写特别容易出错。第三个是损失函数Loss Function。损失函数用来衡量“模型当前预测的结果和真实答案之间差了多少”。分类任务最常用的就是CrossEntropyLoss交叉熵损失。你不需要知道它的数学公式长什么样但你要理解一个直观的道理损失值越小说明预测越接近真实答案训练的目标就是不断让损失值变小。1.3 训练一个模型本质是在干什么把上面三个概念串起来训练过程就一句话反复执行“前向传播 → 算损失 → 反向传播 → 更新权重”。前向传播把一批数据放进网络得到预测结果。算损失拿预测结果和真实标签对比算出一个数值。反向传播根据损失值算出每个权重应该往哪个方向调。更新权重按照梯度方向小幅调整权重让损失变小一点。整个过程就像你蒙着眼睛下山每一步都踩一下地面感受坡度往坡下走一步。走多了就走到山底了。神经网络训练里的“山底”就是损失函数的极小值点。2. 全连接网络不借助任何预训练模型手写一个能识别手写数字的网络2.1 数据准备MNIST数据集是怎么进到网络里的MNIST是一个手写数字图片集每张图是28×28像素的灰度图代表数字0到9。总共有6万张训练图1万张测试图。用torchvision.datasets.MNIST加载它会非常方便但默认下载的数据只是原始图片不能直接喂给网络。需要做两件事第一步是转成Tensor并归一化。图片原始数据是0到255的整数像素值如果不归一化数值范围太大会导致梯度更新不稳定。所以要把像素值缩放到0到1之间像素值除以255。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, # 下载保存位置 trainTrue, # 加载训练集 transformtransform, downloadTrue ) test_dataset datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue )这里有个新手特别容易忽略的细节Normalize操作用了均值0.1307和标准差0.3081这两个数字是MNIST数据集整体像素的统计值。网上很多教程直接写了这两个数但没解释为什么。简单来说做完Normalize之后所有像素值的分布会变成标准正态分布均值接近0方差接近1这样梯度更新会更稳定收敛更快。第二步是用DataLoader分批加载。神经网络的训练不是一次把所有数据都扔进去那样内存吃不消而是每批取一小部分。这一小部分数据叫一个batch批次。from torch.utils.data import DataLoader batch_size 64 train_loader DataLoader( datasettrain_dataset, batch_sizebatch_size, shuffleTrue # 每个epoch打乱数据顺序防止模型记住顺序 ) test_loader DataLoader( datasettest_dataset, batch_sizebatch_size, shuffleFalse # 测试时不需要打乱 )shuffleTrue意味着每个epoch完整遍历一遍所有训练数据数据都会被打乱一次这样能避免模型学到“第几个样本是数字几”这种无意义的顺序信息。2.2 定义全连接网络三个核心组件吃透全连接网络Fully Connected Network也叫多层感知机MLP它的核心思想非常朴素把上一层的每个神经元和下一层的每个神经元都连起来每条连接上有一个权重。对MNIST来说一张图片是28×28784个像素点。我们把784个像素值展平成一个一维向量作为网络的输入。网络结构我设计成了三层的输入层784个神经元每个神经元对应一个像素点 隐藏层1128个神经元激活函数ReLU 隐藏层264个神经元激活函数ReLU 输出层10个神经元对应0-9十个数字为什么中间要加隐藏层因为如果不加隐藏层就变成输入直接连输出那是线性模型学不了复杂特征。加了隐藏层之后模型才有能力学习输入和输出之间的非线性关系。为什么激活函数用ReLUReLU的函数形式是f(x)max(0,x)简单到极致但效果很好。它有两个关键优势一是计算快二是在正区间梯度恒等于1不容易出现梯度消失问题。早期常用的sigmoid函数在输入很大或很小时梯度接近0深层网络训练时梯度会衰减消失导致前面几层参数几乎更新不了。用nn.Sequential来搭建网络是新手最直观的方式一层一层往里填就行import torch.nn as nn import torch.nn.functional as F class FullyConnectedNet(nn.Module): def __init__(self): super().__init__() self.network nn.Sequential( nn.Flatten(), # 把(64, 1, 28, 28)展平成(64, 784) nn.Linear(784, 128), # 全连接层784个输入 - 128个输出 nn.ReLU(), # 激活函数 nn.Linear(128, 64), # 全连接层128 - 64 nn.ReLU(), nn.Linear(64, 10) # 输出层64 - 10 ) def forward(self, x): return self.network(x)这里重点解释一下nn.Linear(784, 128)到底做了什么。它内部有一个权重矩阵W形状是(128, 784)以及一个偏置向量b形状是(128,)。当输入向量x长度784经过这层时输出y Wx b得到一个长度为128的向量。每个输出位置的值是所有784个输入值乘以对应的权重再加偏置的结果所以叫“全连接”。nn.Flatten()的作用是把一个形状为(64, 1, 28, 28)的四维张量64张图1个颜色通道宽28高28压平成(64, 784)。这个操作不含任何参数就是把三维像素矩阵按顺序拉直成一维。2.3 训练循环让网络学会“猜数字”网络定义好之后还需要三样东西损失函数、优化器、训练循环。损失函数我用nn.CrossEntropyLoss()它内部帮我们做了两件事先对输出做softmax把10个数字得分转成概率分布再计算预测概率和真实标签之间的交叉熵。新手最容易踩的坑是把nn.CrossEntropyLoss()和nn.LogSoftmax叠加在一起用导致损失计算结果不对。实际用CrossEntropyLoss时网络最后一层不需要加softmax激活函数因为损失函数内部已经处理了。优化器我选择了Adam。初学者不用陷入优化器的对比纠结中SGD是经典但需要手动调节学习率对新手不友好Adam自带自适应学习率机制基本不需要太多调参就能收敛得很好。import torch.optim as optim model FullyConnectedNet() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001)完整训练循环def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() # 切换到训练模式这会影响dropout和batchnorm的行为 total_loss 0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) # 关键每次迭代前一定要把上次计算的梯度清零 optimizer.zero_grad() # 1. 前向传播把图片输入网络得到预测 outputs model(images) # 2. 计算损失 loss criterion(outputs, labels) # 3. 反向传播计算每个参数的梯度 loss.backward() # 4. 更新参数沿着负梯度方向走一小步 optimizer.step() # 统计损失和准确率 total_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() avg_loss total_loss / len(train_loader) accuracy 100.0 * correct / total return avg_loss, accuracy这一段是整个训练流程的核心我强烈建议你反复看直到闭着眼都能写出来。这四个步骤的顺序千万不能乱先zero_grad()清零梯度再前向传播然后算损失接着backward()反向传播最后step()更新参数。为什么每次都要清零梯度因为PyTorch设计上梯度是累积的。如果你不清零那么下一次backward()时梯度会加上上一次的梯度导致梯度越来越大参数更新错乱。训练多个epoch用一个简单的外部循环epochs 5 device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) for epoch in range(epochs): avg_loss, acc train_one_epoch(model, train_loader, criterion, optimizer, device) print(fEpoch {epoch1}/{epochs}, Loss: {avg_loss:.4f}, Accuracy: {acc:.2f}%)跑5个epoch之后全连接网络在MNIST测试集上能达到97%-98%的准确率。这时候你可能会觉得“深度学习就这好像也不难嘛。”但别急全连接网络只是个热身。2.4 测试评估模型到底“学会”了什么def evaluate(model, test_loader, device): 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) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() accuracy 100.0 * correct / total print(fTest Accuracy: {accuracy:.2f}%) return accuracy注意这里有个关键细节model.eval()和with torch.no_grad():这两行缺一不可。model.eval()告诉网络当前是评估阶段某些层比如dropout、batchnorm的行为会和训练时不同torch.no_grad()则关闭了自动求导机制这样不会构建计算图省内存还加快推理速度。3. 卷积网络为什么它比全连接网络更擅长处理图像3.1 全连接网络处理图像的致命弱点你可能会有个疑问MNIST全连接网络准确率已经97%了为什么还要搞卷积网络有两个原因。第一个原因是参数量暴增。我们用的是28×28的小图784个输入神经元网络很小。但如果是一张256×256的彩色图片输入就是256×256×3196608个像素。第一个全连接层若输出128个神经元这个层的权重就有196608×128≈2500万个参数。一张图就要2500万个参数哪个机器都扛不住。第二个原因是丢失了空间结构。把图片展平成784个像素的一维向量等于把图片的空间关系全拆散了。原来“相邻像素一起构成一个图案边缘”、“像素在空间上聚集形成纹理”这些关键信息在展平那一刻就全丢了。全连接网络“看见”的只是一串没有任何空间含义的数字序列它根本不知道相邻像素之间有什么关系。卷积网络CNN就是专门为图像设计的。它的核心思路是用一个小窗口卷积核在图像上滑动每次只关注局部区域的像素自动提取局部特征。3.2 卷积层在做什么一个小窗口看遍全图想象你拿着一枚放大镜在图片上从左到右、从上到下地移动。每次放大镜只能看到图片上的一小片区域你集中注意力观察这片区域里的图案特征然后移动到下一个位置继续观察。这个放大镜就是卷积核。具体来说一个卷积核是一个小矩阵比如3×3里面存着一组权重。它在输入图像上滑动时每次覆盖一个同样大小的小区域把小区域内的像素值和卷积核的权重对应相乘再相加得到一个输出值。这个值反映了“这个小区域和这个卷积核所代表的图案有多像”。不同卷积核负责识别不同特征有的卷积核专门检测横向边缘有的专门检测竖向边缘有的检测圆点有的检测斜线。在一层卷积里我们通常会设置多个卷积核也称通道让网络同时从多个维度提取特征。听着抽象但PyTorch代码很简单nn.Conv2d( in_channels1, # 输入通道数。灰度图是1彩色RGB图是3 out_channels16, # 输出通道数即用了16个卷积核 kernel_size3, # 卷积核大小3x3 padding1 # 图像四周补一圈0保持输出尺寸不变 )这里有个必须掌握的公式是用来计算卷积层输出尺寸的输出尺寸 (输入尺寸 - 卷积核大小 2 × padding) / stride 1其中stride是卷积核每次移动的步长。对28×28的输入kernel_size3padding1stride1时输出尺寸 (28 - 3 2×1) / 1 1 28输出还是28×28说明padding1保证输出尺寸不变。这个公式你以后会反复用到建议抄在笔记本上。3.3 池化层降维打击提取主要特征卷积层提完特征之后还需要池化层Pooling来压缩数据。池化操作相当于把图像按比例缩小比如一个2×2的池化窗口把窗口里四个像素取最大值最大池化或平均值平均池化然后移动到下一个位置。最大池化是最常用的。它的直观作用是在一个局部区域内保留最显著的特征丢掉次要信息。这不仅减少了后续层的参数量更关键的是让模型对特征的微小位置移动不敏感同一个物体稍微偏了几个像素池化后输出的最大值大概率不变模型依然能认出它。nn.MaxPool2d(kernel_size2, stride2)这个操作把宽高都缩小一半。28×28的图像经过一次2×2最大池化后变成14×14。3.4 完整的卷积网络实现卷积池化全连接的经典组合现代卷积网络的基本范式是先几层“卷积激活池化”的组合自动提取特征然后把提取到的特征图展平接上全连接层做最终分类。我设计的网络结构如下输入(1, 28, 28) → 卷积层116个3×3卷积核输出(16, 28, 28) → ReLU激活 → 最大池化2×2输出(16, 14, 14) → 卷积层232个3×3卷积核输出(32, 14, 14) → ReLU激活 → 最大池化2×2输出(32, 7, 7) → 展平32×7×71568 → 全连接层1568 - 128 → ReLU激活 → 全连接层128 - 10完整代码class ConvNet(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 16, kernel_size3, padding1) # 输入1通道输出16通道 self.conv2 nn.Conv2d(16, 32, kernel_size3, padding1) # 16通道 - 32通道 self.pool nn.MaxPool2d(2, 2) # 2x2最大池化 self.fc1 nn.Linear(32 * 7 * 7, 128) # 全连接层 self.fc2 nn.Linear(128, 10) # 输出层 def forward(self, x): # 第一组卷积 - 激活 - 池化 x self.pool(F.relu(self.conv1(x))) # 第二组卷积 - 激活 - 池化 x self.pool(F.relu(self.conv2(x))) # 展平把三维特征图变成一维向量 x x.view(-1, 32 * 7 * 7) # 全连接层 x F.relu(self.fc1(x)) x self.fc2(x) return x我用F.relu(...)而非nn.ReLU()是因为当激活函数没有额外参数时直接用函数式接口torch.nn.functional更简洁。两种写法本质一样你选一种固定习惯即可。这里的训练循环和全连接网络完全一样不用改任何代码直接复用上一节的train_one_epoch和evaluate函数。把模型替换成ConvNet()就能运行。关键问题来了最后一个全连接层的输入维度为什么是32 * 7 * 7我们从输入开始推一遍。输入是28×28经过第一层卷积padding1kernel_size3stride1输出尺寸计算公式是(28 - 3 2×1)/1 1 28所以输出是(16, 28, 28)。接着最大池化2×2宽高减半变成(16, 14, 14)。经过第二层卷积尺寸不变还是14×14通道变成32即(32, 14, 14)。再经过一次池化变成(32, 7, 7)。所以展平后是32×7×71568个特征值。这个推导过程非常关键如果你在定义nn.Linear时把输入维度写错了运行时会直接报维度不匹配错误。这也是新手经常卡住的问题建议你拿到任何一张图都手动推一遍尺寸变化。3.5 为什么卷积网络准确率更高在MNIST上卷积网络通常能到99%以上的准确率比全连接网络高1-2个百分点。注意这1-2个百分点很关键因为MNIST测试集里多的是非常相似、人眼都容易看错的手写数字。卷积网络因为能用局部感受野抓取笔画特征和边缘结构所以对“长得很像的图”区分能力更强。当然如果你只是对比测试集准确率全连接网络的97%和卷积网络的99%差距看起来并不悬殊。这是因为MNIST实在太简单了。你可以想象如果换成CIFAR-10这种彩色物体识别数据集分类猫、狗、飞机、汽车全连接网络准确率会掉到50%左右而卷积网络能到80%以上。在小数据集上验证网络结构差异再迁移到复杂场景这是研究模型的正确姿势。4. 训练过程中那些必须知道的坑与调试手段4.1 损失不降八成是学习率出了问题你自己写代码训练的时候最常遇到的现象是损失值始终挂在某个值附近死活降不下去准确率也一直趴在10%左右相当于瞎猜。MNIST一共10个数字瞎猜准确率就是10%。多数情况下这是学习率设置不合理导致的。学习率太大参数更新幅度过大在损失曲面上来回震荡永远落不到谷底学习率太小参数更新龟速训练了十几轮损失才动一点点。我的经验是先用lr0.001开局如果损失下降太慢比如每轮只降0.01就调到0.01试试如果损失振荡剧烈loss忽高忽低像过山车就降到0.0001。新手用Adam优化器的话0.001通常是个非常稳定的起点。4.2 过拟合训练集准测试集不准另一个高频坑是训练集准确率99%测试集准确率只有85%左右这种情况就是过拟合——模型过度“背诵”了训练集的内容丧失了泛化能力。全连接网络在MNIST上特别容易过拟合因为全连接层的参数太多了模型容量远远超过数据集本身的复杂度。缓解过拟合最直接的办法有三个增加数据量比如做数据的随机旋转、平移、缩放即数据增强在卷积层和全连接层之间加nn.Dropout(p0.5)让训练时随机让一半神经元失活迫使网络学到更鲁棒的特征减小模型容量比如把隐藏层神经元从128改成64要特别记住的是在PyTorch中Dropout层在model.train()时才生效在model.eval()时自动失效。这个机制由前面提到的model.train()与model.eval()切换来控制。4.3 可视化输出看你每一步都在做什么新手写模型时最痛苦的事情是“对着代码发呆不知道网络内部到底发生了什么”。我的建议是每一轮epoch结束后打印损失值、准确率并从中取出几个批次的数据做可视化。我来写个简单的可视化代码把每个批次中的前几张图片画出来import matplotlib.pyplot as plt def show_images(images, labels, predictions): fig, axes plt.subplots(2, 5, figsize(12, 6)) axes axes.flatten() for i in range(10): img images[i].squeeze().numpy() axes[i].imshow(img, cmapgray) axes[i].set_title(f标签: {labels[i].item()}, 预测: {predictions[i].item()}) axes[i].axis(off) plt.tight_layout() plt.show()看看预测错的是哪几张图你会惊奇地发现大部分错误来自那些本身就“写得极不规范”的字歪歪扭扭的7被认成1封口不严的8被认成3。模型犯错模式和人类相似这本身就是一种很有意思的观察。4.4 让PyTorch设备选择变聪明一点把设备选择写成一个统一的工具函数省得每篇代码里重复粘贴def get_device(): if torch.cuda.is_available(): return torch.device(cuda) elif torch.backends.mps.is_available(): return torch.device(mps) # Apple Silicon芯片用这个 else: return torch.device(cpu)然后把所有训练和评估函数里的device参数都传进来模型、数据都要.to(device)。这是很基础但很关键的工程习惯因为项目和模型的训练迁移到不同设备时改动成本最低。5. 把代码整理成一份完整的可运行脚本很多同学看教程喜欢一段段复制最后没跑起来就是因为拼凑时漏了某段代码。我直接把完整脚本整理在下面你复制到.py文件里完全可以一次跑通。import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms # ------------------- 设备选择 ------------------- def get_device(): if torch.cuda.is_available(): return torch.device(cuda) elif torch.backends.mps.is_available(): return torch.device(mps) else: return torch.device(cpu) # ------------------- 数据准备 ------------------- def load_data(batch_size64): transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue ) test_dataset datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue ) train_loader DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue) test_loader DataLoader(test_dataset, batch_sizebatch_size, shuffleFalse) return train_loader, test_loader # ------------------- 网络定义 ------------------- class FullyConnectedNet(nn.Module): def __init__(self): super().__init__() self.network nn.Sequential( nn.Flatten(), nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, 10) ) def forward(self, x): return self.network(x) class ConvNet(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 16, kernel_size3, padding1) self.conv2 nn.Conv2d(16, 32, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(32 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x self.pool(F.relu(self.conv1(x))) x self.pool(F.relu(self.conv2(x))) x x.view(-1, 32 * 7 * 7) x F.relu(self.fc1(x)) x self.fc2(x) return x # ------------------- 训练与评估 ------------------- def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss 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() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() return total_loss / len(train_loader), 100.0 * correct / total def evaluate(model, test_loader, device): 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) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() return 100.0 * correct / total # ------------------- 主程序 ------------------- def main(): device get_device() print(fUsing device: {device}) train_loader, test_loader load_data(batch_size64) for model_name, model in [(FullyConnectedNet, FullyConnectedNet()), (ConvNet, ConvNet())]: print(f\\nTraining {model_name}...) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) for epoch in range(5): avg_loss, acc train_one_epoch(model, train_loader, criterion, optimizer, device) print(fEpoch {epoch1}/5, Loss: {avg_loss:.4f}, Train Acc: {acc:.2f}%) test_acc evaluate(model, test_loader, device) print(f{model_name} Test Accuracy: {test_acc:.2f}%\\n) if __name__ __main__: main()这段脚本的模块化结构是刻意的。把“数据加载”、“网络定义”、“训练与评估”分成独立函数是为了以后改造成新项目时你只需要替换网络定义部分和数据加载部分其他逻辑不用动。养成模块化习惯比追求代码写得短小精悍重要得多。6. 从MNIST出走接下来你可以尝试什么跑通上面的代码只是起点。我做了一件让我自己进步很大的事这里分享给你。第一步改结构。不要满足于跑通改改网络的层数、通道数、卷积核大小观察准确率怎么变。我试过把卷积层的通道数从16加到32、64MNIST准确率确实小幅上涨但训练时间明显增加。改到一定程度准确率就卡住不涨了这就是模型容量的边际效应。第二步换数据。MNIST大小只有28×28太规整了。接着可以试试Fashion-MNIST它也是28×28的灰度图但内容是衣服、鞋子、包包这些物品分类难度更高一点。代码完全不用改只改datasets.MNIST为datasets.FashionMNIST就能跑。你会看到同样的网络在Fashion-MNIST上准确率会掉不少这就直观感受到不同数据集对模型能力的诉求是不同的。第三步加数据增强。对训练图做随机旋转、平移。MNIST里数字稍微旋转一点人类很容易识别但模型可能就认不出来了。用torchvision.transforms.RandomRotation在训练时随机旋转图片能逼着模型学到旋转不变性测试准确率会提升。这是投入产出比极高的实战进阶操作。第四步把测试图像也做成可视化混淆矩阵。统计模型把哪个数字多认成了哪个数字比如“9被认成4”的次数最多那说明模型对这两个数字的笔画特征区分不够。这一步会培养你从错误数据里找原因的能力这项能力在真实项目里非常值钱。你从零开始写的这两个网络其实已经覆盖了现代深度学习模型最基本的部件全连接层、激活函数、卷积层、池化层、损失函数、优化器、训练与评估流程。PyTorch里那些看似眼花缭乱的模型结构无论是ResNet、Transformer还是Diffusion底层无非是这些基础组件的不同组合。我最后想说的是在我带过的所有新人里区分“学得会”和“学不会”的最大分界线不是智商而是有没有亲手把每一个组件写一遍、跑一遍、改一遍、看它报错、再修好它。代码跟着敲一遍比任何课程都管用。
返回列表