AI: Core ML Algorithms
AI & Machine Learning › Core ML Algorithms
This page is a reference to the classical machine-learning algorithms: linear and logistic regression, decision trees, random forests, gradient boosting (XGBoost, LightGBM, CatBoost, and scikit-learn’s histogram booster), support vector machines, k-nearest neighbors, clustering, and dimensionality reduction. Each section gives the model’s form, how it is fitted, the knobs that matter, and a short, runnable example. The last sections cover how to combine models, how to avoid the evaluation mistakes that invalidate most tabular results, and where the newer tabular foundation models fit.
On structured (tabular) data these methods remain the default choice. They train in seconds to minutes, need little tuning, can be inspected, and a tuned gradient-boosted tree ensemble is still the strongest general-purpose baseline on spreadsheet-shaped data. Deep networks earn their place on images, audio, text, and other high-dimensional signals without named columns; see Deep Learning Architectures. The theory underneath this page (generalization bounds, convexity, the kernel trick in depth) is in Machine Learning Foundations.
Versions. Code targets scikit-learn 1.9, XGBoost 3.4, and LightGBM 4.7 (current as of September 2026). Two recent scikit-learn API changes affect older tutorials: LogisticRegression(penalty=...) is deprecated since 1.8 in favor of l1_ratio, and SVC(probability=True) is deprecated since 1.9 in favor of wrapping the model in CalibratedClassifierCV.
The supervised learning setup
A supervised algorithm learns a function $f$ that maps an input $\mathbf{x} \in \mathbb{R}^d$ to an output $y$, using $n$ labeled examples ${(\mathbf{x}i, y_i)}{i=1}^n$. Almost every method on this page is an instance of regularized empirical risk minimization: pick $f$ from a hypothesis class $\mathcal{F}$ to minimize average training loss plus a complexity penalty.
\[\hat{f} = \arg\min_{f \in \mathcal{F}}\; \frac{1}{n}\sum_{i=1}^{n} L\bigl(y_i,\, f(\mathbf{x}_i)\bigr) + \lambda\, \Omega(f)\]Regression predicts a continuous $y$ (usually squared-error loss); classification predicts a discrete $y$ (usually log-loss, also called cross-entropy). The penalty $\Omega$ and its weight $\lambda$ set the bias–variance tradeoff: too little regularization overfits, too much underfits, and $\lambda$ is chosen by cross-validation. The algorithms differ in the shape of $f$ they can represent and in how they search for it:
| Family | Shape of $f$ | How it is fitted | Needs feature scaling |
|---|---|---|---|
| Linear / logistic | Hyperplane (optionally on engineered features) | Closed form or convex optimization | Yes, when regularized |
| Trees and tree ensembles | Piecewise-constant, axis-aligned regions | Greedy recursive splitting; averaging or boosting | No |
| SVM | Maximum-margin hyperplane in a kernel feature space | Convex quadratic program | Yes |
| k-NN | Local average of stored examples | None (lazy) | Yes |
The code examples share one synthetic classification dataset:
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X, y = make_classification(n_samples=2000, n_features=20, n_informative=8,
random_state=0)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, stratify=y,
random_state=0)
Linear regression
Linear regression assumes the target is an affine function of the features plus noise:
\[\hat{y} = \mathbf{w}^\top \mathbf{x} + b = b + \sum_{j=1}^{d} w_j x_j\]The weights are fitted by minimizing mean squared error, which is the maximum-likelihood estimate under Gaussian noise:
\[L(\mathbf{w}, b) = \frac{1}{n}\sum_{i=1}^{n}\bigl(y_i - \mathbf{w}^\top \mathbf{x}_i - b\bigr)^2\]Closed-form solution
With the data stacked into a design matrix $X \in \mathbb{R}^{n \times d}$ (including a column of ones for the intercept) and a target vector $\mathbf{y}$, the loss is convex. When $X$ has full column rank its unique minimizer is given by the normal equations:
\[\hat{\mathbf{w}} = (X^\top X)^{-1} X^\top \mathbf{y}\]Geometrically, $X\hat{\mathbf{w}}$ is the orthogonal projection of $\mathbf{y}$ onto the column space of $X$. Libraries solve the system with a QR or SVD factorization rather than forming $(X^\top X)^{-1}$, because squaring the matrix squares its condition number and becomes unstable when features are nearly collinear.
Regularization: ridge, lasso, elastic net
When features are correlated, or $d$ is large relative to $n$, least squares overfits and individual weights become large and unstable. A penalty on the weights fixes this:
| Method | Penalty | Effect | Closed form |
|---|---|---|---|
| Ridge | $\lambda \lVert \mathbf{w}\rVert_2^2$ | Shrinks all weights smoothly; stabilizes collinear features | $(X^\top X + \lambda I)^{-1} X^\top \mathbf{y}$ |
| Lasso | $\lambda \lVert \mathbf{w}\rVert_1$ | Drives some weights exactly to zero (feature selection) | No; coordinate descent |
| Elastic net | $\lambda\bigl(\alpha\lVert \mathbf{w}\rVert_1 + \tfrac{1-\alpha}{2}\lVert \mathbf{w}\rVert_2^2\bigr)$ | Sparse like lasso, but keeps groups of correlated features together | No |
The intercept is not penalized. Because the penalty treats all weights alike, features must be on comparable scales, so standardize them first. In scikit-learn, $\lambda$ is called alpha for the regression models; RidgeCV, LassoCV, and ElasticNetCV choose it by cross-validation.
from sklearn.linear_model import LinearRegression, Ridge, LassoCV
from sklearn.model_selection import cross_val_score
rng = np.random.default_rng(0)
Xr = rng.standard_normal((500, 10))
true_w = np.array([3, 0, -2, 0, 0, 1.5, 0, 0, 0, 0.0])
yr = Xr @ true_w + 0.5 * rng.standard_normal(500)
models = {
"OLS": make_pipeline(StandardScaler(), LinearRegression()),
"Ridge": make_pipeline(StandardScaler(), Ridge(alpha=1.0)),
"Lasso": make_pipeline(StandardScaler(), LassoCV(cv=5)), # picks alpha by CV
}
for name, model in models.items():
r2 = cross_val_score(model, Xr, yr, cv=5, scoring="r2").mean()
print(f"{name:6s} CV R^2 = {r2:.3f}")
lasso = models["Lasso"].fit(Xr, yr)
print("Lasso coefficients:", np.round(lasso[-1].coef_, 2)) # zeros = dropped features
Assumptions and diagnostics. The model assumes a linear relationship, errors with constant variance, and limited multicollinearity. Plot residuals against fitted values: a funnel shape suggests transforming the target (for example, $\log y$), and curvature suggests adding polynomial, spline (SplineTransformer), or interaction features. Each coefficient is the change in $\hat{y}$ per unit change in $x_j$ with the other features held fixed, which is only a causal statement if the data were collected to support one.
Logistic regression
Logistic regression is a classification model. It passes a linear score through the sigmoid to produce a probability:
\[p(y = 1 \mid \mathbf{x}) = \sigma(\mathbf{w}^\top \mathbf{x} + b), \qquad \sigma(z) = \frac{1}{1 + e^{-z}}\]Equivalently, the log-odds $\log\frac{p}{1-p}$ is linear in $\mathbf{x}$, so $e^{w_j}$ is the multiplicative change in the odds per unit increase of $x_j$ (an odds ratio). The decision boundary $\mathbf{w}^\top \mathbf{x} + b = 0$ is a hyperplane. The weights minimize binary cross-entropy (the negative log-likelihood), which is convex:
\[L(\mathbf{w}, b) = -\frac{1}{n}\sum_{i=1}^{n}\Bigl[\,y_i \log \hat{p}_i + (1 - y_i)\log(1 - \hat{p}_i)\,\Bigr]\]There is no closed form, but the gradient has the same shape as in linear regression, with the prediction passed through the sigmoid, so second-order solvers such as L-BFGS and Newton-CG converge quickly:
\[\nabla_{\mathbf{w}} L = \frac{1}{n}\sum_{i=1}^{n}(\hat{p}_i - y_i)\,\mathbf{x}_i\]Multiclass and regularization
For $K > 2$ classes, softmax (multinomial) regression gives each class its own weight vector:
\[p(y = k \mid \mathbf{x}) = \frac{\exp(\mathbf{w}_k^\top \mathbf{x})}{\sum_{j=1}^{K} \exp(\mathbf{w}_j^\top \mathbf{x})}\]This is also the output layer of nearly every neural-network classifier. scikit-learn’s LogisticRegression fits the multinomial model for multiclass targets and is $\ell_2$-regularized by default, with strength set by C $= 1/\lambda$ (smaller C means stronger regularization). Since scikit-learn 1.8 the penalty type is chosen with l1_ratio (0 for $\ell_2$, 1 for $\ell_1$, in between for elastic net); the older penalty= argument is deprecated and will be removed in 1.10.
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
clf = make_pipeline(StandardScaler(),
LogisticRegression(C=1.0, l1_ratio=0.0, max_iter=1000))
clf.fit(Xtr, ytr)
proba = clf.predict_proba(Xte)[:, 1]
print(classification_report(yte, clf.predict(Xte)))
print(f"ROC-AUC: {roc_auc_score(yte, proba):.3f}")
Logistic regression is fast, convex (so results are reproducible), usually well calibrated out of the box, and interpretable. It is the standard first model for binary classification and a strong baseline for high-dimensional sparse data such as bag-of-words text.
Decision trees
A decision tree partitions the feature space into axis-aligned boxes by asking a sequence of threshold questions. Each internal node tests one feature against a threshold; each leaf predicts a constant (the mean target for regression, the class distribution for classification). The diagram shows the depth-2 tree that the code below fits to the iris dataset:
flowchart TD
A["petal width ≤ 0.80 cm?<br/>150 samples"] -->|yes| B["Leaf: setosa<br/>50 samples, all setosa"]
A -->|no| C["petal width ≤ 1.75 cm?<br/>100 samples"]
C -->|yes| D["Leaf: versicolor<br/>54 samples (49 versicolor, 5 virginica)"]
C -->|no| E["Leaf: virginica<br/>46 samples (45 virginica, 1 versicolor)"]
Trees handle mixed feature types and nonlinear interactions, need no feature scaling, are invariant to monotone transformations of a feature, and can be read as rules.
Choosing splits
Trees are grown greedily: at each node the algorithm tries every feature and candidate threshold and keeps the split that most reduces impurity. For class proportions $p_k$ in a node, the two standard impurity measures are:
\[\text{Gini} = 1 - \sum_{k=1}^{K} p_k^2, \qquad \text{Entropy} = -\sum_{k=1}^{K} p_k \log_2 p_k\]A split sending $n_L$ of a node’s $n$ samples left and $n_R$ right is scored by the impurity decrease (called information gain when $I$ is entropy):
\[\Delta = I(\text{parent}) - \frac{n_L}{n}I(\text{left}) - \frac{n_R}{n}I(\text{right})\]For regression the impurity is the variance of the targets in the node. Growth stops when a limit is hit (maximum depth, minimum samples per leaf) or no split helps. This greedy procedure is the CART algorithm; finding the globally optimal tree is NP-hard.
Controlling overfitting
A fully grown tree memorizes the training set: near-zero bias, very high variance. Two remedies:
- Pre-pruning caps growth with
max_depth,min_samples_leaf, ormin_samples_split. - Cost-complexity pruning grows a full tree and then removes branches whose impurity reduction does not justify their size, controlled by
ccp_alpha(the candidate values come fromcost_complexity_pruning_path).
Recent scikit-learn trees also accept missing values (NaN) directly and learn which branch they should follow.
from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier, export_text
iris = load_iris()
tree = DecisionTreeClassifier(max_depth=2, random_state=0).fit(iris.data, iris.target)
print(export_text(tree, feature_names=iris.feature_names, show_weights=True))
The weakness of a single tree is instability: a small change in the data can produce a very different tree. Random forests and gradient boosting both fix this by combining many trees.
Ensembles: bagging, boosting, and stacking
An ensemble combines many base models into one predictor. For squared error, expected test error decomposes into squared bias, variance, and irreducible noise:
\[\mathbb{E}\bigl[(y - \hat{f}(\mathbf{x}))^2\bigr] = \bigl(\text{Bias}[\hat{f}(\mathbf{x})]\bigr)^2 + \text{Var}[\hat{f}(\mathbf{x})] + \sigma^2\]The two dominant ensemble strategies attack different terms:
flowchart LR
subgraph BAG["Bagging (random forest): reduces variance"]
direction TB
D1["Training data"] --> S1["Bootstrap sample 1"] --> T1["Deep tree 1"]
D1 --> S2["Bootstrap sample 2"] --> T2["Deep tree 2"]
D1 --> S3["Bootstrap sample B"] --> T3["Deep tree B"]
T1 --> AVG["Average / vote"]
T2 --> AVG
T3 --> AVG
end
subgraph BOOST["Boosting (GBDT): reduces bias"]
direction TB
F0["F0: constant"] --> R1["Fit shallow tree to<br/>residuals of F0"] --> F1["F1 = F0 + lr * tree 1"]
F1 --> R2["Fit shallow tree to<br/>residuals of F1"] --> F2["F2 = F1 + lr * tree 2"]
F2 --> R3["..."] --> FM["FM"]
end
| Bagging | Boosting | Stacking | |
|---|---|---|---|
| Base learners | Deep, low-bias trees | Shallow, high-bias trees | Diverse model types |
| Training | Independent, parallel | Sequential; each corrects the last | Base models, then a meta-model on their out-of-fold predictions |
| Mainly reduces | Variance | Bias | Both, by exploiting uncorrelated errors |
| Canonical example | Random forest | XGBoost, LightGBM, CatBoost | StackingClassifier |
| Overfits as members are added? | No | Yes, without early stopping | Only if the meta-model sees in-sample predictions |
Random forests
A random forest (Breiman, 2001) is bagging applied to decision trees with one addition. It grows $B$ deep trees, each on a bootstrap sample of the rows, and at every split each tree considers only a random subset of features (scikit-learn defaults to $\sqrt{d}$ for classification and all $d$ for regression; $d/3$ is the classic regression recommendation). The forest averages the trees (regression) or their class probabilities (classification):
\[\hat{f}_{\text{RF}}(\mathbf{x}) = \frac{1}{B}\sum_{b=1}^{B} T_b(\mathbf{x})\]Why decorrelation matters
If each tree has variance $\sigma^2$ and trees have pairwise correlation $\rho$, the variance of their average is:
\[\operatorname{Var}\Bigl[\frac{1}{B}\sum_{b=1}^{B} T_b\Bigr] = \rho\,\sigma^2 + \frac{1 - \rho}{B}\,\sigma^2\]More trees drive the second term to zero, but the first term $\rho\sigma^2$ is a floor. Random feature subsampling lowers $\rho$, which is why a random forest beats plain bagged trees. It also explains why adding trees never causes overfitting: it only removes the second term.
Out-of-bag error and feature importance
The chance that a given row is left out of a bootstrap sample is $(1 - 1/n)^n \to e^{-1} \approx 0.368$, so each tree never sees about 37% of the rows. Those out-of-bag (OOB) rows give a free estimate of generalization error without a separate validation set.
Forests report impurity-based importance (mean decrease in impurity), but it is computed on training data and inflated for high-cardinality and continuous features. Permutation importance on held-out data, which measures how much the score drops when one feature’s values are shuffled, is more trustworthy. Neither is causal, and both split credit arbitrarily between correlated features. For per-prediction explanations, SHAP’s TreeExplainer computes exact Shapley values for tree ensembles efficiently.
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
rf = RandomForestClassifier(
n_estimators=300,
max_features="sqrt", # the decorrelation knob
oob_score=True, # free generalization estimate
n_jobs=-1,
random_state=0,
).fit(Xtr, ytr)
print(f"OOB accuracy: {rf.oob_score_:.3f}")
print(f"Test accuracy: {rf.score(Xte, yte):.3f}")
perm = permutation_importance(rf, Xte, yte, n_repeats=10, random_state=0, n_jobs=-1)
for i in perm.importances_mean.argsort()[::-1][:5]:
print(f"feature {i}: {perm.importances_mean[i]:.3f} +/- {perm.importances_std[i]:.3f}")
Random forests are robust to outliers and irrelevant features, parallelize trivially, and work well with default settings, which makes them a good first nonlinear model. The main knobs are n_estimators (more is better, with diminishing returns), max_features, and min_samples_leaf. Extremely randomized trees (ExtraTreesClassifier) also randomize the split thresholds, trading a little bias for lower variance and faster training.
Gradient boosting
Gradient boosting (Friedman, 2001) builds an additive model one tree at a time, each tree fitted to the negative gradient of the loss with respect to the current predictions. It is gradient descent in function space. Starting from a constant $F_0$ (for example, the mean target or the log-odds of the base rate), stage $m$ computes:
\[r_{im} = -\left.\frac{\partial L\bigl(y_i, F(\mathbf{x}_i)\bigr)}{\partial F(\mathbf{x}_i)}\right|_{F = F_{m-1}}, \qquad F_m(\mathbf{x}) = F_{m-1}(\mathbf{x}) + \nu\, h_m(\mathbf{x})\]where $h_m$ is a shallow regression tree fitted to the pseudo-residuals $r_{im}$ and $\nu \in (0, 1]$ is the learning rate (shrinkage). For squared-error loss the pseudo-residuals are the ordinary residuals $y_i - F_{m-1}(\mathbf{x}_i)$, hence the description “each tree fits the leftover error.” For log-loss they are $y_i - \hat{p}_i$.
Because each tree depends on the previous ones, boosting keeps reducing bias and will eventually overfit. The standard regularizers are:
- a small learning rate (0.01 to 0.1) with many trees, stopped by early stopping on a validation set;
- shallow trees (depth 3 to 8, or a cap on leaves);
- row and column subsampling (stochastic gradient boosting);
- $\ell_1$/$\ell_2$ penalties on leaf values and a minimum gain or minimum samples per leaf.
The main implementations
All modern implementations bin continuous features into histograms (typically 255 bins), which makes split finding fast, and all handle missing values natively.
| XGBoost | LightGBM | CatBoost | scikit-learn HistGradientBoosting* |
|
|---|---|---|---|---|
| Origin | Chen and Guestrin, 2016 | Microsoft, 2017 | Yandex, 2017 | scikit-learn, stable since 1.0 |
| Tree growth | Depth-wise by default (grow_policy="lossguide" available) |
Leaf-wise (best-first) | Symmetric (oblivious) trees | Leaf-wise, capped by max_leaf_nodes |
| Categorical features | Native (enable_categorical=True) |
Native | Native, with ordered target statistics | Native (categorical_features="from_dtype") |
| Distinctive ideas | Second-order objective with explicit tree penalty; sparsity-aware splits | GOSS row sampling, exclusive feature bundling | Ordered boosting to avoid target leakage | No extra dependency; integrates with pipelines |
| GPU training | device="cuda" |
device_type="cuda" (build-dependent) |
task_type="GPU" |
No |
| Typical strength | Robust default; mature distributed and GPU support | Fastest on large and wide data | Strong defaults with many categoricals | Good default when you want to stay in scikit-learn |
Differences in accuracy between the four are usually small once each is tuned; choose by data size, the number of categorical columns, deployment constraints, and familiarity.
XGBoost
XGBoost made boosting the dominant method in tabular competitions. It approximates the loss to second order, so each tree uses both the gradient $g_i$ and the Hessian $h_i$ of the loss at each point, and adds an explicit penalty on tree complexity:
\[\mathcal{L}^{(m)} \approx \sum_{i=1}^{n}\Bigl[g_i\, h_m(\mathbf{x}_i) + \tfrac{1}{2} h_i\, h_m(\mathbf{x}_i)^2\Bigr] + \gamma\, T + \tfrac{1}{2}\lambda \sum_{j=1}^{T} w_j^2\]where $T$ is the number of leaves and $w_j$ the leaf values. Minimizing this gives each leaf the closed-form value $w_j^\ast = -G_j / (H_j + \lambda)$, where $G_j$ and $H_j$ sum $g_i$ and $h_i$ over the samples in leaf $j$; a split is kept only if its gain exceeds $\gamma$.
import xgboost as xgb
Xfit, Xval, yfit, yval = train_test_split(Xtr, ytr, test_size=0.2,
stratify=ytr, random_state=0)
model = xgb.XGBClassifier(
n_estimators=2000, # upper bound; early stopping picks the real number
learning_rate=0.05,
max_depth=4,
subsample=0.8,
colsample_bytree=0.8,
reg_lambda=1.0,
tree_method="hist", # the default in XGBoost 2+
early_stopping_rounds=50,
eval_metric="logloss",
n_jobs=-1,
random_state=0,
)
model.fit(Xfit, yfit, eval_set=[(Xval, yval)], verbose=False)
print("Best iteration:", model.best_iteration)
print("Test accuracy:", model.score(Xte, yte))
Early stopping uses a validation split carved out of the training data. Stopping on the test set, as many tutorials do, leaks test information into model selection and inflates the reported score.
LightGBM
LightGBM grows trees leaf-wise: it always splits the leaf with the largest loss reduction, producing deeper, lopsided trees that reach a given loss with fewer leaves than level-wise growth. num_leaves is therefore the main capacity knob, and on small datasets it should be kept low (and min_child_samples raised) to avoid overfitting. Two further optimizations target large data: gradient-based one-side sampling (GOSS) keeps rows with large gradients and subsamples the rest, and exclusive feature bundling packs sparse features that are rarely nonzero together into one.
import lightgbm as lgb
model = lgb.LGBMClassifier(
n_estimators=2000,
learning_rate=0.05,
num_leaves=31, # main capacity knob for leaf-wise growth
min_child_samples=20,
subsample=0.8, subsample_freq=1, # row bagging needs a nonzero frequency
colsample_bytree=0.8,
reg_lambda=1.0,
n_jobs=-1,
random_state=0,
verbose=-1,
)
model.fit(Xfit, yfit, eval_X=(Xval,), eval_y=(yval,), # LightGBM 4.7+; older: eval_set=[(Xval, yval)]
callbacks=[lgb.early_stopping(50, verbose=False)])
print("Trees used:", model.best_iteration_, " Test accuracy:", model.score(Xte, yte))
scikit-learn’s histogram booster
HistGradientBoostingClassifier and HistGradientBoostingRegressor implement the same histogram and leaf-wise ideas inside scikit-learn. They support missing values, native categorical features, monotonic and interaction constraints, and built-in early stopping, and they fit into Pipeline and GridSearchCV without an extra dependency.
from sklearn.ensemble import HistGradientBoostingClassifier
hgb = HistGradientBoostingClassifier(
learning_rate=0.05,
max_iter=2000,
max_leaf_nodes=31,
l2_regularization=1.0,
early_stopping=True, # holds out validation_fraction=0.1 internally
random_state=0,
).fit(Xtr, ytr)
print("Iterations:", hgb.n_iter_, " Test accuracy:", hgb.score(Xte, yte))
Support vector machines
A support vector machine (Cortes and Vapnik, 1995) separates two classes with the hyperplane that has the maximum margin, the largest gap to the nearest training points of either class. With labels $y_i \in {-1, +1}$ and classifier $\hat{y} = \operatorname{sign}(\mathbf{w}^\top \mathbf{x} + b)$, the hard-margin problem is:
\[\min_{\mathbf{w}, b}\; \tfrac{1}{2}\lVert \mathbf{w}\rVert^2 \quad \text{s.t.} \quad y_i(\mathbf{w}^\top \mathbf{x}_i + b) \ge 1 \;\;\text{for all } i\]The margin width is $2 / \lVert \mathbf{w}\rVert$, so minimizing $\lVert \mathbf{w}\rVert$ maximizes it. Only the points on or inside the margin, the support vectors, determine the solution; removing any other point leaves the classifier unchanged.
Soft margin and C
Real data is rarely separable, so the soft-margin SVM adds slack variables $\xi_i \ge 0$ that allow violations at a cost set by $C$:
\[\min_{\mathbf{w}, b, \boldsymbol{\xi}}\; \tfrac{1}{2}\lVert \mathbf{w}\rVert^2 + C\sum_{i=1}^{n}\xi_i \quad \text{s.t.}\quad y_i(\mathbf{w}^\top \mathbf{x}_i + b) \ge 1 - \xi_i,\; \xi_i \ge 0\]This is the same as minimizing the hinge loss $\sum_i \max\bigl(0,\, 1 - y_i(\mathbf{w}^\top\mathbf{x}_i + b)\bigr)$ plus the penalty $\tfrac{1}{2C}\lVert\mathbf{w}\rVert^2$. A large $C$ tolerates few violations (low bias, high variance); a small $C$ gives a wider, softer margin (stronger regularization).
The kernel trick
The dual of the SVM problem uses the data only through inner products $\mathbf{x}_i^\top \mathbf{x}_j$. Replacing them with a kernel $k(\mathbf{x}_i, \mathbf{x}_j) = \langle \phi(\mathbf{x}_i), \phi(\mathbf{x}_j)\rangle$ fits a linear separator in a high- or infinite-dimensional feature space without ever computing $\phi$. The decision function becomes:
\[f(\mathbf{x}) = \operatorname{sign}\!\left(\sum_{i \in \text{SV}} \alpha_i\, y_i\, k(\mathbf{x}_i, \mathbf{x}) + b\right)\]| Kernel | $k(\mathbf{x}, \mathbf{x}’)$ | Use |
|---|---|---|
| Linear | $\mathbf{x}^\top \mathbf{x}’$ | High-dimensional sparse data such as text; use LinearSVC, which scales far better |
| RBF (Gaussian) | $\exp(-\gamma\lVert \mathbf{x} - \mathbf{x}’\rVert^2)$ | Default nonlinear choice; $\gamma$ sets how far each point’s influence reaches |
| Polynomial | $(\gamma\,\mathbf{x}^\top \mathbf{x}’ + r)^p$ | Fixed-degree feature interactions |
Machine Learning Foundations develops the kernel trick and the SVM dual in more depth.
from sklearn.svm import SVC
from sklearn.model_selection import GridSearchCV
svm = make_pipeline(StandardScaler(), SVC(kernel="rbf")) # always scale for SVMs
grid = GridSearchCV(
svm,
param_grid={"svc__C": [0.1, 1, 10, 100],
"svc__gamma": ["scale", 0.01, 0.1, 1.0]},
cv=5, n_jobs=-1,
).fit(Xtr, ytr)
print("Best params:", grid.best_params_)
print("Test accuracy:", grid.score(Xte, yte))
SVMs output a signed distance from the boundary, not a probability. Since scikit-learn 1.9, SVC(probability=True) is deprecated; wrap the model in CalibratedClassifierCV(..., ensemble=False) when calibrated probabilities are needed.
Kernel SVMs do well on small-to-medium datasets with many features and a clear margin. Training time grows between roughly $O(n^2)$ and $O(n^3)$ in the number of samples, so beyond tens of thousands of rows use a linear SVM, an approximate kernel map (Nystroem, RBFSampler) followed by a linear model, or gradient boosting.
k-nearest neighbors
k-NN stores the training set and predicts for a new point from its $k$ closest training points: the majority class for classification, the mean for regression:
\[\hat{y}(\mathbf{x}) = \frac{1}{k}\sum_{i \in N_k(\mathbf{x})} y_i\]where $N_k(\mathbf{x})$ is the set of the $k$ nearest neighbors, usually by Euclidean distance. It is a non-parametric, instance-based method: the training data is the model, and all the work happens at prediction time.
- $k$ sets the bias–variance tradeoff. $k = 1$ gives a jagged boundary that fits every training point; larger $k$ smooths it. Choose by cross-validation; an odd $k$ avoids ties in binary problems.
weights="distance"lets closer neighbors count more. - Scaling matters because a feature with a large range dominates the distance. Standardize.
- Dimensionality is the main limit. In high dimensions distances concentrate, so the nearest and farthest neighbors are almost equally far away, and k-NN degrades past a few dozen features unless dimensionality is reduced first.
from sklearn.neighbors import KNeighborsClassifier
param_grid = {"kneighborsclassifier__n_neighbors": [1, 3, 5, 9, 15, 25]}
knn = GridSearchCV(
make_pipeline(StandardScaler(), KNeighborsClassifier(weights="distance")),
param_grid, cv=5,
).fit(Xtr, ytr)
print("Best k:", knn.best_params_, " Test accuracy:", knn.score(Xte, yte))
Exact neighbor search uses KD-trees or ball trees in low dimensions and brute force otherwise. At scale, and for embedding vectors in particular, approximate nearest neighbor indexes such as HNSW graphs or FAISS inverted-file indexes trade a little recall for orders-of-magnitude faster queries; the same machinery underlies vector databases and retrieval-augmented generation.
Clustering
Clustering groups unlabeled points so that points in a cluster are more similar to each other than to points in other clusters. There is no target to score against, so the choice of algorithm encodes an assumption about what a cluster is.
| Algorithm | Cluster model | Needs $K$ | Handles noise | Scales to |
|---|---|---|---|---|
| k-means | Spherical, similar size, around a centroid | Yes | No | Millions of rows (MiniBatchKMeans beyond) |
| Gaussian mixture | Ellipsoids with soft membership | Yes (choose by BIC) | No | Large |
| Agglomerative | Nested hierarchy; shape depends on linkage | No (cut the dendrogram) | No | Tens of thousands |
| DBSCAN | Dense regions separated by sparse ones | No | Yes | Large, with spatial indexing |
| HDBSCAN | Dense regions at varying density | No | Yes | Large |
k-means
k-means partitions data into $K$ clusters by minimizing the within-cluster sum of squares (inertia):
\[J = \sum_{j=1}^{K}\sum_{\mathbf{x} \in C_j} \lVert \mathbf{x} - \boldsymbol{\mu}_j\rVert^2\]Lloyd’s algorithm alternates two steps, each of which can only decrease $J$, so it converges, though only to a local minimum:
flowchart LR
I["Initialize K centroids<br/>(k-means++)"] --> A["Assign each point<br/>to its nearest centroid"]
A --> U["Move each centroid to<br/>the mean of its points"]
U --> C{"Assignments<br/>changed?"}
C -->|yes| A
C -->|no| D["Done"]
Use k-means++ seeding (the default) and several restarts. Choose $K$ with the silhouette score, the elbow of inertia against $K$, or domain knowledge. A Gaussian mixture model fitted by expectation-maximization generalizes k-means to ellipsoidal clusters with soft assignments.
Hierarchical (agglomerative) clustering
Agglomerative clustering starts with each point as its own cluster and repeatedly merges the closest pair, producing a dendrogram that can be cut at any height. The linkage defines “closest”: single (nearest points, finds chains), complete (farthest points), average, or Ward (smallest increase in within-cluster variance, which gives compact clusters). It needs no preset $K$ and shows nested structure, but pairwise distances cost $O(n^2)$ memory.
DBSCAN and HDBSCAN
DBSCAN marks a point as a core point if at least min_samples points lie within radius eps, grows clusters from connected core points, and labels everything unreachable as noise. It finds arbitrarily shaped clusters and does not need $K$, but a single eps cannot fit clusters of very different density. HDBSCAN (in scikit-learn since 1.3) runs DBSCAN across all values of eps and keeps the most stable clusters, so it handles varying density and needs essentially one parameter, min_cluster_size. It is the better default for density-based clustering.
from sklearn.cluster import KMeans, AgglomerativeClustering, DBSCAN, HDBSCAN
from sklearn.datasets import make_blobs, make_moons
from sklearn.metrics import silhouette_score
Xb, _ = make_blobs(n_samples=600, centers=4, cluster_std=0.8, random_state=0)
Xb = StandardScaler().fit_transform(Xb)
for k in range(2, 7):
km = KMeans(n_clusters=k, random_state=0).fit(Xb)
print(f"k={k} inertia={km.inertia_:.0f} silhouette={silhouette_score(Xb, km.labels_):.3f}")
agg = AgglomerativeClustering(n_clusters=4, linkage="ward").fit(Xb)
# Density-based methods recover non-convex shapes where k-means fails.
Xm, _ = make_moons(n_samples=600, noise=0.06, random_state=0)
for name, algo in [("DBSCAN", DBSCAN(eps=0.2, min_samples=5)),
("HDBSCAN", HDBSCAN(min_cluster_size=20, copy=True))]:
labels = algo.fit_predict(Xm)
n_clusters = len(set(labels) - {-1})
print(f"{name}: {n_clusters} clusters, {(labels == -1).sum()} noise points")
Always scale features before distance-based clustering, and check the result against domain knowledge. Internal scores such as silhouette favor convex clusters and can rank a meaningless partition above a useful one.
Dimensionality reduction
Dimensionality reduction maps data to fewer dimensions, either to speed up and regularize a downstream model or to visualize structure.
- Principal component analysis (PCA) projects onto the orthogonal directions of maximum variance, the top eigenvectors of the covariance matrix, computed by an SVD of the centered data. It is linear, fast, and invertible up to the discarded variance;
explained_variance_ratio_shows how many components are needed. Standardize first when features have different units. - t-SNE and UMAP are nonlinear methods that preserve local neighborhoods and are used mainly for 2-D and 3-D visualization. Distances between clusters and cluster sizes in their plots are not meaningful, and results depend on hyperparameters (perplexity,
n_neighbors) and the random seed. Do not cluster on t-SNE output.
Evaluation and common pitfalls
Most bad tabular results come from evaluation mistakes, not from the choice of algorithm.
- Keep preprocessing inside the pipeline. Scaling, imputation, target encoding, and feature selection must be fitted on training folds only. Wrapping them in a
Pipeline(orColumnTransformer) and cross-validating the whole pipeline prevents leakage; fitting a scaler on all data before splitting does not. - Split the way the model will be used. Use
TimeSeriesSplitfor temporal data,GroupKFoldwhen rows from the same user, patient, or device must not appear on both sides, and stratification for imbalanced classes. - Tune on validation data, report on untouched test data. Nested cross-validation, or a final hold-out set that is used once, gives an unbiased estimate after hyperparameter search.
- Pick metrics that match the decision. Accuracy is misleading for imbalanced classes; use ROC-AUC or, for rare positives, precision–recall AUC, and choose the decision threshold for the actual cost of errors (
TunedThresholdClassifierCV). Check calibration (CalibrationDisplay) when probabilities are consumed downstream. - Search hyperparameters efficiently. Random search or successive halving (
HalvingRandomSearchCV) beat an exhaustive grid for more than two or three parameters; Bayesian optimizers such as Optuna help for expensive boosted models.
Stacking and voting
Stacking (stacked generalization) trains several diverse base models, collects their out-of-fold predictions, and fits a simple meta-model on those predictions to learn how to weight them. Out-of-fold predictions are essential: a meta-model trained on in-sample predictions learns to trust whichever base model overfits most.
from sklearn.ensemble import StackingClassifier
stack = StackingClassifier(
estimators=[
("rf", RandomForestClassifier(n_estimators=200, n_jobs=-1, random_state=0)),
("hgb", HistGradientBoostingClassifier(random_state=0)),
("svm", make_pipeline(StandardScaler(), SVC())), # decision_function is used
("lr", make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))),
],
final_estimator=LogisticRegression(),
cv=5, # out-of-fold predictions feed the meta-model
n_jobs=-1,
).fit(Xtr, ytr)
print("Stacked ensemble test accuracy:", stack.score(Xte, yte))
A voting ensemble (majority vote, or averaged probabilities for soft voting) needs no meta-model. Either kind helps most when the base models are individually strong and make uncorrelated errors; averaging near-identical models gains little.
Tabular foundation models
Since 2025, pretrained tabular foundation models have become a practical alternative for small and medium datasets. TabPFN (Hollmann et al., Nature, 2025) is a transformer pretrained on millions of synthetic datasets drawn from a prior over data-generating processes. It does not train on your data in the usual sense: fit stores the training set, and predict runs the training rows and test rows through the network together, performing in-context learning in a single forward pass. On datasets of up to about ten thousand rows, the published results showed it matching or beating tuned gradient-boosting ensembles with no hyperparameter search. Later releases (TabPFN-2.5, 3, and 3.5, the current default) extend the supported dataset size.
Practical constraints: inference cost grows with the training set size and a GPU is effectively required beyond a few thousand rows; the weights for recent versions are released under a non-commercial license (the v2 weights are permissively licensed); and on large datasets tuned GBDTs remain the stronger and far cheaper choice. Treat these models as another strong baseline to compare against, not a replacement for the methods above.
Choosing an algorithm
flowchart TD
S["Labeled data?"] -->|no| U["Clustering:<br/>k-means, then HDBSCAN<br/>for irregular shapes"]
S -->|yes| T["Tabular with<br/>named columns?"]
T -->|no: images, audio, text| DL["Deep learning or<br/>pretrained embeddings"]
T -->|yes| B["Baseline:<br/>linear / logistic regression"]
B --> RF["Random forest"]
RF --> N["Rows?"]
N -->|up to ~10k| P["Also try a tabular<br/>foundation model"]
N -->|any size| G["Tuned gradient boosting<br/>with early stopping"]
P --> G
G --> E["Stack only if the<br/>gain justifies it"]
| Algorithm | Best for | Strengths | Watch out for |
|---|---|---|---|
| Linear / logistic regression | Baselines, linear effects, sparse text features | Fast, convex, interpretable, calibrated | Underfits nonlinear structure without feature engineering |
| Decision tree | Human-readable rules | No scaling, handles interactions and missing values | High variance on its own |
| Random forest | Strong low-effort baseline | Robust, parallel, few knobs, OOB estimate | Large models; weaker than tuned boosting |
| Gradient boosting | Best accuracy on most tabular data | Handles mixed types, missing values, categoricals | Needs early stopping and some tuning |
| SVM | Small-to-medium, high-dimensional data | Maximum-margin generalization; kernels | Poor scaling in $n$; no native probabilities |
| k-NN | Low-dimensional data, similarity search | No training; simple | Slow prediction; curse of dimensionality |
| Tabular foundation model | Small datasets | Strong accuracy without tuning | GPU cost, licensing, dataset-size limits |
In practice: start with a regularized linear model and a random forest to calibrate expectations, then tune a gradient-boosting model with early stopping, which on most tabular problems is the model to beat. Use SVMs for small high-dimensional problems and k-NN as a sanity check. For unlabeled data, start with k-means and move to HDBSCAN when clusters are irregular or noisy. Move to deep learning when the inputs are high-dimensional and unstructured, or when you need to learn representations from raw signals.
See also
- Machine Learning Foundations — generalization theory, optimization, the kernel trick, and Gaussian processes behind these algorithms
- ML & Deep Learning track — the reading path from foundations to deep architectures
- Deep Learning Architectures — the models to use when data is high-dimensional and unstructured
- Loss Functions — the objectives these models minimize
- AI Mathematics — formal proofs for statistical learning theory and kernel methods