Bootstrap

经典CNN模型(六):ResNeXt(PyTorch详细注释版)

一. 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 模型的一些亮点:

  1. 分组卷积(Grouped Convolutions)

    • ResNeXt 引入了分组卷积的概念,将卷积层分割成多个独立的组,每个组独立地处理输入特征的一部分。这种设计类似于 Inception 模块中的 split-transform-merge 策略,但所有组共享相同的拓扑结构,简化了模型的设计和超参数的选择。
  2. 基数(Cardinality)

    • “基数”是 ResNeXt 的一个核心概念,指的是分组卷积中组的数量。增加基数可以提高模型的性能,这被证明比单纯增加网络深度或宽度更为有效。通过增加基数,模型能够探索更丰富的特征组合,从而增强其表达能力。
  3. 简单的拓扑结构

    • ResNeXt 使用了相同的拓扑结构来构建其模块,这使得模型更加简洁,减少了超参数的引入,提高了模型的通用性和可移植性。
  4. 效率与性能的平衡

    • 在不显著增加参数数量的情况下,ResNeXt 能够提高模型的准确率。通过分组卷积和适度的基数,模型在计算效率和性能之间找到了一个良好的平衡点。
  5. 灵活性

    • ResNeXt 的设计允许模型在不同规模下运行,无论是浅层还是深层网络,或是宽或窄的网络。这使得 ResNeXt 在不同应用场景和计算资源下都能表现出色。
  6. 易于训练和扩展

    • 由于其设计的简单性和高效性,ResNeXt 模型通常比其他复杂模型更容易训练,同时也容易扩展到更大的网络规模,如 ResNeXt-101。
  7. 实证表现

    • ResNeXt 在各种图像识别任务上展示了卓越的性能,特别是在大规模数据集上的表现优于 ResNet 和其他同类模型。

总的来说,ResNeXt 通过引入分组卷积和基数的概念,不仅解决了传统深度学习模型中存在的问题,还开辟了新的模型设计方向,为深度学习领域的研究者和从业者提供了新的工具和思路。

五. ResNeXt 代码实现

开发环境配置说明:本项目使用 Python 3.6.13 和 PyTorch 1.10.2 构建,适用于CPU环境。

  • model.py:定义网络模型
  • train.py:加载数据集并训练,计算 loss 和 accuracy,保存训练好的网络参数
  • predict.py:用自己的数据集进行分类测试
  • batch_predict.py:批量预测(补充代码)
  1. 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)
  1. 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")
  1. 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()
  1. 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()

六. 参考内容

  1. 李沐. (2019). 动手学深度学习. 北京: 人民邮电出版社. [ISBN: 978-7-115-51364-9]
  2. 霹雳吧啦Wz. (202X). 深度学习实战系列 [在线视频]. 哔哩哔哩. URL
  3. PyTorch. (n.d.). PyTorch官方文档和案例 [在线资源]. URL
;