- Compute derivatives and partial derivatives of the functions used in neural networks, and explain the gradient as the direction of steepest ascent.
- Apply the multivariable chain rule on a computational graph to obtain gradients.
- Derive the gradients of a batched linear layer and perform a full forward and backward pass through a two-layer network by hand.
- Compare forward-mode and reverse-mode automatic differentiation and explain why reverse mode is used for training.
- Implement a numerical gradient check with central differences and interpret the relative error.
Before this: Linear Algebra for AI
Training a neural network means nudging millions or billions of numbers so a loss goes down. To know which way to nudge each number, you need its derivative: how much the loss changes when that number changes a little. Backpropagation, the algorithm behind every deep-learning framework, is nothing more than the chain rule from first-year calculus applied systematically to a graph of operations. This lesson builds that machinery from the ground up, works a full backward pass by hand, and shows how to check gradients the way professionals do when they write custom layers.
Intuition: sensitivity and blame
Imagine a factory line where each station transforms a part and passes it on, and a quality inspector at the end gives a score. If the score is bad, each station wants to know: "if I had changed my setting slightly, how much would the final score have moved?" The last station can answer directly. The station before it answers by multiplying its own local sensitivity by the sensitivity of everything downstream. That relay of blame, from the end of the line back to the start, is backpropagation. The derivative is the sensitivity; the chain rule is how sensitivities multiply along the line.
Derivatives
For a function , the derivative at is
It is the slope of the tangent line, and it gives the best linear approximation near : for small . That approximation is the entire justification for gradient descent: if you step against the slope, the function decreases, at least for small steps.
Rules you will use constantly (with a constant):
| Function | Derivative | Where in ML |
|---|---|---|
| linear layers | ||
| squared error | ||
| softmax | ||
| log-likelihood, cross-entropy | ||
| logistic regression, gates | ||
| RNNs | ||
| if , if | most networks |
ReLU is not differentiable at exactly ; frameworks simply pick there (a valid subgradient), and it causes no problems in practice. The sum rule and product rule round out the toolkit.
Example: at has . Check with : , close to 6.
Partial derivatives and the gradient
Models have many parameters, so losses are functions . The partial derivative is the derivative with respect to while every other input is held fixed. Collecting all of them gives the gradient:
Worked example. .
- (treat as a constant).
- (treat as a constant).
- At : and .
Three facts make the gradient the central object of optimization:
- Linear approximation: .
- Steepest ascent: among all directions of unit length, points the way increases fastest, and is the steepest descent. (By Cauchy–Schwarz, over unit is maximized when is parallel to .)
- Stationary points: at a minimum, maximum or saddle point of a smooth function, .
The gradient has the same shape as the parameters: the gradient of a loss with respect to a weight matrix is itself a matrix. That is what param.grad holds in PyTorch.
The chain rule
If and , then
With several intermediate variables, you sum over every path: if depends on through , then
Worked example: one logistic neuron. with , and , , .
- Forward: , .
- Local derivatives: ; ; .
- Chain: and .
Notice that everywhere. Chain twenty sigmoids and the product of local derivatives is at most : this is the vanishing gradient problem, and one reason ReLU (local derivative 1 when active) and residual connections replaced deep sigmoid stacks.
Computational graphs
Any computation can be drawn as a directed acyclic graph (DAG): nodes are intermediate values, edges show which values feed which operations. The forward pass evaluates nodes in order from inputs to output. The backward pass visits nodes in reverse order and, at each node , computes the adjoint by summing over the node's consumers :
Each operation only needs to know its own local derivative; the graph takes care of the rest. That locality is why you can add a new layer type to a framework by writing just its forward function and its local backward rule.
Jacobians, briefly
For a vector-valued function , the derivative is the Jacobian matrix with . The chain rule becomes matrix multiplication: if , then .
Examples: for , the Jacobian is itself. For an elementwise activation, it is diagonal with entries , which is why frameworks implement it as an elementwise multiply rather than building a matrix. For softmax, (with if , else ).
Frameworks never build full Jacobians for large layers. Reverse mode computes vector–Jacobian products directly. For a batched linear layer with upstream gradient (same shape as ), the results are
Check the shapes: is , is , so is , matching . When in doubt, derive matrix gradients by matching shapes, then confirm numerically.
Worked backpropagation through a 2-layer network
Network: input , hidden layer of 2 ReLU units, one linear output, squared-error loss.
Values: , target ,
Forward pass.
- ; .
- .
- .
- .
Backward pass (each line uses only the line above and a local derivative).
- .
- Output layer: ; .
- Into the hidden layer: .
- Through ReLU (local derivative 1 where , 0 where ): .
- First layer: (an outer product):
Interpretation: the second hidden unit is "dead" for this input (its pre-activation is negative), so neither its incoming weights nor receive any gradient. All gradients are negative where non-zero, so a gradient-descent step will increase those weights, raising toward the target. With learning rate , the new would be .
Automatic differentiation: forward vs reverse mode
There are three ways to get derivatives by computer:
- Symbolic differentiation manipulates formulas; it is exact but expressions can blow up in size.
- Numerical differentiation uses finite differences; it is easy but approximate and needs one or two function evaluations per input.
- Automatic differentiation (AD) applies the chain rule to the actual sequence of elementary operations your program executes, giving derivatives exact to floating-point precision.
AD has two modes. For :
| Forward mode | Reverse mode | |
|---|---|---|
| Propagates | tangents from inputs forward | adjoints from outputs backward |
| One pass gives | one column of (Jacobian–vector product) | one row of (vector–Jacobian product) |
| Passes for full | ||
| Memory | low (no need to store the graph) | must store intermediate values from the forward pass |
| Best when | few inputs, many outputs | many inputs, few outputs |
A training loss is a single scalar () depending on billions of parameters ( huge), so reverse mode computes the entire gradient for roughly the cost of a small constant number of forward passes (the usual rule of thumb is that a backward pass costs about twice the forward pass). Backpropagation is reverse-mode AD. The price is memory: activations from the forward pass must be kept for the backward pass, which is why activation memory dominates training and why activation checkpointing (recomputing some activations during the backward pass) trades compute for memory. Forward mode is used for Jacobian–vector products, for example in some second-order methods.
A reverse-mode autodiff engine in 30 lines
import math
class Value:
def __init__(self, data, parents=(), local_grads=()):
self.data, self.grad = data, 0.0
self.parents, self.local_grads = parents, local_grads
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data + other.data, (self, other), (1.0, 1.0))
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data * other.data, (self, other), (other.data, self.data))
def relu(self):
return Value(max(0.0, self.data), (self,), (1.0 if self.data > 0 else 0.0,))
def sigmoid(self):
s = 1 / (1 + math.exp(-self.data))
return Value(s, (self,), (s * (1 - s),))
def backward(self):
order, seen = [], set()
def visit(v): # topological sort of the graph
if id(v) not in seen:
seen.add(id(v))
for p in v.parents: visit(p)
order.append(v)
visit(self)
self.grad = 1.0
for v in reversed(order): # adjoints flow output -> inputs
for p, g in zip(v.parents, v.local_grads):
p.grad += v.grad * g
w, x, b = Value(2.0), Value(1.0), Value(-1.0)
y = (w * x + b).sigmoid()
y.backward()
print(round(y.data, 4), round(w.grad, 4), round(b.grad, 4)) # 0.7311 0.1966 0.1966
Each operation records its parents and its local derivatives during the forward pass; backward sorts the graph topologically and accumulates adjoints with += (the sum over paths in the chain rule). This reproduces the logistic-neuron numbers from earlier. PyTorch, JAX and TensorFlow do the same thing on tensors, with hand-written vector–Jacobian products for each operation. In PyTorch the equivalent is a tensor created with requires_grad=True, a forward computation, then loss.backward(), after which each leaf tensor's .grad holds its gradient.
Checking gradients numerically
When you implement a custom layer or a hand-derived gradient, verify it against the central difference
where is the -th unit vector. Its error shrinks like , versus for the one-sided difference. Choose to in float64: too large and the approximation error dominates; too small and floating-point cancellation does. Compare with the relative error
where is a tiny constant that avoids division by zero. In float64, values around or below indicate a correct gradient; or above means a bug.
import numpy as np
x, y = np.array([1., 2.]), 1.0
params = {"W1": np.array([[.1, .2], [.3, -.4]]), "b1": np.zeros(2),
"w2": np.array([.5, -.6]), "b2": np.array(.1)}
def loss_and_grads(p):
z = p["W1"] @ x + p["b1"]; h = np.maximum(z, 0)
yhat = p["w2"] @ h + p["b2"]; L = 0.5 * (yhat - y) ** 2
d_yhat = yhat - y
d_z = d_yhat * p["w2"] * (z > 0)
grads = {"W1": np.outer(d_z, x), "b1": d_z,
"w2": d_yhat * h, "b2": np.array(d_yhat)}
return L, grads
L, g = loss_and_grads(params)
print(L, g["W1"]) # 0.21125 [[-0.325 -0.65] [0 0]]
h = 1e-6
for name, P in params.items():
num = np.zeros_like(P)
for i in np.ndindex(P.shape):
old = P[i]
P[i] = old + h; Lp, _ = loss_and_grads(params)
P[i] = old - h; Lm, _ = loss_and_grads(params)
P[i] = old
num[i] = (Lp - Lm) / (2 * h)
rel = np.abs(num - g[name]) / np.maximum(1e-12, np.abs(num) + np.abs(g[name]))
print(name, rel.max()) # all tiny (~1e-11)
The loop perturbs each parameter in place, recomputes the loss, and compares. Gradient checking is slow (two forward passes per parameter) so you run it on tiny inputs, once, as a unit test. Avoid checking exactly at ReLU kinks (pre-activations equal to zero), where the numerical derivative is meaningless; and disable dropout or other randomness while checking.
Common pitfalls
- Forgetting to zero gradients. Frameworks accumulate into
.grad(the+=above). In PyTorch, calloptimizer.zero_grad()each step or gradients from previous batches leak in. - Shape-mismatched matrix gradients. If your derived does not have 's shape, a transpose is missing.
- Breaking the graph. Converting to NumPy, using
.item(), or in-place edits on tensors needed for backward can silently stop gradients or raise errors. - Vanishing and exploding gradients. Long chains of small (or large) local derivatives shrink (or blow up) the product; monitor per-layer gradient norms.
- Dead ReLUs. Units whose pre-activation is negative for all inputs get zero gradient forever, as unit 2 did in the worked example.
- Gradient checks in float32 or at kinks. Use float64 and avoid non-differentiable points, or you will chase false alarms.
In practice
- Let autodiff compute gradients; derive by hand only to understand, to write fused kernels, or to debug. Always back hand-derived gradients with a numerical check (PyTorch provides
torch.autograd.gradcheckfor this). - Log the global gradient norm every step. Sudden spikes precede loss spikes; clipping the global norm (often to 1.0 in LLM training recipes) is a common safeguard.
- Memory, not compute, is often the limit in training: activations stored for reverse mode scale with batch size, sequence length and depth. Activation checkpointing, smaller micro-batches and mixed precision are the standard levers.
- Use
torch.no_grad()(or the equivalent) during evaluation and inference so no graph is built.
Key takeaways
- A derivative is a local linear approximation; gradient descent works because stepping against the gradient decreases the function for small enough steps.
- The gradient has the same shape as the parameters and points in the direction of steepest increase.
- Backpropagation is the chain rule applied in reverse topological order on a computational graph, summing over all paths.
- Reverse-mode autodiff gets the full gradient of a scalar loss in a few forward-pass equivalents, at the cost of storing activations.
- For Y = XW with upstream gradient G, dL/dW = XᵀG and dL/dX = GWᵀ; matching shapes is a fast sanity check.
- Always verify hand-written gradients with a float64 central-difference check and relative error.
Terms in this lesson
- Derivative
- The instantaneous rate of change of a function with respect to its input, equal to the slope of its best local linear approximation.
- Partial derivative
- The derivative of a multivariable function with respect to one input while all other inputs are held fixed.
- Gradient
- The vector of all partial derivatives of a scalar function, pointing in the direction of steepest increase.
- Chain rule
- The rule that the derivative of a composition is the product of the derivatives of its parts, summed over all paths of dependence.
- Computational graph
- A directed acyclic graph whose nodes are intermediate values and whose edges record which operations produced them.
- Jacobian
- The m-by-n matrix of first partial derivatives of a function from n inputs to m outputs.
- Reverse-mode automatic differentiation
- An algorithm that computes vector-Jacobian products by propagating adjoints from outputs back to inputs; backpropagation is its use for neural networks.
- Gradient check
- A test that compares analytic gradients with finite-difference estimates to detect implementation bugs.
Check your understanding
- 1.
For f(x, y) = 3x²y − y³, what is ∇f at (1, 2)?
- 2.
A function maps 10 inputs to 1,000,000 outputs and you need its full Jacobian. Which mode of autodiff is cheaper?
- 3.
For Y = XW with X of shape (64, 300) and W of shape (300, 100), what is the shape and formula of ∂L/∂W given G = ∂L/∂Y?
- 4.
Your gradient check reports a relative error of 0.03 for one layer in float64. What is the most likely conclusion?
- 5.
Why do frameworks accumulate gradients with += rather than overwrite them?
Exercises
Work each problem before opening the solution.
- Exercise 1Core
Let with , , , . Compute , and to four decimal places.
Show solution
Forward: ; ; .
Backward: . . So .
; . (Carry full precision through the chain: , so the gradients are and ; doubling the already-rounded would give a misleading .)
- Exercise 2Core
In the worked 2-layer example, change the input to (all weights unchanged, target ). Run the forward and backward passes and give .
Show solution
Forward: ; . Both positive, so . . .
Backward: . . Both ReLUs are active, so .
Also and . The positive gradients in row 2 mean gradient descent will decrease unit 2's weights, reducing a unit whose output currently pushes down (since ).
- Exercise 3Core
Explain why the one-sided difference has error while the central difference has error . Use a Taylor expansion.
Show solution
Taylor:
One-sided: , so the error is .
Central: subtracting the two expansions cancels the even terms, , so , an error.
- Exercise 4Challenge
Extend the
Valueclass from the lesson with__sub__,__pow__(for a constant integer exponent) andexp. Use it to compute the gradient of at , , , , , and confirm one entry with a central difference.Show solution
import math class Value: def __init__(self, data, parents=(), local_grads=()): self.data, self.grad = data, 0.0 self.parents, self.local_grads = parents, local_grads def _wrap(self, o): return o if isinstance(o, Value) else Value(o) def __add__(self, o): o = self._wrap(o); return Value(self.data + o.data, (self, o), (1.0, 1.0)) def __mul__(self, o): o = self._wrap(o); return Value(self.data * o.data, (self, o), (o.data, self.data)) def __sub__(self, o): o = self._wrap(o); return Value(self.data - o.data, (self, o), (1.0, -1.0)) def __pow__(self, k): return Value(self.data ** k, (self,), (k * self.data ** (k - 1),)) def exp(self): e = math.exp(self.data); return Value(e, (self,), (e,)) def sigmoid(self): s = 1 / (1 + math.exp(-self.data)); return Value(s, (self,), (s * (1 - s),)) def backward(self): order, seen = [], set() def visit(v): if id(v) not in seen: seen.add(id(v)); [visit(p) for p in v.parents]; order.append(v) visit(self); self.grad = 1.0 for v in reversed(order): for p, g in zip(v.parents, v.local_grads): p.grad += v.grad * g def loss(w1, w2, b): w1, w2, b = Value(w1), Value(w2), Value(b) L = ((w1 * 1.0 + w2 * 2.0 + b).sigmoid() - 1.0) ** 2 return L, (w1, w2, b) L, (w1, w2, b) = loss(0.3, -0.2, 0.1) L.backward() print(round(L.data, 6), round(w1.grad, 6), round(w2.grad, 6), round(b.grad, 6)) h = 1e-6 num = (loss(0.3 + h, -0.2, 0.1)[0].data - loss(0.3 - h, -0.2, 0.1)[0].data) / (2 * h) print(round(num, 6))By hand: , , . . So , , . The program prints
0.25 -0.25 -0.5 -0.25and the central difference-0.25. (expis included as requested; the lesson's sigmoid uses its own local derivative.)
Further reading
- paperAtılım Güneş Baydin, Barak A. Pearlmutter, Alexey Andreyevich Radul, Jeffrey Mark Siskind, Automatic Differentiation in Machine Learning: a Survey (2018)
- docsPyTorch Tutorials, A Gentle Introduction to torch.autograd
- bookMarc Peter Deisenroth, A. Aldo Faisal, Cheng Soon Ong, Mathematics for Machine Learning (2020), chapter 5: Vector Calculus
- bookIan Goodfellow, Yoshua Bengio, Aaron Courville, Deep Learning (2016), section 6.5: Back-Propagation and Other Differentiation Algorithms
Go further
Sources
Fact-checked and last reviewed 2026-11-05