前途科技
  • 科技
  • AI
    • AI 前沿技术
    • Agent生态
    • AI应用场景
    • AI 行业应用
  • 初创
  • 报告
  • 学习中心
    • 编程与工具
    • 数据科学与工程
我的兴趣
前途科技前途科技
Font ResizerAa
站内搜索
Have an existing account? Sign In
Follow US
Copyright © 2024 AccessPath.com, 前途国际科技咨询(北京)有限公司,版权所有。 | 京ICP备17045010号-1 | 京公网安备 11010502033860号
计算机视觉

MobileNetV3 深度解析:更智能的“微型巨兽”如何提升移动端性能

NEXTECH
Last updated: 2025年11月3日 上午7:18
By NEXTECH
Share
207 Min Read
SHARE

欢迎回到“微型巨兽”系列,本系列旨在深入探讨 MobileNet 架构。在前两篇文章中,系列文章已经介绍了 MobileNetV1 和 MobileNetV2。感兴趣的读者可以查阅参考文献 [1] 和 [2]。本文将继续深入探讨该模型的最新版本:MobileNetV3。

Contents
MobileNetV3 详细架构实验结果分析MobileNetV3 实现完整的 MobileNetV3 模型结语参考文献

MobileNetV3 于 2019 年由 Howard 等人首次在一篇题为《Searching for MobileNetV3》的论文中提出 [3]。快速回顾一下:第一代 MobileNet 的核心思想是将全连接卷积替换为深度可分离卷积,与标准卷积神经网络 (CNN) 相比,参数数量减少了近 90%。在第二代 MobileNet 中,作者引入了所谓的“倒残差”和“线性瓶颈”机制,并将其集成到原有的 MobileNetV1 构建块中。如今,在第三代 MobileNet 中,作者通过引入 Squeeze-and-Excitation (SE) 模块和硬激活函数,进一步提升了网络的性能。此外,MobileNetV3 的整体结构部分采用了神经网络架构搜索 (NAS) 技术进行设计,其本质上类似于在架构层面进行参数调优,以在最大化准确率的同时最小化延迟。不过,本文不会详细探讨 NAS 的工作原理,而是侧重于论文中提出的 MobileNetV3 最终设计。


MobileNetV3 详细架构

作者提出了该模型的两个变体,分别称为 MobileNetV3-Large 和 MobileNetV3-Small。图 1 展示了这两种架构的详细信息。

图 1. MobileNetV3-Large (左) 和 MobileNetV3-Small (右) 架构 [3]。

图 1. MobileNetV3-Large (左) 和 MobileNetV3-Small (右) 架构 [3]。

仔细观察架构,可以看出这两个网络主要由 bneck(瓶颈)块组成。这些块的配置在列 exp size(扩展尺寸)、#out(输出通道数)、SE(Squeeze-and-Excitation 模块)、NL(非线性激活函数)和 s(步长)中描述。这些块的内部结构以及相应的参数配置将在后续小节中进一步讨论。

You Might Also Like

MobileNetV2 深度解析:轻量级模型的智能进化与 PyTorch 实现
中国AI模型全球下载量首超美国,安全隐忧引关注
数据科学演进三阶段:如何明智选择传统机器学习、深度学习与大型语言模型?
RF-DETR深度解析:实时Transformer目标检测的幕后技术与演进

瓶颈块(Bottleneck)

MobileNetV3 沿用了 MobileNetV2 中使用的构建块的修改版本。如前所述,两者之间的主要区别在于是否包含 SE 模块以及使用了硬激活函数。图 2 展示了这两个构建块,其中 MobileNetV2 在上方,MobileNetV3 在下方。

图 2. MobileNetV2 (上) 和 MobileNetV3 (下) 构建块 [3]。

图 2. MobileNetV2 (上) 和 MobileNetV3 (下) 构建块 [3]。

值得注意的是,两个构建块中的前两层卷积基本相同:一个逐点卷积后接一个深度卷积。前者用于将通道数扩展到 exp size(扩展尺寸),而后者负责独立处理所得张量的每个通道。两个构建块之间唯一的区别在于使用的激活函数,这在图中被标记为 NL(非线性)。在 MobileNetV2 中,放置在两个卷积层之后的激活函数固定为 ReLU6,而在 MobileNetV3 中,它可以是 ReLU6 或 hard-swish。图 1 中看到的 RE 和 HS 指的正是这两种激活函数。

接下来,在 MobileNetV3 中,SE 模块被放置在深度卷积层之后。如果读者对 SE 模块尚不熟悉,它本质上是一种可以附加到任何基于 CNN 的模型中的构建块。该组件有助于为不同的通道赋予权重,使模型能够更专注于重要的通道。作者有一篇单独的文章详细讨论了 SE 模块,感兴趣的读者可点击参考文献 [4] 中的链接阅读。需要注意的是,这里使用的 SE 模块略有不同,其最后一个全连接层使用了 hard-sigmoid 而不是标准的 sigmoid 激活函数。(关于 MobileNetV3 中使用的硬激活函数,将在后续小节中进一步讨论。)实际上,SE 模块并非总是包含在每个瓶颈块中。回顾图 1,读者会发现某些瓶颈块的 SE 列中带有勾号,表示应用了 SE 模块。另一方面,一些块没有包含该模块,这可能是因为 NAS 过程并未发现这些块中使用 SE 模块能带来任何性能提升。

在连接 SE 模块之后,需要放置另一个逐点卷积,其作用是根据图 1 中 #out 列调整输出通道的数量。这个逐点卷积不包含任何激活函数,这与 MobileNetV2 中最初引入的“线性瓶颈”设计理念相符。这里需要澄清一点:如果查看图 2 中 MobileNetV2 的构建块,会发现最后一个逐点卷积上放置了 ReLU6。这可能是作者的一个错误,因为根据 MobileNetV2 论文 [6],ReLU6 应该位于块开始处的第一个逐点卷积之后。

最后但同样重要的是,瓶颈块中还存在一个跳过所有层的残差连接。这种连接仅在输出张量与输入张量具有完全相同的维度时才存在,即当输入和输出通道数相同且 s(步长)为 1 时。

Hard-Sigmoid 和 Hard-Swish 激活函数

MobileNetV3 中使用的激活函数在其他深度学习模型中并不常见。首先,我们来看看 hard-sigmoid 激活函数,它被用作 SE 模块中传统 sigmoid 的替代品。请看图 3,了解两者的区别。

图 3. sigmoid 和 hard-sigmoid 激活函数 [3]。

图 3. sigmoid 和 hard-sigmoid 激活函数 [3]。

此时,读者可能想知道,为什么不直接使用传统的 sigmoid 函数?为什么我们需要使用看起来不太平滑的分段线性函数呢?为了回答这个问题,我们需要首先了解 sigmoid 函数的数学定义,这在图 4 中给出。

图 4. 标准 sigmoid 函数的方程 [5]。

图 4. 标准 sigmoid 函数的方程 [5]。

从上图中可以清楚地看到,sigmoid 函数的数学表达式中包含一个指数项。实际上,这个指数项使得函数计算成本较高,从而使其不太适合低功耗设备。不仅如此,sigmoid 函数本身的输出是高精度的浮点值,这对于低功耗设备而言也不太理想,因为它们对处理此类值的支持有限。

如果再次查看图 3,读者可能会认为 hard-sigmoid 函数是直接从原始 sigmoid 派生出来的。然而,这并不完全正确。尽管形状相似,hard-sigmoid 实际上是基于 ReLU6 构造的,其形式化表达式如 图 5 所示。这里可以看到,该方程更为简单,只包含基本的算术运算和裁剪,从而使其处理速度快得多。

图 5. hard-sigmoid 函数的方程 [5]。

图 5. hard-sigmoid 函数的方程 [5]。

MobileNetV3 中将使用的下一个激活函数是 hard-swish,它将应用于瓶颈块中前两个卷积层之后。就像 sigmoid 和 hard-sigmoid 一样,hard-swish 函数的图形与原始 swish 函数相似。

图 6. swish 和 hard-swish 激活函数 [3]。

图 6. swish 和 hard-swish 激活函数 [3]。

原始 swish 函数的数学表达式如 图 7 所示。同样,由于该方程涉及 sigmoid,它会显著减慢计算速度。因此,为了加快处理速度,可以直接用刚刚讨论的 hard-sigmoid 函数替换 sigmoid 函数。通过这样做,就得到了 hard 版本的 swish 激活函数,如 图 8 所示。

图 7. swish 激活函数的方程 [5]。

图 7. swish 激活函数的方程 [5]。

图 8. hard-swish 激活函数的方程 [5]。

图 8. hard-swish 激活函数的方程 [5]。


实验结果分析

在深入探讨实验结果之前,需要了解 MobileNetV3 中有两个参数允许根据需求调整模型大小。这两个参数分别是 width multiplier(宽度乘数)和 input resolution(输入分辨率),在 MobileNetV1 中分别称为 α 和 ρ。尽管在技术上可以自由调整这两个参数的值,但作者已经提供了一些可供使用的数值。对于 width multiplier,可以将其设置为 0.35、0.5、0.75、1.0 或 1.25;使用小于 1.0 的值会导致模型具有比图 1 中所示更少的通道数,从而有效减小模型大小。例如,如果将此参数设置为 0.35,那么整个网络模型的宽度(即通道数)将只有其默认值的 35%。

同时,输入分辨率可以是 96、128、160、192、224 或 256,顾名思义,它直接控制输入图像的空间维度。值得注意的是,即使使用较小的输入尺寸可以减少推理期间的操作数量,但它并不会影响模型大小。因此,如果目标是减小模型大小,需要调整 width multiplier;而如果目标是降低计算成本,则可以同时调整 width multiplier 和 input resolution。

现在查看图 9 中的实验结果,可以清楚地看到 MobileNetV3 在相似延迟下,准确率优于 MobileNetV2。默认配置(即 width multiplier 为 1.0,input resolution 为 224×224)的 MobileNetV3-Small 的准确率确实低于最大的 MobileNetV2 变体。但如果考虑默认的 MobileNetV3-Large,它在准确率和延迟方面都轻松超越了最大的 MobileNetV2。此外,通过将模型大小增加 1.25 倍(右上角的蓝色数据点),还可以进一步提高 MobileNetV3 的准确率,但请记住,这样做会显著牺牲计算速度。

图 9. MobileNetV3-Large、MobileNetV3-Small 和 MobileNetV2 之间的性能比较 [3]。

图 9. MobileNetV3-Large、MobileNetV3-Small 和 MobileNetV2 之间的性能比较 [3]。

作者还对其他轻量级模型进行了比较分析,结果如 图 10 所示。

图 10. MobileNetV3 与其他轻量级模型的性能比较 [3]。

图 10. MobileNetV3 与其他轻量级模型的性能比较 [3]。

上表中的行分为两组,上组用于比较与 MobileNetV3-Large 复杂性相似的模型,下组则包含与 MobileNetV3-Small 相当的模型。从表中可以看出,V3-Large 和 V3-Small 在各自组内均在 ImageNet 上取得了最佳准确率。值得注意的是,尽管 MnasNet-A1 和 V3-Large 具有完全相同的准确率,但前者模型的操作数(MAdds)更高,导致更高的延迟,如列 P-1、P-2 和 P-3(以毫秒为单位)所示。需要说明的是,标签 P-1、P-2 和 P-3 对应于用于测试实际计算速度的不同 Google Pixel 系列设备。此外,必须承认的是,与各自组中的其他模型相比,MobileNetV3 的两个变体都具有最高的参数数量(params 列)。然而,这似乎并非作者主要关注的问题,因为 MobileNetV3 的主要目标是最小化计算延迟,即使这意味着模型略微增大。

作者进行的下一个实验是关于值量化的影响,即一种通过降低浮点数精度来加速计算的技术。尽管网络已经包含了与量化值兼容的硬激活函数,但该实验通过将量化应用于整个网络,以观察速度提升的程度。应用值量化后的实验结果如 图 11 所示。

图 11. 使用量化值时 MobileNetV2 和 MobileNetV3 的准确率和延迟 [3]。

图 11. 使用量化值时 MobileNetV2 和 MobileNetV3 的准确率和延迟 [3]。

如果将图 11 中 V2 和 V3 的结果与图 10 中相应的模型进行比较,会发现延迟有所降低,这证明使用低精度数字确实提高了计算速度。然而,重要的是要记住,这也会导致准确率下降。


MobileNetV3 实现

上述理论解释涵盖了 MobileNetV3 架构的核心要点。接下来,本节将带领读者进入文章最有趣的部分:从零开始实现 MobileNetV3。

一如既往,首先需要导入所需的模块。

# Codeblock 1
import torch
import torch.nn as nn

随后,需要初始化模型的可配置参数,即 WIDTH_MULTIPLIER、INPUT_RESOLUTION 和 NUM_CLASSES,如代码块 2 所示。前两个变量比较直观,因为在上一节已经详细解释过。这里决定为它们分配默认值。如果想调整模型的复杂性,当然可以根据论文中提供的值更改这些数字。第三个变量对应于分类头中的输出神经元数量。这里将其设置为 1000,因为模型最初是在 ImageNet-1K 数据集上训练的。值得注意的是,MobileNetV3 架构实际上不仅限于分类任务。相反,如论文所示,它还可以用于目标检测和语义分割。然而,由于本文的重点是实现骨干网络,为了保持简单,仅使用标准的分类头作为输出层。

# Codeblock 2
WIDTH_MULTIPLIER = 1.0
INPUT_RESOLUTION = 224
NUM_CLASSES      = 1000

接下来将重复的组件封装到单独的类中。通过这样做,以后在需要时可以直接实例化它们,而无需一遍又一遍地重写相同的代码。现在,从 Squeeze-and-Excitation 模块开始。


Squeeze-and-Excitation 模块

该组件的实现如代码块 3 所示。由于它与前一篇文章 [4] 中的实现几乎完全相同,因此不会深入探讨代码细节。但总的来说,这段代码的工作原理是:用一个单一的数字表示每个输入通道(行 #(1)),然后通过一系列线性层处理得到的向量(#(2–3)),接着将其转换为权重向量(#(4))。请记住,在原始 SE 模块中,通常使用标准 sigmoid 激活函数来获取权重向量,但在 MobileNetV3 中,我们使用 hard-sigmoid。然后,这个权重向量将与原始张量相乘,通过这样做可以减少对最终输出没有贡献的通道的影响(#(5))。

# Codeblock 3
class SEModule(nn.Module):
    def __init__(self, num_channels, r):
        super().__init__()

        self.global_pooling = nn.AdaptiveAvgPool2d(output_size=(1,1))
        self.fc0 = nn.Linear(in_features=num_channels,
                             out_features=num_channels//r, 
                             bias=False)
        self.relu6 = nn.ReLU6()
        self.fc1 = nn.Linear(in_features=num_channels//r,
                             out_features=num_channels, 
                             bias=False)
        self.hardsigmoid = nn.Hardsigmoid()

    def forward(self, x):
        print(f'original		: {x.size()}')

        squeezed = self.global_pooling(x)              #(1)
        print(f'after avgpool		: {squeezed.size()}')

        squeezed = torch.flatten(squeezed, 1)
        print(f'after flatten		: {squeezed.size()}')

        excited = self.fc0(squeezed)                   #(2)
        print(f'after fc0		: {excited.size()}')

        excited = self.relu6(excited)
        print(f'after relu6		: {excited.size()}')

        excited = self.fc1(excited)                    #(3)
        print(f'after fc1		: {excited.size()}')

        excited = self.hardsigmoid(excited)            #(4)
        print(f'after hardsigmoid	: {excited.size()}')

        excited = excited[:, :, None, None]
        print(f'after reshape		: {excited.size()}')

        scaled = x * excited                           #(5)
        print(f'after scaling		: {scaled.size()}')

        return scaled

现在,通过创建一个 SEModule 实例并传入一个模拟张量来检查上述代码是否正常工作。详情请参阅代码块 4。这里将 SE 模块配置为接受一个 512 通道的图像作为输入。同时,r(缩减比)参数设置为 4,这意味着两个全连接层之间的向量长度将比其输入和输出小 4 倍。值得注意的是,这个数字与原始 Squeeze-and-Excitation 论文 [7] 中提到的不同,该论文认为 r = 16 是平衡准确率和复杂性的最佳点。

# Codeblock 4
semodule = SEModule(num_channels=512, r=4)
x = torch.randn(1, 512, 28, 28)

out = semodule(x)

如果上述代码产生以下输出,则证实了 SE 模块的实现是正确的,因为它成功地将输入张量通过整个 SE 模块中的所有层。

# Codeblock 4 Output
original          : torch.Size([1, 512, 28, 28])
after avgpool     : torch.Size([1, 512, 1, 1])
after flatten     : torch.Size([1, 512])
after fc0         : torch.Size([1, 128])
after relu6       : torch.Size([1, 128])
after fc1         : torch.Size([1, 512])
after hardsigmoid : torch.Size([1, 512])
after reshape     : torch.Size([1, 512, 1, 1])
after scaling     : torch.Size([1, 512, 28, 28])

卷积块(Convolution Block)

接下来创建的组件是封装在 ConvBlock 类中的模块,其详细实现如代码块 5 所示。事实上,这只是一个标准的卷积层,但由于在 CNN 中通常使用 Conv-BN-ReLU 结构,因此不直接使用 nn.Conv2d。将这三层组合在一个类中会很方便。然而,这里将根据 MobileNetV3 架构的要求对其进行定制,而不是完全遵循标准结构。

# Codeblock 5
class ConvBlock(nn.Module):
    def __init__(self, 
                 in_channels,             #(1)
                 out_channels,            #(2)
                 kernel_size,             #(3)
                 stride,                  #(4)
                 padding,                 #(5)
                 groups=1,                #(6)
                 batchnorm=True,          #(7)
                 activation=nn.ReLU6()):  #(8)
        super().__init__()

        bias = False if batchnorm else True    #(9)

        self.conv = nn.Conv2d(in_channels=in_channels, 
                              out_channels=out_channels,
                              kernel_size=kernel_size, 
                              stride=stride, 
                              padding=padding, 
                              groups=groups,
                              bias=bias)
        self.bn = nn.BatchNorm2d(num_features=out_channels) if batchnorm else nn.Identity()  #(10)
        self.activation = activation

    def forward(self, x):    #(11)
        print(f'original		: {x.size()}')

        x = self.conv(x)
        print(f'after conv		: {x.size()}')

        x = self.bn(x)
        print(f'after bn		: {x.size()}')

        x = self.activation(x)
        print(f'after activation	: {x.size()}')

        return x

实例化 ConvBlock 实例时需要传入几个参数。前五个参数(#(1–5))非常直观,它们基本上只是 nn.Conv2d 层的标准参数。这里将 groups 参数设置为可配置(#(6)),以便该类不仅可以用于标准卷积,还可以用于深度卷积。接下来,在行 #(7) 创建了一个名为 batchnorm 的参数,它决定了 ConvBlock 实例是否实现批归一化层。这主要是因为在某些情况下不实现该层,例如图 1 中带有 NBN 标签(表示无批归一化)的最后两个卷积。这里最后一个参数是激活函数(#(8))。稍后,在某些情况下,需要将其设置为 nn.ReLU6()、nn.Hardswish() 或 nn.Identity()(无激活)。

在 __init__() 方法内部,如果更改 batchnorm 参数的输入参数,会发生两件事。当将其设置为 True 时,首先,卷积层的偏置项将被禁用(#(9)),其次,bn 将是一个 nn.BatchNorm2d() 层(#(10))。在这种情况下,偏置项将不被使用,因为在卷积之后应用批归一化会将其抵消。因此,最初使用偏置基本没有意义。同时,如果将 batchnorm 参数设置为 False,bias 变量将变为 True,因为在这种情况下它不会被抵消。bn 本身将只是一个恒等层,意味着它不会对张量做任何事情。

关于 forward() 方法(#(11)),无需过多解释,因为这里所做的只是按顺序将张量通过各层。现在,我们来看代码块 6,以检查 ConvBlock 实现是否正确。这里尝试创建两个 ConvBlock 实例,第一个使用默认的 batchnorm 和 activation,而第二个省略了批归一化层(#(1))并使用 hard-swish 激活函数(#(2))。这里不是将张量通过它们,而是希望读者在结果输出中看到代码根据传入的输入参数正确实现了两种结构。

# Codeblock 6
convblock1 = ConvBlock(in_channels=64, 
                       out_channels=128, 
                       kernel_size=3, 
                       stride=2, 
                       padding=1)

convblock2 = ConvBlock(in_channels=64, 
                       out_channels=128, 
                       kernel_size=3, 
                       stride=2, 
                       padding=1, 
                       batchnorm=False,             #(1)
                       activation=nn.Hardswish())   #(2)

print(convblock1)
print('')
print(convblock2)
# Codeblock 6 Output
ConvBlock(
  (conv): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
  (bn): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (activation): ReLU6()
)

ConvBlock(
  (conv): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1))
  (bn): Identity()
  (activation): Hardswish()
)

瓶颈块(Bottleneck)

SE 模块和卷积块完成后,现在可以转向 MobileNetV3 架构的核心组件:瓶颈块。瓶颈块本质上只是将一层接一层地放置,其通用结构如 图 2 所示。对于 MobileNetV2,它只包含三个卷积层,而 MobileNetV3 则在第二和第三个卷积之间添加了一个 SE 块。请参阅代码块 7a 和 7b,了解 MobileNetV3 瓶颈块的实现方式。

# Codeblock 7a
class Bottleneck(nn.Module):
    def __init__(self, 
                 in_channels, 
                 out_channels, 
                 kernel_size, 
                 stride,
                 padding,
                 exp_size,     #(1)
                 se,           #(2)
                 activation):
        super().__init__()

        self.add = in_channels == out_channels and stride == 1    #(3)

        self.conv0 = ConvBlock(in_channels=in_channels,    #(4)
                               out_channels=exp_size,    #(5)
                               kernel_size=1,    #(6)
                               stride=1, 
                               padding=0,
                               activation=activation)

        self.conv1 = ConvBlock(in_channels=exp_size,    #(7)
                               out_channels=exp_size,    #(8)
                               kernel_size=kernel_size,    #(9)
                               stride=stride, 
                               padding=padding,
                               groups=exp_size,    #(10)
                               activation=activation)

        self.semodule = SEModule(num_channels=exp_size, r=4) if se else nn.Identity()    #(11)

        self.conv2 = ConvBlock(in_channels=exp_size,    #(12)
                               out_channels=out_channels,    #(13)
                               kernel_size=1,    #(14)
                               stride=1, 
                               padding=0, 
                               activation=nn.Identity())    #(15)

乍一看,Bottleneck 类的输入参数与 ConvBlock 类相似。这很合理,因为将使用它们在 Bottleneck 内部实例化 ConvBlock 实例。然而,如果仔细观察,会发现一些以前未曾见过的参数,即 se (#(1)) 和 exp_size (#(2))。稍后,这些参数的输入参数将从图 1 中表格提供的配置中获取。

在 __init__() 方法内部,首先需要做的是使用行 #(3) 的代码检查输入和输出张量维度是否相同。通过这样做,add 变量将包含 True 或 False。这种维度检查很重要,因为需要决定是否对两者进行逐元素求和,以实现跳过瓶颈块中所有层的跳跃连接。

接下来,实例化层本身,其中前两层是逐点卷积 (conv0) 和深度卷积 (conv1)。对于 conv0,需要将核大小设置为 1×1 (#(6)),而对于 conv1,核大小应与输入参数中的值匹配 (#(9)),可以是 3×3 或 5×5。在 ConvBlock 中应用填充是必要的,以防止图像大小在每次卷积操作后缩小。对于 1×1、3×3 和 5×5 的核大小,所需的填充值分别为 0、1 和 2。关于通道数,conv0 负责将其从 in_channels 扩展到 exp_size (#(4–5))。同时,conv1 的输入和输出通道数完全相同 (#(7–8))。除了 conv1 层,groups 参数应设置为 exp_size (#(10)),因为我们希望每个输入通道彼此独立处理。

完成前两个卷积层后,接下来需要实例化的是 Squeeze-and-Excitation 模块 (#(11))。这里需要将输入通道计数设置为 exp_size,与 conv1 层生成的张量大小匹配。请记住,SE 模块并非总是使用,因此该组件的实例化应在条件内部进行,即仅当 se 参数为 True 时才实例化。否则,它将只是一个恒等层。

最后,最后一个卷积层 (conv2) 负责将输出通道数从 exp_size 映射到 out_channels (#(12–13))。就像 conv0 层一样,这也是一个逐点卷积,因此将核大小设置为 1×1 (#(14)),使其仅专注于沿通道维度聚合信息。该层的激活函数固定设置为 nn.Identity() (#(15)),因为这里将实现线性瓶颈的思想。

以上就是瓶颈块内部的层。之后要做的就是如代码块 7b 所示,在 forward() 方法中创建网络的流程。

# Codeblock 7b
    def forward(self, x):
            residual = x
            print(f'original		: {x.size()}')

            x = self.conv0(x)
            print(f'after conv0		: {x.size()}')

            x = self.conv1(x)
            print(f'after conv1		: {x.size()}')

            x = self.semodule(x)
            print(f'after semodule		: {x.size()}')

            x = self.conv2(x)
            print(f'after conv2		: {x.size()}')

            if self.add:
                x += residual
                print(f'after summation		: {x.size()}')

            return x

现在,我们将通过模拟图 1 中 MobileNetV3-Large 架构表格的第三行来测试刚刚创建的 Bottleneck 类。请看下面的代码块 8,了解具体操作。如果回到架构细节,会发现这个瓶颈块接受一个大小为 16×112×112 的张量 (#(7))。在这种情况下,瓶颈块被配置为将通道数扩展到 64 (#(3)),然后最终缩小到 24 (#(1))。深度卷积的核大小设置为 3×3 (#(2)),步长设置为 2 (#(4)),这将使空间维度减半。这里使用 ReLU6 作为前两个卷积的激活函数 (#(6))。最后,SE 模块不会被实现 (#(5)),因为表格中 SE 列没有勾选。

# Codeblock 8
bottleneck = Bottleneck(in_channels=16,
                        out_channels=24,   #(1)
                        kernel_size=3,     #(2)
                        exp_size=64,       #(3)
                        stride=2,          #(4)
                        padding=1, 
                        se=False,          #(5)
                        activation=nn.ReLU6())  #(6)

x = torch.randn(1, 16, 112, 112)           #(7)
out = bottleneck(x)

如果运行上述代码,屏幕上应出现以下输出。

# Codeblock 8 Output
original        : torch.Size([1, 16, 112, 112])
after conv0     : torch.Size([1, 64, 112, 112])
after conv1     : torch.Size([1, 64, 56, 56])
after semodule  : torch.Size([1, 64, 56, 56])
after conv2     : torch.Size([1, 24, 56, 56])

此输出证实了我们的实现在张量形状方面是正确的,其中空间维度从 112×112 减半到 56×56,而通道数正确地从 16 扩展到 64,然后从 64 减少到 24。更具体地谈到 SE 模块,从上述输出中可以看出,尽管我们将 se 参数设置为 False,张量仍然通过了该组件。实际上,如果尝试打印出此瓶颈块的详细架构,就像在代码块 9 中所做的那样,会发现 semodule 只是一个恒等层,这有效地使该结构的行为就像直接将 conv1 的输出传递给 conv2 一样。

# Codeblock 9
bottleneck
# Codeblock 9 Output
Bottleneck(
  (conv0): ConvBlock(
    (conv): Conv2d(16, 64, kernel_size=(1, 1), stride=(1, 1), bias=False)
    (bn): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (activation): ReLU6()
  )
  (conv1): ConvBlock(
    (conv): Conv2d(64, 64, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), groups=64, bias=False)
    (bn): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (activation): ReLU6()
  )
  (semodule): Identity()
  (conv2): ConvBlock(
    (conv): Conv2d(64, 24, kernel_size=(1, 1), stride=(1, 1), bias=False)
    (bn): BatchNorm2d(24, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (activation): Identity()
  )
)

如果将 se 参数设置为 True 来实例化,上述瓶颈块的行为将有所不同。在下面的代码块 10 中,尝试在 MobileNetV3-Large 架构中创建第五行的瓶颈块。在这种情况下,如果打印出详细结构,会看到 semodule 包含之前创建的 SEModule 类中的所有层,而不再仅仅是一个恒等层。

# Codeblock 10
bottleneck = Bottleneck(in_channels=24, 
                        out_channels=40, 
                        kernel_size=5, 
                        exp_size=72,
                        stride=2, 
                        padding=2, 
                        se=True, 
                        activation=nn.ReLU6())

bottleneck
# Codeblock 10 Output
Bottleneck(
  (conv0): ConvBlock(
    (conv): Conv2d(24, 72, kernel_size=(1, 1), stride=(1, 1), bias=False)
    (bn): BatchNorm2d(72, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (activation): ReLU6()
  )
  (conv1): ConvBlock(
    (conv): Conv2d(72, 72, kernel_size=(5, 5), stride=(2, 2), padding=(2, 2), groups=72, bias=False)
    (bn): BatchNorm2d(72, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (activation): ReLU6()
  )
  (semodule): SEModule(
    (global_pooling): AdaptiveAvgPool2d(output_size=(1, 1))
    (fc0): Linear(in_features=72, out_features=18, bias=False)
    (relu6): ReLU6()
    (fc1): Linear(in_features=18, out_features=72, bias=False)
    (hardsigmoid): Hardsigmoid()
  )
  (conv2): ConvBlock(
    (conv): Conv2d(72, 40, kernel_size=(1, 1), stride=(1, 1), bias=False)
    (bn): BatchNorm2d(40, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (activation): Identity()
  )
)

完整的 MobileNetV3 模型

所有组件都已创建完毕,接下来需要构建 MobileNetV3 模型的主类。在此之前,先初始化一个列表,用于存储实例化瓶颈块所需的输入参数,如代码块 11 所示。请记住,这些参数是根据 MobileNetV3-Large 版本编写的。如果想创建 Small 版本,则需要调整 BOTTLENECKS 列表中的值。

# Codeblock 11
HS = nn.Hardswish()
RE = nn.ReLU6()

BOTTLENECKS = [[16,  16,  3, 16,  False, RE, 1, 1], 
               [16,  24,  3, 64,  False, RE, 2, 1], 
               [24,  24,  3, 72,  False, RE, 1, 1], 
               [24,  40,  5, 72,  True,  RE, 2, 2], 
               [40,  40,  5, 120, True,  RE, 1, 2], 
               [40,  40,  5, 120, True,  RE, 1, 2], 
               [40,  80,  3, 240, False, HS, 2, 1], 
               [80,  80,  3, 200, False, HS, 1, 1], 
               [80,  80,  3, 184, False, HS, 1, 1], 
               [80,  80,  3, 184, False, HS, 1, 1], 
               [80,  112, 3, 480, True,  HS, 1, 1], 
               [112, 112, 3, 672, True,  HS, 1, 1], 
               [112, 160, 5, 672, True,  HS, 2, 2], 
               [160, 160, 5, 960, True,  HS, 1, 2], 
               [160, 160, 5, 960, True,  HS, 1, 2]]

上述参数按以下顺序(从左到右)排列:输入通道数、输出通道数、核大小、扩展尺寸、SE 模块是否启用、激活函数、步长 和 填充。请注意,填充 在原始表格中并未明确说明,但此处将其包含在内,因为它是实例化瓶颈块时必需的输入参数。

现在,我们来创建 MobileNetV3 类。请参阅下面的代码块 12a 和 12b 中的代码实现。

# Codeblock 12a
class MobileNetV3(nn.Module):
    def __init__(self):
        super().__init__()

        self.first_conv = ConvBlock(in_channels=3,    #(1)
                                    out_channels=int(WIDTH_MULTIPLIER*16),
                                    kernel_size=3,
                                    stride=2,
                                    padding=1, 
                                    activation=nn.Hardswish())

        self.blocks = nn.ModuleList([])    #(2)
        for config in BOTTLENECKS:         #(3)
            in_channels, out_channels, kernel_size, exp_size, se, activation, stride, padding = config
            self.blocks.append(Bottleneck(in_channels=int(WIDTH_MULTIPLIER*in_channels), 
                                          out_channels=int(WIDTH_MULTIPLIER*out_channels), 
                                          kernel_size=kernel_size, 
                                          exp_size=int(WIDTH_MULTIPLIER*exp_size), 
                                          stride=stride, 
                                          padding=padding, 
                                          se=se, 
                                          activation=activation))

        self.second_conv = ConvBlock(in_channels=int(WIDTH_MULTIPLIER*160), #(4)
                                     out_channels=int(WIDTH_MULTIPLIER*960),
                                     kernel_size=1,
                                     stride=1,
                                     padding=0, 
                                     activation=nn.Hardswish())

        self.avgpool = nn.AdaptiveAvgPool2d(output_size=(1,1))              #(5)

        self.third_conv = ConvBlock(in_channels=int(WIDTH_MULTIPLIER*960),  #(6)
                                    out_channels=int(WIDTH_MULTIPLIER*1280),
                                    kernel_size=1,
                                    stride=1,
                                    padding=0, 
                                    batchnorm=False,
                                    activation=nn.Hardswish())

        self.dropout = nn.Dropout(p=0.8)    #(7)

        self.output = ConvBlock(in_channels=int(WIDTH_MULTIPLIER*1280),     #(8)
                                out_channels=int(NUM_CLASSES),              #(9)
                                kernel_size=1,
                                stride=1,
                                padding=0, 
                                batchnorm=False,
                                activation=nn.Identity())

在图 1 中,我们看到网络最初从标准卷积层开始。在上述代码块中,该层被称为 first_conv (#(1))。值得注意的是,该层的输入参数未包含在 BOTTLENECKS 列表中,因此需要手动定义。请记住,在每一步中将通道数乘以 WIDTH_MULTIPLIER,因为我们希望模型大小可以通过该变量进行调整。接下来,初始化一个名为 blocks 的占位符,用于存储所有瓶颈块 (#(2))。通过行 #(3) 的简单循环,将遍历 BOTTLENECKS 列表中的所有项,以实际实例化瓶颈块并将其逐个添加到 blocks 中。实际上,这个循环构建了网络中的大部分层,因为它涵盖了表格中列出的几乎所有组件。

瓶颈块序列完成后,接下来将继续处理下一个卷积层,该层被称为 second_conv (#(4))。同样,由于该层的配置参数未存储在 BOTTLENECKS 列表中,因此需要手动硬编码。该层的输出将通过一个全局平均池化层 (#(5)),这将把空间维度降至 1×1。之后,将该层连接到两个连续的逐点卷积 (#(6) 和 #(8)),中间夹着一个 dropout 层 (#(7))。

更具体地谈到这两个卷积,重要的是要知道在具有 1×1 空间维度的张量上应用 1×1 卷积,本质上等同于对扁平化张量应用全连接层,其中通道数将对应于神经元的数量。这就是将最后一层的输出通道数设置为数据集中的类别数量 (#(9)) 的原因。third_conv 和 output 层的 batchnorm 参数都设置为 False,这与架构中的建议一致。

同时,third_conv 的激活函数设置为 nn.Hardswish(),而 output 层使用 nn.Identity(),这相当于根本不应用任何激活函数。这主要是因为在训练期间,softmax 已经包含在损失函数 (nn.CrossEntropyLoss()) 中。在推理阶段,需要在 output 层中将 nn.Identity() 替换为 nn.Softmax(),以便模型直接返回每个类别的概率得分。

接下来,让我们看看下面的 forward() 方法,这里不再做进一步解释,因为它非常容易理解。

# Codeblock 12b
    def forward(self, x):
        print(f'original		: {x.size()}')

        x = self.first_conv(x)
        print(f'after first_conv	: {x.size()}')

        for i, block in enumerate(self.blocks):
            x = block(x)
            print(f"after bottleneck #{i}	: {x.shape}")

        x = self.second_conv(x)
        print(f'after second_conv	: {x.size()}')

        x = self.avgpool(x)
        print(f'after avgpool		: {x.size()}')

        x = self.third_conv(x)
        print(f'after third_conv	: {x.size()}')

        x = self.dropout(x)
        print(f'after dropout		: {x.size()}')

        x = self.output(x)
        print(f'after output		: {x.size()}')

        x = torch.flatten(x, start_dim=1)
        print(f'after flatten		: {x.size()}')

        return x

代码块 13 演示了如何初始化一个 MobileNetV3 实例并通过它传递一个模拟张量。请记住,这里使用默认输入分辨率,因此可以将该张量视为一个大小为 224×224 的单张 RGB 图像批次。

# Codeblock 13
mobilenetv3 = MobileNetV3()

x = torch.randn(1, 3, INPUT_RESOLUTION, INPUT_RESOLUTION)
out = mobilenetv3(x)

下面是结果输出的样子,其中每个块后的张量维度与图 1 中的 MobileNetV3-Large 架构完全匹配。

# Codeblock 13 Output
original             : torch.Size([1, 3, 224, 224])
after first_conv     : torch.Size([1, 16, 112, 112])
after bottleneck #0  : torch.Size([1, 16, 112, 112])
after bottleneck #1  : torch.Size([1, 24, 56, 56])
after bottleneck #2  : torch.Size([1, 24, 56, 56])
after bottleneck #3  : torch.Size([1, 40, 28, 28])
after bottleneck #4  : torch.Size([1, 40, 28, 28])
after bottleneck #5  : torch.Size([1, 40, 28, 28])
after bottleneck #6  : torch.Size([1, 80, 14, 14])
after bottleneck #7  : torch.Size([1, 80, 14, 14])
after bottleneck #8  : torch.Size([1, 80, 14, 14])
after bottleneck #9  : torch.Size([1, 80, 14, 14])
after bottleneck #10 : torch.Size([1, 112, 14, 14])
after bottleneck #11 : torch.Size([1, 112, 14, 14])
after bottleneck #12 : torch.Size([1, 160, 7, 7])
after bottleneck #13 : torch.Size([1, 160, 7, 7])
after bottleneck #14 : torch.Size([1, 160, 7, 7])
after second_conv    : torch.Size([1, 960, 7, 7])
after avgpool        : torch.Size([1, 960, 1, 1])
after third_conv     : torch.Size([1, 1280, 1, 1])
after dropout        : torch.Size([1, 1280, 1, 1])
after output         : torch.Size([1, 1000, 1, 1])
after flatten        : torch.Size([1, 1000])

为了确保实现正确无误,可以使用以下代码打印模型中包含的参数数量。

# Codeblock 14
total_params = sum(p.numel() for p in mobilenetv3.parameters())
total_params
# Codeblock 14 Output
5476416

可以看到,该模型包含大约 550 万个参数,这与原始论文中披露的参数数量大致相同(参见图 10)。此外,PyTorch 文档中给出的参数数量也与此数字相似,如 图 12 所示。基于这些事实,可以确认 MobileNetV3-Large 的实现是正确的。

图 12. PyTorch 官方文档中 MobileNetV3-Large 模型的详细信息 [8]。

图 12. PyTorch 官方文档中 MobileNetV3-Large 模型的详细信息 [8]。


结语

至此,MobileNetV3 架构的全部内容已介绍完毕。鼓励读者尝试从零开始训练该模型,并在不同数据集上进行实验。此外,还可以调整瓶颈块的参数配置,探索 MobileNetV3 性能的进一步提升空间。本文所使用的代码已发布在 GitHub 仓库中,具体链接请参见参考文献 [9]。感谢阅读!如果发现任何解释或代码中的错误,欢迎通过 LinkedIn [10] 联系。期待在下一篇文章中再会!


参考文献

[1] Muhammad Ardi. MobileNetV1 Paper Walkthrough: The Tiny Giant. AI Advances. https://medium.com/ai-advances/mobilenetv1-paper-walkthrough-the-tiny-giant-987196f40cd5 [Accessed October 24, 2025].

[2] Muhammad Ardi. MobileNetV2 Paper Walkthrough: The Smarter Tiny Giant. Towards Data Science. https://towardsdatascience.com/mobilenetv2-paper-walkthrough-the-smarter-tiny-giant/ [Accessed October 24, 2025].

[3] Andrew Howard et al. Searching for MobileNetV3. Arxiv. https://arxiv.org/abs/1905.02244 [Accessed May 1, 2025].

[4] Muhammad Ardi. SENet Paper Walkthrough: The Channel-Wise Attention. AI Advances. https://medium.com/ai-advances/senet-paper-walkthrough-the-channel-wise-attention-8ac72b9cc252 [Accessed October 24, 2025].

[5] Image created originally by author.

[6] Mark Sandler et al. MobileNetV2: Inverted Residuals and Linear Bottlenecks. Arxiv. https://arxiv.org/abs/1801.04381 [Accessed May 12, 2025].

[7] Jie Hu et al. Squeeze and Excitation Networks. Arxiv. https://arxiv.org/abs/1709.01507 [Accessed May 12, 2025].

[8] Mobilenetv3large. PyTorch. https://docs.pytorch.org/vision/main/models/generated/torchvision.models.mobilenetv3large.html#torchvision.models.mobilenetv3large [Accessed May 12, 2025].

[9] MuhammadArdiPutra. The Tiny Giant Getting Even Smarter — MobileNetV3. GitHub. https://github.com/MuhammadArdiPutra/medium_articles/blob/main/The%20Tiny%20Giant%20Getting%20Even%20Smarter%20-%20MobileNetV3.ipynb [Accessed May 12, 2025].

TAGGED:MobileNetV3图像分类模型优化深度学习计算机视觉
Share This Article
Email Copy Link Print
Previous Article 计算残差的公式 从经典到AI:数据中心湿度智能预测,实现能源与水资源高效利用
Next Article 微软Zune播放器图片 Zune为何未能击败iPod?探究微软的超前理念与市场现实
Leave a Comment

发表回复 取消回复

您的邮箱地址不会被公开。 必填项已用 * 标注

最新内容
20251205190349369.jpg
Meta战略大转向:削减30%元宇宙预算,全力押注AI
科技
20251205183721458.jpg
南部非洲古人类基因组改写进化史:20万年隔离与独特基因
科技
20251205180959635.jpg
AMD为对华出口AI芯片支付15%税费,引发美国宪法争议
科技
20251205174331374.jpg
家的定义与核心价值:探索现代居住空间的意义
科技

相关内容

智能体在环境中收集经验,并利用这些经验训练策略
未分类

强化学习深度解析:从基础概念到核心算法的全面指南

2025年11月7日
未分类

机器学习实践洞察:从项目策略到高效工具与学习方法

2025年10月1日
图像1:通过确定性方法检测图像中的猫(作者绘制)
计算机视觉

Excel实战:深入理解卷积神经网络(CNN)的图像识别原理

2025年11月18日
图1 — 数据集中的一个示例图像——欧洲醋栗类别。
计算机视觉

超越肉眼极限:利用CNN与Vision Transformer实现高精度花粉视觉分类

2025年10月2日
Show More
前途科技

前途科技是一个致力于提供全球最新科技资讯的专业网站。我们以实时更新的方式,为用户呈现来自世界各地的科技新闻和深度分析,涵盖从技术创新到企业发展等多方面内容。专注于为用户提供高质量的科技创业新闻和行业动态。

分类

  • AI
  • 初创
  • 学习中心

快速链接

  • 阅读历史
  • 我的关注
  • 我的收藏

Copyright © 2025 AccessPath.com, 前途国际科技咨询(北京)有限公司,版权所有。 | 京ICP备17045010号-1 | 京公网安备 11010502033860号

前途科技
Username or Email Address
Password

Lost your password?

Not a member? Sign Up