Constructing the Head on Learned Features, in JAX/Flax NNX

· 8 min read

#ml#kernels#representation-learning#transfer-learning#jax#flax#nnx#implementation#prototypes#yat#deep-learning

Part 4 of 12The Prototype Network
  1. 1What a Finite Kernel Buys an MLP
  2. 2Your Neuron Is a Direction. It Should Be a Picture.
  3. 3Your Network Is a List of Pictures. You Can Edit It.
  4. 4You Only Have to Train the Featuresthis post's explainer
  5. 5You Don't Even Have to Train the Features
  6. 6How Far Down Can You Build?
  7. 7When 80% Should Mean 80%
  8. 8A Risk Model That Names Its Reasons
  9. 9The White-Box Survival Model on Trial
  10. 10Your Network Is a Stack of Layers. It Could Be a Fixed Point.
  11. 11Edit One Operator, Edit Every Depth
  12. 12One Kernel, Fitted Twice
Explainer companionYou Only Have to Train the FeaturesWant the full intuition first? This is the runnable companion to the explainer.Read the explainer

The explainer split a network into a backbone that builds features and a head that classifies, trained only the backbone, and built the head by hand. This is the implementation in JAX. The backbone is a small Flax NNX convolutional network with a normal training loop; the head is a constructed Yat vote placed on the frozen features, with no gradient steps. The whole point is to run both and see how little the head needs. Every number is from a real run; the script is scripts/jax_train_features.py.

Prefer a notebook? Run the whole thing on Kaggle: the same code, block by block, with the math written out and every figure reproduced from a real run.

The two stages, as two modules

Where does the explainer’s split land in code? In two modules. The backbone is the part we train, two conv-and-pool blocks down to a 64-dimensional feature; the head is a plain linear layer for the trained baseline, and later a placed Yat vote for the constructed version.

import jax, jax.numpy as jnp, optax
from flax import nnx

class Backbone(nnx.Module):
    """Two conv-pool blocks to a 64-d feature. This is the only part that gets trained."""
    def __init__(self, rngs):
        self.c1 = nnx.Conv(1, 16, (3, 3), padding='SAME', rngs=rngs)
        self.c2 = nnx.Conv(16, 32, (3, 3), padding='SAME', rngs=rngs)
        self.lin = nnx.Linear(32 * 7 * 7, 64, rngs=rngs)

    def __call__(self, x):                               # x: [N,28,28,1]
        x = nnx.max_pool(nnx.relu(self.c1(x)), (2, 2), (2, 2))
        x = nnx.max_pool(nnx.relu(self.c2(x)), (2, 2), (2, 2))
        return nnx.relu(self.lin(x.reshape(x.shape[0], -1)))   # [N,64]

class Net(nnx.Module):
    def __init__(self, rngs):
        self.feat = Backbone(rngs); self.head = nnx.Linear(64, 10, rngs=rngs)
    def __call__(self, x): return self.head(self.feat(x))

The head you build instead of train

The constructed head needs no module of its own; it is a function of the frozen features. Z-score them, place twenty k-means prototypes per class, and classify a test point by the nearest prototype under the Yat kernel. Not one gradient step is involved.

from sklearn.cluster import KMeans

def build_head(Ftr, ytr, Fte, per=20):
    mu, sd = Ftr.mean(0), Ftr.std(0) + 1e-6
    Ztr, Zte = (Ftr - mu) / sd, (Fte - mu) / sd
    W, vote = [], []
    for c in range(10):
        W += list(KMeans(per, n_init=2, random_state=0).fit(Ztr[ytr == c][:2500]).cluster_centers_)
        vote += [c] * per
    W = np.array(W, 'float32'); vote = np.array(vote)
    dot = Zte @ W.T
    d2 = (Zte ** 2).sum(1, keepdims=True) + (W ** 2).sum(1) - 2 * dot
    ker = (dot + 0.5) ** 2 / (d2 + np.median(d2) * 0.1)
    score = np.full((len(Zte), 10), -1e9)
    for c in range(10): score[:, c] = ker[:, vote == c].max(1)
    return score.argmax(1)

The surprise, before any training

What happens if the backbone never trains at all? That is the cleanest version of the claim, so run it first. Take a backbone with random weights, never trained, and build the head on the garbage features that come out. You would expect nothing, and the trained linear head on those features confirms it: it sits at chance. But the constructed head sorts them anyway.

rnd = Net(nnx.Rngs(0))
con_rand = accuracy(build_head(feats(rnd, Xtr), ytr, feats(rnd, Xte)), yte)
print(con_rand)   # 72.7%

72.7% from a network that never saw a gradient. Random convolutional features are a known free lunch: even an untuned stack of filters keeps enough class structure for a head that only has to read it.

Test images sorted by two heads on the same random-backbone features into ten class bins: a coin flip sits at 10% while the built Yat head sorts them into the right bins at 73%
The free lunch. The same random-backbone features, sorted by two heads into ten class bins. A coin flip lands them anywhere, at the 10% you would expect from a network that never trained; the built Yat head puts the very same features into the right bins at 73%, each dot coloured by its true class so a same-coloured column is a hit. The structure was already in the random filters; the head only had to read it. Rendered by scripts/render_train_features_gifs.py.

Train the backbone, compare the heads

Now the ordinary training loop, but on the backbone only. Flax NNX makes the step a few lines: a loss, its gradient, an optimizer update.

net = Net(nnx.Rngs(0)); opt = nnx.Optimizer(net, optax.adam(1e-3), wrt=nnx.Param)

@nnx.jit
def step(net, opt, xb, yb):
    def loss(net):
        return optax.softmax_cross_entropy_with_integer_labels(net(xb), yb).mean()
    l, g = nnx.value_and_grad(loss)(net); opt.update(net, g); return l

for ep in range(6):
    perm = np.random.RandomState(ep).permutation(len(Xtr))
    for i in range(0, len(Xtr) - 256, 256):
        j = perm[i:i + 256]; step(net, opt, Xtr[j], jnp.asarray(ytr[j]))

After six epochs the trained head reaches 87.7%. Freeze the same backbone, throw the trained head away, and build a fresh Yat head on its features: 85.9%, with no head training at all. Training the classifier bought under two points; the other seventy-five came from the backbone.

trained = accuracy(net, Xte, yte)                       # 87.7%
con = accuracy(build_head(feats(net, Xtr), ytr, feats(net, Xte)), yte)   # 85.9%

So the curve runs from 72.7% on random features to 85.9% on trained ones, all read by a head that was never trained, always a point or two under the trained head and never more. (The explainer’s 74 / 83.2 / 85.7 are from its reference run, the PyTorch pipeline in scripts/construct_vs_optimize.py; the numbers on this page are this post’s own JAX run. The story is identical, the last digits move with the framework and the seed.) The accuracy lives in the representation. The classifier, and its edits, are furniture you place.

A 2D cloud of Fashion-MNIST test features condensing smoothly from a tangle into separated class droplets as the backbone trains, with a thermometer showing the built head's accuracy climbing
What training the backbone does, read the whole way by a head that is never trained. Each dot is a test image, placed by the backbone’s features at that moment. The untrained cloud is a warm tangle the built head reads at 73%; as the backbone trains the classes condense into droplets and the same built head climbs to 86%, with no head training at any point. Rendered by scripts/render_train_features_gifs.py.
The same 2D feature cloud over training epochs, each point colored green if the never-trained built head classifies it correctly and red if not, the red points turning green as the backbone trains
The same untangling, scored. Now each point is coloured by the never-trained built head’s real verdict: green if it gets that test image right, red if it misses. The head is frozen the whole time, yet its mistakes melt away, from 206 misses to about 90, purely because the backbone underneath is sharpening the features it reads. Rendered by scripts/render_train_features_gifs.py.
Two accuracy curves over training epochs: the built head starts high at 73% and the trained head climbs from chance to finish only a couple of points above it
The two heads, read off the same backbone as it trains. The built head is good immediately, at 73% before a single gradient step, because it just reads the features. The trained head starts at chance and has to climb, and after six epochs it finishes only a point or two ahead. The gap is all training the head ever buys. Rendered by scripts/render_train_features_gifs.py.
A bar chart on one frozen trained backbone with three heads side by side: a random linear head at 16%, the built Yat head at 86%, and the trained linear head at 88%
The thesis in one chart. Freeze the trained backbone and bolt on three different heads, side by side. A random, untrained linear head reads its features at 16%, near chance; the built Yat head, placed with no gradient, reads them at 86%; the trained linear head, at 88%. Once the features are good, the head is furniture: any reasonable one recovers almost all the accuracy, and training it buys the last point or two. Rendered by scripts/render_train_features_gifs.py.

The strength of random convolutional features is from Saxe et al. (2011); the prototype head from Chen et al. (2019); Fashion-MNIST from Xiao et al. (2017); the Yat kernel from Bouhsine (2026). The conceptual companion is You Only Have to Train the Features.

Cite as

Bouhsine, T. (). Constructing the Head on Learned Features, in JAX/Flax NNX. Records of the !mmortal Data Scientist. https://tahabouhsine.com/blog/train-the-features-jax-flax-nnx/

BibTeX
@misc{bouhsine2026trainthefeaturesjaxflaxnnx,
  author       = {Bouhsine, Taha},
  title        = {Constructing the Head on Learned Features, in JAX/Flax NNX},
  year         = {2026},
  month        = {jun},
  howpublished = {\url{https://tahabouhsine.com/blog/train-the-features-jax-flax-nnx/}},
  note         = {Blog post, Records of the !mmortal Data Scientist}
}

References

  1. Saxe, A. M., Koh, P. W., Chen, Z., Bhand, M., Suresh, B., Ng, A. Y. (2011). On Random Weights and Unsupervised Feature Learning. ICML 2011.
  2. Chen, C., Li, O., Tao, D., Barnett, A., Su, J., Rudin, C. (2019). This Looks Like That: Deep Learning for Interpretable Image Recognition. NeurIPS 2019.arXiv:1806.10574
  3. Xiao, H., Rasul, K., Vollgraf, R. (2017). Fashion-MNIST: a Novel Image Dataset for Benchmarking Machine Learning Algorithms. arXiv:1708.07747
  4. Bouhsine, T. (2026). A Universal Reproducing Kernel Hilbert Space from Polynomial Alignment and IMQ Distance. arXiv:2605.03262