DL GEN AI · Week 2

Run a full MLP backward pass on paper

What this page stands on. A dense layer computes \(z = Wx + b\), then an activation \(a = g(z)\); a network that stacks those layers is a multilayer perceptron (MLP). Training means changing the weights to lower a loss, following the gradient of the loss with respect to each weight.

This page runs the training of that machine end to end: the promise, a start that keeps it, the step, the relay that computes the gradient, and the proof on paper. Each node hands the next one exactly what it needs.

flowchart LR
    UAT[UAT promise] --> INIT[break symmetry]
    INIT --> GD[pick direction]
    GD --> BP[backprop relay]
    BP --> SCAL[scalar chain]
    SCAL --> XOR[XOR payoff]

The argument in one line. Promise first, then a start that keeps the promise, then the move, then the machine that computes the move, then the proof on paper.

The rule you will break first — direction is not distance. The gradient points downhill at this point only. The step size \(\eta\) alone decides how far you walk. On \(L(w) = w^2\) from \(w = 3\) with \(\eta = 1.1\) you land at \(w = -3.6\) and the loss climbs \(9 \to 12.96\). Overshoot can raise the loss.

1 · The words this page uses

Four groups of words carry the rest of the page. Read them once. Each returns later in a worked case.

1.1 · Parameter, weight, bias

A parameter is a number the training step changes. A dense layer holds two kinds. A weight multiplies an input; a bias is added after the sum. For one layer, \(z = Wx + b\): the matrix \(W\) holds the weights and the vector \(b\) the biases. The input \(x\) and the target \(y\) are data. Training never changes them.

1.2 · Activation, loss

An activation is the value a neuron passes on after its nonlinear function. The weighted sum is \(z\), the activation is \(a = g(z)\), and \(g\) is the activation function. The sigmoid used below is \(\sigma(z) = 1/(1+e^{-z})\): it turns any number into a value between 0 and 1. The loss is one number that scores the prediction against the target. Lower is better, and the training step changes the parameters to lower it.

1.3 · Gradient, learning rate

The gradient \(\partial L/\partial w\) is the slope of the loss with respect to one parameter at the current point: how much \(L\) moves per unit change in \(w\). The learning rate \(\eta\) is the step size you choose. The update is \(w \gets w - \eta\,\partial L/\partial w\): the gradient gives the direction, \(\eta\) gives the distance.

1.4 · Epoch, symmetry breaking

An epoch is one pass over the whole training set; you choose how many epochs to run. Symmetry breaking is giving neurons different starting weights so they can learn different features. Identical starts keep the layer from learning distinct features.

2 · Universal Approximation Theorem: the promise

Before training, ask what the machine can compute. One hidden layer with enough neurons approximates any continuous function on a bounded patch of inputs, to any accuracy you demand. That is the whole theorem. Each word carries weight.

PhraseWhat it meansWhat it excludes
One hidden layerwidth alone suffices for capabilitydepth is not required for this claim
Enough neuronsmore neurons buy a finer fitno count is given; it can be huge
Continuousno jumps in the targetfunctions with jumps are outside the promise
Bounded patcha finite region of input spacenot all of \(\mathbb{R}^n\)
Any accuracyerror below any \(\varepsilon > 0\) you nameapproximation, never exactness

Why one layer is this strong. A hidden neuron draws a line. With sigmoid it fires near \(1\) on one side, near \(0\) on the other. The output neuron adds those votes with weights. Strips of fired votes fence off small plateaus, and any continuous shape is a stack of small plateaus. Picture a circle made of square pixels. More neurons give smaller pixels and a sharper shape.

The fine print. The theorem needs a nonlinear activation. Stacked linear layers collapse into one linear map. Nonlinearity at every layer keeps the promise alive.

What the UAT does not say. It does not find the weights — it says good weights exist. It does not say how many neurons are enough. It says nothing about unseen data.

3 · Weight initialization: break the symmetry

The UAT says good weights exist. Training starts from weights you choose. The obvious start is zero. Zero quietly breaks the promise.

The rule: if a layer's neurons start identical and the next layer treats them alike, they stay identical forever. Identical neurons get the same inputs, compute the same output, receive the same upstream gradient, and take identical updates. A 64-neuron layer with zero init has the capacity of one neuron. Training longer changes nothing.

One caution. Sigmoid does not rescue this. The output layer still learns, and any hidden row that moves moves with the others, so the hidden rows never separate. With ReLU, \(\max(0,z)\), and zero weights the hidden gradient is exactly zero, so the layer does not move at all. Both are the same failure.

The fix is random weights: each neuron starts with a different line, which is exactly what the UAT needs. The scale matters too. Too large and the signal explodes, sigmoid saturates, \(\sigma'(z) \approx 0\), and the gradient dies. Too small and the signal shrinks layer by layer. Two standard recipes hold the signal near one: Xavier draws each weight with variance \(\frac{1}{n_{in}}\), He uses \(\frac{2}{n_{in}}\) for ReLU. The input count appears because a neuron sums \(n_{in}\) contributions — more contributions need smaller weights. PyTorch ships both: nn.init.xavier_normal_ and nn.init.kaiming_normal_.

3.1 · What happens when two units start identical

Predict first. A 2-2-1 network has two hidden rows. Both rows start at zero and both output weights are 1. After one gradient step with \(\eta = 0.1\), will the two rows still match? Then start the rows at random values. Will they match after one step?

import torch
import torch.nn.functional as F

torch.manual_seed(0)
x = torch.tensor([1.0, 2.0])
y = torch.tensor([1.0])

def rows(W):
    return [[round(v, 4) for v in row] for row in W.tolist()]

def one_step(W1_start):
    W1 = W1_start.clone().requires_grad_()
    b1 = torch.zeros(2, requires_grad=True)
    W2 = torch.ones(1, 2, requires_grad=True)   # equal outgoing weights
    b2 = torch.zeros(1, requires_grad=True)
    h = torch.sigmoid(W1 @ x + b1)
    loss = F.binary_cross_entropy_with_logits(W2 @ h + b2, y)
    loss.backward()
    with torch.no_grad():
        for p in (W1, b1, W2, b2):
            p -= 0.1 * p.grad
    return W1.detach()

for name, start in [("zero", torch.zeros(2, 2)), ("random", torch.randn(2, 2))]:
    print(name, "before", rows(start))
    print(name, "after ", rows(one_step(start)))
zero before [[0.0, 0.0], [0.0, 0.0]]
zero after  [[0.0067, 0.0134], [0.0067, 0.0134]]
random before [[1.541, -0.2934], [-2.1788, 0.5684]]
random after  [[1.5465, -0.2825], [-2.1735, 0.5789]]

Equal output weights make the upstream gradient equal for both rows. The zero rows stay equal; the random rows separate.

What happens. The zero rows stay equal. Each gets the same gradient \((-0.0672, -0.1345)\), so both move to \((0.0067, 0.0134)\). The random rows separate, because different rows produce different hidden values, different sigmoid slopes, and different gradients. The output weights must also be equal for the tie to hold; the hands-on changes them and the rows split.

4 · Gradient descent: read the step

You own the update \(w \gets w - \eta\,\partial L/\partial w\). What remains is the trap and a lab skill: read a loss curve. The gradient gives the direction at the current point only. It says nothing about the distance downhill.

Curve shapeReading
Smooth drop, then slow plateauhealthy; the easy error is gone
Mostly down, sharp spikes up\(\eta\) slightly too large; steps overshoot and bounce back
Rising loss, or NaN\(\eta\) far too large; the steps diverge
Long flat crawl\(\eta\) too small; each step descends but barely

One caution. Mini-batch training adds noise — each batch sees different data, so the curve wobbles even when healthy. Read the trend, not single points.

5 · Backpropagation: the graph in reverse

Gradient descent needs \(\partial L/\partial w\) for every weight, and a real network has thousands. Recomputing the loss once per weight is hopeless. Backprop gets every gradient in one sweep.

The forward pass is a graph. For a one-hidden-layer net: \(x \to z_1 = W_1 x + b_1 \to a_1 = \sigma(z_1) \to z_2 = W_2 a_1 + b_2 \to \hat{y} = \sigma(z_2) \to L(\hat{y}, y)\). Each arrow is one computation, one node. The loss sits at the end.

flowchart LR
    X[x] --> Z1[z1] --> A1[a1] --> Z2[z2] --> YH[y-hat] --> L[L]
    L -. backward .-> X

Forward builds every value and keeps it; backward walks the same edges in reverse, rescaling one relay at a time.

The backward rule. Start at the loss and move toward the input. At each node: add all gradients arriving at its output. For each input of the node: multiply that sum by the node's derivative with respect to this input, and send it down. That is your chain rule along a path plus summation at fan-out, applied systematically from the loss backward.

Why the forward values are kept. Each local derivative is written in terms of the forward value. For sigmoid, \(\sigma'(z) = \sigma(z)\,(1-\sigma(z))\). To use it backward, \(z\) must still exist — so forward computes and keeps every intermediate value.

One training step is two passes plus the update: forward to compute and keep every value, backward to apply the rule from the loss, then \(w \gets w - \eta\,\partial L/\partial w\) for every weight at once. One backward sweep costs about as much as one forward pass, which is why training is practical at all.

5.1 · Flow versus storage

Flow covers every node, activations included — the gradient reaches the weights only through the activations. Storage covers only the parameters: PyTorch saves .grad on leaves (weights and biases) because the update reads only those. Activation gradients are computed, used, and dropped. Both facts hold at once.

import torch

w = torch.tensor([2.0], requires_grad=True)   # a leaf parameter
x = torch.tensor([1.0])
z = w * x                                     # intermediate value
a = torch.sigmoid(z)                          # intermediate value
loss = (a - 0.5) ** 2
loss.backward()

print(w.grad)   # tensor([0.0799]) — stored, w is a leaf
print(a.grad)   # None — computed on the way, then dropped
print(z.grad)   # None — computed on the way, then dropped

The backward pass used the gradients at z and a (roughly 0.76 × 0.105 ≈ 0.08) to reach w, then kept only the leaf.

6 · Backward pass by hand: a scalar network

The rule above is abstract until you run it with numbers. This section is the recipe plus its result. After it, loss.backward() is a machine doing what you just did by hand.

The recipe. Draw the graph left to right, one node per arithmetic step. Forward: fill every value, keep them all. Turn around at the loss. At each node ask what gradient arrived from above and how much your output wiggles when your input wiggles. Multiply the two and pass the result down. At fan-out, sum the arriving gradients first. Stop at every weight and bias.

OperationLocal derivative
\(+b\)\(1\)
\(\times w\)the other factor
\(\sigma\)\(\sigma(z)\,(1-\sigma(z))\)
Huber\(e\) inside the band, \(\delta \cdot \mathrm{sign}(e)\) outside
BCE then \(\sigma\)the collapse: \(\hat{y} - y\)

The network. One input, one hidden neuron, one output neuron, no fan-out yet. \(x = 1.0\), \(z_1 = w_1 x + b_1\), \(a_1 = \sigma(z_1)\), \(z_2 = w_2 a_1 + b_2\), \(\hat{y} = \sigma(z_2)\). Parameters \(w_1 = 0.5\), \(b_1 = 0\), \(w_2 = 1.0\), \(b_2 = 0\), target \(y = 0\). Loss is Huber with \(\delta = 0.5\): quadratic within the band, flat slope beyond it.

Forward pass — compute and keep every value. The error \(0.6508\) exceeds \(\delta = 0.5\), so the Huber slope is \(\delta \cdot \mathrm{sign}(e) = 0.5\).

NodeExpressionValue
\(z_1\)\(0.5 \times 1.0 + 0\)\(0.5000\)
\(a_1\)\(\sigma(0.5)\)\(0.6225\)
\(z_2\)\(1.0 \times 0.6225 + 0\)\(0.6225\)
\(\hat{y}\)\(\sigma(0.6225)\)\(0.6508\)
\(e\)\(\hat{y} - y\)\(0.6508\)
\(L\)\(0.5\,(0.6508 - 0.25)\)\(0.2004\)

Backward pass — start at the loss, multiply by each local derivative on the way down.

NodeLocal derivativeUpstreamGradient out
\(\hat{y}\)Huber \(0.5000\)—\(0.5000\)
\(z_2\)\(\hat{y}(1-\hat{y}) = 0.2273\)\(0.5000\)\(0.1136\)
\(w_2\)\(a_1 = 0.6225\)\(0.1136\)\(0.0707\)
\(b_2\)\(1\)\(0.1136\)\(0.1136\)
\(a_1\)\(w_2 = 1.0\)\(0.1136\)\(0.1136\)
\(z_1\)\(a_1(1-a_1) = 0.2350\)\(0.1136\)\(0.0267\)
\(w_1\)\(x = 1.0\)\(0.0267\)\(0.0267\)
\(b_1\)\(1\)\(0.0267\)\(0.0267\)

Watch two rows get built. Row \(z_2\): arriving \(0.5000\), local \(0.6508 \times 0.3492 = 0.2273\), product \(0.1136\). Row \(w_2\): arriving \(0.1136\), local \(a_1 = 0.6225\), product \(0.0707\). Every other row is the same two questions and one multiply. Bias rows ride the same chain with one factor less, because \(+b\) has derivative \(1\).

6.1 · Check the hand pass with torch

Predict first. The same graph and the same numbers, built in torch. Before you run it, write the six values the code should print: \(\hat{y}\), \(L\), and the gradients at \(w_2\), \(b_2\), \(w_1\), \(b_1\).

import torch
import torch.nn.functional as F

x  = torch.tensor(1.0)
w1 = torch.tensor(0.5, requires_grad=True)
b1 = torch.tensor(0.0, requires_grad=True)
w2 = torch.tensor(1.0, requires_grad=True)
b2 = torch.tensor(0.0, requires_grad=True)

z1   = w1 * x + b1
a1   = torch.sigmoid(z1)
z2   = w2 * a1 + b2
yhat = torch.sigmoid(z2)
loss = F.huber_loss(yhat, torch.tensor(0.0), delta=0.5)
loss.backward()

print("yhat", round(yhat.item(), 4))         # yhat 0.6508
print("loss", round(loss.item(), 4))         # loss 0.2004
print("w2.grad", round(w2.grad.item(), 4))   # w2.grad 0.0707
print("b2.grad", round(b2.grad.item(), 4))   # b2.grad 0.1136
print("w1.grad", round(w1.grad.item(), 4))   # w1.grad 0.0267
print("b1.grad", round(b1.grad.item(), 4))   # b1.grad 0.0267

The code follows the table's graph. backward() walks it in reverse; each .grad is the product of the local derivatives on the path from the loss.

What happens. All six values match the tables to four decimals. \(b_1\) and \(w_1\) share a gradient here because \(x = 1\), so \(\partial z_1/\partial b_1 = \partial z_1/\partial w_1 = 1\). PyTorch is not using a different rule; it is running the same chain rule on the same graph.

7 · XOR: the payoff

XOR outputs 1 when the inputs differ: (0,0) → 0, (0,1) → 1, (1,0) → 1, (1,1) → 0. A linear model computes \(z = w_1 x_1 + w_2 x_2 + b\) and thresholds it — one straight line. No line separates this table: (0,0) forces \(b < 0\), the two singletons force \(w_1, w_2 > -b\), and then (1,1) lands above threshold while the table says 0. Contradiction. The two diagonals of the square cannot be split by any single line. XOR is not linearly separable.

A hidden layer fixes it with two lines instead of one. One hidden neuron learns OR, another learns AND, and the output computes OR minus AND — exactly XOR. The UAT promise in miniature: more lines, more shapes.

The machine. Two inputs, two hidden sigmoid neurons, one output sigmoid neuron, binary cross-entropy (BCE) loss. For one example, \(L = -[\, y \ln \hat{y} + (1-y)\ln(1-\hat{y}) \,]\) with \(\hat{y} = \sigma(z_3)\). Differentiate and multiply by the sigmoid derivative and the denominator cancels exactly: \(\frac{dL}{dz_3} = \hat{y} - y\), prediction minus target. Remember this; it saves you in debugging.

Nine parameters: \(W_1\) has \(2 \times 2 = 4\), \(b_1\) has 2, \(W_2\) has \(1 \times 2 = 2\), and \(b_2\) has 1. The count is small enough to check by hand.

One numeric pass. Input \(x = (1, 0)\), target \(y = 1\). Hidden 1: \(w = (1.0, 0.5)\), \(b = 0\). Hidden 2: \(w = (-0.5, 1.0)\), \(b = 0.5\). Output: \(w = (1.0, 1.0)\), \(b = 0\).

NodeExpressionValue
\(z_1\)\(1.0(1) + 0.5(0) + 0\)\(1.0000\)
\(a_1\)\(\sigma(1)\)\(0.7311\)
\(z_2\)\(-0.5(1) + 1.0(0) + 0.5\)\(0.0000\)
\(a_2\)\(\sigma(0)\)\(0.5000\)
\(z_3\)\(1.0(0.7311) + 1.0(0.5) + 0\)\(1.2311\)
\(\hat{y}\)\(\sigma(1.2311)\)\(0.7740\)
\(L\)\(-\ln(0.7740)\)\(0.2562\)
NodeLocalUpstreamGradient out
\(z_3\)collapse \(\hat{y} - y\)—\(-0.2260\)
\(w_{3,1}\)\(a_1 = 0.7311\)\(-0.2260\)\(-0.1652\)
\(w_{3,2}\)\(a_2 = 0.5000\)\(-0.2260\)\(-0.1130\)
\(b_3\)\(1\)\(-0.2260\)\(-0.2260\)
\(a_1\)\(w_{3,1} = 1.0\)\(-0.2260\)\(-0.2260\)
\(z_1\)\(\sigma' = 0.1966\)\(-0.2260\)\(-0.0444\)
\(b_1\)\(1\)\(-0.0444\)\(-0.0444\)
\(a_2\)\(w_{3,2} = 1.0\)\(-0.2260\)\(-0.2260\)
\(z_2\)\(\sigma' = 0.2500\)\(-0.2260\)\(-0.0565\)
\(b_2\)\(1\)\(-0.0565\)\(-0.0565\)

Hidden weights multiply by their inputs: \(\partial L/\partial w_{1,1} = -0.0444 \times 1 = -0.0444\), \(\partial L/\partial w_{1,2} = 0\), \(\partial L/\partial w_{2,1} = -0.0565\), \(\partial L/\partial w_{2,2} = 0\). The bias gradients are the pre-activation gradients, \(b_1\): \(-0.0444\) and \(b_2\): \(-0.0565\). Fan-out enters at the input \(x_1\): it feeds two hidden neurons, so its gradient sums both paths, \(-0.0444 \times 1.0 + (-0.0565) \times (-0.5) = -0.0162\). Weight gradients never sum — each weight sits on exactly one path.

Drill · 9 questions

Click an option to check your answer. Each question maps to one section above. Options are shuffled on every load and on every reset, so position tells you nothing.

Q1 · Parameters versus settings

A dense layer computes \(z = Wx + b\). Which statement names what the training step changes and what you choose before it starts?
  1. \(W\), \(b\), and \(\eta\) are all parameters changed by the step
  2. \(x\) and \(b\) are parameters; \(W\) is a setting
  3. \(W\) and \(b\) are parameters changed by the step; \(\eta\) and the epoch count are settings you choose
  4. The loss and activation are parameters; \(W\) and \(b\) are settings

Q2 · What the UAT claims

Which claim is the UAT actually making?
  1. One hidden layer exactly represents any continuous function on a bounded patch
  2. One hidden layer approximates any function, continuous or not, on a bounded patch
  3. Only deep stacks approximate complicated functions; one hidden layer cannot
  4. One hidden layer with enough neurons approximates any continuous function on a bounded patch to any accuracy

Q3 · Zero init

A 2-2-1 network starts with every weight and bias at zero. What happens?
  1. All neurons stay identical forever, so the layer behaves like one neuron
  2. Every gradient becomes exactly zero, so nothing learns at all
  3. Any random scale works equally well, so the init distribution needs no tuning
  4. Training slowly separates the neurons, so zero init only costs extra epochs

Q4 · Overshoot arithmetic

\(L(w) = w^2\), \(w = 3\), \(\eta = 1.1\). One GD step. What is the new loss?
  1. About 0.36 — the step lands near the minimum and the loss falls
  2. 12.96 — the step lands at −3.6, past the minimum and higher
  3. Exactly 0 — the step lands on the minimum
  4. 9 — the step leaves the loss unchanged

Q5 · Flow versus storage

After loss.backward() on a 3-layer MLP, which statement is true?
  1. Only the parameters' gradients were computed; activations were skipped
  2. Every node's gradient was computed, and all of them are stored on the tensors
  3. Every node's gradient was computed during the backward pass; only the parameters' gradients are stored
  4. Gradients were computed only for the last layer; earlier layers reuse that value

Q6 · The tiny graph

w is a leaf with requires_grad=True, z = w * x, a = sigmoid(z), then a squared-error loss. After backward(), what do a.grad and w.grad hold?
  1. a.grad is None; w.grad holds a number
  2. a.grad holds a number; w.grad holds a number
  3. a.grad is None; w.grad is None
  4. backward() raises an error because a is not a parameter

Q7 · Scalar chain

Using §6 forward values (\(0.5\) Huber slope, \(\sigma'(z_2) = 0.2273\), \(w_2 = 1.0\), \(\sigma'(z_1) = 0.2350\), \(x = 1.0\)), what is \(\partial L/\partial w_1\)?
  1. 0.0707 — the chain ended one layer early at \(w_2\)
  2. 0.0267 — the full chain \(0.5 × 0.2273 × 1.0 × 0.2350 × 1.0\)
  3. 0.1136 — the gradient at \(z_2\), two layers early
  4. 0.2004 — the loss value, not a gradient

Q8 · Why XOR needs depth

Which argument shows no single line separates XOR?
  1. XOR needs a hidden layer because sigmoid saturates at the extremes
  2. XOR needs a hidden layer because zero init collapses every layer
  3. XOR needs a hidden layer because BCE loss requires two outputs
  4. XOR needs a hidden layer because (0,0) forces b < 0 while the singletons force w1, w2 > −b, so (1,1) lands above threshold against its label

Q9 · XOR fan-out

Using §7 tables (\(\partial L/\partial z_1 = −0.0444\), \(\partial L/\partial z_2 = −0.0565\), \(w_{1,1} = 1.0\), \(w_{2,1} = −0.5\)), what is \(\partial L/\partial x_1\)?
  1. −0.0444 — path 1 only, fan-out forgotten
  2. −0.0162 — both paths summed: \(−0.0444 × 1.0 + (−0.0565) × (−0.5)\)
  3. −0.0726 — both paths subtracted, sign on \(w_{2,1}\) dropped
  4. −0.2260 — the gradient at \(z_3\), backprop never started

Hands-on

  1. Sketch the bump \(g(x) = \sigma(10(x-1)) - \sigma(10(x-2))\). Predict its height and width before you plot it, then check: it is about one unit tall and one unit wide. This is the sigmoid step the UAT stacks.
  2. Run the §3.1 experiment. Then change W2 to torch.tensor([[1.0, -1.0]], requires_grad=True) and run again. Predict whether the two hidden rows still match. They split, because the upstream gradients now differ; identical rows alone do not keep the tie when the next layer treats them differently.
  3. Run \(w \gets w - 1.1 \times 2w\) from \(w=3\) for three steps. Predict whether the loss falls after the second step. It does not: 9, 12.96, 18.66, 26.87. The gradient changes sign with \(w\), and the fixed step keeps overshooting.
  4. Run the §5.1 snippet. Predict which of w.grad, a.grad, z.grad is None before you run it. Check 0.0799 on the leaf; the other two are None.
  5. Rebuild §6's graph in torch. Predict each of the six printed values, then run the code in §6.1. Change \(x\) to 2 and predict whether \(\partial L/\partial w_1\) doubles before you rerun; it does not, because \(z_1 = 1\) changes the sigmoid slope.
  6. Count the 2-2-1 parameters from §7: \(4 + 2 + 2 + 1 = 9\). Then run one SGD step with \(\eta = 0.1\) on the XOR weights. Predict the sign of each weight change from the gradient table before you run it.

One-line understanding. A wide-enough net can draw the shape, a broken-symmetry start lets it, and backprop walks the graph backward multiplying local rates and summing where paths split.