资讯动态

深度学习实战:用PyTorch从零搭建图像分类模型全流程复盘

发布时间:2026/10/18 11:13:36 来源:尧图企业网站定制
这标题刚看到的时候我也愣了一下。d2ld2ld2l乍一看像是随手敲的键盘乱码但干这行久了反而觉得这种“乱码”往往藏着点东西。我第一反应是把它当成一个自嘲式的项目代号d2l 就是 do to learn / deep learning 的语感重复三遍其实就是“学三遍、做三遍、再调三遍”——和深度学习训练模型的过程简直一模一样。于是就有了这篇复盘记录我拿它当某个图像分类小项目的代号从环境搭建到模型训练从踩坑到调参完整走了一遍。这篇内容不是什么高深论文就是一个自学的实战记录适合那些“已经会写 Python 语法、但还没完整跑通过一个深度学习项目”的朋友看。也适合刚入门、想了解“模型从数据到训练再到评估到底走哪些流程”的人。我会把每一步的选择逻辑、参数计算、踩过的坑都写清楚你可以直接照着抄作业。1. 项目起名与整体思路1.1 为什么叫 d2ld2ld2l给项目起名这事看起来无所谓但真到了复现模型的时候名字往往会提醒你“这个项目当初到底想干什么”。我起的 d2ld2ld2l 拆开看就是三个 d2l第一个 d2l先把流程跑通不追求精度只要数据能灌进模型、损失能下降、代码不报错。第二个 d2l把关键细节搞懂为什么用交叉熵、为什么学习率要设成这个数、卷积输出尺寸怎么算。第三个 d2l回来优化加正则、调超参、做数据增强把精度从“能跑”拉到“能看”。这三个阶段恰恰是做任何一个小型深度学习项目都要经历的过程。很多初学者上来就想直接上最复杂的网络结果代码全是在复制粘贴出了问题根本不知道从哪查起。我反而建议先从最简单的东西开始把这个代号里“重复三遍”的节奏走出来。1.2 项目到底要解决什么问题这个项目选的任务是图像分类具体来说是手写字符识别。选择这个任务有两个原因第一数据容易获取公开数据集中就能找到单张图片尺寸不大不需要 GPU 也能完成训练适合作为学习和复现的载体。第二任务本身有代表性。图像分类几乎是所有深度学习入门绕不开的关卡从图像分类里学到的数据加载、模型搭建、训练循环、评估指标换到其他任务上基本都能复用。做这个小项目要解决的核心问题不是“把精度刷到 99.9%”而是建立起一套完整的训练闭环准备数据 - 搭建模型 - 定义损失和优化器 - 训练迭代 - 验证评估 - 根据结果反向调整。这个闭环打通了后面学检测、分割、自然语言处理都会有底气。2. 环境准备与工具链选型2.1 框架选型的取舍我平时做这类小项目首选开源深度学习框架原因很简单社区资料多遇到问题能搜到答案接口风格符合 Python 习惯写起来顺手调试时能把中间张量直接打出来看对理解网络结构很有帮助。有人会问为什么不自己从零写一个神经网络我的看法是自己写反向传播确实能加深理解但那是另一个学习课题。做项目时更重要的是把模型迭代起来框架的作用是把底层计算做好让你把精力放在模型设计和实验分析上。你可以把它类比成做饭自己从种小麦开始磨面粉当然很厉害但大多数时候你只需要用买来的面粉把菜做好。2.2 环境依赖清单这份依赖清单是我实际用过的版本仅供参考版本号不需要完全一致但建议选择稳定版本不要追最新尝鲜尤其是涉及 CUDA 的部分。# Python 3.9 以上 pip install torch --index-url https://download.pytorch.org/whl/cpu pip install numpy pip install matplotlib pip install jupyter我在实际搭建环境时踩过一个坑刚开始图省事直接安装了 CPU 版本的 PyTorch后来电脑换了独立显卡想启用 GPU发现需要重新安装匹配 CUDA 的版本。所以建议你在装框架之前先确认自己机器上有没有可用的 NVIDIA 显卡再决定装哪个版本不然来回卸载重装很耗时间。另外强调一点训练产生的随机数种子问题后面你会反复遇到。环境里最好固定 Python 版本不同 Python 小版本之间某些随机数生成行为可能会有差异影响复现。2.3 数据集怎么准备我用的是公开的手写数字数据集包含 6 万张训练图和 1 万张测试图。每张图片是 28×28 的灰度图像素值范围在 0 到 255 之间数字标签是 0 到 9 共 10 类。数据准备工作有四个要点。第一划分训练集和验证集。严格区分训练集和验证集非常重要。验证集用于在训练过程中评估模型表现指导调参它不能参与训练。我在项目里从原始训练数据里再切出 10% 左右作为验证集剩下的才真正用于训练。第二数据归一化。神经网络对输入数据的尺度比较敏感。如果输入像素值在 0 到 255权重初始化的范围又是小数值第一层输出就会很大。通常的做法是计算全量数据的均值和标准差然后把每个像素转为标准化后的值。这个手写数字数据集的像素均值大约在 0.13 左右标准差大约在 0.31 左右标准化后数据分布会接近以 0 为中心、标准差为 1 的形态。第三转成张量格式。框架里跑的数据基本都是浮点张量数据加载时要把图片从数组或 PIL 图像转成张量。第四用小批量加载。不能一次性把全部数据灌进模型一是内存装不下二是全批量梯度下降容易陷入局部最优或收敛缓慢。按批处理是常见做法。3. 核心模型设计与训练细节3.1 先从线性模型起步第一次跑通流程的时候我故意没有直接上卷积网络而是先写了一个最简单的线性模型。这个模型只有两层把 28×28 的图片展平成 784 维的向量然后直接映射到 10 个类别上相当于对每个像素学一个权重。import torch import torch.nn as nn class LinearNet(nn.Module): def __init__(self): super().__init__() self.fc nn.Linear(28 * 28, 10) def forward(self, x): # x shape: [Batch, 1, 28, 28] x x.view(x.size(0), -1) return self.fc(x)这个模型代码量很少训练也很快。它的作用不是拿好成绩而是让我确认整条数据流水线是通的数据加载是否正常、张量形状是否匹配、损失能否下降、验证流程是否正确。我把这一步称为“冒烟测试”如果最简模型跑不通后面所有复杂模型也都跑不通。线性模型的精度大概只能到 90% 左右不要觉得这个数字低它其实暴露了一个事实像素和类别之间存在较强的线性关系简单的线性分类器就能学到一部分规律。在此基础上我们才能判断后面模型的提升到底来自哪里。3.2 加一层隐藏层多层感知机线性模型的局限在于它只能拟合线性决策边界而手写数字的像素模式显然不是线性的。下一步我给模型加了一个隐藏层激活函数用 ReLU。class MLP(nn.Module): def __init__(self): super().__init__() self.fc1 nn.Linear(28 * 28, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) return self.fc2(x)隐藏层加入之后模型能学到像素组合之间的关系。比如某个数字的左上角弧线和右下角弧线同时出现的模式就能被隐藏层捕捉到。这看起来很简单但背后是神经网络逼近能力的关键非线性激活函数让多层网络不再等价于单层线性变换。这里我想多说一句关于激活函数的选择。ReLU 是现在最常用的选择计算简单、梯度不容易消失但要注意它会让一部分神经元输出恒为 0这在某些情况下反而是帮助稀疏化的好事情。早期常用的 Sigmoid 在深层网络中容易导致梯度消失现在已经不太推荐在隐藏层里用了。3.3 卷积网络与参数推演多层感知机虽然比线性模型好但它把 28×28 的图片展平成向量空间结构就丢了。这时候就轮到卷积网络登场。我用了一个类似经典 LeNet 风格的小网络结构因为图像尺寸和任务规模都很适合这种轻量模型。网络结构如下class ConvNet(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 6, kernel_size5) self.pool1 nn.MaxPool2d(2) self.conv2 nn.Conv2d(6, 16, kernel_size5) self.pool2 nn.MaxPool2d(2) self.fc1 nn.Linear(16 * 4 * 4, 120) self.fc2 nn.Linear(120, 84) self.fc3 nn.Linear(84, 10) def forward(self, x): x self.pool1(torch.relu(self.conv1(x))) x self.pool2(torch.relu(self.conv2(x))) x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) x torch.relu(self.fc2(x)) return self.fc3(x)很多人写网络时最头疼的就是维度算不对。我来把这个尺寸变化完整推演一遍。输入图片是 28×28。第一层卷积核大小是 5×5步长为 1不做填充那么输出尺寸是输出 (28 - 5) / 1 1 24所以卷积后变成 24×24。接着做 2×2 的最大池化尺寸减半变成 12×12。第一层输出通道数是 6所以此时特征图是 6×12×12。第二层卷积同样用 5×5 核输入是 6 通道输出 16 通道。卷积后尺寸输出 (12 - 5) / 1 1 8再做池化变成 4×4。到这一步特征图就是 16×4×4。展平后向量长度是 256。这就是为什么全连接层第一层的输入维度是 16×4×4256。参数量也可以算。第一层卷积输入 1 通道输出 6 通道每组卷积核是 5×5参数为 1×6×5×5150再加 6 个偏置共 156。第二层卷积6×16×5×52400加 16 个偏置共 2416。全连接层第一层256×12012030840。后面两层分别为 120×848410164 和 84×1010850。总参数量大约 4.4 万。这个规模非常小CPU 也能跑但表达能力已经远超线性模型。这里有个值得记住的公式卷积输出尺寸(输入尺寸 - 卷积核大小 2×填充) / 步长 1。只要记住这个绝大多数卷积层的形状推导都不会出错。3.4 损失函数、优化器与学习率的底层逻辑模型结构定好后下一步就是告诉模型“什么叫预测得好什么叫预测得差”。我选的是交叉熵损失它特别适合多分类问题。交叉熵做的事情可以通俗理解为对于每个样本模型输出 10 个类别的分数经过一个 Softmax 变成概率分布然后衡量预测概率分布和真实标签的差异。预测越接近真实标签损失越小预测越离谱损失越大。代码里用 PyTorch 时要特别注意nn.CrossEntropyLoss()这个类本身就把 Softmax 和交叉熵整合在一起了。你在模型最后一层不要额外加 Softmax直接输出原始分数即可。这是很多新手第一次写代码时会犯的错模型输出层加了 Softmax损失函数里又算了一次 Softmax导致训练初期梯度异常。优化器我一开始选的是随机梯度下降 SGD带一点动量。optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9) criterion nn.CrossEntropyLoss()学习率 0.01 是一个相对保守的起点。学习率过大损失曲线会剧烈震荡甚至直接发散学习率过小模型收敛非常慢训练半天损失还在原地转。我后来也试过 Adam默认学习率 0.001它的特点是自动调整每个参数的学习速率对新手更友好但有时候收敛到的精度上限不如精心调过的 SGD。想理解学习率可以把它想象成下山时的步子。步子太小走到天黑也到不了山脚步子太大一步迈过山谷直接跳到对面山坡去了。训练模型就是不断在“快一点”和“稳一点”之间找平衡。4. 训练实验的完整复盘4.1 完整训练脚本示例下面是一个可以直接跑通的最小训练脚本核心逻辑都在没有用到复杂的高级特性。import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset def load_data(): # 假设 train_x, train_y, val_x, val_y 已经是标准化后的张量 train_dataset TensorDataset(train_x, train_y) val_dataset TensorDataset(val_x, val_y) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) val_loader DataLoader(val_dataset, batch_size256, shuffleFalse) return train_loader, val_loader def train_epoch(model, loader, optimizer, criterion): model.train() total_loss 0.0 for x, y in loader: optimizer.zero_grad() out model(x) loss criterion(out, y) loss.backward() optimizer.step() total_loss loss.item() * x.size(0) return total_loss / len(loader.dataset) def evaluate(model, loader, criterion): model.eval() total_loss 0.0 correct 0 total 0 with torch.no_grad(): for x, y in loader: out model(x) loss criterion(out, y) total_loss loss.item() * x.size(0) pred out.argmax(dim1) correct (pred y).sum().item() total y.size(0) return total_loss / total, correct / total model ConvNet() train_loader, val_loader load_data() optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9) criterion nn.CrossEntropyLoss() for epoch in range(10): train_loss train_epoch(model, train_loader, optimizer, criterion) val_loss, val_acc evaluate(model, val_loader, criterion) print(fepoch {epoch1}: train_loss{train_loss:.4f}, val_loss{val_loss:.4f}, val_acc{val_acc:.4f})这个脚本有几个关键约定model.train()和model.eval()必须成对出现。虽然我当前网络没有 Dropout 和 BatchNorm这个习惯依然要养成。验证阶段用torch.no_grad()关闭梯度计算省内存且加速。每轮训练结束都在验证集上评估一次这样才能及时看到模型是否过拟合。打印日志里同时包含训练损失和验证损失单看任何一个指标都容易被误导。4.2 从损失曲线看模型行为训练过程中我记录了每轮的损失和验证集精度。一个非常典型的曲线变化是训练损失稳步下降验证损失也跟着下降一段时间然后开始回升。这是过拟合的经典信号。如果训练损失一直降验证损失却回升说明模型开始把训练样本的个体特例背下来了而不是学习到泛化的规律。这时候不要急着加新层先考虑降低模型容量、加入正则化、增加数据增强或者提前停止训练。如果训练损失和验证损失都降得很慢说明学习率可能太小或者模型容量不足。如果训练损失剧烈震荡甚至跑到极大值说明学习率太大需要调小。我在实验里发现一个细节交叉熵损失在训练初期可能降得很快从 2.3 左右降到 0.5 以内但后面会进入一个很长的缓慢下降区间。这很正常因为前几步模型把类别概率从均分状态快速推向正确方向后面则是在精细调整决策边界。不要因为这个就急着改动网络结构。4.3 调参记录我把几个关键参数的影响整理成了一张表这些都是我在实验中实际调整过的记录。参数尝试范围实际观察学习率0.001 到 0.10.1 震荡明显0.001 收敛太慢0.01 最稳批大小32 到 25632 收敛略快但训练慢256 稳定性好但占用内存高64 是平衡点动量0 到 0.90.9 能明显加速收敛配合 0.01 学习率效果最佳权重衰减0 到 1e-31e-4 能在验证集精度上带来小幅稳定提升训练轮数5 到 3010 轮左右验证精度达到平台期继续训练收益有限批大小这个参数容易被忽略但它很有讲究。批大小实际上会影响梯度的噪声程度。批大小越小每次参数更新用的样本越少梯度越“吵”但有时候这种噪声能帮助跳出局部最优。批大小越大梯度方向越稳定但也越容易收敛到尖锐的极小值。实验时不需要极端取值64 到 128 之间通常是性价比很高的区间。4.4 数据增强要不要上在小数据集上数据增强几乎是改善泛化能力最立竿见影的手段之一。我做的增强包括随机小幅旋转、随机平移几个像素、随机缩放。操作很简单但背后的原理是手写数字的形态本身就有很大的偏移、倾斜和粗细差异把这些形变通过数据增强“喂”给模型相当于隐式扩大了训练数据规模。不过数据增强也要结合任务本身。比如这个任务是数字识别左右翻转就不合适因为 6 和 9 翻转后容易混淆7 翻转后会变成另一个不认识的样子。这一点和自然图像分类不一样自然图像做水平翻转通常问题不大但数字和文字类任务要谨慎。我实验下来加了轻度数据增强之后验证集精度在原有基础上提高了大约 0.3 到 0.5 个百分点。这个涨幅看着不大但考虑到数据集本身的精度已经很高这个提升已经很可观了。5. 常见问题与排查技巧5.1 损失不下降怎么办这是我被问得最多的问题。模型训练起来后损失纹丝不动或者干脆变成 NaN。我总结了几种常见原因。第一学习率过大或过小。先看损失是不是从初始值开始就没变。如果不变试着把学习率调大 10 倍甚至 100 倍看看有没有反应。如果损失变成 NaN立刻把学习率调小。第二数据没有归一化。输入像素值如果还停留在 0 到 255 的区间模型的第一层输出会很大容易导致数值不稳定。第三梯度计算有问题。自己写的模型可能在某一步操作不当导致反向传播的梯度为 0。这种事最容易出现在自己手工实现某些自定义层的时候。我在做项目时会在训练循环里打印一下梯度范数for name, param in model.named_parameters(): if param.grad is not None: print(name, param.grad.norm().item())如果某些层的梯度一直为 0那基本可以锁定问题出在网络结构或数据流上。打印梯度范数是排查这类问题最快的手段之一。5.2 过拟合怎么压过拟合的典型特征是训练损失持续下降验证损失到了某个点开始反弹。压过拟合的方法按优先级来排数据增强最温和几乎不会带来副作用。提前停止当验证损失连续多轮不下降时停止训练。增加权重衰减相当于对大的权重施加惩罚逼迫模型用更小的参数拟合数据。Dropout随机让一部分神经元不参与当前次前向计算可以理解为一轮训练里同时训练多个不同的子网络最后相当于在隐式集成。降低模型容量如果当前网络过大减少层数或通道数可能比加正则更直接。我在实验中发现对于只有几万样本的小型图像任务先用数据增强加提前停止基本上就能把一个合理的卷积网络压到稳定状态。如果还不够再逐个加正则手段每次只改一个变量否则你不知道哪个手段起了作用。5.3 显存或内存不够小项目也经常遇到这个问题尤其是图片尺寸一大或者批大小一大显存直接爆掉。最容易入手的是减小批大小。如果减小批大小会明显影响训练稳定性可以试试梯度累积把几次小批量的梯度累加起来再更新一次模拟出大批量的效果但显存占用依然是小批量的水平。另外不需要保存中间梯度的验证阶段务必要用torch.no_grad()不然验证时同样会占用大量显存。还有一个容易被忽略的点数据和模型默认存在 CPU 上每次迭代都要把数据搬到显卡搬运开销也不小。正确做法是先把模型放进 GPU然后把每批数据也放进同一块 GPU。如果数据很大不要一次性把所有数据都塞到 GPU 里。5.4 复现性怎么保证如果你今天跑出来的结果和昨天不一样大概率不是代码写错了而是随机种子没固定。训练本身涉及多种随机来源数据打乱顺序、权重的随机初始化、GPU 上并行计算的浮点舍入顺序等。固定随机数的常规做法是在训练脚本最开始设置import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42)如果使用 GPU还可以在初始化环境时把 CUDA 的确定性开关打开。这会降低一点运行速度但能显著提高复现性。要注意的是即使这样设置了某些框架的后端还是可能因为算法实现不同而有微小差异所以不要指望像素级完全复现只要误差在可解释范围内就算稳定。5.5 精度上不去时的排查清单我把常见的问题整理成了检查清单按照从低级到高级的顺序排查检查项具体手段数据标签是否正确抽样打印几张图人工核对标签训练集和验证集是否有重叠检查数据划分代码避免泄露数据是否归一化看输入张量的均值、标准差模型输出形状是否正确打印输出张量形状损失函数是否匹配任务多分类用交叉熵学习率是否合适来回调整 10 倍观察损失变化是否过拟合对比训练损失和验证损失曲线是否欠拟合测试模型在小批量样本上能否把损失打到接近 0最后一条其实是很强力的排查手段选一小批数据比如 32 张图训练多个轮次如果模型连这批数据都学不进去说明模型本身有问题比如结构错误、学习率过小、数据预处理不对。如果模型能过拟合这一小批数据再放到全量数据上训练问题往往出在泛化环节。6. 扩展方向与个人体会6.1 从图像分类到迁移学习手写数字识别做完之后我给它扩展了一个方向把训练好的网络作为特征提取器迁移到其他图像分类任务上。迁移学习的思路很简单在一个大数据集上预训练好的模型前几层学到的基础特征往往是可以复用的比如边缘、纹理、形状组合等。只需要冻结前面的层重新训练后面的全连接层就能在小数据集上获得不错的效果。这个思路几乎适用于所有图像任务也是我后来做其他视觉项目时的一个标准起步方案。但我必须提醒一句迁移学习不是万能药。如果源任务和目标任务的数据分布差异很大直接复用特征可能还不如从头训练。这一点需要实验验证不能从理论上拍脑袋。6.2 几点实际操作体会最后分享几个这个项目带给我的实际体会。第一遇到报错不要急着去网上搜索完整答案先自己看报错信息。深度学习框架的报错信息虽然有时候很长但关键信息往往就在最后几行。尤其是张量形状不匹配的错误它会明确告诉你期望的形状和实际形状照着改就行。第二日志一定要打好。我刚开始做实验时只在每轮结束后打印一个损失值导致中间出问题完全不知道是哪批数据造成的。后来我改成每 100 个 batch 都打印一次当前损失和当前进度排查问题就顺利多了。宁可日志多到刷屏也不要吝啬输出关键信息。第三不要同时改多个变量。调参最大的忌讳就是一次动好几个地方。你今天既换了优化器又改了学习率还加了数据增强最后精度涨了你根本不知道是谁的功劳。一次只改一个变量实验记录要跟上这样才能形成可积累的经验。这个项目代号 d2ld2ld2l 我现在还在用每次准备开始一个新的模型实验我就在心里默念一遍先跑通、再弄懂、然后优化。事情往往就是这么简单但能把简单的事重复做、认真做就已经超过大半数人了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑