文章目录
前言
随着分类任务基础知识的掌握,和一些经典模型的了解,我们开始尝试进入实战项目。在本次项目中,我们有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 正则化)。
特性 | SGD | Adam | AdamW |
---|---|---|---|
学习率 | 固定 | 自适应 | 自适应 |
动量 | 无(需手动添加动量项) | 内置动量 | 内置动量 |
权重衰减 | 直接应用 | 与梯度更新耦合(与学习率相关) | 与梯度更新解耦 (与学习率无关) |
收敛速度 | 较慢 | 较快 | 较快 |
适用场景 | 小数据集、简单模型 | 大规模数据集、复杂模型 | 大规模数据集、复杂模型 |
关于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)
总结
以上就是今天要讲的内容,本文通过一定量的带标签数据和大量的无标签数据,展示了半监督分类任务的流程。
任务的主要难点,还是在数据集分类部分,其中还展示了读取文件的方法。在模型部分,我们介绍了迁移学习相关知识,采用了微调的方法。训练模块中,训练方式的根本原理,与我们之前的项目相差不大,只是改进了优化器,并使用字典存储参数。除此之外,我们还介绍了半监督相关知识点,浏览了半监督数据的处理流程。同时,还使用了种子代码,保证了实验的可复现性。