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.
Inspect Architecture: Logistic regression1. 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.
2. The math
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,)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)C = A ⊙ B ⇒ dA = dC ⊙ B, dB = dC ⊙ A P = σ(Z) ⇒ dZ = dP ⊙ P ⊙ (1 − P)
ReLU: dZ = dH ⊙ [Z > 0]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) ✓C = Aᵀ ⇒ dA = (dC)ᵀ
for Z = X Wᵀ + b: d(Wᵀ) = Xᵀ dZ (3 × 1) ⇒ dW = dZᵀ X (1 × 3)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ᵢ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.25L = 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)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 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• 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
4. The code (python)
# 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.
6. Go further
- 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.
- 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?
- 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
In the catalog
Finished this chapter?