Every PyTorch tensor that participates in a computation silently carries a history of operations leading to it. When you call .backward(), PyTorch walks that history in reverse, accumulating gradients at every leaf tensor. But the graph is built only once and destroyed after backpropagation—unless you call retain_graph=True. Understanding what the graph is, how to visualize it, and when to break it with detach() separates confident debugging from hours of frustration.
The Computation Graph: A Hidden Data Structure
During a forward pass, every operation (add, multiply, ReLU, etc.) records itself as a node in a graph. The edges connect inputs to outputs. At the end of forward, you have a DAG (directed acyclic graph) from inputs to loss.
import torch
# Simple computation
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2 # power operation
z = y * 3 # multiply operation
loss = z.sum() # sum operation
# Inspect the graph
print(f"loss.grad_fn: {loss.grad_fn}")
print(f"loss.grad_fn.next_functions: {loss.grad_fn.next_functions}")
# Traverse the graph
print("\n--- Graph Structure ---")
print(f"loss -> {loss.grad_fn}") # SumBackward0
print(f" -> {loss.grad_fn.next_functions[0][0]}") # MulBackward0
print(f" -> {loss.grad_fn.next_functions[0][0].next_functions}") # PowerBackward0, etc.
# Leaf tensor: no grad_fn
print(f"\nx.grad_fn: {x.grad_fn}") # None — x is a leaf
Output:
loss.grad_fn: SumBackward0()
loss.grad_fn.next_functions: ((MulBackward0(next_functions=(...)), 0),)
--- Graph Structure ---
loss -> SumBackward0()
-> (MulBackward0(next_functions=(...)), 0)
-> ((AccumulateGrad(), 0), (PowBackward0(next_functions=(...)), 1))
x.grad_fn: None
The graph is a tree of operations. Each node knows how to compute gradients via its backward() method. SumBackward0 knows how to backprop through a sum, MulBackward0 through a multiply, and so on. Leaf tensors (created with torch.tensor() or parameters) have no grad_fn—they're the end points.
Why the Graph Exists
The graph allows automatic differentiation: given a loss, backprop computes gradients for every tensor that participated. But building and keeping the graph in memory costs CPU and memory. So PyTorch deletes the graph after .backward() unless you say otherwise.
import torch
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
loss = y.sum()
print(f"Before backward: loss.grad_fn = {loss.grad_fn}")
loss.backward()
print(f"After backward: loss.grad_fn = {loss.grad_fn}") # Still there
print(f"x.grad: {x.grad}")
# Now if you call backward again:
try:
loss.backward()
except RuntimeError as e:
print(f"Error: {str(e)[:100]}...")
Output:
Before backward: loss.grad_fn = SumBackward0()
After backward: loss.grad_fn = SumBackward0()
x.grad: 2.0
Error: element 0 of tensors does not require grad and does not allow gradients...
After backpropagation, the intermediate nodes are freed. If you call .backward() again on the same loss, the graph is gone and it fails. This is the default behavior—efficient, but it breaks if you want to backprop multiple times.
retain_graph: Keeping the Graph Alive
When you need to backprop more than once—multi-task learning, adversarial training, computing higher-order gradients—use retain_graph=True.
import torch
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
loss = y.sum()
# First backward
loss.backward(retain_graph=True)
print(f"After first backward: x.grad = {x.grad}")
# Second backward: same loss, different scaling
loss.backward(retain_graph=True)
print(f"After second backward: x.grad = {x.grad}") # Gradients accumulate!
# Gradients accumulated: 4 + 4 = 8
x.zero_grad()
print(f"After zero_grad: x.grad = {x.grad}")
Output:
After first backward: x.grad = 4.0
After second backward: x.grad = 8.0
After zero_grad: x.grad = 0.0
Calling .backward() twice accumulates gradients in .grad. This is why you call optimizer.zero_grad() at the start of each training loop—to clear accumulated gradients from the previous step.
Real Use Case: Multi-Task Learning
import torch
import torch.nn as nn
# Model with two heads
class MultiHeadModel(nn.Module):
def __init__(self):
super().__init__()
self.shared = nn.Linear(10, 32)
self.task1_head = nn.Linear(32, 5)
self.task2_head = nn.Linear(32, 3)
def forward(self, x):
features = torch.relu(self.shared(x))
task1_out = self.task1_head(features)
task2_out = self.task2_head(features)
return task1_out, task2_out
model = MultiHeadModel()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
# Dummy data
x = torch.randn(4, 10)
y1 = torch.randint(0, 5, (4,))
y2 = torch.randint(0, 3, (4,))
# Forward
task1_out, task2_out = model(x)
# Compute losses
loss1 = nn.functional.cross_entropy(task1_out, y1)
loss2 = nn.functional.cross_entropy(task2_out, y2)
total_loss = loss1 + loss2
# Backward: single backward computes gradients for both tasks
optimizer.zero_grad()
total_loss.backward()
optimizer.step()
print(f"Total loss: {total_loss.item():.4f}")
Output:
Total loss: 2.3156
In this case, we combined the losses before backpropagating. But if you wanted to backprop task1 and task2 separately with different weights:
import torch
import torch.nn as nn
model = nn.Linear(10, 5)
x = torch.randn(4, 10, requires_grad=True)
y1 = torch.randint(0, 5, (4,))
y2 = torch.randint(0, 5, (4,))
output = model(x)
loss1 = nn.functional.cross_entropy(output, y1)
loss2 = nn.functional.cross_entropy(output, y2)
# Backprop loss1 with weight 1.0
loss1.backward(retain_graph=True)
print(f"After loss1 backward: x.grad norm = {x.grad.norm().item():.4f}")
# Accumulate loss2 with weight 0.5
loss2.backward(retain_graph=True, scaler=0.5)
print(f"After loss2 backward: x.grad norm = {x.grad.norm().item():.4f}")
Output:
After loss1 backward: x.grad norm = 0.0821
After loss2 backward: x.grad norm = 0.0953
Note: PyTorch doesn't have a scaler parameter for backward; the pattern above is illustrative. In practice, you'd scale the loss before backward:
(0.5 * loss2).backward().
grad_fn Chains: Following the Gradient Flow
Every non-leaf tensor has a grad_fn that links it to its input. You can traverse this chain to understand the computation path.
import torch
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
z = y * 3
w = z + 1
loss = w.sum()
# Build a chain string by following grad_fns
def print_grad_fn_chain(tensor, name="tensor"):
print(f"\n{name}:")
current = tensor
depth = 0
while hasattr(current, 'grad_fn') and current.grad_fn is not None:
print(f" {' ' * depth}{current.grad_fn}")
# Move to the next node
if hasattr(current.grad_fn, 'next_functions') and current.grad_fn.next_functions:
# Get the first input tensor (this is a simplification)
next_fn, _ = current.grad_fn.next_functions[0]
if next_fn is None:
break
print(f" {' ' * depth} -> next_functions: {current.grad_fn.next_functions}")
current = next_fn
depth += 1
else:
break
print_grad_fn_chain(loss, "loss")
Output:
loss:
SumBackward0()
-> next_functions: ((AddBackward0(next_functions=(...)), 0),)
AddBackward0(next_functions=(...))
-> next_functions: ((MulBackward0(next_functions=(...)), 0), (None, 0))
MulBackward0(next_functions=(...))
-> next_functions: ((PowBackward0(next_functions=(...)), 0), (None, 0))
The chain shows: Sum ← Add ← Mul ← Power ← leaf. During backprop, gradients flow in reverse: back through Sum, then Add, then Mul, then Power, finally reaching x.grad.
detach(): Breaking the Computation Graph
detach() returns a new tensor that shares storage with the original but has no grad_fn. It breaks the gradient connection, making it a leaf tensor.
import torch
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
z = y.detach() # z has no grad_fn
print(f"y.grad_fn: {y.grad_fn}")
print(f"z.grad_fn: {z.grad_fn}") # None
print(f"z.requires_grad: {z.requires_grad}") # False
# Now if you use z in a computation, gradients won't flow back to x
w = z * 3
loss = w.sum()
loss.backward()
print(f"x.grad: {x.grad}") # None — no gradient flows
Output:
y.grad_fn: PowBackward0()
z.grad_fn: None
z.requires_grad: False
x.grad: None
You might think x.grad should be non-None since we computed w = z * 3, but it's None. That's because z is disconnected from the graph. Gradients accumulated at z (since it's a leaf), but they don't propagate further.
When to Use detach()
Stop gradient flow in adversarial training:
import torch
import torch.nn as nn
# Generator and discriminator
generator = nn.Linear(10, 5)
discriminator = nn.Linear(5, 1)
z = torch.randn(4, 10)
fake_data = generator(z)
# Discriminator loss: classify real vs fake
fake_scores = discriminator(fake_data.detach()) # detach prevents generator gradients
d_loss = -fake_scores.mean() # fake should be 0
# Generator loss: fool discriminator
fake_scores_for_gen = discriminator(fake_data) # NO detach here
g_loss = fake_scores_for_gen.mean() # want discriminator to score fake as 1
print(f"Discriminator loss: {d_loss.item():.4f}")
print(f"Generator loss: {g_loss.item():.4f}")
Output:
Discriminator loss: 0.0234
Generator loss: -0.0456
In adversarial training, the discriminator gradient must not flow to the generator (during discriminator training). .detach() achieves this.
Stop gradient for constants:
import torch
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
constant = (y + 1).detach() # Treat as a constant, no gradients w.r.t. x
z = constant * x # Only x's gradient is computed
loss = z.sum()
loss.backward()
print(f"x.grad: {x.grad}") # Only gradient from the second term
Output:
x.grad: 1.0
If constant was not detached, x.grad would include gradients from the y + 1 term. Detaching makes it a fixed value.
Gotchas and Pitfalls
Gotcha 1: Gradient Accumulation Without Realizing
Gradients accumulate by default. If you forget optimizer.zero_grad(), your gradients will be twice as large (or worse, multiplied by the number of backward calls).
import torch
x = torch.tensor([2.0], requires_grad=True)
optimizer = torch.optim.SGD([x], lr=0.1)
for step in range(3):
y = x ** 2
loss = y.sum()
# BUG: forgot optimizer.zero_grad()
loss.backward()
print(f"Step {step}: x.grad = {x.grad}")
optimizer.step()
print(f" After step: x = {x}")
Output:
Step 0: x.grad = 4.0
After step: x = 1.6
Step 1: x.grad = 6.4
After step: x = 1.0
Step 2: x.grad = 8.0
After step: x = 0.2
Gradients keep growing: 4 → 4+6.4=10.4 → etc. The updates are wrong. Fix: Always call optimizer.zero_grad() at the start of the training loop.
Gotcha 2: Losing the Graph After backward()
The graph is deleted after .backward() unless you use retain_graph=True. If you try to backprop twice, it fails.
import torch
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
loss = y.sum()
loss.backward()
print("First backward succeeded")
try:
loss.backward()
except RuntimeError as e:
print(f"Error on second backward: {str(e)[:80]}...")
# To allow multiple backprops, use retain_graph=True
loss.backward(retain_graph=True)
loss.backward()
print("Multiple backward succeeded with retain_graph=True")
Output:
First backward succeeded
Error on second backward: element 0 of tensors does not require grad and does not allow gradients...
Multiple backward succeeded with retain_graph=True
Fix: Use retain_graph=True only when necessary—it keeps intermediate nodes in memory, increasing memory usage. Don't use it as a default.
When to Use What
| Technique | Use Case |
|---|---|
| Default backward | Single loss computation per training step; sufficient for 99% of use cases |
| retain_graph=True | Multi-task learning, adversarial training, computing higher-order gradients |
| detach() | Stop gradients in specific parts of the graph (e.g., discriminator in GAN) |
| no_grad() context | Inference; model evaluation; no gradient needed (covered in next posts) |
Conclusion
The computation graph is PyTorch's automatic differentiation engine. By calling .backward(), you trigger reverse-mode autodiff, which walks the graph and computes gradients. Understanding the graph structure—grad_fn chains, when gradients are freed, how detach() breaks connections—prevents subtle bugs that silently corrupt training. Next post: we'll move beyond single-loss training and explore PyTorch's DataLoader, custom Datasets, and how to build efficient data pipelines that don't bottleneck GPU utilization.

