Máquinas de vectores de soporte

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

Los 14 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; 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")
plt.legend()
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 sesgo 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
        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)