Part 8 · Machine Learning · Module 55

Machine Learning Foundations

Machine learning is not magic: it is choosing a family of functions, measuring how wrong each one is with a loss, and searching for the least wrong one with calculus and linear algebra. In this module you build linear and logistic regression from scratch in NumPy, so that every library you use afterwards is a convenience, not a mystery.

⏱ ~3 hoursPrerequisites: M48, M49, M54

What machine learning is (and is not)

In ordinary programming you write the rules: "if the email contains 'WIN A PRIZE', mark it as spam". In machine learning (ML) you supply examples (thousands of emails already marked spam or not) and an algorithm finds the rules for you. More precisely, an ML algorithm picks a function f from a family of candidate functions (the model) so that f(input) is close to the desired output on the examples, and, crucially, on new examples it has never seen. That second part, generalization, is the whole point. Memorizing the training emails is easy; correctly flagging tomorrow's spam is the job.

Three ingredients appear in every ML method, from a straight line to a large language model:

  1. A model family: the shapes of function allowed. "All straight lines y = w·x + b" is a family; each choice of the parameters w and b is one member.
  2. A loss function: a number that says how wrong a particular member is on the data. Smaller is better.
  3. An optimizer: a procedure that searches the family for parameters with small loss, often gradient descent.

ML is not a replacement for thinking. It cannot learn a pattern that is not in the data, it inherits every bias in how the data was collected, and it gives confident wrong answers outside the range of its examples. If a simple rule works ("orders over $10,000 need manual review"), write the rule. Reach for ML when the rules are too many, too fuzzy, or too fast-changing to write by hand: recognizing handwriting, ranking search results, predicting demand.

Supervised learning

Every example comes with the right answer, the label. Predict a number (regression: house price, delivery time) or a category (classification: spam/not spam, digit 0–9). Most of this module.

Unsupervised learning

No labels. Find structure: groups of similar customers (clustering), a smaller set of features that keeps most information (dimensionality reduction), unusual records (anomaly detection). See M57.

Reinforcement learning

An agent takes actions in an environment and receives rewards, learning a policy that maximizes reward over time: game playing, robot control. Powerful but data-hungry; out of scope for this course beyond this card.

Remember: ML = model family + loss + optimizer, judged by performance on unseen data.

Features, labels, and the feature matrix

Each example (also called a sample, row, or observation) is described by a fixed list of numbers called features. A house might have features [area_m2, bedrooms, age_years]. Stack the feature lists of all n examples as rows and you get the feature matrix X with shape (n_samples, n_features). The answers form the label vector (or target) y with shape (n_samples,). Row i of X goes with entry i of y.

X: shape (n_samples, n_features) y: shape (n_samples,) 120315410 65140180 9525350 ⋮⋮⋮⋮ area_m2bedrooms (a column = a feature) price (k$) ← one sample
Rows are samples, columns are features. The highlighted row and its label form one training example.

Every scikit-learn estimator, and every from-scratch model in this module, expects exactly this layout. A single feature still needs a 2-D X: shape (n, 1), not (n,). Text, images, and categories must first be turned into numbers (feature engineering); M56 shows the standard tools.

Remember: X is (n_samples, n_features), y is (n_samples,), and row i of one matches entry i of the other.

Train, validation, and test splits (and leakage)

A model that scores perfectly on the data it learned from may have simply memorized it. To estimate how it will do on future data we hold some data back and never let the model see it while learning. The standard arrangement uses three parts:

train 70%: fit parametersval 15%test 15% choose hyperparametersreport once
Shuffle first, then cut. The test slice is a sealed envelope.

Data leakage happens when information from the validation or test data sneaks into training. The result is a model that looks great in the notebook and fails in production. Classic leaks:

Remember: fit on train, choose on validation, report on test once. Anything computed from data (means, vocabularies, imputation values) is part of fitting and must use train only.

Loss functions: measuring wrongness

A loss function turns predictions and true labels into one number. Training means "find parameters that make this number small", so the loss you choose defines what "good" means.

Mean squared error (regression)

For true values yi and predictions ŷi ("y-hat"), the mean squared error is

MSE = (1/n) Σ (yi − ŷi)²

Squaring makes every error positive and punishes big misses much more than small ones: an error of 4 costs 16, four errors of 1 cost only 4. That is good when big misses are truly worse, and bad when the data has wild outliers (one outlier can dominate the fit). Its square root, RMSE, is in the same units as y, so it is easier to read ("off by about $23k").

Cross-entropy (classification)

A classifier usually outputs a probability p that the label is 1. For a true label y ∈ {0, 1}, the binary cross-entropy (also called log loss) is

L = −[ y·log(p) + (1 − y)·log(1 − p) ]

Read it case by case. If y = 1, the loss is −log(p): 0 when p = 1, about 0.69 when p = 0.5, and enormous as p → 0. A confident wrong answer is punished far harder than a hesitant one. That is exactly the behavior we want from a probability, and it is why we do not use MSE for classification.

Remember: MSE for numbers, cross-entropy for probabilities. The loss encodes what you care about; choosing it is a modeling decision.

Linear regression, part 1: the normal equation

Linear regression predicts a number as a weighted sum of the features plus a constant:

ŷ = w1x1 + w2x2 + … + wdxd + b

The weights w say how much each feature pushes the prediction up or down; the bias (intercept) b is the prediction when every feature is 0. A neat trick folds b into w: add a column of ones to X. Then for all samples at once, ŷ = X w (a matrix-vector product from M49).

Which w minimizes MSE? Calculus gives an exact answer. Setting the derivative of ‖Xw − y‖² to zero yields the normal equation:

(XᵀX) w = Xᵀy

Solve that linear system and you are done, no iteration needed. In NumPy, prefer np.linalg.lstsq(X, y) (or np.linalg.solve(X.T @ X, X.T @ y)) over computing an explicit inverse: it is faster and numerically safer.

Remember: linear regression is ŷ = Xw; least squares has an exact solution, the normal equation, which lstsq computes stably.

Linear regression, part 2: gradient descent

Picture the loss as a landscape: each point on the ground is a choice of parameters, and the height is the loss. You are standing somewhere on a foggy hillside and want to reach the valley floor. You cannot see far, but you can feel which way the ground slopes under your feet. So you take a step downhill, feel again, and repeat. That is gradient descent.

The gradient ∇L(w) is the vector of partial derivatives: for each parameter, "if I nudge this up a little, how fast does the loss go up?" It points uphill, so we step the opposite way:

w ← w − η · ∇L(w)

The step size η (eta) is the learning rate. Try it on the landscape below: drag the learning-rate slider and watch small rates crawl, good rates glide in, and large ones overshoot and diverge.

Deriving the MSE gradient intuitively

For one sample, the error is e = ŷ − y = w·x − y and the loss is e². By the chain rule, the derivative of e² with respect to wj is 2e · xj. Read that as a sentence: the blame assigned to weight j is the error times how much feature j was present. If the prediction was too high (e > 0) and xj was large, wj gets pushed down hard; if xj = 0, that weight was not involved and gets no blame. Averaging over all samples and writing it with matrices gives

∇MSE(w) = (2/n) · Xᵀ (Xw − y)

Xw − y is the vector of errors; multiplying by Xᵀ computes, for every feature at once, "sum over samples of error × feature value". That one line is the engine of linear regression, and the same pattern (error times input) reappears in logistic regression and in every layer of a neural network (M58).

Learning rate and convergence

The algorithm has converged when the loss stops changing meaningfully (or the gradient is nearly zero). Too small a learning rate wastes thousands of steps; too large a rate makes each step overshoot the valley so the loss grows, often to inf or nan. A useful habit: always plot the loss curve. A smooth decreasing curve that flattens is healthy; a zig-zag means the rate is near the edge; a curve that rises means divergence.

Feature scaling

If one feature is measured in square metres (values near 100) and another in bedrooms (values near 2), the loss landscape is a long, narrow valley: very steep along one weight, nearly flat along the other. One learning rate cannot suit both directions, so descent zig-zags across the steep walls while crawling along the floor. Try the elongated bowl below, then imagine it rounded.

Standardization fixes this: subtract each feature's (training) mean and divide by its (training) standard deviation, so every feature has mean 0 and standard deviation 1. The valley becomes round and gradient descent heads straight for the bottom. Scaling does not change what the best model can predict; it changes how fast you find it. (Tree models in M56 do not care about scaling; distance-based models such as k-nearest neighbors care a lot.)

Remember: w ← w − η∇L; the MSE gradient is (2/n)Xᵀ(Xw − y), "error times input"; plot the loss curve; standardize features before gradient descent.

Underfitting, overfitting, and bias vs variance

Give linear regression polynomial features [x, x², …, xd] and it can fit curves. The degree d controls the model's capacity (how wiggly a function it can represent). Too little capacity and the model underfits: it misses real structure and is wrong on train and test. Too much and it overfits: it bends to chase the noise in the training points, so training error is tiny while test error explodes.

Sweep the degree and plot both errors and you get the most important picture in ML: training error falls forever, validation error falls and then rises. The best model sits at the bottom of the validation curve.

The bias-variance trade-off names the two failure modes. Bias is error from wrong assumptions: a straight line can never fit a sine wave, no matter how much data you give it. Variance is error from sensitivity to the particular training sample: refit a degree-15 polynomial on a fresh sample of 20 points and you get a completely different wiggly curve. Simple models have high bias and low variance; flexible models have low bias and high variance. More data reduces variance (it is harder to chase noise in 10,000 points than in 20), which is why big flexible models need big datasets.

SymptomDiagnosisTry
Train error high, validation about the sameUnderfitting (high bias)More features, a more flexible model, less regularization
Train error low, validation much higherOverfitting (high variance)More data, a simpler model, more regularization, early stopping
Both low and closeGood fitCheck the test set once, then ship

Remember: judge models by validation error; the gap between train and validation error is your overfitting meter.

Regularization: ridge and lasso

Overfit polynomials have enormous, alternating weights (+3,000, −9,000, +12,000…) that cancel out on the training points and swing wildly between them. Regularization adds a penalty on weight size to the loss, so the optimizer must trade fit against simplicity:

Geometrically, ridge's penalty is a circle and lasso's is a diamond. The optimum is where the loss contours first touch the penalty shape, and a diamond is usually touched at a corner, where some coordinates are zero. The strength α is a hyperparameter: choose it on validation data. Conventionally the bias term is not penalized, and features are standardized first so one penalty is fair to all of them.

Remember: regularization = loss + penalty on weight size. Ridge shrinks, lasso shrinks and zeroes. Tune α on validation data.

Logistic regression from scratch

For classification we want a probability between 0 and 1, but Xw can be any real number. The sigmoid (logistic) function squashes it:

σ(z) = 1 / (1 + e−z)

σ(0) = 0.5, large positive z gives nearly 1, large negative gives nearly 0. Logistic regression predicts p = σ(Xw) and is trained by minimizing cross-entropy. Despite the name it is a classification method. The quantity z = Xw is called the logit (log-odds): z = log(p / (1 − p)).

The gradient is delightfully familiar. Working through the chain rule, the sigmoid's derivative σ(z)(1 − σ(z)) cancels against the derivative of the log, leaving

∇CE(w) = (1/n) · Xᵀ (σ(Xw) − y)

Same shape as linear regression: error (predicted probability minus label) times input. To classify, threshold the probability, usually at 0.5. The set of points where p = 0.5, that is where Xw = 0, is the decision boundary; for logistic regression it is a straight line (a plane in higher dimensions).

Remember: logistic regression = linear score → sigmoid → probability, trained with cross-entropy; its gradient is again "error times input"; its decision boundary is linear.

Evaluation metrics

Accuracy (fraction correct) is the obvious metric and often the wrong one. If 1% of transactions are fraud, a model that always says "not fraud" is 99% accurate and completely useless. Better metrics start from the confusion matrix, which counts the four outcomes of a binary prediction:

Predicted 0Predicted 1
Actual 0TN (true negative)FP (false positive, "false alarm")
Actual 1FN (false negative, "missed")TP (true positive)

Which to optimize is a business decision. A cancer screen wants high recall (do not miss cases; a follow-up test handles false alarms). A spam filter wants high precision (never bin a real email).

Regression metrics

Remember: look at the confusion matrix first; choose precision vs recall by the cost of each mistake; AUC compares rankings independent of threshold; report RMSE/MAE in target units and R² for "how much is explained".

Practice

Each exercise runs your code, then the tests. NumPy is available.

Write split_indices(n, test_fraction, seed) that returns (train_idx, test_idx) as NumPy integer arrays. Shuffle range(n) with np.random.default_rng(seed).permutation, put the first int(n * test_fraction) shuffled indices in test and the rest in train. The two must not overlap and together must cover every index. The same seed must give the same split.

Hint

order = np.random.default_rng(seed).permutation(n), then slice order[:k] and order[k:].

Show solution
import numpy as np

def split_indices(n, test_fraction, seed):
    order = np.random.default_rng(seed).permutation(n)
    k = int(n * test_fraction)
    return order[k:], order[:k]

Write mse(X, y, w) returning the mean squared error of predictions X @ w as a Python float, and mse_grad(X, y, w) returning the gradient (2/n) Xᵀ(Xw − y) as an array of the same shape as w. The tests compare your gradient with a numerical estimate.

Hint

The residual vector is X @ w - y. MSE is its squared mean; the gradient is 2 * X.T @ residual / len(y).

Show solution
import numpy as np

def mse(X, y, w):
    return float(np.mean((X @ w - y) ** 2))

def mse_grad(X, y, w):
    return 2 * X.T @ (X @ w - y) / len(y)

Write fit_gd(X, y, lr=0.1, n_iter=1000). It should add a column of ones to X (for the bias), start from all-zero weights, run n_iter gradient-descent steps on MSE, and return (w, b, losses): the feature weights, the bias, and a list of the MSE after each step. The tests use standardized data, so lr=0.1 converges.

Hint

Build Xb = np.column_stack([X, np.ones(len(y))]), loop theta -= lr * 2 * Xb.T @ (Xb @ theta - y) / len(y), and split theta[:-1], theta[-1] at the end.

Show solution
import numpy as np

def fit_gd(X, y, lr=0.1, n_iter=1000):
    Xb = np.column_stack([X, np.ones(len(y))])
    theta = np.zeros(Xb.shape[1])
    losses = []
    for _ in range(n_iter):
        residual = Xb @ theta - y
        theta = theta - lr * 2 * Xb.T @ residual / len(y)
        losses.append(float(np.mean((Xb @ theta - y) ** 2)))
    return theta[:-1], float(theta[-1]), losses

Write classification_report(y_true, y_pred) for binary labels (0/1, lists or arrays). Return a dict with keys "tp", "fp", "fn", "tn" (ints) and "precision", "recall", "f1" (floats). When a denominator is zero, the metric is 0.0.

Hint

Convert to arrays and count with boolean masks: tp = int(np.sum((t == 1) & (p == 1))).

Show solution
import numpy as np

def classification_report(y_true, y_pred):
    t, p = np.asarray(y_true), np.asarray(y_pred)
    tp = int(np.sum((t == 1) & (p == 1)))
    fp = int(np.sum((t == 0) & (p == 1)))
    fn = int(np.sum((t == 1) & (p == 0)))
    tn = int(np.sum((t == 0) & (p == 0)))
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn, "precision": precision, "recall": recall, "f1": f1}

Write sigmoid(z), log_loss(y, p) (mean binary cross-entropy, clipping p to [1e-12, 1 - 1e-12]), and fit_logistic(X, y, lr=0.5, n_iter=2000) returning a weight vector whose last entry is the bias (append a ones column). Use the gradient Xᵀ(σ(Xw) − y) / n. The tests check the loss, the gradient's effect, and accuracy on a separable dataset.

Hint

Clip with np.clip(p, 1e-12, 1 - 1e-12). The training loop is the same as linear regression's, with sigmoid(Xb @ w) as the prediction and no factor 2.

Show solution
import numpy as np

def sigmoid(z):
    return 1 / (1 + np.exp(-np.asarray(z, dtype=float)))

def log_loss(y, p):
    p = np.clip(np.asarray(p, dtype=float), 1e-12, 1 - 1e-12)
    y = np.asarray(y, dtype=float)
    return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))

def fit_logistic(X, y, lr=0.5, n_iter=2000):
    Xb = np.column_stack([X, np.ones(len(y))])
    w = np.zeros(Xb.shape[1])
    for _ in range(n_iter):
        w = w - lr * Xb.T @ (sigmoid(Xb @ w) - y) / len(y)
    return w

Mini-project

Mini-projectRegression from scratch, evaluated honestly

Build a tiny NumPy-only ML library, then evaluate it against scikit-learn on two built-in datasets. Create a project with pixi init ml-scratch, cd ml-scratch, and pixi add numpy scikit-learn matplotlib pytest (scikit-learn is used only for datasets and as a reference to compare against).

  1. scratch.py: a LinearRegressionGD class with fit(X, y), predict(X), attributes coef_, intercept_, loss_history_, and hyperparameters lr, n_iter, alpha (ridge strength). A matching LogisticRegressionGD with predict_proba and predict.
  2. A StandardScaler-like helper that is fitted on train only.
  3. evaluate.py: regression on sklearn.datasets.load_diabetes (report MAE, RMSE, R²); classification on load_breast_cancer (report the confusion matrix, precision, recall, F1, and ROC-AUC computed by your own function). Print your numbers next to scikit-learn's LinearRegression/LogisticRegression.
  4. Save two plots: the loss curve for three learning rates, and validation error vs ridge alpha.
  5. test_scratch.py: tests that your gradient matches a numerical gradient, that predictions have the right shape, and that fitting is reproducible. Run with pixi run pytest.
  • Both classes follow the fit/predict convention with trailing-underscore learned attributes
  • Train/validation/test split with a fixed seed; scaling statistics come from train only
  • Your R² on diabetes is within 0.02 of scikit-learn's LinearRegression
  • Your ROC-AUC on breast cancer is within 0.01 of scikit-learn's
  • Loss-curve and alpha plots saved as PNG files
  • pixi run pytest passes, including a numerical gradient check

Stretch: add mini-batch gradient descent with a batch_size parameter and compare wall-clock time; implement lasso with coordinate descent and show which diabetes features it zeroes out.

Quiz

Recap

Going further