3. 函数变换基础
JAX 与 NumPy 最本质的区别,不在于它能算矩阵,而在于它把“函数”本身当作可以加工的对象。JAX 提供了一组函数变换(function transformations):输入一个普通 Python 函数,输出一个具有新能力的新函数。本节要建立的,就是这四个最常用变换的定位感——它们各自解决什么问题、为什么必须组合、以及组合时对函数本身提出了什么要求。
四个变换各自的“职责边界”
先给出一张定位表,避免把四个变换混为一谈:
| 变换 | 作用对象 | 解决的问题 | 典型输入输出 |
|---|---|---|---|
jax.jit | 整个函数 | 把 Python 执行编译成 XLA 计算图,减少解释开销 | 函数 → 编译后的函数 |
jax.grad | 标量输出函数 | 自动求梯度(反向模式) | 函数 → 梯度函数 |
jax.vmap | 含批维的函数 | 把单样本函数自动向量化为批处理 | 函数 → 批处理函数 |
jax.pmap | 含批维的函数 | 把批维切分到多设备并行 | 函数 → 多设备函数 |
关键认识是:它们不是四个并列的工具,而是四个正交的“维度”。jit 管的是“怎么执行”,grad 管的是“对谁求导”,vmap 管的是“沿哪个轴并行”,pmap 管的是“在哪些设备上并行”。正因为正交,它们才能任意嵌套组合,且组合顺序往往可以互换(在满足纯函数前提下)。
为什么必须要求纯函数
JAX 的变换在“追踪”(tracing)阶段执行你的 Python 代码,此时参数是抽象的 tracer 而非真实数值。这意味着函数体内不能依赖真实数值做控制流,也不能有副作用。具体来说,以下写法会出问题:
- Python 副作用:在函数里
print、修改全局列表、写文件。追踪时只执行一次,实际运行时不会按预期重复。 - 依赖具体值的分支:
if x > 0:在 jit 下会因 tracer 无法比较而报错,应改用jax.lax.cond或jnp.where。 - 原地修改数组:
arr[0] = 1不被支持,应使用arr.at[0].set(1)的函数式写法。 - 随机数:不能用全局状态,必须显式传入
PRNGKey。
组合方式:从单样本到批量训练
考虑一个最小但完整的例子:线性回归的单样本损失。先定义纯函数形式的损失:
import jax
import jax.numpy as jnp
def loss(params, x, y):
w, b = params
pred = w * x + b
return (pred - y) ** 2
现在逐层叠加变换。第一步,对参数求梯度:
grad_fn = jax.grad(loss) # 对标量输出求导,返回 (dw, db)
第二步,把“单样本”变成“一批样本”。注意 in_axes 指定每个参数沿哪个轴批处理,params 不批处理所以是 None:
batch_grad = jax.vmap(grad_fn, in_axes=(None, 0, 0))
# 输入: params, x[batch], y[batch]
# 输出: 每个样本的梯度, 形状 (batch, 2)
第三步,把批梯度聚合成一次参数更新,并整体编译:
def step(params, x, y, lr=0.1):
grads = batch_grad(params, x, y) # (batch, 2)
g = jax.tree.map(lambda t: t.mean(axis=0), grads)
return jax.tree.map(lambda p, gi: p - lr * gi, params, g)
step_jit = jax.jit(step)
这里 jax.tree.map 对参数 pytree(元组 (w, b))逐叶子操作,是 JAX 处理结构化参数的惯用手法。整个 step 被 jit 编译后,vmap 与 grad 都已被融合进同一张 XLA 图,运行时只有一次编译调用。
变换链的嵌套结构
下图把上面的组合关系画成一条“变换链”,重点在于看清每个变换作用在哪一层、输入输出的形状如何变化:
组合顺序与常见陷阱
上面的链是 jit(vmap(grad(loss)))。由于四个变换正交,很多顺序在数学上等价,但工程上仍有讲究:
- jit 放最外层:让 vmap 和 grad 的展开都发生在编译图内部,避免多次编译。若把 jit 放内层,外层 vmap 会触发多次编译,反而变慢。
- grad 与 vmap 可互换:
vmap(grad(f))与grad(vmap(f))在 f 为逐样本标量时结果一致,但前者更直观,且能避免对批维求和的歧义。 - pmap 替代 vmap 的位置:当需要多设备时,把
vmap换成pmap,但 pmap 要求批维大小等于设备数,且函数内不能有跨设备通信以外的隐式依赖。
jit(vmap(grad(f))),确认输出形状与数值合理,再切换到 pmap 或更大批量。形状错误几乎总是 in_axes 写错导致的。一个可复现的验证片段
下面这段代码可以直接运行,用来验证变换链的输出形状与梯度正确性(数值为示例,非实测):
params = (jnp.array(0.5), jnp.array(0.1))
x = jnp.array([1.0, 2.0, 3.0, 4.0])
y = 2.0 * x + 1.0
g = batch_grad(params, x, y)
print(g.shape) # (4, 2),每个样本一组 (dw, db)
print(step_jit(params, x, y)) # 更新后的 (w, b)
预期观察:g.shape 为 (4, 2),说明 vmap 正确地把 4 个样本各自求导;step_jit 返回的 w 应向 2.0 靠近、b 应向 1.0 靠近。若形状是 (2,),说明 vmap 的 in_axes 漏了样本轴;若运行报 tracer 比较错误,说明函数体内混入了依赖具体值的 Python 分支。
把四个变换理解为“执行方式、求导、批并行、设备并行”四个正交维度,再牢记纯函数这一前提,就能在后续的优化器、训练循环与多设备扩展中,稳定地把它们像积木一样组合起来,而不至于在形状或追踪错误上反复踩坑。