尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CBAM注意力机制实战:基于ResNet50的美食101分类模型全解析

CBAM注意力机制实战:基于ResNet50的美食101分类模型全解析 过年期间最高频的社交动作就是拍菜一桌年夜饭从凉菜到甜点咔嚓咔嚓几十张照片。作为一个常年鼓捣视觉算法的工程师我今年给自己定了个小项目用CBAM注意力机制做一套“美食101分类”模型把年菜也好、日常料理也罢自动归到对应的菜品类目里。CBAM全称Convolutional Block Attention Module核心是让网络同时关注通道维度和空间维度的关键信息在美食这种“局部细节决定成败”的分类任务上非常对症。这篇就把整个项目从思路、原理、代码到踩坑完整记录下来适合正在做图像分类、注意力机制落地或者拿Food-101这类公开数据集练手的朋友参考。1. 项目定位与设计思路1.1 美食101分类的任务与难点美食101分类最常用的公开基准就是Food-101数据集包含101个类别每类1000张左右图片官方划分是每类750张训练、250张测试。任务本身听起来就是常规图像分类但真正上手会发现坑不少。第一个难点是类间相似度极高。同样是鱼肉清蒸鱼、烤鱼、鱼汤外形差异比不同食材的菜品还要大再比如各类沙拉、汤品颜色和纹理都高度接近。这种“同材异做”和“异材同形”交织在一起非常考验模型提取细粒度特征的能力。第二个难点是类内差异也很大。同一道菜在不同餐厅、不同摆盘、不同光线下拍出来颜色、构图、背景可能完全不一样。尤其新年餐桌环境光以暖黄调为主再加上蒸汽、反光、酒杯餐具遮挡对模型泛化能力的挑战比标准数据集高出不少。第三个难点是背景干扰。美食照片里盘子、桌布、筷子、旁边人的手在视觉上占了很大面积。模型如果没有“选择性注意”的能力很容易学到背景线索而不是菜品本身的特征换一个环境就掉点。这些难点叠加起来常规的ResNet直接硬train也能出结果但天花板比较明显。我这个项目的核心目标就是引入CBAM注意力机制用尽量小的改动提升分类准确率同时让模型的注意力区域变得可解释真正做到“赏”和“鉴”兼得。1.2 为什么选CBAM而不是SE、CA或自注意力注意力机制现在可选的方案很多SE只做通道维度的加权Coordinate AttentionCA引入了坐标信息自注意力在ViT里大放异彩。我最终选择CBAM理由有三个。第一CBAM是通道注意力和空间注意力的组合。SE只告诉模型“哪些通道更重要”却没有告诉模型“这些特征到底在图片的哪个位置”。美食图像的判别信息往往集中在局部——酱汁的光泽、辣椒籽的分布、食材的纹理这些都要靠空间维度的注意力去锁定。CBAM先做通道加权再做空间加权相当于先筛选“看什么”再定位“看哪里”正好对应美食细分类的核心需求。第二CBAM即插即用计算开销极小。按原论文数据接入ResNet50之后增加的参数量和计算量几乎可以忽略具体数字我后面会整理。相比自注意力机制动辄几十亿次浮点运算的实现CBAM在同一套Backbone上做改造成本和风险都可控。第三自注意力机制虽强但它对数据量和训练技巧的要求更高。101类美食分类属于中等规模数据任务ViT类模型如果没有大规模预训练很容易过拟合调参成本也高。CBAM这种卷积搭配注意力的方案在小数据集上更稳也更适合做工程落地。1.3 Backbone选型与整体架构Backbone我选了ResNet50。理由很直白预训练权重丰富、结构成熟、改造空间大。MobileNetV3其实也是一个选项但它自带的SE注意力模块已经有一套通道注意力逻辑再硬塞CBAM进去效果增益不明显还容易干扰原结构的设计平衡。整体架构是这样的输入图片resize到224×224经过ResNet50的卷积层逐级提取特征在每个Stage输出的特征图后面插入一个CBAM模块重新校准特征然后接全局平均池化最后接一个输出维度为101的全连接分类层。这里我额外用了一个策略让FPN那套多尺度思想在浅层也起作用——实际上没有用FPN而是通过在四个Stage后分别加CBAM让浅层关注纹理细节深层关注语义类别各司其职。选型时还考虑了后续部署。ResNet50加上CBAM之后整个模型在GPU和CPU上都能跑得动导出ONNX或者用TensorRT加速都比较顺利。如果是追求Top-1精度极限可以考虑更大Backbone但就美食分类这个任务而言ResNet50CBAM已经能在准确率和推理速度之间取得比较舒服的平衡。2. CBAM注意力机制原理解读与模块实现2.1 通道注意力与空间注意力的协同逻辑CBAM整体结构分两段先是通道注意力模块再是空间注意力模块串联在校准特征图上执行。通道注意力模块的设计思路不复杂。对于一个C×H×W的特征图为了得到每个通道的重要性权重先分别做全局平均池化和全局最大池化得到两个C×1×1的特征描述。这里同时使用平均池化和最大池化是原论文的一个重要细节平均池化能反映通道的整体响应水平最大池化能捕捉通道内最强烈的激活区域两者互补。两个描述向量经过同一个共享MLP映射后相加再过Sigmoid得到最终的通道权重。最后用这个权重对原始特征图逐通道相乘。空间注意力模块接在通道注意力之后。它把经过通道校准的特征图沿通道方向分别取平均和最大值得到两个H×W的单通道特征图拼接成2×H×W然后过一个7×7的卷积将通道数降到1最后经过Sigmoid生成空间权重。这个权重会告诉模型“特征图中哪些位置值得关注”逐元素乘回去就完成了空间维度的重新校准。整个过程可以用一句话概括先教模型关注有判别力的通道再教模型关注这些通道在空间上的分布。这种“通道定位语义、空间定位位置”的协同逻辑比单独使用任意一种注意力都更适合美食图像的细粒度识别。2.2 PyTorch手写CBAM模块CBAM的实现非常简洁核心代码不到50行。我在项目里用的是PyTorch 2.x下面这个版本可以原样复制运行。import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_channels, reduction16): super().__init__() self.mlp nn.Sequential( nn.Conv2d(in_channels, in_channels // reduction, kernel_size1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels // reduction, in_channels, kernel_size1, biasFalse), ) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out self.mlp(torch.mean(x, dim(2, 3), keepdimTrue)) max_out self.mlp(torch.max(x, dim2, keepdimTrue)[0].max(dim3, keepdimTrue)[0]) return self.sigmoid(avg_out max_out) class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super().__init__() self.conv nn.Conv2d(2, 1, kernel_size, paddingkernel_size // 2, biasFalse) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out torch.mean(x, dim1, keepdimTrue) max_out torch.max(x, dim1, keepdimTrue)[0] cat torch.cat([avg_out, max_out], dim1) return self.sigmoid(self.conv(cat)) class CBAM(nn.Module): def __init__(self, in_channels, reduction16, kernel_size7): super().__init__() self.ca ChannelAttention(in_channels, reduction) self.sa SpatialAttention(kernel_size) def forward(self, x): x x * self.ca(x) x x * self.sa(x) return x几个实现细节值得说明。通道注意力里的共享MLP我用两个1×1卷积实现作用和全连接层完全等价但写法上更自然也能避免后接全连接层时对特征图尺寸变化的适应问题。torch.mean和torch.max的写法要注意dim参数我在前面放了一个[0]操作是因为torch.max(x, dim2)返回的是(value, indices)的元组取values需要索引0。reduction取16是经验值。太小比如4会保留较多通道信息但增加参数量太大比如64会压缩过度丢失判别力。7×7的卷积核是空间注意力模块的默认配置这个尺寸能覆盖足够大的感受野让模型看到目标周围的上下文而不是孤立的几个像素。2.3 模块放置位置与附加开销CBAM接入主干网络的位置不同效果差异很明显。我做了三种方案的对比每个卷积层后都接、每个Stage后接、只在最后一个Stage输出后接。实验下来每个Stage后接是性价比最高的方案。以ResNet50为例它的四个Stage输出通道数是256、512、1024、2048分辨率从56×56一路降到7×7。在每个Stage后面插入CBAM参数量增加大约几十万在总参数4000多万的模型里几乎可以忽略但准确率提升最明显。如果每个卷积层后都接一方面训练速度明显变慢另一方面低层特征图分辨率太高空间注意力模块里的7×7卷积会消耗更多显存和算力收益却不成比例。只在最后一个Stage后接虽然能完成整体的特征重标定但对浅层局部特征缺乏引导细粒度分类的提升空间有限。这里我给一个参考数据在ResNet50基础上插入4个CBAM额外参数量约占原模型的0.8%左右额外计算量约占1.5%在V100上训练一轮的时间增加不到5%。相比提升的准确率这点开销完全可以接受。3. 数据集处理与模型训练实操3.1 数据准备与目录结构数据这块我提供了两种路径。如果想要可复现的基准实验直接用Food-101官方数据集下载解压后目录结构如下data/food-101/ images/ apple_pie/ 1000649.jpg 1001016.jpg ... beef_carpaccio/ ... meta/ train.txt test.txt官方给出的train.txt和test.txt里是图片相对路径清洗一下就能用来做训练集和测试集的划分。如果想贴合新年场景做一些扩展可以在Food-101基础上把“春节家宴”常见的菜品图片作为额外验证集比如红烧肉、清蒸鱼、饺子、年糕、汤圆这些类别。不需要重新训练直接用来测试模型的泛化能力看看模型在真实餐桌环境下能不能扛住。我自己实际动手时还写了一个简单的脚本梳理文件名把所有类别的图片放进统一的文件夹并用字典保留类别到id的映射。Food-101的类别名里很多是英文菜名比如apple_pie、beef_carpaccio转成整数标签时要注意顺序一致性不然训练和评估会错位。3.2 数据增强与训练配置数据增强是美食分类的重头戏。新年餐桌的照片有很明显的环境特征光线偏暖、桌面纹理复杂、餐具和手部遮挡多。如果只做简单的RandomResizedCrop和RandomHorizontalFlip模型很难泛化到真实场景。我最终采用的增强流程是RandomResizedCrop(224)RandomHorizontalFlipColorJitter做亮度、对比度、饱和度调整最后加一个RandomErasing随机擦除。ColorJitter的幅度我调得比较激进亮度0.4、对比度0.4、饱和度0.4因为不同手机、不同环境下拍美食照片色彩偏移非常严重。RandomErasing则是模拟餐具和手部遮挡强迫模型去学习更鲁棒的特征而不是依赖某一个局部区域。训练配置方面我用了两阶段策略。第一阶段冻结Backbone只训练新加的分类头和CBAM模块学习率1e-3训练10个epoch。第二阶段解冻全部参数学习率降到1e-4用CosineAnnealingLR做余弦退火继续训练40到60个epoch。优化器选SGDmomentum0.9weight_decay1e-4batch size32。这个组合在多个视觉任务上都很稳不容易跑飞。这里的逻辑是CBAM模块是随机初始化的如果一开始就解冻全部参数刚初始化的注意力模块可能会产生较大的梯度波动扰乱预训练模型的稳定特征。先冻结训练让注意力模块适应预训练特征的分布再一起微调能有效减少震荡。3.3 完整训练流程与代码框架把CBAM嵌入ResNet50我用了一个很简洁的改造方式不直接修改torchvision的ResNet源码而是写一个子类在forward的时候手动插入CBAM。import torch import torch.nn as nn import torchvision.models as models class ResNet50WithCBAM(nn.Module): def __init__(self, num_classes101, reduction16): super().__init__() self.backbone models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) self.features nn.Sequential( self.backbone.conv1, self.backbone.bn1, self.backbone.relu, self.backbone.maxpool, self.backbone.layer1, ) self.cbam1 CBAM(256, reduction) self.stage2 self.backbone.layer2 self.cbam2 CBAM(512, reduction) self.stage3 self.backbone.layer3 self.cbam3 CBAM(1024, reduction) self.stage4 self.backbone.layer4 self.cbam4 CBAM(2048, reduction) self.pool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(2048, num_classes) def forward(self, x): x self.features(x) x self.cbam1(x) x self.stage2(x) x self.cbam2(x) x self.stage3(x) x self.cbam3(x) x self.stage4(x) x self.cbam4(x) x self.pool(x).flatten(1) x self.fc(x) return x训练主循环的标准写法这里就不完整展开了有一个容易被忽略的点是训练和验证模式切换。CBAM里的BatchNorm虽然在验证时会被全局统计量替代但务必调用model.train()和model.eval()否则验证阶段输入输出不稳定。我习惯在训练脚本里加上几个监控点每50个batch打印一次当前loss、学习率、batch耗时每个epoch结束统计验证集的Top-1和Top-5准确率并保存最优模型。日志文件命名带上时间戳方便回溯。训练命令我用了标准的单机单卡模式python train.py --data_dir data/food-101 --batch_size 32 --epochs 60 --lr 1e-4 --gpu 0如果显存不够batch size可以降到16相应地把学习率也降到5e-5保证梯度更新的幅度大致一致。3.4 对比实验Baseline vs CBAM整个项目最有说服力的部分是消融对比。我在相同的数据划分、增强策略、训练配置下分别训练了纯ResNet50和ResNet50CBAM两个模型。数据没有特别处理严格按Food-101官方的train/test划分来。我本地一次完整训练的结果如下不同设备和随机种子会有浮动但相对趋势基本一致模型参数量Top-1准确率Top-5准确率ResNet5023.9M82.6%95.1%ResNet50 CBAM24.3M84.3%96.2%Top-1准确率提升1.7个百分点Top-5提升1.1个百分点。在Food-101这种成熟数据集上没有架构级改动的情况下能稳定提升1个点以上已经说明CBAM对细粒度分类确有帮助。我还单独统计了混淆矩阵发现提升最大的类别集中在容易混淆的几组不同做法的鱼类料理、不同国家的汤品、外观接近的甜点。这些类别名提交给热力图工具后可以明显看到Baseline模型在分类时关注的区域比较分散经常落在盘子和背景桌布上加了CBAM之后注意力重心明显向食材本身集中。这说明空间注意力确实在发挥作用让模型学会了“看该看的地方”。4. 常见问题与排查技巧实录4.1 训练不收敛或Loss起飞训练过程中最常见的两个问题Loss直接变NaN或者验证集准确率纹丝不动。Loss变NaN排查顺序是先降学习率。CBAM模块的Sigmoid输出一旦饱和梯度会极度萎缩但更常见的原因是Backbone解冻后学习率太大导致梯度爆炸。我用的是先冻结训练10个epoch再解冻解冻前务必确认当前学习率已经降到1e-4以下。如果Loss正常下降但验证集准确率不涨先检查数据加载是否正常。我遇到过两次一次是训练集和测试集共用了同一个目录导致测试时看到了训练图片另一次是数据增强里忘了关闭RandomErasing验证阶段也做了随机擦除把图片擦掉一大块自然识别不准。验证阶段必须把增强全部关掉只保留Resize和Normalize。4.2 过拟合与细粒度分类难点的应对Food-101每类750张训练图对ResNet50来说数据量不算大但如果训练轮数过多仍然会出现训练集准确率98、验证集85的情况。我的应对方案有三层。第一层是数据增强除了常规翻转和裁剪我额外开了RandomErasing和MixUp。MixUp的alpha设为0.2增加一点点就好太大会让模型学不到干净的类别特征。第二层是正则化把weight_decay从默认的1e-4调到5e-4限制权重的增长幅度。第三层是早停监控验证集Top-1连续8个epoch不上升就停止训练保存最优模型。细粒度分类上最容易翻车的是颜色相近的菜。比如南瓜汤和胡萝卜汤如果不做色彩扰动模型极有可能直接按色调分类。我把ColorJitter的饱和度范围调到0.2到1.8强制模型不能只靠颜色判断必须去关注形状和纹理的差异。4.3 注意力可视化让模型解释“哪里像”做完分类还不够我希望模型能告诉我“为什么觉得这道菜是红烧肉”。这里用到了Grad-CAM。流程很简单选定一个测试样本取最后一个Stage输出的特征图对预测类别的logit做反向传播求特征图的梯度再按通道加权得到热力图。我实际使用的工具是pytorch_grad_cam库和CBAM配合得很好。import cv2 import numpy as np import torch from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image model.eval() target_layers [model.cbam4] cam GradCAM(modelmodel, target_layerstarget_layers) input_tensor preprocess_image(test_red_braised_pork.jpg) grayscale_cam cam(input_tensorinput_tensor, target_categorypred_class_id)[0] visualization show_cam_on_image(rgb_image.astype(np.float32) / 255.0, grayscale_cam, use_rgbTrue) cv2.imwrite(heatmap_result.jpg, visualization)观察热力图后我发现纯ResNet50的热力图经常把高响应区覆盖到盘子边缘和桌布纹理上而加了CBAM的模型热力图明显向菜品主体收缩。对错分样本做可视化尤其有价值有一次模型把糖醋里脊误判成菠萝咕咾肉热力图显示它注意力集中在了糖醋汁的颜色区域两种菜颜色确实高度相似。这说明模型已经学到了相对合理的局部特征只是类别的边界本身模糊。4.4 推理提速与部署小技巧模型在实验室跑通之后要考虑实际推理场景。我做了三件小事推理效率提升很明显。第一把模型切到eval模式并将BatchNorm层固定为全局统计量。这一步能消除BatchNorm在单张推理时的不稳定统计同时带来微小的加速。第二用torch.compile编译模型在V100上实测推理吞吐提升约15%。第三导出ONNX。用torch.onnx.export导出时把opset_version设为12以上动态轴设置batch维度方便服务端做动态batch。dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, food101_cbam.onnx, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}, logits: {0: batch}}, opset_version12, )导出后建议装个onnxruntime试跑一遍对照PyTorch输出的logits是否一致。由于BatchNorm融合和算子差异理论上会出现微小浮点误差但Softmax后的类别排序不应变化。如果Top-1结果不稳定大概率是预处理阶段的Normalize参数不一致排查一下数据管线和模型输入的通道顺序。部署层面的一个提示美食分类服务如果架在云端首屏响应时间通常控制在300毫秒以内。ResNet50CBAM在T4上用TensorRT FP16推理单张耗时约20毫秒完全够用。如果换了CPU部署可以进一步量化到INT8但我实测INT8对Food-101的Top-1会带来1个百分点左右的损失需要根据业务场景权衡。最后再分享一个小经验模型训练完之后不要急着删数据把每张测试图与预测类别、置信度、Grad-CAM热力图一起保存下来。等到春节家宴那天现场拍一张红烧肉模型正确识别并输出热力图的那一刻你会觉得训练时熬的夜都值了。注意力机制不是玄学它就是给网络加了一种聚焦的能力而美食鉴赏这个场景恰好是这种能力最直观的展示窗口。
返回列表