ml-models / Classical ML / Support vector machines core

Support vector machines

A support vector machine draws the separating line that stays as far as possible from both classes, lets a few points break the rule at a price set by C, and through a kernel can draw curved boundaries while solving only for one weight per training point. This page solves small SVMs exactly with a stdlib SMO solver, checks them against scikit-learn, and shows the margin, the hinge loss, the role of C, kernels and why the features must be scaled.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. The soft-margin primal
  5. The dual and the support vectors
  6. Kernels
  7. Solving the dual: SMO
  8. Worked example
  9. How C trades margin for violations
  10. One mislabelled point
  11. A ring that no line separates
  12. Why the features must be scaled
  13. Implementation
  14. Tradeoffs
  15. Connections
  16. Read first
  17. Read next
  18. References

Problem

Two classes of points can often be split by many different lines. Logistic regression picks the one that maximises the likelihood, which keeps moving every point a little further from the line. A different question is which line will make the fewest mistakes on new points that land near the boundary. A line that grazes a training point leaves no room for error, while one that sits in the middle of the gap tolerates noise on both sides.

Real data adds two complications. Some points sit on the wrong side of any line, so the method must allow violations at a cost. And many classes are not separated by a line at all: points inside a ring belong to one class and points outside to the other. Support vector machines handle the margin and both complications with a single convex optimisation problem, which has one global optimum and a solution that depends only on a few training points.

Intuition

A linear classifier predicts sign(w·x + b). The value y(w·x + b) is the functional margin of a point: positive when the point is on its correct side, larger the further it is. Scaling w and b does not move the line, so fix the scale by asking the closest points to have functional margin exactly 1. The two lines w·x + b = ±1 then bound an empty strip of width 2/‖w‖, and making the strip as wide as possible means making ‖w‖ as small as possible.

When the strip cannot be empty, each point pays a hinge loss max(0, 1 − y(w·x + b)): nothing once it is outside the strip on its correct side, and a cost growing linearly as it moves into the strip or across the line. C sets the exchange rate between a wider strip and those payments. Points that end up exactly on the strip's edge, inside it, or on the wrong side are the support vectors; every other point could be deleted without changing the answer.

Hinge, squared hinge and 0-1 loss as functions of the margin The margin m = y·f(x) runs from −2 to 3 on the horizontal axis. The 0-1 loss is 1 for m below 0 and 0 above. The hinge loss max(0, 1 − m) is a straight line reaching 0 at m = 1 and stays 0 after; at m = −1 it is 2. The squared hinge, LinearSVC's default, is the hinge squared: smooth at m = 1 and 4 at m = −1. Both hinge losses keep charging points with 0 < m < 1, which are correct but inside the margin. −2 −1 0 1 2 3 1 2 3 4 hinge max(0, 1 − m) squared hinge 0-1 loss m = 1 m = y·f(x) →

Loss per point against its margin. The hinge is a convex upper bound on the 0-1 loss that stays positive until m = 1, which turns the "keep a margin" goal into a problem with one optimum.

Mechanics

The soft-margin primal

minimise over w, b:   ½‖w‖² + C Σᵢ max(0, 1 − yᵢ(w·xᵢ + b))    with yᵢ ∈ {−1, +1}

The first term widens the margin, the second charges each violation. With slack variables ξᵢ this is the quadratic program: minimise ½‖w‖² + C Σ ξᵢ subject to yᵢ(w·xᵢ + b) ≥ 1 − ξᵢ and ξᵢ ≥ 0. As C → ∞ every ξᵢ is forced to 0 when that is possible, giving the hard-margin SVM. Divide by C and the objective is the average hinge loss plus an L2 penalty of strength 1/(2C): the same shape as regularised logistic regression with the log loss swapped for the hinge, which is why scikit-learn's SGDClassifier(loss="hinge") trains a linear SVM.

The dual and the support vectors

maximise over α:   Σᵢ αᵢ − ½ Σᵢ Σⱼ αᵢ αⱼ yᵢ yⱼ K(xᵢ, xⱼ)    subject to 0 ≤ αᵢ ≤ C,   Σᵢ αᵢ yᵢ = 0
w = Σᵢ αᵢ yᵢ xᵢ    f(x) = Σᵢ αᵢ yᵢ K(xᵢ, x) + b

The Lagrange dual has one variable per training point and touches the data only through dot products, written K(xᵢ, xⱼ) = xᵢ·xⱼ for the linear SVM. The optimality (KKT) conditions sort the points into three groups:

  • αᵢ = 0: margin yᵢf(xᵢ) ≥ 1, outside the strip. The point has no influence on the solution.
  • 0 < αᵢ < C: margin exactly 1, on the edge of the strip. These free support vectors fix b: b = yᵢ − Σⱼ αⱼ yⱼ K(xⱼ, xᵢ) for any of them.
  • αᵢ = C: margin ≤ 1, inside the strip or misclassified. Bounded support vectors pay hinge loss; no single point can pull harder than C.

At the optimum the primal and dual objectives are equal (strong duality holds because the problem is convex with linear constraints), so the duality gap is a stopping test.

Kernels

Because the dual and the decision function need only K(xᵢ, xⱼ), any function that is an inner product φ(x)·φ(x′) in some feature space can replace the dot product. The SVM then finds a maximum-margin hyperplane in that space, which is a curved boundary in the original one, without ever computing φ. Mercer's condition (K is symmetric and every Gram matrix is positive semidefinite) is what guarantees such a φ exists and keeps the dual concave. The kernels libsvm implements:

linear: x·x′    polynomial: (γ x·x′ + coef0)degree    RBF: exp(−γ ‖x − x′‖²)    sigmoid: tanh(γ x·x′ + coef0)

The polynomial kernel with degree 2 and coef0 = 1 in one dimension is (x x′ + 1)² = x²x′² + 2xx′ + 1 = φ(x)·φ(x′) with φ(x) = (x², √2 x, 1): a line in that space is a parabola in x. The RBF kernel corresponds to an infinite-dimensional φ. Its γ is an inverse squared length scale: a point influences the decision only within roughly 1/√γ of itself. The sigmoid kernel is not positive semidefinite for all parameters, so it is rarely the right choice.

Solving the dual: SMO

The dual is a quadratic program with n variables and an n × n kernel matrix, too big to hand to a generic QP solver for large n. Sequential minimal optimisation (Platt, 1998) updates two αs at a time, the smallest set that can move while keeping Σ αᵢyᵢ = 0, and the two-variable problem has a closed-form solution clipped to the box [0, C]. It keeps the gradient of the dual, picks the pair that most violates the optimality conditions, updates, and stops when the largest violation is below the tolerance (scikit-learn's tol, default 1e-3).

This page's helper uses the first-order rule: the maximal violating pair. libsvm, which scikit-learn's SVC wraps, picks the second index with second-order information (Fan, Chen and Lin, 2005), shrinks variables that are stuck at a bound, and caches kernel rows (cache_size, in MB). Both reach the same optimum; the second-order rule needs fewer iterations. Each step costs a kernel row, so training is between O(n²) and O(n³) and the scikit-learn documentation advises other estimators beyond a few tens of thousands of samples.

ComponentShape/ParamsMeaningSource
SVC C=1.0, kernel="rbf", degree=3, gamma="scale", coef0=0.0, tol=1e-3, cache_size=200, class_weight, max_iter=-1, decision_function_shape="ovr" Kernel SVM classifier wrapping libsvm. Multiclass is trained one-vs-one; the "ovr" shape only reshapes the decision values. _classes.py 623-912, C 648-653, __init__ 876-912
gamma "scale" (default), "auto" or a float "scale" is 1 / (n_features · X.var()), the variance taken over every entry of X at once; "auto" is 1 / n_features. _classes.py 668-678, computed in _base.py 268-283
probability bool, deprecated in 1.9 Platt scaling fit by internal 5-fold CV; will be removed in 1.11 in favour of CalibratedClassifierCV(SVC(), ensemble=False). _classes.py 687-696
BaseLibSVM.fit X (n_samples, n_features), y, sample_weight Validates, resolves gamma, calls libsvm, and for binary problems flips the signs of intercept_ and dual_coef_ so positive scores mean classes_[1]. _base.py 168-325, sign flip 294-302
dual_coef_, support_vectors_, coef_ (n_classes − 1, n_SV), (n_SV, n_features), (1, n_features) dual_coef_ holds αᵢyᵢ for the support vectors only. coef_ = dual_coef_ @ support_vectors_ = w, available for the linear kernel only. _classes.py 772-775, _base.py 686-710
decision_function X → (n_samples,) for two classes Σ αᵢyᵢK(xᵢ, x) + b for each row; the sign is the prediction. _base.py 795-825
Kernel::k_function kernel_type, degree, gamma, coef0 libsvm's linear, polynomial, RBF and sigmoid kernels, exactly the formulas above. svm.cpp 452-529
Solver::Solve, select_working_set Q matrix, box [0, C], eps = tol SMO with gradient maintenance and shrinking; the working set uses second-order gain for the second index. svm.cpp 666-943, 946-1043
calculate_rho free αs The intercept (libsvm stores ρ = −b) averaged over free support vectors, or the midpoint of the feasible range if there are none. svm.cpp 1126-1162
solve_c_svc Cp, Cn, per-sample weights W The C-SVC dual. Each point's upper bound is its sample weight times the class's C, which is how class_weight and sample_weight act. svm.cpp 1600-1647
rbf_kernel, polynomial_kernel X, Y, gamma (default 1 / n_features), degree, coef0 NumPy versions of the same kernels, for building a Gram matrix by hand or passing kernel="precomputed". pairwise.py 1583-1627, 1454-1503
LinearSVC penalty="l2", loss="squared_hinge", dual="auto", C=1.0, fit_intercept, intercept_scaling=1 liblinear's linear SVM: scales to many samples, defaults to the squared hinge, and penalises the intercept like a weight, so results differ slightly from SVC(kernel="linear"). _classes.py 44-364, loss 68-72, intercept_scaling 114-128
SGDClassifier loss="hinge" (default), alpha=1e-4 A linear SVM trained by stochastic subgradient descent on the primal; alpha plays the role of 1/(n·C). _stochastic_gradient.py 1267-1270, loss table 522-526
Deleting a training point leaves an SVM's line exactly where it was. What do you know about that point?

Its α was 0: it was outside the margin on its correct side and was not a support vector. Deleting a support vector, by contrast, generally moves the solution, because the remaining points no longer pin the strip in place.

Worked example

Eight points in two dimensions, four per class: P1 = (1.0, 1.0) (−1), P2 = (2.0, 1.0) (−1), P3 = (1.0, 2.5) (−1), P4 = (2.5, 2.0) (−1), P5 = (3.5, 3.0) (+1), P6 = (4.5, 3.0) (+1), P7 = (3.0, 4.5) (+1), P8 = (4.0, 4.5) (+1). The numbers below come from an exact SMO solver run to a tolerance of 1e-09, and match scikit-learn 1.9.1's SVC(kernel="linear") to every printed digit.

How C trades margin for violations

CwbMargin 2/‖w‖Support vectorsAt α = CΣ hingePrimal = dual
100 (1.0000, 1.0000) −5.5000 1.4142 P4, P5 none 0.0000 1.00000 = 1.00000
0.5 (0.5000, 0.5000) −2.7500 2.8284 P4, P5 P4, P5 1.0000 0.75000 = 0.75000
0.1 (0.4585, 0.4268) −2.3439 3.1926 P2, P3, P4, P5, P6, P7 P3, P4, P5, P7 1.3439 0.33061 = 0.33061

At C = 100 the data are separable and the solution is the hard margin: P4 (2.5, 2.0) and P5 (3.5, 3.0) sit exactly on the strip's edges with α = 1, so w = α(y₅x₅ + y₄x₄) = (3.5 − 2.5, 3 − 2) = (1, 1) and the strip is 2/√2 = 1.4142 wide. The objective is ½‖w‖² = 1.

At C = 0.5 the same two points can no longer reach α = 1, so both sit at the bound α = C = 0.5. The line keeps its direction and position, w halves to (0.50, 0.50), and the strip doubles to 2.8284: P4 and P5 now sit inside it with margin 0.5, paying a total hinge loss of 1. Check the primal: ½ · 0.5 + 0.5 · 1 = 0.75.

At C = 0.1 violations are cheap. The strip widens to 3.1926, it now touches 6 support vectors, and the line tilts to w = (0.4585, 0.4268). More support vectors means the answer averages over more of the data, which is the variance-reducing effect of a small C.

The maximum-margin line at C = 100 and C = 0.1 Two panels show the same eight points. Left, C = 100: the solid line x₁ + x₂ = 5.5 with dashed margin lines through P4 (2.5, 2) and P5 (3.5, 3), strip width 1.414, two support vectors circled. Right, C = 0.1: the line tilts to w = (0.459, 0.427), the dashed margin lines move out to a strip of width 3.193, and 6 points are circled as support vectors, several of them inside the strip. 1 1 2 2 3 3 4 4 5 5 P1 P2 P3 P4 P5 P6 P7 P8 C = 100, margin 1.41, 2 SVs 1 1 2 2 3 3 4 4 5 5 P1 P2 P3 P4 P5 P6 P7 P8 C = 0.1, margin 3.19, 6 SVs

Violet circles are +1, amber squares −1, red rings mark support vectors. The dashed lines are w·x + b = ±1. Points outside the strip on their own side have α = 0 and could be removed.

One mislabelled point

Add P9 = (2.0, 2.0) labelled +1, deep among the −1 points. No line separates the data any more, so even a large C cannot give a hard margin.

CwbMargin 2/‖w‖P9's margin y·fP9's αSupport vectorsPrimal
100 (0.5000, 1.5000) −5.2500 1.2649 −1.2500 100.000 P3, P4, P5, P9 226.2500
0.5 (0.7059, 0.4706) −2.8824 2.3575 −0.5294 0.500 P2, P3, P4, P5, P9 1.5363
0.1 (0.3636, 0.3636) −1.7273 3.8891 −0.2727 0.100 P1, P2, P3, P4, P5, P6, P7, P9 0.4777

At C = 100 the outlier stays misclassified (margin −1.25) but pulls with the maximum force α = C. The line swings to w = (0.50, 1.50) and the strip narrows from 1.414 to 1.265: one bad label has reshaped the boundary for everyone, and the objective jumps from 1 to 226.25. At C = 0.1 the outlier still sits at α = C, but C is now small, so its pull is limited and the line, w = (0.3636, 0.3636), points almost the same way as without it. A small C is a robustness setting: no point can contribute more than C to w.

A ring that no line separates

In one dimension, put +1 at x ∈ {−1, −0.5, 0, 0.5, 1} and −1 at x ∈ {±2, ±2.5, ±3}: the +1 class sits inside |x| < 1.5. A threshold on x can only split left from right, so the linear SVM with C = 10 gives up: w = 0, every point is a support vector, and it labels everything −1 for 55% accuracy. Kernels fix this.

KernelSupport vectorsbTrain accuracyf(x) at x = 6
linear11−1.000055%−1.0000
polynomial, (x·x′ + 1)²41.6667100%grows like −x²
RBF, γ = 0.14−1.8782100%−2.7926
RBF, γ = 17−0.1906100%−0.1906
RBF, γ = 1011−0.1006100%−0.1006

The polynomial kernel's decision values at the eleven points are (−4.33, −2.50, −1.00, 1.00, 1.50, 1.67, 1.50, 1.00, −1.00, −2.50, −4.33): a downward parabola in x, positive only near zero. In the lifted space φ(x) = (x², √2 x, 1) that parabola is a plane. Check the trick on x = 1 and x′ = 2: (1 · 2 + 1)² = 9, and φ(1)·φ(2) = (1.000, 1.414, 1.000) · (4.000, 2.828, 1.000) = 4 + 4 + 1 = 9.

All three RBF models fit the training points perfectly, but they disagree away from them. With γ = 10 each point's influence dies within about 1/√10 ≈ 0.3, every point becomes a support vector, and at x = 6 the score has decayed to the intercept b = −0.1006: the model has memorised the points and has nothing to say away from them. Its ripples between neighbouring points are the same symptom. With γ = 0.1 only 4 support vectors are needed and the curve is smooth. Large γ, like large C, means low bias and high variance.

RBF decision functions on the one-dimensional ring for three values of gamma The horizontal axis is x from −4 to 4, the vertical axis the decision value f(x). Eleven training points sit on the zero line, +1 between −1 and 1 and −1 at ±2 to ±3. γ = 0.1 gives a smooth bump peaking at 1.88 at x = 0 and falling to −2.79 at x = 6. γ = 1 is flatter and crosses zero near ±1.5. γ = 10 hugs ±1 across each cluster with small ripples between neighbouring points, and falls back to b = −0.10 beyond the data. −3 −2 −1 1 −4 −3 −2 −1 0 1 2 3 4 γ = 0.1, 4 SVs γ = 1, 7 SVs γ = 10, 11 SVs x →

Decision value f(x) for three RBF widths, C = 10. Every model classifies the eleven training points correctly; they differ in what they say between and beyond them.

Why the features must be scaled

Take three points with one feature in [0, 1] and one in the thousands: (0.2, 1000.0), (0.8, 1000.0), (0.2, 3000.0). On the raw data gamma="scale" gives γ = 4.39e-07, because the variance of all entries is dominated by the large feature. The first two points differ by the whole range of the small feature, yet their RBF similarity is 0.9999998, indistinguishable from identical points, while the first and third get 0.1726. The small feature is invisible to the kernel. After standardising each column, γ = 0.50 and both pairs have similarity 0.1054: each feature now counts once. gamma="scale" rescales γ by one global variance; it cannot fix features measured in different units, so SVMs go in a pipeline after a scaler.

Implementation

from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC, LinearSVC
from sklearn.linear_model import SGDClassifier
from sklearn.model_selection import GridSearchCV

X = [[1, 1], [2, 1], [1, 2.5], [2.5, 2], [3.5, 3], [4.5, 3], [3, 4.5], [4, 4.5]]
y = [-1, -1, -1, -1, 1, 1, 1, 1]

hard = SVC(kernel="linear", C=100).fit(X, y)
hard.coef_, hard.intercept_      # [[1, 1]], [-5.5]
hard.support_                    # [3, 4]: P4 and P5
hard.dual_coef_                  # [[-1, 1]]: alpha_i * y_i

# Kernel SVMs need scaled inputs; search C and gamma together on a log grid.
model = make_pipeline(StandardScaler(), SVC(kernel="rbf"))
search = GridSearchCV(model, {"svc__C": [0.1, 1, 10, 100],
                              "svc__gamma": ["scale", 0.01, 0.1, 1]}, cv=5)

# Many samples, linear boundary: liblinear or SGD on the primal.
linear = make_pipeline(StandardScaler(), LinearSVC(C=1.0))
sgd = make_pipeline(StandardScaler(), SGDClassifier(loss="hinge", alpha=1e-4))

SVC outputs scores, not probabilities. For calibrated probabilities wrap it in CalibratedClassifierCV; the old probability=True flag is deprecated in 1.9.1. For regression the same machinery with an ε-insensitive loss is SVR.

Tradeoffs

  • Scale. Kernel SVM training needs the kernel matrix, in pieces, so time grows between n² and n³ and memory with the cache. Beyond tens of thousands of samples use LinearSVC, SGDClassifier, or an approximate feature map (Nystroem, RBFSampler) followed by a linear model.
  • Prediction cost. Each prediction evaluates the kernel against every support vector. Small C and large γ make many support vectors and slow predictions.
  • C and γ. Both control flexibility and they interact, so tune them together on a logarithmic grid with cross-validation.
  • Preprocessing. The kernel compares raw distances, so unscaled features silently drop out, as the scaling example shows.
  • Scores, not probabilities. The decision value is a distance-like score. Calibrate it if you need probabilities, and use class_weight for imbalanced classes.
  • Against other models. On small to medium tabular data, gradient-boosted trees usually need less preprocessing and tuning. On images, text and audio, learned features from neural networks replaced hand-picked kernels. SVMs remain strong for small, high-dimensional problems such as text with many sparse features, and the margin and kernel ideas reappear across the field.
Your RBF SVM gets 100% training accuracy, 70% validation accuracy, and nearly every training point is a support vector. What do you change?

It is overfitting: each point has become its own bump. Lower γ so each support vector influences a wider region, lower C so violations are cheaper, and check that the features were standardised. A support vector fraction near 100% with high γ is the same symptom as the γ = 10 ring model above.

Connections

Read first

Read next

No page builds on this one yet.

The hinge loss sits beside the log loss on the loss functions page, and the primal is regularised empirical risk as in regularization. The dual comes from Lagrange multipliers, the constrained version of the stationarity condition on derivatives and gradients. Scoring by similarity to stored examples returns in attention, where the similarity is a dot product of learned features, and gradient boosting is the usual alternative on tabular data.

References