基于Paddle手敲ResNet
放在开头的话
作为一名算法工程师,接触各类模型是必须的,但将模型从论文转换成代码的能力往往比较欠缺;
习惯了搬运开源代码,也被冠以了"炼丹师"的称号,但调参能力并不是算法工程师能力的唯一衡量指标,对模型结构的理解、通过结构图复现代码的能力往往能考验对于各类模型的理解度;
从工作中来看,对于不同的任务,套用相同模型的效果是不一样的,这也就说明哪怕是开源的达到了SOTA的模型,也不适合所有任务;当遇到新的任务时,我们需要思考如何魔改模型结构,在当前任务下达到更优的效果,这是作为算法工程师需要具备的能力;
参考资源
ResNet论文地址:https://arxiv.org/pdf/1512.03385.pdf
主要关注下面两张图:
1、模型结构图
2、ResNet最关键结构(跳连结构)
本次实现都是基于Paddle实现的,Paddle的环境对于新手来说十分友好,也提供了代码环境;
Paddle课程网址:https://aistudio.baidu.com/aistudio/course
个人观点来说,Paddle的课程做的都挺不错的,并且提供了配套的环境来实践,虽然框架是基于Paddle,但原理和Pytorch等框架是一致的;
本次参考的课程为实战进阶中的Vision Transformer,其中的第一章就讲解了ResNet的实现过程,在作业部分也有参考代码,大家可以尝试一下;
网络层实现
本次训练的数据集为cifar10数据集,所以需要修改头两层结构,具体结构图实现如下:
主要关注18层的模型结构,并且将头两层修改为3x3的卷积层;
1、ResNet18主结构:
import paddle # 导入需要用到的Paddle的库
import paddle.nn as nn
class ResNet18(nn.Layer):
# num_classes可通过传入参数修改,表示类别
def __init__(self, in_dim=64, num_classes=1000):
super().__init__()
self.in_dim = in_dim
# 实现头两层结构,3x3卷积层接一个BatchNorm层
self.conv1 = nn.Conv2D(in_channels=3,
out_channels=in_dim,
kernel_size=3,
stride=1,
padding=1,
bias_attr=False)
self.bn1 = nn.BatchNorm2D(in_dim)
self.relu = nn.ReLU()
# blocks结构(将实现封装在_make_layer这个函数中)
self.layer1 = self._make_layer(dim=64, n_block=2, stride=1)
self.layer2 = self._make_layer(dim=128, n_block=2, stride=2)
self.layer3 = self._make_layer(dim=256, n_block=2, stride=2)
self.layer4 = self._make_layer(dim=512, n_block=2, stride=2)
# head layer(进行分类的结构,用全连接输出对应类别的特征)
self.avgpool = nn.AdaptiveAvgPool2D(1)
self.classifier = nn.Linear(512, num_classes)
"""
_make_layer主要是实现blocks结构,通过图可以看出blocks的结构只是传入的参数不同,后续更深层 ResNet的实现也是块的叠加而已;
"""
def _make_layer(self, dim, n_block, stride):
layer_list = []
# Block结构另外实现
layer_list.append(Block(self.in_dim, dim, stride))
self.in_dim = dim
for i in range(1, n_block):
layer_list.append(Block(self.in_dim, dim, stride))
# 注意Sequential这个接口对于多层来说很适用
return nn.Sequential(*layer_list)
# forward表示定义网络的顺序
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
x = self.avgpool(x)
# 将第一个维度的矩阵推平,整体变为二维矩阵
x = x.flatten(1)
x = self.classifier(x)
return x
2、Block结构的实现
# 返回自身的值
class Identity(nn.Layer):
def __init_(self):
super().__init__()
def forward(self, x):
return x
class Block(nn.Layer):
def __init__(self, in_dim, out_dim, stride):
super().__init__()
self.conv1 = nn.Conv2D(in_dim, out_dim, 3, stride=stride, padding=1, bias_attr=False)
self.bn1 = nn.BatchNorm2D(out_dim)
self.conv2 = nn.Conv2D(out_dim, out_dim, 3, stride=1, padding=1, bias_attr=False)
self.bn2 = nn.BatchNorm2D(out_dim)
self.relu = nn.ReLU()
# 这里是关键结构,也就是上图中的跳连结构
# 这里需要判断相加的值的维度是否相等,不等的话需要做同样的卷积操作
if stride == 2 or in_dim != out_dim:
self.downsample = nn.Sequential(*[
nn.Conv2D(in_dim, out_dim, 1, stride=stride),
nn.BatchNorm2D(out_dim)])
else:
# Identity表示返回自身的值,在这用单独一个类定义
self.downsample = Identity()
def forward(self, x):
h = x
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.conv2(x)
x = self.bn2(x)
identity = self.downsample(h)
# 跳连结构本质就是两个矩阵的值相加
x = x + identity
x = self.relu(x)
return x
数据层实现
本次训练采用简单的cifar10数据集,并且Paddle提供的API可下载该数据集;
# 导入对应的库
from paddle.io import Dataset
from paddle.io import DataLoader
from paddle.vision import datasets
from paddle.vision import transforms
# 这里是对每张图像做一个前处理的操作
def get_transforms(mode='train'):
if mode == 'train':
# 下面为一些前处理的操作
data_transforms = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
# 标准化的参考值是由数据集提供的
transforms.Normalize(mean=[0.4914, 0.4822, 0.4465], std=[0.2023, 0.1994, 0.2010])])
else:
# 如果不是训练集,则不需要经过裁剪翻转等处理
data_transforms = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(mean=[0.4914, 0.4822, 0.4465], std=[0.2023, 0.1994, 0.2010])])
return data_transforms
# 加载数据,用内置的datasets提供有Cifar10数据集的下载和加载
def get_dataset(name='cifar10', mode='train'):
if name == 'cifar10':
dataset = datasets.Cifar10(mode=mode, transform=get_transforms(mode))
return dataset
# 将数据集保存为DataLoader,可以理解成存在一个数组中,这个后续会做详细讲解
def get_dataloader(dataset, batch_size=128, mode='train'):
dataloader = DataLoader(dataset, batch_size=batch_size, num_workers=2, shuffle=(mode == 'train'))
return dataloader
主程序实现
首先定义训练一个epoch的函数:
"""
这里需要传入多个参数:
criterion: 损失函数
optimizer: 优化器
report_freq: 间隔多少个batch打印一次信息
"""
def train_one_epoch(model, dataloader, criterion, optimizer, epoch, total_epoch, report_freq=20):
print(f'----- Training Epoch [{epoch}/{total_epoch}]:')
# AverageMeter是定义的一个工具类,可以理解成存放信息的数据结构
loss_meter = AverageMeter()
acc_meter = AverageMeter()
# 模型在训练模式,dropout层会执行
model.train()
# 取出dataloader中的数据
for batch_idx, data in enumerate(dataloader):
image = data[0]
label = data[1]
out = model(image)
# 计算损失值
loss = criterion(out, label)
# 反向传播
loss.backward()
optimizer.step()
optimizer.clear_grad()
# 计算最终的结果
pred = nn.functional.softmax(out, axis=1)
# 这里用paddle自带的函数计算准确率,就不用我们自己写函数了
acc1 = paddle.metric.accuracy(pred, label.unsqueeze(-1))
batch_size = image.shape[0]
# 更新损失值和准确率的值
loss_meter.update(loss.cpu().numpy()[0], batch_size)
acc_meter.update(acc1.cpu().numpy()[0], batch_size)
# 间隔20个batch打印一次信息, f可以在其中传入变量
if batch_idx > 0 and batch_idx % report_freq == 0:
print(f'----- Batch[{batch_idx}/{len(dataloader)}], Loss: {loss_meter.avg:.5}, Acc@1: {acc_meter.avg:.4}')
print(f'----- Epoch[{epoch}/{total_epoch}], Loss: {loss_meter.avg:.5}, Acc@1: {acc_meter.avg:.4}')
验证函数的实现:
当我们训练的同时,也需要同步对验证集做验证,便于在训练过程中发现问题;
# 验证集函数实现,
def validate(model, dataloader, criterion, report_freq=10):
print('----- Validation')
loss_meter = AverageMeter()
acc_meter = AverageMeter()
# 模型切换到推理模式
model.eval()
for batch_idx, data in enumerate(dataloader):
image = data[0]
label = data[1]
out = model(image)
loss = criterion(out, label)
pred = paddle.nn.functional.softmax(out, axis=1)
acc1 = paddle.metric.accuracy(pred, label.unsqueeze(-1))
batch_size = image.shape[0]
loss_meter.update(loss.cpu().numpy()[0], batch_size)
acc_meter.update(acc1.cpu().numpy()[0], batch_size)
# 间隔10个batch打印一次loss信息
if batch_idx > 0 and batch_idx % report_freq == 0:
print(f'----- Batch [{batch_idx}/{len(dataloader)}], Loss: {loss_meter.avg:.5}, Acc@1: {acc_meter.avg:.4}')
print(f'----- Validation Loss: {loss_meter.avg:.5}, Acc@1: {acc_meter.avg:.4}')
最后就是主函数的实现:
# 主函数实现
def main():
# 定义epoch和batchsize的值
total_epoch = 200
batch_size = 512
#batch_size = 256
# 引入模型
model = ResNet18()
# 导入训练集
train_dataset = get_dataset(mode='train')
train_dataloader = get_dataloader(train_dataset, batch_size, mode='train')
# 导入验证集
val_dataset = get_dataset(mode='test')
val_dataloader = get_dataloader(val_dataset, batch_size, mode='test')
# 损失函数定义
criterion = nn.CrossEntropyLoss()
# 梯度下降策略
scheduler = paddle.optimizer.lr.CosineAnnealingDecay(0.02, total_epoch)
#scheduler = paddle.optimizer.lr.CosineAnnealingDecay(0.01, total_epoch)
# 优化器
optimizer = paddle.optimizer.Momentum(learning_rate=scheduler,
parameters=model.parameters(),
momentum=0.9,
weight_decay=5e-4)
# 这里可以直接加载训练后的模型,将该标志位改为True即可
eval_mode = False
if eval_mode:
state_dict = paddle.load('./resnet18_ep200.pdparams')
model.set_state_dict(state_dict)
validate(model, val_dataloader, criterion)
return
save_freq = 50 # 间隔多少个epoch保存模型
test_freq = 10 # 间隔多少个epoch验证模型
# 开始迭代训练和验证
for epoch in range(1, total_epoch+1):
train_one_epoch(model, train_dataloader, criterion, optimizer, epoch, total_epoch)
scheduler.step()
if epoch % test_freq == 0 or epoch == total_epoch:
validate(model, val_dataloader, criterion)
if epoch % save_freq == 0 or epoch == total_epoch:
# 保存模型的路径
paddle.save(model.state_dict(), f'./resnet18_ep{epoch}.pdparams')
paddle.save(optimizer.state_dict(), f'./resnet18_ep{epoch}.pdopts')
执行训练后打印信息如下图所示:
上图可以看出,随着训练的进行,损失值不断在下降,准确率不断上升,说明训练的方式是正确的;仅仅训练了3个epochs,准确率就到了55%,证明模型对该数据集的效果还是很明显的;
当训练结束后,执行验证结果如下图所示:
可以看出在验证集上也可以达到90%以上的准确率;
总结
ResNet算是结构比较简单的网络了,像现在最新的一些网络都会加入很多小trick,并且模型结构相对复杂一些;复现网络的能力还需要不断地提升,可以通过看网络的源码,多结合图片理解;实际上网络的很多结构是相通的,掌握了一个结构往往在别的模型中也会用到,就好比残差结构,基本在最新的模型中都会采用该技巧提升模型的深度;