读完这篇,你能做什么
核对预热峰值与最终学习率,并验证所有参数梯度按同一系数缩放、非有限梯度被发现。
学习理由、准备要求与知识点
为什么学它
学习率调度早一步或晚一步,把全局裁剪误写成逐参数裁剪,都会改变算法,单看损失曲线很难发现。
先读这些内容
知识点清单
- 预热直觉与范围
- 更新编号和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上检查显式预热余弦计划、跨参数全局裁剪及非有限值处理。
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["优化器更新与记录"]按顺序看,每一步做了什么
- 定义N、W、峰值和下限及编号约定。
- 验证预热和衰减端点,没有零分母。
- 用3、4组成的梯度验证全局范数5。
- 记录裁剪前范数与实际更新,并保存调度状态。
03对照实现代码
PyTorch v2.9.1 · clip_grad_norm_;_get_total_norm;_clip_grads_with_norm_
原创L2裁剪参考函数,不复现foreach、稀疏梯度或设备分组;其epsilon与真实实现保持相同作用。
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和设备分组影响执行路径,但不改变本篇讨论的全局范数契约;分布式分片还需相应全局实现。
代码先调用谁,再调用谁
- 收集
Parameter.grad - 计算总范数
- 检查非有限值
- 生成不大于一的系数
- 原地缩放梯度
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逐项合成范数,比直接平方求和更能避免中间平方溢出。
阈值比例不大于一且所有分量相同,因此保持方向;无法表示的总范数仍需拒绝。
为什么这样写是对的裁剪后范数不超过阈值的浮点容差范围,原范数较小时向量不变。
梯度两次扫描分别计算范数和输出,计划只需常数公式运算。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
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梯度范数
- 调度器断点与调用顺序
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
总共 12 次优化器更新、预热 3 次,指定峰值与末值后写出完整学习率表;明确更新前后调用调度器的约定。
- 提交内容
- 学习率表与调用时序
- 怎样算完成
- 端点包含规则明确
- 编号从何处开始可核对
练到的知识点预热直觉与范围 · 更新编号和epoch编号 · 余弦衰减端点 · 调度器断点与调用顺序
- 作业 02原理问答
对两组参数梯度 g1=[1,2]、g2=[2,4] 比较全局范数裁剪与逐元素裁剪,讨论各自改变哪些几何性质。
- 提交内容
- 手算及向量草图
- 怎样算完成
- 全局集合范围明确
- 方向和幅度分别分析
练到的知识点全局L2梯度范数 · 裁剪前后范数 · 方向保持与逐值裁剪区别
- 作业 03原理问答
两个微批分别给梯度 g1=(3,0)、g2=(0,4)。自行设阈值,比较每个微批先裁剪再累加与累加后裁剪,给出两种训练目标的差别。
- 提交内容
- 操作顺序图与计算
- 怎样算完成
- 同一阈值口径明确
- 操作顺序不被省略
练到的知识点全局L2梯度范数
综合应用
- 作业 04编程实践
实现 CPU 梯度监测与裁剪组件,覆盖零梯度、缺失梯度、极大有限值及非有限输入;记录裁剪前后统计与拒绝原因。
- 提交内容
- 组件及边界测试
- 怎样算完成
- 异常处理契约明确
- 监测不会静默掩盖错误
练到的知识点全局L2梯度范数 · 裁剪前后范数 · 非有限梯度诊断
- 作业 05排错实验
训练每三个微批更新一次,在第七次更新恢复断点;故意按微批推进调度并与正确更新计数日志比较。
- 提交内容
- 两份运行日志与恢复测试
- 怎样算完成
- 优化器更新身份可追踪
- 恢复前后序列连续
练到的知识点更新编号和epoch编号 · 调度器断点与调用顺序
- 作业 06面试场景
训练中梯度范数很小但参数更新突然很大。设计同时记录优化器状态、学习率和实际参数差值的诊断实验。
- 提交内容
- 诊断脚本与报告
- 怎样算完成
- 梯度与更新不混为一谈
- 异常前后的状态保存
练到的知识点裁剪前后范数 · 方向保持与逐值裁剪区别 · 非有限梯度诊断
- 作业 07编程实践
CPU 小模型分为两个参数组,写日志核对全局裁剪集合与调度器状态;在重排参数组后恢复断点并检测映射是否兼容。
- 提交内容
- 审计程序与恢复实验
- 怎样算完成
- 组身份不只依靠位置
- 裁剪和学习率作用范围完整
练到的知识点全局L2梯度范数 · 调度器断点与调用顺序
- 作业 08设计评审
训练计划中途延长总更新次数,原来采用余弦衰减。设计“延续原计划”与“重新规划”两种调度规则,说明历史断点包含哪些必要信息。
- 提交内容
- 两种计划与断点 schema
- 怎样算完成
- 历史与新计划边界清楚
- 不宣称两方案自然等价
练到的知识点调度器断点与调用顺序
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
固定计算预算比较两种预热长度与两种裁剪阈值,至少运行三个种子;在运行前指定稳定性和最终质量指标。
- 提交内容
- 四条件实验与结论
- 怎样算完成
- 比较预算相等
- 不只挑选最佳种子
练到的知识点预热直觉与范围 · 余弦衰减端点 · 全局L2梯度范数
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
如果一个参数组被冻结后又解冻,是否能无条件沿用原来的全局裁剪和学习率时间轴?自行构造小训练过程,研究不同约定对应的更新差异。
- 提交内容
- 条件实验与约定比较
- 怎样算完成
- 冻结期间行为明确
- 观察结论限定给定约定
练到的知识点全局L2梯度范数 · 调度器断点与调用顺序
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
PyTorch2.9.1梯度裁剪源码 (新标签页)↗
核对全局范数、返回值和原地更新。
- 深入参考文档英文
PyTorch2.9.1学习率调度源码 (新标签页)↗
检查
LambdaLR状态与调用计数。 - 深入参考文档英文
PyTorch2.9 AMP示例 (新标签页)↗
核对反缩放、裁剪和更新的顺序。