zero_grad:None与零梯度为何更新不同
zero_grad(set_to_none=True)与把梯度张量清成零,不只是内存写法不同。对PyTorch优化器,grad为None表示跳过该参数的本次step;grad是零张量则仍可能进入更新流程,权重衰减或历史动量可能继续改变参数。
文章目录
因此,“本批没有给这个参数新梯度”“本批导数恰好为零”“参数已经冻结”是三个不同判断。下面用两条精确更新路径解释差异,再给出适用于条件分支和分阶段微调的状态核验方法。Read in English。
先区分没有梯度和数值为零
PyTorch 2.8的zero_grad说明明确区分None与零梯度:前者使优化器跳过该参数,后者按零梯度执行step。set_to_none=True之后,没有收到梯度的参数会保持grad为None。这里讨论该版本内置优化器的约定,不把它推广为所有自定义优化器的共同规则。
None是缺少梯度张量的状态;零张量则是一个存在、形状明确、元素为零的对象。一个参数参与了可微计算,但本批导数抵消为零,也可能得到零张量。反过来,没有参与本批路径时,若残留着以前清零后的张量,优化器看到的也不一定是None。
检查时应同时记录grad是否存在、范数或元素值,以及参数是否仍在优化器参数组中。只打印一个梯度范数,甚至把None先映射成0方便展示,会丢掉这篇最重要的状态差别。
零新梯度,衰减仍可以执行
AdamW定义把权重衰减与梯度矩估计分离。为隔离这个因素,先看没有历史矩贡献的教学步骤:θ=1,学习率η=0.1,衰减λ=0.1。若零梯度仍让参数进入step,仅衰减项就给出θ′=(1−ηλ)θ=0.99。
若该参数grad为None,按照上述跳过约定,该step不执行在这个参数上,θ仍为1。这不是在说所有零梯度步骤的AdamW结果必定是0.99;一旦存在历史一阶矩、二阶矩和步数,它们也会影响完整更新。
这个分离例子说明,清零并不等于“不对参数做任何事”。衰减是更新流程的一部分,不要求本批损失导数非零。若某层只在部分批次激活,None与零的选择可能影响它实际经历了多少次衰减,而不是只影响日志显示。
历史动量也能让零梯度移动参数
再看SGD的动量公式,限定已有动量缓冲区、无衰减、无dampening、无Nesterov:b′=μb+g,θ′=θ−ηb′。给定θ=1、b=2、μ=0.9、η=0.1,即使g=0,也有b′=1.8、θ′=0.82。
如果本次以None跳过该参数,这个教学状态保持θ=1、b=2。下一次重新激活时,从哪个缓冲区继续,取决于之前到底执行还是跳过了更新。只比较这一步的梯度数值,解释不了之后轨迹为什么不同。
学习率设为0也不是完整状态冻结的充分条件。在上述零梯度动量路径里,θ可以不动,但b仍从2变成1.8。参数没变化和优化器状态没变化,需要分别验证;它们对未来重新训练的含义不同。
九项检查把参数和状态一起对照
下面两个小函数分别隔离衰减和已有动量的影响。它们不是完整SGD或AdamW实现,不含首次动量初始化、偏差修正、混合精度或张量后端。None分支按本文讨论的跳过约定保留状态。
# Exact teaching transitions, not complete SGD/AdamW implementations.
from fractions import Fraction as F
def decay_only(theta, grad, lr=F(1,10), wd=F(1,10)):
if grad is None:
return theta
if grad != 0:
raise ValueError("this toy handles zero gradients only")
return theta*(1-lr*wd)
def momentum_step(theta, buffer, grad, lr=F(1,10), mu=F(9,10)):
if grad is None:
return theta,buffer
next_buffer=mu*buffer+grad
return theta-lr*next_buffer,next_buffer
theta=F(1)
assert decay_only(theta,None)==1
assert decay_only(theta,F(0))==F(99,100)
assert decay_only(theta,F(0),wd=F(0))==1
assert momentum_step(theta,F(2),None)==(F(1),F(2))
assert momentum_step(theta,F(2),F(0))==(F(41,50),F(9,5))
assert momentum_step(theta,F(0),F(0))==(F(1),F(0))
assert momentum_step(theta,F(2),F(0),lr=F(0))==(F(1),F(9,5))
a,b=momentum_step(theta,F(2),F(0))
assert momentum_step(a,b,F(0))==(F(329,500),F(81,50))
assert decay_only(theta,None)!=decay_only(theta,F(0))
print("9 missing-versus-zero gradient checks passed")
本地九项检查通过,包括None、零梯度、零衰减、无历史动量、零学习率和连续两个零梯度步骤。第二个动量步骤得到θ=0.658、b=1.62,说明状态变化会延续到后续迭代。精确分数避免舍入掩盖差异,但没有测量真实训练效果。
set_to_none=False不保证给所有参数创建零梯度
该版本zero_grad实现只对已经存在的梯度进入清理路径。一个从未得到梯度、当前grad就是None的参数,不能仅凭调用set_to_none=False就假定它一定获得了新建零张量。本文的对照前提是优化器实际看到的状态分别为None和零,而不是只根据调用参数猜结果。
对条件分支,先运行一个确实激活该参数的批次,让它产生梯度,再分别清成None或零;下一批让分支不激活,并比较step前后。这样才能观察“过去有梯度、本批没有梯度”的差别。只用一个从未激活的全新参数,可能得到两条都为None的路径,误以为没有语义差异。
生产训练中还要确认前一批梯度没有残留。若需要梯度累积,就不能每个微批随意清空;若不需要累积,就应保证更新单元开始时状态符合约定。清梯度边界和是否跳过参数,是相关但不同的两件事。
这里的跳过以单个参数为单位。其他参数仍可能更新,全局学习率调度器或外部计数也可能独立推进;保留本参数的优化器状态,不等于暂停整个训练循环。
冻结参数要有明确的更新契约
“冻结”可能指不记录新梯度、暂时不被优化器更新、或者连同历史状态一起保持不变。先选择项目需要哪一种,再决定requires_grad、梯度清理和参数组如何管理。不能把某一个开关当成所有含义的替代。
例如更改requires_grad后,如果旧grad仍存在且参数仍由优化器管理,必须检查step实际会读到什么。它是否应该移出参数组、是否保留动量以便恢复、是否重置历史状态,都应作为分阶段训练方案明确定义,而不是在排错时临时猜测。
恢复训练也不能只对照参数权重。若需要重现更新轨迹,应核对优化器状态、学习率计划和参与更新的参数集合。此前AdamW与L2的区别讨论更新项含义,本篇补充的是:这些项究竟有没有在某个参数上执行。
用一次真实step做最小验收
在项目支持的环境,准备两个相同模型与相同优化器状态副本。先确认同一参数已有历史状态,再分别构造None与零梯度,运行一次step,比较参数值与所有相关状态。不要只比较loss,因为本步根本不需要改变前向loss就能出现更新差异。
随后加上项目实际使用的foreach或fused路径、AMP、参数共享与条件分支,逐项验证。自定义优化器尤其要读其grad为None时的实现。这篇没有运行这些后端验收,所以不会声称九项算术断言已经证明你的训练栈行为。
测试报告可以直接保留“是否进入step、θ前后、状态前后、grad是否None、配置版本”五类信息。它们比“这层梯度为0,所以应该不变”更能说明发生了什么。
别把状态差异压成一个零
选择set_to_none应同时考虑更新语义与实际实现,而不只看少写了多少内存。对条件激活参数,先决定未参与批次是否应该衰减或推进状态,再检查优化器实际契约。不存在脱离目标训练方案、永远正确的一种清梯度写法。
若这组算例和状态对照帮你解释了“梯度为零,参数却还在变”,欢迎通过文末支持区自愿支持。把缺少梯度与零梯度分开记录,是让分阶段训练更容易复现的一步。
来源与补发说明
依据原计划时段前已有的PyTorch 2.8版本定义核对。推导、算术代码和两幅图为原创教学内容。计划文章日期为2026年10月19日23:30,实际公开时间另行核验报告。未运行PyTorch训练、分布式进程或GPU性能测试。
支持
如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。
二维码可点击放大。更多支持方式见支持页面。


