☰
运动鞋细粒度图像分类:迁移学习与ONNX量化部署
2026/10/26 4:47:42 网站建设 项目流程

运动鞋识别这个题目,乍一看像是深度学习入门作业里的“Hello World”,真动手做一遍才知道坑有多深。我在排期表上把这个项目标成 P5 周,也就是第五个交付节点,原本只打算花两三天跑通,结果前后折腾了小半个月。用 PyTorch 搭一个能认出运动鞋款式的模型,技术上并不新鲜,难点全在“认得住”和“认不错”这两件事上:同一款鞋换个配色、换个角度、换双鞋带,传统意义上的准确率就会掉得很难看。这个项目的价值恰恰在这儿——它把图像分类里最容易被忽略的几个真问题一次性摆到了台面上:细粒度类别之间的边界怎么划、数据泄漏怎么防、模型是不是在偷偷看背景,以及一个能跑通训练的脚本离能用的服务到底差多远。下面这些内容适合已经会写nn.Module、能跑通 MNIST 或猫狗二分类,但想让模型真正落地解决一个具体识别问题的朋友;也适合手上正卡在验证集准确率虚高、或者训练完不知道下一步该改哪儿的人。

1. 项目定位与整体设计思路

1.1 运动鞋识别这件事,难点其实不在“认鞋”

很多人第一反应是,鞋不就是个物体吗,分类模型还不是手到擒来。但只要真正把数据摊开看,就会发现问题根本不在“这是鞋还是包”,而在“这是哪一个款”。运动鞋识别本质上属于细粒度图像分类,它的特点是类间差异极小、类内差异极大。同一系列的鞋,差异可能只是侧面一块薄薄的支撑片、鞋头一个弧度、鞋底纹路走向;而同一款鞋的不同配色和不同拍摄角度,视觉差异可能比两个不同款式还大。

我在第一版数据里就吃过这个亏。当时收集了若干款跑鞋,里面有两款属于同一品牌同一系列的迭代版本,外观差异集中在后跟的稳定结构上,占整张图不到百分之五的面积。模型训练到第五个 epoch 时,这两类的混淆矩阵几乎是全互串,准确率卡在六成上不去。原因很简单:输入被缩放到 224 乘 224 之后,那块关键区域只剩下十几像素,卷积核根本“看不清”。

这就带来第一个设计决策:输入分辨率和裁剪策略必须服务于区分性区域,不能无脑缩放到 224。我最后的做法是把输入提到 320 乘 320,同时把随机裁剪的最小缩放比例从常规的 0.08 提高到 0.4 以上,让模型更多看到完整鞋体而不是零碎局部——因为对于“款式识别”而言,整体廓形比纹理细节更有判别力,这一点和识别花鸟、车型这类依赖纹理的任务刚好相反。

再往下还有两个隐性难点。第一个是背景捷径。网上抓的图大量来自商品白底图和穿搭场景图,模型很容易学到“白底就是A款、水泥地就是B款”这种毫无意义的关联,验证集准确率能刷到九十几,一换真实场景立刻崩。第二个是数据泄漏,同一双鞋的不同角度图如果被随机分到训练集和验证集,验证集就变成了“记忆测试”,指标毫无参考价值。这两点后面会单独展开讲,但它们在方案设计阶段就必须提前防范,而不是等指标不对劲再回头找原因。

1.2 技术路线:先用迁移学习把基线跑通,再谈优化

在方案选型上,我没有犹豫太久。自建 CNN 从零训练在这个数据量级下基本没有胜算——我们手上能拿到的标注数据,撑死几千张,而一个像样的卷积网络想从随机初始化学到有判别力的特征,通常需要十万量级的样本。所以路线很明确:用公开大规模分类数据集上预训练好的骨干网络做迁移学习,只替换最后的分类头。

具体做法上,我对比了三种常见策略:

策略做法训练成本小数据表现我的选择
特征提取冻结全部骨干,只训分类头极低中等,易欠拟合只用于第一天摸基线
全量微调骨干与头部一起训,统一学习率中等容易过拟合小类不推荐直接上
分层微调先冻骨干训头,再解冻并用小学习率中等偏高最稳最终方案

分层微调的逻辑其实很朴素:预训练骨干的浅层已经在学边缘、颜色块、简单纹理,这些特征对鞋同样有效,不该被大学习率冲掉;而深层特征和分类头是需要重新适配的。所以先让头部在“特征不变”的前提下收敛到一个合理位置,再放开全部参数用小学习率细调,模型不容易在训练早期就把预训练权重破坏掉。我实测下来,这个两阶段策略相比直接全量微调,验证集宏平均 F1 高出大约 4 到 6 个百分点,而且训练曲线明显更平滑。

骨干网络的选择我列了个对比,这个表格后来成了我们组内的默认参考:

骨干参数量单图推理(CPU)相对精度适用场景
ResNet50约 25M60~90ms基准服务端,精度优先
ResNet18约 11M25~40ms略低 1~3 点轻量服务
MobileNetV3-Large约 5.5M15~25ms接近 ResNet50端侧、边缘设备
EfficientNet-B0约 5.3M20~35ms略优于 MobileNetV3折中选择

最后我在服务端用 ResNet50 做精度基线,端侧用 MobileNetV3-Large。这个“双模型”组合看着有点浪费,但实际收益很直接:服务端负责疑难样本和高置信度校验,端侧保证在普通笔记本上也能跑到实时。

1.3 从硬件预算倒推方案,别先写代码再发现跑不动

这一步很多人会跳过,结果写到一半发现 batch size 只能开到 8,训练一轮要半小时。我的习惯是先算账再动手。

显存估算的经验公式是:显存放不下,基本是激活值而不是参数在占地方。以 ResNet50、输入 320 乘 320、batch 32 为例,参数只占约 100MB(fp32),但中间层激活在反向传播时需要全部保留,实测占用在 6GB 左右;换成混合精度后降到 3.5 到 4GB。MobileNetV3-Large 因为深度可分离卷积的中间通道数少得多,batch 32 在 fp32 下大约 1.8GB 就够。

训练时长的账更好算。假设训练集 3000 张,batch 32,每轮迭代数就是 3000 除以 32 向上取整,约 94 次。单卡上 ResNet50 在 320 分辨率、混合精度下的单次迭代耗时大约 0.12 秒,那么一轮训练约 11 秒;加上验证集约 800 张的前向计算,一轮总计 15 秒上下。跑 40 轮就是十分钟出头。这个量级意味着我一天之内可以做几十组对比实验,调参节奏完全能接受。

反过来,如果一开始把输入开到 512、batch 开到 64,显存直接爆掉,只能退回 16,迭代数翻倍,单轮时间可能涨到 40 秒以上,实验迭代速度立刻掉一个档。所以我的建议是:先用一个能在十分钟内跑完一轮的配置把全流程打通,再逐步加分辨率、加模型容量。整条链路上任何一个环节没验证过,都不该急着调超参。

提示:动手前先写一个只有十行的小脚本,打印模型参数总量、单 batch 显存峰值和单次迭代耗时。这三项数据决定了你后面所有实验的节奏,值得花二十分钟。

2. 数据集构建:上限在第 0 步就定死了

2.1 类别体系怎么定,直接决定项目能不能收敛

这是最容易被低估的一步。很多人的做法是“看到什么收什么”,结果类别粒度混乱:有的类是同系列不同代,有的类是不同品牌的不同风格,颗粒度完全不在一个层面上。模型学起来当然痛苦,因为损失函数面对的边界一会儿极细一会儿极粗。

我在第二版数据里重新定义了类别体系,原则有三条。第一,粒度统一:所有类别都定在“具体款式”这一层,不允许出现“某系列”和“某系列第三代”混在一起的情况。第二,样本量均衡:每类至少 150 张,主力类控制在 300 张左右,低于 80 张的类直接合并或剔除,原因是小样本类在分层学习率下几乎必然过拟合,还会拉低整体宏平均指标。第三,可区分性验证:在正式训练前,我会把候选类别两两配对,人工快速过一遍,如果连我自己都要盯着看五秒才能分辨,那这个类别对模型来说就是噪声,要么合并,要么加更多有区分度的样本。

实际做下来,最终确定的类别数是 18 类,总样本量约 4200 张。这个规模不大,但对于验证整套流程、跑通训练、评估、导出、推理的闭环完全够用。规模再往上堆,边际收益会快速递减,而且标注和清洗的成本会指数上升。

顺带说一句,别小看“负样本”的价值。我在类别里刻意留了一类“其他鞋型”,专门装那些款式不明或者只有一两张图的样本,训练时它的作用是把决策边界往里收,让模型不至于对任何输入都强行给一个高置信度。线上推理时,这一类的概率高就代表“拿不准”,可以直接转到人工复核队列,这是很实用的一道保险。

2.2 采集、去重与人工清洗:脏数据的代价远高于少数据

数据来源无非几类:公开图库、商品详情页、实拍。前两类量大但风格单一,第三类真实但成本高。我的配比大概是对半开:一半来自商品图和公开图库,保证基础覆盖面;另一半是自己拍的实拍图,覆盖不同光线、不同角度、不同背景,专门用来对抗背景捷径。

清洗环节我做了三件事,每件都很土但很有效。

第一件是感知哈希去重。同一张图被不同渠道重复收录是常态,不去重的话,训练集里某个样本可能重复出现十几次,等效于给这个样本加权,模型会严重偏向它。做法很简单,把每张图缩到 8 乘 8 灰度,算均值哈希,汉明距离小于 5 的判为重复,只保留分辨率最高的一张。

第二件是按鞋款 ID 打标签。这一步是防数据泄漏的基础。每收一双鞋,不管有几个角度、几张图,统统打上同一个shoe_id。后面划分数据集时按shoe_id分组,而不是按图片随机分。

第三件是人工抽检加剔除。我随机抽了 15% 的图逐张过,剔除掉的主要是三类:主体占比过小(鞋在画面里不到三分之一)、严重遮挡、以及明显是别的品类的误标。抽检的误标率大概在 6% 到 8% 之间,这个比例听着不高,但它集中在少数几个视觉相近的类上,对混淆矩阵的污染相当明显。

注意:标注错误在细粒度任务里的破坏力,比在粗粒度任务里大一个数量级。粗粒度任务标错一张猫成狗,模型顶多学偏一点;细粒度任务把 A 款标成 B 款,相当于在决策边界上埋了一颗钉子,而且这颗钉子往往就埋在你最难分的那两类之间。

2.3 划分策略:防泄漏比防过拟合更要紧

我见过太多人栽在这里。用train_test_split直接按图片随机切分,训练集 80%、验证集 20%,看起来没毛病,但同一双鞋的不同角度被分到了两边。结果就是验证集上的样本,模型在训练时已经“见过这双鞋”了,只是换了个角度。这种情况下验证准确率能轻松刷到 95% 以上,一旦换新数据就掉到 60%,人直接懵掉。

正确做法是按组划分。用GroupShuffleSplit或者自己写逻辑,以shoe_id为组键,保证同一组的所有图片只出现在一个集合里。我用的比例是训练 70%、验证 15%、测试 15%,并且验证集和测试集在划分时还额外做了一次类别分布校验,确保每个集合里每类至少有 20 张。

import pandas as pd from sklearn.model_selection import GroupShuffleSplit df = pd.read_csv("labels.csv") # 列:image_path, label, shoe_id gss1 = GroupShuffleSplit(n_splits=1, test_size=0.30, random_state=42) train_idx, temp_idx = next(gss1.split(df, groups=df["shoe_id"])) train_df, temp_df = df.iloc[train_idx], df.iloc[temp_idx] gss2 = GroupShuffleSplit(n_splits=1, test_size=0.50, random_state=42) val_idx, test_idx = next(gss2.split(temp_df, groups=temp_df["shoe_id"])) val_df, test_df = temp_df.iloc[val_idx], temp_df.iloc[test_idx] for name, part in [("train", train_df), ("val", val_df), ("test", test_df)]: print(name, len(part), part["label"].nunique())

这套划分跑完之后,我做的第一件事是看验证集准确率相比随机划分掉了多少。实测从 94% 掉到 78%,掉了 16 个点。这 16 个点就是泄漏的“水分”,提前挤掉比后面被现实打脸要好得多。

还有一个细节:验证集和测试集要用不同时期采集的数据。我把测试集专门留给最后一批实拍图,光线和背景都和训练集有明显差异,这样测出来的数字才接近真实部署环境。如果条件允许,测试集甚至应该来自不同的拍摄设备。

2.4 数据增强:不是越多越好,要对着你的短板打

数据增强的常见误区是把torchvision.transforms里能加的全都加上,以为增强越猛泛化越好。实际恰恰相反,增强方式和任务的判别依据冲突时,它是在主动破坏信息。

举个具体的例子。水平翻转对大多数分类任务是标配,但在我这个项目里,有部分款式的前掌外侧和内侧结构是不对称的,翻转之后模型会学到错误的方向特征。我做了个对照实验,开翻转的版本在验证集上反而低了 1.5 个点。所以最终我把翻转关掉了,改成左右各偏转 15 度以内的小角度旋转,既增加了视角多样性,又不会破坏不对称结构。

下面是我最终用的增强配方,按“解决什么问题”来组织:

增强手段参数解决什么问题备注
RandomResizedCropscale=(0.4, 1.0)主体占比不一下限别低于 0.3,否则鞋被裁碎
RandomRotation±15°拍摄角度差异不做水平翻转
ColorJitter亮度 0.3、对比度 0.3、饱和度 0.2光照与色偏饱和度别开太大,配色是判别依据
RandomErasingp=0.25, scale=(0.02, 0.12)遮挡鲁棒性面积别超 15%,会伤主体
Normalize数据集均值方差收敛稳定三个通道分别算,别用默认值

这里有两个我个人踩过的坑值得展开。第一个是色彩增强的尺度。运动鞋识别里配色本身是很强的判别信号,早期我把饱和度抖动开到 0.5,结果同款不同配色的样本被增强得面目全非,模型在验证集上的类内一致性明显变差。后来压到 0.2,问题消失。第二个是归一化参数用默认值。很多人直接用 ImageNet 的均值和方差,如果你的数据整体偏亮或偏暗,这个默认值会让输入分布偏移,前几个 epoch 的 loss 曲线会明显抖。我的做法是在训练集上统计实际的三通道均值和标准差,写死到配置里,收敛速度肉眼可见地快了一点。

还有一点不能省:验证集和测试集只做 resize、中心裁剪和归一化,绝对不加任何随机增强。这个错误看起来低级,但我确实见过有人把增强管道复用到了验证集上,导致每次评估结果都在浮动,排查了半天以为是模型不稳定。

3. 训练流程与关键参数实现

3.1 骨干网络加载与分层学习率的落地写法

先说权重加载。用torchvision加载预训练骨干时,weights参数要显式指定,默认值在不同版本里行为不一致,容易踩坑。加载完之后只替换最后的全连接层,中间层原样保留。

import torch.nn as nn from torchvision import models def build_model(num_classes, arch="resnet50", pretrained=True): weights = "IMAGENET1K_V2" if pretrained else None if arch == "resnet50": model = models.resnet50(weights=weights) in_dim = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_dim, num_classes), ) elif arch == "mobilenet_v3_large": model = models.mobilenet_v3_large(weights=weights) in_dim = model.classifier[-1].in_features model.classifier[-1] = nn.Linear(in_dim, num_classes) return model

分层学习率的实现,关键在于把骨干和头部分成两个参数组,并给不同的学习率。这里有三个实操细节值得说清楚。

第一,偏置项和归一化层的权重不参与权重衰减。这是个常被忽略的细节:对 BatchNorm 的缩放参数和卷积的偏置做 L2 惩罚,在理论上没有依据,实践中还会让模型收敛变慢。正确写法是把参数按“是否二维及以上张量”拆开,只有卷积核和全连接权重进 weight decay 组。

第二,头部学习率给骨干的 5 到 10 倍。我用的配置是头部 1e-3、骨干 1e-4,配合余弦退火。这个比例是试出来的:头部学习率再高,损失会震荡;再低,两阶段训练的收益就体现不出来。

第三,warmup 阶段不能省。解冻骨干的最初几百次迭代,如果直接用 1e-4 的学习率,预训练特征会被冲击得比较厉害。加 3 个 epoch 的线性 warmup,曲线会平稳很多。

def build_optimizer(model, head_lr=1e-3, backbone_lr=1e-4, wd=1e-4): head_params = list(model.fc.parameters()) if hasattr(model, "fc") \ else list(model.classifier.parameters()) head_ids = {id(p) for p in head_params} backbone_decay, backbone_nodecay, head_decay, head_nodecay = [], [], [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue is_head = id(p) in head_ids use_wd = p.ndim >= 2 if is_head: (head_decay if use_wd else head_nodecay).append(p) else: (backbone_decay if use_wd else backbone_nodecay).append(p) return torch.optim.AdamW([ {"params": backbone_decay, "lr": backbone_lr, "weight_decay": wd}, {"params": backbone_nodecay, "lr": backbone_lr, "weight_decay": 0.0}, {"params": head_decay, "lr": head_lr, "weight_decay": wd}, {"params": head_nodecay, "lr": head_lr, "weight_decay": 0.0}, ])

两阶段的具体安排是:前 3 个 epoch 冻结骨干,只训头;第 4 个 epoch 起解冻全部参数,同时把优化器重建一次(因为参数组变了),学习率调度重新开始。这个“重建优化器”的动作很多人会忘,结果解冻之后 learning rate 还停在头部的值上,骨干直接被冲乱。

3.2 损失函数、样本不平衡与标签平滑

损失函数这块我用的是CrossEntropyLoss,但加了两个配置,每个都有明确理由。

第一个是label_smoothing=0.1。标签平滑的作用是把 one-hot 目标从“1 和 0”变成“0.9 和 0.0x”,防止模型对训练样本过度自信。在细粒度任务上它的收益特别明显,因为这些任务的类别边界本身就模糊,强行要求模型输出接近 1 的概率,只会让它把决策边界挤得非常陡峭,泛化变差。我实测标签平滑带来大约 1.5 个点的验证集提升,代价是训练 loss 的绝对值变高——这是正常的,不要因为看到 loss 降不到 0 就以为训练出了问题。

第二个是类别权重。虽然我把每类样本量都控制在了 150 以上,但长尾依然存在,主力类 300 张、边缘类 150 张,差了一倍。处理方式有两种:给损失函数传weight参数,或者用WeightedRandomSampler在采样层面解决。我更倾向后者,因为它同时改变了每个 batch 的类别构成,让 BatchNorm 的统计量更均衡。权重按类别频次的倒数计算,再做归一化,避免某些类的权重过大导致梯度爆炸。

import numpy as np from torch.utils.data import WeightedRandomSampler counts = train_df["label"].value_counts().sort_index().values class_w = 1.0 / counts class_w = class_w / class_w.sum() * len(class_w) # 归一化 sample_w = train_df["label"].map( {i: w for i, w in enumerate(class_w)}).values sampler = WeightedRandomSampler( weights=torch.DoubleTensor(sample_w), num_samples=len(train_df), replacement=True, )

注意replacement=True在这个场景下是必须的,因为要过采样少数类就必须允许重复。这么做的副作用是有可能过拟合少数类,所以我把训练轮数控制在 40 以内,并配合早停,验证指标连续 6 轮不提升就停。

3.3 训练循环与混合精度的实测数据

训练循环我写了个比较朴素的版本,重点是把混合精度、梯度裁剪和指标记录都塞进去。混合精度在 30 系及以上的卡上收益明显,单轮时间大约能省 30% 到 40%,显存也能省下四成左右。

from torch.cuda.amp import autocast, GradScaler from tqdm import tqdm scaler = GradScaler() def train_one_epoch(model, loader, optimizer, criterion, device, clip=1.0): model.train() total_loss, correct, total = 0.0, 0, 0 for imgs, labels in tqdm(loader, leave=False): imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad(set_to_none=True) with autocast(): logits = model(imgs) loss = criterion(logits, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), clip) scaler.step(optimizer) scaler.update() total_loss += loss.item() * imgs.size(0) correct += (logits.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total

optimizer.zero_grad(set_to_none=True)这个小改动值得单独提一句,它把梯度张量置为 None 而不是清零,能省一点显存和内存带宽,在显存吃紧的时候是有感的。梯度裁剪阈值设在 1.0,主要防的是少数异常样本导致的梯度尖峰,尤其是启用过采样之后,同一批里可能出现大量重复样本,梯度分布会偏。

下面是 ResNet50 在 320 分辨率下的实测数据,取自我们一台单卡机器,仅供你估算自己的环境:

项目fp32混合精度
batch=32 显存峰值约 6.1GB约 3.7GB
单次迭代耗时0.16s0.11s
单轮训练(3000 张)约 15s约 11s
40 轮总时长约 12min约 9min
验证集 Top-181.4%81.6%

混合精度在精度上几乎没有损失,这一点不用担心。唯一需要注意的是autocast区域内的数值稳定性,如果出现 loss 为 NaN,优先检查学习率是不是太大,其次检查损失函数里有没有手动做 log 或除法运算。

3.4 断点续训与实验记录:省下来的是排查时间

这两个东西平时不显眼,出事的时候能救命。断点续训要保存的不只是模型权重,还要保存优化器状态、调度器状态、当前 epoch 和随机数状态。少存一样,续训之后的曲线就会和原来对不上。

ckpt = { "epoch": epoch, "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "best_f1": best_f1, "rng": torch.get_rng_state(), } torch.save(ckpt, f"ckpt_e{epoch}.pt")

实验记录我用的是最土的办法:一个 CSV,每行一组实验,记录学习率、骨干、输入尺寸、增强开关、最佳轮次、验证集 Top-1、宏平均 F1、以及一句话备注。写到第三十行的时候你就会感谢自己,因为对比实验一多,靠记忆根本记不住哪组配置是什么结果。这份 CSV 后来还帮我发现了一个规律:输入分辨率从 224 提到 320 带来的收益,比换更大的骨干网络还高,这个结论直接改变了后面的优化方向。

4. 评估与调优:别被一个高分骗了

4.1 指标组合:只看准确率迟早出事

单一准确率在类别均衡的时候够用,但在我们这种长尾分布加细粒度边界的数据上,它会掩盖很多问题。我固定的评估组合是四项:Top-1 准确率、Top-3 准确率、宏平均 F1、混淆矩阵。

Top-3 在细粒度任务里很有参考价值。如果 Top-1 是 80% 但 Top-3 是 97%,说明模型大体上“知道方向”,错的那部分通常是同系列相邻款,这种情况下继续优化边界比换模型更有效。如果 Top-1 和 Top-3 差距很小,比如 78% 对 82%,那说明模型是真的没学会,问题可能出在数据或特征表达上。

宏平均 F1 是必看的,因为它对每个类等权,小样本类表现差会立刻反映出来。我有一次遇到整体准确率 84% 但宏 F1 只有 0.71 的情况,一查混淆矩阵,发现有两个边缘类几乎全错,而它们正好是样本最少的两个。如果只看准确率,这个问题就被平均掉了。

混淆矩阵建议直接画成热力图保存下来,每轮评估后覆盖一次,能直观看出错误集中在哪些类对上。我最初的 18 类里有 3 对是高频混淆的,定位到之后针对性地补了样本、调了裁剪策略,宏 F1 从 0.71 提到了 0.79。

4.2 捷径学习:模型可能在看背景,而不是在看鞋

这是我这个项目里最有价值的一段排查经历。当时验证集准确率到了 93%,我差点就收工了。但出于谨慎,我拿了一批纯色背景的实拍图做了个快速测试,准确率直接掉到 62%。这个落差说明模型严重依赖背景信息。

排查方法很直接:背景替换测试。把验证集图片的鞋体抠出来(或者简单用中心区域裁剪),贴到纯色、渐变、随机纹理三种背景上,分别测准确率。如果纯色背景下掉得特别厉害,基本可以确定是背景捷径。我的结果是从 93% 掉到 62%,板上钉钉。

针对性的解决办法有几个,我按效果排了序:

手段做法效果成本
背景多样化增强采集时主动覆盖多背景最根本高,需重采集
随机背景替换训练时把主体贴到随机背景显著中,需分割
更强的随机裁剪提高裁剪比例下限中等低
CutMix两张图拼接混合标签中等低,但收敛慢

我最终组合用了前三项。其中随机裁剪比例从 0.4 提到 0.5 这一步,成本最低、见效最快,因为它迫使模型在每次迭代里看到的主体占比都不一样,背景的统计规律被打破。加上背景替换之后,纯色测试集的准确率从 62% 回到了 84%,而原始验证集的准确率只掉了 1 个点。这个交换我觉得非常值。

这件事给我的教训是:验证集指标高不等于模型学到了你要它学的东西。一个负责任的评估流程里,必须包含专门针对“模型可能走捷径”的对抗性测试集。哪怕只准备一百张,也比没有强。

4.3 训练过程中的典型症状与处理方式

我把这几周遇到的症状整理成了一张速查表,基本覆盖了同类项目百分之八十的常见问题:

症状最可能原因排查动作处理方式
训练 loss 不降学习率过小或数据标签错乱用 20 张图做单 batch 过拟合测试若能过拟合到 100%,问题在数据或 lr
loss 变成 NaN学习率过大或混合精度溢出关掉 autocast 重跑降 lr,加梯度裁剪
训练高验证低过拟合看 loss 曲线开口加增强、加 dropout、早停
验证高测试低数据泄漏检查划分是否按组重做 GroupShuffleSplit
全部预测同一类类别极度不平衡或 lr 过大看预测分布上 WeightedRandomSampler
两类持续互串视觉太接近看混淆矩阵提分辨率、合并类别或补样本
推理精度低于训练很多预处理不一致对比预处理代码统一 BGR/RGB、统一归一化
换设备精度掉归一化层统计量不匹配检查 eval 模式确认model.eval()

其中“单 batch 过拟合测试”是我最推荐的一个诊断手段。拿 20 张图、固定 batch,不加任何增强,让模型跑 100 个迭代。如果 loss 降不到接近 0、准确率到不了 100%,那说明模型本身或者数据管道有问题,这时候去调超参纯属浪费时间。这个测试我一般在新项目开工第一小时就做,能省掉后面一大半的无效排查。

4.4 提升精度:按性价比排序的几个动作

调优阶段最怕的就是到处乱试。我按“投入产出比”给动作排了个序,这个顺序在不同项目间略有差异,但大方向稳定。

第一优先级是数据侧。补样本、清错误标注、按组重新划分,这三件事的收益通常远超任何模型改动。我在补了大约 400 张实拍图之后,宏 F1 从 0.79 到 0.83,而同期换骨干、调学习率的收益加起来不到 1 个点。

第二优先级是输入分辨率。从 224 提到 320 带来的收益,在我的实验里稳定在 2 到 3 个点,而且不增加太多训练时间。提到 384 收益开始递减,显存压力却翻倍,所以我停在 320。

第三优先级是训练技巧。标签平滑、余弦退火、warmup、混合精度,这几项组合起来的收益大约 2 个点,而且几乎不增加成本,属于必做项。

第四优先级才是骨干网络和模型结构。换更大的骨干、加注意力模块、做多尺度融合,这些在数据量不够的时候收益非常有限。我试过在分类头前加一个轻量注意力模块,验证集提升 0.4 个点,但推理耗时增加了 18%,最后放弃了。

最后的最后是测试时增强。把同一张图翻转、多尺度缩放后取平均预测,能稳定提升 1 个点左右,代价是推理耗时成倍增加。我只在服务端的离线批处理里开了这个,端侧不用。

5. 推理部署与工程化落地

5.1 导出 ONNX:预处理对齐是最容易翻车的地方

训练跑通只完成了一半,真正投入使用还得过导出这一关。我选 ONNX 是因为它跨框架、跨平台的支持最成熟,部署侧不用装 PyTorch。

导出的核心代码不长,但有几个参数必须显式指定:

import torch model.eval() dummy = torch.randn(1, 3, 320, 320).to(device) torch.onnx.export( model, dummy, "shoe_cls.onnx", input_names=["input"], output_names=["logits"], opset_version=13, dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, do_constant_folding=True, )

model.eval()这一行绝对不能在导出时漏掉,否则 BatchNorm 会用当前 batch 的统计量,Dropout 也还在生效,导出的模型精度会莫名其妙地差一截。这个坑我踩过,排查了两个小时。

dynamic_axes是为了支持变长 batch,如果只做单图推理其实可以不要,但线上往往有批量请求,加上更省事。opset_version选 13 是比较稳的选择,新版本算子支持更全,但部分推理引擎的兼容性反而不如 13。

导出之后必须做一件事:数值对齐验证。用同一批输入分别跑 PyTorch 和 ONNX Runtime,比对输出的最大绝对误差。正常情况下误差应该在 1e-5 量级,如果差到 1e-2 以上,基本可以确定是预处理或者某个算子出了问题。

import numpy as np, onnxruntime as ort sess = ort.InferenceSession("shoe_cls.onnx", providers=["CPUExecutionProvider"]) x = np.random.randn(4, 3, 320, 320).astype(np.float32) onnx_out = sess.run(None, {"input": x})[0] with torch.no_grad(): torch_out = model(torch.from_numpy(x).to(device)).cpu().numpy() print("max abs diff:", np.abs(onnx_out - torch_out).max())

预处理对齐是另一个高频翻车点。训练时用 PIL 读图是 RGB 顺序,部署侧用 OpenCV 读图默认是 BGR,不转换的话红蓝通道对调,精度会掉得莫名其妙。这类问题不看代码根本发现不了,因为模型还是能输出结果,只是结果全错。我的做法是把预处理逻辑单独抽成一个函数,训练和推理共用同一份代码,从源头上消除不一致的可能。

5.2 推理脚本与服务封装

单图推理的脚本要写得很克制,别在推理路径里做任何不必要的事。关掉梯度、切到 eval 模式、控制好内存,这三件事是底线。

@torch.no_grad() def predict(model, img_path, transform, idx2label, device, topk=3): model.eval() img = Image.open(img_path).convert("RGB") x = transform(img).unsqueeze(0).to(device) logits = model(x) prob = torch.softmax(logits, dim=1)[0] conf, idx = prob.topk(topk) return [(idx2label[i.item()], round(c.item(), 4)) for c, i in zip(conf, idx)]

注意.convert("RGB")这一步。灰度图和带透明通道的 PNG 在真实场景里都会遇到,不统一转 RGB,后面 Normalize 会直接报错或者算出错误结果。这种问题在测试集上永远不会出现,因为测试集都是干净的 JPEG。

服务封装我走的是最简路线:一个 FastAPI 接口,接收图片,返回 Top-3 和置信度。中间加了两道保险。第一道是置信度阈值:最高置信度低于 0.55 就直接返回“无法确定”,不强行给结果。第二道是模型版本号:每次模型更新都带上版本号返回,方便线上回溯问题时确认是哪个版本出的错。

线上还遇到过一个实际问题:并发上来之后,GPU 显存被多个推理请求同时占用,出现过 OOM。处理办法是在服务层加一个简单的批处理队列,把短时间内的请求攒成一批统一前向,既降低了显存峰值,又提高了吞吐。这个改动把单机 QPS 从 12 提到了 45 左右,效果比我预想的明显。

5.3 端侧量化:收益和代价都要算清楚

端侧我用的是 MobileNetV3-Large,导出 ONNX 之后做动态量化。动态量化的好处是几乎零成本,不需要校准数据,权重从 fp32 转 int8,激活动态量化,模型体积能压到原来的四分之一左右。

实测数据如下(单核 CPU,输入 320):

版本模型体积单图耗时验证集 Top-1
fp32 ONNX约 22MB22ms80.8%
动态量化 int8约 6MB9ms80.3%

精度只掉了 0.5 个点,速度提升了 2.4 倍,这个交换非常划算。但要注意,动态量化主要加速的是全连接层和部分矩阵运算,卷积层的加速有限,所以如果你的模型是纯卷积结构,收益可能没那么夸张。我这个模型最后有全连接层,所以加速明显。

静态量化理论上收益更大,但需要准备校准数据集,而且要处理每个算子对量化是否友好的问题,调起来更费时间。在项目周期紧的情况下,我建议先用动态量化拿到大部分收益,后面有余力再考虑静态量化。

提示:量化后的模型一定要在真实设备上跑一遍完整的评估集,不能只看几张小图。量化误差在小样本上很难暴露,但长尾类别上的精度损失往往比平均值大得多。

6. 复盘:如果重做一次我会怎么做

6.1 排期上我犯的最大错误

如果从头再来,我会把数据清洗和划分策略这两步的时间预算翻倍,把模型调参的时间砍掉一半。这次项目里真正拖慢进度的不是训练,而是三次返工:第一次是发现数据泄漏要重新划分,第二次是发现背景捷径要重新采集,第三次是发现两个类根本没法区分要合并。这三次返工加起来占了总工时的六成以上,而且全都发生在数据层面。

模型这一侧反而是最省心的。ResNet50 加标准训练流程,第一版基线在第三天就跑出了 81% 的验证准确率,后面两周的所有工作本质上都是在把“虚高的 93%”修正成“诚实的 84%”。这个过程听起来是退步,实际上是项目从玩具变成可用系统的唯一路径。

我的建议是,在这类细粒度识别项目里,开训之前先花半天做三件事:随机抽 50 张图肉眼确认标注、按组划分数据集并检查泄漏、准备一个纯色背景的对抗测试集。这三件事做完,后面的路会顺很多。

6.2 那些文档里不会写的小经验

最后分享几个我在实际操作中攒下来的细节,都是些上不了台面但确实有用的东西。

第一个是配置用字典管理,别散落在代码里。学习率、分辨率、增强参数这些,一旦散在十几个文件里,做对比实验时会疯掉。我的做法是一个config.py,里面一个嵌套字典,实验脚本只改这个字典,日志里把整个字典打出来。这样任何一次结果都能精确复现。

第二个是在训练脚本里固定随机种子,而且要固定全流程的:torch、numpy、random、以及DataLoader的worker_init_fn。少固定一个,同样的配置跑两次结果就能差一两个点,你会以为是超参的效果,其实是噪声。

第三个是验证集评估用torch.no_grad()包起来,这个大家都知道,但更隐蔽的是评估之后要切回model.train()。我有一次忘了切回去,后面几轮训练全在 eval 模式下跑,BatchNorm 统计量不更新,损失曲线看着正常,指标就是上不去,查了很久才发现。

第四个是给验证集图片记一个固定的顺序,不要在评估时随机打乱。混淆矩阵的对比需要每次评估的样本集合完全一致,顺序乱了对不齐,定位问题会很难受。

第五个是关于早停的策略。我一开始按验证集准确率早停,后来改成按宏平均 F1,原因是在长尾分布下准确率对小数类不敏感,经常出现准确率还在涨、宏 F1 已经掉了几轮的情况。按宏 F1 早停更贴近真实可用性,虽然训练轮数会变少一些,但最终线上的表现更好。

这个项目做到现在,我最深的体会是:一个能跑起来的模型和一个能用起来的模型之间,隔着的不是更多的技巧,而是对数据、对评估、对部署链路的踏实处理。模型结构可以抄,超参可以搜,但数据怎么清洗、指标怎么选、捷径怎么防,这些没有标准答案,只能一版一版试出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询