Skip to main content

Linear SVMs in practice

A linear SVM on real data: why the features must be scaled first and inside a pipeline, how to choose C by cross-validation, how the SVM compares with logistic regression, and how support vector regression fits a tube instead of a line.
ML02.7.4L2 Intermediate25 min
In review (what this means)

The maths and code on this page are checked by automated tests on every change, but an independent reader has not signed it off yet. Spot a mistake or a confusing step? Tell us.

Before this lesson: ML02.7.3 Soft margins and the hinge loss ML01.9.3 Cross-validation

What you will be able to do
  • Explain why an SVM is not scale invariant and fit it inside a pipeline with StandardScaler.
  • Choose C by cross-validation with GridSearchCV and report an honest test accuracy.
  • Compare the hinge loss with the log loss and say when an SVM and logistic regression differ, including the lack of probabilities.
  • Describe support vector regression by its epsilon-insensitive tube and fit SVR on a small data set by hand and in code.

The question

The breast cancer dataset that ships with scikit-learn describes 569 tumours by 30 measurements, from a “worst area” of about \(880\) on average to a “fractal dimension error” of about \(0.004\). Fit a linear SVM to it. What must you do to the features first, how do you choose \(C\), and is the result any different from logistic regression?

From the last lesson

1. What is the hinge loss of a point, and when is it zero?

Show answer

\({\max(0,\ 1 - y_i f(\vect{x}_i))}\). It is zero when \({y_i f(\vect{x}_i) \ge 1}\): the point is on or beyond its own edge of the street.

2. Does a larger \(C\) mean stronger or weaker regularisation?

Show answer

Weaker. In the form “average hinge loss plus \({\lambda \norm{\vect{w}}^2}\)”, \({\lambda = 1/(2nC)}\).

Intuition

The SVM maximises a distance, and a distance adds up squared differences in every feature. A feature recorded in large units dominates that sum; one recorded in tiny units hardly counts. So the widest street depends on the units, which have nothing to do with the classes.

Two panels of the six points, both drawn in the original units. Left, titled x2 in units 10 times smaller: the hard-margin street found after multiplying x2 by 10 is almost horizontal, with centre line near x2 equals 1.5, edges through (0, 1) and (2, 2), which are on yellow discs, and a label width 1.04: nearly flat. Right, titled Standardised first: the street of lesson 1 along x1 plus x2 equals 2.5 with three support vectors and the label width 2.12. A sentence underneath reads: both streets drawn in the original units; stretching one feature changes which street is widest.
ML02.7.4-F1

The same six points. Record \(x_2\) in units ten times smaller and the SVM, measuring distance in those units, finds a nearly flat street, half as wide in the original units. Standardising first restores the street of lesson 1.

The cure is to put every feature on the same footing before the SVM sees it. Standardising subtracts each feature’s mean and divides by its standard deviation, so that every feature has mean \(0\) and spread \(1\). For the six points the two features already have the same spread, so standardising leaves the street of lesson 1 as it was.

Build it up

Step 1. Scale inside a pipeline. The means and standard deviations are themselves learned from data, so they must come from the training rows only. A Pipeline of StandardScaler and SVC fits both steps on whatever rows it is given, which keeps every validation fold and the test set out of the scaling.

Step 2. Choose \(C\) on validation folds. \(C\) trades width for slack, and the training data cannot judge it: a larger \(C\) always fits the training rows at least as hard. So try a grid of values, \(0.001\) to \(100\) in factors of ten, and score each by 5-fold cross-validation (ML01.9.3).

A plot of cross-validated accuracy against C on a logarithmic axis from 0.001 to 100, for a standardised linear SVC on the 426 training rows of the breast cancer data. A green line with dots rises from 0.932 at C equals 0.001 to a peak of 0.984 at C equals 1, ringed in yellow and labelled best: C equals 1, 0.984, then falls to 0.962 at 10 and 0.958 at 100. Reddish-purple bars show one standard deviation over the 5 folds, from about 0.01 to 0.02 each side. Notes read small C: wide street, underfits, and large C: fits the noise.
ML02.7.4-F2

Cross-validated accuracy of a standardised linear SVM against \(C\). The peak is at \({C = 1}\); the bars show how much the five folds disagree, which is as large as most of the differences.

Step 3. Test once. Refit the chosen model on all the training rows and score it once on the test rows, which played no part in any choice.

Step 4. Read the support vectors. The fitted SVM keeps only the rows with \({\alpha_i > 0}\). On this data it keeps 29 of 426.

The formal statement

Hinge against log loss. Both the soft-margin SVM and logistic regression fit a linear score \({f(\vect{x}) = \vect{w} \cdot \vect{x} + b}\) by minimising a loss of the margin \({m = y f(\vect{x})}\) plus a ridge penalty, with labels \({y \in \{-1, +1\}}\):

\[ \begin{aligned} \text{SVM:}\quad & \closs{\max(0,\ 1 - m)} , \\ \text{logistic:}\quad & \closs{\ln\!\big(1 + e^{-m}\big)} . \end{aligned} \]

The second is the log loss of ML01.6.2 rewritten for labels \(\pm 1\): with \({p = \sigma(f)}\), a point of class \(+1\) costs \({-\ln \sigma(f) = \ln(1 + e^{-f})}\) and a point of class \(-1\) costs \({-\ln(1 - \sigma(f)) = \ln(1 + e^{f})}\), and both are \({\ln(1 + e^{-y f})}\).

Two plots. Left, titled Classification: three losses against the margin y f of x from minus 2 to 3: a grey dashed 0-1 step, a reddish-purple hinge that is exactly 0 beyond margin 1, and a green log loss in bits that stays above zero and approaches it slowly. Right, titled Regression, epsilon equals 1: two losses against the residual from minus 3 to 3: a reddish-purple epsilon-insensitive loss that is zero between minus 1 and 1, labelled free inside the tube, and rises with slope 1 outside, labelled the absolute residual minus epsilon; and a grey dashed parabola, half r squared.
ML02.7.4-F3

The losses side by side, with the log loss drawn in bits (divided by \({\ln 2}\)) so that it also passes through \(1\) at \({m = 0}\). The hinge is exactly zero beyond \({m = 1}\); the log loss never is. In regression the \(\varepsilon\)-insensitive loss ignores residuals smaller than \(\varepsilon\).

Far on the wrong side both losses grow like \({-m}\), so they agree about bad mistakes. They differ beyond \({m = 1}\): the hinge is exactly zero there, so those points drop out of the fit, while the log loss still pulls on every point a little. Hence:

  • the SVM depends only on its support vectors (ISL Section 9.5), logistic regression on all rows;
  • logistic regression estimates a probability \({\sigma(f)}\); the SVM score \(f\) is not a probability. scikit-learn’s SVC offers probability=True, which fits a logistic curve to the scores with an extra internal cross-validation (Platt scaling, ML04.7).

Support vector regression. For a numeric target, replace the street by a tube of half-height \(\varepsilon\) around \({f(x) = \vect{w} \cdot \vect{x} + b}\). Residuals inside the tube cost nothing; outside, the cost grows linearly:

\[ \begin{aligned} \min_{\vect{w},\,b}\ \ &\tfrac{1}{2}\norm{\vect{w}}^2 + C\sum_i \closs{\ell_i} , \\ \ell_i &= \max\big(0,\ \abs{y_i - f(\vect{x}_i)} - \varepsilon\big) . \end{aligned} \]

The \({\tfrac{1}{2}\norm{\vect{w}}^2}\) term asks for the flattest function, as the margin asked for the shortest \(\vect{w}\). Points inside the tube get dual weight \(0\); only points on or outside its edges are support vectors.

Worked example

SVR on the eight-point set. The data are \({x = 1, \dots, 8}\) with \({y = (1, 2, 2, 4, 6, 7, 7, 9)}\). Take \({\varepsilon = 1}\) and ask first for the flattest line whose tube holds every point.

A plot of y against x for the eight points (1, 1), (2, 2), (3, 2), (4, 4), (5, 6), (6, 7), (7, 7) and (8, 9) as blue dots. A thick vermillion line f of x equals x runs through a shaded tube with dashed edges one unit above and below it, labelled the tube: the absolute value of y minus f of x at most epsilon equals 1. Every point is inside or on the tube. The point (3, 2) sits on the lower edge and (8, 9) on the upper edge, both on yellow discs. A grey dotted line, labelled least squares: y equals seven sixths x minus one half, runs slightly steeper.
ML02.7.4-F4

The flattest line whose tube of half-height \(1\) holds all eight points. It is pinned by \({(3, 2)}\) on the lower edge and \({(8, 9)}\) on the upper edge.

Which points pin it? A line \({f(x) = wx + b}\) keeps \({(3, 2)}\) in the tube only if \({2 - (3w + b) \ge -1}\), and \({(8, 9)}\) only if \({9 - (8w + b) \le 1}\). Rearranged:

\[ \begin{aligned} b &\le 3 - 3w, \\ b &\ge 8 - 8w . \end{aligned} \]

Both hold only if \({8 - 8w \le 3 - 3w}\), that is \({5 \le 5w}\), so \({w \ge 1}\). The flattest choice is \({w = 1}\), and then \({0 \le b \le 0}\) forces \({b = 0}\).

Check the rest. With \({f(x) = x}\) the residuals are \({(0, 0, -1, 0, 1, 1, 0, 1)}\): none exceeds \(1\) in size, so every point is in the tube and the loss term is \(0\). Besides \({(8, 9)}\), the points \({(5, 6)}\) and \({(6, 7)}\) also touch the upper edge. The least-squares line, \({y = \tfrac{7}{6}x - \tfrac{1}{2}}\), is steeper: it listens to every residual, while SVR ignores residuals up to \(1\).

With the penalty. For a finite \(C\) the solver may prefer a flatter line that lets some points out of the tube. Lowering \(w\) from \(1\) to \({1 - \delta}\) saves less than \(\delta\) in \({\tfrac{1}{2}w^2}\). But \({(3, 2)}\) then sticks out below the tube by at least \({3w + b - 3}\) and \({(8, 9)}\) above it by at least \({8 - 8w - b}\). These add up to \({5 - 5w = 5\delta}\), whatever \(b\) is, at a cost of at least \({5C\delta}\). For \({C = 1}\) that is a bad trade, so \({f(x) = x}\) is also the answer of SVR with \({C = 1}\).

Code

First the data and the case for scaling: the spreads of the 30 features.

import numpy as np
from sklearn.datasets import load_breast_cancer

data = load_breast_cancer()
X, y = data.data, data.target                       # y = 1 for benign, 0 for malignant
spread = X.std(axis=0)
print(X.shape, "  class counts:", np.bincount(y))
print(f"largest spread: {data.feature_names[spread.argmax()]}, {spread.max():.1f}")
print(f"smallest spread: {data.feature_names[spread.argmin()]}, {spread.min():.4f}")
print(f"ratio: {spread.max() / spread.min():,.0f}")
(569, 30)   class counts: [212 357]
largest spread: worst area, 568.9
smallest spread: fractal dimension error, 0.0026
ratio: 215,171

The six points of the anchor figure, from scratch: multiply \(x_2\) by 10, fit the hard margin, and read the street back in the original units. Then standardise by hand.

from sklearn.svm import SVC

P = np.array([[0, 0], [1, 0], [0, 1], [2, 2], [3, 2], [2, 3]], dtype=float)
labels = np.array([-1, -1, -1, 1, 1, 1])
stretched = P * [1, 10]
svm = SVC(kernel="linear", C=1e6, tol=1e-10).fit(stretched, labels)
w_orig = svm.coef_[0] * [1, 10]                     # the same score, in the original units
print("support vectors:", P[svm.support_].tolist())
print("width in original units:", round(2 / np.linalg.norm(w_orig), 2))

Z = (stretched - stretched.mean(axis=0)) / stretched.std(axis=0)
svm_z = SVC(kernel="linear", C=1e6, tol=1e-10).fit(Z, labels)
print("after standardising, support vectors:", P[svm_z.support_].tolist())
support vectors: [[0.0, 1.0], [2.0, 2.0]]
width in original units: 1.04
after standardising, support vectors: [[1.0, 0.0], [0.0, 1.0], [2.0, 2.0]]

Now the real data: a stratified split, then \(C\) by 5-fold cross-validation with the scaler inside the pipeline, and one test.

from sklearn.model_selection import GridSearchCV, StratifiedKFold, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, stratify=y, random_state=0)
folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
search = GridSearchCV(make_pipeline(StandardScaler(), SVC(kernel="linear")),
                      {"svc__C": 10.0 ** np.arange(-3, 3)}, cv=folds).fit(X_train, y_train)
print("mean CV accuracy:", np.round(search.cv_results_["mean_test_score"], 3))
print("best C:", search.best_params_["svc__C"])
model = search.best_estimator_
correct = int((model.predict(X_test) == y_test).sum())
print(f"test: {correct} of {len(y_test)} correct; support vectors kept: {model[-1].n_support_.sum()} of {len(y_train)}")
mean CV accuracy: [0.932 0.969 0.981 0.984 0.962 0.958]
best C: 1.0
test: 138 of 143 correct; support vectors kept: 29 of 426

The SVM keeps 29 rows. Refit it on those rows alone and nothing changes; do the same to logistic regression and its weights move.

from sklearn.linear_model import LogisticRegression

scaler = StandardScaler().fit(X_train)
Z_train = scaler.transform(X_train)
full = SVC(kernel="linear", C=1.0, tol=1e-8).fit(Z_train, y_train)
keep = full.support_
again = SVC(kernel="linear", C=1.0, tol=1e-8).fit(Z_train[keep], y_train[keep])
print("SVM, largest change in a weight:", f"{np.abs(full.coef_ - again.coef_).max():.1e}")

logistic = LogisticRegression(C=1.0).fit(Z_train, y_train)
logistic_kept = LogisticRegression(C=1.0).fit(Z_train[keep], y_train[keep])
print("logistic, largest change in a weight:", round(float(np.abs(logistic.coef_ - logistic_kept.coef_).max()), 2))

pred_svm = model.predict(X_test)
pred_lr = make_pipeline(StandardScaler(), LogisticRegression(C=1.0)).fit(X_train, y_train).predict(X_test)
print("test predictions that agree:", int((pred_svm == pred_lr).sum()), "of", len(y_test))
print("SVC has predict_proba:", hasattr(model, "predict_proba"))
SVM, largest change in a weight: 2.6e-08
logistic, largest change in a weight: 0.87
test predictions that agree: 140 of 143
SVC has predict_proba: False

LinearSVC solves the linear problem with a different library and is much faster on large data, but by default it minimises the squared hinge and also penalises the bias, so its answers differ a little.

from sklearn.svm import LinearSVC

for loss in ("squared_hinge", "hinge"):
    fast = make_pipeline(StandardScaler(), LinearSVC(C=1.0, loss=loss, max_iter=100_000)).fit(X_train, y_train)
    print(f"LinearSVC, loss={loss}: {int((fast.predict(X_test) == y_test).sum())} of {len(y_test)} correct")
LinearSVC, loss=squared_hinge: 136 of 143 correct
LinearSVC, loss=hinge: 138 of 143 correct

Last, support vector regression on the eight points: the hard tube from scratch with SLSQP, then SVR.

from scipy.optimize import minimize
from sklearn.svm import SVR

x = np.arange(1.0, 9.0)
t = np.array([1, 2, 2, 4, 6, 7, 7, 9], dtype=float)
eps = 1.0
tube = [{"type": "ineq", "fun": lambda p: eps - (t - p[0] * x - p[1])},     # not above the tube
        {"type": "ineq", "fun": lambda p: eps + (t - p[0] * x - p[1])}]     # not below it
flat = minimize(lambda p: 0.5 * p[0] ** 2, [1.2, -0.5], jac=lambda p: np.array([p[0], 0.0]),
                constraints=tube, method="SLSQP", options={"ftol": 1e-12})
print("from scratch: w =", round(flat.x[0], 4), " b =", round(flat.x[1] + 0.0, 4))

svr = SVR(kernel="linear", C=1.0, epsilon=eps, tol=1e-10).fit(x[:, None], t)
print("SVR: w =", np.round(svr.coef_[0], 4), " b =", np.round(svr.intercept_, 4))
print("residuals:", t - svr.predict(x[:, None]))
print("least squares:", np.round(np.polyfit(x, t, 1), 4))
from scratch: w = 1.0  b = -0.0
SVR: w = [1.]  b = [0.]
residuals: [ 0.  0. -1.  0.  1.  1.  0.  1.]
least squares: [ 1.1667 -0.5   ]

Explore

The margin explorer is in lesson 3. Press Six points and Hard margin, then select \({(0, 1)}\) and drag it upwards slowly. The street turns to keep it out: one support vector is enough to tilt the answer, which is why a feature with a large spread, which moves points far, can tilt the street too.

Pitfalls

Two flow charts of four boxes each. Left, marked with a cross and titled Scale, then cross-validate: all 426 training rows, then StandardScaler fit on all rows (reddish purple), then split into 5 folds, then SVC fit on 4 folds and score 1 fold; a note reads the held-out fold already shaped the means and spreads. Right, marked with a tick and titled Scaler inside the pipeline: all 426 training rows, then split into 5 folds, then pipeline fit on 4 folds, scaler then SVC (green), then score the held-out fold; a note reads the held-out fold is never seen before it is scored.
ML02.7.4-F5

Wrong beside right. Fitted once on all rows, the scaler has already seen every validation fold. Inside the pipeline it is refitted on the training part of each fold.

“Scaling is a preprocessing step, so do it once at the start.”

The scaler learns means and spreads, and that learning must not see validation or test rows. Put StandardScaler and the SVM in one pipeline and hand the pipeline to GridSearchCV. The leak is small for a scaler but large for steps that learn more, such as feature selection (ML04.3).

“The SVM score is a probability, or close to one.”

decision_function returns \({\vect{w} \cdot \vect{x} + b}\), a signed distance in units of \({1/\norm{\vect{w}}}\), not a probability, and SVC has no predict_proba unless it is built with probability=True. That option fits a logistic curve to the scores by internal cross-validation; the scikit-learn guide warns that it is expensive and that the probabilities can be inconsistent with the scores.

“LinearSVC and SVC(kernel=‘linear’) are the same model.”

They solve slightly different problems: LinearSVC uses the squared hinge by default and penalises the bias along with the weights. On this split it gets 136 test rows right against 138 for SVC; with loss="hinge" it matches 138. Use LinearSVC for large data, and state the loss.

Check yourself

Recall

1. Why must the features be scaled before a linear SVM?

Show answer

The SVM maximises a Euclidean distance, which is dominated by features with large spreads, so the widest street depends on the units. Standardising gives every feature mean \(0\) and spread \(1\).

2. Why should the scaler sit inside the pipeline?

Show answer

Its means and spreads are learned from data. Inside the pipeline they are learned from the training part of each fold only, so no validation or test row leaks into the model.

3. Name two differences between a linear SVM and logistic regression.

Show answer

The SVM depends only on its support vectors, because the hinge is exactly zero beyond margin \(1\); logistic regression depends on every row. Logistic regression gives probabilities; the SVM gives a score that is not a probability.

4. What does the \(\varepsilon\)-insensitive loss charge for a residual of \(0.6\) and of \(2.5\) when \({\varepsilon = 1}\)?

Show answer

\({\max(0, 0.6 - 1) = 0}\) and \({\max(0, 2.5 - 1) = 1.5}\).

5. Which \(C\) did cross-validation choose on the breast cancer data, and how many support vectors did the model keep?

Show answer

\({C = 1}\), with a mean CV accuracy of \(0.984\), and 29 support vectors out of 426 training rows.

Exercises

1. ★ A standardised linear SVM gives a tumour the score \({f = -0.4}\). Which class is predicted, and is the tumour inside the street?

Show solution

The score is negative, so the class with label \(-1\), here class \(0\) (malignant) in scikit-learn’s coding. Since \({\abs{f} = 0.4 < 1}\), the point lies inside the street: the model is not confident, and a training point there would pay a slack of \({1 - 0.4 = 0.6}\).

2. ★★ With \({\varepsilon = 1.5}\), find the flattest line whose tube holds the eight points. Which points pin it?

Show solution

Try the same pair. \({(3, 2)}\) stays in the tube if \({2 - 3w - b \ge -1.5}\), that is \({b \le 3.5 - 3w}\); \({(8, 9)}\) if \({9 - 8w - b \le 1.5}\), that is \({b \ge 7.5 - 8w}\). Both hold when \({7.5 - 8w \le 3.5 - 3w}\), so \({w \ge 0.8}\). With \({w = 0.8}\), \({b = 3.5 - 2.4 = 1.1}\). Check all points: the residuals of \({f(x) = 0.8x + 1.1}\) for \({x = 1, \dots, 8}\) are

\[ \begin{aligned} &-0.9,\ -0.7,\ -1.5,\ -0.3, \\ &\phantom{-}0.9,\ \phantom{-}1.1,\ \phantom{-}0.3,\ \phantom{-}1.5 , \end{aligned} \]

all within \(1.5\) in size. So \({f(x) = 0.8x + 1.1}\), pinned by \({(3, 2)}\) below and \({(8, 9)}\) above. A wider tube allows a flatter line.

3. ★★★ Show that for labels \(\pm 1\) the log loss of logistic regression is \({\ln(1 + e^{-y f})}\), and that both it and the hinge grow like \({-y f}\) when \({y f}\) is very negative.

Show solution

With \({p = \sigma(f) = 1/(1 + e^{-f})}\), a class \(+1\) point costs \({-\ln p = \ln(1 + e^{-f})}\). A class \(-1\) point costs \({-\ln(1 - p)}\), and \({1 - \sigma(f) = \sigma(-f)}\), so the cost is \({\ln(1 + e^{f})}\). Both are \({\ln(1 + e^{-yf})}\) with \({y = \pm 1}\).

Write \({m = yf}\). For very negative \(m\), \({e^{-m}}\) is huge, so \({\ln(1 + e^{-m}) = -m + \ln(1 + e^{m})}\) and \({\ln(1 + e^{m}) \to 0}\): the log loss behaves like \(-m\). The hinge is \({1 - m}\) there, which also grows like \(-m\). Both charge bad mistakes linearly, unlike the squared loss.

Summary card

Scale inside a pipeline, choose C on folds, test once.

The anchor figure again: the six points with x2 recorded in units ten times smaller give a nearly flat street of width 1.04, while standardising first gives the street of lesson 1, of width 2.12.
ML02.7.4-F1

Units change the widest street.

  • Pipeline. make_pipeline(StandardScaler(), SVC(kernel="linear", C=C)).
  • Choose \(C\). Grid from \(0.001\) to \(100\), 5-fold CV: \({C = 1}\), accuracy \(0.984\), then 138 of 143 on test.
  • Hinge vs log. Same behaviour on mistakes; the hinge is zero beyond margin \(1\), so only support vectors matter, and no probabilities.
  • SVR. Flattest \(f\) with \({\abs{y - f} \le \varepsilon}\) where possible; eight points, \({\varepsilon = 1}\): \({f(x) = x}\).

Sources and next steps

Sources. Each was opened and checked against this lesson on 15 November 2026.

  • scikit-learn 1.9 User Guide, Section 1.4 Support Vector Machines. Section 1.4.5: “Support Vector Machine algorithms are not scale invariant, so it is highly recommended to scale your data”, with a make_pipeline(StandardScaler(), SVC()) example. Section 1.4.1: LinearSVC uses the squared hinge loss and also regularises the intercept. Section 1.4.1.2: probability=True calibrates scores with Platt scaling, fitted by an extra cross-validation, and the probabilities may be inconsistent with the scores. Section 1.4.7.2: the SVR primal, which penalises samples whose prediction is at least \(\varepsilon\) from the target.
  • Gareth James, Daniela Witten, Trevor Hastie and Robert Tibshirani, An Introduction to Statistical Learning, 2nd edition, Section 9.5: the hinge loss against the logistic loss, only support vectors affecting the classifier, and the two methods often giving very similar results.
  • Trevor Hastie, Robert Tibshirani and Jerome Friedman, The Elements of Statistical Learning, 2nd edition, Section 12.3.2: the SVM as loss plus penalty, equation (12.25), compared with other losses in Figure 12.4.

Next. Module ML02.8, The kernel trick, replaces the dot products of the dual with kernels and gives the SVM curved boundaries. Calibrating SVM scores into probabilities is in ML04.7.

This lesson unlocks

ML02.8 The kernel trick ML04.7 Calibration and thresholds (planned)

Your progress is saved in this browser only. No account, no tracking.
Back to top