Generalizar: sobreajuste, validación y regularización

Tema 7 · Fase 2, Aprender de ejemplos · Toda la ruta

En esta ficha · 13 secciones
  1. La pregunta
  2. Actividad: ¿memoriza o aprende?
  3. Datos de una regla conocida
  4. Polinomios: una perilla de complejidad
  5. La curva en forma de U
  6. Sesgo y varianza
  7. Validación cruzada k-fold
  8. Regularización: castigar los pesos grandes
  9. Ridge y Lasso con datos reales
  10. ¿Por qué Lasso deja pesos en cero?
  11. Curva de aprendizaje: ¿más datos ayudarían?
  12. La receta completa con diabetes
  13. Lo que aprendiste y lo que sigue

La pregunta

Dos estudiantes se preparan para el mismo examen con la misma lista de 50 problemas resueltos. Una se aprende de memoria las 50 respuestas. El otro entiende cómo se resuelve cada tipo de problema. Si el examen trae exactamente esos 50 problemas, a los dos les va perfecto. Pero el examen trae problemas nuevos. ¿A quién le va mejor?

Con los modelos pasa lo mismo. En el tema 6 mediste el error del modelo de diabetes con los mismos pacientes con que lo entrenaste, y avisamos que eso era hacerse trampa: es corregir el examen con las preguntas que el modelo ya vio resueltas. Un modelo sirve si acierta en datos que nunca vio. A eso se le llama generalizar, y este tema trata de cómo medirlo y cómo lograrlo.

En este tema vas a:

  • dividir los datos en entrenamiento, validación y prueba, y saber para qué sirve cada parte;
  • reconocer el subajuste y el sobreajuste en las curvas de error;
  • aplicar la validación cruzada k-fold para elegir un modelo sin depender de la suerte de una sola división;
  • usar Ridge (L2) y Lasso (L1), y explicar por qué Lasso deja coeficientes exactamente en cero.

Necesitas lo del tema 6: el modelo ŷ = w·x + b, el MSE, la interfaz fit y predict de scikit-learn y la estandarización.

Actividad: ¿memoriza o aprende?

Los puntos llenos son los datos de entrenamiento: con ellos se ajusta la curva. Los puntos huecos son datos de validación: salen de la misma regla, pero el modelo no los ve al entrenar. La curva es un polinomio: con grado 1 es una recta, con grado 2 una parábola, y con grados más altos puede doblarse más veces. Las dos barras miden el error (el MSE) con cada grupo de puntos.

¿Memoriza o aprende?

Este es el estado inicial (grado 1, sin castigo). Con JavaScript activado puedes cambiar el grado, λ y los datos.

Grado 1, sin castigo. Error de entrenamiento 0,218, error de validación 0,336.

Prueba esto, en orden:

  1. Empieza en grado 1. ¿La recta sigue la forma de los puntos? ¿Las dos barras son altas o bajas?
  2. Sube el grado de a uno hasta 15. Mira la barra de entrenamiento: ¿alguna vez sube? Mira la de validación: ¿qué hace al principio y qué hace al final?
  3. Con grado 15, pulsa «Nuevos datos» varias veces. Las curvas claras son los ajustes con los datos anteriores. ¿Cuánto cambia la curva de un sorteo a otro? Haz lo mismo con grado 3.
  4. Deja el grado en 15 y sube λ (lambda) poco a poco. ¿Qué le pasa a la curva y a la barra de validación? ¿Y si subes λ al máximo?
  5. Activa «Ver la regla verdadera». ¿Qué grado y qué λ la imitan mejor?

Acabas de ver la idea central del tema: que un modelo se equivoque poco con los datos que vio no prueba que haya aprendido; puede haberlos memorizado. El error que importa es el de los datos nuevos, y hay dos perillas para controlarlo: la complejidad del modelo (el grado) y la regularización (λ).

Datos de una regla conocida

Como en el tema 6, empezamos con datos sintéticos: los fabricamos con una regla conocida, una onda y = sen(x), más un ruido al azar. Así podemos comparar lo que aprende el modelo con la verdad.

Y desde el principio, antes de entrenar nada, separamos los datos en tres grupos:

Grupo Para qué sirve Cuándo se usa
Entrenamiento Ajustar los parámetros del modelo (con fit) Siempre que se entrena
Validación Comparar modelos y elegir sus perillas (el grado, λ) Muchas veces, mientras se decide
Prueba Estimar cómo le irá al modelo elegido con datos nuevos Una sola vez, al final

train_test_split de scikit-learn mezcla los datos al azar y los parte en dos. Lo usamos dos veces: primero apartamos la prueba, y del resto sacamos la validación. Su argumento random_state es la semilla de la mezcla: con la misma semilla, la misma división.

import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split

# Semilla fija: cada vez que corras esto saldrán los mismos datos
rng = np.random.default_rng(1)
n = 40
x = rng.uniform(-3, 3, size=n)
# La regla «secreta»: una onda, más un ruido que nadie puede predecir
y = np.sin(x) + rng.normal(0, 0.3, size=n)
X = x.reshape(-1, 1)                  # scikit-learn quiere una tabla

# Primero se aparta la prueba (10 puntos): no se toca hasta el final
X_resto, X_prueba, y_resto, y_prueba = train_test_split(
    X, y, test_size=10, random_state=0)
# Del resto, 10 puntos para validación y 20 para entrenar
X_ent, X_val, y_ent, y_val = train_test_split(
    X_resto, y_resto, test_size=10, random_state=0)

print("Entrenamiento:", len(X_ent), "  validación:", len(X_val),
      "  prueba:", len(X_prueba))
Entrenamiento: 20   validación: 10   prueba: 10

¿Por qué tres grupos y no dos? Porque cada vez que miras el error de validación para decidir algo, ese grupo deja de ser del todo «nuevo»: lo estás usando para elegir. Si eliges entre muchos modelos, alguno va a tener suerte con esos diez puntos. La prueba queda guardada para el final, para tener una medida honesta del modelo que elegiste.

Polinomios: una perilla de complejidad

Una recta no puede seguir una onda. Para doblar la curva usamos un polinomio de grado g:

y^=w1x+w2x2+⋯+wgxg+b

El truco es que esto sigue siendo una regresión lineal: es lineal en los parámetros w. Basta con inventar columnas nuevas, x², x³, …, y dárselas a LinearRegression como si fueran características distintas. PolynomialFeatures hace exactamente eso:

from sklearn.preprocessing import PolynomialFeatures

# Las potencias de x, de 1 a 3, para x = 2 y x = 3
potencias = PolynomialFeatures(degree=3, include_bias=False)
print(potencias.fit_transform([[2], [3]]))
[[ 2.  4.  8.]
 [ 3.  9. 27.]]

Con x = 3 y grado 15, la última columna vale 3¹⁵, más de 14 millones, mientras que la primera vale 3. Columnas con escalas tan distintas son un problema: lo viste en el tema 6 con el valle alargado, y aquí además hacen inexactas las cuentas de la computadora. Por eso, después de calcular las potencias, las estandarizamos con StandardScaler (media 0 y desviación 1, como en los temas 5 y 6).

Para no repetir los tres pasos cada vez, los encadenamos con make_pipeline. El resultado se usa como cualquier modelo: fit calcula las potencias, aprende la media y la desviación de cada columna con los datos de entrenamiento y ajusta la regresión; predict aplica los mismos pasos a datos nuevos. (En el tema 10 vas a ver los pipelines con más detalle.)

from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import make_pipeline
from sklearn.metrics import mean_squared_error

def polinomio(grado):
    """Regresión polinomial: potencias de x, estandarizar, recta."""
    return make_pipeline(PolynomialFeatures(grado, include_bias=False),
                         StandardScaler(), LinearRegression())

# Una cuadrícula fina de x para dibujar las curvas
x_dib = np.linspace(-3, 3, 300).reshape(-1, 1)

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8),
                         sharex=True, sharey=True)
for eje, grado in zip(ejes.flat, [1, 3, 9, 15]):
    modelo = polinomio(grado).fit(X_ent, y_ent)
    e_ent = mean_squared_error(y_ent, modelo.predict(X_ent))
    e_val = mean_squared_error(y_val, modelo.predict(X_val))
    print(f"Grado {grado:2d}:  MSE entrenamiento = {e_ent:.3f}"
          f"   MSE validación = {e_val:.3f}")
    # Solo el grado que mejor generaliza va resaltado (color "C1")
    color = "C1" if grado == 3 else "C0"
    eje.scatter(X_ent, y_ent, s=14, color="C0")
    eje.scatter(X_val, y_val, s=14, facecolors="none", edgecolors="C2")
    eje.plot(x_dib, modelo.predict(x_dib), color=color)
    eje.set_title(f"Grado {grado}")
    eje.set_ylim(-2, 2)
for eje in ejes[1]:
    eje.set_xlabel("x")
for eje in ejes[:, 0]:
    eje.set_ylabel("y")
plt.show()
Grado  1:  MSE entrenamiento = 0.232   MSE validación = 0.292
Grado  3:  MSE entrenamiento = 0.037   MSE validación = 0.029
Grado  9:  MSE entrenamiento = 0.031   MSE validación = 0.059
Grado 15:  MSE entrenamiento = 0.014   MSE validación = 1.253

Cuatro paneles en una rejilla de dos por dos, con los mismos 20 puntos llenos de entrenamiento y 10 puntos huecos de validación, que forman una onda. Grado 1: una recta inclinada que no sigue la onda. Grado 3, resaltado: una curva suave que sigue la onda. Grado 9: parecida, con alguna ondulación de más. Grado 15: una curva que se retuerce para pasar cerca de cada punto lleno y se dispara cerca de los bordes.

Los números y los dibujos cuentan la misma historia:

  • Grado 1, subajuste. La recta es demasiado simple para una onda. Se equivoca mucho con los dos grupos de puntos: no le alcanza ni para los datos que vio. Se dice que el modelo tiene sesgo alto (ojo: es otro sentido de la palabra, no el intercepto b del tema 6; aquí «sesgo» es un error sistemático por usar un modelo demasiado rígido).
  • Grado 3, buen ajuste. Sigue la forma de la onda y se equivoca parecido con los puntos que vio y con los nuevos.
  • Grado 15, sobreajuste. Tiene el error más chico con los puntos de entrenamiento, pero el más grande con los de validación. Se retorció para pasar cerca de cada punto lleno, incluido su ruido. Memorizó en lugar de aprender la forma.

La curva en forma de U

Probemos todos los grados del 1 al 15 y guardemos los dos errores:

grados = range(1, 16)
err_ent, err_val = [], []
for grado in grados:
    modelo = polinomio(grado).fit(X_ent, y_ent)
    err_ent.append(mean_squared_error(y_ent, modelo.predict(X_ent)))
    err_val.append(mean_squared_error(y_val, modelo.predict(X_val)))

mejor = grados[np.argmin(err_val)]
print("Grado con menor error de validación:", mejor)
for grado in [1, 3, 6, 12, 15]:
    print(f"Grado {grado:2d}:  entrenamiento {err_ent[grado - 1]:.3f}"
          f"   validación {err_val[grado - 1]:.3f}")

plt.plot(grados, err_ent, color="C2", marker="o")
plt.plot(grados, err_val, color="C0", marker="o")
plt.scatter([mejor], [min(err_val)], color="C1", s=160, zorder=3)
plt.yscale("log")
plt.ylim(0.006, 4)
# Nombres escritos junto a cada curva, en lugar de una leyenda
plt.text(15, 1.8, "Validación", ha="right")
plt.text(15, 0.008, "Entrenamiento", ha="right", color="C2")
plt.text(3.4, 0.016, "el mejor grado")
plt.xlabel("Grado del polinomio")
plt.ylabel("MSE (escala logarítmica)")
plt.title("Error según la complejidad")
plt.show()
Grado con menor error de validación: 3
Grado  1:  entrenamiento 0.232   validación 0.292
Grado  3:  entrenamiento 0.037   validación 0.029
Grado  6:  entrenamiento 0.031   validación 0.054
Grado 12:  entrenamiento 0.016   validación 0.951
Grado 15:  entrenamiento 0.014   validación 1.253

Dos curvas en escala logarítmica según el grado, de 1 a 15. La de entrenamiento baja casi todo el tiempo. La de validación baja con fuerza de grado 1 a grado 3, donde está su mínimo, resaltado; se mantiene baja unos grados y después sube, sobre todo desde el grado 12.

Esta es la figura más importante del tema. Fíjate en tres cosas:

  1. El error de entrenamiento casi no deja de bajar. Cada grado nuevo agrega una perilla, y con más perillas siempre se puede pasar igual o más cerca de los puntos que se ven. Por eso el error de entrenamiento no sirve para elegir el grado: siempre elegiría el más alto.
  2. El error de validación forma una U. A la izquierda, el modelo es demasiado simple (subajuste). A la derecha, es tan flexible que memoriza el ruido (sobreajuste). El fondo de la U es el mejor equilibrio.
  3. La distancia entre las dos curvas es el síntoma del sobreajuste. Si el error de entrenamiento es mucho menor que el de validación, el modelo se está aprendiendo los datos de memoria.

Una curva de validación es justamente esto: el error de entrenamiento y el de validación frente a una perilla del modelo. scikit-learn la calcula con validation_curve; aquí la programamos con un bucle para ver cada paso.

Sesgo y varianza

El botón «Nuevos datos» de la actividad mostraba otra cara del sobreajuste: el modelo de grado 15 cambiaba muchísimo de un sorteo de datos a otro. Midámoslo. Vamos a sacar 200 muestras distintas de 20 puntos de la misma regla, a entrenar con cada una y a mirar qué predice cada modelo en x = 2, donde la verdad es sen(2) ≈ 0,91.

def nuevos_datos(semilla, n=20):
    """Otra muestra de la misma regla: sen(x) más ruido."""
    r = np.random.default_rng(semilla)
    x_n = r.uniform(-3, 3, size=n)
    return x_n.reshape(-1, 1), np.sin(x_n) + r.normal(0, 0.3, size=n)

x0 = [[2.0]]                          # el punto donde miramos
for grado in [1, 3, 15]:
    preds = []
    for semilla in range(200):
        X_n, y_n = nuevos_datos(semilla)
        modelo = polinomio(grado).fit(X_n, y_n)
        preds.append(modelo.predict(x0)[0])
    preds = np.array(preds)
    print(f"Grado {grado:2d}:  mediana {np.median(preds):5.2f}"
          f"   la mitad central entre {np.percentile(preds, 25):5.2f}"
          f" y {np.percentile(preds, 75):5.2f}")
Grado  1:  mediana  0.69   la mitad central entre  0.57 y  0.81
Grado  3:  mediana  0.98   la mitad central entre  0.89 y  1.04
Grado 15:  mediana  0.95   la mitad central entre  0.54 y  1.29

Usamos la mediana y la «mitad central» (del percentil 25 al 75) en lugar de la media y la desviación estándar, porque unas pocas predicciones del grado 15 son tan disparatadas que arruinarían el promedio. Esos números muestran las dos formas de equivocarse:

  • Sesgo: el grado 1 se equivoca de forma sistemática. Sea cual sea la muestra, predice algo lejos de 0,91: una recta no puede imitar una onda. Más datos no lo arreglan.
  • Varianza: el grado 15 tiene la mediana cerca de la verdad, pero cada muestra lo lleva a un lugar distinto. Su predicción depende tanto de los puntos que le tocaron que no se puede confiar en ninguna en particular.
  • El grado 3 tiene poco de los dos.

Un modelo demasiado simple tiene sesgo alto; uno demasiado flexible para la cantidad de datos que tiene, varianza alta. Bajar uno suele subir el otro: es el equilibrio entre sesgo y varianza, y el fondo de la U es donde está mejor repartido. Más datos ayudan con la varianza (lo verás en la curva de aprendizaje); el sesgo solo se arregla con un modelo más flexible.

Validación cruzada k-fold

Nuestro conjunto de validación tiene solo 10 puntos. Si la división al azar hubiera sido otra, el error de validación habría salido distinto, y quizás hasta otro grado ganador. Veámoslo con el grado 3 y cinco semillas de división:

for semilla in range(5):
    Xe, Xv, ye, yv = train_test_split(X_resto, y_resto, test_size=10,
                                      random_state=semilla)
    modelo = polinomio(3).fit(Xe, ye)
    print(f"random_state={semilla}:  MSE de validación = "
          f"{mean_squared_error(yv, modelo.predict(Xv)):.3f}")
random_state=0:  MSE de validación = 0.029
random_state=1:  MSE de validación = 0.061
random_state=2:  MSE de validación = 0.055
random_state=3:  MSE de validación = 0.027
random_state=4:  MSE de validación = 0.047

El mismo modelo, con los mismos datos, recibe una nota u otra según la suerte de la división: la peor es más del doble de la mejor. La validación cruzada k-fold reparte esa suerte:

  1. Se parten los datos (sin la prueba) en k grupos del mismo tamaño, llamados folds.
  2. Se entrena k veces: cada vez, un grupo distinto hace de validación y los otros k − 1 de entrenamiento.
  3. Se promedian los k errores de validación.

Así cada punto se usa una vez para validar y el resultado ya no depende de una sola división. Lo más común es k = 5 o k = 10. El precio es entrenar k veces.

KFold define los grupos (con shuffle=True los mezcla antes, con su propia semilla) y cross_val_score hace todo el ciclo. Por una convención de scikit-learn, siempre maximiza sus puntajes, así que el MSE viene con el signo cambiado: "neg_mean_squared_error". Le volvemos a cambiar el signo para leerlo.

from sklearn.model_selection import KFold, cross_val_score

cinco = KFold(n_splits=5, shuffle=True, random_state=0)

resultados = []
for grado in grados:
    puntajes = cross_val_score(polinomio(grado), X_resto, y_resto,
                               cv=cinco, scoring="neg_mean_squared_error")
    resultados.append(-puntajes.mean())     # el MSE, con su signo

grado_cv = grados[np.argmin(resultados)]
for grado in [1, 2, 3, 4, 8, 15]:
    print(f"Grado {grado:2d}:  MSE de validación cruzada = "
          f"{resultados[grado - 1]:.3f}")
print("Grado elegido por validación cruzada:", grado_cv)
Grado  1:  MSE de validación cruzada = 0.296
Grado  2:  MSE de validación cruzada = 0.307
Grado  3:  MSE de validación cruzada = 0.045
Grado  4:  MSE de validación cruzada = 0.048
Grado  8:  MSE de validación cruzada = 0.069
Grado 15:  MSE de validación cruzada = 20.008
Grado elegido por validación cruzada: 3

La validación cruzada usó los 30 puntos que no son de prueba, en cinco rondas de 24 para entrenar y 6 para validar. Elige el grado 3, el mismo que eligió nuestra validación de 10 puntos, pero ahora con más respaldo.

Recién ahora, con el grado decidido, usamos la prueba, una sola vez. Entrenamos el modelo elegido con todos los datos que no son de prueba y medimos:

final = polinomio(grado_cv).fit(X_resto, y_resto)
error_prueba = mean_squared_error(y_prueba, final.predict(X_prueba))
print(f"Grado {grado_cv}, MSE con los datos de prueba: {error_prueba:.3f}")
# El MSE de la regla verdadera, sen(x), con estos mismos 10 puntos
print(f"MSE de sen(x) con los datos de prueba: "
      f"{mean_squared_error(y_prueba, np.sin(X_prueba[:, 0])):.3f}")
Grado 3, MSE con los datos de prueba: 0.044
MSE de sen(x) con los datos de prueba: 0.055

Para comparar, calculamos también el error de la regla verdadera, sen(x), que conocemos solo porque fabricamos los datos. Ni ella llega a cero: el ruido no se puede predecir. Como el ruido tiene una desviación de 0,3, ningún modelo puede bajar, en promedio y con muchos datos nuevos, de un MSE de 0,3² = 0,09. Ese piso se llama error irreducible. Aquí los dos números salieron por debajo de 0,09, y el polinomio hasta le ganó por poco a la regla verdadera: con solo diez puntos de prueba, también hay suerte. Lo que sí dicen es que el grado 3 se equivoca con datos nuevos tanto como la verdad misma, que es lo mejor que se puede pedir.

Regularización: castigar los pesos grandes

Elegir un grado bajo es una forma de evitar el sobreajuste. Hay otra que permite usar un modelo flexible sin que se descontrole: la regularización. La idea es cambiar lo que el entrenamiento minimiza. Además de equivocarse poco, le pedimos al modelo que sus pesos sean chicos:

pérdida=MSE+λ∑j=1dwj2

Esto se llama Ridge o regularización L2 (por la suma de cuadrados de los pesos). El número λ (lambda) dice cuánto pesa el castigo: con λ = 0 es la regresión de siempre; con λ muy grande, lo más barato es dejar todos los pesos casi en cero, y la curva se vuelve casi plana. El intercepto b no se castiga: solo mueve la curva arriba o abajo y no la hace más retorcida.

¿Por qué pesos chicos dan curvas más suaves? La curva de grado 15 sin regularizar logra pasar cerca de cada punto sumando potencias con pesos enormes, de signos opuestos, que casi se cancelan; donde dejan de cancelarse, la curva se dispara. Con el castigo, esos pesos enormes salen caros.

En scikit-learn el modelo se llama Ridge y λ se llama alpha. (scikit-learn escribe la pérdida con constantes un poco distintas, así que el valor de alpha no es exactamente el mismo λ; la idea es la misma.)

from sklearn.linear_model import Ridge

def polinomio_ridge(grado, alpha):
    """Como polinomio(), pero con Ridge en lugar de LinearRegression."""
    return make_pipeline(PolynomialFeatures(grado, include_bias=False),
                         StandardScaler(), Ridge(alpha=alpha))

for alpha in [0.001, 0.01, 0.1, 1, 10, 100]:
    modelo = polinomio_ridge(15, alpha).fit(X_ent, y_ent)
    e_ent = mean_squared_error(y_ent, modelo.predict(X_ent))
    e_val = mean_squared_error(y_val, modelo.predict(X_val))
    print(f"alpha = {alpha:7}:  entrenamiento {e_ent:.3f}"
          f"   validación {e_val:.3f}")

sin_reg = polinomio(15).fit(X_ent, y_ent)
con_reg = polinomio_ridge(15, 0.1).fit(X_ent, y_ent)
plt.scatter(X_ent, y_ent, s=18, color="C0")
plt.scatter(X_val, y_val, s=18, facecolors="none", edgecolors="C2")
plt.plot(x_dib, sin_reg.predict(x_dib), color="C2",
         label="Sin regularizar")
plt.plot(x_dib, con_reg.predict(x_dib), color="C1",
         label="Ridge, alpha = 0,1")
# Más espacio arriba, para la leyenda
plt.ylim(-2, 3.2)
plt.xlabel("x")
plt.ylabel("y")
plt.title("Grado 15, con y sin castigo")
plt.legend(loc="upper left")
plt.show()
alpha =   0.001:  entrenamiento 0.028   validación 0.058
alpha =    0.01:  entrenamiento 0.031   validación 0.051
alpha =     0.1:  entrenamiento 0.034   validación 0.047
alpha =       1:  entrenamiento 0.068   validación 0.096
alpha =      10:  entrenamiento 0.192   validación 0.263
alpha =     100:  entrenamiento 0.352   validación 0.467

Los puntos de entrenamiento (llenos) y de validación (huecos) con dos curvas de grado 15. La que no está regularizada se retuerce y se sale del gráfico cerca de los bordes. La de Ridge, resaltada, es una onda suave que sigue los puntos.

Con el mismo grado 15, Ridge da una curva suave. λ es otra perilla de complejidad, como el grado, pero continua: con alpha muy chico vuelve el sobreajuste; con alpha muy grande, el subajuste. Su valor también se elige con validación (o validación cruzada), nunca con la prueba.

Ridge y Lasso con datos reales

Volvamos al conjunto de diabetes del tema 6: 442 pacientes y 10 características. Vamos a ver qué les pasa a los 10 pesos a medida que sube alpha. Primero estandarizamos las columnas, y esto no es un detalle: el castigo trata a todos los pesos por igual, así que las columnas tienen que estar en la misma escala. Si una columna estuviera en metros y otra en milímetros, el castigo apretaría mucho más a una que a otra sin ninguna razón.

from sklearn.datasets import load_diabetes

diabetes = load_diabetes(as_frame=True)
nombres = list(diabetes.data.columns)
X_d = StandardScaler().fit_transform(diabetes.data)
y_d = diabetes.target.to_numpy()

alphas_r = np.logspace(-2, 4, 60)     # de 0,01 a 10 000
pesos_r = np.array([Ridge(alpha=a).fit(X_d, y_d).coef_ for a in alphas_r])

for i, nombre in enumerate(nombres):
    # Solo bmi va resaltado (color "C1"); las otras nueve, en "C2"
    if nombre == "bmi":
        plt.plot(alphas_r, pesos_r[:, i], color="C1", linewidth=3,
                 label="bmi")
    else:
        plt.plot(alphas_r, pesos_r[:, i], color="C2", linewidth=1.2)
plt.axhline(0, color="C0", linewidth=1)
plt.xscale("log")
plt.xlabel("alpha (escala logarítmica)")
plt.ylabel("Peso")
plt.title("Ridge: los pesos se encogen juntos")
plt.legend()
plt.show()

print("Pesos con alpha = 1000:",
      np.round(Ridge(alpha=1000).fit(X_d, y_d).coef_, 1))
Pesos con alpha = 1000: [ 1.6 -1.7  9.9  6.8  1.1  0.  -5.5  4.8  8.7  4.6]

Diez curvas, una por peso, según alpha en escala logarítmica de 0,01 a 10 000. A la izquierda los pesos están entre unos −38 y 36; al subir alpha todos se acercan a cero de a poco, sin llegar a cero. La curva de bmi, resaltada, empieza cerca de 25 y baja lentamente.

Con Ridge, todos los pesos se van encogiendo hacia cero, pero ninguno llega exactamente a cero. Ahora cambiemos una sola cosa del castigo: en lugar de los pesos al cuadrado, sus valores absolutos.

pérdida=MSE+λ∑j=1d|wj|

Esto es Lasso, o regularización L1. Parece un cambio menor, pero el resultado es muy distinto:

from sklearn.linear_model import Lasso

alphas_l = np.logspace(-2, 2, 60)     # de 0,01 a 100
pesos_l = np.array([Lasso(alpha=a).fit(X_d, y_d).coef_ for a in alphas_l])

for i, nombre in enumerate(nombres):
    if nombre == "bmi":
        plt.plot(alphas_l, pesos_l[:, i], color="C1", linewidth=3,
                 label="bmi")
    else:
        plt.plot(alphas_l, pesos_l[:, i], color="C2", linewidth=1.2)
plt.axhline(0, color="C0", linewidth=1)
plt.xscale("log")
plt.xlabel("alpha (escala logarítmica)")
plt.ylabel("Peso")
plt.title("Lasso: los pesos se apagan uno a uno")
plt.legend()
plt.show()

for alpha in [0.1, 1, 5, 20]:
    lasso = Lasso(alpha=alpha).fit(X_d, y_d)
    vivos = [n for n, w in zip(nombres, lasso.coef_) if w != 0]
    print(f"alpha = {alpha:4}:  {len(vivos)} pesos distintos de cero:",
          ", ".join(vivos))
alpha =  0.1:  9 pesos distintos de cero: age, sex, bmi, bp, s1, s2, s4, s5, s6
alpha =    1:  7 pesos distintos de cero: sex, bmi, bp, s1, s3, s5, s6
alpha =    5:  5 pesos distintos de cero: sex, bmi, bp, s3, s5
alpha =   20:  3 pesos distintos de cero: bmi, bp, s5

Diez curvas de pesos según alpha, de 0,01 a 100. Al subir alpha, las curvas van tocando el cero una por una y se quedan ahí, pegadas al eje. La curva de bmi, resaltada, es de las últimas en llegar a cero.

Con Lasso, los pesos no solo se encogen: se apagan, uno por uno, y quedan exactamente en cero. Un peso en cero significa que el modelo ya no usa esa característica. Por eso Lasso sirve también para elegir variables: con alpha grande, se queda solo con las que más ayudan a predecir. Aquí, las últimas en sobrevivir son bmi (índice de masa corporal) y s5 (un análisis de sangre).

¿Por qué Lasso deja pesos en cero?

Hay una forma geométrica de verlo, con un modelo de solo dos pesos, w₁ y w₂. Minimizar «MSE + λ · castigo» es parecido a minimizar el MSE con un presupuesto: los pesos no pueden salir de una región alrededor del origen. Para Ridge, la región es un círculo (w₁² + w₂² no puede pasar de cierto valor). Para Lasso, es un rombo (|w₁| + |w₂| tampoco).

El MSE, como viste en el tema 6, forma un valle con curvas de nivel en forma de elipse. La solución es el punto de la región donde el valle está más bajo: donde la región toca a la primera elipse. Probémoslo con una pérdida inventada cuyo mínimo está en (2, 1), fuera de las dos regiones. Para cada región recorremos 4000 puntos de su borde y nos quedamos con el de menor pérdida:

def perdida(w1, w2):
    """Un valle con el mínimo en (2, 1), más empinado en w1."""
    return 3 * (w1 - 2) ** 2 + (w2 - 1) ** 2

t = np.linspace(0, 2 * np.pi, 4000)
# El borde del círculo de radio 1 y el del rombo |w1| + |w2| = 1
circulo = np.cos(t), np.sin(t)
rombo = (np.cos(t) / (np.abs(np.cos(t)) + np.abs(np.sin(t))),
         np.sin(t) / (np.abs(np.cos(t)) + np.abs(np.sin(t))))

i_c = np.argmin(perdida(*circulo))
i_r = np.argmin(perdida(*rombo))
print(f"Ridge (círculo): w1 = {circulo[0][i_c]:.2f}"
      f"   w2 = {circulo[1][i_c]:.2f}")
print(f"Lasso (rombo):   w1 = {rombo[0][i_r]:.2f}"
      f"   w2 = {rombo[1][i_r]:.2f}")

W1, W2 = np.meshgrid(np.linspace(-1.5, 3, 200), np.linspace(-1.5, 2, 200))
plt.contour(W1, W2, perdida(W1, W2), levels=[0.5, 2, 4.5, 8, 12.5],
            colors="C2", linewidths=1)
plt.plot(*circulo, color="C0", linestyle="--", label="Ridge: círculo")
plt.plot(*rombo, color="C0", label="Lasso: rombo")
plt.scatter([circulo[0][i_c]], [circulo[1][i_c]], color="C0", s=80,
            zorder=3)
plt.scatter([rombo[0][i_r]], [rombo[1][i_r]], color="C1", s=140,
            zorder=4, label="Solución de Lasso: w2 = 0")
plt.scatter([2], [1], color="C0", marker="+", s=120)
plt.gca().set_aspect("equal")
plt.ylim(-1.5, 2.8)
plt.xlabel("w1")
plt.ylabel("w2")
plt.title("El rombo toca en una esquina")
plt.legend(loc="upper left", fontsize=10, frameon=True)
plt.show()
Ridge (círculo): w1 = 0.97   w2 = 0.24
Lasso (rombo):   w1 = 1.00   w2 = 0.00

Elipses de nivel centradas en el punto (2, 1), un círculo punteado y un rombo, ambos centrados en el origen. La solución de Ridge está en el borde del círculo, con los dos pesos distintos de cero. La de Lasso, resaltada, está en la esquina derecha del rombo, sobre el eje w1, donde w2 vale cero.

El círculo es redondo: la elipse lo toca en un punto cualquiera de su borde, donde los dos pesos suelen ser distintos de cero; solo se achican. El rombo, en cambio, tiene esquinas sobre los ejes, y las esquinas sobresalen: es muy probable que la elipse toque primero una esquina. En una esquina, uno de los pesos vale exactamente cero. Con muchas características, el «rombo» tiene muchísimas esquinas y aristas, y por eso Lasso apaga varios pesos a la vez.

Curva de aprendizaje: ¿más datos ayudarían?

Una última herramienta de diagnóstico. La curva de aprendizaje muestra el error de entrenamiento y el de validación según cuántos datos se usan para entrenar. learning_curve de scikit-learn la calcula con validación cruzada: para cada tamaño, entrena con esa cantidad de pacientes y valida con los demás.

from sklearn.model_selection import learning_curve

tamanos, p_ent, p_val = learning_curve(
    make_pipeline(StandardScaler(), LinearRegression()),
    diabetes.data, y_d, train_sizes=[15, 20, 30, 50, 100, 200, 353],
    cv=cinco, scoring="neg_mean_squared_error",
    shuffle=True, random_state=0)
mse_ent = -p_ent.mean(axis=1)         # promedio de los cinco folds
mse_val = -p_val.mean(axis=1)

for t, e, v in zip(tamanos, mse_ent, mse_val):
    print(f"{t:3d} pacientes:  entrenamiento {e:6.0f}"
          f"   validación {v:6.0f}")

plt.plot(tamanos, mse_ent, color="C2", marker="o", label="Entrenamiento")
plt.plot(tamanos, mse_val, color="C0", marker="o", label="Validación")
plt.xscale("log")
plt.yscale("log")
# Marcas legibles en el eje x: solo algunos tamaños
plt.xticks([15, 30, 100, 353], ["15", "30", "100", "353"])
plt.minorticks_off()
plt.xlabel("Pacientes para entrenar")
plt.ylabel("MSE (escala logarítmica)")
plt.title("Curva de aprendizaje: diabetes")
plt.legend()
plt.show()
 15 pacientes:  entrenamiento   1120   validación  10954
 20 pacientes:  entrenamiento   1412   validación   6822
 30 pacientes:  entrenamiento   1882   validación   3921
 50 pacientes:  entrenamiento   2347   validación   3266
100 pacientes:  entrenamiento   2549   validación   3317
200 pacientes:  entrenamiento   2701   validación   3124
353 pacientes:  entrenamiento   2843   validación   2978

Dos curvas según la cantidad de pacientes para entrenar, de 15 a 353, en escalas logarítmicas. La de entrenamiento empieza baja y sube. La de validación empieza muy alta y baja. Al final las dos quedan cerca, alrededor de 3000.

Con pocos pacientes, el modelo de 10 pesos se ajusta muy bien a los que ve y muy mal a los demás: varianza alta. A medida que hay más datos, memorizar se vuelve imposible: el error de entrenamiento sube, el de validación baja y las dos curvas se juntan. Así se lee la curva:

Lo que ves Diagnóstico Qué probar
Las dos curvas altas y juntas Subajuste (sesgo alto) Un modelo más flexible o mejores características; más datos no ayudan
Entrenamiento bajo, validación mucho más alta, y la brecha se achica con más datos Sobreajuste (varianza alta) Más datos, regularizar o un modelo más simple
Las dos juntas y en un nivel aceptable Buen ajuste Nada; probar con la prueba

En diabetes, al final las curvas casi se tocan: con 353 pacientes, un modelo lineal ya no sobreajusta, y conseguir más pacientes casi no lo mejoraría. Para bajar el error haría falta un modelo distinto o más información de cada paciente.

La receta completa con diabetes

Juntemos todo y arreglemos la trampa del tema 6. Separamos un 25 % de los pacientes para la prueba, elegimos alpha de Ridge con validación cruzada solo con el entrenamiento y medimos con la prueba una sola vez. Como el StandardScaler va dentro del pipeline, la media y la desviación también se calculan solo con el entrenamiento: los pacientes de prueba no se miran para nada hasta el final.

from sklearn.metrics import r2_score

Xd_ent, Xd_prueba, yd_ent, yd_prueba = train_test_split(
    diabetes.data, y_d, test_size=0.25, random_state=0)

# Como en el tema 6: entrenar y medir con los mismos pacientes
lineal = make_pipeline(StandardScaler(), LinearRegression())
lineal.fit(Xd_ent, yd_ent)
print(f"R² con los datos de entrenamiento: "
      f"{r2_score(yd_ent, lineal.predict(Xd_ent)):.3f}")

# Elegir alpha con validación cruzada, sin tocar la prueba
for alpha in [0.1, 1, 10, 100]:
    puntajes = cross_val_score(
        make_pipeline(StandardScaler(), Ridge(alpha=alpha)),
        Xd_ent, yd_ent, cv=cinco, scoring="r2")
    print(f"Ridge alpha = {alpha:5}:  R² de validación cruzada = "
          f"{puntajes.mean():.3f}")
R² con los datos de entrenamiento: 0.555
Ridge alpha =   0.1:  R² de validación cruzada = 0.522
Ridge alpha =     1:  R² de validación cruzada = 0.523
Ridge alpha =    10:  R² de validación cruzada = 0.523
Ridge alpha =   100:  R² de validación cruzada = 0.511
# La prueba, una sola vez, con el modelo elegido
elegido = make_pipeline(StandardScaler(), Ridge(alpha=10))
elegido.fit(Xd_ent, yd_ent)
print(f"R² con los datos de prueba: "
      f"{r2_score(yd_prueba, elegido.predict(Xd_prueba)):.3f}")
R² con los datos de prueba: 0.358

alpha = 1 y alpha = 10 empatan en la validación cruzada; elegimos 10. El R² medido con los pacientes de entrenamiento (0,555) era optimista. El de la prueba (0,358) salió más bajo incluso que el de la validación cruzada: 111 pacientes de prueba también traen su dosis de suerte, y en el ejercicio 6 vas a medir cuánto cambia con otra división. Aun así, es la única estimación que no participó en ninguna decisión, y por eso es la honesta. Fíjate también en que todas las alpha dan resultados parecidos: con 331 pacientes y solo 10 características, el modelo lineal casi no sobreajusta (lo dijo la curva de aprendizaje), así que regularizar ayuda poco. La regularización brilla cuando hay muchas características para pocos datos, como en el mini-proyecto.

Una última regla, la más importante del tema: si después de mirar la prueba vuelves a cambiar el modelo, la prueba deja de ser nueva. Su número ya no es honesto. Por eso se usa una sola vez, al final, y todas las decisiones se toman antes, con la validación.

Lo que aprendiste y lo que sigue

  • Un modelo sirve si generaliza: si acierta con datos que no vio. El error de entrenamiento no lo dice; puede bajar a casi cero mientras el modelo memoriza.
  • Los datos se dividen en entrenamiento (ajustar), validación (elegir) y prueba (medir una sola vez, al final).
  • El error de validación frente a la complejidad forma una U: a la izquierda, subajuste (sesgo alto); a la derecha, sobreajuste (varianza alta).
  • La validación cruzada k-fold promedia k divisiones distintas, para no depender de la suerte de una sola.
  • Ridge (L2) encoge todos los pesos; Lasso (L1) apaga algunos exactamente en cero, por las esquinas del rombo. Los dos piden características estandarizadas.
  • La curva de aprendizaje dice si conseguir más datos ayudaría.

Todo esto vale para cualquier modelo que veas desde ahora: los clasificadores del tema 8, los árboles del tema 11 y las redes neuronales, que tienen tantos parámetros que sobreajustar es su riesgo principal (tema 19).