实践部分
本章针对实践通过使用pytorch一个实例对这部分内容进行吸收分析。本章节采用的源代码在这里感兴趣的读者可以自行下载操作。
一、数据集介绍
可以看到数据集本身被存放在了三个文件夹下,其主要是花的图片,被分割成了验证集和训练集,模型训练主要就是采用训练集中的数据进行训练,验证集则用来对模型的性能进行测试。
为了进一步增强数据集的结构化和规范化主要目的更好的使用pytorh工具包下的现成模块,每个图像通常会被放置在代表其类别的文件夹中。这意味着所有同类别的图像会被存放在相同的文件夹里。这样的存放方式不仅使数据集的管理变得简单化,更重要的是,为使用自动化工具提供了便利。例如,图像数据集的这种标准存放形式完美支持了 PyTorch 中的DatasetFolder工具直接进行处理。
前几章节在实战部分讲述过,可以省却重复编码自定义Dataset类的复杂过程。DatasetFolder工具能够直观地从这种组织形式的数据集中加载图像及其对应标签,大幅简化了数据预处理和加载的步骤。
二、模型整体框架
在深度学习模型的训练和部署过程中,整个工程项目通常围绕着以下三个核心文件进行组织,进而构建起模型的完整架构。这些文件分别负责不同的任务,协同工作以实现模型的训练、评估和应用。
-
模型模块(Model Module) - 位于心脏位置的模型模块,负责存放模型的主体架构。它定义了模型的各个层、前向传播逻辑以及计算过程,是整个深度学习任务的基础和核心。
-
训练文件(Training ) - 这个脚本文件负责驱动模型的训练过程。它通过调用先前准备好的数据集及模型模块,以特定的训练策略(例如学习率调整、批处理大小选择等)对模型进行训练。该文件通常会包含模型训练、验证过程,并输出训练过程中的性能指标,如损失和准确率等。
-
预测模块(Prediction) - 一旦模型被训练并优化到满意的状态,预测模块则负责将这个训练好的模型导入并应用到后续的任务中。无论是用于进一步的分析、应对实时的预测请求,还是集成至更广阔的系统中,预测模块都为模型的实际使用提供了接口。
将围绕这三个文件对整个模型的框架进行展开讲解。
三、模型代码详解
3.1、train
首先看下模型所需要的函数部分:
import os # 文件和文件夹提供一系列操作的工具,当前文件中主要用来查找模块文件的路径地址。用于各种地址路径操作
import sys
import json
import torch
import torch.nn as nn
import torch.optim as optim # 优化方法Adam之类的优化算法
from torchvision import transforms, datasets # 数据集操作
from tqdm import tqdm # 进度条
from model_v2 import MobileNetV2 # 编写的模型主体框架文件
接下来看train的主体文件:
def main(): # 主函数在当前文件下直接执行
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") # 判断下GPU是否有效
print("using {} device.".format(device)) # 输出下在什么设备上运行的
batch_size = 16 # 批大小
epochs = 5 # 全部周期
data_transform = {
# 即对打开的图片如何处理再送入模型,数据增强技术 .Compose将做种方式进行整合,可以按照字典的方式进行调取使用
"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),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])}
transforms.Compose
是PyTorch的torchvision.transforms
模块中的一个功能,用于组合多个图像变换操作。以下是这一系列变换操作的具体作用解释:
-
transforms.RandomResizedCrop(224)
:- 这个变换随机地对图像进行裁剪,并将裁剪后的图像缩放到给定的大小(在这个例子中是224x224像素)。这种变换能够在一定程度上减少模型对图像特定部分的依赖,提高模型对于图像位置变化的鲁棒性,常用于数据增强。
-
transforms.RandomHorizontalFlip()
:- 随机地水平翻转图像。对于每个图像,它有50%的概率被翻转。这种变换能够增加数据的多样性,帮助模型学习到对于水平方向不变性的特征,减少过拟合。
-
transforms.ToTensor()
:- 将PIL图像或NumPy的ndarray转换为PyTorch的Tensor。这个操作还会自动将图像的数据从0到255的整数映射到0到1的浮点数,标准化图像的数据范围。
-
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
:- 对图像进行标准化,即减去均值(mean)后再除以标准差(std)进行归一化。这里的均值
[0.485, 0.456, 0.406]
和标准差[0.229, 0.224, 0.225]
是针对每一个通道的(通常为RGB通道)。这样的归一化有助于加速训练过程,减少模型对原始图像灰度尺度的依赖。 - 这组特定的均值和标准差来自ImageNet数据集的统计,是很多预训练模型使用的标准化参数。如果你使用这些预训练模型,采用相同的归一化参数可以保持数据的一致性。
- 对图像进行标准化,即减去均值(mean)后再除以标准差(std)进行归一化。这里的均值
训练集合中这一组变换操作首先对图像进行了数据增强(通过随机裁剪和随机水平翻转),然后转换为了模型训练需要的Tensor格式,并且对图像进行了标准化处理,以便用于模型的训练。这些步骤是进行模型训练时常见的图像预处理流程。
测试集合中操作集合:
-
transforms.Resize(256)
:- 首先对图像进行缩放,使其最短边的长度为256像素。这步是为了保证图像的尺寸一致性,为后续的裁剪操作做准备。
-
transforms.CenterCrop(224)
:- 接下来执行中心裁剪,从缩放后的图像中裁切出一个大小为224x224像素的中心区域。中心裁剪通常用在验证和测试集的图像预处理中,旨在减少模型对图像边缘部分的依赖,同时保留图像最关键的内容区域。
-
transforms.ToTensor()
:- 然后将处理过的图像转换为PyTorch Tensor,并自动将数值范围从[0, 255]归一化到[0, 1]。这是为了使图像数据适配PyTorch模型的输入要求。
-
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
:- 最后,对图像的每个通道执行标准化操作。具体来说,使用给定的均值([0.485, 0.456, 0.406])和标准差([0.229, 0.224, 0.225])对图像的RGB通道进行标准化。这一步骤是基于ImageNet数据集的图像统计特性,可以进一步提升模型的泛化能力。标准化有助于加速模型训练,提高模型性能。
data_root = os.path.abspath(os.path.join(os.getcwd(), "../..")) # get data root path
image_path = os.path.join(data_root, "data_set", "flower_data") # flower data set path
os.getcwd()
是Python中的一个函数,隶属于os
(操作系统)模块。getcwd
是get current working directory
的缩写,这个函数的作用是返回当前工作目录的绝对路径。
在Python程序中,当前工作目录指的是执行当前代码时所在的文件系统目录。
以下是一个简单的使用例子:
import os
# 获取并打印当前工作目录
current_directory = os.getcwd()
print("当前工作目录是:", current_directory)
下述代码找目录,就是找数据集的位置,用来传数据集,由于其为通用代码所以作者为了减少用户修改代码的必要再次进行模型自动调用。
如果你在命令行中运行上述Python脚本,它会打印出从哪个目录运行了Python解释器。了解当前的工作目录对于执行与文件路径操作相关的任务非常有用,比如读取或写入到相对路径的文件。
通过和"…/…"拼接找上两级的菜单作为当前图片的路径信息,如果要运行就自行修改。
data_root = os.path.abspath(os.path.join(os.getcwd(), "../..")) # get data root path
image_path = os.path.join(data_root, "data_set", "flower_data") # flower data set path
使用断言,如果这个路径不存在就报错,确保有数据集
assert os.path.exists(image_path), "{} path does not exist.".format(image_path)
train_dataset = datasets.ImageFolder(root=os.path.join(image_path, "train"),
transform=data_transform["train"])
这个功能和pytorch中的另一个模块比较像:
在PyTorch中,ImageFolder
和DatasetFolder
是两个用来加载数据的类,它们确实有相似之处,但也有一些关键区别。详细地解析一下:
相似之处
- 目的相同:两者都用于加载数据集,特别是那些按文件夹组织的数据集,其中每个文件夹包含一个类别的数据。
- 简化数据加载:它们提供了简洁的接口来加载数据,减少了编写自定义加载逻辑的需要,通过
transforms
参数,还可以很方便地对数据进行预处理和增强。
关键区别
-
使用场景:
ImageFolder
特别适用于图像数据,它假定数据集是以文件夹方式组织的,其中每个文件夹对应一个类别的图像。它自动将文件夹的名字作为类别的标签。DatasetFolder
则更为通用,可以用来加载任何类型的数据,只要数据是按类别组织在不同文件夹中。它允许通过loader
参数自定义如何加载数据,这意味着您可以定义加载图像、文本文件或其他类型文件的函数。
-
灵活性:#实际上是DatasetFolder的一个图片领域的应用,即在DatasetFolder中要规定如何打开这个数据,则这应用特例则直接内部定义好了,极简化处理
ImageFolder
内部实际上是DatasetFolder
的一个具体实现,特化于处理图像文件,并且预设了使用PIL库来加载图像。这使得ImageFolder
使用起来更加简单直观,特别是对于图像数据。DatasetFolder
提供了更多的自定义选项,比如自定义加载函数(loader
)和数据后缀(extensions
),从而可以更灵活地加载不同类型的文件数据。
示例
使用ImageFolder
加载图像数据:
from torchvision.datasets import ImageFolder
from torchvision import transforms
transform = transforms.Compose([
transforms.Resize((256, 256)),
transforms.ToTensor(),
])
dataset = ImageFolder(root='path/to/data', transform=transform)
使用DatasetFolder
加载非图像类型的数据集:
from torchvision.datasets import DatasetFolder
from torchvision import transforms
from my_custom_loader import custom_loader_function
dataset = DatasetFolder(root='path/to/data', loader=custom_loader_function, extensions=('txt',), transform=some_transforms)
总之,虽然ImageFolder
和DatasetFolder
有相似之处,它们都提供了用于加载和处理以文件夹为单位组织的数据集的便捷方法,但DatasetFolder
的设计更为通用,提供了更大的灵活性,而ImageFolder
则专门用于处理图像数据,使用起来更加方便简洁。
train_dataset = datasets.ImageFolder(root=os.path.join(image_path, "train")
transform=data_transform["train"])
train_num = len(train_dataset) # 判断下数据集的长度
#获取属性到类别的映射
假设您有一个将花卉类别映射到编号的字典,如下所示:
# 类别到编号的映射,这个通过数据集的方法获取
{'daisy': 0, 'dandelion': 1, 'roses': 2, 'sunflower': 3, 'tulips': 4}
import json
# 假定这是从您的数据集中获取的映射
train_dataset = {'daisy': 0, 'dandelion': 1, 'roses': 2, 'sunflower': 3, 'tulips': 4}
# 获取花卉类别到编号的映射
flower_list = train_dataset
# 将映射反转,使编号映射到花卉类别
cla_dict = dict((val, key) for key, val in flower_list.items())
# 将反转后的字典编码为JSON字符串
json_str = json.dumps(cla_dict, indent=4)
# 显示JSON字符串
print(json_str)
# 将JSON字符串写入文件
with open('flower_class_to_idx.json', 'w') as json_file:
json_file.write(json_str)
输出和写入文件flower_class_to_idx.json
的JSON字符串看起来会像这样:
{
"0": "daisy",
"1": "dandelion",
"2": "roses",
"3": "sunflower",
"4": "tulips"
}
这个例子说明了如何将类别到编号的映射反转,并使用json.dumps
函数将反转后的字典编码为易于阅读的JSON字符串,最后将这个JSON字符串保存到文件中。
# {'daisy':0, 'dandelion':1, 'roses':2, 'sunflower':3, 'tulips':4}
flower_list = train_dataset.class_to_idx
cla_dict = dict((val, key) for key, val in flower_list.items())
# write dict into json file
json_str = json.dumps(cla_dict, indent=4) #将python对象编码成Json字符串 indent:参数根据数据格式缩进显示,读起来更加清晰。
with open('class_indices.json', 'w') as json_file:
json_file.write(json_str)
# 具体流程就是通过使用class_to_idx得到索引映射信息,使用for进行便利获取。反转位置将文件写入一个json字符串中,并创建一个文件夹对这部分数据进行保存。
nw = min([os.cpu_count(), batch_size if batch_size > 1 else 0, 8]) # number of workers 线程数量 计算单个批次损失你多个size就可以一起运行,多个size在不同的核上使用相同的模型计算,得到损失更新参数。
print('Using {} dataloader workers every process'.format(nw)) # 输出最终决定使用的线程数量
train_loader = torch.utils.data.DataLoader(train_dataset,
batch_size=batch_size, shuffle=True,
num_workers=nw)
# 创建加载器。迭代数据集
validate_dataset = datasets.ImageFolder(root=os.path.join(image_path, "val"),
transform=data_transform["val"])
val_num = len(validate_dataset)
validate_loader = torch.utils.data.DataLoader(validate_dataset,
batch_size=batch_size, shuffle=False,
num_workers=nw)
print("using {} images for training, {} images for validation.".format(train_num,
val_num))
# create model
net = MobileNetV2(num_classes=5) # 实例化模型仅有最终类别需要进行设置
下面这部分主要是就是对预训练的参数进行还在,先通过链接对参数进行下载然后导入到模型中,在实际的应用过程中通常会将模型性能好的参数进行保存从而实现模型的可复现。而预训练参数则是在大型优质数据集上得到的参数进行一个全职性能的锻炼,然后再依赖于自身的数据集进行泛化实践。
# load pretrain weights
# download url: https://download.pytorch.org/models/mobilenet_v2-b0353104.pth
model_weight_path = "./mobilenet_v2.pth"
'''
这个地址是用于下载PyTorch框架的一个预训练模型权重文件,
具体是MobileNetV2模型的权重文件。MobileNetV2是一
种轻量级的深度神经网络,常用于在移动和嵌入式设备上进行图
像识别和处理等任务。预训练模型是指已经在大型数据集(如I
mageNet)上训练过的模型,可用于相似任务的迁移学习或直接
进行预测,无需从头开始训练。
'''
assert os.path.exists(model_weight_path), "file {} dose not exist.".format(model_weight_path)
通过这部分导入简单说一下,保存模型的参数和读取:
torch.save(obj, f, pickle_module=<module '...'>, pickle_protocol=2)
在PyTorch的torch.save
函数中,obj
参数是你想要保存的对象,而f
参数指的是保存这个对象的文件名或者文件对象。
具体来说,f
可以是:
-
字符串(string):代表你想要保存文件的路径。例如,如果你想要保存一个模型权重到当前目录下的文件
model.pth
,你可以这样写torch.save(obj, 'model.pth')
。 -
文件对象(file object):如果你首先打开一个文件用于写操作,你可以将这个文件对象传递给
torch.save
函数。这在你需要更细粒度控制文件保存过程时非常有用。例如:with open('model.pth', 'wb') as f: torch.save(obj, f)
这里的with open('model.pth', 'wb') as f:
语句首先以二进制写模式(‘wb’)打开一个名为model.pth
的文件,并将这个文件对象赋值给变量f
。然后,torch.save(obj, f)
将对象obj
保存到这个文件中。
其他参数pickle_module
和pickle_protocol
允许你定制序列化过程,但它们是可选的。pickle
是Python的一个序列化库,用来将Python对象转换为字节流(方便存储或传输),torch.save
内部使用了pickle
来序列化PyTorch对象。
总结来说,torch.save
函数的f
参数指定了你想要保存PyTorch对象的位置,可以是一个文件路径的字符串,也可以是一个打开的文件对象。
还有一个小的tip
保存整个模型:torch.save(model,‘save.pt’)
只保存训练好的权重:torch.save(model.state_dict(), ‘save.pt’)
torch.load
是PyTorch中的一个函数,用于加载通过torch.save
保存的序列化对象,通常用于加载模型权重和模型参数。这个函数提供了一个便捷的方式来恢复训练过的模型、优化器状态或者任意通过torch.save
保存的Python对象。
基本用法
函数原型如下:
torch.load(f, map_location=None, pickle_module=pickle, **pickle_load_args)
-
f
:要加载的文件名(字符串类型)或者文件对象。如果是文件名,torch.load
将会从这个指定路径加载对象;如果是文件对象,加载操作会从这个文件流中读取数据。 -
map_location
:指定如何映射存储位置。这个参数对于跨设备加载模型非常有用,比如在训练时使用了GPU,而在加载时想要在CPU上加载模型。通过设置map_location
参数,你可以控制数据应该被加载到哪个设备上。例如,map_location='cpu'
将所有张量映射到CPU上。 -
pickle_module
:用于解序列化的pickle
模块。这个参数通常不需要修改,默认使用Python的pickle
模块。 -
pickle_load_args
:传递给pickle模块的附加关键字参数。
实例
假设你已经使用torch.save
保存了模型的状态字典到文件model.pth
,以下是如何加载它的例子:
model = SomeModel() # 假设有一个模型类SomeModel
model.load_state_dict(torch.load('model.pth'))
model.eval() # 将模型设置为评估模式
如果你需要在CPU上加载一个原本在GPU上训练的模型:
model.load_state_dict(torch.load('model.pth', map_location='cpu'))
这样,不管模型原先是在哪个设备上训练的,都可以被加载到CPU上进行推理或继续训练。
torch.load
是模型复现和迁移学习中至关重要的一个函数,使得训练过的模型可以被轻松保存和重新加载,大大简化了模型的部署和共享过程。
pre_weights = torch.load(model_weight_path, map_location='cpu')
# delete classifier weights
pre_dict = {k: v for k, v in pre_weights.items() if net.state_dict()[k].numel() == v.numel()}
missing_keys, unexpected_keys = net.load_state_dict(pre_dict, strict=False)
这段代码的目的是从pre_weights
(一个预训练模型的权重字典)中删除不匹配当前模型net
的分类器层权重,然后将剩余的权重加载到net
里。详细来解释一下:
- 删除分类器层权重:
pre_dict = {k: v for k, v in pre_weights.items() if net.state_dict()[k].numel() == v.numel()}
通过下属例子简单的理解下python中的推倒式
> >>> names = ['Bob','Tom','alice','Jerry','Wendy','Smith']
> >>> new_names = [name.upper()for name in names if len(name)>3]
> >>> print(new_names) ['ALICE', 'JERRY', 'WENDY', 'SMITH']
通过一个字典推倒式判断与训练加载进来的权重信息和模型所需的权重参数维度是否一致,不一致的层就不要了,从而直接甩掉了最终使用softmax层所用的分类不一致的权重信息。
- 在这一行,通过字典推导式创建了一个新字典pre_dict
,包含了符合条件的权重。条件是:只有当pre_weights
中的权重v
(一个张量)的元素数(通过numel()
方法获取)与当前模型net
对应层的权重(net.state_dict()[k]
)的元素数相同,这个权重v
才被包含在pre_dict
内。
- 简而言之,这个操作保留了那些在结构(尺寸)上与net
中现有层相匹配的预训练层权重。这通常用于去除与分类任务相关的层,因为不同任务可能需要不同数量的输出单元。
- 加载筛选过的权重:
missing_keys, unexpected_keys = net.load_state_dict(pre_dict, strict=False)
- 使用
load_state_dict
方法将筛选过的权重pre_dict
加载到当前模型net
中。strict=False
参数意味着在pre_dict
中有而net
中没有的权重,或者net
中有而pre_dict
中没有的权重,都不会导致错误,而是会被忽略。 - 此方法返回两个列表:
missing_keys
(在加载的pre_dict
中缺少而net
中存在的键或层名)和unexpected_keys
(在pre_dict
中存在而在net
中不存在的层名)。这可以用来检查和调试权重加载的过程。
- 使用
总结一下,这段代码的作用是从预训练权重中去除与当前模型的分类层不匹配的权重,加载剩余的匹配权重,同时以非严格模式处理不匹配的层,保证模型可以利用预训练的特征提取层而不受分类层影响。这在迁移学习的场景下非常有用,特别是当你想要重用一个预训练模型的特征提取部分,并用于一个不同类别数的新任务时。
# freeze features weights
for param in net.features.parameters():
param.requires_grad = False
net.to(device)
# define loss function
loss_function = nn.CrossEntropyLoss()
# construct an optimizer
params = [p for p in net.parameters() if p.requires_grad]
optimizer = optim.Adam(params, lr=0.0001)# 告之优化器那些参数可以修改
best_acc = 0.0
save_path = './MobileNetV2.pth'
train_steps = len(train_loader)
for epoch in range(epochs):
# train
net.train()
running_loss = 0.0
train_bar = tqdm(train_loader, file=sys.stdout)
for step, data in enumerate(train_bar):
images, labels = data
optimizer.zero_grad()
logits = net(images.to(device))
loss = loss_function(logits, labels.to(device))
loss.backward()
optimizer.step()
# print statistics
running_loss += loss.item()
train_bar.desc = "train epoch[{}/{}] loss:{:.3f}".format(epoch + 1,
epochs,
loss)
# validate
net.eval()
acc = 0.0 # accumulate accurate number / epoch
with torch.no_grad():
val_bar = tqdm(validate_loader, file=sys.stdout)
for val_data in val_bar:
val_images, val_labels = val_data
outputs = net(val_images.to(device))
# loss = loss_function(outputs, test_labels)
predict_y = torch.max(outputs, dim=1)[1]
acc += torch.eq(predict_y, val_labels.to(device)).sum().item()
val_bar.desc = "valid epoch[{}/{}]".format(epoch + 1,
epochs)
val_accurate = acc / val_num
print('[epoch %d] train_loss: %.3f val_accuracy: %.3f' %
(epoch + 1, running_loss / train_steps, val_accurate))
if val_accurate > best_acc:
best_acc = val_accurate
torch.save(net.state_dict(), save_path)
print('Finished Training')
if __name__ == '__main__':
main()
当前代码执行结束模型训练完成,并且在当前界面进行验证了
3.2、predict
利用当前模块对微调后的模型直接使用
import os
import json
import torch
from PIL import Image
from torchvision import transforms
import matplotlib.pyplot as plt
from model_v2 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 = "../tulip.jpg"
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
model_weight_path = "./MobileNetV2.pth" #现阶段的参数都是直接训练好的参数
model.load_state_dict(torch.load(model_weight_path, map_location=device))
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()
3.3、model v2
模型整体架构
# 定义一个函数,以确保所有层通道数能被8整除
def _make_divisible(ch, divisor=8, min_ch=None):
"""
从原始TensorFlow仓库获取的函数。
确保所有层的通道数都能被8整除。
此函数可以在这里查看:
https://github.com/tensorflow/models/blob/master/research/slim/nets/mobilenet/mobilenet.py
"""
if min_ch is None:
min_ch = divisor
# 新通道数取决于输入通道,保证能被divisor整除,并且不低于min_ch
new_ch = max(min_ch, int(ch + divisor / 2) // divisor * divisor)
# 确保新通道数不会比原通道数小超过10%
if new_ch < 0.9 * ch:
new_ch += divisor
return new_ch
# 定义一个由卷积层、批量归一化和ReLU6激活函数组成的结构
class ConvBNReLU(nn.Sequential):
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)
)
# 定义反向残差块 ,就是定义了一个更大的模块,包含1*1的卷积核层和ConvBNReLU
class InvertedResidual(nn.Module):
def __init__(self, in_channel, out_channel, stride, expand_ratio):
super(InvertedResidual, self).__init__()
# 计算隐藏层通道数
hidden_channel = in_channel * expand_ratio
# 确定是否使用shortcut连接,即是否有残差路径
self.use_shortcut = stride == 1 and in_channel == out_channel
layers = []
# 如果扩展比不为1,增加一个1x1卷积用于扩展通道数
if expand_ratio != 1:
layers.append(ConvBNReLU(in_channel, hidden_channel, kernel_size=1))
# 添加一个3x3逐通道卷积和一个线性1x1卷积,用于调整通道数
layers.extend([
ConvBNReLU(hidden_channel, hidden_channel, stride=stride, groups=hidden_channel),
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_shortcut:
return x + self.conv(x)
else:
return self.conv(x)
# 定义MobileNetV2主体结构
class MobileNetV2(nn.Module):
def __init__(self, num_classes=1000, alpha=1.0, round_nearest=8):
super(MobileNetV2, self).__init__()
# 定义模型块
block = InvertedResidual
# 计算第一层和最后一层的通道数
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))
# 构建瓶颈残差块
for t, c, n, s in inverted_residual_setting:
output_channel = _make_divisible(c * alpha, round_nearest)
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.classifier = 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.classifier(x)
return x
四、总结
现阶段从理论意义到这个模型的实践操作都完成了,下一阶段准备对其他的轻量级模型进行讲解,如果存在些卷积神经网络的优质论文也会一并解决讲解,感兴趣的读者可以跟进下一期进步哦。