The forward pass in matrix form

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 φ\varphi 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 10⋅4=4010 \cdot 4 = 40 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.

Three rectangles in a row, separated by a multiplication sign and an equals sign. The first, labelled X, is divided into six rows and carries below it the shape B by d zero; its third row is shaded green and marked on the left with the letter i. The second, labelled W one transpose, is divided into four columns and carries below it the shape d zero by d one; its second column is shaded green and marked below with the letter j. The third, labelled Z one, has the six rows of the first and the four columns of the second, and carries below it the shape B by d one; of it only the entry where the third row and the second column cross is shaded. The d zero appears in green in the first two shapes and does not appear in the third.
The dimension the two factors share is the only one that vanishes from the result. Each entry on the right is one neuron's opinion of one example, and adding one more example adds a row on the left and another on the right, without touching a single weight.

A whole layer in a single product

Fix any layer and call its width dld_l, the number of neurons it holds. Layer ll receives dl−1d_{l-1} numbers and hands back dld_l, so its weights form a matrix W(l)∈Rdl×dl−1\mathbf{W}^{(l)} \in \mathbb{R}^{d_l \times d_{l-1}} and its biases a vector b(l)∈Rdl\mathbf{b}^{(l)} \in \mathbb{R}^{d_l}, one per neuron. For a single example, the previous lesson already wrote out the result:

z(l)=W(l)h(l−1)+b(l),h(l)=φ(z(l)),\mathbf{z}^{(l)} = \mathbf{W}^{(l)}\mathbf{h}^{(l-1)} + \mathbf{b}^{(l)}, \qquad \mathbf{h}^{(l)} = \varphi\left(\mathbf{z}^{(l)}\right),

with h(l−1)∈Rdl−1\mathbf{h}^{(l-1)} \in \mathbb{R}^{d_{l-1}}, z(l),h(l)∈Rdl\mathbf{z}^{(l)}, \mathbf{h}^{(l)} \in \mathbb{R}^{d_l}, and φ\varphi 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 00, one that computes nothing at all, h(0)=x\mathbf{h}^{(0)} = \mathbf{x}. That makes d0d_0 the dimension of the input, not the width of any actual layer of neurons. For the reviews above, h(0)\mathbf{h}^{(0)} is the eight counts of the bag of words, and d0=8d_0 = 8.

What's worth seeing is that there's nothing new hiding inside that product. Expand the jj-th coordinate:

zj(l)=∑k=1dl−1Wjk(l) hk(l−1)+bj(l).z^{(l)}_j = \sum_{k=1}^{d_{l-1}} \mathbf{W}^{(l)}_{jk}\, h^{(l-1)}_k + b^{(l)}_j.

That sum is exactly the neuron from the lesson on the artificial neuron, with row jj of W(l)\mathbf{W}^{(l)} playing the role of the weight vector. The matrix product doesn't add any new operation: it groups together the dld_l 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: dl⋅dl−1d_l \cdot d_{l-1} 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 BB, and store the BB examples in a matrix X∈RB×d0\mathbf{X} \in \mathbb{R}^{B \times d_0}, 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, H(l)∈RB×dl\mathbf{H}^{(l)} \in \mathbb{R}^{B \times d_l}, row ii of H(l−1)\mathbf{H}^{(l-1)} is example ii laid flat: it's (hi(l−1))⊤\left(\mathbf{h}^{(l-1)}_i\right)^{\top}, 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 ii, remembering that transposing a product reverses the order of its factors:

(zi(l))⊤=(W(l)hi(l−1))⊤+(b(l))⊤=(hi(l−1))⊤(W(l))⊤+(b(l))⊤,\left(\mathbf{z}^{(l)}_i\right)^{\top} = \left(\mathbf{W}^{(l)}\mathbf{h}^{(l-1)}_i\right)^{\top} + \left(\mathbf{b}^{(l)}\right)^{\top} = \left(\mathbf{h}^{(l-1)}_i\right)^{\top}\left(\mathbf{W}^{(l)}\right)^{\top} + \left(\mathbf{b}^{(l)}\right)^{\top},

which is an equality between two rows: on the left, row ii of Z(l)\mathbf{Z}^{(l)}; on the right, row ii of H(l−1)\mathbf{H}^{(l-1)} multiplied by that same matrix everyone else uses. Stacking all BB rows turns into a single product:

Z(l)=H(l−1)(W(l))⊤+1B(b(l))⊤,H(l)=φ(Z(l)),\mathbf{Z}^{(l)} = \mathbf{H}^{(l-1)}\left(\mathbf{W}^{(l)}\right)^{\top} + \mathbf{1}_B\left(\mathbf{b}^{(l)}\right)^{\top}, \qquad \mathbf{H}^{(l)} = \varphi\left(\mathbf{Z}^{(l)}\right),

with Z(l),H(l)∈RB×dl\mathbf{Z}^{(l)}, \mathbf{H}^{(l)} \in \mathbb{R}^{B \times d_l} and 1B∈RB\mathbf{1}_B \in \mathbb{R}^{B} 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. Z(l)\mathbf{Z}^{(l)} has BB rows and b(l)\mathbf{b}^{(l)} has dld_l coordinates: adding the two directly isn't even a valid operation between matrices, so what's really written is the product 1B(b(l))⊤\mathbf{1}_B\left(\mathbf{b}^{(l)}\right)^{\top}, the same row of biases repeated BB 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 (k,j)(k, j) entry of (W(l))⊤\left(\mathbf{W}^{(l)}\right)^{\top} is the (j,k)(j, k) entry of W(l)\mathbf{W}^{(l)}, so by the definition of the matrix product

[H(l−1)(W(l))⊤]ij=∑k=1dl−1Hik(l−1) Wjk(l),\left[\mathbf{H}^{(l-1)}\left(\mathbf{W}^{(l)}\right)^{\top}\right]_{ij} = \sum_{k=1}^{d_{l-1}} \mathbf{H}^{(l-1)}_{ik}\, \mathbf{W}^{(l)}_{jk},

where Hik(l−1)\mathbf{H}^{(l-1)}_{ik} is coordinate kk of example ii. The bias summand contributes

[1B(b(l))⊤]ij=1⋅bj(l)=bj(l),\left[\mathbf{1}_B\left(\mathbf{b}^{(l)}\right)^{\top}\right]_{ij} = 1 \cdot b^{(l)}_j = b^{(l)}_j,

the same number no matter which row ii you pick, which is exactly what "one bias per neuron" means. Add the two together, and the (i,j)(i, j) entry of Z(l)\mathbf{Z}^{(l)} is the pre-activation of neuron jj for example ii: the same sum from the previous section, term by term. The index kk is the one that gets summed over and vanishes; ii and jj 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 LL be the number of layers (including the output one), and let d0,d1,…,dLd_0, d_1, \dots, d_L be the widths of the chain, starting from the input. The forward pass is this recurrence:

H(0)=X,H(l)=φ(H(l−1)(W(l))⊤+1B(b(l))⊤)for l=1,…,L,\mathbf{H}^{(0)} = \mathbf{X}, \qquad \mathbf{H}^{(l)} = \varphi\left(\mathbf{H}^{(l-1)}\left(\mathbf{W}^{(l)}\right)^{\top} + \mathbf{1}_B\left(\mathbf{b}^{(l)}\right)^{\top}\right) \quad \text{for } l = 1, \dots, L,

and whatever comes out of the last layer is the network's prediction for all BB examples at once, Y^=H(L)∈RB×dL\hat{\mathbf{Y}} = \mathbf{H}^{(L)} \in \mathbb{R}^{B \times d_L}. 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: H(l−1)\mathbf{H}^{(l-1)} has dl−1d_{l-1} columns, and W(l)\mathbf{W}^{(l)} needs to match, or the product does not exist at all. The intermediate widths are yours to choose; d0d_0 comes from however the input is represented, and dLd_L from how many answers you want out. The second is that BB 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:

∑l=1L(dl⋅dl−1+dl).\sum_{l=1}^{L}\left(d_l \cdot d_{l-1} + d_l\right).

For the review network below, with d0=8d_0 = 8, d1=4d_1 = 4 and d2=1d_2 = 1, that works out to 4141 numbers. Swap in a vocabulary of 20 00020\,000 entries and a hidden layer of 128128 neurons instead, and the count jumps to 2 560 2572\,560\,257: almost all of it sitting in W(1)\mathbf{W}^{(1)}, none of it depending on BB.

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 time

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

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 00. 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 4 0964\,096 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 Z\mathbf{Z} and the product without a bias comes out to the same triple, −0.761-0.761, 3.2213.221, 0.2210.221, in both row 00 and row 127127, which is 1Bb⊤\mathbf{1}_B\mathbf{b}^{\top} 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 00 and 11. Run it and pay attention to the column of shapes, and to the last line.

import numpy as np

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")
numpy

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 1010 in the first dimension never gets touched anywhere and arrives at the end fully intact, while the second dimension keeps changing, 8→4→18 \to 4 \to 1, in step with the widths. The network carries exactly the 4141 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 0.50.5 (the highest is 0.4850.485, 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 300300 coordinates, has a hidden layer of 128128 neurons and an output layer of 22. 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 W(l)h(l−1)\mathbf{W}^{(l)}\mathbf{h}^{(l-1)}; for a whole batch it is H(l−1)(W(l))⊤\mathbf{H}^{(l-1)}\left(\mathbf{W}^{(l)}\right)^{\top}. Where does that transpose come from?

A network of LL layers chains H(l)=φ(H(l−1)(W(l))⊤+1B(b(l))⊤)\mathbf{H}^{(l)} = \varphi\left(\mathbf{H}^{(l-1)}\left(\mathbf{W}^{(l)}\right)^{\top} + \mathbf{1}_B\left(\mathbf{b}^{(l)}\right)^{\top}\right) starting from H(0)=X\mathbf{H}^{(0)} = \mathbf{X}. 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. H has shape (B, din)(B,\, d_{\text{in}}), W has shape (dout, din)(d_{\text{out}},\, d_{\text{in}}) (one row per neuron), and b has shape (dout,)(d_{\text{out}},); the output has shape (B, dout)(B,\, d_{\text{out}}).
  • forward(X, layers, phi) chains a list of (W, b) pairs starting from X, applying phi to 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 X\mathbf{X} into Y^\hat{\mathbf{Y}}, 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 0.4990.499 or 0.0360.036, 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 Y^\hat{\mathbf{Y}} 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.

Was this helpful?