Chapter 01 — NumPy

NumPy Fundamentals

The backbone of numerical computing in Python. Learn arrays, broadcasting, vectorization, and linear algebra — everything you need before diving into ML.

numpy ndarray broadcasting linalg np.random
01

NumPy Arrays — Creation & Properties

What is a NumPy Array?

A NumPy ndarray (N-dimensional array) is the core data structure of NumPy. Unlike Python lists, arrays store elements of the same data type in contiguous memory — making them extremely fast for numerical computations.

Why NumPy over lists?
NumPy arrays are up to 50x faster than Python lists for numerical operations because they use vectorized C code under the hood and avoid Python object overhead.
Creating Arrays

There are many ways to create arrays. The most common methods are shown below:

array_creation.py
import numpy as np

# From Python list
arr1 = np.array([1, 2, 3, 4, 5])

# 2D array from nested list
arr2 = np.array([[1, 2, 3],
                [4, 5, 6]])

# Filled arrays
zeros  = np.zeros((3, 4))     # 3x4 array of 0.0
ones   = np.ones((2, 3))      # 2x3 array of 1.0
full   = np.full((2, 2), 7)   # 2x2 filled with 7
eye    = np.eye(3)            # 3x3 identity matrix

# Range-based arrays
range_arr = np.arange(0, 10, 2)      # [0,2,4,6,8]
linear    = np.linspace(0, 1, 5)    # 5 evenly spaced from 0→1

print(arr2.shape)    # (2, 3)
print(arr2.ndim)     # 2
print(arr2.dtype)    # int64
print(arr2.size)     # 6
Output
(2, 3) 2 int64 6
FunctionDescription
np.array()Create array from list or nested list
np.zeros(shape)Array filled with 0.0
np.ones(shape)Array filled with 1.0
np.full(shape, val)Array filled with specific value
np.eye(n)n×n identity matrix
np.arange(start, stop, step)Like Python range() but returns array
np.linspace(start, stop, n)n evenly spaced values between start and stop
np.empty(shape)Uninitialized array (fast, but values are garbage)
01b

Indexing & Slicing

NumPy supports powerful indexing — regular indexing, slicing, boolean masking, and fancy indexing. These are essential for selecting data in ML pipelines.

indexing.py
import numpy as np

arr = np.array([10, 20, 30, 40, 50])

# ── 1D Indexing ──
print(arr[0])        # 10  (first element)
print(arr[-1])       # 50  (last element)
print(arr[1:4])      # [20 30 40]
print(arr[::2])      # [10 30 50] (every 2nd)

# ── 2D Indexing ──
mat = np.array([[1,2,3],[4,5,6],[7,8,9]])
print(mat[1, 2])     # 6  (row 1, col 2)
print(mat[0:2, 1:3])  # [[2,3],[5,6]]
print(mat[:, 1])     # [2 5 8]  (entire column 1)

# ── Boolean Masking ──
data = np.array([5, 12, 3, 18, 7])
mask = data > 8
print(mask)          # [False  True False  True False]
print(data[mask])     # [12 18]

# ── Fancy Indexing (pick specific indices) ──
print(data[[0, 2, 4]])  # [5 3 7]
Output
10 | 50 | [20 30 40] | [10 30 50] 6 | [[2 3][5 6]] | [2 5 8] [False True False True False] [12 18] [5 3 7]
Pro Tip — Views vs Copies
Slicing returns a view (modifying it changes the original). Boolean/fancy indexing returns a copy. Use .copy() when you need an independent array: arr[1:3].copy()
01c

Reshaping Arrays

Reshaping is one of the most used operations in ML — converting between 1D, 2D, and 3D arrays when passing data between models and layers.

reshape.py
import numpy as np

arr = np.arange(12)   # [0,1,2,...,11]

# reshape: total elements must match
mat = arr.reshape(3, 4)    # 3 rows, 4 cols
mat2 = arr.reshape(2, -1)   # -1 = NumPy figures out cols

# Flatten back to 1D
flat1 = mat.ravel()      # returns view when possible
flat2 = mat.flatten()    # always returns a copy

# Transpose: swap rows and columns
print(mat.T.shape)       # (4, 3)

# Add/remove dimensions
x = np.array([1, 2, 3])   # shape (3,)
x_col = np.expand_dims(x, axis=1)   # shape (3,1) column vector
x_back = np.squeeze(x_col)           # back to (3,)

print(mat)
print(mat2.shape)
Output
[[ 0 1 2 3] [ 4 5 6 7] [ 8 9 10 11]] (2, 6)
Common Mistake
reshape() requires the total number of elements to stay the same. Reshaping a (12,) array to (4, 4) will raise a ValueError since 4×4=16 ≠ 12.
01d

Stacking & Splitting

stack_split.py
import numpy as np

a = np.array([1, 2, 3])
b = np.array([4, 5, 6])

# Stack horizontally (side by side)
print(np.hstack([a, b]))     # [1 2 3 4 5 6]

# Stack vertically (on top of each other)
print(np.vstack([a, b]))
# [[1 2 3]
#  [4 5 6]]

# Concatenate along axis
print(np.concatenate([a, b], axis=0))  # [1 2 3 4 5 6]

# Split array into parts
arr = np.arange(9)
parts = np.split(arr, 3)          # 3 equal parts
print(parts)

# Split 2D array
mat = np.arange(16).reshape(4,4)
h = np.hsplit(mat, 2)              # split into 2 column halves
v = np.vsplit(mat, 2)              # split into 2 row halves
Output
[1 2 3 4 5 6] [[1 2 3] [4 5 6]] [array([0,1,2]), array([3,4,5]), array([6,7,8])]
02

Broadcasting

Broadcasting is NumPy's ability to perform operations on arrays of different shapes without copying data. NumPy automatically "stretches" the smaller array to match the larger one.

Broadcasting Rules

Two arrays are compatible for broadcasting if, for each dimension pair (aligned from the right), the sizes are either equal or one of them is 1.

broadcasting.py
import numpy as np

# Scalar with array (simplest broadcast)
arr = np.array([1, 2, 3, 4])
print(arr * 2)             # [2 4 6 8]

# 1D array with 2D array
mat = np.array([[1,2,3],
               [4,5,6]])   # shape (2,3)
row = np.array([10, 20, 30])    # shape (3,)
print(mat + row)
# [[11 22 33]       row added to EACH row of mat
#  [14 25 36]]

# Column broadcast — shape (2,1) + (1,3)
col = np.array([[100], [200]])  # shape (2,1)
print(mat + col)
# [[101 102 103]
#  [204 205 206]]
Output
[2 4 6 8] [[11 22 33] [14 25 36]] [[101 102 103] [204 205 206]]
02b

Vectorization

Vectorization means replacing Python for loops with NumPy operations that run in optimized C code. This is a fundamental concept in writing efficient ML code.

vectorization.py
import numpy as np
import time

arr = np.arange(1_000_000)

# ── Slow: Python loop ──
t0 = time.time()
result = []
for x in arr:
    result.append(x ** 2)
print(f"Loop: {time.time()-t0:.3f}s")

# ── Fast: Vectorized ──
t1 = time.time()
result = arr ** 2
print(f"Vectorized: {time.time()-t1:.4f}s")

# ── np.vectorize() — apply custom function element-wise ──
def my_func(x):
    return x ** 2 + 1

vfunc = np.vectorize(my_func)
print(vfunc(np.array([1, 2, 3])))  # [2 5 10]
Output (approximate)
Loop: 0.412s Vectorized: 0.003s [2 5 10]
Rule of Thumb
If you're writing a for loop over a NumPy array, there's almost always a vectorized alternative. Always prefer operations like arr * 2, np.sum(arr), arr[arr > 0] over loops.
03

Math Functions — Basic & Aggregate

math_functions.py
import numpy as np

a = np.array([4, 9, 16, 25])

# Element-wise math
print(np.sqrt(a))         # [2. 3. 4. 5.]
print(np.abs([-3, 4]))    # [3 4]
print(np.power(a, 2))    # [16 81 256 625]

# ── Aggregate functions ──
mat = np.array([[1,2,3],
               [4,5,6]])

print(np.sum(mat))           # 21  (total)
print(np.sum(mat, axis=0))   # [5 7 9]   column sums
print(np.sum(mat, axis=1))   # [6 15]    row sums

print(np.mean(mat))          # 3.5
print(np.median(mat))        # 3.5
print(np.std(mat))           # 1.708
print(np.var(mat))           # 2.916
print(np.min(mat), np.max(mat))  # 1  6
Output
[2. 3. 4. 5.] | [3 4] | [16 81 256 625] 21 | [5 7 9] | [6 15] mean=3.5 median=3.5 std=1.708 var=2.916
Understanding axis parameter
axis=0 means operate down the rows (column-wise result). axis=1 means operate across columns (row-wise result). Think of axis as "which dimension collapses".
03b

Sorting & Searching

sort_search.py
import numpy as np

arr = np.array([3, 1, 4, 1, 5, 9, 2, 6])

print(np.sort(arr))          # [1 1 2 3 4 5 6 9] — returns sorted copy
print(np.argsort(arr))       # [1 3 6 0 2 4 7 5] — indices that would sort
print(np.argmin(arr))        # 1  — index of minimum value
print(np.argmax(arr))        # 5  — index of maximum value
print(np.unique(arr))        # [1 2 3 4 5 6 9] — unique values

# np.where — conditional selection
data = np.array([-2, 5, -1, 8, -3])
result = np.where(data > 0, data, 0)  # replace negatives with 0
print(result)                          # [0 5 0 8 0]

# unique with counts
vals, counts = np.unique(arr, return_counts=True)
print(dict(zip(vals, counts)))   # {1:2, 2:1, 3:1, ...}
Output
[1 1 2 3 4 5 6 9] [1 3 6 0 2 4 7 5] index_min=1 index_max=5 [1 2 3 4 5 6 9] [0 5 0 8 0]
03c

Linear Algebra — np.linalg

Linear algebra is the language of machine learning. Matrix multiplication, eigenvalues, and norms appear constantly in ML algorithms.

linalg.py
import numpy as np

A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])

# Dot product / Matrix multiplication
print(np.dot(A, B))         # [[19 22] [43 50]]
print(A @ B)               # Same as np.dot — @ is preferred in modern Python

# Determinant
print(np.linalg.det(A))    # -2.0

# Inverse
print(np.linalg.inv(A))
# [[-2.   1. ]
#  [ 1.5 -0.5]]

# Eigenvalues and Eigenvectors
vals, vecs = np.linalg.eig(A)
print("Eigenvalues:", vals)  # [-0.372  5.372]

# Norm (length of a vector)
v = np.array([3, 4])
print(np.linalg.norm(v))    # 5.0  (Euclidean distance)
Output
[[19 22] [43 50]] det = -2.0 Eigenvalues: [-0.37228132 5.37228132] norm = 5.0
ML Connection
The @ operator for matrix multiplication is used constantly in deep learning (weight matrices). np.linalg.norm() appears in regularization (L2 norm). Eigenvalues appear in PCA.
03d

Random Module — np.random

The np.random module is used for generating random data, initializing weights, and creating synthetic datasets for testing.

random.py
import numpy as np

# Always set seed for reproducibility!
np.random.seed(42)

# Uniform distribution [0, 1)
print(np.random.rand(3, 2))    # 3x2 matrix of random floats

# Normal (Gaussian) distribution — mean=0, std=1
print(np.random.randn(5))      # 5 values from standard normal

# Random integers
print(np.random.randint(0, 10, size=(2,3)))  # 2x3 ints from 0-9

# Random choice from array
options = ['cat', 'dog', 'bird']
print(np.random.choice(options, size=5))  # random picks with replacement

# Shuffle an array in-place
arr = np.arange(10)
np.random.shuffle(arr)          # modifies arr in-place
print(arr)

# Permutation — returns shuffled copy
print(np.random.permutation(10)) # shuffled [0..9]
Output (with seed=42)
[[0.374 0.951] [0.732 0.599] [0.156 0.058]] [ 0.496 -0.138 0.648 1.523 1.486] [[6 1 4] [4 0 3]]
Always Set a Seed in ML Projects
Without np.random.seed(), your results change every run — making experiments non-reproducible. Set the seed at the top of every notebook or script.
03e

Rounding, Clipping & Trigonometry

rounding_trig.py
import numpy as np

arr = np.array([1.7, 2.3, 3.9, -1.2])

# Rounding
print(np.round(arr, 1))    # [ 1.7  2.3  3.9 -1.2]
print(np.floor(arr))       # [ 1.  2.  3. -2.] round down
print(np.ceil(arr))        # [ 2.  3.  4. -1.] round up

# Clipping — cap values within a range
data = np.array([-5, 2, 15, 7, -1])
print(np.clip(data, 0, 10)) # [0 2 10 7 0] — useful for activation clipping

# Exponential & Logarithm
print(np.exp([0, 1, 2]))   # [1.    2.718  7.389]  — used in softmax!
print(np.log([1, np.e]))   # [0.  1.]  — natural log

# Trigonometry (angles in radians)
angles = np.array([0, np.pi/2, np.pi])
print(np.sin(angles).round(2))   # [0. 1. 0.]
print(np.cos(angles).round(2))   # [1. 0. -1.]
Output
[ 1.7 2.3 3.9 -1.2] [ 1. 2. 3. -2.] [ 2. 3. 4. -1.] [ 0 2 10 7 0] [1. 2.718 7.389]
ML Connection
np.exp() is used in softmax and sigmoid activations. np.log() is used in cross-entropy loss. np.clip() prevents numerical overflow in log computations.