Curso: Machine learning — aprendizaje supervisado de regresión en Python
1 de agosto de 2026
Antes de entrenar cualquier modelo hay que dejar los datos en condiciones
groupby, categorías raras y discretización.Al cerrar cada uno de los ocho capítulos pararemos a medir, con una regresión lineal y una red neuronal, mirando MSE y \(R^2\). Así se ve paso a paso qué aporta cada decisión — y que no aporta lo mismo a los dos modelos.
Hoy sí hay código. Abran el cuaderno y ejecuten conmigo.
En un proyecto real, entre el 60 % y el 80 % del tiempo se va en conseguir, limpiar y preparar los datos. El modelo es la parte corta.
No es una queja: es donde está casi todo el valor. Hoy vamos a ver una red neuronal con un \(R^2\) de −377 —o sea, peor que no hacer nada— y cómo una sola línea de preprocesamiento la deja en +0,449, sin tocarle un parámetro ni una neurona.
Cars93 · 93 filas × 27 columnas 93 automóviles a la venta en EE. UU. en 1993. Queremos predecir el precio. Tiene vacíos de verdad y categóricas de todo tipo. Cabe en pantalla, así que podemos ir viendo la tabla entera.
diamonds · 53.940 filas × 10 columnas Precios de diamantes. Aporta lo que a Cars93 le falta: categóricas ordinales reales (calidad del tallado, color, pureza) y volumen suficiente para que se note el coste de cada decisión.
Los dos vienen de paquetes clásicos de R. Están junto al cuaderno, en la carpeta data/, así que una vez descargados corren sin conexión.
import pandas as pd
import numpy as np
URL = "https://afpuertav.github.io/courses/machine_learning_regresion/Preprocesamiento/data/"
def cargar(nombre):
"""Lee de la carpeta data/ si está; si no, de la web del curso."""
try:
return pd.read_csv("data/" + nombre)
except FileNotFoundError:
return pd.read_csv(URL + nombre)
cars = cargar("cars93.csv")
dia = cargar("diamonds.csv.gz")
print(cars.shape, dia.shape)(93, 27) (53940, 10)
Mirar antes de tocar
Antes de transformar nada: miren los datos. Cuántas filas, qué columnas, de qué tipo, qué falta y qué valores raros hay.
Casi todos los errores caros del preprocesamiento son errores de no haber mirado: una columna que era texto y parecía número, un -999 que significaba “no medido”, una fecha en formato americano.
Ninguno de esos errores da un mensaje de error. Dan un modelo que funciona peor y nadie sabe por qué.
cars.shape # cuántas filas y columnas
cars.head() # las primeras filas
cars.info() # tipos y no-nulos por columna
cars.describe() # resumen numérico<class 'pandas.core.frame.DataFrame'>
RangeIndex: 93 entries, 0 to 92
Data columns (total 27 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Manufacturer 93 non-null object
1 Model 93 non-null object
2 Type 93 non-null object
3 Min.Price 93 non-null float64
4 Price 93 non-null float64
5 Max.Price 93 non-null float64
6 MPG.city 93 non-null int64
7 MPG.highway 93 non-null int64
8 AirBags 59 non-null object
9 DriveTrain 93 non-null object
10 Cylinders 93 non-null object
11 EngineSize 93 non-null float64
12 Horsepower 93 non-null int64
13 RPM 93 non-null int64
14 Rev.per.mile 93 non-null int64
15 Man.trans.avail 93 non-null object
16 Fuel.tank.capacity 93 non-null float64
17 Passengers 93 non-null int64
18 Length 93 non-null int64
19 Wheelbase 93 non-null int64
20 Width 93 non-null int64
21 Turn.circle 93 non-null int64
22 Rear.seat.room 91 non-null float64
23 Luggage.room 82 non-null float64
24 Weight 93 non-null int64
25 Origin 93 non-null object
26 Make 93 non-null object
dtypes: float64(7), int64(11), object(9)
memory usage: 19.7+ KB
Min.Price Price Max.Price MPG.city MPG.highway EngineSize Horsepower RPM Rev.per.mile Fuel.tank.capacity Passengers Length Wheelbase Width Turn.circle Rear.seat.room Luggage.room Weight
count 93.000000 93.000000 93.000000 93.000000 93.000000 93.000000 93.000000 93.000000 93.000000 93.000000 93.000000 93.000000 93.000000 93.000000 93.000000 91.000000 82.000000 93.000000
mean 17.125806 19.509677 21.898925 22.365591 29.086022 2.667742 143.827957 5280.645161 2332.204301 16.664516 5.086022 183.204301 103.946237 69.376344 38.956989 27.829670 13.890244 3072.903226
std 8.746029 9.659430 11.030457 5.619812 5.331726 1.037363 52.374410 596.731690 496.506525 3.279370 1.038979 14.602382 6.819674 3.778986 3.223265 2.989072 2.997967 589.896510
min 6.700000 7.400000 7.900000 15.000000 20.000000 1.000000 55.000000 3800.000000 1320.000000 9.200000 2.000000 141.000000 90.000000 60.000000 32.000000 19.000000 6.000000 1695.000000
25% 10.800000 12.200000 14.700000 18.000000 26.000000 1.800000 103.000000 4800.000000 1985.000000 14.500000 4.000000 174.000000 98.000000 67.000000 37.000000 26.000000 12.000000 2620.000000
50% 14.700000 17.700000 19.600000 21.000000 28.000000 2.400000 140.000000 5200.000000 2340.000000 16.400000 5.000000 183.000000 103.000000 69.000000 39.000000 27.500000 14.000000 3040.000000
75% 20.300000 23.300000 25.300000 25.000000 31.000000 3.300000 170.000000 5750.000000 2565.000000 18.800000 6.000000 192.000000 110.000000 72.000000 41.000000 30.000000 15.000000 3525.000000
max 45.400000 61.900000 80.000000 46.000000 50.000000 5.700000 300.000000 6500.000000 3755.000000 27.000000 8.000000 219.000000 119.000000 78.000000 45.000000 36.000000 22.000000 4105.000000
info()cars[["Manufacturer", "Type", "Price", "AirBags",
"Cylinders", "Horsepower", "Luggage.room", "Origin"]].info()<class 'pandas.core.frame.DataFrame'> RangeIndex: 93 entries, 0 to 92 Data columns (total 8 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Manufacturer 93 non-null object 1 Type 93 non-null object 2 Price 93 non-null float64 3 AirBags 59 non-null object 4 Cylinders 93 non-null object 5 Horsepower 93 non-null int64 6 Luggage.room 82 non-null float64 7 Origin 93 non-null object dtypes: float64(2), int64(1), object(5) memory usage: 5.9+ KB
Dos cosas saltan: AirBags solo tiene 59 de 93 y Cylinders es texto (object) cuando uno esperaría un número.
describe()Price MPG.city Horsepower Weight count 93.00 93.00 93.00 93.0 mean 19.51 22.37 143.83 3072.9 std 9.66 5.62 52.37 589.9 min 7.40 15.00 55.00 1695.0 25% 12.20 18.00 103.00 2620.0 50% 17.70 21.00 140.00 3040.0 75% 23.30 25.00 170.00 3525.0 max 61.90 46.00 300.00 4105.0
Weight vive en los miles y MPG.city en las decenas. Guarden esa idea: es exactamente el problema que resolverá el escalado.
Al cerrar cada capítulo vamos a medir lo mismo, de la misma forma.
Dos modelos LinearRegression() y una red MLPRegressor de una capa de 32 neuronas. Siempre los mismos, sin tocarles nada entre capítulos.
Dos métricas MSE — error cuadrático medio, en (miles de USD)². R² — cuánta variación del precio explica el modelo. 1 es perfecto, 0 es tan bueno como decir la media, y negativo es peor que eso.
Validación cruzada Cinco pliegues sobre los 93 coches. Con una sola partición de 19 coches las cifras bailan tanto que no se podría comparar nada.
from sklearn.linear_model import LinearRegression
from sklearn.neural_network import MLPRegressor
from sklearn.model_selection import cross_validate
def modelos():
"""Los dos del banco. Idénticos en los ocho capítulos."""
return [("LinearRegression", LinearRegression()),
("MLPRegressor", MLPRegressor(hidden_layer_sizes=(32,),
max_iter=2000,
random_state=7))]
historial = []
def chequeo(capitulo, hacer_tuberia, X, y):
"""Mide los dos modelos y acumula la fila en el historial.
hacer_tuberia(modelo) devuelve lo que hay que evaluar: el modelo
a secas, o el modelo dentro de una tubería de preprocesamiento.
"""
fila = [capitulo]
for nombre, modelo in modelos():
try:
t = hacer_tuberia(modelo)
# cross_validate saca las dos métricas en una sola pasada.
# error_score="raise" deja pasar el error de verdad; si no,
# sklearn lo tapa con un "All the 5 fits failed".
r = cross_validate(t, X, y, cv=5, error_score="raise",
scoring=["neg_mean_squared_error", "r2"])
fila += [round(-r["test_neg_mean_squared_error"].mean(), 2),
round(r["test_r2"].mean(), 3)]
except Exception as e:
fila += ["error", "error"]
motivo = str(e).strip().splitlines()[0]
print(f" {nombre}: {type(e).__name__}: {motivo[:60]}")
historial.append(fila)
return pd.DataFrame(historial, columns=["capítulo", "MSE lineal",
"R2 lineal", "MSE red", "R2 red"])random_state=7 fija la inicialización de la red para que el resultado sea reproducible. Todo lo demás se queda igual los ocho capítulos.
# Fuera lo que es el precio disfrazado o un identificador
cars_m = cars.drop(columns=["Min.Price", "Max.Price", "Make", "Model"])
X = cars_m.drop(columns=["Price"])
y = cars_m["Price"]
num_cols = X.select_dtypes(include=np.number).columns.tolist()
cat_cols = X.select_dtypes(exclude=np.number).columns.tolist()
chequeo("1a · Sin tocar nada", lambda m: m, X, y) LinearRegression: ValueError: could not convert string to float: 'Chrylser'
MLPRegressor: ValueError: could not convert string to float: 'Chrylser'
capítulo MSE lineal R2 lineal MSE red R2 red
0 1a · Sin tocar nada error error error error
LinearRegression: ValueError: Input X contains NaN.
MLPRegressor: ValueError: Input X contains NaN.
capítulo MSE lineal R2 lineal MSE red R2 red
0 1a · Sin tocar nada error error error error
1 1b · Solo las numéricas error error error error
capítulo MSE lineal R2 lineal MSE red R2 red 1a · Sin tocar nada error error error error 1b · Solo las numéricas error error error error
No es que el modelo prediga mal: es que no arranca. Los dos errores nos marcan el orden del día.
could not convert string to float Hay texto donde el modelo espera números. Lo resolveremos en el capítulo 5.
Input X contains NaN Hay huecos. Es el capítulo que empieza ahora.
Lo que no está también dice algo
La razón inmediata es que scikit-learn no acepta vacíos: la mayoría de sus modelos fallan en cuanto encuentran un NaN.
from sklearn.linear_model import LinearRegression
LinearRegression().fit(cars[["Horsepower", "Luggage.room"]], cars["Price"])ValueError: Input X contains NaN.
La razón de fondo es más interesante: cómo rellenamos esos huecos cambia lo que el modelo aprende. No es un trámite para que deje de dar error.
faltan = cars.isna().sum()
print(faltan[faltan > 0])
pct = (cars.isna().mean() * 100).round(1)
print(pct[pct > 0].sort_values(ascending=False))AirBags 34 Rear.seat.room 2 Luggage.room 11 dtype: int64 AirBags 36.6 Luggage.room 11.8 Rear.seat.room 2.2 dtype: float64
El porcentaje importa más que el conteo: 34 vacíos sobre 93 filas es más de un tercio de la columna.
Antes de rellenar hay que preguntarse por qué falta el dato. La estadística distingue tres mecanismos, y cada uno admite un tratamiento distinto.
MCAR Missing Completely At Random Falta completamente al azar: la ausencia no depende de nada, ni de lo que vemos ni de lo que no vemos. Es el caso cómodo y también el más raro. Ejemplo: se perdió una hoja del cuestionario.
MAR Missing At Random Falta al azar, condicionado a lo observado: la ausencia depende de otras columnas que sí tenemos. Ejemplo: a las furgonetas nunca les midieron el maletero.
MNAR Missing Not At Random No falta al azar: la ausencia depende del propio valor que falta. El caso peligroso, porque no se puede detectar con los datos que hay. Ejemplo: los de renta alta no declaran su renta.
Si es MCAR Eliminar las filas es válido: lo que queda sigue siendo una muestra representativa. Imputar con la media tampoco sesga.
Si es MAR Eliminar sí sesga (perderíamos casi todas las furgonetas). Imputar usando las columnas relacionadas —por grupo, o con KNN— es lo correcto.
Si es MNAR No hay solución estadística limpia. Lo honesto es añadir una columna que marque que faltaba y reconocer la limitación en el informe.
Supongamos una encuesta de sueldos donde los que ganan mucho no responden. De diez personas responden siete:
sueldos reales : 1.0 1.2 1.3 1.5 1.6 1.8 2.0 6.0 7.0 9.0
↑ estos tres no responden
sueldos observados : 1.0 1.2 1.3 1.5 1.6 1.8 2.0
media observada : 1.49
media verdadera : 3.24
La media que calculamos solo con los que responden ya está sesgada hacia abajo: es la media de los sueldos bajos. Al imputar 1,49 en los tres huecos, no estamos corrigiendo el sesgo, lo estamos copiando tres veces más. La media final baja aún más, a 1,49, cuando la verdadera era 3,24.
Imputar con la media asume MCAR. Con MNAR, la media que usamos está calculada justo sobre las filas que no se parecen a las que faltan.
El error es sistemático No se compensa con más datos: cuantas más filas MNAR haya, peor. No es ruido, es sesgo.
El modelo aprende una relación falsa Si a los coches caros les falta el maletero y les ponemos el maletero medio, el modelo concluye que maletero medio y precio alto van juntos.
Y encima parece que funciona Los vacíos desaparecen, el código corre y las métricas de entrenamiento no se quejan de nada.
La única salida honesta es marcar que faltaba con una columna 0/1 y decirlo en el informe.
AirBagsAirBags Driver only 43 NaN 34 Driver & Passenger 16
Aquí el NaN no es un dato perdido: es el coche que no traía airbag. En el original esa categoría era "None" y al leerla se convirtió en vacío.
Imputar esos 34 con la moda sería inventarse 34 airbags que no existen.
Antes de imputar, la pregunta no es “¿con qué lo relleno?” sino “¿por qué no está?”.
No se midió Imputar tiene sentido.
No aplica Es una categoría, no un vacío. "No aplica".
Se perdió Imputar, y dejar constancia de que ese dato es reconstruido.
Es un cero mal codificado Se corrige, no se imputa.
Muchas veces la respuesta está en el diccionario de datos, no en el código.
print("original ", cars.shape)
print("dropna() filas ", cars.dropna().shape)
print("dropna(axis=1) ", cars.dropna(axis=1).shape)
minimo = int(0.9 * len(cars))
print("thresh 90% cols ", cars.dropna(axis=1, thresh=minimo).shape)original (93, 27) dropna() filas (54, 27) dropna(axis=1) (93, 24) thresh 90% cols (93, 25)
dropna() nos deja 54 de 93 filas: perdemos el 42 % de los datos por culpa de tres columnas. Casi nunca es la opción correcta.
Eliminar la columna Cuando falta muchísimo (>50-60 %) y no es clave. Lo que quede no sostiene una imputación creíble.
Eliminar la fila Cuando son poquísimas filas (<2-5 %) y el faltante es MCAR.
Eliminar la fila, siempre Si lo que falta es la variable objetivo. No se imputa lo que hay que predecir: sería inventarse la respuesta.
No eliminar Cuando el hecho de faltar es informativo. Ahí conviene una columna _faltaba de 0 y 1.
lr = cars["Luggage.room"]
print(f"media {lr.mean():.3f}")
print(f"mediana {lr.median():.3f}")
print(f"moda {lr.mode()[0]:.3f}")media 13.890 mediana 14.000 moda 14.000
Aquí dan casi lo mismo porque la variable es simétrica. En una variable sesgada (ingresos, precios) la media se va detrás de los atípicos y la mediana es la opción segura.
Regla práctica: numérica simétrica → media; numérica sesgada → mediana; categórica → moda o una categoría nueva.
Nos quedamos con las numéricas y tiramos las filas incompletas: sobreviven 82 coches de 93.
X_limpio = X[num_cols].dropna()
y_limpio = y.loc[X_limpio.index]
print("filas que sobreviven:", len(X_limpio), "de", len(X))
chequeo("2 · dropna sobre las numéricas", lambda m: m, X_limpio, y_limpio)filas que sobreviven: 82 de 93
capítulo MSE lineal R2 lineal MSE red R2 red
0 1a · Sin tocar nada error error error error
1 1b · Solo las numéricas error error error error
2 2 · dropna sobre las numéricas 46.77 0.295 68165.39 -1015.81
La lineal arranca \(R^2 = 0{,}295\). Explica menos de un tercio de la variación del precio, pero al menos funciona.
La red es un desastre \(R^2 = -1015\). Un negativo ya significa peor que predecir siempre la media; con esa magnitud, el error es de otro orden que los precios. En el capítulo 7 abriremos la red para ver por qué.
Y hemos perdido 11 coches Un 12 % de los datos, tirado por culpa de dos columnas incompletas.
Los tres problemas tienen arreglo. Vamos con ellos por orden.
El error más caro de todos
Hay fuga de datos (data leakage) cuando en el entrenamiento se cuela información que en el momento de predecir no estaría disponible. El resultado es un modelo que parece buenísimo en pruebas y fracasa en producción.
Imputar con la media de todo el dataset es fuga: esa media contiene información de las filas de prueba, que se supone que aún no hemos visto.
from sklearn.model_selection import train_test_split
# X e y son los del banco de pruebas, ya sin las columnas
# que eran el precio disfrazado
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42)
print("X_train", X_train.shape, " X_test", X_test.shape)
print(f"media de Luggage.room en TODO : {X['Luggage.room'].mean():.3f}")
print(f"media de Luggage.room solo train: {X_train['Luggage.room'].mean():.3f}")X_train (74, 22) X_test (19, 22) media de Luggage.room en TODO : 13.890 media de Luggage.room solo train: 14.212
Son valores distintos. Usar el primero para rellenar el conjunto de prueba significa que la prueba ya sabe algo de sí misma.
Todo lo que aprenda un parámetro de los datos —medias, medianas, mínimos, máximos, categorías, desviaciones— se ajusta solo con entrenamiento y se aplica a prueba.
fit en train Aprende los parámetros y los guarda dentro del objeto.
transform en train y en test Aplica lo aprendido, sin volver a aprender.
Nunca fit en test Ni fit_transform. Es el error clásico y no da ningún aviso.
fit de imputadores, codificadores y escaladores con entrenamiento.transform de ambos conjuntos.El paso 2 puede ir antes de partir porque no aprende nada de los datos: convertir texto a número o quitar duplicados no depende de qué filas nos tocaron.
Rellenamos con la mediana de dos maneras: calculándola con todo el dataset (fuga) y calculándola dentro de la validación cruzada (correcto).
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
# con fuga: esta mediana ve los cinco pliegues
X_fuga = X[num_cols].fillna(X[num_cols].median())
chequeo("3a · Imputando fuera (con fuga)", lambda m: m, X_fuga, y)
# correcto: la mediana se recalcula dentro de cada pliegue
chequeo("3b · Imputando dentro (correcto)",
lambda m: Pipeline([("imp", SimpleImputer(strategy="median")),
("reg", m)]),
X[num_cols], y)capítulo MSE lineal R2 lineal MSE red R2 red 0 1a · Sin tocar nada error error error error 1 1b · Solo las numéricas error error error error 2 2 · dropna sobre las numéricas 46.77 0.295 68165.39 -1015.81 3 3a · Imputando fuera (con fuga) 37.39 0.44 71652.15 -1132.738 4 3b · Imputando dentro (correcto) 37.39 0.44 71652.37 -1132.739
La fuga no se nota en las métricas. Si se notara, la cazaríamos enseguida: el problema es que se cuela sin dejar rastro.
Aquí no cambia nada porque la mediana es muy robusta: quitar 20 coches apenas la mueve. Pero el mismo error con otras técnicas sí hace daño:
Target encoding La media del objetivo por categoría se contamina de lleno con las filas de prueba.
Selección de variables Elegir columnas mirando todo el dataset ya es haber usado el conjunto de prueba.
Escalado con atípicos Un solo valor extremo en prueba mueve el mínimo y el máximo.
La regla no se sigue porque se vea el daño, sino porque no se ve.
Ahora sí, con el conjunto ya partido
SimpleImputer: la versión de scikit-learnHacerlo con pandas funciona, pero deja los valores de relleno sueltos en una variable. SimpleImputer los guarda dentro del objeto, y eso es lo que permite aplicar exactamente lo mismo al conjunto de prueba y a producción.
# estrategias: "mean", "median", "most_frequent", "constant"
imp_num = SimpleImputer(strategy="median")
imp_num.fit(X_train[num_cols])
pd.Series(imp_num.statistics_, index=num_cols)[["Rear.seat.room", "Luggage.room"]]Rear.seat.room 28.0 Luggage.room 14.0
imp_cat = SimpleImputer(strategy="constant", fill_value="Sin airbag")
Xtr_cat = pd.DataFrame(imp_cat.fit_transform(X_train[cat_cols]),
columns=cat_cols, index=X_train.index)
Xtr_cat["AirBags"].value_counts()AirBags Driver only 35 Sin airbag 26 Driver & Passenger 13
Con strategy="constant" creamos una categoría explícita en lugar de disfrazar los vacíos de moda. Es justo lo que pedía el caso de AirBags.
KNNImputerEn vez de un único valor para todos, busca los k coches más parecidos en las demás columnas y promedia su valor. Sirve cuando el faltante es MAR: aprovecha precisamente esas columnas de las que depende la ausencia.
from sklearn.impute import KNNImputer
knn = KNNImputer(n_neighbors=5)
Xtr_knn = pd.DataFrame(knn.fit_transform(X_train[num_cols]),
columns=num_cols, index=X_train.index)mediana knn 65 14.0 17.6 15 14.0 16.6 69 14.0 16.6 16 14.0 19.0
La mediana pone 14 a todos. El KNN nota que esos cuatro son coches grandes y les pone entre 16 y 19. Más fiel, pero más lento y exige escalar antes, porque el vecindario se mide con distancias.
Type Compact 14.0 Large 18.0 Midsize 15.0 Small 12.0 Sporty 11.5 Van NaN
Van sale NaN: a las nueve furgonetas les falta el dato a todas. Si el grupo entero está vacío, la imputación por grupo no rellena nada y hay que caer en un valor global.
Y de paso confirma que el faltante era MAR: depende de Type, que sí observamos.
IterativeImputer Modela cada columna con vacíos a partir de las demás, e itera hasta converger. Lo más potente y lo más caro.
Indicador de faltante SimpleImputer(add_indicator=True) añade una columna 0/1. Deja que el modelo aprenda si el hecho de faltar significa algo.
No imputar HistGradientBoostingRegressor, LightGBM y XGBoost manejan NaN de forma nativa y suelen hacerlo mejor que cualquier imputación.
chequeo("4 · SimpleImputer(mediana)",
lambda m: Pipeline([("imp", SimpleImputer(strategy="median")),
("reg", m)]),
X[num_cols], y)capítulo MSE lineal R2 lineal MSE red R2 red
2 · dropna sobre las numéricas 46.77 0.295 68165.39 -1015.810
4 · SimpleImputer(mediana) 37.39 0.440 71652.37 -1132.739
La lineal mejora de verdad: el MSE baja de 46,77 a 37,39 y el \(R^2\) sube de 0,295 a 0,440. No porque imputar sea mágico, sino porque entrenamos con 93 coches en vez de 82.
La red sigue igual de rota. Su problema es otro y todavía no lo hemos tocado.
Los modelos solo entienden números
ValueError: could not convert string to float: 'Nissan'
Un modelo de regresión multiplica cada columna por un coeficiente y suma. La palabra "Nissan" no se puede multiplicar por nada. Codificar es convertir esas etiquetas en números sin inventarse relaciones que no existen.
Esa última condición es toda la dificultad del asunto.
Nominal Sin orden. Type, Manufacturer, DriveTrain. Un Sedán no es “más” que una furgoneta. → one-hot
Ordinal Con orden real. La pureza de un diamante, un nivel educativo, una talla S/M/L. → ordinal encoding con el orden declarado a mano
Binaria Dos valores. Origin, Man.trans.avail. → 0 y 1, una sola columna
La decisión no la toma el código: la toma quien conoce el dominio.
Manufacturer 31 Type 6 Cylinders 6 DriveTrain 3 AirBags 2 Man.trans.avail 2 Origin 2
Manufacturer tiene 31 niveles distintos en 74 filas de entrenamiento. Guarden ese dato: dentro de un rato va a hacer explotar el modelo.
Origin 1 39 0 35
Con dos categorías, una sola columna 0/1 basta y sobra. Crear dos columnas sería redundante: la segunda sería siempre 1 - primera.
Label encoding sustituye cada categoría por un número entero. Ni más ni menos. LabelEncoder los asigna por orden alfabético, empezando en 0.
Su uso legítimo es la variable objetivo de un problema de clasificación: cuando hay que predecir "perro" / "gato" / "pájaro", el modelo necesita que esas tres etiquetas sean 0, 1 y 2, y ahí el número es solo un identificador que nunca se opera.
El problema empieza cuando alguien lo usa en las variables de entrada.
from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
cilindros = cars["Cylinders"].astype(str)
codigos = le.fit_transform(cilindros)
# la correspondencia que aprendió
pd.DataFrame({"categoría": le.classes_,
"número asignado": range(len(le.classes_))})categoría número asignado 0 3 0 1 4 1 2 5 2 3 6 3 4 8 4 5 rotary 5
Ordenó las seis categorías alfabéticamente y les puso 0, 1, 2, 3, 4 y 5.
Cylinders codificado 0 4 1 1 6 3 2 6 3 3 6 3 4 4 1 5 4 1
Un coche de 4 cilindros pasa a ser un 1, y uno de 6 pasa a ser un 3.
El modelo lee esos números como cantidades, no como etiquetas. Con esta codificación estamos afirmando tres cosas falsas:
rotary > 8 cilindros Al motor rotativo le tocó el 5 y al de 8 cilindros el 4. Alfabéticamente tiene sentido; mecánicamente no significa nada.
Las distancias son iguales De 3 a 4 cilindros hay “1”, y de 6 a 8 también hay “1”. En la realidad no.
El orden es el alfabético Que es una casualidad de cómo se escriben las palabras.
Excepción: los árboles lo toleran bastante bien, porque parten por umbrales y no interpretan la magnitud.
One-hot encoding crea una columna por categoría, con un 1 en la que corresponde y 0 en las demás. Así ninguna categoría queda “por encima” de otra: todas están a la misma distancia.
Type_Compact Type_Large Type_Midsize Type_Small Type_Sporty Type_Van 65 0 0 0 0 0 1 15 0 0 0 0 0 1 68 0 0 1 0 0 0 78 0 0 0 1 0 0 30 0 0 0 1 0 0
get_dummies está bien para explorar, no para producciónSi el conjunto de prueba trae una categoría que el entrenamiento no tenía, get_dummies genera un juego de columnas distinto y el modelo recibe una matriz que no reconoce.
from sklearn.preprocessing import OneHotEncoder
enc = OneHotEncoder(handle_unknown="ignore", sparse_output=False, drop="first")
enc.fit(X_train[["Type", "DriveTrain"]])
enc.get_feature_names_out()array(['Type_Large', 'Type_Midsize', 'Type_Small', 'Type_Sporty',
'Type_Van', 'DriveTrain_Front', 'DriveTrain_Rear'], dtype=object)
OneHotEncoder recuerda las categorías del entrenamiento y siempre devuelve las mismas columnas, en el mismo orden.
handle_unknown="ignore" Una categoría no vista en entrenamiento sale como una fila de ceros, en vez de lanzar una excepción en mitad de producción.
drop="first" Quita una columna por variable. Evita la colinealidad perfecta que deja indeterminados los coeficientes de la regresión lineal.
drop="first"Con las seis columnas de Type, la suma de todas es siempre 1. Esa colinealidad perfecta hace que existan infinitas combinaciones de coeficientes que dan el mismo resultado, y la regresión no sabe cuál elegir.
Al quitar Compact, esa pasa a ser la categoría de referencia y los demás coeficientes se leen como “cuánto más caro que un Compact”, que además es más fácil de explicar.
Con árboles o con regularización (Ridge, Lasso) no hace falta quitarla, y a veces conviene no hacerlo. Con regresión lineal pura, sí.
Manufacturervc = X_train["Manufacturer"].value_counts()
print(vc.head(6))
print("marcas con 1 solo coche:", (vc == 1).sum(), "de", len(vc))Manufacturer Ford 7 Chevrolet 6 Nissan 4 Dodge 4 Pontiac 4 Buick 4 Name: count, dtype: int64 marcas con 1 solo coche: 12 de 31
One-hot sobre esto son 31 columnas nuevas, 12 de ellas con un único 1. De una columna así no se aprende nada: se memoriza esa fila.
Cuándo: siempre que haya una cola larga de categorías poco frecuentes y no exista un criterio de dominio mejor. Es la primera que hay que probar.
frecuentes = vc[vc >= 3].index
X_train["Manufacturer"] = X_train["Manufacturer"].where(
X_train["Manufacturer"].isin(frecuentes), "Otras")Manufacturer Otras 26 Ford 7 Chevrolet 6 Nissan 4 Buick 4 Mazda 4 Dodge 4 Pontiac 4 Oldsmobile 3 Subaru 3 Hyundai 3 Volkswagen 3 Toyota 3 Name: count, dtype: int64
De 31 niveles a 13. OneHotEncoder(min_frequency=3) lo hace solo.
Sustituir cada categoría por la frecuencia con que aparece. Una sola columna numérica, sin importar cuántos niveles haya.
frec = cars["Manufacturer"].value_counts(normalize=True)
cars["Manuf_frec"] = cars["Manufacturer"].map(frec)0 0.0215 1 0.0215 2 0.0215 3 0.0215 4 0.0108 5 0.0430
Cuándo: cuando la popularidad de la categoría es en sí misma informativa —un fabricante con muchos modelos es un fabricante masivo— y hay miles de niveles. Riesgo: dos marcas distintas con la misma frecuencia quedan indistinguibles.
Sustituir cada categoría por la media de la variable objetivo dentro de esa categoría. Una columna, y muy informativa: le da al modelo directamente lo que la categoría dice sobre el precio.
medias = cars.groupby("Manufacturer")["Price"].mean()
cars["Manuf_te"] = cars["Manufacturer"].map(medias)0 24.90 1 24.90 2 33.40 3 33.40 4 30.00 5 21.62
Cuándo: competiciones y modelos tabulares con cardinalidad muy alta (códigos postales, IDs de producto). Suele ser la codificación que más aporta.
Infiniti: media = 47.9 (calculada con 1 coche)
Para una marca con un solo coche, la “media de la categoría” es el precio de ese coche. Le estamos dando al modelo la respuesta de esa fila disfrazada de variable de entrada. Es fuga de datos de manual.
Se controla con suavizado (mezclar la media del grupo con la media global, según cuántas filas tenga) y calculando las medias dentro de una validación cruzada. sklearn.preprocessing.TargetEncoder ya lo trae hecho.
Reemplazar la categoría por una propiedad suya que sí tenga pocos niveles y significado.
Marca → origen Manufacturer (31) → Origin (2). Ya lo tenemos en el dataset.
Marca → gama Generalista / prémium / lujo. 31 → 3.
Marca → grupo empresarial Audi, SEAT y Volkswagen comparten plataformas. 31 → ~10.
Cuándo: siempre que se pueda. Es la única salida que añade información en lugar de limitarse a comprimir la que ya había, y la que mejor resiste categorías nuevas en producción.
Aprender, durante el entrenamiento de una red neuronal, un vector de pocas dimensiones para cada categoría. Categorías que se comportan parecido acaban con vectores parecidos.
Es lo mismo que se hace con las palabras en procesamiento de lenguaje: 50.000 palabras representadas en 300 números.
Cuándo: cardinalidades de miles o decenas de miles (usuarios, productos, ciudades) y con una red neuronal de por medio. Cuándo no: en este curso. Con 31 marcas y 93 coches, un embedding es artillería para matar una mosca.
diamondscut 5 ['Fair', 'Good', 'Ideal', 'Premium', 'Very Good'] color 7 ['D', 'E', 'F', 'G', 'H', 'I', 'J'] clarity 8 ['I1', 'IF', 'SI1', 'SI2', 'VS1', 'VS2', 'VVS1', 'VVS2']
Ese es el orden alfabético, y no tiene nada que ver con la calidad. En color, D es el mejor y J el peor. Si dejamos que la máquina ordene, el orden sale justo al revés.
OrdinalEncoder con el orden declaradofrom sklearn.preprocessing import OrdinalEncoder
cut = ["Fair", "Good", "Very Good", "Premium", "Ideal"]
color = ["J", "I", "H", "G", "F", "E", "D"]
clarity = ["I1", "SI2", "SI1", "VS2", "VS1", "VVS2", "VVS1", "IF"]
oe = OrdinalEncoder(categories=[cut, color, clarity])
oe.fit_transform(dia[["cut", "color", "clarity"]])array([[4., 5., 1.],
[3., 5., 2.],
[1., 5., 4.],
...,
[2., 6., 2.],
[3., 2., 1.],
[4., 6., 1.]], shape=(53940, 3))
De peor a mejor. Ese orden lo pone quien sabe de diamantes, no el código.
one-hot de cut+color+clarity : 20 columnas ordinal de cut+color+clarity : 3 columnas
Ordinal gana cuando El orden es real y el efecto es más o menos monótono. Ahorra 17 columnas.
One-hot gana cuando No hay orden, o el efecto no es monótono: que la categoría del medio sea la más cara, por ejemplo.
En la duda, y con datos suficientes, one-hot es la apuesta segura: no impone ninguna estructura que no esté en los datos.
Ya podemos meter las 7 columnas de texto. One-hot a todas, sin más.
from sklearn.compose import ColumnTransformer
def rama_categorica(min_frec):
return Pipeline([("imp", SimpleImputer(strategy="constant",
fill_value="Desconocido")),
("oh", OneHotEncoder(handle_unknown="ignore",
drop="first",
min_frequency=min_frec))])
pre_todas = ColumnTransformer([
("num", Pipeline([("imp", SimpleImputer(strategy="median"))]), num_cols),
("cat", rama_categorica(1), cat_cols),
])
print("columnas tras codificar:", pre_todas.fit(X).transform(X).shape[1])
chequeo("5 · + one-hot de todas",
lambda m: Pipeline([("pre", pre_todas), ("reg", m)]), X, y)columnas tras codificar: 62 (para 93 coches)
capítulo MSE lineal R2 lineal MSE red R2 red
4 · SimpleImputer(mediana) 37.39 0.440 71652.37 -1132.739
5 · + one-hot de todas 91.10 -0.242 18748.45 -389.281
De 0,440 a −0,242: añadir información hizo el modelo peor que predecir siempre la media. Y no es ruido, es aritmética: 62 columnas para 93 coches.
El culpable tiene nombre Manufacturer, con sus 31 niveles, aporta 30 de esas 62 columnas.
La red “mejora” De −1132 a −389. Sigue siendo un disparate; que baje no significa que sirva.
La codificación no estaba mal. Lo que falta es controlar la cardinalidad, que es el capítulo siguiente.
groupby no es solo para informes
groupby para entender antes de transformar(cars.groupby("Type")
.agg(n=("Price", "size"),
precio_medio=("Price", "mean"),
cv_medio=("MPG.city", "mean"),
hp_medio=("Horsepower", "mean"))
.round(1).sort_values("precio_medio", ascending=False))n precio_medio cv_medio hp_medio Type Midsize 22 27.2 19.5 173.1 Large 11 24.3 18.4 179.5 Sporty 14 19.4 21.8 160.1 Van 9 19.1 17.0 149.4 Compact 16 18.2 22.7 131.0 Small 21 10.2 29.9 91.0
Type separa muy bien el precio: de 10,2 a 27,2. Es una variable que vale la pena conservar.
cars["gama"] = pd.cut(cars["Price"],
bins=[0, 12, 20, 100],
labels=["económico", "medio", "alto"])
cars["gama"].value_counts().sort_index()gama económico 22 medio 40 alto 31
pd.cut Cortes que ustedes eligen. Úsenlo cuando los umbrales significan algo.
pd.qcut Cortes por cuantiles: grupos del mismo tamaño.
KBinsDiscretizer La versión de sklearn, encajable en un Pipeline.
Discretizar tira información: dos coches de 11.900 y 12.100 acaban en gamas distintas, y uno de 12.100 y otro de 19.900 acaban en la misma.
Se justifica cuando la relación no es lineal y el modelo sí lo es, cuando los cortes tienen sentido de negocio, o cuando hay que comunicar el resultado a alguien que no va a leer un coeficiente.
No se justifica “para simplificar”: los modelos no necesitan que les simplifiquen las variables continuas.
Una categoría necesita 15 coches para tener columna propia. Las demás se juntan en un grupo.
pre_agrupadas = ColumnTransformer([
("num", Pipeline([("imp", SimpleImputer(strategy="median"))]), num_cols),
("cat", rama_categorica(15), cat_cols), # <- el cambio
])
print("columnas:", pre_todas.fit(X).transform(X).shape[1], "->",
pre_agrupadas.fit(X).transform(X).shape[1])
chequeo("6 · + agrupar categorías raras",
lambda m: Pipeline([("pre", pre_agrupadas), ("reg", m)]), X, y)columnas: 62 -> 26
capítulo MSE lineal R2 lineal MSE red R2 red
4 · SimpleImputer(mediana) 37.39 0.440 71652.37 -1132.739
5 · + one-hot de todas 91.10 -0.242 18748.45 -389.281
6 · + agrupar categorías raras 33.30 0.455 26326.33 -377.586
El \(R^2\) pasa de −0,242 a 0,455: no solo deshacemos el destrozo del capítulo anterior, sino que superamos el 0,440 que teníamos sin categóricas. Ahora sí aportan.
La lección no es “one-hot es peligroso”. Es que una técnica correcta, aplicada sin mirar lo que produce, hace daño. Bastó contar cuántos coches había por marca.
Seis capítulos y la red sigue en −377.
Poner todas las variables en la misma unidad
Horsepower MPG.city Weight mean 141.20 22.24 3093.11 std 48.03 5.70 592.10 min 55.00 15.00 1695.00 max 300.00 46.00 4105.00
Cualquier algoritmo basado en distancias (KNN, k-means, SVM) o en descenso de gradiente verá que Weight domina, simplemente porque sus números son más grandes. No porque importe más.
from sklearn.neighbors import KNeighborsRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import mean_absolute_error
sin = Pipeline([("imp", SimpleImputer()),
("knn", KNeighborsRegressor(5))]).fit(X_train[num_cols], y_train)
con = Pipeline([("imp", SimpleImputer()),
("esc", StandardScaler()),
("knn", KNeighborsRegressor(5))]).fit(X_train[num_cols], y_train)
for nombre, m in [("sin escalar", sin), ("escalado ", con)]:
p = m.predict(X_test[num_cols])
print(f"KNN {nombre} : MAE {mean_absolute_error(y_test, p):.2f}")KNN sin escalar : MAE 4.39 KNN escalado : MAE 3.29
Un 10 % mejor sin tocar el modelo. Con variables de escalas más dispares la diferencia es mucho mayor.
StandardScalerResta la media y divide por la desviación: media 0, desviación 1.
\[z = \frac{x - \mu}{\sigma}\]
cols = ["Horsepower", "MPG.city", "Weight"]
std = StandardScaler().fit(X_train[cols])
pd.DataFrame(std.transform(X_train[cols]), columns=cols) \
.describe().loc[["mean", "std", "min", "max"]].round(2)Horsepower MPG.city Weight mean -0.00 0.00 0.00 std 1.01 1.01 1.01 min -1.81 -1.28 -2.38 max 3.33 4.20 1.72
No acota el rango: los atípicos siguen lejos (ese 4,20 es un coche de 46 mpg).
MinMaxScalerLleva el mínimo a 0 y el máximo a 1.
\[x' = \frac{x - x_{\min}}{x_{\max} - x_{\min}}\]
from sklearn.preprocessing import MinMaxScaler
mm = MinMaxScaler().fit(X_train[cols])
pd.DataFrame(mm.transform(X_train[cols]), columns=cols) \
.describe().loc[["mean", "std", "min", "max"]].round(2)Horsepower MPG.city Weight mean 0.35 0.23 0.58 std 0.20 0.18 0.25 min 0.00 0.00 0.00 max 1.00 1.00 1.00
Rango garantizado, pero un solo atípico comprime a todos los demás: miren la media de MPG.city, aplastada en 0,23.
RobustScalerUsa la mediana y el rango intercuartílico en vez de media y desviación.
\[x' = \frac{x - \text{mediana}}{Q_3 - Q_1}\]
Horsepower MPG.city Weight mean 0.02 0.25 0.03 std 0.75 1.14 0.68 min -1.33 -1.20 -1.57 max 2.50 5.00 1.19
El centro queda en 0 y el grueso de los datos entre −1 y 1, sin que los atípicos muevan la referencia. Es la opción por defecto cuando hay valores extremos que no queremos eliminar.
StandardScaler El defecto razonable. Regresión lineal, regularizadas, SVM, redes, PCA.
MinMaxScaler Cuando hace falta un rango acotado y no hay atípicos. Imágenes, redes con sigmoide.
RobustScaler Cuando hay atípicos de verdad y no se pueden quitar.
Ninguno Árboles, bosques aleatorios, gradient boosting. Les da exactamente igual.
print("media aprendida en train :", std.mean_.round(2))
print("desviación aprendida :", std.scale_.round(2))media aprendida en train : [ 141.2 22.24 3093.11] desviación aprendida : [ 47.7 5.66 588.08]
Esos seis números son el modelo de preprocesamiento. Hay que guardarlos y llevárselos a producción junto con el modelo. Si se pierden, las predicciones futuras no significan nada.
Horsepower MPG.city Weight min 0.08 0.00 0.15 max 1.00 0.55 0.93
El máximo de MPG.city en test es 0,55 porque el coche más eficiente estaba en entrenamiento. Si un dato de test superara el máximo de train, saldría mayor que 1. No es un error: es la prueba de que no hubo fuga.
Una línea más en la rama numérica: StandardScaler().
pre_escalado = ColumnTransformer([
("num", Pipeline([("imp", SimpleImputer(strategy="median")),
("esc", StandardScaler())]), num_cols),
("cat", rama_categorica(15), cat_cols),
])
chequeo("7 · + escalado",
lambda m: Pipeline([("pre", pre_escalado), ("reg", m)]), X, y)capítulo MSE lineal R2 lineal MSE red R2 red
5 · + one-hot de todas 91.10 -0.242 18748.45 -389.281
6 · + agrupar categorías raras 33.30 0.455 26326.33 -377.586
7 · + StandardScaler 33.30 0.455 35.23 0.449
La red pasa de \(R^2 = -377{,}6\) a \(R^2 = +0{,}449\). El MSE cae de 26.326 a 35: es setecientas veces menos error. La regresión lineal, en la misma línea, no se mueve ni una milésima.
Por qué a la lineal le da igual Resuelve un sistema de ecuaciones. Si una columna vale mil veces más, su coeficiente vale mil veces menos y el resultado es idéntico.
Por qué a la red le cambia todo Entrena a base de pasos pequeños. Con las columnas en escalas incomparables, la dirección de mejora es casi imposible de encontrar. Vamos a verlo por dentro.
No existe “el preprocesamiento correcto”. Existe el correcto para el modelo que van a usar.
from sklearn.model_selection import KFold
Xn = X[num_cols].dropna(); yn = y.loc[Xn.index]
tr, te = next(iter(KFold(5).split(Xn)))
for etiqueta, pasos in [("SIN escalar", []),
("CON escalar", [("esc", StandardScaler())])]:
m = Pipeline(pasos + [("red", MLPRegressor(hidden_layer_sizes=(32,),
max_iter=2000,
random_state=7))])
m.fit(Xn.iloc[tr], yn.iloc[tr])
r, p = m.named_steps["red"], m.predict(Xn.iloc[te])
print(f"{etiqueta}: {r.n_iter_} iteraciones, pérdida {r.loss_:.3g}, "
f"peso mayor {np.abs(r.coefs_[0]).max():.3f}, "
f"predice de {p.min():.0f} a {p.max():.0f}")SIN escalar CON escalar iteraciones usadas 40 de 2000 2000 de 2000 pérdida final 3.27e+04 2.99 peso mayor 0.375 1.978 predicciones -478 a 212 11 a 41 precios reales 11 a 40 11 a 40
Lo primero que sorprende: los pesos no se disparan. El mayor vale 0,375, más pequeño que el 1,978 de la versión escalada. La red no revienta.
Lo que pasa es que se para sola en la iteración 40 de 2000. La pérdida cae rápido al principio —de 690.000 a 33.000— y ahí se estanca. MLPRegressor deja de entrenar cuando la pérdida no mejora más de tol=1e-4 durante 10 rondas seguidas, y eso ocurre enseguida.
Se detiene creyendo que ha terminado, con una pérdida de 33.000 y prediciendo precios negativos: de −478 a 212 mil dólares para coches que cuestan entre 11 y 40.
Y la escalada, en cambio, agota las 2000 iteraciones: todavía estaba mejorando cuando se le acabó el permiso.
\[R^2 = 1 - \frac{SS_{res}}{SS_{tot}}\]
SS_res (error del modelo) 2.102.083 SS_tot (varianza del precio) 1.343 R2 = 1 - 2.102.083 / 1.343 = -1564
El denominador es pequeño: los precios se mueven en un rango estrecho, así que hay poca varianza que explicar. El numerador es enorme porque la red falla por cientos de miles. Un cociente de mil y pico da un \(R^2\) de mil y pico en negativo.
Un \(R^2\) muy negativo no significa “mil veces peor que la media” en ningún sentido intuitivo. Significa que el error es de otro orden de magnitud que la variación que se pretendía explicar.
print(f"asimetría de price : {dia['price'].skew():.3f}")
print(f"asimetría de log(price) : {np.log1p(dia['price']).skew():.3f}")
print(dia["price"].describe().round(1))asimetría de price : 1.618 asimetría de log(price) : 0.116 count 53940.0 mean 3932.8 std 3989.4 min 326.0 25% 950.0 50% 2401.0 75% 5324.2 max 18823.0 Name: price, dtype: float64
Media 3.933 y mediana 2.401: la media va muy por encima, señal clara de cola a la derecha. El logaritmo la deja casi simétrica.
La regresión lineal minimiza el error al cuadrado. Con una cola larga, los pocos diamantes carísimos aportan errores enormes al elevarlos al cuadrado, y el ajuste entero se va detrás de ellos para complacerlos.
Al trabajar en escala logarítmica, un error del 10 % pesa lo mismo en un diamante de 500 dólares que en uno de 15.000. Que suele ser lo que queremos.
np.log1p Para variables positivas con cola derecha: precios, ingresos, poblaciones. El 1p es para que aguante los ceros.
PowerTransformer Yeo-Johnson busca el exponente que más acerca a la normal. Admite negativos.
QuantileTransformer Fuerza la distribución que se le pida. Muy agresivo; puede destruir relaciones reales entre variables.
Si transforman la variable objetivo, las predicciones salen en escala logarítmica: hay que deshacer con np.expm1 antes de interpretar el error.
from sklearn.compose import TransformedTargetRegressor
chequeo("8 · + log del objetivo",
lambda m: TransformedTargetRegressor(
regressor=Pipeline([("pre", pre_escalado), ("reg", m)]),
func=np.log1p, inverse_func=np.expm1),
X, y)capítulo MSE lineal R2 lineal MSE red R2 red
7 · + StandardScaler 33.30 0.455 35.23 0.449
8 · + log del objetivo 29.59 0.581 334.16 -3.704
La lineal firma su mejor resultado de la sesión —\(R^2 = 0{,}581\)— y la red se desploma de 0,449 a −3,7.
El logaritmo ayuda a la regresión lineal porque la relación entre las características y el precio se vuelve más lineal en esa escala. A la red no le hacía falta: ya podía curvarse sola, y lo único que le dimos fue un objetivo más difícil de deshacer.
En el capítulo 7 el escalado salvó a la red y no le hizo nada a la lineal. Aquí pasa exactamente lo contrario. Cada paso hay que probarlo con el modelo que se va a usar, y quedarse con el que mejore.
Ocho capítulos, dos modelos, una tabla
capítulo MSE lineal R2 lineal MSE red R2 red 1a · Sin tocar nada error error error error 1b · Solo las numéricas error error error error 2 · dropna sobre las numéricas 46.77 0.295 68165.39 -1015.810 3 · Partir bien (fuga o no) 37.39 0.440 71652.37 -1132.739 4 · + imputación con la mediana 37.39 0.440 71652.37 -1132.739 5 · + one-hot de todas 91.10 -0.242 18748.45 -389.281 6 · + agrupar categorías raras 33.30 0.455 26326.33 -377.586 7 · + escalado 33.30 0.455 35.23 0.449 8 · + log del objetivo 29.59 0.581 334.16 -3.704
Mismos dos modelos las ocho veces. Todo lo que cambia sale del preprocesamiento.
La regresión lineal Arranca en cuanto hay números y va mejorando poco a poco: 0,295 → 0,440 → 0,455 → 0,581. Solo tropieza una vez, con las 62 columnas del capítulo 5.
La red neuronal Inservible durante seis capítulos — R² entre −377 y −1132 — y de golpe útil al escalar. Después el logaritmo vuelve a hundirla.
Si alguien mira solo la columna de la lineal, concluye que el escalado no sirve para nada. Si mira solo la de la red, concluye que sin escalar no se puede hacer nada. Las dos lecturas son falsas.
Cada paso resuelve un problema distinto Imputar recupera filas. Codificar añade información. Agrupar controla las columnas. Escalar habilita modelos. Transformar linealiza.
Un paso correcto puede empeorar El capítulo 5 hizo el \(R^2\) negativo. La técnica estaba bien; faltaba mirar el resultado.
Y otro puede no cambiar nada El capítulo 3 dio cifras idénticas con fuga y sin ella. Se sigue la regla igual.
El mejor paso depende del modelo El escalado salvó a la red y no tocó a la lineal. El logaritmo, al revés.
Sin medir, nada de esto se ve Ocho chequeos son ocho oportunidades de descubrir que el último cambio fue en la dirección equivocada.
Un estudiante que pruebe la red en el capítulo 6 y vea un \(R^2\) de −377 concluirá que “las redes no sirven para datos tabulares” o que “faltan datos”. Las dos conclusiones son falsas, y las dos son caras.
Lo que hacía falta era una línea: StandardScaler().
Por eso el orden es preparar los datos, medir, y solo entonces cambiar de modelo. Al revés, uno pasa semanas buscando arquitecturas para arreglar un problema de preprocesamiento.
Ninguno de los dos modelos está afinado. La red lleva 32 neuronas y 2000 iteraciones porque hacía falta fijar algo, no porque sea la mejor opción; la lineal no tiene nada que afinar.
Con los datos ya preparados, un \(R^2\) de 0,581 es el punto de partida, no el final. Elegir y ajustar el modelo es lo que viene después — y ahora se puede hacer sobre una base limpia.
Vamos a asomarnos un momento a eso.
Con los datos ya preparados, el resto del curso es elegir modelo y afinarlo. Un adelanto, sobre estos mismos 93 coches:
modelo MSE R2
LinearRegression + preprocesamiento (hoy) 33.30 0.455
Ridge, con alpha buscado 35.19 0.567
Ridge + log del objetivo 31.97 0.609
RandomForest afinado 35.69 0.525
HistGradientBoosting afinado 31.68 0.640
De 0,455 a 0,640 sin tocar ni una línea del preprocesamiento. Eso es lo que aporta la parte del curso que viene después de hoy.
from sklearn.model_selection import GridSearchCV
from sklearn.linear_model import Ridge
busqueda = GridSearchCV(
Pipeline([("pre", pre_escalado), ("reg", Ridge())]),
{"reg__alpha": [0.1, 1, 10, 50, 100]},
cv=5, scoring="r2")
# la búsqueda va DENTRO de la validación cruzada, no fuera
r = cross_validate(busqueda, X, y, cv=5, scoring=["r2"])
print(round(r["test_r2"].mean(), 3))Si se busca el mejor alpha con todos los datos y luego se mide con esos mismos datos, el resultado sale inflado: habríamos elegido el parámetro que mejor le venía al conjunto de prueba. Es fuga otra vez, ahora en los hiperparámetros.
Por eso se anida: en cada pliegue se busca con lo de dentro y se mide con lo de fuera. Se llama validación cruzada anidada.
El mejor de la tabla, HistGradientBoosting, maneja los NaN y las categorías de forma nativa. No necesita ni imputar ni codificar.
¿Sobraba entonces la sesión de hoy? Miren qué pasa si a ese mismo modelo le damos el dataset tal y como viene en el CSV.
HistGradientBoosting, todas las columnas del CSV R2 0.747
HistGradientBoosting, sin Min.Price ni Max.Price R2 0.599
Un \(R^2\) de 0,747: el mejor número de toda la sesión. Y completamente inútil.
correlación con Price Min.Price 0.971 Max.Price 0.982 Price - (Min.Price + Max.Price) / 2 -> error medio de 0.017
Price es el promedio de Min.Price y Max.Price. El modelo no está prediciendo el precio: lo está leyendo de otras dos columnas que, en la vida real, no existirían hasta después de conocerlo.
Ningún modelo, por potente que sea, va a avisar de esto. Quitar esas dos columnas fue una decisión nuestra, en la primera línea del primer chequeo, y es la más importante de toda la sesión.
Lo que el modelo puede hacer por ustedes Imputar, codificar, escalar. Hay modelos modernos que se lo hacen solos, y cada año hacen más.
Lo que no va a hacer nunca Darse cuenta de que una columna es la respuesta disfrazada. Saber que aquel NaN significaba “sin airbag”. Decidir que 31 marcas en 93 coches son demasiadas.
Lo primero se automatiza. Lo segundo es criterio, y es lo que van a estar construyendo durante todo el curso.
shape, head, info, describe.fit solo en entrenamiento.Pipeline.fit sobre todo el dataset El más común y el más silencioso: no da error, solo resultados demasiado buenos que luego no se reproducen.
Label encoding en nominales Le inventa un orden y unas distancias a lo que no las tiene.
One-hot sin mirar la cardinalidad Una columna de identificadores puede generar miles de columnas y hundir el modelo, como acabamos de ver.
Repitan el recorrido completo sobre Ames Housing: 1.460 casas, 80 columnas, vacíos por todas partes y categóricas ordinales de verdad.
from sklearn.datasets import fetch_openml
ames = fetch_openml(name="house_prices", as_frame=True).frameTres preguntas para guiarse: ¿qué columnas tienen tanto vacío que no vale la pena imputarlas? ¿Cuáles de las categóricas son ordinales de verdad? ¿Cuánto mejora el resultado al agrupar las categorías raras?
El cuaderno de la sesión está en la página del curso
Andrés F. PuertaPreprocesamiento de datosSesión 2