近期要用GPT2手搭一个项目,今天手撕了GPT2源码,解决了一些困惑的地方,总结一下!
梯度累积和梯度裁剪是深度学习中常用的两种技术,用于优化训练过程。以下结合代码详细分析其原理及利弊:


一、梯度累积(Gradient Accumulation)

原理
  1. 目标:在显存有限的情况下,通过多次小批量计算梯度并累加,模拟大批量训练的效果。

  2. 代码逻辑

    if args.gradient_accumulation_steps > 1:
        loss = loss / args.gradient_accumulation_steps  # 平均损失
    loss.backward()  # 梯度累积
    
    • 小批量拆分:将一个大批量(batch)拆分为多个小批量(micro-batch)。
    • 梯度累加:每个 micro-batch 计算梯度后,梯度会累积到模型参数中(不立即更新参数)。
    • 归一化损失:将损失除以累积步数,确保每个 micro-batch 的梯度量级与大 batch 一致。
    • 参数更新:累积足够步数后,调用 optimizer.step() 更新参数,并清空梯度。
  3. 数学意义
    假设累积步数为 NNN,每个 micro-batch 的梯度为∇Li\nabla L_iLi,则总梯度为:
    ∇L=∑i=1N∇LiN×N=∑i=1N∇Li \nabla L = \sum_{i=1}^N \frac{\nabla L_i}{N} \times N = \sum_{i=1}^N \nabla L_i L=i=1NNLi×N=i=1NLi
    等效于直接计算大 batch 的梯度。

利弊
  • 优点
    • 节省显存:允许在显存不足时使用更大的等效批量。
    • 训练稳定:大批量通常能提供更准确的梯度方向。
  • 缺点
    • 更新延迟:参数更新频率降低,可能影响收敛速度。
    • 超参数敏感:需根据有效批量调整学习率(例如,线性缩放规则)。

二、梯度裁剪(Gradient Clipping)

原理
  1. 目标:防止梯度爆炸,避免参数更新过大导致训练不稳定。
  2. 代码逻辑
    torch.nn.utils.clip_grad_norm_(model.parameters(), args.max_grad_norm)
    
    • 计算范数:计算所有参数的梯度张量的 L2 范数。
    • 缩放梯度:若范数超过阈值 max_grad_norm,则按比例缩放梯度,使其范数等于阈值。
利弊
  • 优点
    • 稳定训练:防止梯度爆炸,尤其对 RNN、Transformer 等结构有效。
    • 通用性:几乎不增加计算成本,适用于大多数模型。
  • 缺点
    • 阈值敏感:过小的阈值会抑制梯度,导致收敛缓慢;过大的阈值可能无法防止爆炸。
    • 信息损失:极端情况下可能破坏梯度方向。

三、代码潜在问题分析

  1. 梯度裁剪位置错误
    当前代码在每个 micro-batch 反向传播后立即裁剪梯度,导致多次裁剪。正确做法应是累积完所有梯度后裁剪一次。例如:

    for micro_batch in accumulated_steps:
        loss.backward()  # 梯度累加
    clip_grad_norm_(model.parameters(), max_grad_norm)  # 统一裁剪
    optimizer.step()
    
    • 错误影响:每个 micro-batch 的梯度被单独裁剪,累加后的总梯度可能过小或方向扭曲。
  2. 梯度归一化
    loss = loss / gradient_accumulation_steps 是正确的,确保梯度量级与大批量一致。


总结

  • 梯度累积:牺牲更新频率换取显存节省,需合理调整学习率。
  • 梯度裁剪:简单有效防止梯度爆炸,但需谨慎选择阈值。
  • 代码改进:将梯度裁剪移至所有梯度累积完成后执行,避免多次裁剪。

四、关于梯度累积两个问题的详细解释

以下代码中的梯度累计,假如累积步数设置为4,下面的loss是累积了4步的总loss吗,为什么还要除以4。还有一个问题是1个batch的损失会不会重复使用用,下一个batch计算损失之后,会把这个batch的损失与前面3个batch的损失一起计算这个loss,还是要等着后面的3个batch计算完损失,再一起构成这个loss呢?

if args.gradient_accumulation_steps > 1:
loss = loss / args.gradient_accumulation_steps  # 平均损失
loss.backward()  # 

梯度累积以下是两个问题的解答


问题1:累积步数为4时,为什么要将 loss 除以4?
核心原理
  • 梯度累积的本质:在多次小批量(micro-batch)计算中累加梯度,而不是累加损失值。
  • PyTorch的梯度行为:默认情况下,loss.backward() 会将梯度累加到参数的 .grad 属性中,而不是覆盖。
  • 归一化的目的:通过 loss = loss / 4,确保每个 micro-batch 的梯度量级与“等效大批量”一致。
数学推导

假设:

  • 单个 micro-batch 的损失为 LiL_iLi,梯度为 ∇Li\nabla L_iLi
  • 累积步数 N=4N=4N=4,等效大批量的总损失为 L=∑i=14LiL = \sum_{i=1}^4 L_iL=i=14Li

直接大批量训练的梯度
∇L=∑i=14∇Li \nabla L = \sum_{i=1}^4 \nabla L_i L=i=14Li

梯度累积时的梯度

  • 若每个 micro-batch 的损失为 Li/4L_i/4Li/4,则梯度为 ∇(Li/4)=∇Li/4\nabla (L_i/4) = \nabla L_i /4(Li/4)=Li/4
  • 累积4步后的总梯度:
    ∑i=14∇(Li/4)=∑i=14∇Li4=14∑i=14∇Li \sum_{i=1}^4 \nabla (L_i/4) = \sum_{i=1}^4 \frac{\nabla L_i}{4} = \frac{1}{4} \sum_{i=1}^4 \nabla L_i i=14(Li/4)=i=144Li=41i=14Li
  • 参数更新量公式:
    参数更新量=η×14∑∇Li \text{参数更新量} = \eta \times \frac{1}{4} \sum \nabla L_i 参数更新量=η×41Li
  • 这等效于直接使用大批量时的梯度 ( \sum \nabla L_i ),但学习率需要放大4倍(即线性缩放规则)。
关键结论
  • 除以4:通过归一化损失,使得梯度累积后的总梯度与大批量训练的梯度量级一致。
  • 学习率调整:若使用梯度累积,通常需要将学习率乘以累积步数(例如,原学习率 η→η×4\eta \rightarrow \eta \times 4ηη×4)。

问题2:每个 batch 的损失是否会重复使用?
梯度累积的流程

假设累积步数 N=4N=4N=4,一个完整的训练流程如下:

optimizer.zero_grad()  # 清空梯度
for i in range(4):     # 累积4个 micro-batch
    inputs, labels = next_batch()
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    loss = loss / 4    # 归一化损失
    loss.backward()    # 梯度累积到参数的.grad中
optimizer.step()       # 参数更新(等效大批量)
optimizer.zero_grad()  # 清空梯度,准备下一轮累积
关键点
  1. Micro-batch 独立性

    • 每个 micro-batch 的损失独立计算,梯度独立累积。
    • 不会重复使用之前的损失值,每个 micro-batch 的数据是独立的(例如,从数据集中顺序取4个不同的子批量)。
  2. 梯度累加方式

    • 每次 loss.backward() 将当前 micro-batch 的梯度 累加 到参数的 .grad 中。
    • 参数更新(optimizer.step())仅在累积4步后执行一次。
  3. 数据流示例

    • Batch 1: 计算 loss1 → 梯度1/4 → 累积梯度 = 梯度1/4
    • Batch 2: 计算 loss2 → 梯度2/4 → 累积梯度 = 梯度1/4 + 梯度2/4
    • Batch 3: 计算 loss3 → 梯度3/4 → 累积梯度 = 梯度1/4 + 梯度2/4 + 梯度3/4
    • Batch 4: 计算 loss4 → 梯度4/4 → 累积梯度 = 梯度1/4 + 梯度2/4 + 梯度3/4 + 梯度4/4
    • 最终参数更新量 = 学习率 × (梯度1 + 梯度2 + 梯度3 + 梯度4)/4
结论
  • 不重复使用损失:每个 micro-batch 的损失仅用于计算当前梯度,不会被重复累加。
  • 同步性:必须等待4个 micro-batch 全部计算完成,才能执行参数更新。

五、潜在问题与改进

代码中的隐患
  • 梯度裁剪位置:若在每个 micro-batch 反向传播后立即裁剪梯度(如原代码所示),会导致多次裁剪,破坏梯度方向。正确做法应在累积完所有梯度后裁剪一次:
    # 错误:每个 micro-batch 裁剪一次
    for i in range(4):
        loss.backward()
        clip_grad_norm_()  # 错误!
    
    # 正确:累积完所有梯度后裁剪一次
    for i in range(4):
        loss.backward()
    clip_grad_norm_()      # 正确
    optimizer.step()
    
梯度累积的优缺点
  • 优点:允许在显存不足时模拟大批量训练,提升训练稳定性。
  • 缺点
    • 参数更新频率降低,可能影响收敛速度。
    • 需根据累积步数调整学习率(如线性缩放规则:学习率 × 累积步数)。

总结

  1. 为什么除以4:通过归一化梯度量级,使累积后的梯度与大批量训练等效。
  2. 数据独立性:每个 micro-batch 的损失独立计算,梯度累加但不重复使用。
  3. 实现要点:梯度裁剪应在所有梯度累积完成后执行,避免多次裁剪。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐