February 12, 2026
\[ \newcommand\hbb{{\hat{\boldsymbol \beta}}} \newcommand\bb{{\boldsymbol \beta}} \newcommand\expn{{\frac{1}{N} \sum \limits_{i = 1}^N}} \newcommand\sumk{\sum \limits_{k = 1}^K} \newcommand\argminb{\underset{\bb}{\text{argmin }}} \newcommand\argmaxb{\underset{\bb}{\text{argmax }}} \newcommand\gtheta{\mathbf g(\boldsymbol \theta)} \newcommand\htheta{\mathbf H(\boldsymbol \theta)} \]
Last time: we derived how deep networks learn.
Today: why training deep networks is hard — and why they generalize despite being massively overparameterized.
Last lecture we derived backpropagation — the algorithm for computing gradients in deep networks.
The key insight: a deep network is a chain of modules, and each module has:
The chain of equations flowing up (forward) is mirrored by a chain of \(\delta\)’s flowing back down (backward).
This algorithm has three properties that make deep learning possible:
1. Computational efficiency
One forward pass + one backward pass — regardless of how many parameters.
Cost is \(\approx 2\times\) a single forward pass, linear in depth. Compare to finite differences: one forward pass per parameter.
2. Modularity
Each module is self-contained. It only needs the upstream \(\delta\) and its own cached values. You can add new module types without changing the optimization algorithm — just write a forward/backward pair.
3. Full differentiability
As long as every module in the chain is differentiable, SGD can optimize the whole thing end-to-end. This is what makes representation learning possible: the features themselves are learned via the same gradient signal as the final predictions.
These three properties are exactly what PyTorch implements:
model = nn.Sequential(
nn.Linear(P, M1), # forward: z = W^T x + b
nn.ReLU(), # forward: h = max(0, z)
nn.Linear(M1, M2), # forward: z = W^T h + b
nn.ReLU(), # ...
nn.Linear(M2, K),
)Each nn.Module has a forward/backward pair written in optimized C/CUDA.
When you call loss.backward():
.gradPyTorch tracks the passthrough \(\delta\) for you and applies the update rules embedded in each module. You define the architecture; it handles the calculus.
Every backward step in a fully-connected network uses one of two operations:
| Module | Backward operation | Symbol |
|---|---|---|
| Linear | Matrix multiply, outer product | \(\times\) |
| Activation | Elementwise multiply | \(\odot\) |
These are simple, uniform, massively parallel — perfect for GPUs.
This is why deep networks scale to billions of parameters while tree-based methods hit walls with high-dimensional data.
The \(\delta\) arriving at the bottom of a \(D\)-layer chain passes through every module:
\[\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:
\[\delta_{2D} \propto \underset{\text{loss}}{\delta_0} \underset{\times}{\bbeta} \underset{\odot}{\mathbb{I}(\mathbf z_{D} > 0)} \underset{\times}{\mathbf W_D} \underset{\odot}{\mathbb{I}(\mathbf z_{D-1} > 0)} \underset{\times}{\mathbf W_{D-1}} \cdots\]
A chain of weight matrices (\(\times\)) and ReLU masks (\(\odot\)), one pair per layer.
When you multiply many terms together:
If the terms are on average slightly greater than 1:
\[(1 + \epsilon)^D \to \infty \quad \text{as } D \text{ grows}\]
If the terms are on average slightly less than 1:
\[(1 - \epsilon)^D \to 0 \quad \text{as } D \text{ grows}\]
The deeper the network, the smaller \(\epsilon\) needs to be to cause trouble.
This isn’t a bug in backprop — it’s inherent to chaining many differentiable modules.
Exploding gradients: the products of weight matrices are generally \(> 1\)
Vanishing gradients: the products are generally \(< 1\)
We’ll handle explosions first — the fixes are simpler and mostly about scale.
Then we’ll spend the bulk of the lecture on vanishing gradients and generalization, which turn out to be deeply connected.
Symptoms:
NaNWhen it happens:
The geometry: the loss surface has steep cliffs. One SGD step lands on a cliff, the gradient is enormous, and the update shoots parameters into an entirely different region.
The gradient tells us two things: direction and magnitude.
Key insight: as long as the direction is right, we can cap the magnitude.
\[g'(\theta) = \min\left(1, \frac{c}{\|g(\theta)\|}\right) \cdot g(\theta)\]
\(c \approx 1\)–\(5\) is typical. Simple, widely used, especially for recurrent models.
One line of PyTorch:
Clipping prevents catastrophic single steps, but it doesn’t address the root cause.
If you find yourself relying on clipping to keep training stable, something deeper is wrong:
Think of clipping as a safety net — you want it there, but you shouldn’t be hitting it often.
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 up 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.
Simple, but only addresses the input to the chain. What about the intermediate layers?
Gradient descent requires starting values for all parameters.
Naive approach: \(W_{jk} \sim N(0, 1)\)
What happens in the forward pass?
If \(\mathbf x \sim N(0, 1)\) and \(\mathbf W_1\) entries \(\sim N(0, 1)\):
\[z_{i1,m} = \sum_{j=1}^P W_{1,jm} \, x_{ij}\]
\[\text{Var}(z_{i1,m}) = P \cdot \text{Var}(W) \cdot \text{Var}(x) = P\]
The variance of the pre-activations scales with the fan-in \(P\).
For MNIST with \(P = 784\): the pre-activations are \(\sim N(0, 784)\) — wildly large!
Forward pass: activation variance scales with fan-in
\[\text{Var}(\mathbf z_l) \propto M_{l-1} \cdot \text{Var}(\mathbf z_{l-1})\]
Each layer amplifies the variance by a factor of \(M_{l-1}\).
Backward pass: gradient variance scales with fan-out
\[\text{Var}(\delta_l) \propto M_{l+1} \cdot \text{Var}(\delta_{l+1})\]
Each step down the chain amplifies the \(\delta\) variance by a factor of \(M_{l+1}\).
With \(N(0,1)\) initialization, both the activations and the gradients blow up exponentially with depth.
Goal: Choose the variance of \(\mathbf W\) so that both forward and backward variance stay at \(\approx 1\).
Forward constraint: \(\text{Var}(W) \cdot M_{\text{in}} = 1 \implies \text{Var}(W) = \frac{1}{M_{\text{in}}}\)
Backward constraint: \(\text{Var}(W) \cdot M_{\text{out}} = 1 \implies \text{Var}(W) = \frac{1}{M_{\text{out}}}\)
Glorot compromise: split the difference:
\[W_{jk} \sim N\left(0, \frac{2}{M_{\text{in}} + M_{\text{out}}}\right)\]
ReLU kills roughly half the units (\(z \leq 0\)), cutting the effective 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.
Adam’s adaptive per-parameter learning rates provide additional protection:
\[\theta_t = \theta_{t-1} - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon}\]
The denominator \(\sqrt{\hat{v}_t}\) tracks the running variance of each parameter’s gradient.
If a parameter’s gradient is consistently large (explosive), \(\hat{v}_t\) is large, and the effective step size shrinks.
If a parameter’s gradient is consistently small, \(\hat{v}_t\) is small, and the effective step size grows.
Adam automatically rescales each parameter’s update — a form of per-parameter gradient clipping built into the optimizer.
This is one reason Adam is the default in most deep learning code.
These all address the same underlying issue: scale.
The \(\delta\)’s are too big? Make them smaller. The activations are too big? Normalize them. The initial weights amplify too much? Shrink them.
Scale is easy to control.
Vanishing gradients are a different beast entirely.
Look back at the ReLU backward rule:
\[\delta_{\text{below}} = \delta_{\text{above}} \odot \underset{(M \times 1)}{\mathbb{I}(\mathbf z > 0)}\]
Every time a unit is dead (\(z_m \leq 0\)), its gradient is not small — it’s exactly zero.
\[\delta_{\text{above},m} \odot 0 = 0\]
This isn’t a scale problem. The information is genuinely destroyed.
No amount of rescaling can recover a signal that has been multiplied by zero.
Active units: gate is open — gradient passes through. Dead units: gate is shut — gradient is zeroed.
Each ReLU \(\odot\) zeros out some dimensions of \(\delta\). Once a dimension is zeroed, it stays zero through all subsequent layers. After enough layers, most of the gradient signal is gone.
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.
The network “forgets” about its early layers.
If a unit is dead for every observation in the training set:
You can’t “clip up” a vanishing gradient — there’s no signal to amplify.
Turning down volume is easy. Amplifying silence is impossible.
This requires fundamentally different solutions.
Leaky ReLU: \(\varphi(z) = \max(\alpha z, z)\) where \(\alpha \approx 0.01\)–\(0.1\)
ELU: \(\varphi(z) = z\) if \(z > 0\), \(\alpha(e^z - 1)\) if \(z \leq 0\)
GELU: \(\varphi(z) = z \cdot \Phi(z)\) where \(\Phi\) is the standard normal CDF
ReLU’s zeros are a feature, not just a bug.
Each observation activates a sparse subset of hidden units. The zeros mean that:
Leaky ReLU and GELU sacrifice this sparsity for gradient flow.
Everything is a little bit active for every input — a muddier representation.
In practice: ReLU + other fixes (BN, architecture) is the dominant recipe.
We keep ReLU’s sparsity and fix the vanishing problem at other points in the chain.
| Source | Problem | Fix |
|---|---|---|
| Activation kills units | \(\odot 0 =\) dead forever | Leaky ReLU, GELU |
| Activations drift during training | Pre-activations shift negative | Batch normalization |
| Multiplicative chain itself | \(D\) weight matrices in a row | Skip connections (next week) |
Changing the activation helps individual units. But what about the fact that activations drift during training?
He initialization stabilizes variance at step 0. But as weights update, pre-activations can systematically shift — killing units that were initially healthy.
We need something that keeps activations healthy throughout training, not just at initialization.
He initialization puts pre-activations near zero at step 0. But as weights update, pre-activations drift — systematically shifting negative, killing more and more units.
Changing variance is also an issue since this causes the gradients for individual observations to become bigger or smaller (most of the time the smaller ones are the biggest problem since the gradient gets shoved to zero!)
The idea: If the problem is that pre-activations drift away from zero during training, force them back at every step.
Standardize the pre-activations across the current mini-batch — just like standardizing features in linear regression, but at every layer, at every training step.
Given a mini-batch of \(B\) observations, the pre-activations at layer \(d\) are a \(B \times M_d\) matrix.
For each hidden unit \(m\) (each column):
\[\mu_m = \frac{1}{B} \sum_{i \in \text{batch}} z_{i,m} \qquad \sigma^2_m = \frac{1}{B} \sum_{i \in \text{batch}} (z_{i,m} - \mu_m)^2\]
\[\hat{z}_{i,m} = \frac{z_{i,m} - \mu_m}{\sqrt{\sigma^2_m + \epsilon}}\]
Now \(\hat{z}_{i,m}\) has mean 0 and variance 1 across the batch.
BN centers the pre-activations at zero. With a symmetric distribution around zero, roughly half the units are active — plenty of gradient signal flows through.
Forcing all pre-activations to mean 0 and variance 1 might be too restrictive.
Maybe some units should have a different mean or spread!
BN adds two learnable parameters per hidden unit:
\[\tilde{z}_{i,m} = \gamma_m \cdot \hat{z}_{i,m} + \beta_{\text{bn},m}\]
If the optimal pre-activation distribution is \(N(0, 1)\), the network can keep it there.
If the optimal distribution is \(N(2, 0.5)\), the network can learn \(\gamma_m = 0.5\), \(\beta_{\text{bn},m} = 2\).
Key insight: BN starts by centering everything, then lets the network learn where each unit’s distribution should be — but it has to do so explicitly, preventing accidental drift.
Any intuition as to why this is okay to do in the hidden layers?
The hidden layers are somewhat arbitrary projections into a different dimensional space - stretching or shifting the distribution in that hidden layer doesn’t really change the representation that’s being made in that layer!
Without BN: dead units pile up in early layers, gradient norms decay exponentially.
With BN: dead unit fraction is stable (~50%) at every layer, gradients maintain consistent magnitude.
The modern default ordering:
\[\mathbf x \xrightarrow{\text{Linear}} \mathbf z \xrightarrow{\text{BN}} \tilde{\mathbf z} \xrightarrow{\text{ReLU}} \mathbf h\]
Practical detail: The bias \(\mathbf b\) in the linear module is redundant with \(\beta_{\text{bn}}\).
\[\mathbf z = \mathbf W^T \times \mathbf h_{\text{prev}} + \mathbf b\]
BN subtracts the mean — any constant bias is removed. Then \(\beta_{\text{bn}}\) adds a learnable shift.
During training: \(\mu_B\) and \(\sigma^2_B\) are computed from the current mini-batch.
During inference: We don’t have a mini-batch — we might predict on a single observation.
model.train() # BN uses batch statistics (training)
model.eval() # BN uses running averages (inference)This is one reason model.train() and model.eval() matter in PyTorch — they change how BN behaves.
BN fixes gradient flow — the network can train. But training loss → 0 while test loss rises. The network memorizes the training data.
BN + He initialization = we can train deep networks.
But training is only half the problem.
A 20-layer MLP on CIFAR-10 has hundreds of thousands of parameters and only 50,000 training observations. With enough capacity, it will drive training loss to zero — but that doesn’t mean the learned function generalizes.
BN turns “can’t train” into “can train but overfits.”
The remaining question — and it turns out to be the deeper question — is:
How do we get deep networks to generalize?
A 20-layer, 256-wide network on CIFAR-10 has ~600,000 parameters and only 50,000 training observations.
Classical statistics says: catastrophic overfitting. More parameters than observations means a system with infinitely many solutions — and no reason to expect that any of them generalizes.
The empirical reality is shocking: larger networks often generalize better, even when they perfectly memorize the training set.
How?
Think back to linear regression.
If we fit a degree-1 polynomial to nonlinear data: high bias, low variance. Underfitting.
If we fit a degree-\(N\) polynomial to \(N\) points: zero bias, high variance. Overfitting — the curve passes through every point but oscillates wildly.
This predicts a U-shaped test error curve: too simple → bad, just right → good, too complex → bad again.
For decades, this was the guiding principle of model selection.
Neural networks violate this story completely.
A 20-layer MLP with 256 units per layer has ~600K parameters for 50K training observations. That’s a 12:1 parameter-to-observation ratio.
In the linear regression analogy, this is like fitting a degree-600,000 polynomial to 50,000 points.
There are infinitely many weight configurations that achieve zero training loss. The system is massively underdetermined.
Classical statistics says: pick any interpolating solution and it will be garbage on test data.
But deep networks do something different — something about the training procedure selects good solutions from the infinite set of interpolating ones.
A large part of the generalization story comes from SGD itself — completely separate from the neural network architecture.
SGD and Implicit Regularization →
The key takeaway: SGD preferentially finds minimum-norm interpolating solutions — the simplest function (in a specific sense) that fits the training data perfectly.
Left: Too few parameters — can’t capture the pattern. Middle: Exactly \(N\) parameters — one solution, and it’s wiggly. Right: Many more parameters than needed — infinitely many interpolating solutions, and SGD finds a smooth one.
This explains the surprising test error curve:
More parameters = more ways to interpolate = more chances that one of those ways is simple.
Overparameterization provides searchability, not just capacity.
This leads to a striking conjecture:
Frankle & Carlin (2019): A large, randomly-initialized network contains small sub-networks that — if trained in isolation from the same initialization — could reach the same test accuracy as the full network.
These sub-networks are called “winning tickets.”
The large network doesn’t need all its parameters for capacity. It needs them to provide enough random starting configurations that SGD can find a good one.