3.5 - Notas de Luis - Máquinas de vectores de soporte (SVM) con scikit-learn¶
En el cuaderno SVM desde cero entrenamos una SVM lineal con descenso del gradiente. El SVC de scikit-learn resuelve el mismo problema con un optimizador especializado y, sobre todo, ofrece el truco del kernel, que permite a una SVM trazar fronteras curvas. En este cuaderno:
- Importar las bibliotecas.
- El ejemplo del libro con
SVC: la misma recta y el efecto deC. - El truco del kernel: cuando una recta no basta.
- Kernels lineal, polinómico y RBF.
- El parámetro
gammay el sobreajuste. - Las ausencias de Fisioterapia: kernel lineal o RBF, elegido con validación cruzada.
- Comparación final con la regresión logística.
- Probabilidades con una SVM.
Paso 1: Importar las bibliotecas¶
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.svm import SVC
from sklearn.datasets import make_circles
from sklearn.model_selection import train_test_split, StratifiedKFold, GridSearchCV
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, confusion_matrix, roc_auc_score
np.set_printoptions(precision=3, suppress=True)
Paso 2: El ejemplo del libro con SVC¶
Usamos de nuevo los 14 puntos de la figura de la SVM lineal de la sección 3.4. SVC minimiza exactamente el coste de margen suave del libro,
$$\frac{1}{2}\|w\|^2 + C \sum_{i=1}^{m} \xi_i$$
así que la C de scikit-learn es el $\lambda$ del libro. Cuidado: en LogisticRegression, C era la inversa de la intensidad de la regularización. En los dos casos una C grande significa un modelo que se ajusta más a los datos de entrenamiento, pero las fórmulas no son iguales.
Con una C grande no se tolera ningún incumplimiento del margen (margen duro), y deberíamos recuperar la recta del libro, $x_2 = 0{,}5\,x_1 + 1{,}375$:
positive = np.array([[1, 5], [1.5, 4], [2, 5.5], [2.5, 4.5], [3, 5], [3.5, 3.75], [4, 4.8]])
negative = np.array([[3, 2], [3.5, 1.5], [4, 2.75], [4.5, 1.8], [5, 2.3], [5.5, 1.6], [6, 2]])
X_toy = np.vstack([positive, negative])
y_toy = np.array([1] * len(positive) + [-1] * len(negative))
svm = SVC(kernel='linear', C=1000).fit(X_toy, y_toy)
w, b = svm.coef_[0], svm.intercept_[0]
print(f"w = {w.round(3)}, b = {b:.3f}")
print(f"Frontera: x2 = {-w[0] / w[1]:.3f} x1 + {-b / w[1]:.3f}")
print(f"Anchura del margen: {2 / np.linalg.norm(w):.3f}")
print(f"Vectores de soporte: {svm.support_vectors_.tolist()}")
w = [-0.8 1.6], b = -2.200 Frontera: x2 = 0.500 x1 + 1.375 Anchura del margen: 1.118 Vectores de soporte: [[4.0, 2.75], [3.5, 3.75]]
La misma recta y los mismos dos vectores de soporte que obtuvimos desde cero, ahora con los valores exactos. Veamos qué pasa cuando bajamos C, es decir, cuando incumplir el margen sale más barato. Para dibujar las fronteras escribimos una pequeña función que reutilizaremos con todos los modelos del cuaderno:
def plot_decision(model, X, y, ax, title, margins=True):
"""Puntos, frontera (continua), márgenes (discontinuos) y vectores de soporte (rodeados) de un SVC entrenado."""
pad = 0.5
x1, x2 = np.meshgrid(np.linspace(X[:, 0].min() - pad, X[:, 0].max() + pad, 300),
np.linspace(X[:, 1].min() - pad, X[:, 1].max() + pad, 300))
f = model.decision_function(np.c_[x1.ravel(), x2.ravel()]).reshape(x1.shape)
ax.contourf(x1, x2, f > 0, alpha=0.15, cmap='coolwarm_r')
levels, styles = ([-1, 0, 1], ['--', '-', '--']) if margins else ([0], ['-'])
ax.contour(x1, x2, f, levels=levels, colors='k', linestyles=styles, linewidths=1)
classes = np.unique(y)
ax.scatter(*X[y == classes[-1]].T, color='tab:blue', s=25)
ax.scatter(*X[y == classes[0]].T, color='tab:red', marker='s', s=25)
ax.scatter(*model.support_vectors_.T, s=120, facecolors='none', edgecolors='k', linewidths=0.8)
ax.set_title(title)
fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))
for C, ax in zip([1000, 1, 0.1], axes):
model = SVC(kernel='linear', C=C).fit(X_toy, y_toy)
margin = 2 / np.linalg.norm(model.coef_[0])
print(f"C = {C:<6} margen = {margin:.3f} vectores de soporte = {len(model.support_vectors_)}")
plot_decision(model, X_toy, y_toy, ax, f'C = {C}: margen {margin:.2f}, {len(model.support_vectors_)} vectores de soporte')
plt.show()
C = 1000 margen = 1.118 vectores de soporte = 2 C = 1 margen = 1.789 vectores de soporte = 2 C = 0.1 margen = 3.130 vectores de soporte = 8
Con C = 1000 tenemos el margen duro. Con C = 1 el margen se ensancha hasta 1,789: los dos puntos más cercanos quedan ahora dentro y cada uno paga una pequeña penalización. Con C = 0.1 el margen es casi tres veces más ancho que el duro y se apoyan en él 8 puntos. Cuanto menor es C, más incumplimientos toleramos y más puntos participan en la colocación de la frontera.
Paso 3: El truco del kernel¶
Una SVM lineal solo puede trazar rectas (hiperplanos, cuando hay más variables). Algunos problemas necesitan otra cosa. En el siguiente conjunto de datos, una clase forma un anillo alrededor de la otra: ninguna recta puede separarlas.
X_ring, y_ring = make_circles(n_samples=300, noise=0.15, factor=0.4, random_state=0)
Xr_train, Xr_test, yr_train, yr_test = train_test_split(
X_ring, y_ring, test_size=0.3, random_state=42, stratify=y_ring)
linear_ring = SVC(kernel='linear').fit(Xr_train, yr_train)
print(f"SVM lineal: exactitud en entrenamiento = {linear_ring.score(Xr_train, yr_train):.3f}, "
f"en prueba = {linear_ring.score(Xr_test, yr_test):.3f}")
fig, ax = plt.subplots(figsize=(5.5, 5))
plot_decision(linear_ring, X_ring, y_ring, ax, 'Una recta no puede separar un anillo')
plt.show()
SVM lineal: exactitud en entrenamiento = 0.614, en prueba = 0.478
Peor que lanzar una moneda. La idea de la figura del libro (sección 3.4) es añadir una dimensión nueva en la que las clases sí sean separables. Aquí la elección natural es la distancia al centro al cuadrado, $\phi(x_1, x_2) = (x_1,\; x_2,\; x_1^2 + x_2^2)$: los puntos interiores se quedan abajo y el anillo sube, de modo que un plano puede separarlos.
def phi(X):
return np.c_[X, (X ** 2).sum(axis=1)]
linear_3d = SVC(kernel='linear').fit(phi(Xr_train), yr_train)
print(f"SVM lineal sobre (x1, x2, x1² + x2²): exactitud en prueba = {linear_3d.score(phi(Xr_test), yr_test):.3f}")
Z = phi(X_ring)
fig = plt.figure(figsize=(12, 5))
ax1 = fig.add_subplot(1, 2, 1)
ax1.scatter(*X_ring[y_ring == 1].T, color='tab:blue', s=15, label='clase interior')
ax1.scatter(*X_ring[y_ring == 0].T, color='tab:red', marker='s', s=15, label='anillo')
ax1.set_title('Espacio original (2D)')
ax1.legend()
ax2 = fig.add_subplot(1, 2, 2, projection='3d')
ax2.scatter(*Z[y_ring == 1].T, color='tab:blue', s=15)
ax2.scatter(*Z[y_ring == 0].T, color='tab:red', marker='s', s=15)
g1, g2 = np.meshgrid(np.linspace(-1.5, 1.5, 10), np.linspace(-1.5, 1.5, 10))
w3, b3 = linear_3d.coef_[0], linear_3d.intercept_[0]
ax2.plot_surface(g1, g2, -(w3[0] * g1 + w3[1] * g2 + b3) / w3[2], alpha=0.3, color='gray')
ax2.set_zlabel('$x_1^2 + x_2^2$')
ax2.set_box_aspect(None, zoom=0.85)
ax2.set_title('Espacio transformado (3D): un plano las separa')
plt.show()
SVM lineal sobre (x1, x2, x1² + x2²): exactitud en prueba = 0.967
Un plano en 3D es un círculo cuando lo miramos desde arriba, en el espacio original 2D. Sin embargo, elegir $\phi$ a mano exige conocer de antemano la forma del problema y, con muchas variables, el espacio transformado puede ser enorme.
El truco del kernel evita los dos problemas. Para entrenar y para predecir, una SVM solo necesita productos escalares entre pares de puntos, $\phi(x_i) \cdot \phi(x_j)$. Una función kernel $K(x_i, x_j)$ calcula ese producto directamente a partir de los puntos originales, sin construir nunca $\phi(x)$. Los kernels del libro:
| Kernel | $K(x_i, x_j)$ | En SVC |
|---|---|---|
| Lineal | $x_i \cdot x_j$ | kernel='linear' |
| Polinómico | $(x_i \cdot x_j + 1)^d$ | kernel='poly', degree=d, gamma=1, coef0=1 |
| RBF (gaussiano) | $\exp(-\gamma \|x_i - x_j\|^2)$ | kernel='rbf', gamma=γ |
Paso 4: Kernels lineal, polinómico y RBF¶
kernels = {
'lineal': SVC(kernel='linear'),
'polinómico, d = 2': SVC(kernel='poly', degree=2, gamma=1, coef0=1),
'RBF, gamma = 1': SVC(kernel='rbf', gamma=1),
}
fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))
for (name, model), ax in zip(kernels.items(), axes):
model.fit(Xr_train, yr_train)
train_acc, test_acc = model.score(Xr_train, yr_train), model.score(Xr_test, yr_test)
print(f"{name:<18} entrenamiento = {train_acc:.3f} prueba = {test_acc:.3f}")
plot_decision(model, X_ring, y_ring, ax, f'{name}: prueba {test_acc:.2f}')
plt.show()
lineal entrenamiento = 0.614 prueba = 0.478
polinómico, d = 2 entrenamiento = 0.967 prueba = 0.956 RBF, gamma = 1 entrenamiento = 0.976 prueba = 0.967
El kernel polinómico de grado 2 contiene los cuadrados $x_1^2$ y $x_2^2$, así que puede trazar exactamente el tipo de frontera que construimos a mano. Al kernel RBF no le hemos dicho nada sobre círculos: mide lo parecidos que son dos puntos por su distancia y puede curvar la frontera para seguir casi cualquier forma. Por eso es el kernel más utilizado, y también por eso hay que manejarlo con cuidado.
Paso 5: El parámetro gamma y el sobreajuste¶
En el kernel RBF, $\gamma$ indica hasta dónde llega la influencia de cada punto de entrenamiento. Con un $\gamma$ pequeño cada punto influye en una zona amplia y la frontera es suave. Con un $\gamma$ grande cada punto solo influye en su entorno inmediato y la frontera puede envolver puntos individuales: otra vez el sobreajuste.
fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))
for gamma, ax in zip([0.1, 1, 100], axes):
model = SVC(kernel='rbf', gamma=gamma).fit(Xr_train, yr_train)
train_acc, test_acc = model.score(Xr_train, yr_train), model.score(Xr_test, yr_test)
print(f"gamma = {gamma:<4} entrenamiento = {train_acc:.3f} prueba = {test_acc:.3f} "
f"vectores de soporte = {len(model.support_vectors_)} de {len(yr_train)}")
plot_decision(model, Xr_train, yr_train, ax, f'gamma = {gamma}\nentrenamiento {train_acc:.2f}, prueba {test_acc:.2f}',
margins=False)
plt.show()
gamma = 0.1 entrenamiento = 0.862 prueba = 0.822 vectores de soporte = 195 de 210
gamma = 1 entrenamiento = 0.976 prueba = 0.967 vectores de soporte = 56 de 210 gamma = 100 entrenamiento = 1.000 prueba = 0.956 vectores de soporte = 182 de 210
Con $\gamma = 0{,}1$ la frontera es demasiado suave para seguir el anillo: hay subajuste (0,862 en entrenamiento, 0,822 en prueba). Con $\gamma = 1$ sigue el anillo y generaliza bien, con solo 56 vectores de soporte. Con $\gamma = 100$ llega al 100% en entrenamiento envolviendo puntos individuales con la frontera, lo hace algo peor con datos nuevos y necesita 182 de los 210 puntos como vectores de soporte: una señal de que está memorizando los ejemplos en lugar de resumirlos. Con más ruido en los datos, la distancia entre entrenamiento y prueba sería mucho mayor.
C y gamma se eligen igual que $\lambda$ en los cuadernos anteriores: con validación cruzada.
Paso 6: Las ausencias de Fisioterapia¶
Volvemos al problema del capítulo, con las mismas citas sintéticas y la misma división que en los demás cuadernos:
| Columna | Significado |
|---|---|
edad |
Edad del paciente (18–85 años) |
dias_desde_ultima_cita |
Días desde la cita anterior del paciente |
distancia_km |
Distancia de casa al hospital, en km (Minermont atiende una zona rural) |
hora |
Hora de la cita (de 8 a 19 h) |
primera_visita |
1 si es una primera visita, 0 si es una revisión |
ausencias_previas |
Número de citas a las que el paciente faltó anteriormente |
no_asiste |
Objetivo: 1 si el paciente no acudió, 0 si acudió |
def generate_appointments(n, seed):
"""Citas sintéticas de Fisioterapia en Minermont: una fila por cita."""
rng = np.random.default_rng(seed)
age = rng.integers(18, 86, n) # años
days = rng.integers(1, 181, n) # días desde la cita anterior
distance = np.round(rng.gamma(2.0, 6.0, n).clip(0.5, 60), 1) # km hasta el hospital (zona rural)
hour = rng.integers(8, 20, n) # hora de la cita, de 8:00 a 19:00
first = rng.binomial(1, 0.35, n) # 1 = primera visita, 0 = revisión
previous = rng.poisson(0.6, n).clip(0, 6) # ausencias en el historial del paciente
late = np.where(hour >= 17, hour - 16, 0) # a última hora de la tarde se falta más
# La regla oculta detrás de los datos. Solo la usamos para generar las citas:
# el modelo nunca la ve y tiene que descubrir el patrón a partir de los ejemplos.
z = (-1.55 - 0.069 * (age - 50) + 0.026 * (days - 90) + 0.11 * (distance - 12)
+ 1.5 * (previous - 0.6) + 0.56 * (late - 0.75) + 0.63 * (first - 0.35))
no_show = rng.binomial(1, 1 / (1 + np.exp(-z))) # 1 = no acudió
return pd.DataFrame({
'edad': age, 'dias_desde_ultima_cita': days, 'distancia_km': distance, 'hora': hour,
'primera_visita': first, 'ausencias_previas': previous, 'no_asiste': no_show,
})
df = generate_appointments(1200, seed=42)
feature_names = ['edad', 'dias_desde_ultima_cita', 'distancia_km', 'hora', 'primera_visita', 'ausencias_previas']
X = df[feature_names]
y = df['no_asiste']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
print(f"Entrenamiento: {len(y_train)} citas, prueba: {len(y_test)}")
Entrenamiento: 900 citas, prueba: 300
GridSearchCV automatiza el procedimiento de las notas de Luis (sección 3.5): para cada combinación de valores de la rejilla hace una validación cruzada estratificada de 5 grupos sobre el conjunto de entrenamiento, se queda con la mejor combinación y vuelve a entrenar con todo el conjunto de entrenamiento. El escalado forma parte del pipeline, así que se recalcula en cada grupo.
Para el kernel lineal probamos la rejilla logarítmica del libro para C (el $\lambda$ del libro). Para el kernel RBF también hay que elegir gamma, así que la rejilla tiene dos dimensiones:
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
linear_search = GridSearchCV(make_pipeline(StandardScaler(), SVC(kernel='linear')),
{'svc__C': [0.001, 0.01, 0.1, 1, 10, 100, 1000]}, cv=cv)
linear_search.fit(X_train, y_train)
print("Kernel lineal, exactitud media de validación para cada C:")
for C, score in zip([0.001, 0.01, 0.1, 1, 10, 100, 1000], linear_search.cv_results_['mean_test_score']):
print(f" C = {C:<6} {score:.3f}")
print(f"Mejor: C = {linear_search.best_params_['svc__C']} ({linear_search.best_score_:.3f})\n")
rbf_search = GridSearchCV(make_pipeline(StandardScaler(), SVC(kernel='rbf')),
{'svc__C': [0.01, 0.1, 1, 10, 100, 1000], 'svc__gamma': [0.001, 0.01, 0.1, 1]}, cv=cv)
rbf_search.fit(X_train, y_train)
grid = pd.DataFrame(rbf_search.cv_results_).pivot_table(
index='param_svc__C', columns='param_svc__gamma', values='mean_test_score')
grid.index.name, grid.columns.name = 'C', 'gamma'
print("Kernel RBF, exactitud media de validación:")
print(grid.round(3).to_string())
print(f"Mejor: C = {rbf_search.best_params_['svc__C']}, gamma = {rbf_search.best_params_['svc__gamma']} "
f"({rbf_search.best_score_:.3f})")
Kernel lineal, exactitud media de validación para cada C: C = 0.001 0.738 C = 0.01 0.808 C = 0.1 0.839 C = 1 0.832 C = 10 0.832 C = 100 0.831 C = 1000 0.831 Mejor: C = 0.1 (0.839)
Kernel RBF, exactitud media de validación: gamma 0.001 0.010 0.100 1.000 C 0.01 0.738 0.738 0.738 0.738 0.10 0.738 0.738 0.783 0.738 1.00 0.738 0.832 0.831 0.771 10.00 0.832 0.832 0.826 0.750 100.00 0.834 0.838 0.809 0.734 1000.00 0.833 0.826 0.791 0.734 Mejor: C = 100, gamma = 0.01 (0.838)
La tabla muestra los dos peligros que ya conocemos. En la esquina superior izquierda (C pequeña y gamma pequeña) el modelo es demasiado rígido y predice que todo el mundo acude (0,738). En la columna de la derecha (gamma grande) la exactitud de validación vuelve a bajar, porque la frontera se curva alrededor de citas individuales. El mejor modelo RBF (0,838) no mejora al mejor lineal (0,839). No es una sorpresa: la regla oculta que generó los datos es, salvo por las últimas horas de la tarde, una combinación lineal de las variables, así que la flexibilidad extra del kernel RBF no aporta nada.
Paso 7: Comparación final en el conjunto de prueba¶
Ahora, y solo ahora, usamos el conjunto de prueba, una sola vez, para comparar los modelos elegidos con la regresión logística de los cuadernos anteriores. Añadimos también una SVM lineal con class_weight='balanced', que da más peso a los errores en la clase minoritaria. El AUC de una SVM se calcula con su puntuación, decision_function, ya que no produce probabilidades.
best_C = linear_search.best_params_['svc__C']
models = {
'Regresión logística': make_pipeline(StandardScaler(), LogisticRegression()).fit(X_train, y_train),
f'SVM lineal (C = {best_C})': linear_search.best_estimator_,
'SVM RBF (C = {svc__C}, gamma = {svc__gamma})'.format(**rbf_search.best_params_): rbf_search.best_estimator_,
"SVM lineal, class_weight='balanced'": make_pipeline(
StandardScaler(), SVC(kernel='linear', C=best_C, class_weight='balanced')).fit(X_train, y_train),
}
rows = []
for name, model in models.items():
y_hat = model.predict(X_test)
score = model.predict_proba(X_test)[:, 1] if hasattr(model, 'predict_proba') else model.decision_function(X_test)
tn, fp, fn, tp = confusion_matrix(y_test, y_hat).ravel()
rows.append({'modelo': name, 'exactitud': accuracy_score(y_test, y_hat),
'sensibilidad': tp / (tp + fn), 'especificidad': tn / (tn + fp), 'AUC': roc_auc_score(y_test, score)})
print(pd.DataFrame(rows).set_index('modelo').round(3).to_string())
exactitud sensibilidad especificidad AUC modelo Regresión logística 0.827 0.620 0.900 0.880 SVM lineal (C = 0.1) 0.833 0.582 0.923 0.884 SVM RBF (C = 100, gamma = 0.01) 0.817 0.608 0.891 0.876 SVM lineal, class_weight='balanced' 0.770 0.810 0.756 0.874
En el conjunto de prueba los modelos están muy cerca. La SVM lineal tiene la mayor exactitud y el mayor AUC (0,833 y 0,884), con la regresión logística justo detrás, y la SVM RBF, como anticipaba la validación cruzada, no las mejora. Con class_weight='balanced' la SVM lineal detecta el 81% de las ausencias (sensibilidad de 0,810) a cambio de más falsas alarmas, el mismo equilibrio que obtuvimos en los otros cuadernos moviendo el umbral. Eso sí, hay que tener presente la escala: un punto de exactitud sobre 300 citas son solo tres citas.
Paso 8: Probabilidades con una SVM¶
Una SVM da una puntuación, no una probabilidad. Si necesitamos una, SVC(probability=True) calibra la puntuación: ajusta una sigmoide que convierte la puntuación en probabilidad (el escalado de Platt), usando una validación cruzada interna. Es más lento de entrenar y la probabilidad es un añadido, no forma parte del modelo en sí. Comparémosla con la regresión logística para tres citas nuevas:
calibrated = make_pipeline(StandardScaler(), SVC(kernel='linear', C=best_C, probability=True, random_state=42))
calibrated.fit(X_train, y_train)
new_appointments = pd.DataFrame([
[35, 100, 18, 18, 1, 0],
[33, 80, 14, 17, 1, 0],
[62, 30, 6, 9, 0, 0],
], columns=feature_names)
result = new_appointments.copy()
result['puntuación SVM'] = calibrated.decision_function(new_appointments).round(3)
result['P(ausencia) SVM'] = calibrated.predict_proba(new_appointments)[:, 1].round(3)
result['P(ausencia) logística'] = models['Regresión logística'].predict_proba(new_appointments)[:, 1].round(3)
print(result.to_string(index=False))
edad dias_desde_ultima_cita distancia_km hora primera_visita ausencias_previas puntuación SVM P(ausencia) SVM P(ausencia) logística 35 100 18 18 1 0 0.043 0.534 0.568 33 80 14 17 1 0 -0.464 0.347 0.360 62 30 6 9 0 0 -3.767 0.003 0.003
Las probabilidades calibradas de la SVM son muy parecidas a las de la regresión logística. La primera cita está en el límite (puntuación 0,043, probabilidad 0,534); la segunda tiene una probabilidad de alrededor de un tercio en los dos modelos, así que solo se marcaría con un umbral más bajo; la tercera casi seguro que se cumplirá. Fíjate en que predict usa el signo de la puntuación, no la probabilidad calibrada, así que en los casos límite los dos pueden no coincidir.
Conclusión¶
SVCresuelve el problema de margen suave del libro; suCes el $\lambda$ del libro (y no su inversa, como enLogisticRegression).- El truco del kernel permite a una SVM trazar fronteras curvas calculando productos escalares en un espacio transformado sin construirlo. El kernel RBF puede seguir casi cualquier forma, y su
gammacontrola lo mucho que se ciñe a los datos de entrenamiento. Cygammase eligen con validación cruzada (GridSearchCVcon grupos estratificados), y el conjunto de prueba se usa una sola vez, al final.- Con los datos de ausencias, cuyo patrón es esencialmente lineal, la SVM lineal y la regresión logística rinden igual y la flexibilidad del kernel RBF no ayuda. Elegir un modelo consiste en probar los candidatos con honestidad y quedarse con el más sencillo que funcione, que es justo lo que tiene que hacer el equipo de Minermont.