记录一次使用卷积神经网络进行图片二分类的实战

写在前面

笔者目前就读的专业是软件工程,并非人工智能专业,但是由于对人工智能有兴趣,于是课下进行了一些自学。正巧最近有些闲暇时间,就想着使用自学的内容做个小型的实战。这篇文章的主要目的也就是从一个入门者的角度,去记录一下这整个流程,顺便也分享一下自己的心得体会。(这里就假定读者都是有一定的深度学习基础了,一些简单的概念,例如k折交叉验证,就不再具体阐述了)
参考资料采用的是沐神的《动手深度学习》,谷歌一下就能找到这本书的pdf版本

需求分析

在b站上有很多视频,每个视频都会有一个封面,笔者希望能训练出一个模型,这个模型可以帮我们在一大堆视频中找到关于猫的视频。具体而言,就是将封面图片作为输入,将封面中含有猫的确信度作为输出,如果确信度大于50%就认为这个封面里面有猫,从而判断这个视频是和猫相关的。这就相当于一个另类的内容推荐系统了,从一大堆视频中找到自己感兴趣的视频,就是这个模型的实用价值。

准备数据集

数据集是深度学习实战中非常重要而且困难的一个环节,通常在教学中,我们都是使用现成的数据集,来进行模型的训练,例如在CNN教学中最经典的数据集Fashion-MNIST。真正开始自己DIY数据集的时候,摆在我们面前的难题就有2个:

  1. 如何选择负例数据?
    这个问题确实困扰了作为入门者的笔者一段时间,因为正例非常好找,只需要找一大堆包含猫的图片即可,但是负例呢?只需要不包含猫的图片都行吗?如果是的话,那一大堆纯色图也可以作为负例吗?
    在使用教学用的数据集,例如Fashion-MNIST时,我们并没有思考这个问题,因为Fashion-MNIST中的数据只有10个特征都很鲜明的类别,例如,如果我们想要判断一张图是不是衬衫,表面上我们做的事是训练模型来得出 这张图是衬衫 和 这张图不是衬衫 的可信度,但实际上我们做的事是,训练模型来得出 这张图是衬衫 和 这张图是大衣,凉鞋,…等另外9个类别物品 的可信度,但如果按照这样的思路,那我们在找负例的时候,是不是需要穷举所有图片中不包含猫的情况?例如一些包含狗的图片,一些包含汽车的图片…,如果是这样的话,那就不具备可操作性了,因为工作量太大了。
    笔者翻了大量资料,也没有这方面的回答,如果读者对这个问题有所了解,还恳请在评论区赐教。
    最终笔者采取了一个比较妥协的方法,就是在b站里面随机找视频,然后保存封面作为负例,在获取了一定量的封面后,笔者再进行人工筛选,剔除掉包含猫的图片。

  2. 如何获取到大量的图片?
    这一点就比较简单了,只需要使用python爬虫,来爬取b站视频封面即可,具体代码由于可能涉及到版权问题,就不放出来了。
    需要注意的一点是,这一步还需要在下载图片后对图片进行缩放,因为之后输入模型的图片的大小都是统一的大小,这里笔者设定的大小是240x240。
    处理图片可以使用opencv-python库,安装方法为pip install opencv-python,图片的下载和缩放可以用下面的代码解决(可以稍微注意一下,opencv中读入的图片是以numpy中的ndarray的形式保存的)

def save_img(url,path):
    #Download image
    res = requests.get(url)
    img = res.content
    with open(path, "wb") as f:
        f.write(img)
    print("Downloaded " + path)

    #Resize image
    img = cv2.imread(path)
    img = cv2.resize(img, dsize=(240, 240), fx=1, fy=1, interpolation=cv2.INTER_LINEAR)
    cv2.imwrite(path, img, [cv2.IMWRITE_JPEG_QUALITY, 100])
    print("Processed " + path)

搭建模型

接下来就正式的写深度学习代码了,笔者使用的框架是pytorch
最开始搭建的模型的网络结构参考了著名的LeNet,也就是最开始使用卷积神经网络进行手写数字识别的网络,具体网络结构如下
在这里插入图片描述
激活函数均使用的ReLU,代码表现为

def get_net():
    return nn.Sequential(
        nn.Conv2d(3, 6, kernel_size=45), nn.ReLU(),
        nn.AvgPool2d(kernel_size=18, stride=2),
        nn.Conv2d(6, 9, kernel_size=20), nn.ReLU(),
        nn.AvgPool2d(kernel_size=9, stride=2),
        nn.Flatten(),
        nn.Linear(9216, 544), nn.ReLU(),
        nn.Linear(544, 68),nn.ReLU(),
        nn.Linear(68, 2), nn.Softmax()
    ).to(device)

整个项目的完整代码如下

import random

import torch
from torch import nn

from matplotlib import pyplot as plt
import numpy as np
import cv2
import os

from my_dataset import get_dataloader

device="cuda"
img_size=240


def read_img_to_numpy(img_path):
    img = cv2.imread(img_path)/128  #为了进行归一化
    img = np.concatenate(
        (img[:, :, 0].reshape((1, img_size, img_size)), img[:, :, 1].reshape((1, img_size, img_size)), img[:, :, 2].reshape((1, img_size, img_size))),
        axis=0)
    return img


def read_all_img():
    positive_dir="samples/positive/"
    negative_dir="samples/negative/"

    features=None
    labels=[]

    names=[positive_dir+name for name in os.listdir(positive_dir)]+[negative_dir+name for name in os.listdir(negative_dir)]
    indexes=list(range(len(names)))
    random.shuffle(indexes)

    for index in indexes:
        name=names[index]
        if positive_dir in name:
            label=1
        else:
            label=0

        labels.append(label)
        img = read_img_to_numpy(name).reshape(1, 3, img_size, img_size)
        img = torch.tensor(img,dtype=torch.float32, device=device)
        if features is None:
            features=img
        else:
            features=torch.concat((features,img))

    labels=torch.tensor(labels,dtype=torch.int64,device=device)

    return features,labels


def get_net():
    return nn.Sequential(
        nn.Conv2d(3, 6, kernel_size=45), nn.ReLU(),
        nn.AvgPool2d(kernel_size=18, stride=2),
        nn.Conv2d(6, 9, kernel_size=20), nn.ReLU(),
        nn.AvgPool2d(kernel_size=9, stride=2),
        nn.Flatten(),
        nn.Linear(9216, 544), nn.ReLU(),
        nn.Linear(544, 68),nn.ReLU(),
        nn.Linear(68, 2), nn.Softmax()
    ).to(device)


def eval_accuracy(net,test_iter):
    total=0
    accurate=0
    for X,y in test_iter:
        y_hat=net(X).argmax(axis=1)

        e=(y==y_hat)
        accurate+=e.sum()
        total+=len(X)

    return accurate/total


def train_for_k_fold(net,train_iter,test_iter,lr,epochs,fold):
    def init_weights(m):
        if type(m)==nn.Linear or type(m)==nn.Conv2d:
            nn.init.xavier_uniform_(m.weight)
    net.apply(init_weights)
    optimizer=torch.optim.Adam(net.parameters(),lr=lr)
    loss=nn.CrossEntropyLoss()
    loss_record=[]
    accuracy_record=[]
    for epoch in range(epochs):
        net.train()
        for X,y in train_iter:
            optimizer.zero_grad()
            y_hat=net(X)
            l=loss(y_hat,y)
            l.backward()
            optimizer.step()
        loss_record.append(l.to("cpu").detach().numpy())
        accuracy=eval_accuracy(net,test_iter)
        accuracy_record.append(accuracy.to("cpu").detach().numpy())

        print(f"Epoch {epoch}, loss {l}, accuracy {accuracy}")
    #plot
    print(f"Loss {loss_record}")
    print(f"Accuracy {accuracy_record}")
    epoch=np.arange(len(loss_record))
    plt.title(f"fold {fold} lr {lr}, epochs {epochs}")
    plt.plot(epoch,np.array(loss_record),label="Loss")
    plt.plot(epoch,np.array(accuracy_record),label="Accuracy")
    plt.legend()
    plt.savefig(f"fold_{fold}_lr_{lr}_epochs_{epochs}.png")


def k_fold(k=4,lr=0.9,epochs=10,batch_size=3):
    features, labels = read_all_img()
    total_len = len(features)
    fold_len = int(total_len / k)

    for fold in range(k):
        print(f"Start fold {fold}")
        test_start = fold_len * fold
        test_end = min((fold + 1) * fold_len, total_len)

        test_features = features[test_start:test_end]
        test_labels = labels[test_start:test_end]

        train_features = torch.concat((features[:test_start], features[test_end:]))
        train_labels = torch.concat((labels[:test_start], labels[test_end:]))

        train_iter=get_dataloader(train_features,train_labels,batch_size)
        test_iter=get_dataloader(test_features,test_labels,batch_size)

        net = get_net()
        train_for_k_fold(net, train_iter, test_iter, lr, epochs,fold)

        #save net
        torch.save(net.state_dict(),f"fold_{fold}.params")


k_fold(lr=1,epochs=100,batch_size=10)

其中,my_dataset.py这个工具文件的代码如下

import torch
from torch.utils import data


class ArrayDataset(data.Dataset):
    def __init__(self,features,labels):
        self.features=features
        self.labels=labels

    def __getitem__(self, item):
        return self.features[item],self.labels[item]


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


def get_dataloader(features,labels,batch_size,device="cuda"):
    if not torch.is_tensor(features):
        features=torch.tensor(features,dtype=torch.float32,device=device)
        labels=torch.tensor(labels,dtype=torch.int64,device=device)

    return data.DataLoader(ArrayDataset(features,labels),batch_size,shuffle=True)

上面的代码实现的功能主要是做k折交叉验证来帮助我们寻找合适的超参数,正式的训练代码只需要在这基础上简单的改动即可,这里就不放出来了

训练模型

在训练模型阶段,笔者遇到了各种各样的问题,这也是整个过程中最折腾的部分

问题1:显卡显存不足

之前我们使用的模型和数据集都非常简单,所以2G的GTX1050完全能够胜任,但如今数据量是之前的几十倍,一运行起来,数据还没有全部加载完成,就报了显存不足,如下图所示
在这里插入图片描述
这个时候就只能去网上租借GPU服务器了,笔者找到个平台,注册就送10元代金券,目前总共花费了10.3元,其中10元还是平台提供的代金券抵扣的,而且这个平台的计费方式是按使用时长计费,也就是开机时计费,关机后数据会保留,但是不会计费,这对于我们这种只需要短时间使用GPU的学生是非常划算的。为了避免有打广告的嫌疑,平台具体的名字就不放出来了,如果大家有兴趣可以私信笔者
至此,显存不足的问题算是解决了,写到这里,有了一点感悟,这也是沐神在书中提到的一点,早期人工智能领域的发展速度没有当今快,有一个很大的原因就是硬件资源不足。在2000年左右,可能一块2GB显存的显卡都是很贵的硬件,但是后面跑AlexNet至少也需要20GB的显存,所以可见在早期时候,像AlexNet之类的网络,即使能够实现,也很难展开运算

问题2:模型对所有样本的输出相同

相信这是初学者都会碰到的情况,就是无论输入是什么,模型最终的输出都是一样的
具体回到我们这个实战,在训练过程中,我发现每一个epoch的准确率都是相等的,这显然很异常,所以我修改了一下代码,在eval_accuracy函数处做了如下修改

        # y_hat=net(X).argmax(axis=1)
        y_hat=net(X)
        print(y_hat)
        y_hat=y_hat.argmax(axis=1)

这段代码的目的就是为了打印出y_hat,也就是输入为正例和负例的确信度,也就是softmax层的输出
结果输出很奇怪,所有的输出都是一样的,就像下面这张图中的结果
有了上次处理Dead ReLU的经验,这次可以基本确定是学习率太大了,所以尝试把学习率调到1e-3,但是还是一样的结果,最后把学习率调到1e-8,终于不再是清一色的0和1了,但是收敛速度还是略慢,所以继续调高学习率,最终确定为1e-5是个比较合适的值,可以看到,调为1e-5后,输出就正常了
在这里插入图片描述
从这里可以总结出的一个经验就是,遇到输出全部一样的情况,尽管把学习率往小调,直到输出正常,然后再逐步往大调,来提高学习速率,同时需要先解决欠拟合,再考虑解决过拟合

训练结果

最终k折交叉验证的结果如下图(就只取第一个fold的结果展示了,另外3个fold也是类似的)
在这里插入图片描述
最终测试下来,准确率在80%左右,这已经基本达到我的预期了,因为至少这是一个能用的模型了

模型改进

后面笔者也尝试通过更改优化器,学习率,学习周期等方法来增加准确率,但是都没能成功
推测一方面是数据集不够多,例如一张图片里的内容是一个楼梯,但是反例中并没有出现楼梯,所以就会造成反例的确信度降低
另一方面,这个可能是这个网络结构的上限就是如此了,所以我们或许可以通过更改网络结构来提高准确率
其中扩增数据集的工作量较大,而且效果不显著,所以就不再考虑了,这里主要尝试通过修改网络结构来提高准确率
新的网络结构参考了沐神书中提到的AlexNet
在这里插入图片描述
具体代码实现为,将get_net函数修改为如下

def get_net():
    return nn.Sequential(
        nn.Conv2d(3, 96, kernel_size=11,stride=4,padding=1), nn.ReLU(),
        nn.MaxPool2d(kernel_size=3, stride=2),
        nn.Conv2d(96, 256, kernel_size=5,padding=2), nn.ReLU(),
        nn.MaxPool2d(kernel_size=3, stride=2),
        nn.Conv2d(256,384,kernel_size=3,padding=1),nn.ReLU(),
        nn.Conv2d(384, 384, kernel_size=3, padding=1), nn.ReLU(),
        nn.Conv2d(384, 256, kernel_size=3, padding=1), nn.ReLU(),
        nn.MaxPool2d(kernel_size=3,stride=2),
        nn.Flatten(),
        nn.Linear(9216, 4096), nn.ReLU(),
        nn.Dropout(p=0.5),
        nn.Linear(4096, 4096), nn.ReLU(),
        nn.Dropout(p=0.5),
        nn.Linear(4096, 2), nn.Softmax()
    ).to(device)

可以看到,这个网络结构更为复杂了,所以需要的显存也更大了,在实际测试中,10GB的RTX3080已经顶不住了,需要换上24GB的RTX3090来进行训练,最终训练出的模型在测试数据集中的表现达到了87%
其实这并不奇怪,更多的网络参数就意味着这个模型的拟合能力更强,但同时过拟合的可能性也越大,这也是需要我们去权衡的一个trade off

写在最后

这是笔者第一次进行人工智能方向的实战,能做出成果,自然是非常开心的。在这过程中,最深的感悟有两点:

  1. 数据为王
    虽然深度学习不需要像早起机器学习一样,去精心处理数据,但是大量且合理的数据仍然是有必要的,还记得在kaggle上做过一个数字识别的题目,当时kaggle给出的训练数据集中包含了40000个,即使是使用比较简单的LeNet,也能做到91%的识别准确率,所以可见,如果增大数据量,这个模型是有进步空间的
  2. 硬件决定上限
    前面也提到过了,在人工智能发展的早期,硬件资源制约了这个领域的发展。在那个显卡显存可能只有512MB的时代,如果要跑AlexNet这种光是模型参数都有221MB的模型,应该是非常困难的

最后,这篇文章中可能有一些错误或者描述不清的地方,欢迎大家在评论区里批评指正,也欢迎大家在评论区进行探讨与交流

  • 2
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值