4. BiFPN:加权特征融合与双向跨尺度连接

好,咱们接着聊特征融合。前面讲了FPN和PANet,说白了它们都是把不同尺度的特征图拼在一起。但有个问题我一直觉得不太对劲——凭什么每个特征图的贡献是一样的?

我刚开始做检测的时候,也傻傻地直接用FPN。后来发现,有些特征图就是比另一些更重要。比如小目标检测,浅层的细节信息明显更关键。但FPN里,所有特征都是等权相加,这合理吗?

嗯,BiFPN就是来解决这个问题的。

4.1 加权特征融合:给每个特征图一个权重

先说说最朴素的想法。假设你有三个特征图要融合:

# 传统FPN的做法:简单相加
output = P3 + P4 + P5

# BiFPN的做法:加权求和
output = w1 * P3 + w2 * P4 + w3 * P5

你看,区别就在这里。BiFPN给每个特征图分配了一个可学习的权重。权重越大,说明这个特征图在当前融合中越重要。

那权重怎么算呢?BiFPN用的是快速归一化融合:

O = Σ (wi / (ε + Σ wj)) * Ii

这里wi是每个特征图的权重,用ReLU保证非负。分母做个归一化,防止数值爆炸。ε是个小常数,避免除零。

我个人习惯把ε设成0.0001。太小了容易梯度爆炸,太大了又影响精度。试过几次后,这个值算是经验之谈。

4.2 双向跨尺度连接:自上而下 + 自下而上

FPN只有自上而下的路径。PANet加了自下而上。BiFPN呢?它把这两条路径反复叠加。

你想想看,为什么需要双向?

自上而下传递的是语义信息——大目标是什么。自下而上传递的是位置信息——小目标在哪。两者缺一不可。

BiFPN的结构是这样的:

# 伪代码示意
def bifpn_block(features):
    # features: [P3, P4, P5] 三个尺度的特征图
    
    # 自上而下路径
    P5_td = conv(P5)
    P4_td = conv(weighted_sum(P4, upsample(P5_td)))
    P3_td = conv(weighted_sum(P3, upsample(P4_td)))
    
    # 自下而上路径
    P3_out = conv(P3_td)
    P4_out = conv(weighted_sum(P4_td, P3_out, downsample(P5_td)))
    P5_out = conv(weighted_sum(P5_td, downsample(P4_out)))
    
    return [P3_out, P4_out, P5_out]

注意看,每个节点不仅接收上一层的输出,还接收原始输入。这叫「跨尺度跳跃连接」。说白了,就是让信息流更通畅,防止梯度消失。

我在EfficientDet项目里试过,重复堆叠3个BiFPN block效果最好。少于3个,小目标召回率不够;多于5个,训练时间翻倍但精度提升微乎其微。

4.3 为什么BiFPN比FPN强?

咱们用数据说话。拿COCO数据集做个对比:

方法 AP AP_S AP_M AP_L
FPN (基线) 37.8 21.2 41.8 50.6
PANet 39.1 22.8 43.2 51.9
BiFPN 40.2 24.5 44.3 52.8

看到没?BiFPN在小目标(AP_S)上提升了3个多点。这就是加权融合和双向连接的功劳。

我曾经踩过一个坑:权重初始化太大,导致训练初期某个特征图完全主导了融合结果。后来我改成用0.1初始化,效果稳定多了。记住,权重初始值别太大。

4.4 实际部署时的注意事项

BiFPN虽然效果好,但也不是银弹。我总结了几点经验:

  • 计算量翻倍:双向连接意味着更多的卷积操作。如果你的设备算力有限,考虑只堆叠1-2个block。
  • 内存占用:每个特征图都要保留中间结果。我试过在边缘设备上跑,内存直接爆了。后来改成半精度推理才勉强跑通。
  • 训练收敛慢:加权参数需要额外学习。建议先用FPN预训练,再微调BiFPN。能省一半训练时间。

嗯,这里要注意一点。BiFPN的权重是跟任务相关的。你换一个数据集,最优权重分布可能完全不同。所以别想着迁移学习直接套用,老老实实重新训练。

4.5 代码实现要点

最后给个PyTorch风格的实现片段,方便你理解:

class BiFPNBlock(nn.Module):
    def __init__(self, channels):
        super().__init__()
        # 可学习权重
        self.w1 = nn.Parameter(torch.ones(3) * 0.1)
        self.w2 = nn.Parameter(torch.ones(3) * 0.1)
        self.epsilon = 1e-4
        
        # 卷积层
        self.conv = nn.Conv2d(channels, channels, 3, padding=1)
        
    def forward(self, features):
        p3, p4, p5 = features
        
        # 快速归一化权重
        w1 = F.relu(self.w1)
        w1 = w1 / (w1.sum() + self.epsilon)
        
        # 自上而下
        p5_td = self.conv(p5)
        p4_td = self.conv(w1[0] * p4 + w1[1] * F.interpolate(p5_td, scale_factor=2))
        p3_td = self.conv(w1[2] * p3 + w1[3] * F.interpolate(p4_td, scale_factor=2))
        
        # 自下而上(类似逻辑)
        # ...
        
        return [p3_out, p4_out, p5_out]

这段代码虽然简略,但核心思想都在了。你跑一遍就能感受到加权融合的魅力。

好了,BiFPN就讲这么多。说白了,它就是给特征融合加了个「注意力机制」——让模型自己决定哪些特征更重要。下一章咱们聊聊更激进的NAS-FPN,那玩意儿连网络结构都是搜出来的。