Antes de empezar
Varios ejercicios usan las variables y funciones de la teoría: X, y, signo (los 40 puntos de dos clases), dibujar_clases, objetivo, svm_subgradiente, dibujar_svm, y las importaciones de scikit-learn (SVC, LinearSVC, make_pipeline, StandardScaler, GridSearchCV, cross_val_score). Abre un cuaderno nuevo en Colab, pega y ejecuta en orden los bloques de la ficha Código y después trabaja debajo. Intenta cada ejercicio antes de abrir la respuesta.
Ejercicios
1. ¿Quiénes sostienen la calle?
Ocho puntos en el plano. Clase 0: (0, 0), (1, 0), (0, 1) y (−1, −1). Clase 1: (3, 3), (2, 3), (3, 2) y (4, 4).
a) Dibújalos en papel. Sin calcular nada, ¿cuál crees que es la frontera de margen máximo? ¿Qué puntos serán los vectores de soporte?
b) ¿Cuánto mide el ancho de la calle?
c) Si borras los puntos (−1, −1) y (4, 4), ¿cambia la frontera? Comprueba todo con SVC(kernel="linear", C=1000): un C tan grande se comporta como el margen duro.
Respuesta
a) Los puntos más cercanos entre las dos clases son (1, 0) y (0, 1) de un lado, y (3, 2) y (2, 3) del otro. Los dos primeros están sobre la recta x₁ + x₂ = 1 y los otros dos sobre x₁ + x₂ = 5. La frontera va justo por el medio, paralela a ellas: x₁ + x₂ = 3. Esos cuatro puntos son los vectores de soporte.
b) La calle va de x₁ + x₂ = 1 a x₁ + x₂ = 5. La distancia entre esas dos rectas es 4/√2 ≈ 2,83. Con la fórmula del tema: la frontera x₁ + x₂ − 3 = 0 escrita con la convención de la SVM (puntaje ±1 en los vectores de soporte) es 0,5·x₁ + 0,5·x₂ − 1,5 = 0, y 2/‖w‖ = 2/√0,5 ≈ 2,83.
Los cuatro puntos que elegimos tienen y·z = 1: tocan los bordes de la calle. scikit-learn reporta solo dos de ellos como vectores de soporte porque, al estar alineados sobre los bordes, dos bastan para fijar la calle. Con datos reales, con ruido, casi nunca hay puntos tan perfectamente alineados.
c) No cambia: (−1, −1) y (4, 4) están lejos de la calle y no son vectores de soporte.
X1 = np.array([[0, 0], [1, 0], [0, 1], [-1, -1],
[3, 3], [2, 3], [3, 2], [4, 4]])
y1 = np.array([0, 0, 0, 0, 1, 1, 1, 1])
duro = SVC(kernel="linear", C=1000).fit(X1, y1)
w1, b1 = duro.coef_[0], duro.intercept_[0]
print("w =", np.round(w1, 3), " b =", round(b1, 3))
print("y·z de cada punto:", np.round(np.where(y1 == 1, 1, -1)
* duro.decision_function(X1), 2))
print("Vectores de soporte que reporta scikit-learn:",
duro.support_vectors_.tolist())
print(f"Ancho de la calle: {2 / np.linalg.norm(w1):.2f}")
# Sin los dos puntos lejanos (índices 3 y 7)
quedan = [0, 1, 2, 4, 5, 6]
sin_lejanos = SVC(kernel="linear", C=1000).fit(X1[quedan], y1[quedan])
print("Sin los lejanos: w =", np.round(sin_lejanos.coef_[0], 3),
" b =", round(sin_lejanos.intercept_[0], 3))
w = [0.5 0.5] b = -1.5
y·z de cada punto: [1.5 1. 1. 2.5 1.5 1. 1. 2.5]
Vectores de soporte que reporta scikit-learn: [[1.0, 0.0], [3.0, 2.0]]
Ancho de la calle: 2.83
Sin los lejanos: w = [0.5 0.5] b = -1.5
2. Multas a mano
Una SVM tiene w = (2, 0) y b = −2, y estos cuatro puntos con sus clases como signos:
| Punto | (x₁, x₂) | Clase |
|---|---|---|
| P | (0, 1) | −1 |
| Q | (1,2; 0) | +1 |
| R | (0,8; 2) | +1 |
| S | (3, −1) | +1 |
a) ¿Dónde está la frontera? ¿Dónde están los bordes de la calle y cuánto mide de ancho?
b) Para cada punto, calcula z, y·z y su pérdida bisagra. ¿Cuál está fuera de la calle, cuál dentro y cuál del lado equivocado?
c) Calcula el objetivo J con C = 1. Compruébalo con la función objetivo de la teoría.
Respuesta
a) z = 2·x₁ − 2, así que la frontera es la recta vertical x₁ = 1. Los bordes, donde z = ±1, son x₁ = 0,5 y x₁ = 1,5. El ancho es 2/‖w‖ = 2/2 = 1, que coincide con 1,5 − 0,5.
b)
| Punto | z | y·z | Bisagra | Dónde está |
|---|---|---|---|---|
| P | −2 | 2 | 0 | fuera de la calle, lado correcto |
| Q | 0,4 | 0,4 | 0,6 | dentro de la calle, lado correcto |
| R | −0,4 | −0,4 | 1,4 | lado equivocado |
| S | 4 | 4 | 0 | fuera de la calle, lado correcto |
c) J = ½·(2² + 0²) + 1·(0 + 0,6 + 1,4 + 0) = 2 + 2 = 4.
X2 = np.array([[0, 1], [1.2, 0], [0.8, 2], [3, -1]])
s2 = np.array([-1, 1, 1, 1])
w2, b2 = np.array([2.0, 0.0]), -2.0
z = X2 @ w2 + b2
print("z: ", np.round(z, 2))
print("y·z: ", np.round(s2 * z, 2))
print("bisagra:", np.round(np.maximum(0, 1 - s2 * z), 2))
print("J =", round(objetivo(X2, s2, w2, b2, C=1), 2))
z: [-2. 0.4 -0.4 4. ]
y·z: [ 2. 0.4 -0.4 4. ]
bisagra: [0. 0.6 1.4 0. ]
J = 4.0
3. El primer paso de subgradiente
Con los cuatro puntos del ejercicio 2, empieza en w = (0, 0) y b = 0, con C = 1 y tasa α = 0,1.
a) ¿Qué puntos pagan multa en el punto de partida? ¿Por qué?
b) Calcula a mano ∂J/∂w y ∂J/∂b, y da un paso. ¿Cuáles son los nuevos w y b?
c) Compruébalo con svm_subgradiente(..., pasos=1). ¿Bajó el objetivo?
Respuesta
a) Todos: con w = 0 y b = 0, todos los puntajes son 0, así que y·z = 0, que es menor que 1. Cada uno paga una multa de 1 y J = 0 + 4 = 4.
b) Como w = 0, el primer término no aporta y queda −C por la suma de yᵢ·xᵢ de los cuatro puntos:
- Σ yᵢ·xᵢ = −(0, 1) + (1,2; 0) + (0,8; 2) + (3, −1) = (5, 0), así que ∂J/∂w = (−5, 0).
- Σ yᵢ = −1 + 1 + 1 + 1 = 2, así que ∂J/∂b = −2.
El paso: w = (0, 0) − 0,1·(−5, 0) = (0,5; 0) y b = 0 − 0,1·(−2) = 0,2. La frontera nueva es vertical, en x₁ = −0,4: los puntos de la clase +1 tienen x₁ grande y el paso los empujó hacia su lado.
c)
w_paso, b_paso, h = svm_subgradiente(X2, s2, C=1, tasa=0.1, pasos=1)
print("w =", w_paso, " b =", round(b_paso, 2))
print(f"J antes = {h[0]:.2f}"
f" después = {objetivo(X2, s2, w_paso, b_paso, C=1):.3f}")
w = [0.5 0. ] b = 0.2
J antes = 4.00 después = 1.925
Sí bajó, de 4 a 1,925, con un solo paso.
4. Un punto lejano: SVM frente a logística
Agrega a los 40 puntos de la teoría un círculo de la clase 0 en (−1,2; −0,8): del lado correcto y fuera de la calle de la SVM. Entrena una SVM lineal (SVC(kernel="linear", C=1)) y una regresión logística (LogisticRegression()) con y sin ese punto. ¿Cuál de los dos modelos cambia? Explica por qué con la forma de sus pérdidas.
Respuesta
from sklearn.linear_model import LogisticRegression
X_lejos = np.vstack([X, [[-1.2, -0.8]]])
y_lejos = np.append(y, 0)
for nombre, Modelo, opciones in [("SVM", SVC, {"kernel": "linear"}),
("Logística", LogisticRegression, {})]:
antes = Modelo(**opciones).fit(X, y)
despues = Modelo(**opciones).fit(X_lejos, y_lejos)
print(f"{nombre}: sin el punto w = {np.round(antes.coef_[0], 3)},"
f" b = {antes.intercept_[0]:.3f}")
print(f"{nombre}: con el punto w = {np.round(despues.coef_[0], 3)},"
f" b = {despues.intercept_[0]:.3f}")
SVM: sin el punto w = [0.797 0.925], b = 0.174
SVM: con el punto w = [0.797 0.925], b = 0.174
Logística: sin el punto w = [1.514 1.298], b = -0.087
Logística: con el punto w = [1.537 1.301], b = -0.123
La SVM no cambia nada: el punto nuevo está del lado correcto y fuera de la calle, así que su pérdida bisagra es cero y su subgradiente también. No es un vector de soporte. La logística sí cambia, poco pero cambia: la entropía cruzada nunca llega a cero, así que hasta un punto bien clasificado sigue empujando los pesos (cuanto más lejos está, menos empuja, pero nunca deja de hacerlo). Es la diferencia que viste en la figura de la bisagra: en la SVM solo unos pocos puntos deciden la frontera; en la logística opinan todos.
5. Buscar C y gamma
Usa el conjunto de datos de tumores de mama (load_breast_cancer, incluido en scikit-learn; lo conoces del tema 8). Separa un 25 % para prueba con random_state=0 y stratify=y. Con el resto, busca C y γ de una SVM con kernel RBF con GridSearchCV y validación cruzada de 5 pliegues, probando 0,1, 1, 10 y 100 para C y 0,001, 0,01, 0,1 y 1 para γ. No olvides escalar dentro del pipeline.
a) ¿Cuál es la mejor combinación y su exactitud en validación cruzada? ¿Y en prueba?
b) Pide también la exactitud de entrenamiento (return_train_score=True) y compárala con la de validación cruzada en la combinación con C y γ más grandes. ¿Qué pasa ahí?
Respuesta
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
Xc, yc = load_breast_cancer(return_X_y=True)
Xc_ent, Xc_prueba, yc_ent, yc_prueba = train_test_split(
Xc, yc, test_size=0.25, random_state=0, stratify=yc)
pipe_c = make_pipeline(StandardScaler(), SVC(kernel="rbf"))
rejilla_c = {"svc__C": [0.1, 1, 10, 100],
"svc__gamma": [0.001, 0.01, 0.1, 1]}
busqueda_c = GridSearchCV(pipe_c, rejilla_c, cv=5,
return_train_score=True)
busqueda_c.fit(Xc_ent, yc_ent)
print("Mejor combinación:", busqueda_c.best_params_)
print(f"Validación cruzada: {busqueda_c.best_score_:.3f}"
f" prueba: {busqueda_c.score(Xc_prueba, yc_prueba):.3f}")
# La última combinación de la rejilla: C = 100 y gamma = 1
res = busqueda_c.cv_results_
print(f"C = 100, gamma = 1: entrenamiento "
f"{res['mean_train_score'][-1]:.3f}"
f" validación cruzada {res['mean_test_score'][-1]:.3f}")
Mejor combinación: {'svc__C': 100, 'svc__gamma': 0.001}
Validación cruzada: 0.988 prueba: 0.958
C = 100, gamma = 1: entrenamiento 1.000 validación cruzada 0.629
a) La mejor combinación es C = 100 y γ = 0,001, con 0,988 en validación cruzada y 0,958 en prueba. Fíjate en que quedó en un borde de la rejilla (el C más grande y el γ más chico que probamos): conviene ampliar la rejilla hacia ese lado y buscar de nuevo, porque el mejor valor podría estar afuera. La prueba da algo menos que la validación cruzada; es normal, porque el mejor puntaje de una búsqueda es un poco optimista (tema 10).
b) Con C = 100 y γ = 1, el modelo acierta el 100 % de los datos con que entrena, pero en validación cruzada le va mucho peor: sobreajuste. Con 30 características estandarizadas, γ = 1 hace que cada vector de soporte solo influya en su vecindad inmediata, y C grande no perdona errores: el modelo memoriza. La exactitud de prueba se mide una sola vez, al final, con la combinación ya elegida.
6. Levantar una sola variable
Trece puntos sobre una recta, x = −3, −2,5, …, 3. Son de la clase 1 los que cumplen −1,2 ≤ x ≤ 1,2, y de la clase 0 los demás.
a) ¿Puede una SVM lineal con la sola característica x separar las clases? (Con una variable, la frontera es un solo umbral.)
b) Levanta los datos agregando la característica x². ¿Ahora sí? ¿Qué frontera en x corresponde a la recta que encuentra la SVM en el plano (x, x²)?
Respuesta
a) No: la mejor SVM con solo x acierta 0,62 (8 de 13 puntos). Con una variable, un modelo lineal predice una clase a un lado de un umbral y la otra al otro lado. Pero la clase 1 está en el medio y la clase 0 en los dos extremos: ningún umbral solo puede separarlas.
b) Sí. Con x², los puntos del medio quedan abajo (x² chico) y los de los extremos arriba: una recta en el plano (x, x²) los separa. Esa recta, vuelta a la variable original, son dos umbrales, uno a cada lado del cero.
x = np.linspace(-3, 3, 13)
clase = (np.abs(x) <= 1.2).astype(int)
solo_x = LinearSVC().fit(x.reshape(-1, 1), clase)
print(f"Solo x: aciertos {solo_x.score(x.reshape(-1, 1), clase):.2f}")
X_lev = np.column_stack([x, x ** 2])
levantado = LinearSVC().fit(X_lev, clase)
print(f"Con x y x²: aciertos {levantado.score(X_lev, clase):.2f}")
# La frontera w1·x + w2·x² + b = 0 es una ecuación de segundo grado
w_a, w_b = levantado.coef_[0]
print("Umbrales en x:",
np.round(np.sort(np.roots([w_b, w_a, levantado.intercept_[0]])), 2))
Solo x: aciertos 0.62
Con x y x²: aciertos 1.00
Umbrales en x: [-1.21 1.21]
Es el mismo truco que con los anillos de la teoría, en una dimensión menos: lo que en el espacio levantado es una frontera lineal, en el original es una frontera curva (aquí, dos cortes).
Mini-proyecto: dígitos con SVM, logística y bosque
Vas a comparar tres modelos en load_digits (incluido en scikit-learn): 1797 imágenes de dígitos escritos a mano, de 8 × 8 píxeles, que viste en el tema 8.
Pasos.
- Divide. Separa un 25 % para prueba con
train_test_split(..., test_size=0.25, random_state=0, stratify=y). - Tres modelos. Entrena con los mismos datos de entrenamiento:
- una SVM con kernel RBF dentro de un pipeline con
StandardScaler; - una regresión logística en un pipeline igual (con
max_iter=2000); - un
RandomForestClassifier(random_state=0)(tema 11), sin escalar: a los árboles no les importa la escala.
- una SVM con kernel RBF dentro de un pipeline con
- Mide. Para cada uno, la exactitud en prueba y el tiempo de entrenar y de predecir, con
time.perf_counter(). - Ajusta la SVM. Busca C y γ con
GridSearchCVsobre los datos de entrenamiento. ¿Mejora en prueba? - Mira los vectores de soporte. ¿Cuántos de los ejemplos de entrenamiento son vectores de soporte en la SVM ajustada?
- Concluye. Escribe tres conclusiones de una o dos frases. Por ejemplo: ¿cuál conviene si importa la exactitud? ¿Y si importa la velocidad para predecir? ¿Qué pasaría con la SVM si tuvieras un millón de imágenes?
Resultados de referencia, para comprobar los tuyos
Este código hace los pasos 1 a 5. Los tiempos cambian de una computadora a otra, así que solo se comparan.
import time
from sklearn.datasets import load_digits
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
Xd, yd = load_digits(return_X_y=True)
Xd_ent, Xd_prueba, yd_ent, yd_prueba = train_test_split(
Xd, yd, test_size=0.25, random_state=0, stratify=yd)
modelos = {
"SVM (RBF)": make_pipeline(StandardScaler(), SVC()),
"Logística": make_pipeline(StandardScaler(),
LogisticRegression(max_iter=2000)),
"Bosque": RandomForestClassifier(random_state=0),
}
tiempos = {}
for nombre, modelo in modelos.items():
inicio = time.perf_counter()
modelo.fit(Xd_ent, yd_ent)
medio = time.perf_counter()
exactitud = modelo.score(Xd_prueba, yd_prueba)
fin = time.perf_counter()
tiempos[nombre] = (medio - inicio, fin - medio)
print(f"{nombre:10s} exactitud en prueba {exactitud:.3f}")
print("¿La logística predice más rápido que la SVM?",
tiempos["Logística"][1] < tiempos["SVM (RBF)"][1])
rejilla_d = {"svc__C": [1, 10, 100],
"svc__gamma": [0.001, 0.01, 0.1]}
busqueda_d = GridSearchCV(make_pipeline(StandardScaler(), SVC()),
rejilla_d, cv=5)
busqueda_d.fit(Xd_ent, yd_ent)
print("Mejor combinación:", busqueda_d.best_params_)
print(f"Validación cruzada: {busqueda_d.best_score_:.3f}"
f" prueba: {busqueda_d.score(Xd_prueba, yd_prueba):.3f}")
mejor_svc = busqueda_d.best_estimator_[-1]
print("Vectores de soporte:", len(mejor_svc.support_), "de", len(yd_ent))
SVM (RBF) exactitud en prueba 0.982
Logística exactitud en prueba 0.969
Bosque exactitud en prueba 0.980
¿La logística predice más rápido que la SVM? True
Mejor combinación: {'svc__C': 10, 'svc__gamma': 0.01}
Validación cruzada: 0.983 prueba: 0.980
Vectores de soporte: 619 de 1347
- Los tres modelos aciertan entre el 97 % y el 98 % de los dígitos de prueba. La SVM (0,982) y el bosque (0,980) quedan prácticamente empatados; la logística, un poco atrás (0,969). Con 450 imágenes de prueba, una diferencia de 0,002 es una sola imagen: no alcanza para declarar un ganador.
- Ajustar C y γ no mejoró la prueba (0,980 frente a 0,982 con los valores por defecto): los valores por defecto de
SVCya eran buenos para estos datos, y la diferencia es otra vez de una imagen. - La SVM ajustada usa 619 de los 1347 ejemplos de entrenamiento como vectores de soporte, y para predecir tiene que comparar cada imagen nueva con todos ellos. Por eso la logística, que solo hace una multiplicación por sus pesos, predice más rápido. Con un millón de imágenes, entrenar
SVCsería lentísimo: ahí convienenLinearSVC, la logística o una red neuronal.
Errores comunes
- No escalar. La SVM mide distancias: sin estandarizar, la característica con números más grandes manda sola. El escalador va dentro del pipeline, para que se ajuste solo con los datos de entrenamiento de cada pliegue.
- Usar las clases 0 y 1 en la bisagra. La fórmula max(0, 1 − y·z) supone clases −1 y +1. Con y = 0 la multa vale siempre 1, sin importar el puntaje. (scikit-learn hace la conversión por su cuenta; tu código, no.)
- Leer C al revés. En las SVM (y en
LogisticRegression), C grande significa poca regularización: calle angosta, riesgo de sobreajuste. Es lo contrario del α de Ridge. - Probar C y γ en escala lineal. Lo que importa es el orden de magnitud: prueba 0,01, 0,1, 1, 10, 100, no 1, 2, 3, 4.
- Elegir hiperparámetros mirando el conjunto de prueba. Se eligen con validación cruzada sobre el entrenamiento; la prueba se usa una sola vez, al final.
- Usar
SVCcon cientos de miles de ejemplos. Su costo crece mucho más rápido que la cantidad de datos. Si una frontera lineal alcanza, usaLinearSVC. - Esperar probabilidades. Una SVM da un puntaje (
decision_function), no una probabilidad.SVC(probability=True)las estima con un paso extra, más lento y no siempre bien calibrado; si necesitas probabilidades confiables, la logística es más directa. - Creer que el kernel es magia. Un kernel RBF con γ demasiado grande memoriza los datos igual que k-NN con k = 1. La validación cruzada es la que dice si la curva ayuda.
¿Lo lograste?
Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.
- Explicar el margen y los vectores de soporte (ejercicios 1, 2 y 4, y la actividad).
- Explicar el papel de C (margen suave) y su relación con el sobreajuste (ejercicios 2 y 5, y la actividad).
- Programar una SVM lineal con descenso de subgradiente sobre la pérdida bisagra (ejercicio 3).
- Explicar el truco del kernel con un ejemplo de levantar los datos a más dimensiones (ejercicio 6).
- Ajustar C y γ del kernel RBF con validación cruzada (ejercicio 5 y pasos 4 y 5 del mini-proyecto).