
Python Course
NumPy — Fast Numerical Computing
NumPy is the foundation of the entire Python data science stack. pandas, scikit-learn, TensorFlow, PyTorch, and SciPy all build on top of it. At its core, NumPy provides one thing that Python's built-in lists cannot: a fast, typed, multi-dimensional array where mathematical operations run at C speed across every element simultaneously.
This lesson covers creating and inspecting arrays, vectorised operations, broadcasting, indexing and slicing, reshaping, linear algebra, and random number generation.
Why NumPy Exists — Lists vs Arrays
Python lists are flexible but slow for numerical work. NumPy arrays fix this: all elements share the same type, data sits in a contiguous block of memory, and operations run in compiled C rather than interpreted Python.
import numpy as np
import time
# Speed comparison — sum of 10 million numbers
data = list(range(10_000_000))
arr = np.array(data)
start = time.perf_counter()
total_list = sum(data)
t_list = time.perf_counter() - start
start = time.perf_counter()
total_arr = arr.sum()
t_arr = time.perf_counter() - start
print(f"List sum : {total_list:,} — {t_list:.4f}s")
print(f"NumPy sum: {total_arr:,} — {t_arr:.4f}s")
print(f"Speedup : {t_list / t_arr:.0f}x")
# Memory — each Python int is ~28 bytes; NumPy int64 is exactly 8 bytes
import sys
py_list = list(range(1000))
np_arr = np.arange(1000, dtype=np.int64)
print(f"\nPython list 1000 ints : {sys.getsizeof(py_list):,} bytes")
print(f"NumPy array 1000 int64: {np_arr.nbytes:,} bytes")Creating Arrays
import numpy as np
# From Python sequences
a1 = np.array([1, 2, 3, 4, 5])
a2 = np.array([[1, 2, 3], [4, 5, 6]]) # 2-D array (matrix)
a3 = np.array([1.0, 2, 3], dtype=np.float32) # explicit dtype
print("1-D:", a1, "| dtype:", a1.dtype, "| shape:", a1.shape)
print("2-D:\n", a2, "| shape:", a2.shape)
# Factory functions
print(np.zeros((3, 4))) # 3x4 matrix of zeros (float64)
print(np.ones((2, 3), dtype=int)) # 2x3 matrix of ones (int)
print(np.eye(3)) # 3x3 identity matrix
print(np.full((2, 2), 7)) # 2x2 filled with 7
# Ranges and sequences
print(np.arange(0, 10, 2)) # [0 2 4 6 8]
print(np.linspace(0, 1, 5)) # 5 evenly spaced values 0..1
# Array metadata
print("\nShape:", a2.shape) # (2, 3)
print("Ndim :", a2.ndim) # 2
print("Size :", a2.size) # 6 (total elements)
print("Dtype:", a2.dtype) # int64shape— tuple of dimension sizes;ndim— number of dimensions;size— total element count;dtype— element type.np.arange— like Pythonrangebut returns an array;np.linspace— N evenly spaced values between start and stop (inclusive).- All elements in an array share the same
dtype— assigning a float to an int array truncates it.
Vectorised Operations
Operations on NumPy arrays apply element-wise without any explicit loop. This is called vectorisation — the loop runs in C, not Python.
import numpy as np
a = np.array([1, 2, 3, 4, 5])
b = np.array([10, 20, 30, 40, 50])
# Arithmetic — all element-wise
print(a + b) # [11 22 33 44 55]
print(a * b) # [10 40 90 160 250]
print(b / a) # [10. 10. 10. 10. 10.]
print(a ** 2) # [ 1 4 9 16 25]
# Scalar operations broadcast automatically
print(a * 10) # [10 20 30 40 50]
print(a + 100) # [101 102 103 104 105]
# Universal functions (ufuncs) — vectorised math
print(np.sqrt(a)) # [1. 1.41 1.73 2. 2.24]
print(np.exp(np.array([0, 1, 2]))) # [1. 2.718 7.389]
print(np.log(np.array([1, np.e, np.e**2]))) # [0. 1. 2.]
print(np.abs(np.array([-3, -1, 0, 2, 4]))) # [3 1 0 2 4]
# Aggregations
prices = np.array([4.99, 12.50, 89.99, 1.50, 24.99])
print(f"\nMin: {prices.min():.2f} | Max: {prices.max():.2f}")
print(f"Mean: {prices.mean():.2f} | Std: {prices.std():.2f}")
print(f"Sum: {prices.sum():.2f} | Cumsum: {np.cumsum(prices)}")- Every arithmetic operator applies element-wise — no loops needed.
- NumPy ufuncs like
np.sqrt,np.exp,np.logare vectorised versions of math functions. .mean(),.std(),.sum(),.min(),.max(),.cumsum()— all work on the whole array or along an axis.
Indexing, Slicing, and Boolean Masking
import numpy as np
arr = np.array([10, 20, 30, 40, 50, 60, 70, 80])
# Basic indexing and slicing — same syntax as lists
print(arr[0]) # 10
print(arr[-1]) # 80
print(arr[2:5]) # [30 40 50]
print(arr[::2]) # [10 30 50 70] every other element
print(arr[::-1]) # [80 70 60 50 40 30 20 10] reversed
# 2-D indexing — [row, col]
m = np.array([[1,2,3],[4,5,6],[7,8,9]])
print(m[0, 2]) # 3 — row 0, col 2
print(m[1, :]) # [4 5 6] — entire row 1
print(m[:, 1]) # [2 5 8] — entire column 1
print(m[0:2, 1:3]) # [[2 3] [5 6]] — submatrix
# Boolean masking — select elements meeting a condition
prices = np.array([4.99, 12.50, 89.99, 1.50, 24.99])
mask = prices > 10
print(mask) # [False True True False True]
print(prices[mask]) # [12.5 89.99 24.99] — only matching values
# Fancy indexing — select by list of indices
idx = np.array([0, 2, 4])
print(prices[idx]) # [ 4.99 89.99 24.99]
# NumPy slices are VIEWS not copies — modifying affects the original
view = arr[2:5]
view[0] = 999
print(arr) # [10 20 999 40 50 60 70 80]
copy = arr[2:5].copy() # .copy() to avoid this- NumPy slices return views — they share memory with the original. Use
.copy()to get an independent copy. - Boolean masking is the idiomatic way to filter arrays — equivalent to a list comprehension but far faster.
- Fancy indexing with an array of indices returns a copy, not a view.
Reshaping and Stacking
import numpy as np
a = np.arange(12)
print("Original:", a)
# reshape — same data, new shape (total elements must match)
m = a.reshape(3, 4)
print("3x4:\n", m)
m2 = a.reshape(2, 6)
print("2x6:\n", m2)
# -1 means "infer this dimension"
print(a.reshape(4, -1)) # NumPy figures out columns = 3
print(a.reshape(-1, 1)) # column vector (12, 1)
# flatten — always returns a copy; ravel — returns a view when possible
print("flat:", m.flatten())
print("ravel:", m.ravel())
# Transpose
print("Transpose of 3x4:\n", m.T) # shape becomes (4, 3)
# Stacking arrays
x = np.array([1, 2, 3])
y = np.array([4, 5, 6])
print("hstack:", np.hstack([x, y])) # [1 2 3 4 5 6]
print("vstack:\n", np.vstack([x, y])) # 2x3 matrix
print("stack axis=1:\n", np.stack([x, y], axis=1)) # 3x2reshape(-1, n)— use-1to let NumPy infer one dimension automatically.flatten()always returns a copy;ravel()returns a view when the array is contiguous.arr.Ttransposes — swaps rows and columns.hstackjoins along columns;vstackalong rows;stack(axis=n)gives full control.
Broadcasting
Broadcasting lets NumPy operate on arrays of different shapes by automatically expanding the smaller array along the appropriate dimensions — without copying data.
import numpy as np
# Scalar broadcasts over entire array
a = np.array([1, 2, 3, 4])
print(a + 10) # [11 12 13 14]
# 1-D array broadcasts over rows of 2-D array
m = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
row = np.array([10, 20, 30]) # shape (3,)
print(m + row) # row added to EACH row of m
# Column vector broadcasts over columns
col = np.array([[100], [200], [300]]) # shape (3, 1)
print(m + col) # col added to EACH column of m
# Real-world: normalise each feature column (zero mean, unit std)
data = np.array([[1.0, 200, 0.5],
[2.0, 400, 1.5],
[3.0, 600, 2.5]])
mean = data.mean(axis=0) # mean of each column
std = data.std(axis=0)
normalised = (data - mean) / std # broadcasting handles shape automatically
print("\nNormalised:\n", normalised.round(4))- Broadcasting rule: dimensions are compared from the right; a dimension of 1 stretches to match the other.
axis=0aggregates across rows (giving one value per column);axis=1aggregates across columns.- Feature normalisation — subtract mean, divide by std — is a one-liner in NumPy thanks to broadcasting.
Linear Algebra
import numpy as np
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
# Matrix multiplication — use @ operator (Python 3.5+)
print("A @ B:\n", A @ B)
print("np.dot:\n", np.dot(A, B)) # equivalent
# Element-wise multiply (NOT matrix multiply)
print("A * B:\n", A * B)
# Linear algebra operations
print("\nDeterminant:", np.linalg.det(A))
print("Inverse:\n", np.linalg.inv(A).round(4))
print("Eigenvalues:", np.linalg.eigvals(A).round(4))
# Solve Ax = b — finds x
b = np.array([5, 11])
x = np.linalg.solve(A, b)
print("Solution x:", x) # A @ x should equal b
print("Verify A @ x =", A @ x)
# Norms and trace
print("Frobenius norm:", np.linalg.norm(A))
print("Trace (sum of diagonal):", np.trace(A))- Use
@for matrix multiplication — never*which is element-wise. np.linalg.solve(A, b)— more numerically stable than computing the inverse and multiplying.np.trace(A)— sum of the diagonal elements.
Random Number Generation
import numpy as np
rng = np.random.default_rng(seed=42) # reproducible generator (modern API)
# Uniform floats [0, 1)
print(rng.random((2, 3)))
# Integers in range
print(rng.integers(1, 7, size=10)) # 10 dice rolls
# Normal distribution — mean=0, std=1
samples = rng.standard_normal(10_000)
print(f"Mean: {samples.mean():.4f} | Std: {samples.std():.4f}")
# Normal with custom mean and std
heights = rng.normal(loc=170, scale=10, size=5) # cm
print("Heights:", heights.round(1))
# Shuffle and choice
arr = np.arange(10)
rng.shuffle(arr)
print("Shuffled:", arr)
picks = rng.choice(arr, size=4, replace=False) # no replacement
print("Picked:", picks)
# Old API for reference (still common in tutorials)
# np.random.seed(42); np.random.randn(3)np.random.default_rng(seed)— the modern, reproducible API. Always prefer it overnp.random.seed().rng.integers(low, high, size)— high is exclusive (like Pythonrange).rng.normal(loc, scale, size)— normal distribution with specified mean and standard deviation.rng.choice(arr, size, replace=False)— sample without replacement.
Quick Reference Table
| Operation | NumPy Code | Notes |
|---|---|---|
| Create from list | np.array([1,2,3]) | Specify dtype= optionally |
| Zeros / Ones / Identity | np.zeros(shape) / np.ones / np.eye(n) | shape is a tuple |
| Range / Linspace | np.arange(start, stop, step) / np.linspace | arange excludes stop; linspace includes it |
| Reshape | arr.reshape(rows, -1) | -1 infers the missing dimension |
| Boolean mask | arr[arr > 10] | Returns only matching elements |
| Matrix multiply | A @ B | Never use * for matrix multiply |
| Axis aggregation | arr.mean(axis=0) | axis=0 across rows, axis=1 across cols |
Practice
Why are NumPy arrays faster than Python lists for numerical operations?
What is the key difference between np.arange and np.linspace regarding the stop value?
What does a NumPy slice return — a copy or a view?
Which Python operator performs matrix multiplication in NumPy?
What is the difference between arr.mean(axis=0) and arr.mean(axis=1)?
What is the modern NumPy API for creating a reproducible random number generator?
Quick Quiz
Which four attributes describe the structure of a NumPy array?
What does prices[prices > 10] return?
Which reshape call turns a 1-D array of 12 elements into a column vector of shape (12, 1)?
What is the recommended NumPy way to solve a linear system Ax = b?
How does NumPy broadcasting work?
What is the difference between flatten() and ravel()?