🎯 Learning Objectives
- Create and inspect NumPy arrays: shape, dtype, ndim, size
- Use array creation routines:
zeros,ones,arange,linspace,random - Index, slice, and reshape arrays including boolean and fancy indexing
- Apply vectorised arithmetic and universal functions (
ufuncs) — no Python loops - Understand broadcasting rules and apply them to arrays of different shapes
- Perform linear algebra operations: dot product, matrix multiply, inverse, eigenvalues
- Profile NumPy code and understand when it beats pure Python
1 — Why NumPy?
Pure Python lists are ill-suited for numerical work:
- Boxed objects — each element is a heap-allocated Python object with its own type tag and reference count.
- No contiguous memory — list elements are pointers scattered across the heap, killing CPU cache performance.
- No SIMD — the interpreter cannot auto-vectorise a Python
forloop.
NumPy solves all three problems by storing data in a contiguous C array with a single fixed dtype — exactly the same memory layout used by C and Fortran. Operations are delegated to optimised C, BLAS, and LAPACK routines that are often 100× faster than equivalent Python loops.
The benchmark below makes this concrete:
import numpy as np
import time
N = 1_000_000
py_list = list(range(N))
np_array = np.arange(N, dtype=np.float64)
# Python loop
t0 = time.perf_counter()
result = [x * 2 for x in py_list]
print(f"Python list: {time.perf_counter() - t0:.4f}s")
# NumPy vectorised
t0 = time.perf_counter()
result = np_array * 2
print(f"NumPy array: {time.perf_counter() - t0:.4f}s")
# NumPy is typically 50-200x fasterspeed_comparison.pyInstall NumPy (if not already present):
pip install numpyshellNever loop over a NumPy array in Python. If you find yourself writing
for x in arr:, there is almost certainly a vectorised operation that does
the same thing orders of magnitude faster.
2 — Creating Arrays
import numpy as np
# ── From Python data ──
a = np.array([1, 2, 3, 4, 5]) # 1-D, dtype inferred (int64)
b = np.array([[1, 2, 3], [4, 5, 6]]) # 2-D (2×3 matrix)
c = np.array([1.0, 2, 3]) # float64 (one float coerces all)
d = np.array([1, 2, 3], dtype=np.float32) # explicit dtype
# ── Creation routines ──
np.zeros((3, 4)) # 3×4 matrix of 0.0
np.ones((2, 3, 4)) # 2×3×4 tensor of 1.0
np.full((3, 3), 7) # filled with 7
np.eye(4) # 4×4 identity matrix
np.arange(0, 10, 2) # [0, 2, 4, 6, 8] like range()
np.linspace(0, 1, 5) # [0, 0.25, 0.5, 0.75, 1.0] evenly spaced
np.logspace(0, 3, 4) # [1, 10, 100, 1000] log-spaced
# ── Random arrays ──
rng = np.random.default_rng(seed=42) # recommended: Generator API
rng.random((3, 3)) # uniform [0, 1)
rng.integers(0, 10, size=5) # random ints in [0, 10)
rng.normal(0, 1, size=(4, 4)) # standard normal
rng.choice([10, 20, 30], size=6, replace=True)creating_arrays.pyEvery array exposes a set of metadata attributes:
arr = np.zeros((3, 4, 5))
print(arr.shape) # (3, 4, 5)
print(arr.ndim) # 3
print(arr.size) # 60
print(arr.dtype) # float64
print(arr.itemsize) # 8 bytes per element
print(arr.nbytes) # 480 bytes totalarray_attributes.pyCommon dtypes at a glance:
| Category | dtypes | Notes |
|---|---|---|
| Signed integers | int8, int16, int32, int64 |
Default is int64 on 64-bit platforms |
| Unsigned integers | uint8, uint16, uint32, uint64 |
uint8 common for image pixel data |
| Floating point | float16, float32, float64 |
Default is float64; ML models often use float32 |
| Complex | complex64, complex128 |
Pairs of float32 / float64 |
| Boolean | bool |
Stored as 1 byte per element |
| Object | object |
Holds arbitrary Python objects — avoids NumPy optimisations |
3 — Indexing, Slicing & Reshaping
import numpy as np
a = np.arange(12).reshape(3, 4)
# array([[ 0, 1, 2, 3],
# [ 4, 5, 6, 7],
# [ 8, 9, 10, 11]])
# ── Basic indexing ──
a[0] # first row: [0, 1, 2, 3]
a[-1] # last row: [8, 9, 10, 11]
a[1, 2] # row 1, col 2 → 6
a[0, -1] # row 0, last col → 3
# ── Slicing ──
a[:, 1] # all rows, col 1 → [1, 5, 9]
a[1:, 2:] # rows 1+, cols 2+ → [[6,7],[10,11]]
a[::2] # every other row → [[0,1,2,3],[8,9,10,11]]
# ── Boolean indexing ──
mask = a > 6
a[mask] # [7, 8, 9, 10, 11]
a[a % 2 == 0] # all even elements
# ── Fancy indexing ──
rows = np.array([0, 2])
cols = np.array([1, 3])
a[rows, cols] # [a[0,1], a[2,3]] = [1, 11]
# ── Reshaping ──
a.reshape(4, 3) # new shape (must match total elements)
a.reshape(-1) # flatten to 1-D (same as a.ravel())
a.reshape(2, -1) # 2 rows, infer columns → (2, 6)
a.T # transpose
a.flatten() # copy as 1-D (vs ravel which may return a view)
np.newaxis # add dimension: a[np.newaxis, :] → (1, 3, 4)indexing.pyNumPy slices return views of the original array — modifying a slice modifies the original data. Use
.copy() when you need an independent array:
sub = a[1:, 2:].copy()
4 — Vectorised Operations & ufuncs
Every standard arithmetic operator is overloaded to work element-wise on NumPy arrays. Universal functions (ufuncs) are compiled C functions that accept arrays and broadcast automatically.
import numpy as np
a = np.array([1.0, 4.0, 9.0, 16.0])
b = np.array([2.0, 2.0, 3.0, 4.0])
# ── Element-wise arithmetic (all vectorised) ──
a + b # [3, 6, 12, 20]
a - b # [-1, 2, 6, 12]
a * b # [2, 8, 27, 64]
a / b # [0.5, 2, 3, 4]
a ** 2 # [1, 16, 81, 256]
a % 3 # [1, 1, 0, 1]
# ── Universal functions (ufuncs) ──
np.sqrt(a) # [1, 2, 3, 4]
np.exp(np.array([0, 1, 2])) # [1, e, e²]
np.log(a) # natural log
np.log2(a)
np.log10(a)
np.sin(np.linspace(0, np.pi, 5))
np.abs(np.array([-3, -1, 2])) # [3, 1, 2]
np.maximum(a, b) # element-wise max → [2, 4, 9, 16]
np.where(a > 5, a, 0) # conditional: keep a where a>5, else 0
# ── Aggregate functions ──
a.sum() # 30.0
a.mean() # 7.5
a.std() # standard deviation
a.var() # variance
a.min(), a.max() # 1.0, 16.0
a.argmin(), a.argmax() # index of min/max → 0, 3
np.cumsum(a) # cumulative sum
np.prod(a) # product
# ── Axis argument for 2-D ──
m = np.array([[1, 2, 3], [4, 5, 6]])
m.sum(axis=0) # sum each column → [5, 7, 9]
m.sum(axis=1) # sum each row → [6, 15]
m.max(axis=1) # max per row → [3, 6]ufuncs.py5 — Broadcasting
Broadcasting is NumPy's mechanism for applying operations between arrays of different shapes without making explicit copies of the data. Two shapes are compatible if, when right-aligned, each pair of dimensions is either equal or one of them is 1.
import numpy as np
# Rule: dimensions are compatible if they are equal OR one of them is 1
# NumPy pads dimensions on the LEFT with size-1 until shapes match
a = np.array([[1], [2], [3]]) # shape (3, 1)
b = np.array([10, 20, 30, 40]) # shape (4,) → padded to (1, 4)
a + b
# shape (3, 4):
# [[11, 21, 31, 41],
# [12, 22, 32, 42],
# [13, 23, 33, 43]]
# ── Practical examples ──
rng = np.random.default_rng(seed=42)
# Subtract the column mean from each column (zero-centering)
data = rng.random((100, 4))
data_centered = data - data.mean(axis=0) # (100,4) - (4,) → broadcast
# Add a bias term to each row
weights = np.array([0.1, 0.2, 0.3, 0.4]) # shape (4,)
bias = 1.0 # scalar
output = data @ weights + bias # (100,4)@(4,) = (100,) + scalar
# Scale each column by a different factor
scale = np.array([2, 0.5, 10, 1]) # shape (4,)
data_scaled = data * scale # (100,4) * (4,) → broadcastbroadcasting.pyShape alignment rule — right-align dimensions and pad missing ones on the left with 1:
Shape alignment (right-align, pad left with 1s):
a → (3, 1)
b → ( 4) → becomes (1, 4)
------
out → (3, 4) ← output shape
Each output[i, j] = a[i, 0] + b[0, j]
No data is copied — NumPy "pretends" the size-1 dimension repeats.
Broadcasting succeeds silently even when a shape mismatch is an accident. Always verify unexpected results with
print(result.shape) during
development.
6 — Linear Algebra
NumPy's np.linalg sub-module wraps LAPACK and BLAS routines, giving you production-grade linear algebra in a single import.
import numpy as np
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
# ── Matrix operations ──
A @ B # matrix multiply (preferred over np.dot for 2-D+)
np.dot(A, B) # equivalent
A.T # transpose
np.linalg.inv(A) # inverse [[-2, 1], [1.5, -0.5]]
np.linalg.det(A) # determinant → -2.0
np.linalg.matrix_rank(A) # 2
# ── Solving linear systems: Ax = b ──
b = np.array([1, 2])
x = np.linalg.solve(A, b) # faster & more stable than inv(A) @ b
# verify: A @ x ≈ b
# ── Decompositions ──
eigenvalues, eigenvectors = np.linalg.eig(A)
U, S, Vt = np.linalg.svd(A) # singular value decomposition
Q, R = np.linalg.qr(A) # QR decomposition
L = np.linalg.cholesky(A.T @ A) # Cholesky (requires positive-definite)
# ── Norms ──
np.linalg.norm(A) # Frobenius norm
np.linalg.norm(A, ord=1) # max column sum
np.linalg.norm(A, ord=np.inf) # max row sum
# ── Dot products (1-D vectors) ──
u = np.array([1.0, 2.0, 3.0])
v = np.array([4.0, 5.0, 6.0])
np.dot(u, v) # 32.0 (u·v scalar)
np.cross(u, v) # [-3, 6, -3] (u×v vector)
np.outer(u, v) # outer product (3×3 matrix)linear_algebra.py| Operation | Function | Notes |
|---|---|---|
| Matrix multiply | A @ B |
Use @ (PEP 465) for readability |
| Inverse | np.linalg.inv(A) |
Avoid for solving systems — use solve instead |
| Solve Ax = b | np.linalg.solve(A, b) |
More numerically stable than computing the inverse |
| Eigenvalues | np.linalg.eig(A) |
Returns (values, vectors); use eigh for symmetric matrices |
| SVD | np.linalg.svd(A) |
Foundation of PCA, LSA, and recommender systems |
| Norm | np.linalg.norm(A) |
Default is Frobenius norm for matrices, L2 for vectors |
7 — Practical Patterns & Performance
import numpy as np
# ── Avoid Python loops: use vectorised ops ──
# ❌ slow
def slow_norm(arr):
total = 0
for x in arr:
total += x * x
return total ** 0.5
# ✅ fast
def fast_norm(arr):
return np.sqrt(np.dot(arr, arr)) # or np.linalg.norm(arr)
# ── np.vectorize (convenience, NOT speed) ──
def my_func(x):
return x ** 2 + 1 if x > 0 else 0
vfunc = np.vectorize(my_func) # still a Python loop under the hood
# Use only for prototyping — replace with where/clip/ufuncs for speed
# ── np.where for conditional vectorisation ──
arr = np.linspace(-3, 3, 7)
result = np.where(arr > 0, arr ** 2 + 1, 0) # fully vectorised
# ── Avoid repeated memory allocation in loops ──
# ❌ slow: creates a brand-new array every iteration
out = np.zeros(100)
for i in range(100):
out = out + np.ones(100) * i
# ✅ use in-place operations
out = np.zeros(100)
temp = np.ones(100)
for i in range(100):
out += temp * i # or: np.add(out, temp * i, out=out)
# ── Profiling with %timeit (Jupyter / IPython) ──
# %timeit slow_norm(arr)
# %timeit fast_norm(arr)
# ── Saving & loading ──
rng = np.random.default_rng(42)
arr = rng.random((1000, 1000))
np.save("data.npy", arr) # binary .npy
arr2 = np.load("data.npy")
np.savetxt("data.csv", arr[:5, :5], delimiter=",", fmt="%.4f") # text CSV
arr3 = np.loadtxt("data.csv", delimiter=",")
# Multiple arrays in one archive
np.savez("arrays.npz", a=arr, b=arr[:10])
loaded = np.load("arrays.npz")
print(loaded["a"].shape) # (1000, 1000)performance_patterns.pyFor datasets too large to load at once, use
np.memmap (memory-mapped
file) to treat a binary file on disk as a NumPy array — only the slices you access
are loaded into RAM. For structured / multi-dataset storage, prefer
h5py (HDF5 format), which supports compression, chunking, and
partial reads natively.
Structured Arrays
Structured arrays let you store heterogeneous data (like a CSV with mixed types) in a single NumPy array — each element is a record with named fields.
import numpy as np
# Define a dtype with named fields
dt = np.dtype([
("name", "U20"), # Unicode string, max 20 chars
("age", np.int32),
("score", np.float64),
])
data = np.array([
("Alice", 30, 95.5),
("Bob", 25, 87.2),
("Carol", 28, 91.0),
], dtype=dt)
# Access by field name
print(data["name"]) # ['Alice' 'Bob' 'Carol']
print(data["score"]) # [95.5 87.2 91. ]
# Filter with boolean indexing
data[data["score"] > 90] # Alice and Carol
# Sort by field
data_sorted = np.sort(data, order="score") # ascending by score
structured_arrays.py
Stacking, Splitting & Combining
import numpy as np
a = np.array([[1, 2], [3, 4]]) # (2, 2)
b = np.array([[5, 6], [7, 8]]) # (2, 2)
# ── Stacking ──
np.vstack([a, b]) # vertical stack → (4, 2)
np.hstack([a, b]) # horizontal stack → (2, 4)
np.dstack([a, b]) # depth stack → (2, 2, 2)
np.concatenate([a, b], axis=0) # same as vstack
np.concatenate([a, b], axis=1) # same as hstack
np.stack([a, b], axis=0) # new axis → (2, 2, 2)
np.stack([a, b], axis=2) # new axis at position 2 → (2, 2, 2)
# ── Splitting ──
c = np.arange(12).reshape(4, 3)
np.vsplit(c, 2) # split into 2 equal parts along axis 0
np.hsplit(c, 3) # split into 3 equal parts along axis 1
np.array_split(c, 3, axis=0) # unequal splits allowed
# ── Repeat & tile ──
np.repeat(np.array([1, 2, 3]), 3) # [1,1,1,2,2,2,3,3,3]
np.tile(np.array([1, 2, 3]), (2, 3)) # 2 rows, repeat 3 times along cols
# ── Sorting & searching ──
arr = np.array([3, 1, 4, 1, 5, 9, 2, 6])
np.sort(arr) # sorted copy
arr.sort() # in-place sort
np.argsort(arr) # indices that would sort the array
np.searchsorted(np.sort(arr), 5) # binary search → insertion index
np.unique(arr) # unique values (sorted)
np.unique(arr, return_counts=True) # with occurrence counts
stacking.py
A Complete NumPy Workflow
Putting it all together: load tabular data, clean it, compute statistics, and apply a vectorised transformation.
import numpy as np
# ── Simulate loading a dataset ──
rng = np.random.default_rng(42)
raw = rng.normal(loc=50, scale=15, size=(200, 4)) # 200 samples, 4 features
# ── Inspect ──
print(raw.shape) # (200, 4)
print(raw.dtype) # float64
print(raw[:3]) # first 3 rows
# ── Descriptive statistics per feature ──
print("mean :", raw.mean(axis=0))
print("std :", raw.std(axis=0))
print("min :", raw.min(axis=0))
print("max :", raw.max(axis=0))
# ── Min-max normalisation (vectorised, no loops) ──
col_min = raw.min(axis=0) # shape (4,)
col_max = raw.max(axis=0) # shape (4,)
normalised = (raw - col_min) / (col_max - col_min) # broadcasting
# ── Z-score standardisation ──
mean = raw.mean(axis=0)
std = raw.std(axis=0)
standardised = (raw - mean) / std
# ── Remove rows where any feature > 3 std devs from mean ──
outlier_mask = np.any(np.abs(standardised) > 3, axis=1)
clean = raw[~outlier_mask]
print(f"Removed {outlier_mask.sum()} outlier rows; {len(clean)} remain")
# ── Correlation matrix ──
corr = np.corrcoef(clean.T) # (4, 4) correlation matrix
print("Feature correlations:\n", np.round(corr, 2))
# ── Save clean dataset ──
np.save("clean_data.npy", clean)
workflow.py
Best Practices
- Never loop over NumPy arrays in Python — always look for a vectorised operation, ufunc, or
np.where/np.selectequivalent first. - Choose the right dtype —
float32uses half the memory offloat64and is faster on GPUs; useint8/uint8for image pixel data. - Slices are views, not copies — call
.copy()explicitly when you need an independent array; modifying a view modifies the original. - Verify shapes with assertions during development —
assert result.shape == (100, 4), result.shape. Silent broadcasting errors are hard to debug. - Use
np.linalg.solve(A, b)notnp.linalg.inv(A) @ b—solveis faster and numerically more stable. - Seed the RNG for reproducibility — use
np.random.default_rng(seed=42)(not the legacynp.random.seed()) in any experiment. - Use
np.savez_compressedfor large arrays — it compresses automatically and can store multiple arrays in a single file. - Profile before optimising — use
%timeitin Jupyter orcProfileto find the actual bottleneck before rewriting.
Exercises
Exercise 1 — Vectorised Statistics from Scratch
Implement the following without any Python loops, using only NumPy:
row_normalise(X)— divide each row by its L2 norm so every row has unit length.pairwise_distances(X)— return the N×N matrix of Euclidean distances between all row pairs of an (N, D) array. Use broadcasting; noscipy.spatial.softmax(x)— computeexp(x) / sum(exp(x))in a numerically stable way (subtract the max before exponentiation).- Test all three on small inputs and verify with known values.
💡 Hint
def row_normalise(X: np.ndarray) -> np.ndarray:
norms = np.linalg.norm(X, axis=1, keepdims=True) # (N, 1)
return X / norms
def pairwise_distances(X: np.ndarray) -> np.ndarray:
# ||a - b||² = ||a||² + ||b||² - 2 a·b
sq = np.sum(X ** 2, axis=1) # (N,)
dist_sq = sq[:, None] + sq[None, :] - 2 * (X @ X.T)
return np.sqrt(np.clip(dist_sq, 0, None)) # clip negatives from floating point
def softmax(x: np.ndarray) -> np.ndarray:
e = np.exp(x - x.max()) # subtract max for numerical stability
return e / e.sum()
Exercise 2 — Image Processing with NumPy
Images are just NumPy arrays (H × W × 3 for RGB). Implement these operations without any image library — pure NumPy only:
- Create a synthetic 100×100 RGB image where the red channel is a horizontal gradient (0 → 255), green is a vertical gradient, and blue is constant 128.
- Convert to greyscale using the luminosity formula:
0.2126·R + 0.7152·G + 0.0722·B. - Apply a horizontal flip and a vertical flip.
- Crop the centre 60×60 pixels.
- Normalise pixel values to [0, 1] as
float32.
💡 Hint
import numpy as np
H, W = 100, 100
img = np.zeros((H, W, 3), dtype=np.uint8)
img[:, :, 0] = np.linspace(0, 255, W, dtype=np.uint8) # R: horizontal
img[:, :, 1] = np.linspace(0, 255, H, dtype=np.uint8)[:, None] # G: vertical
img[:, :, 2] = 128 # B: constant
# Greyscale
weights = np.array([0.2126, 0.7152, 0.0722])
grey = (img @ weights).astype(np.uint8) # (H, W)
# Flips
h_flip = img[:, ::-1, :]
v_flip = img[::-1, :, :]
# Crop centre 60×60
cy, cx = H // 2, W // 2
crop = img[cy-30:cy+30, cx-30:cx+30, :]
# Normalise
norm = img.astype(np.float32) / 255.0
Exercise 3 — Linear Regression with NumPy
Implement ordinary least-squares linear regression from scratch using only NumPy:
- Generate synthetic data:
y = 3x + 2 + noisewith 100 points. - Implement
fit(X, y)that computes the closed-form OLS solution:w = (XᵀX)⁻¹Xᵀyusingnp.linalg.solve. - Implement
predict(X, w)andmse(y_true, y_pred). - Implement
fit_gradient_descent(X, y, lr=0.01, epochs=1000)and verify it converges to the same weights as the closed-form solution. - Plot the data and the fitted line (optional if matplotlib not installed).
💡 Hint
import numpy as np
rng = np.random.default_rng(42)
X_raw = rng.uniform(0, 10, 100)
y = 3 * X_raw + 2 + rng.normal(0, 1, 100)
# Add bias column
X = np.column_stack([np.ones(100), X_raw]) # (100, 2)
def fit(X, y):
# w = (XᵀX)⁻¹Xᵀy — use solve for numerical stability
return np.linalg.solve(X.T @ X, X.T @ y)
def predict(X, w):
return X @ w
def mse(y_true, y_pred):
return np.mean((y_true - y_pred) ** 2)
w = fit(X, y)
print(f"Intercept: {w[0]:.3f}, Slope: {w[1]:.3f}") # ≈ 2, 3
def fit_gd(X, y, lr=0.01, epochs=1000):
w = np.zeros(X.shape[1])
n = len(y)
for _ in range(epochs):
grad = X.T @ (X @ w - y) / n
w -= lr * grad
return w
w_gd = fit_gd(X, y)
print(f"GD — Intercept: {w_gd[0]:.3f}, Slope: {w_gd[1]:.3f}")