set.seed(5)
n <- 500
x1 <- runif(n) - 0.5
x2 <- runif(n) - 0.5
y <- as.numeric(x1^2 - x2^2 > 0) # true boundary: |x1| = |x2|
dat <- data.frame(x1, x2, y = as.factor(y))
table(y)
## y
## 0 1
## 254 246
The true boundary \(x_1^2 - x_2^2 = 0\) is a pair of crossing diagonals, so the classes form an “X” pattern — strongly non-linear.
plot.class <- function(pred, main) {
plot(x1, x2, col = ifelse(pred == 1, "red", "blue"),
pch = 19, cex = 0.6, xlab = expression(X[1]), ylab = expression(X[2]),
main = main)
}
plot.class(y, "True class labels")
The two classes occupy opposite pairs of the four triangular wedges created by the lines \(x_2 = \pm x_1\).
glm.lin <- glm(y ~ x1 + x2, data = dat, family = binomial)
summary(glm.lin)$coefficients
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -0.03150042 0.08949213 -0.3519910 0.7248450
## x1 -0.06176136 0.30506037 -0.2024562 0.8395601
## x2 -0.11508575 0.31085517 -0.3702231 0.7112163
Neither predictor is significant — a linear model cannot detect this symmetric pattern.
p.lin <- predict(glm.lin, dat, type = "response")
pred.lin <- as.numeric(p.lin > 0.5)
plot.class(pred.lin, "Logistic regression: linear predictors")
c(training_error = mean(pred.lin != y), n_predicted_class1 = sum(pred.lin == 1))
## training_error n_predicted_class1
## 0.548 92.000
The decision boundary is linear: the model can only cut the square with a straight line, so it slices off one corner (92 of the 500 points are assigned to class 1) rather than recovering the “X” pattern. The training error is about 0.548 — worse than random guessing on these balanced classes, confirming that a linear boundary is fundamentally the wrong shape here.
glm.nl <- glm(y ~ x1 + x2 + I(x1^2) + I(x2^2) + I(x1 * x2),
data = dat, family = binomial)
summary(glm.nl)$coefficients
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 3.326437e+14 5463222 60887820 0
## x1 2.028082e+14 10236293 19812661 0
## x2 1.535589e+13 10431559 1472061 0
## I(x1^2) 2.348870e+16 40604997 578468154 0
## I(x2^2) -2.714208e+16 40267676 -674041375 0
## I(x1 * x2) -5.006653e+14 34113923 -14676275 0
pred.nl <- as.numeric(predict(glm.nl, dat, type = "response") > 0.5)
plot.class(pred.nl, "Logistic regression: quadratic predictors")
c(training_error = mean(pred.nl != y))
## training_error
## 0.066
The decision boundary is now obviously non-linear and recovers the “X” shape almost exactly, with a very small training error.
svc <- svm(y ~ x1 + x2, data = dat, kernel = "linear", cost = 10, scale = FALSE)
pred.svc <- predict(svc, dat)
plot.class(pred.svc, "Support vector classifier (linear kernel)")
c(training_error = mean(pred.svc != y),
n_predicted_class1 = sum(pred.svc == 1))
## training_error n_predicted_class1
## 0.492 0.000
The support vector classifier also produces a linear boundary and fails even more completely than the linear logistic model: it assigns all 500 observations to a single class (0 predicted as class 1), giving a training error equal to the proportion of the minority class.
svm.rad <- svm(y ~ x1 + x2, data = dat, kernel = "radial", gamma = 1, cost = 10)
pred.rad <- predict(svm.rad, dat)
plot.class(pred.rad, "SVM with radial kernel")
c(training_error = mean(pred.rad != y))
## training_error
## 0.022
data.frame(
model = c("Logistic (linear)", "Logistic (quadratic)",
"SVC (linear kernel)", "SVM (radial kernel)"),
training_error = round(c(mean(pred.lin != y), mean(pred.nl != y),
mean(pred.svc != y), mean(pred.rad != y)), 4)
)
## model training_error
## 1 Logistic (linear) 0.548
## 2 Logistic (quadratic) 0.066
## 3 SVC (linear kernel) 0.492
## 4 SVM (radial kernel) 0.022
The results show a clear pattern. Linear methods fail: both linear logistic regression and the linear support vector classifier produce a straight-line boundary and cannot separate classes whose true boundary is the pair of lines \(x_2 = \pm x_1\). Both have a training error near \(50\%\) — no better than guessing on these balanced classes. The SVC degenerates completely (every point assigned to one class), while the linear logistic model merely lops off a corner of the square; neither is usable.
Non-linear methods succeed: logistic regression with quadratic terms and the SVM with a radial kernel both recover the “X”-shaped boundary with very small training error. The key insight is that we do not need an SVM to get a non-linear boundary — logistic regression achieves the same thing if we manually supply the right non-linear transformations. The practical advantage of the SVM is that the kernel does this automatically: the radial kernel finds the non-linear boundary without our having to guess which transformations (\(x_1^2\), \(x_1x_2\), …) to include.
Auto data setAuto <- na.omit(Auto)
Auto$mpg01 <- as.factor(ifelse(Auto$mpg > median(Auto$mpg), 1, 0))
Auto2 <- subset(Auto, select = -c(mpg, name)) # drop mpg and name
table(Auto$mpg01)
##
## 0 1
## 196 196
costset.seed(1)
tune.lin <- tune(svm, mpg01 ~ ., data = Auto2, kernel = "linear",
ranges = list(cost = c(0.01, 0.1, 1, 5, 10, 100)))
summary(tune.lin)
##
## Parameter tuning of 'svm':
##
## - sampling method: 10-fold cross validation
##
## - best parameters:
## cost
## 1
##
## - best performance: 0.08435897
##
## - Detailed performance results:
## cost error dispersion
## 1 1e-02 0.08923077 0.04698309
## 2 1e-01 0.09185897 0.04393409
## 3 1e+00 0.08435897 0.03662670
## 4 5e+00 0.08948718 0.03898410
## 5 1e+01 0.08948718 0.03898410
## 6 1e+02 0.08692308 0.03887151
lin.best <- tune.lin$best.parameters$cost
c(best_cost = lin.best, cv_error = round(min(tune.lin$performances$error), 4))
## best_cost cv_error
## 1.0000 0.0844
The CV error reaches its minimum at cost =
1, with a CV error of about 0.0844
(roughly 8.4%). What stands out is how insensitive the
linear classifier is to cost here: across the whole range
\(0.01\)–\(100\) the CV error only moves between about
0.084 and 0.092, a spread of well under two percentage points, with a
shallow minimum at cost = 1. This tells us the two mileage
groups are close to linearly separable, so the amount of slack allowed
by cost barely matters.
cost and gammaset.seed(1)
tune.rad <- tune(svm, mpg01 ~ ., data = Auto2, kernel = "radial",
ranges = list(cost = c(0.1, 1, 5, 10, 100),
gamma = c(0.01, 0.1, 1, 5)))
tune.rad$best.parameters
## cost gamma
## 12 1 1
round(min(tune.rad$performances$error), 4)
## [1] 0.0663
cost and degreeset.seed(1)
tune.poly <- tune(svm, mpg01 ~ ., data = Auto2, kernel = "polynomial",
ranges = list(cost = c(0.1, 1, 5, 10, 100),
degree = c(2, 3, 4)))
tune.poly$best.parameters
## cost degree
## 10 100 3
round(min(tune.poly$performances$error), 4)
## [1] 0.0842
data.frame(
kernel = c("linear", "radial", "polynomial"),
cv_error = round(c(min(tune.lin$performances$error),
min(tune.rad$performances$error),
min(tune.poly$performances$error)), 4)
)
## kernel cv_error
## 1 linear 0.0844
## 2 radial 0.0663
## 3 polynomial 0.0842
Comments. The radial kernel is the
clear winner, cutting the CV error to about 0.0663 versus roughly \(0.084\) for the other two — a reduction of
about two percentage points. Its best setting is an
intermediate gamma = 1 with cost = 1,
and the tuning grid shows why intermediate is right: the best error at
each gamma is 0.0842, 0.0791, 0.0663, 0.0917 for
gamma = 0.01, 0.1, 1, 5. Too small a gamma
gives an almost-linear boundary (no better than the linear kernel),
while too large a gamma (\(5\)) makes the kernel very local and
overfits, giving the worst error of all.
The polynomial kernel needs a large
cost (100) and degree 3 to become competitive, and even
then it only ties the linear classifier rather than
beating it. So the ordering is radial \(\ll\) polynomial \(\approx\) linear: the classes are nearly
linearly separable (hence the strong linear baseline), but a moderately
flexible radial boundary still extracts a genuine further
improvement.
par(mfrow = c(1, 2))
# linear: CV error vs cost
plot(tune.lin$performances$cost, tune.lin$performances$error, type = "b", log = "x",
xlab = "cost (log scale)", ylab = "CV error", main = "Linear kernel")
# radial: CV error vs cost, one line per gamma
perf <- tune.rad$performances
gams <- sort(unique(perf$gamma))
plot(range(perf$cost), range(perf$error), type = "n", log = "x",
xlab = "cost (log scale)", ylab = "CV error", main = "Radial kernel")
for (i in seq_along(gams)) {
sub <- perf[perf$gamma == gams[i], ]
lines(sub$cost, sub$error, type = "b", col = i, pch = 19)
}
legend("topright", legend = paste("gamma =", gams), col = seq_along(gams), lty = 1, cex = 0.8)
par(mfrow = c(1, 1))
The left panel shows how flat the linear
classifier’s CV error is across four orders of magnitude of
cost — the vertical axis spans less than two percentage
points, so no value of cost is meaningfully better than
another. The right panel shows the radial kernel’s behaviour in
gamma: the gamma = 1 curve sits
lowest, the very small gamma = 0.01 curve
is higher (the kernel is so smooth that it behaves almost like the
linear classifier), and the large gamma = 5 curve is
worst at every cost — a very local kernel
that overfits the training data. This is the classic U-shaped
flexibility trade-off, with the optimum at an intermediate
gamma.
svm.rad.best <- svm(mpg01 ~ ., data = Auto2, kernel = "radial",
cost = tune.rad$best.parameters$cost,
gamma = tune.rad$best.parameters$gamma)
par(mfrow = c(1, 2))
plot(Auto2$weight, Auto2$displacement, col = ifelse(Auto2$mpg01 == 1, "red", "blue"),
pch = 19, cex = 0.6, xlab = "weight", ylab = "displacement",
main = "True classes")
plot(Auto2$weight, Auto2$displacement,
col = ifelse(predict(svm.rad.best, Auto2) == 1, "red", "blue"),
pch = 19, cex = 0.6, xlab = "weight", ylab = "displacement",
main = "Radial SVM predictions")
par(mfrow = c(1, 1))
Plotting displacement against weight (two
of the most informative predictors) shows that high-mileage cars (red)
are light with small engines, and the tuned radial SVM reproduces that
separation almost perfectly.
OJ data setset.seed(1)
train <- sample(nrow(OJ), 800)
OJ.train <- OJ[train, ]
OJ.test <- OJ[-train, ]
err.rates <- function(fit) {
c(train = mean(predict(fit, OJ.train) != OJ.train$Purchase),
test = mean(predict(fit, OJ.test) != OJ.test$Purchase))
}
cost = 0.01svc <- svm(Purchase ~ ., data = OJ.train, kernel = "linear", cost = 0.01)
summary(svc)
##
## Call:
## svm(formula = Purchase ~ ., data = OJ.train, kernel = "linear", cost = 0.01)
##
##
## Parameters:
## SVM-Type: C-classification
## SVM-Kernel: linear
## cost: 0.01
##
## Number of Support Vectors: 435
##
## ( 219 216 )
##
##
## Number of Classes: 2
##
## Levels:
## CH MM
The fit uses 435 support vectors (219 in one class
and 216 in the other) — a large fraction of the 800 training
observations, which is expected with such a small cost (a
wide, soft margin).
round(err.rates(svc), 4)
## train test
## 0.1750 0.1778
costset.seed(1)
tune.lin <- tune(svm, Purchase ~ ., data = OJ.train, kernel = "linear",
ranges = list(cost = c(0.01, 0.05, 0.1, 0.5, 1, 5, 10)))
summary(tune.lin)
##
## Parameter tuning of 'svm':
##
## - sampling method: 10-fold cross validation
##
## - best parameters:
## cost
## 0.5
##
## - best performance: 0.16875
##
## - Detailed performance results:
## cost error dispersion
## 1 0.01 0.17625 0.02853482
## 2 0.05 0.17625 0.02853482
## 3 0.10 0.17250 0.03162278
## 4 0.50 0.16875 0.02651650
## 5 1.00 0.17500 0.02946278
## 6 5.00 0.17250 0.03162278
## 7 10.00 0.17375 0.03197764
lin.cost <- tune.lin$best.parameters$cost
lin.cost
## [1] 0.5
costsvc.best <- svm(Purchase ~ ., data = OJ.train, kernel = "linear", cost = lin.cost)
lin.err <- err.rates(svc.best)
round(lin.err, 4)
## train test
## 0.1650 0.1556
Tuning cost to 0.5 gives a modest but
real improvement over cost = 0.01: the test error falls
from 0.1778 to 0.1556 (about two percentage points), and the training
error drops as well.
gamma)svm.rad <- svm(Purchase ~ ., data = OJ.train, kernel = "radial")
c(support_vectors = svm.rad$tot.nSV)
## support_vectors
## 373
round(err.rates(svm.rad), 4)
## train test
## 0.1512 0.1852
set.seed(1)
tune.rad <- tune(svm, Purchase ~ ., data = OJ.train, kernel = "radial",
ranges = list(cost = c(0.01, 0.05, 0.1, 0.5, 1, 5, 10)))
rad.cost <- tune.rad$best.parameters$cost
svm.rad.best <- svm(Purchase ~ ., data = OJ.train, kernel = "radial", cost = rad.cost)
rad.err <- err.rates(svm.rad.best)
c(best_cost = rad.cost)
## best_cost
## 0.5
round(rad.err, 4)
## train test
## 0.1475 0.1778
degree = 2)svm.poly <- svm(Purchase ~ ., data = OJ.train, kernel = "polynomial", degree = 2)
c(support_vectors = svm.poly$tot.nSV)
## support_vectors
## 447
round(err.rates(svm.poly), 4)
## train test
## 0.1825 0.2222
set.seed(1)
tune.poly <- tune(svm, Purchase ~ ., data = OJ.train, kernel = "polynomial", degree = 2,
ranges = list(cost = c(0.01, 0.05, 0.1, 0.5, 1, 5, 10)))
poly.cost <- tune.poly$best.parameters$cost
svm.poly.best <- svm(Purchase ~ ., data = OJ.train, kernel = "polynomial",
degree = 2, cost = poly.cost)
poly.err <- err.rates(svm.poly.best)
c(best_cost = poly.cost)
## best_cost
## 10
round(poly.err, 4)
## train test
## 0.1500 0.1889
results <- data.frame(
kernel = c("Linear", "Radial", "Polynomial (deg 2)"),
best_cost = c(lin.cost, rad.cost, poly.cost),
train_error = round(c(lin.err["train"], rad.err["train"], poly.err["train"]), 4),
test_error = round(c(lin.err["test"], rad.err["test"], poly.err["test"]), 4)
)
results
## kernel best_cost train_error test_error
## 1 Linear 0.5 0.1650 0.1556
## 2 Radial 0.5 0.1475 0.1778
## 3 Polynomial (deg 2) 10.0 0.1500 0.1889
best <- results$kernel[which.min(results$test_error)]
best
## [1] "Linear"
Comparing the tuned models on the held-out test set, the Linear kernel gives the lowest test error rate (0.1556), with the radial and polynomial kernels about two and three percentage points behind respectively.
The most instructive feature of the table is that the training and test rankings are exactly reversed. By training error the flexible kernels look best (radial 0.1475 < polynomial 0.15 < linear 0.165), but by test error the ordering flips (linear 0.1556 < radial 0.1778 < polynomial 0.1889). The extra flexibility of the radial and polynomial kernels buys a better fit to the training data but does not generalise — a textbook illustration of overfitting, and precisely why model selection must rest on cross-validation or held-out data rather than training error. Since the simplest model also generalises best, the linear support vector classifier is the right choice for these data.