Deep Learning β€Ί Training Deep Networks β€Ί Day 204

Day 204: PyTorch: autograd and nn.Module

Day 204 of 365 β€” PyTorch: autograd and nn.Module

Master PyTorch automatic differentiation and neural network abstraction: inspect computational graphs, track requires_grad and grad_fn, build clean object-oriented architectures with torch.nn.Module and torch.nn.Parameter, manage state dictionaries, and execute robust training steps.

Course
Deep Learning
Category
Training Deep Networks
Reading time
β‰ˆ 35 min
Practical time
β‰ˆ 50 min
Lesson duration
1h 25m
Last verified
2026-08-29

Hands-on lab for this lesson

Lab files on GitHub: https://github.com/ai-roadmap-365/ai-roadmap-365.github.io/tree/main/labs/sections/deep-learning/day-204-pytorch-autograd-and-nn-module

  1. Get the hands-on files. Clone the labs repository once (you can reuse this clone for every lesson). This works on macOS, Linux, and Windows (PowerShell or WSL):
    git clone https://github.com/ai-roadmap-365/ai-roadmap-365.github.io.git
    cd ai-roadmap-365.github.io
  2. Open this lesson's lab. Move into the directory for this specific day. Every lab lives at the same predictable path β€” section / subsection / week / day:
    cd labs/sections/deep-learning/day-204-pytorch-autograd-and-nn-module
  3. Read the lab guide. Open `README.md` in that directory. It lists the exact commands, what each does, the expected output, and how to check your work β€” read it before running anything.
  4. Run it and check your work. Follow the README's "How to run" section: run the example first to see the finished result, then complete the numbered exercises in `starter/`, then run the tests. The tests pass (exit 0) only when your work is correct.
    bash tests/run_tests.sh   # or the test command named in the lab README

You can also open the lab as a local page (works offline, shows the file tree and expected output).

Learning objectives

By the end of this lesson you will be able to:

Prerequisites

Why this matters

In Week 29, you derived and implemented every equation of deep learning in pure NumPy: forward affine transformations, non-linear activation gates, analytical backpropagation Jacobians, He parameter initialization, and mini-batch gradient descent. You proved mathematically how gradients flow through layers and trained a model to 96% accuracy on MNIST.

However, writing manual backpropagation by hand for complex architectures (such as 100-layer Residual Networks, Multi-Head Attention Transformers, or Recurrent LSTMs) becomes mathematically intractable and error-prone. A single typo in an analytical matrix transpose can subtly corrupt gradient updates without crashing the program.

Today, we enter the modern era of deep learning engineering with PyTorch: autograd and torch.nn.Module.

PyTorch is the world-leading deep learning framework powering cutting-edge AI research and industrial production systems. By abstracting gradient derivation into dynamic, tape-based Reverse-Mode Automatic Differentiation (autograd) and encapsulating learnable parameters into clean object-oriented modules (nn.Module), PyTorch allows you to design arbitrary, dynamic neural architectures in standard Python while guaranteeing exact mathematical correctness on CPU, GPU, and TPU accelerators.


The idea in plain language

Imagine recording a musical performance on multi-track magnetic tape:

You write only the forward pass using standard Python control flow (if, for, while). PyTorch automatically differentiates whatever path your code took.


Historical background

  1. 2015 (Static Graph Era - Theano and TensorFlow 1.0): Early deep learning frameworks required engineers to construct a static graph (β€œDefine-and-Run”) in a separate domain-specific language before compiling and feeding data into a session. Debugging was painful because standard Python debuggers (pdb) could not inspect values inside the compiled graph.
  2. 2017 (Chainer and PyTorch): PyTorch introduced β€œDefine-by-Run” dynamic computation graphs. Graphs are constructed on the fly as standard Python code executes, enabling native Python loops, dynamic batch shapes, and interactive debugging.
  3. 2019 (PyTorch 1.0 - Paszke et al.): Unified research flexibility with production deployment capabilities through TorchScript, C++ libtorch runtimes, and distributed training engines.
  4. Today: PyTorch is the dominant framework in deep learning research and generative AI engineering.

What it is β€” and what it is not

What PyTorch Autograd & nn.Module IS:

What it is NOT:


Why it was created and what problems it solves

Manual gradient derivation has three major engineering bottlenecks:

  1. Mathematical Overhead: Modifying an activation function or adding a normalization layer requires re-deriving the entire backpropagation Jacobian chain by hand.
  2. Dynamic Control Flow: Handling variable-length sequences or recursive tree structures in static frameworks required complex control operators (tf.while_loop, tf.cond).
  3. State Management Friction: In pure NumPy, maintaining lists of weights, biases, and momentum buffers across dozens of layers requires extensive boilerplate code.

PyTorch solves all three bottlenecks by decoupling architecture design from derivative calculation.


How it works

Let us dissect the two core pillars of PyTorch: the Autograd Dynamic Computational Graph and the nn.Module Lifecycle.

PyTorch dynamic computation graph and autograd engine showing tensor forward execution and reverse gradient propagation through grad_fn nodes


1. Autograd Dynamic Graph Mechanics

Every PyTorch tensor contains several critical metadata attributes governing automatic differentiation:

A. requires_grad (Boolean Flag):

B. is_leaf (Boolean Flag):

C. grad_fn (Computational Graph Node):


2. The Backward Pass and Gradient Accumulation

When you call loss.backward():

  1. PyTorch verifies that loss is a scalar (a 0-dimensional tensor containing 1 element). If loss is a multi-element tensor, you must pass an explicit gradient vector (e.g. loss.backward(torch.ones_like(loss))).
  2. The autograd engine traverses the DAG in reverse topological order, evaluating the backward function of each grad_fn.
  3. Gradients propagate to the leaf parameters and are accumulated (added) into their .grad attributes:
    W.grad += dL / dW

[!IMPORTANT] Because gradients accumulate rather than overwrite, you must always call optimizer.zero_grad() or model.zero_grad() before loss.backward() in a training loop. Failing to zero gradients causes updates from previous batches to compound uncontrollably.


3. The torch.nn.Module Encapsulation Framework

PyTorch nn Module architecture showing parameter encapsulation submodules state dict serialization and forward execution hook

torch.nn.Module is the fundamental building block of PyTorch models. Subclassing nn.Module gives your class three major superpowers:

A. Automatic Parameter Discovery:

When you assign an nn.Parameter or another nn.Module (such as nn.Linear or nn.Conv2d) as an attribute in __init__(), PyTorch automatically registers it:

B. The __call__ Wrapper and Execution Hooks:

Never call model.forward(x) directly. Always invoke model(x). Invoking model(x) triggers nn.Module.__call__(), which executes:

  1. Registered forward pre-hooks (for profiling, layer introspection, or input modification).
  2. The user-defined forward(self, x) method.
  3. Registered forward post-hooks.

C. Model Modes and State Dictionaries:


4. Advanced Autograd: Hooks, Custom Functions, and In-Place Hazards

To diagnose complex deep learning architectures in production, engineers rely on three advanced features of PyTorch autograd:

A. Execution Hooks (register_forward_hook & register_backward_hook):

PyTorch modules allow attaching callback functions that execute automatically during forward or backward passes without modifying the original module code:

# Example: Attaching a forward hook to inspect activation norms
def log_activation_norm(module, input, output):
    print(f"Layer {module.__class__.__name__} output norm: {output.norm().item():.4f}")

hook_handle = model.fc1.register_forward_hook(log_activation_norm)
# When finished, remove the hook to prevent memory leaks
hook_handle.remove()

B. Custom Autograd Operators (torch.autograd.Function):

When you need to introduce non-differentiable operations, custom hardware kernels, or specialized mathematical functions with analytical gradient shortcuts:

  1. Subclass torch.autograd.Function.
  2. Define @staticmethod def forward(ctx, x, ...): Compute the output and save necessary context variables with ctx.save_for_backward(...).
  3. Define @staticmethod def backward(ctx, grad_output): Retrieve saved tensors with ctx.saved_tensors and return exact partial derivatives for every input.

C. In-Place Operation Hazards (inplace=True):

Modifying tensor memory directly in-place (e.g. x += 1 or torch.relu_(x)):


An everyday analogy

Think of PyTorch like an automated financial accounting ledger:


Examples in practice

Let us inspect a complete, canonical PyTorch neural network implementation:

import torch
import torch.nn as nn
import torch.optim as optim

class DeepClassifier(nn.Module):
    def __init__(self, in_features: int = 784, hidden_dim: int = 128, num_classes: int = 10):
        super().__init__()
        # Encapsulated submodules
        self.fc1 = nn.Linear(in_features, hidden_dim)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(hidden_dim, num_classes)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Forward pass definition
        h = self.relu(self.fc1(x))
        out = self.fc2(h) # Raw unnormalized logits
        return out

# 1. Instantiate model, loss, and optimizer
model = DeepClassifier(in_features=784, hidden_dim=128, num_classes=10)
criterion = nn.CrossEntropyLoss() # Combines LogSoftmax and NLLLoss stably
optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9)

# 2. Synthetic batch of 32 images
x_batch = torch.randn(32, 784)
y_batch = torch.randint(0, 10, (32,))

# 3. Canonical 5-step training iteration
model.train()
optimizer.zero_grad()            # Step 1: Clear old gradients
logits = model(x_batch)          # Step 2: Forward pass (via __call__)
loss = criterion(logits, y_batch)# Step 3: Compute scalar loss
loss.backward()                  # Step 4: Backward pass (Autograd DAG traversal)
optimizer.step()                 # Step 5: Update parameters via optimizer rule

print(f"Training step complete. Loss: {loss.item():.4f}")

Implications: security, privacy, performance, scalability, and cost

  1. Memory Management and Graph Lifecycle:
    • By default, PyTorch destroys the computational graph immediately after loss.backward() finishes executing to reclaim memory. If you need to backpropagate through the same graph twice, you must pass loss.backward(retain_graph=True).
  2. Inference Acceleration via torch.no_grad():
    • Wrapping validation loops in with torch.no_grad(): suppresses graph allocation entirely, reducing memory footprint by over 50% and boosting execution speed by up to 2x during model evaluation.

Alternatives: free, open source, and commercial

FrameworkDifferentiation ParadigmModel DefinitionEcosystem Strength
PyTorch (Meta / Linux Foundation)Dynamic Define-by-Runnn.Module Class SubclassingDominant in AI Research & LLMs
JAX / Flax (Google)Pure Functional TransformationPure Functions + DataclassesHigh Performance on TPUs & Physics
TensorFlow 2 / Keras (Google)Hybrid (Eager + tf.function)keras.Model Functional / SequentialStrong in Mobile (TFLite) & TFJS
Tinygrad (George Hotz)Minimalist Autograd EngineLean Python RuntimeExtreme Simplicity (< 5,000 LOC)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   PYTORCH VS PURE NUMPY COMPARISON                     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Dimension             β”‚ Pure NumPy (Week 29)  β”‚ PyTorch (Week 30)      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Gradient Calculation  β”‚ Manual Analytical Mathβ”‚ Automated Autograd DAG β”‚
β”‚ GPU / Hardware Accel  β”‚ None (CPU Only)       β”‚ 1-Line: .to('cuda')    β”‚
β”‚ Parameter Tracking    β”‚ Manual Dictionaries   β”‚ Automatic Module Tree  β”‚
β”‚ Checkpoint Persistenceβ”‚ Manual Pickle / NPZ   β”‚ Native state_dict()    β”‚
β”‚ Custom Architectures  β”‚ Hundreds of math linesβ”‚ Single forward() methodβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

When to use it β€” and when not to

When to USE PyTorch nn.Module:

When NOT to use it:


Knowledge check

  1. What is the role of grad_fn on intermediate tensors in PyTorch?
  2. Why does calling loss.backward() accumulate gradients into .grad rather than overwriting them?
  3. What is the difference between calling model(x) versus calling model.forward(x)?
  4. What happens when code executes inside a with torch.no_grad(): block?
  5. How does nn.Module.state_dict() serialize the state of a model?

Hands-on exercise

In this lab, you will build and test a complete modular PyTorch neural network: construct a custom nn.Module classifier, verify parameter gradient tracking with autograd, inspect grad_fn computational graph nodes, test requires_grad isolation and torch.no_grad() behavior, save and restore model weights via state_dict, and execute a multi-step training cycle.

Expected output

[PyTorch Autograd & nn.Module Architecture Suite]
Constructing Custom DeepClassifier Module [784 -> 128 -> 10]:
  Total Trainable Parameters: 101,770
Verifying Autograd Computation Graph:
  Output Logits grad_fn: <AddmmBackward0>
  Loss Tensor grad_fn: <NllLossBackward0>
Executing 5-Step Canonical Training Loop:
  Step 1: Initial Loss = 2.3412
  Step 5: Final Loss   = 0.4120 [GRADIENTS ZEROED & UPDATED]
Testing Model Checkpoint Serialization:
  Saved state_dict (4 parameter tensors) -> Restored successfully.
Test Suite: 4 passed in 0.25s

Validate your work

Run the automated test runner:

./tests/run_tests.sh

Troubleshooting

Common mistakes


Practice assignment

  1. Implement a custom autograd function by subclassing torch.autograd.Function with static forward and backward methods for a polynomial activation f(x) = x^3 + 2x.
  2. Inspect model.named_parameters() and write a utility function that freezes all layers except the final classification head (param.requires_grad = False).

Extension challenge

Implement a Dynamic Multi-Branch Module that takes two input feature vectors, routes them through separate subnetworks, concatenates their intermediate representations, and applies a shared classification head. Verify that autograd correctly routes separate gradients back through both parallel input branches simultaneously.

Quiz

Q1. What happens under the hood when you invoke loss.backward() on a scalar tensor in PyTorch?

  1. PyTorch traverses the dynamic computational graph backwards starting from the loss node, executing each node grad_fn in reverse order and accumulating analytical partial derivatives into the .grad attribute of every leaf tensor where requires_grad=True
  2. It deletes all tensors in RAM
  3. It resets all model weights to random numbers
  4. It compiles Python code into C++ binaries
Show answer

Answer: A. PyTorch traverses the dynamic computational graph backwards starting from the loss node, executing each node grad_fn in reverse order and accumulating analytical partial derivatives into the .grad attribute of every leaf tensor where requires_grad=True

loss.backward() executes reverse-mode automatic differentiation along the dynamically recorded tape, populating .grad on all leaf parameters.

Q2. Why is it mandatory to call optimizer.zero_grad() (or model.zero_grad()) before executing loss.backward() in a standard training loop?

  1. By design, PyTorch accumulates (sums) gradients into existing .grad buffers across backward passes; without zeroing, gradients from previous batches would sum together and corrupt parameter updates
  2. To free up GPU memory
  3. To prevent the model from overfitting
  4. To normalize inputs to zero mean
Show answer

Answer: A. By design, PyTorch accumulates (sums) gradients into existing .grad buffers across backward passes; without zeroing, gradients from previous batches would sum together and corrupt parameter updates

PyTorch accumulates gradients by default (which enables gradient accumulation across mini-batches). Without zero_grad(), subsequent batches would add to previous gradients.

Q3. When evaluating a trained PyTorch model on a validation dataset, which context manager should you wrap the forward pass in to disable graph construction and reduce memory overhead?

  1. with torch.no_grad():
  2. with torch.autograd.detect_anomaly():
  3. with torch.enable_grad():
  4. with open("eval.txt"):
Show answer

Answer: A. with torch.no_grad():

with torch.no_grad(): deactivates autograd tracking, preventing intermediate activation caching in the computation graph and significantly reducing inference memory consumption.

Q4. What is the critical difference between invoking model(x) versus invoking model.forward(x) on an instance of torch.nn.Module?

  1. Calling model(x) invokes the __call__ method of nn.Module, which executes registered forward pre-hooks and post-hooks before and after calling forward(x); calling model.forward(x) directly bypasses all hooks
  2. model(x) runs on GPU while model.forward(x) runs on CPU
  3. model(x) is slower by 50%
  4. There is no difference
Show answer

Answer: A. Calling model(x) invokes the __call__ method of nn.Module, which executes registered forward pre-hooks and post-hooks before and after calling forward(x); calling model.forward(x) directly bypasses all hooks

Always call model(x) rather than model.forward(x). The Module __call__ wrapper ensures internal hooks, profilers, and autograd callbacks execute correctly.

Q5. What does model.state_dict() return, and how is it used for checkpoint persistence?

  1. It returns an OrderedDict mapping each parameter and persistent buffer name (as strings) to its underlying torch.Tensor; it is saved with torch.save() and reloaded with model.load_state_dict()
  2. It returns the accuracy of the model on the training set
  3. It returns the Python source code of the class
  4. It returns a list of CPU hardware specifications
Show answer

Answer: A. It returns an OrderedDict mapping each parameter and persistent buffer name (as strings) to its underlying torch.Tensor; it is saved with torch.save() and reloaded with model.load_state_dict()

state_dict() serializes the exact parameter and buffer tensors of a model, providing a robust format for model checkpointing and deployment.

Glossary

Autograd
PyTorch reverse-mode automatic differentiation engine that dynamically builds a Directed Acyclic Graph (DAG) during the forward pass to compute analytical gradients.
torch.nn.Module
The fundamental base class for all neural network architectures in PyTorch, encapsulating layers, parameters, forward logic, and hooks.
requires_grad
A boolean tensor flag instructing autograd whether to record operations on this tensor in the computational graph.
grad_fn
A reference on a non-leaf tensor pointing to the backward function node that generated it during forward execution.
Leaf Tensor
A tensor created directly by the user (such as model weights or inputs) that is not the output of a tracked operation.
torch.no_grad()
A Python context manager that disables autograd tracking to accelerate evaluation and reduce memory consumption.
state_dict
A Python dictionary mapping layer names to their corresponding parameter and persistent buffer tensors.
Gradient Accumulation
The default behavior in PyTorch where new gradients computed by backward() are summed into existing .grad buffers rather than overwriting them.

Sources and further reading


Kept in this browser, no account needed. Your progress page turns the whole record into one link you can bookmark or open on another device.