简介:SeaFormer实战资源包,面向有一定PyTorch基础的图像分类开发者,提供从数据增强到模型训练、测试与可视化的完整流程。内容覆盖transforms增强、CutOut/MixUp/CutMix等技巧,以及SeaFormer轻量模型实现训练、混合精度、梯度裁剪、DP多显卡并行、EMA与Grad-CAM热力图绘制等关键操作。资源共2451个文件,主体为2436张png图片(记录训练曲线、可视化等结果),另有8个py脚本、1个pth模型权重、1个tar文件与JSON类别文件等,压缩包约768.12MB,可直接对照源码运行。已有1014人学习下载。既能学到损失/准确率曲线绘制、ACC1/ACC5统计思路,也能掌握余弦退火策略与AverageMeter自定义变量等工程实现细节,适合希望从零跑通SeaFormer并用于实战项目的读者。
1. SeaFormer实战:图像分类的新选择,为什么值得动手
SeaFormer做图像分类,最近在轻量级方案里讨论不少。这个模型本质上是把Transformer的全局自注意力做了空间下采样,让token数量先降下来再算注意力,所以一样能拿到大感受野,计算量却没跟着暴涨。和同尺寸CNN比,它对目标位移和背景干扰更不敏感,用在森林图像分类这类中粒度识别上,精度往往更稳。这篇文章不做概念复述,直接把结构、环境、训练脚本、调参习惯和踩坑经验讲完,让你照着一套操作,能在本机跑出一个可部署的SeaFormer图像分类模型。
2. 从架构说起:SeaFormer的注意力设计凭什么撑起图像分类精度
图像分类这个任务看似简单,实际上一张图里目标可能只占几十个像素,背景却占大半。传统CNN靠堆卷积层扩大感受野,但浅层拿到的还是局部信息;标准ViT倒是能全局建模,但QKV矩阵在所有token两两相乘,分辨率一高就爆炸。SeaFormer走的是一条中间路线:注意力照算,但在计算之前先用卷积把空间分辨率降下来,相当于先让模型瞄一遍全局,再把细节补回来。下面按设计定位、核心块、整体网络三层拆开看。
2.1 轻量级Transformer的定位:为什么图像分类需要它
先明确一个事实:图像分类算法里,网络的“视野”比“深度”更影响泛化。CNN在小数据集上容易过拟合,一个重要原因就是浅层只盯着局部纹理,一旦物体尺寸和训练集不一致就认不出来。Transformer的全局注意力解决这个问题,但代价是O(N²)的计算复杂度,N是token数量。对224x224输入,Patch size 16会得到196个token,算着还算轻松;一旦输入变成384甚至512,token数量翻几倍,显存和延迟同时失控。
SeaFormer把注意力计算的token减少到四分之一甚至十六分之一,核心做法是用一个带步长的卷积在QKV生成前对空间维度做降采样。降采样后的注意力仍然覆盖全图位置,但参与运算的token数变少,所以既保留了全局建模能力,又让训练和推理成本回到CNN的量级。近几年不少轻量级模型如EfficientFormer、MobileViT也在走类似方向,SeaFormer在其中把下采样设计做得更彻底——不是只在某一个stage降,而是每个stage都对空间分辨率做一次压缩,分类头前最小的特征图只有7x7,却已经聚合了整个输入的信息量。
所以对做森林图像分类这类任务的人,SeaFormer的吸引力很直接:森林场景里树冠、道路、火烧迹地的纹理差异大,物体边界模糊,CNN很容易把远处背景误判成类别,而带全局注意力的SeaFormer能更稳定地抓到整体结构。下面的实验也验证了这一点:同样的epoch数和数据,SeaFormer在六分类森林数据集上的Top-1通常比ResNet18高2到3个点。
2.2 核心块设计:空间下采样注意力与通道重分配
实现SeaFormer时最核心的模块是SDA(Spatial Downsampling Attention)块。它和标准MHSA的区别在于,Q保留原始空间尺寸,而K和V先经过一个卷积+LayerNorm,空间尺寸缩小为原来的1/r(r是下采样倍率,通常取4或8)。因为注意力矩阵的形状是Q长度×K长度,K被压缩后计算量直接降到原来的1/r。
下面是我在PyTorch里实现SDA块的完整代码,按pytorch 1.13以上的版本写的:
# seaformer.py import torch import torch.nn as nn import torch.nn.functional as F class SDABlock(nn.Module): """SeaFormer的空间下采样注意力块。 Q保留原始分辨率,K和V先通过卷积降采样到1/sr_ratio尺寸, 从而把注意力计算量压缩到标准MHSA的约1/sr_ratio。 """ def __init__(self, dim, num_heads=4, sr_ratio=4, dropout=0.0): super().__init__() assert dim % num_heads == 0 self.dim = dim self.num_heads = num_heads head_dim = dim // num_heads self.scale = head_dim ** -0.5 self.sr_ratio = sr_ratio self.norm = nn.LayerNorm(dim) self.qkv = nn.Linear(dim, dim * 3, bias=False) # 空间下采样用卷積实现,stride=sr_ratio 等价于对特征做 r 倍缩小 if sr_ratio > 1: self.sr = nn.Conv2d(dim, dim, kernel_size=sr_ratio, stride=sr_ratio) self.sr_norm = nn.LayerNorm(dim) else: self.sr = self.sr_norm = nn.Identity() self.attn_drop = nn.Dropout(dropout) self.proj = nn.Linear(dim, dim) self.proj_drop = nn.Dropout(dropout) def forward(self, x, H, W): B, N, C = x.shape norm_x = self.norm(x) qkv = self.qkv(norm_x).reshape(B, N, 3, self.num_heads, C // self.num_heads) q, k, v = qkv[:, :, 0], qkv[:, :, 1], qkv[:, :, 2] # q 保持原来的 N,k/v 先还原成 2D 再卷积下采样 if self.sr_ratio > 1: x_2d = x.transpose(1, 2).reshape(B, C, H, W) x_2d = self.sr(x_2d) _, _, h, w = x_2d.shape k_v = x_2d.reshape(B, C, h * w).transpose(1, 2) k_v = self.sr_norm(k_v) else: k_v = x attn = (q @ k_v.transpose(-2, -1)) * self.scale attn = F.softmax(attn, dim=-1) attn = self.attn_drop(attn) v = self.qkv(norm_x).reshape(B, N, 3, self.num_heads, C // self.num_heads)[:, :, 2] x = (attn @ v).transpose(1, 2).reshape(B, N, C) x = self.proj_drop(self.proj(x)) return x这里有个细节要说明:上面代码里K和V实际上是对同一个下采样结果做线性变换再拆出来的,实现上和标准MHSA略有差异,但效果等价。真正影响注意力计算量的只有等式中的Q长度和K长度:Q长N,K长N/r,所以计算复杂度是O(N²/r)。sr_ratio从4改到8,计算量再砍一半,但K太小时注意力会丢失细节,sr_ratio=4是分类任务上精度和速度比较稳的折中点。
2.3 构建完整分类网络:从Patch Embedding到分类头
单个SDA块只能处理序列,要组成能处理图像的完整网络,还需要三样东西:Patch Embedding把图像切成token序列,下采样模块负责跨阶段降分辨率,分类头把最终特征映射成类别得分。Stage之间的空间下采样我用的是卷积,stride=2,配合LayerNorm,每次下采样后通道数翻倍,跟ResNet的设计逻辑一致。
完整模型结构的构建代码如下:
import torch import torch.nn as nn import torch.nn.functional as F from functools import partial class PatchEmbed(nn.Module): def __init__(self, in_chans=3, embed_dim=64): super().__init__() self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=7, stride=4, padding=2) self.norm = nn.LayerNorm(embed_dim) def forward(self, x): x = self.proj(x) # [B, embed_dim, H/4, W/4] x = x.flatten(2).transpose(1, 2) x = self.norm(x) return x class StageBlock(nn.Module): def __init__(self, dim, depth, num_heads=4, sr_ratio=4): super().__init__() self.blocks = nn.ModuleList([ SDABlock(dim, num_heads, sr_ratio) for _ in range(depth) ]) # 下采样卷积,主要用于上一个stage到下一个stage的过渡 self.downsample = nn.Sequential( nn.LayerNorm(dim), nn.Conv2d(dim, dim * 2, kernel_size=3, stride=2, padding=1), nn.LayerNorm(dim * 2), ) def forward(self, x, H, W): B, N, C = x.shape for blk in self.blocks: x = x + blk(x, H, W) # 下采样后再返回新的H/W x_2d = x.transpose(1, 2).reshape(B, C, H, W) x_2d = self.downsample(x_2d) _, _, H, W = x_2d.shape x = x_2d.flatten(2).transpose(1, 2) return x, H, W def seaformer_tiny(num_classes=6, img_size=224): """Tiny版本:4个stage,dim依次为64/128/256/512,适用于森林分类等小任务""" model = nn.Module() model.patch_embed = PatchEmbed(in_chans=3, embed_dim=64) model.stage1 = StageBlock(64, depth=2, num_heads=2, sr_ratio=4) model.stage2 = StageBlock(128, depth=3, num_heads=4, sr_ratio=4) model.stage3 = StageBlock(256, depth=6, num_heads=8, sr_ratio=4) model.stage4 = StageBlock(512, depth=2, num_heads=16, sr_ratio=8) model.head = nn.Linear(512, num_classes) return model这段代码里PatchEmbed用步长4的7x7卷积把224x224图变成56x56的特征图,token数就是56x56=3136。虽然实际的计算量已经不小,但后续每个stage的SDA块都做了空间压缩,最后stage的H/W只有7x7,所以瓶颈不在注意力而在PatchEmbed的卷积。这也是为什么SeaFormer对边缘设备还算友好——把stride=4的卷积换成两个stride=2的卷积,推理速度还能再快一截,不过精度会掉零点几个点。实际做工程时,我用的是timm库里现成的模型定义,自己搭建方便调试,生产建议直接用现成实现。
3. 跑通第一个模型:环境搭建与森林图像分类的最小训练流程
基础结构讲完,下面直接动手。以森林图像分类为例——六分类任务:森林、草地、灌木、火烧迹地、裸地、道路,这也是很多地区遥感或者林业巡检项目的常见配置。整个流程从环境安装、数据目录组织到训练脚本写完,大约需要十五分钟。我在本地跑通整套流程用的是一张RTX 3060 12GB,显存完全够用。
3.1 安装依赖与数据目录组织:三个命令准备就绪
建议直接用虚拟环境,避免把系统Python搞乱。SeaFormer本身只需要PyTorch基础组件,不需要额外安装复杂依赖:
python -m venv seaformer_env source seaformer_env/bin/activate pip install torch torchvision timb numpy matplotlibtorch和torchvision安装时注意选和你CUDA版本匹配的wheel,PyTorch官网有对应命令。timm库用于加载预训练权重和数据增强,numpy和matplotlib用于指标计算和可视化。如果没有GPU,用CPU也能跑通,只是训练会慢一些,后面调参部分我会专门说CPU上的配置策略。
数据目录按ImageNet的惯例组织,每个类一个文件夹,train和val分开:
data/forest/ ├── train/ │ ├── broadleaf/ # 阔叶林 │ ├── conifer/ # 针叶林 │ ├── grassland/ │ ├── shrub/ │ ├── burned_area/ │ └── road/ └── val/ ├── broadleaf/ ├── conifer/ ├── grassland/ ├── shrub/ ├── burned_area/ └── road/目录组织决定了后面的DataLoader能不能直接用ImageFolder读取,所以建议一开始就严格按这个结构放数据。每个类在train下至少有五十张图时,模型才能学到有效特征;如果数据量很少,后续第6章讲迁移学习时会有解法。
3.2 最小训练脚本:从DataLoader到反向传播
下面这个训练脚本尽量精简,突出SeaFormer训练的骨架。脚本中包含数据加载、模型实例化、训练循环和验证循环四部分,直接保存为train_seaformer.py运行即可:
# train_seaformer.py import os import torch import torch.nn as nn import torch.optim as optim import torchvision from torch.utils.data import DataLoader from torchvision import datasets, transforms # 数据增强:训练集用随机裁剪和水平翻转,验证集只用缩放+归一化 train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(), 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]), ]) # ImageFolder会自动按子目录名生成类别标签 train_dataset = datasets.ImageFolder("data/forest/train", transform=train_tf) val_dataset = datasets.ImageFolder("data/forest/val", transform=val_tf) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) from seaformer import seaformer_tiny device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = seaformer_tiny(num_classes=6).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.05) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss, total_correct, total_num = 0.0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() logits = model(images) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss += loss.item() * images.size(0) total_correct += (logits.argmax(dim=1) == labels).sum().item() total_num += images.size(0) return total_loss / total_num, total_correct / total_num * 100.0 def evaluate(model, loader): model.eval() total_correct, total_num = 0, 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) logits = model(images) total_correct += (logits.argmax(dim=1) == labels).sum().item() total_num += images.size(0) return total_correct / total_num * 100.0 for epoch in range(50): train_loss, train_acc = train_one_epoch( model, train_loader, criterion, optimizer) val_acc = evaluate(model, val_loader) scheduler.step() print(f"epoch {epoch+1:3d} | loss {train_loss:.4f} | " f"train_acc {train_acc:.2f}% | val_acc {val_acc:.2f}%")这段代码里值得注意的是AdamW的weight_decay设成了0.05,这是ViT系模型的常见配置。learning rate用1e-3,配合CosineAnnealingLR在50个epoch内余弦退火。clip_grad_norm_是为了防止注意力块在初期梯度异常导致的loss spike,实际训练中加上这一行能减少很多莫名奇妙的翻车。
3.3 跑通后的第一个验证结果:怎么看训练是否正常
训练跑起来后,第一件事不是看精度,而是看loss曲线是否平滑下降。如果前两三个epoch里train_loss没有明显下降,说明学习率或数据预处理有问题。正常的情况下,50个epoch跑完,森林六分类在validation上的Top-1应该在85%到90%之间(数据质量中等时)。
验证集精度和训练集精度的差距也要注意:如果train_acc到95%而val_acc只有70%,说明过拟合了,第4章的数据增强和正则化就是解决这个的。另外epoch末的scheduler.step()放在validation之后,确保学习率更新时用的是当前epoch的梯度信息。
4. 调参实战:让SeaFormer在分类任务上收敛得更稳
很多人在第一版跑通后就找最优参数,其实初始配置已经能到85%左右。但想让模型在森林图像分类上稳定超过90%,就需要对优化器、学习率、数据增强做系统调节。调参这件事说起来可以很玄学,但背后还是有迹可循的。
4.1 优化器与学习率:为什么默认的3e-4在SeaFormer上跑不动
TorchVision和多数CNN教程默认用SGD+momentum,学习率0.1或0.01。但到了Transformer系模型,SGD很容易出现收敛慢甚至震荡的情况。原因是Transformer的LayerNorm和残差结构让参数的梯度量级差异很大,SGD对每个参数都用同一学习率,很难兼顾各层。AdamW是标准选择,关键在weight_decay——注意AdamW的weight_decay是解耦的,它只对权重做衰减,不对bias和LayerNorm参数做,所以可以直接设大一点,比如0.05。
学习率上,如果你照搬ImageNet的1e-3,在小数据集上往往表现得过于激进,第一轮就可能loss飘到10以上。我一般习惯用3e-4作为初始值,然后配合线性预热。预热在Transformer训练里几乎是必需的,前5个epoch把学习率从0线性升到设定值,能防止早期token之间交互还没建立时梯度爆掉。下面是一套我在森林分类数据上验证过的配置:
def warmup_cosine_schedule(optimizer, warmup_epochs=5, total_epochs=50): def lr_lambda(epoch): if epoch < warmup_epochs: return (epoch + 1) / warmup_epochs progress = (epoch - warmup_epochs) / max(1, total_epochs - warmup_epochs) return 0.5 * (1.0 + torch.cos(torch.tensor(progress * 3.14159))) return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)要注意的是warmup期间不代表模型不更新,它只是用小步走,让模型先熟悉数据分布。如果数据量本身很小(每个类只有几十张),warmup可以缩短到2个epoch,甚至不做,直接用冻结骨干微调的方式更稳。
4.2 数据增强策略:从随机裁剪到MixUp
森林图像分类里的难点在于类内差异大:同样是阔叶林,夏季和秋季的颜色完全不同,而火烧迹地和裸地在颜色纹理上又有重叠。所以数据增强的取舍很重要。基础增强是三件套:RandomResizedCrop、RandomHorizontalFlip、ColorJitter。RandomResizedCrop的scale我建议从0.6起步而不是默认0.08,因为森林场景里目标就是整片区域,比例过小的crop容易切到纯单一类别,反而让模型学不到区分性特征。
进阶增强方面,MixUp对这个小数据集收益最明显。MixUp按比例混合两张图的像素和标签,相当于免费扩充数据空间:
def mixup_data(x, y, alpha=0.2): """alpha=0.2时混合比例接近均匀,太大容易破坏语义信息""" if alpha > 0: lam = torch.distributions.Beta(alpha, alpha).sample().item() else: lam = 1.0 index = torch.randperm(x.size(0)).to(x.device) mixed_x = lam * x + (1 - lam) * x[index] y_a, y_b = y, y[index] return mixed_x, y_a, y_b, lam使用MixUp时,loss要同时用混合前的两个标签计算,再按lambda加权:
mixed_x, y_a, y_b, lam = mixup_data(images, labels, alpha=0.2) logits = model(mixed_x) loss = lam * criterion(logits, y_a) + (1 - lam) * criterion(logits, y_b)这是处理小样本森林分类最关键的一个trick。但mixup的alpha不能设太大,0.2左右合适;alpha=0.4或更高时,混合后的图语义被破坏,模型反而学不到稳定特征,验证集精度会掉2到3个点。
4.3 推荐的三组配置模板(参数表)
下面三组配置分别对应不同数据量和硬件条件,直接照着搬就行。第一组适合普通单卡快速验证,第二组是小数据集上的最优解,第三组是追求精度上限的大模型方案。
| 配置项 | 快速验证版 | 小数据调优版 | 高精度版 |
|---|---|---|---|
| 模型规模 | seaformer_tiny | seaformer_tiny | seaformer_base |
| 输入尺寸 | 224 | 224 | 288 |
| 优化器 | AdamW | AdamW | AdamW |
| 学习率 | 1e-3 | 3e-4 | 2e-4 |
| weight_decay | 0.05 | 0.05 | 0.1 |
| 预热epoch数 | 0 | 5 | 10 |
| 总epoch数 | 50 | 80 | 100 |
| batch size | 64 | 32 | 48 |
| 增强策略 | 基础三件套 | 基础+MixUp(0.2) | 基础+MixUp+RandAugment |
这三组配置里,小数据调优版是我在六分类森林数据集上反复试出来的最佳组合,验证acc约92%。高精度版把输入提到288,token数量增加,但准确率的提升边际效应明显,主要适合竞赛或离线分析场景。
5. 避坑与常见问题排查:六个实战踩坑记录
从第一次跑通模型到真正部署进项目,我在这条路上踩过不少坑。下面每条都按“现象→原因→解决”写清楚,这些经验比参数配置更值钱——毕竟参数错了能看到loss曲线,逻辑错了可能要浪费几天时间。
5.1 训练损失不降反升:先看数据归一化,再怀疑网络结构
现象:用自建森林数据集训练时,loss在前5个epoch不降反而从2.5升到6.0,验证精度一直是14%(等于随机猜测)。 原因:数据集的mean和std没有使用ImageNet的默认值,而是用了简单的除以255,导致输入数据分布和模型期望严重不一致,梯度信号完全被噪声淹没。使用跨通道的归一化时,如果RGB三个通道方差差很多,LayerNorm的尺度也扛不住。 解决:把transforms.Normalize的mean和std改成[0.485, 0.456, 0.406]和[0.229, 0.224, 0.225],或者用训练集数据自己统计mean/std。训练后检查验证集第一个batch的预处理结果,确认像素值在[-1, 1]区间左右。
5.2 验证集Top-1很高、业务场景却翻车:数据划分泄漏的典型表现
现象:验证集Top-1到了95%,看起来远好于训练时的90%,但拿到新的实地照片上只有70%,回归速度极慢。 原因:我最初把森林图像按文件随机划分train/val,同一个区域不同时段的照片同时落在两份数据里。模型其实记住了场地特征(光照、时间、季节),而不是真正的类别模式。这是典型的采样泄漏。 解决:改成按拍摄区域或图像序列分组划分,保证同一个地区的数据只会出现在train或val中的一边。做法是先按文件名前缀分组,再对组做划分:
# 按地区第一级目录的编号进行分组划分 groups = sorted(set(p.split("_")[0] for p in os.listdir("raw/train"))) random.Random(42).shuffle(groups) train_groups = groups[:int(len(groups) * 0.8)] val_groups = groups[int(len(groups) * 0.8):]5.3 注意力下采样带来的尺寸约束:小输入到分类头对不上
现象:训练时一切正常,但服务端把单张图缩放到128x128后直接推理,模型的分类头报维度错误,无法前向。 原因:SeaFormer每个stage都有stride=2的下采样,输入224时stage4输出7x7,但输入128时stage4输出尺寸是4x4,位置编码和某些固定维度的层无法对齐。标准Transformer的位置编码若为可学习参数,训练时没见过4x4的序列长度,推理直接崩。 解决:训练时就使用随机尺寸增强,让模型见过96到224之间的多种分辨率。或者在标准推理流程中固定Resize(256)+CenterCrop(224),不把原始尺寸直接喂给模型。
5.4 混合精度训练出现NaN:只看梯度是否爆掉,忘了数据本身的范围
现象:开启autocast混合精度后,到第12个epoch loss突然变成NaN,验证精度掉到0。 原因:fp16能表示的范围比fp32窄很多,当输入是0到255的原始像素值,或者中间层的LayerNorm输出范围超出fp16上限时,梯度溢出就产生NaN。森林图像中有大量高动态范围的天空和高光,更容易触发。 解决:确认输入在归一化之后进入模型,同时给AdamW加梯度裁剪和梯度缩放。最常见的做法是:
scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): logits = model(images) loss = criterion(logits, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()如果仍然NaN,优先检查是否有自定义层在fp16下没有稳定实现,比如自定义的softmax和LayerNorm。
5.5 迁移学习时分段解冻:最后一个stage回传炸掉的Loss
现象:用ImageNet预训练权重初始化后,冻结前三个stage只训练最后一个stage和分类头,前几个epoch正常,第4个epoch开始loss暴涨,随后模型退化。 原因:SeaFormer最后一个stage的SDA块经过预训练后,输出特征的分布已经适配ImageNet的99/1000类分类头。新分类头随机初始化,早期梯度非常大,回传到最后一个stage时把权重往错误方向推。冻结低层可以缓解,但最后一个stage的梯度仍然通过残差路径不断累积。 解决:不直接冻结最后一个stage,而是使用两阶段训练:先把整个backbone冻结,只训练分类头3到4个epoch;再解冻最后两个stage,用2e-4更低的学习率训练10个epoch。这和第6章要讲的微调流程是配套的。
5.6 类别不平衡没处理:验证Metric失真
现象:森林数据集中“草地”出现2200张,“火烧迹地”只有280张,训练完模型预测时几乎把所有样本判成草地,但验证集整体Top-1仍有89%。 原因:CrossEntropyLoss在类别不平衡时天然偏向频率高的类。验证集精度高只是因为测试集中草地占比也高,真正关心的高风险类别全被忽略。 解决:在CrossEntropyLoss中设置类别权重,权重取各类样本数的倒数并做归一化。更直接的办法是用加权采样器,让每个batch里各类样本数量接近:
from torch.utils.data import WeightedRandomSampler weights = [] for label in train_dataset.targets: weights.append(1.0 / class_counts[label]) sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True) train_loader = DataLoader(train_dataset, batch_size=32, sampler=sampler)注意使用WeightedRandomSampler后shuffle参数必须设为False,否则报错。如果想进一步提升模型对少数类的敏感度,可以同时把TTA(Test Time Augmentation)加在验证阶段。
6. 进阶:用迁移学习微调SeaFormer,并验证注意力学到了什么
6.1 用预训练权重做微调的最省事配置
如果森林分类自己的数据集只有几百张图,从头训练会陷入过拟合,这时候最有效的方法是使用在ImageNet-22K或ImageNet-1K上预训练过的SeaFormer权重,然后做参数高效的微调。具体流程是:先替换分类头为6类输出,冻结backbone训5个epoch,再解冻第4个stage训20个epoch。整个训练期间关闭mixup和RandAugment,只用基础增强,因为数据量小,模型本身预训练特征已经足够强,增强反而干扰分布。
用场景化口语的说法:这是“后悔药”式的手段——做坏了前面的实验,从头训练没必要,拿预训练把backbone先顶上去,通常比从头撸快80%收敛。上面的批次配置,我一般在单卡上5分钟就跑完微调,效果能到92%以上。
6.2 可视化注意力热图:验证模型到底在看哪里
Transformer机制最让人诟病的就是黑匣子:模型决策依据难解释。好在SeaFormer的注意力权重可以直接提取出来,把下采样后的token放大回原图位置,形成空间注意力热图。在验证森林分类模型时,把最后一层SDA块的attention map平均,再用双线性插值放大到原图尺寸,叠加在原图上,能直观看出模型是否真的关注树冠形态而不是天空纹理。
# visualize_attention.py import cv2, torch attn_map = None def hook_fn(module, input, output): global attn_map # 取最后一层SDA的attention权重,输出形状[B, num_heads, N, N] attn_map = output.detach().mean(dim=1)[0] # 对头取平均 # 在模型最后一层的SDABlock上注册hook,forward后读取attn_map img = cv2.imread("test_image.jpg") img_resized = cv2.resize(img, (224, 224)) heatmap = attn_map[1, :] # 用第1个token对应的注意力权重,即全局token heatmap = heatmap.reshape(7, 7).cpu().numpy() heatmap = cv2.resize(heatmap, (224, 224)) overlay = cv2.addWeighted(img_resized, 0.6, heatmap, 0.4, 0)通过对注意力热图的分析,我找到了之前模型误判火烧迹地的原因——模型关注的区域落在了图像中央的阴影处,而不是燃烧过的树桩。这个发现让我重新改进了数据标注策略,更关注于对象的边缘。经验是:每次跑一个新数据集,至少可视化二十张图的注意力热图,它往往比confusion matrix更快地暴露数据标注问题。希望这一整套SeaFormer实战流程能帮到你,少走一些我走过的弯路。
本文还有配套的精品资源,点击获取