真实模型不只有一块权重,而是许多层、许多矩阵和偏置。PyTree 让 JAX 能把嵌套容器当成一个整体处理,同时仍能访问其中每个数组叶子。

1. 什么是 PyTree

PyTree 不是某个具体类,而是一类树状结构:字典、列表、元组等容器是内部节点,数组等值是叶子。

import jax
import jax.numpy as jnp

params = {
    "embedding": jnp.zeros((100, 32)),
    "block": {
        "attn": {
            "wq": jnp.zeros((32, 32)),
            "wk": jnp.zeros((32, 32)),
            "wv": jnp.zeros((32, 32)),
        },
        "mlp": [
            jnp.zeros((32, 128)),
            jnp.zeros((128, 32)),
        ],
    },
}

leaves = jax.tree.leaves(params)
print(len(leaves))

字典的键和容器结构不是模型参数;最底部的数组才是需要计算、求导和更新的叶子。

2. 为什么模型参数天然是一棵树

Transformer 的结构本来就是分层的:模型包含若干 block,每个 block 包含 attention 和 MLP,attention 又包含 Q/K/V/O 投影。PyTree 只是把这种结构直接反映到代码里。

下面是 Transformer 参数层次的结构示意,其中的 ... 表示尚未展开的参数子树,并非可直接运行的代码:

params = {
    "token_embedding": ...,          # [V, D]
    "blocks": [
        {"attn": {...}, "mlp": {...}, "norm": {...}},
        {"attn": {...}, "mlp": {...}, "norm": {...}},
    ],
    "final_norm": ...,
    "lm_head": ...,                 # [D, V]
}

3. 对整棵树做同一种操作

scaled = jax.tree.map(lambda x: x * 0.1, params)

def sgd_step(params, grads, lr):
    return jax.tree.map(lambda p, g: p - lr * g, params, grads)

两棵树一起 map 时,它们的结构必须一致。jax.grad 对参数树求导后,返回的梯度通常与参数具有相同树结构,这是优化器能统一更新所有参数的原因。

4. 统计参数量

def count_params(params):
    return sum(x.size for x in jax.tree.leaves(params))

估算模型规模,得到树里有多少个参数,整个参数量,矩阵的一个数值就算一个参数。比如 token embedding 的参数量是 V * D;一个不含 bias 的注意力层若有 Q/K/V/O 四个 [D, D] 矩阵,就是约 4D²;常见两层 MLP 若扩展到 4D,约为 8D²。

5. 初始化一个线性层

def init_linear(key, in_dim, out_dim, scale=0.02):
    w = scale * jax.random.normal(key, (in_dim, out_dim))
    b = jnp.zeros((out_dim,))
    return {"w": w, "b": b}

def linear(params, x):
    return x @ params["w"] + params["b"]

初始化函数返回参数树,前向函数接收参数树。这种分离让参数依赖变得显式。

6. 初始化一个 MLP

def init_mlp(key, dim, hidden_dim):
    k1, k2 = jax.random.split(key)
    return {
        "up": init_linear(k1, dim, hidden_dim),
        "down": init_linear(k2, hidden_dim, dim),
    }

def mlp(params, x):
    h = jax.nn.gelu(linear(params["up"], x))
    return linear(params["down"], h)

注意初始化时拆分了随机 key。为什么不能重复使用同一个 key,是下一篇的重点。

7. PyTree 与结构静态性

JIT 编译通常要求树结构在一次已编译程序中保持稳定。叶子的数组值可以变化,但如果今天参数字典有 w,下一步突然多出 adapter,就可能触发重新编译或结构不匹配。

训练状态也适合放进 PyTree:

state = {
    "params": params,
    "step": jnp.array(0),
    "optimizer": {"momentum": ...},
}

其中超参数、函数、字符串等不一定适合当动态数组叶子。成熟框架会提供 dataclass 注册和静态字段机制。

8. 检查树的实用函数

shape_tree = jax.tree.map(lambda x: (x.shape, x.dtype), params)
finite_tree = jax.tree.map(lambda x: jnp.all(jnp.isfinite(x)), params)

调试大模型时,不要打印所有权重;打印 shape、dtype、均值、标准差、最大绝对值和是否包含 NaN,信息更有效。

小练习

对参数树的每个叶子计算 L2 范数。

norm_tree = jax.tree.map(lambda x: jnp.linalg.norm(x), params)

跟之前 shape_tree/finite_tree 的写法完全一致,只是 lambda 换成了计算 L2 范数的 jnp.linalg.norm。


文章作者: 易百分
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 易百分 !

评论

使用 GitHub 账号参与评论,评论将公开保存在 GitHub Issues 中。

在 GitHub 查看评论

 上一篇
JAX 101(四):显式随机数与可复现实验 JAX 101(四):显式随机数与可复现实验
解释 JAX 为什么显式管理伪随机数 key,以及如何通过 key 拆分完成参数初始化、Dropout、自回归采样和可复现实验。
下一篇 
JAX 101(二):函数变换、自动微分与批处理 JAX 101(二):函数变换、自动微分与批处理
从纯函数开始介绍 JAX 的 grad、value_and_grad、vmap、jit 与 tracing,并通过一次梯度更新理解函数变换的组合方式。
  目录