Lazy Training a Yat Network in JAX/Flax NNX
#kernels#random-features#lazy-training#yat#jax#flax#nnx#implementation
Part 13 of 13The Prototype Network
- 1What a Finite Kernel Buys an MLP
- 2Your Neuron Is a Direction. It Should Be a Picture.
- 3Your Network Is a List of Pictures. You Can Edit It.
- 4You Only Have to Train the Features
- 5You Don't Even Have to Train the Features
- 6How Far Down Can You Build?
- 7When 80% Should Mean 80%
- 8A Risk Model That Names Its Reasons
- 9The White-Box Survival Model on Trial
- 10Your Network Is a Stack of Layers. It Could Be a Fixed Point.
- 11Edit One Operator, Edit Every Depth
- 12One Kernel, Fitted Twice
- 13How Many Random Neurons Buy a Trained One?this post's explainer
The explainer freezes a bank of random Yat units and asks how many frozen neurons buy a trained one. This is the implementation side: the layer, the freeze, the fairness protocol, and the telemetry, all from scripts/yat_lazy.py, the script the Kaggle bundles ran (kgl_blog-yatlazy-v1 and its audits); figures are rendered from the bundles by scripts/render_lazy_gifs.py.
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)
Freezing is not a stop_gradient scattered through the forward pass; in NNX it is a filter. The frozen arms declare that only the head’s parameters are trainable, hand that filter 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 scripts/yat_lazy.py 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 by scripts/render_lazy_gifs.py from raw Fashion-MNIST with the run’s exact layer math; the analysis and the redundancy measurement live in scripts/export_yat_lazy_viz.py.
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