Post

Machine Learning Roadmap

Machine Learning Roadmap

Machine Learning: From Beginner to Machine Learning Engineer

A complete, end-to-end, blog-style guide to classical machine learning. Start with no background, finish with the intuition, the math, and the code to design, train, evaluate, and ship ML systems. Every algorithm is built from scratch in NumPy first, then used properly with scikit-learn.

Beginner → Intermediate → Advanced → Engineer
Read it top-to-bottom, or jump to the part you need.


Table of Contents

#PartWhat you’ll learn
0How to Use This GuideRoadmap, mindset, timeline
1Setting Up Your EnvironmentPython, conda, scikit-learn, Jupyter, project layout
2The Data Stack: NumPy, Pandas, PlottingArrays, DataFrames, groupby, merge, EDA plots
3The Math You Actually NeedLinear algebra, calculus, probability, statistics, information theory
4Machine Learning FundamentalsLearning paradigms, the workflow, generalization, bias-variance
5Data Preprocessing & Feature EngineeringCleaning, encoding, scaling, pipelines, leakage
6Linear ModelsLinear/logistic regression from scratch, Ridge, Lasso, softmax
7k-Nearest NeighborsDistance, curse of dimensionality, KNN from scratch
8Decision TreesEntropy, Gini, CART from scratch, pruning
9Ensembles: Bagging, Random Forests, BoostingRandom forest, AdaBoost, gradient boosting from scratch, XGBoost/LightGBM
10Support Vector MachinesMargins, hinge loss, kernels, SVM from scratch
11Naive Bayes & Probabilistic ClassifiersGaussian/Multinomial NB from scratch, LDA/QDA, generative vs discriminative
12Unsupervised LearningK-means, hierarchical, DBSCAN, GMM/EM, PCA, t-SNE/UMAP, anomaly detection
13Model Evaluation & SelectionMetrics, cross-validation, tuning, imbalance, calibration
14Optimization & Learning TheoryGradient descent, convexity, regularization, MLE/MAP, PAC in a page
15Time SeriesStationarity, lag features, ARIMA, backtesting
16Recommender SystemsCollaborative filtering, matrix factorization from scratch
17Classical NLPBag of words, TF-IDF, n-grams, topic models
18InterpretabilityPermutation importance, partial dependence, SHAP, LIME
19From Notebook to ProductionPipelines, persistence, serving, monitoring, reproducibility
20End-to-End Project WalkthroughA complete, production-shaped ML project
21Career Roadmap & ProjectsPortfolio, interviews, what to read
AAppendix: Cheat SheetsAlgorithm selection, sklearn API, hyperparameters
BAppendix: GlossaryEvery term, briefly
CAppendix: Reading ListBooks, courses, papers

Part 0: How to Use This Guide

Who this is for

  • Complete beginners who know some Python and want to understand machine learning properly, not just call .fit().
  • Software engineers and analysts moving into data science / ML engineering.
  • Students preparing for exams, research, or interviews.
  • Deep learning practitioners who skipped the classical foundations and keep getting beaten by gradient-boosted trees on tabular data.

Philosophy

  1. Build it, then use it. Every core algorithm is implemented from scratch in NumPy (so you know what is inside), then used via scikit-learn (so you know how it is done in practice).
  2. Data first. Most real-world ML failures are data failures. We spend serious time on preprocessing, leakage, evaluation, and validation.
  3. Intuition + math. Pictures and equations, side by side.
  4. Engineering matters. Pipelines, reproducibility, serving, and monitoring are part of the job.

The Roadmap

flowchart TD
    A[Python + NumPy + Pandas] --> B[Math: Linear Algebra, Probability, Statistics]
    B --> C[ML Fundamentals: generalization, bias-variance]
    C --> D[Data Preprocessing & Feature Engineering]
    D --> E[Linear Models]
    E --> F[KNN, Trees, Naive Bayes, SVM]
    F --> G[Ensembles: Random Forest, Gradient Boosting]
    E --> H[Unsupervised: Clustering, PCA]
    G --> I[Evaluation, Cross-Validation, Tuning]
    H --> I
    I --> J[Time Series, Recommenders, Classical NLP]
    I --> K[Interpretability]
    J --> L[Production: Pipelines, Serving, Monitoring]
    K --> L
    L --> M[Machine Learning Engineer]

Suggested timeline

WeeksFocusParts
1Setup, data stack, math1-3
2Fundamentals, preprocessing4-5
3Linear models, KNN6-7
4Trees, ensembles8-9
5SVM, Naive Bayes10-11
6Unsupervised learning12
7Evaluation, tuning, theory13-14
8Time series, recommenders, NLP15-17
9Interpretability, production18-19
10End-to-end project, portfolio20-21

Mindset

  • Run every code block. Change the numbers. Break it. Fix it.
  • Plot everything. Distributions, residuals, learning curves, decision boundaries.
  • Always have a baseline. Predict the mean / majority class first. Every model must beat it.
  • Be paranoid about leakage. If a result looks too good, it is probably leaking.
  • Do the projects. Every part ends with one.

Part 1: Setting Up Your Environment

1.1 The toolchain at a glance

1
2
3
4
5
6
7
8
9
10
11
┌──────────────────────────────────────────────────────────────┐
│                         YOUR MACHINE                         │
│  ┌──────────┐  ┌────────────┐  ┌──────────────┐  ┌────────┐  │
│  │  Python  │  │ conda/venv │  │ scikit-learn │  │ pandas │  │
│  │  3.10+   │  │   (envs)   │  │  (models)    │  │ (data) │  │
│  └──────────┘  └────────────┘  └──────────────┘  └────────┘  │
│  ┌──────────┐  ┌────────────┐  ┌──────────────┐  ┌────────┐  │
│  │ Jupyter  │  │  VS Code   │  │ XGBoost /    │  │  git   │  │
│  │(explore) │  │   (code)   │  │ LightGBM     │  │        │  │
│  └──────────┘  └────────────┘  └──────────────┘  └────────┘  │
└──────────────────────────────────────────────────────────────┘

1.2 Install

1
2
3
4
5
6
7
8
9
# Miniconda: https://docs.conda.io/en/latest/miniconda.html
conda create -n ml python=3.11 -y
conda activate ml

pip install numpy pandas scipy scikit-learn matplotlib seaborn jupyterlab
pip install xgboost lightgbm catboost            # gradient boosting libraries
pip install statsmodels                          # statistics, time series
pip install optuna shap imbalanced-learn         # tuning, interpretability, imbalance
pip install joblib tqdm pyyaml

Verify:

1
2
import sklearn, pandas, numpy, xgboost, lightgbm
print(sklearn.__version__, pandas.__version__, numpy.__version__)

1.3 Do you need a GPU?

No. Classical ML runs on CPU. A laptop with 8-16 GB RAM handles datasets up to a few million rows. For bigger data: sample first, then scale out (Spark, Dask, cloud).

1.4 Project structure template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
my-ml-project/
├── data/
│   ├── raw/             # never modify
│   └── processed/
├── notebooks/           # exploration (numbered: 01_eda.ipynb, 02_baseline.ipynb)
├── src/
│   ├── data.py          # loading, splitting
│   ├── features.py      # feature engineering, pipelines
│   ├── train.py         # training entry point
│   ├── evaluate.py
│   └── predict.py
├── models/              # saved artifacts (gitignored)
├── configs/
├── tests/
├── requirements.txt
└── README.md

1.5 Reproducibility helper

1
2
3
4
5
6
7
8
9
10
import random, os
import numpy as np

def seed_everything(seed: int = 42):
    random.seed(seed)
    os.environ["PYTHONHASHSEED"] = str(seed)
    np.random.seed(seed)

seed_everything(42)
# Also pass random_state=42 to every sklearn estimator / splitter that accepts it.

Part 2: The Data Stack: NumPy, Pandas, Plotting

2.1 NumPy in 10 minutes

(The DL guide’s Part 2 covers NumPy in depth. Here is the minimum for ML.)

1
2
3
4
5
6
7
8
9
10
11
12
import numpy as np

X = np.random.randn(100, 5)          # 100 samples, 5 features
y = (X[:, 0] + 0.5 * X[:, 1] > 0).astype(int)

X.shape, X.mean(axis=0), X.std(axis=0)      # per-feature stats
(X - X.mean(0)) / X.std(0)                  # standardization via broadcasting
X @ np.random.randn(5, 1)                   # matrix multiply: a linear model
np.linalg.inv(X.T @ X) @ X.T @ y            # closed-form least squares (don't do this in prod)
X[y == 1]                                   # boolean mask filtering
np.argsort(X[:, 0])[:5]                     # indices of 5 smallest values
np.unique(y, return_counts=True)            # class counts

2.2 Pandas essentials

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import pandas as pd

# Load
df = pd.read_csv("data/raw/train.csv")           # also read_parquet, read_sql, read_excel, read_json
df.head(); df.info(); df.describe(include="all"); df.shape

# Select
df["age"]; df[["age", "fare"]]
df.loc[df["age"] > 30, ["name", "age"]]           # label-based, boolean mask
df.iloc[:5, :3]                                   # position-based
df.query("age > 30 and sex == 'female'")

# Missing values
df.isna().sum()
df["age"].fillna(df["age"].median())
df.dropna(subset=["fare"])

# Types & transforms
df["sex"] = df["sex"].astype("category")
df["log_fare"] = np.log1p(df["fare"])
df["title"] = df["name"].str.extract(r",\s*([^\.]+)\.")
df["date"] = pd.to_datetime(df["date"]); df["month"] = df["date"].dt.month

# Group / aggregate
df.groupby("pclass")["survived"].mean()
df.groupby(["pclass", "sex"]).agg(n=("survived", "size"), rate=("survived", "mean"))
df.pivot_table(values="survived", index="pclass", columns="sex", aggfunc="mean")

# Combine
pd.merge(df, other, on="id", how="left")          # SQL-style join
pd.concat([df1, df2], axis=0)                     # stack rows

# Sort / dedupe
df.sort_values(["pclass", "fare"], ascending=[True, False])
df.drop_duplicates(subset=["id"])

# Apply (slow; prefer vectorized ops)
df["fam"] = df.apply(lambda r: r["sibsp"] + r["parch"] + 1, axis=1)   # better: df["sibsp"] + df["parch"] + 1

# Save
df.to_parquet("data/processed/train.parquet")     # faster + typed; prefer over CSV

Performance rules: vectorize (df[a] + df[b]), avoid iterrows, use categoricals for low-cardinality strings, use Parquet.

2.3 Exploratory Data Analysis (EDA)

EDA is where you find the bugs in your data before they become bugs in your model.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import matplotlib.pyplot as plt
import seaborn as sns

# Target distribution
df["target"].value_counts(normalize=True).plot.bar(); plt.title("class balance"); plt.show()

# Numeric features
df.hist(figsize=(14, 10), bins=30); plt.tight_layout(); plt.show()

# Feature vs target
sns.boxplot(data=df, x="target", y="fare"); plt.show()
sns.barplot(data=df, x="pclass", y="survived"); plt.show()

# Correlations
corr = df.select_dtypes("number").corr()
sns.heatmap(corr, cmap="coolwarm", center=0, annot=False); plt.show()

# Pairwise (small datasets)
sns.pairplot(df[["age", "fare", "pclass", "survived"]], hue="survived"); plt.show()

# Missingness pattern
sns.heatmap(df.isna(), cbar=False); plt.show()

The EDA checklist

1
2
3
4
5
6
7
[ ] What is one row? (unit of analysis). Are there duplicates?
[ ] What is the target? Its distribution? Is it imbalanced?
[ ] For each feature: type, range, missing %, cardinality, obvious errors (age = 999)
[ ] Time: is there a time column? Does the distribution shift over time?
[ ] Leakage suspects: any feature that is a consequence of the target?
[ ] Relationships: which features correlate with the target? with each other?
[ ] Outliers: real or data-entry errors?

Exercise: Load the Titanic dataset (seaborn.load_dataset("titanic")). Compute survival rate by class and sex, plot age distributions by survival, and find at least three data-quality issues.


Part 3: The Math You Actually Need

Working fluency, not proofs. Each concept is tied to where it shows up in ML.

3.1 Linear algebra

Vectors, dot products, norms

A sample is a vector x in R^d. The dot product measures alignment:

\[\mathbf{x}\cdot\mathbf{w} = \sum_i x_i w_i = \|\mathbf{x}\|\,\|\mathbf{w}\|\cos\theta\]
  • A linear model’s prediction is a dot product: ŷ = w·x + b.
  • L2 norm x ₂ = sqrt(sum xᵢ²): Euclidean distance (KNN, K-means), Ridge penalty.
  • L1 norm x ₁ = sumxᵢ: Manhattan distance, Lasso penalty (induces sparsity).
  • Cosine similarity: dot / (norms); used in text and recommenders.
1
2
3
4
x = np.array([1., 2., 3.]); w = np.array([0.5, -1., 2.])
x @ w                                                  # 4.5
np.linalg.norm(x), np.linalg.norm(x, ord=1)            # 3.74, 6.0
x @ w / (np.linalg.norm(x) * np.linalg.norm(w))        # cosine similarity

Matrices

The design matrix X is n × d (rows = samples, columns = features). Key operations:

OperationNumPyWhere it appears
MatmulX @ wevery linear model
TransposeX.Tnormal equations, gradients
Inversenp.linalg.inv(A)closed-form OLS (use solve instead)
Solve Ax = bnp.linalg.solve(A, b)Ridge closed form
Eigendecompositionnp.linalg.eigh(C)PCA on covariance
SVDnp.linalg.svd(X)PCA, low-rank, pseudo-inverse
Ranknp.linalg.matrix_rank(X)multicollinearity

Eigenvalues and SVD (the heart of PCA)

Covariance matrix C = XᵀX/(n-1). Its eigenvectors are the directions of maximum variance; eigenvalues are the variance along them. SVD gives the same result directly from X and is numerically better.

1
2
3
4
5
6
X = np.random.randn(200, 4) @ np.random.randn(4, 4)   # correlated features
Xc = X - X.mean(0)
U, S, Vt = np.linalg.svd(Xc, full_matrices=False)
explained = S**2 / (S**2).sum()
print("variance explained per component:", explained.round(3))
Z = Xc @ Vt[:2].T                                     # project to 2 principal components

3.2 Calculus

Gradients and gradient descent

The gradient ∇L(θ) points uphill. We move downhill:

\[\theta \leftarrow \theta - \eta\,\nabla_\theta L(\theta)\]
1
2
3
4
5
6
7
def f(w):       return (w[0] - 3)**2 + 2 * (w[1] + 1)**2
def grad_f(w):  return np.array([2 * (w[0] - 3), 4 * (w[1] + 1)])

w = np.zeros(2)
for _ in range(100):
    w -= 0.1 * grad_f(w)
print(w)   # -> [3, -1]

The two gradients you must know by heart

For MSE loss L = (1/n) Xw - y ²:
\[\nabla_w L = \frac{2}{n} X^\top (Xw - y)\]

For logistic loss with p = σ(Xw):

\[\nabla_w L = \frac{1}{n} X^\top (p - y)\]

Numerical gradient check

1
2
3
4
5
6
7
8
def numerical_grad(f, w, eps=1e-6):
    g = np.zeros_like(w)
    for i in range(len(w)):
        wp = w.copy(); wp[i] += eps
        wm = w.copy(); wm[i] -= eps
        g[i] = (f(wp) - f(wm)) / (2 * eps)
    return g
print(numerical_grad(f, np.array([1., 1.])), grad_f(np.array([1., 1.])))

Convexity

A function is convex if the line between any two points lies above the curve. Convex losses (MSE, logistic, hinge) have a single global minimum, so gradient descent is guaranteed to find it. Neural nets are not convex; most classical ML is.

1
2
3
4
   convex (one minimum)         non-convex (many)
       \       /                   \  /\    /\
        \     /                     \/  \  /  \
         \___/                           \/

3.3 Probability

Basics

  • P(A ∪ B) = P(A) + P(B) - P(A ∩ B)
  • Conditional: P(AB) = P(A ∩ B)/P(B)
  • Independence: P(A ∩ B) = P(A)P(B)
  • Bayes’ rule:
\[P(\theta\mid D) = \frac{P(D\mid\theta)\,P(\theta)}{P(D)} \qquad \text{posterior} \propto \text{likelihood} \times \text{prior}\]

Naive Bayes is Bayes’ rule with an independence assumption. Regularization is a prior. Bayesian optimization uses posteriors over the objective.

Random variables

  • Expectation E[X] = sum x·p(x); Variance Var[X] = E[(X - E[X])²]; Covariance Cov[X,Y] = E[(X-μx)(Y-μy)].
  • Correlation = Cov / (σx σy), in [-1, 1]. Measures linear relationship only.

Distributions you will meet

DistributionModelsUsed in
Bernoulli(p)one yes/nologistic regression output
Binomial(n, p)count of successesA/B tests
Categoricalone of K classessoftmax, Naive Bayes
Poisson(λ)counts per intervalPoisson regression
Gaussian N(μ, σ²)continuous, symmetriclinear regression noise, GMM, LDA
Exponentialwaiting timessurvival analysis
Uniformno preferencerandom init, random search
1
2
3
4
from scipy import stats
stats.norm(0, 1).pdf(1.0), stats.norm(0, 1).cdf(1.96)      # 0.242, 0.975
stats.binom(10, 0.5).pmf(5)                                # 0.246
x = stats.norm(5, 2).rvs(1000)                              # sample

Maximum likelihood (why losses look the way they do)

Pick the parameters that make the observed data most probable:

\[\hat\theta = \arg\max_\theta \prod_i p(y_i \mid x_i;\theta) = \arg\max_\theta \sum_i \log p(y_i\mid x_i;\theta)\]
  • Gaussian noise on y -> maximizing likelihood = minimizing MSE.
  • Bernoulli y -> maximizing likelihood = minimizing log loss (cross-entropy).
  • Adding a Gaussian prior on w -> Ridge. Laplace prior -> Lasso. (That is MAP estimation.)

3.4 Statistics

Descriptive

Mean, median (robust to outliers), mode, variance, std, quantiles, IQR, skew, kurtosis.

1
2
s = df["fare"]
s.mean(), s.median(), s.std(), s.quantile([.25, .5, .75]), s.skew()

Central Limit Theorem

The mean of n i.i.d. samples is approximately Gaussian with std σ/sqrt(n), regardless of the original distribution. This is why we can put confidence intervals on model metrics.

1
2
3
4
5
6
7
# Bootstrap confidence interval for a metric
def bootstrap_ci(metric_fn, y_true, y_pred, n=1000, alpha=0.05, seed=0):
    rng = np.random.default_rng(seed); vals = []
    for _ in range(n):
        idx = rng.integers(0, len(y_true), len(y_true))
        vals.append(metric_fn(y_true[idx], y_pred[idx]))
    return np.percentile(vals, [100 * alpha / 2, 100 * (1 - alpha / 2)])

Hypothesis testing (enough for ML)

  • Null hypothesis H0: “no effect”. p-value: probability of seeing data at least this extreme if H0 is true. Reject if p < α (0.05 by convention).
  • t-test: compare means of two groups (stats.ttest_ind). Paired t-test to compare two models across the same CV folds.
  • Chi-squared: association between categorical variables (stats.chi2_contingency).
  • Multiple comparisons: testing 20 features at α = 0.05 will find 1 “significant” by chance. Use Bonferroni / FDR correction.
  • Correlation is not causation. Confounders exist. Randomized experiments (A/B tests) are the gold standard.
1
2
3
4
from scipy import stats
a = np.array([0.81, 0.83, 0.80, 0.82, 0.84])   # model A accuracy on 5 folds
b = np.array([0.79, 0.82, 0.79, 0.81, 0.82])   # model B
print(stats.ttest_rel(a, b))                    # paired test

3.5 Information theory

  • Entropy H(p) = -sum p log p: uncertainty. Max when uniform. Used for decision tree splits.
  • Gini impurity 1 - sum p²: cheaper alternative to entropy (CART default).
  • Cross-entropy H(p, q) = -sum p log q: the log-loss metric.
  • KL divergence KL(p q) = sum p log(p/q): how different two distributions are. Used in t-SNE, variational methods, drift detection.
  • Mutual information I(X;Y): how much knowing X reduces uncertainty about Y. Used for feature selection (mutual_info_classif).
1
2
3
4
def entropy(p):  p = p[p > 0]; return -(p * np.log2(p)).sum()
def gini(p):     return 1 - (p**2).sum()
p = np.array([0.5, 0.5]);  print(entropy(p), gini(p))   # 1.0, 0.5 (max impurity)
p = np.array([0.9, 0.1]);  print(entropy(p), gini(p))   # 0.47, 0.18

3.6 Math -> code map

MathCode
ŷ = Xw + bX @ w + b
σ(z) = 1/(1+e^-z)1 / (1 + np.exp(-z)) or scipy.special.expit
softmax(z)scipy.special.softmax(z, axis=1)
∇w MSE2/n * X.T @ (X @ w - y)
(XᵀX)^-1 Xᵀ ynp.linalg.lstsq(X, y, rcond=None)[0]
Entropy of label vectorentropy(np.bincount(y) / len(y))
Pairwise distancesscipy.spatial.distance.cdist(A, B)
Exercise: Derive the gradient of the logistic loss by hand and verify it numerically. Then show that adding (λ/2) w ² to the MSE loss changes the closed-form solution to w = (XᵀX + λI)^-1 Xᵀy.

Part 4: Machine Learning Fundamentals

4.1 What is machine learning?

A program that improves at a task with experience (data). Concretely: choose a model family f_θ, a loss L measuring how wrong f_θ is on data, and an optimizer that finds θ minimizing L. Then check that it also works on new data (generalization).

1
2
3
4
5
6
          ┌──────────┐    prediction ŷ    ┌────────────┐
   x ───> │  f_θ(x)  │ ─────────────────> │  Loss      │ <─── true y
          └──────────┘                    │  L(ŷ, y)   │
               ▲                          └─────┬──────┘
               │          update θ              │
               └────────────────────────────────┘

4.2 The learning paradigms

mindmap
  root((Machine Learning))
    Supervised
      Classification
        binary, multiclass, multilabel
      Regression
      Ranking
    Unsupervised
      Clustering
      Dimensionality reduction
      Density estimation / anomaly detection
      Association rules
    Semi-supervised
    Reinforcement
ParadigmDataGoalExamples
Supervised(x, y) pairspredict y from xspam filter, house prices, churn
Unsupervisedx onlyfind structurecustomer segments, compression, outliers
Semi-supervisedfew labeled + many unlabeleduse bothlabel propagation
Reinforcementactions, rewardsmaximize rewardgames, bidding, control

4.3 The ML workflow

flowchart LR
    A[Define problem & metric] --> B[Collect / load data]
    B --> C[EDA]
    C --> D[Split: train / val / test]
    D --> E[Preprocess + feature engineering]
    E --> F[Baseline model]
    F --> G[Better models + tuning]
    G --> H[Evaluate on validation]
    H -->|iterate| E
    H --> I[Final test evaluation ONCE]
    I --> J[Deploy + monitor]

Step 1 is the one people skip: what exactly are we predicting, for whom, and what metric reflects business value? A fraud model optimized for accuracy on 0.1% fraud is worthless.

4.4 Train / validation / test

1
2
3
4
5
┌────────────────────────────── all labeled data ──────────────────────────────┐
│ ████████████████████████ train (60-80%) ███████ │ validation (10-20%) │ test (10-20%) │
└─────────────────────────────────────────────────┴─────────────────────┴───────────────┘
   fit parameters                                  pick model /            final honest
                                                   hyperparameters         estimate, touch once
1
2
3
from sklearn.model_selection import train_test_split
X_trainval, X_test, y_trainval, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
X_train, X_val, y_train, y_val = train_test_split(X_trainval, y_trainval, test_size=0.25, stratify=y_trainval, random_state=42)
  • stratify=y keeps class proportions identical across splits.
  • Time series: never shuffle; split by time (train on past, test on future).
  • Grouped data (multiple rows per patient/user): split by group so the same entity is not in both train and test.

4.5 Your first models: baselines

Always start here. If your fancy model cannot beat these, something is wrong.

1
2
3
from sklearn.dummy import DummyClassifier, DummyRegressor
DummyClassifier(strategy="most_frequent").fit(X_train, y_train).score(X_val, y_val)
DummyRegressor(strategy="mean").fit(X_train, y_train).score(X_val, y_val)

4.6 Overfitting, underfitting, bias-variance

1
2
3
4
5
6
7
8
9
10
11
   error
     │
     │ \                                          validation error
     │  \                                     ___/
     │   \                               ____/
     │    \_____             ___________/
     │          ‾‾‾‾‾‾‾‾‾‾‾‾‾       <- sweet spot
     │                        ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ training error
     └────────────────────────────────────────────────> model complexity
        underfitting   │                 │  overfitting
        (high bias)    │                 │  (high variance)

Bias = error from wrong assumptions (model too simple). Variance = error from sensitivity to the particular training sample (model too flexible). Noise = irreducible.

\[\mathbb{E}[(y - \hat f(x))^2] = \underbrace{(\mathbb{E}[\hat f(x)] - f(x))^2}_{\text{bias}^2} + \underbrace{\mathbb{E}[(\hat f(x) - \mathbb{E}[\hat f(x)])^2]}_{\text{variance}} + \sigma^2\]
1
2
3
4
5
6
7
8
9
10
11
12
13
# See it: polynomial degree vs train/val error
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import make_pipeline
from sklearn.metrics import mean_squared_error

rng = np.random.default_rng(0)
x = np.sort(rng.uniform(-3, 3, 60)); y = np.sin(x) + rng.normal(0, 0.3, 60)
xt, xv, yt, yv = train_test_split(x, y, test_size=0.3, random_state=0)
for deg in [1, 3, 9, 15]:
    m = make_pipeline(PolynomialFeatures(deg), LinearRegression()).fit(xt[:, None], yt)
    print(f"deg {deg:2d}  train {mean_squared_error(yt, m.predict(xt[:, None])):.3f}  val {mean_squared_error(yv, m.predict(xv[:, None])):.3f}")
# deg 1: underfits (both high). deg 3: good. deg 15: overfits (train tiny, val large).
SymptomDiagnosisFixes
Train error high, val error highunderfitting / high biasmore features, more complex model, less regularization, fix bugs
Train error low, val error highoverfitting / high variancemore data, regularization, simpler model, feature selection, ensembles
Both low, production baddistribution shift or leakageaudit the pipeline, re-split by time/group

4.7 Cross-validation

A single validation split is noisy. k-fold CV uses every sample for validation exactly once.

1
2
3
4
5
  5-fold CV:   data = [ F1 | F2 | F3 | F4 | F5 ]
  round 1:  train F2..F5, validate F1
  round 2:  train F1,F3..F5, validate F2
  ...
  score = mean (and std!) of the 5 validation scores
1
2
3
4
from sklearn.model_selection import cross_val_score, StratifiedKFold
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X_trainval, y_trainval, cv=cv, scoring="roc_auc")
print(f"AUC {scores.mean():.4f} +/- {scores.std():.4f}")

Report the standard deviation. A difference of 0.002 between models with std 0.01 is noise.

4.8 Loss vs metric

  • Loss: what the optimizer minimizes (must be differentiable / optimizable): MSE, log loss, hinge.
  • Metric: what you actually care about: accuracy, F1, AUC, revenue. Often not differentiable.
  • Choose the metric from the business problem first, then pick a loss that is a good proxy.

4.9 The no-free-lunch principle

No algorithm is best for every problem. In practice on tabular data: gradient-boosted trees usually win; linear models win when data is small or features are many and sparse; KNN/SVM shine on small, clean, low-dimensional data. Always try several.

Project: On the Titanic dataset, build a DummyClassifier baseline, then logistic regression, with 5-fold stratified CV. Plot the bias-variance curve for a decision tree by varying max_depth from 1 to 20 and recording train vs CV accuracy.


Part 5: Data Preprocessing & Feature Engineering

“Garbage in, garbage out.” Feature engineering is where most of the practical wins in classical ML come from.

5.1 The preprocessing map

flowchart LR
    R[Raw data] --> C[Clean: dedupe, fix types, handle errors]
    C --> M[Missing values]
    M --> O[Outliers]
    O --> E[Encode categoricals]
    E --> S[Scale numerics]
    S --> F[Create features]
    F --> Sel[Select features]
    Sel --> X[Model-ready matrix]

Golden rule: fit every transformation on the training set only, then apply to validation/test. Anything else is leakage.

5.2 Missing values

1
df.isna().mean().sort_values(ascending=False)    # fraction missing per column
StrategyWhen
Drop rowsfew rows affected, missing at random
Drop columnmostly missing and uninformative
Impute mean/mediannumeric, low missingness (median is robust)
Impute mode / “missing” categorycategorical
Add a “was_missing” indicatormissingness itself is informative (very often!)
Model-based (KNN, iterative)when relationships matter and data is small
Let the model handle itLightGBM/XGBoost/CatBoost handle NaN natively
1
2
3
from sklearn.impute import SimpleImputer, KNNImputer
num_imp = SimpleImputer(strategy="median", add_indicator=True)
cat_imp = SimpleImputer(strategy="constant", fill_value="missing")

5.3 Outliers

Detect: z-score (z> 3), IQR rule (outside Q1 - 1.5 IQR, Q3 + 1.5 IQR), Isolation Forest.
1
2
q1, q3 = df["fare"].quantile([.25, .75]); iqr = q3 - q1
mask = (df["fare"] < q1 - 1.5 * iqr) | (df["fare"] > q3 + 1.5 * iqr)

Treat: remove (if data error), clip/winsorize (np.clip), log-transform, or use robust models (trees do not care).

5.4 Encoding categorical variables

1
2
3
4
5
6
  color: red, green, blue
  one-hot:        red=[1,0,0] green=[0,1,0] blue=[0,0,1]      (linear models, KNN, SVM)
  ordinal:        small=0 medium=1 large=2                    (only when order is real)
  target encode:  red -> mean(y | color=red)                  (high-cardinality; must be done inside CV!)
  frequency:      red -> count(red)/n
  hashing:        hash(red) % 1024                            (huge vocabularies, streaming)
1
2
3
4
5
6
7
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
ohe = OneHotEncoder(handle_unknown="ignore", sparse_output=False, min_frequency=10)   # rare -> "infrequent"
ord_enc = OrdinalEncoder(categories=[["small", "medium", "large"]])

# Target encoding with CV to avoid leakage (sklearn >= 1.3)
from sklearn.preprocessing import TargetEncoder
te = TargetEncoder(target_type="binary", cv=5, smooth="auto")

Tree libraries (LightGBM, CatBoost) accept categorical columns directly and usually do better than one-hot for high cardinality.

5.5 Scaling numeric features

ScalerFormulaUse
StandardScaler(x - mean)/stddefault for linear models, SVM, KNN, PCA
MinMaxScaler(x - min)/(max - min)bounded inputs, neural nets
RobustScaler(x - median)/IQRheavy outliers
PowerTransformer / np.log1pmake skewed data Gaussian-ishincomes, prices, counts
none tree-based models (invariant to monotonic transforms)
1
2
3
from sklearn.preprocessing import StandardScaler
sc = StandardScaler().fit(X_train)          # fit on train only
X_train_s, X_val_s = sc.transform(X_train), sc.transform(X_val)

5.6 Creating features

This is the creative part. Ideas that work again and again:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Numeric interactions and ratios
df["fare_per_person"] = df["fare"] / (df["sibsp"] + df["parch"] + 1)
df["area_x_rooms"] = df["area"] * df["rooms"]

# Binning (turns a nonlinear effect into something a linear model can capture)
df["age_bin"] = pd.cut(df["age"], bins=[0, 12, 18, 35, 60, 120], labels=False)

# Dates
d = pd.to_datetime(df["timestamp"])
df["hour"], df["dow"], df["month"] = d.dt.hour, d.dt.dayofweek, d.dt.month
df["is_weekend"] = (d.dt.dayofweek >= 5).astype(int)
df["days_since_signup"] = (d - pd.to_datetime(df["signup"])).dt.days
# cyclic encoding so hour 23 is close to hour 0
df["hour_sin"], df["hour_cos"] = np.sin(2 * np.pi * df["hour"] / 24), np.cos(2 * np.pi * df["hour"] / 24)

# Text
df["name_len"] = df["name"].str.len()
df["title"] = df["name"].str.extract(r",\s*([^\.]+)\.")

# Aggregations (group statistics; compute on TRAIN only, then merge)
agg = train.groupby("user_id")["amount"].agg(["mean", "std", "count"]).add_prefix("user_amount_")
df = df.merge(agg, left_on="user_id", right_index=True, how="left")

# Polynomial features for linear models
from sklearn.preprocessing import PolynomialFeatures
PolynomialFeatures(degree=2, include_bias=False)

# Domain knowledge beats everything: BMI = weight/height^2, speed = distance/time, ...

5.7 Feature selection

MethodIdeaTool
Filterscore each feature independentlySelectKBest(f_classif / mutual_info_classif), correlation threshold, VarianceThreshold
Wrappersearch subsets using model performanceRFE, SequentialFeatureSelector
Embeddedmodel does it during trainingLasso (zero coefficients), tree feature importances, SelectFromModel
Permutation importanceshuffle a feature, measure droppermutation_importance (Part 18)
1
2
3
from sklearn.feature_selection import SelectKBest, mutual_info_classif, SelectFromModel
from sklearn.linear_model import LassoCV
selector = SelectFromModel(LassoCV(cv=5), threshold="median")

Remove features that are: constant, duplicated, nearly perfectly correlated with another, or leaking.

5.8 Pipelines and ColumnTransformer (the professional way)

Everything above must happen inside a Pipeline so it is (a) fit on train only, (b) applied identically at inference, (c) tunable with CV.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression

num_cols = ["age", "fare", "sibsp", "parch"]
cat_cols = ["sex", "embarked", "pclass"]

numeric = Pipeline([("impute", SimpleImputer(strategy="median", add_indicator=True)),
                    ("scale", StandardScaler())])
categorical = Pipeline([("impute", SimpleImputer(strategy="constant", fill_value="missing")),
                        ("onehot", OneHotEncoder(handle_unknown="ignore"))])

pre = ColumnTransformer([("num", numeric, num_cols),
                         ("cat", categorical, cat_cols)], remainder="drop")

clf = Pipeline([("pre", pre), ("model", LogisticRegression(max_iter=1000))])
clf.fit(X_train, y_train)
print(clf.score(X_val, y_val))

# Feature names after transformation
clf[:-1].get_feature_names_out()
1
2
3
4
5
6
7
8
9
10
11
12
   raw DataFrame
        │
        ▼ ColumnTransformer
   ┌────────────┬────────────┐
   │ numeric    │ categorical│
   │ impute     │ impute     │
   │ scale      │ one-hot    │
   └─────┬──────┴──────┬─────┘
         └──── concat ─┘
                │
                ▼
             model

Custom transformers

1
2
3
4
5
6
7
8
9
10
from sklearn.base import BaseEstimator, TransformerMixin

class FamilyFeatures(BaseEstimator, TransformerMixin):
    def fit(self, X, y=None): return self
    def transform(self, X):
        X = X.copy()
        X["family_size"] = X["sibsp"] + X["parch"] + 1
        X["is_alone"] = (X["family_size"] == 1).astype(int)
        return X
# Use: Pipeline([("fam", FamilyFeatures()), ("pre", pre), ("model", ...)])

5.9 Data leakage: the silent killer

Leakage = information about the target sneaking into features or into the fitting process.

TypeExampleFix
Target leakage“refund_issued” as a feature for predicting fraudremove features that happen after the target
Train/test contaminationscaling with full-data meanfit transformers inside the pipeline on train only
Duplicatessame row in train and testdedupe before splitting
Temporalusing future rows to predict pasttime-based split
Groupsame patient in train and testGroupKFold
Target encoding without CVencodes each row with its own labelTargetEncoder(cv=...)

Smell test: if a single feature gives 99% accuracy, investigate it before celebrating.

Project: Build a full Pipeline + ColumnTransformer for the Titanic data including a custom transformer. Measure CV accuracy with and without each engineered feature (ablation). Introduce a deliberate leak (fit the scaler on all data) and show that CV becomes over-optimistic.


Part 6: Linear Models

Linear models are fast, interpretable, hard to overfit with regularization, and a strong baseline for everything. Understand them deeply.

6.1 Linear regression

Model: ŷ = w·x + b = Xw (absorb b by adding a column of 1s). Loss: MSE.

\[L(w) = \frac{1}{n}\|Xw - y\|^2\]

Closed form (normal equations)

Set gradient to zero: Xᵀ(Xw - y) = 0, so w = (XᵀX)^-1 Xᵀ y.

1
2
3
X1 = np.c_[np.ones(len(X)), X]                       # add bias column
w = np.linalg.solve(X1.T @ X1, X1.T @ y)             # never use inv() directly
# or: w = np.linalg.lstsq(X1, y, rcond=None)[0]      # SVD-based, most stable

Cost O(nd² + d³): fine up to ~10k features. Beyond that, or for streaming, use gradient descent.

Gradient descent from scratch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class LinearRegressionGD:
    def __init__(self, lr=0.01, epochs=1000, l2=0.0):
        self.lr, self.epochs, self.l2 = lr, epochs, l2
    def fit(self, X, y):
        n, d = X.shape
        self.w = np.zeros(d); self.b = 0.0
        for _ in range(self.epochs):
            y_hat = X @ self.w + self.b
            err = y_hat - y
            dw = 2 / n * X.T @ err + 2 * self.l2 * self.w     # L2 term = Ridge
            db = 2 / n * err.sum()
            self.w -= self.lr * dw; self.b -= self.lr * db
        return self
    def predict(self, X): return X @ self.w + self.b

from sklearn.datasets import make_regression
X, y = make_regression(n_samples=500, n_features=5, noise=10, random_state=0)
X = (X - X.mean(0)) / X.std(0)                        # GD needs scaled features
m = LinearRegressionGD(lr=0.05, epochs=500).fit(X, y)
from sklearn.linear_model import LinearRegression
print(np.allclose(m.w, LinearRegression().fit(X, y).coef_, atol=0.5))

Assumptions (and what breaks when they fail)

AssumptionIf violatedCheck
Linearitybiased predictionsresiduals vs fitted plot shows pattern
Independent errorswrong standard errorstime series: Durbin-Watson
Constant variance (homoscedastic)wrong CIsfunnel shape in residual plot
Normal errorsCIs off (predictions fine)Q-Q plot
No multicollinearityunstable coefficientsVIF > 10
1
2
3
# Residual diagnostics
res = y_val - model.predict(X_val)
plt.scatter(model.predict(X_val), res, s=5); plt.axhline(0, c="r"); plt.xlabel("fitted"); plt.ylabel("residual")

6.2 Polynomial regression

Linear in the parameters, nonlinear in the inputs: add x², x³, x₁x₂ as features. Powerful but overfits fast; pair with regularization.

1
poly_ridge = make_pipeline(StandardScaler(), PolynomialFeatures(3), Ridge(alpha=1.0))

6.3 Regularization: Ridge, Lasso, ElasticNet

Add a penalty on the size of w to the loss. Shrinks coefficients, reduces variance, handles multicollinearity.

NamePenaltyEffectClosed form?
Ridge (L2)α sum wᵢ²shrinks all coefficients smoothlyyes: (XᵀX + αI)^-1 Xᵀy
Lasso (L1)α sum abs(wᵢ)drives some coefficients to exactly 0 (feature selection)no (coordinate descent)
ElasticNetmix of bothsparse + stable with correlated featuresno
1
2
3
4
5
6
   L2 ball (circle)             L1 ball (diamond)
        ___                          /\
      /     \   loss contours       /  \   contour touches the
     |   •   |  touch the circle   <  •  >  diamond at a CORNER
      \ ___ /   anywhere            \  /   -> some w_i = 0 exactly
                                     \/
1
2
3
4
5
from sklearn.linear_model import Ridge, Lasso, ElasticNet, RidgeCV, LassoCV
ridge = make_pipeline(StandardScaler(), RidgeCV(alphas=np.logspace(-3, 3, 20)))
lasso = make_pipeline(StandardScaler(), LassoCV(cv=5))
lasso.fit(X_train, y_train)
print("nonzero coefs:", (lasso[-1].coef_ != 0).sum())

Always scale before regularizing, otherwise the penalty depends on feature units.

6.4 Logistic regression (classification)

Squash the linear score through a sigmoid to get a probability:

\[p(y=1\mid x) = \sigma(w\cdot x + b) = \frac{1}{1 + e^{-(w\cdot x + b)}}\]

Loss: binary cross-entropy / log loss (= negative log-likelihood of Bernoulli):

\[L = -\frac{1}{n}\sum_i\left[y_i\log p_i + (1-y_i)\log(1-p_i)\right],\qquad \nabla_w L = \frac{1}{n}X^\top(p - y)\]
1
2
3
4
5
6
7
8
   p
 1 ┤              ____------
   │          _.-'
.5 ┤       _.'          decision boundary: w.x + b = 0  (p = 0.5)
   │   _.-'
 0 ┤--'
   └────────────┴──────────────> w.x + b
                0

From scratch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def sigmoid(z): return 1 / (1 + np.exp(-z))

class LogisticRegressionGD:
    def __init__(self, lr=0.1, epochs=1000, l2=0.0):
        self.lr, self.epochs, self.l2 = lr, epochs, l2
    def fit(self, X, y):
        n, d = X.shape; self.w = np.zeros(d); self.b = 0.0
        for _ in range(self.epochs):
            p = sigmoid(X @ self.w + self.b)
            dw = X.T @ (p - y) / n + self.l2 * self.w
            db = (p - y).mean()
            self.w -= self.lr * dw; self.b -= self.lr * db
        return self
    def predict_proba(self, X): return sigmoid(X @ self.w + self.b)
    def predict(self, X, thr=0.5): return (self.predict_proba(X) >= thr).astype(int)

from sklearn.datasets import load_breast_cancer
X, y = load_breast_cancer(return_X_y=True)
X = (X - X.mean(0)) / X.std(0)
Xt, Xv, yt, yv = train_test_split(X, y, test_size=0.2, stratify=y, random_state=0)
m = LogisticRegressionGD(lr=0.1, epochs=2000, l2=1e-3).fit(Xt, yt)
print("accuracy:", (m.predict(Xv) == yv).mean())     # ~0.97

In scikit-learn

1
2
3
4
5
from sklearn.linear_model import LogisticRegression
lr = make_pipeline(StandardScaler(), LogisticRegression(C=1.0, penalty="l2", max_iter=1000, class_weight="balanced"))
# C = 1/alpha : SMALLER C = STRONGER regularization
lr.fit(X_train, y_train)
lr.predict_proba(X_val)[:, 1]

Interpreting coefficients

Each unit increase in xⱼ multiplies the odds p/(1-p) by e^(wⱼ). With standardized features,wⱼranks feature influence.
1
2
coefs = pd.Series(lr[-1].coef_[0], index=feature_names).sort_values()
np.exp(coefs)      # odds ratios

6.5 Softmax (multinomial) regression

K classes, K weight vectors. p(y=kx) = exp(wₖ·x) / sum_j exp(wⱼ·x). Loss: cross-entropy. LogisticRegression does this automatically for multiclass.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def softmax(Z): Z = Z - Z.max(1, keepdims=True); e = np.exp(Z); return e / e.sum(1, keepdims=True)

class SoftmaxRegression:
    def __init__(self, lr=0.1, epochs=500): self.lr, self.epochs = lr, epochs
    def fit(self, X, y):
        n, d = X.shape; K = y.max() + 1
        self.W = np.zeros((d, K)); self.b = np.zeros(K)
        Y = np.eye(K)[y]                                    # one-hot
        for _ in range(self.epochs):
            P = softmax(X @ self.W + self.b)
            self.W -= self.lr * X.T @ (P - Y) / n
            self.b -= self.lr * (P - Y).mean(0)
        return self
    def predict(self, X): return (X @ self.W + self.b).argmax(1)

One-vs-rest (train K binary classifiers) is the alternative; needed for models that are inherently binary (SVM).

6.6 Generalized linear models (GLMs)

Same idea, different link/distribution: Poisson regression for counts (PoissonRegressor), Gamma/Tweedie for positive skewed targets (insurance claims). Logistic regression is the GLM with a Bernoulli distribution and logit link.

6.7 Online learning with SGD

SGDClassifier / SGDRegressor train on mini-batches, handle millions of rows, support partial_fit for streaming.

1
2
3
4
from sklearn.linear_model import SGDClassifier
sgd = SGDClassifier(loss="log_loss", penalty="elasticnet", alpha=1e-4, max_iter=50, random_state=0)
for X_batch, y_batch in stream:
    sgd.partial_fit(X_batch, y_batch, classes=[0, 1])

6.8 When to use linear models

  • Small data, many features (text, genomics): regularized linear models are often best.
  • Need interpretability or statistical inference (statsmodels gives p-values, CIs).
  • Very large data where speed matters.
  • As a baseline, always.

Project: Implement Ridge regression with the closed form and with GD; confirm they match. On the California housing dataset compare Linear, Ridge, Lasso, ElasticNet with CV; plot the Lasso regularization path (lasso_path). Then implement logistic regression with L2 from scratch and match sklearn’s coefficients.


Part 7: k-Nearest Neighbors

7.1 The idea

No training. To predict a new point, find the k closest training points and vote (classification) or average (regression). The simplest “memorize the data” model.

1
2
3
4
5
        ○ ○
      ○   ○     ?  <- k=3: two ● and one ○ nearby -> predict ●
    ○    ● ●
       ●   ●
      ●  ●

7.2 From scratch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class KNN:
    def __init__(self, k=5, task="clf"): self.k, self.task = k, task
    def fit(self, X, y): self.X, self.y = X, y; return self
    def predict(self, Xq):
        # squared Euclidean distances via (a-b)^2 = a^2 - 2ab + b^2  (no loops)
        d2 = (Xq**2).sum(1)[:, None] - 2 * Xq @ self.X.T + (self.X**2).sum(1)[None, :]
        idx = np.argpartition(d2, self.k, axis=1)[:, :self.k]        # k smallest per row
        neigh = self.y[idx]
        if self.task == "clf":
            return np.array([np.bincount(row).argmax() for row in neigh])
        return neigh.mean(1)

from sklearn.datasets import load_iris
X, y = load_iris(return_X_y=True); X = (X - X.mean(0)) / X.std(0)
Xt, Xv, yt, yv = train_test_split(X, y, test_size=0.3, random_state=0)
print((KNN(k=5).fit(Xt, yt).predict(Xv) == yv).mean())

7.3 Key decisions

ChoiceEffect
k smalllow bias, high variance (jagged boundary)
k largesmooth boundary, may underfit; k = sqrt(n) is a common start
Distance metricEuclidean (default), Manhattan, cosine (text), Hamming (binary)
Weightingweights="distance": closer neighbors count more
Scalingmandatory: a feature in dollars dominates one in years
1
2
from sklearn.neighbors import KNeighborsClassifier
knn = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=15, weights="distance"))

7.4 The curse of dimensionality

In high dimensions all points become roughly equidistant and “nearest” stops meaning anything. Also, data needed grows exponentially with dimension.

1
2
3
4
5
for d in [1, 2, 10, 100, 1000]:
    P = np.random.rand(1000, d)
    dist = np.linalg.norm(P[0] - P[1:], axis=1)
    print(f"d={d:5d}  min/max distance ratio = {dist.min()/dist.max():.3f}")
# ratio -> 1 as d grows: nearest and farthest neighbor are nearly the same distance

Mitigations: feature selection, PCA, learned embeddings.

7.5 Speed

Brute force is O(n·d) per query. For large n use KD-trees / Ball trees (algorithm="kd_tree", good for d < 20) or approximate nearest neighbors (FAISS, Annoy, HNSW) which power vector search and recommenders.

7.6 Where KNN wins

Small, low-dimensional, well-scaled data; recommender “similar items”; anomaly detection (distance to k-th neighbor); as an imputer (KNNImputer).

Project: On the MNIST digits (load_digits), plot validation accuracy vs k for k in 1..50. Apply PCA to 20 dimensions first and compare speed and accuracy. Implement distance-weighted voting in your from-scratch KNN.


Part 8: Decision Trees

8.1 The idea

Ask a sequence of yes/no questions about features until you can make a prediction. Each leaf holds a class (or a value). Trees are interpretable, need no scaling, handle mixed types and nonlinearity, and are the building block of random forests and gradient boosting.

1
2
3
4
5
6
7
8
9
10
                      ┌─────────────────┐
                      │   sex == male?  │
                      └───────┬─────────┘
                       yes    │     no
                    ┌─────────┴─────────┐
            ┌───────▼───────┐    ┌──────▼──────┐
            │  age > 9.5?   │    │ pclass == 3?│
            └───┬───────┬───┘    └───┬─────┬───┘
              yes      no          yes     no
             died   survived      died  survived

8.2 How splits are chosen

At each node, try every feature and every threshold; pick the split that most reduces impurity.

Classification impurity of a node with class proportions p:

  • Gini: G = 1 - sum pₖ²
  • Entropy: H = -sum pₖ log₂ pₖ

Information gain of a split = impurity(parent) - weighted average impurity(children).

Regression impurity: variance (MSE) of targets in the node; the leaf predicts the mean.

1
2
3
4
5
   parent: [50 ●, 50 ○]   Gini = 1 - (0.5² + 0.5²) = 0.5
       split on x < 3
   left:  [45 ●, 5 ○]     Gini = 1 - (0.9² + 0.1²) = 0.18
   right: [5 ●, 45 ○]     Gini = 0.18
   gain = 0.5 - (0.5*0.18 + 0.5*0.18) = 0.32

8.3 CART from scratch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class Node:
    def __init__(self, feature=None, threshold=None, left=None, right=None, value=None):
        self.feature, self.threshold, self.left, self.right, self.value = feature, threshold, left, right, value
    def is_leaf(self): return self.value is not None

class DecisionTree:
    def __init__(self, max_depth=5, min_samples_split=2, task="clf"):
        self.max_depth, self.min_samples_split, self.task = max_depth, min_samples_split, task

    def _impurity(self, y):
        if self.task == "clf":
            p = np.bincount(y, minlength=self.n_classes) / len(y)
            return 1 - (p**2).sum()                       # Gini
        return y.var()                                    # MSE for regression

    def _leaf_value(self, y):
        return np.bincount(y, minlength=self.n_classes).argmax() if self.task == "clf" else y.mean()

    def _best_split(self, X, y):
        n, d = X.shape; parent = self._impurity(y)
        best_gain, best = 0.0, (None, None)
        for j in range(d):
            order = np.argsort(X[:, j]); xs, ys = X[order, j], y[order]
            for i in range(1, n):
                if xs[i] == xs[i - 1]: continue           # can't split between equal values
                left, right = ys[:i], ys[i:]
                gain = parent - (len(left) * self._impurity(left) + len(right) * self._impurity(right)) / n
                if gain > best_gain:
                    best_gain, best = gain, (j, (xs[i] + xs[i - 1]) / 2)
        return best_gain, best

    def _grow(self, X, y, depth):
        if depth >= self.max_depth or len(y) < self.min_samples_split or len(np.unique(y)) == 1:
            return Node(value=self._leaf_value(y))
        gain, (j, thr) = self._best_split(X, y)
        if gain == 0: return Node(value=self._leaf_value(y))
        mask = X[:, j] <= thr
        return Node(j, thr, self._grow(X[mask], y[mask], depth + 1), self._grow(X[~mask], y[~mask], depth + 1))

    def fit(self, X, y):
        self.n_classes = int(y.max()) + 1 if self.task == "clf" else None
        self.root = self._grow(X, y, 0); return self

    def _predict_one(self, x, node):
        while not node.is_leaf():
            node = node.left if x[node.feature] <= node.threshold else node.right
        return node.value

    def predict(self, X): return np.array([self._predict_one(x, self.root) for x in X])

X, y = load_breast_cancer(return_X_y=True)
Xt, Xv, yt, yv = train_test_split(X, y, test_size=0.2, random_state=0)
print("from-scratch tree acc:", (DecisionTree(max_depth=4).fit(Xt, yt).predict(Xv) == yv).mean())

(The real implementation is O(n log n) per feature using cumulative class counts, and uses histograms for speed in LightGBM.)

8.4 In scikit-learn

1
2
3
4
5
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor, plot_tree, export_text
tree = DecisionTreeClassifier(max_depth=4, min_samples_leaf=5, criterion="gini", random_state=0).fit(Xt, yt)
print(export_text(tree, feature_names=list(feature_names)))
plt.figure(figsize=(20, 8)); plot_tree(tree, feature_names=feature_names, filled=True, fontsize=7); plt.show()
tree.feature_importances_            # total impurity decrease per feature

8.5 Controlling overfitting

An unconstrained tree memorizes the training set (100% train accuracy, poor validation). Control it with:

HyperparameterEffect
max_depththe big one; 3-10 typical
min_samples_leafeach leaf must have at least this many samples; 1-50
min_samples_split 
max_featuresconsider only a random subset per split (used by random forests)
ccp_alphacost-complexity (post-)pruning: penalize number of leaves
1
2
3
4
5
# Cost-complexity pruning path
path = DecisionTreeClassifier(random_state=0).cost_complexity_pruning_path(Xt, yt)
for a in path.ccp_alphas[::10]:
    t = DecisionTreeClassifier(ccp_alpha=a, random_state=0).fit(Xt, yt)
    print(f"alpha={a:.4f} leaves={t.get_n_leaves():3d} train={t.score(Xt, yt):.3f} val={t.score(Xv, yv):.3f}")

8.6 Strengths and weaknesses

ProsCons
interpretable, visualizablehigh variance: small data changes -> different tree
no scaling, handles mixed types, missing values (sklearn >= 1.3)axis-aligned splits only (diagonal boundaries need many splits)
captures interactions and nonlinearity automaticallypoor at extrapolation (predicts constants outside training range)
fast to train and predictgreedy: locally optimal splits

The high variance is exactly what ensembles fix (Part 9).

Project: Add entropy as an alternative criterion and min_samples_leaf to the from-scratch tree. Plot the decision boundary of a depth-1, 3, 10, and unlimited tree on make_moons data. Find the ccp_alpha that maximizes CV accuracy.


Part 9: Ensembles: Bagging, Random Forests, Boosting

Ensembles combine many weak-ish models into one strong one. On tabular data they are the state of the art.

flowchart LR
    E[Ensembles] --> B[Bagging: parallel, reduce variance]
    B --> RF[Random Forest]
    B --> ET[Extra Trees]
    E --> BO[Boosting: sequential, reduce bias]
    BO --> ADA[AdaBoost]
    BO --> GB[Gradient Boosting]
    GB --> XGB[XGBoost / LightGBM / CatBoost / HistGradientBoosting]
    E --> ST[Stacking / Voting: combine different models]

9.1 Why ensembles work

Averaging n independent estimators each with variance σ² gives variance σ²/n. Trees have high variance and low bias: perfect candidates for averaging (bagging). Boosting instead builds a sequence where each model fixes the previous ones’ errors, reducing bias.

9.2 Bagging (Bootstrap AGGregating)

  1. Draw B bootstrap samples (sample n rows with replacement).
  2. Train a tree on each.
  3. Average predictions (regression) or vote (classification).

Each bootstrap sample leaves out ~37% of rows (“out-of-bag”), giving a free validation set.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Bagging:
    def __init__(self, base, n_estimators=50, seed=0):
        self.base, self.n, self.rng = base, n_estimators, np.random.default_rng(seed)
    def fit(self, X, y):
        self.models = []
        for _ in range(self.n):
            idx = self.rng.integers(0, len(X), len(X))
            self.models.append(self.base().fit(X[idx], y[idx]))
        return self
    def predict(self, X):
        votes = np.stack([m.predict(X) for m in self.models])
        return np.array([np.bincount(c).argmax() for c in votes.T])

bag = Bagging(lambda: DecisionTreeClassifier(random_state=None), 50).fit(Xt, yt)
print("single tree:", DecisionTreeClassifier().fit(Xt, yt).score(Xv, yv), " bagging:", (bag.predict(Xv) == yv).mean())

9.3 Random Forest

Bagging + random feature subsets at each split (max_features=sqrt(d) for classification, d/3 or 1.0 for regression). The feature randomness de-correlates the trees, which makes averaging more effective.

1
2
3
4
5
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
rf = RandomForestClassifier(n_estimators=500, max_features="sqrt", min_samples_leaf=2,
                            n_jobs=-1, oob_score=True, random_state=0).fit(X_train, y_train)
print("OOB accuracy:", rf.oob_score_)
pd.Series(rf.feature_importances_, index=feature_names).sort_values().plot.barh()

Tuning: more trees never hurts (just slower); max_features, min_samples_leaf, max_depth control variance. Extra Trees (ExtraTreesClassifier) also randomize the thresholds: faster, sometimes better.

Impurity-based feature_importances_ are biased toward high-cardinality features. Prefer permutation importance (Part 18).

9.4 Boosting: AdaBoost

Train weak learners (stumps) sequentially; reweight misclassified samples so the next learner focuses on them; combine with weights αₜ based on accuracy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class AdaBoost:
    def __init__(self, n_estimators=50): self.n = n_estimators
    def fit(self, X, y):                             # y in {-1, +1}
        n = len(y); w = np.full(n, 1 / n)
        self.models, self.alphas = [], []
        for _ in range(self.n):
            stump = DecisionTreeClassifier(max_depth=1).fit(X, y, sample_weight=w)
            pred = stump.predict(X)
            err = w[pred != y].sum() / w.sum()
            alpha = 0.5 * np.log((1 - err) / (err + 1e-10))
            w = w * np.exp(-alpha * y * pred); w /= w.sum()
            self.models.append(stump); self.alphas.append(alpha)
        return self
    def predict(self, X):
        return np.sign(sum(a * m.predict(X) for a, m in zip(self.alphas, self.models)))

ys = np.where(yt == 1, 1, -1); yvs = np.where(yv == 1, 1, -1)
print("AdaBoost acc:", (AdaBoost(100).fit(Xt, ys).predict(Xv) == yvs).mean())

9.5 Gradient Boosting: the workhorse

Generalizes boosting to any differentiable loss. Each new tree is fit to the negative gradient of the loss (the residuals, for MSE) of the current ensemble, and added with a small learning rate.

\(F_0(x) = \arg\min_c \sum_i L(y_i, c)\) \(r_i^{(m)} = -\frac{\partial L(y_i, F(x_i))}{\partial F(x_i)}\Big|_{F = F_{m-1}}\qquad h_m = \text{tree fit to } r^{(m)}\qquad F_m = F_{m-1} + \eta\, h_m\)

1
2
3
4
5
  F0 = mean(y)
  residuals r1 = y - F0 -> tree h1 fits r1   F1 = F0 + η h1
  residuals r2 = y - F1 -> tree h2 fits r2   F2 = F1 + η h2
  ...
  final prediction = F0 + η (h1 + h2 + ... + hM)

From scratch (regression with MSE)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class GradientBoostingRegressorScratch:
    def __init__(self, n_estimators=100, lr=0.1, max_depth=3):
        self.n, self.lr, self.max_depth = n_estimators, lr, max_depth
    def fit(self, X, y):
        self.f0 = y.mean(); F = np.full(len(y), self.f0); self.trees = []
        for _ in range(self.n):
            residual = y - F                                         # negative gradient of MSE
            t = DecisionTreeRegressor(max_depth=self.max_depth).fit(X, residual)
            F += self.lr * t.predict(X)
            self.trees.append(t)
        return self
    def predict(self, X):
        return self.f0 + self.lr * sum(t.predict(X) for t in self.trees)

from sklearn.datasets import fetch_california_housing
Xh, yh = fetch_california_housing(return_X_y=True)
Xht, Xhv, yht, yhv = train_test_split(Xh, yh, test_size=0.2, random_state=0)
gb = GradientBoostingRegressorScratch(200, 0.1, 3).fit(Xht, yht)
from sklearn.metrics import r2_score
print("R2:", r2_score(yhv, gb.predict(Xhv)))    # ~0.80

For classification, use log loss: residual = y - sigmoid(F), and leaf values get a Newton step. XGBoost uses second-order (Hessian) information for every loss.

The modern libraries

LibraryStrengths
LightGBMfastest, histogram-based, leaf-wise growth, native categoricals, handles NaN
XGBoostmature, robust, GPU support, great docs
CatBoostbest default handling of categoricals (ordered target stats), less tuning needed
sklearn HistGradientBoosting*no extra install, fast, good defaults
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import lightgbm as lgb
model = lgb.LGBMClassifier(n_estimators=2000, learning_rate=0.03, num_leaves=31, min_child_samples=20,
                           subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_lambda=1.0, random_state=0)
model.fit(X_train, y_train, eval_set=[(X_val, y_val)], eval_metric="auc",
          callbacks=[lgb.early_stopping(100), lgb.log_evaluation(200)])
print("best iteration:", model.best_iteration_)

import xgboost as xgb
xg = xgb.XGBClassifier(n_estimators=2000, learning_rate=0.03, max_depth=6, subsample=0.8, colsample_bytree=0.8,
                       reg_lambda=1.0, early_stopping_rounds=100, eval_metric="auc", tree_method="hist")
xg.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=200)

from catboost import CatBoostClassifier
cb = CatBoostClassifier(iterations=2000, learning_rate=0.03, depth=6, cat_features=cat_cols, eval_metric="AUC",
                        early_stopping_rounds=100, verbose=200)
cb.fit(X_train, y_train, eval_set=(X_val, y_val))

Tuning gradient boosting (in order of importance)

1
2
3
4
5
6
1. learning_rate (0.01-0.1) + n_estimators with early stopping  (lower LR + more trees = better, slower)
2. num_leaves / max_depth (tree complexity): 15-127 leaves / depth 3-8
3. min_child_samples / min_child_weight (regularize leaves)
4. subsample, colsample_bytree (0.6-0.9): randomness, speed
5. reg_lambda (L2), reg_alpha (L1)
6. for imbalance: scale_pos_weight or is_unbalance

9.6 Stacking and voting

  • Voting: average probabilities (soft) or majority vote (hard) of different model types.
  • Stacking: train a meta-model on out-of-fold predictions of base models.
1
2
3
4
5
6
7
8
from sklearn.ensemble import StackingClassifier, VotingClassifier
from sklearn.svm import SVC
stack = StackingClassifier(
    estimators=[("lr", make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))),
                ("rf", RandomForestClassifier(300, random_state=0)),
                ("gb", lgb.LGBMClassifier(n_estimators=300, learning_rate=0.05))],
    final_estimator=LogisticRegression(), cv=5, n_jobs=-1)
stack.fit(X_train, y_train); print(stack.score(X_val, y_val))

Diversity is what matters: stack models that make different mistakes (linear + trees + KNN), not five GBMs.

9.7 Bagging vs boosting summary

 Bagging / RFBoosting / GBM
Trees trainedin parallel, independentlysequentially, each on previous errors
Reducesvariancebias (and variance with shrinkage/subsampling)
Base treedeep, low biasshallow (depth 3-8)
Overfits with more trees?noyes, use early stopping
Tuning sensitivitylowmedium-high
Typical accuracygoodbest on tabular

Project: Enter a Kaggle tabular competition (e.g. “Playground Series”). Build RF, LightGBM, CatBoost; tune LightGBM with Optuna (Part 13); stack them. Implement gradient boosting for binary classification from scratch using log loss.


Part 10: Support Vector Machines

10.1 Maximum margin

Among all lines separating two classes, pick the one with the largest margin (distance to the nearest points). Those nearest points are the support vectors; only they determine the boundary.

1
2
3
4
5
6
7
8
9
         ○   ○                 margin
       ○    ○   \  ╎  \    ╎   ◄────►
     ○   ○       \ ╎   \   ╎
                  \╎    \  ╎        wx + b = +1
    ────────────────────────────    wx + b =  0   (decision boundary)
         ●  ●     ╎\     ╎ \        wx + b = -1
       ●   ●  ●   ╎ \    ╎  \
          ●       ╎      ╎
         support vectors lie on the dashed lines
Margin width = 2/ w . So **maximize margin = minimize w ²** subject to yᵢ(w·xᵢ + b) >= 1.

10.2 Soft margin and hinge loss

Real data overlaps. Allow violations with slack, penalized by C:

\[\min_{w,b} \frac{1}{2}\|w\|^2 + C\sum_i \max(0,\ 1 - y_i(w\cdot x_i + b))\]

The second term is the hinge loss. Large C: fewer violations, narrower margin, risk of overfitting. Small C: wider margin, more regularization.

1
2
3
4
5
6
7
   hinge loss
     │\
     │ \
     │  \          0 loss once the point is beyond the margin
     │   \______________
     └────┴────────────> y (w.x + b)
          1

10.3 Linear SVM from scratch (sub-gradient descent)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class LinearSVM:
    def __init__(self, C=1.0, lr=0.001, epochs=1000):
        self.C, self.lr, self.epochs = C, lr, epochs
    def fit(self, X, y):                                   # y in {-1, +1}
        n, d = X.shape; self.w = np.zeros(d); self.b = 0.0
        for _ in range(self.epochs):
            margin = y * (X @ self.w + self.b)
            viol = margin < 1
            dw = self.w - self.C * (y[viol, None] * X[viol]).sum(0)
            db = -self.C * y[viol].sum()
            self.w -= self.lr * dw; self.b -= self.lr * db
        return self
    def predict(self, X): return np.sign(X @ self.w + self.b)

X, y = load_breast_cancer(return_X_y=True); X = (X - X.mean(0)) / X.std(0); ys = np.where(y == 1, 1, -1)
Xt, Xv, yt, yv = train_test_split(X, ys, test_size=0.2, random_state=0)
print("linear SVM acc:", (LinearSVM(C=1.0, lr=1e-3, epochs=2000).fit(Xt, yt).predict(Xv) == yv).mean())

10.4 The kernel trick

Map inputs to a higher-dimensional space where they become linearly separable, without ever computing the mapping: the dual form of the SVM only needs dot products φ(xᵢ)·φ(xⱼ) = K(xᵢ, xⱼ).

1
2
   1-D, not separable:      ●●●○○○○●●●
   map x -> (x, x²):   now a parabola; a line separates ○ (middle, low x²) from ● (ends, high x²)
KernelK(x, z)Use    
Linearx·ztext, high-dimensional sparse    
Polynomial(γ x·z + r)^dinteractions    
RBF (Gaussian)exp(-γ x - z ²)default nonlinear choice
Sigmoidtanh(γ x·z + r)rarely    

Prediction: f(x) = sum_i αᵢ yᵢ K(xᵢ, x) + b, with αᵢ > 0 only for support vectors.

1
2
3
4
5
from sklearn.svm import SVC, SVR, LinearSVC
svm = make_pipeline(StandardScaler(), SVC(kernel="rbf", C=1.0, gamma="scale", probability=False))
svm.fit(X_train, y_train)
# gamma: large -> each support vector has small influence radius -> wiggly boundary (overfit)
#        small -> smooth boundary (underfit)

Tuning grid

1
2
3
4
5
from sklearn.model_selection import GridSearchCV
grid = GridSearchCV(make_pipeline(StandardScaler(), SVC()),
                    {"svc__C": np.logspace(-2, 3, 6), "svc__gamma": np.logspace(-4, 1, 6)},
                    cv=5, scoring="accuracy", n_jobs=-1).fit(X_train, y_train)
print(grid.best_params_, grid.best_score_)

10.5 Practical notes

  • Scale features. Always.
  • Training is O(n²) to O(n³): fine up to ~50k samples. Beyond that use LinearSVC, SGDClassifier(loss="hinge"), or kernel approximation (Nystroem, RBFSampler) + linear model.
  • Probabilities (probability=True) come from Platt scaling (an extra CV); slow.
  • SVR (regression) uses an ε-insensitive tube instead of hinge.
  • Multiclass is one-vs-one by default.

Project: Plot decision boundaries of RBF SVMs on make_moons for a grid of C and gamma values. Implement the kernelized SVM dual with a simple SMO or with scipy.optimize and verify the support vectors against sklearn’s support_.


Part 11: Naive Bayes & Probabilistic Classifiers

11.1 Bayes’ rule for classification

\[P(y=k\mid x) = \frac{P(x\mid y=k)\,P(y=k)}{P(x)} \propto P(x\mid y=k)\,P(y=k)\]
Pick the class with the highest posterior. We need the prior P(y=k) (class frequency) and the likelihood P(xy=k).

11.2 The “naive” assumption

Features are conditionally independent given the class: P(xy) = prod_j P(xⱼy). Wrong in practice, but the resulting classifier is fast, needs little data, and works surprisingly well (especially for text).
VariantLikelihood modelData
Gaussian NBeach feature ~ N(μ_kj, σ²_kj)continuous features
Multinomial NBword counts ~ Multinomialtext (bag of words / TF-IDF)
Bernoulli NBbinary presence/absenceshort text, binary features
Categorical NBper-category probabilitiescategorical features
Complement NBcounts from other classesimbalanced text

11.3 Gaussian Naive Bayes from scratch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class GaussianNB_:
    def fit(self, X, y):
        self.classes = np.unique(y)
        self.mu = np.stack([X[y == k].mean(0) for k in self.classes])
        self.var = np.stack([X[y == k].var(0) + 1e-9 for k in self.classes])
        self.log_prior = np.log(np.bincount(y) / len(y))
        return self
    def predict_log_proba(self, X):
        # log N(x | mu, var) summed over features, for each class
        ll = -0.5 * (np.log(2 * np.pi * self.var)[None] + (X[:, None, :] - self.mu[None]) ** 2 / self.var[None]).sum(2)
        return ll + self.log_prior
    def predict(self, X): return self.classes[self.predict_log_proba(X).argmax(1)]

X, y = load_iris(return_X_y=True)
Xt, Xv, yt, yv = train_test_split(X, y, test_size=0.3, random_state=0)
print("GaussianNB acc:", (GaussianNB_().fit(Xt, yt).predict(Xv) == yv).mean())

11.4 Multinomial Naive Bayes for text, from scratch

\[\log P(y=k\mid d) \propto \log P(k) + \sum_{w\in d} \text{count}_w\,\log P(w\mid k),\qquad P(w\mid k) = \frac{N_{kw} + \alpha}{N_k + \alpha V}\]

The +α is Laplace smoothing: avoids zero probability for unseen words.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MultinomialNB_:
    def __init__(self, alpha=1.0): self.alpha = alpha
    def fit(self, X, y):                                   # X: (n_docs, vocab) counts (sparse ok)
        self.classes = np.unique(y)
        counts = np.stack([np.asarray(X[y == k].sum(0)).ravel() for k in self.classes])   # (K, V)
        self.log_cond = np.log((counts + self.alpha) / (counts.sum(1, keepdims=True) + self.alpha * X.shape[1]))
        self.log_prior = np.log(np.bincount(y) / len(y))
        return self
    def predict(self, X):
        return self.classes[(X @ self.log_cond.T + self.log_prior).argmax(1)]

from sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import CountVectorizer
cats = ["sci.space", "rec.sport.hockey", "comp.graphics"]
train = fetch_20newsgroups(subset="train", categories=cats); test = fetch_20newsgroups(subset="test", categories=cats)
vec = CountVectorizer(stop_words="english", min_df=2)
Xtr, Xte = vec.fit_transform(train.data), vec.transform(test.data)
print("MultinomialNB acc:", (MultinomialNB_().fit(Xtr, train.target).predict(Xte) == test.target).mean())   # ~0.95
1
2
from sklearn.naive_bayes import GaussianNB, MultinomialNB, BernoulliNB
nb = make_pipeline(CountVectorizer(ngram_range=(1, 2), min_df=2), MultinomialNB(alpha=0.5))

11.5 Generative vs discriminative

 Generative (NB, LDA, GMM)Discriminative (logistic, SVM, trees)  
ModelsP(xy) and P(y), then BayesP(yx) directly
Data neededless (strong assumptions)more  
Asymptotic accuracylower if assumptions wronghigher  
Can generate / handle missing xyesno  

Classic result (Ng & Jordan 2002): NB reaches its (worse) asymptote faster; logistic regression wins with enough data.

11.6 LDA and QDA

Linear Discriminant Analysis assumes Gaussian class-conditionals with a shared covariance -> linear boundary. QDA gives each class its own covariance -> quadratic boundary. LDA is also a supervised dimensionality reduction method (projects to K-1 dimensions that best separate classes).

1
2
3
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis
lda = LinearDiscriminantAnalysis(n_components=2).fit(X_train, y_train)
Z = lda.transform(X_train)        # 2-D projection for plotting

11.7 Calibration caveat

Naive Bayes probabilities are badly calibrated (too extreme) because of the independence assumption. Rankings are fine; if you need real probabilities, wrap in CalibratedClassifierCV (Part 13).

Project: Build a spam classifier on the SMS Spam Collection with Bernoulli NB, Multinomial NB, and logistic regression on TF-IDF. Compare precision/recall. Plot the learning curves (accuracy vs training set size) of NB vs logistic regression and observe the Ng & Jordan effect.


Part 12: Unsupervised Learning

No labels. Find structure: groups (clustering), compressed representations (dimensionality reduction), unusual points (anomaly detection).

flowchart LR
    U[Unsupervised] --> C[Clustering]
    C --> KM[K-means]
    C --> H[Hierarchical]
    C --> DB[DBSCAN / HDBSCAN]
    C --> GMM[Gaussian Mixture / EM]
    U --> D[Dimensionality reduction]
    D --> PCA
    D --> TS[t-SNE / UMAP]
    D --> NMF[NMF, ICA, SVD]
    U --> A[Anomaly detection]
    A --> IF[Isolation Forest]
    A --> LOF[Local Outlier Factor]
    A --> OC[One-class SVM]

12.1 K-means

Partition data into K clusters by minimizing within-cluster squared distance to centroids.

1
2
3
4
  1. pick K centroids (k-means++ init: spread them out)
  2. assign each point to nearest centroid
  3. move each centroid to the mean of its points
  4. repeat 2-3 until assignments stop changing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class KMeans_:
    def __init__(self, k=3, n_iter=100, seed=0): self.k, self.n_iter, self.rng = k, n_iter, np.random.default_rng(seed)
    def _init_pp(self, X):                                   # k-means++
        C = [X[self.rng.integers(len(X))]]
        for _ in range(1, self.k):
            d2 = np.min(((X[:, None] - np.array(C)[None]) ** 2).sum(2), axis=1)
            C.append(X[self.rng.choice(len(X), p=d2 / d2.sum())])
        return np.array(C)
    def fit(self, X):
        self.C = self._init_pp(X)
        for _ in range(self.n_iter):
            labels = ((X[:, None] - self.C[None]) ** 2).sum(2).argmin(1)
            newC = np.array([X[labels == j].mean(0) if (labels == j).any() else self.C[j] for j in range(self.k)])
            if np.allclose(newC, self.C): break
            self.C = newC
        self.labels_ = labels
        self.inertia_ = ((X - self.C[labels]) ** 2).sum()
        return self
    def predict(self, X): return ((X[:, None] - self.C[None]) ** 2).sum(2).argmin(1)

from sklearn.datasets import make_blobs
X, _ = make_blobs(n_samples=600, centers=4, cluster_std=0.8, random_state=1)
km = KMeans_(k=4).fit(X)
plt.scatter(X[:, 0], X[:, 1], c=km.labels_, s=8); plt.scatter(*km.C.T, c="red", marker="x", s=100); plt.show()

Choosing K

1
2
3
4
5
6
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
for k in range(2, 10):
    km = KMeans(k, n_init=10, random_state=0).fit(X)
    print(k, round(km.inertia_), round(silhouette_score(X, km.labels_), 3))
# Elbow: where inertia stops dropping sharply. Silhouette: higher is better (in [-1, 1]).

Limitations: assumes spherical, similar-size clusters; sensitive to scale and outliers; must choose K. Run several n_init to avoid bad local minima. Scale features first.

12.2 Hierarchical clustering

Build a tree (dendrogram) by repeatedly merging the two closest clusters (agglomerative). Cut the tree at any height to get clusters. No need to pick K upfront.

1
2
3
4
5
6
  height
    │        ┌────────┴────────┐
    │    ┌───┴───┐         ┌───┴───┐
    │  ┌─┴─┐   ┌─┴─┐     ┌─┴─┐   ┌─┴─┐
    │  a   b   c   d     e   f   g   h
    cut here ─────────────────────────── -> 4 clusters

Linkage (distance between clusters): single (min), complete (max), average, Ward (minimizes variance increase; the usual default).

1
2
3
4
5
6
from scipy.cluster.hierarchy import linkage, dendrogram, fcluster
Z = linkage(X, method="ward")
dendrogram(Z, truncate_mode="lastp", p=20); plt.show()
labels = fcluster(Z, t=4, criterion="maxclust")
from sklearn.cluster import AgglomerativeClustering
AgglomerativeClustering(n_clusters=4, linkage="ward").fit_predict(X)

O(n²) memory: fine up to ~20k points.

12.3 DBSCAN

Density-based: a point is a core point if at least min_samples points lie within eps. Clusters are connected core points; points reachable from them are border points; everything else is noise. Finds arbitrary shapes, no K needed, labels outliers as -1.

1
2
3
4
5
from sklearn.cluster import DBSCAN
from sklearn.datasets import make_moons
Xm, _ = make_moons(500, noise=0.07, random_state=0)
labels = DBSCAN(eps=0.2, min_samples=5).fit_predict(Xm)
print("clusters:", len(set(labels)) - (1 if -1 in labels else 0), " noise points:", (labels == -1).sum())

Choosing eps: plot sorted k-th nearest neighbor distances (k = min_samples) and look for the knee. HDBSCAN (sklearn.cluster.HDBSCAN) removes the need for eps and handles varying density.

12.4 Gaussian Mixture Models and EM

Soft clustering: data is a mixture of K Gaussians. Each point has a probability of belonging to each cluster. Fit with Expectation-Maximization:

1
2
3
  E-step: given current Gaussians, compute responsibilities r_ik = P(cluster k | x_i)
  M-step: given responsibilities, update each Gaussian's weight, mean, covariance (weighted MLE)
  repeat until log-likelihood converges

K-means is the special case with hard assignments and identity covariances.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class GMM_:
    def __init__(self, k=3, n_iter=100, seed=0): self.k, self.n_iter, self.rng = k, n_iter, np.random.default_rng(seed)
    def fit(self, X):
        n, d = X.shape
        self.pi = np.full(self.k, 1 / self.k)
        self.mu = X[self.rng.choice(n, self.k, replace=False)]
        self.Sigma = np.array([np.cov(X.T) + 1e-6 * np.eye(d)] * self.k)
        for _ in range(self.n_iter):
            # E-step
            from scipy.stats import multivariate_normal
            R = np.stack([self.pi[k] * multivariate_normal(self.mu[k], self.Sigma[k]).pdf(X) for k in range(self.k)], 1)
            R /= R.sum(1, keepdims=True)
            # M-step
            Nk = R.sum(0)
            self.pi = Nk / n
            self.mu = (R.T @ X) / Nk[:, None]
            for k in range(self.k):
                diff = X - self.mu[k]
                self.Sigma[k] = (R[:, k, None] * diff).T @ diff / Nk[k] + 1e-6 * np.eye(d)
        self.resp_ = R; return self
    def predict(self, X): return self.resp_.argmax(1)

from sklearn.mixture import GaussianMixture
gmm = GaussianMixture(n_components=4, covariance_type="full", n_init=5, random_state=0).fit(X)
gmm.predict_proba(X)[:3]
gmm.bic(X)      # choose K by minimizing BIC/AIC

GMMs also give a density -> anomaly scores (score_samples) and can generate samples.

12.5 PCA: Principal Component Analysis

Find orthogonal directions of maximum variance; project onto the top k. Used for compression, visualization, de-noising, decorrelating features, speeding up other models.

1
2
3
4
5
   original 2-D data          PC1 (max variance)      projection onto PC1 (1-D)
      ·  ·  ·                      ╱                    ·····•·····
    ·  · · ·  ·        ->         ╱ PC1       ->
      · · ·  ·                   ╱
   ·  ·  ·                      ╱  PC2 (orthogonal)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class PCA_:
    def __init__(self, n_components): self.k = n_components
    def fit(self, X):
        self.mean = X.mean(0)
        U, S, Vt = np.linalg.svd(X - self.mean, full_matrices=False)
        self.components_ = Vt[:self.k]                        # (k, d) principal axes
        self.explained_variance_ratio_ = (S[:self.k] ** 2) / (S ** 2).sum()
        return self
    def transform(self, X): return (X - self.mean) @ self.components_.T
    def inverse_transform(self, Z): return Z @ self.components_ + self.mean

from sklearn.datasets import load_digits
Xd, yd = load_digits(return_X_y=True)
p = PCA_(2).fit(Xd); Z = p.transform(Xd)
plt.scatter(Z[:, 0], Z[:, 1], c=yd, cmap="tab10", s=8); plt.show()
print(p.explained_variance_ratio_)
1
2
3
4
from sklearn.decomposition import PCA
pca = make_pipeline(StandardScaler(), PCA(n_components=0.95))    # keep 95% of variance
Z = pca.fit_transform(X_train)
plt.plot(np.cumsum(pca[-1].explained_variance_ratio_)); plt.xlabel("components"); plt.ylabel("cumulative variance")

Always standardize first (else high-variance features dominate). PCA is linear; for nonlinear structure use Kernel PCA, t-SNE/UMAP, or autoencoders.

Related: SVD / TruncatedSVD (works on sparse text matrices: “latent semantic analysis”), NMF (non-negative parts-based factors, good for topics), ICA (independent sources, signal separation).

12.6 t-SNE and UMAP (visualization)

Nonlinear methods that preserve local neighborhoods in 2-D. Great for looking at clusters, bad for anything quantitative: distances between clusters and cluster sizes are not meaningful, results vary with hyperparameters (perplexity).

1
2
3
from sklearn.manifold import TSNE
Z = TSNE(n_components=2, perplexity=30, init="pca", random_state=0).fit_transform(Xd)
# pip install umap-learn ; import umap ; Z = umap.UMAP(n_neighbors=15, min_dist=0.1).fit_transform(Xd)

UMAP is faster, scales better, and has a transform for new data. Reduce to ~50 dims with PCA first for speed.

12.7 Anomaly / outlier detection

MethodIdea
z-score / IQRunivariate thresholds
Isolation Forestrandom splits isolate anomalies in few steps; fast, scalable, default choice
Local Outlier Factordensity relative to neighbors
One-class SVMboundary around normal data
GMM / KDElow density = anomaly
Autoencoderhigh reconstruction error = anomaly (DL guide)
1
2
3
4
from sklearn.ensemble import IsolationForest
iso = IsolationForest(n_estimators=300, contamination=0.02, random_state=0).fit(X_train)
scores = -iso.score_samples(X_val)        # higher = more anomalous
flags = iso.predict(X_val)                # -1 anomaly, 1 normal

Evaluate with labeled anomalies if you have any (precision@k, AUC-PR); otherwise inspect the top-scored examples manually.

12.8 Evaluating clustering

  • With ground truth (rare): Adjusted Rand Index, Normalized Mutual Information (adjusted_rand_score, normalized_mutual_info_score).
  • Without: silhouette, Davies-Bouldin, Calinski-Harabasz, stability across bootstraps, and above all does it make sense to a domain expert.

Project: Segment customers from the “Online Retail” dataset with RFM features (recency, frequency, monetary): scale, pick K via silhouette, profile each cluster. Compare K-means, GMM, and HDBSCAN. Visualize with PCA and UMAP. Run Isolation Forest on credit card fraud data and report AUC-PR.


Part 13: Model Evaluation & Selection

The most important part of practical ML. A model is only as good as your ability to measure it honestly.

13.1 Classification metrics

1
2
3
4
5
6
                      Predicted
                   Positive   Negative
   Actual Pos     [  TP  ]   [  FN  ]     Recall = TP / (TP + FN)       "caught how many of the real ones?"
   Actual Neg     [  FP  ]   [  TN  ]     Specificity = TN / (TN + FP)
                 Precision = TP/(TP+FP)
                 "of my alarms, how many were real?"
MetricFormulaUse when
Accuracy(TP+TN)/allbalanced classes, equal costs
PrecisionTP/(TP+FP)false alarms are expensive (spam -> inbox)
RecallTP/(TP+FN)misses are expensive (cancer, fraud)
F12PR/(P+R)one number balancing P and R
Fβweights recall β times moreβ=2 when recall matters more
ROC-AUCP(random pos ranked above random neg)ranking quality, threshold-free, balanced-ish data
PR-AUC / Average Precisionarea under precision-recall curveimbalanced data (much more informative than ROC)
Log loss-mean(y log p + (1-y) log(1-p))probability quality
Brier scoremean (p - y)²calibration + accuracy
MCCcorrelation between pred and truthimbalanced, single robust number
Cohen’s kappaagreement beyond chance 
1
2
3
4
5
6
7
from sklearn.metrics import (accuracy_score, precision_score, recall_score, f1_score, roc_auc_score,
                             average_precision_score, log_loss, confusion_matrix, classification_report,
                             RocCurveDisplay, PrecisionRecallDisplay, ConfusionMatrixDisplay)
p = model.predict_proba(X_val)[:, 1]; y_pred = (p >= 0.5).astype(int)
print(classification_report(y_val, y_pred, digits=3))
print("ROC-AUC", roc_auc_score(y_val, p), " PR-AUC", average_precision_score(y_val, p), " logloss", log_loss(y_val, p))
ConfusionMatrixDisplay.from_predictions(y_val, y_pred); RocCurveDisplay.from_predictions(y_val, p); PrecisionRecallDisplay.from_predictions(y_val, p)

ROC vs PR curves

1
2
3
4
5
6
7
  ROC: TPR vs FPR                     PR: Precision vs Recall
  1 ┤        ____----                 1 ┤----____
    │    _.-'                           │        ‾‾--.__
    │  .'   AUC = area                  │              ‾-._
    │ /                                 │                  ‾.
  0 ┤/   (diagonal = random)          0 ┤  (horizontal line at prevalence = random)
    └──────────── FPR 1                 └──────────── Recall 1

With 0.1% positives, a model with 1000 false positives per true positive still has FPR ~0 (great ROC) but precision ~0 (terrible PR). Use PR-AUC for rare events.

Choosing the threshold

0.5 is arbitrary. Pick the threshold that optimizes your business metric, using the validation set.

1
2
3
4
5
6
from sklearn.metrics import precision_recall_curve
prec, rec, thr = precision_recall_curve(y_val, p)
f1 = 2 * prec * rec / (prec + rec + 1e-12)
best = thr[np.argmax(f1[:-1])]
print("best F1 threshold:", best)
# Or: cost-based. expected_cost(t) = FP(t)*cost_fp + FN(t)*cost_fn ; choose argmin.

Multiclass

average="macro" (each class counts equally; good for imbalance), "micro" (each sample equally; = accuracy), "weighted". Look at the per-class confusion matrix.

13.2 Regression metrics

MetricFormulaNotes
MSE / RMSEmean (y - ŷ)² / its rootpenalizes big errors; same units as y (RMSE)
MAEmean abs(y - ŷ)robust to outliers; median-optimal
MAPEmean abs((y - ŷ)/y)scale-free; explodes near y = 0
R²1 - SS_res/SS_totfraction of variance explained; can be negative
RMSLERMSE of log1prelative errors, skewed positive targets
Quantile / pinball loss for prediction intervals
1
2
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score, mean_absolute_percentage_error
rmse = mean_squared_error(y_val, y_hat) ** 0.5

Always plot predicted vs actual and the residual histogram.

13.3 Cross-validation strategies

SplitterUse
KFold(shuffle=True)regression, i.i.d. data
StratifiedKFoldclassification (keeps class ratios)
GroupKFold / StratifiedGroupKFoldmultiple rows per entity (patients, users, sessions)
TimeSeriesSplittemporal data (train on past, validate on future)
RepeatedStratifiedKFoldsmall data, lower variance estimate
Nested CVhonest estimate when also tuning hyperparameters
1
2
3
4
  TimeSeriesSplit (expanding window):
  fold 1: [train     ][val]
  fold 2: [train          ][val]
  fold 3: [train               ][val]
1
2
3
4
from sklearn.model_selection import cross_validate, GroupKFold, TimeSeriesSplit
res = cross_validate(pipe, X, y, cv=StratifiedKFold(5, shuffle=True, random_state=0),
                     scoring=["roc_auc", "average_precision", "f1"], return_train_score=True, n_jobs=-1)
pd.DataFrame(res).agg(["mean", "std"])

Nested cross-validation

Outer loop estimates performance; inner loop tunes hyperparameters. Required when you tune and want an unbiased number.

1
2
3
from sklearn.model_selection import GridSearchCV, cross_val_score
inner = GridSearchCV(pipe, param_grid, cv=5, scoring="roc_auc")
outer_scores = cross_val_score(inner, X, y, cv=5, scoring="roc_auc")

13.4 Hyperparameter tuning

flowchart LR
    G[Grid search: exhaustive, small spaces] --> R[Random search: better for many params]
    R --> B[Bayesian / TPE: Optuna, learns from past trials]
    B --> S[Successive halving / Hyperband: early-kill bad configs]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Random search
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import loguniform, randint
search = RandomizedSearchCV(
    make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)),
    {"logisticregression__C": loguniform(1e-3, 1e2)},
    n_iter=30, cv=5, scoring="roc_auc", n_jobs=-1, random_state=0).fit(X_train, y_train)
print(search.best_params_, search.best_score_)

# Optuna (Bayesian) for LightGBM
import optuna
def objective(trial):
    params = dict(
        n_estimators=3000, learning_rate=trial.suggest_float("lr", 0.01, 0.2, log=True),
        num_leaves=trial.suggest_int("num_leaves", 8, 128, log=True),
        min_child_samples=trial.suggest_int("min_child_samples", 5, 100),
        subsample=trial.suggest_float("subsample", 0.5, 1.0), subsample_freq=1,
        colsample_bytree=trial.suggest_float("colsample", 0.4, 1.0),
        reg_lambda=trial.suggest_float("reg_lambda", 1e-3, 10, log=True), random_state=0, verbose=-1)
    cv = StratifiedKFold(5, shuffle=True, random_state=0); scores = []
    for tr, va in cv.split(X_train, y_train):
        m = lgb.LGBMClassifier(**params).fit(X_train.iloc[tr], y_train.iloc[tr],
                                             eval_set=[(X_train.iloc[va], y_train.iloc[va])],
                                             callbacks=[lgb.early_stopping(100, verbose=False)])
        scores.append(roc_auc_score(y_train.iloc[va], m.predict_proba(X_train.iloc[va])[:, 1]))
    return np.mean(scores)
study = optuna.create_study(direction="maximize", sampler=optuna.samplers.TPESampler(seed=0))
study.optimize(objective, n_trials=50)
print(study.best_params, study.best_value)

Tuning tips: log scale for rates and regularization; tune the most sensitive parameters first; do not tune on the test set; a 0.1% CV gain is probably noise.

13.5 Learning and validation curves

1
2
3
from sklearn.model_selection import learning_curve, validation_curve
sizes, tr, va = learning_curve(pipe, X, y, cv=5, train_sizes=np.linspace(0.1, 1.0, 8), scoring="accuracy", n_jobs=-1)
plt.plot(sizes, tr.mean(1), label="train"); plt.plot(sizes, va.mean(1), label="val"); plt.legend(); plt.xlabel("n samples")
1
2
3
4
5
6
  score
    │ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾  train         gap closing with more data -> get more data
    │             ______-------  val           both plateau low -> need a better model / features
    │       _____/
    │  ____/
    └──────────────────────────> training set size

13.6 Imbalanced data

1
  Dataset: 99% negative, 1% positive. Accuracy of "always negative" = 99%.
TechniqueNotes
Use the right metricPR-AUC, F1, recall at fixed precision
class_weight="balanced" / scale_pos_weightreweight the loss; try first
Threshold tuningoften enough on its own
Undersample majorityfast; loses data
Oversample minority / SMOTEsynthetic interpolation; only inside CV folds!
Anomaly detection framingwhen positives are extremely rare and heterogeneous
Collect more positivesbest fix
1
2
3
4
from imblearn.pipeline import Pipeline as ImbPipeline
from imblearn.over_sampling import SMOTE
pipe = ImbPipeline([("pre", pre), ("smote", SMOTE(random_state=0)), ("model", LogisticRegression(max_iter=1000))])
# imblearn's Pipeline applies SMOTE only during fit, never at predict time, and only on the training folds in CV.

13.7 Probability calibration

A calibrated model’s “70%” predictions are right 70% of the time. Logistic regression is naturally calibrated; NB, SVM, boosted trees, random forests are not.

1
2
3
from sklearn.calibration import CalibratedClassifierCV, calibration_curve, CalibrationDisplay
cal = CalibratedClassifierCV(rf, method="isotonic", cv=5).fit(X_train, y_train)   # or method="sigmoid" (Platt)
CalibrationDisplay.from_estimator(cal, X_val, y_val, n_bins=10)
1
2
3
4
5
6
7
   observed freq
   1 ┤          /   perfect = diagonal
     │       /  .   over-confident model bows below
     │    / .
     │ / .
   0 ┤.
     └──────── predicted prob 1

Matters when probabilities feed decisions (expected value, ranking across models, risk scores).

13.8 Comparing models honestly

  • Same folds for every model (fix random_state).
  • Report mean +/- std; use a paired test across folds for small differences.
  • Prefer the simpler model when within noise.
  • Check performance on slices (by segment, time period, class) not only the overall number.
  • Put a bootstrap CI on the final test metric.

13.9 Error analysis

Look at the worst mistakes. Group them. Ask: label noise? missing feature? rare segment? This is where the next 5% comes from, not from tuning.

1
2
3
4
val = X_val.copy(); val["y"] = y_val; val["p"] = p
val["err"] = (val["p"] - val["y"]).abs()
val.sort_values("err", ascending=False).head(20)
val.groupby("segment").apply(lambda g: roc_auc_score(g["y"], g["p"]) if g["y"].nunique() > 1 else np.nan)

Project: On the credit card fraud dataset (0.17% positives): compare accuracy vs PR-AUC for a dummy model and LightGBM; tune the threshold for a cost matrix (FN costs 100, FP costs 1); try class weights vs SMOTE inside CV; calibrate and plot the reliability diagram; produce a slice report by transaction amount bucket.


Part 14: Optimization & Learning Theory

Just enough theory to reason about what you are doing. Short, with code.

14.1 Gradient descent family

VariantGradient fromPros / cons
Batch GDall n samplesexact, slow per step
Stochastic GD1 samplenoisy, cheap, escapes shallow minima
Mini-batch SGD32-1024 samplesthe default
Momentum / Nesterovrunning average of gradientsfaster through ravines
AdaGrad / RMSProp / Adamper-parameter adaptive steprobust default for neural nets
Newton / quasi-Newton (L-BFGS)uses curvature (Hessian)few iterations; sklearn’s LogisticRegression(solver="lbfgs")
Coordinate descentone coordinate at a timeLasso (solver in sklearn)
1
2
3
4
5
6
# Learning-rate effect on convex loss
def gd(lr, steps=50):
    w = 5.0; hist = []
    for _ in range(steps): w -= lr * 2 * w; hist.append(w ** 2)       # loss = w^2
    return hist
for lr in [0.01, 0.1, 0.5, 1.01]: print(lr, gd(lr)[-1])              # 1.01 diverges

14.2 Convexity and guarantees

If the loss is convex in the parameters (linear regression, logistic regression, SVM, Lasso), gradient descent with a small enough step converges to the global optimum. Trees and neural nets are not convex: no guarantee, but heuristics work.

14.3 Regularization as a prior (MLE vs MAP)

\[\hat w_{\text{MAP}} = \arg\max_w \log p(D\mid w) + \log p(w)\]
  • Gaussian prior p(w) ∝ exp(-λ w ²) -> Ridge.
  • Laplace prior p(w) ∝ exp(-λ w ₁) -> Lasso.
  • Regularization strength = prior confidence that weights are small.

14.4 Bias-variance decomposition, empirically

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def bias_variance(model_fn, n_rounds=200, n=50, noise=0.3, seed=0):
    rng = np.random.default_rng(seed); x_test = np.linspace(-3, 3, 100)[:, None]; f = np.sin(x_test).ravel()
    preds = []
    for _ in range(n_rounds):
        x = rng.uniform(-3, 3, n)[:, None]; y = np.sin(x).ravel() + rng.normal(0, noise, n)
        preds.append(model_fn().fit(x, y).predict(x_test))
    P = np.array(preds)
    bias2 = ((P.mean(0) - f) ** 2).mean(); var = P.var(0).mean()
    return bias2, var

for name, fn in [("deg1", lambda: make_pipeline(PolynomialFeatures(1), LinearRegression())),
                 ("deg5", lambda: make_pipeline(PolynomialFeatures(5), LinearRegression())),
                 ("deg12", lambda: make_pipeline(PolynomialFeatures(12), LinearRegression())),
                 ("tree", lambda: DecisionTreeRegressor()),
                 ("rf", lambda: RandomForestRegressor(100))]:
    b, v = bias_variance(fn); print(f"{name:6s} bias^2={b:.3f} var={v:.3f} total={b+v:.3f}")
# Watch: deep tree has low bias, high variance; RF keeps the low bias and slashes the variance.

14.5 Generalization theory in one page

  • Empirical risk minimization: minimize average loss on training data; hope test loss is close.
  • PAC learning: with probability 1-δ, test error <= train error + O(sqrt((capacity + log 1/δ)/n)). More data (n) tightens the bound; more capacity (VC dimension) loosens it.
  • VC dimension: largest number of points a model class can shatter (fit any labeling). Lines in 2-D: 3. Formalizes “model complexity”.
  • Occam’s razor / MDL: prefer the simplest model that explains the data.
  • Modern caveat: over-parameterized models (deep nets, boosted ensembles) can generalize despite huge capacity. The classical bounds are loose there; the bias-variance intuition still helps.

14.6 The curse and the blessing

  • Curse of dimensionality: data needed grows exponentially with d; distances lose meaning.
  • Blessing: real data lies near low-dimensional manifolds; regularization, feature selection, and PCA exploit this.

Project: Implement mini-batch SGD and L-BFGS (via scipy.optimize.minimize) for logistic regression; compare iterations and wall-clock. Reproduce the bias-variance table above with KNN for k in {1, 5, 25}.


Part 15: Time Series

15.1 What makes time series different

Observations are ordered and autocorrelated. The future depends on the past; you cannot shuffle. The goal is usually forecasting (predict y_{t+h}) or anomaly detection on sequences.

1
2
3
4
5
6
  value
    │        /\        /\        /\        /\      trend + seasonality + noise
    │   /\  /  \  /\  /  \  /\  /  \  /\  /  \
    │  /  \/    \/  \/    \/  \/    \/  \/    \
    │ /
    └──────────────────────────────────────────> time

15.2 Decomposition and stationarity

1
2
3
4
5
6
7
8
9
import statsmodels.api as sm
y = df.set_index("date")["sales"].asfreq("D")
dec = sm.tsa.seasonal_decompose(y, model="additive", period=7); dec.plot(); plt.show()

# Stationarity (constant mean/variance over time) is required by ARIMA-type models
from statsmodels.tsa.stattools import adfuller
print("ADF p-value:", adfuller(y.dropna())[1])      # p < 0.05 -> stationary
y_diff = y.diff().dropna()                          # differencing removes trend
y_log = np.log1p(y)                                 # log stabilizes variance

Autocorrelation plots (sm.graphics.tsa.plot_acf / plot_pacf) show which lags matter.

15.3 Baselines (beat these first)

1
2
3
naive = y.shift(1)                     # tomorrow = today
seasonal_naive = y.shift(7)            # tomorrow = same day last week
moving_avg = y.rolling(7).mean().shift(1)

15.4 Classical models

ModelIdea
Exponential smoothing (ETS / Holt-Winters)weighted average of past, with trend & seasonality components
ARIMA(p, d, q)AutoRegressive (p lags) + Integrated (d differences) + Moving Average (q error lags)
SARIMAARIMA + seasonal terms
Prophetadditive trend + seasonality + holidays; robust and easy
1
2
3
4
5
6
7
8
from statsmodels.tsa.holtwinters import ExponentialSmoothing
ets = ExponentialSmoothing(y, trend="add", seasonal="add", seasonal_periods=7).fit()
fc = ets.forecast(14)

from statsmodels.tsa.arima.model import ARIMA
arima = ARIMA(y, order=(2, 1, 1), seasonal_order=(1, 1, 1, 7)).fit()
print(arima.summary()); fc = arima.forecast(14)
# pip install pmdarima ; pmdarima.auto_arima(y, seasonal=True, m=7) picks the orders automatically

15.5 Machine learning approach: lag features + GBM

Turn forecasting into supervised regression. This is what wins most forecasting competitions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def make_features(s: pd.Series, lags=(1, 2, 3, 7, 14, 28), windows=(7, 28)):
    df = pd.DataFrame({"y": s})
    for l in lags: df[f"lag_{l}"] = s.shift(l)
    for w in windows:
        df[f"rmean_{w}"] = s.shift(1).rolling(w).mean()
        df[f"rstd_{w}"] = s.shift(1).rolling(w).std()
    idx = df.index
    df["dow"], df["month"], df["dayofyear"] = idx.dayofweek, idx.month, idx.dayofyear
    df["is_weekend"] = (idx.dayofweek >= 5).astype(int)
    return df.dropna()

F = make_features(y)
X, target = F.drop(columns="y"), F["y"]

# Backtesting with TimeSeriesSplit (never random KFold!)
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5, test_size=28)
for tr, va in tscv.split(X):
    m = lgb.LGBMRegressor(n_estimators=500, learning_rate=0.05, verbose=-1).fit(X.iloc[tr], target.iloc[tr])
    pred = m.predict(X.iloc[va])
    print("MAE:", mean_absolute_error(target.iloc[va], pred))

Every feature must use only information available at prediction time: shift(1) before rolling, no future leakage.

Multi-step forecasting: recursive (feed predictions back as lags), direct (one model per horizon), or multi-output.

15.6 Evaluation

MAE, RMSE, MAPE/sMAPE, MASE (scaled by naive forecast error). Always backtest over several time windows; plot forecasts against actuals.

15.7 Beyond

Hierarchical forecasting, intermittent demand (Croston), prediction intervals (quantile regression, conformal), change-point detection, and deep models (N-BEATS, Temporal Fusion Transformer, foundation models like Chronos). Libraries: statsforecast, sktime, darts, nixtla.

Project: Forecast daily sales for a Kaggle store-sales dataset. Baselines -> Holt-Winters -> SARIMA -> LightGBM with lag features. Backtest all with the same TimeSeriesSplit and compare MASE.


Part 16: Recommender Systems

16.1 The problem

Users × items matrix R, mostly empty. Predict the missing entries (ratings or clicks), then recommend the top-k items per user.

1
2
3
4
             item1  item2  item3  item4
   user A  [  5      ?      1      ?  ]
   user B  [  ?      4      ?      2  ]
   user C  [  4      ?      ?      5  ]

16.2 Approaches

FamilyIdeaPros / cons
Popularityrecommend what is popularbaseline; cold-start friendly
Content-baseditem features + user profilehandles new items; limited serendipity
Collaborative filtering (memory-based)users similar to you liked Xsimple; sparse data hurts
Matrix factorizationR ≈ U Vᵀ, latent factorsscalable, strong; cold-start problem
Implicit feedback (ALS, BPR)clicks/views, not ratingswhat most products actually have
Hybrid / learning-to-rank / two-tower netscombine signals, optimize rankingproduction systems

16.3 User-based collaborative filtering

1
2
3
4
5
6
7
8
9
from sklearn.metrics.pairwise import cosine_similarity
R = ratings.pivot(index="user", columns="item", values="rating")          # NaN where missing
Rf = R.fillna(0).values
sim = cosine_similarity(Rf)                                               # user-user similarity
def predict_user_based(u, i, k=20):
    rated = ~np.isnan(R.values[:, i])
    neigh = np.argsort(-sim[u] * rated)[:k]
    w = sim[u, neigh]; r = R.values[neigh, i]
    return (w * r).sum() / (np.abs(w).sum() + 1e-9)

Item-based CF (similarity between item columns) scales better and is more stable.

16.4 Matrix factorization from scratch (SGD)

\[\hat r_{ui} = \mu + b_u + b_i + p_u\cdot q_i,\qquad \min \sum_{(u,i)\in\text{obs}} (r_{ui} - \hat r_{ui})^2 + \lambda(\|p_u\|^2 + \|q_i\|^2 + b_u^2 + b_i^2)\]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class MF:
    def __init__(self, n_users, n_items, k=32, lr=0.01, reg=0.05, epochs=20, seed=0):
        rng = np.random.default_rng(seed)
        self.P = rng.normal(0, 0.1, (n_users, k)); self.Q = rng.normal(0, 0.1, (n_items, k))
        self.bu = np.zeros(n_users); self.bi = np.zeros(n_items)
        self.lr, self.reg, self.epochs = lr, reg, epochs
    def fit(self, triples):                                  # array of (user, item, rating)
        self.mu = triples[:, 2].mean()
        for ep in range(self.epochs):
            np.random.shuffle(triples); se = 0.0
            for u, i, r in triples:
                u, i = int(u), int(i)
                err = r - self.predict(u, i); se += err ** 2
                self.bu[u] += self.lr * (err - self.reg * self.bu[u])
                self.bi[i] += self.lr * (err - self.reg * self.bi[i])
                pu, qi = self.P[u].copy(), self.Q[i]
                self.P[u] += self.lr * (err * qi - self.reg * pu)
                self.Q[i] += self.lr * (err * pu - self.reg * qi)
            print(f"epoch {ep} RMSE {np.sqrt(se / len(triples)):.4f}")
        return self
    def predict(self, u, i): return self.mu + self.bu[u] + self.bi[i] + self.P[u] @ self.Q[i]
    def recommend(self, u, n=10, exclude=()):
        scores = self.mu + self.bu[u] + self.bi + self.Q @ self.P[u]
        scores[list(exclude)] = -np.inf
        return np.argsort(-scores)[:n]

# MovieLens 100k: pip install surprise OR download ml-100k; map ids to 0..n-1, split by time, fit.

For implicit data use ALS with confidence weights (implicit library) or BPR loss (pairwise ranking).

16.5 Evaluation

  • Rating prediction: RMSE / MAE on held-out ratings.
  • Ranking (what matters): Precision@k, Recall@k, MAP@k, NDCG@k, hit rate, computed on items the user interacted with after the training cutoff.
  • Offline metrics are proxies; the real test is an online A/B test (CTR, conversions, retention).
  • Beware popularity bias, feedback loops, and evaluation leakage (random splits leak the future).
1
2
3
4
5
6
def precision_at_k(recommended, relevant, k=10):
    return len(set(recommended[:k]) & set(relevant)) / k
def ndcg_at_k(recommended, relevant, k=10):
    dcg = sum(1 / np.log2(i + 2) for i, it in enumerate(recommended[:k]) if it in relevant)
    idcg = sum(1 / np.log2(i + 2) for i in range(min(len(relevant), k)))
    return dcg / idcg if idcg else 0.0

16.6 Cold start

New users: popularity, onboarding questions, content features. New items: content-based embeddings (text/image), exploration (bandits).

Project: On MovieLens-1M, build popularity, item-based CF, and SGD matrix factorization. Evaluate RMSE and NDCG@10 with a time-based split. Add genre features for a content-based hybrid and measure the lift for users with < 5 ratings.


Part 17: Classical NLP

Text is the most common unstructured data. Classical NLP turns it into vectors that linear models and NB can use; it is fast, interpretable, and still competitive for many classification tasks.

17.1 Preprocessing pipeline

1
2
  raw text -> lowercase -> remove noise (URLs, HTML) -> tokenize -> remove stop words (optional)
           -> stem / lemmatize (optional) -> n-grams -> vectorize (counts / TF-IDF)
1
2
3
4
5
6
7
8
9
10
import re
def clean(text):
    text = text.lower()
    text = re.sub(r"https?://\S+|www\.\S+", " url ", text)
    text = re.sub(r"<.*?>", " ", text)
    text = re.sub(r"[^a-z0-9'\s]", " ", text)
    return re.sub(r"\s+", " ", text).strip()

# Lemmatization / tokenization with spaCy (pip install spacy; python -m spacy download en_core_web_sm)
# import spacy; nlp = spacy.load("en_core_web_sm"); [t.lemma_ for t in nlp(text) if not t.is_stop]

17.2 Bag of words and TF-IDF

1
2
3
4
  docs: "the cat sat", "the dog sat", "the cat ran"
  vocab: [cat, dog, ran, sat, the]
  counts:  d1 [1 0 0 1 1]   d2 [0 1 0 1 1]   d3 [1 0 1 0 1]
  TF-IDF: downweight words that appear in every doc ("the"), upweight rare informative ones
\[\text{tfidf}(t, d) = \text{tf}(t,d)\cdot\log\frac{1 + N}{1 + \text{df}(t)} + 1\]
1
2
3
4
5
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
tfidf = TfidfVectorizer(preprocessor=clean, ngram_range=(1, 2), min_df=3, max_df=0.9,
                        sublinear_tf=True, max_features=200_000)
Xtr = tfidf.fit_transform(train_texts); Xte = tfidf.transform(test_texts)   # sparse matrices
print(Xtr.shape, Xtr.nnz / np.prod(Xtr.shape))                              # very sparse

Options: analyzer="char_wb" with ngram_range=(3,5) for character n-grams (robust to typos, great for short text and language ID); binary=True for Bernoulli NB; HashingVectorizer for streaming / huge vocabularies.

17.3 Text classification

1
2
3
4
5
6
7
8
9
10
from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC
clf = Pipeline([("tfidf", TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True)),
                ("model", LogisticRegression(C=4.0, max_iter=2000))])        # or LinearSVC(C=0.5)
clf.fit(train_texts, train_labels)
print(classification_report(test_labels, clf.predict(test_texts)))

# What did it learn? Top weighted n-grams per class
feat = clf[0].get_feature_names_out(); coef = clf[1].coef_[0]
print("positive:", feat[np.argsort(coef)[-15:]]); print("negative:", feat[np.argsort(coef)[:15]])

TF-IDF + logistic regression / linear SVM is a strong baseline that often gets within a few points of fine-tuned Transformers on topic classification, at 1000x the speed.

17.4 Topic modeling

Latent Dirichlet Allocation: each document is a mixture of topics; each topic is a distribution over words. NMF on TF-IDF is faster and often gives cleaner topics.

1
2
3
4
5
6
7
from sklearn.decomposition import LatentDirichletAllocation, NMF
cv = CountVectorizer(max_df=0.9, min_df=5, stop_words="english", max_features=20000)
Xc = cv.fit_transform(docs)
lda = LatentDirichletAllocation(n_components=10, learning_method="online", random_state=0).fit(Xc)
words = cv.get_feature_names_out()
for k, topic in enumerate(lda.components_):
    print(k, " ".join(words[i] for i in topic.argsort()[-10:][::-1]))

17.5 Word embeddings (bridge to DL)

Word2Vec / GloVe / fastText give dense vectors where similar words are close. Average them for a document vector; use as features for classical models. Beyond that, sentence-transformers + logistic regression is the cheapest “modern” approach. The DL guide covers all of this.

1
2
3
4
5
# pip install gensim
from gensim.models import Word2Vec
w2v = Word2Vec(sentences=[clean(t).split() for t in train_texts], vector_size=100, window=5, min_count=3, workers=4)
w2v.wv.most_similar("good")
def doc_vec(tokens): v = [w2v.wv[t] for t in tokens if t in w2v.wv]; return np.mean(v, 0) if v else np.zeros(100)

17.6 Other classical tasks

Named entity recognition (CRFs, spaCy), POS tagging, sentiment lexicons (VADER), text similarity (TF-IDF cosine, BM25 for search), language detection (char n-grams), spelling correction (edit distance), keyword extraction (TF-IDF, RAKE, YAKE).

Project: IMDB sentiment: compare NB, logistic regression, and LinearSVC on word vs character TF-IDF with 5-fold CV; show the most influential n-grams; run NMF with 8 topics and label them. Then compare against averaged Word2Vec vectors.


Part 18: Interpretability

Why did the model predict that? Needed for debugging, trust, regulation, and finding leakage.

18.1 Global vs local

  • Global: which features matter overall? (importances, partial dependence)
  • Local: why this prediction? (SHAP values, LIME, counterfactuals)

18.2 Intrinsically interpretable models

Linear/logistic coefficients (with standardized features), small decision trees, rule lists, GAMs (ExplainableBoostingClassifier from interpret). Use these when interpretability is a requirement, not just a nice-to-have.

18.3 Permutation importance

Shuffle one feature in the validation set; the drop in score is its importance. Model-agnostic and unbiased by cardinality (unlike impurity importance).

1
2
3
4
from sklearn.inspection import permutation_importance
r = permutation_importance(model, X_val, y_val, scoring="roc_auc", n_repeats=10, random_state=0, n_jobs=-1)
imp = pd.Series(r.importances_mean, index=X_val.columns).sort_values()
imp.plot.barh(xerr=r.importances_std[imp.index.map(list(X_val.columns).index)]); plt.show()

Caveat: correlated features share importance; permuting one creates unrealistic rows.

18.4 Partial dependence and ICE

Average prediction as a function of one feature, holding others at their observed values. ICE shows one line per sample (reveals heterogeneity).

1
2
from sklearn.inspection import PartialDependenceDisplay
PartialDependenceDisplay.from_estimator(model, X_val, features=["age", "fare", ("age", "fare")], kind="both")

18.5 SHAP (SHapley Additive exPlanations)

Game-theoretic attribution: each feature’s contribution to pushing the prediction away from the base rate, with consistency guarantees. TreeExplainer is exact and fast for tree ensembles.

1
2
3
4
5
6
7
import shap
explainer = shap.TreeExplainer(lgbm_model)
sv = explainer.shap_values(X_val)                       # (n, d)  for binary: use class-1 values
shap.summary_plot(sv, X_val)                            # global: beeswarm of feature impact
shap.dependence_plot("age", sv, X_val)                  # feature effect + interaction
shap.force_plot(explainer.expected_value, sv[0], X_val.iloc[0])    # local: one prediction
shap.plots.waterfall(shap.Explanation(values=sv[0], base_values=explainer.expected_value, data=X_val.iloc[0]))
1
  base rate 0.12 ──> +0.25 (fare high) ──> +0.18 (sex=female) ──> -0.06 (age 60) ──> prediction 0.49

For non-tree models: shap.KernelExplainer (slow, model-agnostic), shap.LinearExplainer.

18.6 LIME

Fit a simple linear model on perturbed samples around one instance. Model-agnostic, intuitive, less stable than SHAP.

1
2
3
4
# pip install lime
from lime.lime_tabular import LimeTabularExplainer
exp = LimeTabularExplainer(X_train.values, feature_names=X_train.columns, class_names=["no", "yes"], mode="classification")
exp.explain_instance(X_val.values[0], model.predict_proba, num_features=8).show_in_notebook()

18.7 Using interpretability to find bugs

  • A feature with suspiciously huge importance: leakage.
  • Partial dependence that contradicts domain knowledge: data error or confounding.
  • SHAP on misclassified examples: what misled the model.
  • Importance that differs between train and validation: overfitting to noise.

18.8 Fairness checks

Compute metrics per protected group (fairlearn.metrics.MetricFrame): selection rate, TPR, FPR. Large gaps need investigation; sometimes the fix is data, sometimes constraints or post-processing thresholds per group (with legal review).

Project: Train LightGBM on the Adult income dataset. Compare impurity, permutation, and SHAP importances. Produce PDP/ICE for age and hours-per-week. Explain 5 individual predictions with SHAP waterfall plots. Compute TPR/FPR by sex and race.


Part 19: From Notebook to Production

19.1 The lifecycle

flowchart LR
    D[Data] --> V[Validate & version]
    V --> T[Train: pipeline + config + tracking]
    T --> R[Register model artifact]
    R --> S[Serve: batch or API]
    S --> M[Monitor: drift, performance]
    M -->|retrain trigger| T

19.2 Package the whole pipeline

The artifact you ship is the entire Pipeline (preprocessing + model), never the model alone.

1
2
3
4
import joblib
joblib.dump(clf, "models/churn_v3.joblib")
clf = joblib.load("models/churn_v3.joblib")
clf.predict_proba(new_df)        # raw DataFrame in, probabilities out

Pin versions (scikit-learn==x.y.z): pickles are not portable across versions. For language-agnostic deployment convert to ONNX (skl2onnx) or PMML; for GBMs use the library’s native save (model.booster_.save_model).

19.3 Experiment tracking

1
2
3
4
5
6
7
8
import mlflow, mlflow.sklearn
mlflow.set_experiment("churn")
with mlflow.start_run():
    mlflow.log_params({"model": "lgbm", "num_leaves": 31, "lr": 0.03})
    clf.fit(X_train, y_train)
    mlflow.log_metrics({"val_auc": roc_auc_score(y_val, clf.predict_proba(X_val)[:, 1])})
    mlflow.sklearn.log_model(clf, "model", registered_model_name="churn")
# mlflow ui  -> browse runs; W&B is the alternative

Log: data version/hash, code commit, params, metrics (with CV std), artifacts (model, plots, feature list), environment.

19.4 Serving

Batch scoring (most common)

1
2
3
4
# predict.py: run nightly by cron / Airflow
df = pd.read_parquet("data/to_score.parquet")
df["score"] = clf.predict_proba(df)[:, 1]
df[["id", "score"]].to_parquet(f"out/scores_{pd.Timestamp.today():%Y%m%d}.parquet")

Real-time API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# serve.py  ->  uvicorn serve:app --port 8000
from fastapi import FastAPI
from pydantic import BaseModel
import joblib, pandas as pd

app = FastAPI(); model = joblib.load("models/churn_v3.joblib")

class Customer(BaseModel):
    age: int; plan: str; monthly_spend: float; tenure_months: int; support_tickets: int

@app.post("/predict")
def predict(c: Customer):
    df = pd.DataFrame([c.model_dump()])
    return {"churn_probability": float(model.predict_proba(df)[0, 1]), "model_version": "v3"}
1
2
3
4
5
6
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "serve:app", "--host", "0.0.0.0", "--port", "8000"]

Validate inputs (types, ranges, unknown categories), log every request and prediction, version the endpoint.

19.5 Monitoring

WhatHow
Data driftcompare feature distributions to training (PSI, KS test, evidently)
Prediction driftdistribution of scores over time
Concept drift / performanceonce labels arrive: AUC, calibration by week; alert on drop
Operationallatency, error rate, missing-feature rate
1
2
3
4
5
6
def psi(expected, actual, bins=10):
    cuts = np.quantile(expected, np.linspace(0, 1, bins + 1)); cuts[0], cuts[-1] = -np.inf, np.inf
    e = np.histogram(expected, cuts)[0] / len(expected) + 1e-6
    a = np.histogram(actual, cuts)[0] / len(actual) + 1e-6
    return ((a - e) * np.log(a / e)).sum()
# PSI < 0.1 stable, 0.1-0.25 moderate shift, > 0.25 investigate / retrain

Set up a retraining schedule (weekly/monthly) plus drift-triggered retraining. Keep a champion/challenger setup and shadow-test new models before switching.

19.6 Testing ML code

1
2
3
4
5
6
7
8
9
def test_pipeline_handles_unknown_category():
    df = X_val.head(3).copy(); df.loc[df.index[0], "embarked"] = "ZZ"
    assert clf.predict_proba(df).shape == (3, 2)

def test_no_leakage_features():
    assert not set(LEAKY_COLUMNS) & set(clf.feature_names_in_)

def test_metric_above_floor():
    assert roc_auc_score(y_val, clf.predict_proba(X_val)[:, 1]) > 0.80

Also: schema tests on incoming data (Great Expectations / Pandera), reproducibility test (same seed -> same metrics).

19.7 Reproducibility checklist

1
2
3
4
5
6
7
[ ] requirements.txt / lockfile + Dockerfile
[ ] seeds fixed everywhere (numpy, model random_state, splitters)
[ ] data snapshot or hash logged with each run (DVC for large files)
[ ] config file (YAML) for every run; CLI overrides logged
[ ] whole Pipeline saved, with feature names and version
[ ] evaluation script separate from training; identical preprocessing
[ ] README: exact command to reproduce the headline metric

19.8 Scaling up

  • Memory: dtype downcasting, categoricals, Parquet, chunked reads, Polars.
  • Compute: n_jobs=-1, LightGBM/XGBoost GPU, Dask-ML, Spark MLlib for truly big data.
  • Feature stores (Feast) for sharing features between training and serving consistently.

Project: Take your Titanic or churn pipeline: track 10 runs in MLflow, save the best pipeline, wrap it in FastAPI + Docker, write 5 tests, and compute PSI between training data and a synthetically shifted dataset.


Part 20: End-to-End Project Walkthrough

Everything above, in one production-shaped script. Problem: customer churn (binary classification, mild imbalance, mixed feature types). Swap in any tabular dataset; the structure stays the same.

20.1 Problem framing

1
2
3
4
5
Goal:        flag customers likely to churn next month so retention team can call them.
Decision:    call the top N customers by churn probability (capacity: N = 500 / month).
Metric:      Precision@500 (business), PR-AUC (model selection), calibration (for expected-value math).
Constraints: must explain why a customer was flagged (SHAP); predictions refreshed monthly.
Baseline:    current rule: "tenure < 6 months and support_tickets > 2".

20.2 The project

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# src/train.py
import argparse, json, yaml
from pathlib import Path
import numpy as np, pandas as pd, joblib
import lightgbm as lgb
from sklearn.model_selection import StratifiedKFold, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.dummy import DummyClassifier
from sklearn.metrics import roc_auc_score, average_precision_score, precision_recall_curve, brier_score_loss
from sklearn.calibration import CalibratedClassifierCV
from sklearn.base import BaseEstimator, TransformerMixin


# ---------- 1. data ----------
def load(path):
    df = pd.read_parquet(path)
    df = df.drop_duplicates(subset="customer_id")
    assert df["churn"].isin([0, 1]).all()
    return df

LEAKY = ["cancellation_date", "refund_amount", "exit_survey_score"]      # happen AFTER churn: never use
ID = "customer_id"; TARGET = "churn"


# ---------- 2. features ----------
class DomainFeatures(BaseEstimator, TransformerMixin):
    def fit(self, X, y=None): return self
    def transform(self, X):
        X = X.copy()
        X["spend_per_month"] = X["total_spend"] / (X["tenure_months"] + 1)
        X["tickets_per_month"] = X["support_tickets"] / (X["tenure_months"] + 1)
        X["is_new"] = (X["tenure_months"] < 6).astype(int)
        X["days_since_login"] = (pd.Timestamp("today") - pd.to_datetime(X["last_login"])).dt.days
        return X.drop(columns=["last_login"])

NUM = ["age", "tenure_months", "total_spend", "support_tickets", "monthly_spend",
       "spend_per_month", "tickets_per_month", "is_new", "days_since_login"]
CAT = ["plan", "region", "payment_method"]

def build_preprocessor():
    num = Pipeline([("impute", SimpleImputer(strategy="median", add_indicator=True)), ("scale", StandardScaler())])
    cat = Pipeline([("impute", SimpleImputer(strategy="constant", fill_value="missing")),
                    ("onehot", OneHotEncoder(handle_unknown="ignore", min_frequency=20))])
    return ColumnTransformer([("num", num, NUM), ("cat", cat, CAT)])


# ---------- 3. models ----------
def candidates(seed):
    pre = build_preprocessor
    return {
        "dummy": Pipeline([("feat", DomainFeatures()), ("pre", pre()), ("m", DummyClassifier(strategy="prior"))]),
        "logreg": Pipeline([("feat", DomainFeatures()), ("pre", pre()),
                            ("m", LogisticRegression(C=0.5, class_weight="balanced", max_iter=2000))]),
        "lgbm": Pipeline([("feat", DomainFeatures()), ("pre", pre()),
                          ("m", lgb.LGBMClassifier(n_estimators=600, learning_rate=0.03, num_leaves=31,
                                                   min_child_samples=30, subsample=0.8, subsample_freq=1,
                                                   colsample_bytree=0.8, reg_lambda=1.0,
                                                   random_state=seed, verbose=-1))]),
    }


# ---------- 4. evaluation ----------
def precision_at_n(y, p, n=500):
    top = np.argsort(-p)[:n]; return y[top].mean()

def cv_evaluate(pipe, X, y, seed):
    cv = StratifiedKFold(5, shuffle=True, random_state=seed); out = {"auc": [], "ap": [], "p@500": []}
    for tr, va in cv.split(X, y):
        pipe.fit(X.iloc[tr], y.iloc[tr]); p = pipe.predict_proba(X.iloc[va])[:, 1]
        out["auc"].append(roc_auc_score(y.iloc[va], p)); out["ap"].append(average_precision_score(y.iloc[va], p))
        out["p@500"].append(precision_at_n(y.iloc[va].values, p))
    return {k: (np.mean(v), np.std(v)) for k, v in out.items()}


# ---------- 5. main ----------
def main(cfg):
    np.random.seed(cfg["seed"])
    df = load(cfg["data"])
    X = df.drop(columns=[TARGET, ID] + LEAKY); y = df[TARGET]
    X_trainval, X_test, y_trainval, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=cfg["seed"])

    results = {}
    for name, pipe in candidates(cfg["seed"]).items():
        results[name] = cv_evaluate(pipe, X_trainval, y_trainval, cfg["seed"])
        print(f"{name:7s} " + "  ".join(f"{k} {m:.4f}+/-{s:.4f}" for k, (m, s) in results[name].items()))

    best_name = max(results, key=lambda k: results[k]["ap"][0])
    best = candidates(cfg["seed"])[best_name]

    # calibrate on trainval with internal CV, then fit on everything
    final = CalibratedClassifierCV(best, method="isotonic", cv=5).fit(X_trainval, y_trainval)

    # ONE honest test evaluation
    p_test = final.predict_proba(X_test)[:, 1]
    test_metrics = {"auc": roc_auc_score(y_test, p_test), "ap": average_precision_score(y_test, p_test),
                    "p@500": precision_at_n(y_test.values, p_test), "brier": brier_score_loss(y_test, p_test)}
    print("TEST:", json.dumps({k: round(v, 4) for k, v in test_metrics.items()}))

    # threshold for the capacity constraint: score of the 500th customer
    thr = np.sort(p_test)[::-1][min(499, len(p_test) - 1)]

    out = Path(cfg["out"]); out.mkdir(parents=True, exist_ok=True)
    joblib.dump(final, out / "model.joblib")
    json.dump({"model": best_name, "cv": results, "test": test_metrics, "threshold": float(thr),
               "features": {"num": NUM, "cat": CAT}, "config": cfg}, open(out / "metadata.json", "w"), indent=2, default=float)

if __name__ == "__main__":
    ap = argparse.ArgumentParser(); ap.add_argument("--config", default="configs/churn.yaml")
    main(yaml.safe_load(open(ap.parse_args().config)))
1
2
3
4
# configs/churn.yaml
data: data/processed/customers.parquet
out: models/churn_v1
seed: 42

20.3 Then

  1. Error analysis on validation folds: which segments have low precision? Any label noise?
  2. SHAP summary + per-customer explanations for the retention team.
  3. Tune LightGBM with Optuna (Part 13) only if the gap to the business goal justifies it.
  4. Ship: predict.py batch job, MLflow registration, monitoring with PSI, monthly retrain.
  5. Write the README: problem, data, approach, results table (with CIs), how to reproduce, limitations.

20.4 The habits that separate professionals

1
2
3
4
5
6
7
8
[ ] Baseline before anything fancy
[ ] Leakage audit of every feature ("would I know this at prediction time?")
[ ] Pipeline for ALL preprocessing; fit only on train folds
[ ] Metric chosen from the business decision, not from habit
[ ] CV with std; test set touched exactly once
[ ] Calibrated probabilities if anyone will treat them as probabilities
[ ] Slice-level evaluation, error analysis, explanations
[ ] Reproducible: config + seed + versions + saved artifact

Part 21: Career Roadmap & Projects

21.1 What an ML engineer / data scientist actually does

1
2
3
4
5
   ~35% data: finding it, cleaning it, building features, validating it
   ~20% modeling: baselines, candidates, tuning, evaluation
   ~20% engineering: pipelines, serving, monitoring, cost
   ~15% analysis & communication: why does it work, what should we do, writing it up
   ~10% experimentation: A/B tests, causal questions

Roles: Data Scientist (analysis + modeling + communication), ML Engineer (production ML systems), Analytics Engineer (data pipelines/BI), Applied Scientist (research-flavored modeling), MLOps (platform).

21.2 Portfolio projects

LevelProjectSkills shown
BeginnerTitanic / housing with full Pipeline, CV, and a written analysisfundamentals, preprocessing
BeginnerImplement linear/logistic regression, KNN, decision tree, K-means, PCA from scratch (a mini-sklearn)understanding
IntermediateKaggle tabular competition top 20% with LightGBM + feature engineering + stackingthe practical toolkit
IntermediateImbalanced fraud detection with cost-based thresholding, calibration, PR-AUC reportingevaluation maturity
IntermediateCustomer segmentation with RFM + clustering + business write-upunsupervised, communication
IntermediateDemand forecasting with backtesting across time windowstime series
AdvancedRecommender with matrix factorization + ranking metrics + cold-start strategyrecsys
AdvancedEnd-to-end deployed model: FastAPI + Docker + MLflow + drift monitoring + testsMLOps
AdvancedReproduce a classic paper (XGBoost, SMOTE, Isolation Forest) and benchmark itresearch skills
AdvancedA/B test analysis with power calculation, CUPED, and a causal inference write-upexperimentation

A good project README: problem -> data -> approach -> results (table with CIs) -> how to reproduce -> limitations and next steps.

21.3 Interview topics (whiteboard-ready)

  • Bias-variance tradeoff; how to diagnose over/underfitting from learning curves.
  • Derive the gradient of logistic regression; why cross-entropy and not MSE.
  • L1 vs L2 regularization: geometry, sparsity, when to use which.
  • How a decision tree chooses splits; Gini vs entropy; why trees overfit; how RF and GBM fix it.
  • Random forest vs gradient boosting: when each wins; key hyperparameters.
  • Precision/recall/F1/ROC/PR-AUC; when accuracy misleads; threshold selection.
  • Cross-validation variants; nested CV; leakage examples; time-series splits.
  • K-means algorithm, its assumptions, choosing K; DBSCAN vs K-means.
  • PCA: what it optimizes, relationship to SVD, when to standardize.
  • SVM: margin, kernel trick, C and gamma.
  • Naive Bayes assumption; why it still works for text.
  • Handling imbalance, missing values, categorical variables with high cardinality.
  • Explain a model to a non-technical stakeholder; SHAP in one sentence.
  • System design: fraud detection / churn / recommendation pipeline end to end.
  • SQL and pandas data manipulation; probability puzzles; A/B test statistics.

21.4 How to keep learning

  • Reimplement one algorithm per week from scratch; then read the sklearn source for it.
  • Do a Kaggle competition and read the winning solutions afterwards.
  • Read the scikit-learn user guide cover to cover; it is excellent.
  • Follow: Kaggle discussions, Towards Data Science (selectively), the scikit-learn / LightGBM release notes.
  • Then continue to the Deep Learning guide (README.md).

Appendix A: Cheat Sheets

A.1 Algorithm selection

SituationTry firstThen
Tabular classification/regression, n > 1kLightGBM / XGBoost / CatBoostRF, logistic/linear, stacking
Tabular, small n (< 1k)logistic / ridge, RFSVM, KNN, NB
Many features, sparse (text, one-hot)logistic / linear SVM with L1/L2NB, LightGBM
Need interpretabilitylogistic regression, small tree, GAM+ SHAP on a GBM
Need probabilitieslogistic regression; calibrate others 
Nonlinear, low-dimensional, small nRBF SVM, KNNGBM
Unsupervised groupingK-means (after scaling)GMM, HDBSCAN, hierarchical
VisualizationPCAUMAP / t-SNE
AnomaliesIsolation ForestLOF, GMM density
Time serieslag features + LightGBMETS, SARIMA, Prophet
Recommendationitem-based CF, matrix factorizationimplicit ALS, two-tower
Text classificationTF-IDF + logistic / linear SVMNB, sentence embeddings, fine-tuned Transformer

A.2 scikit-learn API in one screen

1
2
3
4
5
6
7
8
9
10
est.fit(X, y)                     # learn
est.predict(X)                    # labels / values
est.predict_proba(X)              # class probabilities (classifiers)
est.decision_function(X)          # raw scores (SVM, logistic)
est.score(X, y)                   # default metric (accuracy / R2)
trf.fit_transform(X); trf.transform(X)
est.get_params(); est.set_params(**p)
pipe.named_steps["model"]; pipe[-1]; pipe[:-1]
est.feature_names_in_; est.n_features_in_; pipe[:-1].get_feature_names_out()
clone(est)                        # fresh unfitted copy

A.3 Default hyperparameters to start from

ModelStarting point
LogisticRegressionC=1.0, max_iter=1000, class_weight="balanced" if imbalanced; scale inputs
Ridge / LassoRidgeCV(alphas=logspace(-3,3)), LassoCV()
KNNk=sqrt(n), weights="distance", scale inputs
DecisionTreemax_depth=5, min_samples_leaf=5
RandomForestn_estimators=500, max_features="sqrt", min_samples_leaf=1-5, n_jobs=-1
LightGBMlr=0.03, n_estimators=2000 + early stopping, num_leaves=31, min_child_samples=20, subsample=0.8, colsample_bytree=0.8
XGBoostlr=0.03, max_depth=6, subsample=0.8, colsample_bytree=0.8, early stopping
SVCC in logspace(-2,3), gamma="scale", scale inputs
KMeansn_init=10, scale inputs, choose k via silhouette
PCAn_components=0.95 after StandardScaler
IsolationForestn_estimators=300, contamination = known rate or “auto”

A.4 Metric selection

ProblemPrimarySecondary
Balanced binaryaccuracy, ROC-AUCF1, log loss
Imbalanced binaryPR-AUC, recall@precisionF1, MCC, Brier
Multiclassmacro-F1, accuracyper-class confusion
RegressionRMSE or MAE (choose by cost of big errors)R², MAPE
Ranking / recsysNDCG@k, MAP@kprecision@k, hit rate
ForecastingMASE, sMAPERMSE, coverage of intervals
Clusteringsilhouette + domain senseARI if labels exist

A.5 Common bugs

SymptomLikely cause
CV score ≫ test scoreleakage, tuning on test, group/time leakage
Val score ≫ train scoredropout/regularization only at train (fine) or a leak in val
Accuracy 99% on rare-event datapredicting majority class; use PR-AUC
Coefficients huge / unstablemulticollinearity, unscaled features, no regularization
GBM val loss rising with treesoverfitting; use early stopping, lower LR
K-means clusters look like stripesunscaled features
Great offline, bad onlinedistribution shift, feature skew between training and serving
Different results each runseeds not set, n_jobs nondeterminism, data order
ValueError: could not convert string to floatcategorical not encoded; use ColumnTransformer
Shape mismatch at predict timedifferent columns/order; use a Pipeline with named columns

Appendix B: Glossary

TermMeaning
Accuracyfraction of correct predictions
AUCarea under the ROC (or PR) curve
Baggingbootstrap aggregating; train on resamples, average
Baselinetrivial model to beat (majority class, mean)
Bias (statistical)systematic error from a too-simple model
Boostingsequential ensemble where each model corrects the last
Bootstrapresampling with replacement to estimate uncertainty
Calibrationpredicted probabilities match observed frequencies
Categorical featurediscrete values (color, country)
Class imbalanceone class much rarer than others
Clusteringgrouping unlabeled points by similarity
Collinearityfeatures linearly dependent on each other
Confusion matrixcounts of TP, FP, FN, TN
Cross-validationrepeated train/validate splits for robust estimates
Curse of dimensionalitydata sparsity and distance breakdown in high dimensions
Data leakageinformation from outside the training data (or from the target) contaminating training
Decision boundarysurface separating predicted classes
Dimensionality reductionfewer features preserving structure (PCA, UMAP)
Early stoppingstop boosting/training when validation stops improving
EM algorithmiterative expectation/maximization for latent-variable models (GMM)
Ensemblecombination of models
Entropy / Giniimpurity measures for tree splits
Epochone pass over the training data
Feature engineeringcreating informative inputs from raw data
Feature importancecontribution of each feature to the model
Generalizationperformance on unseen data
Gradient descentiterative minimization following the negative gradient
Hyperparametersetting chosen before training (depth, C, k)
Imputationfilling missing values
Inferencemaking predictions with a trained model
Kernelsimilarity function enabling nonlinear SVMs
Labelthe target value y
Lasso / RidgeL1 / L2 regularized linear regression
Learning ratestep size in gradient descent / boosting shrinkage
Log losscross-entropy between true labels and predicted probabilities
MLE / MAPmaximum likelihood / maximum a posteriori estimation
Multicollinearitysee collinearity
One-hot encodingbinary indicator column per category
Out-of-bag (OOB)samples not in a bootstrap draw; free validation for bagging
Overfittingfitting noise; train ≫ validation performance
Parametervalue learned from data (weights, split thresholds)
PCAprincipal component analysis
Pipelinechained transformers + estimator fit/applied as a unit
Precision / RecallTP/(TP+FP) / TP/(TP+FN)
Regularizationpenalty discouraging complex models
Residualy - ŷ
ROC curveTPR vs FPR across thresholds
SHAPShapley-value feature attributions
SMOTEsynthetic minority oversampling
Standardization(x - mean)/std
Stationaritytime series with constant statistical properties
Stratificationpreserving class ratios in splits
Support vectortraining point on the SVM margin
Target encodingreplace category with mean target (do inside CV)
TF-IDFterm frequency times inverse document frequency
Thresholdprobability cutoff for a positive prediction
Underfittingmodel too simple; high train and validation error
Variance (model)sensitivity to the particular training sample
Weak learnermodel slightly better than chance (stumps in boosting)

Appendix C: Books, Courses & Resources

Books

  • An Introduction to Statistical Learning (ISLR / ISLP, free PDF) -> the best first ML book; Python edition available
  • The Elements of Statistical Learning (free PDF) -> the deeper companion
  • Hands-On Machine Learning with Scikit-Learn, Keras & TensorFlow -> Aurelien Geron; practical and complete
  • Pattern Recognition and Machine Learning -> Bishop; probabilistic view
  • Machine Learning: A Probabilistic Perspective / Probabilistic Machine Learning -> Kevin Murphy (free drafts)
  • Python for Data Analysis -> Wes McKinney (pandas)
  • Feature Engineering for Machine Learning -> Zheng & Casari
  • Interpretable Machine Learning -> Christoph Molnar (free online)
  • Designing Machine Learning Systems -> Chip Huyen
  • Forecasting: Principles and Practice -> Hyndman & Athanasopoulos (free online)
  • Trustworthy Online Controlled Experiments -> Kohavi et al. (A/B testing)

Courses

  • Andrew Ng, Machine Learning Specialization (Coursera)
  • Stanford CS229 (lecture notes are superb)
  • StatQuest (YouTube) for crystal-clear intuition on every algorithm
  • Kaggle Learn micro-courses (pandas, feature engineering, intermediate ML)
  • fast.ai Practical Deep Learning (for the next step)
  • scikit-learn User Guide and example gallery (read it as a course)

Papers worth reading

  • Breiman (2001) Random Forests; Breiman (2001) Statistical Modeling: The Two Cultures
  • Friedman (2001) Greedy Function Approximation: A Gradient Boosting Machine
  • Chen & Guestrin (2016) XGBoost; Ke et al. (2017) LightGBM; Prokhorenkova et al. (2018) CatBoost
  • Cortes & Vapnik (1995) Support-Vector Networks
  • Chawla et al. (2002) SMOTE
  • Liu et al. (2008) Isolation Forest
  • Lundberg & Lee (2017) A Unified Approach to Interpreting Model Predictions (SHAP)
  • Ng & Jordan (2002) On Discriminative vs. Generative Classifiers
  • Domingos (2012) A Few Useful Things to Know About Machine Learning
  • Kaufman et al. (2012) Leakage in Data Mining
  • Grinsztajn et al. (2022) Why do tree-based models still outperform deep learning on tabular data?

Tools map

NeedTool
Core MLscikit-learn
Gradient boostingLightGBM, XGBoost, CatBoost
Datapandas, Polars, NumPy, SciPy
Statisticsstatsmodels, SciPy
Time seriesstatsforecast, sktime, Prophet, darts
Imbalanceimbalanced-learn
TuningOptuna, scikit-optimize, Ray Tune
InterpretabilitySHAP, LIME, interpret, sklearn.inspection
TrackingMLflow, Weights & Biases
Data validationGreat Expectations, Pandera
ServingFastAPI, BentoML, MLflow models, ONNX
MonitoringEvidently, whylogs
Big dataDask, Spark MLlib, Polars
Recsysimplicit, surprise, LightFM
NLP (classical)scikit-learn, spaCy, gensim, NLTK

If this guide helped you, star the repo, share it, and go build something.
Contributions, corrections, and new sections are welcome. Open a PR.

This post is licensed under CC BY 4.0 by the author.