3.5 - Notas de Luis - Máquinas de vectores de soporte (SVM) desde cero¶
Una regresión logística traza una frontera entre las dos clases y convierte la distancia a esa frontera en una probabilidad. Una máquina de vectores de soporte (SVM) también traza una frontera, pero la elige con otro criterio: de todas las rectas que separan las clases, busca la que deja el margen más ancho, la "calle" más ancha sin ningún punto dentro. Los puntos que quedan en el borde de la calle, los vectores de soporte, son los únicos que deciden por dónde pasa la frontera.
En este cuaderno implementamos nosotros mismos una SVM lineal, siguiendo la formulación de las notas de Marta (sección 3.4):
- Importar las bibliotecas.
- El ejemplo del libro: dos clases en el plano.
- La función de coste: la pérdida bisagra (hinge loss).
- Entrenamiento con descenso del gradiente.
- Margen suave: el papel de $\lambda$.
- De vuelta a las ausencias de Fisioterapia.
- Elegir $\lambda$ con validación cruzada de k grupos.
- Evaluación y comparación con la regresión logística.
Paso 1: Importar las bibliotecas¶
Como en los demás cuadernos desde cero, solo usamos scikit-learn para dividir los datos. Todo lo demás es NumPy.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split # solo para dividir los datos
np.set_printoptions(precision=3, suppress=True)
Paso 2: El ejemplo del libro¶
Empezamos con los 14 puntos de la figura de la SVM lineal de la sección 3.4: siete de la clase positiva y siete de la clase negativa. Una SVM etiqueta las clases como $y = +1$ e $y = -1$ (en lugar de 1 y 0), porque así las fórmulas quedan más sencillas.
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))
plt.figure(figsize=(6, 5))
plt.scatter(*positive.T, color='tab:blue', s=60, label='clase +1')
plt.scatter(*negative.T, color='tab:red', marker='s', s=60, label='clase -1')
plt.xlabel('$x_1$')
plt.ylabel('$x_2$')
plt.title('Dos clases separables linealmente')
plt.legend()
plt.grid(True)
plt.show()
Hay infinitas rectas que separan estos dos grupos. La SVM busca la frontera $f(x) = w \cdot x + b = 0$ que cumple
$$y_i \, (w \cdot x_i + b) \geq 1 \quad \text{para todo punto } i$$
es decir, los puntos positivos deben tener $f(x) \geq 1$ y los negativos $f(x) \leq -1$. Las dos rectas discontinuas $f(x) = +1$ y $f(x) = -1$ son los bordes del margen, y la distancia entre ellas es $\frac{2}{\|w\|}$. Por eso minimizar $\frac{1}{2}\|w\|^2$ maximiza el margen.
Paso 3: La función de coste¶
Los datos reales casi nunca son perfectamente separables, así que el libro introduce el margen suave: cada punto puede incumplir la condición en una cantidad $\xi_i \geq 0$ (su holgura), y cada incumplimiento se paga:
$$\min_{w, b} \quad \frac{1}{2}\|w\|^2 + \lambda \sum_{i=1}^{m} \xi_i \qquad \text{con} \qquad \xi_i = \max\big(0,\; 1 - y_i (w \cdot x_i + b)\big)$$
La expresión $\max(0, 1 - y_i f(x_i))$ es la pérdida bisagra (hinge loss). Vale cero para un punto que está en el lado correcto y fuera del margen, y crece linealmente cuando el punto entra en el margen o cruza al lado equivocado. Comparémosla con la pérdida logarítmica de la regresión logística, en función de $y \cdot f(x)$ (positivo cuando el punto está bien clasificado):
def hinge_loss(y, f):
return np.maximum(0, 1 - y * f)
yf = np.linspace(-2, 3, 200)
plt.figure(figsize=(7, 4))
plt.plot(yf, hinge_loss(1, yf), label='pérdida bisagra (SVM)')
plt.plot(yf, np.log(1 + np.exp(-yf)), label='pérdida logarítmica (regresión logística)')
plt.axvline(0, color='gray', linewidth=0.8)
plt.axvline(1, color='gray', linestyle='--', linewidth=0.8)
plt.xlabel('y · f(x) (negativo = lado equivocado, más de 1 = fuera del margen)')
plt.ylabel('Coste del punto')
plt.title('Pérdida bisagra y pérdida logarítmica')
plt.legend()
plt.grid(True)
plt.show()
Las dos castigan los puntos que están en el lado equivocado, pero hay una diferencia clave: la pérdida bisagra vale exactamente cero más allá del margen. Los puntos que están bien clasificados con holgura no aportan nada, así que solo los puntos del margen o de su interior (los vectores de soporte) dan forma a la frontera. La pérdida logarítmica nunca llega a cero: en la regresión logística todos los puntos tiran un poco.
Paso 4: Entrenamiento con descenso del gradiente¶
La pérdida bisagra tiene un pico en $y f(x) = 1$, pero fuera de ese punto su pendiente es sencilla. Para cada punto dentro del margen ($y_i f(x_i) < 1$), la derivada de su término es $-y_i x_i$ respecto a $w$ y $-y_i$ respecto a $b$; los puntos fuera del margen aportan cero. Así que el gradiente del coste es:
$$\frac{\partial J}{\partial w} = w - \lambda \sum_{i:\, y_i f(x_i) < 1} y_i x_i \qquad\qquad \frac{\partial J}{\partial b} = -\lambda \sum_{i:\, y_i f(x_i) < 1} y_i$$
Tres detalles prácticos, que ya aparecieron en los cuadernos anteriores:
- Dividimos el coste por $\lambda m$. Esto no mueve el mínimo, pero mantiene parecido el tamaño del gradiente para cualquier $\lambda$ y cualquier número de puntos, de modo que la misma tasa de aprendizaje sirve en todos los casos.
- Por culpa del pico, el descenso del gradiente rebota alrededor del mínimo. Reducimos la tasa de aprendizaje a medida que avanzan las iteraciones: $\alpha_t = \frac{\alpha}{1 + t/1000}$.
- Centramos las características (les restamos su media) dentro de la función, lo que acelera mucho la convergencia. Al final, devolvemos el sesgo a las unidades originales.
def svm_cost(X, y, w, b, lam):
"""El coste de margen suave del libro: 1/2 ||w||^2 + lambda * suma de las pérdidas bisagra."""
return 0.5 * w @ w + lam * hinge_loss(y, X @ w + b).sum()
def train_svm(X, y, lam, learning_rate=1.0, num_iterations=50000, decay=1000):
m, n = X.shape
mu = X.mean(axis=0)
Xc = X - mu # características centradas
w = np.zeros(n)
b = 0.0
for t in range(num_iterations):
inside = y * (Xc @ w + b) < 1 # puntos dentro del margen (o en el lado equivocado)
grad_w = w / (lam * m) - (y[inside, None] * Xc[inside]).sum(axis=0) / m
grad_b = -y[inside].sum() / m
alpha = learning_rate / (1 + t / decay) # tasa de aprendizaje decreciente
w = w - alpha * grad_w
b = b - alpha * grad_b
return w, b - w @ mu # sesgo en las unidades originales
w, b = train_svm(X_toy, y_toy, lam=10)
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 / ||w|| = {2 / np.linalg.norm(w):.3f}")
print(f"Coste final: {svm_cost(X_toy, y_toy, w, b, lam=10):.3f}")
w = [-0.801 1.602], b = -2.203 Frontera: x2 = 0.500 x1 + 1.375 Anchura del margen: 2 / ||w|| = 1.117 Coste final: 1.604
La frontera que hemos encontrado es la recta de la figura del libro, $x_2 = 0{,}5\,x_1 + 1{,}375$. ¿Qué puntos son los vectores de soporte? Los que tienen $y_i f(x_i) \approx 1$, justo en el borde del margen:
f_toy = X_toy @ w + b
support = y_toy * f_toy < 1 + 1e-2
for x, label, f in zip(X_toy[support], y_toy[support], f_toy[support]):
print(f"Vector de soporte {x}, clase {label:+d}: f(x) = {f:+.3f}")
def plot_svm(X, y, w, b, title, ax):
xs = np.linspace(0, 7, 2)
ax.scatter(*X[y == 1].T, color='tab:blue', s=60, label='clase +1')
ax.scatter(*X[y == -1].T, color='tab:red', marker='s', s=60, label='clase -1')
ax.plot(xs, -(w[0] * xs + b) / w[1], 'k-', label='frontera f(x) = 0')
for level in (1, -1):
ax.plot(xs, -(w[0] * xs + b - level) / w[1], 'k--', linewidth=1)
sv = y * (X @ w + b) < 1 + 1e-2
ax.scatter(*X[sv].T, s=250, facecolors='none', edgecolors='k', label='vectores de soporte')
ax.set_xlim(0, 7)
ax.set_ylim(0, 7)
ax.set_xlabel('$x_1$')
ax.set_ylabel('$x_2$')
ax.set_title(title)
ax.grid(True)
fig, ax = plt.subplots(figsize=(6, 5))
plot_svm(X_toy, y_toy, w, b, f'Margen máximo: {2 / np.linalg.norm(w):.3f}', ax)
ax.legend(loc='upper right')
plt.show()
Vector de soporte [3.5 3.75], clase +1: f(x) = +1.000 Vector de soporte [4. 2.75], clase -1: f(x) = -1.002
Solo dos puntos sujetan la frontera. Podríamos mover o eliminar cualquiera de los otros doce (sin que entrara en el margen) y la solución no cambiaría en absoluto.
Paso 5: Margen suave, el papel de $\lambda$¶
El parámetro $\lambda$ fija el precio de cada incumplimiento del margen. El libro lo explica así: si $\lambda$ es grande, el modelo penaliza más los errores y se ajusta más a los datos; si $\lambda$ es pequeño, permite un margen más amplio y tolera algunos errores a cambio de generalizar mejor.
Para verlo, añadimos un único punto sospechoso: uno de la clase $+1$ situado entre los negativos, que podría ser un error de etiquetado o un caso raro. Entrenamos con $\lambda = 100$ y con $\lambda = 1$:
X_out = np.vstack([X_toy, [4.5, 3.0]])
y_out = np.append(y_toy, 1)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
for lam, ax in zip([100, 1], axes):
w_l, b_l = train_svm(X_out, y_out, lam=lam)
f = X_out @ w_l + b_l
errors = np.sum(np.sign(f) != y_out)
margin = 2 / np.linalg.norm(w_l)
print(f"lambda = {lam:>3}: margen = {margin:.3f}, puntos mal clasificados = {errors}, "
f"frontera x2 = {-w_l[0] / w_l[1]:.3f} x1 + {-b_l / w_l[1]:.3f}")
plot_svm(X_out, y_out, w_l, b_l, f'lambda = {lam}: margen {margin:.2f}, errores: {errors}', ax)
ax.scatter(4.5, 3.0, s=500, marker='*', color='gold', edgecolors='k', zorder=3, label='punto nuevo')
axes[1].legend(loc='center left', bbox_to_anchor=(1.02, 0.5))
plt.show()
lambda = 100: margen = 0.395, puntos mal clasificados = 0, frontera x2 = -0.333 x1 + 4.291
lambda = 1: margen = 1.655, puntos mal clasificados = 1, frontera x2 = 0.149 x1 + 2.393
Con $\lambda = 100$ un error es tan caro que el modelo inclina la frontera para clasificar bien el punto nuevo, y el margen se reduce a una calle estrecha: el modelo se ha desvivido por un único punto. Con $\lambda = 1$ acepta un error y mantiene un margen mucho más ancho, apoyado en más puntos. ¿Cuál es mejor? Depende de si ese punto es un patrón real o ruido, y la forma de averiguarlo es la misma que con la regularización: la validación cruzada.
Fíjate en que el $\lambda$ de la SVM funciona al revés que el $\lambda$ de la regularización en la regresión logística: allí, un $\lambda$ mayor significaba un modelo más simple; aquí, un $\lambda$ mayor significa un modelo que se ajusta más a los datos de entrenamiento.
Paso 6: De vuelta a las ausencias de Fisioterapia¶
Aplicamos ahora nuestra SVM al problema del capítulo, con las mismas citas sintéticas que en los cuadernos de regresión logística:
| 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].to_numpy(dtype=float)
y = np.where(df['no_asiste'] == 1, 1, -1) # +1 = no acude, -1 = acude
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 ({np.mean(y_train == 1):.1%} de ausencias)")
print(f"Prueba: {len(y_test)} citas ({np.mean(y_test == 1):.1%} de ausencias)")
Entrenamiento: 900 citas (26.2% de ausencias) Prueba: 300 citas (26.3% de ausencias)
La SVM mide distancias, así que, igual que con la regresión logística, hay que escalar las características con la media y la desviación típica de los datos de entrenamiento únicamente.
Paso 7: Elegir $\lambda$ con validación cruzada de k grupos¶
Aquí implementamos a mano el procedimiento que describen las notas de Luis (sección 3.5):
- Dividir el conjunto de entrenamiento en $k = 5$ grupos estratificados: cada uno conserva la proporción de ausencias.
- Para cada valor de $\lambda$ de una rejilla logarítmica, entrenar 5 veces, cada vez con 4 grupos, y validar con el restante.
- Quedarse con el $\lambda$ con la mejor exactitud media de validación.
Para ser rigurosos, el escalado también se recalcula en cada ronda solo con los 4 grupos de entrenamiento, de modo que el grupo de validación no influye en nada. El conjunto de prueba no se usa para nada en este paso.
def scale(X_fit, *others):
"""Estandariza con la media y la desviación típica de X_fit."""
mean, std = X_fit.mean(axis=0), X_fit.std(axis=0)
return [(A - mean) / std for A in (X_fit, *others)]
def predict(X, w, b):
return np.where(X @ w + b >= 0, 1, -1)
def stratified_folds(y, k, seed=0):
"""Índices de k grupos, cada uno con la misma proporción de cada clase."""
rng = np.random.default_rng(seed)
folds = [[] for _ in range(k)]
for label in np.unique(y):
idx = rng.permutation(np.where(y == label)[0])
for j, part in enumerate(np.array_split(idx, k)):
folds[j].extend(part)
return [np.array(f) for f in folds]
def cross_val_accuracy(X, y, lam, k=5):
scores = []
for val_idx in stratified_folds(y, k):
train_idx = np.setdiff1d(np.arange(len(y)), val_idx)
X_tr, X_val = scale(X[train_idx], X[val_idx])
w, b = train_svm(X_tr, y[train_idx], lam, num_iterations=3000, decay=100)
scores.append(np.mean(predict(X_val, w, b) == y[val_idx]))
return np.mean(scores)
lambdas = [0.001, 0.01, 0.1, 1, 10, 100]
cv_scores = [cross_val_accuracy(X_train, y_train, lam) for lam in lambdas]
for lam, score in zip(lambdas, cv_scores):
print(f"lambda = {lam:<6} exactitud media de validación = {score:.3f}")
best_lambda = lambdas[int(np.argmax(cv_scores))]
print(f"\nMejor lambda: {best_lambda}")
lambda = 0.001 exactitud media de validación = 0.738 lambda = 0.01 exactitud media de validación = 0.818 lambda = 0.1 exactitud media de validación = 0.846 lambda = 1 exactitud media de validación = 0.837 lambda = 10 exactitud media de validación = 0.837 lambda = 100 exactitud media de validación = 0.834 Mejor lambda: 0.1
Con un $\lambda$ muy pequeño los errores salen casi gratis, así que los pesos se quedan cerca de cero y el modelo predice que casi todo el mundo acude: su exactitud es la de responder siempre "acude". A partir de $\lambda = 0{,}1$ la exactitud apenas cambia: con 900 citas y solo seis variables hay poco margen para sobreajustar, así que el valor exacto importa mucho menos que en el ejemplo pequeño.
Paso 8: Evaluación en el conjunto de prueba¶
Entrenamos con el $\lambda$ elegido sobre todo el conjunto de entrenamiento y evaluamos una sola vez en el conjunto de prueba, con las mismas métricas que en el cuaderno de regresión logística. Un punto se predice como ausencia cuando $f(x) \geq 0$.
X_train_s, X_test_s = scale(X_train, X_test)
w, b = train_svm(X_train_s, y_train, best_lambda, num_iterations=3000, decay=100)
def evaluate(y_true, scores, threshold=0.0):
"""Matriz de confusión y métricas clínicas; clase positiva = ausencia (+1)."""
y_pred = np.where(scores >= threshold, 1, -1)
tp = np.sum((y_pred == 1) & (y_true == 1))
fn = np.sum((y_pred == -1) & (y_true == 1))
fp = np.sum((y_pred == 1) & (y_true == -1))
tn = np.sum((y_pred == -1) & (y_true == -1))
return {
'umbral': threshold, 'VP': tp, 'FN': fn, 'FP': fp, 'VN': tn,
'exactitud': (tp + tn) / len(y_true),
'sensibilidad': tp / (tp + fn),
'especificidad': tn / (tn + fp),
'VPP': tp / (tp + fp),
'VPN': tn / (tn + fn),
}
scores_test = X_test_s @ w + b
r = evaluate(y_test, scores_test)
print("Matriz de confusión (conjunto de prueba):")
print(f" Predice ausencia Predice asistencia")
print(f" No acudió VP = {r['VP']:<13} FN = {r['FN']}")
print(f" Acudió FP = {r['FP']:<13} VN = {r['VN']}")
print()
for k in ['exactitud', 'sensibilidad', 'especificidad', 'VPP', 'VPN']:
print(f"{k:>14}: {r[k]:.3f}")
print(f"\nReferencia (predecir siempre 'acude'): exactitud = {np.mean(y_test == -1):.3f}")
Matriz de confusión (conjunto de prueba):
Predice ausencia Predice asistencia
No acudió VP = 46 FN = 33
Acudió FP = 17 VN = 204
exactitud: 0.833
sensibilidad: 0.582
especificidad: 0.923
VPP: 0.730
VPN: 0.861
Referencia (predecir siempre 'acude'): exactitud = 0.737
La SVM alcanza una exactitud de 0,833, muy cerca del 0,827 de la regresión logística desde cero (mismos datos, misma división). Con su umbral por defecto es algo más prudente: da menos falsas alarmas (más especificidad y VPP), pero detecta menos ausencias (menos sensibilidad).
¿Qué ha aprendido la SVM? Las características están escaladas, así que podemos comparar los pesos entre sí. El signo dice hacia dónde empuja cada variable: positivo, hacia la ausencia; negativo, hacia la asistencia.
weights = pd.Series(w, index=feature_names).sort_values(key=np.abs, ascending=False)
print(weights.round(3).to_string())
inside = y_train * (X_train_s @ w + b) < 1 + 1e-6
print(f"\nVectores de soporte (puntos en el margen o dentro de él): {inside.sum()} de {len(y_train)}")
edad -0.769 ausencias_previas 0.713 dias_desde_ultima_cita 0.709 distancia_km 0.571 primera_visita 0.222 hora 0.208 Vectores de soporte (puntos en el margen o dentro de él): 366 de 900
Las cuatro variables que más pesan son las mismas que en la regresión logística: la edad (negativa: los pacientes mayores faltan menos), las ausencias previas, los días desde la última cita y la distancia. La hora y la primera visita pesan menos. Los pesos son más pequeños que los de la regresión logística porque los dos modelos trabajan en escalas distintas, así que solo se pueden comparar sus tamaños relativos.
Fíjate también en el número de vectores de soporte. En el ejemplo limpio del libro había dos; aquí, donde las clases se solapan, 366 de las 900 citas están en el margen o dentro de él, y todas participan en la colocación de la frontera.
Mover el umbral. Una SVM no devuelve una probabilidad, solo una puntuación $f(x)$: su signo da la clase y su tamaño, lo lejos que está la cita de la frontera. Pero, igual que con la probabilidad de la regresión logística, podemos mover el umbral para detectar más ausencias:
rows = [evaluate(y_test, scores_test, t) for t in [0.0, -0.25, -0.5]]
table = pd.DataFrame(rows)[['umbral', 'VP', 'FN', 'FP', 'exactitud', 'sensibilidad', 'especificidad']]
print(table.round(3).to_string(index=False))
umbral VP FN FP exactitud sensibilidad especificidad 0.00 46 33 17 0.833 0.582 0.923 -0.25 53 26 26 0.827 0.671 0.882 -0.50 60 19 40 0.803 0.759 0.819
Bajar el umbral a −0,5 detecta 60 de las 79 ausencias (sensibilidad de 0,759), a cambio de 40 falsas alarmas. El equilibrio es el mismo que con la regresión logística, y es el hospital quien decide dónde situarlo. Si se necesita una probabilidad, por ejemplo para avisar a recepción de que es muy probable que un paciente no venga, hay que calibrar las puntuaciones de la SVM; scikit-learn puede hacerlo, como veremos en el siguiente cuaderno.
Conclusión¶
- Una SVM lineal busca la frontera con el margen más ancho. Minimizar $\frac{1}{2}\|w\|^2$ ensancha el margen, y la pérdida bisagra cobra por los puntos que entran en él.
- Solo los vectores de soporte deciden la frontera: los puntos claramente bien clasificados no aportan nada al coste.
- $\lambda$ fija el precio de cada error: un $\lambda$ grande se ajusta mucho a los datos de entrenamiento (margen estrecho) y uno pequeño tolera errores (margen ancho). Lo hemos elegido con validación cruzada de k grupos, sin tocar el conjunto de prueba.
- Con los datos de ausencias, la SVM lineal y la regresión logística llegan a un resultado muy parecido. Su ventaja aparece cuando la frontera no es una recta: el truco del kernel, que usaremos con scikit-learn en el siguiente cuaderno.