Worked examples — Building models with nn.Module
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.
-
Write what
nn.Linearcomputes. It computes .- Why this step? You cannot count parameters until you know which tensors exist.
nn.Linearowns exactly two: the weight and the bias .
- Why this step? You cannot count parameters until you know which tensors exist.
-
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.
-
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.
- Why this step? Each output neuron gets its own additive shift, so bias length =
-
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.
-
fc1: . Weight , bias . Subtotal .
- Why this step? Use the Cell-A rule per layer: .
-
fc2: . Weight , bias . Subtotal .
- Why this step? Same rule; here in = out = 128.
-
fc3: . Weight , bias . Subtotal .
- Why this step? Output layer for binary classification produces a single logit.
-
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.
-
Look at the loop.
nn.ModuleList([nn.Linear(20,20) for _ in range(0)])— the comprehensionrange(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 emptynn.ModuleListis perfectly valid.
- Why this step? Degenerate inputs live in the loop bounds.
-
What survives? Only
input_layer() andoutput_layer().- Why this step? The network still transforms input to output — it just has no middle.
-
Count. input_layer: . output_layer: . Total .
- Why this step? Cell-A rule per surviving layer.
-
Forward still runs. The
for layer in self.hidden_layers:loop body simply never executes; data flowsinput_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.

Steps.
-
Recall the clipboard rule. From the definition above: a plain
listis not on the clipboard. PyTorch's__setattr__magic only registersnn.Module,nn.Parameter, and official containers.- Why this step? Registration is what makes
.parameters()find something. No registration → invisible.
- Why this step? Registration is what makes
-
Trace what happens.
self.hidden = [...]stores a normal list. PyTorch sees "a list" and shrugs — it does not peek inside to find thenn.Linearobjects.- Why this step? Look at the figure: the three orange layers sit outside the clipboard, so the collector's recursive walk never reaches them.
-
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.
-
The fix. Wrap it:
self.hidden = nn.ModuleList([...]). Now all appear.- Why this step?
nn.ModuleListis on the clipboard, so its contents are registered recursively.
- Why this step?
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?

Steps.
-
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.
-
Channels.
Conv2d(64, 64, ...)keeps channels at .- Why this step? The addition also needs matching channel count. In-channels = out-channels = 64 by design.
-
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.
-
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.
-
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.
-
Why the scale. Expected value must be preserved: .
- Why this step? So that switching to eval doesn't shift the average activation.
-
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.
-
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.
-
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.
-
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
CrossEntropyLossexpects logits, not probabilities.
- Why this step? Three Linear layers realize the two-hidden-layer spec; ReLU between them; the final layer stays "bare" because
-
Count.
- fc1: .
- fc2: .
- fc3: .
- Why this step? Cell-A rule per layer.
-
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)?

Steps.
-
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.
-
The tie makes them one object.
self.dec.weight = self.enc.weightsets 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.
-
.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.
-
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.