
猫狗分类这个题目几乎每个刚接触计算机视觉的人都会拿它来练手。它看起来简单——不就是区分猫和狗嘛但真要从零把它跑通、跑稳再到能对外提供推理服务中间踩的坑一点都不少。我前前后后在不同机器、不同数据规模上做过好几轮最早用CPU啃到怀疑人生后来上GPU又遇到显存爆炸、精度虚高、线上结果和线下对不上各种问题。这篇就把基于 PaddlePaddle 平台做物体分类、具体落到猫狗二分类这条完整链路摊开讲数据怎么组织、模型怎么选、训练怎么调、导出的模型怎么在推理侧还原出同样的结果。适合谁看如果你是刚学完卷积网络、想找个完整项目把训练—评估—导出—推理串起来的新手这篇能当施工图用如果你已经能跑通demo但模型精度卡在0.9上不去、或者线上预测结果飘忽那第5节的排查清单大概率能帮你省几个小时。文中的代码都是可运行的完整片段参数取值我也标了推导过程不是拍脑袋写的魔法数字。1. 方案设计与选型思路1.1 猫狗分类到底是个什么难度的问题先把问题定性。猫狗二分类在学术上属于细粒度较弱、类间差异明显的二分类任务猫和狗在整体轮廓、耳朵形状、口鼻比例上差异显著这决定了它不需要特别深的网络就能拿到不错的精度。用2.5万张标注图猫狗各一半做迁移学习ResNet50 级别的主干跑到验证集97%以上是很正常的水平再往上抠到99%就开始进入数据噪声和标注错误的区间了。但精度够用和工程可用是两件事。真正落地时会冒出这些问题用户上传的图片是竖构图、带水印、光线昏暗甚至黑白扫描件模型在验证集上表现很好可实际业务里的误判集中在某几类特定场景比如长毛小型犬和长毛猫的侧脸还有的设备只有CPU推理延迟卡在几百毫秒降不下来。所以我在设计阶段就把目标定成三条验证集精度不低于96%、单张图片在CPU上推理控制在150毫秒以内、离线训练和在线推理的预处理完全一致。第三条最容易被忽略也是后面误差来源的大头。1.2 这次把框架定在 PaddlePaddle 的几个理由框架选型不是信仰问题是成本问题。我选 PaddlePaddle 主要看中四点每一条都对应着实际收益动静统一的编程体验。训练阶段用动态图写起来和普通 Python 一样断点调试很顺手训练完了用paddle.jit.save一行导出成静态图推理侧不依赖 Python 解释器性能也更好。这个切换过程不需要重写网络结构省了一大块重复劳动。预训练权重和视觉模型库比较齐。paddle.vision.models里 ResNet、MobileNetV3、PP-LCNet 这些常用主干都有现成实现和 ImageNet 预训练权重一行代码就能拿到。对猫狗分类这种小数据集任务来说预训练权重几乎是决定性的——从头训练2.5万张图精度大概率停在85%左右上不去。部署侧工具链完整。训练完的模型可以直接走 Paddle Inference 做服务端推理不需要再折腾模型转换。之前我用别的框架训练和推理两套环境版本对不上导致输出差异排查了大半天。硬件适配成本低。同一份代码GPU 上训练、CPU 上推理、某些国产芯片上跑推理改动量都很小。如果你的项目有信创或者特定硬件的约束这一点的价值会被放大。考量维度PaddlePaddle传统双框架组合训练/推理分离训练到部署的模型转换原生jit.save无需转换需要中间格式转换易出精度偏差预训练权重获取内置视觉模型库直接调用需自行下载并适配参数名动态图调试支持与静态图同源视框架而定推理侧依赖独立的轻量推理库往往仍需完整框架环境学习曲线API 风格接近主流迁移成本低需要学两套 API1.3 整条链路的拆解与验收标准我把整个项目切成五段每段都有明确的输入输出和验收线这样出问题的时候能快速定位是哪一段塌了数据准备原始图片 → 按类别分目录的 train/val/test 三份验收标准是每个 split 里猫狗比例接近1:1且没有重复图片跨 split 泄漏。数据管道图片路径 → 归一化后的张量批次验收标准是随机抽一个 batch 打印出来shape 是[N,3,224,224]像素值在合理区间。模型训练张量批次 → 权重文件验收标准是验证集精度达标且 loss 曲线收敛平滑。模型导出动态图权重 → 静态图推理模型验收标准是同一张图在导出前后预测结果一致差值小于1e-5。推理服务原始图片字节 → 类别和置信度验收标准是和训练脚本里的验证逻辑结果完全吻合。这五段里第4段和第5段出问题的概率最高因为大部分人的注意力都在训练上导出之后随手写个推理脚本就跑预处理差一点结果就飘了。2. 数据准备与增强策略2.1 目录结构与数据集划分的实操细节我用的目录结构是这样的简单粗暴但足够用dataset/ ├── train/ │ ├── cat/ (9000 张) │ └── dog/ (9000 张) ├── val/ │ ├── cat/ (500 张) │ └── dog/ (500 张) └── test/ ├── cat/ (500 张) └── dog/ (500 张)划分比例按 90:5:5 走。为什么验证集不用常见的 8:2因为2.5万张的规模下5%约1250张的验证集已经足够稳定地反映精度波动多留数据给训练更划算。如果你的数据量只有几千张那还是老老实实 8:1:1。划分的时候有三个点必须注意同一只动物的多张照片必须落在同一个 split 里。如果原始数据里同一只猫有多张连拍或者视频抽帧随机划分会导致验证集里出现训练集见过的个体精度虚高好几个点。第二个是划分要固定随机种子并保存索引列表。我一般把划分结果写成train.txt / val.txt / test.txt每行是相对路径 标签。这样后面换机器、换人复现结果完全一致也不会出现同一张图被分到两个集合里的低级事故。第三个是检查类别目录里混入的异常文件。实际操作中经常遇到.DS_Store、缩略图缓存、命名重复的文件用目录遍历的方式读数据集这些都会被当成样本读进来然后报错。我一般先跑一段脚本统计每个目录下的文件数和非图片文件数确认干净再开始训练。2.2 图像尺寸与归一化参数的取舍输入分辨率直接决定显存占用和精度。224×224 是 ImageNet 的标准尺寸预训练权重的感受野也是按这个尺度训出来的用它最省心。如果你要识别的目标在画面里占比很小——比如远景监控画面里的猫狗——那可以提到 320 或 448代价是显存占用按面积平方增长224 换到 448激活值显存大概翻四倍。我在这个项目里用的是Resize(256) RandomCrop(224)的训练组合验证和测试阶段用Resize(256) CenterCrop(224)。为什么先放大到256再裁到224而不是直接 Resize(224)因为直接缩放会把整张图压扁塞进去长宽比失真而先缩放到短边256再随机裁224既保留了原始长宽比又引入了位置扰动相当于免费的增强。归一化这里有个 PaddlePaddle 特有的坑必须单独说。paddle.vision.transforms.Normalize不会自动把像素值缩到 [0,1]它按照你传入的 mean/std 直接做减法除法。而ToTensor对 PIL 图像输出的值域是 0~255 的 float32。所以如果你照抄 PyTorch 那套mean[0.485,0.456,0.406]实际算出来是(像素值 - 0.485) / 0.229输入分布完全错乱模型精度会掉到随机猜的水平。正确写法是把 ImageNet 的均值方差乘回 255IMAGENET_MEAN [0.485 * 255, 0.456 * 255, 0.406 * 255] IMAGENET_STD [0.229 * 255, 0.224 * 255, 0.225 * 255]这个错误我见过太多次了症状是训练 loss 一开始就异常大、或者收敛得特别慢很多人以为是学习率问题实际上是输入没有归一化到位。2.3 数据增强的边界哪些能用哪些别乱用数据增强不是越多越好尤其是猫狗这种有明确语义方向的任务。我实际验证下来有效的和有害的分得很清楚可以放心用的RandomHorizontalFlip概率0.5猫狗左右对称翻转后语义不变是最划算的增强。ColorJitter亮度、对比度、饱和度各0.2~0.3模拟不同光照和拍摄设备。幅度别超过0.4否则颜色失真严重反而引入噪声。RandomCrop配合先 Resize 再裁的方式位置扰动效果好。轻微RandomRotation±10度以内。超过15度之后很多猫狗的姿态就不自然了。要慎用甚至禁用的RandomVerticalFlip这是最典型的错误增强。自然界里的猫狗绝大多数是四肢着地的垂直翻转会造出大量倒立猫狗的样本模型要花额外的容量去学这种不可能出现的分布反而降低了对真实样本的判别力。实测加了垂直翻转验证集精度反而掉了0.5到1个点。大幅度的RandomResizedCropscale下限低于0.5会把猫耳朵、狗鼻子这类关键部位裁掉只剩一片毛模型学不到东西。强力的Cutout / RandomErasing在小数据集上容易让模型欠拟合。如果你的数据超过10万张再考虑。test集坚决不做任何增强只做 Resize CenterCrop Normalize保持和线上推理一致。2.4 数据管道性能别让 GPU 等着 CPU 喂数据训练的时候如果发现 GPU 利用率在30%上下晃八成是数据加载拖了后腿。几个见效快的调整num_workers 的取值。在 Linux 上一般设成 CPU 核心数的 1/4 到 1/2比如8核机器设2到4就够了。注意 Paddle 的 DataLoader 在 Windows 上多进程容易出问题如果你在 Windows 上跑num_workers设 0 最稳同时训练脚本要把入口代码包在if __name__ __main__:里面否则会无限递归创建进程。图片解码是主要瓶颈。2.5万张 JPEG每张解码大概几毫秒累积起来非常可观。一个讨巧的做法是第一次读取时把图片统一转成 256×256 的小图并缓存到本地后续 epoch 直接读小图IO 和 decode 成本能降一半以上。数据集超过5万张的时候这个优化几乎是必须的。batch_size 和显存的关系。224 分辨率下ResNet50 用 16GB 显存大概能跑到 batch 64MobileNetV3 能跑到 128。batch 太小小于8会导致 BatchNorm 的统计量不稳定训练震荡batch 太大则泛化性能下降学习率还得跟着调。我一般从 32 开始试。3. 模型选型与训练策略3.1 主干网络怎么挑精度、速度、显存的三角权衡这个项目我对比了三个主干都是在同样的数据划分和训练配置下跑的主干网络参数量验证集精度CPU单张推理耗时适用场景ResNet50约25M97.6%约120ms服务端GPU/CPU均可精度优先MobileNetV3-Large约5.4M96.2%约35ms移动端、边缘设备、低延迟要求PP-LCNet约3M96.8%约25msCPU服务端速度和精度平衡选择逻辑很简单如果推理在服务端、对延迟要求不苛刻直接上 ResNet50精度上限最高如果要部署到移动端或者纯CPU环境MobileNetV3 和 PP-LCNet 都是好选择PP-LCNet 在同样速度下精度略高一点因为它的结构是针对 CPU 推理优化过的。需要注意的是这里说的精度是用预训练权重 合理微调的结果。如果你从随机初始化开始训三个网络的差距会拉得更大ResNet50 大约能到88%MobileNetV3 只有82%左右因为小模型容量有限对数据的依赖更强。3.2 迁移学习的冻结与解冻节奏拿到预训练权重之后直接全局微调是我早期最常犯的错误。原因是 ImageNet 预训练出来的卷积核已经学到了很好的通用特征边缘、纹理、局部形状而随机初始化的分类头在训练初期梯度很大会把这批好特征冲垮。表现就是训练头几个 epoch 的 loss 剧烈震荡甚至比从头训练还差。我用的两阶段策略第一阶段冻结骨干只训分类头把除分类层外的所有参数stop_gradient设为 True学习率设 1e-3训 3 个 epoch。这一步让分类头先收敛到一个合理位置避免它把梯度反向传回去破坏骨干。第二阶段全局解冻微调解冻所有参数学习率降到 1e-4 甚至 1e-5训 10 到 15 个 epoch。学习率必须降下来否则预训练权重的精细特征会被破坏。还有一个细节BatchNorm 层的处理。如果第二阶段你的 batch_size 很小比如小于16解冻 BN 层会导致 running_mean/running_var 估计不准训练和推理的表现出现明显差距。这种情况我建议把所有 BN 层固定为 eval 模式只让卷积和全连接层参与训练。3.3 损失函数、优化器与学习率调度二分类的损失直接用交叉熵就行Paddle 里是nn.CrossEntropyLoss()它内部已经做了 softmax所以网络输出层不要加 softmax直接输出2维 logits。标签平滑是我在这个任务上验证有效的技巧。nn.CrossEntropyLoss(label_smooth0.1)把 one-hot 标签软化成 [0.95, 0.05]能缓解模型过度自信的问题。不过它只在验证精度进入99%附近时对泛化有帮助如果你卡在96%上不去加了也没用。优化器我从 SGD 换到了 AdamW主要考虑两点一是小数据集上 AdamW 收敛更快通常10个 epoch 就能到比较好的位置SGD 需要20个以上二是 AdamW 把权重衰减和梯度更新解耦了正则效果更干净。参数上learning_rate1e-4第二阶段、weight_decay1e-4、beta10.9、beta20.999这套组合我试下来比较稳。学习率调度用余弦退火公式是 $\eta_t \eta_{min} \frac{1}{2}(\eta_{max}-\eta_{min})(1\cos(\frac{t\pi}{T}))$Paddle 里直接调paddle.optimizer.lr.CosineAnnealingDecay。余弦退火的好处是后期学习率衰减得平滑模型能在一个较优区域慢慢收敛比阶梯衰减少很多震荡。再配合前两个 epoch 的线性 warmup把学习率从 1e-5 线性升到目标值能避免训练初期的梯度爆炸。3.4 混合精度训练与显存优化如果显存不够或者想训得更快混合精度是性价比最高的手段。Paddle 里通过paddle.amp.auto_cast()和paddle.amp.GradScaler组合使用把卷积计算转成 float16同时保留一份 float32 的权重副本防止下溢。实测在支持的 GPU 上显存占用能降40%左右训练速度提升30%到50%精度几乎无损。scaler paddle.amp.GradScaler(init_loss_scaling1024) with paddle.amp.auto_cast(levelO1): logits model(images) loss criterion(logits, labels) scaled scaler.scale(loss) scaled.backward() scaler.step(optimizer) scaler.update()levelO1表示只对算子做自动类型转换是最常用的模式。还有一种简单粗暴的显存优化把batch_size设小然后用梯度累积比如实际想要 batch 64显存只够16那就累积4次再optimizer.step()效果几乎等价。3.5 训练轮次、早停与模型保存总体训练轮次我定在 18 个 epoch3个冻结 15个解冻。这个数字不是绝对的判断依据看两条曲线训练 loss 和验证 loss 的差值持续扩大、验证精度连续3个 epoch 不涨就该停了。保存模型有个坑很多人保存最后一个 epoch 的权重结果那个 epoch 恰好是过拟合最严重的。正确做法是保存验证集精度最高的那个 epoch。Paddle 里就是每次验证后比较一下当前精度和best_acc超过就paddle.save(model.state_dict(), fbest_{best_acc:.4f}.pdparams)。另外建议至少保存两个文件一个是精度最高的一个是最后一个 epoch 的。万一最好的那个模型出现异常比如导出失败还有备选。同时把 optimizer 的状态也存下来方便中断后恢复训练不然重头开始又得几个小时。4. 完整实操从零跑通整条链路4.1 环境准备与安装校验先确认 Python 版本在3.8以上然后按硬件情况装对应的包# GPU 版本 python -m pip install paddlepaddle-gpu # 纯 CPU 版本 python -m pip install paddlepaddle # 其它依赖 python -m pip install opencv-python pillow tqdm装完必须做一次校验这一步别省import paddle paddle.utils.run_check() print(paddle.__version__)输出里出现PaddlePaddle is installed successfully才算装好。如果 GPU 版本校验通过但你实际训练时发现用的是CPU通常是 CUDA 版本和 paddle 包不匹配用paddle.device.get_device()看一下当前设备。还要确认一下能否正常获取预训练权重。有些内网环境访问不了公网pretrainedTrue会卡住或者报错这种情况需要提前把权重文件下载下来用paddle.vision.models.resnet50(pretrainedFalse)建网之后手动set_state_dict。4.2 数据集类与数据增强代码这一部分是整条链路的基石我写得详细一点import os import paddle from paddle.io import Dataset from paddle.vision import transforms from PIL import Image IMAGENET_MEAN [0.485 * 255, 0.456 * 255, 0.406 * 255] IMAGENET_STD [0.229 * 255, 0.224 * 255, 0.225 * 255] train_tf transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomHorizontalFlip(0.5), transforms.ColorJitter(0.3, 0.3, 0.3), transforms.RandomCrop(224), transforms.ToTensor(), transforms.Normalize(meanIMAGENET_MEAN, stdIMAGENET_STD), ]) eval_tf transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(meanIMAGENET_MEAN, stdIMAGENET_STD), ]) class CatDogDataset(Dataset): def __init__(self, root_dir, transformNone): self.samples [] self.transform transform for label, cls_name in enumerate([cat, dog]): cls_dir os.path.join(root_dir, cls_name) for fname in os.listdir(cls_dir): if fname.lower().endswith((.jpg, .jpeg, .png, .bmp)): self.samples.append((os.path.join(cls_dir, fname), label)) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(RGB) if self.transform: img self.transform(img) return img, paddle.to_tensor(label, dtypeint64) def __len__(self): return len(self.samples)两个细节值得说明。Image.open(...).convert(RGB)一定要加因为这批公开数据里混着灰度图和带透明通道的 PNG不转换的话读出来 shape 是[1,H,W]或[4,H,W]堆 batch 的时候直接报错。第二处是_getitem_里直接返回 tensor 标签省了 collate 的转换开销。listdir的方式在数据量大的时候会慢一点可以改成提前把文件列表缓存成 txt初始化时直接读能省几秒。4.3 组网、优化器与训练主循环import paddle.nn as nn import paddle.optimizer as optim def build_model(num_classes2, freeze_backboneTrue): model paddle.vision.models.resnet50(pretrainedTrue) in_features model.fc.weight.shape[1] model.fc nn.Linear(in_features, num_classes) if freeze_backbone: for name, param in model.named_parameters(): if not name.startswith(fc): param.stop_gradient True return model训练主循环我写成函数方便切换阶段def train_one_epoch(model, loader, criterion, optimizer, scaler, use_ampTrue): model.train() total_loss, correct, total 0.0, 0, 0 for images, labels in loader: if use_amp: with paddle.amp.auto_cast(levelO1): logits model(images) loss criterion(logits, labels) scaled scaler.scale(loss) scaled.backward() scaler.step(optimizer) scaler.update() else: logits model(images) loss criterion(logits, labels) loss.backward() optimizer.step() optimizer.clear_grad() total_loss loss.item() * labels.shape[0] correct (logits.argmax(1) labels).sum().item() total labels.shape[0] return total_loss / total, correct / total验证函数要记得model.eval()和关闭梯度paddle.no_grad() def evaluate(model, loader, criterion): model.eval() total_loss, correct, total 0.0, 0, 0 for images, labels in loader: logits model(images) loss criterion(logits, labels) total_loss loss.item() * labels.shape[0] correct (logits.argmax(1) labels).sum().item() total labels.shape[0] return total_loss / total, correct / total整个流程串起来if __name__ __main__: train_ds CatDogDataset(dataset/train, transformtrain_tf) val_ds CatDogDataset(dataset/val, transformeval_tf) train_loader paddle.io.DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4, drop_lastTrue) val_loader paddle.io.DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4) model build_model(freeze_backboneTrue) criterion nn.CrossEntropyLoss(label_smooth0.1) scaler paddle.amp.GradScaler(init_loss_scaling1024) # 阶段一只训分类头 params [p for p in model.parameters() if not p.stop_gradient] optimizer optim.AdamW(parametersparams, learning_rate1e-3, weight_decay1e-4) for epoch in range(3): tr_loss, tr_acc train_one_epoch(model, train_loader, criterion, optimizer, scaler) va_loss, va_acc evaluate(model, val_loader, criterion) print(f[Stage1][{epoch1}/3] tr_loss{tr_loss:.4f} tr_acc{tr_acc:.4f} fva_loss{va_loss:.4f} va_acc{va_acc:.4f}) # 阶段二全局解冻微调 for name, param in model.named_parameters(): if bn not in name: param.stop_gradient False scheduler paddle.optimizer.lr.CosineAnnealingDecay(learning_rate1e-4, T_max15) optimizer optim.AdamW(parametersmodel.parameters(), learning_ratescheduler, weight_decay1e-4) best_acc 0.0 for epoch in range(15): tr_loss, tr_acc train_one_epoch(model, train_loader, criterion, optimizer, scaler) va_loss, va_acc evaluate(model, val_loader, criterion) scheduler.step() print(f[Stage2][{epoch1}/15] tr_loss{tr_loss:.4f} tr_acc{tr_acc:.4f} fva_loss{va_loss:.4f} va_acc{va_acc:.4f}) if va_acc best_acc: best_acc va_acc paddle.save(model.state_dict(), best_model.pdparams) print(f - saved, best_acc{best_acc:.4f})这套配置我在2.5万张数据上跑下来第8到第10个 epoch 左右验证精度会摸到97%以上之后开始轻微震荡。如果震荡幅度超过0.5个点说明学习率还是偏大把 1e-4 降到 5e-5 再试。4.4 评估与单张图片推理训完之后不能只看 accuracy 一个数字尤其当你的业务对两类错误的容忍度不一样时。我把混淆矩阵、精确率、召回率都打印出来paddle.no_grad() def full_report(model, loader): model.eval() tp fp tn fn 0 for images, labels in loader: pred model(images).argmax(1) for p, t in zip(pred.numpy(), labels.numpy()): if p 1 and t 1: tp 1 elif p 1 and t 0: fp 1 elif p 0 and t 0: tn 1 else: fn 1 precision tp / max(tp fp, 1) recall tp / max(tp fn, 1) f1 2 * precision * recall / max(precision recall, 1e-8) acc (tp tn) / max(tp tn fp fn, 1) print(facc{acc:.4f} precision{precision:.4f} recall{recall:.4f} f1{f1:.4f}) print(fTP{tp} FP{fp} TN{tn} FN{fn})单张图片推理的关键是必须复用eval_tf绝不能重新写一套预处理def predict(image_path, model, transformeval_tf): model.eval() img Image.open(image_path).convert(RGB) tensor transform(img).unsqueeze(0) with paddle.no_grad(): logits model(tensor) prob paddle.nn.functional.softmax(logits, axis1) idx int(prob.argmax(1)) return [cat, dog][idx], float(prob[0, idx])unsqueeze(0)那一步是给单张图加 batch 维度忘了的话模型会报维度错误。还有[0, idx]这个索引容易写错prob 的形状是[1, 2]取第0个样本的第 idx 个类别的概率。4.5 静态图导出与部署侧加载训练完的动态图模型要导出成静态图才能脱离 Python 训练环境model.eval() input_spec [paddle.static.InputSpec(shape[None, 3, 224, 224], dtypefloat32, nameimage)] paddle.jit.save(model, pathinference/catdog, input_specinput_spec)导出目录下会生成.pdmodel网络结构和.pdiparams权重两个文件。注意 Paddle 2.6 之后的版本文件后缀可能变成.json加载方式记得对照官方文档确认。推理侧加载from paddle.inference import Config, create_predictor config Config(inference/catdog.pdmodel, inference/catdog.pdiparams) config.disable_gpu() # CPU 推理 config.enable_mkldnn() # 开启 CPU 加速 config.set_cpu_math_library_num_threads(4) config.disable_glog_info() predictor create_predictor(config) input_handle predictor.get_input_handle(predictor.get_input_names()[0]) output_handle predictor.get_output_handle(predictor.get_output_names()[0]) input_handle.copy_from_cpu(tensor.numpy()) # tensor 必须是 [1,3,224,224] predictor.run() result output_handle.copy_to_cpu()导出后必做的一件事拿同一张图分别在动态图和静态图上跑一次比较两个输出的最大差值。正常情况下应该在 1e-5 以内。如果差得远基本可以断定是输入预处理不一致或者输入 shape 对不上。这个对比我每次都做帮我抓到过好几次问题。5. 常见问题与排查实录5.1 训练阶段的典型异常loss 从第一个 batch 就是 30 以上或者 NaN。九成是归一化参数没对上。先打印一个 batch 的实际数值范围正常应该在 -2 到 2 之间。如果看到几百上千的数就是 Normalize 的均值方差尺度搞错了。其次检查标签是不是越界比如读进来是1和2而类别数是2索引2就越界了。训练精度95%验证精度70%差距巨大。典型的过拟合。三个动作加大weight_decay到 1e-3、把数据增强强度提一档、检查验证集是不是和训练集有重复图片。还有一种隐蔽情况验证集用了train_tf带了随机增强导致每次评估结果都在跳。这个错误很傻但真的常见。精度卡在50%附近无论怎么调都不动。这是二分类的随机猜水平说明模型什么也没学到。排查顺序先确认标签和图片是否对应错了比如路径拼接时 cat 目录被标成了 dog再确认分类头的输出维度是不是2最后看 backbone 是不是被冻结后忘了解冻。训练过程中突然报显存不足之前几个 epoch 都好好的。如果数据集里有尺寸差异极大的图片比如混进来几张 8000×6000 的Resize 时内存峰值会飙上去。解决方案是提前扫描一遍所有图片的尺寸把超大的先缩掉。另一个可能是梯度累积列表没清空检查optimizer.clear_grad()的位置。GPU 利用率只有20%~30%。这就是数据管道瓶颈前面2.4节说的优化手段挨个试一遍通常是num_workers设太小或者图片解码太慢。5.2 推理结果对不上的排查链这是最让人抓狂的一类问题因为训练时一切正常上线之后发现结果飘了。我总结了一套从外往里的排查顺序排查点症状表现处理方式是否调了model.eval()预测结果每次略有不同评估和推理前必须eval()预处理是否一致结果系统性偏移复用同一个 transform 对象别重写颜色通道是否 BGR猫狗判断反了或者置信度偏低cv2 读到的是 BGR要转成 RGB归一化尺度置信度普遍偏低0.5~0.6检查 mean/std 是否乘了255Resize 插值方式边缘细节敏感图判断错PIL 默认 bicubiccv2 默认 bilinear统一成一种输入 shape报错或结果乱必须是[N,3,224,224]CHW 顺序其中颜色通道这个坑我踩过最深的一次。训练用的是 PIL 读图RGB推理脚本图省事用 cv2 读BGR结果红绿通道互换模型的置信度普遍掉到0.6左右但类别大部分还能对所以不容易被发现直到有一批偏红色的图片开始大批误判才暴露出来。从那以后我的推理脚本里cv2.imread后面永远跟一句cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。5.3 常见问题速查表问题可能原因快速验证方法训练极慢num_workers0 / 图片过大 / 未开混合精度看 GPU 利用率和单 epoch 耗时精度上不去学习率不合适 / 增强过强 / 数据标签错误先关掉所有增强训一轮看基线模型文件很大保存了带优化器状态的完整 checkpoint只存 state_dict导出的模型无法加载输入 shape 没指定或版本不匹配用官方示例的加载代码对照CPU 推理慢没开 mkldnn / 线程数没设加enable_mkldnn()和线程配置验证集精度虚高训练集验证集有重叠图片计算两个集合的文件哈希去重5.4 几条踩出来的经验第一条先跑通再优化。我见过不少人一上来就追求99%的精度花几天调各种 trick结果连完整链路都没跑通过一次。先用最朴素的配置跑一遍拿到一个85%的基线再一步步往上加东西每一步都记录改了什么、精度变了多少。这个过程本身比最终数字有价值得多。第二条训练日志要记全。每个 epoch 的 train_loss、train_acc、val_loss、val_acc、学习率、耗时全写进 csv 或者 tensorboard。后面精度出问题时能往回翻是哪次改动引入的。只打印 loss 不看学习率曲线等于闭着眼睛开车。第三条小数据集别迷信大模型。2.5万张图用 ResNet152 或者 ViT效果大概率不如 ResNet50因为参数太多、数据不够过拟合得厉害。数据量和模型容量要匹配这是铁律。第四条置信度阈值要单独调。线上服务里如果模型输出的最高概率低于某个阈值比如0.7我建议直接返回无法判断而不是硬给一个结果。硬猜的错误成本比拒答高得多这个策略在实际业务里能显著降低误判投诉。最后分享一个我最近才用顺手的小技巧训练完之后把验证集里判错的样本全部导出来按置信度从高到低排。排在最前面的那几张——模型非常确信但判错的——大概率是标注有问题。人工核对一遍往往能发现数据清洗阶段漏掉的脏样本。用这个方式我在一轮迭代里清出了三十多张标错的图重新训练后验证精度直接涨了0.8个点比调任何超参都管用。