CNN 卷积神经网络食物分类任务
- 前言
- 一、数据集介绍
- 二、CNN模型整体框架
- 三、卷积神经网络代码详解
- 3.1 导入需要使用的包
- 3.2 数据集,数据加载器,数据增强操作
- 3.2.1 数据增强
- 3.2.2 数据集构建
- 3.2.3 加载器构建
- 3.3 卷积神经网络构建
- 3.4 训练代码
- 3.4.1 半监督训练
- 3.4.2 模型训练验证代码
- 3.5 训练结果可视化
- 3.6 训练结果可视化
- 总结
前言
深度学习领域中,卷积神经网络(CNN)是一个绕不开的重要主题。本章节旨在深入探讨和实践CNN相关的概念和知识点,将依据李宏毅教授在2021年机器学习课程中的第三次作业代码作为例子。通过这一实例,读者将有机会加深对CNN的理解,并提升自己的实践技能。
需要指出的是,本章节中展示的代码并未直接采用课程提供的baseline示例。这是因为基础模型的性能有限,而且与传统的深度神经网络(DNN)代码相比,并不提供更多的学习内容。因此,自行选择了一套演示代码,旨在提供更深入的学习和探索机会。对于有兴趣进一步研究的读者,推荐参考原始代码。
通过本章节的学习,希望帮助读者不仅掌握卷积神经网络的理论基础,还能通过亲手实践,有效地加深对这一强大工具的理解和应用能力。
一、数据集介绍
- 食物分类数据集
- 图像来自被分为11类的food-11数据集。
- 这里的数据集有所修改:
- 训练集:280*11张有标签的图像 + 6786张无标签的图像
- 验证集:60*11张有标签的图像
- 测试集:3347张图像
因为测试部分需要使用kaggle进行上传测试故此,本章节不会对测试代码进行讲解,具体感兴趣的同学可以移步Kaggle进行上传测试
为了规范化地访问数据,数据集被组织如下所示:同一类别的图片被放置在相同的文件夹内,同时测试集和验证集的图片也已被完整地分配到不同的文件夹中以便于调用:
二、CNN模型整体框架
借助之前章节的学习,读者对深度学习代码的整体框架已有充分的了解。因此,本章节的框架结构图仅对重要部分进行了简明划分。在随后的代码详解部分,将针对各个部分进行深入讲解。框架的整体布局是按照编码的逻辑顺序排列的。如果读者在详细分析各个部分时对整个流程感到困惑,可以回顾本节,以便更好地理解代码的整体逻辑。
三、卷积神经网络代码详解
3.1 导入需要使用的包
先来看下模型所需要的使用的库函数,本章节对之前文章中未出现的库函数进行讲解。
# 常规的库函数
import numpy as np
import torch
import torch.nn as nn#针对当前部分引入新的库函数
import torchvision
import torchvision.transforms as transforms
from PIL import Image# "ConcatDataset" and "Subset" are possibly useful when doing semi-supervised learning.
from torch.utils.data import ConcatDataset, DataLoader, Subset, Dataset
from torchvision.datasets import DatasetFolder
# 进度条
from tqdm.auto import tqdm
本章节出现了新的库函数如下:
import torchvision
torchvision独立于pytorch,专门用来处理图像,通常用于计算机视觉领域。
import torchvision.transforms as transforms
transforms 是一个函数,主要作用就是对用于图像预处理和数据增强。transforms 通常与 transforms.Compose 配合使用,transforms.Compose 是一个组合类,它可以将多个图像变换操作组合在一起。
举个例子:
from torchvision import transforms# 定义图像预处理操作
transform = transforms.Compose([transforms.Resize(256), # 缩放图像,使较短的边为256像素transforms.CenterCrop(224), # 从图像中心裁剪出224*224大小的图像transforms.ToTensor(), # 将图像转换为Tensor,归一化至[0,1]transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) # 标准化处理
])# 然后你可以使用这个 `transform` 来处理你的图像数据
# 例如:
# image = Image.open("path_to_your_image.jpg") 当前步骤实例化一个图像的对象,送入transform进行处理
# image = transform(image)
from PIL import Image
主要的作用Image模块是Pillow库的核心模块,用于图像的加载、处理和保存。通过Image模块的对Image.open打开文件,实例化图片,通过对象的方法实现对图像的改变。
from PIL import Image# 加载图像文件
image = Image.open("example.jpg")# 显示图像
image.show()# 对图像进行一些处理
# 比如旋转90度
rotated_image = image.rotate(90)
# 或者将图像转换为灰度
greyscale_image = image.convert("L")
# 又或者调整图像的大小
resized_image = image.resize((300, 300))
from torch.utils.data import ConcatDataset, DataLoader, Subset, Dataset
ConcatDataset 是一个 PyTorch 数据集类,它用于组合多个具有相同的字段(如图像和标签)的数据集。
dataset1 = CustomDataset('path/to/dataset1')
dataset2 = CustomDataset('path/to/dataset2')
# Concatenate datasets
combined_dataset = ConcatDataset([dataset1, dataset2])
Subset 类用于从一个大数据集中选取特定的子集。
# Assuming dataset is a large dataset and you want to select a subset of it
subset_indices = [10, 20, 30, 40, 50] # Just as an example
subset = Subset(dataset, subset_indices)
from torchvision.datasets import DatasetFolder
torchvision.datasets.DatasetFolder 是 PyTorch torchvision 库中一个非常实用的类,它允许快速且轻松地按照特定的文件结构组织和加载数据集,针对那些已经以一定格式存放的数据集特别有用。 这种方式可以大大减少手动编写用于加载数据集的代码量,尤其是当数据集已经按照一定的目录结构组织时。
具体的格式如下:
一目了然,就是将相同类别的数据存储到一个文件夹中,这样就能够使用该类进行数据集好的快速创建。
3.2 数据集,数据加载器,数据增强操作
3.2.1 数据增强
在深度学习特别是图像处理领域中,数据增强(Data Augmentation)是一个非常关键的技术,它可以增加数据集的多样性,减少模型对特定形式输入数据的依赖,从而提高模型的泛化能力。
通过应用各种随机变换,数据增强能够从现有数据集生成新的训练样本,这些变换包括但不限于旋转、平移、缩放、裁剪、翻转、调整亮度/对比度等。transforms 模块提供了许多此类操作,可以方便地用于图像数据的增强。
# It is important to do data augmentation in training.train_tfm = transforms.Compose([transforms.RandomResizedCrop((128, 128)),# 随机从图中裁切一部分,并将裁减的部分的大小限定在128,128。增加随机行模型能够得到更多的训练样本transforms.RandomChoice( # 在下面的几种方式中随机选择一种进行
'''
简单点说就是在不同的数据集上得到的数据增强的技术:1. **`transforms.AutoAugment()`:使用默认的 AutoAugment 策略。如果不指定任何策略
2. ,那么将使用 ImageNet 数据集上实验确定的最佳增强策略。这是一种通用的增强策略,适用于多种图像分类任务。3. **`transforms.AutoAugment(transforms.AutoAugmentPolicy.CIFAR10)`**:
4. 使用为 CIFAR10 数据集特别设计的 AutoAugment 策略。CIFAR10 包含10个类别的小尺
5. 寸彩色图像(32x32像素)。为这个数据集定制的策略考虑到了其特定的特性,如图像尺寸和类型的多样性。6. **`transforms.AutoAugment(transforms.AutoAugmentPolicy.SVHN)`**:使用为
7. 街景门牌号(SVHN, Street View House Numbers)数据集特别设计的 AutoAugment 策略
8. 。SVHN 是现实世界的数字识别数据集,图像来源于谷歌街景的门牌号。因此,这项策略是针对识别数字类别的图像进行优化的。'''[transforms.AutoAugment(),transforms.AutoAugment(transforms.AutoAugmentPolicy.CIFAR10),transforms.AutoAugment(transforms.AutoAugmentPolicy.SVHN)]),transforms.RandomHorizontalFlip(p=0.5), #以 50% 的概率水平翻转图像。这种随机性增加了训练数据的多样性。transforms.ColorJitter(brightness=0.5), #随机改变图像亮度,亮度参数 brightness=0.5 提供了亮度变化的强度。transforms.RandomAffine(degrees=20, translate=(0.2, 0.2), scale=(0.7, 1.3)),# 应用随机仿射变换:
# degrees=20 表示随机旋转的角度范围为 [-20, 20] 度。
# translate=(0.2, 0.2) 允许平移至多达图像宽度和高度20%的大小。
# scale=(0.7, 1.3) 指定缩放比例范围,在原始大小的基础上进行放大或缩小。transforms.ToTensor(), #将 PIL 图像 FloatTensor,并将图像的像素值从 [0, 255] 范围缩放到 [0.0, 1.0] 范围。
])# 并不需要对测试和验证集的数据进行增强
# 需要将所有的图片的大小进行设定,并且转换成tensor类型
test_tfm = transforms.Compose([transforms.Resize((128, 128)),transforms.ToTensor(),
])
通过各种各样的手段对数据进行处理,从而在有限的数据集上能够提供更多的样本给模型进行训练,从而提升模型的性能。
3.2.2 数据集构建
DatasetFolder("food-11/training/labeled", loader=lambda x: Image.open(x), extensions="jpg", transform=train_tfm)
当前主要是针对DatasetFolder这个类实现进行实例化,创建数据集,前提是按照上文中描述的数据集的格式是按照DatasetFolder类的要求进行创建的。
举个例子解释一下,要创建数据集首先就要知道数据集的位置,故此第一个参数是数据集的根目录,然后就是读取数据文件,使用Image.open(x)方法进行打开,而loader作为加载器是会接收到位置信息的,故此这个匿名函数lambda就可以正常运行,为了防止访问到其他非图片数据因此要限定访问数据的格式,在得到了数据对象后,应用上文讲解的数据增强技术对数据进行处理,最终得到数据集对象。由此也可以看到torchvision.Composs和DataFolder的配套使用的。
train_set = DatasetFolder("food-11/training/labeled", loader=lambda x: Image.open(x), extensions="jpg", transform=train_tfm)
valid_set = DatasetFolder("food-11/validation", loader=lambda x: Image.open(x), extensions="jpg", transform=test_tfm)
unlabeled_set = DatasetFolder("food-11/training/unlabeled", loader=lambda x: Image.open(x), extensions="jpg", transform=train_tfm)
test_set = DatasetFolder("food-11/testing", loader=lambda x: Image.open(x), extensions="jpg", transform=test_tfm)
3.2.3 加载器构建
# Construct data loaders.
train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=2, pin_memory=True)
valid_loader = DataLoader(valid_set, batch_size=batch_size, shuffle=True, num_workers=2, pin_memory=True)
test_loader = DataLoader(test_set, batch_size=batch_size, shuffle=False)
# 和前文一样故此不在过多赘述,值得注意就是测试集不打乱数据
3.3 卷积神经网络构建
class Classifier(nn.Module):def __init__(self):super(Classifier, self).__init__()# torch.nn.Conv2d(in_channels输入通道个数, out_channels输出通道个数, kernel_size卷积核大小, stride步长, padding是否填充)# torch.nn.MaxPool2d(kernel_size通道数, stride步长, padding填充)# input image size: [3, 128, 128]self.cnn_layers = nn.Sequential(nn.Conv2d(3, 64, 3, 1), # 卷积层nn.BatchNorm2d(64), # 批归一化nn.ReLU(),# 激活nn.MaxPool2d(2, 2, 0),# 池化层nn.Conv2d(64, 128, 3, 1),nn.BatchNorm2d(128),nn.ReLU(),nn.MaxPool2d(2, 2, 0),nn.Conv2d(128, 256, 3, 1),nn.BatchNorm2d(256),nn.ReLU(),nn.MaxPool2d(2, 2, 0),nn.Conv2d(256, 512, 3, 1),nn.BatchNorm2d(512),nn.ReLU(),nn.MaxPool2d(2, 2, 0),nn.Conv2d(512, 1024, 3, 1),nn.BatchNorm2d(1024),nn.ReLU(),nn.MaxPool2d(2, 2, 0))self.fc_layers = nn.Sequential(nn.Linear(4096, 1024),nn.BatchNorm1d(1024),nn.ReLU(),nn.Dropout(0.6),nn.Linear(1024, 256),nn.BatchNorm1d(256),nn.ReLU(),nn.Dropout(0.4),nn.Linear(256, 11))def forward(self, x):# input (x): [batch_size, 3, 128, 128]# output: [batch_size, 11]x = self.cnn_layers(x)x = x.flatten(1)x = self.fc_layers(x)return x
3.4 训练代码
3.4.1 半监督训练
简单的说,半监督训练本质上就是通过训练到一定程度的模型,对无标签数据进行标注,按照置信度判断是否作为训练数据,实现对训练数据的补充,从而依托更多的训练数据期望模型的性能上升。
在半监督训练框架下,模型首先使用少量的标记数据进行训练。一旦模型达到一定的性能水平,它就可以对未标记的数据进行预测,并给出每个样本的标签及其对应的置信度。然后,根据预先设定的置信度阈值,选择置信度高的预测结果将这些未标记的数据(现在带有预测标签)作为新的训练样本。通过这种方式,模型可以利用更多的数据,包括原始的标记数据和添加的预测标记数据,进行进一步的训练。这个循环可以按需重复进行,每一轮都可能提升模型对数据的理解和预测能力。
在实践中,这种方法的有效性很大程度上依赖于模型的初始性能以及如何选择置信度阈值来决定哪些未标记的样本被认为是可靠的训练数据。如果模型的初始预测准确性很高,那么利用未标记数据所带来的性能提升可能会更明显。相反,如果模型的初始预测性能较差,错误地标记大量未标记的数据可能会引起错误传播,进而影响模型的整体性能。
此外,半监督训练能够有效地缓解标记数据不足的问题,这在许多实际应用中是非常有价值的,因为手动标记大量数据往往是昂贵和耗时的。通过合理地利用未标记数据,半监督学习为学习更丰富和更泛化的模型提供了一条可行的路径。
代码部分
class PseudoDataset(Dataset): # 应用Dataset()类,构建数据集,可以通过,实例化过程中使用的都是模型预测的结果作为y,x就是图数据def __init__(self, x, y):self.x = xself.y = ydef __getitem__(self, id): # 构建索引和数据之间的联系return self.x[id][0], self.y[id]def __len__(self):return len(self.y)def get_pseudo_labels(dataset, model, threshold=0.9): # 当前函数调用PseudoDataset生成无标签的数据集device = "cuda" if torch.cuda.is_available() else "cpu" data_loader = DataLoader(dataset, batch_size=batch_size, shuffle=False) # 导入无标签的数据集生成数据加载器model.eval() # 模型开始评估模式softmax = nn.Softmax(dim=-1) '''
"torch的交叉熵损失函数本身就是具备softmax功能" 在使用交叉熵损失计算梯度时不需要再单独加 softmax 层,
但在预测类别时需要使用 softmax 来获取概率分布。'''idx = [] # 存放数据的索引,用于取出来数据labels = [] # 预测的结果作为标签for i, batch in enumerate(data_loader): # 数据分批导入img, _ = batch # 当前部分的全部类别都是00,有疑问可以看下数据集with torch.no_grad(): # 不会计算梯度logits = model(img.to(device)) # 送入模型预测probs = softmax(logits) #接入softmax层for j, x in enumerate(probs): # 对所有的预测结果进行编码,j是序号而x是预测结果if torch.max(x) > threshold: #比较向量中最大的值是否大于置信度idx.append(i * batch_size + j) # 这个就是索引信息,batchsize就是批次个数乘i就会得到每一个批次的第一个数值的索引编号加上j就是真实的索引信息。labels.append(int(torch.argmax(x))) # 这个就是类别最大的索引信息,x是一个向量取最大位置数值的索引作为真实标签model.train() # 模型开始训练模式print ("\nNew data: {:5d}\n".format(len(idx)))dataset = PseudoDataset(Subset(dataset, idx), labels) # Subset 是按照idx提供的索引列表,从dataset中选出部分数据形成新的数据集。return dataset
上述部分可以是本节最重要的内容,其他的其实都和之前的网络无太大差异
3.4.2 模型训练验证代码
这一部分的流程遵循了传统的模型训练与验证步骤,其核心目标是通过迭代更新参数以优化模型性能。在此过程中,模型首先在训练集上学习,然后通过验证集检验其泛化能力。不同的是,在这一阶段我们考虑了一种扩展策略:即是否引入半监督学习,也就是利用未标记数据来进一步增强模型的性能。
在这个上文中,半监督训练的实施为模型训练带来了新的维度。通过利用之前生成的带有伪标签的数据集,不仅使用了原始的有标记数据,还加入了额外的未标记数据,这些数据被模型预测并选择出高置信度的部分作为新的训练样本。这样的策略有助于模型从更多样化的数据中学习,尤其是在可用的标记数据较少时,这种方法尤为有价值。最终,半监督训练旨在通过扩大训练数据集的规模,提升模型对未见样本的识别能力。
通过这种灵活的训练策略,可以充分利用可用数据资源,进而朝着更加准确和鲁棒的模型性能迈进。综合使用传统训练方法和半监督训练策略,可为模型优化提供更全面的支持,从而在多个层面上提升模型性能。
device = "cuda" if torch.cuda.is_available() else "cpu"# Initialize a model, and put it on the device specified.
model = Classifier().to(device) # 实例化模型送入设备上
model.device = devicefrom torchsummary import summary
summary(model, input_size=(3, 128, 128), device=device)
#通过提供模型实例和输入数据的尺寸,summary 函数允许你快速了解模型的构造和大小
#这在设计、调整网络结构时非常有用。criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.0003, weight_decay=1e-5)
# optimizer = torch.optim.SGD(model.parameters(), lr=0.0003, momentum=0.9, weight_decay=1e-5)n_epochs = 500
do_semi = True # 是否使用半监督
model_path = "model.ckpt"best_acc = 0.0
train_loss_record = []
valid_loss_record = []
train_acc_record = []
valid_acc_record = []for epoch in range(n_epochs):if do_semi and best_acc > 0.7 and epoch % 5 == 0: # 使用半监督进行增强训练的条件pseudo_set = get_pseudo_labels(unlabeled_set, model) # 调用半监督函数 创建无标签数据集concat_dataset = ConcatDataset([train_set, pseudo_set]) # 将两个数据集进行拼接train_loader = DataLoader(concat_dataset, batch_size=batch_size, shuffle=True, num_workers=2, pin_memory=True, drop_last=True) # 得到新的数据集后构建增强数据集的加载器(原始训练集合加上无标签数据集)# ---------- Train ----------model.train()train_loss = []train_accs = []# for batch in tqdm(train_loader):for batch in train_loader:imgs, labels = batchlogits = model(imgs.to(device)) #模型预测loss = criterion(logits, labels.to(device)) #计算损失optimizer.zero_grad() #梯度清0loss.backward()# 计算梯度信息grad_norm = nn.utils.clip_grad_norm_(model.parameters(), max_norm=10)'''可以被视作一种梯度归一化的技术。它确保模型中所有参数的梯度范数不超过一个特定的最大阈值。如果梯度范数已经超过了这个阈值,那么会将梯度按比例缩小,以使得整体梯度的范数恰好等于最大阈值,这个过程也被称为梯度裁剪。通过这种方式,它可以防止在训练期间出现梯度爆炸问题,有助于保持训练过程的稳定性。在某些情况下,当模型极度复杂或是训练数据包含异常值时,梯度可能会变得极大,导致模型参数的大幅更新,从而影响模型的学习效果。通过梯度裁剪,可以限制梯度更新的步幅,使得每次参数更新更加温和,从而有助于稳定训练过程,提高模型的训练效率和最终性能。简而言之,梯度裁剪通过对梯度的大小进行限制,以实现对模型训练过程的一种归一化控制,帮助防止梯度过大导致的训练不稳定问题。'''optimizer.step() #按学习率更新acc = (logits.argmax(dim=-1) == labels.to(device)).float().mean()
# 本身就是一个二维的数据,因此要在最后一个维度找到最大值并取出索引(logits.argmax(dim=-1) ,和真实标签比对,计算均值作为准确率train_loss.append(loss.item())train_accs.append(acc)train_loss = sum(train_loss) / len(train_loss)train_acc = sum(train_accs) / len(train_accs)print(f"[ Train | {epoch + 1:03d} / {n_epochs:03d} ] loss = {train_loss:.5f}, acc = {train_acc:.5f}")# ---------- Validation ---------- 验证部分model.eval()valid_loss = []valid_accs = []# for batch in tqdm(valid_loader):for batch in valid_loader:imgs, labels = batchwith torch.no_grad():logits = model(imgs.to(device))loss = criterion(logits, labels.to(device))acc = (logits.argmax(dim=-1) == labels.to(device)).float().mean()valid_loss.append(loss.item())valid_accs.append(acc)valid_loss = sum(valid_loss) / len(valid_loss)valid_acc = sum(valid_accs) / len(valid_accs)print(f"[ Valid | {epoch + 1:03d} / {n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f}")# ---------- Record ----------if valid_acc > best_acc: # 在最好的准确率下保存模型用于测试best_acc = valid_acctorch.save(model.state_dict(), model_path)train_loss_record.append(train_loss)valid_loss_record.append(valid_loss)train_acc_record.append(train_acc)valid_acc_record.append(valid_acc)
3.5 训练结果可视化
import matplotlib.pyplot as plt # 导入matplotlib.pyplot模块用于绘图x = np.arange(len(train_acc_record)) # 创建一个与训练准确率记录长度相同的序列作为横坐标plt.plot(x, train_acc_record, color="blue", label="Train") # 以蓝色线条绘制训练准确率变化趋势,添加标签"Train"
plt.plot(x, valid_acc_record, color="red", label="Valid") # 以红色线条绘制验证准确率变化趋势,添加标签"Valid"
plt.legend(loc="upper right") # 显示图例,并将其放置在图的右上角
plt.show() # 显示图像
3.6 训练结果可视化
测试阶段代码,将结果保存一个文档。
model.eval()predictions = []for batch in test_loader:imgs, labels = batchwith torch.no_grad():logits = model(imgs.to(device))predictions.extend(logits.argmax(dim=-1).cpu().numpy().tolist())with open("predict.csv", "w") as f: # 保存预测结果f.write("Id,Category\n")for i, pred in enumerate(predictions):f.write(f"{i},{pred}\n")
总结
本章节深入浅出地探讨了使用PyTorch框架进行深度学习模型构建与训练的全流程,旨在帮助读者系统掌握从数据预处理、模型搭建、训练策略到验证评估的每一个环节。通过食物分类任务的实例,结合半监督学习这一高级训练策略,展示了提升模型性能的可能路径。这些内容的覆盖,旨在为读者搭建一个坚实的知识框架,让大家在深度学习的过程中稳健前行,还能灵活应对各种挑战。