写在前面

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 datasetsiterable-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 中的 transformsPIL 中的 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'))

然后将获得的 imglable 作为返回值。

__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 等,需要注意的是 BNdropout 一般不并用,似乎是会导致方差偏移。

有关于卷积层的输入和输出尺寸问题,这里有一个公式,假设一些变量 $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() 来改变学习率。

schedulertorch.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,结果如图

模型二分数

模型二loss

测试集准确率可以到92,但是这个 loss 波动有点夸张,于是我将学习率改成 0.01,跑了 50 个 epoch,结果如图

AdamW 分数

实际上这个是手滑了,本来想用 SGD 的,结果学习率啥的改了,优化器没改。

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(),可能会打乱设定的格式。

横向柱状图

想做一下预测一张图片的时候显示的结果,按照预想的,结果图分为两个部分,左边第一部分是原图片,右边则是从小到大排列的可能的结果。

这个可以通过上面的多子图来实现,不多说。

主要是这个横向柱状图。其实就是把 xy 轴颠倒了一下。

直接使用 barh(x,y,align='center') 来作图,这里的 x 代表纵轴,y 代表横轴,align 表示下表是否居中。使用 axis('off') 可以关闭图像的坐标,可以在作原图像子图时用到。

这里由于 x 全是数字,需要转换成对应的类名,可以使用 yticks(index, label) 来改变纵轴上数值的名称,将 index 改为 label

完成的效果:
predicted result

结尾

真正的从零开始做一个东西,总共算下来大概花了两个周末来完成这个模型,期间狂听 Aimer,已经沉迷于这个甜美的旅人。

从的来说感觉还是挺不错的,好歹学到了一些东西 (指狂听 Aimer,比在家闲着摸鱼强。

本文作者:masteren
本文链接:https://blog.masteren.top/archives/15/
版权声明:本文部分内容引用或转载自网络,已标明出处,若侵权请联系删除。本文原创部分采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议(CC BY-NC-SA 4.0 ) 进行许可,转载时须注明出处及本声明。

上一篇:记录一件比较坑的事

下一篇:Fiddler + N_m3u8DL-CLI 下载钉钉直播录像

添加新评论