February 10, 2026
But: We hand-waved how training actually works
Today: We open the hood
We have a deep network:
\[\hat{y} = f_{\text{out}} \circ f_D \circ \cdots \circ f_1(x)\]
Where:
\[ f_d(\mathbf h_{d - 1}) = \varphi(\mathbf W_d \mathbf h_{d - 1} + \mathbf b_d) \]
And a loss:
\[\mathcal{L} = \frac{1}{N} \sum_{i=1}^N \mathcal{L}(\hat{y}_i, y_i)\]
We need:
\[\theta^* = \arg\min_\theta \mathcal{L}(\theta \mid X, y)\]
Thinking in terms of a nested function:
\[\hat{y} = \sigma_{\text{out}}(\boldsymbol \beta \varphi(\mathbf W_D \varphi(\mathbf W_{D-1} \cdots \varphi(\mathbf W_1 x + \mathbf b_1) \cdots + \mathbf b_{D-1}) + \mathbf b_D) + \boldsymbol \alpha)\]
Input goes in the first layer
Project to a new space with \(\mathbf W\) and \(\mathbf b\)
Apply the activation function \(\varphi\)
Repeat over and over and over…
We have a set of unknowns:
\(\mathbf W_1, \mathbf b_1, \ldots, \mathbf W_D, \mathbf b_D\)
\(\bbeta\) and \(\boldsymbol \alpha\) in the output layer (our regular old linear regression for the final hidden representation)
Goal: Find the values for these parameters given the training data that minimize the loss!
No closed-form solution!
The loss surface is non-convex, high-dimensional, and composed of many nested functions.
We need iterative numerical optimization.
We need to minimize:
\[\mathcal{L}(\theta \mid X, y) = \frac{1}{N} \sum_{i=1}^N \mathcal{L}(\hat{y}_i, y_i)\]
over \(\theta = \{\mathbf W_1, \mathbf b_1, \ldots, \mathbf W_D, \mathbf b_D, \bbeta, \boldsymbol \alpha\}\)
You’ve seen this in the optimization module — this is a quick review.
The gradient descent “optimal” update:
\[\theta_t = \theta_{t-1} - \eta \nabla_\theta \mathcal{L}(\theta_{t-1})\]
Why move in the direction of the negative gradient?
What is \(\eta\)? Why does it matter?
Full gradient descent computes:
\[\nabla_\theta \mathcal{L} = \frac{1}{N} \sum_{i=1}^N \nabla_\theta \mathcal{L}_i\]
This requires a forward pass through all \(N\) observations — expensive!
Especially when \(N\) is large and each training instance has a lot of features.
Like images or text documents.
SGD: Approximate the full gradient with a mini-batch of size \(B\):
\[\nabla_\theta \mathcal{L} \approx \frac{1}{B} \sum_{i \in \text{batch}} \nabla_\theta \mathcal{L}_i\]
Question: Why are we more worried about saddle points in high dimensional parameters spaces than local minima?
Gradient descent only uses first-order information: the slope.
Newton’s method also uses second-order information: the curvature.
\[\theta_{\text{new}} = \theta - \eta \mathbf H^{-1} \nabla_\theta \mathcal{L}\]
where \(\mathbf H\) is the Hessian — the matrix of second derivatives.
The Hessian inverse \(\mathbf H^{-1}\) does two things at once:
Rotates the update — accounts for how parameters interact (off-diagonal terms \(\frac{\partial^2 \mathcal{L}}{\partial w_j \partial w_k}\)), straightening the path instead of zig-zagging
Rescales the axes — normalizes step sizes per-parameter (diagonal terms \(\frac{\partial^2 \mathcal{L}}{\partial w_j^2}\)), so we move at the same effective speed in all directions
Super efficient!
Moves directly towards the minimum instead of orthogonal to the contours of the loss surface
Rescales the update step sizes based on curvature information
Why can’t we directly apply 2nd order optimization methods to deep networks?
Why can’t we use this for deep networks?
The Hessian for a network with \(|\theta|\) parameters is a \(|\theta| \times |\theta|\) matrix.
Second-order methods give the best directions, but the cost is completely infeasible at scale.
We need something that captures the spirit of the Hessian without computing it.
Adam (Adaptive Moment Estimation) approximates both things the Hessian does, cheaply:
Momentum (approximates the rotation)
Adaptive learning rates (approximates the rescaling)
Cost: \(O(|\theta|)\) per step — same as vanilla SGD!
Adam is the default optimizer in most PyTorch code for good reason.
We now have powerful engines: SGD, Momentum, Adam.
They all share the same update structure:
\[\theta_t = \theta_{t-1} - \eta \cdot \text{optimizer}(\nabla_\theta \mathcal{L})\]
The engine (optimizer) determines how we move given gradient information.
The map (gradient) determines where we should go.
The best engine in the world is useless without a map.
The critical bottleneck: All of these methods require \(\frac{\partial \mathcal{L}}{\partial \theta}\) — the gradient of the loss w.r.t. every parameter.
For logistic regression: One set of coefficients, clean expression.
For a deep network with \(D\) layers of nested transformations: ???
The loss is bad — but which of the thousands of parameters are responsible, and by how much?
This is the credit assignment problem: systematically determining how much each parameter in the chain contributed to the final loss.
Next: A systematic, efficient way to compute these gradients — the backpropagation algorithm.
Our network is a nested function:
\[\hat{y} = \sigma_{\text{out}}(\bbeta \varphi(\mathbf W_D \varphi(\mathbf W_{D-1} \cdots \varphi(\mathbf W_1 x + \mathbf b_1) \cdots + \mathbf b_{D-1}) + \mathbf b_D) + \boldsymbol \alpha)\]
The chain rule lets us differentiate through compositions:
\[\frac{d}{dx} f(g(x)) = f'(g(x)) \cdot g'(x)\]
For deep networks, this becomes a chain of local derivatives — one per module in the network.
We don’t need to differentiate the entire nested expression at once. We just need each piece to know its own derivative.
A deep network isn’t one big equation — it’s a sequence of simple steps, each feeding into the next.
Each step is a module: a linear transformation, an activation, a loss computation.
This is exactly how you build a model in PyTorch — stack modules in nn.Sequential:
Each module knows how to do two things:
The chain of equations flowing up is mirrored by a chain of gradients flowing back down.
At each module in the chain, we apply:
\[\boxed{\text{Downstream Gradient} = \text{Local Gradient} \times \text{Upstream Gradient}}\]
\[\frac{\partial \mathcal{L}}{\partial \text{input}} = \frac{\partial \text{output}}{\partial \text{input}} \cdot \frac{\partial \mathcal{L}}{\partial \text{output}}\]
At each module, we only need:
Multiply them together → pass to the module below.
That’s the entire algorithm.
When we take derivatives in neural networks, the shape of the result depends on what we’re differentiating.
Gradient: Derivative of a scalar w.r.t. a vector
Jacobian: Derivative of a vector w.r.t. a vector
Why this matters for backprop:
The loss \(\mathcal{L}\) is always a scalar, so the final answer for each parameter is always a gradient (same shape as the parameter)
But the intermediate derivatives between hidden layers are Jacobians
Backprop chains Jacobians together to produce gradients
The good news: Most Jacobians in neural networks have special structure (diagonal, outer product) so we never actually build the full matrix.
Architecture: \(P\) inputs → \(M_1\) hidden → \(M_2\) hidden → \(M_3\) hidden → \(K\) outputs
\[\underset{(M_1 \times 1)}{\mathbf z_{i1}} = \underset{(M_1 \times P)}{\mathbf W_1^T} \underset{(P \times 1)}{\mathbf x_i} + \underset{(M_1 \times 1)}{\mathbf b_1} \quad \text{[Linear]}\]
\[\underset{(M_1 \times 1)}{\mathbf h_{i1}} = \underset{(M_1 \times 1)}{\varphi(\mathbf z_{i1})} \quad \text{[Activation]}\]
\[\underset{(M_2 \times 1)}{\mathbf z_{i2}} = \underset{(M_2 \times M_1)}{\mathbf W_2^T} \underset{(M_1 \times 1)}{\mathbf h_{i1}} + \underset{(M_2 \times 1)}{\mathbf b_2} \quad \text{[Linear]}\]
\[\underset{(M_2 \times 1)}{\mathbf h_{i2}} = \underset{(M_2 \times 1)}{\varphi(\mathbf z_{i2})} \quad \text{[Activation]}\]
\[\underset{(M_3 \times 1)}{\mathbf z_{i3}} = \underset{(M_3 \times M_2)}{\mathbf W_3^T} \underset{(M_2 \times 1)}{\mathbf h_{i2}} + \underset{(M_3 \times 1)}{\mathbf b_3} \quad \text{[Linear]}\]
\[\underset{(M_3 \times 1)}{\mathbf h_{i3}} = \underset{(M_3 \times 1)}{\varphi(\mathbf z_{i3})} \quad \text{[Activation]}\]
\[\underset{(K \times 1)}{\btheta_i} = \underset{(K \times M_3)}{\bbeta^T} \underset{(M_3 \times 1)}{\mathbf h_{i3}} + \underset{(K \times 1)}{\boldsymbol \alpha} \quad \text{[Linear]}\]
\[\underset{(\text{scalar})}{\mathcal{L}_i} = \text{Loss}(\underset{(K \times 1)}{\sigma_{\text{out}}(\btheta_i)}, \underset{(K \times 1)}{y_i}) \quad \text{[Loss]}\]
Each line is one module. The chain flows up from \(\mathbf x_i\); gradients will flow back down from \(\mathcal{L}_i\).
Unknowns (what we need gradients for):
That’s 8 gradients to compute.
We’ll work from the top of the chain (the loss) back to the bottom (\(\mathbf W_1\)).
At each module, we’ll apply the same principle: Downstream = Local × Upstream.
We can show the chain of partials that will lead to the gradients for each of the unknown weight matrices:
\[\frac{\partial \mathcal{L}_i}{\partial \bbeta} = \frac{\partial \mathcal{L}_i}{\partial \mathbf z_i} \cdot \frac{\partial \mathbf z_i}{\partial \bbeta}\]
\[\frac{\partial \mathcal{L}_i}{\partial \mathbf W_3} = \frac{\partial \mathcal{L}_i}{\partial \mathbf z_i} \cdot \frac{\partial \mathbf z_i}{\partial \mathbf h_{i3}} \cdot \frac{\partial \mathbf h_{i3}}{\partial \mathbf z_{i3}} \cdot \frac{\partial \mathbf z_{i3}}{\partial \mathbf W_3}\]
\[\frac{\partial \mathcal{L}_i}{\partial \mathbf W_2} = \frac{\partial \mathcal{L}_i}{\partial \mathbf z_i} \cdot \frac{\partial \mathbf z_i}{\partial \mathbf h_{i3}} \cdot \frac{\partial \mathbf h_{i3}}{\partial \mathbf z_{i3}} \cdot \frac{\partial \mathbf z_{i3}}{\partial \mathbf h_{i2}} \cdot \frac{\partial \mathbf h_{i2}}{\partial \mathbf z_{i2}} \cdot \frac{\partial \mathbf z_{i2}}{\partial \mathbf W_2}\]
\[\frac{\partial \mathcal{L}_i}{\partial \mathbf W_1} = \frac{\partial \mathcal{L}_i}{\partial \mathbf z_i} \cdot \frac{\partial \mathbf z_i}{\partial \mathbf h_{i3}} \cdot \frac{\partial \mathbf h_{i3}}{\partial \mathbf z_{i3}} \cdot \frac{\partial \mathbf z_{i3}}{\partial \mathbf h_{i2}} \cdot \frac{\partial \mathbf h_{i2}}{\partial \mathbf z_{i2}} \cdot \frac{\partial \mathbf z_{i2}}{\partial \mathbf h_{i1}} \cdot \frac{\partial \mathbf h_{i1}}{\partial \mathbf z_{i1}} \cdot \frac{\partial \mathbf z_{i1}}{\partial \mathbf W_1}\]
The only reason that this is going to work as well as it does in practice is that the partial chains are common until we reach the location in the chain where our parameter of interest lives!
This is what we mean by
\[\boxed{\text{Downstream Gradient} = \text{Local Gradient} \times \text{Upstream Gradient}}\]
Recall our loss function:
\[\mathcal{L}(\theta \mid X, y) = \frac{1}{N} \sum_{i=1}^N \mathcal{L}_i(\hat{y}_i, y_i)\]
The overall loss is an average of per-observation losses.
The derivative of a sum is the sum of the derivatives:
\[\frac{\partial \mathcal{L}}{\partial \theta} = \frac{1}{N} \sum_{i=1}^N \frac{\partial \mathcal{L}_i}{\partial \theta}\]
This means we can compute \(\frac{\partial \mathcal{L}_i}{\partial \theta}\) for a single observation and the full gradient is just the average across all \(N\).
The first step in the backward pass: compute the gradient of the loss w.r.t. the linear predictor \(\btheta_i\).
\[\frac{\partial \mathcal{L}_i}{\partial \btheta_i} = \text{ ???}\]
This is the starting signal — the first \(\delta\) that will flow backward down the chain.
Remarkably, for the three loss functions you’ll encounter most often, this gradient has a clean, unified form.
Output activation: identity (\(\hat{y}_i = \btheta_i\))
Loss:
\[\mathcal{L}_i = \frac{1}{2}(\btheta_i - y_i)^2\]
Gradient:
\[\frac{\partial \mathcal{L}_i}{\partial \btheta_i} = \btheta_i - y_i = \hat{y}_i - y_i\]
Just the residual. Predicted minus true.
Output activation: sigmoid (\(\hat{y}_i = \sigma(\btheta_i)\))
Loss:
\[\mathcal{L}_i = -[y_i \log \sigma(\btheta_i) + (1 - y_i) \log(1 - \sigma(\btheta_i))]\]
Using \(\sigma'(\btheta) = \sigma(\btheta)(1 - \sigma(\btheta))\):
\[\frac{\partial \mathcal{L}_i}{\partial \btheta_i} = \sigma(\btheta_i) - y_i = \hat{y}_i - y_i\]
Same form as MSE! Predicted minus true.
Now \(\btheta_i \in \mathbb{R}^K\) — one score per class.
Output activation: softmax
\[\hat{y}_{ik} = \sigma_{\text{out}}(\btheta_i)_k = \frac{\exp(\btheta_{ik})}{\sum_{g=1}^K \exp(\btheta_{ig})}\]
Loss:
\[\mathcal{L}_i = -\sum_{k=1}^K \mathbb{I}(y_i = k) \log \hat{y}_{ik}\]
Gradient w.r.t. the full vector \(\btheta_i\):
\[\frac{\partial \mathcal{L}_i}{\partial \btheta_i} = \hat{y}_i - e_{y_i} \quad (K \times 1)\]
where \(e_{y_i}\) is a one-hot vector: 1 in position \(y_i\), 0 elsewhere.
\[\frac{\partial \mathcal{L}_i}{\partial \btheta_{ik}} = \hat{y}_{ik} - \mathbb{I}(y_i = k)\]
For the correct class: \(\hat{y}_{ik} - 1\) (negative → push this score up)
For incorrect classes: \(\hat{y}_{ik} - 0 = \hat{y}_{ik}\) (positive → push these scores down)
Again: predicted minus true.
| Loss | \(\sigma_{\text{out}}\) | \(\frac{\partial \mathcal{L}_i}{\partial \btheta_i}\) | Shape |
|---|---|---|---|
| MSE | Identity | \(\hat{y}_i - y_i\) | \((1 \times 1)\) |
| BCE | Sigmoid | \(\hat{y}_i - y_i\) | \((1 \times 1)\) |
| CE | Softmax | \(\hat{y}_i - e_{y_i}\) | \((K \times 1)\) |
We’ll call this starting signal \(\delta_0\):
\[\delta_0 = \frac{\partial \mathcal{L}_i}{\partial \btheta_i} = \hat{y}_i - y_i^*\]
This is the gradient that enters the top of the chain and begins flowing backward.
Each of these loss gradients is trivially cheap to compute:
This is the first piece of evidence that backprop is computationally cheap.
Every module in the chain will have a similarly simple local gradient. The magic is in how they compose.
We’re now one step below the loss in the chain.
The output linear module computes:
\[\underset{(K \times 1)}{\btheta_i} = \underset{(K \times M_3)}{\bbeta^T} \times \underset{(M_3 \times 1)}{\mathbf h_{i3}} + \underset{(K \times 1)}{\boldsymbol \alpha}\]
The upstream gradient arriving from the loss module:
\[\delta_0 = \frac{\partial \mathcal{L}_i}{\partial \btheta_i} \quad (K \times 1)\]
This module needs to produce three things:
We’ll be careful to distinguish two types of multiplication:
\(\times\) — Matrix/outer product: standard matrix multiplication or outer product of vectors
\[\underset{(M \times K)}{\mathbf A} \times \underset{(K \times 1)}{\mathbf v} = \underset{(M \times 1)}{\text{result}}\]
Inner dimensions must match. Result shape = outer dimensions.
\(\odot\) — Elementwise (Hadamard) product: multiply corresponding entries
\[\underset{(M \times 1)}{\mathbf u} \odot \underset{(M \times 1)}{\mathbf v} = \underset{(M \times 1)}{\text{result}}\]
Vectors must be the same shape. Result shape = same as inputs.
\[\btheta_i = \bbeta^T \times \mathbf h_{i3} + \boldsymbol \alpha\]
Local Jacobian:
\[\frac{\partial \btheta_i}{\partial \boldsymbol \alpha} = \mathbf I_K \quad (K \times K)\]
Downstream = Local \(\times\) Upstream:
\[\frac{\partial \mathcal{L}_i}{\partial \boldsymbol \alpha} = \underset{(K \times K)}{\mathbf I_K} \times \underset{(K \times 1)}{\delta_0} = \underset{(K \times 1)}{\delta_0}\]
The bias gradient is just the upstream gradient, passed through unchanged.
This will always be true for any module that adds a parameter.
Shape check: \(\delta_0 \in \mathbb{R}^{K}\), \(\boldsymbol \alpha \in \mathbb{R}^{K}\) ✓
\[\btheta_i = \bbeta^T \times \mathbf h_{i3} + \boldsymbol \alpha\]
How does the output change when the input \(\mathbf h_{i3}\) changes?
Matrix derivative rule: if \(\mathbf z = \mathbf A^T \times \mathbf x\), then \(\frac{\partial \mathbf z}{\partial \mathbf x} = \mathbf A^T\)
Local Jacobian:
\[\frac{\partial \btheta_i}{\partial \mathbf h_{i3}} = \bbeta^T \quad (K \times M_3)\]
Downstream = Local \(\times\) Upstream:
\[\frac{\partial \mathcal{L}_i}{\partial \mathbf h_{i3}} = \underset{(M_3 \times K)}{\bbeta} \times \underset{(K \times 1)}{\delta_0} = \underset{(M_3 \times 1)}{\text{result}}\]
\[\btheta_i = \bbeta^T \times \mathbf h_{i3} + \boldsymbol \alpha\]
\(\btheta_i \in \mathbb{R}^K\) and \(\bbeta \in \mathbb{R}^{M_3 \times K}\)
The full Jacobian \(\frac{\partial \btheta_i}{\partial \bbeta}\) would be a 3D tensor of size \(K \times M_3 \times K\).
We don’t want to compute or store this!
But we can avoid it entirely.
Write out the elements of \(\btheta_i\):
\[\theta_{i1} = \beta_{11} h_{i3,1} + \beta_{21} h_{i3,2} + \cdots + \beta_{M_3,1} h_{i3,M_3} + \alpha_1\]
\[\theta_{i2} = \beta_{12} h_{i3,1} + \beta_{22} h_{i3,2} + \cdots + \beta_{M_3,2} h_{i3,M_3} + \alpha_2\]
\[\vdots\]
\[\theta_{iK} = \beta_{1K} h_{i3,1} + \beta_{2K} h_{i3,2} + \cdots + \beta_{M_3,K} h_{i3,M_3} + \alpha_K\]
Each \(\theta_{ik}\) is a sum over the \(k\)-th column of \(\bbeta\).
The entries \(\beta_{m,j}\) for \(j \neq k\) do not appear in \(\theta_{ik}\).
No cross-talk: changing a weight in column \(k\) of \(\bbeta\) affects \(\theta_{ik}\) and nothing else.
\[\theta_{ik} = \beta_{1k} h_{i3,1} + \beta_{2k} h_{i3,2} + \cdots + \beta_{M_3,k} h_{i3,M_3} + \alpha_k\]
\[\frac{\partial \theta_{ik}}{\partial \boldsymbol \beta_{\cdot k}} = \begin{bmatrix} \frac{\partial \theta_{ik}}{\partial \beta_{1k}} \\ \frac{\partial \theta_{ik}}{\partial \beta_{2k}} \\ \vdots \\ \frac{\partial \theta_{ik}}{\partial \beta_{M_3,k}} \end{bmatrix} = \begin{bmatrix} h_{i3,1} \\ h_{i3,2} \\ \vdots \\ h_{i3,M_3} \end{bmatrix} = \mathbf h_{i3}\]
The local gradient of \(\theta_{ik}\) w.r.t. its column of weights is just the input vector \(\mathbf h_{i3}\).
And this is the same for every \(k\) — because every output is a dot product of the same input with a different column of weights.
Since there’s no cross-talk, the full Jacobian \(\frac{\partial \btheta_i}{\partial \bbeta}\) has a block-diagonal structure.
\[\frac{\partial \btheta_i}{\partial \bbeta} = \begin{bmatrix} \mathbf h_{i3} & \mathbf 0 & \cdots & \mathbf 0 \\ \mathbf 0 & \mathbf h_{i3} & \cdots & \mathbf 0 \\ \vdots & \vdots & \ddots & \vdots \\ \mathbf 0 & \mathbf 0 & \cdots & \mathbf h_{i3} \end{bmatrix}\]
Each block is the same \(\mathbf h_{i3}\) vector — repeated \(K\) times along the diagonal.
We never need to build this full matrix. The structure tells us exactly how to combine things.
We need \(\frac{\partial \mathcal{L}_i}{\partial \bbeta}\), which should be \((M_3 \times K)\) — the same shape as \(\bbeta\).
The upstream gradient is \(\delta_0 \in \mathbb{R}^{K \times 1}\), giving us one scalar per output.
Because of the block-diagonal structure, the gradient for each column \(k\) of \(\bbeta\) is:
\[\frac{\partial \mathcal{L}_i}{\partial \boldsymbol \beta_{\cdot k}} = \delta_{0,k} \cdot \mathbf h_{i3} \quad (M_3 \times 1)\]
The upstream scalar \(\delta_{0,k}\) says “how much does the loss care about output \(k\)?”
The local gradient \(\mathbf h_{i3}\) says “which inputs contributed to output \(k\)?”
Multiplying them: “how much should each weight in column \(k\) change?”
Stacking these \(K\) column gradients side by side:
\[\frac{\partial \mathcal{L}_i}{\partial \bbeta} = \begin{bmatrix} | & | & & | \\ \delta_{0,1} \cdot \mathbf h_{i3} & \delta_{0,2} \cdot \mathbf h_{i3} & \cdots & \delta_{0,K} \cdot \mathbf h_{i3} \\ | & | & & | \end{bmatrix}\]
Each column is the same vector \(\mathbf h_{i3}\) scaled by a different scalar \(\delta_{0,k}\).
This is exactly the definition of an outer product:
\[\frac{\partial \mathcal{L}_i}{\partial \bbeta} = \underset{(M_3 \times 1)}{\mathbf h_{i3}} \times \underset{(1 \times K)}{\delta_0^T} = \underset{(M_3 \times K)}{\text{result}}\]
Shape check: \((M_3 \times 1) \times (1 \times K) = (M_3 \times K)\) — same shape as \(\bbeta\) ✓
For any linear module \(\mathbf z = \mathbf W^T \times \mathbf h + \mathbf b\) with upstream gradient \(\delta\):
| Derivative | Formula | Shape | Type |
|---|---|---|---|
| Bias | \(\frac{\partial \mathcal{L}}{\partial \mathbf b} = \delta\) | same as \(\mathbf b\) | Terminal |
| Weights | \(\frac{\partial \mathcal{L}}{\partial \mathbf W} = \mathbf h \times \delta^T\) | same as \(\mathbf W\) | Terminal |
| Pass-through | \(\frac{\partial \mathcal{L}}{\partial \mathbf h} = \mathbf W \times \delta\) | same as \(\mathbf h\) | Downstream |
What operations did we just use?
No inversions. No sorting. No conditional logic. Just multiplies and adds.
These are the operations that GPUs were built for.
We’re now below the output linear module in the chain.
The activation module computes:
\[\underset{(M_3 \times 1)}{\mathbf h_{i3}} = \underset{(M_3 \times 1)}{\varphi(\mathbf z_{i3})}\]
Applied elementwise: each element of \(\mathbf z_{i3}\) is transformed independently.
The upstream gradient arriving from the linear module above:
\[\delta_1 = \frac{\partial \mathcal{L}_i}{\partial \mathbf h_{i3}} = \underset{(M_3 \times K)}{\bbeta} \times \underset{(K \times 1)}{\delta_0} \quad (M_3 \times 1)\]
This module has no learnable parameters — its only job is to pass the gradient downstream.
\[\varphi(z) = \text{ReLU}(z) = \max(0, z)\]
Applied elementwise: \(h_{i3,m} = \max(0, z_{i3,m})\) for each \(m = 1, \ldots, M_3\)
Since each output depends only on the corresponding input, the Jacobian is diagonal:
\[\frac{\partial h_{i3,m}}{\partial z_{i3,m}} = \begin{cases} 1 & \text{if } z_{i3,m} > 0 \\ 0 & \text{if } z_{i3,m} < 0 \end{cases}\]
No cross-talk: changing \(z_{i3,m}\) has zero effect on \(h_{i3,j}\) for \(j \neq m\).
Multiplying by a diagonal Jacobian is just an elementwise multiply — this is where \(\odot\) appears!
\[\frac{\partial \mathcal{L}_i}{\partial \mathbf z_{i3}} = \underset{(M_3 \times 1)}{\delta_1} \odot \underset{(M_3 \times 1)}{\mathbb{I}(\mathbf z_{i3} > 0)}\]
where \(\mathbb{I}(\mathbf z_{i3} > 0)\) is a binary mask: 1 where \(z_{i3,m} > 0\), and 0 where \(z_{i3,m} \leq 0\).
For each hidden unit:
Shape check: \(\underset{(M_3 \times 1)}{\delta_1} \odot \underset{(M_3 \times 1)}{\mathbb{I}(\mathbf z_{i3} > 0)} = \underset{(M_3 \times 1)}{\text{result}}\) — same shape as \(\mathbf z_{i3}\) ✓
ReLU’s backward pass is a gate on the gradient flow:
Computationally, this is one elementwise multiply — about as cheap as it gets.
But there’s a cost: Every dead unit is a unit that receives no gradient signal.
If a unit is dead for every observation in the training set, its weights will never be updated. It’s permanently dead.
The same logic applies to any elementwise activation \(\varphi\).
The Jacobian is always diagonal, so the backward pass is always \(\odot\):
\[\frac{\partial \mathcal{L}_i}{\partial \mathbf z} = \delta \odot \varphi'(\mathbf z)\]
| Activation | \(\varphi(z)\) | \(\varphi'(z)\) (diagonal entry) |
|---|---|---|
| ReLU | \(\max(0, z)\) | \(\mathbb{I}(z > 0)\) |
| Leaky ReLU | \(\max(\alpha z, z)\) | \(\mathbb{I}(z > 0) + \alpha \cdot \mathbb{I}(z \leq 0)\) |
| Swish/SiLU | \(z \cdot \sigma(z)\) | \(\sigma(z) + z \cdot \sigma(z)(1 - \sigma(z))\) |
| GELU | \(z \cdot \Phi(z)\) | \(\Phi(z) + z \cdot \phi(z)\) |
| Sigmoid | \(\sigma(z)\) | \(\sigma(z)(1 - \sigma(z))\) |
| Tanh | \(\tanh(z)\) | \(1 - \tanh^2(z)\) |
ReLU and its smooth cousins (Leaky ReLU, Swish, GELU) are the default choice for hidden layers:
Leaky ReLU: Prevents dead units by allowing a small gradient when \(z < 0\)
Swish/SiLU and GELU: Smooth approximations that never fully kill gradients
Key advantage: All allow unbounded positive activations — the network can represent arbitrarily large values when needed.
No matter which activation you choose, the backward pass follows the same pattern:
\[\frac{\partial \mathcal{L}_i}{\partial \mathbf z} = \underset{(M \times 1)}{\delta} \odot \underset{(M \times 1)}{\varphi'(\mathbf z)} = \underset{(M \times 1)}{\text{result}}\]
Same shape in, same shape out. Always \(\odot\) (elementwise), never \(\times\) (matrix).
| Activation | Forward | Derivative \(\varphi'(z)\) | Cache needed |
|---|---|---|---|
| ReLU | \(\max(0, z)\) | \(\mathbb{I}(z > 0)\) | Sign of \(z\) |
| Leaky ReLU | \(\max(\alpha z, z)\) | \(\mathbb{I}(z > 0) + \alpha \cdot \mathbb{I}(z \leq 0)\) | Sign of \(z\) |
| GELU | \(z \cdot \Phi(z)\) | \(\Phi(z) + z \cdot \phi(z)\) | Value of \(z\) |
| Sigmoid | \(\sigma(z)\) | \(\sigma(z)(1 - \sigma(z))\) | Value of \(\sigma(z)\) |
| Tanh | \(\tanh(z)\) | \(1 - \tanh^2(z)\) | Value of \(\tanh(z)\) |
The computation is always cheap: one elementwise multiply per hidden unit.
| Module | Operation | Backward uses | Why |
|---|---|---|---|
| Linear | \(\mathbf z = \mathbf W^T \times \mathbf h + \mathbf b\) | \(\times\) (matrix products, outer products) | Weights mix dimensions together |
| Activation | \(\mathbf h = \varphi(\mathbf z)\) | \(\odot\) (elementwise) | Each element transforms independently |
We’ve derived backward rules for two module types:
We’ve applied them to the top of our 3-layer network:
\[\mathcal{L}_i \xrightarrow{\delta_0} \text{Linear (output)} \xrightarrow{\delta_1} \text{ReLU 3} \xrightarrow{\delta_2} \quad \cdots\]
We have gradients for \(\bbeta\) and \(\boldsymbol \alpha\), and the upstream gradient \(\delta_2\) is sitting at the entrance to Linear 3 (\(\mathbf W_3, \mathbf b_3\)).
What does \(\delta_2\) actually look like?
Unrolling what we’ve computed so far:
\[\delta_0 = \hat{y}_i - y_i^* \quad (K \times 1)\]
\[\delta_1 = \underset{(M_3 \times K)}{\bbeta} \times \underset{(K \times 1)}{\delta_0} \quad (M_3 \times 1)\]
\[\delta_2 = \underset{(M_3 \times 1)}{\delta_1} \odot \underset{(M_3 \times 1)}{\mathbb{I}(\mathbf z_{i3} > 0)} \quad (M_3 \times 1)\]
Writing it all out:
\[\delta_2 = \left[\underset{(M_3 \times K)}{\bbeta} \times \underset{(K \times 1)}{(\hat{y}_i - y_i^*)}\right] \odot \underset{(M_3 \times 1)}{\mathbb{I}(\mathbf z_{i3} > 0)}\]
This is the accumulated gradient signal that has flowed down from the loss through the output layer and ReLU 3.
From our chain of partials:
\[\frac{\partial \mathcal{L}_i}{\partial \mathbf W_3} = \underbrace{\frac{\partial \mathcal{L}_i}{\partial \btheta_i} \cdot \frac{\partial \btheta_i}{\partial \mathbf h_{i3}} \cdot \frac{\partial \mathbf h_{i3}}{\partial \mathbf z_{i3}}}_{\delta_2 \text{ (already computed!)}} \cdot \frac{\partial \mathbf z_{i3}}{\partial \mathbf W_3}\]
And to keep going down to \(\mathbf W_2\):
\[\frac{\partial \mathcal{L}_i}{\partial \mathbf W_2} = \underbrace{\frac{\partial \mathcal{L}_i}{\partial \btheta_i} \cdot \frac{\partial \btheta_i}{\partial \mathbf h_{i3}} \cdot \frac{\partial \mathbf h_{i3}}{\partial \mathbf z_{i3}} \cdot \frac{\partial \mathbf z_{i3}}{\partial \mathbf h_{i2}} \cdot \frac{\partial \mathbf h_{i2}}{\partial \mathbf z_{i2}}}_{\delta_4 \text{ (two more steps)}} \cdot \frac{\partial \mathbf z_{i2}}{\partial \mathbf W_2}\]
This looks like a lot of work…
Each of those partial derivatives is a local gradient we’ve already derived:
| Partial | Module | Rule |
|---|---|---|
| \(\frac{\partial \btheta_i}{\partial \mathbf h_{i3}}\) | Linear (output) pass-through | \(\bbeta \times\) |
| \(\frac{\partial \mathbf h_{i3}}{\partial \mathbf z_{i3}}\) | ReLU 3 | \(\odot \; \mathbb{I}(\mathbf z_{i3} > 0)\) |
| \(\frac{\partial \mathbf z_{i3}}{\partial \mathbf h_{i2}}\) | Linear 3 pass-through | \(\mathbf W_3 \times\) |
| \(\frac{\partial \mathbf h_{i2}}{\partial \mathbf z_{i2}}\) | ReLU 2 | \(\odot \; \mathbb{I}(\mathbf z_{i2} > 0)\) |
| \(\frac{\partial \mathbf z_{i2}}{\partial \mathbf h_{i1}}\) | Linear 2 pass-through | \(\mathbf W_2 \times\) |
| \(\frac{\partial \mathbf h_{i1}}{\partial \mathbf z_{i1}}\) | ReLU 1 | \(\odot \; \mathbb{I}(\mathbf z_{i1} > 0)\) |
Every local gradient is either a \(\times\) by a weight matrix or a \(\odot\) with a binary mask.
We just walk down the chain, applying the same two rules over and over.
Starting from \(\delta_0 = \hat{y}_i - y_i^* \quad (K \times 1)\):
Output linear module (\(\bbeta, \boldsymbol \alpha\)):
ReLU 3:
Linear 3 (\(\mathbf W_3, \mathbf b_3\)):
ReLU 2:
Linear 2 (\(\mathbf W_2, \mathbf b_2\)):
ReLU 1:
Linear 1 (\(\mathbf W_1, \mathbf b_1\)):
Done. All 8 gradients computed.
No pass-through needed — \(\mathbf x_i\) is the input, not a learnable parameter.
Since the dimensionality always matches up perfectly at each layer:
You can stack as many layers as you want — the pattern repeats identically.
This is why nn.Sequential works: each module’s backward output is exactly the right shape for the next module’s backward input.
| Step | Module | \(\delta\) update | Terminal gradients | \(\delta\) shape |
|---|---|---|---|---|
| 0 | Loss | \(\delta_0 = \hat{y}_i - y_i^*\) | — | \(K\) |
| 1 | Linear (out) | \(\delta_1 = \bbeta \times \delta_0\) | \(\boldsymbol \alpha = \delta_0\); \(\bbeta = \mathbf h_{i3} \times \delta_0^T\) | \(M_3\) |
| 2 | ReLU 3 | \(\delta_2 = \delta_1 \odot \mathbb{I}(\mathbf z_{i3} > 0)\) | — | \(M_3\) |
| 3 | Linear 3 | \(\delta_3 = \mathbf W_3 \times \delta_2\) | \(\mathbf b_3 = \delta_2\); \(\mathbf W_3 = \mathbf h_{i2} \times \delta_2^T\) | \(M_2\) |
| 4 | ReLU 2 | \(\delta_4 = \delta_3 \odot \mathbb{I}(\mathbf z_{i2} > 0)\) | — | \(M_2\) |
| 5 | Linear 2 | \(\delta_5 = \mathbf W_2 \times \delta_4\) | \(\mathbf b_2 = \delta_4\); \(\mathbf W_2 = \mathbf h_{i1} \times \delta_4^T\) | \(M_1\) |
| 6 | ReLU 1 | \(\delta_6 = \delta_5 \odot \mathbb{I}(\mathbf z_{i1} > 0)\) | — | \(M_1\) |
| 7 | Linear 1 | — | \(\mathbf b_1 = \delta_6\); \(\mathbf W_1 = \mathbf x_i \times \delta_6^T\) | — |
What we just derived is the mathematical content of backpropagation.
But there’s an important structural insight beyond “just apply the chain rule.”
Look at the terminal gradient formulas:
The gradients depend on intermediate values (\(\mathbf h\)’s and \(\mathbf z\)’s) that only exist after we’ve evaluated the network on actual data.
We can’t compute gradients without first running the network forward.
Given current parameter values, evaluate the chain from bottom to top:
This is just prediction — nothing new. But now we keep all the intermediate values.
Starting from the loss, walk backward down the chain:
At each module:
This is the key design principle:
Forward: takes an input, produces an output, caches what it needs
Backward: takes an upstream \(\delta\), produces parameter gradients (if any) and a downstream \(\delta\)
Each module is self-contained — it doesn’t need to know what’s above or below it.
It only needs the upstream \(\delta\) and its own cached values.
This is backpropagation: forward pass (compute and cache) + backward pass (chain \(\delta\)’s).
Backprop is a special case of reverse-mode automatic differentiation.
The recipe:
Any computation that can be expressed as a chain of differentiable modules can be optimized with SGD + autodiff.
This is why the framework extends beyond standard neural networks — anything fully differentiable can be learned.
Each nn.Module (Linear, ReLU, Sigmoid, BatchNorm, etc.) implements:
When you call loss.backward():
.grad attributes of each parameterThis is exactly the chain of \(\delta\)’s we derived — PyTorch just automates it.
That’s it. The entire sigmoid module is these two functions.
Every module in PyTorch follows this pattern: a forward computation and a local gradient \(\odot\) or \(\times\) the upstream \(\delta\).
Each one implements the same pattern:
When you add a new layer type to your model, you don’t change the optimization algorithm.
You just write a new forward/backward pair and plug it into the chain.
Look at the operations each module performs:
These are simple, uniform, massively parallel operations.
This is why PyTorch is fast despite networks having millions of parameters.
Three properties make deep learning uniquely scalable:
1. SGD doesn’t need large batches
The gradient is an average (Section 4). By LLN, a small random sample gives a good estimate. Cost per step scales with \(B\), not \(N\).
2. Representation learning compresses
Each layer transforms high-dimensional input into lower-dimensional, task-relevant features. The weight gradients (\(\mathbf h \times \delta^T\)) operate in this compressed space, not the original \(P\) dimensions.
3. Full differentiability makes it modular
As long as every module has a forward/backward pair, SGD can optimize the whole chain end-to-end. Adding a new module type doesn’t change the algorithm.
Greedy, sequential tree building
Split search at every node
Complex branching logic
XGBoost operations:
“Read this feature, sort it, evaluate every possible split, pick the best one, branch left or right”
Backprop operations:
“Multiply these two matrices (\(\times\)). Multiply these two vectors elementwise (\(\odot\)).”
Deep learning’s primitive operations (\(\times\) and \(\odot\)) map perfectly onto GPU architecture.
Tree-based methods do not.
We can compute gradients for arbitrarily deep networks — great!
The cost is linear in depth — great!
But look at what \(\delta\) actually contains by the time it reaches the bottom of the chain…
Recall from our summary table, \(\delta\) at the bottom of a \(D\)-layer network passes through:
\[\delta_0 \xrightarrow{\bbeta \;\times} \delta_1 \xrightarrow{\odot \; \mathbb{I}} \delta_2 \xrightarrow{\mathbf W_3 \;\times} \delta_3 \xrightarrow{\odot \; \mathbb{I}} \delta_4 \xrightarrow{\mathbf W_2 \;\times} \delta_5 \xrightarrow{\odot \; \mathbb{I}} \delta_6\]
Written out, the \(\delta\) arriving at the first layer is:
\[\delta_{2D} \propto \underset{\text{loss}}{\delta_0} \underset{\times}{\bbeta} \underset{\odot}{\mathbb{I}(\mathbf z_{i3} > 0)} \underset{\times}{\mathbf W_3} \underset{\odot}{\mathbb{I}(\mathbf z_{i2} > 0)} \underset{\times}{\mathbf W_2} \underset{\odot}{\mathbb{I}(\mathbf z_{i1} > 0)}\]
A chain of weight matrices (\(\times\)) and ReLU masks (\(\odot\)), one pair per layer.
The deeper the network, the longer this product chain.
If the terms are on average slightly greater than 1:
\[(1 + \epsilon)^D \to \infty \quad \text{as } D \text{ grows}\]
If the terms are on average slightly less than 1:
\[(1 - \epsilon)^D \to 0 \quad \text{as } D \text{ grows}\]
The deeper the network, the smaller \(\epsilon\) needs to be to cause trouble.
This isn’t a bug in backprop — it’s inherent to chaining many differentiable modules.
Exploding gradients: the products of weight matrices are generally \(> 1\)
Vanishing gradients: the products are generally \(< 1\)
Both prevent early layers from learning effectively.
Symptoms:
NaNWhen it happens:
The geometry: steep cliffs in the loss surface. One SGD step happens to land on a cliff, the gradient is enormous, and the update shoots the parameters into a completely different region.
The gradient tells us two things: direction and magnitude.
Key insight: as long as the direction is right, we can cap the magnitude.
\[g'(\theta) = \min\left(1, \frac{c}{\|g(\theta)\|}\right) \cdot g(\theta)\]
\(c \approx 1\)–\(5\) is typical. Simple, widely used, especially for recurrent models.
This is a one-line fix:
If input features have wildly different scales, the first linear module produces large pre-activations:
\[\mathbf z_{i1} = \mathbf W_1^T \times \mathbf x_i + \mathbf b_1\]
If some \(x_{ij}\) values are in the thousands while others are near zero, \(\mathbf z_{i1}\) can be huge — and that magnitude propagates through the chain.
Fix: Standardize features to zero mean and unit variance, or scale to \([0, 1]\).
This is why we divided MNIST pixels by 255 last lecture.
Simple, but only addresses the input to the chain. What about the intermediate layers?
(This is where batch normalization will help — next lecture.)
Gradient descent requires starting values for all parameters.
Naive approach: \(W_{jk} \sim N(0, 1)\)
What happens in the forward pass?
If \(\mathbf x \sim N(0, 1)\) and \(\mathbf W_1\) entries \(\sim N(0, 1)\):
\[z_{i1,m} = \sum_{j=1}^P W_{1,jm} \, x_{ij}\]
\[\text{Var}(z_{i1,m}) = P \cdot \text{Var}(W) \cdot \text{Var}(x) = P\]
The variance of the pre-activations scales with the fan-in \(P\).
For MNIST with \(P = 784\): the pre-activations are \(\sim N(0, 784)\) — wildly large!
Forward pass: activation variance scales with fan-in
\[\text{Var}(\mathbf z_l) \propto M_{l-1} \cdot \text{Var}(\mathbf z_{l-1})\]
Each layer amplifies the variance by a factor of \(M_{l-1}\).
Backward pass: gradient variance scales with fan-out
\[\text{Var}(\delta_l) \propto M_{l+1} \cdot \text{Var}(\delta_{l+1})\]
Each step down the chain amplifies the \(\delta\) variance by a factor of \(M_{l+1}\).
With \(N(0,1)\) initialization, both the activations and the gradients blow up exponentially with depth.
Goal: Choose the variance of \(\mathbf W\) so that both forward and backward variance stay at \(\approx 1\).
Forward constraint: \(\text{Var}(W) \cdot M_{\text{in}} = 1 \implies \text{Var}(W) = \frac{1}{M_{\text{in}}}\)
Backward constraint: \(\text{Var}(W) \cdot M_{\text{out}} = 1 \implies \text{Var}(W) = \frac{1}{M_{\text{out}}}\)
Glorot compromise: split the difference:
\[W_{jk} \sim N\left(0, \frac{2}{M_{\text{in}} + M_{\text{out}}}\right)\]
ReLU kills roughly half the units (\(z \leq 0\)), cutting the variance in half.
To compensate, double the variance:
\[W_{jk} \sim N\left(0, \frac{2}{M_{\text{in}}}\right)\]
This is the default in PyTorch’s nn.Linear.
The result: activations maintain roughly unit variance across layers, and gradients maintain roughly unit variance as they flow back down the chain.
Proper initialization turns the multiplicative chain from a bomb into a stable signal.
We have three simple tools:
These all address the same underlying issue: scale.
The \(\delta\)’s are too big? Make them smaller. The activations are too big? Normalize them. The initial weights amplify too much? Shrink them.
Scale is easy to control.
Vanishing gradients are a different beast entirely.
Look back at the ReLU backward rule:
\[\delta_{\text{below}} = \delta_{\text{above}} \odot \underset{(M \times 1)}{\mathbb{I}(\mathbf z > 0)}\]
Every time a unit is dead (\(z_m \leq 0\)), its gradient is not small — it’s exactly zero.
\[\delta_{\text{above},m} \odot 0 = 0\]
This isn’t a scale problem. The information is genuinely destroyed.
No amount of rescaling can recover a signal that has been multiplied by zero.
Imagine a deep network. At each ReLU layer, some fraction of units are dead:
\[\delta_6 = \delta_0 \underset{\times}{\bbeta} \underset{\odot}{[1,0,1,\ldots]} \underset{\times}{\mathbf W_3} \underset{\odot}{[0,1,0,\ldots]} \underset{\times}{\mathbf W_2} \underset{\odot}{[1,0,0,\ldots]}\]
Each \(\odot\) with a ReLU mask zeros out some dimensions of \(\delta\).
After enough layers, the \(\delta\) signal can be completely extinguished before reaching the bottom of the chain.
Early layers receive no gradient → their weights don’t update → they don’t learn.
Vanishing gradients: The signal is gone.
Dead ReLUs can be permanent:
If a unit is dead for every observation in the training set:
This requires fundamentally different solutions — not just tuning knobs.
Think about what’s causing the problem:
1. The activation function kills units entirely
2. Activations drift into bad ranges across layers
3. The gradient must pass through every single module
4. The network over-relies on specific units
Activation functions that never fully kill the gradient:
Normalization that keeps activations healthy:
Architectural shortcuts that bypass the chain:
Regularization that distributes information: