Module 2: Watch the gradients vanish
First, why is there a "backward" signal at all?
Training asks one question of every weight in the network: if this weight nudged up a little, would the loss go down? Nudge in whichever direction shrinks the loss, repeat. The gradient is just the vector of answers.
For the output layer the answer is direct; Module 0 measured it: the loss responds to the final logit at rate p − y. But consider a weight in hidden layer 1. Its nudge changes layer 1's output, which changes layer 2's output, which changes layer 3's, and so on to the loss. The influence is real but indirect, relayed through every layer above.
Indirect influence multiplies, and it pays to see why with plain numbers before any formulas. Suppose a 1-unit wiggle in layer 3's activation moves layer 4's activation by 0.2 units (layer 4 responds at rate 0.2), and a 1-unit wiggle in layer 4 moves the loss by 0.1. Then a 1-unit wiggle in layer 3 moves the loss by 0.2 × 0.1 = 0.02: the rates multiply, exactly like currency conversions chained through two exchanges. That is all the chain rule is. In derivative notation, for our stack where each layer's activation h_k feeds the next:
The backward pass starts at the loss with the healthy signal p − y and works down, multiplying in one layer-crossing rate ∂h_k/∂h_{k−1} per layer. Each crossing is a sigmoid layer, h_k = σ(W_k h_{k−1} + b_k), so each rate contains two ingredients you have already met:
- the sigmoid's slope σ′(z_k), at most 0.25 (measured, Module 0), and
- the weight matrix W_k, whose entries started at std 0.31 (measured, Module 1).
Make a prediction
A slope that cannot exceed 0.25, times a smallish weight matrix, makes each layer-crossing rate well below 1. And sub-unity factors compound geometrically. Before any measurement, commit to a number:
Predict. Suppose each layer crossing keeps about one tenth of the incoming signal. What fraction of the signal survives four crossings? Write it down (a power of ten is enough) before reading on.
The compounding is nothing but repeated multiplication:
0.1 after 1 crossing -> 10%
0.1 after 4 crossings -> 0.01% (one ten-thousandth)
0.1 after 10 crossings -> one ten-billionth
Section 5's pass of the experiment script runs this same arithmetic
for a range of factors (no network involved, just factor ** depth) and logs it
(captures/results.log lines 58–61):
factor 0.9: after 4 layers -> 6.56e-01, after 10 layers -> 3.49e-01
factor 0.5: after 4 layers -> 6.25e-02, after 10 layers -> 9.77e-04
factor 0.25: after 4 layers -> 3.91e-03, after 10 layers -> 9.54e-07
factor 0.1: after 4 layers -> 1.00e-04, after 10 layers -> 1.00e-10
Read the bottom row: if each layer keeps only a tenth of the signal, four layers keep a ten-thousandth, and ten layers keep a ten-billionth. On the plot's log scale each factor is a straight line: geometric decay means the exponent grows with depth. Hold onto the 0.1 row; the plain network is about to land almost exactly on it.
Run the measurement
Everything below comes from one forward and one backward pass of the untrained plain network
on the full 800-point training batch: no optimizer, no training steps, just autograd. The
pass itself is four lines, verbatim from the script's measure_at_init (bce is the fused
sigmoid-plus-cross-entropy loss from Module 0):
net.zero_grad()
out = net(X_train, record=True)
loss = bce(out, y_train)
loss.backward()
The record=True flag is the measurement hook, and its whole idea fits in three steps:
- save each hidden layer's activation tensor as the forward pass computes it,
- ask autograd to keep that tensor's gradient (for tensors in the middle of the network it is discarded by default), and
- after
loss.backward(), read the gradient sitting on each saved activation.
That yields the traveling signal: the size of ∂L/∂h_k at every depth. The per-layer
weight gradients ∂L/∂W_k need no hook at all, because PyTorch always keeps gradients on
parameters (weight.grad).
How was this measured?
Optional PyTorch mechanics; the module's argument does not depend on them. The hook, boiled down to its essence (an illustration, simplified from the real code):
h = torch.sigmoid(layer(h)) # one hidden layer's activation
h.retain_grad() # keep dL/dh here when backward() runs
saved.append(h)
# ... the forward pass continues up to the loss ...
loss.backward()
for h in saved:
print(h.grad.norm()) # the backward signal at this layer
retain_grad() is the one non-obvious call: autograd normally discards gradients on
intermediate (non-leaf) tensors the moment they have served their purpose, to save memory,
so the hook asks it to keep them. The real forward() method is more general than this
sketch, because it serves every variant in the workshop (batch and layer normalizers, a
residual branch, ReLU); all of that machinery is inert in this module, where the normalizer
slot holds a do-nothing nn.Identity(). Read the full TinyNet.forward and
measure_at_init on the Source page.
Observe the relay
When Section 4's pass of the experiment script runs the one-pass measurement above on the untrained plain network, this is the relay it finds. Each box holds the measured backward signal at that depth, each arrow the measured hop factor:
The same numbers as a table, read in the direction the signal travels (top row first). Each
"retained" entry is that row's signal divided by the row above's: the measured per-hop
conversion rate. (Distilled from the table Section 4's pass logs at
captures/results.log lines 33–41; the full readout appears in the
diagnosis below.)
| Location | Traveling signal, size of ∂L/∂h | Retained from the layer above |
|---|---|---|
| hidden 4 | 1.47e-02 | (start of the relay) |
| hidden 3 | 1.75e-03 | 12.0% |
| hidden 2 | 1.83e-04 | 10.4% |
| hidden 1 | 1.75e-05 | 9.6% |
Your prediction lands: the relay keeps roughly 10% per hop, losing roughly 90% at every crossing. (If you rerun the script in the exact environment of the reproducing section, seed 0 makes the log match byte for byte; change anything, the seed, the width, the batch, and every digit moves. The finding is the ratio structure, the roughly tenfold drop per hop, not the particular norms.)
The optimizer never sees ∂L/∂h directly; what it uses to update layer k is the weight
gradient ∂L/∂W_k, and that inherits the same cliff, because it is assembled from the
traveling signal arriving at layer k (same log table, ||dL/dW|| column):
| Layer | Weight gradient, size of ∂L/∂W |
|---|---|
| output | 2.00e-01 |
| hidden 4 | 3.99e-02 |
| hidden 3 | 4.01e-03 |
| hidden 2 | 5.37e-04 |
| hidden 1 | 6.63e-05 |
The script divides the ends of that column for us (results.log line
40): layer 1's weight gradient is 3.32e-04 of the output layer's, 3,015 times smaller,
before training has taken a single step. On a log axis the decline is a near-straight line,
the signature of a near-constant factor per layer, landing on the 0.1 row of the prediction
plot:
Where does 0.1 per hop come from?
The chain-rule crossing for h_k = σ(z_k), z_k = W_k h_{k−1} + b_k is (entry-wise slope, then matrix):
Translated: every hop multiplies the signal by an activation-slope effect (each neuron's σ′, a number at most 0.25) and a weight-matrix effect (whatever scaling W_k applies to the vector passing through). Two knobs per hop, nothing else. So if the relay loses 90% per hop, one of those two knobs, or their product, must be responsible.
Diagnose: saturation or scale?
Both knobs can strangle the relay in principle, and they predict different measurements. Module 0 planted the first suspicion: sigmoids have flat, saturated zones where the slope is ~0, and the standard textbook account of vanishing gradients blames them.
- Suspect A, saturation: pre-activations have drifted into the sigmoid's flat zones. Predicts a mean slope near zero, a sizable fraction of units with slope near zero, and a large spread of z (that is what pushes units into the flat zones).
- Suspect B, scale contraction: the sigmoids are fine, operating near their maximum slope of 0.25, but 0.25 times a sub-unity weight gain still multiplies the signal by ~0.1 per hop. Predicts healthy slopes and a small spread of z.
Now the full instrument readout this module has been quoting, from the same one-pass
measurement (captures/results.log lines 33–41). You have already met
the first two columns; the diagnosis lives in the last four:
PLAIN (Linear -> sigmoid): loss at init = 0.7784
layer ||dL/dW|| ||dL/dh|| std(z_pre) std(z) std(h) mean slope %slope<.05 W gain
1 6.63e-05 1.75e-05 0.3513 0.3513 0.0848 0.2420 0.0% 0.767
2 5.37e-04 1.83e-04 0.4514 0.4514 0.1087 0.2379 0.0% 0.604
3 4.01e-03 1.75e-03 0.5744 0.5744 0.1366 0.2313 0.0% 0.623
4 3.99e-02 1.47e-02 0.2321 0.2321 0.0575 0.2467 0.0% 0.470
output 2.00e-01
gradient ratio, layer 1 vs output: 3.32e-04 (3015x smaller)
backward shrink factor per layer (||dL/dh_k|| / ||dL/dh_k+1||): 0.096, 0.104, 0.120
(One column pair needs a word: std(z_pre) is the spread before the network's normalizer
slot and std(z) after it. In the plain network that slot is an identity, so the two match
exactly; keep the pair in mind, because Module 4's batch-norm table is where they split
apart.)
Read the verdict off the slope columns. mean slope is 0.2420, 0.2379, 0.2313, 0.2467
against a theoretical maximum of 0.25: these sigmoids operate within 8% of the steepest
they can possibly be. And a mean can hide a saturated tail, so the %slope<.05 column
counts the units whose slope actually sits in a flat zone: 0.0% at every layer. Not one
unit in the network is saturated. Suspect A is measured out of the case.
Suspect B's prediction is what the table shows instead. The std(z) column says the
pre-activations have spread 0.23–0.57, so nearly all of them sit in the sigmoid's steep
central region around z = 0. Why so central? Small weights (std 0.31) times order-one inputs
give small z. And note what is, and is not, dying on the forward side: a sigmoid fed z near 0
outputs values near 0.5, so the activations themselves stay comfortably mid-range. What
collapses is their variation across examples: std(h) shrinks to 0.06–0.14, meaning each
layer passes along less and less of what distinguished one input from another, even though
the values look healthy. The forward signal's information content and the backward signal
are dying together.
Put the hop together from the table's own columns. Take the hop from h2 down to h1, which
crosses layer 2: mean slope 0.2379, and the W gain column (the root-mean-square singular
value of W_2, i.e. the typical scaling a random direction receives) is 0.604. Their product
predicts a factor of about 0.14; the measured factor is 0.096. Same ballpark (the estimate
is crude because a random 4-wide matrix shrinks most specific directions somewhat more than
its average suggests), and the structure of the answer is what matters:
hop factor ≈ slope × weight gain ≈ 0.24 × 0.5 ≈ 0.1
How much does each ingredient contribute? The sigmoid's slope ceiling is the heavyweight: 0.24⁴ ≈ 0.0033, a ~300x loss over four layers even if the weights preserved scale perfectly. The small weights multiply in another 0.5⁴ to 0.6⁴, roughly a further 8–16x. Compounded together they land near 10⁻⁴, the 3,000x hole the table measured. The lesson is multiplicative: the weights did not create the problem, they multiplied an already serious sigmoid contraction into a catastrophe.
So for this network, at initialization, the mechanism is scale, not saturation: maximally steep sigmoids still multiply in at most 0.25 per layer, and small random weights multiply in another ~0.5. The distinction is not pedantry; it predicts which fixes will work. If saturation were the problem, batch norm's job would be to move z's toward the steep region; Module 4 will show it does something quite different (and measurably makes the slopes smaller, yet fixes the network anyway).
Turn one knob yourself
A measured mechanism earns its keep by making predictions. Every knob lives at the top of
the experiment script (DEPTH, WIDTH, and the norm= / act=
options of TinyNet), and later passes of the same script already measure three turns of
them, so you can predict first and check immediately:
- Depth 4 → 8. Four more hops at ~0.1 each. Predict the layer-1-to-output gradient
ratio before peeking; the measured answer is on
results.logline 80. - Sigmoid → ReLU. ReLU's slope is exactly 1 for every active unit, so the activation-slope knob stops contracting; only the weight gain remains. Predict whether the cliff gets shallower or steeper, then check the measured init ratio on line 93.
- Weights 10x larger. Would a
W gainwell above 1 cancel the 0.25 slope ceiling? That one is quiz question 3.
Module 3 shows what the cliff does to actual training, and Module 4 repeats this module's measurement, identical starting weights, with exactly one change: a batch-norm step between each Linear layer and its sigmoid. The cliff disappears, and the measurement explaining why is not the one the textbook story predicts.
Quiz
- In a deeper plain sigmoid network, the backward signal must cross six layer transitions, each retaining about 0.1 of the incoming signal. Estimate the fraction of the signal that survives all six crossings. If you set the learning rate so the output layer trains well, what does your estimate say about the earliest layers' training?
- The measured slope columns (mean 0.23–0.25,
%slope<.05= 0.0%) rule out saturation at initialization. Describe what those two columns would have shown instead if saturation had been the problem, and name one situation later in training where saturation genuinely could take over. - A friend proposes fixing the plain net by initializing weights 10x larger (std ~3 instead
of 0.31), reasoning that a bigger
W gain(well above 1) would cancel the 0.25 slope. Use the table's columns to predict what would happen tostd(z)andmean slope, and whether the fix works.
Answer Key
- Six crossings at 0.1 each: 0.1⁶ = 10⁻⁶, so the first hidden layer sees about a
millionth of the output layer's gradient (compare the measured 4-layer ratio of 3.3e-4,
results.logline 40, and the measured depth-8 ratio of 5.0e-7, line 80). With the learning rate sized for the output layer, the earliest weights move about a million times more slowly, effectively frozen at their random initialization. The layers above are then stuck computing functions of random features. - Saturation would show up as
mean slopenear zero (recall from Module 0: slope ~0.018 already at z = ±4) and a large%slope<.05(a big fraction of units pinned in the flat zones), driven by a largestd(z)pushing pre-activations away from z = 0. Later in training, saturation genuinely can occur: if weights grow large (for instance after training at an aggressive learning rate, or with the 10x init of question 3), z's spread widens and neurons pin near 0 or 1: a different failure with the same "gradients die" symptom. - Multiplying weights by 10 multiplies
std(z)by roughly 10 (to spread ~3–6), which throws most pre-activations deep into the flat zones;mean slopecollapses toward ~0.02 or less (the measured slope at |z| = 4 is 0.018,results.logline 15), and%slope<.05jumps from 0% toward most of the network. The hop factor becomes (tiny slope) × (large W gain), which does not reliably recover; you have traded the scale problem for a genuine saturation problem. The principled version of "pick the right weight scale" is Xavier initialization, measured in Module 5.