上周有位同行拿着他第一个图像识别项目来找我,训练脚本跑完,训练集准确率99.2%,验证集卡在61%死活不动。他把学习率砍半、batch size翻倍、优化器从Adam换成SGD,折腾两天,曲线纹丝不动。我让他把验证集判错的样本导出看一眼,三分之一以上的标签本身就是错的——爬数据的时候把相似类别的文件夹搞混了。这个场景我见得太多了:用PyTorch做图像识别,让代码跑起来大概二十分钟,真正吃掉你时间的,全是跑通之后才暴露出来的问题。
这篇东西写给两类人。一类是刚把卷积网络、反向传播这些概念啃完,想把纸上的东西变成一个能真正识别图片的模型,但不知道第一个工程该从哪儿下手;另一类是已经跑通过几个demo,模型能训也能出结果,但精度总是差一口气,说不清问题出在数据、模型还是训练策略上。我把图像识别这条链路拆成数据管线、模型选型、训练循环、故障排查、推理导出几个环节,每个环节讲清楚为什么这么做、不这么做会怎样,代码都是可以直接抄走改改就能跑的。卷积核、感受野这类基础概念我只在必要的时候提一句,重点放在工程判断上——哪些选择背后有明确的理由,哪些“惯例”其实是可以打破的。
1. 从一张图片到一份概率分布:图像识别任务的本质拆解
1.1 模型到底在学什么东西
一张RGB图片进入网络之前,它就是一个形状为(3, H, W)的浮点数张量,每个位置是0到1之间的像素强度(或者归一化之后的负数到正数)。图像识别模型要做的事,本质上是把这个高维张量压缩成一个长度为类别数的向量,再让正确类别那一维的数值最大。听起来简单,但从三百万个像素值映射到十个数字,中间那层映射关系没有任何人能手工写出来,只能靠数据去拟合。
卷积层解决的是“怎么有效地看”。如果直接用全连接层接在原始像素上,一张224×224的图展开就是15万个输入,第一层就要几千万参数,而且这层学到的东西换个位置就失效了——猫出现在左上角和出现在右下角,对全连接层来说是两组完全不同的输入。卷积用同一个卷积核在整张图上滑动,参数共享,位置无关,这是图像任务里最重要的先验假设。浅层卷积学到的是边缘、角点、颜色块;中层把这些组合成纹理和简单部件,比如毛发、眼睛轮廓;深层再拼成整体结构。这个层级特征的直觉,是2012年之后深度学习在视觉领域翻身的根本原因。
分类头做的事是“下判断”。主干网络输出一个特征向量,经过全局池化压成一维,再过一个线性层得到logits,softmax转成概率,交叉熵损失衡量预测分布和真实标签的差距。这里有个细节值得留意:softmax之前那一步叫logits,训练时交叉熵损失的输入应该是logits而不是softmax之后的概率,因为nn.CrossEntropyLoss内部已经做了log-softmax,你把softmax结果再喂进去,等于重复计算,梯度会被压缩得很小,收敛速度明显变慢。这个错误我在新人代码里见过不下十次。
1.2 为什么绝大多数场景不该从零写CNN
经常有人问:我想做一个识别X的模型,是不是要先设计一个网络结构?我的回答基本是:先去看你的数据有多少张。
从零训练一个卷积网络,需要的数据量级和算力开销都很可观。教科书上的经典数据集动辄上百万张图片、上千个类别,普通项目根本够不着这个规模。如果你手里只有几千张图、十个类别,从零训练的结果通常是训练集准确率冲到98%,验证集在60%上下晃,模型把训练样本背下来了,泛化能力约等于零。
预训练权重的价值就在这儿。别人在海量图片上训出来的卷积核,已经学会了怎么提取边缘、纹理、形状这些通用特征,这些能力对绝大多数视觉任务都成立。你要做的只是在这个基础上,把最后的分类层换成自己的类别数,再微调一下。这种做法叫迁移学习,它的效果在中小数据集上比从零训练高出一大截,训练时间也从几天压缩到几十分钟。
| 策略 | 适用数据量 | 训练成本 | 典型精度 | 调参难度 |
|---|---|---|---|---|
| 从零训练 | 每类数千张以上 | 高,需要多卡 | 数据充足时可超过微调 | 高,对初始化、学习率极敏感 |
| 全网络微调 | 每类几百到几千张 | 中等 | 高,绝大多数场景首选 | 中等 |
| 只训分类头(特征提取) | 每类几十到几百张 | 低,CPU 也能跑 | 中等,受限于特征质量 | 低 |
| 微调 + 数据增强 + 正则 | 数据少且分布偏 | 中等 | 小数据集上通常最优 | 中等偏高 |
选哪一档,本质上是在“模型容量”和“数据量”之间做平衡。数据越少,越应该限制模型的自由度,冻结更多层、加更强的正则;数据够多,才轮到放开全网络去拟合更细的特征。
2. 数据管线才是决定成败的那一环
2.1 目录结构决定了你要不要自己写Dataset
PyTorch的torchvision.datasets.ImageFolder用起来最省事,但它对目录结构有硬性要求:根目录下每个子文件夹是一个类别,文件夹名就是类名,里面直接放图片。
dataset/ ├── train/ │ ├── cat/ │ │ ├── 001.jpg │ │ └── 002.jpg │ └── dog/ │ ├── 001.jpg │ └── 002.jpg └── val/ ├── cat/ └── dog/只要结构对得上,三行代码就能拿到数据集和类别映射。但现实里的数据很少这么规整,可能是CSV里存了文件名和标签两列,也可能是标签是多标签形式(一张图同时属于“户外”和“夜景”),这时候就得自己写Dataset。
from PIL import Image from torch.utils.data import Dataset class MyDataset(Dataset): def __init__(self, df, img_dir, transform=None): self.df = df.reset_index(drop=True) self.img_dir = img_dir self.transform = transform self.classes = sorted(df["label"].unique()) self.cls2idx = {c: i for i, c in enumerate(self.classes)} def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img = Image.open(f"{self.img_dir}/{row['file']}").convert("RGB") if self.transform: img = self.transform(img) return img, self.cls2idx[row["label"]]这里有两个地方容易出错。一个是convert("RGB"),很多人会漏掉。灰度图读进来是单通道,RGBA图是四通道,直接送进期望三通道的卷积层会报维度错误,或者在某些情况下被静默广播成奇怪的东西。另一个是类别映射必须固定顺序,训练时用sorted()排出来的索引,推理时必须用完全一样的顺序,否则模型输出的第0类和你以为的第0类对不上,准确率会莫名其妙地接近随机。
__getitem__只负责返回单个样本,把多个样本拼成batch是DataLoader的事,它内部会调用默认的collate_fn,把一批图片堆叠成(B, C, H, W)。如果你的模型需要额外的输入(比如多模态任务要同时返回文本),才需要自定义collate_fn,普通图像分类不用管。
2.2 transform链的顺序陷阱
数据增强是提升泛化最便宜的手段,但transform的排列顺序有讲究,写反了会直接报错或者悄悄降精度。
from torchvision import transforms train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])Normalize必须放在ToTensor后面,这是硬性约束。ToTensor做三件事:把PIL图像或numpy数组转成张量、把通道维度从最后挪到最前、把像素值从0-255缩放到0-1。Normalize只接受张量输入,放在前面会直接抛异常。几何变换(裁剪、翻转、旋转)都接受PIL图像,应该放在ToTensor之前,因为对PIL做旋转比在张量上做插值实现效率更高,而且不会引入额外的数值误差。
训练集和验证集必须用两套transform,这一点没有例外。验证集的作用是估计模型在没见过的数据上的表现,如果你给它套上随机裁剪和随机翻转,同一个样本每次评估的结果都不一样,指标会抖得没法看。更隐蔽的一种错误是给验证集也加了RandomHorizontalFlip——在多数自然图像任务里翻转影响不大,但在文字识别、数字识别、方向敏感的工业缺陷检测里,翻转会让标签彻底错掉,模型学到的是一种自相矛盾的映射。
关于归一化参数,mean=[0.485, 0.456, 0.406]这组数字是沿用多年的经验值,配预训练权重时应该保持一致,因为别人的权重就是在这个输入分布上训出来的。如果你的数据分布偏离得特别远(比如全是医学影像,大部分像素接近黑色),继续用这组参数会让有效信号被压缩得很小,这时候应该统计自己训练集的均值和标准差重新设置。
2.3 DataLoader参数对训练效率的实际影响
from torch.utils.data import DataLoader train_loader = DataLoader( train_set, batch_size=32, shuffle=True, num_workers=8, pin_memory=True, drop_last=True, persistent_workers=True, ) val_loader = DataLoader( val_set, batch_size=64, shuffle=False, num_workers=8, pin_memory=True, )num_workers控制读数据的子进程数。设成0意味着所有图片解码在主进程里串行完成,GPU等数据的时间会非常明显,尤其是大分辨率图片。一般的经验是从CPU核数的一半起步往上试,8到16之间覆盖大多数场景,调得过高反而会因为进程调度和内存拷贝的开销拖慢速度。Windows上有个经典坑:多进程启动方式不同,num_workers > 0时必须把训练入口包在if __name__ == "__main__":里面,否则会无限递归创建进程,机器直接卡死。
pin_memory=True配合CUDA使用,把数据锁在页锁定内存里,从CPU到GPU的拷贝会快一些。drop_last=True在训练时建议开着,因为BatchNorm依赖batch内的统计量,最后一个不满的batch如果只有一两张图,统计出来的均值和方差不稳定,会污染running stats。验证集可以不开,反正不影响参数更新。
batch_size的选择不只是显存问题。batch太小(比如2、4),BN层的统计噪声很大,训练过程会明显抖动;batch太大,梯度的随机性不足,容易陷进尖锐的极小值,泛化反而不如小batch。从32起步,显存够就试64、128,同时把学习率按比例往上调,这条经验规则在大模型上经常被打破,但在中小规模卷积网络上大体靠谱。
3. 模型搭建:迁移学习与自建网络的取舍判断
3.1 冻结策略:冻哪几层,为什么
拿到一个预训练主干,最直接的问题是:哪些层的参数该放开。
整网微调(所有参数可训练)精度上限最高,但对数据量和显存要求也高,小数据集上很容易过拟合。只训最后一层(特征提取模式)最稳,训练快,缺点是主干特征完全固定,如果你的任务和预训练数据的差异比较大(比如从自然图像迁移到显微镜图像),特征可能根本不适用。折中方案是冻结前面的层,放开后面几层。
import torch.nn as nn from torchvision import models def build_model(num_classes, unfreeze_from="layer3"): model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) frozen = True for name, child in model.named_children(): if name == unfreeze_from: frozen = False for p in child.parameters(): p.requires_grad = not frozen in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes), ) return model这段逻辑是:named_children()按网络深度顺序遍历,从layer3开始解冻,后面的layer4、avgpool、fc全部可训练。选择哪个位置解冻,取决于你的数据量和与预训练域的相似度。数据相似且量大,可以只冻conv1和layer1;数据差异大且量小,只训分类头更保险。
这里有一个必须单独拎出来说的坑:requires_grad=False并不会让BatchNorm层停止更新。BN层有两类状态,一类是可学习参数(weight、bias),一类是缓冲区(running_mean、running_var)。requires_grad=False只管前者。只要模型整体处于train()模式,BN就会用当前batch的统计量去更新running stats,你辛辛苦苦冻结的主干,其归一化统计量正在被你的小数据集一点点改掉。
如果只想训分类头,标准做法是在每个epoch的model.train()之后,把主干里的BN手动切回eval():
def set_bn_eval(module): if isinstance(module, nn.modules.batchnorm._BatchNorm): module.eval() model.train() model.apply(set_bn_eval) # 放在每个epoch开始处这个函数会把所有BN置成eval模式,用预训练阶段积累的running stats做归一化,不更新。分类头里如果也有BN(通常没有),需要单独处理。
3.2 替换分类头的三种写法与各自的适用场景
最常见的是直接改fc:
in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes)问题在于in_features必须查出来,不能凭空写。resnet18/34/50都是2048(注意resnet18和34其实是512,容易记混),efficientnet系列、vit系列各不相同。写死一个数字,换个主干就报维度不匹配。
第二种是用nn.Sequential包一层,插入Dropout做正则,这是我最常用的形态,因为在数据量有限时Dropout带来的收益很稳定。
第三种是利用torchvision的create_feature_extractor做部分前向,只取中间某层的输出。这种方式在学习率调度、特征可视化、多尺度特征融合时有用,普通分类不必折腾。
替换分类头之后,新加的层是随机初始化的,如果直接用一个较大的学习率去训,随机初始化的梯度会很大,可能会把主干已经学好的特征冲垮。比较稳妥的做法是分参数组设置学习率:主干用小学习率(比如1e-4),新加的分类头用大一些的(1e-3)。PyTorch的优化器支持传参数组,这一点在微调时非常实用。
3.3 输入尺寸和预训练权重是绑定的
预训练权重是在固定分辨率上训出来的,多数是224×224。你当然可以改成320或者128,网络不会报错,因为卷积是尺寸无关的——但精度会有变化。
往大改,对小目标和细粒度分类通常有帮助,代价是显存和计算量按面积增长。往小改,速度快了,但如果原模型在浅层依赖了较高分辨率的细节,性能会掉。ViT类模型对输入尺寸更敏感,因为位置编码是按patch数量固定的,改了尺寸必须做插值,代码里不显式处理的话会直接报shape错误。
我的做法是:默认保持224不动,只有在验证集上明确看到分辨率是瓶颈(比如混淆矩阵显示细分类别之间大量互错)时,才成组地试验320或384,并且保证训练和验证用同样的尺寸。
4. 训练循环里那些不写就出事的东西
4.1 优化器和学习率的搭配逻辑
卷积网络微调,优化器基本就两个选择:SGD带动量,或者Adam/AdamW。
SGD + momentum=0.9 是视觉领域的经典配置,收敛曲线平滑,最终泛化通常略好,缺点是学习率对结果影响很大,选错了要么不收敛要么震荡。微调场景常用1e-3到1e-2。AdamW的自适应学习率让它对超参不敏感,2e-4到1e-3起步一般都能跑,适合不想在调参上花时间的场景。
import torch backbone_params, head_params = [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue (head_params if name.startswith("fc.") else backbone_params).append(p) optimizer = torch.optim.SGD([ {"params": backbone_params, "lr": 1e-3}, {"params": head_params, "lr": 1e-2}, ], momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)weight_decay的取值值得说一句。它本质上是L2正则,抑制参数变大。微调时用1e-4到1e-5比较常见,设太大模型会欠拟合,表现为训练集准确率也上不去。如果你的模型很快过拟合,先把weight_decay和Dropout调一调,再考虑加数据。
学习率调度器我用余弦退火居多,它前期保持较大步长探索,后期逐渐衰减到接近0,比阶梯式下降平滑。要注意scheduler.step()的调用位置:在PyTorch 1.1之后,CosineAnnealingLR和StepLR都是按epoch计数的,应该放在每个epoch训练和验证结束之后、下一轮开始之前。如果误放在batch循环里,学习率会衰减得飞快,几个epoch就归零了,模型提前停止学习。
4.2 训练验证双轨与模型保存策略
一个完整的epoch长这样:
criterion = nn.CrossEntropyLoss(label_smoothing=0.1) for epoch in range(epochs): model.train() model.apply(set_bn_eval) # 若冻结主干 running_loss, correct, total = 0.0, 0, 0 for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() optimizer.zero_grad() logits = model(imgs) loss = criterion(logits, labels) loss.backward() optimizer.step() running_loss += loss.item() * imgs.size(0) correct += (logits.argmax(1) == labels).sum().item() total += imgs.size(0) model.eval() val_correct, val_total, val_loss = 0, 0, 0.0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.cuda(), labels.cuda() logits = model(imgs) val_loss += criterion(logits, labels).item() * imgs.size(0) val_correct += (logits.argmax(1) == labels).sum().item() val_total += imgs.size(0) acc = val_correct / val_total if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "best.pth") scheduler.step()label_smoothing=0.1是个便宜的提分手段,它把硬标签从[1,0,0]变成[0.9,0.033,0.033],防止模型对训练样本过度自信,在小数据集上通常能带来一到两个点的提升。
保存时用state_dict()而不是整个模型对象,前者只存参数字典,体积小、跨代码版本兼容性好,后者用pickle序列化整个类定义,代码结构调整之后就加载不回来了。
model.eval()和torch.no_grad()是两回事,都不能省。前者切换BN和Dropout的行为模式,后者关掉自动求导省内存,只写一个都会出问题。
4.3 混合精度与显存吃紧时的应对
显存不够是最常见的现实约束,有三条路可以走。
第一条是混合精度训练。在支持张量核心的显卡上,前向用float16、参数更新用float32,显存占用能降三到四成,速度提升明显:
scaler = torch.cuda.amp.GradScaler() for imgs, labels in train_loader: optimizer.zero_grad() with torch.cuda.amp.autocast(): logits = model(imgs) loss = criterion(logits, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler的作用是给损失乘一个放大系数,避免float16下梯度下溢成0,更新参数前再除回去。这一套不能省,直接去掉scaler只用autocast,训练十几个epoch之后loss可能出现NaN。另外注意,新版PyTorch把torch.cuda.amp迁移到了torch.amp,参数需要显式指定设备类型,老代码跑在新版本上会看到弃用警告,不影响运行但建议跟进。
第二条是梯度累积,用小的micro batch跑多次再统一更新,等效于大batch:
accum_steps = 4 for i, (imgs, labels) in enumerate(train_loader): with torch.cuda.amp.autocast(): loss = criterion(model(imgs.cuda()), labels.cuda()) / accum_steps scaler.scale(loss).backward() if (i + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()注意loss要除以累积步数,否则梯度被放大对应倍数,等于把学习率乘上去了。
第三条是降分辨率或换更小的主干,这是最后手段,前面两条能解决就别动模型本身。
5. 准确率卡住不动时的排查链路
5.1 排查顺序:先数据,后模型
指标不动的时候,人的本能是去改模型结构、换优化器、调学习率——这是效率最低的方向。我自己的固定排查顺序是这样的。
第一步,抽20张训练图连同标签打印出来看。不是抽查,是每一类都抓两三张。标签错的样本在预处理阶段就会被噪声化,模型学到的是一种自相矛盾的映射,后面所有调参都是在跟噪声较劲。前面提到的那位同行,就是卡在这一步没做。
第二步,检查训练集和验证集之间有没有重叠。图片同名不同路径、同一张图做了不同的裁剪分别放进两个集合,这类泄漏会让验证指标虚高,掩盖真实问题。反过来,如果两个集合的划分是按时间或按批次切的,而数据存在明显的分布漂移,验证集的表现会显著低于训练集,看起来像过拟合,实质是分布不一致。
第三步,确认预处理在两个集合上的一致性。归一化的均值方差是否相同、通道顺序是否一致(OpenCV读进来是BGR,PIL是RGB,混用会让颜色通道错位,模型照样能训,但精度会莫名低几个点)。
第四步,用一个极小的子集做健全性检查。取20张图,关掉所有数据增强和正则,让模型过拟合这20张。如果训练几十轮之后准确率到不了100%,那说明代码里有结构性错误——梯度没回传、优化器没更新参数、标签和输出维度错位,这种时候继续调超参毫无意义。
5.2 过拟合和欠拟合的区分信号
训练和验证的曲线形态基本能定位问题:
| 训练准确率 | 验证准确率 | 判断 | 处理方向 |
|---|---|---|---|
| 高(>95%) | 低且差距大 | 过拟合 | 加数据增强、加Dropout/weight_decay、冻结更多层、早停 |
| 中低 | 与训练接近但都低 | 欠拟合 | 放开更多层、增大模型、提高学习率、减少正则 |
| 高 | 停滞在某个值 | 数据或标签问题 | 回到5.1排查 |
| 波动剧烈 | 波动剧烈 | 学习率过大或batch过小 | 降学习率、增大batch、加warmup |
| 训练loss不降 | 不降 | 代码错误 | 检查梯度、检查损失函数 |
欠拟合在小数据集微调时反而不常见,更常见的是“看起来欠拟合,实际是学习率太小”。冻结主干之后,可训练参数只剩分类头那几万个,如果学习率还按从头训练的1e-2走,前期几乎不动,等到曲线开始抬头,epoch已经用完了。我一般会在前3到5个epoch做warmup,学习率从十分之一线性升到目标值,让随机初始化的分类头平稳过渡。
5.3 类别不平衡时准确率的欺骗性
十类数据,其中一类占了70%,模型把所有样本都预测成这一类,准确率就有70%。这个数字看起来还能接受,实际上模型什么都没学到。
这时候要看混淆矩阵和每一类的召回率。sklearn.metrics里的classification_report能一次性给出precision、recall、f1,注意看macro avg和weighted avg的差距,差距越大说明长尾问题越严重。
处理上有三条路。最直接的是给损失函数加类别权重:
weights = torch.tensor([1.0, 5.0, 3.0], device="cuda") # 按类别频率反比设置 criterion = nn.CrossEntropyLoss(weight=weights, label_smoothing=0.1)权重一般设成类别频次的倒数再归一化。第二种是用WeightedRandomSampler在采样阶段做平衡,让每个batch里各类比例接近均匀,这种做法对BN统计量更友好,因为batch内的分布被拉平了。第三种是针对少数类做定向增强,比如额外的裁剪、混合样本,但这需要谨慎,增强过头会让少数类的特征分布失真。
选哪种取决于不平衡的程度。轻度不平衡(最大类不超过最小类的5倍)用损失加权就够;重度不平衡(几十倍以上)建议采样和加权一起上,同时把评估指标彻底换成per-class召回率,别再盯着总体准确率看。
6. 从训练完到能用的那一步:推理与导出
6.1 eval和no_grad一个都不能少
训练完的模型直接拿来做推理,最常见的错误是忘了model.eval()。这时候Dropout还在随机丢弃神经元,BN还在用当前这张图的统计量做归一化(单张图的均值方差毫无意义),同一个输入连续跑两次能得到不同的结果。这个bug非常隐蔽,因为预测结果“看起来”还挺合理,只是精度比验证集低一截。
@torch.no_grad() def predict(model, img_path, classes, device="cuda"): model.eval() tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) img = Image.open(img_path).convert("RGB") x = tf(img).unsqueeze(0).to(device) logits = model(x) prob = logits.softmax(dim=1)[0] conf, idx = prob.max(0) return classes[idx.item()], conf.item()unsqueeze(0)补上batch维度,模型永远期望四维输入,单张图也不例外。model.eval()是持久状态,调用一次之后一直有效,但如果在同一个脚本里又要训练又要推理,每次切换都得记得调回来。
6.2 单张推理里那些不起眼但会咬人的细节
convert("RGB")必须写。手机拍的图经常带EXIF旋转信息,PIL默认不应用旋转,读进来可能是躺倒的,而训练数据是正的,模型看到的是一个它没见过的姿态。稳妥做法是用ImageOps.exif_transpose()先处理一次。
推理时的预处理必须和验证集完全一致。Resize(256)+CenterCrop(224)这套组合是标准做法,先按短边缩到256保持长宽比,再中心裁出224。有人图省事直接Resize((224,224)),会把图片压扁变形,如果训练时用的是RandomResizedCrop(它保持了一定比例范围),推理时的强制拉伸会造成训练推理不一致,精度掉几个点很正常。
置信度低的时候要有兜底逻辑。softmax输出的最大值低于某个阈值(比如0.5)时,与其硬给一个答案,不如返回“无法判断”。在工业质检、医疗辅助这类场景里,拒识比错判的代价低得多。
6.3 导出成别的格式时要注意什么
模型要上线到没有Python环境的设备上,就得导出。TorchScript是最省事的一种:
model.eval() dummy = torch.randn(1, 3, 224, 224).cuda() traced = torch.jit.trace(model, dummy) traced.save("model_scripted.pt")trace的原理是记录一次前向的实际执行路径,所以模型里不能有依赖输入内容的分支(比如根据输入走不同的if分支),有这种逻辑就得用torch.jit.script,它对代码结构有额外约束。
导出ONNX也很常见,好处是跨框架通用:
torch.onnx.export( model, dummy, "model.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, opset_version=13, )dynamic_axes不写的话,导出的模型会把batch维度固定成1,之后只能一张一张推理。导出之后一定要用同一批输入分别跑原模型和导出模型,逐元素比对输出差异,浮点误差在1e-5以内算正常,差得多说明某个算子在转换时被近似了。
我现在的习惯是,任何一次训练脚本跑出可用的权重,先存一份state_dict作为唯一可信源,之后再从这个文件派生TorchScript、ONNX或者量化版本,绝不直接在训练进程里的模型对象上做导出。这样能保证导出的东西和你验证过的东西是同一个。
说完这些,再补一个实际踩过的坑。有次模型在验证集上稳定在92%,部署到服务上只有85%出头,折腾了一整天,最后发现线上服务用的图像解码库和本地的色彩空间转换不一样,同样的JPEG解出来的像素值有微小偏差,叠加归一化之后被放大了。从那之后我养成了一个习惯:上线前把线上环境解码出来的张量dump出来,和本地预处理的结果做像素级比对,这个检查只要十分钟,能省掉一整天的扯皮。