본문 바로가기
C.W.K.
Stream
Lesson 03 of 06 · published

Equinox, 모델은 곧 Pytree

~10 min · neural-nets, jax, tutorial

Level 0호기심
0 XP0/73 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete

Equinox는 모델 자체가 pytree라는 다른 철학을 택해. nn.Module 같은 거 없이, 그냥 dataclass가 곧 모델이야.

pip install equinox
import equinox as eqx
import jax
import jax.numpy as jnp

# ============ 단일 Linear ============
class Linear(eqx.Module):
    weight: jnp.ndarray
    bias: jnp.ndarray

    def __init__(self, in_dim, out_dim, key):
        wkey, bkey = jax.random.split(key, 2)
        self.weight = jax.random.normal(wkey, (out_dim, in_dim)) * 0.01
        self.bias = jnp.zeros(out_dim)

    def __call__(self, x):
        return self.weight @ x + self.bias

key = jax.random.PRNGKey(0)
layer = Linear(10, 5, key)

x = jnp.zeros(10)
y = layer(x)
print(y.shape)   # (5,)

eqx.Module = @dataclass + 자동 pytree 등록. 그게 전부야.

# model 자체가 pytree
print(jax.tree.leaves(layer))
# [array(weight), array(bias)]

# tree.map 자유롭게
zeroed = jax.tree.map(jnp.zeros_like, layer)

다층 MLP

class MLP(eqx.Module):
    layers: list   # List[Linear]

    def __init__(self, dims, key):
        keys = jax.random.split(key, len(dims) - 1)
        self.layers = [Linear(d_in, d_out, k)
                       for d_in, d_out, k in zip(dims[:-1], dims[1:], keys)]

    def __call__(self, x):
        for layer in self.layers[:-1]:
            x = jax.nn.relu(layer(x))
        return self.layers[-1](x)

key = jax.random.PRNGKey(0)
model = MLP([784, 128, 64, 10], key)
y = model(jnp.zeros(784))

학습

Equinox 모델은 pytree라, 모든 JAX 변환이 그대로 작동:

def loss_fn(model, x, y):
    pred = jax.vmap(model)(x)   # batch
    return jnp.mean((pred - y) ** 2)

@jax.jit
def train_step(model, x, y, lr):
    loss, grads = jax.value_and_grad(loss_fn)(model, x, y)
    new_model = jax.tree.map(lambda p, g: p - lr * g, model, grads)
    return new_model, loss

# loop
for step in range(100):
    model, loss = train_step(model, batch_x, batch_y, 0.01)

특이점이 없고, nnx.split 같은 번거로운 절차 없이, 모델이 그냥 pytree라 jit/grad가 직접 처리해.

filter / partition, trainable과 frozen 분리

model = MLP([784, 10], key)

# 모든 param 이 변경 가능 (default)
# 일부만 학습 — eqx.filter 사용
def loss_with_frozen(diff_model, static_model, x, y):
    model = eqx.combine(diff_model, static_model)
    return loss_fn(model, x, y)

# layer 0 은 freeze, layer 1 만 학습
diff_model, static_model = eqx.partition(model,
    lambda m: True if isinstance(m, Linear) and m is model.layers[1] else False
)

grads = jax.grad(loss_with_frozen)(diff_model, static_model, x, y)

기본 제공 계층

model = eqx.nn.Sequential([
    eqx.nn.Linear(784, 128, key=k1),
    eqx.nn.Lambda(jax.nn.relu),
    eqx.nn.Linear(128, 10, key=k2),
])

# attention block
attn = eqx.nn.MultiheadAttention(
    num_heads=8, query_size=64, key=k3,
)

🌿 Equinox의 정신

"모델도 그냥 데이터"라는 JAX의 철학을 가장 충실히 따르는 방식이야. nn.Module 같은 마법 같은 프로토타이핑 없이 eqx.Module은 그냥 dataclass + pytree 등록. 결과: 모든 JAX 변환이 부담 없이 호환돼. 단점은 PyTorch의 self.x = ... 변경 패턴이 안 돼 (그게 의도). 학습은 새 모델 객체를 매 step마다 만들어서 갱신해.

NNX와 Equinox 중 무엇을 고를지는 팀, 프로젝트, 코드 스타일에 달려 있어. 둘 다 프로덕션에 적용할 수 있어. JAX 코어가 같으니, 한쪽을 익히면 다른 쪽도 빠르게 따라잡을 수 있어.

Code

import equinox as eqx
import jax
import jax.numpy as jnp

class MLP(eqx.Module):
    layers: list
    dropout: eqx.nn.Dropout

    def __init__(self, in_dim, hidden_dim, out_dim, *, key):
        k1, k2, k3 = jax.random.split(key, 3)
        self.layers = [
            eqx.nn.Linear(in_dim, hidden_dim, key=k1),
            eqx.nn.Linear(hidden_dim, out_dim, key=k2),
        ]
        self.dropout = eqx.nn.Dropout(p=0.2)

    def __call__(self, x, key=None):
        x = self.layers[0](x)
        x = jax.nn.relu(x)
        x = self.dropout(x, key=key)
        x = self.layers[1](x)
        return x

# Create model — key for parameter initialization
model = MLP(784, 256, 10, key=jax.random.key(0))

# Call it directly
x = jnp.ones((784,))
y = model(x, key=jax.random.key(1))
print(y.shape)  # (10,)
@eqx.filter_jit
@eqx.filter_grad
def compute_loss(model, x, y):
    pred = jax.vmap(model)(x)
    return jnp.mean((pred - y) ** 2)

# filter_grad automatically differentiates only w.r.t. arrays,
# leaving static fields (dropout rate, etc.) untouched
grads = compute_loss(model, x_batch, y_batch)

# Update with optax
import optax
optimizer = optax.adam(1e-3)
opt_state = optimizer.init(eqx.filter(model, eqx.is_array))

# Apply updates
updates, opt_state = optimizer.update(grads, opt_state, model)
model = eqx.apply_updates(model, updates)
# Split model into trainable and static parts
params, static = eqx.partition(model, eqx.is_array)
# params: same tree structure, but non-arrays replaced with None
# static: same tree structure, but arrays replaced with None

# Recombine
model = eqx.combine(params, static)

# Freeze specific layers
def freeze_first_layer(model):
    # Use tree_at to target specific parts
    filter_spec = jax.tree.map(lambda _: True, model)
    filter_spec = eqx.tree_at(
        lambda m: m.layers[0],
        filter_spec,
        replace=jax.tree.map(lambda _: False, model.layers[0])
    )
    return filter_spec

External links

Exercise

레슨 10-2와 같은 작업을 Equinox로 구현해. Linear를 만들고 한 스텝 학습한 뒤 API를 비교해. '모델이 곧 pytree'인 Equinox와 변경 가능한 상태 모델을 쓰는 Flax의 차이를 설명하고, 새 연구를 시작한다면 어느 쪽을 고를지 이유와 함께 적어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.