Week 2: Linear Algebra & Probability for Machine Learning

Two ideas quietly run underneath everything from Week 11 onward: matrix multiplication (how every neural network layer, and every attention head, actually computes) and probability (how a model expresses uncertainty and turns raw scores into a distribution over outcomes). This week builds both from first principles, entirely in NumPy, so nothing in a transformer paper later feels like unfamiliar notation.

Phase 1 of 8 Week 2 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Read and compute vector/matrix notation, including matrix multiplication by hand
  • Measure similarity between vectors using norms and cosine similarity
  • Apply Bayes' theorem and explain what softmax actually does to a set of scores

1. Vectors & Matrices

You don't need a semester of linear algebra to start — you need to recognize three objects, because they're the literal building blocks of every model from Week 11 onward.

  • Scalar — a single number (e.g. a learning rate)
  • Vector — an ordered list of numbers (e.g. one data point's features, or one word's embedding)
  • Matrix — a 2D grid of numbers (e.g. a whole dataset, or a neural network layer's weights)
vectors_matrices.py
import numpy as np

v = np.array([1.5, 0.0, 3.2])           # a vector, shape (3,)
M = np.array([[1, 2, 3], [4, 5, 6]])    # a matrix, shape (2, 3) -- 2 rows, 3 columns

# The transpose flips rows and columns
M_T = M.T                                # shape (3, 2)

The transpose shows up constantly in the notation you'll read later — a weight matrix applied one way in a forward pass is frequently transposed to compute a gradient going backward, and you'll see Wᵀ throughout the transformer paper in Week 16.

2. The Dot Product & Matrix Multiplication

The one operation to internalize now is the dot product — multiply corresponding elements of two vectors, then sum the results:

dot_product.py
features = np.array([1.5, 0.0, 3.2])   # one data point
weights  = np.array([0.4, 0.9, -0.1])  # a model's learned weights

# Dot product, by hand
manual = sum(f * w for f, w in zip(features, weights))

# Dot product, the NumPy way
dot = features @ weights          # or: np.dot(features, weights)

print(manual, dot)   # 0.28 0.28 -- same result

That single line, features @ weights, is — quite literally — what a neuron in a neural network computes before its activation function is applied. Matrix multiplication is just this same dot product done for every row/column pair at once:

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

C = A @ B
# C[i, j] = dot product of A's row i and B's column j
# C = [[1*5+2*7, 1*6+2*8],
#      [3*5+4*7, 3*6+4*8]]
#   = [[19, 22],
#      [43, 50]]

# The one rule that matters: inner dimensions must match.
# A (2, 3) @ B (3, 4) is valid -> result is (2, 4).
# A (2, 3) @ B (2, 4) raises a ValueError -- the 3 and 2 don't line up.

A batch of 32 data points, each with 3 features, run through a layer with 3 inputs and 4 outputs is exactly one matrix multiplication: (32, 3) @ (3, 4) → (32, 4). Every forward pass in Week 11's neural network, and every attention score in Week 15's transformer, is this same shape-matching rule applied over and over.

The single most common shape bug

"Inner dimensions must match" is the rule you'll debug against most often once you're building networks. If a matrix multiplication fails, the fix is almost always to transpose one side or double-check which axis is "features" vs. "batch."

3. Vector Norms & Cosine Similarity

A norm measures a vector's length. The two you'll see everywhere: the L1 norm (sum of absolute values) and the L2 norm (square root of the sum of squares — ordinary Euclidean length).

norms.py
v = np.array([3.0, -4.0])

l1 = np.sum(np.abs(v))            # 7.0
l2 = np.sqrt(np.sum(v ** 2))      # 5.0  -- or: np.linalg.norm(v)

Norms alone measure size, not direction. To compare how similar two vectors are — regardless of their length — you use cosine similarity: the dot product of two vectors, divided by the product of their norms. It ranges from -1 (opposite direction) to 1 (same direction), with 0 meaning perpendicular/unrelated.

cosine_similarity.py
def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

doc_a = np.array([1.0, 2.0, 0.0])
doc_b = np.array([2.0, 4.0, 0.0])   # same direction as doc_a, just longer
doc_c = np.array([0.0, 0.0, 5.0])   # perpendicular to doc_a

print(cosine_similarity(doc_a, doc_b))   # 1.0  -- identical direction
print(cosine_similarity(doc_a, doc_c))   # 0.0  -- unrelated
Why this is worth memorizing now

Cosine similarity is exactly how a vector database ranks "which documents are most relevant to this query" in Week 22's RAG pipeline — every embedding-based search you'll build comes down to this one formula.

4. Probability Foundations

You'll go deeper on probability as it comes up (evaluation metrics in Week 6, sampling in Week 17), but a few ideas are worth having solid now, because they show up immediately once you start describing data or a model's confidence.

  • Mean — the average value; the "center" of your data
  • Standard deviation — how spread out the values typically are from that center
  • Random variable — a quantity whose value comes from some uncertain process (a die roll, tomorrow's stock price, a model's next-token prediction)
  • Probability distribution — an assignment of likelihood to every possible value a random variable can take, always summing (or integrating) to 1
describing_data.py
scores = np.array([72, 85, 90, 61, 78, 95, 88])

mean = scores.mean()          # 81.28...
std = scores.std()            # 10.9... -- typical distance from the mean

normalized = (scores - mean) / std   # z-scores: how many std-devs from the mean

A small standard deviation means most values cluster tightly around the mean; a large one means they're spread wide. You'll use exactly this pair — mean and standard deviation — to normalize features before training a model in Week 5, and to interpret how "confident" a distribution over outcomes is: a tight distribution means a model is sure; a wide one means it's genuinely uncertain.

5. Conditional Probability & Bayes' Theorem

Conditional probability, written P(A | B), is "the probability of A, given that we already know B happened." Bayes' theorem is the formula for flipping a conditional probability around — going from P(evidence | hypothesis), which is usually easy to estimate, to P(hypothesis | evidence), which is usually what you actually want to know:

Bayes' theorem
P(H | E) = P(E | H) * P(H) / P(E)

# H = hypothesis (e.g. "this email is spam")
# E = evidence   (e.g. "this email contains the word 'free'")

Worked example — a classic spam filter setup:

bayes_spam_filter.py
# Priors and likelihoods, estimated from a labeled dataset
p_spam = 0.30                    # P(spam)  -- 30% of all mail is spam
p_ham = 0.70                     # P(not spam)
p_free_given_spam = 0.60         # P("free" appears | spam)
p_free_given_ham = 0.05          # P("free" appears | not spam)

# P(E) -- total probability the word "free" appears at all, spam or not
p_free = (p_free_given_spam * p_spam) + (p_free_given_ham * p_ham)

# Bayes' theorem: flip P(E | H) into P(H | E)
p_spam_given_free = (p_free_given_spam * p_spam) / p_free

print(round(p_spam_given_free, 3))   # 0.837 -- 83.7% spam, given it contains "free"

Notice the result (84%) is much higher than the raw prior (30%) — seeing the word "free" is strong evidence, and Bayes' theorem is precisely the mechanism for updating a belief once new evidence arrives. This exact update rule — prior belief, new evidence, updated belief — is the conceptual backbone behind how you'll reason about model evaluation and calibration in Week 6.

6. From Raw Scores to a Probability Distribution: Softmax

A model rarely outputs a probability directly — it outputs raw, unbounded numbers called logits (one per possible outcome). Softmax is the function that turns any list of logits into a valid probability distribution: every value between 0 and 1, and the whole set summing to exactly 1.

softmax.py
def softmax(logits):
    shifted = logits - np.max(logits)   # subtract max first -- numerical stability
    exp_scores = np.exp(shifted)
    return exp_scores / np.sum(exp_scores)

logits = np.array([2.0, 1.0, 0.1])
probs = softmax(logits)

print(probs)          # [0.659, 0.242, 0.099]
print(probs.sum())    # 1.0 -- always, by construction

Two details worth noticing: exponentiating first means larger logits become disproportionately more likely — softmax exaggerates differences rather than preserving them linearly. And subtracting np.max(logits) before exponentiating doesn't change the final result (it cancels out in the division) but keeps the numbers from overflowing — a real numerical-stability trick you'll see in nearly every from-scratch transformer implementation in Week 16.

Where this literally shows up next

Softmax converts attention scores (computed via the matrix multiplication from Section 2) into attention weights in Week 15, and converts an LLM's final layer output into a probability distribution over its entire vocabulary — the actual mechanism behind picking the "next token" in Week 17.

7. Hands-on Exercise

Hands-on

Build a tiny document search engine with cosine similarity

You're given five short "documents," already converted into simple word-count vectors over a shared vocabulary — no NLP library needed yet, just the math from this week.

starter data
import numpy as np

# Vocabulary, in order: [cat, dog, python, code, bark, meow]
docs = {
    "d1": np.array([2, 0, 1, 1, 0, 1]),   # about a cat that codes in python
    "d2": np.array([0, 3, 0, 0, 2, 0]),   # about a dog that barks
    "d3": np.array([1, 1, 2, 3, 0, 0]),   # about python code, briefly mentions both animals
    "d4": np.array([0, 0, 3, 3, 0, 0]),   # purely about python code
    "d5": np.array([3, 0, 0, 0, 0, 2]),   # purely about a cat meowing
}

query = np.array([0, 0, 2, 2, 0, 0])       # a search for "python code"

Requirements:

  1. Implement cosine_similarity(a, b) yourself (don't import it from a library) using @ and np.linalg.norm.
  2. Compute the cosine similarity between query and every document in docs, and print the documents ranked from most to least similar.
  3. Confirm d4 (purely about code) ranks above d1 and d3 (mixed topics) — explain in one sentence why, in terms of the vectors' direction.
  4. Write a softmax function from scratch and apply it to the five similarity scores from step 2, turning them into a probability distribution over "which document is most relevant."
  5. Using made-up but reasonable numbers, apply Bayes' theorem to answer: "given that a document mentions 'python', what's the probability it's about coding rather than animals?" State your priors and likelihoods explicitly before computing.
Hint

Stack the five document vectors into one (5, 6) matrix and compute all five dot products with query in a single matrix-vector multiplication, instead of looping — good practice for the vectorized thinking from Week 1.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why must the "inner dimensions" of two matrices match for A @ B to work?

Each output entry C[i, j] is a dot product between a row of A and a column of B — a dot product requires both vectors to have the same length. A's row length (its number of columns) must therefore equal B's column length (its number of rows), which is exactly the "inner dimensions" rule: (m, n) @ (n, p) → (m, p).

Q2

Two vectors point in the exact same direction, but one is twice as long. What is their cosine similarity, and why?

1.0. Cosine similarity divides the dot product by the product of both norms, which exactly cancels out any difference in length — it measures direction only, not magnitude. This is why it's preferred over raw dot product for comparing embeddings of different "lengths" (e.g. a short query vs. a long document).

Q3

In Bayes' theorem, what's the practical difference between the "prior" and the "posterior"?

The prior, P(H), is what you believed before seeing any evidence (e.g. "30% of mail is spam"). The posterior, P(H | E), is your updated belief after incorporating a specific piece of evidence (e.g. "84% spam, now that we've seen the word 'free'"). Bayes' theorem is the exact arithmetic for that update.

Q4

Why subtract np.max(logits) before exponentiating in a softmax implementation?

It's purely for numerical stability — large logits can make np.exp() overflow to inf. Subtracting the maximum shifts every value to be ≤ 0 before exponentiating, so the largest term becomes exp(0) = 1 and everything else is a safe fraction. Because softmax divides by the sum of all terms, this shift cancels out exactly and never changes the final probabilities.