Model Signals on Fashion-MNIST =============================== .. raw:: html
Usecase model signals gradient norm activations per-layer training dynamics
**Example:** ``weightslab/examples/Usecases/wl-fashion-mnist-signals`` This use case trains a small CNN on Fashion-MNIST and adds one thing on top of the plain per-sample logging: the run plots **its own training dynamics**. A loss curve tells you whether the model is learning; these curves tell you *where* in the model something went wrong. Everything below comes from one argument. The integration --------------- .. code-block:: python model = wl.watch_or_edit( FashionCNN(), flag="model", device=device, track_model_signals=True, # <- the whole feature model_signals_every_n_steps=1, ) No hooks to write, and **no call anywhere in the training loop** — the loop is byte-for-byte the same as ``wl-classification``'s. Pass a list instead of ``True`` to narrow the set, e.g. ``track_model_signals=["grad_norm", "activation_std"]``. What gets plotted ----------------- .. code-block:: text metrics/global/grad_norm whole-model gradient L2 norm metrics/global/weights_norm whole-model parameter L2 norm metrics/layer//grad_norm per-layer parameter gradients metrics/layer//weights_norm per-layer parameters metrics/layer//activation_mean metrics/layer//activation_std metrics/layer//activation_max metrics/layer//activation_min Layers with parameters get all eight; parameter-free layers (``ReLU``, ``MaxPool2d``) get the four activation curves only. Containers and shape-only ops (``Sequential``, ``Flatten``, ``Identity``, ``Dropout``) are skipped, since their output statistics duplicate the layer before them. For the model in this example — three conv blocks and a two-layer head — that is 74 curves: 14 layers × 4 activation stats, 8 parameterized layers × 2 norms, and the 2 global norms. The layer legend ---------------- ``metrics/layer/7/grad_norm`` says nothing on its own, so the example prints the mapping at startup: .. code-block:: text layer_id module shape 1 Conv2d (16, 1, 3, 3) 2 BatchNorm2d (16,) 3 ReLU - 4 MaxPool2d - 5 Conv2d (32, 16, 3, 3) 6 BatchNorm2d (32,) 7 ReLU - 8 MaxPool2d - 9 Conv2d (64, 32, 3, 3) 10 BatchNorm2d (64,) 11 ReLU - 12 Flatten - 13 Linear (128, 3136) 14 ReLU - 15 Linear (10, 128) These are the same ids the model panel and every architecture op (freeze / reset / operate) use — so a curve that looks wrong names the layer you then act on, whether from the UI, the CLI, or the agent. Note that every module in this example's model is a **named attribute** rather than a member of an ``nn.Sequential``. That is deliberate: a Sequential block resolves to one layer id, and therefore one curve, which defeats the purpose of per-layer signals. Reading the curves ------------------ Fashion-MNIST is small enough to make each failure mode legible: .. list-table:: :header-rows: 1 :widths: 34 66 * - What you see - What it means * - ``grad_norm`` collapsing toward 0 in the **early** layers while the late ones stay healthy - Vanishing gradient. The run keeps "training" and stops learning. Act from the layer where it dies. * - ``grad_norm`` spiking by orders of magnitude - Exploding gradient. Compare against the loss curve to see which moved first. * - ``activation_std`` → 0 on a layer - That layer has gone constant (dead ReLUs, saturated BatchNorm). Still consuming compute, contributing nothing. * - ``activation_min`` pinned at exactly 0.0 across a whole ReLU - The same story from the other side — nothing is getting through. * - ``weights_norm`` climbing without bound while the loss flattens - The model is growing weights instead of learning structure. Add decay. Cost, and how it is kept low ---------------------------- Three things keep the per-step overhead small enough to leave on by default: - **Activations are reduced on-device** into 0-d tensors and held there. The whole step costs *one* host↔device sync no matter how many layers are tracked. - **Gradients are captured by post-accumulate hooks**, so nothing walks the parameter list a second time — and nothing depends on where your loop calls ``optimizer.zero_grad()``. - **``model_signals_every_n_steps``** samples every Nth step. On a large model, 10–50 makes the cost negligible while the curves stay just as readable. Reach for this before dropping metrics. Collection only happens inside ``guard_training_context``, so the evaluation pass contributes nothing — a gradient or activation curve never contains values the optimizer did not see. This holds even for eval loops that skip ``model.eval()`` or ``torch.no_grad()``. Custom dynamics values ---------------------- ``track_model_signals`` is a collector over ``wl.save_model_signals``, which is the step-keyed write path in its own right. Use it directly for anything the collector does not compute: .. code-block:: python # gradient-to-weight ratio: how big a step is this, relative to the weights? wl.save_model_signals({ "metrics/global/update_ratio": grad_norm / (weight_norm + 1e-12), "metrics/global/lr": optimizer.param_groups[0]["lr"], }) See :ref:`save_model_signals ` for the full reference, and :doc:`../../model_interaction` for how these fit alongside the rest of the model surface.