Classical ML From Scratch Lab
After this lesson, you will be able to:
- Fit a line with the normal equation and with gradient descent, and show that both reach the same weights
- Write a sigmoid that cannot overflow, the log loss, and a gradient step for logistic regression
- Label a new row by a vote of its k nearest rows, with a stated rule for ties
- Write the two alternating steps of k-means, and handle a cluster that owns no rows
- Score a split with Gini impurity and find the best single question over all features
- Compute principal components from a covariance matrix, and write Gaussian naive Bayes in log space
- Split rows into shuffled folds so that no row is ever in training and validation together
Before You Start
#How This Lab Works
TODO marks, a ladder of hints that opens one step at a time, a Run button, a Check my work button, and a way to reveal the solution.Check my work runs your code, then runs a list of hidden tests one at a time. It tells you how many passed and shows the message of the first one that failed. The starter is built to fail on purpose, so a fresh card always says some checks failed. When you finish the TODO lines, all of them pass.
Three things about the tests are worth knowing before you start.
- They are tolerance based. A result close enough to the right answer passes, so a harmless rounding difference never fails you.
- They are deterministic. Any random numbers come from a fixed seed, so a test that fails today fails the same way tomorrow.
- They check more than the hand trace. Some compare your answer with a second route to the same number, such as numpy's own least-squares routine, an independent Newton solver, a slow loop, or a brute-force search. If your code only matches the trace, it will still fail those.
The cards use numpy only. The browser has no scikit-learn, so none of this needs an install, and each card runs in well under a second.
The numbers a model multiplies the features by. In a line, the intercept and the slopes. Learning means finding good weights.
The direction in which the error rises fastest. Gradient descent steps the opposite way, a little at a time.
The cost of a probability. A confident wrong answer costs a lot, a confident right answer costs almost nothing.
The label that most of the chosen neighbours carry. A tie needs a stated rule.
The average position of a group of rows. K-means keeps one centre per cluster.
How mixed a group of labels is: 0 for a single label, larger when the labels are evenly mixed.
A table of how every pair of features moves together. Its diagonal holds each feature's variance.
A direction that a matrix only stretches, without turning. For a covariance matrix these are the directions of spread.
One slice of the rows. Cross-validation holds each fold out in turn and trains on the rest.
#The Conventions Used Here
Most mistakes in this area come from which of two conventions a sentence uses. Fix them before writing code. This table is the single source for the whole lab, and the tests assume it.
| Quantity | Meaning in this lab |
|---|---|
| X | A numpy array of shape (n, d): n rows, d features. Row i is X[i]. |
| y | A numpy array of n labels or targets. Class labels are whole numbers 0, 1, 2, and so on. |
| Intercept | Added as a column of ones in front of X. The first weight is the intercept, then one weight per feature. |
| Distance | Straight-line (Euclidean) distance. Squared distances give the same order, so the square root is skipped. |
| Ties | In a vote, the smaller label wins. In an argmin, the lower index wins. In a split, the smaller threshold wins. |
| Variance | Divide by n for the class variances of naive Bayes. Divide by n minus 1 for the covariance matrix in PCA. |
| Random numbers | np.random.default_rng(seed), always with a seed, so every run is the same. |
| Logs | Natural logarithm, np.log. |
Two notes. First, "divide by n or n minus 1" is a real choice that changes numbers in the fourth decimal place, and the two exercises that need it say which one. Second, each card is independent. It carries its own copy of any helper it needs, so you can do the exercises in any order.
#Exercise 1: Linear Regression Two Ways
(Xb^T Xb) w = Xb^T y. One gradient step is w = w - lr * (2 / n) * Xb^T (Xb w - y).Linear regression: normal equation and gradient descent
Write fit_normal_equation(X, y) and fit_gradient_descent(X, y, lr, steps). Both return the weights with the intercept first. The first solves the normal equation. The second starts from all zeros and takes steps of lr times the gradient of the mean squared error, with no change to X or y.
normal equation: [1. 2.]
one gradient step: [1. 2.2667]Two tests deserve a note. The noisy-data test compares your normal-equation weights with numpy's own least-squares routine, a second route to the same answer. The convergence test needs gradient descent and the normal equation to agree to four decimal places, which ties the two halves of the exercise together.
#Exercise 2: Logistic Regression
Xb^T (p - y) / n, where p holds the predicted chances. That is almost the linear-regression gradient, without the factor 2 and with p in place of the raw weighted sum.1 / (1 + exp(-z)) overflows for a large negative z, because exp(1000) is infinite. The fix is to split the cases: for z at or above 0 use that formula, and for z below 0 use exp(z) / (1 + exp(z)).Logistic regression from scratch
Write sigmoid(z) that never overflows, log_loss(y, p) that clips p away from 0 and 1, and fit_logistic(X, y, lr, steps) that runs gradient descent on the log loss from all zeros, intercept first.
sigmoid(0), sigmoid(2): 0.5 0.8808
one step: [0. 0.25]The strongest test runs a few rounds of Newton's method, a different optimiser, on the same data. Gradient descent and Newton must end at the same weights, so a sign slip or a missing intercept cannot hide. Another test flips every label and expects every weight to change sign.
#Exercise 3: K-Nearest Neighbours
np.argsort, read their labels, count them with np.bincount, and take the biggest count with np.argmax. Both np.argsort(..., kind="stable") and np.argmax keep the earlier item on a tie, which gives the rule you want.K-nearest neighbours from scratch
Write knn_predict(X_train, y_train, X_test, k) that returns one label per test row, using straight-line distance, a majority vote of the k nearest training rows, and the smaller label on a tie.
[0 1]One test compares your labels with a plain Python loop on 90 random rows across k = 1, 3, 5 and 7. Another builds a case where straight-line distance and the sum of the gaps (Manhattan distance) pick different nearest rows, so using the wrong distance fails it.
#Exercise 4: K-Means
There is one awkward case. A centre can end up owning no rows, and the average of nothing is not a number. The rule here is that such a centre stays where it was.
K-means from scratch
Write assign_clusters(X, centers), update_centers(X, labels, centers) where an empty cluster keeps its old centre, and kmeans(X, init_centers, max_iter) that returns the final centres, the labels and the inertia. Do not change the starting centres you are given.
[1.5 9.5] [0 0 1 1] 1.0The tests check several properties that must hold for any correct k-means. Running more rounds never makes the inertia worse. At the end every row is labelled with its nearest final centre. And at convergence each centre equals the mean of its own rows.
#Exercise 5: A Decision Stump by Gini
A cut is scored by the weighted Gini: (n_left x Gini_left + n_right x Gini_right) / n. The candidate cuts are the midpoints between neighbouring different values of a feature. Rows with a value at or below the cut go left. When two cuts tie, the smaller threshold wins.
A Gini decision stump
Write gini(y), best_split(x, y) that returns (threshold, weighted Gini) for one feature, and best_stump(X, y) that returns (feature index, threshold, weighted Gini) over all features. Cut only between different values. A feature with one value cannot split.
0.5 0.375 0.0
(3.5, 0.0)The most useful test recomputes the weighted Gini of your returned threshold from scratch and compares it with the score you returned. A function that splits by position in the sorted list, rather than by value, gives a score that does not match its own threshold when values repeat, and this test catches it. A brute-force search on random data with repeated values catches the rest.
#Exercise 6: PCA by Eigendecomposition
Xc^T Xc / (n - 1). Its eigenvectors are the directions, and its eigenvalues are the variances along them. The direction with the biggest eigenvalue is the first principal component. Projecting the centred data onto the top directions gives the scores.np.linalg.eigh handles symmetric matrices like a covariance matrix. It returns the eigenvalues in increasing order and the eigenvectors as columns, so you must reverse the order and turn the chosen columns into rows. A direction and its negative are the same direction, so the sign of a component is arbitrary, and the tests ignore it.PCA from scratch
Write pca(X, n_components) that returns the components (one unit-length direction per row, biggest spread first), their variances (dividing by n minus 1), and the scores of the centred data. Do not change X.
[[0.7071 0.7071]] [3.3333] [-2.1213 -0.7071 0.7071 2.1213]np.cov. Another compares your directions with the singular value decomposition of the centred data. A third rebuilds the original data from the scores with all components kept, and a fourth checks that the variances add up to the trace of the covariance matrix.#Exercise 7: Naive Bayes
-0.5 * (log(2 * pi * v) + (x - m) ** 2 / v). A score for a class is its log prior plus the sum of those over the features.Gaussian naive Bayes in log space
Write fit_gnb(X, y) that returns a dict with classes, priors, means and variances (divide by the count, then add 1e-9). Write log_scores(model, X) that returns an (n, classes) table of log prior plus summed log densities, and predict_gnb(model, X) that returns the original labels.
[2. 8.] [0.6667 0.6667]
[0 1]Two tests are aimed at the log trick. One uses 1000 features, where a product of densities underflows to 0 for every class, so a multiplying solution labels everything as class 0. The other uses a feature that is constant inside a class, which has variance 0 unless you add the small constant.
#Exercise 8: A Cross-Validation Split
Two details matter. The rows must be shuffled first, because a file sorted by label would give folds that hold only one class. And no row may be in both the training part and the validation part of the same split. This leak is easy to cause and hard to see, because the score just looks excellent.
np.array_split makes the sizes 4, 3, 3, with the larger folds first. The validation sets then have sizes 4, 3, 3, and the matching training sets have sizes 6, 7, 7. Together the three validation folds hold each of the rows 0 to 9 exactly once.K-fold cross-validation
Write kfold_indices(n, k, seed) that returns k pairs (train_idx, val_idx) from a seeded shuffle, and cross_val_accuracy(predict_fn, X, y, k, seed) that returns the plain mean of the k fold accuracies. predict_fn(X_train, y_train, X_val) is any function that returns predicted labels.
number of folds: 3
validation sizes: [4, 3, 3]The leak test passes a spy function as predict_fn. The spy raises an error if any validation row also appears in the training rows it receives, so a split that reuses rows is caught at the moment it happens. Another test builds an uneven split, with 23 rows in 4 folds, and checks that you report the plain mean of the fold scores and not a mean weighted by fold size.
#Where the Traps Are
Every exercise has one common wrong answer that looks right. They fall into four families.
| Symptom | Likely cause |
|---|---|
| Gradient descent blows up to huge numbers | The gradient used a sum where it needed a mean, or the step size is too large |
| Logistic weights have the opposite sign | The gradient used y minus p instead of p minus y |
| Nearest neighbours gives one label for everything | The farthest rows were taken, or one feature has much larger units |
| K-means gives nan for a centre | The mean of an empty cluster was taken |
| A stump returns a threshold equal to a data value | The cut was placed on a point, not between two different values |
| PCA says the first direction is the data's average | The data was not centred |
| Naive Bayes always predicts the first class | Densities were multiplied and underflowed to 0, or the prior was left out |
| Cross-validation accuracy is near perfect | A validation row also sat in the training part |
#Capstone Checklist
Use this list to check that you can read and write the basic models without a library. Tick each item only if you can do it without looking.
- I can write the normal equation and a gradient descent loop for a line, and say why both reach the same weights
- I can write a sigmoid that cannot overflow, and the log loss with clipping
- I can write the gradient of the log loss and say which way the sign goes
- I can write a nearest-neighbour vote and state how ties are broken
- I can write the assign and update steps of k-means, and say what happens to an empty cluster
- I can compute the Gini impurity and the weighted Gini of a cut, and place cuts between values, not on them
- I can compute principal components from a covariance matrix, and say why the data is centred first
- I can write Gaussian naive Bayes in log space and say why
- I can split rows into shuffled folds and name one way a validation row could leak into training
- Given a library call, I can name the choices it hides: tie rules, n versus n minus 1, centring, and clipping
If you can tick every line, you can read the source of a classical ML library and explain its defaults. If you cannot, go back to the exercise for that line, open the solution, and write the function again without it.
#Key Takeaways
- Linear regression has two routes to one answer. The normal equation solves (Xb^T Xb) w = Xb^T y, and gradient descent steps w by lr times (2 / n) Xb^T (Xb w - y). They must agree
- Logistic regression is the same loop with a sigmoid and the gradient Xb^T (p - y) / n. Split the sigmoid on the sign of z so it cannot overflow, and clip p so the log stays finite
- Nearest neighbours has no training step. Take the k smallest distances, vote, and state the tie rule
- K-means alternates assign and update. The inertia never rises, an empty cluster keeps its old centre, and at the end each centre is the mean of its rows
- A stump scores each cut between different values by the weighted Gini, with ties going to the smaller threshold and the lower feature index
- PCA centres the data, builds the covariance matrix, and takes its eigenvectors from the largest eigenvalue down. The variances add up to the trace
- Naive Bayes adds a log prior to a sum of per-feature log densities, which survives a thousand features. Cross-validation shuffles row numbers, cuts them into folds, and never lets a row be in both parts
#Quick Check
A logistic regression has been trained for one step from all-zero weights. Every predicted chance is 0.5. Which expression gives the gradient of the average log loss?