Ch. 05

Trees, SVMs, Kernels

CART splits, the max-margin objective, the kernel trick — and an honest 2026 verdict on when SVMs still win.

CARTSVMkernel-trick

FIG 05 · Explainer video


A decision tree is the rare model you can fit, deploy, and explain to a regulator on the same afternoon. It does not need standardization, it does not care about scales, it handles mixed categorical and numerical inputs out of the box, and the trained tree is a literal flowchart that says "if petal length > 2.45 cm then class is versicolor else setosa". The mech-interp dream for neural networks is to recover something like a decision tree from the weights. The classical-ML dream is the opposite, to make decision trees as accurate as neural networks. Neither dream is realized. This chapter is about the two families of models that pre-date neural networks and still beat them on tabular data when the dataset is small. Decision trees, the readable model. Support vector machines, the maximally-margined model. And the kernel trick, the move that turns an inner-product algorithm into an infinite-dimensional one without ever paying for the infinite dimensions.


FIG 05.1 · Learning outcomes

By the end of this chapter you will be able to:

  • Fit a DecisionTreeClassifier on Iris and read off the splits in plain English.
  • Implement the CART algorithm (binary splits chosen by Gini impurity) in ≈80 lines of Python.
  • Explain why a tree of depth 30 on a 100-point dataset achieves 100% train accuracy and 60% test accuracy, and what to do about it.
  • Fit a linear SVM and derive the max-margin objective from first principles.
  • Solve the SVM dual using cvxpy on a toy problem and verify the support vectors equal sklearn's.
  • Apply the polynomial and RBF kernels via SVC(kernel='rbf') and explain what the kernel trick is not doing (it is not running in 1000 dimensions).
  • Diagnose when SVMs win (small datasets, clear margins, non-linear but smooth boundaries) and when they lose (modern tabular data, where XGBoost dominates).

FIG 05.2 · What you need first

  • Ch 4 — Training Modelsyou need gradient descent, regularization, and the bias-variance trade-off. The SVM sections re-use the L2 penalty geometry directly.
  • Ch 3 — Classificationconfusion matrices, precision/recall, and the basic binary-classification setup.
  • Ch 0 — Math & Python prereqs, specifically the linear algebra section. SVMs are unforgiving if you do not know what a dot product is.

The Lagrangian-duality derivation in §7 is the only place in the chapter that reaches past multivariate calculus; the few terms it needs (dual problem, Gram matrix) are defined on first use there, so you can follow the result without prior optimization theory. For the full machinery (KKT conditions and the proofs), Boyd & Vandenberghe another chapter is the standard reference, but it is optional enrichment. The rest of the chapter is approachable.


FIG 05.3.1

Decision trees: the model that is its own visualization

A decision tree partitions the input space into axis-aligned rectangles, with a constant prediction in each rectangle. To classify a new point, you start at the root, ask one yes/no question per node (e.g., "is petal length > 2.45?"), and follow branches until you reach a leaf. The leaf's prediction is the majority class of training points that landed there.

That is the entire algorithm. A decision tree is a function from Rd\mathbb{R}^d to {1,,K}\{1, \ldots, K\} defined by a sequence of axis-aligned splits.

Python
from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier, export_text

iris = load_iris(as_frame=True)
X, y = iris.data[["petal length (cm)", "petal width (cm)"]].values, iris.target

tree = DecisionTreeClassifier(max_depth=2, random_state=42)
tree.fit(X, y)
print(export_text(tree, feature_names=["petal_length", "petal_width"]))
# |--- petal_length <= 2.45
# |   |--- class: 0
# |--- petal_length >  2.45
# |   |--- petal_width <= 1.75
# |   |   |--- class: 1
# |   |--- petal_width >  1.75
# |   |   |--- class: 2

Four lines of output. The fitted tree fully described. That is the readability story.

Note on scale: trees do not require standardization. Splits are on raw thresholds; a feature by 100 just changes the threshold by 100. This is unique among the model families in this book.

FIG 05.3.2

The CART algorithm: how the splits get chosen

Sklearn's DecisionTreeClassifier implements CART (Classification And Regression Trees). The recipe at each node is greedy: pick the jj and threshold tt that minimize a weighted impurity:

J(j,t)=mleftmGleft+mrightmGrightJ(j, t) = \frac{m_{\text{left}}}{m} G_{\text{left}} + \frac{m_{\text{right}}}{m} G_{\text{right}}

where mm is the number of points at the node, mleftm_{\text{left}} and mrightm_{\text{right}} are the counts after the split, and GG is an impurity measure of the labels at that node.

Two common impurity measures.

Gini impurity for a node with class proportions pkp_k:

G=1k=1Kpk2G = 1 - \sum_{k=1}^{K} p_k^2

Maximum at 1/K1/K for uniform class distribution (most uncertain), zero when one class dominates (pure leaf).

Entropy for the same node:

H=k=1Kpklog2pkH = -\sum_{k=1}^{K} p_k \log_2 p_k

Same shape, slightly different curvature. In practice trees built with Gini and with entropy are nearly identical. Gini is the sklearn default, faster to compute.

CART tries every (feature, threshold) pair, picks the one minimizing JJ, recurses on the two children. Stop when the node is pure, or the depth limit hits, or fewer than min_samples_split examples remain.

DecisionTreeClassifier vs. CART from scratch

Classical ML
LIBRARY
tree = DecisionTreeClassifier(criterion="gini", max_depth=5, random_state=42)
tree.fit(X, y)
preds = tree.predict(X_test)
FROM SCRATCH
def gini(y):
    if len(y) == 0:
        return 0.0
    _, counts = np.unique(y, return_counts=True)
    p = counts / counts.sum()
    return float(1.0 - (p ** 2).sum())

def best_split(X, y):
    n, d = X.shape
    parent = gini(y)
    best_feat, best_thresh, best_gain = None, None, 0.0
    for f in range(d):
        for t in np.unique(X[:, f]):
            left_mask = X[:, f] <= t
            right_mask = ~left_mask
            if left_mask.sum() == 0 or right_mask.sum() == 0:
                continue
            weighted = (left_mask.sum() * gini(y[left_mask])
                        + right_mask.sum() * gini(y[right_mask])) / n
            gain = parent - weighted
            if gain > best_gain:
                best_gain, best_feat, best_thresh = gain, f, float(t)
    return best_feat, best_thresh, best_gain

def build_tree(X, y, max_depth=5, depth=0):
    if depth >= max_depth or len(np.unique(y)) == 1 or len(y) <= 1:
        return TreeNode(prediction=_majority(y))
    feat, thresh, gain = best_split(X, y)
    if feat is None or gain <= 0:
        return TreeNode(prediction=_majority(y))
    left_mask = X[:, feat] <= thresh
    left = build_tree(X[left_mask], y[left_mask], max_depth, depth + 1)
    right = build_tree(X[~left_mask], y[~left_mask], max_depth, depth + 1)
    return TreeNode(feature=feat, threshold=thresh, left=left, right=right)

def _predict_one(node, x):
    while node.prediction is None:
        node = node.left if x[node.feature] <= node.threshold else node.right
    return node.prediction

from scratch: lab/solution.py: gini, best_split, build_tree, predict_tree (TreeNode, _majority, _predict_one)

  1. 1criterion="gini" gini(y) = 1 - sum(p_k^2) over class proportions; the impurity measure best_split tries to reduce
  2. 2tree.fit(X, y): the splitter that scans features/thresholds best_split: double loop over every feature f and every unique threshold t, choosing the (f, t) with max parent-minus-weighted-child Gini gain
  3. 3recursive node growth inside fit + max_depth stopping build_tree: recurse left/right on X[mask]/X[~mask], stop on depth>=max_depth, pure node, or gain<=0
  4. 4leaf class = argmax of class counts at the leaf _majority(y) returns vals[counts.argmax()], stored as TreeNode.prediction
  5. 5tree.predict(X_test) predict_tree -> _predict_one walks node.left/node.right by comparing x[node.feature] <= node.threshold until a leaf
What the one call hides
  • Threshold candidates: sklearn sorts the feature and uses midpoints between consecutive distinct sorted values; the scratch uses np.unique values directly with x <= t (verified: same root feature, thresholds differ only by the midpoint offset, 0.2277 vs 0.2307)
  • Complexity: sklearn's C splitter is roughly O(m log m * d * depth) via presorting; the scratch re-scans and re-computes Gini per candidate threshold, so it is O(m^2 * d * depth)
  • Regularization is wide open by default: max_depth=None, min_samples_split=2, min_samples_leaf=1, ccp_alpha=0.0 grow a fully-memorized tree (verified: a fully grown scratch tree hits 100% train accuracy too)
  • Tie-breaking and feature subsampling: max_features=None considers all features, ties resolved by feature order, splitter='best' vs 'random' changes which equal-gain split wins
  • sklearn handles multiclass, sample_weight, and missing-value surrogate routing; the scratch assumes dense, complete, integer labels
  • Gotcha: max_depth defaults to None: a default DecisionTreeClassifier memorizes the training set and overfits; the scratch default of max_depth=5 quietly hides this
  • Gotcha: min_samples_leaf=1 default lets the tree carve single-point leaves; bump it to ~5 on real data
  • Gotcha: Greedy CART is locally, not globally, optimal (the optimal depth-k tree is NP-hard) and both paths share this; don't expect the 'best' tree
  • Gotcha: Trees are high-variance: a different train split yields a visibly different tree; this is expected and is why you reach for ensembles (Ch 6)

Prefer DecisionTreeClassifier in production (it presorts, prunes, handles multiclass and missing data); the scratch version exists to prove .fit() is just greedy Gini-minimizing axis-aligned splits and .predict() is an if/else walk down the tree.

On the job: At work you tune the regularization knobs (max_depth, min_samples_leaf, ccp_alpha) and the train/val split, not the splitter itself; you essentially never hand-write Gini or the recursion.

Run this on Iris with max_depth=2 and you get the same tree sklearn produced. The runtime is O(mlogmddepth)O(m \log m \cdot d \cdot \text{depth}) for sorted thresholds; the implementation above is O(m2ddepth)O(m^2 \cdot d \cdot \text{depth}) because it does not sort. Sklearn sorts; the production code is more elaborate but the algorithm is the same.

FIG 05.3.3

Tree depth, overfitting, and regularization

A decision tree of unlimited depth will memorize the perfectly. Run sklearn's DecisionTreeClassifier(max_depth=None) on a 100-point dataset and you get 100% train . The corresponding test accuracy is typically poor.

The hyperparameters for DecisionTreeClassifier are all about constraining the tree's growth:

  • max_depth: hard cap on tree depth
  • min_samples_split: minimum number of points required to split a node
  • min_samples_leaf: minimum points in a leaf
  • max_leaf_nodes: cap on total leaves
  • max_features: at each node, consider only a random subset of features (this is the trick random forests exploit)
  • ccp_alpha: cost-complexity pruning strength

The two regimes:

Pre-pruning (the hyperparameters above): never let the tree grow past a complexity bound. Post-pruning (ccp_alpha > 0): grow the tree fully, then snip back leaves whose contribution to training error is too small relative to their complexity cost.

In practice, max_depth in the range 3-10 combined with min_samples_leaf=5 covers most use cases. Tune with cross-validation.

Python
from sklearn.model_selection import GridSearchCV

params = {"max_depth": [3, 5, 7, 10, None], "min_samples_leaf": [1, 5, 10]}
grid = GridSearchCV(DecisionTreeClassifier(random_state=42), params, cv=5)
grid.fit(X, y)
print(grid.best_params_, grid.best_score_)

FIG 05.3.4

Trees for regression

The same algorithm works for regression. Change the impurity from Gini to mean-squared-error of the labels at a node:

J(j,t)=mleftmMSEleft+mrightmMSErightJ(j, t) = \frac{m_{\text{left}}}{m} \text{MSE}_{\text{left}} + \frac{m_{\text{right}}}{m} \text{MSE}_{\text{right}}

The leaf prediction is the mean of training labels that landed there, not the majority vote.

Python
from sklearn.tree import DecisionTreeRegressor

reg = DecisionTreeRegressor(max_depth=3, random_state=42)
reg.fit(X_train, y_train)

Regression trees produce piecewise-constant predictions: every leaf is a single number, so the prediction surface is a step function. This is the right tool when the relationship is piecewise rather than smooth. For smooth surfaces, a linear or kernel model is usually better. For the piecewise step-function case (say, predicting house prices where there are real discontinuities at school-district boundaries), trees are ideal.

FIG 05.3.5

The instability of single trees

Train a decision tree on 100 random points from Iris. Train another on a different 100 random points from Iris. The two trees will pick different root splits, different thresholds, possibly different orderings.

This is the instability problem. Trees have high variance because the greedy split decisions at the top of the tree are extremely sensitive to which examples landed in the . Once the root split is locked in, every downstream decision is conditioned on it.

Géron's Figure 6-7 in 08-geron-notebooks/06_decision_trees shows the same dataset trained twice (with two different random seeds in the data subsampling) producing visibly different decision boundaries. This is not a bug in CART. It is the cost of greedy decisions over discrete splits.

There are two fixes.

Fix 1: more data per node. If each node sees thousands of points, the greedy choice becomes statistically stable.

Fix 2: average many trees. Train TT trees, each on a bootstrap sample of the data (a fresh set of mm points drawn with replacement, so some examples repeat and roughly a third are left out), and take majority vote. This is bagging, and it is the foundation of random forests (another chapter). Variance drops by roughly 1/T1/T if the trees are uncorrelated.

The instability of single trees is why random forests work. Single trees are unstable, but the aggregate of many unstable trees is stable, in the same way that the mean of many noisy measurements is precise.

FIG 05.3.6

Linear SVMs: the maximum-margin classifier

Switch tracks. Support vector machines are the second classical workhorse. The idea: among all linear decision boundaries that separate two classes, pick the one whose margin (distance to the nearest training point on either side) is maximum.

For linearly separable data with labels yi{1,+1}y_i \in \{-1, +1\}, the model is y^=sign(wTx+b)\hat{y} = \text{sign}(\mathbf{w}^T \mathbf{x} + b). The hard-margin SVM problem is:

minw,b12w22subject toyi(wTxi+b)1i\min_{\mathbf{w}, b} \frac{1}{2} \|\mathbf{w}\|_2^2 \quad \text{subject to} \quad y_i (\mathbf{w}^T \mathbf{x}_i + b) \geq 1 \quad \forall i

The constraint says "every training point is on the correct side of the boundary and at least margin 1 away (in scaled units)". The objective says "make the vector as small as possible". Combined, you get the largest geometric margin, because the geometric margin equals 1/w1 / \|\mathbf{w}\|.

For non-separable data (real data), allow some slack:

minw,b,ξ12w22+Ci=1mξi\min_{\mathbf{w}, b, \boldsymbol{\xi}} \frac{1}{2} \|\mathbf{w}\|_2^2 + C \sum_{i=1}^{m} \xi_i subject toyi(wTxi+b)1ξi,ξi0\text{subject to} \quad y_i (\mathbf{w}^T \mathbf{x}_i + b) \geq 1 - \xi_i, \quad \xi_i \geq 0

ξi\xi_i is the slack for example ii (how much it violates the margin). CC is the knob. Small CC = soft margin, large violations tolerated. Large CC = hard margin, model is forced to fit every example.

This is the soft-margin SVM and it is what SVC(kernel='linear') solves.

Python
from sklearn.svm import LinearSVC
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline

svm = Pipeline([
    ("scaler", StandardScaler()),
    ("svc", LinearSVC(C=1, loss="hinge", random_state=42)),
])
svm.fit(X, y)

FIG 05.3.7

The dual problem and support vectors

The SVM problem above is a convex quadratic program (a "convex QP": minimize a convex quadratic objective subject to linear constraints, the smallest step up from the linear least-squares fits of another chapter). By Lagrangian duality, the standard move of folding each constraint into the objective with a multiplier so a constrained problem becomes an equivalent one, every convex QP has a dual problem. For SVMs the dual is:

maxαi=1mαi12i,jαiαjyiyjxiTxj\max_{\boldsymbol{\alpha}} \sum_{i=1}^{m} \alpha_i - \frac{1}{2} \sum_{i, j} \alpha_i \alpha_j y_i y_j \mathbf{x}_i^T \mathbf{x}_j subject to0αiC,iαiyi=0\text{subject to} \quad 0 \leq \alpha_i \leq C, \quad \sum_i \alpha_i y_i = 0

with one Lagrange multiplier αi0\alpha_i \geq 0 per training point (the multiplier introduced by the duality step above). The matrix of all pairwise inner products xiTxj\mathbf{x}_i^T \mathbf{x}_j is the Gram matrix KK; the whole dual depends on the data only through it.

Three things to notice.

The data appears only as inner products xiTxj\mathbf{x}_i^T \mathbf{x}_j (the dot product: xTz=kxkzk\mathbf{x}^T \mathbf{z} = \sum_k x_k z_k, the sum of elementwise products of two vectors). This is the key observation. Everything about the input vectors is mediated by these inner products. If you can compute the inner product, you can solve the SVM. The features themselves can stay implicit.

Most αi\alpha_i end up at zero. Only training points exactly on the margin (or violating it) have αi>0\alpha_i > 0. These are the support vectors. The rest of the dataset contributes nothing to the decision function. Once trained, you can throw away every non-support-vector example and still get the same predictions.

The decision function is a weighted sum of inner products with support vectors:

y^(x)=sign(iSVαiyixiTx+b)\hat{y}(\mathbf{x}) = \text{sign}\left( \sum_{i \in SV} \alpha_i y_i \mathbf{x}_i^T \mathbf{x} + b \right)

This last form is what makes the kernel trick (§9) possible.

SVC(kernel='linear') vs. the soft-margin dual QP from scratch (cvxpy)

Classical ML
LIBRARY
svm = SVC(kernel="linear", C=10.0)
svm.fit(X, y)
w = (svm.dual_coef_ @ svm.support_vectors_).ravel()
sv = svm.support_vectors_
FROM SCRATCH
import cvxpy as cp
import numpy as np

X = np.array([[1, 2], [2, 3], [3, 3], [2, 1], [3, 2], [4, 1]])
y = np.array([1, 1, 1, -1, -1, -1])
m = len(y)
C = 10.0

alpha = cp.Variable(m, nonneg=True)
K = X @ X.T                                  # Gram matrix of inner products
obj = cp.Maximize(cp.sum(alpha)
                  - 0.5 * cp.quad_form(cp.multiply(alpha, y), K))
constraints = [alpha <= C, alpha @ y == 0]
prob = cp.Problem(obj, constraints)
prob.solve()

w = (alpha.value * y) @ X                    # recover primal w
support_vectors = X[alpha.value > 1e-5]      # alpha_i > 0 => support vector

from scratch: draft.md §7 (from-scratch path: solving the SVM dual on a toy 2D problem with cvxpy)

  1. 1SVC(kernel="linear") solving the dual QP internally (libsvm/SMO) cp.Problem(Maximize(sum(alpha) - 0.5*quad_form(alpha*y, K))).solve() — the exact dual objective
  2. 2kernel="linear" => Gram matrix x_i . x_j K = X @ X.T, the matrix of pairwise inner products the whole dual depends on
  3. 3C (box constraint on dual coefficients) and sum(alpha_i y_i) = 0 constraints = [alpha >= 0, alpha <= C, alpha @ y == 0]
  4. 4svm.dual_coef_ (the alpha_i*y_i of the support vectors) alpha.value * y, the signed dual weights
  5. 5w = dual_coef_ @ support_vectors_ (primal recovery) w = (alpha.value * y) @ X
  6. 6svm.support_vectors_ X[alpha.value > 1e-5] — points with nonzero dual variable
What the one call hides
  • Solver choice: SVC uses libsvm's SMO (an O(m^2)-ish coordinate solver tuned for SVMs), not a generic interior-point QP like cvxpy — same optimum, very different scaling
  • Bias b recovery: SVC computes b from the KKT conditions on margin support vectors (0 < alpha_i < C); the toy snippet recovers only w and leaves b implicit
  • The kernel trick: swapping K = X @ X.T for an RBF/poly Gram matrix turns this into a nonlinear SVM with no other code change — SVC(kernel='rbf') does exactly that under the hood
  • Mercer/PSD requirement: the dual is a convex QP only because K is symmetric positive semidefinite; SVC assumes it and your custom kernel must guarantee it
  • Numerical thresholding: 'is alpha a support vector' is alpha > 1e-5 here; sklearn applies its own tolerance and shrinking heuristics to decide the active set
  • Gotcha: SVC(C=...) and SGDClassifier(alpha=...) use opposite-direction knobs; large C = hard margin, large alpha = soft margin
  • Gotcha: Generic QP solvers (cvxpy) are exact but blow up past a few thousand points; never use this formulation for large datasets — that's what SGDClassifier is for
  • Gotcha: SVC still needs scaled features (StandardScaler) for the same metric reason as the SGD SVM; the linear-kernel dual is not scale-invariant either
  • Gotcha: decision_function is a signed margin, not a probability; SVC(probability=True) fits Platt scaling via internal CV and is slow and only roughly calibrated

Use SVC(kernel='linear'/'rbf') in production for small-to-medium exact SVMs; the cvxpy dual is purely pedagogical — it lets you watch the data enter only as the Gram matrix x_i.x_j (the hook for the kernel trick) and see that most alphas collapse to zero so only support vectors define the boundary.

On the job: At work you call SVC and pick C, the kernel, and gamma (and scale features); you never hand the QP to a generic solver — but the dual derivation is what lets you reason about why only support vectors matter and how a custom kernel slots in.

Run this and verify the resulting w and support vectors match sklearn.svm.SVC(kernel='linear', C=10) to floating-point tolerance.

FIG 05.3.8

Hinge loss: the SVM as a regularized linear model

There is a second, fully equivalent way to look at the SVM. Instead of constrained optimization, treat it as regularized empirical risk minimization with the hinge loss:

minw,b12w2+Ci=1mmax(0,1yi(wTxi+b))\min_{\mathbf{w}, b} \frac{1}{2} \|\mathbf{w}\|^2 + C \sum_{i=1}^{m} \max(0, 1 - y_i (\mathbf{w}^T \mathbf{x}_i + b))

The second term is the hinge loss: zero for examples correctly classified with margin ≥ 1, linearly growing otherwise. The first term is L2 on the weights.

This perspective is useful because it puts SVMs on the same footing as logistic regression. Logistic regression minimizes log-loss + L2; SVM minimizes hinge + L2. The only difference is the . And once you see SVM this way, training it with SGD is trivial: just plug hinge into the loss in another chapter's training loop.

sklearn.linear_model.SGDClassifier(loss='hinge') does exactly this and trains SVMs on millions of examples that the dual solver cannot handle.

SGDClassifier(loss='hinge') vs. SVM-by-SGD from scratch

Classical ML
LIBRARY
svm = SGDClassifier(loss="hinge", alpha=0.0001, max_iter=1000, random_state=42)
svm.fit(X_scaled, y)
preds = svm.predict(X_test)
FROM SCRATCH
def hinge_loss(w, b, X, y, C):
    y_pm = np.where(y == 0, -1, 1).astype(np.float64)
    margins = 1 - y_pm * (X @ w + b)
    return float(0.5 * (w @ w) + C * np.maximum(0, margins).sum())

def svm_sgd(X, y, C=1.0, eta=0.01, n_epochs=100, seed=0):
    rng = np.random.default_rng(seed)
    m, d = X.shape
    y_pm = np.where(y == 0, -1, 1).astype(np.float64)
    w = np.zeros(d)
    b = 0.0
    for ep in range(n_epochs):
        idx = rng.permutation(m)
        for i in idx:
            xi, yi = X[i], y_pm[i]
            margin = yi * (xi @ w + b)
            if margin < 1:
                grad_w = w - C * yi * xi
                grad_b = -C * yi
            else:
                grad_w = w
                grad_b = 0.0
            w = w - eta * grad_w
            b = b - eta * grad_b
    return w, b

def predict_svm(w, b, X):
    scores = X @ w + b
    return (scores >= 0).astype(int)

from scratch: lab/solution.py: svm_sgd, hinge_loss, predict_svm

  1. 1loss="hinge" the objective 0.5*||w||^2 + C*sum(max(0, 1 - y*(w.x+b))); the per-sample subgradient is taken only when margin y*(w.x+b) < 1
  2. 2svm.fit(...): the SGD epoch loop for ep in range(n_epochs): shuffle idx = rng.permutation(m), then per-example subgradient update
  3. 3the per-sample subgradient step if margin<1: grad_w = w - C*y*x, grad_b = -C*y; else grad_w = w, grad_b = 0 (regularizer-only step)
  4. 4learning-rate / eta0 schedule fixed eta=0.01 applied as w -= eta*grad_w; b -= eta*grad_b
  5. 5alpha (L2 strength) vs C the 0.5*w@w term; scratch weights the data loss by C, sklearn weights the penalty by alpha and averages the data loss (inverse parametrization)
  6. 6svm.predict(X): class via sign of decision function predict_svm: scores = X@w + b, return (scores >= 0) mapped back to {0,1}
What the one call hides
  • Loss scaling/parametrization: SGDClassifier minimizes (1/n)*sum(hinge) + alpha*||w||^2 (regularization knob is alpha, data term averaged); the scratch minimizes 0.5*||w||^2 + C*sum(hinge) (data summed, C on the data) — same model, inverse-and-rescaled knobs (large C ~ small alpha)
  • Learning-rate schedule: SGDClassifier defaults to learning_rate='optimal' (eta decays as 1/(alpha*(t+t0))); the scratch uses a fixed eta, which the lab hints warn can diverge if too large
  • Intercept handling: sklearn fits the bias with a separate update and fit_intercept=True by default; the scratch updates b with the same eta and no regularization on b (both leave the bias unregularized)
  • Averaging, early stopping, and tol-based convergence (average=False, early_stopping=False, tol=1e-3, n_iter_no_change=5) run silently in sklearn; the scratch just does a fixed n_epochs
  • Label encoding: SGDClassifier accepts arbitrary labels and maps internally to +-1; the scratch hard-codes y==0 -> -1 else +1, so it breaks on non-{0,1} labels
  • Gotcha: Feature scaling is mandatory: the 0.5*||w||^2 objective lives in the Euclidean metric and is not scale-invariant — StandardScaler first, or one large-range feature dominates the margin (sklearn does NOT scale for you)
  • Gotcha: alpha vs C confusion: SGDClassifier's alpha is the L2 strength (bigger = more regularization, softer margin); LinearSVC/SVC use C (bigger = harder margin) — opposite directions
  • Gotcha: decision_function is a signed distance, not a probability; SGDClassifier(loss='hinge') has no predict_proba — wrap in CalibratedClassifierCV if you need probabilities
  • Gotcha: A linear SVM caps around ~0.85 on Moons because the data is nonlinear; accuracy below 0.5 usually means the y -> +-1 encoding is wrong, not the optimizer

In a real job use SGDClassifier(loss='hinge') for large-scale linear SVMs and LinearSVC/SVC for exact small-data solutions; the scratch svm_sgd is here to show an SVM is just hinge+L2 trained by the same SGD loop as logistic regression, with the only change being the loss.

On the job: At work you pick the loss and the regularization strength (alpha/C) and make sure features are scaled; you do not hand-roll the subgradient or the shuffle loop.

FIG 05.3.9

The kernel trick

the dual: data appears only as inner products xiTxj\mathbf{x}_i^T \mathbf{x}_j. The decision function is a weighted sum of inner products with support vectors. Nowhere do you need to compute features explicitly. You only need to evaluate the inner product.

What if you want non-linear decision boundaries? The classical move is to expand features. Polynomial regression added x2x^2, x3x^3, x1x2x_1 x_2 as new columns. SVMs can do this too. But explicit polynomial expansion grows quickly: for degree-kk polynomials in dd dimensions (including lower-degree terms), the count is (d+kk)\binom{d+k}{k}. With d=100d = 100 and k=5k = 5, that is 96,560,646 features—usually impractical to materialize for a nontrivial dataset.

The kernel trick says: do not build the matrix. For the polynomial kernel of degree kk:

K(x,z)=(xTz+c)kK(\mathbf{x}, \mathbf{z}) = (\mathbf{x}^T \mathbf{z} + c)^k

If you expand the algebra, this equals ϕ(x)Tϕ(z)\phi(\mathbf{x})^T \phi(\mathbf{z}) for some implicit ϕ\phi that produces all monomials of degree k\leq k. You never compute ϕ(x)\phi(\mathbf{x}). You evaluate KK in O(d)O(d) time. The implicit feature space might have millions of dimensions; you compute the inner product without visiting any of them.

The RBF (radial basis function) kernel is even better:

K(x,z)=exp(γxz2)K(\mathbf{x}, \mathbf{z}) = \exp\left(-\gamma \|\mathbf{x} - \mathbf{z}\|^2\right)

This corresponds to an infinite-dimensional implicit feature space. The Taylor expansion of exe^x has infinitely many terms. You still compute KK in O(d)O(d) time. The dual SVM is now operating in an infinite-dimensional space, and the model has no idea.

Python
from sklearn.svm import SVC

poly_svm = SVC(kernel="poly", degree=3, coef0=1, C=5)
rbf_svm  = SVC(kernel="rbf", gamma=5, C=0.001)
poly_svm.fit(X_scaled, y)
rbf_svm.fit(X_scaled, y)

Hyperparameters:

  • C is (same as before): small = soft margin, large = hard.
  • gamma (RBF only) is "kernel width". Large gamma = sharp peaks around each support vector, decision boundary wiggles. Small gamma = smooth boundary.
  • degree and coef0 (poly only) control the polynomial.

Tune with cross-validation. Default gamma='scale' is a sensible starting point.

What the kernel trick does NOT do. It does not let you somehow extract the implicit features. They stay implicit forever. You can predict at new points (in O(dSV)O(d \cdot |SV|) time per prediction) but you cannot interpret the model in the implicit feature space. The kernel trick is opaque by construction.

Caveat (Mercer's theorem): Not every function is a valid kernel. K(x,z)K(\mathbf{x}, \mathbf{z}) must be symmetric and positive semi-definite — meaning the Gram matrix KK (§7) on any finite set of inputs is symmetric and satisfies zTKz0\mathbf{z}^T K \mathbf{z} \geq 0 for every vector z\mathbf{z}, which is exactly what guarantees the dual stays a convex QP. Polynomial and RBF satisfy this. So do linear, (sometimes), and Laplace. Inventing your own kernel without checking PSD is a way to get a model that does not train, or trains to garbage.

FIG 05.3.10

Polynomial features without the trick

Sometimes you want polynomial features explicitly (for interpretability, or because you want to combine them with a non-SVM model). sklearn's PolynomialFeatures does this:

Python
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression

poly = PolynomialFeatures(degree=3, include_bias=False)
X_poly = poly.fit_transform(X)
linear_reg = LinearRegression()
linear_reg.fit(X_poly, y)

This is the explicit version of what the polynomial kernel does implicitly. For small dd and small degree, the explicit version can be attractive because you keep readable names. As the expansion grows, alternatives include a kernel method, sparse/selected interactions, approximate feature maps, or a different nonlinear model; the right choice depends on sample count, latency, and interpretability needs.

The decision boundary you get from SVC(kernel='poly', degree=3) is, up to details, the same boundary you would get from PolynomialFeatures(degree=3) + LinearSVC on small problems. Verify this on a toy 2D dataset and you internalize the kernel trick.

FIG 05.3.11

When SVMs win, when they lose (the 2026 reality)

SVMs were the dominant classification algorithm from roughly 1998 to 2012. Then deep learning took over for image and text, and -boosted trees (XGBoost, LightGBM) took over for tabular. Where does that leave SVMs in 2026?

SVMs win when:

  • The dataset is small (~100 to ~10k examples). The dual SVM has a sample-efficient theory and tight margins.
  • Features are dense and the decision boundary is smooth but non-linear. RBF kernel SVMs interpolate cleanly.
  • You need a model with a clear theoretical guarantee. SVM bounds (VC dimension, margin-based bounds) are sharp.
  • You have a kernel that encodes domain knowledge. String kernels for biological sequences, graph kernels for chemistry. Replacing the kernel with a deep encoder is harder than tuning C and gamma.

SVMs lose when:

  • The dataset is large (> 100k examples). Dual solvers scale as O(m2)O(m^2) or worse. SGD-trained linear SVMs are fine but lose the kernel advantage.
  • Features are tabular and heterogeneous. XGBoost handles this better and gives importances.
  • Decision boundary is highly non-smooth. Trees handle discontinuities natively; RBF SVMs smooth them out.
  • The task is image / text / audio / video. Convolutional or transformer-based models dominate.

The honest verdict: SVMs are rarely the right first choice in 2026. They are still the right choice for a small set of small-data problems with kernels that bring in domain knowledge. Knowing them matters because (a) the kernel trick generalizes to other algorithms (kernel ridge regression, kernel PCA, Gaussian processes), and (b) the duality and margin reasoning shows up in the theory of every supervised learner. The SVM is not the deployment target; it is the conceptual scaffold.

FIG 05.3.12

A quick preview of random forests as the obvious next step

Single decision trees have high variance (§5). Many slightly different trees averaged together have much lower variance. That is bagging.

Random forest is bagging applied to decision trees, with one extra trick: at each split, consider only a random subset of features (max_features='sqrt' for classification). This decorrelates the trees, which makes the averaging effect stronger.

Python
from sklearn.ensemble import RandomForestClassifier

rf = RandomForestClassifier(n_estimators=100, max_features="sqrt", random_state=42)
rf.fit(X, y)

That is a teaser. another chapter is the full story: random forests, AdaBoost, boosting, XGBoost, LightGBM, stacking. For now, internalize: a single tree is a starter; an ensemble of trees is the production answer. The classical-ML state of the art on tabular data in 2026 is gradient-boosted trees, not deep learning, not SVMs.


FIG 05.4 · Safety lens · this chapter

The mechanisms in classical ML are different from neural networks, and worth knowing because they show up in any production system that uses these models for credit, criminal-justice, or medical decisions. Decision trees and SVMs both have well-studied failure modes.

Tree-based shortcut learning. A decision tree will happily split on any that improves training . If your dataset contains a feature that correlates strongly with a protected attribute (zip code with race, for instance), the tree will use it. The split is interpretable, which is good. The decision it encodes might still be illegal or unethical, which is the part to catch. The mitigation is to audit the tree post-hoc: tree.feature_importances_ plus export_text(tree) gives you a complete enumeration of the rules. If any rule fires on a forbidden proxy, you have a problem. See 05-safety/huyenchip-index §fairness-audits and 05-safety/anthropic-alignment-index §shortcut-learning.

SVM and adversarial inputs. The output of SVC.decision_function is a signed distance to the hyperplane. It is not a . SVC(probability=True) exists, but it fits Platt (a logistic regression on top of the decision function) using internal cross-validation, which is slow and not particularly well-calibrated. For any deployment that triggers an action above a probability threshold, calibrate explicitly with CalibratedClassifierCV. Then: SVMs trained with RBF kernels are adversarially robust to small perturbations only along directions aligned with support vectors; off-support-vector directions are easily attacked. This was documented in the early adversarial-examples literature (Goodfellow et al. 2014) but is rarely cited because SVMs fell out of vogue before adversarial ML matured. See 05-safety/anthropic-research-core-views-on-ai-safety §robustness and 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications (the LLM-era analog).

Tree-based dataset extraction attacks. A trained decision tree leaks information about its . The thresholds in the splits are literally values from the training data (or near-values, depending on the splitter). With access to the tree's structure, an attacker can reconstruct sensitive examples. This is the membership- attack at its sharpest. For models trained on medical or financial records, the mitigation is to either not export the tree structure (use random forests, where averaging blurs individual thresholds), or to train with differential privacy (DP-trees, which add noise to the split-selection step). See 05-safety/aisafetyatlas-index §privacy and 25-alignment-canon for the broader DP-ML literature.

What habits to adopt from now on:

  • Print tree.feature_importances_ and tree.tree_.feature after every tree fit. Verify no protected-attribute proxy has nonzero importance.
  • Run CalibratedClassifierCV on any SVM that triggers an automated decision. Five lines of code, catches a class of deployment bugs.
  • For models that touch personal data, default to ensembles (RFs) rather than single trees. The blurring helps against extraction attacks.


FIG 05.6 · Chapter notebook

Build this chapter with your own hands

A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.

What you'll build

  • A CART decision tree from scratch (Gini, greedy splits, leaf vote) that you check against scikit-learn on the same data, plus the depth-vs-overfitting curve that explains why a depth-30 tree scores 100% on train and worse on test.
  • A linear SVM dual solved as a quadratic program with scipy, whose weight vector and support-vector set match SVC(kernel="linear") to four decimals. That match proves the SVM sees the data only through inner products.
  • The polynomial kernel's explicit feature map, written out by hand, shown to reproduce (x·z+1)² exactly, then an RBF SVM whose decision boundary you sweep with gamma.
  • A deliberate footgun: an RBF SVM on an unscaled feature, watched failing, then fixed with a StandardScaler pipeline.

~2 min on CPU · 104 cells · 9 checked exercises · runs in Colab


FIG 05.7 · Going further

  • 08-geron-notebooks/05_support_vector_machines

    the reference notebook for everything in §6-§10. Includes the exact figures referenced here.

  • 08-geron-notebooks/06_decision_trees

    same for §1-§5.

  • 23-textbooks/boyd-convex chapters 5 (duality) and 8 (geometric problems) — the math behind the SVM dual, made rigorous.
  • 04-stanford/cs229-main-notes-pdf §svm

    Andrew Ng's CS229 notes on SVMs. Tighter than Géron, looser than Boyd. The right middle level.

  • 02-code-refs/amidi-cs229-supervised

    Amidi's cheatsheet. One-page summary of trees, SVMs, kernels. Useful as a flashcard.

  • 13-fastbook/09_tabular

    fast.ai's "why we use tree ensembles, not neural networks, on tabular data". Read after this chapter and before another chapter.

  • 18-lilian-weng/2021-12-05-semi-supervised

    only the kernel-method intro section; useful for seeing kernels reappear in modern semi-supervised methods.

  • arena-curriculum from 05-safety/arena-curriculum.md — ARENA does not have an SVM chapter. The transition to deep learning is mostly through another chapter onwards in this curriculum.

FIG 05.8 · What this enables

Chapters you can now read, with the connecting idea written out.

  • Random forests are bagging applied to the trees you just built. Gradient boosting is gradient descent in the space of trees. Knowing what a single tree is and what bagging does is the precondition.

  • Kernel PCA reuses the same kernel matrix concept from SVMs. The kernel trick generalizes beyond classification.

  • The hinge loss is one of many loss functions you have now seen. Cross-entropy (another chapter) and hinge are the two classical-ML loss workhorses; you will meet contrastive, triplet, and Wasserstein lossess.


FIG 05.9 · 18 sources
  1. 01-explorables (RBF / kernel intuition resources, if scraped)
  2. 02-code-refs/amidi-cs229-supervised
  3. 02-code-refs/amidi-cs229-ml-tips
  4. 04-stanford/cs229-main-notes-pdf
  5. 05-safety/anthropic-alignment-index
  6. 05-safety/anthropic-research-core-views-on-ai-safety
  7. 05-safety/huyenchip-index
  8. 05-safety/aisafetyatlas-index
  9. 05-safety/arena-curriculum
  10. 08-geron-notebooks/05_support_vector_machines
  11. 08-geron-notebooks/06_decision_trees
  12. 08-geron-notebooks/07_ensemble_learning_and_random_forests
  13. 13-fastbook/09_tabular
  14. 16-d2l-sections/chapter_linear-classification__classification
  15. 18-lilian-weng/2021-12-05-semi-supervised
  16. 23-textbooks/boyd-convex
  17. 23-textbooks/math4ml
  18. 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications