Editing a Network by Hand, in JAX/Flax NNX

· 11 min read

#ml#kernels#interpretability#prototypes#continual-learning#machine-unlearning#yat#jax#flax#nnx#implementation#deep-learning

Part 3 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.this post's explainer
  4. 4You Only Have to Train the Features
  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 companionYour Network Is a List of Pictures. You Can Edit It.Want the full intuition first? This is the runnable companion to the explainer.Read the explainer

The explainer argued that a Yat-kernel network is a list of prototype pictures, so you can teach it a class by adding pictures and make it forget one by deleting them, with no training. This is the implementation: build the network in Flax NNX, then watch teaching reduce to a jnp.concatenate and forgetting to a boolean mask. Every figure and number below is from a real run; the script is scripts/yat_editable_fmnist.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 layer, and what its rows are

If teaching and forgetting are going to be array edits, the array has to be shaped for it: one prototype to a row, and nothing about a class smeared anywhere else. A Yat unit scores an input against a prototype WuW_u with the kernel (Wux+b)2/(xWu2+ε)(W_u^\top x + b)^2 / (\lVert x - W_u\rVert^2 + \varepsilon), a layer is a bank of them, and the readout rows record which class each prototype speaks for. The prototypes are the rows of W, each row’s class a row of A, and editing the network will turn out to be nothing more than editing those rows.

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


class YatEdit(nnx.Module):
    """A bank of prototype rows W and their class assignments A. No activation function."""
    def __init__(self, W, A, *, b=0.5, eps=0.05):
        self.W = nnx.Param(jnp.asarray(W))            # [K, 784]  prototype pictures
        self.A = nnx.Param(jnp.asarray(A))            # [K, 10]   one-hot: row u's class
        self.b, self.eps = b, eps

    def kernel(self, x):                              # [n, K] resemblance of each input to each prototype
        dot = x @ self.W.value.T
        d2 = (x ** 2).sum(-1, keepdims=True) + (self.W.value ** 2).sum(-1) - 2 * dot
        return (dot + self.b) ** 2 / (d2 + self.eps)

    def __call__(self, x):                            # [n, 10] class scores
        k = self.kernel(x)                            # [n, K]
        per_class = jnp.where(self.A.value.T[None] > 0, k[:, None, :], -jnp.inf)
        return per_class.max(-1)                      # nearest prototype within each class

There is no nnx.Linear, no nonlinearity, and crucially no entanglement: prototype uu lives entirely in row uu of W and row uu of A. The decision rule is nearest-prototype within each class: class cc is scored by its best-matching row, sc(x)=maxu:class(u)=cϕu(x)s_c(x) = \max_{u\,:\,\text{class}(u)=c} \phi_u(x), the mode='max' readout in scripts/yat_editable_fmnist.py and the rule behind every number in this post. The explainer writes the same structure as a pure linear readout, s(x)=Aϕ(x)s(x) = A^\top \phi(x), a sum over each class’s rows instead of a max; that view is worth keeping (it is what a linear layer can express, and it is where the field-superposition picture comes from), but it scores a few points lower, 68% against 79% at this scale. What every edit below actually rides on is shared by both readouts: class cc‘s score is computed from class cc‘s rows and no others, so appending rows extends one class and masking rows removes one, with zero cross-talk.

Build it by hand

Before you can edit a network you need one to edit, and this one arrives without training. A few k-means centroids per class become the prototypes, each one’s readout row wired to one-hot, and no optimizer touches it.

import numpy as np, torchvision
from sklearn.cluster import KMeans

tr = torchvision.datasets.FashionMNIST("/tmp/fmnist", train=True, download=True)
te = torchvision.datasets.FashionMNIST("/tmp/fmnist", train=False, download=True)
X = tr.data.numpy().reshape(-1, 784).astype("float32") / 255.0; y = tr.targets.numpy()
Xte = te.data.numpy().reshape(-1, 784).astype("float32") / 255.0; yte = te.targets.numpy()

def prototypes(classes, per=20):
    W, lab = [], []
    for c in classes:
        W += list(KMeans(per, n_init=3, random_state=0).fit(X[y == c][:2500]).cluster_centers_)
        lab += [c] * per
    return np.array(W, "float32"), np.array(lab)

def build(classes, per=20):
    W, lab = prototypes(classes, per)
    A = np.eye(10)[lab].astype("float32")             # row u votes one-hot for class lab[u]
    return YatEdit(W, A)

model = build(range(10))
acc = float((jnp.argmax(model(jnp.asarray(Xte)), -1) == yte).mean())
print("zero-training accuracy:", round(acc * 100, 1))  # 79.4

The network classifies before any training, because it is a kernel machine from the first step. Geometrically, as the explainer frames it, the kernel’s 1/(xWu2+ε)1/(\lVert x - W_u\rVert^2 + \varepsilon) is a softened inverse-square pull, so each prototype is a mass and classifying a point is letting it fall into the deepest basin it feels.

Test points released as particles in a 2D map of Fashion-MNIST, falling into the basins of the classes the network assigns them
Classification as settling, on real Fashion-MNIST laid out by UMAP. Each particle is a test image released into the prototype field; it falls into the basin of the class the network (in full 784 dimensions) assigns it, and the ten basins fill with colour. The forward pass is this descent into the nearest well. Rendered by scripts/render_yat_edit_gifs.py.

Everything from here is editing that field.

Teaching a class is a concatenate

Teaching a deployed model a new class is supposed to be the dangerous direction, the one that quietly wrecks the classes it already knows. Here it is a stack of rows: build the new class’s prototypes and their one-hot readout rows, concatenate them on, and stop. That is the entire operation.

def add_class(model, c, per=20):
    Wc, _ = prototypes([c], per)
    Ac = np.eye(10)[[c] * per].astype("float32")
    model.W = nnx.Param(jnp.concatenate([model.W.value, jnp.asarray(Wc)], 0))
    model.A = nnx.Param(jnp.concatenate([model.A.value, jnp.asarray(Ac)], 0))
    return model

eight = build(range(8))                                # a network that has never seen Bag or Boot
mask8 = np.isin(yte, range(8))
print("8 classes:", round(float((jnp.argmax(eight(jnp.asarray(Xte[mask8])), -1) == yte[mask8]).mean()) * 100, 1))  # 77.8%

for c in (8, 9):                                       # teach Bag, then Boot, no gradient steps
    add_class(eight, c)

def recall(m, c):
    sel = yte == c
    return round(float((jnp.argmax(m(jnp.asarray(Xte[sel])), -1) == c).mean()) * 100)

print("Bag, Boot recall:", recall(eight, 8), recall(eight, 9))                                  # 95, 94
print("all 10:", round(float((jnp.argmax(eight(jnp.asarray(Xte)), -1) == yte).mean()) * 100, 1))  # 79.4
print("from scratch:", round(float((jnp.argmax(build(range(10))(jnp.asarray(Xte)), -1) == yte).mean()) * 100, 1))  # 79.4

The two classes it had never encountered are recognized at 95% and 94% the moment their rows are appended, and the classes it already knew barely move. The reason is one line of the algebra: the appended rows are one-hot at the new class cc, so no other class’s score ever reads them. For every ccc' \ne c, sc(x)s_{c'}(x) is still the max (or the sum) over exactly the rows it had before, unchanged, while sc(x)s_c(x) alone gains the new candidates. Old-class scores are invariant under the concatenate. The headline is the last two lines: the network taught incrementally and the network built on all ten at once reach the same 79.4%, because a max, like a sum, does not remember the order its arguments arrived in. There is no penalty for incrementality, and no rehearsal buffer in sight.

A 2D map of Fashion-MNIST where basins of attraction grow in one class at a time as their prototype masses fade into the landscape, with no training
Teaching as basins forming, on real Fashion-MNIST laid out by UMAP, with zero gradient steps. Each class you teach fades in its prototype masses and a new basin grows into the landscape, claiming its territory; the accuracy in the title is the real 784-dimensional number. Because the fields superpose, the basins already present do not shift when a new one appears. Rendered by scripts/render_yat_edit_gifs.py.

Forgetting a class is a mask

Unlearning is the same move in reverse. Find the rows assigned to the class, drop them. Because class cc lives only in the rows whose readout is one-hot at cc, the deletion is exact, and this time we can hold the explainer to its promise: it said you could diff the weights afterward to prove nothing else moved, so snapshot them before the edit and check.

def forget_class(model, c):
    keep = np.asarray(model.A.value.argmax(-1)) != c   # rows NOT assigned to class c
    model.W = nnx.Param(model.W.value[keep])
    model.A = nnx.Param(model.A.value[keep])
    return model

m = build(range(10))
others = np.isin(yte, [c for c in range(10) if c != 5])
def acc_others(m):
    return round(float((jnp.argmax(m(jnp.asarray(Xte[others])), -1) == yte[others]).mean()) * 100, 1)

W0, A0 = np.asarray(m.W.value), np.asarray(m.A.value)  # snapshot before the edit
before = acc_others(m)
kept = A0.argmax(-1) != 5
forget_class(m, 5)                                     # forget Sandal

print("Sandal recall:", recall(m, 5))                  # 0
print("other 9 classes:", before, "->", acc_others(m)) # 81.1 -> 81.3
assert (np.asarray(m.W.value) == W0[kept]).all()       # surviving rows: bit-for-bit identical
assert (np.asarray(m.A.value) == A0[kept]).all()
print("weight diff over surviving rows:", float(np.abs(np.asarray(m.W.value) - W0[kept]).max()))  # 0.0

Sandal recall goes to 0: nothing in the network resembles a sandal anymore, so nothing can be labelled one. The other nine classes go from 81.1% to 81.3%, untouched, a hair better because the deleted rows are no longer around to occasionally steal a neighbour’s test image. And the two asserts are the unlearning proof, delivered: every surviving row of W and A is equal, bit for bit, to the row it was before the edit, a weight diff of exactly 0.0. The structural identity behind it: for c5c' \ne 5, sc(x)s_{c'}(x) is computed from class cc'‘s rows alone, whether you take their max or their sum, and never indexed a Sandal row, so masking those rows out is a no-op on it. This is the line between exact unlearning and the approximate kind: not “we reduced its influence and hope,” but “the rows are not in the matrix, check.”

A 2D map of Fashion-MNIST where the Sandal basin collapses as its prototype masses fade to zero and neighbouring basins flood in to reclaim the territory, the rest unchanged
Forgetting as a basin collapsing, in JAX, by fading the Sandal prototype masses to zero. Its well vanishes and the neighbouring basins flood in to reclaim exactly the territory it held, while the rest of the landscape does not move, the other nine classes’ recall shifting by tenths of a point. Exact unlearning, as a boolean mask in the code and a removed mass in the field. Rendered by scripts/render_yat_edit_gifs.py.

Why the edits are exact, in one sentence

In an ordinary network the knowledge of a class is smeared across shared weights, so adding a class with gradient descent overwrites a little of every other class (catastrophic forgetting) and deleting one is a research problem (machine unlearning) whose exact baseline is to retrain from scratch. Here a class is a contiguous block of rows, so protecting it during an edit is do not index those rows and erasing it is index them out. jnp.concatenate and a boolean mask are the entire continual-learning and unlearning stack, because the architecture stores knowledge in a form you can address.

What this leaves out

These rows are prototype pictures because the input space is pixel space; that is what makes the edits legible and is also the limit. Put the Yat kernel on top of learned features and a prototype becomes a feature-space exemplar, the ProtoPNet recipe (Chen et al., 2019), and those features still cost a training run, so the no-training claim narrows to no-training-of-the-head. That boundary, how much of a network you can construct rather than optimize, is the subject the explainer leaves open. What does not change is the object: a layer whose unit stores a row you can read, append, and delete, so that on images the row is a picture and editing the network is editing a list.


The prototype-network idea (“this looks like that”) is from Chen et al. (2019); machine unlearning from Bourtoule et al. (2021); Fashion-MNIST from Xiao et al. (2017); the Yat kernel from Bouhsine (2026). The conceptual companion is Your Network Is a List of Pictures. You Can Edit It..

Cite as

Bouhsine, T. (). Editing a Network by Hand, in JAX/Flax NNX. Records of the !mmortal Data Scientist. https://tahabouhsine.com/blog/edit-a-network-jax-flax-nnx/

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

References

  1. 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
  2. Bourtoule, L., Chandrasekaran, V., Choquette-Choo, C. A., Jia, H., Travers, A., Zhang, B., Lie, D., Papernot, N. (2021). Machine Unlearning. IEEE S&P 2021.arXiv:1912.03817
  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