πŸ“š Study Notes / Home / Neural Nets / Session 7
Session 07 Β· Initialization & Normalization

Why training a deep net sometimes just… fails (and how to fix it)

You've built a network, you've picked a loss, you press "train" β€” and the loss either sits flat forever or explodes into NaN. What happened? Very often the culprit is one of three boring-but-crucial things: how you started the weights, how the signal flows through the layers, and a handful of silent bugs. This session is about making deep nets actually train. We assume you've studied none of this before β€” every topic starts with a tiny story, then we go deep with real math and code.

⏱ 23 min readπŸ“– 5 topics

1 Why initialization matters


Explain like I'm 5

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.

The big idea: break the symmetry

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.

Worked example: watch the signal blow up or die

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.

Key takeaway

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.

Recap Weights need starting values. All-zeros (or all-equal) fails because of the symmetry problem β€” neurons become identical clones. So we use small random numbers. But the scale of those numbers is critical: too big explodes the signal, too small kills it, especially in deep nets where the effect compounds layer after layer.

2 Vanishing & exploding gradients


Explain like I'm 5

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.
The core problem

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.

ActivationDerivative rangeEffect on gradients
Sigmoid0 to 0.25 (max is just 0.25!)Multiplying by ≀0.25 every layer crushes gradients fast β†’ strong vanishing. Worst offender.
Tanh0 to 1 (max 1 at the center)Better than sigmoid, but still saturates (flattens) at the edges β†’ derivative β‰ˆ 0 there β†’ vanishing.
ReLUexactly 0 or 1Where 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).

Worked example: the dying gradient through sigmoids
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

SymptomLikely cause
Loss barely moves; early layers' weights hardly changeVanishing gradients
Later layers learn but earlier ones stay near their init valuesVanishing gradients
Loss suddenly spikes or becomes NaN/infExploding gradients
Weights or gradient norms grow huge over a few stepsExploding gradients
A quick fix for explosions: gradient clipping

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

Recap Backprop multiplies a factor per layer, so over many layers the gradient explodes (factors >1) or vanishes (factors <1). Saturating activations like sigmoid (slope ≀ 0.25) and tanh make vanishing worse; ReLU (slope 1 when active) helps. Symptoms: frozen early layers (vanishing) or NaN loss (exploding). Clipping tames explosions; the rest of this session tames vanishing.

3 Smart initialization: Xavier & He


Explain like I'm 5

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:

The variance-preserving idea

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

ActivationUse this initWhy
Sigmoid, TanhXavier / GlorotSymmetric, near-linear at 0 β†’ balance fan-in & fan-out.
ReLU, Leaky ReLU, ELUHeReLU discards half the variance β†’ need the Γ—2 boost.
(Output layer, linear)Xavier is a fine defaultNo squashing nonlinearity to compensate for.
Worked example: it actually preserves the signal

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

Gotcha

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.

Recap Smart init sizes the random weights to preserve variance layer to layer. Xavier/Glorot (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


Explain like I'm 5

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.

The key difference, in one line

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 NormalizationLayer Normalization
Normalizes overThe batch (per feature, across examples)The features (per example, across features)
Depends on batch size?Yes β€” bad with tiny batchesNo β€” works at batch size 1
Train vs inferenceDifferent (uses running stats at inference)Identical
Typical homeCNNs / visionTransformers / 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.

Worked example: BatchNorm vs LayerNorm in PyTorch
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(),
)
Aside: there are more cousins

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.

Recap Normalization re-centers and re-scales values between layers (mean 0, variance 1, then learnable Ξ³/Ξ²). 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


Explain like I'm 5

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.

Worked example: the tiny-batch test
# 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.

In modern frameworks

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 seeWhat it usually meansTry
Loss flat from the startLR too low, vanishing gradients, or bugRaise LR; check init/activations; run the tiny-batch test
Loss explodes / NaNLR too high, exploding gradientsLower LR; add gradient clipping; check for bad data
Loss is jagged / noisyLR a bit high, or batch too smallLower LR; increase batch size
Train loss ↓ but val loss ↑OverfittingRegularize (Session 6): dropout, weight decay, more data
Both losses high & flatUnderfittingBigger 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.
Watch out: change one thing at a time

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.

A practical pre-flight checklist

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.
Recap Nets fail silently, so debug deliberately: overfit a tiny batch (best single sanity check), gradient-check custom code, and read the loss curves for too-high/too-low LR, over/underfitting. Watch for the usual suspects β€” wrong LR, data leakage, label mismatch, train/eval mode, forgotten 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