PyTorch学习记录——PyTorch进阶训练技巧
1.自定义损失函数
自定义损失函数的方法主要包括两种,即以函数的方式定义和以类的方式定义。
1.1 以函数的方式定义损失函数
以函数的方式定义与定义python函数没有什么区别,通过将参与损失计算的张量(即Tensor)作为函数的形参进行定义,例如
def my_loss(output: torch.Tensor, target: torch.Tensor):
loss = torch.mean((output - target) ** 2)
return loss
在上述定义中,我们使用了MSELoss损失函数。同时可以看到,在损失函数编写过程中,可以直接使用我们熟悉的Python中的运算符,包括加减乘除等等,但牵涉到矩阵运算,如矩阵乘法则需要使用Pytorch提供的张量计算接口torch.matmul
。采用这样的方式定义损失函数实际上就仅需要把计算过程定义清楚即可,或者说是把计算图或数据流定义清楚。
1.2 以类的方式定义损失函数
以类的方式定义损失函数需要让我们定义的类继承nn.Module
类。采用这样的方式定义损失函数类,可以让我们把定义的损失函数作为一个神经网络层来对待。Pytorch现有的损失函数也大都采用这种类的方式进行定义的。事实上,在Pytorch中,Loss
函数部分继承自_loss
, 部分继承自_WeightedLoss
, 而_WeightedLoss
继承自_loss
, _loss
继承自 nn.Module
。例如,通过查看Pytorch中CrossEntropyLoss
的代码,我们可以看到上述关系,如下。
class CrossEntropyLoss(_WeightedLoss):
...
class _WeightedLoss(_Loss):
...
class _Loss(Module):
...
1.3 比较与思考
教程中有说到,相比于以函数的方式定义的损失函数,类的方式定义更为常用。
虽然以函数定义的方式很简单,但是以类方式定义更加常用,…
然而,从教程中给出的例子,比如DiceLoss
损失函数的定义
class DiceLoss(nn.Module):
def __init__(self,weight=None,size_average=True):
super(DiceLoss,self).__init__()
def forward(self,inputs,targets,smooth=1):
inputs = F.sigmoid(inputs)
inputs = inputs.view(-1)
targets = targets.view(-1)
intersection = (inputs * targets).sum()
dice = (2.*intersection + smooth)/(inputs.sum() + targets.sum() + smooth)
return 1 - dice
确实又难以体现出其相比函数方法的优越之处。考虑到类这种面向对象的设计方式,上述采用类的方式设计损失函数可能存在如下两个方面的优势:
-
当损失函数计算过程中出现一些类似滑动平均等需要动态缓存一些数的时候,采用类的方式可以直接将这样的数存放在实体对象中;
-
采用类的方式可以通过继承的方式梳理清楚不同损失函数的关系,并有可能能复用一些父类损失函数的特性和方法。
2.动态调整学习率
无论是在深度学习任务中还是深度强化学习任务中,学习率对于神经网络的训练非常重要。因为本质上讲,两者都是通过数据驱动的手段,通过梯度下降类算法,对神经网络的参数进行寻优。对于一个任务,在起始时,我们可能设定了一个比较好的学习率。这使得我们的算法在训练初期收敛的效率和效果都较好。但随着训练的进行,特别是当网络参数非常靠近我们期待的位置时(神经网络参数空间中的理想点),我们初期设置的学习率可能就会显得偏大,导致梯度下降过程步长过长,从而使得神经网络参数在理想点附近震荡。
为解决上述问题,一种方式是通过手动调整学习率,来适应神经网络训练不同的时期,以及神经网络所达到的不同性能。但这样的方式就要求我们要能够自行设计出一套学习率变化的算法,这无疑为我们程序训练的编写又增加了复杂度。另一种方式下,我们可以使用Pytorch中的scheduler进行动态的学习率调整。
Pytorch的scheduler可以提供两种使用方式的支持:官方提供的scheduler API和自定义的scheduler。
2.1 官方提供的scheduler API
官方提供的scheduler API主要放在torch.optim.lr_scheduler
中,具体包括
scheduler API | 说明 | 参数 |
---|---|---|
lr_scheduler.LambdaLR | 学习率lr 为一个初始值乘以一个函数,当last_epoch=-1时,lr 取值为初始值 | * optimizer (Optimizer) – Wrapped optimizer. * lr_lambda (function or list) – A function which computes a multiplicative factor given an integer parameter epoch, or a list of such functions, one for each group in optimizer.param_groups. |
* last_epoch (int) – The index of last epoch. Default: -1. | ||
* verbose (bool) – If True, prints a message to stdout for each update. Default: False. | ||
lr_scheduler.MultiplicativeLR | 学习率lr 为一个初始值乘以一个函数,当last_epoch=-1时,lr 取值为初始值 | * optimizer (Optimizer) – Wrapped optimizer. |
* lr_lambda (function or list) – A function which computes a multiplicative factor given an integer parameter epoch, or a list of such functions, one for each group in optimizer.param_groups. | ||
* last_epoch (int) – The index of last epoch. Default: -1. | ||
* verbose (bool) – If True, prints a message to stdout for each update. Default: False. | ||
lr_scheduler.StepLR | 每step_size个epoch,学习率lr 变为其当前值乘以gamma ,当last_epoch=-1时,lr 取值为初始值 | * optimizer (Optimizer) – Wrapped optimizer. |
* step_size (int) – Period of learning rate decay. | ||
* gamma (float) – Multiplicative factor of learning rate decay. Default: 0.1. | ||
* last_epoch (int) – The index of last epoch. Default: -1. | ||
* verbose (bool) – If True, prints a message to stdout for each update. Default: False. | ||
lr_scheduler.MultiStepLR | 当epoch数达到milestones数量时,学习率lr 变为其当前值乘以gamma ,当last_epoch=-1时,lr 取值为初始值 | * optimizer (Optimizer) – Wrapped optimizer. |
* milestones (list) – List of epoch indices. Must be increasing. | ||
* gamma (float) – Multiplicative factor of learning rate decay. / * Default: 0.1. | ||
* last_epoch (int) – The index of last epoch. Default: -1. | ||
* verbose (bool) – If True, prints a message to stdout for each update. Default: False. | ||
lr_scheduler.ExponentialLR | 每个epoch,学习率lr 变为其当前值乘以gamma ,当last_epoch=-1时,lr 取值为初始值 | * optimizer (Optimizer) – Wrapped optimizer. |
* gamma (float) – Multiplicative factor of learning rate decay. | ||
* last_epoch (int) – The index of last epoch. Default: -1. | ||
* verbose (bool) – If True, prints a message to stdout for each update. Default: False. | ||
lr_scheduler.CosineAnnealingLR | 采用cos衰减的方式调整学习率,当last_epoch=-1时,lr 取值为初始值 | * optimizer (Optimizer) – Wrapped optimizer. |
* T_max (int) – Maximum number of iterations. | ||
* eta_min (float) – Minimum learning rate. Default: 0. | ||
* last_epoch (int) – The index of last epoch. Default: -1. | ||
* verbose (bool) – If True, prints a message to stdout for each update. Default: False. | ||
lr_scheduler.ReduceLROnPlateau | 当某项指标不再下降时削减学习率 | * ptimizer (Optimizer) – Wrapped optimizer. |
* mode (str) – One of min, max. In min mode, lr will be reduced when the quantity monitored has stopped decreasing; in max mode it will be reduced when the quantity monitored has stopped increasing. Default: ‘min’. | ||
* factor (float) – Factor by which the learning rate will be reduced. new_lr = lr * factor. Default: 0.1. | ||
* patience (int) – Number of epochs with no improvement after which learning rate will be reduced. For example, if patience = 2, then we will ignore the first 2 epochs with no improvement, and will only decrease the LR after the 3rd epoch if the loss still hasn’t improved then. Default: 10. | ||
* threshold (float) – Threshold for measuring the new optimum, to only focus on significant changes. Default: 1e-4. | ||
* threshold_mode (str) – One of rel, abs. In rel mode, dynamic_threshold = best * ( 1 + threshold ) in ‘max’ mode or best * ( 1 - threshold ) in min mode. In abs mode, dynamic_threshold = best + threshold in max mode or best - threshold in min mode. Default: ‘rel’. | ||
* cooldown (int) – Number of epochs to wait before resuming normal operation after lr has been reduced. Default: 0. | ||
min_lr (float or list) – A scalar or a list of scalars. A lower bound on the learning rate of all param groups or each group respectively. Default: 0. | ||
* eps (float) – Minimal decay applied to lr. If the difference between new and old lr is smaller than eps, the update is ignored. Default: 1e-8. | ||
* verbose (bool) – If True, prints a message to stdout for each update. Default: False. | ||
lr_scheduler.CyclicLR | 以某种循环策略调整学习率 | 详见https://pytorch.org/docs/stable/generated/torch.optim.lr_scheduler.CyclicLR.html#torch.optim.lr_scheduler.CyclicLR |
lr_scheduler.OneCycleLR | 以某种单次循环策略调整学习率 | 详见https://pytorch.org/docs/stable/generated/torch.optim.lr_scheduler.OneCycleLR.html#torch.optim.lr_scheduler.OneCycleLR |
lr_scheduler.CosineAnnealingWarmRestarts | 采用cos衰减的方式调整学习率,当last_epoch=-1时,lr 取值为初始值 |
在训练中,上述scheduler API通过实例化创建scheduler实例,再通过在optimizer优化一步(即调用step()方法)后,调用step()方法进行学习率调整,如下:
# 选择优化器
optimizer = torch.optim.Adam(...)
# 选择一种或多种动态调整学习率方法
scheduler = torch.optim.lr_scheduler....
# 进行训练
for epoch in range(100):
train(...)
validate(...)
optimizer.step()
# 在优化器参数更新之后再动态调整学习率
scheduler.step()
...
2.2 自定义scheduler
自定义scheduler的方法是通过构建自定义函数adjust_learning_rate
,来改变optimizer
的param_group
中lr
的值实现的,例如:
def adjust_learning_rate(optimizer, epoch):
lr = args.lr * (0.1 ** (epoch // 30))
for param_group in optimizer.param_groups:
param_group['lr'] = lr
基于此定义的scheduler,我们便可以在训练时进行使用,如下:
# 选择优化器
optimizer = torch.optim.Adam(...)
# 进行训练
for epoch in range(100):
train(...)
validate(...)
optimizer.step()
# 调用自定义函数调整学习率
adjust_learning_rate(optimizer, epoch)
...
2.3 问题
在使用自定义学习率调整函数时,自定义学习率调整函数是否也要放在optimizer.step()语句之后?
3.模型微调
当前,模型参数的规模持续膨胀,能够达到的能力水平,甚至跨任务的泛化水平也在不断提高,但这些模型往往均是通过在大数据集上训练的。因此,当前在很多深度学习任务求解上的做法是基于一个在很大的数据集上训练的模型进行进一步调整实现的。这其实就是模型微调——基于预训练模型在当前任务上进行进一步训练。也正是基于这样的思想,近年来预训练大模型开始成为热点。从BERT到GPT-3,到大模型的出现一方面促进了AI模型泛化能力的提升,另一方面也削减了下游任务(具体任务)的训练成本,催生了“大模型预训练+微调”的应用研发范式。
Pytorch提供了许多预训练好的网络模型(VGG,ResNet系列,mobilenet系列…),这些模型都是PyTorch官方在相应的大型数据集训练好的。在面对具体下游任务时,我们可以从中选择与我们任务接近的模型,换成我们的数据进行精调,也就是我们经常见到的finetune。
3.1 模型微调流程
模型精调分为如下几步:
-
在源数据集上预训练一个神经网络模型,即源模型。这一步实际上预训练模型制作方为我们准备好了,即我们在Pytorch中拿到的就已经是预训练好的模型了。
-
创建新的神经网络模型,即目标模型。将源模型中除了最终的输出层外所有部分的模型和相应的参数复制到目标模型中。这一步是通过模型结构和参数的拷贝,将源模型(预训练模型)预训练中的经验赋予目标模型。但由于目标模型与源模型面对的任务不同,因此,目标模型中最后的输出层保留独立。我认为,这里不仅限于输出层,扩充一些。只要是针对当前任务特有的层都可以保留相对于源模型的独立性。
-
为目标模型添加一个与目标模型任务想匹配的输出层,并随机初始化该层的模型参数。
-
在目标数据集上训练目标模型。对于输出层(即目标模型特有的部分),我们将从头训练,而其余层的参数都是基于源模型的参数微调得到的。
在上述流程下,我们可以实现对模型的精调。下面,需要考虑如下几个方面的细节:
- Pytorch中已有模型结构及预训练参数的复用
- Pytorch中模型微调的实现
3.2 Pytorch中已有模型结构及预训练参数的复用
Pytorch中提供了许多预训练好的网络模型,包括它们的网络结构和预训练模型权重,均可通过torchvision
获取,各个预训练网络模型实例的创建如下:
import torchvision.models as models
resnet18 = models.resnet18()
alexnet = models.alexnet()
vgg16 = models.vgg16()
squeezenet = models.squeezenet1_0()
densenet = models.densenet161()
inception = models.inception_v3()
googlenet = models.googlenet()
shufflenet = models.shufflenet_v2_x1_0()
mobilenet_v2 = models.mobilenet_v2()
mobilenet_v3_large = models.mobilenet_v3_large()
mobilenet_v3_small = models.mobilenet_v3_small()
resnext50_32x4d = models.resnext50_32x4d()
wide_resnet50_2 = models.wide_resnet50_2()
mnasnet = models.mnasnet1_0()
上述预训练模型实例创建中调用的初始化函数都包含有一个pretrained
参数,该参数默认为False
。故采用上述代码,我们仅获得了预训练网络模型的结构,而参数是随机初始化的。为载入预训练好的模型参数,需将pretrained
参数设置为True
,如下:
import torchvision.models as models
resnet18 = models.resnet18(pretrained=True)
注意:
-
程序运行时会首先检查默认路径(在
Linux
和Mac
的是用户根目录下的.cache
文件夹。在Windows
下是C:\Users\<username>\.cache\torch\hub\checkpoint
)中是否有已经下载的模型权重,一旦权重被下载,下次加载就不需要下载了。 -
我们也可以将自己的权重下载下来放到同文件夹下,然后再将参数加载网络,例如:
import torchvision.models as models import torch model = models.resnet18(pretrained=False) model.load_state_dict(torch.load('/models/resnet18-f37072fd.pth'))
3.3 Pytorch中模型微调的实现
在上述模型微调流程中,我们仅对与当前任务密切相关且与预训练模型有差异的部分进行完整训练,而对与预训练模型一致的部分进行微调。微调方式分为两种:
-
方法1:先不训练微调部分,集中训练差异部分,而后在以较小的学习率整体微调训练
-
方法2:采用不同的学习率训练微调部分和差异部分
3.3.1 固定微调部分,训练差异部分
在默认情况下,模型参数的属性.requires_grad = True
。如果我们正在提取特征并且只想为新初始化的层计算梯度,其他参数不进行改变,那我们就需要将设置requires_grad = False
来冻结部分层,例如:
import torchvision.models as models
# 载入预训练模型
model = models.resnet18(pretrained=True)
# 冻结预训练模型部分梯度
for param in model.parameters():
param.requires_grad = False
# 修改模型
num_ftrs = model.fc.in_features
model.fc = nn.Linear(in_features=num_ftrs, out_features=4, bias=True)
上述设计下,训练过程中梯度仅会回传至fc
层,而不会影响预训练模型部分。
3.3.2 不同学习率训练不同部分
具体而言,得益于Pytorch的灵活性,我们可以采用不同的学习率对不同的模型层进行训练,以实现采用较大的学习率进行完整训练,采用较小的学习率进行微调训练。现假设我们仅对上述resnet18
的最后一层进行调整,以实现一个二分类任务。
首先,我们对原有的resnet18
的网络结构和模型参数进行“继承”
import torchvision.models as models
import torch
import torch.nn as nn
import torch.optim as optim
model = models.resnet18(pretrained=False)
model.load_state_dict(torch.load('/models/resnet18-f37072fd.pth'))
修改最后的输出层
model.fc = nn.Linear(512, 2)
然后,分割出特征提取部分模型参数(预训练模型参数)和输出层模型参数
output_params = list(map(id, model.fc.parameters()))
feature_params = list(filter(lambda p: id(p) not in output_params, model.parameters()))
接着,我们通过在优化器中指定不同部分的模型采用不同的学习率进行训练,即可实现基于预训练模型的精调,如下:
lr = 0.001
optimizer = opim.SGD([{'params': feature_params},
{'params': model.fc.parameters(), 'lr': lr * 10}], lr=lr, weight_decay=0.001)
3.4 timm库
上述预训练模型微调中,我们主要使用的是torchvision
库。除了该库以外,还有一个常见的预训练模型库,叫做timm
,这个库是由来自加拿大温哥华Ross Wightman创建的。里面提供了许多计算机视觉的SOTA模型,可以当作是torchvision
的扩充版本,并且里面的模型在准确度上也较高。
在得到我们想要使用的预训练模型后,我们可以通过timm.create_model()
的方法来进行模型的创建,我们可以通过传入参数pretrained=True
,来使用预训练模型。同样的,我们也可以使用跟torchvision里面的模型一样的方法查看模型的参数,类型。关于预训练模型的修改,似乎仅能通过timm.create_model()
方法接口在创建和载入模型时进行修改,但尚不确定。如果后续使用,可参考官网或者Github链接。
import timm
import torch
model = timm.create_model('resnet34',pretrained=True)
# 修改模型(将1000类改为10类输出)
model = timm.create_model('resnet34',num_classes=10,pretrained=True)
# 改变输入通道数
model = timm.create_model('resnet34',num_classes=10,pretrained=True,in_chans=1)
补充知识
filter的基础用法
filter
是一个过滤器,其作用是从列表(或其他序列类型)中筛选出满足条件的子列表。对于列表(或其他序列类型),如果希望从中筛选出满足某个约束条件的子列表,我们一般的做法是使用一个for循环遍历每个元素然后执行相同约束条件判断,将满足条件的放入新的子列表中。例如,从列表中找出所有偶数子列表,并按对应的先后顺序放入子列表中:
a = [1, 2, 3, 4, 5]
b = []
for i in a:
if i % 2 == 0:
b.append(i)
那么如果使用filter的话,使用filter函数使得代码变得更简洁:
a = [1, 2, 3, 4, 5]
def check(i): return i % 2 == 0
b = list(filter(check, a))
4.半精度训练
GPU的性能主要分为两部分:算力和显存,前者决定了显卡计算的速度,后者则决定了显卡可以同时放入多少数据用于计算。在可以使用的显存数量一定的情况下,每次训练能够加载的数据更多(也就是batch size更大),则也可以提高训练效率。另外,有时候数据本身也比较大(比如3D图像、视频等),显存较小的情况下可能甚至batch size为1的情况都无法实现。因此,合理使用显存也就显得十分重要。
PyTorch默认的浮点数存储方式用的是torch.float32,小数点后位数更多固然能保证数据的精确性,但绝大多数场景其实并不需要这么精确,只保留一半的信息也不会影响结果,也就是使用torch.float16格式。由于数位减了一半,因此被称为“半精度”,半精度能够减少显存占用,使得显卡可以同时加载更多数据进行计算。
在PyTorch中使用autocast配置半精度训练,同时需要在下面三处加以设置:
-
import autocast
from torch.cuda.amp import autocast
-
模型设置
在模型定义中,使用python的装饰器方法,用autocast装饰模型中的forward函数。
@autocast() def forward(self, x): ... return x
-
训练过程
在训练过程中,只需在将数据输入模型及其之后的部分放入“with autocast():“即可
for x in train_loader: x = x.cuda() with autocast(): output = model(x) ...
半精度训练主要适用于数据本身的size比较大(比如说3D图像、视频等)。当数据本身的size并不大时(比如手写数字MNIST数据集的图片尺寸只有28*28),使用半精度训练则可能不会带来显著的提升。
5.使用argparse进行调参
argparse是python的命令行解析的标准模块,可以让我们直接在命令行中就可以向程序中传入参数,通过argparse将命令行传入的其他参数进行解析、保存和使用。在使用argparse后,我们在命令行输入的参数就可以以这种形式python file.py --lr 1e-4 --batch_size 32
来完成对常见超参数的设置。
总的来说,我们可以将argparse的使用归纳为以下三个步骤:
- 创建
ArgumentParser()
对象
import argparse
# 创建ArgumentParser()对象
parser = argparse.ArgumentParser()
- 调用
add_argument()
方法添加参数
# 添加参数:
# action = `store_true` 会将output参数记录为True
# type 规定了参数的格式, default 规定了默认值
# required=True表示该参数为必选参数,要求必须从命令行给出参数值
parser.add_argument('-o', '--output', action='store_true', help="shows output")
parser.add_argument('--lr', type=float, default=3e-5, help='select the learning rate, default=1e-3')
parser.add_argument('--batch_size', type=int, required=True, help='input batch size')
- 使用
parse_args()
解析参数
# 使用parse_args()解析函数
args = parser.parse_args()
if args.output:
print("This is some output")
print(f"learning rate:{args.lr} ")
总的来说,argparse确实能够便捷化和灵活化命令行参数传递和解析,但即便将其封装在方法里,如果存在多处获取,获取过程还是要反复调用该方法/函数。因此,如果是多处调用的配置,可以采用argparse和配置文件相结合的方式进行参数配置,动态性较强的通过argparse进行配置,而静态性较强的则可以采用配置文件的形式进行配置。