欢迎关注『youcans动手学模型』系列
本专栏内容和资源同步到 GitHub/youcans
【youcans动手学模型】ShuffleNet 模型
- 1. ShuffleNet 网络模型
- 1.1 模型简介
- 1.2 论文介绍
- 2. 在 PyTorch 中定义 ShuffleNet V1 模型类
- 2.1 分组卷积与通道混洗
- 2.2 ShuffleNet 单元
- 2.3 自定义 ShuffleNet V1 模型类
- 2.4 旷视版 ShuffleNet V1 模型类
- 3. 基于 ShuffleNetV1 模型的 CIFAR10 图像分类
- 3.1 PyTorch 建立神经网络模型的基本步骤
- 3.2 加载 CIFAR10 数据集
- 3.3 建立 ShuffleNetV1 网络模型
- 3.4 ShuffleNetV1 模型训练
- 3.5 ShuffleNetV1 模型的保存与加载
- 3.6 模型检验
- 3.7 模型推理
- 4. 基于 ShuffleNetV1 模型对 CIFAR10 进行图像分类的完整例程
本文用 PyTorch 实现 ShuffleNet 网络模型,使用 CIFAR10 数据集训练模型,进行图像分类。
1. ShuffleNet 网络模型
ShuffleNet 是旷视科技提出的一种计算高效的轻量化模型,论文发表于 CVPR2017。
Xiangyu Zhang, Xinyu Zhou, Jian Sun, ShuffleNet: An Extremely Efficient Convolutional Neural Network for Mobile Devices, 2017
通讯作者 孙剑,曾任微软亚洲研究院首席研究员、西安交通大学人工智能学院院长、旷视研究院(Megvii Research)院长,2022年因病去世。
【论文下载地址】
ShuffleNet: An Extremely Efficient Convolutional Neural Network for Mobile Devices
【GitHub地址】:https://github.com/megvii-model/ShuffleNet-Series
1.1 模型简介
ShuffleNet 的设计目标是如何利用有限的计算资源来达到最好的模型精度,实现速度与精度之间的平衡。
ShuffleNet 的主要创新是:(1)使用分组卷积(pointwise group convolution)来取代 1*1 的逐点卷积,(2)提出信道混洗(channel shuffle)来保证信息在不同信道之间的流动,在保持精度的同时显著降低了计算量。
1.2 论文介绍
【论文摘要】
本文提出了一种计算效率极高的 CNN 架构 ShuffleNet,它是专为计算能力有限的移动设备(10-150 MFLOP)设计的。
ShuffleNet 体系结构采用了两种新的操作,分组逐点卷积(pointwise group convolution)和信道混洗(channel shuffle),在保持精度的同时大大降低了计算成本。
在 ImageNet 分类任务和 MS COCO 目标检测任务的实验中,证明了 ShuffleNet 结构的优越性能。在 40 MFLOP的计算能力下,ImageNet 分类任务的 top-1 误差比 MobileNet 低 7.8%。在 ARM 移动设备上,ShuffleNet 的运行速度比 AlexNet 提高约13倍。
【论文背景】
构建更深更大的卷积神经网络是解决主要视觉识别任务的主要趋势。准确率最高的深度神经网络通常有数百层和数千个通道,因此需要 billion级 FLOPs 的计算量,这就限制了此类模型只能用于高性能的服务器集群。而在实际应用中,常常需要使用几十到几百 MFLOP 的计算能力来达到尽量高的精度,例如无人机、机器人和智能手机等移动平台。现有的很多工作侧重于对基准网络进行修剪、压缩或低位表示,本文的目标则是针对移动计算设备探索一种高效的基础架构。
目前的 Xception 使用了可分离卷积,ResNeXt 提出了一种基于分组卷积和残差连接的模块化卷积块 ,实现性能和计算代价的平衡。但是 1*1 卷积的计算量仍然很大,效率较低,例如在 ResNeXt 中,只有 3*3 层配置分组卷积(group convolutions),每个残差单元中 1*1 卷积的计算量占 93.4%。
我们提出使用逐点群组卷积来降低 1*1 卷积的计算复杂度。 然而,分组卷积只在组内进行卷积,组和组之间没有信息交互 。为了解决这个问题,我们提出了一种新的通道混洗操作,以促进信息在特征通道中流动。对于给定的计算性能,ShuffleNet 支持更多的通道数量,有利于获取更多的编码信息。
【主要创新】
ShuffleNet 的主要创新是:
-
(1)使用分组卷积(pointwise group convolution)来取代 1*1 的逐点卷积。
-
(2)提出信道混洗(channel shuffle)来保证信息在不同信道之间的流动。
分组卷积(Group Convolution)
ResNeXt 验证了分组卷积的有效性,而 MobileNet 利用深度可分离卷积(极限的分组卷积)获得了很好的效果。
对于输入特征图 H ∗ W ∗ C i n H*W*C_{in} H∗W∗Cin,分组卷积将其输入特征图分为 g 组,每组的通道数为 C i n / g C_{in}/g Cin/g,从而减少参数量和计算量。
通道混洗(Channel Shuffle)
分组卷积也有缺点,卷积层的每个通道只与上层分组中的几个通道相关,即每个通道的输出只来自一小部分的输入通道,不同组之间的特征图是相互隔离的,因而会降低信息的表达能力。
要解决这个问题,就要让信息在不同分组之间流动起来。这也是可分离卷积在逐通道的深度卷积之后,要使用逐点卷积的原因,目的是实现不同组之间的信息融合。使用 1*1 的逐点卷积实现不同组之间的信息融合,花费的计算量很大,效率很低。
我们提出了一种通道混洗方法来实现不同组之间的信息交流,允许分组卷积从不同的组中得到输入信息,输入和输出的各信道将会紧密相关。通道混洗方法并不进行卷积处理,只是对特征图的分组进行“重组”,从而极大地降低了计算量。
通道混洗的程序实现非常简单,只需要简单的维度操作和转置就可以实现均匀的混洗。
假定将输入层分为 g g g 组,总通道数为 C i n = g ∗ n C_{in}=g*n Cin=g∗n ,首先将通道维度拆分为 ( g , n ) (g, n) (g,n) 两个维度,然后将这两个维度转置变成 ( n , g ) (n,g) (n,g),最后重新 reshape 成一个维度。
一维 g ∗ n g*n g∗n → Reshape 为二维 ( g , n ) (g,n) (g,n) → Transpose 为 ( n , g ) (n,g) (n,g) → Flatten 为一维 ( n ∗ g ) (n*g) (n∗g)
利用通道混洗就可以充分发挥分组卷积的优点,而克服其缺点。 通道卷积也是可导的,这意味着它可以被嵌入到端到端训练(end to end training)的网络结构中。
【ShuffleNet 单元】
我们设计了小型的 ShuffleNet 单元,如下图所示。
图 (a) 中是一个残差模块,由两个 1*1 的逐点卷积和一个 3*3 的深度卷积 DW 组成,并使用残差连接。3*3 的深度卷积DW是瓶颈层(bottleneck)。
图 (b) 中是我们建立的 ShuffleNet 单元。对于图 (a) 中的残差模块,我们把两个 1*1 卷积替换成逐点分组卷积(GConv),并对第一个分组卷积输出信号进行信道混洗(Channel Shuffle)。
图 © 中是另一种 ShuffleNet 单元。我们使用了 stride=2 的 3*3 的深度卷积,这会使残差连接的特征图大小不同而无法相加,于是在支线上添加了 stride=2 的 3*3 均值池化,并用通道拼接(Concat)代替加法操作。这样处理的目的主要是降低计算量和参数量。
由于使用分组卷积和通道混洗,ShuffleNet 单元的计算复杂度更低。换句话说,对于给定的计算量限制,ShuffleNet 可以使用更多的特征通道数。
【模型结构】
ShuffleNet V1 网络模型的具体结构如下,其中 g 是分组数。
模型主要分为 3 个阶段,由 16个 ShuffleNet 单元堆叠而成。每个阶段特征图的空间尺寸减半,而通道数量倍增。
在 Stage2 阶段,由于输入通道数较少,因此没有使用分组卷积。
【模型性能】
ShuffleNet 模型性能如下表所示。
(1)采用通道混洗之后,模型的性能更好 ,证明了通道混洗的有效性。
(2)分组数量 g 越大,模型的性能越好。
(3)与轻量模型比较:与 MobileNet、SqueezeNet 模型相比,ShuffleNet 模型的计算复杂度更低,精度更好。
(4)与经典模型比较:与 VGG-16、GoogleNet、AlexNet 模型相比,在准确率相当的情况下 ShuffleNet 模型的计算复杂度更低。
2. 在 PyTorch 中定义 ShuffleNet V1 模型类
2.1 分组卷积与通道混洗
分组卷积与通道混洗是 ShuffleNet 的核心。
分组卷积(Group Convolution)可以直接在 PyTorch 的 nn.Conv2d 实现 ,通过参数 groups 设置。例程如下。
# 基本的卷积 BN-ReLU 层
class baseConv(nn.Module):
def __init__(self, ch_in, ch_out, kernel_size, stride, groups, hasRelu=False):
super(baseConv, self).__init__()
pad = kernel_size//2
self.baseconv = nn.Sequential(
nn.Conv2d(in_channels=ch_in, out_channels=ch_out, kernel_size=kernel_size,
stride=stride, padding=pad, groups=groups, bias=False),
nn.BatchNorm2d(ch_out),
activate()
)
def forward(self, x):
out = self.baseconv(x)
return out
通道混洗(Channel Shuffle)的例程如下。
# 通道混洗 (Channel Shuffle)
def ChannelShuffle(x, groups):
# Channel shuffle: [N,C,H,W] -> [N,g,C/g,H,W] -> [N,C/g,g,H,w] -> [N,C,H,W]
N, C, H, W = x.size()
g = groups
return x.view(N, g, C//g, H, W).permute(0, 2, 1, 3, 4).reshape(N, C, H, W)
2.2 ShuffleNet 单元
我们首先实现 ShuffleNet 中 stride=1 的基本单元 :
class ShuffleNetUnit1(nn.Module): # ShuffleNet unit for stride=1
def __init__(self, in_channels, out_channels, groups=3):
super(ShuffleNetUnit1, self).__init__()
assert in_channels == out_channels
assert out_channels % 4 == 0
mid_channels = out_channels//4
self.groups = groups
self.group_conv1 = nn.Conv2d(in_channels, mid_channels, kernel_size=1,
groups=groups, stride=1)
self.bn2 = nn.BatchNorm2d(mid_channels)
self.depthwise_conv3 = nn.Conv2d(mid_channels, mid_channels, kernel_size=3,
padding=1, stride=1, groups=mid_channels)
self.bn4 = nn.BatchNorm2d(mid_channels)
self.group_conv5 = nn.Conv2d(mid_channels, out_channels, kernel_size=1,
stride=1, groups=groups)
self.bn6 = nn.BatchNorm2d(out_channels)
def forward(self, x):
out = self.group_conv1(x)
out = F.relu(self.bn2(out))
out = ChannelShuffle(out, groups=self.groups)
out = self.depthwise_conv3(out)
out = self.bn4(out)
out = self.group_conv5(out)
out = self.bn6(out)
out = F.relu(x + out)
return out
然后实现 ShuffleNet 中 stride=2 的基本单元 :
class ShuffleNetUnit2(nn.Module): # ShuffleNet unit for stride=2
def __init__(self, in_channels, out_channels, groups=3):
super(ShuffleNetUnit2, self).__init__()
out_channels -= in_channels
assert out_channels % 4 == 0
mid_channels = out_channels//4
self.groups = groups
self.group_conv1 = nn.Conv2d(in_channels, mid_channels, kernel_size=1,
groups=groups, stride=1)
self.bn2 = nn.BatchNorm2d(mid_channels)
self.depthwise_conv3 = nn.Conv2d(mid_channels, mid_channels, kernel_size=3,
padding=1, stride=2, groups=mid_channels)
self.bn4 = nn.BatchNorm2d(mid_channels)
self.group_conv5 = nn.Conv2d(mid_channels, out_channels, kernel_size=1,
groups=groups, stride=1)
self.bn6 = nn.BatchNorm2d(out_channels)
def forward(self, x):
out = self.group_conv1(x)
out = F.relu(self.bn2(out))
out = ChannelShuffle(out, groups=self.groups)
out = self.depthwise_conv3(out)
out = self.bn4(out)
out = self.group_conv5(out)
out = self.bn6(out)
x = F.avg_pool2d(x, 3, stride=2, padding=1)
out = F.relu(torch.cat([x, out], dim=1))
return out
2.3 自定义 ShuffleNet V1 模型类
最后定义 g = 3 g=3 g=3 的 ShuffleNet V1 模型类如下:
class ShuffleNetV1(nn.Module): # ShuffleNet for groups=3
def __init__(self, groups=3, in_channels=3, num_classes=100):
super(ShuffleNetV1, self).__init__()
self.conv1 = nn.Conv2d(in_channels, 24, 3, stride=2, padding=1)
stage2_seq = [ShuffleNetUnit2(24, 240, groups=3)] + \
[ShuffleNetUnit1(240, 240, groups=groups) for i in range(3)]
self.stage2 = nn.Sequential(*stage2_seq)
stage3_seq = [ShuffleNetUnit2(240, 480, groups=3)] + \
[ShuffleNetUnit1(480, 480, groups=groups) for i in range(7)]
self.stage3 = nn.Sequential(*stage3_seq)
stage4_seq = [ShuffleNetUnit2(480, 960, groups=groups)] + \
[ShuffleNetUnit1(960, 960, groups=groups) for i in range(3)]
self.stage4 = nn.Sequential(*stage4_seq)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(960, num_classes)
def forward(self, x):
x = self.conv1(x)
x = F.max_pool2d(x, 3, stride=2, padding=1)
x = self.stage2(x)
x = self.stage3(x)
x = self.stage4(x)
x = self.avgpool(x)
x = x.view(x.size(0), -1)
x = self.fc(x)
out = F.softmax(x, dim=1)
return out
2.4 旷视版 ShuffleNet V1 模型类
旷视开源了 ShuffleNet Series 模型,涵盖了 ShuffleNet V1、V2、V2+ 等 6个模型,详见:https://github.com/megvii-model/ShuffleNet-Series。
# 定义 ShuffleV1Block
class ShuffleV1Block(nn.Module):
def __init__(self, inp, oup, *, group, first_group, mid_channels, ksize, stride):
super(ShuffleV1Block, self).__init__()
self.stride = stride
assert stride in [1, 2]
self.mid_channels = mid_channels
self.ksize = ksize
pad = ksize // 2
self.pad = pad
self.inp = inp
self.group = group
if stride == 2:
outputs = oup - inp
else:
outputs = oup
branch_main_1 = [
# pw
nn.Conv2d(inp, mid_channels, 1, 1, 0, groups=1 if first_group else group, bias=False),
nn.BatchNorm2d(mid_channels),
nn.ReLU(inplace=True),
# dw
nn.Conv2d(mid_channels, mid_channels, ksize, stride, pad, groups=mid_channels, bias=False),
nn.BatchNorm2d(mid_channels),
]
branch_main_2 = [
# pw-linear
nn.Conv2d(mid_channels, outputs, 1, 1, 0, groups=group, bias=False),
nn.BatchNorm2d(outputs),
]
self.branch_main_1 = nn.Sequential(*branch_main_1)
self.branch_main_2 = nn.Sequential(*branch_main_2)
if stride == 2:
self.branch_proj = nn.AvgPool2d(kernel_size=3, stride=2, padding=1)
def forward(self, old_x):
x = old_x
x_proj = old_x
x = self.branch_main_1(x)
if self.group > 1:
x = self.channel_shuffle(x)
x = self.branch_main_2(x)
if self.stride == 1:
return F.relu(x + x_proj)
elif self.stride == 2:
return torch.cat((self.branch_proj(x_proj), F.relu(x)), 1)
def channel_shuffle(self, x):
batchsize, num_channels, height, width = x.data.size()
assert num_channels % self.group == 0
group_channels = num_channels // self.group
x = x.reshape(batchsize, group_channels, self.group, height, width)
x = x.permute(0, 2, 1, 3, 4)
x = x.reshape(batchsize, num_channels, height, width)
return x
# 定义 ShuffleNetV1_Megvii
class ShuffleNetV1_Megvii(nn.Module):
def __init__(self, input_size=224, n_class=1000, model_size='2.0x', group=None):
super(ShuffleNetV1_Megvii, self).__init__()
print('model size is ', model_size)
assert group is not None
self.stage_repeats = [4, 8, 4]
self.model_size = model_size
if group == 3:
if model_size == '0.5x':
self.stage_out_channels = [-1, 12, 120, 240, 480]
elif model_size == '1.0x':
self.stage_out_channels = [-1, 24, 240, 480, 960]
elif model_size == '1.5x':
self.stage_out_channels = [-1, 24, 360, 720, 1440]
elif model_size == '2.0x':
self.stage_out_channels = [-1, 48, 480, 960, 1920]
else:
raise NotImplementedError
elif group == 8:
if model_size == '0.5x':
self.stage_out_channels = [-1, 16, 192, 384, 768]
elif model_size == '1.0x':
self.stage_out_channels = [-1, 24, 384, 768, 1536]
elif model_size == '1.5x':
self.stage_out_channels = [-1, 24, 576, 1152, 2304]
elif model_size == '2.0x':
self.stage_out_channels = [-1, 48, 768, 1536, 3072]
else:
raise NotImplementedError
# building first layer
input_channel = self.stage_out_channels[1]
self.first_conv = nn.Sequential(
nn.Conv2d(3, input_channel, 3, 2, 1, bias=False),
nn.BatchNorm2d(input_channel),
nn.ReLU(inplace=True),
)
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
self.features = []
for idxstage in range(len(self.stage_repeats)):
numrepeat = self.stage_repeats[idxstage]
output_channel = self.stage_out_channels[idxstage+2]
for i in range(numrepeat):
stride = 2 if i == 0 else 1
first_group = idxstage == 0 and i == 0
self.features.append(ShuffleV1Block(input_channel, output_channel,
group=group, first_group=first_group,
mid_channels=output_channel // 4, ksize=3, stride=stride))
input_channel = output_channel
self.features = nn.Sequential(*self.features)
self.globalpool = nn.AvgPool2d(7)
self.classifier = nn.Sequential(nn.Linear(self.stage_out_channels[-1], n_class, bias=False))
self._initialize_weights()
def forward(self, x):
x = self.first_conv(x)
x = self.maxpool(x)
x = self.features(x)
x = self.globalpool(x)
x = x.contiguous().view(-1, self.stage_out_channels[-1])
x = self.classifier(x)
return x
def _initialize_weights(self):
for name, m in self.named_modules():
if isinstance(m, nn.Conv2d):
if 'first' in name:
nn.init.normal_(m.weight, 0, 0.01)
else:
nn.init.normal_(m.weight, 0, 1.0 / m.weight.shape[1])
if m.bias is not None:
nn.init.constant_(m.bias, 0)
elif isinstance(m, nn.BatchNorm2d):
nn.init.constant_(m.weight, 1)
if m.bias is not None:
nn.init.constant_(m.bias, 0.0001)
nn.init.constant_(m.running_mean, 0)
elif isinstance(m, nn.BatchNorm1d):
nn.init.constant_(m.weight, 1)
if m.bias is not None:
nn.init.constant_(m.bias, 0.0001)
nn.init.constant_(m.running_mean, 0)
elif isinstance(m, nn.Linear):
nn.init.normal_(m.weight, 0, 0.01)
if m.bias is not None:
nn.init.constant_(m.bias, 0)
例程 Begin_Shuffle_CIFAR_2.py 使用 ShuffleNet V1 模型(旷世开源版本)进行模型训练和推理,经过 20 轮左右的训练,使用验证集图片进行验证,模型准确率达到 85%。继续训练到50轮次,可以进一步降低训练损失函数值,验证集的准确率保持在 90% 左右。
3. 基于 ShuffleNetV1 模型的 CIFAR10 图像分类
3.1 PyTorch 建立神经网络模型的基本步骤
使用 PyTorch 建立、训练和使用神经网络模型的基本步骤如下。
- 准备数据集(Prepare dataset):加载数据集,对数据进行预处理。
- 建立模型(Design the model):实例化模型类,定义损失函数和优化器,确定模型结构和训练方法。
- 模型训练(Model trainning):使用训练数据集对模型进行训练,确定模型参数。
- 模型推理(Model inferring):使用训练好的模型进行推理,对输入数据预测输出结果。
- 模型保存与加载(Model saving/loading):保存训练好的模型,以便以后使用或部署。
以下按此步骤讲解 ShuffleNetV1 模型的例程。
3.2 加载 CIFAR10 数据集
通用数据集的样本结构均衡、信息高效,而且组织规范、易于处理。使用通用的数据集训练神经网络,不仅可以提高工作效率,而且便于评估模型性能。
PyTorch 提供了一些常用的图像数据集,预加载在 torchvision.datasets
类中。torchvision
模块实现神经网络所需的核心类和方法, torchvision.datasets
包含流行的数据集、模型架构和常用的图像转换方法。
CIFAR 数据集是一个经典的图像分类小型数据集,有 CIFAR10 和 CIFAR100 两个版本。CIFAR10 有 10 个类别,CIFAR100 有 100 个类别。CIFAR10 每张图像大小为 32*32,包括飞机、小汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车 10 个类别。CIFAR10 共有 60000 张图像,其中训练集 50000张,测试集 10000张。每个类别有 6000张图片,数据集平衡。
加载和使用 CIFAR 数据集的方法为:
torchvision.datasets.CIFAR10()
torchvision.datasets.CIFAR100()
CIFAR 数据集可以从官网下载:http://www.cs.toronto.edu/~kriz/cifar.html 后使用,也可以使用 datasets 类自动加载(如果本地路径没有该文件则自动下载)。
下载数据集时,使用预定义的 transform 方法进行数据预处理,包括调整图像尺寸、标准化处理,将数据格式转换为张量。标准化处理所使用 CIFAR10 数据集的均值和方差为 (0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)。transform_train在训练过程中,增加随机性,提高泛化能力。
大型训练数据集不能一次性加载全部样本来训练,可以使用 Dataloader 类自动加载数据。Dataloader 是一个迭代器,基本功能是传入一个 Dataset 对象,根据参数 batch_size 生成一个 batch 的数据。
需要说明的是,虽然 ShuffleNetV1 模型可以使用 32*32 的图片,但例程中将图像大小调整为 (w,h)=(224,224),可以获得更好的性能。
使用 DataLoader 类加载 CIFAR-10 数据集的例程如下。
# (1) 将 [0,1] 的 PILImage 转换为[-1,1]的Tensor
transform_train = transforms.Compose([
transforms.RandomHorizontalFlip(), # 随机水平翻转
transforms.RandomRotation(10), # 随机旋转
transforms.RandomAffine(0, shear=10, scale=(0.9, 1.1)),
transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
transforms.Resize(224), # 图像大小调整为 (w,h)=(224,224)
transforms.ToTensor(), # 将图像转换为张量 Tensor
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616))])
# 测试集不需要进行数据增强
transform = transforms.Compose([
transforms.Resize(224), # 图像大小调整为 (w,h)=(224,224)
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616))])
# (2) 加载 CIFAR10 数据集
batchsize = 128
# 加载 CIFAR10 数据集, 如果 root 路径加载失败, 则自动在线下载
# 加载 CIFAR10 训练数据集, 50000张训练图片
train_set = torchvision.datasets.CIFAR10(root='../dataset', train=True,
download=True, transform=transform_train)
# train_loader = torch.utils.data.DataLoader(train_set, batch_size=batchsize)
train_loader = torch.utils.data.DataLoader(train_set, batch_size=batchsize,
shuffle=True, num_workers=8)
# 加载 CIFAR10 验证数据集, 10000张验证图片
test_set = torchvision.datasets.CIFAR10(root='../dataset', train=False,
download=True, transform=transform)
test_loader = torch.utils.data.DataLoader(test_set, batch_size=256,
shuffle=True, num_workers=8)
# 创建生成器,用 next 获取一个批次的数据
valid_data_iter = iter(test_loader) # _SingleProcessDataLoaderIter 对象
valid_images, valid_labels = next(valid_data_iter) # images: [batch,3,32,32], labels: [batch]
valid_size = valid_labels.size(0) # 验证数据集大小,batch
print(valid_images.shape, valid_labels.shape)
# 定义类别名称,CIFAR10 数据集的 10个类别
classes = ('plane', 'car', 'bird', 'cat', 'deer',
'dog', 'frog', 'horse', 'ship', 'truck')
3.3 建立 ShuffleNetV1 网络模型
建立一个 ShuffleNetV1 网络模型进行训练,包括三个步骤:
- 实例化 ShuffleNetV1 模型对象;
- 设置训练的损失函数;
- 设置训练的优化器。
torch.nn.functional 模块提供了各种内置损失函数,本例使用交叉熵损失函数 CrossEntropyLoss。
torch.optim 模块提供了各种优化方法,本例使用 Adam 优化器。注意要将 model 的参数 model.parameters() 传给优化器对象,以便优化器扫描需要优化的参数。
# (3) 构造 ShuffleNet 网络模型/旷世开源版
model = ShuffleNetV1_Megvii(input_size=224, n_class=10, model_size='0.5x', group=3)
model.to(device) # 将网络分配到指定的device中
print(model)
# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss() # 定义损失函数 CrossEntropy
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) # 定义优化器 SGD
3.4 ShuffleNetV1 模型训练
PyTorch 模型训练的基本步骤是:
- 前馈计算模型的输出值;
- 计算损失函数值;
- 计算权重 weight 和偏差 bias 的梯度;
- 根据梯度值调整模型参数;
- 将梯度重置为 0(用于下一循环)。
在模型训练过程中,可以使用验证集数据评价训练过程中的模型精度,以便控制训练过程。模型验证就是用验证数据进行模型推理,前向计算得到模型输出,但不反向计算模型误差,因此需要设置 torch.no_grad()。
使用 PyTorch 进行模型训练的例程如下。
# (4) 训练 ShuffleNet 模型
epoch_list = [] # 记录训练轮次
loss_list = [] # 记录训练集的损失值
accu_list = [] # 记录验证集的准确率
num_epochs = 50 # 训练轮次
for epoch in range(num_epochs): # 训练轮次 epoch
running_loss = 0.0 # 每个轮次的累加损失值清零
for step, data in enumerate(train_loader, start=0): # 迭代器加载数据
optimizer.zero_grad() # 损失梯度清零
inputs, labels = data # inputs: [batch,3,224,224] labels: [batch]
outputs = model(inputs.to(device)) # 正向传播
loss = criterion(outputs, labels.to(device)) # 计算损失函数
loss.backward() # 反向传播
optimizer.step() # 参数更新
# 累加训练损失值
running_loss += loss.item()
# if step%100==99: # 每 100 个 step 打印一次训练信息
# print("\t epoch {}, step {}: loss = {:.4f}".format(epoch, step, loss.item()))
# 计算每个轮次的验证集准确率
with torch.no_grad(): # 验证过程, 不计算损失函数梯度
outputs_valid = model(valid_images.to(device)) # 模型对验证集进行推理, [batch, 10]
pred_labels = torch.max(outputs_valid, dim=1)[1] # 预测类别, [batch]
accuracy = torch.eq(pred_labels, valid_labels.to(device)).sum().item() / valid_size * 100 # 计算准确率
print("Epoch {}: train loss={:.4f}, accuracy={:.2f}%".format(epoch, running_loss, accuracy))
# 记录训练过程的统计数据
epoch_list.append(epoch) # 记录迭代次数
loss_list.append(running_loss) # 记录训练集的损失函数
accu_list.append(accuracy) # 记录验证集的准确率
程序运行结果如下:
Epoch 0: train loss=656.7643, accuracy=59.77%
Epoch 1: train loss=483.2695, accuracy=68.36%
Epoch 2: train loss=403.0473, accuracy=70.31%
…
Epoch 48: train loss=129.2714, accuracy=89.45%
Epoch 49: train loss=126.6563, accuracy=90.62%
使用 ShuffleNet V1 模型(旷世开源版本)进行模型训练和推理,经过 20 轮的训练,使用验证集图片进行验证,模型准确率达到 85%。继续训练到 50轮次,训练损失函数值进一步降低,验证集的准确率保持在 90% 左右。
3.5 ShuffleNetV1 模型的保存与加载
模型训练好以后,将模型保存起来,以便下次使用。PyTorch 中模型保存主要有两种方式,一是保存模型权值,二是保存整个模型。本例使用 model.state_dict() 方法以字典形式返回模型权值,torch.save() 方法将权值字典序列化到磁盘,将模型保存为 .pth 文件。
# (5) 保存 ShuffleNet 网络模型
save_path = "../models/ShuffleNet_Cifar2"
model_cpu = model.cpu() # 将模型移动到 CPU
model_path = save_path + ".pth" # 模型文件路径
torch.save(model.state_dict(), model_path) # 保存模型权值
# 优化结果写入数据文件
result_path = save_path + ".csv" # 优化结果文件路径
WriteDataFile(epoch_list, loss_list, accu_list, result_path)
使用训练好的模型,首先要实例化模型类,然后调用 load_state_dict() 方法加载模型的权值参数。
# 以下模型加载和模型推理,可以是另一个独立的程序
# (6) 加载 ShuffleNet 网络模型进行推理
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 检测并指定设备
# 加载 Squeeze 预训练模型
model = ShuffleNetV1_Megvii(input_size=224, n_class=10, model_size='0.5x', group=3)
model.to(device) # 将网络分配到指定的device中
model_path = "../models/ShuffleNet_Cifar2.pth"
model.load_state_dict(torch.load(model_path))
model.eval() # 模型推理模式
需要特别注意的是:
(1)PyTorch 中的 .pth 文件只保存了模型的权值参数,而没有模型的结构信息,因此必须先实例化模型对象,再加载模型参数。
(2)模型对象必须与模型参数严格对应,才能正常使用。注意即使都是 ShuffleNetV1 模型,模型类的具体定义也可能有细微的区别。如果从一个来源获取模型类的定义,从另一个来源获取模型参数文件,就很容易造成模型结构与参数不能匹配。
(3)无论从 PyTorch 模型仓库加载的模型和参数,或从其它来源获取的预训练模型,或自己训练得到的模型,模型加载的方法都是相同的,也都要注意模型结构与参数的匹配问题。
3.6 模型检验
使用加载的 ShuffleNetV1 模型,输入新的图片进行模型推理,可以由模型输出结果确定输入图片所属的类别。
使用测试集数据进行模型推理,根据模型预测结果与图片标签进行比较,可以检验模型的准确率。模型验证集与模型检验集不能交叉使用,但为了简化例程在本程序中未做区分。
# (7) 模型检测
correct = 0
total = 0
for data in test_loader: # 迭代器加载测试数据集
imgs, labels = data # torch.Size([batch,3,224,224) torch.Size([batch])
# print(imgs.shape, labels.shape)
outputs = model(imgs.to(device)) # 正向传播, 模型推理, [batch, 10]
labels_pred = torch.max(outputs, dim=1)[1] # 模型预测的类别 [batch]
# _, labels_pred = torch.max(outputs.data, 1)
total += labels.size(0)
correct += torch.eq(labels_pred, labels.to(device)).sum().item()
accuracy = 100. * correct / total
print("Test samples: {}".format(total))
print("Test accuracy={:.2f}%".format(accuracy))
使用测试集进行模型推理,测试模型准确率为 87.04%。
Test samples: 10000
Test accuracy=87.04%
3.7 模型推理
使用加载的 ShuffleNetV1 模型,输入新的图片进行模型推理,可以由模型输出结果确定输入图片所属的类别。
从测试集中提取几张图片,或者读取图像文件,进行模型推理,获得图片的分类类别。在提取图片或读取文件时,要注意对图片格式和图片大小进行适当的转换。
# (8) 提取测试集图片进行模型推理
batch = 8 # 批次大小
data_set = torchvision.datasets.CIFAR10(root='../dataset', train=False,
download=False, transform=None)
plt.figure(figsize=(9, 6))
for i in range(batch):
imgPIL = data_set[i][0] # 提取 PIL 图片
label = data_set[i][1] # 提取 图片标签
# 预处理/模型推理/后处理
imgTrans = transform(imgPIL) # 预处理变换, torch.Size([3,224,224])
imgBatch = torch.unsqueeze(imgTrans, 0) # 转为批处理,torch.Size([batch=1,3,224,224])
outputs = model(imgBatch.to(device)) # 模型推理, 返回 [batch=1, 10]
indexes = torch.max(outputs, dim=1)[1] # 注意 [batch=1], device = 'device
index = indexes[0].item() # 预测类别,整数
# 绘制第 i 张图片
imgNP = np.array(imgPIL) # PIL -> Numpy
out_text = "label:{}/model:{}".format(classes[label], classes[index])
plt.subplot(2, 4, i+1)
plt.imshow(imgNP)
plt.title(out_text)
plt.axis('off')
plt.tight_layout()
plt.show()
结果如下。
# (9) 读取图像文件进行模型推理
from PIL import Image
filePath = "../images/img_car_01.jpg" # 数据文件的地址和文件名
imgPIL = Image.open(filePath) # PIL 读取图像文件, <class 'PIL.Image.Image'>
# 预处理/模型推理/后处理
imgTrans = transform(imgPIL) # 预处理变换, torch.Size([3,224,224])
imgBatch = torch.unsqueeze(imgTrans, 0) # 转为批处理,torch.Size([batch=1,3,224,224])
outputs = model(imgBatch.to(device)) # 模型推理, 返回 [batch=1, 10]
indexes = torch.max(outputs, dim=1)[1] # 注意 [batch=1], device = 'device
percentages = nn.functional.softmax(outputs, dim=1)[0] * 100
index = indexes[0].item() # 预测类别,整数
percent = percentages[index].item() # 预测类别的概率,浮点数
# 绘制第 i 张图片
imgNP = np.array(imgPIL) # PIL -> Numpy
out_text = "Prediction:{}, {}, {:.2f}%".format(index, classes[index], percent)
print(out_text)
plt.imshow(imgNP)
plt.title(out_text)
plt.axis('off')
plt.tight_layout()
plt.show()
结果如下。
4. 基于 ShuffleNetV1 模型对 CIFAR10 进行图像分类的完整例程
本文的完整例程如下。
# Begin_Shuffle_CIFAR_1.py
# Shuffle model for beginner with PyTorch
# 经典模型: ShuffleNet V1 模型 CIFAR10 图像分类
# Copyright: youcans@qq.com
# Crated: Huang Shan, 2023/06/04
# _*_coding:utf-8_*_
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import torchvision
import torchvision.transforms as transforms
from matplotlib import pyplot as plt
import numpy as np
# https://iq.opengenus.org/shufflenet-implementation-using-pytorch/
# 通道重排
def ChannelShuffle(x, groups):
# Channel shuffle: [N,C,H,W] -> [N,g,C/g,H,W] -> [N,C/g,g,H,w] -> [N,C,H,W]
N, C, H, W = x.size()
g = groups
return x.view(N, g, C//g, H, W).permute(0, 2, 1, 3, 4).reshape(N, C, H, W)
class ShuffleNetUnit1(nn.Module): # ShuffleNet unit for stride=1
def __init__(self, in_channels, out_channels, groups=3):
super(ShuffleNetUnit1, self).__init__()
assert in_channels == out_channels
assert out_channels % 4 == 0
mid_channels = out_channels//4
self.groups = groups
self.group_conv1 = nn.Conv2d(in_channels, mid_channels, kernel_size=1,
groups=groups, stride=1)
self.bn2 = nn.BatchNorm2d(mid_channels)
self.depthwise_conv3 = nn.Conv2d(mid_channels, mid_channels, kernel_size=3,
padding=1, stride=1, groups=mid_channels)
self.bn4 = nn.BatchNorm2d(mid_channels)
self.group_conv5 = nn.Conv2d(mid_channels, out_channels, kernel_size=1,
stride=1, groups=groups)
self.bn6 = nn.BatchNorm2d(out_channels)
def forward(self, x):
out = self.group_conv1(x)
out = F.relu(self.bn2(out))
out = ChannelShuffle(out, groups=self.groups)
out = self.depthwise_conv3(out)
out = self.bn4(out)
out = self.group_conv5(out)
out = self.bn6(out)
out = F.relu(x + out)
return out
class ShuffleNetUnit2(nn.Module): # ShuffleNet unit for stride=2
def __init__(self, in_channels, out_channels, groups=3):
super(ShuffleNetUnit2, self).__init__()
out_channels -= in_channels
assert out_channels % 4 == 0
mid_channels = out_channels//4
self.groups = groups
self.group_conv1 = nn.Conv2d(in_channels, mid_channels, kernel_size=1,
groups=groups, stride=1)
self.bn2 = nn.BatchNorm2d(mid_channels)
self.depthwise_conv3 = nn.Conv2d(mid_channels, mid_channels, kernel_size=3,
padding=1, stride=2, groups=mid_channels)
self.bn4 = nn.BatchNorm2d(mid_channels)
self.group_conv5 = nn.Conv2d(mid_channels, out_channels, kernel_size=1,
groups=groups, stride=1)
self.bn6 = nn.BatchNorm2d(out_channels)
def forward(self, x):
out = self.group_conv1(x)
out = F.relu(self.bn2(out))
out = ChannelShuffle(out, groups=self.groups)
out = self.depthwise_conv3(out)
out = self.bn4(out)
out = self.group_conv5(out)
out = self.bn6(out)
x = F.avg_pool2d(x, 3, stride=2, padding=1)
out = F.relu(torch.cat([x, out], dim=1))
return out
class ShuffleNetV1(nn.Module): # ShuffleNet for groups=3
def __init__(self, groups=3, in_channels=3, num_classes=100):
super(ShuffleNetV1, self).__init__()
self.conv1 = nn.Conv2d(in_channels, 24, 3, stride=2, padding=1)
stage2_seq = [ShuffleNetUnit2(24, 240, groups=3)] + \
[ShuffleNetUnit1(240, 240, groups=groups) for i in range(3)]
self.stage2 = nn.Sequential(*stage2_seq)
stage3_seq = [ShuffleNetUnit2(240, 480, groups=3)] + \
[ShuffleNetUnit1(480, 480, groups=groups) for i in range(7)]
self.stage3 = nn.Sequential(*stage3_seq)
stage4_seq = [ShuffleNetUnit2(480, 960, groups=groups)] + \
[ShuffleNetUnit1(960, 960, groups=groups) for i in range(3)]
self.stage4 = nn.Sequential(*stage4_seq)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(960, num_classes)
def forward(self, x):
x = self.conv1(x)
x = F.max_pool2d(x, 3, stride=2, padding=1)
x = self.stage2(x)
x = self.stage3(x)
x = self.stage4(x)
x = self.avgpool(x)
x = x.view(x.size(0), -1)
x = self.fc(x)
out = F.softmax(x, dim=1)
return out
# 优化结果写入数据文件
import pandas as pd
def WriteDataFile(epoch_list, loss_list, accu_list, filepath):
# print("def WriteDataFile()")
optRecord = {
"epoch": epoch_list,
"train_loss": loss_list,
"accuracy": accu_list}
dfRecord = pd.DataFrame(optRecord)
dfRecord.to_csv(filepath, index=False, encoding="utf_8_sig")
print("写入数据文件: %s 完成。" % filepath)
return
if __name__ == '__main__':
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)
# (1) 将 [0,1] 的 PILImage 转换为[-1,1]的Tensor
transform_train = transforms.Compose([
transforms.RandomHorizontalFlip(), # 随机水平翻转
transforms.RandomRotation(10), # 随机旋转
transforms.RandomAffine(0, shear=10, scale=(0.9, 1.1)),
transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
transforms.Resize(224), # 图像大小调整为 (w,h)=(224,224)
transforms.ToTensor(), # 将图像转换为张量 Tensor
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616))])
# 测试集不需要进行数据增强
transform = transforms.Compose([
transforms.Resize(224), # 图像大小调整为 (w,h)=(224,224)
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616))])
# (2) 加载 CIFAR10 数据集
batchsize = 128
# 加载 CIFAR10 数据集, 如果 root 路径加载失败, 则自动在线下载
# 加载 CIFAR10 训练数据集, 50000张训练图片
train_set = torchvision.datasets.CIFAR10(root='../dataset', train=True,
download=True, transform=transform_train)
train_loader = torch.utils.data.DataLoader(train_set, batch_size=batchsize,
shuffle=True, num_workers=4)
# 加载 CIFAR10 验证数据集, 10000张验证图片
test_set = torchvision.datasets.CIFAR10(root='../dataset', train=False,
download=True, transform=transform)
test_loader = torch.utils.data.DataLoader(test_set, batch_size=128,
shuffle=True, num_workers=4)
# 创建生成器,用 next 获取一个批次的数据
valid_data_iter = iter(test_loader) # _SingleProcessDataLoaderIter 对象
valid_images, valid_labels = next(valid_data_iter) # images: [batch,3,224,224], labels: [batch]
valid_size = valid_labels.size(0) # 验证数据集大小,batch
print(valid_images.shape, valid_labels.shape)
# 定义类别名称,CIFAR10 数据集的 10个类别
classes = ('plane', 'car', 'bird', 'cat', 'deer',
'dog', 'frog', 'horse', 'ship', 'truck')
# (3) 构造 ShuffleNet 网络模型
model = ShuffleNetV1(groups=3, in_channels=3, num_classes=10) # 实例化 ShuffleNet 网络模型
model.to(device) # 将网络分配到指定的device中
print(model)
# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss() # 定义损失函数 CrossEntropy
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) # 定义优化器 SGD
# (4) 训练 ShuffleNet 模型
epoch_list = [] # 记录训练轮次
loss_list = [] # 记录训练集的损失值
accu_list = [] # 记录验证集的准确率
num_epochs = 50 # 训练轮次
for epoch in range(num_epochs): # 训练轮次 epoch
running_loss = 0.0 # 每个轮次的累加损失值清零
for step, data in enumerate(train_loader, start=0): # 迭代器加载数据
optimizer.zero_grad() # 损失梯度清零
inputs, labels = data # inputs: [batch,3,224,224] labels: [batch]
outputs = model(inputs.to(device)) # 正向传播
loss = criterion(outputs, labels.to(device)) # 计算损失函数
loss.backward() # 反向传播
optimizer.step() # 参数更新
# 累加训练损失值
running_loss += loss.item()
# if step%100==99: # 每 100 个 step 打印一次训练信息
# print("\t epoch {}, step {}: loss = {:.4f}".format(epoch, step, loss.item()))
# 计算每个轮次的验证集准确率
with torch.no_grad(): # 验证过程, 不计算损失函数梯度
outputs_valid = model(valid_images.to(device)) # 模型对验证集进行推理, [batch, 10]
pred_labels = torch.max(outputs_valid, dim=1)[1] # 预测类别, [batch]
accuracy = torch.eq(pred_labels, valid_labels.to(device)).sum().item() / valid_size * 100 # 计算准确率
print("Epoch {}: train loss={:.4f}, accuracy={:.2f}%".format(epoch, running_loss, accuracy))
# 记录训练过程的统计数据
epoch_list.append(epoch) # 记录迭代次数
loss_list.append(running_loss) # 记录训练集的损失函数
accu_list.append(accuracy) # 记录验证集的准确率
# (5) 保存 ShuffleNet 网络模型
save_path = "../models/ShuffleNet_Cifar1"
model_cpu = model.cpu() # 将模型移动到 CPU
model_path = save_path + ".pth" # 模型文件路径
torch.save(model.state_dict(), model_path) # 保存模型权值
# 优化结果写入数据文件
result_path = save_path + ".csv" # 优化结果文件路径
WriteDataFile(epoch_list, loss_list, accu_list, result_path)
# 训练结果可视化
plt.figure(figsize=(11, 5))
plt.suptitle("ShuffleNet Model in CIFAR10")
plt.subplot(121), plt.title("Train loss")
plt.plot(epoch_list, loss_list)
plt.xlabel('epoch'), plt.ylabel('loss')
plt.subplot(122), plt.title("Valid accuracy")
plt.plot(epoch_list, accu_list)
plt.xlabel('epoch'), plt.ylabel('accuracy')
plt.show()
# # 以下模型加载和模型推理,可以是另一个独立的程序
# # (6) 加载 ShuffleNet 网络模型进行推理
# device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 检测并指定设备
# # 加载 Squeeze 预训练模型
# model = ShuffleNetV1(num_classes=10) # 实例化 ShuffleNet 网络模型
# model.to(device) # 将网络分配到指定的device中
# model_path = "../models/ShuffleNet_Cifar1.pth"
# model.load_state_dict(torch.load(model_path))
# model.eval() # 模型推理模式
#
# # (7) 模型检测
# correct = 0
# total = 0
# for data in test_loader: # 迭代器加载测试数据集
# imgs, labels = data # torch.Size([batch,3,224,224) torch.Size([batch])
# # print(imgs.shape, labels.shape)
# outputs = model(imgs.to(device)) # 正向传播, 模型推理, [batch, 10]
# labels_pred = torch.max(outputs, dim=1)[1] # 模型预测的类别 [batch]
# # _, labels_pred = torch.max(outputs.data, 1)
# total += labels.size(0)
# correct += torch.eq(labels_pred, labels.to(device)).sum().item()
# accuracy = 100. * correct / total
# print("Test samples: {}".format(total))
# print("Test accuracy={:.2f}%".format(accuracy))
#
# # (8) 提取测试集图片进行模型推理
# batch = 8 # 批次大小
# data_set = torchvision.datasets.CIFAR10(root='../dataset', train=False,
# download=False, transform=None)
# plt.figure(figsize=(9, 6))
# for i in range(batch):
# imgPIL = data_set[i][0] # 提取 PIL 图片
# label = data_set[i][1] # 提取 图片标签
# # 预处理/模型推理/后处理
# imgTrans = transform(imgPIL) # 预处理变换, torch.Size([3,224,224])
# imgBatch = torch.unsqueeze(imgTrans, 0) # 转为批处理,torch.Size([batch=1,3,224,224])
# outputs = model(imgBatch.to(device)) # 模型推理, 返回 [batch=1, 10]
# indexes = torch.max(outputs, dim=1)[1] # 注意 [batch=1], device = 'device
# index = indexes[0].item() # 预测类别,整数
# # 绘制第 i 张图片
# imgNP = np.array(imgPIL) # PIL -> Numpy
# out_text = "label:{}/model:{}".format(classes[label], classes[index])
# plt.subplot(2, 4, i+1)
# plt.imshow(imgNP)
# plt.title(out_text)
# plt.axis('off')
# plt.tight_layout()
# plt.show()
#
# # (9) 读取图像文件进行模型推理
# from PIL import Image
# filePath = "../images/img_car_01.jpg" # 数据文件的地址和文件名
# imgPIL = Image.open(filePath) # PIL 读取图像文件, <class 'PIL.Image.Image'>
#
# # 预处理/模型推理/后处理
# imgTrans = transform(imgPIL) # 预处理变换, torch.Size([3,224,224])
# imgBatch = torch.unsqueeze(imgTrans, 0) # 转为批处理,torch.Size([batch=1,3,224,224])
# outputs = model(imgBatch.to(device)) # 模型推理, 返回 [batch=1, 10]
# indexes = torch.max(outputs, dim=1)[1] # 注意 [batch=1], device = 'device
# percentages = nn.functional.softmax(outputs, dim=1)[0] * 100
# index = indexes[0].item() # 预测类别,整数
# percent = percentages[index].item() # 预测类别的概率,浮点数
#
#
# # 绘制第 i 张图片
# imgNP = np.array(imgPIL) # PIL -> Numpy
# out_text = "Prediction:{}, {}, {:.2f}%".format(index, classes[index], percent)
# print(out_text)
# plt.imshow(imgNP)
# plt.title(out_text)
# plt.axis('off')
# plt.tight_layout()
# plt.show()
参考文献:
- Xiangyu Zhang, Xinyu Zhou, Jian Sun, ShuffleNet: An Extremely Efficient Convolutional Neural Network for Mobile Devices, 2017
【本节完】
版权声明:
欢迎关注『youcans动手学模型』系列
转发请注明原文链接:
【youcans动手学模型】ShuffleNet 模型
Copyright 2023 youcans, XUPT
Crated:2023-06-30