VivaPrep
← Jaber Notes

Jaber Notes · 7 of 16

Classical Algorithms

k-NN, Naive Bayes, decision trees, SVM, k-means, GMM + EM.

The classic model zoo with real derivations: k-NN and kernel regression, Naive Bayes, CART splits from Gini/entropy, the SVM primal-to-dual derivation and kernel trick, Lloyd's algorithm, and the full EM derivation for GMMs.

Visual reference

Bayes' theorem as a tree

1000 peopledisease (1%) — 10healthy (99%) — 990test + : ~10test − : ~0test + (false alarm): ~10test − : ~980≈10 real positives vs ≈10 false alarms → only ~50% of "+" results are real
Even with a 99%-accurate test, a 1% base rate means most positive results come from the huge healthy group's false alarms, not the small sick group — the base-rate fallacy.
Full derivations and theory for each algorithm: assumptions, objective, math, training, prediction, pros/cons.

Table of contents

  1. k-Nearest Neighbors (k-NN)
  2. Naive Bayes classifiers
  3. Decision trees (CART)
  4. Support vector machines (SVM)
  5. k-Means clustering (Lloyd's algorithm)
  6. Gaussian Mixture Models (GMM) and EM

1. k-Nearest Neighbors (k-NN)

1.1 Algorithm

Non-parametric, instance-based learning — no parameters to fit; the training set is the model.

Prediction for a new point x\mathbf{x}:

  1. Compute distance from x\mathbf{x} to every training point.
  2. Find the kk nearest neighbors Nk(x)\mathcal{N}_k(\mathbf{x}).
  3. Classification: majority vote: y^=mode{yi:iNk(x)}\hat{y} = \text{mode}\{y_i : i \in \mathcal{N}_k(\mathbf{x})\}.

Regression: average: y^=1kiNk(x)yi\hat{y} = \frac{1}{k}\sum_{i\in\mathcal{N}_k(\mathbf{x})} y_i.

1.2 Distance metrics

Euclidean (L2): d(x,x)=xx2d(\mathbf{x},\mathbf{x}') = \|\mathbf{x}-\mathbf{x}'\|_2. Standard; assumes features have similar scales.

Manhattan (L1): d(x,x)=xx1d(\mathbf{x},\mathbf{x}') = \|\mathbf{x}-\mathbf{x}'\|_1. Robust to outliers.

Minkowski (Lp): d(x,x)=xxp=(jxjxjp)1/pd(\mathbf{x},\mathbf{x}') = \|\mathbf{x}-\mathbf{x}'\|_p = \left(\sum_j |x_j - x'_j|^p\right)^{1/p}. Generalizes both.

Cosine distance: 1cosθ1 - \cos\theta. Useful for high-dimensional sparse data (text).

Mahalanobis: d(x,x)=(xx)Σ1(xx)d(\mathbf{x},\mathbf{x}') = \sqrt{(\mathbf{x}-\mathbf{x}')^\top \boldsymbol{\Sigma}^{-1}(\mathbf{x}-\mathbf{x}')}. Accounts for feature correlations; scale-invariant.

1.3 Effect of k

kEffect
k=1Lowest bias, highest variance; decision boundary very irregular
Large kHigher bias, lower variance; smoother boundary; approaches global majority vote
k=nAlways predicts global majority class

Optimal k: use cross-validation. Typical: k=nk = \sqrt{n} as heuristic starting point.

1.4 Complexity

  • Training: O(1)O(1) (store data).
  • Prediction (naive): O(nd)O(nd) per query — must compare to all nn training points in dd dimensions.
  • Approximate methods: KD-tree (O(dlogn)O(d\log n) average for low dd), ball tree, HNSW (for large-scale approximate NN).

1.5 Characteristics

ProCon
No training timeSlow prediction (O(nd)O(nd))
Naturally multi-classHigh memory (stores all data)
Handles complex boundariesFeature scaling required
No assumptions on data distributionDegrades in high dimensions (curse of dimensionality)
Works well for small nnSensitive to irrelevant features

1.6 k-NN regression: Nadaraya-Watson interpretation

k-NN regression is a special case of kernel regression with a uniform kernel:

f^(x)=iK ⁣(xxih)yiiK ⁣(xxih),\hat{f}(\mathbf{x}) = \frac{\sum_i K\!\left(\frac{\|\mathbf{x}-\mathbf{x}_i\|}{h}\right) y_i}{\sum_i K\!\left(\frac{\|\mathbf{x}-\mathbf{x}_i\|}{h}\right)},

where KK is uniform over the kk-NN ball (bandwidth hh adapts to local density).


2. Naive Bayes classifiers

2.1 Generative model and Bayes rule

Use Bayes' theorem for classification:

P(y=cx)=P(xy=c)P(y=c)P(x).P(y=c|\mathbf{x}) = \frac{P(\mathbf{x}|y=c)\,P(y=c)}{P(\mathbf{x})}.

Since P(x)P(\mathbf{x}) is the same for all classes, predict:

y^=argmaxcP(y=c)priorP(xy=c).\hat{y} = \arg\max_c \underbrace{P(y=c)}_\text{prior} \cdot P(\mathbf{x}|y=c).

The Naive assumption: features are conditionally independent given the class:

P(xy=c)=j=1dP(xjy=c).P(\mathbf{x}|y=c) = \prod_{j=1}^d P(x_j|y=c).

This reduces a dd-dimensional joint distribution to dd univariate distributions — tractable even in high dimensions.

2.2 Training

Prior: P^(y=c)=nc+αn+Kα\hat{P}(y=c) = \frac{n_c + \alpha}{n + K\alpha} (with Laplace smoothing α\alpha).

Per-feature likelihoods:

  • Gaussian NB (continuous features): assume xjy=cN(μcj,σcj2)x_j|y=c \sim \mathcal{N}(\mu_{cj},\sigma_{cj}^2). Estimate μ^cj=mean{xij:yi=c}\hat{\mu}_{cj} = \text{mean}\{x_{ij}:y_i=c\}, σ^cj2=var{xij:yi=c}\hat{\sigma}^2_{cj} = \text{var}\{x_{ij}:y_i=c\}.
  • Bernoulli NB (binary features): P(xj=1y=c)=pcjP(x_j=1|y=c) = p_{cj}. Estimate from empirical frequency.
  • Multinomial NB (count features): P(xjy=c)θcjxjP(x_j|y=c) \propto \theta_{cj}^{x_j}. MLE: θ^cj=(ncj+α)/(knck+dα)\hat\theta_{cj} = (n_{cj}+\alpha)/(\sum_k n_{ck}+d\alpha).

2.3 Prediction (log-sum for numerical stability)

y^=argmaxc[logP(y=c)+j=1dlogP(xjy=c)].\hat{y} = \arg\max_c \left[\log P(y=c) + \sum_{j=1}^d \log P(x_j|y=c)\right].

Computing in log-space avoids underflow from multiplying many small probabilities.

2.4 Why it often works despite the naive assumption

Even if features are correlated, NB's decision boundary can still be correct. The calibrated probabilities may be off, but the predicted class often isn't.

2.5 Laplace smoothing (additive smoothing)

With zero occurrences in training, P(xjy=c)=0P(x_j|y=c) = 0 → entire product = 0 regardless of other features. Laplace smoothing adds pseudocount α\alpha:

P^(xj=vy=c)=ncj(v)+αnc+αV,\hat{P}(x_j=v|y=c) = \frac{n_{cj}^{(v)} + \alpha}{n_c + \alpha V},

where VV = vocabulary size.

ProCon
Very fast training O(nd)O(nd)Strong independence assumption
Works with very little dataPoor probability estimates (uncalibrated)
Handles high-dimensional text naturallyCannot capture feature interactions
Robust with Laplace smoothingNumerical issues without log-space computation

3. Decision trees (CART)

3.1 Structure

A binary tree where each internal node tests a feature threshold (xjtx_j \leq t), each leaf outputs a prediction.

3.2 Impurity measures (classification)

Gini impurity at node SS:

G(S)=k=1Kpk(1pk)=1kpk2.G(S) = \sum_{k=1}^K p_k(1-p_k) = 1 - \sum_k p_k^2.

Entropy:

H(S)=k=1Kpklog2pk.H(S) = -\sum_{k=1}^K p_k \log_2 p_k.

Information gain: reduction in impurity from a split SSL,SRS \to S_L, S_R:

IG(S,j,t)=I(S)SLSI(SL)SRSI(SR).\text{IG}(S, j, t) = I(S) - \frac{|S_L|}{|S|}I(S_L) - \frac{|S_R|}{|S|}I(S_R).

Gini vs Entropy: both work similarly in practice. Gini slightly favors larger partitions; entropy slightly more computationally expensive (log). CART uses Gini by default.

Regression impurity: MSE of node.

I(S)=1SiS(yiyˉS)2.I(S) = \frac{1}{|S|}\sum_{i\in S}(y_i - \bar{y}_S)^2.

3.3 CART algorithm

function BuildTree(S):
  if stopping_criterion(S):
      return Leaf(prediction(S))
  
  (j*, t*) = argmin_{j,t}  [Gini-weighted split of S on (j,t)]
  S_L = {x ∈ S : x_j ≤ t}
  S_R = {x ∈ S : x_j > t}
  
  return Node(feature=j*, threshold=t*,
              left=BuildTree(S_L),
              right=BuildTree(S_R))

Finding the best split: for each feature jj, sort by xjx_j, try all thresholds between consecutive distinct values. Cost: O(nplogn)O(np \log n) per node → O(nplog2n)O(np \log^2 n) for full tree (average).

3.4 Leaf prediction

  • Classification: majority class.
  • Regression: mean of targets.

3.5 Stopping criteria (pre-pruning)

  • max_depth: maximum tree depth.
  • min_samples_split: minimum samples to split a node.
  • min_samples_leaf: minimum samples in a leaf.
  • min_impurity_decrease: split only if improvement exceeds threshold.

3.6 Pruning (post-pruning)

Cost-complexity pruning (sklearn ccp_alpha): add complexity penalty αT\alpha \cdot |T| (number of leaves) to training loss. Increasing α\alpha prunes more leaves. Select α\alpha via cross-validation.

3.7 Bias-variance of trees

  • Fully grown tree: high variance, low bias (overfits to training set).
  • Shallow tree: low variance, high bias (underfits).
  • Regularize with depth, min-samples, or pruning.

3.8 Key properties

ProCon
Interpretable (can visualize)High variance (unstable)
No feature scaling neededNon-smooth boundaries
Handles mixed typesBiased toward high-cardinality features
Handles non-linearities automaticallyNot globally optimal
Fast inference: O(depth)O(\text{depth})Prone to overfitting

4. Support vector machines (SVM)

4.1 Maximum margin classifier (linearly separable case)

For binary labels yi{1,+1}y_i \in \{-1,+1\}, find hyperplane wx+b=0\mathbf{w}^\top\mathbf{x}+b=0 that maximizes the margin between classes.

The margin is the perpendicular distance from the hyperplane to the nearest points. For normalized w=1\|\mathbf{w}\|=1, the distance from point xi\mathbf{x}_i to the hyperplane is yi(wxi+b)y_i(\mathbf{w}^\top\mathbf{x}_i+b).

Margin =2/w= 2/\|\mathbf{w}\| (when support vectors satisfy yi(wxi+b)=1y_i(\mathbf{w}^\top\mathbf{x}_i+b)=1).

Primal optimization problem:

minw,b12w2s.t.yi(wxi+b)1    i.\min_{\mathbf{w},b} \frac{1}{2}\|\mathbf{w}\|^2 \quad \text{s.t.} \quad y_i(\mathbf{w}^\top\mathbf{x}_i+b) \geq 1 \;\; \forall i.

Quadratic program (QP) with linear constraints → unique solution.

4.2 Lagrangian and dual formulation

Form Lagrangian with multipliers αi0\alpha_i \geq 0:

L(w,b,α)=12w2iαi[yi(wxi+b)1].\mathcal{L}(\mathbf{w},b,\boldsymbol{\alpha}) = \frac{1}{2}\|\mathbf{w}\|^2 - \sum_i \alpha_i[y_i(\mathbf{w}^\top\mathbf{x}_i+b) - 1].

KKT stationarity conditions:

wL=0w=iαiyixi.\nabla_\mathbf{w}\mathcal{L} = \mathbf{0} \Rightarrow \mathbf{w} = \sum_i \alpha_i y_i \mathbf{x}_i.
bL=0iαiyi=0.\nabla_b\mathcal{L} = 0 \Rightarrow \sum_i \alpha_i y_i = 0.

Substituting back, the dual problem:

maxαiαi12i,jαiαjyiyjxixjs.t.αi0,    iαiyi=0.\max_{\boldsymbol{\alpha}} \sum_i \alpha_i - \frac{1}{2}\sum_{i,j}\alpha_i\alpha_j y_i y_j \mathbf{x}_i^\top\mathbf{x}_j \quad \text{s.t.} \quad \alpha_i \geq 0, \;\; \sum_i \alpha_i y_i = 0.

Prediction:

y^=sign ⁣(iαiyixixnew+b).\hat{y} = \text{sign}\!\left(\sum_i \alpha_i y_i \mathbf{x}_i^\top\mathbf{x}_\text{new} + b\right).

Support vectors: points with αi>0\alpha_i > 0. By complementary slackness: αi(yi(wxi+b)1)=0\alpha_i(y_i(\mathbf{w}^\top\mathbf{x}_i+b)-1)=0, so support vectors lie exactly on the margin boundary. Typically a small fraction of training set. The solution only depends on support vectors.

4.3 Soft margin SVM (non-separable data)

Introduce slack ξi0\xi_i \geq 0 (allows some misclassification):

minw,b,ξ12w2+Ciξis.t.yi(wxi+b)1ξi,    ξi0.\min_{\mathbf{w},b,\boldsymbol{\xi}} \frac{1}{2}\|\mathbf{w}\|^2 + C\sum_i \xi_i \quad \text{s.t.} \quad y_i(\mathbf{w}^\top\mathbf{x}_i+b) \geq 1-\xi_i, \;\; \xi_i \geq 0.

C controls tradeoff: large C → penalize violations heavily → smaller margin, fewer support vectors, more overfit. Small C → allow more violations → larger margin, more regularized.

Dual: same structure but 0αiC0 \leq \alpha_i \leq C (bounded dual variables).

Hinge loss equivalence:

Primalminw1C12w2+1nimax(0,1yi(wxi+b)).\text{Primal} \equiv \min_\mathbf{w} \frac{1}{C}\cdot\frac{1}{2}\|\mathbf{w}\|^2 + \frac{1}{n}\sum_i \max(0, 1 - y_i(\mathbf{w}^\top\mathbf{x}_i+b)).

Hinge loss: (z)=max(0,1z)\ell(z) = \max(0, 1-z). Convex, non-differentiable at z=1z=1.

4.4 Kernel trick

In dual problem, data appears only in inner products xixj\mathbf{x}_i^\top\mathbf{x}_j. Replace with a kernel function:

K(xi,xj)=ϕ(xi)ϕ(xj).K(\mathbf{x}_i, \mathbf{x}_j) = \phi(\mathbf{x}_i)^\top\phi(\mathbf{x}_j).

We never need to compute ϕ\phi explicitly — only the kernel value. This implicitly works in a (possibly infinite-dimensional) feature space at the cost of the inner product.

KernelFormulaFeature space
Linearxixj\mathbf{x}_i^\top\mathbf{x}_jOriginal space
Polynomial(γxixj+r)d(\gamma\mathbf{x}_i^\top\mathbf{x}_j+r)^dDegree-dd monomials
RBF (Gaussian)exp(γxixj2)\exp(-\gamma\|\mathbf{x}_i-\mathbf{x}_j\|^2)Infinite-dimensional
Sigmoidtanh(γxixj+r)\tanh(\gamma\mathbf{x}_i^\top\mathbf{x}_j+r)Not always PSD

RBF kernel interpretation: measures similarity as function of Euclidean distance. Hyperparameter γ=1/(2σ2)\gamma = 1/(2\sigma^2): large γ\gamma → narrow Gaussian → model fits locally (high variance); small γ\gamma → broad Gaussian → smoother decision (high bias).

4.5 Mercer's theorem

A function KK is a valid kernel iff the kernel (Gram) matrix Kij=K(xi,xj)\mathbf{K}_{ij} = K(\mathbf{x}_i,\mathbf{x}_j) is PSD for any set of inputs. PSD kernels correspond to inner products in some feature space.

4.6 SMO algorithm

Sequential Minimal Optimization (Platt, 1998): solve SVM dual by optimizing over 2 variables at a time (smallest QP with closed form). The standard algorithm for training SVMs. libsvm uses it.

4.7 SVM for multiclass

  • OvO: train (K2)\binom{K}{2} binary SVMs, majority vote. sklearn default.
  • OvR: train KK SVMs, pick highest score.

4.8 Properties

ProCon
Effective in high dimensionsSlow for large nn (O(n2)O(n^2) to O(n3)O(n^3))
Theoretically motivated (margin/VC)Choosing right kernel requires tuning
Robust to outliers (soft margin)Less interpretable than trees
Kernel trick extends to nonlinearNo native probability outputs

5. k-Means clustering (Lloyd's algorithm)

5.1 Objective

Given data X={xi}i=1n\mathbf{X} = \{\mathbf{x}_i\}_{i=1}^n and target clusters kk, minimize:

J(μ1,,μk,z1,,zn)=i=1nxiμzi2,J(\boldsymbol{\mu}_1,\ldots,\boldsymbol{\mu}_k, z_1,\ldots,z_n) = \sum_{i=1}^n \|\mathbf{x}_i - \boldsymbol{\mu}_{z_i}\|^2,

where zi{1,,k}z_i \in \{1,\ldots,k\} is the cluster assignment of point ii and μc\boldsymbol{\mu}_c is the centroid of cluster cc.

This is NP-hard in general. Lloyd's algorithm finds a local minimum.

5.2 Lloyd's algorithm (alternating optimization)

  1. Initialize: choose kk centroids.
  2. Assignment step: zi=argmincxiμc2z_i = \arg\min_c \|\mathbf{x}_i - \boldsymbol{\mu}_c\|^2. (Assign each point to nearest centroid.)
  3. Update step: μc=1Cci:zi=cxi\boldsymbol{\mu}_c = \frac{1}{|C_c|}\sum_{i:z_i=c}\mathbf{x}_i. (Move centroid to mean of assigned points.)
  4. Repeat until assignments don't change.

Convergence: objective JJ decreases or stays constant at each step (both assignment and update are greedy improvements). Converges in finite steps (finite assignments). But converges to a local minimum.

5.3 k-means++ initialization

Standard random init can lead to poor local minima. k-means++ (Arthur & Vassilvitskii, 2007):

  1. Choose first centroid uniformly at random.
  2. For each subsequent centroid: sample point with probability d(x,nearest centroid)2\propto d(\mathbf{x}, \text{nearest centroid})^2.
  3. Repeat until kk centroids chosen.

Guarantees expected cost O(logk)O(\log k) times the optimal. sklearn uses k-means++ by default.

5.4 Choosing k

  • Elbow method: plot JJ vs kk; look for "elbow" where improvement diminishes.
  • Silhouette coefficient: s(i)=b(i)a(i)max(a(i),b(i))s(i) = \frac{b(i)-a(i)}{\max(a(i),b(i))}, where a(i)a(i) = mean distance to same-cluster points, b(i)b(i) = mean distance to nearest-other-cluster points. Range [1,1][-1,1]; higher = better.
  • Gap statistic: compare logJ\log J to E[logJ]\mathbb{E}[\log J] under reference (random) distribution.

5.5 Properties

ProCon
Simple, fastAssumes spherical, equal-size clusters
Scales to large data (mini-batch k-means)Sensitive to initialization
Works well when clusters are globularMust specify k
Easy to implementSensitive to outliers
Convergence to local optima

6. Gaussian Mixture Models (GMM) and EM

6.1 Model

GMM is a probabilistic generative model: data is assumed to be drawn from a mixture of KK Gaussians:

p(x)=k=1KπkN(x;μk,Σk),p(\mathbf{x}) = \sum_{k=1}^K \pi_k \mathcal{N}(\mathbf{x};\boldsymbol{\mu}_k, \boldsymbol{\Sigma}_k),

where πk0\pi_k \geq 0, kπk=1\sum_k \pi_k = 1 (mixing coefficients).

Latent variable formulation: introduce hidden variable zi{1,,K}z_i \in \{1,\ldots,K\}:

P(zi=k)=πk,p(xizi=k)=N(xi;μk,Σk).P(z_i=k) = \pi_k, \quad p(\mathbf{x}_i|z_i=k) = \mathcal{N}(\mathbf{x}_i;\boldsymbol{\mu}_k,\boldsymbol{\Sigma}_k).

6.2 EM algorithm for GMM

Goal: maximize log-likelihood (θ)=ilogp(xiθ)\ell(\boldsymbol\theta) = \sum_i \log p(\mathbf{x}_i|\boldsymbol\theta). Not tractable directly (sum inside log).

Expectation-Maximization (EM):

E-step (soft assignments):

rik=P(zi=kxi,θ)=πkN(xi;μk,Σk)jπjN(xi;μj,Σj).r_{ik} = P(z_i=k|\mathbf{x}_i,\boldsymbol\theta) = \frac{\pi_k \mathcal{N}(\mathbf{x}_i;\boldsymbol\mu_k,\boldsymbol\Sigma_k)}{\sum_j \pi_j \mathcal{N}(\mathbf{x}_i;\boldsymbol\mu_j,\boldsymbol\Sigma_j)}.

These are responsibilities (soft cluster assignments, krik=1\sum_k r_{ik} = 1).

M-step (update parameters):

πknew=Nkn,μknew=1Nkirikxi,Σknew=1Nkirik(xiμk)(xiμk),\pi_k^{\text{new}} = \frac{N_k}{n}, \quad \boldsymbol\mu_k^{\text{new}} = \frac{1}{N_k}\sum_i r_{ik}\mathbf{x}_i, \quad \boldsymbol\Sigma_k^{\text{new}} = \frac{1}{N_k}\sum_i r_{ik}(\mathbf{x}_i-\boldsymbol\mu_k)(\mathbf{x}_i-\boldsymbol\mu_k)^\top,

where Nk=irikN_k = \sum_i r_{ik} (effective number of points in cluster kk).

EM guarantees: log-likelihood is non-decreasing at each step (E then M). Converges to local maximum.

6.3 EM as generalized alternating optimization

E-step: compute lower bound Q\mathcal{Q} on log-likelihood (tightened at current parameters). M-step: maximize that lower bound.

The lower bound is the ELBO (Evidence Lower Bound): (θ)Q(θ,θold)\ell(\boldsymbol\theta) \geq \mathcal{Q}(\boldsymbol\theta, \boldsymbol\theta_\text{old}). EM maximizes it iteratively.

6.4 GMM vs k-means

k-MeansGMM
AssignmentsHard (one cluster)Soft (probabilities)
ShapeSpherical (equal size)Elliptical (full covariance)
OutputCluster labelsFull probabilistic model
AlgorithmAlternating optimizationEM (special case)
k-Means as special case?Yes — GMM with equal πk\pi_k, spherical Σk=σ2I\boldsymbol\Sigma_k = \sigma^2\mathbf{I} and σ20\sigma^2\to 0 → hard assignments

6.5 Covariance types (sklearn)

covariance_typeParametersNotes
'full'Full Σk\boldsymbol\Sigma_kMost flexible; may overfit
'tied'Shared Σ\boldsymbol\SigmaLDA-style
'diag'DiagonalIndependent features
'spherical'Scalar σk2I\sigma_k^2\mathbf{I}Closest to k-means
from sklearn.mixture import GaussianMixture
gmm = GaussianMixture(n_components=3, covariance_type='full', random_state=0)
gmm.fit(X)
labels = gmm.predict(X)          # hard assignments
probs  = gmm.predict_proba(X)    # soft assignments
log_likelihood = gmm.score(X)    # average log-likelihood per sample
bic   = gmm.bic(X)               # use BIC to select number of components

*File: notes/07_classical_algorithms.md — next: notes/08_ensemble_methods.md*

From-scratch code

Runnable implementations, each checked against its scikit-learn equivalent.

k-NN — Euclidean, from scratch vs sklearnclassical_examples/knn_classifier.py
"""
k-Nearest Neighbors classifier (from scratch, Euclidean distance) vs sklearn.
"""

from __future__ import annotations

import numpy as np
from sklearn.datasets import load_wine
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler


def majority_vote(labels: np.ndarray) -> int:
    vals, counts = np.unique(labels, return_counts=True)
    return int(vals[np.argmax(counts)])


class KNNClassifierScratch:
    def __init__(self, k: int = 5) -> None:
        self.k = k
        self._X: np.ndarray | None = None
        self._y: np.ndarray | None = None

    def fit(self, X: np.ndarray, y: np.ndarray) -> KNNClassifierScratch:
        self._X = np.asarray(X, dtype=float)
        self._y = np.asarray(y)
        return self

    def predict(self, X: np.ndarray) -> np.ndarray:
        assert self._X is not None and self._y is not None
        X = np.asarray(X, dtype=float)
        preds = []
        for x in X:
            d = np.linalg.norm(self._X - x, axis=1)
            idx = np.argpartition(d, self.k)[: self.k]
            nn = self._y[idx]
            preds.append(majority_vote(nn))
        return np.array(preds, dtype=self._y.dtype)


def main() -> None:
    X, y = load_wine(return_X_y=True)
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.3, random_state=0, stratify=y
    )
    sc = StandardScaler()
    X_train = sc.fit_transform(X_train)
    X_test = sc.transform(X_test)

    k = 7
    s = KNNClassifierScratch(k=k).fit(X_train, y_train)
    m = KNeighborsClassifier(n_neighbors=k).fit(X_train, y_train)

    print("k =", k)
    print("Accuracy scratch:", accuracy_score(y_test, s.predict(X_test)))
    print("Accuracy sklearn :", accuracy_score(y_test, m.predict(X_test)))


if __name__ == "__main__":
    main()
Gaussian Naive Bayes — from scratch vs sklearnclassical_examples/naive_bayes_gaussian.py
"""
Gaussian Naive Bayes for continuous features (from scratch vs sklearn).

Assumption: features are conditionally independent given class; each class-conditional
per-feature distribution is Gaussian with mean and variance estimated from data.
"""

from __future__ import annotations

import numpy as np
from sklearn.datasets import load_iris
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import GaussianNB


class GaussianNBScratch:
    """Two-class or multi-class Gaussian NB with diagonal covariance per class."""

    def fit(self, X: np.ndarray, y: np.ndarray) -> GaussianNBScratch:
        self.classes_ = np.unique(y)
        self.means_: list[np.ndarray] = []
        self.vars_: list[np.ndarray] = []
        self.priors_: list[float] = []
        n = len(y)
        for c in self.classes_:
            Xc = X[y == c]
            self.means_.append(Xc.mean(axis=0))
            # Variance with ddof=0; add epsilon for numerical stability
            v = Xc.var(axis=0) + 1e-9
            self.vars_.append(v)
            self.priors_.append(len(Xc) / n)
        return self

    def _log_gaussian(self, X: np.ndarray, mean: np.ndarray, var: np.ndarray) -> np.ndarray:
        """Log pdf per sample (sum of independent 1D Gaussians)."""
        return -0.5 * np.sum(np.log(2 * np.pi * var) + (X - mean) ** 2 / var, axis=1)

    def predict_log_proba(self, X: np.ndarray) -> np.ndarray:
        rows = []
        for i, c in enumerate(self.classes_):
            logp = np.log(self.priors_[i]) + self._log_gaussian(X, self.means_[i], self.vars_[i])
            rows.append(logp)
        return np.column_stack(rows)

    def predict(self, X: np.ndarray) -> np.ndarray:
        log_probs = self.predict_log_proba(X)
        idx = np.argmax(log_probs, axis=1)
        return self.classes_[idx]


def main() -> None:
    X, y = load_iris(return_X_y=True)
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.3, random_state=0, stratify=y
    )

    s = GaussianNBScratch().fit(X_train, y_train)
    k = GaussianNB().fit(X_train, y_train)

    print("Accuracy scratch:", accuracy_score(y_test, s.predict(X_test)))
    print("Accuracy sklearn :", accuracy_score(y_test, k.predict(X_test)))


if __name__ == "__main__":
    main()
k-means — Lloyd + k-means++ vs sklearnclassical_examples/kmeans.py
"""
k-means clustering (Lloyd's algorithm) from scratch vs sklearn.

Minimizes within-cluster sum of squares (WCSS).
"""

from __future__ import annotations

import numpy as np
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
from sklearn.metrics import adjusted_rand_score


def kmeans_pp_init(X: np.ndarray, k: int, rng: np.random.Generator) -> np.ndarray:
    """k-means++: spread initial centroids using squared-distance probabilities."""
    n = X.shape[0]
    centroids = np.empty((k, X.shape[1]), dtype=np.float64)
    centroids[0] = X[rng.integers(0, n)]
    for j in range(1, k):
        dists_sq = np.min(
            np.sum((X[:, None, :] - centroids[None, :j, :]) ** 2, axis=2),
            axis=1,
        )
        probs = dists_sq / dists_sq.sum()
        idx = rng.choice(n, p=probs)
        centroids[j] = X[idx]
    return centroids


def kmeans_lloyd(
    X: np.ndarray,
    k: int,
    max_iter: int = 100,
    rng: np.random.Generator | None = None,
) -> tuple[np.ndarray, np.ndarray]:
    """
    Returns (labels, centroids).
    """
    rng = rng or np.random.default_rng()
    n, d = X.shape
    X = np.asarray(X, dtype=np.float64)
    centroids = kmeans_pp_init(X, k, rng)

    for _ in range(max_iter):
        # Assign
        dists = np.linalg.norm(X[:, None, :] - centroids[None, :, :], axis=2)
        labels = np.argmin(dists, axis=1)
        # Update
        new_c = np.array([X[labels == j].mean(axis=0) if np.any(labels == j) else centroids[j] for j in range(k)])
        if np.allclose(new_c, centroids):
            break
        centroids = new_c
    return labels, centroids


def main() -> None:
    X, y_true = make_blobs(n_samples=400, centers=4, cluster_std=0.85, random_state=42)
    k = 4
    rng = np.random.default_rng(0)
    labels_s, _ = kmeans_lloyd(X, k=k, rng=rng)

    km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X)
    labels_k = km.labels_

    print("Adjusted Rand vs true labels (scratch):", adjusted_rand_score(y_true, labels_s))
    print("Adjusted Rand vs true labels (sklearn) :", adjusted_rand_score(y_true, labels_k))


if __name__ == "__main__":
    main()
Decision stump — Gini-optimal split vs sklearnclassical_examples/decision_tree_stump.py
"""
Decision stump: best axis-aligned split by minimizing Gini impurity (binary classification).

Full CART is recursive application of this idea; this file shows one split from scratch
and compares to sklearn's max_depth=1 tree.
"""

from __future__ import annotations

import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier


def gini(labels: np.ndarray) -> float:
    _, counts = np.unique(labels, return_counts=True)
    p = counts / counts.sum()
    return 1.0 - np.sum(p**2)


def best_stump(X: np.ndarray, y: np.ndarray) -> tuple[int, float, float]:
    """
    Find feature j, threshold t, and score minimizing weighted Gini after split.
    For each (j), sort by X[:,j] and try thresholds between adjacent distinct values.
    """
    best = (np.inf, 0, 0.0)  # score, j, t
    n = len(y)
    for j in range(X.shape[1]):
        order = np.argsort(X[:, j])
        Xj = X[order, j]
        y_sorted = y[order]
        # candidate thresholds: midpoints between consecutive different feature values
        for i in range(1, n):
            if Xj[i] == Xj[i - 1]:
                continue
            t = 0.5 * (Xj[i] + Xj[i - 1])
            left = y_sorted[:i]
            right = y_sorted[i:]
            g = (len(left) / n) * gini(left) + (len(right) / n) * gini(right)
            if g < best[0]:
                best = (g, j, t)
    return best[1], best[2], best[0]


def predict_stump(X: np.ndarray, j: int, t: float, majority_left: int, majority_right: int) -> np.ndarray:
    left_mask = X[:, j] <= t
    pred = np.empty(len(X), dtype=int)
    pred[left_mask] = majority_left
    pred[~left_mask] = majority_right
    return pred


def main() -> None:
    X, y = load_breast_cancer(return_X_y=True)
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.3, random_state=0, stratify=y
    )

    j, t, _ = best_stump(X_train, y_train)
    left_mask = X_train[:, j] <= t
    majority_left = int(np.bincount(y_train[left_mask]).argmax())
    majority_right = int(np.bincount(y_train[~left_mask]).argmax())
    pred_s = predict_stump(X_test, j, t, majority_left, majority_right)

    clf = DecisionTreeClassifier(max_depth=1, random_state=0)
    clf.fit(X_train, y_train)
    pred_k = clf.predict(X_test)

    print("Best stump: feature", j, "threshold", round(t, 4))
    print("Accuracy stump scratch:", accuracy_score(y_test, pred_s))
    print("Accuracy sklearn d=1 :", accuracy_score(y_test, pred_k))


if __name__ == "__main__":
    main()