PyTorch实现图像分类卷积神经网络(CNN)模型
本文中我们将探讨如何使用PyTorch构建简单的卷积神经网络(CNN)来对图像进行分类。
通过阅读完本文,大家将熟悉PyTorch、卷积神经网络、填充(padding)、步长(stride)、最大池化(max pooling)等概念,并能够自己构建CNN模型进行图像分类。
本文使用的数据集是Kaggle上的Intel图像分类数据集。
https://www.kaggle.com/datasets/puneet6060/intel-image-classification
本文目录:
数据集准备
为了训练模型,我们需要一个包含图像及其标签的数据集,但一般来说,用于图像分类的数据集是由存储在相应文件夹中的图像组成的。
例如,我们的数据集包含6种类型的图像,它们分别存储在对应的文件夹中。
PyTorch提供了ImageFolder类,可以轻松地根据这种结构准备数据集。
我们只需将数据的目录传递给它,它即可提供可用于训练模型的数据集。
import torch # 导入PyTorch库
import torchvision # 导入PyTorch的vision库,用于处理图像数据
from torchvision import transforms # 从vision库中导入transforms模块,用于图像预处理
from torchvision.datasets import ImageFolder # 从vision库的datasets中导入ImageFolder类,用于加载文件夹中的图像数据
# 训练数据和测试数据的目录
data_dir = "../input/intel-image-classification/seg_train/seg_train/" # 训练数据集的路径
test_data_dir = "../input/intel-image-classification/seg_test/seg_test" # 测试数据集的路径
# 加载训练数据
dataset = ImageFolder(data_dir, transform=transforms.Compose([ # 使用ImageFolder类加载训练数据,并应用一系列预处理操作
transforms.Resize((150,150)), # 将图像大小调整为150x150
transforms.ToTensor() # 将图像数据转换为PyTorch的张量格式
]))
# 加载测试数据
test_dataset = ImageFolder(test_data_dir, transform=transforms.Compose([ # 使用ImageFolder类加载测试数据,并应用一系列预处理操作
transforms.Resize((150,150)), # 将图像大小调整为150x150
transforms.ToTensor() # 将图像数据转换为PyTorch的张量格式
]))
torchvision.transforms模块提供了各种预处理图像的功能,首先,我们将图像调整为(150*150)的形状,然后将其转换为张量。
# 从数据集中取出第一个样本,其中img代表图像数据,label代表图像的标签
img, label = dataset[0]
# 打印出图像数据的形状和标签
# img.shape 的输出 torch.Size([3, 150, 150]) 表示图像是一个三维张量,
# 其中3代表图像的通道数(RGB三个颜色通道),150x150代表图像的宽度和高度
# label 的输出 0 表示这个图像对应的标签是0,通常用于分类任务中标识类别
print(img.shape, label)
因此,数据集中的第一张图像的形状为(3,150,150),表示图像有3个通道(RGB)、高度为150、宽度为150,该图像的标签为0,代表“建筑物”类别。
图像的标签根据data.classes中的类别索引进行设置。
打印出数据集中存在的类别
print("存在的类别有: \n", dataset.classes)
# 输出示例(基于提供的输出内容):
存在的类别有:
'buildings', 'forest', 'glacier', 'mountain', 'sea', 'street'] [
# dataset.classes 通常是在处理图像分类任务时,数据集对象(如torchvision中的ImageFolder)
自动根据文件夹名称或其他方式设置的类别列表。这里的类别列表包含了六个类别:
'buildings'(建筑物)、'forest'(森林)、'glacier'(冰川)、'mountain'(山)、'sea'(海)、'street'(街道)。
因此,我们的数据集中包含6种类型的图像。
图像探索
我们的数据集以张量的形式包含图像,可以使用matplotlib Python库的imshow()方法来可视化图像。
def display_img(img, label):
# 打印出图像的标签,这里通过dataset.classes列表和label索引来获取具体的类别名称
print(f"标签: {dataset.classes[label]}")
# 使用matplotlib的imshow函数显示图像
# 由于img是一个三维张量,形状为[通道数, 高度, 宽度],而imshow需要[高度, 宽度, 通道数]的形状
# 因此,我们需要使用permute方法重新排列张量的维度
# 注意:对于RGB图像,通道顺序应该是'RGB',即红色、绿色、蓝色
plt.imshow(img.permute(1, 2, 0))
# 显示数据集中的第一张图像
# dataset[0]会返回一个元组,包含图像数据和标签
# 我们使用*操作符来解包这个元组,将图像数据和标签分别传递给display_img函数的参数
display_img(*dataset[0])
permute方法将图像的形状从(3,150,150)重塑为(150,150,3),可以在下方看到第一张训练数据图像是建筑物:
划分数据与准备批次
由于内存大小固定,训练数据很可能超过CPU或GPU的内存容量,因此我们不能将整个数据集一次性传入模型进行训练。
因此,我们将数据集拆分为多个批次,而不是在单个阶段对整个数据集进行训练,批次大小可以根据内存容量决定,通常取2的幂次方。
例如,批次大小可以为16、32、64、128、256等。
我们取批次大小为128,从数据中取2000张图像用于验证,其余数据用于训练,为了将图像随机拆分为训练集和测试集,PyTorch提供了random_split()方法。
我们使用PyTorch的DataLoader类将数据拆分为批次,通过向DataLoader类提供训练数据和批次大小参数,分别为训练和验证数据创建train_dl和val_dl两个对象。
# 导入必要的库
from torch.utils.data.dataloader import DataLoader # 导入DataLoader类,用于加载数据集
from torch.utils.data import random_split # 导入random_split函数,用于随机划分数据集
# 设置批处理大小
batch_size = 128 # 每个批次中的样本数
val_size = 2000 # 验证集中的样本数
# 计算训练集大小,即总样本数减去验证集样本数
train_size = len(dataset) - val_size
# 使用random_split函数随机划分数据集为训练集和验证集
train_data, val_data = random_split(dataset, [train_size, val_size])
# 打印训练集和验证集的长度(即样本数)
print(f"训练集长度: {len(train_data)}")
print(f"验证集长度: {len(val_data)}")
# 输出示例(基于提供的输出内容):
# 训练集长度: 12034
# 验证集长度: 2000
# 使用DataLoader加载训练集和验证集,并将它们分批处理
# 对于训练集,我们设置了shuffle=True以在每个epoch开始时打乱数据,num_workers=4指定了加载数据时使用的进程数,
# pin_memory=True在支持CUDA的设备上可以提高数据从CPU到GPU的传输速度。
train_dl = DataLoader(train_data, batch_size=batch_size, shuffle=True, num_workers=4, pin_memory=True)
# 对于验证集,我们没有设置shuffle=True,因为验证集通常不需要打乱,同时我们设置了更大的batch_size(训练集batch_size的两倍),
# 这取决于具体的需求和资源情况。num_workers和pin_memory的设置与训练集相同。
val_dl = DataLoader(val_data, batch_size=batch_size*2, num_workers=4, pin_memory=True)
图像可视化
为了可视化单个批次中的图像,可以使用torchvision工具包中的make_grid()方法。它以图像网格的形式为我们提供了批次中图像的整体视图。
# 导入必要的库
from torchvision.utils import make_grid # 导入make_grid函数,用于将多个图像拼接成一个网格
import matplotlib.pyplot as plt # 导入matplotlib的pyplot模块,用于绘图
# 定义一个函数,用于显示数据加载器中的一个批次图像
def show_batch(dl):
"""
显示单个批次中的图像网格
参数:
dl (DataLoader): 包含图像数据的数据加载器
"""
# 遍历数据加载器(实际上这里只需要迭代一次,因为我们在循环内部使用了break语句)
for images, labels in dl:
# 创建一个图形和坐标轴对象,设置图形的大小
fig, ax = plt.subplots(figsize=(16, 12))
# 隐藏坐标轴的刻度标记
ax.set_xticks([])
ax.set_yticks([])
# 使用make_grid函数将图像拼接成网格,并调整张量的维度以匹配imshow函数的输入要求
# nrow=16指定了每行显示的图像数,permute(1,2,0)用于调整通道顺序(从CHW到HWC)
grid_images = make_grid(images, nrow=16).permute(1, 2, 0) if images.ndim == 4 else make_grid(images, nrow=16)
ax.imshow(grid_images)
# 由于我们只想显示一个批次的图像,所以在第一次迭代后就退出循环
break
# 调用函数显示训练数据加载器中的一个批次图像
show_batch(train_dl)
图像分类基础模型
我们先准备一个基类,该类扩展了torch.nn.Module(用于开发所有神经网络的基础类)的功能。
我们向基础类中添加各种功能来训练模型、验证模型,并在每个训练周期结束时获取结果。
该代码是可复用的,可用于任何图像分类模型,无需每次都重写。
import torch.nn as nn
import torch.nn.functional as F
class ImageClassificationBase(nn.Module):
def training_step(self, batch):
# 从批次数据中提取图像和标签
images, labels = batch
# 通过模型生成预测结果
out = self(images)
# 计算交叉熵损失
loss = F.cross_entropy(out, labels)
# 返回损失值
return loss
def validation_step(self, batch):
# 从批次数据中提取图像和标签
images, labels = batch
# 通过模型生成预测结果
out = self(images)
# 计算交叉熵损失
loss = F.cross_entropy(out, labels)
# 计算准确率
acc = accuracy(out, labels)
# 返回一个包含损失和准确率的字典
return {'val_loss': loss.detach(), 'val_acc': acc}
def validation_epoch_end(self, outputs):
# 从每个批次的结果中提取损失值
batch_losses = [x['val_loss'] for x in outputs]
# 计算整个验证集的平均损失
epoch_loss = torch.stack(batch_losses).mean()
# 从每个批次的结果中提取准确率
batch_accs = [x['val_acc'] for x in outputs]
# 计算整个验证集的平均准确率
epoch_acc = torch.stack(batch_accs).mean()
# 返回一个包含平均损失和平均准确率的字典
return {'val_loss': epoch_loss.item(), 'val_acc': epoch_acc.item()}
def epoch_end(self, epoch, result):
# 打印当前训练周期(epoch)的信息,包括训练损失、验证损失和验证准确率
print("Epoch [{}], train_loss: {:.4f}, val_loss: {:.4f}, val_acc: {:.4f}".format(
epoch, result['train_loss'], result['val_loss'], result['val_acc']))
卷积、填充、步长、最大池化
现在,让我们了解卷积、填充和最大池化的概念,这些概念有助于我们的神经网络从图像中学习特征。
卷积
根据维基百科,卷积是两个函数之间进行的数学运算,以生成显示一个函数的形状如何被另一个函数修改的第三个函数。
在这里,第一个函数是图像张量,第二个函数是与图像具有相同通道数的矩阵或张量(称为核)。
核被应用于图像以学习图像的特征,基本上,核会对图像的每个段执行点积,然后求和并输出张量。通过以下图像可以更容易地理解:
填充
在卷积中对图像张量应用核时,会减小输出张量的尺寸,这会导致两个问题:
- 输出会缩小
- 图像角落的像素会失去重要性
为了解决这些问题,我们通过向图像张量的边界添加一些额外的像素来增加图像的形状。
这有助于增加图像的大小,并将图像边界的像素值移到张量内部,从它们中学习到的特征会传递到深度神经网络中的进一步层。
在下图中,向二维张量添加了零填充。
步长
步长控制核的活动,即核如何在图像上移动,例如,如果步长设置为(1,1),则核每次在宽度和高度上移动1个像素。
第一个核在宽度上每次移动1个像素,在完成宽度上的操作后,在高度上移动1个像素,然后重复该过程。
如果步长设置为(2,2),则核在图像张量上每次移动两个像素。
池化
池化层有助于总结卷积层(也称为特征图)获得的结果,并将其降低维度。
有各种类型的池化,如最大池化、平均池化等,最大池化常用,下图对其进行了更精确的描述:
用于分类的CNN模型
在了解这些概念之后,我们现在定义CNN模型,该模型包含这些概念以从图像中学习特征并训练模型。
该模型包含3个CNN块,每个块由2个卷积层和1个最大池化层组成。
使用ReLU激活函数从特征图中删除负值,因为像素值不能为负,步长(1,1)和填充也设置为1。
在对图像应用卷积并提取特征后,使用flatten层将具有3个维度的张量展平,flatten层将张量转换为一维,然后添加3个全连接层以减小张量的大小并学习特征。
CNN模型架构
import torch.nn as nn
class NaturalSceneClassification(nn.Module):
def __init__(self):
super().__init__() # 调用父类的构造函数
# 定义一个顺序模型,包含多个卷积层、激活函数、池化层和全连接层
self.network = nn.Sequential(
# 第一个卷积层,输入通道数为3(RGB),输出通道数为32,卷积核大小为3x3,边缘填充为1
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.ReLU(), # 激活函数
# 第二个卷积层,输入通道数为32,输出通道数为64,其余参数同上
nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1),
nn.ReLU(), # 激活函数
# 第一个池化层,使用最大池化,池化窗口大小为2x2
nn.MaxPool2d(2, 2),
# 第三个和第四个卷积层,输入通道数为64,输出通道数为128,其余参数同上
nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1),
nn.ReLU(), # 激活函数
nn.Conv2d(128, 128, kernel_size=3, stride=1, padding=1),
nn.ReLU(), # 激活函数
# 第二个池化层,参数同上
nn.MaxPool2d(2, 2),
# 第五个和第六个卷积层,输入通道数为128,输出通道数为256,其余参数同上
nn.Conv2d(128, 256, kernel_size=3, stride=1, padding=1),
nn.ReLU(), # 激活函数
nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1),
nn.ReLU(), # 激活函数
# 第三个池化层,参数同上
nn.MaxPool2d(2, 2),
# 展平层,将多维的卷积特征图展平为一维向量
nn.Flatten(),
# 第一个全连接层,输入特征数为展平后的特征数量(这里假设为82944,但实际可能因输入图像大小而异),输出特征数为1024
nn.Linear(82944, 1024), # 注意:82944这个数值需要根据实际输入图像大小和前面的网络层计算得出
nn.ReLU(), # 激活函数
# 第二个全连接层,输入特征数为1024,输出特征数为512
nn.Linear(1024, 512),
nn.ReLU(), # 激活函数
# 第三个全连接层(输出层),输入特征数为512,输出特征数为6(假设有6个分类)
nn.Linear(512, 6)
)
def forward(self, xb):
# 定义前向传播路径,输入数据xb通过定义好的网络进行前向传播
return self.network(xb)
超参数、模型训练与评估
现在,我们需要在训练数据集上训练自然场景分类模型,因此,我们首先定义拟合、评估和准确性方法。
import torch
# 计算准确率函数
def accuracy(outputs, labels):
# 使用torch.max获取outputs中最大值所在的索引,即预测值preds
_, preds = torch.max(outputs, dim=1)
# 计算预测正确的数量,并除以总数量得到准确率,然后转换为tensor类型返回
return torch.tensor(torch.sum(preds == labels).item() / len(preds))
# 模型评估函数,使用@torch.no_grad()装饰器表示在评估过程中不计算梯度
def evaluate(model, val_loader):
# 将模型设置为评估模式
model.eval()
# 对验证集中的每个batch进行预测,并将结果存储在outputs列表中
outputs = [model.validation_step(batch) for batch in val_loader]
# 调用模型的validation_epoch_end方法计算并返回评估结果
return model.validation_epoch_end(outputs)
# 模型训练函数
def fit(epochs, lr, model, train_loader, val_loader, opt_func=torch.optim.SGD):
# 存储训练历史的列表
history = []
# 创建优化器,默认使用SGD优化器
optimizer = opt_func(model.parameters(), lr)
# 进行多个epoch的训练
for epoch in range(epochs):
# 将模型设置为训练模式
model.train()
# 存储每个batch的训练损失的列表
train_losses = []
# 对训练集中的每个batch进行训练
for batch in train_loader:
# 调用模型的training_step方法进行前向传播、计算损失等
loss = model.training_step(batch)
# 将损失添加到train_losses列表中
train_losses.append(loss)
# 反向传播计算梯度
loss.backward()
# 更新模型参数
optimizer.step()
# 清零梯度,为下一个batch做准备
optimizer.zero_grad()
# 在一个epoch结束后,对模型进行评估
result = evaluate(model, val_loader)
# 计算并添加训练损失到结果字典中
result['train_loss'] = torch.stack(train_losses).mean().item()
# 调用模型的epoch_end方法,可能用于记录日志、保存模型等
model.epoch_end(epoch, result)
# 将当前epoch的结果添加到历史记录中
history.append(result)
# 训练结束后,返回历史记录
return history
现在,我们针对不同的超参数训练模型,以获得模型的最佳拟合,在这里训练了30个训练周期的模型,学习率为0.001,测试数据的准确率为80%。
num_epochs = 30
opt_func = torch.optim.Adam
lr = 0.001
# 在训练数据上拟合模型,并记录每个epoch后的结果
history = fit(num_epochs, lr, model, train_dl, val_dl, opt_func)
每个训练周期的结果:
Epoch [0], train_loss: 1.2263, val_loss: 1.1289, val_acc: 0.5676
Epoch [1], train_loss: 0.8951, val_loss: 0.8003, val_acc: 0.6766
Epoch [2], train_loss: 0.7444, val_loss: 0.6793, val_acc: 0.7462
Epoch [3], train_loss: 0.5978, val_loss: 0.7027, val_acc: 0.7670
Epoch [4], train_loss: 0.5132, val_loss: 0.5253, val_acc: 0.8123
Epoch [5], train_loss: 0.4110, val_loss: 0.5356, val_acc: 0.8197
Epoch [6], train_loss: 0.3137, val_loss: 0.6205, val_acc: 0.8035
Epoch [7], train_loss: 0.2375, val_loss: 0.6337, val_acc: 0.8153
Epoch [8], train_loss: 0.1705, val_loss: 0.7263, val_acc: 0.8119
Epoch [9], train_loss: 0.1467, val_loss: 0.7694, val_acc: 0.8032
Epoch [10], train_loss: 0.1044, val_loss: 0.9002, val_acc: 0.8026
Epoch [11], train_loss: 0.1071, val_loss: 0.9872, val_acc: 0.8033
Epoch [12], train_loss: 0.0509, val_loss: 1.0953, val_acc: 0.8236
Epoch [13], train_loss: 0.0264, val_loss: 1.2699, val_acc: 0.8131
Epoch [14], train_loss: 0.0281, val_loss: 1.1232, val_acc: 0.8079
Epoch [15], train_loss: 0.0377, val_loss: 1.2976, val_acc: 0.8080
Epoch [16], train_loss: 0.0347, val_loss: 1.2475, val_acc: 0.7905
Epoch [17], train_loss: 0.0287, val_loss: 1.3546, val_acc: 0.8020
Epoch [18], train_loss: 0.0385, val_loss: 1.2569, val_acc: 0.8256
Epoch [19], train_loss: 0.0172, val_loss: 1.2729, val_acc: 0.8147
Epoch [20], train_loss: 0.0447, val_loss: 8.4484, val_acc: 0.4511
Epoch [21], train_loss: 0.7567, val_loss: 0.6766, val_acc: 0.7819
Epoch [22], train_loss: 0.1603, val_loss: 0.9974, val_acc: 0.7862
Epoch [23], train_loss: 0.0449, val_loss: 1.2573, val_acc: 0.7992
Epoch [24], train_loss: 0.0174, val_loss: 1.3928, val_acc: 0.7953
Epoch [25], train_loss: 0.0211, val_loss: 1.2213, val_acc: 0.8135
Epoch [26], train_loss: 0.0057, val_loss: 1.5535, val_acc: 0.8034
Epoch [27], train_loss: 0.0038, val_loss: 1.5412, val_acc: 0.7913
Epoch [28], train_loss: 0.0057, val_loss: 1.5192, val_acc: 0.8018
Epoch [29], train_loss: 0.0033, val_loss: 1.4707, val_acc: 0.8103
import matplotlib.pyplot as plt # 导入matplotlib的pyplot模块用于绘图
def plot_accuracies(history):
"""
绘制准确率历史记录图
:param history: 包含每个epoch结果的列表,每个结果是一个字典,包含'val_acc'等键
"""
# 从history列表中提取每个epoch的验证准确率
accuracies = [x['val_acc'] for x in history]
# 使用plt.plot绘制准确率随epoch变化的曲线,'-x'表示用线段连接数据点,并在数据点处标记'x'
plt.plot(accuracies, '-x')
# 设置x轴标签为'epoch'
plt.xlabel('epoch')
# 设置y轴标签为'accuracy'
plt.ylabel('accuracy')
# 设置图表标题
plt.title('Accuracy vs. No. of epochs')
# 显示图表
plt.show()
# 调用plot_accuracies函数绘制准确率历史记录图
plot_accuracies(history)
def plot_losses(history):
"""
绘制每个epoch的损失图
:param history: 包含每个epoch结果的列表,每个结果是一个字典,包含'train_loss'和'val_loss'等键
"""
# 从history列表中提取每个epoch的训练损失,使用get方法防止键不存在时出错,返回None(但这里我们期望它总是存在)
train_losses = [x.get('train_loss') for x in history]
# 从history列表中提取每个epoch的验证损失
val_losses = [x['val_loss'] for x in history]
# 使用plt.plot绘制训练损失随epoch变化的曲线,'-bx'表示用蓝色线段连接数据点,并在数据点处标记'x'
plt.plot(train_losses, '-bx')
# 使用plt.plot绘制验证损失随epoch变化的曲线,'-rx'表示用红色线段连接数据点,并在数据点处标记'x'
plt.plot(val_losses, '-rx')
# 设置x轴标签为'epoch'
plt.xlabel('epoch')
# 设置y轴标签为'loss'
plt.ylabel('loss')
# 设置图例,区分训练损失和验证损失
plt.legend(['Training', 'Validation'])
# 设置图表标题
plt.title('Loss vs. No. of epochs')
# 显示图表
plt.show()
# 调用plot_losses函数绘制损失历史记录图
plot_losses(history)
来源:人工智能学习指南