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
Full derivations and theory for each algorithm: assumptions, objective, math, training, prediction, pros/cons.
Table of contents
- k-Nearest Neighbors (k-NN)
- Naive Bayes classifiers
- Decision trees (CART)
- Support vector machines (SVM)
- k-Means clustering (Lloyd's algorithm)
- 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 :
- Compute distance from to every training point.
- Find the nearest neighbors .
- Classification: majority vote: .
Regression: average: .
1.2 Distance metrics
Euclidean (L2): . Standard; assumes features have similar scales.
Manhattan (L1): . Robust to outliers.
Minkowski (Lp): . Generalizes both.
Cosine distance: . Useful for high-dimensional sparse data (text).
Mahalanobis: . Accounts for feature correlations; scale-invariant.
1.3 Effect of k
| k | Effect |
|---|---|
| k=1 | Lowest bias, highest variance; decision boundary very irregular |
| Large k | Higher bias, lower variance; smoother boundary; approaches global majority vote |
| k=n | Always predicts global majority class |
Optimal k: use cross-validation. Typical: as heuristic starting point.
1.4 Complexity
- Training: (store data).
- Prediction (naive): per query — must compare to all training points in dimensions.
- Approximate methods: KD-tree ( average for low ), ball tree, HNSW (for large-scale approximate NN).
1.5 Characteristics
| Pro | Con |
|---|---|
| No training time | Slow prediction () |
| Naturally multi-class | High memory (stores all data) |
| Handles complex boundaries | Feature scaling required |
| No assumptions on data distribution | Degrades in high dimensions (curse of dimensionality) |
| Works well for small | Sensitive 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:
where is uniform over the -NN ball (bandwidth adapts to local density).
2. Naive Bayes classifiers
2.1 Generative model and Bayes rule
Use Bayes' theorem for classification:
Since is the same for all classes, predict:
The Naive assumption: features are conditionally independent given the class:
This reduces a -dimensional joint distribution to univariate distributions — tractable even in high dimensions.
2.2 Training
Prior: (with Laplace smoothing ).
Per-feature likelihoods:
- Gaussian NB (continuous features): assume . Estimate , .
- Bernoulli NB (binary features): . Estimate from empirical frequency.
- Multinomial NB (count features): . MLE: .
2.3 Prediction (log-sum for numerical stability)
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, → entire product = 0 regardless of other features. Laplace smoothing adds pseudocount :
where = vocabulary size.
| Pro | Con |
|---|---|
| Very fast training | Strong independence assumption |
| Works with very little data | Poor probability estimates (uncalibrated) |
| Handles high-dimensional text naturally | Cannot capture feature interactions |
| Robust with Laplace smoothing | Numerical issues without log-space computation |
3. Decision trees (CART)
3.1 Structure
A binary tree where each internal node tests a feature threshold (), each leaf outputs a prediction.
3.2 Impurity measures (classification)
Gini impurity at node :
Entropy:
Information gain: reduction in impurity from a split :
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.
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 , sort by , try all thresholds between consecutive distinct values. Cost: per node → 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 (number of leaves) to training loss. Increasing prunes more leaves. Select 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
| Pro | Con |
|---|---|
| Interpretable (can visualize) | High variance (unstable) |
| No feature scaling needed | Non-smooth boundaries |
| Handles mixed types | Biased toward high-cardinality features |
| Handles non-linearities automatically | Not globally optimal |
| Fast inference: | Prone to overfitting |
4. Support vector machines (SVM)
4.1 Maximum margin classifier (linearly separable case)
For binary labels , find hyperplane that maximizes the margin between classes.
The margin is the perpendicular distance from the hyperplane to the nearest points. For normalized , the distance from point to the hyperplane is .
Margin (when support vectors satisfy ).
Primal optimization problem:
Quadratic program (QP) with linear constraints → unique solution.
4.2 Lagrangian and dual formulation
Form Lagrangian with multipliers :
KKT stationarity conditions:
Substituting back, the dual problem:
Prediction:
Support vectors: points with . By complementary slackness: , 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 (allows some misclassification):
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 (bounded dual variables).
Hinge loss equivalence:
Hinge loss: . Convex, non-differentiable at .
4.4 Kernel trick
In dual problem, data appears only in inner products . Replace with a kernel function:
We never need to compute explicitly — only the kernel value. This implicitly works in a (possibly infinite-dimensional) feature space at the cost of the inner product.
| Kernel | Formula | Feature space |
|---|---|---|
| Linear | Original space | |
| Polynomial | Degree- monomials | |
| RBF (Gaussian) | Infinite-dimensional | |
| Sigmoid | Not always PSD |
RBF kernel interpretation: measures similarity as function of Euclidean distance. Hyperparameter : large → narrow Gaussian → model fits locally (high variance); small → broad Gaussian → smoother decision (high bias).
4.5 Mercer's theorem
A function is a valid kernel iff the kernel (Gram) matrix 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 binary SVMs, majority vote.
sklearndefault. - OvR: train SVMs, pick highest score.
4.8 Properties
| Pro | Con |
|---|---|
| Effective in high dimensions | Slow for large ( to ) |
| Theoretically motivated (margin/VC) | Choosing right kernel requires tuning |
| Robust to outliers (soft margin) | Less interpretable than trees |
| Kernel trick extends to nonlinear | No native probability outputs |
5. k-Means clustering (Lloyd's algorithm)
5.1 Objective
Given data and target clusters , minimize:
where is the cluster assignment of point and is the centroid of cluster .
This is NP-hard in general. Lloyd's algorithm finds a local minimum.
5.2 Lloyd's algorithm (alternating optimization)
- Initialize: choose centroids.
- Assignment step: . (Assign each point to nearest centroid.)
- Update step: . (Move centroid to mean of assigned points.)
- Repeat until assignments don't change.
Convergence: objective 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):
- Choose first centroid uniformly at random.
- For each subsequent centroid: sample point with probability .
- Repeat until centroids chosen.
Guarantees expected cost times the optimal. sklearn uses k-means++ by default.
5.4 Choosing k
- Elbow method: plot vs ; look for "elbow" where improvement diminishes.
- Silhouette coefficient: , where = mean distance to same-cluster points, = mean distance to nearest-other-cluster points. Range ; higher = better.
- Gap statistic: compare to under reference (random) distribution.
5.5 Properties
| Pro | Con |
|---|---|
| Simple, fast | Assumes spherical, equal-size clusters |
| Scales to large data (mini-batch k-means) | Sensitive to initialization |
| Works well when clusters are globular | Must specify k |
| Easy to implement | Sensitive 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 Gaussians:
where , (mixing coefficients).
Latent variable formulation: introduce hidden variable :
6.2 EM algorithm for GMM
Goal: maximize log-likelihood . Not tractable directly (sum inside log).
Expectation-Maximization (EM):
E-step (soft assignments):
These are responsibilities (soft cluster assignments, ).
M-step (update parameters):
where (effective number of points in cluster ).
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 on log-likelihood (tightened at current parameters). M-step: maximize that lower bound.
The lower bound is the ELBO (Evidence Lower Bound): . EM maximizes it iteratively.
6.4 GMM vs k-means
| k-Means | GMM | |
|---|---|---|
| Assignments | Hard (one cluster) | Soft (probabilities) |
| Shape | Spherical (equal size) | Elliptical (full covariance) |
| Output | Cluster labels | Full probabilistic model |
| Algorithm | Alternating optimization | EM (special case) |
| k-Means as special case? | Yes — GMM with equal , spherical and → hard assignments |
6.5 Covariance types (sklearn)
covariance_type | Parameters | Notes |
|---|---|---|
'full' | Full | Most flexible; may overfit |
'tied' | Shared | LDA-style |
'diag' | Diagonal | Independent features |
'spherical' | Scalar | 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-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 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 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: 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()