DATASCI 447 Lecture 9: Training Deep Models

Kevin McAlister

February 10, 2026

Administrative Stuff

Where We Left Off

  • Deep networks learn hierarchical representations
  • Depth multiplies expressivity: folds and linear regions grow exponentially
  • We saw that deeper networks achieve better accuracy with fewer total units

But: We hand-waved how training actually works

Today: We open the hood

The Optimization Problem

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)\]

The Optimization Problem

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…

The Optimization Problem

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!

The Optimization Problem

No closed-form solution!

The loss surface is non-convex, high-dimensional, and composed of many nested functions.

We need iterative numerical optimization.

Why Gradient-Based Methods?

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\}\)

  • The loss function w.r.t. the parameters is gnarly

Gradient Descent

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?

Stochastic Gradient Descent (SGD)

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.

Stochastic Gradient Descent (SGD)

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\]

  • Noisy estimate, but unbiased: \(\mathbb{E}[\text{mini-batch gradient}] = \text{full gradient}\)
  • Much cheaper: cost per step scales with \(B\), not \(N\)
  • The noise can actually help — escaping saddle points, implicit regularization

Question: Why are we more worried about saddle points in high dimensional parameters spaces than local minima?

Second-Order Methods: The Ideal We Can’t Afford

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.

Second-Order Methods: The Ideal We Can’t Afford

The Hessian inverse \(\mathbf H^{-1}\) does two things at once:

  1. 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

  2. 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?

Second-Order Methods: The Ideal We Can’t Afford

Why can’t we use this for deep networks?

The Hessian for a network with \(|\theta|\) parameters is a \(|\theta| \times |\theta|\) matrix.

  • Our tiny MNIST network: \(3{,}159 \times 3{,}159 \approx 10\) million entries
  • A modest modern network with 10M parameters: \(10^7 \times 10^7 = 10^{14}\) entries
  • Computing \(\mathbf H\) is \(O(|\theta|^2)\), inverting it is \(O(|\theta|^3)\)

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: A Practical Approximation

Adam (Adaptive Moment Estimation) approximates both things the Hessian does, cheaply:

Momentum (approximates the rotation)

  • Maintains a running average of past gradients
  • Smooths out noise and helps cut through valleys instead of zig-zagging
  • Captures some of the parameter interaction information from \(\mathbf H^{-1}\)

Adaptive learning rates (approximates the rescaling)

  • Tracks the running average of squared gradients per-parameter
  • Parameters with consistently large gradients get smaller steps; parameters with small gradients get larger steps
  • Captures the diagonal scaling from \(\mathbf H^{-1}\) without computing the full matrix

Cost: \(O(|\theta|)\) per step — same as vanilla SGD!

Adam is the default optimizer in most PyTorch code for good reason.

The Engine and the Map

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.

The Credit Assignment Problem

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?

  • Parameters in \(\bbeta\) (the output layer) have a direct effect on \(\hat{y}\)
  • Parameters in \(\mathbf W_D\) have an effect mediated through the output layer
  • Parameters in \(\mathbf W_1\) have an effect mediated through every layer above

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.

The Chain Rule Is the Key

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.

The Network as a Chain of Equations

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:

model = nn.Sequential(
    nn.Linear(P, K1),    # z1 = W1^T x + b1
    nn.ReLU(),           # h1 = ReLU(z1)
    nn.Linear(K1, K2),   # z2 = W2^T h1 + b2
    nn.ReLU(),           # h2 = ReLU(z2)
    nn.Linear(K2, K),    # z_out = beta^T h2 + alpha
)

The Network as a Chain of Equations

Each module knows how to do two things:

  1. Compute its output (forward)
  2. Compute its local gradient (backward)

The chain of equations flowing up is mirrored by a chain of gradients flowing back down.

The Core Principle

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:

  1. The upstream gradient \(\delta\) arriving from the module above
  2. The local gradient — how this module’s output changes w.r.t. its inputs

Multiply them together → pass to the module below.

That’s the entire algorithm.

Gradients vs. Jacobians

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

  • \(\frac{\partial \mathcal{L}}{\partial \mathbf w}\) where \(\mathcal{L} \in \mathbb{R}\) and \(\mathbf w \in \mathbb{R}^P\) → result \(\in \mathbb{R}^P\)
  • “How much does \(\mathcal{L}\) change if I nudge \(w_j\)?”
  • This is what SGD uses to update parameters

Jacobian: Derivative of a vector w.r.t. a vector

  • \(\frac{\partial \mathbf h}{\partial \mathbf z}\) where \(\mathbf h \in \mathbb{R}^M\) and \(\mathbf z \in \mathbb{R}^K\) → result \(\in \mathbb{R}^{M \times K}\)
  • “How much does \(h_j\) change if I nudge \(z_k\)?”
  • Arises at intermediate modules where both input and output are vectors

Gradients vs. Jacobians

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.

Setting Up a 3-Layer Example

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]}\]

Setting Up a 3-Layer Example

\[\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]}\]

Setting Up a 3-Layer Example

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):

  • Output layer: \(\underset{(K \times M_3)}{\bbeta}\), \(\underset{(K \times 1)}{\boldsymbol \alpha}\)
  • Layer 3: \(\underset{(M_3 \times M_2)}{\mathbf W_3}\), \(\underset{(M_3 \times 1)}{\mathbf b_3}\)
  • Layer 2: \(\underset{(M_2 \times M_1)}{\mathbf W_2}\), \(\underset{(M_2 \times 1)}{\mathbf b_2}\)
  • Layer 1: \(\underset{(M_1 \times P)}{\mathbf W_1}\), \(\underset{(M_1 \times 1)}{\mathbf b_1}\)

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.

Setting Up a 3-Layer Example

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}\]

Setting Up a 3-Layer Example

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}}\]

  • Downstream Gradient: The gradient flowing from the loss back through the network.
  • Local Gradient: The gradient of the current module with respect to its inputs.
  • Upstream Gradient: The gradient flowing into the current module from the previous module.

The Structure of the Loss

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 Loss Module

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.

MSE Loss (Regression)

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.

Binary Cross-Entropy Loss (Binary Classification)

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.

Categorical Cross-Entropy Loss (Multi-Class)

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}\]

Categorical Cross-Entropy Loss (Multi-Class)

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.

Categorical Cross-Entropy Loss (Multi-Class)

\[\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.

The Unifying Pattern

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.

A Computational Note

Each of these loss gradients is trivially cheap to compute:

  • A subtraction of two vectors (or scalars)
  • No matrix operations, no inversions, no loops

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.

The Output Linear Module

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:

  1. Gradient for \(\boldsymbol \alpha\) (terminal — used by SGD)
  2. Gradient for \(\bbeta\) (terminal — used by SGD)
  3. Pass-through gradient for \(\mathbf h_{i3}\) (sent to the module below)

A Note on Notation

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.

Bias Gradient

\[\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}\)

Pass-Through Gradient

\[\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}}\]

Weight Gradient — Why It’s Tricky

\[\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\).

  • How much does \(\theta_{ik}\) change when we perturb \(\beta_{mk}\)?

We don’t want to compute or store this!

But we can avoid it entirely.

Weight Gradient

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.

Weight Gradient

\[\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.

Weight Gradient

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.

Weight Gradient

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?”

Weight Gradient — The Outer Product

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\)

The Linear Module Rules

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

A Computational Note

What operations did we just use?

  • Bias gradient: copy the upstream \(\delta\) (free)
  • Weight gradient: outer product (\(\times\)) of two vectors (\(M \times K\) multiplications)
  • Pass-through: matrix-vector multiply (\(\times\)) (\(M \times K\) multiplications)

No inversions. No sorting. No conditional logic. Just multiplies and adds.

These are the operations that GPUs were built for.

The Activation Module

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.

ReLU: The Most Common Activation

\[\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\).

ReLU Backward

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:

  • Active (\(z_{i3,m} > 0\)): \(\delta_{1,m} \odot 1 = \delta_{1,m}\) — gradient passes through unchanged
  • Dead (\(z_{i3,m} \leq 0\)): \(\delta_{1,m} \odot 0 = 0\) — gradient is zeroed out

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 Is a Gate

ReLU’s backward pass is a gate on the gradient flow:

  • Active units: the gate is open — gradient flows through
  • Dead units: the gate is closed — gradient is blocked

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.

  • This will be a problem that we’ll kinda address in the next class.

Other Elementwise Activations

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)\]

Other Elementwise Activations

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)\)

Other Elementwise Activations

Other Elementwise Activations

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\)

  • Use when you suspect dying ReLU is a problem (many units stuck at 0)
  • Common in GANs and very deep networks

Swish/SiLU and GELU: Smooth approximations that never fully kill gradients

  • GELU is the standard in Transformers (BERT, GPT, etc.)
  • Swish used in EfficientNet and mobile architectures
  • Both help with vanishing gradients (more next lecture)

Key advantage: All allow unbounded positive activations — the network can represent arbitrarily large values when needed.

The Universal Rule for Activation Gradients

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).

Cheap Gradients!

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.

Where We’re At

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

Where We Are in the Chain

We’ve derived backward rules for two module types:

  • Linear: bias (\(= \delta\)), weights (\(= \mathbf h \times \delta^T\)), pass-through (\(= \mathbf W \times \delta\))
  • Activation: pass-through (\(= \delta \odot \varphi'(\mathbf z)\))

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?

The Upstream Gradient Arriving at Layer 3

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)\]

The Upstream Gradient Arriving at Layer 3

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.

Gradients for the Hidden Layer

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…

But We Already Know Every Piece!

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.

Walking Down the Chain

Starting from \(\delta_0 = \hat{y}_i - y_i^* \quad (K \times 1)\):

Output linear module (\(\bbeta, \boldsymbol \alpha\)):

  • \(\frac{\partial \mathcal{L}_i}{\partial \boldsymbol \alpha} = \delta_0 \quad (K \times 1)\)
  • \(\frac{\partial \mathcal{L}_i}{\partial \bbeta} = \underset{(M_3 \times 1)}{\mathbf h_{i3}} \times \underset{(1 \times K)}{\delta_0^T} \quad (M_3 \times K)\)
  • \(\delta_1 = \underset{(M_3 \times K)}{\bbeta} \times \underset{(K \times 1)}{\delta_0} \quad (M_3 \times 1)\)

ReLU 3:

  • \(\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)\)

Walking Down the Chain

Linear 3 (\(\mathbf W_3, \mathbf b_3\)):

  • \(\frac{\partial \mathcal{L}_i}{\partial \mathbf b_3} = \delta_2 \quad (M_3 \times 1)\)
  • \(\frac{\partial \mathcal{L}_i}{\partial \mathbf W_3} = \underset{(M_2 \times 1)}{\mathbf h_{i2}} \times \underset{(1 \times M_3)}{\delta_2^T} \quad (M_2 \times M_3)\)
  • \(\delta_3 = \underset{(M_2 \times M_3)}{\mathbf W_3} \times \underset{(M_3 \times 1)}{\delta_2} \quad (M_2 \times 1)\)

ReLU 2:

  • \(\delta_4 = \underset{(M_2 \times 1)}{\delta_3} \odot \underset{(M_2 \times 1)}{\mathbb{I}(\mathbf z_{i2} > 0)} \quad (M_2 \times 1)\)

Walking Down the Chain

Linear 2 (\(\mathbf W_2, \mathbf b_2\)):

  • \(\frac{\partial \mathcal{L}_i}{\partial \mathbf b_2} = \delta_4 \quad (M_2 \times 1)\)
  • \(\frac{\partial \mathcal{L}_i}{\partial \mathbf W_2} = \underset{(M_1 \times 1)}{\mathbf h_{i1}} \times \underset{(1 \times M_2)}{\delta_4^T} \quad (M_1 \times M_2)\)
  • \(\delta_5 = \underset{(M_1 \times M_2)}{\mathbf W_2} \times \underset{(M_2 \times 1)}{\delta_4} \quad (M_1 \times 1)\)

ReLU 1:

  • \(\delta_6 = \underset{(M_1 \times 1)}{\delta_5} \odot \underset{(M_1 \times 1)}{\mathbb{I}(\mathbf z_{i1} > 0)} \quad (M_1 \times 1)\)

Walking Down the Chain

Linear 1 (\(\mathbf W_1, \mathbf b_1\)):

  • \(\frac{\partial \mathcal{L}_i}{\partial \mathbf b_1} = \delta_6 \quad (M_1 \times 1)\)
  • \(\frac{\partial \mathcal{L}_i}{\partial \mathbf W_1} = \underset{(P \times 1)}{\mathbf x_i} \times \underset{(1 \times M_1)}{\delta_6^T} \quad (P \times M_1)\)

Done. All 8 gradients computed.

No pass-through needed — \(\mathbf x_i\) is the input, not a learnable parameter.

The Dimensions Always Work

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.

The Whole Algorithm in One Table

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\)

From Chain Rule to Algorithm

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:

  • \(\frac{\partial \mathcal{L}_i}{\partial \mathbf W_3} = \mathbf h_{i2} \times \delta_2^T\) — needs the cached value \(\mathbf h_{i2}\)
  • \(\frac{\partial \mathcal{L}_i}{\partial \mathbf W_2} = \mathbf h_{i1} \times \delta_4^T\) — needs the cached value \(\mathbf h_{i1}\)
  • \(\delta_2 = \delta_1 \odot \mathbb{I}(\mathbf z_{i3} > 0)\) — needs the cached value \(\mathbf z_{i3}\)

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.

The Forward Pass

Given current parameter values, evaluate the chain from bottom to top:

  1. \(\mathbf z_{i1} = \mathbf W_1^T \times \mathbf x_i + \mathbf b_1\) — cache \(\mathbf z_{i1}\)
  2. \(\mathbf h_{i1} = \varphi(\mathbf z_{i1})\) — cache \(\mathbf h_{i1}\)
  3. \(\mathbf z_{i2} = \mathbf W_2^T \times \mathbf h_{i1} + \mathbf b_2\) — cache \(\mathbf z_{i2}\)
  4. \(\mathbf h_{i2} = \varphi(\mathbf z_{i2})\) — cache \(\mathbf h_{i2}\)
  5. \(\mathbf z_{i3} = \mathbf W_3^T \times \mathbf h_{i2} + \mathbf b_3\) — cache \(\mathbf z_{i3}\)
  6. \(\mathbf h_{i3} = \varphi(\mathbf z_{i3})\) — cache \(\mathbf h_{i3}\)
  7. \(\btheta_i = \bbeta^T \times \mathbf h_{i3} + \boldsymbol \alpha\)
  8. \(\mathcal{L}_i = \text{Loss}(\sigma_{\text{out}}(\btheta_i), y_i)\)

This is just prediction — nothing new. But now we keep all the intermediate values.

The Backward Pass

Starting from the loss, walk backward down the chain:

  1. \(\delta_0 = \hat{y}_i - y_i^*\) — loss module backward
  2. Store \(\frac{\partial \mathcal{L}_i}{\partial \boldsymbol \alpha}\), \(\frac{\partial \mathcal{L}_i}{\partial \bbeta}\); compute \(\delta_1\) — output linear backward
  3. Compute \(\delta_2\) — ReLU 3 backward (uses cached \(\mathbf z_{i3}\))
  4. Store \(\frac{\partial \mathcal{L}_i}{\partial \mathbf b_3}\), \(\frac{\partial \mathcal{L}_i}{\partial \mathbf W_3}\); compute \(\delta_3\) — linear 3 backward (uses cached \(\mathbf h_{i2}\))
  5. … continue to the bottom

At each module:

  • Use the cached forward values and the upstream \(\delta\)
  • Produce terminal gradients (if the module has parameters)
  • Produce the downstream \(\delta\) (for the module below)

Each Module Has a Forward and a Backward

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).

This Is Automatic Differentiation

Backprop is a special case of reverse-mode automatic differentiation.

The recipe:

  1. Define a chain of differentiable modules
  2. Run the chain forward (compute and cache)
  3. Run the chain backward (chain the \(\delta\)’s)

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.

PyTorch’s Design

Each nn.Module (Linear, ReLU, Sigmoid, BatchNorm, etc.) implements:

  • A forward method: computes the output given input, caches intermediates
  • A backward method: takes upstream \(\delta\), produces parameter gradients and downstream \(\delta\)

When you call loss.backward():

  1. PyTorch walks back down the chain of modules
  2. Calls each module’s backward method
  3. Gradients accumulate in .grad attributes of each parameter

This is exactly the chain of \(\delta\)’s we derived — PyTorch just automates it.

PyTorch Sigmoid — The C Code

PyTorch Sigmoid — The C Code

PyTorch Sigmoid — The C Code

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\).

The PyTorch Repository

The PyTorch Repository

The PyTorch Repository

Each one implements the same pattern:

  1. Forward: compute output, cache what’s needed
  2. Backward: local gradient combined with upstream \(\delta\)

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.

C-Optimized and GPU-Ready

Look at the operations each module performs:

  • Linear backward: matrix-vector multiply (\(\times\)), outer product (\(\times\))
  • ReLU backward: elementwise multiply (\(\odot\))
  • Loss backward: vector subtraction

These are simple, uniform, massively parallel operations.

  • Written in C and optimized for CUDA
  • Run on thousands of GPU cores simultaneously
  • The entire mini-batch can be processed in parallel

This is why PyTorch is fast despite networks having millions of parameters.

Why SGD + Backprop Scales

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.

How Does XGBoost Optimize?

Greedy, sequential tree building

  • Each tree is added one at a time — cannot parallelize across trees
  • Each tree must be fully built before the next one starts

Split search at every node

  • Must evaluate candidate splits across all \(P\) features
  • Must examine all \(N\) observations to find the best partition
  • Finding the best split requires sorting each feature: \(O(N \log N)\)

Complex branching logic

  • “If \(x_j < t\), go left; else go right” — inherently sequential, hard to vectorize
  • Every split is a different conditional operation

The GPU Analogy

XGBoost operations:

“Read this feature, sort it, evaluate every possible split, pick the best one, branch left or right”

  • Complex, conditional, sequential
  • Like giving a college student a word problem
  • CPUs are good at this: few cores, each very capable

Backprop operations:

“Multiply these two matrices (\(\times\)). Multiply these two vectors elementwise (\(\odot\)).”

  • Simple, uniform, massively parallel
  • Like giving 10,000 kindergartners “multiply these two numbers”
  • GPUs are good at this: thousands of cores, each doing trivial arithmetic simultaneously

The GPU Analogy

Deep learning’s primitive operations (\(\times\) and \(\odot\)) map perfectly onto GPU architecture.

Tree-based methods do not.

Now the Bad News

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…

The Gradient at 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.

The Multiplicative Chain Problem

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.

Two Failure Modes

Exploding gradients: the products of weight matrices are generally \(> 1\)

  • \(\delta\) grows exponentially as it flows down the chain
  • Early layers get enormous gradients

Vanishing gradients: the products are generally \(< 1\)

  • \(\delta\) shrinks exponentially as it flows down the chain
  • Early layers get near-zero gradients

Both prevent early layers from learning effectively.

  • Exploding: parameters take wild, destructive steps
  • Vanishing: parameters barely move at all

Exploding Gradients — What They Look Like

Symptoms:

  • Loss suddenly spikes or goes to NaN
  • Parameter values diverge to \(\pm \infty\)
  • Training was going fine, then suddenly collapses

When it happens:

  • Very deep networks
  • Poor weight initialization
  • Early in training (before parameters settle into reasonable ranges)

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.

Fix 1 — Gradient Clipping

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)\]

  • If \(\|g\| \leq c\): do nothing — the gradient is fine
  • If \(\|g\| > c\): scale it down to have norm \(c\), preserving direction

\(c \approx 1\)\(5\) is typical. Simple, widely used, especially for recurrent models.

This is a one-line fix:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

Fix 2 — Input Normalization

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.)

Fix 3 — The Initialization Problem

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!

Variance Blowup in Both Directions

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.

Glorot (Xavier) Initialization

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)\]

He Initialization (for ReLU)

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.

Explosions Are Fixable

We have three simple tools:

  • Gradient clipping: cap the step size ✓
  • Input normalization: keep initial activations reasonable ✓
  • Smart initialization (Glorot/He): prevent variance blowup ✓

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.

Vanishing Gradients Are Different

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.

Dead ReLUs in the Chain

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.

Why Clipping Can’t Help

Vanishing gradients: The signal is gone.

  • You can’t “clip up” a zero.
  • You can’t amplify a signal that doesn’t exist.

Dead ReLUs can be permanent:

If a unit is dead for every observation in the training set:

  • \(\mathbb{I}(z_m > 0) = 0\) for all \(i\)
  • Gradient is zero for all \(i\)
  • Weight never updates
  • Unit stays dead forever

This requires fundamentally different solutions — not just tuning knobs.

What Would Fix This?

Think about what’s causing the problem:

1. The activation function kills units entirely

  • ReLU outputs exactly 0 for \(z \leq 0\)
  • What if we used an activation that never fully kills the gradient?

2. Activations drift into bad ranges across layers

  • Pre-activations can systematically shift negative, killing more units
  • What if we normalized activations at every layer to keep them centered?

What Would Fix This?

3. The gradient must pass through every single module

  • A chain of \(D\) modules means \(D\) opportunities to shrink \(\delta\)
  • What if we gave the gradient a shortcut to skip modules?

4. The network over-relies on specific units

  • If a few units carry all the information, killing one is catastrophic
  • What if we forced the network to distribute information across units?

Solutions (Scattered In the Next Month When Needed)

Activation functions that never fully kill the gradient:

  • Leaky ReLU: \(\max(\alpha z, z)\) — gradient is \(\alpha\) (small but nonzero) when \(z < 0\)
  • GELU: smooth, always nonzero gradient

Normalization that keeps activations healthy:

  • Batch normalization

Architectural shortcuts that bypass the chain:

  • Skip connections and residual networks: \(\mathbf h_l = \varphi(\mathbf W_l^T \times \mathbf h_{l-1} + \mathbf b_l) + \mathbf h_{l-1}\)

Regularization that distributes information:

  • Dropout: randomly zero out units during training, forcing redundancy