Ocho módulos con MercaFresh te han enseñado el camino; ahora toca recorrerlo solo. En este primer proyecto abandonamos temporalmente el supermercado para resolver un problema clásico de regresión de punta a punta: predecir el precio medio de la vivienda en distritos de California a partir de datos del censo. Practicarás el flujo completo de las 7 fases que viste en 01-05 "El flujo de trabajo de un proyecto de Machine Learning": definición, datos, exploración, preprocesamiento, modelado, evaluación y conclusiones. Usaremos el dataset California Housing, incluido en scikit-learn (fetch_california_housing), de modo que no necesitas cuentas externas ni descargas de Kaggle: todo funciona con tu entorno habitual de Python.

Contenido

  1. Definición del problema y métrica objetivo
  2. Obtención y carga de los datos
  3. Análisis exploratorio (EDA)
  4. Preprocesamiento con Pipeline
  5. Modelado: del baseline a los ensembles
  6. Comparación honesta con validación cruzada
  7. Análisis de errores
  8. Evaluación final en test
  9. Conclusiones del proyecto

Definición del problema y métrica objetivo

Antes de escribir una línea de código, definimos el problema como aprendiste en 01-05:

  • Pregunta de negocio: dado un distrito de California descrito por 8 variables del censo, estimar el valor mediano de sus viviendas.
  • Tipo de problema: regresión supervisada (la variable objetivo es continua), como en 04-01 "Regresión lineal".
  • Métrica principal: RMSE (raíz del error cuadrático medio), porque penaliza más los errores grandes y se expresa en las mismas unidades que el precio. Acompañaremos con MAE (más robusto a atípicos) y R² (proporción de varianza explicada), las tres presentadas en 06-02 "Métricas de evaluación".
  • Criterio de éxito: batir con claridad a un baseline trivial y conseguir un R² competitivo (para este dataset, por encima de 0,80 es un buen resultado).

El precio objetivo está expresado en centenas de miles de dólares (un valor de 2,5 significa 250.000 $). Un detalle importante que descubriremos en el EDA: los precios están censurados en 5,0 (todo distrito por encima de 500.000 $ aparece como 5,00001).

Obtención y carga de los datos

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_california_housing

datos = fetch_california_housing(as_frame=True)
df = datos.frame  # DataFrame con features + target
print(df.shape)   # (20640, 9)
df.head()

Con as_frame=True obtenemos directamente un DataFrame de pandas. El diccionario de datos:

Variable Significado Unidad
MedInc Ingreso mediano del distrito Decenas de miles de $
HouseAge Antigüedad mediana de las viviendas Años
AveRooms Habitaciones medias por hogar Habitaciones
AveBedrms Dormitorios medios por hogar Dormitorios
Population Población del distrito Personas
AveOccup Ocupantes medios por hogar Personas
Latitude Latitud del distrito Grados
Longitude Longitud del distrito Grados
MedHouseVal Objetivo: precio mediano Centenas de miles de $

Lo primero, como siempre desde 06-01 "División de datos: entrenamiento, validación y prueba", es apartar el conjunto de test antes de explorar nada, para que ninguna decisión se contamine con información que no deberíamos ver:

from sklearn.model_selection import train_test_split

X = df.drop(columns="MedHouseVal")
y = df["MedHouseVal"]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)
print(len(X_train), len(X_test))  # 16512 / 4128

A partir de aquí, todo el EDA se hace solo con el conjunto de entrenamiento. El test queda bajo llave hasta el final.

Análisis exploratorio (EDA)

Distribuciones

train = X_train.copy()
train["MedHouseVal"] = y_train

train.hist(bins=50, figsize=(14, 9))
plt.tight_layout()
plt.show()

print(train.describe().round(2))
print("Nulos por columna:\n", train.isna().sum())

Observaciones clave (compáralas con lo que veas en pantalla):

  • No hay valores nulos: nos ahorramos las estrategias de 03-02 "Manejo de datos faltantes" — un lujo poco habitual en datos reales.
  • MedHouseVal tiene un pico artificial en 5,0: es la censura mencionada. Unos 800 distritos del train valen "500.000 $ o más"; el modelo no podrá distinguir dentro de ese grupo.
  • MedInc y el objetivo tienen asimetría positiva (cola a la derecha), el patrón que aprendiste a tratar con logaritmos en 03-03 "Transformación de datos". Aquí la asimetría del precio es moderada, así que probaremos con y sin transformación en los retos; en el cuerpo del proyecto trabajaremos con el precio en su escala original para interpretar los errores en dólares.
  • AveRooms, AveBedrms, Population y AveOccup tienen atípicos extremos (distritos con residencias colectivas, hoteles, etc.): un AveOccup de 1.200 personas por hogar no es un hogar. Esto afectará a los modelos lineales más que a los de árboles, como razonaste en 04-03 "Árboles de decisión".

Correlaciones

Como en 02-03 "Correlación y covarianza", medimos la relación lineal de cada feature con el objetivo:

corr = train.corr(numeric_only=True)["MedHouseVal"].sort_values(ascending=False)
print(corr.round(3))

Resultado típico: MedInc domina (≈ 0,69), seguida muy de lejos por AveRooms (≈ 0,15); Latitude y Longitude tienen correlaciones lineales pequeñas y de signo negativo, pero eso no significa que no importen: su relación con el precio no es lineal, es geográfica.

El mapa: latitud y longitud coloreadas por precio

plt.figure(figsize=(9, 7))
sc = plt.scatter(
    train["Longitude"], train["Latitude"],
    c=train["MedHouseVal"], cmap="viridis",
    s=8, alpha=0.4
)
plt.colorbar(sc, label="Precio mediano (x100.000 $)")
plt.xlabel("Longitud")
plt.ylabel("Latitud")
plt.title("California: el precio es geografía")
plt.show()

El dibujo que aparece es un mapa de California: los precios altos se concentran en la costa (bahía de San Francisco, Los Ángeles, San Diego) y caen hacia el interior. Esta estructura espacial es exactamente el tipo de patrón no lineal e interactivo (importa la combinación latitud-longitud, no cada una por separado) donde los árboles y ensembles del módulo 7 deberían superar a la regresión lineal. Acabamos de formular una hipótesis comprobable — eso es hacer EDA con propósito.

Preprocesamiento con Pipeline

Todas las variables son numéricas, así que el preprocesamiento es sencillo: estandarizar para los modelos sensibles a escala (lineal, Ridge, Lasso — recuerda 03-05 "Normalización y estandarización") y dejar los datos tal cual para los de árboles, que son invariantes a transformaciones monótonas. Lo encapsulamos en Pipeline como aprendiste al final del módulo 3, para que el escalado se ajuste solo con el train de cada partición y evitar las fugas de datos de 06-01:

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.ensemble import RandomForestRegressor, HistGradientBoostingRegressor
from sklearn.dummy import DummyRegressor

modelos = {
    "Dummy (media)": DummyRegressor(strategy="mean"),
    "Lineal": Pipeline([("esc", StandardScaler()), ("m", LinearRegression())]),
    "Ridge": Pipeline([("esc", StandardScaler()), ("m", Ridge(alpha=1.0))]),
    "Lasso": Pipeline([("esc", StandardScaler()), ("m", Lasso(alpha=0.001))]),
    "Random Forest": RandomForestRegressor(
        n_estimators=200, n_jobs=-1, random_state=42
    ),
    "HistGradientBoosting": HistGradientBoostingRegressor(random_state=42),
}

Fíjate en que el diccionario ya cuenta la historia del curso: el baseline trivial, la regresión lineal de 04-01, las versiones regularizadas de 07-01 "Regularización: Ridge, Lasso y Elastic Net", el Random Forest de 07-02 "Ensemble Learning" y el gradient boosting por histogramas de 07-03 "Gradient Boosting".

Modelado: del baseline a los ensembles

Por qué empezar con DummyRegressor

El baseline responde una pregunta imprescindible: ¿cuánto de bueno es "no saber nada"? DummyRegressor(strategy="mean") predice siempre el precio medio. Su RMSE es, esencialmente, la desviación típica del objetivo (≈ 1,15, es decir, ±115.000 $). Cualquier modelo que no lo bata con holgura no ha aprendido nada; y su cifra da escala a todas las demás.

Comparación honesta con validación cruzada

Comparamos todos los modelos con validación cruzada de 5 particiones sobre el train, como aprendiste en 06-03 "Validación cruzada": ningún modelo ve el test, y cada cifra viene con su variabilidad.

from sklearn.model_selection import cross_validate, KFold

cv = KFold(n_splits=5, shuffle=True, random_state=42)
metricas = {
    "RMSE": "neg_root_mean_squared_error",
    "MAE": "neg_mean_absolute_error",
    "R2": "r2",
}

filas = []
for nombre, modelo in modelos.items():
    res = cross_validate(modelo, X_train, y_train, cv=cv, scoring=metricas)
    filas.append({
        "Modelo": nombre,
        "RMSE": -res["test_RMSE"].mean(),
        "RMSE_std": res["test_RMSE"].std(),
        "MAE": -res["test_MAE"].mean(),
        "R2": res["test_R2"].mean(),
    })

tabla = pd.DataFrame(filas).sort_values("RMSE").round(3)
print(tabla.to_string(index=False))

Resultados orientativos (los tuyos variarán ligeramente en los decimales):

Modelo RMSE (CV) MAE (CV) R² (CV) Lectura
HistGradientBoosting ≈ 0,47 ≈ 0,31 ≈ 0,83 El mejor: capta la no linealidad geográfica
Random Forest ≈ 0,51 ≈ 0,33 ≈ 0,80 Muy cerca, más lento y pesado
Lineal ≈ 0,72 ≈ 0,53 ≈ 0,61 Digno, pero ciego a las interacciones
Ridge ≈ 0,72 ≈ 0,53 ≈ 0,61 Regularizar no ayuda: no había overfitting lineal
Lasso ≈ 0,72 ≈ 0,53 ≈ 0,61 Ídem; con alpha alto empieza a anular coeficientes
Dummy (media) ≈ 1,15 ≈ 0,91 ≈ 0,00 El listón mínimo

Tres lecturas honestas, en el espíritu de 06-05 "Overfitting y underfitting":

  1. Los modelos lineales sufren underfitting: con 8 features y 16.500 filas, el problema no es la varianza sino el sesgo — la relación precio-geografía no es lineal. Por eso Ridge y Lasso, que combaten el overfitting, no aportan nada aquí.
  2. La hipótesis del EDA se confirma: los modelos de árboles, capaces de particionar el espacio latitud-longitud en regiones, reducen el RMSE un 35 %.
  3. Las desviaciones típicas importan: si dos modelos difieren menos que la variabilidad entre particiones, declarar un "ganador" es ruido.

Análisis de errores

Antes de dar por bueno el mejor modelo, miramos dónde falla. Entrenamos el HistGradientBoosting sobre todo el train y analizamos sus residuos con una validación interna:

from sklearn.model_selection import cross_val_predict

mejor = HistGradientBoostingRegressor(random_state=42)
y_pred_cv = cross_val_predict(mejor, X_train, y_train, cv=cv)
residuos = y_train - y_pred_cv

fig, ejes = plt.subplots(1, 2, figsize=(13, 5))
ejes[0].scatter(y_pred_cv, residuos, s=5, alpha=0.3)
ejes[0].axhline(0, color="red", lw=1)
ejes[0].set_xlabel("Predicción"); ejes[0].set_ylabel("Residuo")
ejes[0].set_title("Residuos vs. predicción")

ejes[1].scatter(X_train["Longitude"], X_train["Latitude"],
                c=residuos.abs(), cmap="Reds", s=8, alpha=0.4)
ejes[1].set_title("Error absoluto sobre el mapa")
plt.show()

Qué buscar y qué suele aparecer:

  • Una franja diagonal de residuos positivos en la derecha del primer gráfico: son los distritos censurados en 5,0. El modelo predice 3,5–4,5 para distritos que "valen 5+", y no puede hacerlo mejor porque la etiqueta está recortada. Es un límite del dato, no del modelo.
  • En el mapa de errores, los fallos grandes se concentran en zonas costeras caras y en distritos atípicos (pocas viviendas, usos no residenciales). Saber dónde falla el modelo vale más que una décima de RMSE: orienta qué datos adicionales pedir.

Evaluación final en test

Solo ahora, con el modelo elegido y sin más decisiones pendientes, abrimos el test. Una única vez, como juraste en 06-01:

from sklearn.metrics import root_mean_squared_error, mean_absolute_error, r2_score

mejor.fit(X_train, y_train)
y_pred = mejor.predict(X_test)

print("RMSE test:", round(root_mean_squared_error(y_test, y_pred), 3))
print("MAE  test:", round(mean_absolute_error(y_test, y_pred), 3))
print("R²   test:", round(r2_score(y_test, y_pred), 3))

Deberías obtener cifras muy parecidas a las de la validación cruzada (RMSE ≈ 0,46–0,48, R² ≈ 0,83). Esa coincidencia es la noticia: significa que el protocolo fue limpio y la estimación de CV era fiable. Si el test hubiera salido claramente peor, la sospecha sería una fuga de datos o decisiones sobreajustadas a la validación.

Traducción a negocio: un MAE de ≈ 0,31 significa que el modelo se equivoca, en mediana de distrito, unos 31.000 $ — frente a los 91.000 $ del baseline.

Errores Comunes y Consejos

  • Explorar antes de dividir. Si calculas correlaciones o dibujas el mapa con el dataset completo, tus decisiones ya han "visto" el test. Divide primero, siempre.
  • Escalar fuera del Pipeline. Ajustar StandardScaler sobre todos los datos y luego hacer CV es la fuga clásica de 06-01. El Pipeline lo hace bien automáticamente.
  • Ignorar la censura del objetivo. El pico en 5,0 no es un cluster de mercado: es un recorte del dataset. En un informe real habría que declararlo (y valorar excluir esos distritos o modelarlos aparte).
  • Elegir modelo por una sola cifra. Compara RMSE, MAE y R² con su desviación entre particiones; dos modelos "distintos" en la tercera decimal son el mismo modelo.
  • Interpretar R² como porcentaje de aciertos. R² = 0,83 no significa "acierta el 83 %": significa que explica el 83 % de la varianza del precio. La cifra en dólares (MAE/RMSE) es la que entiende negocio.

Retos para ampliar

  1. Features de interacción. Crea variables nuevas al estilo de 03-06 "Ingeniería de características": habitaciones_por_ocupante = AveRooms / AveOccup, dormitorios_ratio = AveBedrms / AveRooms, o la distancia de cada distrito a las coordenadas de San Francisco y Los Ángeles. Pista: mide su efecto sobre la regresión lineal (donde más deberían ayudar) y sobre el boosting (que quizá ya las descubría solo).
  2. XGBoost contra HistGradientBoosting. Instala xgboost y enfréntalo al ganador con los hiperparámetros por defecto; después afina ambos con RandomizedSearchCV como en 07-05 "Optimización de hiperparámetros". Pista: empieza por n_estimators, learning_rate y max_depth.
  3. Intervalos de predicción. Un precio puntual sin incertidumbre es media respuesta. Investiga HistGradientBoostingRegressor(loss="quantile", quantile=0.05) y su gemelo con 0,95 para construir un intervalo del 90 %. Pista: comprueba qué fracción de precios reales del test cae dentro del intervalo — debería rondar el 90 %.

Conclusión

Primer proyecto completado: has recorrido tú solo el flujo entero que MercaFresh te enseñó por fases — definir el problema y su métrica, apartar el test antes de mirar nada, explorar con hipótesis (la geografía manda), preprocesar dentro de un Pipeline, escalar desde un baseline trivial hasta el gradient boosting, comparar con validación cruzada honesta, analizar residuos para saber dónde y por qué falla el modelo, y confirmar en test que nada se había contaminado. La lección de fondo: el mejor modelo no ganó por magia, sino porque el EDA ya anunciaba una estructura no lineal que los árboles capturan y una recta no. En el siguiente proyecto cambiamos por completo de terreno: de tablas de números a imágenes, donde comprobarás en carne propia por qué los métodos clásicos se quedan cortos y las redes convolucionales de 07-04 toman el relevo.

Curso de Machine Learning

Módulo 1: Introducción al Machine Learning

Módulo 2: Fundamentos de Estadística y Probabilidad

Módulo 3: Preprocesamiento de Datos

Módulo 4: Algoritmos de Machine Learning Supervisado

Módulo 5: Algoritmos de Machine Learning No Supervisado

Módulo 6: Evaluación y Validación de Modelos

Módulo 7: Técnicas Avanzadas y Optimización

Módulo 8: Implementación y Despliegue de Modelos

Módulo 9: Proyectos Prácticos

Módulo 10: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados