半监督食物分类任务


前言

随着分类任务基础知识的掌握,和一些经典模型的了解,我们开始尝试进入实战项目。在本次项目中,我们有11类食物,其中带标签的:28011,不带标签的训练数据:6786,验证集3011,测试集为:3347。

一、带标签数据处理

本次半监督任务,我们不仅会对带标签数据进行处理(即知道Y是什么的数据),同时也会对一些不带标签数据进行生成伪标签,再进行训练等。故,对数据集处理,以及训练过程中,面对不同情况需要分类讨论。在这一段,我们先讨论带标签数据的运行流程,再去讨论无标签数据。

随机种子

在开始正式处理之前,我们先进行一些和之前不一样的内容,随机种子设置。随机种子设置的作用,是确保实验的可重复性,喜欢打游戏的朋友应该比较熟悉,当我们喜欢某次随机关卡时,可以记录下种子代码,下次游玩将代码输入,便可重现上次的关卡。在深度学习实验中,随机种子确保了可复现性,方便了我们调试模型,比较不同模型性能等需求。

数据集处理

与平常的数据集处理环节不同,对于本次多种类大量数据集,我们将文件读取单独设置了一个函数,除此之外,我们还要进行数据增广,提升模型的泛化性。

文件读取

首先,要判断当前模式类型,如果是非无标签数据集,首先是遍历11个类别,再拼接完整路径,使用 os.listdir 获取当前类别文件夹下的所有文件名(如图片文件列表)。注意数据类型和格式调整,因为图片像素值多为整数,且当前图片大小不一定为计算常用的224*224。最后,用两个大数组存储图片对象和类别对象。

        def read_file(self, path):# 定义一个读文件的函数
        if self.mode == "semi":
            file_list = os.listdir(path)
            xi = np.zeros((len(file_list), HW, HW, 3), dtype=np.uint8)
            # 列出文件夹下所有文件名字
            for j, img_name in enumerate(file_list):
                img_path = os.path.join(path, img_name)
                img = Image.open(img_path)
                img = img.resize((HW, HW))
                xi[j, ...] = img
            print("读到了%d个数据" % len(xi))
            return xi
        else:
            for i in tqdm(range(11)):           # 有11类,显示进度条,方便监控读取进度。
                file_dir = path + "/%02d" % i    # 生成文件夹名(如 `/00/`, `/01/`) %02必须为两位。 符合文件夹名字,同时拼接完整路径
                file_list = os.listdir(file_dir)    #使用 os.listdir 获取当前类别文件夹下的所有文件名(如图片文件列表)。
                # 预分配当前类别的图像和标签数组
                xi = np.zeros((len(file_list), HW, HW, 3), dtype=np.uint8)  #图像像素值多为整数(0-255),故要设置为整数类型
                yi = np.zeros(len(file_list), dtype=np.uint8)   # 先把放数据的格子打好。 x的维度是 照片数量*H*W*3

                # 列出文件夹下所有文件名字
                for j, img_name in enumerate(file_list):    #遍历文件列表,同时获取索引 j 和文件名 img_name。
                    #将文件夹路径和文件名拼接为完整的文件路径(跨平台兼容)。
                    img_path = os.path.join(file_dir, img_name)
                    img = Image.open(img_path)  #使用 PIL 库打开图像文件并返回一个PIL图片对象
                    img = img.resize((HW, HW))  #当前图像大小,不一定为我们经常使用的224*224,故需要调整尺寸
                    xi[j, ...] = img##... 表示“省略所有其他维度”,即 xi[j, :, :, :]。将 img 的数据赋值到 xi 的第 j 个位置,覆盖所有高度、宽度和通道维度。
                    yi[j] = i

                # 我们通过拼接的方式,创建两个大存储数组,将11类文件的x,y都存在X,Y中
                if i == 0:
                    X = xi
                    Y = yi
                else:
                    X = np.concatenate((X, xi), axis=0)      # 将11个文件夹的数据合在一起,axis表示维度。
                    Y = np.concatenate((Y, yi), axis=0)
            print("读到了%d个数据" % len(Y))
            return X, Y

数据增广

什么是数据增广呢?就是通过对原始数据进行变化,如裁剪,旋转等,增强项目的辨识能力。但是,要区分训练集,验证集和测试集。因为训练集才是真正要传到模型中训练并更新模型的数据,而验证集和测试集仅仅要做的是验证准确性,数据增广后会对项目的准确性的计算产生影响,无法得出真实值。故,验证集和测试集的传入数据不需要经历增广,仅仅转化成张量即可。
在这里插入图片描述

test_transform = transforms.Compose([
    transforms.ToTensor(),
])              # 测试集只需要转为张量

#数据增广
train_transform = transforms.Compose([
    transforms.ToPILImage(),                    #ToPILImage() 是将 NumPy 数组或 PyTorch 张量转换为 PIL 图像的工具,通常在数据预处理流程中使用。
    transforms.RandomResizedCrop(HW),           #随机缩放进行裁切,随机裁剪图像的一部分,并缩放到指定大小 HW。
    transforms.RandomHorizontalFlip(),          # 随机水平翻转,以默认概率 0.5 水平翻转图像。
    autoaugment.AutoAugment(),                  #应用 AutoAugment 策略,自动组合多种增强操作(如旋转、剪切、颜色抖动等)。
    transforms.ToTensor(),                      #将模型转化为张量
    # transforms.Normalize([0.485,0.456,0.406],[0.229,0.224,0.225])
])                   # 训练集需要做各种变换。   效果参见https://pytorch.org/vision/stable/transforms.html
#

数据集函数

进入函数后,标签数据首先会通过读取函数进行读取,之后分别进入各自的增广函数(train_transform和val_transform),转化为计算所需的数据类型和格式。之后是我们熟悉的获取函数(getitem)和长度函数(len

class food_Dataset(Dataset):
    def __init__(self, path, mode="train"):
        self.mode = mode
        if mode == "semi":
            self.X = self.read_file(path)
        else:
            self.X, self.Y = self.read_file(path)
            self.Y = torch.LongTensor(self.Y)  #标签转为长整形

        if mode == "train":
            self.transform = train_transform
        else:
            self.transform = val_transform

    def read_file(self, path):# 定义一个读文件的函数
        if self.mode == "semi":
            file_list = os.listdir(path)
            xi = np.zeros((len(file_list), HW, HW, 3), dtype=np.uint8)
            # 列出文件夹下所有文件名字
            for j, img_name in enumerate(file_list):
                img_path = os.path.join(path, img_name)
                img = Image.open(img_path)
                img = img.resize((HW, HW))
                xi[j, ...] = img
            print("读到了%d个数据" % len(xi))
            return xi
        else:
            for i in tqdm(range(11)):           # 有11类,显示进度条,方便监控读取进度。
                file_dir = path + "/%02d" % i    # 生成文件夹名(如 `/00/`, `/01/`) %02必须为两位。 符合文件夹名字,同时拼接完整路径
                file_list = os.listdir(file_dir)    #使用 os.listdir 获取当前类别文件夹下的所有文件名(如图片文件列表)。
                # 预分配当前类别的图像和标签数组
                xi = np.zeros((len(file_list), HW, HW, 3), dtype=np.uint8)  #图像像素值多为整数(0-255),故要设置为整数类型
                yi = np.zeros(len(file_list), dtype=np.uint8)   # 先把放数据的格子打好。 x的维度是 照片数量*H*W*3

                # 列出文件夹下所有文件名字
                for j, img_name in enumerate(file_list):    #遍历文件列表,同时获取索引 j 和文件名 img_name。
                    #将文件夹路径和文件名拼接为完整的文件路径(跨平台兼容)。
                    img_path = os.path.join(file_dir, img_name)
                    img = Image.open(img_path)  #使用 PIL 库打开图像文件并返回一个PIL图片对象
                    img = img.resize((HW, HW))  #当前图像大小,不一定为我们经常使用的224*224,故需要调整尺寸
                    xi[j, ...] = img##... 表示“省略所有其他维度”,即 xi[j, :, :, :]。将 img 的数据赋值到 xi 的第 j 个位置,覆盖所有高度、宽度和通道维度。
                    yi[j] = i

                # 我们通过拼接的方式,创建两个大存储数组,将11类文件的x,y都存在X,Y中
                if i == 0:
                    X = xi
                    Y = yi
                else:
                    X = np.concatenate((X, xi), axis=0)      # 将11个文件夹的数据合在一起。
                    Y = np.concatenate((Y, yi), axis=0)
            print("读到了%d个数据" % len(Y))
            return X, Y

    def __getitem__(self, item):
        if self.mode == "semi":
            return self.transform(self.X[item]), self.X[item]           #一个用于检测,一个用于传递到无标签的数据集
        else:
            return self.transform(self.X[item]), self.Y[item]

    def __len__(self):
        return len(self.X)

模型设计

迁移学习,预训练与线性探测

在讲具体模型设计之前,我们要再引入一个新概念——迁移学习,是一种机器学习方法,是一种策略,其核心思想是将从一个任务中学到的知识迁移到另一个相关任务中,以提升目标任务的性能。为什么要使用迁移学习呢?大佬们的模型,花费百万美元,在千万上亿的数据集上训练,参数的调整,提取的特征特别好。故,使用大佬们的模型进行微调,可以极大的提高我们的准确率。
那什么是预训练呢? 预训练是深度学习中一种重要的技术,指的是在大规模数据集上训练一个模型,然后将该模型或其部分参数迁移到目标任务中,作为初始权重或特征提取器。可以提高训练效率、提升模型性能、解决数据稀缺问题。
预训练通常是进行迁移学习的一个步骤。在迁移学习中,一个常见的做法是首先使用大量的数据对模型进行预训练,以学习到一些通用的特征表示。
线性探测(Linear Probing)​ 是迁移学习中的一种策略,指 ​冻结预训练模型的特征提取层,仅训练新添加的线性分类层。其核心思想是:利用预训练模型提取的通用特征,通过简单的线性分类器适配新任务,避免在小数据场景下过拟合。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

模型编写

我们模型设计的主要思路是,将大小为3224224的数据通过卷积和池化转化为51277的大小,之后再将数据拉直,然后使用熟悉的全连接操作,将25088(51277)通过全连接转为1000,最后分类为11类。注意一个小方法,由于卷积过程中,每层的工作模式都是卷积,批次次归一化,激活函数,池化。所以我们可以将功能集合于一层,方便后续通过函数的编写。

class myModel(nn.Module):
    def __init__(self, num_class):
        super(myModel, self).__init__()
        #3 *224 *224  -> 512*7*7 -> 拉直 -》全连接分类
        self.conv1 = nn.Conv2d(3, 64, 3, 1, 1)    # 64*224*224
        self.bn1 = nn.BatchNorm2d(64)
        self.relu = nn.ReLU()
        self.pool1 = nn.MaxPool2d(2)   #64*112*112

        self.layer1 = nn.Sequential(
            nn.Conv2d(64, 128, 3, 1, 1),    # 128*112*112
            nn.BatchNorm2d(128),
            nn.ReLU(),
            nn.MaxPool2d(2)   #128*56*56
        )
        self.layer2 = nn.Sequential(
            nn.Conv2d(128, 256, 3, 1, 1),
            nn.BatchNorm2d(256),
            nn.ReLU(),
            nn.MaxPool2d(2)   #256*28*28
        )
        self.layer3 = nn.Sequential(
            nn.Conv2d(256, 512, 3, 1, 1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.MaxPool2d(2)   #512*14*14
        )

        self.pool2 = nn.MaxPool2d(2)    #512*7*7
        self.fc1 = nn.Linear(25088, 1000)   #25088->1000
        self.relu2 = nn.ReLU()
        self.fc2 = nn.Linear(1000, num_class)  #1000-11

    def forward(self, x):
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.pool1(x)
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.pool2(x)
        x = x.view(x.size()[0], -1)
        x = self.fc1(x)
        x = self.relu2(x)
        x = self.fc2(x)
        return x

模型初始化

在这个模块中,我们可以通过输入模型名字和分类数,返回你想要的模型。在这个函数中,我们对各个模型进行了一些微调操作。通过使用统一接口 initialize_model 实现了对不同模型的微调,核心是通过替换分类层和冻结参数控制训练范围。我们通过设置 linear_prob=True/False 可灵活切换 ​特征提取(线性探测)​ 和 ​完整微调模式。

#传入模型名字,和分类数, 返回你想要的模型
def initialize_model(model_name, num_classes, linear_prob=False, use_pretrained=True):
    # 初始化将在此if语句中设置的这些变量。
    # 每个变量都是模型特定的。
    model_ft = None
    input_size = 0
    if model_name =="MyModel":
        if use_pretrained == True:
            model_ft = torch.load('model_save/MyModel')
        else:
            model_ft = MyModel(num_classes)
        input_size = 224

    elif model_name == "resnet18":
        """ Resnet18
        """
        model_ft = models.resnet18(pretrained=use_pretrained)            # 从网络下载模型  pretrain true 使用参数和架构, false 仅使用架构。
        set_parameter_requires_grad(model_ft, linear_prob)            # 是否为线性探测,线性探测: 固定特征提取器不训练。
        num_ftrs = model_ft.fc.in_features  #分类头的输入维度
        model_ft.fc = nn.Linear(num_ftrs, num_classes)            # 删掉原来分类头, 更改最后一层为想要的分类数的分类头。
        input_size = 224
        
    elif model_name == "resnet50":
        """ Resnet50
        """
        model_ft = models.resnet50(pretrained=use_pretrained)
        set_parameter_requires_grad(model_ft, linear_prob)
        num_ftrs = model_ft.fc.in_features
        model_ft.fc = nn.Linear(num_ftrs, num_classes)
        input_size = 224
        
    elif model_name == "googlenet":
        """ googlenet
        """
        model_ft = models.googlenet(pretrained=use_pretrained)
        set_parameter_requires_grad(model_ft, linear_prob)
        num_ftrs = model_ft.fc.in_features
        model_ft.fc = nn.Linear(num_ftrs, num_classes)
        input_size = 224


    elif model_name == "alexnet":
        """ Alexnet
 """
        model_ft = models.alexnet(pretrained=use_pretrained)
        set_parameter_requires_grad(model_ft, linear_prob)
        num_ftrs = model_ft.classifier[6].in_features
        model_ft.classifier[6] = nn.Linear(num_ftrs,num_classes)
        input_size = 224

    elif model_name == "vgg":
        """ VGG11_bn
 """
        model_ft = models.vgg11_bn(pretrained=use_pretrained)
        set_parameter_requires_grad(model_ft, linear_prob)
        num_ftrs = model_ft.classifier[6].in_features
        model_ft.classifier[6] = nn.Linear(num_ftrs,num_classes)
        input_size = 224

    elif model_name == "squeezenet":
        """ Squeezenet
 """
        model_ft = models.squeezenet1_0(pretrained=use_pretrained)
        set_parameter_requires_grad(model_ft, linear_prob)
        model_ft.classifier[1] = nn.Conv2d(512, num_classes, kernel_size=(1,1), stride=(1,1))
        model_ft.num_classes = num_classes
        input_size = 224

    elif model_name == "densenet":
        """ Densenet
 """
        model_ft = models.densenet121(pretrained=use_pretrained)
        set_parameter_requires_grad(model_ft, linear_prob)
        num_ftrs = model_ft.classifier.in_features
        model_ft.classifier = nn.Linear(num_ftrs, num_classes)
        input_size = 224

    elif model_name == "inception":
        """ Inception v3
 Be careful, expects (299,299) sized images and has auxiliary output
 """
        model_ft = models.inception_v3(pretrained=use_pretrained)
        set_parameter_requires_grad(model_ft, linear_prob)
        # 处理辅助网络
        num_ftrs = model_ft.AuxLogits.fc.in_features
        model_ft.AuxLogits.fc = nn.Linear(num_ftrs, num_classes)
        # 处理主要网络
        num_ftrs = model_ft.fc.in_features
        model_ft.fc = nn.Linear(num_ftrs,num_classes)
        input_size = 299

    else:
        print("Invalid model_utils name, exiting...")
        exit()

    return model_ft, input_size

超参数与训练

优化器

这次的优化器方面,我们不再使用sgd,而是AdamW(Adam with Weight Decay),再介绍AdamW之前,我们需要先介绍它的前版本,Adam(Adaptive Moment Estimation)。这是一个结合了动量法的优化器,可以通过自适应学习率来加速收敛。如下图,面对一个凹点,它也会判断左右的梯度,添加动量冲出当前区域,选择到真正的梯度最低点。而AdamW 是 Adam 的改进版本,主要解决了 Adam 中权重衰减(Weight Decay)的实现问题,更符合权重衰减的原始定义(L2 正则化)。

特性SGDAdamAdamW
学习率固定自适应自适应
动量无(需手动添加动量项)内置动量内置动量
权重衰减直接应用与梯度更新耦合(与学习率相关)与梯度更新解耦 (与学习率无关)
收敛速度较慢较快较快
适用场景小数据集、简单模型大规模数据集、复杂模型大规模数据集、复杂模型

关于Adam 和AdamW的权重衰减,我们再举个例子。假设你训练一个神经网络识别猫狗:

​用Adam:如果某个参数(比如“耳朵检测器”)的梯度突然变大(学习率自动调高),权重衰减也会被放大,导致这个重要参数被过度惩罚(相当于把有用的工具扔了)。
​用AdamW:无论梯度如何变化,权重衰减始终按固定比例作用,保护了重要参数。

同时,我们接着这个机会讲解一下L1正则化和L2正则化。
L1 正则化(Lasso 正则化)​,在损失函数中增加权重的 ​绝对值之和 作为惩罚项,迫使不重要的特征权重变为零。其中,λ:正则化强度系数(人为设定),wi​ :模型权重。
L1正则化可以自动筛选重要特征,将不重要的权重置零(适合 ​特征选择)。
在这里插入图片描述
L2 正则化(Ridge 正则化)​损失函数中增加权重的 ​平方和 作为惩罚项,迫使所有权重趋向较小的值(但不为零)。与L1正则化相比,L2正则化对异常值敏感度较低,可以使曲线更加平滑。
在这里插入图片描述

​特性L1 正则化L2 正则化
​惩罚项绝对值之和(∑∥w∥)平方和( W^2 )
​权重结果稀疏(部分权重归零)平滑(权重接近但不为零)
​抗异常值
计算效率高(适合高维数据)低(需矩阵求逆,复杂度高)
​典型应用特征选择、稀疏模型通用防过拟合、多特征协作

超参数设置

将设置一个字典型对象,将超参数都放置在该字典型对象中,方便后续调用,以及对代码的解读。

#模型和超参数
model, input_size = initialize_model(model_name, 11, use_pretrained=False)

print(input_size)

#注意AdamW的特点,1、学习率有自适应性 2、内置动力 3、有权重衰减 4、收敛速度较快
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate,weight_decay=1e-4)

save_path = 'model_save/model.pth'

trainpara = {
            "model" : model,
             'train_loader': train_loader,
             'val_loader': val_loader,
             'no_label_Loader': no_label_Loader,
             'optimizer': optimizer,
            'batchSize': batchSize,
             'loss': loss,
             'epoch': epoch,
             'device': device,
             'save_path': save_path,
             'save_acc': True,
             'max_acc': 0.5,
             'val_epoch' : 1,
             'acc_thres' : 0.7,         #半监督阈值
             'conf_thres' : 0.99,		
             'do_semi' : True,
            "pre_path" : None
             }


if __name__ == '__main__':          #如果调用的是当前函数,则调用下述代码
    train_val(trainpara)

训练函数

在训练函数中,带标签数据方面其实和我们之前写的函数区别并不是特别大。依旧是设置训练轮次,计算每一轮损失loss和准确值,注意事项还是那些,如模型与数据放置同一设备上之类。
新的需要注意的点,计算轮次损失值时,因为用的是每个批次计算均值损失,总损失为各批次均值之和,所以需要使用平均批次均值作为分母。而,计算轮次准确率时,因为用的是每个批次累加正确样本数,总正确数为绝对数量,所以需相对总样本数计算比例。
在验证方面,每过val_epoch轮次进行验证,验证的流程也与之前的项目差别不大。如果准确率大于当前最高准确值,则进行更新最高准确值。

from tqdm import tqdm
import torch
import time
import matplotlib.pyplot as plt
import numpy as np
from food_classification.model_utils import model
from food_classification.model_utils.data import samplePlot, get_semi_loader


def train_val(para):

########################################################
    model = para['model']
    semi_loader = para['no_label_Loader']
    train_loader =para['train_loader']
    val_loader = para['val_loader']
    optimizer = para['optimizer']
    loss = para['loss']
    epoch = para['epoch']
    device = para['device']
    save_path = para['save_path']
    save_acc = para['save_acc']
    pre_path = para['pre_path']
    max_acc = para['max_acc']
    val_epoch = para['val_epoch']
    acc_thres = para['acc_thres']
    conf_thres = para['conf_thres']
    do_semi= para['do_semi']

    semi_epoch = 10
###################################################
    no_label_Loader = None
    if pre_path != None:
        model = torch.load(pre_path)
    model = model.to(device)
    # model = torch.nn.DataParallel(model).to(device)
    # model.device_ids = [0,1]

    plt_train_loss = []
    plt_train_acc = []
    plt_val_loss = []
    plt_val_acc = []
    plt_semi_acc = []
    val_rel = []
    max_acc = 0     #与线性回归项目记录min_loss不同,本项目选择记录acc的最大值作为判读最好模型的依据

    for i in range(epoch):
        start_time = time.time()
        model.train()
        train_loss = 0.0
        train_acc = 0.0
        val_acc = 0.0
        val_loss = 0.0
        semi_acc = 0.0

        for data in tqdm(train_loader):                    #取数据
            optimizer.zero_grad()                           # 梯度置0
            x, target = data[0].to(device), data[1].to(device)
            pred = model(x)                                 #模型前向
            bat_loss = loss(pred, target)                   # 算交叉熵loss
            bat_loss.backward()                                 # 回传梯度
            optimizer.step()                                    # 根据梯度更新
            train_loss += bat_loss.item()    #.detach 表示去掉梯度
            #argmax:求出最大值的下标;axis=1:沿横向取
            train_acc += np.sum(np.argmax(pred.cpu().data.numpy(),axis=1) == data[1].numpy())#判断预测值与真实值是否相等

            # 预测值和标签相等,正确数就加1.  相等多个, 就加几。

        if no_label_Loader != None:
            for data in tqdm(no_label_Loader):
                optimizer.zero_grad()
                x , target = data[0].to(device), data[1].to(device)
                pred = model(x)
                bat_loss = loss(pred, target)
                bat_loss.backward()
                optimizer.step()

                semi_acc += np.sum(np.argmax(pred.cpu().data.numpy(),axis=1)== data[1].numpy())
            plt_semi_acc .append(semi_acc/no_label_Loader.dataset.__len__())
            print('semi_acc:', plt_semi_acc[-1])

        #用的是每个批次计算均值损失,总损失为各批次均值之和
        plt_train_loss.append(train_loss/train_loader.__len__())
        #用的是每个批次累加正确样本数,总正确数为绝对数量
        plt_train_acc.append(train_acc/train_loader.dataset.__len__())
        #每过几轮进行一次验证
        if i % val_epoch == 0:
            model.eval()
            with torch.no_grad():
                for valdata in val_loader:
                    val_x , val_target = valdata[0].to(device), valdata[1].to(device)
                    val_pred = model(val_x)
                    val_bat_loss = loss(val_pred, val_target)
                    val_loss += val_bat_loss.cpu().item()

                    val_acc += np.sum(np.argmax(val_pred.cpu().data.numpy(), axis=1) == valdata[1].numpy())
                    val_rel.append(val_pred)


            val_acc = val_acc/val_loader.dataset.__len__()
            if val_acc > max_acc:
                torch.save(model, save_path)
                max_acc = val_acc


            plt_val_loss.append(val_loss/val_loader.dataset.__len__())
            plt_val_acc.append(val_acc)
            print('[%03d/%03d] %2.2f sec(s) TrainAcc : %3.6f TrainLoss : %3.6f | valAcc: %3.6f valLoss: %3.6f  ' % \
                  (i, epoch, time.time()-start_time, plt_train_acc[-1], plt_train_loss[-1], plt_val_acc[-1], plt_val_loss[-1])
                  )
        else:   #若当前 epoch 不进行验证,则直接复制上一次的验证损失和准确率。
            plt_val_loss.append(plt_val_loss[-1])
            plt_val_acc.append(plt_val_acc[-1])


        if do_semi and plt_val_acc[-1] > acc_thres and i % semi_epoch==0:         # 如果启用半监督, 且精确度超过阈值, 则开始。
            no_label_Loader = get_semi_loader(semi_loader, semi_loader, model, device, conf_thres)


    plt.plot(plt_train_loss)                   # 画图。
    plt.plot(plt_val_loss)
    plt.title('loss')
    plt.legend(['train', 'val'])
    plt.show()

    plt.plot(plt_train_acc)
    plt.plot(plt_val_acc)
    plt.title('Accuracy')
    plt.legend(['train', 'val'])
    plt.savefig('acc.png')
    plt.show()

二、无标签数据处理

半监督(Semi-Supervised Learning)

半监督学习的核心思想,是结合少量标签数据 + 大量无标签数据进行训练,利用无标签数据提升模型性能。当模型准确度超过一定值时,通过设置阈值,将无标签数据传入模型中,如果得出当前图片为某个种类的概率大于阈值时,则将该预测值作为标签(人工设置的伪标签),投入到训练中。这类方法往往应用到医学影像分类(标注成本高,无标签数据多)。

原始数据处理

对于无标签的数据集,我们在对数据集进行处理时,只需要读取X(数据)的值。

class food_Dataset(Dataset):
    def __init__(self, path, mode="train"):
        self.mode = mode
        if mode == "semi":
            self.X = self.read_file(path)

在读取函数中,除了和标签数据相同的子文件夹名与图片名拼接,要注意无标签数据不会被分为11类。其他操作与标签数据差别不大。

    def read_file(self, path):# 定义一个读文件的函数
        if self.mode == "semi":
            file_list = os.listdir(path)
            xi = np.zeros((len(file_list), HW, HW, 3), dtype=np.uint8)
            # 列出文件夹下所有文件名字
            for j, img_name in enumerate(file_list):
                img_path = os.path.join(path, img_name)
                img = Image.open(img_path)
                img = img.resize((HW, HW))
                xi[j, ...] = img
            print("读到了%d个数据" % len(xi))
            return xi

获取函数方面,除去返回统一数据格式类型的函数(经过验证集的增广函数的数据),还需要返回原始的无标签数据,因为前者是用于生成伪标签,后者用于与伪标签一起加入训练。

    def __getitem__(self, item):
        if self.mode == "semi":
            return self.transform(self.X[item]), self.X[item]           #一个用于模型生成伪标签,一个用于传递到无标签的数据集去一同训练

半监督数据集

由于无标签数据集经过模型后,不能百分百生成带标签数据,故需要一个函数进行判断。

#由于无标签数据集经过模型后,不能百分百生成带标签数据,故需要一个函数进行判断
def get_semi_loader(no_label_loader, model, device, thres):
    semiset = semiDataset(no_label_loader, model, device, thres)
    #没有产生新数据,直接返回None
    if semiset.flag == False:
        return None
    else:
        semi_loader = DataLoader(semiset, batch_size=16, shuffle=False)
        return semi_loader

半监督数据集的流程,与验证流程相似,只不过在半监督数据集中,生成概率分布后,只有概率大于阈值后才会被记录下来。

class semiDataset(Dataset):
    def __init__(self, no_label_loader, model, device, thres=0.99):
        x, y = self.get_label(no_label_loader, model, device, thres)
        if x == []:
            self.flag = False           #flag用于外部判断是否含有数据

        else:
            self.flag = True
            self.X = np.array(x)
            self.Y = torch.LongTensor(y)        #记得转化为整形
            self.transform = train_transform    #由于这批数据最终要和带标签数一同训练,故使用train_transform

    def get_label(self, no_label_loader, model, device, thres):
        model = model.to(device)
        pred_prob = []      #概率值列表
        labels = []         #预测结果
        x = []
        y = []
        soft = nn.Softmax()
        #我们只是想给数据打标签,而非训练模型,故需要关闭梯度
        with torch.no_grad():
            for bat_x, _ in no_label_loader:
                bat_x = bat_x.to(device)
                pred = model(bat_x)
                pred_soft = soft(pred)
                #一个 softmax 函数,用于将 logits 转换为概率分布,找到11个种类里的最大值,该值即为分类结果
                pred_max, pred_value = pred_soft.max(1)         #即返回最大值,也返回最大值的下标,1表示横向维度
                #将张量从 GPU 转移到 CPU(如果模型在 GPU 上运行);将张量转换为 NumPy 数组;将 NumPy 数组转换为 Python 列表
                pred_prob.extend(pred_max.cpu().numpy().tolist())
                labels.extend(pred_value.cpu().numpy().tolist())

        #在通过模型预测之后,将结果进行判断,如果大于阈值,记录下来,最终存入数据中
        for index, prob in enumerate(pred_prob):
            if prob > thres:
                #当通过索引(如 dataset[index])访问数据集时,Python 会自动调用 __getitem__ 方法。
                x.append(no_label_loader.dataset[index][1])   #调用到原始的getitem
                y.append(labels[index])
        return x, y

    def __getitem__(self, item):
        return self.transform(self.X[item]), self.Y[item]
    def __len__(self):
        return len(self.X)

半监督训练

半监督学习使用的模型与自监督学习使用的模型相同,故不进行讨论,直接研究半监督训练过程。
在半监督训练过程中,我们会先将no_label_Loader = None,直到后续模型准确率大于准确率阈值时,才开时启用半监督。
注意开启流程记得放在正常训练流程之后。

no_label_Loader = None

训练过程其实与正常训练过程差别不大,只是多了个开启流程判断。完整代码片段在上文中记录。

        if no_label_Loader != None:
            for data in tqdm(no_label_Loader):
                optimizer.zero_grad()
                x , target = data[0].to(device), data[1].to(device)
                pred = model(x)
                bat_loss = loss(pred, target)
                bat_loss.backward()
                optimizer.step()

                semi_acc += np.sum(np.argmax(pred.cpu().data.numpy(),axis=1)== data[1].numpy())
            plt_semi_acc .append(semi_acc/no_label_Loader.dataset.__len__())
            print('semi_acc:', plt_semi_acc[-1])


 if do_semi and plt_val_acc[-1] > acc_thres and i % semi_epoch==0:         # 如果启用半监督, 且精确度超过阈值, 则开始。
            no_label_Loader = get_semi_loader(semi_loader, semi_loader, model, device, conf_thres)

总结

以上就是今天要讲的内容,本文通过一定量的带标签数据和大量的无标签数据,展示了半监督分类任务的流程。
任务的主要难点,还是在数据集分类部分,其中还展示了读取文件的方法。在模型部分,我们介绍了迁移学习相关知识,采用了微调的方法。训练模块中,训练方式的根本原理,与我们之前的项目相差不大,只是改进了优化器,并使用字典存储参数。除此之外,我们还介绍了半监督相关知识点,浏览了半监督数据的处理流程。同时,还使用了种子代码,保证了实验的可复现性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值