在这里插入图片描述

PyTorch Java 计算机学院硕士研一课程

章节 3: 优化技术与训练策略

高效训练复杂的深度学习模型通常需要的不只是标准优化器和固定学习率。本章侧重介绍提升收敛速度、模型泛化能力以及训练时资源效率的方法。

你会了解除标准SGD或Adam之外的优化算法,例如AdamW和Lookahead。我们会讲解如何实现动态学习率调度,包括余弦退火和预热阶段,以精细调整训练过程。我们还会讲解正则化方法,例如标签平滑和高级权重衰减。

此外,本章还会讨论实际训练中遇到的难题。你会学习一些技巧,例如梯度裁剪用于处理不稳定的梯度,以及梯度累积用于模拟更大的批量大小 (Neffective=Naccum×NbatchNeffectiv**e=Naccum×Nbatch)。我们会介绍使用torch.cuda.amp进行自动混合精度 (AMP) 训练,以在兼容硬件上实现更快的计算和更少的内存占用。处理海量数据集的策略,例如使用IterableDataset,以及整合自动化超参数调整工具,构成本章的完整内容。

高级优化器概述

虽然带有动量的 SGD 和 Adam 等优化器在许多深度学习任务中表现出色且常用,但要获得最佳性能,尤其是在复杂模型或具有挑战性的数据集上,通常会受益于更完善的优化策略。标准优化器有时会表现出不理想的行为,例如对权重衰减的处理不佳或在训练初始阶段不稳定。PyTorch 中可用或常用的几种高级优化器旨在解决这些具体问题。

AdamW:解耦权重衰减

Adam 仍然是一种流行且通常有效的自适应学习率优化器。然而,其标准实现通常将 L2 正则化与真正的权重衰减混淆。

回顾一下,L2 正则化根据权重的平方大小向损失函数添加一个惩罚项: L总=L原始+λ2∣∣w∣∣2L总=L原始+2λ∣∣w∣∣2 在计算梯度时,这会在原始损失的梯度中添加一个与权重本身成比例的项(λwλ**w): ∇wL总=∇wL原始+λw∇w**L总=∇w**L原始+λ**w 在 Adam 等优化器中,这个梯度项 λwλ**w 会被优化器的内部机制(如梯度和平方梯度的移动平均)调整。

权重衰减,如最初提出,是一种不同的做法。它涉及在更新步骤中,梯度计算之后,直接从权重本身中减去一小部分: wt+1=wt−η(∇wL原始+λwt)w**t+1=w**tη(∇w**L原始+λwt) 或者更准确地说,对于自适应方法,衰减通常是单独应用的: wt+1=wt−η⋅自适应梯度(∇wL原始)−ηλ′wtw**t+1=w**tη⋅自适应梯度(∇w**L原始)−η**λw**t 其中 λ′λ′ 是权重衰减系数。

主要区别在于,真正的权重衰减不会被 Adam 计算的自适应学习率缩放。在使用 L2 正则化的标准 Adam 实现中,对历史梯度值较大的权重施加的实际衰减可能远小于预期,而梯度值较小的权重可能衰减过快。

AdamW 明确实现了原始的权重衰减思路,将其与梯度自适应机制解耦。这通常比带有 L2 正则化的 Adam 带来更好的泛化性能,特别是对于对正则化强度敏感的模型。

在 PyTorch 中使用 AdamW 简单直接,因为它包含在 torch.optim 中:

import torch
import torch.nn as nn
import torch.optim as optim

// 假设 'model' 是你的 nn.Module 实例
// AdamW 使用示例
val optimizer = optim.AdamW(
    model.parameters(),
    lr=1e-4,          // 学习率
    betas=Seq(0.9, 0.999), // 移动平均的系数
    eps=1e-8,         // 添加到分母以提高数值稳定性的项
    weight_decay=1e-2,  // 权重衰减系数(正确应用)
    amsgrad=false     // 是否使用 AMSGrad 变体
)
// 典型的训练循环步骤
// optimizer.zero_grad()
// loss.backward()
// optimizer.step()
// 1. 创建 AdamW 优化器选项
        AdamWOptions options = new AdamWOptions(1e-4) ;// 初始学习率 (lr)
        options.betas().put(0.9, 0.999);
//        .betas(new FloatPair(0.9, 0.999))         // 移动平均系数
        options.eps().put(1e-8); //
        //      .eps(1e-8)                                // 数值稳定性项
        options.weight_decay().put(1e-2);                     // 权重衰减
        options.amsgrad().put(false);                          // 是否使用 AMSGrad

// 2. 实例化优化器
// 注意:model.parameters() 返回的是参数列表
        AdamW optimizer = new AdamW(model.parameters(), options);

/* 典型的训练循环步骤(Java 语法):
   
   optimizer.zero_grad();      // 梯度清零
   loss.backward();            // 反向传播计算梯度
   optimizer.step();           // 更新参数
*/

对于许多应用场景,从 optim.Adam 切换到 optim.AdamW 并调整 weight_decay 参数可以带来显著提升,且代码改动最少。它通常被推荐作为默认的起始选择,优于标准 Adam。

Lookahead:稳定学习过程

Lookahead 本身不是一个优化器,而是一种封装现有优化器(如 AdamW 或 SGD)的机制。它旨在通过维护两组权重:“快”权重和“慢”权重来提高学习的稳定性并减少参数更新的方差。

内部基础优化器(例如 AdamW)更新快权重 kk 步。在这 kk 步之后,慢权重通过向该序列中最终的快权重方向移动来更新。然后,快权重重置到新的慢权重位置,此过程重复。

设 wslowwslow 为慢权重, wfastwfast 为快权重。设 OO 为内部优化器(例如 AdamW)。

  1. 初始化 wfast←wslowwfastwslow
  2. 对于 i=1i=1 到 kk:使用 OO 根据当前小批量梯度更新 wfastwfast
  3. 更新 wslowwslow:wslow←wslow+α(wfast−wslow)wslowwslow+α(wfastwslow),其中 αα 是慢步长(通常称为 la_alpha)。
  4. 返回步骤 1。

其思路是,内部优化器使用快权重在参数空间中快速移动,而慢权重则提供更稳定、平均的轨迹,降低在最优值附近波动或超越最优值的风险。Lookahead 通常带来更快的收敛速度和更好的最终性能。

Lookahead 不属于标准的 torch.optim 包,但相对容易实现,或可以在 torchcontrib 等外部库中找到,或可能集成到 PyTorch Lightning 等框架中。

一个实现可能如下所示(简化版):

// Lookahead 使用(需要 Lookahead 实现)
// from some_library import Lookahead // 从某个库导入 Lookahead

// 定义基础优化器
val base_optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2)

// 使用 Lookahead 封装
val optimizer = Lookahead(base_optimizer, la_steps=5, la_alpha=0.5)

// 训练循环保持不变:
// optimizer.zero_grad()
// loss.backward()
// optimizer.step() // Lookahead 内部管理快/慢权重和 k 步

kk (la_steps) 的常见取值是 5 或 10,αα (la_alpha) 的常见取值是 0.5 或 0.8。

RAdam:用于稳定预热的修正Adam

Adam 的自适应学习率,基于梯度的一阶和二阶矩估计,功能强大,但可能在早期训练阶段出现高方差。当已见样本(小批量)数量较少时,二阶矩 (vtv**t) 的估计可能不可靠。这可能导致初始学习率过大或过小,可能阻碍收敛或导致发散。

修正 Adam (RAdam) 通过引入一个修正项来解决这个问题,该修正项根据二阶矩估计的方差调整自适应学习率。简而言之,它衡量自适应学习率项 (mt/(vt+ϵ)m*t*/(*v*t+ϵ)) 的方差。如果方差估计较高(通常在训练早期),RAdam 会暂时关闭自适应学习率,有效地像带有动量的 SGD 那样运行。随着更多数据处理,方差估计变得更可靠(减小),自适应学习率机制逐渐引入。

自适应部分的这种“预热”行为有助于从一开始就稳定训练,使得 RAdam 相对于标准 Adam 对初始学习率的选择不那么敏感。

与 Lookahead 类似,RAdam 可能不属于核心 torch.optim,但在几个流行的扩展库中可用。

// RAdam 使用(需要 RAdam 实现)
// from some_library import RAdam // 从某个库导入 RAdam

// RAdam 使用示例
val optimizer = RAdam(
    model.parameters(),
    lr=1e-3,
    betas=(0.9, 0.999),
    eps=1e-8,
    weight_decay=0 // RAdam 通常在初始阶段不单独使用权重衰减
)

// 训练循环保持不变:
// optimizer.zero_grad()
// loss.backward()
// optimizer.step()

RAdam 在训练已知对初始化或学习率选择敏感的模型时,或者在处理小批量数据时(小批量数据本身方差较高),可能特别有益。

选择优化器

  • AdamW:通常是很好的默认选择,替代标准 Adam。提供更合理的权重衰减,经常带来更好的泛化能力。
  • Lookahead:一种封装器,可能提高任何基础优化器(AdamW、SGD)的性能。如果希望在已调优的基础优化器之上获得额外的稳定性或收敛速度,可以考虑它。需要调优 kk 和 αα
  • RAdam:如果在使用 Adam 或 AdamW 训练的初始阶段遇到不稳定情况时有用。它旨在通过修正自适应学习率的方差来提供更平稳的启动。

实验依然重要。虽然这些优化器解决了简单方法的具体理论不足,但它们的实际影响因模型架构、数据集和其他超参数而异。在选择和调整这些更高级的优化器时,分析训练动态和验证性能非常重要。

高级学习率调度

虽然固定学习率对于较简单的问题可能有效,但训练大型、复杂的模型通常会从优化过程中动态调整学习率中大大受益。精心选择的学习率调度可以加速收敛,帮助处理复杂的损失函数形态,并与使用单一、静态学习率相比,带来更好的最终模型性能。PyTorch通过torch.optim.lr_scheduler模块提供了一个灵活的架构,用于实现各种调度策略。

大多数学习率调度背后的核心思想是直观的:在训练初期,当参数距离最优值较远时,从一个相对较大的学习率开始,以取得快速进展。然后,随着训练的进行,模型接近一个可能的最小值时,逐渐降低学习率,以便进行更精细的调整,并防止越过最佳点。这种动态调整有助于在寻找最佳模型参数时平衡发现与利用。

常用高级调度策略

PyTorch提供了多种内置调度器,让您可以用最少的代码实现复杂的学习率调整。让我们来看看高级训练方案中一些最有效且常用的策略。

余弦退火

余弦退火是一种流行的调度技术,它按照余弦曲线平滑地降低学习率。它从优化器中指定的初始学习率开始,并在定义的回合数或步数(T_max)内逐渐将其降低到最小值(eta_min)。第tt回合的学习率ηtη**t计算如下:

ηt=ηmin+12(ηmax−ηmin)(1+cos⁡(tπTmax))η**t=η**min+21(ηmaxη**min)(1+cos(Tmaxtπ))

这里,ηmaxηmax是初始学习率。

这种平滑、渐进的衰减有助于优化器在训练结束时稳定到较好的最低点,避免了基于步长的衰减方法带来的突变。

这里是如何在PyTorch中实现CosineAnnealingLR

import torch
import torch.optim.SGD 
import torch.optim.lr_scheduler.CosineAnnealingLR
import matplotlib.pyplot as plt # 用于可视化

// 示例设置
val model_params = [torch.randn(10, 5, requires_grad=True)]
val optimizer = SGD(model_params, lr=0.1)

// 余弦退火:将学习率从0.1退火到0,持续100个epoch
val scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=0)

// 模拟训练循环以可视化学习率变化
val lrs = []
for epoch <- range(150): // 模拟超过T_max的epoch数
    // optimizer.step() // 通常在loss.backward()之后调用
    lrs.append(optimizer.param_groups(0)('lr'))
    scheduler.step()

// # 简单绘图(课程集成请替换为Plotly)
// plt.figure()
// plt.plot(range(150), lrs)
// plt.title("CosineAnnealingLR (T_max=100)")
// plt.xlabel("回合")
// plt.ylabel("学习率")
// plt.grid(True)
// plt.show()
        // 1. 模拟模型参数 (10x5)
        Tensor modelParams = randn(new long[]{10, 5}, new TensorOptions().requires_grad(true));
        TensorVector paramsVector = new TensorVector(modelParams);

        // 2. 初始化 SGD 优化器,初始学习率为 0.1
        SGD optimizer2 = new SGD(paramsVector, new SGDOptions(0.1));

        // 3. 余弦退火调度器:T_max=100 (持续100个epoch), eta_min=0 (降至0)
        // 注意:在 JavaCPP 中,调度器通常作用于优化器对象
//        CosineAnnealingLR scheduler = new CosineAnnealingLR(optimizer, 100, 0);

        ReduceLROnPlateauScheduler scheduler  = new ReduceLROnPlateauScheduler(optimizer2);
        // 4. 模拟训练循环
        List<Double> lrs = new ArrayList<>();

        var metric = 1.0f;
        for (int epoch = 0; epoch < 150; epoch++) {
            // 获取当前学习率
            // 在 LibTorch 中,通过获取第一个参数组的当前选项来提取学习率
            double currentLr = optimizer.param_groups().get(0).options().get_lr();
            lrs.add(currentLr);

            // 执行优化器和调度器步骤
            // optimizer.step(); // 实际训练中在此调用
            scheduler.step(metric);
        }

注意学习率如何沿着余弦曲线下降,直到达到T_max(100个回合),然后对于后续回合保持在eta_min(0)。

带热启动的余弦退火

一个变体是CosineAnnealingWarmRestarts。这个调度器不是只退火一次,而是定期重新启动余弦退火周期。它在T_0个回合内退火学习率,然后通过将学习率重置回其初始值并开始新的退火周期来“重新启动”。后续周期的长度可以选择性地通过因子T_mult增加。

ηt=ηmin+12(ηmax−ηmin)(1+cos⁡(TcurπTi))η**t=η**min+21(ηmaxη**min)(1+cos(TiTcurπ))

这里,TiT**i是当前周期的长度(最初是T0T0,每次重启后乘以TmultTmu**lt),TcurTcu**r是自上次重启以来经过的回合数。

这些“热启动”可以帮助优化器摆脱可能在退火阶段陷入的次优局部最小值。

import torch
import torch.optim.AdamW // 常与高级调度器配合使用
import torch.optim.lr_scheduler.CosineAnnealingWarmRestarts
import matplotlib.pyplot as plt // 用于可视化

// 示例设置
val model_params = [torch.randn(10, 5, requires_grad=True)]
val optimizer = AdamW(model_params, lr=0.01) // 初始学习率

// 带热启动的余弦退火:
// 每50个回合重启一次 (T_0=50)。
// 每次重启后周期长度加倍 (T_mult=2)。
// 最小学习率为1e-5。
val scheduler = CosineAnnealingWarmRestarts(optimizer, T_0=50, T_mult=2, eta_min=1e-5)

// 模拟训练循环
val lrs_restarts = []
val num_epochs = 300 // T_0 + T_0*T_mult + T_0*T_mult*T_mult = 50 + 100 + 200 = 350
for epoch <- range(num_epochs):
    // optimizer.step()
    lrs_restarts.append(optimizer.param_groups(0)('lr'))
    scheduler.step()

// # 简单绘图
// plt.figure()
// plt.plot(range(num_epochs), lrs_restarts)
// plt.title("CosineAnnealingWarmRestarts (T_0=50, T_mult=2)")
// plt.xlabel("回合")
// plt.ylabel("学习率")
// plt.grid(True)
// plt.show()

这个调度器创建了退火周期,每个周期可能比前一个更长。

学习率预热

直接以较大的学习率开始训练,尤其是在使用Adam或AdamW等自适应优化器时,有时会导致早期训练不稳定或发散。初始参数梯度可能很大且噪声多,从而导致很大的、可能产生负面影响的更新。

缓解这种情况的常用方法是学习率预热。在训练的前几个回合(或批次)中,学习率从一个非常小的值(例如,接近零)逐渐增加到目标初始学习率。这使得模型在应用较大更新之前得以稳定。

预热通常不是PyTorch的lr_scheduler模块中的独立调度器,而是通过组合调度器或使用LambdaLR来实现的。一种常用方法是实现线性预热,然后是另一个衰减调度,如余弦退火。

这里是一个使用LambdaLR实现线性预热后接余弦退火的示例:

import torch
import torch.optim.AdamW
import torch.optim.lr_scheduler.LambdaLR
import torch.optim.lr_scheduler.CosineAnnealingLR
import math

// 示例设置
val model_params = [torch.randn(10, 5, requires_grad=True)]
val initial_lr = 0.01
val optimizer = AdamW(model_params, lr=initial_lr)

// 参数
val warmup_epochs = 10
val total_epochs = 100
val cosine_epochs = total_epochs - warmup_epochs

// 调度器1:线性预热
val lr_lambda_warmup = (current_epoch: Int) => {
    if current_epoch < warmup_epochs:
        return float(current_epoch + 1) / float(max(1, warmup_epochs))
    else:
        // 预热结束后,让余弦调度器间接接管
        // 我们计算相对于预热阶段结束的衰减因子
        val progress = float(current_epoch - warmup_epochs) / float(max(1, cosine_epochs))
        val cosine_decay = 0.5 * (1.0 + math.cos(math.pi * progress))
        return cosine_decay // 这个因子将乘以initial_lr

val scheduler = LambdaLR(optimizer, lr_lambda=lr_lambda_warmup)

// 模拟训练循环
val lrs_warmup_cosine = []
for epoch <- range(total_epochs + 20): // 模拟稍长一点的时间
    // optimizer.step()
    lrs_warmup_cosine.append(optimizer.param_groups(0)('lr'))
    scheduler.step()

// # 简单绘图
// plt.figure()
// plt.plot(range(total_epochs + 20), lrs_warmup_cosine)
// plt.title("线性预热 (10 回合) + 余弦退火")
// plt.xlabel("回合")
// plt.ylabel("学习率")
// plt.grid(True)
// plt.show()

这种方法使用单个LambdaLR调度器,其lr_lambda函数整合了预热逻辑和随后的余弦衰减逻辑。请注意,较新的PyTorch版本还提供了SequentialLRChainedScheduler,用于更明确地组合不同的调度器。

其他调度器

虽然余弦退火和预热非常普遍,但其他调度器也存在:

  • StepLR: 每step_size个回合将学习率衰减gamma因子。简单,但突然的下降有时会扰乱训练势头。
  • MultiStepLR: 类似于StepLR,但允许指定衰减学习率的确切回合数(milestones)。
  • ExponentialLR: 每个回合将学习率衰减gamma因子。
  • PolynomialLR: 按照多项式函数衰减学习率,在线性和其他衰减形状之间提供灵活性。
  • ReduceLROnPlateau: 当监控的指标(例如,验证损失)在指定回合数(patience)内停止改进时,降低学习率。这是根据性能自适应的,而非固定调度。

将调度器集成到训练循环中

在PyTorch中使用学习率调度器通常涉及两个步骤:

  1. 初始化: 在创建优化器之后,创建调度器实例,并将优化器传递给调度器的构造函数。
  2. 步进: 在训练循环中的适当位置调用调度器的step()方法。

scheduler.step()的放置位置非常重要:

  • 基于回合的调度器: 对于StepLRCosineAnnealingLRCosineAnnealingWarmRestarts(当按回合定义时)、MultiStepLR等调度器,您通常每个回合调用scheduler.step()一次,通常在验证循环之后或训练回合结束时。
  • 基于批次的调度器: 一些调度策略,特别是更精细的或与迭代紧密耦合的(如潜在的基于批次的预热或周期实现),可能需要在每个批次之后(即在optimizer.step()之后)调用scheduler.step()。务必查阅特定调度器的文档。对于这里提到的大多数常见基于回合的调度器,每个回合步进一次是标准做法。请注意,ReduceLROnPlateau需要将其指标值传递给其step()方法(例如,scheduler.step(validation_loss))。
// 典型训练循环片段(基于回合的步进)

val optimizer = AdamW(model_params, lr=initial_lr)
// scheduler = CosineAnnealingLR(optimizer, T_max=num_epochs)
val scheduler = // 在此处初始化您选择的调度器

for epoch <- range(num_epochs):
    model.train()
    for batch <- train_loader:
        val inputs = batch(0)
        val labels = batch(1)
        inputs, labels = inputs.to(device), labels.to(device)

        optimizer.zero_grad()
        val outputs = model(inputs)
        val loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        // 如果需要,基于批次的调度器步进会放在这里

    // --- 回合结束 ---
    // 执行验证、日志记录等

    // 步进调度器(针对基于回合的调度器)
    scheduler.step() // 对于ReduceLROnPlateau:scheduler.step(validation_loss)

    println(s"回合 ${epoch+1}/${num_epochs}, 学习率: ${optimizer.param_groups(0)('lr'):.6f}")

常用调度器的可视化

可视化学习率随时间的变化有助于理解和调试您选择的调度器。

比较不同学习率调度在回合中的表现(y轴对数刻度)。余弦退火表现出平滑衰减,热启动引入了周期性重置,而预热+余弦则包含一个初始的爬升阶段。

选择与调优

最佳的学习率调度及其参数(初始学习率、T_maxT_0T_multeta_min、预热持续时间)很大程度上取决于具体问题、数据集、模型架构和所选优化器。没有适用于所有情况的单一最佳调度器。

  • 余弦退火调度(带或不带重启,常与预热结合)是许多现代架构(如Transformer)的良好通用选择。
  • 预热对大型模型或使用AdamW等自适应优化器时尤其有益。
  • **ReduceLROnPlateau**在进度直接与可衡量的验证指标相关联时可以很有效,但如果指标因学习率之外的原因频繁停滞,它可能反应缓慢。

实验很重要。可视化计划的调度、监控训练/验证损失曲线,以及使用超参数优化工具(本章后面会讨论)对于找到适合您特定任务的最有效调度策略是不可或缺的。请记住,优化器和学习率调度之间的配合很重要,因此它们通常应该一起调整。

正则化方法

虽然优化器和学习率调度器引导训练过程趋向最小值,但正则化技术对于确保所学模型能够很好地泛化到未见过的数据,从而防止过拟合非常重要。基本的 L1 或 L2 权重衰减虽然有用,但有时会显得不足,尤其是在处理复杂架构和大型数据集时。这里将讨论 PyTorch 生态系统中更高级的正则化方法。

解耦权重衰减 (AdamW)

在 Adam 等自适应优化器中,L2 正则化的常见实现通常将权重衰减项与梯度计算耦合。这意味着衰减效果受到优化器计算的自适应学习率的影响(具体来说是梯度平方的历史记录 vtv**t)。这种耦合可能导致次优性能,尤其是在出现大梯度时,因为有效的权重衰减可能比预期小。

AdamW 优化器由 Loshchilov 和 Hutter (2019) 提出,它通过将权重衰减与梯度更新解耦来解决这个问题。AdamW 不会将衰减项添加到梯度中,而是在主要优化步骤之后直接对权重施加衰减。

带有 L2 正则化的标准 Adam 更新如下: gt′=∇f(wt)+λwtg**t′=∇f(w**t)+λwt wt+1=wt−η⋅AdamUpdate(gt′)w**t+1=w**tη⋅AdamUpdate(g**t′)

而 AdamW 执行如下操作: gt=∇f(wt)g**t=∇f(w**t) wt+1′=wt−η⋅AdamUpdate(gt)w**t+1′=w**tη⋅AdamUpdate(g**t) wt+1=wt+1′−ηλwtw**t+1=w**t+1′−ηλw**t

(注意:这里的 ηη 代表可能被 Adam 自适应组件修改过的学习率)。重要的区别在于,衰减项 ηλwtηλw**t 是单独应用的,并且不受 AdamUpdate 中自适应学习率项的缩放。

在 PyTorch 中,使用 AdamW 很简单:

import torch
import torch.optim as optim

// 假设模型已定义
val model = ... // 假设模型已定义

// 使用 AdamW 代替 Adam
val optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2) 

// 训练循环保持不变
// ...
// optimizer.zero_grad()
// loss.backward()
// optimizer.step()
// ...

为 AdamW 选择合适的 weight_decay 值通常需要进行实验,但 1e-21e-1 之间的值是常见的起始值,通常高于标准 Adam 耦合 L2 正则化所用的值。AdamW 已成为训练许多现代架构(尤其是 Transformer)的标准选择。

标签平滑

分类模型通常使用交叉熵损失和独热编码的目标标签(例如 [0, 0, 1, 0])进行训练。这鼓励模型对正确类别产生极高的置信度,而对不正确类别产生非常低的置信度。虽然这促使模型完美拟合训练数据,但它可能导致过度自信和泛化能力差。模型可能对训练数据中的特定特征过于敏感,未能捕捉更广泛的模式。

标签平滑正则化(LSR)通过稍微软化目标标签来解决这个问题。它不是要求模型对正确类别预测为 1、对其他类别预测为 0,而是鼓励模型为不正确的类别分配一个小的概率质量 ϵϵ (epsilon)。

对于一个有 KK 个类别的分类问题,如果样本的原始独热标签是 yky**k (真实类别为 1,否则为 0),则平滑后的标签 yk′y**k′ 变为:

yk′=yk(1−ϵ)+ϵKy**k′=y**k(1−ϵ)+K**ϵ

其中,yky**k 在 kk 是真实类别索引时为 1,否则为 0。项 yk(1−ϵ)y**k(1−ϵ) 将正确类别的目标概率从 1.0 降低到 1−ϵ1−ϵ。项 ϵ/Kϵ/K 将剩余的概率质量 ϵϵ 均匀地分布到所有 KK 个类别中(包括正确类别,尽管其主要作用是增加不正确类别的概率)。

例如,当 K=5K=5 个类别且 ϵ=0.1ϵ=0.1 时,独热标签 [0, 0, 1, 0, 0] 变为: [0.02, 0.02, 0.9, 0.02, 0.02]

这鼓励模型的输出对数(在最终 softmax 之前)对于正确类别而言,相对于不正确类别的对数不那么极端。它通过防止模型变得过度自信来充当正则化器。

PyTorch 的 torch.nn.CrossEntropyLoss 通过 label_smoothing 参数直接支持标签平滑:

import torch
import torch.nn as nn

// 示例用法
val num_classes = 10
val smoothing_factor = 0.1

val criterion = nn.CrossEntropyLoss(label_smoothing=smoothing_factor)

// 在训练循环中
// val outputs = model(inputs) // 形状: [batch_size, num_classes]
// val targets = ... // 形状: [batch_size],包含类别索引
// val loss = criterion(outputs, targets)
// loss.backward()
// optimizer.step()

ϵϵ 的常见值通常很小,通常在 0.05 到 0.1 之间。

随机深度 (DropPath)

Dropout 是一种广泛使用的正则化技术,它在训练期间随机将单个神经元激活设置为零。随机深度,也称为 DropPath,提供了一种不同的方法,在具有残差连接的网络(如 ResNets 或 Transformer)中特别有效。随机深度不是丢弃单个神经元,而是在训练期间随机丢弃整个残差块或层。

考虑一个残差块,其输出为 xl+1=xl+fl(xl)x**l+1=x**l+f**l(x**l)。使用随机深度时,此变换在训练期间进行修改:

xl+1=xl+bl⋅fl(xl)x**l+1=x**l+b**lf**l(x**l)

其中,blb**l 是一个伯努利随机变量,取值可为 0 或 1。它以 1−pl1−p**l 的概率取值 0(丢弃块),以 plp**l 的概率取值 1(保留块)。概率 plp**l 是块 ll生存概率

通常,网络中较深层的生存概率会线性降低。对于一个有 LL 个块的网络,块 ll (其中 ll 的范围从 1 到 LL)的生存概率可以设置为:

pl=1−lL(1−pL)p**l=1−L**l(1−p**L)

其中,pLp**L 是最终块的目标生存概率。这种方案意味着较早的层(更接近输入)更可能被保留,而较深的层更可能被丢弃。

在推理时,所有块都被保留,但它们的输出可能按其各自的生存概率 plp**l 进行缩放,以补偿它们在训练期间出现频率较低的事实。然而,许多实现隐式处理此缩放或认为其没有必要。

随机深度的优点包括:

  1. 正则化: 充当强大的正则化器,通过防止网络过度依赖残差块中的任何单一路径来提高泛化能力。
  2. 减少训练时间: 在前向和后向传播期间跳过块可以减少计算。
  3. 隐式集成: 使用随机深度进行训练可以看作是训练一个由不同深度的网络组成的隐式集成。

实现随机深度通常涉及使用专门的层。像 timm(PyTorch 图像模型)这样的库提供了方便的 DropPath 模块:

// 使用 timm 的 DropPath 示例
// 确保已安装 timm:pip install timm

import timm.models.layers.DropPath

class MyResidualBlock extends nn.Module:
    def __init__(dim: Int, drop_prob: Double = 0.):
        super().__init__()
        val norm1 = nn.LayerNorm(dim)
        val linear1 = nn.Linear(dim, dim * 4)
        val activation = nn.GELU()
        val linear2 = nn.Linear(dim * 4, dim)
        // 随机深度层
        val drop_path = DropPath(drop_prob) if drop_prob > 0. else nn.Identity()

    def forward(self, x: Tensor):
        val shortcut = x
        x = norm1(x)
        x = linear1(x)
        x = activation(x)
        x = linear2(x)
        // 将 DropPath 应用于残差函数的输出
        x = shortcut + self.drop_path(x) 
        return x

// 在大型模型中的使用示例
// drop_probabilities = torch.linspace(0, 0.1, num_layers) // 线性衰减
// block = MyResidualBlock(dim=embed_dim, drop_prob=drop_probabilities(i).item())
import org.bytedeco.pytorch.*;
import org.bytedeco.pytorch.Module;

import static org.bytedeco.pytorch.global.torch.*;

public class Lesson11 {


    static class MyResidualBlock extends Module {
        private LayerNormImpl norm1;
        private LinearImpl linear1;
        private GELUImpl activation;
        private LinearImpl linear2;

        public MyResidualBlock(long dim) {
            // 注册子模块,确保参数能被优化器追踪 new long[]{dim}
            
            this.norm1 = register_module("norm1", new LayerNormImpl(new LongVector(dim)));
            this.linear1 = register_module("linear1", new LinearImpl(dim, dim * 4));
            this.activation = register_module("activation", new GELUImpl());
            this.linear2 = register_module("linear2", new LinearImpl(dim * 4, dim));
        }

        public Tensor forward(Tensor input) {
            // 残差连接(Shortcut)
            try (Tensor shortcut = input.clone();
                 Tensor x1 = norm1.forward(input);
                 Tensor x2 = linear1.forward(x1);
                 Tensor x3 = activation.forward(x2);
                 Tensor x4 = linear2.forward(x3)) {

                // x = shortcut + x
                return shortcut.add(x4);
            }
        }
    }
}

传递给 DropPathdrop_prob 对应于 1−pl1−p**l。选择丢弃概率的范围(例如,从 0 线性增加到 0.1 或 0.2)是另一个需要调整的超参数。

这些高级正则化技术通常结合使用,为提高使用 PyTorch 训练的深度学习模型的稳健性和泛化能力提供了强大的方法。通常需要通过实验为特定任务和架构找到最佳组合和超参数。

梯度裁剪与累积

训练深度神经网络需要应对复杂的优化环境。如本章前述,尽管先进的优化器和学习率调度有助于引导训练过程,但在反向传播过程中常会出现两个特定问题:梯度爆炸和硬件内存限制导致批次大小受限。本节将介绍两种实用技术:梯度裁剪和梯度累积,它们旨在解决这些常见的训练难题,使优化过程更稳定、更高效。

梯度裁剪:驯服梯度爆炸

训练神经网络时,特别是对于循环架构或非常深的神经网络,梯度的量值有时会变得过大。这种现象被称为梯度爆炸,它会使训练不稳定,导致损失函数出现突然的跳跃、数值溢出(产生NaN值),并最终阻碍模型收敛。

梯度裁剪提供了一个直接的解决方案:它对梯度的整体量值施加了一个上限。如果所有模型参数梯度的范数(通常是L2范数)超过了预设的阈值,梯度就会按比例缩小以符合该阈值。这可以在保持梯度向量整体方向的同时,避免模型权重出现极端更新。

数学上,如果 gg 表示所有参数的连接梯度向量,∣∣g∣∣2∣∣g∣∣2 是其L2范数,则按范数进行的梯度裁剪操作如下:

g←{g如果 ∣∣g∣∣2≤max_normg⋅max_norm∣∣g∣∣2如果 ∣∣g∣∣2>max_normg←{g**g⋅∣∣g∣∣2max_norm如果 ∣∣g∣∣2≤max_norm如果 ∣∣g∣∣2>max_norm

在 PyTorch 中,可以使用 torch.nn.utils.clip_grad_norm_ 轻松实现这一点。此函数会计算指定参数梯度的总范数,如果范数超过 max_norm 值,则会就地缩小它们。

以下是如何将其集成到典型的训练循环中:

import torch
import torch.nn as nn

// 假设模型、优化器、数据加载器已定义
val model = nn.Linear(10, 1) // 示例模型
val optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
val data_loader = [(torch.randn(16, 10), torch.randn(16, 1))] // 示例数据

val MAX_GRAD_NORM = 1.0 // 定义裁剪阈值

model.train()
for (inputs, targets) <- data_loader:
    optimizer.zero_grad()

    val outputs = model(inputs)
    val loss = nn.functional.mse_loss(outputs, targets)

    loss.backward() // 计算梯度

    // --- 梯度裁剪 ---
    // 应该在 .backward() 之后、optimizer.step() 之前调用
    val total_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=MAX_GRAD_NORM)
    // 可选:记录 total_norm 以监控梯度量值
    // -------------------------

    optimizer.step() // 更新权重

println(s"训练步骤完成。潜在裁剪前的梯度范数: ${total_norm.item()}")
        // 1. Initialize Model and Optimizer
        var model = new LinearImpl(new LinearOptions(10, 1));
        var optimizer3 = new Adam(model.parameters(), new AdamOptions(1e-3));

        // Mock data
        var inputs = randn(new long[]{16, 10});
        var targets = randn(new long[]{16, 1});

        final double MAX_GRAD_NORM = 1.0;

        model.train(true);

        // Use try-with-resources (PointerScope) to manage native memory in JDK 25
        try (var scope = new org.bytedeco.javacpp.PointerScope()) {

            optimizer3.zero_grad();

            // Forward pass
            Tensor outputs = model.forward(inputs);
            Tensor loss = torch.mse_loss(outputs, targets);

            // Backward pass
            loss.backward();

            // --- Gradient Clipping ---
            // clip_grad_norm_ computes the norm and scales gradients in-place
            double totalNorm = torch.clip_grad_norm_(model.parameters(), MAX_GRAD_NORM);
            // -------------------------

            optimizer.step();

            System.out.printf("Step complete. Norm before clipping: %.4f%n", totalNorm);
        }

选择 max_norm max_norm 的合适值取决于具体问题,通常需要通过实验来确定。1.0 到 5.0 之间的值是常见的起始参考点。在多次迭代中监控函数返回的 total_norm(在应用裁剪之前),可以帮助您了解模型的典型梯度尺度并设置一个合理的阈值。

尽管 torch.nn.utils.clip_grad_value_ 也存在,但通常更推荐按范数进行裁剪,因为它会按比例重新缩放整个梯度向量,保持其原有方向,这通常被认为比裁剪单个梯度分量更有利于优化。

梯度累积:模拟更大批次

更大的批次大小通常会带来更稳定的梯度估计,有时还能提高收敛速度和最终模型性能。然而,将大批次数据载入GPU内存是常见的瓶颈。例如,训练一个大型 Transformer 模型可能需要远超高端加速器内存容量的批次大小。

梯度累积提供了一种有效的替代方案。您可以不处理一个大的批次并执行一次优化器步骤,而是顺序处理多个较小的“微批次”,在进行一次优化器更新之前累积它们的梯度。这模拟了更大批次大小的效果,同时避免了过高的内存成本。

核心思路是延迟调用 optimizer.step()optimizer.zero_grad()。您对多个微批次执行前向和反向传播,使得每次 .backward() 调用中计算出的梯度能够累加到参数的 .grad 属性中。

重要细节: 为确保最终累积的梯度正确表示有效批次上的平均梯度,您应在调用 backward() 之前,将每个微批次的损失除以累积步数进行归一化。

以下是如何实现梯度累积:

import torch
import torch.nn as nn

// 假设模型、优化器、数据加载器已定义
val model = nn.Linear(10, 1) // 示例模型
val optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
// 假设 data_loader 提供大小为 MICRO_BATCH_SIZE 的微批次
val MICRO_BATCH_SIZE = 16
val data_loader = [(torch.randn(MICRO_BATCH_SIZE, 10), torch.randn(MICRO_BATCH_SIZE, 1)) for _ in range(10)] // 示例数据

val ACCUMULATION_STEPS = 4 // 梯度累积的步数
val EFFECTIVE_BATCH_SIZE = MICRO_BATCH_SIZE * ACCUMULATION_STEPS

println(s"微批次大小: $MICRO_BATCH_SIZE")
println(s"累积步数: $ACCUMULATION_STEPS")
println(s"有效批次大小: $EFFECTIVE_BATCH_SIZE")

model.train()
optimizer.zero_grad() # 在循环前将梯度初始化为零

for (i, (inputs, targets)) <- data_loader.zipWithIndex:
    val outputs = model(inputs)
    val loss = nn.functional.mse_loss(outputs, targets)

    // --- 累积前归一化损失 ---
    // 将损失按累积步数进行缩放
    val normalized_loss = loss / ACCUMULATION_STEPS
    // --------------------------------------

    normalized_loss.backward() // 累积梯度

    // --- 累积后执行优化器步骤 ---
    if (i + 1) % ACCUMULATION_STEPS == 0 then 
        // 可选:在累积*之后*应用梯度裁剪
        // torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=MAX_GRAD_NORM)

        optimizer.step() // 根据累积的梯度更新权重
        optimizer.zero_grad() // 为下一个累积周期重置梯度
        println(s"步骤 ${i+1}: 优化器步骤已执行 (有效批次 ${(i + 1) // ACCUMULATION_STEPS})")
    // ----------------------------------------------

// 处理数据集大小不能被完美整除时可能存在的剩余梯度
if (data_loader.length % ACCUMULATION_STEPS != 0):
     optimizer.step()
     optimizer.zero_grad()
     print("对剩余批次执行最终优化器步骤。")

public void trainingLoop() {
        LinearImpl model = new LinearImpl(10, 1);
        Adam optimizer = new Adam(model.parameters(), new AdamOptions(1e-3));

        int MICRO_BATCH_SIZE = 16;
        int ACCUMULATION_STEPS = 4;
        double MAX_GRAD_NORM = 1.0;

        model.train(true);
        optimizer.zero_grad();

        var tensorOptions = new TensorOptions()
                .dtype(new ScalarTypeOptional(ScalarType.Float));
        // 模拟数据循环
        for (int i = 0; i < 40; i++) {
            Tensor inputs = randn(new long[]{MICRO_BATCH_SIZE, 10}, tensorOptions);
            Tensor targets = randn(new long[]{MICRO_BATCH_SIZE, 1}, tensorOptions);

            Tensor outputs = model.forward(inputs);
            Tensor loss = mse_loss(outputs, targets);

            // 1. 归一化损失并反向传播
            Tensor normalizedLoss = loss.div(new Scalar(ACCUMULATION_STEPS));
            normalizedLoss.backward();

            // 2. 累积到指定步数后执行优化
            if ((i + 1) % ACCUMULATION_STEPS == 0) {
                // 梯度裁剪:防止梯度爆炸
                torch.clip_grad_norm_(model.parameters(), MAX_GRAD_NORM);

                optimizer.step();
                optimizer.zero_grad();
                System.out.println("Step " + (i + 1) + ": Optimizer update executed.");
            }

            // 资源清理
            inputs.close(); targets.close(); loss.close();
        }

在此示例中,优化器每 ACCUMULATION_STEPS 次迭代才更新一次权重。有效批次大小变为 MICRO_BATCH_SIZE * ACCUMULATION_STEPS

注意事项:

  • 训练时间: 梯度累积会增加每个周期的实际运行时间,因为微批次是顺序处理的,而不是像真正的大批次那样并行处理。然而,它使得原本因内存限制而无法进行的训练成为可能。
  • 批归一化: 标准的批归一化层会根据当前的微批次计算统计数据。当使用梯度累积时,这些统计数据可能会比使用真正大批次时更嘈杂。尽管 PyTorch 的 BatchNorm 层通常在推断时通过使用运行统计数据来妥善处理此问题,但仍需注意对训练动态的潜在影响,尤其是在微批次大小非常小的情况下。替代方案,如层归一化或组归一化,不受批次大小影响。
  • 分布式训练: 当梯度累积与分布式训练(第 5 章介绍)一同使用时,请确保同步正确发生,通常在 optimizer.step() 之前。

结合裁剪与累积

梯度裁剪和累积并非互斥;它们常被同时使用。如果同时使用这两种方法,梯度裁剪步骤应在有效批次的所有梯度累积之后,但在 optimizer.step() 调用之前进行,如梯度累积代码示例中被注释掉的行所示。

通过有策略地应用梯度裁剪和累积,您可以对训练过程获得更精细的控制,使得即使是面对有挑战性的模型也能实现稳定的优化,并克服硬件内存限制,有效使用更大的批次大小。这些技术是您优化复杂深度学习模型的宝贵补充。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐