Custom PyTorch modules are the point where loose tensor operations become reusable model code. A class based on torch.nn.Module can hold layers, expose trainable parameters to optimizers, and keep the forward pass in one named object that can be saved, loaded, tested, and reused.
A module registers child layers when they are assigned as attributes in __init__(). The forward() method defines how an input tensor moves through those layers, while calling the module instance, such as model(features), lets PyTorch run the normal module call path around that method.
The smoke workflow uses a small CPU regression model with two nn.Linear layers and a ReLU activation. A forward pass proves the output shape, and one optimizer step proves that gradients reach the registered parameters.
import torch from torch import nn torch.manual_seed(7) class SensorRegressor(nn.Module): def __init__(self, in_features=4, hidden_features=8, out_features=2): super().__init__() self.input = nn.Linear(in_features, hidden_features) self.activation = nn.ReLU() self.output = nn.Linear(hidden_features, out_features) def forward(self, features): hidden = self.activation(self.input(features)) return self.output(hidden) features = torch.tensor( [ [0.2, 0.1, 0.7, 0.4], [0.9, 0.0, 0.5, 0.3], [0.4, 0.4, 0.2, 0.8], ], dtype=torch.float32, ) targets = torch.tensor( [ [0.5, 0.1], [0.8, 0.2], [0.3, 0.6], ], dtype=torch.float32, ) model = SensorRegressor() optimizer = torch.optim.SGD(model.parameters(), lr=0.05) loss_fn = nn.MSELoss() predictions = model(features) loss = loss_fn(predictions, targets) optimizer.zero_grad(set_to_none=True) loss.backward() gradient_norms = [ parameter.grad.detach().norm() for parameter in model.parameters() if parameter.grad is not None ] gradient_norm = torch.linalg.vector_norm(torch.stack(gradient_norms), 2) output_weight_before = model.output.weight.detach().clone() optimizer.step() weight_delta = (model.output.weight.detach() - output_weight_before).abs().max() print(f"torch_version={torch.__version__}") print("module=SensorRegressor") print(f"parameter_tensors={len(list(model.parameters()))}") print(f"trainable_parameters={sum(parameter.numel() for parameter in model.parameters())}") print(f"output_shape={tuple(predictions.shape)}") print(f"loss={loss.item():.6f}") print(f"gradient_norm={gradient_norm.item():.6f}") print(f"optimizer_step_changed_weight={bool(weight_delta > 0)}")
super().init() initializes the base module machinery before child layers are assigned. Layers stored in a plain local variable inside __init__() are not registered as child modules.
$ python custom_module_demo.py torch_version=2.12.1+cpu module=SensorRegressor parameter_tensors=4 trainable_parameters=58 output_shape=(3, 2) loss=0.311184 gradient_norm=1.048025 optimizer_step_changed_weight=True
parameter_tensors=4 and trainable_parameters=58 confirm the two Linear layers are registered. output_shape=(3, 2) confirms the module maps a batch of three four-feature rows to two outputs per row.
class SensorRegressor(nn.Module): def __init__(self, in_features=4, hidden_features=8, out_features=2): super().__init__() self.input = nn.Linear(in_features, hidden_features) self.activation = nn.ReLU() self.output = nn.Linear(hidden_features, out_features) def forward(self, features): hidden = self.activation(self.input(features)) return self.output(hidden)
Keep layer names stable when checkpoints or saved state dictionaries will load weights back into the class.
model = SensorRegressor(in_features=4, hidden_features=8, out_features=2) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4) loss_fn = nn.MSELoss() for features, targets in train_loader: optimizer.zero_grad(set_to_none=True) predictions = model(features) loss = loss_fn(predictions, targets) loss.backward() optimizer.step()
Keep features, targets, and the module parameters on the same device.
Related: How to zero gradients in PyTorch
model.eval() with torch.inference_mode(): predictions = model(features)
Calling model(features) uses the module call path that wraps forward() and honors registered hooks. Direct calls such as model.forward(features) skip that dispatch path.
$ rm custom_module_demo.py