前言

本篇主要讲解食物分类实战训练和验证部分代码并引入半监督学习,包含迁移学习和图片增广等数据处理方法。
食物图片一共分为11个类别,其中带标签的数据:280 11,不带标签的训练数据: 6786
验证集30
11,测试集3347。
其中带标签数据用于正常训练,不带标签数据用于半监督学习。


`

1,主文件预处理工作

1.1主函数导包
先导入所需的包并采用多文件编程处理,设定整个代码结构。

主文件设为main,自定义模型设为model,训练验证函数设为train_val,数据处理设为data。

用多文件编程可以避免代码都放在一个文件很混乱,条理不清晰。

import random
import torch
import torch.nn as nn
import numpy as np
import os
 
from torch.utils.data import DataLoader
 
from model_utils.model import initialize_model  # 使用其他人设定的函数,方便快速选择模型
from train_val_sim import train_val  # 我的训练验证函数
from data_sim import food_dataset  # 我的数据处理
 
# from torchvision.models import resnet18  # 迁移学习
# from model_sim import my_model  # 我的初始自定义模型类

1.2定义随机数种子
使用此代码定义,设种子为0,保证每次随机结果保持一致。

def seed_everything(seed):
    torch.manual_seed(seed)
    torch.cuda.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.benchmark = False
    torch.backends.cudnn.deterministic = True
    random.seed(seed)
    np.random.seed(seed)
    os.environ['PYTHONHASHSEED'] = str(seed)
 
 
###############################################
seed_everything(0)
# 随机数种子指定以后,每次随机出来的内容都是一样的
# 不指定的话随机就是随机出来一个种子,还是种子控制
# 就像mc生成地图那个种子
# print(random.randint(1, 8))  # 每次都一样
###############################################

随机数种子

在深度学习和机器学习中,随机种子(Random Seed)是一个非常重要的概念,它用于控制随机数生成器的初始状态,从而使得随机过程具有可重复性。下面为你详细介绍随机种子在你当前代码中的作用、使用方法以及重要性。
在深度学习中,很多操作都涉及到随机数的生成,例如:
数据的随机划分:将数据集划分为训练集、验证集和测试集时,通常会进行随机划分。
模型参数的随机初始化:神经网络的权重和偏置通常是随机初始化的。
数据增强:在数据增强过程中,如随机裁剪、随机旋转等操作也会使用随机数。
使用随机种子可以确保每次运行代码时,这些随机操作生成的结果都是相同的,从而使得实验具有可重复性。也就是说,不同的人在相同的代码和随机种子设置下,能够得到相同的实验结果。
我使用了一个随机种子函数,目的:设置随机种子,确保实验的可重复性。

1.3读入文件
整个文件结构如图所示

在这里插入图片描述

设定路径,写代码调试尽量使用样本图片的数据,样本数据含有少量图片,可以进行快速处理。

(r""可以让反斜杠失效。)

# simple设为含有少量图片,用于测试代码
train_path = r"\food_classification\food-11_sample\training\labeled"
# train_path = r"\food_classification\food-11\training\labeled"
 
val_path = r"\food_classification\food-11_sample\validation"
# val_path = r"\food_classification\food-11\validation"
 
no_label_path = r"\food_classification\food-11\training\unlabeled\00"

1.4设定整体结构
预处理操作完事以后,要划分所有代码文件具体细节要做什么。

train_set = food_dataset(train_path, "train")
val_set = food_dataset(val_path, "val")
no_label_set = food_dataset(no_label_path, "semi")

首先要设定数据处理,从设定好的路径读入图片,然后处理成数组,链接,并进行数据增强操作。

train_loader = DataLoader(train_set, batch_size=8, shuffle=True)
val_loader = DataLoader(val_set, batch_size=8, shuffle=True)
no_label_loader = DataLoader(no_label_set, batch_size=8, shuffle=False)

然后创建数据加载器,将数据集按照设定的批次大小和是否打乱等参数,分批加载到模型中进行训练。

1)train_set:这是训练数据集,包含了用于模型训练的所有样本。
2)batch_size=8:表示每次从数据集中取出的样本数量为8。模型会以这8个样本为一组进行训练,这样可以提高训练效率并减少内存占用。如果想快一点,看显存,可以增大batch_size。
3)shuffle=True:意味着在每个训练周期(epoch)开始时,数据集中的样本顺序会被随机打乱。这样做有助于模型避免学习到数据的固定顺序模式,使训练更加随机和全面。其中无标签数据不进行打乱,无标签数据最后用预测的标签作为其真实标签,打乱的话标签也会打乱,不是很好处理。

# from torchvision.models import resnet18
# model = resnet18(pretrained=True)  # 迁移学习,pretrained=True用已经训练好的参数
# in_features = model.fc.in_features  # 改一下全连接层的输出个数,in_features就是resnet18的分类头
# model.fc = nn.Linear(in_features, 11)   将大佬的分类头修改成自己所需的分类
 
# linear_prob线性探测,就是完全用预训练模型参数不变,不计算梯度,否则会微调参数
# 用别人已经训练好的网络进行训练
model, _ = initialize_model("resnet18", 11, use_pretrained=True, linear_prob=False)
 
# model = my_model(11)
# for batch_x, batch_y in train_loader:
#     pred = model(batch_x)
#     print(batch_x)

定义模型架构,可以使用官方定义好的模型进行迁移学习,或者自己定义的模型,官方定义好的模型需要改全连接输出层的输出个数,改为要分类的类别数。然后需要使用官方训练好的参数并设置允许微调参数,进行参数更新,会比自定义模型的效果要好很多。直接用大佬模型的训练参数(即use_pretrained=true)验证准确率可达60以上,如果设为false只有30%准确率
initialize_model给出你想要的模型名字和分类数,返回你想要的模型。
linear_prob:线性探测指设为true用于冻结大佬模型的参数,以后训练过程中参数不会在梯度下降中更新。
微调:linear_prob设为false则可以将大佬的参数在自己的训练集上继续进行训练,参与梯度下降同时也会变更大佬的参数值,这就是微调,此处为全量微调,后面bert项目中使用了lora微调

use_pretrained:表示是否使用大佬模型训练好的参数

迁移学习

迁移学习:调用了大佬的模型(大佬模型已上传至torch官方),采用resnet18,vgg等模型分别进行训练,如采用resnet18模型作为特征提取器进行预训练,固定其参数(迁移学习最重要的就是沿用大佬设定好的参数),取出他的分类头,再增加一层,输出到我的分类数,即在预测结果时修改最后一层全连接数(自己的目标分类)进行微调。
迁移学习的主要步骤包括:

  1. 选择预训练模型:选择一个在大规模数据集上预训练好的模型,例如在 ImageNet 数据集上预训练的卷积神经网络(CNN)模型。
  2. 特征提取:使用预训练模型的卷积层部分作为特征提取器,将输入数据转换为特征向量。这些特征向量可以作为新任务的输入。
  3. 微调:在新任务的数据集上,对预训练模型的部分或全部参数进行微调,以适应新任务的特定需求。微调可以包括调整模型的最后几层(全连接层),或者对整个模型进行微调。
  4. 评估和优化:使用新任务的验证集或测试集对微调后的模型进行评估,并根据评估结果进行优化,例如调整学习率、增加训练轮数等。

adamw优化器

1.采用自适应学习率优化器Adamw比随机梯度下降优化器SGD好很多,区别:梯度的改变,学习率的改变。相比SGD只看当前点的梯度,Adam不仅看当前点的梯度也看以前点的梯度。Adam会自动调整学习率,梯度大时学习率调小,反之调大,合适的学习率能加快收敛并避免震荡。故可以防止模型坍塌和梯度爆炸。
2.Adam 算法的核心在于自适应地调整每个参数的学习率。学习率可以理解为我们在调整参数时迈出的 “步子大小”。步子太大,可能会错过最优解;步子太小,又会让训练速度变得很慢。Adam 算法会根据每个参数的历史梯度信息,为它们动态地分配合适的学习率,就像给每个参数都配备了一个 “智能导航”,让它们能以合适的速度朝着最优解前进。W为权重衰减,让loss曲线更平缓, 训练过程更加稳定。
3.Adam(Adaptive Moment Estimation)原理:Adam 结合了 AdaGrad 和 RMSProp 的优点,它计算每个参数的自适应学习率。具体来说,它使用一阶矩估计(均值)和二阶矩估计(未中心化的方差)来动态调整每个参数的学习率。优点:收敛速度快,对不同类型的问题有较好的适应性,不需要手动调整学习率。缺点:可能会导致模型过拟合,特别是在使用 L2 正则化时。AdamW(Adam with Weight Decay Fix)原理:AdamW 是对 Adam 的改进,主要解决了 Adam 在使用 L2 正则化时的问题。在 Adam 中,L2 正则化和自适应学习率的结合可能会导致权重衰减的效果不理想。AdamW 通过将权重衰减直接应用到参数更新步骤中,避免了这个问题。优点:在使用 L2 正则化时表现更好,能有效防止模型过拟合。缺点:与 Adam 相比,可能需要更多的调参工作。

lr = 0.001
epochs = 15
loss = nn.CrossEntropyLoss()  # 交叉熵



# adam可以灵活调整参数变化,每次更新会综合历史的梯度和现在的梯度
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
device = "cuda" if torch.cuda.is_available() else "cpu"
save_path = "model_save/best_model_sim.pth"
threshold = 0.99
config = {
    'epochs': epochs,
    'loss': loss,
    'optimizer': optimizer,
    'device': device,
    'save_path': save_path,
    'threshold': threshold,
    'model': model,
    'train_loader': train_loader,
    'val_loader': val_loader,
    'no_label_loader': no_label_loader
}

weight_decay(权重衰减) 是一种常用的正则化技术,主要用于防止模型过拟合。它的核心作用是通过对模型权重施加约束,降低模型的复杂度,从而提高泛化能力。
L2正则化可以看作权重衰减,使用时要么在损失函数中自定义加入权重衰减(如第三章自定义的L2正则化损失函数),要么在优化器的参数中加入权重衰减weight_decay进行正则化。
weight_decay常见取值范围:
在这里插入图片描述

设定各种超参数,优化器,损失等内容,并将其上面设定的所有内容都封装到一个字典中,以便传入到训练函数中进行读取

lr是学习率,epochs是周期数,设置loss为交叉熵损失,上一篇文章说过概率值损失计算要使用交叉熵

设置adam优化器,设备,保存路径等。

if __name__ == '__main__':  # 如果运行的当前文件就执行,调用的话不执行
    train_val(config)

最后声明一个函数,训练和验证,如果不用config,就是要列很多很长的参数。

2,数据处理部分

2.1导入包

import numpy as np
from torch.utils.data import Dataset, DataLoader
import torch
import torch.nn as nn
import os
from torchvision.transforms import transforms  # 进行数据增强用
from tqdm import tqdm  # 展示循环进度
from PIL import Image  # 读取图片数据
from torch.utils.data.dataset import T_co

2.2数据集处理
放data文件。

先定义一个food_dataset数据集的类,同样主要包含三个方法init,getitem和len

class food_dataset(Dataset):
    def __init__(self, path, mode):
        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_transforms
        else:
            self.transform = val_transforms

先看init方法,先将mode传给自己,如果模式是semi半监督的话,只接受整理好的数据,如果不是的话要接受整理好的数据和标签,标签要转为长整型,因为交叉熵损失要求使用长整型数据,并转为张量。

下面如果训练模式下就用训练用的transforms函数,否则用验证用的transforms函数,这个就是数据增强函数的选择

# 要在每次读一个批次的数据时进行数据增强
# compose表示要进行多个数据变换,但要把它们合在一起
train_transforms = transforms.Compose(
    [
        transforms.ToPILImage(),  # 将大小为224*224的图片转为模型可以接受的3*224*224
        transforms.RandomResizedCrop(224),  # 随机放大再裁剪到224
        transforms.RandomRotation(50),  # 随机旋转50度以内
        transforms.ToTensor()  # 转为张量
    ]
)
val_transforms = transforms.Compose(
    [
        transforms.ToPILImage(),  # 224*224*3转为模型可以接受的3*224*224
        transforms.ToTensor()  # 转为张量
    ]
)

在class 外面整体定义好所需的数据增强内容,对于训练集我们要对数据进行一系列变换操作,比如旋转,裁剪,改灰度,色调,加噪点…等,为了使训练出来的模型能适应各种情况,同时扩充了数据集。这里我们只进行简单放大裁剪旋转。对于验证集不需要进行这些操作,直接读入调整维度并转为张量。

数据增强、图片增广

在训练模式下,使用训练集的图像变换操作。通过调用transform对一个预先定义好的图像变换函数或变换组合,用于对训练图像进行增强、归一化等操作包括训练和验证的数据增强,比如随机裁剪、旋转,或对图片进行亮度和色调的调整。torchvision.transforms 模块来进行图像数据增广。在训练集取数据的时候进行图片增广,但验证集取数据验证时没必要进行增广,会导致准确率降低

   def __getitem__(self, index) -> T_co:
        if self.mode == "semi":
            # transform过的用来模型进行检测,x用来及加入semi数据集
            return self.transform(self.x[index]), self.x[index]
        else:
            return self.transform(self.x[index]), self.y[index]  
    def __len__(self):  # x长度和y一样,都是一个图一个长度
        return len(self.x)

先看getitem和len,每次对于实例化的对象要读数据时候就会调用getitem这个方法,统一返回两个内容,len方法返回长度,创建dataloader时候会取长度,从定义好的loader对象取数据时候做for循环操作也会先访问len,之后调用batch_size次数getitem来读取数据。

最后是这个最麻烦的读入数据的函数

       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) #xi表示每一类的图片,所有图片读取列表长度,图片长宽以及深度(通道数)
            # 列出文件夹下所有文件名字
            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))
  #定义图片大小 hw为224因为输入的图片数据集大小不一致,有的是512*512所以要调整为3*224*224
                xi[j, ...] = img
            print("读到了%d个数据" % len(xi))
            return xi
        

先看训练和验证所用的数据处理:

  else:
        x, y = None, None
        for i in tqdm(range(11)):  # i也是标签
            # 文件夹是从00到10共11个,要格式化2位整数
            folder_path = path + r"\%02d" % i
            # 列出文件夹下所有图片文件名
            file_list = os.listdir(folder_path)
 
            # 创建一个arr,从上到下每个格里面放一个图片,用来存储一个类别的图片
            # unit8表示读成整数形式,图片的颜色数据没有小数
            xi = np.zeros((len(file_list), 224, 224, 3), dtype=np.uint8)
            # 创建一个arr,存一个类别的label
            yi = np.zeros(len(file_list), dtype=np.uint8)
 
            # 遍历
            for j, img_name in enumerate(file_list):
                # 将图片文件名和前置路径结合起来
                img_path = os.path.join(folder_path, img_name)
                img = Image.open(img_path)  # 使用PIL读取图片
                img = img.resize((224, 224))  # 重新将图片变成224*,训练用
                # ...表示后面的维度一样
                xi[j, ...] = img  # 存入图片数据
                yi[j] = i  # i也是标签
 
            if i == 0:  # 如果是第一个类别,直接定义赋值
                x = xi
                y = yi
            else:  # 不然就是将它们链接到x后面,axis=0是在竖着连接
                x = np.concatenate((x, xi), axis=0)
                y = np.concatenate((y, yi), axis=0)
        return x, y  # 返回所有图片/标签链接在一起的矩阵arr

先把x和y定义一个none不然编辑器总写警告

进行11次循环,分别取00到10文件夹里面的图片数据,先用os包读文件名列表。

(tqdm会显示一个循环进行轮数的进度条,可以方便查看读取进度)

然后定义xi和yi用来存储一个类别数据和对应标签。

在这里插入图片描述

xi就是上图这样的,竖着每个维度存一张图片,文件数=长度

yi就是简单的一维数据,每一个都存本次取得第几个文件夹号就行。

对于每一个文件夹对应的file_list,进行遍历,读取每个图片文件名,以及它是第几张图。读取图片并进行尺寸变更。最后将所有图片都拼接到一起,concatenate的0轴就是继续竖着链接,比如上面那个图就是后面加11,12…这样的。

   if self.mode == "semi":
        folder_path = path
        # 列出文件夹下所有图片文件名
        file_list = os.listdir(folder_path)
 
        # 创建一个arr,从上到下每个格里面放一个图片,用来存储一个类别的图片
        # unit8表示读成整数形式,图片的颜色数据没有小数
        xi = np.zeros((len(file_list), 224, 224, 3), dtype=np.uint8)
        for j, img_name in enumerate(file_list):
            # 将图片文件名和前置路径结合起来
            img_path = os.path.join(folder_path, img_name)
            img = Image.open(img_path)  # 使用PIL读取图片
            img = img.resize((224, 224))  # 重新将图片变成224*,训练用
            # ...表示后面的维度一样
            xi[j, ...] = img  # 存入图片数据
        return xi

对于semi的模式是类似的,semi不需要y标签,上面理解了这个就很简单了。

3,自定义模型

这个可看可不看,因为我们主要用别人训练好的模型
批归一化在神经网络的隐含层间进行BatchNormed
归一化的好处就是防止过拟合并加速模型收敛

class my_model(nn.Module):
    def __init__(self, num_class):
        super(my_model, self).__init__()
 #目标将3*224*224的图像卷积到512*7*7再拉直全连接
        self.layer1 = nn.Sequential(
            nn.Conv2d(3, 64, 3, 1, 1),  
            # padding为1,步长为1,然后经过64个3*3*3的卷积核得到特征图64*224*224
            nn.BatchNorm2d(64),  # 批归一化,要和输出的通道数保持一致
            nn.ReLU(),
            nn.MaxPool2d(2)  # 64*112*112
        )
        self.layer2 = nn.Sequential(
            nn.Conv2d(64, 128, 3, 1, 1),  # 128*112*112
            nn.BatchNorm2d(128),  # 归一化
            nn.ReLU(),
            nn.MaxPool2d(2)  # 128*56*56
        )
        self.layer3 = nn.Sequential(
            nn.Conv2d(128, 256, 3, 1, 1),  # 256 56 56
            nn.BatchNorm2d(256),  # 归一化
            nn.ReLU(),
            nn.MaxPool2d(2)  # 256 28 28
        )
        self.layer4 = nn.Sequential(
            nn.Conv2d(256, 512, 3, 1, 1),  # 512 28 28
            nn.BatchNorm2d(512),  # 归一化
            nn.ReLU(),
            nn.MaxPool2d(2)  # 512 14 14
        )
 
        self.pool = nn.MaxPool2d(2)  # 512 7 7
        self.flatten = nn.Flatten()  #拉直
        self.linear1 = nn.Linear(25088, 1000) #全连接
        self.relu = nn.ReLU()
        self.linear2 = nn.Linear(1000, num_class)

几个卷积层,每一层内容差不多,由3 * 224 * 224转变为512 * 7 * 7,然后拉直转为25088到1000的全连接,最后输出需要的类别数num_class。每一层都是一个卷积接批归一化,接一个激活函数然后进行一次池化来改变尺寸。 达到目标大小后,flatten拉直,然后全连接分类

def forward(self, x):
    x = self.layer1(x)
    x = self.layer2(x)
    x = self.layer3(x)
    x = self.layer4(x)
    x = self.pool(x)
    # x = x.view(x.size()[0], -1)
    x = self.flatten(x)
    x = self.linear1(x)
    x = self.relu(x)
    x = self.linear2(x)
    return x

前向过程。 让图片通过自己制定的模型

4,训练验证

4.1导入内容

import torch
import time
import matplotlib.pyplot as plt
import numpy as np
 
from data_sim import get_semi_loader
 
def train_val(config_hy):
    epochs = config_hy['epochs']
    loss = config_hy['loss']
    optimizer = config_hy['optimizer']
    device = config_hy['device']
    save_path = config_hy['save_path']
    threshold = config_hy['threshold']
    model = config_hy['model']
    train_loader = config_hy['train_loader']
    val_loader = config_hy['val_loader']
    no_label_loader = config_hy['no_label_loader']
   

导入包,定义函数,并把字典里面的东西取出来。

model = model.to(device)
semi_loader = None  # 初始化
# 分别记录每次loss
plt_train_loss = []
plt_val_loss = []
# 分别记录每次准确率
plt_train_acc = []
plt_val_acc = []
# 记录最大的acc
max_acc = 0.0

初始化变量。整体函数和之前写全连接的数据模型差不多(找6,训练验证函数)。增加一个记录准确率的列表,就是看预测正确多少个。

记录最大总的准确值max(正确样本计数),用来在后面保存最好的模型。(不除以数据个数)验证准确率>最大准确率时保存模型

4.2训练

for epoch in range(epochs):
    train_loss = 0.0
    val_loss = 0.0
    train_acc = 0.0
    val_acc = 0.0  # 预测成功的数量记录,预测成功就是1,失败就是0
    semi_loss = 0.0
    semi_acc = 0.0
    start_time = time.time()

先还是初始化这些内容。用来对每一个epoch的训练结果进行统计。
除了记录

  # 模型进入训练模式
    model.train()
    for batch_x, batch_y in train_loader:
        # 先把数据放到设备上
        x, target = batch_x.to(device), batch_y.to(device)
        pred_y = model(x)
        train_batch_loss = loss(pred_y, target)
        train_batch_loss.backward()
        optimizer.step()  # 更新模型,梯度回传
        optimizer.zero_grad()  # 模型的梯度清零
        # train_batch_loss是放在gpu上的,要把它放到cpu上,然后取出其数值item
 
        train_loss += train_batch_loss.cpu().item()
        train_acc += np.sum(np.argmax(pred_y.detach().cpu().numpy(), axis=1) == target.cpu().numpy())
 
    # train_loss是一个epoch里面所有批次的总loss,因此要除以批次的数
    plt_train_loss.append(train_loss / train_loader.__len__())
    plt_train_acc.append(train_acc / train_loader.dataset.__len__())  # 准确率绘制,对于一整个数据集来说的

每个批次开始前调用optimizer.zero_grad(),确保梯度仅反映当前批次的信息。这是为了防止当前批次的梯度与之前批次的梯度累积,导致参数更新错误。例如,在PyTorch中,每次调用loss.backward()后,梯度会累积到张量的.grad属性中。若不手动清零,后续批次的梯度会与之前的相加,导致模型无法正确学习当前批次的特征。

训练模型,先要调用train_loader的len方法读取总数据长度(个数),然后每一个循环调用getitem读取具体数据,返回给batch_x和batch_y。然后就是经典的放上gpu,经过模型,求每一批次的train_bat_loss,每一批次进行一次反向,并优化器更新模型,本批次梯度清零。

记录loss,item是PyTorch中Tensor类的一个方法,用于从只包含一个元素的张量中提取这个元素的值。

然后这个准确值计数器要在什么时候增加呢,就是当预测的和真值类别相同时候才会增加,pred_y.detach().cpu().numpy(), axis=1表示对预测值先进行分离操作避免带着梯度,然后放到cpu上,因为训练在显卡上,再转为数组,1轴就是竖着对每一个样本进行一次这样的操作。np.argmax表示取指定轴的最大值的索引,也就是预测最有可能的类别索引,如果这个值==真值的索引,那么就增加,==取值为true时就是1,也就是计数器加一。当然每次比较的内容是一个batch的。

将本次epoch的loss放入列表,这里除以的是trainloader的长度,即一个epoch中批次数(batch size)的总和。这样做是为了计算整个训练集上的平均损失。因为是将每一批次的平均值loss (train_bat_loss)累计在train_loss中,所以一轮结束后除以批次数总和可以得到平均每个样本的损失。

再将本次的准确率放入列表,准确率除的是数据集的样本数量,因为准确率是对于每一个图片预测正确与否而言的。train_acc累积的是每个批次内预测对的样本数量,结果即是一轮内预测对的样本数量。
train_loss和train_acc用于衡量本轮次的训练好坏,并不参与梯度下降模型更新

        if semi_loader:
...

先不看semi的部分,先看val部分(semi数据处理后面讲)

 # 将模型调为验证模式
    model.eval()
    with torch.no_grad():
        for batch_x, batch_y in val_loader:
            # 先把数据放到设备上
            x, target = batch_x.to(device), batch_y.to(device)
            pred_y = model(x)
            val_batch_loss = loss(pred_y, target)
            val_loss += val_batch_loss.cpu().item()
            # 求和,求满足条件的
            # pred.detach().cpu().numpy()取下转化为矩阵
            # np.argmax沿着横着x轴求最大值的下标,其中最大值表示模型预测结果是哪种类型的食物
            # 看预测的类型和实际真值是否一样,一样就加和
            val_acc += np.sum(np.argmax(pred_y.detach().cpu().numpy(), axis=1) == target.cpu().numpy())
 
    plt_val_loss.append(val_loss / val_loader.__len__())
    plt_val_acc.append(val_acc / val_loader.dataset.__len__())  # 准确率绘制,对于一整个数据集来说的


验证过程和训练的差不多,但不需要更新模型,不需要累积梯度

 if semi_loader:
        for batch_x, batch_y in semi_loader:
            # 先把数据放到设备上
            x, target = batch_x.to(device), batch_y.to(device)
            pred_y = model(x)
            semi_batch_loss = loss(pred_y, target)
            semi_batch_loss.backward()
            optimizer.step()  # 更新模型,梯度回传
            optimizer.zero_grad()  # 模型的梯度清零
            semi_loss += semi_batch_loss.cpu().item()
            semi_acc += np.sum(np.argmax(pred_y.detach().cpu().numpy(), axis=1) == target.cpu().numpy())
        print("半监督准确率为%.6f" % (semi_acc / train_loader.dataset.__len__()))

再来看半监督的,

# 如果当前轮次验证准确率达到一定值0.7,引入半监督数据,测试调试的话改成0.05
    # epoch % 5 == 0不要每次都取数据,浪费时间
    if plt_val_acc[-1] >= 0.7 and epoch % 5 == 0:
        semi_loader = get_semi_loader(no_label_loader, model, device, threshold)
 
    # 保存最好的模型,准确度最大的模型
    if val_acc > max_acc:
        max_acc = val_acc
        torch.save(model, save_path)
 
    print("[%02d/%02d] %2.2f s TrainLoss:%.6f ValLoss:%.6f TrainAcc:%.6f ValAcc:%.6f" \
          % (epoch, epochs, time.time() - start_time, plt_train_loss[-1], plt_val_loss[-1], plt_train_acc[-1],
             plt_val_acc[-1]))
 
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("acc")
plt.legend(["train", "val"])
plt.show()

如果某一轮训练过程中模型够好的话,如最近一轮的验证准确率设为大于0.7的情况下就启用获取semiloader获取半监督数据,如果semiloader不为空即代表有达到置信值的无标签数据被获取,则对semiloader中的数据也进行一次训练,参与模型更新。并且不要每次都去看一下是否能取到,不然会增加不必要的时间消耗。

semiloader不为空则参与训练:如下代码块

 
        if semi_loader!= None:
            for batch_x, batch_y in semi_loader:
                x, target = batch_x.to(device), batch_y.to(device)
                pred = model(x)
                semi_bat_loss = loss(pred, target)
                semi_bat_loss.backward()
                optimizer.step()  # 更新参数 之后要梯度清零否则会累积梯度
                optimizer.zero_grad()
                semi_loss += semi_bat_loss.cpu().item()
                semi_acc += np.sum(np.argmax(pred.detach().cpu().numpy(), axis=1) == target.cpu().numpy())
            print("半监督数据集的训练准确率为", semi_acc/semi_loader.dataset.__len__())

在第五轮时验证准确率达到61%,所以获取semiloader,并在第六轮用到了获取的无标签数据进行了训练预测,半监督数据集的准确率 达到83%
在这里插入图片描述

最后保存最好的模型,并输出画图。

半监督学习

1.一部分数据有标签,另一部分无标签。为了节约数据,对于无标签的数据通过模型进行预测,当预测后的置信度高于0.9时,用预测值当作真实标签,将无标签数据归入有标签数据进行训练。
2.流程,
①经过一定轮次训练后,在验证时,得到模型准确率大于0.6时(模型准确率足够高才可进行半监督训练):
②则取无标签数据X经过模型,得到predY和acc,若该X对应的标签的“概率"(soft(predY))大于一定的数,则该图片有效,将X和predY一起加入新的数据集semi_data ,只存放半监督打上标签的数据。
③如果取得了半监督学习后的带标签图片,则这也图片也可以继续用于训练。
3.具体实现:自己训练的11类,准确率只有0.09(图片数量少,和随机猜测差不多了)。使用了resnet18后,准确率到达了0.6。

5,半监督数据处理

放data文件。

为将没有标签的数据也能在训练中使用上,不浪费数据。

def get_semi_loader(no_label_loader, model, device, threshold):
    # 获取半监督数据集
    semi = semi_dataset(no_label_loader, model, device, threshold)
 
    if not semi.flag:
        # 如果是空的,训练还达不到水平
        return None
    else:
        return DataLoader(semi, batch_size=8, shuffle=True)

接上面准确率大于0.7那里,调用的函数,获取半监督数据集,如果没有成功获取,就返回空,这个后面说,否则就返回一个dataloader包装的数据集叫做semi_loader.

然后实例化一个semi数据集,semi_dataset用于存放打上标签的半监督数据。
进入init

class semi_dataset(Dataset):
    def __init__(self, no_label_loader, model, device, threshold=0.99):
        x, y = self.get_label(no_label_loader, model, device, threshold)
        if not x:
            self.flag = False
        else:
            self.flag = True
            self.xarr = np.array(x)
            self.yt = torch.LongTensor(y)
            self.transform = train_transforms

先调用自身的get_label方法,如果没获取到x的数据就使得flag为假,返回到get_semi_loader函数判断,否则数据转为数组,标签转为长整型的张量,使用训练的数据变换函数处理,调用时候通过下面的getitem方法返回批次的数据。

   def get_label(self, no_label_loader, model, device, threshold=0.99):
        model = model.to(device)
        # 让数据通过模型
        # 预测概率列表
        pred_prob_list = []
        # 预测标签(第几类)列表
        labels_list = []
        x = []
        y = []

get_label方法,初始化数组。

  with torch.no_grad():
        for bat_x, p in no_label_loader:
            bat_x = bat_x.to(device)
            pred = model(bat_x) #得到预测标签
            # 经过softmax得到
            soft = nn.Softmax()
            pred_soft = soft(pred)
            # 取最大值,既能返回最大值,也能返回最大值下标,1表示横着的最大值
            pred_max, pred_value = pred_soft.max(1)
            # 每个批次的内容存入
            pred_prob_list.extend(pred_max.cpu().numpy().tolist())
            labels_list.extend(pred_value.cpu().numpy().tolist())

无标签数据no_label_dataset在semi_dataset中要通过模型预测进而打标签,这个过程对训练模型没有意义,所以不能累计梯度,取一批无标签数据,通过模型预测一个标签,经过softmax以后转化为概率输出,取预测的最大值和下标,放入列表,对应好标签。

所有数据都经过一次模型以后。

for index, prob in enumerate(pred_prob_list):
    if prob >= threshold:  # 调试时手动将threshold写为0.1就行
        # get_item方法,这个方法返回两个参数,因此要取一个
        # 取到数据集对应内容,1是原始图片
        x.append(no_label_loader.dataset[index][1])
        #
        y.append(labels_list[index])
 
return x, y

判断一下计算的结果数据,如果大于设定的门限值,才能被使用,放入x中,如果预测不准确的结果也拿来用了,可能还会对训练产生反作用,这样不如不用半监督。
注:无标签数据集no_label_dataset进行预测的时候,图片不进行增广,即使用的transform与验证集一致,图片输入模型后若达到置信值则将原始的无标签图片加入semi_dataset,无标签数据集只参与训练,不参与验证和测试

 def __getitem__(self, item):
        return self.transforms(self.xarr[item]), self.yt[item]
 
    def __len__(self):
        return len(self.xarr)

循环调用getitem时,返回变换过的x和y标签。

总结

主文件预处理:导入所需库,设定随机种子保证可复现性,定义数据路径,划分代码结构,初始化模型、优化器、损失函数等超参数。

数据处理:通过自定义food_dataset类加载图像数据,支持训练、验证和半监督模式。对训练数据进行增强(如随机裁剪、旋转),并将数据转换为张量格式,使用DataLoader进行批次加载。

模型定义:支持自定义CNN模型或使用预训练模型(如SqueezeNet)进行迁移学习,修改全连接层输出类别数,并允许微调参数。

训练与验证:在训练过程中计算损失和准确率,支持半监督学习,当验证准确率超过阈值时,利用无标签数据生成高置信度伪标签扩充训练集。记录损失和准确率曲线,保存最佳模型。

半监督数据处理:通过模型预测无标签数据的伪标签,筛选高置信度样本加入训练集,进一步提升模型性能。

结果可视化:绘制训练和验证的损失、准确率曲线,直观展示模型性能变化。

遇到的问题

在一开始训练的时候,验证集准确率过低,等到训练至中间轮次时准确率来到了65,开启半监督,但随后的轮次验证准确率一直下降至四十左右,训练集和半监督数据集准确率却一直上升,发生了过拟合现象
解决方法:
将batchsize调整至32,lr学习率调整至0.0001
使得模型一开始验证的准确率就十分高
在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐