Módulo 3 · Aprendizaje supervisado

Lección 6 de 9

Ingeniería de características

En esta páginaEl problema

El problema

Una inmobiliaria registra 1000 ventas sintéticas de departamentos. Cada fila trae la superficie, las recámaras, el año de construcción, la fecha en que se publicó el anuncio, la fecha de venta, la distancia al metro, el estado de conservación, la colonia y el vendedor. El objetivo es predecir precio_miles, el precio en miles de pesos.

generar_departamentos(n, semilla) simula n ventas con esas columnas, y tabla guarda las 1000. ORDEN lista los niveles de conservación de peor a mejor.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
import numpy as np
import pandas as pd

ORDEN = ["malo", "regular", "bueno", "excelente"]


def generar_departamentos(n, semilla):
    rng = np.random.default_rng(semilla)
    superficie = rng.uniform(35, 160, n).round()
    recamaras = np.clip(np.round(superficie / 40 + rng.normal(0, 0.7, n)), 1, 4)
    anio = rng.integers(1965, 2024, n)
    inicio = pd.Timestamp("2024-01-01")
    publicacion = inicio + pd.to_timedelta(rng.integers(0, 150, n), unit="D")
    dias = rng.integers(5, 200, n)
    venta = publicacion + pd.to_timedelta(dias, unit="D")
    distancia = rng.lognormal(6.5, 1.0, n).round()
    conservacion = rng.choice(ORDEN, n, p=[0.15, 0.35, 0.35, 0.15])
    colonias = [f"col_{i:02d}" for i in range(1, 41)]
    efecto_colonia = dict(zip(colonias, rng.normal(0, 300, 40)))
    colonia = rng.choice(colonias, n)
    vendedor = rng.choice([f"v{i:03d}" for i in range(300)], n)

    nivel = pd.Series(conservacion).map(ORDEN.index).to_numpy()
    precio = (
        1000
        + superficie * (32 - 8 * np.log1p(distancia / 1000))
        + 30 * superficie / recamaras
        - 12 * (venta.year - anio)
        - 5 * dias
        + 300 * nivel
        + pd.Series(colonia).map(efecto_colonia).to_numpy()
        + rng.normal(0, 150, n)
    )
    return pd.DataFrame(
        {
            "superficie_m2": superficie,
            "recamaras": recamaras.astype(int),
            "anio_construccion": anio,
            "fecha_publicacion": publicacion,
            "fecha_venta": venta,
            "distancia_metro_m": distancia,
            "conservacion": conservacion,
            "colonia": colonia,
            "vendedor": vendedor,
            "precio_miles": precio.round(),
        }
    )


tabla = generar_departamentos(1000, 0)

.T transpone la tabla para mostrar cada venta en una columna:

Python 3 · ejemplo reproducible
# astype(str): cada valor conserva su formato al transponer
print(tabla.head(3).astype(str).T.to_string())
Salida esperada

La salida esperada está oculta.

Cada venta tiene dos fechas y tres columnas de texto: conservacion, colonia y vendedor. Una regresión lineal solo acepta números, así que la primera versión usa las cuatro columnas numéricas tal como llegan. lineal escala esas columnas con StandardScaler antes de la regresión. KFold(n_splits=5, shuffle=True, random_state=0) fija 5 folds que se reutilizan en toda la lección, y rmse_folds devuelve el RMSE de cada fold. scoring="neg_root_mean_squared_error" entrega el RMSE con signo negativo, y la función se lo quita.

Python 3 · ejemplo reproducible
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import KFold, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

y = tabla["precio_miles"]
cv = KFold(n_splits=5, shuffle=True, random_state=0)


def rmse_folds(modelo, X):
    puntajes = cross_val_score(
        modelo, X, y, cv=cv, scoring="neg_root_mean_squared_error"
    )
    return -puntajes


lineal = make_pipeline(StandardScaler(), LinearRegression())
base = ["superficie_m2", "recamaras", "anio_construccion", "distancia_metro_m"]
rmse_base = rmse_folds(lineal, tabla[base])
print("RMSE base:", round(rmse_base.mean(), 1))
Salida esperada

La salida esperada está oculta.

Las fechas, la conservación, la colonia y el vendedor no entran al modelo. El RMSE de validación cruzada con 5 folds es de 582 mil pesos. Alguien propone agregar de una vez veinte columnas nuevas y quedarse con la versión que tenga menor RMSE.

Características derivadas

Una regresión lineal multiplica cada columna por un coeficiente y suma. No puede dividir una columna entre otra ni representar una relación curva con una sola columna. Tampoco recibe fechas: hay que convertirlas en números. Si una de esas relaciones existe, hay que construirla como columna nueva. Estas son cuatro candidatas:

  • m2_por_recamara: razón entre superficie y recámaras
  • antiguedad: año de venta menos año de construcción
  • dias_en_mercado: diferencia entre fecha de venta y fecha de publicación
  • log_distancia: np.log1p(distancia_metro_m), es decir, log(1 + x)

log1p comprime los valores grandes y está definido en 0. La asimetría (skew) mide cuánto se carga una distribución hacia un lado: es positiva si la cola larga está a la derecha y vale 0 si es simétrica.

En la celda, .dt.year extrae el año de cada fecha, .dt.days convierte una diferencia de fechas en días y skew() calcula la asimetría de una columna.

Python 3 · ejemplo reproducible
derivadas = pd.DataFrame(
    {
        "m2_por_recamara": tabla["superficie_m2"] / tabla["recamaras"],
        "antiguedad": tabla["fecha_venta"].dt.year - tabla["anio_construccion"],
        "dias_en_mercado": (tabla["fecha_venta"] - tabla["fecha_publicacion"]).dt.days,
        "log_distancia": np.log1p(tabla["distancia_metro_m"]),
    }
)
print("asimetría de distancia_metro_m:", round(tabla["distancia_metro_m"].skew(), 2))
print("asimetría de log_distancia:", round(derivadas["log_distancia"].skew(), 2))
Salida esperada

La salida esperada está oculta.

La primera comparación agrega cada derivada, una por vez, a las cuatro columnas base. Todas las versiones usan los mismos folds, así que la comparación es fold contra fold.

Pregunta. ¿Cuál de las cuatro columnas baja más el RMSE base? ¿Alguna lo deja igual? Todas las ventas ocurrieron en 2024.

Ver resultado

Cada fila agrega una derivada a las cuatro columnas base. RMSE es la media de los 5 folds, cambio es la diferencia con el RMSE base de 582.0 y folds que mejoran cuenta los folds en los que el RMSE baja más de 0.1. La fila de m2_por_recamara dice que con esa columna el RMSE medio es 549.6, 32.4 menos que la base, y que baja en los 5 folds.

versiónRMSEcambiofolds que mejoran
base + m2_por_recamara549.6−32.45 de 5
base + antiguedad582.0+0.00 de 5
base + dias_en_mercado512.4−69.65 de 5
base + log_distancia566.8−15.24 de 5
Lectura. dias_en_mercado baja más el RMSE: −69.6, y mejora en los 5 folds. Las fechas no podían entrar al modelo; su diferencia en días sí. m2_por_recamara baja 32.4 en promedio y mejora en los 5 folds: el modelo puede sumar superficie y recámaras con coeficientes, pero no dividirlas. log_distancia baja 15.2 y mejora en 4 de 5 folds. Su asimetría pasa de 4.14 a 0.13, y en generar_departamentos el precio depende de log1p(distancia / 1000), una curva que se aplana con la distancia. antiguedad no cambia el RMSE medio (+0.0) y no mejora ningún fold. Como todas las ventas son de 2024, antiguedad es 2024 − anio_construccion, una función lineal de una columna que el modelo ya tiene. Una columna derivada ayuda cuando el modelo no puede construirla con las columnas que ya recibe.

Otra relación posible es la interacción: el efecto de una columna depende del valor de otra. Si un metro cuadrado cerca del metro vale más que uno lejos, el efecto de superficie_m2 cambia con log_distancia. El producto de las dos columnas da al modelo lineal un coeficiente para ese cambio.

X_der reúne las columnas base y las tres derivadas que bajaron el RMSE. pd.concat(..., axis=1) une las dos tablas columna junto a columna. assign devuelve una copia de la tabla con una columna nueva, aquí el producto.

Python 3 · ejemplo reproducible
utiles = ["m2_por_recamara", "dias_en_mercado", "log_distancia"]
X_der = pd.concat([tabla[base], derivadas[utiles]], axis=1)
X_prod = X_der.assign(
    superficie_x_log_distancia=X_der["superficie_m2"] * X_der["log_distancia"]
)

reporte imprime el RMSE medio de una versión, su cambio respecto a una referencia y en cuántos de los 5 folds baja más de 0.1. encabezado imprime los títulos de las columnas que imprime reporte.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
def encabezado():
    print(f"{'versión':<32}{'RMSE':>7}{'cambio':>8}  folds que mejoran")


def reporte(nombre, rmse, referencia):
    # + 0.0 convierte -0.0 en 0.0
    cambio = round(rmse.mean() - referencia.mean(), 1) + 0.0
    mejoran = (referencia - rmse > 0.1).sum()
    print(f"{nombre:<32}{rmse.mean():>7.1f}{cambio:>+8.1f}{mejoran:>5} de 5")

PolynomialFeatures(degree=2, interaction_only=True) construye el producto de cada par de columnas, sin cuadrados. Después de ajustarse, n_output_features_ cuenta las columnas que produce.

Pregunta. X_der tiene 7 columnas. ¿Cuántas columnas produce PolynomialFeatures con todos los pares? ¿Esa versión supera al producto elegido a mano?

Python 3 · ejemplo reproducible
from sklearn.preprocessing import PolynomialFeatures

pares = make_pipeline(
    # include_bias=False: sin columna de unos
    PolynomialFeatures(degree=2, interaction_only=True, include_bias=False),
    StandardScaler(),
    LinearRegression(),
)

rmse_der = rmse_folds(lineal, X_der)
encabezado()
reporte("base + 3 derivadas", rmse_der, rmse_base)
reporte("  + superficie_x_log_distancia", rmse_folds(lineal, X_prod), rmse_der)
reporte("  + todos los pares", rmse_folds(pares, X_der), rmse_der)
# [0] es el primer paso del pipeline ajustado
print("columnas con todos los pares:", pares.fit(X_der, y)[0].n_output_features_)
Salida esperada

La salida esperada está oculta.

Codificación de categóricas

conservacion tiene cuatro niveles con orden: malo, regular, bueno y excelente. OrdinalEncoder reemplaza cada categoría por un entero. El parámetro categories fija qué entero recibe cada una. Sin ese parámetro, el codificador decide el orden por su cuenta. Después de fit, categories_ guarda las categorías de cada columna en el orden de sus enteros.

Pregunta. ¿Qué entero recibe "malo" con OrdinalEncoder() sin parámetros?

Python 3 · ejemplo reproducible
from sklearn.preprocessing import OrdinalEncoder

por_defecto = OrdinalEncoder().fit(tabla[["conservacion"]])
# una lista de categorías por cada columna que se codifica
explicito = OrdinalEncoder(categories=[ORDEN]).fit(tabla[["conservacion"]])
print("por defecto:", dict(zip(por_defecto.categories_[0], range(4))))
print("explícito:  ", dict(zip(explicito.categories_[0], range(4))))
Salida esperada

La salida esperada está oculta.

Para medir cada codificación, ColumnTransformer aplica un transformador distinto a cada grupo de columnas. Cada paso es una tupla (nombre, transformador, columnas): StandardScaler para las numéricas de X_prod y el codificador para conservacion. OneHotEncoder crea una columna de ceros y unos por categoría.

Pregunta. Ordena de menor a mayor RMSE las versiones con codificación por defecto, con orden explícito y con one-hot.

Python 3 · ejemplo reproducible
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder

numericas = list(X_prod.columns)
X_cons = pd.concat([X_prod, tabla[["conservacion"]]], axis=1)
codificadores = {
    "ordinal por defecto": OrdinalEncoder(),
    "ordinal explícito": OrdinalEncoder(categories=[ORDEN]),
    "one-hot": OneHotEncoder(),
}
for nombre, codificador in codificadores.items():
    columnas = ColumnTransformer(
        [("num", StandardScaler(), numericas), ("cons", codificador, ["conservacion"])]
    )
    modelo = make_pipeline(columnas, LinearRegression())
    print(f"RMSE {nombre}: {rmse_folds(modelo, X_cons).mean():.1f}")
Salida esperada

La salida esperada está oculta.

vendedor tiene cientos de valores y, en estos datos, no influye en el precio. One-hot crearía una columna por vendedor. TargetEncoder crea una sola: reemplaza cada categoría por el promedio de y en sus filas, acercado al promedio global cuando la categoría tiene pocas filas.

Para ver la codificación en filas nuevas, train_test_split(..., test_size=0.25) aparta una cuarta parte de las ventas como validación. nunique() cuenta los valores distintos de una columna.

Python 3 · ejemplo reproducible
from sklearn.model_selection import train_test_split

X_tabla = tabla.drop(columns="precio_miles")
X_ent, X_val, y_ent, y_val = train_test_split(
    X_tabla, y, test_size=0.25, random_state=0
)
print("vendedores distintos en entrenamiento:", X_ent["vendedor"].nunique())
Salida esperada

La salida esperada está oculta.

La celda codifica las mismas filas de entrenamiento de dos formas: con fit_transform y con transform después de fit, y codifica las filas de validación con transform. Los precios tienen valores enteros (4713.0, 3345.0…), así que scikit-learn los tomaría como clases. Por eso se indica target_type="continuous". np.corrcoef devuelve la matriz de correlación de dos vectores, y su elemento [0, 1] es la correlación entre ambos.

Pregunta. Para cada caso, ¿la correlación entre la codificación de vendedor y el precio estará cerca de 0 o lejos de 0?

Python 3 · ejemplo reproducible
from sklearn.preprocessing import TargetEncoder

codificador = TargetEncoder(target_type="continuous", random_state=0)
# [:, 0]: de matriz de una columna a vector
cruzado = codificador.fit_transform(X_ent[["vendedor"]], y_ent)[:, 0]
directo = codificador.transform(X_ent[["vendedor"]])[:, 0]
en_validacion = codificador.transform(X_val[["vendedor"]])[:, 0]

print("correlación con el precio")
print(f"  fit_transform, entrenamiento: {np.corrcoef(cruzado, y_ent)[0, 1]:.3f}")
print(f"  transform, entrenamiento:     {np.corrcoef(directo, y_ent)[0, 1]:.3f}")
print(f"  transform, validación:        {np.corrcoef(en_validacion, y_val)[0, 1]:.3f}")
Salida esperada

La salida esperada está oculta.

Escalado de columnas

StandardScaler resta la media y divide entre la desviación estándar. MinMaxScaler lleva el mínimo de entrenamiento a 0 y el máximo a 1. RobustScaler resta la mediana y divide entre el rango intercuartílico, la distancia entre los cuartiles Q1 y Q3. distancia_metro_m tiene una cola larga, así que los tres dan resultados distintos.

Pregunta. La cola larga está a la derecha. Con StandardScaler, ¿la mediana quedará arriba o abajo de 0? ¿Con qué escalador queda la mitad central de los departamentos en el intervalo más angosto?

Ver resultado

Cada fila aplica un escalador a distancia_metro_m, ajustado con las filas de entrenamiento. Las columnas dan cinco puntos de la columna escalada: el mínimo, Q1, la mediana, Q3 y el máximo. La fila de StandardScaler dice que, con ese escalador, la mitad central de los departamentos queda entre −0.556 y 0.187, con la mediana en −0.341.

escaladormínQ1medianaQ3máx
StandardScaler−0.772−0.556−0.3410.18711.450
MinMaxScaler0.0000.0180.0350.0781.000
RobustScaler−0.580−0.2890.0000.71115.869
Lectura. RobustScaler deja la mediana exactamente en 0, y de Q1 a Q3 hay 1 unidad. StandardScaler deja la mediana en −0.341, porque la cola larga sube la media. Con MinMaxScaler, el máximo de entrenamiento queda en 1 y la mitad central de los departamentos cabe entre 0.018 y 0.078. Ningún escalador acorta la cola: el máximo queda en 11.450, 1.000 o 15.869, lejos de la mitad central en los tres casos. Un escalador cambia la unidad de la columna; log1p cambia la forma de su distribución.

Como viste con K vecinos y con Ridge, el escalado cambia los modelos por distancia y los que tienen penalización, y no cambia un árbol. También importa en modelos que se ajustan por pasos de un optimizador, como LogisticRegression, que sin escalar puede no converger. Lo que cambia de un escalador a otro es cuánto pesa cada columna.

K vecinos predice con el promedio de las filas más cercanas según la distancia euclidiana, la raíz de la suma de las diferencias al cuadrado en todas las columnas. Entre dos filas al azar, esa suma vale en promedio el doble de la suma de las varianzas de las columnas. Por eso la fracción de la suma de varianzas que aporta cada columna es su peso en la distancia. KNeighborsRegressor(n_neighbors=10) promedia el precio de las 10 ventas más cercanas.

Python 3 · ejemplo reproducible
from sklearn.neighbors import KNeighborsRegressor
from sklearn.preprocessing import MinMaxScaler, RobustScaler

vecinos = KNeighborsRegressor(n_neighbors=10)

La celda siguiente calcula la fracción de cada columna con las filas de entrenamiento de la partición anterior (X_ent.index), sin escalar y con cada escalador, y mide el RMSE de K vecinos en los mismos folds. type(escalador).__name__ da el nombre de la clase de cada escalador y removesuffix("Scaler") lo acorta.

Pregunta. Sin escalar, ¿qué columna de X_prod decide casi toda la distancia? Con MinMaxScaler y con RobustScaler, ¿distancia_metro_m pesará más o menos que con StandardScaler? ¿Cuál de los tres da el menor RMSE?

Python 3 · ejemplo reproducible
X_vecinos = X_prod
filas_ent = X_vecinos.loc[X_ent.index]
partes = {"sin escalar": filas_ent.var() / filas_ent.var().sum()}
rmse_vecinos = {"sin escalar": rmse_folds(vecinos, X_vecinos).mean()}
for escalador in [StandardScaler(), MinMaxScaler(), RobustScaler()]:
    nombre = type(escalador).__name__.removesuffix("Scaler")
    Z = pd.DataFrame(escalador.fit_transform(filas_ent), columns=X_vecinos.columns)
    partes[nombre] = Z.var() / Z.var().sum()
    modelo = make_pipeline(escalador, vecinos)
    rmse_vecinos[nombre] = rmse_folds(modelo, X_vecinos).mean()

print("fracción de la suma de varianzas")
print(pd.DataFrame(partes).round(3).to_string())
print()
print("RMSE de K vecinos")
print(pd.Series(rmse_vecinos).round(1).to_string())
Salida esperada

La salida esperada está oculta.

Pipeline de características

Hasta aquí las columnas nuevas se calcularon con pandas fuera del modelo. Una razón o un log1p no aprenden nada de los datos: la misma fila da siempre el mismo resultado, así que calcularlos antes de dividir no filtra información. Los escaladores y codificadores sí aprenden medias, cuartiles, categorías o promedios de y, y se ajustan solo con el fold de entrenamiento.

FunctionTransformer(derivar) convierte la función derivar en un paso de Pipeline. Así, el modelo recibe la tabla original, incluidas las fechas, y aplica las mismas derivaciones a datos nuevos.

Python 3 · ejemplo reproducible
from sklearn.preprocessing import FunctionTransformer


def derivar(t):
    t = t.assign(
        m2_por_recamara=t["superficie_m2"] / t["recamaras"],
        dias_en_mercado=(t["fecha_venta"] - t["fecha_publicacion"]).dt.days,
        log_distancia=np.log1p(t["distancia_metro_m"]),
    )
    # segundo assign: el producto usa log_distancia, creada arriba
    return t.assign(superficie_x_log_distancia=t["superficie_m2"] * t["log_distancia"])

El ColumnTransformer escala las numéricas, codifica conservacion y colonia, y descarta el resto de las columnas, como las fechas y vendedor. armar recibe la lista de numéricas y los dos codificadores, y devuelve el pipeline completo.

Python 3 · ejemplo reproducible
def armar(numericas, cod_conservacion, cod_colonia):
    columnas = ColumnTransformer(
        [
            ("num", StandardScaler(), numericas),
            ("conservacion", cod_conservacion, ["conservacion"]),
            ("colonia", cod_colonia, ["colonia"]),
        ]
    )
    return make_pipeline(FunctionTransformer(derivar), columnas, LinearRegression())

sin(columna) devuelve numericas sin esa columna. OneHotEncoder(handle_unknown="ignore") codifica con ceros una colonia que no aparece en el fold de entrenamiento. El modelo completo usa todas las numéricas, conservacion con el orden de ORDEN y colonia en one-hot.

Python 3 · ejemplo reproducible
def sin(columna):
    return [c for c in numericas if c != columna]


ordinal = OrdinalEncoder(categories=[ORDEN])
one_hot = OneHotEncoder(handle_unknown="ignore")

rmse_completo = rmse_folds(armar(numericas, ordinal, one_hot), X_tabla)
print(f"RMSE completo: {rmse_completo.mean():.1f}")
Salida esperada

La salida esperada está oculta.

Cada variante quita o cambia una sola pieza del modelo completo y se compara con él en los mismos folds: reporte mide su cambio contra rmse_completo. objetivo codifica colonia con TargetEncoder dentro del ColumnTransformer, así que el Pipeline llama a fit_transform en cada fold de entrenamiento.

Pregunta. Al quitar dias_en_mercado del modelo completo, ¿el RMSE subirá más o menos que los 69.6 que bajó al agregarla a la versión base? Para colonia, con 40 valores, ¿será mejor TargetEncoder o one-hot?

Python 3 · ejemplo reproducible
objetivo = TargetEncoder(target_type="continuous", random_state=0)
variantes = {
    "sin dias_en_mercado": armar(sin("dias_en_mercado"), ordinal, one_hot),
    "sin m2_por_recamara": armar(sin("m2_por_recamara"), ordinal, one_hot),
    "sin superficie_x_log_distancia": armar(
        sin("superficie_x_log_distancia"), ordinal, one_hot
    ),
    "conservacion ordinal por defecto": armar(numericas, OrdinalEncoder(), one_hot),
    "colonia con TargetEncoder": armar(numericas, ordinal, objetivo),
}
encabezado()
for nombre, modelo in variantes.items():
    reporte(nombre, rmse_folds(modelo, X_tabla), rmse_completo)
Salida esperada

La salida esperada está oculta.

Importancia de características

Un bosque aleatorio calcula feature_importances_ durante el entrenamiento: suma cuánto baja el error en cada división que usa una columna. Esa medida usa solo las filas de entrenamiento. permutation_importance mide otra cosa: baraja una columna en el conjunto de validación y registra cuánto sube el RMSE.

Para el bosque, conservacion se codifica con el orden explícito de ORDEN, y otro OrdinalEncoder convierte colonia y vendedor en enteros. Un vendedor que no aparece en entrenamiento recibe -1. Los árboles no necesitan escalado, así que "passthrough" deja las numéricas como están. verbose_feature_names_out=False conserva los nombres originales de las columnas y set_output(transform="pandas") hace que el preprocesamiento devuelva un DataFrame.

Python 3 · ejemplo reproducible
codificar = OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=-1)
prep_bosque = make_pipeline(
    FunctionTransformer(derivar),
    ColumnTransformer(
        [
            ("num", "passthrough", numericas),
            ("conservacion", ordinal, ["conservacion"]),
            ("cat", codificar, ["colonia", "vendedor"]),
        ],
        verbose_feature_names_out=False,
    ),
).set_output(transform="pandas")
A_ent = prep_bosque.fit_transform(X_ent)
A_val = prep_bosque.transform(X_val)

RandomForestRegressor(n_estimators=100) entrena 100 árboles. permutation_importance baraja cada columna de A_val n_repeats=10 veces y guarda la media y la desviación estándar del aumento del RMSE.

Python 3 · ejemplo reproducible
from sklearn.ensemble import RandomForestRegressor
from sklearn.inspection import permutation_importance

bosque = RandomForestRegressor(n_estimators=100, random_state=0).fit(A_ent, y_ent)
perm = permutation_importance(
    bosque,
    A_val,
    y_val,
    n_repeats=10,
    random_state=0,
    scoring="neg_root_mean_squared_error",
)

La tabla reúne, por columna, la cantidad de valores distintos en entrenamiento, la importancia por impureza y la importancia por permutación con su desviación.

Pregunta. vendedor no influye en el precio. ¿Su importancia por impureza será 0? ¿Y su importancia por permutación? Compara ambas con las de conservacion.

Python 3 · ejemplo reproducible
importancias = pd.DataFrame(
    {
        "valores": A_ent.nunique(),
        "impureza": bosque.feature_importances_.round(3),
        "permutación": perm.importances_mean.round(1),
        "desv": perm.importances_std.round(1),
    }
)
print(importancias.sort_values("impureza", ascending=False))
Salida esperada

La salida esperada está oculta.

Dos selectores eligen columnas de forma automática. SelectKBest(f_regression, k=5) conserva las 5 columnas con mayor relación lineal individual con y, igual que f_classif en clasificación; no usa ningún modelo. RFE(estimador, n_features_to_select=5) entrena el estimador, elimina la columna menos importante según su coef_ o su feature_importances_ y repite hasta quedarse con 5. Con coef_, las columnas deben estar escaladas para que los coeficientes se puedan comparar; con feature_importances_, hereda el sesgo por impureza de la tabla anterior. Los dos aprenden de y, así que van dentro del Pipeline y se comparan con validación cruzada contra la versión sin selección.

Práctica

Traza guiada

Antes de ejecutar, predice los valores de a, b y c:

Python 3 · ejemplo reproducible
pequena = pd.DataFrame({"visitas": [0, 9, 99, 999], "talla": ["M", "S", "L", "M"]})
a = np.log1p(pequena["visitas"]).round(2)
b = MinMaxScaler().fit_transform(pequena[["visitas"]])[:, 0].round(3)
c = OrdinalEncoder().fit_transform(pequena[["talla"]])[:, 0]

Comprueba con print. Resultado esperado: a vale 0, 2.3, 4.61 y 6.91; b vale 0, 0.009, 0.099 y 1; c vale 1, 2, 0 y 1. Si c no coincide, ordena las tallas alfabéticamente y compara con OrdinalEncoder(categories=[["S", "M", "L"]]).

Distancia en logaritmo

En la celda que calcula la fracción de la suma de varianzas, cambia X_vecinos = X_prod por X_vecinos = X_prod[cuatro], con cuatro = ["superficie_m2", "m2_por_recamara", "dias_en_mercado", "distancia_metro_m"], y procesa. Después agrega X_vecinos = X_vecinos.assign(distancia_metro_m=np.log1p(X_vecinos["distancia_metro_m"])) debajo de esa línea y procesa otra vez. Antes de procesar, predice con qué escalador sube y con cuál baja el peso de distancia_metro_m al aplicar log1p.

Resultado esperado: en metros, distancia_metro_m aporta 0.25 con StandardScaler, 0.034 con MinMaxScaler y 0.532 con RobustScaler, y los RMSE son 557.2, 590.1 y 566.6. Con log1p, aporta 0.25, 0.118 y 0.231, y los RMSE son 559.5, 571.0 y 555.9. Si tus números no coinciden, revisa que log1p se aplique solo a distancia_metro_m. Explica con la tabla de mínimos, cuartiles y máximos por qué el peso cambia con MinMaxScaler y RobustScaler y no con StandardScaler.

Característica de temporada

Agrega a derivar la columna mes_venta con t["fecha_venta"].dt.month y súmala a numericas en una lista nueva. Antes de procesar, lee generar_departamentos y predice si el RMSE bajará. Mide con reporte contra rmse_completo y escribe:

  1. el cambio de RMSE y los folds que mejoran
  2. qué parte de generar_departamentos explica el resultado
  3. si conservarías la columna

Resultado esperado: RMSE 167.0, cambio +0.0 y 0 de 5 folds que mejoran. Para comprobar que mes_venta entró al modelo, aplica [:-1].fit_transform(X_tabla) al pipeline nuevo: el preprocesamiento entrega 50 columnas, una más que las 49 del modelo completo.

Reto opcional

Usa solo la columna vendedor. Primero codifícala fuera del pipeline con TargetEncoder(target_type="continuous", random_state=0).fit(X_tabla[["vendedor"]], y).transform(X_tabla[["vendedor"]]) y mide LinearRegression() con rmse_folds. Después mide make_pipeline(TargetEncoder(target_type="continuous", random_state=0), LinearRegression()) con X_tabla[["vendedor"]]. Antes de procesar, predice cuál de los dos RMSE queda cerca de y.std() y cuál por debajo. Explica la diferencia con la correlación de 0.629 que viste en entrenamiento.

Resultado esperado: y.std() vale 1246.1. La codificación fuera del pipeline da un RMSE de 1044.2 y el pipeline da 1246.0. Si el pipeline queda por debajo de y.std(), revisa que reciba X_tabla[["vendedor"]] y no la columna ya codificada.

Errores que revisar

  • Si TargetEncoder falla con n_splits=5 cannot be greater than the number of members in each class, infirió clases porque y solo tiene valores enteros. Pasa target_type="continuous".
  • Si OrdinalEncoder lanza Found unknown categories al transformar validación, esa categoría no apareció en entrenamiento. Usa handle_unknown="use_encoded_value" con unknown_value=-1.
  • Si una variable ordenada empeora el modelo, revisa categories_. Sin categories, OrdinalEncoder ordena alfabéticamente y "malo" queda entre "excelente" y "regular".
  • Si codificas con TargetEncoder usando fit y luego transform sobre las mismas filas, el precio de cada fila entra en su propia codificación. Usa fit_transform o deja el codificador dentro del Pipeline.
  • Si una columna sin relación con y aparece con importancia por impureza alta, compárala con permutation_importance en validación antes de conservarla.

Cierre

Propones agregar precio_m2_colonia, el precio medio por metro cuadrado de cada colonia. Explica si esa columna aprende algo de los datos, en qué paso del Pipeline va y con qué comparación decides conservarla.

Lista de comprobación

  • ¿Por qué una regresión lineal necesita m2_por_recamara como columna aunque ya tenga superficie_m2 y recamaras?
  • ¿Qué diferencia hay entre TargetEncoder.fit_transform y fit seguido de transform sobre las mismas filas?
  • ¿Por qué RobustScaler no acorta la cola de distancia_metro_m y log1p sí?
  • ¿Qué mide permutation_importance que feature_importances_ no mide?

Documentación oficial

Cargando progreso…