The forward pass in matrix form
30 min read
The previous lesson, on XOR and hidden layers, left the network complete but unwritten. Complete, because no piece is missing: neurons grouped into layers, each with its own weights and bias, and a sitting between them that stops the stack from collapsing back into a single neuron. Unwritten, because every pre-activation there was computed on its own, and a pile of loose sums isn't a formula: you can't do algebra with it, and you can't program it as a single thing. The walk from input to output has a name, the forward pass, and one fixed form it's always written in. This lesson is that form.
Put some real numbers on it before writing anything down. Take the ten reviews from the lesson on the artificial neuron, with that same eight-entry vocabulary. What goes into the network isn't the review itself but its bag of words: a vector of eight coordinates, one per vocabulary entry, holding how many times that entry shows up in the review. That's all the network ever gets to read. Add a hidden layer of four neurons on top: each one looks at those eight coordinates and returns a number, so the ten reviews together produce hidden pre-activations, each one a sum of eight products plus a bias. Taken one at a time, that's forty separate computations, or two nested loops if you'd rather let the computer grind through them. All forty fit into a single matrix product instead, and the next layer comes out of another product of the same kind. What needs pinning down is what multiplies what, and where the ten reviews actually go in. Because they're going in together: a group of examples running through the network at once is called a batch, and this lesson's batch has ten.
That product isn't shorthand for the two loops: it's the exact same computation, with all the bookkeeping folded into the shapes of the arrays. Look at where each number actually ends up.
A whole layer in a single product
Fix any layer and call its width , the number of neurons it holds. Layer receives numbers and hands back , so its weights form a matrix and its biases a vector , one per neuron. For a single example, the previous lesson already wrote out the result:
with , , and applied coordinate by coordinate. The vectors are columns, as they are everywhere in this course, which is why the product is written in that order.
Each layer leans on the one before it, so the chain needs somewhere to start. The first layer has nothing in front of it: what it receives is the raw example, and the convention is to count the input as layer , one that computes nothing at all, . That makes the dimension of the input, not the width of any actual layer of neurons. For the reviews above, is the eight counts of the bag of words, and .
What's worth seeing is that there's nothing new hiding inside that product. Expand the -th coordinate:
That sum is exactly the neuron from the lesson on the artificial neuron, with row of playing the role of the weight vector. The matrix product doesn't add any new operation: it groups together the sums that were already there, and all it really contributes is that you never have to write them out one by one. The cost also comes from there: products per example, a figure that grows with the size of the layer even though the line of code writing it stays exactly one.
The batch enters from the left, and that is where the transpose comes from
Now bring in the examples. Call the batch size , and store the examples in a matrix , one example per row. That ordering, batch first, is the one this whole course uses, and it's the one NumPy uses too. With the same layout for whatever comes out of each layer, , row of is example laid flat: it's , transposed, because the vector itself is a column and a matrix row is not.
That transpose is the one that propagates through everything else. Transpose the equation above for example , remembering that transposing a product reverses the order of its factors:
which is an equality between two rows: on the left, row of ; on the right, row of multiplied by that same matrix everyone else uses. Stacking all rows turns into a single product:
with and the vector of ones. That last factor deserves its own sentence, since it's the one
thing in the formula that never shows up in the code. has rows and
has coordinates: adding the two directly isn't even a valid operation
between matrices, so what's really written is the product
, the same row of biases repeated times over.
NumPy does that repetition automatically when you write + b, without ever building the matrix,
which is exactly why it's worth seeing once what's actually happening underneath.
The check, entry by entry
You can see the product holds the same sums as before by checking any single entry. The entry of is the entry of , so by the definition of the matrix product
where is coordinate of example . The bias summand contributes
the same number no matter which row you pick, which is exactly what "one bias per neuron" means. Add the two together, and the entry of is the pre-activation of neuron for example : the same sum from the previous section, term by term. The index is the one that gets summed over and vanishes; and are the ones that survive, and they're the two dimensions of the result.
From input to output, one layer after another
With one layer solved, the network amounts to repeating it. Let be the number of layers (including the output one), and let be the widths of the chain, starting from the input. The forward pass is this recurrence:
and whatever comes out of the last layer is the network's prediction for all examples at once, . That chain is the entirety of what a multilayer perceptron (MLP) computes: there are no further steps, and nothing later in this block adds any either. What changes from here on is only the numbers sitting inside the matrices.
Two things follow directly from the shapes. The first is the only condition you actually have to respect when designing a network: has columns, and needs to match, or the product does not exist at all. The intermediate widths are yours to choose; comes from however the input is represented, and from how many answers you want out. The second is that never appears in a single weight matrix. How many examples run through at once is a decision made by whoever runs the network, not a property of the network itself, and that's where the parameter count comes from:
For the review network below, with , and , that works out to numbers. Swap in a vocabulary of entries and a hidden layer of neurons instead, and the count jumps to : almost all of it sitting in , none of it depending on .
The loop, the product and the review network in NumPy
The first cell puts the two versions head to head on a layer of thirty-two neurons: the double loop that follows directly from the definition, and the matrix product. Run it and check three things: whether the two agree, how long each one actually takes, and what happens when the product is written without transposing.
import numpy as np
np.random.seed(0)
# --- One layer of 32 neurons over a batch of 128 examples of 64 coordinates.
B, d0, d1 = 128, 64, 32
X = np.random.randn(B, d0)
W = np.random.randn(d1, d0)
b = np.random.randn(d1)
print("X", X.shape, " W", W.shape, " b", b.shape, "->", B * d1, "preactivations")
no_bias = X @ W.T # used below; also warms up the compiled routine
# 1. Neuron by neuron and example by example, as the definition is written.
t0 = time.perf_counter()
Z_loop = np.zeros((B, d1))
for i in range(B):
for j in range(d1):
Z_loop[i, j] = W[j] @ X[i] + b[j]
t1 = time.perf_counter()
# 2. The same computation, one product.
Z = X @ W.T + b
t2 = time.perf_counter()
print("Z", Z.shape, " same numbers?", bool(np.allclose(Z, Z_loop)))
print("largest difference:", "%.1e" % np.abs(Z - Z_loop).max())
print("loop: %8.2f ms" % ((t1 - t0) * 1e3))
print("product: %8.2f ms" % ((t2 - t1) * 1e3))
print("factor: %8.0f" % ((t1 - t0) / (t2 - t1)))
print()
# 3. Orientation is not negotiable: without transposing, the product does not exist.
try:
X @ W
except ValueError as e:
print("X @ W ->", e)
print()
# 4. The bias is spread by columns: one per neuron, the same in the 128 rows.
print("b, first 3: ", np.round(b[:3], 3))
print("Z - X @ W.T, row 0: ", np.round((Z - no_bias)[0, :3], 3))
print("Z - X @ W.T, row 127: ", np.round((Z - no_bias)[-1, :3], 3))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
The two versions produce the same numbers: the largest difference the cell prints out is . But getting there doesn't cost the same at all: the product runs between one and two orders of magnitude faster, and the exact factor depends on the machine and the browser you're using. Where that speed-up comes from doesn't depend on either one, though. It's not that the product performs fewer operations (it does exactly the same number), but that Python works through the turns of the loop one at a time, while the product runs them inside a compiled routine that never goes back through the interpreter.
The last two experiments are the two classic shape traps. X @ W doesn't exist, and NumPy tells you
so by naming the exact two dimensions that fail to line up; and the difference between
and the product without a bias comes out to the same triple, , , , in both row
and row , which is made visible.
The second cell builds the whole network over the ten reviews: eight vocabulary entries, four hidden neurons with ReLU (rectified linear unit), and one output neuron with a sigmoid, so what it hands back is a number between and . Run it and pay attention to the column of shapes, and to the last line.
np.random.seed(0)
# --- The usual ten reviews and their same vocabulary of eight entries.
reviews = [
"the movie is fun and i recommend it",
"the movie is slow and boring",
"good movie i recommend it",
"bad movie very slow",
"fun and good",
"the movie is bad",
"boring and slow",
"i recommend it",
"good movie but slow",
"fun and i recommend it",
]
labels = np.array([1, 0, 1, 0, 1, 0, 0, 1, 0, 1])
V = ["bad", "boring", "fun", "good", "movie", "recommend", "slow", "the"]
X = np.array([[text.split().count(e) for e in V] for text in reviews], dtype=float)
# Eight entries -> four hidden neurons -> one output. Weights at random.
d0, d1, d2 = len(V), 4, 1
W1 = np.random.randn(d1, d0) * 0.5
b1 = np.zeros(d1)
W2 = np.random.randn(d2, d1) * 0.5
b2 = np.zeros(d2)
H0 = X # (B, d0)
Z1 = H0 @ W1.T + b1 # (B, d1)
H1 = np.maximum(Z1, 0.0) # ReLU, coordinate by coordinate
Z2 = H1 @ W2.T + b2 # (B, d2)
Y = 1.0 / (1.0 + np.exp(-Z2)) # sigmoid on the output layer
for name, A in [("H(0)", H0), ("Z(1)", Z1), ("H(1)", H1), ("Z(2)", Z2), ("Y", Y)]:
print(name, A.shape)
print("network parameters:", W1.size + b1.size + W2.size + b2.size)
print()
for text, y in zip(reviews, Y[:, 0]):
print(text.ljust(37), "%.3f" % y)
print()
print("correct:", int(((Y[:, 0] >= 0.5) == (labels == 1)).sum()), "of 10")
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
Five lines of code for the entire network, and the reason is right there in the column of shapes: the in the first dimension never gets touched anywhere and arrives at the end fully intact, while the second dimension keeps changing, , in step with the widths. The network carries exactly the parameters the count above predicted.
And now the last line, the one that actually matters. Five correct out of ten. Every single output
sits below (the highest is , for i recommend it), so the network answers no to
all ten reviews and happens to get the five negative ones right. That's the score of someone who
reads nothing at all and always says no. The weights came straight out of np.random.randn, and
there was never any reason to expect otherwise.
Test your intuition
Three questions (how many numbers a network actually stores, where the transpose comes from, and what the chain guarantees), plus a challenge that's the entire forward pass.
A network receives vectors of coordinates, has a hidden layer of neurons and an output layer of . How many numbers does it store in total, between weights and biases?
A margin of ±0 is accepted.
For a single example, the layer is ; for a whole batch it is . Where does that transpose come from?
A network of layers chains starting from . Tick everything that is true.
Select every correct option. This is graded all-or-nothing: there is no partial credit.
Write the forward pass in its matrix form: for a whole batch at once, without looping over the examples one by one.
layer(H, W, b)returns the pre-activation of a layer.Hhas shape ,Whas shape (one row per neuron), andbhas shape ; the output has shape .forward(X, layers, phi)chains a list of(W, b)pairs starting fromX, applyingphito the output of each layer.
Neither function may write into the arrays 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 can now answer, whether you feed it one example or a thousand, and what it answers is worthless. Those two facts fit together perfectly, because the forward pass carries no notion of correctness at all: it's a chain of products that turns into , and it would run exactly the same way with the right weights, if anyone actually knew what they were. Correcting the weights means measuring them first, and measuring isn't the same as counting correct answers. The tally on the last line scores exactly the same whether the output is or , and it doesn't budge when a weight shifts slightly: it can't tell apart being wrong by a hair from being wrong completely, and that distinction is the only thing that could actually tell you which way to move a weight.
What's needed is a number that can tell them apart. A single number for the whole batch, one that grows as drifts away from the labels, falls as it moves closer, and changes continuously enough to actually have a derivative. How that number gets built, why the first candidate most people reach for (squaring the difference) is the right call for some tasks and a bad idea for classification, and what takes its place instead, is the next lesson, on loss functions: mean squared error and cross-entropy.