从零开始完成人工智能大作业
写在前面
2020年5月6日,最后一节人工智能课上,老师宣布将期末考试改成大作业的形式(其实之前就说过,但是具体是哪天我忘了)。没办法,零基础的我只能选择一个最简单的图片分类来做一做,这篇文章也是记录一下,如果以后有需要的话还可以参考一下。
现在可以在 Github 上找到这个工程
关于 CNN
网上关于 CNN 的介绍有很多,这里不再废话,有条件的可以看看什么是卷积神经网络 CNN (Convolutional Neural Network)
数据集准备
这里国内交通标识分类数据集比较少,我这里在 Chinese Traffic Sign Database 上找到了一个数据集,可惜的是数据集比较小,供训练的图片只有 4k+ 张,而且分布不均匀,58 个分类,有些分类只有几十张训练图。只能后期进行数据增强了。
下载下来数据集和标记文件,为了方便,我将标记文件的格式改成了
path/fileName class
的格式。
从 torch.utils.data
导入 Dataset
,根据官方文档,可以自定义的数据集有两种,map-style datasets
和 iterable-style datasets
,这里选择了第一种,然后创建继承自 Dataset
的数据集类,创建的数据集应该包括三个方法:
class trafficDataset(Dataset):
def __init__(self, label_file_path, train=True):
def __getitem__(self, index):
def __len__(self):
在初始化方法中,需要读取图片和其分类,只需读取标识文件中的图片路径和分类即可
def __init__(self, label_file_path, train=True):
f = open(label_file_path, 'r')
self.data = list(map(lambda line: line.strip().split(' '), f)) # 读取文件和分类
self.train = train # 标记是否用于训练
在 __getitem__
方法中,需要根据给的下标来读取选择的数据,这里由于之前载入的 data
是图片路径和分类,所以 path, label = self.data[index]
,然后根据是否用于训练对图像分别处理。这里的图片处理和数据增强需要用到 torchvision
中的 transforms
和 PIL
中的 Image
。
if self.train:
# 训练集执行数据增强
img = transforms.Compose([transforms.Resize((256, 256)), # 统一尺寸
transforms.RandomRotation([-30, 30]), # 在-30度到30度之间随机旋转
transforms.RandomGrayscale(0.3), # 以0.3的概率将图片转为灰度
transforms.ColorJitter(brightness=0.5, contrast=0.5), # 随机改变图片的亮度饱和度
transforms.ToTensor(), # 将图片转为 Tensor 格式
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)) # 图片归一化
])(Image.open(path).convert('RGB'))
else:
# 测试集不执行
img = transforms.Compose([transforms.Resize((256, 256)),
transforms.ToTensor(),
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))
])(Image.open(path).convert('RGB'))
然后将获得的 img
和 lable
作为返回值。
__len__
方法为获取数据集长度。
使用 pytorch 构建自己的 CNN 网络
构建数据集之后就是关键的 CNN 网络的构建,由于同样是图形分类,这里网络的构建可以参考网上一些经典的 CNN 网络,比如 LeNet-5 网络。
要构建神经元网络,首先导入 torch
中的 nn
,然后继承 nn.Module
构建自己的神经元网络类,应该包含两个方法
class cnn(nn.Module):
def __init__(self): # 初始化
super(cnn, self).__init__()
def forward(self, x): # 正向传播
按照上面说的三个层次,在初始化方法中依次添加卷积层、池化层和全连接层。LeNet-5 网络除了这三个之外还包括一个激活层。
class cnn(nn.Module):
def __init__(self):
super(cnn, self).__init__()
self.conv1 = nn.Conv2d(in_channels=3, # 卷积层
out_channels=16,
kernel_size=5,
stride=1,
padding=2)
self.relu = nn.ReLU(inplace=True) # 激活层
self.maxpool = nn.MaxPool2d(kernel_size=2) # 池化层
self.fc = nn.Liner(16*128*128, 58) # 全连接层
def forward(self, x):
x = self.conv1(x)
x = self.relu(x)
x = self.maxpool(x)
x = x.reshape(x.size(0), -1)
x = self.fc(x)
return x
一个简单的 LeNet-5 网络就搭建完成了。在这个网络里只进行了一次卷积,实际上需要用到的卷积层可能更多,可以根据不同需求来添加。
同时为避免过拟合通常也会添加 dropout
等,需要注意的是 BN
和 dropout
一般不并用,似乎是会导致方差偏移。
有关于卷积层的输入和输出尺寸问题,这里有一个公式,假设一些变量 $H_i, W_i, D_i, H_o, W_o, D_o$ 分别为输入时的长宽深和输出时的长宽深,$C_i, C_o, K, S, P$ 分别为卷积层的五个参数。
有:
$$ \begin{split} H_o &= \left\lfloor\frac{H_i-K+2P}{S}\right\rfloor+1 \\\\ W_o &= \left\lfloor\frac{W_i-K+2P}{S}\right\rfloor+1 \\\\ D_o &= \left\lfloor\frac{D_i-K+2P}{S}\right\rfloor+1 \end{split} $$
池化层使用同一个公式。
训练部分
在数据集和神经网络都搭建完成之后,就可以开始训练自己的模型了。
开始之前需要创建数据集对象
full_data = trafficDataset(label_file_path='./Train/label_train.txt', train=True) # 指定训练集和验证集的数据
train_size = int(0.9 * len(full_data))
vali_size = len(full_data) - train_size
# 将训练集的一部分拿出来用做验证
train_data, vali_data = torch.utils.data.random_split(full_data, [train_size, vali_size])
train_loader = DataLoader(train_data, batch_size=16, shuffle=True) # 每16个一批进行训练,顺序随机
vali_loader = DataLoader(vali_data, batch_size=16, shuffle=True)
如果需要使用 GPU 进行训练,需要指定模型对象和数据使用的设备
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu') model = cnn().to(device)
然后指定用于后向传播的 loss
方法、优化器、学习率和 epoch
等数据。
EPOCH = 50
LEARNING_RATE = 0.001 # 学习率
loss_func = nn.CrossEntropyLoss() # 交叉熵
optimizer = torch.optim.AdamW(model.parameters(), lr=LEARNING_RATE, amsgrad=False, weight_decay=0.0005)
如果需要按
epoch
设置动态学习率,可以使用torch.optim.lr_scheduler
中的方法来设置衰减率,然后在每个epoch
结束后进行scheduler.step()
来改变学习率。⚠
scheduler
是torch.optim.lr_scheduler.bulabula
的对象
然后就可以开始训练了
for epoch in range(EPOCH):
torch.cuda.empty_cache()
for step, (images, labels) in enumerate(train_loader):
images = images.to(device) # 将数据送到指定设备
labels = labels.to(device)
outputs = model(images) # 获取预测值
loss = loss_func(outputs, labels) # 获取交叉熵
optimizer.zero_grad() # 梯度归零
loss.backward() # 反向传播
optimizer.step() # 梯度更新
# 每20次训练输出一次
if step % 20 == 0:
print('Epoch [{}/{}], Step [{}/{}], Loss: {:.7f}'.format(epoch + 1, EPOCH, step + 1, total_step,
loss.item()))
可以使用类似测试的方法可以在每个 epoch
后对验证集进行一次验证。
训练结束之后可以使用 torch.save(model.state_dict(), 'model.pt')
来将训练好的模型保存到本地,以便测试。
测试部分
如果有保存的模型,先使用创建一个模型对象,并且使用 model.load_state_dict(torch.load('model.pt'))
来加载之前保存的模型。
需要注意的是,在正式开始测试之前,要使用 model.eval()
先将模型对象的模式改成评估。
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
with torch.no_grad():
outputs = model(images) # 获取预测
maxk = max((1, 5))
labels_resize = labels.view(-1, 1)
_, predicted = outputs.topk(maxk, 1, True, True) # 获取可能性最大的5个类别
correct += torch.eq(predicted, labels_resize).sum().item()
total += labels.size(0)
这里采用 top-5 的预测方式来预测类别。
第一个模型
第一个模型用了4个卷积层、激活层、池化层和一个全连接层,没有数据增强。在学习率 0.01 的情况下使用 Adam 优化器跑了 50 个 epoch,结果并不理想,大概只有50左右的正确率。
在后面对训练集进行数据增强后,正确率提高到 80 以上,但是似乎 f1 分数不高,这可把我这个没经验的👶👶急坏了。
于是,在多方咨询和舍友建议下,我将我的 CNN 网络改为了 ResNet。
什么是 ResNet
深度残差网络(Deep residual network, ResNet)是由凯明在2015年提出的一种卷积神经网络。其使用残差学习的方式来解决深层网络退化的问题。
将输入 $x$ 的特征记为 $H(x)$,那么对于这个 $x$, 残差为 $F(x)=H(x)-x$,那么这个特征就是 $H(x)=F(x)+x$。
可以简单理解为在当前特征上增加一些额外信息(残差),这个残差有益最好,但如果这个残差是有害的,这种结构至少可以保证当前特征不会有信息损失。
ResNet 参考并修改了 VGG19 的网络结构,加入了残差单元。
具体在论文里可以看。
ResNet 网络的搭建
pytorch 官方实现
在 pytorch 官方库里有 ResNet 的实现代码,我们可以参照一下,源码在 Github 上可以找到。
自定义搭建
这里主要是在官方的代码上稍微改了一下,由于只用到了 resnet50,所以残差块就只设置了一个,参数也取消掉了。
残差块:
class BasicBlock(nn.Module):
def __init__(self, in_num, num, stride):
super(BasicBlock, self).__init__()
self.stride = stride
self.conv1 = nn.Sequential(
nn.Conv2d(in_num, num, kernel_size=1, bias=False),
nn.BatchNorm2d(num),
nn.ReLU(inplace=True)
)
self.conv2 = nn.Sequential(
nn.Conv2d(num, num, kernel_size=3, stride=self.stride, padding=1, bias=False),
nn.BatchNorm2d(num),
nn.ReLU(inplace=True)
)
self.conv3 = nn.Sequential(
nn.Conv2d(num, num * 4, kernel_size=1, bias=False),
nn.BatchNorm2d(num * 4)
)
self.conv4 = nn.Sequential()
if in_num != num * 4 or stride != 1:
self.conv4 = nn.Sequential(
nn.Conv2d(in_num, num * 4, kernel_size=1, stride=self.stride, bias=False),
nn.BatchNorm2d(num * 4)
)
def forward(self, x):
y = self.conv1(x)
y = self.conv2(y)
y = self.conv3(y)
residual = self.conv4(x)
return nn.ReLU(inplace=True)(residual + y)
第二个模型
在改为 ResNet 网络之后,0.001 学习率用 adamw 跑了 15 个epoch,结果如图
测试集准确率可以到92,但是这个 loss 波动有点夸张,于是我将学习率改成 0.01,跑了 50 个 epoch,结果如图
实际上这个是手滑了,本来想用 SGD 的,结果学习率啥的改了,优化器没改。
感觉还是可以的,至少作为我第一个 AI 模型来说,应该算是比较成功了。
作图
本来是不想画图的,但是要看一个参数的走向,所以就学习了一下。
多子图
由于要作几个分数和 loss 的变化曲线,一个曲线图看起来可能会很挤,而且几个分数的值都差不多,会重叠,所以就做成了多子图的形式。
首先定义一下 figure
的尺寸和 dpi:plt.figure(figsize=(10, 10), dpi=100)
然后这里定义一下网格分布:grid = plt.GridSpec(3, 2, wspace=0.2, hspace=0.5)
,总共是三行两列。
使用 subplot()
可以创建子图,在创建的同时指定子图的布局就行了,比如:plt.subplot(grid[0, 0])
就是在第一行第一列的地方新建一个子图。如果需要跨多个网格,可以使用 start:len
的格式,比如 plt.subplot(grid[2, 0:2])
就是在第 3 行第 1 到 2 列的地方新建一个子图。
这里需要注意一下,如果要定义子图的名称,需要使用的方法是 title()
而不是 subtitle()
。
使用 plot(x,y)
指令就可以作图了。
之后使用 show()
指令就可以显示图表,需要注意的是,如果在子图定义完成之前就使用 show()
,可能会打乱设定的格式。
横向柱状图
想做一下预测一张图片的时候显示的结果,按照预想的,结果图分为两个部分,左边第一部分是原图片,右边则是从小到大排列的可能的结果。
这个可以通过上面的多子图来实现,不多说。
主要是这个横向柱状图。其实就是把 x
,y
轴颠倒了一下。
直接使用 barh(x,y,align='center')
来作图,这里的 x
代表纵轴,y
代表横轴,align
表示下表是否居中。使用 axis('off')
可以关闭图像的坐标,可以在作原图像子图时用到。
这里由于 x
全是数字,需要转换成对应的类名,可以使用 yticks(index, label)
来改变纵轴上数值的名称,将 index
改为 label
。
完成的效果:
结尾
真正的从零开始做一个东西,总共算下来大概花了两个周末来完成这个模型,期间狂听 Aimer,已经沉迷于这个甜美的旅人。
从的来说感觉还是挺不错的,好歹学到了一些东西 (指狂听 Aimer,比在家闲着摸鱼强。
本文作者:masteren本文链接:https://blog.masteren.top/archives/15/ 版权声明:本文部分内容引用或转载自网络,已标明出处,若侵权请联系删除。本文原创部分采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议(CC BY-NC-SA 4.0 ) 进行许可,转载时须注明出处及本声明。