1 Why initialization matters
Imagine a class of kids who all have to guess a number, then the teacher tells them if they're too high or too low so they can fix their guess. If every single kid starts with the exact same guess, they all get the same feedback and all change in the same way β forever. They're really just one kid in disguise! And if a kid starts with a ridiculous guess like a billion, the teacher's gentle "a little lower" never catches up. Where you start matters a lot.
When you create a neural network, every connection has a weight β a number the network will adjust during training. Before training begins you must give those weights some starting values. That choice is called weight initialization, and beginners usually assume it's harmless. It isn't. A bad init can make a perfectly good architecture fail to learn at all.
There are two classic ways init goes wrong. Let's name them.
Problem A β the symmetry problem (why not all zeros?)
The tempting idea is "just set every weight to 0, that's neutral." It's a disaster. If every weight in a layer is identical, then every neuron in that layer computes the same output, receives the same gradient during backpropagation (the algorithm from Session 3 that pushes error backwards to update weights), and therefore gets updated identically. They stay clones of each other forever.
Neurons only become useful if they can specialize β one detects edges, another detects curves, and so on. For that, they must start different from each other. We break symmetry by initializing weights to small random numbers. Randomness here isn't sloppiness β it's the whole point.
Note: biases can safely start at 0, because each neuron's randomly-different weights already break the symmetry. It's the weights that must be random.
Problem B β the scale problem (how big should the random numbers be?)
Okay, random β but random how big? This is the subtle part. Each layer multiplies its input by its weights and sums them up. If the weights are too large, those sums grow bigger and bigger as the signal passes through layer after layer (the values explode). If the weights are too small, the sums shrink toward zero layer after layer (the signal vanishes). Either way, by the time the signal reaches the end β or the gradient travels back to the start β it's useless.
Let's pass a random input through 50 layers and just multiply by weights (no activation yet), using two different weight scales. This is the experiment that makes the problem obvious:
import numpy as np x = np.random.randn(512) # input vector, ~unit variance # Case 1: weights too big (std = 0.1, but 512 of them add up) a = x.copy() for i in range(50): W = np.random.randn(512, 512) * 0.1 a = W @ a print("big :", a.std()) # -> astronomically large (explodes) # Case 2: weights too small (std = 0.001) b = x.copy() for i in range(50): W = np.random.randn(512, 512) * 0.001 b = W @ b print("small :", b.std()) # -> basically 0 (vanishes)
The "big" case prints something enormous (or inf); the "small" case
prints essentially 0. Notice neither weight value looks crazy on its
own β 0.1 seems tiny! The damage comes from repeating the
multiplication 50 times. That repetition is the heart of the next topic.
Initialization must do two jobs at once: (1) be random so neurons break symmetry and can specialize, and (2) be the right scale so the signal neither explodes nor vanishes as it travels through many layers. Topic 3 gives the exact recipe; first we need to understand why the scale problem is so vicious.
2 Vanishing & exploding gradients
Think of a long line of people whispering a message ear to ear (the game "telephone"). If each person whispers a little quieter than they heard, by the end of a long line the message is silence β nobody can hear it to pass it on. If each person whispers a little louder, by the end everyone's screaming nonsense. Gradients travelling back through a deep network are exactly like that whisper: each layer multiplies them a bit, and over many layers they either fade to nothing or blow up.
Recall from Session 3 that we train with backpropagation: we compute the loss at the output, then use the chain rule to figure out how much each weight contributed to that loss β the gradient. The chain rule works backwards layer by layer, and crucially it multiplies a term for every layer it passes through.
Why repeated multiplication is dangerous
Roughly, the gradient that reaches an early layer is a long product of per-layer factors:
grad_early β fβ Β· fβ Β· fβ Β· β¦ Β· f_L
(one factor f per layer, L layers deep)
Now think about what happens to a product of many numbers:
- If each factor is less than 1 (say 0.8), then 0.850 β 0.00001 β the gradient vanishes. Early layers get almost no update signal, so they barely learn.
- If each factor is greater than 1 (say 1.5), then 1.550 β
640,000,000 β the gradient explodes. Updates become huge, weights
jump wildly, and the loss often turns into
NaN.
Deep networks multiply gradients through every layer. Unless each per-layer factor sits very close to 1, the product runs away to zero (vanishing) or to infinity (exploding). The deeper the network, the more violent the effect. This is the reason deep nets were considered "untrainable" for years.
Which activations make it worse
One of those per-layer factors is the derivative of the activation function. So your choice of activation (Session 2) directly controls whether gradients survive.
| Activation | Derivative range | Effect on gradients |
|---|---|---|
| Sigmoid | 0 to 0.25 (max is just 0.25!) | Multiplying by β€0.25 every layer crushes gradients fast β strong vanishing. Worst offender. |
| Tanh | 0 to 1 (max 1 at the center) | Better than sigmoid, but still saturates (flattens) at the edges β derivative β 0 there β vanishing. |
| ReLU | exactly 0 or 1 | Where active, the factor is 1 β gradients pass undamaged. The main reason ReLU made deep nets practical. |
The problem with sigmoid and tanh is saturation: when their input is
large (positive or negative), the curve flattens, so their slope (derivative) is nearly 0. A
nearly-0 factor, multiplied through many layers, guarantees vanishing gradients. ReLU avoids this
on its positive side β its slope is a clean 1 β which is exactly why
modern deep nets lean on ReLU and its cousins (recall Session 2's discussion of activations).
import numpy as np def sigmoid(z): return 1 / (1 + np.exp(-z)) def sig_deriv(z): s = sigmoid(z); return s * (1 - s) # Suppose each layer's activation derivative averages ~0.2 factor = 0.2 for depth in [5, 10, 30]: print(depth, "layers -> gradient scaled by", factor ** depth) # 5 layers -> 0.00032 # 10 layers -> 1.0e-07 # 30 layers -> 1.07e-21 (effectively zero)
By 30 layers the gradient reaching the first layer is one billionth of one billionth of its original size. That first layer is, for all practical purposes, frozen.
Symptoms β how to recognize it in the wild
| Symptom | Likely cause |
|---|---|
| Loss barely moves; early layers' weights hardly change | Vanishing gradients |
| Later layers learn but earlier ones stay near their init values | Vanishing gradients |
Loss suddenly spikes or becomes NaN/inf | Exploding gradients |
| Weights or gradient norms grow huge over a few steps | Exploding gradients |
For exploding gradients there's a simple seatbelt called gradient clipping: if the gradient's overall size exceeds a threshold, scale it back down before the weight update. It caps the damage from any single huge step. (Vanishing has no such cheap patch β that's why we need better init and normalization, our next two topics.)
NaN loss
(exploding). Clipping tames explosions; the rest of this session tames vanishing.
3 Smart initialization: Xavier & He
Imagine pouring water from cup to cup down a long staircase of cups. You want the same amount of water in each cup at every step β not overflowing, not drying up. The trick is to size each cup's spout based on how many cups feed into it. Smart initialization is just that: it sizes the random starting weights so the "amount of signal" stays steady from layer to layer, no matter how deep the staircase.
Topic 1 told us weights must be random and the right scale. Topic 2 told us "right scale" means keeping per-layer factors near 1. Smart initialization turns that into an exact formula. The guiding principle has a name:
Pick the weight scale so the variance (the statistical "spread") of a layer's outputs equals the variance of its inputs. If every layer preserves variance, the signal neither grows nor shrinks as it flows forward β and the same balance keeps gradients healthy as they flow backward. We just need each layer's weights to be neither too loud nor too quiet.
Here's the intuition for the math. A neuron computes a weighted sum of fan-in
inputs (the number of inputs feeding into it, written n_in). When you add
up n_in independent random products, the variance of the sum grows
roughly proportional to n_in. To cancel that growth, we shrink each
weight's variance by a factor of 1 / n_in. That single observation gives
us both famous initialization schemes.
Xavier / Glorot initialization (for sigmoid & tanh)
Xavier initialization (also called Glorot initialization, from Glorot & Bengio, 2010) balances both the forward signal and the backward gradient, so it uses both fan-in and fan-out (number of outputs). Draw weights with variance:
Var(W) = 2 / (n_in + n_out)
Normal form : W ~ Normal(0, sqrt(2 / (n_in + n_out)))
Uniform form: W ~ Uniform(-limit, +limit),
limit = sqrt(6 / (n_in + n_out))
Xavier assumes the activation is roughly linear around 0 and symmetric β which is true for tanh and sigmoid. Use Xavier with those.
He initialization (for ReLU & friends)
He initialization (from He et al., 2015 β the same group behind ResNet) fixes Xavier for ReLU. The catch: ReLU zeroes out all negative inputs, so on average it throws away half the signal's variance. He compensates by doubling the scale:
Var(W) = 2 / n_in
Normal form : W ~ Normal(0, sqrt(2 / n_in))
Uniform form: W ~ Uniform(-limit, +limit),
limit = sqrt(6 / n_in)
That extra factor of 2 exactly undoes ReLU's halving. Use He with ReLU, Leaky ReLU, and similar.
Which to use with which activation
| Activation | Use this init | Why |
|---|---|---|
| Sigmoid, Tanh | Xavier / Glorot | Symmetric, near-linear at 0 β balance fan-in & fan-out. |
| ReLU, Leaky ReLU, ELU | He | ReLU discards half the variance β need the Γ2 boost. |
| (Output layer, linear) | Xavier is a fine default | No squashing nonlinearity to compensate for. |
Re-run the 50-layer experiment from Topic 1, but this time with He init and a ReLU after each layer. Watch the standard deviation stay sane instead of blowing up or dying:
import numpy as np def relu(z): return np.maximum(0, z) x = np.random.randn(512) a = x.copy() for i in range(50): n_in = 512 W = np.random.randn(512, 512) * np.sqrt(2.0 / n_in) # He init a = relu(W @ a) print("He init, 50 ReLU layers -> std =", a.std()) # prints a healthy value of roughly the same order as the input
And the equivalent in PyTorch, where these are one-liners:
import torch.nn as nn layer = nn.Linear(512, 512) nn.init.kaiming_normal_(layer.weight, nonlinearity="relu") # He nn.init.zeros_(layer.bias) # For tanh/sigmoid layers instead: nn.init.xavier_uniform_(layer.weight) # Xavier
(PyTorch calls He init kaiming_ after Kaiming He, the first
author.)
Smart init solves the problem at the start of training. But as weights drift during training, the nice variance balance can erode. That's why we also use normalization layers (next topic) to keep the signal well-behaved throughout β init and normalization are partners, not rivals.
Var = 2/(n_in+n_out)) suits
symmetric activations like tanh/sigmoid. He (Var = 2/n_in)
adds a Γ2 to compensate for ReLU discarding half the variance β use it with ReLU. Right init
keeps gradients near factor-1 and lets deep nets train from step one.
4 Normalization techniques
Imagine a relay race where each runner hands off a baton, but some runners sprint and some crawl, so the next runner never knows how fast the baton will arrive. Chaos. Now imagine a coach who, between every handoff, resets the baton to a steady, predictable speed. Each runner can relax and just do their job well. Normalization is that coach: between layers, it resets the numbers to a steady, predictable range so the next layer always gets a sensible input.
Good initialization keeps the signal balanced at the start. Normalization
keeps it balanced throughout training by actively re-centering and re-scaling the values
flowing between layers. The core operation is the same for every variant: take a set of numbers,
subtract their mean, divide by their standard deviation (so they have mean 0, variance 1), then
let the network re-scale and re-shift them with two learnable parameters,
Ξ³ (gamma, scale) and Ξ² (beta, shift):
normalized = (x - mean) / sqrt(variance + Ξ΅)
output = Ξ³ Β· normalized + Ξ² # Ξ³, Ξ² are learned
The tiny Ξ΅ (epsilon, e.g. 1e-5) just avoids dividing by zero. The only
question that separates the variants is: which numbers do we compute the mean and variance
over?
Recap: Batch Normalization (from a previous session)
Batch Normalization (BatchNorm), which you met earlier in the course, normalizes across the batch: for each feature, it computes the mean and variance over all the examples in the current mini-batch. So if your batch has 32 images, BatchNorm computes statistics for feature #1 across all 32 images, feature #2 across all 32, and so on.
- It stabilizes training, often lets you use a higher learning rate, and adds a mild regularizing effect (recall regularization from Session 6).
- Catch: it depends on the batch. With tiny batches the statistics are noisy, and at inference time (one example at a time) there's no batch β so BatchNorm keeps a running average of training statistics to use instead. This train/inference difference is a common source of bugs (see Topic 5).
New: Layer Normalization
Layer Normalization (LayerNorm) flips the axis: instead of normalizing each feature across the batch, it normalizes each example across its own features. For a single example with 512 features, LayerNorm computes one mean and one variance over those 512 numbers and normalizes them.
BatchNorm normalizes a feature across examples (needs a batch). LayerNorm normalizes an example across its features (works on a single example, batch size 1, and is identical at train and inference time).
That independence from batch size is exactly why LayerNorm is the standard in Transformers (the architecture behind modern language models). Transformers process variable-length sequences where batch statistics are awkward and inference is often one sequence at a time β LayerNorm sidesteps all of that. As a rule of thumb: BatchNorm dominates in convolutional vision networks (CNNs); LayerNorm dominates in Transformers and other sequence/NLP models.
| Batch Normalization | Layer Normalization | |
|---|---|---|
| Normalizes over | The batch (per feature, across examples) | The features (per example, across features) |
| Depends on batch size? | Yes β bad with tiny batches | No β works at batch size 1 |
| Train vs inference | Different (uses running stats at inference) | Identical |
| Typical home | CNNs / vision | Transformers / NLP / RNNs |
Why normalization helps gradient flow
Recall Topic 2: gradients explode or vanish because per-layer factors drift away from 1, and activations saturate when their inputs wander into the flat tails. By forcing each layer's inputs back to a tidy mean-0, variance-1 distribution, normalization keeps activations in their responsive, non-saturated region β where derivatives are healthy and gradients pass through cleanly. It smooths the loss landscape so gradient descent can take bigger, more confident steps. It is, in effect, a continuous version of what good init does once.
import torch import torch.nn as nn x = torch.randn(8, 512) # batch of 8 examples, 512 features each bn = nn.BatchNorm1d(512) # stats over the 8 examples, per feature ln = nn.LayerNorm(512) # stats over the 512 features, per example print(bn(x).shape, ln(x).shape) # both -> torch.Size([8, 512]) # A Transformer block uses LayerNorm; a CNN block uses BatchNorm: transformer_block = nn.Sequential( nn.Linear(512, 512), nn.LayerNorm(512), # <- the Transformer choice nn.ReLU(), )
You'll also hear of Group Normalization and Instance Normalization β same recipe, different choice of which numbers to average over. They exist precisely because "which axis to normalize" is the one knob that matters. BatchNorm and LayerNorm are the two you must know.
Ξ³/Ξ²). BatchNorm averages over
the batch (great for CNNs, but batch-dependent); LayerNorm averages over an
example's features (batch-independent, the standard in Transformers). Both keep
activations out of saturated regions, so gradients flow and training is faster and more stable.
5 Debugging neural nets
If you bake a cake and it comes out flat, you don't throw out the whole kitchen β you check one thing at a time: Did I add the baking powder? Is the oven actually on? Did I read "cup" as "tablespoon"? Debugging a neural net is the same calm, one-thing-at-a-time detective work. The net almost never "just doesn't work" β there's usually one small, findable mistake.
A neural net can fail silently: no error message, it just doesn't learn. So we lean on deliberate checks and habits. Here are the essential ones.
Check 1 β Overfit a tiny batch (the single best sanity check)
Before training on your full dataset, take a handful of examples (say 2β10) and try to make the network memorize them perfectly. A correctly-wired network should be able to drive the loss on 5 examples to nearly zero β it has way more than enough capacity. If it can't, your model, loss, or data pipeline has a real bug, and there's no point training on the full set.
# Grab a tiny fixed batch and overfit it on purpose. x_tiny, y_tiny = x_train[:5], y_train[:5] for step in range(500): optimizer.zero_grad() loss = loss_fn(model(x_tiny), y_tiny) loss.backward() optimizer.step() if step % 100 == 0: print(step, loss.item()) # GOOD: loss marches toward ~0 -> wiring is sound. # BAD : loss stalls / is NaN -> bug in model, loss, or data.
Check 2 β Gradient checking
Gradient checking verifies that your backprop is computing correct
gradients, by comparing them to a brute-force numerical estimate. The numerical gradient
nudges one weight by a tiny h and measures how the loss changes:
numerical_grad β ( L(w + h) β L(w β h) ) / (2h) # h ~ 1e-5
If your analytic (backprop) gradient and this numerical estimate agree to several decimal places, your gradients are correct. This was essential when people hand-wrote backprop; today frameworks like PyTorch compute gradients automatically (autograd), so you'll rarely do it by hand β but it's the gold-standard way to validate any custom layer or loss you write yourself.
PyTorch ships torch.autograd.gradcheck, which runs exactly this
comparison for you on a custom function. Reach for it whenever you implement a non-standard
operation.
Check 3 β Watch the loss curves
Plot training and validation loss over time. The shape of the curve is a diagnostic dashboard:
| What you see | What it usually means | Try |
|---|---|---|
| Loss flat from the start | LR too low, vanishing gradients, or bug | Raise LR; check init/activations; run the tiny-batch test |
Loss explodes / NaN | LR too high, exploding gradients | Lower LR; add gradient clipping; check for bad data |
| Loss is jagged / noisy | LR a bit high, or batch too small | Lower LR; increase batch size |
| Train loss β but val loss β | Overfitting | Regularize (Session 6): dropout, weight decay, more data |
| Both losses high & flat | Underfitting | Bigger model, train longer, better features |
Check 4 β Hunt the common bugs
A few mistakes account for a huge share of "my net won't train" cases:
- Wrong learning rate. The #1 culprit. Too high β divergence/
NaN; too low β painfully slow or stuck. Always sweep a few LRs (e.g. 1e-2, 1e-3, 1e-4). - Data leakage. Information from the validation/test set sneaks into training β e.g. normalizing using statistics computed over the whole dataset, or shuffling before splitting time-series. Symptom: suspiciously great validation scores that collapse in production. Fix: compute all preprocessing stats on the training set only.
- Label mismatch / misalignment. Inputs and labels get shuffled out of sync, or class indices are off by one, or one-hot vs index labels are mixed up. The model is being told the wrong answers. Symptom: loss won't drop even on the tiny-batch test.
- Forgetting
model.train()/model.eval(). Dropout and BatchNorm behave differently in the two modes (Topic 4). Forgetting to switch causes weird train/eval gaps. - Forgetting
optimizer.zero_grad(). In PyTorch, gradients accumulate by default; skip the reset and your updates are garbage. - Unnormalized inputs. Feeding raw pixel values 0β255 instead of scaling them β instantly destabilizes training.
When debugging, resist the urge to change five things at once. Alter a single variable, observe the effect, then move on. Otherwise you'll fix the bug and never know which change did it β and you'll likely introduce a new one.
Run through this before and during any training run:
- β Inputs normalized; labels verified and aligned with inputs.
- β Loss function matches the task (e.g. cross-entropy for classification).
- β Sensible init (He for ReLU, Xavier for tanh β Topic 3).
- β Overfit a tiny batch to β0 loss β the make-or-break check.
- β Verify the initial loss is in the expected range (e.g. β
ln(num_classes)for balanced classification). - β Sweep a few learning rates; watch the loss curve.
- β Correct
train()/eval()modes;zero_grad()each step. - β Monitor gradient norms to catch vanishing/exploding early.
zero_grad,
unnormalized inputs β and always change one thing at a time.
β Putting it all together
These five topics are really one story about keeping the signal healthy as it flows through a deep network β forwards and backwards. Here's the connected version:
A deep net trains by sending signal forward and gradients backward, and each layer multiplies them. Unless those per-layer factors stay near 1, gradients vanish or explode, and saturating activations like sigmoid make it worse. We attack this from three angles. First, smart initialization (Xavier for tanh/sigmoid, He for ReLU) sizes the starting weights to preserve variance from step one. Second, normalization (BatchNorm in CNNs, LayerNorm in Transformers) keeps the signal well-behaved throughout training so activations don't saturate. Third, when things still go wrong, we debug deliberately β overfit a tiny batch, watch the loss curves, and rule out the classic bugs β changing one thing at a time. Master these and "my deep net won't train" stops being a mystery. Next session we put it all into practice in a hands-on ANN project.
Quick self-check
Why can't you initialize all the weights of a layer to the same value (e.g. zero)?
The symmetry problem: identical weights make every neuron compute the same output and receive the same gradient, so they update identically and stay clones forever. Random init breaks the symmetry so neurons can specialize.
Why does the sigmoid activation cause vanishing gradients?
Its derivative maxes out at just 0.25 and approaches 0 when it saturates. Backprop multiplies one such factor per layer, so a product of numbers β€ 0.25 shrinks toward zero across many layers β the gradient vanishes and early layers stop learning.
You're using ReLU activations. Should you use Xavier or He initialization, and why?
He. ReLU zeroes out negative inputs and so discards about half the signal's
variance; He's extra factor of 2 (Var = 2/n_in) compensates for that.
Xavier is the right choice for symmetric activations like tanh/sigmoid.
What's the key difference between BatchNorm and LayerNorm, and why is LayerNorm used in Transformers?
BatchNorm normalizes each feature across the examples in a batch; LayerNorm normalizes each example across its own features. LayerNorm doesn't depend on batch size and is identical at train and inference time, which fits Transformers' variable-length, often single-sequence processing.
What is the single most useful sanity check before a full training run?
Overfit a tiny batch (a handful of examples) and confirm the loss drops to nearly zero. If a correctly-wired net can't memorize 5 examples, there's a bug in the model, loss, or data pipeline β fix it before training on everything.
Your loss suddenly becomes NaN after a few steps. What are the likely causes and fixes?
Exploding gradients, usually from a learning rate that's too high (or bad/unnormalized data). Fixes: lower the learning rate, add gradient clipping, and check inputs are normalized and labels are valid.
π References & Further Reading
Class material
- SST Deep Learning handout (Session 7) β your course handout for this session, covering initialization, normalization, and debugging.
Papers, docs & deep dives
- SST Deep Learning handout (Session 7) β online β the official class handout page for this session.
- Glorot & Bengio (2010): Understanding the difficulty of training deep feedforward networks β the original paper introducing Xavier/Glorot initialization and the variance-preserving argument.
- He et al. (2015): Delving Deep into Rectifiers β introduces He initialization, tailored to ReLU, and the Γ2 variance correction.
- Karpathy: A Recipe for Training Neural Networks β a famous, practical playbook for debugging nets, including the "overfit one batch" advice.
- Ioffe & Szegedy (2015): Batch Normalization β the paper that introduced BatchNorm and the idea of normalizing layer inputs during training.
- Ba, Kiros & Hinton (2016): Layer Normalization β introduces LayerNorm, the normalization used throughout Transformers.