Question bank — Building models with nn.Module
Before we start, a few words we lean on the whole way. Read these once so nothing below uses a term you haven't met.
The parent note is Building models with nn.Module — everything here goes one level deeper into the traps that note's clean code hides.
Three pictures anchor the ideas the traps keep circling back to.
The registration tree — why .parameters(), .to(device), and the optimizer all "just work":

Forward-pass control flow — why you call model(x), never model.forward(x):

The residual skip — why the +identity path keeps gradients alive:

True or false — justify
TF1. Storing layers in a plain Python list self.layers = [nn.Linear(...), ...] trains them just like storing them individually.
.parameters() and the optimizer (the object that nudges parameters) never receives them. Use nn.ModuleList, which registers each contained module.TF2. nn.ReLU() and the functional F.relu(x) produce identical numbers on the same input.
max(0, x). The module form only adds convenience (it appears in .modules() and prints in the model summary).TF3. Forgetting super().__init__() still works as long as you define forward.
__setattr__ machinery. Without it, assigning self.fc1 = nn.Linear(...) raises an error (or silently fails to register), because the module's parameter/submodule dictionaries were never created.TF4. Every tensor you create inside __init__ becomes a trainable parameter.
nn.Parameter are registered as parameters. A raw torch.randn(10) assigned to self.x is stored but is not trainable and won't move with .to(device) unless you register it as a buffer (state that is saved and moved but never trained).TF5. model.parameters() returns only the parameters defined directly in that class, not those of its submodules.
TF6. Calling model(x) and model.forward(x) are completely interchangeable.
model(x) goes through __call__, which runs registered hooks and bookkeeping around forward. model.forward(x) skips all of that. Always use model(x).TF7. nn.Parameter tensors have requires_grad=True by default.
requires_grad=False.TF8. Two modules that share the same nn.Parameter object will have that parameter counted twice by sum(p.numel() for p in model.parameters()) if you're not careful.
model.parameters() at the top level de-duplicates by identity, but iterating submodules separately and summing can over-count tied weights.Spot the error
SE1. self.layers = [nn.Linear(4,4) for _ in range(3)] then later for l in self.layers: x = l(x).
nn.ModuleList([...]), the list-like container that registers each module.SE2. def __init__(self): self.fc = nn.Linear(4,2) — no super().__init__() line.
self.fc before the module internals exist throws AttributeError: cannot assign module before Module.__init__() call.SE3. self.w = torch.randn(4, 2) used as a weight inside forward with x @ self.w (where @ is Python's matrix-multiply operator).
torch.randn gives a plain tensor: not registered, requires_grad=False, won't train, won't move with .to(device). Fix: self.w = nn.Parameter(torch.randn(4, 2)).SE4. def forward(self, x): return self.fc1(x); self.fc2(x).
return on the first statement ends the function; self.fc2 never runs. The second layer's output is silently discarded. Chain them: x = self.fc1(x); return self.fc2(x).SE5. A binary classifier ends with x = torch.sigmoid(self.fc3(x)) and is trained with nn.BCEWithLogitsLoss.
BCEWithLogitsLoss applies its own sigmoid internally. Applying sigmoid first squashes twice, wrecking the gradient. Either output raw logits with BCEWithLogitsLoss, or use plain BCELoss with an explicit sigmoid — never both.SE6. Residual block writes out = self.relu(out); out = out + identity (ReLU before adding the skip).
out = out + identity; out = self.relu(out). Activating the residual branch alone can clip the identity path's contribution and hurts gradient flow.SE7. self.hidden = nn.ModuleList([...]) but in forward the loop is for layer in self.hidden_layers.
self.hidden vs self.hidden_layers — raises AttributeError. A registration name and its usage must match exactly.SE8. Model defined with nn.Dropout but during evaluation the code never calls model.eval().
model.eval() (and model.train() to switch back) so stateful modules behave correctly.Why questions
WHY1. Why subclass nn.Module at all instead of writing a function layer(x, w, b)?
WHY2. Why is the output layer of a classifier usually left with no activation?
BCEWithLogitsLoss, CrossEntropyLoss) expects raw logits and applies the softmax/sigmoid internally for numerical stability. Adding your own activation double-counts it.WHY3. Why does the "+1" (the identity path) in a residual block fight vanishing gradients?
WHY4. Why store even a stateless ReLU as self.relu = nn.ReLU() rather than calling F.relu?
WHY5. Why does .to(device) move everything with one call?
.to(device) walks that tree and moves each registered tensor — which is exactly why unregistered raw tensors get left behind on the wrong device.WHY6. Why must you register a running statistic (like a mean you compute yourself) as a buffer rather than a parameter?
WHY7. Why does nn.Linear beat a hand-written torch.randn weight matrix?
nn.Linear initializes weights with a principled scheme (Kaiming/Xavier) that keeps activation variance sane across layers, handles the bias correctly, and registers everything. Raw randn gives poorly scaled, unregistered weights.Edge cases
EC1. What does model.parameters() return for a module with no nn.Parameter and no submodules (e.g. a pure nn.ReLU)?
EC2. You create nn.Parameter(torch.randn(0)) (a zero-length tensor). Is it a valid parameter?
numel() == 0), so it contributes nothing to training or the parameter count. Usually a sign of a logic bug in your dimension arithmetic.EC3. What happens if two layers share the same weight object (weight tying) — how many parameters does the optimizer update?
.parameters() de-duplicates it, but manual per-submodule counting will over-count.EC4. num_layers = 0 is passed to a network that builds nn.ModuleList([... for _ in range(num_layers)]). Does it break?
ModuleList. The forward loop simply never executes, so input flows straight from the input layer to the output layer. Degenerate but valid.EC5. You reassign self.fc1 = nn.Linear(4, 4) a second time later in __init__. Which one is registered?
Linear's parameters vanish from the tree.EC6. Input batch of size 1 vs a bare unbatched vector — does a nn.Linear-based model care?
nn.Linear operates on the last dimension, so a shape [1, in] works fine. A bare [in] also works for Linear, but BatchNorm and many modules require a batch dimension and error on unbatched input — always keep the leading batch axis.EC7. During model.eval() with no Dropout/BatchNorm anywhere, does anything actually change?
Recall Quick self-test
Why won't a plain Python list of layers train? ::: Its modules are never registered, so .parameters() never sees them and the optimizer never updates them.
What does the +identity in a residual block guarantee for gradients? ::: A +1 term in , so gradients survive even when .
Why leave classifier outputs as logits? ::: The loss applies sigmoid/softmax internally for numerical stability; adding your own double-applies it.