news 2026/10/26 12:55:16

猫狗图像二分类实战:PaddlePaddle迁移学习与部署

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
猫狗图像二分类实战:PaddlePaddle迁移学习与部署

猫狗分类这四个字,几乎是每个接触物体分类的人绕不过去的第一道坎。我在不同阶段拿这个任务练过好几次手,从最早用最朴素的卷积网络跑出一堆玄学结果,到后来基于PaddlePaddle用迁移学习把验证集准确率稳稳推到 97% 以上,中间踩的坑比想象中多得多。这篇就把我这一路的实操过程摊开讲:基于PaddlePaddle做猫狗图像二分类,从数据清洗、目录结构设计、增强策略选择,到网络搭建、训练循环、学习率调度、模型导出与推理部署,一条线走完,把每个关键选择背后的“为什么”都交代清楚。

这个任务表面上简单——两个类别,图像清清楚楚,人类闭着眼睛都能分对。但真把它当练手项目做透,你能顺带摸清物体分类里几乎所有核心环节:数据不平衡怎么处理、预训练权重怎么加载、冻结与解冻的时机怎么定、过拟合怎么判断、推理预处理为什么必须和验证期完全一致。所以它适合两类人:一类是刚学完卷积、想找个完整项目把流程串起来的新手;另一类是做过别的任务,想拿一个干净的baseline验证自己训练流程有没有问题的老手。下面全是能直接照着跑的东西,参数、代码、踩坑记录都给你。

1. 为什么这个老掉牙的题目值得认真做一遍

1.1 它其实是一整套物体分类流程的缩影

很多人对猫狗分类的印象停留在“这不是入门 demo 吗”。我一开始也这么想,直到第一次把模型部署出去,发现训练时 98% 的准确率在真实环境里表现得一塌糊涂。那次之后我才明白,这个任务的价值不在模型本身,而在于它把物体分类的完整链路压缩到了最小规模:数据读取、标签对齐、图像预处理、增强、网络结构、损失函数、优化器、学习率策略、评估指标、模型持久化、推理一致性,一个都不少。

拿它练手最大的好处是反馈快。两万多张图,单卡跑一轮十几分钟,你能在一天之内试五六种配置组合,把“改了什么导致结果变差”这件事的因果链条建立起来。换成大规模数据集,一次实验等一天,试错成本高到你根本不敢动。

另外一个隐性价值是问题暴露得足够典型。猫狗分类里几个经典陷阱——验证集泄漏、预处理不一致、类别不平衡、灰度图混入——在别的任务里同样存在,只是被数据规模掩盖了。在这里它们会以非常直白的方式打你的脸:预测全输出同一类、验证准确率虚高、推理结果随机跳。把这些坑认清一次,后面做更复杂的分类任务心里就有底了。

1.2 选PaddlePaddle的几个实际考虑

框架选择这件事我纠结过一阵。最后落到PaddlePaddle上,主要有三个原因。

第一是高层API的封装程度刚好。paddle.Model这套东西把训练循环、评估、保存都包好了,改一个网络结构、换一个损失函数,几行代码就能跑起来;同时它又保留了底层接口,paddle.nn.Layer自己写前向、自己写训练循环完全没问题。这种“高层能偷懒、底层能深挖”的双层结构对练手特别友好——先跑通,再拆开看。

第二是预训练权重的获取相对省事。paddle.vision.models里直接带resnet50(pretrained=True),不用自己去翻权重文件路径、对 key 名、处理分类头不匹配的加载报错。迁移学习是这个任务的核心手段,权重加载这一环省下来的时间全都花在调参上了。

第三是部署链路是连贯的。训练完的模型用paddle.jit.save导出成静态图,推理端paddle.jit.load直接加载,中间不需要做算子转换、不需要重新对齐预处理。这一点我在别的框架上吃过亏,训练用的归一化参数和部署时写的归一化参数不一致,线上结果全歪,查了两天才定位到。

环境这块没什么花活,CPU 版本能跑通但训练慢得让人怀疑人生,建议至少一张 8G 显存的卡。安装完之后跑一句python -c "import paddle; paddle.utils.run_check()",看到环境检查通过的提示再往下走。

# CPU 版本,仅用于验证流程能跑通 python -m pip install paddlepaddle==2.5.2 -i https://mirror.example.com/simple # GPU 版本,训练用这个 python -m pip install paddlepaddle-gpu==2.5.2 -i https://mirror.example.com/simple # 环境自检 python -c "import paddle; paddle.utils.run_check()"

注意:GPU 版本要和本机驱动、CUDA 版本对上,装错了不会立刻报错,而是paddle.device.is_compiled_with_cuda()返回 False,然后你在一张显卡上默默跑了三小时 CPU 训练还不自知。训练脚本开头就把这行打印出来,能省掉一次崩溃。

2. 数据准备:脏活累活决定模型上限

2.1 目录结构与标签映射的设计

原始数据集通常长这样:一个PetImages目录下两个子目录Cat和Dog,各自堆着一万多张图。这种结构不做任何处理之前是没法直接喂给paddle.io.Dataset的,因为文件名和标签的对应关系没有显式声明。

我习惯的做法是先做一次物理划分,把数据切成train、val、test三个目录,每个目录下再按类别分文件夹。这样目录名本身就是标签,写 Dataset 的时候可以直接用paddle.vision.datasets.ImageFolder,省掉一堆自定义解析逻辑。

import os, random, shutil SRC = "data/PetImages" DST = "data/split" CLASSES = ["Cat", "Dog"] SPLIT = {"train": 0.8, "val": 0.1, "test": 0.1} random.seed(42) for cls in CLASSES: files = [f for f in os.listdir(os.path.join(SRC, cls)) if f.lower().endswith((".jpg", ".jpeg", ".png"))] random.shuffle(files) n = len(files) n_train, n_val = int(n * SPLIT["train"]), int(n * SPLIT["val"]) buckets = { "train": files[:n_train], "val": files[n_train:n_train + n_val], "test": files[n_train + n_val:], } for split_name, subset in buckets.items(): target_dir = os.path.join(DST, split_name, cls) os.makedirs(target_dir, exist_ok=True) for f in subset: shutil.copy(os.path.join(SRC, cls, f), os.path.join(target_dir, f)) print(f"{cls}: total={n}, train={len(buckets['train'])}, val={len(buckets['val'])}, test={len(buckets['test'])}")

按 8:1:1 划分,两万五千张图大概得到训练集两万张、验证集和测试集各两千五百张。这里有个细节值得强调:random.seed(42)一定要写死。我见过不止一次因为每次运行都重新随机划分,导致前后两次实验的验证集根本不是同一批图,然后对着“为什么这次准确率掉了两个点”纠结半天。

训练集两万张、batch size 设 32,每轮就是 625 个 step。跑 10 轮总共 6250 次参数更新。这个量级对迁移学习来说是够的,对从头训练来说偏少——后面选型部分会细说。

2.2 数据清洗:三类必须处理掉的脏样本

公开数据集没有想象中干净。我第一次跑的时候,训练到第三轮突然报错中断,原因是某张图文件损坏,解码返回 None。后来写了个脚本扫了一遍全量数据,发现的问题比预想的多。

第一类是零字节或半截文件。这类文件后缀正常,但读出来是空的。处理方法很简单,用 PIL 打开一次,异常就记下来。

第二类是通道数不对的图。绝大多数是 RGB 三通道,但混着少量灰度图和带透明通道的 RGBA 图。灰度图送进网络会变成单通道,和卷积层期望的三通道对不上,直接报维度错误;RGBA 图则是四通道,同样会炸。统一.convert("RGB")是最省事的解法,灰度图会被复制成三通道,语义上没问题。

第三类,也是最阴的一类,是标签错标的图。这部分程序查不出来,得靠人眼看。我抽样看了几百张,找到十几张明显不对的——比如一张狗的图躺在 Cat 目录里。数量占比很低,但训练后期模型会去拟合这些噪声,验证准确率会在 98% 附近卡住上不去。我的做法是先跑一轮训练,把置信度极高但预测错误的样本导出来人工复核,一般能揪出大部分错标。

from PIL import Image import os bad_files, channel_stats = [], {} for root, _, files in os.walk("data/split"): for f in files: p = os.path.join(root, f) try: img = Image.open(p) img.verify() # 校验文件完整性 img = Image.open(p).convert("RGB") # 强制三通道 mode = Image.open(p).mode channel_stats[mode] = channel_stats.get(mode, 0) + 1 img.load() except Exception as e: bad_files.append((p, str(e))) print("异常文件数:", len(bad_files)) print("原始通道分布:", channel_stats) for p, e in bad_files[:10]: print(p, e)

跑完之后,如果channel_stats里出现L、RGBA、P这些模式,说明确实有混杂样本,转换那步不能省。

2.3 数据增强策略:不是越多越好

增强这块的取舍逻辑值得单独说。猫狗图像的类间差异其实很大——耳朵形状、口鼻轮廓、毛发纹理都不一样,模型很容易学到判别性特征。这意味着增强的作用主要是抑制过拟合,而不是帮模型区分类别。所以策略上要偏保守,加得太猛反而会让模型学不到稳定的纹理特征。

我的配置是这样的:

增强手段参数设置作用与取舍理由
RandomResizedCropscale=(0.7, 1.0), size=224模拟不同拍摄距离,下界 0.7 避免裁到只剩鼻子眼睛这类无判别信息的局部
RandomHorizontalFlipprob=0.5猫狗左右对称,翻转后语义不变,安全的增强
ColorJitterbrightness=0.2, contrast=0.2, saturation=0.2幅度压小,模拟不同光照与设备色偏,太大破坏颜色先验
Normalizemean/std 用 ImageNet 统计量和预训练权重对齐,这步不能自定义
RandomRotation不启用猫狗在自然场景中极少大幅倾斜,加了引入无意义分布
RandomVerticalFlip不启用上下翻转的猫狗是现实中不存在的姿态,属于分布外噪声

关键的取舍在RandomResizedCrop的下界。设 0.5 的时候训练准确率掉得厉害,看可视化发现很多裁剪只保留了半张脸,连人眼都难判断,模型自然学不到东西。调到 0.7 之后就顺了。

还有一个必须遵守的原则:验证集和测试集绝对不做任何随机增强,只做Resize(256) → CenterCrop(224) → Normalize。这个不对齐是新手最容易犯的错,直接后果是验证指标和真实表现脱节。

import paddle.vision.transforms as T train_tf = T.Compose([ T.RandomResizedCrop(224, scale=(0.7, 1.0)), T.RandomHorizontalFlip(0.5), T.ColorJitter(0.2, 0.2, 0.2), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) eval_tf = T.Compose([ T.Resize(256), T.CenterCrop(224), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])

提示:Normalize 的 mean/std 必须和预训练权重训练时用的完全一致。用 ImageNet 权重就写 ImageNet 的统计量,自己编一个会破坏预训练特征的分布假设,收敛速度肉眼可见地变慢。

3. 网络选型与模型搭建的取舍逻辑

3.1 从零搭CNN还是直接上预训练

这个问题的答案取决于你有多少数据、多少算力、多少耐心。我两种都试过。

从零搭一个五六层的卷积网络,参数量几百万,两万张训练图,跑 30 轮下来验证准确率大概在 85% 到 88% 之间晃。想再往上推,得靠加层、加宽、调超参,投入产出比很差。而且这个过程中模型非常容易过拟合——第三轮之后训练损失还在降,验证损失已经开始往上走了,得靠早停和强正则硬压。

换 ResNet50 加 ImageNet 预训练权重,冻结主干只训分类头,10 轮就到 94% 左右;再解冻最后两个 stage 微调 5 轮,稳定在 97% 以上。差距非常直观。

背后的道理不复杂。ImageNet 一千类里有大量动物类目,模型在预训练阶段已经把“毛发纹理”“耳朵轮廓”“眼睛结构”这类底层到中层的视觉基元学得七七八八了。猫狗分类需要的能力恰好落在这个区间里,等于站在了别人的肩膀上。而从零训练意味着所有特征都得从两万张图里现学,样本量根本不够。

不过从零训练这一步我不建议跳过。我个人习惯是先花半小时跑一个小的从零基线,把它当成对照组。有了这个对照组,后面所有迁移学习的提升才有参照系。而且这个过程中你能亲眼看到过拟合是什么样子、学习率设大了是什么样子,这些经验比结果本身值钱。

3.2 基于高层API快速搭一个可训练的ResNet

PaddlePaddle 的paddle.vision.models里现成的 ResNet 系列,最后一层叫fc,输出维度是 1000。我们只需要二分类,把它替换成 2 输出即可。

import paddle import paddle.nn as nn from paddle.vision.models import resnet50 from paddle.metric import Accuracy paddle.set_device("gpu") net = resnet50(pretrained=True) in_features = net.fc.weight.shape[0] # 2048 net.fc = nn.Linear(in_features, 2) # 换成二分类头 print("分类头可训练参数量:", sum(p.numel() for p in net.fc.parameters()))

新分类头的参数量是2048 × 2 + 2 = 4098。整个 ResNet50 大约 2560 万参数,也就是说冻结主干时真正在更新的只有万分之一点六个点的参数。这个数字解释了为什么冻结阶段收敛快但天花板有限——模型只是在学一个线性分类器,把主干已经提取好的 2048 维特征映射到两个类别上。

再往下,解冻微调的时候要分清楚哪些层需要更新。我的经验是先从最后一个 stage(layer4)开始解冻,观察验证损失变化,稳定了就再往前解一个 stage。一次性全解冻在老手手里是常规操作,但学习率必须压到1e-5量级,否则预训练学到的权重会被一次大梯度冲垮,效果反而不如冻结。

# 冻结主干,只训分类头 for name, param in net.named_parameters(): if not name.startswith("fc."): param.stop_gradient = True # 第二阶段:解冻 layer4 一起微调 def unfreeze_layer4(net): for name, param in net.named_parameters(): if name.startswith("layer4.") or name.startswith("fc."): param.stop_gradient = False

注意:param.stop_gradient = True这个写法是原地修改,不需要重新赋值。而且冻结要在优化器构造之前或之后都行,但优化器只传可训练参数会更干净:parameters=[p for p in net.parameters() if not p.stop_gradient]。

3.3 损失函数与优化器的选择依据

损失函数没得选,多分类标准配置就是交叉熵。paddle.nn.CrossEntropyLoss内部已经把 softmax 和 NLLLoss 合在一起了,所以网络的最后一层不要加 softmax,加了就是重复计算,梯度会被压平,收敛变慢甚至不收敛。这个坑我踩过,当时训练损失一直卡在 0.69 附近不动,查了半天才发现是网络末尾多写了一个nn.Softmax。

优化器这边,Adam 和 SGD 我都试了。Adam 的优势是前期收敛快、对学习率不敏感,适合快速验证流程;SGD 加 momentum 在微调阶段往往能到更好的最终精度,但需要配学习率调度,调起来麻烦。

优化器学习率适用阶段实际表现
Adam1e-3冻结主干,只训分类头5 轮内到 93%,收敛快,后期震荡
Adam1e-4解冻 layer4 微调稳定爬到 97% 左右
SGD + momentum 0.91e-2冻结阶段前 3 轮慢,第 6 轮后追上 Adam
SGD + momentum 0.91e-3 + cosine全量微调最终精度最高,约 97.8%,但需要 20 轮以上

我日常的默认组合是:冻结阶段用 Adam,学习率1e-3;解冻微调阶段切 SGD,学习率1e-3,配余弦退火。两段之间重新构造优化器,这样干净,不会带着上一阶段的动量状态。

# 阶段一:冻结主干 optim_stage1 = paddle.optimizer.Adam( learning_rate=1e-3, parameters=[p for p in net.parameters() if not p.stop_gradient], weight_decay=1e-4, ) # 阶段二:解冻微调 optim_stage2 = paddle.optimizer.Momentum( learning_rate=paddle.optimizer.lr.CosineAnnealingDecay(learning_rate=1e-3, T_max=10), momentum=0.9, parameters=net.parameters(), weight_decay=1e-4, )

weight_decay设1e-4是我在验证集上试出来的。设成1e-2会明显欠拟合,训练损失降不下去;完全不设则验证损失抖得厉害,泛化差一点。

4. 训练流程落地与关键环节实现

4.1 Dataset与DataLoader的写法细节

简单的场景直接用paddle.vision.datasets.ImageFolder就够了,它会自动扫目录、按文件夹名建类别索引、按字典序排列。但真实项目里我建议自定义一个paddle.io.Dataset,因为你需要处理坏图、需要能打印出具体哪张图出错、需要灵活控制增强强度。

import os from PIL import Image import paddle from paddle.io import Dataset, DataLoader class PetDataset(Dataset): def __init__(self, root, transform=None): self.samples = [] self.transform = transform self.classes = sorted(os.listdir(root)) self.cls_to_idx = {c: i for i, c in enumerate(self.classes)} for cls in self.classes: cls_dir = os.path.join(root, cls) for f in os.listdir(cls_dir): if f.lower().endswith((".jpg", ".jpeg", ".png")): self.samples.append((os.path.join(cls_dir, f), self.cls_to_idx[cls])) def __getitem__(self, idx): path, label = self.samples[idx] try: img = Image.open(path).convert("RGB") except Exception: # 坏图兜底,返回全黑图而不是让训练崩掉 img = Image.new("RGB", (256, 256), (0, 0, 0)) if self.transform: img = self.transform(img) return img, label def __len__(self): return len(self.samples) train_ds = PetDataset("data/split/train", train_tf) val_ds = PetDataset("data/split/val", eval_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4, drop_last=True) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4)

几个参数值得说明。num_workers=4是数据读取的并行进程数,设太小 GPU 会等数据,设太大在容器环境里容易触发共享内存问题。我一般从 4 开始试,看 GPU 利用率,如果训练时nvidia-smi显示的利用率在 60% 以下来回跳,说明数据供给跟不上,可以往上加。drop_last=True在训练集上用,是为了避免最后一个 batch 只有一个样本,BatchNorm 在这种 batch 上统计量会失真。验证集不需要,设为 False,保证评估覆盖全量。

4.2 完整训练循环与学习率调度

高层 API 跑起来确实快,但练手阶段我强烈建议先手写一遍训练循环。原因很实在:所有细节都是显式的,出了问题是哪一行的锅一眼能看出来。

import time EPOCHS = 10 log_steps = 50 global_step = 0 for epoch in range(EPOCHS): net.train() t0 = time.time() loss_sum, acc_sum, n_batch = 0.0, 0.0, 0 for batch_id, (images, labels) in enumerate(train_loader): logits = net(images) loss = nn.functional.cross_entropy(logits, labels) loss.backward() optim_stage1.step() optim_stage1.clear_grad() acc = paddle.metric.accuracy(logits, labels.unsqueeze(-1)) loss_sum += loss.item(); acc_sum += acc.item(); n_batch += 1 global_step += 1 if batch_id % log_steps == 0: print(f"[epoch {epoch}][step {batch_id}] loss={loss_sum/n_batch:.4f} acc={acc_sum/n_batch:.4f}") # 每个 epoch 跑一次验证 net.eval() v_loss, v_acc, v_n = 0.0, 0.0, 0 with paddle.no_grad(): for images, labels in val_loader: logits = net(images) v_loss += nn.functional.cross_entropy(logits, labels).item() v_acc += paddle.metric.accuracy(logits, labels.unsqueeze(-1)).item() v_n += 1 print(f"== epoch {epoch} done in {time.time()-t0:.1f}s | val_loss={v_loss/v_n:.4f} val_acc={v_acc/v_n:.4f}")

paddle.metric.accuracy对标签形状有要求,需要[N, 1],所以标签要unsqueeze(-1)。这个细节不写会报形状不匹配,第一次遇到挺费解。

学习率策略上,冻结阶段用常数学习率就行,反正只训一个线性层,没什么可调的。解冻微调阶段我用余弦退火,让学习率从1e-3平滑降到接近 0。好处是训练后期步子小,能在最优点附近稳下来,不会像常数学习率那样在谷底来回弹。

# 余弦退火,每 step 更新一次 scheduler = paddle.optimizer.lr.CosineAnnealingDecay(learning_rate=1e-3, T_max=len(train_loader) * 10) optim = paddle.optimizer.Momentum(learning_rate=scheduler, momentum=0.9, parameters=net.parameters()) # 训练循环里每个 step 调用一次 optim.step() scheduler.step()

T_max填的是总 step 数,不是 epoch 数。这一处填错会导致学习率提前降到 0,后半个训练过程等于白跑。我一般直接用len(train_loader) * EPOCHS算出来赋值,避免手算错。

4.3 训练过程中的监控与早停判断

光看最终准确率是不够的,必须盯着训练损失和验证损失这两条线的关系。

健康的训练曲线是这样:前两三轮两条线一起快速下降,之后训练损失继续缓慢下降,验证损失趋于平缓甚至微微上翘。如果验证损失连续三轮都在上涨而训练损失还在降,那就是过拟合了,该早停或者加强正则。

还有一种情况是两条线同时居高不下,那是欠拟合或者学习率有问题。我遇到过验证损失在 0.69 附近纹丝不动的情况,0.69 约等于-ln(0.5),说明模型输出完全随机,两个类别的概率都是 0.5。排查下來是学习率设成了1e-5,太小了,十轮下来权重几乎没动。

class EarlyStopper: def __init__(self, patience=3, min_delta=1e-4): self.patience, self.min_delta = patience, min_delta self.best, self.counter = float("inf"), 0 def should_stop(self, val_loss): if val_loss < self.best - self.min_delta: self.best, self.counter = val_loss, 0 return False self.counter += 1 return self.counter >= self.patience

min_delta设1e-4是为了过滤掉正常波动。验证损失天然会有小数点后两三位的抖动,不加这个阈值,早停很容易在第三轮就误触发。

4.4 模型保存与断点续训

保存分两种,用途完全不同。

一种是只存权重,paddle.save(net.state_dict(), "weights.pdparams"),体积小,适合做实验快照。要恢复的时候先实例化网络结构,再net.set_state_dict(paddle.load("weights.pdparams"))。这个方式要求网络结构的定义代码必须和保存时一致,改了一层就加载不上。

另一种是存完整状态,把优化器状态也一起存下来,用于断点续训。长训练跑到一半被中断,没有这个就得从头来。

# 保存 paddle.save({ "net": net.state_dict(), "optim": optim.state_dict(), "epoch": epoch, "best_acc": best_acc, }, f"ckpt/epoch_{epoch}.pdparams") # 恢复 state = paddle.load("ckpt/epoch_9.pdparams") net.set_state_dict(state["net"]) optim.set_state_dict(state["optim"]) start_epoch = state["epoch"] + 1

我习惯把验证集准确率最高的那一份单独另存,命名为best.pdparams,最终部署就用它。很多人拿最后一轮的权重去部署,但最后一轮未必是最好的,尤其训到后期过拟合开始出现时,最后一轮可能比第五轮还差两个点。

5. 推理部署与效果验证

5.1 单张图片推理:预处理必须严格对齐

这部分是最容易翻车的地方,我自己就折过两次。

训练时的图像处理链路是Resize(256) → CenterCrop(224) → ToTensor → Normalize。推理时必须一模一样,一步都不能少、顺序不能变。少做 Normalize 的后果是输入数值范围差了上百倍,模型输出基本等于随机;只做 Resize 不做 CenterCrop,输入尺寸对不上会直接报错;把 CenterCrop 换成 Resize(224) 虽然能跑,但图像会被拉伸变形,和训练时的裁剪分布不一致,准确率掉几个点。

def predict(image_path, net, idx_to_cls={0: "Cat", 1: "Dog"}): net.eval() img = Image.open(image_path).convert("RGB") tensor = eval_tf(img).unsqueeze(0) # 加 batch 维度 with paddle.no_grad(): logits = net(tensor) prob = paddle.nn.functional.softmax(logits, axis=1) pred_idx = int(paddle.argmax(prob, axis=1).item()) return idx_to_cls[pred_idx], float(prob[0, pred_idx].item()) print(predict("data/split/test/Cat/12.jpg", net))

unsqueeze(0)这一步新手经常忘,忘了就是三通道输入当成单张图送进卷积,报一个维度错误。prob[0, pred_idx]取的是 batch 中第一张图对应预测类别的概率,可以作为置信度参考——低于 0.7 的样本建议人工看一眼,这类通常是图像质量差或者标签有问题。

5.2 批量评估与混淆矩阵

单张能跑通不代表整体没问题。必须跑一遍全量测试集,看混淆矩阵。

from collections import defaultdict confusion = defaultdict(int) net.eval() with paddle.no_grad(): for images, labels in DataLoader(PetDataset("data/split/test", eval_tf), batch_size=64): preds = paddle.argmax(net(images), axis=1) for t, p in zip(labels.numpy().tolist(), preds.numpy().tolist()): confusion[(t, p)] += 1 total = sum(confusion.values()) correct = sum(v for (t, p), v in confusion.items() if t == p) print(f"测试集准确率: {correct/total:.4f}") print(f"真猫判狗: {confusion[(0,1)]}, 真狗判猫: {confusion[(1,0)]}")

混淆矩阵的价值在于看错误分布是否对称。如果模型把大量猫判成狗,而反方向错误很少,那大概率是类别不平衡或者训练数据的标签有问题,而不是模型能力不足。我第一次跑出来就是这个情况,后来查出来是划分时没打乱,某个批次的图全是同一个来源的狗,导致训练集里狗的特征更丰富。

还有一个进阶指标是Top-K 错误分析:把预测错误的样本按置信度从高到低排序,看前 50 张。这些是模型“非常自信地错了”的样本,八成是标签错了或者图像本身有歧义(比如远景里只有一个小黑点)。

5.3 导出静态图与部署前的自检

动态图适合训练和调试,部署我一般转成静态图,推理速度能提升一截,而且不依赖 Python 环境。

# 导出前必须切到 eval 模式,否则 BatchNorm 会用当前 batch 的统计量 net.eval() paddle.jit.save(net, "inference/pet_cls", input_spec=[paddle.static.InputSpec(shape=[None, 3, 224, 224], dtype="float32")]) # 加载使用 loaded = paddle.jit.load("inference/pet_cls") loaded.eval()

input_spec里的None表示 batch 维可变,好处是部署时 batch size 可以随便调。写成固定值 1 也能用,但批量推理就得重新导出。

导出之后有一件事一定要做:并排对比动态图和静态图的输出。同一张图,两边各推理一次,看概率是否一致。不一致说明导出过程有问题,常见原因是导出时忘了切eval(),BatchNorm 层的统计量被固定成了当前 batch 的值。

x = paddle.randn([1, 3, 224, 224]) net.eval() p1 = paddle.nn.functional.softmax(net(x), axis=1).numpy() p2 = paddle.nn.functional.softmax(loaded(x), axis=1).numpy() print("最大绝对误差:", abs(p1 - p2).max()) # 应该在 1e-5 以内

6. 常见问题排查实录

6.1 损失不降、震荡与收敛异常

这一类占了实际问题的七成以上,我按排查顺序排了个优先级。

第一步查数据。标签是不是错位了?最常见的是 Dataset 里__getitem__返回顺序写反了,返回(label, img)而不是(img, label)。这种错误不会报错,只会让模型学到完全错误的关系,损失卡在 0.69 附近不动。用几个 batch 手工打印出来,看图像和标签是否匹配。

第二步查学习率。太大了损失会震荡甚至变成 NaN,太小了损失降得像蜗牛。快速判断方法:把学习率乘 10 和除 10 各跑 100 步,看哪个方向损失下降更快。Adam 在这个任务上1e-3是个合理的起点。

第三步查归一化。没做 Normalize 的话,输入像素值在 0 到 255 之间,梯度会非常大,很容易 NaN。做了但 mean/std 写错,收敛会变慢而且最终精度上不去。

第四步查网络末尾。是不是多加了 softmax?CrossEntropyLoss期望的是 logits,不是概率。多一层 softmax 会让梯度被压缩,训练变得极其缓慢。

6.2 训练指标漂亮但实际预测离谱

这种情况我遇到两次,原因各不相同。

第一次是验证集泄漏。划分数据的时候用了简单的随机 shuffle,但数据集中同一只动物的多张照片被分到了训练集和验证集。模型在训练时见过这只猫的另一张图,验证时自然认得出,指标虚高。真实场景里来的是一只没见过的猫,表现就掉了。解决办法是如果有来源 ID(比如同一只动物的多张照片),要按 ID 做分组划分,同一 ID 的图只出现在一个 split 里。

第二次是预处理不一致。训练用了 ColorJitter,推理没有;训练用 BGR 读图,推理用 RGB 读图。这两个问题都会让输入分布偏移,模型在训练分布上表现好,换个分布就崩。我现在的习惯是把预处理配置写成一个独立模块,训练和推理都从这里 import,从物理上杜绝两边写不一样。

6.3 常见问题速查表

现象可能原因排查动作解决方式
损失卡在 0.69 附近标签错位 / 学习率过小 / 网络末尾多了 softmax打印 batch 内图像标签对;学习率乘除 10 各跑 100 步修正数据返回顺序;调学习率;删掉多余 softmax
损失变 NaN学习率过大 / 未做归一化 / 出现 Inf 梯度检查输入像素范围;打印梯度范数学习率降一个量级;补 Normalize;加梯度裁剪
验证准确率虚高,实测很差验证集泄漏 / 预处理不一致检查同源样本是否跨 split;对比训练与推理的 transform按来源分组划分;预处理配置统一到一处
预测全输出同一类类别严重不平衡 / 分类头没换统计各类别样本数;打印 fc 层输出维度加类别权重;把 1000 维输出改成 2 维
显存溢出batch size 过大 / 输入分辨率过高逐步减半 batch降到 16 或 8;开混合精度训练
训练速度极慢,GPU 利用率低DataLoader 进程数不足 / 磁盘 IO 慢看 nvidia-smi 利用率num_workers 提到 8;数据预读或转 LMDB
报错维度不匹配通道数不对 / 标签形状不对打印 tensor.shape强制 convert("RGB");标签 unsqueeze(-1)
导出静态图后精度下降导出时未切 eval 模式对比动静图同输入输出导出前调 net.eval();用固定输入校验一致性

混合精度训练这里补一句。8G 显存的卡跑 ResNet50、batch size 32 基本是极限,想再大就得开paddle.amp。我的经验是paddle.amp.auto_cast配合GradScaler能省下将近四成显存,速度也快两成左右。但有个前提:学习率要适当调大一点,因为 fp16 的数值精度低,小梯度的更新容易被吞掉。我第一次开混合精度时忘了调学习率,训练损失降得比 fp32 慢了一截,还以为是实现有 bug。

scaler = paddle.amp.GradScaler(init_loss_scaling=1024) net, optim = paddle.amp.decorate(models=net, optimizers=optim, level="O2") for images, labels in train_loader: with paddle.amp.auto_cast(level="O2"): logits = net(images) loss = nn.functional.cross_entropy(logits, labels) scaled = scaler.scale(loss) scaled.backward() scaler.step(optim) scaler.update() optim.clear_grad()

6.4 我个人踩过的最深的两个坑

第一个是验证集 Loss 计算时忘了切 eval 模式。BatchNorm 在 train 模式下用当前 batch 的均值和方差,验证集 batch 只有 32 张图,统计量噪声很大,算出来的 loss 上下剧烈跳动。我盯着那条锯齿状的曲线调了半天的学习率,后来加了一行net.eval()就平滑了。现在的习惯是训练循环里train()和eval()成对出现,绝不让状态悬着。

第二个是用最后一轮权重部署。早期我图省事,训完 20 轮直接拿 final 权重上,线上效果比验证集差了将近三个点。后来看日志才发现,第 12 轮的验证准确率是最高的,之后就开始过拟合了。现在脚本里固定写逻辑:验证准确率创新高就存一份best,训练结束自动加载 best 再导出。

流程跑到这一步,这个项目的核心链路就完整了。如果还想往下延展,可以拿同一个训练框架去试多分类(把类别扩到五到十种宠物)、试试不同的主干网络(轻量一点的 MobileNet 系列在推理速度上优势明显)、或者把数据增强换成 CutMix 这类更强的策略看能不能再压一压过拟合。我个人在实际操作中的体会是,这个任务真正的门槛从来不在模型结构上,而在数据和流程的细节里——把预处理对齐、把划分做干净、把状态切换写对,比换十个网络结构都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 12:54:39

TABS隐藏兵种MOD实战:Unity资源打包、单位注册与数值平衡

最近在整理一份 TABS&#xff08;Totally Accurate Battle Simulator&#xff0c;全面战争模拟器&#xff09;隐藏兵种 MOD 的大版本更新&#xff0c;把之前只存在于草稿里的几个新单位推到"可玩、可展示、可发布"的状态。整个过程比想象中琐碎&#xff1a;资源打包、…

作者头像 李华
网站建设 2026/10/26 12:54:39

TABS派系对抗拆解:大规模AI、布娃娃物理与性能调优

TABS 这个名字&#xff0c;玩沙盒战争游戏的读者应该不陌生。全称 Totally Accurate Battle Simulator&#xff0c;国内通常叫《全面战争模拟器》&#xff0c;由 Landfall 开发&#xff0c;基于 Unity 引擎。它的玩法非常直白&#xff1a;你在一边摆兵&#xff0c;对面摆兵&…

作者头像 李华
网站建设 2026/10/26 12:54:36

Personal Agent与Sol 6.1深度解析:Astra缺席背后的技术取舍

1. 这场发布会到底讲了什么&#xff1a;从标题拆解核心信息 先把标题里的三个关键词拆开看&#xff1a; Personal Agent 、 Sol 6.1 、 缺席的 Astra 。这三个词其实代表了三种完全不同的信号——一个是产品形态的转向&#xff0c;一个是模型能力的迭代&#xff0c;一个是…

作者头像 李华
网站建设 2026/10/26 12:53:58

iCan CAN总线源代码:统一多厂商CAN卡接口与报文收发

简介&#xff1a;面向嵌入式开发与汽车电子方向学习者的 iCAN&#xff08;CAN-bus&#xff09;协议实现源码&#xff0c;适合已掌握 C 语言基础、希望深入理解 CAN 总线通信底层机制的中级开发者。压缩包共 2 个文件&#xff0c;包含 1 个 c 源文件与 1 个 h 头文件&#xff0c…

作者头像 李华
网站建设 2026/10/26 12:53:15

昇腾CANN Runtime架构与AIGC模型优化实践

1. CANN Runtime技术架构解析CANN&#xff08;Compute Architecture for Neural Networks&#xff09;作为专为神经网络计算设计的架构体系&#xff0c;其Runtime仓库承载着模型在昇腾NPU上高效执行的核心功能。这个"动力引擎"主要由以下几个关键组件构成&#xff1a…

作者头像 李华
网站建设 2026/10/26 12:51:40

CPU内存屏障与并发可见性原理详解

1. 从CPU到内存屏障&#xff1a;并发可见性的硬件基础 现代CPU的乱序执行和缓存结构是理解并发问题的起点。当我们在多核环境下操作共享变量时&#xff0c;实际经历了这样的数据旅程&#xff1a;CPU核心修改数据→写入核心私有缓存→经过缓存一致性协议同步→最终到达主内存。这…

作者头像 李华