Learn machine learning from scratch

Start at chapter 1 and build up: what a model is, how it measures its own mistakes, and how it learns. Everything is worked out in plain Python, with the maths shown rather than assumed. Two shorter tracks hold reference notes on the maths and on modern architectures.

Track Progress: 0 Completed
0%
Core CourseLEVEL 2 · INTERMEDIATEChapter 6

From Numbers to Arrays: Batches and Matrix Gradients

Chapter 5's engine builds one object per number, which is far too slow for real data. Teach it to work on whole arrays instead, and learn the three new rules that makes necessary: matrix products, broadcasting and sums.

51 min Prerequisites: Chapters 2 and 5; NumPy arrays at the level of Chapter 2
Inspect Architecture: Logistic regression

1. The idea

Chapter 5's engine works, but count what it builds. The picnic loss for Chapter 3's six recorded days creates 193 Value objects, about 31 per day. A thousand days would mean 31,007 objects, each with its own little Python function to call on the way back. On the laptop this chapter was written on, that takes about 80 milliseconds for a single gradient. Real datasets have millions of examples and real networks need thousands of gradient steps, so this won't do.

The fix is the step Chapter 2 took for the forward pass: stop handling one number at a time and hand whole arrays to NumPy. Here we make that change to the engine. A Tensor is a Value whose data is an array, and its grad is an array of exactly the same shape, holding ∂L/∂(each entry). The six days become one 6 × 3 matrix X, the neuron's forward pass becomes one line, Z = X Wᵀ + b, and the graph has 30 Tensors whether the batch holds 6 days or 100,000. The same thousand-day gradient now takes about a millisecond.

Most of Chapter 5 carries over unchanged. Elementwise operations like +, ×, exp and log use the same local slopes, applied entry by entry. Three things are new. A matrix product needs its own backward rule, which turns out to be two more matrix products: dW = dZᵀ X. Broadcasting, where one bias is added to every row, is secretly copying, and Chapter 5's 'shared values add their gradients' turns that into a sum over rows. Finally, a sum or mean folds an array into one number, so its backward pass spreads the slope back out.

As always, we check the engine against what we already know. Applied to the rides it gives −4.5 and −0.5 again. On the six picnic days it reproduces Chapter 3's mean cross-entropy of 0.3256, and its gradient matches both central differences and the batch version of Chapter 4's shortcut, (P − Y)ᵀ X / N. Then we stack Chapter 2's three-neuron layer under an output neuron and get gradients for all 16 knobs of a two-layer network from one backward() call.

Three rows of matrix diagrams for the six picnic days. Forward: X, 6 by 3, times W transposed, 3 by 1, plus b, a single number, gives Z, 6 by 1. Backward for the weights: dZ transposed, 1 by 6, times X, 6 by 3, gives dW, 1 by 3, the same shape as W. Backward for the bias: the six rows of dZ summed give db, a single number. A note gives the values for the picnic loss: dW = [−0.008, 0.050, 0.058], db = 0.050.Three rows of matrix diagrams for the six picnic days. Forward: X, 6 by 3, times W transposed, 3 by 1, plus b, a single number, gives Z, 6 by 1. Backward for the weights: dZ transposed, 1 by 6, times X, 6 by 3, gives dW, 1 by 3, the same shape as W. Backward for the bias: the six rows of dZ summed give db, a single number. A note gives the values for the picnic loss: dW = [−0.008, 0.050, 0.058], db = 0.050.
A batched neuron and its backward pass, drawn to shape. Each gradient comes out with the shape of the parameter it belongs to, which is how you can check a matrix gradient before computing a single number.

2. The math

The shape rule
T.grad has the same shape as T.data: T.grad[i, j] = ∂L/∂T[i, j] W is (1 × 3) ⇒ W.grad is (1 × 3) b is (1,) ⇒ b.grad is (1,)
Every entry of an array is a knob or an intermediate number in its own right, so it gets its own slope. If a gradient comes out with the wrong shape, there's a bug, and you can see it before checking any values.
A batch in one line (Chapter 2's convention)
Z = X Wᵀ + b X: (N × inputs) W: (neurons × inputs) b: (neurons) Z: (N × neurons) six picnic days, one neuron: (6 × 3)(3 × 1) + (1) → (6 × 1)
Each row of X is one day and each row of W is one neuron. Row i of Z holds every neuron's score for day i.
Elementwise operations: Chapter 5's slopes, entry by entry
C = A ⊙ B ⇒ dA = dC ⊙ B, dB = dC ⊙ A P = σ(Z) ⇒ dZ = dP ⊙ P ⊙ (1 − P) ReLU: dZ = dH ⊙ [Z > 0]
⊙ means 'multiply matching entries'. dA is shorthand for A.grad. Nothing new here: each entry follows the scalar rule on its own. ReLU's slope is 1 where its input was positive and 0 elsewhere.
The matrix product rule
C = A B ⇒ dA = dC Bᵀ dB = Aᵀ dC shapes: A (n × k), B (k × m), dC (n × m) ⇒ dC Bᵀ is (n × k) ✓ Aᵀ dC is (k × m) ✓
Derived entry by entry in the first derivation. If you forget which side the transpose goes on, let the shapes decide: only one arrangement produces an array the shape of A.
Transpose just transposes the gradient
C = Aᵀ ⇒ dA = (dC)ᵀ for Z = X Wᵀ + b: d(Wᵀ) = Xᵀ dZ (3 × 1) ⇒ dW = dZᵀ X (1 × 3)
Chapter 2 stores one neuron per row of W, so the forward pass uses Wᵀ. The engine handles that with a transpose node, and the weights' gradient comes out the right way round.
Broadcasting copies, so its backward pass sums
Z = X Wᵀ + b, b copied to all N rows ⇒ db = Σᵢ dZ[i, :] rides: pred = w · km + b with scalar w, b ⇒ dw = Σᵢ dpredᵢ · kmᵢ, db = Σᵢ dpredᵢ
A broadcast value is one knob used N times, and Chapter 5's rule says shared values add their gradients. The helper unbroadcast() does the adding, over whichever axes were stretched.
Sum and mean spread the slope back out
s = Σ A[i, j] ⇒ dA = ds · 1 (an array of ones) m = mean(A) = s / N ⇒ dA = (1/N) · 1 mean of 4 numbers: each entry's grad = 0.25
Every entry moves a sum one-for-one, so they all get the same slope. The mean divides it by the count, which is where the 1/N in every batch gradient comes from.
The rides, batched: same answer, smaller graph
L = mean((w · km + b − minutes)²) at (3, 5): L = 0.75 backward(): ∂L/∂w = −4.5 ∂L/∂b = −0.5 11 Tensors (Chapter 5: 33 Values)
The batch graph has a fixed number of nodes, one per operation in the formula, not one per operation per ride.
Six picnic days at once
Z = X Wᵀ + b, P = σ(Z) L = mean of the six cross-entropies = 0.3256 (as in Chapter 3) backward(): dW = [−0.0080, 0.0500, 0.0576] db = 0.0504 shortcut: dZ = (P − Y) / N ⇒ dW = (P − Y)ᵀ X / N, db = mean(P − Y) (same numbers)
Chapter 5 found ∂ℓ/∂z = p − y for one day. For the mean over N days each row gets a share 1/N, and the matrix product rule collects them. The engine finds all of this without being told.
Why it's faster: the graph doesn't grow with the batch
Chapter 5 engine: about 31 Values per day → 193 for 6 days, 31,007 for 1,000 Tensor engine: 30 Tensors for 6, 1,000 or 100,000 days 1,000 days on our laptop: about 80 ms vs about 1 ms
The work hasn't disappeared, it has moved into NumPy, which loops in compiled C over contiguous memory. The Python-level bookkeeping shrinks from one object per number to one per operation. The timings depend on your machine; the counts don't.

• Write one entry of C = A B as a dot product: C[i, j] = Σₖ A[i, k] · B[k, j].

• Fix one entry of A, say A[i, k]. It appears in row i of C only, once in each entry C[i, j], multiplied by B[k, j]. So ∂C[i, j]/∂A[i, k] = B[k, j], and entries of C in other rows don't depend on it at all.

• A[i, k] feeds several outputs, so by Chapter 5's rule its gradient adds the contributions: ∂L/∂A[i, k] = Σⱼ dC[i, j] · B[k, j].

• Read that sum as a matrix product: it pairs row i of dC with row k of B, which is column k of Bᵀ. So it's entry (i, k) of dC Bᵀ, giving dA = dC Bᵀ.

• The same argument for B[k, j], which appears in column j of C multiplied by A[i, k], gives ∂L/∂B[k, j] = Σᵢ A[i, k] · dC[i, j], which is entry (k, j) of Aᵀ dC. ∎ For one row and one column (a dot product) this is Chapter 5's multiply rule: each side gets the other side's values.

• Adding b (shape (1,)) to Z (shape (6, 1)) means NumPy uses the same b for all six rows: out[i] = Z[i] + b for i = 1 … 6.

• That's one value feeding six children. The local slope of each child with respect to b is 1, so the multivariate chain rule from Chapter 5 gives ∂L/∂b = Σᵢ ∂L/∂out[i] · 1 = Σᵢ dout[i].

• In general, broadcasting stretches a value along one or more axes: axes it didn't have (added in front) and axes where its size was 1. Each stretch makes copies, so the gradient sums over exactly those axes. That's all unbroadcast() does.

• Without it, the engine would try to store a (6, 1) gradient in b's (1,) array, and NumPy refuses (the first exercise shows the error). So the shape rule catches a missing sum immediately.

• The same reasoning covers scalar knobs used across a batch. In the rides, w is one number multiplied by four distances, so dw collects four terms, Σᵢ dpredᵢ · kmᵢ, which is the Σ in Chapter 4's formula. ∎

3. How it works

1
Stack the examples
Put one example per row: the six picnic days become a (6 × 3) array X and their labels a (6 × 1) array Y. Keep the data as plain NumPy arrays; only the knobs need to be Tensors.
2
Write the batch forward pass
Z = X Wᵀ + b, then the activation, then the loss averaged over rows. Check each intermediate shape as you go: (6 × 3)(3 × 1) + (1,) → (6 × 1).
3
Call backward()
The engine walks the same kind of graph as in Chapter 5, but each node now passes whole arrays: matrix products use dC Bᵀ and Aᵀ dC, broadcasts sum back down, and the mean divides by N.
4
Check shapes, then values
Every .grad must have its Tensor's shape. Then compare a few entries with central differences, or with a known shortcut like (P − Y)ᵀ X / N.
5
Grow the network
A layer is just another Z = X Wᵀ + b followed by an activation. Stack two and backward() still gives every knob its slope, with no new code.

4. The code (python)

core_ch6.py
# Chapter 6: the Chapter 5 engine, one array at a time instead of one number at a time.
import numpy as np


def unbroadcast(grad, shape):
    """Sum grad back down to `shape`: a value copied across rows gets the sum of the rows' slopes."""
    while grad.ndim > len(shape):              # extra leading axes were added by broadcasting
        grad = grad.sum(axis=0)
    for axis, size in enumerate(shape):        # axes of size 1 were stretched
        if size == 1:
            grad = grad.sum(axis=axis, keepdims=True)
    return grad


class Tensor:
    """An array in a calculation, plus dL/d(every entry), in an array of the same shape."""

    def __init__(self, data, parents=(), op=""):
        self.data = np.asarray(data, dtype=float)
        self.grad = np.zeros_like(self.data)
        self._parents = parents
        self._op = op
        self._backward = lambda: None

    shape = property(lambda self: self.data.shape)
    __array_ufunc__ = None                     # with a NumPy array on the left (Y * P), NumPy hands over to us

    def __repr__(self):
        return f"Tensor(shape={self.shape}, op={self._op!r})"

    # --- elementwise operations: Chapter 5's slopes, applied entry by entry ----
    def __add__(self, other):
        other = other if isinstance(other, Tensor) else Tensor(other)
        out = Tensor(self.data + other.data, (self, other), "+")

        def _backward():
            self.grad += unbroadcast(out.grad, self.shape)
            other.grad += unbroadcast(out.grad, other.shape)
        out._backward = _backward
        return out

    def __mul__(self, other):
        other = other if isinstance(other, Tensor) else Tensor(other)
        out = Tensor(self.data * other.data, (self, other), "*")

        def _backward():
            self.grad += unbroadcast(other.data * out.grad, self.shape)
            other.grad += unbroadcast(self.data * out.grad, other.shape)
        out._backward = _backward
        return out

    def __pow__(self, n):
        out = Tensor(self.data ** n, (self,), f"**{n}")

        def _backward():
            self.grad += n * self.data ** (n - 1) * out.grad
        out._backward = _backward
        return out

    def exp(self):
        out = Tensor(np.exp(self.data), (self,), "exp")

        def _backward():
            self.grad += out.data * out.grad
        out._backward = _backward
        return out

    def log(self):
        out = Tensor(np.log(self.data), (self,), "log")

        def _backward():
            self.grad += out.grad / self.data
        out._backward = _backward
        return out

    def relu(self):
        out = Tensor(np.maximum(0, self.data), (self,), "relu")

        def _backward():                       # slope 1 where the input was positive, 0 elsewhere
            self.grad += (self.data > 0) * out.grad
        out._backward = _backward
        return out

    # --- the new operations: matrix product, transpose, sum ----------------------
    def __matmul__(self, other):
        out = Tensor(self.data @ other.data, (self, other), "@")

        def _backward():                       # C = A @ B:  dA = dC @ Bᵀ,  dB = Aᵀ @ dC
            self.grad += out.grad @ other.data.T
            other.grad += self.data.T @ out.grad
        out._backward = _backward
        return out

    @property
    def T(self):
        out = Tensor(self.data.T, (self,), "T")

        def _backward():
            self.grad += out.grad.T
        out._backward = _backward
        return out

    def sum(self):
        out = Tensor(self.data.sum(), (self,), "sum")

        def _backward():                       # every entry moves the total one-for-one
            self.grad += np.ones_like(self.data) * out.grad
        out._backward = _backward
        return out

    # --- built from the above, exactly as in Chapter 5 ------------------------------
    def __neg__(self): return self * -1
    def __sub__(self, other): return self + (-other)
    def __truediv__(self, other): return self * other ** -1 if isinstance(other, Tensor) else self * (1 / other)
    def __radd__(self, other): return self + other
    def __rmul__(self, other): return self * other
    def __rsub__(self, other): return Tensor(other) - self
    def __rtruediv__(self, other): return Tensor(other) * self ** -1
    def mean(self): return self.sum() / self.data.size
    def sigmoid(self): return 1 / (1 + (-self).exp())

    def backward(self):
        order, seen = [], set()

        def visit(t):
            if id(t) not in seen:
                seen.add(id(t))
                for p in t._parents:
                    visit(p)
                order.append(t)
        visit(self)
        self.grad = np.ones_like(self.data)    # dL/dL = 1
        for t in reversed(order):
            t._backward()


def count(t, seen=None):
    """How many Tensors the graph holds."""
    seen = set() if seen is None else seen
    if id(t) not in seen:
        seen.add(id(t))
        for p in t._parents:
            count(p, seen)
    return len(seen)


# === 1. All four rides in one go ==============================================
km = np.array([2, 5, 8, 12])
minutes = np.array([11, 21, 28, 42])
w, b = Tensor(3.0), Tensor(5.0)               # scalars, broadcast across the four rides
L = ((w * km + b - minutes) ** 2).mean()
L.backward()
print("rides:  loss", L.data, " dL/dw =", w.grad, " dL/db =", b.grad, f" ({count(L)} Tensors)")

# === 2. The six picnic days from Chapter 3, as one matrix =======================
X = np.array([[0.7, 0.2, 0.5],                # (sun, rain, wind), one row per day
              [0.1, 0.9, 0.3],
              [0.9, 0.0, 0.1],
              [0.4, 0.3, 0.9],
              [0.6, 0.1, 0.2],
              [0.5, 0.4, 0.4]])
Y = np.array([[1], [0], [1], [0], [1], [0]])  # 1 = the picnic went well; shape (6, 1)

W = Tensor([[2.0, -3.0, -1.0]])               # one neuron: (neurons × inputs) = (1 × 3), as in Chapter 2
b = Tensor([0.5])                             # one bias per neuron
Z = Tensor(X) @ W.T + b                       # (6 × 3)(3 × 1) + (1,) -> (6 × 1)
P = Z.sigmoid()
loss = -(Y * P.log() + (1 - Y) * (1 - P).log()).mean()
loss.backward()
print("\npicnic: mean cross-entropy", round(float(loss.data), 4))
print("        W.grad =", np.round(W.grad, 4), " b.grad =", np.round(b.grad, 4))
print("        shapes: W", W.shape, "W.grad", W.grad.shape, "| b", b.shape, "b.grad", b.grad.shape)

# Chapter 4's shortcut, now for a batch: dW = (P - Y)ᵀ X / N
print("        (P - Y)ᵀ X / N =", np.round((P.data - Y).T @ X / len(X), 4))

# === 3. Gradient check on every entry of W ======================================
def picnic_loss(Wv, bv):
    p = 1 / (1 + np.exp(-(X @ Wv.T + bv)))
    return float(-(Y * np.log(p) + (1 - Y) * np.log(1 - p)).mean())

h, numeric = 1e-5, np.zeros_like(W.data)
for j in range(3):
    up, down = W.data.copy(), W.data.copy()
    up[0, j] += h
    down[0, j] -= h
    numeric[0, j] = (picnic_loss(up, b.data) - picnic_loss(down, b.data)) / (2 * h)
rel = np.abs(W.grad - numeric) / (np.abs(W.grad) + np.abs(numeric))
print("        gradient check, worst relative error:", f"{rel.max():.1e}")

# === 4. Two layers: Chapter 2's three neurons, ReLU, then one output neuron =====
W1 = Tensor([[2.0, -3.0, -1.0],               # picnic
             [-1.0, 0.0, 3.0],                # kite flying
             [0.0, 2.5, 0.0]])                # stay in and read
b1 = Tensor([0.5, -0.5, 0.0])
W2 = Tensor([[1.0, -0.5, -1.0]])              # the output neuron weighs the three opinions
b2 = Tensor([0.0])

H = (Tensor(X) @ W1.T + b1).relu()            # (6 × 3)
P2 = (H @ W2.T + b2).sigmoid()                # (6 × 1)
loss2 = -(Y * P2.log() + (1 - Y) * (1 - P2).log()).mean()
loss2.backward()
print("\ntwo layers: loss", round(float(loss2.data), 4), f" ({count(loss2)} Tensors)")
for name, t in [("W1", W1), ("b1", b1), ("W2", W2), ("b2", b2)]:
    print(f"  {name}: data {t.shape}  grad {t.grad.shape}  grad = {np.round(t.grad, 4).tolist()}")

# === 5. The graph doesn't grow with the batch ===================================
rng = np.random.default_rng(0)
for n in [6, 1_000, 100_000]:
    Xb = rng.random((n, 3))
    Yb = (rng.random((n, 1)) < 0.5).astype(float)
    Wb, bb = Tensor([[2.0, -3.0, -1.0]]), Tensor([0.5])
    Pb = (Tensor(Xb) @ Wb.T + bb).sigmoid()
    lb = -(Yb * Pb.log() + (1 - Yb) * (1 - Pb).log()).mean()
    print(f"batch of {n:>7}: {count(lb)} Tensors in the graph")

5. Practice

Work these out on paper (or in Python) and type the number. Answers are checked with a small tolerance for rounding.

P1 A layer has 5 neurons, each reading 3 inputs. How many numbers does W.grad hold?
W.grad has W's shape, (neurons × inputs).
Solution. W is (5 × 3), so W.grad is (5 × 3): 15 numbers, one slope per weight.
P2 A = [[1, 2]] (1 × 2) and B = [[3], [4]] (2 × 1), so C = A B = [[11]]. With dC = [[1]], what is the second entry of dA?
dA = dC Bᵀ.
Solution. Bᵀ = [[3, 4]], so dA = [[1]] · [[3, 4]] = [[3, 4]]. The second entry is 4: each entry of A gets the B value it was multiplied by.
P3 A bias b is broadcast over three rows, and the slopes arriving at those rows are dZ = [0.1, −0.3, 0.5]. What is db?
A broadcast value's gradient is the sum over the copies.
Solution. 0.1 − 0.3 + 0.5 = 0.3.
P4 m = mean of a 4-entry array. After m.backward(), what is the grad of each entry?
mean = sum / N.
Solution. The sum gives each entry slope 1, and dividing by N = 4 scales it to 0.25.
P5 H = ReLU(Z) with Z = [−1, 2, 0.5], and dH = [3, 3, 3] arrives from above. What is the sum of the entries of dZ?
ReLU's slope is 1 where its input was positive, 0 elsewhere.
Solution. dZ = [0, 3, 3]: the first entry was negative, so no slope passes through. The sum is 6.
P6 For the six picnic days, db = mean(P − Y) with P − Y = [−0.310, 0.091, −0.100, 0.378, −0.231, 0.475]. What is db? (3 decimal places.)
Add the six numbers and divide by 6.
Solution. The sum is about 0.302, and 0.302 / 6 ≈ 0.050. It's positive, so lowering the bias would lower the loss slightly.
P7 dZ = [[0.5], [−0.5]] (2 × 1) and X = [[1, 2, 3], [3, 2, 1]] (2 × 3). What is the third entry of dW = dZᵀ X?
dZᵀ = [[0.5, −0.5]]. The third entry pairs it with X's third column, [3, 1].
Solution. 0.5 · 3 + (−0.5) · 1 = 1.5 − 0.5 = 1. The full row is dW = [−1, 0, 1].
P8 The picnic loss for 1,000 days is built with the Tensor engine. How many Tensors does the graph hold?
The graph has one node per operation in the formula, whatever the batch size.
Solution. 30, the same as for 6 days or 100,000. Chapter 5's engine would build 31,007 Values for the same loss.

6. Go further

  1. Delete the unbroadcast() calls in __add__ and run the chapter's code. Where does it stop, what error does NumPy give, and which line of the derivation 'Why broadcasting's backward pass is a sum' explains it? Put them back and check that b.grad is the sum of the six rows of dZ.
  2. Train the picnic neuron: repeat 'forward, zero the grads, backward(), W ← W − η·dW, b ← b − η·db' for 500 steps with η = 1.0. Print the loss every 100 steps and the final accuracy on the six days. Then do the same for the two-layer network. Does it reach a lower loss?
  3. Time both engines on random data with 1,000 and 10,000 days (copy the Value class from Chapter 5, and build the scalar loss with a loop over the days). How does each time grow as the batch grows by 10×? What does your machine report compared with the 80 ms vs 1 ms in the text?

7. Check yourself

Answer all 5 questions correctly to complete the chapter · 0 / 5 done
Q1/5 W is a (5 × 3) weight matrix. What shape must W.grad have?
Q2/5 For C = A B, which expression gives dA?
Q3/5 A bias vector is added to every row of a batch. Why is its gradient a sum over the rows?
Q4/5 Why does the Tensor engine's graph stay at 30 nodes for 6 or 100,000 picnic days?
Q5/5 P = σ(Z) elementwise. Given dP, what is dZ?

Finished this chapter?