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