3.3.3 · D3Deep Learning Frameworks

Worked examples — Building models with nn.Module

2,401 words11 min readBack to topic

This page is the practice arena for Building models with nn.Module. The parent note taught you the pattern; here we hit every scenario that pattern can throw at you — from a plain layer, through the sneaky "my parameters vanished" bug, all the way to a degenerate zero-layer network.

Before we start, one word we lean on constantly:

The scenario matrix

Every mistake, exam twist, and real bug with nn.Module falls into one of these cells. Each worked example below is tagged with the cell(s) it covers.

Cell Scenario class What can go wrong / what's tested
A Single nn.Linear — count parameters off-by-bias errors, weight-shape confusion
B Multi-layer stack, count total params forgetting a layer, wrong hidden dim
C Zero / degenerate: num_layers = 0 does the loop body ever run?
D The bug cell: Python list vs nn.ModuleList parameters silently NOT registered
E Skip / residual dimension match shapes must line up before +
F train vs eval mode (Dropout/BatchNorm) forgetting .eval(), non-determinism
G Real-world word problem translate a spec into a module
H Exam twist: shared weights / nn.Parameter one tensor counted once, tied layers

Below, we cover all eight cells across eight examples.


Example 1 — Cell A: a single Linear layer

Forecast: guess a number before reading on. Is it ? ? ?

Steps.

  1. Write what nn.Linear computes. It computes .

    • Why this step? You cannot count parameters until you know which tensors exist. nn.Linear owns exactly two: the weight and the bias .
  2. Shape of . nn.Linear(in, out) stores weight of shape .

    • Why this step? PyTorch stores the weight transposed so that the multiply is . That is numbers.
  3. Shape of . The bias has one number per output: shape numbers.

    • Why this step? Each output neuron gets its own additive shift, so bias length = out_features.
  4. Total. learnable parameters.

Verify: sum(p.numel() for p in layer.parameters()) returns ; layer.weight.shape == (3,5), layer.bias.shape == (3,). ✅ Units check: each parameter is one real number, no double counting.


Example 2 — Cell B: a full stack (parent's Example 1)

Forecast: the parent claimed 117121. Try to reproduce it before checking.

Steps.

  1. fc1: . Weight , bias . Subtotal .

    • Why this step? Use the Cell-A rule per layer: .
  2. fc2: . Weight , bias . Subtotal .

    • Why this step? Same rule; here in = out = 128.
  3. fc3: . Weight , bias . Subtotal .

    • Why this step? Output layer for binary classification produces a single logit.
  4. Add. .

Verify: sum(p.numel() for p in SimpleClassifier(784,128,1).parameters()) == 117121. ✅


Example 3 — Cell C: the degenerate zero-layer case

Forecast: crash? empty list? Guess.

Steps.

  1. Look at the loop. nn.ModuleList([nn.Linear(20,20) for _ in range(0)]) — the comprehension range(0) produces nothing.

    • Why this step? Degenerate inputs live in the loop bounds. range(0) is empty, so zero hidden Linears are created. No crash: an empty nn.ModuleList is perfectly valid.
  2. What survives? Only input_layer () and output_layer ().

    • Why this step? The network still transforms input to output — it just has no middle.
  3. Count. input_layer: . output_layer: . Total .

    • Why this step? Cell-A rule per surviving layer.
  4. Forward still runs. The for layer in self.hidden_layers: loop body simply never executes; data flows input_layer → output_layer.

Verify: with num_layers=0, len(model.hidden_layers) == 0 and total params . ✅ Sanity: adding one hidden layer would add more.


Example 4 — Cell D: the bug that eats your parameters

Forecast: ? ? ? Commit to an answer.

Figure — Building models with nn.Module

Steps.

  1. Recall the clipboard rule. From the definition above: a plain list is not on the clipboard. PyTorch's __setattr__ magic only registers nn.Module, nn.Parameter, and official containers.

    • Why this step? Registration is what makes .parameters() find something. No registration → invisible.
  2. Trace what happens. self.hidden = [...] stores a normal list. PyTorch sees "a list" and shrugs — it does not peek inside to find the nn.Linear objects.

    • Why this step? Look at the figure: the three orange layers sit outside the clipboard, so the collector's recursive walk never reaches them.
  3. Count visible params. If the rest of the model has no other layers, sum(p.numel() for p in model.parameters()) == 0.

    • Why this step? Zero registered parameters. The optimizer gets an empty list; training does literally nothing.
  4. The fix. Wrap it: self.hidden = nn.ModuleList([...]). Now all appear.

    • Why this step? nn.ModuleList is on the clipboard, so its contents are registered recursively.

Verify: plain-list version → registered params; nn.ModuleList version → (). ✅


Example 5 — Cell E: residual shapes must match

Forecast: does the + work, or throw a shape error?

Figure — Building models with nn.Module

Steps.

  1. Spatial size under padding=1, kernel=3. Output size . Same for .

    • Why this step? You may only add tensors of identical shape. So we must check the conv preserves . With it does — that is why residual blocks use this exact padding.
  2. Channels. Conv2d(64, 64, ...) keeps channels at .

    • Why this step? The addition also needs matching channel count. In-channels = out-channels = 64 by design.
  3. Compare shapes. is ; is . Identical → the + is legal, element-wise.

    • Why this step? Look at the figure: both boxes are the same size, so the arrows line up one-to-one.
  4. Result shape. , unchanged.

Verify: conv output spatial ; shapes equal; sum shape . ✅


Example 6 — Cell F: train vs eval mode

Forecast: which mode gives repeatable outputs — train or eval?

Steps.

  1. What Dropout does in training. With model.train(), Dropout randomly zeros each element with probability , then scales the survivors by .

    • Why this step? The random mask changes every forward pass, so two calls on the same input give different outputs. This regularizes.
  2. Why the scale. Expected value must be preserved: .

    • Why this step? So that switching to eval doesn't shift the average activation.
  3. What Dropout does in eval. With model.eval(), Dropout becomes the identity — no zeroing, no scaling. Output = input, exactly, every time.

    • Why this step? At test time you want deterministic, full-strength predictions. The mode flag flips this behaviour.
  4. Answer. train → outputs differ (random mask). eval → outputs identical (pass-through).

Verify: in eval mode, dropout(x) equals x; expected scaling factor . ✅


Example 7 — Cell G: real-world word problem

Forecast: roughly how many parameters — tens of thousands, hundreds of thousands, millions?

Steps.

  1. Translate the words into dimensions. Input . Hidden . Output .

    • Why this step? "Flattened 28×28" is the literal input length; "10 class scores" is the output length. Everything else is architecture.
  2. Write it.

    class MNISTNet(nn.Module):
        def __init__(self):
            super().__init__()
            self.fc1 = nn.Linear(784, 256)
            self.fc2 = nn.Linear(256, 256)
            self.fc3 = nn.Linear(256, 10)
            self.relu = nn.ReLU()
        def forward(self, x):
            x = self.relu(self.fc1(x))
            x = self.relu(self.fc2(x))
            return self.fc3(x)   # raw logits, no activation
    • Why this step? Three Linear layers realize the two-hidden-layer spec; ReLU between them; the final layer stays "bare" because CrossEntropyLoss expects logits, not probabilities.
  3. Count.

    • fc1: .
    • fc2: .
    • fc3: .
    • Why this step? Cell-A rule per layer.
  4. Total. .

Verify: total params . ✅ Sanity: hundreds of thousands, as forecast for a modest MLP.


Example 8 — Cell H: the exam twist (weight tying)

Forecast: (two matrices) or (one shared matrix)?

Figure — Building models with nn.Module

Steps.

  1. Count naively. Two nn.Linear(4,4, bias=False) → each weight , total would be .

    • Why this step? This is the trap answer. We must check whether the tie changes the count.
  2. The tie makes them one object. self.dec.weight = self.enc.weight sets the decoder's weight to be the exact same tensor object as the encoder's.

    • Why this step? Look at the figure: both layers point at a single shared box of numbers. There is one tensor, referenced twice.
  3. .parameters() de-duplicates. PyTorch's parameter iterator returns each unique tensor once, even if several modules point to it.

    • Why this step? Counting it twice would apply the gradient twice; de-duplication keeps a single update per tensor.
  4. Answer. unique parameters, not .

Recall Why tie weights at all?

Tying enc/dec weights ::: it halves parameters and, in language models, forces the input and output embedding spaces to agree — often improving generalization.

Verify: len({id(p) for p in model.parameters()}) gives unique tensor of numbers → total numel . ✅


Recall Scenario matrix — self test

Plain Python list of layers: how many params does .parameters() see? ::: Zero — a list is not registered; use nn.ModuleList. DeepNetwork(..., num_layers=0, ...): does it crash? ::: No — range(0) is empty, input and output layers still run. nn.Linear(5,3) parameter count? ::: (weight + bias ). Dropout in eval mode? ::: Identity — deterministic pass-through, no zeroing. Two Linear layers sharing one weight tensor — counted how many times? ::: Once; .parameters() de-duplicates.