🎯 What You'll Learn

  • Perform element-wise and aggregate operations on arrays
  • Understand and apply broadcasting — NumPy's rules for arithmetic on different-shaped arrays
  • Compute dot products and matrix multiplication — the most important operation in ML
  • Use statistical functions: mean, std, sum, max, min along axes
  • Write vectorized code instead of Python loops — and understand why it matters
  • Normalize features for machine learning: zero-mean, unit-variance scaling

1 Element-Wise Operations

NumPy operators (+, -, *, /, **) apply element-by-element when arrays have the same shape. No loops needed.

In [1]:
import numpy as np

a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])

print(a + b)    # [11, 22, 33, 44]  — element-wise addition
print(a * b)    # [10, 40, 90, 160] — element-wise multiplication
print(b / a)    # [10.  10.  10.  10.] — element-wise division
print(a ** 2)   # [ 1   4   9  16]  — element-wise squaring
print(np.sqrt(a)) # [1.    1.414 1.732 2.   ] — element-wise sqrt

The same logic applies to 2D arrays. Every operation targets corresponding positions:

In [2]:
M = np.array([[1, 2], [3, 4]])
N = np.array([[5, 6], [7, 8]])

print(M + N)
# [[ 6  8]
#  [10 12]]

print(M * N)   # element-wise, NOT matrix multiplication
# [[ 5 12]
#  [21 32]]
⚠️
M * N is NOT matrix multiplication

In NumPy, * always means element-wise multiplication. For the linear-algebra matrix product you need in ML (summing the dot products of rows and columns), you must use np.dot(M, N) or the @ operator: M @ N. This is one of the most common mistakes for beginners.

2 Broadcasting — Math Across Different Shapes

Broadcasting is how NumPy handles arithmetic between arrays of different shapes — without copying any data. It's what makes code like arr - arr.mean() work on a 2D array, and it's fundamental to how ML computations are written efficiently.

The simplest case: array + scalar

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

# Adding a scalar to a 2D array
result = arr + 10
print(result)
# [[11 12 13]
#  [14 15 16]]

# The scalar 10 is "broadcast" to every element — no copying

Broadcasting a 1D array across rows of a 2D array

In [4]:
X = np.array([[1, 2, 3],    # shape (3, 3)
              [4, 5, 6],
              [7, 8, 9]])

offset = np.array([10, 20, 30])  # shape (3,)

# NumPy broadcasts offset to every row of X
result = X + offset
print(result)
# [[11 22 33]
#  [14 25 36]
#  [17 28 39]]
1 2 3 4 5 6 7 8 9 X — shape (3, 3) + 10 20 30 10 20 30 10 20 30 offset — shape (3,) faded/dashed rows = virtually repeated, never copied in memory = 11 22 33 14 25 36 17 28 39 result — shape (3, 3)

Broadcasting in action: offset has shape (3,) — just one row. NumPy doesn't physically copy it; it conceptually repeats the row to match every row of X (the faded, dashed cells), then adds element-wise. No extra memory is ever allocated for the repeated copies.

🔑
The Broadcasting Rule

NumPy compares shapes element by element, starting from the trailing (rightmost) dimension. Two dimensions are compatible if they are equal, or if one of them is 1. If the shorter array has fewer dimensions, NumPy pads 1s on the left.

Example: (3, 3) + (3,) → pad to (3, 3) + (1, 3) → broadcast row to match → result (3, 3). ✓

When broadcasting FAILS

In [5]:
a = np.ones((3, 4))   # shape (3, 4)
b = np.ones((3,))     # shape (3,)

# Attempt to add: trailing dimensions are 4 and 3 — neither is 1
try:
    result = a + b
except ValueError as e:
    print(e)
# operands could not be broadcast together with shapes (3,4) (3,)

To broadcast along columns instead, you need to add an axis to b so its shape becomes (3, 1):

In [6]:
b_col = b.reshape(-1, 1)  # shape: (3, 1)
result = a + b_col         # shapes (3, 4) + (3, 1) → broadcast to (3, 4) ✓
print(result.shape)        # (3, 4)

Same a (shape (3, 4), all ones), same b (shape (3,)) — but reshaping b changes which axis it broadcasts along. Toggle between the two to see how the result heatmap changes:

a + b: the row vector [1,2,3,4] is repeated for all 3 rows of a.

Practical Use: Adjusting Every Row of a Dataset at Once

Suppose 100 weather stations each report 5 sensor readings, and you know each sensor type has a fixed calibration error. Broadcasting lets you correct the entire dataset in one line — no loop over rows:

In [7]:
# Readings from 100 weather stations, 5 sensors each
readings = np.random.randn(100, 5)       # shape (100, 5)

# Known calibration offset for each of the 5 sensor types
offsets = np.array([0.5, -0.2, 0.0, 1.1, -0.7])  # shape (5,)

# Broadcasting: apply each sensor's offset to every station at once
corrected = readings + offsets           # shape (100, 5)
print(corrected.shape)                   # (100, 5)

This "one row of per-column values applied to the whole table" pattern is everywhere in ML code — you'll use it in this very lesson to normalize features, and constantly after that.

3 Dot Products & Matrix Multiplication

This is the most important mathematical operation in machine learning. Nearly every model you'll build in this course — from the simplest one in Phase 2 to the deep networks in Phase 4 — spends most of its time doing exactly this operation. Learn it well here, with plain numbers, and everything later gets easier.

Dot Product of Two Vectors

The dot product multiplies corresponding elements and sums the results. For two vectors of length n, it produces a single scalar.

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

dot = np.dot(a, b)        # 1×4 + 2×5 + 3×6 = 4 + 10 + 18 = 32
print(dot)                 # 32

# Equivalently using the @ operator (Python 3.5+)
dot2 = a @ b
print(dot2)                # 32

The dot product has a very natural meaning: it's a weighted sum. Here's an example you already know from school — computing a final course grade where homework counts 20%, the midterm 30%, and the final exam 50%:

In [9]:
scores  = np.array([72, 85, 91])       # homework, midterm, final exam
weights = np.array([0.2, 0.3, 0.5])    # how much each one counts

final_grade = scores @ weights          # 0.2×72 + 0.3×85 + 0.5×91
print(final_grade)                      # 85.4

Keep this picture in mind: a dot product combines several values into one number, using a set of weights that says how important each value is. When you reach Phase 2, you'll discover that this is exactly how ML models make predictions — they just learn the weights from data instead of being handed them.

Matrix-Vector Multiplication

Now suppose the school is considering 4 different grading schemes — each weighting homework, midterm, and final differently. Stack the schemes as rows of a matrix, and one matrix-vector multiplication grades the student under all 4 schemes at once. Each row of W does its own dot product with x:

In [10]:
# W: 4 grading schemes × 3 scores, one scheme per row
W = np.array([[0.2, 0.3, 0.5],    # standard
              [0.4, 0.3, 0.3],    # homework-heavy
              [0.0, 0.5, 0.5],    # exams only
              [1/3, 1/3, 1/3]])   # equal weights — shape (4, 3)

x = np.array([72, 85, 91])        # one student's scores, shape (3,)

# Each row of W computes a dot product with x
grades = W @ x   # shape (4,) — one grade per scheme
print(grades)    # [85.4  81.6  88.   82.67]

Matrix-Matrix Multiplication: The Whole Table at Once

In practice you rarely process one sample at a time — you process the whole dataset in a single operation. With 5 students (rows of X) and 4 grading schemes (rows of W), one matrix multiplication computes all 20 grades:

In [11]:
# X: 5 students, 3 scores each
X = np.array([[72, 85, 91],
              [95, 60, 70],
              [88, 88, 88],
              [50, 75, 92],
              [67, 79, 83]])   # shape (5, 3)

# Every row of X (a student) dot-products with every row of W (a scheme)
# (5, 3) @ (3, 4) → (5, 4)
grades = X @ W.T             # shape (5, 4)
print(grades.shape)          # (5, 4) — 5 students × 4 schemes

Zooming into a single output cell makes the rule concrete: each entry of the result is one dot product between a row of X and a row of W (a column of W.T). Here's student 0's scores against grading scheme 0 — the same numbers from the code above:

X — row 0 (student 0) 72 85 91 shape (3,) — one student's scores × W — row 0 (scheme 0) 0.2 0.3 0.5 shape (3,) — used as a column of W.T multiply pairwise 72×.2 85×.3 91×.5 85.4 grades[0, 0] = 14.4 + 25.5 + 45.5 = 85.4

Matrix multiplication, one cell at a time: grades[0, 0] is the dot product of row 0 of X (student 0's scores) with row 0 of W (scheme 0's weights) — multiply each corresponding pair, then sum. Every other cell of the (5, 4) result is computed the exact same way, just with a different student/scheme pairing.

🔑
The Shape Rule for Matrix Multiplication

For A @ B to work: A's last dimension must equal B's first dimension. The result has A's leading dimensions and B's trailing dimensions.

(m, k) @ (k, n) → (m, n)

Example: (5, 3) @ (3, 4) → (5, 4). The inner dimension k=3 must match.

4 Aggregate Functions and the axis Parameter

NumPy provides fast aggregate functions — sum, mean, std, min, max, argmin, argmax — that can operate over the whole array or along specific dimensions.

In [12]:
arr = np.array([[1, 2, 3],
                [4, 5, 6],
                [7, 8, 9]])   # shape (3, 3)

# No axis — operates over ALL elements
print(np.sum(arr))   # 45
print(np.mean(arr))  # 5.0
print(np.max(arr))   # 9

# axis=0 — collapses rows, result is per-column
print(np.sum(arr, axis=0))   # [12, 15, 18]  — sum of each column
print(np.mean(arr, axis=0))  # [4., 5., 6.]  — mean of each column

# axis=1 — collapses columns, result is per-row
print(np.sum(arr, axis=1))   # [6, 15, 24]   — sum of each row
print(np.mean(arr, axis=1))  # [2., 5., 8.]  — mean of each row
💡
Mental Model for axis

Think: "axis=0 collapses along rows" means you're computing one statistic per column. "axis=1 collapses along columns" means one statistic per row. For a dataset with shape (n_samples, n_features): axis=0 gives stats per feature; axis=1 gives stats per sample.

argmin and argmax — where is the extreme value?

In [13]:
temps = np.array([21.9, 19.6, 17.3, 20.7, 15.2, 18.4])  # daily highs

coldest_day = np.argmin(temps)  # index of the smallest value
print(f"Coldest day: {coldest_day}, temp: {temps[coldest_day]}")
# Coldest day: 4, temp: 15.2

# On a 2D array: which product sold the most in each store?
sales = np.array([[10, 80, 10],    # store 0: product 1 wins
                  [70, 20, 10],    # store 1: product 0 wins
                  [30, 30, 40]])   # store 2: product 2 wins
best_product = np.argmax(sales, axis=1)  # best column per row
print(best_product)  # [1, 0, 2]  — column indices, not values

5 Vectorization: Replace Loops with NumPy

In Python, when you want to apply an operation to every element of a list, you write a for loop. In NumPy, you don't — you write the operation once and let NumPy apply it across the whole array in compiled C. This is called vectorization, and it's the single most important performance principle in ML code.

In [14]:
import numpy as np
import time

# Task: compute Euclidean distance between 100,000 pairs of 128-D vectors

n = 100_000
A = np.random.randn(n, 128)
B = np.random.randn(n, 128)

# ❌ Python loop — slow
start = time.time()
distances_loop = []
for i in range(n):
    diff = A[i] - B[i]
    dist = np.sqrt(np.sum(diff ** 2))
    distances_loop.append(dist)
print(f"Loop:       {time.time() - start:.3f}s")

# ✅ Vectorized — fast
start = time.time()
distances_vec = np.sqrt(np.sum((A - B) ** 2, axis=1))
print(f"Vectorized: {time.time() - start:.3f}s")
Out[14]:
Loop: 1.247s Vectorized: 0.011s

The vectorized version is over 100× faster. Training an ML model means repeating operations like this thousands upon thousands of times — so this is the difference between a model that trains in minutes and one that takes hours.

💡
The Vectorization Reflex

Whenever you find yourself writing a for loop over elements of a NumPy array, pause and ask: "Can I express this as a NumPy operation?" Almost always, you can. This reflex will dramatically improve both your code's performance and its readability.

6 Feature Normalization — A Practical ML Workflow

Before feeding data into most ML models, you need to normalize the features — rescale every column so they all live on a comparable range. Why? Many models (you'll meet them in Phase 2) work by comparing and combining feature values. If one feature ranges 0–1,000,000 (e.g., salary) and another ranges 0–1 (e.g., a yes/no flag), the large-scale feature drowns out the small one — not because it's more important, but simply because its numbers are bigger. Normalization removes that accidental advantage.

Min-Max Normalization (scale to [0, 1])

In [15]:
X = np.array([[200,  0.5,  3],   # feature: salary, ratio, count
              [500,  0.1,  7],
              [150,  0.9,  1],
              [800,  0.3,  5]])  # shape (4, 3)

X_min = X.min(axis=0)    # min per feature (column), shape (3,)
X_max = X.max(axis=0)    # max per feature (column), shape (3,)

X_norm = (X - X_min) / (X_max - X_min)
print(X_norm)
# [[0.196  0.5   0.333]
#  [0.542  0.    1.   ]
#  [0.     1.    0.   ]
#  [1.     0.25  0.667]]

Standardization / Z-score Normalization (mean=0, std=1)

The other classic recipe: subtract each column's mean, then divide by its standard deviation (a measure of how spread out the values are — Lesson 06 covers it properly). Each feature ends up centered on 0 with a spread of 1, and this is the more common choice in practice:

In [16]:
X_mean = X.mean(axis=0)   # mean per feature
X_std  = X.std(axis=0)    # std per feature

X_std_norm = (X - X_mean) / X_std

print(f"Mean after standardization:  {X_std_norm.mean(axis=0).round(10)}")
print(f"Std  after standardization:  {X_std_norm.std(axis=0).round(4)}")
# Mean after standardization:  [0. 0. 0.]
# Std  after standardization:  [1. 1. 1.]

The "salary" column above — [200, 500, 150, 800] — has a very different scale than the ratio or count columns. Here's what each normalization technique does to that single column, using the exact numbers from the code:

Raw salary values — scale 150–800, no relation to the unit-scale features it will be combined with.

Same 4 values after min-max scaling (→ [0, 1]) and after z-score standardization (mean 0, std 1).

⚠️
Looking ahead: a rule you'll meet again in Phase 2

Real ML projects always set aside part of the data to test the model on later — data the model never sees during learning. When that time comes, there's an important rule: compute X_mean and X_std from the learning portion only, and reuse those same numbers on the held-back portion. Lesson 10 explains why this matters (it prevents a subtle form of cheating called leakage). For now, just remember that the mean and std you normalize with are themselves values you must be careful about.

7 Useful NumPy Functions Cheat Sheet

In [17]:
arr = np.random.randn(100, 5)

# Statistics
np.mean(arr)               # overall mean
np.mean(arr, axis=0)       # mean per column (per feature)
np.std(arr, axis=0)        # std per feature
np.var(arr)                # variance
np.median(arr, axis=0)     # median per feature
np.percentile(arr, 75)     # 75th percentile
np.cumsum(arr, axis=0)     # cumulative sum along rows

# Shape manipulation
arr.reshape(20, 25)        # reshape (100, 5) → (20, 25)
arr.flatten()              # → (500,)
arr.T                      # transpose: (100, 5) → (5, 100)
np.concatenate([arr, arr], axis=0)  # stack vertically → (200, 5)
np.concatenate([arr, arr], axis=1)  # stack horizontally → (100, 10)
np.stack([arr, arr], axis=0)        # new axis: → (2, 100, 5)

# Set/test operations
np.unique(np.array([1, 2, 2, 3]))  # [1, 2, 3]
np.where(arr > 0, arr, 0)          # replace negatives with 0, keep positives
np.clip(arr, -1, 1)                # clamp values to [-1, 1]
np.sort(arr, axis=0)               # sort along axis

# Linear algebra
np.dot(A, B)               # dot product / matmul
A @ B                      # matrix multiplication (same)
np.linalg.norm(arr)        # L2 (Euclidean) norm
np.linalg.norm(arr, ord=1) # L1 norm
np.linalg.inv(A)           # matrix inverse (square A)
np.linalg.svd(A)           # singular value decomposition (advanced — Phase 3)
🌍

Real-World Spotlight: Feature Normalization at a Fintech Startup

Imagine you're building a credit-scoring model for a fintech company. Your training dataset has 50,000 loan applications with features like:

  • Annual income: ranges from $20,000 to $500,000
  • Credit score: ranges from 300 to 850
  • Debt-to-income ratio: ranges from 0.01 to 0.95
  • Number of late payments: 0 to 30
  • Loan amount requested: $1,000 to $50,000

Without normalization, the income feature (scale ~100,000) numerically drowns out every other column — a model learning from this data would behave as if income were the only feature that exists. The full normalization workflow in NumPy:

In [18]:
import numpy as np

# Simulated credit application data: 50,000 applicants, 5 features
np.random.seed(0)
n = 50_000
income       = np.random.uniform(20_000,  500_000, n)
credit_score = np.random.uniform(300,     850,     n)
dti_ratio    = np.random.uniform(0.01,    0.95,    n)
late_payments = np.random.randint(0,      31,      n).astype(float)
loan_amount  = np.random.uniform(1_000,   50_000,  n)

# Stack into design matrix: shape (50_000, 5)
X = np.column_stack([income, credit_score, dti_ratio, late_payments, loan_amount])
print(f"X shape: {X.shape}")  # (50000, 5)

# Set aside the last 20% of rows as "held-back" data the model
# will never learn from (the why is coming in Lesson 10 — here,
# notice it's just the slicing you learned in Lesson 01)
split = int(0.8 * n)
X_train, X_test = X[:split], X[split:]

# Compute normalization parameters on the learning portion only
train_mean = X_train.mean(axis=0)   # shape (5,)
train_std  = X_train.std(axis=0)    # shape (5,)

# Apply to both parts (broadcasting handles the (5,) vs (n, 5) shape)
X_train_norm = (X_train - train_mean) / train_std
X_test_norm  = (X_test  - train_mean) / train_std  # same stats — no peeking!

print(f"Train mean after norm: {X_train_norm.mean(axis=0).round(4)}")
print(f"Train std  after norm: {X_train_norm.std(axis=0).round(4)}")
# Train mean: [0. 0. 0. 0. 0.]
# Train std:  [1. 1. 1. 1. 1.]

Now all five features live on the same scale, so a model trained on this data can weigh each one on its merits rather than its magnitude. This normalization step is standard practice before training almost any ML model — and in Phase 2 you'll meet a tool (scikit-learn's StandardScaler) that packages exactly the logic you just wrote by hand.

✍️ Practice Exercise

  1. Create a 5×3 matrix of random floats. Compute the mean of each column (feature means). Subtract the column means from every row (zero-centering). Verify that the result has column means very close to 0.
  2. Write a vectorized function that computes the softmax of a 1D array: softmax(x) = exp(x) / sum(exp(x)). Verify that the output sums to exactly 1.0. (Softmax turns any list of numbers into positive fractions that sum to 1 — a recipe you'll use again and again from Phase 2 onward.)
  3. Given two matrices A of shape (4, 3) and B of shape (3, 6), multiply them using @. What shape is the result? Now try to multiply them in the wrong order: what error do you get and why?
▶ Show Solution
In [19]:
import numpy as np

# Task 1: zero-centering
X = np.random.rand(5, 3)
col_means = X.mean(axis=0)         # shape (3,)
X_centered = X - col_means         # broadcasting: (5,3) - (3,) → (5,3)
print(X_centered.mean(axis=0))     # ≈ [0., 0., 0.]

# Task 2: softmax
def softmax(x):
    e = np.exp(x - x.max())  # subtract max for numerical stability
    return e / e.sum()

scores = np.array([2.0, 1.0, 0.5, -1.0])
probs = softmax(scores)
print(probs)           # [0.653, 0.24, 0.0..., ...]  — sums to 1
print(probs.sum())     # 1.0

# Task 3: matrix multiplication shapes
A = np.random.rand(4, 3)
B = np.random.rand(3, 6)
C = A @ B
print(C.shape)  # (4, 6) ← (m, k) @ (k, n) → (m, n)

try:
    bad = B @ A  # (3, 6) @ (4, 3) — inner dims 6 ≠ 4
except ValueError as e:
    print(e)  # matmul: Input operand 1 has a mismatch in its core dimension

📚 Primary Source for This Lesson

NumPy Broadcasting Guide — official NumPy documentation
The authoritative, visual explanation of broadcasting rules with worked examples. After this lesson, read it end-to-end — the diagrams will cement your understanding of how shapes interact.

💬 Broadcasting errors are among the most common in ML code. If you get a shape mismatch, paste the error and your array shapes into the chat — your tutor will diagnose it immediately.