PyTorch菜鸟学习指南——预训练模型及其配置

训练深度学习模型时,通过在自己的数据上微调预训练模型来进行迁移学习已经成为主流方法。

通过微调这些模型,我们可以利用它们已有的知识,并将其适配到我们的特定任务中,从而节省时间和计算资源。

简介
定义模型涉及一系列重要决策,包括选择合适的架构、自定义模型头部、配置损失函数和学习率、设置浮点精度、确定哪些层需要冻结或微调等。

本文将详细探讨这些方面,为大家提供有价值的见解,帮助大家有效地定义和微调模型。

加载预训练模型

图片

在加载预训练模型之前,必须清楚了解具体问题,并据此选择合适的架构。

虽然这项任务可能看起来具有挑战性,但重要的是不要随机选择模型架构,需要根据业务需求选择适合的架构。
例如,如果你正在微调分类任务且低延迟是优先考虑的因素,那么像MobileNet这样的架构将是一个不错的选择。

通过正确的架构选择,可以优化微调实验以获得更好的结果。
我们可以从多个来源加载预训练模型进行微调,在本文中,主要参考了timm(Pytorch Image Models)和Torchvision模型。
以下是一个从Torchvision加载预训练ResNet50模型的示例:

from torchvision import modelsmodel = models.resnet50(pretrained=False)  # 加载未预训练的ResNet50模型
从timm加载模型的示例:import timm
# 从timm加载预训练模型pretrained_model_name = "resnet50"model = timm.create_model(pretrained_model_name, pretrained=False)  # 加载未预训练的ResNet50模型

需要注意的是,无论预训练模型的来源如何,关键修改是调整模型的全连接层(FC层,也称为线性层/分类器/头部)。

此外,我们还可以为目标任务添加额外的线性层。

修改模型头部

图片

修改模型的头部是使其适配特定目标任务的关键步骤,预训练模型通常是在大规模数据集(如ImageNet用于图像分类,BooksCorpus和Wikipedia用于文本生成)上训练的。

通过修改模型的头部,预训练模型可以适应新任务,并利用其学到的有价值特征,从而在新任务中提升性能。
例如,你可以修改ResNet的头部以适配分类任务:

import torch.nn as nnimport timm
num_classes = 4  # 替换为您的数据类别数
# 从timm加载预训练模型model = timm.create_model('resnet50', pretrained=True)
# 修改模型头部以适配分类任务num_features = model.fc.in_features  # 获取全连接层的输入特征数model.fc = nn.Linear(num_features, num_classes)  # 替换为新的全连接层

或者,可以在修改ResNet头部的同时添加额外的线性层以增强模型的预测能力(以下仅为示例):

import torch.nn as nnimport timm
num_classes = 4  # 替换为您的数据类别数
# 从timm加载预训练模型model = timm.create_model('resnet50', pretrained=True)
# 修改模型头部以适配分类任务num_features = model.fc.in_features
# 添加额外的线性层和Dropout层model.fc = nn.Sequential(    nn.Linear(num_features, 256),  # 额外的线性层,输出256个特征    nn.ReLU(inplace=True),         # 激活函数(可以选择其他激活函数)    nn.Dropout(0.5),               # Dropout层,丢弃概率为50%    nn.Linear(256, num_classes)    # 最终的分类层)

也可以将ResNet头部修改为适配回归任务:

model = timm.create_model('resnet50', pretrained=True)
# 修改模型头部以适配回归任务num_features = model.fc.in_featuresmodel.fc = nn.Linear(num_features, 1)  # 回归任务只有一个输出

需要注意的是,模型并不总是有一个全连接层(FC层)供我们修改输出特征(例如num_classes)。

模型架构可能有所不同,需要修改的层的名称和位置也可能不同。
在许多预训练模型中,尤其是在卷积神经网络(CNN)架构中,通常在模型的末尾有一个线性层或全连接层用于执行最终分类。

然而,这并不是严格的规定,某些模型可能具有不同的结构。
要确定需要修改的层,可以通过打印模型来查看其架构并识别需要修改的层。例如:

import torchimport timm
# 从timm加载预训练模型model = timm.create_model('resnet50', pretrained=True)print(model)  # 打印模型架构

图片

查找作为最终分类层的线性层或全连接层,并将其替换为与类别数量或任务需求匹配的新层。

设置优化器、学习率、权重衰减和动量

图片

在微调过程中,学习率、损失函数和优化器是相互关联的组件,它们共同影响模型适应新任务的能力,同时利用预训练中获得的知识。

一个合适的学习率可以确保模型以合理的速度有效收敛,精心选择的损失函数可以使训练过程中的损失最小化与目标任务保持一致,而适当的优化器可以有效优化模型的参数。
微调需要仔细的实验和这些组件的迭代调整,以达到适当的平衡,并在微调模型中实现所需的性能水平。

优化器
优化器决定了在反向传播过程中基于梯度更新模型参数的算法,不同的优化器(如SGD、Adam或RMSprop)具有不同的参数更新规则和收敛特性。

优化器的选择会显著影响模型训练和微调模型的最终性能,选择最合适的优化器需要考虑任务的性质、数据集的大小以及可用的计算资源等因素。

学习率、动量和权重衰减
在定义优化器时,我们还需要设置学习率(LR),这是一个超参数,决定了优化过程中每次迭代的步长。

它控制了模型参数在反向传播过程中根据计算的梯度更新的幅度。

选择合适的学习率至关重要,因为设置过高可能导致优化过程振荡或发散,而设置过低可能导致收敛缓慢或陷入局部最优。
除了学习率外,定义优化器时还需要考虑其他关键超参数,例如权重衰减和动量(特定于SGD)。

让我们快速了解一下这两个超参数:

  • 权重衰减(Weight Decay),也称为L2正则化,是一种用于防止过拟合并鼓励模型学习更简单、更泛化的表示的技术。
  • 动量(Momentum)用于随机梯度下降(SGD),以加速收敛并逃离局部最优。
import torch.optim as optim
# 定义带有权重衰减的SGD优化器optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=0.001)
# 定义带有权重衰减的Adam优化器optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=0.0001)

选择损失函数

图片

损失函数衡量模型预测输出与实际正确答案之间的差异或差距。它为我们提供了一种理解模型在任务上表现如何的方式,在微调预训练模型时,选择适合特定任务的损失函数非常重要。

例如,对于分类任务,通常使用交叉熵损失,而对于回归问题,均方误差损失更为合适。

选择合适的损失函数可以确保模型在训练过程中专注于优化期望的目标。

以下是定义损失函数的示例代码:

import torch.nn as nn
# 定义分类问题的损失函数loss_function = nn.CrossEntropyLoss()
# 定义回归问题的损失函数loss_function = nn.MSELoss()  # 均方误差损失

此外,还有一些额外的注意事项和技术可以应用于损失函数的选择和处理。
自定义损失函数:你可能需要修改或自定义损失函数以满足特定需求。

例如,对某个重要类别的错误分类施加10倍的惩罚。以下是一个自定义损失函数的示例代码:

import torchimport torch.nn.functional as F
class CustomLoss(torch.nn.Module):    def __init__(self, class_weights):        super(CustomLoss, self).__init__()        self.class_weights = class_weights  # 类别权重
    def forward(self, inputs, targets):        ce_loss = F.cross_entropy(inputs, targets, reduction='none')  # 计算交叉熵损失        weights = torch.ones_like(targets).float()  # 初始化权重        for class_idx, weight in enumerate(self.class_weights):            weights[targets == class_idx] = weight  # 根据类别设置权重        weighted_loss = ce_loss * weights  # 加权损失        return torch.mean(weighted_loss)  # 返回加权平均损失
# 假设您有一个模型和训练数据model = YourModel()optimizer = torch.optim.SGD(model.parameters(), lr=0.01)# 假设有5个类别,类别权重为[1.0, 1.0, 1.0, 1.0, 10.0]criterion = CustomLoss(class_weights=[1.0, 1.0, 1.0, 1.0, 10.0])  
# 在训练循环中optimizer.zero_grad()outputs = model(inputs)loss = criterion(outputs, targets)loss.backward()optimizer.step()

基于指标的损失:在某些情况下,模型的性能可能基于损失本身以外的指标进行评估。

在这种情况下,可以设计或调整损失函数以直接优化这些指标。
正则化:在微调过程中,可以将L1或L2正则化方法纳入损失函数,以防止过拟合并提高模型的泛化能力。

正则化项可以帮助控制模型的复杂性,并减少过度强调数据中特定模式或特征的风险。

L2正则化可以通过在优化器中设置weight_decay值来实现,而L1正则化则需要稍有不同的方法。

以下是L2正则化的实现示例:

# 定义损失函数criterion = nn.CrossEntropyLoss()
# L2正则化optimizer = optim.SGD(model.parameters(), lr=0.01, weight_decay=0.01)

以下是L1正则化的实现示例:

# 定义损失函数criterion = nn.CrossEntropyLoss()optimizer = optim.SGD(model.parameters(), lr=0.01)
# 在训练循环中optimizer.zero_grad()outputs = model(inputs)loss = criterion(outputs, targets)
# L1正则化regularization_loss = 0.0for param in model.parameters():    regularization_loss += torch.norm(param, 1)  # 计算L1正则化项loss += 0.01 * regularization_loss  # 调整正则化强度

冻结整个或部分网络
当我们提到“冻结”时,指的是在微调过程中固定特定层或整个网络的权重。

网络冻结允许我们保留预训练模型所捕获的知识,同时只更新某些层以适应目标任务。

因此,这一点非常关键,如果你正在微调一个预训练模型,这一点不应被忽视。
决定是否在微调前冻结预训练模型的所有层(整个网络)或部分层,完全取决于你的具体目标任务。
例如,如果预训练模型已经在与目标任务相似的大规模数据集上进行了训练,冻结整个网络可以帮助保留已学习的表示,防止它们被覆盖。

在这种情况下,只需修改模型的头部并从头开始训练。
另一方面,当预训练模型的较低层捕捉到可能对新任务有用的一般特征时,冻结部分网络会很有用。

通过冻结这些较低层,我们可以利用预训练模型的知识,同时更新较高层以专注于任务特定的特征。

这种方法在目标数据集较小或与预训练模型的数据集显著不同的情况下特别有用。
在PyTorch中实现冻结时,你可以访问模型中的各个层或模块,并将它们的requires_grad属性设置为False。

这样可以防止在反向传播过程中计算梯度和更新权重。
以下是一个示例代码,展示了如何冻结整个网络:

# 冻结预训练模型的所有层for param in model.parameters():    param.requires_grad = False  # 设置所有参数的梯度计算为False
# 修改模型的头部以适应新任务num_classes = 10  # 假设新任务有10个类别model.fc = nn.Linear(model.fc.in_features, num_classes)  # 替换全连接层

冻结网络中的卷积层:

# 只冻结预训练模型的卷积层for param in model.parameters():    if isinstance(param, nn.Conv2d):  # 判断参数是否为卷积层        param.requires_grad = False  # 冻结卷积层的梯度计算
# 修改模型的头部以适应新任务num_classes = 10model.fc = nn.Linear(model.fc.in_features, num_classes)

冻结网络中的特定层:

# 冻结预训练模型的特定层(例如前两个卷积层)for name, param in model.named_parameters():    if 'conv1' in name or 'layer1' in name:  # 根据层名判断        param.requires_grad = False  # 冻结指定层的梯度计算
# 修改模型的头部以适应新任务num_classes = 10model.fc = nn.Linear(model.fc.in_features, num_classes)

需要注意的是,冻结层时应根据任务和数据集的特定需求进行深度思考。

定义模型的浮点精度
简单来说,模型浮点精度指的是在深度学习模型计算过程中用于表示数值的数据类型。

在PyTorch中,32位(float32或FP32)和16位(float16或FP16或半精度)是两种常用的浮点精度。

  • float32:这种精度提供了广泛的动态范围和高数值精度,允许进行精确计算,但会消耗更多内存。FP32使用32位来表示一个数字。
  • float16:这种较低的精度可以减少模型的内存占用和计算需求,从而提高效率和速度。然而,它可能会导致数值精度的损失,并影响模型的准确性或收敛性。FP16使用16位来表示一个数字。

FP16和FP32被称为单精度,它们各有优缺点,为了结合两者的优势,我们引入了混合精度,它在训练管道中结合了FP16和FP32浮点精度。

混合精度提供了更高的计算效率、减少的内存占用、加速的训练过程以及增加的模型容量。

以下是一个使用PyTorch的自动混合精度(AMP)库实现混合精度训练的示例代码:

import torchfrom torch import nn, optimfrom torch.cuda.amp import autocast, GradScaler
# 定义模型和优化器model = YourModel()  # 替换为你的模型optimizer = optim.Adam(model.parameters(), lr=0.001)  # 使用Adam优化器
# 定义损失函数criterion = nn.CrossEntropyLoss()  # 交叉熵损失函数
# 定义梯度缩放器scaler = GradScaler()  # 用于自动缩放梯度
# 定义训练循环for epoch in range(num_epochs):    for batch_idx, (data, targets) in enumerate(train_loader):        data, targets = data.to(device), targets.to(device)  # 将数据移动到设备(GPU或CPU)              # 清零梯度        optimizer.zero_grad()              # 启用混合精度        with autocast():            # 前向传播            outputs = model(data)            loss = criterion(outputs, targets)  # 计算损失              # 反向传播和梯度缩放        scaler.scale(loss).backward()  # 缩放损失并反向传播              # 更新模型参数        scaler.step(optimizer)  # 更新优化器参数        scaler.update()  # 更新缩放器              # 打印训练进度        if batch_idx % log_interval == 0:            print(f"Epoch {epoch+1}/{num_epochs} | Batch {batch_idx}/{len(train_loader)} | Loss: {loss.item():.4f}")

在上面的示例中,GradScaler()对象用于执行梯度缩放,以下是使用的方法的详细说明:

  • scaler.scale(loss):该方法通过缩放器确定的适当因子缩放损失值。它返回一个缩放后的损失,用于反向传播。
  • scaler.step(optimizer):该方法使用反向传播期间计算的梯度更新优化器的参数。它像往常一样执行优化器步骤,但考虑了缩放器执行的梯度缩放。
  • scaler.update():该方法调整缩放器用于下一次迭代的缩放因子。它通过动态调整缩放因子来防止梯度下溢或溢出问题。

使用GradScaler()和相关方法的目的是在使用较低精度(FP16)计算时缓解可能出现的数值不稳定性问题,通过适当地缩放损失和梯度,缩放器确保优化器的更新保持在稳定范围内。
这种使用PyTorch AMP库的混合精度训练实现允许高效利用FP16计算,以提高训练速度并减少内存使用,同时保持FP32的精度以确保权重更新的准确性。
尽管混合精度训练可以带来多种好处,但在某些情况下可能不适用,甚至可能对训练过程产生负面影响。

使用混合精度训练的潜在危害包括:

  • 数值精度损失:由于FP16的精度较低,可能导致模型准确性下降,特别是在需要高精度的任务中。
  • 下溢和溢出的风险增加:由于数值不稳定性,可能会影响模型的收敛和性能。
  • 复杂性增加:混合精度训练需要额外的考虑,如管理精度转换、缩放梯度以及处理可能的精度不匹配问题。

如果你的模型存在严重的梯度爆炸或消失问题,切换到较低精度计算(FP16)可能会加剧这些问题。

在这种情况下,应在考虑混合精度训练之前解决潜在的不稳定性问题。

训练和验证模式
在微调模型时,加载预训练模型后,模型默认处于训练模式。

然而,在推理或验证期间,我们可以将模型切换到验证模式,这些模式的改变会相应地改变模型的行为。
训练模式:当模型处于训练模式时,它会启用训练过程中所需的特定操作,如计算梯度、更新参数以及应用正则化技术(如Dropout)。

在这种模式下,模型的行为就像在训练数据集上进行训练一样,准备从数据中学习。

model.train()  # 将模型设置为训练模式

验证模式:当模型处于评估模式时,它会禁用仅在训练期间必要的某些操作,如计算梯度、Dropout和更新参数。

此模式通常用于验证或测试期间,当你想要评估模型在未见数据上的性能时。

model.eval()  # 将模型设置为验证模式

在微调过程中将模型设置为正确的模式非常重要,因为这确保了每个阶段(训练或评估)的一致行为和正确操作。

这有助于获得准确的结果、高效利用资源,并防止过拟合或不一致的归一化问题。

单GPU和多GPU
GPU对于深度学习和微调任务至关重要,因为它们擅长执行高度并行的计算,从而显著加快训练过程。

如果你有多个GPU,可以利用它们的集体力量进一步加速训练。

以下是一个示例,展示了如何利用多个GPU(如果有的话):

# 定义模型model = MyModel()  # 替换为你的模型model = model.to(device)  # 将模型移动到目标设备(CPU或GPU)
# 检查是否有多个GPU可用if torch.cuda.device_count() > 1:    print("使用", torch.cuda.device_count(), "个GPU进行训练。")    model = nn.DataParallel(model)  # 使用DataParallel包装模型
# 定义损失函数和优化器criterion = nn.CrossEntropyLoss()  # 交叉熵损失函数optimizer = optim.Adam(model.parameters(), lr=0.001)  # 使用Adam优化器

此代码片段首先使用torch.cuda.device_count()检查是否有多个GPU可用。

如果有多个GPU,模型将被nn.DataParallel包装,从而允许它利用所有可用的GPU进行训练。

每个GPU同时处理一部分数据,从而加快训练速度。

如果只有一个GPU,代码将在该GPU上运行,而不使用nn.DataParallel。

结论
本文详细介绍了在PyTorch中微调预训练模型的关键步骤和技巧,包括冻结网络层、调整浮点精度、切换训练与验证模式、以及利用单GPU和多GPU加速训练,可以帮大家高效地迁移学习并提升模型性能。

来源:人工智能学习指南

THE END