Python Lesson 44 of 71

ML Fundamentals: Supervised vs Unsupervised, Regression, Classification & Clustering

Your manager asks you to write is_spam(email) -> bool. Easy, you think. You write if "viagra" in text. Then if link_count > 20. Then a rule for lookalike domains, then one for the Unicode homoglyphs the spammers switched to the moment your first rule shipped. Six weeks later is_spam is 4,000 lines of if, it still misses half the spam, and every fix breaks two old rules. You cannot win, because you do not actually know the rule — “spam” is a fuzzy, adversarial, ever-shifting pattern that lives in a hundred thousand examples, not in your head.

That is the exact moment machine learning is for. Instead of writing the function, you learn it: hand an algorithm 100,000 emails already labeled spam/not-spam, and it finds the function that best separates them — a function far too complicated for you to have written by hand.

But hold that enthusiasm, because the same week another ticket asks you to “flag any order over ₹50,000 for manual review.” Do not reach for ML. That is amount > 50_000 — one line, exact, instant, explainable to an auditor, and correct on inputs you have never seen. Training a model to approximate a threshold you already know is slower, needs data you shouldn’t need, gives approximate answers to an exact question, and turns a one-line rule into an unexplainable black box. Knowing which of these two situations you are in is the first real skill in ML, and this lesson is built around it. Everything below was executed on CPython 3.12 with scikit-learn 1.9, so if you run the code you will get the numbers on the page.


Why this matters

Here is the one-sentence definition that survives contact with reality: machine learning is the practice of learning a function from data instead of programming it by hand. Classical programming is rules in, answers out — you write the logic, the computer applies it. Machine learning inverts the middle step: data and answers in, rules out — you show the computer example inputs paired with the right outputs, and it produces the function. You then apply that learned function to new inputs.

That inversion is powerful exactly when the rules are unknown or too complex to hand-code: recognizing a face, transcribing speech, ranking search results, detecting fraud that adapts to your last filter, predicting which customer churns. Nobody can write the pixel-by-pixel rule for “cat,” but everybody can collect labeled cat photos. When the rule is learnable-from-examples but not writable-by-hand, ML wins.

And it is a genuinely bad idea when the opposite is true. If you can state the rule — tax brackets, leap years, shipping thresholds, a Luhn checksum, sorting — write the rule. A hand-written rule is exact, testable, instant, explainable, needs zero training data, and never drifts. An ML model for the same job is approximate, needs a labeled dataset, can be wrong on inputs it never saw, and cannot tell you why. The most expensive ML mistakes in industry are not bad models; they are ML deployed where a WHERE clause would have done the job better.

The other thing nobody tells beginners up front: ML is not reasoning, and it is not magic. A trained model is a compressed summary of correlations in the data it was shown. It pattern-matches; it does not think, understand, or know cause from coincidence. It will happily learn that patients photographed with a ruler have more cancer (because the ones the doctor suspected got measured) and confidently “diagnose” rulers. Everything good and everything dangerous about ML flows from that single fact — it learns whatever pattern predicts the label in your data, including the patterns you did not want it to learn.

You need one library beyond the scientific stack. Set up a virtual environment — never pip install into the system Python:

python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install scikit-learn numpy pandas matplotlib
python -c "import sklearn; print('scikit-learn', sklearn.__version__)"
# => scikit-learn 1.9.0   (any recent 1.x is fine)

This lesson is the mental model for the whole ML phase. It assumes you are comfortable with vectorised arrays and DataFrames — if .reshape, .mean(axis=1) and columnar data are not yet reflex, read NumPy: Arrays, Broadcasting & Vectorization and Pandas: Series, DataFrames & Indexing first — and it leans on the statistics you have already met in Descriptive Statistics & Distributions (skew, the normal curve, sampling). We will name metrics like accuracy and R² but treat them lightly; a dedicated metrics-and-validation lesson owns them properly.


Rules vs learning: the honest dividing line

Before a single model, internalise the fork, because it is the decision you will get wrong most often. The question is never “is ML cool here?” — it is “do I know the rule?

# Task A: "is this user old enough to sign up?"  -> you KNOW the rule
def can_sign_up(age: int) -> bool:
    return age >= 18                      # exact, instant, explainable, zero data

# Task B: "is this 400x400 photo a cat?"  -> nobody can WRITE the rule
# You cannot enumerate the pixel logic. But you can COLLECT labeled photos
# and let a model learn the function. THIS is where ML earns its keep.

can_sign_up should never be a model. It is correct on every input forever, a reviewer can read it, and it needs no GPU. A cat classifier can never be a hand-written function — so you learn it. Same programming language, opposite tools, and the only thing that decided it was whether the rule was knowable.

Question Prefer a hand-written rule Prefer machine learning
Do you know the rule? Yes — you can state it No — it lives in examples
Precision required Exact / auditable (tax, eligibility) Approximate is acceptable
Explainability Must justify every decision A score is enough (or you add explainability tooling)
Training data You have none and shouldn’t need it You have many labeled examples
Input space Small, enumerable Huge, messy, high-dimensional (pixels, text, audio)
Drift Rule is stable Pattern shifts over time (fraud, spam, tastes)
Cost of being wrong occasionally High and unacceptable Tolerable, and you can measure the rate
Examples Leap year, shipping fee, Luhn check, sort Spam, faces, churn, recommendations, translation

⚠️ The “don’t use ML for this” trap is real and common. Teams reach for a classifier to reproduce a threshold they already know, or a regression to compute something arithmetic, because ML is fashionable. The result is slower, less accurate, unexplainable, and needs a data pipeline to maintain — strictly worse than the if statement it replaced. Reach for ML when the rule is genuinely unknown, and feel no shame writing the rule when you know it.


Features and labels: X and y

All of supervised ML speaks one vocabulary, and it is worth pinning down exactly because every library, tutorial, and error message assumes it.

The uppercase/lowercase convention is not decoration: X is a 2-D matrix, y is a 1-D vector, and scikit-learn will reject the wrong shape with a specific error you will meet later. Here is the vocabulary made concrete on a house-price problem:

Term Symbol Shape House-price example
Feature matrix X (n_samples, n_features) [[850, 2], [1200, 3], …] — sqft, bedrooms
One feature a column of X (n_samples,) the “sqft” column
Label / target y (n_samples,) [52.0, 68.5, …] — price in ₹ lakh
One sample a row of X (+ its y) (n_features,) one house and its price
Prediction ŷ (“y-hat”) (n_samples,) the model’s estimated prices

One reality beginners hit fast: X is almost always numeric. Most models do arithmetic on features, so a category like "country" or a date or a block of text cannot go in raw — it must be turned into numbers first (one-hot encoding for categories, timestamps split into day/hour, text into token counts or embeddings). That translation is feature engineering, and on real problems it is where most of the accuracy is won or lost. The toy examples here start from clean numeric arrays so the ML ideas stand alone, but remember that in practice, getting messy real-world data into a good X is the bulk of the job — a later lesson owns it properly.

The whole supervised game is: learn a function f such that f(X) ≈ y, then apply f to new rows whose y you do not know. Unsupervised learning uses X only — there is no y — and asks about structure inside X instead.


The taxonomy: the map of ML

“Machine learning” is an umbrella over several genuinely different problems. The first split is whether your data carries labels; everything else hangs off that.

Supervised learning has labels — every training row comes with its answer y. It splits by the type of answer:

Unsupervised learning has no labels — just X. You cannot predict an answer nobody gave you, so instead you find structure:

Each has its own workhorses and its own “what did I even get” question, since there is no y to score against:

Unsupervised task Goal Typical algorithm (sklearn) You give it You get back How you judge it
Clustering Group similar rows KMeans, DBSCAN, AgglomerativeClustering X, often k a cluster id per row inertia / silhouette; do the groups mean something?
Dimensionality reduction Compress features, keep signal PCA, TruncatedSVD, TSNE X, target dims a smaller X variance retained; does a plot separate?
Anomaly detection Flag the odd rows IsolationForest, LocalOutlierFactor X normal/outlier flag precision on known anomalies (if any)

Those two families are 90% of the applied ML you will meet, but the map is bigger, and it is worth knowing the rest exists so you place new terms correctly:

Paradigm Has labels? Learns to… Real example Where you meet it
Supervised — regression Yes, continuous y Predict a number Forecast house price, ETA Most business ML
Supervised — classification Yes, discrete y Predict a class Spam filter, disease screen Most business ML
Unsupervised — clustering No Group similar rows Customer segmentation Exploration, segmentation
Unsupervised — dim. reduction No Compress features PCA before plotting/modeling Preprocessing, visualization
Unsupervised — anomaly detection No Flag the odd rows Fraud, fault detection Monitoring, security
Semi-supervised A few labels + lots without Spread labels through structure Label 1% of images, exploit the rest When labels are expensive
Reinforcement No labels; rewards from acting Choose actions to maximise reward Game AI, robotics, ad bidding Sequential decision problems
Self-supervised Labels invented from the data Predict hidden parts of the input “Next word” over raw text How LLMs are pre-trained

That last row is how large language models relate to this map. An LLM is trained by self-supervision: nobody hand-labels the text; the “label” for each position is simply the next token, which the raw text already contains. That converts an ocean of unlabeled text into a supervised next-word prediction task with effectively free labels — the same fit-a-function-to-(input, answer) idea you will run in three lines below, just at a scale of trillions of tokens and billions of parameters. It is not a different kind of magic; it is this chapter’s mechanics, scaled and self-labeled.

Supervised sub-type Target y Output looks like Metrics (preview) Toy examples in this lesson
Regression Continuous number 52.7, 18.3 RMSE, MAE, R² house price, diabetes progression
Classification Discrete class "spam", 0/1/2 accuracy, precision/recall, F1 pass/fail, wine cultivar

Your first three models: fit and predict

scikit-learn’s genius is a single, uniform API across hundreds of algorithms. You will use exactly three verbs:

Method Who has it What it does Supervised call Unsupervised call
.fit(...) every estimator Learn from data .fit(X, y) .fit(X)
.predict(X) models Apply the learned function to new rows .predict(X_new) .predict(X_new) (e.g. cluster id)
.transform(X) transformers Produce a transformed X (scaling, PCA) .transform(X_new)
.fit_transform(X) transformers fit then transform in one call common for preprocessing
.predict_proba(X) most classifiers Class probabilities, not just the label classification only
.score(X, y) models A quick default metric (R² or accuracy) needs the true y

Learn those and you can drive a linear model, a random forest, or KMeans with the same muscle memory. Let us fit one of each on tiny, hand-checkable data.

Regression: predict a number

LinearRegression fits the straight line (or hyperplane) that minimises squared error. On six houses — feature = size in hundreds of square feet, target = price in ₹ lakh:

import numpy as np
from sklearn.linear_model import LinearRegression

# X MUST be 2-D: one row per house, one column per feature.
X = np.array([[5], [7], [8], [10], [12], [15]])   # size (hundreds of sqft)  shape (6, 1)
y = np.array([25, 34, 38, 47, 56, 70])            # price (₹ lakh) — continuous target

model = LinearRegression()
model.fit(X, y)                                    # LEARN the line from data

print("slope  (coef_)     =", model.coef_)         # => [4.48854962]
print("intercept_         =", round(model.intercept_, 4))   # => 2.3588
print("predict 1100 sqft  =", model.predict([[11]]))        # => [51.73282443]
print("R^2 on train       =", round(model.score(X, y), 4))  # => 0.9997

The model learned price ≈ 4.4885 × size + 2.3588. Ask it about an 1100-sqft house it never saw ([[11]]) and it answers ₹51.73 lakh. The of 0.9997 says the line explains 99.97% of the variance in this (deliberately clean) toy data — real data is never this tidy, as the diabetes example will show. Note the shape discipline: X is (6, 1), a matrix with one feature, not a flat list.

Two scikit-learn conventions are visible here and worth committing to memory. Anything a model learns from data ends in a trailing underscorecoef_, intercept_ — while things you set do not (like n_neighbors). And attributes recorded at fit time let you sanity-check what the model saw:

Attribute / method What it is Value here
.coef_ learned weights (one per feature) [4.4885]
.intercept_ learned bias term 2.3588
.n_features_in_ how many features fit saw 1
.predict(X_new) apply the learned function [51.73] for 1100 sqft
.score(X, y) default metric (R² for regressors) 0.9997
trailing _ rule present ⇒ learned from data; absent ⇒ a setting you chose

Classification: predict a class

LogisticRegression — despite the name, a classifier — learns a boundary between classes. Two features (say two exam scores), label 0 = fail, 1 = pass:

from sklearn.linear_model import LogisticRegression

Xc = np.array([[1, 1], [2, 1], [1, 2], [2, 2],      # low scores  -> fail
               [6, 5], [7, 5], [6, 6], [8, 7]])      # high scores -> pass
yc = np.array([0, 0, 0, 0, 1, 1, 1, 1])              # discrete labels

clf = LogisticRegression()
clf.fit(Xc, yc)

print("predict      =", clf.predict([[2, 2], [7, 6]]))          # => [0 1]
print("predict_proba=\n", np.round(clf.predict_proba([[2, 2], [7, 6]]), 4))
# => [[0.9265 0.0735]
#     [0.0211 0.9789]]
print("classes_     =", clf.classes_)                            # => [0 1]
print("accuracy     =", round(clf.score(Xc, yc), 4))            # => 1.0

Geometrically, the classifier learned a decision boundary — a line through the two-dimensional feature space with “fail” on one side and “pass” on the other, and predict just asks which side a point falls on. So predict returns the hard label — [0, 1], the first is a fail, the second a pass. predict_proba gives the confidence: for [2, 2] the model is 92.65% sure it is class 0; for [7, 6] it is 97.89% sure it is class 1 — points far from the boundary get confident probabilities, points near it get uncertain ones. That probability is often more useful than the label — it lets you set your own threshold (flag anything above 30% risk, say) instead of accepting the default 50% cutoff. classes_ records the label order the probability columns follow.

Call Returns Shape Use it when
clf.predict(X) hard class label per row (n,) you need a decision
clf.predict_proba(X) probability per class (n, n_classes) you need confidence / a custom threshold
clf.classes_ the class labels, in column order (n_classes,) reading predict_proba columns
clf.score(X, y) accuracy (fraction correct) scalar a quick check (not enough alone!)

Clustering: find groups with no labels

KMeans is unsupervised — you give it only X and how many clusters to find; it discovers the groups. Its algorithm is a simple loop worth picturing: drop k centroids at random, then repeat two steps until nothing moves — assign each point to its nearest centroid, then move each centroid to the average of its assigned points. Each pass makes the clusters tighter (lowers inertia); it stops when the assignments stabilise. Two obvious blobs:

from sklearn.cluster import KMeans

Xk = np.array([[1, 1], [1.5, 2], [1, 1.5],          # blob near (1,1)
               [8, 8], [8.5, 9], [9, 8]])            # blob near (8.5,8)

km = KMeans(n_clusters=2, n_init=10, random_state=0)
km.fit(Xk)                                            # NO y — unsupervised!

print("labels_        =", km.labels_)                        # => [1 1 1 0 0 0]
print("cluster_centers_=\n", np.round(km.cluster_centers_, 3))
# => [[8.5   8.333]
#     [1.167 1.5  ]]
print("predict new    =", km.predict([[0, 0], [9, 9]]))      # => [1 0]
print("inertia_       =", round(km.inertia_, 4))            # => 1.8333

KMeans found the two blobs and assigned each point a cluster id in labels_. The cluster_centers_ are the learned centroids — (1.167, 1.5) and (8.5, 8.333). A brand-new point near the origin ([0, 0]) is assigned to cluster 1 (the low blob); one at [9, 9] to cluster 0. inertia_ is the total within-cluster squared distance — lower is tighter, and it is the number you use to choose k, as the lab will show.

KMeans attribute Meaning Value here
.labels_ cluster id assigned to each training row [1 1 1 0 0 0]
.cluster_centers_ the learned centroid of each cluster [[8.5, 8.33], [1.17, 1.5]]
.inertia_ total within-cluster squared distance (lower = tighter) 1.8333
.n_iter_ iterations until the centroids stopped moving 2
.predict(X_new) assign new rows to the nearest existing centroid [1 0]
n_clusters (a setting) how many clusters to find — you must choose it 2

⚠️ The cluster ids are arbitrary labels, not meanings. KMeans calling the low blob “cluster 1” is a coin-flip of initialization; a different random_state might call it “cluster 0.” Clusters have no inherent identity or order — you interpret what each one is after the fact by inspecting its members. Always pass n_init=10 (the modern default runs multiple initializations and keeps the best) and a random_state for reproducibility.


What .fit() actually does: loss and optimization

.fit() looks like magic, but the machinery under every model is the same two-part recipe, and understanding it demystifies the whole field. (1) Define a loss function — a single number that measures how wrong the model currently is on the training data. (2) Search for the parameters that make that number as small as possible. “Learning” is just minimizing a loss.

For LinearRegression, the loss is the sum of squared errors: add up (actual − predicted)² over every training row. fit finds the exact slope and intercept that minimise it. We can prove it found the minimum by nudging the learned slope and watching the loss get worse in both directions:

import numpy as np
from sklearn.linear_model import LinearRegression

X = np.array([[5], [7], [8], [10], [12], [15]])
y = np.array([25, 34, 38, 47, 56, 70])
m = LinearRegression().fit(X, y)

def sse(slope, intercept):                     # the loss: sum of squared errors
    return float(np.sum((y - (slope * X.ravel() + intercept))**2))

print("SSE at fitted slope 4.489 :", round(sse(m.coef_[0],       m.intercept_), 3))  # => 0.366
print("SSE at slope 3.989        :", round(sse(m.coef_[0] - 0.5, m.intercept_), 3))  # => 152.116
print("SSE at slope 4.989        :", round(sse(m.coef_[0] + 0.5, m.intercept_), 3))  # => 152.116

The loss at the fitted slope is 0.366; move the slope half a unit either way and it jumps to 152 — the fitted parameters sit at the bottom of a bowl-shaped loss surface, which is exactly what “best fit” means. LinearRegression finds that bottom with a closed-form equation. Most models cannot solve it in one shot, so they use gradient descent: start somewhere, compute which direction reduces the loss, take a small step, repeat until the loss stops falling. That is how logistic regression, SVMs, and every neural network learn — the loss differs, the “roll downhill” idea does not.

Each task type gets a loss suited to it:

Model / task Loss it minimizes Plain meaning How it’s solved
Linear regression Mean squared error (MSE) average squared miss closed form (or gradient descent)
Logistic regression / classification Log-loss (cross-entropy) penalises confident wrong answers gradient descent
KMeans (clustering) Inertia total distance of points to their centroid iterative (Lloyd’s algorithm)
SVM Hinge loss + margin widen the gap between classes quadratic optimization
Decision tree Gini / entropy per split make each split as “pure” as possible greedy splitting
Neural network Task loss (MSE, cross-entropy…) same idea, many parameters gradient descent + backprop

This is why the shared vocabulary matters: whatever the algorithm, “training” means “minimise a loss on the training data.” And it is the seed of the central danger — minimising the loss on training data is not the goal. A flexible enough model can drive the training loss to zero by memorizing, which is exactly the failure the next section is about. Low training loss is necessary but nowhere near sufficient.


The central problem: generalization

Every model above scored well on the data it was trained on. That number is almost worthless. The entire point of ML is to work on data you have not seen — the training data is just the means. A model that aces the training set and flops on new data has learned nothing useful; it has memorized. This is the deepest idea in the field, and it has a name: generalization.

The threat to generalization is that your data is signal plus noise. The signal is the real, repeatable relationship you want to capture. The noise is the random junk specific to this sample — measurement error, luck, one-off events — that will not repeat. A good model learns the signal and ignores the noise. The two ways to fail are equal and opposite:

Why does flexibility cause memorization? A model has some number of free parameters it can tune, and the training data imposes some number of constraints. When the parameters vastly outnumber the constraints — a degree-15 polynomial (16 coefficients) fit to 18 points — the model has enough freedom to bend through every single training point exactly, noise and all, and still have slack left over. It uses that slack to contort wildly in the gaps between points, where nothing pins it down. A simpler model cannot do this: with only two parameters, a straight line is forced to compromise across all the points, which happens to be a decent way to ignore noise. Constraint is what produces generalization; unconstrained flexibility is what produces memorization. This is also why more data fights overfitting — every extra training point is another constraint, leaving the model less room to wiggle.

Demonstrating it: a polynomial that overfits

Nothing teaches this like watching it happen. We generate 30 points from a smooth cosine signal plus noise, hold out 40% as a test set the model never trains on, and fit polynomials of increasing degree. Degree controls flexibility: degree 1 is a line, degree 15 can wiggle through almost anything.

import numpy as np
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

rng = np.random.default_rng(1)                       # reproducible

def true_signal(x):
    return np.cos(1.5 * np.pi * x)                   # the REAL relationship

x = np.sort(rng.uniform(0, 1, size=30))
y = true_signal(x) + rng.normal(0, 0.12, size=30)    # signal + noise = what we observe
X = x.reshape(-1, 1)

# Hold out 40% the model will NEVER see during training:
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.4, random_state=1)
print(f"n_train={X_tr.shape[0]}  n_test={X_te.shape[0]}")   # => n_train=18  n_test=12

for deg in (1, 3, 15):
    m = make_pipeline(PolynomialFeatures(deg), LinearRegression())
    m.fit(X_tr, y_tr)
    tr = np.sqrt(mean_squared_error(y_tr, m.predict(X_tr)))   # error on TRAIN
    te = np.sqrt(mean_squared_error(y_te, m.predict(X_te)))   # error on HELD-OUT
    print(f"degree {deg:>2}: train RMSE={tr:.4f}  test RMSE={te:.4f}")
# => degree  1: train RMSE=0.4610  test RMSE=0.5353
# => degree  3: train RMSE=0.1197  test RMSE=0.1905
# => degree 15: train RMSE=0.0851  test RMSE=1.5005

Read those three lines slowly, because they are the whole lesson:

The signature of overfitting is right there: train error keeps falling while test error climbs. Watch it happen across every degree from 1 to 15:

for deg in range(1, 16):
    m = make_pipeline(PolynomialFeatures(deg), LinearRegression()).fit(X_tr, y_tr)
    tr = np.sqrt(mean_squared_error(y_tr, m.predict(X_tr)))
    te = np.sqrt(mean_squared_error(y_te, m.predict(X_te)))
    print(f"deg {deg:>2}: train {tr:.4f}   test {te:.4f}")
Degree Train RMSE Test RMSE Verdict
1 0.4610 0.5353 underfit — both high
2 0.1900 0.2316 improving
3 0.1197 0.1905 just right (test minimum region)
4 0.0929 0.2422 starting to overfit
5 0.0901 0.2863 test rising
6 0.0897 0.2324 noisy
7 0.0864 0.7506 test blowing up
10 0.0859 0.9014 overfit
13 0.0854 1.1922 badly overfit
15 0.0851 1.5005 catastrophic

The train column falls monotonically — more flexibility always fits the training data better, all the way to memorization. The test column is a U: it falls as the model gains just enough capacity to catch the signal, bottoms out around degree 3, then rises as the model starts memorizing noise. The bottom of that U is the model you want. You can only see the U because you held out a test set; on the training error alone, degree 15 looks best, and you would ship a disaster.

The picture makes it visceral — the left panel shows the three fits over the data, the right panel plots both error curves:

import matplotlib
matplotlib.use("Agg")                    # headless — no display needed
import matplotlib.pyplot as plt
# ... (full plotting code in the lab) ...
fig.savefig("overfit.png", dpi=110)

On the left, the degree-1 line (blue) slices straight through the curved data, the degree-3 curve (green) traces the true cosine cleanly, and the degree-15 curve (red) convulses — threading every training dot but flinging off to ±2 in the gaps between them, which is exactly where the test points live. On the right, the blue train-error line slides down forever while the red test-error line dives then rockets up: the generalization gap made visible.

The bias–variance tradeoff

The U-curve has a name in theory: the bias–variance tradeoff. Any model’s expected error on new data decomposes into three parts:

Simple models: high bias, low variance (steady but wrong). Complex models: low bias, high variance (flexible but unstable). Total error is roughly bias² + variance + noise, and as you crank up complexity, bias falls while variance rises — so total error traces that U, minimised at the sweet spot where they balance. That is the same U you just measured; the theory and the experiment are one picture.

High bias (underfit) Sweet spot High variance (overfit)
Model complexity too low just right too high
Train error high low very low
Test error high low high
Train vs test gap small small large
Fix more complex model, better features simpler model, more data, regularization
Our polynomial degree 1 degree 3 degree 15

The whole picture — the fork into supervised and unsupervised, the shared fit→predict→evaluate loop, and the generalization gap marked at the end — is worth holding in one image:

Machine learning taxonomy and workflow: a dataset with a feature matrix X and an optional label vector y forks on whether labels are present. Labeled data goes to supervised learning, splitting into regression for a continuous target and classification for a discrete class; unlabeled data goes to unsupervised learning, splitting into clustering, dimensionality reduction and anomaly detection. Both families feed the same scikit-learn loop of fit a model then predict on unseen rows, which is then evaluated on held-out data where underfitting and overfitting are caught as the generalization gap between low training error and high test error

The badges trace the ideas to carry out of this lesson: whether a label y exists is the fork that decides everything (1); supervised learning maps X→y as regression or classification (2); unsupervised learning finds structure in X alone (3); every model shares the one fit/predict API (4); you must score on held-out data or you are only measuring memorization (5); and the gap between train and test error is where underfitting and overfitting live (6).

⚠️ Why you MUST hold out data — and a leakage preview. The test set is sacred: it stands in for “the future,” data the model has never seen. Score on the training set instead and every model looks brilliant, because you are grading the exam with the answer key it studied. Worse is data leakage — when information about the answer sneaks into the features. If you accidentally include a column that is the target in disguise, the model gets a near-perfect score that evaporates in production:

from sklearn.datasets import load_diabetes
X_ok, y = load_diabetes(return_X_y=True)
X_leak = np.column_stack([X_ok, y + rng.normal(0, 1, y.size)])   # a "feature" that IS the target
# ... train/test split, LinearRegression.fit ...
# test R^2 WITH the leaked feature : 0.9998   <- too good to be true
# test R^2 without it              : 0.3594   <- the honest number

A score that looks too good is a symptom to investigate, not celebrate. The full discipline of splitting, cross-validation, and leak-proofing belongs to the metrics-and-validation lesson; for now, the rule is absolute: hold out data, and be suspicious of near-perfect scores.

You score that held-out data with a metric, and the metric depends on the task. You have already seen R² and accuracy; here is the preview map so the names in later lessons land in place — but note the loud warning attached to accuracy, which the lab demonstrates:

Task Metric Range Reads as Caveat
Regression RMSE / MAE 0 → ∞ (lower better) typical error, in target units RMSE punishes big misses harder
Regression ≤ 1 (higher better) fraction of variance explained can go negative (worse than the mean)
Classification Accuracy 0 → 1 fraction correct ⚠️ lies on imbalanced classes
Classification Precision / Recall 0 → 1 of flagged, how many right / of real, how many caught trade off against each other
Classification F1 0 → 1 harmonic mean of precision & recall better than accuracy when imbalanced
Clustering Silhouette −1 → 1 how well-separated the clusters are no labels needed

The model-family map

You have driven a linear model, a logistic classifier, and KMeans. There are dozens more, but they cluster into a handful of families with shared instincts. You do not need the details yet — later lessons take these one at a time — but you should recognise the map so new algorithm names land in the right slot.

Family Idea in one line Examples (sklearn) Strengths Watch out for
Linear Fit a weighted sum of features LinearRegression, LogisticRegression, Ridge, Lasso Fast, interpretable, strong baseline Only linear relationships (unless you add features)
Tree-based Ask yes/no questions, split the data DecisionTreeClassifier, DecisionTreeRegressor Handles non-linearity, no scaling needed, readable A single deep tree overfits badly
Ensembles Combine many weak models RandomForest…, GradientBoosting…, HistGradientBoosting… Top accuracy on tabular data Slower, less interpretable
Distance-based Predict from the nearest examples KNeighborsClassifier, KMeans Simple, no training phase (kNN) Must scale features; slow at prediction; curse of dimensionality
Kernel / SVM Separate classes with a max-margin boundary SVC, SVR Powerful in medium dimensions Scaling-sensitive; slow on large data
Neural networks Layers of learned non-linear transforms MLPClassifier; PyTorch/TensorFlow for deep nets State of the art on images, text, audio Hungry for data and compute; opaque
Naive Bayes Probabilities under a “features independent” assumption GaussianNB, MultinomialNB Very fast, great text baseline The independence assumption is usually false

One axis the table hides is cost: families differ enormously in how training time and prediction latency scale with the data. A lazy kNN trains instantly but then scans every stored row on each prediction — an O(n) query cost that balloons on big data — while a linear model is the opposite (slow-ish to train, near-instant to predict). Reasoning about that scaling is exactly the Big-O skill from Algorithms: Search, Sort & Complexity, now applied to model choice.

The professional move early on: start with the simplest thing that could work — a linear model or a single tree — as a baseline, then only add complexity if it measurably beats that baseline on held-out data. A random forest that barely edges out LinearRegression is rarely worth the loss of interpretability.

There is a theorem behind this humility, the “no free lunch” result: no single algorithm is best across all possible problems. Which family wins is an empirical question you answer by trying them and comparing on held-out data, not by allegiance. That said, strong priors exist and save time: for tabular data (rows and columns, like most business datasets) gradient-boosted trees — HistGradientBoosting, or libraries like XGBoost and LightGBM — are the usual winners, with a random forest close behind. For images, audio, and text, neural networks dominate and nothing else is close. For a fast, explainable baseline you can defend to a stakeholder, a linear or logistic model is hard to beat. Match the family to the data shape first, then let the held-out score settle the rest.


The end-to-end ML workflow

A model is maybe 10% of a real ML project. The other 90% is the pipeline around it, and beginners who jump straight to fit skip the steps that actually determine success. The honest workflow:

# Stage What you actually do Where it goes wrong
1 Frame the problem Is this even ML? Regression or classification? What’s the metric of success? Using ML where a rule fits; optimising the wrong metric
2 Get & understand data Collect, inspect, check quality, plot distributions Non-representative or biased data; too little of it
3 Clean & engineer features Handle missing values, encode categories, scale, build features Leakage; scaling after splitting; garbage features
4 Split Hold out a test set (and a validation set / CV) before touching it Peeking at the test set; splitting after preprocessing
5 Choose & train a model Baseline first, then candidates; .fit(X_train, y_train) Jumping to complex models; no baseline
6 Evaluate Score on held-out data with the right metric; compare to baseline Evaluating on train; accuracy on imbalanced data
7 Tune Adjust hyperparameters via cross-validation Tuning on the test set (it’s no longer held-out)
8 Deploy Ship the model behind an API; version it and the data Training/serving skew; unversioned models
9 Monitor Watch live performance; data drifts, models rot Assuming a model stays good forever

Two stages deserve emphasis because they are where beginners bleed. Splitting comes before preprocessing decisions that learn from data — if you scale or select features using the whole dataset and then split, information from the test set has leaked into training. And monitoring is not optional: the world changes, so the pattern your model learned drifts out of date. This has a name — concept drift — and it is not rare. A demand-forecasting model trained on 2019 data was worthless in April 2020; a fraud model degrades the moment fraudsters adapt to it; a recommender trained on last year’s catalogue slowly stops recommending anything people currently want. The model does not announce its own decay — its accuracy just quietly slides while it keeps returning confident predictions. A model that was excellent at launch can silently rot, and the only way you find out is by tracking live performance against fresh labels and alerting when it drops.

Because the split is where so much rides, know its knobs — train_test_split is the one function you will call in every supervised project:

Parameter Does Sensible default
test_size fraction (or count) held out for testing 0.20.25
random_state seeds the shuffle so the split is reproducible any fixed int (0, 42)
stratify=y keep each class’s proportion equal in train and test always set for classification
shuffle shuffle before splitting (default True) leave True — except for time series
return order X_train, X_test, y_train, y_test memorise this order

⚠️ For time-series data, do not shuffle — you must train on the past and test on the future, or you leak tomorrow into today. Use TimeSeriesSplit instead; a random split on time-ordered data is a classic silent leak.


Data quality dominates: garbage in, garbage out

Here is the least glamorous and most important truth in the field: better data beats a fancier model, almost always. A modest model on clean, representative, well-labeled data will crush a state-of-the-art architecture on biased, mislabeled, or unrepresentative data. Kaggle grandmasters and industry teams spend the majority of their time on data, not models — because that is where the wins are.

The model can only learn what is in the data, faithfully including the mistakes:

Data problem What the model learns instead Symptom Fix
Mislabeled examples The wrong answer, confidently Ceiling on accuracy you can’t break Audit & relabel; measure label quality
Non-representative sample Patterns of the wrong population Great offline, fails on real users Sample to match production reality
Missing values, done wrong Artifacts of your imputation Weird importance on “was-missing” Impute deliberately; add missingness flags
Imbalanced classes “Always predict the majority” High accuracy, catches no positives Resample, reweight, change the metric
Leaked target The answer via a proxy feature Suspiciously perfect scores Audit features; split before engineering
Too few examples The noise, not the signal High variance, overfits Get more data; simpler model; regularize
Historical bias Society’s past discrimination Unfair predictions on protected groups Audit for bias; curate data; fairness constraints

⚠️ A model trained on biased data encodes and amplifies that bias, wearing the mask of objectivity. If your hiring data reflects decades of biased human decisions, a model learns to reproduce them — and now the discrimination comes with a veneer of mathematical neutrality that is harder to challenge. ML does not launder bias out of data; it bakes it in. This is not a corner case; it is a first-order responsibility of anyone who ships models.


The honest limits of ML

To use ML well you must be clear-eyed about what it is not, because the hype systematically oversells it. A trained model is a compressed summary of correlations in its training data — a very sophisticated one, but that is the whole of it. From that single fact every limit follows.

People assume ML… Reality
understands the problem matches patterns in past data, nothing more
finds causes finds correlations — including spurious ones
is objective / neutral reflects the biases baked into its training data
works on any input only trustworthy within its training distribution
can explain itself opaque unless you add explainability tooling
stays good once trained drifts and rots as the world changes
replaces domain expertise needs it more than ever — to frame, check, and catch it

None of this means ML is not extraordinarily useful — it is, for exactly the problems in this lesson’s opening. It means you deploy it as a fallible statistical tool with measured error rates and human oversight, not an oracle. The engineers who get burned are the ones who forgot it was correlation all the way down.


Hands-on lab

Run the full arc on scikit-learn’s built-in datasets — no downloads, they ship with the library — then reproduce the overfitting curve yourself and save the figure headless. Everything is seeded; your numbers will match the comments.

scikit-learn bundles small “toy” datasets (loaded with load_*) and synthetic-data generators (make_*) precisely so you can practise without hunting for data. The ones worth knowing:

Loader Task Shape Classes / target Use for
load_diabetes() regression 442 × 10 continuous 25–346 a realistic (hard) regression
load_iris() classification 150 × 4 3 balanced (50 each) the classic first classifier
load_wine() classification 178 × 13 3 (59/71/48) near-separable, easy win
load_breast_cancer() classification 569 × 30 2 (binary) binary, mildly imbalanced
make_blobs(...) clustering you choose you choose the centers clean clustering demos
make_classification(...) classification you choose you choose stress-testing / imbalance

Setup (once):

python3 -m venv .venv && source .venv/bin/activate    # Windows: .venv\Scripts\activate
pip install scikit-learn numpy pandas matplotlib

Put each step in ml_lab.py and run with python ml_lab.py.

Step 1 — regression on the diabetes dataset (and a baseline).

import numpy as np
from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
from sklearn.dummy import DummyRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error, r2_score

dia = load_diabetes()
print("X shape:", dia.data.shape)                 # => X shape: (442, 10)
print("features:", dia.feature_names)             # age, sex, bmi, bp, s1..s6
X_tr, X_te, y_tr, y_te = train_test_split(dia.data, dia.target, test_size=0.25, random_state=0)

reg = LinearRegression().fit(X_tr, y_tr)
pred = reg.predict(X_te)
print("test R^2 :", round(r2_score(y_te, pred), 4))                 # => 0.3594
print("test MAE :", round(mean_absolute_error(y_te, pred), 2))      # => 45.12

baseline = DummyRegressor(strategy="mean").fit(X_tr, y_tr)          # always predict the mean
print("baseline MAE:", round(mean_absolute_error(y_te, baseline.predict(X_te)), 2))  # => 58.31

What just happened: a real regression. R²=0.36 is modest — real medical data is hard, and that honesty is the point; ML is not magic. But the model’s MAE (45.1) clearly beats the dumb “predict the mean” baseline (58.3), so it has learned something real. Always compute the baseline — a model that cannot beat it is worthless, however sophisticated.

Step 2 — classification on the wine dataset.

from sklearn.datasets import load_wine
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, confusion_matrix

wine = load_wine()
print("X shape:", wine.data.shape, "classes:", wine.target_names.tolist())
# => X shape: (178, 13) classes: ['class_0', 'class_1', 'class_2']
print("class counts:", np.bincount(wine.target).tolist())          # => [59, 71, 48]

X_tr, X_te, y_tr, y_te = train_test_split(
    wine.data, wine.target, test_size=0.25, random_state=0, stratify=wine.target)
clf = RandomForestClassifier(n_estimators=100, random_state=0).fit(X_tr, y_tr)

print("train accuracy:", round(clf.score(X_tr, y_tr), 4))          # => 1.0
print("test  accuracy:", round(accuracy_score(y_te, clf.predict(X_te)), 4))  # => 1.0
print("confusion matrix:\n", confusion_matrix(y_te, clf.predict(X_te)))
# => [[15  0  0]
#     [ 0 18  0]
#     [ 0  0 12]]

What just happened: a 3-class classifier. Wine is nearly separable, so the forest hits 100% on the held-out set — the confusion matrix is perfectly diagonal (every true class predicted correctly, zero off-diagonal mistakes). Note stratify=wine.target, which keeps the class proportions identical in train and test — essential when classes are uneven. Do not expect 100% on real problems; wine is a gentle teaching set.

Step 3 — clustering on blobs, and choosing k (elbow + silhouette).

from sklearn.datasets import make_blobs
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score

X, _ = make_blobs(n_samples=300, centers=3, cluster_std=1.0, random_state=42)
# We deliberately IGNORE the true labels (the _) — clustering is unsupervised.

for k in range(2, 7):
    km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X)
    sil = silhouette_score(X, km.labels_)          # needs >= 2 clusters
    print(f"k={k}: inertia={km.inertia_:8.1f}  silhouette={sil:.3f}")
# => k=2: inertia=  5763.5  silhouette=0.705
# => k=3: inertia=   566.9  silhouette=0.848   <- inertia elbow AND silhouette peak
# => k=4: inertia=   496.8  silhouette=0.660
# => k=5: inertia=   426.3  silhouette=0.492
# => k=6: inertia=   361.1  silhouette=0.331

What just happened: two independent ways to choose k agree on 3. Inertia (total within-cluster distance) always falls as k rises — with k=n every point is its own cluster and inertia hits zero, which is useless as a target — so you look for the elbow, the sharp bend where the drop flattens (here the plunge to k=3 then the flattening after). The silhouette score (−1 to 1, higher = better-separated clusters) needs no such eyeballing: it peaks cleanly at k=3 (0.848). We built three blobs, so both are right. Choosing k is the hard part of KMeans:

Method What it measures Best k is where… On our blobs
Elbow (inertia) within-cluster tightness the curve bends / flattens k=3 (bend)
Silhouette separation vs cohesion, per point the score is highest k=3 (0.848)
Domain knowledge what the clusters are for the business says so “we want 3 tiers”

Step 4 — demonstrate overfitting yourself and save the figure.

import matplotlib
matplotlib.use("Agg")                              # headless: render without a screen
import matplotlib.pyplot as plt
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

rng = np.random.default_rng(1)
x = np.sort(rng.uniform(0, 1, size=30))
y = np.cos(1.5 * np.pi * x) + rng.normal(0, 0.12, size=30)
X = x.reshape(-1, 1)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.4, random_state=1)

degrees = range(1, 16)
train_err, test_err = [], []
for d in degrees:
    m = make_pipeline(PolynomialFeatures(d), LinearRegression()).fit(X_tr, y_tr)
    train_err.append(np.sqrt(mean_squared_error(y_tr, m.predict(X_tr))))
    test_err.append(np.sqrt(mean_squared_error(y_te, m.predict(X_te))))

best = list(degrees)[int(np.argmin(test_err))]
print("lowest test RMSE at degree", best)          # => lowest test RMSE at degree 3

plt.figure(figsize=(7, 4.5))
plt.plot(list(degrees), train_err, "o-", label="train RMSE")
plt.plot(list(degrees), test_err, "s-", label="test RMSE (held-out)")
plt.axvline(best, ls=":", color="green", label=f"best (deg {best})")
plt.yscale("log"); plt.xlabel("polynomial degree"); plt.ylabel("RMSE (log)")
plt.title("Train falls forever; test is a U — the generalization gap")
plt.legend(); plt.tight_layout()
plt.savefig("overfit.png", dpi=110)
print("saved -> overfit.png")

What just happened: you generated the U-curve on your own machine. Open overfit.png: the train line slides down monotonically while the test line dips to a minimum at degree 3 and then climbs steeply. That divergence is overfitting, and you can only see it because you held out a test set. ⚠️ matplotlib.use("Agg") before importing pyplot is what lets this run on a headless server (no display); plotting itself is covered in Matplotlib: Plotting Basics.

Step 5 — the imbalanced-accuracy trap (a preview of why accuracy lies).

from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, balanced_accuracy_score, f1_score

y_imb = np.zeros(1000, dtype=int); y_imb[:50] = 1     # 5% positive (fraud-like)
rng.shuffle(y_imb)
Xz = np.zeros((1000, 1))
dummy = DummyClassifier(strategy="most_frequent").fit(Xz, y_imb)
pred = dummy.predict(Xz)
print("accuracy         :", round(accuracy_score(y_imb, pred), 4))          # => 0.95
print("balanced accuracy:", round(balanced_accuracy_score(y_imb, pred), 4)) # => 0.5
print("F1 (positive)    :", round(f1_score(y_imb, pred), 4))                # => 0.0

What just happened: a model that always predicts “not fraud” scores 95% accuracy on data that is 95% negative — while catching zero actual fraud (F1 = 0, balanced accuracy = 0.5, no better than a coin). Accuracy is a liar on imbalanced data. Which metric to trust, and why, is the heart of the metrics lesson; the lesson here is that a single number is never enough.


Common mistakes and troubleshooting

Symptom / mistake Cause Fix
Chose ML for a task with a known rule ML fashion over judgement If you can state the rule, write the rule — it’s exact, fast, explainable
ValueError: Expected 2D array, got 1D array instead Passed a flat list/1-D array as X X must be 2-D: X.reshape(-1, 1) for one feature, reshape(1, -1) for one sample
100% train accuracy, poor test accuracy Overfitting — model memorized noise Simpler model, more data, regularization; trust the held-out score
Both train and test accuracy poor Underfitting — model too simple More complex model / better features; more capacity
“Amazing” accuracy you can’t reproduce live Evaluated on the training data Always score on a held-out test set — training score is meaningless
Model looks great but nobody knows if it’s good No baseline to compare against Compare to DummyClassifier/DummyRegressor; beat it or it’s worthless
95% accuracy, catches none of the rare class Imbalanced classes fool accuracy Use balanced accuracy / precision-recall / F1; resample or reweight
Test score suspiciously near-perfect Data leakage — a feature encodes the target Audit features; split before preprocessing; drop target-derived columns
High variance, wildly different each run Too little data for the model’s complexity Get more data, simplify the model, add regularization
Great in dev, fails on real users Non-representative training data Sample to match production; check for distribution shift
Clusters make no sense / one feature dominates Clustering without scaling StandardScaler before any distance-based method (KMeans, kNN, SVM)
“X causes Y because they correlate” Confused correlation with causation Correlation is a hint, never proof; ML finds patterns, not causes
NotFittedError on .predict Called .predict before .fit Fit first: model.fit(X, y) then model.predict(X_new)
Found input variables with inconsistent numbers of samples: [n, m] X and y have different lengths len(X) == len(y) — one label per row

The three that do the most silent damage, in prose:

Evaluating on the training set is the original sin, because it always looks like success. Every model, no matter how bad, scores better on data it trained on than on new data — often perfectly, if it is flexible enough to memorize. So the training score is not a measure of quality; it is a measure of memorization capacity. Beginners fit a model, print .score(X_train, y_train), see 0.99, and ship. Then production accuracy is 0.7 and nobody understands why. The habit that saves you: the first number you look at is always the held-out score, and you never, ever tune against the test set (the moment you make decisions based on it, it stops being held-out and you need a fresh one).

Overfitting and underfitting look identical from inside the model — you need the two-number diagnosis. A single accuracy figure cannot tell you which failure you have; you must compare train vs test. Both high → underfitting (add capacity). Train low, test high → overfitting (remove capacity or add data). Train and test both low and close → you are done. Print both numbers, always, and read the gap — the gap between them is the single most informative diagnostic in applied ML, and it is one subtraction.

Forgetting to scale before distance-based methods silently corrupts the result. KMeans, kNN, and SVM measure distances between rows, and distance is dominated by whichever feature has the biggest units. Cluster on {age: 0–100, income: 0–100000} without scaling and the algorithm effectively ignores age — income’s thousands drown age’s tens in the Euclidean distance. We can measure the damage: cluster data whose true structure lives in a small-scale feature while a large-scale noise feature sits alongside, and score against the true labels with the Adjusted Rand Index (1.0 = perfect, 0 = random):

# true groups live in a small-range feature; a huge-range NOISE feature sits beside it
# ARI raw (unscaled) : -0.0034   <- recovers NOTHING; the noise feature hijacks distance
# ARI scaled         :  1.0000   <- StandardScaler first restores the real structure

Unscaled, the clustering recovers nothing (ARI ≈ 0); scaling first recovers the true groups perfectly (ARI = 1.0). The fix is one line — StandardScaler().fit_transform(X) before you cluster — and forgetting it is one of the most common reasons “my clusters look random.”


Cheat-sheet

Task Code Note
Feature matrix / labels X (2-D, n×d), y (1-D, n) uppercase X, lowercase y
Fix 1-D X X.reshape(-1, 1) one feature per column
Train/test split train_test_split(X, y, test_size=0.25, random_state=0) hold out before touching
Stratified split train_test_split(..., stratify=y) keep class balance
Fit a model model.fit(X_train, y_train) learn from data
Predict model.predict(X_new) apply the function
Class probabilities clf.predict_proba(X) confidence, not just label
Cluster (no labels) KMeans(n_clusters=k, n_init=10, random_state=0).fit(X) .labels_, .cluster_centers_, .inertia_
Linear regression LinearRegression() .coef_, .intercept_
Logistic (classifier!) LogisticRegression() despite the name, classification
Random forest RandomForestClassifier(n_estimators=100, random_state=0) strong tabular baseline
Scale features StandardScaler().fit_transform(X) before kNN / KMeans / SVM
Regression metrics r2_score, mean_absolute_error, mean_squared_error R², MAE, MSE
Classification metrics accuracy_score, f1_score, confusion_matrix accuracy lies on imbalance
Baseline DummyRegressor(strategy="mean") / DummyClassifier(strategy="most_frequent") beat it or it’s worthless
Built-in datasets load_diabetes(), load_wine(), load_iris(), make_blobs() no download
Overfit signature train ↓, test ↑ compare the two numbers
Reproducible RNG np.random.default_rng(seed) seed everything
Headless plot matplotlib.use("Agg") before import pyplot save, don’t show

Interview and exam questions

Q: In one sentence, what is machine learning, and how does it differ from ordinary programming? A: Machine learning learns a function from example data instead of having it programmed by hand — ordinary programming is “rules in, answers out” (you write the logic), while ML is “data and answers in, rules out” (the algorithm infers the logic from labeled examples). It is the right tool precisely when the rule is unknown or too complex to write by hand, like recognizing faces or detecting spam.

Q: Give a task where you should NOT use machine learning, and say why. A: Anything with a known, exact rule — computing tax, checking age >= 18, validating a credit-card number with the Luhn checksum, deciding free shipping over a threshold. A hand-written rule is exact, instant, explainable, needs no training data, and never drifts; an ML model for the same job is approximate, needs data, can fail on unseen inputs, and is a black box. Using ML there is strictly worse than an if statement.

Q: Explain supervised vs unsupervised learning, and the two kinds of supervised. A: Supervised learning has labeled data — every training row includes the answer y — and learns to predict it; it splits into regression (continuous target: price, latency) and classification (discrete target: spam/not, which class). Unsupervised learning has no labels, only X, and finds structure instead: clustering (group similar rows), dimensionality reduction (compress features), and anomaly detection (flag outliers).

Q: What are X and y, and what shapes does scikit-learn expect? A: X is the feature matrix — 2-D, shape (n_samples, n_features), one row per example and one column per feature. y is the target vector — 1-D, shape (n_samples,), one label per row. Passing a 1-D array as X raises ValueError: Expected 2D array, got 1D array instead; the fix is X.reshape(-1, 1) for a single feature.

Q: What is overfitting, what is underfitting, and how do you tell them apart? A: Underfitting is a model too simple to capture the signal — it has high error on both training and test data. Overfitting is a model so flexible it memorizes the training noise — very low training error but high test error. You distinguish them by comparing the two numbers: both high → underfit; train low and test high → overfit; both low and close → just right. The gap between train and test error is the diagnostic.

Q: Why must you evaluate on held-out data? What goes wrong if you don’t? A: Because the goal is generalization to unseen data, and a model always scores better — often perfectly — on data it trained on, since it can memorize. Evaluating on the training set measures memorization, not quality, so every model looks great and you ship one that fails in production. You hold out a test set that stands in for “the future” and never make decisions based on it (or it stops being held-out).

Q: Describe the bias–variance tradeoff and how it relates to the U-shaped test-error curve. A: Expected error on new data decomposes into bias (error from too-simple assumptions — underfitting), variance (error from over-sensitivity to the training sample — overfitting), and irreducible noise. Simple models have high bias and low variance; complex models the reverse. As complexity rises, bias falls and variance grows, so total error traces a U — minimised at the sweet spot. In the polynomial demo, degree 1 was high-bias (underfit), degree 15 high-variance (overfit), and degree 3 sat at the bottom of the U.

Q: A classifier reports 97% accuracy on a dataset that is 97% one class. Is it good? A: Almost certainly not — a model that blindly predicts the majority class scores 97% while catching none of the rare class. Accuracy is misleading on imbalanced data. Look at balanced accuracy, precision, recall, or F1 on the minority class, and compare to a DummyClassifier(strategy="most_frequent") baseline; if it doesn’t beat that, it has learned nothing useful.

Q: What is data leakage and how does it show up? A: Leakage is when information about the target sneaks into the features — for example a column derived from the label, or preprocessing (scaling, feature selection) done on the whole dataset before splitting. It shows up as a suspiciously high, “too good to be true” score that collapses in production, because the model was quietly given the answer. Prevent it by splitting before any data-dependent preprocessing and auditing features for target-derived columns.

Q: Why must you scale features before KMeans or kNN, but not (usually) before a decision tree? A: KMeans, kNN, and SVM measure distances between rows, and distance is dominated by the feature with the largest units — an unscaled large-range feature drowns out small-range ones. Scaling (e.g. StandardScaler) puts features on comparable footing. Trees split one feature at a time on thresholds, so they are invariant to feature scale and don’t need it. Skipping scaling before distance methods can make clustering recover essentially nothing (ARI ≈ 0 vs 1.0 scaled).

Q (coding): Fit a model, and report train and test scores to diagnose over/underfitting. A:

from sklearn.model_selection import train_test_split
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=0)
model.fit(X_tr, y_tr)
train_score = model.score(X_tr, y_tr)
test_score  = model.score(X_te, y_te)
print(f"train={train_score:.3f}  test={test_score:.3f}  gap={train_score-test_score:.3f}")
# both low  -> underfit ; train high & test low -> overfit ; both high & close -> good

The gap is the headline: a large positive gap means overfitting, both-low means underfitting.

Q: Why is “better data beats a better model” usually true? A: A model can only learn what’s in the data — it faithfully reproduces mislabeled rows, unrepresentative samples, and historical bias. A modest model on clean, representative, correctly labeled data outperforms a sophisticated one on flawed data, and no architecture fixes garbage inputs (garbage in, garbage out). That’s why practitioners spend most of their time on data collection, cleaning, and labeling, not model selection.

Q: Is a strong correlation or a high-accuracy model evidence that one thing causes another? A: No. ML is correlation-based pattern-matching; it learns what predicts the label, not what causes it. A model can predict drownings from ice-cream sales (summer drives both) without any causal link. Predictive power is not causal proof — establishing causation needs controlled experiments or careful causal inference, not a good .score().


Key takeaways

pythonmachine-learningscikit-learnsupervised-learningunsupervised-learningregressionclassificationclusteringoverfittingbias-variancegeneralizationkmeanstrain-test-splitfeatures-labels
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments