Implementing an MLP from scratch
30 min read
Backpropagation hands over the gradient of a whole network in a single backward pass, and the previous lesson derived it without skipping a single step. What it produced, though, was a list of numbers on a screen. A printed gradient moves no weight on its own: it's an instruction (which way is down, and how much of it falls to each parameter), and somebody still has to carry that out, a thousand times over, before a network goes from answering anything at all to answering well. That loop hasn't been written in any cell of this course yet. Its four pieces have, spread across four lessons of this block, and putting them together also settles a debt open since the very beginning: the eight weights of the review neuron and the seven that separate the XOR (exclusive or) phrases were written by me, by looking at the data alone.
The place to check all this has been chosen since then. The lesson on XOR and hidden layers proved that no single line leaves it's good and not bad on one side and the other two phrases on the other, then solved all four with a hidden layer whose seven numbers I picked by hand. If the loop actually works, the network has to find weights that get all four right (not necessarily mine) starting from numbers that mean nothing at all. And if it doesn't work, you'll notice immediately: with four examples and two coordinates, there's nowhere left for a fault to hide.
The four pieces aren't loose ends. They fall into an order, and that order closes back on itself.
From the example to the batch: the backward pass, stacked by rows
The previous lesson derived the backward pass for one example, in column vectors. The forward pass has been written for a whole batch since the lesson on the forward pass: one example per row in , and from there layer by layer. The backward pass still needs that same treatment, and it comes down to two changes: one of shape, one of size.
The shape change is stacking. Call the batch error at layer : row is the of example , laid on its side, exactly the way row of is its activation laid on its side. Capital for the batch, lower case for the example, this block's usual convention. The output layer enters exactly as before, only now with rows at once:
And the recurrence transposes whole. Take the previous lesson's version, for example , , and transpose it. Transposing a product reverses the order of its factors, and the Hadamard product works coordinate by coordinate, so it doesn't even notice:
which is row of what you're after, written using rows of and of . Stack all of them, and you get
where what's worth noticing is what disappeared: the transpose. That's neither an oversight nor a different convention. The forward pass gained a transpose when it moved to rows ( became ), and the backward pass loses it for the exact same reason, since it started from a matrix that was already transposed. Both times, it's the same product read with the examples sitting in rows.
The size change is the , and it comes straight from how the loss is defined. is the mean of the across the examples, and differentiating a mean means taking the mean of the derivatives, so the gradient of the batch is the mean of the gradients the previous lesson would have handed you one at a time. That mean turns out to be another matrix product:
with the vector of ones from the lesson on the forward pass. The sum over examples the previous lesson wrote out by hand is exactly the index the matrix product runs over and discards, which is why the result has the shape of and carries no memory of at all.
With that in hand, the descent rule from the lesson on gradient descent doesn't change by a single letter: , applied to each matrix and each bias vector on its own. One concession, worth saying out loud: every step here looks at the entire training set (the labelled examples the network has, in this case the four phrases), exactly as that lesson did with the ten reviews. Splitting it into pieces and taking one step per piece only becomes necessary once the set grows large, and this one has all of four rows.
Everything the four-phrase network needs is on the table now: the smallest multilayer perceptron (MLP) worth trying it on, with two inputs, four hidden neurons using and one output neuron with a sigmoid, that is, and . Its forward pass takes one line per layer,
with and , and its backward pass is the three formulas above with . Twelve weights and five biases: seventeen numbers that all need to go somewhere before the very first step.
Four identical neurons are a single neuron
Deciding where those seventeen numbers start is the one call no earlier formula makes, and the comfortable answer, all of them zero, fails outright. Follow the arithmetic through with that same network and seventeen zeros. Then , and since , also ; from there , which is for all four phrases. Feed that into the two gradients above:
the first because is null and the second because is. With , the two gradients of the first layer vanish too, so the step leaves the network exactly where it was, and the next one finds the same starting point all over again. The hidden layer will never move.
And zero isn't really the problem, it's a particular case of something much wider. Take two hidden neurons and that start out with the same input weights, the same bias and the same output weight. The forward pass gives them the same pre-activation and the same activation, and ; the backward pass makes them receive the same error, ; and with the same error and the same activation, all three of their gradients coincide:
Subtracting equal quantities from equal quantities preserves the equality, so the two neurons come out of the step exactly as identical as they went in. And out of the next one after that.
That single line is the very one from the lesson on XOR and hidden layers, which proved that none of them work: the hidden layer effectively has the width of one neuron, no matter how loudly claims four.
Seen from the seventeen numbers rather than from the plane, the equality turns into a trap shaped like a subspace. A configuration with all four neurons identical has only five real numbers dressed up as seventeen: one row of , one bias, one weight of , and . Together they form a five-dimensional subspace sitting inside the seventeen, and the gradient always comes out tangent to it.
Nothing in the algorithm ever breaks that equality on its own, because everything it does is deterministic: the break has to come from outside, and the cheapest source of "different" is a random draw. Choosing those seventeen starting numbers is the initialisation, the one point in training where chance actually enters. Its role isn't quite what it looks like: it contributes no information whatsoever about the phrases, it only pulls some neurons apart from others so the gradient can push them down different paths. How large those numbers are matters too (tiny weights end up looking too much like the case above), and the third cell measures exactly that. Which scale to pick in general depends on the depth of the network, and it comes back in Block 3, on recurrent networks, once the problem stops being a minor inconvenience and becomes the whole subject.
The loop in NumPy, and the loss falling
The first cell turns the formulas above into NumPy: the forward pass, the loss and the backward pass, with no loop yet. Run it and look at the column of shapes, then the last line.
# The four XOR phrases: x1 is +1 if "not" appears and x2 is +1 if the
# evaluative word is "good". The label is 1 if the phrase speaks well.
X = np.array([[-1., 1.], [-1., -1.], [1., 1.], [1., -1.]])
Y = np.array([[1.], [0.], [0.], [1.]])
B, d0, d1, d2 = 4, 2, 4, 1
rng = np.random.default_rng(0) # fixed seed: you will see these numbers
W1, b1 = rng.normal(size=(d1, d0)), np.zeros(d1)
W2, b2 = rng.normal(size=(d2, d1)), np.zeros(d2)
def forward(X, W1, b1, W2, b2):
H1 = np.tanh(X @ W1.T + b1) # (B, d1)
return H1, 1.0 / (1.0 + np.exp(-(H1 @ W2.T + b2)))
def loss(Y_hat, Y):
p = np.clip(Y_hat, 1e-12, 1.0 - 1e-12) # the logarithm does not take zero
return -np.mean(Y * np.log(p) + (1.0 - Y) * np.log(1.0 - p))
def backward(X, Y, H1, Y_hat, W2):
D2 = Y_hat - Y # (B, d2): the output enters with no factor
D1 = (D2 @ W2) * (1.0 - H1 ** 2) # transport and mask: (B, d1)
return (D1.T @ X) / B, D1.sum(0) / B, (D2.T @ H1) / B, D2.sum(0) / B
H1, Y_hat = forward(X, W1, b1, W2, b2)
gW1, gb1, gW2, gb2 = backward(X, Y, H1, Y_hat, W2)
for name, A in [("X", X), ("H(1)", H1), ("Ŷ", Y_hat),
("∇W(1)", gW1), ("∇b(1)", gb1), ("∇W(2)", gW2)]:
print("%-6s %s" % (name, A.shape))
print("initial loss: %.4f" % loss(Y_hat, Y))
# The 1/B in plain sight: the batch gradient is the mean of the four one at a time.
one_at_a_time = np.zeros((d1, d0))
for i in range(B):
d2_i = Y_hat[i] - Y[i]
d1_i = (W2.T @ d2_i) * (1.0 - H1[i] ** 2) # the same δ, as a column
one_at_a_time += np.outer(d1_i, X[i]) / B
print("\n∇W(1) of the batch =", np.round(gW1, 4).tolist())
print("difference from the mean of the four outer products: %.1e"
% np.abs(one_at_a_time - gW1).max())
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
The column of shapes holds the whole division of labour: the examples show up in , in and in , and in none of the three gradients, which come out with the shape of their own parameters instead. The last line checks the and returns zero: the four outer products from the previous lesson, averaged by hand, match the matrix product above entry for entry.
The second cell puts that into a loop and repeats it two thousand times, with . Run it and watch the loss fall.
phrases = ["it's good", "it's bad", "not good", "not bad"]
X = np.array([[-1., 1.], [-1., -1.], [1., 1.], [1., -1.]])
Y = np.array([[1.], [0.], [0.], [1.]])
B, eta, steps = 4, 0.5, 2000
rng = np.random.default_rng(0) # the same network as the previous cell
W1, b1 = rng.normal(size=(4, 2)), np.zeros(4)
W2, b2 = rng.normal(size=(1, 4)), np.zeros(1)
for t in range(steps + 1):
H1 = np.tanh(X @ W1.T + b1) # forward pass
Y_hat = 1.0 / (1.0 + np.exp(-(H1 @ W2.T + b2)))
if t in (0, 25, 50, 100, 200, 500, 1000, 2000):
p = np.clip(Y_hat, 1e-12, 1.0 - 1e-12)
print("step %4d loss %.4f correct %d of 4"
% (t, -np.mean(Y * np.log(p) + (1.0 - Y) * np.log(1.0 - p)),
int(((Y_hat >= 0.5) == (Y == 1.0)).sum())))
if t == steps:
break
D2 = Y_hat - Y # backward pass
D1 = (D2 @ W2) * (1.0 - H1 ** 2)
W1 -= eta * (D1.T @ X) / B # and descent
b1 -= eta * D1.sum(0) / B
W2 -= eta * (D2.T @ H1) / B
b2 -= eta * D2.sum(0) / B
print()
print("neuron w1 w2 bias output weight")
for j in range(4):
print(" %d %+5.2f %+5.2f %+5.2f %+5.2f"
% (j + 1, W1[j, 0], W1[j, 1], b1[j], W2[0, j]))
print()
contribution = W2[0] * H1 # what each neuron puts into z(2)
for phrase, a in zip(phrases, contribution):
print("%-9s neurons 2 and 4: %+5.2f neurons 1 and 3: %+5.2f"
% (phrase, a[1] + a[3], a[0] + a[2]))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
The loss starts at and ends at , and all four correct answers show up before step . The debt is paid off: nobody told the network that not flips the sign of whatever follows, and the weights it found aren't even the ones I wrote in the lesson on XOR and hidden layers.
They're better than that: they're the same ones, rediscovered independently. Look at neurons and . Their weights point in almost exactly the same direction ( and , both close to ), their biases are opposite ( and ), and so are their output weights ( and ). That's the strip from the lesson on XOR and hidden layers: two parallel lines and a subtraction between them. The last table confirms it: that pair contributes to the two positive phrases and only to the other two. And the pair and does the same on the other diagonal with the sign reversed, subtracting exactly where the first pair stays quiet.
What's left is deciding when to stop, and it's the flimsiest of the three decisions by far. Here I stop at a round number of steps because the curve has flattened out; I could equally well have stopped once the loss stopped falling by a thousandth, or once the norm of the gradient dropped below some threshold, and all three are rules of thumb. Correct-answer counts are no use here: by step there are already four of them, and there they stay, while the loss keeps falling by a factor of three hundred afterwards. And all three rules share a limit none of them can fix, because all three only ever look at these same four phrases.
The third cell goes back to the start of the loop and changes exactly one thing: where the weights come from. Six scales at the same seed, and below them, four genuinely identical neurons.
X = np.array([[-1., 1.], [-1., -1.], [1., 1.], [1., -1.]])
Y = np.array([[1.], [0.], [0.], [1.]])
B, eta, steps = 4, 0.5, 1000
def train(scale, identical=False):
rng = np.random.default_rng(0) # the same seed in all of them
W1, W2 = rng.normal(size=(4, 2)) * scale, rng.normal(size=(1, 4)) * scale
if identical: # four copies of one and the same neuron
W1, W2 = np.repeat(W1[:1], 4, axis=0), np.full((1, 4), W2[0, 0])
b1, b2 = np.zeros(4), np.zeros(1)
curve = []
for t in range(steps + 1):
H1 = np.tanh(X @ W1.T + b1)
Y_hat = 1.0 / (1.0 + np.exp(-(H1 @ W2.T + b2)))
p = np.clip(Y_hat, 1e-12, 1.0 - 1e-12)
curve.append(-np.mean(Y * np.log(p) + (1.0 - Y) * np.log(1.0 - p)))
if t == steps:
break
D2 = Y_hat - Y
D1 = (D2 @ W2) * (1.0 - H1 ** 2)
W1 -= eta * (D1.T @ X) / B
b1 -= eta * D1.sum(0) / B
W2 -= eta * (D2.T @ H1) / B
b2 -= eta * D2.sum(0) / B
return curve, int(((Y_hat >= 0.5) == (Y == 1.0)).sum()), W1
print("%7s %8s %8s %8s %9s %s"
% ("scale", "step 0", "step 50", "step 200", "step 1000", "correct"))
for scale in [0.0, 0.01, 0.1, 0.5, 1.0, 10.0]:
curve, correct, _ = train(scale)
print("%7.2f %8.4f %8.4f %8.4f %9.4f %d of 4"
% (scale, curve[0], curve[50], curve[200], curve[1000], correct))
# And symmetry with no zeros: four copies of one neuron, with normal weights.
curve, correct, W1 = train(1.0, identical=True)
print()
print("four identical neurons: loss %.4f -> %.4f, %d of 4"
% (curve[0], curve[-1], correct))
print("W(1) after 1000 steps:", np.round(W1, 4).tolist())
print("are the four rows still the same one?", bool(np.allclose(W1, W1[0])))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
The first two rows of the table never budge from , which is : the network answers to all four phrases at the start and is still answering the same thing a thousand steps later. The second of the two is the one that actually teaches something, since its scale is rather than zero: the four neurons really are different from each other. What happens instead is that each layer receives a gradient multiplied by the other one's scale, and with two small factors stacked together, everything advances hundreds of times more slowly: that network isn't stuck at all, it still reaches the four phrases, only around step fifty thousand instead. At the other end, the scale starts at (confidently wrong) and recovers anyway: with only two layers that comes cheap, and once depth increases, it stops being cheap at all.
The last three lines show symmetry with no zeros anywhere in sight. All four neurons start with proper normal weights, only equal to one another, and a thousand steps later they're still the same row repeated four times over. The loss has fallen from to , which is exactly what a network that answers the same thing to everything achieves: four neurons working as one, and one neuron cannot do the job.
Test your intuition
Two questions (why zero freezes the network, and what can actually be read off a loss curve), plus a challenge that is the whole loop.
The network in this lesson starts with the four weights of and the eight of set to zero, and a thousand steps later it is exactly the same. Why?
Tick everything that is true about the loss curves the second and third cells print.
Select every correct option. This is graded all-or-nothing: there is no partial credit.
Write the whole loop for this lesson's network: inputs, a hidden layer of neurons with and one output with the sigmoid, over a batch of examples.
gradients(X, Y, W1, b1, W2, b2)returns the four gradients of in the order(gW1, gb1, gW2, gb2), each one with the shape of the parameter it differentiates.train(X, Y, W1, b1, W2, b2, eta, steps)takesstepssteps of descent and returns(curve, W1, b1, W2, b2), wherecurveholdssteps + 1losses: one before each step and one at the end.
train may not write into the matrices it receives.
The first run downloads the Python interpreter (~15 MB); after that it stays in the browser cache. This challenge is much easier to solve on a physical keyboard: on a phone, read it and come back later.
The network gets the four phrases it has seen right, and there is no fifth to check it against. That isn't a defect of the training, it's that here getting it right and genuinely learning are indistinguishable, since with only four examples, memorising the answer and understanding the rule produce exactly the same numbers. Nothing this loop prints says what the network would do with not that good, and the loop has no way of ever finding out.
There's also a debt this whole business with the weights has left sitting in plain sight. I invented the two input coordinates myself: whether not appears, and whether the evaluative word is good or bad, are two questions somebody had to formulate after reading the four phrases, and an arbitrary piece of text doesn't arrive already packaged into two numbers. It did arrive, though, as the bag of words from the previous block. Mounting this same loop on a few hundred reviews represented that way, measuring accuracy for the first time on reviews the network has never seen, and finding out what's left of it, is the next lesson, the sentiment classification project.
Further reading2 sources · 1 paper, 1 interactive
Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.
- Understanding the difficulty of training deep feedforward neural networks
The paper this lesson points at when it defers "which scale" to Block 3: it sets the initialisation scale from each layer's width so the signal neither saturates nor dies out. It does not treat symmetry; this lesson already has.
- A Neural Network Playground
The loop you wrote above, with knobs: move the learning rate, the layers, the neurons and the activation, and watch the loss fall. It works on points in the plane rather than text, and brings in regularisation from outside this course.