Generalizar: sobreajuste, validación y regularización

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

En esta ficha · 5 secciones
  1. Antes de empezar
  2. Ejercicios
  3. Mini-proyecto: encontrar las 5 variables que importan
  4. Errores comunes
  5. ¿Lo lograste?

Antes de empezar

Varios ejercicios usan las variables y funciones de la teoría: X_resto, y_resto, X_prueba, y_prueba, polinomio, nuevos_datos, cinco, X_d, y_d, nombres y diabetes. 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. ¿Qué grupo usarías?

Para cada tarea, di si usarías los datos de entrenamiento, de validación o de prueba, y por qué.

  1. Calcular los pesos de una regresión con fit.
  2. Decidir entre un polinomio de grado 3 y uno de grado 5.
  3. Elegir el alpha de Ridge.
  4. Escribir en un informe cuánto se va a equivocar el modelo con casos nuevos.
  5. Calcular la media y la desviación estándar para estandarizar las columnas.
Respuesta
  1. Entrenamiento: es para lo único que existe.
  2. Validación (o validación cruzada con los datos que no son de prueba): comparar modelos es una decisión.
  3. Validación, por la misma razón: alpha es una perilla del modelo, como el grado.
  4. Prueba, una sola vez y al final. Es el único grupo que no participó en ninguna decisión, así que es el único que da un número honesto.
  5. Entrenamiento. Si la media y la desviación se calculan con todos los datos, información de la validación y de la prueba se cuela en el modelo. Por eso en la teoría el StandardScaler va dentro del pipeline: fit lo ajusta solo con los datos de entrenamiento.

2. Leer los errores

Imagina cuatro modelos entrenados con los mismos datos. Estos son sus errores (MSE); el ruido de los datos tiene un MSE de alrededor de 1. Di para cada uno si hay subajuste, sobreajuste o un buen ajuste.

Modelo Entrenamiento Validación
A 4,1 4,3
B 0,2 6,5
C 1,0 1,2
D 0,9 2,9
Respuesta
  • A, subajuste: los dos errores son altos y parecidos, muy por encima del ruido. El modelo no capta ni los datos que ve. Hace falta un modelo más flexible.
  • B, sobreajuste fuerte: el error de entrenamiento es mucho menor que el ruido (está memorizando el ruido) y el de validación es enorme. Hay que simplificar el modelo o regularizar.
  • C, buen ajuste: los dos errores están cerca del ruido y cerca entre sí.
  • D, sobreajuste moderado: el error de entrenamiento parece bueno, pero el de validación es más del triple. Conviene regularizar un poco o probar un modelo más simple.

3. Elegir el grado con validación cruzada

En la teoría, la validación cruzada con 5 folds eligió el grado 3. Repite la elección con 10 folds (KFold(n_splits=10, shuffle=True, random_state=0)) y con 5 folds pero otra semilla de mezcla (random_state=1). ¿Sale el mismo grado? ¿Cuántos modelos se entrenan en total con 10 folds y 15 grados?

Respuesta
def elegir_grado(cv):
    """El grado de 1 a 15 con menor MSE de validación cruzada."""
    errores = []
    for grado in range(1, 16):
        puntajes = cross_val_score(polinomio(grado), X_resto, y_resto,
                                   cv=cv, scoring="neg_mean_squared_error")
        errores.append(-puntajes.mean())
    mejor = int(np.argmin(errores)) + 1
    return mejor, min(errores)

for nombre, cv in [("10 folds        ", KFold(10, shuffle=True,
                                             random_state=0)),
                   ("5 folds, otra mezcla", KFold(5, shuffle=True,
                                                  random_state=1))]:
    grado, error = elegir_grado(cv)
    print(f"{nombre}:  grado {grado}   MSE {error:.3f}")
10 folds        :  grado 3   MSE 0.042
5 folds, otra mezcla:  grado 3   MSE 0.048

Con 10 folds se entrenan 10 modelos por grado: 150 modelos en total.

4. Encuentra el error

Una compañera quiere elegir el grado del polinomio y escribe esto:

errores_prueba = []
for grado in range(1, 16):
    modelo = polinomio(grado).fit(X_resto, y_resto)
    errores_prueba.append(
        mean_squared_error(y_prueba, modelo.predict(X_prueba)))
grado_elegido = int(np.argmin(errores_prueba)) + 1
print(f"Grado elegido: {grado_elegido}   "
      f"MSE de prueba: {min(errores_prueba):.3f}")
Grado elegido: 6   MSE de prueba: 0.042

Y escribe en su informe: «con datos nuevos, el modelo tendrá un MSE de alrededor de 0,042». ¿Qué está mal?

Compruébalo con una simulación. Como fabricamos los datos, podemos repetir su procedimiento 100 veces, cada vez con 40 puntos nuevos (30 para entrenar y 10 de «prueba»), y medir cuánto se equivoca de verdad el modelo elegido con 2000 puntos que nadie usó. Compáralo con un procedimiento honesto: fijar el grado de antemano (el 3, que ya elegimos con validación cruzada) y medirlo con la prueba una sola vez.

Respuesta

Usó la prueba para elegir el grado. Al probar 15 modelos y quedarse con el que mejor le fue con esos 10 puntos, eligió en parte al que tuvo más suerte con ellos. El número que reporta ya no mide datos nuevos: es el mínimo de 15 intentos, y por eso es optimista.

X_nuevos, y_nuevos = nuevos_datos(999, n=2000)
reportado, real = [], []              # eligiendo con la prueba
reportado_3, real_3 = [], []          # grado 3 fijo, prueba una vez
for semilla in range(100):
    X_n, y_n = nuevos_datos(1000 + semilla, n=40)
    X_a, X_b = X_n[:30], X_n[30:]     # 30 para entrenar, 10 de prueba
    y_a, y_b = y_n[:30], y_n[30:]
    modelos = [polinomio(g).fit(X_a, y_a) for g in range(1, 16)]
    errores = [mean_squared_error(y_b, m.predict(X_b)) for m in modelos]
    elegido = modelos[int(np.argmin(errores))]
    reportado.append(min(errores))
    real.append(mean_squared_error(y_nuevos, elegido.predict(X_nuevos)))
    reportado_3.append(errores[2])    # el grado 3 es el tercer modelo
    real_3.append(
        mean_squared_error(y_nuevos, modelos[2].predict(X_nuevos)))

for nombre, rep, rea in [("Eligiendo con la prueba", reportado, real),
                         ("Grado 3 fijo           ", reportado_3, real_3)]:
    print(f"{nombre}:  reportado {np.mean(rep):.3f}"
          f"   real {np.mean(rea):.3f}"
          f"   (medianas {np.median(rep):.3f} y {np.median(rea):.3f})")
Eligiendo con la prueba:  reportado 0.091   real 1.201   (medianas 0.086 y 0.112)
Grado 3 fijo           :  reportado 0.105   real 0.111   (medianas 0.095 y 0.107)

Eligiendo con la prueba, el número reportado promete un error de alrededor de 0,09, pero el error real es mucho mayor: a veces el grado que tuvo suerte con 10 puntos es uno muy alto que se dispara con datos nuevos, y esos casos suben muchísimo el promedio. Incluso la mediana, que no se deja llevar por esos casos, sale más alta que lo reportado. Con el grado fijado de antemano, lo reportado y lo real coinciden bastante: la prueba usada una sola vez no engaña en promedio.

La forma correcta es la de la teoría: elegir con validación (o validación cruzada) sin tocar la prueba, y usar la prueba una sola vez, con el modelo ya elegido.

5. Ridge contra Lasso

Con los datos de diabetes estandarizados de la teoría (X_d, y_d):

a) Entrena Ridge(alpha=1000) y Lasso(alpha=5). Cuenta cuántos pesos de cada uno son exactamente cero y cuál es el peso más chico en valor absoluto.

b) Explica con el dibujo del rombo y el círculo por qué uno deja ceros y el otro no.

Respuesta
ridge = Ridge(alpha=1000).fit(X_d, y_d)
lasso = Lasso(alpha=5).fit(X_d, y_d)
for nombre, m in [("Ridge", ridge), ("Lasso", lasso)]:
    print(f"{nombre}:  pesos en cero = {np.sum(m.coef_ == 0)}"
          f"   el más chico = {np.min(np.abs(m.coef_)):.4f}")
Ridge:  pesos en cero = 0   el más chico = 0.0433
Lasso:  pesos en cero = 5   el más chico = 0.0000

a) Ridge, aun con un castigo enorme, no deja ningún peso en cero: el más chico es diminuto, pero no es cero. Lasso, con un castigo moderado, deja varios exactamente en cero.

b) La región de Ridge es un círculo, sin esquinas: la elipse del MSE lo toca en un punto cualquiera del borde, donde los pesos son chicos pero no nulos. La región de Lasso es un rombo, con esquinas sobre los ejes que sobresalen, y la elipse suele tocarlo primero en una esquina o en una arista, donde uno o varios pesos valen cero.

6. La suerte de la división

En la teoría, el R² de prueba de diabetes salió 0,358 con random_state=0. Repite la división con las semillas 0 a 9 (mismo modelo: StandardScaler y Ridge(alpha=10)) y anota el R² de prueba de cada una. Después calcula el R² de validación cruzada con 5 folds usando todos los pacientes. ¿Qué conclusión sacas?

Respuesta
r2s = []
for semilla in range(10):
    Xa, Xb, ya, yb = train_test_split(diabetes.data, y_d, test_size=0.25,
                                      random_state=semilla)
    m = make_pipeline(StandardScaler(), Ridge(alpha=10)).fit(Xa, ya)
    r2s.append(r2_score(yb, m.predict(Xb)))
print("R² de prueba:", np.round(r2s, 2))
print(f"de {min(r2s):.2f} a {max(r2s):.2f}")

cv_todos = cross_val_score(make_pipeline(StandardScaler(), Ridge(alpha=10)),
                           diabetes.data, y_d, cv=cinco, scoring="r2")
print(f"Validación cruzada: {np.round(cv_todos, 2)}"
      f"   promedio {cv_todos.mean():.3f}")
R² de prueba: [0.36 0.44 0.45 0.43 0.47 0.53 0.45 0.51 0.47 0.6 ]
de 0.36 a 0.60
Validación cruzada: [0.33 0.46 0.53 0.51 0.61]   promedio 0.489

Con 111 pacientes de prueba, el R² cambia bastante según quiénes cayeron en la prueba: va de 0,36 a 0,60, y la semilla 0 fue justo la de peor suerte. Un solo número de prueba con pocos datos tiene un margen de error grande, y eso hay que decirlo al reportarlo. La validación cruzada promedia varias divisiones y da una idea de cuánto varía el resultado. Lo que no se vale es probar semillas hasta encontrar la que da el mejor número.

7. ¿Por qué escalar antes de regularizar?

Imagina que la columna bmi viniera en otra unidad, con números 100 veces más chicos. Para una regresión sin castigo da igual: el peso se multiplica por 100 y las predicciones no cambian. ¿Y para Ridge? Compara el peso de bmi y el R² de validación cruzada de Ridge(alpha=10) con las columnas estandarizadas y con bmi dividida por 100.

Respuesta
X_mal = X_d.copy()
X_mal[:, 2] = X_mal[:, 2] / 100         # bmi, en una unidad 100 veces mayor

for nombre, datos in [("estandarizado", X_d), ("bmi / 100    ", X_mal)]:
    m = Ridge(alpha=10).fit(datos, y_d)
    r2 = cross_val_score(Ridge(alpha=10), datos, y_d, cv=cinco,
                         scoring="r2").mean()
    # El efecto de bmi en las predicciones: peso por desviación de la columna
    efecto = m.coef_[2] * datos[:, 2].std()
    print(f"{nombre}:  efecto de bmi = {efecto:5.2f}   R² (CV) = {r2:.3f}")
estandarizado:  efecto de bmi = 24.60   R² (CV) = 0.489
bmi / 100    :  efecto de bmi =  0.08   R² (CV) = 0.421

Con bmi dividida por 100, para tener el mismo efecto el modelo necesitaría un peso 100 veces mayor, y Ridge castiga ese peso al cuadrado: le sale carísimo. El castigo prácticamente borra a bmi, la variable que más ayudaba, y el modelo empeora. Que una columna se castigue más que otra no debería depender de las unidades en que alguien la anotó: por eso se estandariza antes de regularizar.

8. Las viviendas, ahora con prueba

En el mini-proyecto del tema 6 mediste el modelo de las viviendas de California con los mismos datos de entrenamiento. Repítelo bien: separa un 20 % para prueba (random_state=0), entrena LinearRegression con las 8 columnas estandarizadas (en un pipeline) y compara el R² de entrenamiento con el de prueba. ¿Hay sobreajuste?

Respuesta
from sklearn.datasets import fetch_california_housing

casas = fetch_california_housing(as_frame=True)
Xc_ent, Xc_prueba, yc_ent, yc_prueba = train_test_split(
    casas.data, casas.target, test_size=0.2, random_state=0)
casas_lineal = make_pipeline(StandardScaler(), LinearRegression())
casas_lineal.fit(Xc_ent, yc_ent)
print(f"R² entrenamiento: "
      f"{r2_score(yc_ent, casas_lineal.predict(Xc_ent)):.3f}")
print(f"R² prueba:        "
      f"{r2_score(yc_prueba, casas_lineal.predict(Xc_prueba)):.3f}")
R² entrenamiento: 0.609
R² prueba:        0.594

Los dos R² son casi iguales: con más de 16 000 zonas para entrenar y solo 8 pesos, una regresión lineal no tiene cómo memorizar. Su problema es el contrario, el sesgo: una función lineal de las 8 columnas es demasiado simple para los precios. Para mejorar haría falta un modelo más flexible (por ejemplo, los árboles del tema 11), no regularizar.

Mini-proyecto: encontrar las 5 variables que importan

Vas a fabricar un problema donde sabes la respuesta y a comprobar si Lasso la encuentra. Hay 30 características, pero el objetivo depende solo de 5 de ellas; las otras 25 son puro ruido. Con pocos datos, una regresión común se confunde con tantas columnas inútiles.

rng_p = np.random.default_rng(7)
n_p, d_p = 80, 30
X_p = rng_p.normal(size=(n_p, d_p))
# Solo 5 columnas importan; el resto de los pesos verdaderos son cero
w_verdad = np.zeros(d_p)
utiles = rng_p.choice(d_p, size=5, replace=False)
w_verdad[utiles] = rng_p.choice([-1, 1], size=5) * rng_p.uniform(1, 3, 5)
y_p = X_p @ w_verdad + rng_p.normal(0, 1.5, size=n_p)
print("Forma de X:", X_p.shape)
Forma de X: (80, 30)

No mires utiles ni w_verdad hasta el paso 5: hacen de «la verdad que nadie conoce».

Pasos.

  1. Separa un 25 % de los datos para prueba (random_state=0).
  2. Regresión común. Entrena LinearRegression y compara el R² de entrenamiento con el de prueba. ¿Hay sobreajuste? ¿Cuántos pesos son distintos de cero?
  3. Elige el castigo. Para varios valores de alpha de Lasso (por ejemplo, np.logspace(-3, 0, 13)), calcula el R² de validación cruzada con 5 folds usando solo el entrenamiento. Dibuja el R² frente a alpha y elige el mejor.
  4. Entrena y mide. Entrena Lasso con ese alpha y mide el R² de prueba, una sola vez. Lista las columnas con peso distinto de cero.
  5. Compara con la verdad. Ahora sí, mira utiles. ¿Encontró Lasso las 5? ¿Se le coló alguna columna inútil? ¿Qué tan grandes son los pesos de las que se colaron?
  6. Concluye en tres frases: qué pasó con la regresión común, qué hizo Lasso y por qué Ridge no te habría dicho cuáles variables importan.
Resultados de referencia, para comprobar los tuyos
Xp_ent, Xp_prueba, yp_ent, yp_prueba = train_test_split(
    X_p, y_p, test_size=0.25, random_state=0)

comun = LinearRegression().fit(Xp_ent, yp_ent)
print(f"Regresión común:  R² entrenamiento "
      f"{comun.score(Xp_ent, yp_ent):.3f}"
      f"   R² prueba {comun.score(Xp_prueba, yp_prueba):.3f}")

alphas_p = np.logspace(-3, 0, 13)
r2_cv = [cross_val_score(Lasso(alpha=a), Xp_ent, yp_ent, cv=cinco,
                         scoring="r2").mean() for a in alphas_p]
alpha_p = alphas_p[np.argmax(r2_cv)]
print(f"Mejor alpha por validación cruzada: {alpha_p:.3f}"
      f"   (R² de validación cruzada {max(r2_cv):.3f})")

lasso_p = Lasso(alpha=alpha_p).fit(Xp_ent, yp_ent)
print(f"Lasso:  R² prueba {lasso_p.score(Xp_prueba, yp_prueba):.3f}")
elegidas = np.flatnonzero(lasso_p.coef_)
print("Columnas que usa Lasso:", elegidas)
print("Columnas que importan: ", np.sort(utiles))
colados = [j for j in elegidas if j not in utiles]
print("Pesos de las columnas que se colaron:",
      np.round(lasso_p.coef_[colados], 2))
print("Pesos de las 5 que importan (Lasso / verdad):")
for j in np.sort(utiles):
    print(f"  columna {j:2d}:  {lasso_p.coef_[j]:5.2f} / {w_verdad[j]:5.2f}")
Regresión común:  R² entrenamiento 0.951   R² prueba 0.811
Mejor alpha por validación cruzada: 0.178   (R² de validación cruzada 0.817)
Lasso:  R² prueba 0.880
Columnas que usa Lasso: [ 0  1  2  3  5  6  7 15 23 24 25 26]
Columnas que importan:  [ 3  5 24 25 26]
Pesos de las columnas que se colaron: [ 0.1   0.19 -0.24 -0.19  0.01  0.05 -0.04]
Pesos de las 5 que importan (Lasso / verdad):
  columna  3:  -1.34 / -1.76
  columna  5:   2.51 /  2.76
  columna 24:   1.55 /  2.06
  columna 25:   1.35 /  1.35
  columna 26:   2.40 /  2.62
  • La regresión común sobreajusta: con 60 datos para entrenar y 30 pesos, explica el 95 % de la variación con los datos que vio, pero baja a 0,81 con la prueba.
  • Lasso, con alpha elegido por validación cruzada, mejora la prueba a 0,88 y encuentra las 5 columnas que importan, con pesos cercanos a los verdaderos (casi todos algo más chicos: el castigo los encoge).
  • Se le colaron 7 columnas inútiles, todas con pesos chicos (ninguno pasa de 0,25 en valor absoluto, contra más de 1,3 de las útiles). Con un alpha un poco mayor se apagarían, a costa de encoger más las útiles.
  • Ridge encoge los pesos, pero los deja a los 30 distintos de cero: no te diría cuáles variables importan.

Errores comunes

  • Medir con los datos de entrenamiento. El error de entrenamiento siempre es optimista y no dice nada sobre datos nuevos. Mide siempre con datos que el modelo no vio.
  • Elegir mirando la prueba. Si eliges el grado, alpha o las variables según el resultado de prueba, ese resultado deja de ser honesto. Elige con validación y usa la prueba una sola vez.
  • Estandarizar antes de dividir. Si calculas la media y la desviación con todos los datos, información de la prueba se cuela en el entrenamiento. Pon el StandardScaler dentro de un pipeline.
  • Regularizar sin estandarizar. El castigo trata a todos los pesos por igual; si las columnas tienen escalas distintas, castiga más a unas que a otras sin ninguna razón.
  • Olvidar el signo en scikit-learn. cross_val_score con "neg_mean_squared_error" devuelve el MSE con signo negativo. Cámbiale el signo antes de elegir el mínimo, o elegirás el peor modelo.
  • Usar KFold sin mezclar con datos ordenados. Si los datos vienen ordenados (por fecha, por región, por clase), los folds sin shuffle=True son grupos raros y la validación sale engañosa. (Con datos en el tiempo hay que hacer otra cosa; lo verás en el tema 13.)
  • Creer que más datos siempre ayudan. Si las dos curvas de aprendizaje ya están juntas y altas, el problema es el sesgo: hace falta un modelo mejor, no más datos.
  • Probar semillas hasta que el número salga bonito. La semilla de train_test_split se fija antes de mirar los resultados, no después.
  • Leer los ceros de Lasso como «esta variable no tiene relación». Si dos variables dicen casi lo mismo, Lasso suele quedarse con una y apagar la otra, aunque las dos estén relacionadas con el objetivo.

¿Lo lograste?

Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.

  • Dividir los datos en entrenamiento, validación y prueba, y explicar para qué sirve cada parte (ejercicios 1 y 4, y paso 1 del mini-proyecto).
  • Reconocer el subajuste y el sobreajuste en curvas de error (ejercicios 2 y 8, y paso 2 del mini-proyecto).
  • Aplicar validación cruzada k-fold (ejercicios 3 y 6, y paso 3 del mini-proyecto).
  • Usar Ridge y Lasso y explicar por qué Lasso deja coeficientes en cero (ejercicios 5 y 7, y pasos 4 y 5 del mini-proyecto).