Ch. 05
Trees, SVMs, Kernels
CART splits, the max-margin objective, the kernel trick — and an honest 2026 verdict on when SVMs still win.
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 One piece of information about an example that the model looks at when making a guess.Full glossary → 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
DecisionTreeClassifieron 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
cvxpyon 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 Models — you need gradient descent, regularization, and the bias-variance trade-off. The SVM sections re-use the L2 penalty geometry directly.
- Ch 3 — Classification — confusion 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 Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → algorithm. A decision tree is a function from to defined by a sequence of axis-aligned splits.
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: 2Four lines of output. The fitted tree fully described. That is the readability story.
Note on scale: trees do not require One piece of information about an example that the model looks at when making a guess.Full glossary → standardization. Splits are on raw thresholds; Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → 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 One piece of information about an example that the model looks at when making a guess.Full glossary → and threshold that minimize a weighted impurity:
where is the number of points at the node, and are the counts after the split, and is an impurity measure of the labels at that node.
Two common impurity measures.
Gini impurity for a node with class proportions :
Maximum at for uniform class distribution (most uncertain), zero when one class dominates (pure leaf).
Entropy for the same node:
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 , 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 MLtree = DecisionTreeClassifier(criterion="gini", max_depth=5, random_state=42)
tree.fit(X, y)
preds = tree.predict(X_test)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.predictionfrom scratch: lab/solution.py: gini, best_split, build_tree, predict_tree (TreeNode, _majority, _predict_one)
- 1
criterion="gini"gini(y) = 1 - sum(p_k^2) over class proportions; the impurity measure best_split tries to reduce - 2
tree.fit(X, y): the splitter that scans features/thresholdsbest_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
recursive node growth inside fit + max_depth stoppingbuild_tree: recurse left/right on X[mask]/X[~mask], stop on depth>=max_depth, pure node, or gain<=0 - 4
leaf class = argmax of class counts at the leaf_majority(y) returns vals[counts.argmax()], stored as TreeNode.prediction - 5
tree.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 for sorted thresholds; the implementation above is 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 The batch of examples the model actually studies and learns from.Full glossary → perfectly. Run sklearn's DecisionTreeClassifier(max_depth=None) on a 100-point dataset and you get 100% train The share of guesses the model got right out of all its guesses.Full glossary →. The corresponding test accuracy is typically poor.
The A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → hyperparameters for DecisionTreeClassifier are all about constraining the tree's growth:
max_depth: hard cap on tree depthmin_samples_split: minimum number of points required to split a nodemin_samples_leaf: minimum points in a leafmax_leaf_nodes: cap on total leavesmax_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.
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:
The leaf prediction is the mean of training labels that landed there, not the majority vote.
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 One piece of information about an example that the model looks at when making a guess.Full glossary → 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 The batch of examples the model actually studies and learns from.Full glossary →. 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 trees, each on a bootstrap sample of the data (a fresh set of 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 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 , the model is . The hard-margin SVM problem is:
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 A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → vector as small as possible". Combined, you get the largest geometric margin, because the geometric margin equals .
For non-separable data (real data), allow some slack:
is the slack for example (how much it violates the margin). is the A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → knob. Small = soft margin, large violations tolerated. Large = hard margin, model is forced to fit every example.
This is the soft-margin SVM and it is what SVC(kernel='linear') solves.
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:
with one Lagrange multiplier per training point (the multiplier introduced by the duality step above). The matrix of all pairwise inner products is the Gram matrix ; the whole dual depends on the data only through it.
Three things to notice.
The data appears only as inner products (the dot product: , 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 end up at zero. Only training points exactly on the margin (or violating it) have . 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:
This last form is what makes the kernel trick (§9) possible.
SVC(kernel='linear') vs. the soft-margin dual QP from scratch (cvxpy)
Classical MLsvm = SVC(kernel="linear", C=10.0)
svm.fit(X, y)
w = (svm.dual_coef_ @ svm.support_vectors_).ravel()
sv = svm.support_vectors_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 vectorfrom scratch: draft.md §7 (from-scratch path: solving the SVM dual on a toy 2D problem with cvxpy)
- 1
SVC(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
kernel="linear" => Gram matrix x_i . x_jK = X @ X.T, the matrix of pairwise inner products the whole dual depends on - 3
C (box constraint on dual coefficients) and sum(alpha_i y_i) = 0constraints = [alpha >= 0, alpha <= C, alpha @ y == 0] - 4
svm.dual_coef_ (the alpha_i*y_i of the support vectors)alpha.value * y, the signed dual weights - 5
w = dual_coef_ @ support_vectors_ (primal recovery)w = (alpha.value * y) @ X - 6
svm.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:
The second term is the hinge loss: zero for examples correctly classified with margin ≥ 1, linearly growing otherwise. The first term is L2 A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → 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 A rule that turns one bad guess into a single number measuring how far off it was, where lower is better.Full glossary →. 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 MLsvm = SGDClassifier(loss="hinge", alpha=0.0001, max_iter=1000, random_state=42)
svm.fit(X_scaled, y)
preds = svm.predict(X_test)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
loss="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
svm.fit(...): the SGD epoch loopfor ep in range(n_epochs): shuffle idx = rng.permutation(m), then per-example subgradient update - 3
the per-sample subgradient stepif margin<1: grad_w = w - C*y*x, grad_b = -C*y; else grad_w = w, grad_b = 0 (regularizer-only step) - 4
learning-rate / eta0 schedulefixed eta=0.01 applied as w -= eta*grad_w; b -= eta*grad_b - 5
alpha (L2 strength) vs Cthe 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
svm.predict(X): class via sign of decision functionpredict_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
Out of all the things that really were 'yes,' how many the model managed to catch.Full glossary → the dual: data appears only as inner products . 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 , , as new columns. SVMs can do this too. But explicit polynomial expansion grows quickly: for degree- polynomials in dimensions (including lower-degree terms), the One piece of information about an example that the model looks at when making a guess.Full glossary → count is . With and , 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 :
If you expand the algebra, this equals for some implicit The grid of responses you get after sliding a filter over an image, bright where the pattern was found.Full glossary → that produces all monomials of degree . You never compute . You evaluate in 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:
This corresponds to an infinite-dimensional implicit feature space. The Taylor expansion of has infinitely many terms. You still compute in time. The dual SVM is now operating in an infinite-dimensional space, and the model has no idea.
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:
Cis A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → (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.degreeandcoef0(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 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. must be symmetric and positive semi-definite — meaning the Gram matrix (§7) on any finite set of inputs is symmetric and satisfies for every vector , which is exactly what guarantees the dual stays a convex QP. Polynomial and RBF satisfy this. So do linear, A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary → (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:
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 and small degree, the explicit version can be attractive because you keep readable One piece of information about an example that the model looks at when making a guess.Full glossary → 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 A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → 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 A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary →-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 How well the model handles brand-new examples it never studied.Full glossary → 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 or worse. SGD-trained linear SVMs are fine but lose the kernel advantage.
- Features are tabular and heterogeneous. XGBoost handles this better and gives One piece of information about an example that the model looks at when making a guess.Full glossary → 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.
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, A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → 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 A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → 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 One piece of information about an example that the model looks at when making a guess.Full glossary → that improves training The share of guesses the model got right out of all its guesses.Full glossary →. 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 How well a model's stated confidence matches how often it's actually right.Full glossary → and adversarial inputs. The output of SVC.decision_function is a signed distance to the hyperplane. It is not a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary →. SVC(probability=True) exists, but it fits Platt Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → (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 batch of examples the model actually studies and learns from.Full glossary →. 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-Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → 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_andtree.tree_.featureafter every tree fit. Verify no protected-attribute proxy has nonzero importance. - Run
CalibratedClassifierCVon 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_machinesthe reference notebook for everything in §6-§10. Includes the exact figures referenced here.
08-geron-notebooks/06_decision_treessame for §1-§5.
23-textbooks/boyd-convexchapters 5 (duality) and 8 (geometric problems) — the math behind the SVM dual, made rigorous.04-stanford/cs229-main-notes-pdf §svmAndrew Ng's CS229 notes on SVMs. Tighter than Géron, looser than Boyd. The right middle level.
02-code-refs/amidi-cs229-supervisedAmidi's cheatsheet. One-page summary of trees, SVMs, kernels. Useful as a flashcard.
13-fastbook/09_tabularfast.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-supervisedonly the kernel-method intro section; useful for seeing kernels reappear in modern semi-supervised methods.
arena-curriculumfrom05-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
- 01-explorables (RBF / kernel intuition resources, if scraped)
- 02-code-refs/amidi-cs229-supervised
- 02-code-refs/amidi-cs229-ml-tips
- 04-stanford/cs229-main-notes-pdf
- 05-safety/anthropic-alignment-index
- 05-safety/anthropic-research-core-views-on-ai-safety
- 05-safety/huyenchip-index
- 05-safety/aisafetyatlas-index
- 05-safety/arena-curriculum
- 08-geron-notebooks/05_support_vector_machines
- 08-geron-notebooks/06_decision_trees
- 08-geron-notebooks/07_ensemble_learning_and_random_forests
- 13-fastbook/09_tabular
- 16-d2l-sections/chapter_linear-classification__classification
- 18-lilian-weng/2021-12-05-semi-supervised
- 23-textbooks/boyd-convex
- 23-textbooks/math4ml
- 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications