简介:围绕卷积神经网络实现食物图像识别的完整实践项目,面向具备 Python 与深度学习基础、希望打通数据预处理到模型评估全流程的学习者与开发者。项目以 TensorFlow/Keras 构建 CNN,覆盖卷积、池化、全连接与激活函数等核心结构,并讨论数据增强、迁移学习等提效思路,可用于餐饮推荐、健康监测等场景的原型验证。压缩包共 32 个文件、约 17.28MB,以 12 个 Python 脚本为主体,辅以 pyc 缓存、xml 与 iml 工程配置、pytest 缓存标记及 TFRecords 数据文件,分别承担模型定义、图像变换、训练测试与实验记录等职责,目录组织便于按模块复现。已有 6775 人学习下载。借助包内训练脚本、测试脚本与数据记录,读者可复现训练与预测流程,理解数据预处理、模型搭建、训练调参和结果评估的关键环节,并在此基础上尝试迁移学习改造。
1. 菜名识别为什么总在“看起来差不多”的地方翻车
给餐饮类应用做拍照识菜,大部分人的第一反应是直接上一套卷积神经网络做图像分类,跑通 ImageNet 那套模板就算完事。真把食物图像喂进去才会发现,食物识别翻车的方式和通用图像识别完全不是一回事:同一道宫保鸡丁,家里做的、外卖盒装的、餐厅摆盘的,色调和构图能差出半张图的距离;炒饭、炒面、烩饭在缩略图上又高度相似,人眼都要愣两秒。真实拍摄还经常一盘多菜,分类器却只能吐一个标签。这份资源给的就是这么一套落地流程:数据清洗、主干网络选型、迁移学习改造、训练调参、导出部署与验收,适合已经能跑通分类基线、但卡在长尾类别和细粒度混淆上的从业者。
2. 数据先立住:101 类食物数据的体检脚本与增强边界
食物图像识别里 70% 的精度问题,在写模型之前就已经注定了。很多人拿到一份 101 类、每类千张量级的公开食物数据集,直接ImageFolder一把梭,训练集准确率冲到 95%,换一批手机实拍图就掉到 40% 以内。根因往往不在网络结构,而在数据分布和增强策略跟真实场景对不上。这一章先把数据盘清楚,再谈喂给卷积神经网络之前该做什么、不该做什么。
2.1 先做三件体检:类别分布、坏图、重复图
拿到任何食材或菜品类数据集,我第一件事不是写DataLoader,而是跑一个体检脚本。食物数据集有三个高频问题:一是长尾,常见家常菜可能有几千张,冷门地方菜只有几十张;二是坏图,爬取或搬运过程中混进来的缩略图、截断文件、灰度图;三是重复图,同一张原图被不同增强或不同来源反复收录,训练集和验证集同时出现,验证分数直接失真。
import hashlib from collections import Counter from pathlib import Path from PIL import Image ROOT = Path("data/food") # 约定 data/food/<split>/<class>/<img>.jpg for split in ("train", "val"): counter, broken, dup = Counter(), [], {} for p in (ROOT / split).rglob("*.jpg"): try: with Image.open(p) as im: im.verify() # 只校验文件头,比 im.load() 快一个量级 except Exception: broken.append(str(p)) continue counter[p.parent.name] += 1 h = hashlib.md5(p.read_bytes()).hexdigest() dup.setdefault(h, []).append(str(p)) sizes = sorted(counter.values()) print(f"[{split}] 类别数={len(counter)} 图片数={sum(sizes)}") print(f" 最少类={sizes[0]} 中位数={sizes[len(sizes)//2]} 最多类={sizes[-1]}") print(f" 坏图={len(broken)} 重复组={sum(1 for v in dup.values() if len(v) > 1)}") for name, cnt in counter.most_common()[-5:]: print(f" 长尾类别 {name}: {cnt}")这段脚本做三件事:verify()只读文件头判断图片是否完整,比全量解码快很多,适合几十万张的规模;Counter按父目录名统计类别分布,输出的最少类、中位数、最多类三个数就能判断长尾程度;MD5 去重按文件字节算,能抓出完全相同的副本,抓不到“同图不同尺寸”的情况,那种要用感知哈希,但对初筛够用。参数上唯一要改的是ROOT,如果你的目录是<class>/<img>.jpg扁平结构,把split循环去掉即可。
体检出长尾之后,处理方式不是无脑过采样。我的习惯是:类别样本少于中位数 20% 的,先合并语义近似的子类(比如各种“XX 炒饭”合成一类),再考虑加权采样。直接把几十张的冷门类复制二十遍,模型会在这几类上严重过拟合,验证集好看、实拍全错。
2.2 Dataset 与增强:食物图像最忌讳把颜色改狠了
食物识别和猫狗分类在增强策略上有个关键差异:颜色是食物最强的判别特征。红烧、清蒸、油炸、凉拌,第一眼区分靠的就是色调和油光。所以ColorJitter的hue一定不能开大,我一般控制在 0.03 以内,brightness/saturation也就 0.2 上下。见过有人照搬通用模板写hue=0.5,训练损失降得很慢,排查半天以为是学习率问题,其实是增强把番茄炒蛋和咖喱鸡的色相差异抹平了。
import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image MEAN, STD = (0.485, 0.456, 0.406), (0.229, 0.224, 0.225) train_tf = transforms.Compose([ transforms.RandomResizedCrop(288, scale=(0.65, 1.0), ratio=(0.8, 1.25)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.03), transforms.ToTensor(), transforms.Normalize(MEAN, STD), transforms.RandomErasing(p=0.25, scale=(0.02, 0.10)), # 模拟筷子遮挡/盘边裁切 ]) val_tf = transforms.Compose([ transforms.Resize(320), transforms.CenterCrop(288), # 与训练的 RandomResizedCrop 输出尺寸对齐 transforms.ToTensor(), transforms.Normalize(MEAN, STD), ]) class FoodDataset(Dataset): def __init__(self, samples, transform): self.samples = samples # [(path, label_idx), ...] self.transform = transform def __len__(self): return len(self.samples) def __getitem__(self, i): path, label = self.samples[i] img = Image.open(path).convert("RGB") # 强制三通道,防止灰度图炸 collate return self.transform(img), label几个参数值得说清楚。scale=(0.65, 1.0)而不是常见的(0.08, 1.0):食物的主体通常占画面 60% 以上,裁到只剩 8% 面积时模型学到的是盘子边缘和桌布纹理,属于负收益增强。ratio=(0.8, 1.25)限制长宽比扰动,因为手机竖拍和横拍的菜品构图差异没有通用场景那么大。RandomErasing放在Normalize之后是 torchvision 的推荐顺序,scale上限 0.1 是为了模拟筷子、餐具的局部遮挡,再大就会把主菜遮掉。convert("RGB")这行看着多余,但在混合来源数据里能省掉一整类collate报错。
RandomHorizontalFlip对绝大多数中餐是安全的,摆盘左右翻转不改变语义。但如果你做的是西餐或带品牌包装的外卖识别,包装上的文字翻转后会产生错误监督,这时候要么关掉翻转,要么把翻转概率降到 0.2 以下。
2.3 划分防泄漏:先分图再分增强,别在文件层面切
食物数据最常见的隐形泄漏是“同一张原图的不同增强版本被分到了训练集和验证集”。如果你用脚本预先离线生成了增强副本,然后按文件随机切 8:2,验证集里大概率有训练图的近亲,指标虚高十几个点是常事。
正确做法是:以原图路径为最小单位做分层切分,保证每个类别在训练/验证/测试里的比例一致,然后再在Dataset里在线做增强。分层切分的骨架如下:
import random from collections import defaultdict from pathlib import Path def split_by_class(root: Path, val_ratio=0.15, test_ratio=0.15, seed=42): rng = random.Random(seed) buckets = defaultdict(list) for p in (root / "train").rglob("*.jpg"): buckets[p.parent.name].append(p) out = {"train": [], "val": [], "test": []} for cls, paths in buckets.items(): paths.sort() # 先排序,保证同 seed 结果可复现 rng.shuffle(paths) n = len(paths) n_val, n_test = int(n * val_ratio), int(n * test_ratio) out["val"] += [(p, cls) for p in paths[:n_val]] out["test"] += [(p, cls) for p in paths[n_val:n_val + n_test]] out["train"] += [(p, cls) for p in paths[n_val + n_test:]] return outseed固定住,任何人重跑都能拿到同一份切分,这一点在多人协作里特别重要——不然后面复现实验时对不上账。paths.sort()不能省,rglob的返回顺序在不同文件系统上不保证一致。类别样本少于 7 张时,三层切分会切出空验证集,这种情况要么合并进相邻类,要么整类划到训练集并在评估里剔除。
2.4 分辨率的选择:288 起步,别一上来就 384
输入分辨率是个容易被忽略但影响巨大的参数。食物识别的难点在细粒度——蛋炒饭和扬州炒饭的差别可能就在几粒配菜上。224 是 ImageNet 的默认值,直接搬过来用在食物上,很多类别会糊成一片。我的经验值是从 288 起步,能比 224 稳定提升 2 到 4 个点,显存和延迟增加可控。
再往上到 384 甚至 512,精度还会涨,但代价是显存翻倍、单张推理延迟接近线性增长。移动端场景建议就停在 288,服务端批量场景可以到 384。这里有个取巧做法:训练用 320 的RandomResizedCrop输出,推理时用 288 的CenterCrop,也就是俗称的 train-test resolution discrepancy,能在不增加推理成本的前提下拿到接近高分辨率的收益,代价是要多跑两三组消融确认它对你的数据有效。
3. 模型选型:ResNet-50 和 MobileNetV3 到底选哪个
食物数据集的规模通常在几万到十几万张之间,这个量级从零训练一个卷积神经网络必然欠拟合,迁移学习是默认答案。但“用预训练”只是起点,主干选型、冻结策略、分类头改造这三步做什么,直接决定你能不能在一周内交出可用模型,还是要跟损失曲线纠缠半个月。
3.1 迁移学习的三个理由,以及什么时候它不成立
食物图像和 ImageNet 的语义重叠度其实不低:锅碗瓢盆、食材纹理、油脂反光这些底层特征通用性很强,ImageNet 预训练权重的浅层卷积核可以直接复用。这是迁移学习成立的第一个基础。第二个是样本效率,同样的数据量,预训练初始化的收敛速度通常是从零训练的 3 到 5 倍。第三个是稳定性,随机初始化在食物这种长尾分布上很容易早期就塌到多数类。
反过来,有两种情况迁移学习收益会打折。一是你的数据域和自然图像差太远,比如全是 X 光片、红外图,那 ImageNet 的 RGB 统计就没意义了;二是类别数非常多且细分程度极高(几百类地方菜系),最后几层需要较大幅度的重学,这时候全量微调比冻结更合适。食物识别基本落在中间地带,所以策略是“先冻后放”。
3.2 主干网络对比:精度、延迟、显存的三方权衡
下面这张表是我在 101 类食物、输入 288、单卡 24G 显存条件下的经验区间,具体数值会随数据规模浮动,但量级关系稳定,可以直接拿来定方向。
| 主干 | 参数量 | 单张推理延迟(GPU, fp32) | 相对精度 | 适用场景 |
|---|---|---|---|---|
| MobileNetV3-Large | 约 5.5M | 很低 | 基准 −3~5 点 | 端侧、边缘设备、需要批量并发 |
| EfficientNet-B0 | 约 5.3M | 低 | 基准 −1~3 点 | 移动端想再挤一点精度 |
| ResNet-50 | 约 25.6M | 中等 | 基准 | 服务端、离线审核、需要稳定可解释 |
| ConvNeXt-T | 约 28M | 偏高 | 基准 +1~2 点 | 数据量够、追求极致精度 |
选型的判断依据不是“哪个精度高”,而是你的延迟预算和部署形态。如果是手机端离线识别,MobileNetV3 几乎是唯一解,量化之后单张能压到几十毫秒;如果是服务端接口,QPS 要求不高,ResNet-50 是最稳的选择,生态成熟、导出 ONNX 少踩坑。EfficientNet 系列精度不错,但它的深度可分离卷积在某些推理后端上会被拆成多个算子,实际延迟反而不如表格里好看,上线前一定要实测。
3.3 分类头改造与两段式冻结
分类头改造有个细节很多人做错:直接把fc换成Linear(in_features, num_classes),没有 dropout。食物数据集的长尾让模型很容易记住少数类的具体样本,加一层Dropout(0.2~0.3)是最便宜的缓解手段。
import torch.nn as nn from torchvision import models def build_model(num_classes: int, arch: str = "resnet50", dropout: float = 0.2): if arch == "resnet50": m = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) in_f = m.fc.in_features m.fc = nn.Sequential(nn.Dropout(dropout), nn.Linear(in_f, num_classes)) head_prefix = ("fc",) elif arch == "mobilenet_v3_large": m = models.mobilenet_v3_large(weights=models.MobileNet_V3_Large_Weights.IMAGENET1K_V2) in_f = m.classifier[3].in_features m.classifier[3] = nn.Linear(in_f, num_classes) head_prefix = ("classifier",) else: raise ValueError(arch) # 初始只训练头部,主干冻结 for name, p in m.named_parameters(): p.requires_grad = name.startswith(head_prefix) return m def unfreeze_backbone(model, lr_backbone, lr_head): """第二阶段调用:解冻主干,用更小的学习率做全量微调""" head_ids = {id(p) for n, p in model.named_parameters() if n.startswith(("fc", "classifier"))} groups = [ {"params": [p for p in model.parameters() if id(p) not in head_ids], "lr": lr_backbone}, {"params": [p for n, p in model.named_parameters() if n.startswith(("fc", "classifier"))], "lr": lr_head}, ] return groupshead_prefix用元组而不是字符串,是因为name.startswith("fc")会误伤fc2之类的名字,虽然 torchvision 的模型里没有,但换成自定义主干时容易出事。unfreeze_backbone返回的是按参数组分好的列表,直接喂给优化器,主干学习率设成头部的十分之一,这是两段式微调的通用做法:第一阶段冻主干只训头 3 到 5 个 epoch,等分类头稳定了再解冻,避免随机初始化的头部在反向传播初期把预训练权重冲坏。
3.4 损失函数:标签平滑比类别权重更值得先试
长尾类别不平衡的第一反应是class_weight,但食物识别里更常见的问题不是“冷门类样本少”,而是“标注本身就有噪声”。101 类公开食物数据集里,大约有 5% 左右的标签是错的,典型的是炒饭/炒面互标、不同做法的同一种食材互标。在这种噪声下,加权会放大错误标签的影响,反而更糟。
我通常先用标签平滑,把目标分布从 one-hot 软化成(1-ε)的均匀分布,ε取 0.05 到 0.1。它能显著抑制模型对单一样本的过度自信,配合CrossEntropyLoss(label_smoothing=0.1)一行就能开。如果平滑之后混淆矩阵里仍然集中在少数几类,再对那几类做针对性处理,比如合并子类,或者对这几对类别单独做二阶段判别。
4. 训练流程:学习率、混合精度与断点续训的工程细节
模型搭好只是开始,食物识别的训练曲线比通用分类更容易出现“前 5 个 epoch 掉得漂亮、第 10 个 epoch 开始震荡”的情况。这一章把训练循环的骨架、学习率调度、混合精度的配置和续训逻辑拆开讲,都是可以直接抄的工程细节。
4.1 训练循环骨架
import torch from torch.cuda.amp import autocast, GradScaler def train_one_epoch(model, loader, optimizer, criterion, scaler, device, epoch): model.train() total, correct, loss_sum = 0, 0, 0.0 for step, (imgs, labels) in enumerate(loader): imgs, labels = imgs.to(device, non_blocking=True), labels.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) # 比 zero_grad() 省一次显存写 with autocast(enabled=scaler is not None): logits = model(imgs) loss = criterion(logits, labels) if scaler is not None: scaler.scale(loss).backward() scaler.unscale_(optimizer) # 先反缩放,否则梯度裁剪失效 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) scaler.step(optimizer) scaler.update() else: loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() loss_sum += loss.item() * imgs.size(0) correct += (logits.argmax(1) == labels).sum().item() total += imgs.size(0) return loss_sum / total, correct / total几个位置容易写错。optimizer.zero_grad(set_to_none=True)比默认写法省显存,在大 batch 下能多塞几个样本。scaler.unscale_(optimizer)必须在clip_grad_norm_之前调用,否则你裁剪的是被放大过的梯度,max_norm=5.0形同虚设,这是血泪经验。non_blocking=True只有在DataLoader开了pin_memory=True时才有意义,两者要配套。
max_norm=5.0对食物分类是偏保守的值。如果你用的是 MobileNetV3 这类带 BN 的轻量网络,梯度爆炸概率不高,可以设 10;如果解冻主干之后出现 loss 尖峰,就先降到 1.0 观察。
4.2 学习率调度:warmup 加 cosine 是稳妥组合
两段式训练的两阶段学习率要分开设。第一阶段只训头,头部学习率 1e-3 到 3e-3 都行,主干不动;第二阶段解冻后,主干 1e-4、头部 1e-3,配 cosine 退火。这里必须加 warmup,因为解冻瞬间主干的 BN 统计量还是旧的,直接上满学习率会让 loss 跳一截。
warmup 一般设 1 到 2 个 epoch,按 step 线性升到目标值。调度器用LambdaLR手写最灵活:
import math from torch.optim.lr_scheduler import LambdaLR def cosine_with_warmup(optimizer, warmup_epochs, total_epochs, steps_per_epoch): warmup_steps = warmup_epochs * steps_per_epoch total_steps = total_epochs * steps_per_epoch def fn(step): if step < warmup_steps: return (step + 1) / max(1, warmup_steps) # 线性升 progress = (step - warmup_steps) / max(1, total_steps - warmup_steps) return 0.5 * (1.0 + math.cos(math.pi * progress)) # 余弦降到 0 return LambdaLR(optimizer, fn)这个调度器按 step 而不是按 epoch 更新,所以每个 batch 后要调一次scheduler.step(),别放在 epoch 循环里。cosine的终点给到 0 有点激进,最小学习率那几轮基本学不动,实际用的时候可以在fn里加个下界,乘 0.01 的偏移量。
4.3 混合精度与 batch size 的配套关系
开 AMP 之后显存大概能省 30% 到 40%,但这部分省下来的显存应该用来放大 batch,而不是降低显卡要求。batch 从 64 提到 128,BN 的统计量更稳,训练曲线明显平顺。学习率要跟着按线性或平方根规则缩放,我一般用线性,batch 翻倍就把学习率翻倍。
需要注意 AMP 和某些算子的兼容性。RandomErasing没问题,但LabelSmoothingCrossEntropy的自定义实现里如果有 fp64 运算,autocast会把它们降下来导致数值不稳。稳妥做法是损失计算放在autocast外面,或者在自定义 loss 上加@torch.cuda.amp.custom_fp32装饰器。
另外,AMP 下权重会保存成 fp32,但优化器状态如果用了GradScaler,续训时必须把 scaler 的 state 一起存下来,否则恢复训练后前几个 step 的缩放系数是错的,loss 会出现一次明显跳变。
4.4 日志与断点:存什么、怎么恢复
断点续训要存的不只是state_dict,还有优化器状态、调度器状态、GradScaler状态、当前 epoch 和 step、以及最好指标。少存一样,恢复后结果就对不上。
def save_ckpt(path, model, optimizer, scheduler, scaler, epoch, step, best_acc): torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "scaler": scaler.state_dict() if scaler else None, "epoch": epoch, "step": step, "best_acc": best_acc, }, path) def load_ckpt(path, model, optimizer=None, scheduler=None, scaler=None): ck = torch.load(path, map_location="cpu") model.load_state_dict(ck["model"]) if optimizer is not None: optimizer.load_state_dict(ck["optimizer"]) if scheduler is not None: scheduler.load_state_dict(ck["scheduler"]) if scaler is not None and ck.get("scaler"): scaler.load_state_dict(ck["scaler"]) return ck["epoch"], ck["step"], ck["best_acc"]日志里至少要有四列:训练 loss、训练 acc、验证 loss、验证 acc。食物识别最容易出现的异常是训练 acc 一直涨、验证 loss 先降后升,这是典型过拟合信号,看到这种曲线就不要等到跑完,直接停。我会同时记录每个 epoch 的验证集 top-3 准确率,top-1 停滞但 top-3 还在涨,说明模型已经学到了正确的候选集,只是排序没学好,这时候调损失函数比加数据更有效。
5. 避坑与排查:食物识别上线前必须走完的五条现象链
这一章的五条,全部来自真实项目里出现过的问题。每一条都按“现象 → 原因 → 解决”写,照着对号入座,能省掉大量盲目调参的时间。
5.1 现象:验证集 92%,手机实拍只有 40%
原因几乎总是域偏移加切分泄漏的双重叠加。一方面验证集来自和训练集同源的图片(同样的相机、同样的构图、同样的光照),模型学到了“背景是木桌就是这道菜”这类伪特征;另一方面做切分时按增强后的文件随机分,同一原图的多个副本进了不同集合。这两个问题会让指标虚高 30 个点以上。
解决分两步。先把切分改成按原图分层切,用 2.3 节的脚本重跑一遍,看指标掉多少,掉得越多说明泄漏越严重。然后在验证集里强制混入一批实拍图:不同手机型号、不同光线、不同角度,哪怕只有每类 5 张。这批实拍图不要参与训练,只做验证。如果混入之后指标掉到 60% 以下,说明模型泛化能力不足,这时候改增强比改网络结构有效得多,重点加强亮度和对比度扰动,再加一点高斯噪声模拟高 ISO 噪点。
5.2 现象:混淆矩阵集中在炒饭、炒面、烩饭三类的互相错分
原因是细粒度区分能力不足,两层因素叠加。一是输入分辨率不够,288 下米粒和面条的形态差异基本被抹平;二是这三类的训练样本里,大量图片的主菜只占画面很小比例,模型看到的主要是餐盘和桌布。
解决上,先做一个成本最低的验证:把这三类的图裁到主体区域重新测一遍。如果准确率明显回升,说明是构图问题,解决方法是训练时把RandomResizedCrop的scale下限从 0.65 提到 0.8,减少“看到大片背景”的样本比例。如果裁完还是分不清,那就是分辨率问题,把训练分辨率提到 384 再跑一轮对比。还有一种情况是标注本身混乱,比如同一张图在不同来源里被标成炒饭和烩饭,这种只能人工抽检清洗,模型再怎么调也解决不了。
5.3 现象:训练 loss 前几个 epoch 正常,第 8 个 epoch 突然变成 NaN
原因按概率排序:解冻主干时学习率设太大、AMP 的梯度缩放系数溢出、数据里混进了全黑或全白的异常图。解冻那一步最常见,尤其是从 1e-3 直接切到主干 1e-3,等于没有任何缓冲。
排查顺序是固定的。先在loss.backward()之后加一行检查torch.isnan(loss),定位到具体 step;再看这个 step 对应的 batch 里有没有异常图;如果图正常,就把主干学习率降到 1e-4 甚至 5e-5 重跑。AMP 溢出的典型特征是 loss 不是 NaN 而是突然变成很大的数,这时候把GradScaler(init_scale=2**12)的初始值调小,或者在scaler.step之前加torch.isfinite(loss)判断跳过这个 step。
5.4 现象:训练集 98%,验证集 61%,加大 dropout 没用
这是过拟合,但很多人第一反应是加正则,结果越加越差。食物识别的过拟合很多时候不是模型容量太大,而是数据增强太弱、有效样本数太少。101 类每类 1000 张看着不少,但如果这 1000 张是同源图片,实际多样性可能只相当于 200 张。
正确的处理顺序是:先加强增强(提高RandomErasing概率到 0.4、加RandAugment的轻度配置),再考虑 mixup 或 cutmix,最后才动 dropout 和权重衰减。RandAugment在食物上要慎用,它的posterize和solarize会破坏颜色特征,建议只保留rotate、shear、translate三类几何变换。如果加了增强还是过拟合,那就是数据量真的不够,该去补数据而不是继续调模型。
5.5 现象:离线测试正常,接口 QPS 只有个位数
原因是推理链路里做了多余的事。常见的有:每张图单独transforms一次且在多进程里重复初始化、模型没设eval()导致 BN 走训练分支、没用torch.inference_mode()还在建计算图、预处理在 CPU 上串行做而 GPU 空转。
解决思路是把预处理和推理流水线化。单张请求场景用torch.inference_mode()包住前向,能省 10% 到 20% 延迟;批量场景把图片解码和 resize 放到DataLoader的多 worker 里,GPU 只负责前向。还有一个容易忽略的点:model.eval()不只是关 dropout,它还会冻结 BN 的 running stats,忘了调这一行,同一张图两次推理结果都不一样,排查起来非常折磨。
6. 从训练到上线:ONNX 导出、量化与一次 200 张图的批量验收
模型训完之后,我一般不会直接在 PyTorch 里跑服务,而是先导出 ONNX,用 onnxruntime 做一次独立的精度对齐,再做 int8 量化评估掉点。这一步能提前暴露大部分部署问题,比上线后回滚便宜得多。
6.1 导出与预处理对齐
import torch model.eval().to("cpu") dummy = torch.randn(1, 3, 288, 288) torch.onnx.export( model, dummy, "food_cls.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, opset_version=17, do_constant_folding=True, )dynamic_axes必须写,否则导出的模型 batch 维度写死成 1,批量推理直接报错。opset_version=17是目前算子覆盖和运行时兼容性比较平衡的选择,再高有些边缘设备上的 runtime 还不支持。导出前model.eval()和.to("cpu")两步都不能省,前者影响 BN 和 dropout 的行为,后者影响导出图里是否带 CUDA 算子。
导完之后一定要做数值对齐:同一批 32 张图,分别用 PyTorch 和 onnxruntime 推理,比较两组 logits 的最大绝对误差,正常应该小于 1e-4。如果误差在 1e-2 量级,通常是预处理不一致——最常见的是 resize 的插值方式不同(PyTorch 默认 bilinear,有些运行时默认 nearest),以及归一化的均值方差顺序搞反。
6.2 量化评估与验收脚本
int8 静态量化需要一份校准集,从训练集里分层抽 200 到 500 张就够。量化后精度掉 2 个点以内可以接受,掉 5 个点以上说明某些层的激活值分布太宽,要用 per-channel 量化或者把第一层和最后一层排除在外。
验收脚本我只做三件事:整体 top-1/top-3、每类召回率、以及一份按置信度排序的低分样本清单。第三件事最有用,低分样本里混着的往往是标注错误或采集异常,人工过一遍就能发现下一轮该补什么数据。这张表是我固定会看的验收指标:
| 指标 | 通过线 | 不通过时的动作 |
|---|---|---|
| 整体 top-1 | 与 PyTorch 版差距 ≤ 2 点 | 检查预处理对齐 |
| 最差 5 类召回 | ≥ 50% | 补数据或合并子类 |
| 单张延迟(CPU) | ≤ 预算的 80% | 降分辨率或换轻量主干 |
| 低置信度样本占比 | ≤ 10% | 查标注噪声 |
有一次我没做这个批量验收,直接拿验证集指标上线,结果线上跑了三天才发现某个冷门类别的召回是 0——那类的样本在切分时全被分到了训练集,验证集里一张都没有,指标自然好看。从那以后我每次导出模型,都会强制走一遍这 200 张图的分层验收,哪怕时间再紧也不跳过。希望这套流程能帮到你,少走我踩过的那些弯路。
本文还有配套的精品资源,点击获取