Máquinas de vectores de soporte

Tema 12 · Fase 3, Más modelos, tiempo y estructura · Toda la ruta

Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.

Los 14 bloques de código de la teoría, en el mismo orden. Si prefieres armar tu propio cuaderno, cópialos y córrelos en orden; en la ficha Teoría está la explicación de cada uno.

Muchas rectas separan, ¿cuál elegir?

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs

# 40 puntos sintéticos en dos grupos, 20 de cada clase
X, y = make_blobs(n_samples=40, centers=[[-1.5, -1], [1.5, 1]],
                  cluster_std=0.8, random_state=1)
# Las SVM usan las clases -1 y +1 en lugar de 0 y 1
signo = np.where(y == 1, 1, -1)
print("Clases como 0 y 1:  ", y[:8])
print("Clases como signos: ", signo[:8])

def dibujar_clases(X, y, ax=None, s=30):
    """Clase 0: círculos de color C0. Clase 1: cuadrados de color C1."""
    ax = ax or plt.gca()
    ax.scatter(X[y == 0, 0], X[y == 0, 1], s=s, color="C0",
               marker="o", label="Clase 0")
    ax.scatter(X[y == 1, 0], X[y == 1, 1], s=s, color="C1",
               marker="s", label="Clase 1")

# Tres rectas w1·x1 + w2·x2 + b = 0: nombre, (w1, w2), b
# y dónde escribir el nombre en el dibujo
rectas = [("A", (1, 1), 0, (-2.9, 3)),
          ("B", (3, 1), 0, (-0.8, 3)),
          ("C", (1, 3), 0.5, (3, -0.9))]
x1 = np.array([-4, 4])
dibujar_clases(X, y)
for nombre, (w1, w2), b, (tx, ty) in rectas:
    # Despejamos x2 de la ecuación de la recta
    plt.plot(x1, -(w1 * x1 + b) / w2, color="C2")
    plt.text(tx, ty, nombre, color="C2", fontsize=16)
plt.xlim(-3.5, 3.5)
plt.ylim(-3.5, 3.5)
plt.xlabel("$x_1$")
plt.ylabel("$x_2$")
plt.title("Tres rectas que separan las clases")
plt.show()
def distancias(X, w, b):
    """Distancia de cada fila de X a la recta w·x + b = 0."""
    return np.abs(X @ w + b) / np.linalg.norm(w)

for nombre, (w1, w2), b, _ in rectas:
    w = np.array([w1, w2])
    aciertos = np.mean(np.sign(X @ w + b) == signo)
    cerca = distancias(X, w, b).min()
    print(f"Recta {nombre}, w = ({w1}, {w2}), b = {b}:  aciertos"
          f" {aciertos:.0%}   punto más cercano a {cerca:.2f}")

La pérdida bisagra

yz = np.linspace(-2, 3, 300)          # la clase por el puntaje
bisagra = np.maximum(0, 1 - yz)
entropia = np.log(1 + np.exp(-yz))

for valor in [-1, 0, 0.5, 1, 2, 3]:
    print(f"y·z = {valor:4}:  bisagra {max(0, 1 - valor):.2f}"
          f"   entropía cruzada {np.log(1 + np.exp(-valor)):.2f}")

plt.plot(yz, bisagra, color="C1", linewidth=2.6, label="Bisagra")
plt.plot(yz, entropia, color="C2", linestyle="--",
         label="Entropía cruzada")
plt.axvline(1, color="C2", linewidth=1)
plt.xlabel("y·z  (positivo: lado correcto)")
plt.ylabel("Pérdida")
plt.title("La bisagra no cobra fuera de la calle")
# Con fondo blanco, la línea vertical no cruza el texto de la leyenda
plt.legend(frameon=True)
plt.show()

Programar la SVM desde cero

def objetivo(X, signo, w, b, C):
    """J = ½‖w‖² + C · (suma de las pérdidas bisagra)."""
    multas = np.maximum(0, 1 - signo * (X @ w + b))
    return 0.5 * w @ w + C * multas.sum()

def svm_subgradiente(X, signo, C, tasa, pasos):
    """SVM lineal con descenso de subgradiente.

    Devuelve los pesos w, el término independiente b y el objetivo J
    antes de cada paso.
    """
    n, d = X.shape
    w = np.zeros(d)                   # un peso por característica
    b = 0.0
    historial = []
    for paso in range(pasos):
        historial.append(objetivo(X, signo, w, b, C))
        yz = signo * (X @ w + b)      # la clase por el puntaje
        multa = yz < 1                # los que se meten en la calle
        # X[multa].T @ signo[multa] es la suma de signo[i] * X[i]
        # sobre los puntos que pagan multa (.T es la transpuesta)
        dw = w - C * X[multa].T @ signo[multa]
        db = -C * signo[multa].sum()
        w = w - tasa * dw             # un paso en contra
        b = b - tasa * db
    return w, b, np.array(historial)

w_svm, b_svm, historial = svm_subgradiente(X, signo, C=1, tasa=0.001,
                                           pasos=20000)
for paso in [0, 10, 100, 1000, 19999]:
    print(f"Paso {paso:5d}:   J = {historial[paso]:.4f}")
print("w =", np.round(w_svm, 3), "  b =", round(b_svm, 3))
from sklearn.svm import SVC

svm = SVC(kernel="linear", C=1).fit(X, y)
print("scikit-learn:   w =", np.round(svm.coef_[0], 3),
      "  b =", np.round(svm.intercept_[0], 3))
print(f"Ancho de la calle: {2 / np.linalg.norm(w_svm):.2f}")

# Vectores de soporte: los que tocan la calle o se meten en ella
yz = signo * (X @ w_svm + b_svm)
nuestros = np.flatnonzero(yz < 1.01)
print("Vectores de soporte (nuestro):     ", nuestros)
print("Vectores de soporte (scikit-learn):", np.sort(svm.support_))
print("Su y·z:", np.round(yz[nuestros], 2))
def dibujar_svm(w, b, ax=None, vectores=None):
    """La frontera (z = 0), los bordes (z = -1 y z = 1) y la calle."""
    ax = ax or plt.gca()
    A, B = np.meshgrid(np.linspace(-4, 4, 300), np.linspace(-4, 4, 300))
    Z = w[0] * A + w[1] * B + b
    ax.contourf(A, B, Z, levels=[-1, 1], colors=["C2"], alpha=0.18)
    ax.contour(A, B, Z, levels=[-1, 0, 1], colors=["C2", "C0", "C2"],
               linestyles=["--", "-", "--"], linewidths=[1.4, 2.6, 1.4])
    if vectores is not None:
        # Un anillo alrededor de cada vector de soporte
        ax.scatter(vectores[:, 0], vectores[:, 1], s=190,
                   facecolors="none", edgecolors="C0", linewidths=1.5)
    ax.set_xlim(-3.5, 3.5)
    ax.set_ylim(-3.5, 3.5)

dibujar_svm(w_svm, b_svm, vectores=X[nuestros])
dibujar_clases(X, y)
plt.xlabel("$x_1$")
plt.ylabel("$x_2$")
plt.title("La calle más ancha (C = 1)")
plt.show()

C: cuánto manda un atípico

# Un círculo de la clase 0 metido en la zona de la clase 1
X_atip = np.vstack([X, [[0.3, 1.0]]])
y_atip = np.append(y, 0)

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8),
                         sharex=True, sharey=True)
for eje, C in zip(ejes.flat, [0.01, 0.1, 1, 100]):
    modelo = SVC(kernel="linear", C=C).fit(X_atip, y_atip)
    w, b = modelo.coef_[0], modelo.intercept_[0]
    ancho = 2 / np.linalg.norm(w)
    dibujar_svm(w, b, ax=eje, vectores=modelo.support_vectors_)
    dibujar_clases(X_atip, y_atip, ax=eje, s=10)
    eje.set_title(f"C = {C}\nancho {ancho:.2f}")
    print(f"C = {C:5}:  ancho {ancho:.2f}"
          f"   vectores de soporte {len(modelo.support_):2d}"
          f"   el atípico va a la clase {modelo.predict([[0.3, 1.0]])[0]}")
plt.show()

Escalar es obligatorio

from sklearn.datasets import load_wine
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X_vino, y_vino = load_wine(return_X_y=True)
# Columna 12: prolina. Columna 10: tono del color
print(f"La prolina varía en {np.ptp(X_vino[:, 12]):.0f};"
      f" el tono, en {np.ptp(X_vino[:, 10]):.2f}")

sin_escalar = cross_val_score(SVC(), X_vino, y_vino, cv=5).mean()
escalado = cross_val_score(make_pipeline(StandardScaler(), SVC()),
                           X_vino, y_vino, cv=5).mean()
print(f"Exactitud sin escalar: {sin_escalar:.3f}")
print(f"Exactitud estandarizando: {escalado:.3f}")

Fronteras curvas: el truco del kernel

from sklearn.datasets import make_circles
from sklearn.svm import LinearSVC

# 200 puntos sintéticos: la clase 1 es el anillo de adentro
X_circ, y_circ = make_circles(n_samples=200, factor=0.4, noise=0.08,
                              random_state=0)
# Levantamos cada punto: la altura es su distancia al cuadrado al centro
x3 = X_circ[:, 0] ** 2 + X_circ[:, 1] ** 2
X_3d = np.column_stack([X_circ, x3])

plano = LinearSVC().fit(X_circ, y_circ)
espacio = LinearSVC().fit(X_3d, y_circ)
print(f"Recta en el plano:     aciertos {plano.score(X_circ, y_circ):.2f}")
print(f"Plano en 3D:           aciertos {espacio.score(X_3d, y_circ):.2f}")
print(f"Altura de la clase 1:  hasta {x3[y_circ == 1].max():.2f}")
print(f"Altura de la clase 0:  desde {x3[y_circ == 0].min():.2f}")

fig = plt.figure()
eje = fig.add_subplot(projection="3d")
for clase, color, marca in [(0, "C0", "o"), (1, "C1", "s")]:
    m = y_circ == clase
    eje.scatter(X_circ[m, 0], X_circ[m, 1], x3[m], color=color,
                marker=marca, s=12, depthshade=False)
# Un plano horizontal a media altura separa las dos clases
A, B = np.meshgrid(np.linspace(-1.2, 1.2, 2), np.linspace(-1.2, 1.2, 2))
eje.plot_surface(A, B, np.full_like(A, 0.48), color="C2", alpha=0.3)
eje.set_xlabel("$x_1$")
eje.set_ylabel("$x_2$")
eje.set_zlabel("$x_3$")
eje.set_title("Levantar los anillos a 3D")
plt.show()
def levantar(p):
    """Tres características nuevas a partir de (x1, x2)."""
    return np.array([p[0] ** 2, np.sqrt(2) * p[0] * p[1], p[1] ** 2])

a = np.array([1.0, 2.0])
c = np.array([3.0, -1.0])
print("Levantando y multiplicando:", round(levantar(a) @ levantar(c), 6))
print("Con el kernel (a·c)²:      ", (a @ c) ** 2)
for nombre, modelo in [("lineal", SVC(kernel="linear")),
                       ("polinomial, grado 2", SVC(kernel="poly",
                                                   degree=2)),
                       ("RBF", SVC(kernel="rbf"))]:
    modelo.fit(X_circ, y_circ)
    print(f"Kernel {nombre:20s} aciertos {modelo.score(X_circ, y_circ):.2f}")
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split

# Las dos lunas del tema 8, con la misma división
X_lunas, y_lunas = make_moons(n_samples=200, noise=0.3, random_state=0)
X_entr, X_val, y_entr, y_val = train_test_split(
    X_lunas, y_lunas, test_size=0.3, random_state=0)

A, B = np.meshgrid(np.linspace(-2, 3, 200), np.linspace(-1.5, 2, 200))
rejilla = np.column_stack([A.ravel(), B.ravel()])

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8),
                         sharex=True, sharey=True)
for eje, (gamma, C) in zip(ejes.flat,
                           [(0.1, 1), (0.1, 100), (10, 1), (10, 100)]):
    modelo = SVC(kernel="rbf", C=C, gamma=gamma).fit(X_entr, y_entr)
    zona = modelo.predict(rejilla).reshape(A.shape)
    # Cada región, del color de su clase y muy clara
    eje.contourf(A, B, zona, levels=[-0.5, 0.5, 1.5],
                 colors=["C0", "C1"], alpha=0.2)
    dibujar_clases(X_entr, y_entr, ax=eje, s=8)
    eje.set_title(f"gamma = {gamma}\nC = {C}")
    print(f"γ = {gamma:4}, C = {C:3}:  aciertos al entrenar "
          f"{modelo.score(X_entr, y_entr):.2f}"
          f"   en validación {modelo.score(X_val, y_val):.2f}")
plt.show()

Elegir C y gamma con validación cruzada

from sklearn.model_selection import GridSearchCV
import pandas as pd

pipe = make_pipeline(StandardScaler(), SVC(kernel="rbf"))
rejilla_hp = {"svc__C": [0.1, 1, 10, 100],
              "svc__gamma": [0.1, 1, 10, 100]}
busqueda = GridSearchCV(pipe, rejilla_hp, cv=5)
busqueda.fit(X_entr, y_entr)

# La exactitud media de validación cruzada de las 16 combinaciones
tabla = pd.DataFrame(busqueda.cv_results_["params"])
tabla["exactitud"] = busqueda.cv_results_["mean_test_score"]
print(tabla.pivot(index="svc__C", columns="svc__gamma",
                  values="exactitud").round(2))
print("Mejor combinación:", busqueda.best_params_)
print(f"Exactitud en validación cruzada: {busqueda.best_score_:.3f}")
print(f"Exactitud en el conjunto de validación: "
      f"{busqueda.score(X_val, y_val):.3f}")

Muchos datos: LinearSVC frente a SVC

import time
from sklearn.datasets import make_classification

X_grande, y_grande = make_classification(n_samples=5000, n_features=20,
                                         random_state=0)
inicio = time.perf_counter()
lento = SVC(kernel="linear").fit(X_grande, y_grande)
t_svc = time.perf_counter() - inicio

inicio = time.perf_counter()
rapido = LinearSVC().fit(X_grande, y_grande)
t_lineal = time.perf_counter() - inicio

print(f"SVC:       aciertos {lento.score(X_grande, y_grande):.3f}"
      f"   vectores de soporte {len(lento.support_)}")
print(f"LinearSVC: aciertos {rapido.score(X_grande, y_grande):.3f}")
print("¿LinearSVC fue más de 10 veces más rápido?", t_svc > 10 * t_lineal)