万物 | 炼器 .卷1 —— 启蒙(二)
1.3 打造第一根“烧火棍”——单文件极简卷积网络
现在,终于可以开始编码了。
请打开你惯用的IDE,新建一个main.py文件。我们全程只用PyTorch库的基础模块和python基础库,不调用其他任何第三方高级库,依次实现上文讲到的六个核心模块,手搓一个极简的卷积目标检测网络。
1.3.1 引入依赖
首先,我们要引入依赖。主要有以下几个,请将以下代码敲到你的main.py文件中并保存。
Import os
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
其中:
-
os库是python基础库,提供了操作系统接口,用于创建文件夹路径等文件系统操作
-
torch是整个PyTorch框架的基础,提供了底层张量(Tensor)库与自动微分(Autograd)引擎,知道这是PyTorch框架的基础即可,不必深究,我们在main.py文件中会用到他生成张量、梯度清零等底层方法.
-
torch.nn是神经网络(Neural Network)的基础,提供了 nn.Module 基类,所有的神经网络模块,都要从nn.Module 基类继承而来。torch.nn提供了构建神经网络模型最基础的模块,比如基础卷积Conv2d、基础活函数ReLU等等的实现。此外,nn.MSELoss、nn.CrossEntropyLoss 等损失函数,同样被封装在这个库中.
-
torch.optim,网络模型参数更新的执行器或者叫优化器,实现了optim.Adam、optim.SGD等不同类型的网络参数优化方法。
-
torch.utils.data,包含Dataset, DataLoader等基类,其中Dataset是数据的容器,DataLoader是数据的加载引擎,负责从Dataset中读取数据并传送给模型进行训练。
1.3.2 main.py文件的蓝图
引入依赖,只要是学过python语言的人都理解,也很容易想到。但是引入依赖后,接下来main.py文件应该怎么写?
即便知道了上文说过的6大核心逻辑,好多人在这个地方仍然会卡壳。“知道”和“会写”的小小裂缝,在这里第一次出现了。好在这个裂缝足够小,我们能很轻易的跨过去。如果想不清楚,就先不要为难自己必须面面俱到,不管怎么样先写起来。写代码的第一步,就是要写。
我们是基于pytorch库来搭建我们自己的神经网络系统,所以还是要对pytorch库提供了哪些功能、哪些模块比较熟悉,熟悉了,思路就自然而言的有了,如果不熟悉,那么如何基于pytorch来写代码就会没有头绪。pytorch库就像是我们的基础工具库,知道了有哪些现成的工具,我们才知道要怎么干活。
构建卷积网络模型,必然会想到的就是要有个网络模型的结构,这个网络模型结构,大概是基于pytorch.nn库中提供的模块组织起来的;然后要有数据,就要有处理数据的类,从上文的讨论中,我们知道这个数据类应该是继承自Dataset的;要有一个损失函数来计算loss,要有个优化器来调整优化网络模型的参数,要有个方法来进行模型效果的验证,最后要有个训练方法,把上面这些部分串起来。
再进一步,我们考虑网络结构模块继承自torch.nn.Module,必须要实现前向传播的forward方法,在其__init__初始化方法中,实例化并组织所有的基础模块;数据类继承自Dataset,那么我们就要创建一个自己的数据集类,从torch.utils.data中的DataSet继承,必须要实现__len__和__getitem__方法,当然,少不了的还有初始化方法__init__;验证评估模块和训练模块,暂时分别写成一个方法。Loss计算和优化器,我们刚刚引入的库中都提供了相应的实现,我们就先用pytorch库中现成的方法。
于是,我们自然有了main.py文件的骨架代码:
import os
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
# ---------------------------------------------------------
# 模块 1:网络结构
# ---------------------------------------------------------
class SimpleDetector(nn.Module):
def __init__(self):
pass
def forward(self):
pass
# ---------------------------------------------------------
# 模块 2:数据集
# ---------------------------------------------------------
class SimpleFakeDataset(Dataset):
def __init__(self):
pass
def __len__(self):
pass
def __getitem__(self):
pass
# ---------------------------------------------------------
# 模块 3:验证评估
# ---------------------------------------------------------
def evaluate(model):
pass
# ---------------------------------------------------------
# 模块 4:训练中枢
# ---------------------------------------------------------
def train_model():
# 1. 定义训练需要用到的超参数(batch_size, epochs, lr)
# 2. 实例化组件
# - 实例化训练集和验证集 Train Dataset 和 Val Dataset
# - 封装为 Train DataLoader 和 Val DataLoader
# - 实例化 SimpleDetector 模型
# - 实例化 Loss 函数 (criterion)
# - 实例化 Optimizer 优化器
# 3. 开启 Epoch 循环
# for epoch in range(epochs):
# a. 切换模型至训练状态 (train)
# b. 遍历 Train DataLoader
# - 前向传播计算 outputs
# - 计算loss
# - loss的反向传播
# - 调用优化器调整优化参数
# c.调用验证方法evaluate,获得验证结果
# - val_metrics = evaluate(...)
# d. 模型持久化判断
# - 如果 val_metrics 优于历史最佳,则保存模型
Pass
#程序入口
if __name__ == "__main__":
train_model()
1.3.3 基础法阵:搭建极简检测模型
我们首先来完善SimpleDetector类。
以下是SimpleDetector类的__init__方法,请弄明白各行代码的具体意义后,在您的IDE中敲下它们:
def __init__(self, num_classes=2):
super().__init__() # 调用父类 nn.Module 的初始化方法
# 输出通道数:类别预测(num_classes) + 边界框等属性(8)
self.out_channels = num_classes + 8
self.backbone = nn.Sequential(
nn.Conv2d(3, 16, 3, stride=2, padding=1), # [B, 16, H/2, W/2]
nn.ReLU(), # 激活函数,增加非线性
nn.Conv2d(16, 32, 3, stride=2, padding=1),# [B, 32, H/4, W/4]
nn.ReLU(), # 激活函数
nn.Conv2d(32, 64, 3, stride=2, padding=1),# [B, 64, H/8, W/8]
nn.ReLU(), # 激活函数
)
self.head = nn.Conv2d(64, self.out_channels, kernel_size=1)
SimpleDetectord的初始化方法有一个输入参数num_classes,用来表示预测的类别总数,我们这里默认为2,表示一共可以预测两个类别,比如猫和狗之类的。输出通道数out_channels是num_classes + 8,为什么是num_classes + 8呢?因为对于每一个可能的预测类别,模型都要给一个置信度,也就是说,有多少个类别,输出的时候就要有多少个置信度值与之对应,所以这里有num_classes个置信度值,8表示预测框的坐标,我们的预测框用4个顶点,也就是8个坐标值来表示,所以输出通道数out_channels= num_classes + 8。
接下来就是网络结构的定义。我们通过nn.Sequential把若干个pytorch提供的基础模块组合在一起,形成一个大的网络模块。nn.Sequential是一个常用的有序容器,他就像工厂里的一条直线流水线,数据(张量)从一端输入,按照定义好的顺序依次经过流水线上的每一个节点(神经网络模块),最后从另一端输出。
当然也可以直接挨个模块来写,只不过这样在写forward方法的时候,也一样要每个模块写一遍,当使用的模块比较多的时候,写起来就比较费劲了。使用nn.Sequential包装起来的模块组合,在forward方法里,这要使用这个组合的名称就可以了。
如下:
self.backbone = nn.Sequential(
nn.Conv2d(3, 16, 3, stride=2, padding=1), # [B, 16, H/2, W/2]
nn.ReLU(), # 激活函数,增加非线性
nn.Conv2d(16, 32, 3, stride=2, padding=1),# [B, 32, H/4, W/4]
nn.ReLU(), # 激活函数
nn.Conv2d(32, 64, 3, stride=2, padding=1),# [B, 64, H/8, W/8]
nn.ReLU(), # 激活函数
)
我们将3个基础卷积模块nn.Conv2d和3个ReLU模块,依次组合起来,作为模型的backbone(主干)。注意nn.Conv2d的参数,不要写错,至于这些参数的意义,我们放到第二章再详细讲。
在backbone之后,我们再增加一个卷积模块,作为模型的检测头(head),代码如下:
self.head = nn.Conv2d(64, self.out_channels, kernel_size=1)
这里因为就1个nn.Conv2d卷积模块,所以就没有必要再使用nn.Sequential了。同样注意nn.Conv2d的参数不要写错。检测头(head)使用一个1x1的卷积模块Conv2d,强行把特征图的输出维度转化成我们需要的目标格式。
从代码里可以看出来,所谓的主干(backbone)、检测头(head),后续我们还会看到颈部(neck),他们都是人为从功能上进行的区分,从网络结构来看并没有什么不同,都是由若干基础网络模块组成的。
完成了初始化方法,我们接下来实现forward方法,具体代码如下:
def forward(self, x):
# 前向传播
x = self.backbone(x)
x = self.head(x)
return x
forward方法又称前向传播方法,就是将输入的张量x,依次经过模型结构中的每一个模块进行处理,然后输出处理后的结果。在这里,张量x先是经过了backbone的处理,得到了一个新的x,再将这个新的x作为输入,传入到模型的head中处理,最后将处理得到的结果输出。
几乎所有神经网络的forward方法都是这样的处理方式。
这样,我们硬编码的卷积神经网络模型就完成了。虽然他很简陋,但是已经具备了一个卷积神经网络的雏形,他能接受张量,并根据需要“吐出”我们需要的预测坐标。
1.3.4 老祖的虚幻灵石:一份虚拟的数据
接着完善Dataset类。起名叫SimpleFakedDataset,是因为我们现在还不能真的从train、val文件夹中去读取真实图像和标注文件的内容。处理真实文件和标注,对网络结构有相应的要求,这是我们的第一版代码,尽量以简洁、能够跑通流程为主,所以Dataset中的数据是虚拟出来的,但是数据的格式和真实数据并无二致。
以下是SimpleFakedDataset的__init__方法,请弄明白各行代码的具体意义后,在您的IDE中敲下它们:
def __init__(self, num_samples=100, img_size=256, num_classes=2, num_targets=3):
super().__init__() #调用父类的初始化方法
self.images = [] # 用于存储生成的模拟图像
self.targets = [] # 用于存储生成的模拟网格化标签
#网格尺寸 grid_size,输入图像尺寸除以网络的下采样倍率
grid_size = img_size // 8
# 目标张量的通道数
channels = num_classes + 8
# 在循环生成指定数量的图像
for _ in range(num_samples):
#模拟读取一张独立的图片
single_img = torch.randn(3, img_size, img_size)
# 模拟经过处理后的目标标注信息张量
single_target = torch.randn(channels, grid_size, grid_size)
# 将生成的单张图像和对应的真实标注信息存入列表
self.images.append(single_img)
self.targets.append(single_target)
在SimpleFakedDataset类的构造函数中,我们传入了几个参数,分别是:num_samples,表示数据集中的样本数量,因为我们是虚拟的数据,所以需要传入样本数量,如果是真实的数据集,就直接从文件夹中读取图像或标注文件的数量,无需手动传入,我们这里给了一个默认值100;img_size,图像的大小,默认数据集中的图像宽和高相等,给定的默认值是256,也就是说我们虚拟的图片是一张张256x256像素的图像;num_classes,图像中所包含的目标类型的数量,这里默认是2,即数据集中的图片,最多包含两类目标。
num_targets,这个参数表示每一张我们虚拟的图像上有多少个目标,也就是有多少个标注,这里默认是3。细心的读者可能已经发现了,虽然要传入这个参数,但是在后续的代码中,这个参数实际上并没有用到。这是因为真正的目标检测模型,需要对每张图像上所有的目标标注进行一系列复杂处理(Label Assignment),然后形成和网络输出形状一致的张量,这样稍后我们才可以去计算Loss。我们第一版的代码,刚刚说了以简洁、能够跑通流程为目标,所以在这一版本中我们不会实现Label Assignment,那就太复杂了。我们根据极简检测模型输出数据的格式,直接在SimpleFakedDataset中虚拟出处理过后的标注结果,这样方便我们后续的loss计算,至于复杂的处理过程,现在就先省略了。从上述网络结构分析中,我们已经知道目标张量的形状固定为 [channels, grid_size, grid_size],无论你一张图像上有多少个目标,最终我们都要处理成这个形式的张量,那在这一版的代码中,就直接随机生成这样一个形状的张量就好了。所以num_targets这个参数我们其实是用不到的,放在初始化方法的参数列表中,是为了从形式上模拟一张图像上可能存在多个目标标注。
self.images、self.targets是两个列表,分别用来存储生成的模拟图像和模拟标注。在实际的模型中,这两个列表其实存储的是图像文件地址和标注文件地址。
从上文的极简检测模型结构分析,我们知道它使用了3层stride=2的卷积,下采样倍率是8。那么网络输出的网格尺寸grid_size就是img_size // 8,稍后我们生成虚拟的标注张量的时候,要用到这个量。
网络输出的通道数channels等于每一个目标类型的置信度加上预测框的位置信息(预测框的8个顶点坐标),所以channels=num_classes + 8。
single_img = torch.randn(3, img_size, img_size),模拟读取一张独立的图片并将其转换为对应的张量的过程,张量的形状是[3, H, W],3表示图像是RGB三通道的。因为是虚拟的图片,这里直接通过torch.randn随机生成一个符合形状要求的张量,代表读取了一张图片。
single_target = torch.randn(channels,grid_size,grid_size)模拟原始的num_targets个标注目标,经复杂的Label Assignment后,生成了形状为[channels,grid_size,grid_size]的张量,同样我们用torch.randn随机生成一个符合形状要求的张量,代表处理后得到的真实目标标注信息。
__len__和__getitem__方法的代码如下,请在您的IDE中敲下它们:
def __len__(self):
# 返回数据集的总长度
return len(self.images)
def __getitem__(self, idx):
# 根据索引返回对应的 (图像张量, 网格化标签张量)
return self.images[idx], self.targets[idx]
这两个方法中,__len__返回数据集中数据的量,方便后续dataloader根据batch_size来批量的获取数据;__getitem__根据序号idx,返回对应的图像和标注信息。
SimpleFakedDataset的代码就完成了。