Lazy Training a Yat Network in JAX/Flax NNX
#kernels#random-features#lazy-training#yat#jax#flax#nnx#implementation
Part 9 of 9The Prototype Network
- 1Your Network Is a List of Pictures. You Can Edit It.
- 2How Much of a Fashion-MNIST Network Can You Build by Hand?
- 3How Far Down Can You Build?
- 4When 80% Should Mean 80%
- 5The White-Box Survival Model on Trial
- 6Your Network Is a Stack of Layers. It Could Be a Fixed Point.
- 7Edit One Operator, Edit Every Depth
- 8One Kernel Family, Fitted Two Ways
- 9How Many Random Neurons Buy a Trained One?this post's explainer
Freezing part of a model in NNX is one filter object, not a stop_gradient sprinkled through the forward pass, and that is the least surprising thing here. The surprise is in the fairness protocol: the seven arms wanted learning rates spanning two orders of magnitude, and the frozen Yat bank wanted the hottest rate on the grid. The explainer asks how many frozen neurons buy a trained one; below are the layer, the filter, the per-arm bracketing that keeps the answer fair, and the movement telemetry that caught the anti-lazy power law. Bundles: kgl_blog-yatlazy-v1 and its audits.
The layer, and how to freeze only part of a model
The hidden layer is the series’ standard Yat bank: prototypes as parameters, the kernel as the nonlinearity, both scalars stored raw behind a softplus so admissibility is structural:
class YatLayer(nnx.Module):
def __init__(s, d_in, m, *, rngs, init="lecun"):
if init == "lecun":
W0 = nnx.initializers.lecun_normal()(rngs.params(), (m, d_in))
else: # data-seeded: m random training images as prototypes
idx = jax.random.choice(rngs.params(), len(Xtr), (m,), replace=False)
W0 = jnp.asarray(Xtr)[idx]
s.W = nnx.Param(W0)
s.log_b = nnx.Param(jnp.full((), jnp.log(jnp.expm1(0.5))))
s.log_eps = nnx.Param(jnp.full((), jnp.log(jnp.expm1(0.5))))
def __call__(s, x):
b = jax.nn.softplus(s.log_b.value)
eps = jax.nn.softplus(s.log_eps.value)
dot = x @ s.W.value.T
d2 = (jnp.sum(x * x, -1, keepdims=True)
+ jnp.sum(s.W.value ** 2, -1) - 2 * dot)
return (dot + b) ** 2 / (jnp.maximum(d2, 0.0) + eps)
The filter is one object handed to two places. A frozen arm declares that only the head’s parameters are trainable, passes that declaration to the optimizer and to value_and_grad, and the bank never sees an update:
trainable = (nnx.All(nnx.Param, nnx.PathContains("head"))
if frozen else nnx.Param)
opt = nnx.Optimizer(model, optax.adamw(lr, weight_decay=1e-4), wrt=trainable)
loss, grads = nnx.value_and_grad(
loss_fn, argnums=nnx.DiffState(0, trainable))(model)
opt.update(model, grads)
Everything else, data order, batches, epochs, evaluation, is shared across all seven arms; the only differences are the init (lecun or data-seeded), the nonlinearity (Yat, Gaussian, or ReLU), and that one filter.
One neuron, one sample, watched
The explainer’s central reading is that a frozen random bank is a Monte Carlo estimate of a kernel, one neuron per sample. Here is the estimate concentrating, on thirty real images from three classes: at one neuron the Gram matrix is noise, and the class blocks condense out of it as neurons accumulate:

The readout does the work
With features frozen, training is a convex fit, and it looks like one. Two banks, 64 and 512 units, each with a linear readout trained by Adam inside the render script on 10,000 real images:

The ladder, the law, and the rates that found them
The full grid is ten widths by seven arms by three seeds, and its fairness protocol is worth quoting because the arms wanted wildly different learning rates. Each arm brackets its own rate at m = 256 before running its width grid:
GRIDS = {True: [3e-3, 1e-2, 3e-2], False: [3e-2, 1e-1, 3e-1, 1.0]}
for lr in GRIDS[arm in TRAINED_ARMS]:
cells.append(run(arm, 256, 0, lr=lr))
chosen[arm] = max(cells, key=lambda r: r["best_acc"])["lr"]
| arm | bracketed rate | best of the sweep |
|---|---|---|
| yat, frozen random | 1.0 | 85.3 at m = 512 |
| yat, frozen data-seeded | 0.3 | 83.0 at m = 4096 |
| relu, frozen random | 0.03 | 86.9 at m = 8192 |
| gaussian, frozen data-seeded | 0.03 | 75.2 at m = 1024 |
| yat, trained | 0.01 | 88.9 at m = 2048 |
The convex readouts tolerate rates ten to a hundred times hotter than the full network, and the frozen Yat bank’s tiny feature scale demands the hottest of all. The scoreboard and the post’s headline law, as static figures:


The movement statistic itself is three lines of telemetry, kept per unit so the median and the maximum can disagree (they barely do):
Wf = np.asarray(model.feat.W.value)
out["w_move_rel"] = float(np.linalg.norm(Wf - W0) / np.linalg.norm(W0))
mv = np.linalg.norm(Wf - W0, axis=1) / np.linalg.norm(W0, axis=1)
out["w_move_median"], out["w_move_max"] = float(np.median(mv)), float(mv.max())
Watch the prototypes travel
The trajectory bundle (kgl_blog-yatlazy-traj) retrains three arms at m = 64 with a snapshot of the first sixteen prototypes at every epoch, so the anti-lazy law can be watched instead of summarized:


Every number is from real runs on Kaggle: the main grid kgl_blog-yatlazy-v1 (seven arms, widths 16 to 8,192, three seeds, per-arm bracketed rates), the budget audits kgl_blog-yatlazy-bigm and -bigm2, the rate audits -lrsweep and -lrsweep2, and the trajectory bundle -traj (m = 64, 24 epochs, per-epoch snapshots). The Gram and readout-race figures are computed from raw Fashion-MNIST with the run’s exact layer math, and the redundancy measurement comes out of the same analysis.
Cite as
Bouhsine, T. (). Lazy Training a Yat Network in JAX/Flax NNX. Records of the !mmortal Data Scientist. https://tahabouhsine.com/blog/lazy-training-jax-flax-nnx/
BibTeX
@misc{bouhsine2026lazytrainingjaxflaxnnx,
author = {Bouhsine, Taha},
title = {Lazy Training a Yat Network in JAX/Flax NNX},
year = {2026},
month = {jul},
howpublished = {\url{https://tahabouhsine.com/blog/lazy-training-jax-flax-nnx/}},
note = {Blog post, Records of the !mmortal Data Scientist}
} References
- (2007). Random Features for Large-Scale Kernel Machines. NeurIPS 2007.
- (2019). On Lazy Training in Differentiable Programming. NeurIPS 2019.arXiv:1812.07956