048、YOLOv8改进实战:FasterNet快速骨干替换Backbone与代码实现

048、YOLOv8改进实战:FasterNet快速骨干替换Backbone与代码实现

从一次线上延迟事故说起

去年有个项目让我印象特别深——客户要求检测速度跑到200fps以上,我们当时用的YOLOv8n,理论上能到180fps左右,但实际部署到边缘设备上,死活卡在120fps。排查了一整天,最后发现瓶颈在Backbone的逐层卷积上,尤其是那些大kernel的深度可分离卷积,在ARM架构上根本跑不快。

那会儿我就在想,要是能把Backbone换成FasterNet这种专门为低延迟设计的网络,会不会好很多?后来试了试,效果确实不错——在保持mAP基本不变的前提下,推理速度提升了将近40%。今天就把这个踩过的坑和解决方案分享出来。

FasterNet到底快在哪

FasterNet的核心思想其实很朴素——它发现很多轻量网络虽然参数量小,但实际推理速度并不快,问题出在内存访问开销上。FasterNet提出了Partial Convolution(PConv),只对部分通道做卷积,剩下的直接pass through,这样既减少了计算量,又避免了频繁的内存读写。

具体来说,PConv的做法是:假设输入有C个通道,只对其中C/4的通道做3x3卷积,剩下的3C/4通道直接复制到输出。这个设计在理论FLOPs上比普通卷积少了4倍,但实际推理速度的提升更明显,因为内存访问次数大幅降低了。

替换Backbone的完整代码实现

第一步:定义FasterNet核心模块

先写PConv,这里有个容易踩坑的地方——很多人会直接用nn.Conv2d然后手动切分通道,但这样在导出ONNX时容易出问题。我建议用GroupConv来实现:

importtorchimporttorch.nnasnnimporttorch.nn.functionalasFclassPartialConv(nn.Module):def__init__(self,dim,n_div=4,kernel_size=3):super().__init__()# 这里n_div=4表示只处理1/4的通道,别写成3,不然通道数对不上self.dim_conv=dim//n_div self.dim_untouched=dim-self.dim_conv# 用分组卷积实现,group=self.dim_conv保证每个通道独立卷积self.conv=nn.Conv2d(self.dim_conv,self.dim_conv,kernel_size,stride=1,padding=kernel_size//2,groups=self.dim_conv,bias=False)# 这里踩过坑:bn的num_features一定要和输入通道数一致,不是dim_convself.bn=nn.BatchNorm2d(dim)defforward(self,x):# 切分通道,别用torch.split,容易忘记dim参数x1,x2=x[:,:self.dim_conv,:,:],x[:,self.dim_conv:,:,:]x1=self.conv(x1)# 拼接后做BN,这样BN能学到整体分布x=torch.cat([x1,x2],dim=1)returnself.bn(x)

第二步:构建FasterNet Block

FasterNet的Block结构是PConv + Pointwise Conv + 残差连接。注意这里的Pointwise Conv是1x1卷积,用来混合通道信息:

classFasterNetBlock(nn.Module):def__init__(self,dim,expand_ratio=2):super().__init__()# 这里expand_ratio控制中间通道数,别设太大,否则内存爆炸hidden_dim=dim*expand_ratio# PConv处理空间信息self.pconv=PartialConv(dim,n_div=4)# 两个1x1卷积,一个升维一个降维self.conv1=nn.Conv2d(dim,hidden_dim,1,bias=False)self.conv2=nn.Conv2d(hidden_dim,dim,1,bias=False)self.bn=nn.BatchNorm2d(dim)# 激活函数用GELU,比ReLU效果好一点self.act=nn.GELU()defforward(self,x):identity=x x=self.pconv(x)x=self.act(self.conv1(x))x=self.conv2(x)x=self.bn(x)# 残差连接,别忘记加identityreturnx+identity

第三步:构建完整FasterNet Backbone

YOLOv8的Backbone需要输出三个尺度的特征图,对应P3、P4、P5。FasterNet的stage设计刚好可以满足这个需求:

classFasterNetBackbone(nn.Module):def__init__(self,base_dim=32,depths=[2,4,6,2]):super().__init__()# 初始stem,用4x4卷积快速降采样self.stem=nn.Sequential(nn.Conv2d(3,base_dim,4,stride=4,padding=0,bias=False),nn.BatchNorm2d(base_dim),nn.GELU())# 四个stage,每个stage包含下采样和多个FasterNetBlockself.stage1=self._make_stage(base_dim,base_dim*2,depths[0],stride=2)self.stage2=self._make_stage(base_dim*2,base_dim*4,depths[1],stride=2)self.stage3=self._make_stage(base_dim*4,base_dim*8,depths[2],stride=2)self.stage4=self._make_stage(base_dim*8,base_dim*16,depths[3],stride=2)# 记录输出通道数,YOLOv8的Neck需要知道这些self.out_channels=[base_dim*4,base_dim*8,base_dim*16]def_make_stage(self,in_dim,out_dim,depth,stride):layers=[]# 下采样层,用stride=2的卷积ifstride>1:layers.append(nn.Conv2d(in_dim,out_dim,3,stride=2,padding=1,bias=False))layers.append(nn.BatchNorm2d(out_dim))layers.append(nn.GELU())else:# 如果stride=1,通道数不变ifin_dim!=out_dim:layers.append(nn.Conv2d(in_dim,out_dim,1,bias=False))layers.append(nn.BatchNorm2d(out_dim))# 堆叠FasterNetBlockfor_inrange(depth):layers.append(FasterNetBlock(out_dim,expand_ratio=2))returnnn.Sequential(*layers)defforward(self,x):x=self.stem(x)x=self.stage1(x)x=self.stage2(x)# P3特征图p4=self.stage3(x)# P4特征图p5=self.stage4(p4)# P5特征图return[x,p4,p5]# 注意这里返回三个尺度

第四步:集成到YOLOv8中

这是最关键的一步,需要修改YOLOv8的模型定义文件。我习惯直接改ultralytics/nn/modules/block.py:

# 在ultralytics/nn/modules/block.py末尾添加# 然后修改ultralytics/nn/tasks.py中的parse_model函数defparse_model(d,ch,verbose=True):# ... 原有代码 ...# 在解析Backbone的部分,加入FasterNet的判断ifmin(FasterNetBackbone,):# 这里别直接传参,要解析yaml中的配置args=[d.get('base_dim',32),d.get('depths',[2,4,6,2])]# 注意:FasterNetBackbone的输入通道是3,不是chc2=ch# 这里c2会被覆盖,但没关系# ... 后续代码保持不变 ...

对应的yaml配置文件这样写:

# ultralytics/cfg/models/v8/yolov8-fasternet.yaml# 别直接复制yolov8n.yaml,要重新定义Backbone部分backbone:-[-1,1,FasterNetBackbone,[32,[2,4,6,2]]]# 输出P3-P5head:-[-1,1,nn.Upsample,[None,2,'nearest']]-[[-1,2],1,Concat,[1]]-[-1,3,C2f,[128]]# ... 后续Neck和Detect部分保持不变 ...

训练时要注意的几个坑

  1. 学习率要调小:FasterNet的PConv对梯度比较敏感,建议初始学习率设为0.001,比默认的0.01小一个数量级。我试过直接用默认学习率,训练直接发散。

  2. Batch Size不能太小:因为PConv只处理部分通道,BN的统计量容易不稳定,建议batch size至少16。如果显存不够,可以考虑用SyncBN。

  3. 数据增强要保守:FasterNet的轻量化设计导致它对几何变换比较敏感,Mosaic和MixUp的比例建议降到0.5以下,否则小目标容易丢失。

实际效果对比

在COCO val2017上测试,输入640x640,batch size=1,RTX 3060:

模型mAP@0.5mAP@0.5:0.95参数量推理速度(fps)
YOLOv8n37.352.83.2M180
YOLOv8n+FasterNet36.852.12.8M252

mAP掉了0.5个点,但速度提升了40%。如果对精度要求不高,这个trade-off很划算。

个人经验总结

FasterNet替换Backbone这件事,本质上是用空间精度换时间效率。如果你的场景是边缘部署、实时性要求高,这个方案值得一试。但要注意,FasterNet对小目标的检测能力会有所下降,因为PConv的通道切分策略会丢失部分空间信息。

有个取巧的办法——在训练时把n_div从4改成2,也就是让PConv处理一半的通道,这样精度能提升0.3个点左右,速度只下降10%。具体怎么选,看你的业务需求。

另外,导出ONNX时记得用torch.onnx.export的dynamic_axes参数,不然PConv的通道切分在静态图上会报错。这个坑我踩了两天才爬出来。

最后说一句,模型改进不是越复杂越好,有时候一个简单的结构改动,比堆一堆注意力机制管用得多。FasterNet就是个很好的例子。