引言
MobileNetV1 在计算机视觉领域取得了突破性进展,它证明了深度学习模型并非必须计算成本高昂才能实现高准确率。本文作者曾发布过一篇详细介绍 MobileNetV1 模型及其 PyTorch 从零实现的文章,感兴趣的读者可查阅文末参考文献 [1]。这款 MobileNet 的初代版本由 Google 的 Howard 等人于 2017 年 4 月在题为《MobileNets: Efficient Convolutional Neural Networks for Mobile Vision Applications》的论文 [2] 中首次提出。仅仅数月之后,即 2018 年 1 月,来自同一机构的 Sandler 等人在题为《MobileNetV2: Inverted Residuals and Linear Bottlenecks》的论文 [3] 中,推出了 MobileNetV1 的继任者 MobileNetV2,该版本在准确性和效率两方面均实现了显著提升。本文将深入探讨 MobileNetV2 论文中提出的核心思想,并展示如何从头开始实现其网络架构。
关键改进
初代 MobileNet 模型的效率主要依赖于所谓的“深度可分离卷积层”。确实,使用这些层来替代标准卷积能够使模型极其轻量化。然而,研究人员认为这种架构仍有进一步改进的空间。他们提出了一种新思路:除了深度可分离卷积,还引入了“倒残差块(inverted residual)”和“线性瓶颈(linear bottleneck)”机制,这也正是 MobileNetV2 论文标题的由来。
倒残差块
熟悉 ResNet 的读者,应该对所谓的“瓶颈块(bottleneck block)”有所了解。对于不熟悉的读者而言,这本质上是一种网络构建块机制,其特点是遵循“宽 → 窄 → 宽”的通道模式。图 1 展示了 ResNet 中使用的瓶颈块示例。从中可见,它首先接受一个 256 通道的张量,将其缩减到 64 通道,然后再扩展回 256 通道。
![图1. ResNet的构建块,通常称为“瓶颈块”[4]。](/2025/10/04/d58dac72b8374bc324e85e17407c488b.png)
图1. ResNet的构建块,通常称为“瓶颈块”[4]。
上述块的倒置版本通常被称为“倒置瓶颈块(inverted bottleneck)”,它遵循“窄 → 宽 → 窄”的结构。图 2 展示了 ConvNeXt 论文 [5] 中的一个例子,其中输入张量的通道数为 96,在中间被扩展到 384,然后通过最后一个卷积层再压缩回 96。值得注意的是,在 MobileNetV2 中,出于某些原因,“倒置瓶颈块”被称为“倒残差块”。因此,为避免混淆,下文将统一使用“倒残差块”这一术语。
![图2. ConvNeXt中引入的倒置瓶颈块[5]。](/2025/10/04/3ee45e4c2d552f2ece639c26850033cd.png)
图2. ConvNeXt中引入的倒置瓶颈块[5]。
此时,读者可能想知道为什么 MobileNetV2 不直接使用标准的瓶颈块。答案在于标准瓶颈块设计的初衷,它最初是为了降低计算复杂性而引入的。这主要是因为 ResNet 本身计算成本高昂,但信息丰富。因此,ResNet 的作者们通过在每个构建块的中间缩小张量尺寸来降低计算成本,从而诞生了瓶颈块。
这种通道数量的减少并不会严重损害模型的容量,因为 ResNet 整体上已经拥有大量通道。另一方面,MobileNetV2 的设计目标是尽可能轻量化,这意味着其模型容量不如 ResNet 高。为了增加模型容量,作者们在中间层扩展了张量尺寸,形成了倒残差块,这使得模型能够在仅轻微增加复杂性的情况下学习到更复杂的模式。简而言之,瓶颈块的中间部分(窄)用于提高效率,而倒残差块的中间部分(宽)则用于学习复杂的模式。如果尝试在 MobileNetV2 上应用标准瓶颈块,计算速度会更快,但这可能会导致准确率下降,因为模型将损失大量重要信息。
线性瓶颈
接下来需要理解的概念是“线性瓶颈”。这个概念其实相当简单,其核心在于省略了每个倒残差块最后一个卷积层中的非线性激活(即 ReLU 激活函数)。神经网络中使用激活函数最初是为了让网络能够捕获复杂的模式。然而,如果将其应用于低维张量,它反而会破坏重要信息,尤其是在 MobileNetV2 的上下文中,倒残差块在最后一个卷积层中将高维张量投影到更小的维度。通过移除最后一个卷积层中的激活函数,模型可以有效防止重要信息的丢失。图 3 展示了 MobileNetV2 中使用的倒残差块结构。请注意,ReLU 并未应用于最后一个逐点卷积层之后,这意味着该层在某种程度上类似于一个标准的线性回归层。此外,图中变量 k 和 k’ 分别表示输入和输出通道的数量。在中间处理过程中,通道数会先按 t 倍进行扩展,最终再缩减到 k’。这些变量的详细解释将在下一节提供。
![图3. MobileNetV2中使用的倒残差块。请注意,在最后一个逐点卷积层后未应用ReLU激活函数[3]。](/2025/10/04/4561aaf5992d7ad6604d4a962c7c941f.png)
图3. MobileNetV2中使用的倒残差块。请注意,在最后一个逐点卷积层后未应用ReLU激活函数[3]。
ReLU6
那么,为什么我们使用 ReLU6 而不是普通的 ReLU 呢?如果您还不熟悉,ReLU6 激活函数与 ReLU 相似,只不过其输出值被限制在 6 以内。因此,任何大于 6 的输入都将被映射到 6。同时,对负输入的行为则完全相同。由此可知,ReLU6 的输出值将始终在 0 到 6 的范围内(包括 0 和 6)。请看下面的图 4,以便更好地理解这一概念。
![图4. ReLU6激活函数[6]。](/2025/10/04/7ee38d47ad3b4a2c3350d0b72c1d9605.png)
图4. ReLU6激活函数[6]。
在标准 ReLU 中,输入值(以及输出值)可能变得任意大,这在低精度环境中可能导致不稳定性。考虑到 MobileNet 的设计目标是在小型设备上运行,而这类设备通常为了节省内存而期望较小的数值,例如 8 位整数。在这种特定情况下,过大的激活值可能在量化为低位表示时导致精度损失或截断。因此,为了将数值保持在较小且可管理的范围内,ReLU6 便是一个有效的解决方案。
完整的 MobileNetV2 架构
现在,让我们来审视图 5 中展示的完整 MobileNetV2 架构。与初代 MobileNet 主要由深度可分离卷积构成类似,MobileNetV2 的大部分组件是前文讨论过的带有线性瓶颈的倒残差块。下表中标记为“bottleneck”的每一行都对应一个“阶段(stage)”,其中每个阶段包含若干个倒残差块。关于表中的列,t 表示每个块中间部分使用的“扩展因子(expansion factor)”,c 表示每个块的输出通道数,n 是该阶段内块的重复次数,而 s 则表示该阶段内第一个块的步长。
为了更好地理解这一概念,让我们仔细观察输入形状为 56×56×24 的阶段。这里可以看到,该阶段对应的参数为 t=6,c=32,n=3,和 s=2。这实质上意味着该倒残差阶段由 3 个块组成。所有这些块都是相同的,除了第一个块使用步长 2,将空间维度从 56×56 减半到 28×28。接下来,c=32 相当直观,它表明该阶段内每个块的输出通道数为 32。同时,t=6 表示块内部的中间层比输入层宽 6 倍,形成了倒置瓶颈结构。因此,在这种情况下,通道数将是 32 → 192 → 32。然而,需要注意的是,该阶段的第一个块有所不同,由于其 24 通道的输入张量,它采用 24 → 144 → 32 的结构。如果参考图 3,这两种结构都遵循 k → kt → k’ 的模式。
![图5. 本文即将实现的MobileNetV2架构概览[3]。](/2025/10/04/681fd41f7e5ae980364accbb19e3ed9a.png)
图5. 本文即将实现的MobileNetV2架构概览[3]。
除了上述架构,倒残差块内部还设有跳跃连接。这种跳跃连接只在块的步长设置为 1 时应用。这主要是因为当使用步长为 2 时,图像的空间维度会发生变化,导致输出张量的形状与输入张量不同。这种张量形状的差异将有效阻止我们对原始流和跳跃连接执行逐元素求和操作。详情请参见下面的图 6。请注意,图中两个插图基本上只是图 3 中表格的可视化表示。
![图6. 当步长设置为2时(即层执行空间下采样时),不实现跳跃连接[3]。](/2025/10/04/460644aad49a833b8cab5e4f5d27a1ec.png)
图6. 当步长设置为2时(即层执行空间下采样时),不实现跳跃连接[3]。
参数调优
与 MobileNetV1 类似,MobileNetV2 也具有两个可调节参数,分别称为“宽度乘数(width multiplier)”和“输入分辨率(input resolution)”。前者用于调整网络的宽度,而后者则用于改变输入图像的分辨率。图 5 中展示的架构是基础配置,其中宽度乘数设置为 1,输入分辨率为 224×224。通过这两个参数,可以根据具体需求调整模型,以在准确性和效率之间找到一个最佳平衡点。
尽管理论上可以为这两个参数选择任意数值,但论文作者们在实验中已经提供了几个预设的数值。对于宽度乘数,可以选择 0.75、0.5 或 0.35,这些值都会使模型变得更小。例如,如果使用 0.5,则图 5 中列 c 的所有数值都将减半。对于输入分辨率,如果希望降低推理时的运算量,可以选择 192×192、160×160、128×128 或 96×96 来替代 224×224。
部分实验结果
图 7 展示了作者们进行的实验结果。尽管 MobileNetV1 已被认为是轻量级模型,但 MobileNetV2 证明其在所有指标上均优于前代产品。然而,需要承认的是,基础版 MobileNetV2 并非完全优于其他所有轻量级模型,尤其是在综合考虑所有方面时。为了实现更高的准确率,作者们还尝试通过将 224×224 输入分辨率的宽度乘数更改为 1.4 来放大模型,这在图中对应于最后一行。这样做无疑会增加模型的复杂性和计算时间,但作为回报,它能使模型获得最高的准确率。图 8 的结果也显示了类似的情况,所有 MobileNetV2 变体都完全超越了 MobileNetV1 对应的版本,其中最大的 MobileNetV2 在所有模型中取得了最高的准确率。
![图7. MobileNetV2在ImageNet数据集上与其他轻量级模型的性能对比[3]。](/2025/10/04/5ad6b036c7f6feec973498fdd4b02c2c.png)
图7. MobileNetV2在ImageNet数据集上与其他轻量级模型的性能对比[3]。
![图8. 更多结果展示了MobileNetV2相对于现有模型的优势以及输入分辨率对准确性的影响[3]。](/2025/10/04/b77e9f64758f55330b07dac7ef69d7b9.png)
图8. 更多结果展示了MobileNetV2相对于现有模型的优势以及输入分辨率对准确性的影响[3]。
MobileNetV2 实现
每次深入学习一个新概念后,人们往往会思考是否真正掌握了其精髓。在深度学习领域,通常会尝试在阅读论文后自行实现其架构,以验证自身的理解程度。正如理查德·费曼的名言所启示:
我无法创造的东西,我就不理解。
理查德·费曼
这便是文章中通常包含相关论文代码实现的原因。
插曲过后,现在将焦点重新回到 MobileNetV2。本节将展示如何从头开始实现 MobileNetV2 架构。一如既往,首要任务是导入所需的模块。
# Codeblock 1
import torch
import torch.nn as nn
from torchinfo import summary
接下来,还需要初始化一些配置变量,以便在需要时轻松调整模型的规模。在下面的代码块 2 中,值得关注的两个变量是 WIDTH_MULTIPLIER 和 IMAGE_SIZE,它们分别对应前面讨论过的“宽度乘数”和“输入分辨率”参数。这里将它们设定为 1.0 和 224,旨在实现基础版的 MobileNetV2 架构。
# Codeblock 2
BATCH_SIZE = 1
IMAGE_SIZE = 224
IN_CHANNELS = 3
NUM_CLASSES = 1000
WIDTH_MULTIPLIER = 1.0
回顾图 5 的架构细节,可以看到标记为“bottleneck”的行是一组块,之前将其称为“阶段”。而标记为“conv2d”的每一行则是一个标准的卷积层。本文将首先实现后者,因为它相对简单。
标准卷积层
关于标记为 conv2d 的行,读者可能会疑惑,为何需要将单个卷积层封装在一个独立的类中,而不是直接在主类中使用 nn.Conv2d?实际上,论文中提到,每个卷积层之后总是紧接着一个批量归一化层,最终再由 ReLU6 激活函数进行处理。这与 MobileNetV1 的“conv-BN-ReLU”结构保持一致。为了使代码更加整洁,将这些层封装在一个类中可以避免重复定义。请看下方的代码块 3,了解 Conv 类的创建方式。
# Codeblock 3
class Conv(nn.Module):
def __init__(self, first=False): #(1)
super().__init__()
if first:
in_channels = 3 #(2)
out_channels = int(32*WIDTH_MULTIPLIER) #(3)
kernel_size = 3 #(4)
stride = 2 #(5)
padding = 1 #(6)
else:
in_channels = int(320*WIDTH_MULTIPLIER) #(7)
out_channels = int(1280*WIDTH_MULTIPLIER) #(8)
kernel_size = 1 #(9)
stride = 1 #(10)
padding = 0 #(11)
self.conv = nn.Conv2d(in_channels=in_channels, #(12)
out_channels=out_channels,
kernel_size=kernel_size,
stride=stride,
padding=padding,
bias=False)
self.bn = nn.BatchNorm2d(num_features=out_channels) #(13)
self.relu6 = nn.ReLU6() #(14)
def forward(self, x):
x = self.relu6(self.bn(self.conv(x))) #(15)
return x
每次实例化 Conv 对象时,需要为 first 参数传递一个值,如上述代码中标记为 #(1) 的行所示。如果观察架构图,会发现这个 Conv 层要么在倒残差块序列之前使用,要么在序列之后使用。图 9 再次展示了架构图,其中两个卷积层分别用粉色和绿色高亮显示。在主类中实例化时,若要创建粉色高亮层,只需将 first 标志设为 True;若要创建绿色高亮层,则无需传递参数,因为该标志已默认设为 False。
![图9. Conv类将用于实例化这两个卷积层[3][6]。](/2025/10/04/82a1b559f89b84eefcc20277e54a6435.png)
图9. Conv类将用于实例化这两个卷积层[3][6]。
使用这样的标志有助于为两个卷积层应用不同的配置。当使用 first=True 时,卷积层被设置为接受 3 个输入通道(#(2))并生成一个 32 通道的张量(#(3))。使用的卷积核大小为 3×3(#(4)),步长为 2(#(5)),有效将空间维度减半。对于这种卷积核大小,需要将填充(padding)设置为 1(#(6)),以防止卷积过程进一步减小空间维度。所有这些配置都取自粉色高亮的卷积层。
同时,当使用 first=False 时,此卷积层将接受一个 320 通道的输入张量(#(7)),并生成一个具有 1280 通道的张量(#(8))。这个绿色高亮层是一个逐点卷积,因此需要将卷积核大小设置为 1(#(9))。由于这里不执行空间下采样,步长参数必须设置为 1,如 #(10) 行所示(注意该层和下一层的输入尺寸在空间上都是 7×7)。最后,填充设置为 0(#(11)),因为 1×1 的卷积核本身不会减小空间维度。
在卷积层的参数定义完成后,Conv 类中接下来要做的就是使用 nn.Conv2d(#(12))初始化卷积层本身,以及批量归一化层(#(13))和 ReLU6 激活函数(#(14))。最后,在 forward() 方法中,将这些层组合起来形成“conv-BN-ReLU”结构(#(15))。此外,在指定输入和输出通道数时(即在 #(3)、#(7) 和 #(8) 行),不要忘记应用 WIDTH_MULTIPLIER,以便只需改变该变量的值即可调整模型大小。
现在,通过运行以下两个测试用例,验证 Conv 类是否正确实现。代码块 4 演示了粉色层,而代码块 5 展示了绿色层。两个测试中使用的虚拟张量 x 的形状均根据每个层所需的输入形状设置。根据输出结果,可以确认实现是正确的,因为输出张量形状与相应后续层的预期输入形状完全匹配。
# Codeblock 4
conv = Conv(first=True)
x = torch.randn(1, 3, 224, 224)
out = conv(x)
out.shape
# Codeblock 4 Output
torch.Size([1, 32, 112, 112])
# Codeblock 5
conv = Conv(first=False)
x = torch.randn(1, int(320*WIDTH_MULTIPLIER), 7, 7)
out = conv(x)
out.shape
# Codeblock 5 Output
torch.Size([1, 1280, 7, 7])
步长为 2 的倒残差块
完成了标准卷积层的类定义后,现在将讨论倒残差块的实现。请记住,在某些情况下会使用步长 1,而在另一些情况下则使用步长 2,这导致块结构略有不同(参见图 6)。因此,决定将它们实现在两个独立的类中。从实际应用的角度来看,将它们放在同一个类中可能更简洁。然而,为了本教程的清晰性,将其分解为两个类会更容易理解。本文将首先实现步长为 2 的倒残差块,因为它不包含跳跃连接,结构相对简单。请参见下面的代码块 6 中的 InvResidualS2 类详情。
# Codeblock 6
class InvResidualS2(nn.Module):
def __init__(self, in_channels, out_channels, t): #(1)
super().__init__()
in_channels = int(in_channels*WIDTH_MULTIPLIER) #(2)
out_channels = int(out_channels*WIDTH_MULTIPLIER) #(3)
self.pwconv0 = nn.Conv2d(in_channels=in_channels, #(4)
out_channels=in_channels*t,
kernel_size=1,
stride=1,
bias=False)
self.bn_pwconv0 = nn.BatchNorm2d(num_features=in_channels*t)
self.dwconv = nn.Conv2d(in_channels=in_channels*t, #(5)
out_channels=in_channels*t,
kernel_size=3, #(6)
stride=2,
padding=1,
groups=in_channels*t, #(7)
bias=False)
self.bn_dwconv = nn.BatchNorm2d(num_features=in_channels*t)
self.pwconv1 = nn.Conv2d(in_channels=in_channels*t, #(8)
out_channels=out_channels,
kernel_size=1,
stride=1,
bias=False)
self.bn_pwconv1 = nn.BatchNorm2d(num_features=out_channels)
self.relu6 = nn.ReLU6()
def forward(self, x):
print('original :', x.shape)
x = self.pwconv0(x)
print('after pwconv0 :', x.shape)
x = self.bn_pwconv0(x)
print('after bn0_pwconv0 :', x.shape)
x = self.relu6(x)
print('after relu :', x.shape)
x = self.dwconv(x)
print('after dwconv :', x.shape)
x = self.bn_dwconv(x)
print('after bn_dwconv :', x.shape)
x = self.relu6(x)
print('after relu :', x.shape)
x = self.pwconv1(x)
print('after pwconv1 :', x.shape)
x = self.bn_pwconv1(x)
print('after bn_pwconv1 :', x.shape)
return x
上述类接受三个参数来工作:in_channels、out_channels 和 t,如 #(1) 行所示。前两个参数对应于倒残差块的输入和输出通道数,而 t 则是用于确定块“宽”部分通道数的扩展因子。因此,这里所做的基本上就是使中间张量的通道数比输入通道数多 t 倍。输入和输出通道的数量本身可以通过前面初始化的 WIDTH_MULTIPLIER 变量进行调整,如 #(2) 和 #(3) 行所示。
接下来需要做的是根据图 3 和图 6 中的结构初始化倒残差块内的层。请注意,在两图中,一个深度可分离卷积层被放置在两个逐点卷积层之间。第一个逐点卷积(#(4))用于将通道维度从 in_channels 扩展到 in_channels*t。随后,#(5) 行的深度可分离卷积负责捕获空间维度上的信息。这里将卷积核大小设置为 3×3(#(6)),这使得该层能够从相邻像素捕获空间信息。不要忘记将 groups 参数设置为与该层的输入通道数相同(#(7)),因为希望卷积操作独立于每个通道进行。接下来,使用第二个逐点卷积(#(8))处理生成的张量,该层用于将张量投影到块预期的输出通道数。
在 forward() 方法中,将这些层依次排列。请记住,除了最后一个卷积层外,都遵循“conv-BN-ReLU”结构,这符合之前讨论的线性瓶颈约定。此外,这里还打印出每个层之后的输出形状,以便清晰地看到张量在处理过程中的变换。
接下来,将测试 InvResidualS2 类是否正常工作。以下测试代码模拟了架构中第三行(即输入形状为 16×112×112)的第一个倒残差块(n=1)。
# Codeblock 7
inv_residual_s2 = InvResidualS2(in_channels=16, out_channels=24, t=6)
x = torch.randn(1, int(16*WIDTH_MULTIPLIER), 112, 112)
out = inv_residual_s2(x)
在以下输出中标记为 #(1) 的行中可以看到,第一个逐点卷积成功地将通道轴从 16 扩展到 96。在张量经过中间的深度可分离卷积层处理后,空间维度从 112×112 缩小到 56×56(#(2))。最后,第二个逐点卷积将通道数压缩到 24,如 #(3) 行所示。这个最终的张量维度现在已准备好通过同一阶段内的下一个倒残差块。
# Codeblock 7 Output
original : torch.Size([1, 16, 112, 112])
after pwconv0 : torch.Size([1, 96, 112, 112]) #(1)
after bn0_pwconv0 : torch.Size([1, 96, 112, 112])
after relu : torch.Size([1, 96, 112, 112])
after dwconv : torch.Size([1, 96, 56, 56]) #(2)
after bn_dwconv : torch.Size([1, 96, 56, 56])
after relu : torch.Size([1, 96, 56, 56])
after pwconv1 : torch.Size([1, 24, 56, 56]) #(3)
after bn_pwconv1 : torch.Size([1, 24, 56, 56])
步长为 1 的倒残差块
实现步长为 1 的倒残差块的代码与步长为 2 的版本大部分相似。请参见下面的代码块 8 中的 InvResidualS1 类。
# Codeblock 8
class InvResidualS1(nn.Module):
def __init__(self, in_channels, out_channels, t):
super().__init__()
in_channels = int(in_channels*WIDTH_MULTIPLIER) #(1)
out_channels = int(out_channels*WIDTH_MULTIPLIER) #(2)
self.in_channels = in_channels
self.out_channels = out_channels
self.pwconv0 = nn.Conv2d(in_channels=in_channels,
out_channels=in_channels*t,
kernel_size=1,
stride=1,
bias=False)
self.bn_pwconv0 = nn.BatchNorm2d(num_features=in_channels*t)
self.dwconv = nn.Conv2d(in_channels=in_channels*t,
out_channels=in_channels*t,
kernel_size=3,
stride=1, #(3)
padding=1,
groups=in_channels*t,
bias=False)
self.bn_dwconv = nn.BatchNorm2d(num_features=in_channels*t)
self.pwconv1 = nn.Conv2d(in_channels=in_channels*t,
out_channels=out_channels,
kernel_size=1,
stride=1,
bias=False)
self.bn_pwconv1 = nn.BatchNorm2d(num_features=out_channels)
self.relu6 = nn.ReLU6()
def forward(self, x):
if self.in_channels == self.out_channels: #(4)
residual = x #(5)
print(f'residual : {residual.size()}')
x = self.pwconv0(x)
print('after pwconv0 :', x.shape)
x = self.bn_pwconv0(x)
print('after bn_pwconv0 :', x.shape)
x = self.relu6(x)
print('after relu :', x.shape)
x = self.dwconv(x)
print('after dwconv :', x.shape)
x = self.bn_dwconv(x)
print('after bn_dwconv :', x.shape)
x = self.relu6(x)
print('after relu :', x.shape)
x = self.pwconv1(x)
print('after pwconv1 :', x.shape)
x = self.bn_pwconv1(x)
print('after bn_pwconv1 :', x.shape)
if self.in_channels == self.out_channels:
x = x + residual #(6)
print('after summation :', x.shape)
return x
这里的第一个不同之处显然是 stride 参数本身,特别是属于深度可分离卷积层(#(3))的那个。通过将 stride 参数设置为 1,这个倒残差块的空间输出维度将与输入维度保持一致。
另一个之前未做的事情是为 in_channels 和 out_channels 创建实例属性,如 #(1) 和 #(2) 行所示。现在这样做是因为稍后需要在 forward() 方法中访问这些值。这实际上只是一个基本的面向对象编程概念,如果它们不分配给 self,那么它们将只存在于 __init__() 方法的局部范围内,而无法供类中的其他方法使用。
在 forward() 方法内部,首先需要检查输入和输出通道的数量是否相同(#(4))。如果相同,将保留原始输入张量(#(5))以实现跳跃连接,该张量将与主流程中的张量进行逐元素求和(#(6))。执行此张量维度检查是因为需要确保两个要相加的张量具有完全相同的尺寸。由于已将所有三个卷积层设置为使用步长 1,因此空间维度保证不变。然而,仍然存在输出通道数与输入通道数不同的可能性,就像图 10 中用紫色、蓝色和橙色高亮显示的阶段中的第一个块一样。在这种情况下,将不会应用跳跃连接,因为对形状不同的张量执行逐元素求和是不可能的。
![图10. 尽管未执行空间下采样,但在三个高亮阶段的第一个块中没有跳跃连接,因为输入和输出通道数量不同[3][6]。](/2025/10/04/33d4279773f1eb9b68a4d5b76c9374e1.png)
图10. 尽管未执行空间下采样,但在三个高亮阶段的第一个块中没有跳跃连接,因为输入和输出通道数量不同[3][6]。
现在,通过运行下面的代码块 9 来测试 InvResidualS1 类。这里将模拟架构中第三行(即输入形状为 24x56x56)的第二个倒残差块(n=2),这实际上是前一个测试用例的延续。可以看到,这里使用的虚拟张量与代码块 7 中获得的张量形状完全相同,即 24×56×56。
# Codeblock 9
inv_residual_s1 = InvResidualS1(in_channels=24, out_channels=24, t=6)
x = torch.randn(1, int(24*WIDTH_MULTIPLIER), 56, 56)
out = inv_residual_s1(x)
以下是输出结果。从中清晰可见,网络确实遵循“窄 → 宽 → 窄”的结构,在本例中为 24 → 144 → 24。此外,由于输入和输出张量的空间维度相同,理论上可以根据需要多次堆叠此倒残差块。
# Codeblock 9 Output
residual : torch.Size([1, 24, 56, 56])
after pwconv0 : torch.Size([1, 144, 56, 56])
after bn_pwconv0 : torch.Size([1, 144, 56, 56])
after relu : torch.Size([1, 144, 56, 56])
after dwconv : torch.Size([1, 144, 56, 56])
after bn_dwconv : torch.Size([1, 144, 56, 56])
after relu : torch.Size([1, 144, 56, 56])
after pwconv1 : torch.Size([1, 24, 56, 56])
after bn_pwconv1 : torch.Size([1, 24, 56, 56])
after summation : torch.Size([1, 24, 56, 56])
完整的 MobileNetV2 架构
在完成了 Conv、InvResidualS2 和 InvResidualS1 类的定义后,现在可以将它们全部组合起来,构建完整的 MobileNetV2 架构。请看下面的代码块 10,了解具体的实现方式。
# Codeblock 10
class MobileNetV2(nn.Module):
def __init__(self):
super().__init__()
# Input shape: 3x224x224
self.first_conv = Conv(first=True)
# Input shape: 32x112x112
self.inv_residual0 = InvResidualS1(in_channels=32,
out_channels=16,
t=1)
# Input shape: 16x112x112
self.inv_residual1 = nn.ModuleList([InvResidualS2(in_channels=16,
out_channels=24,
t=6)])
self.inv_residual1.append(InvResidualS1(in_channels=24,
out_channels=24,
t=6))
# Input shape: 24x56x56
self.inv_residual2 = nn.ModuleList([InvResidualS2(in_channels=24,
out_channels=32,
t=6)])
for _ in range(2):
self.inv_residual2.append(InvResidualS1(in_channels=32,
out_channels=32,
t=6))
# Input shape: 32x28x28
self.inv_residual3 = nn.ModuleList([InvResidualS2(in_channels=32,
out_channels=64,
t=6)])
for _ in range(3):
self.inv_residual3.append(InvResidualS1(in_channels=64,
out_channels=64,
t=6))
# Input shape: 64x14x14
self.inv_residual4 = nn.ModuleList([InvResidualS1(in_channels=64,
out_channels=96,
t=6)])
for _ in range(2):
self.inv_residual4.append(InvResidualS1(in_channels=96,
out_channels=96,
t=6))
# Input shape: 96x14x14
self.inv_residual5 = nn.ModuleList([InvResidualS2(in_channels=96,
out_channels=160,
t=6)])
for _ in range(2):
self.inv_residual5.append(InvResidualS1(in_channels=160,
out_channels=160,
t=6))
# Input shape: 160x7x7
self.inv_residual6 = InvResidualS1(in_channels=160,
out_channels=320,
t=6)
# Input shape: 320x7x7
self.last_conv = Conv(first=False)
self.avgpool = nn.AdaptiveAvgPool2d(output_size=(1,1)) #(1)
self.dropout = nn.Dropout(p=0.2) #(2)
self.fc = nn.Linear(in_features=int(1280*WIDTH_MULTIPLIER), #(3)
out_features=1000)
def forward(self, x):
x = self.first_conv(x)
print(f"after first_conv : {x.shape}")
x = self.inv_residual0(x)
print(f"after inv_residual0 : {x.shape}")
for i, layer in enumerate(self.inv_residual1):
x = layer(x)
print(f"after inv_residual1 #{i} : {x.shape}")
for i, layer in enumerate(self.inv_residual2):
x = layer(x)
print(f"after inv_residual2 #{i} : {x.shape}")
for i, layer in enumerate(self.inv_residual3):
x = layer(x)
print(f"after inv_residual3 #{i} : {x.shape}")
for i, layer in enumerate(self.inv_residual4):
x = layer(x)
print(f"after inv_residual4 #{i} : {x.shape}")
for i, layer in enumerate(self.inv_residual5):
x = layer(x)
print(f"after inv_residual5 #{i} : {x.shape}")
x = self.inv_residual6(x)
print(f"after inv_residual6 : {x.shape}")
x = self.last_conv(x)
print(f"after last_conv : {x.shape}")
x = self.avgpool(x)
print(f"after avgpool : {x.shape}")
x = torch.flatten(x, start_dim=1)
print(f"after flatten : {x.shape}")
x = self.dropout(x)
print(f"after dropout : {x.shape}")
x = self.fc(x)
print(f"after fc : {x.shape}")
return x
尽管代码篇幅较长,但其逻辑相当直观,核心任务是根据给定的架构细节来组织各模块。然而,值得注意的是单个阶段内模块重复次数(n)以及阶段中第一个模块是否执行下采样(s)。这是因为架构似乎并未遵循特定的模式。有些情况下模块重复四次,有些情况下是两三次,甚至有一个阶段只包含一个模块。不仅如此,论文也未明确说明作者们决定在阶段的第一个模块中使用步长 1 或 2 的具体条件。然而,可以推测这个最终架构是基于他们未在论文中讨论的内部设计迭代和实验获得的。
回到代码,在各个阶段初始化完成后,接下来需要初始化剩余的层,即一个平均池化层(#(1))、一个 Dropout 层(#(2))和一个用于分类头的线性层(#(3))。如果回顾架构细节,会注意到最终层应该是一个逐点卷积,而不是这样的线性层。实际上,当输入张量的空间维度为 1×1 时,逐点卷积与线性层是等价的。因此,使用其中任何一个都是可以的。
为确保 MobileNetV2 模型正常运行,可以执行下面的代码块 11。从中可以看到,这个类实例运行没有出现任何错误。更重要的是,输出形状与论文中指定的架构完全匹配。这证实了实现的正确性,并已准备好进行训练——只需记得调整最后一层的输出尺寸以匹配数据集中的类别数量。
# Codeblock 11
mobilenetv2 = MobileNetV2()
x = torch.randn(BATCH_SIZE, IN_CHANNELS, IMAGE_SIZE, IMAGE_SIZE)
out = mobilenetv2(x)
# Codeblock 11 Output
after first_conv : torch.Size([1, 32, 112, 112])
after inv_residual1 : torch.Size([1, 16, 112, 112])
after inv_residual1 #0 : torch.Size([1, 24, 56, 56])
after inv_residual1 #1 : torch.Size([1, 24, 56, 56])
after inv_residual2 #0 : torch.Size([1, 32, 28, 28])
after inv_residual2 #1 : torch.Size([1, 32, 28, 28])
after inv_residual2 #2 : torch.Size([1, 32, 28, 28])
after inv_residual3 #0 : torch.Size([1, 64, 14, 14])
after inv_residual3 #1 : torch.Size([1, 64, 14, 14])
after inv_residual3 #2 : torch.Size([1, 64, 14, 14])
after inv_residual3 #3 : torch.Size([1, 64, 14, 14])
after inv_residual4 #0 : torch.Size([1, 96, 14, 14])
after inv_residual4 #1 : torch.Size([1, 96, 14, 14])
after inv_residual4 #2 : torch.Size([1, 96, 14, 14])
after inv_residual5 #0 : torch.Size([1, 160, 7, 7])
after inv_residual5 #1 : torch.Size([1, 160, 7, 7])
after inv_residual5 #2 : torch.Size([1, 160, 7, 7])
after inv_residual6 : torch.Size([1, 320, 7, 7])
after last_conv : torch.Size([1, 1280, 7, 7])
after avgpool : torch.Size([1, 1280, 1, 1])
after flatten : torch.Size([1, 1280])
after dropout : torch.Size([1, 1280])
after fc : torch.Size([1, 1000])
或者,也可以使用 torchinfo 库的 summary() 函数来测试 MobileNetV2 模型,它还会显示每层包含的参数数量。如果滚动到输出的末尾,会发现这个默认宽度乘数的模型拥有 3,505,960 个可训练参数。这个数字与论文中披露的 340 万有所不同(根据图 7)。然而,如果查阅 PyTorch 官方文档 [7],它指出该模型的参数数量为 3,504,872,这与本文的实现非常接近。如果读者了解如何调整代码以使其参数数量与 PyTorch 官方实现完全匹配,欢迎分享您的见解。
# Codeblock 12
mobilenetv2 = MobileNetV2()
summary(mobilenetv2, input_size=(BATCH_SIZE, IN_CHANNELS, IMAGE_SIZE, IMAGE_SIZE))
# Codeblock 12 Output
==========================================================================================
Layer (type:depth-idx) Output Shape Param #
==========================================================================================
MobileNetV2 [1, 1000] --
├─Conv: 1-1 [1, 32, 112, 112] --
│ └─Conv2d: 2-1 [1, 32, 112, 112] 864
│ └─BatchNorm2d: 2-2 [1, 32, 112, 112] 64
│ └─ReLU6: 2-3 [1, 32, 112, 112] --
├─InvResidualS1: 1-2 [1, 16, 112, 112] --
│ └─Conv2d: 2-4 [1, 32, 112, 112] 1,024
│ └─BatchNorm2d: 2-5 [1, 32, 112, 112] 64
│ └─ReLU6: 2-6 [1, 32, 112, 112] --
│ └─Conv2d: 2-7 [1, 32, 112, 112] 288
│ └─BatchNorm2d: 2-8 [1, 32, 112, 112] 64
│ └─ReLU6: 2-9 [1, 32, 112, 112] --
│ └─Conv2d: 2-10 [1, 16, 112, 112] 512
│ └─BatchNorm2d: 2-11 [1, 16, 112, 112] 32
├─ModuleList: 1-3 -- --
│ └─InvResidualS2: 2-12 [1, 24, 56, 56] --
│ │ └─Conv2d: 3-1 [1, 96, 112, 112] 1,536
│ │ └─BatchNorm2d: 3-2 [1, 96, 112, 112] 192
│ │ └─ReLU6: 3-3 [1, 96, 112, 112] --
│ │ └─Conv2d: 3-4 [1, 96, 56, 56] 864
│ │ └─BatchNorm2d: 3-5 [1, 96, 56, 56] 192
│ │ └─ReLU6: 3-6 [1, 96, 56, 56] --
│ │ └─Conv2d: 3-7 [1, 24, 56, 56] 2,304
│ │ └─BatchNorm2d: 3-8 [1, 24, 56, 56] 48
│ └─InvResidualS1: 2-13 [1, 24, 56, 56] --
│ │ └─Conv2d: 3-9 [1, 144, 56, 56] 3,456
│ │ └─BatchNorm2d: 3-10 [1, 144, 56, 56] 288
│ │ └─ReLU6: 3-11 [1, 144, 56, 56] --
│ │ └─Conv2d: 3-12 [1, 144, 56, 56] 1,296
│ │ └─BatchNorm2d: 3-13 [1, 144, 56, 56] 288
│ │ └─ReLU6: 3-14 [1, 144, 56, 56] --
│ │ └─Conv2d: 3-15 [1, 24, 56, 56] 3,456
│ │ └─BatchNorm2d: 3-16 [1, 24, 56, 56] 48
├─ModuleList: 1-4 -- --
│ └─InvResidualS2: 2-14 [1, 32, 28, 28] --
│ │ └─Conv2d: 3-17 [1, 144, 56, 56] 3,456
│ │ └─BatchNorm2d: 3-18 [1, 144, 56, 56] 288
│ │ └─ReLU6: 3-19 [1, 144, 56, 56] --
│ │ └─Conv2d: 3-20 [1, 144, 28, 28] 1,296
│ │ └─BatchNorm2d: 3-21 [1, 144, 28, 28] 288
│ │ └─ReLU6: 3-22 [1, 144, 28, 28] --
│ │ └─Conv2d: 3-23 [1, 32, 28, 28] 4,608
│ │ └─BatchNorm2d: 3-24 [1, 32, 28, 28] 64
│ └─InvResidualS1: 2-15 [1, 32, 28, 28] --
│ │ └─Conv2d: 3-25 [1, 192, 28, 28] 6,144
│ │ └─BatchNorm2d: 3-26 [1, 192, 28, 28] 384
│ │ └─ReLU6: 3-27 [1, 192, 28, 28] --
│ │ └─Conv2d: 3-28 [1, 192, 28, 28] 1,728
│ │ └─BatchNorm2d: 3-29 [1, 192, 28, 28] 384
│ │ └─ReLU6: 3-30 [1, 192, 28, 28] --
│ │ └─Conv2d: 3-31 [1, 32, 28, 28] 6,144
│ │ └─BatchNorm2d: 3-32 [1, 32, 28, 28] 64
│ └─InvResidualS1: 2-16 [1, 32, 28, 28] --
│ │ └─Conv2d: 3-33 [1, 192, 28, 28] 6,144
│ │ └─BatchNorm2d: 3-34 [1, 192, 28, 28] 384
│ │ └─ReLU6: 3-35 [1, 192, 28, 28] --
│ │ └─Conv2d: 3-36 [1, 192, 28, 28] 1,728
│ │ └─BatchNorm2d: 3-37 [1, 192, 28, 28] 384
│ │ └─ReLU6: 3-38 [1, 192, 28, 28] --
│ │ └─Conv2d: 3-39 [1, 32, 28, 28] 6,144
│ │ └─BatchNorm2d: 3-40 [1, 32, 28, 28] 64
├─ModuleList: 1-5 -- --
│ └─InvResidualS2: 2-17 [1, 64, 14, 14] --
│ │ └─Conv2d: 3-41 [1, 192, 28, 28] 6,144
│ │ └─BatchNorm2d: 3-42 [1, 192, 28, 28] 384
│ │ └─ReLU6: 3-43 [1, 192, 28, 28] --
│ │ └─Conv2d: 3-44 [1, 192, 14, 14] 1,728
│ │ └─BatchNorm2d: 3-45 [1, 192, 14, 14] 384
│ │ └─ReLU6: 3-46 [1, 192, 14, 14] --
│ │ └─Conv2d: 3-47 [1, 64, 14, 14] 12,288
│ │ └─BatchNorm2d: 3-48 [1, 64, 14, 14] 128
│ └─InvResidualS1: 2-18 [1, 64, 14, 14] --
│ │ └─Conv2d: 3-49 [1, 384, 14, 14] 24,576
│ │ └─BatchNorm2d: 3-50 [1, 384, 14, 14] 768
│ │ └─ReLU6: 3-51 [1, 384, 14, 14] --
│ │ └─Conv2d: 3-52 [1, 384, 14, 14] 3,456
│ │ └─BatchNorm2d: 3-53 [1, 384, 14, 14] 768
│ │ └─ReLU6: 3-54 [1, 384, 14, 14] --
│ │ └─Conv2d: 3-55 [1, 64, 14, 14] 24,576
│ │ └─BatchNorm2d: 3-56 [1, 64, 14, 14] 128
│ └─InvResidualS1: 2-19 [1, 64, 14, 14] --
│ │ └─Conv2d: 3-57 [1, 384, 14, 14] 24,576
│ │ └─BatchNorm2d: 3-58 [1, 384, 14, 14] 768
│ │ └─ReLU6: 3-59 [1, 384, 14, 14] --
│ │ └─Conv2d: 3-60 [1, 384, 14, 14] 3,456
│ │ └─BatchNorm2d: 3-61 [1, 384, 14, 14] 768
│ │ └─ReLU6: 3-62 [1, 384, 14, 14] --
│ │ └─Conv2d: 3-63 [1, 64, 14, 14] 24,576
│ │ └─BatchNorm2d: 3-64 [1, 64, 14, 14] 128
├─ModuleList: 1-6 -- --
│ └─InvResidualS1: 2-21 [1, 96, 14, 14] --
│ │ └─Conv2d: 3-73 [1, 384, 14, 14] 24,576
│ │ └─BatchNorm2d: 3-74 [1, 384, 14, 14] 768
│ │ └─ReLU6: 3-75 [1, 384, 14, 14] --
│ │ └─Conv2d: 3-76 [1, 384, 14, 14] 3,456
│ │ └─BatchNorm2d: 3-77 [1, 384, 14, 14] 768
│ │ └─ReLU6: 3-78 [1, 384, 14, 14] --
│ │ └─Conv2d: 3-79 [1, 96, 14, 14] 36,864
│ │ └─BatchNorm2d: 3-80 [1, 96, 14, 14] 192
│ └─InvResidualS1: 2-22 [1, 96, 14, 14] --
│ │ └─Conv2d: 3-81 [1, 576, 14, 14] 55,296
│ │ └─BatchNorm2d: 3-82 [1, 576, 14, 14] 1,152
│ │ └─ReLU6: 3-83 [1, 576, 14, 14] --
│ │ └─Conv2d: 3-84 [1, 576, 14, 14] 5,184
│ │ └─BatchNorm2d: 3-85 [1, 576, 14, 14] 1,152
│ │ └─ReLU6: 3-86 [1, 576, 14, 14] --
│ │ └─Conv2d: 3-87 [1, 96, 14, 14] 55,296
│ │ └─BatchNorm2d: 3-88 [1, 96, 14, 14] 192
│ └─InvResidualS1: 2-23 [1, 96, 14, 14] --
│ │ └─Conv2d: 3-89 [1, 576, 14, 14] 55,296
│ │ └─BatchNorm2d: 3-90 [1, 576, 14, 14] 1,152
│ │ └─ReLU6: 3-91 [1, 576, 14, 14] --
│ │ └─Conv2d: 3-92 [1, 576, 14, 14] 5,184
│ │ └─BatchNorm2d: 3-93 [1, 576, 14, 14] 1,152
│ │ └─ReLU6: 3-94 [1, 576, 14, 14] --
│ │ └─Conv2d: 3-95 [1, 96, 14, 14] 55,296
│ │ └─BatchNorm2d: 3-96 [1, 96, 14, 14] 192
├─ModuleList: 1-7 -- --
│ └─InvResidualS2: 2-24 [1, 160, 7, 7] --
│ │ └─Conv2d: 3-97 [1, 576, 14, 14] 55,296
│ │ └─BatchNorm2d: 3-98 [1, 576, 14, 14] 1,152
│ │ └─ReLU6: 3-99 [1, 576, 14, 14] --
│ │ └─Conv2d: 3-100 [1, 576, 7, 7] 5,184
│ │ └─BatchNorm2d: 3-101 [1, 576, 7, 7] 1,152
│ │ └─ReLU6: 3-102 [1, 576, 7, 7] --
│ │ └─Conv2d: 3-103 [1, 160, 7, 7] 92,160
│ │ └─BatchNorm2d: 3-104 [1, 160, 7, 7] 320
│ └─InvResidualS1: 2-25 [1, 160, 7, 7] --
│ │ └─Conv2d: 3-105 [1, 960, 7, 7] 153,600
│ │ └─BatchNorm2d: 3-106 [1, 960, 7, 7] 1,920
│ │ └─ReLU6: 3-107 [1, 960, 7, 7] --
│ │ └─Conv2d: 3-108 [1, 960, 7, 7] 8,640
│ │ └─BatchNorm2d: 3-109 [1, 960, 7, 7] 1,920
│ │ └─ReLU6: 3-110 [1, 960, 7, 7] --
│ │ └─Conv2d: 3-111 [1, 160, 7, 7] 153,600
│ │ └─BatchNorm2d: 3-112 [1, 160, 7, 7] 320
│ └─InvResidualS1: 2-26 [1, 160, 7, 7] --
│ │ └─Conv2d: 3-113 [1, 960, 7, 7] 153,600
│ │ └─BatchNorm2d: 3-114 [1, 960, 7, 7] 1,920
│ │ └─ReLU6: 3-115 [1, 960, 7, 7] --
│ │ └─Conv2d: 3-116 [1, 960, 7, 7] 8,640
│ │ └─BatchNorm2d: 3-117 [1, 960, 7, 7] 1,920
│ │ └─ReLU6: 3-118 [1, 960, 7, 7] --
│ │ └─Conv2d: 3-119 [1, 160, 7, 7] 153,600
│ │ └─BatchNorm2d: 3-120 [1, 160, 7, 7] 320
├─InvResidualS1: 1-8 [1, 320, 7, 7] --
│ └─Conv2d: 2-27 [1, 960, 7, 7] 153,600
│ └─BatchNorm2d: 2-28 [1, 960, 7, 7] 1,920
│ └─ReLU6: 2-29 [1, 960, 7, 7] --
│ └─Conv2d: 2-30 [1, 960, 7, 7] 8,640
│ └─BatchNorm2d: 2-31 [1, 960, 7, 7] 1,920
│ └─ReLU6: 2-32 [1, 960, 7, 7] --
│ └─Conv2d: 2-33 [1, 320, 7, 7] 307,200
│ └─BatchNorm2d: 2-34 [1, 320, 7, 7] 640
├─Conv: 1-9 [1, 1280, 7, 7] --
│ └─Conv2d: 2-35 [1, 1280, 7, 7] 409,600
│ └─BatchNorm2d: 2-36 [1, 1280, 7, 7] 2,560
│ └─ReLU6: 2-37 [1, 1280, 7, 7] --
├─AdaptiveAvgPool2d: 1-10 [1, 1280, 1, 1] --
├─Dropout: 1-11 [1, 1280] --
├─Linear: 1-12 [1, 1000] 1,281,000
==========================================================================================
Total params: 3,505,960
Trainable params: 3,505,960
Non-trainable params: 0
Total mult-adds (Units.MEGABYTES): 313.65
==========================================================================================
Input size (MB): 0.60
Forward/backward pass size (MB): 113.28
Params size (MB): 14.02
Estimated Total Size (MB): 127.91
==========================================================================================
结语
以上便是关于 MobileNetV2 的全部内容。本文鼓励读者自行探索该架构——至少在图像分类数据集上进行实际训练。请务必尝试调整“宽度乘数”和“输入分辨率”参数,以在预测准确性和计算效率之间找到恰当的平衡点。本文所使用的完整代码可在 GitHub 仓库 [8] 中找到。
希望本文能为您带来新的启发。感谢您的阅读!
参考文献
[1] Muhammad Ardi. MobileNetV1 Paper Walkthrough: The Tiny Giant. Towards Data Science. https://towardsdatascience.com/the-tiny-giant-mobilenetv1/ [Accessed September 25, 2025].
[2] Andrew G. Howard et al. MobileNets: Efficient Convolutional Neural Networks for Mobile Vision Applications. Arxiv. https://arxiv.org/abs/1704.04861 [Accessed April 7, 2025].
[3] Mark Sandler et al. MobileNetV2: Inverted Residuals and Linear Bottlenecks. Arxiv. https://arxiv.org/abs/1801.04381 [Accessed April 12, 2025].
[4] Kaiming He et al. Deep Residual Learning for Image Recognition. Arxiv. https://arxiv.org/abs/1512.03385 [Accessed April 12, 2025].
[5] Zhuang Liu et al. A ConvNet for the 2020s. Arxiv. https://arxiv.org/abs/2201.03545 [Accessed April 12, 2025].
[6] Image created originally by author.
[7] mobilenetv2. PyTorch. https://pytorch.org/vision/main/models/generated/torchvision.models.mobilenetv2.html#mobilenet-v2 [Accessed April 12, 2025].
