NumPy — Numerical Python
NumPy is the foundational package for numerical computing in Python. Every other library in the analytics stack — Pandas, Matplotlib, scikit-learn, SciPy — is built on top of it.
Why NumPy Instead of Python Lists?
| Feature | Python list | NumPy array (ndarray) |
|---|---|---|
| Data type | Heterogeneous (mixed types) | Homogeneous (one dtype) |
| Memory | Stores pointers to objects — scattered | Contiguous block — compact |
| Speed | Slow (Python-level loops) | 10–100× faster (vectorised C loops) |
| Element-wise operations | Requires a loop or comprehension | Built in: a + b, a * 2 |
| Multi-dimensional | Nested lists, awkward | Native n-dimensional support |
| Memory usage | High | Low |
import numpy as np
import time
size = 1_000_000
py_list1 = list(range(size))
py_list2 = list(range(size))
np_arr1 = np.arange(size)
np_arr2 = np.arange(size)
start = time.time()
result_list = [a + b for a, b in zip(py_list1, py_list2)]
list_time = time.time() - start
start = time.time()
result_arr = np_arr1 + np_arr2 # vectorised — no Python loop at all
np_time = time.time() - start
print(f"Python list: {list_time:.4f}s")
print(f"NumPy array: {np_time:.4f}s")
print(f"NumPy is {list_time / np_time:.0f}x faster")
# NumPy is typically 30-80x faster on this operation
Creating Arrays
import numpy as np
# From a Python list
a = np.array([1, 2, 3, 4, 5])
b = np.array([[1, 2, 3], [4, 5, 6]]) # 2-D array
# Built-in generators
print(np.zeros((2, 3))) # 2x3 of zeros
print(np.ones((3, 2))) # 3x2 of ones
print(np.full((2, 2), 7)) # filled with 7
print(np.eye(3)) # 3x3 identity matrix
print(np.arange(0, 10, 2)) # [0 2 4 6 8]
print(np.linspace(0, 1, 5)) # [0. 0.25 0.5 0.75 1. ]
print(np.random.rand(2, 3)) # uniform [0,1)
print(np.random.randn(3)) # standard normal
print(np.random.randint(1, 100, 5)) # random integers
print(np.random.seed(42)) # reproducibility
Array Attributes
arr = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]])
print("Array:\n", arr)
print("Shape: ", arr.shape) # (3, 4) -> 3 rows, 4 columns
print("Dimensions:", arr.ndim) # 2
print("Size: ", arr.size) # 12 (total elements)
print("Data type:", arr.dtype) # int64
print("Item size:", arr.itemsize, "bytes") # 8
print("Total bytes:", arr.nbytes) # 96
Indexing and Slicing
arr = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]])
print(arr[0, 2]) # 3 element at row 0, column 2
print(arr[1]) # [5 6 7 8] entire row 1
print(arr[:, 1]) # [2 6 10] entire column 1
print(arr[0:2, 1:3]) # [[2 3] [6 7]] sub-matrix
print(arr[-1, -1]) # 12 last element
print(arr[::2]) # every other row
# BOOLEAN INDEXING — the most useful feature for data analysis
print(arr[arr > 6]) # [ 7 8 9 10 11 12]
print(arr[(arr > 3) & (arr < 9)]) # [4 5 6 7 8] note & not 'and'
# FANCY INDEXING — select by a list of indices
row_picks = np.array([0, 2])
print(arr[row_picks]) # rows 0 and 2
# Conditional replacement
arr_copy = arr.copy()
arr_copy[arr_copy > 8] = 0
print(arr_copy)
print(np.where(arr > 6, "High", "Low")) # element-wise if-else
View vs copy: basic slicing returns a view that shares memory with the original — modifying the slice modifies the original. Use .copy() when you need an independent array. Boolean and fancy indexing always return copies.
Array Operations — Vectorisation
a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])
print(a + b) # [11 22 33 44]
print(b - a) # [ 9 18 27 36]
print(a * b) # [ 10 40 90 160] ELEMENT-WISE, not matrix multiply
print(b / a) # [10. 10. 10. 10.]
print(a ** 2) # [ 1 4 9 16]
print(a + 100) # [101 102 103 104] broadcasting a scalar
# Universal functions (ufuncs)
print(np.sqrt(np.array([4, 9, 16]))) # [2. 3. 4.]
print(np.exp(np.array([0, 1, 2])).round(3)) # [1. 2.718 7.389]
print(np.log(np.array([1, np.e])).round(3)) # [0. 1.]
print(np.abs(np.array([-3, -1, 2]))) # [3 1 2]
print(np.round(np.array([1.234, 5.678]), 1))# [1.2 5.7]
Broadcasting
Broadcasting lets NumPy operate on arrays of different shapes by virtually stretching the smaller one.
Rules (compared from the trailing dimension backwards):
1. Dimensions are compatible if they are EQUAL, or one of them is 1
2. A missing dimension is treated as 1
(3, 4) + (4,) -> the (4,) is stretched across all 3 rows ✓
(3, 1) + (1, 4) -> both stretched -> result (3, 4) ✓
(3, 4) + (3,) -> INCOMPATIBLE (4 vs 3) ✗
matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
row = np.array([10, 20, 30])
print(matrix + row) # row is added to EVERY row
# [[11 22 33]
# [14 25 36]
# [17 28 39]]
col = np.array([[100], [200], [300]])
print(matrix + col) # col is added to every column
# Practical use: standardising each column (z-score, from Unit 1)
data = np.random.randn(100, 3) * [10, 5, 2] + [50, 20, 5]
standardised = (data - data.mean(axis=0)) / data.std(axis=0)
print("Column means after standardising:", standardised.mean(axis=0).round(10))
print("Column SDs after standardising: ", standardised.std(axis=0).round(10))
# means ≈ 0, SDs = 1
Statistical Functions — Unit 2 in One Line Each
data = np.array([[85, 90, 78], [92, 88, 95], [70, 75, 82], [88, 91, 79]])
print("Sum: ", data.sum()) # all elements
print("Col sums: ", data.sum(axis=0)) # down the columns
print("Row sums: ", data.sum(axis=1)) # across the rows
print("Mean: ", data.mean().round(2)) # 84.42
print("Col means:", data.mean(axis=0).round(2)) # [83.75 86. 83.5 ]
print("Median: ", np.median(data)) # 86.5
print("Std dev: ", data.std().round(3)) # population SD
print("Std (n-1):", data.std(ddof=1).round(3)) # sample SD
print("Variance: ", data.var().round(3))
print("Min/Max: ", data.min(), data.max())
print("Argmax: ", data.argmax()) # index of the maximum
print("Percentiles:", np.percentile(data, [25, 50, 75]))
print("Correlation:\n", np.corrcoef(data[:, 0], data[:, 1]).round(4))
print("Cumulative sum:", np.cumsum([1, 2, 3, 4])) # [ 1 3 6 10]
Theaxisargument is the most common source of confusion.axis=0collapses rows (giving one result per column);axis=1collapses columns (one result per row).
Reshaping and Combining
arr = np.arange(12)
print(arr.reshape(3, 4)) # 3 rows x 4 columns
print(arr.reshape(4, -1)) # -1 means "infer this dimension" -> 4x3
print(arr.reshape(3, 4).T) # transpose -> 4x3
print(arr.reshape(3, 4).flatten())# back to 1-D (a copy)
print(arr.reshape(3, 4).ravel()) # back to 1-D (a view where possible)
a = np.array([[1, 2], [3, 4]])
b = np.array([[5, 6], [7, 8]])
print(np.concatenate([a, b], axis=0)) # stack vertically -> 4x2
print(np.concatenate([a, b], axis=1)) # stack horizontally -> 2x4
print(np.vstack([a, b])) # same as axis=0
print(np.hstack([a, b])) # same as axis=1
print(np.split(np.arange(9), 3)) # split into 3 equal parts
Linear Algebra
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
print(A * B) # ELEMENT-WISE multiplication
print(A @ B) # MATRIX multiplication (same as np.dot(A, B))
print(np.dot(A, B))
print("Determinant:", round(np.linalg.det(A), 4)) # -2.0
print("Inverse:\n", np.linalg.inv(A).round(4))
print("Rank:", np.linalg.matrix_rank(A))
eigenvalues, eigenvectors = np.linalg.eig(A)
print("Eigenvalues:", eigenvalues.round(4))
# Solving a system: 2x + y = 11, x + 3y = 18
coeffs = np.array([[2, 1], [1, 3]])
constants = np.array([11, 18])
print("Solution [x, y]:", np.linalg.solve(coeffs, constants)) # [3. 5.]
Handling Missing Values
arr = np.array([1.0, 2.0, np.nan, 4.0, np.nan, 6.0])
print("Has NaN:", np.isnan(arr))
print("Count of NaN:", np.isnan(arr).sum()) # 2
print("mean(): ", arr.mean()) # nan — NaN poisons the result
print("nanmean():", np.nanmean(arr)) # 3.25 — NaN-aware version
print("nansum(): ", np.nansum(arr)) # 13.0
print("nanstd(): ", round(np.nanstd(arr), 4))
# Impute with the mean (Unit 1 cleaning, vectorised)
filled = np.where(np.isnan(arr), np.nanmean(arr), arr)
print("Imputed:", filled)
A Practical Analytics Example
np.random.seed(42)
# Marks of 50 students across 4 subjects
marks = np.random.randint(35, 100, size=(50, 4))
subjects = ["DA", "DBMS", "OS", "NLP"]
student_totals = marks.sum(axis=1)
student_avgs = marks.mean(axis=1)
subject_avgs = marks.mean(axis=0)
print("Subject averages:", dict(zip(subjects, subject_avgs.round(2))))
print("Class average:", round(marks.mean(), 2))
print("Topper index:", student_totals.argmax(), "with", student_totals.max(), "/400")
# Grade assignment — fully vectorised, no loop
grades = np.select(
[student_avgs >= 85, student_avgs >= 70, student_avgs >= 55, student_avgs >= 40],
["A", "B", "C", "D"],
default="F",
)
unique, counts = np.unique(grades, return_counts=True)
print("Grade distribution:", dict(zip(unique, counts)))
# Students failing in any subject
failing = np.any(marks < 40, axis=1)
print(f"Students failing at least one subject: {failing.sum()}")
# Percentile rank of each student
percentiles = np.array([(student_totals < t).mean() * 100 for t in student_totals])
print("Highest percentile:", percentiles.max().round(1))
NumPy provides the fast numeric engine. Pandas, the next lesson, wraps it in labelled, table-like structures that match how analysts actually think about data.