DL练习:树叶分类

试一试捏。


#

使用残差网络做树叶分类。

# 数据来源与预处理

此处下载训练集与测试集。这是包含[latex]176[/latex]类叶子的图像数据。

构造图像数据迭代器

训练数据解压得到一个含有大量图片数据的[latex]\textbf{images文件夹}[/latex]和一个记录图片相对路径和标签的[latex]\textbf{train.csv文件}[/latex]。因此需要构造一个读取这些数据的类及其方法。由于方法内涉及打开图像,因此需要\(\text{from PIL import Image}\)

class custom_dateset(data.Dataset):
    def __init__(self, df, image_dir, transform=None):
        self.data_frame = df
        self.image_dir = image_dir
        self.transform = transform

    def __len__(self):
        return len(self.data_frame)

    def __getitem__(self, idx):
        if torch.is_tensor(idx):
            idx = idx.to_list()
        img_name = os.path.join(self.image_dir, self.data_frame.iloc[idx, 0].split('/')[1])
        image = Image.open(img_name)
        label = self.data_frame.iloc[idx, 1]
        if self.transform:
            image = self.transform(image)
        return image, label

其中因为记录了包含文件夹名的相对路径,与 image_dir 冗余,因此使用了 .split() 

该方法可用来读取非测试集的图像数据,因为同时返回了 image  label 

给标签编号,然后将 train.csv 分为训练集和验证集,并实例化训练集和训练集数据迭代器

# 读取train.csv为dataframe
data_df = pandas.read_csv('./leaves_class/train.csv', header=0)
# 将字符串标签编号
label_encoder = LabelEncoder()
data_df['label'] = label_encoder.fit_transform(data_df.iloc[:, 1])
# 划分训练集和验证集
train_df, valid_df = train_test_split(data_df, test_size=0.3, shuffle=True)
# 数据转换
trans = transforms.Compose([transforms.Resize(224), transforms.ToTensor()])
trans_train = transforms.Compose([
    transforms.Resize(224),
    transforms.RandomHorizontalFlip(p=0.5),  # 随机水平翻转
    transforms.RandomVerticalFlip(p=0.5),  # 除了水平竖直反转之外其他的处理方法貌似都会降低acc
    transforms.ToTensor(),
                 ])
## 构建数据迭代器
train_dataset = custom_dateset(train_df, image_dir='./leaves_class/images', transform=trans)
valid_dataset = custom_dateset(valid_df, image_dir='./leaves_class/images', transform=trans)
train_iter = data.DataLoader(train_dataset, batch_size, shuffle=True, num_workers=6)
num_batch = len(train_iter)  # 得到该batch_size下有多少个batch
valid_iter = data.DataLoader(valid_dataset, batch_size, shuffle=True, num_workers=6)

注:自行调整[latex]\textit{num_workers}[/latex]。

# 训练

此处使用 PyTorch 提供的现成模型 ResNet-34 进行图像分类,最后的全连接层输出通道为叶子的总类数[latex]176[/latex]。

设定超参数并定义模型,初始化模型参数

lr, batch_size = 0.0001, 64
weight_decay = 0.001
epoch_num = 50
net = models.resnet34(weights=ResNet34_Weights.IMAGENET1K_V1)
net.fc = nn.Sequential(nn.LazyLinear(176))  # 定义最后的全连接层
# net.fc = nn.Sequential(nn.Linear(net.fc.in_features, 176))
# nn.init.xavier_uniform_(net.fc[0].weight)
net.to(device='cuda:0')

定义优化器和损失函数

optimizer = torch.optim.Adam(net.parameters(), lr=lr, weight_decay=weight_decay)
loss = nn.CrossEntropyLoss()

注:优化器使用的是[latex]\textit{Adam}[/latex]。

开始训练

train_acc = torch.Tensor().to(device='cuda:0')  # 用于记录每一次迭代的训练集准确率
valid_acc = torch.Tensor().to(device='cuda:0')  # 记录一个epoch内每次迭代的验证集batch准确率。用于求平均,得到每个epoch的验证集准确率
valid_acc_list = []  # 记录所有epoch的验证集准确率
timer_list = []  # 记录每个epoch所用的训练时间
l_list = torch.Tensor().to(device='cuda:0')  # 记录每次迭代的损失
best_model_acc = 0.  # 记录最佳精度
for epoch in range(epoch_num):
    start_time = time.time()  # 计时开始
    # 转变为训练形态!
    net.train()
    for i, (X, y) in enumerate(train_iter):  # 来笔数据做iteration!
        X, y = X.to(device='cuda:0'), y.to(device='cuda:0')  # 把这笔数据丢显卡上!
        optimizer.zero_grad()  # 清空梯度!
        l = loss(net(X), y)  # 计算损失!
        l.backward()  # 反向传播!
        optimizer.step()  # 同步更新参数!
        with torch.no_grad():  # 后面两行代码不要记录梯度!
            acc_train = (net(X).argmax(dim=-1) == y).float().mean()  # 计算得到这一小笔训练数据的准确率!
            train_acc = torch.cat([train_acc, acc_train.reshape(-1)])  # 由于acc_train是零维[],需要reshape
        l_list = torch.cat([l_list, l.reshape(-1)])
        # if (i + 1) % (num_batch // 5) == 0 or i == num_batch - 1:  # 控制输出进程的时机
        print(f"epoch: {epoch} ",
              f"loss: {l.item():.6f}\t ",
              f"train accuracy: {100. * acc_train :.2f}%\t")
        # 用于debug
        # if i == 3:
        #     break
    end_time = time.time()  # 计时结束
    timer_list.append(end_time - start_time)  # 记录每个epoch所用的训练时间
    net.eval()  # 转变为评估形态!
    valid_acc = torch.Tensor().to(device='cuda:0')  # 清空valid_acc!
    for i, (X_valid, y_valid) in enumerate(valid_iter):  # 来笔数据做iteration!
        X_valid, y_valid = X_valid.to(device='cuda:0'), y_valid.to(device='cuda:0')  # 把这笔数据丢显卡上!
        with torch.no_grad():  # 下面的两行代码不要记录梯度!
            acc_valid = (net(X_valid).argmax(dim=1) == y_valid).float().mean()  # 计算得到这一小笔验证数据的准确率!
            valid_acc = torch.cat([valid_acc, acc_valid.reshape(-1)])  # 记录在每个验证集batch上的准确率
    valid_acc_list.append(valid_acc.to(device="cpu").numpy().flatten().mean())  # 记录该epoch的全体验证样本上的准确率
    if valid_acc_list[-1] > best_model_acc:
        best_model_acc = valid_acc_list[-1]
        torch.save(net.state_dict(), './leaves_class/best_model.ckpt')
        print('到达世界最高城——理塘!')
    print(f'epoch_{epoch}的验证精度为{valid_acc.to(device="cpu").numpy().flatten().mean():.3f}')
注意:在训练集和验证集上计算精度时,一定不要记录梯度。这里使用了\(\textit{with torch.no_grad()}\)。

打印结果

print(f'看看速度:{(len(train_df.iloc[:, 0]) * epoch_num) / sum(timer_list)}')  # 看看速度
print(f'看看时间:{sum(timer_list)}')
print(valid_acc_list)

# 测试集

构造测试集图像数据迭代器

class custom_testset(data.Dataset):
    def __init__(self, df, image_dir, transform=None):
        self.data_frame = df
        self.image_dir = image_dir
        self.transform = transform

    def __len__(self):
        return len(self.data_frame)

    def __getitem__(self, idx):
        if torch.is_tensor(idx):
            idx = idx.to_list()
        img_name = os.path.join(self.image_dir, self.data_frame.iloc[idx, 0].split('/')[1])
        image = Image.open(img_name)
        if self.transform:
            image = self.transform(image)
        return image

与之前读取训练集不同的是,这里只返回一个 image 。聪明的小伙伴可以把本帖中的两个数据集方法结合为一个。

读取测试集数据

# 读取测试集csv
submission_df = pandas.read_csv('./leaves_class/test.csv', header=0)
# 实例化测试集图像数据迭代器
submission_dataset = custom_testset(submission_df, image_dir='./leaves_class/images', transform=trans)
submission_iter = data.DataLoader(submission_dataset, batch_size, shuffle=False)

加载最好的模型用于推理

best_net = models.resnet34()
best_net.fc = nn.Sequential(nn.LazyLinear(176))
best_net.load_state_dict(torch.load('./leaves_class/best_model.ckpt'))
best_net.to(device='cuda:0')
需要注意的是,用于推理的模型与之前的模型在结构上必须一致。如之前的全连接输出层使用了\(\textit{nn.Sequential()}\)将其包裹,那么这里的\(\textbf{ best_net }\)也需要使用一个\(\textit{nn.Sequential()}\)将其包裹。

推理,并将得到的编码数据解码,然后输出

rslt_list = torch.Tensor().to(device='cuda:0')  # 用于在gpu上存放推理结果数据
for imgs in submission_iter:
    imgs = imgs.to(device='cuda:0')
    with torch.no_grad():
        rslt_list = torch.cat([rslt_list, net(imgs).argmax(dim=1)])
labels_decoded = label_encoder.inverse_transform(rslt_list.to(device='cpu').numpy().flatten().astype(int))  # 需要转int才能转回label
submission_df['label'] = labels_decoded
submission_df.to_csv('./submission.csv', index=False)

# 总结

神经网络架构也会影响训练集准确率

尽管[latex]\textit{ResNet34}[/latex]和[latex]\textit{ResNeXt}[/latex]在训练集上的准确率均能到达[latex]100\%[/latex],但是两者在训练集上的表现却有较大差异。

在固定[latex]epoch=100[/latex],[latex]alpha=0.0001[/latex],[latex]batch_size=64[/latex],[latex]weight_decay=0.001[/latex]的情况下,[latex]\textit{ResNet34}[/latex]在训练集上的准确率最高只有[latex]88\%[/latex],而[latex]\textit{ResNeXt}[/latex]有[latex]95\%[/latex]。

使用预训练的网络极大加速收敛速度

from torchvision import models
from torchvision.models import resnext50_32x4d, ResNeXt50_32X4D_Weights
net = models.resnext50_32x4d(weights=ResNeXt50_32X4D_Weights.IMAGENET1K_V1)
net = models.resnext50_32x4d(pretrained=True)  # 已弃用。等价于上面的代码

# 附录

将\(\textit{RGB}\)图片以及训练过程用 axis.imshow() 以及 axis.plot() 展示出来

train_acc = train_acc.to(device='cpu')
valid_acc = valid_acc.to(device='cpu')
images, labels = next(iter(train_iter))
fig, axes = pyplot.subplots(1, 3)
image_data = numpy.moveaxis(images[10].numpy(), 0, -1)  # 将通道维放到最后,并从Tensor转ndarray
axes[0].imshow(images[10][0])  # 第11张图的第1个通道
axes[1].imshow(image_data)
axes[2].plot([i for i in range(len(train_acc))], train_acc, color='green')
axes[2].plot([i * (len(train_acc) / len(valid_acc_list)) for i in range(len(valid_acc_list))], valid_acc_list, color='red')
# axes[2].plot([i for i in range(len(l_list))], l_list, color='grey', linestyle='--')
pyplot.show()

手动构建的残差网络

详见此处

QA

增加数据和调参对泛化性的影响?

很多时候增加高质量数据对提升泛化性比调参有用。过多调参也只会拟合当前数据,在业务场景可能会不断增加数据。所以调参调到还行就可以了。

为什么\(w-=lr*w.grad\)可以而\(w=w-lr*w.grad\)梯度会消失?

因为后者的[latex]w[/latex]是一个新的 Tensor ,梯度没了。

为什么精度在\(0.7\)就不动了,是没做\(\text{weight_decay}\)吗?

\(\text{weight_decay}\)可能把精度从\(0.7\)提升到\(0.8\),从\(0.9\)提升到\(0.95\)。但不可能从\(0.7\)提到\(0.99\)。

工业界与学术界不同

工业界更关注数据,可能选一个稳定的模型,先调一个过得去的参数,然后不断补充新数据。之后再固定超参数,补充数据。一年调一两次参数或更久更换一次模型。而学术界更像竞赛,固定数据,换模型调参。

技术分析

改进策略
1数据增强,在测试时多次使用稍弱的增强然后取平均
2使用多个模型,最后结果加权平均
3使用更合适的算法和学习率
4清理数据,实践中有大量同一图片具有不同标号,需要清理

数据方面

 1. 手动去除重复图片; 2. 树叶没有方向性,且图片背景较多。可以做更强的增强,如随机旋转和更大的裁剪; 3. 跨图片增强。可以使用\(\textbf{Mixup}\)随机叠加两张图片,也可以采用\(\textbf{CutMix}\)随机组合来自不同图片的块。

模型方面

 1. 模型多为\(\textbf{ResNet}\)变种。如\(\textbf{DenseNet}\)、\(\textbf{ResNeXt}\)、\(\textbf{ResNeSt}\)、\(\textbf{EfficientNet}\)等; 2. 优化算法多为\(\textbf{Adam}\)及其变种; 3. 学习率一般是\(\cos\)函数,或训练不动时往下调。如采用\(\textbf{lr_scheduler}\)。

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments