Regresión lineal y descenso de gradiente

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

Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.

Los 13 bloques de código de la teoría, en el mismo orden. Si prefieres armar tu propio cuaderno, cópialos y córrelos en orden; en la ficha Teoría está la explicación de cada uno.

El modelo: una recta con dos perillas

import numpy as np
import matplotlib.pyplot as plt

# Semilla fija: cada vez que corras esto saldrán los mismos datos
rng = np.random.default_rng(42)

n = 30                                # cantidad de estudiantes
# Horas de estudio en la semana del examen, entre 0 y 8
horas = rng.uniform(0, 8, size=n)
# Todo lo que las horas no explican (sueño, nervios, suerte)
ruido = rng.normal(0, 6, size=n)
# La regla «secreta»: 7 puntos por hora, partiendo de 30
nota = 30 + 7 * horas + ruido

print("Horas:", np.round(horas[:5], 1))
print("Notas:", np.round(nota[:5], 1))

plt.scatter(horas, nota)
plt.xlabel("Horas de estudio")
plt.ylabel("Nota del examen (sobre 100)")
plt.title("Treinta estudiantes (datos sintéticos)")
plt.show()

Medir el error: residuos y función de pérdida

def predecir(x, w, b):
    """La predicción del modelo: una recta."""
    return w * x + b

# Una primera recta elegida «a ojo»
w, b = 5.0, 40.0
prediccion = predecir(horas, w, b)
residuos = nota - prediccion          # real menos predicho

for i in range(3):
    print(f"Estudiante {i}: estudió {horas[i]:.1f} h, "
          f"sacó {nota[i]:.1f}, la recta dice {prediccion[i]:.1f}, "
          f"residuo {residuos[i]:+.1f}")

# Dos puntos bastan para dibujar una recta
x_recta = np.array([0, 8])
plt.scatter(horas, nota)
plt.plot(x_recta, predecir(x_recta, w, b), color="C0",
         label="Recta a ojo: 5·x + 40")
plt.vlines(horas, prediccion, nota, color="C2", linewidth=1.2,
           label="Residuos")
plt.xlabel("Horas de estudio")
plt.ylabel("Nota del examen (sobre 100)")
plt.title("Cada residuo es un error de la recta")
plt.legend()
plt.show()
def mse(y, y_pred):
    """Error cuadrático medio."""
    return np.mean((y - y_pred) ** 2)

def mae(y, y_pred):
    """Error absoluto medio."""
    return np.mean(np.abs(y - y_pred))

for w, b in [(5, 40), (7, 30), (9, 20)]:
    p = predecir(horas, w, b)
    error = mse(nota, p)
    print(f"w = {w}, b = {b}:   MSE = {error:4.1f}   "
          f"RMSE = {np.sqrt(error):3.1f}   MAE = {mae(nota, p):3.1f}")

La pérdida es un paisaje

# 200 pendientes posibles entre 0 y 14
ws = np.linspace(0, 14, 200)
perdidas = [mse(nota, predecir(horas, w, 30)) for w in ws]
mejor = ws[np.argmin(perdidas)]       # la pendiente con menor pérdida

plt.plot(ws, perdidas)
plt.scatter([mejor], [min(perdidas)], color="C1", s=140, zorder=3,
            label="El punto más bajo")
plt.xlabel("Pendiente w (con b = 30 fijo)")
plt.ylabel("MSE")
plt.title("La pérdida según w")
plt.legend()
plt.show()

print(f"Con b = 30, la mejor pendiente está cerca de w = {mejor:.2f}")

El gradiente: hacia dónde sube el valle

def gradiente(x, y, w, b):
    """Derivadas parciales del MSE respecto de w y de b."""
    # Error = predicho menos real: el residuo con el signo cambiado
    error = predecir(x, w, b) - y
    dw = 2 * np.mean(error * x)
    db = 2 * np.mean(error)
    return dw, db

def perdida_en(w, b):
    """El MSE de la recta (w, b) con los datos de las notas."""
    return mse(nota, predecir(horas, w, b))

# Comprobación con la derivada numérica (diferencia centrada),
# en la recta a ojo
h = 1e-6
w, b = 5.0, 40.0
dw_num = (perdida_en(w + h, b) - perdida_en(w - h, b)) / (2 * h)
db_num = (perdida_en(w, b + h) - perdida_en(w, b - h)) / (2 * h)
print("Con la fórmula:   ", np.round(gradiente(horas, nota, w, b), 4))
print("Derivada numérica:", np.round([dw_num, db_num], 4))

Descenso de gradiente: bajar paso a paso

def descenso_gradiente(x, y, tasa, pasos, w=0.0, b=0.0):
    """Ajusta y ≈ w·x + b bajando por el MSE.

    Devuelve los w y b finales y el historial de (w, b, MSE).
    """
    historial = []                    # (w, b, MSE) antes de cada paso
    for paso in range(pasos):
        historial.append((w, b, mse(y, predecir(x, w, b))))
        dw, db = gradiente(x, y, w, b)  # hacia dónde sube el valle
        w = w - tasa * dw             # un paso en contra
        b = b - tasa * db
    historial.append((w, b, mse(y, predecir(x, w, b))))
    return w, b, np.array(historial)

w_final, b_final, historial = descenso_gradiente(
    horas, nota, tasa=0.02, pasos=2000)

for paso in [0, 1, 10, 100, 500, 2000]:
    w_p, b_p, perdida = historial[paso]
    print(f"Paso {paso:4d}:   w = {w_p:5.2f}   b = {b_p:5.2f}"
          f"   MSE = {perdida:7.1f}")
fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8),
                         sharex=True, sharey=True)
for eje, paso in zip(ejes.flat, [0, 10, 100, 2000]):
    w_p, b_p, perdida = historial[paso]
    # Solo la recta final va resaltada (color "C1")
    color = "C1" if paso == 2000 else "C2"
    eje.scatter(horas, nota, s=14)
    eje.plot(x_recta, predecir(x_recta, w_p, b_p), color=color)
    # Título en dos líneas para que quepa en cada panel
    eje.set_title(f"Paso {paso}\nMSE = {perdida:.0f}")
for eje in ejes[1]:
    eje.set_xlabel("Horas")
for eje in ejes[:, 0]:
    eje.set_ylabel("Nota")
plt.show()
# Una cuadrícula de 200 × 200 valores de (w, b)
W, B = np.meshgrid(np.linspace(-2, 14, 200), np.linspace(-10, 60, 200))
# Solo para dibujar: el MSE de las 40 000 rectas a la vez. [..., None]
# agrega un eje al final para combinarlo con los 30 estudiantes; no
# necesitas entender cada detalle
Z = np.mean((W[..., None] * horas + B[..., None] - nota) ** 2, axis=-1)

niveles = np.geomspace(Z.min() + 1, Z.max(), 14)
# Más alta que la de siempre: la leyenda va debajo
plt.figure(figsize=(4.8, 4.4))
plt.contour(W, B, Z, levels=niveles, colors="C2", linewidths=1,
            alpha=0.5)
plt.plot(historial[:, 0], historial[:, 1], color="C0",
         label="Camino del descenso")
algunos = [0, 1, 2, 3, 10, 100]       # algunos pasos, marcados
plt.scatter(historial[algunos, 0], historial[algunos, 1],
            color="C0", s=30, zorder=3)
plt.scatter([w_final], [b_final], color="C1", s=160, zorder=4,
            label="Después de 2000 pasos")
plt.xlabel("w (pendiente)")
plt.ylabel("b (intercepto)")
plt.title("El valle del MSE visto desde arriba")
# La leyenda va debajo del gráfico, para no tapar las curvas
plt.legend(loc="upper center", bbox_to_anchor=(0.5, -0.24),
           frameon=False)
plt.show()

La tasa de aprendizaje

tasas = [(0.002, "C0", "lenta"),
         (0.02, "C1", "adecuada"),
         (0.04, "C2", "demasiado grande")]

plt.figure()
for tasa, color, nombre in tasas:
    _, _, h = descenso_gradiente(horas, nota, tasa=tasa, pasos=600)
    plt.plot(h[:, 2], color=color, label=f"tasa {tasa}: {nombre}")
    print(f"tasa {tasa}:  MSE después de 600 pasos = {h[-1, 2]:.4g}")

# Escala logarítmica: cada marca del eje es 10 veces la anterior
plt.yscale("log")
plt.ylim(10, 1e5)
plt.xlabel("Paso")
plt.ylabel("MSE (escala logarítmica)")
plt.title("La misma bajada con tres tasas")
plt.legend()
plt.show()

La solución de scikit-learn

from sklearn.linear_model import LinearRegression

modelo = LinearRegression()
# X tiene que ser una tabla: 30 filas y 1 columna
modelo.fit(horas.reshape(-1, 1), nota)

print(f"scikit-learn:      w = {modelo.coef_[0]:.4f}"
      f"   b = {modelo.intercept_:.4f}")
print(f"Nuestro descenso:  w = {w_final:.4f}   b = {b_final:.4f}")
print("Nota predicha para 6 horas:", modelo.predict([[6]]).round(1))

Varias variables a la vez

from sklearn.datasets import load_diabetes
import pandas as pd

diabetes = load_diabetes(as_frame=True)
X = diabetes.data      # tabla: 442 pacientes × 10 características
y = diabetes.target    # avance de la enfermedad un año después

print("Forma de X:", X.shape)
print("Columnas:", list(X.columns))
print("Objetivo: de", y.min(), "a", y.max(),
      "con media", round(y.mean(), 1))
# Estandarizar: cada columna queda con media 0 y desviación estándar 1
X_est = ((X - X.mean()) / X.std(ddof=0)).to_numpy()
y_arr = y.to_numpy()

def descenso_gradiente_varias(X, y, tasa, pasos):
    """Descenso de gradiente para y ≈ X @ w + b (un peso por columna)."""
    n, d = X.shape
    w = np.zeros(d)                   # un peso por característica
    b = 0.0
    for paso in range(pasos):
        error = X @ w + b - y         # los n errores de una vez
        w = w - tasa * 2 * X.T @ error / n
        b = b - tasa * 2 * np.mean(error)
    return w, b

w_gd, b_gd = descenso_gradiente_varias(X_est, y_arr, tasa=0.1,
                                       pasos=20000)
modelo_diabetes = LinearRegression().fit(X_est, y_arr)

comparacion = pd.DataFrame(
    {"descenso": w_gd, "scikit-learn": modelo_diabetes.coef_},
    index=X.columns)
print(comparacion.round(2))
print(f"b:  descenso = {b_gd:.2f}"
      f"   scikit-learn = {modelo_diabetes.intercept_:.2f}")

¿Qué tan bueno es el modelo?

from sklearn.metrics import r2_score

pred = modelo_diabetes.predict(X_est)
residuos = y_arr - pred
r2 = 1 - np.sum(residuos ** 2) / np.sum((y_arr - y_arr.mean()) ** 2)
rmse = np.sqrt(np.mean(residuos ** 2))
error_abs = np.mean(np.abs(residuos))

print(f"RMSE = {rmse:.1f}   MAE = {error_abs:.1f}   R² = {r2:.3f}")
print(f"R² según scikit-learn: {r2_score(y_arr, pred):.3f}")

# Un modelo con una sola columna: bmi (la de índice 2)
solo_bmi = X_est[:, [2]]
modelo_bmi = LinearRegression().fit(solo_bmi, y_arr)
print(f"R² usando solo bmi: {modelo_bmi.score(solo_bmi, y_arr):.3f}")

plt.scatter(pred, residuos, s=16)
plt.axhline(0, color="C2")
plt.xlabel("Predicción del modelo")
plt.ylabel("Residuo (real menos predicho)")
plt.title("Residuos frente a predicciones")
plt.show()