猫狗分类实战:从数据清洗到部署落地的完整链路

猫狗分类这个题目,几乎是每个刚接触图像识别的人都会撞上的第一堵墙。我见过太多人卡在同一个地方:教程里跑一遍预训练模型,准确率刷到九十几,截图发完朋友圈,然后呢?换一批自己拍的照片,模型立刻原形毕露,猫认成狗、狗认成猫,甚至把一只橘猫判成"金毛"。问题不在于模型不行,而在于大多数人只跑了"能跑通"的那条路,没跑"能落地"的那条路。这篇东西就是把我自己在猫狗分类上反复折腾出来的经验摊开讲——从数据集的坑、模型选型的取舍,到训练策略的细节、评估方式的误区,再到最后怎么把这个模型真正用起来。不管你是刚入门想搞懂图像分类的完整链路,还是已经跑通过一遍但效果不稳想找原因,下面这些内容应该都能对上你的胃口。

1. 先搞清楚猫狗分类到底难在哪

很多人对这个任务的难度判断是失真的。因为公开数据集里的猫狗图片太"干净"了——主体居中、光照均匀、背景简单,模型很容易学到"猫是尖耳朵、狗是垂耳朵"这种表面特征。一旦进入真实场景,这套逻辑立刻崩。所以第一步不是急着写代码,而是先想明白:这个任务的真实难点分布在哪里。

1.1 表面特征与语义特征的错位

猫和狗在低分辨率下确实有可区分的轮廓差异,比如猫的脸更圆、耳朵更立,狗的吻部更长。但这些都是统计意义上的倾向,不是硬规则。折耳猫、无毛猫、短吻犬这些品种会直接打破你的直觉。模型如果只依赖这些浅层特征,在品种分布偏移时就会大面积翻车。

我在早期做过一个对比实验:用同一份数据训练两个模型,一个输入 64×64,一个输入 224×224。小分辨率模型在验证集上能到 88% 左右,但换到手机随手拍的照片上掉到 70% 出头;大分辨率模型验证集 94%,真实照片上还有 85% 上下。差距的来源就是分辨率决定了模型能看到多少细节——毛发的纹理、眼睛的形状、鼻头的结构,这些才是更稳的判别依据。

1.2 类别不平衡与"难样本"的隐蔽性

猫狗二分类看似天然平衡,实际采集时很容易偏。比如你从某个图库批量下载,猫的图片可能普遍是室内特写,狗的多是户外全身照,模型会偷偷学到"背景是草地就是狗"这种伪相关。这类问题在验证集上不一定暴露,因为验证集和训练集同源,分布一致。

提示:判断数据是否存在伪相关,一个简单办法是把图片的背景裁掉只留主体,再跑一遍验证。如果准确率大幅下降,说明模型严重依赖背景。

真正难缠的是"难样本"——趴在沙发上的深色猫、背对镜头的狗、被遮挡一半的脸。这些样本在训练集里占比往往不到 5%,但对真实场景的准确率影响极大。我一般会专门把这些样本挑出来做一个小验证集,单独盯它的表现,而不是只看总体准确率那个好看的数字。

1.3 任务本身的边界模糊

有些图片连人都难判。比如一只远距离的黑色小动物,轮廓模糊;或者一张图里同时有猫和狗。二分类模型被迫输出一个概率,这时候高置信度的错误比低置信度的犹豫更危险。所以我在设计整个流程时,会把"拒识"作为一个可选项考虑进去——当模型输出的概率落在 0.4 到 0.6 之间时,标记为"不确定",交给人工或直接丢弃。这个策略在真实产品里非常实用,能显著降低误判带来的体验损失。

2. 数据集这道坎:比模型更值得花时间

我个人的经验是,猫狗分类这个任务上,数据质量对最终效果的影响远大于模型结构的选择。同一套 ResNet,数据清洗做得好和做得差,真实场景准确率能差出十几个点。所以这一节我把数据处理的实操细节讲透。

2.1 数据来源与去重

公开的猫狗数据集(比如常见的 Kaggle 版本)有 25000 张,听起来够用,但里面存在大量近似重复——同一只猫的不同角度、同一场景的连拍。这些重复样本会让训练集和验证集之间产生"泄漏",验证准确率虚高。

去重的实操做法是计算图像感知哈希(pHash)。用imagehash库几行代码就能搞定:

import imagehash from PIL import Image from pathlib import Path def build_hash_index(img_dir, hash_size=8): index = {} for p in Path(img_dir).rglob("*.jpg"): h = imagehash.phash(Image.open(p), hash_size=hash_size) index.setdefault(str(h), []).append(str(p)) return index index = build_hash_index("data/train") dups = {k: v for k, v in index.items() if len(v) > 1} print(f"发现 {len(dups)} 组近似重复")

哈希距离阈值我一般设在 5 以内算重复(8×8 的 pHash,汉明距离小于等于 5 基本是同一张图的变体)。去重时每组只保留一张,其余移出训练集。这一步做完,25000 张通常会缩到 22000 左右,但验证结果会变得可信得多。

2.2 划分策略:别用随机划分糊弄自己

随机划分训练/验证集是新手最容易犯的错。如果数据里有连拍或同源图片,随机划分会让相似图片同时出现在两边,验证集失去意义。更稳的做法是按来源分组划分——同一批下载的、同一只动物的图片必须整体落在同一侧。

我通常按 8:1:1 划训练、验证、测试,且划分前先按文件来源或拍摄批次打组标签,用GroupShuffleSplit而不是普通的train_test_split:

from sklearn.model_selection import GroupShuffleSplit groups = [get_source_group(p) for p in all_paths] # 自定义分组函数 gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, temp_idx = next(gss.split(all_paths, groups=groups))

测试集要单独留出来,在整个调参过程中一次都不许看。我见过太多人反复在测试集上调阈值,最后报出来的数字毫无意义。

2.3 数据增强的度:过犹不及

增强是提升泛化的重要手段,但猫狗分类有个特殊性——水平翻转是安全的,垂直翻转是危险的。倒过来的猫在现实中几乎不存在,垂直翻转会引入分布外的样本,反而伤害模型。旋转角度也要克制,±15 度以内比较稳妥,超过 30 度猫狗的姿态就失真了。

我常用的增强组合是这样的:

增强类型参数设置说明
随机水平翻转p=0.5安全且有效
随机旋转±15°模拟拍摄角度偏差
颜色抖动亮度/对比度 0.2应对光照变化
随机裁剪缩放 0.8~1.0 后裁模拟主体位置偏移
高斯模糊p=0.1,核 3模拟失焦

注意不要叠加太多强增强,尤其是小数据集上。我试过把 RandAugment 全开,训练集准确率死活上不去,模型欠拟合。增强的强度要跟数据量匹配——数据少时适度增强,数据多时反而可以收敛一些。

2.4 分辨率与预处理的统一

前面提过分辨率的重要性。我的建议是训练和推理用同一套预处理流程,包括 resize 的插值方法。训练用bilinear、推理用bicubic这种不一致,会带来细微的分布偏移,虽然影响不大,但在追求稳定性的场景下值得统一。

另外归一化的均值方差要用你自己数据算出来的,而不是无脑套 ImageNet 的[0.485, 0.456, 0.406]。如果你的数据整体偏暗(比如大量室内猫图),用 ImageNet 的统计量会让输入分布偏移。算一下自己数据集的均值和标准差,几行代码的事:

import numpy as np from PIL import Image def dataset_stats(paths, size=224): means, stds = [], [] for p in paths: arr = np.asarray(Image.open(p).convert("RGB").resize((size, size)), dtype=np.float32) / 255.0 means.append(arr.mean(axis=(0, 1))) stds.append(arr.std(axis=(0, 1))) return np.mean(means, axis=0), np.mean(stds, axis=0)

3. 模型选型:从能跑到跑好的取舍逻辑

模型这块,市面上的选择很多,但猫狗分类这个任务其实不需要太重的网络。关键不是"用最大的模型",而是"用匹配数据规模和部署约束的模型"。我把几种常见路线的实际表现和适用场景列一下。

3.1 从零训练 vs 迁移学习

从零训练一个 CNN 在 25000 张图上,准确率大概能到 85% 上下,但要调很久,且对初始化和学习率非常敏感。迁移学习拿 ImageNet 预训练权重做微调,同样的数据能轻松到 95% 以上,训练时间还短。除非你的数据域和 ImageNet 差异极大(比如医学影像、卫星图),否则迁移学习是默认选择。

猫狗恰好是 ImageNet 的类别之一,预训练权重里本身就包含大量猫狗特征,这是天然优势。我一般直接加载预训练 backbone,冻结前面几层,只微调后面的 stage 和分类头。

3.2 几个 backbone 的实测对比

我在同一份清洗后的数据上跑过几个常见 backbone,输入统一 224×224,训练 15 个 epoch,结果大致如下:

Backbone参数量验证准确率单张推理耗时(CPU)适用场景
MobileNetV3-Small2.5M93.2%~8ms移动端/边缘设备
EfficientNet-B05.3M95.1%~22ms平衡精度与速度
ResNet-5025.6M95.8%~65ms服务端,追求精度
ViT-B/1686M96.3%~180ms数据充足、算力充裕

差距其实没有想象中大。MobileNetV3 在猫狗这种二分类上已经足够,除非你的场景对精度极其敏感。我个人的默认选择是 EfficientNet-B0——精度接近 ResNet-50,参数量只有五分之一,部署友好。

3.3 分类头的设计细节

很多人直接接一个Linear(features, 2)就完事,这没问题,但有几个细节值得注意。第一,加 dropout,哪怕只有 0.2,也能缓解小数据集上的过拟合。第二,分类头前加一层 BatchNorm 或 LayerNorm,能让微调更稳定。第三,如果后续要做"不确定"判断,输出层用 softmax 后取最大概率即可,不需要额外结构。

一个我常用的分类头长这样:

import torch.nn as nn class ClassificationHead(nn.Module): def __init__(self, in_features, num_classes=2, dropout=0.2): super().__init__() self.norm = nn.LayerNorm(in_features) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(in_features, num_classes) def forward(self, x): return self.fc(self.dropout(self.norm(x)))

LayerNorm 而不是 BatchNorm,是因为微调时 batch size 往往不大(受显存限制),BatchNorm 在小 batch 上统计量不稳。

3.4 冻结策略的渐进式解冻

微调不是一上来就全放开。我的做法是分阶段:先冻结 backbone 只训分类头 3 个 epoch,让分类头收敛;然后解冻最后两个 stage,用较小的学习率(比如 1e-4)继续训;最后如果验证集还在涨,再全解冻用更小的学习率(1e-5)收尾。这个渐进式解冻比一次性全放开稳定得多,尤其是在数据量不大的时候。

4. 训练过程中的那些"玄学"其实都有解释

训练这块,很多参数看起来像玄学,但背后都有明确的机制。这一节我把几个关键设置讲清楚,顺便说说我踩过的坑。

4.1 学习率与优化器的搭配

迁移学习场景下,backbone 和分类头应该用不同的学习率——backbone 小、分类头大,这叫判别式学习率。因为预训练权重已经很好,大学习率会把它破坏掉;而分类头是随机初始化的,需要快速收敛。

optimizer = torch.optim.AdamW([ {"params": backbone.parameters(), "lr": 1e-4}, {"params": head.parameters(), "lr": 1e-3}, ], weight_decay=1e-4)

优化器我偏好 AdamW 而不是 SGD,因为对学习率不那么敏感,省去很多调参时间。SGD 在精细调优时可能略好,但需要配合 momentum 和更仔细的 lr schedule,性价比不高。

学习率调度用 cosine annealing 比较稳,配合 2~3 个 epoch 的 warmup。warmup 的作用是避免训练初期大梯度破坏预训练权重,尤其在 batch size 较大时很重要。

4.2 Batch size 与 BatchNorm 的联动

batch size 不只是显存问题,它直接影响 BatchNorm 的统计质量。batch 太小(比如 8 以下),BN 的均值和方差估计噪声大,训练会抖。我一般把 batch 设在 32~64,显存不够就用梯度累积模拟大 batch:

accum_steps = 4 # 等效 batch = 实际 batch × 4 for i, batch in enumerate(loader): loss = model(batch) / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()

注意梯度累积时,如果有 BN 层,统计量仍然是按实际小 batch 算的,所以它不能完全替代大 batch,只是缓解梯度噪声。

4.3 早停与模型选择的标准

早停看什么指标?很多人看验证准确率,但类别不平衡时准确率会骗人。猫狗虽然大体平衡,但如果你的数据有偏,应该看 F1 或 AUC。我一般同时监控验证 loss 和 F1,loss 连续 5 个 epoch 不降就停,保存 F1 最高的那个 checkpoint。

还有一个细节:保存 checkpoint 时把优化器状态、epoch、预处理参数一起存下来,方便复现和继续训练。只存模型权重,后面想接着训就得重新对齐所有状态,很麻烦。

4.4 过拟合的识别与应对

训练集准确率 99%、验证集 88%,这是典型过拟合。应对手段按优先级排:先加数据增强,再加 dropout 和 weight decay,然后考虑减小模型或冻结更多层,最后才是加数据。不要一上来就上正则化大礼包,容易把模型压得欠拟合。

我遇到过一个反直觉的情况:验证 loss 先降后升,但验证准确率还在涨。这是因为模型对正确样本越来越自信,对错误样本的惩罚也在加大,但决策边界还没错。这时候不该急着早停,看 F1 更靠谱。

5. 评估方式:别被一个准确率数字骗了

模型训完,评估这块的坑一点不比训练少。一个 95% 的准确率,可能藏着很糟糕的实际表现。

5.1 混淆矩阵比准确率信息量大得多

二分类一定要看混淆矩阵。猫判成狗和狗判成猫,在很多应用里代价是不一样的。比如宠物门禁场景,把自家猫认成狗拒之门外,比把野狗认成猫放进来,后果完全不同。混淆矩阵能让你看清错误的方向:

预测猫预测狗
实际猫94060
实际狗35965

从这张表能看出,猫被误判的比例略高(60/1000),可能是猫的姿态更多样。针对性地补充猫的难样本,比盲目加数据有效。

5.2 置信度校准:概率不等于可信度

模型输出的 0.9,不代表真有 90% 的把握。深度网络普遍存在过度自信的问题。校准的做法是画可靠性图(reliability diagram),或者算 ECE(Expected Calibration Error)。如果发现模型过度自信,可以用温度缩放(temperature scaling)在验证集上校准:

# 在验证集上搜索最优温度 T import torch import torch.nn.functional as F def find_temperature(logits, labels): best_t, best_ece = 1.0, float("inf") for t in [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]: probs = F.softmax(logits / t, dim=1) ece = compute_ece(probs, labels) if ece < best_ece: best_t, best_ece = t, ece return best_t

校准后的概率才能用来做"不确定拒识"的阈值判断,否则阈值设了也不准。

5.3 真实场景的鲁棒性测试

验证集表现好不代表真实场景好。我一般会额外构造几个"压力测试集":低光照的、模糊的、主体偏小的、有遮挡的,分别测准确率。这几个数字比总体准确率更能反映模型能不能用。如果某个子集掉得特别厉害,就针对性地补数据或增强。

6. 从模型到可用:部署与推理的落地细节

训好的模型要真正用起来,还有一段路。这一节讲部署时容易忽略的点。

6.1 导出与推理优化

PyTorch 模型导出成 ONNX 或 TorchScript,推理速度通常能提升 20%~40%,还方便跨平台部署。导出时注意把预处理也固化进去,避免训练和推理的预处理不一致:

import torch model.eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, "catdog.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}}, opset_version=13, )

导出后一定要用同一批图片对比 ONNX 和原模型的输出,确认数值一致(误差在 1e-4 以内)。我遇到过导出后精度掉几个点的情况,原因是某个自定义层没被正确转换。

6.2 推理服务的批处理与超时

如果做成服务,要注意批处理的权衡。批处理能提升吞吐,但会增加单张延迟。猫狗分类这种任务,单张推理本来就快,批处理收益有限,反而可能因为等待凑批增加延迟。我的做法是设一个很短的等待窗口(比如 10ms),窗口内到的请求凑一批,超时就单张走。

6.3 线上监控与数据回流

模型上线不是终点。要监控线上输入的分布,如果发现大量低置信度输出,说明遇到了训练时没见过的场景,需要回流数据重新训练。我一般会记录每张图的预测概率,定期统计低置信度样本的占比,超过阈值就触发一次数据审查。

7. 几个我踩过的坑和对应的解法

最后这部分是我个人在猫狗分类上真实踩过的坑,写出来给后来人省点时间。

第一个坑是验证集泄漏。早期我没做去重,验证准确率 97%,结果上线后惨不忍睹。后来加了 pHash 去重和分组划分,验证准确率降到 93%,但线上表现反而稳了。虚高的数字比难看的数字更危险。

第二个坑是预处理不一致。训练时用了RandomResizedCrop,推理时直接Resize,导致主体尺度分布不一致,线上准确率掉了 5 个点。后来把推理的 resize 策略改成和训练的中心裁剪对齐,问题解决。

第三个坑是过度依赖 ImageNet 归一化。我的数据偏暗,用 ImageNet 统计量后模型收敛慢、最终精度低。换成自己算的均值方差后,训练稳定性和精度都有提升。

第四个坑是忽略难样本。总体准确率 95% 看着很美,但深色猫、背对镜头的狗几乎全错。后来专门补了这两类样本各几百张,总体准确率只涨了 0.5%,但真实场景的可用性提升明显。总体指标和分场景指标要一起看。

第五个坑是过早全量微调。一上来就解冻所有层用大学习率,预训练权重被破坏,训练 loss 震荡不收敛。改成渐进式解冻后,训练曲线平滑多了。

这些坑的共同点是:它们都不会在"跑通教程"的阶段暴露,只有当你认真对待真实场景时才会浮现。猫狗分类这个任务本身不难,难的是把它做扎实。把数据、评估、部署这几环的细节抠到位,一个轻量模型的实际表现,往往比一个没调好的大模型更靠谱。