qinghuan
← Transformer 与语言建模 / 全部笔记

单元 23 / Transformer 与语言建模

P1 · 重点提升L2 · 进阶

学习率预热、调度与梯度裁剪

明确学习率每一步怎样变化、梯度过大时怎样缩小,分别检查更新步数、裁剪前梯度长度和实际参数变化。

本篇目录 · 按需要跳转

读完这篇,你能做什么

核对预热峰值与最终学习率,并验证所有参数梯度按同一系数缩放、非有限梯度被发现。

学习理由、准备要求与知识点

为什么学它

学习率调度早一步或晚一步,把全局裁剪误写成逐参数裁剪,都会改变算法,单看损失曲线很难发现。

先读这些内容

知识点清单

  • 预热直觉与范围
  • 更新编号和epoch编号
  • 余弦衰减端点
  • 全局L2梯度范数
  • 裁剪前后范数
  • 方向保持与逐值裁剪区别
  • 非有限梯度诊断
  • 调度器断点与调用顺序

运行前先确认PyTorch 2.9.1+cpu、Python 3.14、Java 17。使用显式更新编号的预热余弦计划与CPU全局范数裁剪实验;不提供通用最佳超参数。

01先弄懂它是什么

训练刚开始时,参数每次改多大,往往需要控制。学习率 eta 决定使用梯度的步长尺度;对于 AdamW 这类自适应优化器,实际改动还受动量、二阶矩和权重衰减影响。预热就是先用较小学习率,再逐渐升到目标值,常用于训练初期状态还不稳定的时候。它不能保证一定收敛;预热多久,要结合总更新数、批量、初始化和数据来定。

学习率按什么进度变化,也必须说清楚。每轮数据结束才调整一次,与每次 optimizer.step 后调整一次,得到的曲线不同;使用梯度累积时,批次数也多于更新次数。本篇把 u 定义为计划边界编号,范围从 0 到 N,预热结束点为 W;在 u=W 达到峰值,在 u=N 达到下限。每次真正更新参数前使用对应计划值,并打印或检查编号,避免被默认的起始偏移弄错一步。

梯度裁剪处理的是过大的梯度向量。全局L2范数把所有参数梯度想象成一个长向量g,若范数大于阈值c,就统一乘c/||g||。这个操作保持向量方向,但不能保证任意优化器的参数更新范数也不超过c。逐元素限制到区间则会改变方向,不能当成同一种裁剪。

文中这几个词,先认一认

Warmup
预热:训练开始后逐渐提高学习率,需明确升到多高、到第几步结束。
全局梯度范数
先把一次更新要用到的所有参数梯度接成一条长向量,再衡量它整体有多大。本篇用 L2 范数,也就是每项平方后相加再开平方,并对整体使用同一个裁剪阈值。
非有限梯度
梯度中出现 NaN 或无穷值。此时应查明数值问题,或按预先规定的规则跳过这次更新。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

预热阶段eta(u)=eta_max*u/W;之后使用eta_min+(eta_max-eta_min)*(1+cos(pi*(u-W)/(N-W)))/2。W必须小于N;若W为零则不进入预热分支。实现应测试u=0、W、N和越界输入,尤其不要因为分母N-W为零让整个训练计划生成NaN。

梯度裁剪应放在反向完成后、参数更新前。若一次更新要累积多个微批,先收齐这些梯度再裁剪;每个微批分别裁剪后相加,通常不等于先相加再裁剪。使用混合精度的 loss scaling 时,还要先把梯度还原到原来的尺度,再检查大小并裁剪,后文会专门验证这个顺序。

PyTorch clip_grad_norm_返回裁剪前总范数并原地修改梯度。记录该值可以判断阈值是否频繁生效,但不能把裁剪后有限值当成原始训练稳定的证明。设置error_if_nonfinite可以让异常显式暴露;若运行策略选择跳过更新,调度器是否前进应和实际成功更新保持一致,并写进实验记录。

先跑通一个最小例子

CPU上检查显式预热余弦计划、跨参数全局裁剪及非有限值处理。

experiment.py · python
import math
import torch
from torch import nn
torch.manual_seed(23)
torch.set_num_threads(1)
def rate(u, total=6, warmup=2, peak=1., floor=.1):
    if not (0 <= warmup < total and 0 <= u <= total):
        raise ValueError("schedule")
    if warmup and u <= warmup:
        return peak * u / warmup
    return floor + (peak-floor) * (1+math.cos(math.pi*(u-warmup)/(total-warmup))) / 2
assert rate(0) == 0 and rate(2) == 1
assert abs(rate(6)-.1) < 1e-12
assert all(rate(i) >= rate(i+1) for i in range(2, 6))
a, b = nn.Parameter(torch.tensor([0.])), nn.Parameter(torch.tensor([0.]))
a.grad, b.grad = torch.tensor([3.]), torch.tensor([4.])
before = nn.utils.clip_grad_norm_([a, b], 2., error_if_nonfinite=True)
assert abs(before.item()-5.) < 1e-6  # 返回裁剪前全局范数
assert torch.allclose(a.grad, torch.tensor([1.2]), atol=1e-6)
assert torch.allclose(b.grad, torch.tensor([1.6]), atol=1e-6)
a.grad, b.grad = torch.zeros(1), torch.zeros(1)
assert nn.utils.clip_grad_norm_([a, b], 2.).item() == 0
a.grad = torch.tensor([float("inf")])
failed = False
try:
    nn.utils.clip_grad_norm_([a], 2., error_if_nonfinite=True)
except RuntimeError:
    failed = True
assert failed  # 不能把无穷梯度当作普通大梯度掩盖
print("8 schedule and clipping checks passed")

核对运行结果预热峰值和衰减终点准确;两个参数共用一个裁剪比例,零梯度保持有限,Inf触发错误。

正在加载流程图…

裁剪与计划围绕实际参数更新组织,不能随微批次数随意推进。
查看流程图源码(Mermaid)
flowchart LR
A["按更新编号计算学习率"] --> B["前向和反向"]
B --> C["完成累积并还原缩放"]
C --> D["检查全局梯度范数"]
D --> E["统一比例裁剪"]
E --> F["优化器更新与记录"]

按顺序看,每一步做了什么

  1. 定义N、W、峰值和下限及编号约定。
  2. 验证预热和衰减端点,没有零分母。
  3. 用3、4组成的梯度验证全局范数5。
  4. 记录裁剪前范数与实际更新,并保存调度状态。

03对照实现代码

clip_grad_norm_先求总范数再统一修改梯度 (新标签页)↗

PyTorch v2.9.1 · clip_grad_norm_;_get_total_norm;_clip_grads_with_norm_

原创L2裁剪参考函数,不复现foreach、稀疏梯度或设备分组;其epsilon与真实实现保持相同作用。

核心机制代码 · python
import math
def clip_reference(gradients, maximum):
    norm = math.sqrt(sum(x*x for row in gradients for x in row))
    if not math.isfinite(norm):
        raise ValueError("nonfinite gradient")  # 异常数值单独处理
    coefficient = min(1.0, maximum / (norm + 1e-6))
    clipped = [[x * coefficient for x in row] for row in gradients]
    return norm, clipped  # 返回旧范数,全部参数使用同一缩放系数
# 此函数返回副本;PyTorch真实接口原地修改Parameter.grad

真实函数先收集非None的梯度,计算整体范数,然后调用统一裁剪过程,不是每个参数各自限制到max_norm。

返回值是裁剪前总范数;下划线接口表明原地修改,日志需正确标注。

内部用带小epsilon的比例并将其上限截为一,因此不会放大小梯度。

foreach和设备分组影响执行路径,但不改变本篇讨论的全局范数契约;分布式分片还需相应全局实现。

代码先调用谁,再调用谁

  1. 收集Parameter.grad
  2. 计算总范数
  3. 检查非有限值
  4. 生成不大于一的系数
  5. 原地缩放梯度

04动手写一个例子

预热余弦计划与稳定全局L2裁剪

根据边界编号生成学习率,并对一组有限梯度按全局范数裁剪。Java参考使用精确阈值比例,不加PyTorch的1e-6,因此比较时允许微小实现差异。

输入 1
N=6,W=2,峰值1,下限0.1
输出 1
u0=0,u2=1,u6=0.1
输入 2
g=[3,4],阈值2
输出 2
原范数5,输出[1.2,1.6]

分段函数先处理预热,再处理余弦区间,明确端点与无预热情况。

使用Math.hypot逐项合成范数,比直接平方求和更能避免中间平方溢出。

阈值比例不大于一且所有分量相同,因此保持方向;无法表示的总范数仍需拒绝。

为什么这样写是对的裁剪后范数不超过阈值的浮点容差范围,原范数较小时向量不变。

时间 计划O(1),裁剪O(n)额外空间 O(n)输出

梯度两次扫描分别计算范数和输出,计划只需常数公式运算。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

Main.java · 完整可运行程序 · Java
public class Main {
  static double rate(int u,int total,int warm,double peak,double floor){
    if(total<=0||warm<0||warm>=total||u<0||u>total||!Double.isFinite(peak)||
       !Double.isFinite(floor)||floor<0||peak<floor)throw new IllegalArgumentException();
    if(warm>0&&u<=warm)return peak*u/warm; // 预热端点达到峰值
    return floor+(peak-floor)*(1+Math.cos(Math.PI*(u-warm)/(total-warm)))/2;
  }
  static double[] clip(double[] gradient,double cap){
    if(!Double.isFinite(cap)||cap<0)throw new IllegalArgumentException();
    double norm=0;
    for(double x:gradient){
      if(!Double.isFinite(x))throw new IllegalArgumentException();
      norm=Math.hypot(norm,x); // 避免直接平方的大中间值
    }
    if(!Double.isFinite(norm))throw new ArithmeticException("norm range");
    double coefficient=norm==0?1:Math.min(1,cap/norm);
    double[] out=new double[gradient.length];
    for(int i=0;i<out.length;i++)out[i]=gradient[i]*coefficient; // 全向量统一比例
    return out;
  }
  static void close(double a,double b){if(Math.abs(a-b)>1e-9)throw new AssertionError();}
  public static void main(String[] args){
    close(rate(0,6,2,1,.1),0);close(rate(2,6,2,1,.1),1);close(rate(6,6,2,1,.1),.1);
    double[] c=clip(new double[]{3,4},2);close(c[0],1.2);close(c[1],1.6);
    close(clip(new double[]{3,4},10)[0],3);
    close(clip(new double[]{0,0},0)[0],0);
    close(rate(0,6,0,1,.1),1);
    boolean bad=false;try{rate(1,2,2,1,0);}catch(IllegalArgumentException e){bad=true;}
    if(!bad)throw new AssertionError();
    System.out.println("8 schedule clipping checks passed");
  }
}

这些特殊情况也要试一试

  • 预热长度等于总长度在本题拒绝,避免未定义衰减区间。
  • 零梯度无需缩放。
  • 阈值为零会把非零有限梯度归零,并不等于停止权重衰减等所有优化器变化。
进阶选读 · 生产拓展:按有效token预算调度

场景序列长度与并行规模变化后,相同步数对应的训练信息量不同。

做法以累计有效token为调度自变量,更新时记录全局有效计数;恢复同时加载计数和调度状态,比较固定token预算下的结果。

复杂度计数更新本地O(1),分布式需要同步计数,通信成本另计。

好处与代价token口径更可比,但数据重复、掩码和采样策略仍会影响有效信息。

05项目里怎么用

阈值过低会让多数更新都被压缩,训练可能变慢;阈值过高几乎不起作用。先记录裁剪触发比例和原范数分布,再在固定预算下调整,不能只看最终一个损失数字。

调度器通常需与optimizer.step的顺序和调用频率配合,框架初始化还可能计算一次起始值。接入LambdaLR等工具时先打印前几个更新实际使用的学习率,保存状态,并用恢复对照检验无偏移。

遇到NaN先找首个非有限张量,检查输入、损失、指数和除法等数值操作。裁剪是控制有限大梯度的工具,不是把损坏计算变成正确训练的修复器。

容易踩的坑

  • 每个参数分别裁剪却声称全局裁剪。
  • 记录返回范数时误标为裁剪后。
  • 按微批推进按更新设计的调度。
  • 在缩放梯度上直接使用未缩放阈值。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
固定学习率易解释与复现不同阶段可能不合适基线和小实验
预热加衰减可平滑早期并降低后期步长需要预算与端点约定固定训练预算
全局范数裁剪统一限制梯度尺度并保方向过强会抑制更新偶发大梯度场景

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01全局范数裁剪保持什么?

展开参考答案与推导

非零向量方向保持,所有分量乘同一非负系数。

02裁剪阈值是否限制Adam参数更新范数?

展开参考答案与推导

不直接限制,Adam还通过历史矩和衰减改变更新。

03clip_grad_norm_返回什么?

展开参考答案与推导

裁剪前总梯度范数,并原地修改梯度。

换个情况,还会处理吗

01累积四微批,每批都推进调度会怎样?

展开参考答案与推导

若计划按更新数设计,学习率将提前四倍推进,需要按实际更新计数。

02梯度为Inf,应该只裁剪吗?

展开参考答案与推导

不应视为普通大梯度,需显式检测、按策略跳过并定位来源。

03恢复后学习率错一格,怎么核对?

展开参考答案与推导

检查保存的调度状态、初始化行为和step调用前后顺序。

写代码试一试

01[3,4]裁剪到范数2结果是什么?

输入 / 条件
全局L2
输出 / 预期
[1.2,1.6]

提示 原范数5。

展开参考答案与推导

统一乘2/5。

02预热2步峰值1,边界u=1学习率?

输入 / 条件
线性预热
输出 / 预期
0.5

提示 u/W。

展开参考答案与推导

1乘1/2。

03梯度为零时如何避免0除0?

输入 / 条件
norm=0
输出 / 预期
保持零向量

提示 单独分支或安全分母。

展开参考答案与推导

零向量已满足任何非负阈值,不需计算cap/norm。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

核对预热峰值与最终学习率,并验证所有参数梯度按同一系数缩放、非有限梯度被发现。

本篇重点加练

全局裁剪与调度恢复都依赖操作顺序,重点训练多步组合中容易静默变化的语义。

  • 全局L2梯度范数
  • 调度器断点与调用顺序

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    总共 12 次优化器更新、预热 3 次,指定峰值与末值后写出完整学习率表;明确更新前后调用调度器的约定。

    提交内容
    学习率表与调用时序
    怎样算完成
    • 端点包含规则明确
    • 编号从何处开始可核对

    练到的知识点预热直觉与范围 · 更新编号和epoch编号 · 余弦衰减端点 · 调度器断点与调用顺序

  2. 作业 02原理问答

    对两组参数梯度 g1=[1,2]、g2=[2,4] 比较全局范数裁剪与逐元素裁剪,讨论各自改变哪些几何性质。

    提交内容
    手算及向量草图
    怎样算完成
    • 全局集合范围明确
    • 方向和幅度分别分析

    练到的知识点全局L2梯度范数 · 裁剪前后范数 · 方向保持与逐值裁剪区别

  3. 作业 03原理问答

    两个微批分别给梯度 g1=(3,0)、g2=(0,4)。自行设阈值,比较每个微批先裁剪再累加与累加后裁剪,给出两种训练目标的差别。

    提交内容
    操作顺序图与计算
    怎样算完成
    • 同一阈值口径明确
    • 操作顺序不被省略

    练到的知识点全局L2梯度范数

综合应用

  1. 作业 04编程实践

    实现 CPU 梯度监测与裁剪组件,覆盖零梯度、缺失梯度、极大有限值及非有限输入;记录裁剪前后统计与拒绝原因。

    提交内容
    组件及边界测试
    怎样算完成
    • 异常处理契约明确
    • 监测不会静默掩盖错误

    练到的知识点全局L2梯度范数 · 裁剪前后范数 · 非有限梯度诊断

  2. 作业 05排错实验

    训练每三个微批更新一次,在第七次更新恢复断点;故意按微批推进调度并与正确更新计数日志比较。

    提交内容
    两份运行日志与恢复测试
    怎样算完成
    • 优化器更新身份可追踪
    • 恢复前后序列连续

    练到的知识点更新编号和epoch编号 · 调度器断点与调用顺序

  3. 作业 06面试场景

    训练中梯度范数很小但参数更新突然很大。设计同时记录优化器状态、学习率和实际参数差值的诊断实验。

    提交内容
    诊断脚本与报告
    怎样算完成
    • 梯度与更新不混为一谈
    • 异常前后的状态保存

    练到的知识点裁剪前后范数 · 方向保持与逐值裁剪区别 · 非有限梯度诊断

  4. 作业 07编程实践

    CPU 小模型分为两个参数组,写日志核对全局裁剪集合与调度器状态;在重排参数组后恢复断点并检测映射是否兼容。

    提交内容
    审计程序与恢复实验
    怎样算完成
    • 组身份不只依靠位置
    • 裁剪和学习率作用范围完整

    练到的知识点全局L2梯度范数 · 调度器断点与调用顺序

  5. 作业 08设计评审

    训练计划中途延长总更新次数,原来采用余弦衰减。设计“延续原计划”与“重新规划”两种调度规则,说明历史断点包含哪些必要信息。

    提交内容
    两种计划与断点 schema
    怎样算完成
    • 历史与新计划边界清楚
    • 不宣称两方案自然等价

    练到的知识点调度器断点与调用顺序

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    固定计算预算比较两种预热长度与两种裁剪阈值,至少运行三个种子;在运行前指定稳定性和最终质量指标。

    提交内容
    四条件实验与结论
    怎样算完成
    • 比较预算相等
    • 不只挑选最佳种子

    练到的知识点预热直觉与范围 · 余弦衰减端点 · 全局L2梯度范数

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    如果一个参数组被冻结后又解冻,是否能无条件沿用原来的全局裁剪和学习率时间轴?自行构造小训练过程,研究不同约定对应的更新差异。

    提交内容
    条件实验与约定比较
    怎样算完成
    • 冻结期间行为明确
    • 观察结论限定给定约定

    练到的知识点全局L2梯度范数 · 调度器断点与调用顺序

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗