The Hand-Built Network, in JAX/Flax NNX

· 10 min read

#ml#kernels#computer-vision#hog#jax#flax#nnx#implementation#prototypes#yat#deep-learning

Part 5 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 Features
  5. 5You Don't Even Have to Train the Featuresthis post's explainer
  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 Don't Even Have to Train the FeaturesWant the full intuition first? This is the runnable companion to the explainer.Read the explainer

The explainer argued that you can build a whole image classifier by hand, features and all, and still reach 83.3% on Fashion-MNIST with nothing trained. This is the implementation. The feature extractor is pure JAX, a few lines of gradient and pooling; the classifier is a Flax NNX module that holds k-means prototypes and votes with the Yat kernel. There is no training loop in this post, because there is nothing to train. Every number is from a real run; the script is scripts/jax_handbuilt.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 detectors, as a convolution

Where does a hand-built feature start? As a gradient. Two Sobel kernels give the horizontal and vertical derivative of the image, and from those come the magnitude and angle of every edge. The only subtlety is the padding: we replicate the border (mode='edge') so an edge at the image boundary is not invented out of zeros.

import jax, jax.numpy as jnp

NB, GRID, NCH = 6, 7, 7                                  # 6 edge orientations + 1 corner, 7x7 patches
CENTERS = jnp.linspace(0, jnp.pi, NB, endpoint=False)   # orientation bin centres
SX = jnp.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], jnp.float32)
SY = SX.T

def _conv(imgs, k):                                     # edge-replicated 3x3 correlation
    x = jnp.pad(imgs, ((0, 0), (1, 1), (1, 1)), mode='edge')[:, None]
    return jax.lax.conv_general_dilated(x, k[None, None], (1, 1), 'VALID',
                                        dimension_numbers=('NCHW', 'OIHW', 'NCHW'))[:, 0]
Two Sobel kernels applied to a garment to produce the x and y gradient images, then the gradient field as arrows over the edge-magnitude map
The first line of the network, run. The two Sobel kernels give the derivatives gₓ and g_y at every pixel; their magnitude and angle are the gradient field on the right. This is the _conv call above, nothing more, a fixed convolution with no weights to learn. Rendered by scripts/render_handbuilt_gifs.py.

The seven channels follow directly. Each oriented-edge channel is the gradient magnitude weighted by how close the edge angle sits to that channel’s orientation; the corner channel is the product of the two gradient directions, large only where both are strong. None of this has a parameter to fit.

@jax.jit
def features(imgs):                                     # [N,28,28] in [0,1] -> [N,343]
    gx, gy = _conv(imgs, SX), _conv(imgs, SY)
    mag = jnp.sqrt(gx ** 2 + gy ** 2) + 1e-6
    ang = jnp.mod(jnp.arctan2(gy, gx), jnp.pi)          # undirected edge angle
    d = jnp.abs(jnp.mod(ang[:, None] - CENTERS[None, :, None, None] + jnp.pi / 2, jnp.pi) - jnp.pi / 2)
    edges = jnp.clip(1 - d / (jnp.pi / NB), 0, 1) * mag[:, None]   # [N,NB,28,28]
    corner = (jnp.abs(gx) * jnp.abs(gy))[:, None]
    chans = jnp.concatenate([edges, corner], 1)         # [N,NCH,28,28]
    ...
Strong edges in a Fashion-MNIST garment sorted, by angle, into six orientation bins arranged as a rose diagram
What an oriented-edge channel measures, made visual. Every strong edge in the picture belongs to the bin nearest its angle, and the six wedges are the garment’s edge signature. This is exactly the edges tensor above: gradient magnitude weighted by closeness to each of the six orientations, nothing fit. Rendered by scripts/render_handbuilt_gifs.py.

The pooling, where the dimensions are named

The maps are full resolution, far more than a classifier wants, so each gets averaged over a 7-by-7 grid of patches. After that, the feature vector is one number for every (detector, patch) pair, 343 of them, and we L2-normalise each image so contrast does not matter.

    n = imgs.shape[0]; ps = 28 // GRID
    pooled = chans[:, :, :GRID * ps, :GRID * ps].reshape(n, NCH, GRID, ps, GRID, ps).mean((3, 5))
    V = pooled.reshape(n, -1)
    return V / (jnp.linalg.norm(V, axis=1, keepdims=True) + 1e-6)    # [N,343]

The whole extractor is @jax.jit and has no state. It is a function from an image to a fixed, named vector, which is exactly the thing the explainer means by “you built the representation.”

Seven full-resolution detector maps and their 7 by 7 pooled versions, and the single 343-number feature vector they flatten into, colored by detector
The pooling step as a coarse-graining. Each detector map (top) averages from full resolution into a 7-by-7 grid of patch averages (middle), the way a fine field is read as blocks, and the 7 maps of 49 cells flatten into one vector. That vector is φ(x): 343 numbers, one per (detector, patch) pair, every axis with a name. Rendered by scripts/render_handbuilt_gifs.py.

The head, as a parameter-free NNX module

What does a classifier nobody trains look like in a framework built for training? A perfectly ordinary module. The constructed Yat head from the earlier posts is written as Flax NNX so it slots into the same code as a trained network; the difference is that its variables are placed, not learned: the rows of W are k-means centroids of the training features, and A is a one-hot table sending each prototype’s vote to its class. The forward pass is the Yat kernel against every prototype, then the per-class maximum, then an argmax.

from flax import nnx

class YatHead(nnx.Module):
    """Prototypes W (k-means centroids), one-hot votes A, nearest-prototype vote."""
    def __init__(self, W, vote, b=0.5, eps=1.0):
        self.W = nnx.Variable(jnp.asarray(W))                       # [K, 343] prototypes
        self.A = nnx.Variable(jax.nn.one_hot(jnp.asarray(vote), 10))
        self.b, self.eps = b, eps

    def __call__(self, z):                                         # z: [N, 343] features
        dot = z @ self.W.value.T
        d2 = (z ** 2).sum(1, keepdims=True) + (self.W.value ** 2).sum(1) - 2 * dot
        ker = (dot + self.b) ** 2 / (d2 + self.eps)                # the Yat kernel
        scores = jnp.where(self.A.value.T[None].astype(bool), ker[:, None, :], -jnp.inf).max(-1)
        return scores.argmax(-1)
The Yat kernel drawn as a 2D field and as a 1D cross-section, a finite peak at the prototype with heavy inverse-square tails, compared to a true inverse-square that blows up and a Gaussian with thin tails
The one line that does the work, (dot + b)² / (d2 + eps), drawn out. Cut perpendicular to the prototype the kernel is a softened inverse-square well: a true 1/r² blows up at the prototype, a Gaussian dies too fast in the tails, and the Yat kernel sits between them, finite at the prototype because ε softens the singularity and heavy-tailed beyond it. The ε you place sets how sharp the well is. Rendered by scripts/render_handbuilt_gifs.py.

There is no nnx.Param anywhere in it. nnx.Variable holds state that an optimizer would never touch, which is the right type for a thing you place by hand.

Building it and running it

The pieces assemble with no training step. Extract the features, z-score them, run k-means per class to get the prototypes, set the softening ε from the data’s own distance scale, and hand it all to the head. Even ε is placed, not learned: a tenth of the median squared feature-to-prototype distance, the knob the kernel-shape figure above turns.

Ftr, Fte = np.asarray(features(Xtr)), np.asarray(features(Xte))
mu, sd = Ftr.mean(0), Ftr.std(0) + 1e-6
Ftr, Fte = (Ftr - mu) / sd, (Fte - mu) / sd                        # z-score into the head's space

PER = 20; W, vote = [], []
for c in range(10):
    W += list(KMeans(PER, n_init=2, random_state=0).fit(Ftr[ytr == c][:2500]).cluster_centers_)
    vote += [c] * PER
W = np.array(W, 'float32'); vote = np.array(vote)

d2 = (Fte ** 2).sum(1, keepdims=True) + (W ** 2).sum(1) - 2 * Fte @ W.T
eps = float(np.median(d2) * 0.1)                                   # placed, like everything else

head = YatHead(W, vote, b=0.5, eps=eps)
pred = np.asarray(head(jnp.asarray(Fte)))
print(f"hand-built, no training anywhere: {100 * (pred == yte).mean():.1f}%")   # 83.3%
k-means iterations placing twelve prototypes inside each of three well-separated feature clouds (Trouser, Sneaker, Bag), the centroids settling to cluster centers
The only step that even looks like learning. Inside each class’s real feature cloud, k-means moves a handful of prototypes to the centres of nearby points, iteration by iteration, with no gradient and no labels beyond the class it runs within. The result is the rows of W: a few exemplars per class that the kernel votes against. Shown for three well-separated classes; the real head uses twenty per class. Rendered by scripts/render_handbuilt_gifs.py.

It prints 83.3%, the same number the explainer’s interactive demo computes in the browser, because it is the same pipeline. The only place a fit happens at all is k-means, which is choosing where to place the prototypes, not learning a representation. Drop even that and the floor is still high: one plain mean per class, nearest mean wins, is two lines and reaches 75.6% (the check is in the explainer’s scripts/handbuilt_vision.py).

cen = np.stack([Ftr[ytr == c].mean(0) for c in range(10)])
print(100 * (((Fte[:, None] - cen[None]) ** 2).sum(2).argmin(1) == yte).mean())   # 75.6%
Four garments, each with ten wells along the class axis whose depths are that garment's real Yat scores; the star sits in the deepest well, correct for three and a miss for the fourth
The forward pass of the head, as ten wells, for four real garments. Each well’s depth is that garment’s real Yat score for that class, the numbers the module computes, so the feature vector fixes all ten depths and the star sits at the bottom of the deepest one: that is scores.argmax, nothing simulated. Three land in the right well; the fourth is a coat scoring highest for pullover, with coat the runner-up, which is exactly why the two confuse: the real 83.3%, not a perfect classifier. Rendered by scripts/render_handbuilt_gifs.py.

What this leaves out

The k-means step is the one piece that looks like learning, and it is worth being precise: it is an unsupervised clustering of features the extractor already fixed, used only to pick prototype locations, with no gradient and no objective tied to accuracy. Everything upstream of it, the part that turns a picture into 343 numbers, is hand-written and frozen. That is the whole claim of the post made literal in code: a network whose every layer you can read, none of which was trained, that still classifies most of Fashion-MNIST correctly.


Hand-built oriented-gradient features are the HOG idea from Dalal and Triggs (2005); 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 Don’t Even Have to Train the Features.

Cite as

Bouhsine, T. (). The Hand-Built Network, in JAX/Flax NNX. Records of the !mmortal Data Scientist. https://tahabouhsine.com/blog/handbuilt-features-jax-flax-nnx/

BibTeX
@misc{bouhsine2026handbuiltfeaturesjaxflaxnnx,
  author       = {Bouhsine, Taha},
  title        = {The Hand-Built Network, in JAX/Flax NNX},
  year         = {2026},
  month        = {jun},
  howpublished = {\url{https://tahabouhsine.com/blog/handbuilt-features-jax-flax-nnx/}},
  note         = {Blog post, Records of the !mmortal Data Scientist}
}

References

  1. Dalal, N., Triggs, B. (2005). Histograms of Oriented Gradients for Human Detection. CVPR 2005.
  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