卷积神经网络是深度学习中的一个非常重要的分支,本作业学习了进行图像分类任务的基本范式。
1.准备数据
通过利用10类食物的图片数据训练一个卷积神经网络模型,来对图像进行分类。
本代码提供了一个基本的CNN网络,可以通过改变不同的网络架构去获得不同的效果,例如使用:ALexnet,VGG,ResNet等。
# 创建文件夹并解压缩数据集,忽略输出
!wget -O hw3_data.zip "https://www.modelscope.cn/datasets/Datawhale/LeeDL-HW3-CNN/resolve/master/hw3_data.zip"
!mkdir -p ./hw3_data
!unzip -o hw3_data.zip -d ./hw3_data > /dev/null
# 定义实验名称,用于标识实验配置或运行的标识符
_exp_name = "sample"
2. 导入所需要的库/工具包
# 导入必要的库
import numpy as np
import pandas as pd
import torch
import os
import torch.nn as nn
import torchvision.transforms as transforms
from PIL import Image
# “ConcatDataset” 和 “Subset” 在进行半监督学习时可能是有用的。
from torch.utils.data import ConcatDataset, DataLoader, Subset, Dataset
from torchvision.datasets import DatasetFolder, VisionDataset
# 这个是用来显示进度条的。
from tqdm.auto import tqdm
import random
确保实验的可重复性,设置随机种子,并对CUDA进行配置以确保确定性:
# 设置随机种子以确保实验结果的可重复性
myseed = 6666
# 确保在使用CUDA时,卷积运算具有确定性,以增强实验结果的可重复性
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
# 为numpy和pytorch设置随机种子
np.random.seed(myseed)
torch.manual_seed(myseed)
# 如果使用CUDA,为所有GPU设置随机种子
if torch.cuda.is_available():
torch.cuda.manual_seed_all(myseed)
3.图像预处理/变换
Torchvision为图像预处理、数据增强和数据加载提供了一系列的API,这些API可以方便的实现图像预处理、数据增强和数据加载。
# 在测试和验证阶段,通常不需要图像增强。
# 我们所需要的只是调整PIL图像的大小并将其转换为Tensor。
test_tfm = transforms.Compose([
transforms.Resize((128, 128)),
transforms.ToTensor(),
])
# 不过,在测试阶段使用图像增强也是有可能的。
# 你可以使用train_tfm生成多种图像,然后使用集成方法进行测试。
train_tfm = transforms.Compose([
# 将图像调整为固定大小(高度和宽度均为128)
transforms.Resize((128, 128)),
# 你可以在这里添加一些图像增强的操作。
# ToTensor()应该是所有变换中的最后一个。
transforms.ToTensor(),
])
4.数据集
数据通过名称进行标记,因此在调用’getitem’时同时加载图像和标签。
定义了一个名为 FoodDataset 的类,继承自 Dataset,用于加载并预处理食品图像数据集,支持图像变换及从文件名中提取标签。
class FoodDataset(Dataset):
"""
用于加载食品图像数据集的类。
该类继承自Dataset,提供了对食品图像数据集的加载和预处理功能。
它可以自动从指定路径加载所有的jpg图像,并对这些图像应用给定的变换。
"""
def __init__(self, path, tfm=test_tfm, files=None):
"""
初始化FoodDataset实例。
参数:
- path: 图像数据所在的目录路径。
- tfm: 应用于图像的变换方法(默认为测试变换)。
- files: 可选参数,用于直接指定图像文件的路径列表(默认为None)。
"""
super(FoodDataset).__init__()
self.path = path
# 列出目录下所有jpg文件,并按顺序排序
self.files = sorted([os.path.join(path, x) for x in os.listdir(path) if x.endswith(".jpg")])
if files is not None:
self.files = files # 如果提供了文件列表,则使用该列表
self.transform = tfm # 图像变换方法
def __len__(self):
"""返回数据集中图像的数量。"""
return len(self.files)
def __getitem__(self, idx):
"""
获取给定索引的图像及其标签。
参数:
idx: 图像在数据集中的索引。
返回:
im: 应用了变换后的图像。
label: 图像对应的标签(如果可用)。
"""
fname = self.files[idx]
im = Image.open(fname)
im = self.transform(im) # 应用图像变换
# 尝试从文件名中提取标签
try:
label = int(fname.split("/")[-1].split("_")[0])
except:
label = -1 # 如果无法提取标签,则设置为-1(测试数据无标签)
return im, label
5.模型
class Classifier(nn.Module):
"""
定义一个图像分类器类,继承自PyTorch的nn.Module。
该分类器包含卷积层和全连接层,用于对图像进行分类。
"""
def __init__(self):
"""
初始化函数,构建卷积神经网络的结构。
包含一系列的卷积层、批归一化层、激活函数和池化层。
"""
super(Classifier, self).__init__()
# 定义卷积神经网络的序列结构
self.cnn = nn.Sequential(
nn.Conv2d(3, 64, 3, 1, 1), # 输入通道3,输出通道64,卷积核大小3,步长1,填充1
nn.BatchNorm2d(64), # 批归一化,作用于64个通道
nn.ReLU(), # ReLU激活函数
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0
nn.Conv2d(64, 128, 3, 1, 1), # 输入通道64,输出通道128,卷积核大小3,步长1,填充1
nn.BatchNorm2d(128), # 批归一化,作用于128个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0
nn.Conv2d(128, 256, 3, 1, 1), # 输入通道128,输出通道256,卷积核大小3,步长1,填充1
nn.BatchNorm2d(256), # 批归一化,作用于256个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0
nn.Conv2d(256, 512, 3, 1, 1), # 输入通道256,输出通道512,卷积核大小3,步长1,填充1
nn.BatchNorm2d(512), # 批归一化,作用于512个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0
nn.Conv2d(512, 512, 3, 1, 1), # 输入通道512,输出通道512,卷积核大小3,步长1,填充1
nn.BatchNorm2d(512), # 批归一化,作用于512个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0
)
# 定义全连接神经网络的序列结构
self.fc = nn.Sequential(
nn.Linear(512*4*4, 1024), # 输入大小512*4*4,输出大小1024
nn.ReLU(),
nn.Linear(1024, 512), # 输入大小1024,输出大小512
nn.ReLU(),
nn.Linear(512, 11) # 输入大小512,输出大小11,最终输出11个类别的概率
)
def forward(self, x):
"""
前向传播函数,对输入进行处理。
参数:
x -- 输入的图像数据,形状为(batch_size, 3, 128, 128)
返回:
输出的分类结果,形状为(batch_size, 11)
"""
out = self.cnn(x) # 通过卷积神经网络处理输入
out = out.view(out.size()[0], -1) # 展平输出,以适配全连接层的输入要求
return self.fc(out) # 通过全连接神经网络得到最终输出
6.配置
# 根据GPU是否可用选择设备类型
device = "cuda" if torch.cuda.is_available() else "cpu"
# 初始化模型,并将其放置在指定的设备上
model = Classifier().to(device)
# 定义批量大小
batch_size = 64
# 定义训练轮数
n_epochs = 8
# 如果在'patience'轮中没有改进,则提前停止
patience = 5
# 对于分类任务,我们使用交叉熵作为性能衡量标准
criterion = nn.CrossEntropyLoss()
# 初始化优化器,您可以自行调整一些超参数,如学习率
optimizer = torch.optim.Adam(model.parameters(), lr=0.0003, weight_decay=1e-5)
7.加载数据
# 构建训练和验证数据集
# "loader" 参数定义了torchvision如何读取数据
train_set = FoodDataset("./hw3_data/train", tfm=train_tfm)
# 创建训练数据加载器,设置批量大小、是否打乱数据顺序、是否使用多线程加载以及是否固定内存地址
train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=0, pin_memory=True)
# 构建验证数据集
# "loader" 参数定义了torchvision如何读取数据
valid_set = FoodDataset("./hw3_data/valid", tfm=test_tfm)
# 创建验证数据加载器,设置批量大小、是否打乱数据顺序、是否使用多线程加载以及是否固定内存地址
valid_loader = DataLoader(valid_set, batch_size=batch_size, shuffle=True, num_workers=0, pin_memory=True)
8.开始训练
# 初始化追踪器,这些不是参数,不应该被更改
stale = 0
best_acc = 0
for epoch in range(n_epochs):
# ---------- 训练阶段 ----------
# 确保模型处于训练模式
model.train()
# 这些用于记录训练过程中的信息
train_loss = []
train_accs = []
for batch in tqdm(train_loader):
# 每个批次包含图像数据及其对应的标签
imgs, labels = batch
# imgs = imgs.half()
# print(imgs.shape,labels.shape)
# 前向传播数据。(确保数据和模型位于同一设备上)
logits = model(imgs.to(device))
# 计算交叉熵损失。
# 在计算交叉熵之前不需要应用softmax,因为它会自动完成。
loss = criterion(logits, labels.to(device))
# 清除上一步中参数中存储的梯度
optimizer.zero_grad()
# 计算参数的梯度
loss.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()
# 记录损失和准确率
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"[ 训练 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {train_loss:.5f}, acc = {train_acc:.5f}")
# ---------- 验证阶段 ----------
# 确保模型处于评估模式,以便某些模块如dropout被禁用并能够正常工作
model.eval()
# 这些用于记录验证过程中的信息
valid_loss = []
valid_accs = []
# 按批次迭代验证集
for batch in tqdm(valid_loader):
# 每个批次包含图像数据及其对应的标签
imgs, labels = batch
# imgs = imgs.half()
# 我们在验证阶段不需要梯度。
# 使用 torch.no_grad() 加速前向传播过程。
with 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)
# break
# 整个验证集的平均损失和准确率是所记录值的平均
valid_loss = sum(valid_loss) / len(valid_loss)
valid_acc = sum(valid_accs) / len(valid_accs)
# 打印信息
print(f"[ 验证 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f}")
# 更新日志(输出)
if valid_acc > best_acc:
with open(f"./{_exp_name}_log.txt", "a"):
print(f"[ 验证 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f} -> 最佳")
else:
with open(f"./{_exp_name}_log.txt", "a"):
print(f"[ 验证 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f}")
# 保存模型
if valid_acc > best_acc:
print(f"在第 {epoch} 轮找到最佳模型,正在保存模型")
torch.save(model.state_dict(), f"{_exp_name}_best.ckpt") # 只保存最佳模型以防止输出内存超出错误
best_acc = valid_acc
stale = 0
else:
stale += 1
if stale > patience:
print(f"连续 {patience} 轮没有改进,提前停止")
break
9.加载数据用于测试
# 构建测试数据集
# "loader"参数指定了torchvision如何读取数据
test_set = FoodDataset("./hw3_data/test", tfm=test_tfm)
# 创建测试数据加载器,批量大小为batch_size,不打乱数据顺序,不使用多线程,启用pin_memory以提高数据加载效率
test_loader = DataLoader(test_set, batch_size=batch_size, shuffle=False, num_workers=0, pin_memory=True)
10.测试并生成预测 CSV
# 实例化分类器模型,并将其转移到指定的设备上
model_best = Classifier().to(device)
# 加载模型的最优状态字典
model_best.load_state_dict(torch.load(f"{_exp_name}_best.ckpt"))
# 将模型设置为评估模式
model_best.eval()
# 初始化一个空列表,用于存储所有预测标签
prediction = []
# 使用torch.no_grad()上下文管理器,禁用梯度计算
with torch.no_grad():
# 遍历测试数据加载器
for data, _ in tqdm(test_loader):
# 将数据转移到指定设备上,并获得模型的预测结果
test_pred = model_best(data.to(device))
# 选择具有最高分数的类别作为预测标签
test_label = np.argmax(test_pred.cpu().data.numpy(), axis=1)
# 将预测标签添加到结果列表中
prediction += test_label.squeeze().tolist()
# 创建测试csv文件
def pad4(i):
"""
将输入数字i转换为长度为4的字符串,如果长度不足4,则在前面补0。
:param i: 需要转换的数字
:return: 补0后的字符串
"""
return "0" * (4 - len(str(i))) + str(i)
# 创建一个空的DataFrame对象
df = pd.DataFrame()
# 使用列表推导式生成Id列,列表长度等于测试集的长度
df["Id"] = [pad4(i) for i in range(len(test_set))]
# 将预测结果赋值给Category列
df["Category"] = prediction
# 将DataFrame对象保存为submission.csv文件,不保存索引
df.to_csv("submission.csv", index=False)
Q1. 数据增强实现
下面的代码用于定义数据预处理和增强的操作。创建了一个数据转换流程(train_tfm
),该流程会在每次加载训练图像时自动应用。这包括:
- 调整图像大小:将所有输入图像的尺寸调整为128x128像素。
- 转换为Tensor:将图像数据转换为PyTorch张量,以便在训练过程中可以直接用于模型。
数据增强的实现:
目前这段代码只包含了图像大小调整和转换为张量的操作,并没有实现其他形式的数据增强。数据增强的常见操作包括:
- 随机裁剪(
transforms.RandomCrop
) - 随机水平翻转(
transforms.RandomHorizontalFlip
) - 颜色抖动(
transforms.ColorJitter
) - 旋转(
transforms.RandomRotation
)
例如,添加随机水平翻转,可在transforms.Compose
中加入transforms.RandomHorizontalFlip()
:
train_tfm = transforms.Compose([
transforms.Resize((128, 128)),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
])
Q2. 可视化表示实现
通过在卷积神经网络(CNN)模型的验证集上实现t-SNE(t分布随机邻域嵌入),可视化学习到的视觉表示,包括顶层和中间层的输出。
# 导入必要的库和模块
import torch
import numpy as np
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt
from tqdm import tqdm
import matplotlib.cm as cm
import torch.nn as nn
# 根据CUDA是否可用选择执行设备
device = 'cuda' if torch.cuda.is_available() else 'cpu'
# 加载训练好的模型
model = Classifier().to(device)
# 加载模型保存的参数
state_dict = torch.load(f"{_exp_name}_best.ckpt")
# 将参数加载到模型中
model.load_state_dict(state_dict)
# 设置模型为评估模式
model.eval()
# 打印模型结构
print(model)
Classifier( (cnn): Sequential( (0): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) (2): ReLU() (3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) (4): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (5): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) (6): ReLU() (7): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) (8): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (9): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) (10): ReLU() (11): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) (12): Conv2d(256, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (13): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) (14): ReLU() (15): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) (16): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (17): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) (18): ReLU() (19): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) ) (fc): Sequential( (0): Linear(in_features=8192, out_features=1024, bias=True) (1): ReLU() (2): Linear(in_features=1024, out_features=512, bias=True) (3): ReLU() (4): Linear(in_features=512, out_features=11, bias=True) ) )
from tqdm import tqdm
import numpy as np
import matplotlib.pyplot as plt
from sklearn.manifold import TSNE
import matplotlib.cm as cm
import torch
def forward_to_layer(model, input_tensor, layer_index):
outputs = []
for i, layer in enumerate(model.children()):
input_tensor = layer(input_tensor)
if i == layer_index:
break
outputs.append(input_tensor)
return outputs[-1] # 返回所选层的输出
# 假设model, test_tfm, FoodDataset, DataLoader已经被定义且正确初始化
# 加载由TA定义的验证集
valid_set = FoodDataset("./hw3_data/valid", tfm=test_tfm)
valid_loader = DataLoader(valid_set, batch_size=64, shuffle=False, num_workers=0, pin_memory=True)
# 提取模型特定层的表示
index = 19 # 假设你想提取第19层的特征
features = []
labels = []
# 定义设备
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
for batch in tqdm(valid_loader):
imgs, lbls = batch
imgs, lbls = imgs.to(device), lbls.to(device) # 确保数据在正确的设备上
with torch.no_grad():
# 获取特定层的特征
logits = forward_to_layer(model.cnn, imgs, index)
logits = logits.view(logits.size(0), -1)
labels.extend(lbls.cpu().numpy())
features.extend(logits.cpu().numpy())
# 将features和labels列表转换为numpy数组
features = np.array(features)
labels = np.array(labels)
# 应用t-SNE到特征上
features_tsne = TSNE(n_components=2, init='pca', random_state=42).fit_transform(features)
# 绘制t-SNE可视化图
plt.figure(figsize=(10, 8))
for label in np.unique(labels):
# 使用布尔索引选择特定标签的数据点
mask = (labels == label)
plt.scatter(features_tsne[mask, 0], features_tsne[mask, 1], label=f'Class {label}', s=5)
plt.legend()
plt.title('All Classes t-SNE Visualization')
plt.show()
# 绘制特定类别的t-SNE可视化图
plt.figure(figsize=(10, 8))
selected_label = 5
mask = (labels == selected_label)
if mask.any(): # 使用 .any() 替代 .sum() 来检查是否有True值
plt.scatter(features_tsne[mask, 0], features_tsne[mask, 1], label=f'Class {selected_label}', s=5)
plt.legend()
plt.title(f'Class {selected_label} t-SNE Visualization')
plt.show()
最后的代码构建一个测试数据集和数据加载器,以便高效地读取数据。
实例化并加载预训练的分类器模型,并将其设置为评估模式。在不计算梯度的情况下,遍历测试数据,使用模型进行预测,并将预测标签存储在列表中。将预测结果与测试集的ID生成一个DataFrame,并将其保存为submission.csv
文件。
优化方向
代码的最后一部分提供了数据增强/图像增广的示例,并结合t-SNE算法对增强后的特征进行降维和可视化。