Machine Learning — Handbook
Rules vs learning
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
Three kinds of learning
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
Features & labels
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
Code
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)Train / test split
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?
Code
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 setTrain / val / test split
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
Reported vs reality
Why a third set
Code
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-testThe ML workflow
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
Precision vs accuracy
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
Distributions & spread
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
Code
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 centerMean, median & mode
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
Code
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 -- robustProbability & Bayes
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…
Code
# 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))Sampling bias
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
Code
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())Sampling methods
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
Subgroup proportions
Code
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))Bootstrapping
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
Result
Why it's powerful
Code
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}]")Feature scaling
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
Code
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 1Correlation
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
Code
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 relationOutliers & robustness
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
Code
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 ruleOne-hot encoding
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
Code
import pandas as pd
df = pd.DataFrame({'city': ['NY','LA','NY','SF']})
print(pd.get_dummies(df['city']).values) # one binary column per categoryMissing data
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
Code
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)Feature engineering
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
Common transforms
Code
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])Fit on train, apply to test
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
Why train-only?
Code
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")Target / mean encoding
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
Columns needed
The leakage trap
Code
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/smoothingData augmentation
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
Domain-specific
Code
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")Feature selection
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
Result
The shape
Code
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])Text features (TF-IDF)
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
Why it works
Code
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 moreMulticollinearity & VIF
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
What to do
Code
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 highHow models learn
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
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)) # -> 3Loss surface & LR
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
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 divergesOverfitting & underfitting
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
Code
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 -> overfitsBias–variance tradeoff
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
The terms
Code
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))Underfitting — deep dive
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
The math · The math
When new data arrives
How to fix it
Code
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))Overfitting — deep dive
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
The math · The math
When new data arrives
How to fix it
Code
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 oscillationBias–variance decomposition
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
Live estimate
The point
Code
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}")Ridge regression (L2)
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
The flow as λ grows
When new data arrives
Code
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))Lasso regression (L1)
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
Ridge vs Lasso
When new data arrives
Code
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 zerosGradient descent — the math
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
Why the learning rate matters
Code
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]Optimizers — SGD, momentum, Adam
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
Why it matters
Code
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))Logistic regression — the math
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
Current fit
Why log-loss, not accuracy?
Code
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))SVM — the margin objective
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
This boundary
Why the widest margin?
Code
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))Information gain — the math
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
This split
What the tree does
Code
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))k-Means — the objective
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)
Inertia J
Why it converges (to a local min)
Code
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))Backpropagation — chain rule
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
The key idea
Code
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))Cost functions — the core idea
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
This prediction
Why it matters
Code
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}")Decision tree — the algorithm
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
How it runs
The catch
Code
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))Linear regression — least squares
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
Current fit
Why squares?
Code
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))PCA — eigenvectors
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
Eigenvalues → variance
Dimensionality reduction
Code
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))Naive Bayes — the posterior
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
Posterior
Why “naive” still works
Code
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))Confusion matrix — the metrics
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
Live metrics
The tension
Code
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}")Softmax & cross-entropy
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
Output
Why softmax?
Code
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))k-NN & the curse of dimensions
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
Distance concentration
The curse
Code
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())ROC / AUC — the integral
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
Score
Why AUC?
Code
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)The kernel trick
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
Why it's a “trick”
Code
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))Maximum likelihood
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
Fit
The big idea
Code
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))Convolution arithmetic
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
Rules of thumb
Code
# 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)Linear regression playground
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
Code
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))Polynomial regression
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
The flexibility trap
Code
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))Elastic Net
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
Why blend
Code
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))Logistic regression
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
Code
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))The perceptron
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
Power and limits
Code
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))k-Nearest Neighbors
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 →
Code
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 trainingk-NN regression
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
The bias-variance dial
Code
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))Decision tree
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
Code
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}")Regression trees
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
Code
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))Entropy & impurity
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
Code
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))Support vector margin
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
Code
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 pointsSupport vector regression
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
Why the tube
Code
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))Naive Bayes
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)
Code
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))One-vs-rest multiclass
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
vs softmax
Code
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 metrics
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
Nearest to the query
Why it matters
Code
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())Cosine similarity
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
The key insight
Code
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)Mahalanobis distance
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
Where it's used
Code
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))Hamming & Jaccard
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
Distances
Where each fits
Code
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))k-Means clustering
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
Code
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))DBSCAN (density)
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
Code
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())Hierarchical clustering
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
Code
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:])Gaussian mixtures (EM)
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
vs k-means
Code
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))Choosing k
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
Reading the curves
Code
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 kk-medoids (PAM)
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
Why it's useful
Code
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))Mean-shift
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
Bandwidth is everything
Code
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_)))Spectral clustering
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
Why it wins here
Code
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 eigenvectorsWhy ensembles work
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
Code
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}")Bagging (bootstrap)
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
Code
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 + aggregateRandom forest
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
Code
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))AdaBoost
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
Code
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))Gradient boosting
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
Code
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 residualsStacking
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
Code
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))Threshold & confusion matrix
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
Code
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,tpROC curve & AUC
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
Code
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))Cross-validation
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
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))Probability calibration
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
Why it matters
Code
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}")Regression metrics
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
Which to use
Code
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}")Precision-Recall curve
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
The tell
Code
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))Stratified k-fold
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
Why it matters
Code
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 foldLeave-one-out CV
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
Progress
The trade-off
Code
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 1Time-series CV
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
Why standard k-fold fails here
Code
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 futureGroup k-fold
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
Leaked groups
The hidden trap
Code
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+testNested cross-validation
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
Why bother
Code
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))Class imbalance
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”
Code
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()})Hyperparameter search
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
Code
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_)Learning curves
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
Code
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 helpData leakage
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
Code
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])Early stopping
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
Code
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); breakOver / under-sampling
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
Trade-offs
Code
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]))Error analysis
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
The point
Code
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}")Bias & variance — what to do
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
Picking an eval metric
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
Chosen model
Why not average them?
Train/dev/test mismatch
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
The math · Reading the four levels
Diagnosis
Why the train-dev set
Model interpretability
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
Global vs local
Code
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))Concept drift & monitoring
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
Kinds of drift
Code
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)))A/B testing & significance
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
The intuition
Code
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")Fairness & bias
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)
Competing definitions
Code
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))Baselines & data-centric ML
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
The lessons
Inside a neuron
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
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))Activation functions
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
Code
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))A network forward pass
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
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)Dropout
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
The ensemble view
Code
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))Batch normalization
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
Why it helps
Code
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))Convolution (CNNs)
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
Code
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))Attention (transformers)
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
“it” attends to:
Code
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)Word embeddings
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
Code
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()])Recurrent nets (sequences)
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
Code
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)Q-learning
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
Code
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))Regularization
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
Code
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}")PCA — dimensionality
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
Code
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))t-SNE / UMAP
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
Code
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)Anomaly detection
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
Code
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 = anomalyGaussian anomaly detection
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
Result
vs supervised learning
Code
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))Recommenders
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
Code
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))Content-based filtering
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
Top pick
Cold start
Code
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])Linear discriminant analysis
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
1-D overlap after projection
The point
Code
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))Association rules (Apriori)
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
Why Apriori
Code
# 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))Your first model, end to end
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
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))