想象一下,你是一名餐厅主厨,面前摆着一堆新鲜食材——那是模型训练时不断产生的梯度。梯度就像刚切好的配菜:有的太辣(梯度爆炸)需要冲淡,有的味道不够(梯度太小)需要加料,有的需要按特定节奏下锅(学习率调度),有的则需要在出锅前撒一点点盐(权重衰减)。Optax 就是这座“中央厨房”的全套厨具:它没有一口锁死的大铁锅(无状态对象),而是给你一套不锈钢操作台和标准化工序——每道工序都是纯函数,把食材传进去、得到处理后的食材传出来,锅和铲从不私藏任何秘密。你可以把“剪辣椒”“调火力”“撒盐”“翻炒”这些独立工序按任意顺序拼成一条完整的流水线,这就是 Optax 的核心——梯度变换管道(Gradient Transformation Pipeline)。 今天,我们就从这家“中央厨房”的通风管道爬进去,看看每一道工序是怎么设计的、怎么组合的,以及如何根据自己的口味打造一条私人流水线。
职业价值:掌握 Optax 的函数式设计与组合哲学,意味着你不再被“优化器黑盒”困住——从大模型预训练到强化学习策略调优,你都能像搭积木一样自由定制训练流程,这是 JAX 生态进阶开发者的核心差异化能力。
可视化演示
展示Optax梯度变换管道的模块化组合,以及函数式状态无关设计的工作原理。
本节配有交互动效(进入视口后自动加载,约 3KB)。
梯度变换管道 chain组合:梯度裁剪→权重衰减→学习率调度→Adam更新 梯度裁剪:限制梯度范数防止爆炸 学习率调度:warmup+cosine衰减 optax.chain(clip, wd, sched, adam) 模块化组合构建自定义优化策略
函数式设计
走进这座“中央厨房”,你首先会注意到一个奇怪的现象:每口锅、每把铲在用过之后都锃亮如新,仿佛从不记得自己炒过什么菜。这与传统厨房截然不同——传统厨房里的锅会积累油渍、铲子会残留味道,就像 PyTorch 中优化器对象内部会维护自己的 state_dict。而在 Optax 的厨房里,没有一口“有记忆的锅”。每个厨具(优化器变换)都是纯函数:你把食材(梯度)递进去,它处理完递出来,自己的状态(动量、方差)则装在一个透明盒子里显式传给你保管。你完全清楚盒子里的东西,随时可以检查、保存、甚至中途换一个盒子。
这种设计的第一大好处是可预测性:同样的输入永远得到同样的输出,没有暗藏的全局变量悄悄改变菜的味道。第二大好处是 JAX 的 jit 编译器可以大胆地对整个烹饪流程做优化——因为每一步都不依赖外部隐藏状态,编译时可以放心地把整个流水线内联展开,就像把几十道工序写进一张精确到秒的 SOP 表。传统优化器对象的方法调用会打破 JIT 的编译边界,而纯函数则畅通无阻。
从数学上看,一个优化器变换可以理解为对梯度不断施加的函数组合:
回到厨房的比喻:这就是一间“透明厨房”——所有工序、所有调料、所有半成品都摆在明面上。你不再依赖某位秘传厨师握着锅铲告诉你“凭感觉放盐”,而是拿到了一张可以无限复制的标准化菜谱。函数式设计,让深度学习训练从“手工作坊”升级为“现代厨房”。
- 函数组合符号这里的 $\circ$ 表示“先执行右边、再执行左边”的复合运算符,像流水线上两道前后衔接的工序。
- 梯度输入$g$ 是当前梯度,即流水线入口处的原始食材。
- 状态显式传递$s$ 是优化器状态(如动量缓存),随每个工序流动,任何工序都无权把它藏起来。
- 最终输出$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 就像一台“扫码即出餐”的自动售货机——你投币(传入梯度),它掉出食物(更新量),同时内部齿轮转动一格(状态更新)。你不需要拆开机器看它是怎么转的,只需要知道投什么币、掉什么货。
- 输入参数结构$\text{Params}$ 表示模型的全部参数,
init_fn需要遍历它来决定状态的内存布局。 - 柯里化返回箭头向右表示
transform调用后返回的是一个“函数”,而不是一个对象。 - 更新量与状态
update_fn的输入是更新量 $\text{Updates}$ 和当前状态 $\text{OptState}$。 - 输出新状态函数返回处理后的更新量和更新后的状态,无任何隐藏副作用。
常用优化器
中央厨房的墙上挂着一排标准化菜谱,对应 Optax 中开箱即用的优化器。首先是 sgd——最简单的“家常炒菜”:梯度乘上学习率,直接更新参数。它炒出来的菜朴实无华,但火候难掌握,容易颠锅时洒出食材。接着是 adam——厨房里的“智能炒锅”:它同时监测“温度变化方向”(一阶动量)和“温度波动幅度”(二阶动量),自动调节每一勺的力度。RMSProp 则像“减摇炒锅”,专门对付剧烈颠簸的锅体;而 Lion 像“轻快炒锅”,只用梯度符号做决策,省油省气,速度飞快。
Adam 之所以成为默认选择,是因为它把“火候自动调节”做到了极致。它维护两个状态:$m_t$ 是梯度的指数滑动平均(方向感),$v_t$ 是梯度平方的指数滑动平均(波动感)。参数更新时,用方向感除以波动感的平方根,相当于在“顺风”时加速、在“颠簸”时减速。其核心公式为:
这就像智能炒锅内置了一个陀螺仪:你只需设定温度档位(学习率),锅自己会根据食材翻滚的剧烈程度微调铲子力度。你不需要理解陀螺仪内部原理,只需要知道选 Adam 通常不会砸锅。当然,Adam 也不是万能的,后面我们会看到如何给它“加料”。
- 一阶动量更新$m_t$ 是梯度方向的历史平均,$\beta_1$ 控制记住多久以前的方向,越接近 1 记忆越长。
- 二阶动量更新$v_t$ 是梯度平方的历史平均,反映最近梯度波动的剧烈程度。
- 偏差修正训练刚开始时 $m_t$、$v_t$ 被初始化为 0 会偏小,除以 $1-\beta^t$ 相当于“预热校正”,让前几步不至于迈得太小。
- 参数更新$\eta$ 是学习率(基准火力),$\epsilon$ 是防止除零的小常数,整个分式相当于“单位化”后的步长。
梯度裁剪
炖汤时最怕什么?火候失控导致汤汁扑出锅沿。训练神经网络最怕什么?梯度爆炸——某一步的梯度突然变得巨大,参数被推到一个离谱的位置,loss 瞬间变成 NaN。这时候 Optax 的“锅盖”出场了:梯度裁剪。它的逻辑极其朴素:如果梯度向量的全局范数超过阈值 $c$,就统一缩放,把它压回半径为 $c$ 的球内。就像汤要扑出来时、快速把锅盖一扣,气压把泡沫压回去。
具体公式如下:
Optax 中对应实现是 clip_by_global_norm(c),它把整个参数树的所有梯度当做一个整体来裁剪——相当于把所有汤料一起压住。还有 clip_by_block_norm,把每一层参数单独看待、逐层裁剪,相当于在每个小炖盅上分别加盖。什么时候用哪种?如果你的模型有几层特别容易爆炸(比如 Transformer 的 FFN 层),逐层裁剪更精准;如果整体训练不稳定,全局裁剪更省心。裁剪与 Adam 组合时一般放在链首,先压住爆炸势头再让动量平滑。
- 整体缩放系数$\min$ 取括号里较小者,如果梯度范数小于 $c$,系数就是 1,等于没有裁剪。
- 梯度范数$\|g\|_2$ 是所有梯度元素平方和再开方,代表这一刻所有“配料”的总强度。
- 缩放计算将总强度按比例压缩到 $c$ 以内,方向不变、大小改变。
学习率调度
如果说梯度裁剪是防止“扑锅”的锅盖,那学习率调度就是控制“火力”的旋钮。同一个菜谱,大火爆炒和小火慢炖出来的口感天差地别。深度学习训练亦然:训练初期参数离最优解很远,可以用较大的学习率快速逼近;后期接近最优点,需要调小学习率精细打磨,否则会在最优点附近来回震荡。
Optax 把学习率调度也实现为一种梯度变换。最常用的策略是 warmup + 余弦退火:前 $t_{\text{warm}}$ 步,学习率从 0 线性爬升到峰值 $\eta_{\max}$(预热阶段,防止一开始步子太大把参数踢飞);之后按余弦曲线从峰值平滑下降到 $\eta_{\min}$“余温慢炖”。其公式为:
在 Optax 中,warmup_cosine_decay_schedule 返回的是一个“函数”——输入步数、输出学习率。这个函数可以作为 scale_by_learning_rate 的系数传入,也可以拿来做任何需要数值缩放的地方。一切皆是变换、一切皆是函数,这正是函数式设计的精髓:火力表也是一道可插拔的工序。
- 线性预热在 $t < t_{\text{warm}}$ 时,学习率线性从 0 升到峰值,让模型“小口试菜”。
- 余弦退火核心$\cos$ 在 $0$ 到 $\pi$ 之间从 1 单调下降到 -1,经变换后学习率从峰值降到谷值。
- 归一化进度$\text{ratio} = \frac{t - t_{\text{warm}}}{T - t_{\text{warm}}}$ 表示预热结束后训练已走过的进度比例,从 0 到 1。
- 线性插值映射将 $[0,1]$ 映射到 $[\eta_{\min}, \eta_{\max}]$ 区间,得到当前步的实际学习率。
权重衰减
炖肉的最后一步,厨师会尝一口汤,如果味道太咸太浓,就加一点水稀释。权重衰减在训练中起的就是“加水稀释”的作用:每轮更新时,把参数本身向零点拉近一点点,防止某些权重变得过大、模型过度自信(过拟合)。经典做法是在损失函数上加 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,让衰减直接作用于原始梯度、不参与自适应缩放。这又是“积木顺序决定策略”的绝佳例证——同一个积木,放的位置不同,出来的菜完全不一样。
- 更新量记号$u_t$ 表示优化器内部计算出的更新方向(如 Adam 的 $\frac{\hat{m}_t}{\sqrt{\hat{v}_t}+\epsilon}$)。
- 衰减项$\lambda \theta_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)- 裁剪段$g_0$ 是原始梯度,
clip把它压回安全范数内,得到 $g_1$。 - 衰减段$g_1$ 加上权重衰减项 $\lambda \theta$,得到 $g_2$。
- 自适应段$g_2$ 进入 Adam 的动量与方差计算,输出归一化方向 $g_3$。
- 缩放段最后乘上当前步的学习率 $\eta_t$,得到最终更新量 $u$。
自定义优化器
理论说了一千遍,不如亲手做一道菜。现在来拼装一个属于你自己的优化器。标准流程分三步:定义变换、拼接链条、投入训练。
这段代码就是完整答案。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 复用在不同模型上。至此,你不再只是“调用优化器”,而是“设计优化策略”。
---
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 演示
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))
chain 如何把裁剪、Adam、学习率缩放组合成一条梯度变换流水线,并在小规模参数上完成训练循环。def scale_by_adam(b1=0.9, b2=0.999, eps=1e-8):定义 Adam 变换的工厂函数,返回init_fn和update_fn,对应“制作智能炒锅”的两份说明书。jax.tree_util.tree_map(lambda m, g: b1 * m + (1 - b1) * g, state.mu, grads)递归地对参数树的每个叶子执行动量更新,避免手写循环遍历。scale = jnp.minimum(1.0, max_norm / (norm + 1e-8))计算裁剪比例,1e-8防止除零。def chain(*transforms):接收多个变换,把它们按顺序串联成一条完整流水线。grads, adam_state = update_fns[0](grads, state.adam, params)依次执行各变换,前一个变换的输出作为后一个的输入。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 中的优化器本质是什么?
- A.一个持有可变状态的对象
- B.一个包含 init_fn 和 update_fn 的二元组← 正确答案
- C.一个只包含学习率的数值
- D.一个 PyTorch 优化器的 JAX 版本
答案:B。Optax 优化器是一个 GradientTransformation,本质上是 (init_fn, update_fn) 二元组,两个函数分别负责初始化状态和更新梯度。它不持有任何可变状态,因此 A 错误、B 正确。
Q2. Optax 中状态(OptState)由谁保管?
- A.优化器对象自己
- B.模型参数内部
- C.训练循环中的变量← 正确答案
- D.梯度自身的属性
答案:C。函数式设计的核心是状态显式传递,update_fn 返回新状态,由调用者(训练循环)持有并传回下一轮。优化器自己不存状态,参数内部也不存优化状态。
Q3. 下列哪个是典型的梯度裁剪函数?
- 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 的作用是什么?
- A.并行运行多个优化器
- B.把多个梯度变换串成流水线← 正确答案
- C.自动选择最优优化器
- D.加速梯度计算
答案:B。chain 把多个变换按顺序串联,前一变换的输出是后一变换的输入,形成积木式流水线。它不并行运行、不自动选择,也不能加速梯度计算本身。
Q5. warmup 阶段学习率的变化规律是?
- A.从峰值线性下降到 0
- B.从 0 线性爬升到峰值← 正确答案
- C.始终保持恒定
- D.正弦波振荡
答案:B。warmup 将所有初始学习率从 0 或很小值线性升到峰值,避免训练初期大步长导致参数被踢飞。下降属于退火阶段,恒定和振荡不符合 warmup 定义。
Q6. 关于权重衰减与 L2 正则化,下列说法正确的是?
- A.在 SGD 下两者等价← 正确答案
- B.在 Adam 下两者等价
- C.解耦权重衰减更适合自适应优化器← 正确答案
- D.权重衰减等同于增加学习率
答案:AC。SGD 下 L2 正则化与权重衰减在数学上等价,但 Adam 的统一缩放会破坏等价关系,所以 A 对、B 错。解耦权重衰减(AdamW)把衰减独立出来,C 对。权重衰减是抑制参数无限增大,与学习率无关,D 错。
Q7. Optax 梯度变换的“状态”包含哪些特征?
- A.显式传递← 正确答案
- B.由调用者保存← 正确答案
- C.被藏在优化器内部
- D.可以序列化和恢复← 正确答案
答案:ABD。Optax 状态显式传给调用者、由调用者保存,作为纯数据是可以被序列化的,因此 A、B、D 正确。状态绝不藏在优化器内部,C 错误。
Q8. 使用 chain(clip_by_global_norm, add_decayed_weights, scale_by_adam) 时,权重衰减项受 Adam 自适应缩放影响吗?
- A.受影响
- B.不受影响← 正确答案
- C.取决于 batch_size
- D.取决于参数数量
答案:B。衰减项在进入 scale_by_adam 之前已经被加到梯度里,因此后续会被 Adam 缩放。若想不受影响,应把 add_decayed_weights 放在 Adam 之后、仅对最终更新量加衰减。
Q9. 自定义 GradientTransformation 时,update_fn 的输入不包括以下哪项?
- A.梯度
- B.当前状态
- C.全局训练步数← 正确答案
- D.学习率← 正确答案
答案:CD。标准 update_fn 签名是 (updates, state, params),不含训练步数和学习率。步数信息需要自己编进状态里,学习率需要通过 scale_by_learning_rate 等变换引入。
Q10. 实现 AdamW 时,“解耦”的具体含义是?
- A.把动量和方差分开
- B.权重衰减不参与自适应缩放← 正确答案
- C.把学习率从优化器中移除
- D.把梯度和参数分开存储
答案:B。AdamW 中“解耦”特指把权重衰减从梯度管道中抽离出来,使其不参与 Adam 的一阶/二阶动量归一化缩放,从而保证衰减力度均匀与可控。动量和方差的分离是 Adam 固有设计;学习率始终是独立变换;梯度和参数的存储方式与解耦无关。