Module 01 Beginner 20 min read

NumPy Mastery

N-dimensional arrays, broadcasting, and vectorisation — the numerical backbone of every ML framework.

Updated 2025 · Edit on GitHub

Why NumPy?

Python lists are slow for numerical computation — they store pointers to arbitrary Python objects and execute operations in a single thread. NumPy stores homogeneous data in contiguous memory blocks and dispatches to vectorised C/Fortran routines. The speedup is typically 10–200×.

Python
import numpy as np
import time

n = 1_000_000
py_list = list(range(n))
np_arr  = np.arange(n)

t0 = time.perf_counter(); sum(py_list);          print(f"Python list sum: {time.perf_counter()-t0:.4f}s")
t0 = time.perf_counter(); np_arr.sum();          print(f"NumPy sum:        {time.perf_counter()-t0:.4f}s")
# NumPy is typically 50-100x faster

Creating Arrays

Python
import numpy as np

# From Python lists
a = np.array([1, 2, 3, 4, 5])               # 1-D, int64
b = np.array([[1.0, 2.0], [3.0, 4.0]])      # 2-D, float64

# Built-in constructors
zeros   = np.zeros((3, 4))                   # 3x4 of 0.0
ones    = np.ones((2, 5), dtype=np.int32)   # 2x5 of 1 (int)
eye     = np.eye(4)                          # 4x4 identity matrix
rng     = np.random.default_rng(42)         # reproducible RNG
normal  = rng.standard_normal((100, 10))    # 100 samples, 10 features
uniform = rng.uniform(0, 1, size=(50,))     # 50 uniform samples
linsp   = np.linspace(0, 2*np.pi, 100)     # 100 evenly spaced in [0, 2π]
arange  = np.arange(0, 20, 2)              # [0, 2, 4, ..., 18]

# Inspect
print(a.shape, a.dtype, a.ndim, a.size)     # (5,) int64 1 5

Indexing, Slicing & Fancy Indexing

Python
X = np.arange(12).reshape(3, 4)  # [[0,1,2,3],[4,5,6,7],[8,9,10,11]]

# Basic slicing — returns VIEWS (no copy)
print(X[1, 2])          # 6    (row 1, col 2)
print(X[0, :])          # [0 1 2 3]  (first row)
print(X[:, -1])         # [3 7 11]   (last column)
print(X[1:, ::2])       # rows 1-end, every 2nd column

# Boolean (mask) indexing — returns a COPY
mask = X > 5
print(X[mask])          # [6 7 8 9 10 11]
X[X < 0] = 0            # zero-out negatives in-place

# Fancy indexing with integer arrays
rows = np.array([0, 2])
cols = np.array([1, 3])
print(X[rows, cols])    # [X[0,1], X[2,3]] = [1, 11]

Broadcasting

Broadcasting lets NumPy perform operations on arrays of different but compatible shapes without making explicit copies. It's the key to writing fast, readable numerical code.

Broadcasting Rule$$ ext{Two shapes are compatible if, for each trailing dimension, the sizes are equal OR one of them is 1.}$$
Python
import numpy as np

# Example 1: scalar broadcasts over all elements
a = np.array([1, 2, 3, 4])
print(a * 10)             # [10 20 30 40]

# Example 2: (3,4) + (4,) → each row gets the same column added
X = np.ones((3, 4))
col = np.array([1, 2, 3, 4])
print(X + col)            # [[2,3,4,5], [2,3,4,5], [2,3,4,5]]

# Example 3: (3,1) + (1,4) → outer product style
rows = np.array([[10], [20], [30]])   # shape (3,1)
cols = np.array([[1,  2,  3,  4]])   # shape (1,4)
print(rows + cols)        # shape (3,4) — each element rows[i]+cols[j]

# ML use case: mean-centre a dataset (subtract per-feature mean)
X = np.random.randn(1000, 20)        # 1000 samples, 20 features
X_centred = X - X.mean(axis=0)      # mean shape (20,) broadcasts over rows
X_scaled  = X_centred / X.std(axis=0)  # standardise
💡
Memory tip: Broadcasting never actually copies data — it uses virtual strides. np.array([1,2,3]) * 10 is as fast as if both operands had the same shape.

Vectorisation

Vectorisation means replacing Python for loops with NumPy operations. Loops are Python-level (slow); NumPy operations execute in compiled C (fast).

Python
import numpy as np

X = np.random.randn(10_000, 50)   # 10k samples, 50 features
w = np.random.randn(50)
b = 0.5

# Slow — explicit Python loop
def predict_slow(X, w, b):
    return [sum(x * w) + b for x in X]   # pure Python

# Fast — vectorised
def predict_fast(X, w, b):
    return X @ w + b                      # single BLAS call

# Euclidean distance (vectorised)
a, b_vec = np.random.randn(5), np.random.randn(5)
dist = np.sqrt(np.sum((a - b_vec)**2))     # = np.linalg.norm(a - b_vec)

Essential Operations Cheatsheet

Python
import numpy as np

A = np.random.randn(4, 3)

# Shape manipulation
A.reshape(2, 6)       # new view with shape (2,6)
A.flatten()           # 1-D copy
A.T                   # transpose: (3,4)
np.expand_dims(A, 0)  # insert axis: (1,4,3)
A.squeeze()           # remove size-1 axes

# Aggregations (axis=0 → along rows, axis=1 → along columns)
A.sum(axis=0)         # column sums, shape (3,)
A.mean(axis=1)        # row means, shape (4,)
A.std(axis=0)         # column std devs
A.argmax(axis=1)      # index of max per row
np.sort(A, axis=0)    # sort each column

# Stacking
B = np.ones((4, 2))
np.hstack([A, B])     # (4, 5)  — side by side
np.vstack([A, A])     # (8, 3)  — on top of each other

# Linear algebra
np.dot(A, A.T)        # (4,4)  — A @ A.T
np.linalg.norm(A)     # Frobenius norm
np.linalg.inv(A.T @ A)     # matrix inverse (use solve in practice!)
np.linalg.eigvalsh(A.T @ A)  # eigenvalues of symmetric matrix

Summary

  • NumPy arrays are fast because they store contiguous, homogeneous data in C-level memory.
  • Broadcasting: operations on compatible shapes without copying data. Shapes align from the right.
  • Vectorisation: replace Python loops with NumPy operations. Always prefer X @ w over sum(x*w for x in X).
  • Use axis=0 to aggregate along rows (per-column result), axis=1 for per-row result.