Home Chinland InfoTech Academy
Ready
All Courses

Machine Learning — Handbook

A complete, example-driven course in machine learning: from what learning even means, through data and features, the core algorithms and how they work mathematically, clustering and ensembles, evaluation and validation, practical strategy, and on to neural networks and deep learning — each idea explained plainly, with the key math, an intuition, and runnable code.
131 topics · 18 chapters · companion to the ML Concept Lab
Each topic is laid out the same way: The idea (the plain-language concept), Picture it (what the interactive in the lab shows), The math (the key equations), Derivation (where they come from), Code (a short runnable illustration), and In practice (how it is used). Use the search box on the left to jump to any topic.
Foundations
01

Rules vs learning

The core shift

The idea

Traditional programming: you write the rules, the computer applies them. Machine learning flips it — you feed in examples (data + answers), and the machine figures out the rules itself. Same goal, opposite direction. Toggle to see it.

Traditional code

You hand-write the rules (“if email contains ‘free money’ → spam”). Works until reality gets messy and the rules explode.
02

Three kinds of learning

Supervised · unsupervised · RL

The idea

Almost all of ML falls into three families, defined by what the data looks like and what you're trying to do. Tap each to see how it learns and a real example.

Supervised

Learn from labeled examples (input → known answer). Predict the label for new inputs. Most of this week.
03

Features & labels

X and y

The idea

Nearly all ML starts as a table. The columns you learn from are features (X); the column you're trying to predict is the label (y). Each row is one example. Toggle what we're predicting to see X and y change.

The shapes

Features→X Label / target→y

Code

python
import numpy as np
# Features X (inputs) and labels y (the answer to predict)
X = np.array([[1500, 3], [2100, 4], [900, 2.]])   # rows=samples, cols=features
y = np.array([320_000, 410_000, 180_000])          # target (house price)
print("X shape:", X.shape, " y shape:", y.shape)
04

Train / test split

Why we hold data back

The idea

The golden rule of ML: never judge a model on data it learned from. You split your data — train on most, then test on the held-out part it has never seen. That's the only honest measure of how it'll do in the real world.

Why?

A model can memorize its training data and look perfect — then fail on anything new. The test set catches that lie.

Code

python
import numpy as np
from sklearn.model_selection import train_test_split
X = np.random.randn(100, 3); y = np.random.randint(0, 2, 100)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=0)
print(len(X_tr), "train /", len(X_te), "test")   # never train on the test set
05

Train / val / test split

The third set

The idea

Train/test isn't enough once you start tuning. Every time you tweak a hyperparameter and check the same set, you quietly fit to it. So we use three: train to fit parameters, validation to tune and choose, test touched exactly once for an honest final number. Slide how many configs you try and watch the cheating gap grow.

The three roles

Train: fit the model's parameters. Validation: tune hyperparameters & pick the best model — looked at many times. Test: the vault: one final, untouched estimate.

Reported vs reality

true skill of chosen model82% if you tuned on TEST87%

Why a third set

Pick the best of many models by their test score and the winner is partly lucky — the more you try, the more its test score overstates reality (the multiple-comparisons trap). The validation set absorbs that optimism so the test set stays an honest proxy for new data.

Code

python
import numpy as np
from sklearn.model_selection import train_test_split
X = np.random.randn(100, 3); y = np.random.randint(0,2,100)
X_tmp, X_te, y_tmp, y_te = train_test_split(X, y, test_size=0.15, random_state=0)
X_tr, X_val, y_tr, y_val = train_test_split(X_tmp, y_tmp, test_size=0.18, random_state=0)
print(len(X_tr), len(X_val), len(X_te))   # train, tune-on-val, report-on-test
06

The ML workflow

The loop

The idea

Building a model is a cycle, not a straight line. You rarely get it right first pass — you evaluate, learn, and go around again. This loop is the backbone of every project this week. Step through it.

1 · Get & explore data

Load it, look at it, clean it. Understand what you have before modeling. Most real time goes here.
Stats & measurement
07

Precision vs accuracy

The dartboard

The idea

Two different ideas people mix up. Accuracy = how close you land to the truth (the bullseye). Precision = how consistent you are — hit the same spot every time and you're precise, even if that spot is wrong. Drag the two sliders and fire darts to feel the difference.

Accurate & precise

Tight cluster on the bullseye — the ideal.
08

Distributions & spread

Mean, variance, std

The idea

Most data clusters around a typical value and tails off — the bell-shaped normal distribution. The mean says where the center is; the standard deviation (σ) says how spread out it is. ~68% of values fall within ±1σ, ~95% within ±2σ. Slide them and watch the curve move and stretch.

The 68–95–99.7 rule

within ±1σ68% within ±2σ95% within ±3σ99.7%

Code

python
import numpy as np
x = np.random.normal(10, 3, 1000)
print("mean", round(x.mean(),2), "std", round(x.std(),2))
print("quartiles", np.round(np.percentile(x, [25,50,75]), 2))  # spread, not just center
09

Mean, median & mode

Drag the outlier

The idea

Three ways to say “the typical value.” The mean is the average (sensitive to extremes), the median is the middle value (robust), the mode is the most common. They agree on tidy data — but drag one point far out and watch the mean chase it while the median barely moves.

The three measures

Mean (average)— Median (middle)— Mode (most common)—

Code

python
import numpy as np
x = np.array([1, 2, 2, 3, 100.])         # one big outlier
print("mean  :", x.mean())                # 21.6 -- dragged by 100
print("median:", np.median(x))            # 2.0  -- robust
10

Probability & Bayes

The base-rate trap

The idea

A test is 99% accurate and you test positive — should you panic? Often not. When the condition is rare, most positives are false positives. Bayes' rule combines the base rate with the test accuracy to give the real answer. Tune the sliders and watch a “99% accurate” test mislead.

If you test positive…

P(actually have it)16.1%

Code

python
# Bayes' rule: update a prior with evidence
p_d, sens, spec = 0.01, 0.99, 0.95
p_pos = sens*p_d + (1-spec)*(1-p_d)
print("P(disease | +) =", round(sens*p_d/p_pos, 3))
11

Sampling bias

Who got left out?

The idea

Your model is only as fair as your data. If your sample doesn't look like the real population — because of how you collected it — every conclusion is skewed, no matter how much data you have. Compare a fair random sample with a biased one and watch the estimate drift from the truth.

Estimate vs truth

True average— Sample estimate—

Code

python
import numpy as np
# Biased sample -> biased estimate of the population mean
pop = np.r_[np.zeros(9000), np.ones(1000)]      # true rate 10%
biased = np.r_[np.zeros(500), np.ones(500)]      # over-samples the minority
print("true:", pop.mean(), " biased estimate:", biased.mean())
12

Sampling methods

How to draw a sample

The idea

You rarely measure a whole population — you sample it. How you sample decides whether the sample mirrors reality. Compare four schemes and watch how well each preserves the true mix of subgroups.

Simple random

Every point has an equal chance. Unbiased on average, but a single sample can still miss small subgroups by luck.

Subgroup proportions

true mix60 / 30 / 10 % sample mix63 / 33 / 4 %

Code

python
import numpy as np
data = np.arange(100)
simple = np.random.choice(data, 10, replace=False)         # simple random
strata = [data[:50], data[50:]]                             # stratified: sample each
strat = np.r_[np.random.choice(strata[0],5), np.random.choice(strata[1],5)]
print(sorted(strat))
13

Bootstrapping

Resample for confidence

The idea

How sure are you about an estimate from one sample? The bootstrap answers without any formula: resample your data with replacement hundreds of times, recompute the statistic each time, and the spread of those answers is your uncertainty. Run it and watch a confidence interval emerge.

The math · The idea

Treat your sample as a stand-in for the population. Draw n points from it with replacement (duplicates allowed), compute the mean, repeat: x̄*₁, x̄*₂, … , x̄*B Their distribution ≈ the sampling distribution of the mean.

Result

bootstrap samples0 95% CI—

Why it's powerful

It needs no assumption that the data is normal and works for almost any statistic — medians, correlations, model scores. It's also the engine inside bagging and random forests.

Code

python
import numpy as np
# Bootstrap: resample with replacement to estimate uncertainty
data = np.random.randn(50) + 5
means = [np.random.choice(data, len(data), replace=True).mean() for _ in range(10000)]
lo, hi = np.percentile(means, [2.5, 97.5])
print(f"95% CI for the mean: [{lo:.2f}, {hi:.2f}]")
Data & features
14

Feature scaling

Why units matter

The idea

Distance-based models (k-NN, k-means, SVM) measure how “far apart” points are. If one feature is in dollars (0–100k) and another in years (0–80), the big-number feature drowns out the other — purely because of its units. Toggle scaling and watch the nearest neighbor actually change.

Raw units

Distances are dominated by income (huge numbers). Age barely counts — the “nearest” neighbor is just whoever has similar income.

Code

python
import numpy as np
X = np.array([[1.,1000.],[2.,2000.],[3.,3000.]])
mu, sd = X.mean(0), X.std(0)
print(np.round((X - mu)/sd, 2))    # standardized: each column mean 0, std 1
15

Correlation

Drag the strength

The idea

Correlation (r) measures how tightly two variables move together, from −1 (perfect down) through 0 (no relationship) to +1 (perfect up). It's the first thing you check between features and a target. Drag the strength and watch the cloud tighten — but remember the catch below.

Strong positive

As one goes up, the other tends to go up. The tighter the cloud hugs the line, the closer |r| is to 1.

Code

python
import numpy as np
x = np.random.randn(200); y = 2*x + np.random.randn(200)
r = np.corrcoef(x, y)[0,1]
print("Pearson r =", round(r, 2))   # +1/-1 linear, 0 = no linear relation
16

Outliers & robustness

Drag the rogue point

The idea

A single bad data point can wreck a model. Ordinary least-squares regression minimizes squared error, so one far-off point yanks the whole line toward it. Drag the amber outlier and watch the standard line lurch — while a robust line barely flinches.

Lines

Least-squares (OLS)squared error Robustignores the outlier

Code

python
import numpy as np
x = np.r_[np.random.randn(100), [12, -9]]
q1, q3 = np.percentile(x, [25, 75]); iqr = q3 - q1
mask = (x < q1 - 1.5*iqr) | (x > q3 + 1.5*iqr)
print("outliers:", np.round(x[mask], 1))   # IQR rule
17

One-hot encoding

Categories → numbers

The idea

Models do math, so text categories must become numbers. But labeling red=1, green=2, blue=3 is a trap — it tells the model blue > red, which is nonsense. One-hot encoding instead makes one yes/no column per category. Toggle the methods to see why one-hot is safe.

One-hot encoding

Each category gets its own 0/1 column. No fake ordering — every category is equally distinct. This is the safe default for unordered categories.

Code

python
import pandas as pd
df = pd.DataFrame({'city': ['NY','LA','NY','SF']})
print(pd.get_dummies(df['city']).values)   # one binary column per category
18

Missing data

Fill the gaps

The idea

Real datasets are full of holes — and models can't train on blanks. You either drop the gaps or impute (fill) them, and the choice changes your results. Toggle the strategies to see each fill the missing values differently, with its own trade-off.

Drop rows

Throw out any row with a gap. Simple and safe — but you can lose a lot of data, and bias creeps in if gaps aren’t random.

Code

python
import numpy as np
X = np.array([[1., np.nan, 3.], [4., 5., np.nan]])
col_mean = np.nanmean(X, axis=0)
idx = np.where(np.isnan(X)); X[idx] = np.take(col_mean, idx[1])
print(X)   # mean imputation (fit on TRAIN only in practice)
19

Feature engineering

Make better inputs

The idea

Often the model isn't the problem — the features are. A straight line can't fit a curve, but give it the right engineered feature and it suddenly can. Toggle the transform and watch a failing linear fit snap into place as R² jumps.

Linear fit quality

R² (variance explained)0.000 A line through curved data leaves huge errors — R² is low. The model class can’t bend.

Common transforms

Polynomial: x², x³ — capture curves Interaction: x₁·x₂ — combined effects Log: tame skew / multiplicative scale Binning: continuous → categories Date parts: day-of-week, month, is-holiday

Code

python
import numpy as np
# Engineer informative features from raw columns
length = np.array([2., 3., 5.]); width = np.array([1., 1., 2.])
area = length * width                       # interaction
ratio = length / width                       # ratio feature
print(np.c_[area, ratio])
20

Fit on train, apply to test

Learned parameters

The idea

Preprocessing has parameters too — the mean and std a scaler uses, the median an imputer fills with. These must be learned from training data only, then applied unchanged to test and future data. Compute them on everything and you've leaked the test set. Toggle the two ways and watch what the test point sees.

The math · The standardization

z = x − μσ μ used0.395 σ used0.061

Why train-only?

At deployment you score one new point at a time — you simply don't have the test distribution to average over. The scaler must carry the training-time μ and σ and apply them to whatever arrives, even if the new point lands oddly. Fitting on all data lets the test set influence training → optimistic, dishonest scores.

Code

python
import numpy as np
Xtr = np.random.randn(80,3); Xte = np.random.randn(20,3)
mu, sd = Xtr.mean(0), Xtr.std(0)            # fit ONLY on train
Xtr_s = (Xtr-mu)/sd; Xte_s = (Xte-mu)/sd    # reuse train stats on test
print("no leakage: test uses train's mu/sd")
21

Target / mean encoding

High-cardinality categories

The idea

One-hot encoding a column with thousands of categories (zip code, product id) explodes into thousands of columns. Target encoding replaces each category with the average target for that category — one tidy numeric column. The catch: done naively it leaks the label, so rare categories must be smoothed toward the global average.

The math · The encoding

Replace category c with its smoothed mean target: enc(c) = nᶜ·ȳc + m·ȳnᶜ + m Categories with few rows (small nᶜ) get pulled toward the global mean ȳ — protection against noise.

Columns needed

one-hot6 columns target encoding1

The leakage trap

If you encode using a row's own target, the model just memorizes it. Always compute the means on the training data only — ideally out-of-fold (each fold encoded from the others) — so the encoding can't peek at the answer.

Code

python
import pandas as pd
df = pd.DataFrame({'city':['A','A','B','B'], 'y':[1,0,1,1]})
means = df.groupby('city')['y'].transform('mean')   # category -> mean target
print(means.values)    # powerful but leak-prone; use CV/smoothing
22

Data augmentation

Free training data

The idea

More data fights overfitting — and you can manufacture some for free by applying label-preserving transformations. A rotated, flipped, or slightly noised “7” is still a “7,” so each original becomes many training examples. Toggle the transforms and watch the variants multiply.

Why it works

The label stays the same, so the model learns the invariances you care about — a cat is a cat whether it faces left or right. It can't memorize exact pixels because it never sees the same image twice.

Domain-specific

Images: flip, rotate, crop, recolor, cutout Text: synonyms, back-translation Audio: pitch/speed shift, add noise Tabular: jitter, SMOTE

Code

python
import numpy as np
# Augment data with label-preserving transforms (here: noise + flip)
img = np.random.rand(8, 8)
aug = [img, img[:, ::-1], img + 0.05*np.random.randn(8,8)]
print(len(aug), "views of the same label")
23

Feature selection

Keep what matters

The idea

More features isn't more better — irrelevant ones add noise, slow training, and invite overfitting. Feature selection keeps the informative subset. Slide how many features you keep and watch performance climb, peak, then sag as you start adding pure noise.

Three families

Filter: rank by a stat (correlation, mutual info) — fast, model-agnostic. Wrapper: try subsets, score a model (RFE) — accurate, expensive. Embedded: selection during training (Lasso, tree importance).

Result

validation accuracy68.3%

The shape

Bars are each feature's relevance (mutual information). Keep the informative ones and accuracy rises; keep going into the low-relevance noise features and accuracy falls — extra noise dimensions hurt. The sweet spot is the elbow.

Code

python
import numpy as np
from sklearn.feature_selection import SelectKBest, f_classif
X = np.random.randn(200, 10); y = (X[:,0]+X[:,1] > 0).astype(int)
sel = SelectKBest(f_classif, k=3).fit(X, y)
print("kept features:", np.where(sel.get_support())[0])
24

Text features (TF-IDF)

Words → numbers

The idea

Classical models can't read text, so we vectorize it. Bag-of-words just counts terms; TF-IDF is smarter — it boosts words that are frequent in this document but rare across the corpus, and crushes ubiquitous words like “the.” Toggle the two and watch which terms light up.

The math · The math

tfidf(t,d) = tf(t,d) · log Ndf(t) A term in every document has df = N, so log(N/df) = 0 — it's zeroed out. Rare, document-specific terms get the highest weight.

Why it works

“the” appears everywhere and distinguishes nothing; TF-IDF silences it automatically without a stop-word list. The result is a sparse vector per document where the big numbers are the words that actually characterize it — a strong, cheap baseline before embeddings.

Code

python
from sklearn.feature_extraction.text import TfidfVectorizer
docs = ["the cat sat", "the dog ran", "cat and dog"]
tfidf = TfidfVectorizer().fit_transform(docs)
print(tfidf.shape)   # rows=docs, cols=terms; rare-but-present terms weigh more
25

Multicollinearity & VIF

Correlated predictors

The idea

When two predictors carry nearly the same information, a linear model can't tell which deserves the credit — so its coefficients become wild and unstable, even though predictions stay fine. The variance inflation factor measures it. Crank up the correlation and watch the coefficients swing.

The math · VIF

VIFj = 11 − Rj² VIF1.2 coefficient stabilitystable

What to do

Rule of thumb: VIF > 5–10 signals trouble. Predictions are usually unaffected, but you can't trust the individual coefficients or their p-values. Fix by dropping one of the pair, combining them, or using regularization (ridge) which tolerates correlation. Also check residual plots for non-random patterns.

Code

python
import numpy as np
# VIF flags features that are linear combinations of others
X = np.random.randn(100, 3); X = np.c_[X, X[:,0]+X[:,1]]   # col 4 is redundant
def vif(X, i):
    others = np.delete(X, i, 1); coef,_,_,_ = np.linalg.lstsq(others, X[:,i], rcond=None)
    r2 = 1 - ((X[:,i]-others@coef)**2).sum()/((X[:,i]-X[:,i].mean())**2).sum()
    return 1/(1-r2+1e-9)
print("VIF of redundant col:", round(vif(X,3), 1))   # very high
How models learn
26

How models learn

Gradient descent · interactive

The idea

A model “learns” by minimizing error. Picture the error as a hill — the model takes small downhill steps to reach the bottom (lowest error). The step size is the learning rate. Too small = slow; too big = it overshoots. Run it and feel the difference.

Code

python
import numpy as np
# Learning = follow the gradient downhill on a loss
loss = lambda w: (w-3)**2; grad = lambda w: 2*(w-3)
w, lr = 0.0, 0.1
for _ in range(50): w -= lr*grad(w)
print("learned w:", round(w, 3))   # -> 3
27

Loss surface & LR

Descend the valley

The idea

Gradient descent in 2-D. The contours are a loss “valley”; the model walks downhill toward the minimum. On a stretched valley a big learning rate bounces off the walls instead of flowing down the floor. Click to drop a starting point, pick a rate, and run.

Code

python
import numpy as np
# The learning rate sets the step size on the loss surface
grad = lambda w: 2*w
for lr in (0.05, 0.5, 1.05):
    w = 5.0
    for _ in range(30): w -= lr*grad(w)
    print(f"lr={lr}: w={w:.2e}")   # 1.05 diverges
28

Overfitting & underfitting

Drag the complexity

The idea

The central struggle of ML. Too simple a model underfits — it misses the real pattern. Too complex and it overfits — it memorizes the noise and fails on new data. Drag the model complexity and watch the fit (and the test error) change.

Underfitting

A straight line can’t capture a curve. High error on both training and test data — the model is too simple.

Code

python
import numpy as np
from numpy.polynomial import polynomial as P
x = np.linspace(0,1,10); y = np.sin(6*x)+0.1*np.random.randn(10)
for deg in (1, 3, 9):
    c = np.polyfit(x, y, deg); tr = np.mean((np.polyval(c,x)-y)**2)
    print(f"degree {deg}: train MSE {tr:.4f}")   # deg 9 fits noise -> overfits
29

Bias–variance tradeoff

Find the sweet spot

The idea

The same story, drawn as curves. As complexity rises, training error keeps falling — but test error follows a U: it drops, bottoms out, then climbs as the model starts memorizing. The bottom of that U is the model you want. Move the complexity to find it.

Balanced

Near the bottom of the U — low test error. This is the model that generalizes best to new data.

The terms

High bias=underfit (too simple) High variance=overfit (too complex)

Code

python
import numpy as np
# Bias^2 + variance: simple models biased, complex models high-variance
def fit_predict(deg, x0):
    preds = []
    for _ in range(100):
        x=np.sort(np.random.rand(15)); y=np.sin(6*x)+0.2*np.random.randn(15)
        preds.append(np.polyval(np.polyfit(x,y,deg), x0))
    return np.var(preds)
print("variance deg1:", round(fit_predict(1,0.5),3), " deg9:", round(fit_predict(9,0.5),3))
Deep dives (the math)
30

Underfitting — deep dive

High bias, explained

The idea

An underfit model is too simple to capture the real pattern. It's wrong on the data it trained on and on new data — and no amount of extra data will save it. Press “add new data” and watch the new points miss too.

What you're seeing

Train errorhigh (0.013) Test error (new data)high A straight line can't bend to a curved truth. Both errors are high and similar — the tell-tale signature of underfitting.

The math · The math

Expected test error splits into three parts: Err = Bias² + Var + σ² Underfitting means a large Bias²: the model's average prediction is systematically off because its hypothesis class (here, straight lines) simply cannot represent the true function.

When new data arrives

The new points are predicted poorly — but notice the model was already wrong on the training points. The error is systematic, not random. So more data won't help; the line still can't bend.

How to fix it

1Add capacity: a higher-degree model, more layers, a more flexible algorithm. 2Add or engineer features that expose the real signal. 3Reduce regularization (it's forcing the model too simple).

Code

python
import numpy as np
# Underfitting: model too simple -> high train AND test error
x = np.linspace(0,1,30); y = np.sin(6*x)
c = np.polyfit(x, y, 1)                       # a line can't follow a sine
print("train MSE (linear):", round(np.mean((np.polyval(c,x)-y)**2), 3))
31

Overfitting — deep dive

High variance, explained

The idea

An overfit model fits the training points almost perfectly — including their noise. It looks brilliant in training and falls apart on anything new. Add new data and watch the wiggly curve miss points it should have nailed.

What you're seeing

Train error~0 (looks great) Test error (new data)high The big gap between train and test error is the signature of overfitting — the model memorized, it didn't learn.

The math · The math

Err = Bias² + Var + σ² Overfitting means a large Variance: tiny changes in the training set (different noise) swing the fitted curve wildly. The model is sensitive to noise it should ignore. gap = Errtest − Errtrain ≈ Var

When new data arrives

The curve was bent to pass through the exact training points, noise and all. New points carry different noise, so they land far from the curve. It can't generalize — it learned the dataset, not the pattern.

How to fix it

1Regularize (Ridge / Lasso) to penalize complexity. 2Get more training data — harder to memorize. 3Simplify the model, or stop training early. 4Use cross-validation to catch it before deployment.

Code

python
import numpy as np
x = np.linspace(0,1,12); y = np.sin(6*x)+0.1*np.random.randn(12)
c = np.polyfit(x, y, 11)                       # degree = points-1 -> interpolates noise
xt = np.linspace(0,1,100)
print("wild test values:", np.round(np.polyval(c, xt).ptp(), 1))   # huge oscillation
32

Bias–variance decomposition

Error = bias² + variance + noise

The idea

Why is there a sweet spot? Because test error is the sum of three parts. Here we train the same model on many different samples and draw every fit at once — the spread between them is variance; the distance from their average to the truth is bias. Toggle complexity to trade one for the other.

The math · The decomposition

E[(ŷ − y)²] = Bias[ŷ]² + Var[ŷ] + σ² Bias²: how far the average model is from the truth (systematic error). Variance: how much models disagree across different training sets. σ²: irreducible noise — no model can beat it.

Live estimate

Bias²0.0058 Variance0.0019 Total error0.0277

The point

On new data you pay the whole sum. Simple models lose to bias; complex ones lose to variance. The best model minimizes the total — and regularization deliberately adds a little bias to remove a lot of variance.

Code

python
import numpy as np
# E[(y - f_hat)^2] = bias^2 + variance + irreducible noise
true = 1.0; preds = np.random.normal(1.2, 0.3, 1000)   # biased + variable
bias2 = (preds.mean() - true)**2
var = preds.var()
print(f"bias^2={bias2:.3f}  variance={var:.3f}")
33

Ridge regression (L2)

Shrink the weights

The idea

Ridge fights overfitting by adding a penalty on the size of the coefficients. It shrinks every weight smoothly toward zero — never quite reaching it — trading a touch of bias for a big drop in variance. Slide λ and watch the wild fit calm down as the coefficient bars shrink together.

The math · The cost function

Ordinary least squares, plus an L2 penalty on the weights: J(β) = Σ(yᵢ − ŷᵢ)² + λ Σj βⱼ² It has a clean closed-form solution — λ just adds to the diagonal, which also keeps the matrix invertible: β = (XᵀX + λI)⁻¹ Xᵀy

The flow as λ grows

0λ = 0: plain least squares — free to overfit. ↑larger λ → every coefficient is pulled toward 0 proportionally and smoothly. ∞λ → ∞: all weights ≈ 0 → a flat line (underfit).

When new data arrives

Smaller weights mean the model reacts less to noise in any one feature, so its predictions are far more stable across new samples. That's variance reduced — at the price of a little bias. The net test error usually drops. Geometry: the L2 penalty is a circular budget (β₁² + β₂² ≤ t). Its smooth round edge rarely touches an axis — so coefficients shrink but stay non-zero.

Code

python
import numpy as np
# Ridge: add lambda*I to the normal equations (shrinks weights, stabilizes)
X = np.random.randn(50,5); y = X @ [3,0,0,1,0.] + 0.3*np.random.randn(50)
for lam in (0, 1, 20):
    b = np.linalg.solve(X.T@X + lam*np.eye(5), X.T@y)
    print(lam, np.round(b, 2))
34

Lasso regression (L1)

Zero them out

The idea

Lasso uses a different penalty — the absolute value of the weights — and that one change has a dramatic effect: it drives some coefficients to exactly zero, automatically selecting features. Slide λ and watch weights snap to zero one by one, leaving a sparse, interpretable model.

The math · The cost function

Same loss, but an L1 penalty — absolute values, not squares: J(β) = Σ(yᵢ − ŷᵢ)² + λ Σj |βⱼ| |β| has a kink at 0, so there's no closed form. It's solved by coordinate descent, whose update is soft-thresholding: S(z, γ) = sign(z)·max(|z| − γ, 0) If a weight's pull is smaller than γ, it's set to exactly 0.

Ridge vs Lasso

Ridge L2: shrinks all weights smoothly; keeps every feature. Lasso L1: zeroes weak weights; performs feature selection.

When new data arrives

A sparse model leans on only the features that truly matter, ignoring the noise-carrying ones entirely. With many irrelevant features that often generalizes better — and it's far easier to explain which inputs drive the prediction. Geometry: the L1 budget (|β₁| + |β₂| ≤ t) is a diamond. Its sharp corners sit on the axes, so the solution tends to land exactly on one → a zero coefficient.

Code

python
import numpy as np
from sklearn.linear_model import Lasso
X = np.random.randn(100,8); y = X @ [4,0,0,2,0,0,0,0.] + 0.3*np.random.randn(100)
w = Lasso(alpha=0.1).fit(X, y).coef_
print("nonzero weights:", np.where(np.abs(w)>1e-6)[0])   # L1 -> exact zeros
35

Gradient descent — the math

The update rule

The idea

Almost every model learns the same way: nudge each parameter a little in the direction that lowers the loss, and repeat. That direction is the negative gradient; the step size is the learning rate. Step through it and watch the exact numbers in the update rule.

The math · The update rule

θ ← θ − η · ∂J∂θ Here the loss is a simple bowl J(θ) = θ², so its slope is: ∂J∂θ = 2θ

Why the learning rate matters

Too small → crawls toward the minimum (slow). Too big → it overshoots and can diverge, bouncing further out each step. Try η near the top of the slider and watch it blow up.

Code

python
import numpy as np
# Gradient descent: theta <- theta - eta * dJ/dtheta
def J(t): return (t[0]-1)**2 + (t[1]+2)**2
def grad(t): return np.array([2*(t[0]-1), 2*(t[1]+2)])
t, eta = np.zeros(2), 0.1
for _ in range(100): t -= eta*grad(t)
print(np.round(t, 3))   # -> [1, -2]
36

Optimizers — SGD, momentum, Adam

Smarter descent

The idea

Plain gradient descent crawls and zig-zags down narrow valleys. Smarter optimizers add momentum (build up speed in a consistent direction) and adaptive rates (Adam — a different step size per parameter). Pick one and watch its path down the same ravine.

The math · The update rules

θ ← θ − η∇J

Why it matters

Momentum dampens the side-to-side oscillation and accelerates along the valley floor. Adam adapts each parameter's step from its gradient history — robust defaults that train deep nets far faster than plain GD.

Code

python
import numpy as np
# Momentum smooths the path; Adam adapts the step per-parameter
g = lambda w: 2*w; w_m, v = 5.0, 0.0
for _ in range(40):
    v = 0.9*v + 0.1*g(w_m); w_m -= 0.1*v     # momentum
print("momentum:", round(w_m, 3))
37

Logistic regression — the math

Sigmoid + log-loss

The idea

A linear score is squashed by the sigmoid into a probability, and the model is trained to minimize log-loss — which punishes confident wrong answers brutally. Move the boundary's steepness and position to hand-minimize the loss and feel how it responds.

The math · The model

z = w·x + b p = 11 + e−z Trained to minimize log-loss (cross-entropy): L = −1n Σ [ y log p + (1−y) log(1−p) ]

Current fit

Total log-loss0.495 Accuracy82%

Why log-loss, not accuracy?

Log-loss is smooth (so we can take gradients) and it explodes for confident mistakes: predicting p=0.99 on a true-0 point costs ≈ 4.6, while a cautious p=0.6 costs only ≈ 0.9. It rewards being calibrated, not just right.

Code

python
import numpy as np
sig = lambda z: 1/(1+np.exp(-z))
X = np.c_[np.ones(200), np.random.randn(200,2)]; y = (X[:,1]+X[:,2]>0).astype(float)
w = np.zeros(3)
for _ in range(400): w -= 0.1 * X.T @ (sig(X@w)-y) / len(y)   # grad of log-loss
print(np.round(w, 2))
38

SVM — the margin objective

Maximize the margin

The idea

An SVM picks the boundary that sits as far as possible from both classes. That “as far as possible” is a precise optimization: maximize the margin width, which equals minimizing ‖w‖². Tilt the boundary and watch the margin shrink away from its maximum.

The math · The objective

Decision function and its margin: f(x) = w·x + b margin = 2‖w‖ Widest margin ⇔ smallest weights, subject to every point staying on its side: min 12‖w‖² s.t. yᵢ(w·xᵢ+b) ≥ 1

This boundary

Margin width— vs maximum—

Why the widest margin?

A fat margin is a buffer: new points near the boundary are more likely to still fall on the correct side. Only the closest points (the support vectors, where the constraint = 1) determine it — the rest are irrelevant.

Code

python
import numpy as np
# Max-margin = minimize ||w|| subject to y(w.x+b) >= 1; hinge for soft margin
hinge = lambda y, s: np.maximum(0, 1 - y*s)
w, b = np.array([1.,-1.]), 0.0
X = np.array([[2,0.],[0,2.],[-1,-1.]]); y = np.array([1,1,-1.])
print("hinge losses:", hinge(y, X@w + b))
39

Information gain — the math

How a split is chosen

The idea

A decision tree picks the split that most reduces disorder. It measures disorder with entropy, then scores a split by its information gain — how much entropy drops from parent to weighted children. Drag the split line and watch the gain rise and fall; the tree would pick its peak.

The math · The math

Entropy of a node (fraction p of class A): H = −p log₂p − (1−p) log₂(1−p) Information gain of a split: IG = Hparent − Σ |child||parent| Hchild

This split

H(parent)1.000 H(left) · H(right)0.41 · 0.41 Information gain0.586

What the tree does

It tries every feature and threshold, computes IG for each, and greedily takes the highest — then repeats inside each child. (Gini impurity is a near-identical alternative that's cheaper to compute.)

Code

python
import numpy as np
def entropy(y):
    p = np.bincount(y, minlength=2)/len(y); p = p[p>0]
    return -(p*np.log2(p)).sum()
parent = np.array([0,0,1,1,1,1])
left, right = parent[:2], parent[2:]
ig = entropy(parent) - (len(left)/6*entropy(left) + len(right)/6*entropy(right))
print("information gain:", round(ig, 3))
40

k-Means — the objective

Minimizing inertia

The idea

k-Means isn't magic — it's coordinate descent on one number: inertia, the total squared distance from points to their centroids. Each step can only lower it, which is why it always converges. Step through assign → update and watch the objective fall.

The math · The objective (inertia)

J = Σk Σx∈Cₖ ‖x − μₖ‖² AAssign: each point joins its nearest centroid — c(x) = argminₖ ‖x − μₖ‖². UUpdate: each centroid moves to its members' mean — μₖ = mean(Cₖ).

Inertia J

current8.202 last change—

Why it converges (to a local min)

Assign lowers J (each point picks its closest centroid); Update lowers J (the mean is the point that minimizes squared distance). Both steps only decrease J, so it must settle — though possibly at a local minimum, which is why we reseed and keep the best.

Code

python
import numpy as np
# k-Means minimizes within-cluster squared distance (inertia)
X = np.vstack([np.random.randn(50,2), np.random.randn(50,2)+5])
C = X[np.random.choice(len(X),2,replace=False)]
for _ in range(10):
    lab = ((X[:,None]-C[None])**2).sum(-1).argmin(1)
    C = np.array([X[lab==k].mean(0) for k in range(2)])
inertia = ((X - C[lab])**2).sum()
print("inertia:", round(inertia, 1))
41

Backpropagation — chain rule

Gradients flow back

The idea

Training a network means finding ∂Loss/∂(every weight). Backprop does it efficiently with the chain rule: run a forward pass, then multiply local derivatives backward through the layers. Step through a tiny net and watch each gradient assemble from the chain.

The math · Forward

z = w·x+b a = σ(z) L = ½(a−y)² ∂L∂w = ∂L∂a · ∂a∂z · ∂z∂w with σ′(z) = σ(z)(1−σ(z)) and ∂z/∂w = the input into that weight.

The key idea

Each node only needs its local derivative; backprop chains them by multiplication, reusing the gradient flowing in from the right. That reuse is why training huge networks is even possible — one backward pass gets every gradient.

Code

python
import numpy as np
# Backprop = chain rule applied layer by layer, reverse order
x, w1, w2 = 1.0, 0.5, -0.3
h = np.tanh(w1*x); y = w2*h; L = 0.5*(y-1)**2
dy = y-1; dw2 = dy*h; dh = dy*w2; dw1 = dh*(1-h**2)*x
print("dL/dw1:", round(dw1,4), " dL/dw2:", round(dw2,4))
42

Cost functions — the core idea

What learning minimizes

The idea

This is the heart of machine learning: a cost function (or loss) is a single number measuring how wrong the model is. “Learning” is nothing more than adjusting parameters to make that number small. Different tasks use different costs — pick the wrong one and you optimize the wrong goal.

The math · The learning objective

minimize 1n Σi Loss(ŷᵢ, yᵢ) L = (ŷ − y)²

This prediction

true value y0.70 your prediction0.30 loss0.160

Why it matters

Squared error punishes big misses quadratically and is smooth, so gradient descent can follow it. The minimum of this curve is exactly the target value.

Code

python
import numpy as np
# A cost function scores predictions; training minimizes it
y, yhat = np.array([1.,0.,1.]), np.array([0.8,0.3,0.6])
mse = np.mean((y-yhat)**2)
ce  = -np.mean(y*np.log(yhat)+(1-y)*np.log(1-yhat))
print(f"MSE={mse:.3f}  cross-entropy={ce:.3f}")
43

Decision tree — the algorithm

Recursive greedy splits

The idea

A tree is built top-down: at each node, try every possible split, keep the one that makes the children purest, then recurse. Grow the depth and watch the space carve into boxes on the left while the actual tree — split conditions and Gini scores — assembles on the right.

The math · The split criterion

Impurity of a node (Gini): G = 1 − Σk pₖ² Pick the split (feature j, threshold t) that minimizes the weighted child impurity: minj,t |L|nGL + |R|nGR

How it runs

1At a node, scan every feature & threshold; score each by child impurity. 2Keep the best split; send points left/right. 3Recurse on each child until pure, max depth, or too few points. 4Predict: walk a point down to a leaf → majority class.

The catch

It's greedy — each split is locally best, not globally optimal. And left unbounded it grows until every leaf is pure (G=0): perfect on training, overfit on new data. Depth, min-samples, and pruning hold it back.

Code

python
import numpy as np
# Greedy tree: pick the split that most reduces impurity, recurse
def gini(y): p=np.bincount(y,minlength=2)/len(y); return 1-(p**2).sum()
x = np.array([1,2,3,4,5,6.]); y = np.array([0,0,0,1,1,1])
gains = {t: gini(y) - (np.mean(x<=t)*gini(y[x<=t])+np.mean(x>t)*gini(y[x>t]))
         for t in (x[:-1]+x[1:])/2}
print("best split:", max(gains, key=gains.get))
44

Linear regression — least squares

The normal equations

The idea

“Least squares” is literal: the best line minimizes the total area of the squares built on each residual. Adjust the slope and intercept and watch the squares — and the cost — grow and shrink. There's a one-shot formula that lands exactly at the minimum.

The math · The math

ŷ = β₀ + β₁x Cost = mean squared error: MSE = 1n Σ(yᵢ − ŷᵢ)² Setting the gradient to zero gives a closed form — the normal equations: β = (XᵀX)⁻¹ Xᵀy

Current fit

Your MSE0.0034 Best possible (OLS)0.0015

Why squares?

Squaring makes the cost smooth and convex (one global minimum), and punishes big errors more than small ones. That convexity is why a single formula — no iteration needed — finds the optimum.

Code

python
import numpy as np
# Least squares: minimize ||y - X beta||^2 -> normal equations
X = np.c_[np.ones(50), np.random.randn(50)]; y = 2 + 3*X[:,1] + 0.2*np.random.randn(50)
beta = np.linalg.solve(X.T@X, X.T@y)
print("intercept, slope:", np.round(beta, 2))
45

PCA — eigenvectors

Covariance & variance

The idea

PCA isn't a black box: it's the eigen-decomposition of the data's covariance matrix. The eigenvectors point along the directions of greatest spread; the eigenvalues say how much variance lives along each. Click to add points and watch the covariance, axes, and variance split update live.

The math · The math

Center the data, then form the covariance matrix: Σ = 1n XᵀX = [ … ] Its eigenvectors are the principal components: Σvᵢ = λᵢvᵢ Each λᵢ is the variance captured by component vᵢ.

Eigenvalues → variance

λ₁ (PC1)— λ₂ (PC2)— variance on PC1—

Dimensionality reduction

Keep the top-k eigenvectors (largest λ) and project onto them: you compress the data while preserving the most variance. Drop PC2 here and you've gone 2-D → 1-D with minimal loss.

Code

python
import numpy as np
# PCA: top eigenvectors of the covariance = directions of max variance
X = np.random.randn(300,2) @ [[3,1],[0,1.]]; Xc = X - X.mean(0)
vals, vecs = np.linalg.eigh(np.cov(Xc.T))
order = vals.argsort()[::-1]
print("variance explained:", np.round(vals[order]/vals.sum(), 3))
46

Naive Bayes — the posterior

Prior × likelihood

The idea

Naive Bayes applies Bayes' theorem directly, with one bold shortcut: it assumes every feature is independent given the class. That “naive” assumption turns a hard joint probability into a simple product — fast, and shockingly effective. Toggle words and watch the posterior assemble term by term.

The math · The math

P(c|x) = P(x|c) P(c)P(x) The naive assumption — features independent given the class — factorizes the likelihood: P(x|c) = ∏j P(xⱼ|c) Predict the class maximizing prior × product of likelihoods (done in log-space to avoid underflow).

Posterior

P(spam | words)40.0%

Why “naive” still works

Words obviously aren't independent (“free” and “winner” co-occur), so the probabilities are miscalibrated — but the ranking of classes is usually still right, which is all you need to classify. Cheap, scalable, a great baseline.

Code

python
import numpy as np
# Naive Bayes posterior ~ prior * product of per-feature likelihoods
prior = np.array([0.5, 0.5])
# likelihoods P(x_i | class) assumed independent -> multiply (sum in log space)
loglik = np.array([[-1.2,-0.8], [-0.5,-2.0]]).sum(0)   # over features, per class
post = np.exp(np.log(prior)+loglik); post/=post.sum()
print("posterior:", np.round(post, 3))
47

Confusion matrix — the metrics

Precision, recall, F1

The idea

Every classification metric is built from four counts. Drag the threshold and watch the confusion matrix shift — and every metric recompute from its formula. Precision and recall pull against each other; F1 balances them.

The math · The formulas

Accuracy: TP+TNall Precision: TPTP+FP — of those flagged, how many were right Recall: TPTP+FN — of the real positives, how many we caught F1: 2·P·RP+R — harmonic mean

Live metrics

Accuracy82% Precision81% Recall83% F10.82

The tension

Lower the threshold → catch more positives (recall ↑) but more false alarms (precision ↓). Raise it → the reverse. Which you favor depends on the cost of a miss vs a false alarm.

Code

python
import numpy as np
tp, fp, fn, tn = 40, 10, 5, 45
precision = tp/(tp+fp); recall = tp/(tp+fn)
f1 = 2*precision*recall/(precision+recall)
print(f"P={precision:.2f} R={recall:.2f} F1={f1:.2f}")
48

Softmax & cross-entropy

Scores → probabilities

The idea

For multi-class problems, a network outputs raw scores (logits) — softmax squashes them into a probability distribution that sums to 1, and cross-entropy scores how far that is from the true class. Drag the logits and watch probability mass flow between classes.

The math · The math

pᵢ = ezᵢΣj ezⱼ Loss = categorical cross-entropy (only the true class's term survives): L = −Σi yᵢ log pᵢ = −log ptrue

Output

loss0.266

Why softmax?

Exponentiating makes everything positive; dividing normalizes to a valid distribution. It's the multi-class generalization of the sigmoid — and its gradient with cross-entropy simplifies beautifully to just (p − y).

Code

python
import numpy as np
def softmax(z): e=np.exp(z-z.max()); return e/e.sum()
z = np.array([2.0, 1.0, 0.1])
p = softmax(z)
ce = -np.log(p[0])                       # cross-entropy if true class is 0
print(np.round(p,3), "CE:", round(ce,3))
49

k-NN & the curse of dimensions

When distance breaks

The idea

k-NN trusts that “nearby” means “similar.” That holds in 2-D — but in high dimensions, every point becomes almost equidistant from every other, so “nearest neighbor” loses meaning. Slide the dimension count and watch the nearest and farthest distances collapse together.

The math · Distance metrics

Euclidean: d = √Σ(xᵢ−qᵢ)² Manhattan: d = Σ|xᵢ−qᵢ|

Distance concentration

nearest0.008 farthest0.670 ratio near/far0.012

The curse

As d grows, that ratio races toward 1 — the nearest and farthest points are nearly the same distance away. “Closest” becomes meaningless, so k-NN (and any distance method) degrades. The fix: reduce dimensions first (PCA) or pick few, good features.

Code

python
import numpy as np
# k-NN: predict by majority of nearest neighbors; curse of dimensionality
X = np.random.randn(200, 2); y = (X[:,0]>0).astype(int)
q = np.array([0.5, 0.0])
d = np.linalg.norm(X - q, axis=1); nn = d.argsort()[:5]
print("predicted class:", np.bincount(y[nn]).argmax())
50

ROC / AUC — the integral

Area under the curve

The idea

Sweep the threshold from high to low and trace true-positive rate against false-positive rate: that's the ROC. The shaded area under it is the AUC — and it has a lovely meaning: the probability that a random positive scores higher than a random negative. Slide separability and watch the area grow.

The math · The math

TPR = TPTP+FN FPR = FPFP+TN AUC = ∫ TPR d(FPR) Computed by the trapezoid rule — and equal to P( score(+) > score(−) ).

Score

AUC (shaded area)0.991

Why AUC?

It's threshold-independent — it judges how well the model ranks positives above negatives across all thresholds at once. 1.0 = perfect ranking, 0.5 = random (the diagonal).

Code

python
import numpy as np
from sklearn.metrics import roc_auc_score
y = np.r_[np.zeros(100), np.ones(100)]
s = np.r_[np.random.rand(100), np.random.rand(100)+0.6]
print("AUC =", round(roc_auc_score(y, s), 3))   # P(rank positive > negative)
51

The kernel trick

Lift to separate

The idea

Some data can't be split by any straight line — like one class ringed around another. The trick: lift it into a higher dimension where a flat boundary does separate it. Here the rings become separable the moment we add a “distance from center” axis. And kernels compute this without ever building the new coordinates.

The math · The math

Map inputs up with φ, e.g. add a radius feature: φ(x,y) = (x, y, x²+y²) Algorithms only need inner products, so we compute a kernel directly — never the lifted coordinates: K(x,x′) = φ(x)·φ(x′) RBF: e−γ‖x−x′‖²

Why it's a “trick”

The lifted space can be huge (even infinite for the RBF kernel), so computing φ would be impossible. The kernel sidesteps that entirely — you get the power of a high-dimensional boundary at the cost of a simple similarity function.

Code

python
import numpy as np
# Kernel trick: compute inner products in feature space implicitly
a, b = np.array([1.,2.]), np.array([2.,1.])
poly_kernel = (a @ b + 1)**2                 # = phi(a).phi(b) without phi
rbf = np.exp(-0.5*np.sum((a-b)**2))
print("poly:", poly_kernel, " rbf:", round(rbf,3))
52

Maximum likelihood

Where losses come from

The idea

Why squared error? Why log-loss? They aren't arbitrary — they fall out of maximum likelihood: choose the parameters that make the observed data most probable. Slide the mean of a Gaussian and watch the likelihood peak exactly at the sample mean — which is the same as minimizing squared error.

The math · The principle

L(θ) = ∏i p(xᵢ | θ) Maximize the log-likelihood (sums are nicer than products): θ̂ = argmaxθ Σi log p(xᵢ|θ) For Gaussian noise, maximizing this is exactly minimizing Σ(xᵢ−μ)² — that's where MSE comes from. Bernoulli data → log-loss.

Fit

log-likelihood1.09 sample mean (MLE)0.511

The big idea

Pick a probabilistic story for your data, write down its likelihood, and the “right” loss function appears automatically. Most ML losses are negative log-likelihoods in disguise.

Code

python
import numpy as np
# MLE: parameters that maximize likelihood = minimize negative log-likelihood
data = np.random.normal(4.0, 2.0, 500)
mu_hat, sigma_hat = data.mean(), data.std()   # closed-form MLE for a Gaussian
print(round(mu_hat,2), round(sigma_hat,2))
53

Convolution arithmetic

Stride, padding, size

The idea

A CNN layer's output size is fully determined by four numbers: input size, kernel size, stride, and padding. Get them wrong and your tensor shapes won't line up. Tune the dials and watch the kernel step across the (padded) input while the output grid resizes to match the formula.

The math · The output-size formula

out = ⌊ N + 2P − KS ⌋ + 1 = ⌊(7+0−3) / 1⌋ + 1 = 5

Rules of thumb

“Same” padding (P=⌊K/2⌋, S=1) keeps the size unchanged. Stride > 1 downsamples. Each extra filter adds an output channel — depth — so a layer's output is out × out × (#filters).

Code

python
# Conv output size: (W - K + 2P)/S + 1
def out(W,K,P,S): return (W-K+2*P)//S + 1
print(out(28,3,1,1), out(28,3,0,2))   # 28 (same), 13 (downsampled)
Core algorithms
54

Linear regression playground

Click to add data

The idea

The simplest model: fit a straight line that minimizes the squared error to the points. Click anywhere on the plot to add a data point and watch the best-fit line and the error update instantly. The grey sticks are the residuals it's trying to shrink.

Live fit

Equationy = 0.55x + 0.25 MSE (error)0.0007 Points12

Code

python
import numpy as np
X = np.c_[np.ones(60), np.random.randn(60)]; y = 1 + 2*X[:,1] + 0.3*np.random.randn(60)
beta = np.linalg.lstsq(X, y, rcond=None)[0]
print("intercept, slope:", np.round(beta, 2))
55

Polynomial regression

Fit the curve

The idea

A straight line can't follow a curve — but a polynomial can. By adding powers of x (x², x³, …) as features, the same linear machinery fits curved trends. The degree is a flexibility dial: too low underfits, too high overfits with wild wiggles. Slide it and watch.

The math · The model

ŷ = w₀ + w₁x + w₂x² + … + wdxd Still linear in the weights — so it's solved exactly like linear regression, just on expanded features. train error0.0016

The flexibility trap

Degree 1 is a line (underfits the curve); a moderate degree captures the real shape; a very high degree threads every point — including the noise — and swings violently between them. That high-degree wiggle is overfitting made visible.

Code

python
import numpy as np
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
x = np.linspace(-2,2,40).reshape(-1,1); y = (x.ravel()**2 + 0.2*np.random.randn(40))
Xp = PolynomialFeatures(2).fit_transform(x)
print("R^2:", round(LinearRegression().fit(Xp, y).score(Xp, y), 3))
56

Elastic Net

L1 + L2 blend

The idea

Ridge (L2) shrinks all coefficients smoothly and handles correlated features gracefully; Lasso (L1) drives some exactly to zero for automatic feature selection. Elastic Net blends both, so you get sparsity and stability. Dial the mix and the regularization strength, and watch the coefficients respond.

The math · The penalty

min ‖y−Xw‖² + λ( α‖w‖₁ + (1−α)‖w‖² ) non-zero coefficients4 / 8

Why blend

Pure Lasso struggles when features are correlated — it arbitrarily keeps one and zeros its twins. Adding a little L2 stabilizes that, so correlated features are shrunk together. Elastic Net is the safe default when you want selection but have messy, correlated predictors.

Code

python
import numpy as np
from sklearn.linear_model import ElasticNet
X = np.random.randn(100,10); y = X @ np.r_[[3,2],np.zeros(8)] + 0.3*np.random.randn(100)
w = ElasticNet(alpha=0.1, l1_ratio=0.5).fit(X, y).coef_   # mixes L1 + L2
print(np.round(w, 2))
57

Logistic regression

Move the boundary

The idea

Despite the name, it's a classifier. It draws a straight decision boundary, then uses the sigmoid to turn distance-from-the-line into a probability. The background shows that probability gradient. Rotate and shift the boundary and watch accuracy respond.

Result

Accuracy— Boundaryprob = 0.5

Code

python
import numpy as np
from sklearn.linear_model import LogisticRegression
X = np.random.randn(200,2); y = (X[:,0]+X[:,1] > 0).astype(int)
clf = LogisticRegression().fit(X, y)
print("accuracy:", clf.score(X, y))
58

The perceptron

The first classifier

The idea

The 1958 ancestor of every neural network: a single linear unit that learns by correction. Show it a point; if it misclassifies, nudge the weights toward the right answer; repeat. On separable data the boundary rotates into place and the updates stop. Step through the learning.

The math · The update rule

For each point, predict sign(w·x). If wrong, correct toward it: w ← w + η · y · x misclassified10 updates made0

Power and limits

It provably converges if the data is linearly separable — but it can't solve problems that aren't (famously XOR). Stacking many of these units with non-linear activations is exactly what gave us multi-layer neural networks.

Code

python
import numpy as np
# Perceptron: update weights only on misclassified points
X = np.c_[np.random.randn(100,2), np.ones(100)]; y = np.sign(X[:,0]+X[:,1])
w = np.zeros(3)
for _ in range(20):
    for xi, yi in zip(X, y):
        if yi*(w@xi) <= 0: w += yi*xi      # the perceptron rule
print("train acc:", np.mean(np.sign(X@w)==y))
59

k-Nearest Neighbors

Move the point · change k

The idea

The most intuitive classifier: to label a new point, look at its k closest neighbors and take a majority vote. Move your mouse over the plot to place the query point — its nearest neighbors light up and it gets colored by their vote. Change k and watch the decision flip.

Hover the plot →

Move over the plot to drop a query point. Its k nearest neighbors vote on its class.

Code

python
import numpy as np
from sklearn.neighbors import KNeighborsClassifier
X = np.random.randn(200,2); y = (X[:,0]>0).astype(int)
print("acc:", KNeighborsClassifier(5).fit(X,y).score(X,y))   # lazy: no training
60

k-NN regression

Average the neighbors

The idea

The same nearest-neighbor idea, but predicting a number: for any input, find its k closest training points and average their values. No training, no equation — just lookup and average. k is a smoothness dial: k=1 is a jagged step pattern, large k is a flat, over-smoothed line.

The math · The prediction

ŷ(x) = 1k Σi∈Nk(x) yᵢ Just the mean of the k nearest training targets (optionally distance-weighted).

The bias-variance dial

Small k = low bias, high variance (the curve chases noise). Large k = high bias, low variance (it flattens toward the global mean). It's a clean, instance-based illustration of the same trade-off every model faces — and it makes no assumptions about the function's shape.

Code

python
import numpy as np
# k-NN regression: predict the average target of the k nearest points
X = np.sort(np.random.rand(50)); y = np.sin(6*X)
q = 0.5; k = 5
nn = np.argsort(np.abs(X-q))[:k]
print("prediction at 0.5:", round(y[nn].mean(), 3))
61

Decision tree

Grow the depth

The idea

A tree asks a sequence of yes/no questions on single features, carving the space into axis-aligned boxes. More depth = more questions = a finer fit. Increase the depth and watch the boundary go from blunt to jagged — and start overfitting.

Shallow tree

Few questions, blocky regions. May underfit — but it generalizes and is easy to read. Train accuracy—

Code

python
import numpy as np
from sklearn.tree import DecisionTreeClassifier
X = np.random.randn(300,2); y = (X[:,0]*X[:,1] > 0).astype(int)
for d in (1, 3, 10):
    print(f"depth {d}: train acc {DecisionTreeClassifier(max_depth=d).fit(X,y).score(X,y):.2f}")
62

Regression trees

Predict by region

The idea

A decision tree can predict a number, not just a class: it splits the input into regions and predicts the average of the training points in each — a staircase, not a smooth curve. Each split is chosen to cut the variance most. Grow the depth and watch the steps multiply.

The math · How it splits

At each node, pick the threshold that most reduces the sum of squared error; predict each leaf's mean: split to minimize Σ (yᵢ − ȳregion)²

Code

python
import numpy as np
from sklearn.tree import DecisionTreeRegressor
X = np.sort(np.random.rand(60,1),0); y = np.sin(6*X).ravel()
m = DecisionTreeRegressor(max_depth=3).fit(X, y)   # piecewise-constant fit
print("MSE:", round(np.mean((m.predict(X)-y)**2), 4))
63

Entropy & impurity

What a split optimizes

The idea

How does a tree pick its questions? It measures how “mixed” a group is — its impurity. A pure group (all one class) scores 0; a 50/50 mix scores highest. Every split tries to produce purer children. Drag the class mix and watch entropy and Gini move.

Impurity

Entropy1.000 Gini0.500

Code

python
import numpy as np
def gini(y): p=np.bincount(y)/len(y); return 1-(p**2).sum()
def entropy(y): p=np.bincount(y)/len(y); p=p[p>0]; return -(p*np.log2(p)).sum()
y = np.array([0,0,1,1,1,1])
print("gini:", round(gini(y),3), " entropy:", round(entropy(y),3))
64

Support vector margin

Drag a point

The idea

An SVM doesn't just separate the classes — it finds the boundary with the widest margin between them. Only the closest points (the support vectors) matter; the rest could move freely. Drag any point and watch which ones actually decide the boundary.

How it works

The boundary sits halfway between the two closest opposing points. Those points are the support vectors (ringed). Drag points around to see them change.

Code

python
import numpy as np
from sklearn.svm import SVC
X = np.r_[np.random.randn(50,2)-2, np.random.randn(50,2)+2]; y = np.r_[np.zeros(50), np.ones(50)]
clf = SVC(kernel='linear', C=1.0).fit(X, y)
print("support vectors:", clf.n_support_)   # margin set by the hardest points
65

Support vector regression

Fit inside a tube

The idea

SVR flips the support-vector idea for regression: instead of maximizing a margin between classes, it fits a function and ignores any error that stays within an ε-tube around it. Only points outside the tube — the support vectors — incur a penalty and shape the fit. Widen the tube and watch errors get forgiven.

The math · ε-insensitive loss

Errors smaller than ε cost nothing; only the excess is penalized: loss = max(0, |y − ŷ| − ε) support vectors (outside tube)0

Why the tube

It buys robustness and sparsity: the model doesn't chase every wiggle of noise, and the final fit depends only on the boundary points. A wider ε means a simpler, flatter fit with fewer support vectors; a narrow ε hugs the data more tightly.

Code

python
import numpy as np
from sklearn.svm import SVR
X = np.sort(np.random.rand(60,1),0); y = np.sin(6*X).ravel()
m = SVR(kernel='rbf', C=10, epsilon=0.05).fit(X, y)   # epsilon-insensitive tube
print("R^2:", round(m.score(X, y), 3))
66

Naive Bayes

Toggle the evidence

The idea

A fast probabilistic classifier built on Bayes' theorem. It starts with a prior belief, then multiplies in the evidence from each feature (“naively” assuming they're independent) to land on a posterior probability. Toggle which words appear in an email and watch P(spam) update.

P(spam | words)

Probability40.0% Verdict✅ Not spam

Code

python
import numpy as np
from sklearn.naive_bayes import GaussianNB
X = np.r_[np.random.randn(100,2), np.random.randn(100,2)+3]; y = np.r_[np.zeros(100),np.ones(100)]
print("acc:", GaussianNB().fit(X, y).score(X, y))
67

One-vs-rest multiclass

Binary → many classes

The idea

A logistic regression only separates two classes — so how do you handle three? Train one binary classifier per class (“this class vs everything else”), then for any point pick the class whose classifier is most confident. Toggle between the individual binary views and the combined decision.

The math · The math

Train K classifiers, each giving P(class k | x). Predict the winner: ŷ = argmaxk hk(x)

vs softmax

One-vs-rest wraps any binary learner into a multiclass one — simple and general, but the K classifiers are trained independently and their scores aren't jointly normalized. Softmax regression instead models all classes together in one shot (probabilities that sum to 1).

Code

python
import numpy as np
from sklearn.multiclass import OneVsRestClassifier
from sklearn.linear_model import LogisticRegression
X = np.random.randn(300,2); y = np.random.randint(0,3,300)   # 3 classes
clf = OneVsRestClassifier(LogisticRegression()).fit(X, y)    # one binary clf per class
print("classes:", clf.classes_)
Distance & similarity
68

Distance metrics

Euclidean, Manhattan, …

The idea

Almost every clustering and nearest-neighbor method rests on one question: how far apart are two points? The Minkowski family answers it with a dial, p. At p=2 you get familiar straight-line Euclidean; at p=1, city-block Manhattan; as p→∞, Chebyshev (the largest single-axis gap). Slide p and watch the "unit circle" — and which point is nearest — change shape.

The math · The formula

d = ( Σi |xᵢ−yᵢ|p )1/p p = 1: Manhattan / taxicab — sum of axis gaps p = 2: Euclidean — straight line p → ∞: Chebyshev — the single largest gap

Nearest to the query

closest pointpoint B (d=0.56)

Why it matters

The "unit circle" (all points exactly distance 1 away) is a diamond at p=1, a circle at p=2, a square at p=∞. Because the shape differs, the nearest neighbor can flip depending on the metric — so the choice is a real modeling decision, not a detail.

Code

python
import numpy as np
a, b = np.array([1.,2.,3.]), np.array([4.,0.,3.])
print("euclidean:", round(np.linalg.norm(a-b),2))
print("manhattan:", np.abs(a-b).sum())
print("chebyshev:", np.abs(a-b).max())
69

Cosine similarity

Angle, not magnitude

The idea

Sometimes direction matters more than length. Cosine similarity measures the angle between two vectors, ignoring how long they are — so a document and its doubled copy are identical (similarity 1), even though they're far apart by Euclidean distance. It's the default for text and embeddings. Rotate and stretch a vector to feel the difference.

The math · The measure

cos(θ) = a · b‖a‖ ‖b‖ cosine similarity0.766 Euclidean distance0.498

The key insight

Change B's length and the cosine similarity doesn't move — only the angle matters. Euclidean distance, by contrast, changes a lot. In high-dimensional, sparse spaces like word counts, that magnitude-invariance is exactly what you want: long and short documents on the same topic should count as similar.

Code

python
import numpy as np
a, b = np.array([1.,2.,3.]), np.array([2.,4.,6.])
cos = a@b/(np.linalg.norm(a)*np.linalg.norm(b))
print("cosine similarity:", round(cos, 3))   # 1.0 -> same direction (ignores magnitude)
70

Mahalanobis distance

Distance, shape-aware

The idea

Euclidean distance treats every direction the same — but real data has shape. Mahalanobis distance stretches space by the data's own covariance, so it measures distance in "standard deviations along the cloud's axes." A point can sit close to the center by Euclidean reckoning yet be a glaring outlier in Mahalanobis terms. Move the query and compare.

The math · The formula

d = √( (x−μ)ᵀ Σ⁻¹ (x−μ) ) Euclidean (raw)0.81 Mahalanobis2.93

Where it's used

The Σ⁻¹ "whitens" the data — equidistance contours become ellipses aligned with the cloud, not circles. It's the natural distance for correlated features and the backbone of multivariate anomaly detection: how unusual is this point given the data's normal shape?

Code

python
import numpy as np
# Mahalanobis: distance that accounts for feature correlation/scale
X = np.random.randn(500,2) @ [[2,1.],[0,1]]
cov_inv = np.linalg.inv(np.cov(X.T)); mu = X.mean(0)
d = lambda x: np.sqrt((x-mu) @ cov_inv @ (x-mu))
print("distance of an outlier:", round(d(np.array([6.,6.])), 2))
71

Hamming & Jaccard

Categories & sets

The idea

Euclidean distance is meaningless for non-numeric data — there's no "straight line" between two words or two shopping baskets. Hamming counts how many positions differ in equal-length strings or binary vectors; Jaccard measures set overlap as intersection over union. Flip bits and swap items to see them move.

The math · The measures

Hamming = #{ i : aᵢ ≠ bᵢ } Jaccard = 1 − |A ∩ B||A ∪ B|

Distances

Hamming (bits differ)3 / 8 Jaccard distance0.57

Where each fits

Hamming: fixed-length codes, DNA, one-hot vectors, error-correcting codes. Jaccard: sets where presence matters but order/count doesn't — market baskets, document keyword sets, recommender overlaps. For variable-length text, edit (Levenshtein) distance counts insert/delete/substitute operations.

Code

python
import numpy as np
a = np.array([1,0,1,1]); b = np.array([1,1,1,0])
hamming = (a != b).mean()
jaccard = (a & b).sum() / (a | b).sum()
print("hamming:", hamming, " jaccard:", round(jaccard,3))
Clustering
72

k-Means clustering

Step it to convergence

The idea

Unsupervised: no labels — the algorithm finds groups itself by repeating two steps. Assign each point to its nearest centroid, then move each centroid to the mean of its points. Repeat until nothing changes. Step through it and watch the clusters snap into place.

Ready

Press Step to assign points to the nearest centroid.

Code

python
import numpy as np
from sklearn.cluster import KMeans
X = np.vstack([np.random.randn(100,2), np.random.randn(100,2)+5])
km = KMeans(2, n_init=10).fit(X)
print("centroids:\n", np.round(km.cluster_centers_, 1))
73

DBSCAN (density)

Tune the radius

The idea

Unlike k-means, DBSCAN doesn't need you to pick the number of clusters — it grows them from dense regions and labels lonely points as noise. It finds arbitrary shapes k-means can't. Tune the neighborhood radius ε and watch clusters merge, split, and outliers fall away.

Result

Clusters found— Noise points—

Code

python
import numpy as np
from sklearn.cluster import DBSCAN
X = np.vstack([np.random.randn(100,2), np.random.randn(100,2)+6, [[20,20]]])
labels = DBSCAN(eps=0.8, min_samples=5).fit_predict(X)
print("clusters:", len(set(labels))-(1 if -1 in labels else 0), " noise pts:", (labels==-1).sum())
74

Hierarchical clustering

Build the dendrogram

The idea

Instead of fixing k upfront, agglomerative clustering starts with every point as its own cluster and repeatedly merges the two closest — building a tree (a dendrogram) of how things group at every scale. Step through the merges, then cut the tree at any height to get your clusters.

Every point alone

Start: each point is its own cluster. Each step joins the two nearest into one — watch the dendrogram grow upward. Clusters left12

Code

python
import numpy as np
from scipy.cluster.hierarchy import linkage, fcluster
X = np.vstack([np.random.randn(20,2), np.random.randn(20,2)+5])
Z = linkage(X, method='ward')              # merge closest clusters bottom-up
labels = fcluster(Z, t=2, criterion='maxclust')
print("cluster sizes:", np.bincount(labels)[1:])
75

Gaussian mixtures (EM)

Soft, elliptical clusters

The idea

k-Means makes hard, spherical assignments. A Gaussian mixture is softer and richer: each cluster is a Gaussian (any shape), and every point gets a probability of belonging to each. The EM algorithm alternates two steps — guess responsibilities, then refit the Gaussians — until it settles. Step through it.

Log-likelihood

should keep rising—

vs k-means

k-means is the special case of GMM with hard assignments and spherical, equal-size clusters. GMM captures elliptical, overlapping clusters and gives calibrated membership probabilities — useful when clusters genuinely blend.

Code

python
import numpy as np
from sklearn.mixture import GaussianMixture
X = np.vstack([np.random.randn(150,2), np.random.randn(150,2)+4])
gmm = GaussianMixture(2).fit(X)            # soft clusters via EM
print("soft memberships:\n", np.round(gmm.predict_proba(X[:2]), 2))
76

Choosing k

Elbow & silhouette

The idea

k-Means needs you to pick the number of clusters — but how many is right? Two trusted guides: the elbow in the within-cluster error (it drops fast, then flattens once you've captured the real groups) and the silhouette score (how well-separated the clusters are), which peaks at the natural k. Slide k and read both.

Readings

inertia (within-cluster SSE)0.62 silhouette score0.46

Reading the curves

Inertia always falls as k rises (more clusters fit tighter) — so you don't want the minimum, you want the elbow where the gains flatten. Silhouette gives a cleaner signal: it's highest at the k that best balances tight clusters with wide gaps. Here both point to the same natural answer.

Code

python
import numpy as np
from sklearn.cluster import KMeans
X = np.vstack([np.random.randn(100,2)+c for c in ([0,0],[5,5],[0,5])])
inertias = [KMeans(k, n_init=10).fit(X).inertia_ for k in range(1,7)]
print("inertia by k:", np.round(inertias, 0))   # elbow ~ true k
77

k-medoids (PAM)

Real points as centers

The idea

k-Means centers are averages — fictional points that get yanked around by outliers, and only make sense for Euclidean distance. k-medoids uses actual data points as centers (medoids) and minimizes total distance under any metric. That makes it robust to outliers and usable with the cosine, Hamming, or Mahalanobis distances we just met. Toggle the outlier.

The math · The objective

min Σi d(xᵢ, medoid(xᵢ)) The medoid is the cluster member with the smallest total distance to the others — a real, representative point.

Why it's useful

Add an outlier and a k-means centroid drifts toward it; the medoid stays put on a genuinely central point. And because it only needs a distance function — never coordinates or averages — k-medoids clusters things you can't average, like text or categorical records.

Code

python
import numpy as np
# k-medoids: cluster centers are actual data points (robust to outliers)
X = np.vstack([np.random.randn(30,2), np.random.randn(30,2)+5])
medoids = X[[0, 35]]
for _ in range(10):
    lab = ((X[:,None]-medoids[None])**2).sum(-1).argmin(1)
    for k in range(2):
        pts = X[lab==k]; costs = ((pts[:,None]-pts[None])**2).sum(-1).sum(1)
        medoids[k] = pts[costs.argmin()]
print("medoids:\n", np.round(medoids, 1))
78

Mean-shift

Climb to the peaks

The idea

Unlike k-means, mean-shift doesn't need you to pick the number of clusters. Treat the points as a density landscape; each point repeatedly hops to the average of its neighbors — climbing uphill toward the nearest peak. Points that arrive at the same peak form a cluster. The number of clusters simply emerges from the bandwidth. Step the climb.

The math · The shift

Move each point to the mean of the neighbors within the bandwidth, and repeat: x ← mean{ xⱼ : ‖xⱼ−x‖ < h } clusters found28

Bandwidth is everything

A small bandwidth finds many fine peaks; a large one merges them into a few. So instead of choosing k directly, you choose a scale — often more natural. Mean-shift handles arbitrary cluster shapes, but it's slower and bandwidth-sensitive. It's also used for image segmentation and tracking.

Code

python
import numpy as np
from sklearn.cluster import MeanShift
X = np.vstack([np.random.randn(80,2), np.random.randn(80,2)+6])
ms = MeanShift(bandwidth=2).fit(X)         # finds modes; k chosen automatically
print("found clusters:", len(set(ms.labels_)))
79

Spectral clustering

Cluster by graph

The idea

k-Means assumes clusters are round blobs — so it fails on shapes like two interleaving moons, slicing straight through them. Spectral clustering instead builds a similarity graph (connect nearby points), then cuts the graph where connections are sparse. It follows the data's connectivity, capturing curves and rings that distance-to-center methods can't.

The math · The recipe

1Build a graph: connect each point to its near neighbors. 2Form the graph Laplacian and take its smallest eigenvectors. 3Cluster points in that new embedding (k-means there).

Why it wins here

Two points on opposite ends of the same moon are far in straight-line distance but connected through a chain of neighbors — so the graph keeps them together. k-means only sees distance-to-center, so it carves the moons in half. Spectral shines on non-convex, manifold-shaped data.

Code

python
import numpy as np
from sklearn.cluster import SpectralClustering
X = np.vstack([np.random.randn(60,2), np.random.randn(60,2)+5])
labels = SpectralClustering(2, affinity='nearest_neighbors').fit_predict(X)
print("cluster sizes:", np.bincount(labels))   # uses graph Laplacian eigenvectors
Ensembles
80

Why ensembles work

Bagging vs boosting

The idea

One model is a single opinion; an ensemble combines many to do better than any alone. There are two great families: bagging trains models in parallel on random subsets and averages them (kills variance), while boosting trains them in sequence, each fixing the last's mistakes (kills bias). Toggle to compare.

Bagging

Many independent models on random subsets, trained in parallel, then averaged. Each is a full-strength model; averaging cancels their random errors → lower variance. Random forest is the classic.

Code

python
import numpy as np
# Averaging independent models reduces variance ~ 1/n
errs = np.random.randn(1000, 10)*1.0       # 10 models, each noisy
single = errs[:,0].std()
avg = errs.mean(1).std()
print(f"single std={single:.2f}  averaged std={avg:.2f}")
81

Bagging (bootstrap)

Resample & average

The idea

“Bagging” = Bootstrap AGGregatING. From your one dataset you draw many random samples with replacement — each sample duplicates some points and misses others. Train a model on each, then average their votes. Draw new bags and watch which points get picked, doubled, or left “out-of-bag”.

This bag

Picked (some twice)— Out-of-bag—

Code

python
import numpy as np
from sklearn.ensemble import BaggingClassifier
from sklearn.tree import DecisionTreeClassifier
X = np.random.randn(300,4); y = (X[:,0]+X[:,1]>0).astype(int)
bag = BaggingClassifier(DecisionTreeClassifier(), n_estimators=50).fit(X, y)
print("acc:", round(bag.score(X, y), 3))   # bootstrap + aggregate
82

Random forest

Add trees, smooth the vote

The idea

One deep tree overfits — its boundary is jagged and noisy. A forest trains many trees on random subsets, then has them vote. The wobbles average out and the combined boundary gets smooth and robust. Add trees and watch it happen.

A single tree

One tree → a jagged, overconfident boundary that chases noise. This is what we want to tame.

Code

python
import numpy as np
from sklearn.ensemble import RandomForestClassifier
X = np.random.randn(300,5); y = (X[:,0]*X[:,1]>0).astype(int)
rf = RandomForestClassifier(100).fit(X, y)
print("feature importances:", np.round(rf.feature_importances_, 2))
83

AdaBoost

Reweight the mistakes

The idea

Adaptive Boosting trains weak learners in sequence — but after each round it grows the weight of the points it got wrong, so the next learner is forced to focus on the hard cases. Step through the rounds and watch the misclassified points swell, pulling the next stump toward them.

Round 0

All points start equal weight. Press Next: a weak stump splits them, then the ones it misclassifies grow bigger for the next round. Round0

Code

python
import numpy as np
from sklearn.ensemble import AdaBoostClassifier
X = np.random.randn(300,2); y = (X[:,0]>0).astype(int)
ada = AdaBoostClassifier(n_estimators=50).fit(X, y)   # reweight hard examples each round
print("acc:", round(ada.score(X, y), 3))
84

Gradient boosting

Fix the residuals

The idea

Boosting builds models sequentially: each new little model fixes the errors (residuals) the previous ones left behind. Add them up and a pile of weak learners becomes a strong one. Step through the rounds and watch the fit chase down the leftover error.

Round 0

Start: predict a flat average. The grey sticks are the residuals — the error each next round will attack. Rounds0 Error0.0538

Code

python
import numpy as np
from sklearn.ensemble import GradientBoostingRegressor
X = np.sort(np.random.rand(120,1),0); y = np.sin(6*X).ravel()
gb = GradientBoostingRegressor(n_estimators=100, learning_rate=0.1).fit(X, y)
print("MSE:", round(np.mean((gb.predict(X)-y)**2), 4))   # each tree fits residuals
85

Stacking

A model of models

The idea

Why pick one algorithm when you can use several and let a final model learn how to blend them? Stacking feeds the predictions of diverse base models into a meta-model that learns who to trust when. Toggle base models on and off and watch the stacked accuracy respond.

Stacked meta-model

Combined accuracy91.0%

Code

python
from sklearn.ensemble import StackingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
import numpy as np
X = np.random.randn(300,4); y = (X[:,0]>0).astype(int)
stack = StackingClassifier(
  estimators=[('tree', DecisionTreeClassifier()), ('lr', LogisticRegression())],
  final_estimator=LogisticRegression()).fit(X, y)        # meta-model combines bases
print("acc:", round(stack.score(X, y), 3))
Evaluation
86

Threshold & confusion matrix

Drag the threshold

The idea

A classifier outputs a score; you pick a threshold to turn it into a yes/no. Where you put that line is a real decision — it trades false alarms against misses. Drag the threshold and watch the confusion matrix and every metric move. There is no free lunch.

Metrics

Accuracy85% Precision83% Recall88% F10.85

Code

python
import numpy as np
from sklearn.metrics import confusion_matrix
y, scores = np.r_[np.zeros(50),np.ones(50)], np.r_[np.random.rand(50), np.random.rand(50)+0.4]
for thr in (0.3, 0.7):
    pred = (scores > thr).astype(int)
    print(f"thr={thr}:", confusion_matrix(y, pred).ravel())   # tn,fp,fn,tp
87

ROC curve & AUC

Sweep the threshold

The idea

Sweep the threshold across every value and plot the true-positive rate against the false-positive rate — that traced curve is the ROC. The area under it (AUC) summarizes the classifier in one number: 1.0 is perfect, 0.5 is a coin flip. Drag the threshold and ride the curve.

This point

True-positive rate78% False-positive rate22% AUC (whole curve)0.888

Code

python
import numpy as np
from sklearn.metrics import roc_curve, roc_auc_score
y = np.r_[np.zeros(100),np.ones(100)]; s = np.r_[np.random.rand(100),np.random.rand(100)+0.5]
fpr, tpr, _ = roc_curve(y, s)
print("AUC:", round(roc_auc_score(y, s), 3))
88

Cross-validation

Rotate the folds

The idea

One train/test split can be lucky or unlucky. k-fold cross-validation splits the data into k parts, then trains k times — each fold takes a turn as the test set. You average the k scores for a far more trustworthy estimate. Step through the rotation.

Code

python
import numpy as np
from sklearn.model_selection import cross_val_score
from sklearn.ensemble import RandomForestClassifier
X = np.random.randn(200,4); y = (X[:,0]>0).astype(int)
s = cross_val_score(RandomForestClassifier(50), X, y, cv=5)
print("CV:", np.round(s,3), "mean", round(s.mean(),3))
89

Probability calibration

Do 70%s happen 70%?

The idea

A model can rank well yet lie about its confidence. Calibration asks: of all the times it says “70% sure,” do about 70% actually turn out positive? A reliability diagram plots predicted vs observed — perfect calibration is the diagonal. Slide the miscalibration and then fix it.

The math · Reliability & error

Bin predictions, compare each bin's mean prediction to its actual positive rate. Expected Calibration Error: ECE = Σb |b|n |acc(b) − conf(b)| ECE—

Why it matters

When a probability feeds a decision — a medical risk, a loan threshold, expected value — it must mean what it says. Methods like Platt scaling and isotonic regression remap the scores onto the diagonal without changing their ranking (so AUC is unchanged).

Code

python
import numpy as np
p = np.random.rand(5000); y = (np.random.rand(5000) < p).astype(int)
for lo in np.linspace(0,0.8,5):
    m = (p>=lo)&(p<lo+0.2)
    if m.sum(): print(f"pred~{lo+0.1:.1f} actual={y[m].mean():.2f}")
90

Regression metrics

MAE, RMSE, R²

The idea

Classification has accuracy; regression has a family of error measures that disagree on purpose. MAE treats every miss equally; MSE/RMSE punish big misses quadratically; R² is the fraction of variance explained; MAPE is a percentage. Drag the outlier and watch which metrics panic.

The math · The metrics

MAE0.024 MSE0.001 RMSE0.031 R²0.961 MAPE5.0%

Which to use

MAE when all errors matter equally and outliers are noise. RMSE when big errors are disproportionately bad (it's in the target's units). R² for a unitless "how much better than the mean." MAPE for relative error — but it explodes near zero and is undefined at zero.

Code

python
import numpy as np
y, yhat = np.array([3.,5.,2.,8.]), np.array([2.5,5.5,2.,7.])
mae = np.abs(y-yhat).mean(); rmse = np.sqrt(((y-yhat)**2).mean())
r2 = 1 - ((y-yhat)**2).sum()/((y-y.mean())**2).sum()
print(f"MAE={mae:.2f} RMSE={rmse:.2f} R2={r2:.3f}")
91

Precision-Recall curve

Better on imbalance

The idea

ROC can look flatteringly good when the positive class is rare, because true negatives swamp the false-positive rate. The precision-recall curve ignores true negatives entirely, so it tells the honest story on imbalanced data. Sweep the threshold and shrink the positive class to watch the gap.

The math · Axes

precision = TPTP+FP recall = TPTP+FN Average precision (area)0.690 baseline (random)0.30

The tell

A random classifier's PR baseline is just the positive rate — so at 5% positives, "good-looking" AUC of 0.9 can hide a PR curve that barely beats 0.05. Always pair PR with ROC when classes are skewed.

Code

python
import numpy as np
from sklearn.metrics import average_precision_score
# PR curve is better than ROC when positives are rare
y = np.r_[np.zeros(950), np.ones(50)]; s = np.r_[np.random.rand(950), np.random.rand(50)+0.4]
print("average precision:", round(average_precision_score(y, s), 3))
Validation strategies
92

Stratified k-fold

Keep class ratios

The idea

Plain k-fold splits randomly — fine until a class is rare, when some folds may end up with no minority examples at all. Stratified k-fold forces every fold to keep the same class ratio as the whole set. Toggle it and watch the per-fold minority counts even out.

The math · The rule

Each fold's class proportion equals the overall proportion: minorityᶠ|fold| ≈ minorityn

Why it matters

If a validation fold has zero minority cases, your metric for that class is undefined or wildly noisy — and the average misleads you. Stratification gives every fold a fair, comparable test. It's the default for classification.

Code

python
import numpy as np
from sklearn.model_selection import StratifiedKFold
y = np.r_[np.zeros(90), np.ones(10)]       # imbalanced
for tr, te in StratifiedKFold(5).split(np.zeros(len(y)), y):
    print("test positives:", int(y[te].sum()), end='  ')   # keeps class ratio per fold
93

Leave-one-out CV

k = n folds

The idea

Push k-fold to its limit: with n data points, make n folds — each leaves out exactly one point to test, trains on all the rest, and you average the n results. Almost no data wasted per fit, but you pay for n separate trainings. Step through it.

The math · The setup

k = n → n fits Each round: 1 point is the test set, the other n−1 train the model. Final score = mean over all n held-out points.

Progress

iteration0 / n running mean error—

The trade-off

Nearly unbiased (trains on almost all the data) and fully deterministic — but expensive for large n, and the n models are so similar that the estimate can have high variance. Most people use 5- or 10-fold instead; LOOCV shines on tiny datasets.

Code

python
import numpy as np
from sklearn.model_selection import LeaveOneOut
X = np.random.randn(20, 2)
print("folds:", LeaveOneOut().get_n_splits(X))   # n folds: train on n-1, test on 1
94

Time-series CV

Never test the past

The idea

With time-ordered data, shuffling is cheating — you'd train on the future to predict the past. Instead the split walks forward: train on history, test on the next block, then expand and repeat. Step through the rolling folds.

The math · The constraint

train: t ≤ T · test: t > T Every training point comes strictly before every test point — the model only ever sees the past.

Why standard k-fold fails here

Random folds would put future data in the training set, leaking information that won't exist at prediction time → wildly optimistic scores that collapse in production. Expanding window keeps all history; rolling window uses a fixed recent span (better if the process drifts).

Code

python
import numpy as np
from sklearn.model_selection import TimeSeriesSplit
X = np.arange(20).reshape(-1,1)
for tr, te in TimeSeriesSplit(4).split(X):
    print("train", tr[-1], "-> test", te[0], te[-1])   # never train on the future
95

Group k-fold

Keep groups together

The idea

Sometimes rows come in groups — multiple scans from one patient, many photos from one camera, several clicks from one user. If a group lands in both train and test, the model "recognizes" it instead of generalizing. Group k-fold keeps every group wholly in one fold. Toggle it and spot the leak.

The math · The rule

group(train) ∩ group(test) = ∅ No group's members may appear on both sides of a split.

Leaked groups

groups split across train/test0 / 6

The hidden trap

Plain k-fold can score brilliantly and still fail on truly new groups, because it was quietly tested on patients/users it had already partly seen. Group k-fold gives the honest answer: how well do you do on someone entirely new?

Code

python
import numpy as np
from sklearn.model_selection import GroupKFold
X = np.random.randn(12,2); y = np.random.randint(0,2,12)
groups = np.repeat(np.arange(4), 3)        # e.g., same patient
for tr, te in GroupKFold(4).split(X, y, groups):
    print("test groups:", set(groups[te]))   # a group never spans train+test
96

Nested cross-validation

Tune and test honestly

The idea

If you tune hyperparameters on the same folds you report, the score is optimistic — you've fit to the validation data. Nested CV uses two loops: an inner loop picks the best hyperparameters, an outer loop evaluates on data the tuning never saw. Step through the outer folds.

The math · The two loops

↻Outer: hold out a test fold for an honest estimate. ↺Inner: on the remaining data, run CV to pick hyperparameters. =Score the tuned model on the untouched outer fold. Average over outer folds.

Why bother

Regular "CV for tuning + CV for reporting on the same splits" leaks the test folds into model selection, inflating the number. Nested CV is the gold standard when you must both tune and report — at the cost of training many more models (outer × inner × configs).

Code

python
import numpy as np
from sklearn.model_selection import cross_val_score, GridSearchCV
from sklearn.svm import SVC
X = np.random.randn(120,4); y = (X[:,0]>0).astype(int)
inner = GridSearchCV(SVC(), {'C':[0.1,1,10]}, cv=3)     # inner: tune
outer = cross_val_score(inner, X, y, cv=4)               # outer: unbiased estimate
print("nested CV score:", round(outer.mean(), 3))
Tuning & pitfalls
97

Class imbalance

The accuracy trap

The idea

When one class is rare (fraud, disease, churn), accuracy lies. A model that just says “never” can score 99% — and catch zero real cases. Drag the rarity and watch a “predict all-negative” model look brilliant on accuracy while being useless on recall.

“Always predict negative”

Accuracy95% Recall (caught)0%

Code

python
import numpy as np
# Class weights make the loss care more about the rare class
y = np.r_[np.zeros(950), np.ones(50)]
w = {0: 1.0, 1: len(y)/(2*(y==1).sum())}   # inverse-frequency weighting
print("class weights:", {k: round(v,1) for k,v in w.items()})
98

Hyperparameter search

Hunt the best combo

The idea

Models have knobs you set before training (tree depth, learning rate, k…). Grid search just tries every combination and keeps the best on validation data. Click cells to “evaluate” them — reveal the scores and hunt for the sweet spot, then let it show you the winner.

Best so far

Click around — the best combination found will show here.

Code

python
import numpy as np
from sklearn.model_selection import GridSearchCV
from sklearn.svm import SVC
X = np.random.randn(150,4); y = (X[:,0]>0).astype(int)
gs = GridSearchCV(SVC(), {'C':[0.1,1,10], 'gamma':['scale','auto']}, cv=5).fit(X,y)
print("best:", gs.best_params_)
99

Learning curves

Does more data help?

The idea

Plot performance as you feed in more training data. The gap between training and validation scores tells you what's wrong: a big gap means overfitting (more data helps); both low and close means underfitting (more data won't — you need a better model). Slide the data size and read the diagnosis.

Flexible model

Big gap between train and validation → overfitting. The curves are still converging, so more data should help.

Code

python
import numpy as np
from sklearn.model_selection import learning_curve
from sklearn.linear_model import LogisticRegression
X = np.random.randn(300,4); y = (X[:,0]>0).astype(int)
sizes, tr, va = learning_curve(LogisticRegression(), X, y, cv=5)
print("val score vs size:", np.round(va.mean(1), 3))   # plateau => more data won't help
100

Data leakage

The too-good-to-be-true trap

The idea

The most dangerous bug in ML, because it hides as great results. Leakage is when information that won't be available at prediction time sneaks into training — so your model looks brilliant in testing and collapses in production. Toggle a leak and watch the illusion.

With leakage

A feature secretly derived from the answer (e.g. “payment_received” when predicting “will pay”). Test accuracy looks amazing — but it’s cheating.

Code

python
import numpy as np
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
# Put preprocessing INSIDE the pipeline so scaling is fit per-fold (no leakage)
pipe = make_pipeline(StandardScaler(), LogisticRegression())
print("scaler fits on train folds only ->", pipe.steps[0][0])
101

Early stopping

Stop at the bottom

The idea

Train a neural net too long and it starts memorizing. Watch both curves: training loss keeps falling, but validation loss bottoms out and then climbs — that turning point is where the model is at its best. Early stopping just halts there. Slide the epochs and find it.

Overtrained

Past the validation minimum: training loss keeps dropping but validation is rising. The extra epochs are pure overfitting. Best epochepoch 42

Code

python
import numpy as np
val = [1.0,0.8,0.65,0.6,0.62,0.64]; best, wait, patience = 9, 0, 2
for e,v in enumerate(val):
    if v<best: best,wait = v,0
    else:
        wait+=1
        if wait>=patience: print("stop at epoch", e); break
102

Over / under-sampling

Rebalance the classes

The idea

When one class is rare, a model can ignore it and still look accurate. Resampling rebalances the training set: duplicate or synthesize the minority (oversample), drop some majority (undersample), or generate new minority points between neighbors (SMOTE). Toggle them and watch the boundary start to respect the minority.

Imbalanced

90% majority, 10% minority. A model minimizing error can just predict "majority" everywhere and score 90%. class balance90% / 10%

Trade-offs

Undersampling throws away real data. Plain oversampling duplicates points (can overfit them). SMOTE interpolates new minority points between near neighbors — more diverse, but can blur class borders. Always resample inside cross-validation, never before.

Code

python
import numpy as np
# Random oversampling duplicates minority examples to balance classes
maj = np.random.randn(90, 2); minr = np.random.randn(10, 2) + 3
idx = np.random.randint(0, len(minr), len(maj))     # resample minority up
balanced = np.vstack([maj, minr[idx]])
print("balanced sizes:", len(maj), len(minr[idx]))
Applied ML & strategy
103

Error analysis

Look at what breaks

The idea

Before optimizing anything, look at the mistakes. Pull ~100 misclassified examples, sort them into categories, and count — the biggest bucket is where your effort pays off most. It's the cheapest, highest-leverage habit in applied ML, and it stops you polishing things that barely matter.

Error rate

starting dev error20.0% after your fixes20.0%

The point

“Blurry” and “lookalikes” are 75% of all errors — fixing either moves the needle far more than chasing the 8% mislabeled or 5% other. Without counting, teams routinely spend weeks on a category worth a fraction of a percent.

Code

python
import numpy as np
# Bucket the misclassified examples to find where the model fails
y, pred = np.random.randint(0,2,200), np.random.randint(0,2,200)
group = np.random.randint(0,3,200)                   # some segment
wrong = y != pred
for g in range(3):
    m = (group==g)
    print(f"segment {g}: error rate {wrong[m].mean():.2f}")
104

Bias & variance — what to do

Diagnose, then act

The idea

The decomposition is only useful if it tells you what to change. Compare three numbers — a human/Bayes baseline, your train error, and your dev error. The gap from baseline to train is avoidable bias; the gap from train to dev is variance. Whichever is bigger decides your next move. Slide them and read the prescription.

Diagnosis

avoidable bias13% variance3% verdicthigh bias (underfitting)
105

Picking an eval metric

One number to chase

The idea

A team that argues over several metrics moves slowly. Pick a single number to optimize so you can rank models instantly. When metrics trade off (accuracy vs speed), split them: satisficing metrics just need to clear a bar, while one optimizing metric is maximized. Set the bar and watch the winner change.

The math · The rule

Among models that meet the bar, maximize the one metric that matters: maximize accuracy s.t. latency ≤ budget

Chosen model

winnerModel B (93.0%, 95ms)

Why not average them?

Averaging accuracy and latency mixes incompatible units and hides real constraints — a model that's 0.5% better but 3× too slow shouldn't “win.” Satisficing encodes the hard requirement; optimizing picks the best of what's left.
106

Train/dev/test mismatch

Match the real world

The idea

Your dev and test sets define the target you're aiming at — so they must look like the data you'll actually face in production. If you train on data from a different distribution, a clever trick separates ordinary overfitting from a genuine data mismatch: a “train-dev” set, drawn from the training distribution but never trained on.

Scenario

Train ≈ dev distribution Turn this off to simulate training on, say, clean web images but deploying on blurry phone photos.

The math · Reading the four levels

human: → train: avoidable bias train: → train-dev: variance train-dev: → dev: data mismatch

Diagnosis

dominant problem—

Why the train-dev set

Without it, you can't tell whether a big train→dev gap is the model overfitting (variance) or the dev data simply being different (mismatch). The train-dev set — same distribution as train, held out — splits the gap. Mismatch calls for different fixes: make training data more like production, or collect real production data.
107

Model interpretability

Why did it predict that?

The idea

A black-box model that can't explain itself is hard to trust, debug, or deploy in regulated settings. Two lenses: global importance (which features matter overall — e.g. permutation importance) and local attributions (why this one prediction came out as it did — SHAP-style). Toggle between them.

The math · How they're computed

Permutation importance: shuffle one feature's values and measure how much accuracy drops. Big drop = the model leaned on it heavily.

Global vs local

Global tells you the model's overall priorities; local explains a single decision — vital when a person is owed a reason (a declined loan, a flagged transaction). SHAP values add up: base rate + each feature's push = the final prediction, and they're consistent across the two views.

Code

python
import numpy as np
from sklearn.inspection import permutation_importance
from sklearn.ensemble import RandomForestClassifier
X = np.random.randn(300,4); y = (X[:,0]+X[:,1]>0).astype(int)
rf = RandomForestClassifier(100).fit(X, y)
imp = permutation_importance(rf, X, y, n_repeats=10).importances_mean
print("permutation importance:", np.round(imp, 3))
108

Concept drift & monitoring

Models rot in production

The idea

A model is trained on a snapshot of the world — but the world moves. Customer behavior shifts, fraud adapts, seasons change, and accuracy quietly decays. You can't fix what you don't watch: monitor live performance, alarm when it dips, and retrain. Let it drift, then hit retrain.

Status

live accuracy— alarm threshold80%

Kinds of drift

Covariate: inputs shift (new user mix) Label: class balance shifts Concept: the input→output rule itself changes Monitoring + scheduled or triggered retraining is the whole job of MLOps after launch.

Code

python
import numpy as np
# Monitor a rolling error rate; alarm when it crosses a threshold
err = np.r_[np.random.rand(200) < 0.1, np.random.rand(200) < 0.4]
roll = np.convolve(err, np.ones(30)/30, 'valid')
print("drift first detected near step:", int(np.argmax(roll > 0.25)))
109

A/B testing & significance

Is B really better?

The idea

Model B scored higher offline — but will it actually beat A with real users? Run a controlled experiment and ask whether the gap is real or just noise. With small samples the confidence intervals overlap and you can't tell; grow the sample and, if the effect is real, significance emerges. Tune both and watch.

Result

A / B conversion30.0% / 36.0% p-value0.071 verdictinconclusive

The intuition

Each bar has a confidence interval. If the intervals clearly separate, B's win is unlikely to be chance (small p). If they overlap, you simply don't have enough data to conclude — a real difference can hide in noise until n is large enough. Significance is not the same as a big effect.

Code

python
import numpy as np
from scipy import stats
# A/B test: is B's conversion rate significantly higher than A's?
a = np.random.binomial(1, 0.10, 2000); b = np.random.binomial(1, 0.12, 2000)
z, p = stats.ttest_ind(a, b)
print(f"lift={b.mean()-a.mean():+.3f}  p={p:.3f}", "significant" if p<0.05 else "not yet")
110

Fairness & bias

Accurate ≠ fair

The idea

A model can be accurate overall yet treat groups unequally — because the training data, the labels, or society itself is skewed. With one shared threshold, two groups with different score distributions get selected at different rates. Toggle group-specific thresholds to equalize opportunity, and see the tension with a single cutoff.

Selection rate (qualified)

group A— group B— disparity—

Competing definitions

Demographic parity: equal selection rates. Equal opportunity: equal true-positive rates among the qualified. Equalized odds: equal TPR and FPR. These often can't all hold at once — fairness is a value choice, not just a metric, and a single threshold rarely satisfies it.

Code

python
import numpy as np
# Demographic parity: selection rate should be similar across groups
pred = np.random.randint(0,2,1000); grp = np.random.randint(0,2,1000)
rates = {g: round(pred[grp==g].mean(), 3) for g in (0,1)}
print("selection rates:", rates, " gap:", round(abs(rates[0]-rates[1]), 3))
111

Baselines & data-centric ML

Start simple, fix data

The idea

Before reaching for a deep net, build a dumb baseline — predict the majority class, or a one-line heuristic. It tells you whether your fancy model is actually earning its keep. And once you have a decent model, the biggest gains usually come from better data, not a bigger model.

Accuracy ladder

majority-class baseline62% simple model81% complex model86% after your investment88%

The lessons

If a heavyweight model barely beats "always predict the majority," something's wrong. And past a point, doubling model size buys a fraction of a percent, while fixing mislabeled examples and inconsistent annotation can buy several — the data-centric insight Ng champions.
Neural networks
112

Inside a neuron

Tune the weights

The idea

Every neural network is built from this one tiny unit. It multiplies each input by a weight, adds them up with a bias, and squashes the result through an activation. Drag the weights and watch the signal flow and the output change — this is the whole computation.

Code

python
import numpy as np
# A neuron: weighted sum + bias, then a nonlinearity
def neuron(x, w, b, act=lambda z: max(0, z)): return act(x @ w + b)
print(neuron(np.array([1.,2.]), np.array([0.5,-0.3]), 0.1))
113

Activation functions

Pick & probe

The idea

The activation is what lets networks learn non-linear things — without it, stacking layers is pointless. Each one squashes or gates its input differently. Pick a function and drag the input to see exactly what comes out, and where its gradient lives.

Sigmoid

Squashes any input into (0, 1) — great for probabilities. Saturates at the ends, so gradients vanish for large inputs. input x0.0 output f(x)0.50

Code

python
import numpy as np
z = np.array([-2., 0., 2.])
relu = np.maximum(0, z); sigmoid = 1/(1+np.exp(-z)); tanh = np.tanh(z)
print("relu", relu, "\nsigmoid", np.round(sigmoid,2), "\ntanh", np.round(tanh,2))
114

A network forward pass

Watch signal propagate

The idea

Stack neurons into layers and you get a neural network. Each layer transforms the previous one's outputs; the signal flows left to right until it becomes a prediction. Move the inputs and watch every activation light up and ripple through the layers.

Code

python
import numpy as np
def forward(x, W1, b1, W2, b2):
    h = np.maximum(0, x @ W1 + b1)         # hidden ReLU layer
    return h @ W2 + b2                       # output layer
x = np.random.randn(4, 8)
print(forward(x, np.random.randn(8,16), np.zeros(16),
                 np.random.randn(16,2), np.zeros(2)).shape)
115

Dropout

Randomly drop neurons

The idea

A powerful regularizer for neural nets: during training, randomly switch off a fraction of neurons on every step. The network can't rely on any single neuron, so it learns redundant, robust features — like training a huge ensemble of thinned networks. Step it and watch a different sub-network each time.

The math · Train vs test

Training: keep each neuron with probability (1−p), zero the rest: a = mask · a , maski ~ Bernoulli(1−p) Test: use all neurons (no dropping) — outputs are scaled so the expected activation matches training.

The ensemble view

Each step trains a different thinned network; at test time you average them all by using the full net. That averaging is what reduces overfitting — closely related to bagging.

Code

python
import numpy as np
def dropout(h, p=0.5, train=True):
    if not train: return h
    return h * (np.random.rand(*h.shape) > p) / (1-p)   # zero + rescale
print(dropout(np.ones((2,6)), 0.5))
116

Batch normalization

Stabilize activations

The idea

As a network trains, the distribution of each layer's inputs keeps shifting, which slows everything down. Batch norm re-centers and re-scales each layer's activations to a clean distribution every batch — then lets the network learn its own scale and shift back. Slide the incoming distribution and watch it get normalized.

The math · The transform

x̂ = x − μB√(σ²B+ε) y = γ x̂ + β Normalize to mean 0, variance 1 — then γ, β let the net rescale if it wants.

Why it helps

Stable input distributions let you use higher learning rates and train deeper nets faster. It also has a mild regularizing effect (each batch's statistics add a little noise).

Code

python
import numpy as np
def batchnorm(x, gamma=1., beta=0., eps=1e-5):
    mu, var = x.mean(0), x.var(0)
    return gamma*(x - mu)/np.sqrt(var+eps) + beta       # normalize per feature
h = np.random.randn(8, 4)*5 + 3
print("after BN: mean", np.round(batchnorm(h).mean(0),2))
Deep learning
117

Convolution (CNNs)

Slide the kernel

The idea

Convolutional nets see images by sliding a small kernel across them, computing a weighted sum at each spot to build a feature map. Different kernels detect different things — edges, blurs. Hover over the image to place the kernel and watch that output pixel get computed.

Edge detector

Hover the image. The 3×3 kernel multiplies the patch under it and sums — that one number becomes a pixel in the feature map.

Code

python
import numpy as np
img = np.eye(5); kernel = np.array([[1,0,-1],[1,0,-1],[1,0,-1.]])   # vertical edges
out = np.array([[ (img[i:i+3,j:j+3]*kernel).sum() for j in range(3)] for i in range(3)])
print(np.round(out, 1))
118

Attention (transformers)

What attends to what

The idea

The idea behind every LLM. Instead of reading word by word, attention lets each word look at every other word and decide which ones matter for its meaning. Click a word to see what it attends to — thicker links mean stronger attention.

The sentence

👆 Click any word to see its attention. Notice how “it” reaches back to what it refers to.

“it” attends to:

Most strongly: cat, sleepy. Each word’s meaning is rebuilt as a weighted blend of the words it attends to.

Code

python
import numpy as np
def softmax(z): e=np.exp(z-z.max(-1,keepdims=True)); return e/e.sum(-1,keepdims=True)
X = np.random.randn(4, 8); Wq,Wk,Wv = [np.random.randn(8,8) for _ in range(3)]
Q,K,V = X@Wq, X@Wk, X@Wv
out = softmax(Q@K.T/np.sqrt(8)) @ V        # each token attends to all tokens
print(out.shape)
119

Word embeddings

Meaning as direction

The idea

Models can't read words — so we map each word to a vector of numbers, placed so that similar meanings sit close and relationships become directions. The classic: king − man + woman lands near queen. Pick an analogy and watch the parallel arrows.

Gender direction

The arrow from “man” to “woman” is almost identical to “king” to “queen” — the embedding has learned a consistent “gender” direction.

Code

python
import numpy as np
# Word embeddings: similar words have nearby vectors; analogies via arithmetic
vocab = {'king':0,'queen':1,'man':2,'woman':3}
E = np.random.randn(4, 8)
analogy = E[0] - E[2] + E[3]               # king - man + woman ~ queen
sims = E @ analogy
print("closest:", list(vocab)[sims.argmax()])
120

Recurrent nets (sequences)

Carry the hidden state

The idea

Text, audio, and time-series arrive as sequences where order matters. A recurrent net reads one element at a time, and crucially passes a hidden state — its running memory — forward to the next step. Step through the sequence and watch the memory accumulate.

Ready

At each step the cell combines the new input with the hidden state from the previous step, producing an updated state. That loop is the “memory.”

Code

python
import numpy as np
def step(x, h, Wx, Wh): return np.tanh(x@Wx + h@Wh)
Wx, Wh = np.random.randn(4,8), np.random.randn(8,8)
h = np.zeros(8)
for x in np.random.randn(6, 4): h = step(x, h, Wx, Wh)   # carry state over time
print("final state:", h.shape)
Reinforcement learning
121

Q-learning

Watch an agent learn

The idea

Reinforcement learning has no labeled answers — just rewards. The agent wanders a grid, bumps into a goal (and a pit), and gradually learns which moves pay off. The arrows show its current best-known action per square. Run it and watch a strategy emerge from pure trial and error.

Progress

Episodes0 Last reward—

Code

python
import numpy as np
# Q-learning: Q(s,a) <- Q + alpha*(r + gamma*max Q(s') - Q)
Q = np.zeros((3, 2)); alpha, gamma = 0.5, 0.9
# one update toward reward 1 from state 0, action 1, next state 2
s, a, r, s2 = 0, 1, 1.0, 2
Q[s,a] += alpha*(r + gamma*Q[s2].max() - Q[s,a])
print(np.round(Q, 2))
Advanced
122

Regularization

Dial down complexity

The idea

Instead of choosing a simpler model, you can penalize complexity. Regularization (here, ridge / L2) adds a cost for large coefficients, so the fit stays smooth even with a flexible model. Turn up the strength λ and watch a wild overfit calm down.

No penalty

λ = 0: a free high-degree fit that chases every point — overfit. Test error0.000

Code

python
import numpy as np
# Regularization adds a penalty so weights stay small (better generalization)
X = np.random.randn(40,6); y = X @ np.r_[[3,2],np.zeros(4)] + 0.3*np.random.randn(40)
for lam in (0, 5):
    b = np.linalg.solve(X.T@X + lam*np.eye(6), X.T@y)
    print(f"lambda={lam}: ||w||={np.linalg.norm(b):.2f}")
123

PCA — dimensionality

Find the main axes

The idea

Principal Component Analysis finds the directions your data actually varies along. PC1 is the axis of greatest spread; PC2 is perpendicular to it. Projecting onto PC1 alone compresses 2-D to 1-D while keeping most of the information. Click to add points and watch the axes re-orient.

Variance explained

PC1— PC2—

Code

python
import numpy as np
from sklearn.decomposition import PCA
X = np.random.randn(200, 10)
p = PCA(n_components=3).fit(X)
print("variance explained:", np.round(p.explained_variance_ratio_, 3))
124

t-SNE / UMAP

Untangle high-D structure

The idea

How do you see data with hundreds of features? t-SNE and UMAP squeeze it into 2-D for plotting, arranging points so that neighbors stay neighbors — similar things land together, revealing clusters you couldn't otherwise see. Run the embedding and watch structure emerge from the scramble.

What's happening

Points start as a meaningless jumble (the “high-D” view). The algorithm pulls similar points together and pushes different ones apart until the hidden groups appear.

Code

python
import numpy as np
from sklearn.manifold import TSNE
X = np.vstack([np.random.randn(80,20), np.random.randn(80,20)+5])
emb = TSNE(2, perplexity=30, init='pca').fit_transform(X)   # local structure -> 2-D
print(emb.shape)
125

Anomaly detection

Flag the odd ones out

The idea

Fraud, faults, intrusions — often you can't label every bad case, so instead you learn what normal looks like and flag whatever doesn't fit. Tune the sensitivity: too strict and you cry wolf on normal points; too loose and real anomalies slip through. Find the balance.

Result

Flagged anomalies18 False alarms11

Code

python
import numpy as np
from sklearn.ensemble import IsolationForest
X = np.r_[np.random.randn(200,2), [[8,8]]]
print("flags:", IsolationForest(contamination=0.01).fit_predict(X)[[0,-1]])  # -1 = anomaly
126

Gaussian anomaly detection

Density & a threshold

The idea

A principled way to flag the odd ones out: fit a Gaussian to your normal data, then compute each point's probability density p(x). Anything in a low-density region — p(x) below a threshold ε — is an anomaly. Slide ε and watch the boundary tighten around the dense core.

The math · The model

Fit μ, σ² per feature; the density (assuming independence) is the product: p(x) = ∏j 1√(2π)σⱼ e−(xⱼ−μⱼ)²/2σⱼ² flag if p(x) < ε

Result

flagged6 points

vs supervised learning

Use this when anomalies are rare and varied — fraud, novel faults — so you can't gather enough positive examples to train a classifier. You model only “normal,” and anything improbable is suspect. With many labelled positives of a consistent type, a supervised classifier is usually better.

Code

python
import numpy as np
# Fit a Gaussian; flag low-probability points as anomalies
X = np.random.randn(500, 2)
mu, cov = X.mean(0), np.cov(X.T); cov_inv = np.linalg.inv(cov)
def nll(x): return (x-mu) @ cov_inv @ (x-mu)
print("anomaly score of [5,5]:", round(nll(np.array([5.,5.])), 1))
127

Recommenders

Users like you also liked

The idea

How does Netflix guess what you'll like? Collaborative filtering finds users who rated things the way you did, then predicts your missing ratings from theirs. No content analysis needed — just the pattern of who-likes-what. Pick the cell to predict and watch it find your taste-twins.

Find taste-twins

Press Predict: the users whose ratings most match yours light up, and their opinion of the unseen movie becomes your predicted rating.

Code

python
import numpy as np
# Collaborative filtering by matrix factorization (R ~ U V^T)
R = np.array([[5,3,0,1],[4,0,0,1],[1,1,0,5],[0,0,5,4.]]); mask = R>0
U, V = np.random.rand(4,2), np.random.rand(4,2)
for _ in range(3000):
    E = mask*(R - U@V.T); U += 0.01*(E@V); V += 0.01*(E.T@U)
print(np.round(U@V.T, 1))
128

Content-based filtering

Match by features

The idea

Collaborative filtering needs lots of ratings; content-based filtering instead uses item features — genre, tempo, topic — and learns a per-user preference vector. A recommendation is just the item whose features best align with your taste. Spin your taste vector and watch the rankings reorder.

The math · The score

Predicted rating = your preference vector · the item's feature vector: r̂ui = wu · xi Recommend the items with the highest dot product.

Top pick

recommendedBourne

Cold start

Because it relies on features, not other users' ratings, content-based filtering can recommend a brand-new item the moment it's added — solving the “cold-start” problem that stumps pure collaborative filtering. Many real systems blend the two (hybrid).

Code

python
import numpy as np
# Content-based: recommend items similar to what the user liked (by features)
item_feats = np.random.randn(5, 8)
liked = item_feats[1]                       # user liked item 1
sims = item_feats @ liked / (np.linalg.norm(item_feats,axis=1)*np.linalg.norm(liked))
print("recommend:", sims.argsort()[::-1][1])
129

Linear discriminant analysis

Supervised projection

The idea

PCA finds the direction of most variance — but the biggest-variance axis isn't always the one that separates your classes. LDA uses the labels: it finds the projection that pushes class means apart while keeping each class tight. Compare the two axes and project the data onto each.

The math · The objective

Maximize between-class spread over within-class spread: max wᵀ SB wwᵀ SW w → w ∝ SW⁻¹(μ1−μ2)

1-D overlap after projection

class overlap0% (low ✓)

The point

On this data PCA's top axis runs along the shared elongation — projecting onto it smears the classes together. LDA picks the axis across the gap, so the 1-D projection separates cleanly. Use LDA when you have labels and want a discriminative low-D view.

Code

python
import numpy as np
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
X = np.r_[np.random.randn(100,4), np.random.randn(100,4)+2]; y = np.r_[np.zeros(100),np.ones(100)]
lda = LinearDiscriminantAnalysis().fit(X, y)   # projection that best separates classes
print("acc:", round(lda.score(X, y), 3))
130

Association rules (Apriori)

Market-basket mining

The idea

“People who buy diapers also buy beer.” Association-rule mining scans transactions for items that co-occur and scores rules X→Y by support (how often), confidence (how reliably), and lift (how much more than chance). Raise the thresholds and watch weak rules drop away.

The math · The three measures

support: P(X and Y) — how often the combo appears confidence: P(Y | X) — if X, how often Y lift: P(Y|X) / P(Y) — >1 means X makes Y likelier

Why Apriori

Checking every itemset is exponential. Apriori's trick: if an itemset is rare, every superset is rarer — so prune aggressively. Lift matters most: a high-confidence rule for an already-popular item (high P(Y)) may be worthless (lift ≈ 1).

Code

python
# Apriori idea: find item pairs that co-occur more than chance (lift > 1)
baskets = [{'bread','milk'},{'bread','butter'},{'bread','milk','butter'},{'milk'}]
n = len(baskets)
def support(items): return sum(items <= b for b in baskets)/n
lift = support({'bread','butter'}) / (support({'bread'})*support({'butter'}))
print("lift(bread->butter):", round(lift, 2))
Putting it together
131

Your first model, end to end

The whole loop in code

The idea

Everything today, in one pass. This is the exact sequence you'll run in the lab — from raw data to a tested prediction. Step through the pipeline you'll build by the end of Day 1.

Code

python
import numpy as np
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
X = np.random.randn(300, 5); y = (X[:,0]+X[:,1] > 0).astype(int)
pipe = make_pipeline(StandardScaler(), LogisticRegression())   # preprocessing + model
print("CV accuracy:", round(cross_val_score(pipe, X, y, cv=5).mean(), 3))