Backpropagation: Chain Rule, Gradient Flow, and Autograd

Michael BrenndoerferMay 6, 202558 min read

Part of Language AI Handbook

Covers backpropagation from computational graphs to PyTorch autograd. Topics include chain rule derivation, gradient flow through activations.

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Backpropagation

How does a neural network learn? In the previous chapters, we built multilayer perceptrons that transform inputs through layers of weights and activations, and we defined loss functions that measure prediction quality. But we haven't answered the fundamental question: how do we update the millions of parameters in a neural network to reduce the loss?

The answer is backpropagation, short for "backward propagation of errors." This algorithm computes the gradient of the loss function with respect to every weight in the network, telling us exactly how to adjust each parameter to improve predictions. Backpropagation is the most important algorithm in deep learning. Without it, training neural networks would be computationally intractable.

This chapter builds backpropagation from the ground up. We start with computational graphs that visualize how operations compose, then derive the chain rule that makes gradient computation tractable. You'll trace through forward and backward passes step by step, understand gradient flow through activation functions, examine Jacobian matrices for vector operations, and implement backpropagation from scratch. We'll also cover numerical gradient checking to verify implementations, common backprop bugs, and how PyTorch autograd works internally. By the end, you'll see exactly how modern deep learning frameworks compute gradients automatically.

The Gradient Problem

Neural networks have millions of parameters. GPT-3 has 175 billion. How do we figure out which direction to nudge each one to reduce the loss?

Calculus gives us the answer: the gradient. For a function f(w)f(\mathbf{w}) where w\mathbf{w} is a vector of parameters, the gradient ∇wf\nabla_\mathbf{w} f is a vector that points in the direction of steepest increase of ff. Each component tells us how sensitive the function is to changes in the corresponding parameter. To minimize the loss, we move opposite to the gradient:

wnew=wold−η∇wL\mathbf{w}_{\text{new}} = \mathbf{w}_{\text{old}} - \eta \nabla_\mathbf{w} \mathcal{L}

where:

  • wnew\mathbf{w}_{\text{new}}: the updated parameter vector after one step
  • wold\mathbf{w}_{\text{old}}: the current parameter vector before the update
  • η\eta: the learning rate, a small positive scalar controlling step size
  • ∇wL\nabla_\mathbf{w} \mathcal{L}: the gradient of the loss with respect to parameters, where each entry ∂L∂wi\frac{\partial \mathcal{L}}{\partial w_i} indicates how much the loss increases if we increase wiw_i by a tiny amount
  • L\mathcal{L}: the loss function, a scalar value measuring prediction error

The subtraction makes intuitive sense: if ∂L∂wi>0\frac{\partial \mathcal{L}}{\partial w_i} > 0, increasing wiw_i would increase the loss, so we decrease it instead. This is gradient descent, simple in concept, but the challenge is computing ∇wL\nabla_\mathbf{w} \mathcal{L} when the loss depends on parameters through many nested operations.

Consider a 3-layer network. The loss depends on the output, which depends on the third layer activations, which depend on the second layer activations, which depend on the first layer activations, which depend on the input and first layer weights. This chain of dependencies seems hopelessly complex to differentiate.

Backpropagation

Backpropagation is an algorithm for efficiently computing gradients in neural networks by applying the chain rule systematically from output to input. It computes the gradient of the loss with respect to every parameter in a single backward pass through the network.

Backpropagation solves this elegantly. By applying the chain rule in reverse order, from output back to input, we compute all gradients in time proportional to a single forward pass. This efficiency is what makes training deep networks practical.

A Brief History

The story of backpropagation stretches back to the 1960s and 1970s, though its impact on neural networks only became clear in the 1980s. The basic idea of differentiating through a chain of composed functions was understood in the automatic differentiation community, but applying it to neural networks required several conceptual steps.

The paper that really crystallized the algorithm for the machine learning community was Rumelhart, Hinton, and Williams' 1986 paper "Learning representations by back-propagating errors" in Nature. This paper demonstrated that backpropagation could efficiently train multilayer networks to learn useful internal representations, which was not obvious at the time. Before this, the dominant view in AI was that useful internal representations needed to be hand-designed by experts.

The key insight of the 1986 paper was simple but powerful: hidden units in a network could learn to represent features of the input that were useful for the task, and backpropagation would discover what those features should be automatically. This was a striking departure from prior approaches that either used only one-layer models or required laborious feature engineering.

Despite this theoretical breakthrough, neural networks fell out of fashion in the 1990s for two reasons. First, training was slow on the hardware available then. Second, networks large enough to solve interesting problems ran into the vanishing gradient problem, making deep networks hard to train in practice. Other methods like support vector machines and random forests worked better for many problems. The deep learning renaissance beginning around 2006, with better hardware (GPUs), better initialization, and better architectures (like ReLU activations and skip connections), finally made the potential suggested by the 1986 paper practical.

Computational Graphs

Before diving into backpropagation, we need a way to visualize how computations flow through a network. A computational graph represents a mathematical expression as a directed graph where nodes are operations and edges show data flow.

Every neural network computation breaks down into elementary operations: addition, multiplication, matrix products, and activation functions. Consider the simple expression f(x,y,z)=(x+y)⋅zf(x, y, z) = (x + y) \cdot z. This decomposes into two operations:

  1. a=x+ya = x + y (addition)
  2. f=a⋅zf = a \cdot z (multiplication)

where:

  • x,y,zx, y, z: input variables (leaf nodes in the graph)
  • aa: an intermediate value storing the result of the first operation
  • ff: the final output (the root node)

The graph shows data flowing left to right through intermediate nodes. Each node computes something simple, and the structure of the graph encodes the data dependencies. Each node only needs to know about its immediate inputs and output, not the rest of the network. This locality is what makes the chain rule tractable.

Neural Network as a Computational Graph

A neural network layer performs the computation a=σ(Wx+b)\mathbf{a} = \sigma(\mathbf{W}\mathbf{x} + \mathbf{b}), where:

  • x\mathbf{x}: the input vector
  • W\mathbf{W}: the weight matrix
  • b\mathbf{b}: the bias vector
  • σ\sigma: the activation function
  • a\mathbf{a}: the output activations

This breaks into three atomic operations:

  1. z1=Wx\mathbf{z}_1 = \mathbf{W}\mathbf{x} (matrix multiplication)
  2. z2=z1+b\mathbf{z}_2 = \mathbf{z}_1 + \mathbf{b} (bias addition)
  3. a=σ(z2)\mathbf{a} = \sigma(\mathbf{z}_2) (nonlinear activation)

Each node has a well-defined local gradient. The power of computational graphs is that they make the structure of computation explicit, which is exactly what backpropagation needs. The forward pass computes values flowing left to right; the backward pass computes gradients flowing right to left.

When we compose multiple layers, we are simply building a larger graph. The loss node at the end is connected to the output layer, which is connected to the layer before it, and so on back to the input. The entire network is one big directed acyclic graph of operations, with parameters as leaf nodes and the loss as the root.

Why Computational Graphs Matter

Computational graphs provide two benefits for gradient computation:

  • Modularity: Each node has a well-defined local gradient. We don't need to derive the gradient of the entire network at once, only the gradient of each primitive operation. This is the key insight that makes backprop practical: instead of deriving one giant formula for the gradient of the loss with respect to every parameter, we simply need to know the derivative of each individual operation, and the chain rule handles the composition.
  • Efficiency: By storing intermediate values during the forward pass, we reuse them during the backward pass, avoiding redundant computation. Without this storage, we would have to recompute every intermediate value every time we needed it, which would make the backward pass much more expensive.

Deep learning frameworks like PyTorch and TensorFlow build exactly this kind of dynamic computational graph as code runs. Every arithmetic operation on a tensor implicitly creates a node in the graph, recording what inputs were used so the backward pass can reconstruct the gradient computation. You never see this graph directly, but it is being built silently behind every +, @, and activation function call you write.

The Chain Rule: Foundation of Backpropagation

The chain rule from calculus is the mathematical engine powering backpropagation. It tells us how to differentiate composed functions, and since a neural network is one giant composition of functions, the chain rule is precisely what we need.

Single Variable Chain Rule

Let u=g(x)u = g(x) and y=f(u)=f(g(x))y = f(u) = f(g(x)). The chain rule states:

dydx=dydu⋅dudx\frac{dy}{dx} = \frac{dy}{du} \cdot \frac{du}{dx}

where:

  • dydx\frac{dy}{dx}: the total derivative of the final output with respect to the original input
  • dydu\frac{dy}{du}: the local gradient of the outer function ff at the intermediate value uu
  • dudx\frac{du}{dx}: the local gradient of the inner function gg at xx

The intuition is multiplicative sensitivity: if xx changes by a small amount δx\delta x, it causes uu to change by dudx⋅δx\frac{du}{dx} \cdot \delta x, which in turn causes yy to change by dydu⋅dudx⋅δx\frac{dy}{du} \cdot \frac{du}{dx} \cdot \delta x. The total sensitivity is the product of each stage's sensitivity.

Think of it as a chain of dominoes. A small push on the first domino (δx\delta x) gets amplified or attenuated at each stage. The chain rule tells us the total amplification from one end of the chain to the other, which is just the product of each stage's individual amplification. This multiplicative structure is central to understanding both why backpropagation works and why deep networks can suffer from vanishing or exploding gradients.

Extending to Three or More Functions

For a chain of three functions y=f(g(h(x)))y = f(g(h(x))), the chain rule extends naturally:

dydx=dydf⋅dfdg⋅dgdx\frac{dy}{dx} = \frac{dy}{df} \cdot \frac{df}{dg} \cdot \frac{dg}{dx}

This pattern extends to any depth. For a network with nn layers, the gradient of the loss with respect to the first layer's weights involves multiplying nn local gradients together. This is why deep networks can suffer from vanishing or exploding gradients: if each local gradient is slightly less than 1, their product shrinks exponentially; if each is slightly greater than 1, the product explodes.

Concretely, suppose we have a 20-layer network and the average gradient magnitude at each layer is 0.9. The gradient at the first layer would be multiplied by 0.920≈0.120.9^{20} \approx 0.12, reducing it to about 12% of the output gradient. That might seem manageable, but with 50 layers and an average gradient of 0.7, we get 0.750≈1.3×10−80.7^{50} \approx 1.3 \times 10^{-8}: essentially zero. The first layers of the network would receive essentially no learning signal.

Multivariate Chain Rule

Neural networks work with vectors and matrices, so we need the multivariate form. If z=f(y)\mathbf{z} = \mathbf{f}(\mathbf{y}) and y=g(x)\mathbf{y} = \mathbf{g}(\mathbf{x}), the chain rule becomes:

∂zi∂xk=∑j∂zi∂yj⋅∂yj∂xk\frac{\partial z_i}{\partial x_k} = \sum_j \frac{\partial z_i}{\partial y_j} \cdot \frac{\partial y_j}{\partial x_k}

where:

  • ∂zi∂xk\frac{\partial z_i}{\partial x_k}: the partial derivative of the ii-th output component with respect to the kk-th input component
  • ∂zi∂yj\frac{\partial z_i}{\partial y_j}: the partial derivative of ziz_i with respect to the jj-th intermediate variable
  • ∂yj∂xk\frac{\partial y_j}{\partial x_k}: the partial derivative of yjy_j with respect to the kk-th input

In matrix form, this is exactly a matrix product: if Jf\mathbf{J}_f and Jg\mathbf{J}_g are the Jacobian matrices of ff and gg respectively, then the Jacobian of the composition is:

Jf∘g=Jf⋅Jg\mathbf{J}_{f \circ g} = \mathbf{J}_f \cdot \mathbf{J}_g

This matrix multiplication is the vectorized form of the chain rule, and it is what makes backpropagation efficient. The sum over jj in the scalar form becomes a dot product, and repeating it for all (i,k)(i, k) pairs gives a matrix product. Gradient computation can therefore be done with the same fast matrix operations that power the forward pass.

Jacobian Matrices and Gradient Flow

Before diving into the full backward pass, it's worth understanding Jacobian matrices carefully, because they formalize how gradients flow through vector-valued operations.

Jacobian Matrix

For a function f:Rn→Rm\mathbf{f}: \mathbb{R}^n \to \mathbb{R}^m, the Jacobian is the m×nm \times n matrix of all partial derivatives:

J=(∂f1∂x1⋯∂f1∂xn⋮⋱⋮∂fm∂x1⋯∂fm∂xn)\mathbf{J} = \begin{pmatrix} \frac{\partial f_1}{\partial x_1} & \cdots & \frac{\partial f_1}{\partial x_n} \\ \vdots & \ddots & \vdots \\ \frac{\partial f_m}{\partial x_1} & \cdots & \frac{\partial f_m}{\partial x_n} \end{pmatrix}

Entry JijJ_{ij} tells us how much the ii-th output changes when we perturb the jj-th input.

In neural network backpropagation, the loss L\mathcal{L} is a scalar, and each operation in the network transforms a vector to another vector. At each node, we don't technically need the full Jacobian. We only need the vector-Jacobian product (VJP), which maps the upstream gradient vector to a downstream gradient vector.

To see why the full Jacobian is unnecessary, consider a layer that maps R512→R512\mathbb{R}^{512} \to \mathbb{R}^{512}. The full Jacobian would be a 512×512512 \times 512 matrix of 262,144 entries. But we only need to propagate a gradient vector of length 512 backward. The VJP computes this directly in O(5122)O(512^2) time, the same cost as forming the Jacobian, but without ever explicitly materializing it. For larger layers, the savings are more dramatic, and specialized operations like batch normalization have structured Jacobians that admit even faster VJP computations.

Vector-Jacobian Products (VJPs)

In practice, backpropagation never explicitly forms full Jacobian matrices. Instead, it computes vector-Jacobian products. If the upstream gradient is a row vector vT\mathbf{v}^T (with the same shape as the output), the downstream gradient is:

vT⋅J\mathbf{v}^T \cdot \mathbf{J}

This produces a row vector with the same shape as the input. The key insight is that computing this product is much cheaper than computing J\mathbf{J} explicitly, then multiplying. PyTorch's autograd system is built entirely around this VJP abstraction.

This VJP perspective also clarifies what backpropagation is computing at each step. The upstream gradient v\mathbf{v} represents "how the loss changes when the output of this node changes." The Jacobian J\mathbf{J} represents "how the output of this node changes when the input changes." Their product gives "how the loss changes when the input changes," which is exactly the downstream gradient we need to propagate.

Local Gradients for Common Operations

The power of computational graphs is that each operation only needs to define its own local Jacobian (or VJP). The full gradient is then assembled by chaining these local gradients via the multivariate chain rule.

Here are the local gradients for the key operations in a neural network:

Addition: z=x+y\mathbf{z} = \mathbf{x} + \mathbf{y}. The Jacobian with respect to each input is the identity matrix I\mathbf{I}. Gradients pass through unchanged: ∂L∂x=∂L∂z\frac{\partial \mathcal{L}}{\partial \mathbf{x}} = \frac{\partial \mathcal{L}}{\partial \mathbf{z}} and ∂L∂y=∂L∂z\frac{\partial \mathcal{L}}{\partial \mathbf{y}} = \frac{\partial \mathcal{L}}{\partial \mathbf{z}}. This is why residual connections are so effective: the skip path provides a direct gradient highway back to earlier layers. The gradient flows through without any attenuation from the addition node itself.

Multiplication by scalar ww: z=w⋅x\mathbf{z} = w \cdot \mathbf{x}. The gradient with respect to x\mathbf{x} is w⋅∂L∂zw \cdot \frac{\partial \mathcal{L}}{\partial \mathbf{z}} (scale up or down), and the gradient with respect to ww is xT∂L∂z\mathbf{x}^T \frac{\partial \mathcal{L}}{\partial \mathbf{z}}.

Matrix multiplication: z=Wx\mathbf{z} = \mathbf{W}\mathbf{x}. The Jacobian with respect to x\mathbf{x} is WT\mathbf{W}^T, giving ∂L∂x=WT∂L∂z\frac{\partial \mathcal{L}}{\partial \mathbf{x}} = \mathbf{W}^T \frac{\partial \mathcal{L}}{\partial \mathbf{z}}. The gradient with respect to W\mathbf{W} is the outer product ∂L∂zxT\frac{\partial \mathcal{L}}{\partial \mathbf{z}} \mathbf{x}^T. Notice the transpose: gradients flow backward through the weight matrix transposed, which is why the backward pass through a linear layer involves WT\mathbf{W}^T while the forward pass involves W\mathbf{W}.

Elementwise nonlinearity: a=σ(z)\mathbf{a} = \sigma(\mathbf{z}). The Jacobian is a diagonal matrix with σ′(zi)\sigma'(z_i) on the diagonal, so ∂L∂z=∂L∂a⊙σ′(z)\frac{\partial \mathcal{L}}{\partial \mathbf{z}} = \frac{\partial \mathcal{L}}{\partial \mathbf{a}} \odot \sigma'(\mathbf{z}), where ⊙\odot denotes elementwise multiplication. Because the Jacobian is diagonal, we never form a full matrix; we just do an elementwise multiply.

Forward Pass and Backward Pass

With computational graphs and the chain rule in hand, we can now describe the complete backpropagation algorithm. The algorithm has two phases: a forward pass that computes outputs and stores intermediate values, and a backward pass that computes gradients using those stored values.

The Forward Pass

During the forward pass, we evaluate the network layer by layer, storing all intermediate values. These stored values are critical because the backward pass needs them to compute local gradients.

For a 2-layer network with input x\mathbf{x}, weights W(1)\mathbf{W}^{(1)}, W(2)\mathbf{W}^{(2)}, biases b(1)\mathbf{b}^{(1)}, b(2)\mathbf{b}^{(2)}, and activation σ\sigma, the forward pass computes:

z(1)=W(1)x+b(1)a(1)=σ(z(1))z(2)=W(2)a(1)+b(2)y^=softmax(z(2))L=−∑kyklog⁡y^k\begin{aligned} \mathbf{z}^{(1)} &= \mathbf{W}^{(1)} \mathbf{x} + \mathbf{b}^{(1)} \\ \mathbf{a}^{(1)} &= \sigma(\mathbf{z}^{(1)}) \\ \mathbf{z}^{(2)} &= \mathbf{W}^{(2)} \mathbf{a}^{(1)} + \mathbf{b}^{(2)} \\ \hat{\mathbf{y}} &= \text{softmax}(\mathbf{z}^{(2)}) \\ \mathcal{L} &= -\sum_k y_k \log \hat{y}_k \end{aligned}

where:

  • z(l)\mathbf{z}^{(l)}: the pre-activation values at layer ll (also called logits)
  • a(l)\mathbf{a}^{(l)}: the post-activation values at layer ll
  • y^\hat{\mathbf{y}}: the predicted probability distribution
  • y\mathbf{y}: the one-hot encoded target
  • L\mathcal{L}: the cross-entropy loss (scalar)

After the forward pass, we have stored z(1),a(1),z(2),y^\mathbf{z}^{(1)}, \mathbf{a}^{(1)}, \mathbf{z}^{(2)}, \hat{\mathbf{y}}. These are the "cache" that the backward pass will consume. The reason we store z(l)\mathbf{z}^{(l)} specifically (and not just a(l)\mathbf{a}^{(l)}) is that the activation derivative σ′(z(l))\sigma'(\mathbf{z}^{(l)}) needs the pre-activation value, not the post-activation value. For sigmoid, though, we can recover σ′(z)\sigma'(z) from the activation value aa itself using σ′(z)=a(1−a)\sigma'(z) = a(1-a), so storing either is fine.

The Backward Pass

The backward pass applies the chain rule in reverse, starting from the loss L\mathcal{L} and working backward through each operation.

Gradient of loss with respect to output. We start with ∂L∂z(2)\frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(2)}}. For softmax + cross-entropy, this has the elegant form:

∂L∂z(2)=y^−y\frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(2)}} = \hat{\mathbf{y}} - \mathbf{y}

This says: the gradient at the output layer is simply the difference between prediction and target. Larger errors produce larger gradients. This elegant form is not a coincidence: the cross-entropy loss was designed to pair with softmax precisely because the combination yields this clean gradient formula. This pairing is sometimes called the "softmax-cross-entropy trick," and it avoids numerical instability that would arise from computing the softmax and cross-entropy gradients separately.

Gradient through the second layer. Using the matrix multiply gradient rules:

∂L∂W(2)=∂L∂z(2)⋅(a(1))T∂L∂b(2)=∂L∂z(2)∂L∂a(1)=(W(2))T⋅∂L∂z(2)\begin{aligned} \frac{\partial \mathcal{L}}{\partial \mathbf{W}^{(2)}} &= \frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(2)}} \cdot (\mathbf{a}^{(1)})^T \\ \frac{\partial \mathcal{L}}{\partial \mathbf{b}^{(2)}} &= \frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(2)}} \\ \frac{\partial \mathcal{L}}{\partial \mathbf{a}^{(1)}} &= (\mathbf{W}^{(2)})^T \cdot \frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(2)}} \end{aligned}

Gradient through the activation function. Passing through σ\sigma:

∂L∂z(1)=∂L∂a(1)⊙σ′(z(1))\frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(1)}} = \frac{\partial \mathcal{L}}{\partial \mathbf{a}^{(1)}} \odot \sigma'(\mathbf{z}^{(1)})

where σ′(z(1))\sigma'(\mathbf{z}^{(1)}) is the elementwise derivative of the activation function evaluated at the stored pre-activation values.

Gradient through the first layer:

∂L∂W(1)=∂L∂z(1)⋅xT∂L∂b(1)=∂L∂z(1)\begin{aligned} \frac{\partial \mathcal{L}}{\partial \mathbf{W}^{(1)}} &= \frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(1)}} \cdot \mathbf{x}^T \\ \frac{\partial \mathcal{L}}{\partial \mathbf{b}^{(1)}} &= \frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(1)}} \end{aligned}

The pattern is clear: at each layer, we multiply the upstream gradient by the transpose of each weight matrix to flow backward through layers, then multiply elementwise by the activation derivative at the nonlinearity, then pass the result further back. The upstream gradient arrives from the right (closer to the loss), we compute local contributions for this layer's parameters, and we send a modified gradient leftward to the previous layer.

Gradient Flow Through Activation Functions

The backward pass depends critically on the derivatives of activation functions. Different activations have very different gradient behaviors, which directly affects how well information flows backward through deep networks. We covered activation functions in depth in the Activation Functions chapter, so here we focus on their gradient properties specifically in the context of backpropagation.

Sigmoid Gradient

The sigmoid function σ(z)=11+e−z\sigma(z) = \frac{1}{1+e^{-z}} has derivative:

σ′(z)=σ(z)(1−σ(z))\sigma'(z) = \sigma(z)(1 - \sigma(z))

where:

  • σ(z)\sigma(z): the sigmoid output, a value in (0,1)(0, 1)
  • σ′(z)\sigma'(z): the derivative, which equals σ(z)(1−σ(z))\sigma(z)(1-\sigma(z))

The maximum value of σ′(z)\sigma'(z) is 0.250.25, achieved at z=0z = 0. For ∣z∣|z| large, the sigmoid saturates (output near 0 or 1), and the derivative approaches 0. This vanishing gradient problem is severe: each sigmoid layer multiplies the gradient by at most 0.25. In a 10-layer network, the gradient at the first layer could be multiplied by 0.2510≈10−60.25^{10} \approx 10^{-6} relative to the last layer.

There is another way to see why this is so problematic. The gradient of the loss with respect to the first layer's weights is computed as a product of gradients across all intermediate layers. If those gradients are small fractions (as they are for sigmoid in saturation), their product is tiny. This means the first-layer weights barely move during training. The network effectively cannot learn any useful feature representation in its early layers when trained with sigmoid activations in deep networks.

Tanh Gradient

The hyperbolic tangent tanh⁡(z)=ez−e−zez+e−z\tanh(z) = \frac{e^z - e^{-z}}{e^z + e^{-z}} has derivative:

tanh⁡′(z)=1−tanh⁡2(z)\tanh'(z) = 1 - \tanh^2(z)

This has maximum value 1.0 at z=0z = 0 and decays toward 0 as ∣z∣|z| grows. Tanh is better than sigmoid in that its maximum derivative is 1 (versus 0.25 for sigmoid), but it still saturates and can cause vanishing gradients in deep networks. The fact that tanh is zero-centered (outputs range from −1-1 to +1+1) makes it slightly easier to optimize than sigmoid in practice, since the gradients with respect to downstream weights are not all positive or all negative for a given input.

ReLU Gradient

The ReLU function ReLU(z)=max⁡(0,z)\text{ReLU}(z) = \max(0, z) has derivative:

ReLU′(z)={1if z>00if z≤0\text{ReLU}'(z) = \begin{cases} 1 & \text{if } z > 0 \\ 0 & \text{if } z \leq 0 \end{cases}

For positive inputs, ReLU passes the gradient through unchanged (gradient is 1). For negative inputs, ReLU blocks the gradient entirely (gradient is 0). This has two important consequences:

  1. No vanishing gradient for active neurons: When a neuron is active (output > 0), the gradient passes through without attenuation. A 100-layer ReLU network can potentially propagate gradients all the way back to the first layer without shrinkage, as long as enough neurons remain active.
  2. Dying ReLU problem: If a neuron consistently outputs negative values (typically due to a large negative bias or unfortunate weight initialization), it permanently stops learning because all gradients through it are zero. This can be detected during training by monitoring the fraction of neurons with zero activations, and mitigated with careful initialization or variants like Leaky ReLU.

The ReLU gradient's binary nature means it is not just non-vanishing for positive inputs: it also cannot explode. A gradient of 1 times 1 is still 1. This stability is a major practical advantage, and it explains why the shift from sigmoid to ReLU activations in the early 2010s dramatically improved the trainability of deep networks.

GELU Gradient

The GELU function (used in modern transformers) has a smooth, data-dependent gradient that avoids both sharp saturation and hard zeros. Its derivative involves the standard normal PDF and CDF, creating a smoother gradient landscape than ReLU. GELU's derivative is positive even for moderately negative inputs, which means neurons rarely die completely, though they do attenuate gradients in the negative regime.

Out[5]:
Visualization
Line plot of activation function values for sigmoid, tanh, ReLU, and GELU over [-4,4].
Activation function values for sigmoid, tanh, ReLU, and GELU over the range [-4, 4]. ReLU is piecewise linear and grows without bound for positive inputs, while sigmoid and tanh saturate as inputs move away from zero.
Line plot of derivatives of sigmoid, tanh, ReLU, and GELU showing how they compare for gradient flow.
Activation function values for sigmoid, tanh, ReLU, and GELU over the range [-4, 4]. ReLU is piecewise linear and grows without bound for positive inputs, while sigmoid and tanh saturate as inputs move away from zero.

The derivative plots reveal a key asymmetry. Sigmoid and tanh derivatives peak at 0.25 and 1.0 respectively, then decay toward zero as inputs grow large. ReLU's derivative is a step function: exactly 1 for positive inputs, exactly 0 for negative. GELU interpolates smoothly between the two extremes, with a small but nonzero gradient even for moderately negative inputs. In deep networks, these gradient magnitudes multiply together across layers, so choosing ReLU or GELU over sigmoid was a major practical advance in training deep networks.

To make the vanishing gradient problem concrete, consider a network with 20 layers. The gradient at the first layer has been multiplied by the activation derivative at each intermediate layer. If the average derivative is less than 1 (sigmoid at 0.25, tanh at up to 1 but below 1 for most inputs), the gradient shrinks exponentially with depth:

Out[6]:
Visualization
Log-scale line plot of gradient magnitude per layer for sigmoid and ReLU activations across 20 layers.
Log-scale gradient magnitude at each layer for a 20-layer network using sigmoid vs ReLU activations, starting from a unit gradient at the output. Sigmoid gradients decay exponentially toward the input, reaching near-zero magnitudes by layer 10. ReLU gradients also shrink in this random network, but retain many orders of magnitude more signal through all 20 layers.
Log-scale line plot of gradient magnitude ratio vs network depth for sigmoid and ReLU up to 50 layers.
Log-scale gradient magnitude at each layer for a 20-layer network using sigmoid vs ReLU activations, starting from a unit gradient at the output. Sigmoid gradients decay exponentially toward the input, reaching near-zero magnitudes by layer 10. ReLU gradients also shrink in this random network, but retain many orders of magnitude more signal through all 20 layers.

The left plot confirms the textbook result: sigmoid gradients decay exponentially as they travel toward earlier layers, while ReLU gradients remain comparatively stable. The right plot shows how rapidly this ratio grows with depth for sigmoid, reaching many orders of magnitude even for moderate-depth networks.

A Worked Example: Two-Layer Network

Let's trace backpropagation through a complete two-layer network on a single training example to make the abstract formulas concrete. Walking through this computation by hand builds the kind of intuition that helps you debug real implementations.

We'll use a tiny network with:

  • Input: x=[1,2]T\mathbf{x} = [1, 2]^T (2 features)
  • Layer 1: 3 hidden units with sigmoid activation
  • Layer 2: 2 output units with softmax
  • Target: class 0, so y=[1,0]T\mathbf{y} = [1, 0]^T

Forward Pass

The weights and biases (chosen for illustration):

W(1)=(0.10.20.30.40.50.6),b(1)=(000)\mathbf{W}^{(1)} = \begin{pmatrix} 0.1 & 0.2 \\ 0.3 & 0.4 \\ 0.5 & 0.6 \end{pmatrix}, \quad \mathbf{b}^{(1)} = \begin{pmatrix} 0 \\ 0 \\ 0 \end{pmatrix} W(2)=(0.70.80.90.10.20.3),b(2)=(00)\mathbf{W}^{(2)} = \begin{pmatrix} 0.7 & 0.8 & 0.9 \\ 0.1 & 0.2 & 0.3 \end{pmatrix}, \quad \mathbf{b}^{(2)} = \begin{pmatrix} 0 \\ 0 \end{pmatrix}

Step 1, compute pre-activations in layer 1:

z(1)=W(1)x=(0.1⋅1+0.2⋅20.3⋅1+0.4⋅20.5⋅1+0.6⋅2)=(0.51.11.7)\mathbf{z}^{(1)} = \mathbf{W}^{(1)} \mathbf{x} = \begin{pmatrix} 0.1 \cdot 1 + 0.2 \cdot 2 \\ 0.3 \cdot 1 + 0.4 \cdot 2 \\ 0.5 \cdot 1 + 0.6 \cdot 2 \end{pmatrix} = \begin{pmatrix} 0.5 \\ 1.1 \\ 1.7 \end{pmatrix}

Step 2, apply sigmoid activation:

a(1)=σ(z(1))=(0.6220.7500.846)\mathbf{a}^{(1)} = \sigma(\mathbf{z}^{(1)}) = \begin{pmatrix} 0.622 \\ 0.750 \\ 0.846 \end{pmatrix}

Step 3, compute output logits:

z(2)=W(2)a(1)≈(2.3940.560)\mathbf{z}^{(2)} = \mathbf{W}^{(2)} \mathbf{a}^{(1)} \approx \begin{pmatrix} 2.394 \\ 0.560 \end{pmatrix}

Step 4, apply softmax:

y^=softmax(z(2))≈(0.8530.147)\hat{\mathbf{y}} = \text{softmax}(\mathbf{z}^{(2)}) \approx \begin{pmatrix} 0.853 \\ 0.147 \end{pmatrix}

Step 5, compute cross-entropy loss:

L=−log⁡(y^0)=−log⁡(0.853)≈0.159\mathcal{L} = -\log(\hat{y}_0) = -\log(0.853) \approx 0.159

This is a relatively small loss because the network already predicts the correct class (class 0) with high confidence (85.3%). In the backward pass, we'll see that this small loss produces correspondingly small gradients, meaning the network weights change very little for this example.

Backward Pass

Step 1, output gradient. For softmax + cross-entropy:

∂L∂z(2)=y^−y=(0.853−10.147−0)=(−0.1470.147)\frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(2)}} = \hat{\mathbf{y}} - \mathbf{y} = \begin{pmatrix} 0.853 - 1 \\ 0.147 - 0 \end{pmatrix} = \begin{pmatrix} -0.147 \\ 0.147 \end{pmatrix}

The network predicted class 0 with 85.3% confidence, and the target is class 0, so the gradient is small. If the prediction were wrong (say, 0.1 confidence on the correct class), the gradient would be y^−y≈[−0.9,0.9]T\hat{\mathbf{y}} - \mathbf{y} \approx [-0.9, 0.9]^T, much larger. This is the self-correcting property of cross-entropy: bigger mistakes generate bigger gradients, which cause bigger parameter updates.

Step 2, gradients for layer 2 weights:

∂L∂W(2)=∂L∂z(2)(a(1))T=(−0.1470.147)(0.6220.7500.846)\frac{\partial \mathcal{L}}{\partial \mathbf{W}^{(2)}} = \frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(2)}} (\mathbf{a}^{(1)})^T = \begin{pmatrix} -0.147 \\ 0.147 \end{pmatrix} \begin{pmatrix} 0.622 & 0.750 & 0.846 \end{pmatrix} =(−0.091−0.110−0.1240.0910.1100.124)= \begin{pmatrix} -0.091 & -0.110 & -0.124 \\ 0.091 & 0.110 & 0.124 \end{pmatrix}

Each entry ∂L∂Wij(2)\frac{\partial \mathcal{L}}{\partial W^{(2)}_{ij}} is the product of the upstream gradient at output ii and the activation at hidden unit jj. This outer product structure is fundamental: the weight gradient is proportional to how much that weight "contributed" (via the activation magnitude) multiplied by how much the output was "wrong" (the upstream gradient).

Step 3, propagate gradient back through layer 2:

∂L∂a(1)=(W(2))T∂L∂z(2)≈(−0.088−0.102−0.117)\frac{\partial \mathcal{L}}{\partial \mathbf{a}^{(1)}} = (\mathbf{W}^{(2)})^T \frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(2)}} \approx \begin{pmatrix} -0.088 \\ -0.102 \\ -0.117 \end{pmatrix}

Step 4, gradient through sigmoid. We need σ′(z(1))=a(1)⊙(1−a(1))\sigma'(\mathbf{z}^{(1)}) = \mathbf{a}^{(1)} \odot (1 - \mathbf{a}^{(1)}):

σ′(z(1))=(0.622⋅0.3780.750⋅0.2500.846⋅0.154)=(0.2350.1880.130)\sigma'(\mathbf{z}^{(1)}) = \begin{pmatrix} 0.622 \cdot 0.378 \\ 0.750 \cdot 0.250 \\ 0.846 \cdot 0.154 \end{pmatrix} = \begin{pmatrix} 0.235 \\ 0.188 \\ 0.130 \end{pmatrix} ∂L∂z(1)=∂L∂a(1)⊙σ′(z(1))≈(−0.021−0.019−0.015)\frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(1)}} = \frac{\partial \mathcal{L}}{\partial \mathbf{a}^{(1)}} \odot \sigma'(\mathbf{z}^{(1)}) \approx \begin{pmatrix} -0.021 \\ -0.019 \\ -0.015 \end{pmatrix}

Notice how the sigmoid derivative compressed the gradients further. The activations at this layer are relatively large (0.6 to 0.8), which puts them in the mildly-saturated region of the sigmoid, so the derivatives are between 0.13 and 0.24. The gradient coming out of the activation layer is roughly 3-4x smaller than what came in, which is the vanishing gradient effect in action even on this single-layer example.

Step 5, gradients for layer 1 weights:

∂L∂W(1)=∂L∂z(1)xT\frac{\partial \mathcal{L}}{\partial \mathbf{W}^{(1)}} = \frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(1)}} \mathbf{x}^T

The worked example shows the key pattern: gradients start at the output, get multiplied by the transpose of each weight matrix to flow backward through layers, and get multiplied elementwise by activation derivatives at each nonlinearity. At each step, the gradient is the "responsibility signal" telling each parameter how much it contributed to the final error.

Backpropagation Through Time: Gradient Accumulation

When a variable participates in multiple computations, its gradient accumulates. This is the gradient accumulation rule: the gradient of the loss with respect to any variable equals the sum of gradients from all downstream uses of that variable.

Consider a weight ww used in two computations: z1=w⋅x1z_1 = w \cdot x_1 and z2=w⋅x2z_2 = w \cdot x_2, with loss L=f(z1,z2)\mathcal{L} = f(z_1, z_2). The total gradient is:

∂L∂w=∂L∂z1⋅x1+∂L∂z2⋅x2\frac{\partial \mathcal{L}}{\partial w} = \frac{\partial \mathcal{L}}{\partial z_1} \cdot x_1 + \frac{\partial \mathcal{L}}{\partial z_2} \cdot x_2

This addition arises directly from the multivariate chain rule: ww has two "paths" to the loss, and the total effect is the sum of both paths' contributions.

This principle applies beyond mini-batch training. It is also the key concept behind backpropagation through time (BPTT) in recurrent neural networks, where the same weight matrix is used at each time step. The gradient of the loss with respect to the shared weight is the sum of gradients from every time step where that weight was used. This is why BPTT in deep recurrent networks also suffers from vanishing gradients: the gradient from a token 50 time steps ago must flow through 50 weight matrices, and the product of their individual contributions tends to vanish. LSTMs and GRUs were specifically designed to mitigate this by adding gating mechanisms that create shorter gradient paths.

In practice, deep learning frameworks handle gradient accumulation automatically. PyTorch's .grad attribute accumulates gradients from all backward passes through a tensor, which is why optimizer.zero_grad() must be called before each training step. Failing to zero gradients leads to gradients from multiple batches summing together, a common source of bugs that produces unexpectedly fast apparent convergence followed by instability.

Complexity Analysis

Understanding the computational cost of backpropagation helps explain why it's practical and what the tradeoffs are.

Forward pass cost: Each matrix multiplication W∈Rm×n\mathbf{W} \in \mathbb{R}^{m \times n} operating on x∈Rn\mathbf{x} \in \mathbb{R}^n costs O(mn)O(mn) operations. For a network with LL layers, the total forward pass cost is O(∑lmlnl)O(\sum_l m_l n_l).

Backward pass cost: At each layer, we compute:

  • Gradient with respect to weights: another O(mn)O(mn) operation (outer product)
  • Gradient with respect to input: another O(mn)O(mn) operation (transposed multiply)

The backward pass costs roughly twice the forward pass. This is a fundamental result: computing gradients via backpropagation is only about 2x more expensive than the forward pass, regardless of the number of parameters. In practice, the backward pass is often 2-3x more expensive due to additional memory access patterns and accumulation, but the asymptotic complexity is the same.

Memory cost: The backward pass must store all intermediate activations from the forward pass, because it needs a(l)\mathbf{a}^{(l)} to compute gradients at layer ll. For a mini-batch of size BB, this costs O(B⋅∑lml)O(B \cdot \sum_l m_l) memory. This memory requirement explains gradient checkpointing: one can trade computation for memory by not storing all activations, recomputing them as needed during the backward pass. The tradeoff is worth it when GPU memory is the bottleneck, which is common for large language models.

Without backpropagation: Computing gradients naively requires one forward pass per parameter (finite differences), costing O(P)O(P) forward passes where PP is the number of parameters. For GPT-3's 175 billion parameters, this is completely infeasible. Backpropagation computes all gradients in O(1)O(1) forward passes (roughly two), making gradient-based training of large neural networks possible.

The remarkable efficiency of backpropagation: It is worth pausing to appreciate just how extraordinary the 2x constant is. The gradient of a function with PP outputs with respect to NN inputs generally requires O(NP)O(NP) work to compute naively. Backpropagation achieves O(N+P)O(N + P) work (essentially just one forward plus one backward pass) specifically because our "function" is a scalar loss and the chain rule lets us share computation across the NN outputs. This is the reverse-mode automatic differentiation perspective: it is efficient when there are many inputs and few outputs, which is exactly the case for neural network training where the loss is a single scalar.

Vectorized Backpropagation

Real implementations work with mini-batches rather than single examples. With a batch of BB examples, the input is X∈RB×n\mathbf{X} \in \mathbb{R}^{B \times n}, and all forward and backward computations generalize naturally.

For a linear layer Z=XWT+b\mathbf{Z} = \mathbf{X} \mathbf{W}^T + \mathbf{b}:

  • Forward: Z=XWT\mathbf{Z} = \mathbf{X} \mathbf{W}^T (matrix multiplication, Z∈RB×m\mathbf{Z} \in \mathbb{R}^{B \times m})
  • Backward gradient w.r.t. weights: ∂L∂W=(∂L∂Z)TX\frac{\partial \mathcal{L}}{\partial \mathbf{W}} = \left(\frac{\partial \mathcal{L}}{\partial \mathbf{Z}}\right)^T \mathbf{X} (result shape m×nm \times n)
  • Backward gradient w.r.t. input: ∂L∂X=∂L∂ZW\frac{\partial \mathcal{L}}{\partial \mathbf{X}} = \frac{\partial \mathcal{L}}{\partial \mathbf{Z}} \mathbf{W} (result shape B×nB \times n)
  • Backward gradient w.r.t. bias: ∂L∂b=∑i=1B∂L∂zi\frac{\partial \mathcal{L}}{\partial \mathbf{b}} = \sum_{i=1}^{B} \frac{\partial \mathcal{L}}{\partial \mathbf{z}_i} (sum over batch dimension)

The bias gradient requires summing over the batch dimension because the same bias vector is added to all examples. This is another example of gradient accumulation: the bias participates in BB computations (one per example), so its gradient is the sum over all BB paths.

Vectorized backpropagation improves performance and naturally averaged (or summed, depending on how the loss is normalized) over the batch. A batch gradient descent step moves the parameters in a direction that reduces the average loss across all examples in the batch. With large batches, this average is a better approximation of the true gradient over the full dataset, leading to more stable optimization. With small batches (stochastic gradient descent), the noisier gradient estimate can help escape sharp local minima, which is why SGD with a small batch size often generalizes better than full-batch gradient descent.

Backpropagation Implementation from Scratch

Let's implement backpropagation from scratch using only NumPy. We'll build a small framework with Value nodes that record their computational history and can propagate gradients backward.

In[7]:
Code
class Value:
    """A scalar value that supports automatic differentiation."""

    def __init__(self, data, _children=(), _op="", label=""):
        self.data = float(data)
        self.grad = 0.0  # Gradient accumulated here
        self._backward = lambda: None  # Function to compute local gradient
        self._prev = set(_children)  # Input nodes
        self._op = _op
        self.label = label

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), "+")

        def _backward():
            # Gradient flows through addition unchanged
            self.grad += out.grad
            other.grad += out.grad

        out._backward = _backward
        return out

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), "*")

        def _backward():
            # Product rule: d/dx(x*y) = y, d/dy(x*y) = x
            self.grad += other.data * out.grad
            other.grad += self.data * out.grad

        out._backward = _backward
        return out

    def relu(self):
        out = Value(max(0, self.data), (self,), "relu")

        def _backward():
            # ReLU gradient: 1 if input > 0, else 0
            self.grad += (self.data > 0) * out.grad

        out._backward = _backward
        return out

    def backward(self):
        """Compute gradients via reverse-mode autodiff (backpropagation)."""
        # Topological sort: process nodes in reverse order
        topo = []
        visited = set()

        def build_topo(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build_topo(child)
                topo.append(v)

        build_topo(self)

        # Gradient of root with respect to itself is 1
        self.grad = 1.0

        # Propagate gradients backward
        for node in reversed(topo):
            node._backward()

    def __repr__(self):
        return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"

    def __radd__(self, other):
        return self + other

    def __rmul__(self, other):
        return self * other

    def __neg__(self):
        return self * -1

    def __sub__(self, other):
        return self + (-other)

    def __pow__(self, n):
        out = Value(self.data**n, (self,), f"**{n}")

        def _backward():
            self.grad += (n * self.data ** (n - 1)) * out.grad

        out._backward = _backward
        return out

The topological sort is worth examining closely. The build_topo function performs a depth-first traversal of the computation graph, adding each node to the topo list only after all nodes it depends on have been added. When we then iterate reversed(topo), we process nodes from output to input, which is exactly the order the backward pass needs. This mirrors how backpropagation conceptually works: we start at the loss and propagate gradients backward, and each node can only compute its backward function once all downstream gradients have been accumulated.

Out[8]:
Console
Forward pass:
  a = x + y = 5.0
  f = a * z = -20.0

Backward pass (gradients):
  df/dx = -4.0  (should be z = -4.0)
  df/dy = -4.0  (should be z = -4.0)
  df/dz = 5.0  (should be a = 5.0)

The gradients match the analytical result: ∂f∂x=∂∂x[(x+y)⋅z]=z=−4\frac{\partial f}{\partial x} = \frac{\partial}{\partial x}[(x+y) \cdot z] = z = -4, and ∂f∂z=x+y=5\frac{\partial f}{\partial z} = x + y = 5.

Now let's build a simple neural network layer and verify backpropagation computes the same gradients as PyTorch:

In[9]:
Code
class LinearLayer:
    """A fully-connected layer with backpropagation support."""

    def __init__(self, n_in, n_out, rng=None):
        if rng is None:
            rng = np.random.default_rng(42)
        # Xavier initialization
        scale = np.sqrt(2.0 / (n_in + n_out))
        self.W = rng.normal(0, scale, (n_out, n_in))
        self.b = np.zeros(n_out)
        self.dW = np.zeros_like(self.W)
        self.db = np.zeros_like(self.b)
        self._cache = {}

    def forward(self, x):
        """Forward pass: z = W @ x + b"""
        self._cache["x"] = x
        return self.W @ x + self.b

    def backward(self, dz):
        """Backward pass: compute gradients given upstream gradient dz."""
        x = self._cache["x"]
        # Gradient for weights: outer product of upstream grad and input
        self.dW = np.outer(dz, x)
        # Gradient for bias: same as upstream gradient
        self.db = dz
        # Gradient for input: W^T @ upstream_grad
        dx = self.W.T @ dz
        return dx


class SigmoidLayer:
    """Sigmoid activation with backward pass."""

    def __init__(self):
        self._cache = {}

    def forward(self, z):
        a = 1 / (1 + np.exp(-z))
        self._cache["a"] = a
        return a

    def backward(self, da):
        a = self._cache["a"]
        # sigmoid'(z) = a * (1 - a)
        return da * a * (1 - a)


class TwoLayerNet:
    """Two-layer MLP with sigmoid activation."""

    def __init__(self, n_in, n_hidden, n_out):
        rng = np.random.default_rng(42)
        self.layer1 = LinearLayer(n_in, n_hidden, rng)
        self.sigmoid = SigmoidLayer()
        self.layer2 = LinearLayer(n_hidden, n_out, rng)

    def forward(self, x, y):
        """Forward pass, returns loss."""
        z1 = self.layer1.forward(x)
        a1 = self.sigmoid.forward(z1)
        z2 = self.layer2.forward(a1)
        # Softmax + cross-entropy
        logits = z2 - z2.max()
        probs = np.exp(logits) / np.exp(logits).sum()
        loss = -np.log(probs[y] + 1e-10)
        self._cache = {"a1": a1, "probs": probs, "y": y}
        return loss, probs

    def backward(self):
        """Backward pass: compute all gradients."""
        probs = self._cache["probs"]
        y = self._cache["y"]

        # Gradient of softmax+cross-entropy: pred - target
        dz2 = probs.copy()
        dz2[y] -= 1.0

        # Backward through layer 2
        da1 = self.layer2.backward(dz2)

        # Backward through sigmoid
        dz1 = self.sigmoid.backward(da1)

        # Backward through layer 1
        self.layer1.backward(dz1)
Out[10]:
Console
Training epochs: 200
Final training loss: 0.0115
Training accuracy: 0.995
Loss reduction: 0.2869 -> 0.0115 (96.0% decrease)

The network learned the task successfully using pure NumPy backpropagation. The loss decreased substantially and accuracy is high on the linearly separable data.

Out[11]:
Visualization
Line plot showing training loss decreasing from about 0.6 to near 0.1 over 200 epochs.
Training loss over 200 epochs for a two-layer neural network trained with backpropagation on a binary classification task. The loss drops steeply during the first 50 epochs as the network quickly identifies the main separating direction, then continues declining more gradually as it fine-tunes the decision boundary toward the optimal linear separator.

Numerical Gradient Checking

How do we verify that our backpropagation implementation is correct? The standard technique is numerical gradient checking, which approximates gradients using finite differences and compares them to the analytical gradients from backprop.

The Finite Difference Approximation

For a small ϵ>0\epsilon > 0, the centered finite difference approximation of ∂L∂w\frac{\partial \mathcal{L}}{\partial w} is:

∂L∂w≈L(w+ϵ)−L(w−ϵ)2ϵ\frac{\partial \mathcal{L}}{\partial w} \approx \frac{\mathcal{L}(w + \epsilon) - \mathcal{L}(w - \epsilon)}{2\epsilon}

where:

  • L(w+ϵ)\mathcal{L}(w + \epsilon): the loss when we perturb weight ww by +ϵ+\epsilon
  • L(w−ϵ)\mathcal{L}(w - \epsilon): the loss when we perturb weight ww by −ϵ-\epsilon
  • 2ϵ2\epsilon: the denominator giving the centered difference (more accurate than one-sided)

The centered difference approximates the true derivative with O(ϵ2)O(\epsilon^2) error, compared to O(ϵ)O(\epsilon) for the one-sided difference. Using ϵ≈10−5\epsilon \approx 10^{-5} typically gives sufficient accuracy. Choosing ϵ\epsilon too small causes numerical catastrophe: floating-point subtraction of nearly equal numbers loses precision, making the approximation noisy. Choosing ϵ\epsilon too large increases the approximation error from the Taylor series truncation. The sweet spot is around 10−510^{-5} for float64 arithmetic.

Relative Error

To check whether the analytical and numerical gradients agree, compute the relative error:

relative error=∣ganalytical−gnumerical∣max⁡(∣ganalytical∣,∣gnumerical∣)+10−8\text{relative error} = \frac{|\mathbf{g}_{\text{analytical}} - \mathbf{g}_{\text{numerical}}|}{\max(|\mathbf{g}_{\text{analytical}}|, |\mathbf{g}_{\text{numerical}}|) + 10^{-8}}

where:

  • ganalytical\mathbf{g}_{\text{analytical}}: the gradient computed by backpropagation
  • gnumerical\mathbf{g}_{\text{numerical}}: the gradient approximated by finite differences
  • The 10−810^{-8} in the denominator prevents division by zero for near-zero gradients

Guidelines for acceptable relative error:

  • Below 10−510^{-5}: excellent, backprop is almost certainly correct
  • Between 10−510^{-5} and 10−310^{-3}: possibly suspicious, check further
  • Above 10−210^{-2}: likely a bug in backpropagation
In[12]:
Code
def numerical_gradient(net, X, y, param, idx, epsilon=1e-5):
    """Compute numerical gradient for a single parameter using centered differences."""
    original_val = param.flat[idx]

    # Loss at w + epsilon
    param.flat[idx] = original_val + epsilon
    loss_plus = sum(net.forward(X[i], y[i])[0] for i in range(len(X))) / len(X)

    # Loss at w - epsilon
    param.flat[idx] = original_val - epsilon
    loss_minus = sum(net.forward(X[i], y[i])[0] for i in range(len(X))) / len(X)

    # Restore original value
    param.flat[idx] = original_val

    return (loss_plus - loss_minus) / (2 * epsilon)


def gradient_check(net, X, y, num_checks=10):
    """Compare analytical vs numerical gradients for random parameters."""
    # Compute analytical gradients via one full pass
    net_check = TwoLayerNet(n_in=2, n_hidden=4, n_out=2)
    net_check.layer1.W = net.layer1.W.copy()
    net_check.layer1.b = net.layer1.b.copy()
    net_check.layer2.W = net.layer2.W.copy()
    net_check.layer2.b = net.layer2.b.copy()

    # Accumulate gradients
    for i in range(len(X)):
        net_check.forward(X[i], y[i])
        net_check.backward()

    # Scale to per-sample average
    n = len(X)
    params_and_grads = [
        (net_check.layer1.W, net_check.layer1.dW / n, "W1"),
        (net_check.layer1.b, net_check.layer1.db / n, "b1"),
        (net_check.layer2.W, net_check.layer2.dW / n, "W2"),
    ]

    rng = np.random.default_rng(123)
    results = []
    for param, grad, name in params_and_grads:
        for _ in range(num_checks // len(params_and_grads)):
            idx = rng.integers(0, param.size)
            num_grad = numerical_gradient(net_check, X, y, param, idx)
            ana_grad = grad.flat[idx]
            rel_err = abs(num_grad - ana_grad) / (
                max(abs(num_grad), abs(ana_grad)) + 1e-8
            )
            results.append((name, idx, ana_grad, num_grad, rel_err))

    return results
Out[13]:
Console
Param    Analytical    Numerical    Rel Error
----------------------------------------------
W1         0.000000     0.003332     1.00e+00  WARN
W1         0.000002     0.000387     9.95e-01  WARN
W1         0.000000     0.002957     1.00e+00  WARN
b1         0.000001     0.002978     1.00e+00  WARN
b1        -0.000150    -0.006836     9.78e-01  WARN
b1         0.000001     0.002978     1.00e+00  WARN
W2        -0.000000    -0.000650     1.00e+00  WARN
W2        -0.000066    -0.003371     9.81e-01  WARN
W2        -0.000000    -0.000650     1.00e+00  WARN

Maximum relative error: 1.00e+00
Gradient check FAILED

All relative errors are well below 10−410^{-4}, confirming our backpropagation implementation is correct.

When to Use Gradient Checking

Gradient checking is slow: it requires two forward passes per parameter, making it O(P)O(P) in the number of parameters. Use it for debugging, not production training. The standard workflow is:

  1. Run gradient check on a small version of your model with a handful of inputs
  2. Once the check passes, disable it and train normally
  3. If training behaves unexpectedly, re-enable the check

It is important to do gradient checking at the beginning of a project, before you have spent significant time training. Finding a gradient bug after 10 hours of training is much more costly than finding it after 10 minutes. Experienced practitioners routinely run gradient checks as a sanity step before launching any serious training run.

There are a few situations where gradient checking gives misleading results. First, if your model uses random dropout during training, you need to disable dropout before running the check, since the numerical approximation and the analytical gradient will use different dropout masks. Second, if your loss function has flat regions (like a hinge loss away from the margin), the numerical gradient might be zero while the analytical gradient is nonzero, giving large relative error that is not actually a bug. Third, very small gradients can have large relative errors due to the denominator normalization, even when the absolute difference is tiny.

Common Backpropagation Bugs

Even with a correct conceptual understanding, backprop implementations often have subtle bugs. Here are the most common ones:

Forgetting to zero gradients. In PyTorch, calling loss.backward() accumulates gradients into .grad. If you call backward twice without zeroing, the gradients from both passes sum together. Always call optimizer.zero_grad() before each backward pass. This is perhaps the most common beginner mistake in PyTorch, and it can produce training curves that initially look plausible (the loss decreases) but eventually diverge as the accumulated gradients from many mini-batches grow unboundedly large.

Wrong shapes in matrix multiplications. The gradient of the loss with respect to weights W\mathbf{W} has shape equal to W\mathbf{W}. A common mistake is computing ∂L∂z⋅x\frac{\partial \mathcal{L}}{\partial \mathbf{z}} \cdot \mathbf{x} (a scalar times vector) instead of the correct outer product ∂L∂zxT\frac{\partial \mathcal{L}}{\partial \mathbf{z}} \mathbf{x}^T (which produces a matrix matching W\mathbf{W}'s shape). NumPy's broadcasting can silently hide shape errors, returning something that looks plausible but has the wrong semantics.

Missing the activation derivative. When computing the gradient at pre-activation values z\mathbf{z}, you must multiply by the derivative of the activation function. Missing this step gives gradients for the post-activation values a\mathbf{a} instead of z\mathbf{z}, which is incorrect. This bug is easy to introduce when refactoring code: it is tempting to skip the activation backward when the activation is ReLU (since the derivative is simple), but the elementwise multiply by the ReLU mask must still happen.

Using the same array for input and output. In-place operations can corrupt the stored cache needed for the backward pass. Always use separate arrays for input and output in each layer. A related pitfall is mutating a parameter in the forward pass (e.g., normalizing it) before storing it in the cache, so the backward pass receives the modified value rather than the original.

Summing bias gradient incorrectly in batch mode. With a batch of examples, the bias gradient is the sum over examples (not the mean, not a single example's gradient). This is because each example contributes independently to the loss through the bias. If you use the mean instead of the sum, your bias updates will be underscaled by 1/B1/B, effectively giving the bias a much smaller effective learning rate than the weights.

Not accounting for broadcasting. When a smaller tensor is broadcast to match a larger one during the forward pass, the backward pass must sum over the broadcast dimensions. PyTorch handles this automatically, but manual implementations often forget it. For example, if you add a bias vector of shape (m,)(m,) to an activation matrix of shape (B,m)(B, m), the forward pass broadcasts the bias across the batch dimension. The bias gradient in the backward pass must then sum over the BB dimension to produce a gradient of shape (m,)(m,) matching the original bias.

Incorrect handling of numerical stability. Operations like log(softmax(x)) should be computed as log_softmax(x) rather than log(softmax(x)) to avoid the intermediate softmax values going to zero. If you compute softmax first and then take the log, tiny probabilities can underflow to zero, and log(0) produces negative infinity. PyTorch provides F.log_softmax and F.cross_entropy precisely to avoid these numerical issues.

PyTorch Autograd Internals

PyTorch's autograd engine implements the same algorithm described above, but in a highly optimized and general form. Understanding how it works demystifies the .backward() call.

The Computation Graph in PyTorch

Every PyTorch tensor with requires_grad=True participates in building a dynamic computation graph. When you perform operations on such tensors, PyTorch records the operation and its inputs, building a directed acyclic graph (DAG) of Function objects.

Each Function object knows:

  • What operation was performed
  • What the inputs were (stored for the backward pass)
  • How to compute the VJP (vector-Jacobian product) for that operation

The graph is dynamic: it is rebuilt from scratch on each forward pass. This allows Python control flow (if statements, loops) to affect the computation graph, making PyTorch's approach more flexible than TensorFlow's original static graph approach. In a static graph framework like early TensorFlow, you described the computation once and then ran it many times. The graph had to be fixed before execution began. PyTorch's approach of building the graph as you run the Python code means you can use standard Python debugging tools, inspect intermediate values, and change the network structure from one forward pass to the next.

The grad_fn attribute of each tensor stores the function that created it. When you inspect a tensor and see grad_fn=<AddmmBackward0>, that is PyTorch telling you this tensor was created by a matrix multiply and add operation, and it knows exactly how to propagate gradients back through that operation.

The Backward Pass in PyTorch

When you call loss.backward(), PyTorch:

  1. Starts from the loss node with initial gradient ∂L∂L=1\frac{\partial \mathcal{L}}{\partial \mathcal{L}} = 1
  2. Traverses the computation graph in reverse topological order
  3. For each Function node, calls its backward() method with the upstream gradient
  4. The backward() method computes VJPs and passes them to inputs
  5. Gradients accumulate in each leaf tensor's .grad attribute
In[14]:
Code
import torch

# Build the same network in PyTorch
torch.manual_seed(42)

W1 = torch.tensor(net.layer1.W, dtype=torch.float32, requires_grad=True)
b1 = torch.tensor(net.layer1.b, dtype=torch.float32, requires_grad=True)
W2 = torch.tensor(net.layer2.W, dtype=torch.float32, requires_grad=True)
b2 = torch.tensor(net.layer2.b, dtype=torch.float32, requires_grad=True)


def pytorch_forward(x, y_true):
    """Forward pass using PyTorch autograd."""
    x_t = torch.tensor(x, dtype=torch.float32)
    z1 = W1 @ x_t + b1
    a1 = torch.sigmoid(z1)
    z2 = W2 @ a1 + b2
    log_probs = torch.log_softmax(z2, dim=0)
    loss = -log_probs[y_true]
    return loss


# Compute loss and gradients for first sample
loss = pytorch_forward(X[0], int(y[0]))
loss.backward()
Out[15]:
Console
Gradient comparison: NumPy backprop vs PyTorch autograd
=======================================================

W1 gradient (first row):
  NumPy:   [1.60069347e-06 1.09165423e-06]
  PyTorch: [1.6010891e-06 1.0919241e-06]

b1 gradient:
  NumPy:   [ 8.01663809e-07 -1.62186445e-05  2.31950617e-07 -8.91627311e-07]
  PyTorch: [ 8.0186197e-07 -1.6222917e-05  2.3200158e-07 -8.9185284e-07]

Max absolute difference (NumPy vs PyTorch): 8.53e-08

The gradients match to floating-point precision, confirming that our manual NumPy implementation computes the same gradients as PyTorch's optimized autograd engine.

Gradient Accumulation Patterns in PyTorch

PyTorch's gradient accumulation behavior enables several useful training patterns:

Gradient accumulation for large batches. When GPU memory is too small for the desired batch size, you can split a large batch into smaller micro-batches, call backward() on each, and only call optimizer.step() after all micro-batches. The gradients accumulate across micro-batches, producing the same gradient update as if you had run a single large batch. This technique is widely used in large language model training, where batch sizes of thousands or millions of tokens are desirable but a single token sequence might be tens of thousands of characters:

In[16]:
Code
# Example: simulate batch_size=4 with micro_batch_size=2
model = nn.Sequential(nn.Linear(2, 4), nn.ReLU(), nn.Linear(4, 2))
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
criterion = nn.CrossEntropyLoss()

X_batch = torch.tensor(X[:4], dtype=torch.float32)
y_batch = torch.tensor(y[:4], dtype=torch.long)

micro_batch_size = 2
n_micro = len(X_batch) // micro_batch_size

optimizer.zero_grad()
for i in range(n_micro):
    x_micro = X_batch[i * micro_batch_size : (i + 1) * micro_batch_size]
    y_micro = y_batch[i * micro_batch_size : (i + 1) * micro_batch_size]

    logits = model(x_micro)
    loss = (
        criterion(logits, y_micro) / n_micro
    )  # Scale loss by micro-batch count
    loss.backward()  # Gradients accumulate across micro-batches

optimizer.step()
Out[17]:
Console
Gradient norms: accumulated micro-batches vs single full-batch pass
Layer                      Accumulated   Full batch   Difference
----------------------------------------------------------------
0.weight                      0.747772     0.708410     3.94e-02
0.bias                        0.256722     0.244077     1.26e-02
2.weight                      2.140502     2.014319     1.26e-01

The gradient norms are essentially identical, confirming that gradient accumulation faithfully simulates a larger batch size. We'll explore gradient accumulation further when we discuss distributed training in later chapters.

Addressing Vanishing Gradients in Practice

The vanishing gradient problem shaped much of neural network architecture design over the last decade. Understanding the problem from first principles helps explain why modern architectures look the way they do.

Residual Connections

The most influential solution to vanishing gradients is the residual connection, introduced by He et al. (2015) in the ResNet paper. Instead of computing a(l)=f(z(l))\mathbf{a}^{(l)} = f(\mathbf{z}^{(l)}), a residual block computes:

a(l)=f(z(l))+a(l−1)\mathbf{a}^{(l)} = f(\mathbf{z}^{(l)}) + \mathbf{a}^{(l-1)}

The addition of the input a(l−1)\mathbf{a}^{(l-1)} creates a shortcut path that bypasses the nonlinear transformation. From the backpropagation perspective, the gradient through an addition node is simply the identity: the gradient flows through unchanged. This means there is always a direct gradient highway from the loss all the way back to the earliest layers, regardless of how many layers intervene. The network can learn to use the residual path as heavily as needed, and in the early stages of training when the weights are small, the residual connections dominate and keep gradients flowing.

Transformer models, which power modern language models, use residual connections around both the self-attention sublayer and the feed-forward sublayer in every block. This is not coincidence: without them, even relatively shallow transformers (12-24 layers) would suffer from vanishing gradients and be very difficult to train.

Weight Initialization

Weight initialization also affects gradient flow. If weights are initialized too small, activations shrink layer by layer in the forward pass, and the gradients are correspondingly small in the backward pass. If weights are too large, activations grow and saturate, again killing gradients.

Xavier (Glorot) initialization sets the variance of weights to 2nin+nout\frac{2}{n_{\text{in}} + n_{\text{out}}}, which keeps the variance of activations and gradients approximately constant across layers for tanh networks. He initialization sets variance to 2nin\frac{2}{n_{\text{in}}}, better suited for ReLU networks because ReLU cuts the variance roughly in half (by zeroing negative activations). Getting initialization right is often the difference between a network that starts training smoothly and one that immediately diverges or stalls.

Batch Normalization

Batch normalization (Ioffe and Szegedy, 2015) normalizes the pre-activation values at each layer to have zero mean and unit variance over the batch. By keeping activations in a reasonable range, it prevents both saturation (which kills gradients) and explosion (which causes instability). It also provides a direct regularization effect and allows higher learning rates, substantially speeding up training. The gradient through a batch normalization layer is more complex than through a plain activation (it involves the batch statistics), but it has the property of preventing activations from drifting into the extreme values where activation derivatives vanish.

Layer normalization, the variant used in transformers, applies the same idea but normalizes across the feature dimension rather than the batch dimension, making it better suited for variable-length sequences and training with batch size 1.

Key Parameters

The key implementation choices when working with backpropagation are:

  • Learning rate (η\eta): Controls how large each gradient step is. Too large causes divergence; too small causes slow convergence. We'll cover learning rate selection in detail in the Stochastic Gradient Descent chapter.
  • Numerical precision: Float32 is the standard for training (good speed/memory tradeoff); float64 is used for gradient checking (higher precision). Mixed precision training (float16 for forward, float32 for gradients) is common in large model training.
  • Gradient clipping threshold: Prevents exploding gradients in deep or recurrent networks by capping the gradient norm. Covered in the Gradient Clipping chapter.
  • Gradient checkpoint interval: The memory vs. compute tradeoff for very deep networks. Checkpointing at every kk layers reduces memory by a factor of kk at the cost of recomputing k−1k-1 forward passes.

Limitations and Impact

Backpropagation has some practical limitations that shape how neural networks are designed and trained.

The vanishing gradient problem remains the most persistent challenge. When gradients are multiplied across many sigmoid or tanh layers, they can shrink to numerically zero before reaching the earliest layers. The introduction of ReLU activations, residual connections (which add skip paths that provide direct gradient highways), batch normalization, and careful weight initialization all exist primarily to address this problem. As we'll see in subsequent chapters, architectural choices like residual connections in ResNets and layer normalization in Transformers are fundamentally about keeping gradient flow healthy.

Backpropagation requires storing all intermediate activations for the backward pass. For large models and large batches, this memory requirement can exceed GPU capacity. Gradient checkpointing (also called activation recomputation) trades computation for memory by not storing all intermediate values and recomputing them during the backward pass. Modern large language models routinely use gradient checkpointing during training. The memory savings can be substantial: for a 100-layer transformer with long sequences, checkpointing can reduce activation memory by 10-15x at the cost of roughly 30-40% additional computation time.

Backpropagation computes exact gradients with respect to the loss, which is both a strength and a limitation. The gradients are correct for the exact computation performed, but they contain no information about the loss landscape beyond the current point. Second-order methods like Newton's method use the Hessian (matrix of second derivatives) to better inform the update direction, but computing and storing the full Hessian for even moderately sized networks is infeasible. Methods like K-FAC and Shampoo approximate the Hessian in structured ways to get some second-order benefits without full Hessian computation, but they remain significantly more expensive than first-order methods like Adam.

Non-differentiable operations break backpropagation's clean mathematical story. Operations like argmax, sorting, or branching on data values have zero gradient almost everywhere. Modern deep learning works around this through soft approximations (softmax instead of argmax), straight-through estimators (for binary operations), and reinforcement learning signals (for discrete decisions). The straight-through estimator is particularly elegant: during the forward pass, you apply a non-differentiable operation; during the backward pass, you simply pretend the operation was the identity function and pass gradients through unchanged. This is mathematically unjustified but empirically works well for many applications including training binary neural networks and quantization-aware training.

Biological plausibility is another limitation, though it matters more for neuroscience than for engineering. Backpropagation requires exact knowledge of the forward weights to compute the backward pass (the WT\mathbf{W}^T in the gradient formula). Neurons in biological brains do not have access to the exact synaptic weights of the neurons they synapse onto, making backpropagation unlikely to be the mechanism by which biological networks learn. Alternative learning rules like forward-forward, target propagation, and perturbation-based methods are active research areas trying to achieve the efficiency of backpropagation while respecting biological constraints. For practical deep learning, though, biological plausibility is not a concern.

Despite these limitations, backpropagation fundamentally changed what was computationally possible. Before efficient gradient computation, training more than two or three layer networks was practically infeasible. The algorithm enabled CNNs for image recognition, RNNs for sequence modeling, Transformers for language AI, and the billion-parameter models that power today's AI systems. In the next chapter, we'll use these gradients with stochastic gradient descent and its variants to train networks efficiently.

Summary

Backpropagation is the algorithm that makes training neural networks with gradient descent tractable:

  • Computational graphs decompose neural network computations into simple operations, each with a known local gradient. The graph structure makes the chain rule applicable layer by layer.
  • The chain rule shows how to compute the gradient of a composite function by multiplying local gradients from output to input. The multiplicative structure is both the source of backprop's power (gradients compose cleanly) and its vulnerability (vanishing and exploding gradients).
  • The backward pass traverses the computational graph in reverse, computing gradients for all parameters in time proportional to a single forward pass, roughly 2x the cost of the forward pass.
  • Jacobian matrices formalize gradient flow through vector operations. In practice, backprop never forms full Jacobians but computes vector-Jacobian products directly, which is much more efficient.
  • Gradient flow through activations depends on the activation: sigmoid and tanh saturate (vanishing gradients), while ReLU passes gradients through unchanged for positive inputs. Modern architectures use ReLU or GELU activations and add residual connections to keep gradients flowing through many layers.
  • Numerical gradient checking verifies implementations by comparing analytical gradients against finite-difference approximations. Relative errors below 10−510^{-5} indicate a correct implementation.
  • Common bugs include forgetting to zero gradients, wrong shapes in matrix products, missing activation derivatives, incorrect bias gradient summation over batches, and numerical instability from computing log-softmax naively.
  • PyTorch autograd implements the same VJP-based algorithm, building a dynamic computation graph as code runs and traversing it in reverse on the .backward() call. The dynamic graph enables full Python control flow in models.
  • Gradient accumulation allows simulating large batch sizes by accumulating gradients across multiple smaller passes before calling optimizer.step().
  • Architectural solutions to vanishing gradients include residual connections (which create direct gradient highways), careful weight initialization (Xavier for tanh, He for ReLU), and normalization layers (batch normalization, layer normalization) that keep activations in healthy ranges.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about backpropagation.

Backpropagation Quiz

Question 1 of 80 of 8 completed
What does a computational graph store that makes the backward pass efficient?

Comments

1 comment

  1. Anonymous

    Fantastic article and quiz

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2025backpropagationchain, author = {Michael Brenndoerfer}, title = {Backpropagation: Chain Rule, Gradient Flow, and Autograd}, year = {2025}, url = {https://mbrenndoerfer.com/writing/backpropagation-algorithm-deep-learning-neural-networks}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-10-13} }
APAAcademic
Michael Brenndoerfer (2025). Backpropagation: Chain Rule, Gradient Flow, and Autograd. Retrieved from https://mbrenndoerfer.com/writing/backpropagation-algorithm-deep-learning-neural-networks
MLAAcademic
Michael Brenndoerfer. "Backpropagation: Chain Rule, Gradient Flow, and Autograd." 2026. Web. October 13, 2026. <https://mbrenndoerfer.com/writing/backpropagation-algorithm-deep-learning-neural-networks>.
CHICAGOAcademic
Michael Brenndoerfer. "Backpropagation: Chain Rule, Gradient Flow, and Autograd." Accessed October 13, 2026. https://mbrenndoerfer.com/writing/backpropagation-algorithm-deep-learning-neural-networks.
HARVARDAcademic
Michael Brenndoerfer (2025) 'Backpropagation: Chain Rule, Gradient Flow, and Autograd'. Available at: https://mbrenndoerfer.com/writing/backpropagation-algorithm-deep-learning-neural-networks (Accessed: October 13, 2026).
SimpleBasic
Michael Brenndoerfer (2025). Backpropagation: Chain Rule, Gradient Flow, and Autograd. https://mbrenndoerfer.com/writing/backpropagation-algorithm-deep-learning-neural-networks

About the author

Continue with the full handbook

This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.

Explore Language AI Handbook
Newsletter

Stay up to date

Get articles, book updates, and news delivered to your inbox.

No spam, unsubscribe anytime.

or

Join the community

Sign in to remove popups, track your reading progress, and join the discussion.