8.5 Model surgery — MNIST FFN (Net2Net)¶
A comprehensive walk-through of notebooks/model_surgery-mnist-ffnn-pytorch/ — the canonical
in-repo demo of nnx.surgery and the function-preserving model-edit pattern. This page is the
deep-dive companion to the task notebook: it states the problem, builds the math of Net2WiderNet
and Net2DeeperNet, dissects the surgery contract, reads the code top to bottom, reports the
measured warm-start vs cold-start results, and catalogues the pitfalls and extensions.
The notebook is Tier-A — CPU re-runs in roughly 45 seconds and it is re-executed end-to-end in CI on every pull request. It is part of the "efficient/compressed MLP" family that also includes the born-again distillation (§8.6), magnitude-pruning (§8.7), and quantization (§8.8) tasks; model surgery is the odd-one-out of that family because it changes the architecture rather than the bitwidth or sparsity, but it shares the same overarching goal — keep the trained weights and make the deployed model better, not smaller.
8.5.1 Problem & motivation¶
Most training pipelines treat architecture as immutable: you pick hidden_dims, train, then either
keep the model or restart from scratch with a different shape. The restart is wasteful — everything
the old model learned is thrown away — and it is often unnecessary, because most of the trained
weights in the old model have exact, mathematically-defined counterparts in the new one. Model
surgery (Net2Net; Chen et al., 2015) is the alternative: edit a trained model's architecture
(widen a layer, insert a layer, drop a layer) in a way that preserves its forward output exactly,
then continue training from there.
This notebook exists for two reasons:
- First in-repo exercise of
nnx.surgery. Thennxrelease shipsnnx.widen,nnx.deepen,nnx.drop_layer, andnnx.low_rank_factorizeas a surgery namespace; this notebook is the canonical demo of the two headline operations — Net2WiderNet (nnx.widen) and Net2DeeperNet (nnx.deepen) — on a real trained model. - The function-preservation contract is the technical headline. Once the surgery preserves the forward output to numerical precision, warm-starting a strictly larger model from a smaller one is free: there is no accuracy cliff at step 0 of resumed training. The notebook asserts the contract on a probe batch before relying on it.
The falsifiable hypothesis tested by the notebook is that warm-starting a wider model via surgery reaches a given validation loss faster than cold-starting the same wider shape from random init — the Net2Net "accelerating learning via knowledge transfer" claim, exercised at a budget where the gap is observable.
8.5.2 Concepts¶
| Concept | Where it shows up |
|---|---|
| Function-preserving transform | widen / deepen must match the original forward to atol=1e-5 |
| Net2WiderNet | nnx.widen(layer_name="layers.0", new_width=128) — widen a hidden layer by replicating units |
| Net2DeeperNet | nnx.deepen(after_layer_name="layers.0") — insert an identity-initialized Linear |
| Identity initialization | The inserted deeper layer is a no-op at step 0 (ReLU-only contract) |
| Warm-start vs cold-start | Same target shape [128, 64], different starting weights (surgery vs random) |
| ReLU activation constraint | deepen's identity-init is function-preserving only for ReLU |
nnx.FeedFwdNN (Nets.FEED_FWD) |
The substrate architecture; two hidden layers |
| Reproducibility | nnx.set_seed(0) pins Python random, NumPy, PyTorch CPU + CUDA + cuDNN |
The nnx surface consumed is: NNModel, NNParams, NNModelParams, NNTrainParams, NNDataset,
Activations, Devices, Losses, Nets, Utils, VisUtils, set_seed, and the surgery
primitives nnx.widen / nnx.deepen. The enums (Nets.FEED_FWD, Losses.CROSS_ENTROPY,
Activations.RELU, Devices.CPU) make the contract read as configuration rather than magic
strings.
8.5.3 Mathematical formulation¶
Both operations are function-preserving: if \(f\) is the original network and \(f'\) the operated network, then \(f'(x) = f(x)\) for every input \(x\) (to numerical precision). The constructions below are the Net2Net recipes.
Net2WiderNet. Suppose layer \(i\) has weight \(W \in \mathbb{R}^{k \times m}\) (mapping \(m\) inputs to \(k\) hidden units) and the next layer has weight \(U \in \mathbb{R}^{m' \times k}\). To widen layer \(i\) from \(k\) to \(k' > k\) units, choose an index map \(\mathbf{r} \in \{1,\dots,k\}^{k'}\) that says, for each new unit, which old unit it copies. The new weights are
The \(W'\) column-copy makes the new unit compute the same pre-activation as its donor; the
\(U'\) row-scaling (dividing each outgoing row by the number of units sharing that donor) makes the
post-activation contribution sum to exactly the original. nnx.widen implements this with a random
replication map; the construction preserves the forward to \(\sim 10^{-6}\) (floating-point
rounding, not bit-exact).
Net2DeeperNet. To insert a new Linear layer after an existing activation, initialize it as the
identity: \(W_{\text{new}} = I\), \(b_{\text{new}} = 0\). For a ReLU activation the composition
\(\text{ReLU}(I\,h + 0) = \text{ReLU}(h)\) is exactly the original, so the inserted layer is a
literal no-op at step 0 — bit-exact, drift \(0.00\). For sigmoid/tanh/GELU the identity-init does
not compose through the activation; exact preservation would require a materially different surgery
operator, not a different constant initialization. The notebook pins the baseline to
Activations.RELU to honor this.
The training objective is cross-entropy on the MNIST logits,
and the optimizer is Adam with \(\eta = 10^{-2}\), \(\beta = (0.9, 0.999)\), weight decay \(5 \times 10^{-5}\).
8.5.4 Architecture¶
The substrate is Nets.FEED_FWD (nnx.FeedFwdNN): a 784-unit input layer (flattened MNIST), two
hidden layers with ReLU, and a 10-unit output layer consumed by softmax + cross-entropy. Surgery is
demonstrated on a deliberately small baseline so the post-surgery model is meaningfully larger.
| Model | hidden_dims |
Role |
|---|---|---|
| Baseline | [64, 64] |
The surgery substrate; trained for 3 epochs (undertrained on purpose) |
| Warm-widen | [128, 64] |
Baseline after nnx.widen("layers.0", new_width=128); warm-started |
| Cold-widen | [128, 64] |
Fresh random init at the same target shape; the control |
| Continue | [64, 64] |
Keep training the baseline shape (no surgery); the capacity ceiling |
The shared contract across all four:
- Net:
Nets.FEED_FWD, activation:Activations.RELU(deepen contract) - Loss:
Losses.CROSS_ENTROPY - Optimizer: Adam,
max_lr=1e-2,weight_decay=5e-5,momentum=(0.9, 0.999) - Device:
Devices.CPU - Budget: baseline 3 epochs; resumed models 5 epochs each (
BASELINE_EPOCHS=3,RESUME_EPOCHS=5, orSMOKE_TEST_EPOCHS=1underSMOKE_TEST=1) - Seed:
0for the baseline; resumed models use seeds1,2,3respectively (so each resume run is reproducible but the three resume trajectories differ from each other only in their data-shuffle order, not in their init — the init is set by the surgery / fresh-construct step)
The widen/deepen targets are pinned in the configuration cell: WIDEN_LAYER_NAME="layers.0"
(widen the first Linear, 64→128), DEEPEN_AFTER="layers.0" (insert an identity Linear after
the first activation). Dropout is held at 0.0 throughout — the surgery contract is exact, not
statistical, and dropout's stochastic masking would break the forward-equality assertion.
8.5.5 Code walkthrough¶
8.5.5.1 Baseline construction¶
def make_model(hidden_dims):
# nnx.deepen's identity-init insertion is function-preserving only for ReLU.
return NNModel(
net_params=NNParams(
input_dim=ds.input_dim, output_dim=ds.output_dim,
hidden_dims=hidden_dims, dropout_prob=DROPOUT_PROB,
activation=Activations.RELU,
),
params=NNModelParams(net=Nets.FEED_FWD, device=DEVICE, loss=Losses.CROSS_ENTROPY),
)
baseline = make_model(BASE_HIDDEN_DIMS) # [64, 64]
The ReLU pin is load-bearing for the deepen step that follows — see pitfalls. The baseline trains
for 3 epochs and reaches a final validation loss of 1.3150: deliberately undertrained, so the
post-surgery resume has headroom to improve.
8.5.5.2 Asserting the function-preservation contract¶
x_probe, _ = next(iter(ds.val_loader))
x_probe = x_probe.view(x_probe.size(0), -1)
baseline.net.eval()
with torch.no_grad():
y_before = baseline.net(x_probe)
widened_net = nnx.widen(copy.deepcopy(baseline.net),
layer_name=WIDEN_LAYER_NAME, new_width=WIDEN_NEW_WIDTH)
with torch.no_grad():
y_after_widen = widened_net(x_probe)
widen_drift = (y_before - y_after_widen).abs().max().item()
assert widen_drift < 1e-5, "widen broke function preservation"
deeper_net = nnx.deepen(copy.deepcopy(baseline.net), after_layer_name=DEEPEN_AFTER)
with torch.no_grad():
y_after_deepen = deeper_net(x_probe)
deepen_drift = (y_before - y_after_deepen).abs().max().item()
assert deepen_drift < 1e-5, "deepen broke function preservation"
The probe batch is one validation minibatch; the assertions are the empirical half of the Net2Net
proof. The recorded drifts on the committed run are widen_drift = 1.91e-06 and
deepen_drift = 0.00e+00. The asymmetry is real: widen divides each outgoing row by a shared-donor
count, which accumulates floating-point rounding error; deepen's identity matrix is bit-exact for
ReLU. The 1e-5 tolerance should not be relaxed — drift past it means the surgery primitive is
broken.
8.5.5.3 Wrapping an operated net for resumed training¶
def make_model_from_net(net, model_params):
m = NNModel(
net_params=NNParams(input_dim=ds.input_dim, output_dim=ds.output_dim,
hidden_dims=BASE_HIDDEN_DIMS, dropout_prob=DROPOUT_PROB,
activation=Activations.RELU),
params=model_params,
)
m.net = net # inject the operated (or copied) net over the freshly-constructed one
return m
The operated net is injected into the live m.net slot so the standard model.train(...) loop
applies to it. Note the comment in the notebook: m.net_params stays at the constructor dims
([64, 64]), which is stale relative to the injected wider net's actual [128, 64] — net_params
is descriptive metadata here, and the live m.net drives training. This is a known seam in the
nnx API surface (no public call both injects a net and syncs params); it does not affect the
recorded results.
8.5.5.4 The warm-start vs cold-start race¶
cont_model = make_model_from_net(copy.deepcopy(baseline.net), ...)
warm_widened = make_model_from_net(widened_net, ...)
cold_widened = make_model([WIDEN_NEW_WIDTH, BASE_HIDDEN_DIMS[1]]) # fresh [128, 64]
cont_run = cont_model.train(params=make_resume_params(seed=1))
warm_run = warm_widened.train(params=make_resume_params(seed=2))
cold_run = cold_widened.train(params=make_resume_params(seed=3))
All three resume for the same RESUME_EPOCHS=5 budget. continue keeps the baseline shape;
warm-widen and cold-widen share the target shape [128, 64] but differ in starting weights —
surgery-transferred vs random. The loss curves are overlaid via VisUtils.multi_line_plot so the
step-0 loss gap and the convergence trajectories are directly comparable.
8.5.6 Results & analysis¶
On the recorded (seeded) run, the four models land as:
| Model | Final train loss | Final val loss |
|---|---|---|
continue (base [64, 64]) |
0.9971 | 0.9260 |
warm-widen ([128, 64]) |
1.2413 | 1.1409 |
cold-widen ([128, 64]) |
1.0854 | 0.8612 |
Three observations:
- The surgery contract holds at step 0. The warm-widen trajectory starts at exactly the
baseline's resumed validation loss (the
1.91e-06widen drift is invisible at loss scale), while cold-widen starts much higher from random init. This is the function-preservation promise made visible. - At this very short budget, cold-widen reaches the lowest validation loss (0.8612). This is the honest recorded outcome and it is not a violation of Net2Net — it is the budget-dependence the notebook's §6.3 calls out directly. The baseline trained for only 3 epochs, so the surgery-transferred weights are themselves undertrained; cold-widen's fresh, fully-random init at the wider shape gets 5 uninterrupted epochs of optimization at the right capacity, while warm-widen spends part of its budget "unlearning" the narrow-shape solution.
- The Net2Net advantage shows up at longer schedules. The headline pedagogical point — warm-start beats cold-start on time-to-target — appears once the baseline is well-converged before surgery and the resume budget is long enough for cold-start to spend epochs just reaching the warm-start's step-0 loss. At Tier-A's 3 + 5 epochs (chosen for CPU feasibility) the gap can go either way; the recorded run is an example of cold-widen winning.
The right reading of the table is therefore: the contract works (warm-widen's step-0 loss equals the baseline's), and the comparison is budget-dependent (the cold-widen win at this budget is a real effect, not a bug). Extending the budget is the first item in §8.5.8.
8.5.7 Pitfalls & edge cases¶
nnx.deepenis function-preserving only for ReLU. The identity-initialized inserted layer composes through ReLU exactly; for sigmoid/tanh/GELU it does not. Exact preservation would require a materially different surgery operator, not another constant initialization. The notebook pinsActivations.RELUto honor the contract — if you swap the activation, thedeepen_drift < 1e-5assertion will fail.nnx.widenis not bit-exact. Its drift is \(\sim 10^{-6}\) (floating-point rounding from the shared-donor row scaling), not0.00. The1e-5tolerance accommodates this; do not tighten it to0or the widen assertion will fire on a correct primitive.- Do not relax the
1e-5assertion. Drift past1e-5means the surgery primitive is broken (wrong layer targeted, wrong replication map, activation mismatch). The assertion is the empirical half of the Net2Net proof — keep it strict. - The warm-vs-cold comparison is budget-dependent. At the short Tier-A budget (3 baseline + 5 resume epochs), cold-widen can match or beat warm-widen, as the recorded run shows. Do not present the notebook as "warm-start always wins" — the §6.3 cell is explicit about this. The Net2Net time-to-target advantage emerges at longer schedules with a well-converged baseline.
- Injecting an operated net leaves
net_paramsstale.make_model_from_netinjects the wider net intom.netbutm.net_paramsstill reports the constructor's[64, 64]. This is descriptive metadata only (the livem.netdrives training), but any downstream code that readsmodel.net_params.hidden_dimsto size a new layer will see the wrong width. Surgery consumers should read shapes offmodel.netdirectly. - Keep dropout at 0 during surgery verification. The function-preservation assertion is
deterministic; dropout's stochastic masking would make
y_beforeandy_afterdiffer for reasons unrelated to the surgery. The notebook holdsDROPOUT_PROB=0.0for exactly this reason. - Re-pin the seed for reproducibility.
nnx.set_seed(0)is called before baseline training; the three resume runs take seeds1,2,3. Without explicit seeds the three trajectories vary run-to-run and the comparison becomes noise.
8.5.8 Extensions & references¶
- Lengthen the budget to expose the Net2Net time-to-target gap. Train the baseline to convergence (10–20 epochs) before surgery, then resume warm- and cold-widen for a longer schedule. The warm-start should reach a target validation loss in fewer epochs than cold-start — the canonical Net2Net result that the Tier-A budget cannot reliably show.
- Exercise
nnx.drop_layerandnnx.low_rank_factorize. The surgery namespace ships two more primitives not covered here.drop_layeris the dual ofdeepen(remove a layer function-preservingly);low_rank_factorizeinserts a low-rank bottleneck. A sibling notebook demonstrating the full four-operation surface would complete the surgery story. - Surgery on a non-ReLU net. Construct a sigmoid/tanh baseline and either (a) accept that
deepenwill not be function-preserving and re-derive the post-insertion init, or (b) restrict surgery towidenonly. Either path documents the activation-dependence of the contract. - Combine surgery with distillation (§8.6). Warm-start a wider student via
widen, then distill back from the narrower teacher. This is the "Net2Net + born-again" pipeline used in some production compression workflows; the two notebooks'nnxprimitives compose cleanly. - References. Chen et al., 2015, Net2Net: Accelerating Learning via Knowledge Transfer
(arXiv:1511.05641) — the original function-preserving construction. The
nnxsurgery primitives implement Net2WiderNet (§3.1) and Net2DeeperNet (§3.2) of that paper; see also the taskREADME.mdfor the in-repo contract summary anddocs/env-setup.md§4.1.6 for the Tier-A execution path.