
最开始接触 PyTorch 时我第一个完整跑通的项目就是食物图片分类。那时候我连tensor的形状都经常搞混nn.Module怎么继承也是边查边写但食物分类这个任务给了我很大的正反馈图片直观、类别接近生活、模型效果一眼就能看出来而且数据集、预训练模型、参考资料都相对好找。如果你也是零基础想入门 PyTorch我的建议很直接——别去啃复杂的检测或分割先从食物图片分类这种“短平快”的分类任务切入把数据、模型、训练、评估这条链路完整走一遍比看一百篇教程都有用。这篇文章我会按我自己实际做项目的顺序从数据准备、模型搭建到训练调参、评估排查完整讲一遍。代码不是玩具都是能直接跑的级别同时会解释每个关键选择背后的原因以及我踩过的坑。适合刚看完 Python 语法、对深度学习有一点概念但还没独立做过项目的读者。你会发现很多看似复杂的流程其实只需要几十行代码。1. 项目概述与整体思路1.1 为什么首推食物图片分类作为入门项目图像分类是计算机视觉最基础的任务也是入门深度学习最自然的起点。食物图片分类这个方向恰好把“任务难度”和“学习成本”放在了一个很舒服的位置你不需要去理解目标框、分割掩码这些复杂概念只需要让模型判断一张图属于哪一类食物。我选它的另一个人理由是结果可感知。你做猫狗分类模型错了你甚至觉得正常但食物分类错了你能立刻发现它把“宫保鸡丁”认成了“辣子鸡丁”这种错误会让你愿意去分析原因。而且食物类别的视觉差异往往很细微比如不同做法的炸鸡、不同种类的面食这天然要求模型学习更细粒度的特征对理解features到底在学什么很有帮助。从工程角度看食物分类的数据获取成本也低。公开数据集中既有几十万张的大规模食物数据集也有单类别几千张的小型子集新手完全可以用几个类的子集练手十几分钟就能下载完。配合 PyTorch 生态里的预训练模型几十行代码就能达到一个不错的基线这种快速反馈对建立信心特别重要。1.2 零基础需要准备的工具与环境在开始写代码之前先把环境梳理清楚。你不需要一开始就配齐深度学习专用的 GPU 服务器一台普通电脑也能跑只是训练慢一点。我个人的建议是有 NVIDIA 显卡最好显存 4GB 以上就够训练 ResNet18 这种规模的模型没有显卡也不用慌先拿 CPU 跑一个小数据集把流程走通后面再上 GPU。依赖方面核心是 PyTorch 和 torchvision。建议用虚拟环境避免重装环境时搞乱已有的包。安装完以后顺手验证一下python -c import torch; print(torch.__version__, torch.cuda.is_available())如果输出True说明 CUDA 可用如果输出False也没关系代码里我会用device torch.device(cuda if torch.cuda.is_available() else cpu)自动切换。除此以外还需要matplotlib用于可视化numpy用于数值处理tqdm显示进度条这几个用 pip 一下就好。新手容易忽略的一件事是数据格式。PyTorch 的torchvision提供了很多图像处理工具但底层读取图片依赖 PIL 或 OpenCV。装好pillow就行不需要额外装 OpenCV除非你要做一些特殊预处理。1.3 整体流程与关键决策一个标准的图像分类项目流程其实非常固定准备数据下载或收集图片按类别组织目录结构。预处理缩放、裁剪、归一化让模型输入更规范。构建数据加载器把海量图片分成一个个 batch训练时逐批送入模型。定义模型选一个预训练模型把最后一层换成自己的分类层。定义损失函数和优化器分类任务一般用交叉熵损失。训练循环前向传播、计算损失、反向传播、更新参数循环多个 epoch。评估在验证集上算准确率、混淆矩阵用可视化发现模型缺陷。推理加载保存好的模型对单张图片做预测。这套流程几乎所有 CV 任务都适用所以只要跑通一次后面迁移到其他项目会快很多。这里有个关键决策用自定义的小型 CNN还是用迁移学习加载预训练模型我的答案是如果你是想学习原理两者都该懂但目标是“零基础跑通第一个项目”直接用预训练模型是最省力也最稳妥的方案。原因很简单深度学习模型不是越大越好也不是从零训练的模型效果一定好。对于个人项目、小规模数据集预训练模型提供了在大型数据集上已经学好的通用特征我们只需要微调最后一层或者全部参数就能在很短的时间内获得不错的效果。等跑通之后再回头写一个简单的 CNN理解卷积、池化这些概念效果会好很多。2. 数据准备与预处理决定模型上限的隐形功臣2.1 数据集获取与目录规划做食物分类最常见的选择是使用公开的食物数据集规模大的有几十万张图覆盖多种菜系。但零基础阶段我不建议一上来就搞大的类别多、图片多训练时间长排查问题也麻烦。我当时的做法是先抽取 10 个类每类几百张图一共几千张用来验证思路。跑通之后再逐步扩大到完整类别。无论用什么数据集目录规划会直接影响后续代码的简洁程度。推荐按下面的结构组织data/ ├── train/ │ ├── burger/ │ │ ├── 001.jpg │ │ └── ... │ ├── pizza/ │ └── ... └── val/ ├── burger/ │ ├── 101.jpg │ └── ... ├── pizza/ └── ...这样用torchvision.datasets.ImageFolder可以直接读取类名自动从文件夹名里解析顺序按字母排序。千万别把图片和标签放在 Excel 里再手动读那样增加一层不必要的复杂度。下载完数据后我强烈建议先随机抽查一下图片内容确认数据和类名是否对得上。食物数据集的公共资源也可能存在少量标注错误尤其在类间相似度高的时候。这一步只需要打印几张图看看成本很低但能避免后面训了半天发现标签是乱的。2.2 数据增强到底该做哪些先记住一个核心原则训练集要“折腾”验证集要“规矩”。训练集的数据增强是用来模拟现实中千变万化的拍摄条件的。我们拍照时食物可能正放、斜放、大小不一、光线不同如果模型只在干净的图片上训练到了真实场景就会很脆弱。常用的增强手段包括随机缩放裁剪RandomResizedCrop相当于让模型看不同比例、不同位置的局部内容。随机水平翻转RandomHorizontalFlip让模型学会镜像对称的食物。随机旋转RandomRotation应对拍摄角度变化。颜色扰动ColorJitter调整亮度、对比度、饱和度模拟不同光线环境。不可滥用。如果任务本身对方向敏感比如识别文字就不要做随意翻转旋转。食物分类这类任务方向不会改变类别本质做这些增强是安全的。验证集不需要增强但需要统一尺寸和归一化。因为验证集是对模型真实水平的客观评估增强带来的随机性会影响评估的稳定性和可复现性。验证集的预处理通常是Resize到较大的尺寸再做CenterCrop剪到模型输入尺寸。这样能保证图片内容不因裁剪丢失太多。归一化是新手最容易忽略的一步。预训练模型在 ImageNet 上训练时输入图片的像素分布有特定均值和标准差。我们用预训练模型做迁移学习输入也应该尽量服从同样分布否则预训练特征的优势就大打折扣。标准值就是那组均值[0.485, 0.456, 0.406]标准差[0.229, 0.224, 0.225]不用自己算记住就能用。from torchvision import transforms train_transforms transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transforms transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])这里的Resize(256)是为了保证长边缩放到 256然后RandomResizedCrop(224)或CenterCrop(224)裁出模型输入尺寸。PyTorch 预训练模型的默认输入是 224×224这个尺寸在计算成本和效果之间比较平衡。2.3 数据加载器的配置细节数据加载器决定了训练时如何把数据集一小批一小批地喂给模型。这里有几个概念新手一定要理清batch_size是每批多少张图epoch是整个训练集过完一遍iteration是处理一个batch的过程。一次训练通常由许多iteration组成所有iteration共同组成一个epoch。DataLoader的配置看似简单但几个参数能直接影响训练速度。from torch.utils.data import DataLoader train_dataset ImageFolder(rootdata/train, transformtrain_transforms) val_dataset ImageFolder(rootdata/val, transformval_transforms) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue)shuffleTrue只在训练集使用。每次 epoch 前打乱数据顺序避免模型学到数据排列中的偶然规律。比如文件夹里先全是第一类再全是第二类如果不打乱模型可能在连续的 batch 里只看到单一类别梯度更新方向会来回震荡收敛变慢。验证集不需要打乱因为评估结果和顺序无关。num_workers是数据加载使用多少个并行进程。设为 0 的话数据加载在主进程中运行简单但慢设为 4 或 8 通常能显著提高吞吐量。不过 Windows 下进程并行需要把训练代码包在if __name__ __main__:里否则可能报与多进程相关的错误。我第一次跑的时候就卡在这里改了入口后才顺利。pin_memoryTrue对 GPU 训练有帮助。它让数据保存在锁页内存中GPU 拷贝数据更快。CPU 上训练时设不设都行影响不大。3. 模型搭建不是所有代码都要自己写3.1 从一个迷你 CNN 到迁移学习我见过很多教程一上来就贴一个完整的 CNN然后说“这就是模型”。但对零基础的人来说那一堆nn.Conv2d、nn.BatchNorm2d、nn.ReLU、nn.MaxPool2d名词很容易劝退。我的建议是先动手跑再回头拆细节。如果你坚持想先看一个最简单的 CNN 长什么样下面是迷你版import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 16, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Linear(64 * 28 * 28, num_classes) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x)这个网络输入 224×224 的彩色图经过三个池化层后空间尺寸变成 28×28通道数变成 64最后展平接全连接层。它能工作但在少量数据下效果一般而且训练收敛速度远不如预训练模型。所以我更推荐用 PyTorch 里现成的预训练模型这也是大多数实际项目的做法import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.DEFAULT)ResNet18属于残差网络核心思想是在层与层之间增加“短路连接”让梯度更容易从深层回传到浅层。它是在 ImageNet 上预训练过的已经学会了很多通用的视觉特征比如边缘、纹理、形状。我们要做的是在这个基础上适应新的分类任务。迁移学习的本质就是把别人在超大数据集上花很长时间练出来的“通用视觉能力”拿过来然后在我们自己的小数据集上微调。这就好比你会开车换一辆车只需要适应一下方向盘灵敏度而不是从学踩油门开始。3.2 替换分类头与层结构预训练模型原本的分类层是按 ImageNet 的 1000 个类别设计的最后是一个nn.Linear(512, 1000)ResNet18 是 512 维特征。我们的食物分类类别可能是 10 类、40 类所以必须把最后一层替换掉。num_classes 10 in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes)model.fc在 ResNet 里是最后一个全连接层in_features是它输入的维度。你不需要关心它前面经过了什么样的卷积层只需要知道特征维度是多少然后接一个大小匹配的分类头就行。接下来还需要选择训练策略。这里有两个选项只微调最后一层把前面的卷积层参数全部冻结requires_gradFalse只在新的fc层上训练。优点是训练很快占显存少缺点是模型只调整了分类层前面通用的特征可能不完全适配食物这种细粒度任务。全部微调所有层的参数都参与更新模型能更充分地适应新数据。数据量不太少时效果通常更好。缺点是训练时间更长容易过拟合新手需要配合数据增强和早停。我的实际经验是如果数据集只有几千张图类间差异不是特别大先试“只微调最后一层”把基线跑出来如果准确率不满意再解冻更多层做全量微调。对于零基础入门我更推荐直接全量微调因为食物分类的细粒度特征确实需要底层特征的参与而且全量微调在 ResNet18 这种小模型上的训练开销完全可以接受。3.3 损失函数和优化器选择分类任务的默认选择是nn.CrossEntropyLoss()。这个函数在 PyTorch 里做了两件事先对模型输出做 softmax再计算交叉熵损失。所以你的模型最后一层不要提前做 softmax直接输出原始 logits 就行。计算损失时传入模型的预测 logits 和真实标签即可。CrossEntropyLoss有几个好处数值上比手动 softmax 加 NLL 更稳定而且它对类别不平衡有一定容忍度。对于食物分类这种类别相对均衡的任务基础版本就够了。优化器的选择同样有讲究。我最初图省事用了torch.optim.Adam(model.parameters(), lr0.001)因为 Adam 自适应学习率不太需要精细调节。Adam 在实际项目中确实省心尤其对新手不容易把学习率设得太离谱导致训练发散。如果你想更接近论文风格可以试试带动量的 SGDoptimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9)SGD 往往在充分调参后效果更好但学习率、动量的组合需要经验。作为入门先上 Adam 是对的。学习率调度方面我建议加一个简单的StepLR或CosineAnnealingLR。训练初期较大的学习率让模型快速下降后期逐渐降低学习率让损失在最小值附近收敛得更稳。代码长这样scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max15)这里的T_max一般设为总的 epoch 数学习率会从初始值按余弦曲线降到接近 0。还有一个容易忽略的点如果使用了冻结部分层的策略model.parameters()会包含所有参数包括冻结的。冻结层的requires_gradFalse之后传给优化器时即使包含它梯度也是None不会更新但为了干净通常直接传入所有参数也没问题。更细致的人可以只传需要更新的参数列表但对这个小项目来说没有太大区别。4. 训练循环与调参实战4.1 标准训练循环的写法训练循环是 PyTorch 项目里最核心、最模式的代码。一旦你理解了这段循环以后所有模型都能套用。先明确每个 epoch 需要做的事把训练集所有图片分成多个 batch逐个 batch 让模型预测计算损失反向传播更新权重完成整个训练集后在验证集上计算当前模型的准确率判断是否比之前更好。from tqdm import tqdm import torch def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for images, labels in tqdm(train_loader): images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_acc有几个细节新手必须理解model.train()和model.eval()不是摆设。像 BatchNorm、Dropout 这些层在训练和推理时行为不一样。训练时 BN 会根据当前 batch 统计均值方差Dropout 会随机丢弃神经元推理时要用固定的统计量和不丢弃。忘记切换会在验证阶段得到很诡异的结果。optimizer.zero_grad()一定要在每个 batch 前调用。PyTorch 的梯度是累积的如果不手动清零上一个 batch 的梯度会叠加到当前 batch 上导致更新方向混乱。outputs.argmax(dim1)是取每一行最大值对应的索引也就是预测类别。preds labels返回布尔向量sum()得到该 batch 中预测正确的数量。loss.item()用于从 loss 张量中取出 Python 浮点数。注意必须在loss.backward()之前或者之后调用都可以但只用于记录不能影响梯度计算。4.2 验证与保存最优模型验证集的作用是模拟模型在没见过数据上的表现。每个 epoch 后我们把模型切到eval()模式下跑一遍验证集torch.no_grad() def validate(model, val_loader, criterion, device): model.eval() running_loss 0.0 correct 0 total 0 for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) running_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return running_loss / total, correct / totaltorch.no_grad()是验证的关键。它告诉 PyTorch 不需要记录梯度因为验证阶段只做前向传播不需要反向传播。这样能省显存、加快速度也避免误调用.backward()导致梯度累积。训练过程中我习惯保存“验证集准确率最高”的模型而不是最后一个 epoch 的模型。原因是最后一个 epoch 不一定是最好的有时候已经过拟合了。best_acc 0.0 for epoch in range(num_epochs): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc validate(model, val_loader, criterion, device) scheduler.step() print(fEpoch {epoch1}/{num_epochs} | fTrain Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | fVal Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_acc: best_acc, }, best_model.pth) print(fBest model saved, val_acc{best_acc:.4f})保存时用字典包起来是个好习惯。只保存model.state_dict()也够用但带上 epoch、优化器状态等信息以后想接着训练或者看信息就方便了。4.3 关键超参数和训练技巧超参数不靠背靠试和观察。我第一次训练时就是踩了不少坑下面是一些比较实用的经验先用几十张图跑一个 batch 或几个 step确保前向、反向、更新的代码没有 bug。我习惯在正式训练前取一个 batch 数据过一遍比如loss能正常下降再从完整数据开始。这样能省掉大量反复试错的等待时间。学习率用1e-3起步比较稳。如果发现 loss 震荡剧烈就把学习率降为原来的十分之一再试。如果 loss 下降非常慢可能是学习率太小适当调大。固定随机种子能让结果可复现。在数据加载和模型初始化前调用torch.manual_seed(42)如果用了 GPU 再调用torch.cuda.manual_seed_all(42)。虽然不能做到严格百分之百复现但能减少很多奇怪的不确定性。监控验证集准确率而不是训练集准确率。训练集准确率 90% 但验证集只有 60%说明过拟合了这时候不是继续训练而是找办法正则化。训练轮数不要贪多。使用预训练模型加数据增强10~20 个 epoch 通常就能看到收敛。小数据集下跑 50 个 epoch 往往是浪费时间还会增加过拟合风险。如果显卡有amp自动混合精度可以尝试开启scaler torch.cuda.amp.GradScaler() with torch.autocast(device_typecuda): outputs model(images) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度训练能减少显存占用同时训练速度提升不少。对新手来说不是必须但用起来也不难可以等流程跑通后再加。5. 模型评估与结果可视化5.1 准确率不够还要看混淆矩阵训练结束后我们通常先看一个总体准确率。但准确率只能告诉你“模型有多好”不能告诉你“模型在哪里不好”。比如模型把“炸鸡”识别成“薯条”和把“寿司”识别成“刺身”这两类错误的含义完全不同。混淆矩阵能非常直观地展示类别之间的互相混淆。它的每一行代表真实类别每一列代表预测类别对角线上的数字越大越好非对角线上的点代表错误。from sklearn.metrics import confusion_matrix import numpy as np all_preds [] all_labels [] model.eval() with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.cpu().numpy()) cm confusion_matrix(all_labels, all_preds)画热力图时可以按类别数量调整图片大小类别多就用大一点画布。如果看到某两类互相混在一起比如“炸鸡”和“炸鱼”混淆严重说明它们在纹理或颜色上确实接近这时候可以考虑加强数据增强中的颜色扰动或者收集更多这类样本。5.2 从错误样本找出模型短板只看指标永远不够把预测错误的图片真正打开看一眼你会迅速理解模型的困境。我写了一个小的可视化逻辑遍历验证集找出preds ! labels的图片把原图、真实标签、预测标签、预测概率画在一张图上。这部分代码量不大但对判断问题特别有效。看了错误样本后常见的发现有以下几类数据标注错误图片里明明不是那个菜标签是错的。这种问题只能靠人工修正。图片过于相似比如不同菜系的“红烧肉”外观很像。这种情况模型表现不佳是可以理解的需要更细的类别定义或更多样本。图像质量差过暗、过糊、被遮挡。现实中拍照经常这样可以考虑在训练集加更强的数据增强甚至用模糊增强模拟噪点。错误样本分析不是什么高深技术但它能帮你建立直觉模型到底是在学食物本身还是在学背景颜色、盘子形状。这个直觉对以后做任何视觉任务都有帮助。5.3 用训练好的模型预测单张图模型训练好了最终目的是给一张新图片预测类别。加载模型的方式checkpoint torch.load(best_model.pth, map_locationdevice) model.load_state_dict(checkpoint[model_state_dict]) model.to(device) model.eval()单张图片的预处理必须和验证集保持一致尤其是Resize、CenterCrop、Normalize这三个步骤。很多人直接在读图后缩放到 224×224 就喂给模型结果发现准确率骤降就是因为没有做CenterCrop和Normalize。from PIL import Image def predict_image(image_path, model, class_names, device): image Image.open(image_path).convert(RGB) tensor val_transforms(image).unsqueeze(0).to(device) model.eval() with torch.no_grad(): outputs model(tensor) probs torch.softmax(outputs, dim1) top_prob, top_idx probs.max(dim1) return class_names[top_idx.item()], top_prob.item()softmax把 logits 转成概率max(dim1)取最大值对应的类别。说实话这里直接argmax也能得到类别但用softmax能额外看到概率值方便判断模型是确信还是犹豫。如果概率低于 0.5说明模型信心不足这时候要么模型对该类没见过足够多样本要么图片与训练集分布差异太大。实用一点的做法是设置一个阈值低于阈值就返回“无法确定”这在真实业务场景里很有价值。6. 常见问题与排查技巧实录6.1 训练 loss 一直不下降最让人沮丧的情况就是训练了好几轮loss一直在初始值附近波动几乎没有下降。这时候不要盲目调学习率按顺序排查以下问题首先检查数据是否真的被正确读取了。打印一个 batch 的形状和标签范围。图片是 3 通道大小和模型输入匹配标签从 0 到 num_classes-1没有越界。如果标签不是从 0 开始的连续整数CrossEntropyLoss会对不上。其次检查归一化是否忘写了。如果图片没做Normalize像素值在 0~255 之间预训练模型的输入分布被破坏可能让 loss 不收敛。然后检查学习率是否过大或过小。过大时 loss 会跳来跳去甚至变成 NaN过小时 loss 缓慢但一直不降。以 Adam 为例1e-3是个不错的起点如果不降可以试1e-4或3e-4。最后建议做一次“小样本过拟合测试”只取 20 张训练图片不放增强强制让它拟合。如果小样本上 loss 能降到接近 0说明模型和训练流程没问题问题出在数据量或数据分布上如果小样本也降不下去问题大概率在输入处理或代码逻辑。6.2 GPU 显存不够很多人的显卡显存只有 4GB 或 6GB训练一个 ResNet18 虽然够用但如果 batch_size 设置得太大会立刻报CUDA out of memory。解决策略按优先级排序降低batch_size。从 32 降到 16 或 8显存占用成比例下降。代价是每个 epoch 的迭代次数变多训练时间变长一点。降低输入尺寸。ResNet18 虽然默认是 224但用 192 或 160 也能运行。不过输入越小信息损失越大准确率可能轻微下降不推荐新手一开始就改。验证和预测时用torch.no_grad()。如果忘了加验证集也会建计算图显存占用会明显上升。开启混合精度AMP。在 4.3 小节提到的autocast和GradScaler能减少约一半显存占用而且速度通常更快。如果尝试以上方法后仍然不够还有一个办法是换更小的模型。ResNet18已经算小的了可以考虑ResNet18之外更轻量的MobileNetV3或EfficientNet-B0。这些模型专门为移动端设计参数量更少训练更快适合资源有限时使用。6.3 训练准确率很高但验证集很低这是典型的过拟合信号。训练准确率 95% 以上但验证集只有 70%说明模型没有学到泛化的规律而是记住了训练集中的细节或者噪声。过拟合的应对手段有很多我按实用程度排序增加数据增强。训练集已有的增强如果不够“狠”可以加RandomRotation的角度、ColorJitter的幅度甚至加RandomAffine。要让模型在更多变体中学习不变特征。使用正则化。在CrossEntropyLoss中没有自带正则你可以给优化器加weight_decay。Adam 的weight_decay一般设1e-4或1e-5能有效抑制大权重。减少模型容量。如果你已经用了很大的预训练模型考虑换更小的模型或者只微调最后两层。参数量越少越不容易记住噪声。早停。观察验证集准确率一旦连续几个 epoch 没有提高就停止训练恢复之前保存的最佳模型。检查验证集是否“脏”。如果你把训练集里的一部分图片也混进了验证集模型在验证集上表现好就是假象。目录划分时一定保证类别文件不重叠。还有一个被很多人忽略的问题数据增强只有在训练集上做验证集不要做。如果为了“公平”给验证集也加了随机翻转验证集每次评估结果都会有波动而且这种波动并不代表模型的真实能力。6.4 可视化中的中文字体乱码折腾到画混淆矩阵和错误样本时你大概率会遇到中文字体显示成方框的情况。这是因为 matplotlib 默认字体不支持中文。最省事的办法是在绘制图之前设置字体import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei] plt.rcParams[axes.unicode_minus] False但前提是系统里已经装了对应字体。如果没有不少人的做法是把所有类别标签映射成英文拼音或者英文翻译画图时显示英文。这样其实更省心因为在命令行和大多数图表环境中英文的兼容性最稳定。如果你是在演示项目标签直接显示中文当然更友好那就按上面的配置调字体。6.5 一些容易忽略但很实用的小细节最后分享几个我在实际项目中反复用到、但教程里很少强调的小细节。模型和数据的设备一定要统一。最常见的报错就是“Expected all tensors to be on the same device”通常是因为模型在了 GPU数据还在 CPU。解决办法就是训练循环里把每个 batch 的images和labels都.to(device)模型在定义后也.to(device)。计算准确率时outputs.argmax(dim1)出来的结果和labels的类型要能直接比较。如果labels也是 LongTensor直接用就行如果不放心可以.long()统一一下。对新手来说尽量别在一开始就追求复杂的分布式训练或混合精度那些花活。先把 CPU/GPU 训练跑稳再逐步尝试优化。我在最初做食物分类时连tqdm都不用每次训练只能干等着后来加了进度条那种随时能看到进度的感觉对保持耐心帮助很大。还有一个小技巧训练时每多少个 step 打印一次 batch 损失不要等一个 epoch 结束才看。因为 epoch 结束往往要好几分钟中途看了才能及时发现问题。我习惯在每 50 或 100 个 iteration 的时候打印一次当前 loss这样能更早发现学习率是否合适。如果你已经跑通了上面的流程恭喜你你已经具备了做一个图像分类项目的基础能力。接下来可以往两个方向走一是增加类别数把模型换成 ResNet50 或 EfficientNet探索更多预训练模型的效果二是把训练代码整理成可复用的模板加入命令行参数、日志系统、更多评估指标让它成为一个真正属于自己的项目框架。食物图片分类虽然小但模型训练的整套思路在里面是完整的。把它吃透后面再接触目标检测、图像分割这些复杂任务时你会发现自己不是从零开始了。