ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TensorFlow 2实战:MobileNet V1/V2/V3骨干替换YOLOv4目标检测

TensorFlow 2实战:MobileNet V1/V2/V3骨干替换YOLOv4目标检测

1. 为什么要在TensorFlow 2上折腾MobileNet版YOLOv4

目标检测这个方向,YOLO系列一直是工程落地的主力。YOLOv4在精度和速度的平衡上做得相当出色,但原版基于Darknet框架,CSPDarknet53作为骨干网络参数量不小,直接往边缘设备上塞,推理延迟和内存占用都会让人头疼。我最早在几个嵌入式项目里尝试过直接部署原版YOLOv4,结果在算力有限的板子上帧率惨不忍睹,后来才转向用轻量级骨干网络替换的思路。

MobileNet系列是Google为移动端和嵌入式场景专门设计的轻量级网络,从V1到V3经历了三代演进,核心思路都是用深度可分离卷积替代标准卷积来压缩计算量。把MobileNet作为YOLOv4的骨干网络,理论上可以在保持可接受精度的前提下,把模型体积和推理时间压到原来的几分之一。而TensorFlow 2相比1.x版本,在动态图、Keras集成、部署工具链上都有明显改善,用TF2来搭建这套平台,后续导出SavedModel、转TFLite、上TensorRT都会顺畅很多。

这套方案适合谁?如果你手头有边缘计算设备需要跑目标检测,或者你想在自己的数据集上快速训练一个轻量检测器,又或者你单纯想搞清楚YOLOv4的各个组件怎么在TF2里从零搭起来,那这篇内容应该能帮到你。我会把MobileNet V1/V2/V3三种骨干的接入方式都讲清楚,包括为什么这么选、每个模块的原理、实际训练中会遇到什么问题,以及我踩过的那些坑。

需要提前说明的是,YOLOv4的完整结构包含骨干网络、颈部网络(SPP+PANet)和检测头三部分。换骨干网络只影响第一部分,后面的结构和损失函数逻辑保持不变。所以理解这套代码的关键在于:搞清楚MobileNet的输出特征图怎么和YOLOv4的颈部网络对接。

2. MobileNet三代骨干的核心差异与选型依据

2.1 V1的深度可分离卷积到底省在哪里

MobileNet V1的核心创新就是深度可分离卷积(Depthwise Separable Convolution)。标准卷积在做一个3x3卷积时,每个输出通道都要和所有输入通道做卷积运算,计算量是Dk * Dk * M * N * Df * Df,其中Dk是卷积核尺寸,M是输入通道数,N是输出通道数,Df是特征图尺寸。深度可分离卷积把它拆成两步:先做逐通道卷积(Depthwise Convolution),每个通道独立卷,计算量是Dk * Dk * M * Df * Df;再做逐点卷积(Pointwise Convolution),用1x1卷积做通道融合,计算量是M * N * Df * Df。

两者相除,计算量压缩比大约是1/N + 1/Dk²。以3x3卷积为例,理论上能省8到9倍的计算量。实际部署中,虽然深度可分离卷积的内存访问模式不如标准卷积友好,但在算力受限的设备上,这个计算量节省是实打实的。

V1还引入了两个超参数:宽度乘子(Width Multiplier)和分辨率乘子(Resolution Multiplier)。宽度乘子控制每层通道数,取0.25到1.0之间的值;分辨率乘子控制输入图像尺寸。这两个参数让你可以在精度和速度之间灵活权衡。我在实际项目里通常先用宽度乘子1.0、输入416x416跑一版基线,如果延迟不达标再往下调。

2.2 V2的倒残差结构解决了什么问题

V1有个问题:深度可分离卷积的逐通道卷积部分,卷积核参数量太少,训练时容易退化,梯度传播也不够顺畅。V2引入了倒残差结构(Inverted Residual Block),核心思路是"先升维、再卷积、后降维"。

具体来说,标准残差块是"先降维、再卷积、后升维",两头宽中间窄;倒残差反过来,先用1x1卷积把通道数扩大(通常扩大6倍),然后在高维空间做3x3深度卷积,最后再用1x1卷积压回低维。这样做的好处是深度卷积在高维空间进行,能提取更丰富的特征,同时残差连接加在低维的输入输出之间,保证了梯度回传。

V2还去掉了V1中深度卷积后面的ReLU6激活,改用线性激活(Linear Bottleneck)。原因是ReLU在高维空间做非线性激活后,再降维到低维时信息损失较大,用线性激活能保留更多信息。这个细节在实现时很容易忽略,但影响不小。

2.3 V3的SE模块和h-swish激活值不值得上

V3在V2的基础上引入了两个关键改进:SE模块(Squeeze-and-Excitation)和h-swish激活函数。SE模块是一个通道注意力机制,通过全局平均池化把每个通道压缩成一个标量,然后经过两个全连接层学习通道间的权重,最后乘回原特征图。这个操作能让网络自适应地强调重要通道、抑制不重要通道。

h-swish是对swish激活函数的硬件友好近似。swish是x * sigmoid(x),计算sigmoid在移动端比较费时,h-swish用ReLU6来近似:x * ReLU6(x+3) / 6。实测下来,h-swish在深层网络中带来的精度提升比较明显,但在浅层用普通ReLU就够了,因为浅层特征图分辨率高,h-swish的计算开销相对更大。

那到底选哪个版本?我的经验是:如果追求极致轻量,V1最省;如果精度优先且设备算力尚可,V3最好;V2是折中选择。在YOLOv4的框架下,V3作为骨干通常能比V1高出2到4个mAP点,但推理时间也会增加30%到50%。具体选哪个,得看你的延迟预算和精度要求。

版本核心模块参数量(约)适用场景
V1深度可分离卷积4.2M极低算力设备,延迟敏感
V2倒残差+线性瓶颈3.4M平衡场景,通用部署
V3SE模块+h-swish5.4M精度优先,算力尚可

3. 在TF2里从零搭建MobileNet骨干网络

3.1 用Keras函数式API定义V1的卷积块

TF2的Keras API让网络定义变得很直观。V1的卷积块可以封装成一个函数,输入是特征图和输出通道数,内部依次做3x3深度卷积、BN、ReLU6,再跟1x1逐点卷积、BN、ReLU6。这里有个细节:深度卷积的padding要设为same,步长根据是否需要下采样来定。

import tensorflow as tf from tensorflow.keras import layers, Model def conv_block_v1(inputs, filters, strides=1): x = layers.DepthwiseConv2D(kernel_size=3, strides=strides, padding='same', use_bias=False)(inputs) x = layers.BatchNormalization()(x) x = layers.ReLU(max_value=6.0)(x) x = layers.Conv2D(filters=filters, kernel_size=1, strides=1, padding='same', use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.ReLU(max_value=6.0)(x) return x

V1的完整结构是:一个标准3x3卷积(步长2)做初始下采样,然后堆叠13个深度可分离卷积块,其中部分块步长为2用于下采样。最终输出特征图尺寸是输入尺寸的1/32。对于YOLOv4,我们需要的是最后三个尺度的特征图,分别对应1/8、1/16、1/32的下采样率。

3.2 V2倒残差块的实现与通道扩展系数

V2的倒残差块实现稍微复杂一点,需要处理残差连接的条件。只有当步长为1且输入输出通道数相同时,才能加残差连接。通道扩展系数通常取6,但也可以根据宽度乘子调整。

def inverted_residual_block(inputs, filters, strides, expand_ratio=6): input_channels = inputs.shape[-1] expanded_channels = input_channels * expand_ratio x = layers.Conv2D(expanded_channels, 1, use_bias=False)(inputs) x = layers.BatchNormalization()(x) x = layers.ReLU(max_value=6.0)(x) x = layers.DepthwiseConv2D(3, strides=strides, padding='same', use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.ReLU(max_value=6.0)(x) x = layers.Conv2D(filters, 1, use_bias=False)(x) x = layers.BatchNormalization()(x) if strides == 1 and input_channels == filters: x = layers.Add()([inputs, x]) return x

注意最后一个1x1卷积后面没有激活函数,这是线性瓶颈的体现。我在第一次实现时习惯性地加了ReLU,结果精度掉了将近1个点,排查了好久才发现是这个原因。

3.3 V3的SE模块与h-swish激活的代码落地

V3的SE模块需要先做全局平均池化,然后两个全连接层,最后sigmoid激活。h-swish的实现要注意数值稳定性,用ReLU6来避免溢出。

def se_block(inputs, reduction=4): channels = inputs.shape[-1] x = layers.GlobalAveragePooling2D()(inputs) x = layers.Reshape((1, 1, channels))(x) x = layers.Conv2D(channels // reduction, 1, activation='relu')(x) x = layers.Conv2D(channels, 1, activation='sigmoid')(x) return layers.Multiply()([inputs, x]) def h_swish(x): return x * tf.nn.relu6(x + 3.0) / 6.0

V3的bottleneck块在倒残差的基础上,深度卷积后加了SE模块,激活函数在特定层用h-swish。实际实现时,我建议把h-swish封装成一个自定义层,这样在模型保存和加载时不会出问题。

3.4 三个尺度特征图的提取与对齐

YOLOv4需要骨干网络输出三个尺度的特征图。对于416x416的输入,MobileNet系列的下采样率分别是1/8、1/16、1/32,对应52x52、26x26、13x13的特征图。在构建骨干网络时,需要记录这些中间层的输出。

def build_mobilenet_backbone(input_shape=(416, 416, 3), version='v2'): inputs = layers.Input(shape=input_shape) # ... 构建各层 ... # 假设已经得到三个特征图 return Model(inputs, [feat_small, feat_medium, feat_large])

这里有个容易踩的坑:不同版本的MobileNet下采样位置不同,提取特征图的层索引需要根据实际结构来定。我建议在构建时打印每层的输出形状,确认三个特征图的尺寸正确后再往下接颈部网络。

4. 把MobileNet骨干接入YOLOv4颈部与检测头

4.1 SPP模块的多尺度池化配置

YOLOv4的SPP模块加在骨干网络输出的最深层特征图上,用不同尺寸的池化核来增加感受野。标准配置是13x13、9x9、5x5、1x1四种池化,然后把结果拼接起来。对于MobileNet骨干,最深层特征图是13x13,池化核尺寸要相应调整,否则会出问题。

def spp_block(inputs): x1 = layers.MaxPooling2D(pool_size=13, strides=1, padding='same')(inputs) x2 = layers.MaxPooling2D(pool_size=9, strides=1, padding='same')(inputs) x3 = layers.MaxPooling2D(pool_size=5, strides=1, padding='same')(inputs) x4 = inputs x = layers.Concatenate()([x1, x2, x3, x4]) x = layers.Conv2D(inputs.shape[-1], 1, use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.LeakyReLU(alpha=0.1)(x) return x

注意池化核尺寸不能超过特征图尺寸,13x13的特征图用13x13池化刚好,但如果输入分辨率变了,这个配置也要跟着调。

4.2 PANet的双向特征融合路径

PANet在FPN的基础上增加了一条自底向上的路径。FPN是高层特征上采样后与低层特征融合,PANet再加一条低层特征下采样后与高层特征融合的路径。这样做的目的是让浅层的定位信息和深层的语义信息充分交互。

在实现时,上采样用最近邻插值,下采样用步长为2的3x3卷积。每次融合后跟一个CSP块(Cross Stage Partial),这个块把特征图分成两部分,一部分经过卷积处理后再和另一部分拼接,能减少计算量同时保持梯度流动。

4.3 检测头的锚框分配与损失函数

YOLOv4的检测头在每个尺度上预测三个锚框。锚框尺寸需要根据你的数据集重新聚类,不能直接用COCO的配置。我通常用K-means对训练集的标注框做聚类,得到9个锚框,然后按面积分配到三个尺度上。

损失函数包括三部分:边界框回归损失(CIoU Loss)、置信度损失(二分类交叉熵)、分类损失(二分类交叉熵)。CIoU Loss相比IoU Loss考虑了中心点距离和长宽比,收敛更快。

def yolo_loss(y_true, y_pred, anchors, num_classes): # 解码预测框 # 计算CIoU损失 # 计算置信度和分类损失 return total_loss

实际训练时,我建议先用较小的学习率预热几个epoch,再进入正常训练。因为MobileNet骨干是预训练权重初始化的,而颈部网络是随机初始化的,直接上大学习率容易把预训练权重带偏。

4.4 预训练权重的加载与冻结策略

MobileNet在ImageNet上的预训练权重可以直接从Keras Applications加载。但要注意,YOLOv4的输入分辨率通常是416x416或608x608,而ImageNet预训练是224x224,输入尺寸不匹配会影响BN层的统计量。

我的做法是:先加载预训练权重,冻结骨干网络训练几个epoch让颈部网络先适应,然后解冻全部网络做微调。解冻时学习率要调小,通常是初始学习率的十分之一。如果显存不够,可以只解冻骨干网络的后半部分。

5. 训练调参中那些文档不会告诉你的细节

5.1 学习率预热与余弦退火的组合策略

YOLOv4原版用的是步进式学习率衰减,但在TF2里我更推荐余弦退火配合预热。预热阶段用线性增长把学习率从0升到初始值,然后余弦退火慢慢降到接近0。这样训练曲线更平滑,最终精度通常也更好。

def lr_schedule(epoch, lr): if epoch < warmup_epochs: return initial_lr * (epoch + 1) / warmup_epochs progress = (epoch - warmup_epochs) / (total_epochs - warmup_epochs) return initial_lr * 0.5 * (1 + tf.cos(tf.constant(3.14159) * progress))

初始学习率我一般设1e-3,warmup用5个epoch,总epoch数根据数据集大小定,通常100到300个epoch。如果数据集小,可以适当减少epoch数但增加数据增强强度。

5.2 数据增强的Mosaic与MixUp实际效果对比

Mosaic是YOLOv4的招牌增强,把四张图拼成一张,能大幅增加训练样本的多样性,特别是对小目标检测效果明显。MixUp是把两张图按比例混合,标签也按比例混合。实测下来,Mosaic对小目标提升明显,MixUp对分类精度有帮助但对定位精度提升有限。

我通常两个都用,但MixUp的概率设低一些,0.1到0.2就够了。Mosaic的概率可以设0.5到1.0。注意Mosaic在训练后期可以关掉,让模型在真实分布上做最后的微调。

5.3 正负样本平衡与CIoU损失的调参经验

YOLOv4的正负样本分配用的是跨网格搜索策略,一个真实框可能匹配多个锚框。这会导致正样本数量增加,需要调整损失权重来平衡。置信度损失的权重我一般设1.0,分类损失设1.0,CIoU损失设0.05。如果发现模型倾向于预测背景,可以适当提高置信度损失的权重。

CIoU损失里的长宽比惩罚项系数v的权重,原版是0.5,但在小目标数据集上可以调到0.3左右,避免长宽比惩罚过度影响小目标的定位。

5.4 显存不够时的梯度累积与混合精度

如果显存不够,有两个实用技巧。一是梯度累积:用小batch_size跑多次前向传播,累积梯度后再更新一次权重。二是混合精度训练:用float16做前向传播,float32做权重更新,能省将近一半显存。

policy = tf.keras.mixed_precision.Policy('mixed_float16') tf.keras.mixed_precision.set_global_policy(policy)

混合精度训练时要注意损失缩放,TF2的优化器会自动处理,但自定义损失函数里如果有数值不稳定的操作,需要手动加缩放。

6. 模型导出与推理部署的完整链路

6.1 SavedModel导出与TFLite转换的坑

训练完成后,用model.save()导出SavedModel格式。转TFLite时,如果模型里有自定义层(比如h-swish),需要提供自定义对象的字典,否则转换会失败。

converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir) converter.optimizations = [tf.lite.Optimize.DEFAULT] converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS] tflite_model = converter.convert()

如果要做全整数量化,需要提供代表性数据集,让转换器统计激活值的动态范围。这个过程比较耗时,但量化后的模型体积能缩小到原来的四分之一,推理速度也能提升2到3倍。

6.2 后处理NMS在边缘设备上的优化

YOLOv4的输出是三个尺度的特征图,需要解码成边界框后再做NMS。在边缘设备上,NMS可能是整个推理流程的瓶颈。优化方法包括:用快速NMS算法、限制每类保留的框数量、用IOU阈值提前过滤。

我通常把置信度阈值设0.25,NMS的IOU阈值设0.45。如果发现漏检多,可以降低置信度阈值;如果误检多,可以提高置信度阈值或降低IOU阈值。

6.3 实际部署中的输入分辨率与锚框适配

部署时的输入分辨率不一定和训练时一样。如果改了分辨率,锚框尺寸也要按比例缩放。比如训练用416x416,部署用320x320,锚框要乘以320/416。这个细节很容易忽略,导致检测框位置偏移。

另外,不同设备的预处理方式可能不同。有的设备输入是RGB,有的是BGR;有的归一化到0到1,有的归一化到-1到1。这些都要和训练时保持一致,否则精度会大幅下降。

7. 我在三个版本切换中积累的实操心得

先说一个最直接的体会:不要一上来就追求V3。我最早做边缘部署时,看到V3精度最高就直接上了,结果在目标设备上帧率只有个位数。后来换成V1,帧率翻了三倍,精度只掉了不到2个点。所以选型的顺序应该是:先用V1跑通全流程,确认延迟和精度基线,再根据需求决定是否升级到V2或V3。

第二个心得是关于预训练权重的。MobileNet的ImageNet预训练权重对YOLOv4的精度提升非常明显,特别是当你的数据集比较小的时候。我试过从零训练和用预训练权重,同样的数据集,预训练版本收敛快了一倍,最终mAP高了5个点左右。所以除非你有海量数据,否则一定要用预训练权重。

第三个是BN层的处理。在微调时,如果batch_size太小(比如小于8),BN层的统计量会不准,影响精度。这时候可以考虑冻结BN层,或者用同步BN。我在batch_size为4的时候试过冻结BN,精度比不冻结高了1.5个点。

第四个是关于数据增强的。Mosaic增强虽然效果好,但在训练初期会让损失下降很慢,因为拼接后的图像分布和真实分布差异大。我的做法是前10个epoch不用Mosaic,让模型先学基本特征,然后再开启。这样训练曲线更稳定。

最后一个建议:多尺度训练对MobileNet版YOLOv4的提升比原版更明显。因为MobileNet本身就是在多尺度上设计的,对输入尺寸变化适应性好。我通常每隔10个batch随机换一次输入尺寸,在320到608之间选,最终模型对不同分辨率的鲁棒性会好很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进