本文的任务是实现对于食品数据集的分类,数据集来源:https://www.kaggle.com/datasets/zhaopang/ml2021springhw3/data
1. 理论概述:
此项目的数据集分为training,testing,validation,而training训练集中又分为labeled、unlabeled两部分,对于labeled带标签的数据我们使用监督学习的方式,而对于unlabeled的数据集我们则用半监督学习(Semi-Supervised Learning)的方式训练。在监督学习中,既有x(输入特征),y(目标标签,即模型需要预测的输出值);而在半监督学习中,我们仅有x,该如何训练模型呢?此时我们通过某种打标签的方式(伪标签)将无标签数据经过模型的预测值作为我们的y,并且值大小要超过我们所规定的参数:thres(置信度)。只有这样的数据被才视为可信数据,传入semi_dataset数据集中进行半监督学习的模型训练。
值得注意的是,对于分类任务的输出,也就是是或不是某类的问题,我们采取独热编码(one-hot)的形式将y由一离散值转化为连续的概率分布,而对于模型的输出我们使用softmax函数转换为概率分布,进而实现统一的数据处理
- 真实标签:
[0, 0, 1, 0]
(one-hot 编码)。 - 模型输出:
[0.1, 0.2, 0.6, 0.1]
(Softmax 输出)。
同时,对于模型性能好坏的评估,我们则采用交叉熵损失函数(cross entropy)作为loss来进行衡量。
下面我们再介绍可能会用到的基本概念:
A.我们知道,图片实际上就是一堆像素点的集合,天然为矩阵。拿彩色图片举例,每个像素的值就是一个向量,表示颜色通道:也就是我们常说的RGB。由此,彩色图片可以表示为一个3维矩阵,即为(C,H,W)(通道数,高度,宽度)
B.对于高分辨率的图片,全连接层的参数数量会非常庞大,导致计算与存储开销大;同时,若我们考虑flatten展平此图片,部分空间结构信息也可能丢失。对此,我们引入了卷积操作。(convolution)
直观感受一下卷积的效果:(卷积操作是通过卷积核在输入图片上滑动,每次计算卷积核覆盖区域与卷积核的 逐元素乘积,然后将所有乘积结果 求和,得到输出特征图的一个值。)
输入图片
[
[1, 0, 1],
[0, 1, 0],
[1, 0, 1]
]
卷积核
[
[1, 0],
[0, -1]
]
输出结果
[
[0, 0],
[0, 0]
]
我们由一个3*3的图片得到了2*2的图片,有效降低了特征图的尺寸(若考虑维持特征图的尺寸,则可以考虑通过zeropadding操作,外圈补零)。所以说,我们可以考虑通过卷积操作之后再flatten来有效的降低参数数量
C.继B往后说,如果我们引入卷积操作后依然参数过多(给出卷积层的参数量计算公式为(H*W*C_in+1)*C_out,其中1为偏置参数,H、W为卷积核的高度与宽度,C_in、C_out为输入特征图与输出特征图的通道数),此时我们则考虑1.扩大卷积步长。我们给出一个卷积尺寸的计算公式:O = (I - K+2P) / S + 1,(O为输出特征图的尺寸,I为输入特征图的尺寸,K则是卷积核的大小,S为步长,P则为padding补零的次数)。可惜的是,此方法计算复杂且可能会丢失信息。故我们常考虑 2. 池化(Pooling),其含义大概是从一个矩阵中选取一个代表数来表征信息。池化有2种,最大池化(Max)与平均池化(Avg),我们这里考虑最大池化,每次操作后减半特征图的尺寸
由A、B、C,我们引出分类任务中一个重要的概念:卷积神经网络(CNN),其结构如下:
- 卷积层:提取局部特征。多个卷积核卷,改变C(通道数)
- 激活层:引入激活函数(如 ReLU)。
- 池化层:减少特征图的空间尺寸,降低计算量。改变H,W(高宽)
- 全连接层(FC):将提取的特征映射到输出类别。应用linear(A,B)操作,将向量从A维度改变至B维度
- 应用softmax函数得到模型输出
- 计算5.中结果与y的交叉熵损失得到loss,更新网络参数,训练模型
简而言之,图片分类模型的训练流程就是:将输入图片经过CNN提取特征,再经由分类头得到分类结果,并同标签计算交叉熵损失,更新模型
值得注意的是,CNN中还有两种常用的技术:Dropout、Batch Norm:
批归一化层:对于每个小批量的数据进行归一化,加速训练,提高模型稳定性。
Dropout 层:在训练过程中,随机丢弃一部分神经元(输出置为0),防止过拟合
2. 进入代码的编写,还是熟悉的导入相关函数库
import random
import torch
import torch.nn as nn
import numpy as np
import os
from PIL import Image #处理图像数据
from torch.utils.data import Dataset, DataLoader #加载数据
from tqdm import tqdm #显示进度条
from torchvision import transforms #数据变换
import time
import matplotlib.pyplot as plt
from model_utils.model import initialize_model
3.确保实验的可重复性与初始化:
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) #固定随机种子为0
###############################################
HW = 224 #设定图像默认尺寸为244*244
4. 引入数据增广,提高模型泛化能力
数据增广有多种方式,如旋转、翻转、裁剪、缩放、改变灰度......
#引入数据增广,防止过拟合
train_transform = transforms.Compose( #训练集的数据变换
[
transforms.ToPILImage(), #224, 224, 3模型 :3, 224, 224
transforms.RandomResizedCrop(224), #随机裁切
transforms.RandomRotation(50), #随机变换
transforms.ToTensor() #将PIL图像化为tensor张量
]
)
val_transform = transforms.Compose( #验证集的数据变换
[
transforms.ToPILImage(), #224, 224, 3模型 :3, 224, 224
transforms.ToTensor()
]
)
5.数据预处理:
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)) #调整图片大小为默认的224*224
xi[j, ...] = img #存图
print("读到了%d个数据" % len(xi))
return xi
else:
for i in tqdm(range(11)): #tqdm显示进度
file_dir = path + "/%02d" % i #文件路径
file_list = os.listdir(file_dir) #列出文件夹下所有文件的名称
xi = np.zeros((len(file_list), HW, HW, 3), dtype=np.uint8)
yi = np.zeros(len(file_list), dtype=np.uint8)#初始化存标签的数组
# 列出文件夹下所有文件名字
for j, img_name in enumerate(file_list):
img_path = os.path.join(file_dir, img_name) #图片路径
img = Image.open(img_path)
img = img.resize((HW, HW))
xi[j, ...] = img
yi[j] = i #存标签
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)
class semiDataset(Dataset):
def __init__(self, no_label_loder, model, device, thres=0.99):
x, y = self.get_label(no_label_loder, model, device, thres) #获取标签
if x == []:
self.flag = False
else:
self.flag = True
self.X = np.array(x)
self.Y = torch.LongTensor(y)
self.transform = train_transform
def get_label(self, no_label_loder, 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_loder:
bat_x = bat_x.to(device)
pred = model(bat_x)
pred_soft = soft(pred) #softmax
pred_max, pred_value = pred_soft.max(1) #获取每个样本的最大概率及其对应下标
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:
x.append(no_label_loder.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)
6.数据加载
def get_semi_loader(no_label_loder, model, device, thres):
semiset = semiDataset(no_label_loder, model, device, thres)
if semiset.flag == False: #探测是否有有效样本
return None
else:
semi_loader = DataLoader(semiset, batch_size=16, shuffle=False)
return semi_loader
7.定义模型
这是自己定义的训练模型,下面还会用到大佬的模型:
class myModel(nn.Module):
def __init__(self, num_class):
super(myModel, self).__init__()
#3 *224 *224 -> 512*7*7 -> 拉直 -> 全连接分类
self.layer0 = nn.Sequential(
nn.Conv2d(3, 64, 3, 1, 1), # 64*224*224
nn.BatchNorm2d(64), # 批归一化
nn.ReLU(), # 激活函数
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.layer0(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.pool2(x)
x = x.view(x.size()[0], -1) #flatten
x = self.fc1(x)#全连接
x = self.relu2(x)#激活
x = self.fc2(x)
return x
8.模型训练:
def train_val(model, train_loader, val_loader, no_label_loader, device, epochs, optimizer, loss, thres, save_path):
model = model.to(device)
semi_loader = None
plt_train_loss = [] #训练损失
plt_val_loss = [] #验证损失
plt_train_acc = [] #训练准确率
plt_val_acc = [] #验证准确率
max_acc = 0.0
for epoch in range(epochs):
train_loss = 0.0
val_loss = 0.0
train_acc = 0.0
val_acc = 0.0
semi_loss = 0.0
semi_acc = 0.0
start_time = time.time()
model.train() #模型为训练模式
for batch_x, batch_y in train_loader:
x, target = batch_x.to(device), batch_y.to(device)
pred = model(x)
train_bat_loss = loss(pred, target)
train_bat_loss.backward()
optimizer.step() # 更新参数 之后要梯度清零否则会累积梯度
optimizer.zero_grad()
train_loss += train_bat_loss.cpu().item()
train_acc += np.sum(np.argmax(pred.detach().cpu().numpy(), axis=1) == target.cpu().numpy()) #累计准确的个数
plt_train_loss.append(train_loss / train_loader.__len__())
plt_train_acc.append(train_acc/train_loader.dataset.__len__()) #记录准确率,
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__())
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 = model(x)
val_bat_loss = loss(pred, target)
val_loss += val_bat_loss.cpu().item()
val_acc += np.sum(np.argmax(pred.detach().cpu().numpy(), axis=1) == target.cpu().numpy())
plt_val_loss.append(val_loss / val_loader.dataset.__len__())
plt_val_acc.append(val_acc / val_loader.dataset.__len__())
if epoch%3 == 0 and plt_val_acc[-1] > 0.6: #每3轮进行加一次数据
semi_loader = get_semi_loader(no_label_loader, model, device, thres)
if val_acc > max_acc:
torch.save(model, save_path)
max_acc = val_loss
print('[%03d/%03d] %2.2f sec(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])
) # 打印训练结果。 注意python语法, %2.2f 表示小数位为2的浮点数, 后面可以对应。
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()
9.实例化参数与模型
train_path = r"D:\project1\food_classification\food-11_sample\training\labeled" #选用样例数据集训练,本地跑的更快点
val_path = r"D:\project1\food_classification\food-11_sample\validation"
no_label_path = r"D:\project1\food_classification\food-11_sample\training\unlabeled\00"
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=16, shuffle=True)
val_loader = DataLoader(val_set, batch_size=16, shuffle=True)
no_label_loader = DataLoader(no_label_set, batch_size=16, shuffle=False)
# model = myModel(11)
model, _ = initialize_model("vgg", 11, use_pretrained=True) #迁移学习
lr = 0.001 #学习率
loss = nn.CrossEntropyLoss() #交差熵损失
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) #Adamw优化
device = "cuda" if torch.cuda.is_available() else "cpu"
save_path = "model_save/best_model.pth"
epochs = 15
thres = 0.99 #置信度
train_val(model, train_loader, val_loader, no_label_loader, device, epochs, optimizer, loss, thres, save_path) #训练
值得注意的是,这里我并没有调用自己的模型,而是选择使用了迁移学习,调用了大佬的模型(效果好)
迁移学习:
- 在一个 源任务(Source Task) 上训练模型。
- 将学到的知识(如模型参数、特征表示等)迁移到 目标任务(Target Task) 中。
- 在目标任务上进一步微调或直接应用模型(线性探测)。
迁移学习常用作医学影像、图像分类、自然语言处理(nlp)领域
10.测试结果:
由trainning数据集中带标签的子集可知,我们大概要分出11类,而我们的预测准确率却仅有0.09,也就是相当于11个里面随机猜1个是不是对应的真实类。
造成这个现象的原因主要是由于我在模型训练过程中用的是sample的数据集,训练样本太小,进而导致分类准确率不高。考虑应用food11的大数据集,并尝试应用不同的CNN模型,如:resnet18、AlexNet、VGG以及自己设计的模型,对比性能后有以下结果:
Resnet18:
VGG:
AlexNet:
自己的模型:
对比可知,AlexNet模型训练效果最差,和sample数据集时准确率竟然差不多;表现最好的是ResNet18模型,达到了60%的验证准确率。
------晚点这里放个调整超参数后的结果,望记得
11.卷积神经网络的发展历程:
LeNet:最早的CNN之一,用于手写数字识别
AlexNet(开创性)创新点:
A.应用了ReLU激活函数
B.drop out 舍弃操作,缓解过拟合
C.池化
VGG: 应用了小卷积核代替大卷积核,使CNN更深更大,进而提升了模型能力
ResNet,创新点:
A.1*1卷积:减少了参数量
B.残差连接,防梯度消失,数学上表示为: y=F(x)+x,意为在神经网络中,将某一层的输入直接加到后续层的输出上
C.批归一化,加速了模型训练过程
12. 总结与其他:
1.深度学习 = 玩特征,数据特征的处理非常重要。同时望自己对于此文常看常新
2.要注意数据不平衡问题,考虑从两方面入手解决,一方面考虑过采样、欠采样、SMOTE解决问题,另一方面考虑引入F1-Score、召回率(减少假阴性的影响)、精确率(减少假阳性的影响)等分类任务常用的评价指标