前言(非论文前言)
和用言语讲出来相比,用文字解读论文挺有难度,因为作为听/观众来说,耳朵对信息的过滤效果要好于眼睛。论文看完,用代码把模型跑通、跑出较好的效果也很有难度,因为作者为了涨精度,常常加入一些tricks,但又不写在论文里。
AlexNet模型结构比较简单,所以实现起来不算难,但如果要在自己的数据集上训练,那还是有一定难度的,因为这涉及到数据处理、搭模型、推理预测等阶段,期间会出现很多bug,得一个一个解决。
出于篇幅和工作量的问题,本文的解读方式并不是像网上那样翻译一波+长篇大论(或复制别人的讲解),而是把我觉得有价值的地方捋一遍,适当补充一些知识点,然后把我自己跑通的代码讲一讲(又一次不忍吐槽:网上大部分代码都是bug满天飞的,作者到底有没有自己运行过...)。另外,本文采用的数据集是去年华为组织的一个竞赛的数据,由于数据量较大,因此每个标签只选取1-3张图片,旨在把模型跑通。(数据集可私聊我获取)
正文:论文解读
论文地址:https://proceedings.neurips.cc/paper/2012/file/c399862d3b9d6b76c8436e924a68c45b-Paper.pdf
ImageNet LSVRC是计算机视觉领域里一个十分重要的比赛,而作为深度学习在计算机视觉领域的开山之作AlexNet,在这个比赛中勇夺桂冠,而且大幅超过之前性能最好的方法,后面出现的大多数SOTA(表现最好)视觉模型都是以AlexNet为出发点进行改进的,因此这篇论文成为经典之作。
首先看摘要,实际上讲了三件事情:
-
使用了多个卷积层+池化层
-
使用了ReLU激活函数
-
使用了Drop-out
注:不能小看这三件事,至今为止,大部分视觉模型里都有这三个的影子。
一、卷积层+池化层。
什么是卷积?通俗地说,卷积就一种获取图片局部信息的计算方法,通常采用多个卷积核提取不同种类的特征,有多少个卷积核,就能提取多少种“模式”。举个例子,有的卷积核提取到的是和“边缘”相关的信息,比如线条、轮廓等,有的卷积核提取到的是色块信息,比如绿色色块、红色色块等,如下图所示:
什么是池化?通俗地说,池化的主要作用是降低图片的分辨率,但又尽可能地保留有用信息。比如最大池化,用某一小块响应最大的那个像素来替代整块区域;又比如平均池化,用某一小块所有像素的均值作为响应值,起到平滑的作用。
相比于全连接层,卷积层的优势就是可以大幅减少参数量和计算量,但又不会使得效果变差。全连接层是通过寻找整幅图片的特征来确定图片内容,输出层的每个节点需要和输入层的所有节点连接;而卷积计算利用了图像的局部相关性,只需要对限定的那么一片区域进行关联即可。卷积之所以能够work,是因为:图片的局部相关性是很突出的,比如文字中每一笔画附近的像素往往是相同的,一块背景区域的像素之间的颜色差异不大,等等。通常,一个像素和距离很远的像素关系不大,因此卷积计算在关注附近像素的同时,忽略了远处的像素,这也和实际图像表现出来的性质类似。
而池化呢?池化的英文叫“pooling”,实际上是“汇聚、聚集”的意思,目的在于从附近的卷积结果中再采样选择一些高价值的信息,丢弃一些重复的低质量信息,从而对特征信息做进一步的过滤,使特征变得少而精。作者在论文中提到,他采用重叠池化(Overlapping Pooling)的方法,如下图:
绿色框框以步长2进行滑动,从而和红框产生重叠,这样做有利于缓解过拟合。
网络架构如下图所示:
网络架构比较简单,使用了5个卷积层+3个最大池化层+3个全连接层,由于当时GPU显存较少(GTX 580只有3G显存),因此作者利用2块GPU并行计算,从上图可看出上下两部分对应于两块GPU。
二、ReLU激活函数
作者把修正线性单元(ReLU)用在了模型里,发现训练速度显著提高,原因在于传统用的是饱和非线性激活函数,例如tanh(·),训练时如果进入到饱和区域,那么会因为梯度变化过小而难以训练;而ReLU(·)是一种非饱和非线性激活函数,接受阈是0~∞,不存在tanh的问题。
ReLU
tanh
ReLU的好处是可以有效缓解梯度消失的问题,同时它可以被看做一种线性操作,从而更有利于模型分析(注意,这里只能说是“被看做线性”,由于大于0的部分和小于0的部分不同,因此整体是非线性的)。但缺点是无法限制数据的上界,因为可以趋于∞,因此会造成数值上的不稳定,另一个缺点是小于0的部分会被直接置为0,并且一直不会改变,导致这个数据“失效”。虽有缺点,但后面也有了应对的方法,例如在ReLU前采用批量归一化(BN)压缩到0-1之间来解决数值问题,采用Leaky ReLU来解决数据失效问题。
下图为作者使用tanh和ReLU训练模型的结果,可以看出在达到相同性能的情况下,ReLU只需5个epoch就能实现,而tanh需要36个epoch。
三、Drop-out
随机失活(Drop-out)技术是很常用的,其思想很简单(事后诸葛亮):全连接层由于参数过于庞大,因此很容易出现过拟合,那么每次迭代的时候把一些神经元以概率p失活,这样每次迭代时都是一个新的模型,显著提高了健壮性(Robust),某种意义可以看做通过集成不同的模型来提高泛化能力。举个例子如下图:
无Drop-out
有Drop-out
说完三个主要的创新点,下面说说论文中另一个有意思的地方:利用数据增强减少过拟合。
数据增强方法已经成为现阶段图片预处理时的标配,不足为奇,但在当时还是挺有新意的。
训练阶段,作者把一张长宽都为256的图片以步长1进行裁切,同时把裁切后的图片再水平翻转,这样就把一张图片扩充为2048张((256-224)^2×2),尽管每张图片相关性较高,但能够缓解过拟合的情况。(对此我的理解是:尽管大部分图片是高度相关的,但由于扩充倍数实在太大,那么也能够学习到其中的微小差异,并利用这些差异来提高模型的泛化能力。如有其它想法,期待交流探讨。)
测试阶段,作者通过在图片的四角和中心进行裁剪+翻转,把每张图片扩充为10张图片,分别预测,然后计算均值,如下图:
作者经过实验后发现,这个数据增强方法在ImageNet数据集上有较好的表现。(这里说一点题外话,用哪种数据增强方法,需要看自己手里的数据集是什么情况,在ImageNet上好用的方法,在其它数据集上不一定好用,比较好的办法就是多尝试,然后获取经验)
正文:代码实现
主要包括:数据集介绍、模型搭建、数据预处理、模型训练和推理五个模块。
任务:利用AlexNet对华为金相图片进行分类。
框架:pytorch。
下面给出部分代码,完整代码可以在我的微信公众号文章里查看哦~(链接见文末^_^)
一、数据集介绍
样本是显微镜下的金相图,分为6.5-13.0共14个类别,如下图所示:
可以看出,标签值越小,小块越大。每个标签下有1-3张图片,总共有31张图片(为了快速实验模型是否能跑通,如果用所有数据集,那么跑一个epoch会很慢)。
注:在训练之前,需要把数据集划分为训练集和验证集,并放在dataset文件夹下(也可以放其它地方,那么后面的代码需要一下路径):
二、模型搭建
下面的代码定义了一个类,叫做AlexNet,然后根据论文里的模型架构一层层搭建即可。需要注意的是,前面提到过,论文里作者把每一个卷积层输出的通道数拆分为二,送到两块GPU上训练,那么这里就只弄一半的通道数即可,例如第一个卷积层,原文是48×2,那么这里就定义为48。
这段代码保存为一个 .py文件,名为my_net.py。
import torch
import torch.nn as nn
class AlexNet(nn.Module): # AlexNet继承nn.Module的初始化方法
# def __init__的参数写多少无所谓,后面创建实例的时候写上即可
def __init__(self, num_class = 14, init_weights = False):
super(AlexNet, self).__init__()
# 下面定义特征提取层:CNN+pooling
self.feature_extract = nn.Sequential(
# 第一个模块:卷积->ReLU->maxpool
nn.Conv2d(3, 48, kernel_size=11, stride=4, padding=2), # padding有两种表述方式:int和tuple
nn.ReLU(inplace=True), # 参数含义:对传过来的tensor直接修改,节省内存空间
nn.MaxPool2d(kernel_size=3, stride=2),
# 第二个模块:卷积->ReLU->maxpool
nn.Conv2d(48, 128, kernel_size=5, padding=2),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2),
# 第三个模块:卷积->ReLU
nn.Conv2d(128, 192, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
# 第四个模块:卷积->ReLU
nn.Conv2d(192, 192, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
# 第五个模块:卷积->ReLU->maxpool
nn.Conv2d(192, 128, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2),
)
# 下面定义分类层:FC
self.classifier = nn.Sequential(
nn.Dropout(p=0.5),
nn.Linear(in_features=128*6*6, out_features=2048),
···(省略)
# 最终输出模型
model = AlexNet(num_class=14, init_weights=False)
print(model)
三、数据预处理
下面的代码主要对图片进行标准化、数据增强,然后把图片转为可以输入模型的张量(tensor),最后送到模型里。
ImageFolder是pytorch自带的加载数据工具,可以对原始图片进行各种处理,然后封装起来。DataLoader是pytorch自带的数据装载工具,比较重要的功能是把数据拆分为多个批量(batch)送到模型中训练。
这段代码保存为一个 .py文件,名为my_data_preprocess.py。
import torch
import torch.nn as nn
from torchvision import datasets, transforms, utils
import os
import json
import matplotlib.pyplot as plt
import numpy as np
#### 定义一些全局的参数 ####
batch_size = 2
#### 定义一些数据预处理方法 ####
# 这两组数据是ImageNet数据集所有样本的RGB均值和标准差,用这个一般没错
normalize = transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
#### 定义文件路径 ####
os.chdir('...自己定义') # 修改当前工作目录,后面便于操作
data_root = os.path.join(os.getcwd(), r'dataset') # 这是获取数据集路径的代码,
# 一般需要自行设置
#### 利用transforms.Compose()对图片预处理,同时转为可训练的tensor ####
data_transform = {
"train": transforms.Compose([transforms.RandomResizedCrop(224),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
normalize,
]),
"val": transforms.Compose([transforms.RandomResizedCrop(224),
transforms.ToTensor(),
normalize,
])
}
···(省略)
四、模型训练
下面的代码主要是定义训练过程,比如定义epoch、学习率、损失函数、优化算法等等,这部分的基础写法是比较固定的,但如果要采用其它策略进行训练,那就需要定义很多东西了,比如学习率衰减、冻结部分层进行微调、不同层用不同学习率、加载其它预训练模型等等,这些内容后面再补充。
这段代码保存为一个 .py文件,名为my_train.py。
import os
import torch
import torch.nn as nn
import torch.optim as optim
import time
from tqdm import tqdm # 显示进度条的库
from AlexNet.my_net import AlexNet # pycharm中,同级导入需要加上文件所在目录名称
from AlexNet.my_data_preprocess import *
#### 定义一些参数 ####
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 选择用CPU还是GPU训练
num_epoch = 2
learning_rate = 0.1
save_path = os.getcwd() # 保持模型的路径
#### 创建网络实例 ####
model = AlexNet(num_class=14, init_weights=False)
# 模型放到设备上, 这里先把model的参数放到设备上,后面训练每一个epoch时,要把图片和标签也放到设备上
model.to(device)
# 定义损失函数
loss_func = nn.CrossEntropyLoss()
# 定义优化器
optimizer = optim.SGD(model.parameters(), lr=learning_rate, momentum=0.9, weight_decay=1e-4)
#### 开始训练 ####
best_acc = 0.0
running_time = []
print('开始训练------------>')
for epoch in range(num_epoch):
# 训练阶段
model.train() # 训练时用 .train(),后面验证时用 .eval()。因为像dropout、BN层,训练时和验证时是不一样的!
running_train_loss = 0.0 # 统计训练集的平均损失
start_time = time.perf_counter() # 统计训练一个epoch需要多久, 需要两个time.perf_counter()计算区间
train_loader = tqdm(train_loader, file=sys.stdout)
for step, (image, target) in enumerate(train_loader):
image, target = image.to(device), target.to(device)
# 计算输出、损失
output = model(image) # 前向传播
loss = loss_func(output, target) # 计算损失
# 反向传播
optimizer.zero_grad() # 梯度清零
loss.backward() # 反向传播求解梯度
optimizer.step() # 更新权重参数
···(省略)
print('训练结束<------------')
五、推理
下面的代码主要是对新传入的图片进行预测,实际上和训练阶段中对验证集的操作比较像,区别是推理阶段多了一步导入预训练好的权重。
这段代码保存为一个 .py文件,名为my_inference.py。
import os
import json
import torch
from PIL import Image
from torchvision import transforms
import matplotlib.pyplot as plt
from AlexNet.my_net import AlexNet
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
normalize = transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
data_transform = transforms.Compose([transforms.Resize((224, 224)),
transforms.ToTensor(),
normalize])
# 加载图片
img_path = "./dataset/val/6.5/164-101-6.5-500x.jpg"
img = Image.open(img_path)
plt.imshow(img) # 先把图画出来
img = data_transform(img)
print(img.shape)
img = torch.unsqueeze(img, dim=0) # 在tensor的第0个位置加上batch信息
# 读取标签的json文件
json_path = './class_indices.json'
json_file = open(json_path, "r")
class_indict = json.load(json_file)
# print(class_indict)
# 创建模型
model = AlexNet(num_class=14).to(device)
# 加载预训练权重
weights_path = "./model.pth"
···(省略)
plt.title(title)
plt.show()
最后,看一下推理结果(当然是很不准确的):
后记1:文中涉及到很多基础知识,每次写到那个知识点的时候就想多扩展一些,但是这样做的话不知道得写多少字了,遂尽量精简,其实那些知识点在书本上、网上都有更详细的讲解,推荐使用“哪里不会查哪里”的方法。
后记2:后期准备持续用这样的方式解读论文,方向主要是目标检测,并且主要在微信公众号进行更新,喜欢的朋友可以关注一波哦~
关注我的微信公众号“风的思考笔记”,期待和大家一起进步!
如有新的想法,期待交流探讨~