← Todos los capítulos
Capítulo 2 · California Housing

Lo que el modelo aprendió,
y lo que no se puede afirmar

20,640 viviendas, nueve modelos, 180 experimentos registrados. El resultado interesante no fue el error más bajo, sino descubrir cuáles de las mejoras eran reales y cuáles solo lo parecían.

Modelo desplegadoXGBoost300 árboles · 2.4 MB
RMSE en test0.429± USD 42,910
R²0.8634,128 casas
Latencia2.2 mspor predicción
Hallazgos

Tres cosas que resultaron al revés de lo esperado

La variable “más importante” no mejoraba nada

+0.004 RMSE en XGBoost

El dataset incluye una variable categórica que dice si el distrito está tierra adentro, cerca de la bahía o frente al mar. Al añadirla, XGBoost la reportó como su variable número uno por importancia, 0.61 contra 0.14 del ingreso medio, que era el favorito hasta entonces. Parecía la mejora grande del proyecto.

Midiendo con una ablación (mismo conjunto, misma semilla, única diferencia la columna) resultó que mejora seis de siete modelos por márgenes mínimos, y que a XGBoost lo empeora.

La explicación: latitud y longitud ya estaban ahí. “Cerca del mar” era deducible de las coordenadas, solo que costaba varios cortes. Al recibirlo servido, el modelo lo usa de inmediato y reordena su ranking de importancia, pero la información no era nueva. Importancia alta no significa mejora predictiva cuando hay redundancia.

Lo que sí funcionó fue darle la geografía masticada

−0.036 RMSE

Un árbol solo puede partir el espacio con cortes rectangulares sobre latitud y longitud, así que aproximar “cerca de San Francisco” le cuesta muchos cortes y nunca queda fino. La solución del libro es un transformador propio: agrupar las coordenadas con K-means y reemplazar cada casa por su similitud a cada centro.

Fue el cambio que más rindió de todo el proyecto, más que cualquier ajuste de hiperparámetros. Y reordenó por completo la tabla: los métodos que promedian árboles independientes, que eran los más penalizados por tener que aproximar la geografía a mano, pasaron a ganarle a los de boosting.

Una variable que se veía excelente estaba haciendo trampa

correlación 0.85 contra 0.99

Siguiendo la misma idea, se añadió otra variable geográfica: un k-vecinos sobre las coordenadas que responde “¿cuánto cuestan las casas de al lado?”. Su respuesta entra al modelo como una columna más.

En la primera versión su correlación con el precio era de 0.993. Una variable casi perfecta, y ahí estaba el problema: era demasiado buena.

El k-vecinos se había entrenado con las mismas casas sobre las que después predecía, así que cada casa aparecía entre sus propios vecinos. Estaba copiando la respuesta. Calculando la predicción de cada fila con un modelo que nunca la vio, la correlación cae a 0.845. Ese es su valor real.

Aun corregida, sigue siendo la segunda variable más útil del proyecto. Pero sin ese ajuste habría lucido espléndida durante el entrenamiento y se habría derrumbado en producción.

Cuánto aportó cada decisión
Medido con ablación sobre el mismo conjunto: se quita el elemento y se vuelve a entrenar. La selección de variables aparece del lado malo porque descarta la mitad de las columnas y cuesta precisión. La partición estratificada no aparece porque no mueve el error. Hace que el número sea confiable, que es otra cosa.
Dónde falla

El error no está repartido por igual

Un RMSE es un número por 4,128 distritos, y esconde que el modelo se equivoca mucho más en unas zonas que en otras.

Proximidad al océano
Categoría de ingreso
Colorear por
Cada punto es un distrito que el modelo nunca vio al entrenar. Las predicciones se calcularon una vez y se sirven como JSON; los filtros y las métricas se recalculan en el navegador sobre el subconjunto visible.
Demo Inventar un distrito y ver qué precio le pone el modelo
El hallazgo incómodo

Tres modelos empatados, aunque la tabla diga otra cosa

Con todo afinado, extra_trees quedó primero con 0.4063 de RMSE y XGBoost tercero con 0.4291. Ordenar por esa columna y declarar un ganador es el reflejo natural. Es también un error.

El RMSE sale de una muestra concreta de 4,128 casas. Con otras 4,128 habría dado distinto. El intervalo de confianza acota cuánto puede moverse, y cuando dos intervalos se traslapan, la diferencia cabe dentro del ruido del muestreo.

RMSE con intervalo de confianza al 95%
Calculado con la distribución t sobre los errores cuadrados del conjunto de prueba. Los tres primeros se solapan entre sí: no hay evidencia para ordenarlos.
La decisión

Entonces gana el que se puede desplegar

Si la precisión no distingue a los tres primeros, el desempate lo pone lo que sí se puede medir sin ambigüedad. Y ahí la diferencia no es sutil.

Costo de servir cada modelo
Medido con make cost: cada artefacto se carga en su propio subproceso y se cronometran 20 predicciones de una sola fila, de las que se reporta la mediana. Modelo en RAM es lo que añade el modelo al cargarse; proceso incluye además los 258 MB de suelo del intérprete y las librerías, idénticos en los cuatro, y es lo que hay que provisionar de verdad.

extra_trees requiere 2.2 GB de memoria para el modelo y 2.5 GB de proceso, por encima de lo que admite un plan gratuito o un servidor pequeño, y tarda trece veces más por petición. XGBoost ocupa 2.4 MB de artefacto, 0.4 GB de proceso, y responde en 2.2 milisegundos.

El intercambio son 0.023 de RMSE que no se pueden demostrar, contra un artefacto 293 veces más pequeño. Cuando la precisión no distingue, el desempate lo pone el costo.

En la tabla aparece además un resultado que no se buscaba: random_forest tarda lo mismo que extra_trees, 28.7 ms los dos. La lentitud no corresponde a un modelo sino a la familia: ambos promedian 300 árboles crecidos sin podar, y cada predicción los recorre enteros. Los de boosting podan a 7 niveles y bajan a 2 ms.

De ahí se sigue una consecuencia incómoda. gradient_boosting quedó cuarto, y su intervalo no se traslapa con el de extra_trees: contra el mejor de la tabla sí es medible que es peor. Pero sí se traslapa con el de XGBoost, que es el modelo desplegado. Entre esos dos la precisión tampoco distingue.

Y en costo gradient_boosting va parejo o por delante: 1.9 ms contra 2.2, y 116 MB de modelo contra 123. Lo único donde XGBoost gana con holgura es el artefacto, 2.4 MB contra 7.3. Ese es el criterio que de verdad separa a los dos, y no se aplicó nunca, porque la comparación de despliegue se cerró sobre los dos primeros del RMSE sin revisar quién más tenía el intervalo traslapado.

Resultados

Tabla completa

Siete de los nueve pasaron por búsqueda aleatoria de hiperparámetros, sobre el modelo y el preprocesamiento. Cada combinación probada quedó registrada en MLflow como ejecución anidada: 137 candidatos en total.

Los dos huecos de la columna de validación

linear_regression no tiene hiperparámetros que buscar, así que nunca entró en una búsqueda y no hay nada que anotar en esa columna. Su RMSE de prueba es su único número.

svr sí quedó fuera por decisión, y con medición detrás. svr_study.py encontró que el costo de la búsqueda no está en el tamaño del conjunto (el ajuste escala con n^1.33, no con n²) sino en el valor de C: con kernel polinómico y C = 100 un solo ajuste tarda 60.5 s, contra 0.45 s con rbf y C = 1. Multiplicado por cinco pliegues y decenas de candidatos, la búsqueda completa no cabía en el presupuesto del capítulo.

Afinado sobre una submuestra de 8,000 filas llegó a 0.523 en validación y 0.531 en prueba, peor que los 0.509 de su propia versión sin afinar con todos los datos, y lejos de los 0.429 del mejor. La búsqueda entera habría confirmado lo que la submuestra ya sugiere. Reproducible con make svr-study.

Los ejercicios

Los seis, resueltos dentro del desarrollo

El capítulo cierra con seis ejercicios, y el desarrollo los fue resolviendo por su cuenta porque los necesitaba. Pero resolver algo parecido no es resolver lo que el enunciado pide, así que cada uno se ejecutó también en la versión del libro. De ahí salieron las dos horas que cuesta el candidato más caro del ejercicio 1, y la fuga que tiene la solución oficial del ejercicio 4.

Ejercicio 1 SVR con varios kernels e hiperparámetros src/svr_official.py · src/svr_study.py 0.5347 el mejor · 2 h el candidato más caro, y empeora

Qué pideProbar un SVR con kernel lineal y varios valores de C, y con kernel rbf y varios valores de C y gamma. El enunciado advierte que SVM no escala bien, así que sugiere entrenar con las primeras 5,000 filas y validación cruzada de 3 pliegues. Y pregunta cuánto rinde el mejor.

La versión del libro
param_grid = [
    {"svr__kernel": ["linear"], "svr__C": [10., 30., 100., 300., 1000.,
                                           3000., 10000., 30000.]},
    {"svr__kernel": ["rbf"],    "svr__C": [1., 3., 10., 30., 100., 300., 1000.],
     "svr__gamma": [0.01, 0.03, 0.1, 0.3, 1., 3.]},
]

50 candidatos con cv=3, ejecutados enteros. La respuesta a la pregunta del enunciado: rbf con C = 3 y gamma = 0.1, 0.5347 en validación y 0.5414 en prueba. Tarda 0.7 segundos.

linear C=10RMSE 0.64526 s
linear C=1,000RMSE 0.64524.8 min
linear C=10,000RMSE 0.643944 min
linear C=30,000RMSE 0.6465117 min

Ahí está lo que el ejercicio no anuncia: el kernel lineal se estanca. Tres órdenes de magnitud de C mueven la cuarta decimal, y el último valor, que costó dos horas, sale peor que el primero, que costó seis segundos. C castiga el error de entrenamiento, pero un hiperplano no se dobla por mucho que se le castigue: el error que queda es de sesgo, y C no toca el sesgo.

Ocho de los 50 candidatos se gastaron en esa meseta. La rejilla no puede saberlo, porque la lista se escribe antes de medir nada.

Qué pasó aquí
# src/svr_study.py: medir de dónde viene el costo, en vez de suponerlo
for kernel in ["rbf", "linear", "poly"]:
    for C in [1.0, 10.0, 100.0]:
        pipe = build_pipeline(SVR(kernel=kernel, C=C), Xs)
        t0 = time.perf_counter()
        pipe.fit(Xs, ys)
        rows.append({"kernel": kernel, "C": C,
                     "seconds": time.perf_counter() - t0})

La explicación cómoda era que SVR escala con el cuadrado del número de filas. Medido sobre cinco tamaños, el exponente real es 1.33, y un ajuste completo sobre las 16,512 filas se proyecta en 6.2 segundos. Lo que no escala es C: con kernel polinómico y C = 100, 60.5 s; con rbf y C = 1, 0.45 s. Los mismos datos, 135 veces el tiempo.

El aviso del enunciado apunta al sitio equivocado: recortar a 5,000 filas ataca la parte barata.

La proyección del costo también falló, y en la misma dirección: 79 minutos calculados para el último candidato contra 117 reales. Extrapolar un factor por década sobre dos décadas y media se queda corto cuando el factor crece.

make svr-study

Ejercicio 2 Búsqueda aleatoria en lugar de rejilla src/tune.py pierde contra la rejilla: 0.5553 contra 0.5414

Qué pideSustituir GridSearchCV por RandomizedSearchCV, sobre el mismo SVR y las mismas 5,000 filas del ejercicio anterior.

La versión del libro
param_distribs = {
    "svr__kernel": ["linear", "rbf"],
    "svr__C": loguniform(20, 200_000),
    "svr__gamma": expon(scale=1.0),
}
RandomizedSearchCV(svr_pipeline, param_distribs, n_iter=50, cv=3,
                   scoring="neg_root_mean_squared_error", random_state=42)

Lo que enseña no es cambiar de clase, es de dónde salen los valores: loguniform reparte parejo por órdenes de magnitud, así que explora la zona baja de C tanto como la alta, y muestrea 50 valores distintos en cada eje en lugar de los 8 o 6 de una lista (Bergstra y Bengio, 2012).

Ejecutada aquí, pierde: 0.5553 en prueba contra 0.5414 de la rejilla, con el mismo presupuesto de 50 candidatos. Y el motivo no es el método.

rbf, gamma=0.1, C=10.5445
rbf, gamma=0.1, C=30.5347 el mejor de los 50
rbf, gamma=0.1, C=1000.6059
rbf, gamma=0.1, C=1,0000.7846

El óptimo está en C = 3, y loguniform(20, 200_000) empieza en 20: la zona buena queda fuera de su rango por debajo. La rejilla la cubría porque su lista de rbf empieza en 1. Gana el método cuyo rango contiene el óptimo, y la ventaja de la aleatoria no es magia sino cobertura.

En el notebook oficial ocurre lo contrario, y por la misma razón invertida: allí la rejilla elige el kernel lineal con 69,814 de RMSE, porque su rbf se corta en C = 1000, y la aleatoria encuentra C = 157,055 y baja a 55,854. En un pipeline el óptimo estaba por encima del borde y en el otro por debajo. Un rango de C no se copia entre proyectos: es una suposición sobre unos datos concretos.

43 candidatos ejecutados2.4 h de reloj
19 lineales2.2 h, el 92% del tiempo
24 rbf10.8 minutos
7 saltados por el tope27.1 h que no se gastaron

La mitad de los sorteos cae en el kernel lineal, que el ejercicio 1 ya midió como una meseta. Un tope de 30 minutos por candidato ahorró 27 horas de CPU en una rama sin recorrido, y los siete saltados quedan anotados con su proyección.

Qué pasó aquí
# src/tune.py: cada candidato queda registrado, no solo el ganador
with mlflow.start_run(nested=True, run_name=f"candidato_{i:02d}"):
    mlflow.log_params(params)
    mlflow.log_metric("cv_rmse", -score)
    mlflow.log_metric("rank", rank)

Toda la búsqueda del capítulo usa RandomizedSearchCV con cinco pliegues, sobre los nueve modelos. La rejilla no escala: cinco parámetros con cinco valores cada uno son 3,125 combinaciones.

El añadido está en el registro. Sin él, cv_results_ se descarta al terminar y queda una cifra sin contexto; con los 137 candidatos guardados como ejecuciones anidadas se puede ver después qué movió la aguja y qué no.

make svr-random

Ejercicio 3 Selección de variables dentro del pipeline src/preprocessing.py · src/select_study.py empeora en los cuatro casos medidos

Qué pideAñadir un transformador SelectFromModel al pipeline de preparación, para quedarse solo con los atributos más importantes.

La versión del libro
selector_pipeline = Pipeline([
    ("preprocessing", preprocessing),
    ("selector", SelectFromModel(RandomForestRegressor(random_state=42),
                                 threshold=0.005)),   # importancia mínima
    ("svr", SVR(**mejores_parametros)),
])

Umbral absoluto y sobre el SVR de 5,000 filas, no sobre el bosque con todos los datos. Ejecutado con el ganador del ejercicio 1:

SVR sin selección0.534725 columnas
SVR con umbral 0.0050.5393 +0.004620 columnas
SVR con umbral "median"0.5478 +0.013113 columnas
random_forest sin selección0.502725 columnas
random_forest con umbral 0.0050.502620 columnas

El umbral del libro es tan bajo que apenas descarta cinco columnas, y sobre el bosque no cambia nada. Cuanto más agresivo el umbral, peor el resultado.

Qué pasó aquí
steps = [("prep", build_preprocessor(X, use_geo_clusters, use_knn_geo, use_log))]
if select_features:
    steps.append(("select", SelectFromModel(
        RandomForestRegressor(n_estimators=50, random_state=42, n_jobs=-1),
        threshold=select_threshold,
    )))
steps.append(("model", estimator))

Un paso opcional del pipeline, entre el preprocesamiento y el modelo. Sobre random_forest con las 16,512 filas y umbral "median" descarta cerca de la mitad de las columnas y cuesta 0.0068 de RMSE: la única barra del lado malo en la gráfica de aportes.

Cuatro mediciones, dos modelos, dos umbrales, y ninguna mejora. Con 20,640 filas y una docena de variables no hay problema de dimensionalidad que compense la información perdida. El resultado negativo es parte de lo que el ejercicio enseña: la selección de variables no es gratis por defecto.

make select-study

Ejercicio 4 Transformador propio con k-vecinos sobre las coordenadas src/preprocessing.py −0.0146 RMSE · correlación 0.845 y no 0.993

Qué pideEscribir un transformador propio que entrene un KNeighborsRegressor en su fit() y devuelva sus predicciones en transform(). Añadirlo al pipeline usando latitud y longitud como entradas, para que aporte el precio mediano de los distritos vecinos.

La versión del libro
def transform(self, X):
    check_is_fitted(self)
    predictions = self.estimator_.predict(X)   # el mismo X con el que se ajustó
    if predictions.ndim == 1:
        predictions = predictions.reshape(-1, 1)
    return predictions

La solución oficial tiene fuga. Sobre el pliegue de entrenamiento, ese predict devuelve predicciones en muestra: con weights="distance", cada casa aparece entre sus propios vecinos a distancia cero y la columna copia su etiqueta.

No es un detalle cosmético. Medido: con la fuga, la correlación de la columna con el precio es 0.993; sin ella, 0.845. Una variable que parece casi perfecta y que en datos nuevos no lo es.

Qué pasó aquí
def fit_transform(self, X, y=None, **kwargs):
    # Sobre el train, predicciones FUERA DE PLIEGUE: cada fila la predice un
    # k-vecinos que no la vio. TransformerMixin llamaría fit().transform()
    # y filtraría el objetivo.
    self.fit(X, y)
    return self.oof_

def transform(self, X):
    # Datos nuevos: aquí sí vale el k-vecinos entrenado con todo, porque no
    # hay solapamiento posible.
    return self.knn_.predict(check_array(X)).reshape(-1, 1)

KNNGeoFeature ajusta un k-vecinos (k = 10, pesos por distancia) sobre las coordenadas y mete su predicción como una columna más. Responde a cuánto cuestan las casas de al lado.

La asimetría entre fit_transform y transform es todo el ejercicio, y TransformerMixin no la da gratis: su fit_transform por defecto encadena fit y transform, que es justo lo que filtra. Por eso el método está escrito a mano.

Aporta 0.0146 de RMSE en random_forest y 0.0121 en XGBoost: la segunda variable más útil del proyecto.

Ejercicio 5 Explorar opciones de preparación en la misma búsqueda src/prep_search.py · src/tune.py 0.5123 en prueba, pero 1.3263 sin recortar

Qué pideUsar la búsqueda de hiperparámetros para explorar también las opciones de preparación de datos. La solución oficial mete en el mismo espacio los parámetros del transformador de k-vecinos del ejercicio 4 y los del SVR.

La versión del libro
param_distribs = {
    "preprocessing__geo__estimator__n_neighbors": range(1, 30),
    "preprocessing__geo__estimator__weights": ["distance", "uniform"],
    "svr__C": loguniform(20, 200_000),
    "svr__gamma": expon(scale=1.0),
}

Lo que cambia respecto al ejercicio 2 no es el espacio, es que ahora el preprocesamiento se reajusta en cada pliegue. Solo se puede porque vive dentro del Pipeline: ajustar el k-vecinos una vez fuera contaminaría la validación con las filas que después hacen de validación.

El mejor de los 40 candidatos medidos es linear con C = 45.19, k = 8 y pesos por distancia: 0.4917 en validación cruzada. Mejora el 0.5347 del ejercicio 1, así que buscar el preprocesamiento junto al modelo sirvió.

En prueba da 1.3263, y la distancia entre las dos cifras es todo el resultado.

Predicción mayor del test72.45
Máximo del objetivo5.00001
Predicciones fuera de rango54 de 4,128
RMSE recortado al rango0.5123

La culpa es de AveOccup. Dos distritos del test valen 599.7 y 1243.3 ocupantes por vivienda, y el máximo de las 5,000 filas de entrenamiento es 21.33. Un kernel lineal no está acotado, así que sobre entradas de hasta 58 veces ese máximo devuelve 34 y 72. Recortar al rango conocido del objetivo baja el RMSE de 1.3263 a 0.5123, y sigue ganándole al 0.5414 del ejercicio 1.

El mejor rbf no tiene el problema: predice como mucho 6.3, y recortar apenas le mueve la cifra, de 0.6017 a 0.5988. La validación cruzada premió al lineal porque los pliegues salen de las mismas 5,000 filas, donde esos distritos no aparecen.

Qué pasó aquí
# src/prep_search.py: el recorte se anota, no se calla
estado["recorte"] = {
    "ejecutados": 40,
    "sin_ejecutar": 10,
    "reloj_medido_horas": 1.14,
    "reloj_proyectado_restante_horas": 24.2,
}

La búsqueda se cortó en 40 de 50 candidatos. Los 40 costaron 1.14 h de reloj medido; los 10 que faltan son todos lineales con C entre 4,685 y 145,200, y proyectan 24.2 h más. Por encima del tope de dos horas del cuaderno, así que quedan anotados con su proyección en lugar de ejecutados.

Es la misma meseta del ejercicio 1 vista desde otro sitio: el candidato ganador tardó 26.6 s, y el más caro de los medidos gastó 1,572 s para salir peor.

En el desarrollo del capítulo la idea entra por otra puerta. src/tune.py mete tres decisiones del preprocesamiento en el espacio de búsqueda de los nueve modelos: la estrategia de imputación, el número de barrios representativos del agrupamiento geográfico, y el gamma de la similitud a esos barrios.

De ahí salió una lección sobre los rangos. Con el número de barrios acotado a 30, la búsqueda elegía exactamente 30. Tocar el borde del espacio significa que el óptimo puede estar más allá, así que el rango se extendió hasta 60.

make prep-search

Ejercicio 6 Reimplementar StandardScaler desde cero src/preprocessing.py 4 defectos, dos los encontró check_estimator

Qué pideReimplementar StandardScalerClone desde cero. Añadirle inverse_transform, guardar feature_names_in_ cuando la entrada es un DataFrame, e implementar get_feature_names_out con un argumento opcional input_features, que debe comprobarse contra n_features_in_ y contra feature_names_in_.

La versión del libro
class StandardScalerClone(TransformerMixin, BaseEstimator):
    def fit(self, X, y=None):
        X = validate_data(self, X, ensure_2d=True)
        self.n_features_in_ = X.shape[1]
        if self.with_mean:
            self.mean_ = np.mean(X, axis=0)
        self.scale_ = np.std(X, axis=0, ddof=0)
        self.scale_[self.scale_ == 0] = 1   # evitar dividir entre cero
        return self

No aporta nada al modelo: el StandardScaler de sklearn hace lo mismo y mejor. Lo que enseña es el contrato que cumple un transformador, y ese contrato es lo que hace falta para escribir los propios. De ahí salieron los de los ejercicios 3 y 4.

Qué pasó aquí
# Lo que fallaba, y no se veía:
X = check_array(X)          # el DataFrame se vuelve ndarray
if hasattr(X, "columns"):   # ya no hay columnas: la rama nunca entra
    self.feature_names_in_ = ...

# Lo que quedó:
if hasattr(X, "columns"):
    self.feature_names_in_ = np.array(X.columns, dtype=object)
X = validate_data(self, X)  # guarda n_features_in_ y lo comprueba
                            # con el mensaje que espera check_estimator

La versión que había aquí daba por implementados los tres añadidos, y probados fallaban dos. check_estimator, que es lo que usa el propio notebook, encontró otros dos que a mano no se veían.

feature_names_in_No se guardaba nunca
get_feature_names_out()Devolvía x0, x1 en vez de los nombres
get_feature_names_out(otros)Aceptaba una lista de cualquier largo
Mensaje de transformPropio, y sklearn no lo reconocía
Orden de mixinsBaseEstimator iba primero, al revés

El defecto de fondo cabía en el orden de dos líneas: check_array devuelve un array, así que preguntar por X.columns después de validar no encuentra nunca nada, y el atributo se queda sin poner en silencio. Ninguna prueba propia lo detectaba porque la prueba era que no reventara.

El orden de los mixins afectaba a los tres transformadores del capítulo, no solo a este.

check_estimator(StandardScalerClone()) → pasa

Cada ficha lleva tres bloques: qué pide el enunciado, la versión del libro ejecutada, y qué pasó aquí. Enunciados según el notebook de la tercera edición.
Detalles
Datos
California Housing, 20,640 distritos del censo de 1990. CSV original del repositorio del libro.
Partición
80/20 estratificada por categoría de ingreso. Una partición aleatoria simple subrepresentaba el estrato más pobre en 9.4%.
Preprocesamiento
Imputación por mediana, estandarización, codificación one-hot y similitud a centros geográficos, todo dentro del Pipeline.
Seguimiento
MLflow. 180 ejecuciones, 137 de ellas candidatos anidados de las búsquedas.