李宏毅-食物图像分类器


1 实验目的

掌握使用Pytorch的使用方法:

  • Pytorch的安装以及环境搭建
  • Pytorch处理数据
  • Pytorch计算梯度以及搭建神经网络
  • Pytorch训练模型

并使用Pytorch来训练CNN模型,实作一个食物的图像分类器。


2 实验要求

  • 可以使用tensorflow或者pytorch库
  • 必须使用CNN实作model
  • 不能使用额外dataset
  • 禁止使用 pre-trained model(只能自己手写CNN)
  • 请不要上网寻找 label
  • 上传格式为 csv,第一行必须为 Id, Category,第二行开始为预测结果,
    每行分别为 id 以及预测的 Category,请以逗号分隔
  • 请说明你实现的CNN模型,其模型架构、训练参数量和准确率为何
  • 请实作与第一题接近的参数量,但CNN深度(CNN层数)减半的模型,并说明其模型架构、训练参数量和准确率为何
  • 请说明由 1 ~ 2 题的实验中你观察到了什么
  • 请尝试 data normalization 及 data augmentation,说明实作方法并且说明实行前后对准确率有什么样的影响

3 实验环境

3.1 硬件环境
  • 笔记本电脑
  • Intel Core i5
  • NVIDIA GeForce MX350
3.2 软件环境
  • windows10操作系统
  • Python 3.8 64-bit
  • Visual Studio Code
  • NVIDIA CUDA
  • Kaggle Accelerator GPU

4 实验数据

此次数据集为网络上搜集到的食物照片,共有11类:

Bread, Dairy product, Dessert, Egg, Fried food, Meat, Noodles/Pasta, Rice, Seafood, Soup, Vegetable/Fruit

Training set: 9866张

Validation set: 3430张

Testing set: 3347张

下载 zip 文件后解压缩会有三个文件夹,分别为training、validation 以及 testing
training 以及 validation 中的照片名称格式为 [类别]_[编号].jpg,例如 3_100.jpg 即为类别 3 的照片(编号不重要)


5 模型A:5Layers

5.1 读取数据集

首先定义函数readfile,输入参数path为文件夹路径名,布尔型参数label判断是否需要额外读取label。

readfile函数先通过os.listdir()返回文件夹包含的文件或文件夹的名字的列表,再利用OpenCV(cv2)读入图片,将每个图片的形状统一缩放为128*128,最后存放在numpy array中返回:

def readfile(path, label):image_dir = sorted(os.listdir(path))# 创建array类型的x为输入向量# 一共len(image_dir)个图片,每个图片像素大小128*128,分为3个channel# 所以向量大小为len(image_dir)*128*128*3# 其次规定了数据类型为uint8x = np.zeros((len(image_dir), 128, 128, 3), dtype=np.uint8)# 创建array类型的y为输出向量# 一共len(image_dir)个图片,每个图片各有1个label# 所以向量大小为len(image_dir)*1# 同样规定了数据类型为uint8y = np.zeros((len(image_dir)), dtype=np.uint8)# 枚举每个图片的路径for i, file in enumerate(image_dir):# 拼接图片路径并读取图片数据img = cv2.imread(os.path.join(path, file))# 将每个图片统一resize为128*128x[i, :, :] = cv2.resize(img,(128, 128))# 是否有label即输出,有则将其存入y向量if label:y[i] = int(file.split("_")[0])# 根据label是否为True返回值不同if label:return x, yelse:return x

找到数据集的目录路径,利用刚刚定义的readfile函数读取数据集,并计算输出训练集和验证集的大小:

# 所有数据集的目录路径workspace_dir = '../input/ml-lab/food11'print("Reading data......")# 用自定义的readfile函数读取训练集数据,label已知,所以参数为Truetrain_x, train_y = readfile(os.path.join(workspace_dir, "training"), True)# 训练集大小print("Size of training data = {}".format(len(train_x)))# 用自定义的readfile函数读取验证集数据,label已知,所以参数为Trueval_x, val_y = readfile(os.path.join(workspace_dir, "validation"), True)# 验证集大小print("Size of validation data = {}".format(len(val_x)))# 用自定义的readfile函数读取测试集数据,label未知,所以参数为Falsetest_x = readfile(os.path.join(workspace_dir, "test"), False)# 测试集大小print("Size of Testing data = {}".format(len(test_x)))
5.2 数据预处理

定义训练集和验证集的数据处理对象transforms.Compose(),transforms.Compose()是一个图像预处理包,它可以包含其类内的一系列操作:

  • transforms.Resize()把给定的图片resize到given size
  • transforms.ToPILImage()将tensor转换为PIL图像
  • transforms.ToTensor()将PIL图像转换为tensor

这里将训练集和验证集的数据处理分开定义是为了便于之后模型的改进:

train_transform = transforms.Compose([# 转换为PIL图像transforms.ToPILImage(),# 转换为Tensor对象transforms.ToTensor(), ])test_transform = transforms.Compose([# 转换为PIL图像transforms.ToPILImage(),# 转换为Tensor对象transforms.ToTensor(), ])

自定义一个类ImgDataset来继承Dataset,然后实现getitem()方法和len()方法,len()返回Dataset的大小,getitem()返回某个规定index图片处理后的结果:

class ImgDataset(Dataset):# 魔术构造函数初始化def __init__(self, x, y=None, transform=None):# 承接向量x和yself.x = xself.y = y# 将label的类型转为LongTensorif y is not None:self.y = torch.LongTensor(y)# 使用刚刚定义的transformself.transform = transformdef __len__(self):return len(self.x)def __getitem__(self, index):X = self.x[index]if self.transform is not None:# 图片处理X = self.transform(X)if self.y is not None:# 获取labelY = self.y[index]return X, Yelse:return X

定义数据处理的块大小,再对训练集和验证集实例化刚刚定义的Dataset类:

batch_size = 128train_set = ImgDataset(train_x, train_y, train_transform)val_set = ImgDataset(val_x, val_y, test_transform)

Dataloader的处理逻辑是先通过Dataset类里面的 getitem()函数获取单个的数据,然后组合成batch,再使用collate_fn所指定的函数对这个batch做一些操作,比如padding等。

另外Dataloader的布尔类型参数shuffle决定是否打乱数据,训练集需要,验证集不需要:

train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True)val_loader = DataLoader(val_set, batch_size=batch_size, shuffle=False)
5.3 神经网络架构
5.3.1 CNN神经网络

在使用torch.nn.Sequential构建CNN神经网络过程中使用了pytorch库中nn模块的四个函数,分别为:

  • 卷积运算函数torch.nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding)
  • 归一化处理函数torch.nn.BatchNorm2d(channels)可以使得在过激活函数的时候不会因为数据过大导致网络的不稳定
  • 激活函数torch.nn.ReLU()
  • 池化函数torch.nn.MaxPool2d(kernel_size, stride, padding)采用MaxPooling策略,可以降低模型的运算量

第一层:

卷积层3个channel,64个filter,每个filter大小3*3,stride=1,padding=1,输入3*128*128,输出64*128*128。

池化层使用Max Pooling方法,输入64*128*128,输出64*64*64。

nn.Conv2d(3, 64, 3, 1, 1),nn.BatchNorm2d(64),nn.ReLU(),nn.MaxPool2d(2, 2, 0),

第二层:

卷积层64个channel,128个filter,每个filter大小3*3,stride=1,padding=1,输入64*64*64,输出128*64*64。

池化层使用Max Pooling方法,输128*64*64,输出128*32*32。

nn.Conv2d(64, 128, 3, 1, 1),nn.BatchNorm2d(128),nn.ReLU(),nn.MaxPool2d(2, 2, 0),

第三层:

卷积层,128个channel,256个filter,每个filter大小3*3,stride=1,padding=1,输入128*32*32,输出256*32*32。

池化层使用Max Pooling方法,输入256*32*32,输出256*16*16。

nn.Conv2d(128, 256, 3, 1, 1),nn.BatchNorm2d(256),nn.ReLU(),nn.MaxPool2d(2, 2, 0),

第四层:

卷积层,256个channel,512个filter,每个filter大小3*3,stride=1,padding=1,输入256*16*16,输出512*16*16。

池化层使用Max Pooling方法,输入512*16*16,输出512*8*8。

nn.Conv2d(256, 512, 3, 1, 1),nn.BatchNorm2d(512),nn.ReLU(),nn.MaxPool2d(2, 2, 0),

第五层:

卷积层,512个channel,512个filter,每个filter大小3*3,stride=1,padding=1,输入512*8*8,输出512*8*8。

池化层使用Max Pooling方法,输入512*8*8,输出512*4*4。

nn.Conv2d(512, 512, 3, 1, 1),nn.BatchNorm2d(512),nn.ReLU(),nn.MaxPool2d(2, 2, 0),
5.3.2 全连接神经网络

在使用torch.nn.Sequential构建全连接神经网络过程中使用了pytorch库中nn模块的两个函数,分别为:

  • 全连接分类器torch.nn.Linear(in_features , out_features)
  • 激活函数torch.nn.ReLU()
self.fc = nn.Sequential(# 输入512*4*4,输出1024*1nn.Linear(512*4*4, 1024),nn.ReLU(),# 输入1024*1,输出512*1nn.Linear(1024, 512),nn.ReLU(),# 输入512*1,输出11*1,即11种食物种类nn.Linear(512, 11))
5.3.3 计算参数量
输入filter输出计算式参数量
CNN13*(128*128)64*(3*3)64*(128*128)64*(3*3*3)1728
CNN264*(64*64)128*(3*3)128*(64*64)128*(3*3*64)73728
CNN3128*(32*32)256*(3*3)256*(32*32)256*(3*3*128)294912
CNN4256*(16*16)512*(3*3)512*(16*16)512*(3*3*256)1179648
CNN5512*(8*8)512*(3*3)512*(8*8)512*(3*3*512)2359296
FC1512*4*41024(8192+1)*10248389632
FC21024512(1024+1)*512524800
FC351211(512+1)*115643

参数总量=1728+73728+294912+1179648+2359296+8389632+524800+5643=12829387

5.4 训练和验证

首先实例化刚刚定义的训练模型,并初始化一些参数,如GPU加速、损失函数、学习率、迭代次数等:

# GPU加速model = Classifier().cuda()# 由于是分类模型,损失函数使用交叉熵loss = nn.CrossEntropyLoss()# optimizer使用Adam优化器,学习率设为0.001optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # 迭代次数num_epoch = 30

每次迭代的过程如下:

训练过程包括切换model为train,通过模型输出预测值,计算某个batch的损失函数和梯度,利用梯度更新参数值,最后汇总得到这一轮迭代的准确率和loss。

验证过程也类似,切换model为eval确保在验证的过程是使用的训练数据的参数,直接通过模型输出预测值与实际label比较计算得到准确率和loss即可。

for epoch in range(num_epoch):# 记录开始时间epoch_start_time = time.time()# 训练集上的准确率train_acc = 0.0# 训练集上的losstrain_loss = 0.0# 验证集上的准确率val_acc = 0.0# 验证集上的lossval_loss = 0.0# 确保model是在train model,即训练model.train() for i, data in enumerate(train_loader):# 梯度参数归零optimizer.zero_grad()# 呼叫forward函数,并得到预测值train_pred = model(data[0].cuda())# 计算这个batch的lossbatch_loss = loss(train_pred, data[1].cuda())# 计算每个参数的梯度batch_loss.backward()# 更新参数值optimizer.step() # 更新准确率,numpy.argmax(array, axis)返回一个numpy数组中最大值的索引值train_acc += np.sum(np.argmax(train_pred.cpu().data.numpy(), axis=1) == data[1].numpy())# 将这个batch的loss添加到总losstrain_loss += batch_loss.item()# 切换model到eval model,即测试model.eval()with torch.no_grad():for i, data in enumerate(val_loader):# 呼叫forward函数,并得到预测值val_pred = model(data[0].cuda())# 计算这个batch的lossbatch_loss = loss(val_pred, data[1].cuda())# 更新准确率,numpy.argmax(array, axis)返回一个numpy数组中最大值的索引值val_acc += np.sum(np.argmax(val_pred.cpu().data.numpy(), axis=1) == data[1].numpy())# 将这个batch的loss添加到总lossval_loss += batch_loss.item()#打印本次迭代的结果print('[%03d/%03d] %2.2f sec(s) Train Acc: %3.6f Loss: %3.6f | Val Acc: %3.6f loss: %3.6f' % (epoch + 1, num_epoch, time.time()-epoch_start_time, train_acc/train_set.__len__(), train_loss/train_set.__len__(), val_acc/val_set.__len__(), val_loss/val_set.__len__()))
5.5 扩大训练集

在5.4中我们已经通过训练找到了比较好的参数,并通过验证集大概得到了模型的准确率。

我们还想进一步训练模型,但是限于实验要求不能使用数据集之外的数据。

一个很好的策略就是扩大训练集,利用的就是5.4中的验证集,将训练集和验证集合并为新的更大的训练集,在这个新的训练集上再次训练,这样充分利用了实验给出的数据集。

下面的代码展示了如何将训练集和验证集合并为新的更大的训练集:

# 拼接训练集和验证集成为一个新的更大的训练集train_val_x = np.concatenate((train_x, val_x), axis=0)train_val_y = np.concatenate((train_y, val_y), axis=0)# 对新的训练集实例化Datasettrain_val_set = ImgDataset(train_val_x, train_val_y, train_transform)# 对新的训练集进行DataLoader,其中布尔类型参数shuffle决定是否打乱数据,训练集需要,所以为Truetrain_val_loader = DataLoader(train_val_set, batch_size=batch_size, shuffle=True)

训练过程同5.4中的训练过程,此时已经没有验证集,所以没有验证过程,代码几乎相同,限于篇幅这里就不赘述了。

5.6 预测结果

先载入测试集到Dataset中,切换模型的model为eval确保在预测的过程是使用的训练数据的参数,另外定义列表prediction用于存储预测结果:

# 对测试集实例化Datasettest_set = ImgDataset(test_x, transform=test_transform)# 对测试集进行DataLoader,其中布尔类型参数shuffle决定是否打乱数据,测试集不需要,所以为Falsetest_loader = DataLoader(test_set, batch_size=batch_size, shuffle=False)# 切换model到eval model,即测试model_best.eval()# 存储预测值,以便于写入结果文件prediction = []

预测过程类似验证过程,将输入给到训练好的模型,得到输出结果并添加到结果列表prediction:

with torch.no_grad():for i, data in enumerate(test_loader):# 呼叫forward函数,并得到预测值test_pred = model_best(data.cuda())# 得到该图片的预测结果,通过numpy.argmax(array, axis)返回一个numpy数组中最大值的索引值test_label = np.argmax(test_pred.cpu().data.numpy(), axis=1)#将结果加入预测结果的列表for y in test_label:prediction.append(y)

最终按照实验要求整理格式,并输出结果到csv文件

with open("predict_模型A_5Layers.csv", 'w') as f:f.write('Id,Category\n')for i, y in enumerate(prediction):f.write('{},{}\n'.format(i, y))
5.7 模型准确率
训练次数训练集准确率验证集准确率扩大训练集准确率
100.6471720.4725950.706453
200.8615450.5810500.942464
300.9798300.6387760.980445

6 模型B:3Layers

6.1 深度减半的CNN神经网络

为了达到与模型A相近的参数量,不可以单纯从5层CNN中随意删除2层来达到目的,需要适当调整filter的数量。

第一层:

卷积层3个channel,256个filter,每个filter大小3*3,stride=1,padding=1,输入3*128*128,输出256*128*128。

池化层使用Max Pooling方法,输入256*128*128,输出256*64*64。

nn.Conv2d(3, 256, 3, 1, 1),nn.BatchNorm2d(256),nn.ReLU(),nn.MaxPool2d(2, 2, 0),

第二层:

卷积层256个channel,512个filter,每个filter大小3*3,stride=1,padding=1,输入256*64*64,输出512*64*64。

池化层使用Max Pooling方法,输入512*64*64,输出512*16*16。

nn.Conv2d(256, 512, 3, 1, 1),nn.BatchNorm2d(512),nn.ReLU(),nn.MaxPool2d(4, 4, 0),

第三层:

卷积层,512个channel,512个filter,每个filter大小3*3,stride=1,padding=1,输入512*16*16,输出512*16*16。

池化层使用Max Pooling方法,输入512*16*16,输出512*4*4。

nn.Conv2d(512, 512, 3, 1, 1),nn.BatchNorm2d(512),nn.ReLU(),nn.MaxPool2d(4, 4, 0),
6.2 计算参数量
输入filter输出计算式参数量
CNN13*(128*128)256*(3*3)256*(128*128)256*(3*3*3)6912
CNN2256*(64*64)512*(3*3)512*(64*64)512*(3*3*256)1179648
CNN3512*(16*16)512*(3*3)512*(16*16)512*(3*3*512)2359296
FC1512*4*41024(8192+1)*10248389632
FC21024512(1024+1)*512524800
FC351211(512+1)*115643

参数总量=6912+1179648+2359296+8389632+524800+5643=12465931

可见参数总量和模型A的参数总量(12829387)相近,可以更客观地反映出CNN深度对于模型准确率的影响。

6.3 模型准确率
训练次数训练集准确率验证集准确率扩大训练集准确率
100.6972430.5151600.742329
200.8655990.5860060.922307
300.9771940.6344020.973827

7 模型C:5Layers+normalization

7.1 归一化过程

transforms.Normalize()用均值和标准差归一化张量图像,将每个元素分布到(-1,1),参照如下公式:
σ=1 N∑ i=1N ( x i−μ)2 z i=x i−μσ\sigma=\sqrt{\frac{1}{\mathrm{~N}} \sum_{i=1}^{\mathrm{N}}\left(\mathrm{x}_{\mathrm{i}}-\mu\right)^{2}}\\ \mathrm{z}_{\mathrm{i}}=\frac{\mathrm{x}_{\mathrm{i}}-\mu}{\sigma} σ=N1i=1N(xiμ)2 zi=σxiμ
修改训练集和验证集的数据处理对象transforms.Compose():

train_transform = transforms.Compose([# 转换为PIL图像transforms.ToPILImage(),# 转换为Tensor对象transforms.ToTensor(), # 用均值和标准差归一化张量图像transforms.Normalize((0.5,0.5,0.5),(0.5,0.5,0.5))])test_transform = transforms.Compose([# 转换为PIL图像transforms.ToPILImage(),# 转换为Tensor对象transforms.ToTensor(), # 用均值和标准差归一化张量图像transforms.Normalize((0.5,0.5,0.5),(0.5,0.5,0.5))])
7.2 模型准确率
训练次数训练集准确率验证集准确率扩大训练集准确率
100.6327790.4772590.727888
200.8404620.6256560.947879
300.9882420.6504370.980295

8 模型D:5Layers+augmentation

8.1 数据增强过程

数据增强包括翻转(flips)、移位(translations)、旋转(rotations)、缩放比例(Scale)、裁剪(Crop)、高斯噪声(Gaussian Noise)等微小的改变,以此训练出的模型适应性会更强。

在图像预处理包transforms.Compose()中可以找到相应的函数来完成这些操作:

  • transforms.RandomHorizontalFlip()以0.5的概率水平翻转给定的PIL图像
  • transforms.RandomVerticalFlip()以0.5的概率竖直翻转给定的PIL图像
  • transforms.RandomGrayscale()将图像以一定的概率转换为灰度图像
  • transforms.ColorJitter()随机改变图像的亮度对比度和饱和度
  • transforms.RandomResizedCrop()将PIL图像裁剪成任意大小和纵横比
  • transforms.RandomRotation()按照degree将图像随机旋转一定角度

由于测试集不需要数据增强,因此只需要修改训练集的数据处理对象transforms.Compose():

# 训练集相比测试集额外添加了数据增强(data augmentation)train_transform = transforms.Compose([# 转换为PIL图像transforms.ToPILImage(),# 以0.5概率水平翻转随机PIL图像transforms.RandomHorizontalFlip(),# 按照degree将图像随机旋转一定角度transforms.RandomRotation(15), # 转换为Tensor对象transforms.ToTensor(), ])
8.2 模型准确率
训练次数训练集准确率验证集准确率扩大训练集准确率
100.5871680.5317780.660725
200.7572470.6145770.809792
300.8500910.6615160.912229

9 分析评估

9.1 CNN深度对模型准确率的影响

根据5.7和6.3中的数据,模型B无论是在训练集还是验证集上的准确率相比模型A均有轻微下降,且模型B在训练过程中的收敛速度稍快。

分析原因,模型A的参数总量为12829387,而模型B的参数总量为12465931,参数在数量上接近,后者相比前者略少,CNN层数也仅差2层,非线性的表达能力差别不是特别大。

在一定范围内,CNN的深度越深,参数量越多,模型的训练效果就会越好。

但是目前研究已经发现发现传统的CNN网络结构随着层数加深到一定程度之后,越深的CNN网络训练出的模型反而效果更差。

9.2 归一化对模型准确率的影响

根据5.7和7.2中的数据,经过data normalization之后,模型C无论是在训练集还是验证集上的准确率相比模型A均有提升,其中在验证集上的准确率提升了2个百分点,可见归一化操作对模型性能提升方面发挥了积极作用。

由于模型A和模型C的神经网络架构相同,均为5Layers,所以两个模型在训练过程中的收敛速度接近。

9.3 数据增强对模型准确率的影响

根据5.7和8.2中的数据,经过data augmentation之后,模型D虽然在训练集上的准确率相比模型A有所降低,但是在验证集上的准确率有了比模型C更大的提升,可以认为data augmentation增强了模型的适应性,提升了模型的性能。

由于模型A和模型D的神经网络架构相同,均为5Layers,所以两个模型在训练过程中的收敛速度接近。

9.4 进一步优化模型

如果想要进一步优化模型,还可以从以下几个并未在实验中探究的方面入手:

  • 尝试寻找filter的最佳kernel size
  • 尝试寻找最佳的learning rate
  • 尝试寻找最佳的batch size
  • 进一步完善模型的神经网络结构
  • 进一步对数据集data augmentation

10 References

[1] python.org

[2] kaggle.com

[3] baike.baidu.com

[4] www.csdn.net

[5] pytorch.org