一. MobileNetV2 神经网络介绍
MobileNetV2 是 Google 于 2018 年推出的一款专为移动设备和嵌入式系统设计的轻量级卷积神经网络模型。它是 MobileNetV1 的升级版,旨在提高模型的效率和准确性,同时保持低计算成本和小内存占用。MobileNetV2 的关键创新点在于引入了所谓的“倒置残差结构”(Inverted Residuals)和“线性瓶颈层”(Linear Bottlenecks)
二. 概念拓展
1. 倒置残差结构(Inverted Residuals)
MobileNetV2 中的倒置残差结构与传统的残差结构相反。在传统的残差结构中,输入和输出通道数较多,而中间的卷积层通道数较少,形成一个瓶颈结构。而在 MobileNetV2 中,输入和输出通道数较少,中间的深度可分离卷积层通道数较多,形成一个倒置的瓶颈结构。
如上图,在传统的残差网络(ResNet)中,先使用 1×1 卷积实现降维,然后通过 3×3 卷积,最后通过 1×1 卷积实现升维,即两头大中间小。这样的设计形成了一个瓶颈,可以有效地减少计算量。残差模块还包括一个跳跃连接,将输入直接加到经过两个卷积层的输出上,以帮助梯度传播。在 MobileNetV2 中,倒置残差结构颠倒了这个逻辑。它首先通过 1x1 的点卷积(pointwise convolution)扩展输入通道数,然后通过 3x3 的深度可分离卷积(depthwise separable convolution)进行特征提取,最后再次通过 1x1 的点卷积将通道数压缩回原来的数量。这种设计被称为倒置残差结构,因为它在中间阶段拥有更多的通道,与传统的瓶颈结构相反。
另外,在倒置残差结构(Inverted Residuals)中,使用了 ReLU6 作为激活函数。ReLU6 是一种受限的线性单元(Rectified Linear Unit)激活函数,它将 ReLU 的输出限制在 0 到 6 之间。数学上可以定义为:
R
e
L
U
6
(
x
)
=
m
i
n
(
m
a
x
(
0
,
x
)
,
6
)
ReLU6(x)=min(max(0,x),6)
ReLU6(x)=min(max(0,x),6)
在倒置残差结构中,ReLU6 激活函数通常应用于扩张层(expansion layer)和深度可分离卷积层(depthwise separable convolution)之后。在扩张层中,ReLU6 有助于非线性特征的提取;在深度可分离卷积层后,ReLU6 则帮助提取更丰富的空间特征,同时保持计算效率。值得注意的是,压缩层(compression layer)或线性瓶颈层(linear bottleneck)之后并不使用 ReLU6 或其他激活函数,这是为了保持线性性质,减少信息损失,并有利于梯度传播。
在 MobileNetV2 中的 Inverted Residuals 结构图如下所示,与 ResNet 不同的是,只有当 stride = 1 且输入特征矩阵与输出特征矩阵 shape 相同的时候才有 shortcut 连接。
2. 线性瓶颈层(Linear Bottlenecks)
在倒置残差结构(Inverted Residuals)中,最后一层 1x1 卷积层使用线性激活函数而不是ReLU的原因在于避免信息损失。作者通过实验展示了 ReLU 激活函数在高维空间中对低维流形的影响。他们首先将一个二维矩阵(channel=1)通过矩阵T升维到更高维度,接着应用 ReLU 激活函数,然后用T的逆矩阵将其还原回二维空间。当T的维度较低(如 2 或 3)时,还原后的二维矩阵会丢失很多信息。然而,随着维度的增加,信息丢失逐渐减少。因此,为了避免 ReLU 激活函数对低维特征造成过多信息损失,倒置残差结构选择使用线性激活函数(即linear bottleneck),这样可以在输出时保持尽可能多的信息完整性。实验表明,使用线性瓶颈可以有效防止非线性操作破坏过多的信息。
三. MobileNetV2神经网络结构
MobileNetV2 的主要组成部分包括:
1. 倒残差块(Inverted Residual Blocks)
这是 MobileNetV2 的核心创新之一。传统的卷积神经网络中,卷积层通常会减少通道数来降低计算成本,而在解码阶段再恢复通道数。但是,MobileNetV2 采取了一种相反的方法,在开始时增加通道数,然后通过深度可分离卷积(Depthwise Separable Convolution)进行特征提取,最后使用 1x1 卷积降维。这样的设计可以有效利用中间的高维表示,并通过跳跃连接(skip connection)保持信息流。
2. 线性瓶颈(Linear Bottlenecks)
在倒残差块中,扩张部分的卷积通常是非线性的,但 MobileNetV2 在扩张部分后添加了一个线性瓶颈,即 1x1 的点卷积(pointwise convolution),其激活函数为线性。这有助于保留更多的信息并减少计算量。
3. 深度可分离卷积(Depthwise Separable Convolutions)
这是 MobileNet 系列的一个关键特性,它将标准卷积分解为两步:深度卷积(depthwise convolution)和点卷积(pointwise convolution)。深度卷积对输入的每个通道分别应用一个单独的滤波器,而点卷积则混合这些输出。这种方法显著减少了参数数量和计算需求。
MobileNetV2 的具体架构:
- 输入层:接收输入图像,通常大小为 224x224。
- 起始卷积层:一个标准的 3x3 卷积层,用于初步特征提取。
- 倒残差块序列:由多个倒残差块组成,每个块包含:
- 扩张 1x1 卷积(expand convolution),增加通道数。
- 深度卷积 3x3 或 5x5,具有 stride 为 1 或 2,用于空间特征提取。
- 线性瓶颈 1x1 卷积(squeeze and excite),降维。
- 跳跃连接(仅当输入输出维度相同时)。
- 结束前的卷积层:一个 1x1 的点卷积,用于最终特征图的生成。
- 全局平均池化层:将特征图压缩成单一向量。
- 全连接层:用于分类任务的输出。
四. MobileNetV2 模型亮点
MobileNetV2 作为轻量级神经网络的代表,它的设计亮点主要集中在几个方面,这些亮点使其在资源受限的设备上能够高效地运行,同时也保持了相当高的准确性。以下是 MobileNetV2 的一些关键亮点:
-
倒残差结构(Inverted Residuals):
在传统神经网络中,卷积层通常会在深度上压缩特征图以减少计算复杂度。然而,MobileNetV2 采取了一种相反的做法,它首先扩展特征图的深度,然后通过深度可分离卷积(Depthwise Separable Convolution)进行高效的空间特征提取,最后再通过 1x1 卷积减少通道数。这种设计能够利用中间的高维表示,同时保持较低的计算成本。 -
线性瓶颈(Linear Bottlenecks):
在倒残差结构中,扩展后的部分使用线性激活函数而不是非线性激活函数,这被称为线性瓶颈。线性瓶颈有助于保持信息流的同时减少计算量,因为非线性激活函数往往需要额外的计算资源。 -
深度可分离卷积(Depthwise Separable Convolutions):
这是 MobileNet 系列的一个重要特点,它将标准的卷积操作分解为深度卷积(Depthwise Convolution)和点卷积(Pointwise Convolution)。深度卷积在每个输入通道上独立应用滤波器,而点卷积则用于混合这些特征。这种方法大大减少了参数数量和计算需求。 -
跳跃连接(Skip Connections):
类似于 ResNet 中的跳跃连接,MobileNetV2 在倒残差块中也使用了跳跃连接,当输入输出维度匹配时,它可以将输入直接加到输出上。这有助于缓解梯度消失问题,同时也提高了模型的稳定性。 -
可扩展性(Scalability):
MobileNetV2 提供了宽度和分辨率的可调节因子,允许用户根据具体的应用场景和设备的计算能力调整模型的大小。这意味着可以从一个基础模型衍生出多个变体,满足不同场景下的需求。 -
高效率与良好精度的平衡:
尽管 MobileNetV2 是一个轻量级模型,但它在 ImageNet 等数据集上仍然达到了较高的分类准确率,相比于其他轻量级模型,MobileNetV2 在效率和准确率之间找到了很好的平衡点。
这些设计使得 MobileNetV2 非常适合在移动设备和边缘计算设备上部署,它不仅能够快速处理图像识别任务,而且由于计算量和内存需求较低,还可以在电池供电的设备上长时间运行。
五. MobileNetV2代码实现
开发环境配置说明:本项目使用 Python 3.6.13 和 PyTorch 1.10.2 构建,适用于CPU环境。
- model.py:定义网络模型
- train.py:加载数据集并训练,计算 loss 和 accuracy,保存训练好的网络参数
- predict.py:用自己的数据集进行分类测试
- model.py
import torch
from torch import nn
def _make_divisible(ch, divisor=8, min_ch=None):
"""
:param ch: 输入特征矩阵的channel
:param divisor: 基数
:param min_ch: 最小通道数
"""
if min_ch is None:
min_ch = divisor
# 将ch调整到距离8最近的整数倍
# int(ch + divisor / 2) // divisor 向上取整
new_ch = max(min_ch, int(ch + divisor / 2) // divisor * divisor)
# 确保向下取整时不会减少超过10%
if new_ch < 0.9 * ch:
new_ch += divisor
return new_ch
# 定义 卷积-BN-ReLU6 联合操作
class ConvBNReLU(nn.Sequential):
# PyTorch中DW卷积通过调用 nn.Conv2d() 来实现
# 参数 (groups=1) 为普通卷积,参数 (groups=输入特征矩阵的深度) 为DW卷积
def __init__(self, in_channel, out_channel, kernel_size=3, stride=1, groups=1):
padding = (kernel_size - 1) // 2
super(ConvBNReLU, self).__init__(
nn.Conv2d(in_channel, out_channel, kernel_size, stride, padding, groups=groups, bias=False),
nn.BatchNorm2d(out_channel),
nn.ReLU6(inplace=True)
)
# 倒残差结构
class InvertedResidual(nn.Module):
# expand_ratio:扩展因子(t)
def __init__(self, in_channel, out_channel, stride, expand_ratio):
super(InvertedResidual, self).__init__()
# 定义隐层,对应第一层的输出通道数 (tk)
hidden_channel = in_channel * expand_ratio
# 当stride=1且输入特征矩阵与输出特征矩阵shape相同是才有shortcut
self.use_shotcut = stride == 1 and in_channel == out_channel
layers = []
if expand_ratio != 1:
# 1x1 pointwise conv
layers.append(ConvBNReLU(in_channel, hidden_channel, kernel_size=1))
layers.extend([
# 3x3 depthwise conv
ConvBNReLU(hidden_channel, hidden_channel, stride=stride, groups=hidden_channel),
# 1x1 pointwise conv(linear) linear:不添加激活函数就等于线性函数
nn.Conv2d(hidden_channel, out_channel, kernel_size=1, bias=False),
nn.BatchNorm2d(out_channel),
])
self.conv = nn.Sequential(*layers)
def forward(self, x):
if self.use_shotcut:
return x + self.conv(x)
else:
return self.conv(x)
class MobileNetV2(nn.Module):
# alpha:用来控制卷积层中所使用卷积核个数的参数
def __init__(self, num_classes=1000, alpha=1.0, round_nearest=8):
super(MobileNetV2, self).__init__()
# 初始化倒残差模块
block =InvertedResidual
# 通过_make_divisible将卷积核个数调整为8的整数倍
input_channel = _make_divisible(32 * alpha, round_nearest)
last_channel = _make_divisible(1280 * alpha, round_nearest)
# 创建参数列表
inverted_residual_setting = [
# t, c, n, s
[1, 16, 1, 1],
[6, 24, 2, 2],
[6, 32, 3, 2],
[6, 64, 4, 2],
[6, 96, 3, 1],
[6, 160, 3, 2],
[6, 320, 1, 1],
]
features = []
features.append(ConvBNReLU(3, input_channel, stride=2))
# 定义一系列block结构
for t, c, n, s in inverted_residual_setting:
# 调整输出通道数
output_channel = _make_divisible(c * alpha, round_nearest)
# 重复倒残差结构
# 第一层:stride=n 其它层:stride=1
for i in range(n):
stride = s if i == 0 else 1
features.append(block(input_channel, output_channel, stride, expand_ratio=t))
input_channel = output_channel
# 定义最后一个卷积层
features.append(ConvBNReLU(input_channel, last_channel, 1))
# 特征提取层
self.features = nn.Sequential(*features)
# 分类器部分
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.classifer = nn.Sequential(
nn.Dropout(0.2),
nn.Linear(last_channel, num_classes)
)
# 初始化权重
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out')
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.BatchNorm2d):
nn.init.ones_(m.weight)
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Linear):
nn.init.normal_(m.weight, 0, 0.01)
nn.init.zeros_(m.bias)
def forward(self, x):
x = self.features(x)
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.classifer(x)
return x
- train.py
import torch
import torch.nn as nn
from torchvision import transforms, datasets
from torch.utils.data import DataLoader
import torch.optim as optim
from model import MobileNetV2
import os
import json
import torchvision.models.mobilenet
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
# print(device)
data_transform = {
"train" : transforms.Compose([transforms.RandomResizedCrop(224), # 随机裁剪
transforms.RandomHorizontalFlip(), # 随机翻转
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])]),
"val" : transforms.Compose([transforms.Resize(256), # 长宽比不变,最小边长缩放到256
transforms.CenterCrop(224), # 中心裁剪到 224x224
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])}
# 获取数据集所在的根目录
# 通过os.getcwd()获取当前的目录,并将当前目录与".."链接获取上一层目录
data_root = os.path.abspath(os.path.join(os.getcwd(), ".."))
# 获取花类数据集路径
image_path = data_root + "/data_set/flower_data/"
# 加载数据集
train_dataset = datasets.ImageFolder(root=image_path + "/train",
transform=data_transform["train"])
# 获取训练集图像数量
train_num = len(train_dataset)
# 获取分类的名称
# {'daisy': 0, 'dandelion': 1, 'roses': 2, 'sunflowers': 3, 'tulips': 4}
flower_list = train_dataset.class_to_idx
# 采用遍历方法,将分类名称的key与value反过来
cla_dict = dict((val, key) for key, val in flower_list.items())
# 将字典cla_dict编码为json格式
json_str = json.dumps(cla_dict, indent=4)
with open("class_indices.json", "w") as json_file:
json_file.write(json_str)
batch_size = 16
train_loader = DataLoader(train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=0)
validate_dataset = datasets.ImageFolder(root=image_path + "/val",
transform=data_transform["val"])
val_num = len(validate_dataset)
validate_loader = DataLoader(validate_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=0)
# 定义模型
net = MobileNetV2(num_classes=5) # 实例化模型
net.to(device)
model_weight_path = "./mobilenet_v2.pth"
# 载入模型权重
pre_weights = torch.load(model_weight_path)
# 删除分类权重
pre_dict = {k: v for k, v in pre_weights.items() if "classifier" not in k}
missing_keys, unexpected_keys = net.load_state_dict(pre_dict, strict=False)
# 冻结除最后全连接层以外的所有权重
for param in net.features.parameters():
param.requires_grad = False
loss_function = nn.CrossEntropyLoss() # 定义损失函数
#pata = list(net.parameters()) # 查看模型参数
optimizer = optim.Adam(net.parameters(), lr=0.0001) # 定义优化器
# 设置存储权重路径
save_path = './mobilenetV2.pth'
best_acc = 0.0
for epoch in range(1):
# train
net.train() # 用来管理Dropout方法:训练时使用Dropout方法,验证时不使用Dropout方法
running_loss = 0.0 # 用来累加训练中的损失
for step, data in enumerate(train_loader, start=0):
# 获取数据的图像和标签
images, labels = data
# 将历史损失梯度清零
optimizer.zero_grad()
# 参数更新
outputs = net(images.to(device)) # 获得网络输出
loss = loss_function(outputs, labels.to(device)) # 计算loss
loss.backward() # 误差反向传播
optimizer.step() # 更新节点参数
# 打印统计信息
running_loss += loss.item()
# 打印训练进度
rate = (step + 1) / len(train_loader)
a = "*" * int(rate * 50)
b = "." * int((1 - rate) * 50)
print("\rtrain loss: {:^3.0f}%[{}->{}]{:.3f}".format(int(rate * 100), a, b, loss), end="")
print()
# validate
net.eval() # 关闭Dropout方法
acc = 0.0
# 验证过程中不计算损失梯度
with torch.no_grad():
for data_test in validate_loader:
test_images, test_labels = data_test
outputs = net(test_images.to(device))
predict_y = torch.max(outputs, dim=1)[1]
# acc用来累计验证集中预测正确的数量
# 对比预测值与真实标签,sum()求出预测正确的累加值,item()获取累加值
acc += (predict_y == test_labels.to(device)).sum().item()
accurate_test = acc / val_num
# 如果当前准确率大于历史最优准确率
if accurate_test > best_acc:
# 更新历史最优准确率
best_acc = accurate_test
# 保存当前权重
torch.save(net.state_dict(), save_path)
# 打印相应信息
print("[epoch %d] train_loss: %.3f test_accuracy: %.3f"%
(epoch + 1, running_loss / step, acc / val_num))
print("Finished Training")
- predict
import os
import json
import torch
from PIL import Image
from torchvision import transforms
import matplotlib.pyplot as plt
from model import MobileNetV2
def main():
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
data_transform = transforms.Compose(
[transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])
# load image
img_path = "./郁金香.png"
assert os.path.exists(img_path), "file: '{}' dose not exist.".format(img_path)
img = Image.open(img_path)
plt.imshow(img)
# [N, C, H, W]
img = data_transform(img)
# expand batch dimension
img = torch.unsqueeze(img, dim=0)
# read class_indict
json_path = './class_indices.json'
assert os.path.exists(json_path), "file: '{}' dose not exist.".format(json_path)
with open(json_path, "r") as f:
class_indict = json.load(f)
# create model
model = MobileNetV2(num_classes=5).to(device)
# load model weights
weights_path = "./mobilenetV2.pth"
assert os.path.exists(weights_path), "file: '{}' dose not exist.".format(weights_path)
model.load_state_dict(torch.load(weights_path, map_location=device))
# prediction
model.eval()
with torch.no_grad():
# predict class
output = torch.squeeze(model(img.to(device))).cpu()
predict = torch.softmax(output, dim=0)
predict_cla = torch.argmax(predict).numpy()
print_res = "class: {} prob: {:.3}".format(class_indict[str(predict_cla)],
predict[predict_cla].numpy())
plt.title(print_res)
for i in range(len(predict)):
print("class: {:10} prob: {:.3}".format(class_indict[str(i)],
predict[i].numpy()))
plt.show()
if __name__ == '__main__':
main()