Máquinas de Vectores de Soporte

ISLR2 · Capítulo 9 — con R

Estadística Avanzada · Maestría en Ciencias de la Ingeniería

Universidad Iberoamericana CDMX

Antes de empezar

Ruta de la sesión

  1. Preliminares: hiperplanos y distancia de un punto a un hiperplano (álgebra lineal + cálculo)
  2. Clasificador de margen máximo (§9.1)
  3. Clasificador de vectores de soporte (§9.2)
  4. Máquinas de vectores de soporte: kernels (§9.3), aplicación a Heart
  5. Más de dos clases (§9.4) y relación con la regresión logística (§9.5)
  6. Laboratorio (§9.6): e1071, validación cruzada, curvas ROC, multiclase, expresión génica (Khan)
  7. Ejercicios

Nota

Casi todas las figuras del capítulo se reproducen con datos simulados (las semillas y el generador de números aleatorios no coinciden con los del libro, así que las figuras serán parecidas, no idénticas). El libro no publica el código de las figuras; aquí lo reconstruimos.

Paquetes: copia y pega esta diapositiva

# --- Instalar (solo los que falten) ------------------------------------
paquetes <- c("tidyverse",  # ggplot2, dplyr, tidyr, purrr, tibble, readr
              "e1071",      # svm(), tune()
              "ISLR2",      # datos: Khan, Auto, OJ ...
              "pROC",       # curvas ROC (reemplaza a ROCR del libro)
              "patchwork",  # combinar gráficas
              "MASS")       # lda()  (se usa siempre como MASS::lda)
faltan <- setdiff(paquetes, rownames(installed.packages()))
if (length(faltan) > 0) install.packages(faltan)

# --- Cargar -------------------------------------------------------------
library(tidyverse)
library(e1071)
library(ISLR2)
library(pROC)
library(patchwork)

theme_set(theme_minimal(base_size = 14))
set.seed(1)

Tip

MASS no se carga con library() a propósito: enmascara a dplyr::select(). Lo usamos como MASS::lda().

Funciones auxiliares (copiar y pegar después de los paquetes)

# Paleta: clase 1 = morado, clase 2 = azul, clase 3 = naranja
pal <- c("#C45BC4", "#2BA8E0", "#E8A33D")

# Puntaje de un SVM binario: > 0  <=>  segunda clase (levels[2]).
# (e1071 devuelve el signo contrario: positivo = primera clase)
score_svm <- function(fit, newdata) {
  dv <- attr(predict(fit, newdata, decision.values = TRUE), "decision.values")
  -as.numeric(dv[, 1])
}

# Coeficientes de un SVM LINEAL (solo si scale = FALSE):  f(x) = beta0 + beta'x
# e1071 no los entrega directamente; se reconstruyen desde los vectores de soporte.
coef_svm <- function(fit) {
  stopifnot(fit$kernel == 0, !any(fit$scaled))
  beta <- -colSums(fit$coefs[, 1] * fit$SV)   # signo: positivo = levels[2]
  list(beta0 = fit$rho, beta = beta, margen = 1 / sqrt(sum(beta^2)))
}

# Frontera de decisión (línea continua), margen (punteada) y vectores de soporte (círculos)
plot_svm <- function(fit, data, titulo = NULL, margenes = TRUE, n = 150) {
  lev <- levels(data$y)
  rejilla <- expand_grid(
    x1 = seq(min(data$x1) - .4, max(data$x1) + .4, length.out = n),
    x2 = seq(min(data$x2) - .4, max(data$x2) + .4, length.out = n))
  rejilla$pred <- predict(fit, rejilla)
  binario <- length(lev) == 2
  if (binario) rejilla$score <- score_svm(fit, rejilla)
  data$sv <- seq_len(nrow(data)) %in% fit$index

  p <- ggplot() +
    geom_raster(data = rejilla, aes(x1, x2, fill = pred), alpha = .15) +
    geom_point(data = data, aes(x1, x2, colour = y), size = 2) +
    geom_point(data = filter(data, sv), aes(x1, x2), shape = 1, size = 4.5) +
    scale_fill_manual(values = pal, guide = "none") +
    scale_colour_manual(values = pal) +
    coord_cartesian(expand = FALSE) +
    labs(title = titulo, colour = "clase", x = expression(X[1]), y = expression(X[2]))
  if (binario) {
    p <- p + geom_contour(data = rejilla, aes(x1, x2, z = score),
                          breaks = 0, colour = "black", linewidth = .8)
    if (margenes)
      p <- p + geom_contour(data = rejilla, aes(x1, x2, z = score),
                            breaks = c(-1, 1), colour = "black",
                            linetype = "dashed", linewidth = .4)
  }
  p
}

# Matriz de confusión como mapa de calor
plot_conf <- function(pred, real, titulo = NULL) {
  tibble(predicho = pred, real = real) |>
    count(predicho, real) |>
    ggplot(aes(real, predicho, fill = n)) +
    geom_tile(colour = "white") + geom_text(aes(label = n), size = 6) +
    scale_fill_gradient(low = "white", high = pal[2], guide = "none") +
    labs(title = titulo, x = "clase real", y = "clase predicha")
}

# Curvas ROC con pROC (lista nombrada de objetos roc)
plot_roc <- function(lst, titulo = NULL) {
  nombres <- imap_chr(lst, \(r, nm) sprintf("%s (AUC = %.3f)", nm, as.numeric(auc(r))))
  ggroc(setNames(lst, nombres), legacy.axes = TRUE, linewidth = 1) +
    geom_abline(linetype = 3) + coord_equal() +
    labs(title = titulo, x = "Tasa de falsos positivos",
         y = "Tasa de verdaderos positivos", colour = NULL) +
    theme(legend.position = "bottom", legend.direction = "vertical")
}

Preliminares: hiperplanos

¿Qué es un hiperplano?

En \(\mathbb{R}^p\), un hiperplano es un subespacio afín plano de dimensión \(p-1\):

\[ H=\{x\in\mathbb{R}^p:\ \beta_0+\beta_1x_1+\dots+\beta_px_p=0\}=\{x:\ \beta_0+\beta^\top x=0\} \]

  • \(p=2\): una recta (\(\beta_0+\beta_1X_1+\beta_2X_2=0\)) · \(p=3\): un plano · \(p>3\): no lo vemos, pero la idea es la misma.
  • “Afín” = no tiene que pasar por el origen (eso lo controla \(\beta_0\)).
  • El hiperplano divide el espacio en dos mitades: \(\beta_0+\beta^\top x>0\) de un lado y \(<0\) del otro.

Vector normal. Si \(x_a,x_b\in H\), entonces \(\beta^\top(x_a-x_b)=0\): \(\;\beta\) es ortogonal a cualquier dirección contenida en \(H\). Es el vector normal a \(H\).

Figura 9.1: el hiperplano \(1+2X_1+3X_2=0\)

rejilla <- expand_grid(x1 = seq(-1.5, 1.5, length.out = 45),
                       x2 = seq(-1.5, 1.5, length.out = 45)) |>
  mutate(lado = if_else(1 + 2*x1 + 3*x2 > 0, "> 0", "< 0"))

ggplot(rejilla, aes(x1, x2, colour = lado)) +
  geom_point(size = .8) +
  geom_abline(intercept = -1/3, slope = -2/3, linewidth = 1) +   # X2 = -(1 + 2 X1)/3
  scale_colour_manual(values = c("> 0" = pal[2], "< 0" = pal[1])) +
  coord_fixed(xlim = c(-1.5, 1.5), ylim = c(-1.5, 1.5), expand = FALSE) +
  labs(colour = expression(1 + 2*X[1] + 3*X[2]), x = expression(X[1]), y = expression(X[2]))

Figura 1

Distancia de un punto a un hiperplano: geometría

Sea \(x_0\) un punto cualquiera y \(x_1\in H\) (por tanto \(\beta^\top x_1=-\beta_0\)).

La distancia es la longitud de la proyección de \(x_0-x_1\) sobre la dirección normal unitaria \(\beta/\|\beta\|\):

\[ d(x_0,H)=\frac{|\beta^\top(x_0-x_1)|}{\|\beta\|}=\frac{|\beta^\top x_0-\beta^\top x_1|}{\|\beta\|} =\boxed{\frac{|\beta_0+\beta^\top x_0|}{\|\beta\|}} \]

Sin el valor absoluto obtenemos la distancia con signo:

\[ \frac{\beta_0+\beta^\top x_0}{\|\beta\|}\; \begin{cases}>0 & \text{lado "positivo"}\\ <0 & \text{lado "negativo"}\end{cases} \]

Importante

Si \(\|\beta\|=1\) (restricción (9.10) del libro), \(f(x_0)=\beta_0+\beta^\top x_0\) es directamente la distancia con signo. Por eso en (9.11) la cantidad \(y_i f(x_i)\) es la distancia correcta de la observación \(i\) al hiperplano.

Distancia de un punto a un hiperplano: cálculo (Lagrange)

Queremos el punto \(x^*\in H\) más cercano a \(x_0\):

\[ \min_x \tfrac12\|x-x_0\|^2\quad\text{s.a.}\quad \beta_0+\beta^\top x=0 \]

Lagrangiano \(\;\mathcal L(x,\lambda)=\tfrac12\|x-x_0\|^2-\lambda(\beta_0+\beta^\top x)\).

  • \(\nabla_x\mathcal L=x-x_0-\lambda\beta=0\;\Rightarrow\;x^*=x_0+\lambda\beta\) (¡el pie de la perpendicular va en dirección de \(\beta\)!)
  • Sustituyendo en la restricción: \(\beta_0+\beta^\top x_0+\lambda\|\beta\|^2=0\;\Rightarrow\;\lambda=-\dfrac{\beta_0+\beta^\top x_0}{\|\beta\|^2}\)

\[ x^*=x_0-\frac{\beta_0+\beta^\top x_0}{\|\beta\|^2}\,\beta,\qquad d=\|x^*-x_0\|=|\lambda|\,\|\beta\|=\frac{|\beta_0+\beta^\top x_0|}{\|\beta\|}\ \checkmark \]

En R: distancia, pie de la perpendicular y verificación numérica

# Distancia con signo y pie de la perpendicular
dist_h <- function(x0, beta0, beta) (beta0 + sum(beta * x0)) / sqrt(sum(beta^2))
pie_h  <- function(x0, beta0, beta) x0 - (beta0 + sum(beta * x0)) / sum(beta^2) * beta

b0 <- 1; b <- c(2, 3)          # el hiperplano de la Figura 9.1
P  <- c(1, 1)                  # un punto
Q  <- pie_h(P, b0, b)

c(distancia_con_signo = dist_h(P, b0, b),
  a_mano              = 6 / sqrt(13),                # |1 + 2 + 3| / sqrt(4 + 9)
  norma_de_P_menos_Q  = sqrt(sum((P - Q)^2)),
  Q_en_el_hiperplano  = b0 + sum(b * Q))             # debe ser 0
distancia_con_signo              a_mano  norma_de_P_menos_Q  Q_en_el_hiperplano 
           1.664101            1.664101            1.664101            0.000000 
# Verificación "de cálculo": minimizar la distancia sobre la recta, a ciegas
sobre_H <- \(t) c(t, -(b0 + b[1] * t) / b[2])        # parametrización de H
optimize(\(t) sum((sobre_H(t) - P)^2), c(-5, 5))$objective |> sqrt()
[1] 1.664101

Visualización: \(P\), su proyección \(Q\) y el vector normal

ggplot() +
  geom_abline(intercept = -b0 / b[2], slope = -b[1] / b[2], linewidth = 1) +
  geom_segment(aes(x = P[1], y = P[2], xend = Q[1], yend = Q[2]),
               colour = pal[3], linewidth = 1.2) +
  geom_segment(aes(x = Q[1], y = Q[2], xend = Q[1] + .5 * b[1] / sqrt(sum(b^2)),
                   yend = Q[2] + .5 * b[2] / sqrt(sum(b^2))),
               arrow = arrow(length = unit(.2, "cm")), colour = pal[2], linewidth = 1) +
  geom_point(aes(c(P[1], Q[1]), c(P[2], Q[2])), size = 3) +
  annotate("text", x = P[1] + .1, y = P[2] + .15, label = "P", size = 6) +
  annotate("text", x = Q[1] - .15, y = Q[2] - .15, label = "Q", size = 6) +
  annotate("text", x = (P[1] + Q[1]) / 2 + .35, y = (P[2] + Q[2]) / 2,
           label = sprintf("d = %.3f", dist_h(P, b0, b)), colour = pal[3], size = 5) +
  annotate("text", x = Q[1] + .55, y = Q[2] + .15, label = "β", colour = pal[2], size = 6) +
  coord_fixed(xlim = c(-1.5, 2), ylim = c(-1.5, 2)) +
  labs(x = expression(X[1]), y = expression(X[2]),
       title = expression("Hiperplano  1 + 2X"[1] * " + 3X"[2] * " = 0"))

Figura 2

Mini-ejercicio (ejercicio conceptual 2 del libro)

Frontera no lineal \((1+X_1)^2+(2-X_2)^2=4\) (un círculo). Regla: azul si \((1+X_1)^2+(2-X_2)^2>4\), roja en otro caso.

puntos <- tribble(~x1, ~x2,
                   0,    0,
                  -1,    1,
                   2,    2,
                   3,    8) |>
  mutate(valor = (1 + x1)^2 + (2 - x2)^2,
         clase = if_else(valor > 4, "azul", "roja"))
puntos
# A tibble: 4 × 4
     x1    x2 valor clase
  <dbl> <dbl> <dbl> <chr>
1     0     0     5 azul 
2    -1     1     1 roja 
3     2     2     9 azul 
4     3     8    52 azul 
circulo <- tibble(t = seq(0, 2*pi, length.out = 200))
ggplot() +
  geom_path(data = circulo, aes(-1 + 2*cos(t), 2 + 2*sin(t))) +
  geom_point(data = puntos, aes(x1, x2, colour = clase), size = 4) +
  scale_colour_manual(values = c(azul = pal[2], roja = "firebrick")) + coord_fixed()
  1. Expandiendo: \(\;1+2X_1-4X_2+X_1^2+X_2^2>0\). No es lineal en \((X_1,X_2)\) pero sí es lineal en \((X_1,X_1^2,X_2,X_2^2)\): esta es la idea central de la sección 9.3.

Clasificador de margen máximo (§9.1)

Idea

Datos \(x_i\in\mathbb{R}^p\), etiquetas \(y_i\in\{-1,+1\}\). Un hiperplano separador cumple

\[ y_i(\beta_0+\beta^\top x_i)>0\qquad\forall i \]

Si existe uno, existen infinitos. ¿Cuál elegimos?

El de margen máximo: el que está más lejos de las observaciones de entrenamiento.

  • Margen = menor distancia (perpendicular) de una observación al hiperplano.
  • Se clasifica una observación nueva por el signo de \(f(x^*)=\hat\beta_0+\hat\beta^\top x^*\), y \(|f(x^*)|\) grande indica confianza.

Datos separables (simulación)

# Simula dos nubes y repite con otra semilla hasta que sean separables
sim_separable <- function(seed = 1, n = 20, shift = c(2.5, -2.5)) {
  repeat {
    set.seed(seed)
    x <- matrix(rnorm(n * 2), ncol = 2)
    y <- rep(c(-1, 1), each = n / 2)
    x[y == 1, ] <- sweep(x[y == 1, ], 2, shift, "+")
    d <- tibble(x1 = x[, 1], x2 = x[, 2], y = factor(y))
    ajuste <- svm(y ~ ., data = d, kernel = "linear", cost = 1e5, scale = FALSE)
    if (all(fitted(ajuste) == d$y)) return(d)
    seed <- seed + 1
  }
}
d_sep <- sim_separable()
mm    <- svm(y ~ ., data = d_sep, kernel = "linear", cost = 1e5, scale = FALSE)
cf    <- coef_svm(mm)
cf
$beta0
[1] -78.93054

$beta
       x1        x2 
 11.41081 -30.02369 

$margen
[1] 0.03113424

Figura 9.2: muchos hiperplanos separadores

Rotamos el hiperplano de margen máximo alrededor de un punto central y conservamos solo los que siguen separando.

yy <- if_else(d_sep$y == levels(d_sep$y)[2], 1, -1)
X  <- as.matrix(d_sep[c("x1", "x2")])
c0 <- pie_h(colMeans(X), cf$beta0, cf$beta)          # centro de la "brecha", sobre H
rot <- \(th, v) c(cos(th) * v[1] - sin(th) * v[2], sin(th) * v[1] + cos(th) * v[2])

alt <- tibble(grados = seq(-60, 60, by = 5)) |>
  mutate(beta  = map(grados * pi / 180, rot, v = cf$beta),
         beta0 = map_dbl(beta, \(b) -sum(b * c0)),
         b1 = map_dbl(beta, 1), b2 = map_dbl(beta, 2),
         separa = map2_lgl(beta, beta0, \(b, b0) all(yy * (b0 + X %*% b) > 0)))

sep    <- filter(alt, separa)
lineas <- bind_rows(slice(sep, c(1, n())), filter(alt, grados == 0))

p_izq <- ggplot(d_sep, aes(x1, x2, colour = y)) +
  geom_abline(data = lineas, aes(intercept = -beta0 / b2, slope = -b1 / b2)) +
  geom_point(size = 2.5) + scale_colour_manual(values = pal) +
  coord_cartesian(xlim = range(d_sep$x1), ylim = range(d_sep$x2)) +
  labs(title = "Tres hiperplanos separadores")
p_der <- plot_svm(mm, d_sep, "Uno de ellos define la regla", margenes = FALSE)
p_izq + p_der + plot_layout(guides = "collect")

Figura 3

Figura 9.3: el hiperplano de margen máximo

plot_svm(mm, d_sep, sprintf("Margen máximo M = %.3f; %d vectores de soporte", cf$margen, nrow(mm$SV)))

Figura 4

  • Los vectores de soporte (círculos) están sobre las líneas punteadas, a distancia exacta \(M\).
  • Mover cualquier otra observación (sin cruzar el margen) no cambia el hiperplano.

Construcción: el problema de optimización

\[ \max_{\beta_0,\beta,M}\ M\quad\text{s.a.}\quad \sum_{j}\beta_j^2=1,\qquad y_i(\beta_0+\beta^\top x_i)\ge M\ \ \forall i \tag{9.9–9.11} \]

  • Como \(\|\beta\|=1\), \(y_i f(x_i)\) es la distancia (ver el resultado de hace unas diapositivas): todas las observaciones están del lado correcto y a distancia \(\ge M\).

Forma equivalente (la que usan los solvers; \(\tilde\beta=\beta/M\), de modo que el margen queda en \(f=\pm1\)):

\[ \min_{\tilde\beta_0,\tilde\beta}\ \tfrac12\|\tilde\beta\|^2\quad\text{s.a.}\quad y_i(\tilde\beta_0+\tilde\beta^\top x_i)\ge1 \qquad\Rightarrow\qquad M=\frac1{\|\tilde\beta\|} \]

Es un problema cuadrático convexo: maximizar el margen = minimizar la norma. De aquí salen margen = 1/sqrt(sum(beta^2)) en coef_svm() y las líneas punteadas en \(f=\pm1\).

Figura 9.5: el margen máximo es sensible a una sola observación

# Agregamos un punto de la clase 2 en el centro de la brecha
p_nuevo <- c0 + 0.1 * cf$beta / sqrt(sum(cf$beta^2))
d_nuevo <- bind_rows(d_sep, tibble(x1 = p_nuevo[1], x2 = p_nuevo[2],
                                   y = factor(levels(d_sep$y)[2], levels = levels(d_sep$y))))
mm2 <- svm(y ~ ., data = d_nuevo, kernel = "linear", cost = 1e5, scale = FALSE)

plot_svm(mm,  d_sep,   sprintf("Sin el punto: M = %.2f", cf$margen), margenes = FALSE) +
plot_svm(mm2, d_nuevo, sprintf("Con el punto: M = %.2f", coef_svm(mm2)$margen), margenes = FALSE) +
  plot_layout(guides = "collect")

Figura 5

Un solo punto cambia drásticamente la frontera y casi anula el margen: riesgo de sobreajuste y de no existir solución si las clases no son separables.

Clasificador de vectores de soporte (§9.2)

Cuando no hay hiperplano separador (Figura 9.4)

set.seed(2)
d_sol <- tibble(x1 = rnorm(40), x2 = rnorm(40), y = factor(rep(c(-1, 1), each = 20))) |>
  mutate(x1 = x1 + 1.2 * (y == "1"), x2 = x2 - 1.2 * (y == "1"))

ggplot(d_sol, aes(x1, x2, colour = y)) + geom_point(size = 3) +
  scale_colour_manual(values = pal) + coord_fixed()

Ya no existe \(M>0\). Incluso si existiera, un clasificador de margen duro puede ser frágil. Solución: margen suave (soft margin).

El problema del clasificador de vectores de soporte

\[ \begin{aligned} \max_{\beta_0,\beta,\epsilon,M}\ & M \quad\text{s.a.}\quad \|\beta\|^2=1,\\ & y_i(\beta_0+\beta^\top x_i)\ge M(1-\epsilon_i),\qquad \epsilon_i\ge0,\qquad \sum_i\epsilon_i\le C \end{aligned}\tag{9.12–9.15} \]

Holgura Ubicación de la observación \(i\)
\(\epsilon_i=0\) lado correcto del margen
\(0<\epsilon_i\le1\) viola el margen, pero sigue del lado correcto del hiperplano
\(\epsilon_i>1\) lado equivocado del hiperplano (mal clasificada)
  • \(C\) es un presupuesto de violaciones. \(C=0\): margen máximo (si existe). Más \(C\): margen más ancho, más vectores de soporte, más sesgo, menos varianza.
  • Solo las observaciones sobre o dentro del margen (los vectores de soporte) influyen en el ajuste.

Holguras en R

Con el margen normalizado a \(f=\pm1\), la holgura es \(\epsilon_i=\max(0,\,1-y_if(x_i))\).

aj <- svm(y ~ ., data = d_sol, kernel = "linear", cost = 1, scale = FALSE)

tabla <- d_sol |>
  mutate(f        = score_svm(aj, d_sol),
         yy       = if_else(y == "1", 1, -1),
         holgura  = pmax(0, 1 - yy * f),
         ubicacion = case_when(
           holgura > 1    ~ "3. lado equivocado del hiperplano (ε > 1)",
           holgura > 1e-6 ~ "2. viola el margen (0 < ε ≤ 1)",
           TRUE           ~ "1. lado correcto del margen (ε = 0)"))

count(tabla, ubicacion)
# A tibble: 3 × 2
  ubicacion                                     n
  <chr>                                     <int>
1 1. lado correcto del margen (ε = 0)          15
2 2. viola el margen (0 < ε ≤ 1)               16
3 3. lado equivocado del hiperplano (ε > 1)     9
c(suma_holguras = sum(tabla$holgura), n_vectores_soporte = length(aj$index))
     suma_holguras n_vectores_soporte 
          22.20563           25.00000 

Los vectores de soporte incluyen a los de \(\epsilon>0\) y a los que caen exactamente sobre el margen (\(\epsilon=0\)).

Figura 9.7: el efecto del parámetro de ajuste

costos <- c(0.01, 0.1, 1, 100)
graficas <- map(costos, \(k) {
  aj <- svm(y ~ ., data = d_sol, kernel = "linear", cost = k, scale = FALSE)
  plot_svm(aj, d_sol, sprintf("cost = %g  ·  %d vectores de soporte", k, nrow(aj$SV)))
})
wrap_plots(graficas, ncol = 2, guides = "collect")

Figura 6

¡Ojo! cost en e1071 no es el \(C\) del libro

svm() resuelve (con el margen normalizado a 1):

\[ \min_{\beta_0,\beta,\epsilon}\ \tfrac12\|\beta\|^2+\texttt{cost}\sum_i\epsilon_i\quad\text{s.a. }\ y_i(\beta_0+\beta^\top x_i)\ge1-\epsilon_i,\ \epsilon_i\ge0 \]

  • cost es una penalización por violar el margen; \(C\) del libro es un presupuesto: van en sentido contrario.
    • cost pequeño \(\Leftrightarrow\) \(C\) grande \(\Rightarrow\) margen ancho, muchos vectores de soporte.
    • cost grande \(\Leftrightarrow\) \(C\) pequeño \(\Rightarrow\) margen angosto, pocos vectores de soporte.
  • Equivale a la forma “pérdida + penalización” (9.25) con \(\lambda=1/(2\,\texttt{cost})\).
  • Se elige por validación cruzada (lo haremos en el laboratorio).

Máquinas de vectores de soporte (§9.3)

Fronteras no lineales: el clasificador lineal falla (Figura 9.8)

set.seed(1)
d_nl <- bind_rows(
  tibble(x1 = rnorm(50, 0.5, 0.9), x2 = rnorm(50, -0.2, 0.9), y = "A"),
  tibble(x1 = rnorm(25, 2.8, 1),   x2 = rnorm(25, 2.8, 1),    y = "B"),
  tibble(x1 = rnorm(25, -3, 1),    x2 = rnorm(25, -3, 1),     y = "B")) |>
  mutate(y = factor(y))

svc_lin <- svm(y ~ ., data = d_nl, kernel = "linear", cost = 1, scale = FALSE)

p1 <- ggplot(d_nl, aes(x1, x2, colour = y)) + geom_point(size = 2) +
  scale_colour_manual(values = pal) + labs(title = "Datos")
p2 <- plot_svm(svc_lin, d_nl, "Clasificador de vectores de soporte (lineal)")
p1 + p2 + plot_layout(guides = "collect")

Figura 7

Solución 1: ampliar el espacio de variables

Igual que en el Capítulo 7: en lugar de \((X_1,X_2)\) usamos \((X_1,X_1^2,X_2,X_2^2,X_1X_2)\). El clasificador es lineal en el espacio ampliado pero cuadrático en el original (ecuación 9.16).

svc_cuad <- svm(y ~ x1 + x2 + I(x1^2) + I(x2^2) + I(x1 * x2), data = d_nl,
                kernel = "linear", cost = 1)
plot_svm(svc_cuad, d_nl, "SVC lineal en (X1, X2, X1², X2², X1·X2)")
mean(predict(svc_cuad, d_nl) == d_nl$y)    # exactitud de entrenamiento
[1] 0.99

Problema: con muchas variables y grados altos, el espacio ampliado se vuelve enorme. Los kernels resuelven esto con cálculo eficiente.

Solución 2: kernels

La solución del SVC solo depende de productos internos \(\langle x_i,x_{i'}\rangle=\sum_j x_{ij}x_{i'j}\):

\[ f(x)=\beta_0+\sum_{i\in\mathcal S}\alpha_i\langle x,x_i\rangle\ \ \longrightarrow\ \ f(x)=\beta_0+\sum_{i\in\mathcal S}\alpha_i\,K(x,x_i)\tag{9.23} \]

donde \(\mathcal S\) = índices de los vectores de soporte (\(\alpha_i=0\) para el resto) y \(K\) es un kernel (medida de similitud).

Kernel Fórmula Argumento en e1071
Lineal \(\sum_j x_{ij}x_{i'j}\) kernel = "linear"
Polinomial \(\bigl(1+\sum_j x_{ij}x_{i'j}\bigr)^d\) kernel = "polynomial", degree = d, gamma = 1, coef0 = 1
Radial \(\exp\bigl(-\gamma\sum_j(x_{ij}-x_{i'j})^2\bigr)\) kernel = "radial", gamma = γ

Advertencia

e1071 define el polinomial como \((\gamma\,u^\top v+\texttt{coef0})^d\) con gamma = 1/p y coef0 = 0 por defecto. Para obtener la fórmula (9.22) del libro hay que fijar gamma = 1, coef0 = 1.

Un kernel es un producto interno en un espacio ampliado

Para \(p=2\) y \(d=2\): \(K(a,b)=(1+a^\top b)^2=\langle\varphi(a),\varphi(b)\rangle\) con

\[ \varphi(x)=\bigl(1,\ \sqrt2x_1,\ \sqrt2x_2,\ x_1^2,\ x_2^2,\ \sqrt2x_1x_2\bigr) \]

phi <- \(x) c(1, sqrt(2) * x[1], sqrt(2) * x[2], x[1]^2, x[2]^2, sqrt(2) * x[1] * x[2])
a <- c(1, 2); b <- c(-0.5, 3)
c(kernel = (1 + sum(a * b))^2, producto_en_espacio_ampliado = sum(phi(a) * phi(b)))
                      kernel producto_en_espacio_ampliado 
                       42.25                        42.25 

Ventaja del kernel: se calcula \(K(x_i,x_{i'})\) para los \(\binom n2\) pares sin construir \(\varphi\). Para el kernel radial, \(\varphi\) es de dimensión infinita y no podríamos hacerlo de otra forma.

El kernel radial es local

\(K(x,z)=\exp(-\gamma\|x-z\|^2)\) decae rápido con la distancia: solo los vectores de soporte cercanos a \(x\) influyen en su clasificación. \(\gamma\) grande \(\Rightarrow\) más local \(\Rightarrow\) frontera más flexible.

expand_grid(dist = seq(0, 3, length.out = 200), gamma = c(0.1, 0.5, 1, 5)) |>
  mutate(K = exp(-gamma * dist^2), gamma = factor(gamma)) |>
  ggplot(aes(dist, K, colour = gamma)) + geom_line(linewidth = 1.1) +
  labs(x = expression("||x - z||"), y = "K(x, z)", colour = expression(gamma))

Figura 9.9: polinomial (d = 3) vs. radial

svm_pol <- svm(y ~ ., data = d_nl, kernel = "polynomial", degree = 3,
               gamma = 1, coef0 = 1, cost = 1)
svm_rad <- svm(y ~ ., data = d_nl, kernel = "radial", gamma = 0.5, cost = 1)

plot_svm(svm_pol, d_nl, "Kernel polinomial, d = 3") +
plot_svm(svm_rad, d_nl, "Kernel radial, γ = 0.5") + plot_layout(guides = "collect")

Figura 8

Abriendo la caja: reconstruir \(f(x)\) a mano

aj_rad <- svm(y ~ ., data = d_nl, kernel = "radial", gamma = 0.5, cost = 1, scale = FALSE)

K <- \(a, b) exp(-0.5 * sum((a - b)^2))                      # kernel radial con γ = 0.5
f_mano <- \(x) sum(aj_rad$coefs[, 1] * apply(aj_rad$SV, 1, K, b = x)) - aj_rad$rho

nuevos <- as.matrix(d_nl[1:6, c("x1", "x2")])
cbind(a_mano = apply(nuevos, 1, f_mano),
      e1071  = attr(predict(aj_rad, d_nl[1:6, ], decision.values = TRUE), "decision.values")[, 1])
     a_mano     e1071
1 1.0941900 1.0941900
2 1.2317002 1.2317002
3 1.0647422 1.0647422
4 1.0001131 1.0001131
5 1.4740254 1.4740254
6 0.9999084 0.9999084

fit$coefs son los \(\alpha_iy_i\) (solo para los vectores de soporte, fit$SV) y fit$rho es \(-\beta_0\). Las dos columnas deben coincidir (e1071 usa el signo “positivo = primera clase”).

Aplicación: datos Heart (SVM vs. LDA)

297 pacientes, 13 predictores, respuesta AHD (enfermedad cardíaca: Yes/No). Los datos no vienen en ISLR2; se descargan del sitio del libro.

heart <- read_csv("https://www.statlearning.com/s/Heart.csv", show_col_types = FALSE) |>
  select(-1) |>                              # primera columna = índice
  drop_na() |>                               # quita los 6 casos con NA (Ca, Thal)
  mutate(across(where(is.character), as.factor))
dim(heart)
[1] 297  14
set.seed(1)
idx      <- sample(nrow(heart), 207)         # 207 entrenamiento / 90 prueba, como en el libro
heart_tr <- heart[idx, ]
heart_te <- heart[-idx, ]

lda_fit <- MASS::lda(AHD ~ ., data = heart_tr)
svc     <- svm(AHD ~ ., data = heart_tr, kernel = "linear", cost = 1)
svm_g   <- map(c(`γ = 1e-3` = 1e-3, `γ = 1e-2` = 1e-2, `γ = 1e-1` = 1e-1),
               \(g) svm(AHD ~ ., data = heart_tr, kernel = "radial", gamma = g, cost = 1))

Curvas ROC (Figuras 9.10 y 9.11)

roc_de <- \(score, datos) roc(datos$AHD, score, levels = c("No", "Yes"),
                              direction = "<", quiet = TRUE)

rocs <- \(datos) list(
  izq = list(SVC = roc_de(score_svm(svc, datos), datos),
             LDA = roc_de(predict(lda_fit, datos)$posterior[, "Yes"], datos)),
  der = c(list(SVC = roc_de(score_svm(svc, datos), datos)),
          map(svm_g, \(m) roc_de(score_svm(m, datos), datos))))

tr <- rocs(heart_tr); te <- rocs(heart_te)
(plot_roc(tr$izq, "Entrenamiento: SVC vs. LDA") + plot_roc(tr$der, "Entrenamiento: kernel radial")) /
(plot_roc(te$izq, "Prueba: SVC vs. LDA")        + plot_roc(te$der, "Prueba: kernel radial"))

Lección: con \(\gamma=0.1\) el AUC de entrenamiento es casi perfecto, pero en prueba es el peor: más flexibilidad \(\neq\) mejor desempeño fuera de muestra.

Más de dos clases (§9.4)

Uno contra uno y uno contra todos

El hiperplano separador es intrínsecamente binario. Para \(K>2\) clases:

Uno contra uno (one-versus-one)

  • Se ajustan \(\binom K2\) SVMs, uno por cada par de clases.
  • Cada clasificador “vota”; se asigna la clase con más votos.
  • Es lo que hace e1071::svm() automáticamente.

Uno contra todos (one-versus-all)

  • Se ajustan \(K\) SVMs: la clase \(k\) (+1) contra el resto (−1).
  • Se asigna la clase con mayor \(f_k(x^*)=\beta_{0k}+\beta_k^\top x^*\).
  • Se implementa a mano (ver laboratorio, §9.6.4).

Relación con la regresión logística (§9.5)

El SVC como “pérdida + penalización”

El problema (9.12)–(9.15) es equivalente a

\[ \min_{\beta_0,\dots,\beta_p}\ \Bigl\{\underbrace{\sum_{i=1}^n\max\bigl[0,\,1-y_if(x_i)\bigr]}_{\text{pérdida bisagra}}+\lambda\sum_{j=1}^p\beta_j^2\Bigr\}\tag{9.25} \]

Es la misma estructura “pérdida + penalización” de ridge/lasso (Cap. 6). La regresión logística usa la pérdida \(\log(1+e^{-y_if(x_i)})\).

Figura 9.12: pérdida bisagra vs. pérdida logística

tibble(z = seq(-7, 3, length.out = 400),
       `Pérdida SVM (bisagra)` = pmax(0, 1 - z),
       `Pérdida logística`     = log(1 + exp(-z))) |>
  pivot_longer(-z, names_to = "pérdida", values_to = "valor") |>
  ggplot(aes(z, valor, colour = pérdida)) +
  geom_line(linewidth = 1.2) +
  geom_vline(xintercept = 1, linetype = 3) +
  scale_colour_manual(values = c(pal[2], pal[1])) +
  labs(x = expression(y[i] * f(x[i])), y = "Pérdida", colour = NULL) +
  theme(legend.position = "top")

Figura 9

  • La bisagra es exactamente 0 si \(y_if(x_i)\ge1\) (lado correcto del margen): por eso solo los vectores de soporte importan.
  • La logística nunca es 0, pero es minúscula lejos de la frontera. Resultados parecidos; SVM suele ir mejor con clases bien separadas y la logística con clases muy solapadas.

En datos: SVC lineal vs. regresión logística

aj_svc <- svm(y ~ ., data = d_sol, kernel = "linear", cost = 1, scale = FALSE)
aj_glm <- glm(y ~ x1 + x2, data = d_sol, family = binomial)

cs <- coef_svm(aj_svc); cg <- coef(aj_glm)
fronteras <- tibble(modelo     = c("SVC (cost = 1)", "Logística"),
                    intercepto = c(-cs$beta0 / cs$beta[2], -cg[[1]] / cg[[3]]),
                    pendiente  = c(-cs$beta[1] / cs$beta[2], -cg[[2]] / cg[[3]]))

ggplot() +
  geom_point(data = d_sol, aes(x1, x2, colour = y), size = 2.5) +
  geom_abline(data = fronteras, aes(intercept = intercepto, slope = pendiente, linetype = modelo),
              linewidth = 1) +
  scale_colour_manual(values = pal) + coord_fixed()

Extensión: regresión con vectores de soporte (SVR)

Con respuesta numérica, svm() hace support vector regression: solo los residuos mayores que \(\varepsilon\) aportan a la pérdida (un “margen” alrededor de la curva).

set.seed(1)
d_r <- tibble(x = sort(runif(100, 0, 10)), y = sin(x) + rnorm(100, 0, 0.3))
svr <- svm(y ~ x, data = d_r, kernel = "radial", cost = 10, epsilon = 0.2)

d_r |> mutate(ajuste = predict(svr)) |>
  ggplot(aes(x, y)) + geom_point(alpha = .6) +
  geom_line(aes(y = ajuste), colour = pal[2], linewidth = 1.2) +
  geom_line(aes(y = ajuste + 0.2), colour = pal[2], linetype = "dashed") +
  geom_line(aes(y = ajuste - 0.2), colour = pal[2], linetype = "dashed")

Laboratorio §9.6

Laboratorio: qué cambia respecto al libro

Libro Aquí
data.frame(x = x, y = as.factor(y)) → columnas x.1, x.2 tibble(x1, x2, y = factor(y)), para poder usar plot_svm()
plot(svmfit, dat) (gráfica base, ejes intercambiados) plot_svm() con ggplot2: frontera, margen y vectores de soporte
summary(tune.out) solo en texto Además, gráfica de error de CV vs. parámetros
table(pred, truth) Tabla y mapa de calor (plot_conf())
ROCR (prediction, performance) pROC (roc, ggroc, auc)
decision.values = TRUE y signo invertido a mano score_svm() ya devuelve el puntaje con la convención correcta

Nota

Se conservan las semillas y la estructura del libro, pero los resultados exactos pueden diferir ligeramente según la versión de R (sample() cambió en R 3.6) y de e1071.

9.6.1 Clasificador de vectores de soporte: datos

set.seed(1)
x <- matrix(rnorm(20 * 2), ncol = 2)
y <- c(rep(-1, 10), rep(1, 10))
x[y == 1, ] <- x[y == 1, ] + 1
dat <- tibble(x1 = x[, 1], x2 = x[, 2], y = factor(y))

ggplot(dat, aes(x1, x2, colour = y)) + geom_point(size = 3) +
  scale_colour_manual(values = pal) + coord_fixed()

No son linealmente separables. Ajustamos un SVC con kernel = "linear" (scale = FALSE para no reescalar las variables).

9.6.1 Ajuste con cost = 10

svmfit <- svm(y ~ ., data = dat, kernel = "linear", cost = 10, scale = FALSE)
plot_svm(svmfit, dat, "cost = 10")
svmfit$index          # índices de los vectores de soporte
[1]  1  2  5  7 14 16 17
summary(svmfit)

Call:
svm(formula = y ~ ., data = dat, kernel = "linear", cost = 10, scale = FALSE)


Parameters:
   SVM-Type:  C-classification 
 SVM-Kernel:  linear 
       cost:  10 

Number of Support Vectors:  7

 ( 4 3 )


Number of Classes:  2 

Levels: 
 -1 1

9.6.1 Con cost = 0.1: margen más ancho

svmfit01 <- svm(y ~ ., data = dat, kernel = "linear", cost = 0.1, scale = FALSE)
plot_svm(svmfit01, dat, "cost = 0.1")
svmfit01$index
 [1]  1  2  3  4  5  7  9 10 12 13 14 15 16 17 18 20

El libro dice que svm() no entrega los coeficientes ni el margen. Con nuestro coef_svm() sí:

coef_svm(svmfit)
$beta0
[1] -1.506707

$beta
      x1       x2 
1.555547 1.611534 

$margen
[1] 0.4464656
coef_svm(svmfit01)
$beta0
[1] -0.6473973

$beta
       x1        x2 
0.4676136 0.5116151 

$margen
[1] 1.442756

9.6.1 Validación cruzada con tune()

set.seed(1)
tune_out <- tune(svm, y ~ ., data = dat, kernel = "linear",
                 ranges = list(cost = c(0.001, 0.01, 0.1, 1, 5, 10, 100)))
summary(tune_out)

Parameter tuning of 'svm':

- sampling method: 10-fold cross validation 

- best parameters:
 cost
  0.1

- best performance: 0.05 

- Detailed performance results:
   cost error dispersion
1 1e-03  0.55  0.4377975
2 1e-02  0.55  0.4377975
3 1e-01  0.05  0.1581139
4 1e+00  0.15  0.2415229
5 5e+00  0.15  0.2415229
6 1e+01  0.15  0.2415229
7 1e+02  0.15  0.2415229
tune_out$performances |>
  ggplot(aes(cost, error)) +
  geom_line() + geom_point(size = 3) +
  geom_errorbar(aes(ymin = pmax(0, error - dispersion), ymax = error + dispersion), width = .1, alpha = .4) +
  scale_x_log10() +
  labs(x = "cost (escala log)", y = "Error de validación cruzada (10 pliegues)")

9.6.1 Predicción sobre datos de prueba

mejor <- tune_out$best.model

xtest <- matrix(rnorm(20 * 2), ncol = 2)
ytest <- sample(c(-1, 1), 20, replace = TRUE)
xtest[ytest == 1, ] <- xtest[ytest == 1, ] + 1
testdat <- tibble(x1 = xtest[, 1], x2 = xtest[, 2], y = factor(ytest))

ypred <- predict(mejor, testdat)
table(predicho = ypred, real = testdat$y)
        real
predicho -1 1
      -1  9 1
      1   2 8
mean(ypred == testdat$y)          # exactitud
[1] 0.85
# ¿y con cost = 0.01?
ypred_001 <- predict(svm(y ~ ., data = dat, kernel = "linear", cost = 0.01, scale = FALSE), testdat)
table(predicho = ypred_001, real = testdat$y)
        real
predicho -1  1
      -1 11  6
      1   0  3

9.6.1 Caso (apenas) separable

x[y == 1, ] <- x[y == 1, ] + 0.5
dat_s <- tibble(x1 = x[, 1], x2 = x[, 2], y = factor(y))

fit_1e5 <- svm(y ~ ., data = dat_s, kernel = "linear", cost = 1e5, scale = FALSE)
fit_1   <- svm(y ~ ., data = dat_s, kernel = "linear", cost = 1,   scale = FALSE)

plot_svm(fit_1e5, dat_s, sprintf("cost = 1e5: %d SV, margen = %.2f", nrow(fit_1e5$SV), coef_svm(fit_1e5)$margen)) +
plot_svm(fit_1,   dat_s, sprintf("cost = 1: %d SV, margen = %.2f",   nrow(fit_1$SV),   coef_svm(fit_1)$margen)) +
  plot_layout(guides = "collect")

cost = 1e5: cero errores de entrenamiento pero margen muy angosto (probablemente peor en prueba). cost = 1: se equivoca en una observación, margen más ancho, mejor candidato.

9.6.2 Máquina de vectores de soporte: datos

set.seed(1)
x <- matrix(rnorm(200 * 2), ncol = 2)
x[1:100, ]   <- x[1:100, ] + 2
x[101:150, ] <- x[101:150, ] - 2
y <- c(rep(1, 150), rep(2, 50))
dat <- tibble(x1 = x[, 1], x2 = x[, 2], y = factor(y))

train <- sample(200, 100)
ggplot(dat, aes(x1, x2, colour = y)) + geom_point(size = 2) + scale_colour_manual(values = pal)

9.6.2 Kernel radial: cost = 1 vs. cost = 1e5

svm_c1   <- svm(y ~ ., data = dat[train, ], kernel = "radial", gamma = 1, cost = 1)
svm_c1e5 <- svm(y ~ ., data = dat[train, ], kernel = "radial", gamma = 1, cost = 1e5)

plot_svm(svm_c1,   dat[train, ], "gamma = 1, cost = 1") +
plot_svm(svm_c1e5, dat[train, ], "gamma = 1, cost = 1e5") + plot_layout(guides = "collect")
summary(svm_c1)

Call:
svm(formula = y ~ ., data = dat[train, ], kernel = "radial", gamma = 1, 
    cost = 1)


Parameters:
   SVM-Type:  C-classification 
 SVM-Kernel:  radial 
       cost:  1 

Number of Support Vectors:  31

 ( 16 15 )


Number of Classes:  2 

Levels: 
 1 2

cost alto reduce los errores de entrenamiento pero la frontera se vuelve irregular: riesgo de sobreajuste.

9.6.2 tune() de cost y gamma a la vez

set.seed(1)
tune_rad <- tune(svm, y ~ ., data = dat[train, ], kernel = "radial",
                 ranges = list(cost = c(0.1, 1, 10, 100, 1000), gamma = c(0.5, 1, 2, 3, 4)))
tune_rad$best.parameters
  cost gamma
2    1   0.5
tune_rad$best.performance
[1] 0.07
tune_rad$performances |>
  ggplot(aes(factor(gamma), factor(cost), fill = error)) +
  geom_tile(colour = "white") + geom_text(aes(label = round(error, 2))) +
  scale_fill_gradient(low = "white", high = pal[1]) +
  labs(x = "gamma", y = "cost", fill = "Error CV")

9.6.2 Error en prueba

pred_te <- predict(tune_rad$best.model, newdata = dat[-train, ])
table(real = dat[-train, ]$y, predicho = pred_te)
    predicho
real  1  2
   1 67 10
   2  2 21
mean(pred_te != dat[-train, ]$y)       # tasa de error (el libro reporta ≈ 12 %)
[1] 0.12
plot_conf(pred_te, dat[-train, ]$y, "Prueba")

9.6.3 Curvas ROC con pROC

Un SVM entrega, además de la clase, un puntaje \(\hat f(x)\); variando el umbral \(t\) obtenemos la curva ROC.

svm_opt  <- svm(y ~ ., data = dat[train, ], kernel = "radial", gamma = 2,  cost = 1)
svm_flex <- svm(y ~ ., data = dat[train, ], kernel = "radial", gamma = 50, cost = 1)
modelos  <- list("γ = 2" = svm_opt, "γ = 50" = svm_flex)

# score_svm(): positivo = segunda clase  =>  direction = "<" con levels = c("1", "2")
rocs_lab <- \(datos) map(modelos, \(m) roc(datos$y, score_svm(m, datos),
                                           levels = levels(datos$y), direction = "<", quiet = TRUE))

plot_roc(rocs_lab(dat[train, ]),  "Entrenamiento") +
plot_roc(rocs_lab(dat[-train, ]), "Prueba")

Con \(\gamma=50\) el ajuste de entrenamiento es casi perfecto, pero en prueba el modelo con \(\gamma=2\) es el mejor.

9.6.4 SVM con varias clases

set.seed(1)
x <- rbind(x, matrix(rnorm(50 * 2), ncol = 2))
y <- c(y, rep(0, 50))
x[y == 0, 2] <- x[y == 0, 2] + 2
dat3 <- tibble(x1 = x[, 1], x2 = x[, 2], y = factor(y))

svm3 <- svm(y ~ ., data = dat3, kernel = "radial", cost = 10, gamma = 1)
plot_svm(svm3, dat3, "SVM radial con 3 clases (uno contra uno)")

9.6.4 Uno contra uno vs. uno contra todos (hecho a mano)

# Uno contra uno: e1071 ajusta choose(3, 2) = 3 clasificadores
attr(predict(svm3, dat3[1:4, ], decision.values = TRUE), "decision.values")
       1/2      1/0        2/0
1 1.733038 1.363580 -0.5262125
2 1.229062 3.137106 -0.2459884
3 1.754287 0.647321 -1.0201972
4 1.496808 2.493188  0.7668653
# Uno contra todos: K clasificadores binarios, gana el mayor puntaje
ova <- map(levels(dat3$y), \(k) {
  d <- mutate(dat3, y = factor(if_else(y == k, "si", "no"), levels = c("no", "si")))
  svm(y ~ ., data = d, kernel = "radial", cost = 10, gamma = 1)
})
puntajes <- map(ova, \(m) score_svm(m, dat3)) |> set_names(levels(dat3$y)) |> as_tibble()
pred_ova <- levels(dat3$y)[max.col(puntajes)]

c(exactitud_OvO = mean(predict(svm3, dat3) == dat3$y),
  exactitud_OvA = mean(pred_ova == dat3$y))
exactitud_OvO exactitud_OvA 
        0.848         0.316 

Si la respuesta es numérica, svm() hace regresión (SVR) en lugar de clasificación.

9.6.5 Expresión génica: datos Khan

Cuatro tipos de tumores de células azules redondas pequeñas; 2308 genes; \(p\gg n\).

names(Khan)
[1] "xtrain" "xtest"  "ytrain" "ytest" 
dim(Khan$xtrain); dim(Khan$xtest)
[1]   63 2308
[1]   20 2308
table(entrenamiento = Khan$ytrain)
entrenamiento
 1  2  3  4 
 8 23 12 20 
table(prueba = Khan$ytest)
prueba
1 2 3 4 
3 6 6 5 

Con tantas variables, un kernel lineal basta: más flexibilidad es innecesaria.

9.6.5 Ajuste y desempeño

out <- svm(Khan$xtrain, factor(Khan$ytrain), kernel = "linear", cost = 10)
summary(out)

Call:
svm.default(x = Khan$xtrain, y = factor(Khan$ytrain), kernel = "linear", 
    cost = 10)


Parameters:
   SVM-Type:  C-classification 
 SVM-Kernel:  linear 
       cost:  10 

Number of Support Vectors:  58

 ( 20 20 11 7 )


Number of Classes:  4 

Levels: 
 1 2 3 4
table(ajustado = out$fitted, real = Khan$ytrain)      # entrenamiento: sin errores
        real
ajustado  1  2  3  4
       1  8  0  0  0
       2  0 23  0  0
       3  0  0 12  0
       4  0  0  0 20
pred_te <- predict(out, Khan$xtest)
table(predicho = pred_te, real = Khan$ytest)           # prueba
        real
predicho 1 2 3 4
       1 3 0 0 0
       2 0 6 2 0
       3 0 0 4 0
       4 0 0 0 5
plot_conf(pred_te, factor(Khan$ytest), "Khan: prueba (cost = 10)")

Sin errores de entrenamiento es esperable: con \(p\gg n\) siempre hay hiperplanos que separan. Lo importante es el error de prueba (el libro reporta 2 errores).

9.6.5 Extensión: elegir cost por validación cruzada

set.seed(1)
tune_khan <- tune.svm(Khan$xtrain, factor(Khan$ytrain), kernel = "linear",
                      cost = c(0.001, 0.01, 0.1, 1, 10, 100))
tune_khan$performances |>
  ggplot(aes(cost, error)) + geom_line() + geom_point(size = 3) + scale_x_log10() +
  labs(x = "cost (escala log)", y = "Error de CV")
table(predicho = predict(tune_khan$best.model, Khan$xtest), real = Khan$ytest)
        real
predicho 1 2 3 4
       1 3 0 0 0
       2 0 6 2 0
       3 0 0 4 0
       4 0 0 0 5

Ejercicios

Ejercicio guiado: margen máximo con 7 puntos (ejercicio 3 del libro)

ej3 <- tribble(~x1, ~x2, ~y,
                3,   4,  "Roja",
                2,   2,  "Roja",
                4,   4,  "Roja",
                1,   4,  "Roja",
                2,   1,  "Azul",
                4,   3,  "Azul",
                4,   1,  "Azul") |>
  mutate(y = factor(y, levels = c("Roja", "Azul")))

fit3 <- svm(y ~ ., data = ej3, kernel = "linear", cost = 1e5, scale = FALSE)
coef_svm(fit3)        # Azul = clase "positiva"
$beta0
[1] -1.00041

$beta
       x1        x2 
 1.999846 -1.999693 

$margen
[1] 0.3535941
fit3$index            # ¿qué observaciones son vectores de soporte?
[1] 2 3 6
plot_svm(fit3, ej3, "Margen máximo, 7 observaciones")

Tareas (a mano y verificando con el código):

  1. Hiperplano óptimo \(\beta_0+\beta_1X_1+\beta_2X_2=0\) y regla de clasificación. (Pista: la frontera es \(X_1-X_2-0.5=0\).)
  2. Margen \(M\) con \(\|\beta\|=1\) usando la fórmula de distancia de las primeras diapositivas (\(M=0.5/\sqrt2\approx0.354\)).
  3. Vectores de soporte (obs. 2, 3, 5, 6). Argumenta por qué mover la obs. 7 un poco no cambia el hiperplano.
  4. Dibuja un hiperplano separador que no sea el óptimo, y una observación adicional que haga las clases no separables.

Ejercicios aplicados del libro

  • Ej. 4. Simula 100 observaciones con frontera no lineal visible; compara SVC, SVM polinomial y radial en entrenamiento y prueba.
  • Ej. 5. Frontera cuadrática (y <- 1 * (x1^2 - x2^2 > 0)): compara regresión logística lineal, logística con términos no lineales, SVC y SVM. Grafica las predicciones de cada modelo.
  • Ej. 6. Datos apenas separables: error de CV y de prueba para cost desde muy pequeño hasta 1e5.
  • Ej. 7. Auto: clasifica millas por galón alto/bajo (mediana). Sin mpg como predictor. Para \(p>2\) usa plot(svmfit, dat, x1 ~ x4) de e1071.
  • Ej. 8. OJ: Purchase con 800 observaciones de entrenamiento; SVC (cost = 0.01), tune() en \([0.01,10]\), kernel radial y polinomial de grado 2.

Resumen

Clasificador de margen máximo Clasificador de vectores de soporte Máquina de vectores de soporte
Frontera hiperplano hiperplano no lineal (kernel)
Clases separables no separables no separables
Parámetros — cost cost, kernel, gamma/degree
Idea máx. margen margen suave ampliar el espacio de variables con kernels
  • La distancia de \(x_0\) al hiperplano es \(|\beta_0+\beta^\top x_0|/\|\beta\|\); con \(\|\beta\|=1\), \(y_if(x_i)\) es el margen de la observación \(i\).
  • Solo los vectores de soporte determinan la frontera. cost controla el sesgo-varianza: siempre se elige por validación cruzada.
  • Más flexibilidad (mayor \(\gamma\), mayor cost) mejora el entrenamiento, no necesariamente la predicción.
  • SVC y regresión logística son parientes cercanos (bisagra vs. logística); los kernels también pueden usarse con otros métodos.

Referencias

  • James, G., Witten, D., Hastie, T., Tibshirani, R. (2021). An Introduction to Statistical Learning with Applications in R, 2.ª ed., Cap. 9. Springer. Recursos: https://www.statlearning.com/resources-second-edition
  • Datos Heart: https://www.statlearning.com/s/Heart.csv
  • Meyer, D., et al. e1071: Misc Functions of the Department of Statistics (TU Wien). CRAN.
  • Robin, X., et al. (2011). pROC: an open-source package for R and S+ to analyze and compare ROC curves. BMC Bioinformatics, 12, 77.
  • Wickham, H., et al. (2019). Welcome to the tidyverse. Journal of Open Source Software, 4(43), 1686.