3.3.2Deep Learning Frameworks

Autograd and computational graphs in PyTorch

3,647 words17 min readdifficulty · medium

Overview

PyTorch's autograd system is the engine that powers automatic differentiation—it tracks operations on tensors and computes gradients automatically. This eliminates manual derivative calculations during backpropagation, making deep learning research fast and error-free.

Figure — Autograd and computational graphs in PyTorch

Core Concepts

How Autograd Works: Step-by-Step Derivation

Step 1: Forward Pass and Graph Construction

When you perform operations on tensors with requires_grad=True, PyTorch records them:

import torch
 
x = torch.tensor([2.0], requires_grad=True)
y = torch.tensor([3.0], requires_grad=True)
 
z = x * y + x**2  # z = 3x + x²

What happens internally:

  • x * y creates a node with grad_fn=<MulBackward>
  • x**2 creates a node with grad_fn=<PowBackward>
  • + combines them with grad_fn=<AddBackward>
  • z now has z.grad_fn = <AddBackward> pointing to the entire graph

Why this matters: The graph structure encodes the chain of operations, which is exactly what we need for the chain rule.

Step 2: Backward Pass and Chain Rule

The chain rule states: if z=f(g(x))z = f(g(x)), then dzdx=dzdgdgdx\frac{dz}{dx} = \frac{dz}{dg} \cdot \frac{dg}{dx}.

For our example z=xy+x2z = xy + x^2:

dzdx=x(xy)+x(x2)=y+2x\frac{dz}{dx} = \frac{\partial}{\partial x}(xy) + \frac{\partial}{\partial x}(x^2) = y + 2x

dzdy=y(xy)+y(x2)=x+0=x\frac{dz}{dy} = \frac{\partial}{\partial y}(xy) + \frac{\partial}{\partial y}(x^2) = x + 0 = x

Derivation from first principles:

Starting with the definition of derivative: dzdx=limh0z(x+h,y)z(x,y)h\frac{dz}{dx} = \lim_{h \to 0} \frac{z(x+h, y) - z(x, y)}{h}

=limh0[(x+h)y+(x+h)2][xy+x2]h= \lim_{h \to 0} \frac{[(x+h)y + (x+h)^2] - [xy + x^2]}{h}

=limh0xy+hy+x2+2xh+h2xyx2h= \lim_{h \to 0} \frac{xy + hy + x^2 + 2xh + h^2 - xy - x^2}{h}

=limh0hy+2xh+h2h=limh0(y+2x+h)=y+2x= \lim_{h \to 0} \frac{hy + 2xh + h^2}{h} = \lim_{h \to 0}(y + 2x + h) = y + 2x

Why this step? We expand the function at x+hx+h, cancel terms, factor out hh, and take the limit. This is the fundamental definition of a derivative.

PyTorch automates this:

z.backward()  # Compute dz/dx and dz/dy
print(x.grad)  # tensor([8.]) = 3+ 2*2
print(y.grad)  # tensor([2.]) = 2

Step 3: Gradient Accumulation

By default, calling .backward() accumulates gradients into .grad:

tensor.gradtensor.grad+Ltensor\text{tensor.grad} \leftarrow \text{tensor.grad} + \frac{\partial L}{\partial \text{tensor}}

Why accumulation? In mini-batch training, you sum gradients across multiple samples before updating weights. But this means you must zero gradients between iterations:

x.grad.zero_()  # Clear previous gradients

Derivation of why accumulation is needed:

In batch gradient descent, the loss is: Lbatch=1Ni=1NLiL_{\text{batch}} = \frac{1}{N}\sum_{i=1}^N L_i

The gradient is: Lbatchw=1Ni=1NLiw\frac{\partial L_{\text{batch}}}{\partial w} = \frac{1}{N}\sum_{i=1}^N \frac{\partial L_i}{\partial w}

If we compute Liw\frac{\partial L_i}{\partial w} for each sample separately, we must sum them. Accumulation implements this sum automatically.

Detailed Examples

Common Mistakes

Dynamic vs Static Graphs

The .grad_fn Chain

Every tensor created by an operation stores a reference to the function that created it:

x = torch.tensor([1.0], requires_grad=True)
y = x * 2
z = y + 3
 
print(y.grad_fn)  # <MulBackward0>
print(z.grad_fn)  # <AddBackward0>
print(z.grad_fn.next_functions)  # Shows MulBackward0

This chain is the computational graph. Following grad_fn pointers backward reconstructs the entire computation.

Leaf tensors (created by user, not operations) have grad_fn=None and is_leaf=True:

print(x.is_leaf)  # True
print(y.is_leaf)  # False (created by operation)

Why this matters: Only leaf tensors store gradients by default. Intermediate gradients are freed unless you call retain_grad().

Advanced: torch.no_grad() and Inference Mode

During inference, you don't need gradients. Disabling autograd saves memory and speeds up computation:

with torch.no_grad():
    output = model(x)  # No graph built

Why this works: PyTorch skips grad_fn assignment, eliminating overhead.

Mathematical insight: In inference, we only need the forward pass: y^=f(x;θ)\hat{y} = f(x; \theta)

No need to compute Lθ\frac{\partial L}{\partial \theta}, so no graph required.

Inference mode (newer, faster):

with torch.inference_mode():
    output = model(x)

Difference: inference_mode() is more aggressive—tensors created inside cannot be used in gradient computation later (even outside the context). Use for pure inference; use no_grad() if you might need gradients later.

Memory Management

Strategies:

  1. Gradient checkpointing: Recompute intermediate activations during backward pass (trade compute for memory)
  2. Delete graphs after use: PyTorch's default (graphs freed after .backward())
  3. Detach unnecessary tensors: Stop gradients where not needed

Formula for memory cost: MemoryO(NS)\text{Memory} \approx O(N \cdot S) where NN = number of layers, SS = activation size. Gradient checkpointing reduces this to O(NS)O(\sqrt{N} \cdot S) at cost of 2×2 \times compute.

Active Recall Exercises

Recall Explain to a 12-Year-Old

Imagine you're building a really tall tower with LEGO blocks. Every time you place a block, you write down on a piece of paper: "I put block #5 on top of block #3." That list of notes is your computational graph—it's a record of how you built the tower.

Now, at the end, someone tells you, "The tower is 2 inches too tall." You need to figure out which blocks to remove. You start at the top and work backward: "If I remove this block, the tower gets 1 inch shorter. If I remove the one below, it gets another inch shorter." That's backpropagation—going backward through your notes to see how each piece contributed to the final height.

PyTorch's autograd is like having a robot that automatically writes those notes for you and calculates how changing each block affects the tower's height. You just build (forward pass), and the robot figures out the changes (backward pass) for you!

Connections


#flashcards/ai-ml

What is a computational graph in PyTorch? :: A directed acyclic graph (DAG) where nodes are tensors/operations and edges are data flow. PyTorch builds it dynamically during the forward pass and uses it during backward pass to compute gradients via the chain rule.

What does requires_grad=True do?
Marks a tensor for gradient tracking. PyTorch records all operations on such tensors, building the computational graph that enables automatic differentiation during backpropagation.
Why must you call optimizer.zero_grad() before each training iteration?
PyTorch accumulates gradients by default (adds new gradients to existing .grad values). Without zeroing, gradients from previous iterations add up incorrectly, causing wrong parameter updates.
What's the difference between torch.no_grad() and torch.inference_mode()?
Both disable gradient tracking. no_grad() allows tensors to later be used in gradient computation (after exiting the context). inference_mode() is more aggressive—tensors created inside cannot participate in autograd later, but it's faster for pure inference.
Why does .backward() require a scalar input?
Backpropagation computes Lx\frac{\partial L}{\partial x} where LL is scalar. For vector outputs, the gradient would be a Jacobian matrix. You must reduce to scalar (via sum, mean, or indexing) or provide a gradient argument.
What does .detach() do?
Creates a new tensor that shares data with the original but has requires_grad=False and grad_fn=None. Gradients do not flow through detached tensors—they're treated as constants in the computational graph.
What is grad_fn?
A reference to the function that created a tensor (e.g., <MulBackward>, <AddBackward>). It forms the computational graph—following grad_fn pointers backward reconstructs the sequence of operations needed for backpropagation.
Why do in-place operations (like +=) sometimes break autograd?
Autograd needs original values to compute correct gradients. In-place ops overwrite history. If you modify a tensor that's needed later in the backward pass, the gradient computation fails. Use out-of-place operations (y = y + 1 instead of y += 1) during training.
What's the chain rule formula for a computational graph path xabzx \to a \to b \to z?
zx=zbbaax\frac{\partial z}{\partial x} = \frac{\partial z}{\partial b} \cdot \frac{\partial b}{\partial a} \cdot \frac{\partial a}{\partial x}. Autograd computes this by traversing the graph backward, multiplying local gradients at each node.
When would you use retain_graph=True?
When you need to call .backward() multiple times on the same graph (e.g., computing multiple losses from one forward pass in multi-task learning or adversarial training). By default, PyTorch frees the graph after first .backward() to save memory.
What's a leaf tensor?
A tensor created directly by the user (not as result of an operation). It has is_leaf=True and grad_fn=None. Only leaf tensors store gradients by default; intermediate tensors need retain_grad() to keep their gradients.

Derive ddx(x2)\frac{d}{dx}(x^2) from the limit definition :: ddx(x2)=limh0(x+h)2x2h=limh0x2+2xh+h2x2h=limh02xh+h2h=limh0(2x+h)=2x\frac{d}{dx}(x^2) = \lim_{h \to 0} \frac{(x+h)^2 - x^2}{h} = \lim_{h \to 0} \frac{x^2 + 2xh + h^2 - x^2}{h} = \lim_{h \to 0} \frac{2xh + h^2}{h} = \lim_{h \to 0}(2x + h) = 2x

Why does gradient accumulation happen by default in PyTorch?
To support mini-batch training where you sum gradients across multiple samples: Lbatchw=1Ni=1NLiw\frac{\partial L_{\text{batch}}}{\partial w} = \frac{1}{N}\sum_{i=1}^N \frac{\partial L_i}{\partial w}. Accumulation implements this sum. You must manually zero between training iterations.
What's the memory complexity of storing a computational graph?
O(NS)O(N \cdot S) where NN is the number of operations/layers and SS is the size of activations. Each operation stores inputs and metadata for the backward pass. Gradient checkpointing reduces this to O(NS)O(\sqrt{N} \cdot S) by recomputing activations.

Concept Map

builds

is a

nodes are

enables

triggers tracking by

records ops into

each op stores

traverses graph via

computes

stored in

built dynamically as

Autograd System

Computational Graph

Directed Acyclic Graph

Tensors and Operations

Automatic Differentiation

requires_grad True

Forward Pass

grad_fn

backward call

Chain Rule

Gradients

grad attribute

Define-by-Run

Hinglish (regional understanding)

Intuition Hinglish mein samjho

Hinglish (regional understanding)

Intuition Hinglish mein samjho

Dekho, autograd basically PyTorch ka woh magical engine hai jo automatically derivatives (gradients) nikaal deta hai — tumhe khud haath se calculus karne ki zarurat nahi padti. Jab tum neural network train karte ho, tumhe loss function ka gradient nikaalna padta hai lakhon parameters ke respect me. Ye kaam manually karna impossible hai aur galtiyaan hone ki full guarantee. Isliye PyTorch ek "tape recorder" ki tarah kaam karta hai — jab tum forward pass me tensors pe operations karte ho (jaise multiply, add, power), to woh chupchaap ek computational graph banata jaata hai jisme har operation record hota hai. Jaise hi tum .backward() call karte ho, woh is graph ko ulta traverse karke, chain rule laga ke saare gradients ek hi baar me nikaal deta hai.

Ismein main trick ye hai ki tum tensor pe requires_grad=True set karte ho — matlab "iss tensor ko track karo". Fir har naya tensor jo operations se banta hai, uske paas ek grad_fn hota hai (jaise <MulBackward>, <AddBackward>) jo batata hai ki woh kis operation se aaya. Yehi grad_fn saare tensors ko jodkar poora graph bana deta hai. Backward pass me PyTorch bas chain rule apply karta hai — agar z=xy+x2z = xy + x^2 hai, to dz/dx=y+2xdz/dx = y + 2x automatically nikal aata hai, aur tumhe answer .grad attribute me mil jaata hai. Yaani jo math tum kaagaz pe limit definition se derive karte, woh PyTorch milliseconds me kar deta hai.

Ek important baat jo dhyaan me rakhni hai — .backward() by default gradients ko accumulate karta hai, matlab purane gradient me naya add hota jaata hai. Ye mini-batch training ke liye kaam ka hai kyunki tum multiple samples ke gradients sum karte ho, par iska matlab hai ki har iteration ke baad tumhe x.grad.zero_() se gradients clear karne padte hain, warna galat purane values add hote rahenge aur tumhara training kharab ho jaayega. Ye samajhna kyun zaroori hai? Kyunki deep learning ka poora foundation isi automatic differentiation pe khada hai — bina iske tum bade networks efficiently train hi nahi kar sakte, aur ye concept tumhe har PyTorch project me kaam aayega.

Go deeper — visual, from zero

Test yourself — Deep Learning Frameworks

Connections