NumPy and vectorized thinking
Shapes, broadcasting and the end of for-loops
Every tensor library — NumPy, PyTorch, JAX — shares one mental model: n-dimensional arrays with shapes, operations that apply to whole arrays at once, and broadcasting rules that let arrays of different shapes combine. Learning this model once in NumPy makes PyTorch (2.4 PyTorch I: tensors, autograd and the training loop) feel familiar, and it is how you will implement softmax (4.2 Activation functions), attention (7.2 Scaled dot-product and multi-head attention, fully derived) and pairwise distances for kNN (3.4 k-Nearest Neighbors) in a handful of lines that run 100× faster than loops.
You'll be able to
- Create arrays with specific shapes and dtypes and read a shape tuple as 'what each axis means'
- Apply the broadcasting rules to predict the output shape of an operation, or explain why it fails
- Use boolean masks, fancy indexing and axis reductions without loops
- Write matrix products with @ and einsum and translate between the two notations
- Implement softmax, pairwise squared distances and a linear layer forward pass in vectorized NumPy
Key ideas
- An ndarray is one contiguous memory block plus shape/dtype/strides; reshape, transpose and slicing return views in O(1), fancy indexing copies.
- Read a shape as meanings (batch × time × features); printing shapes is the fastest way to find ML bugs.
- Broadcasting aligns shapes from the right; axes match if equal or 1; use keepdims=True or [:, None] to line axes up.
Show 5 more key ideas
- Reductions remove the axis you name; argmax(axis=1) on (B, C) logits gives per-example predictions.
- Stable softmax subtracts the row max before exponentiating; the result is mathematically identical.
- A @ B is matrix multiplication (batched over leading axes); einsum spells the index pattern for anything else.
- ‖a−b‖² = ‖a‖² + ‖b‖² − 2a·b turns pairwise distances into one matrix product; vectorized code is 10–100× faster than loops.
- Use np.random.default_rng(seed) and pass the generator around for reproducibility.
Suggested path through this lesson
Arrays: one block of numbers, many views
A NumPy ndarray is a contiguous block of memory plus metadata: a shape (a tuple of axis lengths), a dtype (every element has the same type, e.g. float32, int64, bool) and strides (how many bytes to step along each axis). Because the metadata is separate from the data, operations like transposing, reshaping and slicing create views — new metadata over the same memory — in constant time. That is a superpower (reshaping a million numbers is free) and a trap (modifying a slice modifies the original; call .copy() when you need independence).
Read shapes as meanings. A dataset of 60,000 grayscale digits is (60000, 28, 28): samples × rows × columns. A batch of 32 token embeddings of width 768 is (32, 768). A batch of 8 sequences of 128 tokens, each a 512-dimensional vector, is (8, 128, 512) — batch × time × features, the shape convention transformers use (7.2 Scaled dot-product and multi-head attention, fully derived). Half of all ML bugs are shape bugs, so print shapes relentlessly.
import numpy as np
X = np.random.default_rng(0).normal(size=(60000, 28, 28)).astype(np.float32)
X.shape, X.dtype, X.nbytes / 1e6 # (60000, 28, 28), float32, 188.16 MB
flat = X.reshape(60000, -1) # (60000, 784): -1 means "infer this axis"
flat.base is X # True: a view, no copy
dtype choices are cost choices: float64 is NumPy's default and 8 bytes per number; deep learning runs in float32 (4 bytes) or smaller (4.12 Compute, hardware and scale basics). Integers for labels and token ids, booleans for masks.
Indexing and masks
Basic slicing works per axis: X[0] is the first image (28, 28), X[:, 10, :] is row 10 of every image (60000, 28), X[..., ::2] takes every other column. Boolean masking selects by condition: y[y == 7], X[y == 7].mean(axis=0) gives the average "7". Fancy indexing with an integer array gathers rows: E[ids] where E is a (V, d) embedding table and ids is (B, T) returns (B, T, d) — that single line is an embedding layer (6.3 Word embeddings). Fancy indexing copies; slicing views.
Broadcasting: the rule that replaces loops
Broadcasting lets arrays of different shapes combine element-wise. The rule: align the shapes from the right; two axes are compatible if they are equal or one of them is 1; a missing axis counts as 1. The result has the maximum length along each axis, and the length-1 axes are conceptually repeated without copying memory.
| A shape | B shape | result | reading |
|---|---|---|---|
| (3, 4) | (4,) | (3, 4) | add a bias vector to every row |
| (3, 4) | (3, 1) | (3, 4) | subtract a per-row mean |
| (8, 1, 512) | (1, 128, 512) | (8, 128, 512) | combine per-batch and per-position vectors |
| (3, 4) | (3,) | error | 4 vs 3 do not match from the right — reshape B to (3, 1) |
The last row is the classic mistake: standardizing columns is (X - X.mean(axis=0)) / X.std(axis=0) because the mean over axis 0 has shape (d,) which aligns with the last axis; standardizing rows needs keepdims=True so the mean is (n, 1). Use None (or np.newaxis) to insert axes: a[:, None] - b[None, :] builds every pairwise difference between vectors a and b in one expression.
Reductions along axes
sum, mean, max, argmax, std all take axis: the axis you name is the one that disappears. For logits of shape (B, C), logits.argmax(axis=1) is the predicted class per example, (B,). keepdims=True keeps the reduced axis with length 1 so the result broadcasts back — which is exactly what a numerically stable softmax needs:
Subtracting the maximum does not change the result (multiply numerator and denominator by ) but prevents from overflowing to infinity when in float64 or in float32.
def softmax(z, axis=-1):
z = z - z.max(axis=axis, keepdims=True)
e = np.exp(z)
return e / e.sum(axis=axis, keepdims=True)
Matrix products: @ and einsum
A @ B is matrix multiplication (np.matmul); A * B is element-wise. For 2-D arrays (n, k) @ (k, m) -> (n, m); for stacks, @ broadcasts over leading batch axes, so (8, 128, 64) @ (8, 64, 128) -> (8, 128, 128) computes eight attention-score matrices at once (7.2 Scaled dot-product and multi-head attention, fully derived). A linear layer with inputs and weights (PyTorch's convention, out × in) is
costing floating-point operations. np.einsum spells out the index pattern: np.einsum("nd,kd->nk", X, W) says "sum over the shared index d". It shines when the operation has no name: einsum("bqd,bkd->bqk", Q, K) is batched ; einsum("ii->", A) is a trace; einsum("bi,bi->b", a, b) is a per-row dot product.
Worked example: pairwise distances without loops
kNN (3.4 k-Nearest Neighbors) and k-means (3.11 Clustering: k-means, GMMs/EM, DBSCAN and hierarchical) need the distance from every query to every training point. For :
The three terms have shapes (n, 1), (1, m) and (n, m), so broadcasting assembles the full matrix from one matrix product:
def pairwise_sq_dists(A, B): # A: (n, d), B: (m, d)
a2 = (A ** 2).sum(axis=1, keepdims=True) # (n, 1)
b2 = (B ** 2).sum(axis=1)[None, :] # (1, m)
return np.maximum(a2 + b2 - 2 * A @ B.T, 0) # clip tiny negatives from rounding
rng = np.random.default_rng(0)
A, B = rng.normal(size=(1000, 64)), rng.normal(size=(5000, 64))
D = pairwise_sq_dists(A, B) # (1000, 5000) in ~10 ms
nearest = D.argmin(axis=1) # index of nearest B for each A
A double Python loop over 5 million pairs takes seconds to minutes; this takes milliseconds. The speed-up comes from three things: the loop runs in compiled C, the data is contiguous in memory so the CPU streams it, and the matrix product uses a tuned BLAS library that exploits SIMD instructions and cache blocking. The rule of thumb: if you wrote for i in range(n) over data, there is almost certainly an array expression that replaces it.
Randomness done right
Use the modern generator API: rng = np.random.default_rng(seed), then rng.normal(size=...), rng.integers(0, V, size=...), rng.permutation(n) for shuffling, rng.choice(n, size=k, replace=False) for sampling. Pass the generator around rather than seeding globally; it makes experiments reproducible (2.5 Tooling: Git, GPUs, experiments and the Hugging Face Hub).
Where this shows up later
- Shapes and broadcasting are identical in PyTorch — 2.4 PyTorch I: tensors, autograd and the training loop; batch × time × features is the transformer convention — 7.2 Scaled dot-product and multi-head attention, fully derived.
- Stable softmax reappears in cross-entropy with logits — 4.3 Loss functions catalog — and in attention — 7.2 Scaled dot-product and multi-head attention, fully derived.
- Pairwise distances power kNN and k-means — 3.4 k-Nearest Neighbors, 3.11 Clustering: k-means, GMMs/EM, DBSCAN and hierarchical; fancy indexing is the embedding lookup — 6.3 Word embeddings.
- The linear-layer formula and its cost are the unit of FLOP accounting — 4.12 Compute, hardware and scale basics, 7.9 Scaling laws and compute-optimal training.
Common pitfalls (5)
- A * B is element-wise, A @ B is matrix multiplication; mixing them up often still runs (via broadcasting) and silently gives wrong numbers.
- Reducing without keepdims produces (n,) instead of (n, 1), which then broadcasts along the wrong axis or raises an error.
- Modifying a slice modifies the original array; use .copy() when you need an independent array.
- Computing exp(z) without subtracting the max overflows to inf for moderately large logits.
- Float64 by default doubles memory versus float32; set dtypes explicitly for large arrays.
Recap: all 8 key points
- An ndarray is one contiguous memory block plus shape/dtype/strides; reshape, transpose and slicing return views in O(1), fancy indexing copies.
- Read a shape as meanings (batch × time × features); printing shapes is the fastest way to find ML bugs.
- Broadcasting aligns shapes from the right; axes match if equal or 1; use keepdims=True or [:, None] to line axes up.
- Reductions remove the axis you name; argmax(axis=1) on (B, C) logits gives per-example predictions.
- Stable softmax subtracts the row max before exponentiating; the result is mathematically identical.
- A @ B is matrix multiplication (batched over leading axes); einsum spells the index pattern for anything else.
- ‖a−b‖² = ‖a‖² + ‖b‖² − 2a·b turns pairwise distances into one matrix product; vectorized code is 10–100× faster than loops.
- Use np.random.default_rng(seed) and pass the generator around for reproducibility.
Watch
What a tensor (n-dimensional array) is and why shapes carry meaning.
Read
Exercises
- Predict the shape, then check
For ten pairs of shapes of your choosing (include (5,1)+(1,4), (3,4)+(3,), (2,1,6)+(7,1), (8,128,64)@(8,64,128)), write down the result shape or 'error' before running. Expected: you get all ten right or can explain each miss with the right-alignment rule.
- Implement three building blocks
Write
softmax(z, axis),pairwise_sq_dists(A, B)andlinear(X, W, b)in NumPy with no Python loops. Check: softmax rows sum to 1 and matchscipy.special.softmax; distances match a double loop on a 20×30 case to 1e-9;linearoutput shape is (n, k). - Time the speed-up
Compute the mean of each column of a (10,000, 500) array three ways: nested Python loops, a loop over columns calling
.mean(), andX.mean(axis=0). Time each withtimeit. Expected: roughly 1000× between the slowest and fastest. - An embedding lookup with fancy indexing
Create a (50, 8) table
Eand an (4, 10) integer arrayidswith values in [0, 50). ComputeE[ids]and confirm its shape is (4, 10, 8) and thatE[ids][2, 3]equalsE[ids[2, 3]].
Quiz
Flashcards
These cards are now in your spaced-repetition queue. Review all due cards →