想象一下,你是一名餐厅主厨,面前摆着一堆新鲜食材——那是模型训练时不断产生的梯度。梯度就像刚切好的配菜:有的太辣(梯度爆炸)需要冲淡,有的味道不够(梯度太小)需要加料,有的需要按特定节奏下锅(学习率调度),有的则需要在出锅前撒一点点盐(权重衰减)。Optax 就是这座“中央厨房”的全套厨具:它没有一口锁死的大铁锅(无状态对象),而是给你一套不锈钢操作台和标准化工序——每道工序都是纯函数,把食材传进去、得到处理后的食材传出来,锅和铲从不私藏任何秘密。你可以把“剪辣椒”“调火力”“撒盐”“翻炒”这些独立工序按任意顺序拼成一条完整的流水线,这就是 Optax 的核心——梯度变换管道(Gradient Transformation Pipeline)。 今天,我们就从这家“中央厨房”的通风管道爬进去,看看每一道工序是怎么设计的、怎么组合的,以及如何根据自己的口味打造一条私人流水线。

岗位关联

职业价值:掌握 Optax 的函数式设计与组合哲学,意味着你不再被“优化器黑盒”困住——从大模型预训练到强化学习策略调优,你都能像搭积木一样自由定制训练流程,这是 JAX 生态进阶开发者的核心差异化能力。

目录
  1. 可视化演示
  2. 函数式设计
  3. 核心数据结构
  4. 常用优化器
  5. 梯度裁剪
  6. 学习率调度
  7. 权重衰减
  8. 组合与叠加
  9. 自定义优化器
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示Optax梯度变换管道的模块化组合,以及函数式状态无关设计的工作原理。

本节配有交互动效(进入视口后自动加载,约 3KB)。

梯度变换管道 chain组合:梯度裁剪→权重衰减→学习率调度→Adam更新 梯度裁剪:限制梯度范数防止爆炸 学习率调度:warmup+cosine衰减 optax.chain(clip, wd, sched, adam) 模块化组合构建自定义优化策略

函数式设计

走进这座“中央厨房”,你首先会注意到一个奇怪的现象:每口锅、每把铲在用过之后都锃亮如新,仿佛从不记得自己炒过什么菜。这与传统厨房截然不同——传统厨房里的锅会积累油渍、铲子会残留味道,就像 PyTorch 中优化器对象内部会维护自己的 state_dict。而在 Optax 的厨房里,没有一口“有记忆的锅”。每个厨具(优化器变换)都是纯函数:你把食材(梯度)递进去,它处理完递出来,自己的状态(动量、方差)则装在一个透明盒子里显式传给你保管。你完全清楚盒子里的东西,随时可以检查、保存、甚至中途换一个盒子。

这种设计的第一大好处是可预测性:同样的输入永远得到同样的输出,没有暗藏的全局变量悄悄改变菜的味道。第二大好处是 JAX 的 jit 编译器可以大胆地对整个烹饪流程做优化——因为每一步都不依赖外部隐藏状态,编译时可以放心地把整个流水线内联展开,就像把几十道工序写进一张精确到秒的 SOP 表。传统优化器对象的方法调用会打破 JIT 的编译边界,而纯函数则畅通无阻。

从数学上看,一个优化器变换可以理解为对梯度不断施加的函数组合:

回到厨房的比喻:这就是一间“透明厨房”——所有工序、所有调料、所有半成品都摆在明面上。你不再依赖某位秘传厨师握着锅铲告诉你“凭感觉放盐”,而是拿到了一张可以无限复制的标准化菜谱。函数式设计,让深度学习训练从“手工作坊”升级为“现代厨房”。

Topt(g,s)=(fn∘fn−1∘⋯∘f1)(g,s)T_{\text{opt}}(g, s) = \big( f_n \circ f_{n-1} \circ \cdots \circ f_1 \big)(g, s)
  1. 函数组合符号这里的 $\circ$ 表示“先执行右边、再执行左边”的复合运算符,像流水线上两道前后衔接的工序。
    fn∘fn−1∘⋯∘f1f_n \circ f_{n-1} \circ \cdots \circ f_1
  2. 梯度输入$g$ 是当前梯度,即流水线入口处的原始食材。
    g∈Rdg \in \mathbb{R}^d
  3. 状态显式传递$s$ 是优化器状态(如动量缓存),随每个工序流动,任何工序都无权把它藏起来。
    sk+1=fk(sk)s_{k+1} = f_k(s_k)
  4. 最终输出$T_{\text{opt}}$ 输出更新后的梯度和状态,整个过程不产生任何副作用。

核心数据结构

在前面的厨房里,每一道工序都被抽象成了一个标准化的“插口”。在 Optax 中,这个插口叫 GradientTransformation。它不是 Python 类实例,而是一个简单的数据结构——通常是一个二元组 (init_fn, update_fn)。init_fn 是“上岗前检查”:给定模型参数结构,初始化该工序需要的状态(比如动量缓存全 0);update_fn 是“执行工序”:给定梯度、当前状态和参数,返回处理后的梯度和新状态。

如果你用过 PyTorch,一定熟悉 optimizer.step() 这种“锅自己翻菜”的写法。而 Optax 明确区分两个角色:init_fn 负责发给你一个空饭盒,update_fn 负责往饭盒里装新状态。你(训练循环)才是真正拿饭盒的人,每轮迭代你拿着饭盒去找 update_fn,它把处理好的梯度还给你,同时把新状态装回饭盒递回。这种“状态物权归属清晰”的设计,让分布式训练中的状态同步变得异常简单——状态就是普通数据,可以被 jax.device_get 取回、被 jax.device_put 放回。

形式化地看,GradientTransformation 的签名可以写作:

类比一下:GradientTransformation 就像一台“扫码即出餐”的自动售货机——你投币(传入梯度),它掉出食物(更新量),同时内部齿轮转动一格(状态更新)。你不需要拆开机器看它是怎么转的,只需要知道投什么币、掉什么货。

transform:Params→((Updates,OptState)→(Updates′,OptState′))\text{transform}: \text{Params} \to \big( (\text{Updates}, \text{OptState}) \to (\text{Updates}', \text{OptState}') \big)
  1. 输入参数结构$\text{Params}$ 表示模型的全部参数,init_fn 需要遍历它来决定状态的内存布局。
    Params={θ(1),θ(2),…,θ(L)}\text{Params} = \{\theta^{(1)}, \theta^{(2)}, \dots, \theta^{(L)}\}
  2. 柯里化返回箭头向右表示 transform 调用后返回的是一个“函数”,而不是一个对象。
    transform(params)↦(init_fn,update_fn)\text{transform}(\text{params}) \mapsto (\text{init\_fn}, \text{update\_fn})
  3. 更新量与状态update_fn 的输入是更新量 $\text{Updates}$ 和当前状态 $\text{OptState}$。
    (Updates,OptState)(\text{Updates}, \text{OptState})
  4. 输出新状态函数返回处理后的更新量和更新后的状态,无任何隐藏副作用。
    (Updates′,OptState′)(\text{Updates}', \text{OptState}')

常用优化器

中央厨房的墙上挂着一排标准化菜谱,对应 Optax 中开箱即用的优化器。首先是 sgd——最简单的“家常炒菜”:梯度乘上学习率,直接更新参数。它炒出来的菜朴实无华,但火候难掌握,容易颠锅时洒出食材。接着是 adam——厨房里的“智能炒锅”:它同时监测“温度变化方向”(一阶动量)和“温度波动幅度”(二阶动量),自动调节每一勺的力度。RMSProp 则像“减摇炒锅”,专门对付剧烈颠簸的锅体;而 Lion 像“轻快炒锅”,只用梯度符号做决策,省油省气,速度飞快。

Adam 之所以成为默认选择,是因为它把“火候自动调节”做到了极致。它维护两个状态:$m_t$ 是梯度的指数滑动平均(方向感),$v_t$ 是梯度平方的指数滑动平均(波动感)。参数更新时,用方向感除以波动感的平方根,相当于在“顺风”时加速、在“颠簸”时减速。其核心公式为:

这就像智能炒锅内置了一个陀螺仪:你只需设定温度档位(学习率),锅自己会根据食材翻滚的剧烈程度微调铲子力度。你不需要理解陀螺仪内部原理,只需要知道选 Adam 通常不会砸锅。当然,Adam 也不是万能的,后面我们会看到如何给它“加料”。

m^t=mt1−β1t,v^t=vt1−β2t,θt+1=θt−ηv^t+ϵm^t\hat{m}_t = \frac{m_t}{1-\beta_1^t}, \quad\hat{v}_t = \frac{v_t}{1-\beta_2^t}, \quad\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t
  1. 一阶动量更新$m_t$ 是梯度方向的历史平均,$\beta_1$ 控制记住多久以前的方向,越接近 1 记忆越长。
    mt=β1mt−1+(1−β1)gtm_t = \beta_1 m_{t-1} + (1-\beta_1) g_t
  2. 二阶动量更新$v_t$ 是梯度平方的历史平均,反映最近梯度波动的剧烈程度。
    vt=β2vt−1+(1−β2)gt2v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2
  3. 偏差修正训练刚开始时 $m_t$、$v_t$ 被初始化为 0 会偏小,除以 $1-\beta^t$ 相当于“预热校正”,让前几步不至于迈得太小。
    m^t=mt1−β1t,v^t=vt1−β2t\hat{m}_t = \frac{m_t}{1-\beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1-\beta_2^t}
  4. 参数更新$\eta$ 是学习率(基准火力),$\epsilon$ 是防止除零的小常数,整个分式相当于“单位化”后的步长。
    θt+1=θt−ηv^t+ϵm^t\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t

梯度裁剪

炖汤时最怕什么?火候失控导致汤汁扑出锅沿。训练神经网络最怕什么?梯度爆炸——某一步的梯度突然变得巨大,参数被推到一个离谱的位置,loss 瞬间变成 NaN。这时候 Optax 的“锅盖”出场了:梯度裁剪。它的逻辑极其朴素:如果梯度向量的全局范数超过阈值 $c$,就统一缩放,把它压回半径为 $c$ 的球内。就像汤要扑出来时、快速把锅盖一扣,气压把泡沫压回去。

具体公式如下:

Optax 中对应实现是 clip_by_global_norm(c),它把整个参数树的所有梯度当做一个整体来裁剪——相当于把所有汤料一起压住。还有 clip_by_block_norm,把每一层参数单独看待、逐层裁剪,相当于在每个小炖盅上分别加盖。什么时候用哪种?如果你的模型有几层特别容易爆炸(比如 Transformer 的 FFN 层),逐层裁剪更精准;如果整体训练不稳定,全局裁剪更省心。裁剪与 Adam 组合时一般放在链首,先压住爆炸势头再让动量平滑。

gclipped=g⋅min⁡(1,c∥g∥2)g_{\text{clipped}} = g \cdot \min\left(1, \frac{c}{\|g\|_2}\right)
  1. 整体缩放系数$\min$ 取括号里较小者,如果梯度范数小于 $c$,系数就是 1,等于没有裁剪。
    min⁡(1,c∥g∥2)∈(0,1]\min\left(1, \frac{c}{\|g\|_2}\right) \in (0, 1]
  2. 梯度范数$\|g\|_2$ 是所有梯度元素平方和再开方,代表这一刻所有“配料”的总强度。
    ∥g∥2=∑i=1ngi2\|g\|_2 = \sqrt{\sum_{i=1}^{n} g_i^2}
  3. 缩放计算将总强度按比例压缩到 $c$ 以内,方向不变、大小改变。
    gclipped=g⋅scaleg_{\text{clipped}} = g \cdot \text{scale}

学习率调度

如果说梯度裁剪是防止“扑锅”的锅盖,那学习率调度就是控制“火力”的旋钮。同一个菜谱,大火爆炒和小火慢炖出来的口感天差地别。深度学习训练亦然:训练初期参数离最优解很远,可以用较大的学习率快速逼近;后期接近最优点,需要调小学习率精细打磨,否则会在最优点附近来回震荡。

Optax 把学习率调度也实现为一种梯度变换。最常用的策略是 warmup + 余弦退火:前 $t_{\text{warm}}$ 步,学习率从 0 线性爬升到峰值 $\eta_{\max}$(预热阶段,防止一开始步子太大把参数踢飞);之后按余弦曲线从峰值平滑下降到 $\eta_{\min}$“余温慢炖”。其公式为:

在 Optax 中,warmup_cosine_decay_schedule 返回的是一个“函数”——输入步数、输出学习率。这个函数可以作为 scale_by_learning_rate 的系数传入,也可以拿来做任何需要数值缩放的地方。一切皆是变换、一切皆是函数,这正是函数式设计的精髓:火力表也是一道可插拔的工序。

ηt=ηmin⁡+(ηmax⁡−ηmin⁡)⋅1+cos⁡(π⋅t−twarmT−twarm)2\eta_t = \eta_{\min} + (\eta_{\max} - \eta_{\min}) \cdot \frac{1 + \cos\left(\pi \cdot \frac{t - t_{\text{warm}}}{T - t_{\text{warm}}}\right)}{2}
  1. 线性预热在 $t < t_{\text{warm}}$ 时,学习率线性从 0 升到峰值,让模型“小口试菜”。
    ηt=ηmax⁡⋅ttwarm\eta_t = \eta_{\max} \cdot \frac{t}{t_{\text{warm}}}
  2. 余弦退火核心$\cos$ 在 $0$ 到 $\pi$ 之间从 1 单调下降到 -1,经变换后学习率从峰值降到谷值。
    1+cos⁡(π⋅ratio)2∈[0,1]\frac{1 + \cos(\pi \cdot \text{ratio})}{2} \in [0, 1]
  3. 归一化进度$\text{ratio} = \frac{t - t_{\text{warm}}}{T - t_{\text{warm}}}$ 表示预热结束后训练已走过的进度比例,从 0 到 1。
    0≤t−twarmT−twarm≤10 \le \frac{t - t_{\text{warm}}}{T - t_{\text{warm}}} \le 1
  4. 线性插值映射将 $[0,1]$ 映射到 $[\eta_{\min}, \eta_{\max}]$ 区间,得到当前步的实际学习率。
    ηt=ηmin⁡+(ηmax⁡−ηmin⁡)⋅cosine_factor\eta_t = \eta_{\min} + (\eta_{\max} - \eta_{\min}) \cdot \text{cosine\_factor}

权重衰减

炖肉的最后一步,厨师会尝一口汤,如果味道太咸太浓,就加一点水稀释。权重衰减在训练中起的就是“加水稀释”的作用:每轮更新时,把参数本身向零点拉近一点点,防止某些权重变得过大、模型过度自信(过拟合)。经典做法是在损失函数上加 L2 正则项 $\frac{\lambda}{2}\|\theta\|^2$,这样梯度里就多了一项 $\lambda \theta$,相当于“每一铲都顺手把锅里的肉往下压一点”。

但这里有一个极其隐蔽的坑:当优化器是 Adam 这类自适应方法时,L2 正则化和权重衰减不再等价。Adam 会把梯度除以二阶动量的平方根,导致正则项也被“自适应”地缩放了,实际衰减效果变得不均匀。于是出现了 de-coupled weight decay(解耦权重衰减)——也就是 AdamW 的核心思想:把“衰减参数”这一步从优化器的自适应缩放中抽离出来,直接在参数更新时单独执行:

Optax 中的实现是 add_decayed_weights(decay)(加衰减项)与 scale_by_adam(...) 链式组合。注意顺序:一般先 add_decayed_weights 再 scale_by_adam,让衰减直接作用于原始梯度、不参与自适应缩放。这又是“积木顺序决定策略”的绝佳例证——同一个积木,放的位置不同,出来的菜完全不一样。

θt+1=θt−ηt(λθt+ut)\theta_{t+1} = \theta_t - \eta_t \left( \lambda \theta_t + u_t \right)
  1. 更新量记号$u_t$ 表示优化器内部计算出的更新方向(如 Adam 的 $\frac{\hat{m}_t}{\sqrt{\hat{v}_t}+\epsilon}$)。
    ut=m^tv^t+ϵu_t = \frac{\hat{m}_t}{\sqrt{\hat{v}_t}+\epsilon}
  2. 衰减项$\lambda \theta_t$ 与当前梯度无关,只与参数当前值成正比,像“离圆心越远,往回拉的力量越大”。
    λθt,λ∈[0,1)\lambda \theta_t, \quad \lambda \in [0, 1)
  3. 加和更新优化器方向和衰减方向拼接在一起,共同决定参数下一步的位置。
    θt+1=θt−ηt(λθt+ut)\theta_{t+1} = \theta_t - \eta_t (\lambda \theta_t + u_t)

组合与叠加

逛了一圈厨房,你会发现所有工具都遵循同一个标准接口:输入梯度 + 状态,输出新梯度 + 新状态。既然接口统一,你就可以把任意多道工序串成一条流水线——这正是 Optax 的杀手锏 chain。裁剪、调度、衰减、动量、自适应缩放……每一件都是标准积木,chain 是拼装积木的“卡扣”。

一个典型的自定义优化器拼装实现:

每一段的输出恰好是下一段的输入,这就是组装流水线的全部秘密。Optax 的 chain 实现会分别调用每个变换自己的 init_fn 初始化状态,然后把所有状态打包成一个嵌套结构;update_fn 则依次调用各变换,把前一个的输出喂给后一个。

这就像乐高积木:每块积木只有一个凸起和一个凹槽(统一接口),你可以随意拼接。今天想先裁剪再衰减,明天想先衰减再裁剪,只需调整 chain 里的排列顺序。积木是别人造好的,但拼法由你决定。

g_1 = \text{clip}(g_0), \quadg_2 = \text{decay}(g_1), \quadg_3 = \text{adam\_update}(g_2), \quadu = \text{scale\_by\_lr}(g_3)
  1. 裁剪段$g_0$ 是原始梯度,clip 把它压回安全范数内,得到 $g_1$。
    g1=clip(g0)g_1 = \text{clip}(g_0)
  2. 衰减段$g_1$ 加上权重衰减项 $\lambda \theta$,得到 $g_2$。
    g2=g1+λθg_2 = g_1 + \lambda \theta
  3. 自适应段$g_2$ 进入 Adam 的动量与方差计算,输出归一化方向 $g_3$。
    g3=adam_update(g2)g_3 = \text{adam\_update}(g_2)
  4. 缩放段最后乘上当前步的学习率 $\eta_t$,得到最终更新量 $u$。
    u=ηt⋅g3u = \eta_t \cdot g_3

自定义优化器

理论说了一千遍,不如亲手做一道菜。现在来拼装一个属于你自己的优化器。标准流程分三步:定义变换、拼接链条、投入训练。

这段代码就是完整答案。chain 里的四个积木依次执行:先全局裁剪,再加重衰减,再 Adam 自适应,最后乘学习率。想换成 SGD?把 scale_by_adam 换成 scale_by_schedule + trace 即可。真正的自定义优化器,本质上是对一个公共接口的熟悉程度——你认识的积木越多,能拼出的流水线就越丰富。

调试自定义优化器时,一个实用的方法是用 optax.apply_updates(params, grads) 单步执行,打印每一步的状态;

更高级的技巧是参数分组(param_group):不同层使用不同策略,就像同一桌菜、有人吃辣有人不吃辣。通过 optax.multi_transform,可以为 Transformer 的 embedding 层和 attention 层分别指定不同的梯度变换,这在 LoRA 微调中非常常见。你也可以把上述整个链条封装成一个纯函数,配合 functools.partial 复用在不同模型上。至此,你不再只是“调用优化器”,而是“设计优化策略”。

---

python7 行
def my_optimizer(lr, max_norm, decay, b1=0.9, b2=0.999, eps=1e-8):    return optax.chain(        optax.clip_by_global_norm(max_norm),        optax.add_decayed_weights(decay),        optax.scale_by_adam(b1=b1, b2=b2, eps=eps),        optax.scale_by_learning_rate(lr),    )

AI 应用场景

大模型预训练的安全阀

阶段:训练稳定性保障

解决的问题:大模型预训练时梯度爆炸是常见灾难,传统做法是反复调小学习率,训练速度大打折扣。Optax 通过 clip_by_global_norm 在流水线入口统一裁剪,配合 warmup 余弦退火调度,让训练在快与稳之间取得平衡。

具体案例:某团队在训练 13B 参数语言模型时,梯度范数偶尔会从 1 飙升至 50,导致 loss 尖刺。他们在 chain 中引入 clip_by_global_norm(1.0) 后,尖刺完全消失,收敛速度反而提升了 20%。

强化学习策略梯度校准

阶段:策略优化与更新

解决的问题:强化学习中策略梯度的方差极大,同一批轨迹的梯度可能正负悬殊。传统 PPO 实现需要在损失函数中手工加裁剪逻辑。Optax 中直接用 chain(clip_by_global_norm, scale_by_adam, scale_by_learning_rate) 组合一个自适应裁剪优化器。

具体案例:一个机器人控制实验采用 Optax 自定义流水线后,训练曲线从剧烈震荡变为平滑上升,成功收敛的随机种子比例从 60% 提高到 95%。

迁移学习中的细粒度控制

阶段:微调与适配

解决的问题:微调预训练模型时,通常希望主干网络学习率小、新增头部学习率大。Optax 的 multi_transform 可以对不同参数组施加不同的梯度变换,类似厨师对主料和配菜分别采取不同火候。

具体案例:在图像分类微调中,对主干网络设置 1e-5 学习率与 0.0001 权重衰减,对分类头设置 1e-3 学习率与 0.01 权重衰减,准确率比统一学习率提升 3.2 个百分点。

Python 演示

python128 行
import mathfrom typing import NamedTuple import jaximport jax.numpy as jnp  class AdamState(NamedTuple):    count: jnp.ndarray    mu: jnp.ndarray    nu: jnp.ndarray  class ChainState(NamedTuple):    adam: AdamState    clip_scale: jnp.ndarray  def scale_by_adam(b1=0.9, b2=0.999, eps=1e-8):    def init_fn(params):        leaves, _ = jax.tree_util.tree_flatten(params)        return AdamState(            count=jnp.zeros((), dtype=jnp.int32),            mu=jax.tree_util.tree_map(jnp.zeros_like, params),            nu=jax.tree_util.tree_map(jnp.zeros_like, params),        )     def update_fn(grads, state, params=None):        count = state.count + 1        mu = jax.tree_util.tree_map(            lambda m, g: b1 * m + (1 - b1) * g, state.mu, grads        )        nu = jax.tree_util.tree_map(            lambda v, g: b2 * v + (1 - b2) * g * g, state.nu, grads        )        mu_hat = jax.tree_util.tree_map(            lambda m: m / (1 - b1 ** count), mu        )        nu_hat = jax.tree_util.tree_map(            lambda v: v / (1 - b2 ** count), nu        )        updates = jax.tree_util.tree_map(            lambda m, v: m / (jnp.sqrt(v) + eps), mu_hat, nu_hat        )        new_state = AdamState(count=count, mu=mu, nu=nu)        return updates, new_state     return init_fn, update_fn  def clip_by_global_norm(max_norm):    def init_fn(params):        return jnp.zeros(())     def update_fn(grads, state, params=None):        norm = jnp.sqrt(            sum(                jnp.sum(jnp.square(g))                for g in jax.tree_util.tree_leaves(grads)            )        )        scale = jnp.minimum(1.0, max_norm / (norm + 1e-8))        updates = jax.tree_util.tree_map(lambda g: g * scale, grads)        return updates, scale     return init_fn, update_fn  def scale_by_learning_rate(learning_rate):    def init_fn(params):        return ()     def update_fn(grads, state, params=None):        return jax.tree_util.tree_map(lambda g: g * learning_rate, grads), state     return init_fn, update_fn  def chain(*transforms):    init_fns = [t[0] for t in transforms]    update_fns = [t[1] for t in transforms]     def init_fn(params):        return ChainState(            adam=init_fns[0](params),            clip_scale=init_fns[1](params),        )     def update_fn(grads, state, params=None):        grads, clip_scale = update_fns[1](grads, state.clip_scale, params)        grads, adam_state = update_fns[0](grads, state.adam, params)        grads, _ = update_fns[2](grads, None, params)        return grads, ChainState(adam=adam_state, clip_scale=clip_scale)     return init_fn, update_fn  def warmup_cosine_schedule(lr_max, warmup_steps, total_steps):    def schedule(step):        if step < warmup_steps:            return lr_max * step / warmup_steps        ratio = (step - warmup_steps) / (total_steps - warmup_steps)        cosine = 0.5 * (1.0 + math.cos(math.pi * ratio))        return lr_max * 0.05 + (lr_max - lr_max * 0.05) * cosine    return schedule  params = {"w": jnp.ones((2, 2)), "b": jnp.zeros((2,))}optimizer = chain(    scale_by_adam(),    clip_by_global_norm(1.0),    scale_by_learning_rate(0.01),)opt_state = optimizer[0](params)  def loss_fn(p, batch):    pred = jnp.dot(batch, p["w"]) + p["b"]    return jnp.mean((pred - jnp.array([1.0, 0.0])) ** 2)  batch = jnp.array([[1.0, 2.0], [3.0, 4.0]])grads = jax.grad(loss_fn)(params, batch)for step in range(3):    grads = jax.grad(loss_fn)(params, batch)    updates, opt_state = optimizer[1](grads, opt_state, params)    params = jax.tree_util.tree_map(lambda p, u: p - u, params, updates)    print("step", step, "loss", loss_fn(params, batch))
目的:从零实现一个 Optax 风格的自定义优化器,展示 chain 如何把裁剪、Adam、学习率缩放组合成一条梯度变换流水线,并在小规模参数上完成训练循环。
  1. def scale_by_adam(b1=0.9, b2=0.999, eps=1e-8):定义 Adam 变换的工厂函数,返回 init_fn 和 update_fn,对应“制作智能炒锅”的两份说明书。
  2. jax.tree_util.tree_map(lambda m, g: b1 * m + (1 - b1) * g, state.mu, grads)递归地对参数树的每个叶子执行动量更新,避免手写循环遍历。
  3. scale = jnp.minimum(1.0, max_norm / (norm + 1e-8))计算裁剪比例,1e-8 防止除零。
  4. def chain(*transforms):接收多个变换,把它们按顺序串联成一条完整流水线。
  5. grads, adam_state = update_fns[0](grads, state.adam, params)依次执行各变换,前一个变换的输出作为后一个的输入。
  6. warmup_cosine_schedule生成一个“步数到学习率”的纯函数,可拆可装。

常见误区

误区“我在代码里创建了 `optim = optax.adam(1e-3)`,然后调用 `optim.update(...)`,我以为这个 `optim` 对象内部会自己记住动量,不用我操心。”

正解Optax 的 optim 只是一个变换的载体,它没有任何可变内部状态。动量等状态完全由你持有的 opt_state 变量承载。每次调用 update 时,你会收到一个新的 opt_state,必须手动传回下一轮。这就像你向厨师买了一份菜谱,但调料和锅都得自己准备和保管——菜谱本身不会替你炒菜。

为何常见:PyTorch 的优化器对象内部持有 state 字典,很多从 PyTorch 转到 JAX 的开发者会被这种“类对象持有状态”的习惯误导。

误区“反正最后都是梯度,先裁剪还是先衰减效果差不多吧?我随便排了顺序,结果训练不稳定,肯定是代码有 bug。”

正解顺序就是策略。clip_by_global_norm 放在链首会先压制爆炸梯度,add_decayed_weights 放的位置决定了权重衰减项是否参与后续的自适应缩放。标准做法是先裁剪、再衰减、再 Adam、最后学习率缩放。调整顺序前,想清楚每一道工序之间是否相互影响。

为何常见:初学者把梯度变换当作“加减法”看待,忽略了变换之间的耦合关系,以为顺序只是风格差异。

误区“L2 正则化就是在损失里加 `lambda * ||theta||^2`,Adam 里也这么做就对了,反正效果一样。”

正解在 SGD 下两者确实等价,但在 Adam 下完全不同——Adam 会对梯度做逐元素缩放,导致正则项被不均匀缩放,破坏“远离零点越远、回拉越大”的语义。AdamW 的解耦权重衰减把衰减独立出来,才是正确做法。

为何常见:教科书通常先讲 L2 正则化与权重衰减在 SGD 上的等价性,初学者容易忽略优化器自适应缩放带来的影响。

面试题

Q1. Optax 中的优化器本质是什么?

单选题 · easy

  • A.一个持有可变状态的对象
  • B.一个包含 init_fn 和 update_fn 的二元组← 正确答案
  • C.一个只包含学习率的数值
  • D.一个 PyTorch 优化器的 JAX 版本

答案:B。Optax 优化器是一个 GradientTransformation,本质上是 (init_fn, update_fn) 二元组,两个函数分别负责初始化状态和更新梯度。它不持有任何可变状态,因此 A 错误、B 正确。

Q2. Optax 中状态(OptState)由谁保管?

单选题 · easy

  • A.优化器对象自己
  • B.模型参数内部
  • C.训练循环中的变量← 正确答案
  • D.梯度自身的属性

答案:C。函数式设计的核心是状态显式传递,update_fn 返回新状态,由调用者(训练循环)持有并传回下一轮。优化器自己不存状态,参数内部也不存优化状态。

Q3. 下列哪个是典型的梯度裁剪函数?

单选题 · easy

  • A.optax.scale_by_adam
  • B.optax.clip_by_global_norm← 正确答案
  • C.optax.add_decayed_weights
  • D.optax.warmup_cosine_decay_schedule

答案:B。clip_by_global_norm 按全局范数裁剪梯度,是题中唯一的梯度裁剪函数。其余选项分别是自适应缩放、衰减和调度的变换。

Q4. optax.chain 的作用是什么?

单选题 · mid

  • A.并行运行多个优化器
  • B.把多个梯度变换串成流水线← 正确答案
  • C.自动选择最优优化器
  • D.加速梯度计算

答案:B。chain 把多个变换按顺序串联,前一变换的输出是后一变换的输入,形成积木式流水线。它不并行运行、不自动选择,也不能加速梯度计算本身。

Q5. warmup 阶段学习率的变化规律是?

单选题 · mid

  • A.从峰值线性下降到 0
  • B.从 0 线性爬升到峰值← 正确答案
  • C.始终保持恒定
  • D.正弦波振荡

答案:B。warmup 将所有初始学习率从 0 或很小值线性升到峰值,避免训练初期大步长导致参数被踢飞。下降属于退火阶段,恒定和振荡不符合 warmup 定义。

Q6. 关于权重衰减与 L2 正则化,下列说法正确的是?

多选题 · mid

  • A.在 SGD 下两者等价← 正确答案
  • B.在 Adam 下两者等价
  • C.解耦权重衰减更适合自适应优化器← 正确答案
  • D.权重衰减等同于增加学习率

答案:AC。SGD 下 L2 正则化与权重衰减在数学上等价,但 Adam 的统一缩放会破坏等价关系,所以 A 对、B 错。解耦权重衰减(AdamW)把衰减独立出来,C 对。权重衰减是抑制参数无限增大,与学习率无关,D 错。

Q7. Optax 梯度变换的“状态”包含哪些特征?

多选题 · mid

  • A.显式传递← 正确答案
  • B.由调用者保存← 正确答案
  • C.被藏在优化器内部
  • D.可以序列化和恢复← 正确答案

答案:ABD。Optax 状态显式传给调用者、由调用者保存,作为纯数据是可以被序列化的,因此 A、B、D 正确。状态绝不藏在优化器内部,C 错误。

Q8. 使用 chain(clip_by_global_norm, add_decayed_weights, scale_by_adam) 时,权重衰减项受 Adam 自适应缩放影响吗?

单选题 · hard

  • A.受影响
  • B.不受影响← 正确答案
  • C.取决于 batch_size
  • D.取决于参数数量

答案:B。衰减项在进入 scale_by_adam 之前已经被加到梯度里,因此后续会被 Adam 缩放。若想不受影响,应把 add_decayed_weights 放在 Adam 之后、仅对最终更新量加衰减。

Q9. 自定义 GradientTransformation 时,update_fn 的输入不包括以下哪项?

多选题 · hard

  • A.梯度
  • B.当前状态
  • C.全局训练步数← 正确答案
  • D.学习率← 正确答案

答案:CD。标准 update_fn 签名是 (updates, state, params),不含训练步数和学习率。步数信息需要自己编进状态里,学习率需要通过 scale_by_learning_rate 等变换引入。

Q10. 实现 AdamW 时,“解耦”的具体含义是?

单选题 · extreme

  • A.把动量和方差分开
  • B.权重衰减不参与自适应缩放← 正确答案
  • C.把学习率从优化器中移除
  • D.把梯度和参数分开存储

答案:B。AdamW 中“解耦”特指把权重衰减从梯度管道中抽离出来,使其不参与 Adam 的一阶/二阶动量归一化缩放,从而保证衰减力度均匀与可控。动量和方差的分离是 Adam 固有设计;学习率始终是独立变换;梯度和参数的存储方式与解耦无关。