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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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=10 | RMSE 0.6452 | 6 s |
linear C=1,000 | RMSE 0.6452 | 4.8 min |
linear C=10,000 | RMSE 0.6439 | 44 min |
linear C=30,000 | RMSE 0.6465 | 117 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.
# 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
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.
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=1 | 0.5445 |
| rbf, gamma=0.1, C=3 | 0.5347 el mejor de los 50 |
| rbf, gamma=0.1, C=100 | 0.6059 |
| rbf, gamma=0.1, C=1,000 | 0.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 ejecutados | 2.4 h de reloj |
| 19 lineales | 2.2 h, el 92% del tiempo |
| 24 rbf | 10.8 minutos |
| 7 saltados por el tope | 27.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.
# 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
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.
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ón | 0.5347 | 25 columnas |
| SVR con umbral 0.005 | 0.5393 +0.0046 | 20 columnas |
| SVR con umbral "median" | 0.5478 +0.0131 | 13 columnas |
| random_forest sin selección | 0.5027 | 25 columnas |
| random_forest con umbral 0.005 | 0.5026 | 20 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.
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
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.
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.
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.
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.
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 test | 72.45 |
| Máximo del objetivo | 5.00001 |
| Predicciones fuera de rango | 54 de 4,128 |
| RMSE recortado al rango | 0.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.
# 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
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_.
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.
# 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 transform | Propio, y sklearn no lo reconocía |
| Orden de mixins | BaseEstimator 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
Pipeline.