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×.
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
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
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.
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
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).
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
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 @ woversum(x*w for x in X). - Use
axis=0to aggregate along rows (per-column result),axis=1for per-row result.