Custom Layers & Advanced Containers¶
Overview¶
PyTorch lets you write layers as plain Python classes— but the framework discovers parameters and submodules through attribute inspection, not through your code. Learning the discovery rules lets you build clean, correct, composable layers instead of fighting the framework.
nn.Modulesubclasses get params/submodules from attributes assigned in__init__.- Plain Python containers (
list,dict) are invisible to the module tree → useModuleList/ModuleDict/ParameterList. forwardis just Python; any logic is allowed, but the graph comes from tensor ops.- Reuse primitive ops (
F.conv2d,F.linear)— you rarely need to implement raw kernels in Python.
If a param "disappears" from
model.parameters()after serialization, it was never registered— check how you store it.
-
Anatomy of a Custom Layer¶
import torch
import torch.nn as nn
import torch.nn.functional as F
class DenseBlock(nn.Module):
def __init__(self, in_ch, out_ch, kernel=3, stride=1):
super().__init__()
# REGISTERED as submodule -> device moves & state_dict follow automatically
self.conv = nn.Conv2d(in_ch, out_ch, kernel, stride=stride, padding=kernel // 2)
self.bn = nn.BatchNorm2d(out_ch)
# Plain tensor would NOT be a param:
self.alpha = nn.Parameter(torch.tensor(1.0)) # <- registered leaf param
def forward(self, x):
return self.alpha * F.relu(self.bn(self.conv(x)))
model = DenseBlock(3, 8)
print("params:", [n for n, _ in model.named_parameters()])
The Discovery Rules (the "module tree")¶
class Naive(nn.Module):
def __init__(self):
super().__init__()
self.layers = [nn.Linear(4, 4) for _ in range(2)] # invisible!
self.also_lost = {"a": nn.Linear(4, 4)} # invisible!
class Fixed(nn.Module):
def __init__(self):
super().__init__()
self.layers = nn.ModuleList([nn.Linear(4, 4) for _ in range(2)])
self.also_kept = nn.ModuleDict({"a": nn.Linear(4, 4)})
naive = Naive(); fixed = Fixed()
print("Naive params:", sum(p.numel() for p in naive.parameters())) # 0
print("Fixed params:", sum(p.numel() for p in fixed.parameters())) # 2*20
Parameter vs plain tensor¶
| Storage | Found by .parameters() |
Tracked by state_dict | Grad by default |
|---|---|---|---|
nn.Parameter(t) |
|||
| plain tensor attr | (unless register_buffer) |
class BufferExample(nn.Module):
def __init__(self):
super().__init__()
self.register_buffer("running_mean", torch.zeros(3)) # moved with.to(), saved, no grads
b = BufferExample()
b.to('meta') # buffers follow device moves too
print(b.running_mean.device)
-
Containers: ModuleList, ModuleDict, Sequential¶
m = nn.ModuleList([
nn.Linear(4, 8),
nn.Linear(8, 2),
])
# index access, iterate, keep tree intact
out = m[1](m[0](torch.randn(3, 4)))
md = nn.ModuleDict({"up": nn.Linear(4, 8), "down": nn.Linear(8, 4)})
print(md["up"])
When Sequential isn't enough¶
nn.Sequential hardwires order; build forward manually for branching/skipping/attention masks:
class Branchy(nn.Module):
def __init__(self):
super().__init__()
self.trunk = nn.Sequential(nn.Linear(4, 8), nn.ReLU())
self.side = nn.Linear(4, 8)
self.head = nn.Linear(8, 2)
def forward(self, x):
return self.head(self.trunk(x) + self.side(x)) # residual branch
Init Discipline— Where & When¶
class W(nn.Module):
def __init__(self, dims):
super().__init__()
self.w = nn.Parameter(torch.empty(*dims))
self.reset_parameters() # call in __init__ right after alloc
def reset_parameters(self):
nn.init.xavier_uniform_(self.w)
# mirror torch's own pattern (Linear.reset_parameters)
Avoid lazy init inside
forward— it breaks export, device moves, andstate_dictround-trips. (Exceptnn.LazyLinearetc., which manage the bookkeeping.)
-
Building Blocks Best Practices¶
- Compose via small modules, not giant
forwardmethods, to keepnamed_modules()usable. - Use
F.*ops for pointwise/activation logic— no params, no state. - Keep non-tensor config as plain attributes (
self.kernel = kernel)— they don't need registration. - Override
extra_repr()for readableprint(model), and__repr__stays informative. - Never
delor reassign params insideforward; assign once in__init__.
class ConvBlock(nn.Module):
def __init__(self, c, k=3):
super().__init__()
self.conv = nn.Conv2d(c, c, k, padding=k // 2)
self.gn = nn.GroupNorm(min(8, c), c)
def extra_repr(self):
return f"groupnorm_groups={self.gn.num_groups}"
def forward(self, x):
return F.relu(self.gn(self.conv(x)))
-
Key Takeaways¶
- The module tree is attribute-based: registration is what makes params trainable, movable, serializable.
- Reaching for
nn.ModuleList/Dict/ParameterListinstead of Python lists fixes 90% of "missing param" bugs. forwardfreedom is your friend, but correctness (params, buffers, init) lives in__init__.- Prefer
F.*primitives; keep classes small andextra_reprinformative. - Init immediately after allocation, not lazily during forward.
-
Related Topics¶
- Weight Initialization
- [Parameter Sharing & Weight Tying](/06-pytorch/02-module-and-layer-engineering/(03-parameter-sharing-weight-tying/)
- Hooks