ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SeaFormer实战:6M参数轻量级Transformer图像分类训练全攻略

2026/9/28 23:10:36 拓冰建站 浏览量
SeaFormer实战:6M参数轻量级Transformer图像分类训练全攻略 简介SeaFormer实战资源包是一套面向轻量级图像分类任务的完整工程目标读者为接触过PyTorch、希望了解Transformer轻量化设计并落地移动端的开发者。资源从SeaFormer_T这一轻量级模型出发围绕分类任务给出可复现的训练流程和数据增强方案包括transforms常规增强、CutOut、MixUp、CutMix、混合精度训练、梯度裁剪、DP多卡并行、余弦退火调整学习率、EMA模型参数滑动平均以及Grad-CAM热力图可视化等关键操作。同时附加训练与验证曲线绘制、ACC1与ACC5指标统计、验证集测评报告和测试脚本编写示例帮助读者掌握从数据准备、模型训练到评估测试的完整思路。压缩包内共2451个文件其中以2436张运行过程图像、Python脚本、模型权重和配置文件为主整体大小约768MB目录结构清晰便于按需取用。目前已有1014人学习浏览适合希望快速启动SeaFormer项目并借鉴工程化训练经验的中级算法工程师。1. SeaFormer实战6M参数的轻量级Transformer图像分类链路做移动端图像分类选模型一直是最纠结的一步CNN里轻量的精度不够ViT系的Transformer又普遍偏大。SeaFormer在中间找到一个位置——压缩轴向注意力配合细节增强最小的SeaFormer_T只有6M参数计算量压到了移动端能接受的范围内。这篇实战笔记把用SeaFormer跑图像分类的完整流程拆开讲class.json怎么组织数据、CutOut/MixUp/CutMix怎么落地、EMA和混合精度在训练里解决什么问题、余弦退火参数怎么定、Grad-CAM怎么看。适合已经会PyTorch基本流程、想换轻量Transformer模型但不想从零摸代码的读者。2. 数据准备与增强从class.json到MixUp/CutMix的完整配方2.1 先读class.json类别映射决定Dataset怎么组织打开这份资源第一步我建议先把 class.json 打开看一眼。它决定了整个训练和测试阶段怎么读标签。常见的结构是从 0 开始的数字索引到类别名的映射类似{0: cat, 1: dog, 2: bird}。我见过不少新手上来就把数据集目录和 JSON 里的顺序弄反训练出来报告全是错的。# datasets.py import json import os from PIL import Image from torch.utils.data import Dataset class SeaFormerDataset(Dataset): 轻量级图像分类的标准Dataset。 约定目录结构 data/train/ cat/1.png dog/2.png data/val/ cat/3.png def __init__(self, root_dir, class_fileclass.json, transformNone): with open(class_file, r, encodingutf-8) as f: class_dict json.load(f) # 双向映射训练时用类名找索引验证报告用索引找类名 self.idx_to_class {int(k): v for k, v in class_dict.items()} self.class_to_idx {v: int(k) for k, v in class_dict.items()} self.samples list() for cls_name in sorted(os.listdir(root_dir)): cls_dir os.path.join(root_dir, cls_name) if not os.path.isdir(cls_dir): continue if cls_name not in self.class_to_idx: continue cls_idx self.class_to_idx[cls_name] for img_name in sorted(os.listdir(cls_dir)): if img_name.lower().endswith((.jpg, .jpeg, .png)): self.samples.append((os.path.join(cls_dir, img_name), cls_idx)) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, label self.samples[idx] img Image.open(img_path).convert(RGB) if self.transform: img self.transform(img) return img, label这段代码里我做了两个映射idx_to_class是给验证报告和 test.py 用的输出类别名时需要它class_to_idx是给训练读标签用的。文件名的.convert(RGB)不要省灰度图、单通道 PNG 在后续 Normalize 时最常翻车。参数上root_dir指向按类别分好的目录class_file默认取资源里带的 class.json。目录里如果有多余的隐藏文件os.path.isdir会把它过滤掉不会影响样本列表。2.2 基础增强与CutOut先让模型不缺数据SeaFormer 的输入是 224×224和大多数视觉 Transformer 保持一致。训练集增强我会先上三件套随机裁剪、水平翻转、颜色抖动。很多人忽视 ColorJitter但在真实数据集上它对提升鲁棒性非常明显尤其是室外拍摄的图片光照和色偏几乎不可避免。# configs.py from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0), ratio(0.75, 1.333)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.4, contrast0.4, saturation0.4), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform 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]), ])裁剪的 scale 我习惯从 0.6 起而不是默认的 0.08因为 224 分辨率下塞入过多背景对轻量模型是负担等于强迫它去学背景特征。验证集用 Resize(256)CenterCrop(224)保持和大多数预训练模型一致的评测方式。如果数据集本身很小再叠加 CutOut——在输入上随机挖掉几块正方形区域强迫模型不依赖局部纹理。# augmentation.py import numpy as np import torch class Cutout: 在图像上随机挖掉n_holes块正方形区域。 def __init__(self, n_holes1, length32): self.n_holes n_holes self.length length def __call__(self, img): h, w img.size(1), img.size(2) mask torch.ones((h, w), dtypetorch.float32) for _ in range(self.n_holes): y np.random.randint(h) x np.random.randint(w) y1 max(0, y - self.length // 2) y2 min(h, y self.length // 2) x1 max(0, x - self.length // 2) x2 min(w, x self.length // 2) mask[y1:y2, x1:x2] 0 img img * mask return imgCutOut 的参数按输入分辨率调整224×224 的输入length32 相当于挖掉约 2% 面积肉眼几乎看不出来但正则效果还在如果数据集特别干净可以加大到 48。把 Cutout 插在 ToTensor 之后、Normalize 之前使用注意它的输入是 Tensor不是 PIL Image。2.3 MixUp/CutMix用collate_fn把增强塞进DataLoaderMixUp 和 CutMix 这类样本混合增强不适合放进 torchvision 的 Compose因为要在 batch 层面打乱配对所以我习惯把它们写在 collate_fn 里对 DataLoader 透明。这样训练循环不用改任何代码只需要在构造 DataLoader 时换一下 collate_fn 参数。# augmentation.py def mixup_collate(batch, alpha0.2): images, labels zip(*batch) images torch.stack(images, 0) labels torch.tensor(labels) lam np.random.beta(alpha, alpha) index torch.randperm(images.size(0)) mixed_images lam * images (1 - lam) * images[index] return mixed_images, labels, labels[index], lam def rand_bbox(size, lam): CutMix里按比例算裁剪区域。 W, H size[2], size[3] cut_rat np.sqrt(1.0 - lam) cut_w int(W * cut_rat) cut_h int(H * cut_rat) cx np.random.randint(W) cy np.random.randint(H) bbx1 np.clip(cx - cut_w // 2, 0, W) bby1 np.clip(cy - cut_h // 2, 0, H) bbx2 np.clip(cx cut_w // 2, 0, W) bby2 np.clip(cy cut_h // 2, 0, H) return bbx1, bby1, bbx2, bby2 def cutmix_collate(batch, alpha0.2): images, labels zip(*batch) images torch.stack(images, 0) labels torch.tensor(labels) lam np.random.beta(alpha, alpha) index torch.randperm(images.size(0)) bbx1, bby1, bbx2, bby2 rand_bbox(images.size(), lam) images[:, :, bbx1:bbx2, bby1:bby2] images[index, :, bbx1:bbx2, bby1:bby2] # 实际的lam要按剪切面积重新算 lam 1.0 - ((bbx2 - bbx1) * (bby2 - bby1) / (images.size(2) * images.size(3))) return images, labels, labels[index], lam配合的损失函数也要改不能直接用 nn.CrossEntropyLoss 单标签版本def mixup_criterion(criterion, pred, labels_a, labels_b, lam): MixUp/CutMix统一入口。 return lam * criterion(pred, labels_a) (1 - lam) * criterion(pred, labels_b)DataLoader 里挂上就行train_loader DataLoader( train_dataset, batch_size64, shuffleTrue, num_workers8, pin_memoryTrue, collate_fnmixup_collate, )值得注意MixUp 会让训练 loss 比正常时高这是预期的不要因为 loss 降得慢就觉得模型没在学。CutMix 在目标检测场景更常用分类任务里两者效果接近我一般优先 MixUp数据很少时才换 CutMix。验证集不要用任何样本混合增强。下面是我整理的不同增强手段适用场景直接照着选增强手段作用关键参数适用场景RandomResizedCrop尺度与长宽比扰动scale(0.6,1.0), ratio(0.75,1.333)所有分类任务默认带上ColorJitter颜色扰动提升泛化brightness/contrast/saturation0.4真实场景图片、光照变化大Cutout局部遮挡正则n_holes1, length32小数据集、目标局部特征明显MixUp样本线性混合alpha0.2大数据集、训练晚期收敛CutMix区域剪切混合alpha0.2目标易遮挡、样本不均衡2.4 测试集transform与test.py推理结构测试阶段和验证集一样只做 ResizeCenterCrop不碰随机增强。我把 test.py 单独拆一个文件逻辑上只做三件事读图、过模型、打印 top5 概率。这样拿到资源包里的示例图片可以第一时间验证数据流和模型权重是否对齐。# test.py import torch import torch.nn.functional as F from PIL import Image def test_single_image(model, image_path, transform, idx_to_class, device): img Image.open(image_path).convert(RGB) img transform(img).unsqueeze(0).to(device) model.eval() with torch.no_grad(): output model(img) prob F.softmax(output, dim1) top_prob, top_idx prob.topk(5, dim1) for i in range(5): idx top_idx[0][i].item() print(f{idx_to_class[idx]:20s} {top_prob[0][i].item():.4f})这个结构直接对单张图片跑省去把整个测试集都过一遍的等待。资源里的 5e4d1ee0d.png 这类文件名我一般直接作为命令行参数传进来。注意 test.py 里不能夹带训练阶段的随机增强否则同一个模型每次预测结果都会变这是很多人排查半天才发现的问题。3. 训练主流程EMA、混合精度、DP多卡与余弦退火怎么配3.1 AverageMeter与ACC1/ACC5统计训练过程里最常用的工具就是 AverageMeter。很多人图省事把每个 batch 的 loss 塞进 list 最后平均数据少无所谓训练几万步以后 list 越长内存占用和计算开销都会变大而且很多情况需要按 n 加权比如梯度累积时。我一般开场就把它写好。# utils/metrics.py class AverageMeter: def __init__(self): self.reset() def reset(self): self.val 0 self.avg 0 self.sum 0 self.count 0 def update(self, val, n1): self.val val self.sum val * n self.count n self.avg self.sum / self.count def accuracy(output, target, topk(1, 5)): maxk max(topk) batch_size target.size(0) # 取每个样本topk的预测索引 _, pred output.topk(maxk, 1, True, True) pred pred.t() correct pred.eq(target.view(1, -1).expand_as(pred)) res [] for k in topk: correct_k correct[:k].reshape(-1).float().sum(0, keepdimTrue) res.append(correct_k.mul_(100.0 / batch_size)) return resACC1 就是 top-1 命中率ACC5 是预测的前五个类别里包含正确标签就算对。在类别数多的数据集上 ACC5 比 ACC1 更有参考意义比如 1000 类时很多相似类别肉眼都难分。topk(1, True, True)里后两个参数是 largest 和 sorted普通分类脚本保持默认即可。AverageMeter 的 update 里 n 参数传 batch size这样即使最后一个 batch 不满也不会算错均值。3.2 EMA轻量Transformer训练的稳定器EMA指数移动平均维护一份参数的影子副本每个 step 用 0.999 的比例和当前权重做加权平均。训练时用当前权重验证时把影子权重换进去通常能比原始权重拿到更稳的验证分数。在 ViT 类模型上收益尤其明显因为 Transformer 的训练过程本来就有一定振荡。# utils/ema.py import torch class EMA: def __init__(self, model, decay0.999): self.decay decay # 影子权重从当前参数拷贝一份不参与梯度更新 self.shadow { name: param.clone().detach() for name, param in model.state_dict().items() } torch.no_grad() def update(self, model): for name, param in model.state_dict().items(): # 就地更新shadow decay * shadow (1 - decay) * param self.shadow[name].mul_(self.decay).add_(param, alpha1 - self.decay) def apply_shadow(self, model): model.load_state_dict(self.shadow, strictTrue)decay 取 0.999 代表影子权重在过去约 1000 步上做平均训练 epoch 多可以提高到 0.9999。这里有一个很容易忽略的点EMA 的 update 必须在每个 step 之后调用不是每个 epoch。DP 多卡下EMA 维护的是 model.state_dict()也就是带 module. 前缀的原始状态运行时没有问题但保存以后加载要注意前缀第4章会说。3.3 训练循环混合精度、梯度裁剪、DP多卡一锅端训练循环是这套代码的核心承载点。PyTorch 自带 AMP 混合精度配合 GradScaler 能把显存省约 40%在 6M 参数的轻量模型上训练速度提升非常明显。梯度裁剪解决的是 loss 偶发爆炸问题Transformer 结构在深层次上很容易出现梯度范数突变。def train_one_epoch(model, loader, criterion, optimizer, scaler, ema, accum_steps1): model.train() losses AverageMeter() top1 AverageMeter() top5 AverageMeter() optimizer.zero_grad() for batch_idx, (images, target) in enumerate(loader): # 如果用了MixUptarget会变成两个标签和一个lam这里先演示普通情况 images images.cuda(non_blockingTrue) target target.cuda(non_blockingTrue) with torch.cuda.amp.autocast(): output model(images) loss criterion(output, target) / accum_steps scaler.scale(loss).backward() if (batch_idx 1) % accum_steps 0: # 顺序不能换先unscale再clip scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) scaler.step(optimizer) scaler.update() optimizer.zero_grad() if ema is not None: ema.update(model) acc1, acc5 accuracy(output, target, topk(1, 5)) losses.update(loss.item() * accum_steps, images.size(0)) top1.update(acc1.item(), images.size(0)) top5.update(acc5.item(), images.size(0)) return losses.avg, top1.avg, top5.avg三个参数说明max_norm1.0 是常见起步值训练不稳定可以降到 0.5accum_steps 用于梯度累积显存不够时把 batch 拆小梯度累积几步再更新autocast 的上下文要包住前向和 loss反向在 scale 之后执行。注意 loss 除以 accum_steps 是为了让累积梯度等效于大 batch 的均值否则等效学习率会变大。DP 多卡在主函数里直接包一层model SeaFormer_T(num_classeslen(train_dataset.class_to_idx)) if torch.cuda.device_count() 1: model nn.DataParallel(model, device_idslist(range(torch.cuda.device_count())))单机多卡、数据集不算超大时 DP 够用DDP 虽然效率更高但需要额外初始化进程组和调整 sampler对 6M 参数的小模型性价比不高。DP 模式下 batch 会被自动切到每张卡上实际 batch size 等于 batch_size * 卡数调学习率时要把这个因素算进去。3.4 余弦退火先warmup再降温不然前几个epoch容易发飘学习率策略直接决定收敛质量。我的固定组合是前 5 个 epoch 线性 warmup之后接余弦退火到训练结束。直接上 CosineAnnealingLR 的问题在第一个 epoch 学习率很高轻量 Transformer 的前几百步容易产生 loss 发飘后面想拉回来就得重训。from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR warmup_epochs 5 total_epochs 300 scheduler SequentialLR( optimizer, schedulers[ LinearLR(optimizer, start_factor0.01, total_iterswarmup_epochs), CosineAnnealingLR(optimizer, T_maxtotal_epochs - warmup_epochs, eta_min1e-6), ], milestones[warmup_epochs], )start_factor0.01 表示第一个 epoch 用正常学习率的 1%warmup 5 个 epoch 线性涨到满值eta_min1e-6 是余弦的下限不建议设成 0因为训练后期还需要一点探索空间。scheduler.step() 记得放在每个 epoch 结束而不是每个 batch。SequentialLR 的 milestones 填 warmup 结束的 epoch之后自动切换成余弦退火。3.5 验证与测评loss/acc曲线数据与val报告每个 epoch 结束做一次验证把关键指标记到历史列表里训练完一次性绘出曲线。这个流程比只看最终数字有用得多——曲线能直接看出是否过拟合、学习率衰减是否到位、什么时候开始掉点。def validate(model, val_loader, criterion, device): model.eval() losses AverageMeter() top1 AverageMeter() top5 AverageMeter() with torch.no_grad(): for images, target in val_loader: images images.to(device) target target.to(device) output model(images) loss criterion(output, target) acc1, acc5 accuracy(output, target, topk(1, 5)) losses.update(loss.item(), images.size(0)) top1.update(acc1.item(), images.size(0)) top5.update(acc5.item(), images.size(0)) return losses.avg, top1.avg, top5.avg注意 validate 前要把 EMA 权重 apply_shadow 到 model验证结束再换回来继续训练这一步漏掉等于 EMA 白跑。训练历史存成 CSV方便后面用任何工具回看import csv history.append({ epoch: epoch, train_loss: train_loss, train_acc1: train_acc1, val_loss: val_loss, val_acc1: val_acc1, }) with open(train_history.csv, w, newline) as f: writer csv.DictWriter(f, fieldnameshistory[0].keys()) writer.writeheader() writer.writerows(history) # 绘制loss/acc曲线 import matplotlib.pyplot as plt plt.figure(figsize(12, 5)) plt.subplot(1, 2, 1) plt.plot([h[epoch] for h in history], [h[train_loss] for h in history], labeltrain_loss) plt.plot([h[epoch] for h in history], [h[val_loss] for h in history], labelval_loss) plt.legend() plt.grid(True) plt.subplot(1, 2, 2) plt.plot([h[epoch] for h in history], [h[train_acc1] for h in history], labeltrain_acc1) plt.plot([h[epoch] for h in history], [h[val_acc1] for h in history], labelval_acc1) plt.legend() plt.grid(True) plt.savefig(train_curve.png)val 测评报告按类别输出正确率格式类似 per-class accuracy 表用于定位模型在哪些类上不行def val_report(model, val_loader, idx_to_class, device): model.eval() class_correct {} class_total {} with torch.no_grad(): for images, target in val_loader: images images.to(device) target target.to(device) output model(images) pred output.argmax(dim1) for t, p in zip(target.cpu().numpy(), pred.cpu().numpy()): class_total[t] class_total.get(t, 0) 1 if t p: class_correct[t] class_correct.get(t, 0) 1 for idx in sorted(class_total.keys()): acc 100.0 * class_correct.get(idx, 0) / class_total[idx] print(f{idx_to_class[idx]:20s} {acc:.2f}%)这份报告能直接看出是不是某个类别数据本身就少还是模型对该类别特征抓不住是调数据而不是调模型时最重要的依据。4. 避坑与常见问题SeaFormer训练里反复出现的五个坑4.1 EMA权重没换回去验证分数始终比训练低一截现象训练 acc 已经到 94%val 却一直在 88% 左右上不去换成不用 EMA 的普通权重训练验证分数反而正常。EMA 看起来对结果没有任何正面贡献甚至像是拖累。原因EMA 只在每个 step 更新了影子权重但 validate 函数里用的还是 model 的原始权重影子权重从来没参与过验证。EMA 的意义就是用更平滑的权重做推理训练期保持原始权重方便收敛推理期换影子权重拿更稳的结果。没换过去等于白算。解决在 validate 前调用 ema.apply_shadow(model)验证结束后再调用 ema.apply_shadow(model) 把权重切回原始权重继续训练。我习惯把这两句写在 validate 函数内部的开头和结尾比写在主循环里更不容易遗漏。4.2 混合精度下梯度裁剪顺序写反loss变成nan现象加了 GradScaler 之后训练到几百个 step loss 突然变成 nan重启训练到同样的步数再次复现看起来像是随机不稳定但其实每次都发生。原因autocast 下梯度是被 scale 过的直接 clip_grad_norm_ 相当于对放大后的梯度做裁剪再执行 scaler.step 时梯度本来就会被 unscale实际裁剪阈值等于完全失效。如果此时梯度里刚好有异常大的值配合 scale 放大直接溢出为 inf 或 nan。解决必须先调用 scaler.unscale_(optimizer) 再执行 clip。标准顺序是unscale_ - clip_grad_norm_ - scaler.step - scaler.update缺一步都不行。我排查这个问题时的经验是先注释掉梯度裁剪跑几个 epoch如果不翻车基本就是这个顺序问题。4.3 MixUp的lam没传进loss两个标签只用了第一个现象训练 loss 降得很快但 val 精度纹丝不动关闭 MixUp 后精度反而上升。很多人会误判成 MixUp 不适合这个数据集其实问题不在 MixUp 本身。原因collate_fn 返回了 labels_a、labels_b 和 lam但训练循环里仍然写 criterion(output, target)实际上只用到了第一个标签混合损失变成了错误的监督信号模型在学一个不存在的目标。解决判断 collate_fn 的返回结构MixUp 时把 loss 换成 mixup_criterion(criterion, output, labels_a, labels_b, lam)。这种错误比较隐蔽因为 loss 还是会下降只是下降方向不对。建议在训练循环里加一个断言检查返回的第二个元素是不是 int 张量如果莫名其妙多出一个维度大概率是混合增强的返回值没被消费掉。4.4 Cutout的length设置过大小目标被整块抹掉现象加上 CutOut 后 val acc 比不加还低 2 个百分点尤其在小目标类别上掉得厉害换回不带 CutOut 的增强配置精度立刻恢复。原因length48 以上时224×224 输入里目标区域可能只有 30×30 像素Cutout 直接就把整个目标抹没了模型被迫从背景推断类别学习信号被破坏。这个坑在森林图像分类这类小目标密集的场景特别容易踩标签是远处一棵树Cutout 挖掉的部分正好是树冠。解决Cutout 的 length 不要超过目标最小尺寸的一半。小目标数据集建议 length16 起步必要时把 n_holes 降到 0。我的习惯是先单独跑 30 个 epoch 对比有无 Cutout 的 val acc确认正收益再保留而不是默认加上。4.5 保存的权重带module.前缀测试脚本加载报unexpected key现象DP 多卡训练保存的 .pth 文件在单卡测试时 load_state_dict 报了一堆 unexpected key打印 key 全是 module.conv1、module.blocks.0 这种前缀。原因nn.DataParallel 会把模型包一层state_dict 里的 key 全部变成 module. 开头加载到裸模型自然对不上。这个问题在资源包里特别常见因为训练环境和测试环境往往不是同一套代码。解决保存时如果用的是 DataParallel就先取 model.module 再存save_model model.module.state_dict() if isinstance(model, nn.DataParallel) else model.state_dict() torch.save(save_model, seaformer_t.pth)加载时反过来做一次兼容处理也可以但我更建议统一在保存端处理这样 test.py 就永远只面对裸模型。还有一个更省事的办法是用 OrderedDict 去掉前缀但多一道转换就多一个出错点不如保存端一刀切干净。5. Grad-CAM可视化验证SeaFormer关注区域的最后一步5.1 记录SeaFormer最后一个Block的输出训练报告再漂亮模型真正学到的特征可能是错误的——比如背景和主体的相关性。Grad-CAM 是我每换一个模型都会跑一遍的验证手段用梯度对最后一个 Block 的特征图加权得到类别响应热力图。# utils/gradcam.py import cv2 import torch import torch.nn.functional as F class GradCAM: def __init__(self, model, target_layer): self.model model self.gradients None self.activations None target_layer.register_forward_hook(self.save_activation) target_layer.register_full_backward_hook(self.save_gradient) def save_activation(self, module, input, output): self.activations output.detach() def save_gradient(self, module, grad_input, grad_output): self.gradients grad_output[0].detach() def generate(self, input_tensor, class_idxNone): output self.model(input_tensor) if class_idx is None: class_idx output.argmax(dim1).item() self.model.zero_grad() output[0, class_idx].backward() # 梯度按空间维度求平均作为权重再和激活图加权求和 weights self.gradients.mean(dim(2, 3), keepdimTrue) cam (weights * self.activations).sum(dim1, keepdimTrue) cam F.relu(cam).squeeze(0).squeeze(0) cam cam - cam.min() cam cam / (cam.max() 1e-8) cam cam.cpu().numpy() cam cv2.resize(cam, (input_tensor.shape[3], input_tensor.shape[2]), interpolationcv2.INTER_LINEAR) return camtarget_layer 选 SeaFormer 最后一个 Transformer Block 输出的特征图维度要能对上不同实现的层名不同自己 print 一下 model 的结构去匹配。使用方式是把图片和模型一起喂进去拿返回的 cam 和原图做 alpha 融合即可。下载的资源里已经带了 class.json 和示例图片拿到手先跑一遍 test.py 和 Grad-CAM确认数据流没问题再开始训练。判断骨架如果热力图集中在主体边缘而非中心说明模型没有真正学到核心特征此时优先检查数据和增强是否破坏了目标区域。从那以后我每次换新模型都会先跑一版 Grad-CAM 确认它在关注目标区域再继续调参——这一步省掉了很多在错误方向上调参的时间。希望帮到你。本文还有配套的精品资源点击获取