4. Model surgery — nnx.surgery¶
The nnx.surgery subpackage ships five primitives that take a trained nn.Module and return a fresh module with a structural change applied. Four are function-preserving in some sense:
widenanddeepenpreserve the forward output (before any training step) within their supported sites (§5).low_rank_factorizeis function-preserving at max rank and approximate below.expand_embeddingpreserves the forward output on original token IDs.
Only drop_layer is purely chain-preserving. It changes the function the network computes; the surged module is meant to be refined via NNModel.train() to recover quality. See the table in §1 for the per-primitive breakdown.
This is the unique compositional payoff of pairing surgery primitives with a training loop in the same toolkit. Every primitive returns a fresh module, and that module is a drop-in target for NNModel.train(). expand_embedding returns the new nn.Embedding with a mask of the original rows.
1. The five primitives at a glance¶
| Primitive | Op | Function-preserving? | Returns |
|---|---|---|---|
widen(model, *, layer_name, new_width, rng_seed=0) |
Net2WiderNet — grow out_features, rescale the consumer's incoming columns |
yes — for a Linear inside nn.Sequential or FeedFwdNN.layers whose path to the next Linear holds only elementwise ops (activations, Dropout, Identity). Parity is in eval mode when dropout > 0. Softmax / LayerNorm / BatchNorm boundaries, other containers and aliased modules are rejected. |
fresh nn.Module |
deepen(model, *, after_layer_name) |
Net2DeeperNet — identity-init Linear after a ReLU | yes (ReLU only) | fresh nn.Module |
drop_layer(model, *, layer_name, importance=None) |
Replace named layer with nn.Identity |
no — chain-preserving only | fresh nn.Module |
low_rank_factorize(linear, *, rank, method='svd') |
SVD truncation: Linear → Sequential(Linear, Linear) |
yes at max rank, approximate below | fresh nn.Sequential |
expand_embedding(emb, *, new_num_embeddings, init=...) |
Resize Embedding; preserve original rows | yes on original token IDs | (nn.Embedding, frozen_mask) |
All primitives accept keyword-only arguments after the first positional and operate on a deep copy of the input so the caller's reference survives.
2. End-to-end: widen → refine → save¶
The canonical surgery workflow has four steps. Load a trained checkpoint and apply a function-preserving edit. Hand the surged net to NNModel.train() for a brief refinement pass. Then save the result.
import torch
from torch.utils.data import DataLoader, TensorDataset
from nnx import (
NNCheckpoint, NNModel, NNModelParams, NNOptimParams, NNParams,
NNRun, NNSchedulerParams, NNTrainParams, Activations, Checkpoints,
Devices, Losses, Nets, Optims, widen,
)
# 1. Load a previously trained run (or train one inline if you don't have one).
run = NNRun.load(id="<md5>")
ckpt = NNCheckpoint.load(run=run.id, type=Checkpoints.BEST)
model = NNModel.from_checkpoint(checkpoint=ckpt)
# 2. Widen the first hidden layer. The new model is a FeedFwdNN with
# layers.0 expanded; the forward output equals model.net's exactly
# (within FP rounding) before any training.
new_net = widen(model.net, layer_name="layers.0", new_width=64)
x = torch.randn(8, model.net.params.input_dim)
model.net.eval() # compare in eval mode: dropout would break parity
new_net.eval()
with torch.no_grad():
assert torch.allclose(model.net(x), new_net(x), atol=1e-5)
# 3. Rewire NNModel around the wider net: build a NEW, correctly
# described model and load the surged state_dict. On a FeedFwdNN,
# `widen` already returns aligned params (the new width, every
# per-layer override kept), so adopt them. Never assign the widened
# net beneath the old `net_params`: ordinary checkpoint
# reconstruction reads the descriptor, and a stale one no longer
# describes the network.
refined = NNModel(
net_params=new_net.params,
params=NNModelParams(net=Nets.FEED_FWD, device=Devices.CPU, loss=Losses.CROSS_ENTROPY),
)
refined.net.load_state_dict(new_net.state_dict())
# 4. Refine. A few epochs absorb the surgery — function-preservation
# means the starting point is still good.
train_loader = DataLoader(TensorDataset(torch.randn(256, 8), torch.randint(0, 3, (256,))), batch_size=32, shuffle=True)
refined.train(params=NNTrainParams(
n_epochs=3,
train_loader=train_loader,
optim=NNOptimParams(name=Optims.ADAM, max_lr=1e-3, momentum=(0.9, 0.999), weight_decay=5e-5),
scheduler=NNSchedulerParams(min_lr=1e-7, factor=0.5, patience=3, cooldown=1, threshold=1e-3),
))
# The refined run is saved under runs/<new-id>/ via NNRun + NNCheckpoint as usual.
The same template works for widen and deepen: only the surgery line changes, and both return aligned params on a FeedFwdNN. A low-rank factorization cannot be described by NNParams, so record it as a recipe (§8). expand_embedding needs a module with an embedding (§7). drop_layer on a FeedFwdNN leaves its layer keys and params.hidden_dims as they were, so NNParams cannot describe the result either.
3. Parameter-count tables¶
Each table compares the original module against its surged form. The "delta" column is the bottom-line growth (negative for shrinking primitives).
3.1. widen on nn.Linear(in=4, out=8) followed by nn.Linear(in=8, out=2)¶
| Layer | Before | After (new_width=16) |
|---|---|---|
| First Linear (weight + bias) | 8·4 + 8 = 40 |
16·4 + 16 = 80 |
| Second Linear (weight + bias) | 2·8 + 2 = 18 |
2·16 + 2 = 34 |
| Total | 58 | 114 — delta +56 |
The first layer grows by q·(in + 1) (new units · (incoming weight + bias)); the second's input fan-in grows by q·out_down (rescaled, not biased). The downstream bias is untouched.
3.2. deepen on nn.Sequential(Linear(4,8), ReLU(), Linear(8,2))¶
| Layer | Before | After (insert after the ReLU) |
|---|---|---|
| Linear(4, 8) | 40 | 40 |
| Identity-init Linear(8, 8) | — | 8·8 + 8 = 72 |
| Linear(8, 2) | 18 | 18 |
| Total | 58 | 130 — delta +72 |
The inserted Linear has dim·dim + dim parameters and is identity-initialized (weight = I, bias = 0).
A named nn.Sequential is built from an OrderedDict, at the root or nested below another module. In one, address the ReLU by its dotted key — deepen(model, after_layer_name="body.relu"). Then:
- Every original key is preserved in order.
- The two new modules are registered as
_nnx_deepen_linear_N/_nnx_deepen_relu_Nright after the site.Nincrements until both keys are unused, so repeated deepening never collides. - A module registered in several slots keeps every slot pointing at one copied object.
A purely numeric container keeps its positional contract: deeper[2] is the new Linear and deeper[3] the new ReLU. It is rebuilt by position, so the modules after the site shift by two ("2" becomes "4"). In a named container, retained keys (numeric ones included) are not renumbered, and every surgery primitive addresses a registered key exactly. So there a later widen(deeper, layer_name="1") targets the module named "1" even when it no longer sits at position 1.
On a FeedFwdNN, deeper.params.hidden_dims gains the matching entry. Any activations / dropout_probs overrides stay aligned (dropout 0.0 at the new site).
3.3. low_rank_factorize on nn.Linear(in=64, out=32)¶
| Form | Parameter count |
|---|---|
Original nn.Linear(64, 32) |
32·64 + 32 = 2080 |
Factored at rank=8 |
8·64 + 32·8 + 32 = 800 — delta −1280 (≈ 61% reduction) |
Factored at rank=16 |
16·64 + 32·16 + 32 = 1568 — delta −512 (≈ 25% reduction) |
Factored at rank=32 (= max, exact) |
32·64 + 32·32 + 32 = 3104 — delta +1024 (factored form is bigger past breakeven) |
The breakeven rank below which factoring saves parameters is k* = (out·in) / (out + in). For a 64×32 Linear that's 32·64 / 96 ≈ 21. At ranks below k*, factoring strictly reduces parameter count. At higher ranks the two-Linear sandwich actually carries more parameters than the original. The rank-truncated weight still fits the original lower-rank structure.
3.4. drop_layer and expand_embedding¶
drop_layer replaces a submodule with nn.Identity, so parameter count drops by the entire dropped layer. For example, dropping a square Linear(d, d) removes d² + d parameters.
expand_embedding grows row count from old_num to new_num. Parameter count grows by (new_num − old_num) · embedding_dim — exactly the new-row weights, regardless of init.
4. The function-preservation contract¶
Every test in tests/test_surgery_*.py for a function-preserving primitive compares the surged module's output with the original's on the same input. The tolerance is atol=1e-5 (and rtol=1e-5 where it uses torch.testing.assert_close).
A change to widen, deepen, or low_rank_factorize (at max rank) can produce an output difference above 1e-5. If it does, the surgery is broken — do not relax the tolerance. The whole point of these primitives is that the post-surgery model is immediately a good starting point for training. An accuracy cliff at step 0 defeats the construction.
5. When function-preservation doesn't hold¶
wideninfers the consumer only where the execution order is proven:- A target inside an
nn.Sequential(walking the following siblings). - A target inside
FeedFwdNN.layers(resolving the effectiveactivation_for(i)/dropout_for(i), not the scalar defaults).
- A target inside an
- Every op between the
widentarget and the next Linear must be elementwise: the built-in activations except Softmax,Dropout,Identity. Duplicating units through a width-dependent op (Softmax's denominator, LayerNorm / BatchNorm statistics) changes the output. widenraises aValueErrornaming the boundary before anything is allocated. The source model, its module identities and the RNG are untouched. It refuses:- width-dependent ops;
- other containers (registration order is not data flow), and a container subclass with its own forward;
- hooks on the container, or on the path from the layer to its consumer;
- missing consumers;
- aliases (modules registered under more than one path).
- With dropout configured, the
widenparity promise is for eval mode. A stochastic training forward is not identical by construction. deepenrejects any activation other than ReLU: aValueErrorat aFeedFwdNNsite, aTypeErrorfor a non-ReLU target in annn.Sequential. The ReLU must be fed by annn.Linearthrough elementwise or norm modules only; any other module in between is refused with aValueError. The identity-Linear trick only function-preserves through ReLU. For sigmoid / tanh / GELU networks, structurally similar insertions silently produce a drifted forward output.deepenrebuilds annn.Sequentialas a plainnn.Sequentialof the same children. It therefore refuses a subclass with its ownforward, and a container with its own parameters, buffers or hooks. These would be dropped. It also refuses a target ReLU with hooks, and aModuleListthat is not thelayers/fcsof a stockFeedFwdNN/ConvNN.- On a
FeedFwdNN, thedeepencheck reads the activation actually applied at the insertion site —params.activation_for(i). So a per-layer override wins over the scalar default in both directions. - On a
FeedFwdNN,deepenreturns a network with updated immutableparams:- One more
hidden_dimsentry at the site. - A ReLU inserted into an explicit
activationslist. - The new identity site's dropout set to
0.0, while every existing site keeps its own probability. A nonzero scalar dropout is materialized into a per-layer list rather than switched off.
- One more
- On a
FeedFwdNN,widenlikewise returnsparamswhosehidden_dimsrecord the new width, with every per-layer override kept. - Adopt
deeper.params(or the widened net'sparams) as thenet_paramsof a freshNNModelwhen you rebuild for refinement. An outer model still holding the old descriptor cannot reconstruct the deeper checkpoint. drop_layeris never function-preserving, with one degenerate exception. The exception is a dropped layer that was already the identity on its inputs (e.g. a ReLU fed strictly positive activations). The function is chain-preserving: dotted-name lookup, downstream shapes, and the forward pass still work.low_rank_factorizeatrank < min(out, in)is an approximation. The Frobenius error of the truncation is bounded by the L2 norm of the discarded singular values (Eckart-Young). That bound is asserted as a regression test intests/test_surgery_low_rank.py.expand_embeddingpreserves the original rows exactly, so any token ID< old_numis unchanged. But it introduces new rows that must be initialized. Pickinit="zeros"for a safe default, orinit="copy_mean"when you want the new rows to warm-start near the existing manifold.
6. See it in practice¶
Worked end-to-end in ml-eng-lab:
- model_surgery-mnist-ffnn-pytorch — applies
nnx.surgery.widenandnnx.surgery.deepenfor function-preserving architectural edits on a trained MNISTFeedFwdNN.
7. Combining with nnx.finetune for the "freeze old, train new" pattern¶
Surgery preserves trainability and modes. Two primitives rebuild an existing Linear: widen for the target layer and its downstream consumer, and low_rank_factorize for both SVD factors. Each gives every replacement tensor the requires_grad flag of the tensor it replaces:
- The target's weight flag and bias flag are carried independently.
- The downstream layer keeps its own flags, never the target's.
- Both factors inherit the source weight's flag, while only the second factor's bias (
1.biasin the returnednn.Sequential) inherits the source bias's flag.
Each replacement module also takes the train/eval mode of the module it replaces, so a mixed root/child configuration survives unchanged.
widen, low_rank_factorize and expand_embedding refuse a module with a weight_norm / spectral_norm or an unbaked prune mask, which a rebuilt plain module would drop silently. Remove it first: torch.nn.utils.parametrize.remove_parametrizations for the parametrization form, remove_weight_norm / remove_spectral_norm for the legacy hooks, torch.nn.utils.prune.remove for a mask.
The same primitives refuse a subclass of nn.Linear / nn.Embedding with its own forward, such as a fake-quantized QAT Linear. The rebuilt plain module would drop that forward. deepen refuses a ReLU subclass with its own forward for the same reason. widen also refuses such a subclass as the consumer or on the path.
Build the optimizer after surgery. A frozen layer stays frozen through widening or factorization and never enters build_param_groups(..., strict=True). If you want the new factors to learn, unfreeze them explicitly first. Genuinely new layers inserted by deepen are ordinary fresh modules: trainable, and in the mode of the container they join. Existing layers keep their own modes. expand_embedding gives the new table the source table's requires_grad flag and mode.
These flags and modes live on the returned object only. state_dict() carries neither, so they are not a cross-process freeze policy.
expand_embedding returns a frozen_mask of bool shape (new_num_embeddings,) — True for rows that came from the original embedding, False for new rows. The mask is a hand-off to the caller's training step.
The example uses a custom module net with an embed attribute; no built-in net has one. As in §2, a fresh NNModel wraps the changed module, so its runs and checkpoints describe the expanded topology:
from nnx import NNModel, NNOptimParams, NNParamGroupSpec, Optims, expand_embedding
new_emb, frozen_mask = expand_embedding(net.embed, new_num_embeddings=20_000, init="copy_mean")
net.embed = new_emb # reattach — expand_embedding returns a NEW module
model = NNModel(module=net, params=model_params) # a fresh model over the expanded net
# Register a gradient hook ONCE before training: hooks fire during
# backward(), i.e. before optimizer.step(), so frozen rows receive
# zero gradient. (Zeroing .grad AFTER default_train_step(ctx) would
# be too late — that helper already stepped the optimizer.)
keep = (~frozen_mask).unsqueeze(1)
new_emb.weight.register_hook(lambda g: g * keep.to(device=g.device, dtype=g.dtype))
# One more trap: Adam applies weight decay INSIDE step() — after the
# hook — so the frozen rows would still drift under the default
# weight_decay. Give the embedding a decay-free param group:
optim = NNOptimParams(
name=Optims.ADAM, max_lr=1e-2, momentum=(0.9, 0.999), weight_decay=5e-5,
param_groups=[NNParamGroupSpec(name_pattern="embed.*", weight_decay=0.0)],
)
from dataclasses import replace
model.train(params=replace(train_params, optim=optim)) # default supervised step works as-is
With the reattach, the hook and the decay-free group together, the frozen rows do not change during training while the new rows learn.
nnx.finetune.freeze covers the simpler case of freezing entire parameter tensors via fnmatch globs; the frozen_mask covers the row-level case that freeze can't reach.
8. Recorded recipes: surgery that checkpoints can rebuild¶
The primitives above edit a module and record nothing, so a model they changed cannot be rebuilt from its descriptor. NNModel.train refuses such a net before any run is reserved:
- unrecorded low-rank surgery;
- unbaked prune masks;
weight_norm/spectral_normparametrizations.
Remove masks with torch.nn.utils.prune.remove and parametrizations with torch.nn.utils.parametrize.remove_parametrizations (remove_weight_norm / remove_spectral_norm for the legacy hooks). Alternatively, train the net as NNModel(module=...). NNx can replay two topology changes: LoRA on explicit nn.Linear targets and SVD low-rank replacement. For these, use a recipe (nnx.transforms):
from nnx.optimizers import build_optimizer
from nnx.transforms import TransformRecipe, check_optimizer, lora, low_rank
recipe = TransformRecipe(
[lora("layers.0", r=4, alpha=8.0), low_rank("layers.1", rank=4)],
materialization="in_place", # the default; "fresh" transforms a new, randomly initialized base instead
)
recipe.validate(model) # mutates nothing; one RecipeError names every problem
recipe.materialize(model) # transactional (model and random streams); the operations are recorded
optimizer = build_optimizer(model.net, optim_params) # AFTER the recipe
check_optimizer(model, optimizer)
- What is recorded. Each operation's
id("lora"/"low_rank"),version, explicittargetsandconfig, in order.- Targets are dotted path strings under
model.net, any module name included. Glob characters*?[], empty segments and non-string targets are refused. - The recipe copies its operations: changing the list they came from changes nothing. Its configs are read-only.
- Operations and recipes hash, compare and pickle by value.
- Targets are dotted path strings under
- Which models. A recipe needs a model NNx can rebuild: built-in
Netsor a registeredModelSpec.- A runtime-only
module=is refused, because nothing could replay the recipe on it. Usennx.peft/nnx.surgerydirectly there. - A target that was an uninitialized lazy layer (
nn.LazyLinear) when the model was built is also refused, since a rebuild starts lazy again.
- A runtime-only
- Materialization.
"in_place"(the default) transforms the given model and keeps its trained weights."fresh"builds a new plainNNModelfrom the descriptor — randomly initialized, keeping the batch adapter — and transforms that, leaving the given model untouched. A"fresh"recipe is validated against that base, not the given model. -
Validation. Before anything changes,
validate/materializereject each of these, named by operation index and target:- a missing target, or a target that is not exactly an
nn.Linear; - a target registered under more than one name;
- two targets of one operation that are the same or nested;
- a target inside a subtree another operation (or an earlier recipe) transforms;
- a rank above
min(in, out); - an unknown operation or version.
They also reject any optimizer passed in
optimizers=that already holds a target's parameters. The error instructs you to build the optimizer after the recipe. - a missing target, or a target that is not exactly an
-
What
validatechecks.validatechecks exactly whatmaterializewould (the fresh base for"fresh"). In place, it also refuses a model that already differs from its descriptor plus its recorded recipe, since no checkpoint could rebuild it. This includes unrecorded surgery anywhere, such as an added, resized or bias-changed layer. -
Optimizer check.
check_optimizer(model, optimizer)refuses an optimizer that is stale for the current topology, whenever it was built. A stale optimizer holds either of these:- parameters a low-rank operation replaced (or the parameters of the model a
"fresh"materialization started from); - a LoRA target's frozen base weights but none of its adapter, so it would never train.
It accepts an optimizer built afterwards over the trainable parameters. This covers every one, a subset, parameters outside
model.net(a learnable loss temperature, say), or BitFit-style over unfrozen base tensors. - parameters a low-rank operation replaced (or the parameters of the model a
-
What carries the recipe. The recorded operations travel with every run checkpoint (LAST, BEST, the phase tags and
ModelCheckpointsnapshots). They also travel withNNCheckpoint.to_filein both formats,save_pretrained, and run bundles.nnx.bundles.export_bundlerefuses any other recorded transform (a callback's own train-end operation, say), naming its index and id, because a bundle is rebuilt from data alone. - Reconstruction.
NNModel.from_checkpointandfrom_pretrainedbuild a fresh base and replay the operations. The replay puts LoRA wrappers around the fresh layers and allocates low-rank factors in their recorded two-Linear shape. Only then do they load the saved tensors. SVD is never rerun, and the ordered keys, shapes, trainability and predictions match the saved model. An unknown operation or version, or malformed recorded options, is refused before any tensor is loaded, naming the transform's index. - Training. A recipe model trains through
NNModel.train/Trainer.train, customtrain_step_fnincluded.- Before any run is reserved, both check the live topology. It must be exactly the base plus its recipe: tensor names and shapes, and each target's module and configuration.
- So surgery outside the recipe is refused. This includes a recipe target that unrecorded surgery added, because a rebuild from the base could not find it.
- The recipe is part of the run id (runs without one keep theirs) and of
ExperimentManifest.for_model.
- Resume. A resume, from any tag or snapshot, needs the resuming model to have materialized the same recipe — ids, versions, targets and configs. It says so otherwise.
ExperimentPlanbuilds untransformed models and cannot express a recipe, so resume a recipe run throughNNModel.trainorTrainer.train. - Checkpoints during training. The final
LASTstores the weights once: a recipe is the live topology, not a train-end transform. A train-end transform such as a QAT conversion still keeps the pre-transform state for resuming.- A recipe is materialized before training. Before each checkpoint a run writes, and again after
on_train_end, NNx refuses recipe operations a callback applied or declared during training. - On a recipe model, it also refuses unrecorded surgery at those points, since no checkpoint could rebuild or resume them.
- The run's earlier checkpoints are kept.
- A recipe is materialized before training. Before each checkpoint a run writes, and again after
- What cannot rebuild a recipe. A raw
export_state_dict()file or an adapter-onlysave_lora_weightsexport carries no recipe.- Loading transformed weights without one through
NNModel.from_checkpointor a strictfrom_pretrainedfails with an error saying so. - A non-strict load (
from_pretrained(strict=False),nnx.finetune.load_pretrained) keeps its partial-load contract and cannot tell. - Materialize the recipe on the model first and load into it, or keep a checkpoint /
save_pretrainedartifact.
- Loading transformed weights without one through
The manual refinement loop in examples/20_low_rank_surgery_ffn.py stays (unrecorded surgery); its recipe_reconstruction_workflow() helper shows the recorded path end to end.