一. ResNeXt 神经网络介绍
ResNeXt(Residual Networks with Next)是一种深度学习模型,它是在经典的ResNet(残差网络)的基础上发展起来的,是 ResNet 和 Inception 的结合体,旨在通过引入“cardinality”(基数)的概念来提高网络的性能和效率。ResNeXt 由 Facebook AI Research 团队提出,首次出现在 2016 年的论文《Aggregated Residual Transformations for Deep Neural Networks》中。
二. 概念拓展
1. Cardinality(基数):
Cardinality 是指 ResNeX t中并行的路径或分支的数量。在 ResNeXt 中,每个残差块内包含多个并行的卷积路径,这些路径独立地处理输入特征,并在最后将结果聚合。这个概念类似于 Inception 网络中的多尺度卷积,但 ResNeXt 的并行路径共享相同的滤波器尺寸,这提供了额外的灵活性和多样性。
2. Group Convolutions(分组卷积):
2.1 常规卷积与分组卷积
如下图,分组卷积是将输入层的不同特征图进行分组,然后采用不同的卷积核再对各个组进行卷积,这样会降低卷积的计算量。因为一般的卷积都是在所有的输入特征图上做卷积,可以说是全通道卷积,这是一种通道密集连接方式(channel dense connection)。而 group convolution 相比则是一种通道稀疏连接方式(channel sparse connection)。
2.2 常规卷积与分组卷积的参数变化
1)常规卷积(Convolution):
如果输入 feature map 尺寸为 𝐶∗𝐻∗𝑊,卷积核有 𝑁 个,输出 feature map 与卷积核的数量相同也是 𝑁,每个卷积核的尺寸为 𝐶∗𝐾∗𝐾,𝑁 个卷积核的总参数量为 𝑁∗𝐶∗𝐾∗𝐾。
2)分组卷积(Group Convolution)
Group Convolution 是对输入 feature map 进行分组,然后每组分别卷积。假设输入 feature map 的尺寸仍为 𝐶∗𝐻∗𝑊,输出 feature map 的数量为 𝑁 个,如果设定要分成 𝐺 个 groups,则每组的输入 feature map 数量为 𝐶 / 𝐺,每组的输出 feature map 数量为 𝑁 / 𝐺,每个卷积核的尺寸为 𝐶/𝐺∗𝐾∗𝐾,卷积核的总数仍为 𝑁 个,每组的卷积核数量为 𝑁 / 𝐺,卷积核只与其同组的输入 map 进行卷积,卷积核的总参数量为 𝑁∗𝐶 / 𝐺∗𝐾∗𝐾,可见,总参数量减少为原来的 1 / 𝐺。
三. ResNeXt 神经网络结构
ResNeXt50 是一个基于 ResNet(残差网络)的变种,它在 ResNet 的基础上引入了“cardinality”(基数)的概念,通过并行的分组卷积(group convolution)来提升网络的性能和效率。下面详细介绍 ResNeXt50 的结构:
输入层
- 初始卷积:ResNeXt50 以一个 7x7 的卷积层开始,步长为 2,用于提取输入图像的初步特征。这个卷积层通常用于处理 3 通道的 RGB 图像输入,输出通道数为 64。
最大池化层
- Max Pooling:紧随初始卷积层之后的是一个 3x3 的最大池化层,步长同样为 2,用于进一步缩小特征图的尺寸。
主体部分
ResNeXt50 的主体部分由四个阶段组成,每个阶段包含一系列的 ResNeXt 模块。这些阶段通常被称为 layer1、layer2、layer3 和 layer4。
Layer1
- 三个 ResNeXt 模块:每个模块包含三个子层,其中中间层使用 3x3 的分组卷积,而两侧则使用 1x1 的卷积。Layer1 的输出通道数为 256,每个分组卷积的通道数为 64。ResNeXt50 的基数(cardinality)为 32,这意味着在分组卷积中有 32 个并行的卷积路径。
Layer2
- 四个 ResNeXt 模块:结构与 Layer1 相似,但是输出通道数增加到 512,每个分组卷积的通道数为 128。在第一个模块中,输入和输出的维度不匹配,因此使用一个 1x1 的卷积层来调整输入的维度,以匹配模块输出的维度。
Layer3
- 六个 ResNeXt 模块:输出通道数为1024,每个分组卷积的通道数为256。同样,在第一个模块中,输入和输出的维度需要通过1x1卷积层进行调整。
Layer4
- 三个ResNeXt模块:输出通道数为 2048,每个分组卷积的通道数为 512。在第一个模块中,输入和输出的维度不匹配,使用 1x1 卷积层进行调整。
输出层
-
全局平均池化层:在最后一个残差模块之后,使用全局平均池化(Global Average Pooling,GAP)层将特征图转换为固定长度的向量。
-
全连接层:GAP 层的输出被馈送到一个全连接层,其节点数等于分类任务的类别数。例如,对于 ImageNet 数据集,该层的输出节点数为 1000。
-
Softmax层:全连接层的输出被送入 Softmax 层,生成各个类别的概率预测。
四. ResNeXt 模型亮点
ResNeXt 模型,全称Residual Networks with Next Generation (ResNeXt),是由Facebook AI Research 团队提出的一种深度学习模型,旨在解决深度神经网络中常见的梯度消失问题,并通过新颖的架构设计提升模型的性能。以下是 ResNeXt 模型的一些亮点:
-
分组卷积(Grouped Convolutions):
- ResNeXt 引入了分组卷积的概念,将卷积层分割成多个独立的组,每个组独立地处理输入特征的一部分。这种设计类似于 Inception 模块中的 split-transform-merge 策略,但所有组共享相同的拓扑结构,简化了模型的设计和超参数的选择。
-
基数(Cardinality):
- “基数”是 ResNeXt 的一个核心概念,指的是分组卷积中组的数量。增加基数可以提高模型的性能,这被证明比单纯增加网络深度或宽度更为有效。通过增加基数,模型能够探索更丰富的特征组合,从而增强其表达能力。
-
简单的拓扑结构:
- ResNeXt 使用了相同的拓扑结构来构建其模块,这使得模型更加简洁,减少了超参数的引入,提高了模型的通用性和可移植性。
-
效率与性能的平衡:
- 在不显著增加参数数量的情况下,ResNeXt 能够提高模型的准确率。通过分组卷积和适度的基数,模型在计算效率和性能之间找到了一个良好的平衡点。
-
灵活性:
- ResNeXt 的设计允许模型在不同规模下运行,无论是浅层还是深层网络,或是宽或窄的网络。这使得 ResNeXt 在不同应用场景和计算资源下都能表现出色。
-
易于训练和扩展:
- 由于其设计的简单性和高效性,ResNeXt 模型通常比其他复杂模型更容易训练,同时也容易扩展到更大的网络规模,如 ResNeXt-101。
-
实证表现:
- ResNeXt 在各种图像识别任务上展示了卓越的性能,特别是在大规模数据集上的表现优于 ResNet 和其他同类模型。
总的来说,ResNeXt 通过引入分组卷积和基数的概念,不仅解决了传统深度学习模型中存在的问题,还开辟了新的模型设计方向,为深度学习领域的研究者和从业者提供了新的工具和思路。
五. ResNeXt 代码实现
开发环境配置说明:本项目使用 Python 3.6.13 和 PyTorch 1.10.2 构建,适用于CPU环境。
- model.py:定义网络模型
- train.py:加载数据集并训练,计算 loss 和 accuracy,保存训练好的网络参数
- predict.py:用自己的数据集进行分类测试
- batch_predict.py:批量预测(补充代码)
- model.py
import torch.nn as nn
import torch
# 定义18层网络和34层网络的残差结构
class BasicBlock(nn.Module):
# expansion对应残差结构中,主分支的卷积核数有没有发生变化
# 18层和34层的网络没有变化,50层、101层和152层的网络发生变化
expansion = 1
# downsample下采样参数,用于残差分支的尺寸维度缩放
def __init__(self, in_channel, out_channel, stride=1, downsample=None):
super(BasicBlock, self).__init__()
self.conv1 = nn.Conv2d(in_channels=in_channel, out_channels=out_channel,
kernel_size=3, stride=stride, padding=1, bias=False)
# BN层
self.bn1 = nn.BatchNorm2d(out_channel)
self.relu = nn.ReLU()
self.conv2 = nn.Conv2d(in_channels=out_channel, out_channels=out_channel,
kernel_size=3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channel)
# 下采样方法
self.downsample = downsample
def forward(self, x):
# 分支线上的输出
# 将x赋值给identity,捷径上不执行下采样的输出值
identity = x
# 判断downsample=None,对捷径执行下采样操作并输出
if self.downsample is not None:
identity = self.downsample(x)
# 主支线上的输出
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
# 主分支输出加上捷径分支输出
out += identity
out = self.relu(out)
return out
# 定义50层网络、101层网络和152层网络的残差结构
class Bottleneck(nn.Module):
# expansion对应残差结构中,主分支的卷积核数有没有发生变化
# 50层、101层和152层的网络发生变化,其残差结构中第三层的卷积核个数为前两层的四倍,例如64—64—256
expansion = 4
"""与ResNet的区别:初始化中加入了 groups 和 width_per_group"""
def __init__(self, in_channel, out_channel, stride=1, downsample=None,
groups=1, width_per_group=64):
super(Bottleneck, self).__init__()
width = int(out_channel * (width_per_group / 64.)) * groups
self.conv1 = nn.Conv2d(in_channels=in_channel, out_channels=width,
kernel_size=1, stride=1, bias=False)
self.bn1 = nn.BatchNorm2d(width)
# -----------------------------------------
self.conv2 = nn.Conv2d(in_channels=width, out_channels=width, groups=groups,
kernel_size=3, stride=stride, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(width)
# -----------------------------------------
self.conv3 = nn.Conv2d(in_channels=width, out_channels=out_channel*self.expansion,
kernel_size=1, stride=1, bias=False)
self.bn3 = nn.BatchNorm2d(out_channel*self.expansion)
self.relu = nn.ReLU(inplace=True)
self.dowmsample = downsample
def forward(self, x):
identity = x
if self.dowmsample is not None:
identity = self.dowmsample(x)
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
out = self.relu(out)
out = self.conv3(out)
out = self.bn3(out)
out += identity
out = self.relu(out)
return out
"""与ResNet的区别:初始化中加入了 groups 和 width_per_group"""
# 定义ResNet网络
class ResNeXt(nn.Module):
# block:残差结构 block_num(list):残差结构数量 include_top=True:方便在ResNet上搭建其他网络
def __init__(self, block, block_num, num_classes=1000, include_top=True,
groups=1, width_per_group=64):
super(ResNeXt, self).__init__()
self.include_top = include_top
self.in_channel = 64
self.groups = groups
self.width_per_group = width_per_group
self.conv1 = nn.Conv2d(3, self.in_channel, kernel_size=7, stride=2,
padding=3, bias=False)
self.bn1 = nn.BatchNorm2d(self.in_channel)
self.relu = nn.ReLU(inplace=True)
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
self.layer1 = self._make_layer(block, 64, block_num[0])
self.layer2 = self._make_layer(block, 128, block_num[1], stride=2)
self.layer3 = self._make_layer(block, 256, block_num[2], stride=2)
self.layer4 = self._make_layer(block, 512, block_num[3], stride=2)
# 输出层+全连接层
if self.include_top:
self.avepool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512 * block.expansion, num_classes)
# 对卷积层初始化
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
# 定义生成残差结构的方法
# block:残差结构 channel:第一层卷积核的个数 block_num:残差结构数量
def _make_layer(self, block, channel, block_num, stride=1):
downsample = None
# 判断通道数是否发生变化,来执行下采样操作
if stride != 1 or self.in_channel != channel * block.expansion:
downsample = nn.Sequential(
nn.Conv2d(self.in_channel, channel * block.expansion, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(channel * block.expansion))
layers = []
# 添加第一层残差结构
layers.append(block(self.in_channel, channel, downsample=downsample, stride=stride,
groups=self.groups, width_per_group=self.width_per_group))
# 根据expansion来生成实线和虚线的残差结构
self.in_channel = channel * block.expansion
# 残差结构中除了第一层均为实线结构,将其依次添加到layers中
for _ in range(1, block_num): # 从1开始,即实线残差结构从第二层开始
layers.append(block(self.in_channel, channel,
groups=self.groups, width_per_group=self.width_per_group))
return nn.Sequential(*layers)
# 正向传播过程
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.maxpool(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
if self.include_top:
x = self.avepool(x)
x = torch.flatten(x, 1)
x = self.fc(x)
return x
def resnext50_32x4d(num_classes=1000, include_top=True):
# https://download.pytorch.org/models/resnext50_32x4d-7cdf4587.pth
groups = 32
width_per_group = 4
return ResNeXt(Bottleneck, [3, 4, 6, 3], num_classes=num_classes, include_top=include_top,
groups=groups, width_per_group=width_per_group)
def resnext101_32x8d(num_classes=1000, include_top=True):
# https://download.pytorch.org/models/resnext101_32x8d-8ba56ff5.pth
groups = 32
width_per_group = 8
return ResNeXt(Bottleneck, [3, 4, 23, 3], num_classes=num_classes, include_top=include_top,
groups=groups, width_per_group=width_per_group)
- 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 resnext50_32x4d
import os
import json
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 = resnext50_32x4d() # 实例化模型
net.to(device)
model_weight_path = "./resnext50_32x4d-pre.pth"
# 载入模型权重
missing_keys, unexpected_keys = net.load_state_dict(torch.load(model_weight_path), strict=False)
# 冻结除最后全连接层以外的所有权重
for param in net.parameters():
param.requires_grad = False
# 定义输入特征矩阵的深度
inchannel = net.fc.in_features
# 重新赋值全连接层
net.fc = nn.Linear(inchannel, 5)
loss_function = nn.CrossEntropyLoss() # 定义损失函数
#pata = list(net.parameters()) # 查看模型参数
optimizer = optim.Adam(net.parameters(), lr=0.0001) # 定义优化器
# 设置存储权重路径
save_path = './resnext50.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.py
import os
import json
import torch
from PIL import Image
from torchvision import transforms
import matplotlib.pyplot as plt
from model import resnext50_32x4d
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 = resnext50_32x4d(num_classes=5).to(device)
# load model weights
weights_path = "./resnext50.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()
- batch_predict.py
import os
import json
import torch
from PIL import Image
from torchvision import transforms
from model import resnext50_32x4d
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
# 指向需要遍历预测的图像文件夹
imgs_root = "./batch_imgs"
assert os.path.exists(imgs_root), f"file: '{imgs_root}' dose not exist."
# 读取指定文件夹下所有jpg图像路径
img_path_list = [os.path.join(imgs_root, i) for i in os.listdir(imgs_root) if i.endswith(".png")]
# read class_indict
json_path = './class_indices.json'
assert os.path.exists(json_path), f"file: '{json_path}' dose not exist."
json_file = open(json_path, "r")
class_indict = json.load(json_file)
# create model
model = resnext50_32x4d(num_classes=5).to(device)
# load model weights
weights_path = "./resnext50.pth"
assert os.path.exists(weights_path), f"file: '{weights_path}' dose not exist."
model.load_state_dict(torch.load(weights_path, map_location=device))
# prediction
model.eval()
batch_size = 2 # 每次预测时将多少张图片打包成一个batch
with torch.no_grad():
for ids in range(0, len(img_path_list) // batch_size):
img_list = []
for img_path in img_path_list[ids * batch_size: (ids + 1) * batch_size]:
assert os.path.exists(img_path), f"file: '{img_path}' dose not exist."
img = Image.open(img_path)
img = data_transform(img)
img_list.append(img)
# batch img
# 将img_list列表中的所有图像打包成一个batch
batch_img = torch.stack(img_list, dim=0)
# predict class
output = model(batch_img.to(device)).cpu()
predict = torch.softmax(output, dim=1)
probs, classes = torch.max(predict, dim=1)
for idx, (pro, cla) in enumerate(zip(probs, classes)):
print("image: {} class: {} prob: {:.3}".format(img_path_list[ids * batch_size + idx],
class_indict[str(cla.numpy())],
pro.numpy()))
if __name__ == '__main__':
main()